Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
8c1a818
Fix embedded subtitle discovery and merging
enikesha Aug 26, 2026
105d59e
Handle same-language subtitle variants
enikesha Aug 27, 2026
0ea108e
Fix HLS subtitle identity mapping
enikesha Aug 27, 2026
18fbef5
Use compact partial subtitle label
enikesha Aug 28, 2026
c686137
Restrict HLS detection to the stream path
enikesha Aug 29, 2026
333eab9
Disambiguate same-language subtitle labels
enikesha Aug 29, 2026
f45a7eb
Harden player track preference restore
enikesha Aug 29, 2026
5fd63ed
Drop unreachable subtitle code paths
enikesha Aug 29, 2026
08f7e15
Side-load API subtitles on HLS streams
enikesha Aug 29, 2026
64524da
Select subtitle tracks by merge-stable group id
enikesha Aug 29, 2026
6e636a0
Present subtitles by language name and group variants
enikesha Aug 29, 2026
1a9e70c
Drop side-loaded subtitles the manifest already covers
enikesha Aug 29, 2026
e509096
Hide side-loaded subtitles only when the manifest covers them
enikesha Aug 29, 2026
b449cf2
Treat the HLS manifest as the subtitle list when it has one
enikesha Aug 29, 2026
a43d54a
Remove dead subtitle track state and drive-by changes
enikesha Aug 29, 2026
0495a3a
Cut redundant subtitle tests and the forced-URL guess
enikesha Aug 29, 2026
00710f6
Use one construction style in the subtitle tests
enikesha Aug 29, 2026
8d84a95
Carry the stream container instead of guessing it from the URL
enikesha Aug 29, 2026
3b6acd9
Drop a selector rule that cannot fire
enikesha Aug 29, 2026
004c965
Harden embedded subtitle fallback and restore
enikesha Aug 29, 2026
b9d5c07
Drop the removed subtitle index from the focus test
enikesha Aug 31, 2026
cfe1a06
Adapt embedded subtitles to updated player fixtures
enikesha Sep 2, 2026
0cba582
Handle AI and unknown subtitle language labels
enikesha Sep 2, 2026
17fb720
Remove subtitle diagnostic logging
enikesha Sep 2, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -248,8 +248,6 @@ internal class HomeSectionRemovalFocusTest {
onAction = {},
tabRouter = tabRouter,
tabAppRouterHolder = tabAppRouterHolder,
onSearchClick = {},
onSettingsClick = {},
)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -357,8 +357,6 @@ private data object TopTabDetailsProbeHostScreen : PuberScreen {
onAction = {},
tabRouter = TopTabDetailsProbeHost.requireTabRouter(),
tabAppRouterHolder = TopTabDetailsProbeHost.requireTabAppRouterHolder(),
onSearchClick = {},
onSettingsClick = {},
)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -218,8 +218,6 @@ private data object TopTabHistoryProbeHostScreen : PuberScreen {
onAction = {},
tabRouter = requireNotNull(TopTabHistoryProbeHost.tabRouter),
tabAppRouterHolder = requireNotNull(TopTabHistoryProbeHost.tabAppRouterHolder),
onSearchClick = {},
onSettingsClick = {},
)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -196,8 +196,6 @@ private fun RapidSwitchingHost(
},
tabRouter = tabRouter,
tabAppRouterHolder = tabAppRouterHolder,
onSearchClick = {},
onSettingsClick = {},
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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")
Expand Down Expand Up @@ -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()
}
}

Expand Down Expand Up @@ -705,9 +703,6 @@ private class PlayerE2EFixture(
.any { it.isSelected }
} == true

fun preferredTextLanguages(): List<String> =
uiPlayerRead { trackSelectionParameters.preferredTextLanguages.toList() }.orEmpty()

fun awaitPlayerReady() {
awaitPlayerCondition("Media3 READY without PlayerScreen error") {
uiPlayerRead { playbackState } == Player.STATE_READY
Expand Down Expand Up @@ -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}
}]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand All @@ -135,6 +136,7 @@ internal class PlaybackControllerNetworkTracksTest : PlayerInstrumentationTestCa
SubtitleLink(
lang = "en",
url = subtitleUrl,
embed = true,
),
),
)
Expand All @@ -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,
Expand Down Expand Up @@ -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))
}

Expand Down Expand Up @@ -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
Expand All @@ -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" &&
Expand Down Expand Up @@ -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)),
Expand All @@ -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)),
)
}
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -674,7 +676,11 @@ internal class PlaybackControllerResourcesTest : PlayerInstrumentationTestCase()
record("ended")
}

override fun onTracksUpdated(audioTracks: List<AudioTrackUIState>, selectedIndex: Int) {
override fun onTracksUpdated(
audioTracks: List<AudioTrackUIState>,
selectedIndex: Int,
subtitleTracks: List<SubtitleTrackUIState>,
) {
record("tracks:${audioTracks.size}:$selectedIndex")
}

Expand Down
6 changes: 5 additions & 1 deletion app/src/main/java/com/kino/puber/data/api/models/Models.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<VideoFile>?,
val audios: List<Audio>?,
Expand Down Expand Up @@ -191,11 +200,11 @@ internal class PlayerInteractor(
}
}

fun selectStreamUrl(files: List<VideoFile>?, qualityIndex: Int): String? {
return selectBaseStreamUrl(files, qualityIndex)?.let(::withSurroundAudioPreference)
fun selectStreamUrl(files: List<VideoFile>?, qualityIndex: Int): StreamSource? {
return selectBaseStreamUrl(files, qualityIndex)?.withSurroundAudioPreference()
}

private fun selectBaseStreamUrl(files: List<VideoFile>?, qualityIndex: Int): String? {
private fun selectBaseStreamUrl(files: List<VideoFile>?, qualityIndex: Int): StreamSource? {
if (files.isNullOrEmpty()) return null
return if (qualityIndex == 0) {
selectAutoStreamUrl(files)
Expand All @@ -204,22 +213,29 @@ internal class PlayerInteractor(
}
}

private fun selectAutoStreamUrl(files: List<VideoFile>): String? {
val url = files.firstOrNull()?.url
return url?.hls4 ?: url?.hls ?: url?.http
private fun selectAutoStreamUrl(files: List<VideoFile>): StreamSource? {
val url = files.firstOrNull()?.url ?: return null
return hlsSource(url.hls4) ?: hlsSource(url.hls) ?: progressiveSource(url.http)
}

private fun selectManualStreamUrl(files: List<VideoFile>, qualityIndex: Int): String? {
private fun selectManualStreamUrl(files: List<VideoFile>, qualityIndex: Int): StreamSource? {
val uniqueFiles = files.distinctBy { it.quality ?: "${it.h}p" }
.sortedByDescending { it.qualityId ?: 0 }
val url = (uniqueFiles.getOrNull(qualityIndex - 1) ?: files.first()).url
return url?.hls ?: url?.hls4 ?: url?.http
val url = (uniqueFiles.getOrNull(qualityIndex - 1) ?: files.first()).url ?: return null
return hlsSource(url.hls) ?: hlsSource(url.hls4) ?: progressiveSource(url.http)
}

private fun withSurroundAudioPreference(baseUrl: String): String {
if (!playerPreferencesRepository.preferSurroundAudio) return baseUrl
val separator = if ("?" in baseUrl) "&" else "?"
return "${baseUrl}${separator}ac3default=1"
private fun hlsSource(url: String?): StreamSource? =
url?.let { StreamSource(url = it, isHls = true) }

// The progressive field is trusted unless it plainly carries a playlist.
private fun progressiveSource(url: String?): StreamSource? =
url?.let { StreamSource(url = it, isHls = it.substringBefore('?').endsWith(".m3u8", true)) }

private fun StreamSource.withSurroundAudioPreference(): StreamSource {
if (!playerPreferencesRepository.preferSurroundAudio) return this
val separator = if ("?" in url) "&" else "?"
return copy(url = "$url${separator}ac3default=1")
}

private fun findFirstUnwatchedEpisode(item: Item): Pair<Int, Int>? {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,11 +39,11 @@ private val previewAudioTracks = listOf(
)

private val previewSubtitleTracks = listOf(
SubtitleTrackUIState(0, "Выкл.", "", ""),
SubtitleTrackUIState(1, "Русские", "ru", "https://example.com/ru.vtt"),
SubtitleTrackUIState(2, "Русские для слабослышащих", "ru", "https://example.com/ru-sdh.vtt"),
SubtitleTrackUIState(3, "English", "en", "https://example.com/en.vtt"),
SubtitleTrackUIState(4, "Узбекские · Созданы нейросетью", "uz", "https://example.com/uz.vtt"),
SubtitleTrackUIState("Выкл.", "", ""),
SubtitleTrackUIState("Русские", "ru", "https://example.com/ru.vtt"),
SubtitleTrackUIState("Русские для слабослышащих", "ru", "https://example.com/ru-sdh.vtt"),
SubtitleTrackUIState("English", "en", "https://example.com/en.vtt"),
SubtitleTrackUIState("Узбекские · Созданы нейросетью", "uz", "https://example.com/uz.vtt"),
)

private val previewSoundModes = listOf(
Expand Down
Loading