Player UI
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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() }
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
290
app/src/main/kotlin/ru/stenfredd/movies/ui/player/PlayerOsd.kt
Normal file
290
app/src/main/kotlin/ru/stenfredd/movies/ui/player/PlayerOsd.kt
Normal 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() }
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -134,7 +134,8 @@ internal fun BaseItemDto.toPerson() = Person(
|
||||
image = imageTags?.get(ImageType.PRIMARY)?.let { image(id, ImageKind.Primary, it) },
|
||||
)
|
||||
|
||||
private fun MediaSourceInfo.toMediaInfo(): MediaInfo {
|
||||
/** The file as the app models it; the player also maps the source its session got from PlaybackInfo. */
|
||||
fun MediaSourceInfo.toMediaInfo(): MediaInfo {
|
||||
val streams = mediaStreams.orEmpty()
|
||||
return MediaInfo(
|
||||
sourceId = id.orEmpty(),
|
||||
|
||||
@@ -72,3 +72,21 @@ data class EngineStats(
|
||||
val droppedFrames: Int = 0,
|
||||
val extra: Map<String, String> = emptyMap(),
|
||||
)
|
||||
|
||||
/** Look of text subtitles, chosen in the player's subtitle panel. */
|
||||
data class SubtitleStyle(
|
||||
val size: Size = Size.M,
|
||||
/** ARGB. */
|
||||
val color: Int = WHITE,
|
||||
val background: Background = Background.SHADOW,
|
||||
) {
|
||||
enum class Size(val scale: Float) { S(0.8f), M(1f), L(1.25f), XL(1.5f) }
|
||||
|
||||
enum class Background { NONE, SHADOW, BOX }
|
||||
|
||||
companion object {
|
||||
const val WHITE = 0xFFFFFFFF.toInt()
|
||||
const val YELLOW = 0xFFFFE066.toInt()
|
||||
const val LIGHT_BLUE = 0xFF9FD3FF.toInt()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,9 @@ interface PlaybackEngine {
|
||||
/** False once the engine knows it can't seek in this source (e.g. a Matroska index media3 can't find). */
|
||||
val isSeekable: Boolean get() = true
|
||||
|
||||
/** How far the media is loaded ahead (the timeline's buffer bar), or -1 if the engine doesn't tell. */
|
||||
val bufferedPositionMs: Long get() = -1
|
||||
|
||||
/** Adds the engine's own video views (surface, subtitle overlay) into [container]. */
|
||||
fun attachVideo(container: FrameLayout)
|
||||
fun detachVideo()
|
||||
@@ -65,6 +68,15 @@ interface DolbyVisionSwitchable {
|
||||
var useDolbyVisionDecoder: Boolean
|
||||
}
|
||||
|
||||
/** Engines that draw text subtitles (SRT, VTT …) in the user's style; ASS and bitmap subtitles keep their own look. */
|
||||
interface SubtitleStyleable {
|
||||
/** Returns false if the engine can only take the style before [PlaybackEngine.load] (the session then restarts it). */
|
||||
fun setSubtitleStyle(style: SubtitleStyle): Boolean
|
||||
|
||||
/** Lifts text subtitles above the player controls while they are shown; fraction of the video height. */
|
||||
fun setSubtitleLift(fraction: Float) {}
|
||||
}
|
||||
|
||||
interface SubtitleDelayCapable {
|
||||
fun setSubtitleDelay(delayMs: Long)
|
||||
}
|
||||
|
||||
@@ -20,4 +20,5 @@ dependencies {
|
||||
implementation(libs.hilt.android)
|
||||
ksp(libs.hilt.compiler)
|
||||
implementation(libs.timber)
|
||||
implementation(libs.androidx.datastore.preferences)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
package ru.stenfredd.movies.playback.core
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.map
|
||||
import org.json.JSONObject
|
||||
import ru.stenfredd.movies.core.jellyfin.JellyfinSession
|
||||
import ru.stenfredd.movies.playback.api.SubtitleStyle
|
||||
import timber.log.Timber
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
private val Context.playbackStore by preferencesDataStore("playback")
|
||||
|
||||
/**
|
||||
* A remembered track, by what identifies it across files of one series: an episode's stream indexes differ from the
|
||||
* next one's, but "Русский · HDRezka, AC3" stays the same.
|
||||
*/
|
||||
data class TrackPick(val language: String?, val name: String?, val codec: String?) {
|
||||
/** The candidate that is the same track: language must agree, then the track's own title, then the codec. */
|
||||
fun <T> bestOf(candidates: List<T>, describe: (T) -> TrackPick): T? = candidates
|
||||
.map { it to describe(it) }
|
||||
.filter { (_, c) -> c.language.equals(language, ignoreCase = true) || (language == null && c.name != null && c.name == name) }
|
||||
.maxByOrNull { (_, c) -> (if (c.name == name) 4 else 0) + (if (c.codec.equals(codec, ignoreCase = true)) 2 else 0) }
|
||||
?.first
|
||||
}
|
||||
|
||||
/** What the user picked last for a movie or a series. [subtitle] null with [subtitleOff] = subtitles off. */
|
||||
data class RememberedTracks(val audio: TrackPick?, val subtitle: TrackPick?, val subtitleOff: Boolean)
|
||||
|
||||
/**
|
||||
* Player settings kept on the device: the look of text subtitles (for everyone on this TV) and the tracks chosen
|
||||
* per movie or series (per account: family members watch with different tracks).
|
||||
*/
|
||||
@Singleton
|
||||
class PlaybackPreferences @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val session: JellyfinSession,
|
||||
) {
|
||||
val subtitleStyle: Flow<SubtitleStyle> = context.playbackStore.data.map { parseStyle(it[KEY_SUBTITLE_STYLE]) }
|
||||
|
||||
suspend fun subtitleStyle(): SubtitleStyle = subtitleStyle.first()
|
||||
|
||||
suspend fun setSubtitleStyle(style: SubtitleStyle) {
|
||||
context.playbackStore.edit { it[KEY_SUBTITLE_STYLE] = "${style.size}|${style.color}|${style.background}" }
|
||||
}
|
||||
|
||||
/** [key]: the series of an episode, else the item itself. */
|
||||
suspend fun tracks(key: UUID): RememberedTracks? {
|
||||
val raw = context.playbackStore.data.first()[tracksKey(key) ?: return null] ?: return null
|
||||
return runCatching {
|
||||
val json = JSONObject(raw)
|
||||
RememberedTracks(
|
||||
audio = json.optJSONObject("a")?.toPick(),
|
||||
subtitle = json.optJSONObject("s")?.toPick(),
|
||||
subtitleOff = json.optBoolean("off"),
|
||||
)
|
||||
}.onFailure { Timber.w(it, "Bad remembered tracks %s", raw) }.getOrNull()
|
||||
}
|
||||
|
||||
suspend fun rememberAudio(key: UUID, pick: TrackPick) = update(key) { it.put("a", pick.toJson()) }
|
||||
|
||||
/** null = subtitles off. */
|
||||
suspend fun rememberSubtitle(key: UUID, pick: TrackPick?) = update(key) {
|
||||
if (pick == null) it.remove("s") else it.put("s", pick.toJson())
|
||||
it.put("off", pick == null)
|
||||
}
|
||||
|
||||
private suspend fun update(key: UUID, change: (JSONObject) -> Unit) {
|
||||
val prefKey = tracksKey(key) ?: return
|
||||
context.playbackStore.edit { prefs ->
|
||||
val json = prefs[prefKey]?.let { runCatching { JSONObject(it) }.getOrNull() } ?: JSONObject()
|
||||
change(json)
|
||||
prefs[prefKey] = json.toString()
|
||||
}
|
||||
}
|
||||
|
||||
private fun tracksKey(key: UUID): Preferences.Key<String>? {
|
||||
val account = session.session.value?.accountId ?: return null
|
||||
return stringPreferencesKey("tracks/$account/$key")
|
||||
}
|
||||
|
||||
private fun parseStyle(raw: String?): SubtitleStyle {
|
||||
val parts = raw?.split('|') ?: return SubtitleStyle()
|
||||
return runCatching {
|
||||
SubtitleStyle(SubtitleStyle.Size.valueOf(parts[0]), parts[1].toInt(), SubtitleStyle.Background.valueOf(parts[2]))
|
||||
}.getOrDefault(SubtitleStyle())
|
||||
}
|
||||
|
||||
private fun TrackPick.toJson() = JSONObject().apply {
|
||||
language?.let { put("l", it) }
|
||||
name?.let { put("n", it) }
|
||||
codec?.let { put("c", it) }
|
||||
}
|
||||
|
||||
private fun JSONObject.toPick() = TrackPick(optString("l").ifEmpty { null }, optString("n").ifEmpty { null }, optString("c").ifEmpty { null })
|
||||
|
||||
private companion object {
|
||||
val KEY_SUBTITLE_STYLE = stringPreferencesKey("subtitle_style")
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,7 @@ import org.jellyfin.sdk.api.client.extensions.sessionApi
|
||||
import org.jellyfin.sdk.api.client.extensions.videoApi
|
||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||
import org.jellyfin.sdk.model.api.MediaSourceInfo
|
||||
import org.jellyfin.sdk.model.api.MediaStream
|
||||
import org.jellyfin.sdk.model.api.MediaStreamType
|
||||
import org.jellyfin.sdk.model.api.PlayMethod
|
||||
import org.jellyfin.sdk.model.api.PlaybackInfoDto
|
||||
@@ -43,6 +44,10 @@ import ru.stenfredd.movies.playback.api.ExternalSubtitle
|
||||
import ru.stenfredd.movies.playback.api.LoadRequest
|
||||
import ru.stenfredd.movies.playback.api.PlaybackEngine
|
||||
import ru.stenfredd.movies.playback.api.StreamSelection
|
||||
import ru.stenfredd.movies.playback.api.SubtitleStyle
|
||||
import ru.stenfredd.movies.playback.api.SubtitleStyleable
|
||||
import ru.stenfredd.movies.playback.api.stream
|
||||
import ru.stenfredd.movies.playback.api.streams
|
||||
import ru.stenfredd.movies.playback.api.Verdict
|
||||
import ru.stenfredd.movies.playback.api.VideoPathSwitchable
|
||||
import ru.stenfredd.movies.playback.api.videoStream
|
||||
@@ -58,6 +63,15 @@ data class PlaybackUiState(
|
||||
val engineState: EngineState = EngineState(),
|
||||
val positionMs: Long = 0,
|
||||
val durationMs: Long = 0,
|
||||
/** Loaded up to here, or -1 if the engine doesn't tell. */
|
||||
val bufferedMs: Long = -1,
|
||||
/** The item being played (an episode, a movie) and the series of an episode. */
|
||||
val itemId: UUID? = null,
|
||||
val seriesId: UUID? = null,
|
||||
/** Quality cap chosen in the player, bit/s; null = original. */
|
||||
val maxBitrate: Int? = null,
|
||||
/** The engine was picked by the user ("Дополнительно"), not by the selector. */
|
||||
val manualEngine: Boolean = false,
|
||||
val playMethod: PlayMethod? = null,
|
||||
val transcodeReasons: String? = null,
|
||||
val source: MediaSourceInfo? = null,
|
||||
@@ -72,8 +86,9 @@ class PlaybackSessionFactory @Inject constructor(
|
||||
private val session: JellyfinSession,
|
||||
private val selector: EngineSelector,
|
||||
private val probe: DeviceCapabilitiesProbe,
|
||||
private val prefs: PlaybackPreferences,
|
||||
) {
|
||||
fun create(scope: CoroutineScope) = PlaybackSession(session, selector, probe, scope)
|
||||
fun create(scope: CoroutineScope) = PlaybackSession(session, selector, probe, prefs, scope)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -87,6 +102,7 @@ class PlaybackSession(
|
||||
private val session: JellyfinSession,
|
||||
private val selector: EngineSelector,
|
||||
private val probe: DeviceCapabilitiesProbe,
|
||||
private val prefs: PlaybackPreferences,
|
||||
private val scope: CoroutineScope,
|
||||
) {
|
||||
private val _state = MutableStateFlow(PlaybackUiState())
|
||||
@@ -116,6 +132,9 @@ class PlaybackSession(
|
||||
/** The user picked the engine: the stall watchdog only logs instead of switching away from their choice. */
|
||||
private var manualEngine = false
|
||||
private var lastSeekAt = 0L
|
||||
private var maxBitrate: Int? = null
|
||||
private var subtitleStyle = SubtitleStyle()
|
||||
private var subtitleLift = 0f
|
||||
|
||||
private var playSessionId: String? = null
|
||||
private var playMethod: PlayMethod = PlayMethod.DIRECT_PLAY
|
||||
@@ -140,12 +159,15 @@ class PlaybackSession(
|
||||
withContext(Dispatchers.Default) { probe.capabilities }
|
||||
item = session.call { libraryApi.getItem(itemId, user.userId).content }
|
||||
source = item.mediaSources?.firstOrNull() ?: error("У элемента нет медиаисточников")
|
||||
selection = streams ?: StreamSelection(source.defaultAudioStreamIndex, source.defaultSubtitleStreamIndex)
|
||||
subtitleStyle = prefs.subtitleStyle()
|
||||
selection = streams ?: rememberedSelection() ?: StreamSelection(source.defaultAudioStreamIndex, source.defaultSubtitleStreamIndex)
|
||||
val startMs = startFraction?.let { ((item.runTimeTicks ?: 0) / TICKS_PER_MS * it).toLong() } ?: startPositionMs
|
||||
ranking = selector.rank(source, selection, device)
|
||||
_state.update {
|
||||
it.copy(
|
||||
title = item.name.orEmpty(),
|
||||
itemId = item.id,
|
||||
seriesId = item.seriesId,
|
||||
source = source,
|
||||
selection = selection,
|
||||
ranking = ranking.map { r -> r.provider.id to r.verdict },
|
||||
@@ -155,6 +177,7 @@ class PlaybackSession(
|
||||
val first = forcedEngine?.let(selector::provider) ?: ranking.first().provider
|
||||
val reason = if (forcedEngine != null) "выбран вручную" else ranking.first().verdict.describe()
|
||||
manualEngine = forcedEngine != null
|
||||
_state.update { it.copy(manualEngine = manualEngine) }
|
||||
launchEngine(first, startMs, forceTranscode = false, reason = reason)
|
||||
}.onFailure { fail("Не удалось начать воспроизведение", it) }
|
||||
}
|
||||
@@ -166,6 +189,13 @@ class PlaybackSession(
|
||||
reportProgressAsync()
|
||||
}
|
||||
|
||||
fun pause() {
|
||||
val e = engine ?: return
|
||||
if (!e.state.value.playWhenReady) return
|
||||
e.pause()
|
||||
reportProgressAsync()
|
||||
}
|
||||
|
||||
fun seekBy(deltaMs: Long) = seekTo(positionMs() + deltaMs)
|
||||
|
||||
fun seekTo(positionMs: Long) {
|
||||
@@ -189,13 +219,60 @@ class PlaybackSession(
|
||||
}
|
||||
|
||||
fun selectAudio(streamIndex: Int) {
|
||||
if (selection.audioStreamIndex == streamIndex) return
|
||||
selection = selection.copy(audioStreamIndex = streamIndex)
|
||||
onSelectionChanged("смена аудио") { it.selectAudio(streamIndex) }
|
||||
source.stream(streamIndex)?.let { s -> scope.launch { prefs.rememberAudio(memoryKey(), s.pick()) } }
|
||||
}
|
||||
|
||||
fun selectSubtitle(streamIndex: Int?) {
|
||||
if (selection.subtitleStreamIndex == streamIndex) return
|
||||
selection = selection.copy(subtitleStreamIndex = streamIndex)
|
||||
onSelectionChanged("смена субтитров") { it.selectSubtitle(streamIndex) }
|
||||
val pick = streamIndex?.let { source.stream(it)?.pick() }
|
||||
scope.launch { prefs.rememberSubtitle(memoryKey(), pick) }
|
||||
}
|
||||
|
||||
/** Remembered per series (an episode) or per movie. */
|
||||
private fun memoryKey(): UUID = item.seriesId ?: item.id
|
||||
|
||||
/** The tracks picked last time for this movie or series, found again in this file. */
|
||||
private suspend fun rememberedSelection(): StreamSelection? {
|
||||
val memory = prefs.tracks(memoryKey()) ?: return null
|
||||
val audio = memory.audio?.bestOf(source.streams(MediaStreamType.AUDIO)) { it.pick() }?.index
|
||||
val subtitle = if (memory.subtitleOff) null else memory.subtitle?.bestOf(source.streams(MediaStreamType.SUBTITLE)) { it.pick() }?.index
|
||||
if (audio == null && subtitle == null && !memory.subtitleOff) return null
|
||||
log("Запомненные дорожки: аудио $audio, субтитры $subtitle")
|
||||
return StreamSelection(audio ?: source.defaultAudioStreamIndex, if (memory.subtitleOff || subtitle != null) subtitle else source.defaultSubtitleStreamIndex)
|
||||
}
|
||||
|
||||
private fun MediaStream.pick() = TrackPick(language, title?.takeIf { it.isNotBlank() }, codec)
|
||||
|
||||
/** Quality: null = original (direct play where possible), else the server transcodes down to [bitrate]. */
|
||||
fun setMaxBitrate(bitrate: Int?) {
|
||||
if (bitrate == maxBitrate) return
|
||||
maxBitrate = bitrate
|
||||
_state.update { it.copy(maxBitrate = bitrate) }
|
||||
val provider = engine?.id?.let(selector::provider) ?: return
|
||||
val position = positionMs()
|
||||
val reason = if (bitrate == null) "качество: оригинал" else "качество: до ${bitrate / 1000} кбит/с"
|
||||
// The server decides: direct play if the file fits under the cap, else a transcode.
|
||||
scope.launch {
|
||||
runCatching { launchEngine(provider, position, forceTranscode = false, reason = reason) }.onFailure { fail("Не удалось сменить качество", it) }
|
||||
}
|
||||
}
|
||||
|
||||
fun setSubtitleStyle(style: SubtitleStyle) {
|
||||
if (style == subtitleStyle) return
|
||||
subtitleStyle = style
|
||||
val e = engine as? SubtitleStyleable ?: return
|
||||
// libVLC takes the look only at start: restart it, but only if text subtitles are on screen now.
|
||||
if (!e.setSubtitleStyle(style) && selection.subtitleStreamIndex != null) restartCurrent("вид субтитров")
|
||||
}
|
||||
|
||||
fun setSubtitleLift(fraction: Float) {
|
||||
subtitleLift = fraction
|
||||
(engine as? SubtitleStyleable)?.setSubtitleLift(fraction)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -228,6 +305,7 @@ class PlaybackSession(
|
||||
nextVideoPath = videoPath
|
||||
nextDolbyVision = dolbyVision
|
||||
manualEngine = true
|
||||
_state.update { it.copy(manualEngine = true) }
|
||||
launchEngine(selector.provider(id), position, forceTranscode = transcode, reason = if (transcode) "транскод вручную" else "выбран вручную")
|
||||
}
|
||||
}
|
||||
@@ -243,6 +321,7 @@ class PlaybackSession(
|
||||
nextDolbyVision = null
|
||||
defaultDolbyVision = true
|
||||
manualEngine = false
|
||||
_state.update { it.copy(manualEngine = false) }
|
||||
launchEngine(first.provider, position, forceTranscode = false, reason = first.verdict.describe())
|
||||
}
|
||||
}
|
||||
@@ -251,6 +330,7 @@ class PlaybackSession(
|
||||
val position = positionMs()
|
||||
val provider = engine?.id?.let(selector::provider) ?: return
|
||||
manualEngine = true
|
||||
_state.update { it.copy(manualEngine = true) }
|
||||
scope.launch { launchEngine(provider, position, forceTranscode = true, reason = "транскод вручную") }
|
||||
}
|
||||
|
||||
@@ -293,7 +373,7 @@ class PlaybackSession(
|
||||
item.id,
|
||||
PlaybackInfoDto(
|
||||
userId = user.userId,
|
||||
maxStreamingBitrate = MAX_BITRATE,
|
||||
maxStreamingBitrate = maxBitrate ?: MAX_BITRATE,
|
||||
startTimeTicks = startMs * TICKS_PER_MS,
|
||||
audioStreamIndex = selection.audioStreamIndex,
|
||||
subtitleStreamIndex = selection.subtitleStreamIndex,
|
||||
@@ -346,6 +426,10 @@ class PlaybackSession(
|
||||
nextVideoPath?.let { (newEngine as? VideoPathSwitchable)?.setVideoPathMode(it) }
|
||||
nextVideoPath = null
|
||||
nextDolbyVision?.let { (newEngine as? DolbyVisionSwitchable)?.useDolbyVisionDecoder = it }
|
||||
(newEngine as? SubtitleStyleable)?.let {
|
||||
it.setSubtitleStyle(subtitleStyle)
|
||||
it.setSubtitleLift(subtitleLift)
|
||||
}
|
||||
nextDolbyVision = null
|
||||
defaultDolbyVision = false
|
||||
engine = newEngine
|
||||
@@ -476,7 +560,7 @@ class PlaybackSession(
|
||||
var sinceReport = 0L
|
||||
while (scope.isActive && gen == generation) {
|
||||
val e = engine ?: return
|
||||
_state.update { it.copy(positionMs = positionMs(), durationMs = e.durationMs) }
|
||||
_state.update { it.copy(positionMs = positionMs(), durationMs = e.durationMs, bufferedMs = e.bufferedPositionMs) }
|
||||
delay(TICK_MS)
|
||||
sinceReport += TICK_MS
|
||||
if (sinceReport >= PROGRESS_INTERVAL_MS) {
|
||||
|
||||
@@ -4,6 +4,7 @@ package ru.stenfredd.movies.playback.exo
|
||||
|
||||
import android.content.Context
|
||||
import android.content.res.Configuration
|
||||
import android.graphics.Color
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
@@ -37,6 +38,7 @@ import androidx.media3.exoplayer.mediacodec.MediaCodecUtil
|
||||
import androidx.media3.exoplayer.audio.AudioSink
|
||||
import androidx.media3.exoplayer.trackselection.DefaultTrackSelector
|
||||
import androidx.media3.ui.AspectRatioFrameLayout
|
||||
import androidx.media3.ui.CaptionStyleCompat
|
||||
import androidx.media3.ui.SubtitleView
|
||||
import io.github.peerless2012.ass.media.AssHandler
|
||||
import io.github.peerless2012.ass.media.AssHandlerConfig
|
||||
@@ -65,12 +67,14 @@ import ru.stenfredd.movies.playback.api.EngineState
|
||||
import ru.stenfredd.movies.playback.api.EngineStats
|
||||
import ru.stenfredd.movies.playback.api.LoadRequest
|
||||
import ru.stenfredd.movies.playback.api.PlaybackEngine
|
||||
import ru.stenfredd.movies.playback.api.SubtitleStyle
|
||||
import ru.stenfredd.movies.playback.api.SubtitleStyleable
|
||||
import ru.stenfredd.movies.playback.api.VideoPathSwitchable
|
||||
import ru.stenfredd.movies.playback.api.embeddedOrdinal
|
||||
import ru.stenfredd.movies.playback.api.videoStream
|
||||
import timber.log.Timber
|
||||
|
||||
class ExoEngine(context: Context) : PlaybackEngine, VideoPathSwitchable, DolbyVisionSwitchable {
|
||||
class ExoEngine(context: Context) : PlaybackEngine, VideoPathSwitchable, DolbyVisionSwitchable, SubtitleStyleable {
|
||||
override val id = ExoEngineProvider.ID
|
||||
|
||||
/** See [gpuPathFor]: only TVs known to over-process SDR on the video plane. */
|
||||
@@ -145,9 +149,10 @@ class ExoEngine(context: Context) : PlaybackEngine, VideoPathSwitchable, DolbyVi
|
||||
resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT
|
||||
addView(surfaceView, FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT))
|
||||
}
|
||||
private val subtitleView = SubtitleView(context).apply {
|
||||
setUserDefaultStyle()
|
||||
setUserDefaultTextSize()
|
||||
private val subtitleView = SubtitleView(context)
|
||||
|
||||
init {
|
||||
setSubtitleStyle(SubtitleStyle())
|
||||
}
|
||||
|
||||
private var request: LoadRequest? = null
|
||||
@@ -371,6 +376,28 @@ class ExoEngine(context: Context) : PlaybackEngine, VideoPathSwitchable, DolbyVi
|
||||
|
||||
override val isSeekable get() = player.currentTimeline.isEmpty || player.isCurrentMediaItemSeekable
|
||||
|
||||
override val bufferedPositionMs get() = player.bufferedPosition
|
||||
|
||||
override fun setSubtitleStyle(style: SubtitleStyle): Boolean {
|
||||
val box = style.background == SubtitleStyle.Background.BOX
|
||||
subtitleView.setStyle(
|
||||
CaptionStyleCompat(
|
||||
style.color,
|
||||
if (box) BOX_COLOR else Color.TRANSPARENT,
|
||||
Color.TRANSPARENT,
|
||||
if (style.background == SubtitleStyle.Background.SHADOW) CaptionStyleCompat.EDGE_TYPE_DROP_SHADOW else CaptionStyleCompat.EDGE_TYPE_NONE,
|
||||
Color.BLACK,
|
||||
null,
|
||||
),
|
||||
)
|
||||
subtitleView.setFractionalTextSize(SubtitleView.DEFAULT_TEXT_SIZE_FRACTION * style.size.scale)
|
||||
return true
|
||||
}
|
||||
|
||||
override fun setSubtitleLift(fraction: Float) {
|
||||
subtitleView.setBottomPaddingFraction(SubtitleView.DEFAULT_BOTTOM_PADDING_FRACTION + fraction)
|
||||
}
|
||||
|
||||
override fun play() = player.play()
|
||||
override fun pause() = player.pause()
|
||||
override fun seekTo(positionMs: Long) {
|
||||
@@ -491,6 +518,8 @@ class ExoEngine(context: Context) : PlaybackEngine, VideoPathSwitchable, DolbyVi
|
||||
|
||||
companion object {
|
||||
private const val EXTERNAL_PREFIX = "ext:"
|
||||
/** "Плашка" behind the text: black, ~72 % opaque as in the mockup. */
|
||||
private const val BOX_COLOR = 0xB8000000.toInt()
|
||||
private const val MIN_BUFFER_MB = 32
|
||||
private const val FRAME_RATE_SAMPLE_MS = 2000L
|
||||
/** Compose UI surface on the TV; the GPU path is shown at this rate. */
|
||||
|
||||
@@ -31,6 +31,8 @@ import ru.stenfredd.movies.playback.api.EngineStats
|
||||
import ru.stenfredd.movies.playback.api.LoadRequest
|
||||
import ru.stenfredd.movies.playback.api.PlaybackEngine
|
||||
import ru.stenfredd.movies.playback.api.SubtitleDelayCapable
|
||||
import ru.stenfredd.movies.playback.api.SubtitleStyle
|
||||
import ru.stenfredd.movies.playback.api.SubtitleStyleable
|
||||
import ru.stenfredd.movies.playback.api.embeddedOrdinal
|
||||
import ru.stenfredd.movies.playback.api.stream
|
||||
import ru.stenfredd.movies.playback.api.streams
|
||||
@@ -42,11 +44,26 @@ import timber.log.Timber
|
||||
* objects in [release] (leaks here were what killed libVLC in the official Android TV client).
|
||||
* All network access goes through [LoopbackProxy]: libVLC itself only sees plain HTTP on 127.0.0.1.
|
||||
*/
|
||||
class VlcEngine(context: Context, httpClient: OkHttpClient, private val device: DeviceCapabilities) : PlaybackEngine, AudioDelayCapable, SubtitleDelayCapable {
|
||||
class VlcEngine(context: Context, httpClient: OkHttpClient, private val device: DeviceCapabilities) :
|
||||
PlaybackEngine, AudioDelayCapable, SubtitleDelayCapable, SubtitleStyleable {
|
||||
override val id = VlcEngineProvider.ID
|
||||
|
||||
private val libVlc = LibVLC(context.applicationContext, OPTIONS)
|
||||
private val player = MediaPlayer(libVlc)
|
||||
private val appContext = context.applicationContext
|
||||
private var subtitleStyle = SubtitleStyle()
|
||||
|
||||
// Created on first use: libVLC reads the text subtitle look only from its instance options (see [setSubtitleStyle]).
|
||||
private val libVlcLazy = lazy {
|
||||
LibVLC(appContext, OPTIONS + styleOptions(subtitleStyle)).also {
|
||||
// The native library is loaded by the first LibVLC: version() only works after that.
|
||||
_stats.update { s -> s.copy(videoDecoder = "libVLC ${LibVLC.version()}") }
|
||||
}
|
||||
}
|
||||
private val libVlc by libVlcLazy
|
||||
private val playerLazy = lazy {
|
||||
Dialog.setCallbacks(libVlc, dialogCallbacks)
|
||||
MediaPlayer(libVlc).apply { setEventListener(::onEvent) }
|
||||
}
|
||||
private val player by playerLazy
|
||||
private val proxy = LoopbackProxy(httpClient)
|
||||
private var videoLayout: VLCVideoLayout? = null
|
||||
private var media: Media? = null
|
||||
@@ -56,7 +73,7 @@ class VlcEngine(context: Context, httpClient: OkHttpClient, private val device:
|
||||
override val state = _state.asStateFlow()
|
||||
private val _events = MutableSharedFlow<EngineEvent>(extraBufferCapacity = 16, onBufferOverflow = BufferOverflow.DROP_OLDEST)
|
||||
override val events = _events.asSharedFlow()
|
||||
private val _stats = MutableStateFlow(EngineStats(videoDecoder = "libVLC ${LibVLC.version()}"))
|
||||
private val _stats = MutableStateFlow(EngineStats())
|
||||
override val stats = _stats.asStateFlow()
|
||||
|
||||
override val positionMs get() = player.time.coerceAtLeast(0)
|
||||
@@ -89,11 +106,6 @@ class VlcEngine(context: Context, httpClient: OkHttpClient, private val device:
|
||||
override fun onProgressUpdate(dialog: Dialog.ProgressDialog) = Unit
|
||||
}
|
||||
|
||||
init {
|
||||
Dialog.setCallbacks(libVlc, dialogCallbacks)
|
||||
player.setEventListener(::onEvent)
|
||||
}
|
||||
|
||||
override fun attachVideo(container: FrameLayout) {
|
||||
detachVideo()
|
||||
// Must be created with the Activity context: libVLC's VideoHelper only computes the surface size
|
||||
@@ -279,9 +291,20 @@ class VlcEngine(context: Context, httpClient: OkHttpClient, private val device:
|
||||
player.setSpuDelay(delayMs * 1000)
|
||||
}
|
||||
|
||||
/** Only before the first [attachVideo]/[load]: later the session restarts the engine with the new style. */
|
||||
override fun setSubtitleStyle(style: SubtitleStyle): Boolean {
|
||||
if (playerLazy.isInitialized()) return false
|
||||
subtitleStyle = style
|
||||
return true
|
||||
}
|
||||
|
||||
override fun release() {
|
||||
if (released) return
|
||||
released = true
|
||||
if (!playerLazy.isInitialized()) {
|
||||
proxy.close()
|
||||
return
|
||||
}
|
||||
player.setEventListener(null)
|
||||
detachVideo()
|
||||
val m = media
|
||||
@@ -382,6 +405,22 @@ class VlcEngine(context: Context, httpClient: OkHttpClient, private val device:
|
||||
}
|
||||
|
||||
private companion object {
|
||||
/** freetype's size divisor: smaller = bigger text, 16 is libVLC's default. */
|
||||
fun styleOptions(style: SubtitleStyle): List<String> = listOf(
|
||||
"--freetype-rel-fontsize=" + when (style.size) {
|
||||
SubtitleStyle.Size.S -> 20
|
||||
SubtitleStyle.Size.M -> 16
|
||||
SubtitleStyle.Size.L -> 13
|
||||
SubtitleStyle.Size.XL -> 10
|
||||
},
|
||||
"--freetype-color=${style.color and 0xFFFFFF}",
|
||||
"--freetype-background-opacity=${if (style.background == SubtitleStyle.Background.BOX) 184 else 0}",
|
||||
"--freetype-background-color=0",
|
||||
"--freetype-shadow-opacity=${if (style.background == SubtitleStyle.Background.SHADOW) 160 else 0}",
|
||||
// libVLC's default outline keeps text readable on any picture; "Нет" means plain text, as in ExoPlayer.
|
||||
"--freetype-outline-thickness=${if (style.background == SubtitleStyle.Background.NONE) 0 else 4}",
|
||||
)
|
||||
|
||||
const val SEEK_SETTLE_MS = 1500L
|
||||
const val SEEK_TOLERANCE_MS = 5000L
|
||||
const val MAX_SEEK_CORRECTIONS = 3
|
||||
|
||||
Reference in New Issue
Block a user