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.EngineId
|
||||||
import ru.stenfredd.movies.playback.api.StreamSelection
|
import ru.stenfredd.movies.playback.api.StreamSelection
|
||||||
import ru.stenfredd.movies.ui.ItemsScreen
|
import ru.stenfredd.movies.ui.ItemsScreen
|
||||||
import ru.stenfredd.movies.ui.PlayerScreen
|
|
||||||
import ru.stenfredd.movies.ui.PrototypeScreen
|
import ru.stenfredd.movies.ui.PrototypeScreen
|
||||||
import ru.stenfredd.movies.ui.Screen
|
import ru.stenfredd.movies.ui.Screen
|
||||||
import ru.stenfredd.movies.ui.TourScreen
|
import ru.stenfredd.movies.ui.TourScreen
|
||||||
import ru.stenfredd.movies.ui.details.DetailsScreen
|
import ru.stenfredd.movies.ui.details.DetailsScreen
|
||||||
import ru.stenfredd.movies.ui.home.HomeScreen
|
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.login.LoginScreen
|
||||||
import ru.stenfredd.movies.ui.shell.Shell
|
import ru.stenfredd.movies.ui.shell.Shell
|
||||||
import ru.stenfredd.movies.ui.shell.Tab
|
import ru.stenfredd.movies.ui.shell.Tab
|
||||||
@@ -75,6 +75,11 @@ class MainActivity : ComponentActivity() {
|
|||||||
fun back() {
|
fun back() {
|
||||||
if (stack.size > 1) stack.removeAt(stack.lastIndex) else finish()
|
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) {
|
fun resetTo(screen: Screen) {
|
||||||
stack.clear()
|
stack.clear()
|
||||||
stack.add(screen)
|
stack.add(screen)
|
||||||
@@ -99,7 +104,7 @@ class MainActivity : ComponentActivity() {
|
|||||||
Screen.Prototype -> Legacy { PrototypeScreen(onOpen = ::open, onLogout = { resetTo(Screen.Login) }) }
|
Screen.Prototype -> Legacy { PrototypeScreen(onOpen = ::open, onLogout = { resetTo(Screen.Login) }) }
|
||||||
is Screen.Items -> Legacy { ItemsScreen(screen, onOpen = ::open) }
|
is Screen.Items -> Legacy { ItemsScreen(screen, onOpen = ::open) }
|
||||||
is Screen.Detail -> DetailsScreen(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)
|
is Screen.Tour -> TourScreen(screen.startIndex, onExit = ::back)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ import java.util.UUID
|
|||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import org.json.JSONArray
|
import org.json.JSONArray
|
||||||
import ru.stenfredd.movies.playback.api.StreamSelection
|
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
|
import timber.log.Timber
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -36,6 +36,11 @@ data class FocusStyle(
|
|||||||
val ring: Dp = 1.5.dp,
|
val ring: Dp = 1.5.dp,
|
||||||
val ringColor: Color = AppColors.Accent,
|
val ringColor: Color = AppColors.Accent,
|
||||||
val glows: List<Glow> = CardGlows,
|
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
|
@Immutable
|
||||||
data class Glow(val blur: Dp, val spread: Dp, val color: Color)
|
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
|
focused = focusState.hasFocus
|
||||||
if (!isAttached) return
|
if (!isAttached) return
|
||||||
invalidatePlacement()
|
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 {
|
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"),
|
Check("M5 12.5l4.5 4.5L19 7.5"),
|
||||||
Plus("M12 5v14M5 12h14"),
|
Plus("M12 5v14M5 12h14"),
|
||||||
Play("M7 4.5v15l13-7.5z", stroked = false),
|
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() }
|
internal val path: Path by lazy { PathParser().parsePathString(pathData).toPath() }
|
||||||
|
|||||||
@@ -86,3 +86,27 @@ fun DefaultScrolling(content: @Composable () -> Unit) {
|
|||||||
val default = LocalDefaultBringIntoViewSpec.current
|
val default = LocalDefaultBringIntoViewSpec.current
|
||||||
if (default == null) content() else CompositionLocalProvider(LocalBringIntoViewSpec provides default, content = content)
|
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.WatchRepository
|
||||||
import ru.stenfredd.movies.core.data.model.ItemDetails
|
import ru.stenfredd.movies.core.data.model.ItemDetails
|
||||||
import ru.stenfredd.movies.core.data.model.ItemKind
|
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.core.data.model.PersonKind
|
||||||
import ru.stenfredd.movies.playback.api.StreamSelection
|
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.Screen
|
||||||
import ru.stenfredd.movies.ui.components.PosterModel
|
import ru.stenfredd.movies.ui.components.PosterModel
|
||||||
import ru.stenfredd.movies.ui.components.RemoteImageSpec
|
import ru.stenfredd.movies.ui.components.RemoteImageSpec
|
||||||
@@ -90,6 +93,7 @@ class DetailsViewModel @AssistedInject constructor(
|
|||||||
private val watch: WatchRepository,
|
private val watch: WatchRepository,
|
||||||
private val images: ImageUrls,
|
private val images: ImageUrls,
|
||||||
private val cards: CardMapper,
|
private val cards: CardMapper,
|
||||||
|
private val prefs: PlaybackPreferences,
|
||||||
cache: SessionCache,
|
cache: SessionCache,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
@AssistedFactory
|
@AssistedFactory
|
||||||
@@ -103,6 +107,9 @@ class DetailsViewModel @AssistedInject constructor(
|
|||||||
/** The user picked tracks on this page: keep the choice over reloads. */
|
/** The user picked tracks on this page: keep the choice over reloads. */
|
||||||
private var tracksChosen = false
|
private var tracksChosen = false
|
||||||
private var similarTargets: Map<String, Screen> = emptyMap()
|
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 {
|
init {
|
||||||
viewModelScope.launch { load() }
|
viewModelScope.launch { load() }
|
||||||
@@ -115,11 +122,15 @@ class DetailsViewModel @AssistedInject constructor(
|
|||||||
fun selectAudio(index: Int?) {
|
fun selectAudio(index: Int?) {
|
||||||
tracksChosen = true
|
tracksChosen = true
|
||||||
_state.update { it.copy(audio = index) }
|
_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?) {
|
fun selectSubtitle(index: Int?) {
|
||||||
tracksChosen = true
|
tracksChosen = true
|
||||||
_state.update { it.copy(subtitle = index) }
|
_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. */
|
/** 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 similar = async { runCatching { library.similar(itemId) }.getOrDefault(emptyList()) }
|
||||||
val details = library.details(itemId)
|
val details = library.details(itemId)
|
||||||
val model = withContext(Dispatchers.Default) { details.toModel() }
|
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 ->
|
_state.update { s ->
|
||||||
val media = details.media
|
|
||||||
val keep = tracksChosen && s.item != null
|
val keep = tracksChosen && s.item != null
|
||||||
s.copy(
|
s.copy(
|
||||||
loading = false,
|
loading = false,
|
||||||
error = null,
|
error = null,
|
||||||
item = model,
|
item = model,
|
||||||
audio = if (keep) s.audio else media?.defaultAudioIndex ?: media?.audio?.firstOrNull()?.index,
|
audio = when {
|
||||||
subtitle = if (keep) s.subtitle else media?.defaultSubtitleIndex,
|
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.
|
// 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 {
|
private fun ItemDetails.toModel(): DetailsModel {
|
||||||
val item = summary
|
val item = summary
|
||||||
val episode = item.kind == ItemKind.Episode
|
val episode = item.kind == ItemKind.Episode
|
||||||
|
|||||||
@@ -101,7 +101,7 @@ internal object MediaLabels {
|
|||||||
VideoRange.SDR, null -> null
|
VideoRange.SDR, null -> null
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun videoCodec(codec: String?): String? = when (codec?.lowercase()) {
|
fun videoCodec(codec: String?): String? = when (codec?.lowercase()) {
|
||||||
null -> null
|
null -> null
|
||||||
"hevc", "h265" -> "HEVC"
|
"hevc", "h265" -> "HEVC"
|
||||||
"h264", "avc" -> "H.264"
|
"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()),
|
image = image(if (episode) item.seriesPrimary ?: item.primary else item.primary, AppDimens.POSTER_W.toFloat(), AppDimens.POSTER_H.toFloat()),
|
||||||
badge = item.formatBadge(),
|
badge = item.formatBadge(),
|
||||||
progress = item.user.progress,
|
progress = item.user.progress,
|
||||||
isNew = item.isNew(),
|
isNew = isNew(item),
|
||||||
watched = item.user.played,
|
watched = item.user.played,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -80,7 +80,7 @@ class CardMapper @Inject constructor(
|
|||||||
else -> Screen.Detail(item.id)
|
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) }
|
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. */
|
/** 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? =
|
private fun ItemSummary.episodeLabel(): String? =
|
||||||
if (kind == ItemKind.Episode && seasonNumber != null && episodeNumber != null) "S$seasonNumber · E$episodeNumber · $name" else null
|
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? {
|
private fun ItemSummary.formatBadge(): String? {
|
||||||
val w = videoWidth ?: return null
|
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.background
|
||||||
import androidx.compose.foundation.focusable
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.fillMaxHeight
|
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.padding
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.layout.widthIn
|
import androidx.compose.foundation.layout.widthIn
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.runtime.Composable
|
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.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
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.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.FontFamily
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
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.Button
|
||||||
import androidx.tv.material3.MaterialTheme
|
import androidx.tv.material3.MaterialTheme
|
||||||
import androidx.tv.material3.Text
|
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.MediaStreamType
|
||||||
import org.jellyfin.sdk.model.api.PlayMethod
|
import org.jellyfin.sdk.model.api.PlayMethod
|
||||||
import ru.stenfredd.movies.playback.api.Codecs
|
import ru.stenfredd.movies.playback.api.Codecs
|
||||||
import ru.stenfredd.movies.playback.api.DolbyVisionSwitchable
|
import ru.stenfredd.movies.playback.api.DolbyVisionSwitchable
|
||||||
import ru.stenfredd.movies.playback.api.EngineId
|
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.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.PlaybackSession
|
||||||
import ru.stenfredd.movies.playback.core.PlaybackSessionFactory
|
|
||||||
import ru.stenfredd.movies.playback.core.PlaybackUiState
|
import ru.stenfredd.movies.playback.core.PlaybackUiState
|
||||||
import ru.stenfredd.movies.playback.core.describe
|
import ru.stenfredd.movies.playback.core.describe
|
||||||
import ru.stenfredd.movies.playback.exo.ExoEngineProvider
|
import ru.stenfredd.movies.playback.exo.ExoEngineProvider
|
||||||
import ru.stenfredd.movies.playback.vlc.VlcEngineProvider
|
import ru.stenfredd.movies.playback.vlc.VlcEngineProvider
|
||||||
import javax.inject.Inject
|
|
||||||
|
|
||||||
@HiltViewModel
|
/*
|
||||||
class PlayerViewModel @Inject constructor(
|
* Debug builds only: the prototype's numbered playback variants (badge in the corner, digit keys 0–9, MENU opens the
|
||||||
private val factory: PlaybackSessionFactory,
|
* variant/track/engine list, INFO cycles badge → diagnostics → nothing) and the debug tour. Release builds never call
|
||||||
val selector: EngineSelector,
|
* into this file (every use is behind BuildConfig.DEBUG, R8 drops it).
|
||||||
val probe: DeviceCapabilitiesProbe,
|
*/
|
||||||
) : ViewModel() {
|
|
||||||
var session: PlaybackSession? = null
|
|
||||||
private set
|
|
||||||
|
|
||||||
fun start(screen: Screen.Player): PlaybackSession {
|
/** Debug tour context for [PlayerScreen] (when the OSD is hidden): ↑ cycles playback variants, ↓ goes to the next file. */
|
||||||
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. */
|
|
||||||
class TourInfo(val number: Int, val total: Int, val title: String, val label: String, val onNextFile: () -> Unit)
|
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. */
|
/** 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
|
internal 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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Numbered playback variants for A/B testing on the TV: the badge in the corner shows the number, so the tester can
|
* 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),
|
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. */
|
/** Returns false if [number] is not a variant. */
|
||||||
private fun applyVariant(number: Int, session: PlaybackSession): Boolean? {
|
internal fun applyVariant(number: Int, session: PlaybackSession): Boolean {
|
||||||
when (number) {
|
when (number) {
|
||||||
0 -> {
|
0 -> session.switchToAuto()
|
||||||
session.switchToAuto()
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
TRANSCODE_KEY -> session.forceTranscode()
|
TRANSCODE_KEY -> session.forceTranscode()
|
||||||
else -> {
|
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 current = session.activeEngine
|
||||||
val sameDecoder = (current as? DolbyVisionSwitchable)?.useDolbyVisionDecoder?.let { it == v.dolbyVision } ?: true
|
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)
|
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
|
@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 engine = session.activeEngine
|
||||||
val path = (engine as? VideoPathSwitchable)?.let { if (it.usingGpuPath) "GPU" else "видеослой" }
|
val path = (engine as? VideoPathSwitchable)?.let { if (it.usingGpuPath) "GPU" else "видеослой" }
|
||||||
?.plus(if ((engine as? DolbyVisionSwitchable)?.useDolbyVisionDecoder == true) " · DV" else "")
|
?.plus(if ((engine as? DolbyVisionSwitchable)?.useDolbyVisionDecoder == true) " · DV" else "")
|
||||||
val transcode = state.playMethod != null && state.playMethod != PlayMethod.DIRECT_PLAY
|
val transcode = state.playMethod != null && state.playMethod != PlayMethod.DIRECT_PLAY
|
||||||
val head = listOfNotNull(state.engine?.value?.uppercase(), path, if (transcode) "ТРАНСКОД" else null).joinToString(" · ")
|
val head = listOfNotNull(state.engine?.value?.uppercase(), path, if (transcode) "ТРАНСКОД" else null).joinToString(" · ")
|
||||||
val mode = when {
|
val mode = when {
|
||||||
manual -> "вручную"
|
state.manualEngine -> "вручную"
|
||||||
state.engineReason.startsWith("fallback") || "не умеет" in state.engineReason || "все движки" in state.engineReason -> "авто, запасной"
|
state.engineReason.startsWith("fallback") || "не умеет" in state.engineReason || "все движки" in state.engineReason -> "авто, запасной"
|
||||||
else -> "авто"
|
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),
|
fontSize = 26.sp, fontWeight = FontWeight.Bold, fontFamily = FontFamily.Monospace, color = Color(0xFFFFD54F),
|
||||||
)
|
)
|
||||||
Text(
|
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,
|
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)
|
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
|
@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 caps = vm.probe.capabilities
|
||||||
val lines = buildList {
|
val lines = buildList {
|
||||||
add("Движок: ${state.engine} — ${state.engineReason}")
|
add("Движок: ${state.engine} — ${state.engineReason}")
|
||||||
@@ -295,12 +147,11 @@ private fun DebugPanel(state: PlaybackUiState, vm: PlayerViewModel, modifier: Mo
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun Menu(
|
internal fun DebugMenu(
|
||||||
state: PlaybackUiState,
|
state: PlaybackUiState,
|
||||||
session: PlaybackSession,
|
session: PlaybackSession,
|
||||||
vm: PlayerViewModel,
|
vm: PlayerViewModel,
|
||||||
modifier: Modifier,
|
modifier: Modifier,
|
||||||
onManual: (Boolean) -> Unit,
|
|
||||||
close: () -> Unit,
|
close: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val source = state.source ?: return
|
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 "Транскод сервером (текущим движком)")
|
val variants = listOf(0 to "Авто") + Variant.entries.map { it.number to it.label } + listOf(TRANSCODE_KEY to "Транскод сервером (текущим движком)")
|
||||||
variants.forEach { (number, label) ->
|
variants.forEach { (number, label) ->
|
||||||
item {
|
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) }
|
item { Text("Аудио", style = MaterialTheme.typography.titleMedium) }
|
||||||
@@ -337,9 +188,9 @@ private fun Menu(
|
|||||||
}
|
}
|
||||||
item { Text("Движок", style = MaterialTheme.typography.titleMedium) }
|
item { Text("Движок", style = MaterialTheme.typography.titleMedium) }
|
||||||
vm.selector.engines.forEach { p ->
|
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 ->
|
(session.activeEngine as? VideoPathSwitchable)?.let { engine ->
|
||||||
item { Text("Вывод видео", style = MaterialTheme.typography.titleMedium) }
|
item { Text("Вывод видео", style = MaterialTheme.typography.titleMedium) }
|
||||||
val modes = listOf(
|
val modes = listOf(
|
||||||
@@ -349,7 +200,7 @@ private fun Menu(
|
|||||||
)
|
)
|
||||||
modes.forEach { (mode, label) ->
|
modes.forEach { (mode, label) ->
|
||||||
item {
|
item {
|
||||||
Button(onClick = { engine.setVideoPathMode(mode); onManual(mode != VideoPathSwitchable.Mode.AUTO); close() }) {
|
Button(onClick = { engine.setVideoPathMode(mode); close() }) {
|
||||||
Text(mark(engine.videoPathMode == mode) + label)
|
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.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.TextStyle
|
||||||
import androidx.compose.ui.draw.drawBehind
|
import androidx.compose.ui.draw.drawBehind
|
||||||
import androidx.compose.ui.focus.FocusRequester
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
import androidx.compose.foundation.focusGroup
|
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. */
|
/** HH:mm, recomposes once a minute and only itself. */
|
||||||
@Composable
|
@Composable
|
||||||
fun Clock(modifier: Modifier = Modifier) {
|
fun Clock(modifier: Modifier = Modifier, style: TextStyle = AppType.Clock) {
|
||||||
var now by remember { mutableStateOf(LocalTime.now()) }
|
var now by remember { mutableStateOf(LocalTime.now()) }
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
while (true) {
|
while (true) {
|
||||||
@@ -267,7 +268,7 @@ fun Clock(modifier: Modifier = Modifier) {
|
|||||||
now = LocalTime.now()
|
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")
|
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) },
|
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()
|
val streams = mediaStreams.orEmpty()
|
||||||
return MediaInfo(
|
return MediaInfo(
|
||||||
sourceId = id.orEmpty(),
|
sourceId = id.orEmpty(),
|
||||||
|
|||||||
@@ -72,3 +72,21 @@ data class EngineStats(
|
|||||||
val droppedFrames: Int = 0,
|
val droppedFrames: Int = 0,
|
||||||
val extra: Map<String, String> = emptyMap(),
|
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). */
|
/** 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
|
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]. */
|
/** Adds the engine's own video views (surface, subtitle overlay) into [container]. */
|
||||||
fun attachVideo(container: FrameLayout)
|
fun attachVideo(container: FrameLayout)
|
||||||
fun detachVideo()
|
fun detachVideo()
|
||||||
@@ -65,6 +68,15 @@ interface DolbyVisionSwitchable {
|
|||||||
var useDolbyVisionDecoder: Boolean
|
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 {
|
interface SubtitleDelayCapable {
|
||||||
fun setSubtitleDelay(delayMs: Long)
|
fun setSubtitleDelay(delayMs: Long)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,4 +20,5 @@ dependencies {
|
|||||||
implementation(libs.hilt.android)
|
implementation(libs.hilt.android)
|
||||||
ksp(libs.hilt.compiler)
|
ksp(libs.hilt.compiler)
|
||||||
implementation(libs.timber)
|
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.api.client.extensions.videoApi
|
||||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||||
import org.jellyfin.sdk.model.api.MediaSourceInfo
|
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.MediaStreamType
|
||||||
import org.jellyfin.sdk.model.api.PlayMethod
|
import org.jellyfin.sdk.model.api.PlayMethod
|
||||||
import org.jellyfin.sdk.model.api.PlaybackInfoDto
|
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.LoadRequest
|
||||||
import ru.stenfredd.movies.playback.api.PlaybackEngine
|
import ru.stenfredd.movies.playback.api.PlaybackEngine
|
||||||
import ru.stenfredd.movies.playback.api.StreamSelection
|
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.Verdict
|
||||||
import ru.stenfredd.movies.playback.api.VideoPathSwitchable
|
import ru.stenfredd.movies.playback.api.VideoPathSwitchable
|
||||||
import ru.stenfredd.movies.playback.api.videoStream
|
import ru.stenfredd.movies.playback.api.videoStream
|
||||||
@@ -58,6 +63,15 @@ data class PlaybackUiState(
|
|||||||
val engineState: EngineState = EngineState(),
|
val engineState: EngineState = EngineState(),
|
||||||
val positionMs: Long = 0,
|
val positionMs: Long = 0,
|
||||||
val durationMs: 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 playMethod: PlayMethod? = null,
|
||||||
val transcodeReasons: String? = null,
|
val transcodeReasons: String? = null,
|
||||||
val source: MediaSourceInfo? = null,
|
val source: MediaSourceInfo? = null,
|
||||||
@@ -72,8 +86,9 @@ class PlaybackSessionFactory @Inject constructor(
|
|||||||
private val session: JellyfinSession,
|
private val session: JellyfinSession,
|
||||||
private val selector: EngineSelector,
|
private val selector: EngineSelector,
|
||||||
private val probe: DeviceCapabilitiesProbe,
|
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 session: JellyfinSession,
|
||||||
private val selector: EngineSelector,
|
private val selector: EngineSelector,
|
||||||
private val probe: DeviceCapabilitiesProbe,
|
private val probe: DeviceCapabilitiesProbe,
|
||||||
|
private val prefs: PlaybackPreferences,
|
||||||
private val scope: CoroutineScope,
|
private val scope: CoroutineScope,
|
||||||
) {
|
) {
|
||||||
private val _state = MutableStateFlow(PlaybackUiState())
|
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. */
|
/** The user picked the engine: the stall watchdog only logs instead of switching away from their choice. */
|
||||||
private var manualEngine = false
|
private var manualEngine = false
|
||||||
private var lastSeekAt = 0L
|
private var lastSeekAt = 0L
|
||||||
|
private var maxBitrate: Int? = null
|
||||||
|
private var subtitleStyle = SubtitleStyle()
|
||||||
|
private var subtitleLift = 0f
|
||||||
|
|
||||||
private var playSessionId: String? = null
|
private var playSessionId: String? = null
|
||||||
private var playMethod: PlayMethod = PlayMethod.DIRECT_PLAY
|
private var playMethod: PlayMethod = PlayMethod.DIRECT_PLAY
|
||||||
@@ -140,12 +159,15 @@ class PlaybackSession(
|
|||||||
withContext(Dispatchers.Default) { probe.capabilities }
|
withContext(Dispatchers.Default) { probe.capabilities }
|
||||||
item = session.call { libraryApi.getItem(itemId, user.userId).content }
|
item = session.call { libraryApi.getItem(itemId, user.userId).content }
|
||||||
source = item.mediaSources?.firstOrNull() ?: error("У элемента нет медиаисточников")
|
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
|
val startMs = startFraction?.let { ((item.runTimeTicks ?: 0) / TICKS_PER_MS * it).toLong() } ?: startPositionMs
|
||||||
ranking = selector.rank(source, selection, device)
|
ranking = selector.rank(source, selection, device)
|
||||||
_state.update {
|
_state.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
title = item.name.orEmpty(),
|
title = item.name.orEmpty(),
|
||||||
|
itemId = item.id,
|
||||||
|
seriesId = item.seriesId,
|
||||||
source = source,
|
source = source,
|
||||||
selection = selection,
|
selection = selection,
|
||||||
ranking = ranking.map { r -> r.provider.id to r.verdict },
|
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 first = forcedEngine?.let(selector::provider) ?: ranking.first().provider
|
||||||
val reason = if (forcedEngine != null) "выбран вручную" else ranking.first().verdict.describe()
|
val reason = if (forcedEngine != null) "выбран вручную" else ranking.first().verdict.describe()
|
||||||
manualEngine = forcedEngine != null
|
manualEngine = forcedEngine != null
|
||||||
|
_state.update { it.copy(manualEngine = manualEngine) }
|
||||||
launchEngine(first, startMs, forceTranscode = false, reason = reason)
|
launchEngine(first, startMs, forceTranscode = false, reason = reason)
|
||||||
}.onFailure { fail("Не удалось начать воспроизведение", it) }
|
}.onFailure { fail("Не удалось начать воспроизведение", it) }
|
||||||
}
|
}
|
||||||
@@ -166,6 +189,13 @@ class PlaybackSession(
|
|||||||
reportProgressAsync()
|
reportProgressAsync()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun pause() {
|
||||||
|
val e = engine ?: return
|
||||||
|
if (!e.state.value.playWhenReady) return
|
||||||
|
e.pause()
|
||||||
|
reportProgressAsync()
|
||||||
|
}
|
||||||
|
|
||||||
fun seekBy(deltaMs: Long) = seekTo(positionMs() + deltaMs)
|
fun seekBy(deltaMs: Long) = seekTo(positionMs() + deltaMs)
|
||||||
|
|
||||||
fun seekTo(positionMs: Long) {
|
fun seekTo(positionMs: Long) {
|
||||||
@@ -189,13 +219,60 @@ class PlaybackSession(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun selectAudio(streamIndex: Int) {
|
fun selectAudio(streamIndex: Int) {
|
||||||
|
if (selection.audioStreamIndex == streamIndex) return
|
||||||
selection = selection.copy(audioStreamIndex = streamIndex)
|
selection = selection.copy(audioStreamIndex = streamIndex)
|
||||||
onSelectionChanged("смена аудио") { it.selectAudio(streamIndex) }
|
onSelectionChanged("смена аудио") { it.selectAudio(streamIndex) }
|
||||||
|
source.stream(streamIndex)?.let { s -> scope.launch { prefs.rememberAudio(memoryKey(), s.pick()) } }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun selectSubtitle(streamIndex: Int?) {
|
fun selectSubtitle(streamIndex: Int?) {
|
||||||
|
if (selection.subtitleStreamIndex == streamIndex) return
|
||||||
selection = selection.copy(subtitleStreamIndex = streamIndex)
|
selection = selection.copy(subtitleStreamIndex = streamIndex)
|
||||||
onSelectionChanged("смена субтитров") { it.selectSubtitle(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
|
nextVideoPath = videoPath
|
||||||
nextDolbyVision = dolbyVision
|
nextDolbyVision = dolbyVision
|
||||||
manualEngine = true
|
manualEngine = true
|
||||||
|
_state.update { it.copy(manualEngine = true) }
|
||||||
launchEngine(selector.provider(id), position, forceTranscode = transcode, reason = if (transcode) "транскод вручную" else "выбран вручную")
|
launchEngine(selector.provider(id), position, forceTranscode = transcode, reason = if (transcode) "транскод вручную" else "выбран вручную")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -243,6 +321,7 @@ class PlaybackSession(
|
|||||||
nextDolbyVision = null
|
nextDolbyVision = null
|
||||||
defaultDolbyVision = true
|
defaultDolbyVision = true
|
||||||
manualEngine = false
|
manualEngine = false
|
||||||
|
_state.update { it.copy(manualEngine = false) }
|
||||||
launchEngine(first.provider, position, forceTranscode = false, reason = first.verdict.describe())
|
launchEngine(first.provider, position, forceTranscode = false, reason = first.verdict.describe())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -251,6 +330,7 @@ class PlaybackSession(
|
|||||||
val position = positionMs()
|
val position = positionMs()
|
||||||
val provider = engine?.id?.let(selector::provider) ?: return
|
val provider = engine?.id?.let(selector::provider) ?: return
|
||||||
manualEngine = true
|
manualEngine = true
|
||||||
|
_state.update { it.copy(manualEngine = true) }
|
||||||
scope.launch { launchEngine(provider, position, forceTranscode = true, reason = "транскод вручную") }
|
scope.launch { launchEngine(provider, position, forceTranscode = true, reason = "транскод вручную") }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -293,7 +373,7 @@ class PlaybackSession(
|
|||||||
item.id,
|
item.id,
|
||||||
PlaybackInfoDto(
|
PlaybackInfoDto(
|
||||||
userId = user.userId,
|
userId = user.userId,
|
||||||
maxStreamingBitrate = MAX_BITRATE,
|
maxStreamingBitrate = maxBitrate ?: MAX_BITRATE,
|
||||||
startTimeTicks = startMs * TICKS_PER_MS,
|
startTimeTicks = startMs * TICKS_PER_MS,
|
||||||
audioStreamIndex = selection.audioStreamIndex,
|
audioStreamIndex = selection.audioStreamIndex,
|
||||||
subtitleStreamIndex = selection.subtitleStreamIndex,
|
subtitleStreamIndex = selection.subtitleStreamIndex,
|
||||||
@@ -346,6 +426,10 @@ class PlaybackSession(
|
|||||||
nextVideoPath?.let { (newEngine as? VideoPathSwitchable)?.setVideoPathMode(it) }
|
nextVideoPath?.let { (newEngine as? VideoPathSwitchable)?.setVideoPathMode(it) }
|
||||||
nextVideoPath = null
|
nextVideoPath = null
|
||||||
nextDolbyVision?.let { (newEngine as? DolbyVisionSwitchable)?.useDolbyVisionDecoder = it }
|
nextDolbyVision?.let { (newEngine as? DolbyVisionSwitchable)?.useDolbyVisionDecoder = it }
|
||||||
|
(newEngine as? SubtitleStyleable)?.let {
|
||||||
|
it.setSubtitleStyle(subtitleStyle)
|
||||||
|
it.setSubtitleLift(subtitleLift)
|
||||||
|
}
|
||||||
nextDolbyVision = null
|
nextDolbyVision = null
|
||||||
defaultDolbyVision = false
|
defaultDolbyVision = false
|
||||||
engine = newEngine
|
engine = newEngine
|
||||||
@@ -476,7 +560,7 @@ class PlaybackSession(
|
|||||||
var sinceReport = 0L
|
var sinceReport = 0L
|
||||||
while (scope.isActive && gen == generation) {
|
while (scope.isActive && gen == generation) {
|
||||||
val e = engine ?: return
|
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)
|
delay(TICK_MS)
|
||||||
sinceReport += TICK_MS
|
sinceReport += TICK_MS
|
||||||
if (sinceReport >= PROGRESS_INTERVAL_MS) {
|
if (sinceReport >= PROGRESS_INTERVAL_MS) {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ package ru.stenfredd.movies.playback.exo
|
|||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.res.Configuration
|
import android.content.res.Configuration
|
||||||
|
import android.graphics.Color
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.Handler
|
import android.os.Handler
|
||||||
import android.os.Looper
|
import android.os.Looper
|
||||||
@@ -37,6 +38,7 @@ import androidx.media3.exoplayer.mediacodec.MediaCodecUtil
|
|||||||
import androidx.media3.exoplayer.audio.AudioSink
|
import androidx.media3.exoplayer.audio.AudioSink
|
||||||
import androidx.media3.exoplayer.trackselection.DefaultTrackSelector
|
import androidx.media3.exoplayer.trackselection.DefaultTrackSelector
|
||||||
import androidx.media3.ui.AspectRatioFrameLayout
|
import androidx.media3.ui.AspectRatioFrameLayout
|
||||||
|
import androidx.media3.ui.CaptionStyleCompat
|
||||||
import androidx.media3.ui.SubtitleView
|
import androidx.media3.ui.SubtitleView
|
||||||
import io.github.peerless2012.ass.media.AssHandler
|
import io.github.peerless2012.ass.media.AssHandler
|
||||||
import io.github.peerless2012.ass.media.AssHandlerConfig
|
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.EngineStats
|
||||||
import ru.stenfredd.movies.playback.api.LoadRequest
|
import ru.stenfredd.movies.playback.api.LoadRequest
|
||||||
import ru.stenfredd.movies.playback.api.PlaybackEngine
|
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.VideoPathSwitchable
|
||||||
import ru.stenfredd.movies.playback.api.embeddedOrdinal
|
import ru.stenfredd.movies.playback.api.embeddedOrdinal
|
||||||
import ru.stenfredd.movies.playback.api.videoStream
|
import ru.stenfredd.movies.playback.api.videoStream
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
|
|
||||||
class ExoEngine(context: Context) : PlaybackEngine, VideoPathSwitchable, DolbyVisionSwitchable {
|
class ExoEngine(context: Context) : PlaybackEngine, VideoPathSwitchable, DolbyVisionSwitchable, SubtitleStyleable {
|
||||||
override val id = ExoEngineProvider.ID
|
override val id = ExoEngineProvider.ID
|
||||||
|
|
||||||
/** See [gpuPathFor]: only TVs known to over-process SDR on the video plane. */
|
/** 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
|
resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT
|
||||||
addView(surfaceView, FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT))
|
addView(surfaceView, FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT))
|
||||||
}
|
}
|
||||||
private val subtitleView = SubtitleView(context).apply {
|
private val subtitleView = SubtitleView(context)
|
||||||
setUserDefaultStyle()
|
|
||||||
setUserDefaultTextSize()
|
init {
|
||||||
|
setSubtitleStyle(SubtitleStyle())
|
||||||
}
|
}
|
||||||
|
|
||||||
private var request: LoadRequest? = null
|
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 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 play() = player.play()
|
||||||
override fun pause() = player.pause()
|
override fun pause() = player.pause()
|
||||||
override fun seekTo(positionMs: Long) {
|
override fun seekTo(positionMs: Long) {
|
||||||
@@ -491,6 +518,8 @@ class ExoEngine(context: Context) : PlaybackEngine, VideoPathSwitchable, DolbyVi
|
|||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val EXTERNAL_PREFIX = "ext:"
|
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 MIN_BUFFER_MB = 32
|
||||||
private const val FRAME_RATE_SAMPLE_MS = 2000L
|
private const val FRAME_RATE_SAMPLE_MS = 2000L
|
||||||
/** Compose UI surface on the TV; the GPU path is shown at this rate. */
|
/** 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.LoadRequest
|
||||||
import ru.stenfredd.movies.playback.api.PlaybackEngine
|
import ru.stenfredd.movies.playback.api.PlaybackEngine
|
||||||
import ru.stenfredd.movies.playback.api.SubtitleDelayCapable
|
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.embeddedOrdinal
|
||||||
import ru.stenfredd.movies.playback.api.stream
|
import ru.stenfredd.movies.playback.api.stream
|
||||||
import ru.stenfredd.movies.playback.api.streams
|
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).
|
* 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.
|
* 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
|
override val id = VlcEngineProvider.ID
|
||||||
|
|
||||||
private val libVlc = LibVLC(context.applicationContext, OPTIONS)
|
private val appContext = context.applicationContext
|
||||||
private val player = MediaPlayer(libVlc)
|
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 val proxy = LoopbackProxy(httpClient)
|
||||||
private var videoLayout: VLCVideoLayout? = null
|
private var videoLayout: VLCVideoLayout? = null
|
||||||
private var media: Media? = null
|
private var media: Media? = null
|
||||||
@@ -56,7 +73,7 @@ class VlcEngine(context: Context, httpClient: OkHttpClient, private val device:
|
|||||||
override val state = _state.asStateFlow()
|
override val state = _state.asStateFlow()
|
||||||
private val _events = MutableSharedFlow<EngineEvent>(extraBufferCapacity = 16, onBufferOverflow = BufferOverflow.DROP_OLDEST)
|
private val _events = MutableSharedFlow<EngineEvent>(extraBufferCapacity = 16, onBufferOverflow = BufferOverflow.DROP_OLDEST)
|
||||||
override val events = _events.asSharedFlow()
|
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 stats = _stats.asStateFlow()
|
||||||
|
|
||||||
override val positionMs get() = player.time.coerceAtLeast(0)
|
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
|
override fun onProgressUpdate(dialog: Dialog.ProgressDialog) = Unit
|
||||||
}
|
}
|
||||||
|
|
||||||
init {
|
|
||||||
Dialog.setCallbacks(libVlc, dialogCallbacks)
|
|
||||||
player.setEventListener(::onEvent)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun attachVideo(container: FrameLayout) {
|
override fun attachVideo(container: FrameLayout) {
|
||||||
detachVideo()
|
detachVideo()
|
||||||
// Must be created with the Activity context: libVLC's VideoHelper only computes the surface size
|
// 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)
|
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() {
|
override fun release() {
|
||||||
if (released) return
|
if (released) return
|
||||||
released = true
|
released = true
|
||||||
|
if (!playerLazy.isInitialized()) {
|
||||||
|
proxy.close()
|
||||||
|
return
|
||||||
|
}
|
||||||
player.setEventListener(null)
|
player.setEventListener(null)
|
||||||
detachVideo()
|
detachVideo()
|
||||||
val m = media
|
val m = media
|
||||||
@@ -382,6 +405,22 @@ class VlcEngine(context: Context, httpClient: OkHttpClient, private val device:
|
|||||||
}
|
}
|
||||||
|
|
||||||
private companion object {
|
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_SETTLE_MS = 1500L
|
||||||
const val SEEK_TOLERANCE_MS = 5000L
|
const val SEEK_TOLERANCE_MS = 5000L
|
||||||
const val MAX_SEEK_CORRECTIONS = 3
|
const val MAX_SEEK_CORRECTIONS = 3
|
||||||
|
|||||||
Reference in New Issue
Block a user