From 1bbbbce326f7dc18001fa9f00b954aa068c40aac Mon Sep 17 00:00:00 2001 From: LagradOst <11805592+LagradOst@users.noreply.github.com> Date: Sun, 13 Feb 2022 01:53:40 +0100 Subject: [PATCH] android tv recc not working :( --- app/build.gradle | 11 +- app/src/main/AndroidManifest.xml | 2 + .../ui/home/HomeChildItemAdapter.kt | 15 +- .../cloudstream3/ui/home/HomeFragment.kt | 28 +-- .../ui/home/HomeParentItemAdapter.kt | 4 +- .../cloudstream3/ui/result/ResultFragment.kt | 6 +- .../lagradost/cloudstream3/utils/AppUtils.kt | 165 ++++++++++++++++-- 7 files changed, 197 insertions(+), 34 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index 542a11ad..96c54f37 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -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" } \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 22dab297..ac05b212 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -9,6 +9,7 @@ + @@ -137,6 +138,7 @@ android:name="androidx.core.content.FileProvider" android:authorities="${applicationId}.provider" android:exported="false" + android:enabled="true" android:grantUriPermissions="true"> , + val cardList: MutableList, 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) { + 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, diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeFragment.kt index 3d797c05..488a9015 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeFragment.kt @@ -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( diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeParentItemAdapter.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeParentItemAdapter.kt index 8425dcca..58df0b49 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeParentItemAdapter.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeParentItemAdapter.kt @@ -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) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultFragment.kt index e61d3f81..05a29440 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultFragment.kt @@ -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() } } } diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/AppUtils.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/AppUtils.kt index 0dce75c2..3b2f6592 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/AppUtils.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/AppUtils.kt @@ -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 { + 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 { + 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) { + 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 parseJson(value : String): T { + inline fun parseJson(value: String): T { return mapper.readValue(value) } - inline fun tryParseJson(value : String): T? { + inline fun 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)) {