package ru.stenfredd.sleepyowl.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.sleepyowl.playback.api.StreamSelection import ru.stenfredd.sleepyowl.ui.player.PlayerScreen import ru.stenfredd.sleepyowl.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 = 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)) } } }