142 lines
6.2 KiB
Kotlin
142 lines
6.2 KiB
Kotlin
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 ru.stenfredd.movies.ui.player.PlayerScreen
|
||
import ru.stenfredd.movies.ui.player.TourInfo
|
||
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))
|
||
}
|
||
}
|
||
}
|