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,
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<T> {
},
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<T> {
},
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<T> {
},
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<T> {
},
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<T> {
},
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<T> {
},
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<T> {
},
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<T> {
},
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<T> {
},
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,

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.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
}
}

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.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)
}

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.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>(LoadingState.Pending)
val refreshState = MutableLiveData<LoadingState>(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<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(
itemId: UUID,
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.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<AppPreferences>,
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(