Merge pull request 'Play held-title theme music while the title page is resumed' (#71) from fm/theme-music-title into fm/tv-round2

This commit is contained in:
justinzeus 2026-11-07 23:47:29 +01:00
commit 5f5b5ffd54
11 changed files with 276 additions and 14 deletions

View file

@ -50,7 +50,7 @@ class ConductarrTitleSurfaceTest {
runBlocking { session.authenticate() }
val facade = mockk<TitleReadFacade>()
coEvery { facade.findHeld(any(), any()) } returns null
val viewModel = WolfskinTitleViewModel(rule.activity, facade, mockk(), mockk<NavigationManager>(relaxed = true), session, mockk<ServerRepository>(relaxed = true))
val viewModel = WolfskinTitleViewModel(rule.activity, facade, mockk(), mockk<NavigationManager>(relaxed = true), session, mockk<ServerRepository>(relaxed = true), mockk(relaxed = true))
val revokedLookup = CompletableDeferred<java.util.UUID?>()
try {
val route = titleRoute("movie:42")

View file

@ -53,7 +53,7 @@ class DiscoverRefreshMotionTest {
runBlocking { session.authenticate() }
val facade = mockk<TitleReadFacade>()
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<TitleReadFacade>()
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) }

View file

@ -46,7 +46,7 @@ class DiscoverTitleTimingTest {
val facade = mockk<TitleReadFacade>()
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")
}

View file

@ -220,7 +220,7 @@ class PersonSwitchThreadingTest {
val cache = mockk<org.justinvisser.wolfskin.tv.read.HomeLibraryCache>(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()

View file

@ -60,7 +60,7 @@ class TitleAsyncFocusTest {
val facade = mockk<TitleReadFacade>()
coEvery { facade.readSaved(id, any()) } returns null
coEvery { facade.read(id, any(), any()) } coAnswers { ready.await() }
viewModel = WolfskinTitleViewModel(rule.activity, facade, mockk<TitleFixtureProvider>(), navigation, ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()), mockk<com.github.damontecres.wholphin.data.ServerRepository>(relaxed = true))
viewModel = WolfskinTitleViewModel(rule.activity, facade, mockk<TitleFixtureProvider>(), navigation, ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()), mockk<com.github.damontecres.wholphin.data.ServerRepository>(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<TitleReadFacade>()
coEvery { facade.readPersonSaved(id) } returns null
coEvery { facade.readPerson(id, 0) } coAnswers { reply.await() }
viewModel = WolfskinTitleViewModel(rule.activity, facade, mockk<TitleFixtureProvider>(), navigation, ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()), mockk<com.github.damontecres.wholphin.data.ServerRepository>(relaxed = true))
viewModel = WolfskinTitleViewModel(rule.activity, facade, mockk<TitleFixtureProvider>(), navigation, ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()), mockk<com.github.damontecres.wholphin.data.ServerRepository>(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<TitleFixtureProvider>(), navigation, ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()), mockk<com.github.damontecres.wholphin.data.ServerRepository>(relaxed = true))
viewModel = WolfskinTitleViewModel(rule.activity, facade, mockk<TitleFixtureProvider>(), navigation, ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()), mockk<com.github.damontecres.wholphin.data.ServerRepository>(relaxed = true), mockk(relaxed = true))
val viewer = ShellPerson("justin", "Justin", "J", false)
val route = titleRoute(id.toString())
navigation.navigateTo(Destination.Wolfskin(route))

View file

@ -112,7 +112,7 @@ class TitleEntryBackTest {
}
conductarr = ConductarrSession(MemoryCacheStore())
viewModel = WolfskinTitleViewModel(activity, facade, mockk<TitleFixtureProvider>(), navigation, conductarr,
mockk<ServerRepository>(relaxed = true))
mockk<ServerRepository>(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")))))

View file

@ -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<ServerRepository>(relaxed = true))
val model = WolfskinTitleViewModel(RuntimeEnvironment.getApplication(), facade, mockk(), navigation, session, mockk<ServerRepository>(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<ServerRepository>(relaxed = true))
val model = WolfskinTitleViewModel(RuntimeEnvironment.getApplication(), facade, mockk(), navigation, session, mockk<ServerRepository>(relaxed = true), mockk(relaxed = true))
try {
model.load(titleRoute(seriesId.toString()), person, preferences, fixtures = false)
advanceUntilIdle()

View file

@ -46,7 +46,7 @@ class TitleRequestSafetyTest {
runBlocking { session.authenticate() }
val facade = mockk<TitleReadFacade>()
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) }

View file

@ -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<ComponentActivity>()
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<TitleItem?>(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<String>(), playback.events)
rule.runOnIdle {
item.value = movie
preferences.value = preferences(ThemeSongVolume.DISABLED)
}
rule.waitForIdle()
assertEquals(emptyList<String>(), 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<String>(), 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<TitleReadFacade>()
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<ThemeSongPlayer>()
val events = mutableListOf<String>()
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"
}
}
}
}

View file

@ -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<TitleState?>(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 }

View file

@ -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