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:
commit
5f5b5ffd54
11 changed files with 276 additions and 14 deletions
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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")))))
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue