Home screen and movie page

This commit is contained in:
2026-10-14 13:13:42 +03:00
parent c56a74a3a0
commit 5ddc026102
15 changed files with 2185 additions and 2173 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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