mirror of
https://github.com/JustinZeus/Wholphin.git
synced 2026-07-08 15:50:16 +02:00
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:
parent
46415fcdd0
commit
cda5b118a3
6 changed files with 232 additions and 63 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue