diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/BaseItem.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/BaseItem.kt index a5526f87d..f2467052f 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/model/BaseItem.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/BaseItem.kt @@ -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?, diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/FavoriteWatchManager.kt b/app/src/main/java/com/github/damontecres/wholphin/services/FavoriteWatchManager.kt index 93b3ebe40..f87196394 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/FavoriteWatchManager.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/FavoriteWatchManager.kt @@ -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( diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/PlaybackResultCache.kt b/app/src/main/java/com/github/damontecres/wholphin/services/PlaybackResultCache.kt new file mode 100644 index 000000000..f460fd9a2 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/services/PlaybackResultCache.kt @@ -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() + + fun record( + itemId: UUID, + positionTicks: Long, + played: Boolean, + ) { + results[itemId] = Result(positionTicks, played) + } + + fun take(itemId: UUID): Result? = results.remove(itemId) + } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderView.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderView.kt index dd6ed7cee..b54d8e954 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderView.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderView.kt @@ -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 @@ -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 @@ -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, @@ -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) { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt index afaaac561..3c4055992 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt @@ -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 @@ -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 diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomeViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomeViewModel.kt index feaf8d04e..caf55427b 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomeViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomeViewModel.kt @@ -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 @@ -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 @@ -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 = _state @@ -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, @@ -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( @@ -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(null) + val result = takeResult(item.id) ?: return@flatMap listOf(item) + if (result.played) { + emptyList() + } else { + listOf(item.withLocalPlayback(result.positionTicks, result.played)) + } + } + return row.copy(items = newItems) +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt index 37d3e5ad1..4b501601e 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt @@ -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 @@ -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, @@ -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) diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/ApiRequestPager.kt b/app/src/main/java/com/github/damontecres/wholphin/util/ApiRequestPager.kt index 555a5a511..2ff447220 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/util/ApiRequestPager.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/util/ApiRequestPager.kt @@ -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 @@ -90,6 +91,32 @@ class ApiRequestPager( } } + /** + * 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 */ diff --git a/app/src/test/java/com/github/damontecres/wholphin/data/model/WithLocalPlaybackTests.kt b/app/src/test/java/com/github/damontecres/wholphin/data/model/WithLocalPlaybackTests.kt new file mode 100644 index 000000000..3f28638c4 --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/data/model/WithLocalPlaybackTests.kt @@ -0,0 +1,118 @@ +package com.github.damontecres.wholphin.data.model + +import org.jellyfin.sdk.model.UUID +import org.jellyfin.sdk.model.api.BaseItemDto +import org.jellyfin.sdk.model.api.BaseItemKind +import org.jellyfin.sdk.model.api.UserItemDataDto +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Tests for [withLocalPlayback], the pure helper that updates an item's user data to reflect a + * just-finished local playback. The resume bar is driven by playedPercentage, which the server has + * not computed yet for a freshly-played item, so it must be derived from position/runtime here. + * + * Scope: these are pure unit tests covering the decision logic only. The end-to-end wiring — the + * lifecycle resume trigger that re-applies this on return, recording the outcome on playback/toggle, + * and the card actually rendering the bar — is not covered here and can only be verified by an + * instrumented/integration test (emulator + a fake data layer). + */ +class WithLocalPlaybackTests { + /** + * @param rt runtime ticks of the item, @param pos new playback position, @param played whether + * playback finished, @param existing the item's prior playedPercentage, @param expected the + * resulting playedPercentage (null = none/cleared). + */ + private data class Case( + val rt: Long?, + val pos: Long, + val played: Boolean, + val existing: Double?, + val expected: Double?, + ) + + @Test + fun `playback updates position, played and a derived percentage`() { + listOf( + Case(rt = 10_000L, pos = 2_500L, played = false, existing = null, expected = 25.0), + Case(rt = 10_000L, pos = 5_000L, played = false, existing = 10.0, expected = 50.0), + Case(rt = 10_000L, pos = 0L, played = true, existing = 80.0, expected = null), + Case(rt = null, pos = 1_000L, played = false, existing = 42.0, expected = 42.0), + Case(rt = 10_000L, pos = 0L, played = false, existing = 42.0, expected = 42.0), + ).forEach { c -> + val userData = + item(runTimeTicks = c.rt, playedPercentage = c.existing) + .withLocalPlayback(c.pos, c.played) + .data.userData!! + + assertEquals("case=$c played", c.played, userData.played) + assertEquals("case=$c position", c.pos, userData.playbackPositionTicks) + if (c.expected == null) { + assertNull("case=$c percentage", userData.playedPercentage) + } else { + assertEquals("case=$c percentage", c.expected, userData.playedPercentage!!, EPS) + } + } + } + + @Test + fun `item without user data is returned unchanged`() { + val original = item(runTimeTicks = 10_000L, withUserData = false) + val result = original.withLocalPlayback(1_000L, false) + + assertNull(result.data.userData) + assertSame(original, result) + } + + @Test + fun `unrelated user data fields are preserved`() { + val result = + item(runTimeTicks = 10_000L, playedPercentage = null, isFavorite = true, playCount = 3) + .withLocalPlayback(2_500L, false) + + val userData = result.data.userData!! + assertTrue(userData.isFavorite) + assertEquals(3, userData.playCount) + } + + private fun item( + runTimeTicks: Long? = null, + positionTicks: Long = 0L, + played: Boolean = false, + playedPercentage: Double? = null, + isFavorite: Boolean = false, + playCount: Int = 0, + withUserData: Boolean = true, + ): BaseItem { + val id = UUID.randomUUID() + val userData = + if (withUserData) { + UserItemDataDto( + playedPercentage = playedPercentage, + playbackPositionTicks = positionTicks, + playCount = playCount, + isFavorite = isFavorite, + played = played, + key = "key", + itemId = id, + ) + } else { + null + } + return BaseItem( + BaseItemDto( + id = id, + type = BaseItemKind.MOVIE, + runTimeTicks = runTimeTicks, + userData = userData, + ), + ) + } + + companion object { + private const val EPS = 0.0001 + } +} diff --git a/app/src/test/java/com/github/damontecres/wholphin/ui/main/PatchWatchingRowTests.kt b/app/src/test/java/com/github/damontecres/wholphin/ui/main/PatchWatchingRowTests.kt new file mode 100644 index 000000000..7bb60f5de --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/ui/main/PatchWatchingRowTests.kt @@ -0,0 +1,147 @@ +package com.github.damontecres.wholphin.ui.main + +import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.data.model.HomeRowConfig +import com.github.damontecres.wholphin.services.PlaybackResultCache +import com.github.damontecres.wholphin.ui.util.EmptyStringProvider +import com.github.damontecres.wholphin.util.HomeRowLoadingState +import org.jellyfin.sdk.model.UUID +import org.jellyfin.sdk.model.api.BaseItemDto +import org.jellyfin.sdk.model.api.BaseItemKind +import org.jellyfin.sdk.model.api.UserItemDataDto +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Tests for [patchWatchingRow], which applies locally-known playback outcomes to a freshly fetched + * "continue watching" row so it does not race the asynchronous server playback-stopped report. + * + * Scope: these are pure unit tests covering the decision logic only. The end-to-end wiring — the + * lifecycle resume trigger that calls the refresh, recording the outcome on playback/toggle, and the + * card actually rendering the result — is not covered here and can only be verified by an + * instrumented/integration test (emulator + a fake data layer). + */ +class PatchWatchingRowTests { + @Test + fun `non-watching row is returned unchanged`() { + val row = successRow(rowType = HomeRowConfig.RecentlyAdded(UUID.randomUUID()), items = listOf(item())) + + val result = patchWatchingRow(row) { error("takeResult must not be called for a non-watching row") } + + assertSame(row, result) + } + + @Test + fun `item without a known result is kept as is`() { + val original = item() + val row = watchingRow(listOf(original)) + + val result = patchWatchingRow(row) { null } as HomeRowLoadingState.Success + + assertEquals(1, result.items.size) + assertSame(original, result.items[0]) + } + + @Test + fun `null item is preserved`() { + val row = watchingRow(listOf(null)) + + val result = patchWatchingRow(row) { null } as HomeRowLoadingState.Success + + assertEquals(1, result.items.size) + assertNull(result.items[0]) + } + + @Test + fun `finished item is dropped from the row`() { + val finished = item(runTimeTicks = 10_000L) + val row = watchingRow(listOf(finished)) + val results = mapOf(finished.id to PlaybackResultCache.Result(0L, played = true)) + + val result = patchWatchingRow(row) { results[it] } as HomeRowLoadingState.Success + + assertTrue(result.items.isEmpty()) + } + + @Test + fun `mixed row drops finished, patches partial, keeps the rest in order`() { + val partial = item(runTimeTicks = 10_000L) + val finished = item(runTimeTicks = 10_000L) + val untouched = item(runTimeTicks = 10_000L) + val row = watchingRow(listOf(partial, finished, untouched)) + val results = + mapOf( + partial.id to PlaybackResultCache.Result(5_000L, played = false), + finished.id to PlaybackResultCache.Result(0L, played = true), + ) + + val result = patchWatchingRow(row) { results[it] } as HomeRowLoadingState.Success + + assertEquals(2, result.items.size) + assertEquals(partial.id, result.items[0]!!.id) + assertEquals( + 50.0, + result.items[0]!! + .data.userData!! + .playedPercentage!!, + 0.0001, + ) + assertSame(untouched, result.items[1]) + } + + @Test + fun `each item result is consumed exactly once and in order`() { + // Production relies on take() being one-shot: every item is queried exactly once. + val partial = item(runTimeTicks = 10_000L) + val finished = item(runTimeTicks = 10_000L) + val row = watchingRow(listOf(partial, finished)) + val queried = mutableListOf() + val results = + mapOf( + partial.id to PlaybackResultCache.Result(2_500L, played = false), + finished.id to PlaybackResultCache.Result(0L, played = true), + ) + + patchWatchingRow(row) { id -> + queried.add(id) + results[id] + } + + assertEquals(listOf(partial.id, finished.id), queried) + } + + private fun watchingRow(items: List) = successRow(rowType = HomeRowConfig.ContinueWatching(), items = items) + + private fun successRow( + rowType: HomeRowConfig?, + items: List, + ) = HomeRowLoadingState.Success( + title = EmptyStringProvider, + items = items, + rowType = rowType, + ) + + private fun item(runTimeTicks: Long? = null): BaseItem { + val id = UUID.randomUUID() + return BaseItem( + BaseItemDto( + id = id, + type = BaseItemKind.MOVIE, + runTimeTicks = runTimeTicks, + userData = + UserItemDataDto( + playedPercentage = null, + playbackPositionTicks = 0L, + playCount = 0, + isFavorite = false, + played = false, + key = "key", + itemId = id, + ), + ), + ) + } +} diff --git a/app/src/test/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModelTests.kt b/app/src/test/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModelTests.kt index d2d824db6..2be102557 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModelTests.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModelTests.kt @@ -22,6 +22,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.PlayerCreation import com.github.damontecres.wholphin.services.PlayerFactory import com.github.damontecres.wholphin.services.PlaylistCreationResult @@ -127,6 +128,7 @@ class PlaybackViewModelTests { imageUrlService = mockImageUrlService, screensaverService = mockScreensaverService, musicService = mockMusicService, + playbackResultCache = PlaybackResultCache(), destination = destination, )