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(