Player UI

This commit is contained in:
2026-10-14 14:23:47 +03:00
parent 5ddc026102
commit 9eafdfd5c2
24 changed files with 2422 additions and 205 deletions

View File

@@ -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)
}
}

View File

@@ -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
/**

View File

@@ -36,6 +36,11 @@ data class FocusStyle(
val ring: Dp = 1.5.dp,
val ringColor: Color = AppColors.Accent,
val glows: List<Glow> = 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 {

View File

@@ -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() }

View File

@@ -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)
}

View File

@@ -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<String, Screen> = 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<Int?, Int?>? {
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

View File

@@ -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"

View File

@@ -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

View File

@@ -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)
}

View File

@@ -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,
)
}
}

View File

@@ -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<TrackOption>, val subtitles: List<TrackOption>) {
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<InfoRow> = 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
}
}

View File

@@ -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<Pair<Float, Color>>): 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<Pair<Float, Color>>, 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<Long>, 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() }
}
}

View File

@@ -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<PanelChoice>)
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<PanelChoice>, 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<PanelChoice>, 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<InfoRow>) {
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<TrackOption>.toChoices(selected: Int?): List<PanelChoice> =
map { PanelChoice(it.key, it.label, it.detail, it.index == selected) }

View File

@@ -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<FrameLayout?>(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<PlayerPanel?>(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<Long?>(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<Pair<PlayerPanel, OsdChipModel>> = 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

View File

@@ -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<Long>,
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<CastModel>,
val seasonNumber: Int?,
val qualities: List<QualityOption>,
)
/** 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<EpisodeModel>,
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<PlaybackSession?>(null)
val session: StateFlow<PlaybackSession?> = _session.asStateFlow()
private val _info = MutableStateFlow<PlayerInfo?>(null)
val info: StateFlow<PlayerInfo?> = _info.asStateFlow()
private val _season = MutableStateFlow<SeasonEpisodes?>(null)
val season: StateFlow<SeasonEpisodes?> = _season.asStateFlow()
val subtitleStyle: StateFlow<SubtitleStyle> = 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<ItemSummary> = 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<ItemSummary>): 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<QualityOption> {
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
}

View File

@@ -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<Floa
/** HH:mm, recomposes once a minute and only itself. */
@Composable
fun Clock(modifier: Modifier = Modifier) {
fun Clock(modifier: Modifier = Modifier, style: TextStyle = AppType.Clock) {
var now by remember { mutableStateOf(LocalTime.now()) }
LaunchedEffect(Unit) {
while (true) {
@@ -267,7 +268,7 @@ fun Clock(modifier: Modifier = Modifier) {
now = LocalTime.now()
}
}
BasicText(now.format(ClockFormat), modifier, style = AppType.Clock)
BasicText(now.format(ClockFormat), modifier, style = style)
}
private val ClockFormat = DateTimeFormatter.ofPattern("HH:mm")