Playback fixes: stall watchdog, DVD and MPEG-2, LAN streaming, Dolby Vision

This commit is contained in:
2026-10-14 09:24:35 +03:00
parent dec4b44571
commit 69a793abcb
28 changed files with 2788 additions and 83 deletions

View File

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

View File

@@ -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 <id> [--es engine exo|vlc] [--el start <ms>]`.
* `adb shell am start -S -n ru.stenfredd.movies/.MainActivity --es item <id> [--es engine exo|vlc] [--el start <ms>]
* [--ei audio <index>] [--ei sub <index>]` (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<Screen>? {
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()
}
}

View File

@@ -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<String?>(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()

View File

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

View File

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

View File

@@ -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<TourEntry> = 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))
}
}
}

View File

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

View File

@@ -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://<ip>: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<String> {
val found = mutableListOf<String>()
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()
}
}
}

View File

@@ -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"),
}

View File

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

View File

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

View File

@@ -27,7 +27,7 @@ class EngineSelector @Inject constructor(
providers.map { RankedEngine(it, it.evaluate(source, selection, device)) }
.sortedWith(compareBy<RankedEngine> { 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

View File

@@ -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<RankedEngine> = emptyList()
private val tried = mutableSetOf<EngineId>()
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<Int>()
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
}
}

View File

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

View File

@@ -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).
*
* <p>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.
*
* <p>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<Ac3Reader> 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;
}
}

View File

@@ -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.
*
* <p>media3's {@code PsExtractor} with two fixes for DVD rips (VOB/MPG):
*
* <ul>
* <li>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.
* <li>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.
* </ul>
*/
@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<PesReader> 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.
*
* <p>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);
}
}
}
}

View File

@@ -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.
*
* <p>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.
*
* <p>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);
}
}

View File

@@ -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).
*
* <p>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.
*
* <p>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.
*
* <p>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.
*
* <p>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;
}
}

View File

@@ -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<FramingTrackOutput>()
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())
}
}
}

View File

@@ -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,
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<EngineEvent>(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<MediaCodecInfo> {
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
}
}

View File

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

View File

@@ -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<FixingTrackOutput>()
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)
}
}
}

View File

@@ -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<Extractor> = wrap(inner.createExtractors())
override fun createExtractors(uri: Uri, responseHeaders: Map<String, List<String>>): Array<Extractor> =
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<Extractor>): Array<Extractor> =
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<Patch> = 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<SeekEntry> {
val entries = mutableListOf<SeekEntry>()
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<Patch>) : 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
}
}

View File

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

View File

@@ -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",
)
}
}

View File

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

188
tools/tour/build_tour.py Normal file
View File

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

View File

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