Series page

This commit is contained in:
2026-10-14 16:47:20 +03:00
parent 9eafdfd5c2
commit 1032fe3747
19 changed files with 4234 additions and 419 deletions

View File

@@ -37,6 +37,7 @@ import ru.stenfredd.movies.ui.TourScreen
import ru.stenfredd.movies.ui.details.DetailsScreen import ru.stenfredd.movies.ui.details.DetailsScreen
import ru.stenfredd.movies.ui.home.HomeScreen import ru.stenfredd.movies.ui.home.HomeScreen
import ru.stenfredd.movies.ui.player.PlayerScreen import ru.stenfredd.movies.ui.player.PlayerScreen
import ru.stenfredd.movies.ui.series.SeriesScreen
import ru.stenfredd.movies.ui.login.LoginScreen import ru.stenfredd.movies.ui.login.LoginScreen
import ru.stenfredd.movies.ui.shell.Shell import ru.stenfredd.movies.ui.shell.Shell
import ru.stenfredd.movies.ui.shell.Tab import ru.stenfredd.movies.ui.shell.Tab
@@ -80,6 +81,11 @@ class MainActivity : ComponentActivity() {
stack.removeAt(stack.lastIndex) stack.removeAt(stack.lastIndex)
if (stack.lastOrNull() != Screen.Detail(id)) stack.add(Screen.Detail(id)) 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) { fun resetTo(screen: Screen) {
stack.clear() stack.clear()
stack.add(screen) stack.add(screen)
@@ -104,7 +110,13 @@ class MainActivity : ComponentActivity() {
Screen.Prototype -> Legacy { PrototypeScreen(onOpen = ::open, onLogout = { resetTo(Screen.Login) }) } Screen.Prototype -> Legacy { PrototypeScreen(onOpen = ::open, onLogout = { resetTo(Screen.Login) }) }
is Screen.Items -> Legacy { ItemsScreen(screen, onOpen = ::open) } is Screen.Items -> Legacy { ItemsScreen(screen, onOpen = ::open) }
is Screen.Detail -> DetailsScreen(screen, onOpen = ::open) is Screen.Detail -> DetailsScreen(screen, onOpen = ::open)
is Screen.Player -> PlayerScreen(screen, onExit = ::back, 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) is Screen.Tour -> TourScreen(screen.startIndex, onExit = ::back)
} }
} }
@@ -147,15 +159,16 @@ class MainActivity : ComponentActivity() {
/** /**
* Opens an item from the launch intent: * 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>] * `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. * 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>? { private fun intentTarget(): List<Screen>? {
if (BuildConfig.DEBUG && intent.getBooleanExtra("tour", false)) return listOf(Screen.Tour(intent.getIntExtra("tour_file", 1) - 1)) if (BuildConfig.DEBUG && intent.getBooleanExtra("tour", false)) return listOf(Screen.Tour(intent.getIntExtra("tour_file", 1) - 1))
val raw = intent.getStringExtra("item") ?: return null intent.getStringExtra("series")?.let(::parseId)?.let { series ->
val id = runCatching { return listOf(Screen.Series(series, intent.getStringExtra("episode")?.let(::parseId)))
UUID.fromString(if ('-' in raw) raw else raw.replace(Regex("(.{8})(.{4})(.{4})(.{4})(.{12})"), "$1-$2-$3-$4-$5")) }
}.getOrNull() ?: return null val id = intent.getStringExtra("item")?.let(::parseId) ?: return null
val engine = if (BuildConfig.DEBUG) intent.getStringExtra("engine")?.let(::EngineId) else null val engine = if (BuildConfig.DEBUG) intent.getStringExtra("engine")?.let(::EngineId) else null
val play = engine != null || intent.getBooleanExtra("play", false) val play = engine != null || intent.getBooleanExtra("play", false)
val streams = if (intent.hasExtra("audio") || intent.hasExtra("sub")) { val streams = if (intent.hasExtra("audio") || intent.hasExtra("sub")) {
@@ -166,4 +179,9 @@ class MainActivity : ComponentActivity() {
return listOf(Screen.Detail(id)) + return listOf(Screen.Detail(id)) +
if (play) listOf(Screen.Player(id, engine, intent.getLongExtra("start", 0), streams = streams)) else emptyList() 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()
} }

View File

@@ -38,6 +38,7 @@ import coil3.compose.AsyncImage
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.jellyfin.sdk.model.api.BaseItemDto 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.core.jellyfin.JellyfinSession
import ru.stenfredd.movies.ui.components.TextInput import ru.stenfredd.movies.ui.components.TextInput
import ru.stenfredd.movies.ui.components.rememberTextInputState import ru.stenfredd.movies.ui.components.rememberTextInputState
@@ -48,7 +49,11 @@ import javax.inject.Inject
@HiltViewModel @HiltViewModel
class BrowseViewModel @Inject constructor(val library: Library, val session: JellyfinSession) : ViewModel() 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 @Composable
fun PrototypeScreen(onOpen: (Screen) -> Unit, onLogout: () -> Unit, vm: BrowseViewModel = hiltViewModel()) { fun PrototypeScreen(onOpen: (Screen) -> Unit, onLogout: () -> Unit, vm: BrowseViewModel = hiltViewModel()) {

View File

@@ -18,6 +18,9 @@ sealed interface Screen {
data object Prototype : Screen data object Prototype : Screen
data class Items(val parentId: UUID, val title: String) : Screen data class Items(val parentId: UUID, val title: String) : Screen
data class Detail(val itemId: UUID) : 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( data class Player(
val itemId: UUID, val itemId: UUID,
val engine: EngineId?, val engine: EngineId?,

View File

@@ -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. */ /** Backdrop, dimmed and faded at its edges ([BackdropTransformation]), at the top right of the first screen. */
@Composable @Composable
private fun Backdrop(url: String) { internal fun Backdrop(url: String) {
val context = LocalContext.current val context = LocalContext.current
val area = remember { RectF(L.PAGE_W - L.BACKDROP_W, 0f, L.PAGE_W, L.BACKDROP_H) } val area = remember { RectF(L.PAGE_W - L.BACKDROP_W, 0f, L.PAGE_W, L.BACKDROP_H) }
val request = remember(url) { val request = remember(url) {

View File

@@ -27,7 +27,6 @@ import ru.stenfredd.movies.core.data.model.MediaInfo
import ru.stenfredd.movies.core.data.model.PersonKind import ru.stenfredd.movies.core.data.model.PersonKind
import ru.stenfredd.movies.playback.api.StreamSelection import ru.stenfredd.movies.playback.api.StreamSelection
import ru.stenfredd.movies.playback.core.PlaybackPreferences import ru.stenfredd.movies.playback.core.PlaybackPreferences
import ru.stenfredd.movies.playback.core.TrackPick
import ru.stenfredd.movies.ui.Screen import ru.stenfredd.movies.ui.Screen
import ru.stenfredd.movies.ui.components.PosterModel import ru.stenfredd.movies.ui.components.PosterModel
import ru.stenfredd.movies.ui.components.RemoteImageSpec import ru.stenfredd.movies.ui.components.RemoteImageSpec
@@ -122,15 +121,13 @@ class DetailsViewModel @AssistedInject constructor(
fun selectAudio(index: Int?) { fun selectAudio(index: Int?) {
tracksChosen = true tracksChosen = true
_state.update { it.copy(audio = index) } _state.update { it.copy(audio = index) }
val track = media?.audio?.firstOrNull { it.index == index } ?: return viewModelScope.launch { TrackChoices.rememberAudio(prefs, memoryKey, media, index) }
viewModelScope.launch { prefs.rememberAudio(memoryKey, TrackPick(track.language, track.name, track.codec)) }
} }
fun selectSubtitle(index: Int?) { fun selectSubtitle(index: Int?) {
tracksChosen = true tracksChosen = true
_state.update { it.copy(subtitle = index) } _state.update { it.copy(subtitle = index) }
val pick = media?.subtitles?.firstOrNull { it.index == index }?.let { TrackPick(it.language, it.name, it.codec) } viewModelScope.launch { TrackChoices.rememberSubtitle(prefs, memoryKey, media, index) }
viewModelScope.launch { prefs.rememberSubtitle(memoryKey, pick) }
} }
/** The player screen for this item: from the resume point unless [fromStart], with the chosen tracks. */ /** The player screen for this item: from the resume point unless [fromStart], with the chosen tracks. */
@@ -175,23 +172,15 @@ class DetailsViewModel @AssistedInject constructor(
val media = details.media val media = details.media
this@DetailsViewModel.media = media this@DetailsViewModel.media = media
memoryKey = details.summary.seriesId ?: itemId memoryKey = details.summary.seriesId ?: itemId
val remembered = media?.let { remembered(it) } val initial = media?.let { TrackChoices.initial(prefs, memoryKey, it) }
_state.update { s -> _state.update { s ->
val keep = tracksChosen && s.item != null val keep = tracksChosen && s.item != null
s.copy( s.copy(
loading = false, loading = false,
error = null, error = null,
item = model, item = model,
audio = when { audio = if (keep) s.audio else initial?.first,
keep -> s.audio subtitle = if (keep) s.subtitle else initial?.second,
remembered?.first != null -> remembered.first
else -> media?.defaultAudioIndex ?: media?.audio?.firstOrNull()?.index
},
subtitle = when {
keep -> s.subtitle
remembered != null -> remembered.second
else -> media?.defaultSubtitleIndex
},
) )
} }
// Similar items only make sense for movies; an episode would get random other series. // Similar items only make sense for movies; an episode would get random other series.
@@ -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 { private fun ItemDetails.toModel(): DetailsModel {
val item = summary val item = summary
val episode = item.kind == ItemKind.Episode 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, remainingMinutes = if (progress != null && item.runtimeMs != null) (item.runtimeMs!! * (1 - progress) / 60_000).roundToInt() else null,
favorite = item.user.favorite, favorite = item.user.favorite,
played = item.user.played, played = item.user.played,
audio = media?.audio.orEmpty().map { TrackOption("audio/${it.index}", it.index, MediaLabels.audioLabel(it), MediaLabels.audioDetail(it)) }, audio = TrackChoices.audio(media),
subtitles = if (media == null || media.subtitles.isEmpty()) { subtitles = TrackChoices.subtitles(media),
emptyList()
} else {
listOf(TrackOption("sub/off", null, "Выкл", null)) +
media.subtitles.map { TrackOption("sub/${it.index}", it.index, MediaLabels.subtitleLabel(it), MediaLabels.subtitleDetail(it)) }
},
castTitle = listOfNotNull("В ролях", directors.takeIf { it.isNotEmpty() }?.let { "режиссёр " + it.joinToString(", ") }).joinToString(" · "), castTitle = listOfNotNull("В ролях", directors.takeIf { it.isNotEmpty() }?.let { "режиссёр " + it.joinToString(", ") }).joinToString(" · "),
cast = actors.map { p -> cast = actors.map { p ->
CastModel( CastModel(

View File

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

View File

@@ -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) { 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) else -> Screen.Detail(item.id)
} }

View File

@@ -85,6 +85,7 @@ internal fun PlayerDrawer(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val episodes = season?.takeIf { info?.isEpisode == true } val episodes = season?.takeIf { info?.isEpisode == true }
val page = remember { FocusRequester() }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
withFrameNanos { } withFrameNanos { }
runCatching { initial.requestFocus() } runCatching { initial.requestFocus() }
@@ -118,9 +119,10 @@ internal fun PlayerDrawer(
) )
BasicText("▲ — свернуть", Modifier.padding(end = 6.dp), style = AppType.MonoCaption.copy(color = AppColors.TextFaint)) BasicText("▲ — свернуть", Modifier.padding(end = 6.dp), style = AppType.MonoCaption.copy(color = AppColors.TextFaint))
PagePill( PagePill(
if (episodes != null) "Страница серии" else "Страница фильма", if (episodes != null) "К сериалу" else "Страница фильма",
onClick = onPage, onClick = onPage,
modifier = Modifier modifier = Modifier
.focusRequester(page)
.then(if (episodes == null) Modifier.focusRequester(initial) else Modifier) .then(if (episodes == null) Modifier.focusRequester(initial) else Modifier)
.onPreviewKeyEvent { .onPreviewKeyEvent {
if (it.type == KeyEventType.KeyDown && it.key.nativeKeyCode == AndroidKeyEvent.KEYCODE_DPAD_UP) { if (it.type == KeyEventType.KeyDown && it.key.nativeKeyCode == AndroidKeyEvent.KEYCODE_DPAD_UP) {
@@ -133,7 +135,17 @@ internal fun PlayerDrawer(
) )
} }
when { 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) info != null -> AboutMovie(info)
} }
} }

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

View File

@@ -302,5 +302,28 @@ internal fun PanelCloseButton(onClick: () -> Unit, modifier: Modifier = Modifier
SecondaryButton("Закрыть", onClick = onClick, 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> = internal fun List<TrackOption>.toChoices(selected: Int?): List<PanelChoice> =
map { PanelChoice(it.key, it.label, it.detail, it.index == selected) } map { PanelChoice(it.key, it.label, it.detail, it.index == selected) }

View File

@@ -81,6 +81,7 @@ fun PlayerScreen(
screen: Screen.Player, screen: Screen.Player,
onExit: () -> Unit, onExit: () -> Unit,
onOpenDetails: (UUID) -> Unit = {}, onOpenDetails: (UUID) -> Unit = {},
onOpenSeries: (seriesId: UUID, episodeId: UUID) -> Unit = { _, _ -> },
tour: TourInfo? = null, tour: TourInfo? = null,
vm: PlayerViewModel = hiltViewModel(), vm: PlayerViewModel = hiltViewModel(),
) { ) {
@@ -89,7 +90,7 @@ fun PlayerScreen(
val session by vm.session.collectAsStateWithLifecycle() val session by vm.session.collectAsStateWithLifecycle()
Box(Modifier.fillMaxSize().background(Color.Black)) { Box(Modifier.fillMaxSize().background(Color.Black)) {
VideoSurface(session) 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 var tourStep = 0
/** Back went down while the player had focus; it acts on release. */ /** Back went down while the player had focus; it acts on release. */
var backPressed = false 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 root = FocusRequester()
val playPause = FocusRequester() val playPause = FocusRequester()
@@ -130,6 +135,7 @@ private class PlayerUiState {
val panelInitial = FocusRequester() val panelInitial = FocusRequester()
val drawerInitial = FocusRequester() val drawerInitial = FocusRequester()
val errorButton = FocusRequester() val errorButton = FocusRequester()
val nextPlay = FocusRequester()
val debugMenuFocus = FocusRequester() val debugMenuFocus = FocusRequester()
val chips = PlayerPanel.entries.associateWith { FocusRequester() } val chips = PlayerPanel.entries.associateWith { FocusRequester() }
var panelFrom: FocusRequester? = null var panelFrom: FocusRequester? = null
@@ -151,7 +157,14 @@ private class PlayerUiState {
} }
@Composable @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 state by session.state.collectAsStateWithLifecycle()
val info by vm.info.collectAsStateWithLifecycle() val info by vm.info.collectAsStateWithLifecycle()
val season by vm.season.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 playing = state.engineState.playWhenReady
val ended = state.engineState.phase == EngineState.Phase.ENDED val ended = state.engineState.phase == EngineState.Phase.ENDED
val shownPosition = ui.scrubTarget ?: state.positionMs 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() { fun hideOsd() {
runCatching { ui.root.requestFocus() } runCatching { ui.root.requestFocus() }
ui.osd = false ui.osd = false
ui.drawer = false ui.drawer = false
if (next != null) ui.focus(ui.nextPlay)
} }
fun showOsd(focus: FocusRequester) { fun showOsd(focus: FocusRequester) {
@@ -216,6 +236,10 @@ private fun PlayerUi(session: PlaybackSession, vm: PlayerViewModel, onExit: () -
ui.panel != null -> closePanel() ui.panel != null -> closePanel()
ui.drawer -> closeDrawer() ui.drawer -> closeDrawer()
ui.osd && state.error == null -> hideOsd() ui.osd && state.error == null -> hideOsd()
next != null -> {
ui.nextDismissedFor = state.itemId
ui.focus(ui.root)
}
else -> onExit() 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_REWIND -> scrub(-1, repeat).also { if (!ui.osd) showOsd(ui.timeline) }
AndroidKeyEvent.KEYCODE_MEDIA_NEXT -> season?.next?.let(vm::playEpisode) AndroidKeyEvent.KEYCODE_MEDIA_NEXT -> season?.next?.let(vm::playEpisode)
AndroidKeyEvent.KEYCODE_MEDIA_PREVIOUS -> season?.previous?.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 return true
} }
@@ -286,9 +310,19 @@ private fun PlayerUi(session: PlaybackSession, vm: PlayerViewModel, onExit: () -
} }
LaunchedEffect(ended) { LaunchedEffect(ended) {
if (!ended) return@LaunchedEffect 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() 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() } LifecycleEventEffect(Lifecycle.Event.ON_STOP) { session.pause() }
// Back while nothing in the player has focus (the key handler above doesn't see it then). // 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, playedFraction = if (state.durationMs > 0) shownPosition.toFloat() / state.durationMs else 0f,
initial = ui.drawerInitial, initial = ui.drawerInitial,
onClose = ::closeDrawer, 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 -> onEpisode = { id ->
if (id == state.itemId) { if (id == state.itemId) {
closeDrawer() closeDrawer()
@@ -337,6 +379,25 @@ private fun PlayerUi(session: PlaybackSession, vm: PlayerViewModel, onExit: () -
modifier = Modifier.align(Alignment.BottomCenter), 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)) if (state.loading && state.error == null) LoadingBadge(Modifier.align(Alignment.Center))
state.error?.let { error -> state.error?.let { error ->
ErrorCard(error.lineSequence().first(), Modifier.align(Alignment.Center)) { ErrorCard(error.lineSequence().first(), Modifier.align(Alignment.Center)) {
@@ -373,6 +434,7 @@ private fun onNavigationKey(
scrub: (Int, Int) -> Unit, scrub: (Int, Int) -> Unit,
showOsd: (FocusRequester) -> Unit, showOsd: (FocusRequester) -> Unit,
openDrawer: () -> Unit, openDrawer: () -> Unit,
promptShown: Boolean,
): Boolean { ): Boolean {
if (BuildConfig.DEBUG && !ui.debugMenu) { if (BuildConfig.DEBUG && !ui.debugMenu) {
when (code) { when (code) {
@@ -388,6 +450,8 @@ private fun onNavigationKey(
} }
} }
if (ui.panel != null || ui.drawer || ui.debugMenu) return false 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) { if (!ui.osd) {
when (code) { when (code) {
AndroidKeyEvent.KEYCODE_DPAD_UP if tour != null -> { AndroidKeyEvent.KEYCODE_DPAD_UP if tour != null -> {
@@ -563,21 +627,9 @@ private fun PanelLayer(
session.selectSubtitle(tracks.subtitles.firstOrNull { it.key == c.key }?.index) session.selectSubtitle(tracks.subtitles.firstOrNull { it.key == c.key }?.index)
} }
} }
PanelSection("РАЗМЕР") { PanelSection("РАЗМЕР") { PanelChips(SubtitleChoices.sizes(style)) { vm.setSubtitleStyle(SubtitleChoices.apply(style, it)) } }
PanelChips(SubtitleStyle.Size.entries.map { PanelChoice("size/$it", it.name, null, it == style.size) }) { c -> PanelSection("ЦВЕТ") { PanelChips(SubtitleChoices.colors(style)) { vm.setSubtitleStyle(SubtitleChoices.apply(style, it)) } }
vm.setSubtitleStyle(style.copy(size = SubtitleStyle.Size.valueOf(c.key.substringAfter('/')))) PanelSection("ФОН") { PanelChips(SubtitleChoices.backgrounds(style)) { vm.setSubtitleStyle(SubtitleChoices.apply(style, it)) } }
}
}
PanelSection("ЦВЕТ") {
PanelChips(SUBTITLE_COLORS.map { (argb, name) -> PanelChoice("color/$argb", name, null, argb == style.color, Color(argb)) }) { c ->
vm.setSubtitleStyle(style.copy(color = c.key.substringAfter('/').toInt()))
}
}
PanelSection("ФОН") {
PanelChips(SUBTITLE_BACKGROUNDS.map { (bg, name) -> PanelChoice("bg/$bg", name, null, bg == style.background) }) { c ->
vm.setSubtitleStyle(style.copy(background = SubtitleStyle.Background.valueOf(c.key.substringAfter('/'))))
}
}
val codec = selected?.codec?.lowercase() val codec = selected?.codec?.lowercase()
when { when {
codec in GRAPHIC_SUBTITLES -> PanelNote("Это графические субтитры (картинки): они показываются как есть, размер и цвет к ним не применяются.") 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 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 OSD_TIMEOUT_MS = 6_000L
private const val TOP_SHADE = 105 private const val TOP_SHADE = 105
private const val BOTTOM_SHADE = 170 private const val BOTTOM_SHADE = 170

View File

@@ -56,11 +56,21 @@ data class EpisodeModel(
val isNew: Boolean, 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. */ /** What the controls and the drawer show about the item being played; display strings only. */
@Immutable @Immutable
data class PlayerInfo( data class PlayerInfo(
val id: UUID, val id: UUID,
val isEpisode: Boolean, val isEpisode: Boolean,
val seriesId: UUID?,
/** "ФИЛЬМ", "СЕРИАЛ" */ /** "ФИЛЬМ", "СЕРИАЛ" */
val label: String, val label: String,
val title: String, val title: String,
@@ -90,6 +100,7 @@ data class SeasonEpisodes(
val episodes: List<EpisodeModel>, val episodes: List<EpisodeModel>,
val previous: UUID?, val previous: UUID?,
val next: UUID?, val next: UUID?,
val nextEpisode: NextEpisode?,
) )
@HiltViewModel @HiltViewModel
@@ -211,6 +222,13 @@ class PlayerViewModel @javax.inject.Inject constructor(
}, },
previous = all.getOrNull(index - 1)?.id?.takeIf { index > 0 }, previous = all.getOrNull(index - 1)?.id?.takeIf { index > 0 },
next = 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( return PlayerInfo(
id = item.id, id = item.id,
isEpisode = episode, isEpisode = episode,
seriesId = item.seriesId.takeIf { episode },
label = if (episode) "СЕРИАЛ" else "ФИЛЬМ", label = if (episode) "СЕРИАЛ" else "ФИЛЬМ",
title = if (episode) item.seriesName ?: item.name else item.name, title = if (episode) item.seriesName ?: item.name else item.name,
subtitle = if (episode) { subtitle = if (episode) {
@@ -290,4 +309,6 @@ internal object PlayerLayout {
const val POSTER_W = 100f const val POSTER_W = 100f
const val POSTER_H = 150f const val POSTER_H = 150f
const val AVATAR = 24f const val AVATAR = 24f
const val NEXT_W = 104f
const val NEXT_H = 58.5f
} }

View File

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

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

View File

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

View File

@@ -2,6 +2,7 @@ package ru.stenfredd.movies.baselineprofile
import androidx.benchmark.macro.junit4.BaselineProfileRule import androidx.benchmark.macro.junit4.BaselineProfileRule
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.uiautomator.By import androidx.test.uiautomator.By
import androidx.test.uiautomator.Until import androidx.test.uiautomator.Until
import org.junit.Rule import org.junit.Rule
@@ -13,9 +14,11 @@ import org.junit.runner.RunWith
* interpreted and JIT-compiled while the user scrolls (first passes over a screen used to drop frames). * interpreted and JIT-compiled while the user scrolls (first passes over a screen used to drop frames).
* *
* Path: cold start → home (snapshot, then fresh data) → along "Continue watching" → down through the rows, along * Path: cold start → home (snapshot, then fresh data) → along "Continue watching" → down through the rows, along
* each → open a card, down through its page → back. The device must be logged in. * each → open a card, down through its page → back. With the instrumentation argument `seriesId` also a series page:
* along the buttons, the season chips, the episode row, the audio/subtitle tray. The device must be logged in.
* *
* Run: `./gradlew :app:generateReleaseBaselineProfile` with the TV connected over adb. The result is written to * Run: `./gradlew :app:generateReleaseBaselineProfile [-Pandroid.testInstrumentationRunnerArguments.seriesId=<id>]`
* with the TV connected over adb (`gen_profile` in tools/tv/env.sh). The result is written to
* `app/src/release/generated/baselineProfiles/` and goes into the release APK. * `app/src/release/generated/baselineProfiles/` and goes into the release APK.
*/ */
@RunWith(AndroidJUnit4::class) @RunWith(AndroidJUnit4::class)
@@ -52,6 +55,28 @@ class BaselineProfileGenerator {
repeat(4) { press { device.pressDPadUp() } } repeat(4) { press { device.pressDPadUp() } }
press { device.pressBack() } press { device.pressBack() }
Thread.sleep(1_000) Thread.sleep(1_000)
val seriesId = InstrumentationRegistry.getArguments().getString("seriesId") ?: return@collect
startActivityAndWait { it.putExtra("series", seriesId) }
Thread.sleep(2_000)
repeat(2) { press { device.pressDPadRight() } }
repeat(2) { press { device.pressDPadLeft() } }
press { device.pressDPadDown() }
repeat(2) { press { device.pressDPadRight() } }
repeat(2) { press { device.pressDPadLeft() } }
press { device.pressDPadDown() }
repeat(8) { press { device.pressDPadRight() } }
repeat(8) { press { device.pressDPadLeft() } }
press { device.pressDPadUp() }
press { device.pressDPadUp() }
// The tray: audio, subtitles, their look; no choice is made (it would change what the account remembers).
press { device.pressDPadRight() }
press { device.pressDPadCenter() }
repeat(2) { press { device.pressDPadRight() } }
repeat(2) { press { device.pressDPadDown() } }
press { device.pressBack() }
press { device.pressBack() }
Thread.sleep(1_000)
} }
private inline fun press(action: () -> Unit) { private inline fun press(action: () -> Unit) {

View File

@@ -49,8 +49,10 @@ install_release() {
} }
# Снять baseline profile заново на ТВ (~8 мин, ТВ должен быть с выполненным входом) и положить в app/src/release. # Снять baseline profile заново на ТВ (~8 мин, ТВ должен быть с выполненным входом) и положить в app/src/release.
# Gradle может закончить с «failing tests» при успешном прогоне — тогда профиль копируется вручную. # Gradle может закончить с «failing tests» при успешном прогоне — тогда профиль копируется вручную.
# PROFILE_SERIES — id сериала для прохода по странице сериала.
gen_profile() { gen_profile() {
ANDROID_SERIAL=$TV_ADB ./gradlew :app:generateReleaseBaselineProfile -Pandroid.injected.androidTest.leaveApksInstalledAfterRun=true ANDROID_SERIAL=$TV_ADB ./gradlew :app:generateReleaseBaselineProfile -Pandroid.injected.androidTest.leaveApksInstalledAfterRun=true \
-Pandroid.testInstrumentationRunnerArguments.seriesId=${PROFILE_SERIES:-00000000000000000000000000000000}
local src="baselineprofile/build/outputs/connected_android_test_additional_output/nonMinifiedRelease/connected" local src="baselineprofile/build/outputs/connected_android_test_additional_output/nonMinifiedRelease/connected"
src=$(find "$src" -name 'BaselineProfileGenerator_generate-startup-prof.txt' | head -1) src=$(find "$src" -name 'BaselineProfileGenerator_generate-startup-prof.txt' | head -1)
[ -n "$src" ] || { echo "профиль не найден"; return 1; } [ -n "$src" ] || { echo "профиль не найден"; return 1; }