Skip to content

Commit f4252fb

Browse files
committed
Apply locally-known watched/resume state on return instead of racing the server
Derive the resume percentage locally for freshly-played items, refresh the home rows on resume, and remember manual watched toggles, so grids and the continue-watching row reflect the new state immediately rather than racing the asynchronous server playback-stopped report.
1 parent 2afac3a commit f4252fb

10 files changed

Lines changed: 457 additions & 8 deletions

File tree

app/src/main/java/com/github/damontecres/wholphin/data/model/BaseItem.kt

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -281,6 +281,45 @@ data class BaseItem(
281281

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

284+
/**
285+
* Returns a copy of this item with its user data updated to reflect a just-finished local
286+
* playback (final resume position / played). A freshly-played item has no server-side
287+
* playedPercentage yet, so it is derived from position/runtime (the same formula the server
288+
* uses) to keep the resume bar rendering. Returns the item unchanged if it carries no user data.
289+
*/
290+
fun BaseItem.withLocalPlayback(
291+
positionTicks: Long,
292+
played: Boolean,
293+
): BaseItem {
294+
val userData = data.userData ?: return this
295+
val runTimeTicks = data.runTimeTicks
296+
val newPercentage =
297+
when {
298+
played -> {
299+
null
300+
}
301+
302+
runTimeTicks != null && runTimeTicks > 0 && positionTicks > 0 -> {
303+
positionTicks.toDouble() / runTimeTicks.toDouble() * 100.0
304+
}
305+
306+
else -> {
307+
userData.playedPercentage
308+
}
309+
}
310+
return copy(
311+
data =
312+
data.copy(
313+
userData =
314+
userData.copy(
315+
played = played,
316+
playbackPositionTicks = positionTicks,
317+
playedPercentage = newPercentage,
318+
),
319+
),
320+
)
321+
}
322+
284323
@Immutable
285324
data class BaseItemUi(
286325
val episodeCornerText: String?,

app/src/main/java/com/github/damontecres/wholphin/services/FavoriteWatchManager.kt

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,17 +18,23 @@ class FavoriteWatchManager
1818
constructor(
1919
private val api: ApiClient,
2020
private val datePlayedService: DatePlayedService,
21+
private val playbackResultCache: PlaybackResultCache,
2122
) {
2223
suspend fun setWatched(
2324
itemId: UUID,
2425
played: Boolean,
2526
): UserItemDataDto {
2627
datePlayedService.invalidate(itemId)
27-
return if (played) {
28-
api.playStateApi.markPlayedItem(itemId).content
29-
} else {
30-
api.playStateApi.markUnplayedItem(itemId).content
31-
}
28+
val content =
29+
if (played) {
30+
api.playStateApi.markPlayedItem(itemId).content
31+
} else {
32+
api.playStateApi.markUnplayedItem(itemId).content
33+
}
34+
// Remember the authoritative outcome locally so a returning grid/row reflects it
35+
// immediately instead of racing the server write we just issued.
36+
playbackResultCache.record(itemId, content.playbackPositionTicks, content.played)
37+
return content
3238
}
3339

3440
suspend fun setFavorite(
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
package com.github.damontecres.wholphin.services
2+
3+
import java.util.UUID
4+
import java.util.concurrent.ConcurrentHashMap
5+
import javax.inject.Inject
6+
import javax.inject.Singleton
7+
8+
/**
9+
* Remembers the locally-known outcome of a just-finished playback (final resume position and
10+
* whether the item is now considered played), keyed by item id.
11+
*
12+
* A grid returning from playback can apply this immediately instead of re-querying the server,
13+
* which would race the asynchronous playback-stopped report (a write-then-read race).
14+
*/
15+
@Singleton
16+
class PlaybackResultCache
17+
@Inject
18+
constructor() {
19+
data class Result(
20+
val positionTicks: Long,
21+
val played: Boolean,
22+
)
23+
24+
private val results = ConcurrentHashMap<UUID, Result>()
25+
26+
fun record(
27+
itemId: UUID,
28+
positionTicks: Long,
29+
played: Boolean,
30+
) {
31+
results[itemId] = Result(positionTicks, played)
32+
}
33+
34+
fun take(itemId: UUID): Result? = results.remove(itemId)
35+
}

app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderView.kt

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ import com.github.damontecres.wholphin.services.MediaManagementService
4949
import com.github.damontecres.wholphin.services.MediaReportService
5050
import com.github.damontecres.wholphin.services.MusicService
5151
import com.github.damontecres.wholphin.services.NavigationManager
52+
import com.github.damontecres.wholphin.services.PlaybackResultCache
5253
import com.github.damontecres.wholphin.services.StreamChoiceService
5354
import com.github.damontecres.wholphin.services.ThemeSongPlayer
5455
import com.github.damontecres.wholphin.services.UserPreferencesService
@@ -77,6 +78,7 @@ import dagger.assisted.AssistedFactory
7778
import dagger.assisted.AssistedInject
7879
import dagger.hilt.android.lifecycle.HiltViewModel
7980
import dagger.hilt.android.qualifiers.ApplicationContext
81+
import kotlinx.coroutines.CancellationException
8082
import kotlinx.coroutines.flow.MutableStateFlow
8183
import kotlinx.coroutines.flow.StateFlow
8284
import kotlinx.coroutines.flow.catch
@@ -118,6 +120,7 @@ class CollectionFolderViewModel
118120
val streamChoiceService: StreamChoiceService,
119121
val mediaReportService: MediaReportService,
120122
private val filterOptionCache: FilterOptionCache,
123+
private val playbackResultCache: PlaybackResultCache,
121124
@Assisted val itemId: String,
122125
@Assisted initialSortAndDirection: SortAndDirection?,
123126
@Assisted("recursive") private val recursive: Boolean,
@@ -499,6 +502,26 @@ class CollectionFolderViewModel
499502

500503
fun onResumePage() {
501504
viewModelScope.launchIO {
505+
// After returning (e.g. from playback) refresh the focused item's watched
506+
// state immediately instead of waiting for a reload. Prefer the locally known
507+
// playback result (race-free); fall back to a server refresh otherwise.
508+
try {
509+
((state.value.items as? DataLoadingState.Success)?.data as? ApiRequestPager<*>)
510+
?.let { pager ->
511+
(pager.getOrNull(position) as? BaseItem)?.let { item ->
512+
val result = playbackResultCache.take(item.id)
513+
if (result != null) {
514+
pager.updateUserData(position, item.id, result.positionTicks, result.played)
515+
} else {
516+
pager.refreshItem(position, item.id)
517+
}
518+
}
519+
}
520+
} catch (ex: CancellationException) {
521+
throw ex
522+
} catch (ex: Exception) {
523+
Timber.e(ex, "Error refreshing focused item on resume")
524+
}
502525
state.value.item.successValue?.let {
503526
Timber.v("onResumePage: %s", state.value.items::class)
504527
if (it.type == BaseItemKind.BOX_SET && state.value.items !is DataLoadingState.Error) {

app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ import androidx.compose.ui.unit.Dp
4444
import androidx.compose.ui.unit.DpSize
4545
import androidx.compose.ui.unit.dp
4646
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
47+
import androidx.lifecycle.compose.LifecycleResumeEffect
4748
import androidx.tv.material3.MaterialTheme
4849
import androidx.tv.material3.Text
4950
import com.github.damontecres.wholphin.R
@@ -103,8 +104,12 @@ fun HomePage(
103104
playlistViewModel: AddPlaylistViewModel = hiltViewModel(),
104105
) {
105106
val context = LocalContext.current
106-
LaunchedEffect(Unit) {
107+
// Refresh on every resume (not just first composition) so returning from playback reflects
108+
// the new watched/resume state, mirroring the grid's LifecycleResumeEffect. init() refreshes
109+
// in place when rows are already loaded.
110+
LifecycleResumeEffect(Unit) {
107111
viewModel.init()
112+
onPauseOrDispose { }
108113
}
109114
val state by viewModel.state.collectAsState()
110115
val loading = state.loadingState

app/src/main/java/com/github/damontecres/wholphin/ui/main/HomeViewModel.kt

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import androidx.lifecycle.viewModelScope
66
import com.github.damontecres.wholphin.data.ServerRepository
77
import com.github.damontecres.wholphin.data.model.BaseItem
88
import com.github.damontecres.wholphin.data.model.HomeRowConfig
9+
import com.github.damontecres.wholphin.data.model.withLocalPlayback
910
import com.github.damontecres.wholphin.preferences.AppPreferences
1011
import com.github.damontecres.wholphin.services.BackdropService
1112
import com.github.damontecres.wholphin.services.DatePlayedService
@@ -17,6 +18,7 @@ import com.github.damontecres.wholphin.services.MediaManagementService
1718
import com.github.damontecres.wholphin.services.MediaReportService
1819
import com.github.damontecres.wholphin.services.NavDrawerService
1920
import com.github.damontecres.wholphin.services.NavigationManager
21+
import com.github.damontecres.wholphin.services.PlaybackResultCache
2022
import com.github.damontecres.wholphin.services.UserPreferencesService
2123
import com.github.damontecres.wholphin.services.deleteItem
2224
import com.github.damontecres.wholphin.services.tvAccess
@@ -64,6 +66,7 @@ class HomeViewModel
6466
private val userPreferencesService: UserPreferencesService,
6567
private val mediaManagementService: MediaManagementService,
6668
private val latestNextUpService: LatestNextUpService,
69+
private val playbackResultCache: PlaybackResultCache,
6770
) : ViewModel() {
6871
private val _state = MutableStateFlow(HomeState.EMPTY)
6972
val state: StateFlow<HomeState> = _state
@@ -174,10 +177,13 @@ class HomeViewModel
174177
}
175178
Timber.v("Got row data index=%s", rowIndex)
176179
remaining.removeIf { it.index == rowIndex }
180+
// Patch outside _state.update: the cache take() has a side effect
181+
// and update {} may re-run its block on concurrent updates.
182+
val patchedRow = patchWatchingRow(rowData, playbackResultCache::take)
177183
_state.update { state ->
178184
val newRows =
179185
state.homeRows.toMutableList().apply {
180-
set(rowIndex, rowData)
186+
set(rowIndex, patchedRow)
181187
}
182188
state.copy(
183189
homeRows = newRows,
@@ -191,7 +197,7 @@ class HomeViewModel
191197
)
192198
}
193199
} else {
194-
val rows = deferred.awaitAll()
200+
val rows = deferred.awaitAll().map { patchWatchingRow(it, playbackResultCache::take) }
195201
Timber.v("Got all rows")
196202
_state.update {
197203
it.copy(
@@ -311,3 +317,32 @@ private fun isWatchingRow(row: HomeRowConfig) =
311317
row is HomeRowConfig.ContinueWatching ||
312318
row is HomeRowConfig.NextUp ||
313319
row is HomeRowConfig.ContinueWatchingCombined
320+
321+
/**
322+
* Applies locally-known playback outcomes (race-free) to a freshly fetched "continue watching"
323+
* row: the server may not have processed the playback-stopped report yet, so a just-watched item
324+
* would otherwise show a stale resume bar or none at all. Finished items are dropped from the row;
325+
* partially-watched items get their resume position/percentage refreshed. Non-watching rows, null
326+
* items, and items without a known result are returned unchanged.
327+
*
328+
* [takeResult] returns (and consumes) the locally-known result for an item id, or null if none.
329+
*/
330+
internal fun patchWatchingRow(
331+
row: HomeRowLoadingState,
332+
takeResult: (UUID) -> PlaybackResultCache.Result?,
333+
): HomeRowLoadingState {
334+
if (row !is HomeRowLoadingState.Success) return row
335+
val rowType = row.rowType ?: return row
336+
if (!isWatchingRow(rowType)) return row
337+
val newItems =
338+
row.items.flatMap { item ->
339+
if (item == null) return@flatMap listOf<BaseItem?>(null)
340+
val result = takeResult(item.id) ?: return@flatMap listOf<BaseItem?>(item)
341+
if (result.played) {
342+
emptyList()
343+
} else {
344+
listOf<BaseItem?>(item.withLocalPlayback(result.positionTicks, result.played))
345+
}
346+
}
347+
return row.copy(items = newItems)
348+
}

app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ import com.github.damontecres.wholphin.services.DeviceProfileService
4545
import com.github.damontecres.wholphin.services.ImageUrlService
4646
import com.github.damontecres.wholphin.services.MusicService
4747
import com.github.damontecres.wholphin.services.NavigationManager
48+
import com.github.damontecres.wholphin.services.PlaybackResultCache
4849
import com.github.damontecres.wholphin.services.PlayerFactory
4950
import com.github.damontecres.wholphin.services.PlaylistCreationResult
5051
import com.github.damontecres.wholphin.services.PlaylistCreator
@@ -151,6 +152,7 @@ class PlaybackViewModel
151152
private val imageUrlService: ImageUrlService,
152153
private val screensaverService: ScreensaverService,
153154
private val musicService: MusicService,
155+
private val playbackResultCache: PlaybackResultCache,
154156
@Assisted private val destination: Destination,
155157
) : ViewModel(),
156158
Player.Listener,
@@ -207,6 +209,18 @@ class PlaybackViewModel
207209
player.removeListener(this@PlaybackViewModel)
208210
(player as? ExoPlayer)?.removeAnalyticsListener(this@PlaybackViewModel)
209211

212+
// Remember the outcome locally (final resume position / played) so a returning
213+
// grid can update instantly instead of racing the async playback-stopped report.
214+
if (this@PlaybackViewModel::itemId.isInitialized) {
215+
val pos = player.currentPosition
216+
val dur = player.duration
217+
if (dur > 0 && pos > 0) {
218+
val played = pos >= dur * 0.9
219+
val resumeTicks = if (played) 0L else pos * 10_000L
220+
playbackResultCache.record(itemId, resumeTicks, played)
221+
}
222+
}
223+
210224
this@PlaybackViewModel.activityListener?.let {
211225
it.release()
212226
player.removeListener(it)

app/src/main/java/com/github/damontecres/wholphin/util/ApiRequestPager.kt

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package com.github.damontecres.wholphin.util
22

33
import com.github.damontecres.wholphin.data.model.BaseItem
4+
import com.github.damontecres.wholphin.data.model.withLocalPlayback
45
import com.github.damontecres.wholphin.ui.DEFAULT_PAGE_SIZE
56
import kotlinx.coroutines.CoroutineScope
67
import kotlinx.coroutines.sync.withLock
@@ -87,6 +88,32 @@ class ApiRequestPager<T>(
8788
}
8889
}
8990

91+
/**
92+
* Update a single cached item's user data locally (played / resume position) without
93+
* re-querying the server, used when the client already knows the outcome of playback.
94+
* Avoids the write(report)-then-read(refresh) race against the server.
95+
*/
96+
suspend fun updateUserData(
97+
position: Int,
98+
itemId: UUID,
99+
positionTicks: Long,
100+
played: Boolean,
101+
) {
102+
mutex.withLock {
103+
val pageNumber = position / pageSize
104+
val index = position - pageNumber * pageSize
105+
val page = cachedPages.getIfPresent(pageNumber)
106+
if (page != null && index in page.indices) {
107+
val existing = page[index]
108+
if (existing.id == itemId && existing.data.userData != null) {
109+
page[index] = existing.withLocalPlayback(positionTicks, played)
110+
cachedPages.put(pageNumber, page)
111+
items = ItemList(size, pageSize, cachedPages.asMap())
112+
}
113+
}
114+
}
115+
}
116+
90117
/**
91118
* Dumps the cache for all the pages at or after the given position and fetches a new page
92119
*/

0 commit comments

Comments
 (0)