Fix TV episode navigation and live resume freshness
This commit is contained in:
parent
54c23e076a
commit
8e40ac0869
34 changed files with 793 additions and 101 deletions
|
|
@ -24,6 +24,13 @@ class NavigationManager
|
||||||
log()
|
log()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Replace only the current page, preserving every parent entry. */
|
||||||
|
fun replaceTop(destination: Destination) {
|
||||||
|
if (backStack.isEmpty()) backStack.add(destination)
|
||||||
|
else backStack[backStack.lastIndex] = destination
|
||||||
|
log()
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Go to the specified [Destination], but reset the back stack to Home first
|
* Go to the specified [Destination], but reset the back stack to Home first
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -82,9 +82,11 @@ class PlayExternalViewModel
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
val launched = savedStateHandle.getMutableStateFlow("launched", false)
|
val launched = savedStateHandle.getMutableStateFlow("launched", false)
|
||||||
val state = MutableStateFlow(PlayExternalState())
|
val state = MutableStateFlow(PlayExternalState())
|
||||||
|
private var watchScope: String? = null
|
||||||
|
|
||||||
fun init(destination: Destination) {
|
fun init(destination: Destination) {
|
||||||
Timber.v("init called: %s", destination)
|
Timber.v("init called: %s", destination)
|
||||||
|
watchScope = "${serverRepository.currentServer?.id}:${serverRepository.currentUser?.id}"
|
||||||
state.update { it.copy(loading = LoadingState.Loading) }
|
state.update { it.copy(loading = LoadingState.Loading) }
|
||||||
viewModelScope.launchDefault {
|
viewModelScope.launchDefault {
|
||||||
val prefs = userPreferencesService.getCurrent()
|
val prefs = userPreferencesService.getCurrent()
|
||||||
|
|
@ -358,6 +360,7 @@ class PlayExternalViewModel
|
||||||
failed = false,
|
failed = false,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
watchScope?.let { com.github.damontecres.wholphin.util.PlaybackWatchState.stopped(context, it) }
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Timber.w(
|
Timber.w(
|
||||||
|
|
|
||||||
|
|
@ -830,10 +830,12 @@ class PlaybackViewModel
|
||||||
}
|
}
|
||||||
|
|
||||||
val playbackItemState = PlaybackItemState(playback)
|
val playbackItemState = PlaybackItemState(playback)
|
||||||
|
val watchScope = "${serverRepository.currentServer?.id}:${serverRepository.currentUser?.id}"
|
||||||
val activityListener =
|
val activityListener =
|
||||||
TrackActivityPlaybackListener(
|
TrackActivityPlaybackListener(
|
||||||
api = api,
|
api = api,
|
||||||
player = player,
|
player = player,
|
||||||
|
onStopped = { com.github.damontecres.wholphin.util.PlaybackWatchState.stopped(context, watchScope) },
|
||||||
getState = { playbackItemState },
|
getState = { playbackItemState },
|
||||||
)
|
)
|
||||||
player.addListener(activityListener)
|
player.addListener(activityListener)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
package com.github.damontecres.wholphin.util
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import org.justinvisser.wolfskin.conductarr.FileCacheStore
|
||||||
|
import java.io.File
|
||||||
|
import java.io.IOException
|
||||||
|
import timber.log.Timber
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
|
||||||
|
/** Only an acknowledged stop invalidates the captured server/user's watch projections. */
|
||||||
|
object PlaybackWatchState {
|
||||||
|
private val mutableRevisions = MutableStateFlow<Map<String, Long>>(emptyMap())
|
||||||
|
val revisions = mutableRevisions.asStateFlow()
|
||||||
|
|
||||||
|
/** Remove persisted watch grids before publishing the captured user's acknowledgement. */
|
||||||
|
fun stopped(context: Context, scope: String) {
|
||||||
|
try {
|
||||||
|
FileCacheStore(File(context.filesDir, "wolfskin-library-cache")).clear(scope)
|
||||||
|
} catch (exception: IOException) {
|
||||||
|
Timber.w(exception, "Could not invalidate playback watch snapshots")
|
||||||
|
}
|
||||||
|
val time = System.currentTimeMillis()
|
||||||
|
mutableRevisions.update { previous -> previous + (scope to maxOf(time, (previous[scope] ?: 0L) + 1L)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -7,6 +7,8 @@ import com.github.damontecres.wholphin.ui.launchIO
|
||||||
import com.github.damontecres.wholphin.ui.playback.CurrentPlayback
|
import com.github.damontecres.wholphin.ui.playback.CurrentPlayback
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
import org.jellyfin.sdk.api.client.ApiClient
|
import org.jellyfin.sdk.api.client.ApiClient
|
||||||
import org.jellyfin.sdk.api.client.extensions.playStateApi
|
import org.jellyfin.sdk.api.client.extensions.playStateApi
|
||||||
import org.jellyfin.sdk.model.api.PlayMethod
|
import org.jellyfin.sdk.model.api.PlayMethod
|
||||||
|
|
@ -31,9 +33,12 @@ import kotlin.time.Duration.Companion.seconds
|
||||||
class TrackActivityPlaybackListener(
|
class TrackActivityPlaybackListener(
|
||||||
private val api: ApiClient,
|
private val api: ApiClient,
|
||||||
private val player: Player,
|
private val player: Player,
|
||||||
|
private val onStopped: () -> Unit = {},
|
||||||
private val getState: () -> PlaybackItemState?,
|
private val getState: () -> PlaybackItemState?,
|
||||||
) : Player.Listener {
|
) : Player.Listener {
|
||||||
private val coroutineScope = CoroutineScope(WholphinDispatchers.Main)
|
private val coroutineScope = CoroutineScope(WholphinDispatchers.Main)
|
||||||
|
private val reports = Mutex()
|
||||||
|
@Volatile private var released = false
|
||||||
private val task: TimerTask =
|
private val task: TimerTask =
|
||||||
object : TimerTask() {
|
object : TimerTask() {
|
||||||
override fun run() {
|
override fun run() {
|
||||||
|
|
@ -70,19 +75,23 @@ class TrackActivityPlaybackListener(
|
||||||
|
|
||||||
val delay = 5.seconds.inWholeMilliseconds
|
val delay = 5.seconds.inWholeMilliseconds
|
||||||
// Every x seconds, check if the video is playing
|
// Every x seconds, check if the video is playing
|
||||||
TIMER.schedule(task, delay, delay)
|
if (!released) {
|
||||||
initialized = true
|
TIMER.schedule(task, delay, delay)
|
||||||
|
initialized = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun release() {
|
fun release() {
|
||||||
// player.removeListener(this)
|
if (released) return
|
||||||
|
released = true
|
||||||
task.cancel()
|
task.cancel()
|
||||||
TIMER.purge()
|
TIMER.purge()
|
||||||
val position = player.currentPosition.milliseconds
|
val position = player.currentPosition.milliseconds
|
||||||
|
val stoppedState = getState.invoke()
|
||||||
launch("reportPlaybackStopped") {
|
launch("reportPlaybackStopped") {
|
||||||
getState.invoke()?.let { state ->
|
stoppedState?.let { state ->
|
||||||
Timber.v("reportPlaybackStopped for ${state.itemId} at $position")
|
Timber.v("reportPlaybackStopped for ${state.itemId} at $position")
|
||||||
if (position < Duration.ZERO) {
|
if (position < Duration.ZERO) {
|
||||||
Timber.w("Negative position when reporting playback stopped: %s", position)
|
Timber.w("Negative position when reporting playback stopped: %s", position)
|
||||||
|
|
@ -96,6 +105,7 @@ class TrackActivityPlaybackListener(
|
||||||
liveStreamId = state.liveStreamId,
|
liveStreamId = state.liveStreamId,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
onStopped()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -117,6 +127,7 @@ class TrackActivityPlaybackListener(
|
||||||
|
|
||||||
private fun saveActivity(position: Long) {
|
private fun saveActivity(position: Long) {
|
||||||
launch("saveActivity") {
|
launch("saveActivity") {
|
||||||
|
if (released) return@launch
|
||||||
getState.invoke()?.let { state ->
|
getState.invoke()?.let { state ->
|
||||||
val calcPosition =
|
val calcPosition =
|
||||||
withContext(WholphinDispatchers.Main) {
|
withContext(WholphinDispatchers.Main) {
|
||||||
|
|
@ -152,7 +163,7 @@ class TrackActivityPlaybackListener(
|
||||||
) {
|
) {
|
||||||
coroutineScope.launchIO {
|
coroutineScope.launchIO {
|
||||||
try {
|
try {
|
||||||
block.invoke(this)
|
reports.withLock { block.invoke(this) }
|
||||||
} catch (ex: Exception) {
|
} catch (ex: Exception) {
|
||||||
Timber.w(ex, "Exception during %s", name)
|
Timber.w(ex, "Exception during %s", name)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -114,6 +114,7 @@ class PlayExternalViewModelTest {
|
||||||
every { api.subtitleApi } returns subtitleApi
|
every { api.subtitleApi } returns subtitleApi
|
||||||
every { api.playStateApi } returns playStateApi
|
every { api.playStateApi } returns playStateApi
|
||||||
every { serverRepository.currentUser } returns null
|
every { serverRepository.currentUser } returns null
|
||||||
|
every { serverRepository.currentServer } returns null
|
||||||
coEvery { userLibraryApi.getItem(movie.id) } returns successResponse(movie)
|
coEvery { userLibraryApi.getItem(movie.id) } returns successResponse(movie)
|
||||||
coEvery { streamChoiceService.chooseSource(any(), any()) } returns mediaSource
|
coEvery { streamChoiceService.chooseSource(any(), any()) } returns mediaSource
|
||||||
coEvery { streamChoiceService.chooseSubtitleStream(any(), any(), any(), any(), any(), any()) } returns
|
coEvery { streamChoiceService.chooseSubtitleStream(any(), any(), any(), any(), any(), any()) } returns
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,95 @@
|
||||||
|
package com.github.damontecres.wholphin.util
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import org.robolectric.RobolectricTestRunner
|
||||||
|
import org.robolectric.RuntimeEnvironment
|
||||||
|
import org.robolectric.annotation.Config
|
||||||
|
import androidx.media3.common.Player
|
||||||
|
import io.mockk.*
|
||||||
|
import kotlinx.coroutines.CompletableDeferred
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.test.*
|
||||||
|
import org.jellyfin.sdk.api.client.ApiClient
|
||||||
|
import org.jellyfin.sdk.api.client.extensions.playStateApi
|
||||||
|
import org.jellyfin.sdk.api.operations.PlayStateApi
|
||||||
|
import org.jellyfin.sdk.model.api.PlayMethod
|
||||||
|
import com.github.damontecres.wholphin.ui.successResponse
|
||||||
|
import org.junit.Assert.*
|
||||||
|
import org.junit.Test
|
||||||
|
import java.util.UUID
|
||||||
|
import org.justinvisser.wolfskin.conductarr.FileCacheStore
|
||||||
|
import org.justinvisser.wolfskin.conductarr.CacheEntry
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
/** Return refresh waits for the stop write rather than racing its acknowledgement. */
|
||||||
|
@RunWith(RobolectricTestRunner::class)
|
||||||
|
@Config(sdk = [34], application = Application::class)
|
||||||
|
@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class)
|
||||||
|
class PlaybackWatchStateTest {
|
||||||
|
@Test fun revisionAppearsOnlyAfterTheCapturedUsersStopIsAcknowledged() = runTest {
|
||||||
|
val dispatcher = StandardTestDispatcher(testScheduler)
|
||||||
|
val previous = WholphinDispatchers.IO
|
||||||
|
Dispatchers.setMain(dispatcher)
|
||||||
|
WholphinDispatchers.IO = dispatcher
|
||||||
|
val api = mockk<ApiClient>()
|
||||||
|
val reports = mockk<PlayStateApi>()
|
||||||
|
every { api.playStateApi } returns reports
|
||||||
|
val player = mockk<Player> { every { currentPosition } returns 751_246L }
|
||||||
|
val scope = "server:${UUID.randomUUID()}"
|
||||||
|
val another = "server:${UUID.randomUUID()}"
|
||||||
|
val context = RuntimeEnvironment.getApplication()
|
||||||
|
val cache = FileCacheStore(File(context.filesDir, "wolfskin-library-cache"))
|
||||||
|
val saved = CacheEntry("old progress", null, 1L, 1L, group = "library")
|
||||||
|
cache.write(scope, "library:movies", saved)
|
||||||
|
cache.write(another, "library:movies", saved)
|
||||||
|
val acknowledgement = CompletableDeferred<Unit>()
|
||||||
|
coEvery { reports.reportPlaybackStopped(any()) } coAnswers {
|
||||||
|
acknowledgement.await()
|
||||||
|
successResponse(Unit)
|
||||||
|
}
|
||||||
|
val state = PlaybackItemState(UUID.randomUUID(), PlayMethod.DIRECT_PLAY)
|
||||||
|
val listener = TrackActivityPlaybackListener(api, player, onStopped = { PlaybackWatchState.stopped(RuntimeEnvironment.getApplication(), scope) }, getState = { state })
|
||||||
|
try {
|
||||||
|
listener.release()
|
||||||
|
runCurrent()
|
||||||
|
assertNull(PlaybackWatchState.revisions.value[scope])
|
||||||
|
assertNotNull(cache.read(scope, "library:movies"))
|
||||||
|
acknowledgement.complete(Unit)
|
||||||
|
advanceUntilIdle()
|
||||||
|
assertNotNull(PlaybackWatchState.revisions.value[scope])
|
||||||
|
assertNull(PlaybackWatchState.revisions.value[another])
|
||||||
|
assertNull(cache.read(scope, "library:movies"))
|
||||||
|
assertEquals(saved, cache.read(another, "library:movies"))
|
||||||
|
coVerify(exactly = 1) { reports.reportPlaybackStopped(match { it.itemId == state.itemId && it.positionTicks == 7_512_460_000L }) }
|
||||||
|
listener.release()
|
||||||
|
advanceUntilIdle()
|
||||||
|
coVerify(exactly = 1) { reports.reportPlaybackStopped(any()) }
|
||||||
|
} finally {
|
||||||
|
WholphinDispatchers.IO = previous
|
||||||
|
Dispatchers.resetMain()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun failedStopDoesNotPublishAFictitiousAcknowledgement() = runTest {
|
||||||
|
val dispatcher = StandardTestDispatcher(testScheduler)
|
||||||
|
val previous = WholphinDispatchers.IO
|
||||||
|
Dispatchers.setMain(dispatcher)
|
||||||
|
WholphinDispatchers.IO = dispatcher
|
||||||
|
val api = mockk<ApiClient>()
|
||||||
|
val reports = mockk<PlayStateApi>()
|
||||||
|
every { api.playStateApi } returns reports
|
||||||
|
coEvery { reports.reportPlaybackStopped(any()) } throws java.io.IOException("Stop rejected")
|
||||||
|
val scope = "server:${UUID.randomUUID()}"
|
||||||
|
val listener = TrackActivityPlaybackListener(api, mockk<Player> { every { currentPosition } returns 751_246L },
|
||||||
|
onStopped = { PlaybackWatchState.stopped(RuntimeEnvironment.getApplication(), scope) }, getState = { PlaybackItemState(UUID.randomUUID(), PlayMethod.DIRECT_PLAY) })
|
||||||
|
try {
|
||||||
|
listener.release()
|
||||||
|
advanceUntilIdle()
|
||||||
|
assertNull(PlaybackWatchState.revisions.value[scope])
|
||||||
|
} finally {
|
||||||
|
WholphinDispatchers.IO = previous
|
||||||
|
Dispatchers.resetMain()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -52,8 +52,9 @@ class ConductarrHomeSurfaceTest {
|
||||||
session.select("${server.id}:${user.id}", SHARED_TOKEN, fake.url)
|
session.select("${server.id}:${user.id}", SHARED_TOKEN, fake.url)
|
||||||
runBlocking { session.authenticate() }
|
runBlocking { session.authenticate() }
|
||||||
val home = mockk<HomeReadFacade>()
|
val home = mockk<HomeReadFacade>()
|
||||||
coEvery { home.read(user.id) } returns HomeLibrary(resume = listOf(
|
coEvery { home.readWatch(user.id) } returns HomeLibrary(resume = listOf(
|
||||||
HomeTitle("00000000-0000-0000-0000-000000000010", "Jellyfin held item", "movie", held = true)))
|
HomeTitle("00000000-0000-0000-0000-000000000010", "Jellyfin held item", "movie", held = true)))
|
||||||
|
coEvery { home.readLibrary(user.id) } returns HomeLibrary()
|
||||||
val cache = mockk<HomeLibraryCache>(relaxed = true)
|
val cache = mockk<HomeLibraryCache>(relaxed = true)
|
||||||
every { cache.read(any()) } returns null
|
every { cache.read(any()) } returns null
|
||||||
val model = WolfskinHomeViewModel(rule.activity, home, cache, NavigationManager(), WolfskinSeeAllLists(), session)
|
val model = WolfskinHomeViewModel(rule.activity, home, cache, NavigationManager(), WolfskinSeeAllLists(), session)
|
||||||
|
|
|
||||||
|
|
@ -43,11 +43,13 @@ class HomeRecoveryTest {
|
||||||
val started = CompletableDeferred<Unit>()
|
val started = CompletableDeferred<Unit>()
|
||||||
val response = CompletableDeferred<HomeLibrary>()
|
val response = CompletableDeferred<HomeLibrary>()
|
||||||
val facade = mockk<HomeReadFacade>()
|
val facade = mockk<HomeReadFacade>()
|
||||||
coEvery { facade.read(personA.id) } coAnswers { started.complete(Unit); response.await() }
|
coEvery { facade.readWatch(personA.id) } coAnswers { started.complete(Unit); response.await() }
|
||||||
|
coEvery { facade.readLibrary(any()) } returns HomeLibrary()
|
||||||
val model = WolfskinHomeViewModel(context, facade, cache, NavigationManager(), WolfskinSeeAllLists(), ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()))
|
val model = WolfskinHomeViewModel(context, facade, cache, NavigationManager(), WolfskinSeeAllLists(), ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()))
|
||||||
val refresh = async(Dispatchers.IO) { model.loadLibrary(server, personA, viewerA, retry = true) }
|
val refresh = async(Dispatchers.IO) { model.loadLibrary(server, personA, viewerA, retry = true) }
|
||||||
started.await()
|
started.await()
|
||||||
assertEquals(film.id, model.snapshot.rows.first().cards.first().id)
|
assertEquals(film.id, model.snapshot.rows.first().cards.first().id)
|
||||||
|
assertFalse(model.snapshot.rows.first().watchReady)
|
||||||
response.completeExceptionally(IOException("fixture offline"))
|
response.completeExceptionally(IOException("fixture offline"))
|
||||||
refresh.await()
|
refresh.await()
|
||||||
assertFalse(model.snapshot.jellyfinAvailable)
|
assertFalse(model.snapshot.jellyfinAvailable)
|
||||||
|
|
@ -62,8 +64,9 @@ class HomeRecoveryTest {
|
||||||
cache.write("${server.id}:${personA.id}", library, 1000L)
|
cache.write("${server.id}:${personA.id}", library, 1000L)
|
||||||
val startedA = CompletableDeferred<Unit>()
|
val startedA = CompletableDeferred<Unit>()
|
||||||
val responseA = CompletableDeferred<HomeLibrary>()
|
val responseA = CompletableDeferred<HomeLibrary>()
|
||||||
coEvery { facade.read(personA.id) } coAnswers { startedA.complete(Unit); responseA.await() }
|
coEvery { facade.readWatch(personA.id) } coAnswers { startedA.complete(Unit); responseA.await() }
|
||||||
coEvery { facade.read(personB.id) } throws IOException("fixture offline")
|
coEvery { facade.readWatch(personB.id) } throws IOException("fixture offline")
|
||||||
|
coEvery { facade.readLibrary(any()) } returns HomeLibrary()
|
||||||
val model = WolfskinHomeViewModel(context, facade, cache, NavigationManager(), WolfskinSeeAllLists(), ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()))
|
val model = WolfskinHomeViewModel(context, facade, cache, NavigationManager(), WolfskinSeeAllLists(), ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()))
|
||||||
val requestA = async(Dispatchers.IO) { model.loadLibrary(server, personA, viewerA) }
|
val requestA = async(Dispatchers.IO) { model.loadLibrary(server, personA, viewerA) }
|
||||||
startedA.await()
|
startedA.await()
|
||||||
|
|
@ -80,7 +83,8 @@ class HomeRecoveryTest {
|
||||||
|
|
||||||
@Test fun readableJellyfinDoesNotBecomeAnOutageWhenLocalCacheCannotBeWritten() = runBlocking {
|
@Test fun readableJellyfinDoesNotBecomeAnOutageWhenLocalCacheCannotBeWritten() = runBlocking {
|
||||||
val facade = mockk<HomeReadFacade>()
|
val facade = mockk<HomeReadFacade>()
|
||||||
coEvery { facade.read(personA.id) } returns library
|
coEvery { facade.readWatch(personA.id) } returns library
|
||||||
|
coEvery { facade.readLibrary(any()) } returns HomeLibrary()
|
||||||
val cache = mockk<HomeLibraryCache>()
|
val cache = mockk<HomeLibraryCache>()
|
||||||
every { cache.read(any()) } returns null
|
every { cache.read(any()) } returns null
|
||||||
every { cache.write(any(), any(), any()) } throws IOException("fixture disk full")
|
every { cache.write(any(), any(), any()) } throws IOException("fixture disk full")
|
||||||
|
|
@ -93,7 +97,8 @@ class HomeRecoveryTest {
|
||||||
|
|
||||||
@Test fun renamedHouseholdProfileKeepsItsOwnJellyfinLibraryWithoutAConductarrIdentity() = runBlocking {
|
@Test fun renamedHouseholdProfileKeepsItsOwnJellyfinLibraryWithoutAConductarrIdentity() = runBlocking {
|
||||||
val facade = mockk<HomeReadFacade>()
|
val facade = mockk<HomeReadFacade>()
|
||||||
coEvery { facade.read(personA.id) } returns library
|
coEvery { facade.readWatch(personA.id) } returns library
|
||||||
|
coEvery { facade.readLibrary(any()) } returns HomeLibrary()
|
||||||
val cache = mockk<HomeLibraryCache>(relaxed = true)
|
val cache = mockk<HomeLibraryCache>(relaxed = true)
|
||||||
every { cache.read(any()) } returns null
|
every { cache.read(any()) } returns null
|
||||||
val model = WolfskinHomeViewModel(context, facade, cache, NavigationManager(), WolfskinSeeAllLists(), ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()))
|
val model = WolfskinHomeViewModel(context, facade, cache, NavigationManager(), WolfskinSeeAllLists(), ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()))
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,21 @@ class LibrarySnapshotStoreTest {
|
||||||
assertFalse(reads.last().unreachable)
|
assertFalse(reads.last().unreachable)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test fun acknowledgedPlaybackInvalidatesOnlyOlderWatchSnapshotsBeforeTheTtl() = runBlocking {
|
||||||
|
var now = 1000L
|
||||||
|
val store = LibrarySnapshotStore(MemoryCacheStore()) { now }
|
||||||
|
val old = listOf(LibraryTitle("film", "Film", "movie", progress = .1f))
|
||||||
|
store.read("server:justin", "movies") { flowOf(old) }.toList()
|
||||||
|
store.read("server:mindel", "movies") { flowOf(old) }.toList()
|
||||||
|
now = 1002L
|
||||||
|
val updated = listOf(old.single().copy(progress = .6f))
|
||||||
|
val reads = store.read("server:justin", "movies", invalidatedAtMillis = 1001L) { flowOf(updated) }.toList()
|
||||||
|
assertEquals(.1f, reads.first().items.single().progress)
|
||||||
|
assertEquals(.6f, reads.last().items.single().progress)
|
||||||
|
assertEquals(updated, store.read("server:justin", "movies", invalidatedAtMillis = 1001L) { error("Already revalidated") }.toList().single().items)
|
||||||
|
assertEquals(old, store.read("server:mindel", "movies") { error("Other viewer stays cached") }.toList().single().items)
|
||||||
|
}
|
||||||
|
|
||||||
@Test fun failedLaterChunkKeepsTheCompleteSavedCopyOrTheColdPartialWithoutSavingIt() = runBlocking {
|
@Test fun failedLaterChunkKeepsTheCompleteSavedCopyOrTheColdPartialWithoutSavingIt() = runBlocking {
|
||||||
val partial = listOf(LibraryTitle("partial", "First film", "movie"))
|
val partial = listOf(LibraryTitle("partial", "First film", "movie"))
|
||||||
val complete = partial + LibraryTitle("last", "Last film", "movie")
|
val complete = partial + LibraryTitle("last", "Last film", "movie")
|
||||||
|
|
|
||||||
|
|
@ -214,8 +214,9 @@ class PersonSwitchThreadingTest {
|
||||||
every { repository.currentUser } returns user
|
every { repository.currentUser } returns user
|
||||||
every { repository.currentServer } returns server
|
every { repository.currentServer } returns server
|
||||||
val home = mockk<org.justinvisser.wolfskin.tv.read.HomeReadFacade>()
|
val home = mockk<org.justinvisser.wolfskin.tv.read.HomeReadFacade>()
|
||||||
coEvery { home.read(user.id) } returns org.justinvisser.wolfskin.design.HomeLibrary(resume = listOf(
|
coEvery { home.readWatch(user.id) } returns org.justinvisser.wolfskin.design.HomeLibrary(resume = listOf(
|
||||||
org.justinvisser.wolfskin.design.HomeTitle("justin-film", "Justin film", "movie", held = true)))
|
org.justinvisser.wolfskin.design.HomeTitle("justin-film", "Justin film", "movie", held = true)))
|
||||||
|
coEvery { home.readLibrary(user.id) } returns org.justinvisser.wolfskin.design.HomeLibrary()
|
||||||
val cache = mockk<org.justinvisser.wolfskin.tv.read.HomeLibraryCache>(relaxed = true)
|
val cache = mockk<org.justinvisser.wolfskin.tv.read.HomeLibraryCache>(relaxed = true)
|
||||||
every { cache.read(any()) } returns null
|
every { cache.read(any()) } returns null
|
||||||
val homeModel = WolfskinHomeViewModel(context, home, cache, mockk(relaxed = true), WolfskinSeeAllLists(), conductarr)
|
val homeModel = WolfskinHomeViewModel(context, home, cache, mockk(relaxed = true), WolfskinSeeAllLists(), conductarr)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
package org.justinvisser.wolfskin.tv
|
||||||
|
|
||||||
|
import org.jellyfin.sdk.model.api.UserItemDataDto
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
/** Checkpoints use the SDK's required identity and watch fields. */
|
||||||
|
internal fun playbackUserData(itemId: UUID, ticks: Long, played: Boolean = false, playCount: Int = 0): UserItemDataDto =
|
||||||
|
UserItemDataDto(playbackPositionTicks = ticks, playCount = playCount, isFavorite = false,
|
||||||
|
lastPlayedDate = null, played = played, key = "", itemId = itemId)
|
||||||
|
|
@ -58,7 +58,7 @@ class PlayerHandoffTest {
|
||||||
@Test fun failedPlaybackReadLeavesSavedHomeFocusedAndDrawsTheExactOfflineToast() {
|
@Test fun failedPlaybackReadLeavesSavedHomeFocusedAndDrawsTheExactOfflineToast() {
|
||||||
val snapshot = HomeFixture(rule.activity).snapshot("mindel", "offline")
|
val snapshot = HomeFixture(rule.activity).snapshot("mindel", "offline")
|
||||||
val title = snapshot.rows.first().cards.first()
|
val title = snapshot.rows.first().cards.first()
|
||||||
coEvery { facade.playableId(user.id, title) } throws IOException("fixture offline")
|
coEvery { facade.preparePlayback(user.id, title) } throws IOException("fixture offline")
|
||||||
rule.setContent { WolfskinTheme {
|
rule.setContent { WolfskinTheme {
|
||||||
ShellSurface(shell, Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight)) { modifier ->
|
ShellSurface(shell, Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight)) { modifier ->
|
||||||
HomeScreen(shell, snapshot, modifier) { model.act(it, shell, server, user) }
|
HomeScreen(shell, snapshot, modifier) { model.act(it, shell, server, user) }
|
||||||
|
|
@ -80,14 +80,16 @@ class PlayerHandoffTest {
|
||||||
@Test fun successfulPlaybackUsesTheRealFullscreenDestinationAndBackKeepsTheResumeCard() {
|
@Test fun successfulPlaybackUsesTheRealFullscreenDestinationAndBackKeepsTheResumeCard() {
|
||||||
val title = HomeFixture(rule.activity).snapshot("mindel").rows.first().cards.first()
|
val title = HomeFixture(rule.activity).snapshot("mindel").rows.first().cards.first()
|
||||||
val id = UUID.fromString("00000000-0000-0000-0000-000000000003")
|
val id = UUID.fromString("00000000-0000-0000-0000-000000000003")
|
||||||
coEvery { facade.playableId(user.id, title) } returns id
|
coEvery { facade.preparePlayback(user.id, title) } returns org.jellyfin.sdk.model.api.BaseItemDto(
|
||||||
|
id = id, type = org.jellyfin.sdk.model.api.BaseItemKind.EPISODE,
|
||||||
|
userData = playbackUserData(id, 11_362_740_000L, playCount = 2))
|
||||||
val view = shell.view
|
val view = shell.view
|
||||||
view.cards["hero"] = 0
|
view.cards["hero"] = 0
|
||||||
rule.runOnIdle { model.act(HomeAction.Play(title, resume = true), shell, server, user) }
|
rule.runOnIdle { model.act(HomeAction.Play(title), shell, server, user) }
|
||||||
awaitModel { navigation.backStack.last() is Destination.Playback }
|
awaitModel { navigation.backStack.last() is Destination.Playback }
|
||||||
val destination = navigation.backStack.last() as Destination.Playback
|
val destination = navigation.backStack.last() as Destination.Playback
|
||||||
assertEquals(id, destination.itemId)
|
assertEquals(id, destination.itemId)
|
||||||
assertEquals((title.resumeSeconds * 1000).toLong(), destination.positionMs)
|
assertEquals(1_136_274L, destination.positionMs)
|
||||||
assertTrue(destination.fullScreen)
|
assertTrue(destination.fullScreen)
|
||||||
navigation.goBack()
|
navigation.goBack()
|
||||||
assertTrue(navigation.backStack.single() is Destination.Home)
|
assertTrue(navigation.backStack.single() is Destination.Home)
|
||||||
|
|
@ -99,11 +101,11 @@ class PlayerHandoffTest {
|
||||||
@Test fun anotherPersonsSessionCannotResolveOrStartPlayback() {
|
@Test fun anotherPersonsSessionCannotResolveOrStartPlayback() {
|
||||||
val title = HomeFixture(rule.activity).snapshot("mindel").rows.first().cards.first()
|
val title = HomeFixture(rule.activity).snapshot("mindel").rows.first().cards.first()
|
||||||
val otherUser = user.copy(name = "Justin")
|
val otherUser = user.copy(name = "Justin")
|
||||||
rule.runOnIdle { model.act(HomeAction.Play(title, resume = true), shell, server, otherUser) }
|
rule.runOnIdle { model.act(HomeAction.Play(title), shell, server, otherUser) }
|
||||||
awaitModel { model.toast != null }
|
awaitModel { model.toast != null }
|
||||||
assertEquals(RecoveryMessage.PlaybackUnavailable, model.toast)
|
assertEquals(RecoveryMessage.PlaybackUnavailable, model.toast)
|
||||||
assertTrue(navigation.backStack.single() is Destination.Home)
|
assertTrue(navigation.backStack.single() is Destination.Home)
|
||||||
coVerify(exactly = 0) { facade.playableId(any(), any()) }
|
coVerify(exactly = 0) { facade.preparePlayback(any(), any()) }
|
||||||
}
|
}
|
||||||
private fun awaitModel(condition: () -> Boolean) {
|
private fun awaitModel(condition: () -> Boolean) {
|
||||||
rule.waitUntil(5000) {
|
rule.waitUntil(5000) {
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,9 @@ import androidx.compose.ui.test.pressKey
|
||||||
import androidx.compose.ui.test.onNodeWithTag
|
import androidx.compose.ui.test.onNodeWithTag
|
||||||
import androidx.compose.ui.test.performMouseInput
|
import androidx.compose.ui.test.performMouseInput
|
||||||
import androidx.compose.ui.test.onNodeWithText
|
import androidx.compose.ui.test.onNodeWithText
|
||||||
|
import androidx.compose.ui.test.assertIsDisplayed
|
||||||
|
import androidx.compose.ui.test.hasClickAction
|
||||||
|
import androidx.compose.ui.test.hasText
|
||||||
import androidx.compose.ui.test.performSemanticsAction
|
import androidx.compose.ui.test.performSemanticsAction
|
||||||
import androidx.compose.ui.semantics.SemanticsActions
|
import androidx.compose.ui.semantics.SemanticsActions
|
||||||
import androidx.compose.ui.text.TextLayoutResult
|
import androidx.compose.ui.text.TextLayoutResult
|
||||||
|
|
@ -62,6 +65,27 @@ class TitleKeysTest {
|
||||||
} }
|
} }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test fun downFocusesThePlayableLaterSeasonAndScrollsItIntoViewWithoutLosingBrowsingMemory() {
|
||||||
|
render("Andor") { page ->
|
||||||
|
page.copy(item = page.item.copy(seasons = 30), seasonNumbers = (1..30).toList(), selectedSeason = 29,
|
||||||
|
playTarget = page.playTarget!!.copy(season = 29))
|
||||||
|
}
|
||||||
|
press(Key.DirectionDown)
|
||||||
|
assertEquals("seasons", state.row)
|
||||||
|
assertEquals(28, state.column)
|
||||||
|
rule.onNode(hasText("Season 29") and hasClickAction()).assertIsDisplayed()
|
||||||
|
press(Key.DirectionLeft)
|
||||||
|
assertEquals(27, state.column)
|
||||||
|
rule.onNode(hasText("Season 28") and hasClickAction()).assertIsDisplayed()
|
||||||
|
press(Key.DirectionUp)
|
||||||
|
press(Key.DirectionDown)
|
||||||
|
assertEquals(27, state.column)
|
||||||
|
press(Key.DirectionCenter)
|
||||||
|
assertEquals(28, state.season)
|
||||||
|
assertEquals(28, state.requestedSeason)
|
||||||
|
assertEquals(TitleAction.Season(28), effects.last())
|
||||||
|
}
|
||||||
|
|
||||||
private fun press(key: Key) { rule.onRoot().performKeyInput { pressKey(key) } }
|
private fun press(key: Key) { rule.onRoot().performKeyInput { pressKey(key) } }
|
||||||
private fun replay(end: String): Int {
|
private fun replay(end: String): Int {
|
||||||
val path = paths.single { it.getString("end") == end }
|
val path = paths.single { it.getString("end") == end }
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,136 @@
|
||||||
|
package org.justinvisser.wolfskin.tv
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
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.services.NavigationManager
|
||||||
|
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||||
|
import io.mockk.coEvery
|
||||||
|
import io.mockk.coVerify
|
||||||
|
import io.mockk.mockk
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.cancel
|
||||||
|
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||||
|
import kotlinx.coroutines.test.advanceUntilIdle
|
||||||
|
import kotlinx.coroutines.test.resetMain
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import kotlinx.coroutines.test.setMain
|
||||||
|
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||||
|
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||||
|
import org.junit.Assert.*
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import org.justinvisser.wolfskin.conductarr.MemoryCacheStore
|
||||||
|
import org.justinvisser.wolfskin.design.*
|
||||||
|
import org.justinvisser.wolfskin.tv.read.TitleReadFacade
|
||||||
|
import org.robolectric.RobolectricTestRunner
|
||||||
|
import org.robolectric.RuntimeEnvironment
|
||||||
|
import org.robolectric.annotation.Config
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
/** Production title actions preserve parents and resolve live playback instead of cached offsets. */
|
||||||
|
@RunWith(RobolectricTestRunner::class)
|
||||||
|
@Config(sdk = [34], application = Application::class)
|
||||||
|
@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class)
|
||||||
|
class TitlePlaybackNavigationTest {
|
||||||
|
private val seriesId = UUID.fromString("00000000-0000-0000-0000-000000000001")
|
||||||
|
private val firstId = UUID.fromString("00000000-0000-0000-0000-000000000002")
|
||||||
|
private val secondId = UUID.fromString("00000000-0000-0000-0000-000000000003")
|
||||||
|
private val person = ShellPerson("justin", "Justin", "J", false)
|
||||||
|
private val preferences = UserPreferences(AppPreferences.getDefaultInstance(), null)
|
||||||
|
private val series = TitleItem(seriesId.toString(), "Series", "tv", seasons = 5, held = true)
|
||||||
|
private val first = TitleItem(firstId.toString(), "First episode", "episode", held = true, season = 3, episode = 1, resumeSeconds = 12)
|
||||||
|
private val second = first.copy(id = secondId.toString(), name = "Second episode", episode = 2)
|
||||||
|
|
||||||
|
@Test fun repeatedEpisodeChoicesReplaceOnlyEpisodesAndBackPreservesTheirSeriesParent() = runTest {
|
||||||
|
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
|
||||||
|
val navigation = NavigationManager()
|
||||||
|
val facade = mockk<TitleReadFacade>()
|
||||||
|
coEvery { facade.readSaved(any(), any()) } returns null
|
||||||
|
coEvery { facade.read(any(), any(), any()) } coAnswers {
|
||||||
|
val id = firstArg<UUID>()
|
||||||
|
TitlePageData(series, playTarget = first, watchTarget = if (id == seriesId) series else first.copy(id = id.toString()),
|
||||||
|
episodes = listOf(first, second), related = listOf(series), cast = listOf(TitlePerson("actor", "Actor")))
|
||||||
|
}
|
||||||
|
val session = ConductarrSession(MemoryCacheStore())
|
||||||
|
val model = WolfskinTitleViewModel(RuntimeEnvironment.getApplication(), facade, mockk(), navigation, session, mockk<ServerRepository>(relaxed = true))
|
||||||
|
fun open(id: UUID) = model.load(titleRoute(id.toString()), person, preferences, fixtures = false, force = true)
|
||||||
|
try {
|
||||||
|
navigation.navigateTo(Destination.Wolfskin(titleRoute(seriesId.toString())))
|
||||||
|
open(seriesId)
|
||||||
|
advanceUntilIdle()
|
||||||
|
model.title!!.click("episodes", 0)
|
||||||
|
assertEquals(3, navigation.backStack.size)
|
||||||
|
assertEquals(firstId, (navigation.backStack.last() as Destination.MediaItem).itemId)
|
||||||
|
open(firstId)
|
||||||
|
advanceUntilIdle()
|
||||||
|
for (id in listOf(secondId, firstId, secondId)) {
|
||||||
|
model.title!!.click("episodes", if (id == firstId) 0 else 1)
|
||||||
|
assertEquals(3, navigation.backStack.size)
|
||||||
|
assertEquals(id, (navigation.backStack.last() as Destination.MediaItem).itemId)
|
||||||
|
open(id)
|
||||||
|
advanceUntilIdle()
|
||||||
|
}
|
||||||
|
model.title!!.click("related", 0)
|
||||||
|
assertEquals(4, navigation.backStack.size)
|
||||||
|
navigation.goBack()
|
||||||
|
model.title!!.click("cast", 0)
|
||||||
|
assertEquals(4, navigation.backStack.size)
|
||||||
|
navigation.goBack()
|
||||||
|
model.title!!.back(ShellState(listOf(person), person.key).apply { pickerVisible = false; pickerLaunch = false })
|
||||||
|
assertEquals(listOf(Destination.Home(), Destination.Wolfskin(titleRoute(seriesId.toString()))), navigation.backStack.toList())
|
||||||
|
navigation.goBack()
|
||||||
|
assertEquals(listOf(Destination.Home()), navigation.backStack.toList())
|
||||||
|
navigation.navigateTo(Destination.Wolfskin(titleRoute(firstId.toString())))
|
||||||
|
open(firstId)
|
||||||
|
advanceUntilIdle()
|
||||||
|
model.title!!.click("episodes", 1)
|
||||||
|
navigation.goBack()
|
||||||
|
assertEquals(listOf(Destination.Home()), navigation.backStack.toList())
|
||||||
|
} finally {
|
||||||
|
model.viewModelScope.cancel()
|
||||||
|
session.close()
|
||||||
|
Dispatchers.resetMain()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun primarySeriesActionReResolvesNextUpButRestartAndExplicitEpisodesKeepTheirIdentity() = runTest {
|
||||||
|
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
|
||||||
|
val navigation = NavigationManager()
|
||||||
|
val facade = mockk<TitleReadFacade>()
|
||||||
|
coEvery { facade.readSaved(any(), any()) } returns null
|
||||||
|
coEvery { facade.read(any(), any(), any()) } coAnswers {
|
||||||
|
TitlePageData(series, first, watchTarget = if (firstArg<UUID>() == seriesId) series else first)
|
||||||
|
}
|
||||||
|
coEvery { facade.preparePlayback(seriesId) } returns BaseItemDto(id = secondId, type = BaseItemKind.EPISODE,
|
||||||
|
userData = playbackUserData(secondId, 11_362_740_000L, playCount = 2))
|
||||||
|
coEvery { facade.preparePlayback(firstId) } returns BaseItemDto(id = firstId, type = BaseItemKind.EPISODE,
|
||||||
|
userData = playbackUserData(firstId, 960_780_000L, playCount = 1))
|
||||||
|
val session = ConductarrSession(MemoryCacheStore())
|
||||||
|
val model = WolfskinTitleViewModel(RuntimeEnvironment.getApplication(), facade, mockk(), navigation, session, mockk<ServerRepository>(relaxed = true))
|
||||||
|
try {
|
||||||
|
model.load(titleRoute(seriesId.toString()), person, preferences, fixtures = false)
|
||||||
|
advanceUntilIdle()
|
||||||
|
model.title!!.click("header", 0)
|
||||||
|
advanceUntilIdle()
|
||||||
|
assertEquals(Destination.Playback(secondId, 1_136_274L), navigation.backStack.last())
|
||||||
|
navigation.goBack()
|
||||||
|
model.title!!.click("header", model.title!!.options.indexOfFirst { it.id == "restart" })
|
||||||
|
advanceUntilIdle()
|
||||||
|
assertEquals(Destination.Playback(firstId, 0L), navigation.backStack.last())
|
||||||
|
navigation.goBack()
|
||||||
|
model.load(titleRoute(firstId.toString()), person, preferences, fixtures = false)
|
||||||
|
advanceUntilIdle()
|
||||||
|
model.title!!.click("header", 0)
|
||||||
|
advanceUntilIdle()
|
||||||
|
assertEquals(Destination.Playback(firstId, 96_078L), navigation.backStack.last())
|
||||||
|
coVerify(exactly = 1) { facade.preparePlayback(seriesId) }
|
||||||
|
} finally {
|
||||||
|
model.viewModelScope.cancel()
|
||||||
|
session.close()
|
||||||
|
Dispatchers.resetMain()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -128,7 +128,7 @@ class TitleReadFacadeTest {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test fun diskCopyPreservesFileAndNonContiguousStreamIndexesWithoutRatingsBeforeSignIn() = runTest {
|
@Test fun diskCopyPreservesFileAndNonContiguousStreamIndexesWithoutRatingsBeforeSignIn() = runTest {
|
||||||
coEvery { library.getItem(firstId) } returns successResponse(file(firstId))
|
coEvery { library.getItem(firstId) } returns successResponse(file(firstId).copy(userData = playbackUserData(firstId, 200_000_000L)))
|
||||||
facade.read(firstId, null, preferences)
|
facade.read(firstId, null, preferences)
|
||||||
coEvery { library.getItem(any()) } throws java.io.IOException("offline")
|
coEvery { library.getItem(any()) } throws java.io.IOException("offline")
|
||||||
val saved = facade.readSaved(firstId, preferences)!!
|
val saved = facade.readSaved(firstId, preferences)!!
|
||||||
|
|
@ -139,6 +139,7 @@ class TitleReadFacadeTest {
|
||||||
assertEquals(TitleConnection.SignedOut, saved.connection)
|
assertEquals(TitleConnection.SignedOut, saved.connection)
|
||||||
assertTrue(saved.item.ratings.isEmpty())
|
assertTrue(saved.item.ratings.isEmpty())
|
||||||
assertEquals(firstId.toString(), saved.watchTarget.id)
|
assertEquals(firstId.toString(), saved.watchTarget.id)
|
||||||
|
assertFalse(screen.options.any { it.id == "play" || it.id == "restart" })
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test fun diskCopiesNeverCrossViewerIdentity() = runTest {
|
@Test fun diskCopiesNeverCrossViewerIdentity() = runTest {
|
||||||
|
|
|
||||||
135
app/src/test/java/org/justinvisser/wolfskin/tv/WatchReadTest.kt
Normal file
135
app/src/test/java/org/justinvisser/wolfskin/tv/WatchReadTest.kt
Normal file
|
|
@ -0,0 +1,135 @@
|
||||||
|
package org.justinvisser.wolfskin.tv
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import android.content.Context
|
||||||
|
import com.github.damontecres.wholphin.WholphinApplication
|
||||||
|
import com.github.damontecres.wholphin.data.ServerRepository
|
||||||
|
import com.github.damontecres.wholphin.services.ImageUrlService
|
||||||
|
import com.github.damontecres.wholphin.ui.successQueryResult
|
||||||
|
import com.github.damontecres.wholphin.ui.successResponse
|
||||||
|
import io.mockk.*
|
||||||
|
import kotlinx.coroutines.CompletableDeferred
|
||||||
|
import kotlinx.coroutines.cancel
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.test.*
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import org.jellyfin.sdk.api.client.ApiClient
|
||||||
|
import org.jellyfin.sdk.api.client.extensions.itemsApi
|
||||||
|
import org.jellyfin.sdk.api.client.extensions.tvShowsApi
|
||||||
|
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
|
||||||
|
import org.jellyfin.sdk.api.operations.ItemsApi
|
||||||
|
import org.jellyfin.sdk.api.operations.TvShowsApi
|
||||||
|
import org.jellyfin.sdk.api.operations.UserLibraryApi
|
||||||
|
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||||
|
import org.jellyfin.sdk.model.api.BaseItemDtoQueryResult
|
||||||
|
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||||
|
import org.junit.After
|
||||||
|
import org.junit.Assert.*
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import org.justinvisser.wolfskin.conductarr.MemoryCacheStore
|
||||||
|
import org.justinvisser.wolfskin.design.*
|
||||||
|
import org.justinvisser.wolfskin.tv.read.*
|
||||||
|
import org.robolectric.RobolectricTestRunner
|
||||||
|
import org.robolectric.RuntimeEnvironment
|
||||||
|
import org.robolectric.annotation.Config
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.github.damontecres.wholphin.data.model.JellyfinServer
|
||||||
|
import com.github.damontecres.wholphin.data.model.JellyfinUser
|
||||||
|
import com.github.damontecres.wholphin.services.NavigationManager
|
||||||
|
import java.util.UUID
|
||||||
|
import com.github.damontecres.wholphin.util.WholphinDispatchers
|
||||||
|
import com.github.damontecres.wholphin.util.configure
|
||||||
|
import com.github.damontecres.wholphin.util.reset
|
||||||
|
|
||||||
|
/** Recorded Jellyfin watch shapes exercise newer checkpoints and independent Home publication. */
|
||||||
|
@RunWith(RobolectricTestRunner::class)
|
||||||
|
@Config(sdk = [34], application = Application::class)
|
||||||
|
@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class)
|
||||||
|
class WatchReadTest {
|
||||||
|
private val api = mockk<ApiClient>()
|
||||||
|
private val library = mockk<UserLibraryApi>()
|
||||||
|
private val shows = mockk<TvShowsApi>()
|
||||||
|
private val items = mockk<ItemsApi>()
|
||||||
|
private val images = mockk<ImageUrlService>(relaxed = true)
|
||||||
|
private val userId = UUID.fromString("167649f8-00c9-44b5-a86a-f371eccc2d2e")
|
||||||
|
private val seriesId = UUID.fromString("00000000-0000-0000-0000-000000000003")
|
||||||
|
private val json = Json { ignoreUnknownKeys = true }
|
||||||
|
// Selected fields from the read-only Resume and NextUp replies, without credentials.
|
||||||
|
private val resumeWire = """{"Items":[{"Id":"a8ed46755a50a5dfb443dd5cdd7125a3","Name":"Sex and the Country","Type":"Episode","ParentIndexNumber":4,"IndexNumber":9,"RunTimeTicks":17507200000,"UserData":{"PlaybackPositionTicks":960780000,"Played":false,"PlayCount":1,"IsFavorite":false,"LastPlayedDate":null,"Key":"","ItemId":"a8ed46755a50a5dfb443dd5cdd7125a3"}}],"TotalRecordCount":1,"StartIndex":0}"""
|
||||||
|
private val nextWire = """{"Items":[{"Id":"d6a9588e91a4a22ecb9ae279eb7e09d6","Name":"Me and My Town","Type":"Episode","SeriesName":"Desperate Housewives","ParentIndexNumber":5,"IndexNumber":9,"RunTimeTicks":25368960000,"UserData":{"PlaybackPositionTicks":11362740000,"Played":false,"PlayCount":2,"IsFavorite":false,"LastPlayedDate":"2026-11-06T10:39:27.1772471Z","Key":"","ItemId":"d6a9588e91a4a22ecb9ae279eb7e09d6"}}],"TotalRecordCount":1,"StartIndex":0}"""
|
||||||
|
private fun decode(wire: String) = json.decodeFromString(BaseItemDtoQueryResult.serializer(), wire).items.first()
|
||||||
|
|
||||||
|
@Before fun setup() {
|
||||||
|
every { api.userLibraryApi } returns library
|
||||||
|
every { api.tvShowsApi } returns shows
|
||||||
|
every { api.itemsApi } returns items
|
||||||
|
mockkObject(WholphinApplication.Companion)
|
||||||
|
every { WholphinApplication.instance } returns mockk {
|
||||||
|
every { resources } returns RuntimeEnvironment.getApplication().resources
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@After fun teardown() { unmockkObject(WholphinApplication.Companion) }
|
||||||
|
|
||||||
|
@Test fun bothFacadesUseTheLiveCheckpointAndSeriesNextUpWhileExplicitEpisodeKeepsItsIdentity() = runTest {
|
||||||
|
val resume = decode(resumeWire)
|
||||||
|
val next = decode(nextWire)
|
||||||
|
val series = BaseItemDto(id = seriesId, type = BaseItemKind.SERIES)
|
||||||
|
coEvery { library.getItem(itemId = seriesId, userId = userId) } returns successResponse(series)
|
||||||
|
coEvery { library.getItem(itemId = resume.id, userId = userId) } returns successResponse(resume)
|
||||||
|
coEvery { library.getItem(itemId = next.id, userId = userId) } returns successResponse(next)
|
||||||
|
coEvery { shows.getNextUp(userId = userId, seriesId = seriesId, limit = 1, enableResumable = true, enableUserData = true) } returns successQueryResult(listOf(next))
|
||||||
|
val home = HomeReadFacade(api, images, mockk())
|
||||||
|
val repository = mockk<ServerRepository> {
|
||||||
|
every { currentUser } returns mockk { every { id } returns userId }
|
||||||
|
}
|
||||||
|
val title = TitleReadFacade(api, images, mockk(), repository, mockk(), mockk<Context>())
|
||||||
|
val cached = HomeTitle(resume.id.toString(), "Sex and the Country", "episode", resumeSeconds = 10.0)
|
||||||
|
assertEquals(96_078L, home.preparePlayback(userId, cached)!!.playbackPositionMs())
|
||||||
|
assertEquals(96_078L, title.preparePlayback(resume.id)!!.playbackPositionMs())
|
||||||
|
assertEquals(next.id, home.preparePlayback(userId, HomeTitle(seriesId.toString(), "Series", "tv"))!!.id)
|
||||||
|
val selected = title.preparePlayback(seriesId)!!
|
||||||
|
assertEquals(next.id, selected.id)
|
||||||
|
assertEquals(1_136_274L, selected.playbackPositionMs())
|
||||||
|
coVerify(exactly = 2) { shows.getNextUp(userId = userId, seriesId = seriesId, limit = 1, enableResumable = true, enableUserData = true) }
|
||||||
|
val completed = resume.copy(userData = resume.userData!!.copy(playbackPositionTicks = 0, played = true))
|
||||||
|
coEvery { library.getItem(itemId = resume.id, userId = userId) } returns successResponse(completed)
|
||||||
|
assertEquals(0L, home.preparePlayback(userId, cached)!!.playbackPositionMs())
|
||||||
|
assertEquals(true, title.preparePlayback(resume.id)!!.userData?.played)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun freshResumeAndNextUpPaintBeforeSlowLibraryAndSurviveItsFailure() = runTest {
|
||||||
|
WholphinDispatchers.configure(StandardTestDispatcher(testScheduler))
|
||||||
|
val server = JellyfinServer(seriesId, "Fixture", "https://example.invalid", null)
|
||||||
|
val user = JellyfinUser(id = userId, serverId = server.id, name = "Mindel", accessToken = null)
|
||||||
|
val person = ShellPerson("mindel", "Mindel", "M", false, userId.toString())
|
||||||
|
val resume = decode(resumeWire)
|
||||||
|
val next = decode(nextWire)
|
||||||
|
coEvery { items.getResumeItems(userId = userId, fields = any(), mediaTypes = any(), limit = 20) } returns successQueryResult(listOf(resume))
|
||||||
|
coEvery { shows.getNextUp(userId = userId, fields = any(), limit = 20, enableUserData = true) } returns successQueryResult(listOf(next))
|
||||||
|
val facade = spyk(HomeReadFacade(api, images, mockk()))
|
||||||
|
val releaseLibrary = CompletableDeferred<Unit>()
|
||||||
|
coEvery { facade.readLibrary(userId) } coAnswers { releaseLibrary.await(); throw java.io.IOException("Library failed") }
|
||||||
|
val cache = mockk<HomeLibraryCache>(relaxed = true)
|
||||||
|
every { cache.read(any()) } returns (HomeLibrary(resume = listOf(HomeTitle(resume.id.toString(), "Cached episode", "episode", resumeSeconds = 10.0))) to 1L)
|
||||||
|
val session = ConductarrSession(MemoryCacheStore())
|
||||||
|
val model = WolfskinHomeViewModel(RuntimeEnvironment.getApplication(), facade, cache, NavigationManager(), WolfskinSeeAllLists(), session)
|
||||||
|
try {
|
||||||
|
val loading = launch { model.loadLibrary(server, user, person) }
|
||||||
|
runCurrent()
|
||||||
|
assertFalse(loading.isCompleted)
|
||||||
|
assertTrue(model.snapshot.rows.first().watchReady)
|
||||||
|
assertEquals(1136.274, model.snapshot.rows.single { it.id == "nu" }.cards.single().resumeSeconds, .0001)
|
||||||
|
releaseLibrary.complete(Unit)
|
||||||
|
loading.join()
|
||||||
|
assertEquals(96.078, model.snapshot.rows.first().cards.first().resumeSeconds, .0001)
|
||||||
|
assertTrue(model.snapshot.jellyfinAvailable)
|
||||||
|
} finally {
|
||||||
|
model.viewModelScope.cancel()
|
||||||
|
session.close()
|
||||||
|
WholphinDispatchers.reset()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,77 @@
|
||||||
|
package org.justinvisser.wolfskin.tv
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import android.os.Looper
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.github.damontecres.wholphin.data.model.JellyfinServer
|
||||||
|
import com.github.damontecres.wholphin.data.model.JellyfinUser
|
||||||
|
import com.github.damontecres.wholphin.services.NavigationManager
|
||||||
|
import com.github.damontecres.wholphin.util.PlaybackWatchState
|
||||||
|
import io.mockk.coEvery
|
||||||
|
import io.mockk.every
|
||||||
|
import io.mockk.mockk
|
||||||
|
import kotlinx.coroutines.cancel
|
||||||
|
import org.junit.Assert.*
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import org.justinvisser.wolfskin.conductarr.MemoryCacheStore
|
||||||
|
import org.justinvisser.wolfskin.design.*
|
||||||
|
import org.justinvisser.wolfskin.tv.read.HomeLibraryCache
|
||||||
|
import org.justinvisser.wolfskin.tv.read.HomeReadFacade
|
||||||
|
import org.robolectric.RobolectricTestRunner
|
||||||
|
import org.robolectric.Shadows.shadowOf
|
||||||
|
import org.robolectric.annotation.Config
|
||||||
|
import java.util.UUID
|
||||||
|
import java.util.concurrent.atomic.AtomicReference
|
||||||
|
|
||||||
|
/** A still-composed Home must paint the checkpoint acknowledged after its first return read. */
|
||||||
|
@RunWith(RobolectricTestRunner::class)
|
||||||
|
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
|
||||||
|
class WatchReturnTest {
|
||||||
|
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
|
||||||
|
|
||||||
|
@Test fun acknowledgedStopRefreshesVisibleHeroWithoutRecreatingHomeOrLosingFocus() {
|
||||||
|
val server = JellyfinServer(UUID.randomUUID(), "Fixture", "https://example.invalid", null)
|
||||||
|
val user = JellyfinUser(id = UUID.randomUUID(), serverId = server.id, name = "Mindel", accessToken = null)
|
||||||
|
val person = ShellPerson("mindel", "Mindel", "M", false, user.id.toString())
|
||||||
|
val shell = ShellState(listOf(person), person.key).apply { pickerVisible = false; pickerLaunch = false }
|
||||||
|
val episode = HomeTitle("episode", "Me and My Town", "episode", runtimeSeconds = 2536, resumeSeconds = 10.0, held = true, seriesTitle = "Desperate Housewives", season = 5, episode = 9)
|
||||||
|
val current = AtomicReference(episode)
|
||||||
|
val facade = mockk<HomeReadFacade>()
|
||||||
|
every { facade.audiences } returns null
|
||||||
|
coEvery { facade.readWatch(user.id) } coAnswers { HomeLibrary(resume = listOf(current.get())) }
|
||||||
|
coEvery { facade.readLibrary(user.id) } returns HomeLibrary()
|
||||||
|
val cache = mockk<HomeLibraryCache>(relaxed = true)
|
||||||
|
every { cache.read(any()) } returns null
|
||||||
|
val session = ConductarrSession(MemoryCacheStore())
|
||||||
|
val model = WolfskinHomeViewModel(rule.activity, facade, cache, NavigationManager(), WolfskinSeeAllLists(), session)
|
||||||
|
val oldFixtures = ConductarrPreference.fixtures
|
||||||
|
ConductarrPreference.updateFixtures(false)
|
||||||
|
try {
|
||||||
|
rule.setContent { WolfskinTheme {
|
||||||
|
ShellSurface(shell) { HomeDestination(shell, server, user, it, model) }
|
||||||
|
} }
|
||||||
|
rule.waitUntil(5000) {
|
||||||
|
shadowOf(Looper.getMainLooper()).idle()
|
||||||
|
model.snapshot.rows.firstOrNull()?.cards?.firstOrNull()?.resumeSeconds == 10.0
|
||||||
|
}
|
||||||
|
val view = shell.rememberedView("home")
|
||||||
|
rule.runOnIdle { shell.navFocused = false; view.cards["hero"] = 0; current.set(episode.copy(resumeSeconds = 1136.274)) }
|
||||||
|
PlaybackWatchState.stopped(rule.activity, "${server.id}:${user.id}")
|
||||||
|
rule.waitUntil(5000) {
|
||||||
|
shadowOf(Looper.getMainLooper()).idle()
|
||||||
|
model.snapshot.rows.firstOrNull()?.cards?.firstOrNull()?.resumeSeconds == 1136.274
|
||||||
|
}
|
||||||
|
assertSame(view, shell.rememberedView("home"))
|
||||||
|
assertEquals(0, view.cards["hero"])
|
||||||
|
assertTrue(model.snapshot.rows.first().watchReady)
|
||||||
|
} finally {
|
||||||
|
model.viewModelScope.cancel()
|
||||||
|
session.close()
|
||||||
|
ConductarrPreference.updateFixtures(oldFixtures)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -18,10 +18,12 @@ import java.util.Date
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import kotlinx.coroutines.CancellationException
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.Dispatchers
|
import com.github.damontecres.wholphin.util.WholphinDispatchers
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
import kotlinx.coroutines.coroutineScope
|
||||||
|
import org.justinvisser.wolfskin.tv.read.playbackPositionMs
|
||||||
import org.justinvisser.wolfskin.design.*
|
import org.justinvisser.wolfskin.design.*
|
||||||
import org.justinvisser.wolfskin.tv.read.HomeLibraryCache
|
import org.justinvisser.wolfskin.tv.read.HomeLibraryCache
|
||||||
import org.justinvisser.wolfskin.tv.read.HomeReadFacade
|
import org.justinvisser.wolfskin.tv.read.HomeReadFacade
|
||||||
|
|
@ -50,9 +52,9 @@ class WolfskinHomeViewModel @Inject constructor(
|
||||||
|
|
||||||
suspend fun load(server: JellyfinServer, user: JellyfinUser, person: ShellPerson, retry: Boolean = false) {
|
suspend fun load(server: JellyfinServer, user: JellyfinUser, person: ShellPerson, retry: Boolean = false) {
|
||||||
catalogueJob?.cancel()
|
catalogueJob?.cancel()
|
||||||
withContext(Dispatchers.IO) { debugHomeSnapshot(context, person.key) }?.let { snapshot = it; return }
|
withContext(WholphinDispatchers.IO) { debugHomeSnapshot(context, person.key) }?.let { snapshot = it; return }
|
||||||
if (retry && conductarr.identity.value?.signedIn != true) {
|
if (retry && conductarr.identity.value?.signedIn != true) {
|
||||||
withContext(Dispatchers.IO) {
|
withContext(WholphinDispatchers.IO) {
|
||||||
conductarr.select("${server.id}:${user.id}", user.accessToken, ConductarrPreference.baseUrl, retry = true)
|
conductarr.select("${server.id}:${user.id}", user.accessToken, ConductarrPreference.baseUrl, retry = true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -74,11 +76,12 @@ class WolfskinHomeViewModel @Inject constructor(
|
||||||
toast = null
|
toast = null
|
||||||
snapshot = HomeSnapshot(buildHomeRows(HomeLibrary(), emptyList(), loading = true), HomeAvailability.Loading)
|
snapshot = HomeSnapshot(buildHomeRows(HomeLibrary(), emptyList(), loading = true), HomeAvailability.Loading)
|
||||||
}
|
}
|
||||||
val saved = withContext(Dispatchers.IO) { try { cache.read(scope) } catch (_: java.io.IOException) { null } }
|
val saved = withContext(WholphinDispatchers.IO) { try { cache.read(scope) } catch (_: java.io.IOException) { null } }
|
||||||
if (revision != loadRevision) return
|
if (revision != loadRevision) return
|
||||||
var libraryCopy = saved?.first ?: HomeLibrary()
|
var libraryCopy = saved?.first ?: HomeLibrary()
|
||||||
var libraryAvailable = true
|
var libraryAvailable = true
|
||||||
var librarySavedAt: String? = null
|
var librarySavedAt: String? = null
|
||||||
|
var watchReady = false
|
||||||
val identity = conductarr.identity.value
|
val identity = conductarr.identity.value
|
||||||
var catalogueCopy = DiscoverSnapshot(status = if (identity?.signedIn == true || (retry && identity != null)) DiscoverStatus.Loading else DiscoverStatus.NeedsSignIn)
|
var catalogueCopy = DiscoverSnapshot(status = if (identity?.signedIn == true || (retry && identity != null)) DiscoverStatus.Loading else DiscoverStatus.NeedsSignIn)
|
||||||
fun publish() {
|
fun publish() {
|
||||||
|
|
@ -89,8 +92,9 @@ class WolfskinHomeViewModel @Inject constructor(
|
||||||
DiscoverStatus.Unavailable -> HomeAvailability.Unavailable
|
DiscoverStatus.Unavailable -> HomeAvailability.Unavailable
|
||||||
else -> if (catalogueCopy.savedAt != null) HomeAvailability.Saved else HomeAvailability.Ready
|
else -> if (catalogueCopy.savedAt != null) HomeAvailability.Saved else HomeAvailability.Ready
|
||||||
}
|
}
|
||||||
snapshot = HomeSnapshot(buildHomeRows(libraryCopy, catalogueCopy.homeRows(), libraryAvailable = libraryAvailable),
|
snapshot = HomeSnapshot(buildHomeRows(libraryCopy, catalogueCopy.homeRows(), libraryAvailable = libraryAvailable).map {
|
||||||
status, libraryAvailable, catalogueCopy.savedAt ?: librarySavedAt)
|
if (it.kind == HomeRowKind.Hero) it.copy(watchReady = watchReady) else it
|
||||||
|
}, status, libraryAvailable, catalogueCopy.savedAt ?: librarySavedAt)
|
||||||
}
|
}
|
||||||
catalogueSnapshot = catalogueCopy
|
catalogueSnapshot = catalogueCopy
|
||||||
publishCatalogue = { value -> if (revision == loadRevision && sourceScope == scope) { catalogueCopy = value; catalogueSnapshot = value; publish() } }
|
publishCatalogue = { value -> if (revision == loadRevision && sourceScope == scope) { catalogueCopy = value; catalogueSnapshot = value; publish() } }
|
||||||
|
|
@ -107,20 +111,43 @@ class WolfskinHomeViewModel @Inject constructor(
|
||||||
if (revision == loadRevision && conductarr.identity.value?.client === identity.client) publishCatalogue?.invoke(catalogue)
|
if (revision == loadRevision && conductarr.identity.value?.client === identity.client) publishCatalogue?.invoke(catalogue)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
try {
|
suspend fun save() {
|
||||||
val library = withContext(Dispatchers.IO) { home.read(user.id) }
|
val library = libraryCopy
|
||||||
val savedAt = System.currentTimeMillis()
|
withContext(WholphinDispatchers.IO) {
|
||||||
libraryCopy = library
|
try { cache.write(scope, library, System.currentTimeMillis()) } catch (_: java.io.IOException) { /* Disk failure cannot revoke fresh watch data. */ }
|
||||||
publish()
|
}
|
||||||
withContext(Dispatchers.IO) { try { cache.write(scope, library, savedAt) } catch (_: java.io.IOException) { /* Saving cannot delay first paint or turn a reachable source into an outage. */ } }
|
|
||||||
} catch (exception: Exception) {
|
|
||||||
if (exception is CancellationException) throw exception
|
|
||||||
if (revision != loadRevision) return
|
|
||||||
libraryAvailable = false
|
|
||||||
librarySavedAt = saved?.second?.let { SimpleDateFormat("HH:mm", Locale.getDefault()).format(Date(it)) }
|
|
||||||
publish()
|
|
||||||
if (retry) showToast(RecoveryMessage.StillUnreachable)
|
|
||||||
}
|
}
|
||||||
|
coroutineScope {
|
||||||
|
launch {
|
||||||
|
try {
|
||||||
|
val watch = withContext(WholphinDispatchers.IO) { home.readWatch(user.id) }
|
||||||
|
if (revision != loadRevision) return@launch
|
||||||
|
libraryCopy = libraryCopy.copy(resume = watch.resume, nextUp = watch.nextUp)
|
||||||
|
watchReady = true
|
||||||
|
libraryAvailable = true
|
||||||
|
publish()
|
||||||
|
} catch (exception: Exception) {
|
||||||
|
if (exception is CancellationException) throw exception
|
||||||
|
if (revision != loadRevision) return@launch
|
||||||
|
libraryAvailable = false
|
||||||
|
librarySavedAt = saved?.second?.let { SimpleDateFormat("HH:mm", Locale.getDefault()).format(Date(it)) }
|
||||||
|
publish()
|
||||||
|
if (retry) showToast(RecoveryMessage.StillUnreachable)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
launch {
|
||||||
|
try {
|
||||||
|
val shelves = withContext(WholphinDispatchers.IO) { home.readLibrary(user.id) }
|
||||||
|
if (revision != loadRevision) return@launch
|
||||||
|
libraryCopy = libraryCopy.copy(newest = shelves.newest, destinations = shelves.destinations, destinationTotals = shelves.destinationTotals)
|
||||||
|
publish()
|
||||||
|
} catch (exception: Exception) {
|
||||||
|
if (exception is CancellationException) throw exception
|
||||||
|
if (revision == loadRevision && retry) showToast(RecoveryMessage.StillUnreachable)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (revision == loadRevision) save()
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun readAhead(lane: String?) {
|
suspend fun readAhead(lane: String?) {
|
||||||
|
|
@ -138,6 +165,7 @@ class WolfskinHomeViewModel @Inject constructor(
|
||||||
is HomeAction.SeeAll -> if (action.row.target in shell.tabs) openRoot(shell, action.row.target!!) else openCatalogueGrid(action.row, shell)
|
is HomeAction.SeeAll -> if (action.row.target in shell.tabs) openRoot(shell, action.row.target!!) else openCatalogueGrid(action.row, shell)
|
||||||
is HomeAction.Details -> navigation.navigateTo(Destination.Wolfskin(titleRoute(action.title.id)))
|
is HomeAction.Details -> navigation.navigateTo(Destination.Wolfskin(titleRoute(action.title.id)))
|
||||||
is HomeAction.Play -> viewModelScope.launch {
|
is HomeAction.Play -> viewModelScope.launch {
|
||||||
|
val personKey = shell.person.key
|
||||||
val selectedId = shell.person.jellyfinUserId
|
val selectedId = shell.person.jellyfinUserId
|
||||||
val matchesUser = if (selectedId != null) selectedId == user.id.toString() else
|
val matchesUser = if (selectedId != null) selectedId == user.id.toString() else
|
||||||
shell.person.key == user.name.orEmpty().lowercase() || (conductarr.identity.value?.person == "${server.id}:${user.id}" && shell.person.key == conductarr.identity.value?.operator?.requester)
|
shell.person.key == user.name.orEmpty().lowercase() || (conductarr.identity.value?.person == "${server.id}:${user.id}" && shell.person.key == conductarr.identity.value?.operator?.requester)
|
||||||
|
|
@ -146,9 +174,9 @@ class WolfskinHomeViewModel @Inject constructor(
|
||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
val id = withContext(Dispatchers.IO) { home.playableId(user.id, action.title) }
|
val item = withContext(WholphinDispatchers.IO) { home.preparePlayback(user.id, action.title) }
|
||||||
if (id == null) showToast(RecoveryMessage.PlaybackUnavailable)
|
if (item == null) showToast(RecoveryMessage.PlaybackUnavailable)
|
||||||
else navigation.navigateTo(Destination.Playback(id, if (action.resume) (action.title.resumeSeconds * 1000).toLong() else 0L))
|
else if (shell.person.key == personKey && (sourceScope == null || sourceScope == "${server.id}:${user.id}")) navigation.navigateTo(Destination.Playback(item.id, item.playbackPositionMs()))
|
||||||
} catch (exception: Exception) {
|
} catch (exception: Exception) {
|
||||||
if (exception is CancellationException) throw exception
|
if (exception is CancellationException) throw exception
|
||||||
showToast(RecoveryMessage.PlaybackUnavailable)
|
showToast(RecoveryMessage.PlaybackUnavailable)
|
||||||
|
|
@ -204,7 +232,8 @@ internal fun HomeTitle.libraryTitle(): LibraryTitle = LibraryTitle(
|
||||||
@Composable
|
@Composable
|
||||||
fun HomeDestination(shell: ShellState, server: JellyfinServer, user: JellyfinUser, modifier: Modifier = Modifier, model: WolfskinHomeViewModel = hiltViewModel()) {
|
fun HomeDestination(shell: ShellState, server: JellyfinServer, user: JellyfinUser, modifier: Modifier = Modifier, model: WolfskinHomeViewModel = hiltViewModel()) {
|
||||||
val identity by model.conductarrState.collectAsState()
|
val identity by model.conductarrState.collectAsState()
|
||||||
LaunchedEffect(shell.person, server.id, user.id, identity?.client, identity?.signedIn, model.audiences) { model.load(server, user, shell.person) }
|
val watchRefresh = rememberWatchRefresh("${server.id}:${user.id}")
|
||||||
|
LaunchedEffect(shell.person, server.id, user.id, identity?.client, identity?.signedIn, model.audiences, watchRefresh) { model.load(server, user, shell.person) }
|
||||||
val view = shell.rememberedView("home")
|
val view = shell.rememberedView("home")
|
||||||
LaunchedEffect(view.row, model.snapshot.rows) {
|
LaunchedEffect(view.row, model.snapshot.rows) {
|
||||||
val lane = model.snapshot.rows.drop(view.row).take(3).lastOrNull { it.catalogue }?.id
|
val lane = model.snapshot.rows.drop(view.row).take(3).lastOrNull { it.catalogue }?.id
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,7 @@ import org.justinvisser.wolfskin.design.TitleReadStatus
|
||||||
import org.justinvisser.wolfskin.design.TitleState
|
import org.justinvisser.wolfskin.design.TitleState
|
||||||
import org.justinvisser.wolfskin.design.TitleSurface
|
import org.justinvisser.wolfskin.design.TitleSurface
|
||||||
import org.justinvisser.wolfskin.tv.read.TitleReadFacade
|
import org.justinvisser.wolfskin.tv.read.TitleReadFacade
|
||||||
|
import org.justinvisser.wolfskin.tv.read.playbackPositionMs
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
|
@ -79,6 +80,7 @@ class WolfskinTitleViewModel @Inject constructor(
|
||||||
private val saveMutex = Mutex()
|
private val saveMutex = Mutex()
|
||||||
private var performerPage = 1
|
private var performerPage = 1
|
||||||
var conductarrAction: ((TitleAction) -> Unit)? = null
|
var conductarrAction: ((TitleAction) -> Unit)? = null
|
||||||
|
val watchScope: String get() = "${repository.currentServer?.id}:${repository.currentUser?.id}"
|
||||||
|
|
||||||
fun openRoute(route: String) = navigation.navigateTo(Destination.Wolfskin(route))
|
fun openRoute(route: String) = navigation.navigateTo(Destination.Wolfskin(route))
|
||||||
fun popRoute() = navigation.goBack()
|
fun popRoute() = navigation.goBack()
|
||||||
|
|
@ -87,6 +89,14 @@ class WolfskinTitleViewModel @Inject constructor(
|
||||||
/** Only an explicit preview/test choice admits assets; real catalogue ids use Conductarr. */
|
/** Only an explicit preview/test choice admits assets; real catalogue ids use Conductarr. */
|
||||||
fun load(destination: String, selected: ShellPerson, userPreferences: UserPreferences, fixtures: Boolean = BuildConfig.DEBUG, force: Boolean = false) {
|
fun load(destination: String, selected: ShellPerson, userPreferences: UserPreferences, fixtures: Boolean = BuildConfig.DEBUG, force: Boolean = false) {
|
||||||
if (!force && route == destination && ::viewer.isInitialized && viewer.key == selected.key) return
|
if (!force && route == destination && ::viewer.isInitialized && viewer.key == selected.key) return
|
||||||
|
if (force && route == destination && ::viewer.isInitialized && viewer.key == selected.key &&
|
||||||
|
title?.data?.status == TitleReadStatus.Ready && !destination.startsWith("person/") && fixture == null) {
|
||||||
|
loadJob?.cancel()
|
||||||
|
preferences = userPreferences
|
||||||
|
title?.let { it.update(it.data.copy(watchReady = false)) }
|
||||||
|
loadJob = viewModelScope.launch { readTitle(Uri.decode(destination.substringAfter('/'))) }
|
||||||
|
return
|
||||||
|
}
|
||||||
route = destination
|
route = destination
|
||||||
loadJob?.cancel()
|
loadJob?.cancel()
|
||||||
person = null
|
person = null
|
||||||
|
|
@ -132,7 +142,7 @@ class WolfskinTitleViewModel @Inject constructor(
|
||||||
if (title !== current) return
|
if (title !== current) return
|
||||||
if (heldId != null) {
|
if (heldId != null) {
|
||||||
if (current.data.status != TitleReadStatus.Ready) facade.readSaved(heldId, preferences)?.let { current.update(it) }
|
if (current.data.status != TitleReadStatus.Ready) facade.readSaved(heldId, preferences)?.let { current.update(it) }
|
||||||
held = facade.read(heldId, current.takeIf { it.data.status == TitleReadStatus.Ready }?.season, preferences)
|
held = facade.read(heldId, current.requestedSeason, preferences)
|
||||||
current.update(held)
|
current.update(held)
|
||||||
}
|
}
|
||||||
} catch (exception: Exception) {
|
} catch (exception: Exception) {
|
||||||
|
|
@ -223,13 +233,21 @@ class WolfskinTitleViewModel @Inject constructor(
|
||||||
when (action) {
|
when (action) {
|
||||||
TitleAction.Pop -> navigation.goBack()
|
TitleAction.Pop -> navigation.goBack()
|
||||||
is TitleAction.Person -> navigation.navigateTo(action.person.id.toUuidOrNull()?.let { Destination.MediaItem(it, org.jellyfin.sdk.model.api.BaseItemKind.PERSON) } ?: Destination.Wolfskin(personRoute(action.person.id)))
|
is TitleAction.Person -> navigation.navigateTo(action.person.id.toUuidOrNull()?.let { Destination.MediaItem(it, org.jellyfin.sdk.model.api.BaseItemKind.PERSON) } ?: Destination.Wolfskin(personRoute(action.person.id)))
|
||||||
is TitleAction.OpenTitle -> navigation.navigateTo(action.item.id.toUuidOrNull()?.let { Destination.MediaItem(it, when (action.item.kind) {
|
is TitleAction.OpenTitle -> {
|
||||||
"movie" -> org.jellyfin.sdk.model.api.BaseItemKind.MOVIE
|
val destination = action.item.id.toUuidOrNull()?.let { Destination.MediaItem(it, when (action.item.kind) {
|
||||||
"episode" -> org.jellyfin.sdk.model.api.BaseItemKind.EPISODE
|
"movie" -> org.jellyfin.sdk.model.api.BaseItemKind.MOVIE
|
||||||
else -> org.jellyfin.sdk.model.api.BaseItemKind.SERIES
|
"episode" -> org.jellyfin.sdk.model.api.BaseItemKind.EPISODE
|
||||||
}) } ?: Destination.Wolfskin(titleRoute(action.item.id)))
|
else -> org.jellyfin.sdk.model.api.BaseItemKind.SERIES
|
||||||
is TitleAction.Play -> play(action.item.id.toUuidOrNull(), if (action.restart) 0 else action.item.resumeSeconds * 1000)
|
}) } ?: Destination.Wolfskin(titleRoute(action.item.id))
|
||||||
is TitleAction.Trailer -> if (action.url.startsWith("jellyfin:")) play(action.url.removePrefix("jellyfin:").toUuidOrNull(), 0)
|
if (action.episodeSelection && title?.data?.watchTarget?.kind == "episode") navigation.replaceTop(destination)
|
||||||
|
else navigation.navigateTo(destination)
|
||||||
|
}
|
||||||
|
is TitleAction.Play -> {
|
||||||
|
val opened = title?.data?.watchTarget
|
||||||
|
play((if (action.restart || opened?.kind != "tv") action.item.id else opened.id).toUuidOrNull(),
|
||||||
|
restart = action.restart)
|
||||||
|
}
|
||||||
|
is TitleAction.Trailer -> if (action.url.startsWith("jellyfin:")) play(action.url.removePrefix("jellyfin:").toUuidOrNull(), restart = true)
|
||||||
else if (!TitleTrailer(context).open(action.url)) title?.toast = "No YouTube app is available to play this trailer."
|
else if (!TitleTrailer(context).open(action.url)) title?.toast = "No YouTube app is available to play this trailer."
|
||||||
is TitleAction.Version, is TitleAction.Track, is TitleAction.Watched -> savePlayback(action)
|
is TitleAction.Version, is TitleAction.Track, is TitleAction.Watched -> savePlayback(action)
|
||||||
is TitleAction.Season -> {
|
is TitleAction.Season -> {
|
||||||
|
|
@ -261,12 +279,15 @@ class WolfskinTitleViewModel @Inject constructor(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun play(id: UUID?, position: Long) {
|
private fun play(id: UUID?, restart: Boolean) {
|
||||||
|
val current = title
|
||||||
|
val playbackScope = watchScope
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
try {
|
try {
|
||||||
if (id == null) throw IllegalArgumentException("No Jellyfin playback identity")
|
if (id == null) throw IllegalArgumentException("No Jellyfin playback identity")
|
||||||
facade.preparePlayback(id)
|
val item = facade.preparePlayback(id) ?: throw IllegalArgumentException("No playable item")
|
||||||
navigation.navigateTo(Destination.Playback(id, position))
|
if (title !== current || watchScope != playbackScope) return@launch
|
||||||
|
navigation.navigateTo(Destination.Playback(item.id, if (restart) 0L else item.playbackPositionMs()))
|
||||||
} catch (exception: Exception) {
|
} catch (exception: Exception) {
|
||||||
if (exception is CancellationException) throw exception
|
if (exception is CancellationException) throw exception
|
||||||
title?.toast = RecoveryMessage.PlaybackUnavailable
|
title?.toast = RecoveryMessage.PlaybackUnavailable
|
||||||
|
|
@ -308,7 +329,8 @@ class WolfskinTitleViewModel @Inject constructor(
|
||||||
@Composable
|
@Composable
|
||||||
fun TitleDestination(route: String, shell: ShellState, preferences: UserPreferences, modifier: Modifier = Modifier, viewModel: WolfskinTitleViewModel = hiltViewModel(), fixtures: Boolean = ConductarrPreference.fixtures) {
|
fun TitleDestination(route: String, shell: ShellState, preferences: UserPreferences, modifier: Modifier = Modifier, viewModel: WolfskinTitleViewModel = hiltViewModel(), fixtures: Boolean = ConductarrPreference.fixtures) {
|
||||||
val identity by viewModel.conductarr.identity.collectAsStateWithLifecycle()
|
val identity by viewModel.conductarr.identity.collectAsStateWithLifecycle()
|
||||||
LaunchedEffect(route, shell.person.key, identity?.client, identity?.signedIn, fixtures) { viewModel.load(route, shell.person, preferences, fixtures, force = true) }
|
val watchRefresh = rememberWatchRefresh(viewModel.watchScope)
|
||||||
|
LaunchedEffect(route, shell.person.key, identity?.client, identity?.signedIn, fixtures, watchRefresh) { viewModel.load(route, shell.person, preferences, fixtures, force = true) }
|
||||||
val title = viewModel.title?.takeIf { it.person.key == shell.person.key }
|
val title = viewModel.title?.takeIf { it.person.key == shell.person.key }
|
||||||
val requests = if (route.startsWith("title/")) rememberTitleRequests(viewModel, shell, route, identity, fixtures, title) else null
|
val requests = if (route.startsWith("title/")) rememberTitleRequests(viewModel, shell, route, identity, fixtures, title) else null
|
||||||
LaunchedEffect(title?.data?.status) {
|
LaunchedEffect(title?.data?.status) {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,32 @@
|
||||||
|
package org.justinvisser.wolfskin.tv
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.lifecycle.Lifecycle
|
||||||
|
import androidx.lifecycle.LifecycleEventObserver
|
||||||
|
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||||
|
import com.github.damontecres.wholphin.util.PlaybackWatchState
|
||||||
|
|
||||||
|
/** Re-read on entry, foreground and successful stop, even when a screen remains composed. */
|
||||||
|
@Composable
|
||||||
|
internal fun rememberWatchRefresh(scope: String?): Pair<Long, Int> {
|
||||||
|
val revisions by PlaybackWatchState.revisions.collectAsState()
|
||||||
|
var foreground by remember(scope) { mutableIntStateOf(0) }
|
||||||
|
val owner = LocalLifecycleOwner.current
|
||||||
|
DisposableEffect(owner, scope) {
|
||||||
|
var initialResume = owner.lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)
|
||||||
|
val observer = LifecycleEventObserver { _, event ->
|
||||||
|
if (event == Lifecycle.Event.ON_RESUME) {
|
||||||
|
if (initialResume) initialResume = false else foreground++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
owner.lifecycle.addObserver(observer)
|
||||||
|
onDispose { owner.lifecycle.removeObserver(observer) }
|
||||||
|
}
|
||||||
|
return (revisions[scope] ?: 0L) to foreground
|
||||||
|
}
|
||||||
|
|
@ -72,7 +72,8 @@ fun WolfskinLibraryContent(
|
||||||
ShellPlaceholder("See all list unavailable", modifier)
|
ShellPlaceholder("See all list unavailable", modifier)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
LaunchedEffect(grid, grid.refreshRevision, fixture, if (route in LIBRARY_DESTINATIONS) facade.audiences else null) {
|
val watchRefresh = rememberWatchRefresh(cacheScope)
|
||||||
|
LaunchedEffect(grid, grid.refreshRevision, fixture, if (route in LIBRARY_DESTINATIONS) facade.audiences else null, watchRefresh) {
|
||||||
if (route in LIBRARY_DESTINATIONS && (!ConductarrPreference.fixtures || fixture != null)) {
|
if (route in LIBRARY_DESTINATIONS && (!ConductarrPreference.fixtures || fixture != null)) {
|
||||||
val wasLoading = grid.status == LibraryStatus.Loading
|
val wasLoading = grid.status == LibraryStatus.Loading
|
||||||
if (wasLoading && !shell.navFocused) shell.focusNav(shell.currentTab)
|
if (wasLoading && !shell.navFocused) shell.focusNav(shell.currentTab)
|
||||||
|
|
@ -82,7 +83,7 @@ fun WolfskinLibraryContent(
|
||||||
grid.status = LibraryStatus.Ready
|
grid.status = LibraryStatus.Ready
|
||||||
} else {
|
} else {
|
||||||
val store = withContext(Dispatchers.IO) { snapshots.value }
|
val store = withContext(Dispatchers.IO) { snapshots.value }
|
||||||
store.read(cacheScope, route, force = grid.refreshRevision > 0) { facade.readLibrary(user.id, route) }.collect { snapshot ->
|
store.read(cacheScope, route, force = grid.refreshRevision > 0, invalidatedAtMillis = watchRefresh.first) { facade.readLibrary(user.id, route) }.collect { snapshot ->
|
||||||
val focusedId = grid.focusedTitle?.id
|
val focusedId = grid.focusedTitle?.id
|
||||||
grid.source = snapshot.items
|
grid.source = snapshot.items
|
||||||
focusedId?.let { id -> grid.items.indexOfFirst { it.id == id }.takeIf { it >= 0 }?.let { grid.index = it } }
|
focusedId?.let { id -> grid.items.indexOfFirst { it.id == id }.takeIf { it >= 0 }?.let { grid.index = it } }
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,8 @@ import org.justinvisser.wolfskin.design.HomeTitle
|
||||||
/** All Home SDK calls and image URLs stay behind this facade, with their own required field list. */
|
/** All Home SDK calls and image URLs stay behind this facade, with their own required field list. */
|
||||||
class HomeReadFacade @Inject constructor(private val api: ApiClient, private val images: ImageUrlService, private val libraries: LibraryReadFacade) {
|
class HomeReadFacade @Inject constructor(private val api: ApiClient, private val images: ImageUrlService, private val libraries: LibraryReadFacade) {
|
||||||
val audiences get() = libraries.audiences
|
val audiences get() = libraries.audiences
|
||||||
suspend fun read(userId: UUID): HomeLibrary {
|
/** Watch rows are publishable without waiting for any library shelf. */
|
||||||
|
suspend fun readWatch(userId: UUID): HomeLibrary {
|
||||||
val resumeItems = api.itemsApi.getResumeItems(userId = userId, fields = FIELDS, mediaTypes = listOf(MediaType.VIDEO), limit = 20).content.items
|
val resumeItems = api.itemsApi.getResumeItems(userId = userId, fields = FIELDS, mediaTypes = listOf(MediaType.VIDEO), limit = 20).content.items
|
||||||
val nextItems = api.tvShowsApi.getNextUp(userId = userId, fields = FIELDS, limit = 20, enableUserData = true).content.items
|
val nextItems = api.tvShowsApi.getNextUp(userId = userId, fields = FIELDS, limit = 20, enableUserData = true).content.items
|
||||||
val series = (resumeItems + nextItems).mapNotNull { it.seriesId }.distinct().associateWith { id ->
|
val series = (resumeItems + nextItems).mapNotNull { it.seriesId }.distinct().associateWith { id ->
|
||||||
|
|
@ -35,6 +36,11 @@ class HomeReadFacade @Inject constructor(private val api: ApiClient, private val
|
||||||
}
|
}
|
||||||
val resume = resumeItems.map { title(it, series) }
|
val resume = resumeItems.map { title(it, series) }
|
||||||
val next = nextItems.map { title(it, series) }
|
val next = nextItems.map { title(it, series) }
|
||||||
|
return HomeLibrary(resume = resume, nextUp = next)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Library failures do not discard an already successful watch read. */
|
||||||
|
suspend fun readLibrary(userId: UUID): HomeLibrary {
|
||||||
val libraries = api.userViewsApi.getUserViews(userId = userId).content.items
|
val libraries = api.userViewsApi.getUserViews(userId = userId).content.items
|
||||||
val destinations = linkedMapOf<String, MutableList<HomeTitle>>()
|
val destinations = linkedMapOf<String, MutableList<HomeTitle>>()
|
||||||
val newest = mutableListOf<HomeTitle>()
|
val newest = mutableListOf<HomeTitle>()
|
||||||
|
|
@ -46,23 +52,18 @@ class HomeReadFacade @Inject constructor(private val api: ApiClient, private val
|
||||||
totals[key] = (totals[key] ?: 0) + response.totalRecordCount
|
totals[key] = (totals[key] ?: 0) + response.totalRecordCount
|
||||||
newest.addAll(api.itemsApi.getItems(userId = userId, parentId = library.id, recursive = true, includeItemTypes = listOf(BaseItemKind.MOVIE, BaseItemKind.SERIES), fields = FIELDS, sortBy = listOf(ItemSortBy.DATE_CREATED), sortOrder = listOf(SortOrder.DESCENDING), limit = 12).content.items.map { title(it) })
|
newest.addAll(api.itemsApi.getItems(userId = userId, parentId = library.id, recursive = true, includeItemTypes = listOf(BaseItemKind.MOVIE, BaseItemKind.SERIES), fields = FIELDS, sortBy = listOf(ItemSortBy.DATE_CREATED), sortOrder = listOf(SortOrder.DESCENDING), limit = 12).content.items.map { title(it) })
|
||||||
}
|
}
|
||||||
return HomeLibrary(resume, next, newest.sortedByDescending { it.addedAt }.take(12), destinations, totals)
|
return HomeLibrary(newest = newest.sortedByDescending { it.addedAt }.take(12), destinations = destinations, destinationTotals = totals)
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun playableId(userId: UUID, title: HomeTitle): UUID? {
|
/** Resolve the current checkpoint even when the hero was painted from disk. */
|
||||||
title.id.let { runCatching { UUID.fromString(it) }.getOrNull() }?.let { id ->
|
suspend fun preparePlayback(userId: UUID, title: HomeTitle): BaseItemDto? {
|
||||||
val item = api.userLibraryApi.getItem(userId = userId, itemId = id).content
|
val id = runCatching { UUID.fromString(title.id) }.getOrNull() ?: run {
|
||||||
if (item.type == BaseItemKind.SERIES) {
|
val kinds = if (title.isEpisode) listOf(BaseItemKind.EPISODE) else listOf(BaseItemKind.MOVIE)
|
||||||
return (api.tvShowsApi.getNextUp(userId = userId, seriesId = id, limit = 1).content.items.firstOrNull()
|
api.itemsApi.getItems(userId = userId, recursive = true, searchTerm = title.title, includeItemTypes = kinds, fields = FIELDS, limit = 20).content.items.firstOrNull {
|
||||||
?: api.tvShowsApi.getEpisodes(seriesId = id, userId = userId, limit = 1).content.items.firstOrNull())
|
it.name == title.title && (!title.isEpisode || (it.seriesName == title.seriesTitle && it.parentIndexNumber == title.season && it.indexNumber == title.episode))
|
||||||
?.takeIf { it.type == BaseItemKind.EPISODE }?.id
|
}?.id
|
||||||
}
|
} ?: return null
|
||||||
return item.id.takeIf { item.type == BaseItemKind.MOVIE || item.type == BaseItemKind.EPISODE }
|
return readPlayback(api, userId, id)
|
||||||
}
|
|
||||||
val kinds = if (title.isEpisode) listOf(BaseItemKind.EPISODE) else listOf(BaseItemKind.MOVIE)
|
|
||||||
return api.itemsApi.getItems(userId = userId, recursive = true, searchTerm = title.title, includeItemTypes = kinds, fields = FIELDS, limit = 20).content.items.firstOrNull {
|
|
||||||
it.name == title.title && (!title.isEpisode || (it.seriesName == title.seriesTitle && it.parentIndexNumber == title.season && it.indexNumber == title.episode))
|
|
||||||
}?.id
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun title(item: BaseItemDto, series: Map<UUID, Int?> = emptyMap()): HomeTitle {
|
private fun title(item: BaseItemDto, series: Map<UUID, Int?> = emptyMap()): HomeTitle {
|
||||||
|
|
|
||||||
|
|
@ -19,13 +19,13 @@ data class LibrarySnapshot(val items: List<LibraryTitle>, val savedAtMillis: Lon
|
||||||
|
|
||||||
/** Library snapshots share the existing atomic disk store, partitioned by server and person. */
|
/** Library snapshots share the existing atomic disk store, partitioned by server and person. */
|
||||||
class LibrarySnapshotStore(private val cache: CacheStore, private val now: () -> Long = System::currentTimeMillis) {
|
class LibrarySnapshotStore(private val cache: CacheStore, private val now: () -> Long = System::currentTimeMillis) {
|
||||||
fun read(scope: String, destination: String, force: Boolean = false, load: () -> Flow<List<LibraryTitle>>): Flow<LibrarySnapshot> = flow {
|
fun read(scope: String, destination: String, force: Boolean = false, invalidatedAtMillis: Long = 0L, load: () -> Flow<List<LibraryTitle>>): Flow<LibrarySnapshot> = flow {
|
||||||
val key = "library:$destination"
|
val key = "library:$destination"
|
||||||
val saved = try { cache.read(scope, key) } catch (_: IOException) { null }
|
val saved = try { cache.read(scope, key) } catch (_: IOException) { null }
|
||||||
val titles = saved?.let { try { decode(it.body) } catch (_: JSONException) { null } }
|
val titles = saved?.let { try { decode(it.body) } catch (_: JSONException) { null } }
|
||||||
if (saved != null && titles != null) emit(LibrarySnapshot(titles, saved.savedAtMillis, false))
|
if (saved != null && titles != null) emit(LibrarySnapshot(titles, saved.savedAtMillis, false))
|
||||||
val lifetime = (WolfskinTokens.Behaviour.libraryRefreshMinutes * 60_000).toLong()
|
val lifetime = (WolfskinTokens.Behaviour.libraryRefreshMinutes * 60_000).toLong()
|
||||||
if (!force && saved != null && titles != null && now() - saved.validatedAtMillis < lifetime) return@flow
|
if (!force && saved != null && titles != null && saved.validatedAtMillis > invalidatedAtMillis && now() - saved.validatedAtMillis < lifetime) return@flow
|
||||||
var current: List<LibraryTitle> = emptyList()
|
var current: List<LibraryTitle> = emptyList()
|
||||||
try {
|
try {
|
||||||
load().collect { chunk ->
|
load().collect { chunk ->
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
package org.justinvisser.wolfskin.tv.read
|
||||||
|
|
||||||
|
import org.jellyfin.sdk.api.client.ApiClient
|
||||||
|
import org.jellyfin.sdk.api.client.extensions.tvShowsApi
|
||||||
|
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
|
||||||
|
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||||
|
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
/** A click resolves its target and checkpoint from Jellyfin, never from a painted projection. */
|
||||||
|
internal suspend fun readPlayback(api: ApiClient, userId: UUID?, id: UUID): BaseItemDto? {
|
||||||
|
val opened = api.userLibraryApi.getItem(itemId = id, userId = userId).content
|
||||||
|
val target = if (opened.type == BaseItemKind.SERIES) {
|
||||||
|
api.tvShowsApi.getNextUp(userId = userId, seriesId = id, limit = 1, enableResumable = true, enableUserData = true)
|
||||||
|
.content.items.firstOrNull()?.id
|
||||||
|
?: api.tvShowsApi.getEpisodes(seriesId = id, userId = userId, limit = 1, enableUserData = true).content.items.firstOrNull()?.id
|
||||||
|
} else opened.id
|
||||||
|
val item = if (target == opened.id) opened else target?.let { api.userLibraryApi.getItem(itemId = it, userId = userId).content }
|
||||||
|
return item?.takeIf { it.type == BaseItemKind.EPISODE || it.type == BaseItemKind.MOVIE }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Preserve subsecond server checkpoints, including resumable rewatches marked Played. */
|
||||||
|
internal fun BaseItemDto.playbackPositionMs(): Long = (userData?.playbackPositionTicks ?: 0L) / 10_000L
|
||||||
|
|
@ -77,7 +77,7 @@ class TitleReadFacade @Inject constructor(
|
||||||
val rows = json.decodeFromString(ListSerializer(BaseItemDto.serializer()), saved.getString("items"))
|
val rows = json.decodeFromString(ListSerializer(BaseItemDto.serializer()), saved.getString("items"))
|
||||||
val numbers = saved.getJSONArray("seasonNumbers").let { array -> List(array.length()) { array.getInt(it) } }
|
val numbers = saved.getJSONArray("seasonNumbers").let { array -> List(array.length()) { array.getInt(it) } }
|
||||||
build(rows, numbers, saved.optInt("selectedSeason"), saved.getString("watch"), saved.optString("target").takeIf { it.isNotEmpty() }, preferences)
|
build(rows, numbers, saved.optInt("selectedSeason"), saved.getString("watch"), saved.optString("target").takeIf { it.isNotEmpty() }, preferences)
|
||||||
.copy(savedAt = savedTime(file), trailerUrl = saved.optString("trailer").takeIf { it.isNotEmpty() })
|
.copy(savedAt = savedTime(file), trailerUrl = saved.optString("trailer").takeIf { it.isNotEmpty() }, watchReady = false)
|
||||||
} catch (_: java.io.IOException) { null } catch (_: IllegalArgumentException) { null } catch (_: org.json.JSONException) { null }
|
} catch (_: java.io.IOException) { null } catch (_: IllegalArgumentException) { null } catch (_: org.json.JSONException) { null }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -122,8 +122,9 @@ class TitleReadFacade @Inject constructor(
|
||||||
return item.copy(providerIds = metadata.providerIds ?: item.providerIds, people = item.people ?: metadata.people)
|
return item.copy(providerIds = metadata.providerIds ?: item.providerIds, people = item.people ?: metadata.people)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A failed reachability read leaves navigation on the title instead of opening a dead player. */
|
/** Series actions re-resolve NextUp; explicit episodes and Restart retain their exact identity. */
|
||||||
suspend fun preparePlayback(id: UUID) { api.userLibraryApi.getItem(id) }
|
suspend fun preparePlayback(id: UUID): BaseItemDto? =
|
||||||
|
readPlayback(api, repository.currentUser?.id, id)
|
||||||
|
|
||||||
/** Track selection uses the same repository consumed by the upstream player. */
|
/** Track selection uses the same repository consumed by the upstream player. */
|
||||||
suspend fun apply(action: TitleAction) = withContext(Dispatchers.IO) {
|
suspend fun apply(action: TitleAction) = withContext(Dispatchers.IO) {
|
||||||
|
|
|
||||||
|
|
@ -315,16 +315,29 @@ an untouched fresh entry focuses hero button zero; intervening nav input prevent
|
||||||
that automatic transfer. Home, Libraries and Discover use the same `ShellContentFocus`
|
that automatic transfer. Home, Libraries and Discover use the same `ShellContentFocus`
|
||||||
release/repeat guard and Back ladder.
|
release/repeat guard and Back ladder.
|
||||||
|
|
||||||
`HomeReadFacade` is the only Home code importing Jellyfin API clients. Release
|
`HomeReadFacade` confines Home SDK reads to the selected user. Resume and Next up
|
||||||
variants read the selected user's Resume, Next up, newest titles and destination
|
publish independently of slow or failed library shelves; saved metadata still
|
||||||
shelves, with app-private last-known-good projections through the shared atomic
|
paints immediately, but a saved hero offers Details until watch data revalidates.
|
||||||
disk cache. A missing saved library becomes a recovery panel; a saved library
|
Home and title Play actions resolve the current Jellyfin item and its current
|
||||||
stays visible when Jellyfin is unreachable. Playback resolves an actual film or
|
checkpoint at the click, preserving millisecond positions. Series actions use
|
||||||
episode and checks Jellyfin before opening upstream's player. No season,
|
live NextUp with resumable episodes enabled; an explicitly opened episode stays
|
||||||
recommendation stub or different person's session is promoted to playback.
|
that episode, and Restart deliberately starts that displayed episode at zero.
|
||||||
|
Played flags do not discard a positive checkpoint for an intentional rewatch.
|
||||||
|
No season, recommendation stub or different person's session becomes playable.
|
||||||
An unresolved person switch guards Home and both owned/upstream library content
|
An unresolved person switch guards Home and both owned/upstream library content
|
||||||
before mounting any source read with the prior person's Jellyfin session.
|
before mounting any source read with the prior person's Jellyfin session.
|
||||||
|
|
||||||
|
Home and title watch reads refresh on entry and foreground. A successful
|
||||||
|
PlaybackStopped acknowledgement publishes a revision for the captured server/user,
|
||||||
|
so a return read that beat the stop write is refreshed again after that write.
|
||||||
|
The acknowledgement also removes that user's persisted library grid snapshots
|
||||||
|
instead of waiting for their ten-minute TTL; an in-memory revision bypasses any
|
||||||
|
older snapshot that raced invalidation. Stop-report failures remain logged and
|
||||||
|
do not publish a successful acknowledgement. External players refresh after a
|
||||||
|
supported stop result; an external app that returns no position cannot supply a
|
||||||
|
missing checkpoint. Another client's changes after the final read are outside
|
||||||
|
that click's snapshot.
|
||||||
|
|
||||||
Debug variants additionally package the locked fixture and local artwork under
|
Debug variants additionally package the locked fixture and local artwork under
|
||||||
`wolfskin-design/src/debug/assets/wolfskin-home/`; release variants exclude them.
|
`wolfskin-design/src/debug/assets/wolfskin-home/`; release variants exclude them.
|
||||||
The explicit fixture provider supplies approved household data and Home states
|
The explicit fixture provider supplies approved household data and Home states
|
||||||
|
|
@ -586,6 +599,13 @@ Selections persist through the same playback repository as the upstream player.
|
||||||
Marking a whole series asks first; an explicit episode remains the exact play and
|
Marking a whole series asks first; an explicit episode remains the exact play and
|
||||||
watched target even while another season is selected. Nested person/title visits
|
watched target even while another season is selected. Nested person/title visits
|
||||||
retain their entry's focus and selection state.
|
retain their entry's focus and selection state.
|
||||||
|
Choosing another episode from an episode page replaces only the top navigation
|
||||||
|
entry, preserving its series/Home parent. Choosing the first episode from a
|
||||||
|
series, related titles, cast and performer credits still push normal drill-ins.
|
||||||
|
Down into season chips initially focuses the selected playable season, including
|
||||||
|
offscreen later seasons; the row scrolls that chip into view and remembers
|
||||||
|
subsequent deliberate browsing. Saved title watch data cannot enable Play/Resume
|
||||||
|
or Restart before source revalidation.
|
||||||
Hardware and remote Back share the sheet/picker/request-overlay precedence, then
|
Hardware and remote Back share the sheet/picker/request-overlay precedence, then
|
||||||
pop a pushed title/person from content **or** nav. Initial loading has no content
|
pop a pushed title/person from content **or** nav. Initial loading has no content
|
||||||
target: Down stays in nav, while Back can still leave the title. A missing rating
|
target: Down stays in nav, while Back can still leave the title. A missing rating
|
||||||
|
|
|
||||||
|
|
@ -69,7 +69,7 @@ class HomeFocus(
|
||||||
private fun activate(row: HomeRow) {
|
private fun activate(row: HomeRow) {
|
||||||
when (row.kind) {
|
when (row.kind) {
|
||||||
HomeRowKind.Hero -> row.cards.firstOrNull()?.let { title ->
|
HomeRowKind.Hero -> row.cards.firstOrNull()?.let { title ->
|
||||||
onAction(if (column == 0 && row.heroKind != "pick") HomeAction.Play(title, row.heroKind == "resume") else HomeAction.Details(title))
|
onAction(if (column == 0 && row.heroKind != "pick" && row.watchReady) HomeAction.Play(title) else HomeAction.Details(title))
|
||||||
}
|
}
|
||||||
HomeRowKind.Shelf -> if (column < row.cards.size) onAction(HomeAction.Details(row.cards[column])) else onAction(HomeAction.SeeAll(row))
|
HomeRowKind.Shelf -> if (column < row.cards.size) onAction(HomeAction.Details(row.cards[column])) else onAction(HomeAction.SeeAll(row))
|
||||||
HomeRowKind.State -> onAction(HomeAction.Retry)
|
HomeRowKind.State -> onAction(HomeAction.Retry)
|
||||||
|
|
|
||||||
|
|
@ -43,9 +43,10 @@ fun HomeHero(row: HomeRow, focused: Boolean, column: Int, ratings: List<HomeRati
|
||||||
Text(title.overview, color = C.muted, style = T.body, maxLines = 2, overflow = TextOverflow.Ellipsis, modifier = Modifier.width(L.heroOverviewMax).exactLineBox(T.body))
|
Text(title.overview, color = C.muted, style = T.body, maxLines = 2, overflow = TextOverflow.Ellipsis, modifier = Modifier.width(L.heroOverviewMax).exactLineBox(T.body))
|
||||||
Spacer(Modifier.height(H.overviewBottom))
|
Spacer(Modifier.height(H.overviewBottom))
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) {
|
Row(horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) {
|
||||||
val primary = if (row.heroKind == "pick") "Details" else (if (row.heroKind == "resume") "Resume" else "Play") + if (title.isEpisode) " ${title.episodeLabel}" else ""
|
val detailsOnly = row.heroKind == "pick" || !row.watchReady
|
||||||
PickerButton(primary, focused && column == 0, true, if (row.heroKind == "pick") "about" else "playback", onFocus = { onHover(0) }, textStyle = T.button) { onButton(0) }
|
val primary = if (detailsOnly) "Details" else (if (row.heroKind == "resume") "Resume" else "Play") + if (title.isEpisode) " ${title.episodeLabel}" else ""
|
||||||
if (row.heroKind != "pick") PickerButton("Details", focused && column == 1, false, "about", onFocus = { onHover(1) }, textStyle = T.button) { onButton(1) }
|
PickerButton(primary, focused && column == 0, true, if (detailsOnly) "about" else "playback", onFocus = { onHover(0) }, textStyle = T.button) { onButton(0) }
|
||||||
|
if (!detailsOnly) PickerButton("Details", focused && column == 1, false, "about", onFocus = { onHover(1) }, textStyle = T.button) { onButton(1) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
|
|
@ -67,9 +67,10 @@ data class HomeRow(
|
||||||
val skeleton: Boolean = false,
|
val skeleton: Boolean = false,
|
||||||
val calm: Boolean = false,
|
val calm: Boolean = false,
|
||||||
val allCards: List<HomeTitle> = cards,
|
val allCards: List<HomeTitle> = cards,
|
||||||
|
val watchReady: Boolean = true,
|
||||||
) {
|
) {
|
||||||
val hasSeeAll: Boolean get() = total > cards.size
|
val hasSeeAll: Boolean get() = total > cards.size
|
||||||
val count: Int get() = when (kind) { HomeRowKind.Hero -> if (heroKind == "pick") 1 else 2; HomeRowKind.Shelf -> cards.size + if (hasSeeAll) 1 else 0; HomeRowKind.State, HomeRowKind.Explore -> 1; else -> 0 }
|
val count: Int get() = when (kind) { HomeRowKind.Hero -> if (heroKind == "pick" || !watchReady) 1 else 2; HomeRowKind.Shelf -> cards.size + if (hasSeeAll) 1 else 0; HomeRowKind.State, HomeRowKind.Explore -> 1; else -> 0 }
|
||||||
val focusable: Boolean get() = !skeleton && count > 0
|
val focusable: Boolean get() = !skeleton && count > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -84,7 +85,7 @@ data class HomeSnapshot(
|
||||||
|
|
||||||
/** Home emits integration intents, never owns the upstream player or later-slice pages. */
|
/** Home emits integration intents, never owns the upstream player or later-slice pages. */
|
||||||
sealed interface HomeAction {
|
sealed interface HomeAction {
|
||||||
data class Play(val title: HomeTitle, val resume: Boolean) : HomeAction
|
data class Play(val title: HomeTitle) : HomeAction
|
||||||
data class Details(val title: HomeTitle) : HomeAction
|
data class Details(val title: HomeTitle) : HomeAction
|
||||||
data class SeeAll(val row: HomeRow) : HomeAction
|
data class SeeAll(val row: HomeRow) : HomeAction
|
||||||
data object Discover : HomeAction
|
data object Discover : HomeAction
|
||||||
|
|
|
||||||
|
|
@ -91,6 +91,7 @@ data class TitlePageData(
|
||||||
val episodesStatus: TitleReadStatus = TitleReadStatus.Ready,
|
val episodesStatus: TitleReadStatus = TitleReadStatus.Ready,
|
||||||
val requestsAllowed: Boolean = true,
|
val requestsAllowed: Boolean = true,
|
||||||
val requestFor: String? = null,
|
val requestFor: String? = null,
|
||||||
|
val watchReady: Boolean = true,
|
||||||
)
|
)
|
||||||
|
|
||||||
/** Effects are handled by the app's authenticated facade and navigation layer. */
|
/** Effects are handled by the app's authenticated facade and navigation layer. */
|
||||||
|
|
@ -101,7 +102,7 @@ sealed interface TitleAction {
|
||||||
data class Watched(val item: TitleItem, val watched: Boolean) : TitleAction
|
data class Watched(val item: TitleItem, val watched: Boolean) : TitleAction
|
||||||
data class Trailer(val url: String) : TitleAction
|
data class Trailer(val url: String) : TitleAction
|
||||||
data class Person(val person: TitlePerson) : TitleAction
|
data class Person(val person: TitlePerson) : TitleAction
|
||||||
data class OpenTitle(val item: TitleItem) : TitleAction
|
data class OpenTitle(val item: TitleItem, val episodeSelection: Boolean = false) : TitleAction
|
||||||
data class Season(val number: Int) : TitleAction
|
data class Season(val number: Int) : TitleAction
|
||||||
data class Request(val item: TitleItem, val forSomeoneElse: Boolean) : TitleAction
|
data class Request(val item: TitleItem, val forSomeoneElse: Boolean) : TitleAction
|
||||||
data class Hide(val item: TitleItem, val hidden: Boolean) : TitleAction
|
data class Hide(val item: TitleItem, val hidden: Boolean) : TitleAction
|
||||||
|
|
|
||||||
|
|
@ -182,7 +182,12 @@ private fun titleRowHeight(row: String, headerHeight: Dp): Dp = when (row) {
|
||||||
@Composable
|
@Composable
|
||||||
private fun TitleSeasons(state: TitleState, active: Boolean) {
|
private fun TitleSeasons(state: TitleState, active: Boolean) {
|
||||||
val focusOwner = LocalTitleFocusOwner.current
|
val focusOwner = LocalTitleFocusOwner.current
|
||||||
androidx.compose.foundation.lazy.LazyRow(Modifier.height(L.row_chips).padding(top = D.chipTop), contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = L.safeSide), horizontalArrangement = Arrangement.spacedBy(D.chipGap)) {
|
val list = androidx.compose.foundation.lazy.rememberLazyListState()
|
||||||
|
val visibleChip = if (active) state.column else state.data.seasonNumbers.indexOf(state.season)
|
||||||
|
LaunchedEffect(visibleChip, state.data.seasonNumbers) {
|
||||||
|
if (visibleChip >= 0) list.animateScrollToItem(visibleChip)
|
||||||
|
}
|
||||||
|
androidx.compose.foundation.lazy.LazyRow(Modifier.height(L.row_chips).padding(top = D.chipTop), state = list, contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = L.safeSide), horizontalArrangement = Arrangement.spacedBy(D.chipGap)) {
|
||||||
items(state.data.seasonNumbers.size, key = { "season-${state.data.seasonNumbers[it]}" }, contentType = { "chip" }) { index ->
|
items(state.data.seasonNumbers.size, key = { "season-${state.data.seasonNumbers[it]}" }, contentType = { "chip" }) { index ->
|
||||||
val number = state.data.seasonNumbers[index]
|
val number = state.data.seasonNumbers[index]
|
||||||
val focused = active && state.column == index
|
val focused = active && state.column == index
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,8 @@ class TitleState(
|
||||||
var watched by mutableStateOf(initial.watchTarget.watched)
|
var watched by mutableStateOf(initial.watchTarget.watched)
|
||||||
var toast by mutableStateOf<String?>(null)
|
var toast by mutableStateOf<String?>(null)
|
||||||
private var armedCard: String? = null
|
private var armedCard: String? = null
|
||||||
|
private var seasonChosen = false
|
||||||
|
val requestedSeason: Int? get() = season.takeIf { seasonChosen }
|
||||||
private var okHeld = false
|
private var okHeld = false
|
||||||
val target: TitleItem? get() = data.playTarget ?: data.item.takeIf { it.kind == "movie" && it.held }
|
val target: TitleItem? get() = data.playTarget ?: data.item.takeIf { it.kind == "movie" && it.held }
|
||||||
val version: TitleVersion? get() = data.versions.firstOrNull { it.id == versionId } ?: data.versions.firstOrNull()
|
val version: TitleVersion? get() = data.versions.firstOrNull { it.id == versionId } ?: data.versions.firstOrNull()
|
||||||
|
|
@ -56,7 +58,7 @@ class TitleState(
|
||||||
val pendingAudio = audioIndex != data.selectedAudio
|
val pendingAudio = audioIndex != data.selectedAudio
|
||||||
val pendingSubtitle = subtitleIndex != data.selectedSubtitle
|
val pendingSubtitle = subtitleIndex != data.selectedSubtitle
|
||||||
data = value
|
data = value
|
||||||
if (wasLoading || season !in value.seasonNumbers) season = value.selectedSeason ?: value.playTarget?.season?.takeIf { it in value.seasonNumbers } ?: value.seasonNumbers.firstOrNull() ?: 1
|
if (wasLoading || (!seasonChosen && !columns.containsKey("seasons")) || season !in value.seasonNumbers) season = value.selectedSeason ?: value.playTarget?.season?.takeIf { it in value.seasonNumbers } ?: value.seasonNumbers.firstOrNull() ?: 1
|
||||||
if (!pendingWatched) watched = value.watchTarget.watched
|
if (!pendingWatched) watched = value.watchTarget.watched
|
||||||
if (!pendingVersion) versionId = value.selectedVersion
|
if (!pendingVersion) versionId = value.selectedVersion
|
||||||
if (!pendingAudio) audioIndex = value.selectedAudio
|
if (!pendingAudio) audioIndex = value.selectedAudio
|
||||||
|
|
@ -152,7 +154,8 @@ class TitleState(
|
||||||
val next = rows[index]
|
val next = rows[index]
|
||||||
val x = centers[row]?.get(column)
|
val x = centers[row]?.get(column)
|
||||||
val nearest = if (x != null) centers[next]?.entries?.minByOrNull { kotlin.math.abs(it.value - x) }?.key else null
|
val nearest = if (x != null) centers[next]?.entries?.minByOrNull { kotlin.math.abs(it.value - x) }?.key else null
|
||||||
focus(next, (columns[next] ?: nearest ?: column).coerceAtMost((rowSize(next) - 1).coerceAtLeast(0)))
|
val selected = if (next == "seasons") data.seasonNumbers.indexOf(season).takeIf { it >= 0 } else null
|
||||||
|
focus(next, (columns[next] ?: selected ?: nearest ?: column).coerceAtMost((rowSize(next) - 1).coerceAtLeast(0)))
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun rowSize(id: String): Int = when (id) {
|
private fun rowSize(id: String): Int = when (id) {
|
||||||
|
|
@ -168,15 +171,15 @@ class TitleState(
|
||||||
when (row) {
|
when (row) {
|
||||||
"cast" -> data.cast.getOrNull(column)?.let { onAction(TitleAction.Person(it)) }
|
"cast" -> data.cast.getOrNull(column)?.let { onAction(TitleAction.Person(it)) }
|
||||||
"related" -> data.related.getOrNull(column)?.let { onAction(TitleAction.OpenTitle(it)) }
|
"related" -> data.related.getOrNull(column)?.let { onAction(TitleAction.OpenTitle(it)) }
|
||||||
"episodes" -> data.episodes.getOrNull(column)?.let { onAction(TitleAction.OpenTitle(it)) }
|
"episodes" -> data.episodes.getOrNull(column)?.let { onAction(TitleAction.OpenTitle(it, episodeSelection = true)) }
|
||||||
"seasons" -> { season = data.seasonNumbers[column]; onAction(TitleAction.Season(season)) }
|
"seasons" -> { seasonChosen = true; season = data.seasonNumbers[column]; onAction(TitleAction.Season(season)) }
|
||||||
"header" -> options.getOrNull(column)?.let { activateOption(it.id) }
|
"header" -> options.getOrNull(column)?.let { activateOption(it.id) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun activateOption(id: String) {
|
private fun activateOption(id: String) {
|
||||||
when (id) {
|
when (id) {
|
||||||
"play", "restart" -> target?.let { onAction(TitleAction.Play(it, id == "restart")) }
|
"play", "restart" -> if (data.watchReady) target?.let { onAction(TitleAction.Play(it, id == "restart")) }
|
||||||
"version" -> { sheetIndex = data.versions.indexOf(version).coerceAtLeast(0); sheet = TitleSheet.Version }
|
"version" -> { sheetIndex = data.versions.indexOf(version).coerceAtLeast(0); sheet = TitleSheet.Version }
|
||||||
"tracks" -> { sheetIndex = 0; sheet = TitleSheet.Tracks }
|
"tracks" -> { sheetIndex = 0; sheet = TitleSheet.Tracks }
|
||||||
"more" -> { sheetIndex = 0; sheet = TitleSheet.MediaInfo }
|
"more" -> { sheetIndex = 0; sheet = TitleSheet.MediaInfo }
|
||||||
|
|
@ -260,7 +263,8 @@ fun titleOptions(state: TitleState): List<TitleOption> = buildList {
|
||||||
return@buildList
|
return@buildList
|
||||||
}
|
}
|
||||||
if (item.held) {
|
if (item.held) {
|
||||||
if (target != null) {
|
if (!data.watchReady) add(TitleOption("retry", "Try again", "wifi", primary = true, icon = false))
|
||||||
|
if (target != null && data.watchReady) {
|
||||||
val verb = if (target.resumeSeconds > 0) "Resume" else "Play"
|
val verb = if (target.resumeSeconds > 0) "Resume" else "Play"
|
||||||
add(TitleOption("play", verb + if (target.kind == "episode") " ${target.episodeLabel}" else "", "play", primary = true, icon = false))
|
add(TitleOption("play", verb + if (target.kind == "episode") " ${target.episodeLabel}" else "", "play", primary = true, icon = false))
|
||||||
if (target.resumeSeconds > 0) add(TitleOption("restart", "Restart", "restart", "play from the beginning"))
|
if (target.resumeSeconds > 0) add(TitleOption("restart", "Restart", "restart", "play from the beginning"))
|
||||||
|
|
|
||||||
|
|
@ -36,8 +36,8 @@ class HomeKeysTest {
|
||||||
for (key in 0 until keys.length()) press(keys.getString(key))
|
for (key in 0 until keys.length()) press(keys.getString(key))
|
||||||
assertEquals(path.getString("goal"), path.getInt("presses"), keys.length())
|
assertEquals(path.getString("goal"), path.getInt("presses"), keys.length())
|
||||||
when (index) {
|
when (index) {
|
||||||
0 -> { assertEquals("mindel", shell.person.key); assertTrue(action is HomeAction.Play); assertTrue((action as HomeAction.Play).resume) }
|
0 -> { assertEquals("mindel", shell.person.key); assertTrue(action is HomeAction.Play) }
|
||||||
1 -> { assertTrue(action is HomeAction.Play); assertFalse((action as HomeAction.Play).resume); assertEquals("Yu Yu Hakusho", (action as HomeAction.Play).title.seriesTitle) }
|
1 -> { assertTrue(action is HomeAction.Play); assertEquals("Yu Yu Hakusho", (action as HomeAction.Play).title.seriesTitle) }
|
||||||
2 -> assertEquals("trending", fixture.snapshot("justin").rows[shell.view.row].id)
|
2 -> assertEquals("trending", fixture.snapshot("justin").rows[shell.view.row].id)
|
||||||
3 -> { val row = fixture.snapshot("justin").rows[shell.view.row]; assertEquals("trending", row.id); assertEquals(row.cards.size, shell.view.cards[row.id]); assertTrue(row.hasSeeAll) }
|
3 -> { val row = fixture.snapshot("justin").rows[shell.view.row]; assertEquals("trending", row.id); assertEquals(row.cards.size, shell.view.cards[row.id]); assertTrue(row.hasSeeAll) }
|
||||||
4 -> { val row = fixture.snapshot("justin").rows[shell.view.row]; assertEquals("lib-shows", row.id); assertEquals(2, shell.view.cards[row.id]); assertFalse(shell.navFocused) }
|
4 -> { val row = fixture.snapshot("justin").rows[shell.view.row]; assertEquals("lib-shows", row.id); assertEquals(2, shell.view.cards[row.id]); assertFalse(shell.navFocused) }
|
||||||
|
|
@ -170,7 +170,6 @@ class HomeKeysTest {
|
||||||
assertEquals(0, shell.view.cards["hero"])
|
assertEquals(0, shell.view.cards["hero"])
|
||||||
press("OK")
|
press("OK")
|
||||||
val play = action as HomeAction.Play
|
val play = action as HomeAction.Play
|
||||||
assertFalse(play.resume)
|
|
||||||
assertEquals("Yu Yu Hakusho", play.title.seriesTitle)
|
assertEquals("Yu Yu Hakusho", play.title.seriesTitle)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue