TV UI foundation
This commit is contained in:
@@ -20,7 +20,12 @@ android {
|
|||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
release {
|
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)
|
implementation(libs.compose.ui.tooling.preview)
|
||||||
debugImplementation(libs.compose.ui.tooling)
|
debugImplementation(libs.compose.ui.tooling)
|
||||||
implementation(libs.tv.material)
|
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.compose)
|
||||||
implementation(libs.coil.network.okhttp)
|
implementation(libs.coil.network.okhttp)
|
||||||
implementation(libs.hilt.android)
|
implementation(libs.hilt.android)
|
||||||
ksp(libs.hilt.compiler)
|
ksp(libs.hilt.compiler)
|
||||||
implementation(libs.timber)
|
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:label="@string/app_name"
|
||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
android:networkSecurityConfig="@xml/network_security_config"
|
android:networkSecurityConfig="@xml/network_security_config"
|
||||||
android:theme="@android:style/Theme.DeviceDefault.NoActionBar">
|
android:theme="@style/Theme.Movies">
|
||||||
|
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
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 android.os.Bundle
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.BackHandler
|
|
||||||
import androidx.activity.compose.setContent
|
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.Box
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
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.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateListOf
|
import androidx.compose.runtime.mutableStateListOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
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.Surface
|
||||||
import androidx.tv.material3.Text
|
import androidx.tv.material3.SurfaceDefaults
|
||||||
import androidx.tv.material3.darkColorScheme
|
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 dagger.hilt.android.AndroidEntryPoint
|
||||||
import ru.stenfredd.movies.core.jellyfin.JellyfinSession
|
import ru.stenfredd.movies.core.jellyfin.JellyfinSession
|
||||||
|
import ru.stenfredd.movies.playback.api.EngineId
|
||||||
|
import ru.stenfredd.movies.playback.api.StreamSelection
|
||||||
import ru.stenfredd.movies.ui.DetailScreen
|
import ru.stenfredd.movies.ui.DetailScreen
|
||||||
import ru.stenfredd.movies.ui.HomeScreen
|
|
||||||
import ru.stenfredd.movies.ui.ItemsScreen
|
import ru.stenfredd.movies.ui.ItemsScreen
|
||||||
import ru.stenfredd.movies.ui.LoginScreen
|
import ru.stenfredd.movies.ui.LoginScreen
|
||||||
import ru.stenfredd.movies.ui.PlayerScreen
|
import ru.stenfredd.movies.ui.PlayerScreen
|
||||||
|
import ru.stenfredd.movies.ui.PrototypeScreen
|
||||||
import ru.stenfredd.movies.ui.Screen
|
import ru.stenfredd.movies.ui.Screen
|
||||||
import ru.stenfredd.movies.ui.TourScreen
|
import ru.stenfredd.movies.ui.TourScreen
|
||||||
import ru.stenfredd.movies.playback.api.EngineId
|
import ru.stenfredd.movies.ui.home.HomeScreen
|
||||||
import ru.stenfredd.movies.playback.api.StreamSelection
|
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 java.util.UUID
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@@ -38,54 +53,104 @@ class MainActivity : ComponentActivity() {
|
|||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
setContent {
|
setContent {
|
||||||
MaterialTheme(colorScheme = darkColorScheme()) {
|
AppTheme {
|
||||||
Surface(modifier = Modifier.fillMaxSize()) {
|
val stack = remember { mutableStateListOf<Screen>() }
|
||||||
val stack = remember { mutableStateListOf<Screen>() }
|
var ready by remember { mutableStateOf(false) }
|
||||||
var ready by remember { mutableStateOf(false) }
|
LaunchedEffect(Unit) {
|
||||||
LaunchedEffect(Unit) {
|
val loggedIn = session.restore()
|
||||||
stack += if (session.restore()) Screen.Home else Screen.Login
|
stack += if (loggedIn) Screen.Root(Tab.Home) else Screen.Login
|
||||||
if (stack.last() == Screen.Home) debugTarget()?.let(stack::addAll)
|
if (loggedIn) intentTarget()?.let(stack::addAll)
|
||||||
ready = true
|
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) })
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
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:
|
* Opens an item from the launch intent:
|
||||||
* `adb shell am start -S -n ru.stenfredd.movies/.MainActivity --es item <id> [--es engine exo|vlc] [--el start <ms>]
|
* `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),
|
* [--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).
|
* 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>? {
|
private fun intentTarget(): List<Screen>? {
|
||||||
if (!BuildConfig.DEBUG) return null
|
if (BuildConfig.DEBUG && intent.getBooleanExtra("tour", false)) return listOf(Screen.Tour(intent.getIntExtra("tour_file", 1) - 1))
|
||||||
if (intent.getBooleanExtra("tour", false)) return listOf(Screen.Tour(intent.getIntExtra("tour_file", 1) - 1))
|
|
||||||
val raw = intent.getStringExtra("item") ?: return null
|
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 id = runCatching {
|
||||||
val engine = intent.getStringExtra("engine")?.let(::EngineId)
|
UUID.fromString(if ('-' in raw) raw else raw.replace(Regex("(.{8})(.{4})(.{4})(.{4})(.{12})"), "$1-$2-$3-$4-$5"))
|
||||||
val play = intent.hasExtra("engine") || intent.getBooleanExtra("play", false)
|
}.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")) {
|
val streams = if (intent.hasExtra("audio") || intent.hasExtra("sub")) {
|
||||||
StreamSelection(intent.getIntExtra("audio", -1).takeIf { it >= 0 }, intent.getIntExtra("sub", -1).takeIf { it >= 0 })
|
StreamSelection(intent.getIntExtra("audio", -1).takeIf { it >= 0 }, intent.getIntExtra("sub", -1).takeIf { it >= 0 })
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -1,13 +1,32 @@
|
|||||||
package ru.stenfredd.movies
|
package ru.stenfredd.movies
|
||||||
|
|
||||||
import android.app.Application
|
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 dagger.hilt.android.HiltAndroidApp
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
|
|
||||||
@HiltAndroidApp
|
@HiltAndroidApp
|
||||||
class MoviesApp : Application() {
|
class MoviesApp : Application(), SingletonImageLoader.Factory {
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
Timber.plant(Timber.DebugTree())
|
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())
|
private fun BaseItemDto.target(): Screen = if (isPlayable) Screen.Detail(id) else Screen.Items(id, name.orEmpty())
|
||||||
|
|
||||||
@Composable
|
@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 views by remember { mutableStateOf(emptyList<BaseItemDto>()) }
|
||||||
var resume by remember { mutableStateOf(emptyList<BaseItemDto>()) }
|
var resume by remember { mutableStateOf(emptyList<BaseItemDto>()) }
|
||||||
var results 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
|
).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 }
|
suspend fun item(id: UUID): BaseItemDto = session.call { libraryApi.getItem(id, userId).content }
|
||||||
|
|
||||||
fun posterUrl(item: BaseItemDto): String? {
|
fun posterUrl(item: BaseItemDto): String? {
|
||||||
|
|||||||
@@ -6,7 +6,12 @@ import java.util.UUID
|
|||||||
|
|
||||||
sealed interface Screen {
|
sealed interface Screen {
|
||||||
data object Login : 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 Items(val parentId: UUID, val title: String) : Screen
|
||||||
data class Detail(val itemId: UUID) : Screen
|
data class Detail(val itemId: UUID) : Screen
|
||||||
data class Player(
|
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"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Нейтральный баннер Android TV (320×180), пока нет названия и логотипа: фиолетовый круг с «play». -->
|
||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
android:width="320dp"
|
android:width="320dp"
|
||||||
android:height="180dp"
|
android:height="180dp"
|
||||||
android:viewportWidth="320"
|
android:viewportWidth="320"
|
||||||
android:viewportHeight="180">
|
android:viewportHeight="180">
|
||||||
<path android:fillColor="#1B1F2A" android:pathData="M0,0h320v180h-320z" />
|
<path android:fillColor="#0A0B10" android:pathData="M0,0h320v180h-320z" />
|
||||||
<path android:fillColor="#E8B04B" android:pathData="M130,55 L130,125 L195,90 Z" />
|
<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>
|
</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"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources>
|
<resources>
|
||||||
<string name="app_name">Movies (прототип)</string>
|
<!-- Рабочее название, пока нет настоящего. Меняется здесь и в логотипе (drawable/banner.xml, Shell.Logo). -->
|
||||||
|
<string name="app_name">Movies</string>
|
||||||
</resources>
|
</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"
|
jellyfin-sdk = "1.9.2"
|
||||||
coil = "3.6.3"
|
coil = "3.6.3"
|
||||||
timber = "5.0.1"
|
timber = "5.0.1"
|
||||||
|
profileinstaller = "1.4.1"
|
||||||
|
navigation3 = "1.2.0"
|
||||||
okhttp = "5.5.0"
|
okhttp = "5.5.0"
|
||||||
slf4j = "2.0.20"
|
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-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" }
|
coil-network-okhttp = { module = "io.coil-kt.coil3:coil-network-okhttp", version.ref = "coil" }
|
||||||
okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" }
|
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" }
|
timber = { module = "com.jakewharton.timber:timber", version.ref = "timber" }
|
||||||
slf4j-api = { module = "org.slf4j:slf4j-api", version.ref = "slf4j" }
|
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"; }
|
aout() { $A shell dumpsys media.audio_flinger | grep -E "^Output thread|Total writes|Tracks of which"; }
|
||||||
# Журнал локального прокси VLC (диапазоны, кэш, сколько скачано с сервера)
|
# Журнал локального прокси VLC (диапазоны, кэш, сколько скачано с сервера)
|
||||||
vproxy() { $A logcat -d | grep "VLC proxy" | sed -E 's/^[0-9-]+ ([0-9:.]+).*VLC proxy/\1/' | cut -c1-170; }
|
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