diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 35cdaf8..66fe958 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -20,7 +20,12 @@ android { buildTypes { release { - isMinifyEnabled = false + // Скорость интерфейса проверяем только на этой сборке: R8 + baseline profile. + // Пока своего ключа нет, подписываем debug-ключом, чтобы ставилась на ТВ. + isMinifyEnabled = true + isShrinkResources = true + proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") + signingConfig = signingConfigs.getByName("debug") } } @@ -59,9 +64,13 @@ dependencies { implementation(libs.compose.ui.tooling.preview) debugImplementation(libs.compose.ui.tooling) implementation(libs.tv.material) + implementation(libs.navigation3.runtime) + implementation(libs.navigation3.ui) + implementation(libs.androidx.lifecycle.viewmodel.navigation3) implementation(libs.coil.compose) implementation(libs.coil.network.okhttp) implementation(libs.hilt.android) ksp(libs.hilt.compiler) implementation(libs.timber) + implementation(libs.androidx.profileinstaller) } diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..32fc1d8 --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,17 @@ +# Правила R8 для release-сборки. + +# Обратные вызовы из нативного кода ищут классы, поля и методы по имени. +-keep class org.videolan.libvlc.** { *; } +-keep class androidx.media3.decoder.ffmpeg.** { *; } + +# ass-media (libass) читает поля MatroskaExtractor рефлексией (extractorOutput, subtitleSample), +# и у неё самой JNI. +-keep class io.github.peerless2012.ass.** { *; } +-keep class androidx.media3.extractor.mkv.MatroskaExtractor { *; } + +# Стек-трейсы в логах остаются читаемыми. +-keepattributes SourceFile,LineNumberTable + +# Open-source app: obfuscation gives nothing, and readable class names keep log tags +# (Timber uses the class name) and stack traces usable. R8 still shrinks and optimises. +-dontobfuscate diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 33c24ea..2d49b72 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -15,7 +15,7 @@ android:label="@string/app_name" android:supportsRtl="true" android:networkSecurityConfig="@xml/network_security_config" - android:theme="@android:style/Theme.DeviceDefault.NoActionBar"> + android:theme="@style/Theme.Movies"> () } - var ready by remember { mutableStateOf(false) } - LaunchedEffect(Unit) { - stack += if (session.restore()) Screen.Home else Screen.Login - if (stack.last() == Screen.Home) debugTarget()?.let(stack::addAll) - ready = true - } - fun open(screen: Screen) = stack.add(screen) - BackHandler(enabled = stack.size > 1) { stack.removeAt(stack.lastIndex) } - - if (!ready) { - Box(Modifier.fillMaxSize()) { Text("Загрузка…") } - return@Surface - } - when (val screen = stack.last()) { - Screen.Login -> LoginScreen(onLoggedIn = { - stack.clear() - stack += Screen.Home - }) - Screen.Home -> HomeScreen(onOpen = ::open, onLogout = { - stack.clear() - stack += Screen.Login - }) - is Screen.Items -> ItemsScreen(screen, onOpen = ::open) - is Screen.Detail -> DetailScreen(screen, onOpen = ::open) - is Screen.Player -> PlayerScreen(screen, onExit = { stack.removeAt(stack.lastIndex) }) - is Screen.Tour -> TourScreen(screen.startIndex, onExit = { stack.removeAt(stack.lastIndex) }) - } + AppTheme { + val stack = remember { mutableStateListOf() } + var ready by remember { mutableStateOf(false) } + LaunchedEffect(Unit) { + val loggedIn = session.restore() + stack += if (loggedIn) Screen.Root(Tab.Home) else Screen.Login + if (loggedIn) intentTarget()?.let(stack::addAll) + ready = true } + if (ready) Navigation(stack) } } } + @Composable + private fun Navigation(stack: MutableList) { + fun open(screen: Screen) { + stack.add(screen) + } + fun back() { + if (stack.size > 1) stack.removeAt(stack.lastIndex) else finish() + } + fun resetTo(screen: Screen) { + stack.clear() + stack.add(screen) + } + + NavDisplay( + backStack = stack, + onBack = { back() }, + entryDecorators = listOf( + rememberSaveableStateHolderNavEntryDecorator(), + rememberViewModelStoreNavEntryDecorator(), + ), + // No screen transitions: two screens composed at once is expensive on weak TVs. + transitionSpec = { EnterTransition.None togetherWith ExitTransition.None }, + popTransitionSpec = { EnterTransition.None togetherWith ExitTransition.None }, + predictivePopTransitionSpec = { EnterTransition.None togetherWith ExitTransition.None }, + entryProvider = { screen -> + NavEntry(screen) { + when (screen) { + Screen.Login -> Legacy { LoginScreen(onLoggedIn = { resetTo(Screen.Root(Tab.Home)) }) } + is Screen.Root -> Root(screen.tab, onTab = { resetTo(Screen.Root(it)) }, 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.Detail -> Legacy { DetailScreen(screen, onOpen = ::open) } + is Screen.Player -> PlayerScreen(screen, onExit = ::back) + is Screen.Tour -> TourScreen(screen.startIndex, onExit = ::back) + } + } + }, + ) + } + + @Composable + private fun Root(tab: Tab, onTab: (Tab) -> Unit, onOpen: (Screen) -> Unit, onLogout: () -> Unit) { + val user by session.session.collectAsStateWithLifecycle() + Shell( + current = tab, + userName = user?.userName.orEmpty(), + onTab = onTab, + // Profile switching comes with the new login screens (stage 3); until then the prototype has "Выйти". + onProfile = { onOpen(Screen.Prototype) }, + ) { + when (tab) { + Tab.Home -> HomeScreen(onOpen = onOpen) + // Until stage 7 the library tab is the old prototype: libraries, text search, debug tour, logout. + Tab.Library -> Legacy { PrototypeScreen(onOpen = onOpen, onLogout = onLogout) } + Tab.Search, Tab.Favorites -> Placeholder("${tab.label}: скоро") + } + } + } + + /** Old prototype screens use tv-material components, which take their text colour from a Surface. */ + @Composable + private fun Legacy(content: @Composable () -> Unit) { + Surface(Modifier.fillMaxSize(), colors = SurfaceDefaults.colors(containerColor = Color.Transparent, contentColor = AppColors.Text)) { content() } + } + + @Composable + private fun Placeholder(text: String) { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { BasicText(text, style = AppType.Body) } + } + /** - * Debug-only shortcut for testing on a TV without typing: - * `adb shell am start -S -n ru.stenfredd.movies/.MainActivity --es item [--es engine exo|vlc] [--el start ] - * [--ei audio ] [--ei sub ]` (stream indexes as Jellyfin numbers them; with one given, the other is off), - * or `--ez tour true [--ei tour_file N]` for the debug tour (from file N). + * Opens an item from the launch intent: + * `adb shell am start -S -n ru.stenfredd.movies/.MainActivity --es item [--ez play true] [--el start ] + * [--ei audio ] [--ei sub ]` (stream indexes as Jellyfin numbers them; with one given, the other is off). + * Debug builds also take `--es engine exo|vlc` (implies play) and `--ez tour true [--ei tour_file N]` for the debug tour. */ - private fun debugTarget(): List? { - if (!BuildConfig.DEBUG) return null - if (intent.getBooleanExtra("tour", false)) return listOf(Screen.Tour(intent.getIntExtra("tour_file", 1) - 1)) + private fun intentTarget(): List? { + if (BuildConfig.DEBUG && intent.getBooleanExtra("tour", false)) return listOf(Screen.Tour(intent.getIntExtra("tour_file", 1) - 1)) val raw = intent.getStringExtra("item") ?: return null - val id = UUID.fromString(if ('-' in raw) raw else raw.replace(Regex("(.{8})(.{4})(.{4})(.{4})(.{12})"), "$1-$2-$3-$4-$5")) - val engine = intent.getStringExtra("engine")?.let(::EngineId) - val play = intent.hasExtra("engine") || intent.getBooleanExtra("play", false) + val id = runCatching { + UUID.fromString(if ('-' in raw) raw else raw.replace(Regex("(.{8})(.{4})(.{4})(.{4})(.{12})"), "$1-$2-$3-$4-$5")) + }.getOrNull() ?: return null + val engine = if (BuildConfig.DEBUG) intent.getStringExtra("engine")?.let(::EngineId) else null + val play = engine != null || intent.getBooleanExtra("play", false) val streams = if (intent.hasExtra("audio") || intent.hasExtra("sub")) { StreamSelection(intent.getIntExtra("audio", -1).takeIf { it >= 0 }, intent.getIntExtra("sub", -1).takeIf { it >= 0 }) } else { diff --git a/app/src/main/kotlin/ru/stenfredd/movies/MoviesApp.kt b/app/src/main/kotlin/ru/stenfredd/movies/MoviesApp.kt index 85ffc5c..d0933af 100644 --- a/app/src/main/kotlin/ru/stenfredd/movies/MoviesApp.kt +++ b/app/src/main/kotlin/ru/stenfredd/movies/MoviesApp.kt @@ -1,13 +1,32 @@ package ru.stenfredd.movies import android.app.Application +import coil3.ImageLoader +import coil3.PlatformContext +import coil3.SingletonImageLoader +import coil3.disk.DiskCache +import coil3.disk.directory +import coil3.memory.MemoryCache +import coil3.request.allowRgb565 +import coil3.request.crossfade import dagger.hilt.android.HiltAndroidApp import timber.log.Timber @HiltAndroidApp -class MoviesApp : Application() { +class MoviesApp : Application(), SingletonImageLoader.Factory { override fun onCreate() { super.onCreate() Timber.plant(Timber.DebugTree()) } + + /** + * Images: posters and stills are opaque, so RGB_565 halves their memory; the memory cache is capped + * well below the heap (the video buffer needs it); the disk cache keeps posters across launches. + */ + override fun newImageLoader(context: PlatformContext): ImageLoader = ImageLoader.Builder(context) + .memoryCache { MemoryCache.Builder().maxSizePercent(context, 0.15).build() } + .diskCache { DiskCache.Builder().directory(context.cacheDir.resolve("images")).maxSizeBytes(200L * 1024 * 1024).build() } + .allowRgb565(true) + .crossfade(false) + .build() } diff --git a/app/src/main/kotlin/ru/stenfredd/movies/ui/BrowseScreens.kt b/app/src/main/kotlin/ru/stenfredd/movies/ui/BrowseScreens.kt index 05b0cb0..7314d3e 100644 --- a/app/src/main/kotlin/ru/stenfredd/movies/ui/BrowseScreens.kt +++ b/app/src/main/kotlin/ru/stenfredd/movies/ui/BrowseScreens.kt @@ -48,7 +48,7 @@ class BrowseViewModel @Inject constructor(val library: Library, val session: Jel private fun BaseItemDto.target(): Screen = if (isPlayable) Screen.Detail(id) else Screen.Items(id, name.orEmpty()) @Composable -fun HomeScreen(onOpen: (Screen) -> Unit, onLogout: () -> Unit, vm: BrowseViewModel = hiltViewModel()) { +fun PrototypeScreen(onOpen: (Screen) -> Unit, onLogout: () -> Unit, vm: BrowseViewModel = hiltViewModel()) { var views by remember { mutableStateOf(emptyList()) } var resume by remember { mutableStateOf(emptyList()) } var results by remember { mutableStateOf(emptyList()) } diff --git a/app/src/main/kotlin/ru/stenfredd/movies/ui/Library.kt b/app/src/main/kotlin/ru/stenfredd/movies/ui/Library.kt index 0631c5c..13a2fd3 100644 --- a/app/src/main/kotlin/ru/stenfredd/movies/ui/Library.kt +++ b/app/src/main/kotlin/ru/stenfredd/movies/ui/Library.kt @@ -38,6 +38,28 @@ class Library @Inject constructor(private val session: JellyfinSession) { ).content.items } + suspend fun latest(parentId: UUID? = null, limit: Int = 20): List = session.call { + libraryApi.getLatestMedia(userId = userId, parentId = parentId, limit = limit, groupItems = true).content + } + + /** Newest movies and series of a library, with the total count. */ + suspend fun libraryItems(parentId: UUID, limit: Int = 20): Pair, Int> = session.call { + val r = libraryApi.getItems( + userId = userId, + parentId = parentId, + recursive = true, + includeItemTypes = listOf(BaseItemKind.MOVIE, BaseItemKind.SERIES), + sortBy = listOf(ItemSortBy.DATE_CREATED), + sortOrder = listOf(org.jellyfin.sdk.model.api.SortOrder.DESCENDING), + limit = limit, + ).content + r.items to r.totalRecordCount + } + + /** Image of exactly the displayed size (px), so the TV only decodes. */ + fun imageUrl(id: UUID, type: ImageType, tag: String, widthPx: Int, heightPx: Int): String = + api.imageApi.getItemImageUrl(id, type, fillWidth = widthPx, fillHeight = heightPx, quality = 90, tag = tag) + suspend fun item(id: UUID): BaseItemDto = session.call { libraryApi.getItem(id, userId).content } fun posterUrl(item: BaseItemDto): String? { diff --git a/app/src/main/kotlin/ru/stenfredd/movies/ui/Screen.kt b/app/src/main/kotlin/ru/stenfredd/movies/ui/Screen.kt index b26bc73..ce4aeb4 100644 --- a/app/src/main/kotlin/ru/stenfredd/movies/ui/Screen.kt +++ b/app/src/main/kotlin/ru/stenfredd/movies/ui/Screen.kt @@ -6,7 +6,12 @@ import java.util.UUID sealed interface Screen { data object Login : Screen - data object Home : Screen + + /** Root screens with the side rail. */ + data class Root(val tab: ru.stenfredd.movies.ui.shell.Tab) : Screen + + /** Old prototype home (libraries, text search, logout, debug tour) while the real screens are being built. */ + data object Prototype : Screen data class Items(val parentId: UUID, val title: String) : Screen data class Detail(val itemId: UUID) : Screen data class Player( diff --git a/app/src/main/kotlin/ru/stenfredd/movies/ui/components/Buttons.kt b/app/src/main/kotlin/ru/stenfredd/movies/ui/components/Buttons.kt new file mode 100644 index 0000000..598710e --- /dev/null +++ b/app/src/main/kotlin/ru/stenfredd/movies/ui/components/Buttons.kt @@ -0,0 +1,121 @@ +package ru.stenfredd.movies.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicText +import androidx.compose.runtime.Composable +import androidx.compose.runtime.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.onFocusChanged +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import ru.stenfredd.movies.ui.theme.AppColors +import ru.stenfredd.movies.ui.theme.AppType + +/** Main action: gradient pill "▶ Смотреть". */ +@Composable +fun PrimaryButton(label: String, onClick: () -> Unit, modifier: Modifier = Modifier, icon: AppIcon? = AppIcon.Play, trailing: String? = null) { + Row( + modifier + .focusCard(FocusStyle(scale = 1.04f, corner = 19.dp, glows = FocusStyle.ButtonGlows)) + .height(38.dp) + .background(AppColors.PrimaryGradient, RoundedCornerShape(19.dp)) + .clickable(interactionSource = null, indication = null, onClick = onClick) + .padding(start = 16.dp, end = 20.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + icon?.let { Icon(it, Color.White, Modifier.size(14.dp)) } + BasicText(label, style = AppType.Button.copy(fontSize = AppType.CardTitle.fontSize)) + trailing?.let { BasicText(it, style = AppType.Body.copy(color = Color(0xCCFFFFFF))) } + } +} + +/** Secondary pill ("С начала") and round icon buttons: dark, filled light when focused. */ +@Composable +fun SecondaryButton(label: String, onClick: () -> Unit, modifier: Modifier = Modifier) { + var focused by remember { mutableStateOf(false) } + Box( + modifier + .focusCard(FocusStyle(scale = 1.04f, corner = 19.dp, glows = FocusStyle.ButtonGlows)) + .height(38.dp) + .background(if (focused) AppColors.FocusFill else AppColors.SurfaceRaised, RoundedCornerShape(19.dp)) + .border(0.5.dp, if (focused) Color.Transparent else AppColors.Border, RoundedCornerShape(19.dp)) + .onFocusChanged { focused = it.isFocused } + .clickable(interactionSource = null, indication = null, onClick = onClick) + .padding(horizontal = 16.dp), + contentAlignment = Alignment.Center, + ) { + BasicText(label, style = AppType.Button.copy(fontSize = AppType.CardTitle.fontSize, color = if (focused) AppColors.OnFocusFill else AppColors.Text)) + } +} + +@Composable +fun CircleButton(icon: AppIcon, onClick: () -> Unit, modifier: Modifier = Modifier, tint: Color = AppColors.Text, size: Dp = 38.dp) { + var focused by remember { mutableStateOf(false) } + Box( + modifier + .focusCard(FocusStyle(scale = 1.06f, corner = size / 2, glows = FocusStyle.ButtonGlows)) + .size(size) + .background(if (focused) AppColors.FocusFill else AppColors.SurfaceRaised, CircleShape) + .border(0.5.dp, if (focused) Color.Transparent else AppColors.Border, CircleShape) + .onFocusChanged { focused = it.isFocused } + .clickable(interactionSource = null, indication = null, onClick = onClick), + contentAlignment = Alignment.Center, + ) { + Icon(icon, if (focused) AppColors.OnFocusFill else tint, Modifier.size(size * 0.42f)) + } +} + +/** Option chip: "Русский TrueHD Atmos 7.1"; selected = violet. */ +@Composable +fun Chip(label: String, selected: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier, detail: String? = null) { + val shape = RoundedCornerShape(18.dp) + Row( + modifier + .focusCard(FocusStyle(scale = 1.04f, corner = 18.dp, glows = FocusStyle.ButtonGlows)) + .height(36.dp) + .background(if (selected) AppColors.Selected else AppColors.SurfaceRaised, shape) + .border(0.5.dp, if (selected) AppColors.SelectedBorder else AppColors.Border, shape) + .clickable(interactionSource = null, indication = null, onClick = onClick) + .padding(horizontal = 14.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + BasicText(label, style = AppType.Button.copy(fontSize = 12.sp, color = if (selected) AppColors.Text else AppColors.TextSecondary)) + detail?.let { BasicText(it, style = AppType.MonoCaption) } + } +} + +/** Format badge: "4K", "HDR10", "Atmos" with its colour. */ +@Composable +fun FormatBadge(text: String, modifier: Modifier = Modifier) { + val color = when (text) { + "4K" -> AppColors.Blue + "HDR10", "HDR10+", "DV", "HLG" -> AppColors.Yellow + "Atmos", "5.1", "7.1", "FLAC", "TrueHD", "DTS" -> AppColors.Pink + else -> AppColors.TextMuted + } + BasicText( + text, + modifier + .border(0.5.dp, color.copy(alpha = 0.6f), RoundedCornerShape(3.dp)) + .padding(horizontal = 4.dp, vertical = 1.5.dp), + style = AppType.MonoCaption.copy(color = color), + ) +} diff --git a/app/src/main/kotlin/ru/stenfredd/movies/ui/components/CardImageTransformation.kt b/app/src/main/kotlin/ru/stenfredd/movies/ui/components/CardImageTransformation.kt new file mode 100644 index 0000000..cf13e9d --- /dev/null +++ b/app/src/main/kotlin/ru/stenfredd/movies/ui/components/CardImageTransformation.kt @@ -0,0 +1,70 @@ +package ru.stenfredd.movies.ui.components + +import android.graphics.Bitmap +import android.graphics.BitmapShader +import android.graphics.Canvas +import android.graphics.LinearGradient +import android.graphics.Matrix +import android.graphics.Paint +import android.graphics.PorterDuff +import android.graphics.PorterDuffXfermode +import android.graphics.RectF +import android.graphics.Shader +import androidx.compose.runtime.Immutable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import coil3.size.Dimension +import coil3.size.Size +import coil3.transform.Transformation + +/** + * Bottom shade under text over an image: [stops] go from the bottom edge (0) to the top of the shade (1), + * which covers the bottom [height] fraction of the image. + */ +@Immutable +data class ImageShade(val height: Float, val stops: List>) { + val cacheKey = "$height:" + stops.joinToString(",") { "${it.first}=${it.second.toArgb()}" } +} + +/** + * Rounded corners and the text shade baked into the bitmap once, on Coil's decode thread. + * The TV this was measured on redraws the whole screen every frame (no dirty-region rendering), and every + * translucent layer over an image costs GPU time on every frame; baked, the card is a single bitmap draw. + */ +class CardImageTransformation(private val radiusPx: Float, private val shade: ImageShade?) : Transformation() { + override val cacheKey = "card:$radiusPx:${shade?.cacheKey}" + + override suspend fun transform(input: Bitmap, size: Size): Bitmap { + // Crop to the card's own size first: when the server image has other proportions (posters aren't all 2:3), + // cropping it later at draw time would cut the baked corners off. + 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 paint = Paint(Paint.ANTI_ALIAS_FLAG or Paint.FILTER_BITMAP_FLAG) + val scale = maxOf(w.toFloat() / input.width, h.toFloat() / input.height) + paint.shader = BitmapShader(input, Shader.TileMode.CLAMP, Shader.TileMode.CLAMP).apply { + setLocalMatrix( + Matrix().apply { + setScale(scale, scale) + postTranslate((w - input.width * scale) / 2f, (h - input.height * scale) / 2f) + }, + ) + } + canvas.drawRoundRect(RectF(0f, 0f, w.toFloat(), h.toFloat()), radiusPx, radiusPx, paint) + if (shade != null) { + val top = h * (1f - shade.height) + val gradient = Paint() + gradient.shader = LinearGradient( + 0f, h.toFloat(), 0f, top, + shade.stops.map { it.second.toArgb() }.toIntArray(), + shade.stops.map { it.first }.toFloatArray(), + Shader.TileMode.CLAMP, + ) + // Only over the image's own pixels, so the rounded corners stay transparent. + gradient.xfermode = PorterDuffXfermode(PorterDuff.Mode.SRC_ATOP) + canvas.drawRect(0f, top, w.toFloat(), h.toFloat(), gradient) + } + return output + } +} diff --git a/app/src/main/kotlin/ru/stenfredd/movies/ui/components/Cards.kt b/app/src/main/kotlin/ru/stenfredd/movies/ui/components/Cards.kt new file mode 100644 index 0000000..62db87c --- /dev/null +++ b/app/src/main/kotlin/ru/stenfredd/movies/ui/components/Cards.kt @@ -0,0 +1,243 @@ +package ru.stenfredd.movies.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.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.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicText +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithCache +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import ru.stenfredd.movies.ui.theme.AppColors +import ru.stenfredd.movies.ui.theme.AppDimens +import ru.stenfredd.movies.ui.theme.AppType + +/** What a poster card shows. Built by view models from server data; composables never see SDK DTOs. */ +@Immutable +data class PosterModel( + val key: String, + val title: String, + val caption: String?, + val imageUrl: String?, + /** Format badge at the bottom left: "4K", "1080p". */ + val badge: String? = null, + /** Watch progress 0..1, or null. */ + val progress: Float? = null, + val isNew: Boolean = false, + val watched: Boolean = false, +) + +/** Episode still or backdrop with a title over it: "Continue watching" cards. */ +@Immutable +data class WideModel( + val key: String, + val title: String, + val subtitle: String, + val imageUrl: String?, + val progress: Float?, +) + +private val PosterCorner = 7.dp +private val WideCorner = 8.dp +private val FeaturedCorner = 11.dp + +private val WideShade = ImageShade(0.7f, listOf(0f to Color(0xF006070C), 0.75f to Color(0x4006070C), 1f to Color.Transparent)) +private val FeaturedShade = ImageShade(0.8f, listOf(0f to Color(0xF506070C), 0.6f to Color(0x9906070C), 1f to Color.Transparent)) + +@Composable +fun PosterCard(model: PosterModel, onClick: () -> Unit, modifier: Modifier = Modifier) { + Column(modifier.width(AppDimens.POSTER_W.dp)) { + Box( + Modifier + .focusCard(FocusStyle.Poster) + .size(AppDimens.POSTER_W.dp, AppDimens.POSTER_H.dp) + .clickable(interactionSource = null, indication = null, onClick = onClick), + ) { + RemoteImage(model.imageUrl, PosterCorner, Modifier.fillMaxSize()) + if (model.isNew) NewBadge(Modifier.align(Alignment.TopStart).padding(6.dp)) + if (model.watched) WatchedBadge(Modifier.align(Alignment.TopEnd).padding(6.dp)) + // The mockup's bottom shade under the badge costs a card-sized blend; a small dark plate does the same job. + model.badge?.let { + BasicText( + it, + Modifier + .align(Alignment.BottomStart) + .padding(6.dp) + .background(Color(0x9906070C), RoundedCornerShape(3.dp)) + .padding(horizontal = 4.dp, vertical = 1.dp), + style = AppType.MonoCaption.copy(color = Color(0xD9FFFFFF)), + ) + } + model.progress?.let { + ProgressLine( + it, + Modifier.align(Alignment.BottomStart).padding(horizontal = 6.dp, vertical = 5.dp).fillMaxWidth().height(2.5.dp), + ) + } + } + BasicText( + model.title, + Modifier.padding(top = 8.dp), + style = AppType.PosterTitle, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + model.caption?.let { + BasicText(it, Modifier.padding(top = 3.dp), style = AppType.MonoCaption, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + } +} + +@Composable +fun WideCard(model: WideModel, onClick: () -> Unit, modifier: Modifier = Modifier) { + Box( + modifier + .focusCard(FocusStyle.Wide) + .size(AppDimens.WIDE_W.dp, AppDimens.WIDE_H.dp) + .clickable(interactionSource = null, indication = null, onClick = onClick), + ) { + RemoteImage(model.imageUrl, WideCorner, Modifier.fillMaxSize(), shade = WideShade) + Column(Modifier.align(Alignment.BottomStart).padding(start = 10.dp, end = 10.dp, bottom = 11.dp)) { + BasicText(model.title, style = AppType.CardTitle, maxLines = 1, overflow = TextOverflow.Ellipsis) + BasicText( + model.subtitle, + Modifier.padding(top = 3.dp), + style = AppType.MonoMeta, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + model.progress?.let { + ProgressLine(it, Modifier.align(Alignment.BottomStart).padding(horizontal = 8.dp, vertical = 5.dp).fillMaxWidth().height(3.dp)) + } + } +} + +/** The big first "Continue watching" card on the home screen. */ +@Immutable +data class FeaturedModel( + val key: String, + val label: String, + val title: String, + val subtitle: String, + val description: String?, + val imageUrl: String?, + val progress: Float, + val remaining: String?, +) + +@Composable +fun FeaturedCard(model: FeaturedModel, onClick: () -> Unit, modifier: Modifier = Modifier) { + Box( + modifier + .focusCard(FocusStyle.Featured) + .size(410.dp, 230.5.dp) + .clickable(interactionSource = null, indication = null, onClick = onClick), + ) { + RemoteImage(model.imageUrl, FeaturedCorner, Modifier.fillMaxSize(), shade = FeaturedShade) + Column(Modifier.align(Alignment.BottomStart).padding(start = 20.dp, end = 20.dp, bottom = 22.dp)) { + BasicText(model.label, style = AppType.MonoLabel) + BasicText( + model.title, + Modifier.padding(top = 5.dp), + style = AppType.Hero, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + BasicText(model.subtitle, Modifier.padding(top = 4.dp), style = AppType.BodyLarge, maxLines = 1, overflow = TextOverflow.Ellipsis) + model.description?.let { + BasicText( + it, + Modifier.padding(top = 7.dp).widthIn(max = 350.dp), + style = AppType.Body, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + Row(Modifier.padding(top = 11.dp), verticalAlignment = Alignment.CenterVertically) { + PlayPill("Продолжить") + Spacer(Modifier.width(10.dp)) + ProgressLine(model.progress, Modifier.weight(1f).height(3.dp)) + model.remaining?.let { + Spacer(Modifier.width(10.dp)) + BasicText(it, style = AppType.MonoMeta.copy(fontSize = AppType.MonoCount.fontSize)) + } + } + } + } +} + +/** Non-focusable gradient "▶ label" pill, part of a larger focusable card. */ +@Composable +fun PlayPill(label: String, modifier: Modifier = Modifier) { + Row( + modifier + .height(28.dp) + .background(AppColors.PrimaryGradient, RoundedCornerShape(14.dp)) + .padding(start = 9.dp, end = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + PlayIcon(Modifier.size(12.dp)) + BasicText(label, style = AppType.Button) + } +} + +/** Rounded track with a gradient fill, one draw call, no clipping. */ +@Composable +fun ProgressLine(fraction: Float, modifier: Modifier = Modifier) { + Spacer( + modifier.drawWithCache { + val r = CornerRadius(size.height / 2) + val fill = size.width * fraction.coerceIn(0f, 1f) + onDrawBehind { + drawRoundRect(AppColors.ProgressTrack, cornerRadius = r) + if (fill > 0f) drawRoundRect(AppColors.ProgressGradient, size = Size(fill, size.height), cornerRadius = r) + } + }, + ) +} + +@Composable +fun NewBadge(modifier: Modifier = Modifier) { + BasicText( + "НОВОЕ", + modifier + .background(AppColors.NewBadge, RoundedCornerShape(3.dp)) + .padding(horizontal = 4.5.dp, vertical = 2.dp), + style = AppType.MonoBadge.copy(color = Color.White), + ) +} + +@Composable +fun WatchedBadge(modifier: Modifier = Modifier) { + Box( + modifier + .size(18.dp) + .background(Color(0x2E7EE0A3), CircleShape) + .border(0.5.dp, AppColors.Green, CircleShape), + contentAlignment = Alignment.Center, + ) { + CheckIcon(Modifier.size(9.dp), AppColors.Green) + } +} diff --git a/app/src/main/kotlin/ru/stenfredd/movies/ui/components/FocusCard.kt b/app/src/main/kotlin/ru/stenfredd/movies/ui/components/FocusCard.kt new file mode 100644 index 0000000..40fd51a --- /dev/null +++ b/app/src/main/kotlin/ru/stenfredd/movies/ui/components/FocusCard.kt @@ -0,0 +1,136 @@ +package ru.stenfredd.movies.ui.components + +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.tween +import androidx.compose.runtime.Immutable +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusEventModifierNode +import androidx.compose.ui.focus.FocusState +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.ContentDrawScope +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.layout.Measurable +import androidx.compose.ui.layout.MeasureResult +import androidx.compose.ui.layout.MeasureScope +import androidx.compose.ui.node.DrawModifierNode +import androidx.compose.ui.node.LayoutModifierNode +import androidx.compose.ui.node.ModifierNodeElement +import androidx.compose.ui.node.invalidateDraw +import androidx.compose.ui.node.invalidatePlacement +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.launch +import ru.stenfredd.movies.ui.theme.AppColors + +/** Focus look of a card: scale, outer ring and glow, as `style-focus` in the mockup (sizes in dp). */ +@Immutable +data class FocusStyle( + val scale: Float, + val corner: Dp, + val ring: Dp = 1.5.dp, + val ringColor: Color = AppColors.Accent, + val glows: List = CardGlows, +) { + @Immutable + data class Glow(val blur: Dp, val spread: Dp, val color: Color) + + companion object { + /** `0 0 32px 6px rgba(124,92,255,.55), 0 0 90px 14px rgba(70,120,255,.22)` */ + val CardGlows = listOf( + Glow(16.dp, 3.dp, Color(0x8C7C5CFF)), + Glow(45.dp, 7.dp, Color(0x384678FF)), + ) + + /** `0 0 30px 6px rgba(124,92,255,.5)` — buttons and chips. */ + val ButtonGlows = listOf(Glow(15.dp, 3.dp, Color(0x807C5CFF))) + + val Poster = FocusStyle(scale = 1.07f, corner = 7.dp) + val Wide = FocusStyle(scale = 1.05f, corner = 8.dp) + val Featured = FocusStyle(scale = 1.025f, corner = 11.dp) + val Tile = FocusStyle(scale = 1.03f, corner = 11.dp, ring = 1.dp) + } +} + +/** + * Focus decoration for a card: animates scale, ring and glow when the element (or a child) gets focus. + * Put it before `clip` and `clickable`/`focusable` so the glow isn't clipped and focus events reach it. + * + * Implemented as a modifier node: the animation only touches the layer and draw phases, + * a focus change never recomposes the card. + */ +fun Modifier.focusCard(style: FocusStyle): Modifier = this then FocusCardElement(style) + +private data class FocusCardElement(val style: FocusStyle) : ModifierNodeElement() { + override fun create() = FocusCardNode(style) + override fun update(node: FocusCardNode) { + node.style = style + node.invalidateDraw() + } +} + +private class FocusCardNode(var style: FocusStyle) : Modifier.Node(), FocusEventModifierNode, LayoutModifierNode, DrawModifierNode { + private val progress = Animatable(0f) + private var focused = false + + override fun onFocusEvent(focusState: FocusState) { + if (focusState.hasFocus == focused) return + focused = focusState.hasFocus + if (!isAttached) return + invalidatePlacement() + coroutineScope.launch { progress.animateTo(if (focused) 1f else 0f, tween(if (focused) 180 else 140)) } + } + + override fun MeasureScope.measure(measurable: Measurable, constraints: Constraints): MeasureResult { + val placeable = measurable.measure(constraints) + return layout(placeable.width, placeable.height) { + // Focused card on top so its scaled edge and glow aren't covered by neighbours. + placeable.placeWithLayer(IntOffset.Zero, zIndex = if (focused) 1f else 0f) { + val s = 1f + (style.scale - 1f) * progress.value + scaleX = s + scaleY = s + } + } + } + + override fun ContentDrawScope.draw() { + val p = progress.value + if (p > 0f) { + val corner = style.corner.toPx() + if (style.glows.isNotEmpty()) drawGlow(corner, p) + val ring = style.ring.toPx() + drawRoundRect( + color = style.ringColor, + topLeft = Offset(-ring / 2, -ring / 2), + size = Size(size.width + ring, size.height + ring), + cornerRadius = CornerRadius(corner + ring / 2), + style = Stroke(ring), + alpha = p, + ) + } + drawContent() + } + + private fun ContentDrawScope.drawGlow(corner: Float, p: Float) { + val glow = GlowCache.get( + size.width, + size.height, + corner, + style.glows.map { GlowLayer(it.blur.toPx(), it.spread.toPx(), it.color) }, + ) + drawImage( + image = glow.bitmap, + dstOffset = IntOffset(-glow.marginPx.toInt(), -glow.marginPx.toInt()), + dstSize = IntSize( + (size.width + 2 * glow.marginPx).toInt(), + (size.height + 2 * glow.marginPx).toInt(), + ), + alpha = p, + ) + } +} diff --git a/app/src/main/kotlin/ru/stenfredd/movies/ui/components/Glow.kt b/app/src/main/kotlin/ru/stenfredd/movies/ui/components/Glow.kt new file mode 100644 index 0000000..2031cfa --- /dev/null +++ b/app/src/main/kotlin/ru/stenfredd/movies/ui/components/Glow.kt @@ -0,0 +1,65 @@ +package ru.stenfredd.movies.ui.components + +import android.graphics.Bitmap +import android.graphics.BlurMaskFilter +import android.graphics.Canvas +import android.graphics.Paint +import android.graphics.RectF +import android.util.LruCache +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.graphics.toArgb +import kotlin.math.roundToInt + +/** + * One soft glow layer, CSS `box-shadow: 0 0 `, sizes in px. + */ +data class GlowLayer(val blurPx: Float, val spreadPx: Float, val color: Color) + +/** + * Pre-rendered focus glows. The mockup's glows are 30–120 px blurred shadows; blurring them every frame + * is too slow for weak TVs, so each (size, shape, layers) combination is rendered once into a bitmap + * at half resolution (a blur loses nothing when upscaled) and then drawn as a plain image. + * Only the focused element draws its glow, and focusable elements of one kind share a size, + * so the cache stays small. + */ +object GlowCache { + private const val SCALE = 0.5f + + class Glow(val bitmap: ImageBitmap, val marginPx: Float) + + private data class Key(val w: Int, val h: Int, val corner: Int, val layers: List) + + // Bytes of all cached bitmaps; ~4 MB is a few dozen glows. + private val cache = object : LruCache(4 * 1024 * 1024) { + override fun sizeOf(key: Key, value: Glow) = value.bitmap.width * value.bitmap.height * 4 + } + + fun get(widthPx: Float, heightPx: Float, cornerPx: Float, layers: List): Glow { + val key = Key(widthPx.roundToInt(), heightPx.roundToInt(), cornerPx.roundToInt(), layers) + return cache.get(key) ?: render(key).also { cache.put(key, it) } + } + + private fun render(key: Key): Glow { + // A blurred edge fades out at roughly 1× the CSS blur radius beyond the spread. + val margin = key.layers.maxOf { it.blurPx + it.spreadPx } + val w = ((key.w + 2 * margin) * SCALE).roundToInt().coerceAtLeast(1) + val h = ((key.h + 2 * margin) * SCALE).roundToInt().coerceAtLeast(1) + val bitmap = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888) + val canvas = Canvas(bitmap) + canvas.scale(SCALE, SCALE) + val paint = Paint(Paint.ANTI_ALIAS_FLAG) + for (layer in key.layers) { + // CSS blur radius B is a Gaussian with sigma B/2; BlurMaskFilter's radius r has sigma ≈ 0.57735·r + 0.5. + val radius = ((layer.blurPx / 2 - 0.5f) / 0.57735f).coerceAtLeast(0.5f) + paint.maskFilter = BlurMaskFilter(radius, BlurMaskFilter.Blur.NORMAL) + paint.color = layer.color.toArgb() + val s = layer.spreadPx + val rect = RectF(margin - s, margin - s, margin + key.w + s, margin + key.h + s) + val corner = key.corner + s + canvas.drawRoundRect(rect, corner, corner, paint) + } + return Glow(bitmap.asImageBitmap(), margin) + } +} diff --git a/app/src/main/kotlin/ru/stenfredd/movies/ui/components/Icons.kt b/app/src/main/kotlin/ru/stenfredd/movies/ui/components/Icons.kt new file mode 100644 index 0000000..60059cb --- /dev/null +++ b/app/src/main/kotlin/ru/stenfredd/movies/ui/components/Icons.kt @@ -0,0 +1,59 @@ +package ru.stenfredd.movies.ui.components + +import androidx.compose.foundation.layout.Spacer +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithCache +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.StrokeJoin +import androidx.compose.ui.graphics.drawscope.CanvasDrawScope +import androidx.compose.ui.graphics.drawscope.Fill +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.drawscope.scale +import androidx.compose.ui.graphics.vector.PathParser +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.toSize +import kotlin.math.ceil + +/** + * 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. + */ +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"), + 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"), + 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"), + Check("M5 12.5l4.5 4.5L19 7.5"), + Plus("M12 5v14M5 12h14"), + Play("M7 4.5v15l13-7.5z", stroked = false), + ; + + internal val path: Path by lazy { PathParser().parsePathString(pathData).toPath() } +} + +@Composable +fun Icon(icon: AppIcon, color: Color, modifier: Modifier = Modifier, strokeWidth: Float = 2f) { + Spacer( + modifier.drawWithCache { + val bitmapSize = IntSize(ceil(size.width).toInt().coerceAtLeast(1), ceil(size.height).toInt().coerceAtLeast(1)) + val bitmap = ImageBitmap(bitmapSize.width, bitmapSize.height) + val k = size.minDimension / 24f + val style = if (icon.stroked) Stroke(strokeWidth, cap = StrokeCap.Round, join = StrokeJoin.Round) else Fill + CanvasDrawScope().draw(this, layoutDirection, androidx.compose.ui.graphics.Canvas(bitmap), bitmapSize.toSize()) { + scale(k, k, pivot = Offset.Zero) { drawPath(icon.path, color, style = style) } + } + onDrawBehind { drawImage(bitmap) } + }, + ) +} + +@Composable +fun PlayIcon(modifier: Modifier = Modifier, color: Color = Color.White) = Icon(AppIcon.Play, color, modifier) + +@Composable +fun CheckIcon(modifier: Modifier = Modifier, color: Color) = Icon(AppIcon.Check, color, modifier, strokeWidth = 3f) diff --git a/app/src/main/kotlin/ru/stenfredd/movies/ui/components/RemoteImage.kt b/app/src/main/kotlin/ru/stenfredd/movies/ui/components/RemoteImage.kt new file mode 100644 index 0000000..f5c4fe8 --- /dev/null +++ b/app/src/main/kotlin/ru/stenfredd/movies/ui/components/RemoteImage.kt @@ -0,0 +1,50 @@ +package ru.stenfredd.movies.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Dp +import coil3.compose.AsyncImage +import coil3.request.ImageRequest +import coil3.request.transformations +import ru.stenfredd.movies.ui.theme.AppColors + +/** + * Server image with rounded corners. + * + * Corners and the optional text [shade] are baked into the bitmap when it is decoded ([CardImageTransformation]), + * instead of clipping and blending every frame; the rounded placeholder is drawn only until the image arrives. + * The image size comes from the URL (the view model asks the server for exactly the displayed size), + * so Coil only decodes. No crossfade: it would redraw every visible card for its duration. + */ +@Composable +fun RemoteImage(url: String?, corner: Dp, modifier: Modifier = Modifier, shade: ImageShade? = null, contentScale: ContentScale = ContentScale.Crop) { + var loaded by remember(url) { mutableStateOf(false) } + val shape = remember(corner) { RoundedCornerShape(corner) } + Box(if (loaded) modifier else modifier.background(AppColors.SurfaceRaised, shape)) { + if (url != null) { + val context = LocalContext.current + val radius = with(LocalDensity.current) { corner.toPx() } + val request = remember(url, radius, shade) { + ImageRequest.Builder(context).data(url).transformations(CardImageTransformation(radius, shade)).build() + } + AsyncImage( + model = request, + contentDescription = null, + modifier = Modifier.fillMaxSize(), + contentScale = contentScale, + onSuccess = { loaded = true }, + ) + } + } +} diff --git a/app/src/main/kotlin/ru/stenfredd/movies/ui/components/Scrolling.kt b/app/src/main/kotlin/ru/stenfredd/movies/ui/components/Scrolling.kt new file mode 100644 index 0000000..add355f --- /dev/null +++ b/app/src/main/kotlin/ru/stenfredd/movies/ui/components/Scrolling.kt @@ -0,0 +1,88 @@ +package ru.stenfredd.movies.ui.components + +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.tween +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.ScrollState +import androidx.compose.foundation.gestures.BringIntoViewSpec +import androidx.compose.foundation.gestures.LocalBringIntoViewSpec +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.Stable +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.layout.LayoutCoordinates +import androidx.compose.ui.layout.onPlaced +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Dp +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch + +/** + * Vertical scrolling of a screen of rows, done by hand: Compose's own bring-into-view animation can't be + * slowed down (custom specs are no longer supported) and jumps too fast on a TV. + * + * When a row ([anchoredRow]) gets focus and isn't fully visible, the screen scrolls smoothly so the row's top + * sits [anchor] below the viewport top; a visible row doesn't scroll at all. Automatic vertical + * bring-into-view is switched off inside [content]; rows keep the platform horizontal behaviour, see [DefaultScrolling]. + */ +@Stable +class AnchoredScroll internal constructor(val scroll: ScrollState, private val scope: CoroutineScope, private val anchorPx: Float) { + internal var viewport: LayoutCoordinates? = null + private var job: Job? = null + + internal fun bring(row: LayoutCoordinates) { + val vp = viewport?.takeIf { it.isAttached } ?: return + if (!row.isAttached) return + val bounds = vp.localBoundingBoxOf(row, clipBounds = false) + if (bounds.top >= 0f && bounds.bottom <= vp.size.height) return + val target = (scroll.value + bounds.top - anchorPx).toInt().coerceIn(0, scroll.maxValue) + job?.cancel() + job = scope.launch { scroll.animateScrollTo(target, tween(ScrollMillis, easing = FastOutSlowInEasing)) } + } + + private companion object { + const val ScrollMillis = 450 + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun AnchoredScrolling(scroll: ScrollState, anchor: Dp, content: @Composable (AnchoredScroll) -> Unit) { + val default = LocalBringIntoViewSpec.current + val scope = rememberCoroutineScope() + val anchorPx = with(LocalDensity.current) { anchor.toPx() } + val state = remember(scroll, anchorPx) { AnchoredScroll(scroll, scope, anchorPx) } + CompositionLocalProvider(LocalBringIntoViewSpec provides NoScroll, LocalDefaultBringIntoViewSpec provides default) { + content(state) + } +} + +/** The scroll container's viewport: put it before `verticalScroll`. */ +fun Modifier.anchorViewport(state: AnchoredScroll): Modifier = onPlaced { state.viewport = it } + +/** A row of the screen: scrolled to the anchor when focus enters it. */ +fun Modifier.anchoredRow(state: AnchoredScroll): Modifier { + var coords: LayoutCoordinates? = null + return onPlaced { coords = it }.onFocusChanged { if (it.hasFocus) coords?.let(state::bring) } +} + +@OptIn(ExperimentalFoundationApi::class) +private val NoScroll = object : BringIntoViewSpec { + override fun calculateScrollDistance(offset: Float, size: Float, containerSize: Float) = 0f +} + +@OptIn(ExperimentalFoundationApi::class) +private val LocalDefaultBringIntoViewSpec = staticCompositionLocalOf { null } + +/** Restores the platform scrolling behaviour for nested (horizontal) lists inside [AnchoredScrolling]. */ +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun DefaultScrolling(content: @Composable () -> Unit) { + val default = LocalDefaultBringIntoViewSpec.current + if (default == null) content() else CompositionLocalProvider(LocalBringIntoViewSpec provides default, content = content) +} diff --git a/app/src/main/kotlin/ru/stenfredd/movies/ui/components/Section.kt b/app/src/main/kotlin/ru/stenfredd/movies/ui/components/Section.kt new file mode 100644 index 0000000..763a928 --- /dev/null +++ b/app/src/main/kotlin/ru/stenfredd/movies/ui/components/Section.kt @@ -0,0 +1,46 @@ +package ru.stenfredd.movies.ui.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.text.BasicText +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.focusRestorer +import androidx.compose.ui.unit.dp +import ru.stenfredd.movies.ui.theme.AppDimens +import ru.stenfredd.movies.ui.theme.AppType + +/** "Недавно добавлено 12" */ +@Composable +fun SectionTitle(title: String, count: Int?, modifier: Modifier = Modifier) { + Row(modifier.padding(horizontal = AppDimens.CONTENT_START.dp), verticalAlignment = Alignment.Bottom, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + BasicText(title, style = AppType.Section) + count?.let { BasicText(it.toString(), Modifier.padding(bottom = 1.dp), style = AppType.MonoCount) } + } +} + +/** + * A titled horizontal row. Returning to the row (↑/↓) focuses the card that was focused before, + * not the first visible one. + */ +@Composable +fun ContentRow(title: String, count: Int?, modifier: Modifier = Modifier, content: LazyListScope.() -> Unit) { + Column(modifier) { + SectionTitle(title, count) + DefaultScrolling { LazyRow( + state = rememberLazyListState(), + modifier = Modifier.focusRestorer(), + // Vertical padding leaves room for the focused card's scale and glow. + contentPadding = PaddingValues(horizontal = AppDimens.CONTENT_START.dp, vertical = 14.dp), + horizontalArrangement = Arrangement.spacedBy(AppDimens.ROW_GAP.dp), + content = content, + ) } + } +} diff --git a/app/src/main/kotlin/ru/stenfredd/movies/ui/focus/ScreenFocus.kt b/app/src/main/kotlin/ru/stenfredd/movies/ui/focus/ScreenFocus.kt new file mode 100644 index 0000000..2aa5578 --- /dev/null +++ b/app/src/main/kotlin/ru/stenfredd/movies/ui/focus/ScreenFocus.kt @@ -0,0 +1,62 @@ +package ru.stenfredd.movies.ui.focus + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.runtime.withFrameNanos +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged + +/** + * Remembers which element of a screen had focus and puts focus back there when the user returns + * (Back from a details page): navigation disposes the screen, its state holder keeps [lastKey] + * and the lazy lists' scroll positions, and [Restore] focuses the same card again. + * + * Every focusable element of the screen gets a stable [key] via [Modifier.remembered]. + * Only the element whose key matches gets a [FocusRequester], so the cost per card is one focus callback. + */ +@Stable +class ScreenFocus(initialKey: String?) { + var lastKey: String? by mutableStateOf(initialKey) + internal set + internal val requester = FocusRequester() +} + +private val ScreenFocusSaver = Saver( + save = { it.lastKey.orEmpty() }, + restore = { ScreenFocus(it.ifEmpty { null }) }, +) + +@Composable +fun rememberScreenFocus(): ScreenFocus { + return rememberSaveable(saver = ScreenFocusSaver) { ScreenFocus(null) } +} + +/** + * Marks a focusable element with [key]. [isDefault] marks the element to focus on the first visit. + */ +fun Modifier.remembered(focus: ScreenFocus, key: String, isDefault: Boolean = false): Modifier { + val target = focus.lastKey ?: if (isDefault) key else null + return this + .then(if (target == key) Modifier.focusRequester(focus.requester) else Modifier) + .onFocusChanged { if (it.isFocused) focus.lastKey = key } +} + +/** + * Focuses the remembered (or default) element once [ready] becomes true, after the content is laid out. + */ +@Composable +fun ScreenFocus.Restore(ready: Boolean) { + LaunchedEffect(ready) { + if (!ready) return@LaunchedEffect + withFrameNanos { } + runCatching { requester.requestFocus() } + } +} diff --git a/app/src/main/kotlin/ru/stenfredd/movies/ui/home/HomeScreen.kt b/app/src/main/kotlin/ru/stenfredd/movies/ui/home/HomeScreen.kt new file mode 100644 index 0000000..03df86a --- /dev/null +++ b/app/src/main/kotlin/ru/stenfredd/movies/ui/home/HomeScreen.kt @@ -0,0 +1,100 @@ +package ru.stenfredd.movies.ui.home + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.text.BasicText +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.focusRestorer +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import ru.stenfredd.movies.ui.Screen +import ru.stenfredd.movies.ui.components.AnchoredScrolling +import ru.stenfredd.movies.ui.components.anchorViewport +import ru.stenfredd.movies.ui.components.anchoredRow +import ru.stenfredd.movies.ui.components.ContentRow +import ru.stenfredd.movies.ui.components.FeaturedCard +import ru.stenfredd.movies.ui.components.PosterCard +import ru.stenfredd.movies.ui.components.SectionTitle +import ru.stenfredd.movies.ui.components.WideCard +import ru.stenfredd.movies.ui.focus.Restore +import ru.stenfredd.movies.ui.focus.rememberScreenFocus +import ru.stenfredd.movies.ui.focus.remembered +import ru.stenfredd.movies.ui.theme.AppDimens +import ru.stenfredd.movies.ui.theme.AppType + +@Composable +fun HomeScreen(onOpen: (Screen) -> Unit, vm: HomeViewModel = hiltViewModel()) { + val state by vm.state.collectAsStateWithLifecycle() + val focus = rememberScreenFocus() + val open = { key: String -> vm.target(key)?.let(onOpen) ?: Unit } + + when { + state.loading -> Message("Загрузка…") + state.error != null -> Message("Не удалось загрузить: ${state.error}") + // A plain scrolling column, not a LazyColumn: home has a handful of rows, and composing a row while it + // scrolls into view cost up to 50 ms frames on the test TV. All rows are composed once on load; + // the cards inside each row stay lazy. + else -> AnchoredScrolling(rememberScrollState(), anchor = AppDimens.CONTENT_TOP.dp) { anchored -> + Column( + Modifier + .fillMaxSize() + .anchorViewport(anchored) + .verticalScroll(anchored.scroll) + .padding(top = AppDimens.CONTENT_TOP.dp, bottom = 200.dp), + verticalArrangement = Arrangement.spacedBy(AppDimens.SECTION_GAP.dp - 14.dp), + ) { + state.featured?.let { featured -> + Column(Modifier.anchoredRow(anchored)) { + SectionTitle("Продолжить просмотр", state.continueCount) + Row( + Modifier.padding(start = AppDimens.CONTENT_START.dp, top = 13.dp, bottom = 14.dp).focusRestorer(), + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + FeaturedCard(featured, { open(featured.key) }, Modifier.remembered(focus, featured.key, isDefault = true)) + ContinueGrid(state, focus, open) + } + } + } + for (row in state.rows) { + key(row.key) { + ContentRow(row.title, row.count, Modifier.anchoredRow(anchored)) { + items(row.items, key = { it.key }, contentType = { "poster" }) { p -> + PosterCard(p, { open(p.key) }, Modifier.remembered(focus, p.key, isDefault = state.featured == null && p === state.rows.first().items.first())) + } + } + } + } + } + } + } + focus.Restore(ready = !state.loading && state.error == null) +} + +@Composable +private fun ContinueGrid(state: HomeState, focus: ru.stenfredd.movies.ui.focus.ScreenFocus, open: (String) -> Unit) { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + state.continueItems.chunked(2).forEach { pair -> + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + pair.forEach { w -> WideCard(w, { open(w.key) }, Modifier.remembered(focus, w.key)) } + } + } + } +} + +@Composable +private fun Message(text: String) { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { BasicText(text, style = AppType.Body) } +} diff --git a/app/src/main/kotlin/ru/stenfredd/movies/ui/home/HomeViewModel.kt b/app/src/main/kotlin/ru/stenfredd/movies/ui/home/HomeViewModel.kt new file mode 100644 index 0000000..b0d0d5b --- /dev/null +++ b/app/src/main/kotlin/ru/stenfredd/movies/ui/home/HomeViewModel.kt @@ -0,0 +1,194 @@ +package ru.stenfredd.movies.ui.home + +import android.content.Context +import androidx.compose.runtime.Immutable +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import org.jellyfin.sdk.model.api.BaseItemDto +import org.jellyfin.sdk.model.api.BaseItemKind +import org.jellyfin.sdk.model.api.ImageType +import ru.stenfredd.movies.ui.Library +import ru.stenfredd.movies.ui.Screen +import ru.stenfredd.movies.ui.components.FeaturedModel +import ru.stenfredd.movies.ui.components.PosterModel +import ru.stenfredd.movies.ui.components.WideModel +import ru.stenfredd.movies.ui.theme.AppDimens +import timber.log.Timber +import java.time.LocalDateTime +import javax.inject.Inject +import kotlin.math.roundToInt + +@Immutable +data class HomeRow(val key: String, val title: String, val count: Int, val items: List) + +@Immutable +data class HomeState( + val loading: Boolean = true, + val error: String? = null, + val featured: FeaturedModel? = null, + val continueItems: List = emptyList(), + val continueCount: Int = 0, + val rows: List = emptyList(), +) + +/** + * Home screen data. Stage 1 version straight on top of [Library]; stage 2 moves loading into repositories. + */ +@HiltViewModel +class HomeViewModel @Inject constructor( + private val library: Library, + @ApplicationContext context: Context, +) : ViewModel() { + private val density = context.resources.displayMetrics.density + private val _state = MutableStateFlow(HomeState()) + val state: StateFlow = _state.asStateFlow() + + /** Card key → where it leads. */ + private val targets = HashMap() + + init { load() } + + fun target(key: String): Screen? = targets[key] + + private fun load() = viewModelScope.launch { + val started = System.nanoTime() + runCatching { + coroutineScope { + val resume = async { library.resume() } + val latest = async { library.latest() } + val views = library.views() + val perView = views.map { v -> async { v to library.libraryItems(v.id) } } + val resumeItems = resume.await() + val rows = buildList { + val l = latest.await() + if (l.isNotEmpty()) add(HomeRow("latest", "Недавно добавлено", l.size, l.map { poster(it, "latest") })) + perView.awaitAll().forEach { (v, r) -> + val (items, total) = r + if (items.isNotEmpty()) add(HomeRow("lib-${v.id}", v.name.orEmpty(), total, items.map { poster(it, "lib-${v.id}") })) + } + } + HomeState( + loading = false, + featured = resumeItems.firstOrNull()?.let(::featured), + continueItems = resumeItems.drop(1).take(4).map(::wide), + continueCount = resumeItems.size, + rows = rows, + ) + } + }.onSuccess { + _state.value = it + Timber.i("Home loaded in %d ms", (System.nanoTime() - started) / 1_000_000) + }.onFailure { + Timber.w(it, "Home load failed") + _state.value = HomeState(loading = false, error = it.message ?: it.toString()) + } + } + + private fun px(dp: Float) = (dp * density).roundToInt() + + private fun poster(item: BaseItemDto, row: String): PosterModel { + val key = "$row/${item.id}" + targets[key] = item.target() + // A single new episode comes as the episode itself: show it as its series, poster included. + val episode = item.type == BaseItemKind.EPISODE + val imageId = if (episode) item.seriesId else item.id + val tag = if (episode) item.seriesPrimaryImageTag else item.imageTags?.get(ImageType.PRIMARY) + return PosterModel( + key = key, + title = (if (episode) item.seriesName else null) ?: item.name.orEmpty(), + caption = if (episode) { + listOfNotNull(item.parentIndexNumber?.let { "S$it" }, item.indexNumber?.let { "E$it" }).joinToString(" · ").ifEmpty { null } + } else { + listOfNotNull(item.productionYear?.toString(), item.communityRating?.let { "★ %.1f".format(it) }).joinToString(" · ").ifEmpty { null } + }, + imageUrl = if (imageId != null && tag != null) library.imageUrl(imageId, ImageType.PRIMARY, tag, px(AppDimens.POSTER_W.toFloat()), px(AppDimens.POSTER_H.toFloat())) else null, + badge = item.formatBadge(), + progress = item.userData?.playedPercentage?.takeIf { it > 0 && item.type != BaseItemKind.SERIES }?.let { (it / 100).toFloat() }, + isNew = item.dateCreated?.isAfter(LocalDateTime.now().minusDays(NEW_DAYS)) == true, + watched = item.userData?.played == true, + ) + } + + private fun wide(item: BaseItemDto): WideModel { + val key = "continue/${item.id}" + targets[key] = Screen.Detail(item.id) + return WideModel( + key = key, + title = item.seriesName ?: item.name.orEmpty(), + subtitle = listOfNotNull(item.episodeLabel() ?: item.productionYear?.toString(), item.remaining()).joinToString(" · "), + imageUrl = item.landscapeImage(px(AppDimens.WIDE_W.toFloat()), px(AppDimens.WIDE_H.toFloat())), + progress = item.progress(), + ) + } + + private fun featured(item: BaseItemDto): FeaturedModel { + val key = "continue/${item.id}" + targets[key] = Screen.Detail(item.id) + val isEpisode = item.type == BaseItemKind.EPISODE + return FeaturedModel( + key = key, + label = if (isEpisode) "СЕРИАЛ" else "ФИЛЬМ", + title = item.seriesName ?: item.name.orEmpty(), + subtitle = if (isEpisode) { + item.episodeLabel().orEmpty() + } else { + listOfNotNull(item.productionYear?.toString(), item.genres?.take(2)?.joinToString(", ")?.ifEmpty { null }).joinToString(" · ") + }, + description = item.overview, + imageUrl = item.backdrop(px(410f), px(230.5f)) ?: item.landscapeImage(px(410f), px(230.5f)), + progress = item.progress() ?: 0f, + remaining = item.remaining(), + ) + } + + private fun BaseItemDto.progress() = userData?.playedPercentage?.let { (it / 100).toFloat() } + + private fun BaseItemDto.remaining(): String? { + val ticks = runTimeTicks ?: return null + val left = ticks * (1 - (progress() ?: 0f)) / 600_000_000 + return "осталось ${left.roundToInt()} мин" + } + + private fun BaseItemDto.episodeLabel(): String? = + if (type == BaseItemKind.EPISODE && parentIndexNumber != null && indexNumber != null) "S$parentIndexNumber · E$indexNumber · $name" else null + + private fun BaseItemDto.backdrop(w: Int, h: Int): String? { + backdropImageTags?.firstOrNull()?.let { return library.imageUrl(id, ImageType.BACKDROP, it, w, h) } + val parent = parentBackdropItemId ?: return null + return parentBackdropImageTags?.firstOrNull()?.let { library.imageUrl(parent, ImageType.BACKDROP, it, w, h) } + } + + /** Episode still, else the item's or series' backdrop. */ + private fun BaseItemDto.landscapeImage(w: Int, h: Int): String? { + if (type == BaseItemKind.EPISODE) imageTags?.get(ImageType.PRIMARY)?.let { return library.imageUrl(id, ImageType.PRIMARY, it, w, h) } + return backdrop(w, h) + } + + private fun BaseItemDto.formatBadge(): String? { + val w = width ?: return null + return when { + w >= 3200 -> "4K" + w >= 1800 -> "1080p" + w >= 1200 -> "720p" + else -> null + } + } + + private fun BaseItemDto.target(): Screen = when (type) { + BaseItemKind.SERIES, BaseItemKind.SEASON, BaseItemKind.BOX_SET, BaseItemKind.FOLDER, BaseItemKind.COLLECTION_FOLDER -> Screen.Items(id, name.orEmpty()) + else -> Screen.Detail(id) + } + + private companion object { + const val NEW_DAYS = 14L + } +} diff --git a/app/src/main/kotlin/ru/stenfredd/movies/ui/shell/Shell.kt b/app/src/main/kotlin/ru/stenfredd/movies/ui/shell/Shell.kt new file mode 100644 index 0000000..68502c1 --- /dev/null +++ b/app/src/main/kotlin/ru/stenfredd/movies/ui/shell/Shell.kt @@ -0,0 +1,271 @@ +package ru.stenfredd.movies.ui.shell + +import androidx.activity.compose.BackHandler +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicText +import androidx.compose.runtime.Composable +import androidx.compose.runtime.State +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.draw.drawBehind +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.foundation.focusGroup +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.focusRestorer +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.lerp +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.util.lerp +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.zIndex +import kotlinx.coroutines.delay +import ru.stenfredd.movies.ui.components.AppIcon +import ru.stenfredd.movies.ui.components.Icon +import ru.stenfredd.movies.ui.theme.AppColors +import ru.stenfredd.movies.ui.theme.AppDimens +import ru.stenfredd.movies.ui.theme.AppType +import java.time.LocalTime +import java.time.format.DateTimeFormatter + +enum class Tab(val label: String, val icon: AppIcon) { + Search("Поиск", AppIcon.Search), + Home("Главная", AppIcon.Home), + Library("Медиатека", AppIcon.Library), + Favorites("Избранное", AppIcon.Heart), +} + +/** + * Root screens (the tabs): side rail on the left, clock on the top right, content behind. + * The rail overlaps the content and widens over it when focused, so opening it never relays out the content. + * + * Back on a tab moves focus to the rail; Back on the rail leaves the app. + */ +@Composable +fun Shell( + current: Tab, + userName: String, + onTab: (Tab) -> Unit, + onProfile: () -> Unit, + content: @Composable () -> Unit, +) { + var railFocused by remember { mutableStateOf(false) } + val railEntry = remember { FocusRequester() } + BackHandler(enabled = !railFocused) { runCatching { railEntry.requestFocus() } } + + Box(Modifier.fillMaxSize()) { + Box(Modifier.fillMaxSize().padding(start = 32.dp).focusGroup()) { content() } + Rail( + current = current, + userName = userName, + entry = railEntry, + onFocusChange = { railFocused = it }, + onTab = onTab, + onProfile = onProfile, + modifier = Modifier.zIndex(1f), + ) + Clock(Modifier.align(Alignment.TopEnd).padding(top = 26.dp, end = AppDimens.CONTENT_START.dp)) + } +} + +/** + * The rail's layout width switches once when it opens or closes; the visible animation (panel width, item + * highlight, labels, edge shadow) runs in the draw phase only, so a frame of it re-records the rail and nothing else. + */ +@Composable +private fun Rail( + current: Tab, + userName: String, + entry: FocusRequester, + onFocusChange: (Boolean) -> Unit, + onTab: (Tab) -> Unit, + onProfile: () -> Unit, + modifier: Modifier = Modifier, +) { + var open by remember { mutableStateOf(false) } + val progress = animateFloatAsState(if (open) 1f else 0f, tween(200), label = "rail") + + Column( + modifier + .fillMaxHeight() + .width(if (open) AppDimens.RAIL_OPEN.dp else AppDimens.RAIL_COLLAPSED.dp) + .drawBehind { + val p = progress.value + val w = lerp(AppDimens.RAIL_COLLAPSED.dp.toPx(), AppDimens.RAIL_OPEN.dp.toPx(), p) + drawRect(lerp(AppColors.Rail, AppColors.RailOpen, p), size = Size(w, size.height)) + drawRect(AppColors.BorderSubtle, topLeft = Offset(w - 1f, 0f), size = Size(1f, size.height)) + // Edge shadow of the open rail over the content (`30px 0 80px rgba(0,0,0,.6)`), a narrow strip. + if (p > 0f) { + val shadow = 40.dp.toPx() + drawRect( + Brush.horizontalGradient(listOf(Color.Black.copy(alpha = 0.5f * p), Color.Transparent), startX = w, endX = w + shadow), + topLeft = Offset(w, 0f), + size = Size(shadow, size.height), + ) + } + } + .onFocusChanged { + open = it.hasFocus + onFocusChange(it.hasFocus) + } + .focusRestorer(entry) + .focusGroup() + .padding(horizontal = 9.dp, vertical = 28.dp), + verticalArrangement = Arrangement.spacedBy(5.dp), + ) { + Logo(progress, Modifier.padding(start = 13.dp, bottom = 20.dp)) + for (tab in Tab.entries) { + RailItem( + tab = tab, + selected = tab == current, + progress = progress, + onClick = { if (tab != current) onTab(tab) }, + modifier = if (tab == current) Modifier.focusRequester(entry) else Modifier, + ) + } + Spacer(Modifier.weight(1f)) + ProfileItem(userName, progress, onProfile) + } +} + +/** Neutral placeholder logo until the app has a name: a glowing dot and the working title. */ +@Composable +private fun Logo(progress: State, modifier: Modifier = Modifier) { + Row(modifier.height(32.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) { + Box( + Modifier + .size(11.dp) + .drawBehind { + drawCircle(AppColors.AccentStrong.copy(alpha = 0.35f), radius = size.minDimension) + drawCircle(AppColors.AccentStrong) + }, + ) + BasicText( + "movies", + Modifier.graphicsLayer { alpha = progress.value }, + style = AppType.Rail.copy(fontSize = AppType.Section.fontSize, fontWeight = FontWeight.Bold), + maxLines = 1, + softWrap = false, + ) + } +} + +/** Focused item fill, growing with the rail panel. */ +private fun Modifier.railHighlight(focused: Boolean, color: Color, progress: State) = drawBehind { + if (!focused) return@drawBehind + val collapsed = (AppDimens.RAIL_COLLAPSED - 18).dp.toPx() + val w = lerp(collapsed, size.width, progress.value) + drawRoundRect(color, size = Size(w, size.height), cornerRadius = CornerRadius(8.dp.toPx())) +} + +@Composable +private fun RailItem(tab: Tab, selected: Boolean, progress: State, onClick: () -> Unit, modifier: Modifier = Modifier) { + var focused by remember { mutableStateOf(false) } + val content = when { + focused -> AppColors.OnFocusFill + selected -> AppColors.Text + else -> AppColors.TextDim + } + Box(modifier.fillMaxWidth().height(36.dp)) { + // Selected tab marker on the rail's left edge, visible while the rail is closed. + if (selected) { + Box( + Modifier + .padding(top = 9.dp, bottom = 9.dp) + .width(2.dp) + .fillMaxHeight() + .graphicsLayer { + translationX = -9.dp.toPx() + alpha = 1f - progress.value + } + .background(AppColors.Accent, RoundedCornerShape(1.dp)), + ) + } + Row( + Modifier + .fillMaxSize() + .railHighlight(focused, AppColors.FocusFill, progress) + .onFocusChanged { focused = it.isFocused } + .clickable(interactionSource = null, indication = null, onClick = onClick) + .padding(horizontal = 11.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(11.dp), + ) { + Icon(tab.icon, content, Modifier.size(16.dp)) + BasicText( + tab.label, + Modifier.graphicsLayer { alpha = progress.value }, + style = AppType.Rail.copy(color = content), + maxLines = 1, + softWrap = false, + ) + } + } +} + +@Composable +private fun ProfileItem(userName: String, progress: State, onClick: () -> Unit) { + var focused by remember { mutableStateOf(false) } + Row( + Modifier + .fillMaxWidth() + .height(38.dp) + .railHighlight(focused, AppColors.SurfaceFocused, progress) + .onFocusChanged { focused = it.isFocused } + .clickable(interactionSource = null, indication = null, onClick = onClick) + .padding(horizontal = 7.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(9.dp), + ) { + Box(Modifier.size(24.dp).background(AppColors.SelectedBorder, CircleShape), contentAlignment = Alignment.Center) { + BasicText(userName.take(1).uppercase(), style = AppType.CardTitle.copy(fontSize = AppType.PosterTitle.fontSize)) + } + Column(Modifier.graphicsLayer { alpha = progress.value }) { + BasicText(userName, style = AppType.CardTitle.copy(fontSize = AppType.Rail.fontSize), maxLines = 1, overflow = TextOverflow.Ellipsis, softWrap = false) + BasicText("сменить профиль", style = AppType.MonoCaption.copy(fontSize = AppType.MonoBadge.fontSize), maxLines = 1, softWrap = false) + } + } +} + +/** HH:mm, recomposes once a minute and only itself. */ +@Composable +fun Clock(modifier: Modifier = Modifier) { + var now by remember { mutableStateOf(LocalTime.now()) } + LaunchedEffect(Unit) { + while (true) { + delay(60_000L - (System.currentTimeMillis() % 60_000L)) + now = LocalTime.now() + } + } + BasicText(now.format(ClockFormat), modifier, style = AppType.Clock) +} + +private val ClockFormat = DateTimeFormatter.ofPattern("HH:mm") diff --git a/app/src/main/kotlin/ru/stenfredd/movies/ui/theme/Theme.kt b/app/src/main/kotlin/ru/stenfredd/movies/ui/theme/Theme.kt new file mode 100644 index 0000000..257aa40 --- /dev/null +++ b/app/src/main/kotlin/ru/stenfredd/movies/ui/theme/Theme.kt @@ -0,0 +1,15 @@ +package ru.stenfredd.movies.ui.theme + +import androidx.compose.runtime.Composable +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.darkColorScheme + +/** + * The app background is the window background (`values/themes.xml`), drawn once + * per frame by the system; Compose screens don't paint a background of their own unless they need a different one. + * The tv-material theme is only for the old prototype screens; new screens use [AppColors]/[AppType]. + */ +@Composable +fun AppTheme(content: @Composable () -> Unit) { + MaterialTheme(colorScheme = darkColorScheme(), content = content) +} diff --git a/app/src/main/kotlin/ru/stenfredd/movies/ui/theme/Tokens.kt b/app/src/main/kotlin/ru/stenfredd/movies/ui/theme/Tokens.kt new file mode 100644 index 0000000..63b2673 --- /dev/null +++ b/app/src/main/kotlin/ru/stenfredd/movies/ui/theme/Tokens.kt @@ -0,0 +1,117 @@ +package ru.stenfredd.movies.ui.theme + +import androidx.compose.runtime.Immutable +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.Font +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.LineHeightStyle +import androidx.compose.ui.unit.em +import androidx.compose.ui.unit.sp +import ru.stenfredd.movies.R + +/** + * Design tokens from `design/…/Jellyfin TV.dc.html`. The mockup is drawn at 1920×1080 px, + * the TV UI is 960×540 dp, so every size here is the mockup's px / 2. + */ +object AppColors { + val Background = Color(0xFF0A0B10) + val BackgroundDeep = Color(0xFF050609) + val DotGrid = Color(0xFF1B1E2B) + + val Surface = Color(0xFF12141C) + val SurfaceRaised = Color(0xFF151824) + val SurfaceFocused = Color(0xFF1D2031) + val Rail = Color(0xB30A0B10) + val RailOpen = Color(0xFF0D0E16) + + val Border = Color(0xFF2A2E40) + val BorderSubtle = Color(0xFF1D2030) + val Hairline = Color(0x14FFFFFF) + + val Text = Color(0xFFECEEF6) + val TextSecondary = Color(0xFFC3C7DD) + val TextMuted = Color(0xFFA3A8C3) + val TextDim = Color(0xFF8B90AD) + val TextFaint = Color(0xFF5F6582) + + val Accent = Color(0xFFA594FF) + val AccentStrong = Color(0xFF8F6BFF) + val AccentGlow = Color(0xFF7C5CFF) + val FocusFill = Color(0xFFECE9FF) + val OnFocusFill = Color(0xFF0B0C12) + val Selected = Color(0xFF1D1A3D) + val SelectedBorder = Color(0xFF5F55E6) + + val Green = Color(0xFF7EE0A3) + val Pink = Color(0xFFFF9CF9) + val Yellow = Color(0xFFFFD166) + val Blue = Color(0xFF64B5F6) + val Orange = Color(0xFFFFB454) + val Red = Color(0xFFFF6E6E) + val NewBadge = Color(0xFF4D7CFF) + + val PrimaryGradient = Brush.linearGradient(listOf(Color(0xFF4D7CFF), Color(0xFF8A5CFF))) + val ProgressGradient = Brush.horizontalGradient(listOf(Color(0xFF4D8BFF), Color(0xFF9B6BFF))) + val ProgressTrack = Color(0x24FFFFFF) +} + +object AppFonts { + val Onest = FontFamily( + Font(R.font.onest_400, FontWeight.Normal), + Font(R.font.onest_500, FontWeight.Medium), + Font(R.font.onest_600, FontWeight.SemiBold), + Font(R.font.onest_700, FontWeight.Bold), + Font(R.font.onest_800, FontWeight.ExtraBold), + ) + val Mono = FontFamily( + Font(R.font.jetbrains_mono_400, FontWeight.Normal), + Font(R.font.jetbrains_mono_500, FontWeight.Medium), + Font(R.font.jetbrains_mono_600, FontWeight.SemiBold), + ) +} + +/** Text styles used by the mockup, named by role. */ +@Immutable +object AppType { + private val base = TextStyle( + fontFamily = AppFonts.Onest, + color = AppColors.Text, + lineHeightStyle = LineHeightStyle(LineHeightStyle.Alignment.Center, LineHeightStyle.Trim.Both), + ) + private val mono = base.copy(fontFamily = AppFonts.Mono) + + val Hero = base.copy(fontSize = 30.sp, fontWeight = FontWeight.ExtraBold, letterSpacing = (-0.02).em) + val Display = base.copy(fontSize = 40.sp, fontWeight = FontWeight.ExtraBold, letterSpacing = (-0.02).em) + val Section = base.copy(fontSize = 15.sp, fontWeight = FontWeight.SemiBold) + val CardTitle = base.copy(fontSize = 13.sp, fontWeight = FontWeight.Bold) + val PosterTitle = base.copy(fontSize = 11.5.sp, fontWeight = FontWeight.SemiBold) + val Body = base.copy(fontSize = 11.sp, lineHeight = 16.sp, color = AppColors.TextMuted) + val BodyLarge = base.copy(fontSize = 13.sp, color = Color(0xFFD9DCEC)) + val Button = base.copy(fontSize = 11.5.sp, fontWeight = FontWeight.SemiBold) + val Rail = base.copy(fontSize = 13.5.sp, fontWeight = FontWeight.SemiBold) + + val MonoCount = mono.copy(fontSize = 10.sp, color = AppColors.TextFaint) + val MonoCaption = mono.copy(fontSize = 9.sp, color = AppColors.TextDim) + val MonoMeta = mono.copy(fontSize = 9.5.sp, color = AppColors.TextSecondary) + val MonoLabel = mono.copy(fontSize = 10.sp, letterSpacing = 0.12.em, color = AppColors.Accent) + val MonoBadge = mono.copy(fontSize = 8.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 0.06.em) + val Clock = mono.copy(fontSize = 13.sp, color = AppColors.TextMuted) +} + +/** Layout constants (dp), mockup px / 2. */ +object AppDimens { + const val RAIL_COLLAPSED = 56 + const val RAIL_OPEN = 190 + /** Content starts at 64 px + 120 px padding in the mockup. */ + const val CONTENT_START = 60 + const val CONTENT_TOP = 44 + const val ROW_GAP = 14 + const val SECTION_GAP = 22 + const val POSTER_W = 110 + const val POSTER_H = 165 + const val WIDE_W = 190 + const val WIDE_H = 107 +} diff --git a/app/src/main/res/drawable/banner.xml b/app/src/main/res/drawable/banner.xml index 6cd1f3b..41920ec 100644 --- a/app/src/main/res/drawable/banner.xml +++ b/app/src/main/res/drawable/banner.xml @@ -1,9 +1,12 @@ + - - + + + + diff --git a/app/src/main/res/font/jetbrains_mono_400.ttf b/app/src/main/res/font/jetbrains_mono_400.ttf new file mode 100644 index 0000000..4e6ef0d Binary files /dev/null and b/app/src/main/res/font/jetbrains_mono_400.ttf differ diff --git a/app/src/main/res/font/jetbrains_mono_500.ttf b/app/src/main/res/font/jetbrains_mono_500.ttf new file mode 100644 index 0000000..21b8c33 Binary files /dev/null and b/app/src/main/res/font/jetbrains_mono_500.ttf differ diff --git a/app/src/main/res/font/jetbrains_mono_600.ttf b/app/src/main/res/font/jetbrains_mono_600.ttf new file mode 100644 index 0000000..b1fba5e Binary files /dev/null and b/app/src/main/res/font/jetbrains_mono_600.ttf differ diff --git a/app/src/main/res/font/onest_400.ttf b/app/src/main/res/font/onest_400.ttf new file mode 100644 index 0000000..d973d2b Binary files /dev/null and b/app/src/main/res/font/onest_400.ttf differ diff --git a/app/src/main/res/font/onest_500.ttf b/app/src/main/res/font/onest_500.ttf new file mode 100644 index 0000000..cb6da7b Binary files /dev/null and b/app/src/main/res/font/onest_500.ttf differ diff --git a/app/src/main/res/font/onest_600.ttf b/app/src/main/res/font/onest_600.ttf new file mode 100644 index 0000000..bd48c9f Binary files /dev/null and b/app/src/main/res/font/onest_600.ttf differ diff --git a/app/src/main/res/font/onest_700.ttf b/app/src/main/res/font/onest_700.ttf new file mode 100644 index 0000000..7a14fcb Binary files /dev/null and b/app/src/main/res/font/onest_700.ttf differ diff --git a/app/src/main/res/font/onest_800.ttf b/app/src/main/res/font/onest_800.ttf new file mode 100644 index 0000000..1eb1638 Binary files /dev/null and b/app/src/main/res/font/onest_800.ttf differ diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..9cbbd6f --- /dev/null +++ b/app/src/main/res/values/colors.xml @@ -0,0 +1,4 @@ + + + #0A0B10 + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 7eb8492..5503312 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,4 +1,5 @@ - Movies (прототип) + + Movies diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..d3f9a2f --- /dev/null +++ b/app/src/main/res/values/themes.xml @@ -0,0 +1,10 @@ + + + + + diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b9f4464..784070f 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -18,6 +18,8 @@ libvlc = "3.7.7" jellyfin-sdk = "1.9.2" coil = "3.6.3" timber = "5.0.1" +profileinstaller = "1.4.1" +navigation3 = "1.2.0" okhttp = "5.5.0" slf4j = "2.0.20" @@ -48,6 +50,10 @@ jellyfin-sdk = { module = "org.jellyfin.sdk:jellyfin-core", version.ref = "jelly coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" } coil-network-okhttp = { module = "io.coil-kt.coil3:coil-network-okhttp", version.ref = "coil" } okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" } +androidx-profileinstaller = { module = "androidx.profileinstaller:profileinstaller", version.ref = "profileinstaller" } +navigation3-runtime = { module = "androidx.navigation3:navigation3-runtime", version.ref = "navigation3" } +navigation3-ui = { module = "androidx.navigation3:navigation3-ui", version.ref = "navigation3" } +androidx-lifecycle-viewmodel-navigation3 = { module = "androidx.lifecycle:lifecycle-viewmodel-navigation3", version.ref = "lifecycle" } timber = { module = "com.jakewharton.timber:timber", version.ref = "timber" } slf4j-api = { module = "org.slf4j:slf4j-api", version.ref = "slf4j" } diff --git a/tools/tv/env.sh b/tools/tv/env.sh index 33a21f0..bb878db 100755 --- a/tools/tv/env.sh +++ b/tools/tv/env.sh @@ -29,3 +29,15 @@ for s in json.load(sys.stdin): aout() { $A shell dumpsys media.audio_flinger | grep -E "^Output thread|Total writes|Tracks of which"; } # Журнал локального прокси VLC (диапазоны, кэш, сколько скачано с сервера) vproxy() { $A logcat -d | grep "VLC proxy" | sed -E 's/^[0-9-]+ ([0-9:.]+).*VLC proxy/\1/' | cut -c1-170; } +# Замер плавности интерфейса (только на release-сборке!): ui_bench [right|down] [N] +# Листает ряд (или экран) вперёд и назад N раз и печатает перцентили кадра и GPU, затем разбивку по фазам. +ui_bench() { + local dir=${1:-right} n=${2:-8} back P=ru.stenfredd.movies + [ "$dir" = right ] && back=left || back=up + $A shell dumpsys gfxinfo $P reset >/dev/null + for i in $(seq 1 $n); do $A shell input keyevent DPAD_${dir^^}; sleep 0.35; done + for i in $(seq 1 $n); do $A shell input keyevent DPAD_${back^^}; sleep 0.35; done + $A shell dumpsys gfxinfo $P | grep -E 'Janky frames:|50th percentile|90th percentile|99th percentile|50th gpu|90th gpu' + $A shell dumpsys gfxinfo $P framestats | python3 tools/tv/framestats.py +} +install_release() { ./gradlew :app:assembleRelease -q && $A install -r app/build/outputs/apk/release/app-release.apk; } diff --git a/tools/tv/framestats.py b/tools/tv/framestats.py new file mode 100644 index 0000000..ef7a09f --- /dev/null +++ b/tools/tv/framestats.py @@ -0,0 +1,24 @@ +#!/usr/bin/env python3 +# Разбивка времени кадра по фазам из `adb shell dumpsys gfxinfo <пакет> framestats` (stdin), см. ui_bench в env.sh. +import sys, statistics as st +lines=sys.stdin.read().splitlines() +hdr=None; rows=[] +for l in lines: + if l.startswith('Flags,'): hdr=l.strip().strip(',').split(','); continue + if hdr and l and l[0].isdigit(): + v=l.strip().strip(',').split(',') + if len(v)>=len(hdr): rows.append(dict(zip(hdr,map(int,v[:len(hdr)])))) + if l.startswith('---PROFILEDATA---') and rows: pass +rows=[r for r in rows if r['Flags']==0] +def ms(a,b): return [(r[b]-r[a])/1e6 for r in rows if r[a]>0 and r[b]>0] +def p(x): + x=sorted(x); return f"p50 {x[len(x)//2]:.1f} p90 {x[int(len(x)*.9)]:.1f} max {x[-1]:.1f}" if x else "-" +print("frames",len(rows)) +print("total ", p(ms('IntendedVsync','FrameCompleted'))) +print("input+anim ", p(ms('HandleInputStart','PerformTraversalsStart'))) +print("traversal ", p(ms('PerformTraversalsStart','DrawStart'))) +print("record draw", p(ms('DrawStart','SyncQueued'))) +print("sync ", p(ms('SyncStart','IssueDrawCommandsStart'))) +print("issue cmds ", p(ms('IssueDrawCommandsStart','SwapBuffers'))) +print("swap ", p(ms('SwapBuffers','FrameCompleted'))) +if 'GpuCompleted' in hdr: print("gpu ", p(ms('IssueDrawCommandsStart','GpuCompleted')))