Series page
This commit is contained in:
@@ -37,6 +37,7 @@ import ru.stenfredd.movies.ui.TourScreen
|
|||||||
import ru.stenfredd.movies.ui.details.DetailsScreen
|
import ru.stenfredd.movies.ui.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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()) {
|
||||||
|
|||||||
@@ -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?,
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package ru.stenfredd.movies.ui.details
|
||||||
|
|
||||||
|
import ru.stenfredd.movies.core.data.model.MediaInfo
|
||||||
|
import ru.stenfredd.movies.playback.core.PlaybackPreferences
|
||||||
|
import ru.stenfredd.movies.playback.core.TrackPick
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
/** Audio and subtitle choices of a file as the movie page and the series tray show them. */
|
||||||
|
internal object TrackChoices {
|
||||||
|
fun audio(media: MediaInfo?): List<TrackOption> =
|
||||||
|
media?.audio.orEmpty().map { TrackOption("audio/${it.index}", it.index, MediaLabels.audioLabel(it), MediaLabels.audioDetail(it)) }
|
||||||
|
|
||||||
|
/** "Выкл" first, then the tracks; empty when the file has no subtitles. */
|
||||||
|
fun subtitles(media: MediaInfo?): List<TrackOption> =
|
||||||
|
if (media == null || media.subtitles.isEmpty()) {
|
||||||
|
emptyList()
|
||||||
|
} else {
|
||||||
|
listOf(TrackOption("sub/off", null, "Выкл", null)) +
|
||||||
|
media.subtitles.map { TrackOption("sub/${it.index}", it.index, MediaLabels.subtitleLabel(it), MediaLabels.subtitleDetail(it)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The tracks picked last time for this movie or series ([key]), found in [media]: (audio, subtitle), or null if
|
||||||
|
* nothing is remembered. Unmatched audio stays null (the file's default is used); a remembered subtitle that this
|
||||||
|
* file doesn't have falls back to the default too.
|
||||||
|
*/
|
||||||
|
suspend fun remembered(prefs: PlaybackPreferences, key: UUID, media: MediaInfo): Pair<Int?, Int?>? {
|
||||||
|
val memory = prefs.tracks(key) ?: return null
|
||||||
|
val audio = memory.audio?.bestOf(media.audio) { TrackPick(it.language, it.name, it.codec) }?.index
|
||||||
|
val subtitle = when {
|
||||||
|
memory.subtitleOff -> null
|
||||||
|
else -> memory.subtitle?.bestOf(media.subtitles) { TrackPick(it.language, it.name, it.codec) }?.index ?: media.defaultSubtitleIndex
|
||||||
|
}
|
||||||
|
return audio to subtitle
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Remembered tracks, else the file's defaults: (audio, subtitle). */
|
||||||
|
suspend fun initial(prefs: PlaybackPreferences, key: UUID, media: MediaInfo): Pair<Int?, Int?> {
|
||||||
|
val remembered = remembered(prefs, key, media)
|
||||||
|
return Pair(
|
||||||
|
remembered?.first ?: media.defaultAudioIndex ?: media.audio.firstOrNull()?.index,
|
||||||
|
if (remembered != null) remembered.second else media.defaultSubtitleIndex,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun rememberAudio(prefs: PlaybackPreferences, key: UUID, media: MediaInfo?, index: Int?) {
|
||||||
|
val track = media?.audio?.firstOrNull { it.index == index } ?: return
|
||||||
|
prefs.rememberAudio(key, TrackPick(track.language, track.name, track.codec))
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun rememberSubtitle(prefs: PlaybackPreferences, key: UUID, media: MediaInfo?, index: Int?) {
|
||||||
|
val pick = media?.subtitles?.firstOrNull { it.index == index }?.let { TrackPick(it.language, it.name, it.codec) }
|
||||||
|
prefs.rememberSubtitle(key, pick)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -74,9 +74,15 @@ class CardMapper @Inject constructor(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Where a card leads. Series and folders still open the old prototype list until their screens exist. */
|
/**
|
||||||
|
* Where a card leads: a movie to its page, a series, season or episode to the series page (on that episode).
|
||||||
|
* Collections and folders still open the old prototype list until the library screen exists.
|
||||||
|
*/
|
||||||
fun target(item: ItemSummary): Screen = when (item.kind) {
|
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
136
app/src/main/kotlin/ru/stenfredd/movies/ui/player/PlayerNext.kt
Normal file
136
app/src/main/kotlin/ru/stenfredd/movies/ui/player/PlayerNext.kt
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
package ru.stenfredd.movies.ui.player
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.text.BasicText
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.drawBehind
|
||||||
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
|
import androidx.compose.ui.focus.focusRequester
|
||||||
|
import androidx.compose.ui.focus.onFocusChanged
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.geometry.Size
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.em
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import ru.stenfredd.movies.ui.components.AppIcon
|
||||||
|
import ru.stenfredd.movies.ui.components.FocusStyle
|
||||||
|
import ru.stenfredd.movies.ui.components.Icon
|
||||||
|
import ru.stenfredd.movies.ui.components.RemoteImage
|
||||||
|
import ru.stenfredd.movies.ui.components.focusCard
|
||||||
|
import ru.stenfredd.movies.ui.theme.AppColors
|
||||||
|
import ru.stenfredd.movies.ui.theme.AppType
|
||||||
|
|
||||||
|
/** The card shows this long before the end of an episode that has a next one. */
|
||||||
|
internal const val NEXT_PROMPT_MS = 30_000L
|
||||||
|
|
||||||
|
private val CardColor = Color(0xFF0E1018)
|
||||||
|
private val CardBorder = Color(0xFF6F63FF)
|
||||||
|
private val PromptFocus = FocusStyle(scale = 1f, corner = 17.dp, glows = FocusStyle.ButtonGlows, animate = false)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Следующая через N с" at the end of an episode: still and title of the next one, "Смотреть сейчас" and
|
||||||
|
* "Досмотреть титры". Opaque (over video a translucent card costs a blend every frame); the countdown and the bar
|
||||||
|
* move once a second, as the position does.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun NextEpisodeCard(
|
||||||
|
next: NextEpisode,
|
||||||
|
remainingMs: Long,
|
||||||
|
playNow: FocusRequester,
|
||||||
|
onPlay: () -> Unit,
|
||||||
|
onStay: () -> Unit,
|
||||||
|
onFocus: (Boolean) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val shape = RoundedCornerShape(10.dp)
|
||||||
|
val seconds = ((remainingMs + 999) / 1000).coerceAtLeast(0)
|
||||||
|
val fraction = (1f - remainingMs.toFloat() / NEXT_PROMPT_MS).coerceIn(0f, 1f)
|
||||||
|
Column(
|
||||||
|
modifier
|
||||||
|
.width(320.dp)
|
||||||
|
.background(CardColor, shape)
|
||||||
|
.border(0.5.dp, CardBorder, shape)
|
||||||
|
.onFocusChanged { onFocus(it.hasFocus) },
|
||||||
|
) {
|
||||||
|
Row(Modifier.padding(11.dp), horizontalArrangement = Arrangement.spacedBy(11.dp)) {
|
||||||
|
RemoteImage(next.image, 6.dp, Modifier.size(PlayerLayout.NEXT_W.dp, PlayerLayout.NEXT_H.dp))
|
||||||
|
Column(Modifier.weight(1f)) {
|
||||||
|
BasicText(
|
||||||
|
"СЛЕДУЮЩАЯ ЧЕРЕЗ $seconds С",
|
||||||
|
style = AppType.MonoLabel.copy(fontSize = 9.sp, letterSpacing = 0.08.em),
|
||||||
|
)
|
||||||
|
BasicText(
|
||||||
|
next.title,
|
||||||
|
Modifier.padding(top = 5.dp),
|
||||||
|
style = AppType.Section.copy(fontSize = 13.sp, lineHeight = 16.sp),
|
||||||
|
maxLines = 2,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Row(Modifier.padding(start = 11.dp, end = 11.dp, bottom = 11.dp), horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
|
PromptButton("Смотреть сейчас", primary = true, onClick = onPlay, modifier = Modifier.weight(1f).focusRequester(playNow))
|
||||||
|
PromptButton("Досмотреть титры", primary = false, onClick = onStay)
|
||||||
|
}
|
||||||
|
// Countdown bar along the bottom edge, inside the rounded corners.
|
||||||
|
Spacer(
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(2.5.dp)
|
||||||
|
.padding(horizontal = 10.dp)
|
||||||
|
.drawBehind {
|
||||||
|
drawRect(AppColors.ProgressTrack)
|
||||||
|
drawRect(AppColors.ProgressGradient, Offset.Zero, Size(size.width * fraction, size.height))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun PromptButton(label: String, primary: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier) {
|
||||||
|
var focused by remember { mutableStateOf(false) }
|
||||||
|
val shape = RoundedCornerShape(17.dp)
|
||||||
|
Row(
|
||||||
|
modifier
|
||||||
|
.focusCard(PromptFocus)
|
||||||
|
.height(34.dp)
|
||||||
|
.then(
|
||||||
|
when {
|
||||||
|
primary -> Modifier.background(AppColors.PrimaryGradient, shape)
|
||||||
|
focused -> Modifier.background(AppColors.FocusFill, shape)
|
||||||
|
else -> Modifier.background(OsdColors.Fill, shape).border(0.5.dp, OsdColors.Border, shape)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.then(if (primary && focused) Modifier.border(1.5.dp, Color(0xFFD9D2FF), shape) else Modifier)
|
||||||
|
.onFocusChanged { focused = it.isFocused }
|
||||||
|
.clickable(interactionSource = null, indication = null, onClick = onClick)
|
||||||
|
.padding(horizontal = 14.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(6.dp, Alignment.CenterHorizontally),
|
||||||
|
) {
|
||||||
|
val color = if (!primary && focused) AppColors.OnFocusFill else Color.White
|
||||||
|
if (primary) Icon(AppIcon.Play, color, Modifier.size(11.dp))
|
||||||
|
BasicText(label, style = AppType.Button.copy(fontSize = 12.sp, fontWeight = if (primary) FontWeight.SemiBold else FontWeight.Normal, color = color))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -302,5 +302,28 @@ internal fun PanelCloseButton(onClick: () -> Unit, modifier: Modifier = Modifier
|
|||||||
SecondaryButton("Закрыть", onClick = onClick, modifier = modifier)
|
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) }
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,679 @@
|
|||||||
|
package ru.stenfredd.movies.ui.series
|
||||||
|
|
||||||
|
import android.view.KeyEvent as AndroidKeyEvent
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.offset
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.layout.wrapContentSize
|
||||||
|
import androidx.compose.foundation.lazy.LazyListState
|
||||||
|
import androidx.compose.foundation.lazy.LazyRow
|
||||||
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.text.BasicText
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.Stable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.saveable.listSaver
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.runtime.withFrameNanos
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.drawBehind
|
||||||
|
import androidx.compose.ui.draw.drawWithCache
|
||||||
|
import androidx.compose.ui.draw.rotate
|
||||||
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
|
import androidx.compose.ui.focus.focusRequester
|
||||||
|
import androidx.compose.ui.focus.onFocusChanged
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.geometry.Size
|
||||||
|
import androidx.compose.ui.graphics.Canvas
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.ImageBitmap
|
||||||
|
import androidx.compose.ui.graphics.drawscope.CanvasDrawScope
|
||||||
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
|
import androidx.compose.ui.graphics.drawscope.DrawScope
|
||||||
|
import androidx.compose.ui.input.key.KeyEvent
|
||||||
|
import androidx.compose.ui.input.key.KeyEventType
|
||||||
|
import androidx.compose.ui.input.key.key
|
||||||
|
import androidx.compose.ui.input.key.nativeKeyCode
|
||||||
|
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
||||||
|
import androidx.compose.ui.input.key.type
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.em
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import ru.stenfredd.movies.ui.Screen
|
||||||
|
import ru.stenfredd.movies.ui.components.AppIcon
|
||||||
|
import ru.stenfredd.movies.ui.components.CircleButton
|
||||||
|
import ru.stenfredd.movies.ui.components.FocusStyle
|
||||||
|
import ru.stenfredd.movies.ui.components.FormatBadge
|
||||||
|
import ru.stenfredd.movies.ui.components.MinimalScrolling
|
||||||
|
import ru.stenfredd.movies.ui.components.PrimaryButton
|
||||||
|
import ru.stenfredd.movies.ui.components.ProgressLine
|
||||||
|
import ru.stenfredd.movies.ui.components.RemoteImage
|
||||||
|
import ru.stenfredd.movies.ui.components.focusCard
|
||||||
|
import ru.stenfredd.movies.ui.details.Backdrop
|
||||||
|
import ru.stenfredd.movies.ui.focus.Restore
|
||||||
|
import ru.stenfredd.movies.ui.focus.ScreenFocus
|
||||||
|
import ru.stenfredd.movies.ui.focus.rememberScreenFocus
|
||||||
|
import ru.stenfredd.movies.ui.focus.remembered
|
||||||
|
import ru.stenfredd.movies.ui.theme.AppColors
|
||||||
|
import ru.stenfredd.movies.ui.theme.AppDimens
|
||||||
|
import ru.stenfredd.movies.ui.theme.AppType
|
||||||
|
import kotlin.math.floor
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Series page, the mockup's "Сериал" screen. Full screen, without the side rail, nothing scrolls vertically:
|
||||||
|
* header with "Продолжить S2 E4", the audio/subtitle choice and ♥; the season map; season chips; every episode of
|
||||||
|
* every season in one row (a divider before each season); the focused episode's description at the bottom.
|
||||||
|
*
|
||||||
|
* Remote: ↓ from the buttons → the chip of the current season → its next episode; ↑ from an episode → its season's
|
||||||
|
* chip. Moving along the chips scrolls the row to that season; moving along the row lights up its season.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun SeriesScreen(
|
||||||
|
screen: Screen.Series,
|
||||||
|
onOpen: (Screen) -> Unit,
|
||||||
|
vm: SeriesViewModel = hiltViewModel<SeriesViewModel, SeriesViewModel.Factory>(creationCallback = { it.create(screen.seriesId) }),
|
||||||
|
) {
|
||||||
|
val state by vm.state.collectAsStateWithLifecycle()
|
||||||
|
val focus = rememberScreenFocus()
|
||||||
|
val item = state.item
|
||||||
|
when {
|
||||||
|
item != null -> SeriesPage(screen, item, state, focus, vm, onOpen)
|
||||||
|
state.error != null -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
|
BasicText("Не удалось загрузить: ${state.error}", style = AppType.Body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
focus.Restore(ready = item != null)
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val KEY_PLAY = "play"
|
||||||
|
private const val KEY_TRACKS = "tracks"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the page highlights: the season of the chips and the map, the episode described at the bottom.
|
||||||
|
* [episode] is read only by the parts that show it, so moving along the row doesn't recompose the page.
|
||||||
|
*/
|
||||||
|
@Stable
|
||||||
|
private class SeriesSelection(season: Int, episode: Int) {
|
||||||
|
var season by mutableIntStateOf(season)
|
||||||
|
var episode by mutableIntStateOf(episode)
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
val Saver = listSaver<SeriesSelection, Int>({ listOf(it.season, it.episode) }, { SeriesSelection(it[0], it[1]) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SeriesPage(screen: Screen.Series, item: SeriesModel, state: SeriesState, focus: ScreenFocus, vm: SeriesViewModel, onOpen: (Screen) -> Unit) {
|
||||||
|
// Opened on an episode (a new one, continue watching): it is focused and shown first, else the next one to watch.
|
||||||
|
val start = remember(item.id) { item.episodes.indexOfFirst { it.id == screen.episodeId }.takeIf { it >= 0 } }
|
||||||
|
val first = start ?: item.nextUp
|
||||||
|
val selection = rememberSaveable(saver = SeriesSelection.Saver) { SeriesSelection(item.episodes.getOrNull(first)?.season ?: 0, first) }
|
||||||
|
// The data can change on reload (a new episode): stay in range.
|
||||||
|
val seasonIndex = selection.season.coerceIn(0, (item.seasons.size - 1).coerceAtLeast(0))
|
||||||
|
|
||||||
|
val episodesRow = rememberLazyListState(initialFirstVisibleItemIndex = item.episodes.getOrNull(first)?.rowIndex?.minus(1)?.coerceAtLeast(0) ?: 0)
|
||||||
|
val chipsRow = rememberLazyListState(initialFirstVisibleItemIndex = (seasonIndex - 1).coerceAtLeast(0))
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
var trayOpen by rememberSaveable { mutableStateOf(false) }
|
||||||
|
val tracksButton = remember { FocusRequester() }
|
||||||
|
|
||||||
|
/** Focuses the element [key], scrolling [list] to [index] first when it isn't on screen. */
|
||||||
|
fun moveFocus(key: String, list: LazyListState? = null, index: Int = 0) {
|
||||||
|
scope.launch {
|
||||||
|
if (list != null && list.layoutInfo.visibleItemsInfo.none { it.index == index }) list.scrollToItem((index - 1).coerceAtLeast(0))
|
||||||
|
focus.lastKey = key
|
||||||
|
// One frame to compose the element with its new requester, one to lay it out.
|
||||||
|
withFrameNanos { }
|
||||||
|
withFrameNanos { }
|
||||||
|
focus.requestFocus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun focusChip(season: Int) = item.seasons.getOrNull(season)?.let { moveFocus(it.key, chipsRow, season) }
|
||||||
|
|
||||||
|
fun focusEpisode(index: Int) = item.episodes.getOrNull(index)?.let { moveFocus(it.key, episodesRow, it.rowIndex) }
|
||||||
|
|
||||||
|
fun onSeasonFocused(season: Int) {
|
||||||
|
if (season == selection.season) return
|
||||||
|
selection.season = season
|
||||||
|
val s = item.seasons[season]
|
||||||
|
selection.episode = s.target
|
||||||
|
// The row follows the chips: the season's divider (or its next episode, deeper in the season) at the start.
|
||||||
|
val target = if (s.target == s.first) s.dividerIndex else item.episodes[s.target].rowIndex - 1
|
||||||
|
scope.launch { episodesRow.animateScrollToItem(target.coerceAtLeast(0)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onEpisodeFocused(index: Int) {
|
||||||
|
selection.episode = index
|
||||||
|
val season = item.episodes[index].season
|
||||||
|
if (season != selection.season) {
|
||||||
|
selection.season = season
|
||||||
|
if (chipsRow.layoutInfo.visibleItemsInfo.none { it.index == season }) scope.launch { chipsRow.animateScrollToItem((season - 1).coerceAtLeast(0)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Box(Modifier.fillMaxSize()) {
|
||||||
|
item.backdropUrl?.let { Backdrop(it) }
|
||||||
|
Header(
|
||||||
|
item,
|
||||||
|
state,
|
||||||
|
focus,
|
||||||
|
tracksButton,
|
||||||
|
isDefault = start == null,
|
||||||
|
onPlay = { item.episodes.getOrNull(item.nextUp)?.let { onOpen(vm.player(it)) } },
|
||||||
|
onTracks = { trayOpen = true },
|
||||||
|
onFavorite = vm::toggleFavorite,
|
||||||
|
modifier = Modifier.onPreviewKeyEvent { e -> e.isDown(AndroidKeyEvent.KEYCODE_DPAD_DOWN).also { if (it) focusChip(seasonIndex) } },
|
||||||
|
)
|
||||||
|
SeasonMap(
|
||||||
|
item,
|
||||||
|
seasonIndex,
|
||||||
|
current = { selection.episode },
|
||||||
|
modifier = Modifier.offset(y = 183.dp).padding(horizontal = AppDimens.CONTENT_START.dp).fillMaxWidth(),
|
||||||
|
)
|
||||||
|
SeasonChips(
|
||||||
|
item,
|
||||||
|
seasonIndex,
|
||||||
|
chipsRow,
|
||||||
|
focus,
|
||||||
|
onFocused = ::onSeasonFocused,
|
||||||
|
onClick = { focusEpisode(item.seasons[it].target) },
|
||||||
|
modifier = Modifier
|
||||||
|
.offset(y = 220.dp)
|
||||||
|
.onPreviewKeyEvent { e ->
|
||||||
|
when {
|
||||||
|
e.isDown(AndroidKeyEvent.KEYCODE_DPAD_DOWN) -> true.also { focusEpisode(item.seasons[seasonIndex].target) }
|
||||||
|
e.isDown(AndroidKeyEvent.KEYCODE_DPAD_UP) -> true.also { moveFocus(KEY_PLAY) }
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
EpisodeRow(
|
||||||
|
item,
|
||||||
|
episodesRow,
|
||||||
|
focus,
|
||||||
|
defaultKey = start?.let { item.episodes[it].key },
|
||||||
|
onFocused = ::onEpisodeFocused,
|
||||||
|
onClick = { onOpen(vm.player(it)) },
|
||||||
|
modifier = Modifier
|
||||||
|
.offset(y = 254.dp)
|
||||||
|
.onPreviewKeyEvent { e -> e.isDown(AndroidKeyEvent.KEYCODE_DPAD_UP).also { if (it) focusChip(item.episodes[selection.episode.coerceIn(item.episodes.indices)].season) } },
|
||||||
|
)
|
||||||
|
SelectedEpisode(item, selection, Modifier.align(Alignment.BottomStart))
|
||||||
|
if (trayOpen) {
|
||||||
|
val style by vm.subtitleStyle.collectAsStateWithLifecycle()
|
||||||
|
fun close() {
|
||||||
|
trayOpen = false
|
||||||
|
// Its own requester: when the tray goes, the system first focuses the page's first button, which
|
||||||
|
// would also take over the remembered key.
|
||||||
|
scope.launch {
|
||||||
|
withFrameNanos { }
|
||||||
|
runCatching { tracksButton.requestFocus() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
BackHandler { close() }
|
||||||
|
SeriesTray(item.title, state, style, vm, onClose = ::close, modifier = Modifier.align(Alignment.BottomCenter))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun KeyEvent.isDown(code: Int) = type == KeyEventType.KeyDown && key.nativeKeyCode == code
|
||||||
|
|
||||||
|
private val TitleStyle = AppType.Display.copy(fontSize = 36.sp, lineHeight = 38.sp, letterSpacing = (-0.03).em)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun Header(
|
||||||
|
item: SeriesModel,
|
||||||
|
state: SeriesState,
|
||||||
|
focus: ScreenFocus,
|
||||||
|
tracksButton: FocusRequester,
|
||||||
|
isDefault: Boolean,
|
||||||
|
onPlay: () -> Unit,
|
||||||
|
onTracks: () -> Unit,
|
||||||
|
onFavorite: () -> Unit,
|
||||||
|
modifier: Modifier,
|
||||||
|
) {
|
||||||
|
val left = AppDimens.CONTENT_START.dp
|
||||||
|
RemoteImage(
|
||||||
|
item.poster,
|
||||||
|
8.dp,
|
||||||
|
Modifier
|
||||||
|
.offset(left, 32.dp)
|
||||||
|
.size(SeriesLayout.POSTER_W.dp, SeriesLayout.POSTER_H.dp)
|
||||||
|
.border(0.5.dp, Color(0x1AFFFFFF), RoundedCornerShape(8.dp)),
|
||||||
|
)
|
||||||
|
Column(Modifier.padding(start = left + SeriesLayout.POSTER_W.dp + 20.dp, end = left, top = 32.dp)) {
|
||||||
|
BasicText(item.label, style = AppType.MonoLabel)
|
||||||
|
BasicText(item.title, Modifier.padding(top = 9.dp), style = TitleStyle, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||||
|
Meta(item, state.badges, Modifier.padding(top = 9.dp))
|
||||||
|
Row(modifier.padding(top = 13.dp), horizontalArrangement = Arrangement.spacedBy(7.dp)) {
|
||||||
|
PrimaryButton(item.playLabel, onPlay, Modifier.remembered(focus, KEY_PLAY, isDefault = isDefault))
|
||||||
|
if (state.audioLabel != null || state.subtitleLabel != null) {
|
||||||
|
TracksButton(state.audioLabel, state.subtitleLabel, onTracks, Modifier.focusRequester(tracksButton).remembered(focus, KEY_TRACKS))
|
||||||
|
}
|
||||||
|
CircleButton(
|
||||||
|
if (item.favorite) AppIcon.HeartFilled else AppIcon.Heart,
|
||||||
|
onFavorite,
|
||||||
|
Modifier.remembered(focus, "favorite"),
|
||||||
|
tint = if (item.favorite) AppColors.Pink else Color(0xFFE3E5F2),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "★ 8.4 16+ 2022 Фантастика, Драма 4K HDR10 Atmos" */
|
||||||
|
@Composable
|
||||||
|
private fun Meta(item: SeriesModel, badges: List<String>, modifier: Modifier) {
|
||||||
|
val text = AppType.Body.copy(fontSize = 11.5.sp, lineHeight = 12.sp, color = AppColors.TextSecondary)
|
||||||
|
Row(modifier, verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(9.dp)) {
|
||||||
|
item.rating?.let { BasicText(it, style = text.copy(color = AppColors.Yellow, fontWeight = FontWeight.SemiBold)) }
|
||||||
|
item.age?.let {
|
||||||
|
BasicText(
|
||||||
|
it,
|
||||||
|
Modifier.border(0.5.dp, Color(0xFF4A4F68), RoundedCornerShape(3.dp)).padding(horizontal = 5.dp, vertical = 2.dp),
|
||||||
|
style = text.copy(fontSize = 10.sp, lineHeight = 10.sp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
listOfNotNull(item.year, item.genres).forEach { BasicText(it, style = text, maxLines = 1, overflow = TextOverflow.Ellipsis) }
|
||||||
|
if (badges.isNotEmpty()) Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { badges.forEach { FormatBadge(it) } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val TracksFocus = FocusStyle(scale = 1.05f, corner = 21.dp, glows = FocusStyle.ButtonGlows)
|
||||||
|
|
||||||
|
/** "● Русский 5.1 ● без субтитров": opens the tray. */
|
||||||
|
@Composable
|
||||||
|
private fun TracksButton(audio: String?, subtitle: String?, onClick: () -> Unit, modifier: Modifier) {
|
||||||
|
var focused by remember { mutableStateOf(false) }
|
||||||
|
val shape = RoundedCornerShape(21.dp)
|
||||||
|
val color = if (focused) AppColors.OnFocusFill else Color(0xFFE3E5F2)
|
||||||
|
Row(
|
||||||
|
modifier
|
||||||
|
.focusCard(TracksFocus)
|
||||||
|
.height(42.dp)
|
||||||
|
.background(if (focused) AppColors.FocusFill else AppColors.SurfaceRaised, shape)
|
||||||
|
.border(0.5.dp, if (focused) AppColors.FocusFill else AppColors.Border, shape)
|
||||||
|
.onFocusChanged { focused = it.isFocused }
|
||||||
|
.clickable(interactionSource = null, indication = null, onClick = onClick)
|
||||||
|
.padding(horizontal = 14.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
val style = AppType.Button.copy(fontSize = 12.sp, fontWeight = FontWeight.Normal, color = color)
|
||||||
|
audio?.let {
|
||||||
|
Box(Modifier.size(5.dp).background(AppColors.Pink, CircleShape))
|
||||||
|
BasicText(it, Modifier.padding(start = 6.dp), style = style, maxLines = 1)
|
||||||
|
}
|
||||||
|
subtitle?.let {
|
||||||
|
Box(Modifier.padding(start = if (audio != null) 10.dp else 0.dp).size(5.dp).background(AppColors.Yellow, CircleShape))
|
||||||
|
BasicText(it, Modifier.padding(start = 6.dp), style = style, maxLines = 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private object MapColors {
|
||||||
|
val Watched = Color(0xFF3F8F63)
|
||||||
|
val Empty = Color(0xFF33384D)
|
||||||
|
val Label = Color(0xFF6F7592)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Season map: a bar per episode — watched green, next violet, new outlined pink — the selected episode tall and
|
||||||
|
* white. Bars shrink for long seasons; if the series still doesn't fit, the seasons around the selected one are shown.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun SeasonMap(item: SeriesModel, selectedSeason: Int, current: () -> Int, modifier: Modifier) {
|
||||||
|
val layout = remember(item.seasons, selectedSeason) { mapLayout(item.seasons, selectedSeason) }
|
||||||
|
Row(modifier.height(MAP_H.dp), verticalAlignment = Alignment.Bottom) {
|
||||||
|
Row(Modifier.width(MAP_W.dp), horizontalArrangement = Arrangement.spacedBy(MAP_GAP.dp), verticalAlignment = Alignment.Bottom) {
|
||||||
|
if (layout.from > 0) BasicText("…", style = AppType.MonoCaption.copy(color = MapColors.Label))
|
||||||
|
for (s in layout.from until layout.to) {
|
||||||
|
val season = item.seasons[s]
|
||||||
|
val width = (layout.bar + layout.gap) * season.count - layout.gap
|
||||||
|
Column {
|
||||||
|
BasicText(
|
||||||
|
season.mapLabel,
|
||||||
|
style = AppType.MonoCaption.copy(fontSize = 8.5.sp, color = if (s == selectedSeason) AppColors.Text else MapColors.Label),
|
||||||
|
maxLines = 1,
|
||||||
|
)
|
||||||
|
// The selected episode is a separate marker moved by its layer: the bars stay one baked bitmap.
|
||||||
|
Box(Modifier.padding(top = 4.dp).size(width.coerceAtLeast(1f).dp, BAR_TALL.dp)) {
|
||||||
|
Spacer(
|
||||||
|
Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.drawWithCache {
|
||||||
|
// Baked once: hundreds of 1 px outline sides drawn every frame cost ~9 ms of GPU on
|
||||||
|
// the test TV (it redraws the whole screen each frame), one bitmap almost nothing.
|
||||||
|
val image = ImageBitmap(size.width.toInt().coerceAtLeast(1), size.height.toInt().coerceAtLeast(1))
|
||||||
|
CanvasDrawScope().draw(this, layoutDirection, Canvas(image), size) { drawBars(item, season, layout) }
|
||||||
|
onDrawBehind { drawImage(image) }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
Spacer(
|
||||||
|
Modifier
|
||||||
|
.size(layout.bar.dp, BAR_TALL.dp)
|
||||||
|
.graphicsLayer {
|
||||||
|
val i = current() - season.first
|
||||||
|
alpha = if (i in 0 until season.count) 1f else 0f
|
||||||
|
translationX = i.coerceAtLeast(0) * floor((layout.bar + layout.gap).dp.toPx()).coerceAtLeast(floor(layout.bar.dp.toPx()).coerceAtLeast(1f) + 1f)
|
||||||
|
}
|
||||||
|
.drawBehind { drawRect(Color.White, size = Size(floor(size.width).coerceAtLeast(1f), size.height)) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (layout.to < item.seasons.size) BasicText("…", style = AppType.MonoCaption.copy(color = MapColors.Label))
|
||||||
|
}
|
||||||
|
Spacer(Modifier.weight(1f))
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(9.dp)) {
|
||||||
|
val legend = AppType.MonoCaption
|
||||||
|
BasicText("■ просмотрено", style = legend.copy(color = AppColors.Green))
|
||||||
|
BasicText("■ следующая", style = legend.copy(color = AppColors.Accent))
|
||||||
|
BasicText("□ новая", style = legend.copy(color = AppColors.Pink))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bars of one season: plain rectangles, outlines as four 1 px sides (rounded or stroked shapes are tessellated one by one). */
|
||||||
|
private fun DrawScope.drawBars(item: SeriesModel, season: SeriesSeason, layout: MapLayout) {
|
||||||
|
val bar = floor(layout.bar.dp.toPx()).coerceAtLeast(1f)
|
||||||
|
val step = floor((layout.bar + layout.gap).dp.toPx()).coerceAtLeast(bar + 1f)
|
||||||
|
val low = floor(BAR_H.dp.toPx())
|
||||||
|
val top = size.height - low
|
||||||
|
fun outline(color: Color, x: Float) {
|
||||||
|
drawRect(color, Offset(x, top), Size(bar, 1f))
|
||||||
|
drawRect(color, Offset(x, size.height - 1f), Size(bar, 1f))
|
||||||
|
drawRect(color, Offset(x, top), Size(1f, low))
|
||||||
|
drawRect(color, Offset(x + bar - 1f, top), Size(1f, low))
|
||||||
|
}
|
||||||
|
for (i in 0 until season.count) {
|
||||||
|
val index = season.first + i
|
||||||
|
val x = i * step
|
||||||
|
when (item.episodes[index].state) {
|
||||||
|
EpisodeState.Watched -> drawRect(MapColors.Watched, Offset(x, top), Size(bar, low))
|
||||||
|
EpisodeState.InProgress, EpisodeState.Next -> drawRect(AppColors.Accent, Offset(x, top), Size(bar, low))
|
||||||
|
EpisodeState.New -> outline(AppColors.Pink, x)
|
||||||
|
EpisodeState.None -> if (bar >= 3f) outline(MapColors.Empty, x) else drawRect(MapColors.Empty, Offset(x, top), Size(bar, low))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val MAP_W = 600f
|
||||||
|
private const val MAP_H = 32f
|
||||||
|
private const val MAP_GAP = 14f
|
||||||
|
private const val BAR_H = 10f
|
||||||
|
private const val BAR_TALL = 17f
|
||||||
|
/** Width (dp) of one character of the map labels (JetBrains Mono advance is 0.6 em). */
|
||||||
|
private const val LABEL_CHAR = 8.5f * 0.6f
|
||||||
|
|
||||||
|
private data class MapLayout(val bar: Float, val gap: Float, val from: Int, val to: Int)
|
||||||
|
|
||||||
|
/** Bar size that fits all seasons in [MAP_W], else the widest window of seasons around [selected] at the smallest size. */
|
||||||
|
private fun mapLayout(seasons: List<SeriesSeason>, selected: Int): MapLayout {
|
||||||
|
if (seasons.isEmpty()) return MapLayout(7f, 2f, 0, 0)
|
||||||
|
fun width(s: SeriesSeason, bar: Float, gap: Float) = maxOf(s.count * (bar + gap) - gap, s.mapLabel.length * LABEL_CHAR)
|
||||||
|
fun total(range: IntRange, bar: Float, gap: Float) = range.sumOf { width(seasons[it], bar, gap).toDouble() }.toFloat() + MAP_GAP * (range.count() - 1)
|
||||||
|
for ((bar, gap) in listOf(7f to 2f, 5f to 1.5f, 3f to 1f, 2f to 1f)) {
|
||||||
|
if (total(seasons.indices, bar, gap) <= MAP_W) return MapLayout(bar, gap, 0, seasons.size)
|
||||||
|
}
|
||||||
|
val (bar, gap) = 2f to 1f
|
||||||
|
// Room for the "…" marks at both ends.
|
||||||
|
val room = MAP_W - 2 * (MAP_GAP + LABEL_CHAR)
|
||||||
|
var from = selected.coerceIn(seasons.indices)
|
||||||
|
var to = from + 1
|
||||||
|
while (true) {
|
||||||
|
val grown = when {
|
||||||
|
to < seasons.size && total(from..to, bar, gap) <= room -> from until to + 1
|
||||||
|
from > 0 && total(from - 1 until to, bar, gap) <= room -> from - 1 until to
|
||||||
|
else -> break
|
||||||
|
}
|
||||||
|
from = grown.first
|
||||||
|
to = grown.last + 1
|
||||||
|
}
|
||||||
|
// A single season longer than the map: its bars get thinner still.
|
||||||
|
val single = if (to - from == 1) (room / seasons[from].count).coerceIn(1f, bar + gap) else bar + gap
|
||||||
|
return if (to - from == 1 && single < bar + gap) MapLayout(single * 0.6f, single * 0.4f, from, to) else MapLayout(bar, gap, from, to)
|
||||||
|
}
|
||||||
|
|
||||||
|
private val SeasonChipFocus = FocusStyle(scale = 1f, corner = 15.dp, glows = FocusStyle.ButtonGlows)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SeasonChips(
|
||||||
|
item: SeriesModel,
|
||||||
|
selected: Int,
|
||||||
|
state: LazyListState,
|
||||||
|
focus: ScreenFocus,
|
||||||
|
onFocused: (Int) -> Unit,
|
||||||
|
onClick: (Int) -> Unit,
|
||||||
|
modifier: Modifier,
|
||||||
|
) {
|
||||||
|
MinimalScrolling(margin = 16.dp) {
|
||||||
|
LazyRow(
|
||||||
|
modifier,
|
||||||
|
state = state,
|
||||||
|
contentPadding = PaddingValues(horizontal = AppDimens.CONTENT_START.dp, vertical = 6.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
|
) {
|
||||||
|
itemsIndexed(item.seasons, key = { _, s -> s.key }) { i, s ->
|
||||||
|
SeasonChip(
|
||||||
|
s,
|
||||||
|
i == selected,
|
||||||
|
onClick = { onClick(i) },
|
||||||
|
modifier = Modifier
|
||||||
|
.remembered(focus, s.key)
|
||||||
|
.onFocusChanged { if (it.isFocused) onFocused(i) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SeasonChip(season: SeriesSeason, selected: Boolean, onClick: () -> Unit, modifier: Modifier) {
|
||||||
|
var focused by remember { mutableStateOf(false) }
|
||||||
|
val shape = RoundedCornerShape(15.dp)
|
||||||
|
val color = when {
|
||||||
|
focused -> AppColors.OnFocusFill
|
||||||
|
selected -> AppColors.Text
|
||||||
|
else -> AppColors.TextSecondary
|
||||||
|
}
|
||||||
|
Row(
|
||||||
|
modifier
|
||||||
|
.focusCard(SeasonChipFocus)
|
||||||
|
.height(30.dp)
|
||||||
|
.background(if (focused) AppColors.FocusFill else if (selected) Color(0xFF211D4A) else AppColors.SurfaceRaised, shape)
|
||||||
|
.border(0.5.dp, if (focused) AppColors.FocusFill else if (selected) Color(0xFF7A6CFF) else AppColors.Border, shape)
|
||||||
|
.onFocusChanged { focused = it.isFocused }
|
||||||
|
.clickable(interactionSource = null, indication = null, onClick = onClick)
|
||||||
|
.padding(horizontal = 12.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
|
) {
|
||||||
|
BasicText(season.label, style = AppType.Button.copy(color = color), maxLines = 1)
|
||||||
|
BasicText(season.stat, style = AppType.MonoCaption.copy(color = color.copy(alpha = 0.75f)), maxLines = 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val EpisodeFocus = FocusStyle(scale = 1.06f, corner = 8.dp)
|
||||||
|
|
||||||
|
/** Every episode of the series, a "СЕЗОН N" divider before each season. */
|
||||||
|
@Composable
|
||||||
|
private fun EpisodeRow(
|
||||||
|
item: SeriesModel,
|
||||||
|
state: LazyListState,
|
||||||
|
focus: ScreenFocus,
|
||||||
|
defaultKey: String?,
|
||||||
|
onFocused: (Int) -> Unit,
|
||||||
|
onClick: (SeriesEpisode) -> Unit,
|
||||||
|
modifier: Modifier,
|
||||||
|
) {
|
||||||
|
MinimalScrolling(margin = 18.dp) {
|
||||||
|
LazyRow(
|
||||||
|
modifier,
|
||||||
|
state = state,
|
||||||
|
contentPadding = PaddingValues(start = AppDimens.CONTENT_START.dp, end = AppDimens.CONTENT_START.dp, top = 12.dp, bottom = 12.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
for (season in item.seasons) {
|
||||||
|
item(key = "div/${season.key}", contentType = "divider") { SeasonDivider(season.label.uppercase()) }
|
||||||
|
itemsIndexed(item.episodes.subList(season.first, season.first + season.count), key = { _, e -> e.key }, contentType = { _, _ -> "episode" }) { i, e ->
|
||||||
|
val index = season.first + i
|
||||||
|
EpisodeCard(
|
||||||
|
e,
|
||||||
|
onClick = { onClick(e) },
|
||||||
|
modifier = Modifier
|
||||||
|
.remembered(focus, e.key, isDefault = e.key == defaultKey)
|
||||||
|
.onFocusChanged { if (it.isFocused) onFocused(index) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Vertical "СЕЗОН 2" on a dashed line. */
|
||||||
|
@Composable
|
||||||
|
private fun SeasonDivider(label: String) {
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.size(22.dp, SeriesLayout.EPISODE_H.dp)
|
||||||
|
.drawBehind {
|
||||||
|
// Dashes as plain rectangles (a dashed path effect is a path, slow on this GPU).
|
||||||
|
val dash = 3.dp.toPx().roundToInt().toFloat()
|
||||||
|
var y = 0f
|
||||||
|
while (y < size.height) {
|
||||||
|
drawRect(MapColors.Empty, Offset(0f, y), Size(1f, dash.coerceAtMost(size.height - y)))
|
||||||
|
y += dash * 2
|
||||||
|
}
|
||||||
|
},
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
BasicText(
|
||||||
|
label,
|
||||||
|
Modifier.wrapContentSize(unbounded = true).rotate(-90f),
|
||||||
|
style = AppType.MonoCaption.copy(letterSpacing = 0.14.em),
|
||||||
|
maxLines = 1,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun EpisodeCard(e: SeriesEpisode, onClick: () -> Unit, modifier: Modifier) {
|
||||||
|
Column(Modifier.width(SeriesLayout.EPISODE_W.dp)) {
|
||||||
|
Box(
|
||||||
|
modifier
|
||||||
|
.focusCard(EpisodeFocus)
|
||||||
|
.size(SeriesLayout.EPISODE_W.dp, SeriesLayout.EPISODE_H.dp)
|
||||||
|
.clickable(interactionSource = null, indication = null, onClick = onClick),
|
||||||
|
) {
|
||||||
|
RemoteImage(e.image, 8.dp, Modifier.fillMaxSize())
|
||||||
|
BasicText(
|
||||||
|
e.code,
|
||||||
|
Modifier.padding(start = 8.dp, top = 6.dp),
|
||||||
|
style = AppType.MonoMeta.copy(fontSize = 18.sp, fontWeight = FontWeight.SemiBold, color = Color(0xE0FFFFFF)),
|
||||||
|
)
|
||||||
|
badge(e)?.let { (text, color) ->
|
||||||
|
BasicText(
|
||||||
|
text,
|
||||||
|
Modifier
|
||||||
|
.align(Alignment.TopEnd)
|
||||||
|
.padding(7.dp)
|
||||||
|
.background(Color(0xBF0A0B10), RoundedCornerShape(3.5.dp))
|
||||||
|
.border(0.5.dp, color, RoundedCornerShape(3.5.dp))
|
||||||
|
.padding(horizontal = 5.dp, vertical = 2.dp),
|
||||||
|
style = AppType.MonoBadge.copy(fontSize = 8.5.sp, color = color),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
e.progress?.let {
|
||||||
|
ProgressLine(it, Modifier.align(Alignment.BottomStart).padding(horizontal = 6.dp, vertical = 4.dp).fillMaxWidth().height(3.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
BasicText(
|
||||||
|
e.title,
|
||||||
|
Modifier.padding(top = 7.dp),
|
||||||
|
style = AppType.PosterTitle.copy(fontSize = 12.sp, color = if (e.state == EpisodeState.Watched) AppColors.TextMuted else AppColors.Text),
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
e.meta?.let { BasicText(it, Modifier.padding(top = 3.dp), style = AppType.MonoCaption, maxLines = 1) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun badge(e: SeriesEpisode): Pair<String, Color>? = when (e.state) {
|
||||||
|
EpisodeState.Watched -> "✓" to AppColors.Green
|
||||||
|
EpisodeState.InProgress -> "${((e.progress ?: 0f) * 100).roundToInt()}%" to AppColors.Accent
|
||||||
|
EpisodeState.Next -> "далее" to AppColors.Accent
|
||||||
|
EpisodeState.New -> "новая" to AppColors.Pink
|
||||||
|
EpisodeState.None -> null
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SelectedEpisode(item: SeriesModel, selection: SeriesSelection, modifier: Modifier) {
|
||||||
|
item.episodes.getOrNull(selection.episode.coerceIn(0, (item.episodes.size - 1).coerceAtLeast(0)))?.let { EpisodeDetail(it, modifier) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The selected episode under the row: "СЕЗОН 2 · СЕРИЯ 4", title, description, duration and air date. */
|
||||||
|
@Composable
|
||||||
|
private fun EpisodeDetail(e: SeriesEpisode, modifier: Modifier) {
|
||||||
|
Row(
|
||||||
|
modifier
|
||||||
|
.padding(horizontal = AppDimens.CONTENT_START.dp)
|
||||||
|
.padding(bottom = 22.dp)
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(66.dp)
|
||||||
|
.drawBehind { drawRect(AppColors.BorderSubtle, size = Size(size.width, 1f)) }
|
||||||
|
.padding(top = 11.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(14.dp),
|
||||||
|
) {
|
||||||
|
BasicText(e.longCode, Modifier.width(126.dp).padding(top = 3.dp), style = AppType.MonoLabel.copy(letterSpacing = 0.06.em))
|
||||||
|
Column(Modifier.weight(1f)) {
|
||||||
|
BasicText(e.title, style = AppType.Section.copy(fontSize = 14.sp), maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||||
|
e.overview?.let {
|
||||||
|
BasicText(
|
||||||
|
it,
|
||||||
|
Modifier.padding(top = 4.dp),
|
||||||
|
style = AppType.Body.copy(fontSize = 11.sp, lineHeight = 15.sp),
|
||||||
|
maxLines = 2,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Column(Modifier.padding(top = 2.dp)) {
|
||||||
|
val style = AppType.MonoMeta.copy(fontSize = 10.sp, color = AppColors.TextDim, textAlign = TextAlign.End)
|
||||||
|
e.duration?.let { BasicText(it, Modifier.align(Alignment.End), style = style) }
|
||||||
|
e.airDate?.let { BasicText(it, Modifier.align(Alignment.End).padding(top = 5.dp), style = style) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
172
app/src/main/kotlin/ru/stenfredd/movies/ui/series/SeriesTray.kt
Normal file
172
app/src/main/kotlin/ru/stenfredd/movies/ui/series/SeriesTray.kt
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
package ru.stenfredd.movies.ui.series
|
||||||
|
|
||||||
|
import android.view.KeyEvent as AndroidKeyEvent
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.focusGroup
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.ColumnScope
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.RowScope
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.text.BasicText
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.withFrameNanos
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.drawBehind
|
||||||
|
import androidx.compose.ui.focus.FocusDirection
|
||||||
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
|
import androidx.compose.ui.focus.focusProperties
|
||||||
|
import androidx.compose.ui.geometry.Size
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.input.key.KeyEventType
|
||||||
|
import androidx.compose.ui.input.key.key
|
||||||
|
import androidx.compose.ui.input.key.nativeKeyCode
|
||||||
|
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
||||||
|
import androidx.compose.ui.input.key.type
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.em
|
||||||
|
import ru.stenfredd.movies.playback.api.SubtitleStyle
|
||||||
|
import ru.stenfredd.movies.ui.components.MinimalScrolling
|
||||||
|
import ru.stenfredd.movies.ui.player.PanelChips
|
||||||
|
import ru.stenfredd.movies.ui.player.PanelList
|
||||||
|
import ru.stenfredd.movies.ui.player.PanelSection
|
||||||
|
import ru.stenfredd.movies.ui.player.SubtitleChoices
|
||||||
|
import ru.stenfredd.movies.ui.player.toChoices
|
||||||
|
import ru.stenfredd.movies.ui.theme.AppColors
|
||||||
|
import ru.stenfredd.movies.ui.theme.AppType
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Аудио и субтитры" tray at the bottom of the series page: audio, subtitles and their look, applied at once and
|
||||||
|
* remembered for the whole series (the tracks are those of the next episode's file; other episodes get the closest
|
||||||
|
* match). Opaque, without the mockup's full-screen dimming, which is expensive on weak TV GPUs.
|
||||||
|
*
|
||||||
|
* Focus stays inside; ↓ past the bottom or Back closes it.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun SeriesTray(
|
||||||
|
title: String,
|
||||||
|
state: SeriesState,
|
||||||
|
style: SubtitleStyle,
|
||||||
|
vm: SeriesViewModel,
|
||||||
|
onClose: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val initial = remember { FocusRequester() }
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
withFrameNanos { }
|
||||||
|
runCatching { initial.requestFocus() }
|
||||||
|
}
|
||||||
|
// Back went down inside the tray; it acts on release.
|
||||||
|
val backDown = remember { booleanArrayOf(false) }
|
||||||
|
Column(
|
||||||
|
modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(TRAY_H.dp)
|
||||||
|
.background(AppColors.Background)
|
||||||
|
.drawBehind { drawRect(Color(0xFF262A3A), size = Size(size.width, 1f)) }
|
||||||
|
// Back acts on release, as Android does: closing on the press would send the release to the page, which
|
||||||
|
// would leave it.
|
||||||
|
.onPreviewKeyEvent {
|
||||||
|
val code = it.key.nativeKeyCode
|
||||||
|
if (code != AndroidKeyEvent.KEYCODE_BACK && code != AndroidKeyEvent.KEYCODE_ESCAPE) return@onPreviewKeyEvent false
|
||||||
|
when (it.type) {
|
||||||
|
KeyEventType.KeyDown -> backDown[0] = true
|
||||||
|
KeyEventType.KeyUp -> if (backDown[0]) onClose()
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
.focusProperties {
|
||||||
|
onExit = {
|
||||||
|
if (requestedFocusDirection == FocusDirection.Down) onClose()
|
||||||
|
cancelFocusChange()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.focusGroup()
|
||||||
|
.padding(start = 48.dp, end = 48.dp, top = 12.dp, bottom = 22.dp),
|
||||||
|
) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(7.dp)) {
|
||||||
|
Box(Modifier.size(6.dp).background(AppColors.Accent, CircleShape))
|
||||||
|
BasicText(
|
||||||
|
"АУДИО И СУБТИТРЫ · " + title.uppercase(),
|
||||||
|
Modifier.weight(1f),
|
||||||
|
style = AppType.MonoLabel.copy(color = AppColors.TextSecondary, letterSpacing = 0.1.em),
|
||||||
|
maxLines = 1,
|
||||||
|
)
|
||||||
|
BasicText("Запоминается для всего сериала · Назад или ▼ — закрыть", style = AppType.MonoCaption.copy(color = Color(0xFF6F7592)))
|
||||||
|
}
|
||||||
|
Row(Modifier.padding(top = 10.dp).fillMaxWidth().weight(1f), horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||||
|
val audioFirst = state.audio.isNotEmpty()
|
||||||
|
if (state.audio.isNotEmpty()) {
|
||||||
|
TrayColumn("АУДИО", AppColors.Pink) {
|
||||||
|
PanelList(state.audio.toChoices(state.selectedAudio), initial) { c ->
|
||||||
|
vm.selectAudio(state.audio.firstOrNull { it.key == c.key }?.index)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (state.subtitles.isNotEmpty()) {
|
||||||
|
TrayColumn("СУБТИТРЫ", AppColors.Yellow) {
|
||||||
|
PanelList(state.subtitles.toChoices(state.selectedSubtitle), if (audioFirst) null else initial) { c ->
|
||||||
|
vm.selectSubtitle(state.subtitles.firstOrNull { it.key == c.key }?.index)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
TrayColumn("ВИД СУБТИТРОВ", AppColors.Yellow) {
|
||||||
|
PanelSection("РАЗМЕР") { PanelChips(SubtitleChoices.sizes(style)) { vm.setSubtitleStyle(SubtitleChoices.apply(style, it)) } }
|
||||||
|
PanelSection("ЦВЕТ") { PanelChips(SubtitleChoices.colors(style)) { vm.setSubtitleStyle(SubtitleChoices.apply(style, it)) } }
|
||||||
|
PanelSection("ФОН") { PanelChips(SubtitleChoices.backgrounds(style)) { vm.setSubtitleStyle(SubtitleChoices.apply(style, it)) } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val TRAY_H = 262f
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun RowScope.TrayColumn(title: String, dot: Color, spacing: Dp = 4.dp, content: @Composable ColumnScope.() -> Unit) {
|
||||||
|
val shape = RoundedCornerShape(9.dp)
|
||||||
|
Column(
|
||||||
|
Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.fillMaxHeight()
|
||||||
|
.background(Color(0xFF10121A), shape)
|
||||||
|
.border(0.5.dp, Color(0xFF262A3A), shape),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(25.dp)
|
||||||
|
.background(Color(0xFF181B27), RoundedCornerShape(topStart = 9.dp, topEnd = 9.dp))
|
||||||
|
.padding(horizontal = 9.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(5.dp),
|
||||||
|
) {
|
||||||
|
Box(Modifier.size(5.dp).background(dot, CircleShape))
|
||||||
|
BasicText(title, style = AppType.MonoCaption.copy(color = AppColors.TextMuted, letterSpacing = 0.1.em))
|
||||||
|
}
|
||||||
|
MinimalScrolling(margin = 10.dp) {
|
||||||
|
Column(
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
.padding(8.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(spacing),
|
||||||
|
content = content,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,385 @@
|
|||||||
|
package ru.stenfredd.movies.ui.series
|
||||||
|
|
||||||
|
import android.os.SystemClock
|
||||||
|
import androidx.compose.runtime.Immutable
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import dagger.assisted.Assisted
|
||||||
|
import dagger.assisted.AssistedFactory
|
||||||
|
import dagger.assisted.AssistedInject
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.async
|
||||||
|
import kotlinx.coroutines.coroutineScope
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import ru.stenfredd.movies.core.data.ImageUrls
|
||||||
|
import ru.stenfredd.movies.core.data.LibraryRepository
|
||||||
|
import ru.stenfredd.movies.core.data.SessionCache
|
||||||
|
import ru.stenfredd.movies.core.data.WatchRepository
|
||||||
|
import ru.stenfredd.movies.core.data.model.ItemDetails
|
||||||
|
import ru.stenfredd.movies.core.data.model.ItemSummary
|
||||||
|
import ru.stenfredd.movies.core.data.model.MediaInfo
|
||||||
|
import ru.stenfredd.movies.playback.api.SubtitleStyle
|
||||||
|
import ru.stenfredd.movies.playback.core.PlaybackPreferences
|
||||||
|
import ru.stenfredd.movies.ui.Screen
|
||||||
|
import ru.stenfredd.movies.ui.components.RemoteImageSpec
|
||||||
|
import ru.stenfredd.movies.ui.details.MediaLabels
|
||||||
|
import ru.stenfredd.movies.ui.details.TrackChoices
|
||||||
|
import ru.stenfredd.movies.ui.details.TrackOption
|
||||||
|
import ru.stenfredd.movies.ui.model.CardMapper
|
||||||
|
import ru.stenfredd.movies.ui.player.PlayerLabels
|
||||||
|
import ru.stenfredd.movies.ui.player.plural
|
||||||
|
import timber.log.Timber
|
||||||
|
import java.time.Instant
|
||||||
|
import java.time.ZoneId
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
import java.util.Locale
|
||||||
|
import java.util.UUID
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
/** Where an episode is in watching: the season map colours and the card's corner badge. */
|
||||||
|
enum class EpisodeState { Watched, InProgress, Next, New, None }
|
||||||
|
|
||||||
|
@Immutable
|
||||||
|
data class SeriesEpisode(
|
||||||
|
val key: String,
|
||||||
|
val id: UUID,
|
||||||
|
/** Index of its season in [SeriesModel.seasons]. */
|
||||||
|
val season: Int,
|
||||||
|
/** Position in the episode row, which has a divider before every season. */
|
||||||
|
val rowIndex: Int,
|
||||||
|
/** "S2·E04" */
|
||||||
|
val code: String,
|
||||||
|
/** "СЕЗОН 2 · СЕРИЯ 4" */
|
||||||
|
val longCode: String,
|
||||||
|
val title: String,
|
||||||
|
/** "56 мин · 13.06.2023" */
|
||||||
|
val meta: String?,
|
||||||
|
val duration: String?,
|
||||||
|
val airDate: String?,
|
||||||
|
val overview: String?,
|
||||||
|
val image: RemoteImageSpec?,
|
||||||
|
val progress: Float?,
|
||||||
|
val state: EpisodeState,
|
||||||
|
val resumeMs: Long,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Immutable
|
||||||
|
data class SeriesSeason(
|
||||||
|
val key: String,
|
||||||
|
/** "Сезон 2", or the server's name for specials. */
|
||||||
|
val label: String,
|
||||||
|
/** "✓", "новый", "3/10" */
|
||||||
|
val stat: String,
|
||||||
|
/** "S2 · 3/10" over the season map. */
|
||||||
|
val mapLabel: String,
|
||||||
|
/** Its episodes are [first] until [first] + [count] in [SeriesModel.episodes]. */
|
||||||
|
val first: Int,
|
||||||
|
val count: Int,
|
||||||
|
/** The episode ↓ from the season chip goes to: started, next, first unwatched, else the first. */
|
||||||
|
val target: Int,
|
||||||
|
/** Row index of the season's divider. */
|
||||||
|
val dividerIndex: Int,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Immutable
|
||||||
|
data class SeriesModel(
|
||||||
|
val id: UUID,
|
||||||
|
/** "СЕРИАЛ · 3 СЕЗОНА · 28 СЕРИЙ" */
|
||||||
|
val label: String,
|
||||||
|
val title: String,
|
||||||
|
val rating: String?,
|
||||||
|
val age: String?,
|
||||||
|
val year: String?,
|
||||||
|
val genres: String?,
|
||||||
|
val poster: RemoteImageSpec?,
|
||||||
|
val backdropUrl: String?,
|
||||||
|
val favorite: Boolean,
|
||||||
|
val seasons: List<SeriesSeason>,
|
||||||
|
val episodes: List<SeriesEpisode>,
|
||||||
|
/** Index of the episode "Продолжить"/"Смотреть" plays. */
|
||||||
|
val nextUp: Int,
|
||||||
|
/** "Продолжить S2 E4 · ещё 32 мин" */
|
||||||
|
val playLabel: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Immutable
|
||||||
|
data class SeriesState(
|
||||||
|
val loading: Boolean = true,
|
||||||
|
val error: String? = null,
|
||||||
|
val item: SeriesModel? = null,
|
||||||
|
/** Formats of the next episode's file: "4K", "HDR10", "Atmos". */
|
||||||
|
val badges: List<String> = emptyList(),
|
||||||
|
/** Tracks of the next episode's file, for the "Аудио и субтитры" tray. */
|
||||||
|
val audio: List<TrackOption> = emptyList(),
|
||||||
|
val subtitles: List<TrackOption> = emptyList(),
|
||||||
|
val selectedAudio: Int? = null,
|
||||||
|
val selectedSubtitle: Int? = null,
|
||||||
|
/** "Русский 5.1" */
|
||||||
|
val audioLabel: String? = null,
|
||||||
|
/** "Русский", "без субтитров" */
|
||||||
|
val subtitleLabel: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Series page: header, season map and chips, every episode in one row, and the audio/subtitle choice for the series
|
||||||
|
* (remembered per series, see [PlaybackPreferences]; the player picks it up for every episode).
|
||||||
|
*/
|
||||||
|
@HiltViewModel(assistedFactory = SeriesViewModel.Factory::class)
|
||||||
|
class SeriesViewModel @AssistedInject constructor(
|
||||||
|
@Assisted private val seriesId: UUID,
|
||||||
|
private val library: LibraryRepository,
|
||||||
|
private val watch: WatchRepository,
|
||||||
|
private val images: ImageUrls,
|
||||||
|
private val cards: CardMapper,
|
||||||
|
private val prefs: PlaybackPreferences,
|
||||||
|
cache: SessionCache,
|
||||||
|
) : ViewModel() {
|
||||||
|
@AssistedFactory
|
||||||
|
interface Factory {
|
||||||
|
fun create(seriesId: UUID): SeriesViewModel
|
||||||
|
}
|
||||||
|
|
||||||
|
private val _state = MutableStateFlow(SeriesState())
|
||||||
|
val state: StateFlow<SeriesState> = _state.asStateFlow()
|
||||||
|
|
||||||
|
val subtitleStyle: StateFlow<SubtitleStyle> = prefs.subtitleStyle.stateIn(viewModelScope, SharingStarted.Eagerly, SubtitleStyle())
|
||||||
|
|
||||||
|
/** File of the next episode: its tracks are what the tray offers. */
|
||||||
|
private var media: MediaInfo? = null
|
||||||
|
|
||||||
|
init {
|
||||||
|
viewModelScope.launch { load() }
|
||||||
|
// Back from the player (progress, next episode) or watched/favourite changed elsewhere: reload.
|
||||||
|
viewModelScope.launch { cache.invalidations.collect { load() } }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun player(episode: SeriesEpisode): Screen.Player = Screen.Player(
|
||||||
|
itemId = episode.id,
|
||||||
|
engine = null,
|
||||||
|
// A watched episode starts over; the tracks come from the series' memory.
|
||||||
|
startPositionMs = if (episode.state == EpisodeState.Watched) 0 else episode.resumeMs,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun selectAudio(index: Int?) {
|
||||||
|
_state.update { it.withTracks(media, index, it.selectedSubtitle) }
|
||||||
|
viewModelScope.launch { TrackChoices.rememberAudio(prefs, seriesId, media, index) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun selectSubtitle(index: Int?) {
|
||||||
|
_state.update { it.withTracks(media, it.selectedAudio, index) }
|
||||||
|
viewModelScope.launch { TrackChoices.rememberSubtitle(prefs, seriesId, media, index) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setSubtitleStyle(style: SubtitleStyle) {
|
||||||
|
viewModelScope.launch { prefs.setSubtitleStyle(style) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Shows the new value at once and reverts it if the server refuses. */
|
||||||
|
fun toggleFavorite() {
|
||||||
|
val item = _state.value.item ?: return
|
||||||
|
val value = !item.favorite
|
||||||
|
_state.update { s -> s.copy(item = s.item?.copy(favorite = value)) }
|
||||||
|
viewModelScope.launch {
|
||||||
|
try {
|
||||||
|
watch.setFavorite(seriesId, value)
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Timber.w(e, "Series: favourite failed")
|
||||||
|
_state.update { s -> s.copy(item = s.item?.copy(favorite = !value)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun load() {
|
||||||
|
try {
|
||||||
|
coroutineScope {
|
||||||
|
val started = SystemClock.elapsedRealtime()
|
||||||
|
val seasons = async { library.seasons(seriesId) }
|
||||||
|
val episodes = async { library.episodes(seriesId) }
|
||||||
|
val details = library.details(seriesId)
|
||||||
|
val model = withContext(Dispatchers.Default) { model(details, seasons.await(), episodes.await()) }
|
||||||
|
_state.update { it.copy(loading = false, error = null, item = model) }
|
||||||
|
Timber.i("Series loaded in %d ms (%d episodes)", SystemClock.elapsedRealtime() - started, model.episodes.size)
|
||||||
|
// The tray and the badges describe the file of the episode "Продолжить" plays.
|
||||||
|
model.episodes.getOrNull(model.nextUp)?.let { next ->
|
||||||
|
val media = runCatching { library.details(next.id).media }.getOrNull()
|
||||||
|
this@SeriesViewModel.media = media
|
||||||
|
val (audio, subtitle) = media?.let { TrackChoices.initial(prefs, seriesId, it) } ?: (null to null)
|
||||||
|
_state.update { s ->
|
||||||
|
s.copy(badges = MediaLabels.badges(media), audio = TrackChoices.audio(media), subtitles = TrackChoices.subtitles(media))
|
||||||
|
.withTracks(media, audio, subtitle)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Timber.w(e, "Series: load failed")
|
||||||
|
// Keep what is shown when a reload fails.
|
||||||
|
_state.update { if (it.item == null) it.copy(loading = false, error = e.message ?: e.toString()) else it }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun SeriesState.withTracks(media: MediaInfo?, audio: Int?, subtitle: Int?) = copy(
|
||||||
|
selectedAudio = audio,
|
||||||
|
selectedSubtitle = subtitle,
|
||||||
|
audioLabel = media?.takeIf { it.audio.isNotEmpty() }?.let { PlayerLabels.audioChip(it, audio) },
|
||||||
|
subtitleLabel = media?.takeIf { it.subtitles.isNotEmpty() }?.let { m ->
|
||||||
|
if (subtitle == null) "без субтитров" else PlayerLabels.subtitleChip(m, subtitle)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun model(details: ItemDetails, seasonItems: List<ItemSummary>, episodeItems: List<ItemSummary>): SeriesModel {
|
||||||
|
val item = details.summary
|
||||||
|
// Specials (season 0) go after the regular seasons; episodes of seasons the server didn't list are left out.
|
||||||
|
val ordered = seasonItems.sortedBy { if (it.seasonNumber == 0) 1 else 0 }
|
||||||
|
val bySeason = episodeItems.groupBy { it.seasonId }
|
||||||
|
val groups = ordered.mapNotNull { s -> bySeason[s.id]?.takeIf { it.isNotEmpty() }?.let { s to it } }
|
||||||
|
val all = groups.flatMap { it.second }
|
||||||
|
val nextUp = nextUp(all)
|
||||||
|
|
||||||
|
val episodes = ArrayList<SeriesEpisode>(all.size)
|
||||||
|
val seasons = ArrayList<SeriesSeason>(groups.size)
|
||||||
|
var row = 0
|
||||||
|
groups.forEachIndexed { si, (season, list) ->
|
||||||
|
val first = episodes.size
|
||||||
|
val dividerIndex = row++
|
||||||
|
list.forEachIndexed { i, e ->
|
||||||
|
episodes += episode(e, si, row++, i, season, isNextUp = e === all.getOrNull(nextUp))
|
||||||
|
}
|
||||||
|
val own = episodes.subList(first, episodes.size)
|
||||||
|
val watched = own.count { it.state == EpisodeState.Watched }
|
||||||
|
val number = season.seasonNumber
|
||||||
|
seasons += SeriesSeason(
|
||||||
|
key = "season/${season.id}",
|
||||||
|
label = if (number != null && number > 0) "Сезон $number" else season.name,
|
||||||
|
stat = when {
|
||||||
|
watched == own.size -> "✓"
|
||||||
|
watched == 0 && own.all { it.state == EpisodeState.New } -> "новый"
|
||||||
|
else -> "$watched/${own.size}"
|
||||||
|
},
|
||||||
|
mapLabel = "${if (number != null && number > 0) "S$number" else "SP"} · $watched/${own.size}",
|
||||||
|
first = first,
|
||||||
|
count = own.size,
|
||||||
|
target = first + (
|
||||||
|
own.indexOfFirst { it.state == EpisodeState.InProgress || it.state == EpisodeState.Next }.takeIf { it >= 0 }
|
||||||
|
?: own.indexOfFirst { it.state != EpisodeState.Watched }.takeIf { it >= 0 }
|
||||||
|
?: 0
|
||||||
|
),
|
||||||
|
dividerIndex = dividerIndex,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val next = episodes.getOrNull(nextUp)
|
||||||
|
val regularSeasons = groups.count { (it.first.seasonNumber ?: 1) > 0 }
|
||||||
|
return SeriesModel(
|
||||||
|
id = item.id,
|
||||||
|
label = listOf(
|
||||||
|
"СЕРИАЛ",
|
||||||
|
"$regularSeasons ${plural(regularSeasons, "СЕЗОН", "СЕЗОНА", "СЕЗОНОВ")}",
|
||||||
|
"${all.size} ${plural(all.size, "СЕРИЯ", "СЕРИИ", "СЕРИЙ")}",
|
||||||
|
).joinToString(" · "),
|
||||||
|
title = item.name,
|
||||||
|
rating = item.communityRating?.let { String.format(Locale.US, "★ %.1f", it) },
|
||||||
|
age = item.officialRating?.let { r -> Regex("""(\d{1,2}\+)$""").find(r)?.value ?: r },
|
||||||
|
year = item.year?.toString(),
|
||||||
|
genres = item.genres.take(3).joinToString(", ").ifEmpty { null },
|
||||||
|
poster = cards.image(item.primary, SeriesLayout.POSTER_W, SeriesLayout.POSTER_H),
|
||||||
|
backdropUrl = item.backdrop?.let { images.url(it, cards.px(SeriesLayout.BACKDROP_W), cards.px(SeriesLayout.BACKDROP_H), quality = 80) },
|
||||||
|
favorite = item.user.favorite,
|
||||||
|
seasons = seasons,
|
||||||
|
episodes = episodes,
|
||||||
|
nextUp = nextUp.coerceAtLeast(0),
|
||||||
|
playLabel = next?.let { playLabel(it, all[nextUp]) } ?: "Смотреть",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun episode(e: ItemSummary, season: Int, rowIndex: Int, position: Int, seasonItem: ItemSummary, isNextUp: Boolean): SeriesEpisode {
|
||||||
|
val seasonNumber = e.seasonNumber ?: seasonItem.seasonNumber ?: 0
|
||||||
|
// Some files have no episode number: their place in the season stands in.
|
||||||
|
val number = e.episodeNumber ?: (position + 1)
|
||||||
|
val numberLabel = e.episodeNumberEnd?.takeIf { it > number }?.let { "$number–$it" } ?: "%02d".format(number)
|
||||||
|
val progress = e.user.progress?.takeIf { !e.user.played }
|
||||||
|
val state = when {
|
||||||
|
e.user.played -> EpisodeState.Watched
|
||||||
|
progress != null -> EpisodeState.InProgress
|
||||||
|
isNextUp -> EpisodeState.Next
|
||||||
|
cards.isNew(e) -> EpisodeState.New
|
||||||
|
else -> EpisodeState.None
|
||||||
|
}
|
||||||
|
val duration = e.runtimeMs?.let { "${(it / 60_000.0).roundToInt()} мин" }
|
||||||
|
val air = e.premiereAt?.let { DateFormat.format(Instant.ofEpochMilli(it).atZone(ZoneId.systemDefault())) }
|
||||||
|
return SeriesEpisode(
|
||||||
|
key = "ep/${e.id}",
|
||||||
|
id = e.id,
|
||||||
|
season = season,
|
||||||
|
rowIndex = rowIndex,
|
||||||
|
code = "S$seasonNumber·E$numberLabel",
|
||||||
|
longCode = "СЕЗОН $seasonNumber · СЕРИЯ ${e.episodeNumber ?: number}",
|
||||||
|
title = e.name,
|
||||||
|
meta = listOfNotNull(duration, air).joinToString(" · ").ifEmpty { null },
|
||||||
|
duration = duration,
|
||||||
|
airDate = air,
|
||||||
|
overview = e.overview,
|
||||||
|
image = cards.image(e.primary ?: e.thumb ?: e.backdrop, SeriesLayout.EPISODE_W, SeriesLayout.EPISODE_H),
|
||||||
|
progress = progress,
|
||||||
|
state = state,
|
||||||
|
resumeMs = if (progress != null) e.user.positionMs else 0,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun playLabel(next: SeriesEpisode, item: ItemSummary): String {
|
||||||
|
val code = next.code.replace("·", " ").replace(Regex("""E0(\d)"""), "E$1")
|
||||||
|
return when (next.state) {
|
||||||
|
EpisodeState.InProgress -> {
|
||||||
|
val left = item.runtimeMs?.let { (it * (1 - (next.progress ?: 0f)) / 60_000).roundToInt() }
|
||||||
|
"Продолжить $code" + (left?.let { " · ещё $it мин" } ?: "")
|
||||||
|
}
|
||||||
|
else -> "Смотреть $code" + (next.duration?.let { " · $it" } ?: "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The episode to continue with: the last one played if it isn't finished (also when it was only started for a few
|
||||||
|
* seconds and the server kept no position), else the first unwatched one after it; nothing played yet — the first
|
||||||
|
* unwatched. Specials only count when the series has nothing else.
|
||||||
|
*/
|
||||||
|
private fun nextUp(all: List<ItemSummary>): Int {
|
||||||
|
if (all.isEmpty()) return -1
|
||||||
|
val regular = all.indices.filter { (all[it].seasonNumber ?: 1) > 0 }.ifEmpty { all.indices.toList() }
|
||||||
|
val last = regular.filter { all[it].user.lastPlayedAt != null }.maxByOrNull { all[it].user.lastPlayedAt!! }
|
||||||
|
if (last != null) {
|
||||||
|
val e = all[last]
|
||||||
|
if (!e.user.played) return last
|
||||||
|
regular.firstOrNull { it > last && !all[it].user.played }?.let { return it }
|
||||||
|
}
|
||||||
|
return regular.firstOrNull { !all[it].user.played } ?: regular.first()
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
val DateFormat: DateTimeFormatter = DateTimeFormatter.ofPattern("dd.MM.yyyy")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sizes (dp) shared by the layout, the image requests and the baked backdrop. */
|
||||||
|
internal object SeriesLayout {
|
||||||
|
const val PAGE_W = 960f
|
||||||
|
const val PAGE_H = 540f
|
||||||
|
const val POSTER_W = 90f
|
||||||
|
const val POSTER_H = 135f
|
||||||
|
const val EPISODE_W = 200f
|
||||||
|
const val EPISODE_H = 112.5f
|
||||||
|
/** The backdrop covers 70 % of the page width and height at the top right, as on the movie page. */
|
||||||
|
const val BACKDROP_W = 672f
|
||||||
|
const val BACKDROP_H = 378f
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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) {
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
Reference in New Issue
Block a user