From cda5b118a3dcf28f9902dee1b634e5a580ce466e Mon Sep 17 00:00:00 2001 From: damontecres <154766448+damontecres@users.noreply.github.com> Date: Tue, 18 Nov 2025 15:21:50 -0500 Subject: [PATCH] Option to combine continue watching & next up rows (#192) A real implementation for combining the `Continue Watching` and `Next Up` rows on the home page. It requires a query for every next up item, so it could become slow especially for many items and larger servers. The app makes a best effort to cache the information. The order is determined by querying each episode in next up to associate it to its immediately previous episode's last played date. Then both continue watching & next up items are reversed sorted by the last played data. So this only makes sense if you watch series in order. Closes #19 --- .../wholphin/preferences/AppPreference.kt | 22 ++-- .../wholphin/services/DatePlayedService.kt | 124 ++++++++++++++++++ .../wholphin/services/FavoriteWatchManager.kt | 62 ++++----- .../wholphin/services/hilt/AppModule.kt | 7 - .../wholphin/ui/main/HomeViewModel.kt | 77 +++++++++-- .../wholphin/ui/playback/PlaybackViewModel.kt | 3 + 6 files changed, 232 insertions(+), 63 deletions(-) create mode 100644 app/src/main/java/com/github/damontecres/wholphin/services/DatePlayedService.kt diff --git a/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt b/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt index 9def0960..cd4e9a47 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt @@ -217,7 +217,7 @@ sealed interface AppPreference { }, displayValues = R.array.theme_song_volume, indexToValue = { ThemeSongVolume.forNumber(it) }, - valueToIndex = { it.number }, + valueToIndex = { if (it != ThemeSongVolume.UNRECOGNIZED) it.number else 0 }, ) val PlaybackDebugInfo = @@ -433,7 +433,7 @@ sealed interface AppPreference { }, displayValues = R.array.app_theme_colors, indexToValue = { AppThemeColors.forNumber(it) }, - valueToIndex = { it.number }, + valueToIndex = { if (it != AppThemeColors.UNRECOGNIZED) it.number else 0 }, ) val InstalledVersion = @@ -495,7 +495,7 @@ sealed interface AppPreference { }, displayValues = R.array.skip_behaviors, indexToValue = { SkipSegmentBehavior.forNumber(it) }, - valueToIndex = { it.number }, + valueToIndex = { if (it != SkipSegmentBehavior.UNRECOGNIZED) it.number else 0 }, ) val SkipOutros = @@ -508,7 +508,7 @@ sealed interface AppPreference { }, displayValues = R.array.skip_behaviors, indexToValue = { SkipSegmentBehavior.forNumber(it) }, - valueToIndex = { it.number }, + valueToIndex = { if (it != SkipSegmentBehavior.UNRECOGNIZED) it.number else 0 }, ) val SkipCommercials = @@ -521,7 +521,7 @@ sealed interface AppPreference { }, displayValues = R.array.skip_behaviors, indexToValue = { SkipSegmentBehavior.forNumber(it) }, - valueToIndex = { it.number }, + valueToIndex = { if (it != SkipSegmentBehavior.UNRECOGNIZED) it.number else 0 }, ) val SkipPreviews = @@ -534,7 +534,7 @@ sealed interface AppPreference { }, displayValues = R.array.skip_behaviors, indexToValue = { SkipSegmentBehavior.forNumber(it) }, - valueToIndex = { it.number }, + valueToIndex = { if (it != SkipSegmentBehavior.UNRECOGNIZED) it.number else 0 }, ) val SkipRecaps = @@ -547,7 +547,7 @@ sealed interface AppPreference { }, displayValues = R.array.skip_behaviors, indexToValue = { SkipSegmentBehavior.forNumber(it) }, - valueToIndex = { it.number }, + valueToIndex = { if (it != SkipSegmentBehavior.UNRECOGNIZED) it.number else 0 }, ) val GlobalContentScale = @@ -560,7 +560,7 @@ sealed interface AppPreference { }, displayValues = R.array.content_scale, indexToValue = { PrefContentScale.forNumber(it) }, - valueToIndex = { it.number }, + valueToIndex = { if (it != PrefContentScale.UNRECOGNIZED) it.number else 0 }, ) val FfmpegPreference = @@ -573,7 +573,7 @@ sealed interface AppPreference { }, displayValues = R.array.ffmpeg_extension_options, indexToValue = { MediaExtensionStatus.forNumber(it) }, - valueToIndex = { it.number }, + valueToIndex = { if (it != MediaExtensionStatus.UNRECOGNIZED) it.number else 0 }, ) val ClearImageCache = @@ -641,7 +641,7 @@ sealed interface AppPreference { }, displayValues = R.array.show_next_up_when_options, indexToValue = { ShowNextUpWhen.forNumber(it) }, - valueToIndex = { it.number }, + valueToIndex = { if (it != ShowNextUpWhen.UNRECOGNIZED) it.number else 0 }, ) val ShowClock = @@ -707,6 +707,7 @@ val basicPreferences = preferences = listOf( AppPreference.HomePageItems, + AppPreference.CombineContinueNext, AppPreference.RewatchNextUp, AppPreference.PlayThemeMusic, AppPreference.RememberSelectedTab, @@ -766,7 +767,6 @@ val advancedPreferences = preferences = listOf( AppPreference.ShowClock, - AppPreference.CombineContinueNext, // Temporarily disabled, see https://github.com/damontecres/Wholphin/pull/127#issuecomment-3478058418 // AppPreference.NavDrawerSwitchOnFocus, AppPreference.ControllerTimeout, diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/DatePlayedService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/DatePlayedService.kt new file mode 100644 index 00000000..e2dcdbe1 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/services/DatePlayedService.kt @@ -0,0 +1,124 @@ +package com.github.damontecres.wholphin.services + +import android.content.Context +import androidx.appcompat.app.AppCompatActivity +import com.github.damontecres.wholphin.data.ServerRepository +import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.preferences.AppPreference +import com.github.damontecres.wholphin.util.GetEpisodesRequestHandler +import com.google.common.cache.CacheBuilder +import com.google.common.cache.CacheLoader +import dagger.hilt.android.qualifiers.ActivityContext +import dagger.hilt.android.scopes.ActivityScoped +import kotlinx.coroutines.runBlocking +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.exception.InvalidStatusException +import org.jellyfin.sdk.api.client.extensions.userLibraryApi +import org.jellyfin.sdk.model.api.BaseItemKind +import org.jellyfin.sdk.model.api.request.GetEpisodesRequest +import timber.log.Timber +import java.time.LocalDateTime +import java.util.UUID +import java.util.concurrent.TimeUnit +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class DatePlayedService + @Inject + constructor( + private val api: ApiClient, + ) { + private val datePlayedCache = + CacheBuilder + .newBuilder() + .maximumSize(AppPreference.HomePageItems.max) + .expireAfterWrite(2, TimeUnit.HOURS) + .build( + object : + CacheLoader() { + override fun load(key: SeriesItemId): LocalDateTime = getLastPlayed(key) + }, + ) + + private fun getLastPlayed(key: SeriesItemId): LocalDateTime { + val request = + GetEpisodesRequest( + seriesId = key.seriesId, + adjacentTo = key.itemId, + limit = 1, + ) + return try { + val result = + runBlocking { + GetEpisodesRequestHandler + .execute( + api, + request, + ).content.items + } + result.firstOrNull()?.userData?.lastPlayedDate ?: LocalDateTime.MIN + } catch (ex: InvalidStatusException) { + Timber.w("Error fetching %s: %s", key, ex.localizedMessage) + LocalDateTime.MIN + } + } + + suspend fun getLastPlayed(item: BaseItem): LocalDateTime? { + val seriesId = item.data.seriesId + return if (seriesId != null) { + datePlayedCache.get(SeriesItemId(seriesId, item.id)) + } else { + null + } + } + + fun invalidate(item: BaseItem) { + item.data.seriesId?.let { seriesId -> + Timber.d("Invalidating %s", seriesId) + datePlayedCache.asMap().keys.removeIf { it.seriesId == seriesId } + } + } + + suspend fun invalidate(itemId: UUID) { + val seriesId = + api.userLibraryApi.getItem(itemId = itemId).content.let { + if (it.type == BaseItemKind.SERIES) { + itemId + } else { + it.seriesId + } + } + if (seriesId != null) { + Timber.d("Invalidating %s", seriesId) + datePlayedCache.asMap().keys.removeIf { it.seriesId == seriesId } + } + } + + fun invalidateAll() { + Timber.d("invalidateAll") + datePlayedCache.invalidateAll() + } + } + +@ActivityScoped +class DatePlayedInvalidationService + @Inject + constructor( + @param:ActivityContext private val context: Context, + private val serverRepository: ServerRepository, + private val datePlayedService: DatePlayedService, + ) { + private val activity = (context as AppCompatActivity) + + init { + serverRepository.current.observe(activity) { + datePlayedService.invalidateAll() + } + } + } + +private data class SeriesItemId( + val seriesId: UUID, + val itemId: UUID, +) diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/FavoriteWatchManager.kt b/app/src/main/java/com/github/damontecres/wholphin/services/FavoriteWatchManager.kt index 6a3e9a68..a68a6fe5 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/FavoriteWatchManager.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/FavoriteWatchManager.kt @@ -5,39 +5,35 @@ import org.jellyfin.sdk.api.client.extensions.playStateApi import org.jellyfin.sdk.api.client.extensions.userLibraryApi import org.jellyfin.sdk.model.api.UserItemDataDto import java.util.UUID +import javax.inject.Inject +import javax.inject.Singleton -interface FavoriteWatchManager { - suspend fun setWatched( - itemId: UUID, - played: Boolean, - ): UserItemDataDto - - suspend fun setFavorite( - itemId: UUID, - favorite: Boolean, - ): UserItemDataDto -} - -class FavoriteWatchManagerImpl( - private val api: ApiClient, -) : FavoriteWatchManager { - override suspend fun setWatched( - itemId: UUID, - played: Boolean, - ): UserItemDataDto = - if (played) { - api.playStateApi.markPlayedItem(itemId).content - } else { - api.playStateApi.markUnplayedItem(itemId).content +@Singleton +class FavoriteWatchManager + @Inject + constructor( + private val api: ApiClient, + private val datePlayedService: DatePlayedService, + ) { + suspend fun setWatched( + itemId: UUID, + played: Boolean, + ): UserItemDataDto { + datePlayedService.invalidate(itemId) + return if (played) { + api.playStateApi.markPlayedItem(itemId).content + } else { + api.playStateApi.markUnplayedItem(itemId).content + } } - override suspend fun setFavorite( - itemId: UUID, - favorite: Boolean, - ): UserItemDataDto = - if (favorite) { - api.userLibraryApi.markFavoriteItem(itemId).content - } else { - api.userLibraryApi.unmarkFavoriteItem(itemId).content - } -} + suspend fun setFavorite( + itemId: UUID, + favorite: Boolean, + ): UserItemDataDto = + if (favorite) { + api.userLibraryApi.markFavoriteItem(itemId).content + } else { + api.userLibraryApi.unmarkFavoriteItem(itemId).content + } + } diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/hilt/AppModule.kt b/app/src/main/java/com/github/damontecres/wholphin/services/hilt/AppModule.kt index 8d2d2e79..580a53ae 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/hilt/AppModule.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/hilt/AppModule.kt @@ -10,8 +10,6 @@ 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.preferences.updateInterfacePreferences -import com.github.damontecres.wholphin.services.FavoriteWatchManager -import com.github.damontecres.wholphin.services.FavoriteWatchManagerImpl import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.RememberTabManager import dagger.Module @@ -25,7 +23,6 @@ import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.launch import okhttp3.OkHttpClient import org.jellyfin.sdk.Jellyfin -import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.util.AuthorizationHeaderBuilder import org.jellyfin.sdk.api.okhttp.OkHttpFactory import org.jellyfin.sdk.createJellyfin @@ -185,8 +182,4 @@ object AppModule { @Singleton @IoCoroutineScope fun ioCoroutineScope(): CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) - - @Provides - @Singleton - fun favoriteWatchManager(api: ApiClient): FavoriteWatchManager = FavoriteWatchManagerImpl(api) } 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 388ec0c6..7864ff19 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 @@ -9,6 +9,7 @@ import com.github.damontecres.wholphin.data.NavDrawerItemRepository import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.preferences.UserPreferences +import com.github.damontecres.wholphin.services.DatePlayedService import com.github.damontecres.wholphin.services.FavoriteWatchManager import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.ui.SlimItemFields @@ -23,7 +24,11 @@ import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext 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 @@ -38,8 +43,10 @@ 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 @@ -51,6 +58,7 @@ class HomeViewModel val serverRepository: ServerRepository, val navDrawerItemRepository: NavDrawerItemRepository, private val favoriteWatchManager: FavoriteWatchManager, + private val datePlayedService: DatePlayedService, ) : ViewModel() { val loadingState = MutableLiveData(LoadingState.Pending) val refreshState = MutableLiveData(LoadingState.Pending) @@ -59,6 +67,10 @@ class HomeViewModel private lateinit var preferences: UserPreferences + init { + datePlayedService.invalidateAll() + } + fun init(preferences: UserPreferences): Job { val reload = loadingState.value != LoadingState.Success if (reload) { @@ -84,31 +96,41 @@ class HomeViewModel .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, !prefs.combineContinueNext) + val resume = getResume(userDto.id, limit, true) val nextUp = getNextUp( userDto.id, limit, prefs.enableRewatchingNextUp, - prefs.combineContinueNext, + false, ) val watching = buildList { - if (resume.isNotEmpty()) { + if (prefs.combineContinueNext) { + val items = buildCombined(resume, nextUp) add( HomeRowLoadingState.Success( title = context.getString(R.string.continue_watching), - items = resume, - ), - ) - } - if (nextUp.isNotEmpty()) { - add( - HomeRowLoadingState.Success( - title = context.getString(R.string.next_up), - items = nextUp, + items = items, ), ) + } else { + if (resume.isNotEmpty()) { + add( + HomeRowLoadingState.Success( + title = context.getString(R.string.continue_watching), + items = resume, + ), + ) + } + if (nextUp.isNotEmpty()) { + add( + HomeRowLoadingState.Success( + title = context.getString(R.string.next_up), + items = nextUp, + ), + ) + } } } @@ -255,6 +277,37 @@ class HomeViewModel latestRows.setValueOnMain(rows) } + private suspend fun buildCombined( + resume: List, + nextUp: List, + ): List { + val start = System.currentTimeMillis() + val semaphore = Semaphore(3) + val deferred = + nextUp + .filter { it.data.seriesId != null } + .map { item -> + semaphore.withPermit { + viewModelScope.async { + try { + 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 result + } + fun setWatched( itemId: UUID, played: Boolean, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt index c7710d68..af8b774b 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt @@ -34,6 +34,7 @@ import com.github.damontecres.wholphin.preferences.PlayerBackend import com.github.damontecres.wholphin.preferences.ShowNextUpWhen import com.github.damontecres.wholphin.preferences.SkipSegmentBehavior import com.github.damontecres.wholphin.preferences.UserPreferences +import com.github.damontecres.wholphin.services.DatePlayedService import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.PlayerFactory import com.github.damontecres.wholphin.services.PlaylistCreator @@ -118,6 +119,7 @@ class PlaybackViewModel val itemPlaybackRepository: ItemPlaybackRepository, val appPreferences: DataStore, private val playerFactory: PlayerFactory, + private val datePlayedService: DatePlayedService, ) : ViewModel(), Player.Listener { val player by lazy { @@ -235,6 +237,7 @@ class PlaybackViewModel ): Boolean = withContext(Dispatchers.IO) { Timber.i("Playing ${item.id}") + datePlayedService.invalidate(item) autoSkippedSegments.clear() if (item.type !in supportItemKinds) { showToast(