TV UI foundation
This commit is contained in:
@@ -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
17
app/proguard-rules.pro
vendored
Normal 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
|
||||
@@ -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"
|
||||
|
||||
93
app/src/main/assets/licenses/JetBrainsMono-OFL.txt
Normal file
93
app/src/main/assets/licenses/JetBrainsMono-OFL.txt
Normal 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.
|
||||
93
app/src/main/assets/licenses/Onest-OFL.txt
Normal file
93
app/src/main/assets/licenses/Onest-OFL.txt
Normal 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.
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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>()) }
|
||||
|
||||
@@ -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? {
|
||||
|
||||
@@ -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(
|
||||
|
||||
121
app/src/main/kotlin/ru/stenfredd/movies/ui/components/Buttons.kt
Normal file
121
app/src/main/kotlin/ru/stenfredd/movies/ui/components/Buttons.kt
Normal 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),
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
243
app/src/main/kotlin/ru/stenfredd/movies/ui/components/Cards.kt
Normal file
243
app/src/main/kotlin/ru/stenfredd/movies/ui/components/Cards.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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 },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
) }
|
||||
}
|
||||
}
|
||||
@@ -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() }
|
||||
}
|
||||
}
|
||||
100
app/src/main/kotlin/ru/stenfredd/movies/ui/home/HomeScreen.kt
Normal file
100
app/src/main/kotlin/ru/stenfredd/movies/ui/home/HomeScreen.kt
Normal 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) }
|
||||
}
|
||||
194
app/src/main/kotlin/ru/stenfredd/movies/ui/home/HomeViewModel.kt
Normal file
194
app/src/main/kotlin/ru/stenfredd/movies/ui/home/HomeViewModel.kt
Normal 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
|
||||
}
|
||||
}
|
||||
271
app/src/main/kotlin/ru/stenfredd/movies/ui/shell/Shell.kt
Normal file
271
app/src/main/kotlin/ru/stenfredd/movies/ui/shell/Shell.kt
Normal 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")
|
||||
15
app/src/main/kotlin/ru/stenfredd/movies/ui/theme/Theme.kt
Normal file
15
app/src/main/kotlin/ru/stenfredd/movies/ui/theme/Theme.kt
Normal 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)
|
||||
}
|
||||
117
app/src/main/kotlin/ru/stenfredd/movies/ui/theme/Tokens.kt
Normal file
117
app/src/main/kotlin/ru/stenfredd/movies/ui/theme/Tokens.kt
Normal 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
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
BIN
app/src/main/res/font/jetbrains_mono_400.ttf
Normal file
BIN
app/src/main/res/font/jetbrains_mono_400.ttf
Normal file
Binary file not shown.
BIN
app/src/main/res/font/jetbrains_mono_500.ttf
Normal file
BIN
app/src/main/res/font/jetbrains_mono_500.ttf
Normal file
Binary file not shown.
BIN
app/src/main/res/font/jetbrains_mono_600.ttf
Normal file
BIN
app/src/main/res/font/jetbrains_mono_600.ttf
Normal file
Binary file not shown.
BIN
app/src/main/res/font/onest_400.ttf
Normal file
BIN
app/src/main/res/font/onest_400.ttf
Normal file
Binary file not shown.
BIN
app/src/main/res/font/onest_500.ttf
Normal file
BIN
app/src/main/res/font/onest_500.ttf
Normal file
Binary file not shown.
BIN
app/src/main/res/font/onest_600.ttf
Normal file
BIN
app/src/main/res/font/onest_600.ttf
Normal file
Binary file not shown.
BIN
app/src/main/res/font/onest_700.ttf
Normal file
BIN
app/src/main/res/font/onest_700.ttf
Normal file
Binary file not shown.
BIN
app/src/main/res/font/onest_800.ttf
Normal file
BIN
app/src/main/res/font/onest_800.ttf
Normal file
Binary file not shown.
4
app/src/main/res/values/colors.xml
Normal file
4
app/src/main/res/values/colors.xml
Normal file
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="background">#0A0B10</color>
|
||||
</resources>
|
||||
@@ -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>
|
||||
|
||||
10
app/src/main/res/values/themes.xml
Normal file
10
app/src/main/res/values/themes.xml
Normal 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>
|
||||
@@ -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" }
|
||||
|
||||
|
||||
@@ -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; }
|
||||
|
||||
24
tools/tv/framestats.py
Normal file
24
tools/tv/framestats.py
Normal file
@@ -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')))
|
||||
Reference in New Issue
Block a user