Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,45 @@ data class BaseItem(

val BaseItemDto.aspectRatioFloat: Float? get() = width?.let { w -> height?.let { h -> w.toFloat() / h.toFloat() } }

/**
* Returns a copy of this item with its user data updated to reflect a just-finished local
* playback (final resume position / played). A freshly-played item has no server-side
* playedPercentage yet, so it is derived from position/runtime (the same formula the server
* uses) to keep the resume bar rendering. Returns the item unchanged if it carries no user data.
*/
fun BaseItem.withLocalPlayback(
positionTicks: Long,
played: Boolean,
): BaseItem {
val userData = data.userData ?: return this
val runTimeTicks = data.runTimeTicks
val newPercentage =
when {
played -> {
null
}

runTimeTicks != null && runTimeTicks > 0 && positionTicks > 0 -> {
positionTicks.toDouble() / runTimeTicks.toDouble() * 100.0
}

else -> {
userData.playedPercentage
}
}
return copy(
data =
data.copy(
userData =
userData.copy(
played = played,
playbackPositionTicks = positionTicks,
playedPercentage = newPercentage,
),
),
)
}

@Immutable
data class BaseItemUi(
val episodeCornerText: String?,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,17 +18,23 @@ class FavoriteWatchManager
constructor(
private val api: ApiClient,
private val datePlayedService: DatePlayedService,
private val playbackResultCache: PlaybackResultCache,
) {
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
}
val content =
if (played) {
api.playStateApi.markPlayedItem(itemId).content
} else {
api.playStateApi.markUnplayedItem(itemId).content
}
// Remember the authoritative outcome locally so a returning grid/row reflects it
// immediately instead of racing the server write we just issued.
playbackResultCache.record(itemId, content.playbackPositionTicks, content.played)
return content
}

suspend fun setFavorite(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package com.github.damontecres.wholphin.services

import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
import javax.inject.Inject
import javax.inject.Singleton

/**
* Remembers the locally-known outcome of a just-finished playback (final resume position and
* whether the item is now considered played), keyed by item id.
*
* A grid returning from playback can apply this immediately instead of re-querying the server,
* which would race the asynchronous playback-stopped report (a write-then-read race).
*/
@Singleton
class PlaybackResultCache
@Inject
constructor() {
data class Result(
val positionTicks: Long,
val played: Boolean,
)

private val results = ConcurrentHashMap<UUID, Result>()

fun record(
itemId: UUID,
positionTicks: Long,
played: Boolean,
) {
results[itemId] = Result(positionTicks, played)
}

fun take(itemId: UUID): Result? = results.remove(itemId)
}
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ import com.github.damontecres.wholphin.services.MediaManagementService
import com.github.damontecres.wholphin.services.MediaReportService
import com.github.damontecres.wholphin.services.MusicService
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.services.PlaybackResultCache
import com.github.damontecres.wholphin.services.StreamChoiceService
import com.github.damontecres.wholphin.services.ThemeSongPlayer
import com.github.damontecres.wholphin.services.UserPreferencesService
Expand Down Expand Up @@ -77,6 +78,7 @@ import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.catch
Expand Down Expand Up @@ -118,6 +120,7 @@ class CollectionFolderViewModel
val streamChoiceService: StreamChoiceService,
val mediaReportService: MediaReportService,
private val filterOptionCache: FilterOptionCache,
private val playbackResultCache: PlaybackResultCache,
@Assisted val itemId: String,
@Assisted initialSortAndDirection: SortAndDirection?,
@Assisted("recursive") private val recursive: Boolean,
Expand Down Expand Up @@ -499,6 +502,26 @@ class CollectionFolderViewModel

fun onResumePage() {
viewModelScope.launchIO {
// After returning (e.g. from playback) refresh the focused item's watched
// state immediately instead of waiting for a reload. Prefer the locally known
// playback result (race-free); fall back to a server refresh otherwise.
try {
((state.value.items as? DataLoadingState.Success)?.data as? ApiRequestPager<*>)
?.let { pager ->
(pager.getOrNull(position) as? BaseItem)?.let { item ->
val result = playbackResultCache.take(item.id)
if (result != null) {
pager.updateUserData(position, item.id, result.positionTicks, result.played)
} else {
pager.refreshItem(position, item.id)
}
}
}
} catch (ex: CancellationException) {
throw ex
} catch (ex: Exception) {
Timber.e(ex, "Error refreshing focused item on resume")
}
state.value.item.successValue?.let {
Timber.v("onResumePage: %s", state.value.items::class)
if (it.type == BaseItemKind.BOX_SET && state.value.items !is DataLoadingState.Error) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.LifecycleResumeEffect
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import com.github.damontecres.wholphin.R
Expand Down Expand Up @@ -103,8 +104,12 @@ fun HomePage(
playlistViewModel: AddPlaylistViewModel = hiltViewModel(),
) {
val context = LocalContext.current
LaunchedEffect(Unit) {
// Refresh on every resume (not just first composition) so returning from playback reflects
// the new watched/resume state, mirroring the grid's LifecycleResumeEffect. init() refreshes
// in place when rows are already loaded.
LifecycleResumeEffect(Unit) {
viewModel.init()
onPauseOrDispose { }
}
val state by viewModel.state.collectAsState()
val loading = state.loadingState
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import androidx.lifecycle.viewModelScope
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.HomeRowConfig
import com.github.damontecres.wholphin.data.model.withLocalPlayback
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.services.BackdropService
import com.github.damontecres.wholphin.services.DatePlayedService
Expand All @@ -17,6 +18,7 @@ import com.github.damontecres.wholphin.services.MediaManagementService
import com.github.damontecres.wholphin.services.MediaReportService
import com.github.damontecres.wholphin.services.NavDrawerService
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.services.PlaybackResultCache
import com.github.damontecres.wholphin.services.UserPreferencesService
import com.github.damontecres.wholphin.services.deleteItem
import com.github.damontecres.wholphin.services.tvAccess
Expand Down Expand Up @@ -64,6 +66,7 @@ class HomeViewModel
private val userPreferencesService: UserPreferencesService,
private val mediaManagementService: MediaManagementService,
private val latestNextUpService: LatestNextUpService,
private val playbackResultCache: PlaybackResultCache,
) : ViewModel() {
private val _state = MutableStateFlow(HomeState.EMPTY)
val state: StateFlow<HomeState> = _state
Expand Down Expand Up @@ -174,10 +177,13 @@ class HomeViewModel
}
Timber.v("Got row data index=%s", rowIndex)
remaining.removeIf { it.index == rowIndex }
// Patch outside _state.update: the cache take() has a side effect
// and update {} may re-run its block on concurrent updates.
val patchedRow = patchWatchingRow(rowData, playbackResultCache::take)
_state.update { state ->
val newRows =
state.homeRows.toMutableList().apply {
set(rowIndex, rowData)
set(rowIndex, patchedRow)
}
state.copy(
homeRows = newRows,
Expand All @@ -191,7 +197,7 @@ class HomeViewModel
)
}
} else {
val rows = deferred.awaitAll()
val rows = deferred.awaitAll().map { patchWatchingRow(it, playbackResultCache::take) }
Timber.v("Got all rows")
_state.update {
it.copy(
Expand Down Expand Up @@ -311,3 +317,32 @@ private fun isWatchingRow(row: HomeRowConfig) =
row is HomeRowConfig.ContinueWatching ||
row is HomeRowConfig.NextUp ||
row is HomeRowConfig.ContinueWatchingCombined

/**
* Applies locally-known playback outcomes (race-free) to a freshly fetched "continue watching"
* row: the server may not have processed the playback-stopped report yet, so a just-watched item
* would otherwise show a stale resume bar or none at all. Finished items are dropped from the row;
* partially-watched items get their resume position/percentage refreshed. Non-watching rows, null
* items, and items without a known result are returned unchanged.
*
* [takeResult] returns (and consumes) the locally-known result for an item id, or null if none.
*/
internal fun patchWatchingRow(
row: HomeRowLoadingState,
takeResult: (UUID) -> PlaybackResultCache.Result?,
): HomeRowLoadingState {
if (row !is HomeRowLoadingState.Success) return row
val rowType = row.rowType ?: return row
if (!isWatchingRow(rowType)) return row
val newItems =
row.items.flatMap { item ->
if (item == null) return@flatMap listOf<BaseItem?>(null)
val result = takeResult(item.id) ?: return@flatMap listOf<BaseItem?>(item)
if (result.played) {
emptyList()
} else {
listOf<BaseItem?>(item.withLocalPlayback(result.positionTicks, result.played))
}
}
return row.copy(items = newItems)
}
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ import com.github.damontecres.wholphin.services.DeviceProfileService
import com.github.damontecres.wholphin.services.ImageUrlService
import com.github.damontecres.wholphin.services.MusicService
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.services.PlaybackResultCache
import com.github.damontecres.wholphin.services.PlayerFactory
import com.github.damontecres.wholphin.services.PlaylistCreationResult
import com.github.damontecres.wholphin.services.PlaylistCreator
Expand Down Expand Up @@ -151,6 +152,7 @@ class PlaybackViewModel
private val imageUrlService: ImageUrlService,
private val screensaverService: ScreensaverService,
private val musicService: MusicService,
private val playbackResultCache: PlaybackResultCache,
@Assisted private val destination: Destination,
) : ViewModel(),
Player.Listener,
Expand Down Expand Up @@ -211,6 +213,18 @@ class PlaybackViewModel
player.removeListener(this@PlaybackViewModel)
(player as? ExoPlayer)?.removeAnalyticsListener(this@PlaybackViewModel)

// Remember the outcome locally (final resume position / played) so a returning
// grid can update instantly instead of racing the async playback-stopped report.
if (this@PlaybackViewModel::itemId.isInitialized) {
val pos = player.currentPosition
val dur = player.duration
if (dur > 0 && pos > 0) {
val played = pos >= dur * 0.9
val resumeTicks = if (played) 0L else pos * 10_000L
playbackResultCache.record(itemId, resumeTicks, played)
}
}

this@PlaybackViewModel.activityListener?.let {
it.release()
player.removeListener(it)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.github.damontecres.wholphin.util

import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.withLocalPlayback
import com.github.damontecres.wholphin.ui.DEFAULT_PAGE_SIZE
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.sync.withLock
Expand Down Expand Up @@ -90,6 +91,32 @@ class ApiRequestPager<T>(
}
}

/**
* Update a single cached item's user data locally (played / resume position) without
* re-querying the server, used when the client already knows the outcome of playback.
* Avoids the write(report)-then-read(refresh) race against the server.
*/
suspend fun updateUserData(
position: Int,
itemId: UUID,
positionTicks: Long,
played: Boolean,
) {
mutex.withLock {
val pageNumber = position / pageSize
val index = position - pageNumber * pageSize
val page = cachedPages.getIfPresent(pageNumber)
if (page != null && index in page.indices) {
val existing = page[index]
if (existing.id == itemId && existing.data.userData != null) {
page[index] = existing.withLocalPlayback(positionTicks, played)
cachedPages.put(pageNumber, page)
items = ItemList(size, pageSize, cachedPages.asMap())
}
}
}
}

/**
* Dumps the cache for all the pages at or after the given position and fetches a new page
*/
Expand Down
Loading