diff --git a/app/src/androidTest/kotlin/com/kino/puber/ui/feature/home/component/HomeSectionRemovalFocusTest.kt b/app/src/androidTest/kotlin/com/kino/puber/ui/feature/home/component/HomeSectionRemovalFocusTest.kt index b0f67d1b..085988e3 100644 --- a/app/src/androidTest/kotlin/com/kino/puber/ui/feature/home/component/HomeSectionRemovalFocusTest.kt +++ b/app/src/androidTest/kotlin/com/kino/puber/ui/feature/home/component/HomeSectionRemovalFocusTest.kt @@ -248,8 +248,6 @@ internal class HomeSectionRemovalFocusTest { onAction = {}, tabRouter = tabRouter, tabAppRouterHolder = tabAppRouterHolder, - onSearchClick = {}, - onSettingsClick = {}, ) } } diff --git a/app/src/androidTest/kotlin/com/kino/puber/ui/feature/main/toptabs/TopTabDetailsBackFocusTest.kt b/app/src/androidTest/kotlin/com/kino/puber/ui/feature/main/toptabs/TopTabDetailsBackFocusTest.kt index eaeee731..446b35a5 100644 --- a/app/src/androidTest/kotlin/com/kino/puber/ui/feature/main/toptabs/TopTabDetailsBackFocusTest.kt +++ b/app/src/androidTest/kotlin/com/kino/puber/ui/feature/main/toptabs/TopTabDetailsBackFocusTest.kt @@ -357,8 +357,6 @@ private data object TopTabDetailsProbeHostScreen : PuberScreen { onAction = {}, tabRouter = TopTabDetailsProbeHost.requireTabRouter(), tabAppRouterHolder = TopTabDetailsProbeHost.requireTabAppRouterHolder(), - onSearchClick = {}, - onSettingsClick = {}, ) } } diff --git a/app/src/androidTest/kotlin/com/kino/puber/ui/feature/main/toptabs/TopTabMainContentHistoryFocusTest.kt b/app/src/androidTest/kotlin/com/kino/puber/ui/feature/main/toptabs/TopTabMainContentHistoryFocusTest.kt index 04f3be16..463c110a 100644 --- a/app/src/androidTest/kotlin/com/kino/puber/ui/feature/main/toptabs/TopTabMainContentHistoryFocusTest.kt +++ b/app/src/androidTest/kotlin/com/kino/puber/ui/feature/main/toptabs/TopTabMainContentHistoryFocusTest.kt @@ -218,8 +218,6 @@ private data object TopTabHistoryProbeHostScreen : PuberScreen { onAction = {}, tabRouter = requireNotNull(TopTabHistoryProbeHost.tabRouter), tabAppRouterHolder = requireNotNull(TopTabHistoryProbeHost.tabAppRouterHolder), - onSearchClick = {}, - onSettingsClick = {}, ) } } diff --git a/app/src/androidTest/kotlin/com/kino/puber/ui/feature/main/toptabs/TopTabRapidSwitchingFocusTest.kt b/app/src/androidTest/kotlin/com/kino/puber/ui/feature/main/toptabs/TopTabRapidSwitchingFocusTest.kt index 56a99067..12767b38 100644 --- a/app/src/androidTest/kotlin/com/kino/puber/ui/feature/main/toptabs/TopTabRapidSwitchingFocusTest.kt +++ b/app/src/androidTest/kotlin/com/kino/puber/ui/feature/main/toptabs/TopTabRapidSwitchingFocusTest.kt @@ -196,8 +196,6 @@ private fun RapidSwitchingHost( }, tabRouter = tabRouter, tabAppRouterHolder = tabAppRouterHolder, - onSearchClick = {}, - onSettingsClick = {}, ) } diff --git a/app/src/androidTest/kotlin/com/kino/puber/ui/feature/player/component/PlayerScreenContentFocusTest.kt b/app/src/androidTest/kotlin/com/kino/puber/ui/feature/player/component/PlayerScreenContentFocusTest.kt index 30135997..38afb041 100644 --- a/app/src/androidTest/kotlin/com/kino/puber/ui/feature/player/component/PlayerScreenContentFocusTest.kt +++ b/app/src/androidTest/kotlin/com/kino/puber/ui/feature/player/component/PlayerScreenContentFocusTest.kt @@ -525,7 +525,7 @@ internal class PlayerScreenContentFocusTest : PlayerComposeInstrumentationTestCa playPauseIndicator = null, audioTracks = listOf(AudioTrackUIState(0, "Русский", "ru")), selectedAudioTrackIndex = 0, - subtitleTracks = listOf(SubtitleTrackUIState(0, "Выкл.", "", "")), + subtitleTracks = listOf(SubtitleTrackUIState("Выкл.", "", "")), selectedSubtitleIndex = 0, soundModes = listOf(SoundModeUIState(0, FIXTURE_SOUND_MODE)), selectedSoundModeIndex = 0, diff --git a/app/src/androidTest/kotlin/com/kino/puber/ui/feature/player/component/PlayerScreenE2ETest.kt b/app/src/androidTest/kotlin/com/kino/puber/ui/feature/player/component/PlayerScreenE2ETest.kt index a86355ed..87d9548a 100644 --- a/app/src/androidTest/kotlin/com/kino/puber/ui/feature/player/component/PlayerScreenE2ETest.kt +++ b/app/src/androidTest/kotlin/com/kino/puber/ui/feature/player/component/PlayerScreenE2ETest.kt @@ -140,7 +140,7 @@ internal class PlayerScreenE2ETest : PlayerComposeInstrumentationTestCase() { assertFocusedTag(PlayerScreenTestTags.AudioSubtitles, "audio/subtitle button") } - step("Select Spanish audio and external subtitles through typed panel items") { + step("Select Spanish audio and the API subtitle marked embedded through typed panel items") { robot.press(PlayerRemoteKey.Select) assertFocusedPanelItem("sound", 0) robot.press(PlayerRemoteKey.Right) @@ -156,10 +156,9 @@ internal class PlayerScreenE2ETest : PlayerComposeInstrumentationTestCase() { robot.press(PlayerRemoteKey.Right) assertFocusedPanelItem("subtitle", 1) robot.press(PlayerRemoteKey.Select) - awaitPlayerCondition("external subtitle is persisted and selected in Media3") { + awaitPlayerCondition("API subtitle marked embedded is persisted and selected in Media3") { preferredSubtitleLanguage() == SUBTITLE_LANGUAGE && - selectedTextTrack() && - preferredTextLanguages().contains(SUBTITLE_LANGUAGE) + selectedTextTrack() } robot.pressBack() assertFocusedTag(PlayerScreenTestTags.AudioSubtitles, "audio/subtitle button") @@ -226,8 +225,7 @@ internal class PlayerScreenE2ETest : PlayerComposeInstrumentationTestCase() { } awaitPlayerCondition("recreated Media3 restores audio and subtitle choices") { selectedAudioLanguage() == SPANISH_LANGUAGE && - selectedTextTrack() && - preferredTextLanguages().contains(SUBTITLE_LANGUAGE) + selectedTextTrack() } } @@ -705,9 +703,6 @@ private class PlayerE2EFixture( .any { it.isSelected } } == true - fun preferredTextLanguages(): List = - uiPlayerRead { trackSelectionParameters.preferredTextLanguages.toList() }.orEmpty() - fun awaitPlayerReady() { awaitPlayerCondition("Media3 READY without PlayerScreen error") { uiPlayerRead { playbackState } == Player.STATE_READY @@ -1170,7 +1165,8 @@ private class PlayerE2EFixture( "lang": "$SUBTITLE_LANGUAGE", "url": "${mediaUrl( "${media.high}/subtitle.vtt?signature=screen-test&scenario=$scenarioToken", - )}" + )}", + "embed": true }], "watching": {"time": $watchingTime, "duration": 4, "status": 0} }] diff --git a/app/src/androidTest/kotlin/com/kino/puber/ui/feature/player/vm/PlaybackControllerDeviceTest.kt b/app/src/androidTest/kotlin/com/kino/puber/ui/feature/player/vm/PlaybackControllerDeviceTest.kt index 82165c94..ee006bfa 100644 --- a/app/src/androidTest/kotlin/com/kino/puber/ui/feature/player/vm/PlaybackControllerDeviceTest.kt +++ b/app/src/androidTest/kotlin/com/kino/puber/ui/feature/player/vm/PlaybackControllerDeviceTest.kt @@ -15,6 +15,7 @@ import androidx.test.core.app.ActivityScenario import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry import com.kino.puber.data.repository.PlayerPreferencesRepository +import com.kino.puber.domain.interactor.player.StreamSource import com.kino.puber.playertestfixtures.FixtureId import com.kino.puber.playertestfixtures.PlayerTestFixtures import com.kino.puber.playertestfixtures.network.LoopbackNetworkJournal @@ -306,14 +307,20 @@ internal class PlaybackControllerDeviceTest : PlayerInstrumentationTestCase() { startPositionMs: Long? = null, ): PlayerProbe { val probe = PlayerProbe() - val streamUrl = when (fixture) { - FixtureId.ProgressiveMp4 -> loopbackUrl("/media/progressive.mp4") - FixtureId.HlsMaster -> loopbackUrl("/media/hls/master.m3u8") + val stream = when (fixture) { + FixtureId.ProgressiveMp4 -> StreamSource( + url = loopbackUrl("/media/progressive.mp4"), + isHls = false, + ) + FixtureId.HlsMaster -> StreamSource( + url = loopbackUrl("/media/hls/master.m3u8"), + isHls = true, + ) else -> error("Unsupported device fixture: $fixture") } scenario.onActivity { controller.prepare( - streamUrl = streamUrl, + stream = stream, subtitles = null, startPosition = startPositionMs, bufferPreset = BufferPreset.SMALL, diff --git a/app/src/androidTest/kotlin/com/kino/puber/ui/feature/player/vm/PlaybackControllerNetworkTracksTest.kt b/app/src/androidTest/kotlin/com/kino/puber/ui/feature/player/vm/PlaybackControllerNetworkTracksTest.kt index 92c8271c..e0ed1f8f 100644 --- a/app/src/androidTest/kotlin/com/kino/puber/ui/feature/player/vm/PlaybackControllerNetworkTracksTest.kt +++ b/app/src/androidTest/kotlin/com/kino/puber/ui/feature/player/vm/PlaybackControllerNetworkTracksTest.kt @@ -20,6 +20,7 @@ import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry import com.kino.puber.data.api.models.SubtitleLink import com.kino.puber.data.repository.PlayerPreferencesRepository +import com.kino.puber.domain.interactor.player.StreamSource import com.kino.puber.playertestfixtures.FixtureId import com.kino.puber.playertestfixtures.PlayerTestFixtures import com.kino.puber.playertestfixtures.network.LoopbackNetworkJournal @@ -122,11 +123,11 @@ internal class PlaybackControllerNetworkTracksTest : PlayerInstrumentationTestCa } @Test - fun hlsTracks_selectsAudioAndSubtitle_observesCueAndDisablesText() = run { + fun hlsTracks_sideLoadsApiSubtitleMarkedEmbedded_selectsCueAndDisablesText() = run { lateinit var probe: PlayerProbe lateinit var subtitleUrl: String - step("Prepare HLS with two AAC renditions and side-loaded WebVTT") { + step("Prepare HLS with two AAC renditions and API WebVTT marked embedded") { server.reset(commonHlsRoutes()) subtitleUrl = loopbackUrl("/media/subtitle.vtt?signature=test-signature") probe = prepare( @@ -135,6 +136,7 @@ internal class PlaybackControllerNetworkTracksTest : PlayerInstrumentationTestCa SubtitleLink( lang = "en", url = subtitleUrl, + embed = true, ), ), ) @@ -158,7 +160,6 @@ internal class PlaybackControllerNetworkTracksTest : PlayerInstrumentationTestCa step("Select the external WebVTT track without losing audio selection") { val subtitle = SubtitleTrackUIState( - index = 1, label = "English", language = "en", url = subtitleUrl, @@ -263,7 +264,7 @@ internal class PlaybackControllerNetworkTracksTest : PlayerInstrumentationTestCa highUrl = loopbackUrl("/media/hls/quality-high.m3u8") val subtitleUrl = loopbackUrl("/media/subtitle.vtt?scenario=quality-switch") subtitle = SubtitleLink(lang = "en", url = subtitleUrl) - subtitleTrack = SubtitleTrackUIState(1, "English", "en", subtitleUrl) + subtitleTrack = SubtitleTrackUIState(label = "English", language = "en", url = subtitleUrl) probe = prepare("/media/hls/quality-low.m3u8", listOf(subtitle)) } @@ -291,7 +292,9 @@ internal class PlaybackControllerNetworkTracksTest : PlayerInstrumentationTestCa runOnPlayer { controller.pause() } awaitCondition("paused switch source") { !isPlaying() } pausedPosition = currentPosition() - runOnPlayer { controller.switchStream(highUrl, listOf(subtitle)) } + runOnPlayer { + controller.switchStream(StreamSource(url = highUrl, isHls = true), listOf(subtitle)) + } awaitCondition("high quality source prepared") { currentMediaPath() == "/media/hls/quality-high.m3u8" && playbackState() == Player.STATE_READY @@ -312,9 +315,9 @@ internal class PlaybackControllerNetworkTracksTest : PlayerInstrumentationTestCa isPlaying() && playbackIntent() == PlaybackIntent.PlayRequested } runOnPlayer { - controller.switchStream(lowUrl, listOf(subtitle)) - controller.switchStream(highUrl, listOf(subtitle)) - controller.switchStream(lowUrl, listOf(subtitle)) + controller.switchStream(StreamSource(url = lowUrl, isHls = true), listOf(subtitle)) + controller.switchStream(StreamSource(url = highUrl, isHls = true), listOf(subtitle)) + controller.switchStream(StreamSource(url = lowUrl, isHls = true), listOf(subtitle)) } awaitCondition("last rapid switch wins") { currentMediaPath() == "/media/hls/quality-low.m3u8" && @@ -344,7 +347,7 @@ internal class PlaybackControllerNetworkTracksTest : PlayerInstrumentationTestCa step("Prepare the first progressive source with external WebVTT") { server.reset(commonHlsRoutes()) subtitleUrl = loopbackUrl("/media/subtitle.vtt") - subtitle = SubtitleTrackUIState(1, "English", "en", subtitleUrl) + subtitle = SubtitleTrackUIState(label = "English", language = "en", url = subtitleUrl) first = prepare( path = "/media/progressive-a.mp4", subtitles = listOf(SubtitleLink(lang = "en", url = subtitleUrl)), @@ -365,7 +368,7 @@ internal class PlaybackControllerNetworkTracksTest : PlayerInstrumentationTestCa val secondUrl = loopbackUrl("/media/progressive-b.mp4") runOnPlayer { controller.switchStream( - streamUrl = secondUrl, + stream = StreamSource(url = secondUrl, isHls = false), subtitles = listOf(SubtitleLink(lang = "en", url = subtitleUrl)), ) } @@ -643,7 +646,10 @@ internal class PlaybackControllerNetworkTracksTest : PlayerInstrumentationTestCa val streamUrl = loopbackUrl(path) scenario.onActivity { controller.prepare( - streamUrl = streamUrl, + stream = StreamSource( + url = streamUrl, + isHls = path.endsWith(".m3u8", ignoreCase = true), + ), subtitles = subtitles, startPosition = 0L, bufferPreset = BufferPreset.SMALL, diff --git a/app/src/androidTest/kotlin/com/kino/puber/ui/feature/player/vm/PlaybackControllerResourcesTest.kt b/app/src/androidTest/kotlin/com/kino/puber/ui/feature/player/vm/PlaybackControllerResourcesTest.kt index 6f07f47c..eb95e480 100644 --- a/app/src/androidTest/kotlin/com/kino/puber/ui/feature/player/vm/PlaybackControllerResourcesTest.kt +++ b/app/src/androidTest/kotlin/com/kino/puber/ui/feature/player/vm/PlaybackControllerResourcesTest.kt @@ -20,6 +20,7 @@ import androidx.test.core.app.ActivityScenario import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry import com.kino.puber.data.repository.PlayerPreferencesRepository +import com.kino.puber.domain.interactor.player.StreamSource import com.kino.puber.playertestfixtures.FixtureId import com.kino.puber.playertestfixtures.PlayerTestFixtures import com.kino.puber.playertestfixtures.network.LoopbackNetworkJournal @@ -30,6 +31,7 @@ import com.kino.puber.profile.PlayerTestControl import com.kino.puber.ui.feature.player.PlayerInstrumentationTestCase import com.kino.puber.ui.feature.player.model.AudioTrackUIState import com.kino.puber.ui.feature.player.model.BufferPreset +import com.kino.puber.ui.feature.player.model.SubtitleTrackUIState import java.io.File import java.io.IOException import java.util.UUID @@ -408,7 +410,7 @@ internal class PlaybackControllerResourcesTest : PlayerInstrumentationTestCase() val probe = PlayerProbe() scenario.onActivity { controller.prepare( - streamUrl = url, + stream = StreamSource(url = url, isHls = false), subtitles = null, startPosition = 0L, bufferPreset = BufferPreset.SMALL, @@ -674,7 +676,11 @@ internal class PlaybackControllerResourcesTest : PlayerInstrumentationTestCase() record("ended") } - override fun onTracksUpdated(audioTracks: List, selectedIndex: Int) { + override fun onTracksUpdated( + audioTracks: List, + selectedIndex: Int, + subtitleTracks: List, + ) { record("tracks:${audioTracks.size}:$selectedIndex") } diff --git a/app/src/main/java/com/kino/puber/data/api/models/Models.kt b/app/src/main/java/com/kino/puber/data/api/models/Models.kt index 4dc26ade..c9b917cd 100644 --- a/app/src/main/java/com/kino/puber/data/api/models/Models.kt +++ b/app/src/main/java/com/kino/puber/data/api/models/Models.kt @@ -374,7 +374,11 @@ data class SubtitleLink( val url: String, val shift: Int? = null, val embed: Boolean? = null, -) + val forced: Boolean? = null, + val file: String? = null, +) { + fun shouldSideLoad(isHls: Boolean): Boolean = isHls || embed != true +} @Serializable data class TVChannel( diff --git a/app/src/main/java/com/kino/puber/domain/interactor/player/PlayerInteractor.kt b/app/src/main/java/com/kino/puber/domain/interactor/player/PlayerInteractor.kt index c56fee77..af4df984 100644 --- a/app/src/main/java/com/kino/puber/domain/interactor/player/PlayerInteractor.kt +++ b/app/src/main/java/com/kino/puber/domain/interactor/player/PlayerInteractor.kt @@ -16,6 +16,15 @@ import kotlinx.coroutines.CancellationException private const val WATCHED_STATUS = 1 private const val UNWATCHED_STATUS = 0 +/** + * The stream to play, with the container the API published it under. + * [isHls] is taken from the field the url came from rather than guessed from its text. + */ +internal data class StreamSource( + val url: String, + val isHls: Boolean, +) + internal data class ResolvedMedia( val files: List?, val audios: List