From 9ae4965c2ab42b4987f4cffe9e23be4c88cd4bef Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Sat, 27 Dec 2025 15:40:45 -0500 Subject: [PATCH] Add resume/continue watching items to Android TV watch next row (#372) ## Details Adds resume and continue watching items for the logged in user to the Android TV OS level watch next "channel" (https://developer.android.com/training/tv/discovery/watch-next-add-programs). ## Issues Closes #206 Related to #581 --- app/build.gradle.kts | 4 + app/src/main/AndroidManifest.xml | 8 +- .../damontecres/wholphin/MainActivity.kt | 61 +++++ .../wholphin/WholphinApplication.kt | 17 +- .../wholphin/services/LatestNextUpService.kt | 190 ++++++++++++++ .../wholphin/services/NavigationManager.kt | 13 +- .../tvprovider/TvProviderSchedulerService.kt | 89 +++++++ .../services/tvprovider/TvProviderWorker.kt | 245 ++++++++++++++++++ .../ui/components/RecommendedTvShow.kt | 9 +- .../wholphin/ui/main/HomeViewModel.kt | 187 +------------ .../wholphin/ui/nav/ApplicationContent.kt | 3 +- gradle/libs.versions.toml | 8 + 12 files changed, 645 insertions(+), 189 deletions(-) create mode 100644 app/src/main/java/com/github/damontecres/wholphin/services/LatestNextUpService.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/services/tvprovider/TvProviderSchedulerService.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/services/tvprovider/TvProviderWorker.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 3080e2ec..51d2590f 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -191,6 +191,9 @@ dependencies { implementation(libs.androidx.activity.compose) implementation(libs.androidx.datastore) implementation(libs.protobuf.kotlin.lite) + implementation(libs.androidx.tvprovider) + implementation(libs.androidx.work.runtime.ktx) + implementation(libs.androidx.hilt.work) implementation(libs.androidx.media3.exoplayer) implementation(libs.androidx.media3.datasource.okhttp) @@ -227,6 +230,7 @@ dependencies { implementation(libs.androidx.palette.ktx) ksp(libs.androidx.room.compiler) ksp(libs.hilt.android.compiler) + ksp(libs.androidx.hilt.compiler) implementation(libs.timber) implementation(libs.slf4j2.timber) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 6d84299e..9edf3067 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,5 +1,6 @@ - + @@ -11,6 +12,7 @@ + + diff --git a/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt b/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt index 7b5c41f3..c21aa161 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt @@ -1,5 +1,6 @@ package com.github.damontecres.wholphin +import android.content.Intent import android.os.Bundle import androidx.activity.compose.setContent import androidx.activity.viewModels @@ -47,10 +48,13 @@ import com.github.damontecres.wholphin.services.SetupDestination import com.github.damontecres.wholphin.services.SetupNavigationManager import com.github.damontecres.wholphin.services.UpdateChecker import com.github.damontecres.wholphin.services.hilt.AuthOkHttpClient +import com.github.damontecres.wholphin.services.tvprovider.TvProviderSchedulerService import com.github.damontecres.wholphin.ui.CoilConfig import com.github.damontecres.wholphin.ui.LocalImageUrlService +import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisodeIds import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.nav.ApplicationContent +import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.setup.SwitchServerContent import com.github.damontecres.wholphin.ui.setup.SwitchUserContent import com.github.damontecres.wholphin.ui.theme.WholphinTheme @@ -60,6 +64,7 @@ import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.firstOrNull import okhttp3.OkHttpClient +import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.serializer.toUUIDOrNull import timber.log.Timber import javax.inject.Inject @@ -96,6 +101,9 @@ class MainActivity : AppCompatActivity() { @Inject lateinit var refreshRateService: RefreshRateService + @Inject + lateinit var tvProviderSchedulerService: TvProviderSchedulerService + private var signInAuto = true @OptIn(ExperimentalTvMaterial3Api::class) @@ -115,6 +123,7 @@ class MainActivity : AppCompatActivity() { } } viewModel.appStart() + val requestedDestination = this.intent?.let(::extractDestination) setContent { val appPreferences by userPreferencesDataStore.data.collectAsState(null) appPreferences?.let { appPreferences -> @@ -225,6 +234,9 @@ class MainActivity : AppCompatActivity() { ApplicationContent( user = current.user, server = current.server, + startDestination = + requestedDestination + ?: Destination.Home(), navigationManager = navigationManager, preferences = preferences, modifier = Modifier.fillMaxSize(), @@ -278,6 +290,7 @@ class MainActivity : AppCompatActivity() { override fun onStop() { super.onStop() Timber.d("onStop") + tvProviderSchedulerService.launchOneTimeRefresh() } override fun onPause() { @@ -304,6 +317,54 @@ class MainActivity : AppCompatActivity() { super.onDestroy() Timber.d("onDestroy") } + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + Timber.v("onNewIntent") + extractDestination(intent)?.let { + navigationManager.replace(it) + } + } + + private fun extractDestination(intent: Intent): Destination? = + intent.let { + val itemId = + it.getStringExtra(INTENT_ITEM_ID)?.toUUIDOrNull() + val type = + it.getStringExtra(INTENT_ITEM_TYPE)?.let(BaseItemKind::fromNameOrNull) + if (itemId != null && type != null) { + val seriesId = it.getStringExtra(INTENT_SERIES_ID)?.toUUIDOrNull() + val seasonId = it.getStringExtra(INTENT_SEASON_ID)?.toUUIDOrNull() + val episodeNumber = it.getIntExtra(INTENT_EPISODE_NUMBER, -1) + val seasonNumber = it.getIntExtra(INTENT_SEASON_NUMBER, -1) + if (seriesId != null && seasonId != null && episodeNumber >= 0 && seasonNumber >= 0) { + Destination.SeriesOverview( + itemId = seriesId, + type = BaseItemKind.SERIES, + seasonEpisode = + SeasonEpisodeIds( + seasonId = seasonId, + seasonNumber = seasonNumber, + episodeId = itemId, + episodeNumber = episodeNumber, + ), + ) + } else { + Destination.MediaItem(itemId, type) + } + } else { + null + } + } + + companion object { + const val INTENT_ITEM_ID = "itemId" + const val INTENT_ITEM_TYPE = "itemType" + const val INTENT_SERIES_ID = "seriesId" + const val INTENT_EPISODE_NUMBER = "epNum" + const val INTENT_SEASON_NUMBER = "seaNum" + const val INTENT_SEASON_ID = "seaId" + } } @HiltViewModel diff --git a/app/src/main/java/com/github/damontecres/wholphin/WholphinApplication.kt b/app/src/main/java/com/github/damontecres/wholphin/WholphinApplication.kt index 3368091a..ae4b5d28 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/WholphinApplication.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/WholphinApplication.kt @@ -7,6 +7,8 @@ import android.os.StrictMode.ThreadPolicy import android.util.Log import androidx.compose.runtime.Composer import androidx.compose.runtime.ExperimentalComposeRuntimeApi +import androidx.hilt.work.HiltWorkerFactory +import androidx.work.Configuration import dagger.hilt.android.HiltAndroidApp import org.acra.ACRA import org.acra.ReportField @@ -14,10 +16,13 @@ import org.acra.config.dialog import org.acra.data.StringFormat import org.acra.ktx.initAcra import timber.log.Timber +import javax.inject.Inject @OptIn(ExperimentalComposeRuntimeApi::class) @HiltAndroidApp -class WholphinApplication : Application() { +class WholphinApplication : + Application(), + Configuration.Provider { init { instance = this @@ -94,6 +99,16 @@ class WholphinApplication : Application() { ACRA.errorReporter.putCustomData("SDK_INT", Build.VERSION.SDK_INT.toString()) } + @Inject + lateinit var workerFactory: HiltWorkerFactory + + override val workManagerConfiguration: Configuration + get() = + Configuration + .Builder() + .setWorkerFactory(workerFactory) + .build() + companion object { lateinit var instance: WholphinApplication private set diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/LatestNextUpService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/LatestNextUpService.kt new file mode 100644 index 00000000..44ed1a58 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/services/LatestNextUpService.kt @@ -0,0 +1,190 @@ +package com.github.damontecres.wholphin.services + +import android.content.Context +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.ui.SlimItemFields +import com.github.damontecres.wholphin.ui.main.LatestData +import com.github.damontecres.wholphin.ui.main.supportedLatestCollectionTypes +import com.github.damontecres.wholphin.util.HomeRowLoadingState +import com.github.damontecres.wholphin.util.supportItemKinds +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.sync.Semaphore +import kotlinx.coroutines.sync.withPermit +import kotlinx.coroutines.withContext +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.extensions.itemsApi +import org.jellyfin.sdk.api.client.extensions.tvShowsApi +import org.jellyfin.sdk.api.client.extensions.userLibraryApi +import org.jellyfin.sdk.api.client.extensions.userViewsApi +import org.jellyfin.sdk.model.api.BaseItemKind +import org.jellyfin.sdk.model.api.UserDto +import org.jellyfin.sdk.model.api.request.GetLatestMediaRequest +import org.jellyfin.sdk.model.api.request.GetNextUpRequest +import org.jellyfin.sdk.model.api.request.GetResumeItemsRequest +import timber.log.Timber +import java.time.LocalDateTime +import java.util.UUID +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.time.Duration.Companion.milliseconds + +@Singleton +class LatestNextUpService + @Inject + constructor( + @param:ApplicationContext private val context: Context, + private val api: ApiClient, + private val datePlayedService: DatePlayedService, + ) { + suspend fun getResume( + userId: UUID, + limit: Int, + includeEpisodes: Boolean, + ): List { + val request = + GetResumeItemsRequest( + userId = userId, + fields = SlimItemFields, + limit = limit, + includeItemTypes = + if (includeEpisodes) { + supportItemKinds + } else { + supportItemKinds + .toMutableSet() + .apply { + remove(BaseItemKind.EPISODE) + } + }, + ) + val items = + api.itemsApi + .getResumeItems(request) + .content + .items + .map { BaseItem.from(it, api, true) } + return items + } + + suspend fun getNextUp( + userId: UUID, + limit: Int, + enableRewatching: Boolean, + enableResumable: Boolean, + ): List { + val request = + GetNextUpRequest( + userId = userId, + fields = SlimItemFields, + imageTypeLimit = 1, + parentId = null, + limit = limit, + enableResumable = enableResumable, + enableUserData = true, + enableRewatching = enableRewatching, + ) + val nextUp = + api.tvShowsApi + .getNextUp(request) + .content + .items + .map { BaseItem.from(it, api, true) } + return nextUp + } + + suspend fun getLatest( + user: UserDto, + limit: Int, + includedIds: List, + ): List { + val excluded = user.configuration?.latestItemsExcludes.orEmpty() + val views by api.userViewsApi.getUserViews() + val latestData = + views.items + .filter { + it.id in includedIds && it.id !in excluded && + it.collectionType in supportedLatestCollectionTypes + }.map { view -> + val title = + view.name?.let { context.getString(R.string.recently_added_in, it) } + ?: context.getString(R.string.recently_added) + val request = + GetLatestMediaRequest( + fields = SlimItemFields, + imageTypeLimit = 1, + parentId = view.id, + groupItems = true, + limit = limit, + isPlayed = null, // Server will handle user's preference + ) + LatestData(title, request) + } + + return latestData + } + + suspend fun loadLatest(latestData: List): List { + val rows = + latestData.mapNotNull { (title, request) -> + try { + val latest = + api.userLibraryApi + .getLatestMedia(request) + .content + .map { BaseItem.from(it, api, true) } + if (latest.isNotEmpty()) { + HomeRowLoadingState.Success( + title = title, + items = latest, + ) + } else { + null + } + } catch (ex: Exception) { + Timber.e(ex, "Exception fetching %s", title) + HomeRowLoadingState.Error( + title = title, + exception = ex, + ) + } + } + return rows + } + + suspend fun buildCombined( + resume: List, + nextUp: List, + ): List = + withContext(Dispatchers.IO) { + val start = System.currentTimeMillis() + val semaphore = Semaphore(3) + val deferred = + nextUp + .filter { it.data.seriesId != null } + .map { item -> + async(Dispatchers.IO) { + try { + semaphore.withPermit { + datePlayedService.getLastPlayed(item) + } + } catch (ex: Exception) { + Timber.e(ex, "Error fetching %s", item.id) + null + } + } + } + + val nextUpLastPlayed = deferred.awaitAll() + val timestamps = mutableMapOf() + nextUp.map { it.id }.zip(nextUpLastPlayed).toMap(timestamps) + resume.forEach { timestamps[it.id] = it.data.userData?.lastPlayedDate } + val result = (resume + nextUp).sortedByDescending { timestamps[it.id] } + val duration = (System.currentTimeMillis() - start).milliseconds + Timber.v("buildCombined took %s", duration) + return@withContext result + } + } diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/NavigationManager.kt b/app/src/main/java/com/github/damontecres/wholphin/services/NavigationManager.kt index 136cb746..63a85cef 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/NavigationManager.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/NavigationManager.kt @@ -68,9 +68,20 @@ class NavigationManager log() } + /** + * Resets the backstack to the specified destination + */ + fun replace(destination: Destination) { + while (backStack.size > 1) { + backStack.removeLastOrNull() + } + backStack[0] = destination + log() + } + private fun log() { val dest = backStack.lastOrNull().toString() - Timber.Forest.i("Current Destination: %s", dest) + Timber.i("Current Destination: %s", dest) ACRA.errorReporter.putCustomData("destination", dest) } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/tvprovider/TvProviderSchedulerService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/tvprovider/TvProviderSchedulerService.kt new file mode 100644 index 00000000..05ce1d2a --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/services/tvprovider/TvProviderSchedulerService.kt @@ -0,0 +1,89 @@ +package com.github.damontecres.wholphin.services.tvprovider + +import android.content.Context +import android.content.pm.PackageManager +import android.os.Build +import androidx.appcompat.app.AppCompatActivity +import androidx.lifecycle.lifecycleScope +import androidx.work.BackoffPolicy +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.PeriodicWorkRequestBuilder +import androidx.work.WorkManager +import androidx.work.await +import androidx.work.workDataOf +import com.github.damontecres.wholphin.data.ServerRepository +import com.github.damontecres.wholphin.ui.launchIO +import com.github.damontecres.wholphin.util.ExceptionHandler +import dagger.hilt.android.qualifiers.ActivityContext +import dagger.hilt.android.scopes.ActivityScoped +import timber.log.Timber +import javax.inject.Inject +import kotlin.time.Duration.Companion.hours +import kotlin.time.Duration.Companion.minutes +import kotlin.time.toJavaDuration + +@ActivityScoped +class TvProviderSchedulerService + @Inject + constructor( + @param:ActivityContext private val context: Context, + private val serverRepository: ServerRepository, + ) { + private val activity = (context as AppCompatActivity) + private val workManager = WorkManager.getInstance(context) + + private val supportsTvProvider = + // TODO <=25 has limited support + Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && + context.packageManager.hasSystemFeature(PackageManager.FEATURE_LEANBACK) + + init { + serverRepository.current.observe(activity) { user -> + workManager.cancelUniqueWork(TvProviderWorker.WORK_NAME) + if (supportsTvProvider) { + if (user != null) { + activity.lifecycleScope.launchIO(ExceptionHandler()) { + Timber.i("Scheduling TvProviderWorker for ${user.user}") + workManager + .enqueueUniquePeriodicWork( + uniqueWorkName = TvProviderWorker.WORK_NAME, + existingPeriodicWorkPolicy = ExistingPeriodicWorkPolicy.UPDATE, + request = + PeriodicWorkRequestBuilder( + repeatInterval = 1.hours.toJavaDuration(), + ).setBackoffCriteria( + BackoffPolicy.LINEAR, + 15.minutes.toJavaDuration(), + ).setInputData( + workDataOf( + TvProviderWorker.PARAM_USER_ID to user.user.id.toString(), + TvProviderWorker.PARAM_SERVER_ID to user.server.id.toString(), + ), + ).build(), + ).await() + } + } + } + } + } + + fun launchOneTimeRefresh() { + if (supportsTvProvider) { + activity.lifecycleScope.launchIO(ExceptionHandler()) { + serverRepository.current.value?.let { user -> + Timber.i("Scheduling on-time TvProviderWorker for ${user.user}") + workManager.enqueue( + OneTimeWorkRequestBuilder() + .setInputData( + workDataOf( + TvProviderWorker.PARAM_USER_ID to user.user.id.toString(), + TvProviderWorker.PARAM_SERVER_ID to user.server.id.toString(), + ), + ).build(), + ) + } + } + } + } + } diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/tvprovider/TvProviderWorker.kt b/app/src/main/java/com/github/damontecres/wholphin/services/tvprovider/TvProviderWorker.kt new file mode 100644 index 00000000..1d54f256 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/services/tvprovider/TvProviderWorker.kt @@ -0,0 +1,245 @@ +package com.github.damontecres.wholphin.services.tvprovider + +import android.content.Context +import android.content.Intent +import android.database.Cursor +import androidx.core.net.toUri +import androidx.datastore.core.DataStore +import androidx.hilt.work.HiltWorker +import androidx.tvprovider.media.tv.TvContractCompat +import androidx.tvprovider.media.tv.TvContractCompat.WatchNextPrograms +import androidx.tvprovider.media.tv.WatchNextProgram +import androidx.work.CoroutineWorker +import androidx.work.WorkerParameters +import com.github.damontecres.wholphin.MainActivity +import com.github.damontecres.wholphin.data.ServerRepository +import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.preferences.AppPreferences +import com.github.damontecres.wholphin.services.ImageUrlService +import com.github.damontecres.wholphin.services.LatestNextUpService +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import kotlinx.coroutines.flow.firstOrNull +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.exception.ApiClientException +import org.jellyfin.sdk.model.api.BaseItemKind +import org.jellyfin.sdk.model.api.ImageType +import org.jellyfin.sdk.model.extensions.ticks +import org.jellyfin.sdk.model.serializer.toUUIDOrNull +import timber.log.Timber +import java.time.ZoneId +import java.util.Date +import java.util.UUID +import kotlin.time.Duration.Companion.minutes + +@HiltWorker +class TvProviderWorker + @AssistedInject + constructor( + @Assisted private val context: Context, + @Assisted workerParams: WorkerParameters, + private val serverRepository: ServerRepository, + private val preferences: DataStore, + private val api: ApiClient, + private val latestNextUpService: LatestNextUpService, + private val imageUrlService: ImageUrlService, + ) : CoroutineWorker(context, workerParams) { + override suspend fun doWork(): Result { + Timber.d("Start") + val serverId = + inputData.getString(PARAM_SERVER_ID)?.toUUIDOrNull() ?: return Result.failure() + val userId = + inputData.getString(PARAM_USER_ID)?.toUUIDOrNull() ?: return Result.failure() + + if (api.baseUrl.isNullOrBlank() || api.accessToken.isNullOrBlank()) { + // Not active + var currentUser = serverRepository.current.value + if (currentUser == null) { + serverRepository.restoreSession(serverId, userId) + currentUser = serverRepository.current.value + } + if (currentUser == null) { + Timber.w("No user found during run") + return Result.failure() + } + } + try { + val prefs = preferences.data.firstOrNull() ?: AppPreferences.getDefaultInstance() + val potentialItemsToAdd = + getPotentialItems( + userId, + prefs.homePagePreferences.enableRewatchingNextUp, + prefs.homePagePreferences.combineContinueNext, + ) + val potentialItemsToAddIds = potentialItemsToAdd.map { it.id.toString() } + + Timber.v("potentialItemsToAddIds=%s", potentialItemsToAddIds) + val currentItems = getCurrentTvChannelNextUp() + val currentItemIds = currentItems.map { it.internalProviderId } + + val toRemove = + currentItems.filterNot { it.internalProviderId in potentialItemsToAddIds } + + val userRemoved = currentItems.filterNot { it.isBrowsable } + val userRemovedIds = userRemoved.map { it.internalProviderId } + Timber.v("toRemove (%s)=%s", toRemove.size, toRemove.map { it.internalProviderId }) + val toAdd = + potentialItemsToAdd.filterNot { it.id.toString() in currentItemIds && it.id.toString() in userRemovedIds } + Timber.v("toAdd (%s)=%s", toAdd.size, toAdd.map { it.id }) + + // Remove existing items if they are no longer in the next up from server + (toRemove + userRemoved) + .map { TvContractCompat.buildWatchNextProgramUri(it.id) } + .forEach { + context.contentResolver.delete(it, null, null) + } + + // Add new ones + val addedCount = + context.contentResolver.bulkInsert( + WatchNextPrograms.CONTENT_URI, + toAdd + .map { convert(it).toContentValues() } + .toTypedArray(), + ) + Timber.v("Added %s", addedCount) + + Timber.d("Completed successfully") + } catch (_: ApiClientException) { + return Result.retry() + } catch (_: Exception) { + return Result.failure() + } + return Result.success() + } + + private suspend fun getPotentialItems( + userId: UUID, + enableRewatching: Boolean, + combineContinueNext: Boolean, + ): List { + val resumeItems = latestNextUpService.getResume(userId, 10, true) + val seriesIds = resumeItems.mapNotNull { it.data.seriesId } + val nextUpItems = + latestNextUpService + .getNextUp(userId, 10, enableRewatching, false) + .filter { it.data.seriesId != null && it.data.seriesId !in seriesIds } + return if (combineContinueNext) { + latestNextUpService.buildCombined(resumeItems, nextUpItems) + } else { + resumeItems + nextUpItems + } + } + + private suspend fun getCurrentTvChannelNextUp(): List = + context.contentResolver + .query( + WatchNextPrograms.CONTENT_URI, + WatchNextProgram.PROJECTION, + null, + null, + null, + )?.map(WatchNextProgram::fromCursor) + .orEmpty() + + private fun convert(item: BaseItem): WatchNextProgram = + WatchNextProgram + .Builder() + .apply { + val dto = item.data + setInternalProviderId(item.id.toString()) + + val type = + when (item.type) { + BaseItemKind.EPISODE -> WatchNextPrograms.TYPE_TV_EPISODE + BaseItemKind.MOVIE -> WatchNextPrograms.TYPE_MOVIE + else -> WatchNextPrograms.TYPE_CLIP + } + setType(type) + + val resumePosition = dto.userData?.playbackPositionTicks?.ticks + if (resumePosition != null && resumePosition >= 2.minutes) { + // https://developer.android.com/training/tv/discovery/guidelines-app-developers#types-of-content + setWatchNextType(WatchNextPrograms.WATCH_NEXT_TYPE_CONTINUE) + setLastPlaybackPositionMillis(resumePosition.inWholeMilliseconds.toInt()) + } else { + setWatchNextType(WatchNextPrograms.WATCH_NEXT_TYPE_NEXT) + } + dto.runTimeTicks + ?.ticks + ?.inWholeMilliseconds + ?.toInt() + ?.let(::setDurationMillis) + + setLastEngagementTimeUtcMillis( + dto.userData + ?.lastPlayedDate + ?.atZone(ZoneId.systemDefault()) + ?.toEpochSecond() + ?: Date().time, // TODO + ) + + setTitle(item.title) + setDescription(dto.overview) + if (item.type == BaseItemKind.EPISODE) { + setEpisodeTitle(item.name) + dto.indexNumber?.let(::setEpisodeNumber) + dto.parentIndexNumber?.let(::setSeasonNumber) + } + + setPosterArtAspectRatio(TvContractCompat.PreviewProgramColumns.ASPECT_RATIO_16_9) + val imageType = + when (item.type) { + BaseItemKind.EPISODE -> ImageType.THUMB + else -> ImageType.PRIMARY + } + setPosterArtUri(imageUrlService.getItemImageUrl(item, imageType)!!.toUri()) + + setIntent( + Intent(context, MainActivity::class.java) + .putExtra(MainActivity.INTENT_ITEM_ID, item.id.toString()) + .putExtra(MainActivity.INTENT_ITEM_TYPE, item.type.serialName) + .apply { + if (item.type == BaseItemKind.EPISODE) { + putExtra( + MainActivity.INTENT_SERIES_ID, + dto.seriesId?.toString(), + ) + putExtra( + MainActivity.INTENT_SEASON_ID, + dto.seasonId?.toString(), + ) + dto.parentIndexNumber?.let { + putExtra( + MainActivity.INTENT_SEASON_NUMBER, + it, + ) + } + dto.indexNumber?.let { + putExtra( + MainActivity.INTENT_EPISODE_NUMBER, + it, + ) + } + } + }, + ) + }.build() + + companion object { + const val WORK_NAME = "com.github.damontecres.wholphin.services.tvprovider.TvProviderWorker" + const val PARAM_USER_ID = "userId" + const val PARAM_SERVER_ID = "serverId" + } + } + +fun Cursor.map(transform: (Cursor) -> T): List = + this.use { + buildList { + if (moveToFirst()) { + do { + add(transform.invoke(this@map)) + } while (moveToNext()) + } + } + } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedTvShow.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedTvShow.kt index 6e48692a..fa4071b6 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedTvShow.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedTvShow.kt @@ -12,12 +12,11 @@ import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.services.BackdropService -import com.github.damontecres.wholphin.services.DatePlayedService import com.github.damontecres.wholphin.services.FavoriteWatchManager +import com.github.damontecres.wholphin.services.LatestNextUpService import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.ui.SlimItemFields import com.github.damontecres.wholphin.ui.data.RowColumn -import com.github.damontecres.wholphin.ui.main.buildCombinedNextUp import com.github.damontecres.wholphin.ui.setValueOnMain import com.github.damontecres.wholphin.ui.toBaseItems import com.github.damontecres.wholphin.util.ExceptionHandler @@ -58,7 +57,7 @@ class RecommendedTvShowViewModel private val api: ApiClient, private val serverRepository: ServerRepository, private val preferencesDataStore: DataStore, - private val datePlayedService: DatePlayedService, + private val lastestNextUpService: LatestNextUpService, @Assisted val parentId: UUID, navigationManager: NavigationManager, favoriteWatchManager: FavoriteWatchManager, @@ -126,9 +125,7 @@ class RecommendedTvShowViewModel val nextUpItems = nextUpItemsDeferred.await() if (combineNextUp) { val combined = - buildCombinedNextUp( - viewModelScope, - datePlayedService, + lastestNextUpService.buildCombined( resumeItems, nextUpItems, ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomeViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomeViewModel.kt index 7370398c..e2704ad8 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomeViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomeViewModel.kt @@ -12,8 +12,8 @@ import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.DatePlayedService import com.github.damontecres.wholphin.services.FavoriteWatchManager +import com.github.damontecres.wholphin.services.LatestNextUpService import com.github.damontecres.wholphin.services.NavigationManager -import com.github.damontecres.wholphin.ui.SlimItemFields import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.nav.ServerNavDrawerItem import com.github.damontecres.wholphin.ui.setValueOnMain @@ -21,34 +21,18 @@ import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.HomeRowLoadingState import com.github.damontecres.wholphin.util.LoadingExceptionHandler import com.github.damontecres.wholphin.util.LoadingState -import com.github.damontecres.wholphin.util.supportItemKinds import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job -import kotlinx.coroutines.async -import kotlinx.coroutines.awaitAll import kotlinx.coroutines.launch -import kotlinx.coroutines.sync.Semaphore -import kotlinx.coroutines.sync.withPermit import kotlinx.coroutines.withContext import org.jellyfin.sdk.api.client.ApiClient -import org.jellyfin.sdk.api.client.extensions.itemsApi -import org.jellyfin.sdk.api.client.extensions.tvShowsApi -import org.jellyfin.sdk.api.client.extensions.userLibraryApi -import org.jellyfin.sdk.api.client.extensions.userViewsApi -import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.CollectionType -import org.jellyfin.sdk.model.api.UserDto import org.jellyfin.sdk.model.api.request.GetLatestMediaRequest -import org.jellyfin.sdk.model.api.request.GetNextUpRequest -import org.jellyfin.sdk.model.api.request.GetResumeItemsRequest import timber.log.Timber -import java.time.LocalDateTime import java.util.UUID import javax.inject.Inject -import kotlin.time.Duration.Companion.milliseconds @HiltViewModel class HomeViewModel @@ -61,6 +45,7 @@ class HomeViewModel val navDrawerItemRepository: NavDrawerItemRepository, private val favoriteWatchManager: FavoriteWatchManager, private val datePlayedService: DatePlayedService, + private val latestNextUpService: LatestNextUpService, private val backdropService: BackdropService, ) : ViewModel() { val loadingState = MutableLiveData(LoadingState.Pending) @@ -101,10 +86,9 @@ class HomeViewModel .getFilteredNavDrawerItems(navDrawerItemRepository.getNavDrawerItems()) .filter { it is ServerNavDrawerItem } .map { (it as ServerNavDrawerItem).itemId } - // TODO data is fetched all together which may be slow for large servers - val resume = getResume(userDto.id, limit, true) + val resume = latestNextUpService.getResume(userDto.id, limit, true) val nextUp = - getNextUp( + latestNextUpService.getNextUp( userDto.id, limit, prefs.enableRewatchingNextUp, @@ -113,13 +97,7 @@ class HomeViewModel val watching = buildList { if (prefs.combineContinueNext) { - val items = - buildCombinedNextUp( - viewModelScope, - datePlayedService, - resume, - nextUp, - ) + val items = latestNextUpService.buildCombined(resume, nextUp) add( HomeRowLoadingState.Success( title = context.getString(R.string.continue_watching), @@ -146,7 +124,7 @@ class HomeViewModel } } - val latest = getLatest(userDto, limit, includedIds) + val latest = latestNextUpService.getLatest(userDto, limit, includedIds) val pendingLatest = latest.map { HomeRowLoadingState.Loading(it.title) } withContext(Dispatchers.Main) { @@ -156,127 +134,13 @@ class HomeViewModel } loadingState.value = LoadingState.Success } - loadLatest(latest) refreshState.setValueOnMain(LoadingState.Success) + val loadedLatest = latestNextUpService.loadLatest(latest) + this@HomeViewModel.latestRows.setValueOnMain(loadedLatest) } } } - private suspend fun getResume( - userId: UUID, - limit: Int, - includeEpisodes: Boolean, - ): List { - val request = - GetResumeItemsRequest( - userId = userId, - fields = SlimItemFields, - limit = limit, - includeItemTypes = - if (includeEpisodes) { - supportItemKinds - } else { - supportItemKinds - .toMutableSet() - .apply { - remove(BaseItemKind.EPISODE) - } - }, - ) - val items = - api.itemsApi - .getResumeItems(request) - .content - .items - .map { BaseItem.from(it, api, true) } - return items - } - - private suspend fun getNextUp( - userId: UUID, - limit: Int, - enableRewatching: Boolean, - enableResumable: Boolean, - ): List { - val request = - GetNextUpRequest( - userId = userId, - fields = SlimItemFields, - imageTypeLimit = 1, - parentId = null, - limit = limit, - enableResumable = enableResumable, - enableUserData = true, - enableRewatching = enableRewatching, - ) - val nextUp = - api.tvShowsApi - .getNextUp(request) - .content - .items - .map { BaseItem.from(it, api, true) } - return nextUp - } - - private suspend fun getLatest( - user: UserDto, - limit: Int, - includedIds: List, - ): List { - val excluded = user.configuration?.latestItemsExcludes.orEmpty() - val views by api.userViewsApi.getUserViews() - val latestData = - views.items - .filter { - it.id in includedIds && it.id !in excluded && - it.collectionType in supportedLatestCollectionTypes - }.map { view -> - val title = - view.name?.let { context.getString(R.string.recently_added_in, it) } - ?: context.getString(R.string.recently_added) - val request = - GetLatestMediaRequest( - fields = SlimItemFields, - imageTypeLimit = 1, - parentId = view.id, - groupItems = true, - limit = limit, - isPlayed = null, // Server will handle user's preference - ) - LatestData(title, request) - } - - return latestData - } - - private suspend fun loadLatest(latestData: List) { - val rows = - latestData.mapNotNull { (title, request) -> - try { - val latest = - api.userLibraryApi - .getLatestMedia(request) - .content - .map { BaseItem.from(it, api, true) } - if (latest.isNotEmpty()) { - HomeRowLoadingState.Success( - title = title, - items = latest, - ) - } else { - null - } - } catch (ex: Exception) { - Timber.e(ex, "Exception fetching %s", title) - HomeRowLoadingState.Error( - title = title, - exception = ex, - ) - } - } - latestRows.setValueOnMain(rows) - } - fun setWatched( itemId: UUID, played: Boolean, @@ -317,38 +181,3 @@ data class LatestData( val title: String, val request: GetLatestMediaRequest, ) - -suspend fun buildCombinedNextUp( - scope: CoroutineScope, - datePlayedService: DatePlayedService, - resume: List, - nextUp: List, -): List = - withContext(Dispatchers.IO) { - val start = System.currentTimeMillis() - val semaphore = Semaphore(3) - val deferred = - nextUp - .filter { it.data.seriesId != null } - .map { item -> - scope.async(Dispatchers.IO) { - try { - semaphore.withPermit { - datePlayedService.getLastPlayed(item) - } - } catch (ex: Exception) { - Timber.e(ex, "Error fetching %s", item.id) - null - } - } - } - - val nextUpLastPlayed = deferred.awaitAll() - val timestamps = mutableMapOf() - nextUp.map { it.id }.zip(nextUpLastPlayed).toMap(timestamps) - resume.forEach { timestamps[it.id] = it.data.userData?.lastPlayedDate } - val result = (resume + nextUp).sortedByDescending { timestamps[it.id] } - val duration = (System.currentTimeMillis() - start).milliseconds - Timber.v("buildCombined took %s", duration) - return@withContext result - } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/ApplicationContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/ApplicationContent.kt index 7e7d0d2b..14f34747 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/ApplicationContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/ApplicationContent.kt @@ -69,6 +69,7 @@ class ApplicationContentViewModel fun ApplicationContent( server: JellyfinServer, user: JellyfinUser, + startDestination: Destination, navigationManager: NavigationManager, preferences: UserPreferences, modifier: Modifier = Modifier, @@ -80,7 +81,7 @@ fun ApplicationContent( user, serializer = NavBackStackSerializer(elementSerializer = NavKeySerializer()), ) { - NavBackStack(Destination.Home()) + NavBackStack(startDestination) } navigationManager.backStack = backStack val backdrop by viewModel.backdropService.backdropFlow.collectAsStateWithLifecycle() diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 9ed29469..d0c03548 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -5,7 +5,9 @@ agp = "8.13.2" auto-service = "1.1.1" autoServiceKsp = "1.2.0" desugar_jdk_libs = "2.1.5" +hiltCompiler = "1.3.0" hiltNavigationCompose = "1.3.0" +hiltWork = "1.3.0" kotlin = "2.2.21" ksp = "2.3.0" coreKtx = "1.17.0" @@ -33,6 +35,8 @@ protobuf-javalite = "4.33.2" hilt = "2.57.2" room = "2.8.4" preferenceKtx = "1.2.1" +tvprovider = "1.1.0" +workRuntimeKtx = "2.11.0" paletteKtx = "1.0.0" [libraries] @@ -54,12 +58,16 @@ androidx-compose-material3 = { group = "androidx.compose.material3", name = "mat androidx-compose-runtime = { group = "androidx.compose.runtime", name = "runtime-android" } androidx-compose-runtime-livedata = { group = "androidx.compose.runtime", name = "runtime-livedata" } +androidx-hilt-compiler = { module = "androidx.hilt:hilt-compiler", version.ref = "hiltWork" } androidx-hilt-navigation-compose = { module = "androidx.hilt:hilt-navigation-compose", version.ref = "hiltNavigationCompose" } androidx-tv-foundation = { group = "androidx.tv", name = "tv-foundation", version.ref = "tvFoundation" } androidx-tv-material = { group = "androidx.tv", name = "tv-material", version.ref = "tvMaterial" } androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" } androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } androidx-datastore = { module = "androidx.datastore:datastore", version.ref = "datastore" } +androidx-tvprovider = { module = "androidx.tvprovider:tvprovider", version.ref = "tvprovider" } +androidx-work-runtime-ktx = { module = "androidx.work:work-runtime-ktx", version.ref = "workRuntimeKtx" } +androidx-hilt-work = { module = "androidx.hilt:hilt-work", version.ref = "hiltWork" } auto-service-annotations = { module = "com.google.auto.service:auto-service-annotations", version.ref = "auto-service" } auto-service-ksp = { module = "dev.zacsweers.autoservice:auto-service-ksp", version.ref = "autoServiceKsp" } desugar_jdk_libs = { module = "com.android.tools:desugar_jdk_libs", version.ref = "desugar_jdk_libs" }