diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 3f030ca..33c24ea 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -11,6 +11,7 @@ android:allowBackup="false" android:banner="@drawable/banner" android:icon="@drawable/banner" + android:largeHeap="true" android:label="@string/app_name" android:supportsRtl="true" android:networkSecurityConfig="@xml/network_security_config" diff --git a/app/src/main/kotlin/ru/stenfredd/movies/MainActivity.kt b/app/src/main/kotlin/ru/stenfredd/movies/MainActivity.kt index 2531d68..9888732 100644 --- a/app/src/main/kotlin/ru/stenfredd/movies/MainActivity.kt +++ b/app/src/main/kotlin/ru/stenfredd/movies/MainActivity.kt @@ -25,7 +25,9 @@ import ru.stenfredd.movies.ui.ItemsScreen import ru.stenfredd.movies.ui.LoginScreen import ru.stenfredd.movies.ui.PlayerScreen import ru.stenfredd.movies.ui.Screen +import ru.stenfredd.movies.ui.TourScreen import ru.stenfredd.movies.playback.api.EngineId +import ru.stenfredd.movies.playback.api.StreamSelection import java.util.UUID import javax.inject.Inject @@ -64,6 +66,7 @@ class MainActivity : ComponentActivity() { is Screen.Items -> ItemsScreen(screen, onOpen = ::open) is Screen.Detail -> DetailScreen(screen, onOpen = ::open) is Screen.Player -> PlayerScreen(screen, onExit = { stack.removeAt(stack.lastIndex) }) + is Screen.Tour -> TourScreen(screen.startIndex, onExit = { stack.removeAt(stack.lastIndex) }) } } } @@ -72,14 +75,23 @@ class MainActivity : ComponentActivity() { /** * Debug-only shortcut for testing on a TV without typing: - * `adb shell am start -S -n ru.stenfredd.movies/.MainActivity --es item [--es engine exo|vlc] [--el start ]`. + * `adb shell am start -S -n ru.stenfredd.movies/.MainActivity --es item [--es engine exo|vlc] [--el start ] + * [--ei audio ] [--ei sub ]` (stream indexes as Jellyfin numbers them; with one given, the other is off), + * or `--ez tour true [--ei tour_file N]` for the debug tour (from file N). */ private fun debugTarget(): List? { if (!BuildConfig.DEBUG) return null + if (intent.getBooleanExtra("tour", false)) return listOf(Screen.Tour(intent.getIntExtra("tour_file", 1) - 1)) val raw = intent.getStringExtra("item") ?: return null val id = UUID.fromString(if ('-' in raw) raw else raw.replace(Regex("(.{8})(.{4})(.{4})(.{4})(.{12})"), "$1-$2-$3-$4-$5")) val engine = intent.getStringExtra("engine")?.let(::EngineId) val play = intent.hasExtra("engine") || intent.getBooleanExtra("play", false) - return listOf(Screen.Detail(id)) + if (play) listOf(Screen.Player(id, engine, intent.getLongExtra("start", 0))) else emptyList() + val streams = if (intent.hasExtra("audio") || intent.hasExtra("sub")) { + StreamSelection(intent.getIntExtra("audio", -1).takeIf { it >= 0 }, intent.getIntExtra("sub", -1).takeIf { it >= 0 }) + } else { + null + } + return listOf(Screen.Detail(id)) + + if (play) listOf(Screen.Player(id, engine, intent.getLongExtra("start", 0), streams = streams)) else emptyList() } } diff --git a/app/src/main/kotlin/ru/stenfredd/movies/ui/BrowseScreens.kt b/app/src/main/kotlin/ru/stenfredd/movies/ui/BrowseScreens.kt index 312b431..05b0cb0 100644 --- a/app/src/main/kotlin/ru/stenfredd/movies/ui/BrowseScreens.kt +++ b/app/src/main/kotlin/ru/stenfredd/movies/ui/BrowseScreens.kt @@ -25,6 +25,7 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @@ -54,6 +55,8 @@ fun HomeScreen(onOpen: (Screen) -> Unit, onLogout: () -> Unit, vm: BrowseViewMod var query by remember { mutableStateOf("") } var error by remember { mutableStateOf(null) } val scope = rememberCoroutineScope() + val context = LocalContext.current + val tourAvailable = remember { TourEntry.available(context) } LaunchedEffect(Unit) { runCatching { @@ -70,6 +73,7 @@ fun HomeScreen(onOpen: (Screen) -> Unit, onLogout: () -> Unit, vm: BrowseViewMod item { Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { views.forEach { v -> Button(onClick = { onOpen(Screen.Items(v.id, v.name.orEmpty())) }) { Text(v.name.orEmpty()) } } + if (tourAvailable) Button(onClick = { onOpen(Screen.Tour()) }) { Text("Режим отладки") } Button(onClick = { scope.launch { vm.session.logout() diff --git a/app/src/main/kotlin/ru/stenfredd/movies/ui/PlayerScreen.kt b/app/src/main/kotlin/ru/stenfredd/movies/ui/PlayerScreen.kt index 91ef699..e4a2bf5 100644 --- a/app/src/main/kotlin/ru/stenfredd/movies/ui/PlayerScreen.kt +++ b/app/src/main/kotlin/ru/stenfredd/movies/ui/PlayerScreen.kt @@ -13,6 +13,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect @@ -32,6 +33,8 @@ import androidx.compose.ui.input.key.nativeKeyCode import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.input.key.key import androidx.compose.ui.input.key.type +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.viewinterop.AndroidView @@ -45,7 +48,10 @@ import androidx.tv.material3.Text import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.delay import org.jellyfin.sdk.model.api.MediaStreamType +import org.jellyfin.sdk.model.api.PlayMethod import ru.stenfredd.movies.playback.api.Codecs +import ru.stenfredd.movies.playback.api.DolbyVisionSwitchable +import ru.stenfredd.movies.playback.api.EngineId import ru.stenfredd.movies.playback.api.EngineState import ru.stenfredd.movies.playback.api.VideoPathSwitchable import ru.stenfredd.movies.playback.core.DeviceCapabilitiesProbe @@ -54,6 +60,8 @@ import ru.stenfredd.movies.playback.core.PlaybackSession import ru.stenfredd.movies.playback.core.PlaybackSessionFactory import ru.stenfredd.movies.playback.core.PlaybackUiState import ru.stenfredd.movies.playback.core.describe +import ru.stenfredd.movies.playback.exo.ExoEngineProvider +import ru.stenfredd.movies.playback.vlc.VlcEngineProvider import javax.inject.Inject @HiltViewModel @@ -69,26 +77,36 @@ class PlayerViewModel @Inject constructor( release() return factory.create(viewModelScope).also { session = it - it.start(screen.itemId, screen.engine, screen.startPositionMs) + it.start(screen.itemId, screen.engine, screen.startPositionMs, screen.streams, screen.startFraction) } } - fun release() { - session?.release() - session = null + /** Releases [target] (default: the current session); a stale screen disposing late can't kill a newer session. */ + fun release(target: PlaybackSession? = session) { + target?.release() + if (target === session) session = null } override fun onCleared() = release() } +/** Debug tour context for [PlayerScreen]: ↑ cycles playback variants, ↓ goes to the next file. */ +class TourInfo(val number: Int, val total: Int, val title: String, val label: String, val onNextFile: () -> Unit) + +/** Variants ↑ steps through in the tour: auto first, then each engine/path, then a server transcode. */ +private val TOUR_VARIANTS = listOf(0) + Variant.entries.map { it.number } + TRANSCODE_KEY + @Composable -fun PlayerScreen(screen: Screen.Player, onExit: () -> Unit, vm: PlayerViewModel = hiltViewModel()) { +fun PlayerScreen(screen: Screen.Player, onExit: () -> Unit, tour: TourInfo? = null, vm: PlayerViewModel = hiltViewModel()) { val session = remember(screen) { vm.start(screen) } - DisposableEffect(session) { onDispose { vm.release() } } + DisposableEffect(session) { onDispose { vm.release(session) } } val state by session.state.collectAsStateWithLifecycle() - var showDebug by remember { mutableStateOf(true) } + // ↑ cycles: variant badge only → badge + diagnostics → nothing. + var overlay by remember { mutableIntStateOf(0) } + var manual by remember { mutableStateOf(screen.engine != null) } var showMenu by remember { mutableStateOf(false) } + var tourStep by remember { mutableIntStateOf(0) } var barTick by remember { mutableIntStateOf(0) } var showBar by remember { mutableStateOf(true) } val rootFocus = remember { FocusRequester() } @@ -116,7 +134,14 @@ fun PlayerScreen(screen: Screen.Player, onExit: () -> Unit, vm: PlayerViewModel AndroidKeyEvent.KEYCODE_MEDIA_PLAY_PAUSE, AndroidKeyEvent.KEYCODE_SPACE -> session.togglePlayPause() AndroidKeyEvent.KEYCODE_DPAD_LEFT, AndroidKeyEvent.KEYCODE_MEDIA_REWIND -> session.seekBy(-10_000) AndroidKeyEvent.KEYCODE_DPAD_RIGHT, AndroidKeyEvent.KEYCODE_MEDIA_FAST_FORWARD -> session.seekBy(30_000) - AndroidKeyEvent.KEYCODE_DPAD_UP, AndroidKeyEvent.KEYCODE_INFO -> showDebug = !showDebug + AndroidKeyEvent.KEYCODE_DPAD_UP if tour != null -> { + tourStep = (tourStep + 1) % TOUR_VARIANTS.size + manual = applyVariant(TOUR_VARIANTS[tourStep], session) ?: manual + } + AndroidKeyEvent.KEYCODE_DPAD_DOWN if tour != null -> tour.onNextFile() + AndroidKeyEvent.KEYCODE_DPAD_UP, AndroidKeyEvent.KEYCODE_INFO -> overlay = (overlay + 1) % 3 + in AndroidKeyEvent.KEYCODE_0..AndroidKeyEvent.KEYCODE_9 -> + manual = applyVariant(e.key.nativeKeyCode - AndroidKeyEvent.KEYCODE_0, session) ?: return@onPreviewKeyEvent false AndroidKeyEvent.KEYCODE_DPAD_DOWN, AndroidKeyEvent.KEYCODE_MENU -> showMenu = true else -> return@onPreviewKeyEvent false } @@ -130,16 +155,17 @@ fun PlayerScreen(screen: Screen.Player, onExit: () -> Unit, vm: PlayerViewModel onRelease = { session.detachVideo() }, modifier = Modifier.fillMaxSize(), ) - if (showDebug) DebugPanel(state, vm, Modifier.align(Alignment.TopStart)) - if (showBar || state.loading || state.error != null) StatusBar(state, Modifier.align(Alignment.BottomCenter)) + if (overlay == 1) DebugPanel(state, vm, Modifier.align(Alignment.TopStart)) + if (overlay < 2) VariantBadge(state, session, manual, tour, Modifier.align(Alignment.TopEnd).padding(24.dp)) + if (showBar || state.loading || state.error != null) StatusBar(state, tour != null, Modifier.align(Alignment.BottomCenter)) if (showMenu) { - Menu(state, session, vm, Modifier.align(Alignment.CenterEnd).focusRequester(menuFocus)) { showMenu = false } + Menu(state, session, vm, Modifier.align(Alignment.CenterEnd).focusRequester(menuFocus), onManual = { manual = it }) { showMenu = false } } } } @Composable -private fun StatusBar(state: PlaybackUiState, modifier: Modifier) { +private fun StatusBar(state: PlaybackUiState, touring: Boolean, modifier: Modifier) { Column( modifier.fillMaxWidth().background(Color(0xAA000000)).padding(24.dp, 12.dp), verticalArrangement = Arrangement.spacedBy(4.dp), @@ -153,7 +179,95 @@ private fun StatusBar(state: PlaybackUiState, modifier: Modifier) { } Text("$phase ${state.title} ${formatTime(state.positionMs)} / ${formatTime(state.durationMs)}") state.error?.let { Text(it, color = MaterialTheme.colorScheme.error) } - Text("OK — пауза · ←/→ — перемотка · ↑ — диагностика · ↓ — дорожки и движок", fontSize = 12.sp, color = Color.LightGray) + val hint = if (touring) { + "OK — пауза · ←/→ — перемотка · ↑ — следующий режим · ↓ — следующий файл · Меню — дорожки · Info — диагностика" + } else { + "OK — пауза · ←/→ — перемотка · ↑ — значок/диагностика · ↓ — варианты, дорожки, движок" + } + Text(hint, fontSize = 12.sp, color = Color.LightGray) + } +} + +/** + * Numbered playback variants for A/B testing on the TV: the badge in the corner shows the number, so the tester can + * note "file — variant — impression". 0 returns to the automatic choice. Picked from the ↓ menu or with digit keys. + */ +private enum class Variant( + val number: Int, + val label: String, + val engine: EngineId, + val path: VideoPathSwitchable.Mode? = null, + /** For Exo: decode Dolby Vision files through the DV decoder instead of the base layer. */ + val dolbyVision: Boolean = false, +) { + EXO_PLANE(1, "Exo · видеослой ТВ", ExoEngineProvider.ID, VideoPathSwitchable.Mode.VIDEO_PLANE), + EXO_GPU(2, "Exo · через GPU", ExoEngineProvider.ID, VideoPathSwitchable.Mode.GPU), + VLC(3, "VLC", VlcEngineProvider.ID), + EXO_DV(4, "Exo · DV-декодер", ExoEngineProvider.ID, VideoPathSwitchable.Mode.VIDEO_PLANE, dolbyVision = true), +} + +private const val TRANSCODE_KEY = 9 + +/** Returns the new "manual" flag, or null if [number] is not a variant. */ +private fun applyVariant(number: Int, session: PlaybackSession): Boolean? { + when (number) { + 0 -> { + session.switchToAuto() + return false + } + TRANSCODE_KEY -> session.forceTranscode() + else -> { + val v = Variant.entries.firstOrNull { it.number == number } ?: return null + val current = session.activeEngine + val sameDecoder = (current as? DolbyVisionSwitchable)?.useDolbyVisionDecoder?.let { it == v.dolbyVision } ?: true + if (current?.id == v.engine && current is VideoPathSwitchable && v.path != null && sameDecoder) current.setVideoPathMode(v.path) + else session.switchEngine(v.engine, videoPath = v.path, dolbyVision = v.dolbyVision.takeIf { v.engine == ExoEngineProvider.ID }) + } + } + return true +} + +/** The variant actually playing, e.g. "2" (Exo through GPU) or "3Т" (VLC on a server transcode). */ +private fun currentVariantCode(state: PlaybackUiState, session: PlaybackSession): String { + val engine = session.activeEngine + val gpu = (engine as? VideoPathSwitchable)?.usingGpuPath + val dv = (engine as? DolbyVisionSwitchable)?.useDolbyVisionDecoder == true + val v = Variant.entries.firstOrNull { + it.engine == state.engine && it.dolbyVision == dv && (it.path == null || gpu == (it.path == VideoPathSwitchable.Mode.GPU)) + } + val transcode = state.playMethod != null && state.playMethod != PlayMethod.DIRECT_PLAY + return (v?.number?.toString() ?: "?") + if (transcode) "Т" else "" +} + +@Composable +private fun VariantBadge(state: PlaybackUiState, session: PlaybackSession, manual: Boolean, tour: TourInfo?, modifier: Modifier) { + val engine = session.activeEngine + val path = (engine as? VideoPathSwitchable)?.let { if (it.usingGpuPath) "GPU" else "видеослой" } + ?.plus(if ((engine as? DolbyVisionSwitchable)?.useDolbyVisionDecoder == true) " · DV" else "") + val transcode = state.playMethod != null && state.playMethod != PlayMethod.DIRECT_PLAY + val head = listOfNotNull(state.engine?.value?.uppercase(), path, if (transcode) "ТРАНСКОД" else null).joinToString(" · ") + val mode = when { + manual -> "вручную" + state.engineReason.startsWith("fallback") || "не умеет" in state.engineReason || "все движки" in state.engineReason -> "авто, запасной" + else -> "авто" + } + Column( + modifier.background(Color(0xC0000000)).padding(16.dp, 10.dp), + horizontalAlignment = Alignment.End, + ) { + tour?.let { + Text("Отладка: файл ${it.number} из ${it.total}", fontSize = 14.sp, color = Color(0xFF80CBC4)) + } + Text( + "${currentVariantCode(state, session)} $head", + fontSize = 26.sp, fontWeight = FontWeight.Bold, fontFamily = FontFamily.Monospace, color = Color(0xFFFFD54F), + ) + Text( + "$mode · ${formatTime(state.positionMs)} · пропуски ${state.stats.droppedFrames}", + fontSize = 14.sp, fontFamily = FontFamily.Monospace, color = Color.White, + ) + Text(tour?.title ?: state.title, fontSize = if (tour != null) 16.sp else 12.sp, color = Color.LightGray) + tour?.let { Text(it.label, fontSize = 14.sp, color = Color.White, modifier = Modifier.widthIn(max = 900.dp)) } } } @@ -181,13 +295,27 @@ private fun DebugPanel(state: PlaybackUiState, vm: PlayerViewModel, modifier: Mo } @Composable -private fun Menu(state: PlaybackUiState, session: PlaybackSession, vm: PlayerViewModel, modifier: Modifier, close: () -> Unit) { +private fun Menu( + state: PlaybackUiState, + session: PlaybackSession, + vm: PlayerViewModel, + modifier: Modifier, + onManual: (Boolean) -> Unit, + close: () -> Unit, +) { val source = state.source ?: return fun mark(selected: Boolean) = if (selected) "● " else " " LazyColumn( modifier.fillMaxHeight().width(520.dp).background(Color(0xE0101010)).padding(16.dp), verticalArrangement = Arrangement.spacedBy(6.dp), ) { + item { Text("Вариант (сейчас ${currentVariantCode(state, session)})", style = MaterialTheme.typography.titleMedium) } + val variants = listOf(0 to "Авто") + Variant.entries.map { it.number to it.label } + listOf(TRANSCODE_KEY to "Транскод сервером (текущим движком)") + variants.forEach { (number, label) -> + item { + Button(onClick = { applyVariant(number, session)?.let(onManual); close() }) { Text("$number $label") } + } + } item { Text("Аудио", style = MaterialTheme.typography.titleMedium) } source.mediaStreams.orEmpty().filter { it.type == MediaStreamType.AUDIO }.forEach { s -> item { @@ -209,9 +337,9 @@ private fun Menu(state: PlaybackUiState, session: PlaybackSession, vm: PlayerVie } item { Text("Движок", style = MaterialTheme.typography.titleMedium) } vm.selector.engines.forEach { p -> - item { Button(onClick = { session.switchEngine(p.id); close() }) { Text(mark(state.engine == p.id) + p.displayName) } } + item { Button(onClick = { session.switchEngine(p.id); onManual(true); close() }) { Text(mark(state.engine == p.id) + p.displayName) } } } - item { Button(onClick = { session.forceTranscode(); close() }) { Text(" Транскод на сервере") } } + item { Button(onClick = { session.forceTranscode(); onManual(true); close() }) { Text(" Транскод на сервере") } } (session.activeEngine as? VideoPathSwitchable)?.let { engine -> item { Text("Вывод видео", style = MaterialTheme.typography.titleMedium) } val modes = listOf( @@ -220,7 +348,11 @@ private fun Menu(state: PlaybackUiState, session: PlaybackSession, vm: PlayerVie VideoPathSwitchable.Mode.GPU to "Через GPU, как VLC (без HDR, до 1080p)", ) modes.forEach { (mode, label) -> - item { Button(onClick = { engine.setVideoPathMode(mode); close() }) { Text(mark(engine.videoPathMode == mode) + label) } } + item { + Button(onClick = { engine.setVideoPathMode(mode); onManual(mode != VideoPathSwitchable.Mode.AUTO); close() }) { + Text(mark(engine.videoPathMode == mode) + label) + } + } } } } diff --git a/app/src/main/kotlin/ru/stenfredd/movies/ui/Screen.kt b/app/src/main/kotlin/ru/stenfredd/movies/ui/Screen.kt index 496c1eb..b26bc73 100644 --- a/app/src/main/kotlin/ru/stenfredd/movies/ui/Screen.kt +++ b/app/src/main/kotlin/ru/stenfredd/movies/ui/Screen.kt @@ -1,6 +1,7 @@ package ru.stenfredd.movies.ui import ru.stenfredd.movies.playback.api.EngineId +import ru.stenfredd.movies.playback.api.StreamSelection import java.util.UUID sealed interface Screen { @@ -8,5 +9,16 @@ sealed interface Screen { data object Home : Screen data class Items(val parentId: UUID, val title: String) : Screen data class Detail(val itemId: UUID) : Screen - data class Player(val itemId: UUID, val engine: EngineId?, val startPositionMs: Long) : Screen + data class Player( + val itemId: UUID, + val engine: EngineId?, + val startPositionMs: Long, + /** Tracks to start with instead of the file's defaults. */ + val streams: StreamSelection? = null, + /** Start at this fraction of the runtime instead of [startPositionMs]. */ + val startFraction: Double? = null, + ) : Screen + + /** Debug tour: plays a prepared list of files covering every format on the server, see [TourScreen]. */ + data class Tour(val startIndex: Int = 0) : Screen } diff --git a/app/src/main/kotlin/ru/stenfredd/movies/ui/TourScreen.kt b/app/src/main/kotlin/ru/stenfredd/movies/ui/TourScreen.kt new file mode 100644 index 0000000..89e1ef3 --- /dev/null +++ b/app/src/main/kotlin/ru/stenfredd/movies/ui/TourScreen.kt @@ -0,0 +1,139 @@ +package ru.stenfredd.movies.ui + +import android.content.Context +import android.view.KeyEvent as AndroidKeyEvent +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.background +import androidx.compose.foundation.focusable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.nativeKeyCode +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.input.key.type +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.tv.material3.Text +import java.util.UUID +import kotlinx.coroutines.delay +import org.json.JSONArray +import ru.stenfredd.movies.playback.api.StreamSelection +import timber.log.Timber + +/** + * One file of the debug tour with the tracks to select. The list is `assets/tour.json` of the debug build, + * generated by `tools/tour/build_tour.py` (a minimal set of files covering every format feature on the server). + */ +class TourEntry(val id: UUID, val title: String, val audio: Int?, val subtitle: Int?, val label: String) { + companion object { + private const val ASSET = "tour.json" + + fun available(context: Context): Boolean = runCatching { ASSET in context.assets.list("").orEmpty() }.getOrDefault(false) + + fun load(context: Context): List = runCatching { + val json = JSONArray(context.assets.open(ASSET).bufferedReader().use { it.readText() }) + (0 until json.length()).map { i -> + val o = json.getJSONObject(i) + TourEntry( + id = UUID.fromString(o.getString("id").replace(Regex("(.{8})(.{4})(.{4})(.{4})(.{12})"), "$1-$2-$3-$4-$5")), + title = o.optString("title"), + audio = if (o.isNull("audio")) null else o.getInt("audio"), + subtitle = if (o.isNull("subtitle")) null else o.getInt("subtitle"), + label = o.optString("label"), + ) + } + }.onFailure { Timber.w(it, "Tour list not loaded") }.getOrDefault(emptyList()) + } +} + +/** + * Plays the tour files one by one, each from the middle: ↓ next file, ↑ next playback variant (see [PlayerScreen]). + * + * ↓ first only shows which file is next, the file starts after [START_DELAY_MS] without further keys: skipping + * several files in a row would otherwise start and tear down a player on every press and choke the TV. + * While waiting, ↓/↑ move forward/back through the list. + */ +@Composable +fun TourScreen(startIndex: Int, onExit: () -> Unit) { + val context = LocalContext.current + val entries = remember { TourEntry.load(context) } + var index by rememberSaveable { mutableIntStateOf(startIndex.coerceIn(0, (entries.size - 1).coerceAtLeast(0))) } + var playing by rememberSaveable { mutableIntStateOf(index) } + if (entries.isEmpty()) { + Box(Modifier.fillMaxSize()) { Text("Список файлов для отладки пуст (assets/tour.json)", Modifier.align(Alignment.Center)) } + return + } + LaunchedEffect(index) { + if (index == playing) return@LaunchedEffect + delay(START_DELAY_MS) + playing = index + } + val entry = entries[index] + if (index != playing) { + TourPending(index + 1, entries.size, entry, onMove = { step -> index = (index + step).mod(entries.size) }, onExit = onExit) + return + } + key(index) { + PlayerScreen( + Screen.Player( + itemId = entry.id, + engine = null, + startPositionMs = 0, + streams = StreamSelection(entry.audio, entry.subtitle), + startFraction = 0.5, + ), + onExit = onExit, + tour = TourInfo(index + 1, entries.size, entry.title, entry.label) { index = (index + 1) % entries.size }, + ) + } +} + +private const val START_DELAY_MS = 2000L + +/** Black screen with the file that will start: nothing is loaded yet. */ +@Composable +private fun TourPending(number: Int, total: Int, entry: TourEntry, onMove: (Int) -> Unit, onExit: () -> Unit) { + val focus = remember { FocusRequester() } + LaunchedEffect(Unit) { runCatching { focus.requestFocus() } } + BackHandler(onBack = onExit) + Box( + Modifier + .fillMaxSize() + .background(Color.Black) + .focusRequester(focus) + .onPreviewKeyEvent { e -> + if (e.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false + when (e.key.nativeKeyCode) { + AndroidKeyEvent.KEYCODE_DPAD_DOWN -> onMove(1) + AndroidKeyEvent.KEYCODE_DPAD_UP -> onMove(-1) + else -> return@onPreviewKeyEvent false + } + true + } + .focusable(), + ) { + Column(Modifier.align(Alignment.Center).padding(48.dp), horizontalAlignment = Alignment.CenterHorizontally) { + Text("Отладка: файл $number из $total", fontSize = 20.sp, color = Color(0xFF80CBC4)) + Text(entry.title, fontSize = 28.sp, color = Color.White) + Text(entry.label, fontSize = 16.sp, color = Color.LightGray) + Text("Запуск через 2 с · ↓ дальше · ↑ назад", fontSize = 14.sp, color = Color.Gray, modifier = Modifier.padding(top = 24.dp)) + } + } +} diff --git a/core/jellyfin/src/main/kotlin/ru/stenfredd/movies/core/jellyfin/JellyfinSession.kt b/core/jellyfin/src/main/kotlin/ru/stenfredd/movies/core/jellyfin/JellyfinSession.kt index 9747c19..50b9bc0 100644 --- a/core/jellyfin/src/main/kotlin/ru/stenfredd/movies/core/jellyfin/JellyfinSession.kt +++ b/core/jellyfin/src/main/kotlin/ru/stenfredd/movies/core/jellyfin/JellyfinSession.kt @@ -2,22 +2,27 @@ package ru.stenfredd.movies.core.jellyfin import android.content.Context import android.os.Build +import android.os.SystemClock import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.preferencesDataStore import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.extensions.authenticateUserByName import org.jellyfin.sdk.api.client.extensions.authenticationApi import org.jellyfin.sdk.createJellyfin import org.jellyfin.sdk.model.ClientInfo import org.jellyfin.sdk.model.DeviceInfo -import kotlinx.coroutines.withContext +import timber.log.Timber import java.util.UUID import javax.inject.Inject import javax.inject.Singleton @@ -49,6 +54,30 @@ class JellyfinSession @Inject constructor( val deviceId: String get() = api.deviceInfo.id + /** Direct LAN address of the same server for media streams, see [LocalServerDiscovery]; null away from home. */ + @Volatile + private var localUrl: String? = null + @Volatile + private var localCheckedAt = 0L + private val discoveryScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + + /** + * Looks for the server on the local network in the background (at most every [LOCAL_RECHECK_MS]); playback uses + * whatever is known at that moment and never waits for it. + */ + fun refreshLocalAddress() { + val s = _session.value ?: return + val now = SystemClock.elapsedRealtime() + if (localCheckedAt != 0L && now - localCheckedAt < LOCAL_RECHECK_MS) return + localCheckedAt = now + discoveryScope.launch { + val id = LocalServerDiscovery.serverId(s.serverUrl) + val found = id?.let { LocalServerDiscovery.find(it) } + if (found != localUrl) Timber.i("Media streams: %s", found ?: "через ${s.serverUrl}") + localUrl = found + } + } + /** Restores the saved session. Returns false when the user has to log in. */ suspend fun restore(): Boolean { val prefs = context.sessionStore.data.first() @@ -58,6 +87,7 @@ class JellyfinSession @Inject constructor( val userId = prefs[KEY_USER_ID]?.let(UUID::fromString) ?: return false api.update(baseUrl = url, accessToken = token) _session.value = SessionInfo(url, userId, prefs[KEY_USER_NAME].orEmpty(), token) + refreshLocalAddress() return true } @@ -75,6 +105,9 @@ class JellyfinSession @Inject constructor( it[KEY_USER_NAME] = user.name.orEmpty() } _session.value = SessionInfo(url, user.id, user.name.orEmpty(), token) + localUrl = null + localCheckedAt = 0 + refreshLocalAddress() } suspend fun logout() { @@ -91,10 +124,18 @@ class JellyfinSession @Inject constructor( fun requireSession(): SessionInfo = checkNotNull(_session.value) { "Нет активной сессии" } - /** Absolute URL for a server-relative path, with the token as `ApiKey` (engines can't send headers). */ + /** + * Absolute URL for a server-relative path or a URL on the server, with the token as `ApiKey` (engines can't send + * headers). Media goes to the LAN address when one was found; API calls keep using [SessionInfo.serverUrl]. + */ fun authorizedUrl(pathOrUrl: String): String { val s = requireSession() - val absolute = if (pathOrUrl.startsWith("http")) pathOrUrl else s.serverUrl + pathOrUrl + val base = localUrl ?: s.serverUrl + val absolute = when { + pathOrUrl.startsWith(s.serverUrl) -> base + pathOrUrl.removePrefix(s.serverUrl) + pathOrUrl.startsWith("http") -> pathOrUrl + else -> base + pathOrUrl + } if (absolute.contains("ApiKey=", ignoreCase = true) || absolute.contains("api_key=")) return absolute val separator = if ('?' in absolute) '&' else '?' return "$absolute${separator}${ApiClient.QUERY_ACCESS_TOKEN}=${s.accessToken}" @@ -116,5 +157,6 @@ class JellyfinSession @Inject constructor( val KEY_USER_ID = stringPreferencesKey("user_id") val KEY_USER_NAME = stringPreferencesKey("user_name") val KEY_DEVICE_ID = stringPreferencesKey("device_id") + const val LOCAL_RECHECK_MS = 10 * 60_000L } } diff --git a/core/jellyfin/src/main/kotlin/ru/stenfredd/movies/core/jellyfin/LocalServerDiscovery.kt b/core/jellyfin/src/main/kotlin/ru/stenfredd/movies/core/jellyfin/LocalServerDiscovery.kt new file mode 100644 index 0000000..d1fa1d9 --- /dev/null +++ b/core/jellyfin/src/main/kotlin/ru/stenfredd/movies/core/jellyfin/LocalServerDiscovery.kt @@ -0,0 +1,82 @@ +package ru.stenfredd.movies.core.jellyfin + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.json.JSONObject +import timber.log.Timber +import java.net.DatagramPacket +import java.net.DatagramSocket +import java.net.HttpURLConnection +import java.net.InetAddress +import java.net.SocketTimeoutException +import java.net.URL + +/** + * Finds a direct LAN address of the logged-in server, for streaming. + * + * At home the TV reached the server through its public HTTPS name: router hairpin, reverse proxy, and TLS decrypted + * by the TV's CPU. That capped Exo at 40–115 Mbit/s and a 66 Mbit/s 4K remux rebuffered every + * few seconds, while plain HTTP to the LAN address gives the TV ~240 Mbit/s (2026-10-13). + * + * The server's own `LocalAddress` is the public name too (it is configured that way), so the address comes from + * Jellyfin's UDP discovery (port 7359): the reply's source IP, with the default HTTP port. It is only used if + * `/System/Info/Public` there reports the same server id. + */ +internal object LocalServerDiscovery { + private const val DISCOVERY_PORT = 7359 + private const val HTTP_PORT = 8096 + private const val TIMEOUT_MS = 1500 + private const val MESSAGE = "Who is JellyfinServer?" + + /** Server id from `/System/Info/Public` of [baseUrl], or null. */ + suspend fun serverId(baseUrl: String): String? = withContext(Dispatchers.IO) { + runCatching { JSONObject(get("$baseUrl/System/Info/Public")).optString("Id").takeIf { it.isNotEmpty() } } + .onFailure { Timber.d(it, "Server id not read from %s", baseUrl) } + .getOrNull() + } + + /** `http://:8096` of the server with [serverId] on the local network, or null. */ + suspend fun find(serverId: String): String? = withContext(Dispatchers.IO) { + val candidates = runCatching { discover(serverId) }.onFailure { Timber.d(it, "Jellyfin discovery failed") }.getOrDefault(emptyList()) + candidates.firstOrNull { url -> serverId(url) == serverId } + } + + private fun discover(serverId: String): List { + val found = mutableListOf() + DatagramSocket().use { socket -> + socket.broadcast = true + socket.soTimeout = TIMEOUT_MS + val message = MESSAGE.toByteArray() + socket.send(DatagramPacket(message, message.size, InetAddress.getByName("255.255.255.255"), DISCOVERY_PORT)) + val buffer = ByteArray(4096) + val deadline = System.currentTimeMillis() + TIMEOUT_MS + while (System.currentTimeMillis() < deadline) { + val packet = DatagramPacket(buffer, buffer.size) + try { + socket.receive(packet) + } catch (_: SocketTimeoutException) { + break + } + val reply = runCatching { JSONObject(String(packet.data, 0, packet.length)) }.getOrNull() ?: continue + if (reply.optString("Id") != serverId) continue + val host = packet.address.hostAddress ?: continue + found += "http://$host:$HTTP_PORT" + // The advertised address, if it happens to be a plain LAN URL, is worth a try as well. + reply.optString("Address").takeIf { it.startsWith("http://") }?.let { found += it.trimEnd('/') } + break + } + } + return found.distinct() + } + + private fun get(url: String): String { + val connection = URL(url).openConnection() as HttpURLConnection + connection.connectTimeout = TIMEOUT_MS + connection.readTimeout = TIMEOUT_MS + return try { + connection.inputStream.bufferedReader().use { it.readText() } + } finally { + connection.disconnect() + } + } +} diff --git a/playback/api/src/main/kotlin/ru/stenfredd/movies/playback/api/EngineProvider.kt b/playback/api/src/main/kotlin/ru/stenfredd/movies/playback/api/EngineProvider.kt index d237cdd..4f159f3 100644 --- a/playback/api/src/main/kotlin/ru/stenfredd/movies/playback/api/EngineProvider.kt +++ b/playback/api/src/main/kotlin/ru/stenfredd/movies/playback/api/EngineProvider.kt @@ -40,7 +40,10 @@ enum class Loss(val weight: Int, val label: String) { SOFTWARE_DECODE(15, "программное декодирование"), DV_TO_BASE_LAYER(30, "Dolby Vision → базовый слой"), HDR_UNVERIFIED(25, "HDR-вывод не проверен"), - ASS_SIMPLIFIED(20, "ASS упрощён"), + /** Outweighs [SOFTWARE_DECODE]: playback stutters and stalls, not just a quality loss. */ + BROKEN_AUDIO_TIMING(30, "сбивается синхронизация звука"), /** Small: TV SoCs usually deinterlace in the display pipeline after the hardware decoder. */ NO_DEINTERLACE(5, "без деинтерлейса в плеере"), + /** Outweighs [SOFTWARE_DECODE]: visible combing in motion, not a subtle quality loss. */ + DVD_COMBING(30, "гребёнка на чересстрочном DVD"), } diff --git a/playback/api/src/main/kotlin/ru/stenfredd/movies/playback/api/Models.kt b/playback/api/src/main/kotlin/ru/stenfredd/movies/playback/api/Models.kt index c2bda21..499ac65 100644 --- a/playback/api/src/main/kotlin/ru/stenfredd/movies/playback/api/Models.kt +++ b/playback/api/src/main/kotlin/ru/stenfredd/movies/playback/api/Models.kt @@ -35,6 +35,8 @@ data class EngineError( AUDIO_OUTPUT, NETWORK, NO_VIDEO_OUTPUT, + /** Playing but not progressing, or dropping a large share of frames (seen by the session's stall watchdog). */ + STALLED, UNKNOWN, } diff --git a/playback/api/src/main/kotlin/ru/stenfredd/movies/playback/api/PlaybackEngine.kt b/playback/api/src/main/kotlin/ru/stenfredd/movies/playback/api/PlaybackEngine.kt index 0ffe09f..e8ca771 100644 --- a/playback/api/src/main/kotlin/ru/stenfredd/movies/playback/api/PlaybackEngine.kt +++ b/playback/api/src/main/kotlin/ru/stenfredd/movies/playback/api/PlaybackEngine.kt @@ -57,6 +57,14 @@ interface VideoPathSwitchable { fun setVideoPathMode(mode: Mode) } +/** + * Dolby Vision files with a base layer can be decoded either as that base layer (HDR10/HLG, the default) or through + * the Dolby Vision decoder, which switches the TV into its Dolby Vision mode. Must be set before [PlaybackEngine.load]. + */ +interface DolbyVisionSwitchable { + var useDolbyVisionDecoder: Boolean +} + interface SubtitleDelayCapable { fun setSubtitleDelay(delayMs: Long) } diff --git a/playback/core/src/main/kotlin/ru/stenfredd/movies/playback/core/EngineSelector.kt b/playback/core/src/main/kotlin/ru/stenfredd/movies/playback/core/EngineSelector.kt index ea21029..e2d4d77 100644 --- a/playback/core/src/main/kotlin/ru/stenfredd/movies/playback/core/EngineSelector.kt +++ b/playback/core/src/main/kotlin/ru/stenfredd/movies/playback/core/EngineSelector.kt @@ -27,7 +27,7 @@ class EngineSelector @Inject constructor( providers.map { RankedEngine(it, it.evaluate(source, selection, device)) } .sortedWith(compareBy { score(it.verdict) }.thenBy { it.provider.priority }) - private fun score(verdict: Verdict): Int = when (verdict) { + fun score(verdict: Verdict): Int = when (verdict) { is Verdict.Direct -> 0 is Verdict.Degraded -> verdict.losses.sumOf(Loss::weight) is Verdict.No -> 10_000 diff --git a/playback/core/src/main/kotlin/ru/stenfredd/movies/playback/core/PlaybackSession.kt b/playback/core/src/main/kotlin/ru/stenfredd/movies/playback/core/PlaybackSession.kt index 9fc7eb6..9e77155 100644 --- a/playback/core/src/main/kotlin/ru/stenfredd/movies/playback/core/PlaybackSession.kt +++ b/playback/core/src/main/kotlin/ru/stenfredd/movies/playback/core/PlaybackSession.kt @@ -1,5 +1,7 @@ package ru.stenfredd.movies.playback.core +import android.net.Uri +import android.os.SystemClock import android.widget.FrameLayout import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -30,6 +32,7 @@ import org.jellyfin.sdk.model.api.PlaybackStopInfo import org.jellyfin.sdk.model.api.RepeatMode import org.jellyfin.sdk.model.api.SubtitleDeliveryMethod import ru.stenfredd.movies.core.jellyfin.JellyfinSession +import ru.stenfredd.movies.playback.api.DolbyVisionSwitchable import ru.stenfredd.movies.playback.api.EngineError import ru.stenfredd.movies.playback.api.EngineEvent import ru.stenfredd.movies.playback.api.EngineId @@ -41,6 +44,7 @@ import ru.stenfredd.movies.playback.api.LoadRequest import ru.stenfredd.movies.playback.api.PlaybackEngine import ru.stenfredd.movies.playback.api.StreamSelection import ru.stenfredd.movies.playback.api.Verdict +import ru.stenfredd.movies.playback.api.VideoPathSwitchable import ru.stenfredd.movies.playback.api.videoStream import timber.log.Timber import java.util.UUID @@ -105,6 +109,13 @@ class PlaybackSession( private var ranking: List = emptyList() private val tried = mutableSetOf() private var transcodeTried = false + private var nextVideoPath: VideoPathSwitchable.Mode? = null + private var nextDolbyVision: Boolean? = null + /** Set by [switchToAuto]: the next engine keeps its own default decoder choice instead of the old engine's. */ + private var defaultDolbyVision = false + /** The user picked the engine: the stall watchdog only logs instead of switching away from their choice. */ + private var manualEngine = false + private var lastSeekAt = 0L private var playSessionId: String? = null private var playMethod: PlayMethod = PlayMethod.DIRECT_PLAY @@ -121,14 +132,16 @@ class PlaybackSession( container = null } - fun start(itemId: UUID, forcedEngine: EngineId?, startPositionMs: Long) { + fun start(itemId: UUID, forcedEngine: EngineId?, startPositionMs: Long, streams: StreamSelection? = null, startFraction: Double? = null) { scope.launch { runCatching { val user = session.requireSession() + session.refreshLocalAddress() withContext(Dispatchers.Default) { probe.capabilities } item = session.call { libraryApi.getItem(itemId, user.userId).content } source = item.mediaSources?.firstOrNull() ?: error("У элемента нет медиаисточников") - selection = StreamSelection(source.defaultAudioStreamIndex, source.defaultSubtitleStreamIndex) + selection = streams ?: StreamSelection(source.defaultAudioStreamIndex, source.defaultSubtitleStreamIndex) + val startMs = startFraction?.let { ((item.runTimeTicks ?: 0) / TICKS_PER_MS * it).toLong() } ?: startPositionMs ranking = selector.rank(source, selection, device) _state.update { it.copy( @@ -141,7 +154,8 @@ class PlaybackSession( log("Ранжирование: " + ranking.joinToString { "${it.provider.id}=${it.verdict.describe()}" }) val first = forcedEngine?.let(selector::provider) ?: ranking.first().provider val reason = if (forcedEngine != null) "выбран вручную" else ranking.first().verdict.describe() - launchEngine(first, startPositionMs, forceTranscode = false, reason = reason) + manualEngine = forcedEngine != null + launchEngine(first, startMs, forceTranscode = false, reason = reason) }.onFailure { fail("Не удалось начать воспроизведение", it) } } } @@ -152,7 +166,7 @@ class PlaybackSession( reportProgressAsync() } - fun seekBy(deltaMs: Long) = seekTo((engine?.positionMs ?: 0) + deltaMs) + fun seekBy(deltaMs: Long) = seekTo(positionMs() + deltaMs) fun seekTo(positionMs: Long) { val e = engine ?: return @@ -169,34 +183,74 @@ class PlaybackSession( return } } + lastSeekAt = SystemClock.elapsedRealtime() e.seekTo(target) reportProgressAsync() } fun selectAudio(streamIndex: Int) { selection = selection.copy(audioStreamIndex = streamIndex) - _state.update { it.copy(selection = selection) } - if (playMethod == PlayMethod.DIRECT_PLAY) engine?.selectAudio(streamIndex) else restartCurrent("смена аудио") + onSelectionChanged("смена аудио") { it.selectAudio(streamIndex) } } fun selectSubtitle(streamIndex: Int?) { selection = selection.copy(subtitleStreamIndex = streamIndex) - _state.update { it.copy(selection = selection) } - if (playMethod == PlayMethod.DIRECT_PLAY) engine?.selectSubtitle(streamIndex) else restartCurrent("смена субтитров") + onSelectionChanged("смена субтитров") { it.selectSubtitle(streamIndex) } } - fun switchEngine(id: EngineId) { - val position = engine?.positionMs ?: 0 + /** + * Tracks change what each engine can do (ASS subtitles, TrueHD audio, ...): re-rank, and in automatic mode move + * to an engine that is now strictly better. Engines that already failed in this session are not retried. + */ + private fun onSelectionChanged(what: String, applyInPlace: (PlaybackEngine) -> Unit) { + ranking = selector.rank(source, selection, device) + _state.update { it.copy(selection = selection, ranking = ranking.map { r -> r.provider.id to r.verdict }) } + val e = engine ?: return + val current = ranking.firstOrNull { it.provider.id == e.id } + val best = ranking.firstOrNull { it.provider.id == e.id || it.provider.id !in tried } + if (!manualEngine && current != null && best != null && best.provider.id != e.id && + selector.score(best.verdict) < selector.score(current.verdict) + ) { + val position = positionMs() + log("$what: ${best.provider.id} теперь подходит лучше (${best.verdict.describe()})") + scope.launch { launchEngine(best.provider, position, forceTranscode = false, reason = "$what: ${best.verdict.describe()}") } + return + } + if (playMethod == PlayMethod.DIRECT_PLAY) applyInPlace(e) else restartCurrent(what) + } + + /** [videoPath] is applied to the new engine before it loads (if it is [VideoPathSwitchable]). */ + fun switchEngine(id: EngineId, transcode: Boolean = false, videoPath: VideoPathSwitchable.Mode? = null, dolbyVision: Boolean? = null) { + val position = positionMs() scope.launch { tried.clear() transcodeTried = false - launchEngine(selector.provider(id), position, forceTranscode = false, reason = "выбран вручную") + nextVideoPath = videoPath + nextDolbyVision = dolbyVision + manualEngine = true + launchEngine(selector.provider(id), position, forceTranscode = transcode, reason = if (transcode) "транскод вручную" else "выбран вручную") + } + } + + /** Back to what the selector would pick for this file. */ + fun switchToAuto() { + val first = ranking.firstOrNull() ?: return + val position = positionMs() + scope.launch { + tried.clear() + transcodeTried = false + nextVideoPath = VideoPathSwitchable.Mode.AUTO + nextDolbyVision = null + defaultDolbyVision = true + manualEngine = false + launchEngine(first.provider, position, forceTranscode = false, reason = first.verdict.describe()) } } fun forceTranscode() { - val position = engine?.positionMs ?: 0 + val position = positionMs() val provider = engine?.id?.let(selector::provider) ?: return + manualEngine = true scope.launch { launchEngine(provider, position, forceTranscode = true, reason = "транскод вручную") } } @@ -211,7 +265,7 @@ class PlaybackSession( private fun restartCurrent(why: String) { val provider = engine?.id?.let(selector::provider) ?: return - val position = engine?.positionMs ?: 0 + val position = positionMs() scope.launch { launchEngine(provider, position, forceTranscode = playMethod != PlayMethod.DIRECT_PLAY, reason = why) } } @@ -220,10 +274,13 @@ class PlaybackSession( engineJobs.forEach(Job::cancel) engineJobs.clear() engine?.let { old -> - reportStop(old.positionMs, failed = false) + if (nextVideoPath == null) nextVideoPath = (old as? VideoPathSwitchable)?.videoPathMode + if (nextDolbyVision == null && !defaultDolbyVision) nextDolbyVision = (old as? DolbyVisionSwitchable)?.useDolbyVisionDecoder + reportStop(positionMs(), failed = false) old.release() } engine = null + launchPositionMs = startMs tried += provider.id if (forceTranscode) transcodeTried = true _state.update { it.copy(loading = true, error = null, engine = provider.id, engineReason = reason) } @@ -278,13 +335,19 @@ class PlaybackSession( } val transcodeReasons = ms.transcodingUrl?.let { Regex("TranscodeReasons=([^&]+)").find(it)?.groupValues?.get(1) } ?.let { java.net.URLDecoder.decode(it, "UTF-8") } - log("${provider.id}: $playMethod" + (transcodeReasons?.let { " (причины транскода: $it)" } ?: "")) + val host = Uri.parse(url).let { "${it.scheme}://${it.authority}" } + log("${provider.id}: $playMethod через $host" + (transcodeReasons?.let { " (причины транскода: $it)" } ?: "")) val external = ms.mediaStreams.orEmpty() .filter { it.type == MediaStreamType.SUBTITLE && it.deliveryMethod == SubtitleDeliveryMethod.EXTERNAL && it.deliveryUrl != null } .map { ExternalSubtitle(it.index, session.authorizedUrl(it.deliveryUrl!!), it.codec, it.language, it.displayTitle) } val newEngine = provider.create(device) + nextVideoPath?.let { (newEngine as? VideoPathSwitchable)?.setVideoPathMode(it) } + nextVideoPath = null + nextDolbyVision?.let { (newEngine as? DolbyVisionSwitchable)?.useDolbyVisionDecoder = it } + nextDolbyVision = null + defaultDolbyVision = false engine = newEngine firstFrameSeen = false container?.let(newEngine::attachVideo) @@ -293,6 +356,7 @@ class PlaybackSession( engineJobs += scope.launch { newEngine.stats.collect { s -> _state.update { it.copy(stats = s) } } } engineJobs += scope.launch { tickLoop(gen) } engineJobs += scope.launch { firstFrameWatchdog(gen) } + engineJobs += scope.launch { stallWatchdog(gen) } _state.update { it.copy(playMethod = playMethod, transcodeReasons = transcodeReasons) } newEngine.load( @@ -311,6 +375,18 @@ class PlaybackSession( } private var firstFrameSeen = false + /** Where the current engine was asked to start; see [positionMs]. */ + private var launchPositionMs = 0L + + /** + * Playback position for handing over to another engine. Until the engine shows its first frame its own position + * is meaningless (0 or -1, or relative to a transcode), so the position it was started at is used: otherwise a + * quick variant switch (or a fallback from an engine that never got going) restarts the film from the beginning. + */ + private fun positionMs(): Long { + val e = engine + return if (e == null || !firstFrameSeen) launchPositionMs else e.positionMs + } private suspend fun firstFrameWatchdog(gen: Int) { if (source.videoStream == null) return @@ -320,6 +396,52 @@ class PlaybackSession( } } + /** + * Catches what the first-frame watchdog can't: an engine that started fine but then hangs (reports READY and + * playing, position stands still) or plays so badly it is useless (e.g. Exo on AC3-in-AVI with broken audio + * timing: ~40% of frames dropped). Buffering alone is not treated as a stall: that is usually the network, and + * another engine would not fetch faster. + */ + private suspend fun stallWatchdog(gen: Int) { + val video = source.videoStream ?: return + val fps = (video.realFrameRate ?: video.averageFrameRate ?: 25f).coerceIn(10f, 120f) + var lastPosition = -1L + var stuckMs = 0L + val drops = ArrayDeque() + while (scope.isActive && gen == generation) { + delay(STALL_TICK_MS) + val e = engine ?: return + val s = e.state.value + val settled = firstFrameSeen && SystemClock.elapsedRealtime() - lastSeekAt > SEEK_GRACE_MS + if (!s.playWhenReady || s.phase == EngineState.Phase.ENDED || !settled) { + lastPosition = -1 + stuckMs = 0 + drops.clear() + continue + } + val position = e.positionMs + stuckMs = if (s.phase == EngineState.Phase.READY && lastPosition >= 0 && position <= lastPosition) stuckMs + STALL_TICK_MS else 0 + lastPosition = position + drops.addLast(_state.value.stats.droppedFrames) + if (drops.size > DROP_WINDOW_TICKS + 1) drops.removeFirst() + val dropped = if (drops.size > DROP_WINDOW_TICKS) drops.last() - drops.first() else 0 + val windowS = DROP_WINDOW_TICKS * STALL_TICK_MS / 1000 + val reason = when { + stuckMs >= STALL_TIMEOUT_MS -> "играет, но позиция стоит ${stuckMs / 1000} с" + dropped > fps * windowS * MAX_DROPPED_SHARE -> "пропущено $dropped кадров за $windowS с" + else -> continue + } + if (manualEngine) { + log("Застой ${e.id}: $reason (движок выбран вручную, не переключаю)") + stuckMs = 0 + drops.clear() + continue + } + onEngineEvent(gen, EngineEvent.Error(EngineError(EngineError.Kind.STALLED, "Застой: $reason"))) + return + } + } + private fun onEngineEvent(gen: Int, event: EngineEvent) { if (gen != generation) return when (event) { @@ -329,7 +451,7 @@ class PlaybackSession( } is EngineEvent.Error -> { val failedEngine = engine?.id - val position = engine?.positionMs ?: 0 + val position = positionMs() log("Ошибка $failedEngine: ${event.error.kind} — ${event.error.message}") Timber.w(event.error.cause, "Engine %s failed: %s", failedEngine, event.error.message) val next = if (event.error.worthFallback) { @@ -354,7 +476,7 @@ class PlaybackSession( var sinceReport = 0L while (scope.isActive && gen == generation) { val e = engine ?: return - _state.update { it.copy(positionMs = e.positionMs, durationMs = e.durationMs) } + _state.update { it.copy(positionMs = positionMs(), durationMs = e.durationMs) } delay(TICK_MS) sinceReport += TICK_MS if (sinceReport >= PROGRESS_INTERVAL_MS) { @@ -468,5 +590,11 @@ class PlaybackSession( const val TICK_MS = 500L const val PROGRESS_INTERVAL_MS = 10_000L const val FIRST_FRAME_TIMEOUT_MS = 60_000L + const val STALL_TICK_MS = 1000L + const val STALL_TIMEOUT_MS = 8_000L + /** Seeking rebuffers and drops a few frames: give the engine time to settle before judging it. */ + const val SEEK_GRACE_MS = 5_000L + const val DROP_WINDOW_TICKS = 10 + const val MAX_DROPPED_SHARE = 0.25 } } diff --git a/playback/engine-exo/build.gradle.kts b/playback/engine-exo/build.gradle.kts index 2bc2e25..47876ba 100644 --- a/playback/engine-exo/build.gradle.kts +++ b/playback/engine-exo/build.gradle.kts @@ -19,6 +19,8 @@ dependencies { implementation(libs.media3.exoplayer) implementation(libs.media3.exoplayer.hls) implementation(libs.media3.ui) + implementation(libs.ass.media) + implementation(libs.jellyfin.ffmpeg) implementation(libs.hilt.android) ksp(libs.hilt.compiler) implementation(libs.timber) diff --git a/playback/engine-exo/src/main/java/ru/stenfredd/movies/playback/exo/ps/DvdPrivateStreamReader.java b/playback/engine-exo/src/main/java/ru/stenfredd/movies/playback/exo/ps/DvdPrivateStreamReader.java new file mode 100644 index 0000000..102573e --- /dev/null +++ b/playback/engine-exo/src/main/java/ru/stenfredd/movies/playback/exo/ps/DvdPrivateStreamReader.java @@ -0,0 +1,129 @@ +package ru.stenfredd.movies.playback.exo.ps; + +import android.util.SparseArray; +import androidx.annotation.Nullable; +import androidx.media3.common.C; +import androidx.media3.common.MimeTypes; +import androidx.media3.common.ParserException; +import androidx.media3.common.util.ParsableByteArray; +import androidx.media3.common.util.UnstableApi; +import androidx.media3.extractor.ExtractorOutput; +import androidx.media3.extractor.ts.Ac3Reader; +import androidx.media3.extractor.ts.ElementaryStreamReader; +import androidx.media3.extractor.ts.TsPayloadReader; + +/** + * Reader for MPEG-PS private stream 1 (0xBD) as DVDs use it: every payload starts with a substream header (id, number + * of frame headers, 2-byte first access unit pointer), and one stream id carries all AC3 tracks (0x80-0x87), DTS + * (0x88-0x8F), LPCM (0xA0-0xA7) and subpictures (0x20-0x3F). + * + *

Each AC3 substream gets its own {@link Ac3Reader} and track, so the usual track selection picks the language; + * other substreams are dropped. A stream without substream headers (raw AC3 starting with the 0B 77 sync word) goes + * to a single reader unchanged. + * + *

Tracks can only be created before {@link DvdPsExtractor} ends the track list; substreams that first show up later + * are ignored. + */ +@UnstableApi +/* package */ final class DvdPrivateStreamReader implements ElementaryStreamReader { + + private static final int MODE_UNKNOWN = 0; + private static final int MODE_SUBSTREAMS = 1; + private static final int MODE_RAW = 2; + private static final int RAW_KEY = -1; + + private final SparseArray readers = new SparseArray<>(); + @Nullable private ExtractorOutput output; + private int mode = MODE_UNKNOWN; + private boolean tracksEnded; + private long pesTimeUs = C.TIME_UNSET; + private int pesFlags; + @Nullable private Ac3Reader current; + + /** Called by {@link DvdPsExtractor} when it ends the track list. */ + public void onTracksEnded() { + tracksEnded = true; + } + + @Override + public void seek() { + for (int i = 0; i < readers.size(); i++) { + readers.valueAt(i).seek(); + } + current = null; + } + + @Override + public void createTracks(ExtractorOutput extractorOutput, TsPayloadReader.TrackIdGenerator idGenerator) { + // Tracks are created per substream as they appear. + output = extractorOutput; + } + + @Override + public void packetStarted(long pesTimeUs, @TsPayloadReader.Flags int flags) { + this.pesTimeUs = pesTimeUs; + this.pesFlags = flags; + } + + @Override + public void consume(ParsableByteArray data) throws ParserException { + current = null; + if (data.bytesLeft() < 4) { + return; + } + byte[] bytes = data.getData(); + int position = data.getPosition(); + if (mode == MODE_UNKNOWN) { + boolean ac3Sync = (bytes[position] & 0xFF) == 0x0B && (bytes[position + 1] & 0xFF) == 0x77; + mode = ac3Sync ? MODE_RAW : MODE_SUBSTREAMS; + } + int key; + if (mode == MODE_RAW) { + key = RAW_KEY; + } else { + key = bytes[position] & 0xFF; + if (key < 0x80 || key > 0x87) { + return; + } + } + @Nullable Ac3Reader reader = readerFor(key); + if (reader == null) { + return; + } + if (mode == MODE_SUBSTREAMS) { + data.skipBytes(4); + } + current = reader; + reader.packetStarted(pesTimeUs, pesFlags); + reader.consume(data); + } + + @Override + public void packetFinished() { + if (current != null) { + current.packetFinished(); + } + current = null; + } + + @Override + public void endOfInputReached() { + for (int i = 0; i < readers.size(); i++) { + readers.valueAt(i).endOfInputReached(); + } + } + + @Nullable + private Ac3Reader readerFor(int key) { + @Nullable Ac3Reader reader = readers.get(key); + if (reader != null || tracksEnded || output == null) { + return reader; + } + reader = new Ac3Reader(MimeTypes.VIDEO_PS); + // Track ids above the PS stream id range (0..0xFF) used by DvdPsExtractor for the other streams. + int trackId = key == RAW_KEY ? DvdPsExtractor.PRIVATE_STREAM_1 : 0x100 + key; + reader.createTracks(output, new TsPayloadReader.TrackIdGenerator(trackId, 0x200)); + readers.put(key, reader); + return reader; + } +} diff --git a/playback/engine-exo/src/main/java/ru/stenfredd/movies/playback/exo/ps/DvdPsExtractor.java b/playback/engine-exo/src/main/java/ru/stenfredd/movies/playback/exo/ps/DvdPsExtractor.java new file mode 100644 index 0000000..81bd04f --- /dev/null +++ b/playback/engine-exo/src/main/java/ru/stenfredd/movies/playback/exo/ps/DvdPsExtractor.java @@ -0,0 +1,447 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * Modified for the Movies app (2026-10-13), copied from media3 1.11.1 androidx.media3.extractor.ts: + * see DvdPsExtractor for what changed. + */ +package ru.stenfredd.movies.playback.exo.ps; + +import static com.google.common.base.Preconditions.checkNotNull; + +import android.util.SparseArray; +import androidx.annotation.Nullable; +import androidx.media3.common.C; +import androidx.media3.common.MimeTypes; +import androidx.media3.common.ParserException; +import androidx.media3.common.util.ParsableBitArray; +import androidx.media3.common.util.ParsableByteArray; +import androidx.media3.common.util.TimestampAdjuster; +import androidx.media3.common.util.UnstableApi; +import androidx.media3.extractor.Extractor; +import androidx.media3.extractor.ExtractorInput; +import androidx.media3.extractor.ExtractorOutput; +import androidx.media3.extractor.ExtractorsFactory; +import androidx.media3.extractor.PositionHolder; +import androidx.media3.extractor.SeekMap; +import androidx.media3.extractor.ts.ElementaryStreamReader; +import androidx.media3.extractor.ts.H262Reader; +import androidx.media3.extractor.ts.MpegAudioReader; +import androidx.media3.extractor.ts.TsPayloadReader; +import androidx.media3.extractor.ts.TsPayloadReader.TrackIdGenerator; +import java.io.IOException; + +/** + * Extracts data from the MPEG-2 PS container format. + * + *

media3's {@code PsExtractor} with two fixes for DVD rips (VOB/MPG): + * + *

    + *
  • A PES packet without a PTS gets {@link C#TIME_UNSET} instead of 0. With 0, most pictures of + * every GOP came out at time 0 and were skipped (~1.5 fps on screen); see also + * Mpeg2TimestampFixExtractor for the decode-order extrapolation in H262Reader. + *
  • DVD private stream 1 starts every payload with a substream header (id, frame count, first + * access unit pointer). The original fed it, and the subpicture/DTS/LPCM substreams that share + * the stream id, straight into one AC3 reader: the 4 header bytes ended up inside AC3 frames + * that span two packets, giving "quacking" audio, and several audio tracks were mixed. Here + * {@link DvdPrivateStreamReader} strips the header and gives every AC3 substream its own track. + *
+ */ +@UnstableApi +public final class DvdPsExtractor implements Extractor { + + /** Factory for {@link DvdPsExtractor} instances. */ + public static final ExtractorsFactory FACTORY = () -> new Extractor[] {new DvdPsExtractor()}; + + /* package */ static final int PACK_START_CODE = 0x000001BA; + /* package */ static final int SYSTEM_HEADER_START_CODE = 0x000001BB; + /* package */ static final int PACKET_START_CODE_PREFIX = 0x000001; + /* package */ static final int MPEG_PROGRAM_END_CODE = 0x000001B9; + private static final int MAX_STREAM_ID_PLUS_ONE = 0x100; + + // Max search length for first audio and video track in input data. + private static final long MAX_SEARCH_LENGTH = 1024 * 1024; + // Max search length for additional audio and video tracks in input data after at least one audio + // and video track has been found. + private static final long MAX_SEARCH_LENGTH_AFTER_AUDIO_AND_VIDEO_FOUND = 8 * 1024; + + public static final int PRIVATE_STREAM_1 = 0xBD; + public static final int AUDIO_STREAM = 0xC0; + public static final int AUDIO_STREAM_MASK = 0xE0; + public static final int VIDEO_STREAM = 0xE0; + public static final int VIDEO_STREAM_MASK = 0xF0; + + private final TimestampAdjuster timestampAdjuster; + private final SparseArray psPayloadReaders; // Indexed by pid + private final ParsableByteArray psPacketBuffer; + private final PsDurationReader durationReader; + + private boolean foundAllTracks; + @Nullable private DvdPrivateStreamReader privateStreamReader; + private boolean foundAudioTrack; + private boolean foundVideoTrack; + private long lastTrackPosition; + + // Accessed only by the loading thread. + @Nullable private PsBinarySearchSeeker psBinarySearchSeeker; + private ExtractorOutput output; + private boolean hasOutputSeekMap; + + public DvdPsExtractor() { + this(new TimestampAdjuster(0)); + } + + public DvdPsExtractor(TimestampAdjuster timestampAdjuster) { + this.timestampAdjuster = timestampAdjuster; + psPacketBuffer = new ParsableByteArray(4096); + psPayloadReaders = new SparseArray<>(); + durationReader = new PsDurationReader(); + } + + // Extractor implementation. + + @Override + public boolean sniff(ExtractorInput input) throws IOException { + byte[] scratch = new byte[14]; + input.peekFully(scratch, 0, 14); + + // Verify the PACK_START_CODE for the first 4 bytes + if (PACK_START_CODE + != (((scratch[0] & 0xFF) << 24) + | ((scratch[1] & 0xFF) << 16) + | ((scratch[2] & 0xFF) << 8) + | (scratch[3] & 0xFF))) { + return false; + } + // Verify the 01xxx1xx marker on the 5th byte + if ((scratch[4] & 0xC4) != 0x44) { + return false; + } + // Verify the xxxxx1xx marker on the 7th byte + if ((scratch[6] & 0x04) != 0x04) { + return false; + } + // Verify the xxxxx1xx marker on the 9th byte + if ((scratch[8] & 0x04) != 0x04) { + return false; + } + // Verify the xxxxxxx1 marker on the 10th byte + if ((scratch[9] & 0x01) != 0x01) { + return false; + } + // Verify the xxxxxx11 marker on the 13th byte + if ((scratch[12] & 0x03) != 0x03) { + return false; + } + // Read the stuffing length from the 14th byte (last 3 bits) + int packStuffingLength = scratch[13] & 0x07; + input.advancePeekPosition(packStuffingLength); + // Now check that the next 3 bytes are the beginning of an MPEG start code + input.peekFully(scratch, 0, 3); + return (PACKET_START_CODE_PREFIX + == (((scratch[0] & 0xFF) << 16) | ((scratch[1] & 0xFF) << 8) | (scratch[2] & 0xFF))); + } + + @Override + public void init(ExtractorOutput output) { + this.output = output; + } + + @Override + public void seek(long position, long timeUs) { + // If the timestamp adjuster has not yet established a timestamp offset, we need to reset its + // expected first sample timestamp to be the new seek position. Without this, the timestamp + // adjuster would incorrectly establish its timestamp offset assuming that the first sample + // after this seek corresponds to the start of the stream (or a previous seek position, if there + // was one). + boolean resetTimestampAdjuster = timestampAdjuster.getTimestampOffsetUs() == C.TIME_UNSET; + if (!resetTimestampAdjuster) { + long adjusterFirstSampleTimestampUs = timestampAdjuster.getFirstSampleTimestampUs(); + // Also reset the timestamp adjuster if its offset was calculated based on a non-zero position + // in the stream (other than the position being seeked to), since in this case the offset may + // not be accurate. + resetTimestampAdjuster = + adjusterFirstSampleTimestampUs != C.TIME_UNSET + && adjusterFirstSampleTimestampUs != 0 + && adjusterFirstSampleTimestampUs != timeUs; + } + if (resetTimestampAdjuster) { + timestampAdjuster.reset(timeUs); + } + + if (psBinarySearchSeeker != null) { + psBinarySearchSeeker.setSeekTargetUs(timeUs); + } + for (int i = 0; i < psPayloadReaders.size(); i++) { + psPayloadReaders.valueAt(i).seek(); + } + } + + @Override + public void release() { + // Do nothing + } + + @Override + public int read(ExtractorInput input, PositionHolder seekPosition) throws IOException { + // Asserts init has been called. + checkNotNull(output); + + long inputLength = input.getLength(); + boolean canReadDuration = inputLength != C.LENGTH_UNSET; + if (canReadDuration && !durationReader.isDurationReadFinished()) { + return durationReader.readDuration(input, seekPosition); + } + maybeOutputSeekMap(inputLength); + if (psBinarySearchSeeker != null && psBinarySearchSeeker.isSeeking()) { + return psBinarySearchSeeker.handlePendingSeek(input, seekPosition); + } + + input.resetPeekPosition(); + long peekBytesLeft = + inputLength != C.LENGTH_UNSET ? inputLength - input.getPeekPosition() : C.LENGTH_UNSET; + if (peekBytesLeft != C.LENGTH_UNSET && peekBytesLeft < 4) { + onEndOfInput(); + return RESULT_END_OF_INPUT; + } + // First peek and check what type of start code is next. + if (!input.peekFully(psPacketBuffer.getData(), 0, 4, true)) { + onEndOfInput(); + return RESULT_END_OF_INPUT; + } + + psPacketBuffer.setPosition(0); + int nextStartCode = psPacketBuffer.readInt(); + if (nextStartCode == MPEG_PROGRAM_END_CODE) { + onEndOfInput(); + return RESULT_END_OF_INPUT; + } else if (nextStartCode == PACK_START_CODE) { + // Now peek the rest of the pack_header. + input.peekFully(psPacketBuffer.getData(), 0, 10); + + // We only care about the pack_stuffing_length in here, skip the first 77 bits. + psPacketBuffer.setPosition(9); + + // Last 3 bits is the length. + int packStuffingLength = psPacketBuffer.readUnsignedByte() & 0x07; + + // Now skip the stuffing and the pack header. + input.skipFully(packStuffingLength + 14); + return RESULT_CONTINUE; + } else if (nextStartCode == SYSTEM_HEADER_START_CODE) { + // We just skip all this, but we need to get the length first. + input.peekFully(psPacketBuffer.getData(), 0, 2); + + // Length is the next 2 bytes. + psPacketBuffer.setPosition(0); + int systemHeaderLength = psPacketBuffer.readUnsignedShort(); + input.skipFully(systemHeaderLength + 6); + return RESULT_CONTINUE; + } else if (((nextStartCode & 0xFFFFFF00) >> 8) != PACKET_START_CODE_PREFIX) { + input.skipFully(1); // Skip bytes until we see a valid start code again. + return RESULT_CONTINUE; + } + + // We're at the start of a regular PES packet now. + // Get the stream ID off the last byte of the start code. + int streamId = nextStartCode & 0xFF; + + // Check to see if we have this one in our map yet, and if not, then add it. + PesReader payloadReader = psPayloadReaders.get(streamId); + if (!foundAllTracks) { + if (payloadReader == null) { + @Nullable ElementaryStreamReader elementaryStreamReader = null; + if (streamId == PRIVATE_STREAM_1) { + // Private stream: AC3 audio, on DVDs split into substreams. + privateStreamReader = new DvdPrivateStreamReader(); + elementaryStreamReader = privateStreamReader; + foundAudioTrack = true; + lastTrackPosition = input.getPosition(); + } else if ((streamId & AUDIO_STREAM_MASK) == AUDIO_STREAM) { + elementaryStreamReader = new MpegAudioReader(MimeTypes.VIDEO_PS); + foundAudioTrack = true; + lastTrackPosition = input.getPosition(); + } else if ((streamId & VIDEO_STREAM_MASK) == VIDEO_STREAM) { + elementaryStreamReader = new H262Reader(MimeTypes.VIDEO_PS); + foundVideoTrack = true; + lastTrackPosition = input.getPosition(); + } + if (elementaryStreamReader != null) { + TrackIdGenerator idGenerator = new TrackIdGenerator(streamId, MAX_STREAM_ID_PLUS_ONE); + elementaryStreamReader.createTracks(output, idGenerator); + payloadReader = new PesReader(elementaryStreamReader, timestampAdjuster); + psPayloadReaders.put(streamId, payloadReader); + } + } + // DVD audio substreams are interleaved over the first seconds: keep looking for them over the + // whole initial search window instead of stopping 8 KB after the first audio packet. + long maxSearchPosition = + foundAudioTrack && foundVideoTrack && privateStreamReader == null + ? lastTrackPosition + MAX_SEARCH_LENGTH_AFTER_AUDIO_AND_VIDEO_FOUND + : MAX_SEARCH_LENGTH; + if (input.getPosition() > maxSearchPosition) { + foundAllTracks = true; + if (privateStreamReader != null) { + privateStreamReader.onTracksEnded(); + } + output.endTracks(); + } + } + + // The next 2 bytes are the length. Once we have that we can consume the complete packet. + input.peekFully(psPacketBuffer.getData(), 0, 2); + psPacketBuffer.setPosition(0); + int payloadLength = psPacketBuffer.readUnsignedShort(); + int pesLength = payloadLength + 6; + + if (payloadReader == null) { + // Just skip this data. + input.skipFully(pesLength); + } else { + psPacketBuffer.reset(pesLength); + // Read the whole packet and the header for consumption. + input.readFully(psPacketBuffer.getData(), 0, pesLength); + psPacketBuffer.setPosition(6); + payloadReader.consume(psPacketBuffer); + psPacketBuffer.setLimit(psPacketBuffer.capacity()); + } + + return RESULT_CONTINUE; + } + + // Internals. + + private void onEndOfInput() { + for (int i = 0; i < psPayloadReaders.size(); i++) { + psPayloadReaders.valueAt(i).consumeEndOfInput(); + } + } + + private void maybeOutputSeekMap(long inputLength) { + if (!hasOutputSeekMap) { + hasOutputSeekMap = true; + if (durationReader.getDurationUs() != C.TIME_UNSET) { + psBinarySearchSeeker = + new PsBinarySearchSeeker( + durationReader.getScrTimestampAdjuster(), + durationReader.getDurationUs(), + inputLength); + output.seekMap(psBinarySearchSeeker.getSeekMap()); + } else { + output.seekMap(new SeekMap.Unseekable(durationReader.getDurationUs())); + } + } + } + + /** Parses PES packet data and extracts samples. */ + private static final class PesReader { + + private static final int PES_SCRATCH_SIZE = 64; + + private final ElementaryStreamReader pesPayloadReader; + private final TimestampAdjuster timestampAdjuster; + private final ParsableBitArray pesScratch; + + private boolean ptsFlag; + private boolean dtsFlag; + private boolean seenFirstDts; + private int extendedHeaderLength; + private long timeUs; + + public PesReader(ElementaryStreamReader pesPayloadReader, TimestampAdjuster timestampAdjuster) { + this.pesPayloadReader = pesPayloadReader; + this.timestampAdjuster = timestampAdjuster; + pesScratch = new ParsableBitArray(new byte[PES_SCRATCH_SIZE]); + } + + /** + * Notifies the reader that a seek has occurred. + * + *

Following a call to this method, the data passed to the next invocation of {@link + * #consume(ParsableByteArray)} will not be a continuation of the data that was previously + * passed. Hence the reader should reset any internal state. + */ + public void seek() { + seenFirstDts = false; + pesPayloadReader.seek(); + } + + /** + * Consumes the payload of a PS packet. + * + * @param data The PES packet. The position will be set to the start of the payload. + * @throws ParserException If the payload could not be parsed. + */ + public void consume(ParsableByteArray data) throws ParserException { + data.readBytes(pesScratch.data, 0, 3); + pesScratch.setPosition(0); + parseHeader(); + data.readBytes(pesScratch.data, 0, extendedHeaderLength); + pesScratch.setPosition(0); + parseHeaderExtension(); + pesPayloadReader.packetStarted(timeUs, TsPayloadReader.FLAG_DATA_ALIGNMENT_INDICATOR); + pesPayloadReader.consume(data); + // We always have complete PES packets with program stream. + pesPayloadReader.packetFinished(); + } + + private void consumeEndOfInput() { + pesPayloadReader.endOfInputReached(); + } + + private void parseHeader() { + // Note: see ISO/IEC 13818-1, section 2.4.3.6 for detailed information on the format of + // the header. + // First 8 bits are skipped: '10' (2), PES_scrambling_control (2), PES_priority (1), + // data_alignment_indicator (1), copyright (1), original_or_copy (1) + pesScratch.skipBits(8); + ptsFlag = pesScratch.readBit(); + dtsFlag = pesScratch.readBit(); + // ESCR_flag (1), ES_rate_flag (1), DSM_trick_mode_flag (1), + // additional_copy_info_flag (1), PES_CRC_flag (1), PES_extension_flag (1) + pesScratch.skipBits(6); + extendedHeaderLength = pesScratch.readBits(8); + } + + private void parseHeaderExtension() { + timeUs = C.TIME_UNSET; + if (ptsFlag) { + pesScratch.skipBits(4); // '0010' or '0011' + long pts = (long) pesScratch.readBits(3) << 30; + pesScratch.skipBits(1); // marker_bit + pts |= pesScratch.readBits(15) << 15; + pesScratch.skipBits(1); // marker_bit + pts |= pesScratch.readBits(15); + pesScratch.skipBits(1); // marker_bit + if (!seenFirstDts && dtsFlag) { + pesScratch.skipBits(4); // '0011' + long dts = (long) pesScratch.readBits(3) << 30; + pesScratch.skipBits(1); // marker_bit + dts |= pesScratch.readBits(15) << 15; + pesScratch.skipBits(1); // marker_bit + dts |= pesScratch.readBits(15); + pesScratch.skipBits(1); // marker_bit + // Subsequent PES packets may have earlier presentation timestamps than this one, but they + // should all be greater than or equal to this packet's decode timestamp. We feed the + // decode timestamp to the adjuster here so that in the case that this is the first to be + // fed, the adjuster will be able to compute an offset to apply such that the adjusted + // presentation timestamps of all future packets are non-negative. + timestampAdjuster.adjustTsTimestamp(dts); + seenFirstDts = true; + } + timeUs = timestampAdjuster.adjustTsTimestamp(pts); + } + } + } +} diff --git a/playback/engine-exo/src/main/java/ru/stenfredd/movies/playback/exo/ps/PsBinarySearchSeeker.java b/playback/engine-exo/src/main/java/ru/stenfredd/movies/playback/exo/ps/PsBinarySearchSeeker.java new file mode 100644 index 0000000..cb26f78 --- /dev/null +++ b/playback/engine-exo/src/main/java/ru/stenfredd/movies/playback/exo/ps/PsBinarySearchSeeker.java @@ -0,0 +1,215 @@ +/* + * Copyright (C) 2018 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * Modified for the Movies app (2026-10-13), copied from media3 1.11.1 androidx.media3.extractor.ts: + * see DvdPsExtractor for what changed. + */ +package ru.stenfredd.movies.playback.exo.ps; + +import static java.lang.Math.min; + +import androidx.media3.common.C; +import androidx.media3.common.util.ParsableByteArray; +import androidx.media3.common.util.TimestampAdjuster; +import androidx.media3.common.util.Util; +import androidx.media3.extractor.BinarySearchSeeker; +import androidx.media3.extractor.ExtractorInput; +import androidx.media3.extractor.ts.TsExtractor; +import java.io.IOException; + +/** + * A seeker that supports seeking within PS stream using binary search. + * + *

This seeker uses the first and last SCR values within the stream, as well as the stream + * duration to interpolate the SCR value of the seeking position. Then it performs binary search + * within the stream to find a packets whose SCR value is with in {@link #SEEK_TOLERANCE_US} from + * the target SCR. + */ +/* package */ final class PsBinarySearchSeeker extends BinarySearchSeeker { + + private static final long SEEK_TOLERANCE_US = 100_000; + private static final int MINIMUM_SEARCH_RANGE_BYTES = 1000; + private static final int TIMESTAMP_SEARCH_BYTES = 20_000; + + public PsBinarySearchSeeker( + TimestampAdjuster scrTimestampAdjuster, long streamDurationUs, long inputLength) { + super( + new DefaultSeekTimestampConverter(), + new PsScrSeeker(scrTimestampAdjuster), + streamDurationUs, + /* floorTimePosition= */ 0, + /* ceilingTimePosition= */ streamDurationUs + 1, + /* floorBytePosition= */ 0, + /* ceilingBytePosition= */ inputLength, + /* approxBytesPerFrame= */ TsExtractor.TS_PACKET_SIZE, + MINIMUM_SEARCH_RANGE_BYTES); + } + + /** + * A seeker that looks for a given SCR timestamp at a given position in a PS stream. + * + *

Given a SCR timestamp, and a position within a PS stream, this seeker will peek up to {@link + * #TIMESTAMP_SEARCH_BYTES} bytes from that stream position, look for all packs in that range, and + * then compare the SCR timestamps (if available) of these packets to the target timestamp. + */ + private static final class PsScrSeeker implements TimestampSeeker { + + private final TimestampAdjuster scrTimestampAdjuster; + private final ParsableByteArray packetBuffer; + + private PsScrSeeker(TimestampAdjuster scrTimestampAdjuster) { + this.scrTimestampAdjuster = scrTimestampAdjuster; + packetBuffer = new ParsableByteArray(); + } + + @Override + public TimestampSearchResult searchForTimestamp(ExtractorInput input, long targetTimestamp) + throws IOException { + long inputPosition = input.getPosition(); + int bytesToSearch = (int) min(TIMESTAMP_SEARCH_BYTES, input.getLength() - inputPosition); + + packetBuffer.reset(bytesToSearch); + input.peekFully(packetBuffer.getData(), /* offset= */ 0, bytesToSearch); + + return searchForScrValueInBuffer(packetBuffer, targetTimestamp, inputPosition); + } + + @Override + public void onSeekFinished() { + packetBuffer.reset(Util.EMPTY_BYTE_ARRAY); + } + + private TimestampSearchResult searchForScrValueInBuffer( + ParsableByteArray packetBuffer, long targetScrTimeUs, long bufferStartOffset) { + int startOfLastPacketPosition = C.INDEX_UNSET; + int endOfLastPacketPosition = C.INDEX_UNSET; + long lastScrTimeUsInRange = C.TIME_UNSET; + + while (packetBuffer.bytesLeft() >= 4) { + int nextStartCode = peekIntAtPosition(packetBuffer.getData(), packetBuffer.getPosition()); + if (nextStartCode != DvdPsExtractor.PACK_START_CODE) { + packetBuffer.skipBytes(1); + continue; + } else { + packetBuffer.skipBytes(4); + } + + // We found a pack. + long scrValue = PsDurationReader.readScrValueFromPack(packetBuffer); + if (scrValue != C.TIME_UNSET) { + long scrTimeUs = scrTimestampAdjuster.adjustTsTimestamp(scrValue); + if (scrTimeUs > targetScrTimeUs) { + if (lastScrTimeUsInRange == C.TIME_UNSET) { + // First SCR timestamp is already over target. + return TimestampSearchResult.overestimatedResult(scrTimeUs, bufferStartOffset); + } else { + // Last SCR timestamp < target timestamp < this timestamp. + return TimestampSearchResult.targetFoundResult( + bufferStartOffset + startOfLastPacketPosition); + } + } else if (scrTimeUs + SEEK_TOLERANCE_US > targetScrTimeUs) { + long startOfPacketInStream = bufferStartOffset + packetBuffer.getPosition(); + return TimestampSearchResult.targetFoundResult(startOfPacketInStream); + } + + lastScrTimeUsInRange = scrTimeUs; + startOfLastPacketPosition = packetBuffer.getPosition(); + } + skipToEndOfCurrentPack(packetBuffer); + endOfLastPacketPosition = packetBuffer.getPosition(); + } + + if (lastScrTimeUsInRange != C.TIME_UNSET) { + long endOfLastPacketPositionInStream = bufferStartOffset + endOfLastPacketPosition; + return TimestampSearchResult.underestimatedResult( + lastScrTimeUsInRange, endOfLastPacketPositionInStream); + } else { + return TimestampSearchResult.NO_TIMESTAMP_IN_RANGE_RESULT; + } + } + + /** + * Skips the buffer position to the position after the end of the current PS pack in the buffer, + * given the byte position right after the {@link DvdPsExtractor#PACK_START_CODE} of the pack in + * the buffer. If the pack ends after the end of the buffer, skips to the end of the buffer. + */ + private static void skipToEndOfCurrentPack(ParsableByteArray packetBuffer) { + int limit = packetBuffer.limit(); + + if (packetBuffer.bytesLeft() < 10) { + // We require at least 9 bytes for pack header to read SCR value + 1 byte for pack_stuffing + // length. + packetBuffer.setPosition(limit); + return; + } + packetBuffer.skipBytes(9); + + int packStuffingLength = packetBuffer.readUnsignedByte() & 0x07; + if (packetBuffer.bytesLeft() < packStuffingLength) { + packetBuffer.setPosition(limit); + return; + } + packetBuffer.skipBytes(packStuffingLength); + + if (packetBuffer.bytesLeft() < 4) { + packetBuffer.setPosition(limit); + return; + } + + int nextStartCode = peekIntAtPosition(packetBuffer.getData(), packetBuffer.getPosition()); + if (nextStartCode == DvdPsExtractor.SYSTEM_HEADER_START_CODE) { + packetBuffer.skipBytes(4); + int systemHeaderLength = packetBuffer.readUnsignedShort(); + if (packetBuffer.bytesLeft() < systemHeaderLength) { + packetBuffer.setPosition(limit); + return; + } + packetBuffer.skipBytes(systemHeaderLength); + } + + // Find the position of the next PACK_START_CODE or MPEG_PROGRAM_END_CODE, which is right + // after the end position of this pack. + // If we couldn't find these codes within the buffer, return the buffer limit, or return + // the first position which PES packets pattern does not match (some malformed packets). + while (packetBuffer.bytesLeft() >= 4) { + nextStartCode = peekIntAtPosition(packetBuffer.getData(), packetBuffer.getPosition()); + if (nextStartCode == DvdPsExtractor.PACK_START_CODE + || nextStartCode == DvdPsExtractor.MPEG_PROGRAM_END_CODE) { + break; + } + if (nextStartCode >>> 8 != DvdPsExtractor.PACKET_START_CODE_PREFIX) { + break; + } + packetBuffer.skipBytes(4); + + if (packetBuffer.bytesLeft() < 2) { + // 2 bytes for PES_packet length. + packetBuffer.setPosition(limit); + return; + } + int pesPacketLength = packetBuffer.readUnsignedShort(); + packetBuffer.setPosition( + min(packetBuffer.limit(), packetBuffer.getPosition() + pesPacketLength)); + } + } + } + + private static int peekIntAtPosition(byte[] data, int position) { + return (data[position] & 0xFF) << 24 + | (data[position + 1] & 0xFF) << 16 + | (data[position + 2] & 0xFF) << 8 + | (data[position + 3] & 0xFF); + } +} diff --git a/playback/engine-exo/src/main/java/ru/stenfredd/movies/playback/exo/ps/PsDurationReader.java b/playback/engine-exo/src/main/java/ru/stenfredd/movies/playback/exo/ps/PsDurationReader.java new file mode 100644 index 0000000..e514999 --- /dev/null +++ b/playback/engine-exo/src/main/java/ru/stenfredd/movies/playback/exo/ps/PsDurationReader.java @@ -0,0 +1,265 @@ +/* + * Copyright (C) 2018 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * Modified for the Movies app (2026-10-13), copied from media3 1.11.1 androidx.media3.extractor.ts: + * see DvdPsExtractor for what changed. + */ +package ru.stenfredd.movies.playback.exo.ps; + +import static java.lang.Math.min; + +import androidx.media3.common.C; +import androidx.media3.common.util.ParsableByteArray; +import androidx.media3.common.util.TimestampAdjuster; +import androidx.media3.common.util.Util; +import androidx.media3.extractor.Extractor; +import androidx.media3.extractor.ExtractorInput; +import androidx.media3.extractor.PositionHolder; +import java.io.IOException; + +/** + * A reader that can extract the approximate duration from a given MPEG program stream (PS). + * + *

This reader extracts the duration by reading system clock reference (SCR) values from the + * header of a pack at the start and at the end of the stream, calculating the difference, and + * converting that into stream duration. This reader also handles the case when a single SCR + * wraparound takes place within the stream, which can make SCR values at the beginning of the + * stream larger than SCR values at the end. This class can only be used once to read duration from + * a given stream, and the usage of the class is not thread-safe, so all calls should be made from + * the same thread. + * + *

Note: See ISO/IEC 13818-1, Table 2-33 for details of the SCR field in pack_header. + */ +/* package */ final class PsDurationReader { + + private static final String TAG = "PsDurationReader"; + + private static final int TIMESTAMP_SEARCH_BYTES = 20_000; + + private final TimestampAdjuster scrTimestampAdjuster; + private final ParsableByteArray packetBuffer; + + private boolean isDurationRead; + private boolean isFirstScrValueRead; + private boolean isLastScrValueRead; + + private long firstScrValue; + private long lastScrValue; + private long durationUs; + + /* package */ PsDurationReader() { + scrTimestampAdjuster = new TimestampAdjuster(/* firstSampleTimestampUs= */ 0); + firstScrValue = C.TIME_UNSET; + lastScrValue = C.TIME_UNSET; + durationUs = C.TIME_UNSET; + packetBuffer = new ParsableByteArray(); + } + + /** Returns true if a PS duration has been read. */ + public boolean isDurationReadFinished() { + return isDurationRead; + } + + public TimestampAdjuster getScrTimestampAdjuster() { + return scrTimestampAdjuster; + } + + /** + * Reads a PS duration from the input. + * + *

This reader reads the duration by reading SCR values from the header of a pack at the start + * and at the end of the stream, calculating the difference, and converting that into stream + * duration. + * + * @param input The {@link ExtractorInput} from which data should be read. + * @param seekPositionHolder If {@link Extractor#RESULT_SEEK} is returned, this holder is updated + * to hold the position of the required seek. + * @return One of the {@code RESULT_} values defined in {@link Extractor}. + * @throws IOException If an error occurred reading from the input. + */ + public @Extractor.ReadResult int readDuration( + ExtractorInput input, PositionHolder seekPositionHolder) throws IOException { + if (!isLastScrValueRead) { + return readLastScrValue(input, seekPositionHolder); + } + if (lastScrValue == C.TIME_UNSET) { + return finishReadDuration(input); + } + if (!isFirstScrValueRead) { + return readFirstScrValue(input, seekPositionHolder); + } + if (firstScrValue == C.TIME_UNSET) { + return finishReadDuration(input); + } + + long minScrPositionUs = scrTimestampAdjuster.adjustTsTimestamp(firstScrValue); + long maxScrPositionUs = + scrTimestampAdjuster.adjustTsTimestampGreaterThanPreviousTimestamp(lastScrValue); + durationUs = maxScrPositionUs - minScrPositionUs; + return finishReadDuration(input); + } + + /** Returns the duration last read from {@link #readDuration(ExtractorInput, PositionHolder)}. */ + public long getDurationUs() { + return durationUs; + } + + /** + * Returns the SCR value read from the next pack in the stream, given the buffer at the pack + * header start position (just behind the pack start code). + */ + public static long readScrValueFromPack(ParsableByteArray packetBuffer) { + int originalPosition = packetBuffer.getPosition(); + if (packetBuffer.bytesLeft() < 9) { + // We require at 9 bytes for pack header to read scr value + return C.TIME_UNSET; + } + byte[] scrBytes = new byte[9]; + packetBuffer.readBytes(scrBytes, /* offset= */ 0, scrBytes.length); + packetBuffer.setPosition(originalPosition); + if (!checkMarkerBits(scrBytes)) { + return C.TIME_UNSET; + } + return readScrValueFromPackHeader(scrBytes); + } + + private int finishReadDuration(ExtractorInput input) { + packetBuffer.reset(Util.EMPTY_BYTE_ARRAY); + isDurationRead = true; + input.resetPeekPosition(); + return Extractor.RESULT_CONTINUE; + } + + private int readFirstScrValue(ExtractorInput input, PositionHolder seekPositionHolder) + throws IOException { + int bytesToSearch = (int) min(TIMESTAMP_SEARCH_BYTES, input.getLength()); + int searchStartPosition = 0; + if (input.getPosition() != searchStartPosition) { + seekPositionHolder.position = searchStartPosition; + return Extractor.RESULT_SEEK; + } + + packetBuffer.reset(bytesToSearch); + input.resetPeekPosition(); + input.peekFully(packetBuffer.getData(), /* offset= */ 0, bytesToSearch); + + firstScrValue = readFirstScrValueFromBuffer(packetBuffer); + isFirstScrValueRead = true; + return Extractor.RESULT_CONTINUE; + } + + private long readFirstScrValueFromBuffer(ParsableByteArray packetBuffer) { + int searchStartPosition = packetBuffer.getPosition(); + int searchEndPosition = packetBuffer.limit(); + for (int searchPosition = searchStartPosition; + searchPosition < searchEndPosition - 3; + searchPosition++) { + int nextStartCode = peekIntAtPosition(packetBuffer.getData(), searchPosition); + if (nextStartCode == DvdPsExtractor.PACK_START_CODE) { + packetBuffer.setPosition(searchPosition + 4); + long scrValue = readScrValueFromPack(packetBuffer); + if (scrValue != C.TIME_UNSET) { + return scrValue; + } + } + } + return C.TIME_UNSET; + } + + private int readLastScrValue(ExtractorInput input, PositionHolder seekPositionHolder) + throws IOException { + long inputLength = input.getLength(); + int bytesToSearch = (int) min(TIMESTAMP_SEARCH_BYTES, inputLength); + long searchStartPosition = inputLength - bytesToSearch; + if (input.getPosition() != searchStartPosition) { + seekPositionHolder.position = searchStartPosition; + return Extractor.RESULT_SEEK; + } + + packetBuffer.reset(bytesToSearch); + input.resetPeekPosition(); + input.peekFully(packetBuffer.getData(), /* offset= */ 0, bytesToSearch); + + lastScrValue = readLastScrValueFromBuffer(packetBuffer); + isLastScrValueRead = true; + return Extractor.RESULT_CONTINUE; + } + + private long readLastScrValueFromBuffer(ParsableByteArray packetBuffer) { + int searchStartPosition = packetBuffer.getPosition(); + int searchEndPosition = packetBuffer.limit(); + for (int searchPosition = searchEndPosition - 4; + searchPosition >= searchStartPosition; + searchPosition--) { + int nextStartCode = peekIntAtPosition(packetBuffer.getData(), searchPosition); + if (nextStartCode == DvdPsExtractor.PACK_START_CODE) { + packetBuffer.setPosition(searchPosition + 4); + long scrValue = readScrValueFromPack(packetBuffer); + if (scrValue != C.TIME_UNSET) { + return scrValue; + } + } + } + return C.TIME_UNSET; + } + + private int peekIntAtPosition(byte[] data, int position) { + return (data[position] & 0xFF) << 24 + | (data[position + 1] & 0xFF) << 16 + | (data[position + 2] & 0xFF) << 8 + | (data[position + 3] & 0xFF); + } + + private static boolean checkMarkerBits(byte[] scrBytes) { + // Verify the 01xxx1xx marker on the 0th byte + if ((scrBytes[0] & 0xC4) != 0x44) { + return false; + } + // 1st byte belongs to scr field. + // Verify the xxxxx1xx marker on the 2nd byte + if ((scrBytes[2] & 0x04) != 0x04) { + return false; + } + // 3rd byte belongs to scr field. + // Verify the xxxxx1xx marker on the 4rd byte + if ((scrBytes[4] & 0x04) != 0x04) { + return false; + } + // Verify the xxxxxxx1 marker on the 5th byte + if ((scrBytes[5] & 0x01) != 0x01) { + return false; + } + // 6th and 7th bytes belongs to program_max_rate field. + // Verify the xxxxxx11 marker on the 8th byte + return (scrBytes[8] & 0x03) == 0x03; + } + + /** + * Returns the value of SCR base - 33 bits in big endian order from the PS pack header, ignoring + * the marker bits. Note: See ISO/IEC 13818-1, Table 2-33 for details of the SCR field in + * pack_header. + * + *

We ignore SCR Ext, because it's too small to have any significance. + */ + private static long readScrValueFromPackHeader(byte[] scrBytes) { + return ((scrBytes[0] & 0b00111000L) >> 3) << 30 + | (scrBytes[0] & 0b00000011L) << 28 + | (scrBytes[1] & 0xFFL) << 20 + | ((scrBytes[2] & 0b11111000L) >> 3) << 15 + | (scrBytes[2] & 0b00000011L) << 13 + | (scrBytes[3] & 0xFFL) << 5 + | (scrBytes[4] & 0b11111000L) >> 3; + } +} diff --git a/playback/engine-exo/src/main/kotlin/ru/stenfredd/movies/playback/exo/AviAudioFramingExtractor.kt b/playback/engine-exo/src/main/kotlin/ru/stenfredd/movies/playback/exo/AviAudioFramingExtractor.kt new file mode 100644 index 0000000..020f1ca --- /dev/null +++ b/playback/engine-exo/src/main/kotlin/ru/stenfredd/movies/playback/exo/AviAudioFramingExtractor.kt @@ -0,0 +1,163 @@ +package ru.stenfredd.movies.playback.exo + +import androidx.media3.common.C +import androidx.media3.common.DataReader +import androidx.media3.common.Format +import androidx.media3.common.MimeTypes +import androidx.media3.common.util.ParsableByteArray +import androidx.media3.common.util.UnstableApi +import androidx.media3.extractor.Ac3Util +import androidx.media3.extractor.DtsUtil +import androidx.media3.extractor.Extractor +import androidx.media3.extractor.ExtractorInput +import androidx.media3.extractor.ExtractorOutput +import androidx.media3.extractor.PositionHolder +import androidx.media3.extractor.SeekMap +import androidx.media3.extractor.TrackOutput +import timber.log.Timber +import java.io.EOFException +import java.nio.ByteBuffer + +/** + * Re-frames AC3/E-AC3/DTS audio from [androidx.media3.extractor.avi.AviExtractor]. + * + * AviExtractor hands every AVI chunk to the decoder as one sample, but muxers cut compressed audio into chunks of + * their own size (1280 bytes there) that don't match the codec frames (1024 bytes for that AC3). The audio sink + * then takes each sample for one frame (32 ms) while the chunk times advance by 40 ms: endless + * `UnexpectedDiscontinuityException`, stutter and rebuffering, and through passthrough frames split across samples. + * + * AviExtractor's chunk times come from the chunk index (duration × index / chunk count), which drifts when chunk + * sizes vary (a large preload chunk at the start: 0.5 s jump). The audio is CBR, so only the first chunk after + * start or seek is used as the anchor, and each frame gets `anchor + bytes since it × frame duration / frame size`. + * Other tracks pass straight through. + */ +@UnstableApi +internal class AviAudioFramingExtractor(private val delegate: Extractor) : Extractor { + private val framers = mutableListOf() + + override fun sniff(input: ExtractorInput) = delegate.sniff(input) + override fun read(input: ExtractorInput, seekPosition: PositionHolder) = delegate.read(input, seekPosition) + override fun getUnderlyingImplementation() = delegate + override fun release() = delegate.release() + + override fun init(output: ExtractorOutput) = delegate.init(object : ExtractorOutput { + override fun track(id: Int, type: Int): TrackOutput { + val track = output.track(id, type) + return if (type == C.TRACK_TYPE_AUDIO) FramingTrackOutput(track).also(framers::add) else track + } + override fun endTracks() = output.endTracks() + override fun seekMap(seekMap: SeekMap) = output.seekMap(seekMap) + }) + + override fun seek(position: Long, timeUs: Long) { + framers.forEach(FramingTrackOutput::reset) + delegate.seek(position, timeUs) + } + + private class FramingTrackOutput(private val out: TrackOutput) : TrackOutput { + private enum class Codec { AC3, DTS } + + private var codec: Codec? = null + private var sampleRate = 0 + /** Bytes of the current chunk followed by the unframed rest of the previous ones. */ + private var pending = ByteArray(64 * 1024) + private var pendingLength = 0 + /** Stream byte position of pending[0]. */ + private var pendingStart = 0L + /** Time of the first chunk after start or seek, which begins at stream byte 0. */ + private var anchorTimeUs = C.TIME_UNSET + private var resyncs = 0L + + fun reset() { + pendingLength = 0 + pendingStart = 0 + anchorTimeUs = C.TIME_UNSET + } + + override fun format(format: Format) { + codec = when (format.sampleMimeType) { + MimeTypes.AUDIO_AC3, MimeTypes.AUDIO_E_AC3 -> Codec.AC3 + MimeTypes.AUDIO_DTS -> Codec.DTS + else -> null + } + sampleRate = format.sampleRate + if (sampleRate <= 0) codec = null + out.format(format) + } + + override fun durationUs(durationUs: Long) = out.durationUs(durationUs) + + override fun sampleData(input: DataReader, length: Int, allowEndOfInput: Boolean, sampleDataPart: Int): Int { + if (codec == null) return out.sampleData(input, length, allowEndOfInput, sampleDataPart) + ensureCapacity(length) + val read = input.read(pending, pendingLength, length) + if (read == C.RESULT_END_OF_INPUT) { + if (allowEndOfInput) return read + throw EOFException() + } + pendingLength += read + return read + } + + override fun sampleData(data: ParsableByteArray, length: Int, sampleDataPart: Int) { + if (codec == null) return out.sampleData(data, length, sampleDataPart) + ensureCapacity(length) + data.readBytes(pending, pendingLength, length) + pendingLength += length + } + + override fun sampleMetadata(timeUs: Long, flags: Int, size: Int, offset: Int, cryptoData: TrackOutput.CryptoData?) { + val codec = codec ?: return out.sampleMetadata(timeUs, flags, size, offset, cryptoData) + if (anchorTimeUs == C.TIME_UNSET) anchorTimeUs = timeUs + var i = 0 + while (pendingLength - i >= HEADER_BYTES) { + val frameSize = frameSize(codec, i) + if (frameSize <= 0) { + if (resyncs++ % 1000 == 0L) Timber.w("AVI %s: no sync word, skipping a byte (%d)", codec, resyncs) + i++ + continue + } + if (pendingLength - i < frameSize) break + val frameTimeUs = anchorTimeUs + (pendingStart + i) * frameDurationUs(codec, i) / frameSize + out.sampleData(ParsableByteArray(pending.copyOfRange(i, i + frameSize)), frameSize) + out.sampleMetadata(frameTimeUs, C.BUFFER_FLAG_KEY_FRAME, frameSize, 0, null) + i += frameSize + } + System.arraycopy(pending, i, pending, 0, pendingLength - i) + pendingLength -= i + pendingStart += i + } + + private fun frameSize(codec: Codec, at: Int): Int = when (codec) { + Codec.AC3 -> if (pending[at] == 0x0B.toByte() && pending[at + 1] == 0x77.toByte()) { + Ac3Util.parseAc3SyncframeSize(pending.copyOfRange(at, at + HEADER_BYTES)) + } else { + C.LENGTH_UNSET + } + Codec.DTS -> if (ByteBuffer.wrap(pending, at, 4).int in DTS_SYNC_WORDS) { + DtsUtil.getDtsFrameSize(pending.copyOfRange(at, at + HEADER_BYTES)) + } else { + C.LENGTH_UNSET + } + } + + private fun frameDurationUs(codec: Codec, at: Int): Long { + val samples = when (codec) { + Codec.AC3 -> Ac3Util.parseAc3SyncframeAudioSampleCount(ByteBuffer.wrap(pending, at, HEADER_BYTES).slice()) + Codec.DTS -> DtsUtil.parseDtsAudioSampleCount(pending.copyOfRange(at, at + HEADER_BYTES)) + } + return samples * C.MICROS_PER_SECOND / sampleRate + } + + private fun ensureCapacity(extra: Int) { + if (pendingLength + extra > pending.size) pending = pending.copyOf(maxOf(pending.size * 2, pendingLength + extra)) + } + + private companion object { + /** Enough for the AC3/E-AC3 and DTS (incl. 14-bit) header fields the size/duration parsers read. */ + const val HEADER_BYTES = 16 + /** Core sync word: big-endian, little-endian, 14-bit big-endian, 14-bit little-endian. */ + val DTS_SYNC_WORDS = setOf(0x7FFE8001, 0xFE7F0180.toInt(), 0x1FFFE800, 0xFF1F00E8.toInt()) + } + } +} diff --git a/playback/engine-exo/src/main/kotlin/ru/stenfredd/movies/playback/exo/ExoEngine.kt b/playback/engine-exo/src/main/kotlin/ru/stenfredd/movies/playback/exo/ExoEngine.kt index 5344e04..d137870 100644 --- a/playback/engine-exo/src/main/kotlin/ru/stenfredd/movies/playback/exo/ExoEngine.kt +++ b/playback/engine-exo/src/main/kotlin/ru/stenfredd/movies/playback/exo/ExoEngine.kt @@ -3,6 +3,11 @@ package ru.stenfredd.movies.playback.exo import android.content.Context +import android.content.res.Configuration +import android.os.Build +import android.os.Handler +import android.os.Looper +import android.os.SystemClock import android.view.Gravity import android.view.SurfaceView import android.view.TextureView @@ -22,26 +27,38 @@ import androidx.media3.common.VideoSize import androidx.media3.common.text.CueGroup import androidx.media3.common.util.UnstableApi import androidx.media3.exoplayer.DecoderReuseEvaluation +import androidx.media3.exoplayer.DefaultLoadControl import androidx.media3.exoplayer.DefaultRenderersFactory import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.analytics.AnalyticsListener import androidx.media3.exoplayer.mediacodec.MediaCodecInfo +import androidx.media3.exoplayer.source.DefaultMediaSourceFactory import androidx.media3.exoplayer.mediacodec.MediaCodecUtil import androidx.media3.exoplayer.audio.AudioSink import androidx.media3.exoplayer.trackselection.DefaultTrackSelector import androidx.media3.ui.AspectRatioFrameLayout import androidx.media3.ui.SubtitleView +import io.github.peerless2012.ass.media.AssHandler +import io.github.peerless2012.ass.media.AssHandlerConfig +import io.github.peerless2012.ass.media.extractor.AssMatroskaExtractor +import io.github.peerless2012.ass.media.factory.AssRenderersFactory +import io.github.peerless2012.ass.media.parser.AssSubtitleParserFactory +import io.github.peerless2012.ass.media.type.AssRenderType +import io.github.peerless2012.ass.media.widget.AssSubtitleView import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update +import kotlin.math.abs +import kotlin.math.roundToInt import org.jellyfin.sdk.model.api.MediaStreamType import org.jellyfin.sdk.model.api.PlayMethod import org.jellyfin.sdk.model.api.VideoRange import org.jellyfin.sdk.model.api.VideoRangeType import ru.stenfredd.movies.playback.api.Codecs +import ru.stenfredd.movies.playback.api.DolbyVisionSwitchable import ru.stenfredd.movies.playback.api.EngineError import ru.stenfredd.movies.playback.api.EngineEvent import ru.stenfredd.movies.playback.api.EngineState @@ -53,9 +70,13 @@ import ru.stenfredd.movies.playback.api.embeddedOrdinal import ru.stenfredd.movies.playback.api.videoStream import timber.log.Timber -class ExoEngine(context: Context) : PlaybackEngine, VideoPathSwitchable { +class ExoEngine(context: Context) : PlaybackEngine, VideoPathSwitchable, DolbyVisionSwitchable { override val id = ExoEngineProvider.ID + /** See [gpuPathFor]: only TVs known to over-process SDR on the video plane. */ + private val sdrThroughGpu = Build.MANUFACTURER.equals("Xiaomi", ignoreCase = true) && + (context.resources.configuration.uiMode and Configuration.UI_MODE_TYPE_MASK) == Configuration.UI_MODE_TYPE_TELEVISION + private val trackSelector = DefaultTrackSelector(context).apply { parameters = buildUponParameters() .setTunnelingEnabled(false) @@ -63,20 +84,51 @@ class ExoEngine(context: Context) : PlaybackEngine, VideoPathSwitchable { .build() } + /** + * Full ASS/SSA rendering (styles, positioning, embedded fonts) through libass (ass-media), drawn by an overlay + * view above the video. Without it media3 turns ASS into plain text cues. + */ + private val assHandler = AssHandler(AssRenderType.OVERLAY_OPEN_GL, AssHandlerConfig(cacheSize = ASS_CACHE_MB, maxRenderPixels = 1920 * 1080)) + private val assParserFactory = AssSubtitleParserFactory(assHandler) + private val player: ExoPlayer = ExoPlayer.Builder( context, - DefaultRenderersFactory(context) - .setMediaCodecSelector(::decoderInfos) - .setEnableDecoderFallback(true) - .setExtensionRendererMode(DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON), + AssRenderersFactory( + assHandler, + DefaultRenderersFactory(context) + .setMediaCodecSelector(::decoderInfos) + .setEnableDecoderFallback(true) + .setExtensionRendererMode(DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON), + ), ) .setTrackSelector(trackSelector) + .setLoadControl( + DefaultLoadControl.Builder() + .setTargetBufferBytes(bufferBytes()) + .setPrioritizeTimeOverSizeThresholds(false) + .build(), + ) + .setMediaSourceFactory( + DefaultMediaSourceFactory(context, ExoExtractorsFactory { AssMatroskaExtractor(assParserFactory, assHandler) }) + // External ASS files are parsed by this factory directly. + .setSubtitleParserFactory(assParserFactory), + ) .setAudioAttributes( AudioAttributes.Builder().setUsage(C.USAGE_MEDIA).setContentType(C.AUDIO_CONTENT_TYPE_MOVIE).build(), true, ) .build() + /** + * The default target (~145 MB for video) is sized for phones with big heaps: this TV gives the app 192 MB + * (512 MB with largeHeap), and once the buffer filled up after a few engine switches GC thrashed, playback + * stuttered and the app died with OutOfMemoryError. A quarter of the heap, at most 64 MB: still ~8 s of a 60 Mbit/s 4K remux, plenty over the LAN. + */ + private fun bufferBytes(): Int { + val heapMb = (Runtime.getRuntime().maxMemory() shr 20).toInt() + return (heapMb / 4).coerceIn(MIN_BUFFER_MB, MAX_BUFFER_MB) shl 20 + } + private val _state = MutableStateFlow(EngineState()) override val state = _state.asStateFlow() private val _events = MutableSharedFlow(extraBufferCapacity = 16, onBufferOverflow = BufferOverflow.DROP_OLDEST) @@ -121,6 +173,11 @@ class ExoEngine(context: Context) : PlaybackEngine, VideoPathSwitchable { override fun onPlayerError(error: PlaybackException) = reportError(map(error)) } + private var audioUnderruns = 0 + private var rebuffers = 0 + /** Buffering right after load/seek is expected, only later ones are counted. */ + private var startedAtMs = 0L + private val analytics = object : AnalyticsListener { override fun onVideoDecoderInitialized(eventTime: AnalyticsListener.EventTime, decoderName: String, initializedTimestampMs: Long, initializationDurationMs: Long) { _stats.update { it.copy(videoDecoder = decoderName) } @@ -137,6 +194,24 @@ class ExoEngine(context: Context) : PlaybackEngine, VideoPathSwitchable { override fun onDroppedVideoFrames(eventTime: AnalyticsListener.EventTime, droppedFrames: Int, elapsedMs: Long) { _stats.update { it.copy(droppedFrames = it.droppedFrames + droppedFrames) } } + // Not visible as dropped frames: AC3-in-AVI / MPEG-PS style timing problems show up as these. + override fun onAudioUnderrun(eventTime: AnalyticsListener.EventTime, bufferSize: Int, bufferSizeMs: Long, elapsedSinceLastFeedMs: Long) { + audioUnderruns++ + Timber.w("Exo audio underrun #%d (%d ms since last feed)", audioUnderruns, elapsedSinceLastFeedMs) + _stats.update { it.copy(extra = it.extra + ("срывы звука" to audioUnderruns.toString())) } + } + override fun onBandwidthEstimate(eventTime: AnalyticsListener.EventTime, totalLoadTimeMs: Int, totalBytesLoaded: Long, bitrateEstimate: Long) { + Timber.d("Exo bandwidth: %.1f Mbit/s (%d KB in %d ms)", bitrateEstimate / 1e6, totalBytesLoaded / 1024, totalLoadTimeMs) + } + override fun onAudioSinkError(eventTime: AnalyticsListener.EventTime, audioSinkError: Exception) { + Timber.w(audioSinkError, "Exo audio sink error") + } + override fun onPlaybackStateChanged(eventTime: AnalyticsListener.EventTime, state: Int) { + if (state != Player.STATE_BUFFERING || eventTime.realtimeMs - startedAtMs < 3000) return + rebuffers++ + Timber.w("Exo rebuffering #%d at %d ms", rebuffers, eventTime.currentPlaybackPositionMs) + _stats.update { it.copy(extra = it.extra + ("буферизаций" to rebuffers.toString())) } + } override fun onAudioTrackInitialized(eventTime: AnalyticsListener.EventTime, audioTrackConfig: AudioSink.AudioTrackConfig) { val c = audioTrackConfig val kind = if (c.encoding == C.ENCODING_PCM_16BIT || c.encoding == C.ENCODING_PCM_FLOAT) "PCM (декодировано)" else "passthrough" @@ -146,17 +221,61 @@ class ExoEngine(context: Context) : PlaybackEngine, VideoPathSwitchable { } } - init { - player.addListener(listener) - player.addAnalyticsListener(analytics) - applyVideoPath() - } + /** + * Chosen in the player menu or by a variant key. Per engine: a new playback starts in AUTO, within one playback + * PlaybackSession carries it over to the next engine instance (fallback, restart). + */ + private var pathMode = VideoPathSwitchable.Mode.AUTO + + /** + * Default set by [ExoEngineProvider.create] (on if the display supports Dolby Vision); debug variants 1/4 override it. + * Carried over like [pathMode]. With it the TV does switch to Dolby Vision (DispService "Hdr Dolby"). + */ + override var useDolbyVisionDecoder = false override val videoPathMode get() = pathMode override var usingGpuPath = false private set private var pathApplied = false + private val mainHandler = Handler(Looper.getMainLooper()) + private var lastRendered = -1 + private var lastSkipped = 0 + + init { + assHandler.init(player) + // Inside the aspect frame: libass lays out relative to the picture, not to the letterboxed screen. + aspectFrame.addView(AssSubtitleView(context, assHandler), FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)) + player.addListener(listener) + player.addAnalyticsListener(analytics) + applyVideoPath() + mainHandler.postDelayed(::sampleFrameRate, FRAME_RATE_SAMPLE_MS) + } + + /** + * Frames actually shown per second, from the decoder counters. "Dropped" only counts frames that were too late; + * a stream the decoder skips or never outputs still plays at a moving position with 0 dropped + * (a DVD rip: ~1 fps on screen, 0 dropped). + */ + private fun sampleFrameRate() { + val counters = player.videoDecoderCounters + if (counters != null && player.isPlaying) { + counters.ensureUpdated() + val rendered = counters.renderedOutputBufferCount + if (lastRendered >= 0) { + val fps = (rendered - lastRendered) * 1000f / FRAME_RATE_SAMPLE_MS + val skipped = counters.skippedOutputBufferCount + counters.skippedInputBufferCount + Timber.d("Exo video: %.1f fps shown, skipped %d, dropped %d", fps, skipped - lastSkipped, counters.droppedBufferCount) + _stats.update { it.copy(extra = it.extra + ("кадров/с на экране" to "%.1f".format(fps)) + ("пропущено декодером" to skipped.toString())) } + lastSkipped = skipped + } + lastRendered = rendered + } else { + lastRendered = -1 + } + mainHandler.postDelayed(::sampleFrameRate, FRAME_RATE_SAMPLE_MS) + } + override fun setVideoPathMode(mode: VideoPathSwitchable.Mode) { pathMode = mode applyVideoPath() @@ -166,16 +285,28 @@ class ExoEngine(context: Context) : PlaybackEngine, VideoPathSwitchable { * On the Xiaomi TV the hardware video plane gets the TV's own "enhancement" (sharpening, contrast): compressed * SDR sources look visibly harsher than through the GPU (user A/B against VLC, 2026-10-12). AUTO therefore * composites SDR up to 1080p through the GPU, and keeps the video plane where it is required: HDR (the GPU - * path is SDR only) and >1080p (the UI surface is 1080p, the GPU path would throw away resolution). + * path is SDR only), >1080p (the UI surface is 1080p, the GPU path would throw away resolution) and interlaced + * video (only the video plane deinterlaces; through the GPU the fields show as combing). Also frame rates that + * don't divide 60 Hz (23.976/24/25/50): the video plane switches the panel to 100/120 Hz, the GPU path stays at + * 60 Hz and judders (3:2). The user preferred the smooth video plane (2026-10-13). Other devices (phones, TVs + * without such post-processing) always use the video plane: it is cheaper and keeps the panel's own refresh rate. */ private fun gpuPathFor(request: LoadRequest?): Boolean = when (pathMode) { VideoPathSwitchable.Mode.GPU -> true VideoPathSwitchable.Mode.VIDEO_PLANE -> false - VideoPathSwitchable.Mode.AUTO -> request?.mediaSource?.videoStream?.let { v -> - v.videoRange != VideoRange.HDR && (v.width ?: 0) <= 1920 && (v.height ?: 0) <= 1088 + VideoPathSwitchable.Mode.AUTO -> sdrThroughGpu && request?.mediaSource?.videoStream?.let { v -> + v.videoRange != VideoRange.HDR && !v.isInterlaced && (v.width ?: 0) <= 1920 && (v.height ?: 0) <= 1088 && + fitsUiRefreshRate(v.realFrameRate ?: v.averageFrameRate) } == true } + /** 30/60 fps (and 29.97/59.94) show evenly on the 60 Hz UI surface; unknown rate counts as fitting. */ + private fun fitsUiRefreshRate(fps: Float?): Boolean { + if (fps == null || fps <= 0f) return true + val cadence = UI_REFRESH_HZ / fps + return cadence >= 0.99f && abs(cadence - cadence.roundToInt()) < 0.02f + } + private fun applyVideoPath() { val gpu = gpuPathFor(request) if (pathApplied && gpu == usingGpuPath) return @@ -189,6 +320,7 @@ class ExoEngine(context: Context) : PlaybackEngine, VideoPathSwitchable { if (gpu) player.setVideoTextureView(textureView) else player.setVideoSurfaceView(surfaceView) val label = (if (gpu) "через GPU (TextureView)" else "видеослой ТВ (SurfaceView)") + if (pathMode == VideoPathSwitchable.Mode.AUTO) ", авто" else ", вручную" + Timber.i("Exo video output: %s", label) _stats.update { it.copy(extra = it.extra + ("вывод видео" to label)) } } @@ -221,6 +353,7 @@ class ExoEngine(context: Context) : PlaybackEngine, VideoPathSwitchable { .setSubtitleConfigurations(subtitles) .apply { if (request.url.contains(".m3u8")) setMimeType(MimeTypes.APPLICATION_M3U8) } .build() + startedAtMs = SystemClock.elapsedRealtime() player.setMediaItem(item, request.startPositionMs) player.prepare() } @@ -230,7 +363,7 @@ class ExoEngine(context: Context) : PlaybackEngine, VideoPathSwitchable { * metadata. See [ExoEngineProvider.USE_DOLBY_VISION_DECODER] for why that is the default. */ private fun decoderInfos(mimeType: String, requiresSecureDecoder: Boolean, requiresTunnelingDecoder: Boolean): List { - if (mimeType == MimeTypes.VIDEO_DOLBY_VISION && !ExoEngineProvider.USE_DOLBY_VISION_DECODER && !requiresDolbyVisionDecoder) { + if (mimeType == MimeTypes.VIDEO_DOLBY_VISION && !useDolbyVisionDecoder && !requiresDolbyVisionDecoder) { return emptyList() } return MediaCodecUtil.getDecoderInfos(mimeType, requiresSecureDecoder, requiresTunnelingDecoder) @@ -240,7 +373,10 @@ class ExoEngine(context: Context) : PlaybackEngine, VideoPathSwitchable { override fun play() = player.play() override fun pause() = player.pause() - override fun seekTo(positionMs: Long) = player.seekTo(positionMs) + override fun seekTo(positionMs: Long) { + startedAtMs = SystemClock.elapsedRealtime() + player.seekTo(positionMs) + } override fun selectAudio(streamIndex: Int) { val req = request ?: return @@ -262,10 +398,17 @@ class ExoEngine(context: Context) : PlaybackEngine, VideoPathSwitchable { textGroups.filterNot { it.getTrackFormat(0).id?.contains(EXTERNAL_PREFIX) == true }.getOrNull(ordinal) } if (group != null) builder.setOverrideForType(TrackSelectionOverride(group.mediaTrackGroup, 0)) + Timber.i( + "Exo subtitle %d -> %s (text tracks: %s)", + streamIndex, + group?.getTrackFormat(0)?.let { "${it.id} ${it.sampleMimeType}" } ?: "не найдена", + textGroups.joinToString { "${it.getTrackFormat(0).id}" }, + ) trackSelector.parameters = builder.build() } override fun release() { + mainHandler.removeCallbacksAndMessages(null) detachVideo() player.removeListener(listener) player.removeAnalyticsListener(analytics) @@ -347,9 +490,13 @@ class ExoEngine(context: Context) : PlaybackEngine, VideoPathSwitchable { } companion object { - /** Chosen in the player menu; kept for the process lifetime (no settings screen yet). */ - private var pathMode = VideoPathSwitchable.Mode.AUTO - private const val EXTERNAL_PREFIX = "ext:" + private const val MIN_BUFFER_MB = 32 + private const val FRAME_RATE_SAMPLE_MS = 2000L + /** Compose UI surface on the TV; the GPU path is shown at this rate. */ + private const val UI_REFRESH_HZ = 60f + /** libass glyph/bitmap cache; the default 128 MB of native memory is a lot on a 2 GB TV. */ + private const val ASS_CACHE_MB = 32 + private const val MAX_BUFFER_MB = 64 } } diff --git a/playback/engine-exo/src/main/kotlin/ru/stenfredd/movies/playback/exo/ExoEngineProvider.kt b/playback/engine-exo/src/main/kotlin/ru/stenfredd/movies/playback/exo/ExoEngineProvider.kt index 4ad8815..6e0c3d1 100644 --- a/playback/engine-exo/src/main/kotlin/ru/stenfredd/movies/playback/exo/ExoEngineProvider.kt +++ b/playback/engine-exo/src/main/kotlin/ru/stenfredd/movies/playback/exo/ExoEngineProvider.kt @@ -3,6 +3,7 @@ package ru.stenfredd.movies.playback.exo import android.content.Context import android.media.MediaCodecInfo.CodecProfileLevel import android.view.Display.HdrCapabilities +import androidx.media3.decoder.ffmpeg.FfmpegLibrary import dagger.hilt.android.qualifiers.ApplicationContext import org.jellyfin.sdk.model.api.CodecType import org.jellyfin.sdk.model.api.DeviceProfile @@ -53,18 +54,26 @@ class ExoEngineProvider @Inject constructor( } evaluateRange(v.videoRangeType, v.dvProfile, device, no, losses, notes) if (v.isInterlaced) losses += Loss.NO_DEINTERLACE + // DVD rips (MPEG-PS): such a rip combs in motion on the TV's video plane even with correct + // timestamps; it is bottom field first, while «Иван Васильевич» (MKV, top field first) is fine. Jellyfin + // doesn't report field order, so this goes by container; VLC (yadif) plays these cleanly (user, 2026-10-13). + if (v.isInterlaced && containers.any { it in PROGRAM_STREAM }) losses += Loss.DVD_COMBING } source.stream(selection.audioStreamIndex)?.let { a -> val mime = Codecs.audioMime(a.codec) val passthrough = Codecs.passthroughEncoding(a.codec, a.profile)?.let { it in device.passthroughEncodings } == true - if (!passthrough && (mime == null || !device.hasAudioDecoder(mime))) no += "аудио ${a.codec}" - if (passthrough) notes += "${a.codec}: passthrough" + when { + passthrough -> notes += "${a.codec}: passthrough" + mime != null && device.hasAudioDecoder(mime) -> Unit + mime != null && ffmpegDecodes(mime) -> notes += "${a.codec}: ffmpeg" + else -> no += "аудио ${a.codec}" + } + // AC3/DTS in AVI: re-framed by AviAudioFramingExtractor. } source.stream(selection.subtitleStreamIndex)?.let { s -> when (s.codec?.lowercase()) { - "ass", "ssa" -> losses += Loss.ASS_SIMPLIFIED "pgssub", "dvdsub", "dvbsub" -> if (s.isExternal) no += "внешние ${s.codec}" } } @@ -113,7 +122,7 @@ class ExoEngineProvider @Inject constructor( override fun deviceProfile(device: DeviceCapabilities): DeviceProfile { val videoCodecs = VIDEO_CODECS.filter { c -> Codecs.videoMime(c)?.let { m -> device.videoDecoders.any { it.mime.equals(m, true) } } == true } val audioCodecs = AUDIO_CODECS.filter { c -> - Codecs.audioMime(c)?.let(device::hasAudioDecoder) == true || + Codecs.audioMime(c)?.let { device.hasAudioDecoder(it) || ffmpegDecodes(it) } == true || Codecs.passthroughEncoding(c, null)?.let { it in device.passthroughEncodings } == true } val hasDv = device.hardwareVideoDecoders(DV_MIME).isNotEmpty() && device.displaySupportsHdr(HdrCapabilities.HDR_TYPE_DOLBY_VISION) @@ -178,24 +187,33 @@ class ExoEngineProvider @Inject constructor( } } - override fun create(device: DeviceCapabilities): PlaybackEngine = ExoEngine(context) + override fun create(device: DeviceCapabilities): PlaybackEngine = ExoEngine(context).apply { + useDolbyVisionDecoder = USE_DOLBY_VISION_DECODER && device.displaySupportsHdr(HdrCapabilities.HDR_TYPE_DOLBY_VISION) + } companion object { val ID = EngineId("exo") private const val DV_MIME = "video/dolby-vision" /** - * On the Xiaomi MT9689 the DV decoder (`c2.mtk.dvhe.st`) plays, but the picture is visibly duller than the - * same file's HDR10/HDR10+ base layer (user comparison against VLC, 2026-10-12): the TV apparently does not - * switch into Dolby Vision mode. Until that is solved (tunneling? see NEXT_SESSION.md §4.3) profiles with a - * base layer (8.x, 9, 10) are played as HEVC/AVC/AV1 HDR. Profile 5 always needs the DV decoder. + * Profiles with a base layer (8.x, 9, 10) go to the DV decoder when the display supports Dolby Vision, otherwise + * media3 plays them as HEVC/AVC/AV1 HDR. On the Xiaomi MT9689 (`c2.mtk.dvhe.st`) the TV switches to Dolby Vision + * and the user rated the picture good (2026-10-13; the earlier "duller" was the TV's DV picture mode). + * Debug variant 1 plays the base layer for comparison. Profile 5 always needs the DV decoder. */ - const val USE_DOLBY_VISION_DECODER = false + const val USE_DOLBY_VISION_DECODER = true private const val MAX_BITRATE = 400_000_000 + private val PROGRAM_STREAM = setOf("mpeg", "mpg", "vob", "mpegps") private val CONTAINERS = listOf("mkv", "webm", "mp4", "m4v", "mov", "ts", "mpegts", "avi", "mpeg", "ogg", "flv", "3gp") private val VIDEO_CODECS = listOf("h264", "hevc", "vp8", "vp9", "av1", "mpeg2video", "mpeg4") private val AUDIO_CODECS = listOf("aac", "mp3", "mp2", "ac3", "eac3", "truehd", "dts", "flac", "opus", "vorbis", "alac", "pcm_s16le", "pcm_s24le") + /** + * Jellyfin's media3 ffmpeg extension (TrueHD, DTS, ... without passthrough: phones, soundbars without the + * decoder). [ExoEngine] adds it after the MediaCodec renderers, so hardware decoders and passthrough win. + */ + private fun ffmpegDecodes(mime: String) = FfmpegLibrary.supportsFormat(mime) + private fun dvProfileConstant(profile: Int?): Int? = when (profile) { 4 -> CodecProfileLevel.DolbyVisionProfileDvheDtr 5 -> CodecProfileLevel.DolbyVisionProfileDvheStn diff --git a/playback/engine-exo/src/main/kotlin/ru/stenfredd/movies/playback/exo/Mpeg2TimestampFixExtractor.kt b/playback/engine-exo/src/main/kotlin/ru/stenfredd/movies/playback/exo/Mpeg2TimestampFixExtractor.kt new file mode 100644 index 0000000..825711a --- /dev/null +++ b/playback/engine-exo/src/main/kotlin/ru/stenfredd/movies/playback/exo/Mpeg2TimestampFixExtractor.kt @@ -0,0 +1,169 @@ +package ru.stenfredd.movies.playback.exo + +import androidx.media3.common.C +import androidx.media3.common.DataReader +import androidx.media3.common.Format +import androidx.media3.common.MimeTypes +import androidx.media3.common.util.ParsableByteArray +import androidx.media3.common.util.UnstableApi +import androidx.media3.extractor.Extractor +import androidx.media3.extractor.ExtractorInput +import androidx.media3.extractor.ExtractorOutput +import androidx.media3.extractor.PositionHolder +import androidx.media3.extractor.SeekMap +import androidx.media3.extractor.TrackOutput +import timber.log.Timber +import java.io.EOFException +import kotlin.math.abs + +/** + * Fixes MPEG-2 video timestamps from [androidx.media3.extractor.ts.PsExtractor] (DVD rips). + * + * media3's H262Reader gives a picture the PTS of its PES packet, or, if the packet has none, the previous picture's + * time plus one frame. That is done in decode order, which is wrong with B-frames: in DVD VOBs the P-pictures often + * start mid-packet and get "previous + 40 ms", colliding with the B-pictures that follow (I .28 → P .32, B .32, + * B .36 → P .40 ...), and the decoder outputs duplicate and backwards timestamps. + * + * Worse, PsExtractor passes 0 (not "unset") as the time of a PES packet without a PTS, so in the same VOBs 14 of + * 15 pictures of every GOP came out at time 0: the renderer skipped them as before the start position, and the + * picture ran at ~1.5 fps (one keyframe per GOP) while the position advanced normally. + * + * Every MPEG-2 picture header carries its display index within the GOP (temporal_reference), so such times + * (extrapolated, or more than a second off) are replaced by `keyframe time + (temporal_reference - keyframe's) × + * frame duration`. Plausible times are kept, which also keeps pulldown material (uneven real PTS) as the file has it. + */ +@UnstableApi +internal class Mpeg2TimestampFixExtractor(private val delegate: Extractor) : Extractor { + private val fixers = mutableListOf() + + override fun sniff(input: ExtractorInput) = delegate.sniff(input) + override fun read(input: ExtractorInput, seekPosition: PositionHolder) = delegate.read(input, seekPosition) + override fun getUnderlyingImplementation() = delegate + override fun release() = delegate.release() + + override fun init(output: ExtractorOutput) = delegate.init(object : ExtractorOutput { + override fun track(id: Int, type: Int): TrackOutput { + val track = output.track(id, type) + return if (type == C.TRACK_TYPE_VIDEO) FixingTrackOutput(track).also(fixers::add) else track + } + override fun endTracks() = output.endTracks() + override fun seekMap(seekMap: SeekMap) = output.seekMap(seekMap) + }) + + override fun seek(position: Long, timeUs: Long) { + fixers.forEach(FixingTrackOutput::reset) + delegate.seek(position, timeUs) + } + + private class FixingTrackOutput(private val out: TrackOutput) : TrackOutput { + private var frameDurationUs = 0L + /** Sample bytes written since the end of the last emitted sample. */ + private var pending = ByteArray(256 * 1024) + private var pendingLength = 0 + private var previousTimeUs = C.TIME_UNSET + private var anchorTimeUs = C.TIME_UNSET + private var anchorReference = 0 + private var fixedCount = 0L + + fun reset() { + pendingLength = 0 + previousTimeUs = C.TIME_UNSET + anchorTimeUs = C.TIME_UNSET + } + + override fun format(format: Format) { + // H262Reader keeps the frame rate to itself; read frame_rate_code from the sequence header (CSD) again. + val code = format.initializationData.firstOrNull()?.takeIf { it.size > 7 }?.let { (it[7].toInt() and 0x0F) - 1 } ?: -1 + frameDurationUs = if (format.sampleMimeType == MimeTypes.VIDEO_MPEG2 && code in FRAME_RATES.indices) { + (C.MICROS_PER_SECOND / FRAME_RATES[code]).toLong() + } else { + 0 + } + out.format(format) + } + + override fun durationUs(durationUs: Long) = out.durationUs(durationUs) + + override fun sampleData(input: DataReader, length: Int, allowEndOfInput: Boolean, sampleDataPart: Int): Int { + if (frameDurationUs == 0L) return out.sampleData(input, length, allowEndOfInput, sampleDataPart) + ensureCapacity(length) + val read = input.read(pending, pendingLength, length) + if (read == C.RESULT_END_OF_INPUT) { + if (allowEndOfInput) return read + throw EOFException() + } + out.sampleData(ParsableByteArray(pending.copyOfRange(pendingLength, pendingLength + read)), read, sampleDataPart) + pendingLength += read + return read + } + + override fun sampleData(data: ParsableByteArray, length: Int, sampleDataPart: Int) { + if (frameDurationUs != 0L) { + ensureCapacity(length) + System.arraycopy(data.data, data.position, pending, pendingLength, length) + pendingLength += length + } + out.sampleData(data, length, sampleDataPart) + } + + override fun sampleMetadata(timeUs: Long, flags: Int, size: Int, offset: Int, cryptoData: TrackOutput.CryptoData?) { + if (frameDurationUs == 0L) { + out.sampleMetadata(timeUs, flags, size, offset, cryptoData) + return + } + val start = pendingLength - offset - size + val reference = if (start >= 0) temporalReference(start, size) else -1 + val extrapolated = previousTimeUs != C.TIME_UNSET && abs(timeUs - (previousTimeUs + frameDurationUs)) <= 1 + previousTimeUs = timeUs + var fixedTimeUs = timeUs + when { + reference < 0 -> Unit + flags and C.BUFFER_FLAG_KEY_FRAME != 0 -> { + anchorTimeUs = timeUs + anchorReference = reference + } + anchorTimeUs != C.TIME_UNSET && abs(reference - anchorReference) <= MAX_GOP_FRAMES -> { + val expectedUs = anchorTimeUs + (reference - anchorReference) * frameDurationUs + if (extrapolated || abs(timeUs - expectedUs) > MAX_PTS_DEVIATION_US) fixedTimeUs = expectedUs + if (fixedTimeUs != timeUs && fixedCount++ == 0L) { + Timber.i("MPEG-2: rebuilding broken timestamps from temporal_reference") + } + } + } + // Keep only the bytes written past this sample (they belong to the next one). + if (start >= 0) { + val consumed = start + size + System.arraycopy(pending, consumed, pending, 0, pendingLength - consumed) + pendingLength -= consumed + } else { + pendingLength = 0 + } + out.sampleMetadata(fixedTimeUs, flags, size, offset, cryptoData) + } + + /** temporal_reference of the first picture header in the sample, or -1. */ + private fun temporalReference(start: Int, size: Int): Int { + val end = start + size - 6 + var i = start + while (i <= end) { + if (pending[i].toInt() == 0 && pending[i + 1].toInt() == 0 && pending[i + 2].toInt() == 1 && pending[i + 3].toInt() == 0) { + return ((pending[i + 4].toInt() and 0xFF) shl 2) or ((pending[i + 5].toInt() and 0xFF) ushr 6) + } + i++ + } + return -1 + } + + private fun ensureCapacity(extra: Int) { + if (pendingLength + extra > pending.size) pending = pending.copyOf(maxOf(pending.size * 2, pendingLength + extra)) + } + + private companion object { + /** Larger jumps mean a broken header or a GOP we didn't see start: leave the time alone. */ + const val MAX_GOP_FRAMES = 60 + /** A real PTS this far from the GOP's own picture order is not a PTS (PsExtractor's 0, see above). */ + const val MAX_PTS_DEVIATION_US = 1_000_000L + val FRAME_RATES = doubleArrayOf(24000 / 1001.0, 24.0, 25.0, 30000 / 1001.0, 30.0, 50.0, 60000 / 1001.0, 60.0) + } + } +} diff --git a/playback/engine-exo/src/main/kotlin/ru/stenfredd/movies/playback/exo/SeekHeadChainExtractor.kt b/playback/engine-exo/src/main/kotlin/ru/stenfredd/movies/playback/exo/SeekHeadChainExtractor.kt new file mode 100644 index 0000000..85e9882 --- /dev/null +++ b/playback/engine-exo/src/main/kotlin/ru/stenfredd/movies/playback/exo/SeekHeadChainExtractor.kt @@ -0,0 +1,292 @@ +package ru.stenfredd.movies.playback.exo + +import android.net.Uri +import androidx.media3.common.util.UnstableApi +import androidx.media3.extractor.DefaultExtractorsFactory +import androidx.media3.extractor.Extractor +import androidx.media3.extractor.ExtractorInput +import androidx.media3.extractor.ExtractorOutput +import androidx.media3.extractor.ExtractorsFactory +import androidx.media3.extractor.ForwardingExtractorInput +import androidx.media3.extractor.PositionHolder +import androidx.media3.extractor.avi.AviExtractor +import androidx.media3.extractor.mkv.MatroskaExtractor +import androidx.media3.extractor.text.SubtitleParser +import androidx.media3.extractor.ts.PsExtractor +import ru.stenfredd.movies.playback.exo.ps.DvdPsExtractor +import timber.log.Timber + +/** + * [DefaultExtractorsFactory] with fixes: [MatroskaExtractor] wrapped in [SeekHeadChainExtractor], [PsExtractor] + * replaced by [DvdPsExtractor] wrapped in [Mpeg2TimestampFixExtractor], [AviExtractor] wrapped in + * [AviAudioFramingExtractor]. + */ +@UnstableApi +internal class ExoExtractorsFactory( + /** Replaces media3's MatroskaExtractor (ASS support), still wrapped in [SeekHeadChainExtractor]. */ + private val matroska: (() -> MatroskaExtractor)? = null, +) : ExtractorsFactory { + private val inner = DefaultExtractorsFactory() + + override fun createExtractors(): Array = wrap(inner.createExtractors()) + + override fun createExtractors(uri: Uri, responseHeaders: Map>): Array = + wrap(inner.createExtractors(uri, responseHeaders)) + + override fun setSubtitleParserFactory(subtitleParserFactory: SubtitleParser.Factory) = + apply { inner.setSubtitleParserFactory(subtitleParserFactory) } + + @Deprecated("Deprecated in media3") + @Suppress("DEPRECATION") + override fun experimentalSetTextTrackTranscodingEnabled(textTrackTranscodingEnabled: Boolean) = + apply { inner.experimentalSetTextTrackTranscodingEnabled(textTrackTranscodingEnabled) } + + override fun experimentalSetCodecsToParseWithinGopSampleDependencies(codecsToParseWithinGopSampleDependencies: Int) = + apply { inner.experimentalSetCodecsToParseWithinGopSampleDependencies(codecsToParseWithinGopSampleDependencies) } + + override fun setParseHagcMetadata(parseHagcMetadata: Boolean) = apply { inner.setParseHagcMetadata(parseHagcMetadata) } + + private fun wrap(extractors: Array): Array = + extractors.map { + when (it) { + is MatroskaExtractor -> SeekHeadChainExtractor(matroska?.invoke() ?: it) + is PsExtractor -> Mpeg2TimestampFixExtractor(DvdPsExtractor()) + is AviExtractor -> AviAudioFramingExtractor(it) + else -> it + } + }.toTypedArray() +} + +/** + * Lets media3 seek in Matroska files whose first SeekHead only points to a second SeekHead at the end of the file + * (mkvpropedit/MakeMKV), which in turn points to Cues. [MatroskaExtractor] follows one level only, finds no Cues and + * reports the file as unseekable. + * + * Before handing the input over, reads the second SeekHead and then rewrites the first one's entry + * "SeekHead at X" into "Cues at Y" on the fly. Both fields keep their byte length, so nothing else in the file moves. + * Costs two extra range requests, only for such files; everything else passes straight through. + */ +@UnstableApi +internal class SeekHeadChainExtractor(private val delegate: Extractor) : Extractor { + private enum class Phase { PROBE, READ_TAIL, PASS } + + private class SeekEntry(val id: Long, val position: Long, val idOffset: Long, val idSize: Int, val positionOffset: Long, val positionSize: Int) + private class Patch(val offset: Long, val bytes: ByteArray) + + private var phase = Phase.PROBE + private var segmentStart = 0L + private var chain: SeekEntry? = null + private var patches: List = emptyList() + + override fun sniff(input: ExtractorInput) = delegate.sniff(input) + override fun init(output: ExtractorOutput) = delegate.init(output) + override fun getUnderlyingImplementation() = delegate + override fun release() = delegate.release() + + override fun seek(position: Long, timeUs: Long) { + if (phase != Phase.PASS && position != 0L) phase = Phase.PASS + delegate.seek(position, timeUs) + } + + override fun read(input: ExtractorInput, seekPosition: PositionHolder): Int { + when (phase) { + Phase.PROBE -> { + phase = Phase.PASS + if (input.position == 0L) { + val target = runCatching { probe(input) }.onFailure { Timber.w(it, "MKV SeekHead probe failed") }.getOrNull() + input.resetPeekPosition() + if (target != null) { + phase = Phase.READ_TAIL + seekPosition.position = target + return Extractor.RESULT_SEEK + } + } + } + Phase.READ_TAIL -> { + phase = Phase.PASS + runCatching { readTail(input) }.onFailure { Timber.w(it, "MKV tail SeekHead read failed") } + seekPosition.position = 0 + return Extractor.RESULT_SEEK + } + Phase.PASS -> Unit + } + return delegate.read(if (patches.isEmpty()) input else PatchingInput(input, patches), seekPosition) + } + + /** Returns the position of the second SeekHead if the first one points only there, without consuming input. */ + private fun probe(input: ExtractorInput): Long? { + val r = EbmlReader(input, peek = true) + var e = r.element() + if (e.id != ID_EBML) return null + r.skip(e.size) + e = r.element() + if (e.id != ID_SEGMENT) return null + segmentStart = r.position + repeat(MAX_ELEMENTS_BEFORE_SEEK_HEAD) { + e = r.element() + when (e.id) { + ID_SEEK_HEAD -> { + val entries = parseSeekHead(r, e.size) + if (entries.any { it.id == ID_CUES }) return null + val link = entries.firstOrNull { it.id == ID_SEEK_HEAD && it.idSize == 4 } ?: return null + chain = link + return segmentStart + link.position + } + ID_CLUSTER -> return null + else -> r.skip(e.size) + } + } + return null + } + + private fun readTail(input: ExtractorInput) { + val link = chain ?: return + val r = EbmlReader(input, peek = false) + val e = r.element() + if (e.id != ID_SEEK_HEAD) return + val cues = parseSeekHead(r, e.size).firstOrNull { it.id == ID_CUES } ?: return + val size = link.positionSize + if (size < 8 && cues.position ushr (8 * size) != 0L) return + patches = listOf( + Patch(link.idOffset, byteArrayOf(0x1C, 0x53, 0xBB.toByte(), 0x6B)), + Patch(link.positionOffset, ByteArray(size) { i -> (cues.position ushr (8 * (size - 1 - i))).toByte() }), + ) + Timber.i("MKV SeekHead chain: Cues at %d via second SeekHead, first SeekHead patched", segmentStart + cues.position) + } + + private fun parseSeekHead(r: EbmlReader, size: Long): List { + val entries = mutableListOf() + val end = r.position + size + while (r.position < end) { + val seek = r.element() + if (seek.id != ID_SEEK) { + r.skip(seek.size) + continue + } + val seekEnd = r.position + seek.size + var id = -1L + var idOffset = 0L + var idSize = 0 + var position = -1L + var positionOffset = 0L + var positionSize = 0 + while (r.position < seekEnd) { + val c = r.element() + when { + c.size !in 1..8 -> r.skip(c.size) + c.id == ID_SEEK_ID -> { idOffset = r.position; idSize = c.size.toInt(); id = r.uint(idSize) } + c.id == ID_SEEK_POSITION -> { positionOffset = r.position; positionSize = c.size.toInt(); position = r.uint(positionSize) } + else -> r.skip(c.size) + } + } + if (id >= 0 && position >= 0) entries += SeekEntry(id, position, idOffset, idSize, positionOffset, positionSize) + } + return entries + } + + /** Minimal EBML reading over either the peek or the read position of an [ExtractorInput]. */ + private class EbmlReader(private val input: ExtractorInput, private val peek: Boolean) { + class Element(val id: Long, val size: Long) + + private val buf = ByteArray(8) + val position get() = if (peek) input.peekPosition else input.position + + fun element(): Element { + val id = vint(keepMarker = true) + val size = vint(keepMarker = false) + require(size >= 0) { "unknown element size" } + return Element(id, size) + } + + fun skip(size: Long) { + require(size in 0..MAX_SKIP) { "element too large to skip: $size" } + if (peek) input.advancePeekPosition(size.toInt()) else input.skipFully(size.toInt()) + } + + fun uint(size: Int): Long { + fill(size) + return (0 until size).fold(0L) { acc, i -> (acc shl 8) or (buf[i].toLong() and 0xFF) } + } + + private fun vint(keepMarker: Boolean): Long { + fill(1) + val first = buf[0].toInt() and 0xFF + val length = Integer.numberOfLeadingZeros(first) - 23 + require(length in 1..8) { "bad EBML varint" } + var value = if (keepMarker) first.toLong() else (first and (0xFF ushr length)).toLong() + var allOnes = value == (0xFF ushr length).toLong() + if (length > 1) { + fill(length - 1) + for (i in 0 until length - 1) { + val b = buf[i].toLong() and 0xFF + allOnes = allOnes && b == 0xFFL + value = (value shl 8) or b + } + } + return if (!keepMarker && allOnes) -1 else value + } + + private fun fill(n: Int) { + if (peek) input.peekFully(buf, 0, n) else input.readFully(buf, 0, n) + } + } + + /** Replaces bytes at absolute file offsets in everything read or peeked through it. */ + private class PatchingInput(input: ExtractorInput, private val patches: List) : ForwardingExtractorInput(input) { + override fun read(buffer: ByteArray, offset: Int, length: Int): Int { + val at = position + return super.read(buffer, offset, length).also { if (it > 0) apply(at, buffer, offset, it) } + } + + override fun readFully(target: ByteArray, offset: Int, length: Int, allowEndOfInput: Boolean): Boolean { + val at = position + return super.readFully(target, offset, length, allowEndOfInput).also { if (it) apply(at, target, offset, length) } + } + + override fun readFully(target: ByteArray, offset: Int, length: Int) { + val at = position + super.readFully(target, offset, length) + apply(at, target, offset, length) + } + + override fun peek(target: ByteArray, offset: Int, length: Int): Int { + val at = peekPosition + return super.peek(target, offset, length).also { if (it > 0) apply(at, target, offset, it) } + } + + override fun peekFully(target: ByteArray, offset: Int, length: Int, allowEndOfInput: Boolean): Boolean { + val at = peekPosition + return super.peekFully(target, offset, length, allowEndOfInput).also { if (it) apply(at, target, offset, length) } + } + + override fun peekFully(target: ByteArray, offset: Int, length: Int) { + val at = peekPosition + super.peekFully(target, offset, length) + apply(at, target, offset, length) + } + + private fun apply(at: Long, buffer: ByteArray, offset: Int, length: Int) { + for (p in patches) { + for (i in p.bytes.indices) { + val abs = p.offset + i + if (abs >= at && abs < at + length) buffer[offset + (abs - at).toInt()] = p.bytes[i] + } + } + } + } + + private companion object { + const val ID_EBML = 0x1A45DFA3L + const val ID_SEGMENT = 0x18538067L + const val ID_SEEK_HEAD = 0x114D9B74L + const val ID_SEEK = 0x4DBBL + const val ID_SEEK_ID = 0x53ABL + const val ID_SEEK_POSITION = 0x53ACL + const val ID_CUES = 0x1C53BB6BL + const val ID_CLUSTER = 0x1F43B675L + + /** The SeekHead is normally the first child of the Segment; don't buffer half a file looking for it. */ + const val MAX_ELEMENTS_BEFORE_SEEK_HEAD = 8 + const val MAX_SKIP = 1L shl 20 + } +} diff --git a/playback/engine-vlc/src/main/kotlin/ru/stenfredd/movies/playback/vlc/RecentBytesCache.kt b/playback/engine-vlc/src/main/kotlin/ru/stenfredd/movies/playback/vlc/RecentBytesCache.kt index 2abe5f1..39350d6 100644 --- a/playback/engine-vlc/src/main/kotlin/ru/stenfredd/movies/playback/vlc/RecentBytesCache.kt +++ b/playback/engine-vlc/src/main/kotlin/ru/stenfredd/movies/playback/vlc/RecentBytesCache.kt @@ -31,7 +31,11 @@ internal class RecentBytesCache(private val capacity: Long) { @Synchronized fun putMeta(url: String, meta: Meta) { val entry = entries.getOrPut(url, ::Entry) - if (entry.meta?.totalLength != meta.totalLength) entry.blocks.clear() + if (entry.meta?.totalLength != meta.totalLength) { + size -= entry.blocks.values.sumOf { it.size.toLong() } + entry.blocks.clear() + order.removeAll { it.first == url } + } entry.meta = meta } @@ -41,7 +45,10 @@ internal class RecentBytesCache(private val capacity: Long) { val floor = entry.blocks.floorEntry(offset) // Already covered (two connections fetched the same bytes). if (floor != null && floor.key + floor.value.size >= offset + data.size) return - entry.blocks.put(offset, data)?.let { size -= it.size } + entry.blocks.put(offset, data)?.let { old -> + size -= old.size + order.remove(url to offset) + } size += data.size order.addLast(url to offset) while (size > capacity) { diff --git a/playback/engine-vlc/src/main/kotlin/ru/stenfredd/movies/playback/vlc/VlcEngine.kt b/playback/engine-vlc/src/main/kotlin/ru/stenfredd/movies/playback/vlc/VlcEngine.kt index 0699755..556fad1 100644 --- a/playback/engine-vlc/src/main/kotlin/ru/stenfredd/movies/playback/vlc/VlcEngine.kt +++ b/playback/engine-vlc/src/main/kotlin/ru/stenfredd/movies/playback/vlc/VlcEngine.kt @@ -13,6 +13,7 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import org.jellyfin.sdk.model.api.MediaStreamType import org.jellyfin.sdk.model.api.PlayMethod +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import okhttp3.OkHttpClient import org.videolan.libvlc.Dialog import org.videolan.libvlc.LibVLC @@ -130,6 +131,7 @@ class VlcEngine(context: Context, httpClient: OkHttpClient, private val device: val m = Media(libVlc, proxy.localUrl(request.url).toUri()).apply { setHWDecoderEnabled(needsHardware, false) if (request.startPositionMs > 0) addOption(":start-time=${request.startPositionMs / 1000.0}") + if (request.startPositionMs > 0) startSeek(request.startPositionMs) else seekTargetMs = -1 for (sub in request.externalSubtitles) addSlave(IMedia.Slave(IMedia.Slave.Type.Subtitle, 4, proxy.localUrl(sub.url))) } media?.release() @@ -150,9 +152,48 @@ class VlcEngine(context: Context, httpClient: OkHttpClient, private val device: } override fun seekTo(positionMs: Long) { + startSeek(positionMs) player.setTime(positionMs, false) } + /* + * libVLC seeks in MPEG-PS (DVD rips) by an average-bitrate estimate and lands minutes off: asked for 30:00 it + * played 26:25 of a DVD rip, and its clock is right about that (checked against ffmpeg frames), + * so the user simply sees the wrong scene. After a seek (or a start position) we look where playback landed and + * re-seek with the error subtracted, a few times at most. Precise containers land within tolerance at once. + */ + private var seekTargetMs = -1L + private var seekRequestMs = 0L + private var seekIssuedAt = 0L + private var seekAttempts = 0 + + private fun startSeek(targetMs: Long) { + seekTargetMs = targetMs + seekRequestMs = targetMs + seekIssuedAt = SystemClock.elapsedRealtime() + seekAttempts = 0 + } + + private fun checkSeek() { + if (seekTargetMs < 0) return + val time = player.time + val elapsed = SystemClock.elapsedRealtime() - seekIssuedAt + if (time <= 0 || elapsed < SEEK_SETTLE_MS) return + // Playback has been running for at most `elapsed` since the seek. + val landed = time - elapsed.coerceAtMost(time) + val error = seekTargetMs - landed + if (error in -SEEK_TOLERANCE_MS..SEEK_TOLERANCE_MS || seekAttempts >= MAX_SEEK_CORRECTIONS) { + if (seekAttempts > 0) Timber.i("VLC seek to %d s: landed at %d s after %d corrections", seekTargetMs / 1000, landed / 1000, seekAttempts) + seekTargetMs = -1 + return + } + seekAttempts++ + seekRequestMs = (seekRequestMs + error).coerceAtLeast(0) + seekIssuedAt = SystemClock.elapsedRealtime() + Timber.i("VLC seek to %d s landed at %d s, retrying with %d s", seekTargetMs / 1000, landed / 1000, seekRequestMs / 1000) + player.setTime(seekRequestMs, false) + } + override fun selectAudio(streamIndex: Int) { val req = request ?: return val ordinal = req.mediaSource.embeddedOrdinal(streamIndex, MediaStreamType.AUDIO) ?: return @@ -169,7 +210,8 @@ class VlcEngine(context: Context, httpClient: OkHttpClient, private val device: val source = request?.mediaSource ?: return val stream = source.stream(streamIndex)?.takeIf { it.type == MediaStreamType.AUDIO } ?: source.streams(MediaStreamType.AUDIO).let { all -> all.firstOrNull { it.isDefault } ?: all.firstOrNull() } - val encoding = Codecs.passthroughEncoding(stream?.codec, stream?.profile) + val codec = outputAudioCodec(stream?.codec) + val encoding = Codecs.passthroughEncoding(codec, stream?.profile.takeIf { codec == stream?.codec }) val passthrough = encoding != null && encoding in device.passthroughEncodings val mode = if (passthrough) "encoded" else "stereo" if (mode == audioOutputMode) return @@ -179,18 +221,38 @@ class VlcEngine(context: Context, httpClient: OkHttpClient, private val device: } else { player.setAudioOutputDevice(mode) } - val label = if (passthrough) "passthrough ${Codecs.encodingName(encoding!!)}" else "PCM стерео (${stream?.codec ?: "?"})" + val label = if (passthrough) "passthrough ${Codecs.encodingName(encoding!!)}" else "PCM стерео (${codec ?: "?"})" Timber.i("VLC audio output: %s", label) _stats.update { it.copy(audioOutput = label) } } + /** + * The codec VLC will actually get. A transcode keeps the source audio only if it is in the URL's `AudioCodec` + * list (stream copy), otherwise the server encodes to the first one: TrueHD transcoded comes out as AAC, and + * forcing TrueHD passthrough for it would give silence. + */ + private fun outputAudioCodec(sourceCodec: String?): String? { + val req = request ?: return sourceCodec + if (req.playMethod != PlayMethod.TRANSCODE) return sourceCodec + val allowed = req.url.toHttpUrlOrNull()?.queryParameter("AudioCodec")?.split(',')?.filter(String::isNotBlank) + if (allowed.isNullOrEmpty()) return sourceCodec + return allowed.firstOrNull { it.equals(sourceCodec, ignoreCase = true) } ?: allowed.first() + } + + /** + * Subtitle asked for before its track existed: external files are slaves that libVLC opens after the main + * input, so at the first ESAdded only the embedded tracks are there. Retried on every later ESAdded. + */ + private var pendingSubtitle: Int? = null + override fun selectSubtitle(streamIndex: Int?) { val req = request ?: return + pendingSubtitle = null if (streamIndex == null) { player.setSpuTrack(-1) return } - val tracks = player.spuTracks?.filter { it.id >= 0 } ?: return + val tracks = player.spuTracks?.filter { it.id >= 0 }.orEmpty() val stream = req.mediaSource.mediaStreams.orEmpty().firstOrNull { it.index == streamIndex } ?: return val track = if (stream.isExternal || req.externalSubtitles.any { it.streamIndex == streamIndex }) { // Slaves are appended after the embedded tracks, in the order they were added. @@ -200,7 +262,13 @@ class VlcEngine(context: Context, httpClient: OkHttpClient, private val device: } else { req.mediaSource.embeddedOrdinal(streamIndex, MediaStreamType.SUBTITLE)?.let(tracks::getOrNull) } - track?.let { player.setSpuTrack(it.id) } + if (track == null) { + Timber.d("VLC subtitle %d not there yet (spu tracks: %s)", streamIndex, tracks.joinToString { "${it.id} ${it.name}" }) + pendingSubtitle = streamIndex + return + } + Timber.i("VLC subtitle %d -> track %d «%s»", streamIndex, track.id, track.name) + player.setSpuTrack(track.id) } override fun setAudioDelay(delayMs: Long) { @@ -248,8 +316,14 @@ class VlcEngine(context: Context, httpClient: OkHttpClient, private val device: _events.tryEmit(EngineEvent.FirstFrameRendered) updateStats(force = true) } - MediaPlayer.Event.ESAdded, MediaPlayer.Event.ESSelected -> applyInitialTracks() - MediaPlayer.Event.TimeChanged -> updateStats(force = false) + MediaPlayer.Event.ESAdded, MediaPlayer.Event.ESSelected -> { + applyInitialTracks() + pendingSubtitle?.let(::selectSubtitle) + } + MediaPlayer.Event.TimeChanged -> { + checkSeek() + updateStats(force = false) + } MediaPlayer.Event.EncounteredError -> reportError( EngineError(EngineError.Kind.DECODER, lastDialogError ?: "libVLC: EncounteredError"), ) @@ -271,6 +345,16 @@ class VlcEngine(context: Context, httpClient: OkHttpClient, private val device: lastStatsAt = now val video = player.currentVideoTrack val s = media?.stats + // libVLC's decodedVideo counts frames twice and inputBitrate averages in the prefetch: use deltas instead. + val rates = s?.let { st -> + val prev = lastStatsSample + lastStatsSample = StatsSample(now, st.displayedPictures, st.readBytes) + prev?.takeIf { now > it.atMs && st.displayedPictures >= it.displayed }?.let { p -> + val sec = (now - p.atMs) / 1000.0 + // readBytes is an Int that wraps after 2 GB; the Int difference is still right. + (st.displayedPictures - p.displayed) / sec to (st.readBytes - p.readBytes).toUInt().toDouble() * 8 / sec / 1e6 + } + } _stats.update { it.copy( videoFormat = video?.let { v -> @@ -278,14 +362,18 @@ class VlcEngine(context: Context, httpClient: OkHttpClient, private val device: }, droppedFrames = s?.lostPictures ?: it.droppedFrames, extra = it.extra + listOfNotNull( - s?.let { st -> "кадры показано/декод." to "${st.displayedPictures}/${st.decodedVideo}" }, - s?.let { st -> "битрейт входа" to "%.1f Мбит/с".format(st.inputBitrate * 8000 / 1000) }, + s?.let { st -> "кадров показано" to "${st.displayedPictures}" }, + rates?.let { (fps, _) -> "кадров/с на экране" to "%.1f".format(fps) }, + rates?.let { (_, mbps) -> "чтение входа" to "%.1f Мбит/с".format(mbps) }, "прокси" to "${proxy.activeConnections} соед., %.1f МБ".format(proxy.bytesTransferred / 1048576.0), ), ) } } + private class StatsSample(val atMs: Long, val displayed: Int, val readBytes: Int) + private var lastStatsSample: StatsSample? = null + private fun reportError(error: EngineError) { if (errorReported) return errorReported = true @@ -294,13 +382,16 @@ class VlcEngine(context: Context, httpClient: OkHttpClient, private val device: } private companion object { + const val SEEK_SETTLE_MS = 1500L + const val SEEK_TOLERANCE_MS = 5000L + const val MAX_SEEK_CORRECTIONS = 3 val OPTIONS = arrayListOf( "--network-caching=3000", "--audio-time-stretch", "--no-sub-autodetect-file", "--deinterlace=-1", "--deinterlace-mode=yadif", - "-vv", + "-v", ) } } diff --git a/playback/engine-vlc/src/main/kotlin/ru/stenfredd/movies/playback/vlc/VlcEngineProvider.kt b/playback/engine-vlc/src/main/kotlin/ru/stenfredd/movies/playback/vlc/VlcEngineProvider.kt index 08c0066..87eeeae 100644 --- a/playback/engine-vlc/src/main/kotlin/ru/stenfredd/movies/playback/vlc/VlcEngineProvider.kt +++ b/playback/engine-vlc/src/main/kotlin/ru/stenfredd/movies/playback/vlc/VlcEngineProvider.kt @@ -51,7 +51,11 @@ class VlcEngineProvider @Inject constructor( // Hardware decoding is only used by this engine for >1080p (see VlcEngine), so treat the rest as software. if (!hardware || (v.width ?: 0) * (v.height ?: 0) <= 1920 * 1088) { val pixels = (v.width ?: 0) * (v.height ?: 0) - losses += if (pixels > 1920 * 1088 || (v.bitDepth ?: 8) > 8) Loss.SOFTWARE_DECODE_HEAVY else Loss.SOFTWARE_DECODE + // Deinterlacing (yadif) doubles the frame rate. The tour on the TV: 720p60 H.264 and 1080i50 drop + // frames in VLC, 1080p at 24-30 and SD at 50 (deinterlaced DVD) play fine. + val fps = (v.realFrameRate ?: v.averageFrameRate ?: 0f) * if (v.isInterlaced) 2 else 1 + val heavy = pixels > 1920 * 1088 || (v.bitDepth ?: 8) > 8 || (fps > MAX_LIGHT_FPS && pixels * fps > MAX_LIGHT_PIXEL_RATE) + losses += if (heavy) Loss.SOFTWARE_DECODE_HEAVY else Loss.SOFTWARE_DECODE notes += v.codec.orEmpty() } when { @@ -92,5 +96,8 @@ class VlcEngineProvider @Inject constructor( companion object { val ID = EngineId("vlc") private const val MAX_BITRATE = 400_000_000 + private const val MAX_LIGHT_FPS = 31f + /** Above 30 fps: SD at 50 (~21 Mpx/s) is light, 720p60 (~55 Mpx/s) is not. */ + private const val MAX_LIGHT_PIXEL_RATE = 1280f * 720 * 50 } } diff --git a/tools/tour/build_tour.py b/tools/tour/build_tour.py new file mode 100644 index 0000000..5572584 --- /dev/null +++ b/tools/tour/build_tour.py @@ -0,0 +1,188 @@ +#!/usr/bin/env python3 +""" +Builds the debug "tour" playlist: a small set of library files that together cover every distinct format feature on +the server (container, video codec/bit depth/resolution/HDR type/frame rate/interlacing, audio codec/channels, +subtitle codec) and the pairs that matter for engine choice (container x codec, codec x HDR, ...). + +Each entry also fixes which audio and subtitle track to select, so subtitle/audio codecs are actually exercised. + +Usage: + python3 tools/tour/build_tour.py --server http://192.168.1.10:8096 --token [--out app/src/debug/assets/tour.json] + python3 tools/tour/build_tour.py --input library.json ... # reuse a saved /Items response +Token from the TV: `source tools/tv/env.sh; tv_token`. +""" +import argparse +import json +import sys +import urllib.request +from collections import Counter + +PAIR_KEYS = [("cont", "vcodec"), ("cont", "acodec"), ("vcodec", "range"), ("res", "range"), ("vcodec", "res"), ("cont", "sub")] + + +def fetch(server, token): + def get(path): + req = urllib.request.Request(server + path, headers={"Authorization": f'MediaBrowser Token="{token}"'}) + with urllib.request.urlopen(req, timeout=120) as r: + return json.load(r) + user = get("/Users/Me")["Id"] + return get(f"/Users/{user}/Items?Recursive=true&IncludeItemTypes=Movie,Episode&Fields=MediaSources" + "&EnableImages=false&EnableUserData=false&Limit=100000") + + +def res_class(w, h): + if w >= 3000 or h >= 1700: + return "4K" + if w >= 1700 or h >= 900: + return "1080p" + if w >= 1100 or h >= 650: + return "720p" + return "SD" + + +def fps_class(f): + if not f: + return "?" + for name, v in (("23.976", 23.976), ("24", 24), ("25", 25), ("29.97", 29.97), ("30", 30), ("50", 50), ("59.94", 59.94), ("60", 60)): + if abs(f - v) < 0.02: + return name + return "other" + + +def video_features(v): + codec = (v.get("Codec") or "?").lower() + depth = v.get("BitDepth") or 8 + vc = f"{codec}-{depth}bit" if codec in ("h264", "hevc", "av1", "vp9") else codec + return { + "vcodec": vc, + "res": res_class(v.get("Width") or 0, v.get("Height") or 0), + "range": v.get("VideoRangeType") or v.get("VideoRange") or "SDR", + "fps": fps_class(v.get("RealFrameRate") or v.get("AverageFrameRate")), + "interlaced": "yes" if v.get("IsInterlaced") else "no", + } + + +def audio_name(a): + codec = (a.get("Codec") or "?").lower() + profile = (a.get("Profile") or "").lower() + if codec == "dts" and "ma" in profile: + codec = "dts-hd-ma" + elif codec == "dts" and "hra" in profile: + codec = "dts-hd-hra" + elif codec == "aac" and "he" in profile: + codec = "he-aac" + if a.get("AudioSpatialFormat") in ("DolbyAtmos",) or "atmos" in profile: + codec += "-atmos" + return codec + + +def channels(a): + c = a.get("Channels") or 0 + return {1: "1.0", 2: "2.0", 6: "5.1", 8: "7.1"}.get(c, f"{c}ch") + + +def sub_name(s): + if s is None: + return "none" + return (s.get("Codec") or "?").lower() + ("-ext" if s.get("IsExternal") else "") + + +def candidates(item): + ms = (item.get("MediaSources") or [None])[0] + if not ms or (item.get("RunTimeTicks") or 0) < 10 * 60 * 10**7: + return [] + streams = ms.get("MediaStreams") or [] + videos = [s for s in streams if s["Type"] == "Video"] + audios = [s for s in streams if s["Type"] == "Audio"] + subs = [s for s in streams if s["Type"] == "Subtitle"] + if not videos or not audios: + return [] + base = video_features(videos[0]) + base["cont"] = (ms.get("Container") or "?").split(",")[0].lower() + flags = [] + if any(s["Type"] == "EmbeddedImage" for s in streams): + flags.append("cover-image") + if len(subs) >= 10: + flags.append("many-subs") + if len(audios) >= 5: + flags.append("many-audio") + + by_index = {s["Index"]: s for s in streams} + default_audio = by_index.get(ms.get("DefaultAudioStreamIndex")) or audios[0] + default_sub = by_index.get(ms.get("DefaultSubtitleStreamIndex")) + audio_opts = {audio_name(default_audio) + channels(default_audio): default_audio} + for a in audios: + audio_opts.setdefault(audio_name(a) + channels(a), a) + sub_opts = {sub_name(default_sub): default_sub, "none": None} + for s in subs: + sub_opts.setdefault(sub_name(s), s) + + combos = [(default_audio, s) for s in sub_opts.values()] + [(a, default_sub) for a in audio_opts.values()] + out = [] + seen = set() + for a, s in combos: + key = (a["Index"], s["Index"] if s else None) + if key in seen: + continue + seen.add(key) + f = dict(base, acodec=audio_name(a), ach=channels(a), sub=sub_name(s)) + tokens = {f"{k}:{v}" for k, v in f.items()} | {f"flag:{x}" for x in flags} + tokens |= {f"{k1}+{k2}:{f[k1]}+{f[k2]}" for k1, k2 in PAIR_KEYS} + out.append((item, ms, a, s, f, flags, tokens)) + return out + + +def title(item): + if item.get("Type") == "Episode": + return f'{item.get("SeriesName", "")} — {item.get("Name", "")}' + return item.get("Name", "") + + +def label(f, flags, a, s): + video = f'{f["vcodec"]} {f["res"]} {f["range"]} {f["fps"]}' + (" interlaced" if f["interlaced"] == "yes" else "") + audio = f'{f["acodec"]} {f["ach"]}' + sub = "без субтитров" if s is None else f'субт. {f["sub"]}' + return " · ".join([f["cont"].upper(), video, audio, sub] + flags) + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--server") + ap.add_argument("--token") + ap.add_argument("--input", help="saved /Items JSON instead of querying the server") + ap.add_argument("--out", default="app/src/debug/assets/tour.json") + args = ap.parse_args() + data = json.load(open(args.input)) if args.input else fetch(args.server, args.token) + + cands = [c for item in data["Items"] for c in candidates(item)] + universe = set().union(*(c[6] for c in cands)) + # How common each feature is: rarer features first in the tie-break, so odd files are represented by + # themselves and not by whatever happens to also cover them. + freq = Counter(t for c in cands for t in c[6]) + uncovered = set(universe) + chosen = [] + while uncovered: + best = max(cands, key=lambda c: (len(c[6] & uncovered), -min(freq[t] for t in c[6] & uncovered) if c[6] & uncovered else 0)) + gain = best[6] & uncovered + if not gain: + break + chosen.append(best) + uncovered -= gain + + chosen.sort(key=lambda c: (c[4]["cont"], c[4]["vcodec"], c[4]["res"], c[4]["range"])) + entries = [{ + "id": item["Id"], + "title": title(item), + "audio": a["Index"], + "subtitle": s["Index"] if s else None, + "label": label(f, flags, a, s), + } for item, ms, a, s, f, flags, _ in chosen] + with open(args.out, "w") as fh: + json.dump(entries, fh, ensure_ascii=False, indent=1) + print(f"{len(cands)} candidates, {len(universe)} features → {len(entries)} files in {args.out}", file=sys.stderr) + for i, e in enumerate(entries, 1): + print(f'{i:3}. {e["title"][:40]:40} {e["label"]}', file=sys.stderr) + + +if __name__ == "__main__": + main() diff --git a/tools/tv/env.sh b/tools/tv/env.sh index aec588d..33a21f0 100755 --- a/tools/tv/env.sh +++ b/tools/tv/env.sh @@ -2,7 +2,7 @@ # Helpers for driving the test TV over adb. Usage: source tools/tv/env.sh # TV_ADB: адрес из «Беспроводной отладки» (порт меняется после перезагрузки ТВ). export JAVA_HOME=${JAVA_HOME:-$HOME/.jdks/jdk-21.0.12.1+1} -TV_ADB=${TV_ADB:-192.168.1.20:34277} +TV_ADB=${TV_ADB:-192.168.1.20:5555} A="$HOME/Android/Sdk/platform-tools/adb -s $TV_ADB" OUT=${OUT:-/tmp/movies-tv}; mkdir -p "$OUT" k() { for x in "$@"; do $A shell input keyevent "$x"; sleep 0.7; done; }