Playback fixes: stall watchdog, DVD and MPEG-2, LAN streaming, Dolby Vision
This commit is contained in:
@@ -11,6 +11,7 @@
|
||||
android:allowBackup="false"
|
||||
android:banner="@drawable/banner"
|
||||
android:icon="@drawable/banner"
|
||||
android:largeHeap="true"
|
||||
android:label="@string/app_name"
|
||||
android:supportsRtl="true"
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
|
||||
@@ -25,7 +25,9 @@ import ru.stenfredd.movies.ui.ItemsScreen
|
||||
import ru.stenfredd.movies.ui.LoginScreen
|
||||
import ru.stenfredd.movies.ui.PlayerScreen
|
||||
import ru.stenfredd.movies.ui.Screen
|
||||
import ru.stenfredd.movies.ui.TourScreen
|
||||
import ru.stenfredd.movies.playback.api.EngineId
|
||||
import ru.stenfredd.movies.playback.api.StreamSelection
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
|
||||
@@ -64,6 +66,7 @@ class MainActivity : ComponentActivity() {
|
||||
is Screen.Items -> ItemsScreen(screen, onOpen = ::open)
|
||||
is Screen.Detail -> DetailScreen(screen, onOpen = ::open)
|
||||
is Screen.Player -> PlayerScreen(screen, onExit = { stack.removeAt(stack.lastIndex) })
|
||||
is Screen.Tour -> TourScreen(screen.startIndex, onExit = { stack.removeAt(stack.lastIndex) })
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -72,14 +75,23 @@ class MainActivity : ComponentActivity() {
|
||||
|
||||
/**
|
||||
* Debug-only shortcut for testing on a TV without typing:
|
||||
* `adb shell am start -S -n ru.stenfredd.movies/.MainActivity --es item <id> [--es engine exo|vlc] [--el start <ms>]`.
|
||||
* `adb shell am start -S -n ru.stenfredd.movies/.MainActivity --es item <id> [--es engine exo|vlc] [--el start <ms>]
|
||||
* [--ei audio <index>] [--ei sub <index>]` (stream indexes as Jellyfin numbers them; with one given, the other is off),
|
||||
* or `--ez tour true [--ei tour_file N]` for the debug tour (from file N).
|
||||
*/
|
||||
private fun debugTarget(): List<Screen>? {
|
||||
if (!BuildConfig.DEBUG) return null
|
||||
if (intent.getBooleanExtra("tour", false)) return listOf(Screen.Tour(intent.getIntExtra("tour_file", 1) - 1))
|
||||
val raw = intent.getStringExtra("item") ?: return null
|
||||
val id = UUID.fromString(if ('-' in raw) raw else raw.replace(Regex("(.{8})(.{4})(.{4})(.{4})(.{12})"), "$1-$2-$3-$4-$5"))
|
||||
val engine = intent.getStringExtra("engine")?.let(::EngineId)
|
||||
val play = intent.hasExtra("engine") || intent.getBooleanExtra("play", false)
|
||||
return listOf(Screen.Detail(id)) + if (play) listOf(Screen.Player(id, engine, intent.getLongExtra("start", 0))) else emptyList()
|
||||
val streams = if (intent.hasExtra("audio") || intent.hasExtra("sub")) {
|
||||
StreamSelection(intent.getIntExtra("audio", -1).takeIf { it >= 0 }, intent.getIntExtra("sub", -1).takeIf { it >= 0 })
|
||||
} else {
|
||||
null
|
||||
}
|
||||
return listOf(Screen.Detail(id)) +
|
||||
if (play) listOf(Screen.Player(id, engine, intent.getLongExtra("start", 0), streams = streams)) else emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -54,6 +55,8 @@ fun HomeScreen(onOpen: (Screen) -> Unit, onLogout: () -> Unit, vm: BrowseViewMod
|
||||
var query by remember { mutableStateOf("") }
|
||||
var error by remember { mutableStateOf<String?>(null) }
|
||||
val scope = rememberCoroutineScope()
|
||||
val context = LocalContext.current
|
||||
val tourAvailable = remember { TourEntry.available(context) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
runCatching {
|
||||
@@ -70,6 +73,7 @@ fun HomeScreen(onOpen: (Screen) -> Unit, onLogout: () -> Unit, vm: BrowseViewMod
|
||||
item {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
views.forEach { v -> Button(onClick = { onOpen(Screen.Items(v.id, v.name.orEmpty())) }) { Text(v.name.orEmpty()) } }
|
||||
if (tourAvailable) Button(onClick = { onOpen(Screen.Tour()) }) { Text("Режим отладки") }
|
||||
Button(onClick = {
|
||||
scope.launch {
|
||||
vm.session.logout()
|
||||
|
||||
@@ -13,6 +13,7 @@ import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
@@ -32,6 +33,8 @@ import androidx.compose.ui.input.key.nativeKeyCode
|
||||
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
||||
import androidx.compose.ui.input.key.key
|
||||
import androidx.compose.ui.input.key.type
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
@@ -45,7 +48,10 @@ import androidx.tv.material3.Text
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.delay
|
||||
import org.jellyfin.sdk.model.api.MediaStreamType
|
||||
import org.jellyfin.sdk.model.api.PlayMethod
|
||||
import ru.stenfredd.movies.playback.api.Codecs
|
||||
import ru.stenfredd.movies.playback.api.DolbyVisionSwitchable
|
||||
import ru.stenfredd.movies.playback.api.EngineId
|
||||
import ru.stenfredd.movies.playback.api.EngineState
|
||||
import ru.stenfredd.movies.playback.api.VideoPathSwitchable
|
||||
import ru.stenfredd.movies.playback.core.DeviceCapabilitiesProbe
|
||||
@@ -54,6 +60,8 @@ import ru.stenfredd.movies.playback.core.PlaybackSession
|
||||
import ru.stenfredd.movies.playback.core.PlaybackSessionFactory
|
||||
import ru.stenfredd.movies.playback.core.PlaybackUiState
|
||||
import ru.stenfredd.movies.playback.core.describe
|
||||
import ru.stenfredd.movies.playback.exo.ExoEngineProvider
|
||||
import ru.stenfredd.movies.playback.vlc.VlcEngineProvider
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
@@ -69,26 +77,36 @@ class PlayerViewModel @Inject constructor(
|
||||
release()
|
||||
return factory.create(viewModelScope).also {
|
||||
session = it
|
||||
it.start(screen.itemId, screen.engine, screen.startPositionMs)
|
||||
it.start(screen.itemId, screen.engine, screen.startPositionMs, screen.streams, screen.startFraction)
|
||||
}
|
||||
}
|
||||
|
||||
fun release() {
|
||||
session?.release()
|
||||
session = null
|
||||
/** Releases [target] (default: the current session); a stale screen disposing late can't kill a newer session. */
|
||||
fun release(target: PlaybackSession? = session) {
|
||||
target?.release()
|
||||
if (target === session) session = null
|
||||
}
|
||||
|
||||
override fun onCleared() = release()
|
||||
}
|
||||
|
||||
/** Debug tour context for [PlayerScreen]: ↑ cycles playback variants, ↓ goes to the next file. */
|
||||
class TourInfo(val number: Int, val total: Int, val title: String, val label: String, val onNextFile: () -> Unit)
|
||||
|
||||
/** Variants ↑ steps through in the tour: auto first, then each engine/path, then a server transcode. */
|
||||
private val TOUR_VARIANTS = listOf(0) + Variant.entries.map { it.number } + TRANSCODE_KEY
|
||||
|
||||
@Composable
|
||||
fun PlayerScreen(screen: Screen.Player, onExit: () -> Unit, vm: PlayerViewModel = hiltViewModel()) {
|
||||
fun PlayerScreen(screen: Screen.Player, onExit: () -> Unit, tour: TourInfo? = null, vm: PlayerViewModel = hiltViewModel()) {
|
||||
val session = remember(screen) { vm.start(screen) }
|
||||
DisposableEffect(session) { onDispose { vm.release() } }
|
||||
DisposableEffect(session) { onDispose { vm.release(session) } }
|
||||
val state by session.state.collectAsStateWithLifecycle()
|
||||
|
||||
var showDebug by remember { mutableStateOf(true) }
|
||||
// ↑ cycles: variant badge only → badge + diagnostics → nothing.
|
||||
var overlay by remember { mutableIntStateOf(0) }
|
||||
var manual by remember { mutableStateOf(screen.engine != null) }
|
||||
var showMenu by remember { mutableStateOf(false) }
|
||||
var tourStep by remember { mutableIntStateOf(0) }
|
||||
var barTick by remember { mutableIntStateOf(0) }
|
||||
var showBar by remember { mutableStateOf(true) }
|
||||
val rootFocus = remember { FocusRequester() }
|
||||
@@ -116,7 +134,14 @@ fun PlayerScreen(screen: Screen.Player, onExit: () -> Unit, vm: PlayerViewModel
|
||||
AndroidKeyEvent.KEYCODE_MEDIA_PLAY_PAUSE, AndroidKeyEvent.KEYCODE_SPACE -> session.togglePlayPause()
|
||||
AndroidKeyEvent.KEYCODE_DPAD_LEFT, AndroidKeyEvent.KEYCODE_MEDIA_REWIND -> session.seekBy(-10_000)
|
||||
AndroidKeyEvent.KEYCODE_DPAD_RIGHT, AndroidKeyEvent.KEYCODE_MEDIA_FAST_FORWARD -> session.seekBy(30_000)
|
||||
AndroidKeyEvent.KEYCODE_DPAD_UP, AndroidKeyEvent.KEYCODE_INFO -> showDebug = !showDebug
|
||||
AndroidKeyEvent.KEYCODE_DPAD_UP if tour != null -> {
|
||||
tourStep = (tourStep + 1) % TOUR_VARIANTS.size
|
||||
manual = applyVariant(TOUR_VARIANTS[tourStep], session) ?: manual
|
||||
}
|
||||
AndroidKeyEvent.KEYCODE_DPAD_DOWN if tour != null -> tour.onNextFile()
|
||||
AndroidKeyEvent.KEYCODE_DPAD_UP, AndroidKeyEvent.KEYCODE_INFO -> overlay = (overlay + 1) % 3
|
||||
in AndroidKeyEvent.KEYCODE_0..AndroidKeyEvent.KEYCODE_9 ->
|
||||
manual = applyVariant(e.key.nativeKeyCode - AndroidKeyEvent.KEYCODE_0, session) ?: return@onPreviewKeyEvent false
|
||||
AndroidKeyEvent.KEYCODE_DPAD_DOWN, AndroidKeyEvent.KEYCODE_MENU -> showMenu = true
|
||||
else -> return@onPreviewKeyEvent false
|
||||
}
|
||||
@@ -130,16 +155,17 @@ fun PlayerScreen(screen: Screen.Player, onExit: () -> Unit, vm: PlayerViewModel
|
||||
onRelease = { session.detachVideo() },
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
if (showDebug) DebugPanel(state, vm, Modifier.align(Alignment.TopStart))
|
||||
if (showBar || state.loading || state.error != null) StatusBar(state, Modifier.align(Alignment.BottomCenter))
|
||||
if (overlay == 1) DebugPanel(state, vm, Modifier.align(Alignment.TopStart))
|
||||
if (overlay < 2) VariantBadge(state, session, manual, tour, Modifier.align(Alignment.TopEnd).padding(24.dp))
|
||||
if (showBar || state.loading || state.error != null) StatusBar(state, tour != null, Modifier.align(Alignment.BottomCenter))
|
||||
if (showMenu) {
|
||||
Menu(state, session, vm, Modifier.align(Alignment.CenterEnd).focusRequester(menuFocus)) { showMenu = false }
|
||||
Menu(state, session, vm, Modifier.align(Alignment.CenterEnd).focusRequester(menuFocus), onManual = { manual = it }) { showMenu = false }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StatusBar(state: PlaybackUiState, modifier: Modifier) {
|
||||
private fun StatusBar(state: PlaybackUiState, touring: Boolean, modifier: Modifier) {
|
||||
Column(
|
||||
modifier.fillMaxWidth().background(Color(0xAA000000)).padding(24.dp, 12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
@@ -153,7 +179,95 @@ private fun StatusBar(state: PlaybackUiState, modifier: Modifier) {
|
||||
}
|
||||
Text("$phase ${state.title} ${formatTime(state.positionMs)} / ${formatTime(state.durationMs)}")
|
||||
state.error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
|
||||
Text("OK — пауза · ←/→ — перемотка · ↑ — диагностика · ↓ — дорожки и движок", fontSize = 12.sp, color = Color.LightGray)
|
||||
val hint = if (touring) {
|
||||
"OK — пауза · ←/→ — перемотка · ↑ — следующий режим · ↓ — следующий файл · Меню — дорожки · Info — диагностика"
|
||||
} else {
|
||||
"OK — пауза · ←/→ — перемотка · ↑ — значок/диагностика · ↓ — варианты, дорожки, движок"
|
||||
}
|
||||
Text(hint, fontSize = 12.sp, color = Color.LightGray)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Numbered playback variants for A/B testing on the TV: the badge in the corner shows the number, so the tester can
|
||||
* note "file — variant — impression". 0 returns to the automatic choice. Picked from the ↓ menu or with digit keys.
|
||||
*/
|
||||
private enum class Variant(
|
||||
val number: Int,
|
||||
val label: String,
|
||||
val engine: EngineId,
|
||||
val path: VideoPathSwitchable.Mode? = null,
|
||||
/** For Exo: decode Dolby Vision files through the DV decoder instead of the base layer. */
|
||||
val dolbyVision: Boolean = false,
|
||||
) {
|
||||
EXO_PLANE(1, "Exo · видеослой ТВ", ExoEngineProvider.ID, VideoPathSwitchable.Mode.VIDEO_PLANE),
|
||||
EXO_GPU(2, "Exo · через GPU", ExoEngineProvider.ID, VideoPathSwitchable.Mode.GPU),
|
||||
VLC(3, "VLC", VlcEngineProvider.ID),
|
||||
EXO_DV(4, "Exo · DV-декодер", ExoEngineProvider.ID, VideoPathSwitchable.Mode.VIDEO_PLANE, dolbyVision = true),
|
||||
}
|
||||
|
||||
private const val TRANSCODE_KEY = 9
|
||||
|
||||
/** Returns the new "manual" flag, or null if [number] is not a variant. */
|
||||
private fun applyVariant(number: Int, session: PlaybackSession): Boolean? {
|
||||
when (number) {
|
||||
0 -> {
|
||||
session.switchToAuto()
|
||||
return false
|
||||
}
|
||||
TRANSCODE_KEY -> session.forceTranscode()
|
||||
else -> {
|
||||
val v = Variant.entries.firstOrNull { it.number == number } ?: return null
|
||||
val current = session.activeEngine
|
||||
val sameDecoder = (current as? DolbyVisionSwitchable)?.useDolbyVisionDecoder?.let { it == v.dolbyVision } ?: true
|
||||
if (current?.id == v.engine && current is VideoPathSwitchable && v.path != null && sameDecoder) current.setVideoPathMode(v.path)
|
||||
else session.switchEngine(v.engine, videoPath = v.path, dolbyVision = v.dolbyVision.takeIf { v.engine == ExoEngineProvider.ID })
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/** The variant actually playing, e.g. "2" (Exo through GPU) or "3Т" (VLC on a server transcode). */
|
||||
private fun currentVariantCode(state: PlaybackUiState, session: PlaybackSession): String {
|
||||
val engine = session.activeEngine
|
||||
val gpu = (engine as? VideoPathSwitchable)?.usingGpuPath
|
||||
val dv = (engine as? DolbyVisionSwitchable)?.useDolbyVisionDecoder == true
|
||||
val v = Variant.entries.firstOrNull {
|
||||
it.engine == state.engine && it.dolbyVision == dv && (it.path == null || gpu == (it.path == VideoPathSwitchable.Mode.GPU))
|
||||
}
|
||||
val transcode = state.playMethod != null && state.playMethod != PlayMethod.DIRECT_PLAY
|
||||
return (v?.number?.toString() ?: "?") + if (transcode) "Т" else ""
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun VariantBadge(state: PlaybackUiState, session: PlaybackSession, manual: Boolean, tour: TourInfo?, modifier: Modifier) {
|
||||
val engine = session.activeEngine
|
||||
val path = (engine as? VideoPathSwitchable)?.let { if (it.usingGpuPath) "GPU" else "видеослой" }
|
||||
?.plus(if ((engine as? DolbyVisionSwitchable)?.useDolbyVisionDecoder == true) " · DV" else "")
|
||||
val transcode = state.playMethod != null && state.playMethod != PlayMethod.DIRECT_PLAY
|
||||
val head = listOfNotNull(state.engine?.value?.uppercase(), path, if (transcode) "ТРАНСКОД" else null).joinToString(" · ")
|
||||
val mode = when {
|
||||
manual -> "вручную"
|
||||
state.engineReason.startsWith("fallback") || "не умеет" in state.engineReason || "все движки" in state.engineReason -> "авто, запасной"
|
||||
else -> "авто"
|
||||
}
|
||||
Column(
|
||||
modifier.background(Color(0xC0000000)).padding(16.dp, 10.dp),
|
||||
horizontalAlignment = Alignment.End,
|
||||
) {
|
||||
tour?.let {
|
||||
Text("Отладка: файл ${it.number} из ${it.total}", fontSize = 14.sp, color = Color(0xFF80CBC4))
|
||||
}
|
||||
Text(
|
||||
"${currentVariantCode(state, session)} $head",
|
||||
fontSize = 26.sp, fontWeight = FontWeight.Bold, fontFamily = FontFamily.Monospace, color = Color(0xFFFFD54F),
|
||||
)
|
||||
Text(
|
||||
"$mode · ${formatTime(state.positionMs)} · пропуски ${state.stats.droppedFrames}",
|
||||
fontSize = 14.sp, fontFamily = FontFamily.Monospace, color = Color.White,
|
||||
)
|
||||
Text(tour?.title ?: state.title, fontSize = if (tour != null) 16.sp else 12.sp, color = Color.LightGray)
|
||||
tour?.let { Text(it.label, fontSize = 14.sp, color = Color.White, modifier = Modifier.widthIn(max = 900.dp)) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -181,13 +295,27 @@ private fun DebugPanel(state: PlaybackUiState, vm: PlayerViewModel, modifier: Mo
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Menu(state: PlaybackUiState, session: PlaybackSession, vm: PlayerViewModel, modifier: Modifier, close: () -> Unit) {
|
||||
private fun Menu(
|
||||
state: PlaybackUiState,
|
||||
session: PlaybackSession,
|
||||
vm: PlayerViewModel,
|
||||
modifier: Modifier,
|
||||
onManual: (Boolean) -> Unit,
|
||||
close: () -> Unit,
|
||||
) {
|
||||
val source = state.source ?: return
|
||||
fun mark(selected: Boolean) = if (selected) "● " else " "
|
||||
LazyColumn(
|
||||
modifier.fillMaxHeight().width(520.dp).background(Color(0xE0101010)).padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
item { Text("Вариант (сейчас ${currentVariantCode(state, session)})", style = MaterialTheme.typography.titleMedium) }
|
||||
val variants = listOf(0 to "Авто") + Variant.entries.map { it.number to it.label } + listOf(TRANSCODE_KEY to "Транскод сервером (текущим движком)")
|
||||
variants.forEach { (number, label) ->
|
||||
item {
|
||||
Button(onClick = { applyVariant(number, session)?.let(onManual); close() }) { Text("$number $label") }
|
||||
}
|
||||
}
|
||||
item { Text("Аудио", style = MaterialTheme.typography.titleMedium) }
|
||||
source.mediaStreams.orEmpty().filter { it.type == MediaStreamType.AUDIO }.forEach { s ->
|
||||
item {
|
||||
@@ -209,9 +337,9 @@ private fun Menu(state: PlaybackUiState, session: PlaybackSession, vm: PlayerVie
|
||||
}
|
||||
item { Text("Движок", style = MaterialTheme.typography.titleMedium) }
|
||||
vm.selector.engines.forEach { p ->
|
||||
item { Button(onClick = { session.switchEngine(p.id); close() }) { Text(mark(state.engine == p.id) + p.displayName) } }
|
||||
item { Button(onClick = { session.switchEngine(p.id); onManual(true); close() }) { Text(mark(state.engine == p.id) + p.displayName) } }
|
||||
}
|
||||
item { Button(onClick = { session.forceTranscode(); close() }) { Text(" Транскод на сервере") } }
|
||||
item { Button(onClick = { session.forceTranscode(); onManual(true); close() }) { Text(" Транскод на сервере") } }
|
||||
(session.activeEngine as? VideoPathSwitchable)?.let { engine ->
|
||||
item { Text("Вывод видео", style = MaterialTheme.typography.titleMedium) }
|
||||
val modes = listOf(
|
||||
@@ -220,7 +348,11 @@ private fun Menu(state: PlaybackUiState, session: PlaybackSession, vm: PlayerVie
|
||||
VideoPathSwitchable.Mode.GPU to "Через GPU, как VLC (без HDR, до 1080p)",
|
||||
)
|
||||
modes.forEach { (mode, label) ->
|
||||
item { Button(onClick = { engine.setVideoPathMode(mode); close() }) { Text(mark(engine.videoPathMode == mode) + label) } }
|
||||
item {
|
||||
Button(onClick = { engine.setVideoPathMode(mode); onManual(mode != VideoPathSwitchable.Mode.AUTO); close() }) {
|
||||
Text(mark(engine.videoPathMode == mode) + label)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package ru.stenfredd.movies.ui
|
||||
|
||||
import ru.stenfredd.movies.playback.api.EngineId
|
||||
import ru.stenfredd.movies.playback.api.StreamSelection
|
||||
import java.util.UUID
|
||||
|
||||
sealed interface Screen {
|
||||
@@ -8,5 +9,16 @@ sealed interface Screen {
|
||||
data object Home : Screen
|
||||
data class Items(val parentId: UUID, val title: String) : Screen
|
||||
data class Detail(val itemId: UUID) : Screen
|
||||
data class Player(val itemId: UUID, val engine: EngineId?, val startPositionMs: Long) : Screen
|
||||
data class Player(
|
||||
val itemId: UUID,
|
||||
val engine: EngineId?,
|
||||
val startPositionMs: Long,
|
||||
/** Tracks to start with instead of the file's defaults. */
|
||||
val streams: StreamSelection? = null,
|
||||
/** Start at this fraction of the runtime instead of [startPositionMs]. */
|
||||
val startFraction: Double? = null,
|
||||
) : Screen
|
||||
|
||||
/** Debug tour: plays a prepared list of files covering every format on the server, see [TourScreen]. */
|
||||
data class Tour(val startIndex: Int = 0) : Screen
|
||||
}
|
||||
|
||||
139
app/src/main/kotlin/ru/stenfredd/movies/ui/TourScreen.kt
Normal file
139
app/src/main/kotlin/ru/stenfredd/movies/ui/TourScreen.kt
Normal file
@@ -0,0 +1,139 @@
|
||||
package ru.stenfredd.movies.ui
|
||||
|
||||
import android.content.Context
|
||||
import android.view.KeyEvent as AndroidKeyEvent
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.focusable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.key.KeyEventType
|
||||
import androidx.compose.ui.input.key.key
|
||||
import androidx.compose.ui.input.key.nativeKeyCode
|
||||
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
||||
import androidx.compose.ui.input.key.type
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.tv.material3.Text
|
||||
import java.util.UUID
|
||||
import kotlinx.coroutines.delay
|
||||
import org.json.JSONArray
|
||||
import ru.stenfredd.movies.playback.api.StreamSelection
|
||||
import timber.log.Timber
|
||||
|
||||
/**
|
||||
* One file of the debug tour with the tracks to select. The list is `assets/tour.json` of the debug build,
|
||||
* generated by `tools/tour/build_tour.py` (a minimal set of files covering every format feature on the server).
|
||||
*/
|
||||
class TourEntry(val id: UUID, val title: String, val audio: Int?, val subtitle: Int?, val label: String) {
|
||||
companion object {
|
||||
private const val ASSET = "tour.json"
|
||||
|
||||
fun available(context: Context): Boolean = runCatching { ASSET in context.assets.list("").orEmpty() }.getOrDefault(false)
|
||||
|
||||
fun load(context: Context): List<TourEntry> = runCatching {
|
||||
val json = JSONArray(context.assets.open(ASSET).bufferedReader().use { it.readText() })
|
||||
(0 until json.length()).map { i ->
|
||||
val o = json.getJSONObject(i)
|
||||
TourEntry(
|
||||
id = UUID.fromString(o.getString("id").replace(Regex("(.{8})(.{4})(.{4})(.{4})(.{12})"), "$1-$2-$3-$4-$5")),
|
||||
title = o.optString("title"),
|
||||
audio = if (o.isNull("audio")) null else o.getInt("audio"),
|
||||
subtitle = if (o.isNull("subtitle")) null else o.getInt("subtitle"),
|
||||
label = o.optString("label"),
|
||||
)
|
||||
}
|
||||
}.onFailure { Timber.w(it, "Tour list not loaded") }.getOrDefault(emptyList())
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Plays the tour files one by one, each from the middle: ↓ next file, ↑ next playback variant (see [PlayerScreen]).
|
||||
*
|
||||
* ↓ first only shows which file is next, the file starts after [START_DELAY_MS] without further keys: skipping
|
||||
* several files in a row would otherwise start and tear down a player on every press and choke the TV.
|
||||
* While waiting, ↓/↑ move forward/back through the list.
|
||||
*/
|
||||
@Composable
|
||||
fun TourScreen(startIndex: Int, onExit: () -> Unit) {
|
||||
val context = LocalContext.current
|
||||
val entries = remember { TourEntry.load(context) }
|
||||
var index by rememberSaveable { mutableIntStateOf(startIndex.coerceIn(0, (entries.size - 1).coerceAtLeast(0))) }
|
||||
var playing by rememberSaveable { mutableIntStateOf(index) }
|
||||
if (entries.isEmpty()) {
|
||||
Box(Modifier.fillMaxSize()) { Text("Список файлов для отладки пуст (assets/tour.json)", Modifier.align(Alignment.Center)) }
|
||||
return
|
||||
}
|
||||
LaunchedEffect(index) {
|
||||
if (index == playing) return@LaunchedEffect
|
||||
delay(START_DELAY_MS)
|
||||
playing = index
|
||||
}
|
||||
val entry = entries[index]
|
||||
if (index != playing) {
|
||||
TourPending(index + 1, entries.size, entry, onMove = { step -> index = (index + step).mod(entries.size) }, onExit = onExit)
|
||||
return
|
||||
}
|
||||
key(index) {
|
||||
PlayerScreen(
|
||||
Screen.Player(
|
||||
itemId = entry.id,
|
||||
engine = null,
|
||||
startPositionMs = 0,
|
||||
streams = StreamSelection(entry.audio, entry.subtitle),
|
||||
startFraction = 0.5,
|
||||
),
|
||||
onExit = onExit,
|
||||
tour = TourInfo(index + 1, entries.size, entry.title, entry.label) { index = (index + 1) % entries.size },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private const val START_DELAY_MS = 2000L
|
||||
|
||||
/** Black screen with the file that will start: nothing is loaded yet. */
|
||||
@Composable
|
||||
private fun TourPending(number: Int, total: Int, entry: TourEntry, onMove: (Int) -> Unit, onExit: () -> Unit) {
|
||||
val focus = remember { FocusRequester() }
|
||||
LaunchedEffect(Unit) { runCatching { focus.requestFocus() } }
|
||||
BackHandler(onBack = onExit)
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Black)
|
||||
.focusRequester(focus)
|
||||
.onPreviewKeyEvent { e ->
|
||||
if (e.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false
|
||||
when (e.key.nativeKeyCode) {
|
||||
AndroidKeyEvent.KEYCODE_DPAD_DOWN -> onMove(1)
|
||||
AndroidKeyEvent.KEYCODE_DPAD_UP -> onMove(-1)
|
||||
else -> return@onPreviewKeyEvent false
|
||||
}
|
||||
true
|
||||
}
|
||||
.focusable(),
|
||||
) {
|
||||
Column(Modifier.align(Alignment.Center).padding(48.dp), horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text("Отладка: файл $number из $total", fontSize = 20.sp, color = Color(0xFF80CBC4))
|
||||
Text(entry.title, fontSize = 28.sp, color = Color.White)
|
||||
Text(entry.label, fontSize = 16.sp, color = Color.LightGray)
|
||||
Text("Запуск через 2 с · ↓ дальше · ↑ назад", fontSize = 14.sp, color = Color.Gray, modifier = Modifier.padding(top = 24.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user