forked from recloudstream/cloudstream
android tv recc not working :(
This commit is contained in:
parent
c5d53d7621
commit
1bbbbce326
7 changed files with 197 additions and 34 deletions
|
@ -88,6 +88,7 @@ repositories {
|
|||
}
|
||||
|
||||
dependencies {
|
||||
implementation 'com.google.android.mediahome:video:1.0.0'
|
||||
testImplementation 'org.json:json:20180813'
|
||||
|
||||
implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
|
||||
|
@ -95,10 +96,10 @@ dependencies {
|
|||
implementation 'androidx.appcompat:appcompat:1.4.1'
|
||||
implementation 'com.google.android.material:material:1.5.0'
|
||||
implementation 'androidx.constraintlayout:constraintlayout:2.1.3'
|
||||
implementation 'androidx.navigation:navigation-fragment-ktx:2.5.0-alpha01'
|
||||
implementation 'androidx.navigation:navigation-ui-ktx:2.5.0-alpha01'
|
||||
implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.4.0'
|
||||
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.4.0'
|
||||
implementation 'androidx.navigation:navigation-fragment-ktx:2.5.0-alpha02'
|
||||
implementation 'androidx.navigation:navigation-ui-ktx:2.5.0-alpha02'
|
||||
implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.4.1'
|
||||
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.4.1'
|
||||
testImplementation 'junit:junit:4.13.2'
|
||||
androidTestImplementation 'androidx.test.ext:junit:1.1.3'
|
||||
androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0'
|
||||
|
@ -167,4 +168,6 @@ dependencies {
|
|||
|
||||
// for shimmer when loading
|
||||
implementation 'com.facebook.shimmer:shimmer:0.5.0'
|
||||
|
||||
implementation "androidx.tvprovider:tvprovider:1.0.0"
|
||||
}
|
|
@ -9,6 +9,7 @@
|
|||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
|
||||
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS"/>
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES"/>
|
||||
<uses-permission android:name="com.android.providers.tv.permission.WRITE_EPG_DATA" />
|
||||
|
||||
<uses-feature android:name="android.hardware.touchscreen"
|
||||
android:required="false"/>
|
||||
|
@ -137,6 +138,7 @@
|
|||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.provider"
|
||||
android:exported="false"
|
||||
android:enabled="true"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
|
|
|
@ -3,14 +3,16 @@ package com.lagradost.cloudstream3.ui.home
|
|||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.recyclerview.widget.DiffUtil
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.lagradost.cloudstream3.R
|
||||
import com.lagradost.cloudstream3.SearchResponse
|
||||
import com.lagradost.cloudstream3.ui.search.SearchClickCallback
|
||||
import com.lagradost.cloudstream3.ui.search.SearchResponseDiffCallback
|
||||
import com.lagradost.cloudstream3.ui.search.SearchResultBuilder
|
||||
|
||||
class HomeChildItemAdapter(
|
||||
var cardList: List<SearchResponse>,
|
||||
val cardList: MutableList<SearchResponse>,
|
||||
val layout: Int = R.layout.home_result_grid,
|
||||
private val nextFocusUp: Int? = null,
|
||||
private val nextFocusDown: Int? = null,
|
||||
|
@ -44,6 +46,17 @@ class HomeChildItemAdapter(
|
|||
return (cardList[position].id ?: position).toLong()
|
||||
}
|
||||
|
||||
fun updateList(newList: List<SearchResponse>) {
|
||||
val diffResult = DiffUtil.calculateDiff(
|
||||
SearchResponseDiffCallback(this.cardList, newList)
|
||||
)
|
||||
|
||||
cardList.clear()
|
||||
cardList.addAll(newList)
|
||||
|
||||
diffResult.dispatchUpdatesTo(this)
|
||||
}
|
||||
|
||||
class CardViewHolder
|
||||
constructor(
|
||||
itemView: View, private val clickCallback: (SearchClickCallback) -> Unit, private val itemCount: Int,
|
||||
|
|
|
@ -124,7 +124,7 @@ class HomeFragment : Fragment() {
|
|||
|
||||
val spanListener = { span: Int ->
|
||||
recycle.spanCount = span
|
||||
(recycle.adapter as SearchAdapter).notifyDataSetChanged()
|
||||
//(recycle.adapter as SearchAdapter).notifyDataSetChanged()
|
||||
}
|
||||
|
||||
configEvent += spanListener
|
||||
|
@ -133,7 +133,7 @@ class HomeFragment : Fragment() {
|
|||
configEvent -= spanListener
|
||||
}
|
||||
|
||||
(recycle.adapter as SearchAdapter).notifyDataSetChanged()
|
||||
//(recycle.adapter as SearchAdapter).notifyDataSetChanged()
|
||||
|
||||
bottomSheetDialogBuilder.show()
|
||||
}
|
||||
|
@ -399,7 +399,7 @@ class HomeFragment : Fragment() {
|
|||
val randomSize = items.size
|
||||
home_main_poster_recyclerview?.adapter =
|
||||
HomeChildItemAdapter(
|
||||
items,
|
||||
items.toMutableList(),
|
||||
R.layout.home_result_big_grid,
|
||||
nextFocusUp = home_main_poster_recyclerview.nextFocusUpId,
|
||||
nextFocusDown = home_main_poster_recyclerview.nextFocusDownId
|
||||
|
@ -438,7 +438,7 @@ class HomeFragment : Fragment() {
|
|||
val d = data.value
|
||||
|
||||
currentHomePage = d
|
||||
(home_master_recycler?.adapter as ParentItemAdapter?)?.updateList(
|
||||
(home_master_recycler?.adapter as? ParentItemAdapter?)?.updateList(
|
||||
d?.items?.mapNotNull {
|
||||
try {
|
||||
HomePageList(it.name, it.list.filterSearchResponse())
|
||||
|
@ -483,6 +483,7 @@ class HomeFragment : Fragment() {
|
|||
home_loaded?.isVisible = false
|
||||
}
|
||||
is Resource.Loading -> {
|
||||
(home_master_recycler?.adapter as? ParentItemAdapter?)?.updateList(listOf())
|
||||
home_loading_shimmer?.startShimmer()
|
||||
home_loading?.isVisible = true
|
||||
home_loading_error?.isVisible = false
|
||||
|
@ -556,13 +557,12 @@ class HomeFragment : Fragment() {
|
|||
home_bookmarked_parent_item_title?.text = getString(availableWatchStatusTypes.first.stringRes)*/
|
||||
}
|
||||
|
||||
observe(homeViewModel.bookmarks) { pair ->
|
||||
home_bookmarked_holder.isVisible = pair.first
|
||||
observe(homeViewModel.bookmarks) { (isVis, bookmarks) ->
|
||||
home_bookmarked_holder.isVisible = isVis
|
||||
|
||||
val bookmarks = pair.second
|
||||
(home_bookmarked_child_recyclerview?.adapter as HomeChildItemAdapter?)?.cardList =
|
||||
(home_bookmarked_child_recyclerview?.adapter as? HomeChildItemAdapter?)?.updateList(
|
||||
bookmarks
|
||||
home_bookmarked_child_recyclerview?.adapter?.notifyDataSetChanged()
|
||||
)
|
||||
|
||||
home_bookmarked_child_more_info?.setOnClickListener {
|
||||
activity?.loadHomepageList(
|
||||
|
@ -576,9 +576,15 @@ class HomeFragment : Fragment() {
|
|||
|
||||
observe(homeViewModel.resumeWatching) { resumeWatching ->
|
||||
home_watch_holder?.isVisible = resumeWatching.isNotEmpty()
|
||||
(home_watch_child_recyclerview?.adapter as HomeChildItemAdapter?)?.cardList =
|
||||
(home_watch_child_recyclerview?.adapter as? HomeChildItemAdapter?)?.updateList(
|
||||
resumeWatching
|
||||
home_watch_child_recyclerview?.adapter?.notifyDataSetChanged()
|
||||
)
|
||||
|
||||
//if (context?.isTvSettings() == true) {
|
||||
// if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
// context?.addProgramsToContinueWatching(resumeWatching.mapNotNull { it as? DataStoreHelper.ResumeWatchingResult })
|
||||
// }
|
||||
//}
|
||||
|
||||
home_watch_child_more_info?.setOnClickListener {
|
||||
activity?.loadHomepageList(
|
||||
|
|
|
@ -65,12 +65,12 @@ class ParentItemAdapter(
|
|||
fun bind(info: HomePageList) {
|
||||
title.text = info.name
|
||||
recyclerView.adapter = HomeChildItemAdapter(
|
||||
info.list,
|
||||
info.list.toMutableList(),
|
||||
clickCallback = clickCallback,
|
||||
nextFocusUp = recyclerView.nextFocusUpId,
|
||||
nextFocusDown = recyclerView.nextFocusDownId
|
||||
)
|
||||
(recyclerView.adapter as HomeChildItemAdapter).notifyDataSetChanged()
|
||||
//(recyclerView.adapter as HomeChildItemAdapter).notifyDataSetChanged()
|
||||
|
||||
moreInfo.setOnClickListener {
|
||||
moreInfoClickCallback.invoke(info)
|
||||
|
|
|
@ -1183,9 +1183,9 @@ class ResultFragment : Fragment(), PanelsChildGestureRegionObserver.GestureRegio
|
|||
result_episode_loading?.isVisible = false
|
||||
if (result_episodes == null || result_episodes.adapter == null) return@observe
|
||||
currentEpisodes = episodes.value
|
||||
(result_episodes?.adapter as EpisodeAdapter?)?.cardList = episodes.value
|
||||
(result_episodes?.adapter as EpisodeAdapter?)?.updateLayout()
|
||||
(result_episodes?.adapter as EpisodeAdapter?)?.notifyDataSetChanged()
|
||||
(result_episodes?.adapter as? EpisodeAdapter?)?.cardList = episodes.value
|
||||
(result_episodes?.adapter as? EpisodeAdapter?)?.updateLayout()
|
||||
(result_episodes?.adapter as? EpisodeAdapter?)?.notifyDataSetChanged()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
package com.lagradost.cloudstream3.utils
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.Activity
|
||||
import android.content.ComponentName
|
||||
import android.content.ContentValues
|
||||
|
@ -10,6 +11,7 @@ import android.database.Cursor
|
|||
import android.media.AudioAttributes
|
||||
import android.media.AudioFocusRequest
|
||||
import android.media.AudioManager
|
||||
import android.media.tv.TvContract.Channels.COLUMN_INTERNAL_PROVIDER_ID
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.NetworkCapabilities
|
||||
import android.net.Uri
|
||||
|
@ -18,20 +20,23 @@ import android.os.Environment
|
|||
import android.os.ParcelFileDescriptor
|
||||
import android.provider.MediaStore
|
||||
import android.util.Log
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.annotation.WorkerThread
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.tvprovider.media.tv.PreviewChannelHelper
|
||||
import androidx.tvprovider.media.tv.TvContractCompat
|
||||
import androidx.tvprovider.media.tv.WatchNextProgram
|
||||
import androidx.tvprovider.media.tv.WatchNextProgram.fromCursor
|
||||
import com.fasterxml.jackson.module.kotlin.readValue
|
||||
import com.google.android.gms.cast.framework.CastContext
|
||||
import com.google.android.gms.cast.framework.CastState
|
||||
import com.google.android.gms.common.ConnectionResult
|
||||
import com.google.android.gms.common.GoogleApiAvailability
|
||||
import com.google.android.gms.common.wrappers.Wrappers
|
||||
import com.lagradost.cloudstream3.MainActivity
|
||||
import com.lagradost.cloudstream3.R
|
||||
import com.lagradost.cloudstream3.SearchResponse
|
||||
import com.lagradost.cloudstream3.mapper
|
||||
import com.lagradost.cloudstream3.movieproviders.MeloMovieProvider
|
||||
import com.lagradost.cloudstream3.*
|
||||
import com.lagradost.cloudstream3.mvvm.logError
|
||||
import com.lagradost.cloudstream3.ui.result.ResultFragment
|
||||
import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
|
||||
import com.lagradost.cloudstream3.utils.FillerEpisodeCheck.toClassDir
|
||||
import com.lagradost.cloudstream3.utils.JsUnpacker.Companion.load
|
||||
import com.lagradost.cloudstream3.utils.UIHelper.navigate
|
||||
|
@ -41,6 +46,136 @@ import java.net.URL
|
|||
import java.net.URLDecoder
|
||||
|
||||
object AppUtils {
|
||||
//fun Context.deleteFavorite(data: SearchResponse) {
|
||||
// if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
||||
// normalSafeApiCall {
|
||||
// val existingId =
|
||||
// getWatchNextProgramByVideoId(data.url, this).second ?: return@normalSafeApiCall
|
||||
// contentResolver.delete(
|
||||
//
|
||||
// TvContractCompat.buildWatchNextProgramUri(existingId),
|
||||
// null, null
|
||||
// )
|
||||
// }
|
||||
//}
|
||||
@SuppressLint("RestrictedApi")
|
||||
private fun buildWatchNextProgramUri(
|
||||
context: Context,
|
||||
card: DataStoreHelper.ResumeWatchingResult
|
||||
): WatchNextProgram {
|
||||
val isSeries = !card.type.isMovieType()
|
||||
val title = if (isSeries) {
|
||||
context.getNameFull(card.name, card.episode, card.season)
|
||||
} else {
|
||||
card.name
|
||||
}
|
||||
|
||||
val builder = WatchNextProgram.Builder()
|
||||
.setEpisodeTitle(title)
|
||||
.setType(
|
||||
if (isSeries) {
|
||||
TvContractCompat.WatchNextPrograms.TYPE_TV_EPISODE
|
||||
} else TvContractCompat.WatchNextPrograms.TYPE_MOVIE
|
||||
)
|
||||
.setWatchNextType(TvContractCompat.WatchNextPrograms.WATCH_NEXT_TYPE_CONTINUE)
|
||||
.setTitle(title)
|
||||
.setPosterArtUri(Uri.parse(card.posterUrl))
|
||||
.setIntentUri(Uri.parse(card.url)) //TODO FIX intent
|
||||
.setInternalProviderId(card.url)
|
||||
//.setLastEngagementTimeUtcMillis(System.currentTimeMillis())
|
||||
|
||||
card.watchPos?.let {
|
||||
builder.setDurationMillis(it.duration.toInt())
|
||||
builder.setLastPlaybackPositionMillis(it.position.toInt())
|
||||
}
|
||||
// .setLastEngagementTimeUtcMillis() //TODO
|
||||
|
||||
if (isSeries)
|
||||
card.episode?.let {
|
||||
builder.setEpisodeNumber(it)
|
||||
}
|
||||
|
||||
return builder.build()
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the Watch Next program for given id.
|
||||
* Returns the first instance available.
|
||||
*/
|
||||
@SuppressLint("RestrictedApi")
|
||||
// Suppress RestrictedApi due to https://issuetracker.google.com/138150076
|
||||
fun findFirstWatchNextProgram(context: Context, predicate: (Cursor) -> Boolean):
|
||||
Pair<WatchNextProgram?, Long?> {
|
||||
val COLUMN_WATCH_NEXT_ID_INDEX = 0
|
||||
// val COLUMN_WATCH_NEXT_INTERNAL_PROVIDER_ID_INDEX = 1
|
||||
// val COLUMN_WATCH_NEXT_COLUMN_BROWSABLE_INDEX = 2
|
||||
|
||||
val cursor = context.contentResolver.query(
|
||||
TvContractCompat.WatchNextPrograms.CONTENT_URI,
|
||||
WatchNextProgram.PROJECTION,
|
||||
/* selection = */ null,
|
||||
/* selectionArgs = */ null,
|
||||
/* sortOrder= */ null
|
||||
)
|
||||
cursor?.use {
|
||||
if (it.moveToFirst()) {
|
||||
do {
|
||||
if (predicate(cursor)) {
|
||||
return fromCursor(cursor) to cursor.getLong(COLUMN_WATCH_NEXT_ID_INDEX)
|
||||
}
|
||||
} while (it.moveToNext())
|
||||
}
|
||||
}
|
||||
return null to null
|
||||
}
|
||||
|
||||
/**
|
||||
* Query the Watch Next list and find the program with given videoId.
|
||||
* Return null if not found.
|
||||
*/
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.O)
|
||||
@SuppressLint("Range")
|
||||
@Synchronized
|
||||
private fun getWatchNextProgramByVideoId(
|
||||
id: String,
|
||||
context: Context
|
||||
): Pair<WatchNextProgram?, Long?> {
|
||||
return findFirstWatchNextProgram(context) { cursor ->
|
||||
(cursor.getString(cursor.getColumnIndex(COLUMN_INTERNAL_PROVIDER_ID)) == id)
|
||||
}
|
||||
}
|
||||
|
||||
// https://github.com/googlearchive/leanback-homescreen-channels/blob/master/app/src/main/java/com/google/android/tvhomescreenchannels/SampleTvProvider.java
|
||||
@SuppressLint("RestrictedApi")
|
||||
@WorkerThread
|
||||
fun Context.addProgramsToContinueWatching(data: List<DataStoreHelper.ResumeWatchingResult>) {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
||||
|
||||
ioSafe {
|
||||
data.forEach { episodeInfo ->
|
||||
try {
|
||||
val (program, id) = getWatchNextProgramByVideoId(episodeInfo.url, this)
|
||||
val nextProgram = buildWatchNextProgramUri(this, episodeInfo)
|
||||
|
||||
// If the program is already in the Watch Next row, update it
|
||||
if (program != null && id != null) {
|
||||
PreviewChannelHelper(this).updateWatchNextProgram(
|
||||
nextProgram,
|
||||
id,
|
||||
)
|
||||
} else {
|
||||
PreviewChannelHelper(this)
|
||||
.publishWatchNextProgram(nextProgram)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logError(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("Range")
|
||||
fun getVideoContentUri(context: Context, videoFilePath: String): Uri? {
|
||||
val cursor = context.contentResolver.query(
|
||||
MediaStore.Video.Media.EXTERNAL_CONTENT_URI, arrayOf(MediaStore.Video.Media._ID),
|
||||
|
@ -94,14 +229,14 @@ object AppUtils {
|
|||
return mapper.writeValueAsString(this)
|
||||
}
|
||||
|
||||
inline fun <reified T> parseJson(value : String): T {
|
||||
inline fun <reified T> parseJson(value: String): T {
|
||||
return mapper.readValue(value)
|
||||
}
|
||||
|
||||
inline fun <reified T> tryParseJson(value : String): T? {
|
||||
inline fun <reified T> tryParseJson(value: String): T? {
|
||||
return try {
|
||||
parseJson(value)
|
||||
} catch (_ : Exception) {
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
@ -168,7 +303,7 @@ object AppUtils {
|
|||
startAction: Int = 0,
|
||||
startValue: Int = 0
|
||||
) {
|
||||
(this as AppCompatActivity?)?.loadResult(card.url, card.apiName, startAction, startValue)
|
||||
(this as? AppCompatActivity?)?.loadResult(card.url, card.apiName, startAction, startValue)
|
||||
}
|
||||
|
||||
fun Activity.requestLocalAudioFocus(focusRequest: AudioFocusRequest?) {
|
||||
|
@ -230,6 +365,7 @@ object AppUtils {
|
|||
}
|
||||
|
||||
// Copied from https://github.com/videolan/vlc-android/blob/master/application/vlc-android/src/org/videolan/vlc/util/FileUtils.kt
|
||||
@SuppressLint("Range")
|
||||
fun Context.getUri(data: Uri?): Uri? {
|
||||
var uri = data
|
||||
val ctx = this
|
||||
|
@ -245,11 +381,13 @@ object AppUtils {
|
|||
arrayOf(MediaStore.MediaColumns.DISPLAY_NAME), null, null, null
|
||||
)
|
||||
if (cursor != null && cursor.moveToFirst()) {
|
||||
val filename = cursor.getString(cursor.getColumnIndex(MediaStore.MediaColumns.DISPLAY_NAME))
|
||||
.replace("/", "")
|
||||
val filename =
|
||||
cursor.getString(cursor.getColumnIndex(MediaStore.MediaColumns.DISPLAY_NAME))
|
||||
.replace("/", "")
|
||||
inputStream = ctx.contentResolver.openInputStream(data)
|
||||
if (inputStream == null) return data
|
||||
os = FileOutputStream(Environment.getExternalStorageDirectory().path + "/Download/" + filename)
|
||||
os =
|
||||
FileOutputStream(Environment.getExternalStorageDirectory().path + "/Download/" + filename)
|
||||
val buffer = ByteArray(1024)
|
||||
var bytesRead = inputStream.read(buffer)
|
||||
while (bytesRead >= 0) {
|
||||
|
@ -272,7 +410,8 @@ object AppUtils {
|
|||
arrayOf(MediaStore.Video.Media.DATA), null, null, null
|
||||
)?.use {
|
||||
val columnIndex = it.getColumnIndexOrThrow(MediaStore.Video.Media.DATA)
|
||||
if (it.moveToFirst()) Uri.fromFile(File(it.getString(columnIndex))) ?: data else data
|
||||
if (it.moveToFirst()) Uri.fromFile(File(it.getString(columnIndex)))
|
||||
?: data else data
|
||||
}
|
||||
//uri = MediaUtils.getContentMediaUri(data)
|
||||
/*} else if (data.authority == ctx.getString(R.string.tv_provider_authority)) {
|
||||
|
|
Loading…
Reference in a new issue