Playback fixes: stall watchdog, DVD and MPEG-2, LAN streaming, Dolby Vision
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
139
app/src/main/kotlin/ru/stenfredd/movies/ui/TourScreen.kt
Normal file
139
app/src/main/kotlin/ru/stenfredd/movies/ui/TourScreen.kt
Normal 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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"),
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,11 @@
|
||||
package ru.stenfredd.movies.playback.exo
|
||||
|
||||
import android.content.Context
|
||||
import android.content.res.Configuration
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.os.SystemClock
|
||||
import android.view.Gravity
|
||||
import android.view.SurfaceView
|
||||
import android.view.TextureView
|
||||
@@ -22,26 +27,38 @@ import androidx.media3.common.VideoSize
|
||||
import androidx.media3.common.text.CueGroup
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.exoplayer.DecoderReuseEvaluation
|
||||
import androidx.media3.exoplayer.DefaultLoadControl
|
||||
import androidx.media3.exoplayer.DefaultRenderersFactory
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import androidx.media3.exoplayer.analytics.AnalyticsListener
|
||||
import androidx.media3.exoplayer.mediacodec.MediaCodecInfo
|
||||
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
|
||||
import androidx.media3.exoplayer.mediacodec.MediaCodecUtil
|
||||
import androidx.media3.exoplayer.audio.AudioSink
|
||||
import androidx.media3.exoplayer.trackselection.DefaultTrackSelector
|
||||
import androidx.media3.ui.AspectRatioFrameLayout
|
||||
import androidx.media3.ui.SubtitleView
|
||||
import io.github.peerless2012.ass.media.AssHandler
|
||||
import io.github.peerless2012.ass.media.AssHandlerConfig
|
||||
import io.github.peerless2012.ass.media.extractor.AssMatroskaExtractor
|
||||
import io.github.peerless2012.ass.media.factory.AssRenderersFactory
|
||||
import io.github.peerless2012.ass.media.parser.AssSubtitleParserFactory
|
||||
import io.github.peerless2012.ass.media.type.AssRenderType
|
||||
import io.github.peerless2012.ass.media.widget.AssSubtitleView
|
||||
import kotlinx.coroutines.channels.BufferOverflow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.roundToInt
|
||||
import org.jellyfin.sdk.model.api.MediaStreamType
|
||||
import org.jellyfin.sdk.model.api.PlayMethod
|
||||
import org.jellyfin.sdk.model.api.VideoRange
|
||||
import org.jellyfin.sdk.model.api.VideoRangeType
|
||||
import ru.stenfredd.movies.playback.api.Codecs
|
||||
import ru.stenfredd.movies.playback.api.DolbyVisionSwitchable
|
||||
import ru.stenfredd.movies.playback.api.EngineError
|
||||
import ru.stenfredd.movies.playback.api.EngineEvent
|
||||
import ru.stenfredd.movies.playback.api.EngineState
|
||||
@@ -53,9 +70,13 @@ import ru.stenfredd.movies.playback.api.embeddedOrdinal
|
||||
import ru.stenfredd.movies.playback.api.videoStream
|
||||
import timber.log.Timber
|
||||
|
||||
class ExoEngine(context: Context) : PlaybackEngine, VideoPathSwitchable {
|
||||
class ExoEngine(context: Context) : PlaybackEngine, VideoPathSwitchable, DolbyVisionSwitchable {
|
||||
override val id = ExoEngineProvider.ID
|
||||
|
||||
/** See [gpuPathFor]: only TVs known to over-process SDR on the video plane. */
|
||||
private val sdrThroughGpu = Build.MANUFACTURER.equals("Xiaomi", ignoreCase = true) &&
|
||||
(context.resources.configuration.uiMode and Configuration.UI_MODE_TYPE_MASK) == Configuration.UI_MODE_TYPE_TELEVISION
|
||||
|
||||
private val trackSelector = DefaultTrackSelector(context).apply {
|
||||
parameters = buildUponParameters()
|
||||
.setTunnelingEnabled(false)
|
||||
@@ -63,20 +84,51 @@ class ExoEngine(context: Context) : PlaybackEngine, VideoPathSwitchable {
|
||||
.build()
|
||||
}
|
||||
|
||||
/**
|
||||
* Full ASS/SSA rendering (styles, positioning, embedded fonts) through libass (ass-media), drawn by an overlay
|
||||
* view above the video. Without it media3 turns ASS into plain text cues.
|
||||
*/
|
||||
private val assHandler = AssHandler(AssRenderType.OVERLAY_OPEN_GL, AssHandlerConfig(cacheSize = ASS_CACHE_MB, maxRenderPixels = 1920 * 1080))
|
||||
private val assParserFactory = AssSubtitleParserFactory(assHandler)
|
||||
|
||||
private val player: ExoPlayer = ExoPlayer.Builder(
|
||||
context,
|
||||
DefaultRenderersFactory(context)
|
||||
.setMediaCodecSelector(::decoderInfos)
|
||||
.setEnableDecoderFallback(true)
|
||||
.setExtensionRendererMode(DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON),
|
||||
AssRenderersFactory(
|
||||
assHandler,
|
||||
DefaultRenderersFactory(context)
|
||||
.setMediaCodecSelector(::decoderInfos)
|
||||
.setEnableDecoderFallback(true)
|
||||
.setExtensionRendererMode(DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON),
|
||||
),
|
||||
)
|
||||
.setTrackSelector(trackSelector)
|
||||
.setLoadControl(
|
||||
DefaultLoadControl.Builder()
|
||||
.setTargetBufferBytes(bufferBytes())
|
||||
.setPrioritizeTimeOverSizeThresholds(false)
|
||||
.build(),
|
||||
)
|
||||
.setMediaSourceFactory(
|
||||
DefaultMediaSourceFactory(context, ExoExtractorsFactory { AssMatroskaExtractor(assParserFactory, assHandler) })
|
||||
// External ASS files are parsed by this factory directly.
|
||||
.setSubtitleParserFactory(assParserFactory),
|
||||
)
|
||||
.setAudioAttributes(
|
||||
AudioAttributes.Builder().setUsage(C.USAGE_MEDIA).setContentType(C.AUDIO_CONTENT_TYPE_MOVIE).build(),
|
||||
true,
|
||||
)
|
||||
.build()
|
||||
|
||||
/**
|
||||
* The default target (~145 MB for video) is sized for phones with big heaps: this TV gives the app 192 MB
|
||||
* (512 MB with largeHeap), and once the buffer filled up after a few engine switches GC thrashed, playback
|
||||
* stuttered and the app died with OutOfMemoryError. A quarter of the heap, at most 64 MB: still ~8 s of a 60 Mbit/s 4K remux, plenty over the LAN.
|
||||
*/
|
||||
private fun bufferBytes(): Int {
|
||||
val heapMb = (Runtime.getRuntime().maxMemory() shr 20).toInt()
|
||||
return (heapMb / 4).coerceIn(MIN_BUFFER_MB, MAX_BUFFER_MB) shl 20
|
||||
}
|
||||
|
||||
private val _state = MutableStateFlow(EngineState())
|
||||
override val state = _state.asStateFlow()
|
||||
private val _events = MutableSharedFlow<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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
188
tools/tour/build_tour.py
Normal 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()
|
||||
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user