Series page
This commit is contained in:
@@ -37,6 +37,7 @@ import ru.stenfredd.movies.ui.TourScreen
|
||||
import ru.stenfredd.movies.ui.details.DetailsScreen
|
||||
import ru.stenfredd.movies.ui.home.HomeScreen
|
||||
import ru.stenfredd.movies.ui.player.PlayerScreen
|
||||
import ru.stenfredd.movies.ui.series.SeriesScreen
|
||||
import ru.stenfredd.movies.ui.login.LoginScreen
|
||||
import ru.stenfredd.movies.ui.shell.Shell
|
||||
import ru.stenfredd.movies.ui.shell.Tab
|
||||
@@ -80,6 +81,11 @@ class MainActivity : ComponentActivity() {
|
||||
stack.removeAt(stack.lastIndex)
|
||||
if (stack.lastOrNull() != Screen.Detail(id)) stack.add(Screen.Detail(id))
|
||||
}
|
||||
/** "К сериалу" from the player: back to the series page, or open it if the player came from elsewhere. */
|
||||
fun showSeries(seriesId: UUID, episodeId: UUID) {
|
||||
stack.removeAt(stack.lastIndex)
|
||||
if ((stack.lastOrNull() as? Screen.Series)?.seriesId != seriesId) stack.add(Screen.Series(seriesId, episodeId))
|
||||
}
|
||||
fun resetTo(screen: Screen) {
|
||||
stack.clear()
|
||||
stack.add(screen)
|
||||
@@ -104,7 +110,13 @@ class MainActivity : ComponentActivity() {
|
||||
Screen.Prototype -> Legacy { PrototypeScreen(onOpen = ::open, onLogout = { resetTo(Screen.Login) }) }
|
||||
is Screen.Items -> Legacy { ItemsScreen(screen, onOpen = ::open) }
|
||||
is Screen.Detail -> DetailsScreen(screen, onOpen = ::open)
|
||||
is Screen.Player -> PlayerScreen(screen, onExit = ::back, onOpenDetails = { id -> showDetails(id) })
|
||||
is Screen.Series -> SeriesScreen(screen, onOpen = ::open)
|
||||
is Screen.Player -> PlayerScreen(
|
||||
screen,
|
||||
onExit = ::back,
|
||||
onOpenDetails = { id -> showDetails(id) },
|
||||
onOpenSeries = { seriesId, episodeId -> showSeries(seriesId, episodeId) },
|
||||
)
|
||||
is Screen.Tour -> TourScreen(screen.startIndex, onExit = ::back)
|
||||
}
|
||||
}
|
||||
@@ -147,15 +159,16 @@ class MainActivity : ComponentActivity() {
|
||||
/**
|
||||
* Opens an item from the launch intent:
|
||||
* `adb shell am start -S -n ru.stenfredd.movies/.MainActivity --es item <id> [--ez play true] [--el start <ms>]
|
||||
* [--ei audio <index>] [--ei sub <index>]` (stream indexes as Jellyfin numbers them; with one given, the other is off).
|
||||
* [--ei audio <index>] [--ei sub <index>]` (stream indexes as Jellyfin numbers them; with one given, the other is off),
|
||||
* or a series page: `--es series <id> [--es episode <id>]`.
|
||||
* Debug builds also take `--es engine exo|vlc` (implies play) and `--ez tour true [--ei tour_file N]` for the debug tour.
|
||||
*/
|
||||
private fun intentTarget(): List<Screen>? {
|
||||
if (BuildConfig.DEBUG && intent.getBooleanExtra("tour", false)) return listOf(Screen.Tour(intent.getIntExtra("tour_file", 1) - 1))
|
||||
val raw = intent.getStringExtra("item") ?: return null
|
||||
val id = runCatching {
|
||||
UUID.fromString(if ('-' in raw) raw else raw.replace(Regex("(.{8})(.{4})(.{4})(.{4})(.{12})"), "$1-$2-$3-$4-$5"))
|
||||
}.getOrNull() ?: return null
|
||||
intent.getStringExtra("series")?.let(::parseId)?.let { series ->
|
||||
return listOf(Screen.Series(series, intent.getStringExtra("episode")?.let(::parseId)))
|
||||
}
|
||||
val id = intent.getStringExtra("item")?.let(::parseId) ?: return null
|
||||
val engine = if (BuildConfig.DEBUG) intent.getStringExtra("engine")?.let(::EngineId) else null
|
||||
val play = engine != null || intent.getBooleanExtra("play", false)
|
||||
val streams = if (intent.hasExtra("audio") || intent.hasExtra("sub")) {
|
||||
@@ -166,4 +179,9 @@ class MainActivity : ComponentActivity() {
|
||||
return listOf(Screen.Detail(id)) +
|
||||
if (play) listOf(Screen.Player(id, engine, intent.getLongExtra("start", 0), streams = streams)) else emptyList()
|
||||
}
|
||||
|
||||
/** Jellyfin ids come with or without dashes. */
|
||||
private fun parseId(raw: String): UUID? = runCatching {
|
||||
UUID.fromString(if ('-' in raw) raw else raw.replace(Regex("(.{8})(.{4})(.{4})(.{4})(.{12})"), "$1-$2-$3-$4-$5"))
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
@@ -38,6 +38,7 @@ import coil3.compose.AsyncImage
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import ru.stenfredd.movies.core.jellyfin.JellyfinSession
|
||||
import ru.stenfredd.movies.ui.components.TextInput
|
||||
import ru.stenfredd.movies.ui.components.rememberTextInputState
|
||||
@@ -48,7 +49,11 @@ import javax.inject.Inject
|
||||
@HiltViewModel
|
||||
class BrowseViewModel @Inject constructor(val library: Library, val session: JellyfinSession) : ViewModel()
|
||||
|
||||
private fun BaseItemDto.target(): Screen = if (isPlayable) Screen.Detail(id) else Screen.Items(id, name.orEmpty())
|
||||
private fun BaseItemDto.target(): Screen = when {
|
||||
type == BaseItemKind.SERIES -> Screen.Series(id)
|
||||
isPlayable -> Screen.Detail(id)
|
||||
else -> Screen.Items(id, name.orEmpty())
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun PrototypeScreen(onOpen: (Screen) -> Unit, onLogout: () -> Unit, vm: BrowseViewModel = hiltViewModel()) {
|
||||
|
||||
@@ -18,6 +18,9 @@ sealed interface Screen {
|
||||
data object Prototype : Screen
|
||||
data class Items(val parentId: UUID, val title: String) : Screen
|
||||
data class Detail(val itemId: UUID) : Screen
|
||||
|
||||
/** Series page; [episodeId] = the episode to show first (a new episode, continue watching), else the next one to watch. */
|
||||
data class Series(val seriesId: UUID, val episodeId: UUID? = null) : Screen
|
||||
data class Player(
|
||||
val itemId: UUID,
|
||||
val engine: EngineId?,
|
||||
|
||||
@@ -168,7 +168,7 @@ private val TitleStyle = AppType.Display.copy(fontSize = 46.sp, lineHeight = 48.
|
||||
|
||||
/** Backdrop, dimmed and faded at its edges ([BackdropTransformation]), at the top right of the first screen. */
|
||||
@Composable
|
||||
private fun Backdrop(url: String) {
|
||||
internal fun Backdrop(url: String) {
|
||||
val context = LocalContext.current
|
||||
val area = remember { RectF(L.PAGE_W - L.BACKDROP_W, 0f, L.PAGE_W, L.BACKDROP_H) }
|
||||
val request = remember(url) {
|
||||
|
||||
@@ -27,7 +27,6 @@ import ru.stenfredd.movies.core.data.model.MediaInfo
|
||||
import ru.stenfredd.movies.core.data.model.PersonKind
|
||||
import ru.stenfredd.movies.playback.api.StreamSelection
|
||||
import ru.stenfredd.movies.playback.core.PlaybackPreferences
|
||||
import ru.stenfredd.movies.playback.core.TrackPick
|
||||
import ru.stenfredd.movies.ui.Screen
|
||||
import ru.stenfredd.movies.ui.components.PosterModel
|
||||
import ru.stenfredd.movies.ui.components.RemoteImageSpec
|
||||
@@ -122,15 +121,13 @@ class DetailsViewModel @AssistedInject constructor(
|
||||
fun selectAudio(index: Int?) {
|
||||
tracksChosen = true
|
||||
_state.update { it.copy(audio = index) }
|
||||
val track = media?.audio?.firstOrNull { it.index == index } ?: return
|
||||
viewModelScope.launch { prefs.rememberAudio(memoryKey, TrackPick(track.language, track.name, track.codec)) }
|
||||
viewModelScope.launch { TrackChoices.rememberAudio(prefs, memoryKey, media, index) }
|
||||
}
|
||||
|
||||
fun selectSubtitle(index: Int?) {
|
||||
tracksChosen = true
|
||||
_state.update { it.copy(subtitle = index) }
|
||||
val pick = media?.subtitles?.firstOrNull { it.index == index }?.let { TrackPick(it.language, it.name, it.codec) }
|
||||
viewModelScope.launch { prefs.rememberSubtitle(memoryKey, pick) }
|
||||
viewModelScope.launch { TrackChoices.rememberSubtitle(prefs, memoryKey, media, index) }
|
||||
}
|
||||
|
||||
/** The player screen for this item: from the resume point unless [fromStart], with the chosen tracks. */
|
||||
@@ -175,23 +172,15 @@ class DetailsViewModel @AssistedInject constructor(
|
||||
val media = details.media
|
||||
this@DetailsViewModel.media = media
|
||||
memoryKey = details.summary.seriesId ?: itemId
|
||||
val remembered = media?.let { remembered(it) }
|
||||
val initial = media?.let { TrackChoices.initial(prefs, memoryKey, it) }
|
||||
_state.update { s ->
|
||||
val keep = tracksChosen && s.item != null
|
||||
s.copy(
|
||||
loading = false,
|
||||
error = null,
|
||||
item = model,
|
||||
audio = when {
|
||||
keep -> s.audio
|
||||
remembered?.first != null -> remembered.first
|
||||
else -> media?.defaultAudioIndex ?: media?.audio?.firstOrNull()?.index
|
||||
},
|
||||
subtitle = when {
|
||||
keep -> s.subtitle
|
||||
remembered != null -> remembered.second
|
||||
else -> media?.defaultSubtitleIndex
|
||||
},
|
||||
audio = if (keep) s.audio else initial?.first,
|
||||
subtitle = if (keep) s.subtitle else initial?.second,
|
||||
)
|
||||
}
|
||||
// Similar items only make sense for movies; an episode would get random other series.
|
||||
@@ -211,21 +200,6 @@ class DetailsViewModel @AssistedInject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The tracks picked last time for this movie or series, found in this file: (audio, subtitle), or null if nothing
|
||||
* is remembered. Unmatched audio stays null (the file's default is used); a remembered subtitle that this file
|
||||
* doesn't have falls back to the default too.
|
||||
*/
|
||||
private suspend fun remembered(media: MediaInfo): Pair<Int?, Int?>? {
|
||||
val memory = prefs.tracks(memoryKey) ?: return null
|
||||
val audio = memory.audio?.bestOf(media.audio) { TrackPick(it.language, it.name, it.codec) }?.index
|
||||
val subtitle = when {
|
||||
memory.subtitleOff -> null
|
||||
else -> memory.subtitle?.bestOf(media.subtitles) { TrackPick(it.language, it.name, it.codec) }?.index ?: media.defaultSubtitleIndex
|
||||
}
|
||||
return audio to subtitle
|
||||
}
|
||||
|
||||
private fun ItemDetails.toModel(): DetailsModel {
|
||||
val item = summary
|
||||
val episode = item.kind == ItemKind.Episode
|
||||
@@ -259,13 +233,8 @@ class DetailsViewModel @AssistedInject constructor(
|
||||
remainingMinutes = if (progress != null && item.runtimeMs != null) (item.runtimeMs!! * (1 - progress) / 60_000).roundToInt() else null,
|
||||
favorite = item.user.favorite,
|
||||
played = item.user.played,
|
||||
audio = media?.audio.orEmpty().map { TrackOption("audio/${it.index}", it.index, MediaLabels.audioLabel(it), MediaLabels.audioDetail(it)) },
|
||||
subtitles = if (media == null || media.subtitles.isEmpty()) {
|
||||
emptyList()
|
||||
} else {
|
||||
listOf(TrackOption("sub/off", null, "Выкл", null)) +
|
||||
media.subtitles.map { TrackOption("sub/${it.index}", it.index, MediaLabels.subtitleLabel(it), MediaLabels.subtitleDetail(it)) }
|
||||
},
|
||||
audio = TrackChoices.audio(media),
|
||||
subtitles = TrackChoices.subtitles(media),
|
||||
castTitle = listOfNotNull("В ролях", directors.takeIf { it.isNotEmpty() }?.let { "режиссёр " + it.joinToString(", ") }).joinToString(" · "),
|
||||
cast = actors.map { p ->
|
||||
CastModel(
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
package ru.stenfredd.movies.ui.details
|
||||
|
||||
import ru.stenfredd.movies.core.data.model.MediaInfo
|
||||
import ru.stenfredd.movies.playback.core.PlaybackPreferences
|
||||
import ru.stenfredd.movies.playback.core.TrackPick
|
||||
import java.util.UUID
|
||||
|
||||
/** Audio and subtitle choices of a file as the movie page and the series tray show them. */
|
||||
internal object TrackChoices {
|
||||
fun audio(media: MediaInfo?): List<TrackOption> =
|
||||
media?.audio.orEmpty().map { TrackOption("audio/${it.index}", it.index, MediaLabels.audioLabel(it), MediaLabels.audioDetail(it)) }
|
||||
|
||||
/** "Выкл" first, then the tracks; empty when the file has no subtitles. */
|
||||
fun subtitles(media: MediaInfo?): List<TrackOption> =
|
||||
if (media == null || media.subtitles.isEmpty()) {
|
||||
emptyList()
|
||||
} else {
|
||||
listOf(TrackOption("sub/off", null, "Выкл", null)) +
|
||||
media.subtitles.map { TrackOption("sub/${it.index}", it.index, MediaLabels.subtitleLabel(it), MediaLabels.subtitleDetail(it)) }
|
||||
}
|
||||
|
||||
/**
|
||||
* The tracks picked last time for this movie or series ([key]), found in [media]: (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.
|
||||
*/
|
||||
suspend fun remembered(prefs: PlaybackPreferences, key: UUID, media: MediaInfo): Pair<Int?, Int?>? {
|
||||
val memory = prefs.tracks(key) ?: 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
|
||||
}
|
||||
|
||||
/** Remembered tracks, else the file's defaults: (audio, subtitle). */
|
||||
suspend fun initial(prefs: PlaybackPreferences, key: UUID, media: MediaInfo): Pair<Int?, Int?> {
|
||||
val remembered = remembered(prefs, key, media)
|
||||
return Pair(
|
||||
remembered?.first ?: media.defaultAudioIndex ?: media.audio.firstOrNull()?.index,
|
||||
if (remembered != null) remembered.second else media.defaultSubtitleIndex,
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun rememberAudio(prefs: PlaybackPreferences, key: UUID, media: MediaInfo?, index: Int?) {
|
||||
val track = media?.audio?.firstOrNull { it.index == index } ?: return
|
||||
prefs.rememberAudio(key, TrackPick(track.language, track.name, track.codec))
|
||||
}
|
||||
|
||||
suspend fun rememberSubtitle(prefs: PlaybackPreferences, key: UUID, media: MediaInfo?, index: Int?) {
|
||||
val pick = media?.subtitles?.firstOrNull { it.index == index }?.let { TrackPick(it.language, it.name, it.codec) }
|
||||
prefs.rememberSubtitle(key, pick)
|
||||
}
|
||||
}
|
||||
@@ -74,9 +74,15 @@ class CardMapper @Inject constructor(
|
||||
)
|
||||
}
|
||||
|
||||
/** Where a card leads. Series and folders still open the old prototype list until their screens exist. */
|
||||
/**
|
||||
* Where a card leads: a movie to its page, a series, season or episode to the series page (on that episode).
|
||||
* Collections and folders still open the old prototype list until the library screen exists.
|
||||
*/
|
||||
fun target(item: ItemSummary): Screen = when (item.kind) {
|
||||
ItemKind.Series, ItemKind.Season, ItemKind.BoxSet, ItemKind.Folder -> Screen.Items(item.id, item.name)
|
||||
ItemKind.Series -> Screen.Series(item.id)
|
||||
ItemKind.Season -> item.seriesId?.let { Screen.Series(it) } ?: Screen.Items(item.id, item.name)
|
||||
ItemKind.Episode -> item.seriesId?.let { Screen.Series(it, item.id) } ?: Screen.Detail(item.id)
|
||||
ItemKind.BoxSet, ItemKind.Folder -> Screen.Items(item.id, item.name)
|
||||
else -> Screen.Detail(item.id)
|
||||
}
|
||||
|
||||
|
||||
@@ -85,6 +85,7 @@ internal fun PlayerDrawer(
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val episodes = season?.takeIf { info?.isEpisode == true }
|
||||
val page = remember { FocusRequester() }
|
||||
LaunchedEffect(Unit) {
|
||||
withFrameNanos { }
|
||||
runCatching { initial.requestFocus() }
|
||||
@@ -118,9 +119,10 @@ internal fun PlayerDrawer(
|
||||
)
|
||||
BasicText("▲ — свернуть", Modifier.padding(end = 6.dp), style = AppType.MonoCaption.copy(color = AppColors.TextFaint))
|
||||
PagePill(
|
||||
if (episodes != null) "Страница серии" else "Страница фильма",
|
||||
if (episodes != null) "К сериалу" else "Страница фильма",
|
||||
onClick = onPage,
|
||||
modifier = Modifier
|
||||
.focusRequester(page)
|
||||
.then(if (episodes == null) Modifier.focusRequester(initial) else Modifier)
|
||||
.onPreviewKeyEvent {
|
||||
if (it.type == KeyEventType.KeyDown && it.key.nativeKeyCode == AndroidKeyEvent.KEYCODE_DPAD_UP) {
|
||||
@@ -133,7 +135,17 @@ internal fun PlayerDrawer(
|
||||
)
|
||||
}
|
||||
when {
|
||||
episodes != null -> EpisodeRow(episodes, currentId, playedFraction, initial, onEpisode)
|
||||
// ↑ from the episodes goes to "К сериалу" (the controls above would leave the drawer open behind them).
|
||||
episodes != null -> Box(
|
||||
Modifier.onPreviewKeyEvent {
|
||||
if (it.type == KeyEventType.KeyDown && it.key.nativeKeyCode == AndroidKeyEvent.KEYCODE_DPAD_UP) {
|
||||
runCatching { page.requestFocus() }
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
},
|
||||
) { EpisodeRow(episodes, currentId, playedFraction, initial, onEpisode) }
|
||||
info != null -> AboutMovie(info)
|
||||
}
|
||||
}
|
||||
|
||||
136
app/src/main/kotlin/ru/stenfredd/movies/ui/player/PlayerNext.kt
Normal file
136
app/src/main/kotlin/ru/stenfredd/movies/ui/player/PlayerNext.kt
Normal file
@@ -0,0 +1,136 @@
|
||||
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.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.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.BasicText
|
||||
import androidx.compose.runtime.Composable
|
||||
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.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
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.em
|
||||
import androidx.compose.ui.unit.sp
|
||||
import ru.stenfredd.movies.ui.components.AppIcon
|
||||
import ru.stenfredd.movies.ui.components.FocusStyle
|
||||
import ru.stenfredd.movies.ui.components.Icon
|
||||
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
|
||||
|
||||
/** The card shows this long before the end of an episode that has a next one. */
|
||||
internal const val NEXT_PROMPT_MS = 30_000L
|
||||
|
||||
private val CardColor = Color(0xFF0E1018)
|
||||
private val CardBorder = Color(0xFF6F63FF)
|
||||
private val PromptFocus = FocusStyle(scale = 1f, corner = 17.dp, glows = FocusStyle.ButtonGlows, animate = false)
|
||||
|
||||
/**
|
||||
* "Следующая через N с" at the end of an episode: still and title of the next one, "Смотреть сейчас" and
|
||||
* "Досмотреть титры". Opaque (over video a translucent card costs a blend every frame); the countdown and the bar
|
||||
* move once a second, as the position does.
|
||||
*/
|
||||
@Composable
|
||||
internal fun NextEpisodeCard(
|
||||
next: NextEpisode,
|
||||
remainingMs: Long,
|
||||
playNow: FocusRequester,
|
||||
onPlay: () -> Unit,
|
||||
onStay: () -> Unit,
|
||||
onFocus: (Boolean) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val shape = RoundedCornerShape(10.dp)
|
||||
val seconds = ((remainingMs + 999) / 1000).coerceAtLeast(0)
|
||||
val fraction = (1f - remainingMs.toFloat() / NEXT_PROMPT_MS).coerceIn(0f, 1f)
|
||||
Column(
|
||||
modifier
|
||||
.width(320.dp)
|
||||
.background(CardColor, shape)
|
||||
.border(0.5.dp, CardBorder, shape)
|
||||
.onFocusChanged { onFocus(it.hasFocus) },
|
||||
) {
|
||||
Row(Modifier.padding(11.dp), horizontalArrangement = Arrangement.spacedBy(11.dp)) {
|
||||
RemoteImage(next.image, 6.dp, Modifier.size(PlayerLayout.NEXT_W.dp, PlayerLayout.NEXT_H.dp))
|
||||
Column(Modifier.weight(1f)) {
|
||||
BasicText(
|
||||
"СЛЕДУЮЩАЯ ЧЕРЕЗ $seconds С",
|
||||
style = AppType.MonoLabel.copy(fontSize = 9.sp, letterSpacing = 0.08.em),
|
||||
)
|
||||
BasicText(
|
||||
next.title,
|
||||
Modifier.padding(top = 5.dp),
|
||||
style = AppType.Section.copy(fontSize = 13.sp, lineHeight = 16.sp),
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
Row(Modifier.padding(start = 11.dp, end = 11.dp, bottom = 11.dp), horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
PromptButton("Смотреть сейчас", primary = true, onClick = onPlay, modifier = Modifier.weight(1f).focusRequester(playNow))
|
||||
PromptButton("Досмотреть титры", primary = false, onClick = onStay)
|
||||
}
|
||||
// Countdown bar along the bottom edge, inside the rounded corners.
|
||||
Spacer(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(2.5.dp)
|
||||
.padding(horizontal = 10.dp)
|
||||
.drawBehind {
|
||||
drawRect(AppColors.ProgressTrack)
|
||||
drawRect(AppColors.ProgressGradient, Offset.Zero, Size(size.width * fraction, size.height))
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PromptButton(label: String, primary: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier) {
|
||||
var focused by remember { mutableStateOf(false) }
|
||||
val shape = RoundedCornerShape(17.dp)
|
||||
Row(
|
||||
modifier
|
||||
.focusCard(PromptFocus)
|
||||
.height(34.dp)
|
||||
.then(
|
||||
when {
|
||||
primary -> Modifier.background(AppColors.PrimaryGradient, shape)
|
||||
focused -> Modifier.background(AppColors.FocusFill, shape)
|
||||
else -> Modifier.background(OsdColors.Fill, shape).border(0.5.dp, OsdColors.Border, shape)
|
||||
},
|
||||
)
|
||||
.then(if (primary && focused) Modifier.border(1.5.dp, Color(0xFFD9D2FF), shape) else Modifier)
|
||||
.onFocusChanged { focused = it.isFocused }
|
||||
.clickable(interactionSource = null, indication = null, onClick = onClick)
|
||||
.padding(horizontal = 14.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp, Alignment.CenterHorizontally),
|
||||
) {
|
||||
val color = if (!primary && focused) AppColors.OnFocusFill else Color.White
|
||||
if (primary) Icon(AppIcon.Play, color, Modifier.size(11.dp))
|
||||
BasicText(label, style = AppType.Button.copy(fontSize = 12.sp, fontWeight = if (primary) FontWeight.SemiBold else FontWeight.Normal, color = color))
|
||||
}
|
||||
}
|
||||
@@ -302,5 +302,28 @@ internal fun PanelCloseButton(onClick: () -> Unit, modifier: Modifier = Modifier
|
||||
SecondaryButton("Закрыть", onClick = onClick, modifier = modifier)
|
||||
}
|
||||
|
||||
/** Size, colour and background choices of text subtitles, for the player's panel and the series tray. */
|
||||
internal object SubtitleChoices {
|
||||
private val colors = listOf(SubtitleStyle.WHITE to "Белый", SubtitleStyle.YELLOW to "Жёлтый", SubtitleStyle.LIGHT_BLUE to "Голубой")
|
||||
private val backgrounds = listOf(SubtitleStyle.Background.NONE to "Нет", SubtitleStyle.Background.SHADOW to "Тень", SubtitleStyle.Background.BOX to "Плашка")
|
||||
|
||||
fun sizes(style: SubtitleStyle) = SubtitleStyle.Size.entries.map { PanelChoice("size/$it", it.name, null, it == style.size) }
|
||||
|
||||
fun colors(style: SubtitleStyle) = colors.map { (argb, name) -> PanelChoice("color/$argb", name, null, argb == style.color, Color(argb)) }
|
||||
|
||||
fun backgrounds(style: SubtitleStyle) = backgrounds.map { (bg, name) -> PanelChoice("bg/$bg", name, null, bg == style.background) }
|
||||
|
||||
/** [style] with [choice] (one of the lists above) applied. */
|
||||
fun apply(style: SubtitleStyle, choice: PanelChoice): SubtitleStyle {
|
||||
val value = choice.key.substringAfter('/')
|
||||
return when (choice.key.substringBefore('/')) {
|
||||
"size" -> style.copy(size = SubtitleStyle.Size.valueOf(value))
|
||||
"color" -> style.copy(color = value.toInt())
|
||||
"bg" -> style.copy(background = SubtitleStyle.Background.valueOf(value))
|
||||
else -> style
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal fun List<TrackOption>.toChoices(selected: Int?): List<PanelChoice> =
|
||||
map { PanelChoice(it.key, it.label, it.detail, it.index == selected) }
|
||||
|
||||
@@ -81,6 +81,7 @@ fun PlayerScreen(
|
||||
screen: Screen.Player,
|
||||
onExit: () -> Unit,
|
||||
onOpenDetails: (UUID) -> Unit = {},
|
||||
onOpenSeries: (seriesId: UUID, episodeId: UUID) -> Unit = { _, _ -> },
|
||||
tour: TourInfo? = null,
|
||||
vm: PlayerViewModel = hiltViewModel(),
|
||||
) {
|
||||
@@ -89,7 +90,7 @@ fun PlayerScreen(
|
||||
val session by vm.session.collectAsStateWithLifecycle()
|
||||
Box(Modifier.fillMaxSize().background(Color.Black)) {
|
||||
VideoSurface(session)
|
||||
session?.let { PlayerUi(it, vm, onExit, onOpenDetails, tour) }
|
||||
session?.let { PlayerUi(it, vm, onExit, onOpenDetails, onOpenSeries, tour) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -123,6 +124,10 @@ private class PlayerUiState {
|
||||
var tourStep = 0
|
||||
/** Back went down while the player had focus; it acts on release. */
|
||||
var backPressed = false
|
||||
/** "Досмотреть титры" was chosen for this item: no "next episode" card for it any more. */
|
||||
var nextDismissedFor by mutableStateOf<UUID?>(null)
|
||||
/** The "next episode" card has focus: arrows and OK go to its buttons. */
|
||||
var promptFocused = false
|
||||
|
||||
val root = FocusRequester()
|
||||
val playPause = FocusRequester()
|
||||
@@ -130,6 +135,7 @@ private class PlayerUiState {
|
||||
val panelInitial = FocusRequester()
|
||||
val drawerInitial = FocusRequester()
|
||||
val errorButton = FocusRequester()
|
||||
val nextPlay = FocusRequester()
|
||||
val debugMenuFocus = FocusRequester()
|
||||
val chips = PlayerPanel.entries.associateWith { FocusRequester() }
|
||||
var panelFrom: FocusRequester? = null
|
||||
@@ -151,7 +157,14 @@ private class PlayerUiState {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PlayerUi(session: PlaybackSession, vm: PlayerViewModel, onExit: () -> Unit, onOpenDetails: (UUID) -> Unit, tour: TourInfo?) {
|
||||
private fun PlayerUi(
|
||||
session: PlaybackSession,
|
||||
vm: PlayerViewModel,
|
||||
onExit: () -> Unit,
|
||||
onOpenDetails: (UUID) -> Unit,
|
||||
onOpenSeries: (UUID, UUID) -> Unit,
|
||||
tour: TourInfo?,
|
||||
) {
|
||||
val state by session.state.collectAsStateWithLifecycle()
|
||||
val info by vm.info.collectAsStateWithLifecycle()
|
||||
val season by vm.season.collectAsStateWithLifecycle()
|
||||
@@ -162,11 +175,18 @@ private fun PlayerUi(session: PlaybackSession, vm: PlayerViewModel, onExit: () -
|
||||
val playing = state.engineState.playWhenReady
|
||||
val ended = state.engineState.phase == EngineState.Phase.ENDED
|
||||
val shownPosition = ui.scrubTarget ?: state.positionMs
|
||||
val remainingMs = state.durationMs - state.positionMs
|
||||
// "Следующая через N с" near the end of an episode, unless dismissed or something is open over the video.
|
||||
val next = season?.nextEpisode?.takeIf {
|
||||
info?.isEpisode == true && state.durationMs > 0 && remainingMs in 0..NEXT_PROMPT_MS && !ended && state.error == null &&
|
||||
ui.nextDismissedFor != state.itemId && ui.panel == null && !ui.drawer
|
||||
}
|
||||
|
||||
fun hideOsd() {
|
||||
runCatching { ui.root.requestFocus() }
|
||||
ui.osd = false
|
||||
ui.drawer = false
|
||||
if (next != null) ui.focus(ui.nextPlay)
|
||||
}
|
||||
|
||||
fun showOsd(focus: FocusRequester) {
|
||||
@@ -216,6 +236,10 @@ private fun PlayerUi(session: PlaybackSession, vm: PlayerViewModel, onExit: () -
|
||||
ui.panel != null -> closePanel()
|
||||
ui.drawer -> closeDrawer()
|
||||
ui.osd && state.error == null -> hideOsd()
|
||||
next != null -> {
|
||||
ui.nextDismissedFor = state.itemId
|
||||
ui.focus(ui.root)
|
||||
}
|
||||
else -> onExit()
|
||||
}
|
||||
}
|
||||
@@ -245,7 +269,7 @@ private fun PlayerUi(session: PlaybackSession, vm: PlayerViewModel, onExit: () -
|
||||
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)
|
||||
else -> return onNavigationKey(code, repeat, ui, session, tour, ::scrub, ::showOsd, ::openDrawer, promptShown = next != null)
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -286,9 +310,19 @@ private fun PlayerUi(session: PlaybackSession, vm: PlayerViewModel, onExit: () -
|
||||
}
|
||||
LaunchedEffect(ended) {
|
||||
if (!ended) return@LaunchedEffect
|
||||
// Stage 6 adds "Следующая серия через N"; for now the next episode simply starts.
|
||||
// The end of an episode plays the next one (the card counted down to it, or was dismissed); else leave.
|
||||
season?.next?.let(vm::playEpisode) ?: onExit()
|
||||
}
|
||||
// Another episode started (⏭, media keys) with the drawer open: fold it, its focus belonged to the old list.
|
||||
LaunchedEffect(state.itemId) {
|
||||
if (ui.drawer) closeDrawer()
|
||||
}
|
||||
// The card takes focus when it appears over the bare video; with the controls shown they keep it.
|
||||
val nextShown = next != null
|
||||
LaunchedEffect(nextShown) {
|
||||
if (nextShown && !ui.osd) ui.focus(ui.nextPlay)
|
||||
if (!nextShown) ui.promptFocused = false
|
||||
}
|
||||
LifecycleEventEffect(Lifecycle.Event.ON_STOP) { session.pause() }
|
||||
|
||||
// Back while nothing in the player has focus (the key handler above doesn't see it then).
|
||||
@@ -325,7 +359,15 @@ private fun PlayerUi(session: PlaybackSession, vm: PlayerViewModel, onExit: () -
|
||||
playedFraction = if (state.durationMs > 0) shownPosition.toFloat() / state.durationMs else 0f,
|
||||
initial = ui.drawerInitial,
|
||||
onClose = ::closeDrawer,
|
||||
onPage = { state.itemId?.let(onOpenDetails) },
|
||||
onPage = {
|
||||
val id = state.itemId
|
||||
val seriesId = info?.seriesId
|
||||
when {
|
||||
id == null -> Unit
|
||||
seriesId != null -> onOpenSeries(seriesId, id)
|
||||
else -> onOpenDetails(id)
|
||||
}
|
||||
},
|
||||
onEpisode = { id ->
|
||||
if (id == state.itemId) {
|
||||
closeDrawer()
|
||||
@@ -337,6 +379,25 @@ private fun PlayerUi(session: PlaybackSession, vm: PlayerViewModel, onExit: () -
|
||||
modifier = Modifier.align(Alignment.BottomCenter),
|
||||
)
|
||||
}
|
||||
next?.let { n ->
|
||||
NextEpisodeCard(
|
||||
n,
|
||||
remainingMs = remainingMs,
|
||||
playNow = ui.nextPlay,
|
||||
onPlay = {
|
||||
hideOsd()
|
||||
ui.focus(ui.root)
|
||||
vm.playEpisode(n.id)
|
||||
},
|
||||
onStay = {
|
||||
ui.nextDismissedFor = state.itemId
|
||||
ui.focus(if (ui.osd) ui.playPause else ui.root)
|
||||
},
|
||||
onFocus = { ui.promptFocused = it },
|
||||
// Above the controls while they are shown.
|
||||
modifier = Modifier.align(Alignment.BottomEnd).padding(end = 48.dp, bottom = if (ui.osd) 132.dp else 32.dp),
|
||||
)
|
||||
}
|
||||
if (state.loading && state.error == null) LoadingBadge(Modifier.align(Alignment.Center))
|
||||
state.error?.let { error ->
|
||||
ErrorCard(error.lineSequence().first(), Modifier.align(Alignment.Center)) {
|
||||
@@ -373,6 +434,7 @@ private fun onNavigationKey(
|
||||
scrub: (Int, Int) -> Unit,
|
||||
showOsd: (FocusRequester) -> Unit,
|
||||
openDrawer: () -> Unit,
|
||||
promptShown: Boolean,
|
||||
): Boolean {
|
||||
if (BuildConfig.DEBUG && !ui.debugMenu) {
|
||||
when (code) {
|
||||
@@ -388,6 +450,8 @@ private fun onNavigationKey(
|
||||
}
|
||||
}
|
||||
if (ui.panel != null || ui.drawer || ui.debugMenu) return false
|
||||
// The "next episode" card over the bare video: ←/→ and OK work its buttons.
|
||||
if (!ui.osd && promptShown && ui.promptFocused && code in PROMPT_KEYS) return false
|
||||
if (!ui.osd) {
|
||||
when (code) {
|
||||
AndroidKeyEvent.KEYCODE_DPAD_UP if tour != null -> {
|
||||
@@ -563,21 +627,9 @@ private fun PanelLayer(
|
||||
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('/'))))
|
||||
}
|
||||
}
|
||||
PanelSection("РАЗМЕР") { PanelChips(SubtitleChoices.sizes(style)) { vm.setSubtitleStyle(SubtitleChoices.apply(style, it)) } }
|
||||
PanelSection("ЦВЕТ") { PanelChips(SubtitleChoices.colors(style)) { vm.setSubtitleStyle(SubtitleChoices.apply(style, it)) } }
|
||||
PanelSection("ФОН") { PanelChips(SubtitleChoices.backgrounds(style)) { vm.setSubtitleStyle(SubtitleChoices.apply(style, it)) } }
|
||||
val codec = selected?.codec?.lowercase()
|
||||
when {
|
||||
codec in GRAPHIC_SUBTITLES -> PanelNote("Это графические субтитры (картинки): они показываются как есть, размер и цвет к ним не применяются.")
|
||||
@@ -644,10 +696,16 @@ private fun PanelLayer(
|
||||
}
|
||||
}
|
||||
|
||||
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 val PROMPT_KEYS = setOf(
|
||||
AndroidKeyEvent.KEYCODE_DPAD_LEFT,
|
||||
AndroidKeyEvent.KEYCODE_DPAD_RIGHT,
|
||||
AndroidKeyEvent.KEYCODE_DPAD_CENTER,
|
||||
AndroidKeyEvent.KEYCODE_ENTER,
|
||||
AndroidKeyEvent.KEYCODE_NUMPAD_ENTER,
|
||||
)
|
||||
|
||||
private const val OSD_TIMEOUT_MS = 6_000L
|
||||
private const val TOP_SHADE = 105
|
||||
private const val BOTTOM_SHADE = 170
|
||||
|
||||
@@ -56,11 +56,21 @@ data class EpisodeModel(
|
||||
val isNew: Boolean,
|
||||
)
|
||||
|
||||
/** The episode after the one being played: the "Следующая через N с" card. */
|
||||
@Immutable
|
||||
data class NextEpisode(
|
||||
val id: UUID,
|
||||
/** "S1 · E5 · Разлом" */
|
||||
val title: String,
|
||||
val image: RemoteImageSpec?,
|
||||
)
|
||||
|
||||
/** 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 seriesId: UUID?,
|
||||
/** "ФИЛЬМ", "СЕРИАЛ" */
|
||||
val label: String,
|
||||
val title: String,
|
||||
@@ -90,6 +100,7 @@ data class SeasonEpisodes(
|
||||
val episodes: List<EpisodeModel>,
|
||||
val previous: UUID?,
|
||||
val next: UUID?,
|
||||
val nextEpisode: NextEpisode?,
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
@@ -211,6 +222,13 @@ class PlayerViewModel @javax.inject.Inject constructor(
|
||||
},
|
||||
previous = all.getOrNull(index - 1)?.id?.takeIf { index > 0 },
|
||||
next = all.getOrNull(index + 1)?.id?.takeIf { index >= 0 },
|
||||
nextEpisode = all.getOrNull(index + 1)?.takeIf { index >= 0 }?.let { n ->
|
||||
NextEpisode(
|
||||
id = n.id,
|
||||
title = listOfNotNull(n.seasonNumber?.let { "S$it" }, n.episodeNumber?.let { "E$it" }, n.name).joinToString(" · "),
|
||||
image = cards.image(n.primary ?: n.thumb ?: n.backdrop, PlayerLayout.NEXT_W, PlayerLayout.NEXT_H),
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -221,6 +239,7 @@ class PlayerViewModel @javax.inject.Inject constructor(
|
||||
return PlayerInfo(
|
||||
id = item.id,
|
||||
isEpisode = episode,
|
||||
seriesId = item.seriesId.takeIf { episode },
|
||||
label = if (episode) "СЕРИАЛ" else "ФИЛЬМ",
|
||||
title = if (episode) item.seriesName ?: item.name else item.name,
|
||||
subtitle = if (episode) {
|
||||
@@ -290,4 +309,6 @@ internal object PlayerLayout {
|
||||
const val POSTER_W = 100f
|
||||
const val POSTER_H = 150f
|
||||
const val AVATAR = 24f
|
||||
const val NEXT_W = 104f
|
||||
const val NEXT_H = 58.5f
|
||||
}
|
||||
|
||||
@@ -0,0 +1,679 @@
|
||||
package ru.stenfredd.movies.ui.series
|
||||
|
||||
import android.view.KeyEvent as AndroidKeyEvent
|
||||
import androidx.activity.compose.BackHandler
|
||||
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.layout.wrapContentSize
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
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.Stable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.listSaver
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
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.draw.drawWithCache
|
||||
import androidx.compose.ui.draw.rotate
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Canvas
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.graphics.drawscope.CanvasDrawScope
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.graphics.drawscope.DrawScope
|
||||
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.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 androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import kotlinx.coroutines.launch
|
||||
import ru.stenfredd.movies.ui.Screen
|
||||
import ru.stenfredd.movies.ui.components.AppIcon
|
||||
import ru.stenfredd.movies.ui.components.CircleButton
|
||||
import ru.stenfredd.movies.ui.components.FocusStyle
|
||||
import ru.stenfredd.movies.ui.components.FormatBadge
|
||||
import ru.stenfredd.movies.ui.components.MinimalScrolling
|
||||
import ru.stenfredd.movies.ui.components.PrimaryButton
|
||||
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.details.Backdrop
|
||||
import ru.stenfredd.movies.ui.focus.Restore
|
||||
import ru.stenfredd.movies.ui.focus.ScreenFocus
|
||||
import ru.stenfredd.movies.ui.focus.rememberScreenFocus
|
||||
import ru.stenfredd.movies.ui.focus.remembered
|
||||
import ru.stenfredd.movies.ui.theme.AppColors
|
||||
import ru.stenfredd.movies.ui.theme.AppDimens
|
||||
import ru.stenfredd.movies.ui.theme.AppType
|
||||
import kotlin.math.floor
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
* Series page, the mockup's "Сериал" screen. Full screen, without the side rail, nothing scrolls vertically:
|
||||
* header with "Продолжить S2 E4", the audio/subtitle choice and ♥; the season map; season chips; every episode of
|
||||
* every season in one row (a divider before each season); the focused episode's description at the bottom.
|
||||
*
|
||||
* Remote: ↓ from the buttons → the chip of the current season → its next episode; ↑ from an episode → its season's
|
||||
* chip. Moving along the chips scrolls the row to that season; moving along the row lights up its season.
|
||||
*/
|
||||
@Composable
|
||||
fun SeriesScreen(
|
||||
screen: Screen.Series,
|
||||
onOpen: (Screen) -> Unit,
|
||||
vm: SeriesViewModel = hiltViewModel<SeriesViewModel, SeriesViewModel.Factory>(creationCallback = { it.create(screen.seriesId) }),
|
||||
) {
|
||||
val state by vm.state.collectAsStateWithLifecycle()
|
||||
val focus = rememberScreenFocus()
|
||||
val item = state.item
|
||||
when {
|
||||
item != null -> SeriesPage(screen, item, state, focus, vm, onOpen)
|
||||
state.error != null -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
BasicText("Не удалось загрузить: ${state.error}", style = AppType.Body)
|
||||
}
|
||||
}
|
||||
focus.Restore(ready = item != null)
|
||||
}
|
||||
|
||||
private const val KEY_PLAY = "play"
|
||||
private const val KEY_TRACKS = "tracks"
|
||||
|
||||
/**
|
||||
* What the page highlights: the season of the chips and the map, the episode described at the bottom.
|
||||
* [episode] is read only by the parts that show it, so moving along the row doesn't recompose the page.
|
||||
*/
|
||||
@Stable
|
||||
private class SeriesSelection(season: Int, episode: Int) {
|
||||
var season by mutableIntStateOf(season)
|
||||
var episode by mutableIntStateOf(episode)
|
||||
|
||||
companion object {
|
||||
val Saver = listSaver<SeriesSelection, Int>({ listOf(it.season, it.episode) }, { SeriesSelection(it[0], it[1]) })
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SeriesPage(screen: Screen.Series, item: SeriesModel, state: SeriesState, focus: ScreenFocus, vm: SeriesViewModel, onOpen: (Screen) -> Unit) {
|
||||
// Opened on an episode (a new one, continue watching): it is focused and shown first, else the next one to watch.
|
||||
val start = remember(item.id) { item.episodes.indexOfFirst { it.id == screen.episodeId }.takeIf { it >= 0 } }
|
||||
val first = start ?: item.nextUp
|
||||
val selection = rememberSaveable(saver = SeriesSelection.Saver) { SeriesSelection(item.episodes.getOrNull(first)?.season ?: 0, first) }
|
||||
// The data can change on reload (a new episode): stay in range.
|
||||
val seasonIndex = selection.season.coerceIn(0, (item.seasons.size - 1).coerceAtLeast(0))
|
||||
|
||||
val episodesRow = rememberLazyListState(initialFirstVisibleItemIndex = item.episodes.getOrNull(first)?.rowIndex?.minus(1)?.coerceAtLeast(0) ?: 0)
|
||||
val chipsRow = rememberLazyListState(initialFirstVisibleItemIndex = (seasonIndex - 1).coerceAtLeast(0))
|
||||
val scope = rememberCoroutineScope()
|
||||
var trayOpen by rememberSaveable { mutableStateOf(false) }
|
||||
val tracksButton = remember { FocusRequester() }
|
||||
|
||||
/** Focuses the element [key], scrolling [list] to [index] first when it isn't on screen. */
|
||||
fun moveFocus(key: String, list: LazyListState? = null, index: Int = 0) {
|
||||
scope.launch {
|
||||
if (list != null && list.layoutInfo.visibleItemsInfo.none { it.index == index }) list.scrollToItem((index - 1).coerceAtLeast(0))
|
||||
focus.lastKey = key
|
||||
// One frame to compose the element with its new requester, one to lay it out.
|
||||
withFrameNanos { }
|
||||
withFrameNanos { }
|
||||
focus.requestFocus()
|
||||
}
|
||||
}
|
||||
|
||||
fun focusChip(season: Int) = item.seasons.getOrNull(season)?.let { moveFocus(it.key, chipsRow, season) }
|
||||
|
||||
fun focusEpisode(index: Int) = item.episodes.getOrNull(index)?.let { moveFocus(it.key, episodesRow, it.rowIndex) }
|
||||
|
||||
fun onSeasonFocused(season: Int) {
|
||||
if (season == selection.season) return
|
||||
selection.season = season
|
||||
val s = item.seasons[season]
|
||||
selection.episode = s.target
|
||||
// The row follows the chips: the season's divider (or its next episode, deeper in the season) at the start.
|
||||
val target = if (s.target == s.first) s.dividerIndex else item.episodes[s.target].rowIndex - 1
|
||||
scope.launch { episodesRow.animateScrollToItem(target.coerceAtLeast(0)) }
|
||||
}
|
||||
|
||||
fun onEpisodeFocused(index: Int) {
|
||||
selection.episode = index
|
||||
val season = item.episodes[index].season
|
||||
if (season != selection.season) {
|
||||
selection.season = season
|
||||
if (chipsRow.layoutInfo.visibleItemsInfo.none { it.index == season }) scope.launch { chipsRow.animateScrollToItem((season - 1).coerceAtLeast(0)) }
|
||||
}
|
||||
}
|
||||
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
item.backdropUrl?.let { Backdrop(it) }
|
||||
Header(
|
||||
item,
|
||||
state,
|
||||
focus,
|
||||
tracksButton,
|
||||
isDefault = start == null,
|
||||
onPlay = { item.episodes.getOrNull(item.nextUp)?.let { onOpen(vm.player(it)) } },
|
||||
onTracks = { trayOpen = true },
|
||||
onFavorite = vm::toggleFavorite,
|
||||
modifier = Modifier.onPreviewKeyEvent { e -> e.isDown(AndroidKeyEvent.KEYCODE_DPAD_DOWN).also { if (it) focusChip(seasonIndex) } },
|
||||
)
|
||||
SeasonMap(
|
||||
item,
|
||||
seasonIndex,
|
||||
current = { selection.episode },
|
||||
modifier = Modifier.offset(y = 183.dp).padding(horizontal = AppDimens.CONTENT_START.dp).fillMaxWidth(),
|
||||
)
|
||||
SeasonChips(
|
||||
item,
|
||||
seasonIndex,
|
||||
chipsRow,
|
||||
focus,
|
||||
onFocused = ::onSeasonFocused,
|
||||
onClick = { focusEpisode(item.seasons[it].target) },
|
||||
modifier = Modifier
|
||||
.offset(y = 220.dp)
|
||||
.onPreviewKeyEvent { e ->
|
||||
when {
|
||||
e.isDown(AndroidKeyEvent.KEYCODE_DPAD_DOWN) -> true.also { focusEpisode(item.seasons[seasonIndex].target) }
|
||||
e.isDown(AndroidKeyEvent.KEYCODE_DPAD_UP) -> true.also { moveFocus(KEY_PLAY) }
|
||||
else -> false
|
||||
}
|
||||
},
|
||||
)
|
||||
EpisodeRow(
|
||||
item,
|
||||
episodesRow,
|
||||
focus,
|
||||
defaultKey = start?.let { item.episodes[it].key },
|
||||
onFocused = ::onEpisodeFocused,
|
||||
onClick = { onOpen(vm.player(it)) },
|
||||
modifier = Modifier
|
||||
.offset(y = 254.dp)
|
||||
.onPreviewKeyEvent { e -> e.isDown(AndroidKeyEvent.KEYCODE_DPAD_UP).also { if (it) focusChip(item.episodes[selection.episode.coerceIn(item.episodes.indices)].season) } },
|
||||
)
|
||||
SelectedEpisode(item, selection, Modifier.align(Alignment.BottomStart))
|
||||
if (trayOpen) {
|
||||
val style by vm.subtitleStyle.collectAsStateWithLifecycle()
|
||||
fun close() {
|
||||
trayOpen = false
|
||||
// Its own requester: when the tray goes, the system first focuses the page's first button, which
|
||||
// would also take over the remembered key.
|
||||
scope.launch {
|
||||
withFrameNanos { }
|
||||
runCatching { tracksButton.requestFocus() }
|
||||
}
|
||||
}
|
||||
BackHandler { close() }
|
||||
SeriesTray(item.title, state, style, vm, onClose = ::close, modifier = Modifier.align(Alignment.BottomCenter))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun KeyEvent.isDown(code: Int) = type == KeyEventType.KeyDown && key.nativeKeyCode == code
|
||||
|
||||
private val TitleStyle = AppType.Display.copy(fontSize = 36.sp, lineHeight = 38.sp, letterSpacing = (-0.03).em)
|
||||
|
||||
@Composable
|
||||
private fun Header(
|
||||
item: SeriesModel,
|
||||
state: SeriesState,
|
||||
focus: ScreenFocus,
|
||||
tracksButton: FocusRequester,
|
||||
isDefault: Boolean,
|
||||
onPlay: () -> Unit,
|
||||
onTracks: () -> Unit,
|
||||
onFavorite: () -> Unit,
|
||||
modifier: Modifier,
|
||||
) {
|
||||
val left = AppDimens.CONTENT_START.dp
|
||||
RemoteImage(
|
||||
item.poster,
|
||||
8.dp,
|
||||
Modifier
|
||||
.offset(left, 32.dp)
|
||||
.size(SeriesLayout.POSTER_W.dp, SeriesLayout.POSTER_H.dp)
|
||||
.border(0.5.dp, Color(0x1AFFFFFF), RoundedCornerShape(8.dp)),
|
||||
)
|
||||
Column(Modifier.padding(start = left + SeriesLayout.POSTER_W.dp + 20.dp, end = left, top = 32.dp)) {
|
||||
BasicText(item.label, style = AppType.MonoLabel)
|
||||
BasicText(item.title, Modifier.padding(top = 9.dp), style = TitleStyle, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
Meta(item, state.badges, Modifier.padding(top = 9.dp))
|
||||
Row(modifier.padding(top = 13.dp), horizontalArrangement = Arrangement.spacedBy(7.dp)) {
|
||||
PrimaryButton(item.playLabel, onPlay, Modifier.remembered(focus, KEY_PLAY, isDefault = isDefault))
|
||||
if (state.audioLabel != null || state.subtitleLabel != null) {
|
||||
TracksButton(state.audioLabel, state.subtitleLabel, onTracks, Modifier.focusRequester(tracksButton).remembered(focus, KEY_TRACKS))
|
||||
}
|
||||
CircleButton(
|
||||
if (item.favorite) AppIcon.HeartFilled else AppIcon.Heart,
|
||||
onFavorite,
|
||||
Modifier.remembered(focus, "favorite"),
|
||||
tint = if (item.favorite) AppColors.Pink else Color(0xFFE3E5F2),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** "★ 8.4 16+ 2022 Фантастика, Драма 4K HDR10 Atmos" */
|
||||
@Composable
|
||||
private fun Meta(item: SeriesModel, badges: List<String>, modifier: Modifier) {
|
||||
val text = AppType.Body.copy(fontSize = 11.5.sp, lineHeight = 12.sp, color = AppColors.TextSecondary)
|
||||
Row(modifier, verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(9.dp)) {
|
||||
item.rating?.let { BasicText(it, style = text.copy(color = AppColors.Yellow, fontWeight = FontWeight.SemiBold)) }
|
||||
item.age?.let {
|
||||
BasicText(
|
||||
it,
|
||||
Modifier.border(0.5.dp, Color(0xFF4A4F68), RoundedCornerShape(3.dp)).padding(horizontal = 5.dp, vertical = 2.dp),
|
||||
style = text.copy(fontSize = 10.sp, lineHeight = 10.sp),
|
||||
)
|
||||
}
|
||||
listOfNotNull(item.year, item.genres).forEach { BasicText(it, style = text, maxLines = 1, overflow = TextOverflow.Ellipsis) }
|
||||
if (badges.isNotEmpty()) Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { badges.forEach { FormatBadge(it) } }
|
||||
}
|
||||
}
|
||||
|
||||
private val TracksFocus = FocusStyle(scale = 1.05f, corner = 21.dp, glows = FocusStyle.ButtonGlows)
|
||||
|
||||
/** "● Русский 5.1 ● без субтитров": opens the tray. */
|
||||
@Composable
|
||||
private fun TracksButton(audio: String?, subtitle: String?, onClick: () -> Unit, modifier: Modifier) {
|
||||
var focused by remember { mutableStateOf(false) }
|
||||
val shape = RoundedCornerShape(21.dp)
|
||||
val color = if (focused) AppColors.OnFocusFill else Color(0xFFE3E5F2)
|
||||
Row(
|
||||
modifier
|
||||
.focusCard(TracksFocus)
|
||||
.height(42.dp)
|
||||
.background(if (focused) AppColors.FocusFill else AppColors.SurfaceRaised, shape)
|
||||
.border(0.5.dp, if (focused) AppColors.FocusFill else AppColors.Border, shape)
|
||||
.onFocusChanged { focused = it.isFocused }
|
||||
.clickable(interactionSource = null, indication = null, onClick = onClick)
|
||||
.padding(horizontal = 14.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
val style = AppType.Button.copy(fontSize = 12.sp, fontWeight = FontWeight.Normal, color = color)
|
||||
audio?.let {
|
||||
Box(Modifier.size(5.dp).background(AppColors.Pink, CircleShape))
|
||||
BasicText(it, Modifier.padding(start = 6.dp), style = style, maxLines = 1)
|
||||
}
|
||||
subtitle?.let {
|
||||
Box(Modifier.padding(start = if (audio != null) 10.dp else 0.dp).size(5.dp).background(AppColors.Yellow, CircleShape))
|
||||
BasicText(it, Modifier.padding(start = 6.dp), style = style, maxLines = 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private object MapColors {
|
||||
val Watched = Color(0xFF3F8F63)
|
||||
val Empty = Color(0xFF33384D)
|
||||
val Label = Color(0xFF6F7592)
|
||||
}
|
||||
|
||||
/**
|
||||
* Season map: a bar per episode — watched green, next violet, new outlined pink — the selected episode tall and
|
||||
* white. Bars shrink for long seasons; if the series still doesn't fit, the seasons around the selected one are shown.
|
||||
*/
|
||||
@Composable
|
||||
private fun SeasonMap(item: SeriesModel, selectedSeason: Int, current: () -> Int, modifier: Modifier) {
|
||||
val layout = remember(item.seasons, selectedSeason) { mapLayout(item.seasons, selectedSeason) }
|
||||
Row(modifier.height(MAP_H.dp), verticalAlignment = Alignment.Bottom) {
|
||||
Row(Modifier.width(MAP_W.dp), horizontalArrangement = Arrangement.spacedBy(MAP_GAP.dp), verticalAlignment = Alignment.Bottom) {
|
||||
if (layout.from > 0) BasicText("…", style = AppType.MonoCaption.copy(color = MapColors.Label))
|
||||
for (s in layout.from until layout.to) {
|
||||
val season = item.seasons[s]
|
||||
val width = (layout.bar + layout.gap) * season.count - layout.gap
|
||||
Column {
|
||||
BasicText(
|
||||
season.mapLabel,
|
||||
style = AppType.MonoCaption.copy(fontSize = 8.5.sp, color = if (s == selectedSeason) AppColors.Text else MapColors.Label),
|
||||
maxLines = 1,
|
||||
)
|
||||
// The selected episode is a separate marker moved by its layer: the bars stay one baked bitmap.
|
||||
Box(Modifier.padding(top = 4.dp).size(width.coerceAtLeast(1f).dp, BAR_TALL.dp)) {
|
||||
Spacer(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.drawWithCache {
|
||||
// Baked once: hundreds of 1 px outline sides drawn every frame cost ~9 ms of GPU on
|
||||
// the test TV (it redraws the whole screen each frame), one bitmap almost nothing.
|
||||
val image = ImageBitmap(size.width.toInt().coerceAtLeast(1), size.height.toInt().coerceAtLeast(1))
|
||||
CanvasDrawScope().draw(this, layoutDirection, Canvas(image), size) { drawBars(item, season, layout) }
|
||||
onDrawBehind { drawImage(image) }
|
||||
},
|
||||
)
|
||||
Spacer(
|
||||
Modifier
|
||||
.size(layout.bar.dp, BAR_TALL.dp)
|
||||
.graphicsLayer {
|
||||
val i = current() - season.first
|
||||
alpha = if (i in 0 until season.count) 1f else 0f
|
||||
translationX = i.coerceAtLeast(0) * floor((layout.bar + layout.gap).dp.toPx()).coerceAtLeast(floor(layout.bar.dp.toPx()).coerceAtLeast(1f) + 1f)
|
||||
}
|
||||
.drawBehind { drawRect(Color.White, size = Size(floor(size.width).coerceAtLeast(1f), size.height)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (layout.to < item.seasons.size) BasicText("…", style = AppType.MonoCaption.copy(color = MapColors.Label))
|
||||
}
|
||||
Spacer(Modifier.weight(1f))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(9.dp)) {
|
||||
val legend = AppType.MonoCaption
|
||||
BasicText("■ просмотрено", style = legend.copy(color = AppColors.Green))
|
||||
BasicText("■ следующая", style = legend.copy(color = AppColors.Accent))
|
||||
BasicText("□ новая", style = legend.copy(color = AppColors.Pink))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Bars of one season: plain rectangles, outlines as four 1 px sides (rounded or stroked shapes are tessellated one by one). */
|
||||
private fun DrawScope.drawBars(item: SeriesModel, season: SeriesSeason, layout: MapLayout) {
|
||||
val bar = floor(layout.bar.dp.toPx()).coerceAtLeast(1f)
|
||||
val step = floor((layout.bar + layout.gap).dp.toPx()).coerceAtLeast(bar + 1f)
|
||||
val low = floor(BAR_H.dp.toPx())
|
||||
val top = size.height - low
|
||||
fun outline(color: Color, x: Float) {
|
||||
drawRect(color, Offset(x, top), Size(bar, 1f))
|
||||
drawRect(color, Offset(x, size.height - 1f), Size(bar, 1f))
|
||||
drawRect(color, Offset(x, top), Size(1f, low))
|
||||
drawRect(color, Offset(x + bar - 1f, top), Size(1f, low))
|
||||
}
|
||||
for (i in 0 until season.count) {
|
||||
val index = season.first + i
|
||||
val x = i * step
|
||||
when (item.episodes[index].state) {
|
||||
EpisodeState.Watched -> drawRect(MapColors.Watched, Offset(x, top), Size(bar, low))
|
||||
EpisodeState.InProgress, EpisodeState.Next -> drawRect(AppColors.Accent, Offset(x, top), Size(bar, low))
|
||||
EpisodeState.New -> outline(AppColors.Pink, x)
|
||||
EpisodeState.None -> if (bar >= 3f) outline(MapColors.Empty, x) else drawRect(MapColors.Empty, Offset(x, top), Size(bar, low))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private const val MAP_W = 600f
|
||||
private const val MAP_H = 32f
|
||||
private const val MAP_GAP = 14f
|
||||
private const val BAR_H = 10f
|
||||
private const val BAR_TALL = 17f
|
||||
/** Width (dp) of one character of the map labels (JetBrains Mono advance is 0.6 em). */
|
||||
private const val LABEL_CHAR = 8.5f * 0.6f
|
||||
|
||||
private data class MapLayout(val bar: Float, val gap: Float, val from: Int, val to: Int)
|
||||
|
||||
/** Bar size that fits all seasons in [MAP_W], else the widest window of seasons around [selected] at the smallest size. */
|
||||
private fun mapLayout(seasons: List<SeriesSeason>, selected: Int): MapLayout {
|
||||
if (seasons.isEmpty()) return MapLayout(7f, 2f, 0, 0)
|
||||
fun width(s: SeriesSeason, bar: Float, gap: Float) = maxOf(s.count * (bar + gap) - gap, s.mapLabel.length * LABEL_CHAR)
|
||||
fun total(range: IntRange, bar: Float, gap: Float) = range.sumOf { width(seasons[it], bar, gap).toDouble() }.toFloat() + MAP_GAP * (range.count() - 1)
|
||||
for ((bar, gap) in listOf(7f to 2f, 5f to 1.5f, 3f to 1f, 2f to 1f)) {
|
||||
if (total(seasons.indices, bar, gap) <= MAP_W) return MapLayout(bar, gap, 0, seasons.size)
|
||||
}
|
||||
val (bar, gap) = 2f to 1f
|
||||
// Room for the "…" marks at both ends.
|
||||
val room = MAP_W - 2 * (MAP_GAP + LABEL_CHAR)
|
||||
var from = selected.coerceIn(seasons.indices)
|
||||
var to = from + 1
|
||||
while (true) {
|
||||
val grown = when {
|
||||
to < seasons.size && total(from..to, bar, gap) <= room -> from until to + 1
|
||||
from > 0 && total(from - 1 until to, bar, gap) <= room -> from - 1 until to
|
||||
else -> break
|
||||
}
|
||||
from = grown.first
|
||||
to = grown.last + 1
|
||||
}
|
||||
// A single season longer than the map: its bars get thinner still.
|
||||
val single = if (to - from == 1) (room / seasons[from].count).coerceIn(1f, bar + gap) else bar + gap
|
||||
return if (to - from == 1 && single < bar + gap) MapLayout(single * 0.6f, single * 0.4f, from, to) else MapLayout(bar, gap, from, to)
|
||||
}
|
||||
|
||||
private val SeasonChipFocus = FocusStyle(scale = 1f, corner = 15.dp, glows = FocusStyle.ButtonGlows)
|
||||
|
||||
@Composable
|
||||
private fun SeasonChips(
|
||||
item: SeriesModel,
|
||||
selected: Int,
|
||||
state: LazyListState,
|
||||
focus: ScreenFocus,
|
||||
onFocused: (Int) -> Unit,
|
||||
onClick: (Int) -> Unit,
|
||||
modifier: Modifier,
|
||||
) {
|
||||
MinimalScrolling(margin = 16.dp) {
|
||||
LazyRow(
|
||||
modifier,
|
||||
state = state,
|
||||
contentPadding = PaddingValues(horizontal = AppDimens.CONTENT_START.dp, vertical = 6.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
itemsIndexed(item.seasons, key = { _, s -> s.key }) { i, s ->
|
||||
SeasonChip(
|
||||
s,
|
||||
i == selected,
|
||||
onClick = { onClick(i) },
|
||||
modifier = Modifier
|
||||
.remembered(focus, s.key)
|
||||
.onFocusChanged { if (it.isFocused) onFocused(i) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SeasonChip(season: SeriesSeason, selected: Boolean, onClick: () -> Unit, modifier: Modifier) {
|
||||
var focused by remember { mutableStateOf(false) }
|
||||
val shape = RoundedCornerShape(15.dp)
|
||||
val color = when {
|
||||
focused -> AppColors.OnFocusFill
|
||||
selected -> AppColors.Text
|
||||
else -> AppColors.TextSecondary
|
||||
}
|
||||
Row(
|
||||
modifier
|
||||
.focusCard(SeasonChipFocus)
|
||||
.height(30.dp)
|
||||
.background(if (focused) AppColors.FocusFill else if (selected) Color(0xFF211D4A) else AppColors.SurfaceRaised, shape)
|
||||
.border(0.5.dp, if (focused) AppColors.FocusFill else if (selected) Color(0xFF7A6CFF) else AppColors.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),
|
||||
) {
|
||||
BasicText(season.label, style = AppType.Button.copy(color = color), maxLines = 1)
|
||||
BasicText(season.stat, style = AppType.MonoCaption.copy(color = color.copy(alpha = 0.75f)), maxLines = 1)
|
||||
}
|
||||
}
|
||||
|
||||
private val EpisodeFocus = FocusStyle(scale = 1.06f, corner = 8.dp)
|
||||
|
||||
/** Every episode of the series, a "СЕЗОН N" divider before each season. */
|
||||
@Composable
|
||||
private fun EpisodeRow(
|
||||
item: SeriesModel,
|
||||
state: LazyListState,
|
||||
focus: ScreenFocus,
|
||||
defaultKey: String?,
|
||||
onFocused: (Int) -> Unit,
|
||||
onClick: (SeriesEpisode) -> Unit,
|
||||
modifier: Modifier,
|
||||
) {
|
||||
MinimalScrolling(margin = 18.dp) {
|
||||
LazyRow(
|
||||
modifier,
|
||||
state = state,
|
||||
contentPadding = PaddingValues(start = AppDimens.CONTENT_START.dp, end = AppDimens.CONTENT_START.dp, top = 12.dp, bottom = 12.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
for (season in item.seasons) {
|
||||
item(key = "div/${season.key}", contentType = "divider") { SeasonDivider(season.label.uppercase()) }
|
||||
itemsIndexed(item.episodes.subList(season.first, season.first + season.count), key = { _, e -> e.key }, contentType = { _, _ -> "episode" }) { i, e ->
|
||||
val index = season.first + i
|
||||
EpisodeCard(
|
||||
e,
|
||||
onClick = { onClick(e) },
|
||||
modifier = Modifier
|
||||
.remembered(focus, e.key, isDefault = e.key == defaultKey)
|
||||
.onFocusChanged { if (it.isFocused) onFocused(index) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Vertical "СЕЗОН 2" on a dashed line. */
|
||||
@Composable
|
||||
private fun SeasonDivider(label: String) {
|
||||
Box(
|
||||
Modifier
|
||||
.size(22.dp, SeriesLayout.EPISODE_H.dp)
|
||||
.drawBehind {
|
||||
// Dashes as plain rectangles (a dashed path effect is a path, slow on this GPU).
|
||||
val dash = 3.dp.toPx().roundToInt().toFloat()
|
||||
var y = 0f
|
||||
while (y < size.height) {
|
||||
drawRect(MapColors.Empty, Offset(0f, y), Size(1f, dash.coerceAtMost(size.height - y)))
|
||||
y += dash * 2
|
||||
}
|
||||
},
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
BasicText(
|
||||
label,
|
||||
Modifier.wrapContentSize(unbounded = true).rotate(-90f),
|
||||
style = AppType.MonoCaption.copy(letterSpacing = 0.14.em),
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EpisodeCard(e: SeriesEpisode, onClick: () -> Unit, modifier: Modifier) {
|
||||
Column(Modifier.width(SeriesLayout.EPISODE_W.dp)) {
|
||||
Box(
|
||||
modifier
|
||||
.focusCard(EpisodeFocus)
|
||||
.size(SeriesLayout.EPISODE_W.dp, SeriesLayout.EPISODE_H.dp)
|
||||
.clickable(interactionSource = null, indication = null, onClick = onClick),
|
||||
) {
|
||||
RemoteImage(e.image, 8.dp, Modifier.fillMaxSize())
|
||||
BasicText(
|
||||
e.code,
|
||||
Modifier.padding(start = 8.dp, top = 6.dp),
|
||||
style = AppType.MonoMeta.copy(fontSize = 18.sp, fontWeight = FontWeight.SemiBold, color = Color(0xE0FFFFFF)),
|
||||
)
|
||||
badge(e)?.let { (text, color) ->
|
||||
BasicText(
|
||||
text,
|
||||
Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.padding(7.dp)
|
||||
.background(Color(0xBF0A0B10), RoundedCornerShape(3.5.dp))
|
||||
.border(0.5.dp, color, RoundedCornerShape(3.5.dp))
|
||||
.padding(horizontal = 5.dp, vertical = 2.dp),
|
||||
style = AppType.MonoBadge.copy(fontSize = 8.5.sp, color = color),
|
||||
)
|
||||
}
|
||||
e.progress?.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(fontSize = 12.sp, color = if (e.state == EpisodeState.Watched) AppColors.TextMuted else AppColors.Text),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
e.meta?.let { BasicText(it, Modifier.padding(top = 3.dp), style = AppType.MonoCaption, maxLines = 1) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun badge(e: SeriesEpisode): Pair<String, Color>? = when (e.state) {
|
||||
EpisodeState.Watched -> "✓" to AppColors.Green
|
||||
EpisodeState.InProgress -> "${((e.progress ?: 0f) * 100).roundToInt()}%" to AppColors.Accent
|
||||
EpisodeState.Next -> "далее" to AppColors.Accent
|
||||
EpisodeState.New -> "новая" to AppColors.Pink
|
||||
EpisodeState.None -> null
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SelectedEpisode(item: SeriesModel, selection: SeriesSelection, modifier: Modifier) {
|
||||
item.episodes.getOrNull(selection.episode.coerceIn(0, (item.episodes.size - 1).coerceAtLeast(0)))?.let { EpisodeDetail(it, modifier) }
|
||||
}
|
||||
|
||||
/** The selected episode under the row: "СЕЗОН 2 · СЕРИЯ 4", title, description, duration and air date. */
|
||||
@Composable
|
||||
private fun EpisodeDetail(e: SeriesEpisode, modifier: Modifier) {
|
||||
Row(
|
||||
modifier
|
||||
.padding(horizontal = AppDimens.CONTENT_START.dp)
|
||||
.padding(bottom = 22.dp)
|
||||
.fillMaxWidth()
|
||||
.height(66.dp)
|
||||
.drawBehind { drawRect(AppColors.BorderSubtle, size = Size(size.width, 1f)) }
|
||||
.padding(top = 11.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(14.dp),
|
||||
) {
|
||||
BasicText(e.longCode, Modifier.width(126.dp).padding(top = 3.dp), style = AppType.MonoLabel.copy(letterSpacing = 0.06.em))
|
||||
Column(Modifier.weight(1f)) {
|
||||
BasicText(e.title, style = AppType.Section.copy(fontSize = 14.sp), maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
e.overview?.let {
|
||||
BasicText(
|
||||
it,
|
||||
Modifier.padding(top = 4.dp),
|
||||
style = AppType.Body.copy(fontSize = 11.sp, lineHeight = 15.sp),
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
Column(Modifier.padding(top = 2.dp)) {
|
||||
val style = AppType.MonoMeta.copy(fontSize = 10.sp, color = AppColors.TextDim, textAlign = TextAlign.End)
|
||||
e.duration?.let { BasicText(it, Modifier.align(Alignment.End), style = style) }
|
||||
e.airDate?.let { BasicText(it, Modifier.align(Alignment.End).padding(top = 5.dp), style = style) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
172
app/src/main/kotlin/ru/stenfredd/movies/ui/series/SeriesTray.kt
Normal file
172
app/src/main/kotlin/ru/stenfredd/movies/ui/series/SeriesTray.kt
Normal file
@@ -0,0 +1,172 @@
|
||||
package ru.stenfredd.movies.ui.series
|
||||
|
||||
import android.view.KeyEvent as AndroidKeyEvent
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
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.RowScope
|
||||
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.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.LaunchedEffect
|
||||
import androidx.compose.runtime.remember
|
||||
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.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.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.em
|
||||
import ru.stenfredd.movies.playback.api.SubtitleStyle
|
||||
import ru.stenfredd.movies.ui.components.MinimalScrolling
|
||||
import ru.stenfredd.movies.ui.player.PanelChips
|
||||
import ru.stenfredd.movies.ui.player.PanelList
|
||||
import ru.stenfredd.movies.ui.player.PanelSection
|
||||
import ru.stenfredd.movies.ui.player.SubtitleChoices
|
||||
import ru.stenfredd.movies.ui.player.toChoices
|
||||
import ru.stenfredd.movies.ui.theme.AppColors
|
||||
import ru.stenfredd.movies.ui.theme.AppType
|
||||
|
||||
/**
|
||||
* "Аудио и субтитры" tray at the bottom of the series page: audio, subtitles and their look, applied at once and
|
||||
* remembered for the whole series (the tracks are those of the next episode's file; other episodes get the closest
|
||||
* match). Opaque, without the mockup's full-screen dimming, which is expensive on weak TV GPUs.
|
||||
*
|
||||
* Focus stays inside; ↓ past the bottom or Back closes it.
|
||||
*/
|
||||
@Composable
|
||||
internal fun SeriesTray(
|
||||
title: String,
|
||||
state: SeriesState,
|
||||
style: SubtitleStyle,
|
||||
vm: SeriesViewModel,
|
||||
onClose: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val initial = remember { FocusRequester() }
|
||||
LaunchedEffect(Unit) {
|
||||
withFrameNanos { }
|
||||
runCatching { initial.requestFocus() }
|
||||
}
|
||||
// Back went down inside the tray; it acts on release.
|
||||
val backDown = remember { booleanArrayOf(false) }
|
||||
Column(
|
||||
modifier
|
||||
.fillMaxWidth()
|
||||
.height(TRAY_H.dp)
|
||||
.background(AppColors.Background)
|
||||
.drawBehind { drawRect(Color(0xFF262A3A), size = Size(size.width, 1f)) }
|
||||
// Back acts on release, as Android does: closing on the press would send the release to the page, which
|
||||
// would leave it.
|
||||
.onPreviewKeyEvent {
|
||||
val code = it.key.nativeKeyCode
|
||||
if (code != AndroidKeyEvent.KEYCODE_BACK && code != AndroidKeyEvent.KEYCODE_ESCAPE) return@onPreviewKeyEvent false
|
||||
when (it.type) {
|
||||
KeyEventType.KeyDown -> backDown[0] = true
|
||||
KeyEventType.KeyUp -> if (backDown[0]) onClose()
|
||||
}
|
||||
true
|
||||
}
|
||||
.focusProperties {
|
||||
onExit = {
|
||||
if (requestedFocusDirection == FocusDirection.Down) onClose()
|
||||
cancelFocusChange()
|
||||
}
|
||||
}
|
||||
.focusGroup()
|
||||
.padding(start = 48.dp, end = 48.dp, top = 12.dp, bottom = 22.dp),
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(7.dp)) {
|
||||
Box(Modifier.size(6.dp).background(AppColors.Accent, CircleShape))
|
||||
BasicText(
|
||||
"АУДИО И СУБТИТРЫ · " + title.uppercase(),
|
||||
Modifier.weight(1f),
|
||||
style = AppType.MonoLabel.copy(color = AppColors.TextSecondary, letterSpacing = 0.1.em),
|
||||
maxLines = 1,
|
||||
)
|
||||
BasicText("Запоминается для всего сериала · Назад или ▼ — закрыть", style = AppType.MonoCaption.copy(color = Color(0xFF6F7592)))
|
||||
}
|
||||
Row(Modifier.padding(top = 10.dp).fillMaxWidth().weight(1f), horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
val audioFirst = state.audio.isNotEmpty()
|
||||
if (state.audio.isNotEmpty()) {
|
||||
TrayColumn("АУДИО", AppColors.Pink) {
|
||||
PanelList(state.audio.toChoices(state.selectedAudio), initial) { c ->
|
||||
vm.selectAudio(state.audio.firstOrNull { it.key == c.key }?.index)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (state.subtitles.isNotEmpty()) {
|
||||
TrayColumn("СУБТИТРЫ", AppColors.Yellow) {
|
||||
PanelList(state.subtitles.toChoices(state.selectedSubtitle), if (audioFirst) null else initial) { c ->
|
||||
vm.selectSubtitle(state.subtitles.firstOrNull { it.key == c.key }?.index)
|
||||
}
|
||||
}
|
||||
}
|
||||
TrayColumn("ВИД СУБТИТРОВ", AppColors.Yellow) {
|
||||
PanelSection("РАЗМЕР") { PanelChips(SubtitleChoices.sizes(style)) { vm.setSubtitleStyle(SubtitleChoices.apply(style, it)) } }
|
||||
PanelSection("ЦВЕТ") { PanelChips(SubtitleChoices.colors(style)) { vm.setSubtitleStyle(SubtitleChoices.apply(style, it)) } }
|
||||
PanelSection("ФОН") { PanelChips(SubtitleChoices.backgrounds(style)) { vm.setSubtitleStyle(SubtitleChoices.apply(style, it)) } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private const val TRAY_H = 262f
|
||||
|
||||
@Composable
|
||||
private fun RowScope.TrayColumn(title: String, dot: Color, spacing: Dp = 4.dp, content: @Composable ColumnScope.() -> Unit) {
|
||||
val shape = RoundedCornerShape(9.dp)
|
||||
Column(
|
||||
Modifier
|
||||
.weight(1f)
|
||||
.fillMaxHeight()
|
||||
.background(Color(0xFF10121A), shape)
|
||||
.border(0.5.dp, Color(0xFF262A3A), shape),
|
||||
) {
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(25.dp)
|
||||
.background(Color(0xFF181B27), RoundedCornerShape(topStart = 9.dp, topEnd = 9.dp))
|
||||
.padding(horizontal = 9.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(5.dp),
|
||||
) {
|
||||
Box(Modifier.size(5.dp).background(dot, CircleShape))
|
||||
BasicText(title, style = AppType.MonoCaption.copy(color = AppColors.TextMuted, letterSpacing = 0.1.em))
|
||||
}
|
||||
MinimalScrolling(margin = 10.dp) {
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(spacing),
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,385 @@
|
||||
package ru.stenfredd.movies.ui.series
|
||||
|
||||
import android.os.SystemClock
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
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.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import ru.stenfredd.movies.core.data.ImageUrls
|
||||
import ru.stenfredd.movies.core.data.LibraryRepository
|
||||
import ru.stenfredd.movies.core.data.SessionCache
|
||||
import ru.stenfredd.movies.core.data.WatchRepository
|
||||
import ru.stenfredd.movies.core.data.model.ItemDetails
|
||||
import ru.stenfredd.movies.core.data.model.ItemSummary
|
||||
import ru.stenfredd.movies.core.data.model.MediaInfo
|
||||
import ru.stenfredd.movies.playback.api.SubtitleStyle
|
||||
import ru.stenfredd.movies.playback.core.PlaybackPreferences
|
||||
import ru.stenfredd.movies.ui.Screen
|
||||
import ru.stenfredd.movies.ui.components.RemoteImageSpec
|
||||
import ru.stenfredd.movies.ui.details.MediaLabels
|
||||
import ru.stenfredd.movies.ui.details.TrackChoices
|
||||
import ru.stenfredd.movies.ui.details.TrackOption
|
||||
import ru.stenfredd.movies.ui.model.CardMapper
|
||||
import ru.stenfredd.movies.ui.player.PlayerLabels
|
||||
import ru.stenfredd.movies.ui.player.plural
|
||||
import timber.log.Timber
|
||||
import java.time.Instant
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.util.Locale
|
||||
import java.util.UUID
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/** Where an episode is in watching: the season map colours and the card's corner badge. */
|
||||
enum class EpisodeState { Watched, InProgress, Next, New, None }
|
||||
|
||||
@Immutable
|
||||
data class SeriesEpisode(
|
||||
val key: String,
|
||||
val id: UUID,
|
||||
/** Index of its season in [SeriesModel.seasons]. */
|
||||
val season: Int,
|
||||
/** Position in the episode row, which has a divider before every season. */
|
||||
val rowIndex: Int,
|
||||
/** "S2·E04" */
|
||||
val code: String,
|
||||
/** "СЕЗОН 2 · СЕРИЯ 4" */
|
||||
val longCode: String,
|
||||
val title: String,
|
||||
/** "56 мин · 13.06.2023" */
|
||||
val meta: String?,
|
||||
val duration: String?,
|
||||
val airDate: String?,
|
||||
val overview: String?,
|
||||
val image: RemoteImageSpec?,
|
||||
val progress: Float?,
|
||||
val state: EpisodeState,
|
||||
val resumeMs: Long,
|
||||
)
|
||||
|
||||
@Immutable
|
||||
data class SeriesSeason(
|
||||
val key: String,
|
||||
/** "Сезон 2", or the server's name for specials. */
|
||||
val label: String,
|
||||
/** "✓", "новый", "3/10" */
|
||||
val stat: String,
|
||||
/** "S2 · 3/10" over the season map. */
|
||||
val mapLabel: String,
|
||||
/** Its episodes are [first] until [first] + [count] in [SeriesModel.episodes]. */
|
||||
val first: Int,
|
||||
val count: Int,
|
||||
/** The episode ↓ from the season chip goes to: started, next, first unwatched, else the first. */
|
||||
val target: Int,
|
||||
/** Row index of the season's divider. */
|
||||
val dividerIndex: Int,
|
||||
)
|
||||
|
||||
@Immutable
|
||||
data class SeriesModel(
|
||||
val id: UUID,
|
||||
/** "СЕРИАЛ · 3 СЕЗОНА · 28 СЕРИЙ" */
|
||||
val label: String,
|
||||
val title: String,
|
||||
val rating: String?,
|
||||
val age: String?,
|
||||
val year: String?,
|
||||
val genres: String?,
|
||||
val poster: RemoteImageSpec?,
|
||||
val backdropUrl: String?,
|
||||
val favorite: Boolean,
|
||||
val seasons: List<SeriesSeason>,
|
||||
val episodes: List<SeriesEpisode>,
|
||||
/** Index of the episode "Продолжить"/"Смотреть" plays. */
|
||||
val nextUp: Int,
|
||||
/** "Продолжить S2 E4 · ещё 32 мин" */
|
||||
val playLabel: String,
|
||||
)
|
||||
|
||||
@Immutable
|
||||
data class SeriesState(
|
||||
val loading: Boolean = true,
|
||||
val error: String? = null,
|
||||
val item: SeriesModel? = null,
|
||||
/** Formats of the next episode's file: "4K", "HDR10", "Atmos". */
|
||||
val badges: List<String> = emptyList(),
|
||||
/** Tracks of the next episode's file, for the "Аудио и субтитры" tray. */
|
||||
val audio: List<TrackOption> = emptyList(),
|
||||
val subtitles: List<TrackOption> = emptyList(),
|
||||
val selectedAudio: Int? = null,
|
||||
val selectedSubtitle: Int? = null,
|
||||
/** "Русский 5.1" */
|
||||
val audioLabel: String? = null,
|
||||
/** "Русский", "без субтитров" */
|
||||
val subtitleLabel: String? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* Series page: header, season map and chips, every episode in one row, and the audio/subtitle choice for the series
|
||||
* (remembered per series, see [PlaybackPreferences]; the player picks it up for every episode).
|
||||
*/
|
||||
@HiltViewModel(assistedFactory = SeriesViewModel.Factory::class)
|
||||
class SeriesViewModel @AssistedInject constructor(
|
||||
@Assisted private val seriesId: UUID,
|
||||
private val library: LibraryRepository,
|
||||
private val watch: WatchRepository,
|
||||
private val images: ImageUrls,
|
||||
private val cards: CardMapper,
|
||||
private val prefs: PlaybackPreferences,
|
||||
cache: SessionCache,
|
||||
) : ViewModel() {
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
fun create(seriesId: UUID): SeriesViewModel
|
||||
}
|
||||
|
||||
private val _state = MutableStateFlow(SeriesState())
|
||||
val state: StateFlow<SeriesState> = _state.asStateFlow()
|
||||
|
||||
val subtitleStyle: StateFlow<SubtitleStyle> = prefs.subtitleStyle.stateIn(viewModelScope, SharingStarted.Eagerly, SubtitleStyle())
|
||||
|
||||
/** File of the next episode: its tracks are what the tray offers. */
|
||||
private var media: MediaInfo? = null
|
||||
|
||||
init {
|
||||
viewModelScope.launch { load() }
|
||||
// Back from the player (progress, next episode) or watched/favourite changed elsewhere: reload.
|
||||
viewModelScope.launch { cache.invalidations.collect { load() } }
|
||||
}
|
||||
|
||||
fun player(episode: SeriesEpisode): Screen.Player = Screen.Player(
|
||||
itemId = episode.id,
|
||||
engine = null,
|
||||
// A watched episode starts over; the tracks come from the series' memory.
|
||||
startPositionMs = if (episode.state == EpisodeState.Watched) 0 else episode.resumeMs,
|
||||
)
|
||||
|
||||
fun selectAudio(index: Int?) {
|
||||
_state.update { it.withTracks(media, index, it.selectedSubtitle) }
|
||||
viewModelScope.launch { TrackChoices.rememberAudio(prefs, seriesId, media, index) }
|
||||
}
|
||||
|
||||
fun selectSubtitle(index: Int?) {
|
||||
_state.update { it.withTracks(media, it.selectedAudio, index) }
|
||||
viewModelScope.launch { TrackChoices.rememberSubtitle(prefs, seriesId, media, index) }
|
||||
}
|
||||
|
||||
fun setSubtitleStyle(style: SubtitleStyle) {
|
||||
viewModelScope.launch { prefs.setSubtitleStyle(style) }
|
||||
}
|
||||
|
||||
/** Shows the new value at once and reverts it if the server refuses. */
|
||||
fun toggleFavorite() {
|
||||
val item = _state.value.item ?: return
|
||||
val value = !item.favorite
|
||||
_state.update { s -> s.copy(item = s.item?.copy(favorite = value)) }
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
watch.setFavorite(seriesId, value)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Timber.w(e, "Series: favourite failed")
|
||||
_state.update { s -> s.copy(item = s.item?.copy(favorite = !value)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun load() {
|
||||
try {
|
||||
coroutineScope {
|
||||
val started = SystemClock.elapsedRealtime()
|
||||
val seasons = async { library.seasons(seriesId) }
|
||||
val episodes = async { library.episodes(seriesId) }
|
||||
val details = library.details(seriesId)
|
||||
val model = withContext(Dispatchers.Default) { model(details, seasons.await(), episodes.await()) }
|
||||
_state.update { it.copy(loading = false, error = null, item = model) }
|
||||
Timber.i("Series loaded in %d ms (%d episodes)", SystemClock.elapsedRealtime() - started, model.episodes.size)
|
||||
// The tray and the badges describe the file of the episode "Продолжить" plays.
|
||||
model.episodes.getOrNull(model.nextUp)?.let { next ->
|
||||
val media = runCatching { library.details(next.id).media }.getOrNull()
|
||||
this@SeriesViewModel.media = media
|
||||
val (audio, subtitle) = media?.let { TrackChoices.initial(prefs, seriesId, it) } ?: (null to null)
|
||||
_state.update { s ->
|
||||
s.copy(badges = MediaLabels.badges(media), audio = TrackChoices.audio(media), subtitles = TrackChoices.subtitles(media))
|
||||
.withTracks(media, audio, subtitle)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Timber.w(e, "Series: load failed")
|
||||
// Keep what is shown when a reload fails.
|
||||
_state.update { if (it.item == null) it.copy(loading = false, error = e.message ?: e.toString()) else it }
|
||||
}
|
||||
}
|
||||
|
||||
private fun SeriesState.withTracks(media: MediaInfo?, audio: Int?, subtitle: Int?) = copy(
|
||||
selectedAudio = audio,
|
||||
selectedSubtitle = subtitle,
|
||||
audioLabel = media?.takeIf { it.audio.isNotEmpty() }?.let { PlayerLabels.audioChip(it, audio) },
|
||||
subtitleLabel = media?.takeIf { it.subtitles.isNotEmpty() }?.let { m ->
|
||||
if (subtitle == null) "без субтитров" else PlayerLabels.subtitleChip(m, subtitle)
|
||||
},
|
||||
)
|
||||
|
||||
private fun model(details: ItemDetails, seasonItems: List<ItemSummary>, episodeItems: List<ItemSummary>): SeriesModel {
|
||||
val item = details.summary
|
||||
// Specials (season 0) go after the regular seasons; episodes of seasons the server didn't list are left out.
|
||||
val ordered = seasonItems.sortedBy { if (it.seasonNumber == 0) 1 else 0 }
|
||||
val bySeason = episodeItems.groupBy { it.seasonId }
|
||||
val groups = ordered.mapNotNull { s -> bySeason[s.id]?.takeIf { it.isNotEmpty() }?.let { s to it } }
|
||||
val all = groups.flatMap { it.second }
|
||||
val nextUp = nextUp(all)
|
||||
|
||||
val episodes = ArrayList<SeriesEpisode>(all.size)
|
||||
val seasons = ArrayList<SeriesSeason>(groups.size)
|
||||
var row = 0
|
||||
groups.forEachIndexed { si, (season, list) ->
|
||||
val first = episodes.size
|
||||
val dividerIndex = row++
|
||||
list.forEachIndexed { i, e ->
|
||||
episodes += episode(e, si, row++, i, season, isNextUp = e === all.getOrNull(nextUp))
|
||||
}
|
||||
val own = episodes.subList(first, episodes.size)
|
||||
val watched = own.count { it.state == EpisodeState.Watched }
|
||||
val number = season.seasonNumber
|
||||
seasons += SeriesSeason(
|
||||
key = "season/${season.id}",
|
||||
label = if (number != null && number > 0) "Сезон $number" else season.name,
|
||||
stat = when {
|
||||
watched == own.size -> "✓"
|
||||
watched == 0 && own.all { it.state == EpisodeState.New } -> "новый"
|
||||
else -> "$watched/${own.size}"
|
||||
},
|
||||
mapLabel = "${if (number != null && number > 0) "S$number" else "SP"} · $watched/${own.size}",
|
||||
first = first,
|
||||
count = own.size,
|
||||
target = first + (
|
||||
own.indexOfFirst { it.state == EpisodeState.InProgress || it.state == EpisodeState.Next }.takeIf { it >= 0 }
|
||||
?: own.indexOfFirst { it.state != EpisodeState.Watched }.takeIf { it >= 0 }
|
||||
?: 0
|
||||
),
|
||||
dividerIndex = dividerIndex,
|
||||
)
|
||||
}
|
||||
|
||||
val next = episodes.getOrNull(nextUp)
|
||||
val regularSeasons = groups.count { (it.first.seasonNumber ?: 1) > 0 }
|
||||
return SeriesModel(
|
||||
id = item.id,
|
||||
label = listOf(
|
||||
"СЕРИАЛ",
|
||||
"$regularSeasons ${plural(regularSeasons, "СЕЗОН", "СЕЗОНА", "СЕЗОНОВ")}",
|
||||
"${all.size} ${plural(all.size, "СЕРИЯ", "СЕРИИ", "СЕРИЙ")}",
|
||||
).joinToString(" · "),
|
||||
title = item.name,
|
||||
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(),
|
||||
genres = item.genres.take(3).joinToString(", ").ifEmpty { null },
|
||||
poster = cards.image(item.primary, SeriesLayout.POSTER_W, SeriesLayout.POSTER_H),
|
||||
backdropUrl = item.backdrop?.let { images.url(it, cards.px(SeriesLayout.BACKDROP_W), cards.px(SeriesLayout.BACKDROP_H), quality = 80) },
|
||||
favorite = item.user.favorite,
|
||||
seasons = seasons,
|
||||
episodes = episodes,
|
||||
nextUp = nextUp.coerceAtLeast(0),
|
||||
playLabel = next?.let { playLabel(it, all[nextUp]) } ?: "Смотреть",
|
||||
)
|
||||
}
|
||||
|
||||
private fun episode(e: ItemSummary, season: Int, rowIndex: Int, position: Int, seasonItem: ItemSummary, isNextUp: Boolean): SeriesEpisode {
|
||||
val seasonNumber = e.seasonNumber ?: seasonItem.seasonNumber ?: 0
|
||||
// Some files have no episode number: their place in the season stands in.
|
||||
val number = e.episodeNumber ?: (position + 1)
|
||||
val numberLabel = e.episodeNumberEnd?.takeIf { it > number }?.let { "$number–$it" } ?: "%02d".format(number)
|
||||
val progress = e.user.progress?.takeIf { !e.user.played }
|
||||
val state = when {
|
||||
e.user.played -> EpisodeState.Watched
|
||||
progress != null -> EpisodeState.InProgress
|
||||
isNextUp -> EpisodeState.Next
|
||||
cards.isNew(e) -> EpisodeState.New
|
||||
else -> EpisodeState.None
|
||||
}
|
||||
val duration = e.runtimeMs?.let { "${(it / 60_000.0).roundToInt()} мин" }
|
||||
val air = e.premiereAt?.let { DateFormat.format(Instant.ofEpochMilli(it).atZone(ZoneId.systemDefault())) }
|
||||
return SeriesEpisode(
|
||||
key = "ep/${e.id}",
|
||||
id = e.id,
|
||||
season = season,
|
||||
rowIndex = rowIndex,
|
||||
code = "S$seasonNumber·E$numberLabel",
|
||||
longCode = "СЕЗОН $seasonNumber · СЕРИЯ ${e.episodeNumber ?: number}",
|
||||
title = e.name,
|
||||
meta = listOfNotNull(duration, air).joinToString(" · ").ifEmpty { null },
|
||||
duration = duration,
|
||||
airDate = air,
|
||||
overview = e.overview,
|
||||
image = cards.image(e.primary ?: e.thumb ?: e.backdrop, SeriesLayout.EPISODE_W, SeriesLayout.EPISODE_H),
|
||||
progress = progress,
|
||||
state = state,
|
||||
resumeMs = if (progress != null) e.user.positionMs else 0,
|
||||
)
|
||||
}
|
||||
|
||||
private fun playLabel(next: SeriesEpisode, item: ItemSummary): String {
|
||||
val code = next.code.replace("·", " ").replace(Regex("""E0(\d)"""), "E$1")
|
||||
return when (next.state) {
|
||||
EpisodeState.InProgress -> {
|
||||
val left = item.runtimeMs?.let { (it * (1 - (next.progress ?: 0f)) / 60_000).roundToInt() }
|
||||
"Продолжить $code" + (left?.let { " · ещё $it мин" } ?: "")
|
||||
}
|
||||
else -> "Смотреть $code" + (next.duration?.let { " · $it" } ?: "")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The episode to continue with: the last one played if it isn't finished (also when it was only started for a few
|
||||
* seconds and the server kept no position), else the first unwatched one after it; nothing played yet — the first
|
||||
* unwatched. Specials only count when the series has nothing else.
|
||||
*/
|
||||
private fun nextUp(all: List<ItemSummary>): Int {
|
||||
if (all.isEmpty()) return -1
|
||||
val regular = all.indices.filter { (all[it].seasonNumber ?: 1) > 0 }.ifEmpty { all.indices.toList() }
|
||||
val last = regular.filter { all[it].user.lastPlayedAt != null }.maxByOrNull { all[it].user.lastPlayedAt!! }
|
||||
if (last != null) {
|
||||
val e = all[last]
|
||||
if (!e.user.played) return last
|
||||
regular.firstOrNull { it > last && !all[it].user.played }?.let { return it }
|
||||
}
|
||||
return regular.firstOrNull { !all[it].user.played } ?: regular.first()
|
||||
}
|
||||
|
||||
private companion object {
|
||||
val DateFormat: DateTimeFormatter = DateTimeFormatter.ofPattern("dd.MM.yyyy")
|
||||
}
|
||||
}
|
||||
|
||||
/** Sizes (dp) shared by the layout, the image requests and the baked backdrop. */
|
||||
internal object SeriesLayout {
|
||||
const val PAGE_W = 960f
|
||||
const val PAGE_H = 540f
|
||||
const val POSTER_W = 90f
|
||||
const val POSTER_H = 135f
|
||||
const val EPISODE_W = 200f
|
||||
const val EPISODE_H = 112.5f
|
||||
/** The backdrop covers 70 % of the page width and height at the top right, as on the movie page. */
|
||||
const val BACKDROP_W = 672f
|
||||
const val BACKDROP_H = 378f
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user