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

@ -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(