From 2f3cdf233976e60745c9be10e7c920cad1d83674 Mon Sep 17 00:00:00 2001 From: justinzeus Date: Sat, 7 Nov 2026 23:42:12 +0100 Subject: [PATCH] Play held-title themes only while their page is resumed --- .../wolfskin/tv/ConductarrTitleSurfaceTest.kt | 2 +- .../wolfskin/tv/DiscoverRefreshMotionTest.kt | 6 +- .../wolfskin/tv/DiscoverTitleTimingTest.kt | 2 +- .../wolfskin/tv/PersonSwitchThreadingTest.kt | 2 +- .../wolfskin/tv/TitleAsyncFocusTest.kt | 6 +- .../wolfskin/tv/TitleEntryBackTest.kt | 2 +- .../tv/TitlePlaybackNavigationTest.kt | 4 +- .../wolfskin/tv/TitleRequestSafetyTest.kt | 2 +- .../wolfskin/tv/TitleThemeMusicTest.kt | 225 ++++++++++++++++++ .../wolfskin/tv/TitleDestination.kt | 29 ++- docker/README.md | 10 + 11 files changed, 276 insertions(+), 14 deletions(-) create mode 100644 app/src/test/java/org/justinvisser/wolfskin/tv/TitleThemeMusicTest.kt diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrTitleSurfaceTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrTitleSurfaceTest.kt index 26f7d3f4..8fad3a82 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrTitleSurfaceTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrTitleSurfaceTest.kt @@ -50,7 +50,7 @@ class ConductarrTitleSurfaceTest { runBlocking { session.authenticate() } val facade = mockk() coEvery { facade.findHeld(any(), any()) } returns null - val viewModel = WolfskinTitleViewModel(rule.activity, facade, mockk(), mockk(relaxed = true), session, mockk(relaxed = true)) + val viewModel = WolfskinTitleViewModel(rule.activity, facade, mockk(), mockk(relaxed = true), session, mockk(relaxed = true), mockk(relaxed = true)) val revokedLookup = CompletableDeferred() try { val route = titleRoute("movie:42") diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/DiscoverRefreshMotionTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/DiscoverRefreshMotionTest.kt index 49036e95..d6b6d14c 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/DiscoverRefreshMotionTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/DiscoverRefreshMotionTest.kt @@ -53,7 +53,7 @@ class DiscoverRefreshMotionTest { runBlocking { session.authenticate() } val facade = mockk() coEvery { facade.findHeld(any(), any()) } returns null - val model = WolfskinTitleViewModel(rule.activity, facade, mockk(), NavigationManager(), session, mockk(relaxed = true)) + val model = WolfskinTitleViewModel(rule.activity, facade, mockk(), NavigationManager(), session, mockk(relaxed = true), mockk(relaxed = true)) val person = ShellPerson("shared", "Shared", "S", false) val route = titleRoute("tv:44") val shell = ShellState(listOf(person), person.key).apply { pickerVisible = false; enterDestination("discover"); enterDestination(route) } @@ -94,7 +94,7 @@ class DiscoverRefreshMotionTest { @Test fun loadedFixtureRefreshKeepsTheSameReadyHeaderAndCatalogueIdentity() { ConductarrSession(MemoryCacheStore()).use { session -> val model = WolfskinTitleViewModel(rule.activity, mockk(relaxed = true), TitleFixtureProvider(rule.activity), - NavigationManager(), session, mockk(relaxed = true)) + NavigationManager(), session, mockk(relaxed = true), mockk(relaxed = true)) val person = ShellPerson("justin", "Justin", "J", true) val route = titleRoute("movie-the-amazing-spider-man-2012") val shell = ShellState(listOf(person), person.key).apply { pickerVisible = false; enterDestination("discover"); enterDestination(route) } @@ -145,7 +145,7 @@ class DiscoverRefreshMotionTest { val facade = mockk() coEvery { facade.readSaved(any(), any()) } returns null coEvery { facade.read(any(), any(), any()) } returns held - val model = WolfskinTitleViewModel(rule.activity, facade, mockk(), NavigationManager(), session, mockk(relaxed = true)) + val model = WolfskinTitleViewModel(rule.activity, facade, mockk(), NavigationManager(), session, mockk(relaxed = true), mockk(relaxed = true)) val person = ShellPerson("shared", "Shared", "S", false) val route = titleRoute(id.toString()) val shell = ShellState(listOf(person), person.key).apply { pickerVisible = false; enterDestination(route) } diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/DiscoverTitleTimingTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/DiscoverTitleTimingTest.kt index 9c10c6c5..cd5f3dd0 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/DiscoverTitleTimingTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/DiscoverTitleTimingTest.kt @@ -46,7 +46,7 @@ class DiscoverTitleTimingTest { val facade = mockk() coEvery { facade.findHeld(any(), any()) } returns null val navigation = NavigationManager().apply { replace(Destination.Wolfskin("discover")) } - val model = WolfskinTitleViewModel(rule.activity, facade, mockk(), navigation, session, mockk(relaxed = true)) + val model = WolfskinTitleViewModel(rule.activity, facade, mockk(), navigation, session, mockk(relaxed = true), mockk(relaxed = true)) val shell = ShellState(listOf(ShellPerson("shared", "Shared", "S", false)), "shared").apply { pickerVisible = false; pickerLaunch = false; enterDestination("discover") } diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/PersonSwitchThreadingTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/PersonSwitchThreadingTest.kt index 9a1ff11e..2ae2763a 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/PersonSwitchThreadingTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/PersonSwitchThreadingTest.kt @@ -220,7 +220,7 @@ class PersonSwitchThreadingTest { val cache = mockk(relaxed = true) every { cache.read(any()) } returns null val homeModel = WolfskinHomeViewModel(context, home, cache, mockk(relaxed = true), WolfskinSeeAllLists(), conductarr) - val titleModel = WolfskinTitleViewModel(context, mockk(), mockk(), mockk(relaxed = true), conductarr, repository) + val titleModel = WolfskinTitleViewModel(context, mockk(), mockk(), mockk(relaxed = true), conductarr, repository, mockk(relaxed = true)) try { conductarr.select("${server.id}:${user.id}", user.accessToken, fake.url) conductarr.authenticate() diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/TitleAsyncFocusTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/TitleAsyncFocusTest.kt index a6e70319..55f81ce4 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/TitleAsyncFocusTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/TitleAsyncFocusTest.kt @@ -60,7 +60,7 @@ class TitleAsyncFocusTest { val facade = mockk() coEvery { facade.readSaved(id, any()) } returns null coEvery { facade.read(id, any(), any()) } coAnswers { ready.await() } - viewModel = WolfskinTitleViewModel(rule.activity, facade, mockk(), navigation, ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()), mockk(relaxed = true)) + viewModel = WolfskinTitleViewModel(rule.activity, facade, mockk(), navigation, ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()), mockk(relaxed = true), mockk(relaxed = true)) val person = ShellPerson("justin", "Justin", "J", false) val route = titleRoute(id.toString()) navigation.navigateTo(Destination.Wolfskin(route)) @@ -103,7 +103,7 @@ class TitleAsyncFocusTest { val facade = mockk() coEvery { facade.readPersonSaved(id) } returns null coEvery { facade.readPerson(id, 0) } coAnswers { reply.await() } - viewModel = WolfskinTitleViewModel(rule.activity, facade, mockk(), navigation, ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()), mockk(relaxed = true)) + viewModel = WolfskinTitleViewModel(rule.activity, facade, mockk(), navigation, ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()), mockk(relaxed = true), mockk(relaxed = true)) val viewer = ShellPerson("justin", "Justin", "J", false) val route = personRoute(id.toString()) navigation.navigateTo(Destination.Wolfskin(route)) @@ -150,7 +150,7 @@ class TitleAsyncFocusTest { reads.incrementAndGet() throw java.io.IOException("offline") } - viewModel = WolfskinTitleViewModel(rule.activity, facade, mockk(), navigation, ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()), mockk(relaxed = true)) + viewModel = WolfskinTitleViewModel(rule.activity, facade, mockk(), navigation, ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()), mockk(relaxed = true), mockk(relaxed = true)) val viewer = ShellPerson("justin", "Justin", "J", false) val route = titleRoute(id.toString()) navigation.navigateTo(Destination.Wolfskin(route)) diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/TitleEntryBackTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/TitleEntryBackTest.kt index d2102c49..0fc6c7b7 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/TitleEntryBackTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/TitleEntryBackTest.kt @@ -112,7 +112,7 @@ class TitleEntryBackTest { } conductarr = ConductarrSession(MemoryCacheStore()) viewModel = WolfskinTitleViewModel(activity, facade, mockk(), navigation, conductarr, - mockk(relaxed = true)) + mockk(relaxed = true), mockk(relaxed = true)) shell.enterDestination(route) navigation.navigateTo(destination) val home = HomeSnapshot(listOf(HomeRow("hero", HomeRowKind.Hero, cards = listOf(HomeTitle("home-film", "Home film", "movie"))))) diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/TitlePlaybackNavigationTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/TitlePlaybackNavigationTest.kt index 75c92ed5..1838f45c 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/TitlePlaybackNavigationTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/TitlePlaybackNavigationTest.kt @@ -55,7 +55,7 @@ class TitlePlaybackNavigationTest { 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(relaxed = true)) + val model = WolfskinTitleViewModel(RuntimeEnvironment.getApplication(), facade, mockk(), navigation, session, mockk(relaxed = true), mockk(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()))) @@ -109,7 +109,7 @@ class TitlePlaybackNavigationTest { 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(relaxed = true)) + val model = WolfskinTitleViewModel(RuntimeEnvironment.getApplication(), facade, mockk(), navigation, session, mockk(relaxed = true), mockk(relaxed = true)) try { model.load(titleRoute(seriesId.toString()), person, preferences, fixtures = false) advanceUntilIdle() diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/TitleRequestSafetyTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/TitleRequestSafetyTest.kt index fbfd10eb..d022a821 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/TitleRequestSafetyTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/TitleRequestSafetyTest.kt @@ -46,7 +46,7 @@ class TitleRequestSafetyTest { runBlocking { session.authenticate() } val facade = mockk() coEvery { facade.findHeld(any(), any()) } returns null - val model = WolfskinTitleViewModel(rule.activity, facade, mockk(), NavigationManager(), session, mockk(relaxed = true)) + val model = WolfskinTitleViewModel(rule.activity, facade, mockk(), NavigationManager(), session, mockk(relaxed = true), mockk(relaxed = true)) val person = ShellPerson("shared", "Shared", "S", true) val route = titleRoute("movie:42") val shell = ShellState(listOf(person), person.key).apply { pickerVisible = false; enterDestination("discover"); enterDestination(route) } diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/TitleThemeMusicTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/TitleThemeMusicTest.kt new file mode 100644 index 00000000..f2bcdf90 --- /dev/null +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/TitleThemeMusicTest.kt @@ -0,0 +1,225 @@ +package org.justinvisser.wolfskin.tv + +import android.app.Application +import androidx.activity.ComponentActivity +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.LifecycleRegistry +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.viewModelScope +import com.github.damontecres.wholphin.preferences.AppPreferences +import com.github.damontecres.wholphin.preferences.InterfacePreferences +import com.github.damontecres.wholphin.preferences.ThemeSongVolume +import com.github.damontecres.wholphin.preferences.UserPreferences +import com.github.damontecres.wholphin.services.NavigationManager +import com.github.damontecres.wholphin.services.ThemeSongPlayer +import com.github.damontecres.wholphin.ui.nav.Destination +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.cancel +import org.jellyfin.sdk.model.api.BaseItemDto +import org.jellyfin.sdk.model.api.BaseItemKind +import org.junit.After +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.ScreenEntry +import org.justinvisser.wolfskin.design.ShellPerson +import org.justinvisser.wolfskin.design.ShellState +import org.justinvisser.wolfskin.design.TitleItem +import org.justinvisser.wolfskin.design.TitlePageData +import org.justinvisser.wolfskin.design.WolfskinTheme +import org.justinvisser.wolfskin.tv.read.TitleReadFacade +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode +import java.util.UUID + +/** Real Compose/lifecycle transitions must relinquish theme ownership before another page or video. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") +@GraphicsMode(GraphicsMode.Mode.NATIVE) +class TitleThemeMusicTest { + @get:Rule val rule = createAndroidComposeRule() + private val movieId = UUID.fromString("00000000-0000-0000-0000-000000000041") + private val seriesId = UUID.fromString("00000000-0000-0000-0000-000000000042") + private val episodeId = UUID.fromString("00000000-0000-0000-0000-000000000043") + private val movie = TitleItem(movieId.toString(), "Held movie", "movie", held = true) + private val playback = ThemePlayback() + private val item = mutableStateOf(movie) + private val preferences = mutableStateOf(preferences(ThemeSongVolume.HIGH)) + private val active = mutableStateOf(true) + private val visible = mutableStateOf(true) + private val viewer = mutableStateOf("server:justin") + private val lifecycleOwner = ThemeLifecycleOwner() + private var model: WolfskinTitleViewModel? = null + private var session: ConductarrSession? = null + + @After fun dispose() { + model?.viewModelScope?.cancel() + session?.close() + } + + @Test fun heldMovieStartsOnceAcrossRefreshAndRecreationNeverOverlaps() { + mountEffect() + assertEquals(listOf("play:$movieId"), playback.events) + rule.runOnIdle { item.value = movie.copy(name = "Refreshed metadata") } + rule.waitForIdle() + assertEquals(listOf("play:$movieId"), playback.events) + rule.runOnIdle { visible.value = false } + rule.waitForIdle() + assertEquals(listOf("play:$movieId", "stop"), playback.events) + rule.runOnIdle { visible.value = true } + rule.waitForIdle() + assertEquals(listOf("play:$movieId", "stop", "play:$movieId"), playback.events) + } + + @Test fun episodeUsesItsSeriesAndMissingSeriesStaysSilent() { + item.value = movie.copy(id = episodeId.toString(), kind = "episode", seriesId = seriesId.toString()) + mountEffect() + assertEquals(listOf("play:$seriesId"), playback.events) + rule.runOnIdle { item.value = item.value!!.copy(seriesId = null) } + rule.waitForIdle() + assertEquals(listOf("play:$seriesId", "stop"), playback.events) + } + + @Test fun catalogueOnlyAndDisabledTitlesStaySilentAndDisablingStopsPlayingTheme() { + item.value = movie.copy(id = "movie:42", held = false) + mountEffect() + assertEquals(emptyList(), playback.events) + rule.runOnIdle { + item.value = movie + preferences.value = preferences(ThemeSongVolume.DISABLED) + } + rule.waitForIdle() + assertEquals(emptyList(), playback.events) + rule.runOnIdle { preferences.value = preferences(ThemeSongVolume.HIGH) } + rule.waitForIdle() + assertEquals(listOf("play:$movieId"), playback.events) + rule.runOnIdle { preferences.value = preferences(ThemeSongVolume.DISABLED) } + rule.waitForIdle() + assertEquals(listOf("play:$movieId", "stop"), playback.events) + } + + @Test fun pausedPageStopsAndOnlyResumedPageRestarts() { + rule.runOnIdle { lifecycleOwner.registry.currentState = Lifecycle.State.STARTED } + mountEffect(resumed = false) + assertEquals(emptyList(), playback.events) + rule.runOnIdle { lifecycleOwner.registry.currentState = Lifecycle.State.RESUMED } + rule.waitForIdle() + assertEquals(listOf("play:$movieId"), playback.events) + rule.runOnIdle { lifecycleOwner.registry.currentState = Lifecycle.State.CREATED } + rule.waitForIdle() + assertEquals(listOf("play:$movieId", "stop"), playback.events) + rule.runOnIdle { lifecycleOwner.registry.currentState = Lifecycle.State.RESUMED } + rule.waitForIdle() + assertEquals(listOf("play:$movieId", "stop", "play:$movieId"), playback.events) + } + + @Test fun outgoingEntryStopsEvenWhileStillComposedAndCanResumeOnBack() { + mountEffect() + rule.runOnIdle { active.value = false } + rule.waitForIdle() + assertEquals(listOf("play:$movieId", "stop"), playback.events) + rule.runOnIdle { item.value = movie.copy(overview = "Exit animation still composed") } + rule.waitForIdle() + assertNull(playback.playing) + rule.runOnIdle { active.value = true } + rule.waitForIdle() + assertEquals(listOf("play:$movieId", "stop", "play:$movieId"), playback.events) + } + + @Test fun personSwitchReleasesOldThemeAndSignOutDisposesIt() { + mountEffect() + rule.runOnIdle { viewer.value = "server:shared" } + rule.waitForIdle() + assertEquals(listOf("play:$movieId", "stop", "play:$movieId"), playback.events) + rule.runOnIdle { visible.value = false } + rule.waitForIdle() + assertNull(playback.playing) + assertEquals("stop", playback.events.last()) + } + + @Test fun realEpisodePageStartsSeriesThemeAndStopsBeforePlaybackNavigation() { + val series = movie.copy(id = seriesId.toString(), name = "Series", kind = "tv") + val episode = movie.copy(id = episodeId.toString(), name = "Episode", kind = "episode", seriesId = seriesId.toString()) + val navigation = NavigationManager() + val route = titleRoute(episodeId.toString()) + val destination = Destination.Wolfskin(route) + val person = ShellPerson("justin", "Justin", "J", false) + val shell = ShellState(listOf(person), person.key).apply { + pickerVisible = false; pickerLaunch = false; enterDestination(route) + } + val facade = mockk() + coEvery { facade.readSaved(episodeId, any()) } returns null + coEvery { facade.read(episodeId, any(), any()) } returns TitlePageData(series, playTarget = episode, watchTarget = episode) + coEvery { facade.preparePlayback(episodeId) } returns BaseItemDto(id = episodeId, type = BaseItemKind.EPISODE) + val conductarr = ConductarrSession(MemoryCacheStore()).also { session = it } + val titleModel = WolfskinTitleViewModel(rule.activity, facade, mockk(), navigation, conductarr, + mockk(relaxed = true), playback.player).also { model = it } + rule.runOnIdle { navigation.navigateTo(destination) } + rule.setContent { + WolfskinTheme { + ScreenEntry(shell, route, navigation.backStack.lastOrNull() == destination) { + TitleDestination(route, shell, preferences.value, viewModel = titleModel, fixtures = false) + } + } + } + rule.waitUntil(5_000) { playback.playing == seriesId } + assertEquals(listOf("play:$seriesId"), playback.events) + playback.onStop = { if (playback.playing != null) assertEquals(destination, navigation.backStack.last()) } + rule.runOnIdle { titleModel.title!!.click("header", 0) } + rule.waitUntil(5_000) { navigation.backStack.last() is Destination.Playback } + playback.onStop = {} + rule.waitForIdle() + assertEquals(Destination.Playback(episodeId, 0L), navigation.backStack.last()) + assertNull(playback.playing) + assertEquals(1, playback.events.count { it.startsWith("play:") }) + } + + private fun mountEffect(resumed: Boolean = true) { + if (resumed) rule.runOnIdle { lifecycleOwner.registry.currentState = Lifecycle.State.RESUMED } + rule.setContent { + CompositionLocalProvider(LocalLifecycleOwner provides lifecycleOwner) { + if (visible.value) TitleThemeMusic(item.value, preferences.value, playback.player, active.value, viewer.value) + } + } + rule.waitForIdle() + } + + private fun preferences(volume: ThemeSongVolume) = UserPreferences( + AppPreferences.newBuilder().setInterfacePreferences( + InterfacePreferences.newBuilder().setPlayThemeSongs(volume).build(), + ).build(), null, + ) + + private class ThemeLifecycleOwner : LifecycleOwner { + val registry = LifecycleRegistry(this) + override val lifecycle: Lifecycle get() = registry + } + + private class ThemePlayback { + val player = mockk() + val events = mutableListOf() + var playing: UUID? = null + var onStop: () -> Unit = {} + init { + every { player.playThemeFor(any()) } answers { + assertNull("Two title entries must never own theme playback simultaneously", playing) + playing = firstArg() + events += "play:$playing" + } + every { player.stop() } answers { + onStop() + playing = null + events += "stop" + } + } + } +} diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/TitleDestination.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/TitleDestination.kt index f6423f7c..aeb7f687 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/TitleDestination.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/TitleDestination.kt @@ -6,6 +6,7 @@ import androidx.activity.compose.BackHandler import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier @@ -14,6 +15,7 @@ import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.input.key.KeyEventType import androidx.compose.ui.input.key.type import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.compose.LifecycleResumeEffect import org.justinvisser.wolfskin.design.DiscoverOverlay import org.justinvisser.wolfskin.design.ShellKey import org.justinvisser.wolfskin.design.isScreenEntryActive @@ -23,7 +25,9 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.github.damontecres.wholphin.BuildConfig import com.github.damontecres.wholphin.preferences.UserPreferences +import com.github.damontecres.wholphin.preferences.ThemeSongVolume import com.github.damontecres.wholphin.services.NavigationManager +import com.github.damontecres.wholphin.services.ThemeSongPlayer import com.github.damontecres.wholphin.ui.nav.Destination import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext @@ -66,6 +70,7 @@ class WolfskinTitleViewModel @Inject constructor( private val navigation: NavigationManager, val conductarr: ConductarrSession, private val repository: ServerRepository, + val themeSongPlayer: ThemeSongPlayer, ) : ViewModel() { var title by mutableStateOf(null) private set @@ -265,7 +270,10 @@ class WolfskinTitleViewModel @Inject constructor( 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 { + themeSongPlayer.stop() + 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.Season -> { loadJob?.cancel() @@ -304,6 +312,7 @@ class WolfskinTitleViewModel @Inject constructor( if (id == null) throw IllegalArgumentException("No Jellyfin playback identity") val item = facade.preparePlayback(id) ?: throw IllegalArgumentException("No playable item") if (title !== current || watchScope != playbackScope) return@launch + themeSongPlayer.stop() navigation.navigateTo(Destination.Playback(item.id, if (restart) 0L else item.playbackPositionMs())) } catch (exception: Exception) { if (exception is CancellationException) throw exception @@ -349,6 +358,9 @@ fun TitleDestination(route: String, shell: ShellState, preferences: UserPreferen 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 } + TitleThemeMusic(title?.data?.item, preferences, viewModel.themeSongPlayer, + active = isScreenEntryActive() && route.startsWith("title/") && title?.data?.status == TitleReadStatus.Ready, + owner = "${shell.person.key}:${viewModel.watchScope}") val requests = if (route.startsWith("title/")) rememberTitleRequests(viewModel, shell, route, identity, fixtures, title) else null LaunchedEffect(title?.data?.status) { if (title?.data?.status == TitleReadStatus.Ready) shell.navFocused = false @@ -371,4 +383,19 @@ fun TitleDestination(route: String, shell: ShellState, preferences: UserPreferen } } +/** Only the resumed, current title entry owns theme playback; episodes borrow their series theme. */ +@Composable +internal fun TitleThemeMusic(item: TitleItem?, preferences: UserPreferences, player: ThemeSongPlayer, active: Boolean, owner: String) { + val itemId = remember(item?.id, item?.seriesId, item?.held, item?.kind) { + item?.takeIf { it.held }?.let { (if (it.kind == "episode") it.seriesId else it.id)?.toUuidOrNull() } + } + val volume = preferences.appPreferences.interfacePreferences.playThemeSongs + if (active && itemId != null && volume != ThemeSongVolume.DISABLED && volume != ThemeSongVolume.UNRECOGNIZED) { + LifecycleResumeEffect(itemId, owner, volume) { + player.playThemeFor(itemId) + onPauseOrDispose { player.stop() } + } + } +} + private fun String.toUuidOrNull(): UUID? = try { UUID.fromString(this) } catch (_: IllegalArgumentException) { null } diff --git a/docker/README.md b/docker/README.md index 2b4357bf..abe554a0 100644 --- a/docker/README.md +++ b/docker/README.md @@ -1010,6 +1010,16 @@ votes enrich those real episode identities; no catalogue stub becomes playable. Debug-only `wolfskin-titles.json` and local artwork remain behind the explicit Advanced fixture switch; release never packages or selects them. +Held title pages use the existing upstream `ThemeSongPlayer` while their current +navigation entry is resumed. Episodes use the series Jellyfin id, never the +episode id; catalogue-only titles have no theme identity. The existing +`AppPreference.PlayThemeMusic` controls playback (Disabled is silent; High is +volume 0.5). Pause, disposal, outgoing navigation and person/session changes +release the theme; video and trailer hand-offs stop it before opening playback. +`TitleThemeMusicTest` exercises these ownership transitions with a fake player +and the real native title destination. Jellyfin still needs an indexed local +`theme.*` file supplied by the library publisher; empty theme lists stay silent. + Held trailers use Jellyfin remote/local trailer data. YouTube URLs target an installed YouTube handler such as SmartTube, never a browser. No handler leaves the title visible with a calm message. Titles nobody holds have no Trailer