diff --git a/app/src/main/kotlin/ru/stenfredd/movies/MainActivity.kt b/app/src/main/kotlin/ru/stenfredd/movies/MainActivity.kt index cf6f27b..b614beb 100644 --- a/app/src/main/kotlin/ru/stenfredd/movies/MainActivity.kt +++ b/app/src/main/kotlin/ru/stenfredd/movies/MainActivity.kt @@ -31,12 +31,12 @@ import ru.stenfredd.movies.core.jellyfin.JellyfinSession import ru.stenfredd.movies.playback.api.EngineId import ru.stenfredd.movies.playback.api.StreamSelection import ru.stenfredd.movies.ui.ItemsScreen -import ru.stenfredd.movies.ui.PlayerScreen import ru.stenfredd.movies.ui.PrototypeScreen import ru.stenfredd.movies.ui.Screen import ru.stenfredd.movies.ui.TourScreen import ru.stenfredd.movies.ui.details.DetailsScreen import ru.stenfredd.movies.ui.home.HomeScreen +import ru.stenfredd.movies.ui.player.PlayerScreen import ru.stenfredd.movies.ui.login.LoginScreen import ru.stenfredd.movies.ui.shell.Shell import ru.stenfredd.movies.ui.shell.Tab @@ -75,6 +75,11 @@ class MainActivity : ComponentActivity() { fun back() { if (stack.size > 1) stack.removeAt(stack.lastIndex) else finish() } + /** "Страница фильма" from the player: back to the item's page, or open it if the player came from elsewhere. */ + fun showDetails(id: UUID) { + stack.removeAt(stack.lastIndex) + if (stack.lastOrNull() != Screen.Detail(id)) stack.add(Screen.Detail(id)) + } fun resetTo(screen: Screen) { stack.clear() stack.add(screen) @@ -99,7 +104,7 @@ class MainActivity : ComponentActivity() { Screen.Prototype -> Legacy { PrototypeScreen(onOpen = ::open, onLogout = { resetTo(Screen.Login) }) } is Screen.Items -> Legacy { ItemsScreen(screen, onOpen = ::open) } is Screen.Detail -> DetailsScreen(screen, onOpen = ::open) - is Screen.Player -> PlayerScreen(screen, onExit = ::back) + is Screen.Player -> PlayerScreen(screen, onExit = ::back, onOpenDetails = { id -> showDetails(id) }) is Screen.Tour -> TourScreen(screen.startIndex, onExit = ::back) } } diff --git a/app/src/main/kotlin/ru/stenfredd/movies/ui/TourScreen.kt b/app/src/main/kotlin/ru/stenfredd/movies/ui/TourScreen.kt index 89e1ef3..d116941 100644 --- a/app/src/main/kotlin/ru/stenfredd/movies/ui/TourScreen.kt +++ b/app/src/main/kotlin/ru/stenfredd/movies/ui/TourScreen.kt @@ -35,6 +35,8 @@ 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 /** diff --git a/app/src/main/kotlin/ru/stenfredd/movies/ui/components/FocusCard.kt b/app/src/main/kotlin/ru/stenfredd/movies/ui/components/FocusCard.kt index 40fd51a..3fe80b8 100644 --- a/app/src/main/kotlin/ru/stenfredd/movies/ui/components/FocusCard.kt +++ b/app/src/main/kotlin/ru/stenfredd/movies/ui/components/FocusCard.kt @@ -36,6 +36,11 @@ data class FocusStyle( val ring: Dp = 1.5.dp, val ringColor: Color = AppColors.Accent, val glows: List = CardGlows, + /** + * False: focus shows at once. Over video (the player) every frame redraws the controls' shades too, so an + * animated focus change costs ~8 expensive frames instead of one. + */ + val animate: Boolean = true, ) { @Immutable data class Glow(val blur: Dp, val spread: Dp, val color: Color) @@ -83,7 +88,8 @@ private class FocusCardNode(var style: FocusStyle) : Modifier.Node(), FocusEvent focused = focusState.hasFocus if (!isAttached) return invalidatePlacement() - coroutineScope.launch { progress.animateTo(if (focused) 1f else 0f, tween(if (focused) 180 else 140)) } + val target = if (focused) 1f else 0f + coroutineScope.launch { if (style.animate) progress.animateTo(target, tween(if (focused) 180 else 140)) else progress.snapTo(target) } } override fun MeasureScope.measure(measurable: Measurable, constraints: Constraints): MeasureResult { diff --git a/app/src/main/kotlin/ru/stenfredd/movies/ui/components/Icons.kt b/app/src/main/kotlin/ru/stenfredd/movies/ui/components/Icons.kt index ae136ae..483e4f9 100644 --- a/app/src/main/kotlin/ru/stenfredd/movies/ui/components/Icons.kt +++ b/app/src/main/kotlin/ru/stenfredd/movies/ui/components/Icons.kt @@ -34,6 +34,13 @@ enum class AppIcon(val pathData: String, val stroked: Boolean = true) { Check("M5 12.5l4.5 4.5L19 7.5"), Plus("M12 5v14M5 12h14"), Play("M7 4.5v15l13-7.5z", stroked = false), + Pause("M6 4h4v16H6zM14 4h4v16h-4z", stroked = false), + Replay("M4 12a8 8 0 1 0 2.5-5.8M4 4v4.5h4.5"), + Forward("M20 12a8 8 0 1 1-2.5-5.8M20 4v4.5h-4.5"), + Previous("M19 5v14L9 12zM5 5h3v14H5z", stroked = false), + Next("M5 5v14l10-7zM16 5h3v14h-3z", stroked = false), + ChevronDown("M6 9l6 6 6-6"), + ChevronRight("M9 6l6 6-6 6"), ; internal val path: Path by lazy { PathParser().parsePathString(pathData).toPath() } diff --git a/app/src/main/kotlin/ru/stenfredd/movies/ui/components/Scrolling.kt b/app/src/main/kotlin/ru/stenfredd/movies/ui/components/Scrolling.kt index add355f..57a84a6 100644 --- a/app/src/main/kotlin/ru/stenfredd/movies/ui/components/Scrolling.kt +++ b/app/src/main/kotlin/ru/stenfredd/movies/ui/components/Scrolling.kt @@ -86,3 +86,27 @@ fun DefaultScrolling(content: @Composable () -> Unit) { val default = LocalDefaultBringIntoViewSpec.current if (default == null) content() else CompositionLocalProvider(LocalBringIntoViewSpec provides default, content = content) } + +/** + * Scrolls only as far as needed to show the focused element whole, plus [margin] (room for its focus glow): + * for short side lists where the TV default (keep focus at ~30 % of the height) would scroll on the first focus. + */ +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun MinimalScrolling(margin: Dp, content: @Composable () -> Unit) { + val marginPx = with(LocalDensity.current) { margin.toPx() } + val spec = remember(marginPx) { + object : BringIntoViewSpec { + override fun calculateScrollDistance(offset: Float, size: Float, containerSize: Float): Float { + val start = offset - marginPx + val end = offset + size + marginPx + return when { + start < 0f -> start + end > containerSize -> (end - containerSize).coerceAtMost(start) + else -> 0f + } + } + } + } + CompositionLocalProvider(LocalBringIntoViewSpec provides spec, content = content) +} diff --git a/app/src/main/kotlin/ru/stenfredd/movies/ui/details/DetailsViewModel.kt b/app/src/main/kotlin/ru/stenfredd/movies/ui/details/DetailsViewModel.kt index aee1f13..4df3235 100644 --- a/app/src/main/kotlin/ru/stenfredd/movies/ui/details/DetailsViewModel.kt +++ b/app/src/main/kotlin/ru/stenfredd/movies/ui/details/DetailsViewModel.kt @@ -23,8 +23,11 @@ import ru.stenfredd.movies.core.data.SessionCache import ru.stenfredd.movies.core.data.WatchRepository import ru.stenfredd.movies.core.data.model.ItemDetails import ru.stenfredd.movies.core.data.model.ItemKind +import ru.stenfredd.movies.core.data.model.MediaInfo import ru.stenfredd.movies.core.data.model.PersonKind import ru.stenfredd.movies.playback.api.StreamSelection +import ru.stenfredd.movies.playback.core.PlaybackPreferences +import ru.stenfredd.movies.playback.core.TrackPick import ru.stenfredd.movies.ui.Screen import ru.stenfredd.movies.ui.components.PosterModel import ru.stenfredd.movies.ui.components.RemoteImageSpec @@ -90,6 +93,7 @@ class DetailsViewModel @AssistedInject constructor( private val watch: WatchRepository, private val images: ImageUrls, private val cards: CardMapper, + private val prefs: PlaybackPreferences, cache: SessionCache, ) : ViewModel() { @AssistedFactory @@ -103,6 +107,9 @@ class DetailsViewModel @AssistedInject constructor( /** The user picked tracks on this page: keep the choice over reloads. */ private var tracksChosen = false private var similarTargets: Map = emptyMap() + /** Tracks are remembered per series (an episode) or per movie, see [PlaybackPreferences]. */ + private var memoryKey: UUID = itemId + private var media: MediaInfo? = null init { viewModelScope.launch { load() } @@ -115,11 +122,15 @@ class DetailsViewModel @AssistedInject constructor( fun selectAudio(index: Int?) { tracksChosen = true _state.update { it.copy(audio = index) } + val track = media?.audio?.firstOrNull { it.index == index } ?: return + viewModelScope.launch { prefs.rememberAudio(memoryKey, TrackPick(track.language, track.name, track.codec)) } } fun selectSubtitle(index: Int?) { tracksChosen = true _state.update { it.copy(subtitle = index) } + val pick = media?.subtitles?.firstOrNull { it.index == index }?.let { TrackPick(it.language, it.name, it.codec) } + viewModelScope.launch { prefs.rememberSubtitle(memoryKey, pick) } } /** The player screen for this item: from the resume point unless [fromStart], with the chosen tracks. */ @@ -161,15 +172,26 @@ class DetailsViewModel @AssistedInject constructor( val similar = async { runCatching { library.similar(itemId) }.getOrDefault(emptyList()) } val details = library.details(itemId) val model = withContext(Dispatchers.Default) { details.toModel() } + val media = details.media + this@DetailsViewModel.media = media + memoryKey = details.summary.seriesId ?: itemId + val remembered = media?.let { remembered(it) } _state.update { s -> - val media = details.media val keep = tracksChosen && s.item != null s.copy( loading = false, error = null, item = model, - audio = if (keep) s.audio else media?.defaultAudioIndex ?: media?.audio?.firstOrNull()?.index, - subtitle = if (keep) s.subtitle else media?.defaultSubtitleIndex, + audio = when { + keep -> s.audio + remembered?.first != null -> remembered.first + else -> media?.defaultAudioIndex ?: media?.audio?.firstOrNull()?.index + }, + subtitle = when { + keep -> s.subtitle + remembered != null -> remembered.second + else -> media?.defaultSubtitleIndex + }, ) } // Similar items only make sense for movies; an episode would get random other series. @@ -189,6 +211,21 @@ class DetailsViewModel @AssistedInject constructor( } } + /** + * The tracks picked last time for this movie or series, found in this file: (audio, subtitle), or null if nothing + * is remembered. Unmatched audio stays null (the file's default is used); a remembered subtitle that this file + * doesn't have falls back to the default too. + */ + private suspend fun remembered(media: MediaInfo): Pair? { + val memory = prefs.tracks(memoryKey) ?: return null + val audio = memory.audio?.bestOf(media.audio) { TrackPick(it.language, it.name, it.codec) }?.index + val subtitle = when { + memory.subtitleOff -> null + else -> memory.subtitle?.bestOf(media.subtitles) { TrackPick(it.language, it.name, it.codec) }?.index ?: media.defaultSubtitleIndex + } + return audio to subtitle + } + private fun ItemDetails.toModel(): DetailsModel { val item = summary val episode = item.kind == ItemKind.Episode diff --git a/app/src/main/kotlin/ru/stenfredd/movies/ui/details/MediaLabels.kt b/app/src/main/kotlin/ru/stenfredd/movies/ui/details/MediaLabels.kt index 1ddb696..dced37f 100644 --- a/app/src/main/kotlin/ru/stenfredd/movies/ui/details/MediaLabels.kt +++ b/app/src/main/kotlin/ru/stenfredd/movies/ui/details/MediaLabels.kt @@ -101,7 +101,7 @@ internal object MediaLabels { VideoRange.SDR, null -> null } - private fun videoCodec(codec: String?): String? = when (codec?.lowercase()) { + fun videoCodec(codec: String?): String? = when (codec?.lowercase()) { null -> null "hevc", "h265" -> "HEVC" "h264", "avc" -> "H.264" diff --git a/app/src/main/kotlin/ru/stenfredd/movies/ui/model/CardMapper.kt b/app/src/main/kotlin/ru/stenfredd/movies/ui/model/CardMapper.kt index 89c2555..a2128dd 100644 --- a/app/src/main/kotlin/ru/stenfredd/movies/ui/model/CardMapper.kt +++ b/app/src/main/kotlin/ru/stenfredd/movies/ui/model/CardMapper.kt @@ -43,7 +43,7 @@ class CardMapper @Inject constructor( image = image(if (episode) item.seriesPrimary ?: item.primary else item.primary, AppDimens.POSTER_W.toFloat(), AppDimens.POSTER_H.toFloat()), badge = item.formatBadge(), progress = item.user.progress, - isNew = item.isNew(), + isNew = isNew(item), watched = item.user.played, ) } @@ -80,7 +80,7 @@ class CardMapper @Inject constructor( else -> Screen.Detail(item.id) } - private fun image(ref: ImageRef?, wDp: Float, hDp: Float): RemoteImageSpec? = + fun image(ref: ImageRef?, wDp: Float, hDp: Float): RemoteImageSpec? = ref?.let { RemoteImageSpec(images.url(it, px(wDp), px(hDp)), it.blurHash) } /** Episode still, else the backdrop or thumb of the item or its series. */ @@ -95,7 +95,8 @@ class CardMapper @Inject constructor( private fun ItemSummary.episodeLabel(): String? = if (kind == ItemKind.Episode && seasonNumber != null && episodeNumber != null) "S$seasonNumber · E$episodeNumber · $name" else null - private fun ItemSummary.isNew(): Boolean = dateAddedAt?.let { System.currentTimeMillis() - it < NEW_MS } == true + /** "Новинка": added within the last 14 days. */ + fun isNew(item: ItemSummary): Boolean = item.dateAddedAt?.let { System.currentTimeMillis() - it < NEW_MS } == true private fun ItemSummary.formatBadge(): String? { val w = videoWidth ?: return null diff --git a/app/src/main/kotlin/ru/stenfredd/movies/ui/PlayerScreen.kt b/app/src/main/kotlin/ru/stenfredd/movies/ui/player/PlayerDebug.kt similarity index 52% rename from app/src/main/kotlin/ru/stenfredd/movies/ui/PlayerScreen.kt rename to app/src/main/kotlin/ru/stenfredd/movies/ui/player/PlayerDebug.kt index 9f2d6ea..b45b765 100644 --- a/app/src/main/kotlin/ru/stenfredd/movies/ui/PlayerScreen.kt +++ b/app/src/main/kotlin/ru/stenfredd/movies/ui/player/PlayerDebug.kt @@ -1,192 +1,47 @@ -package ru.stenfredd.movies.ui +package ru.stenfredd.movies.ui.player -import android.view.KeyEvent as AndroidKeyEvent -import android.widget.FrameLayout -import androidx.activity.compose.BackHandler import androidx.compose.foundation.background -import androidx.compose.foundation.focusable import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxHeight -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 -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -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.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 -import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel -import androidx.lifecycle.ViewModel -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.lifecycle.viewModelScope import androidx.tv.material3.Button import androidx.tv.material3.MaterialTheme 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 -import ru.stenfredd.movies.playback.core.EngineSelector 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 -class PlayerViewModel @Inject constructor( - private val factory: PlaybackSessionFactory, - val selector: EngineSelector, - val probe: DeviceCapabilitiesProbe, -) : ViewModel() { - var session: PlaybackSession? = null - private set +/* + * Debug builds only: the prototype's numbered playback variants (badge in the corner, digit keys 0–9, MENU opens the + * variant/track/engine list, INFO cycles badge → diagnostics → nothing) and the debug tour. Release builds never call + * into this file (every use is behind BuildConfig.DEBUG, R8 drops it). + */ - fun start(screen: Screen.Player): PlaybackSession { - release() - return factory.create(viewModelScope).also { - session = it - it.start(screen.itemId, screen.engine, screen.startPositionMs, screen.streams, screen.startFraction) - } - } - - /** 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. */ +/** Debug tour context for [PlayerScreen] (when the OSD is hidden): ↑ 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, tour: TourInfo? = null, vm: PlayerViewModel = hiltViewModel()) { - val session = remember(screen) { vm.start(screen) } - DisposableEffect(session) { onDispose { vm.release(session) } } - val state by session.state.collectAsStateWithLifecycle() - - // ↑ 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() } - val menuFocus = remember { FocusRequester() } - - LaunchedEffect(barTick) { - showBar = true - delay(4000) - showBar = false - } - LaunchedEffect(showMenu) { - runCatching { if (showMenu) menuFocus.requestFocus() else rootFocus.requestFocus() } - } - BackHandler { if (showMenu) showMenu = false else onExit() } - - Box( - Modifier - .fillMaxSize() - .background(Color.Black) - .focusRequester(rootFocus) - .onPreviewKeyEvent { e -> - if (showMenu || e.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false - when (e.key.nativeKeyCode) { - AndroidKeyEvent.KEYCODE_DPAD_CENTER, AndroidKeyEvent.KEYCODE_ENTER, - 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 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 - } - barTick++ - true - } - .focusable(), - ) { - AndroidView( - factory = { ctx -> FrameLayout(ctx).also(session::attachVideo) }, - onRelease = { session.detachVideo() }, - modifier = Modifier.fillMaxSize(), - ) - 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), onManual = { manual = it }) { showMenu = false } - } - } -} - -@Composable -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), - ) { - val phase = when { - state.error != null -> "Ошибка" - state.loading -> "Загрузка…" - state.engineState.phase == EngineState.Phase.ENDED -> "Конец" - state.engineState.playWhenReady -> "▶" - else -> "❚❚" - } - Text("$phase ${state.title} ${formatTime(state.positionMs)} / ${formatTime(state.durationMs)}") - state.error?.let { Text(it, color = MaterialTheme.colorScheme.error) } - val hint = if (touring) { - "OK — пауза · ←/→ — перемотка · ↑ — следующий режим · ↓ — следующий файл · Меню — дорожки · Info — диагностика" - } else { - "OK — пауза · ←/→ — перемотка · ↑ — значок/диагностика · ↓ — варианты, дорожки, движок" - } - Text(hint, fontSize = 12.sp, color = Color.LightGray) - } -} +internal val TOUR_VARIANTS = listOf(0) + Variant.entries.map { it.number } + TRANSCODE_KEY /** * Numbered playback variants for A/B testing on the TV: the badge in the corner shows the number, so the tester can @@ -206,18 +61,15 @@ private enum class Variant( EXO_DV(4, "Exo · DV-декодер", ExoEngineProvider.ID, VideoPathSwitchable.Mode.VIDEO_PLANE, dolbyVision = true), } -private const val TRANSCODE_KEY = 9 +internal 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? { +/** Returns false if [number] is not a variant. */ +internal fun applyVariant(number: Int, session: PlaybackSession): Boolean { when (number) { - 0 -> { - session.switchToAuto() - return false - } + 0 -> session.switchToAuto() TRANSCODE_KEY -> session.forceTranscode() else -> { - val v = Variant.entries.firstOrNull { it.number == number } ?: return null + val v = Variant.entries.firstOrNull { it.number == number } ?: return false 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) @@ -240,14 +92,14 @@ private fun currentVariantCode(state: PlaybackUiState, session: PlaybackSession) } @Composable -private fun VariantBadge(state: PlaybackUiState, session: PlaybackSession, manual: Boolean, tour: TourInfo?, modifier: Modifier) { +internal fun VariantBadge(state: PlaybackUiState, session: PlaybackSession, 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.manualEngine -> "вручную" state.engineReason.startsWith("fallback") || "не умеет" in state.engineReason || "все движки" in state.engineReason -> "авто, запасной" else -> "авто" } @@ -263,7 +115,7 @@ private fun VariantBadge(state: PlaybackUiState, session: PlaybackSession, manua fontSize = 26.sp, fontWeight = FontWeight.Bold, fontFamily = FontFamily.Monospace, color = Color(0xFFFFD54F), ) Text( - "$mode · ${formatTime(state.positionMs)} · пропуски ${state.stats.droppedFrames}", + "$mode · ${PlayerLabels.time(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) @@ -272,7 +124,7 @@ private fun VariantBadge(state: PlaybackUiState, session: PlaybackSession, manua } @Composable -private fun DebugPanel(state: PlaybackUiState, vm: PlayerViewModel, modifier: Modifier) { +internal fun DebugPanel(state: PlaybackUiState, vm: PlayerViewModel, modifier: Modifier) { val caps = vm.probe.capabilities val lines = buildList { add("Движок: ${state.engine} — ${state.engineReason}") @@ -295,12 +147,11 @@ private fun DebugPanel(state: PlaybackUiState, vm: PlayerViewModel, modifier: Mo } @Composable -private fun Menu( +internal fun DebugMenu( state: PlaybackUiState, session: PlaybackSession, vm: PlayerViewModel, modifier: Modifier, - onManual: (Boolean) -> Unit, close: () -> Unit, ) { val source = state.source ?: return @@ -313,7 +164,7 @@ private fun Menu( 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") } + Button(onClick = { applyVariant(number, session); close() }) { Text("$number $label") } } } item { Text("Аудио", style = MaterialTheme.typography.titleMedium) } @@ -337,9 +188,9 @@ private fun Menu( } item { Text("Движок", style = MaterialTheme.typography.titleMedium) } vm.selector.engines.forEach { p -> - item { Button(onClick = { session.switchEngine(p.id); onManual(true); close() }) { Text(mark(state.engine == p.id) + p.displayName) } } + item { Button(onClick = { session.switchEngine(p.id); close() }) { Text(mark(state.engine == p.id) + p.displayName) } } } - item { Button(onClick = { session.forceTranscode(); onManual(true); close() }) { Text(" Транскод на сервере") } } + item { Button(onClick = { session.forceTranscode(); close() }) { Text(" Транскод на сервере") } } (session.activeEngine as? VideoPathSwitchable)?.let { engine -> item { Text("Вывод видео", style = MaterialTheme.typography.titleMedium) } val modes = listOf( @@ -349,7 +200,7 @@ private fun Menu( ) modes.forEach { (mode, label) -> item { - Button(onClick = { engine.setVideoPathMode(mode); onManual(mode != VideoPathSwitchable.Mode.AUTO); close() }) { + Button(onClick = { engine.setVideoPathMode(mode); close() }) { Text(mark(engine.videoPathMode == mode) + label) } } @@ -358,7 +209,3 @@ private fun Menu( } } -fun formatTime(ms: Long): String { - val total = ms / 1000 - return if (total >= 3600) "%d:%02d:%02d".format(total / 3600, total / 60 % 60, total % 60) else "%d:%02d".format(total / 60, total % 60) -} diff --git a/app/src/main/kotlin/ru/stenfredd/movies/ui/player/PlayerDrawer.kt b/app/src/main/kotlin/ru/stenfredd/movies/ui/player/PlayerDrawer.kt new file mode 100644 index 0000000..20ee746 --- /dev/null +++ b/app/src/main/kotlin/ru/stenfredd/movies/ui/player/PlayerDrawer.kt @@ -0,0 +1,285 @@ +package ru.stenfredd.movies.ui.player + +import android.view.KeyEvent as AndroidKeyEvent +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicText +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.withFrameNanos +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.geometry.Size +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.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.em +import androidx.compose.ui.unit.sp +import ru.stenfredd.movies.ui.components.AppIcon +import ru.stenfredd.movies.ui.components.Avatar +import ru.stenfredd.movies.ui.components.FocusStyle +import ru.stenfredd.movies.ui.components.Icon +import ru.stenfredd.movies.ui.components.ImageShade +import ru.stenfredd.movies.ui.components.ProgressLine +import ru.stenfredd.movies.ui.components.RemoteImage +import ru.stenfredd.movies.ui.components.focusCard +import ru.stenfredd.movies.ui.theme.AppColors +import ru.stenfredd.movies.ui.theme.AppType +import java.util.UUID + +/** Height of the drawer; the controls move up by this much while it is open. */ +internal const val DRAWER_HEIGHT = 210 + +private val DrawerBackground = Color(0xFF0C0D14) +private val EpisodeShade = ImageShade(0.6f, listOf(0f to Color(0xB306070C), 1f to Color.Transparent)) +private val EpisodeFocus = FocusStyle(scale = 1.06f, corner = 8.dp, animate = false) +private val PillFocus = FocusStyle(scale = 1f, corner = 15.dp, glows = FocusStyle.ButtonGlows, animate = false) + +/** + * Drawer under the controls (↓): "О фильме" for a movie, the season's episodes for an episode (picking one plays it + * without leaving the player). ↑ from the header button folds it. [initial] gets focus when it opens. + */ +@Composable +internal fun PlayerDrawer( + info: PlayerInfo?, + season: SeasonEpisodes?, + currentId: UUID?, + playedFraction: Float, + initial: FocusRequester, + onClose: () -> Unit, + onPage: () -> Unit, + onEpisode: (UUID) -> Unit, + modifier: Modifier = Modifier, +) { + val episodes = season?.takeIf { info?.isEpisode == true } + LaunchedEffect(Unit) { + withFrameNanos { } + runCatching { initial.requestFocus() } + } + Column( + modifier + .fillMaxWidth() + .height(DRAWER_HEIGHT.dp) + .background(DrawerBackground, RoundedCornerShape(topStart = 14.dp, topEnd = 14.dp)) + .drawBehind { drawRect(Color(0xFF2A2E48), size = Size(size.width, 0.5.dp.toPx())) }, + ) { + Box(Modifier.fillMaxWidth().padding(top = 6.dp), contentAlignment = Alignment.Center) { + Box(Modifier.size(32.dp, 2.5.dp).background(Color(0xFF33384D), RoundedCornerShape(1.5.dp))) + } + Row( + Modifier.fillMaxWidth().padding(start = 48.dp, end = 48.dp, top = 7.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Box(Modifier.size(6.dp).background(if (episodes != null) AppColors.Blue else AppColors.Accent, CircleShape)) + BasicText( + if (episodes != null) episodes.title else "О ФИЛЬМЕ", + style = AppType.MonoLabel.copy(color = AppColors.TextSecondary, letterSpacing = 0.1.em), + ) + BasicText( + (if (episodes != null) episodes.stat else info?.title).orEmpty(), + Modifier.weight(1f), + style = AppType.MonoCaption.copy(fontSize = 9.5.sp, color = Color(0xFF6F7592)), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + BasicText("▲ — свернуть", Modifier.padding(end = 6.dp), style = AppType.MonoCaption.copy(color = AppColors.TextFaint)) + PagePill( + if (episodes != null) "Страница серии" else "Страница фильма", + onClick = onPage, + modifier = Modifier + .then(if (episodes == null) Modifier.focusRequester(initial) else Modifier) + .onPreviewKeyEvent { + if (it.type == KeyEventType.KeyDown && it.key.nativeKeyCode == AndroidKeyEvent.KEYCODE_DPAD_UP) { + onClose() + true + } else { + false + } + }, + ) + } + when { + episodes != null -> EpisodeRow(episodes, currentId, playedFraction, initial, onEpisode) + info != null -> AboutMovie(info) + } + } +} + +@Composable +private fun PagePill(label: String, onClick: () -> Unit, modifier: Modifier = Modifier) { + var focused by remember { mutableStateOf(false) } + val shape = RoundedCornerShape(15.dp) + Row( + modifier + .focusCard(PillFocus) + .height(30.dp) + .background(if (focused) AppColors.FocusFill else OsdColors.Fill, shape) + .border(0.5.dp, if (focused) AppColors.FocusFill else OsdColors.Border, shape) + .onFocusChanged { focused = it.isFocused } + .clickable(interactionSource = null, indication = null, onClick = onClick) + .padding(start = 12.dp, end = 9.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + val color = if (focused) AppColors.OnFocusFill else OsdColors.Text + BasicText(label, style = AppType.Button.copy(fontSize = 11.sp, fontWeight = FontWeight.Medium, color = color)) + Icon(AppIcon.ChevronRight, color, Modifier.size(10.dp), strokeWidth = 2.4f) + } +} + +@Composable +private fun AboutMovie(info: PlayerInfo) { + Row(Modifier.fillMaxWidth().padding(start = 48.dp, end = 48.dp, top = 14.dp), horizontalArrangement = Arrangement.spacedBy(20.dp)) { + RemoteImage(info.poster, 7.dp, Modifier.offset(y = (-3).dp).size(PlayerLayout.POSTER_W.dp, PlayerLayout.POSTER_H.dp)) + Column(Modifier.weight(1f)) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(9.dp)) { + val meta = AppType.BodyLarge.copy(fontSize = 11.5.sp, color = AppColors.TextSecondary) + info.rating?.let { BasicText(it, style = meta.copy(color = AppColors.Yellow, fontWeight = FontWeight.SemiBold)) } + info.age?.let { + BasicText( + it, + Modifier.border(0.5.dp, Color(0xFF4A4F68), RoundedCornerShape(3.dp)).padding(horizontal = 5.dp, vertical = 1.dp), + style = meta.copy(fontSize = 9.5.sp), + ) + } + listOfNotNull(info.year, info.runtime, info.genres).forEach { BasicText(it, style = meta, maxLines = 1) } + } + info.description?.let { + BasicText( + it, + Modifier.padding(top = 8.dp), + style = AppType.Body.copy(fontSize = 12.sp, lineHeight = 18.sp, color = AppColors.TextSecondary), + maxLines = 3, + overflow = TextOverflow.Ellipsis, + ) + } + info.director?.let { + BasicText("Режиссёр: $it", Modifier.padding(top = 7.dp), style = AppType.Body.copy(fontSize = 10.5.sp, color = AppColors.TextDim), maxLines = 1) + } + } + if (info.cast.isNotEmpty()) { + Column(Modifier.width(210.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { + BasicText("В РОЛЯХ", style = AppType.MonoCaption.copy(color = Color(0xFF6F7592), letterSpacing = 0.1.em)) + Spacer(Modifier.height(1.dp)) + for (p in info.cast) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(7.dp)) { + if (p.image != null) { + RemoteImage(p.image, 12.dp, Modifier.size(PlayerLayout.AVATAR.dp)) + } else { + Avatar(p.name, seed = p.key, size = PlayerLayout.AVATAR.dp, textStyle = AppType.MonoBadge.copy(fontSize = 9.sp, color = Color.White), letters = p.letters) + } + BasicText(p.name, style = AppType.CardTitle.copy(fontSize = 11.sp, fontWeight = FontWeight.SemiBold), maxLines = 1, overflow = TextOverflow.Ellipsis) + } + } + } + } + } +} + +@Composable +private fun EpisodeRow(season: SeasonEpisodes, currentId: UUID?, playedFraction: Float, initial: FocusRequester, onEpisode: (UUID) -> Unit) { + val currentIndex = season.episodes.indexOfFirst { it.id == currentId }.coerceAtLeast(0) + val listState = rememberLazyListState(initialFirstVisibleItemIndex = (currentIndex - 1).coerceAtLeast(0)) + LazyRow( + Modifier.fillMaxWidth().padding(top = 13.dp), + state = listState, + contentPadding = PaddingValues(horizontal = 48.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + items(season.episodes, key = { it.key }, contentType = { "episode" }) { e -> + val current = e.id == currentId + EpisodeCard( + e, + current = current, + progress = if (current) playedFraction else e.progress, + onClick = { onEpisode(e.id) }, + modifier = if (current) Modifier.focusRequester(initial) else Modifier, + ) + } + } +} + +@Composable +private fun EpisodeCard(e: EpisodeModel, current: Boolean, progress: Float?, onClick: () -> Unit, modifier: Modifier = Modifier) { + Column(Modifier.width(PlayerLayout.EPISODE_W.dp)) { + Box( + modifier + .focusCard(EpisodeFocus) + .size(PlayerLayout.EPISODE_W.dp, PlayerLayout.EPISODE_H.dp) + .then(if (current) Modifier.border(1.dp, Color(0xFF8B7DFF), RoundedCornerShape(8.dp)) else Modifier) + .clickable(interactionSource = null, indication = null, onClick = onClick), + ) { + RemoteImage(e.image, 8.dp, Modifier.fillMaxSize(), shade = EpisodeShade) + BasicText( + e.number, + Modifier.padding(start = 8.dp, top = 6.dp), + style = AppType.MonoMeta.copy(fontSize = 18.sp, fontWeight = FontWeight.SemiBold, color = Color(0xE0FFFFFF)), + ) + val badge = when { + current -> Triple("СЕЙЧАС", AppColors.AccentStrong, Color.White) + e.watched -> Triple("✓", Color(0xBF0A0B10), AppColors.Green) + e.isNew -> Triple("НОВАЯ", Color(0xBF0A0B10), AppColors.Pink) + else -> null + } + badge?.let { (text, bg, fg) -> + BasicText( + text, + Modifier + .align(Alignment.TopEnd) + .padding(7.dp) + .background(bg, RoundedCornerShape(3.5.dp)) + .then(if (current) Modifier else Modifier.border(0.5.dp, fg, RoundedCornerShape(3.5.dp))) + .padding(horizontal = 5.dp, vertical = 2.dp), + style = AppType.MonoBadge.copy(color = fg), + ) + } + e.duration?.let { + BasicText(it, Modifier.align(Alignment.BottomStart).padding(start = 8.dp, bottom = 8.dp), style = AppType.MonoMeta.copy(fontSize = 9.sp, color = Color(0xFFD9DCEC))) + } + progress?.takeIf { it > 0f }?.let { + ProgressLine(it, Modifier.align(Alignment.BottomStart).padding(horizontal = 6.dp, vertical = 4.dp).fillMaxWidth().height(3.dp)) + } + } + BasicText( + e.title, + Modifier.padding(top = 7.dp), + style = AppType.PosterTitle.copy(color = if (e.watched && !current) AppColors.TextMuted else AppColors.Text), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } +} diff --git a/app/src/main/kotlin/ru/stenfredd/movies/ui/player/PlayerLabels.kt b/app/src/main/kotlin/ru/stenfredd/movies/ui/player/PlayerLabels.kt new file mode 100644 index 0000000..06693c4 --- /dev/null +++ b/app/src/main/kotlin/ru/stenfredd/movies/ui/player/PlayerLabels.kt @@ -0,0 +1,166 @@ +package ru.stenfredd.movies.ui.player + +import androidx.compose.runtime.Immutable +import androidx.compose.ui.graphics.Color +import org.jellyfin.sdk.model.api.PlayMethod +import ru.stenfredd.movies.core.data.model.MediaInfo +import ru.stenfredd.movies.core.data.model.VideoRange +import ru.stenfredd.movies.playback.api.DolbyVisionSwitchable +import ru.stenfredd.movies.playback.api.PlaybackEngine +import ru.stenfredd.movies.playback.api.VideoPathSwitchable +import ru.stenfredd.movies.playback.core.PlaybackUiState +import ru.stenfredd.movies.ui.details.MediaLabels +import ru.stenfredd.movies.ui.details.TrackOption +import ru.stenfredd.movies.ui.theme.AppColors +import java.util.Locale + +/** Audio and subtitle choices of the file being played. Subtitles start with "Выкл" (index null). */ +@Immutable +data class PlayerTracks(val audio: List, val subtitles: List) { + companion object { + fun of(media: MediaInfo?): PlayerTracks { + media ?: return PlayerTracks(emptyList(), emptyList()) + return PlayerTracks( + audio = media.audio.map { TrackOption("audio/${it.index}", it.index, MediaLabels.audioLabel(it), MediaLabels.audioDetail(it)) }, + subtitles = listOf(TrackOption("sub/off", null, "Выкл", null)) + + media.subtitles.map { TrackOption("sub/${it.index}", it.index, MediaLabels.subtitleLabel(it), MediaLabels.subtitleDetail(it)) }, + ) + } + } +} + +/** One line of "Информация о потоке"; [color] is the dot: video blue, audio pink, subtitles yellow, the rest green. */ +@Immutable +data class InfoRow(val key: String, val value: String, val color: Color) + +/** The verdict at the top of "Информация о потоке". */ +@Immutable +data class StreamMode(val direct: Boolean, val title: String, val reason: String) + +internal object PlayerLabels { + /** "Русский 5.1"; a track without a language: its own title, else the format ("AC3 5.1"). */ + fun audioChip(media: MediaInfo?, index: Int?): String { + val track = media?.audio?.firstOrNull { it.index == index } ?: return "Аудио" + val name = MediaLabels.language(track.language) ?: track.name ?: return MediaLabels.audioDetail(track) ?: "Аудио" + return listOfNotNull(name, channels(track.channels)).joinToString(" ") + } + + /** "Русский" or "Субтитры выкл" */ + fun subtitleChip(media: MediaInfo?, index: Int?): String { + val track = media?.subtitles?.firstOrNull { it.index == index } ?: return "Субтитры выкл" + return MediaLabels.language(track.language) ?: track.name ?: "Субтитры" + } + + fun isDirect(state: PlaybackUiState) = state.playMethod == null || state.playMethod == PlayMethod.DIRECT_PLAY + + fun mode(state: PlaybackUiState): StreamMode { + if (isDirect(state)) return StreamMode(true, "Прямое воспроизведение", "Файл воспроизводится без изменений") + val reasons = state.transcodeReasons?.split(',')?.mapNotNull { reason(it.trim()) }?.distinct().orEmpty() + val why = when { + reasons.isNotEmpty() -> reasons.joinToString(", ") + state.maxBitrate != null -> "ограничение качества" + else -> "выбрано вручную" + } + return StreamMode(false, "Транскодирование", "Причина: $why") + } + + /** "ExoPlayer · видеослой ТВ", "VLC · вручную"; the DV decoder is only mentioned for a Dolby Vision file. */ + fun engine(state: PlaybackUiState, engine: PlaybackEngine?, dolbyVisionFile: Boolean): String { + val name = when (val id = state.engine?.value) { + "exo" -> "ExoPlayer" + "vlc" -> "VLC" + null -> "—" + else -> id + } + val path = (engine as? VideoPathSwitchable)?.let { if (it.usingGpuPath) "через GPU" else "видеослой ТВ" } + val dv = "DV-декодер".takeIf { dolbyVisionFile && (engine as? DolbyVisionSwitchable)?.useDolbyVisionDecoder == true } + return listOfNotNull(name, path, dv, "вручную".takeIf { state.manualEngine }).joinToString(" · ") + } + + fun rows(state: PlaybackUiState, media: MediaInfo?, engine: PlaybackEngine?): List = buildList { + val video = media?.video + val transcode = !isDirect(state) + media?.container?.let { add(InfoRow("Контейнер", it.uppercase() + if (transcode) " → HLS" else "", AppColors.Green)) } + if (video != null) { + val format = listOfNotNull( + MediaLabels.videoCodec(video.codec), + video.bitDepth?.takeIf { it > 8 }?.let { "$it бит" }, + if (video.width != null && video.height != null) "${video.width}×${video.height}" else null, + range(video.range, video.dvProfile), + ).joinToString(" · ") + add(InfoRow("Видео", format, AppColors.Blue)) + video.frameRate?.takeIf { it > 0 }?.let { add(InfoRow("Частота", String.format(Locale.US, "%.3f к/с", it).replace(".000", ""), AppColors.Blue)) } + } + media?.bitrate?.takeIf { it > 0 }?.let { + val cap = state.maxBitrate?.let { c -> " → до " + mbps(c) }.orEmpty() + add(InfoRow("Битрейт", String.format(Locale.US, "%.1f Мбит/с", it / 1e6) + cap, AppColors.Green)) + } + media?.audio?.firstOrNull { it.index == state.selection.audioStreamIndex }?.let { + add(InfoRow("Аудио", listOfNotNull(MediaLabels.audioDetail(it), MediaLabels.audioLabel(it)).joinToString(" · "), AppColors.Pink)) + } + val subtitle = media?.subtitles?.firstOrNull { it.index == state.selection.subtitleStreamIndex } + add(InfoRow("Субтитры", subtitle?.let { listOfNotNull(MediaLabels.subtitleLabel(it), MediaLabels.subtitleDetail(it)).joinToString(" · ") } ?: "выкл", AppColors.Yellow)) + add(InfoRow("Движок", engine(state, engine, video?.range == VideoRange.DolbyVision), AppColors.Green)) + state.stats.videoDecoder?.let { add(InfoRow("Декодер", it, AppColors.Blue)) } + state.stats.audioOutput?.let { add(InfoRow("Звук", it, AppColors.Pink)) } + if (state.bufferedMs > state.positionMs) add(InfoRow("Буфер", "${(state.bufferedMs - state.positionMs) / 1000} с", AppColors.Green)) + add(InfoRow("Пропущено", "${state.stats.droppedFrames} кадров", AppColors.Blue)) + } + + /** "20 Мбит/с", "1.5 Мбит/с" */ + fun mbps(bitrate: Int): String { + val m = bitrate / 1e6 + return if (m >= 10) "${m.toInt()} Мбит/с" else String.format(Locale.US, "%.1f Мбит/с", m) + } + + /** "1:24:25" */ + fun time(ms: Long): String { + val total = (ms.coerceAtLeast(0) + 500) / 1000 + return if (total >= 3600) "%d:%02d:%02d".format(total / 3600, total / 60 % 60, total % 60) else "%d:%02d".format(total / 60, total % 60) + } + + private fun channels(n: Int?) = when (n) { + null, 0 -> null + 1 -> "1.0" + 2 -> "2.0" + 6 -> "5.1" + 8 -> "7.1" + else -> "${n}ch" + } + + private fun range(range: VideoRange, dvProfile: Int?) = when (range) { + VideoRange.SDR -> null + VideoRange.HDR10 -> "HDR10" + VideoRange.HDR10Plus -> "HDR10+" + VideoRange.HLG -> "HLG" + VideoRange.DolbyVision -> dvProfile?.let { "DV P$it" } ?: "DV" + VideoRange.Other -> "HDR" + } + + /** Jellyfin's TranscodeReason names in plain Russian; unknown ones are dropped. */ + private fun reason(name: String): String? = when (name) { + "ContainerNotSupported" -> "контейнер не поддерживается" + "VideoCodecNotSupported", "VideoCodecTagNotSupported" -> "видеокодек не поддерживается" + "AudioCodecNotSupported" -> "аудиокодек не поддерживается" + "SubtitleCodecNotSupported" -> "субтитры нужно вшить" + "AudioIsExternal" -> "внешняя аудиодорожка" + "SecondaryAudioNotSupported" -> "вторая аудиодорожка" + "VideoProfileNotSupported" -> "профиль видео" + "VideoLevelNotSupported" -> "уровень видео" + "VideoResolutionNotSupported" -> "разрешение видео" + "VideoBitDepthNotSupported" -> "глубина цвета" + "VideoFramerateNotSupported" -> "частота кадров" + "RefFramesNotSupported" -> "опорные кадры" + "AnamorphicVideoNotSupported" -> "анаморфное видео" + "InterlacedVideoNotSupported" -> "чересстрочное видео" + "AudioChannelsNotSupported" -> "число каналов звука" + "AudioProfileNotSupported" -> "профиль звука" + "AudioSampleRateNotSupported" -> "частота дискретизации" + "AudioBitDepthNotSupported" -> "разрядность звука" + "ContainerBitrateExceedsLimit", "VideoBitrateNotSupported", "AudioBitrateNotSupported" -> "ограничение битрейта" + "VideoRangeTypeNotSupported" -> "HDR не поддерживается" + "DirectPlayError" -> "ошибка прямого воспроизведения" + "UnknownVideoStreamInfo", "UnknownAudioStreamInfo" -> "неизвестный формат потока" + else -> null + } +} diff --git a/app/src/main/kotlin/ru/stenfredd/movies/ui/player/PlayerOsd.kt b/app/src/main/kotlin/ru/stenfredd/movies/ui/player/PlayerOsd.kt new file mode 100644 index 0000000..d46b7a2 --- /dev/null +++ b/app/src/main/kotlin/ru/stenfredd/movies/ui/player/PlayerOsd.kt @@ -0,0 +1,290 @@ +package ru.stenfredd.movies.ui.player + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicText +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.draw.drawWithCache +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.em +import androidx.compose.ui.unit.sp +import ru.stenfredd.movies.ui.components.AppIcon +import kotlin.math.roundToInt +import ru.stenfredd.movies.ui.components.FocusStyle +import ru.stenfredd.movies.ui.components.Icon +import ru.stenfredd.movies.ui.components.StatusDot +import ru.stenfredd.movies.ui.components.focusCard +import ru.stenfredd.movies.ui.shell.Clock +import ru.stenfredd.movies.ui.theme.AppColors +import ru.stenfredd.movies.ui.theme.AppType + +/* + * The player's controls ("OSD") as drawn in the mockup's player screen; sizes are the mockup's px / 2. + * Everything here is drawn over the video, which on TVs sits on its own hardware plane: the UI layer is only + * redrawn when something in it changes (a second of the clock, a focus move), so the shades cost nothing while + * the picture plays. Through the GPU path (SDR on the Xiaomi TV) every video frame redraws them, see APP_PLAN.md. + */ + +internal object OsdColors { + val Fill = Color(0x14FFFFFF) + val Border = Color(0x24FFFFFF) + val Text = Color(0xFFE3E5F2) + /** `linear-gradient(180deg, rgba(5,6,10,.85), transparent)` */ + val TopShade = listOf(0f to Color(0xD905060A), 1f to Color(0x0005060A)) + /** `linear-gradient(0deg, rgba(5,6,10,.97), rgba(5,6,10,.6) 60%, transparent)` */ + val BottomShade = listOf(0f to Color(0x0005060A), 0.4f to Color(0x9905060A), 1f to Color(0xF705060A)) +} + +/** + * A vertical shade drawn as thin bands of plain colour. On the test TV a real gradient over the video costs ~8 ms of + * GPU per frame (HWUI dithers gradients) and a stretched gradient bitmap ~6 ms; plain fills cost about half that. + * With bands a few px tall the steps are below what the eye sees over moving video. + */ +internal fun Modifier.verticalShade(stops: List>): Modifier = drawWithCache { + val bands = (size.height / SHADE_BAND_DP.dp.toPx()).toInt().coerceIn(2, MAX_SHADE_BANDS) + // Whole-pixel edges: a rectangle at a fractional position is drawn antialiased, by a heavier shader. + val edges = List(bands + 1) { i -> (size.height * i / bands).roundToInt().toFloat() } + val colors = List(bands) { i -> colorAt(stops, (i + 0.5f) / bands) } + val width = size.width.roundToInt().toFloat() + onDrawBehind { + for (i in 0 until bands) { + if (colors[i].alpha > 0.004f) drawRect(colors[i], Offset(0f, edges[i]), Size(width, edges[i + 1] - edges[i])) + } + } +} + +private fun colorAt(stops: List>, t: Float): Color { + val after = stops.indexOfFirst { it.first >= t }.let { if (it < 0) stops.lastIndex else it } + if (after == 0) return stops[0].second + val (t0, c0) = stops[after - 1] + val (t1, c1) = stops[after] + return androidx.compose.ui.graphics.lerp(c0, c1, ((t - t0) / (t1 - t0)).coerceIn(0f, 1f)) +} + +private const val SHADE_BAND_DP = 4f +private const val MAX_SHADE_BANDS = 64 + +private val ButtonFocus = FocusStyle(scale = 1f, corner = 19.dp, glows = FocusStyle.ButtonGlows, animate = false) + +/** `0 0 0 3px #d9d2ff, 0 0 36px 8px rgba(124,92,255,.7)`, scale 1.06 */ +private val PlayFocus = FocusStyle( + scale = 1.06f, + corner = 23.dp, + ringColor = Color(0xFFD9D2FF), + glows = listOf(FocusStyle.Glow(18.dp, 4.dp, Color(0xB37C5CFF))), + animate = false, +) + +/** Label, title and episode on the left; clock and "закончится в" on the right. */ +@Composable +internal fun OsdTop(label: String, title: String, subtitle: String?, endsAt: String?, modifier: Modifier = Modifier) { + Row(modifier.fillMaxWidth(), verticalAlignment = Alignment.Top) { + Column(Modifier.weight(1f)) { + BasicText(label, style = AppType.MonoLabel) + BasicText( + title, + Modifier.padding(top = 5.dp), + style = AppType.Hero.copy(fontSize = 24.sp, fontWeight = FontWeight.Bold, letterSpacing = 0.em), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + subtitle?.let { + BasicText(it, Modifier.padding(top = 4.dp), style = AppType.BodyLarge.copy(color = AppColors.TextSecondary), maxLines = 1, overflow = TextOverflow.Ellipsis) + } + } + Column(Modifier.padding(start = 24.dp), horizontalAlignment = Alignment.End) { + Clock(style = AppType.Clock.copy(fontSize = 17.sp, color = AppColors.Text)) + endsAt?.let { BasicText(it, Modifier.padding(top = 3.dp), style = AppType.MonoCaption.copy(fontSize = 10.sp)) } + } + } +} + +/** + * Track with the buffered part, the played part and chapter marks; thicker with a knob when focused. + * Drawn as plain rectangles in one draw call: no layers, no paths. + */ +@Composable +internal fun Timeline(positionMs: Long, durationMs: Long, bufferedMs: Long, chaptersMs: List, focused: Boolean, modifier: Modifier = Modifier) { + Spacer( + modifier + .fillMaxWidth() + .height(26.dp) + .drawBehind { + val trackH = (if (focused) 7.dp else 3.dp).toPx() + val top = (size.height - trackH) / 2 + val r = CornerRadius(trackH / 2) + val w = size.width + fun x(ms: Long) = if (durationMs > 0) (ms.toFloat() / durationMs).coerceIn(0f, 1f) * w else 0f + drawRoundRect(Color(0x29FFFFFF), Offset(0f, top), Size(w, trackH), r) + if (bufferedMs > positionMs) drawRoundRect(Color(0x2EFFFFFF), Offset(0f, top), Size(x(bufferedMs), trackH), r) + val played = x(positionMs) + if (played > 0f) { + drawRoundRect( + Brush.horizontalGradient(listOf(Color(0xFF4D8BFF), Color(0xFF9B6BFF)), endX = played.coerceAtLeast(1f)), + Offset(0f, top), + Size(played, trackH), + r, + ) + } + val mark = 1.5.dp.toPx() + for (c in chaptersMs) { + if (c >= durationMs) continue + drawRect(Color(0xD90A0B10), Offset(x(c) - mark / 2, top), Size(mark, trackH)) + } + if (focused) { + val center = Offset(played, size.height / 2) + drawCircle(Color(0x80A594FF), 9.5.dp.toPx(), center) + drawCircle(Color.White, 7.5.dp.toPx(), center) + } + }, + ) +} + +/** ⟲ ⟳ ⏮ ⏭: translucent circle, light fill when focused. */ +@Composable +internal fun OsdCircleButton(icon: AppIcon, size: Dp, iconSize: Dp, onClick: () -> Unit, modifier: Modifier = Modifier) { + var focused by remember { mutableStateOf(false) } + Box( + modifier + .focusCard(ButtonFocus.copy(corner = size / 2)) + .size(size) + .background(if (focused) AppColors.FocusFill else OsdColors.Fill, CircleShape) + .border(0.5.dp, if (focused) AppColors.FocusFill else OsdColors.Border, CircleShape) + .onFocusChanged { focused = it.isFocused } + .clickable(interactionSource = null, indication = null, onClick = onClick), + contentAlignment = Alignment.Center, + ) { + Icon(icon, if (focused) AppColors.OnFocusFill else Color.White, Modifier.size(iconSize), strokeWidth = 2.2f) + } +} + +/** The big gradient ⏯ in the middle of the controls. */ +@Composable +internal fun OsdPlayButton(playing: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier) { + Box( + modifier + .focusCard(PlayFocus) + .size(46.dp) + .background(AppColors.PrimaryGradient, CircleShape) + .clickable(interactionSource = null, indication = null, onClick = onClick), + contentAlignment = Alignment.Center, + ) { + if (playing) Icon(AppIcon.Pause, Color.White, Modifier.size(19.dp)) else Icon(AppIcon.Play, Color.White, Modifier.size(20.dp)) + } +} + +@Immutable +internal data class OsdChipModel(val key: String, val dot: Color, val label: String) + +/** "● Русский 7.1": opens a panel. */ +@Composable +internal fun OsdChip(chip: OsdChipModel, onClick: () -> Unit, modifier: Modifier = Modifier) { + var focused by remember { mutableStateOf(false) } + val shape = RoundedCornerShape(17.dp) + Row( + modifier + .focusCard(ButtonFocus.copy(corner = 17.dp)) + .height(34.dp) + .background(if (focused) AppColors.FocusFill else OsdColors.Fill, shape) + .border(0.5.dp, if (focused) AppColors.FocusFill else OsdColors.Border, shape) + .onFocusChanged { focused = it.isFocused } + .clickable(interactionSource = null, indication = null, onClick = onClick) + .padding(horizontal = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + Box(Modifier.size(6.dp).background(chip.dot, CircleShape)) + BasicText( + chip.label, + Modifier.widthIn(max = 170.dp), + style = AppType.Button.copy(fontSize = 11.5.sp, fontWeight = FontWeight.Normal, color = if (focused) AppColors.OnFocusFill else OsdColors.Text), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } +} + +/** "⌄ О фильме" under the controls: ↓ opens the drawer. */ +@Composable +internal fun PeekHint(label: String, modifier: Modifier = Modifier) { + Row(modifier, verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(5.dp)) { + Icon(AppIcon.ChevronDown, AppColors.TextDim, Modifier.size(9.dp), strokeWidth = 2.4f) + BasicText(label, style = AppType.MonoCaption.copy(fontSize = 9.5.sp, letterSpacing = 0.06.em)) + } +} + +/** Shown in the middle while paused with the controls hidden. */ +@Composable +internal fun PausedBadge(modifier: Modifier = Modifier) { + Box( + modifier + .size(75.dp) + .background(Color(0x8C0A0B10), CircleShape) + .border(0.5.dp, Color(0x33FFFFFF), CircleShape), + contentAlignment = Alignment.Center, + ) { + Icon(AppIcon.Pause, Color.White, Modifier.size(28.dp)) + } +} + +/** Until the first frame. The dot pulses in steps: a smooth animation would redraw the whole screen every frame. */ +@Composable +internal fun LoadingBadge(modifier: Modifier = Modifier) { + Row( + modifier + .background(Color(0xCC0A0B10), RoundedCornerShape(16.dp)) + .padding(horizontal = 14.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + StatusDot(AppColors.Accent, pulsing = true) + BasicText("ЗАГРУЗКА", style = AppType.MonoLabel.copy(color = AppColors.TextSecondary)) + } +} + +@Composable +internal fun ErrorCard(message: String, modifier: Modifier = Modifier, actions: @Composable () -> Unit) { + Column( + modifier + .width(420.dp) + .background(AppColors.Surface, RoundedCornerShape(12.dp)) + .border(0.5.dp, AppColors.Border, RoundedCornerShape(12.dp)) + .padding(20.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + BasicText("Не удалось воспроизвести", style = AppType.Section.copy(fontWeight = FontWeight.Bold)) + BasicText(message, style = AppType.Body, maxLines = 6, overflow = TextOverflow.Ellipsis) + Row(Modifier.padding(top = 6.dp), horizontalArrangement = Arrangement.spacedBy(10.dp)) { actions() } + } +} diff --git a/app/src/main/kotlin/ru/stenfredd/movies/ui/player/PlayerPanels.kt b/app/src/main/kotlin/ru/stenfredd/movies/ui/player/PlayerPanels.kt new file mode 100644 index 0000000..8f6fa76 --- /dev/null +++ b/app/src/main/kotlin/ru/stenfredd/movies/ui/player/PlayerPanels.kt @@ -0,0 +1,306 @@ +package ru.stenfredd.movies.ui.player + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.focusGroup +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicText +import androidx.compose.foundation.verticalScroll +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.withFrameNanos +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.focus.FocusDirection +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusProperties +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shadow +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.em +import androidx.compose.ui.unit.sp +import ru.stenfredd.movies.playback.api.SubtitleStyle +import ru.stenfredd.movies.ui.components.FocusStyle +import ru.stenfredd.movies.ui.components.MinimalScrolling +import ru.stenfredd.movies.ui.components.SecondaryButton +import ru.stenfredd.movies.ui.components.focusCard +import ru.stenfredd.movies.ui.details.TrackOption +import ru.stenfredd.movies.ui.theme.AppColors +import ru.stenfredd.movies.ui.theme.AppType + +enum class PlayerPanel { Audio, Subtitles, Quality, Info } + +/** One choice in a panel list or chip row; [onPick] is applied by the screen. */ +@Immutable +internal data class PanelChoice(val key: String, val label: String, val detail: String?, val selected: Boolean, val swatch: Color? = null) + +@Immutable +internal data class PanelGroup(val key: String, val title: String, val choices: List) + +private object PanelColors { + val Background = Color(0xFF10121A) + val Header = Color(0xFF161925) + val HeaderLine = Color(0xFF232637) + val Edge = Color(0xFF262A3A) + val Option = Color(0xFF151824) + val OptionBorder = Color(0xFF232637) + val Selected = Color(0xFF1B1D38) + val SelectedBorder = Color(0xFF5F55E6) + val Focused = Color(0xFF1F2238) + val Ring = Color(0xFF4A4F68) + val SectionTitle = Color(0xFF6F7592) +} + +/** `border-color: #a594ff; box-shadow: 0 0 0 1px #a594ff, 0 0 30px rgba(124,92,255,.45)` */ +private val OptionFocus = FocusStyle(scale = 1f, corner = 7.dp, ring = 0.5.dp, glows = listOf(FocusStyle.Glow(15.dp, 0.dp, Color(0x737C5CFF))), animate = false) +private val ChipFocus = FocusStyle(scale = 1f, corner = 15.dp, glows = FocusStyle.ButtonGlows, animate = false) + +/** + * Settings panel on the right, full height, opaque (a translucent full-screen scrim is expensive on weak TV GPUs). + * Focus stays inside: ←/→ at the edge don't leak to the controls behind. [initial] gets focus when it opens. + */ +@Composable +internal fun SidePanel( + title: String, + dot: Color, + hint: String, + initial: FocusRequester, + modifier: Modifier = Modifier, + content: @Composable ColumnScope.() -> Unit, +) { + LaunchedEffect(Unit) { + withFrameNanos { } + runCatching { initial.requestFocus() } + } + Column( + modifier + .fillMaxHeight() + .width(350.dp) + .background(PanelColors.Background) + .drawBehind { drawRect(PanelColors.Edge, size = androidx.compose.ui.geometry.Size(0.5.dp.toPx(), size.height)) } + .focusProperties { + onExit = { + if (requestedFocusDirection in TRAPPED) cancelFocusChange() + } + } + .focusGroup(), + ) { + Row( + Modifier + .fillMaxWidth() + .height(36.dp) + .background(PanelColors.Header) + .drawBehind { + val line = 0.5.dp.toPx() + drawRect(PanelColors.HeaderLine, Offset(0f, size.height - line), androidx.compose.ui.geometry.Size(size.width, line)) + } + .padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(7.dp), + ) { + Box(Modifier.size(7.dp).background(dot, CircleShape)) + BasicText(title, Modifier.weight(1f), style = AppType.MonoLabel.copy(color = AppColors.TextSecondary, letterSpacing = 0.1.em)) + BasicText(hint, style = AppType.MonoCaption.copy(color = PanelColors.SectionTitle)) + } + MinimalScrolling(margin = 14.dp) { + Column( + Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp, vertical = 14.dp), + verticalArrangement = Arrangement.spacedBy(15.dp), + content = content, + ) + } + } +} + +private val TRAPPED = setOf(FocusDirection.Left, FocusDirection.Right, FocusDirection.Up, FocusDirection.Down) + +@Composable +internal fun PanelSection(title: String, content: @Composable ColumnScope.() -> Unit) { + Column { + BasicText(title, style = AppType.MonoCaption.copy(color = PanelColors.SectionTitle, letterSpacing = 0.1.em)) + Column(Modifier.padding(top = 7.dp), verticalArrangement = Arrangement.spacedBy(4.dp), content = content) + } +} + +@Composable +internal fun PanelNote(text: String) { + BasicText(text, style = AppType.Body.copy(fontSize = 10.5.sp, lineHeight = 15.sp, color = AppColors.TextDim)) +} + +/** List option with a radio mark: "◉ Русский TrueHD Atmos · 7.1". */ +@Composable +internal fun PanelOption(choice: PanelChoice, onClick: () -> Unit, modifier: Modifier = Modifier) { + var focused by remember { mutableStateOf(false) } + val shape = RoundedCornerShape(7.dp) + Row( + modifier + .focusCard(OptionFocus) + .fillMaxWidth() + .height(42.dp) + .background(if (focused) PanelColors.Focused else if (choice.selected) PanelColors.Selected else PanelColors.Option, shape) + .border(0.5.dp, if (focused) AppColors.Accent else if (choice.selected) PanelColors.SelectedBorder else PanelColors.OptionBorder, shape) + .onFocusChanged { focused = it.isFocused } + .clickable(interactionSource = null, indication = null, onClick = onClick) + .padding(horizontal = 11.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + Box( + Modifier.size(13.dp).border(1.dp, if (choice.selected) AppColors.Accent else PanelColors.Ring, CircleShape), + contentAlignment = Alignment.Center, + ) { + if (choice.selected) Box(Modifier.size(6.dp).background(AppColors.Accent, CircleShape)) + } + BasicText( + choice.label, + Modifier.weight(1f), + style = AppType.BodyLarge.copy(fontWeight = FontWeight.Medium, color = AppColors.Text), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + choice.detail?.let { BasicText(it, style = AppType.MonoCaption.copy(fontSize = 10.sp), maxLines = 1) } + } +} + +/** Small choice in a row: size "M", colour "● Жёлтый", background "Тень". */ +@Composable +internal fun PanelChip(choice: PanelChoice, onClick: () -> Unit, modifier: Modifier = Modifier) { + var focused by remember { mutableStateOf(false) } + val shape = RoundedCornerShape(15.dp) + Row( + modifier + .focusCard(ChipFocus) + .height(30.dp) + .background(if (focused) AppColors.FocusFill else if (choice.selected) PanelColors.Selected else PanelColors.Option, shape) + .border(0.5.dp, if (focused) AppColors.FocusFill else if (choice.selected) PanelColors.SelectedBorder else PanelColors.OptionBorder, shape) + .onFocusChanged { focused = it.isFocused } + .clickable(interactionSource = null, indication = null, onClick = onClick) + .padding(horizontal = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(5.dp), + ) { + choice.swatch?.let { Box(Modifier.size(9.dp).background(it, CircleShape).border(0.5.dp, Color(0x4D000000), CircleShape)) } + BasicText(choice.label, style = AppType.Button.copy(fontSize = 11.5.sp, fontWeight = FontWeight.Normal, color = if (focused) AppColors.OnFocusFill else OsdColors.Text)) + } +} + +/** A list of [choices]; the selected one (or the first) takes [initial] focus. */ +@Composable +internal fun PanelList(choices: List, initial: FocusRequester?, onPick: (PanelChoice) -> Unit) { + val target = choices.firstOrNull { it.selected } ?: choices.firstOrNull() + for (c in choices) { + PanelOption(c, onClick = { onPick(c) }, Modifier.then(if (c === target && initial != null) Modifier.focusRequester(initial) else Modifier)) + } +} + +@Composable +internal fun PanelChips(choices: List, onPick: (PanelChoice) -> Unit) { + Row(horizontalArrangement = Arrangement.spacedBy(5.dp)) { + for (c in choices) PanelChip(c, onClick = { onPick(c) }) + } +} + +/** How text subtitles will look: sample line over a dark picture-like box. */ +@Composable +internal fun SubtitlePreview(style: SubtitleStyle, on: Boolean) { + Box( + Modifier + .fillMaxWidth() + .height(85.dp) + .background(Brush.linearGradient(listOf(Color(0xFF2B4A66), Color(0xFF16233A), Color(0xFF241B36))), RoundedCornerShape(7.dp)) + .border(0.5.dp, PanelColors.Edge, RoundedCornerShape(7.dp)) + .padding(bottom = 11.dp), + contentAlignment = Alignment.BottomCenter, + ) { + val box = style.background == SubtitleStyle.Background.BOX + BasicText( + if (on) "— Ты слышишь? Сигнал снова идёт со дна." else "Субтитры выключены", + Modifier + .then(if (box) Modifier.background(Color(0xB8000000), RoundedCornerShape(3.dp)) else Modifier) + .padding(horizontal = 7.dp, vertical = 2.dp), + style = AppType.Body.copy( + fontSize = 13.sp * style.size.scale, + lineHeight = 16.sp * style.size.scale, + fontWeight = FontWeight.SemiBold, + color = Color(style.color), + textAlign = TextAlign.Center, + shadow = if (style.background == SubtitleStyle.Background.SHADOW) Shadow(Color.Black, Offset(0f, 2f), 6f) else null, + ), + ) + } +} + +/** "Прямое воспроизведение / Файл воспроизводится без изменений" in a green (or orange) frame. */ +@Composable +internal fun StreamModeCard(mode: StreamMode) { + val color = if (mode.direct) AppColors.Green else AppColors.Orange + Column( + Modifier + .fillMaxWidth() + .border(0.5.dp, color, RoundedCornerShape(7.dp)) + .padding(horizontal = 11.dp, vertical = 10.dp), + ) { + BasicText(mode.title, style = AppType.Section.copy(fontWeight = FontWeight.Bold, color = color)) + BasicText(mode.reason, Modifier.padding(top = 4.dp), style = AppType.Body.copy(color = AppColors.TextMuted)) + } +} + +@Composable +internal fun InfoRows(rows: List) { + Column { + for (r in rows) { + Row( + Modifier + .fillMaxWidth() + .drawBehind { + val line = 0.5.dp.toPx() + drawRect(AppColors.BorderSubtle, Offset(0f, size.height - line), androidx.compose.ui.geometry.Size(size.width, line)) + } + .padding(vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box(Modifier.size(5.dp).background(r.color, CircleShape)) + BasicText(r.key, Modifier.padding(start = 7.dp).width(75.dp), style = AppType.MonoMeta.copy(fontSize = 10.sp, color = PanelColors.SectionTitle)) + BasicText(r.value, Modifier.weight(1f), style = AppType.MonoMeta.copy(fontSize = 10.sp, color = OsdColors.Text), maxLines = 2, overflow = TextOverflow.Ellipsis) + } + } + } +} + +@Composable +internal fun PanelCloseButton(onClick: () -> Unit, modifier: Modifier = Modifier) { + SecondaryButton("Закрыть", onClick = onClick, modifier = modifier) +} + +internal fun List.toChoices(selected: Int?): List = + map { PanelChoice(it.key, it.label, it.detail, it.index == selected) } diff --git a/app/src/main/kotlin/ru/stenfredd/movies/ui/player/PlayerScreen.kt b/app/src/main/kotlin/ru/stenfredd/movies/ui/player/PlayerScreen.kt new file mode 100644 index 0000000..f21f309 --- /dev/null +++ b/app/src/main/kotlin/ru/stenfredd/movies/ui/player/PlayerScreen.kt @@ -0,0 +1,655 @@ +package ru.stenfredd.movies.ui.player + +import android.view.KeyEvent as AndroidKeyEvent +import android.widget.FrameLayout +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.background +import androidx.compose.foundation.focusable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.BasicText +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.withFrameNanos +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusProperties +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.key.KeyEvent +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.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LifecycleEventEffect +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import kotlinx.coroutines.delay +import ru.stenfredd.movies.BuildConfig +import ru.stenfredd.movies.core.data.model.MediaInfo +import ru.stenfredd.movies.core.data.model.VideoRange +import ru.stenfredd.movies.core.data.toMediaInfo +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.SubtitleStyle +import ru.stenfredd.movies.playback.api.VideoPathSwitchable +import ru.stenfredd.movies.playback.core.PlaybackSession +import ru.stenfredd.movies.playback.core.PlaybackUiState +import ru.stenfredd.movies.playback.exo.ExoEngineProvider +import ru.stenfredd.movies.ui.Screen +import ru.stenfredd.movies.ui.components.AppIcon +import ru.stenfredd.movies.ui.components.SecondaryButton +import ru.stenfredd.movies.ui.theme.AppColors +import ru.stenfredd.movies.ui.theme.AppType +import java.time.LocalTime +import java.time.format.DateTimeFormatter +import java.util.UUID + +/** + * The player: video with the controls of the mockup over it. + * + * Remote: with the controls hidden ←/→ seek by 10 s (held: 30 s, then 60 s) and show the timeline, OK pauses, + * ↓ opens the drawer ("О фильме" / episodes), ↑ shows the controls. Seeks are collected while the keys are pressed + * and sent to the engine once they stop. The controls hide after 6 s of no keys while playing. + * Back: panel → drawer → controls → leave the player. + */ +@Composable +fun PlayerScreen( + screen: Screen.Player, + onExit: () -> Unit, + onOpenDetails: (UUID) -> Unit = {}, + tour: TourInfo? = null, + vm: PlayerViewModel = hiltViewModel(), +) { + LaunchedEffect(screen) { vm.start(screen) } + DisposableEffect(vm) { onDispose { vm.release() } } + val session by vm.session.collectAsStateWithLifecycle() + Box(Modifier.fillMaxSize().background(Color.Black)) { + VideoSurface(session) + session?.let { PlayerUi(it, vm, onExit, onOpenDetails, tour) } + } +} + +/** Engines put their own views (surface, subtitles) into this container. */ +@Composable +private fun VideoSurface(session: PlaybackSession?) { + var container by remember { mutableStateOf(null) } + AndroidView(factory = { ctx -> FrameLayout(ctx).also { container = it } }, modifier = Modifier.fillMaxSize()) + DisposableEffect(session, container) { + val c = container + if (session != null && c != null) session.attachVideo(c) + onDispose { session?.detachVideo() } + } +} + +@Stable +private class PlayerUiState { + var osd by mutableStateOf(true) + var drawer by mutableStateOf(false) + var panel by mutableStateOf(null) + /** Bumped by every key: restarts the hide timer. */ + var activity by mutableIntStateOf(0) + var timelineFocused by mutableStateOf(false) + /** Where the user is seeking to; sent to the engine once the keys stop. */ + var scrubTarget by mutableStateOf(null) + var scrubVersion by mutableIntStateOf(0) + /** Bumped when a panel action changes engine state the panel shows (video path): re-reads it. */ + var panelRevision by mutableIntStateOf(0) + var debugOverlay by mutableIntStateOf(0) + var debugMenu by mutableStateOf(false) + var tourStep = 0 + /** Back went down while the player had focus; it acts on release. */ + var backPressed = false + + val root = FocusRequester() + val playPause = FocusRequester() + val timeline = FocusRequester() + val panelInitial = FocusRequester() + val drawerInitial = FocusRequester() + val errorButton = FocusRequester() + val debugMenuFocus = FocusRequester() + val chips = PlayerPanel.entries.associateWith { FocusRequester() } + var panelFrom: FocusRequester? = null + + var focusTarget: FocusRequester? = null + private set + var focusVersion by mutableIntStateOf(0) + private set + + /** Focuses [target] once it is composed (next frame). */ + fun focus(target: FocusRequester) { + focusTarget = target + focusVersion++ + } + + fun poke() { + activity++ + } +} + +@Composable +private fun PlayerUi(session: PlaybackSession, vm: PlayerViewModel, onExit: () -> Unit, onOpenDetails: (UUID) -> Unit, tour: TourInfo?) { + val state by session.state.collectAsStateWithLifecycle() + val info by vm.info.collectAsStateWithLifecycle() + val season by vm.season.collectAsStateWithLifecycle() + val style by vm.subtitleStyle.collectAsStateWithLifecycle() + val ui = remember { PlayerUiState() } + val media = remember(state.source) { state.source?.toMediaInfo() } + val tracks = remember(media) { PlayerTracks.of(media) } + val playing = state.engineState.playWhenReady + val ended = state.engineState.phase == EngineState.Phase.ENDED + val shownPosition = ui.scrubTarget ?: state.positionMs + + fun hideOsd() { + runCatching { ui.root.requestFocus() } + ui.osd = false + ui.drawer = false + } + + fun showOsd(focus: FocusRequester) { + ui.osd = true + ui.focus(focus) + } + + fun scrub(direction: Int, repeat: Int) { + val step = when { + repeat < 8 -> 10_000L + repeat < 24 -> 30_000L + else -> 60_000L + } + val end = (state.durationMs - 1_000).coerceAtLeast(0) + ui.scrubTarget = ((ui.scrubTarget ?: state.positionMs) + direction * step).coerceIn(0, end) + ui.scrubVersion++ + } + + fun openDrawer() { + if (info == null) return + ui.osd = true + ui.drawer = true + } + + fun closeDrawer() { + ui.drawer = false + ui.focus(ui.playPause) + } + + fun openPanel(panel: PlayerPanel) { + ui.panelFrom = ui.chips[panel] + ui.panel = panel + } + + fun closePanel() { + ui.panel = null + ui.panelFrom?.let(ui::focus) + } + + fun back() { + ui.poke() + when { + ui.debugMenu -> { + ui.debugMenu = false + ui.focus(ui.root) + } + ui.panel != null -> closePanel() + ui.drawer -> closeDrawer() + ui.osd && state.error == null -> hideOsd() + else -> onExit() + } + } + + fun onKey(e: KeyEvent): Boolean { + val code = e.key.nativeKeyCode + // Handled here, not only by BackHandler: an unconsumed Back first just clears Compose focus. + // Acted on when released (as Android does): leaving on the press would send the release to the next screen. + if (code == AndroidKeyEvent.KEYCODE_BACK || code == AndroidKeyEvent.KEYCODE_ESCAPE) { + when (e.type) { + KeyEventType.KeyDown -> if (e.nativeKeyEvent.repeatCount == 0) ui.backPressed = true + KeyEventType.KeyUp -> if (ui.backPressed) { + ui.backPressed = false + back() + } + } + return true + } + if (e.type != KeyEventType.KeyDown) return false + ui.poke() + val repeat = e.nativeKeyEvent.repeatCount + when (code) { + AndroidKeyEvent.KEYCODE_MEDIA_PLAY_PAUSE -> session.togglePlayPause() + AndroidKeyEvent.KEYCODE_MEDIA_PLAY -> if (!playing) session.togglePlayPause() + AndroidKeyEvent.KEYCODE_MEDIA_PAUSE -> session.pause() + AndroidKeyEvent.KEYCODE_MEDIA_FAST_FORWARD -> scrub(1, repeat).also { if (!ui.osd) showOsd(ui.timeline) } + AndroidKeyEvent.KEYCODE_MEDIA_REWIND -> scrub(-1, repeat).also { if (!ui.osd) showOsd(ui.timeline) } + AndroidKeyEvent.KEYCODE_MEDIA_NEXT -> season?.next?.let(vm::playEpisode) + AndroidKeyEvent.KEYCODE_MEDIA_PREVIOUS -> season?.previous?.let(vm::playEpisode) + else -> return onNavigationKey(code, repeat, ui, session, tour, ::scrub, ::showOsd, ::openDrawer) + } + return true + } + + LaunchedEffect(Unit) { ui.focus(ui.playPause) } + LaunchedEffect(ui.focusVersion) { + val target = ui.focusTarget ?: return@LaunchedEffect + withFrameNanos { } + runCatching { target.requestFocus() } + } + // Hide the controls after a while of no keys, only while playing and nothing is open on top. + LaunchedEffect(ui.activity, ui.osd, ui.drawer, ui.panel, playing, state.loading, state.error) { + if (!ui.osd || ui.drawer || ui.panel != null || ui.debugMenu || !playing || state.loading || state.error != null) return@LaunchedEffect + delay(OSD_TIMEOUT_MS) + hideOsd() + } + LaunchedEffect(ui.scrubVersion) { + val target = ui.scrubTarget ?: return@LaunchedEffect + delay(SCRUB_COMMIT_MS) + session.seekTo(target) + // Keep showing the target until the engine reports the new position. + delay(SCRUB_SETTLE_MS) + ui.scrubTarget = null + } + // Text subtitles move just above the controls while they are shown (fractions of the 540 dp screen, on top of + // the subtitle view's own 8 % bottom padding). + LaunchedEffect(session, ui.osd, ui.drawer) { + session.setSubtitleLift( + when { + ui.drawer -> 0.58f + ui.osd -> 0.22f + else -> 0f + }, + ) + } + LaunchedEffect(state.error) { + if (state.error != null) showOsd(ui.errorButton) + } + LaunchedEffect(ended) { + if (!ended) return@LaunchedEffect + // Stage 6 adds "Следующая серия через N"; for now the next episode simply starts. + season?.next?.let(vm::playEpisode) ?: onExit() + } + LifecycleEventEffect(Lifecycle.Event.ON_STOP) { session.pause() } + + // Back while nothing in the player has focus (the key handler above doesn't see it then). + BackHandler { back() } + + Box( + Modifier + .fillMaxSize() + .focusRequester(ui.root) + .onPreviewKeyEvent(::onKey) + .focusable(), + ) { + if (!ui.osd && !playing && !state.loading && state.error == null && !ended) PausedBadge(Modifier.align(Alignment.Center)) + if (ui.osd) { + OsdLayer( + state = state, + info = info, + season = season, + media = media, + ui = ui, + positionMs = shownPosition, + onToggle = session::togglePlayPause, + onSeek = { scrub(it, 0) }, + onEpisode = vm::playEpisode, + onPanel = ::openPanel, + onDrawer = ::openDrawer, + ) + } + if (ui.drawer) { + PlayerDrawer( + info = info, + season = season, + currentId = state.itemId, + playedFraction = if (state.durationMs > 0) shownPosition.toFloat() / state.durationMs else 0f, + initial = ui.drawerInitial, + onClose = ::closeDrawer, + onPage = { state.itemId?.let(onOpenDetails) }, + onEpisode = { id -> + if (id == state.itemId) { + closeDrawer() + } else { + hideOsd() + vm.playEpisode(id) + } + }, + modifier = Modifier.align(Alignment.BottomCenter), + ) + } + if (state.loading && state.error == null) LoadingBadge(Modifier.align(Alignment.Center)) + state.error?.let { error -> + ErrorCard(error.lineSequence().first(), Modifier.align(Alignment.Center)) { + SecondaryButton("Назад", onClick = onExit, modifier = Modifier.focusRequester(ui.errorButton)) + } + } + ui.panel?.let { panel -> + PanelLayer(panel, state, info, media, tracks, style, session, vm, ui, ::closePanel, Modifier.align(Alignment.CenterEnd)) + } + if (BuildConfig.DEBUG) { + if (ui.debugOverlay == 1) DebugPanel(state, vm, Modifier.align(Alignment.TopStart)) + if (ui.debugOverlay < 2 && !ui.osd) VariantBadge(state, session, tour, Modifier.align(Alignment.TopEnd).padding(24.dp)) + if (ui.debugMenu) { + DebugMenu(state, session, vm, Modifier.align(Alignment.CenterEnd).focusRequester(ui.debugMenuFocus)) { + ui.debugMenu = false + ui.focus(ui.root) + } + LaunchedEffect(Unit) { + withFrameNanos { } + runCatching { ui.debugMenuFocus.requestFocus() } + } + } + } + } +} + +/** Keys not handled everywhere: arrows and OK while the controls are hidden, seeking on the timeline, debug keys. */ +private fun onNavigationKey( + code: Int, + repeat: Int, + ui: PlayerUiState, + session: PlaybackSession, + tour: TourInfo?, + scrub: (Int, Int) -> Unit, + showOsd: (FocusRequester) -> Unit, + openDrawer: () -> Unit, +): Boolean { + if (BuildConfig.DEBUG && !ui.debugMenu) { + when (code) { + AndroidKeyEvent.KEYCODE_INFO -> { + ui.debugOverlay = (ui.debugOverlay + 1) % 3 + return true + } + AndroidKeyEvent.KEYCODE_MENU -> { + ui.debugMenu = true + return true + } + in AndroidKeyEvent.KEYCODE_0..AndroidKeyEvent.KEYCODE_9 -> return applyVariant(code - AndroidKeyEvent.KEYCODE_0, session) + } + } + if (ui.panel != null || ui.drawer || ui.debugMenu) return false + if (!ui.osd) { + when (code) { + AndroidKeyEvent.KEYCODE_DPAD_UP if tour != null -> { + ui.tourStep = (ui.tourStep + 1) % TOUR_VARIANTS.size + applyVariant(TOUR_VARIANTS[ui.tourStep], session) + } + AndroidKeyEvent.KEYCODE_DPAD_DOWN if tour != null -> tour.onNextFile() + AndroidKeyEvent.KEYCODE_DPAD_LEFT -> { + scrub(-1, repeat) + showOsd(ui.timeline) + } + AndroidKeyEvent.KEYCODE_DPAD_RIGHT -> { + scrub(1, repeat) + showOsd(ui.timeline) + } + AndroidKeyEvent.KEYCODE_DPAD_CENTER, AndroidKeyEvent.KEYCODE_ENTER, AndroidKeyEvent.KEYCODE_NUMPAD_ENTER -> { + session.togglePlayPause() + showOsd(ui.playPause) + } + AndroidKeyEvent.KEYCODE_DPAD_DOWN -> openDrawer() + AndroidKeyEvent.KEYCODE_DPAD_UP -> showOsd(ui.playPause) + else -> return false + } + return true + } + if (ui.timelineFocused) { + when (code) { + AndroidKeyEvent.KEYCODE_DPAD_LEFT -> scrub(-1, repeat) + AndroidKeyEvent.KEYCODE_DPAD_RIGHT -> scrub(1, repeat) + AndroidKeyEvent.KEYCODE_DPAD_CENTER, AndroidKeyEvent.KEYCODE_ENTER -> session.togglePlayPause() + else -> return false + } + return true + } + return false +} + +@Composable +private fun OsdLayer( + state: PlaybackUiState, + info: PlayerInfo?, + season: SeasonEpisodes?, + media: MediaInfo?, + ui: PlayerUiState, + positionMs: Long, + onToggle: () -> Unit, + onSeek: (Int) -> Unit, + onEpisode: (UUID) -> Unit, + onPanel: (PlayerPanel) -> Unit, + onDrawer: () -> Unit, +) { + val drawer = ui.drawer + val episodes = season?.takeIf { info?.isEpisode == true } + Box(Modifier.fillMaxSize()) { + // The shades are most of the cost of a frame (~11 ms at the mockup's 130 + 230 dp on the test TV): only as tall + // as the text over them needs. + Box(Modifier.fillMaxWidth().height(TOP_SHADE.dp).verticalShade(OsdColors.TopShade)) + Box(Modifier.align(Alignment.BottomStart).fillMaxWidth().height((if (drawer) DRAWER_HEIGHT + BOTTOM_SHADE else BOTTOM_SHADE).dp).verticalShade(OsdColors.BottomShade)) + val remaining = (state.durationMs - positionMs).coerceAtLeast(0) + OsdTop( + label = info?.label ?: "", + title = info?.title ?: state.title, + subtitle = info?.subtitle, + endsAt = if (state.durationMs > 0) "закончится в " + LocalTime.now().plusSeconds(remaining / 1000).format(EndsFormat) else null, + modifier = Modifier.padding(start = 48.dp, end = 48.dp, top = 32.dp), + ) + Column( + Modifier + .align(Alignment.BottomStart) + .fillMaxWidth() + .padding(start = 48.dp, end = 48.dp, bottom = if (drawer) (DRAWER_HEIGHT + 16).dp else 32.dp), + ) { + Row(Modifier.fillMaxWidth()) { + BasicText(PlayerLabels.time(positionMs), style = AppType.MonoMeta.copy(fontSize = 12.sp)) + Spacer(Modifier.weight(1f)) + if (state.durationMs > 0) BasicText("−" + PlayerLabels.time(remaining), style = AppType.MonoMeta.copy(fontSize = 12.sp)) + } + Timeline( + positionMs = positionMs, + durationMs = state.durationMs, + bufferedMs = state.bufferedMs, + chaptersMs = info?.chaptersMs.orEmpty(), + focused = ui.timelineFocused, + modifier = Modifier + .padding(top = 3.dp) + .focusRequester(ui.timeline) + .focusProperties { down = ui.playPause } + .onFocusChanged { ui.timelineFocused = it.isFocused } + .focusable(), + ) + Row( + Modifier + .padding(top = 12.dp) + .fillMaxWidth() + .onPreviewKeyEvent { + if (it.type == KeyEventType.KeyDown && it.key.nativeKeyCode == AndroidKeyEvent.KEYCODE_DPAD_DOWN && !drawer) { + onDrawer() + true + } else { + false + } + }, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + episodes?.previous?.let { id -> OsdCircleButton(AppIcon.Previous, 34.dp, 14.dp, onClick = { onEpisode(id) }) } + OsdCircleButton(AppIcon.Replay, 38.dp, 17.dp, onClick = { onSeek(-1) }) + OsdPlayButton(state.engineState.playWhenReady, onClick = onToggle, modifier = Modifier.focusRequester(ui.playPause)) + OsdCircleButton(AppIcon.Forward, 38.dp, 17.dp, onClick = { onSeek(1) }) + episodes?.next?.let { id -> OsdCircleButton(AppIcon.Next, 34.dp, 14.dp, onClick = { onEpisode(id) }) } + Spacer(Modifier.weight(1f)) + for ((panel, chip) in chips(state, info, media)) { + OsdChip(chip, onClick = { onPanel(panel) }, modifier = Modifier.focusRequester(ui.chips.getValue(panel))) + } + } + } + if (!drawer && info != null) { + PeekHint( + if (episodes != null) "Серии · сезон ${info.seasonNumber ?: ""}".trimEnd() else "О фильме", + Modifier.align(Alignment.BottomCenter).padding(bottom = 8.dp), + ) + } + } +} + +private val EndsFormat = DateTimeFormatter.ofPattern("HH:mm") + +/** Audio, subtitles, quality, Direct/Транскод: only the ones that have something to choose. */ +private fun chips(state: PlaybackUiState, info: PlayerInfo?, media: MediaInfo?): List> = buildList { + val selection = state.selection + if ((media?.audio?.size ?: 0) > 0) add(PlayerPanel.Audio to OsdChipModel("audio", AppColors.Pink, PlayerLabels.audioChip(media, selection.audioStreamIndex))) + if ((media?.subtitles?.size ?: 0) > 0) { + add(PlayerPanel.Subtitles to OsdChipModel("subs", AppColors.Yellow, PlayerLabels.subtitleChip(media, selection.subtitleStreamIndex))) + } + info?.qualities?.takeIf { it.size > 1 }?.let { options -> + val label = options.firstOrNull { it.bitrate == state.maxBitrate }?.label ?: "Оригинал" + add(PlayerPanel.Quality to OsdChipModel("quality", AppColors.Blue, label)) + } + val direct = PlayerLabels.isDirect(state) + add(PlayerPanel.Info to OsdChipModel("info", if (direct) AppColors.Green else AppColors.Orange, if (direct) "Direct" else "Транскод")) +} + +@Composable +private fun PanelLayer( + panel: PlayerPanel, + state: PlaybackUiState, + info: PlayerInfo?, + media: MediaInfo?, + tracks: PlayerTracks, + style: SubtitleStyle, + session: PlaybackSession, + vm: PlayerViewModel, + ui: PlayerUiState, + close: () -> Unit, + modifier: Modifier, +) { + val selection = state.selection + when (panel) { + PlayerPanel.Audio -> SidePanel("АУДИОДОРОЖКА", AppColors.Pink, "Назад — закрыть", ui.panelInitial, modifier) { + PanelSection("ДОРОЖКИ" + (info?.title?.let { " · " + it.uppercase() } ?: "")) { + PanelList(tracks.audio.toChoices(selection.audioStreamIndex), ui.panelInitial) { c -> + tracks.audio.firstOrNull { it.key == c.key }?.index?.let(session::selectAudio) + close() + } + } + PanelNote(if (info?.isEpisode == true) "Выбор запоминается для всего сериала." else "Выбор запоминается для этого фильма.") + } + PlayerPanel.Subtitles -> SidePanel("СУБТИТРЫ", AppColors.Yellow, "Назад — готово", ui.panelInitial, modifier) { + val selected = media?.subtitles?.firstOrNull { it.index == selection.subtitleStreamIndex } + SubtitlePreview(style, selected != null) + PanelSection("ДОРОЖКА") { + PanelList(tracks.subtitles.toChoices(selection.subtitleStreamIndex), ui.panelInitial) { c -> + session.selectSubtitle(tracks.subtitles.firstOrNull { it.key == c.key }?.index) + } + } + PanelSection("РАЗМЕР") { + PanelChips(SubtitleStyle.Size.entries.map { PanelChoice("size/$it", it.name, null, it == style.size) }) { c -> + vm.setSubtitleStyle(style.copy(size = SubtitleStyle.Size.valueOf(c.key.substringAfter('/')))) + } + } + PanelSection("ЦВЕТ") { + PanelChips(SUBTITLE_COLORS.map { (argb, name) -> PanelChoice("color/$argb", name, null, argb == style.color, Color(argb)) }) { c -> + vm.setSubtitleStyle(style.copy(color = c.key.substringAfter('/').toInt())) + } + } + PanelSection("ФОН") { + PanelChips(SUBTITLE_BACKGROUNDS.map { (bg, name) -> PanelChoice("bg/$bg", name, null, bg == style.background) }) { c -> + vm.setSubtitleStyle(style.copy(background = SubtitleStyle.Background.valueOf(c.key.substringAfter('/')))) + } + } + val codec = selected?.codec?.lowercase() + when { + codec in GRAPHIC_SUBTITLES -> PanelNote("Это графические субтитры (картинки): они показываются как есть, размер и цвет к ним не применяются.") + codec == "ass" || codec == "ssa" -> PanelNote("У субтитров ASS своё оформление из файла: размер и цвет здесь меняют только текстовые субтитры (SRT, VTT).") + else -> PanelNote("Вид применяется к текстовым субтитрам (SRT, VTT) и запоминается на этом ТВ.") + } + } + PlayerPanel.Quality -> SidePanel("КАЧЕСТВО", AppColors.Blue, "Назад — закрыть", ui.panelInitial, modifier) { + val options = info?.qualities.orEmpty() + PanelSection("БИТРЕЙТ") { + PanelList(options.map { PanelChoice(it.key, it.label, it.detail, it.bitrate == state.maxBitrate) }, ui.panelInitial) { c -> + vm.setQuality(options.firstOrNull { it.key == c.key }?.bitrate) + close() + } + } + PanelNote("Всё, что ниже оригинала, сервер перекодирует на лету. Это нагружает процессор сервера.") + } + PlayerPanel.Info -> SidePanel("ИНФОРМАЦИЯ О ПОТОКЕ", AppColors.Green, "Назад — закрыть", ui.panelInitial, modifier) { + val engine = session.activeEngine + StreamModeCard(PlayerLabels.mode(state)) + InfoRows(PlayerLabels.rows(state, media, engine)) + PanelCloseButton(onClick = close, modifier = Modifier.focusRequester(ui.panelInitial)) + PanelSection("ДОПОЛНИТЕЛЬНО · ДВИЖОК") { + val choices = listOf(PanelChoice("engine/auto", "Авто", "выбор по файлу", !state.manualEngine)) + + vm.selector.engines.map { PanelChoice("engine/${it.id}", it.displayName, null, state.manualEngine && state.engine == it.id) } + PanelList(choices, initial = null) { c -> + val id = c.key.substringAfter('/') + if (id == "auto") session.switchToAuto() else session.switchEngine(EngineId(id)) + close() + } + } + // Switching the path only changes the engine (no new state): [PlayerUiState.panelRevision] redraws the marks. + (engine as? VideoPathSwitchable)?.let { e -> key(ui.panelRevision) { + PanelSection("ВЫВОД ВИДЕО") { + val now = if (e.usingGpuPath) "сейчас через GPU" else "сейчас видеослой" + val choices = listOf( + PanelChoice("path/AUTO", "Авто", now, e.videoPathMode == VideoPathSwitchable.Mode.AUTO), + PanelChoice("path/VIDEO_PLANE", "Видеослой ТВ", "HDR, обработка ТВ", e.videoPathMode == VideoPathSwitchable.Mode.VIDEO_PLANE), + PanelChoice("path/GPU", "Через GPU", "без HDR, до 1080p", e.videoPathMode == VideoPathSwitchable.Mode.GPU), + ) + PanelList(choices, initial = null) { c -> + e.setVideoPathMode(VideoPathSwitchable.Mode.valueOf(c.key.substringAfter('/'))) + ui.panelRevision++ + } + } + } } + val dv = engine as? DolbyVisionSwitchable + if (dv != null && media?.video?.range == VideoRange.DolbyVision) { + PanelSection("DOLBY VISION") { + val choices = listOf( + PanelChoice("dv/false", "Базовый слой", "HDR10 / HDR10+", !dv.useDolbyVisionDecoder), + PanelChoice("dv/true", "DV-декодер", "режим Dolby Vision ТВ", dv.useDolbyVisionDecoder), + ) + PanelList(choices, initial = null) { c -> + val on = c.key.endsWith("true") + if (on != dv.useDolbyVisionDecoder) { + session.switchEngine(ExoEngineProvider.ID, videoPath = (engine as? VideoPathSwitchable)?.videoPathMode, dolbyVision = on) + } + close() + } + } + } + } + } +} + +private val SUBTITLE_COLORS = listOf(SubtitleStyle.WHITE to "Белый", SubtitleStyle.YELLOW to "Жёлтый", SubtitleStyle.LIGHT_BLUE to "Голубой") +private val SUBTITLE_BACKGROUNDS = listOf(SubtitleStyle.Background.NONE to "Нет", SubtitleStyle.Background.SHADOW to "Тень", SubtitleStyle.Background.BOX to "Плашка") +private val GRAPHIC_SUBTITLES = setOf("pgssub", "hdmv_pgs_subtitle", "dvdsub", "dvd_subtitle", "vobsub", "dvbsub") + +private const val OSD_TIMEOUT_MS = 6_000L +private const val TOP_SHADE = 105 +private const val BOTTOM_SHADE = 170 +private const val SCRUB_COMMIT_MS = 600L +private const val SCRUB_SETTLE_MS = 1_200L diff --git a/app/src/main/kotlin/ru/stenfredd/movies/ui/player/PlayerViewModel.kt b/app/src/main/kotlin/ru/stenfredd/movies/ui/player/PlayerViewModel.kt new file mode 100644 index 0000000..9a00437 --- /dev/null +++ b/app/src/main/kotlin/ru/stenfredd/movies/ui/player/PlayerViewModel.kt @@ -0,0 +1,293 @@ +package ru.stenfredd.movies.ui.player + +import androidx.compose.runtime.Immutable +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import ru.stenfredd.movies.core.data.LibraryRepository +import ru.stenfredd.movies.core.data.model.ItemDetails +import ru.stenfredd.movies.core.data.model.ItemKind +import ru.stenfredd.movies.core.data.model.ItemSummary +import ru.stenfredd.movies.core.data.model.PersonKind +import ru.stenfredd.movies.playback.api.EngineId +import ru.stenfredd.movies.playback.api.StreamSelection +import ru.stenfredd.movies.playback.api.SubtitleStyle +import ru.stenfredd.movies.playback.core.DeviceCapabilitiesProbe +import ru.stenfredd.movies.playback.core.EngineSelector +import ru.stenfredd.movies.playback.core.PlaybackPreferences +import ru.stenfredd.movies.playback.core.PlaybackSession +import ru.stenfredd.movies.playback.core.PlaybackSessionFactory +import ru.stenfredd.movies.ui.Screen +import ru.stenfredd.movies.ui.components.RemoteImageSpec +import ru.stenfredd.movies.ui.details.CastModel +import ru.stenfredd.movies.ui.details.MediaLabels +import ru.stenfredd.movies.ui.model.CardMapper +import timber.log.Timber +import java.util.Locale +import java.util.UUID + +/** "Качество": [bitrate] null = the original file. */ +@Immutable +data class QualityOption(val key: String, val bitrate: Int?, val label: String, val detail: String?) + +/** One episode in the player's drawer. */ +@Immutable +data class EpisodeModel( + val key: String, + val id: UUID, + /** "E04" */ + val number: String, + /** "4. Разлом" */ + val title: String, + val duration: String?, + val image: RemoteImageSpec?, + val progress: Float?, + val watched: Boolean, + val isNew: Boolean, +) + +/** What the controls and the drawer show about the item being played; display strings only. */ +@Immutable +data class PlayerInfo( + val id: UUID, + val isEpisode: Boolean, + /** "ФИЛЬМ", "СЕРИАЛ" */ + val label: String, + val title: String, + /** "2024 · Драма, Приключения" or "Сезон 2 · Серия 4 · Разлом" */ + val subtitle: String?, + val chaptersMs: List, + val poster: RemoteImageSpec?, + val rating: String?, + val age: String?, + val year: String?, + val runtime: String?, + val genres: String?, + val description: String?, + val director: String?, + val cast: List, + val seasonNumber: Int?, + val qualities: List, +) + +/** The season of the episode being played, for the drawer and ⏮/⏭. */ +@Immutable +data class SeasonEpisodes( + /** "СЕЗОН 2 · СЕРИИ" */ + val title: String, + /** "10 серий · 3 просмотрено" */ + val stat: String, + val episodes: List, + val previous: UUID?, + val next: UUID?, +) + +@HiltViewModel +class PlayerViewModel @javax.inject.Inject constructor( + private val factory: PlaybackSessionFactory, + val selector: EngineSelector, + val probe: DeviceCapabilitiesProbe, + private val library: LibraryRepository, + private val cards: CardMapper, + private val prefs: PlaybackPreferences, +) : ViewModel() { + private val _session = MutableStateFlow(null) + val session: StateFlow = _session.asStateFlow() + + private val _info = MutableStateFlow(null) + val info: StateFlow = _info.asStateFlow() + + private val _season = MutableStateFlow(null) + val season: StateFlow = _season.asStateFlow() + + val subtitleStyle: StateFlow = prefs.subtitleStyle.stateIn(viewModelScope, SharingStarted.Eagerly, SubtitleStyle()) + + private var startedFor: Screen.Player? = null + private var infoJob: Job? = null + /** Quality chosen in the player: kept for the next episode. */ + private var maxBitrate: Int? = null + /** Every episode of the series being played, in order. */ + private var allEpisodes: List = emptyList() + + fun start(screen: Screen.Player) { + if (screen == startedFor) return + startedFor = screen + play(screen.itemId, screen.engine, screen.startPositionMs, screen.streams, screen.startFraction) + } + + /** Switches to another episode (drawer, ⏮/⏭, end of an episode): from its resume point, with the remembered tracks. */ + fun playEpisode(id: UUID) { + val episode = allEpisodes.firstOrNull { it.id == id } ?: return + play(id, null, episode.resumeMs(), streams = null, startFraction = null) + } + + private fun ItemSummary.resumeMs(): Long = user.progress?.takeIf { it < RESUME_LIMIT }?.let { user.positionMs } ?: 0 + + private fun play(itemId: UUID, engine: EngineId?, startMs: Long, streams: StreamSelection?, startFraction: Double?) { + val old = _session.value + val session = factory.create(viewModelScope) + session.setMaxBitrate(maxBitrate) + _session.value = session + old?.release() + session.start(itemId, engine, startMs, streams, startFraction) + loadInfo(itemId) + } + + fun setQuality(bitrate: Int?) { + maxBitrate = bitrate + _session.value?.setMaxBitrate(bitrate) + } + + fun setSubtitleStyle(style: SubtitleStyle) { + _session.value?.setSubtitleStyle(style) + viewModelScope.launch { prefs.setSubtitleStyle(style) } + } + + /** Releases [target] (default: the current session); a stale screen disposing late can't kill a newer session. */ + fun release(target: PlaybackSession? = _session.value) { + target?.release() + if (target === _session.value) { + _session.value = null + startedFor = null + } + } + + override fun onCleared() = release() + + private fun loadInfo(itemId: UUID) { + infoJob?.cancel() + if (_info.value?.id != itemId) _info.value = null + infoJob = viewModelScope.launch { + try { + val details = library.details(itemId) + _info.value = withContext(Dispatchers.Default) { details.toInfo() } + val item = details.summary + val seriesId = item.seriesId + if (item.kind == ItemKind.Episode && seriesId != null) { + val all = library.episodes(seriesId) + allEpisodes = all + _season.value = withContext(Dispatchers.Default) { season(item, all) } + } else { + _season.value = null + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Timber.w(e, "Player: info failed") + } + } + } + + private fun season(current: ItemSummary, all: List): SeasonEpisodes { + val index = all.indexOfFirst { it.id == current.id } + val inSeason = all.filter { it.seasonId == current.seasonId } + val watched = inSeason.count { it.user.played } + return SeasonEpisodes( + title = listOfNotNull(current.seasonNumber?.let { "СЕЗОН $it" }, "СЕРИИ").joinToString(" · "), + stat = "${inSeason.size} ${plural(inSeason.size, "серия", "серии", "серий")} · $watched просмотрено", + episodes = inSeason.map { e -> + val progress = e.user.progress + EpisodeModel( + key = "ep/${e.id}", + id = e.id, + number = e.episodeNumber?.let { "E%02d".format(it) } ?: "", + title = listOfNotNull(e.episodeNumber?.let { "$it." }, e.name).joinToString(" "), + duration = e.runtimeMs?.let { "${it / 60_000} мин" }, + image = cards.image(e.primary ?: e.thumb ?: e.backdrop, PlayerLayout.EPISODE_W, PlayerLayout.EPISODE_H), + progress = progress, + watched = e.user.played, + isNew = cards.isNew(e), + ) + }, + previous = all.getOrNull(index - 1)?.id?.takeIf { index > 0 }, + next = all.getOrNull(index + 1)?.id?.takeIf { index >= 0 }, + ) + } + + private fun ItemDetails.toInfo(): PlayerInfo { + val item = summary + val episode = item.kind == ItemKind.Episode + val media = media + return PlayerInfo( + id = item.id, + isEpisode = episode, + label = if (episode) "СЕРИАЛ" else "ФИЛЬМ", + title = if (episode) item.seriesName ?: item.name else item.name, + subtitle = if (episode) { + // Episodes without their own title are often named "Серия 3": don't say it twice. + val name = item.name.takeUnless { n -> item.episodeNumber != null && n.equals("Серия ${item.episodeNumber}", ignoreCase = true) } + listOfNotNull(item.seasonNumber?.let { "Сезон $it" }, item.episodeNumber?.let { "Серия $it" }, name).joinToString(" · ") + } else { + listOfNotNull(item.year?.toString(), item.genres.take(3).joinToString(", ").ifEmpty { null }).joinToString(" · ").ifEmpty { null } + }, + chaptersMs = chapters.map { it.startMs }.filter { it > 0 }, + poster = cards.image(if (episode) item.seriesPrimary ?: item.primary else item.primary, PlayerLayout.POSTER_W, PlayerLayout.POSTER_H), + rating = item.communityRating?.let { String.format(Locale.US, "★ %.1f", it) }, + age = item.officialRating?.let { r -> Regex("""(\d{1,2}\+)$""").find(r)?.value ?: r }, + year = item.year?.toString(), + runtime = MediaLabels.runtime(item.runtimeMs), + genres = item.genres.take(3).joinToString(", ").ifEmpty { null }, + description = item.overview, + director = people.filter { it.kind == PersonKind.Director }.joinToString(", ") { it.name }.ifEmpty { null }, + cast = people.filter { it.kind == PersonKind.Actor }.take(CAST_LIMIT).map { p -> + CastModel( + key = "cast/${p.id}", + name = p.name, + role = p.role, + letters = p.name.split(' ').filter { it.isNotBlank() }.take(2).joinToString("") { it.take(1).uppercase() }, + image = cards.image(p.image, PlayerLayout.AVATAR, PlayerLayout.AVATAR), + ) + }, + seasonNumber = item.seasonNumber, + qualities = qualities(media?.bitrate, media?.video?.height), + ) + } + + /** "Оригинал" and the transcode caps below the file's own bitrate, labelled with the resulting resolution. */ + private fun qualities(fileBitrate: Int?, height: Int?): List { + val original = QualityOption("q/original", null, "Оригинал", fileBitrate?.let { PlayerLabels.mbps(it) }) + val caps = QUALITY_CAPS.filter { (bitrate, _) -> fileBitrate == null || bitrate < fileBitrate * 0.9 } + return listOf(original) + caps.map { (bitrate, lines) -> + val shown = if (height != null && height in 1 until lines) height else lines + QualityOption("q/$bitrate", bitrate, "${shown}p", PlayerLabels.mbps(bitrate)) + } + } + + private companion object { + const val CAST_LIMIT = 4 + /** An episode watched further than this starts from the beginning when picked in the drawer. */ + const val RESUME_LIMIT = 0.9f + /** Bitrate cap → resolution the server scales to at that bitrate (its own rule of thumb). */ + val QUALITY_CAPS = listOf(20_000_000 to 1080, 10_000_000 to 1080, 4_000_000 to 720, 1_500_000 to 480) + } +} + +internal fun plural(n: Int, one: String, few: String, many: String): String { + val mod100 = n % 100 + val mod10 = n % 10 + return when { + mod100 in 11..14 -> many + mod10 == 1 -> one + mod10 in 2..4 -> few + else -> many + } +} + +/** Sizes (dp) shared by the layout and the image requests. */ +internal object PlayerLayout { + const val EPISODE_W = 180f + const val EPISODE_H = 101f + const val POSTER_W = 100f + const val POSTER_H = 150f + const val AVATAR = 24f +} diff --git a/app/src/main/kotlin/ru/stenfredd/movies/ui/shell/Shell.kt b/app/src/main/kotlin/ru/stenfredd/movies/ui/shell/Shell.kt index 3c4154f..71b4ab3 100644 --- a/app/src/main/kotlin/ru/stenfredd/movies/ui/shell/Shell.kt +++ b/app/src/main/kotlin/ru/stenfredd/movies/ui/shell/Shell.kt @@ -28,6 +28,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.text.TextStyle import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.focus.FocusRequester import androidx.compose.foundation.focusGroup @@ -259,7 +260,7 @@ private fun ProfileItem(userName: String, userSeed: String, progress: State = emptyMap(), ) + +/** Look of text subtitles, chosen in the player's subtitle panel. */ +data class SubtitleStyle( + val size: Size = Size.M, + /** ARGB. */ + val color: Int = WHITE, + val background: Background = Background.SHADOW, +) { + enum class Size(val scale: Float) { S(0.8f), M(1f), L(1.25f), XL(1.5f) } + + enum class Background { NONE, SHADOW, BOX } + + companion object { + const val WHITE = 0xFFFFFFFF.toInt() + const val YELLOW = 0xFFFFE066.toInt() + const val LIGHT_BLUE = 0xFF9FD3FF.toInt() + } +} diff --git a/playback/api/src/main/kotlin/ru/stenfredd/movies/playback/api/PlaybackEngine.kt b/playback/api/src/main/kotlin/ru/stenfredd/movies/playback/api/PlaybackEngine.kt index e8ca771..66699e8 100644 --- a/playback/api/src/main/kotlin/ru/stenfredd/movies/playback/api/PlaybackEngine.kt +++ b/playback/api/src/main/kotlin/ru/stenfredd/movies/playback/api/PlaybackEngine.kt @@ -22,6 +22,9 @@ interface PlaybackEngine { /** False once the engine knows it can't seek in this source (e.g. a Matroska index media3 can't find). */ val isSeekable: Boolean get() = true + /** How far the media is loaded ahead (the timeline's buffer bar), or -1 if the engine doesn't tell. */ + val bufferedPositionMs: Long get() = -1 + /** Adds the engine's own video views (surface, subtitle overlay) into [container]. */ fun attachVideo(container: FrameLayout) fun detachVideo() @@ -65,6 +68,15 @@ interface DolbyVisionSwitchable { var useDolbyVisionDecoder: Boolean } +/** Engines that draw text subtitles (SRT, VTT …) in the user's style; ASS and bitmap subtitles keep their own look. */ +interface SubtitleStyleable { + /** Returns false if the engine can only take the style before [PlaybackEngine.load] (the session then restarts it). */ + fun setSubtitleStyle(style: SubtitleStyle): Boolean + + /** Lifts text subtitles above the player controls while they are shown; fraction of the video height. */ + fun setSubtitleLift(fraction: Float) {} +} + interface SubtitleDelayCapable { fun setSubtitleDelay(delayMs: Long) } diff --git a/playback/core/build.gradle.kts b/playback/core/build.gradle.kts index 292144c..705d3b7 100644 --- a/playback/core/build.gradle.kts +++ b/playback/core/build.gradle.kts @@ -20,4 +20,5 @@ dependencies { implementation(libs.hilt.android) ksp(libs.hilt.compiler) implementation(libs.timber) + implementation(libs.androidx.datastore.preferences) } diff --git a/playback/core/src/main/kotlin/ru/stenfredd/movies/playback/core/PlaybackPreferences.kt b/playback/core/src/main/kotlin/ru/stenfredd/movies/playback/core/PlaybackPreferences.kt new file mode 100644 index 0000000..6e97899 --- /dev/null +++ b/playback/core/src/main/kotlin/ru/stenfredd/movies/playback/core/PlaybackPreferences.kt @@ -0,0 +1,108 @@ +package ru.stenfredd.movies.playback.core + +import android.content.Context +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import org.json.JSONObject +import ru.stenfredd.movies.core.jellyfin.JellyfinSession +import ru.stenfredd.movies.playback.api.SubtitleStyle +import timber.log.Timber +import java.util.UUID +import javax.inject.Inject +import javax.inject.Singleton + +private val Context.playbackStore by preferencesDataStore("playback") + +/** + * A remembered track, by what identifies it across files of one series: an episode's stream indexes differ from the + * next one's, but "Русский · HDRezka, AC3" stays the same. + */ +data class TrackPick(val language: String?, val name: String?, val codec: String?) { + /** The candidate that is the same track: language must agree, then the track's own title, then the codec. */ + fun bestOf(candidates: List, describe: (T) -> TrackPick): T? = candidates + .map { it to describe(it) } + .filter { (_, c) -> c.language.equals(language, ignoreCase = true) || (language == null && c.name != null && c.name == name) } + .maxByOrNull { (_, c) -> (if (c.name == name) 4 else 0) + (if (c.codec.equals(codec, ignoreCase = true)) 2 else 0) } + ?.first +} + +/** What the user picked last for a movie or a series. [subtitle] null with [subtitleOff] = subtitles off. */ +data class RememberedTracks(val audio: TrackPick?, val subtitle: TrackPick?, val subtitleOff: Boolean) + +/** + * Player settings kept on the device: the look of text subtitles (for everyone on this TV) and the tracks chosen + * per movie or series (per account: family members watch with different tracks). + */ +@Singleton +class PlaybackPreferences @Inject constructor( + @ApplicationContext private val context: Context, + private val session: JellyfinSession, +) { + val subtitleStyle: Flow = context.playbackStore.data.map { parseStyle(it[KEY_SUBTITLE_STYLE]) } + + suspend fun subtitleStyle(): SubtitleStyle = subtitleStyle.first() + + suspend fun setSubtitleStyle(style: SubtitleStyle) { + context.playbackStore.edit { it[KEY_SUBTITLE_STYLE] = "${style.size}|${style.color}|${style.background}" } + } + + /** [key]: the series of an episode, else the item itself. */ + suspend fun tracks(key: UUID): RememberedTracks? { + val raw = context.playbackStore.data.first()[tracksKey(key) ?: return null] ?: return null + return runCatching { + val json = JSONObject(raw) + RememberedTracks( + audio = json.optJSONObject("a")?.toPick(), + subtitle = json.optJSONObject("s")?.toPick(), + subtitleOff = json.optBoolean("off"), + ) + }.onFailure { Timber.w(it, "Bad remembered tracks %s", raw) }.getOrNull() + } + + suspend fun rememberAudio(key: UUID, pick: TrackPick) = update(key) { it.put("a", pick.toJson()) } + + /** null = subtitles off. */ + suspend fun rememberSubtitle(key: UUID, pick: TrackPick?) = update(key) { + if (pick == null) it.remove("s") else it.put("s", pick.toJson()) + it.put("off", pick == null) + } + + private suspend fun update(key: UUID, change: (JSONObject) -> Unit) { + val prefKey = tracksKey(key) ?: return + context.playbackStore.edit { prefs -> + val json = prefs[prefKey]?.let { runCatching { JSONObject(it) }.getOrNull() } ?: JSONObject() + change(json) + prefs[prefKey] = json.toString() + } + } + + private fun tracksKey(key: UUID): Preferences.Key? { + val account = session.session.value?.accountId ?: return null + return stringPreferencesKey("tracks/$account/$key") + } + + private fun parseStyle(raw: String?): SubtitleStyle { + val parts = raw?.split('|') ?: return SubtitleStyle() + return runCatching { + SubtitleStyle(SubtitleStyle.Size.valueOf(parts[0]), parts[1].toInt(), SubtitleStyle.Background.valueOf(parts[2])) + }.getOrDefault(SubtitleStyle()) + } + + private fun TrackPick.toJson() = JSONObject().apply { + language?.let { put("l", it) } + name?.let { put("n", it) } + codec?.let { put("c", it) } + } + + private fun JSONObject.toPick() = TrackPick(optString("l").ifEmpty { null }, optString("n").ifEmpty { null }, optString("c").ifEmpty { null }) + + private companion object { + val KEY_SUBTITLE_STYLE = stringPreferencesKey("subtitle_style") + } +} diff --git a/playback/core/src/main/kotlin/ru/stenfredd/movies/playback/core/PlaybackSession.kt b/playback/core/src/main/kotlin/ru/stenfredd/movies/playback/core/PlaybackSession.kt index c29a1d1..daa6c02 100644 --- a/playback/core/src/main/kotlin/ru/stenfredd/movies/playback/core/PlaybackSession.kt +++ b/playback/core/src/main/kotlin/ru/stenfredd/movies/playback/core/PlaybackSession.kt @@ -22,6 +22,7 @@ import org.jellyfin.sdk.api.client.extensions.sessionApi import org.jellyfin.sdk.api.client.extensions.videoApi import org.jellyfin.sdk.model.api.BaseItemDto import org.jellyfin.sdk.model.api.MediaSourceInfo +import org.jellyfin.sdk.model.api.MediaStream import org.jellyfin.sdk.model.api.MediaStreamType import org.jellyfin.sdk.model.api.PlayMethod import org.jellyfin.sdk.model.api.PlaybackInfoDto @@ -43,6 +44,10 @@ import ru.stenfredd.movies.playback.api.ExternalSubtitle import ru.stenfredd.movies.playback.api.LoadRequest import ru.stenfredd.movies.playback.api.PlaybackEngine import ru.stenfredd.movies.playback.api.StreamSelection +import ru.stenfredd.movies.playback.api.SubtitleStyle +import ru.stenfredd.movies.playback.api.SubtitleStyleable +import ru.stenfredd.movies.playback.api.stream +import ru.stenfredd.movies.playback.api.streams import ru.stenfredd.movies.playback.api.Verdict import ru.stenfredd.movies.playback.api.VideoPathSwitchable import ru.stenfredd.movies.playback.api.videoStream @@ -58,6 +63,15 @@ data class PlaybackUiState( val engineState: EngineState = EngineState(), val positionMs: Long = 0, val durationMs: Long = 0, + /** Loaded up to here, or -1 if the engine doesn't tell. */ + val bufferedMs: Long = -1, + /** The item being played (an episode, a movie) and the series of an episode. */ + val itemId: UUID? = null, + val seriesId: UUID? = null, + /** Quality cap chosen in the player, bit/s; null = original. */ + val maxBitrate: Int? = null, + /** The engine was picked by the user ("Дополнительно"), not by the selector. */ + val manualEngine: Boolean = false, val playMethod: PlayMethod? = null, val transcodeReasons: String? = null, val source: MediaSourceInfo? = null, @@ -72,8 +86,9 @@ class PlaybackSessionFactory @Inject constructor( private val session: JellyfinSession, private val selector: EngineSelector, private val probe: DeviceCapabilitiesProbe, + private val prefs: PlaybackPreferences, ) { - fun create(scope: CoroutineScope) = PlaybackSession(session, selector, probe, scope) + fun create(scope: CoroutineScope) = PlaybackSession(session, selector, probe, prefs, scope) } /** @@ -87,6 +102,7 @@ class PlaybackSession( private val session: JellyfinSession, private val selector: EngineSelector, private val probe: DeviceCapabilitiesProbe, + private val prefs: PlaybackPreferences, private val scope: CoroutineScope, ) { private val _state = MutableStateFlow(PlaybackUiState()) @@ -116,6 +132,9 @@ class PlaybackSession( /** 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 maxBitrate: Int? = null + private var subtitleStyle = SubtitleStyle() + private var subtitleLift = 0f private var playSessionId: String? = null private var playMethod: PlayMethod = PlayMethod.DIRECT_PLAY @@ -140,12 +159,15 @@ class PlaybackSession( withContext(Dispatchers.Default) { probe.capabilities } item = session.call { libraryApi.getItem(itemId, user.userId).content } source = item.mediaSources?.firstOrNull() ?: error("У элемента нет медиаисточников") - selection = streams ?: StreamSelection(source.defaultAudioStreamIndex, source.defaultSubtitleStreamIndex) + subtitleStyle = prefs.subtitleStyle() + selection = streams ?: rememberedSelection() ?: StreamSelection(source.defaultAudioStreamIndex, source.defaultSubtitleStreamIndex) val startMs = startFraction?.let { ((item.runTimeTicks ?: 0) / TICKS_PER_MS * it).toLong() } ?: startPositionMs ranking = selector.rank(source, selection, device) _state.update { it.copy( title = item.name.orEmpty(), + itemId = item.id, + seriesId = item.seriesId, source = source, selection = selection, ranking = ranking.map { r -> r.provider.id to r.verdict }, @@ -155,6 +177,7 @@ class PlaybackSession( val first = forcedEngine?.let(selector::provider) ?: ranking.first().provider val reason = if (forcedEngine != null) "выбран вручную" else ranking.first().verdict.describe() manualEngine = forcedEngine != null + _state.update { it.copy(manualEngine = manualEngine) } launchEngine(first, startMs, forceTranscode = false, reason = reason) }.onFailure { fail("Не удалось начать воспроизведение", it) } } @@ -166,6 +189,13 @@ class PlaybackSession( reportProgressAsync() } + fun pause() { + val e = engine ?: return + if (!e.state.value.playWhenReady) return + e.pause() + reportProgressAsync() + } + fun seekBy(deltaMs: Long) = seekTo(positionMs() + deltaMs) fun seekTo(positionMs: Long) { @@ -189,13 +219,60 @@ class PlaybackSession( } fun selectAudio(streamIndex: Int) { + if (selection.audioStreamIndex == streamIndex) return selection = selection.copy(audioStreamIndex = streamIndex) onSelectionChanged("смена аудио") { it.selectAudio(streamIndex) } + source.stream(streamIndex)?.let { s -> scope.launch { prefs.rememberAudio(memoryKey(), s.pick()) } } } fun selectSubtitle(streamIndex: Int?) { + if (selection.subtitleStreamIndex == streamIndex) return selection = selection.copy(subtitleStreamIndex = streamIndex) onSelectionChanged("смена субтитров") { it.selectSubtitle(streamIndex) } + val pick = streamIndex?.let { source.stream(it)?.pick() } + scope.launch { prefs.rememberSubtitle(memoryKey(), pick) } + } + + /** Remembered per series (an episode) or per movie. */ + private fun memoryKey(): UUID = item.seriesId ?: item.id + + /** The tracks picked last time for this movie or series, found again in this file. */ + private suspend fun rememberedSelection(): StreamSelection? { + val memory = prefs.tracks(memoryKey()) ?: return null + val audio = memory.audio?.bestOf(source.streams(MediaStreamType.AUDIO)) { it.pick() }?.index + val subtitle = if (memory.subtitleOff) null else memory.subtitle?.bestOf(source.streams(MediaStreamType.SUBTITLE)) { it.pick() }?.index + if (audio == null && subtitle == null && !memory.subtitleOff) return null + log("Запомненные дорожки: аудио $audio, субтитры $subtitle") + return StreamSelection(audio ?: source.defaultAudioStreamIndex, if (memory.subtitleOff || subtitle != null) subtitle else source.defaultSubtitleStreamIndex) + } + + private fun MediaStream.pick() = TrackPick(language, title?.takeIf { it.isNotBlank() }, codec) + + /** Quality: null = original (direct play where possible), else the server transcodes down to [bitrate]. */ + fun setMaxBitrate(bitrate: Int?) { + if (bitrate == maxBitrate) return + maxBitrate = bitrate + _state.update { it.copy(maxBitrate = bitrate) } + val provider = engine?.id?.let(selector::provider) ?: return + val position = positionMs() + val reason = if (bitrate == null) "качество: оригинал" else "качество: до ${bitrate / 1000} кбит/с" + // The server decides: direct play if the file fits under the cap, else a transcode. + scope.launch { + runCatching { launchEngine(provider, position, forceTranscode = false, reason = reason) }.onFailure { fail("Не удалось сменить качество", it) } + } + } + + fun setSubtitleStyle(style: SubtitleStyle) { + if (style == subtitleStyle) return + subtitleStyle = style + val e = engine as? SubtitleStyleable ?: return + // libVLC takes the look only at start: restart it, but only if text subtitles are on screen now. + if (!e.setSubtitleStyle(style) && selection.subtitleStreamIndex != null) restartCurrent("вид субтитров") + } + + fun setSubtitleLift(fraction: Float) { + subtitleLift = fraction + (engine as? SubtitleStyleable)?.setSubtitleLift(fraction) } /** @@ -228,6 +305,7 @@ class PlaybackSession( nextVideoPath = videoPath nextDolbyVision = dolbyVision manualEngine = true + _state.update { it.copy(manualEngine = true) } launchEngine(selector.provider(id), position, forceTranscode = transcode, reason = if (transcode) "транскод вручную" else "выбран вручную") } } @@ -243,6 +321,7 @@ class PlaybackSession( nextDolbyVision = null defaultDolbyVision = true manualEngine = false + _state.update { it.copy(manualEngine = false) } launchEngine(first.provider, position, forceTranscode = false, reason = first.verdict.describe()) } } @@ -251,6 +330,7 @@ class PlaybackSession( val position = positionMs() val provider = engine?.id?.let(selector::provider) ?: return manualEngine = true + _state.update { it.copy(manualEngine = true) } scope.launch { launchEngine(provider, position, forceTranscode = true, reason = "транскод вручную") } } @@ -293,7 +373,7 @@ class PlaybackSession( item.id, PlaybackInfoDto( userId = user.userId, - maxStreamingBitrate = MAX_BITRATE, + maxStreamingBitrate = maxBitrate ?: MAX_BITRATE, startTimeTicks = startMs * TICKS_PER_MS, audioStreamIndex = selection.audioStreamIndex, subtitleStreamIndex = selection.subtitleStreamIndex, @@ -346,6 +426,10 @@ class PlaybackSession( nextVideoPath?.let { (newEngine as? VideoPathSwitchable)?.setVideoPathMode(it) } nextVideoPath = null nextDolbyVision?.let { (newEngine as? DolbyVisionSwitchable)?.useDolbyVisionDecoder = it } + (newEngine as? SubtitleStyleable)?.let { + it.setSubtitleStyle(subtitleStyle) + it.setSubtitleLift(subtitleLift) + } nextDolbyVision = null defaultDolbyVision = false engine = newEngine @@ -476,7 +560,7 @@ class PlaybackSession( var sinceReport = 0L while (scope.isActive && gen == generation) { val e = engine ?: return - _state.update { it.copy(positionMs = positionMs(), durationMs = e.durationMs) } + _state.update { it.copy(positionMs = positionMs(), durationMs = e.durationMs, bufferedMs = e.bufferedPositionMs) } delay(TICK_MS) sinceReport += TICK_MS if (sinceReport >= PROGRESS_INTERVAL_MS) { diff --git a/playback/engine-exo/src/main/kotlin/ru/stenfredd/movies/playback/exo/ExoEngine.kt b/playback/engine-exo/src/main/kotlin/ru/stenfredd/movies/playback/exo/ExoEngine.kt index d137870..c1046e5 100644 --- a/playback/engine-exo/src/main/kotlin/ru/stenfredd/movies/playback/exo/ExoEngine.kt +++ b/playback/engine-exo/src/main/kotlin/ru/stenfredd/movies/playback/exo/ExoEngine.kt @@ -4,6 +4,7 @@ package ru.stenfredd.movies.playback.exo import android.content.Context import android.content.res.Configuration +import android.graphics.Color import android.os.Build import android.os.Handler import android.os.Looper @@ -37,6 +38,7 @@ import androidx.media3.exoplayer.mediacodec.MediaCodecUtil import androidx.media3.exoplayer.audio.AudioSink import androidx.media3.exoplayer.trackselection.DefaultTrackSelector import androidx.media3.ui.AspectRatioFrameLayout +import androidx.media3.ui.CaptionStyleCompat import androidx.media3.ui.SubtitleView import io.github.peerless2012.ass.media.AssHandler import io.github.peerless2012.ass.media.AssHandlerConfig @@ -65,12 +67,14 @@ import ru.stenfredd.movies.playback.api.EngineState import ru.stenfredd.movies.playback.api.EngineStats import ru.stenfredd.movies.playback.api.LoadRequest import ru.stenfredd.movies.playback.api.PlaybackEngine +import ru.stenfredd.movies.playback.api.SubtitleStyle +import ru.stenfredd.movies.playback.api.SubtitleStyleable import ru.stenfredd.movies.playback.api.VideoPathSwitchable import ru.stenfredd.movies.playback.api.embeddedOrdinal import ru.stenfredd.movies.playback.api.videoStream import timber.log.Timber -class ExoEngine(context: Context) : PlaybackEngine, VideoPathSwitchable, DolbyVisionSwitchable { +class ExoEngine(context: Context) : PlaybackEngine, VideoPathSwitchable, DolbyVisionSwitchable, SubtitleStyleable { override val id = ExoEngineProvider.ID /** See [gpuPathFor]: only TVs known to over-process SDR on the video plane. */ @@ -145,9 +149,10 @@ class ExoEngine(context: Context) : PlaybackEngine, VideoPathSwitchable, DolbyVi resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT addView(surfaceView, FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)) } - private val subtitleView = SubtitleView(context).apply { - setUserDefaultStyle() - setUserDefaultTextSize() + private val subtitleView = SubtitleView(context) + + init { + setSubtitleStyle(SubtitleStyle()) } private var request: LoadRequest? = null @@ -371,6 +376,28 @@ class ExoEngine(context: Context) : PlaybackEngine, VideoPathSwitchable, DolbyVi override val isSeekable get() = player.currentTimeline.isEmpty || player.isCurrentMediaItemSeekable + override val bufferedPositionMs get() = player.bufferedPosition + + override fun setSubtitleStyle(style: SubtitleStyle): Boolean { + val box = style.background == SubtitleStyle.Background.BOX + subtitleView.setStyle( + CaptionStyleCompat( + style.color, + if (box) BOX_COLOR else Color.TRANSPARENT, + Color.TRANSPARENT, + if (style.background == SubtitleStyle.Background.SHADOW) CaptionStyleCompat.EDGE_TYPE_DROP_SHADOW else CaptionStyleCompat.EDGE_TYPE_NONE, + Color.BLACK, + null, + ), + ) + subtitleView.setFractionalTextSize(SubtitleView.DEFAULT_TEXT_SIZE_FRACTION * style.size.scale) + return true + } + + override fun setSubtitleLift(fraction: Float) { + subtitleView.setBottomPaddingFraction(SubtitleView.DEFAULT_BOTTOM_PADDING_FRACTION + fraction) + } + override fun play() = player.play() override fun pause() = player.pause() override fun seekTo(positionMs: Long) { @@ -491,6 +518,8 @@ class ExoEngine(context: Context) : PlaybackEngine, VideoPathSwitchable, DolbyVi companion object { private const val EXTERNAL_PREFIX = "ext:" + /** "Плашка" behind the text: black, ~72 % opaque as in the mockup. */ + private const val BOX_COLOR = 0xB8000000.toInt() 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. */ diff --git a/playback/engine-vlc/src/main/kotlin/ru/stenfredd/movies/playback/vlc/VlcEngine.kt b/playback/engine-vlc/src/main/kotlin/ru/stenfredd/movies/playback/vlc/VlcEngine.kt index 556fad1..0cc7e92 100644 --- a/playback/engine-vlc/src/main/kotlin/ru/stenfredd/movies/playback/vlc/VlcEngine.kt +++ b/playback/engine-vlc/src/main/kotlin/ru/stenfredd/movies/playback/vlc/VlcEngine.kt @@ -31,6 +31,8 @@ import ru.stenfredd.movies.playback.api.EngineStats import ru.stenfredd.movies.playback.api.LoadRequest import ru.stenfredd.movies.playback.api.PlaybackEngine import ru.stenfredd.movies.playback.api.SubtitleDelayCapable +import ru.stenfredd.movies.playback.api.SubtitleStyle +import ru.stenfredd.movies.playback.api.SubtitleStyleable import ru.stenfredd.movies.playback.api.embeddedOrdinal import ru.stenfredd.movies.playback.api.stream import ru.stenfredd.movies.playback.api.streams @@ -42,11 +44,26 @@ import timber.log.Timber * objects in [release] (leaks here were what killed libVLC in the official Android TV client). * All network access goes through [LoopbackProxy]: libVLC itself only sees plain HTTP on 127.0.0.1. */ -class VlcEngine(context: Context, httpClient: OkHttpClient, private val device: DeviceCapabilities) : PlaybackEngine, AudioDelayCapable, SubtitleDelayCapable { +class VlcEngine(context: Context, httpClient: OkHttpClient, private val device: DeviceCapabilities) : + PlaybackEngine, AudioDelayCapable, SubtitleDelayCapable, SubtitleStyleable { override val id = VlcEngineProvider.ID - private val libVlc = LibVLC(context.applicationContext, OPTIONS) - private val player = MediaPlayer(libVlc) + private val appContext = context.applicationContext + private var subtitleStyle = SubtitleStyle() + + // Created on first use: libVLC reads the text subtitle look only from its instance options (see [setSubtitleStyle]). + private val libVlcLazy = lazy { + LibVLC(appContext, OPTIONS + styleOptions(subtitleStyle)).also { + // The native library is loaded by the first LibVLC: version() only works after that. + _stats.update { s -> s.copy(videoDecoder = "libVLC ${LibVLC.version()}") } + } + } + private val libVlc by libVlcLazy + private val playerLazy = lazy { + Dialog.setCallbacks(libVlc, dialogCallbacks) + MediaPlayer(libVlc).apply { setEventListener(::onEvent) } + } + private val player by playerLazy private val proxy = LoopbackProxy(httpClient) private var videoLayout: VLCVideoLayout? = null private var media: Media? = null @@ -56,7 +73,7 @@ class VlcEngine(context: Context, httpClient: OkHttpClient, private val device: override val state = _state.asStateFlow() private val _events = MutableSharedFlow(extraBufferCapacity = 16, onBufferOverflow = BufferOverflow.DROP_OLDEST) override val events = _events.asSharedFlow() - private val _stats = MutableStateFlow(EngineStats(videoDecoder = "libVLC ${LibVLC.version()}")) + private val _stats = MutableStateFlow(EngineStats()) override val stats = _stats.asStateFlow() override val positionMs get() = player.time.coerceAtLeast(0) @@ -89,11 +106,6 @@ class VlcEngine(context: Context, httpClient: OkHttpClient, private val device: override fun onProgressUpdate(dialog: Dialog.ProgressDialog) = Unit } - init { - Dialog.setCallbacks(libVlc, dialogCallbacks) - player.setEventListener(::onEvent) - } - override fun attachVideo(container: FrameLayout) { detachVideo() // Must be created with the Activity context: libVLC's VideoHelper only computes the surface size @@ -279,9 +291,20 @@ class VlcEngine(context: Context, httpClient: OkHttpClient, private val device: player.setSpuDelay(delayMs * 1000) } + /** Only before the first [attachVideo]/[load]: later the session restarts the engine with the new style. */ + override fun setSubtitleStyle(style: SubtitleStyle): Boolean { + if (playerLazy.isInitialized()) return false + subtitleStyle = style + return true + } + override fun release() { if (released) return released = true + if (!playerLazy.isInitialized()) { + proxy.close() + return + } player.setEventListener(null) detachVideo() val m = media @@ -382,6 +405,22 @@ class VlcEngine(context: Context, httpClient: OkHttpClient, private val device: } private companion object { + /** freetype's size divisor: smaller = bigger text, 16 is libVLC's default. */ + fun styleOptions(style: SubtitleStyle): List = listOf( + "--freetype-rel-fontsize=" + when (style.size) { + SubtitleStyle.Size.S -> 20 + SubtitleStyle.Size.M -> 16 + SubtitleStyle.Size.L -> 13 + SubtitleStyle.Size.XL -> 10 + }, + "--freetype-color=${style.color and 0xFFFFFF}", + "--freetype-background-opacity=${if (style.background == SubtitleStyle.Background.BOX) 184 else 0}", + "--freetype-background-color=0", + "--freetype-shadow-opacity=${if (style.background == SubtitleStyle.Background.SHADOW) 160 else 0}", + // libVLC's default outline keeps text readable on any picture; "Нет" means plain text, as in ExoPlayer. + "--freetype-outline-thickness=${if (style.background == SubtitleStyle.Background.NONE) 0 else 4}", + ) + const val SEEK_SETTLE_MS = 1500L const val SEEK_TOLERANCE_MS = 5000L const val MAX_SEEK_CORRECTIONS = 3