Home screen and movie page
This commit is contained in:
@@ -30,12 +30,12 @@ import dagger.hilt.android.AndroidEntryPoint
|
|||||||
import ru.stenfredd.movies.core.jellyfin.JellyfinSession
|
import ru.stenfredd.movies.core.jellyfin.JellyfinSession
|
||||||
import ru.stenfredd.movies.playback.api.EngineId
|
import ru.stenfredd.movies.playback.api.EngineId
|
||||||
import ru.stenfredd.movies.playback.api.StreamSelection
|
import ru.stenfredd.movies.playback.api.StreamSelection
|
||||||
import ru.stenfredd.movies.ui.DetailScreen
|
|
||||||
import ru.stenfredd.movies.ui.ItemsScreen
|
import ru.stenfredd.movies.ui.ItemsScreen
|
||||||
import ru.stenfredd.movies.ui.PlayerScreen
|
import ru.stenfredd.movies.ui.PlayerScreen
|
||||||
import ru.stenfredd.movies.ui.PrototypeScreen
|
import ru.stenfredd.movies.ui.PrototypeScreen
|
||||||
import ru.stenfredd.movies.ui.Screen
|
import ru.stenfredd.movies.ui.Screen
|
||||||
import ru.stenfredd.movies.ui.TourScreen
|
import ru.stenfredd.movies.ui.TourScreen
|
||||||
|
import ru.stenfredd.movies.ui.details.DetailsScreen
|
||||||
import ru.stenfredd.movies.ui.home.HomeScreen
|
import ru.stenfredd.movies.ui.home.HomeScreen
|
||||||
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
|
||||||
@@ -98,7 +98,7 @@ class MainActivity : ComponentActivity() {
|
|||||||
is Screen.Root -> Root(screen.tab, onTab = { resetTo(root(it)) }, onOpen = ::open, onLogout = { resetTo(Screen.Login) })
|
is Screen.Root -> Root(screen.tab, onTab = { resetTo(root(it)) }, onOpen = ::open, onLogout = { resetTo(Screen.Login) })
|
||||||
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 -> Legacy { DetailScreen(screen, onOpen = ::open) }
|
is Screen.Detail -> DetailsScreen(screen, onOpen = ::open)
|
||||||
is Screen.Player -> PlayerScreen(screen, onExit = ::back)
|
is Screen.Player -> PlayerScreen(screen, onExit = ::back)
|
||||||
is Screen.Tour -> TourScreen(screen.startIndex, onExit = ::back)
|
is Screen.Tour -> TourScreen(screen.startIndex, onExit = ::back)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,134 +0,0 @@
|
|||||||
package ru.stenfredd.movies.ui
|
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.focus.FocusRequester
|
|
||||||
import androidx.compose.ui.focus.focusRequester
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
|
||||||
import androidx.lifecycle.ViewModel
|
|
||||||
import androidx.tv.material3.Button
|
|
||||||
import androidx.tv.material3.MaterialTheme
|
|
||||||
import androidx.tv.material3.Text
|
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
|
||||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
|
||||||
import org.jellyfin.sdk.model.api.MediaSourceInfo
|
|
||||||
import org.jellyfin.sdk.model.api.MediaStream
|
|
||||||
import org.jellyfin.sdk.model.api.MediaStreamType
|
|
||||||
import ru.stenfredd.movies.playback.api.StreamSelection
|
|
||||||
import ru.stenfredd.movies.playback.core.DeviceCapabilitiesProbe
|
|
||||||
import ru.stenfredd.movies.playback.core.EngineSelector
|
|
||||||
import ru.stenfredd.movies.playback.core.RankedEngine
|
|
||||||
import ru.stenfredd.movies.playback.core.describe
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
import timber.log.Timber
|
|
||||||
import javax.inject.Inject
|
|
||||||
|
|
||||||
@HiltViewModel
|
|
||||||
class DetailViewModel @Inject constructor(
|
|
||||||
val library: Library,
|
|
||||||
val selector: EngineSelector,
|
|
||||||
val probe: DeviceCapabilitiesProbe,
|
|
||||||
) : ViewModel()
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun DetailScreen(screen: Screen.Detail, onOpen: (Screen) -> Unit, vm: DetailViewModel = hiltViewModel()) {
|
|
||||||
var item by remember { mutableStateOf<BaseItemDto?>(null) }
|
|
||||||
var ranking by remember { mutableStateOf(emptyList<RankedEngine>()) }
|
|
||||||
var error by remember { mutableStateOf<String?>(null) }
|
|
||||||
val focus = remember { FocusRequester() }
|
|
||||||
|
|
||||||
LaunchedEffect(screen.itemId) {
|
|
||||||
runCatching {
|
|
||||||
Timber.d("Detail: loading %s", screen.itemId)
|
|
||||||
val loaded = vm.library.item(screen.itemId)
|
|
||||||
Timber.d("Detail: loaded %s", loaded.name)
|
|
||||||
item = loaded
|
|
||||||
val caps = withContext(Dispatchers.Default) { vm.probe.capabilities }
|
|
||||||
loaded.mediaSources?.firstOrNull()?.let { src ->
|
|
||||||
ranking = vm.selector.rank(src, StreamSelection(src.defaultAudioStreamIndex, src.defaultSubtitleStreamIndex), caps)
|
|
||||||
}
|
|
||||||
}.onFailure {
|
|
||||||
Timber.e(it, "Detail: failed")
|
|
||||||
error = it.message
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val it = item
|
|
||||||
LazyColumn(
|
|
||||||
modifier = Modifier.fillMaxSize(),
|
|
||||||
contentPadding = PaddingValues(48.dp, 32.dp),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
|
||||||
) {
|
|
||||||
error?.let { e -> item { Text(e, color = MaterialTheme.colorScheme.error) } }
|
|
||||||
if (it == null) {
|
|
||||||
item { Text("Загрузка…") }
|
|
||||||
return@LazyColumn
|
|
||||||
}
|
|
||||||
item { Text(it.label, style = MaterialTheme.typography.headlineMedium) }
|
|
||||||
item {
|
|
||||||
val resumeMs = (it.userData?.playbackPositionTicks ?: 0) / 10_000
|
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
|
|
||||||
if (resumeMs > 0) {
|
|
||||||
Button(onClick = { onOpen(Screen.Player(it.id, null, resumeMs)) }, modifier = Modifier.focusRequester(focus)) {
|
|
||||||
Text("Продолжить с ${formatTime(resumeMs)}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Button(
|
|
||||||
onClick = { onOpen(Screen.Player(it.id, null, 0)) },
|
|
||||||
modifier = if (resumeMs > 0) Modifier else Modifier.focusRequester(focus),
|
|
||||||
) { Text("Смотреть (авто)") }
|
|
||||||
vm.selector.engines.forEach { p ->
|
|
||||||
Button(onClick = { onOpen(Screen.Player(it.id, p.id, resumeMs)) }) { Text(p.displayName) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
LaunchedEffect(Unit) { runCatching { focus.requestFocus() } }
|
|
||||||
}
|
|
||||||
item { Text("Выбор движка", style = MaterialTheme.typography.titleLarge) }
|
|
||||||
ranking.forEachIndexed { i, r ->
|
|
||||||
item { Text("${i + 1}. ${r.provider.displayName}: ${r.verdict.describe()}") }
|
|
||||||
}
|
|
||||||
it.mediaSources?.firstOrNull()?.let { src -> mediaInfo(src) }
|
|
||||||
it.overview?.let { o -> item { Text(o, style = MaterialTheme.typography.bodySmall) } }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun androidx.compose.foundation.lazy.LazyListScope.mediaInfo(src: MediaSourceInfo) {
|
|
||||||
item { Text("Файл", style = MaterialTheme.typography.titleLarge) }
|
|
||||||
item {
|
|
||||||
Text(
|
|
||||||
"${src.container} · ${src.size?.let { "%.1f ГБ".format(it / 1e9) } ?: "?"} · " +
|
|
||||||
"${src.bitrate?.let { "%.1f Мбит/с".format(it / 1e6) } ?: "?"}",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
src.mediaStreams.orEmpty().forEach { s -> item { Text(describeStream(s, src)) } }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun describeStream(s: MediaStream, src: MediaSourceInfo): String {
|
|
||||||
val default = if (s.index == src.defaultAudioStreamIndex || s.index == src.defaultSubtitleStreamIndex) " [по умолчанию]" else ""
|
|
||||||
return when (s.type) {
|
|
||||||
MediaStreamType.VIDEO -> "Видео #${s.index}: ${s.codec} ${s.profile.orEmpty()} ${s.width}x${s.height} " +
|
|
||||||
"${s.bitDepth ?: 8}bit ${s.videoRangeType.serialName}" + (s.dvProfile?.let { " DV P$it" } ?: "") +
|
|
||||||
(if (s.isInterlaced) " interlaced" else "") + " ${s.realFrameRate ?: ""}fps"
|
|
||||||
MediaStreamType.AUDIO -> "Аудио #${s.index}: ${s.displayTitle ?: s.codec}$default"
|
|
||||||
MediaStreamType.SUBTITLE -> "Субтитры #${s.index}: ${s.displayTitle ?: s.codec} (${s.codec})" +
|
|
||||||
(if (s.isExternal) " внешние" else "") + default
|
|
||||||
else -> "${s.type.serialName} #${s.index}: ${s.codec}"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun formatTime(ms: Long): String {
|
|
||||||
val total = ms / 1000
|
|
||||||
return if (total >= 3600) "%d:%02d:%02d".format(total / 3600, total / 60 % 60, total % 60) else "%d:%02d".format(total / 60, total % 60)
|
|
||||||
}
|
|
||||||
@@ -357,3 +357,8 @@ private fun Menu(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun formatTime(ms: Long): String {
|
||||||
|
val total = ms / 1000
|
||||||
|
return if (total >= 3600) "%d:%02d:%02d".format(total / 3600, total / 60 % 60, total % 60) else "%d:%02d".format(total / 60, total % 60)
|
||||||
|
}
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ import kotlin.math.sin
|
|||||||
* so a user keeps the same colour everywhere.
|
* so a user keeps the same colour everywhere.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun Avatar(name: String, seed: String, size: Dp, textStyle: TextStyle, modifier: Modifier = Modifier) {
|
fun Avatar(name: String, seed: String, size: Dp, textStyle: TextStyle, modifier: Modifier = Modifier, letters: String = name.trim().take(1).uppercase()) {
|
||||||
val colors = remember(seed) { avatarColors(seed) }
|
val colors = remember(seed) { avatarColors(seed) }
|
||||||
Box(
|
Box(
|
||||||
modifier
|
modifier
|
||||||
@@ -35,7 +35,7 @@ fun Avatar(name: String, seed: String, size: Dp, textStyle: TextStyle, modifier:
|
|||||||
},
|
},
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
BasicText(name.trim().take(1).uppercase(), style = textStyle)
|
BasicText(letters, style = textStyle)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -24,81 +24,108 @@ import androidx.compose.ui.graphics.Color
|
|||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.foundation.layout.widthIn
|
||||||
import ru.stenfredd.movies.ui.theme.AppColors
|
import ru.stenfredd.movies.ui.theme.AppColors
|
||||||
import ru.stenfredd.movies.ui.theme.AppType
|
import ru.stenfredd.movies.ui.theme.AppType
|
||||||
|
|
||||||
|
private val PrimaryFocus = FocusStyle(
|
||||||
|
scale = 1.05f,
|
||||||
|
corner = 21.dp,
|
||||||
|
ringColor = Color(0xFFD9D2FF),
|
||||||
|
// `0 0 36px 8px rgba(124,92,255,.65), 0 0 100px rgba(70,120,255,.35)`
|
||||||
|
glows = listOf(FocusStyle.Glow(18.dp, 4.dp, Color(0xA67C5CFF)), FocusStyle.Glow(50.dp, 0.dp, Color(0x594678FF))),
|
||||||
|
)
|
||||||
|
|
||||||
|
private val LightFocus = FocusStyle(scale = 1.05f, corner = 21.dp, glows = FocusStyle.ButtonGlows)
|
||||||
|
|
||||||
/** Main action: gradient pill "▶ Смотреть". */
|
/** Main action: gradient pill "▶ Смотреть". */
|
||||||
@Composable
|
@Composable
|
||||||
fun PrimaryButton(label: String, onClick: () -> Unit, modifier: Modifier = Modifier, icon: AppIcon? = AppIcon.Play, trailing: String? = null) {
|
fun PrimaryButton(label: String, onClick: () -> Unit, modifier: Modifier = Modifier, icon: AppIcon? = AppIcon.Play) {
|
||||||
Row(
|
Row(
|
||||||
modifier
|
modifier
|
||||||
.focusCard(FocusStyle(scale = 1.04f, corner = 19.dp, glows = FocusStyle.ButtonGlows))
|
.focusCard(PrimaryFocus)
|
||||||
.height(38.dp)
|
.height(42.dp)
|
||||||
.background(AppColors.PrimaryGradient, RoundedCornerShape(19.dp))
|
.background(AppColors.PrimaryGradient, RoundedCornerShape(21.dp))
|
||||||
.clickable(interactionSource = null, indication = null, onClick = onClick)
|
.clickable(interactionSource = null, indication = null, onClick = onClick)
|
||||||
.padding(start = 16.dp, end = 20.dp),
|
.padding(start = 15.dp, end = 19.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
) {
|
) {
|
||||||
icon?.let { Icon(it, Color.White, Modifier.size(14.dp)) }
|
icon?.let { Icon(it, Color.White, Modifier.size(15.dp)) }
|
||||||
BasicText(label, style = AppType.Button.copy(fontSize = AppType.CardTitle.fontSize))
|
BasicText(label, style = AppType.Button.copy(fontSize = 14.sp, color = Color.White))
|
||||||
trailing?.let { BasicText(it, style = AppType.Body.copy(color = Color(0xCCFFFFFF))) }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Secondary pill ("С начала") and round icon buttons: dark, filled light when focused. */
|
/** Secondary pill ("С начала"): dark, filled light when focused. */
|
||||||
@Composable
|
@Composable
|
||||||
fun SecondaryButton(label: String, onClick: () -> Unit, modifier: Modifier = Modifier) {
|
fun SecondaryButton(label: String, onClick: () -> Unit, modifier: Modifier = Modifier) {
|
||||||
var focused by remember { mutableStateOf(false) }
|
var focused by remember { mutableStateOf(false) }
|
||||||
Box(
|
Box(
|
||||||
modifier
|
modifier
|
||||||
.focusCard(FocusStyle(scale = 1.04f, corner = 19.dp, glows = FocusStyle.ButtonGlows))
|
.focusCard(LightFocus)
|
||||||
.height(38.dp)
|
.height(42.dp)
|
||||||
.background(if (focused) AppColors.FocusFill else AppColors.SurfaceRaised, RoundedCornerShape(19.dp))
|
.background(if (focused) AppColors.FocusFill else AppColors.SurfaceRaised, RoundedCornerShape(21.dp))
|
||||||
.border(0.5.dp, if (focused) Color.Transparent else AppColors.Border, RoundedCornerShape(19.dp))
|
.border(0.5.dp, if (focused) AppColors.FocusFill else AppColors.Border, RoundedCornerShape(21.dp))
|
||||||
.onFocusChanged { focused = it.isFocused }
|
.onFocusChanged { focused = it.isFocused }
|
||||||
.clickable(interactionSource = null, indication = null, onClick = onClick)
|
.clickable(interactionSource = null, indication = null, onClick = onClick)
|
||||||
.padding(horizontal = 16.dp),
|
.padding(horizontal = 15.dp),
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
BasicText(label, style = AppType.Button.copy(fontSize = AppType.CardTitle.fontSize, color = if (focused) AppColors.OnFocusFill else AppColors.Text))
|
BasicText(label, style = AppType.Button.copy(fontSize = 13.sp, fontWeight = FontWeight.Normal, color = if (focused) AppColors.OnFocusFill else Color(0xFFE3E5F2)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Round icon button (favourite, watched); [tint] is the icon colour when not focused. */
|
||||||
@Composable
|
@Composable
|
||||||
fun CircleButton(icon: AppIcon, onClick: () -> Unit, modifier: Modifier = Modifier, tint: Color = AppColors.Text, size: Dp = 38.dp) {
|
fun CircleButton(icon: AppIcon, onClick: () -> Unit, modifier: Modifier = Modifier, tint: Color = Color(0xFFE3E5F2), size: Dp = 42.dp) {
|
||||||
var focused by remember { mutableStateOf(false) }
|
var focused by remember { mutableStateOf(false) }
|
||||||
Box(
|
Box(
|
||||||
modifier
|
modifier
|
||||||
.focusCard(FocusStyle(scale = 1.06f, corner = size / 2, glows = FocusStyle.ButtonGlows))
|
.focusCard(LightFocus.copy(corner = size / 2))
|
||||||
.size(size)
|
.size(size)
|
||||||
.background(if (focused) AppColors.FocusFill else AppColors.SurfaceRaised, CircleShape)
|
.background(if (focused) AppColors.FocusFill else AppColors.SurfaceRaised, CircleShape)
|
||||||
.border(0.5.dp, if (focused) Color.Transparent else AppColors.Border, CircleShape)
|
.border(0.5.dp, if (focused) AppColors.FocusFill else AppColors.Border, CircleShape)
|
||||||
.onFocusChanged { focused = it.isFocused }
|
.onFocusChanged { focused = it.isFocused }
|
||||||
.clickable(interactionSource = null, indication = null, onClick = onClick),
|
.clickable(interactionSource = null, indication = null, onClick = onClick),
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
Icon(icon, if (focused) AppColors.OnFocusFill else tint, Modifier.size(size * 0.42f))
|
Icon(icon, if (focused) AppColors.OnFocusFill else tint, Modifier.size(size * 0.36f))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Option chip: "Русский TrueHD Atmos 7.1"; selected = violet. */
|
/** Option chip: "Русский TrueHD Atmos 7.1"; selected = violet, focused = light fill. */
|
||||||
@Composable
|
@Composable
|
||||||
fun Chip(label: String, selected: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier, detail: String? = null) {
|
fun Chip(label: String, selected: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier, detail: String? = null) {
|
||||||
val shape = RoundedCornerShape(18.dp)
|
var focused by remember { mutableStateOf(false) }
|
||||||
|
val shape = RoundedCornerShape(14.5.dp)
|
||||||
|
val textColor = when {
|
||||||
|
focused -> AppColors.OnFocusFill
|
||||||
|
selected -> AppColors.Text
|
||||||
|
else -> AppColors.TextSecondary
|
||||||
|
}
|
||||||
Row(
|
Row(
|
||||||
modifier
|
modifier
|
||||||
.focusCard(FocusStyle(scale = 1.04f, corner = 18.dp, glows = FocusStyle.ButtonGlows))
|
.focusCard(FocusStyle(scale = 1f, corner = 14.5.dp, glows = FocusStyle.ButtonGlows))
|
||||||
.height(36.dp)
|
.height(29.dp)
|
||||||
.background(if (selected) AppColors.Selected else AppColors.SurfaceRaised, shape)
|
.background(
|
||||||
.border(0.5.dp, if (selected) AppColors.SelectedBorder else AppColors.Border, shape)
|
when {
|
||||||
|
focused -> AppColors.FocusFill
|
||||||
|
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)
|
.clickable(interactionSource = null, indication = null, onClick = onClick)
|
||||||
.padding(horizontal = 14.dp),
|
.padding(horizontal = 11.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
horizontalArrangement = Arrangement.spacedBy(5.dp),
|
||||||
) {
|
) {
|
||||||
BasicText(label, style = AppType.Button.copy(fontSize = 12.sp, color = if (selected) AppColors.Text else AppColors.TextSecondary))
|
BasicText(label, Modifier.widthIn(max = 190.dp), style = AppType.Button.copy(fontSize = 11.sp, fontWeight = FontWeight.Normal, color = textColor), maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||||
detail?.let { BasicText(it, style = AppType.MonoCaption) }
|
detail?.let { BasicText(it, style = AppType.MonoCaption.copy(fontSize = 8.5.sp, color = textColor.copy(alpha = 0.65f)), maxLines = 1) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,15 +134,15 @@ fun Chip(label: String, selected: Boolean, onClick: () -> Unit, modifier: Modifi
|
|||||||
fun FormatBadge(text: String, modifier: Modifier = Modifier) {
|
fun FormatBadge(text: String, modifier: Modifier = Modifier) {
|
||||||
val color = when (text) {
|
val color = when (text) {
|
||||||
"4K" -> AppColors.Blue
|
"4K" -> AppColors.Blue
|
||||||
"HDR10", "HDR10+", "DV", "HLG" -> AppColors.Yellow
|
"HDR10", "HDR10+", "DV", "HLG", "HDR" -> AppColors.Yellow
|
||||||
"Atmos", "5.1", "7.1", "FLAC", "TrueHD", "DTS" -> AppColors.Pink
|
"Atmos", "5.1", "7.1", "FLAC", "TrueHD", "DTS", "DTS:X" -> AppColors.Pink
|
||||||
else -> AppColors.TextMuted
|
else -> AppColors.TextMuted
|
||||||
}
|
}
|
||||||
BasicText(
|
BasicText(
|
||||||
text,
|
text,
|
||||||
modifier
|
modifier
|
||||||
.border(0.5.dp, color.copy(alpha = 0.6f), RoundedCornerShape(3.dp))
|
.border(0.5.dp, color, RoundedCornerShape(3.5.dp))
|
||||||
.padding(horizontal = 4.dp, vertical = 1.5.dp),
|
.padding(horizontal = 5.dp, vertical = 2.dp),
|
||||||
style = AppType.MonoCaption.copy(color = color),
|
style = AppType.MonoCaption.copy(color = color),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ import androidx.compose.ui.unit.IntSize
|
|||||||
import androidx.compose.ui.unit.toSize
|
import androidx.compose.ui.unit.toSize
|
||||||
import kotlin.math.ceil
|
import kotlin.math.ceil
|
||||||
|
|
||||||
|
private const val HEART = "M12 20s-7.5-4.6-7.5-10A4.2 4.2 0 0 1 12 7.6 4.2 4.2 0 0 1 19.5 10c0 5.4-7.5 10-7.5 10z"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Icons from the mockup's inline SVGs (24×24 viewBox). Paths are rasterised into a small bitmap once per
|
* Icons from the mockup's inline SVGs (24×24 viewBox). Paths are rasterised into a small bitmap once per
|
||||||
* size and colour, then drawn as an image: weak TV GPUs re-rasterise antialiased paths every frame.
|
* size and colour, then drawn as an image: weak TV GPUs re-rasterise antialiased paths every frame.
|
||||||
@@ -27,7 +29,8 @@ enum class AppIcon(val pathData: String, val stroked: Boolean = true) {
|
|||||||
Search("M11 4a7 7 0 1 0 0 14a7 7 0 1 0 0-14zM20 20l-3.5-3.5"),
|
Search("M11 4a7 7 0 1 0 0 14a7 7 0 1 0 0-14zM20 20l-3.5-3.5"),
|
||||||
Home("M4 11l8-7 8 7v9h-5v-6H9v6H4z"),
|
Home("M4 11l8-7 8 7v9h-5v-6H9v6H4z"),
|
||||||
Library("M5 5h14a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2zM3 9h18M8 5v4M16 5v4"),
|
Library("M5 5h14a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2zM3 9h18M8 5v4M16 5v4"),
|
||||||
Heart("M12 20s-7.5-4.6-7.5-10A4.2 4.2 0 0 1 12 7.6 4.2 4.2 0 0 1 19.5 10c0 5.4-7.5 10-7.5 10z"),
|
Heart(HEART),
|
||||||
|
HeartFilled(HEART, stroked = false),
|
||||||
Check("M5 12.5l4.5 4.5L19 7.5"),
|
Check("M5 12.5l4.5 4.5L19 7.5"),
|
||||||
Plus("M12 5v14M5 12h14"),
|
Plus("M12 5v14M5 12h14"),
|
||||||
Play("M7 4.5v15l13-7.5z", stroked = false),
|
Play("M7 4.5v15l13-7.5z", stroked = false),
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
package ru.stenfredd.movies.ui.details
|
||||||
|
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.Canvas
|
||||||
|
import android.graphics.LinearGradient
|
||||||
|
import android.graphics.Matrix
|
||||||
|
import android.graphics.Paint
|
||||||
|
import android.graphics.RectF
|
||||||
|
import android.graphics.Shader
|
||||||
|
import androidx.compose.ui.graphics.toArgb
|
||||||
|
import coil3.size.Dimension
|
||||||
|
import coil3.size.Size
|
||||||
|
import coil3.transform.Transformation
|
||||||
|
import ru.stenfredd.movies.ui.theme.AppColors
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The item page backdrop as one opaque bitmap: the image dimmed to 70 % over the app background and the mockup's
|
||||||
|
* darkening gradient, baked in once on Coil's decode thread, so on screen it is a single opaque image draw.
|
||||||
|
*
|
||||||
|
* It covers only [area] (dp) of the [pageHeight] dp tall page, not the whole screen: on the test TV every frame
|
||||||
|
* redraws the whole screen and a full-screen image costs ~10 ms of GPU per frame. Towards the left and bottom
|
||||||
|
* edges of [area] the image fades into the background, so it has no visible edge.
|
||||||
|
*/
|
||||||
|
class BackdropTransformation(private val area: RectF, private val pageHeight: Float) : Transformation() {
|
||||||
|
override val cacheKey = "backdrop:$area:$pageHeight"
|
||||||
|
|
||||||
|
override suspend fun transform(input: Bitmap, size: Size): Bitmap {
|
||||||
|
val w = (size.width as? Dimension.Pixels)?.px ?: input.width
|
||||||
|
val h = (size.height as? Dimension.Pixels)?.px ?: input.height
|
||||||
|
val output = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888)
|
||||||
|
val canvas = Canvas(output)
|
||||||
|
val bg = AppColors.Background
|
||||||
|
canvas.drawColor(bg.toArgb())
|
||||||
|
|
||||||
|
// Image, cropped to fill, at 70 % opacity.
|
||||||
|
val image = Paint(Paint.FILTER_BITMAP_FLAG).apply { alpha = (0.7f * 255).toInt() }
|
||||||
|
val scale = maxOf(w.toFloat() / input.width, h.toFloat() / input.height)
|
||||||
|
canvas.drawBitmap(
|
||||||
|
input,
|
||||||
|
Matrix().apply {
|
||||||
|
setScale(scale, scale)
|
||||||
|
postTranslate((w - input.width * scale) / 2f, (h - input.height * scale) / 2f)
|
||||||
|
},
|
||||||
|
image,
|
||||||
|
)
|
||||||
|
|
||||||
|
val k = w / area.width()
|
||||||
|
// The mockup's full-page `linear-gradient(0deg, #0a0b10 0%, rgba(10,11,16,.75) 50%, rgba(10,11,16,.55) 100%)`,
|
||||||
|
// placed in page coordinates.
|
||||||
|
canvas.fill(w, h, LinearGradient(0f, (pageHeight - area.top) * k, 0f, -area.top * k, colors(bg.toArgb(), 1f, 0.75f, 0.55f), floatArrayOf(0f, 0.5f, 1f), Shader.TileMode.CLAMP))
|
||||||
|
// Fade into the background at the left and bottom edges.
|
||||||
|
val fadeX = w * LEFT_FADE
|
||||||
|
canvas.fill(w, h, LinearGradient(0f, 0f, fadeX, 0f, colors(bg.toArgb(), 1f, 0.6f, 0f), floatArrayOf(0f, 0.35f, 1f), Shader.TileMode.CLAMP))
|
||||||
|
val fadeY = h * BOTTOM_FADE
|
||||||
|
canvas.fill(w, h, LinearGradient(0f, h.toFloat(), 0f, h - fadeY, colors(bg.toArgb(), 1f, 0f), null, Shader.TileMode.CLAMP))
|
||||||
|
|
||||||
|
output.setHasAlpha(false)
|
||||||
|
return output
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Canvas.fill(w: Int, h: Int, shader: Shader) = drawRect(0f, 0f, w.toFloat(), h.toFloat(), Paint().apply { this.shader = shader })
|
||||||
|
|
||||||
|
private fun colors(argb: Int, vararg alphas: Float) = IntArray(alphas.size) { (argb and 0xFFFFFF) or ((alphas[it] * 255).toInt() shl 24) }
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val LEFT_FADE = 0.4f
|
||||||
|
const val BOTTOM_FADE = 0.35f
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,341 @@
|
|||||||
|
package ru.stenfredd.movies.ui.details
|
||||||
|
|
||||||
|
import android.graphics.RectF
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.focusable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.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.widthIn
|
||||||
|
import androidx.compose.foundation.lazy.LazyRow
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
|
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.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.focus.FocusRequester
|
||||||
|
import androidx.compose.ui.focus.focusRequester
|
||||||
|
import androidx.compose.ui.focus.focusRestorer
|
||||||
|
import androidx.compose.ui.focus.onFocusChanged
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
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 androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import coil3.compose.AsyncImage
|
||||||
|
import coil3.request.ImageRequest
|
||||||
|
import coil3.request.transformations
|
||||||
|
import ru.stenfredd.movies.ui.Screen
|
||||||
|
import ru.stenfredd.movies.ui.components.AnchoredScrolling
|
||||||
|
import ru.stenfredd.movies.ui.components.AppIcon
|
||||||
|
import ru.stenfredd.movies.ui.components.Avatar
|
||||||
|
import ru.stenfredd.movies.ui.components.Chip
|
||||||
|
import ru.stenfredd.movies.ui.components.CircleButton
|
||||||
|
import ru.stenfredd.movies.ui.components.ContentRow
|
||||||
|
import ru.stenfredd.movies.ui.components.DefaultScrolling
|
||||||
|
import ru.stenfredd.movies.ui.components.FocusStyle
|
||||||
|
import ru.stenfredd.movies.ui.components.FormatBadge
|
||||||
|
import ru.stenfredd.movies.ui.components.PosterCard
|
||||||
|
import ru.stenfredd.movies.ui.components.PrimaryButton
|
||||||
|
import ru.stenfredd.movies.ui.components.RemoteImage
|
||||||
|
import ru.stenfredd.movies.ui.components.SecondaryButton
|
||||||
|
import ru.stenfredd.movies.ui.components.SectionTitle
|
||||||
|
import ru.stenfredd.movies.ui.components.anchorViewport
|
||||||
|
import ru.stenfredd.movies.ui.components.anchoredRow
|
||||||
|
import ru.stenfredd.movies.ui.components.focusCard
|
||||||
|
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 ru.stenfredd.movies.ui.details.DetailsLayout as L
|
||||||
|
|
||||||
|
/** Movie (or episode) page, the mockup's "Фильм" screen. Full screen, without the side rail. */
|
||||||
|
@Composable
|
||||||
|
fun DetailsScreen(
|
||||||
|
screen: Screen.Detail,
|
||||||
|
onOpen: (Screen) -> Unit,
|
||||||
|
vm: DetailsViewModel = hiltViewModel<DetailsViewModel, DetailsViewModel.Factory>(creationCallback = { it.create(screen.itemId) }),
|
||||||
|
) {
|
||||||
|
val state by vm.state.collectAsStateWithLifecycle()
|
||||||
|
val focus = rememberScreenFocus()
|
||||||
|
val item = state.item
|
||||||
|
|
||||||
|
when {
|
||||||
|
item != null -> AnchoredScrolling(rememberScrollState(), anchor = AppDimens.CONTENT_TOP.dp) { anchored ->
|
||||||
|
Column(
|
||||||
|
Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.anchorViewport(anchored)
|
||||||
|
.verticalScroll(anchored.scroll)
|
||||||
|
.padding(bottom = 200.dp),
|
||||||
|
) {
|
||||||
|
Hero(item, state, focus, vm, onOpen, Modifier.anchoredRow(anchored))
|
||||||
|
if (item.cast.isNotEmpty()) Cast(item, focus, Modifier.anchoredRow(anchored))
|
||||||
|
if (state.similar.isNotEmpty()) {
|
||||||
|
ContentRow("Похожие", null, Modifier.anchoredRow(anchored).padding(top = 6.dp)) {
|
||||||
|
items(state.similar, key = { it.key }, contentType = { "poster" }) { p ->
|
||||||
|
PosterCard(p, { vm.similarTarget(p.key)?.let(onOpen) }, Modifier.remembered(focus, p.key))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
state.error != null -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
|
BasicText("Не удалось загрузить: ${state.error}", style = AppType.Body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// "С начала" disappears once the movie is watched to the end: focus "Смотреть" instead.
|
||||||
|
val restartShown = (item?.resumeMs ?: 0) > 0
|
||||||
|
LaunchedEffect(restartShown) {
|
||||||
|
if (!restartShown && focus.lastKey == KEY_RESTART) focus.lastKey = KEY_PLAY
|
||||||
|
}
|
||||||
|
focus.Restore(ready = item != null)
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val KEY_PLAY = "play"
|
||||||
|
private const val KEY_RESTART = "restart"
|
||||||
|
|
||||||
|
/** First screen of the page: backdrop, poster with formats, title, actions and the track choice. */
|
||||||
|
@Composable
|
||||||
|
private fun Hero(item: DetailsModel, state: DetailsState, focus: ScreenFocus, vm: DetailsViewModel, onOpen: (Screen) -> Unit, modifier: Modifier) {
|
||||||
|
Box(modifier.fillMaxWidth().height(L.PAGE_H.dp)) {
|
||||||
|
item.backdropUrl?.let { Backdrop(it) }
|
||||||
|
Column(Modifier.offset(L.POSTER_X.dp, L.POSTER_Y.dp).width(L.POSTER_W.dp)) {
|
||||||
|
val shape = RoundedCornerShape(L.POSTER_CORNER.dp)
|
||||||
|
RemoteImage(
|
||||||
|
item.poster,
|
||||||
|
L.POSTER_CORNER.dp,
|
||||||
|
Modifier.size(L.POSTER_W.dp, L.POSTER_H.dp).border(0.5.dp, Color(0x1AFFFFFF), shape),
|
||||||
|
)
|
||||||
|
if (item.badges.isNotEmpty()) {
|
||||||
|
Row(Modifier.padding(top = 11.dp), horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||||
|
item.badges.forEach { FormatBadge(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
item.fileLine?.let { BasicText(it, Modifier.padding(top = 7.dp), style = AppType.MonoMeta.copy(color = AppColors.TextDim)) }
|
||||||
|
}
|
||||||
|
Column(Modifier.padding(start = 320.dp, end = 60.dp, top = L.POSTER_Y.dp)) {
|
||||||
|
BasicText(item.label, style = AppType.MonoLabel)
|
||||||
|
BasicText(item.title, Modifier.padding(top = 12.dp), style = TitleStyle, maxLines = 2, overflow = TextOverflow.Ellipsis)
|
||||||
|
item.subtitle?.let { BasicText(it, Modifier.padding(top = 8.dp), style = AppType.BodyLarge.copy(fontSize = 15.sp), maxLines = 1, overflow = TextOverflow.Ellipsis) }
|
||||||
|
Meta(item, Modifier.padding(top = 11.dp))
|
||||||
|
item.description?.let {
|
||||||
|
BasicText(
|
||||||
|
it,
|
||||||
|
Modifier.padding(top = 11.dp).widthIn(max = 520.dp),
|
||||||
|
style = AppType.Body.copy(fontSize = 13.sp, lineHeight = 19.5.sp, color = AppColors.TextSecondary),
|
||||||
|
maxLines = if (item.subtitle != null) 3 else 4,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Actions(item, focus, vm, onOpen, Modifier.padding(top = 18.dp))
|
||||||
|
if (item.audio.isNotEmpty() || item.subtitles.isNotEmpty()) PlaybackOptions(item, state, focus, vm, Modifier.padding(top = 20.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val TitleStyle = AppType.Display.copy(fontSize = 46.sp, lineHeight = 48.sp, letterSpacing = (-0.03).em)
|
||||||
|
|
||||||
|
/** Backdrop, dimmed and faded at its edges ([BackdropTransformation]), at the top right of the first screen. */
|
||||||
|
@Composable
|
||||||
|
private fun Backdrop(url: String) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val area = remember { RectF(L.PAGE_W - L.BACKDROP_W, 0f, L.PAGE_W, L.BACKDROP_H) }
|
||||||
|
val request = remember(url) {
|
||||||
|
ImageRequest.Builder(context)
|
||||||
|
.data(url)
|
||||||
|
.transformations(BackdropTransformation(area, L.PAGE_H))
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
AsyncImage(
|
||||||
|
request,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.offset(area.left.dp, area.top.dp).size(area.width().dp, area.height().dp),
|
||||||
|
contentScale = ContentScale.FillBounds,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "★ 7.9 16+ 2024 2 ч 14 мин Драма, Приключения" */
|
||||||
|
@Composable
|
||||||
|
private fun Meta(item: DetailsModel, modifier: Modifier) {
|
||||||
|
val text = AppType.Body.copy(fontSize = 12.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.runtime, item.genres).forEach { BasicText(it, style = text, maxLines = 1, overflow = TextOverflow.Ellipsis) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun Actions(item: DetailsModel, focus: ScreenFocus, vm: DetailsViewModel, onOpen: (Screen) -> Unit, modifier: Modifier) {
|
||||||
|
Row(modifier, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
val resume = item.resumeMs > 0
|
||||||
|
PrimaryButton(
|
||||||
|
when {
|
||||||
|
!resume -> "Смотреть"
|
||||||
|
item.remainingMinutes != null -> "Продолжить · ещё ${item.remainingMinutes} мин"
|
||||||
|
else -> "Продолжить"
|
||||||
|
},
|
||||||
|
{ vm.player(fromStart = false)?.let(onOpen) },
|
||||||
|
Modifier.remembered(focus, KEY_PLAY, isDefault = true),
|
||||||
|
)
|
||||||
|
if (resume) SecondaryButton("С начала", { vm.player(fromStart = true)?.let(onOpen) }, Modifier.remembered(focus, KEY_RESTART))
|
||||||
|
CircleButton(
|
||||||
|
if (item.favorite) AppIcon.HeartFilled else AppIcon.Heart,
|
||||||
|
vm::toggleFavorite,
|
||||||
|
Modifier.remembered(focus, "favorite"),
|
||||||
|
tint = if (item.favorite) AppColors.Pink else Color(0xFFE3E5F2),
|
||||||
|
)
|
||||||
|
CircleButton(AppIcon.Check, vm::togglePlayed, Modifier.remembered(focus, "watched"), tint = if (item.played) AppColors.Green else Color(0xFFE3E5F2))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "Параметры воспроизведения": audio and subtitle chips, passed to the player when it starts. */
|
||||||
|
@Composable
|
||||||
|
private fun PlaybackOptions(item: DetailsModel, state: DetailsState, focus: ScreenFocus, vm: DetailsViewModel, modifier: Modifier) {
|
||||||
|
val shape = RoundedCornerShape(10.dp)
|
||||||
|
Column(
|
||||||
|
modifier
|
||||||
|
.widthIn(max = 580.dp)
|
||||||
|
.fillMaxWidth()
|
||||||
|
.background(PanelColor, shape)
|
||||||
|
.border(0.5.dp, Color(0xFF262A3A), shape),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(26.dp)
|
||||||
|
.background(Color(0xFF181B27), RoundedCornerShape(topStart = 10.dp, topEnd = 10.dp))
|
||||||
|
.padding(horizontal = 12.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Spacer(Modifier.size(6.dp).background(AppColors.Accent, CircleShape))
|
||||||
|
BasicText(
|
||||||
|
"ПАРАМЕТРЫ ВОСПРОИЗВЕДЕНИЯ",
|
||||||
|
Modifier.padding(start = 6.dp).weight(1f),
|
||||||
|
style = AppType.MonoCaption.copy(letterSpacing = 0.1.em, color = AppColors.TextMuted),
|
||||||
|
)
|
||||||
|
BasicText("применятся при запуске", style = AppType.MonoCaption.copy(color = Color(0xFF6F7592)))
|
||||||
|
}
|
||||||
|
Spacer(Modifier.fillMaxWidth().height(0.5.dp).background(Color(0xFF232637)))
|
||||||
|
Column(Modifier.padding(vertical = 5.dp)) {
|
||||||
|
if (item.audio.isNotEmpty()) OptionRow("Аудио", AppColors.Pink, item.audio, state.audio, focus, vm::selectAudio)
|
||||||
|
if (item.subtitles.isNotEmpty()) OptionRow("Субтитры", AppColors.Yellow, item.subtitles, state.subtitle, focus, vm::selectSubtitle)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val PanelColor = Color(0xFF111319)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun OptionRow(title: String, color: Color, options: List<TrackOption>, selected: Int?, focus: ScreenFocus, onPick: (Int?) -> Unit) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Row(Modifier.padding(start = 12.dp).width(78.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Spacer(Modifier.size(5.dp).background(color, CircleShape))
|
||||||
|
BasicText(title, Modifier.padding(start = 5.dp), style = AppType.Body.copy(fontSize = 11.sp, lineHeight = 11.sp, color = AppColors.TextDim))
|
||||||
|
}
|
||||||
|
// Files can have a dozen subtitle tracks: the chips scroll instead of wrapping. The row opens on the chosen
|
||||||
|
// track, and coming into the row from above focuses it.
|
||||||
|
val chosen = remember { FocusRequester() }
|
||||||
|
val selectedAt = options.indexOfFirst { it.index == selected }
|
||||||
|
DefaultScrolling {
|
||||||
|
LazyRow(
|
||||||
|
state = rememberLazyListState(initialFirstVisibleItemIndex = if (selectedAt > 1) selectedAt - 1 else 0),
|
||||||
|
modifier = Modifier.focusRestorer(chosen),
|
||||||
|
contentPadding = PaddingValues(start = 2.dp, end = 12.dp, top = 6.dp, bottom = 6.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(5.dp),
|
||||||
|
) {
|
||||||
|
items(options, key = { it.key }) { o ->
|
||||||
|
val modifier = if (o.index == selected) Modifier.focusRequester(chosen) else Modifier
|
||||||
|
Chip(o.label, o.index == selected, { onPick(o.index) }, modifier.remembered(focus, o.key), detail = o.detail)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun Cast(item: DetailsModel, focus: ScreenFocus, modifier: Modifier) {
|
||||||
|
Column(modifier.padding(top = 10.dp)) {
|
||||||
|
SectionTitle(item.castTitle, null)
|
||||||
|
DefaultScrolling {
|
||||||
|
LazyRow(
|
||||||
|
state = rememberLazyListState(),
|
||||||
|
modifier = Modifier.focusRestorer(),
|
||||||
|
contentPadding = PaddingValues(horizontal = AppDimens.CONTENT_START.dp, vertical = 15.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
items(item.cast, key = { it.key }) { p -> CastChip(p, Modifier.remembered(focus, p.key)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val CastFocus = FocusStyle(scale = 1.03f, corner = 27.5.dp, ring = 1.dp, glows = listOf(FocusStyle.Glow(17.dp, 0.dp, Color(0x737C5CFF))))
|
||||||
|
|
||||||
|
/** Actor pill: photo (or initials), name, role. Focusable for scrolling; opening a person comes with search (stage 8). */
|
||||||
|
@Composable
|
||||||
|
private fun CastChip(person: CastModel, modifier: Modifier) {
|
||||||
|
var focused by remember { mutableStateOf(false) }
|
||||||
|
val shape = RoundedCornerShape(27.5.dp)
|
||||||
|
Row(
|
||||||
|
modifier
|
||||||
|
.focusCard(CastFocus)
|
||||||
|
.height(55.dp)
|
||||||
|
.background(if (focused) Color(0xFF1F2238) else AppColors.Surface, shape)
|
||||||
|
.border(0.5.dp, if (focused) AppColors.Accent else Color(0xFF262A3A), shape)
|
||||||
|
.onFocusChanged { focused = it.isFocused }
|
||||||
|
.focusable()
|
||||||
|
.padding(start = 8.dp, end = 14.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(9.dp),
|
||||||
|
) {
|
||||||
|
if (person.image != null) {
|
||||||
|
RemoteImage(person.image, (L.AVATAR / 2).dp, Modifier.size(L.AVATAR.dp))
|
||||||
|
} else {
|
||||||
|
Avatar(person.name, person.key, L.AVATAR.dp, AppType.CardTitle.copy(fontSize = 14.sp), letters = person.letters)
|
||||||
|
}
|
||||||
|
Column {
|
||||||
|
BasicText(person.name, style = AppType.CardTitle.copy(fontSize = 12.sp, fontWeight = FontWeight.SemiBold), maxLines = 1)
|
||||||
|
person.role?.let {
|
||||||
|
BasicText(it, Modifier.padding(top = 2.dp).widthIn(max = 160.dp), style = AppType.Body.copy(fontSize = 9.5.sp, lineHeight = 11.sp, color = AppColors.TextDim), maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,264 @@
|
|||||||
|
package ru.stenfredd.movies.ui.details
|
||||||
|
|
||||||
|
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.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
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.ItemKind
|
||||||
|
import ru.stenfredd.movies.core.data.model.PersonKind
|
||||||
|
import ru.stenfredd.movies.playback.api.StreamSelection
|
||||||
|
import ru.stenfredd.movies.ui.Screen
|
||||||
|
import ru.stenfredd.movies.ui.components.PosterModel
|
||||||
|
import ru.stenfredd.movies.ui.components.RemoteImageSpec
|
||||||
|
import ru.stenfredd.movies.ui.model.CardMapper
|
||||||
|
import timber.log.Timber
|
||||||
|
import java.util.Locale
|
||||||
|
import java.util.UUID
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
/** One audio or subtitle choice; [index] is the server's stream index, null = subtitles off. */
|
||||||
|
@Immutable
|
||||||
|
data class TrackOption(val key: String, val index: Int?, val label: String, val detail: String?)
|
||||||
|
|
||||||
|
@Immutable
|
||||||
|
data class CastModel(val key: String, val name: String, val role: String?, val letters: String, val image: RemoteImageSpec?)
|
||||||
|
|
||||||
|
/** Everything the movie (or episode) page shows, as display strings. */
|
||||||
|
@Immutable
|
||||||
|
data class DetailsModel(
|
||||||
|
val id: UUID,
|
||||||
|
/** "ФИЛЬМ", "СЕРИЯ · S1 · E3". */
|
||||||
|
val label: String,
|
||||||
|
val title: String,
|
||||||
|
/** Episode title under the series title. */
|
||||||
|
val subtitle: String?,
|
||||||
|
val rating: String?,
|
||||||
|
val age: String?,
|
||||||
|
val year: String?,
|
||||||
|
val runtime: String?,
|
||||||
|
val genres: String?,
|
||||||
|
val description: String?,
|
||||||
|
val poster: RemoteImageSpec?,
|
||||||
|
val backdropUrl: String?,
|
||||||
|
val badges: List<String>,
|
||||||
|
val fileLine: String?,
|
||||||
|
val resumeMs: Long,
|
||||||
|
val remainingMinutes: Int?,
|
||||||
|
val favorite: Boolean,
|
||||||
|
val played: Boolean,
|
||||||
|
val audio: List<TrackOption>,
|
||||||
|
val subtitles: List<TrackOption>,
|
||||||
|
val castTitle: String,
|
||||||
|
val cast: List<CastModel>,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Immutable
|
||||||
|
data class DetailsState(
|
||||||
|
val loading: Boolean = true,
|
||||||
|
val error: String? = null,
|
||||||
|
val item: DetailsModel? = null,
|
||||||
|
/** Chosen audio stream index. */
|
||||||
|
val audio: Int? = null,
|
||||||
|
/** Chosen subtitle stream index, null = off. */
|
||||||
|
val subtitle: Int? = null,
|
||||||
|
val similar: List<PosterModel> = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Movie or episode page: details, the track choice passed on to the player, favourite and watched. */
|
||||||
|
@HiltViewModel(assistedFactory = DetailsViewModel.Factory::class)
|
||||||
|
class DetailsViewModel @AssistedInject constructor(
|
||||||
|
@Assisted private val itemId: UUID,
|
||||||
|
private val library: LibraryRepository,
|
||||||
|
private val watch: WatchRepository,
|
||||||
|
private val images: ImageUrls,
|
||||||
|
private val cards: CardMapper,
|
||||||
|
cache: SessionCache,
|
||||||
|
) : ViewModel() {
|
||||||
|
@AssistedFactory
|
||||||
|
interface Factory {
|
||||||
|
fun create(itemId: UUID): DetailsViewModel
|
||||||
|
}
|
||||||
|
|
||||||
|
private val _state = MutableStateFlow(DetailsState())
|
||||||
|
val state: StateFlow<DetailsState> = _state.asStateFlow()
|
||||||
|
|
||||||
|
/** The user picked tracks on this page: keep the choice over reloads. */
|
||||||
|
private var tracksChosen = false
|
||||||
|
private var similarTargets: Map<String, Screen> = emptyMap()
|
||||||
|
|
||||||
|
init {
|
||||||
|
viewModelScope.launch { load() }
|
||||||
|
// Back from the player (progress) or watched/favourite changed elsewhere: reload.
|
||||||
|
viewModelScope.launch { cache.invalidations.collect { load() } }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun similarTarget(key: String): Screen? = similarTargets[key]
|
||||||
|
|
||||||
|
fun selectAudio(index: Int?) {
|
||||||
|
tracksChosen = true
|
||||||
|
_state.update { it.copy(audio = index) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun selectSubtitle(index: Int?) {
|
||||||
|
tracksChosen = true
|
||||||
|
_state.update { it.copy(subtitle = index) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The player screen for this item: from the resume point unless [fromStart], with the chosen tracks. */
|
||||||
|
fun player(fromStart: Boolean): Screen.Player? {
|
||||||
|
val s = _state.value
|
||||||
|
val item = s.item ?: return null
|
||||||
|
return Screen.Player(
|
||||||
|
itemId = item.id,
|
||||||
|
engine = null,
|
||||||
|
startPositionMs = if (fromStart) 0 else item.resumeMs,
|
||||||
|
streams = StreamSelection(s.audio, s.subtitle),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun toggleFavorite() = toggle({ it.favorite }, { m, v -> m.copy(favorite = v) }) { watch.setFavorite(itemId, it) }
|
||||||
|
|
||||||
|
fun togglePlayed() = toggle({ it.played }, { m, v -> m.copy(played = v) }) { watch.setPlayed(itemId, it) }
|
||||||
|
|
||||||
|
/** Shows the new value at once and reverts it if the server refuses. */
|
||||||
|
private fun toggle(get: (DetailsModel) -> Boolean, set: (DetailsModel, Boolean) -> DetailsModel, send: suspend (Boolean) -> Unit) {
|
||||||
|
val item = _state.value.item ?: return
|
||||||
|
val value = !get(item)
|
||||||
|
_state.update { s -> s.copy(item = s.item?.let { set(it, value) }) }
|
||||||
|
viewModelScope.launch {
|
||||||
|
try {
|
||||||
|
send(value)
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Timber.w(e, "Details: update failed")
|
||||||
|
_state.update { s -> s.copy(item = s.item?.let { set(it, !value) }) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun load() {
|
||||||
|
try {
|
||||||
|
coroutineScope {
|
||||||
|
val similar = async { runCatching { library.similar(itemId) }.getOrDefault(emptyList()) }
|
||||||
|
val details = library.details(itemId)
|
||||||
|
val model = withContext(Dispatchers.Default) { details.toModel() }
|
||||||
|
_state.update { s ->
|
||||||
|
val media = details.media
|
||||||
|
val keep = tracksChosen && s.item != null
|
||||||
|
s.copy(
|
||||||
|
loading = false,
|
||||||
|
error = null,
|
||||||
|
item = model,
|
||||||
|
audio = if (keep) s.audio else media?.defaultAudioIndex ?: media?.audio?.firstOrNull()?.index,
|
||||||
|
subtitle = if (keep) s.subtitle else media?.defaultSubtitleIndex,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// Similar items only make sense for movies; an episode would get random other series.
|
||||||
|
val related = if (details.summary.kind == ItemKind.Episode) emptyList() else similar.await()
|
||||||
|
val (posters, targets) = withContext(Dispatchers.Default) {
|
||||||
|
related.map { cards.poster(it, SIMILAR) } to related.associate { cards.key(SIMILAR, it) to cards.target(it) }
|
||||||
|
}
|
||||||
|
similarTargets = targets
|
||||||
|
_state.update { it.copy(similar = posters) }
|
||||||
|
}
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Timber.w(e, "Details: 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 ItemDetails.toModel(): DetailsModel {
|
||||||
|
val item = summary
|
||||||
|
val episode = item.kind == ItemKind.Episode
|
||||||
|
val media = media
|
||||||
|
val directors = people.filter { it.kind == PersonKind.Director }.map { it.name }
|
||||||
|
val actors = people.filter { it.kind == PersonKind.Actor }.take(CAST_LIMIT)
|
||||||
|
val progress = item.user.progress
|
||||||
|
return DetailsModel(
|
||||||
|
id = item.id,
|
||||||
|
label = if (episode) {
|
||||||
|
listOfNotNull("СЕРИЯ", item.seasonNumber?.let { "S$it" }, item.episodeNumber?.let { "E$it" }).joinToString(" · ")
|
||||||
|
} else {
|
||||||
|
"ФИЛЬМ"
|
||||||
|
},
|
||||||
|
title = if (episode) item.seriesName ?: item.name else item.name,
|
||||||
|
subtitle = if (episode) item.name else null,
|
||||||
|
rating = item.communityRating?.let { String.format(Locale.US, "★ %.1f", it) },
|
||||||
|
// "RU-16+", "age6+" → "16+", "6+": the rating system's prefix says nothing on screen. "PG-13" stays.
|
||||||
|
age = item.officialRating?.let { r -> Regex("""(\d{1,2}\+)$""").find(r)?.value ?: r },
|
||||||
|
year = item.year?.toString(),
|
||||||
|
runtime = MediaLabels.runtime(item.runtimeMs),
|
||||||
|
genres = item.genres.take(3).joinToString(", ").ifEmpty { null },
|
||||||
|
description = item.overview,
|
||||||
|
poster = (if (episode) item.seriesPrimary ?: item.primary else item.primary)?.let {
|
||||||
|
RemoteImageSpec(images.url(it, cards.px(DetailsLayout.POSTER_W), cards.px(DetailsLayout.POSTER_H)), it.blurHash)
|
||||||
|
},
|
||||||
|
backdropUrl = item.backdrop?.let { images.url(it, cards.px(DetailsLayout.BACKDROP_W), cards.px(DetailsLayout.BACKDROP_H), quality = 80) },
|
||||||
|
badges = MediaLabels.badges(media),
|
||||||
|
fileLine = MediaLabels.fileLine(media),
|
||||||
|
resumeMs = item.user.positionMs.takeIf { progress != null } ?: 0,
|
||||||
|
remainingMinutes = if (progress != null && item.runtimeMs != null) (item.runtimeMs!! * (1 - progress) / 60_000).roundToInt() else null,
|
||||||
|
favorite = item.user.favorite,
|
||||||
|
played = item.user.played,
|
||||||
|
audio = media?.audio.orEmpty().map { TrackOption("audio/${it.index}", it.index, MediaLabels.audioLabel(it), MediaLabels.audioDetail(it)) },
|
||||||
|
subtitles = if (media == null || media.subtitles.isEmpty()) {
|
||||||
|
emptyList()
|
||||||
|
} else {
|
||||||
|
listOf(TrackOption("sub/off", null, "Выкл", null)) +
|
||||||
|
media.subtitles.map { TrackOption("sub/${it.index}", it.index, MediaLabels.subtitleLabel(it), MediaLabels.subtitleDetail(it)) }
|
||||||
|
},
|
||||||
|
castTitle = listOfNotNull("В ролях", directors.takeIf { it.isNotEmpty() }?.let { "режиссёр " + it.joinToString(", ") }).joinToString(" · "),
|
||||||
|
cast = actors.map { p ->
|
||||||
|
CastModel(
|
||||||
|
key = "cast/${p.id}",
|
||||||
|
name = p.name,
|
||||||
|
role = p.role,
|
||||||
|
letters = p.name.split(' ').filter { it.isNotBlank() }.take(2).joinToString("") { it.take(1).uppercase() },
|
||||||
|
image = p.image?.let { RemoteImageSpec(images.url(it, cards.px(DetailsLayout.AVATAR), cards.px(DetailsLayout.AVATAR)), it.blurHash) },
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val SIMILAR = "similar"
|
||||||
|
const val CAST_LIMIT = 20
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sizes (dp) of the page shared by the layout, the image requests and the baked backdrop. */
|
||||||
|
internal object DetailsLayout {
|
||||||
|
const val PAGE_W = 960f
|
||||||
|
const val PAGE_H = 540f
|
||||||
|
const val POSTER_X = 60f
|
||||||
|
const val POSTER_Y = 48f
|
||||||
|
const val POSTER_W = 220f
|
||||||
|
const val POSTER_H = 330f
|
||||||
|
const val POSTER_CORNER = 11f
|
||||||
|
/** The backdrop covers 70 % of the page width and height at the top right (see [BackdropTransformation]). */
|
||||||
|
const val BACKDROP_W = 672f
|
||||||
|
const val BACKDROP_H = 378f
|
||||||
|
const val AVATAR = 39f
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
package ru.stenfredd.movies.ui.details
|
||||||
|
|
||||||
|
import ru.stenfredd.movies.core.data.model.AudioTrack
|
||||||
|
import ru.stenfredd.movies.core.data.model.MediaInfo
|
||||||
|
import ru.stenfredd.movies.core.data.model.SubtitleTrack
|
||||||
|
import ru.stenfredd.movies.core.data.model.VideoRange
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
/** How the item page names languages, codecs and formats: "Русский", "TrueHD Atmos 7.1", "HEVC 2160p HDR10 · 62.4 ГБ". */
|
||||||
|
internal object MediaLabels {
|
||||||
|
private val russian = Locale.forLanguageTag("ru")
|
||||||
|
|
||||||
|
// The server gives ISO 639-2 codes; Locale knows most, but not the bibliographic variants (fre, ger, chi …).
|
||||||
|
private val languages = mapOf(
|
||||||
|
"rus" to "Русский", "eng" to "English", "ukr" to "Українська", "jpn" to "日本語", "kor" to "한국어",
|
||||||
|
"chi" to "中文", "zho" to "中文", "fre" to "Français", "fra" to "Français", "ger" to "Deutsch", "deu" to "Deutsch",
|
||||||
|
"spa" to "Español", "ita" to "Italiano", "por" to "Português", "pol" to "Polski", "tur" to "Türkçe",
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Language in its own name for the common ones (as the mockup: "Русский", "English"), else in Russian. */
|
||||||
|
fun language(code: String?): String? {
|
||||||
|
val c = code?.lowercase()?.takeIf { it.isNotBlank() && it != "und" } ?: return null
|
||||||
|
languages[c]?.let { return it }
|
||||||
|
val name = Locale.forLanguageTag(c).getDisplayLanguage(russian)
|
||||||
|
return name.takeIf { it.isNotBlank() && !it.equals(c, ignoreCase = true) }?.replaceFirstChar { it.titlecase(russian) } ?: c
|
||||||
|
}
|
||||||
|
|
||||||
|
fun audioLabel(track: AudioTrack): String =
|
||||||
|
listOfNotNull(language(track.language), track.name).joinToString(" · ").ifEmpty { track.title }
|
||||||
|
|
||||||
|
/** "TrueHD Atmos 7.1" */
|
||||||
|
fun audioDetail(track: AudioTrack): String? = listOfNotNull(
|
||||||
|
audioCodec(track.codec, track.profile),
|
||||||
|
"Atmos".takeIf { track.atmos },
|
||||||
|
channels(track.channels),
|
||||||
|
).joinToString(" ").ifEmpty { null }
|
||||||
|
|
||||||
|
fun subtitleLabel(track: SubtitleTrack): String {
|
||||||
|
val name = track.name ?: "надписи".takeIf { track.forced }
|
||||||
|
return listOfNotNull(language(track.language), name).joinToString(" · ").ifEmpty { track.title }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun subtitleDetail(track: SubtitleTrack): String? = when (val codec = track.codec?.lowercase()) {
|
||||||
|
null -> null
|
||||||
|
"subrip", "srt" -> "SRT"
|
||||||
|
"ass", "ssa" -> "ASS"
|
||||||
|
"pgssub", "hdmv_pgs_subtitle" -> "PGS"
|
||||||
|
"dvdsub", "dvd_subtitle", "vobsub" -> "VOBSUB"
|
||||||
|
"webvtt", "vtt" -> "VTT"
|
||||||
|
"mov_text" -> "TX3G"
|
||||||
|
else -> codec.uppercase()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "4K", "HDR10", "Atmos": resolution, dynamic range and the best sound of the file. */
|
||||||
|
fun badges(media: MediaInfo?): List<String> {
|
||||||
|
media ?: return emptyList()
|
||||||
|
val video = media.video
|
||||||
|
val width = video?.width ?: 0
|
||||||
|
val height = video?.height ?: 0
|
||||||
|
return buildList {
|
||||||
|
when {
|
||||||
|
width >= 3200 || height >= 2000 -> add("4K")
|
||||||
|
width >= 1800 || height >= 1000 -> add("1080p")
|
||||||
|
width >= 1200 || height >= 700 -> add("720p")
|
||||||
|
}
|
||||||
|
range(video?.range)?.let(::add)
|
||||||
|
val audio = media.audio
|
||||||
|
when {
|
||||||
|
audio.any { it.atmos } -> add("Atmos")
|
||||||
|
audio.any { (it.channels ?: 0) >= 8 } -> add("7.1")
|
||||||
|
audio.any { (it.channels ?: 0) >= 6 } -> add("5.1")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "HEVC 2160p HDR10 · 62.4 ГБ" */
|
||||||
|
fun fileLine(media: MediaInfo?): String? {
|
||||||
|
media ?: return null
|
||||||
|
val video = media.video
|
||||||
|
val format = listOfNotNull(
|
||||||
|
videoCodec(video?.codec),
|
||||||
|
video?.height?.let { "${it}p" },
|
||||||
|
range(video?.range)?.let { if (it == "DV" && video?.dvProfile != null) "DV P${video.dvProfile}" else it },
|
||||||
|
).joinToString(" ")
|
||||||
|
val size = media.sizeBytes?.takeIf { it > 0 }?.let { String.format(Locale.US, "%.1f ГБ", it / 1e9) }
|
||||||
|
return listOfNotNull(format.ifEmpty { null }, size).joinToString(" · ").ifEmpty { null }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "2 ч 14 мин" */
|
||||||
|
fun runtime(ms: Long?): String? {
|
||||||
|
val minutes = ((ms ?: return null) / 60_000).toInt().takeIf { it > 0 } ?: return null
|
||||||
|
return if (minutes >= 60) "${minutes / 60} ч ${minutes % 60} мин" else "$minutes мин"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun range(range: VideoRange?): String? = when (range) {
|
||||||
|
VideoRange.HDR10 -> "HDR10"
|
||||||
|
VideoRange.HDR10Plus -> "HDR10+"
|
||||||
|
VideoRange.HLG -> "HLG"
|
||||||
|
VideoRange.DolbyVision -> "DV"
|
||||||
|
VideoRange.Other -> "HDR"
|
||||||
|
VideoRange.SDR, null -> null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun videoCodec(codec: String?): String? = when (codec?.lowercase()) {
|
||||||
|
null -> null
|
||||||
|
"hevc", "h265" -> "HEVC"
|
||||||
|
"h264", "avc" -> "H.264"
|
||||||
|
"mpeg2video" -> "MPEG-2"
|
||||||
|
"mpeg4" -> "MPEG-4"
|
||||||
|
"vc1" -> "VC-1"
|
||||||
|
else -> codec.uppercase()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun audioCodec(codec: String?, profile: String?): String? = when (codec?.lowercase()) {
|
||||||
|
null -> null
|
||||||
|
"ac3" -> "AC3"
|
||||||
|
"eac3" -> "E-AC3"
|
||||||
|
"truehd" -> "TrueHD"
|
||||||
|
"dts" -> when {
|
||||||
|
profile == null -> "DTS"
|
||||||
|
profile.contains("DTS:X") -> "DTS:X"
|
||||||
|
profile.contains("MA") -> "DTS-HD MA"
|
||||||
|
profile.contains("HRA") || profile.contains("HD") -> "DTS-HD"
|
||||||
|
else -> "DTS"
|
||||||
|
}
|
||||||
|
"aac" -> "AAC"
|
||||||
|
"flac" -> "FLAC"
|
||||||
|
"opus" -> "Opus"
|
||||||
|
"mp3" -> "MP3"
|
||||||
|
"pcm_s16le", "pcm_s24le", "pcm_bluray" -> "PCM"
|
||||||
|
else -> codec.uppercase()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun channels(n: Int?): String? = when (n) {
|
||||||
|
null, 0 -> null
|
||||||
|
1 -> "1.0"
|
||||||
|
2 -> "2.0"
|
||||||
|
6 -> "5.1"
|
||||||
|
8 -> "7.1"
|
||||||
|
else -> "${n}ch"
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -13,7 +13,7 @@ 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 → back. The device must be logged in.
|
* each → open a card, down through its page → back. 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` with the TV connected over adb. 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.
|
||||||
@@ -40,8 +40,16 @@ class BaselineProfileGenerator {
|
|||||||
}
|
}
|
||||||
repeat(ROWS) { press { device.pressDPadUp() } }
|
repeat(ROWS) { press { device.pressDPadUp() } }
|
||||||
|
|
||||||
|
// Item page: actions, track chips, cast, similar, and back up.
|
||||||
press { device.pressDPadCenter() }
|
press { device.pressDPadCenter() }
|
||||||
Thread.sleep(2_000)
|
Thread.sleep(2_000)
|
||||||
|
repeat(3) { press { device.pressDPadRight() } }
|
||||||
|
repeat(3) { press { device.pressDPadLeft() } }
|
||||||
|
repeat(4) {
|
||||||
|
press { device.pressDPadDown() }
|
||||||
|
repeat(3) { press { device.pressDPadRight() } }
|
||||||
|
}
|
||||||
|
repeat(4) { press { device.pressDPadUp() } }
|
||||||
press { device.pressBack() }
|
press { device.pressBack() }
|
||||||
Thread.sleep(1_000)
|
Thread.sleep(1_000)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -144,8 +144,9 @@ private fun MediaSourceInfo.toMediaInfo(): MediaInfo {
|
|||||||
video = streams.firstOrNull { it.type == MediaStreamType.VIDEO }?.toVideo(),
|
video = streams.firstOrNull { it.type == MediaStreamType.VIDEO }?.toVideo(),
|
||||||
audio = streams.filter { it.type == MediaStreamType.AUDIO }.map { it.toAudio(defaultAudioStreamIndex) },
|
audio = streams.filter { it.type == MediaStreamType.AUDIO }.map { it.toAudio(defaultAudioStreamIndex) },
|
||||||
subtitles = streams.filter { it.type == MediaStreamType.SUBTITLE }.map { it.toSubtitle(defaultSubtitleStreamIndex) },
|
subtitles = streams.filter { it.type == MediaStreamType.SUBTITLE }.map { it.toSubtitle(defaultSubtitleStreamIndex) },
|
||||||
defaultAudioIndex = defaultAudioStreamIndex,
|
defaultAudioIndex = defaultAudioStreamIndex?.takeIf { it >= 0 },
|
||||||
defaultSubtitleIndex = defaultSubtitleStreamIndex,
|
// -1 is the server's "no subtitles".
|
||||||
|
defaultSubtitleIndex = defaultSubtitleStreamIndex?.takeIf { it >= 0 },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -171,8 +172,10 @@ private fun VideoRangeType.toRange(): VideoRange = when {
|
|||||||
private fun MediaStream.toAudio(defaultIndex: Int?) = AudioTrack(
|
private fun MediaStream.toAudio(defaultIndex: Int?) = AudioTrack(
|
||||||
index = index,
|
index = index,
|
||||||
title = displayTitle ?: title ?: codec.orEmpty(),
|
title = displayTitle ?: title ?: codec.orEmpty(),
|
||||||
|
name = title?.takeIf { it.isNotBlank() },
|
||||||
language = language,
|
language = language,
|
||||||
codec = codec,
|
codec = codec,
|
||||||
|
profile = profile,
|
||||||
channels = channels,
|
channels = channels,
|
||||||
atmos = audioSpatialFormat == AudioSpatialFormat.DOLBY_ATMOS || profile?.contains("Atmos", ignoreCase = true) == true,
|
atmos = audioSpatialFormat == AudioSpatialFormat.DOLBY_ATMOS || profile?.contains("Atmos", ignoreCase = true) == true,
|
||||||
isDefault = index == defaultIndex,
|
isDefault = index == defaultIndex,
|
||||||
@@ -181,6 +184,7 @@ private fun MediaStream.toAudio(defaultIndex: Int?) = AudioTrack(
|
|||||||
private fun MediaStream.toSubtitle(defaultIndex: Int?) = SubtitleTrack(
|
private fun MediaStream.toSubtitle(defaultIndex: Int?) = SubtitleTrack(
|
||||||
index = index,
|
index = index,
|
||||||
title = displayTitle ?: title ?: codec.orEmpty(),
|
title = displayTitle ?: title ?: codec.orEmpty(),
|
||||||
|
name = title?.takeIf { it.isNotBlank() },
|
||||||
language = language,
|
language = language,
|
||||||
codec = codec,
|
codec = codec,
|
||||||
external = isExternal,
|
external = isExternal,
|
||||||
|
|||||||
@@ -143,9 +143,14 @@ enum class VideoRange { SDR, HDR10, HDR10Plus, HLG, DolbyVision, Other }
|
|||||||
|
|
||||||
data class AudioTrack(
|
data class AudioTrack(
|
||||||
val index: Int,
|
val index: Int,
|
||||||
|
/** Server's display title ("Russian - Dolby TrueHD + Dolby Atmos 7.1 - Default"). */
|
||||||
val title: String,
|
val title: String,
|
||||||
|
/** The stream's own title from the file ("MVO, HDRezka"): tells apart tracks of one language. */
|
||||||
|
val name: String?,
|
||||||
val language: String?,
|
val language: String?,
|
||||||
val codec: String?,
|
val codec: String?,
|
||||||
|
/** Codec profile ("DTS-HD MA"). */
|
||||||
|
val profile: String?,
|
||||||
val channels: Int?,
|
val channels: Int?,
|
||||||
/** Dolby Atmos (TrueHD or E-AC-3 JOC). */
|
/** Dolby Atmos (TrueHD or E-AC-3 JOC). */
|
||||||
val atmos: Boolean,
|
val atmos: Boolean,
|
||||||
@@ -155,6 +160,8 @@ data class AudioTrack(
|
|||||||
data class SubtitleTrack(
|
data class SubtitleTrack(
|
||||||
val index: Int,
|
val index: Int,
|
||||||
val title: String,
|
val title: String,
|
||||||
|
/** The stream's own title from the file ("Надписи", "Full"). */
|
||||||
|
val name: String?,
|
||||||
val language: String?,
|
val language: String?,
|
||||||
val codec: String?,
|
val codec: String?,
|
||||||
val external: Boolean,
|
val external: Boolean,
|
||||||
|
|||||||
Reference in New Issue
Block a user