TV UI foundation

This commit is contained in:
2026-10-14 10:50:40 +03:00
parent 69a793abcb
commit b86da05fee
40 changed files with 2075 additions and 55 deletions

View File

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

17
app/proguard-rules.pro vendored Normal file
View File

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

View File

@@ -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">
<activity
android:name=".MainActivity"

View File

@@ -0,0 +1,93 @@
Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at: https://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

View File

@@ -0,0 +1,93 @@
Copyright 2021 The Onest Project Authors (https://github.com/googlefonts/onest)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

View File

@@ -2,32 +2,47 @@ package ru.stenfredd.movies
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.BackHandler
import androidx.activity.compose.setContent
import androidx.compose.animation.EnterTransition
import androidx.compose.animation.ExitTransition
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.text.BasicText
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
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.tv.material3.MaterialTheme
import androidx.compose.ui.graphics.Color
import androidx.tv.material3.Surface
import androidx.tv.material3.Text
import androidx.tv.material3.darkColorScheme
import androidx.tv.material3.SurfaceDefaults
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
import androidx.navigation3.runtime.NavEntry
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
import androidx.navigation3.ui.NavDisplay
import dagger.hilt.android.AndroidEntryPoint
import ru.stenfredd.movies.core.jellyfin.JellyfinSession
import ru.stenfredd.movies.playback.api.EngineId
import ru.stenfredd.movies.playback.api.StreamSelection
import ru.stenfredd.movies.ui.DetailScreen
import ru.stenfredd.movies.ui.HomeScreen
import ru.stenfredd.movies.ui.ItemsScreen
import ru.stenfredd.movies.ui.LoginScreen
import ru.stenfredd.movies.ui.PlayerScreen
import ru.stenfredd.movies.ui.PrototypeScreen
import ru.stenfredd.movies.ui.Screen
import ru.stenfredd.movies.ui.TourScreen
import ru.stenfredd.movies.playback.api.EngineId
import ru.stenfredd.movies.playback.api.StreamSelection
import ru.stenfredd.movies.ui.home.HomeScreen
import ru.stenfredd.movies.ui.shell.Shell
import ru.stenfredd.movies.ui.shell.Tab
import ru.stenfredd.movies.ui.theme.AppColors
import ru.stenfredd.movies.ui.theme.AppTheme
import ru.stenfredd.movies.ui.theme.AppType
import java.util.UUID
import javax.inject.Inject
@@ -38,54 +53,104 @@ class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
MaterialTheme(colorScheme = darkColorScheme()) {
Surface(modifier = Modifier.fillMaxSize()) {
val stack = remember { mutableStateListOf<Screen>() }
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<Screen>() }
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<Screen>) {
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 <id> [--es engine exo|vlc] [--el start <ms>]
* [--ei audio <index>] [--ei sub <index>]` (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 <id> [--ez play true] [--el start <ms>]
* [--ei audio <index>] [--ei sub <index>]` (stream indexes as Jellyfin numbers them; with one given, the other is off).
* 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<Screen>? {
if (!BuildConfig.DEBUG) return null
if (intent.getBooleanExtra("tour", false)) return listOf(Screen.Tour(intent.getIntExtra("tour_file", 1) - 1))
private fun intentTarget(): List<Screen>? {
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 {

View File

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

View File

@@ -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<BaseItemDto>()) }
var resume by remember { mutableStateOf(emptyList<BaseItemDto>()) }
var results by remember { mutableStateOf(emptyList<BaseItemDto>()) }

View File

@@ -38,6 +38,28 @@ class Library @Inject constructor(private val session: JellyfinSession) {
).content.items
}
suspend fun latest(parentId: UUID? = null, limit: Int = 20): List<BaseItemDto> = 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<List<BaseItemDto>, 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? {

View File

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

View File

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

View File

@@ -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<Pair<Float, Color>>) {
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
}
}

View File

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

View File

@@ -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<Glow> = 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<FocusCardNode>() {
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,
)
}
}

View File

@@ -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 <blur> <spread> <color>`, 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<GlowLayer>)
// Bytes of all cached bitmaps; ~4 MB is a few dozen glows.
private val cache = object : LruCache<Key, Glow>(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<GlowLayer>): 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)
}
}

View File

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

View File

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

View File

@@ -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<BringIntoViewSpec?> { 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)
}

View File

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

View File

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

View File

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

View File

@@ -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<PosterModel>)
@Immutable
data class HomeState(
val loading: Boolean = true,
val error: String? = null,
val featured: FeaturedModel? = null,
val continueItems: List<WideModel> = emptyList(),
val continueCount: Int = 0,
val rows: List<HomeRow> = 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<HomeState> = _state.asStateFlow()
/** Card key → where it leads. */
private val targets = HashMap<String, Screen>()
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
}
}

View File

@@ -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<Float>, 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<Float>) = 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<Float>, 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<Float>, 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")

View File

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

View File

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

View File

@@ -1,9 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Нейтральный баннер Android TV (320×180), пока нет названия и логотипа: фиолетовый круг с «play». -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="320dp"
android:height="180dp"
android:viewportWidth="320"
android:viewportHeight="180">
<path android:fillColor="#1B1F2A" android:pathData="M0,0h320v180h-320z" />
<path android:fillColor="#E8B04B" android:pathData="M130,55 L130,125 L195,90 Z" />
<path android:fillColor="#0A0B10" android:pathData="M0,0h320v180h-320z" />
<path android:fillColor="#408F6BFF" android:pathData="M160,90m-62,0a62,62 0,1 1,124 0a62,62 0,1 1,-124 0" />
<path android:fillColor="#8F6BFF" android:pathData="M160,90m-40,0a40,40 0,1 1,80 0a40,40 0,1 1,-80 0" />
<path android:fillColor="#FFFFFF" android:pathData="M148,68 L148,112 L184,90 Z" />
</vector>

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="background">#0A0B10</color>
</resources>

View File

@@ -1,4 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Movies (прототип)</string>
<!-- Рабочее название, пока нет настоящего. Меняется здесь и в логотипе (drawable/banner.xml, Shell.Logo). -->
<string name="app_name">Movies</string>
</resources>

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Фон приложения — фон окна: без белой вспышки до первого кадра Compose, и экраны не рисуют свой фон.
Сплошной цвет: GPU заливает его почти бесплатно. Сетка точек из макета стоила ~5 мс каждого кадра
на тестовом ТВ (он перерисовывает весь экран на каждом кадре) — убрана. -->
<style name="Theme.Movies" parent="@android:style/Theme.DeviceDefault.NoActionBar">
<item name="android:windowBackground">@color/background</item>
<item name="android:colorBackground">@color/background</item>
</style>
</resources>