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
This commit is contained in:
damontecres 2025-11-18 15:21:50 -05:00 committed by GitHub
parent 46415fcdd0
commit cda5b118a3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 232 additions and 63 deletions

View file

@ -217,7 +217,7 @@ sealed interface AppPreference<T> {
}, },
displayValues = R.array.theme_song_volume, displayValues = R.array.theme_song_volume,
indexToValue = { ThemeSongVolume.forNumber(it) }, indexToValue = { ThemeSongVolume.forNumber(it) },
valueToIndex = { it.number }, valueToIndex = { if (it != ThemeSongVolume.UNRECOGNIZED) it.number else 0 },
) )
val PlaybackDebugInfo = val PlaybackDebugInfo =
@ -433,7 +433,7 @@ sealed interface AppPreference<T> {
}, },
displayValues = R.array.app_theme_colors, displayValues = R.array.app_theme_colors,
indexToValue = { AppThemeColors.forNumber(it) }, indexToValue = { AppThemeColors.forNumber(it) },
valueToIndex = { it.number }, valueToIndex = { if (it != AppThemeColors.UNRECOGNIZED) it.number else 0 },
) )
val InstalledVersion = val InstalledVersion =
@ -495,7 +495,7 @@ sealed interface AppPreference<T> {
}, },
displayValues = R.array.skip_behaviors, displayValues = R.array.skip_behaviors,
indexToValue = { SkipSegmentBehavior.forNumber(it) }, indexToValue = { SkipSegmentBehavior.forNumber(it) },
valueToIndex = { it.number }, valueToIndex = { if (it != SkipSegmentBehavior.UNRECOGNIZED) it.number else 0 },
) )
val SkipOutros = val SkipOutros =
@ -508,7 +508,7 @@ sealed interface AppPreference<T> {
}, },
displayValues = R.array.skip_behaviors, displayValues = R.array.skip_behaviors,
indexToValue = { SkipSegmentBehavior.forNumber(it) }, indexToValue = { SkipSegmentBehavior.forNumber(it) },
valueToIndex = { it.number }, valueToIndex = { if (it != SkipSegmentBehavior.UNRECOGNIZED) it.number else 0 },
) )
val SkipCommercials = val SkipCommercials =
@ -521,7 +521,7 @@ sealed interface AppPreference<T> {
}, },
displayValues = R.array.skip_behaviors, displayValues = R.array.skip_behaviors,
indexToValue = { SkipSegmentBehavior.forNumber(it) }, indexToValue = { SkipSegmentBehavior.forNumber(it) },
valueToIndex = { it.number }, valueToIndex = { if (it != SkipSegmentBehavior.UNRECOGNIZED) it.number else 0 },
) )
val SkipPreviews = val SkipPreviews =
@ -534,7 +534,7 @@ sealed interface AppPreference<T> {
}, },
displayValues = R.array.skip_behaviors, displayValues = R.array.skip_behaviors,
indexToValue = { SkipSegmentBehavior.forNumber(it) }, indexToValue = { SkipSegmentBehavior.forNumber(it) },
valueToIndex = { it.number }, valueToIndex = { if (it != SkipSegmentBehavior.UNRECOGNIZED) it.number else 0 },
) )
val SkipRecaps = val SkipRecaps =
@ -547,7 +547,7 @@ sealed interface AppPreference<T> {
}, },
displayValues = R.array.skip_behaviors, displayValues = R.array.skip_behaviors,
indexToValue = { SkipSegmentBehavior.forNumber(it) }, indexToValue = { SkipSegmentBehavior.forNumber(it) },
valueToIndex = { it.number }, valueToIndex = { if (it != SkipSegmentBehavior.UNRECOGNIZED) it.number else 0 },
) )
val GlobalContentScale = val GlobalContentScale =
@ -560,7 +560,7 @@ sealed interface AppPreference<T> {
}, },
displayValues = R.array.content_scale, displayValues = R.array.content_scale,
indexToValue = { PrefContentScale.forNumber(it) }, indexToValue = { PrefContentScale.forNumber(it) },
valueToIndex = { it.number }, valueToIndex = { if (it != PrefContentScale.UNRECOGNIZED) it.number else 0 },
) )
val FfmpegPreference = val FfmpegPreference =
@ -573,7 +573,7 @@ sealed interface AppPreference<T> {
}, },
displayValues = R.array.ffmpeg_extension_options, displayValues = R.array.ffmpeg_extension_options,
indexToValue = { MediaExtensionStatus.forNumber(it) }, indexToValue = { MediaExtensionStatus.forNumber(it) },
valueToIndex = { it.number }, valueToIndex = { if (it != MediaExtensionStatus.UNRECOGNIZED) it.number else 0 },
) )
val ClearImageCache = val ClearImageCache =
@ -641,7 +641,7 @@ sealed interface AppPreference<T> {
}, },
displayValues = R.array.show_next_up_when_options, displayValues = R.array.show_next_up_when_options,
indexToValue = { ShowNextUpWhen.forNumber(it) }, indexToValue = { ShowNextUpWhen.forNumber(it) },
valueToIndex = { it.number }, valueToIndex = { if (it != ShowNextUpWhen.UNRECOGNIZED) it.number else 0 },
) )
val ShowClock = val ShowClock =
@ -707,6 +707,7 @@ val basicPreferences =
preferences = preferences =
listOf( listOf(
AppPreference.HomePageItems, AppPreference.HomePageItems,
AppPreference.CombineContinueNext,
AppPreference.RewatchNextUp, AppPreference.RewatchNextUp,
AppPreference.PlayThemeMusic, AppPreference.PlayThemeMusic,
AppPreference.RememberSelectedTab, AppPreference.RememberSelectedTab,
@ -766,7 +767,6 @@ val advancedPreferences =
preferences = preferences =
listOf( listOf(
AppPreference.ShowClock, AppPreference.ShowClock,
AppPreference.CombineContinueNext,
// Temporarily disabled, see https://github.com/damontecres/Wholphin/pull/127#issuecomment-3478058418 // Temporarily disabled, see https://github.com/damontecres/Wholphin/pull/127#issuecomment-3478058418
// AppPreference.NavDrawerSwitchOnFocus, // AppPreference.NavDrawerSwitchOnFocus,
AppPreference.ControllerTimeout, AppPreference.ControllerTimeout,

View file

@ -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<SeriesItemId, LocalDateTime?>(
object :
CacheLoader<SeriesItemId, LocalDateTime>() {
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,
)

View file

@ -5,39 +5,35 @@ import org.jellyfin.sdk.api.client.extensions.playStateApi
import org.jellyfin.sdk.api.client.extensions.userLibraryApi import org.jellyfin.sdk.api.client.extensions.userLibraryApi
import org.jellyfin.sdk.model.api.UserItemDataDto import org.jellyfin.sdk.model.api.UserItemDataDto
import java.util.UUID import java.util.UUID
import javax.inject.Inject
import javax.inject.Singleton
interface FavoriteWatchManager { @Singleton
suspend fun setWatched( class FavoriteWatchManager
itemId: UUID, @Inject
played: Boolean, constructor(
): UserItemDataDto private val api: ApiClient,
private val datePlayedService: DatePlayedService,
suspend fun setFavorite( ) {
itemId: UUID, suspend fun setWatched(
favorite: Boolean, itemId: UUID,
): UserItemDataDto played: Boolean,
} ): UserItemDataDto {
datePlayedService.invalidate(itemId)
class FavoriteWatchManagerImpl( return if (played) {
private val api: ApiClient, api.playStateApi.markPlayedItem(itemId).content
) : FavoriteWatchManager { } else {
override suspend fun setWatched( api.playStateApi.markUnplayedItem(itemId).content
itemId: UUID, }
played: Boolean,
): UserItemDataDto =
if (played) {
api.playStateApi.markPlayedItem(itemId).content
} else {
api.playStateApi.markUnplayedItem(itemId).content
} }
override suspend fun setFavorite( suspend fun setFavorite(
itemId: UUID, itemId: UUID,
favorite: Boolean, favorite: Boolean,
): UserItemDataDto = ): UserItemDataDto =
if (favorite) { if (favorite) {
api.userLibraryApi.markFavoriteItem(itemId).content api.userLibraryApi.markFavoriteItem(itemId).content
} else { } else {
api.userLibraryApi.unmarkFavoriteItem(itemId).content api.userLibraryApi.unmarkFavoriteItem(itemId).content
} }
} }

View file

@ -10,8 +10,6 @@ import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.preferences.updateInterfacePreferences 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.ExceptionHandler
import com.github.damontecres.wholphin.util.RememberTabManager import com.github.damontecres.wholphin.util.RememberTabManager
import dagger.Module import dagger.Module
@ -25,7 +23,6 @@ import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import org.jellyfin.sdk.Jellyfin 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.client.util.AuthorizationHeaderBuilder
import org.jellyfin.sdk.api.okhttp.OkHttpFactory import org.jellyfin.sdk.api.okhttp.OkHttpFactory
import org.jellyfin.sdk.createJellyfin import org.jellyfin.sdk.createJellyfin
@ -185,8 +182,4 @@ object AppModule {
@Singleton @Singleton
@IoCoroutineScope @IoCoroutineScope
fun ioCoroutineScope(): CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) fun ioCoroutineScope(): CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
@Provides
@Singleton
fun favoriteWatchManager(api: ApiClient): FavoriteWatchManager = FavoriteWatchManagerImpl(api)
} }

View file

@ -9,6 +9,7 @@ import com.github.damontecres.wholphin.data.NavDrawerItemRepository
import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.preferences.UserPreferences 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.FavoriteWatchManager
import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.ui.SlimItemFields import com.github.damontecres.wholphin.ui.SlimItemFields
@ -23,7 +24,11 @@ import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.itemsApi 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.GetNextUpRequest
import org.jellyfin.sdk.model.api.request.GetResumeItemsRequest import org.jellyfin.sdk.model.api.request.GetResumeItemsRequest
import timber.log.Timber import timber.log.Timber
import java.time.LocalDateTime
import java.util.UUID import java.util.UUID
import javax.inject.Inject import javax.inject.Inject
import kotlin.time.Duration.Companion.milliseconds
@HiltViewModel @HiltViewModel
class HomeViewModel class HomeViewModel
@ -51,6 +58,7 @@ class HomeViewModel
val serverRepository: ServerRepository, val serverRepository: ServerRepository,
val navDrawerItemRepository: NavDrawerItemRepository, val navDrawerItemRepository: NavDrawerItemRepository,
private val favoriteWatchManager: FavoriteWatchManager, private val favoriteWatchManager: FavoriteWatchManager,
private val datePlayedService: DatePlayedService,
) : ViewModel() { ) : ViewModel() {
val loadingState = MutableLiveData<LoadingState>(LoadingState.Pending) val loadingState = MutableLiveData<LoadingState>(LoadingState.Pending)
val refreshState = MutableLiveData<LoadingState>(LoadingState.Pending) val refreshState = MutableLiveData<LoadingState>(LoadingState.Pending)
@ -59,6 +67,10 @@ class HomeViewModel
private lateinit var preferences: UserPreferences private lateinit var preferences: UserPreferences
init {
datePlayedService.invalidateAll()
}
fun init(preferences: UserPreferences): Job { fun init(preferences: UserPreferences): Job {
val reload = loadingState.value != LoadingState.Success val reload = loadingState.value != LoadingState.Success
if (reload) { if (reload) {
@ -84,31 +96,41 @@ class HomeViewModel
.filter { it is ServerNavDrawerItem } .filter { it is ServerNavDrawerItem }
.map { (it as ServerNavDrawerItem).itemId } .map { (it as ServerNavDrawerItem).itemId }
// TODO data is fetched all together which may be slow for large servers // 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 = val nextUp =
getNextUp( getNextUp(
userDto.id, userDto.id,
limit, limit,
prefs.enableRewatchingNextUp, prefs.enableRewatchingNextUp,
prefs.combineContinueNext, false,
) )
val watching = val watching =
buildList { buildList {
if (resume.isNotEmpty()) { if (prefs.combineContinueNext) {
val items = buildCombined(resume, nextUp)
add( add(
HomeRowLoadingState.Success( HomeRowLoadingState.Success(
title = context.getString(R.string.continue_watching), title = context.getString(R.string.continue_watching),
items = resume, items = items,
),
)
}
if (nextUp.isNotEmpty()) {
add(
HomeRowLoadingState.Success(
title = context.getString(R.string.next_up),
items = nextUp,
), ),
) )
} 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) latestRows.setValueOnMain(rows)
} }
private suspend fun buildCombined(
resume: List<BaseItem>,
nextUp: List<BaseItem>,
): List<BaseItem> {
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<UUID, LocalDateTime?>()
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( fun setWatched(
itemId: UUID, itemId: UUID,
played: Boolean, played: Boolean,

View file

@ -34,6 +34,7 @@ import com.github.damontecres.wholphin.preferences.PlayerBackend
import com.github.damontecres.wholphin.preferences.ShowNextUpWhen import com.github.damontecres.wholphin.preferences.ShowNextUpWhen
import com.github.damontecres.wholphin.preferences.SkipSegmentBehavior import com.github.damontecres.wholphin.preferences.SkipSegmentBehavior
import com.github.damontecres.wholphin.preferences.UserPreferences 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.NavigationManager
import com.github.damontecres.wholphin.services.PlayerFactory import com.github.damontecres.wholphin.services.PlayerFactory
import com.github.damontecres.wholphin.services.PlaylistCreator import com.github.damontecres.wholphin.services.PlaylistCreator
@ -118,6 +119,7 @@ class PlaybackViewModel
val itemPlaybackRepository: ItemPlaybackRepository, val itemPlaybackRepository: ItemPlaybackRepository,
val appPreferences: DataStore<AppPreferences>, val appPreferences: DataStore<AppPreferences>,
private val playerFactory: PlayerFactory, private val playerFactory: PlayerFactory,
private val datePlayedService: DatePlayedService,
) : ViewModel(), ) : ViewModel(),
Player.Listener { Player.Listener {
val player by lazy { val player by lazy {
@ -235,6 +237,7 @@ class PlaybackViewModel
): Boolean = ): Boolean =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
Timber.i("Playing ${item.id}") Timber.i("Playing ${item.id}")
datePlayedService.invalidate(item)
autoSkippedSegments.clear() autoSkippedSegments.clear()
if (item.type !in supportItemKinds) { if (item.type !in supportItemKinds) {
showToast( showToast(