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:allowBackup="false"
|
||||||
android:banner="@drawable/banner"
|
android:banner="@drawable/banner"
|
||||||
android:icon="@drawable/banner"
|
android:icon="@drawable/banner"
|
||||||
|
android:largeHeap="true"
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
android:networkSecurityConfig="@xml/network_security_config"
|
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.LoginScreen
|
||||||
import ru.stenfredd.movies.ui.PlayerScreen
|
import ru.stenfredd.movies.ui.PlayerScreen
|
||||||
import ru.stenfredd.movies.ui.Screen
|
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.EngineId
|
||||||
|
import ru.stenfredd.movies.playback.api.StreamSelection
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@@ -64,6 +66,7 @@ class MainActivity : ComponentActivity() {
|
|||||||
is Screen.Items -> ItemsScreen(screen, onOpen = ::open)
|
is Screen.Items -> ItemsScreen(screen, onOpen = ::open)
|
||||||
is Screen.Detail -> DetailScreen(screen, onOpen = ::open)
|
is Screen.Detail -> DetailScreen(screen, onOpen = ::open)
|
||||||
is Screen.Player -> PlayerScreen(screen, onExit = { stack.removeAt(stack.lastIndex) })
|
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:
|
* 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>? {
|
private fun debugTarget(): List<Screen>? {
|
||||||
if (!BuildConfig.DEBUG) return null
|
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 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 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 engine = intent.getStringExtra("engine")?.let(::EngineId)
|
||||||
val play = intent.hasExtra("engine") || intent.getBooleanExtra("play", false)
|
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.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.text.input.KeyboardType
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
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 query by remember { mutableStateOf("") }
|
||||||
var error by remember { mutableStateOf<String?>(null) }
|
var error by remember { mutableStateOf<String?>(null) }
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
val context = LocalContext.current
|
||||||
|
val tourAvailable = remember { TourEntry.available(context) }
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
runCatching {
|
runCatching {
|
||||||
@@ -70,6 +73,7 @@ fun HomeScreen(onOpen: (Screen) -> Unit, onLogout: () -> Unit, vm: BrowseViewMod
|
|||||||
item {
|
item {
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
|
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||||
views.forEach { v -> Button(onClick = { onOpen(Screen.Items(v.id, v.name.orEmpty())) }) { Text(v.name.orEmpty()) } }
|
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 = {
|
Button(onClick = {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
vm.session.logout()
|
vm.session.logout()
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import androidx.compose.foundation.layout.fillMaxSize
|
|||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.layout.widthIn
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.DisposableEffect
|
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.onPreviewKeyEvent
|
||||||
import androidx.compose.ui.input.key.key
|
import androidx.compose.ui.input.key.key
|
||||||
import androidx.compose.ui.input.key.type
|
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.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.compose.ui.viewinterop.AndroidView
|
import androidx.compose.ui.viewinterop.AndroidView
|
||||||
@@ -45,7 +48,10 @@ import androidx.tv.material3.Text
|
|||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import org.jellyfin.sdk.model.api.MediaStreamType
|
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.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.EngineState
|
||||||
import ru.stenfredd.movies.playback.api.VideoPathSwitchable
|
import ru.stenfredd.movies.playback.api.VideoPathSwitchable
|
||||||
import ru.stenfredd.movies.playback.core.DeviceCapabilitiesProbe
|
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.PlaybackSessionFactory
|
||||||
import ru.stenfredd.movies.playback.core.PlaybackUiState
|
import ru.stenfredd.movies.playback.core.PlaybackUiState
|
||||||
import ru.stenfredd.movies.playback.core.describe
|
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
|
import javax.inject.Inject
|
||||||
|
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
@@ -69,26 +77,36 @@ class PlayerViewModel @Inject constructor(
|
|||||||
release()
|
release()
|
||||||
return factory.create(viewModelScope).also {
|
return factory.create(viewModelScope).also {
|
||||||
session = it
|
session = it
|
||||||
it.start(screen.itemId, screen.engine, screen.startPositionMs)
|
it.start(screen.itemId, screen.engine, screen.startPositionMs, screen.streams, screen.startFraction)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun release() {
|
/** Releases [target] (default: the current session); a stale screen disposing late can't kill a newer session. */
|
||||||
session?.release()
|
fun release(target: PlaybackSession? = session) {
|
||||||
session = null
|
target?.release()
|
||||||
|
if (target === session) session = null
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onCleared() = release()
|
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
|
@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) }
|
val session = remember(screen) { vm.start(screen) }
|
||||||
DisposableEffect(session) { onDispose { vm.release() } }
|
DisposableEffect(session) { onDispose { vm.release(session) } }
|
||||||
val state by session.state.collectAsStateWithLifecycle()
|
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 showMenu by remember { mutableStateOf(false) }
|
||||||
|
var tourStep by remember { mutableIntStateOf(0) }
|
||||||
var barTick by remember { mutableIntStateOf(0) }
|
var barTick by remember { mutableIntStateOf(0) }
|
||||||
var showBar by remember { mutableStateOf(true) }
|
var showBar by remember { mutableStateOf(true) }
|
||||||
val rootFocus = remember { FocusRequester() }
|
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_MEDIA_PLAY_PAUSE, AndroidKeyEvent.KEYCODE_SPACE -> session.togglePlayPause()
|
||||||
AndroidKeyEvent.KEYCODE_DPAD_LEFT, AndroidKeyEvent.KEYCODE_MEDIA_REWIND -> session.seekBy(-10_000)
|
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_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
|
AndroidKeyEvent.KEYCODE_DPAD_DOWN, AndroidKeyEvent.KEYCODE_MENU -> showMenu = true
|
||||||
else -> return@onPreviewKeyEvent false
|
else -> return@onPreviewKeyEvent false
|
||||||
}
|
}
|
||||||
@@ -130,16 +155,17 @@ fun PlayerScreen(screen: Screen.Player, onExit: () -> Unit, vm: PlayerViewModel
|
|||||||
onRelease = { session.detachVideo() },
|
onRelease = { session.detachVideo() },
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
)
|
)
|
||||||
if (showDebug) DebugPanel(state, vm, Modifier.align(Alignment.TopStart))
|
if (overlay == 1) DebugPanel(state, vm, Modifier.align(Alignment.TopStart))
|
||||||
if (showBar || state.loading || state.error != null) StatusBar(state, Modifier.align(Alignment.BottomCenter))
|
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) {
|
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
|
@Composable
|
||||||
private fun StatusBar(state: PlaybackUiState, modifier: Modifier) {
|
private fun StatusBar(state: PlaybackUiState, touring: Boolean, modifier: Modifier) {
|
||||||
Column(
|
Column(
|
||||||
modifier.fillMaxWidth().background(Color(0xAA000000)).padding(24.dp, 12.dp),
|
modifier.fillMaxWidth().background(Color(0xAA000000)).padding(24.dp, 12.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(4.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)}")
|
Text("$phase ${state.title} ${formatTime(state.positionMs)} / ${formatTime(state.durationMs)}")
|
||||||
state.error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
|
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
|
@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
|
val source = state.source ?: return
|
||||||
fun mark(selected: Boolean) = if (selected) "● " else " "
|
fun mark(selected: Boolean) = if (selected) "● " else " "
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
modifier.fillMaxHeight().width(520.dp).background(Color(0xE0101010)).padding(16.dp),
|
modifier.fillMaxHeight().width(520.dp).background(Color(0xE0101010)).padding(16.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(6.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) }
|
item { Text("Аудио", style = MaterialTheme.typography.titleMedium) }
|
||||||
source.mediaStreams.orEmpty().filter { it.type == MediaStreamType.AUDIO }.forEach { s ->
|
source.mediaStreams.orEmpty().filter { it.type == MediaStreamType.AUDIO }.forEach { s ->
|
||||||
item {
|
item {
|
||||||
@@ -209,9 +337,9 @@ private fun Menu(state: PlaybackUiState, session: PlaybackSession, vm: PlayerVie
|
|||||||
}
|
}
|
||||||
item { Text("Движок", style = MaterialTheme.typography.titleMedium) }
|
item { Text("Движок", style = MaterialTheme.typography.titleMedium) }
|
||||||
vm.selector.engines.forEach { p ->
|
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 ->
|
(session.activeEngine as? VideoPathSwitchable)?.let { engine ->
|
||||||
item { Text("Вывод видео", style = MaterialTheme.typography.titleMedium) }
|
item { Text("Вывод видео", style = MaterialTheme.typography.titleMedium) }
|
||||||
val modes = listOf(
|
val modes = listOf(
|
||||||
@@ -220,7 +348,11 @@ private fun Menu(state: PlaybackUiState, session: PlaybackSession, vm: PlayerVie
|
|||||||
VideoPathSwitchable.Mode.GPU to "Через GPU, как VLC (без HDR, до 1080p)",
|
VideoPathSwitchable.Mode.GPU to "Через GPU, как VLC (без HDR, до 1080p)",
|
||||||
)
|
)
|
||||||
modes.forEach { (mode, label) ->
|
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
|
package ru.stenfredd.movies.ui
|
||||||
|
|
||||||
import ru.stenfredd.movies.playback.api.EngineId
|
import ru.stenfredd.movies.playback.api.EngineId
|
||||||
|
import ru.stenfredd.movies.playback.api.StreamSelection
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
sealed interface Screen {
|
sealed interface Screen {
|
||||||
@@ -8,5 +9,16 @@ sealed interface Screen {
|
|||||||
data object Home : Screen
|
data object Home : Screen
|
||||||
data class Items(val parentId: UUID, val title: String) : Screen
|
data class Items(val parentId: UUID, val title: String) : Screen
|
||||||
data class Detail(val itemId: UUID) : 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.content.Context
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
|
import android.os.SystemClock
|
||||||
import androidx.datastore.preferences.core.edit
|
import androidx.datastore.preferences.core.edit
|
||||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||||
import androidx.datastore.preferences.preferencesDataStore
|
import androidx.datastore.preferences.preferencesDataStore
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.flow.first
|
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.ApiClient
|
||||||
import org.jellyfin.sdk.api.client.extensions.authenticateUserByName
|
import org.jellyfin.sdk.api.client.extensions.authenticateUserByName
|
||||||
import org.jellyfin.sdk.api.client.extensions.authenticationApi
|
import org.jellyfin.sdk.api.client.extensions.authenticationApi
|
||||||
import org.jellyfin.sdk.createJellyfin
|
import org.jellyfin.sdk.createJellyfin
|
||||||
import org.jellyfin.sdk.model.ClientInfo
|
import org.jellyfin.sdk.model.ClientInfo
|
||||||
import org.jellyfin.sdk.model.DeviceInfo
|
import org.jellyfin.sdk.model.DeviceInfo
|
||||||
import kotlinx.coroutines.withContext
|
import timber.log.Timber
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
@@ -49,6 +54,30 @@ class JellyfinSession @Inject constructor(
|
|||||||
|
|
||||||
val deviceId: String get() = api.deviceInfo.id
|
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. */
|
/** Restores the saved session. Returns false when the user has to log in. */
|
||||||
suspend fun restore(): Boolean {
|
suspend fun restore(): Boolean {
|
||||||
val prefs = context.sessionStore.data.first()
|
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
|
val userId = prefs[KEY_USER_ID]?.let(UUID::fromString) ?: return false
|
||||||
api.update(baseUrl = url, accessToken = token)
|
api.update(baseUrl = url, accessToken = token)
|
||||||
_session.value = SessionInfo(url, userId, prefs[KEY_USER_NAME].orEmpty(), token)
|
_session.value = SessionInfo(url, userId, prefs[KEY_USER_NAME].orEmpty(), token)
|
||||||
|
refreshLocalAddress()
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,6 +105,9 @@ class JellyfinSession @Inject constructor(
|
|||||||
it[KEY_USER_NAME] = user.name.orEmpty()
|
it[KEY_USER_NAME] = user.name.orEmpty()
|
||||||
}
|
}
|
||||||
_session.value = SessionInfo(url, user.id, user.name.orEmpty(), token)
|
_session.value = SessionInfo(url, user.id, user.name.orEmpty(), token)
|
||||||
|
localUrl = null
|
||||||
|
localCheckedAt = 0
|
||||||
|
refreshLocalAddress()
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun logout() {
|
suspend fun logout() {
|
||||||
@@ -91,10 +124,18 @@ class JellyfinSession @Inject constructor(
|
|||||||
|
|
||||||
fun requireSession(): SessionInfo = checkNotNull(_session.value) { "Нет активной сессии" }
|
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 {
|
fun authorizedUrl(pathOrUrl: String): String {
|
||||||
val s = requireSession()
|
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
|
if (absolute.contains("ApiKey=", ignoreCase = true) || absolute.contains("api_key=")) return absolute
|
||||||
val separator = if ('?' in absolute) '&' else '?'
|
val separator = if ('?' in absolute) '&' else '?'
|
||||||
return "$absolute${separator}${ApiClient.QUERY_ACCESS_TOKEN}=${s.accessToken}"
|
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_ID = stringPreferencesKey("user_id")
|
||||||
val KEY_USER_NAME = stringPreferencesKey("user_name")
|
val KEY_USER_NAME = stringPreferencesKey("user_name")
|
||||||
val KEY_DEVICE_ID = stringPreferencesKey("device_id")
|
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, "программное декодирование"),
|
SOFTWARE_DECODE(15, "программное декодирование"),
|
||||||
DV_TO_BASE_LAYER(30, "Dolby Vision → базовый слой"),
|
DV_TO_BASE_LAYER(30, "Dolby Vision → базовый слой"),
|
||||||
HDR_UNVERIFIED(25, "HDR-вывод не проверен"),
|
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. */
|
/** Small: TV SoCs usually deinterlace in the display pipeline after the hardware decoder. */
|
||||||
NO_DEINTERLACE(5, "без деинтерлейса в плеере"),
|
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,
|
AUDIO_OUTPUT,
|
||||||
NETWORK,
|
NETWORK,
|
||||||
NO_VIDEO_OUTPUT,
|
NO_VIDEO_OUTPUT,
|
||||||
|
/** Playing but not progressing, or dropping a large share of frames (seen by the session's stall watchdog). */
|
||||||
|
STALLED,
|
||||||
UNKNOWN,
|
UNKNOWN,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -57,6 +57,14 @@ interface VideoPathSwitchable {
|
|||||||
fun setVideoPathMode(mode: Mode)
|
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 {
|
interface SubtitleDelayCapable {
|
||||||
fun setSubtitleDelay(delayMs: Long)
|
fun setSubtitleDelay(delayMs: Long)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ class EngineSelector @Inject constructor(
|
|||||||
providers.map { RankedEngine(it, it.evaluate(source, selection, device)) }
|
providers.map { RankedEngine(it, it.evaluate(source, selection, device)) }
|
||||||
.sortedWith(compareBy<RankedEngine> { score(it.verdict) }.thenBy { it.provider.priority })
|
.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.Direct -> 0
|
||||||
is Verdict.Degraded -> verdict.losses.sumOf(Loss::weight)
|
is Verdict.Degraded -> verdict.losses.sumOf(Loss::weight)
|
||||||
is Verdict.No -> 10_000
|
is Verdict.No -> 10_000
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
package ru.stenfredd.movies.playback.core
|
package ru.stenfredd.movies.playback.core
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.SystemClock
|
||||||
import android.widget.FrameLayout
|
import android.widget.FrameLayout
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
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.RepeatMode
|
||||||
import org.jellyfin.sdk.model.api.SubtitleDeliveryMethod
|
import org.jellyfin.sdk.model.api.SubtitleDeliveryMethod
|
||||||
import ru.stenfredd.movies.core.jellyfin.JellyfinSession
|
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.EngineError
|
||||||
import ru.stenfredd.movies.playback.api.EngineEvent
|
import ru.stenfredd.movies.playback.api.EngineEvent
|
||||||
import ru.stenfredd.movies.playback.api.EngineId
|
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.PlaybackEngine
|
||||||
import ru.stenfredd.movies.playback.api.StreamSelection
|
import ru.stenfredd.movies.playback.api.StreamSelection
|
||||||
import ru.stenfredd.movies.playback.api.Verdict
|
import ru.stenfredd.movies.playback.api.Verdict
|
||||||
|
import ru.stenfredd.movies.playback.api.VideoPathSwitchable
|
||||||
import ru.stenfredd.movies.playback.api.videoStream
|
import ru.stenfredd.movies.playback.api.videoStream
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
@@ -105,6 +109,13 @@ class PlaybackSession(
|
|||||||
private var ranking: List<RankedEngine> = emptyList()
|
private var ranking: List<RankedEngine> = emptyList()
|
||||||
private val tried = mutableSetOf<EngineId>()
|
private val tried = mutableSetOf<EngineId>()
|
||||||
private var transcodeTried = false
|
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 playSessionId: String? = null
|
||||||
private var playMethod: PlayMethod = PlayMethod.DIRECT_PLAY
|
private var playMethod: PlayMethod = PlayMethod.DIRECT_PLAY
|
||||||
@@ -121,14 +132,16 @@ class PlaybackSession(
|
|||||||
container = null
|
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 {
|
scope.launch {
|
||||||
runCatching {
|
runCatching {
|
||||||
val user = session.requireSession()
|
val user = session.requireSession()
|
||||||
|
session.refreshLocalAddress()
|
||||||
withContext(Dispatchers.Default) { probe.capabilities }
|
withContext(Dispatchers.Default) { probe.capabilities }
|
||||||
item = session.call { libraryApi.getItem(itemId, user.userId).content }
|
item = session.call { libraryApi.getItem(itemId, user.userId).content }
|
||||||
source = item.mediaSources?.firstOrNull() ?: error("У элемента нет медиаисточников")
|
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)
|
ranking = selector.rank(source, selection, device)
|
||||||
_state.update {
|
_state.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
@@ -141,7 +154,8 @@ class PlaybackSession(
|
|||||||
log("Ранжирование: " + ranking.joinToString { "${it.provider.id}=${it.verdict.describe()}" })
|
log("Ранжирование: " + ranking.joinToString { "${it.provider.id}=${it.verdict.describe()}" })
|
||||||
val first = forcedEngine?.let(selector::provider) ?: ranking.first().provider
|
val first = forcedEngine?.let(selector::provider) ?: ranking.first().provider
|
||||||
val reason = if (forcedEngine != null) "выбран вручную" else ranking.first().verdict.describe()
|
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) }
|
}.onFailure { fail("Не удалось начать воспроизведение", it) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -152,7 +166,7 @@ class PlaybackSession(
|
|||||||
reportProgressAsync()
|
reportProgressAsync()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun seekBy(deltaMs: Long) = seekTo((engine?.positionMs ?: 0) + deltaMs)
|
fun seekBy(deltaMs: Long) = seekTo(positionMs() + deltaMs)
|
||||||
|
|
||||||
fun seekTo(positionMs: Long) {
|
fun seekTo(positionMs: Long) {
|
||||||
val e = engine ?: return
|
val e = engine ?: return
|
||||||
@@ -169,34 +183,74 @@ class PlaybackSession(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
lastSeekAt = SystemClock.elapsedRealtime()
|
||||||
e.seekTo(target)
|
e.seekTo(target)
|
||||||
reportProgressAsync()
|
reportProgressAsync()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun selectAudio(streamIndex: Int) {
|
fun selectAudio(streamIndex: Int) {
|
||||||
selection = selection.copy(audioStreamIndex = streamIndex)
|
selection = selection.copy(audioStreamIndex = streamIndex)
|
||||||
_state.update { it.copy(selection = selection) }
|
onSelectionChanged("смена аудио") { it.selectAudio(streamIndex) }
|
||||||
if (playMethod == PlayMethod.DIRECT_PLAY) engine?.selectAudio(streamIndex) else restartCurrent("смена аудио")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun selectSubtitle(streamIndex: Int?) {
|
fun selectSubtitle(streamIndex: Int?) {
|
||||||
selection = selection.copy(subtitleStreamIndex = streamIndex)
|
selection = selection.copy(subtitleStreamIndex = streamIndex)
|
||||||
_state.update { it.copy(selection = selection) }
|
onSelectionChanged("смена субтитров") { it.selectSubtitle(streamIndex) }
|
||||||
if (playMethod == PlayMethod.DIRECT_PLAY) engine?.selectSubtitle(streamIndex) else restartCurrent("смена субтитров")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
scope.launch {
|
||||||
tried.clear()
|
tried.clear()
|
||||||
transcodeTried = false
|
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() {
|
fun forceTranscode() {
|
||||||
val position = engine?.positionMs ?: 0
|
val position = positionMs()
|
||||||
val provider = engine?.id?.let(selector::provider) ?: return
|
val provider = engine?.id?.let(selector::provider) ?: return
|
||||||
|
manualEngine = true
|
||||||
scope.launch { launchEngine(provider, position, forceTranscode = true, reason = "транскод вручную") }
|
scope.launch { launchEngine(provider, position, forceTranscode = true, reason = "транскод вручную") }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -211,7 +265,7 @@ class PlaybackSession(
|
|||||||
|
|
||||||
private fun restartCurrent(why: String) {
|
private fun restartCurrent(why: String) {
|
||||||
val provider = engine?.id?.let(selector::provider) ?: return
|
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) }
|
scope.launch { launchEngine(provider, position, forceTranscode = playMethod != PlayMethod.DIRECT_PLAY, reason = why) }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -220,10 +274,13 @@ class PlaybackSession(
|
|||||||
engineJobs.forEach(Job::cancel)
|
engineJobs.forEach(Job::cancel)
|
||||||
engineJobs.clear()
|
engineJobs.clear()
|
||||||
engine?.let { old ->
|
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()
|
old.release()
|
||||||
}
|
}
|
||||||
engine = null
|
engine = null
|
||||||
|
launchPositionMs = startMs
|
||||||
tried += provider.id
|
tried += provider.id
|
||||||
if (forceTranscode) transcodeTried = true
|
if (forceTranscode) transcodeTried = true
|
||||||
_state.update { it.copy(loading = true, error = null, engine = provider.id, engineReason = reason) }
|
_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) }
|
val transcodeReasons = ms.transcodingUrl?.let { Regex("TranscodeReasons=([^&]+)").find(it)?.groupValues?.get(1) }
|
||||||
?.let { java.net.URLDecoder.decode(it, "UTF-8") }
|
?.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()
|
val external = ms.mediaStreams.orEmpty()
|
||||||
.filter { it.type == MediaStreamType.SUBTITLE && it.deliveryMethod == SubtitleDeliveryMethod.EXTERNAL && it.deliveryUrl != null }
|
.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) }
|
.map { ExternalSubtitle(it.index, session.authorizedUrl(it.deliveryUrl!!), it.codec, it.language, it.displayTitle) }
|
||||||
|
|
||||||
val newEngine = provider.create(device)
|
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
|
engine = newEngine
|
||||||
firstFrameSeen = false
|
firstFrameSeen = false
|
||||||
container?.let(newEngine::attachVideo)
|
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 { newEngine.stats.collect { s -> _state.update { it.copy(stats = s) } } }
|
||||||
engineJobs += scope.launch { tickLoop(gen) }
|
engineJobs += scope.launch { tickLoop(gen) }
|
||||||
engineJobs += scope.launch { firstFrameWatchdog(gen) }
|
engineJobs += scope.launch { firstFrameWatchdog(gen) }
|
||||||
|
engineJobs += scope.launch { stallWatchdog(gen) }
|
||||||
|
|
||||||
_state.update { it.copy(playMethod = playMethod, transcodeReasons = transcodeReasons) }
|
_state.update { it.copy(playMethod = playMethod, transcodeReasons = transcodeReasons) }
|
||||||
newEngine.load(
|
newEngine.load(
|
||||||
@@ -311,6 +375,18 @@ class PlaybackSession(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var firstFrameSeen = false
|
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) {
|
private suspend fun firstFrameWatchdog(gen: Int) {
|
||||||
if (source.videoStream == null) return
|
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) {
|
private fun onEngineEvent(gen: Int, event: EngineEvent) {
|
||||||
if (gen != generation) return
|
if (gen != generation) return
|
||||||
when (event) {
|
when (event) {
|
||||||
@@ -329,7 +451,7 @@ class PlaybackSession(
|
|||||||
}
|
}
|
||||||
is EngineEvent.Error -> {
|
is EngineEvent.Error -> {
|
||||||
val failedEngine = engine?.id
|
val failedEngine = engine?.id
|
||||||
val position = engine?.positionMs ?: 0
|
val position = positionMs()
|
||||||
log("Ошибка $failedEngine: ${event.error.kind} — ${event.error.message}")
|
log("Ошибка $failedEngine: ${event.error.kind} — ${event.error.message}")
|
||||||
Timber.w(event.error.cause, "Engine %s failed: %s", failedEngine, event.error.message)
|
Timber.w(event.error.cause, "Engine %s failed: %s", failedEngine, event.error.message)
|
||||||
val next = if (event.error.worthFallback) {
|
val next = if (event.error.worthFallback) {
|
||||||
@@ -354,7 +476,7 @@ class PlaybackSession(
|
|||||||
var sinceReport = 0L
|
var sinceReport = 0L
|
||||||
while (scope.isActive && gen == generation) {
|
while (scope.isActive && gen == generation) {
|
||||||
val e = engine ?: return
|
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)
|
delay(TICK_MS)
|
||||||
sinceReport += TICK_MS
|
sinceReport += TICK_MS
|
||||||
if (sinceReport >= PROGRESS_INTERVAL_MS) {
|
if (sinceReport >= PROGRESS_INTERVAL_MS) {
|
||||||
@@ -468,5 +590,11 @@ class PlaybackSession(
|
|||||||
const val TICK_MS = 500L
|
const val TICK_MS = 500L
|
||||||
const val PROGRESS_INTERVAL_MS = 10_000L
|
const val PROGRESS_INTERVAL_MS = 10_000L
|
||||||
const val FIRST_FRAME_TIMEOUT_MS = 60_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)
|
||||||
implementation(libs.media3.exoplayer.hls)
|
implementation(libs.media3.exoplayer.hls)
|
||||||
implementation(libs.media3.ui)
|
implementation(libs.media3.ui)
|
||||||
|
implementation(libs.ass.media)
|
||||||
|
implementation(libs.jellyfin.ffmpeg)
|
||||||
implementation(libs.hilt.android)
|
implementation(libs.hilt.android)
|
||||||
ksp(libs.hilt.compiler)
|
ksp(libs.hilt.compiler)
|
||||||
implementation(libs.timber)
|
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
|
package ru.stenfredd.movies.playback.exo
|
||||||
|
|
||||||
import android.content.Context
|
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.Gravity
|
||||||
import android.view.SurfaceView
|
import android.view.SurfaceView
|
||||||
import android.view.TextureView
|
import android.view.TextureView
|
||||||
@@ -22,26 +27,38 @@ import androidx.media3.common.VideoSize
|
|||||||
import androidx.media3.common.text.CueGroup
|
import androidx.media3.common.text.CueGroup
|
||||||
import androidx.media3.common.util.UnstableApi
|
import androidx.media3.common.util.UnstableApi
|
||||||
import androidx.media3.exoplayer.DecoderReuseEvaluation
|
import androidx.media3.exoplayer.DecoderReuseEvaluation
|
||||||
|
import androidx.media3.exoplayer.DefaultLoadControl
|
||||||
import androidx.media3.exoplayer.DefaultRenderersFactory
|
import androidx.media3.exoplayer.DefaultRenderersFactory
|
||||||
import androidx.media3.exoplayer.ExoPlayer
|
import androidx.media3.exoplayer.ExoPlayer
|
||||||
import androidx.media3.exoplayer.analytics.AnalyticsListener
|
import androidx.media3.exoplayer.analytics.AnalyticsListener
|
||||||
import androidx.media3.exoplayer.mediacodec.MediaCodecInfo
|
import androidx.media3.exoplayer.mediacodec.MediaCodecInfo
|
||||||
|
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
|
||||||
import androidx.media3.exoplayer.mediacodec.MediaCodecUtil
|
import androidx.media3.exoplayer.mediacodec.MediaCodecUtil
|
||||||
import androidx.media3.exoplayer.audio.AudioSink
|
import androidx.media3.exoplayer.audio.AudioSink
|
||||||
import androidx.media3.exoplayer.trackselection.DefaultTrackSelector
|
import androidx.media3.exoplayer.trackselection.DefaultTrackSelector
|
||||||
import androidx.media3.ui.AspectRatioFrameLayout
|
import androidx.media3.ui.AspectRatioFrameLayout
|
||||||
import androidx.media3.ui.SubtitleView
|
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.channels.BufferOverflow
|
||||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.asSharedFlow
|
import kotlinx.coroutines.flow.asSharedFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.flow.update
|
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.MediaStreamType
|
||||||
import org.jellyfin.sdk.model.api.PlayMethod
|
import org.jellyfin.sdk.model.api.PlayMethod
|
||||||
import org.jellyfin.sdk.model.api.VideoRange
|
import org.jellyfin.sdk.model.api.VideoRange
|
||||||
import org.jellyfin.sdk.model.api.VideoRangeType
|
import org.jellyfin.sdk.model.api.VideoRangeType
|
||||||
import ru.stenfredd.movies.playback.api.Codecs
|
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.EngineError
|
||||||
import ru.stenfredd.movies.playback.api.EngineEvent
|
import ru.stenfredd.movies.playback.api.EngineEvent
|
||||||
import ru.stenfredd.movies.playback.api.EngineState
|
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 ru.stenfredd.movies.playback.api.videoStream
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
|
|
||||||
class ExoEngine(context: Context) : PlaybackEngine, VideoPathSwitchable {
|
class ExoEngine(context: Context) : PlaybackEngine, VideoPathSwitchable, DolbyVisionSwitchable {
|
||||||
override val id = ExoEngineProvider.ID
|
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 {
|
private val trackSelector = DefaultTrackSelector(context).apply {
|
||||||
parameters = buildUponParameters()
|
parameters = buildUponParameters()
|
||||||
.setTunnelingEnabled(false)
|
.setTunnelingEnabled(false)
|
||||||
@@ -63,20 +84,51 @@ class ExoEngine(context: Context) : PlaybackEngine, VideoPathSwitchable {
|
|||||||
.build()
|
.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(
|
private val player: ExoPlayer = ExoPlayer.Builder(
|
||||||
context,
|
context,
|
||||||
DefaultRenderersFactory(context)
|
AssRenderersFactory(
|
||||||
.setMediaCodecSelector(::decoderInfos)
|
assHandler,
|
||||||
.setEnableDecoderFallback(true)
|
DefaultRenderersFactory(context)
|
||||||
.setExtensionRendererMode(DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON),
|
.setMediaCodecSelector(::decoderInfos)
|
||||||
|
.setEnableDecoderFallback(true)
|
||||||
|
.setExtensionRendererMode(DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
.setTrackSelector(trackSelector)
|
.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(
|
.setAudioAttributes(
|
||||||
AudioAttributes.Builder().setUsage(C.USAGE_MEDIA).setContentType(C.AUDIO_CONTENT_TYPE_MOVIE).build(),
|
AudioAttributes.Builder().setUsage(C.USAGE_MEDIA).setContentType(C.AUDIO_CONTENT_TYPE_MOVIE).build(),
|
||||||
true,
|
true,
|
||||||
)
|
)
|
||||||
.build()
|
.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())
|
private val _state = MutableStateFlow(EngineState())
|
||||||
override val state = _state.asStateFlow()
|
override val state = _state.asStateFlow()
|
||||||
private val _events = MutableSharedFlow<EngineEvent>(extraBufferCapacity = 16, onBufferOverflow = BufferOverflow.DROP_OLDEST)
|
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))
|
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 {
|
private val analytics = object : AnalyticsListener {
|
||||||
override fun onVideoDecoderInitialized(eventTime: AnalyticsListener.EventTime, decoderName: String, initializedTimestampMs: Long, initializationDurationMs: Long) {
|
override fun onVideoDecoderInitialized(eventTime: AnalyticsListener.EventTime, decoderName: String, initializedTimestampMs: Long, initializationDurationMs: Long) {
|
||||||
_stats.update { it.copy(videoDecoder = decoderName) }
|
_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) {
|
override fun onDroppedVideoFrames(eventTime: AnalyticsListener.EventTime, droppedFrames: Int, elapsedMs: Long) {
|
||||||
_stats.update { it.copy(droppedFrames = it.droppedFrames + droppedFrames) }
|
_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) {
|
override fun onAudioTrackInitialized(eventTime: AnalyticsListener.EventTime, audioTrackConfig: AudioSink.AudioTrackConfig) {
|
||||||
val c = audioTrackConfig
|
val c = audioTrackConfig
|
||||||
val kind = if (c.encoding == C.ENCODING_PCM_16BIT || c.encoding == C.ENCODING_PCM_FLOAT) "PCM (декодировано)" else "passthrough"
|
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)
|
* Chosen in the player menu or by a variant key. Per engine: a new playback starts in AUTO, within one playback
|
||||||
player.addAnalyticsListener(analytics)
|
* PlaybackSession carries it over to the next engine instance (fallback, restart).
|
||||||
applyVideoPath()
|
*/
|
||||||
}
|
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 val videoPathMode get() = pathMode
|
||||||
override var usingGpuPath = false
|
override var usingGpuPath = false
|
||||||
private set
|
private set
|
||||||
private var pathApplied = false
|
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) {
|
override fun setVideoPathMode(mode: VideoPathSwitchable.Mode) {
|
||||||
pathMode = mode
|
pathMode = mode
|
||||||
applyVideoPath()
|
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
|
* 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
|
* 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
|
* 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) {
|
private fun gpuPathFor(request: LoadRequest?): Boolean = when (pathMode) {
|
||||||
VideoPathSwitchable.Mode.GPU -> true
|
VideoPathSwitchable.Mode.GPU -> true
|
||||||
VideoPathSwitchable.Mode.VIDEO_PLANE -> false
|
VideoPathSwitchable.Mode.VIDEO_PLANE -> false
|
||||||
VideoPathSwitchable.Mode.AUTO -> request?.mediaSource?.videoStream?.let { v ->
|
VideoPathSwitchable.Mode.AUTO -> sdrThroughGpu && request?.mediaSource?.videoStream?.let { v ->
|
||||||
v.videoRange != VideoRange.HDR && (v.width ?: 0) <= 1920 && (v.height ?: 0) <= 1088
|
v.videoRange != VideoRange.HDR && !v.isInterlaced && (v.width ?: 0) <= 1920 && (v.height ?: 0) <= 1088 &&
|
||||||
|
fitsUiRefreshRate(v.realFrameRate ?: v.averageFrameRate)
|
||||||
} == true
|
} == 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() {
|
private fun applyVideoPath() {
|
||||||
val gpu = gpuPathFor(request)
|
val gpu = gpuPathFor(request)
|
||||||
if (pathApplied && gpu == usingGpuPath) return
|
if (pathApplied && gpu == usingGpuPath) return
|
||||||
@@ -189,6 +320,7 @@ class ExoEngine(context: Context) : PlaybackEngine, VideoPathSwitchable {
|
|||||||
if (gpu) player.setVideoTextureView(textureView) else player.setVideoSurfaceView(surfaceView)
|
if (gpu) player.setVideoTextureView(textureView) else player.setVideoSurfaceView(surfaceView)
|
||||||
val label = (if (gpu) "через GPU (TextureView)" else "видеослой ТВ (SurfaceView)") +
|
val label = (if (gpu) "через GPU (TextureView)" else "видеослой ТВ (SurfaceView)") +
|
||||||
if (pathMode == VideoPathSwitchable.Mode.AUTO) ", авто" else ", вручную"
|
if (pathMode == VideoPathSwitchable.Mode.AUTO) ", авто" else ", вручную"
|
||||||
|
Timber.i("Exo video output: %s", label)
|
||||||
_stats.update { it.copy(extra = it.extra + ("вывод видео" to label)) }
|
_stats.update { it.copy(extra = it.extra + ("вывод видео" to label)) }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -221,6 +353,7 @@ class ExoEngine(context: Context) : PlaybackEngine, VideoPathSwitchable {
|
|||||||
.setSubtitleConfigurations(subtitles)
|
.setSubtitleConfigurations(subtitles)
|
||||||
.apply { if (request.url.contains(".m3u8")) setMimeType(MimeTypes.APPLICATION_M3U8) }
|
.apply { if (request.url.contains(".m3u8")) setMimeType(MimeTypes.APPLICATION_M3U8) }
|
||||||
.build()
|
.build()
|
||||||
|
startedAtMs = SystemClock.elapsedRealtime()
|
||||||
player.setMediaItem(item, request.startPositionMs)
|
player.setMediaItem(item, request.startPositionMs)
|
||||||
player.prepare()
|
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.
|
* metadata. See [ExoEngineProvider.USE_DOLBY_VISION_DECODER] for why that is the default.
|
||||||
*/
|
*/
|
||||||
private fun decoderInfos(mimeType: String, requiresSecureDecoder: Boolean, requiresTunnelingDecoder: Boolean): List<MediaCodecInfo> {
|
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 emptyList()
|
||||||
}
|
}
|
||||||
return MediaCodecUtil.getDecoderInfos(mimeType, requiresSecureDecoder, requiresTunnelingDecoder)
|
return MediaCodecUtil.getDecoderInfos(mimeType, requiresSecureDecoder, requiresTunnelingDecoder)
|
||||||
@@ -240,7 +373,10 @@ class ExoEngine(context: Context) : PlaybackEngine, VideoPathSwitchable {
|
|||||||
|
|
||||||
override fun play() = player.play()
|
override fun play() = player.play()
|
||||||
override fun pause() = player.pause()
|
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) {
|
override fun selectAudio(streamIndex: Int) {
|
||||||
val req = request ?: return
|
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)
|
textGroups.filterNot { it.getTrackFormat(0).id?.contains(EXTERNAL_PREFIX) == true }.getOrNull(ordinal)
|
||||||
}
|
}
|
||||||
if (group != null) builder.setOverrideForType(TrackSelectionOverride(group.mediaTrackGroup, 0))
|
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()
|
trackSelector.parameters = builder.build()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun release() {
|
override fun release() {
|
||||||
|
mainHandler.removeCallbacksAndMessages(null)
|
||||||
detachVideo()
|
detachVideo()
|
||||||
player.removeListener(listener)
|
player.removeListener(listener)
|
||||||
player.removeAnalyticsListener(analytics)
|
player.removeAnalyticsListener(analytics)
|
||||||
@@ -347,9 +490,13 @@ class ExoEngine(context: Context) : PlaybackEngine, VideoPathSwitchable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
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 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.content.Context
|
||||||
import android.media.MediaCodecInfo.CodecProfileLevel
|
import android.media.MediaCodecInfo.CodecProfileLevel
|
||||||
import android.view.Display.HdrCapabilities
|
import android.view.Display.HdrCapabilities
|
||||||
|
import androidx.media3.decoder.ffmpeg.FfmpegLibrary
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import org.jellyfin.sdk.model.api.CodecType
|
import org.jellyfin.sdk.model.api.CodecType
|
||||||
import org.jellyfin.sdk.model.api.DeviceProfile
|
import org.jellyfin.sdk.model.api.DeviceProfile
|
||||||
@@ -53,18 +54,26 @@ class ExoEngineProvider @Inject constructor(
|
|||||||
}
|
}
|
||||||
evaluateRange(v.videoRangeType, v.dvProfile, device, no, losses, notes)
|
evaluateRange(v.videoRangeType, v.dvProfile, device, no, losses, notes)
|
||||||
if (v.isInterlaced) losses += Loss.NO_DEINTERLACE
|
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 ->
|
source.stream(selection.audioStreamIndex)?.let { a ->
|
||||||
val mime = Codecs.audioMime(a.codec)
|
val mime = Codecs.audioMime(a.codec)
|
||||||
val passthrough = Codecs.passthroughEncoding(a.codec, a.profile)?.let { it in device.passthroughEncodings } == true
|
val passthrough = Codecs.passthroughEncoding(a.codec, a.profile)?.let { it in device.passthroughEncodings } == true
|
||||||
if (!passthrough && (mime == null || !device.hasAudioDecoder(mime))) no += "аудио ${a.codec}"
|
when {
|
||||||
if (passthrough) notes += "${a.codec}: passthrough"
|
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 ->
|
source.stream(selection.subtitleStreamIndex)?.let { s ->
|
||||||
when (s.codec?.lowercase()) {
|
when (s.codec?.lowercase()) {
|
||||||
"ass", "ssa" -> losses += Loss.ASS_SIMPLIFIED
|
|
||||||
"pgssub", "dvdsub", "dvbsub" -> if (s.isExternal) no += "внешние ${s.codec}"
|
"pgssub", "dvdsub", "dvbsub" -> if (s.isExternal) no += "внешние ${s.codec}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -113,7 +122,7 @@ class ExoEngineProvider @Inject constructor(
|
|||||||
override fun deviceProfile(device: DeviceCapabilities): DeviceProfile {
|
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 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 ->
|
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
|
Codecs.passthroughEncoding(c, null)?.let { it in device.passthroughEncodings } == true
|
||||||
}
|
}
|
||||||
val hasDv = device.hardwareVideoDecoders(DV_MIME).isNotEmpty() && device.displaySupportsHdr(HdrCapabilities.HDR_TYPE_DOLBY_VISION)
|
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 {
|
companion object {
|
||||||
val ID = EngineId("exo")
|
val ID = EngineId("exo")
|
||||||
private const val DV_MIME = "video/dolby-vision"
|
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
|
* Profiles with a base layer (8.x, 9, 10) go to the DV decoder when the display supports Dolby Vision, otherwise
|
||||||
* same file's HDR10/HDR10+ base layer (user comparison against VLC, 2026-10-12): the TV apparently does not
|
* media3 plays them as HEVC/AVC/AV1 HDR. On the Xiaomi MT9689 (`c2.mtk.dvhe.st`) the TV switches to Dolby Vision
|
||||||
* switch into Dolby Vision mode. Until that is solved (tunneling? see NEXT_SESSION.md §4.3) profiles with a
|
* and the user rated the picture good (2026-10-13; the earlier "duller" was the TV's DV picture mode).
|
||||||
* base layer (8.x, 9, 10) are played as HEVC/AVC/AV1 HDR. Profile 5 always needs the DV decoder.
|
* 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 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 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 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")
|
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) {
|
private fun dvProfileConstant(profile: Int?): Int? = when (profile) {
|
||||||
4 -> CodecProfileLevel.DolbyVisionProfileDvheDtr
|
4 -> CodecProfileLevel.DolbyVisionProfileDvheDtr
|
||||||
5 -> CodecProfileLevel.DolbyVisionProfileDvheStn
|
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
|
@Synchronized
|
||||||
fun putMeta(url: String, meta: Meta) {
|
fun putMeta(url: String, meta: Meta) {
|
||||||
val entry = entries.getOrPut(url, ::Entry)
|
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
|
entry.meta = meta
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,7 +45,10 @@ internal class RecentBytesCache(private val capacity: Long) {
|
|||||||
val floor = entry.blocks.floorEntry(offset)
|
val floor = entry.blocks.floorEntry(offset)
|
||||||
// Already covered (two connections fetched the same bytes).
|
// Already covered (two connections fetched the same bytes).
|
||||||
if (floor != null && floor.key + floor.value.size >= offset + data.size) return
|
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
|
size += data.size
|
||||||
order.addLast(url to offset)
|
order.addLast(url to offset)
|
||||||
while (size > capacity) {
|
while (size > capacity) {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import kotlinx.coroutines.flow.asStateFlow
|
|||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import org.jellyfin.sdk.model.api.MediaStreamType
|
import org.jellyfin.sdk.model.api.MediaStreamType
|
||||||
import org.jellyfin.sdk.model.api.PlayMethod
|
import org.jellyfin.sdk.model.api.PlayMethod
|
||||||
|
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import org.videolan.libvlc.Dialog
|
import org.videolan.libvlc.Dialog
|
||||||
import org.videolan.libvlc.LibVLC
|
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 {
|
val m = Media(libVlc, proxy.localUrl(request.url).toUri()).apply {
|
||||||
setHWDecoderEnabled(needsHardware, false)
|
setHWDecoderEnabled(needsHardware, false)
|
||||||
if (request.startPositionMs > 0) addOption(":start-time=${request.startPositionMs / 1000.0}")
|
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)))
|
for (sub in request.externalSubtitles) addSlave(IMedia.Slave(IMedia.Slave.Type.Subtitle, 4, proxy.localUrl(sub.url)))
|
||||||
}
|
}
|
||||||
media?.release()
|
media?.release()
|
||||||
@@ -150,9 +152,48 @@ class VlcEngine(context: Context, httpClient: OkHttpClient, private val device:
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun seekTo(positionMs: Long) {
|
override fun seekTo(positionMs: Long) {
|
||||||
|
startSeek(positionMs)
|
||||||
player.setTime(positionMs, false)
|
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) {
|
override fun selectAudio(streamIndex: Int) {
|
||||||
val req = request ?: return
|
val req = request ?: return
|
||||||
val ordinal = req.mediaSource.embeddedOrdinal(streamIndex, MediaStreamType.AUDIO) ?: 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 source = request?.mediaSource ?: return
|
||||||
val stream = source.stream(streamIndex)?.takeIf { it.type == MediaStreamType.AUDIO }
|
val stream = source.stream(streamIndex)?.takeIf { it.type == MediaStreamType.AUDIO }
|
||||||
?: source.streams(MediaStreamType.AUDIO).let { all -> all.firstOrNull { it.isDefault } ?: all.firstOrNull() }
|
?: 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 passthrough = encoding != null && encoding in device.passthroughEncodings
|
||||||
val mode = if (passthrough) "encoded" else "stereo"
|
val mode = if (passthrough) "encoded" else "stereo"
|
||||||
if (mode == audioOutputMode) return
|
if (mode == audioOutputMode) return
|
||||||
@@ -179,18 +221,38 @@ class VlcEngine(context: Context, httpClient: OkHttpClient, private val device:
|
|||||||
} else {
|
} else {
|
||||||
player.setAudioOutputDevice(mode)
|
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)
|
Timber.i("VLC audio output: %s", label)
|
||||||
_stats.update { it.copy(audioOutput = 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?) {
|
override fun selectSubtitle(streamIndex: Int?) {
|
||||||
val req = request ?: return
|
val req = request ?: return
|
||||||
|
pendingSubtitle = null
|
||||||
if (streamIndex == null) {
|
if (streamIndex == null) {
|
||||||
player.setSpuTrack(-1)
|
player.setSpuTrack(-1)
|
||||||
return
|
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 stream = req.mediaSource.mediaStreams.orEmpty().firstOrNull { it.index == streamIndex } ?: return
|
||||||
val track = if (stream.isExternal || req.externalSubtitles.any { it.streamIndex == streamIndex }) {
|
val track = if (stream.isExternal || req.externalSubtitles.any { it.streamIndex == streamIndex }) {
|
||||||
// Slaves are appended after the embedded tracks, in the order they were added.
|
// 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 {
|
} else {
|
||||||
req.mediaSource.embeddedOrdinal(streamIndex, MediaStreamType.SUBTITLE)?.let(tracks::getOrNull)
|
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) {
|
override fun setAudioDelay(delayMs: Long) {
|
||||||
@@ -248,8 +316,14 @@ class VlcEngine(context: Context, httpClient: OkHttpClient, private val device:
|
|||||||
_events.tryEmit(EngineEvent.FirstFrameRendered)
|
_events.tryEmit(EngineEvent.FirstFrameRendered)
|
||||||
updateStats(force = true)
|
updateStats(force = true)
|
||||||
}
|
}
|
||||||
MediaPlayer.Event.ESAdded, MediaPlayer.Event.ESSelected -> applyInitialTracks()
|
MediaPlayer.Event.ESAdded, MediaPlayer.Event.ESSelected -> {
|
||||||
MediaPlayer.Event.TimeChanged -> updateStats(force = false)
|
applyInitialTracks()
|
||||||
|
pendingSubtitle?.let(::selectSubtitle)
|
||||||
|
}
|
||||||
|
MediaPlayer.Event.TimeChanged -> {
|
||||||
|
checkSeek()
|
||||||
|
updateStats(force = false)
|
||||||
|
}
|
||||||
MediaPlayer.Event.EncounteredError -> reportError(
|
MediaPlayer.Event.EncounteredError -> reportError(
|
||||||
EngineError(EngineError.Kind.DECODER, lastDialogError ?: "libVLC: EncounteredError"),
|
EngineError(EngineError.Kind.DECODER, lastDialogError ?: "libVLC: EncounteredError"),
|
||||||
)
|
)
|
||||||
@@ -271,6 +345,16 @@ class VlcEngine(context: Context, httpClient: OkHttpClient, private val device:
|
|||||||
lastStatsAt = now
|
lastStatsAt = now
|
||||||
val video = player.currentVideoTrack
|
val video = player.currentVideoTrack
|
||||||
val s = media?.stats
|
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 {
|
_stats.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
videoFormat = video?.let { v ->
|
videoFormat = video?.let { v ->
|
||||||
@@ -278,14 +362,18 @@ class VlcEngine(context: Context, httpClient: OkHttpClient, private val device:
|
|||||||
},
|
},
|
||||||
droppedFrames = s?.lostPictures ?: it.droppedFrames,
|
droppedFrames = s?.lostPictures ?: it.droppedFrames,
|
||||||
extra = it.extra + listOfNotNull(
|
extra = it.extra + listOfNotNull(
|
||||||
s?.let { st -> "кадры показано/декод." to "${st.displayedPictures}/${st.decodedVideo}" },
|
s?.let { st -> "кадров показано" to "${st.displayedPictures}" },
|
||||||
s?.let { st -> "битрейт входа" to "%.1f Мбит/с".format(st.inputBitrate * 8000 / 1000) },
|
rates?.let { (fps, _) -> "кадров/с на экране" to "%.1f".format(fps) },
|
||||||
|
rates?.let { (_, mbps) -> "чтение входа" to "%.1f Мбит/с".format(mbps) },
|
||||||
"прокси" to "${proxy.activeConnections} соед., %.1f МБ".format(proxy.bytesTransferred / 1048576.0),
|
"прокси" 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) {
|
private fun reportError(error: EngineError) {
|
||||||
if (errorReported) return
|
if (errorReported) return
|
||||||
errorReported = true
|
errorReported = true
|
||||||
@@ -294,13 +382,16 @@ class VlcEngine(context: Context, httpClient: OkHttpClient, private val device:
|
|||||||
}
|
}
|
||||||
|
|
||||||
private companion object {
|
private companion object {
|
||||||
|
const val SEEK_SETTLE_MS = 1500L
|
||||||
|
const val SEEK_TOLERANCE_MS = 5000L
|
||||||
|
const val MAX_SEEK_CORRECTIONS = 3
|
||||||
val OPTIONS = arrayListOf(
|
val OPTIONS = arrayListOf(
|
||||||
"--network-caching=3000",
|
"--network-caching=3000",
|
||||||
"--audio-time-stretch",
|
"--audio-time-stretch",
|
||||||
"--no-sub-autodetect-file",
|
"--no-sub-autodetect-file",
|
||||||
"--deinterlace=-1",
|
"--deinterlace=-1",
|
||||||
"--deinterlace-mode=yadif",
|
"--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.
|
// 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) {
|
if (!hardware || (v.width ?: 0) * (v.height ?: 0) <= 1920 * 1088) {
|
||||||
val pixels = (v.width ?: 0) * (v.height ?: 0)
|
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()
|
notes += v.codec.orEmpty()
|
||||||
}
|
}
|
||||||
when {
|
when {
|
||||||
@@ -92,5 +96,8 @@ class VlcEngineProvider @Inject constructor(
|
|||||||
companion object {
|
companion object {
|
||||||
val ID = EngineId("vlc")
|
val ID = EngineId("vlc")
|
||||||
private const val MAX_BITRATE = 400_000_000
|
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
|
# Helpers for driving the test TV over adb. Usage: source tools/tv/env.sh
|
||||||
# TV_ADB: адрес из «Беспроводной отладки» (порт меняется после перезагрузки ТВ).
|
# TV_ADB: адрес из «Беспроводной отладки» (порт меняется после перезагрузки ТВ).
|
||||||
export JAVA_HOME=${JAVA_HOME:-$HOME/.jdks/jdk-21.0.12.1+1}
|
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"
|
A="$HOME/Android/Sdk/platform-tools/adb -s $TV_ADB"
|
||||||
OUT=${OUT:-/tmp/movies-tv}; mkdir -p "$OUT"
|
OUT=${OUT:-/tmp/movies-tv}; mkdir -p "$OUT"
|
||||||
k() { for x in "$@"; do $A shell input keyevent "$x"; sleep 0.7; done; }
|
k() { for x in "$@"; do $A shell input keyevent "$x"; sleep 0.7; done; }
|
||||||
|
|||||||
Reference in New Issue
Block a user