Rename to SleepyOwl, add logo
This commit is contained in:
154
app/src/main/kotlin/ru/stenfredd/sleepyowl/LauncherFeed.kt
Normal file
154
app/src/main/kotlin/ru/stenfredd/sleepyowl/LauncherFeed.kt
Normal file
@@ -0,0 +1,154 @@
|
||||
package ru.stenfredd.sleepyowl
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.debounce
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.merge
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import ru.stenfredd.sleepyowl.core.data.ImageUrls
|
||||
import ru.stenfredd.sleepyowl.core.data.SessionCache
|
||||
import ru.stenfredd.sleepyowl.core.data.WatchRepository
|
||||
import ru.stenfredd.sleepyowl.core.data.model.ItemKind
|
||||
import ru.stenfredd.sleepyowl.core.data.model.ItemSummary
|
||||
import ru.stenfredd.sleepyowl.core.jellyfin.JellyfinSession
|
||||
import ru.stenfredd.sleepyowl.ui.model.landscape
|
||||
import ru.stenfredd.sleepyowl.ui.model.remaining
|
||||
import timber.log.Timber
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
* "Продолжить просмотр" on the TV's own home screen (the `ru.stenfredd.tvhome` shell): the app sends the shell the
|
||||
* ready list whenever it changes, and the shell keeps and shows it without waking this app on every Home press.
|
||||
*
|
||||
* Contract v1: `call(content://ru.stenfredd.tvhome.feed, "continue_watching", <JSON array of cards>)`; the shell
|
||||
* accepts it only from an app signed with its own key. An empty array hides the row. No shell on the device (another
|
||||
* TV, an emulator) → nothing is computed or sent.
|
||||
*
|
||||
* Recomputed once per process start, when the account changes (sign-in, profile switch, sign-out) and when watch
|
||||
* state changes ([SessionCache.invalidations]: playback stopped or left with Home, marked watched or favourite).
|
||||
* Progress made on other devices shows up only at the next start: no periodic background work on this TV.
|
||||
*/
|
||||
@Singleton
|
||||
class LauncherFeed @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val session: JellyfinSession,
|
||||
private val cache: SessionCache,
|
||||
private val watch: WatchRepository,
|
||||
private val images: ImageUrls,
|
||||
) {
|
||||
/** What the shell has from this process; it is sent again after a restart in case the shell lost it. */
|
||||
private var lastSent: String? = null
|
||||
|
||||
/** Runs for the life of the process; call off the main thread. */
|
||||
@OptIn(FlowPreview::class)
|
||||
suspend fun run() {
|
||||
session.restored.first { it }
|
||||
// Home loads the same lists at start: after it they come from the cache, and the start isn't slowed down.
|
||||
delay(START_DELAY_MS)
|
||||
merge(session.session.map { it?.accountId }.distinctUntilChanged(), cache.invalidations)
|
||||
.debounce(DEBOUNCE_MS)
|
||||
.collectLatest { publish() }
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION") // the flags overload is API 33+
|
||||
private suspend fun publish() {
|
||||
if (context.packageManager.resolveContentProvider(AUTHORITY, 0) == null) return
|
||||
val account = session.session.value?.accountId
|
||||
val items = if (account == null) {
|
||||
emptyList()
|
||||
} else {
|
||||
try {
|
||||
// The home screen's own call (same cache entries), so usually no request at all.
|
||||
watch.continueWatching().take(MAX_ITEMS)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
// The shell keeps the previous list: better than an empty row.
|
||||
Timber.w(e, "Launcher feed: not updated")
|
||||
return
|
||||
}
|
||||
}
|
||||
if (session.session.value?.accountId != account) return
|
||||
val json = JSONArray(items.map(::card)).toString()
|
||||
if (json == lastSent) return
|
||||
try {
|
||||
context.contentResolver.call(Uri.parse("content://$AUTHORITY"), METHOD, json, null)
|
||||
lastSent = json
|
||||
Timber.i("Launcher feed: sent %d cards", items.size)
|
||||
} catch (e: IllegalArgumentException) {
|
||||
Timber.d("Launcher feed: no shell (%s)", e.message)
|
||||
} catch (e: SecurityException) {
|
||||
// The shell takes lists only from apps signed with its key: sign both with the same release key.
|
||||
Timber.w(e, "Launcher feed: refused by the shell")
|
||||
}
|
||||
}
|
||||
|
||||
private fun card(item: ItemSummary): JSONObject {
|
||||
val started = item.user.progress != null
|
||||
return JSONObject()
|
||||
.put("id", item.id.hex())
|
||||
.put("title", title(item))
|
||||
.putOpt("subtitle", if (started) item.remaining() else if (item.kind == ItemKind.Episode) "следующая серия" else null)
|
||||
.putOpt("progress", item.user.progress?.let { (it * 1000).roundToInt() / 1000.0 })
|
||||
.putOpt("image", item.landscape()?.let { direct(images.url(it, IMAGE_W, IMAGE_H)) })
|
||||
.put("intent", intent(item).toUri(Intent.URI_INTENT_SCHEME))
|
||||
}
|
||||
|
||||
/** One line of ~28 characters: "Сериал · S2 E5" or the movie's name (an episode's name doesn't fit). */
|
||||
private fun title(item: ItemSummary): String {
|
||||
val series = item.seriesName?.takeIf { item.kind == ItemKind.Episode } ?: return item.name
|
||||
val number = listOfNotNull(item.seasonNumber?.let { "S$it" }, item.episodeNumber?.let { "E$it" }).joinToString(" ")
|
||||
return if (number.isEmpty()) series else "$series · $number"
|
||||
}
|
||||
|
||||
/**
|
||||
* Plays the item from where it was left (a next episode from the start), with the item's page under the player:
|
||||
* a movie's page, an episode's series page on that episode. See `MainActivity.intentTarget`.
|
||||
*/
|
||||
private fun intent(item: ItemSummary): Intent = Intent()
|
||||
.setClassName(context.packageName, MainActivity::class.java.name)
|
||||
.putExtra("item", item.id.hex())
|
||||
.putExtra("play", true)
|
||||
.apply {
|
||||
if (item.kind == ItemKind.Episode) item.seriesId?.let { putExtra("series", it.hex()) }
|
||||
if (item.user.positionMs > 0) putExtra("start", item.user.positionMs)
|
||||
}
|
||||
|
||||
/**
|
||||
* The shell downloads covers itself, without the app's routing (`ServerRouting`): at home give it the LAN
|
||||
* address the app uses, else the public one. The tag stays in the URL, so a changed image is a new URL.
|
||||
*/
|
||||
private fun direct(url: String): String {
|
||||
val local = session.localUrl ?: return url
|
||||
val public = session.session.value?.serverUrl?.trimEnd('/') ?: return url
|
||||
return if (url.startsWith(public)) local + url.removePrefix(public) else url
|
||||
}
|
||||
|
||||
/** Jellyfin's own form of ids: no dashes. */
|
||||
private fun UUID.hex() = toString().replace("-", "")
|
||||
|
||||
private companion object {
|
||||
const val AUTHORITY = "ru.stenfredd.tvhome.feed"
|
||||
const val METHOD = "continue_watching"
|
||||
/** The shell keeps up to 20 and shows 5. */
|
||||
const val MAX_ITEMS = 10
|
||||
/** The shell's card image, px. */
|
||||
const val IMAGE_W = 326
|
||||
const val IMAGE_H = 184
|
||||
const val START_DELAY_MS = 3_000L
|
||||
const val DEBOUNCE_MS = 1_000L
|
||||
}
|
||||
}
|
||||
260
app/src/main/kotlin/ru/stenfredd/sleepyowl/MainActivity.kt
Normal file
260
app/src/main/kotlin/ru/stenfredd/sleepyowl/MainActivity.kt
Normal file
@@ -0,0 +1,260 @@
|
||||
package ru.stenfredd.sleepyowl
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.compose.animation.EnterTransition
|
||||
import androidx.compose.animation.ExitTransition
|
||||
import androidx.compose.animation.togetherWith
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.text.BasicText
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.tv.material3.Surface
|
||||
import androidx.tv.material3.SurfaceDefaults
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
|
||||
import androidx.navigation3.runtime.NavEntry
|
||||
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
|
||||
import androidx.navigation3.ui.NavDisplay
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import androidx.compose.runtime.produceState
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import ru.stenfredd.sleepyowl.core.data.LibraryRepository
|
||||
import ru.stenfredd.sleepyowl.core.data.model.LibraryType
|
||||
import ru.stenfredd.sleepyowl.core.jellyfin.JellyfinSession
|
||||
import ru.stenfredd.sleepyowl.playback.api.EngineId
|
||||
import ru.stenfredd.sleepyowl.playback.api.StreamSelection
|
||||
import ru.stenfredd.sleepyowl.ui.ItemsScreen
|
||||
import ru.stenfredd.sleepyowl.ui.Screen
|
||||
import ru.stenfredd.sleepyowl.ui.TourScreen
|
||||
import ru.stenfredd.sleepyowl.ui.details.DetailsScreen
|
||||
import ru.stenfredd.sleepyowl.ui.home.HomeScreen
|
||||
import ru.stenfredd.sleepyowl.ui.favorites.FavoritesScreen
|
||||
import ru.stenfredd.sleepyowl.ui.library.LibraryMemory
|
||||
import ru.stenfredd.sleepyowl.ui.library.LibraryScreen
|
||||
import ru.stenfredd.sleepyowl.ui.person.PersonScreen
|
||||
import ru.stenfredd.sleepyowl.ui.search.SearchScreen
|
||||
import ru.stenfredd.sleepyowl.ui.settings.LicenseScreen
|
||||
import ru.stenfredd.sleepyowl.ui.settings.SettingsScreen
|
||||
import ru.stenfredd.sleepyowl.ui.player.PlayerScreen
|
||||
import ru.stenfredd.sleepyowl.ui.series.SeriesScreen
|
||||
import ru.stenfredd.sleepyowl.ui.login.LoginScreen
|
||||
import ru.stenfredd.sleepyowl.ui.menu.WatchMenuHost
|
||||
import ru.stenfredd.sleepyowl.ui.components.AppIcon
|
||||
import ru.stenfredd.sleepyowl.ui.shell.RailLibrary
|
||||
import ru.stenfredd.sleepyowl.ui.shell.Shell
|
||||
import ru.stenfredd.sleepyowl.ui.shell.Tab
|
||||
import ru.stenfredd.sleepyowl.ui.theme.AppColors
|
||||
import ru.stenfredd.sleepyowl.ui.theme.AppTheme
|
||||
import ru.stenfredd.sleepyowl.ui.theme.AppType
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
|
||||
@AndroidEntryPoint
|
||||
class MainActivity : ComponentActivity() {
|
||||
@Inject lateinit var session: JellyfinSession
|
||||
@Inject lateinit var libraryRepository: LibraryRepository
|
||||
@Inject lateinit var libraryMemory: LibraryMemory
|
||||
|
||||
/** Account → its libraries on the rail, see [Root]. */
|
||||
private var railLibraries: Pair<String, List<RailLibrary>> = "" to emptyList()
|
||||
|
||||
/** Launches while the app is already open (a card on the TV's home screen), see [onNewIntent]. */
|
||||
private val launches = Channel<Intent>(Channel.CONFLATED)
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContent {
|
||||
AppTheme {
|
||||
val stack = remember { mutableStateListOf<Screen>() }
|
||||
var ready by remember { mutableStateOf(false) }
|
||||
LaunchedEffect(Unit) {
|
||||
val loggedIn = session.restore()
|
||||
// One that came during the restore is already [getIntent], read just below.
|
||||
launches.tryReceive()
|
||||
stack += if (loggedIn) root(Tab.Home) else Screen.Login
|
||||
if (loggedIn) intentTarget(intent)?.let(stack::addAll)
|
||||
ready = true
|
||||
}
|
||||
if (ready) {
|
||||
// Long press on a card (or ✓ on the series page): mark watched / unwatched, over any screen.
|
||||
WatchMenuHost { Navigation(stack) }
|
||||
LaunchedEffect(Unit) {
|
||||
for (launch in launches) {
|
||||
// Signed out: the login screen stays, as at a cold start. No target (the app's icon): stay where the app was.
|
||||
if (session.session.value == null) continue
|
||||
val target = intentTarget(launch) ?: continue
|
||||
// As at a cold start, so Back leads to the app's home. An open player is released and saves its position.
|
||||
stack.clear()
|
||||
stack += root(Tab.Home)
|
||||
stack += target
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The activity is `singleTask`: a launch from outside while it exists (the TV's home screen starts cards with
|
||||
* `FLAG_ACTIVITY_NEW_TASK`) comes here instead of only bringing the old task with its old screen to the front.
|
||||
*/
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
setIntent(intent)
|
||||
launches.trySend(intent)
|
||||
}
|
||||
|
||||
@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()
|
||||
}
|
||||
/** "Страница фильма" from the player: back to the item's page, or open it if the player came from elsewhere. */
|
||||
fun showDetails(id: UUID) {
|
||||
stack.removeAt(stack.lastIndex)
|
||||
if (stack.lastOrNull() != Screen.Detail(id)) stack.add(Screen.Detail(id))
|
||||
}
|
||||
/** "К сериалу" from the player: back to the series page, or open it if the player came from elsewhere. */
|
||||
fun showSeries(seriesId: UUID, episodeId: UUID) {
|
||||
stack.removeAt(stack.lastIndex)
|
||||
if ((stack.lastOrNull() as? Screen.Series)?.seriesId != seriesId) stack.add(Screen.Series(seriesId, episodeId))
|
||||
}
|
||||
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 -> LoginScreen(onDone = { resetTo(root(Tab.Home)) })
|
||||
is Screen.Root -> Root(
|
||||
screen.tab,
|
||||
onTab = { resetTo(root(it)) },
|
||||
onLibrary = { id ->
|
||||
val account = session.session.value?.accountId.orEmpty()
|
||||
libraryMemory.setLibrary(account, id)
|
||||
resetTo(Screen.Root(Tab.Library, account, library = id))
|
||||
},
|
||||
onOpen = ::open,
|
||||
onSignedOut = { resetTo(Screen.Login) },
|
||||
onSwitched = { resetTo(root(Tab.Home)) },
|
||||
)
|
||||
is Screen.Items -> Legacy { ItemsScreen(screen, onOpen = ::open) }
|
||||
is Screen.Detail -> DetailsScreen(screen, onOpen = ::open)
|
||||
is Screen.Series -> SeriesScreen(screen, onOpen = ::open)
|
||||
is Screen.Person -> PersonScreen(screen, onOpen = ::open)
|
||||
is Screen.Player -> PlayerScreen(
|
||||
screen,
|
||||
onExit = ::back,
|
||||
onOpenDetails = { id -> showDetails(id) },
|
||||
onOpenSeries = { seriesId, episodeId -> showSeries(seriesId, episodeId) },
|
||||
)
|
||||
is Screen.License -> LicenseScreen(screen)
|
||||
is Screen.Tour -> TourScreen(screen.startIndex, onExit = ::back)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private fun root(tab: Tab) = Screen.Root(tab, session.session.value?.accountId.orEmpty())
|
||||
|
||||
@Composable
|
||||
private fun Root(tab: Tab, onTab: (Tab) -> Unit, onLibrary: (UUID) -> Unit, onOpen: (Screen) -> Unit, onSignedOut: () -> Unit, onSwitched: () -> Unit) {
|
||||
val user by session.session.collectAsStateWithLifecycle()
|
||||
// Cached by the repository (shared with home and the library tab); the rail just goes without them on failure.
|
||||
// The last list starts the next tab's rail, so it does not appear a frame later on every tab switch.
|
||||
val account = user?.accountId.orEmpty()
|
||||
val libraries by produceState(railLibraries.takeIf { it.first == account }?.second.orEmpty(), account) {
|
||||
value = runCatching { libraryRepository.views() }.getOrNull().orEmpty().map { view ->
|
||||
val icon = when (view.type) {
|
||||
LibraryType.Movies -> AppIcon.Film
|
||||
LibraryType.Shows -> AppIcon.Tv
|
||||
else -> AppIcon.Library
|
||||
}
|
||||
RailLibrary(view.id, view.name, icon)
|
||||
}
|
||||
railLibraries = account to value
|
||||
}
|
||||
Shell(
|
||||
current = tab,
|
||||
userName = user?.userName.orEmpty(),
|
||||
userSeed = user?.userId?.toString().orEmpty(),
|
||||
libraries = libraries,
|
||||
onTab = onTab,
|
||||
onLibrary = onLibrary,
|
||||
onProfile = { onOpen(Screen.Login) },
|
||||
) {
|
||||
when (tab) {
|
||||
Tab.Home -> HomeScreen(onOpen = onOpen)
|
||||
Tab.Library -> LibraryScreen(onOpen = onOpen)
|
||||
Tab.Search -> SearchScreen(onOpen = onOpen)
|
||||
Tab.Favorites -> FavoritesScreen(onOpen = onOpen)
|
||||
Tab.Settings -> SettingsScreen(onOpen = onOpen, onSignedOut = onSignedOut, onSwitched = onSwitched)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 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() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens an item from the launch intent:
|
||||
* `adb shell am start -S -n ru.stenfredd.sleepyowl/.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),
|
||||
* or a series page: `--es series <id> [--es episode <id>]`. With both `item` (an episode) and `series`, the page
|
||||
* under the player is the series page on that episode, as from the app's own cards (the TV's home screen does so).
|
||||
* Debug builds also take `--es engine exo|vlc` (implies play) and `--ez tour true [--ei tour_file N]` for the debug tour.
|
||||
*/
|
||||
private fun intentTarget(intent: Intent): List<Screen>? {
|
||||
if (BuildConfig.DEBUG && intent.getBooleanExtra("tour", false)) return listOf(Screen.Tour(intent.getIntExtra("tour_file", 1) - 1))
|
||||
val series = intent.getStringExtra("series")?.let(::parseId)
|
||||
val id = intent.getStringExtra("item")?.let(::parseId)
|
||||
if (series != null && id == null) return listOf(Screen.Series(series, intent.getStringExtra("episode")?.let(::parseId)))
|
||||
if (id == null) return null
|
||||
val engine = if (BuildConfig.DEBUG) intent.getStringExtra("engine")?.let(::EngineId) else null
|
||||
val play = engine != null || intent.getBooleanExtra("play", false)
|
||||
val streams = if (intent.hasExtra("audio") || intent.hasExtra("sub")) {
|
||||
StreamSelection(intent.getIntExtra("audio", -1).takeIf { it >= 0 }, intent.getIntExtra("sub", -1).takeIf { it >= 0 })
|
||||
} else {
|
||||
null
|
||||
}
|
||||
return listOf(series?.let { Screen.Series(it, id) } ?: Screen.Detail(id)) +
|
||||
if (play) listOf(Screen.Player(id, engine, intent.getLongExtra("start", 0), streams = streams)) else emptyList()
|
||||
}
|
||||
|
||||
/** Jellyfin ids come with or without dashes. */
|
||||
private fun parseId(raw: String): UUID? = runCatching {
|
||||
UUID.fromString(if ('-' in raw) raw else raw.replace(Regex("(.{8})(.{4})(.{4})(.{4})(.{12})"), "$1-$2-$3-$4-$5"))
|
||||
}.getOrNull()
|
||||
}
|
||||
48
app/src/main/kotlin/ru/stenfredd/sleepyowl/SleepyOwlApp.kt
Normal file
48
app/src/main/kotlin/ru/stenfredd/sleepyowl/SleepyOwlApp.kt
Normal file
@@ -0,0 +1,48 @@
|
||||
package ru.stenfredd.sleepyowl
|
||||
|
||||
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.network.okhttp.OkHttpNetworkFetcherFactory
|
||||
import coil3.request.allowRgb565
|
||||
import coil3.request.crossfade
|
||||
import dagger.Lazy
|
||||
import dagger.hilt.android.HiltAndroidApp
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
import okhttp3.OkHttpClient
|
||||
import timber.log.Timber
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltAndroidApp
|
||||
class SleepyOwlApp : Application(), SingletonImageLoader.Factory {
|
||||
/** The API's client: images go to the server's LAN address at home too (`ServerRouting`), sharing its connections. */
|
||||
@Inject lateinit var httpClient: OkHttpClient
|
||||
@Inject lateinit var launcherFeed: Lazy<LauncherFeed>
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
Timber.plant(Timber.DebugTree())
|
||||
// Built and run off the main thread: the first screen must not wait for it.
|
||||
CoroutineScope(SupervisorJob() + Dispatchers.IO).launch { launcherFeed.get().run() }
|
||||
}
|
||||
|
||||
/**
|
||||
* 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. Cache keys are the
|
||||
* URLs on the server's public address, whichever address the image actually came from.
|
||||
*/
|
||||
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() }
|
||||
.components { add(OkHttpNetworkFetcherFactory(callFactory = { httpClient })) }
|
||||
.allowRgb565(true)
|
||||
.crossfade(false)
|
||||
.build()
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package ru.stenfredd.sleepyowl.ui
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.items
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
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.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.tv.material3.Button
|
||||
import androidx.tv.material3.Card
|
||||
import androidx.tv.material3.MaterialTheme
|
||||
import androidx.tv.material3.Text
|
||||
import coil3.compose.AsyncImage
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import ru.stenfredd.sleepyowl.core.jellyfin.JellyfinSession
|
||||
import ru.stenfredd.sleepyowl.ui.components.TextInput
|
||||
import ru.stenfredd.sleepyowl.ui.components.rememberTextInputState
|
||||
import ru.stenfredd.sleepyowl.ui.theme.AppColors
|
||||
import ru.stenfredd.sleepyowl.ui.theme.AppType
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class BrowseViewModel @Inject constructor(val library: Library, val session: JellyfinSession) : ViewModel()
|
||||
|
||||
private fun BaseItemDto.target(): Screen = when {
|
||||
type == BaseItemKind.SERIES -> Screen.Series(id)
|
||||
isPlayable -> Screen.Detail(id)
|
||||
else -> Screen.Items(id, name.orEmpty())
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ItemsScreen(screen: Screen.Items, onOpen: (Screen) -> Unit, vm: BrowseViewModel = hiltViewModel()) {
|
||||
var items by remember { mutableStateOf<List<BaseItemDto>?>(null) }
|
||||
var error by remember { mutableStateOf<String?>(null) }
|
||||
LaunchedEffect(screen.parentId) {
|
||||
runCatching { items = vm.library.children(screen.parentId) }.onFailure { error = it.message }
|
||||
}
|
||||
Column(Modifier.fillMaxSize().padding(48.dp, 32.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
Text(screen.title, style = MaterialTheme.typography.headlineMedium)
|
||||
error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
|
||||
when (val list = items) {
|
||||
null -> Text("Загрузка…")
|
||||
else -> LazyVerticalGrid(
|
||||
columns = GridCells.Adaptive(160.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
items(list, key = { it.id }) { item -> Poster(item, vm.library, Modifier.fillMaxWidth()) { onOpen(item.target()) } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Poster(item: BaseItemDto, library: Library, modifier: Modifier, onClick: () -> Unit) {
|
||||
Column(modifier, verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Card(onClick = onClick, modifier = Modifier.fillMaxWidth().aspectRatio(2f / 3f)) {
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
val url = remember(item.id) { library.posterUrl(item) }
|
||||
if (url != null) {
|
||||
AsyncImage(model = url, contentDescription = item.name, contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize())
|
||||
} else {
|
||||
Text(item.name.orEmpty(), modifier = Modifier.padding(8.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
Text(item.label, maxLines = 2, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
}
|
||||
43
app/src/main/kotlin/ru/stenfredd/sleepyowl/ui/Library.kt
Normal file
43
app/src/main/kotlin/ru/stenfredd/sleepyowl/ui/Library.kt
Normal file
@@ -0,0 +1,43 @@
|
||||
package ru.stenfredd.sleepyowl.ui
|
||||
|
||||
import org.jellyfin.sdk.api.client.extensions.imageApi
|
||||
import org.jellyfin.sdk.api.client.extensions.libraryApi
|
||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import org.jellyfin.sdk.model.api.ImageType
|
||||
import org.jellyfin.sdk.model.api.ItemSortBy
|
||||
import ru.stenfredd.sleepyowl.core.jellyfin.JellyfinSession
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
|
||||
/** Plain item lists of the old prototype, still used for collections and folders ([ItemsScreen]). */
|
||||
class Library @Inject constructor(private val session: JellyfinSession) {
|
||||
private val api get() = session.api
|
||||
private val userId get() = session.requireSession().userId
|
||||
|
||||
suspend fun children(parentId: UUID): List<BaseItemDto> = session.call {
|
||||
libraryApi.getItems(
|
||||
userId = userId,
|
||||
parentId = parentId,
|
||||
sortBy = listOf(ItemSortBy.PARENT_INDEX_NUMBER, ItemSortBy.INDEX_NUMBER, ItemSortBy.SORT_NAME),
|
||||
limit = 1000,
|
||||
).content.items
|
||||
}
|
||||
|
||||
fun posterUrl(item: BaseItemDto): String? {
|
||||
val tag = item.imageTags?.get(ImageType.PRIMARY) ?: return null
|
||||
return api.imageApi.getItemImageUrl(item.id, ImageType.PRIMARY, maxHeight = 360, tag = tag)
|
||||
}
|
||||
}
|
||||
|
||||
val BaseItemDto.isPlayable get() = type in setOf(BaseItemKind.MOVIE, BaseItemKind.EPISODE, BaseItemKind.VIDEO, BaseItemKind.MUSIC_VIDEO)
|
||||
|
||||
val BaseItemDto.label: String
|
||||
get() = when (type) {
|
||||
BaseItemKind.EPISODE -> listOfNotNull(
|
||||
seriesName,
|
||||
if (parentIndexNumber != null && indexNumber != null) "S%02dE%02d".format(parentIndexNumber, indexNumber) else null,
|
||||
name,
|
||||
).joinToString(" · ")
|
||||
else -> listOfNotNull(name, productionYear?.toString()).joinToString(" · ")
|
||||
}
|
||||
42
app/src/main/kotlin/ru/stenfredd/sleepyowl/ui/Screen.kt
Normal file
42
app/src/main/kotlin/ru/stenfredd/sleepyowl/ui/Screen.kt
Normal file
@@ -0,0 +1,42 @@
|
||||
package ru.stenfredd.sleepyowl.ui
|
||||
|
||||
import ru.stenfredd.sleepyowl.playback.api.EngineId
|
||||
import ru.stenfredd.sleepyowl.playback.api.StreamSelection
|
||||
import java.util.UUID
|
||||
|
||||
sealed interface Screen {
|
||||
/** Login flow; also "сменить профиль" from the menu (then Back returns to the app). */
|
||||
data object Login : Screen
|
||||
|
||||
/**
|
||||
* Root screens with the side rail. [account] keeps each account's screens (and their view models) apart:
|
||||
* after a profile switch the stack is rebuilt, and an equal entry would be kept with the old account's state.
|
||||
* [library]: the library tab opened on a library from the rail; a new entry, so the tab starts over on it.
|
||||
*/
|
||||
data class Root(val tab: ru.stenfredd.sleepyowl.ui.shell.Tab, val account: String, val library: UUID? = null) : Screen
|
||||
|
||||
/** Collections and folders: the prototype's plain grid until they get a real screen. */
|
||||
data class Items(val parentId: UUID, val title: String) : Screen
|
||||
data class Detail(val itemId: UUID) : Screen
|
||||
|
||||
/** A person: photo, name and their movies and series. */
|
||||
data class Person(val personId: UUID) : Screen
|
||||
|
||||
/** Series page; [episodeId] = the episode to show first (a new episode, continue watching), else the next one to watch. */
|
||||
data class Series(val seriesId: UUID, val episodeId: UUID? = null) : Screen
|
||||
data class Player(
|
||||
val itemId: UUID,
|
||||
val engine: EngineId?,
|
||||
val startPositionMs: Long,
|
||||
/** Tracks to start with instead of the file's defaults. */
|
||||
val streams: StreamSelection? = null,
|
||||
/** Start at this fraction of the runtime instead of [startPositionMs]. */
|
||||
val startFraction: Double? = null,
|
||||
) : Screen
|
||||
|
||||
/** Text of a licence from "О приложении" in the settings; [asset] is a file in `assets/licenses`. */
|
||||
data class License(val name: String, val license: String, val asset: String) : Screen
|
||||
|
||||
/** Debug tour: plays a prepared list of files covering every format on the server, see [TourScreen]. */
|
||||
data class Tour(val startIndex: Int = 0) : Screen
|
||||
}
|
||||
141
app/src/main/kotlin/ru/stenfredd/sleepyowl/ui/TourScreen.kt
Normal file
141
app/src/main/kotlin/ru/stenfredd/sleepyowl/ui/TourScreen.kt
Normal file
@@ -0,0 +1,141 @@
|
||||
package ru.stenfredd.sleepyowl.ui
|
||||
|
||||
import android.content.Context
|
||||
import android.view.KeyEvent as AndroidKeyEvent
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.focusable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.key.KeyEventType
|
||||
import androidx.compose.ui.input.key.key
|
||||
import androidx.compose.ui.input.key.nativeKeyCode
|
||||
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
||||
import androidx.compose.ui.input.key.type
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.tv.material3.Text
|
||||
import java.util.UUID
|
||||
import kotlinx.coroutines.delay
|
||||
import org.json.JSONArray
|
||||
import ru.stenfredd.sleepyowl.playback.api.StreamSelection
|
||||
import ru.stenfredd.sleepyowl.ui.player.PlayerScreen
|
||||
import ru.stenfredd.sleepyowl.ui.player.TourInfo
|
||||
import timber.log.Timber
|
||||
|
||||
/**
|
||||
* One file of the debug tour with the tracks to select. The list is `assets/tour.json` of the debug build,
|
||||
* generated by `tools/tour/build_tour.py` (a minimal set of files covering every format feature on the server).
|
||||
*/
|
||||
class TourEntry(val id: UUID, val title: String, val audio: Int?, val subtitle: Int?, val label: String) {
|
||||
companion object {
|
||||
private const val ASSET = "tour.json"
|
||||
|
||||
fun available(context: Context): Boolean = runCatching { ASSET in context.assets.list("").orEmpty() }.getOrDefault(false)
|
||||
|
||||
fun load(context: Context): List<TourEntry> = runCatching {
|
||||
val json = JSONArray(context.assets.open(ASSET).bufferedReader().use { it.readText() })
|
||||
(0 until json.length()).map { i ->
|
||||
val o = json.getJSONObject(i)
|
||||
TourEntry(
|
||||
id = UUID.fromString(o.getString("id").replace(Regex("(.{8})(.{4})(.{4})(.{4})(.{12})"), "$1-$2-$3-$4-$5")),
|
||||
title = o.optString("title"),
|
||||
audio = if (o.isNull("audio")) null else o.getInt("audio"),
|
||||
subtitle = if (o.isNull("subtitle")) null else o.getInt("subtitle"),
|
||||
label = o.optString("label"),
|
||||
)
|
||||
}
|
||||
}.onFailure { Timber.w(it, "Tour list not loaded") }.getOrDefault(emptyList())
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Plays the tour files one by one, each from the middle: ↓ next file, ↑ next playback variant (see [PlayerScreen]).
|
||||
*
|
||||
* ↓ first only shows which file is next, the file starts after [START_DELAY_MS] without further keys: skipping
|
||||
* several files in a row would otherwise start and tear down a player on every press and choke the TV.
|
||||
* While waiting, ↓/↑ move forward/back through the list.
|
||||
*/
|
||||
@Composable
|
||||
fun TourScreen(startIndex: Int, onExit: () -> Unit) {
|
||||
val context = LocalContext.current
|
||||
val entries = remember { TourEntry.load(context) }
|
||||
var index by rememberSaveable { mutableIntStateOf(startIndex.coerceIn(0, (entries.size - 1).coerceAtLeast(0))) }
|
||||
var playing by rememberSaveable { mutableIntStateOf(index) }
|
||||
if (entries.isEmpty()) {
|
||||
Box(Modifier.fillMaxSize()) { Text("Список файлов для отладки пуст (assets/tour.json)", Modifier.align(Alignment.Center)) }
|
||||
return
|
||||
}
|
||||
LaunchedEffect(index) {
|
||||
if (index == playing) return@LaunchedEffect
|
||||
delay(START_DELAY_MS)
|
||||
playing = index
|
||||
}
|
||||
val entry = entries[index]
|
||||
if (index != playing) {
|
||||
TourPending(index + 1, entries.size, entry, onMove = { step -> index = (index + step).mod(entries.size) }, onExit = onExit)
|
||||
return
|
||||
}
|
||||
key(index) {
|
||||
PlayerScreen(
|
||||
Screen.Player(
|
||||
itemId = entry.id,
|
||||
engine = null,
|
||||
startPositionMs = 0,
|
||||
streams = StreamSelection(entry.audio, entry.subtitle),
|
||||
startFraction = 0.5,
|
||||
),
|
||||
onExit = onExit,
|
||||
tour = TourInfo(index + 1, entries.size, entry.title, entry.label) { index = (index + 1) % entries.size },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private const val START_DELAY_MS = 2000L
|
||||
|
||||
/** Black screen with the file that will start: nothing is loaded yet. */
|
||||
@Composable
|
||||
private fun TourPending(number: Int, total: Int, entry: TourEntry, onMove: (Int) -> Unit, onExit: () -> Unit) {
|
||||
val focus = remember { FocusRequester() }
|
||||
LaunchedEffect(Unit) { runCatching { focus.requestFocus() } }
|
||||
BackHandler(onBack = onExit)
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Black)
|
||||
.focusRequester(focus)
|
||||
.onPreviewKeyEvent { e ->
|
||||
if (e.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false
|
||||
when (e.key.nativeKeyCode) {
|
||||
AndroidKeyEvent.KEYCODE_DPAD_DOWN -> onMove(1)
|
||||
AndroidKeyEvent.KEYCODE_DPAD_UP -> onMove(-1)
|
||||
else -> return@onPreviewKeyEvent false
|
||||
}
|
||||
true
|
||||
}
|
||||
.focusable(),
|
||||
) {
|
||||
Column(Modifier.align(Alignment.Center).padding(48.dp), horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text("Отладка: файл $number из $total", fontSize = 20.sp, color = Color(0xFF80CBC4))
|
||||
Text(entry.title, fontSize = 28.sp, color = Color.White)
|
||||
Text(entry.label, fontSize = 16.sp, color = Color.LightGray)
|
||||
Text("Запуск через 2 с · ↓ дальше · ↑ назад", fontSize = 14.sp, color = Color.Gray, modifier = Modifier.padding(top = 24.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package ru.stenfredd.sleepyowl.ui.components
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.text.BasicText
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.drawWithCache
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import kotlin.math.PI
|
||||
import kotlin.math.cos
|
||||
import kotlin.math.pow
|
||||
import kotlin.math.sin
|
||||
|
||||
/**
|
||||
* User avatar from the mockup: a gradient circle with the first letter of the name,
|
||||
* `linear-gradient(145deg, oklch(62% .13 h), oklch(40% .12 h+40))`; the hue comes from [seed] (the user id),
|
||||
* so a user keeps the same colour everywhere.
|
||||
*/
|
||||
@Composable
|
||||
fun Avatar(name: String, seed: String, size: Dp, textStyle: TextStyle, modifier: Modifier = Modifier, letters: String = name.trim().take(1).uppercase()) {
|
||||
val colors = remember(seed) { avatarColors(seed) }
|
||||
Box(
|
||||
modifier
|
||||
.size(size)
|
||||
.drawWithCache {
|
||||
val brush = Brush.linearGradient(colors, start = Offset(this.size.width * 0.2f, 0f), end = Offset(this.size.width * 0.8f, this.size.height))
|
||||
onDrawBehind { drawCircle(brush) }
|
||||
},
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
BasicText(letters, style = textStyle)
|
||||
}
|
||||
}
|
||||
|
||||
fun avatarColors(seed: String): List<Color> {
|
||||
val hue = Math.floorMod(seed.hashCode(), 360).toDouble()
|
||||
return listOf(oklch(0.62, 0.13, hue), oklch(0.40, 0.12, hue + 40))
|
||||
}
|
||||
|
||||
/** CSS `oklch()` → sRGB. */
|
||||
private fun oklch(l: Double, c: Double, hueDeg: Double): Color {
|
||||
val h = hueDeg * PI / 180
|
||||
val a = c * cos(h)
|
||||
val b = c * sin(h)
|
||||
val l1 = cube(l + 0.3963377774 * a + 0.2158037573 * b)
|
||||
val m1 = cube(l - 0.1055613458 * a - 0.0638541728 * b)
|
||||
val s1 = cube(l - 0.0894841775 * a - 1.2914855480 * b)
|
||||
val r = 4.0767416621 * l1 - 3.3077115913 * m1 + 0.2309699292 * s1
|
||||
val g = -1.2684380046 * l1 + 2.6097574011 * m1 - 0.3413193965 * s1
|
||||
val bl = -0.0041960863 * l1 - 0.7034186147 * m1 + 1.7076147010 * s1
|
||||
return Color(gamma(r), gamma(g), gamma(bl))
|
||||
}
|
||||
|
||||
private fun cube(x: Double) = x * x * x
|
||||
|
||||
private fun gamma(x: Double): Float {
|
||||
val v = x.coerceIn(0.0, 1.0)
|
||||
return (if (v <= 0.0031308) 12.92 * v else 1.055 * v.pow(1 / 2.4) - 0.055).toFloat()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
package ru.stenfredd.sleepyowl.ui.components
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapShader
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Matrix
|
||||
import android.graphics.Paint
|
||||
import android.graphics.RectF
|
||||
import android.graphics.Shader
|
||||
import android.util.LruCache
|
||||
import kotlin.math.cos
|
||||
import kotlin.math.pow
|
||||
import kotlin.math.roundToInt
|
||||
import kotlin.math.withSign
|
||||
|
||||
/**
|
||||
* Placeholder from the image's BlurHash (sent by the server with every item): a blurred colour sketch of the picture
|
||||
* while it loads.
|
||||
*
|
||||
* Decoded tiny (the hash holds only a few colour components anyway), then upscaled to a quarter of the card with
|
||||
* the rounded corners baked in, like the real images ([CardImageTransformation]); drawn stretched to the card it is a
|
||||
* single cheap bitmap draw. Decoding runs off the main thread; results are cached by hash and size.
|
||||
*/
|
||||
object BlurHash {
|
||||
private val cache = LruCache<String, Bitmap>(CACHE_ENTRIES)
|
||||
|
||||
/** A placeholder bitmap for a [widthPx]×[heightPx] card with [radiusPx] corners and [shade], or null for a broken hash. */
|
||||
fun placeholder(hash: String, widthPx: Int, heightPx: Int, radiusPx: Float, shade: ImageShade?): Bitmap? {
|
||||
val w = (widthPx / SCALE).coerceAtLeast(1)
|
||||
val h = (heightPx / SCALE).coerceAtLeast(1)
|
||||
val key = "$hash:$w:$h:$radiusPx:${shade?.cacheKey}"
|
||||
cache.get(key)?.let { return it }
|
||||
val small = decode(hash, DECODE_SIZE, (DECODE_SIZE * heightPx / widthPx.toFloat()).roundToInt().coerceAtLeast(1)) ?: return null
|
||||
val out = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888)
|
||||
val paint = Paint(Paint.ANTI_ALIAS_FLAG or Paint.FILTER_BITMAP_FLAG)
|
||||
paint.shader = BitmapShader(small, Shader.TileMode.CLAMP, Shader.TileMode.CLAMP).apply {
|
||||
setLocalMatrix(Matrix().apply { setScale(w / small.width.toFloat(), h / small.height.toFloat()) })
|
||||
}
|
||||
val r = radiusPx / SCALE
|
||||
val canvas = Canvas(out)
|
||||
canvas.drawRoundRect(RectF(0f, 0f, w.toFloat(), h.toFloat()), r, r, paint)
|
||||
// The same shade as the real image, or the text over a bright blur would be unreadable.
|
||||
shade?.let { canvas.drawShade(it, w, h) }
|
||||
small.recycle()
|
||||
cache.put(key, out)
|
||||
return out
|
||||
}
|
||||
|
||||
/** Standard BlurHash decoding (https://blurha.sh), punch 1. */
|
||||
fun decode(hash: String, width: Int, height: Int): Bitmap? {
|
||||
if (hash.length < 6) return null
|
||||
val sizeFlag = decode83(hash, 0, 1)
|
||||
val numX = sizeFlag % 9 + 1
|
||||
val numY = sizeFlag / 9 + 1
|
||||
if (hash.length != 4 + 2 * numX * numY) return null
|
||||
val maxAc = (decode83(hash, 1, 2) + 1) / 166f
|
||||
val colors = Array(numX * numY) { i ->
|
||||
if (i == 0) {
|
||||
val v = decode83(hash, 2, 6)
|
||||
floatArrayOf(srgbToLinear(v shr 16), srgbToLinear((v shr 8) and 255), srgbToLinear(v and 255))
|
||||
} else {
|
||||
val v = decode83(hash, 4 + i * 2, 6 + i * 2)
|
||||
floatArrayOf(signedPow2((v / (19 * 19) - 9) / 9f) * maxAc, signedPow2(((v / 19) % 19 - 9) / 9f) * maxAc, signedPow2((v % 19 - 9) / 9f) * maxAc)
|
||||
}
|
||||
}
|
||||
val cosX = FloatArray(width * numX) { cos(Math.PI * (it / numX) * (it % numX) / width).toFloat() }
|
||||
val cosY = FloatArray(height * numY) { cos(Math.PI * (it / numY) * (it % numY) / height).toFloat() }
|
||||
val pixels = IntArray(width * height)
|
||||
for (y in 0 until height) for (x in 0 until width) {
|
||||
var r = 0f
|
||||
var g = 0f
|
||||
var b = 0f
|
||||
for (j in 0 until numY) for (i in 0 until numX) {
|
||||
val basis = cosX[x * numX + i] * cosY[y * numY + j]
|
||||
val c = colors[j * numX + i]
|
||||
r += c[0] * basis
|
||||
g += c[1] * basis
|
||||
b += c[2] * basis
|
||||
}
|
||||
pixels[y * width + x] = (0xFF shl 24) or (linearToSrgb(r) shl 16) or (linearToSrgb(g) shl 8) or linearToSrgb(b)
|
||||
}
|
||||
return Bitmap.createBitmap(pixels, width, height, Bitmap.Config.ARGB_8888)
|
||||
}
|
||||
|
||||
private const val CHARS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz#\$%*+,-.:;=?@[]^_{|}~"
|
||||
|
||||
private fun decode83(s: String, from: Int, to: Int): Int {
|
||||
var v = 0
|
||||
for (i in from until to) {
|
||||
val d = CHARS.indexOf(s[i])
|
||||
if (d < 0) return 0
|
||||
v = v * 83 + d
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
private fun srgbToLinear(v: Int): Float {
|
||||
val f = v / 255f
|
||||
return if (f <= 0.04045f) f / 12.92f else ((f + 0.055f) / 1.055f).pow(2.4f)
|
||||
}
|
||||
|
||||
private fun linearToSrgb(v: Float): Int {
|
||||
val f = v.coerceIn(0f, 1f)
|
||||
return if (f <= 0.0031308f) (f * 12.92f * 255 + 0.5f).toInt() else ((1.055f * f.pow(1 / 2.4f) - 0.055f) * 255 + 0.5f).toInt()
|
||||
}
|
||||
|
||||
private fun signedPow2(v: Float) = (v * v).withSign(v)
|
||||
|
||||
private const val DECODE_SIZE = 16
|
||||
/** The placeholder is a quarter of the card: it is a blur, upscaling it costs nothing visible. */
|
||||
private const val SCALE = 4
|
||||
private const val CACHE_ENTRIES = 300
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package ru.stenfredd.sleepyowl.ui.components
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import ru.stenfredd.sleepyowl.R
|
||||
|
||||
/**
|
||||
* The app's logo: the owl and the "SleepyOwl" wordmark from `design/logo/sleepyowl.png`, cut out of its navy
|
||||
* background by `tools/logo/make_assets.py`. The pictures are made at the default sizes here (xhdpi), so on the TV
|
||||
* they are drawn pixel for pixel; other sizes resample them.
|
||||
*/
|
||||
@Composable
|
||||
fun OwlMark(modifier: Modifier = Modifier, size: Dp = 36.dp) {
|
||||
Image(painterResource(R.drawable.logo_owl), null, modifier.size(size))
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun Wordmark(modifier: Modifier = Modifier, height: Dp = 18.dp) {
|
||||
val painter = painterResource(R.drawable.logo_wordmark)
|
||||
Image(
|
||||
painter,
|
||||
stringResource(R.string.app_name),
|
||||
modifier.height(height).aspectRatio(painter.intrinsicSize.width / painter.intrinsicSize.height),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
package ru.stenfredd.sleepyowl.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 androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import ru.stenfredd.sleepyowl.ui.theme.AppColors
|
||||
import ru.stenfredd.sleepyowl.ui.theme.AppType
|
||||
|
||||
private val PrimaryFocus = FocusStyle(
|
||||
scale = 1.05f,
|
||||
corner = 21.dp,
|
||||
ringColor = Color(0xFFD9D2FF),
|
||||
// `0 0 36px 8px rgba(124,92,255,.65), 0 0 100px rgba(70,120,255,.35)`
|
||||
glows = listOf(FocusStyle.Glow(18.dp, 4.dp, Color(0xA67C5CFF)), FocusStyle.Glow(50.dp, 0.dp, Color(0x594678FF))),
|
||||
)
|
||||
|
||||
private val LightFocus = FocusStyle(scale = 1.05f, corner = 21.dp, glows = FocusStyle.ButtonGlows)
|
||||
|
||||
/** Main action: gradient pill "▶ Смотреть". */
|
||||
@Composable
|
||||
fun PrimaryButton(label: String, onClick: () -> Unit, modifier: Modifier = Modifier, icon: AppIcon? = AppIcon.Play) {
|
||||
Row(
|
||||
modifier
|
||||
.focusCard(PrimaryFocus)
|
||||
.height(42.dp)
|
||||
.background(AppColors.PrimaryGradient, RoundedCornerShape(21.dp))
|
||||
.clickable(interactionSource = null, indication = null, onClick = onClick)
|
||||
.padding(start = 15.dp, end = 19.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
icon?.let { Icon(it, Color.White, Modifier.size(15.dp)) }
|
||||
BasicText(label, style = AppType.Button.copy(fontSize = 14.sp, color = Color.White))
|
||||
}
|
||||
}
|
||||
|
||||
/** Secondary pill ("С начала"): dark, filled light when focused. */
|
||||
@Composable
|
||||
fun SecondaryButton(label: String, onClick: () -> Unit, modifier: Modifier = Modifier) {
|
||||
var focused by remember { mutableStateOf(false) }
|
||||
Box(
|
||||
modifier
|
||||
.focusCard(LightFocus)
|
||||
.height(42.dp)
|
||||
.background(if (focused) AppColors.FocusFill else AppColors.SurfaceRaised, RoundedCornerShape(21.dp))
|
||||
.border(0.5.dp, if (focused) AppColors.FocusFill else AppColors.Border, RoundedCornerShape(21.dp))
|
||||
.onFocusChanged { focused = it.isFocused }
|
||||
.clickable(interactionSource = null, indication = null, onClick = onClick)
|
||||
.padding(horizontal = 15.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
BasicText(label, style = AppType.Button.copy(fontSize = 13.sp, fontWeight = FontWeight.Normal, color = if (focused) AppColors.OnFocusFill else Color(0xFFE3E5F2)))
|
||||
}
|
||||
}
|
||||
|
||||
/** Round icon button (favourite, watched); [tint] is the icon colour when not focused. */
|
||||
@Composable
|
||||
fun CircleButton(icon: AppIcon, onClick: () -> Unit, modifier: Modifier = Modifier, tint: Color = Color(0xFFE3E5F2), size: Dp = 42.dp) {
|
||||
var focused by remember { mutableStateOf(false) }
|
||||
Box(
|
||||
modifier
|
||||
.focusCard(LightFocus.copy(corner = size / 2))
|
||||
.size(size)
|
||||
.background(if (focused) AppColors.FocusFill else AppColors.SurfaceRaised, CircleShape)
|
||||
.border(0.5.dp, if (focused) AppColors.FocusFill 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.36f))
|
||||
}
|
||||
}
|
||||
|
||||
/** Option chip: "Русский TrueHD Atmos 7.1" (or "● Жёлтый" with a [swatch]); selected = violet, focused = light fill. */
|
||||
@Composable
|
||||
fun Chip(label: String, selected: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier, detail: String? = null, swatch: Color? = null) {
|
||||
var focused by remember { mutableStateOf(false) }
|
||||
val shape = RoundedCornerShape(14.5.dp)
|
||||
val textColor = when {
|
||||
focused -> AppColors.OnFocusFill
|
||||
selected -> AppColors.Text
|
||||
else -> AppColors.TextSecondary
|
||||
}
|
||||
Row(
|
||||
modifier
|
||||
.focusCard(FocusStyle(scale = 1f, corner = 14.5.dp, glows = FocusStyle.ButtonGlows))
|
||||
.height(29.dp)
|
||||
.background(
|
||||
when {
|
||||
focused -> AppColors.FocusFill
|
||||
selected -> Color(0xFF211D4A)
|
||||
else -> AppColors.SurfaceRaised
|
||||
},
|
||||
shape,
|
||||
)
|
||||
.border(0.5.dp, if (focused) AppColors.FocusFill else if (selected) Color(0xFF7A6CFF) else AppColors.Border, shape)
|
||||
.onFocusChanged { focused = it.isFocused }
|
||||
.clickable(interactionSource = null, indication = null, onClick = onClick)
|
||||
.padding(horizontal = 11.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(5.dp),
|
||||
) {
|
||||
swatch?.let { Box(Modifier.size(9.dp).background(it, CircleShape).border(0.5.dp, Color(0x4D000000), CircleShape)) }
|
||||
BasicText(label, Modifier.widthIn(max = 190.dp), style = AppType.Button.copy(fontSize = 11.sp, fontWeight = FontWeight.Normal, color = textColor), maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
detail?.let { BasicText(it, style = AppType.MonoCaption.copy(fontSize = 8.5.sp, color = textColor.copy(alpha = 0.65f)), maxLines = 1) }
|
||||
}
|
||||
}
|
||||
|
||||
/** 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", "HDR" -> AppColors.Yellow
|
||||
"Atmos", "5.1", "7.1", "FLAC", "TrueHD", "DTS", "DTS:X" -> AppColors.Pink
|
||||
else -> AppColors.TextMuted
|
||||
}
|
||||
BasicText(
|
||||
text,
|
||||
modifier
|
||||
.border(0.5.dp, color, RoundedCornerShape(3.5.dp))
|
||||
.padding(horizontal = 5.dp, vertical = 2.dp),
|
||||
style = AppType.MonoCaption.copy(color = color),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package ru.stenfredd.sleepyowl.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)
|
||||
shade?.let { canvas.drawShade(it, w, h) }
|
||||
return output
|
||||
}
|
||||
}
|
||||
|
||||
/** Draws [shade] over the bottom of a [w]×[h] image already on this canvas, only over its own (non-transparent) pixels. */
|
||||
internal fun Canvas.drawShade(shade: ImageShade, w: Int, h: Int) {
|
||||
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)
|
||||
drawRect(0f, top, w.toFloat(), h.toFloat(), gradient)
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
package ru.stenfredd.sleepyowl.ui.components
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
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.sleepyowl.ui.menu.WatchTarget
|
||||
import ru.stenfredd.sleepyowl.ui.menu.clickableWithMenu
|
||||
import ru.stenfredd.sleepyowl.ui.theme.AppColors
|
||||
import ru.stenfredd.sleepyowl.ui.theme.AppDimens
|
||||
import ru.stenfredd.sleepyowl.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 image: RemoteImageSpec?,
|
||||
/** 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,
|
||||
/** Heart in the corner (favourites); shown instead of the watched mark. */
|
||||
val favorite: Boolean = false,
|
||||
/** What a long press marks watched or unwatched. */
|
||||
val watch: WatchTarget? = null,
|
||||
)
|
||||
|
||||
/** 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 image: RemoteImageSpec?,
|
||||
val progress: Float?,
|
||||
val watch: WatchTarget? = null,
|
||||
)
|
||||
|
||||
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)
|
||||
.clickableWithMenu(model.watch, onClick),
|
||||
) {
|
||||
RemoteImage(model.image, PosterCorner, Modifier.fillMaxSize())
|
||||
if (model.isNew) NewBadge(Modifier.align(Alignment.TopStart).padding(6.dp))
|
||||
if (model.favorite) {
|
||||
FavoriteBadge(Modifier.align(Alignment.TopEnd).padding(6.dp))
|
||||
} else 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)
|
||||
.clickableWithMenu(model.watch, onClick),
|
||||
) {
|
||||
RemoteImage(model.image, 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 image: RemoteImageSpec?,
|
||||
val progress: Float,
|
||||
val remaining: String?,
|
||||
val watch: WatchTarget? = null,
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun FeaturedCard(model: FeaturedModel, onClick: () -> Unit, modifier: Modifier = Modifier) {
|
||||
Box(
|
||||
modifier
|
||||
.focusCard(FocusStyle.Featured)
|
||||
.size(410.dp, 230.5.dp)
|
||||
.clickableWithMenu(model.watch, onClick),
|
||||
) {
|
||||
RemoteImage(model.image, 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)
|
||||
}
|
||||
}
|
||||
|
||||
/** Pink heart in a dark circle (`rgba(10,11,16,.6)`, pink border): favourites. */
|
||||
@Composable
|
||||
fun FavoriteBadge(modifier: Modifier = Modifier) {
|
||||
Box(
|
||||
modifier
|
||||
.size(19.dp)
|
||||
.background(Color(0x990A0B10), CircleShape)
|
||||
.border(0.5.dp, AppColors.Pink.copy(alpha = 0.6f), CircleShape),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(AppIcon.HeartFilled, AppColors.Pink, Modifier.size(10.dp))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package ru.stenfredd.sleepyowl.ui.components
|
||||
|
||||
import android.graphics.DashPathEffect
|
||||
import android.graphics.Paint
|
||||
import android.graphics.RectF
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.drawBehind
|
||||
import androidx.compose.ui.draw.drawWithCache
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.graphics.asAndroidBitmap
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlin.math.PI
|
||||
import kotlin.math.ceil
|
||||
import kotlin.math.cos
|
||||
|
||||
/**
|
||||
* Dashed rounded frame (`border: 2px dashed`), rasterised once per size into a bitmap: a dashed path is
|
||||
* re-tessellated every frame by the GPU, and the test TV redraws the whole screen on every frame.
|
||||
*/
|
||||
fun Modifier.dashedBorder(color: Color, width: Dp, corner: Dp, dash: Dp = 6.dp, gap: Dp = 4.dp): Modifier = drawWithCache {
|
||||
val w = ceil(size.width).toInt().coerceAtLeast(1)
|
||||
val h = ceil(size.height).toInt().coerceAtLeast(1)
|
||||
val bitmap = ImageBitmap(w, h)
|
||||
val stroke = width.toPx()
|
||||
val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
style = Paint.Style.STROKE
|
||||
strokeWidth = stroke
|
||||
this.color = color.toArgb()
|
||||
pathEffect = DashPathEffect(floatArrayOf(dash.toPx(), gap.toPx()), 0f)
|
||||
}
|
||||
val r = corner.toPx()
|
||||
android.graphics.Canvas(bitmap.asAndroidBitmap()).drawRoundRect(RectF(stroke / 2, stroke / 2, w - stroke / 2, h - stroke / 2), r, r, paint)
|
||||
onDrawBehind { drawImage(bitmap) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Status dot; [pulsing] fades it in and out while something is in progress. The fade is stepped at ~8 frames
|
||||
* a second: the test TV redraws the whole screen for any change (~17 ms of GPU on the login screen), so a smooth
|
||||
* 60 fps pulse would keep its GPU busy for nothing.
|
||||
*/
|
||||
@Composable
|
||||
fun StatusDot(color: Color, pulsing: Boolean, modifier: Modifier = Modifier, size: Dp = 6.dp) {
|
||||
val alpha = remember { mutableFloatStateOf(1f) }
|
||||
if (pulsing) {
|
||||
LaunchedEffect(Unit) {
|
||||
try {
|
||||
var t = 0
|
||||
while (true) {
|
||||
alpha.floatValue = 0.625f + 0.375f * cos(t * PI / 6).toFloat()
|
||||
t++
|
||||
delay(PULSE_STEP_MS)
|
||||
}
|
||||
} finally {
|
||||
alpha.floatValue = 1f
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(modifier.size(size).drawBehind { drawCircle(color, alpha = alpha.floatValue) })
|
||||
}
|
||||
|
||||
private const val PULSE_STEP_MS = 120L
|
||||
@@ -0,0 +1,142 @@
|
||||
package ru.stenfredd.sleepyowl.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.sleepyowl.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,
|
||||
/**
|
||||
* False: focus shows at once. Over video (the player) every frame redraws the controls' shades too, so an
|
||||
* animated focus change costs ~8 expensive frames instead of one.
|
||||
*/
|
||||
val animate: Boolean = true,
|
||||
) {
|
||||
@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()
|
||||
val target = if (focused) 1f else 0f
|
||||
coroutineScope.launch { if (style.animate) progress.animateTo(target, tween(if (focused) 180 else 140)) else progress.snapTo(target) }
|
||||
}
|
||||
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
108
app/src/main/kotlin/ru/stenfredd/sleepyowl/ui/components/Glow.kt
Normal file
108
app/src/main/kotlin/ru/stenfredd/sleepyowl/ui/components/Glow.kt
Normal file
@@ -0,0 +1,108 @@
|
||||
package ru.stenfredd.sleepyowl.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 java.nio.ByteBuffer
|
||||
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)
|
||||
// Each layer's coverage is drawn at full strength (255 levels) and the layers are composited in float:
|
||||
// a dim colour stored directly in 8 bits has only 15–60 levels per channel, so the tail shows bands
|
||||
// several px wide and R, G, B step at different places — a visible staircase with colour shifts.
|
||||
val mask = Bitmap.createBitmap(w, h, Bitmap.Config.ALPHA_8)
|
||||
val canvas = Canvas(mask)
|
||||
canvas.scale(SCALE, SCALE)
|
||||
val paint = Paint(Paint.ANTI_ALIAS_FLAG)
|
||||
val stride = mask.rowBytes
|
||||
val coverage = ByteBuffer.allocate(stride * h)
|
||||
val premul = FloatArray(w * h * 4)
|
||||
for (layer in key.layers) {
|
||||
mask.eraseColor(0)
|
||||
// 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)
|
||||
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)
|
||||
coverage.rewind()
|
||||
mask.copyPixelsToBuffer(coverage)
|
||||
val px = coverage.array()
|
||||
val c = layer.color
|
||||
for (y in 0 until h) for (x in 0 until w) {
|
||||
val v = px[y * stride + x].toInt() and 0xFF
|
||||
if (v == 0) continue
|
||||
val a = c.alpha * v / 255f
|
||||
val i = (y * w + x) * 4
|
||||
premul[i] = c.red * a + premul[i] * (1 - a)
|
||||
premul[i + 1] = c.green * a + premul[i + 1] * (1 - a)
|
||||
premul[i + 2] = c.blue * a + premul[i + 2] * (1 - a)
|
||||
premul[i + 3] = a + premul[i + 3] * (1 - a)
|
||||
}
|
||||
}
|
||||
// To 8 bits with ±1.5 levels of noise: invisible, but the average is smooth. White noise rather than
|
||||
// a finer pattern, since the bitmap is upscaled 2× and the filtering would average a fine pattern out.
|
||||
val out = ByteBuffer.allocate(w * h * 4)
|
||||
val bytes = out.array()
|
||||
for (i in 0 until w * h) {
|
||||
val o = i * 4
|
||||
if (premul[o + 3] <= 0f) continue
|
||||
val n = 3f * noise(i) - 1.5f
|
||||
val a = (premul[o + 3] * 255f + n).roundToInt().coerceIn(0, 255)
|
||||
for (k in 0..2) bytes[o + k] = (premul[o + k] * 255f + n).roundToInt().coerceIn(0, a).toByte()
|
||||
bytes[o + 3] = a.toByte()
|
||||
}
|
||||
val bitmap = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888)
|
||||
bitmap.copyPixelsFromBuffer(out)
|
||||
return Glow(bitmap.asImageBitmap(), margin)
|
||||
}
|
||||
|
||||
/** Hash-based white noise, 0..1. */
|
||||
private fun noise(i: Int): Float {
|
||||
var x = i * 0x27d4eb2d
|
||||
x = (x xor (x ushr 15)) * 0x2c1b3c6d
|
||||
x = (x xor (x ushr 12)) * 0x297a2d39
|
||||
x = x xor (x ushr 15)
|
||||
return (x ushr 8) / 16777216f
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package ru.stenfredd.sleepyowl.ui.components
|
||||
|
||||
import androidx.compose.foundation.lazy.grid.GridItemSpan
|
||||
import androidx.compose.foundation.lazy.grid.LazyGridScope
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.layout.layout
|
||||
import androidx.compose.ui.unit.Dp
|
||||
|
||||
/*
|
||||
* Screens built as one lazy grid of posters (library, favourites, search results, a person's films): headers and
|
||||
* rows of cards are full-width items of the grid, posters are its cells, so only what is on screen is composed.
|
||||
*/
|
||||
|
||||
/** A full-width item of the grid (header, row of cards). */
|
||||
fun LazyGridScope.fullItem(key: String, type: String, content: @Composable () -> Unit) =
|
||||
item(key = key, span = { GridItemSpan(maxLineSpan) }, contentType = type) { content() }
|
||||
|
||||
/**
|
||||
* Lays a full-width item out [width] wide starting [side] to the left of the grid's content, i.e. outside the grid's
|
||||
* side padding: rows of cards then scroll from the screen edge to edge, as on home.
|
||||
*/
|
||||
fun Modifier.bleed(width: Dp, side: Dp) = layout { measurable, constraints ->
|
||||
val w = width.roundToPx()
|
||||
val placeable = measurable.measure(constraints.copy(minWidth = w, maxWidth = w))
|
||||
layout(constraints.maxWidth, placeable.height) { placeable.place(-side.roundToPx(), 0) }
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package ru.stenfredd.sleepyowl.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
|
||||
|
||||
private const val HEART = "M12 20s-7.5-4.6-7.5-10A4.2 4.2 0 0 1 12 7.6 4.2 4.2 0 0 1 19.5 10c0 5.4-7.5 10-7.5 10z"
|
||||
|
||||
/**
|
||||
* Icons from the mockup's inline SVGs (24×24 viewBox). Paths are rasterised into a small bitmap once per
|
||||
* 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(HEART),
|
||||
HeartFilled(HEART, stroked = false),
|
||||
Check("M5 12.5l4.5 4.5L19 7.5"),
|
||||
Plus("M12 5v14M5 12h14"),
|
||||
Play("M7 4.5v15l13-7.5z", stroked = false),
|
||||
Pause("M6 4h4v16H6zM14 4h4v16h-4z", stroked = false),
|
||||
Replay("M4 12a8 8 0 1 0 2.5-5.8M4 4v4.5h4.5"),
|
||||
Forward("M20 12a8 8 0 1 1-2.5-5.8M20 4v4.5h-4.5"),
|
||||
Previous("M19 5v14L9 12zM5 5h3v14H5z", stroked = false),
|
||||
Next("M5 5v14l10-7zM16 5h3v14h-3z", stroked = false),
|
||||
ChevronDown("M6 9l6 6 6-6"),
|
||||
ChevronRight("M9 6l6 6-6 6"),
|
||||
Settings("M4 7h9M17 7h3M4 17h3M11 17h9M15 4.5v5M9 14.5v5"),
|
||||
/** A movie library on the rail. */
|
||||
Film("M5 4h14a1 1 0 0 1 1 1v14a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1zM8 4v16M16 4v16M4 9h4M4 15h4M16 9h4M16 15h4"),
|
||||
/** A series library on the rail. */
|
||||
Tv("M5 7h14a1 1 0 0 1 1 1v10a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V8a1 1 0 0 1 1-1zM8.5 3l3.5 4 3.5-4"),
|
||||
;
|
||||
|
||||
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,77 @@
|
||||
package ru.stenfredd.sleepyowl.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.Immutable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.drawBehind
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import coil3.compose.AsyncImage
|
||||
import coil3.request.ImageRequest
|
||||
import coil3.request.transformations
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import ru.stenfredd.sleepyowl.ui.theme.AppColors
|
||||
|
||||
/** A server image: URL of exactly the displayed size and the BlurHash shown while it loads. */
|
||||
@Immutable
|
||||
data class RemoteImageSpec(val url: String, val blurHash: String? = null)
|
||||
|
||||
/**
|
||||
* 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. Until the image arrives the card shows its [BlurHash] placeholder
|
||||
* (decoded off the main thread), or a plain rounded rectangle. The image size comes from the URL, so Coil only
|
||||
* decodes. No crossfade: it would redraw every visible card for its duration.
|
||||
*/
|
||||
@Composable
|
||||
fun RemoteImage(image: RemoteImageSpec?, corner: Dp, modifier: Modifier = Modifier, shade: ImageShade? = null, contentScale: ContentScale = ContentScale.Crop) {
|
||||
val url = image?.url
|
||||
var loaded by remember(url) { mutableStateOf(false) }
|
||||
val shape = remember(corner) { RoundedCornerShape(corner) }
|
||||
val radius = with(LocalDensity.current) { corner.toPx() }
|
||||
var boxSize by remember { mutableStateOf(IntSize.Zero) }
|
||||
val placeholder by produceState<ImageBitmap?>(null, image?.blurHash, boxSize, radius, shade) {
|
||||
val hash = image?.blurHash
|
||||
value = if (hash == null || boxSize == IntSize.Zero) null else withContext(Dispatchers.Default) { BlurHash.placeholder(hash, boxSize.width, boxSize.height, radius, shade)?.asImageBitmap() }
|
||||
}
|
||||
val background = when {
|
||||
loaded -> modifier
|
||||
placeholder != null -> modifier.drawBehind {
|
||||
placeholder?.let { drawImage(it, srcOffset = IntOffset.Zero, dstSize = boxSize) }
|
||||
}
|
||||
else -> modifier.background(AppColors.SurfaceRaised, shape)
|
||||
}
|
||||
Box(background.onSizeChanged { boxSize = it }) {
|
||||
if (url != null) {
|
||||
val context = LocalContext.current
|
||||
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,114 @@
|
||||
package ru.stenfredd.sleepyowl.ui.components
|
||||
|
||||
import androidx.compose.animation.core.FastOutSlowInEasing
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.gestures.ScrollableState
|
||||
import androidx.compose.foundation.gestures.animateScrollBy
|
||||
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 (a scrolling column or a lazy list), 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<S : ScrollableState> internal constructor(val scroll: S, 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
|
||||
// A relative scroll works for a plain column and a lazy list alike; either stops at its ends by itself.
|
||||
val delta = bounds.top - anchorPx
|
||||
job?.cancel()
|
||||
job = scope.launch { scroll.animateScrollBy(delta, tween(ScrollMillis, easing = FastOutSlowInEasing)) }
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val ScrollMillis = 450
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun <S : ScrollableState> AnchoredScrolling(scroll: S, anchor: Dp, content: @Composable (AnchoredScroll<S>) -> 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)
|
||||
}
|
||||
|
||||
/**
|
||||
* Scrolls only as far as needed to show the focused element whole, plus [margin] (room for its focus glow):
|
||||
* for short side lists where the TV default (keep focus at ~30 % of the height) would scroll on the first focus.
|
||||
*/
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun MinimalScrolling(margin: Dp, content: @Composable () -> Unit) {
|
||||
val marginPx = with(LocalDensity.current) { margin.toPx() }
|
||||
val spec = remember(marginPx) {
|
||||
object : BringIntoViewSpec {
|
||||
override fun calculateScrollDistance(offset: Float, size: Float, containerSize: Float): Float {
|
||||
val start = offset - marginPx
|
||||
val end = offset + size + marginPx
|
||||
return when {
|
||||
start < 0f -> start
|
||||
end > containerSize -> (end - containerSize).coerceAtMost(start)
|
||||
else -> 0f
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
CompositionLocalProvider(LocalBringIntoViewSpec provides spec, content = content)
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package ru.stenfredd.sleepyowl.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.sleepyowl.ui.theme.AppDimens
|
||||
import ru.stenfredd.sleepyowl.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,218 @@
|
||||
package ru.stenfredd.sleepyowl.ui.components
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.ime
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.layout.LayoutCoordinates
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.layout.findRootCoordinates
|
||||
import androidx.compose.ui.layout.positionInRoot
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.BasicText
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
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.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusDirection
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusProperties
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.input.key.Key
|
||||
import androidx.compose.ui.input.key.KeyEventType
|
||||
import androidx.compose.ui.input.key.key
|
||||
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
||||
import androidx.compose.ui.input.key.type
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.text.input.VisualTransformation
|
||||
import androidx.compose.ui.unit.dp
|
||||
import ru.stenfredd.sleepyowl.ui.theme.AppColors
|
||||
|
||||
/**
|
||||
* Remote-friendly text field state. D-pad focus lands on the field's frame only; OK (or [edit]) puts the cursor in
|
||||
* and opens the on-screen keyboard. The keyboard's action button, Back, or Up/Down leave editing, so the remote can
|
||||
* always move on (a focused text field otherwise swallows the D-pad, and a keyboard popping up on mere focus is
|
||||
* annoying when passing through a form).
|
||||
*/
|
||||
@Stable
|
||||
class TextInputState {
|
||||
internal var editing by mutableStateOf(false)
|
||||
internal val frame = FocusRequester()
|
||||
internal val field = FocusRequester()
|
||||
|
||||
/** Focuses the field and starts typing. */
|
||||
fun edit() {
|
||||
editing = true
|
||||
}
|
||||
|
||||
/** Focuses the field's frame without opening the keyboard. */
|
||||
fun focus() {
|
||||
runCatching { frame.requestFocus() }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun rememberTextInputState() = remember { TextInputState() }
|
||||
|
||||
/**
|
||||
* How far a screen must move up so that the field being edited stays above the on-screen keyboard.
|
||||
*
|
||||
* Gboard for TV floats over the bottom ~45 % of the screen and reports no insets (its content insets start at the
|
||||
* screen bottom), so the system neither resizes nor pans the window. The keyboard top is taken from the IME insets
|
||||
* when a keyboard reports them, else assumed at [FLOATING_KEYBOARD_TOP] of the screen height.
|
||||
* The screen applies [offset] as a layer translation ([Modifier.liftForKeyboard]); fields report themselves.
|
||||
*/
|
||||
@Stable
|
||||
class KeyboardLift {
|
||||
var offset by mutableFloatStateOf(0f)
|
||||
internal set
|
||||
}
|
||||
|
||||
val LocalKeyboardLift = staticCompositionLocalOf<KeyboardLift?> { null }
|
||||
|
||||
fun Modifier.liftForKeyboard(lift: KeyboardLift) = graphicsLayer { translationY = -lift.offset }
|
||||
|
||||
private const val FLOATING_KEYBOARD_TOP = 0.54f
|
||||
|
||||
@Composable
|
||||
fun TextInput(
|
||||
value: String,
|
||||
onValueChange: (String) -> Unit,
|
||||
state: TextInputState,
|
||||
modifier: Modifier = Modifier,
|
||||
placeholder: String = "",
|
||||
textStyle: TextStyle,
|
||||
password: Boolean = false,
|
||||
keyboardType: KeyboardType = KeyboardType.Text,
|
||||
imeAction: ImeAction = ImeAction.Done,
|
||||
/** The keyboard's action button: next field, submit … */
|
||||
onAction: () -> Unit = {},
|
||||
) {
|
||||
val keyboard = LocalSoftwareKeyboardController.current
|
||||
val focusManager = LocalFocusManager.current
|
||||
val lift = LocalKeyboardLift.current
|
||||
val density = LocalDensity.current
|
||||
val imeBottom = WindowInsets.ime.getBottom(density)
|
||||
var focused by remember { mutableStateOf(false) }
|
||||
var swallowBackUp by remember { mutableStateOf(false) }
|
||||
val shape = RoundedCornerShape(8.dp)
|
||||
|
||||
fun stopEditing() {
|
||||
keyboard?.hide()
|
||||
state.editing = false
|
||||
state.focus()
|
||||
}
|
||||
|
||||
var coordinates by remember { mutableStateOf<LayoutCoordinates?>(null) }
|
||||
if (lift != null) DisposableEffect(state.editing, imeBottom) {
|
||||
val editing = state.editing
|
||||
val c = coordinates
|
||||
if (editing && c != null && c.isAttached) {
|
||||
val screenHeight = c.findRootCoordinates().size.height
|
||||
val keyboardTop = if (imeBottom > 0) screenHeight - imeBottom else (screenHeight * FLOATING_KEYBOARD_TOP).toInt()
|
||||
// Position without the current lift, so that applying it doesn't feed back.
|
||||
val bottom = c.positionInRoot().y + lift.offset + c.size.height
|
||||
lift.offset = (bottom + with(density) { 12.dp.toPx() } - keyboardTop).coerceAtLeast(0f)
|
||||
}
|
||||
onDispose { if (editing) lift.offset = 0f }
|
||||
}
|
||||
|
||||
LaunchedEffect(state.editing) {
|
||||
if (state.editing) {
|
||||
runCatching { state.field.requestFocus() }
|
||||
keyboard?.show()
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier
|
||||
.onGloballyPositioned { coordinates = it }
|
||||
.focusRequester(state.frame)
|
||||
.focusCard(InputFocus)
|
||||
.height(42.dp)
|
||||
.background(Color(0xFF0D0F16), shape)
|
||||
.border(0.5.dp, if (focused) AppColors.Accent else Color(0xFF2C3044), shape)
|
||||
.onFocusChanged { focused = it.hasFocus }
|
||||
.clickable(interactionSource = null, indication = null) { state.edit() }
|
||||
.padding(horizontal = 12.dp),
|
||||
contentAlignment = Alignment.CenterStart,
|
||||
) {
|
||||
if (value.isEmpty() && placeholder.isNotEmpty()) BasicText(placeholder, style = textStyle.copy(color = AppColors.TextFaint))
|
||||
BasicTextField(
|
||||
value = value,
|
||||
onValueChange = onValueChange,
|
||||
singleLine = true,
|
||||
textStyle = textStyle,
|
||||
cursorBrush = SolidColor(AppColors.Accent),
|
||||
visualTransformation = if (password) PasswordVisualTransformation() else VisualTransformation.None,
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = if (password) KeyboardType.Password else keyboardType,
|
||||
imeAction = imeAction,
|
||||
autoCorrectEnabled = false,
|
||||
),
|
||||
keyboardActions = KeyboardActions(onAny = {
|
||||
stopEditing()
|
||||
onAction()
|
||||
}),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.focusRequester(state.field)
|
||||
.focusProperties { canFocus = state.editing }
|
||||
.onFocusChanged { if (!it.isFocused && state.editing) state.editing = false }
|
||||
.onPreviewKeyEvent { e ->
|
||||
when {
|
||||
e.key == Key.Back && e.type == KeyEventType.KeyUp && swallowBackUp -> {
|
||||
swallowBackUp = false
|
||||
true
|
||||
}
|
||||
e.type != KeyEventType.KeyDown -> false
|
||||
e.key == Key.Back -> {
|
||||
swallowBackUp = true
|
||||
stopEditing()
|
||||
true
|
||||
}
|
||||
e.key == Key.DirectionUp || e.key == Key.DirectionDown -> {
|
||||
stopEditing()
|
||||
focusManager.moveFocus(if (e.key == Key.DirectionUp) FocusDirection.Up else FocusDirection.Down)
|
||||
true
|
||||
}
|
||||
else -> false
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private val InputFocus = FocusStyle(
|
||||
scale = 1f,
|
||||
corner = 8.dp,
|
||||
ring = 1.5.dp,
|
||||
ringColor = Color(0x59A594FF),
|
||||
glows = listOf(FocusStyle.Glow(15.dp, 0.dp, Color(0x597C5CFF))),
|
||||
)
|
||||
@@ -0,0 +1,69 @@
|
||||
package ru.stenfredd.sleepyowl.ui.details
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.LinearGradient
|
||||
import android.graphics.Matrix
|
||||
import android.graphics.Paint
|
||||
import android.graphics.RectF
|
||||
import android.graphics.Shader
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import coil3.size.Dimension
|
||||
import coil3.size.Size
|
||||
import coil3.transform.Transformation
|
||||
import ru.stenfredd.sleepyowl.ui.theme.AppColors
|
||||
|
||||
/**
|
||||
* The item page backdrop as one opaque bitmap: the image dimmed to 70 % over the app background and the mockup's
|
||||
* darkening gradient, baked in once on Coil's decode thread, so on screen it is a single opaque image draw.
|
||||
*
|
||||
* It covers only [area] (dp) of the [pageHeight] dp tall page, not the whole screen: on the test TV every frame
|
||||
* redraws the whole screen and a full-screen image costs ~10 ms of GPU per frame. Towards the left and bottom
|
||||
* edges of [area] the image fades into the background, so it has no visible edge.
|
||||
*/
|
||||
class BackdropTransformation(private val area: RectF, private val pageHeight: Float) : Transformation() {
|
||||
override val cacheKey = "backdrop:$area:$pageHeight"
|
||||
|
||||
override suspend fun transform(input: Bitmap, size: Size): Bitmap {
|
||||
val w = (size.width as? Dimension.Pixels)?.px ?: input.width
|
||||
val h = (size.height as? Dimension.Pixels)?.px ?: input.height
|
||||
val output = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888)
|
||||
val canvas = Canvas(output)
|
||||
val bg = AppColors.Background
|
||||
canvas.drawColor(bg.toArgb())
|
||||
|
||||
// Image, cropped to fill, at 70 % opacity.
|
||||
val image = Paint(Paint.FILTER_BITMAP_FLAG).apply { alpha = (0.7f * 255).toInt() }
|
||||
val scale = maxOf(w.toFloat() / input.width, h.toFloat() / input.height)
|
||||
canvas.drawBitmap(
|
||||
input,
|
||||
Matrix().apply {
|
||||
setScale(scale, scale)
|
||||
postTranslate((w - input.width * scale) / 2f, (h - input.height * scale) / 2f)
|
||||
},
|
||||
image,
|
||||
)
|
||||
|
||||
val k = w / area.width()
|
||||
// The mockup's full-page `linear-gradient(0deg, #0a0b10 0%, rgba(10,11,16,.75) 50%, rgba(10,11,16,.55) 100%)`,
|
||||
// placed in page coordinates.
|
||||
canvas.fill(w, h, LinearGradient(0f, (pageHeight - area.top) * k, 0f, -area.top * k, colors(bg.toArgb(), 1f, 0.75f, 0.55f), floatArrayOf(0f, 0.5f, 1f), Shader.TileMode.CLAMP))
|
||||
// Fade into the background at the left and bottom edges.
|
||||
val fadeX = w * LEFT_FADE
|
||||
canvas.fill(w, h, LinearGradient(0f, 0f, fadeX, 0f, colors(bg.toArgb(), 1f, 0.6f, 0f), floatArrayOf(0f, 0.35f, 1f), Shader.TileMode.CLAMP))
|
||||
val fadeY = h * BOTTOM_FADE
|
||||
canvas.fill(w, h, LinearGradient(0f, h.toFloat(), 0f, h - fadeY, colors(bg.toArgb(), 1f, 0f), null, Shader.TileMode.CLAMP))
|
||||
|
||||
output.setHasAlpha(false)
|
||||
return output
|
||||
}
|
||||
|
||||
private fun Canvas.fill(w: Int, h: Int, shader: Shader) = drawRect(0f, 0f, w.toFloat(), h.toFloat(), Paint().apply { this.shader = shader })
|
||||
|
||||
private fun colors(argb: Int, vararg alphas: Float) = IntArray(alphas.size) { (argb and 0xFFFFFF) or ((alphas[it] * 255).toInt() shl 24) }
|
||||
|
||||
private companion object {
|
||||
const val LEFT_FADE = 0.4f
|
||||
const val BOTTOM_FADE = 0.35f
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,342 @@
|
||||
package ru.stenfredd.sleepyowl.ui.details
|
||||
|
||||
import android.graphics.RectF
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.focusable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.BasicText
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.focus.focusRestorer
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.em
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import coil3.compose.AsyncImage
|
||||
import coil3.request.ImageRequest
|
||||
import coil3.request.transformations
|
||||
import ru.stenfredd.sleepyowl.ui.Screen
|
||||
import ru.stenfredd.sleepyowl.ui.components.AnchoredScrolling
|
||||
import ru.stenfredd.sleepyowl.ui.components.AppIcon
|
||||
import ru.stenfredd.sleepyowl.ui.components.Avatar
|
||||
import ru.stenfredd.sleepyowl.ui.components.Chip
|
||||
import ru.stenfredd.sleepyowl.ui.components.CircleButton
|
||||
import ru.stenfredd.sleepyowl.ui.components.ContentRow
|
||||
import ru.stenfredd.sleepyowl.ui.components.DefaultScrolling
|
||||
import ru.stenfredd.sleepyowl.ui.components.FocusStyle
|
||||
import ru.stenfredd.sleepyowl.ui.components.FormatBadge
|
||||
import ru.stenfredd.sleepyowl.ui.components.PosterCard
|
||||
import ru.stenfredd.sleepyowl.ui.components.PrimaryButton
|
||||
import ru.stenfredd.sleepyowl.ui.components.RemoteImage
|
||||
import ru.stenfredd.sleepyowl.ui.components.SecondaryButton
|
||||
import ru.stenfredd.sleepyowl.ui.components.SectionTitle
|
||||
import ru.stenfredd.sleepyowl.ui.components.anchorViewport
|
||||
import ru.stenfredd.sleepyowl.ui.components.anchoredRow
|
||||
import ru.stenfredd.sleepyowl.ui.components.focusCard
|
||||
import ru.stenfredd.sleepyowl.ui.focus.Restore
|
||||
import ru.stenfredd.sleepyowl.ui.focus.ScreenFocus
|
||||
import ru.stenfredd.sleepyowl.ui.focus.rememberScreenFocus
|
||||
import ru.stenfredd.sleepyowl.ui.focus.remembered
|
||||
import ru.stenfredd.sleepyowl.ui.theme.AppColors
|
||||
import ru.stenfredd.sleepyowl.ui.theme.AppDimens
|
||||
import ru.stenfredd.sleepyowl.ui.theme.AppType
|
||||
import ru.stenfredd.sleepyowl.ui.details.DetailsLayout as L
|
||||
|
||||
/** Movie (or episode) page, the mockup's "Фильм" screen. Full screen, without the side rail. */
|
||||
@Composable
|
||||
fun DetailsScreen(
|
||||
screen: Screen.Detail,
|
||||
onOpen: (Screen) -> Unit,
|
||||
vm: DetailsViewModel = hiltViewModel<DetailsViewModel, DetailsViewModel.Factory>(creationCallback = { it.create(screen.itemId) }),
|
||||
) {
|
||||
val state by vm.state.collectAsStateWithLifecycle()
|
||||
val focus = rememberScreenFocus()
|
||||
val item = state.item
|
||||
|
||||
when {
|
||||
item != null -> AnchoredScrolling(rememberScrollState(), anchor = AppDimens.CONTENT_TOP.dp) { anchored ->
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.anchorViewport(anchored)
|
||||
.verticalScroll(anchored.scroll)
|
||||
.padding(bottom = 200.dp),
|
||||
) {
|
||||
Hero(item, state, focus, vm, onOpen, Modifier.anchoredRow(anchored))
|
||||
if (item.cast.isNotEmpty()) Cast(item, focus, onOpen, Modifier.anchoredRow(anchored))
|
||||
if (state.similar.isNotEmpty()) {
|
||||
ContentRow("Похожие", null, Modifier.anchoredRow(anchored).padding(top = 6.dp)) {
|
||||
items(state.similar, key = { it.key }, contentType = { "poster" }) { p ->
|
||||
PosterCard(p, { vm.similarTarget(p.key)?.let(onOpen) }, Modifier.remembered(focus, p.key))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
state.error != null -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
BasicText("Не удалось загрузить: ${state.error}", style = AppType.Body)
|
||||
}
|
||||
}
|
||||
|
||||
// "С начала" disappears once the movie is watched to the end: focus "Смотреть" instead.
|
||||
val restartShown = (item?.resumeMs ?: 0) > 0
|
||||
LaunchedEffect(restartShown) {
|
||||
if (!restartShown && focus.lastKey == KEY_RESTART) focus.lastKey = KEY_PLAY
|
||||
}
|
||||
focus.Restore(ready = item != null)
|
||||
}
|
||||
|
||||
private const val KEY_PLAY = "play"
|
||||
private const val KEY_RESTART = "restart"
|
||||
|
||||
/** First screen of the page: backdrop, poster with formats, title, actions and the track choice. */
|
||||
@Composable
|
||||
private fun Hero(item: DetailsModel, state: DetailsState, focus: ScreenFocus, vm: DetailsViewModel, onOpen: (Screen) -> Unit, modifier: Modifier) {
|
||||
Box(modifier.fillMaxWidth().height(L.PAGE_H.dp)) {
|
||||
item.backdropUrl?.let { Backdrop(it) }
|
||||
Column(Modifier.offset(L.POSTER_X.dp, L.POSTER_Y.dp).width(L.POSTER_W.dp)) {
|
||||
val shape = RoundedCornerShape(L.POSTER_CORNER.dp)
|
||||
RemoteImage(
|
||||
item.poster,
|
||||
L.POSTER_CORNER.dp,
|
||||
Modifier.size(L.POSTER_W.dp, L.POSTER_H.dp).border(0.5.dp, Color(0x1AFFFFFF), shape),
|
||||
)
|
||||
if (item.badges.isNotEmpty()) {
|
||||
Row(Modifier.padding(top = 11.dp), horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
item.badges.forEach { FormatBadge(it) }
|
||||
}
|
||||
}
|
||||
item.fileLine?.let { BasicText(it, Modifier.padding(top = 7.dp), style = AppType.MonoMeta.copy(color = AppColors.TextDim)) }
|
||||
}
|
||||
Column(Modifier.padding(start = 320.dp, end = 60.dp, top = L.POSTER_Y.dp)) {
|
||||
BasicText(item.label, style = AppType.MonoLabel)
|
||||
BasicText(item.title, Modifier.padding(top = 12.dp), style = TitleStyle, maxLines = 2, overflow = TextOverflow.Ellipsis)
|
||||
item.subtitle?.let { BasicText(it, Modifier.padding(top = 8.dp), style = AppType.BodyLarge.copy(fontSize = 15.sp), maxLines = 1, overflow = TextOverflow.Ellipsis) }
|
||||
Meta(item, Modifier.padding(top = 11.dp))
|
||||
item.description?.let {
|
||||
BasicText(
|
||||
it,
|
||||
Modifier.padding(top = 11.dp).widthIn(max = 520.dp),
|
||||
style = AppType.Body.copy(fontSize = 13.sp, lineHeight = 19.5.sp, color = AppColors.TextSecondary),
|
||||
maxLines = if (item.subtitle != null) 3 else 4,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
Actions(item, focus, vm, onOpen, Modifier.padding(top = 18.dp))
|
||||
if (item.audio.isNotEmpty() || item.subtitles.isNotEmpty()) PlaybackOptions(item, state, focus, vm, Modifier.padding(top = 20.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val TitleStyle = AppType.Display.copy(fontSize = 46.sp, lineHeight = 48.sp, letterSpacing = (-0.03).em)
|
||||
|
||||
/** Backdrop, dimmed and faded at its edges ([BackdropTransformation]), at the top right of the first screen. */
|
||||
@Composable
|
||||
internal fun Backdrop(url: String) {
|
||||
val context = LocalContext.current
|
||||
val area = remember { RectF(L.PAGE_W - L.BACKDROP_W, 0f, L.PAGE_W, L.BACKDROP_H) }
|
||||
val request = remember(url) {
|
||||
ImageRequest.Builder(context)
|
||||
.data(url)
|
||||
.transformations(BackdropTransformation(area, L.PAGE_H))
|
||||
.build()
|
||||
}
|
||||
AsyncImage(
|
||||
request,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.offset(area.left.dp, area.top.dp).size(area.width().dp, area.height().dp),
|
||||
contentScale = ContentScale.FillBounds,
|
||||
)
|
||||
}
|
||||
|
||||
/** "★ 7.9 16+ 2024 2 ч 14 мин Драма, Приключения" */
|
||||
@Composable
|
||||
private fun Meta(item: DetailsModel, modifier: Modifier) {
|
||||
val text = AppType.Body.copy(fontSize = 12.sp, lineHeight = 12.sp, color = AppColors.TextSecondary)
|
||||
Row(modifier, verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(9.dp)) {
|
||||
item.rating?.let { BasicText(it, style = text.copy(color = AppColors.Yellow, fontWeight = FontWeight.SemiBold)) }
|
||||
item.age?.let {
|
||||
BasicText(
|
||||
it,
|
||||
Modifier.border(0.5.dp, Color(0xFF4A4F68), RoundedCornerShape(3.dp)).padding(horizontal = 5.dp, vertical = 2.dp),
|
||||
style = text.copy(fontSize = 10.sp, lineHeight = 10.sp),
|
||||
)
|
||||
}
|
||||
listOfNotNull(item.year, item.runtime, item.genres).forEach { BasicText(it, style = text, maxLines = 1, overflow = TextOverflow.Ellipsis) }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Actions(item: DetailsModel, focus: ScreenFocus, vm: DetailsViewModel, onOpen: (Screen) -> Unit, modifier: Modifier) {
|
||||
Row(modifier, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
val resume = item.resumeMs > 0
|
||||
PrimaryButton(
|
||||
when {
|
||||
!resume -> "Смотреть"
|
||||
item.remainingMinutes != null -> "Продолжить · ещё ${item.remainingMinutes} мин"
|
||||
else -> "Продолжить"
|
||||
},
|
||||
{ vm.player(fromStart = false)?.let(onOpen) },
|
||||
Modifier.remembered(focus, KEY_PLAY, isDefault = true),
|
||||
)
|
||||
if (resume) SecondaryButton("С начала", { vm.player(fromStart = true)?.let(onOpen) }, Modifier.remembered(focus, KEY_RESTART))
|
||||
CircleButton(
|
||||
if (item.favorite) AppIcon.HeartFilled else AppIcon.Heart,
|
||||
vm::toggleFavorite,
|
||||
Modifier.remembered(focus, "favorite"),
|
||||
tint = if (item.favorite) AppColors.Pink else Color(0xFFE3E5F2),
|
||||
)
|
||||
CircleButton(AppIcon.Check, vm::togglePlayed, Modifier.remembered(focus, "watched"), tint = if (item.played) AppColors.Green else Color(0xFFE3E5F2))
|
||||
}
|
||||
}
|
||||
|
||||
/** "Параметры воспроизведения": audio and subtitle chips, passed to the player when it starts. */
|
||||
@Composable
|
||||
private fun PlaybackOptions(item: DetailsModel, state: DetailsState, focus: ScreenFocus, vm: DetailsViewModel, modifier: Modifier) {
|
||||
val shape = RoundedCornerShape(10.dp)
|
||||
Column(
|
||||
modifier
|
||||
.widthIn(max = 580.dp)
|
||||
.fillMaxWidth()
|
||||
.background(PanelColor, shape)
|
||||
.border(0.5.dp, Color(0xFF262A3A), shape),
|
||||
) {
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(26.dp)
|
||||
.background(Color(0xFF181B27), RoundedCornerShape(topStart = 10.dp, topEnd = 10.dp))
|
||||
.padding(horizontal = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Spacer(Modifier.size(6.dp).background(AppColors.Accent, CircleShape))
|
||||
BasicText(
|
||||
"ПАРАМЕТРЫ ВОСПРОИЗВЕДЕНИЯ",
|
||||
Modifier.padding(start = 6.dp).weight(1f),
|
||||
style = AppType.MonoCaption.copy(letterSpacing = 0.1.em, color = AppColors.TextMuted),
|
||||
)
|
||||
BasicText("применятся при запуске", style = AppType.MonoCaption.copy(color = Color(0xFF6F7592)))
|
||||
}
|
||||
Spacer(Modifier.fillMaxWidth().height(0.5.dp).background(Color(0xFF232637)))
|
||||
Column(Modifier.padding(vertical = 5.dp)) {
|
||||
if (item.audio.isNotEmpty()) OptionRow("Аудио", AppColors.Pink, item.audio, state.audio, focus, vm::selectAudio)
|
||||
if (item.subtitles.isNotEmpty()) OptionRow("Субтитры", AppColors.Yellow, item.subtitles, state.subtitle, focus, vm::selectSubtitle)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val PanelColor = Color(0xFF111319)
|
||||
|
||||
@Composable
|
||||
private fun OptionRow(title: String, color: Color, options: List<TrackOption>, selected: Int?, focus: ScreenFocus, onPick: (Int?) -> Unit) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Row(Modifier.padding(start = 12.dp).width(78.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Spacer(Modifier.size(5.dp).background(color, CircleShape))
|
||||
BasicText(title, Modifier.padding(start = 5.dp), style = AppType.Body.copy(fontSize = 11.sp, lineHeight = 11.sp, color = AppColors.TextDim))
|
||||
}
|
||||
// Files can have a dozen subtitle tracks: the chips scroll instead of wrapping. The row opens on the chosen
|
||||
// track, and coming into the row from above focuses it.
|
||||
val chosen = remember { FocusRequester() }
|
||||
val selectedAt = options.indexOfFirst { it.index == selected }
|
||||
DefaultScrolling {
|
||||
LazyRow(
|
||||
state = rememberLazyListState(initialFirstVisibleItemIndex = if (selectedAt > 1) selectedAt - 1 else 0),
|
||||
modifier = Modifier.focusRestorer(chosen),
|
||||
contentPadding = PaddingValues(start = 2.dp, end = 12.dp, top = 6.dp, bottom = 6.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(5.dp),
|
||||
) {
|
||||
items(options, key = { it.key }) { o ->
|
||||
val modifier = if (o.index == selected) Modifier.focusRequester(chosen) else Modifier
|
||||
Chip(o.label, o.index == selected, { onPick(o.index) }, modifier.remembered(focus, o.key), detail = o.detail)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Cast(item: DetailsModel, focus: ScreenFocus, onOpen: (Screen) -> Unit, modifier: Modifier) {
|
||||
Column(modifier.padding(top = 10.dp)) {
|
||||
SectionTitle(item.castTitle, null)
|
||||
DefaultScrolling {
|
||||
LazyRow(
|
||||
state = rememberLazyListState(),
|
||||
modifier = Modifier.focusRestorer(),
|
||||
contentPadding = PaddingValues(horizontal = AppDimens.CONTENT_START.dp, vertical = 15.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
items(item.cast, key = { it.key }) { p -> CastChip(p, { p.personId?.let { onOpen(Screen.Person(it)) } }, Modifier.remembered(focus, p.key)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val CastFocus = FocusStyle(scale = 1.03f, corner = 27.5.dp, ring = 1.dp, glows = listOf(FocusStyle.Glow(17.dp, 0.dp, Color(0x737C5CFF))))
|
||||
|
||||
/** Actor pill: photo (or initials), name, role; opens the person's page. */
|
||||
@Composable
|
||||
private fun CastChip(person: CastModel, onClick: () -> Unit, modifier: Modifier) {
|
||||
var focused by remember { mutableStateOf(false) }
|
||||
val shape = RoundedCornerShape(27.5.dp)
|
||||
Row(
|
||||
modifier
|
||||
.focusCard(CastFocus)
|
||||
.height(55.dp)
|
||||
.background(if (focused) Color(0xFF1F2238) else AppColors.Surface, shape)
|
||||
.border(0.5.dp, if (focused) AppColors.Accent else Color(0xFF262A3A), shape)
|
||||
.onFocusChanged { focused = it.isFocused }
|
||||
.clickable(interactionSource = null, indication = null, onClick = onClick)
|
||||
.padding(start = 8.dp, end = 14.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(9.dp),
|
||||
) {
|
||||
if (person.image != null) {
|
||||
RemoteImage(person.image, (L.AVATAR / 2).dp, Modifier.size(L.AVATAR.dp))
|
||||
} else {
|
||||
Avatar(person.name, person.key, L.AVATAR.dp, AppType.CardTitle.copy(fontSize = 14.sp), letters = person.letters)
|
||||
}
|
||||
Column {
|
||||
BasicText(person.name, style = AppType.CardTitle.copy(fontSize = 12.sp, fontWeight = FontWeight.SemiBold), maxLines = 1)
|
||||
person.role?.let {
|
||||
BasicText(it, Modifier.padding(top = 2.dp).widthIn(max = 160.dp), style = AppType.Body.copy(fontSize = 9.5.sp, lineHeight = 11.sp, color = AppColors.TextDim), maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
package ru.stenfredd.sleepyowl.ui.details
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import ru.stenfredd.sleepyowl.core.data.ImageUrls
|
||||
import ru.stenfredd.sleepyowl.core.data.LibraryRepository
|
||||
import ru.stenfredd.sleepyowl.core.data.SessionCache
|
||||
import ru.stenfredd.sleepyowl.core.data.WatchRepository
|
||||
import ru.stenfredd.sleepyowl.core.data.model.ItemDetails
|
||||
import ru.stenfredd.sleepyowl.core.data.model.ItemKind
|
||||
import ru.stenfredd.sleepyowl.core.data.model.MediaInfo
|
||||
import ru.stenfredd.sleepyowl.core.data.model.PersonKind
|
||||
import ru.stenfredd.sleepyowl.playback.api.StreamSelection
|
||||
import ru.stenfredd.sleepyowl.playback.core.PlaybackPreferences
|
||||
import ru.stenfredd.sleepyowl.ui.Screen
|
||||
import ru.stenfredd.sleepyowl.ui.components.PosterModel
|
||||
import ru.stenfredd.sleepyowl.ui.components.RemoteImageSpec
|
||||
import ru.stenfredd.sleepyowl.ui.model.CardMapper
|
||||
import timber.log.Timber
|
||||
import java.util.Locale
|
||||
import java.util.UUID
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/** One audio or subtitle choice; [index] is the server's stream index, null = subtitles off. */
|
||||
@Immutable
|
||||
data class TrackOption(val key: String, val index: Int?, val label: String, val detail: String?)
|
||||
|
||||
@Immutable
|
||||
data class CastModel(val key: String, val name: String, val role: String?, val letters: String, val image: RemoteImageSpec?, val personId: UUID? = null)
|
||||
|
||||
/** Everything the movie (or episode) page shows, as display strings. */
|
||||
@Immutable
|
||||
data class DetailsModel(
|
||||
val id: UUID,
|
||||
/** "ФИЛЬМ", "СЕРИЯ · S1 · E3". */
|
||||
val label: String,
|
||||
val title: String,
|
||||
/** Episode title under the series title. */
|
||||
val subtitle: String?,
|
||||
val rating: String?,
|
||||
val age: String?,
|
||||
val year: String?,
|
||||
val runtime: String?,
|
||||
val genres: String?,
|
||||
val description: String?,
|
||||
val poster: RemoteImageSpec?,
|
||||
val backdropUrl: String?,
|
||||
val badges: List<String>,
|
||||
val fileLine: String?,
|
||||
val resumeMs: Long,
|
||||
val remainingMinutes: Int?,
|
||||
val favorite: Boolean,
|
||||
val played: Boolean,
|
||||
val audio: List<TrackOption>,
|
||||
val subtitles: List<TrackOption>,
|
||||
val castTitle: String,
|
||||
val cast: List<CastModel>,
|
||||
)
|
||||
|
||||
@Immutable
|
||||
data class DetailsState(
|
||||
val loading: Boolean = true,
|
||||
val error: String? = null,
|
||||
val item: DetailsModel? = null,
|
||||
/** Chosen audio stream index. */
|
||||
val audio: Int? = null,
|
||||
/** Chosen subtitle stream index, null = off. */
|
||||
val subtitle: Int? = null,
|
||||
val similar: List<PosterModel> = emptyList(),
|
||||
)
|
||||
|
||||
/** Movie or episode page: details, the track choice passed on to the player, favourite and watched. */
|
||||
@HiltViewModel(assistedFactory = DetailsViewModel.Factory::class)
|
||||
class DetailsViewModel @AssistedInject constructor(
|
||||
@Assisted private val itemId: UUID,
|
||||
private val library: LibraryRepository,
|
||||
private val watch: WatchRepository,
|
||||
private val images: ImageUrls,
|
||||
private val cards: CardMapper,
|
||||
private val prefs: PlaybackPreferences,
|
||||
cache: SessionCache,
|
||||
) : ViewModel() {
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
fun create(itemId: UUID): DetailsViewModel
|
||||
}
|
||||
|
||||
private val _state = MutableStateFlow(DetailsState())
|
||||
val state: StateFlow<DetailsState> = _state.asStateFlow()
|
||||
|
||||
/** The user picked tracks on this page: keep the choice over reloads. */
|
||||
private var tracksChosen = false
|
||||
private var similarTargets: Map<String, Screen> = emptyMap()
|
||||
/** Tracks are remembered per series (an episode) or per movie, see [PlaybackPreferences]. */
|
||||
private var memoryKey: UUID = itemId
|
||||
private var media: MediaInfo? = null
|
||||
|
||||
init {
|
||||
viewModelScope.launch { load() }
|
||||
// Back from the player (progress) or watched/favourite changed elsewhere: reload.
|
||||
viewModelScope.launch { cache.invalidations.collect { load() } }
|
||||
}
|
||||
|
||||
fun similarTarget(key: String): Screen? = similarTargets[key]
|
||||
|
||||
fun selectAudio(index: Int?) {
|
||||
tracksChosen = true
|
||||
_state.update { it.copy(audio = index) }
|
||||
viewModelScope.launch { TrackChoices.rememberAudio(prefs, memoryKey, media, index) }
|
||||
}
|
||||
|
||||
fun selectSubtitle(index: Int?) {
|
||||
tracksChosen = true
|
||||
_state.update { it.copy(subtitle = index) }
|
||||
viewModelScope.launch { TrackChoices.rememberSubtitle(prefs, memoryKey, media, index) }
|
||||
}
|
||||
|
||||
/** The player screen for this item: from the resume point unless [fromStart], with the chosen tracks. */
|
||||
fun player(fromStart: Boolean): Screen.Player? {
|
||||
val s = _state.value
|
||||
val item = s.item ?: return null
|
||||
return Screen.Player(
|
||||
itemId = item.id,
|
||||
engine = null,
|
||||
startPositionMs = if (fromStart) 0 else item.resumeMs,
|
||||
streams = StreamSelection(s.audio, s.subtitle),
|
||||
)
|
||||
}
|
||||
|
||||
fun toggleFavorite() = toggle({ it.favorite }, { m, v -> m.copy(favorite = v) }) { watch.setFavorite(itemId, it) }
|
||||
|
||||
fun togglePlayed() = toggle({ it.played }, { m, v -> m.copy(played = v) }) { watch.setPlayed(itemId, it) }
|
||||
|
||||
/** Shows the new value at once and reverts it if the server refuses. */
|
||||
private fun toggle(get: (DetailsModel) -> Boolean, set: (DetailsModel, Boolean) -> DetailsModel, send: suspend (Boolean) -> Unit) {
|
||||
val item = _state.value.item ?: return
|
||||
val value = !get(item)
|
||||
_state.update { s -> s.copy(item = s.item?.let { set(it, value) }) }
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
send(value)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Timber.w(e, "Details: update failed")
|
||||
_state.update { s -> s.copy(item = s.item?.let { set(it, !value) }) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun load() {
|
||||
try {
|
||||
coroutineScope {
|
||||
val similar = async { runCatching { library.similar(itemId) }.getOrDefault(emptyList()) }
|
||||
val details = library.details(itemId)
|
||||
val model = withContext(Dispatchers.Default) { details.toModel() }
|
||||
val media = details.media
|
||||
this@DetailsViewModel.media = media
|
||||
memoryKey = details.summary.seriesId ?: itemId
|
||||
val initial = media?.let { TrackChoices.initial(prefs, memoryKey, it) }
|
||||
_state.update { s ->
|
||||
val keep = tracksChosen && s.item != null
|
||||
s.copy(
|
||||
loading = false,
|
||||
error = null,
|
||||
item = model,
|
||||
audio = if (keep) s.audio else initial?.first,
|
||||
subtitle = if (keep) s.subtitle else initial?.second,
|
||||
)
|
||||
}
|
||||
// Similar items only make sense for movies; an episode would get random other series.
|
||||
val related = if (details.summary.kind == ItemKind.Episode) emptyList() else similar.await()
|
||||
val (posters, targets) = withContext(Dispatchers.Default) {
|
||||
related.map { cards.poster(it, SIMILAR) } to related.associate { cards.key(SIMILAR, it) to cards.target(it) }
|
||||
}
|
||||
similarTargets = targets
|
||||
_state.update { it.copy(similar = posters) }
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Timber.w(e, "Details: load failed")
|
||||
// Keep what is shown when a reload fails.
|
||||
_state.update { if (it.item == null) it.copy(loading = false, error = e.message ?: e.toString()) else it }
|
||||
}
|
||||
}
|
||||
|
||||
private fun ItemDetails.toModel(): DetailsModel {
|
||||
val item = summary
|
||||
val episode = item.kind == ItemKind.Episode
|
||||
val media = media
|
||||
val directors = people.filter { it.kind == PersonKind.Director }.map { it.name }
|
||||
val actors = people.filter { it.kind == PersonKind.Actor }.take(CAST_LIMIT)
|
||||
val progress = item.user.progress
|
||||
return DetailsModel(
|
||||
id = item.id,
|
||||
label = if (episode) {
|
||||
listOfNotNull("СЕРИЯ", item.seasonNumber?.let { "S$it" }, item.episodeNumber?.let { "E$it" }).joinToString(" · ")
|
||||
} else {
|
||||
"ФИЛЬМ"
|
||||
},
|
||||
title = if (episode) item.seriesName ?: item.name else item.name,
|
||||
subtitle = if (episode) item.name else null,
|
||||
rating = item.communityRating?.let { String.format(Locale.US, "★ %.1f", it) },
|
||||
// "RU-16+", "age6+" → "16+", "6+": the rating system's prefix says nothing on screen. "PG-13" stays.
|
||||
age = item.officialRating?.let { r -> Regex("""(\d{1,2}\+)$""").find(r)?.value ?: r },
|
||||
year = item.year?.toString(),
|
||||
runtime = MediaLabels.runtime(item.runtimeMs),
|
||||
genres = item.genres.take(3).joinToString(", ").ifEmpty { null },
|
||||
description = item.overview,
|
||||
poster = (if (episode) item.seriesPrimary ?: item.primary else item.primary)?.let {
|
||||
RemoteImageSpec(images.url(it, cards.px(DetailsLayout.POSTER_W), cards.px(DetailsLayout.POSTER_H)), it.blurHash)
|
||||
},
|
||||
backdropUrl = item.backdrop?.let { images.url(it, cards.px(DetailsLayout.BACKDROP_W), cards.px(DetailsLayout.BACKDROP_H), quality = 80) },
|
||||
badges = MediaLabels.badges(media),
|
||||
fileLine = MediaLabels.fileLine(media),
|
||||
resumeMs = item.user.positionMs.takeIf { progress != null } ?: 0,
|
||||
remainingMinutes = if (progress != null && item.runtimeMs != null) (item.runtimeMs!! * (1 - progress) / 60_000).roundToInt() else null,
|
||||
favorite = item.user.favorite,
|
||||
played = item.user.played,
|
||||
audio = TrackChoices.audio(media),
|
||||
subtitles = TrackChoices.subtitles(media),
|
||||
castTitle = listOfNotNull("В ролях", directors.takeIf { it.isNotEmpty() }?.let { "режиссёр " + it.joinToString(", ") }).joinToString(" · "),
|
||||
cast = actors.map { p ->
|
||||
CastModel(
|
||||
key = "cast/${p.id}",
|
||||
name = p.name,
|
||||
role = p.role,
|
||||
letters = p.name.split(' ').filter { it.isNotBlank() }.take(2).joinToString("") { it.take(1).uppercase() },
|
||||
image = p.image?.let { RemoteImageSpec(images.url(it, cards.px(DetailsLayout.AVATAR), cards.px(DetailsLayout.AVATAR)), it.blurHash) },
|
||||
personId = p.id,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val SIMILAR = "similar"
|
||||
const val CAST_LIMIT = 20
|
||||
}
|
||||
}
|
||||
|
||||
/** Sizes (dp) of the page shared by the layout, the image requests and the baked backdrop. */
|
||||
internal object DetailsLayout {
|
||||
const val PAGE_W = 960f
|
||||
const val PAGE_H = 540f
|
||||
const val POSTER_X = 60f
|
||||
const val POSTER_Y = 48f
|
||||
const val POSTER_W = 220f
|
||||
const val POSTER_H = 330f
|
||||
const val POSTER_CORNER = 11f
|
||||
/** The backdrop covers 70 % of the page width and height at the top right (see [BackdropTransformation]). */
|
||||
const val BACKDROP_W = 672f
|
||||
const val BACKDROP_H = 378f
|
||||
const val AVATAR = 39f
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
package ru.stenfredd.sleepyowl.ui.details
|
||||
|
||||
import ru.stenfredd.sleepyowl.core.data.model.AudioTrack
|
||||
import ru.stenfredd.sleepyowl.core.data.model.MediaInfo
|
||||
import ru.stenfredd.sleepyowl.core.data.model.SubtitleTrack
|
||||
import ru.stenfredd.sleepyowl.core.data.model.VideoRange
|
||||
import java.util.Locale
|
||||
|
||||
/** How the item page names languages, codecs and formats: "Русский", "TrueHD Atmos 7.1", "HEVC 2160p HDR10 · 62.4 ГБ". */
|
||||
internal object MediaLabels {
|
||||
private val russian = Locale.forLanguageTag("ru")
|
||||
|
||||
// The server gives ISO 639-2 codes; Locale knows most, but not the bibliographic variants (fre, ger, chi …).
|
||||
private val languages = mapOf(
|
||||
"rus" to "Русский", "eng" to "English", "ukr" to "Українська", "jpn" to "日本語", "kor" to "한국어",
|
||||
"chi" to "中文", "zho" to "中文", "fre" to "Français", "fra" to "Français", "ger" to "Deutsch", "deu" to "Deutsch",
|
||||
"spa" to "Español", "ita" to "Italiano", "por" to "Português", "pol" to "Polski", "tur" to "Türkçe",
|
||||
)
|
||||
|
||||
/** Language in its own name for the common ones (as the mockup: "Русский", "English"), else in Russian. */
|
||||
fun language(code: String?): String? {
|
||||
val c = code?.lowercase()?.takeIf { it.isNotBlank() && it != "und" } ?: return null
|
||||
languages[c]?.let { return it }
|
||||
val name = Locale.forLanguageTag(c).getDisplayLanguage(russian)
|
||||
return name.takeIf { it.isNotBlank() && !it.equals(c, ignoreCase = true) }?.replaceFirstChar { it.titlecase(russian) } ?: c
|
||||
}
|
||||
|
||||
fun audioLabel(track: AudioTrack): String =
|
||||
listOfNotNull(language(track.language), track.name).joinToString(" · ").ifEmpty { track.title }
|
||||
|
||||
/** "TrueHD Atmos 7.1" */
|
||||
fun audioDetail(track: AudioTrack): String? = listOfNotNull(
|
||||
audioCodec(track.codec, track.profile),
|
||||
"Atmos".takeIf { track.atmos },
|
||||
channels(track.channels),
|
||||
).joinToString(" ").ifEmpty { null }
|
||||
|
||||
fun subtitleLabel(track: SubtitleTrack): String {
|
||||
val name = track.name ?: "надписи".takeIf { track.forced }
|
||||
return listOfNotNull(language(track.language), name).joinToString(" · ").ifEmpty { track.title }
|
||||
}
|
||||
|
||||
fun subtitleDetail(track: SubtitleTrack): String? = when (val codec = track.codec?.lowercase()) {
|
||||
null -> null
|
||||
"subrip", "srt" -> "SRT"
|
||||
"ass", "ssa" -> "ASS"
|
||||
"pgssub", "hdmv_pgs_subtitle" -> "PGS"
|
||||
"dvdsub", "dvd_subtitle", "vobsub" -> "VOBSUB"
|
||||
"webvtt", "vtt" -> "VTT"
|
||||
"mov_text" -> "TX3G"
|
||||
else -> codec.uppercase()
|
||||
}
|
||||
|
||||
/** "4K", "HDR10", "Atmos": resolution, dynamic range and the best sound of the file. */
|
||||
fun badges(media: MediaInfo?): List<String> {
|
||||
media ?: return emptyList()
|
||||
val video = media.video
|
||||
val width = video?.width ?: 0
|
||||
val height = video?.height ?: 0
|
||||
return buildList {
|
||||
when {
|
||||
width >= 3200 || height >= 2000 -> add("4K")
|
||||
width >= 1800 || height >= 1000 -> add("1080p")
|
||||
width >= 1200 || height >= 700 -> add("720p")
|
||||
}
|
||||
range(video?.range)?.let(::add)
|
||||
val audio = media.audio
|
||||
when {
|
||||
audio.any { it.atmos } -> add("Atmos")
|
||||
audio.any { (it.channels ?: 0) >= 8 } -> add("7.1")
|
||||
audio.any { (it.channels ?: 0) >= 6 } -> add("5.1")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** "HEVC 2160p HDR10 · 62.4 ГБ" */
|
||||
fun fileLine(media: MediaInfo?): String? {
|
||||
media ?: return null
|
||||
val video = media.video
|
||||
val format = listOfNotNull(
|
||||
videoCodec(video?.codec),
|
||||
video?.height?.let { "${it}p" },
|
||||
range(video?.range)?.let { if (it == "DV" && video?.dvProfile != null) "DV P${video.dvProfile}" else it },
|
||||
).joinToString(" ")
|
||||
val size = media.sizeBytes?.takeIf { it > 0 }?.let { String.format(Locale.US, "%.1f ГБ", it / 1e9) }
|
||||
return listOfNotNull(format.ifEmpty { null }, size).joinToString(" · ").ifEmpty { null }
|
||||
}
|
||||
|
||||
/** "2 ч 14 мин" */
|
||||
fun runtime(ms: Long?): String? {
|
||||
val minutes = ((ms ?: return null) / 60_000).toInt().takeIf { it > 0 } ?: return null
|
||||
return if (minutes >= 60) "${minutes / 60} ч ${minutes % 60} мин" else "$minutes мин"
|
||||
}
|
||||
|
||||
private fun range(range: VideoRange?): String? = when (range) {
|
||||
VideoRange.HDR10 -> "HDR10"
|
||||
VideoRange.HDR10Plus -> "HDR10+"
|
||||
VideoRange.HLG -> "HLG"
|
||||
VideoRange.DolbyVision -> "DV"
|
||||
VideoRange.Other -> "HDR"
|
||||
VideoRange.SDR, null -> null
|
||||
}
|
||||
|
||||
fun videoCodec(codec: String?): String? = when (codec?.lowercase()) {
|
||||
null -> null
|
||||
"hevc", "h265" -> "HEVC"
|
||||
"h264", "avc" -> "H.264"
|
||||
"mpeg2video" -> "MPEG-2"
|
||||
"mpeg4" -> "MPEG-4"
|
||||
"vc1" -> "VC-1"
|
||||
else -> codec.uppercase()
|
||||
}
|
||||
|
||||
private fun audioCodec(codec: String?, profile: String?): String? = when (codec?.lowercase()) {
|
||||
null -> null
|
||||
"ac3" -> "AC3"
|
||||
"eac3" -> "E-AC3"
|
||||
"truehd" -> "TrueHD"
|
||||
"dts" -> when {
|
||||
profile == null -> "DTS"
|
||||
profile.contains("DTS:X") -> "DTS:X"
|
||||
profile.contains("MA") -> "DTS-HD MA"
|
||||
profile.contains("HRA") || profile.contains("HD") -> "DTS-HD"
|
||||
else -> "DTS"
|
||||
}
|
||||
"aac" -> "AAC"
|
||||
"flac" -> "FLAC"
|
||||
"opus" -> "Opus"
|
||||
"mp3" -> "MP3"
|
||||
"pcm_s16le", "pcm_s24le", "pcm_bluray" -> "PCM"
|
||||
else -> codec.uppercase()
|
||||
}
|
||||
|
||||
private fun channels(n: Int?): String? = when (n) {
|
||||
null, 0 -> null
|
||||
1 -> "1.0"
|
||||
2 -> "2.0"
|
||||
6 -> "5.1"
|
||||
8 -> "7.1"
|
||||
else -> "${n}ch"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package ru.stenfredd.sleepyowl.ui.details
|
||||
|
||||
import ru.stenfredd.sleepyowl.core.data.model.MediaInfo
|
||||
import ru.stenfredd.sleepyowl.playback.core.PlaybackPreferences
|
||||
import ru.stenfredd.sleepyowl.playback.core.TrackPick
|
||||
import java.util.UUID
|
||||
|
||||
/** Audio and subtitle choices of a file as the movie page and the series tray show them. */
|
||||
internal object TrackChoices {
|
||||
fun audio(media: MediaInfo?): List<TrackOption> =
|
||||
media?.audio.orEmpty().map { TrackOption("audio/${it.index}", it.index, MediaLabels.audioLabel(it), MediaLabels.audioDetail(it)) }
|
||||
|
||||
/** "Выкл" first, then the tracks; empty when the file has no subtitles. */
|
||||
fun subtitles(media: MediaInfo?): List<TrackOption> =
|
||||
if (media == null || media.subtitles.isEmpty()) {
|
||||
emptyList()
|
||||
} else {
|
||||
listOf(TrackOption("sub/off", null, "Выкл", null)) +
|
||||
media.subtitles.map { TrackOption("sub/${it.index}", it.index, MediaLabels.subtitleLabel(it), MediaLabels.subtitleDetail(it)) }
|
||||
}
|
||||
|
||||
/**
|
||||
* The tracks picked last time for this movie or series ([key]), found in [media]: (audio, subtitle), or null if
|
||||
* nothing is remembered. Unmatched audio stays null (the file's default is used); a remembered subtitle that this
|
||||
* file doesn't have falls back to the default too.
|
||||
*/
|
||||
suspend fun remembered(prefs: PlaybackPreferences, key: UUID, media: MediaInfo): Pair<Int?, Int?>? {
|
||||
val memory = prefs.tracks(key) ?: return null
|
||||
val audio = memory.audio?.bestOf(media.audio) { TrackPick(it.language, it.name, it.codec) }?.index
|
||||
val subtitle = when {
|
||||
memory.subtitleOff -> null
|
||||
else -> memory.subtitle?.bestOf(media.subtitles) { TrackPick(it.language, it.name, it.codec) }?.index ?: media.defaultSubtitleIndex
|
||||
}
|
||||
return audio to subtitle
|
||||
}
|
||||
|
||||
/** Remembered tracks, else the file's defaults: (audio, subtitle). */
|
||||
suspend fun initial(prefs: PlaybackPreferences, key: UUID, media: MediaInfo): Pair<Int?, Int?> {
|
||||
val remembered = remembered(prefs, key, media)
|
||||
return Pair(
|
||||
remembered?.first ?: media.defaultAudioIndex ?: media.audio.firstOrNull()?.index,
|
||||
if (remembered != null) remembered.second else media.defaultSubtitleIndex,
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun rememberAudio(prefs: PlaybackPreferences, key: UUID, media: MediaInfo?, index: Int?) {
|
||||
val track = media?.audio?.firstOrNull { it.index == index } ?: return
|
||||
prefs.rememberAudio(key, TrackPick(track.language, track.name, track.codec))
|
||||
}
|
||||
|
||||
suspend fun rememberSubtitle(prefs: PlaybackPreferences, key: UUID, media: MediaInfo?, index: Int?) {
|
||||
val pick = media?.subtitles?.firstOrNull { it.index == index }?.let { TrackPick(it.language, it.name, it.codec) }
|
||||
prefs.rememberSubtitle(key, pick)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package ru.stenfredd.sleepyowl.ui.favorites
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.items
|
||||
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
|
||||
import androidx.compose.foundation.text.BasicText
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.focusRestorer
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import ru.stenfredd.sleepyowl.ui.Screen
|
||||
import ru.stenfredd.sleepyowl.ui.components.AnchoredScrolling
|
||||
import ru.stenfredd.sleepyowl.ui.components.Chip
|
||||
import ru.stenfredd.sleepyowl.ui.components.PosterCard
|
||||
import ru.stenfredd.sleepyowl.ui.components.anchorViewport
|
||||
import ru.stenfredd.sleepyowl.ui.components.anchoredRow
|
||||
import ru.stenfredd.sleepyowl.ui.components.fullItem
|
||||
import ru.stenfredd.sleepyowl.ui.focus.ForgetMissing
|
||||
import ru.stenfredd.sleepyowl.ui.focus.Restore
|
||||
import ru.stenfredd.sleepyowl.ui.focus.rememberScreenFocus
|
||||
import ru.stenfredd.sleepyowl.ui.focus.remembered
|
||||
import ru.stenfredd.sleepyowl.ui.theme.AppColors
|
||||
import ru.stenfredd.sleepyowl.ui.theme.AppDimens
|
||||
import ru.stenfredd.sleepyowl.ui.theme.AppType
|
||||
|
||||
/** Favourites tab (mockup screen «Избранное»): title with the count, library filter chips, grid of posters. */
|
||||
@Composable
|
||||
fun FavoritesScreen(onOpen: (Screen) -> Unit, vm: FavoritesViewModel = hiltViewModel()) {
|
||||
val state by vm.state.collectAsStateWithLifecycle()
|
||||
val focus = rememberScreenFocus()
|
||||
val grid = rememberLazyGridState()
|
||||
|
||||
when {
|
||||
state.loading -> Message("Загрузка…")
|
||||
state.error != null -> Message("Не удалось загрузить: ${state.error}")
|
||||
else -> AnchoredScrolling(grid, anchor = AppDimens.CONTENT_TOP.dp) { anchored ->
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.FixedSize(AppDimens.POSTER_W.dp),
|
||||
state = grid,
|
||||
modifier = Modifier.fillMaxSize().anchorViewport(anchored),
|
||||
contentPadding = PaddingValues(start = Side, end = Side, top = AppDimens.CONTENT_TOP.dp, bottom = 200.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(GridGap),
|
||||
) {
|
||||
fullItem("header", "header") {
|
||||
Column(Modifier.anchoredRow(anchored).padding(bottom = 18.dp)) {
|
||||
Row(verticalAlignment = Alignment.Bottom, horizontalArrangement = Arrangement.spacedBy(9.dp)) {
|
||||
BasicText("Избранное", style = AppType.Display.copy(fontSize = 28.sp))
|
||||
BasicText(state.total, Modifier.padding(bottom = 4.dp), style = AppType.MonoCount.copy(fontSize = 11.sp, color = AppColors.TextDim))
|
||||
}
|
||||
Row(Modifier.padding(top = 15.dp).focusRestorer(), horizontalArrangement = Arrangement.spacedBy(5.dp)) {
|
||||
for (f in state.filters) {
|
||||
Chip(
|
||||
f.label,
|
||||
f.libraryId == state.selected,
|
||||
{ vm.select(f.libraryId) },
|
||||
Modifier.remembered(focus, f.key, isDefault = f.key == FavoritesViewModel.ALL_KEY),
|
||||
detail = f.count.toString(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (state.items.isEmpty()) {
|
||||
fullItem("empty", "text") {
|
||||
BasicText(
|
||||
"Здесь появятся фильмы и сериалы, отмеченные сердечком на их странице.",
|
||||
Modifier.padding(top = 12.dp).widthIn(max = 550.dp),
|
||||
style = AppType.Body.copy(fontSize = 14.sp, lineHeight = 20.sp),
|
||||
)
|
||||
}
|
||||
}
|
||||
items(state.items, key = { it.key }, contentType = { "poster" }) { p ->
|
||||
PosterCard(
|
||||
p,
|
||||
{ vm.target(p.key)?.let(onOpen) },
|
||||
Modifier.padding(bottom = RowGap).anchoredRow(anchored).remembered(focus, p.key),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
val ready = !state.loading && state.error == null
|
||||
if (ready) focus.ForgetMissing(state.filters.mapTo(HashSet()) { it.key } + state.items.map { it.key })
|
||||
focus.Restore(ready = ready)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Message(text: String) {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { BasicText(text, style = AppType.Body) }
|
||||
}
|
||||
|
||||
private val Side = AppDimens.CONTENT_START.dp
|
||||
private val GridGap = 14.dp
|
||||
private val RowGap = 18.dp
|
||||
@@ -0,0 +1,119 @@
|
||||
package ru.stenfredd.sleepyowl.ui.favorites
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
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.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import ru.stenfredd.sleepyowl.core.data.LibraryRepository
|
||||
import ru.stenfredd.sleepyowl.core.data.SessionCache
|
||||
import ru.stenfredd.sleepyowl.core.data.WatchRepository
|
||||
import ru.stenfredd.sleepyowl.core.data.model.ItemSummary
|
||||
import ru.stenfredd.sleepyowl.core.data.model.LibraryView
|
||||
import ru.stenfredd.sleepyowl.ui.Screen
|
||||
import ru.stenfredd.sleepyowl.ui.components.PosterModel
|
||||
import ru.stenfredd.sleepyowl.ui.model.CardMapper
|
||||
import ru.stenfredd.sleepyowl.ui.model.plural
|
||||
import timber.log.Timber
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import javax.inject.Inject
|
||||
|
||||
/** "Фильмы 3": a library with favourites, or everything ([libraryId] null). */
|
||||
@Immutable
|
||||
data class FavoriteFilter(val key: String, val libraryId: UUID?, val label: String, val count: Int)
|
||||
|
||||
@Immutable
|
||||
data class FavoritesState(
|
||||
val loading: Boolean = true,
|
||||
val error: String? = null,
|
||||
/** "7 тайтлов". */
|
||||
val total: String = "",
|
||||
val filters: List<FavoriteFilter> = emptyList(),
|
||||
val selected: UUID? = null,
|
||||
val items: List<PosterModel> = emptyList(),
|
||||
)
|
||||
|
||||
/**
|
||||
* Favourites tab: favourite movies and series of every library, filtered by library (the mockup's Всё / Фильмы /
|
||||
* Сериалы / Аниме are this server's libraries). Fetched per library with its own item type (fast on the server),
|
||||
* reloaded when watch state or favourites change.
|
||||
*/
|
||||
@HiltViewModel
|
||||
class FavoritesViewModel @Inject constructor(
|
||||
private val library: LibraryRepository,
|
||||
private val watch: WatchRepository,
|
||||
private val cache: SessionCache,
|
||||
private val cards: CardMapper,
|
||||
) : ViewModel() {
|
||||
private val _state = MutableStateFlow(FavoritesState())
|
||||
val state: StateFlow<FavoritesState> = _state.asStateFlow()
|
||||
|
||||
private var byLibrary: List<Pair<LibraryView, List<ItemSummary>>> = emptyList()
|
||||
private val targets = ConcurrentHashMap<String, Screen>()
|
||||
|
||||
init {
|
||||
viewModelScope.launch { load() }
|
||||
viewModelScope.launch { cache.invalidations.collect { load() } }
|
||||
}
|
||||
|
||||
fun target(key: String): Screen? = targets[key]
|
||||
|
||||
fun select(libraryId: UUID?) {
|
||||
if (libraryId == _state.value.selected) return
|
||||
viewModelScope.launch { publish(libraryId) }
|
||||
}
|
||||
|
||||
private suspend fun load() {
|
||||
try {
|
||||
byLibrary = coroutineScope {
|
||||
library.views().map { view -> async { view to watch.favorites(view) } }.awaitAll()
|
||||
}
|
||||
publish(_state.value.selected)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Timber.w(e, "Favourites failed")
|
||||
if (_state.value.loading) _state.value = FavoritesState(loading = false, error = e.message ?: e.toString())
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun publish(selected: UUID?) {
|
||||
val withItems = byLibrary.filter { it.second.isNotEmpty() }
|
||||
// A library whose last favourite was removed has no chip any more: back to everything.
|
||||
val filter = selected?.takeIf { id -> withItems.any { it.first.id == id } }
|
||||
val total = withItems.sumOf { it.second.size }
|
||||
val shown = withItems.filter { filter == null || it.first.id == filter }.flatMap { it.second }.sortedBy { it.name.lowercase() }
|
||||
val items = withContext(Dispatchers.Default) {
|
||||
shown.map { item ->
|
||||
targets[cards.key(ROW, item)] = cards.target(item)
|
||||
cards.kindPoster(item, ROW).copy(favorite = true)
|
||||
}
|
||||
}
|
||||
_state.update {
|
||||
FavoritesState(
|
||||
loading = false,
|
||||
total = "$total ${plural(total, "тайтл", "тайтла", "тайтлов")}",
|
||||
filters = listOf(FavoriteFilter(ALL_KEY, null, "Всё", total)) +
|
||||
withItems.map { (view, list) -> FavoriteFilter("fav/${view.id}", view.id, view.name, list.size) },
|
||||
selected = filter,
|
||||
items = items,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val ALL_KEY = "fav/all"
|
||||
private const val ROW = "fav"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package ru.stenfredd.sleepyowl.ui.focus
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.SideEffect
|
||||
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()
|
||||
|
||||
/** Focuses the element with [lastKey] (after it has been composed with that key). */
|
||||
fun requestFocus() {
|
||||
runCatching { requester.requestFocus() }
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
// Two frames: the content may still recompose once (see [ForgetMissing]) before the requester is in place.
|
||||
withFrameNanos { }
|
||||
withFrameNanos { }
|
||||
runCatching { requester.requestFocus() }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Forgets the remembered element when it is no longer among [keys] (an item left the list while the screen was in
|
||||
* the back stack, e.g. removed from favourites), so the default element gets focus instead of none.
|
||||
*/
|
||||
@Composable
|
||||
fun ScreenFocus.ForgetMissing(keys: Set<String>) {
|
||||
SideEffect { if (lastKey != null && lastKey !in keys) lastKey = null }
|
||||
}
|
||||
100
app/src/main/kotlin/ru/stenfredd/sleepyowl/ui/home/HomeScreen.kt
Normal file
100
app/src/main/kotlin/ru/stenfredd/sleepyowl/ui/home/HomeScreen.kt
Normal file
@@ -0,0 +1,100 @@
|
||||
package ru.stenfredd.sleepyowl.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.sleepyowl.ui.Screen
|
||||
import ru.stenfredd.sleepyowl.ui.components.AnchoredScrolling
|
||||
import ru.stenfredd.sleepyowl.ui.components.anchorViewport
|
||||
import ru.stenfredd.sleepyowl.ui.components.anchoredRow
|
||||
import ru.stenfredd.sleepyowl.ui.components.ContentRow
|
||||
import ru.stenfredd.sleepyowl.ui.components.FeaturedCard
|
||||
import ru.stenfredd.sleepyowl.ui.components.PosterCard
|
||||
import ru.stenfredd.sleepyowl.ui.components.SectionTitle
|
||||
import ru.stenfredd.sleepyowl.ui.components.WideCard
|
||||
import ru.stenfredd.sleepyowl.ui.focus.Restore
|
||||
import ru.stenfredd.sleepyowl.ui.focus.rememberScreenFocus
|
||||
import ru.stenfredd.sleepyowl.ui.focus.remembered
|
||||
import ru.stenfredd.sleepyowl.ui.theme.AppDimens
|
||||
import ru.stenfredd.sleepyowl.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.sleepyowl.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) }
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package ru.stenfredd.sleepyowl.ui.home
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.launch
|
||||
import ru.stenfredd.sleepyowl.core.data.HomeContent
|
||||
import ru.stenfredd.sleepyowl.core.data.HomeRepository
|
||||
import ru.stenfredd.sleepyowl.core.data.model.ItemSummary
|
||||
import ru.stenfredd.sleepyowl.ui.Screen
|
||||
import ru.stenfredd.sleepyowl.ui.components.FeaturedModel
|
||||
import ru.stenfredd.sleepyowl.ui.components.PosterModel
|
||||
import ru.stenfredd.sleepyowl.ui.components.WideModel
|
||||
import ru.stenfredd.sleepyowl.ui.model.CardMapper
|
||||
import timber.log.Timber
|
||||
import javax.inject.Inject
|
||||
|
||||
@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: [HomeRepository] content (shown at once from the last snapshot, then refreshed) as card models. */
|
||||
@HiltViewModel
|
||||
class HomeViewModel @Inject constructor(
|
||||
private val home: HomeRepository,
|
||||
private val cards: CardMapper,
|
||||
) : ViewModel() {
|
||||
private val _state = MutableStateFlow(HomeState())
|
||||
val state: StateFlow<HomeState> = _state.asStateFlow()
|
||||
|
||||
/** Card key → where it leads; replaced with every update. */
|
||||
private var targets: Map<String, Screen> = emptyMap()
|
||||
|
||||
init {
|
||||
val started = System.nanoTime()
|
||||
var first = true
|
||||
viewModelScope.launch {
|
||||
home.home()
|
||||
// ~100 cards with their image URLs: built off the main thread.
|
||||
.map { build(it) }
|
||||
.flowOn(Dispatchers.Default)
|
||||
.catch {
|
||||
Timber.w(it, "Home load failed")
|
||||
_state.value = HomeState(loading = false, error = it.message ?: it.toString())
|
||||
}
|
||||
.collect { (state, newTargets) ->
|
||||
if (first) Timber.i("Home shown in %d ms", (System.nanoTime() - started) / 1_000_000)
|
||||
first = false
|
||||
targets = newTargets
|
||||
_state.value = state
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun target(key: String): Screen? = targets[key]
|
||||
|
||||
private fun build(content: HomeContent): Pair<HomeState, Map<String, Screen>> {
|
||||
val newTargets = HashMap<String, Screen>()
|
||||
fun <T> ItemSummary.card(row: String, map: (ItemSummary, String) -> T): T {
|
||||
newTargets[cards.key(row, this)] = cards.target(this)
|
||||
return map(this, row)
|
||||
}
|
||||
val rows = buildList {
|
||||
if (content.latest.isNotEmpty()) add(HomeRow("latest", "Недавно добавлено", null, content.latest.map { it.card("latest", cards::poster) }))
|
||||
content.libraries.forEach { lib ->
|
||||
val key = "lib-${lib.library.id}"
|
||||
if (lib.items.isNotEmpty()) add(HomeRow(key, lib.library.name, lib.total, lib.items.map { it.card(key, cards::poster) }))
|
||||
}
|
||||
}
|
||||
val cont = content.continueWatching
|
||||
val state = HomeState(
|
||||
loading = false,
|
||||
featured = cont.firstOrNull()?.card(CONTINUE, cards::featured),
|
||||
continueItems = cont.drop(1).take(4).map { it.card(CONTINUE, cards::wide) },
|
||||
continueCount = cont.size,
|
||||
rows = rows,
|
||||
)
|
||||
return state to newTargets
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val CONTINUE = "continue"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package ru.stenfredd.sleepyowl.ui.library
|
||||
|
||||
import ru.stenfredd.sleepyowl.core.data.model.ItemSort
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* What the library tab was left at, for this run of the app: the selected library of each account and the grid's
|
||||
* sort and filter of each library. The tab's view model goes away when another tab opens.
|
||||
*/
|
||||
@Singleton
|
||||
class LibraryMemory @Inject constructor() {
|
||||
private val libraries = ConcurrentHashMap<String, UUID>()
|
||||
private val sorts = ConcurrentHashMap<UUID, Pair<ItemSort, Boolean>>()
|
||||
|
||||
fun library(account: String): UUID? = libraries[account]
|
||||
|
||||
fun setLibrary(account: String, id: UUID) {
|
||||
libraries[account] = id
|
||||
}
|
||||
|
||||
/** Sort and "unwatched only" of a library's grid. */
|
||||
fun sort(library: UUID): Pair<ItemSort, Boolean> = sorts[library] ?: (ItemSort.Name to false)
|
||||
|
||||
fun setSort(library: UUID, sort: ItemSort, unwatched: Boolean) {
|
||||
sorts[library] = sort to unwatched
|
||||
}
|
||||
}
|
||||
|
||||
/** Sizes (dp) shared by the layout and the image requests. */
|
||||
internal object LibraryLayout {
|
||||
const val TILE_W = 260f
|
||||
const val TILE_H = 110f
|
||||
const val TILE_CORNER = 11f
|
||||
/** Posters of a tile's mosaic: 120×180 px in the mockup. */
|
||||
const val ART_POSTER_W = 60f
|
||||
const val ART_POSTER_H = 90f
|
||||
/** Grid: 6 posters of 220 px with 28 px between them, rows 36 px apart. */
|
||||
const val GRID_GAP = 14
|
||||
const val GRID_ROW_GAP = 18
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
package ru.stenfredd.sleepyowl.ui.library
|
||||
|
||||
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.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.LazyGridState
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.items
|
||||
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
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.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.drawBehind
|
||||
import androidx.compose.ui.focus.FocusDirection
|
||||
import androidx.compose.ui.focus.focusRestorer
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
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 androidx.compose.ui.unit.sp
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import ru.stenfredd.sleepyowl.ui.Screen
|
||||
import ru.stenfredd.sleepyowl.ui.components.AnchoredScroll
|
||||
import ru.stenfredd.sleepyowl.ui.components.AnchoredScrolling
|
||||
import ru.stenfredd.sleepyowl.ui.components.Chip
|
||||
import ru.stenfredd.sleepyowl.ui.components.ContentRow
|
||||
import ru.stenfredd.sleepyowl.ui.components.DefaultScrolling
|
||||
import ru.stenfredd.sleepyowl.ui.components.FocusStyle
|
||||
import ru.stenfredd.sleepyowl.ui.components.PosterCard
|
||||
import ru.stenfredd.sleepyowl.ui.components.WideCard
|
||||
import ru.stenfredd.sleepyowl.ui.components.anchorViewport
|
||||
import ru.stenfredd.sleepyowl.ui.components.anchoredRow
|
||||
import ru.stenfredd.sleepyowl.ui.components.bleed
|
||||
import ru.stenfredd.sleepyowl.ui.components.fullItem
|
||||
import ru.stenfredd.sleepyowl.ui.components.focusCard
|
||||
import ru.stenfredd.sleepyowl.ui.focus.Restore
|
||||
import ru.stenfredd.sleepyowl.ui.focus.ScreenFocus
|
||||
import ru.stenfredd.sleepyowl.ui.focus.rememberScreenFocus
|
||||
import ru.stenfredd.sleepyowl.ui.focus.remembered
|
||||
import ru.stenfredd.sleepyowl.ui.theme.AppColors
|
||||
import ru.stenfredd.sleepyowl.ui.theme.AppDimens
|
||||
import ru.stenfredd.sleepyowl.ui.theme.AppType
|
||||
|
||||
/**
|
||||
* The library tab (mockup screen «Медиатека»): library tiles on top; under them, for the selected library,
|
||||
* continue watching, recently added, watch history, then the whole library as a grid with sort and filter chips.
|
||||
*
|
||||
* The screen is one lazy grid: rows and headers are full-width items, posters are its cells, so a library of
|
||||
* thousands of items only composes what is on screen. Vertical scrolling is [AnchoredScrolling], as on home.
|
||||
*/
|
||||
@Composable
|
||||
fun LibraryScreen(onOpen: (Screen) -> Unit, vm: LibraryViewModel = hiltViewModel()) {
|
||||
val state by vm.state.collectAsStateWithLifecycle()
|
||||
val focus = rememberScreenFocus()
|
||||
val ready = !state.loading && state.error == null && state.tiles.isNotEmpty()
|
||||
|
||||
when {
|
||||
state.loading -> Message("Загрузка…")
|
||||
state.error != null -> Message("Не удалось загрузить: ${state.error}")
|
||||
state.tiles.isEmpty() -> Message("На сервере нет библиотек с видео")
|
||||
else -> Library(state, vm, focus, onOpen)
|
||||
}
|
||||
focus.Restore(ready = ready)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Library(state: LibraryState, vm: LibraryViewModel, focus: ScreenFocus, onOpen: (Screen) -> Unit) {
|
||||
val open = { key: String -> vm.target(key)?.let(onOpen) ?: Unit }
|
||||
val grid = rememberLazyGridState()
|
||||
val content = state.content
|
||||
LoadMore(grid, vm)
|
||||
|
||||
BoxWithConstraints(Modifier.fillMaxSize()) {
|
||||
// Full-width items reach from the screen edge to edge, outside the grid's side padding (see [bleed]).
|
||||
val fullWidth = maxWidth
|
||||
AnchoredScrolling(grid, anchor = AppDimens.CONTENT_TOP.dp) { anchored ->
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.FixedSize(AppDimens.POSTER_W.dp),
|
||||
state = grid,
|
||||
modifier = Modifier.fillMaxSize().anchorViewport(anchored),
|
||||
contentPadding = PaddingValues(start = Side, end = Side, top = AppDimens.CONTENT_TOP.dp, bottom = 200.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(LibraryLayout.GRID_GAP.dp),
|
||||
) {
|
||||
fullItem("tiles", "tiles") {
|
||||
Tiles(state, vm, focus, Modifier.bleed(fullWidth, Side).anchoredRow(anchored))
|
||||
}
|
||||
if (content == null) {
|
||||
state.contentError?.let { error -> fullItem("error", "text") { Note("Не удалось загрузить: $error") } }
|
||||
return@LazyVerticalGrid
|
||||
}
|
||||
rows(content, focus, open, fullWidth, anchored)
|
||||
fullItem("all", "all") {
|
||||
AllHeader(content, vm, focus, Modifier.anchoredRow(anchored))
|
||||
}
|
||||
items(content.grid, key = { it.key }, contentType = { "poster" }) { p ->
|
||||
PosterCard(
|
||||
p,
|
||||
{ open(p.key) },
|
||||
Modifier.padding(bottom = LibraryLayout.GRID_ROW_GAP.dp).anchoredRow(anchored).remembered(focus, p.key),
|
||||
)
|
||||
}
|
||||
if (content.grid.isEmpty()) {
|
||||
fullItem("grid-note", "text") {
|
||||
Note(
|
||||
when {
|
||||
!content.gridComplete -> "Загрузка…"
|
||||
content.unwatched -> "Всё просмотрено"
|
||||
else -> "Здесь пока пусто"
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun androidx.compose.foundation.lazy.grid.LazyGridScope.rows(
|
||||
content: LibraryContent,
|
||||
focus: ScreenFocus,
|
||||
open: (String) -> Unit,
|
||||
fullWidth: Dp,
|
||||
anchored: AnchoredScroll<LazyGridState>,
|
||||
) {
|
||||
val id = content.libraryId
|
||||
if (content.continueItems.isNotEmpty()) {
|
||||
fullItem("$id/cont", "wide-row") {
|
||||
ContentRow("Продолжить просмотр", content.continueItems.size, Modifier.bleed(fullWidth, Side).padding(top = RowGap).anchoredRow(anchored)) {
|
||||
items(content.continueItems, key = { it.key }, contentType = { "wide" }) { w ->
|
||||
WideCard(w, { open(w.key) }, Modifier.remembered(focus, w.key))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for ((name, title, posters) in listOf(Triple("new", "Недавно добавлено", content.latest), Triple("hist", "История просмотра", content.history))) {
|
||||
if (posters.isEmpty()) continue
|
||||
fullItem("$id/$name", "poster-row") {
|
||||
ContentRow(title, posters.size, Modifier.bleed(fullWidth, Side).padding(top = RowGap).anchoredRow(anchored)) {
|
||||
items(posters, key = { it.key }, contentType = { "poster" }) { p ->
|
||||
PosterCard(p, { open(p.key) }, Modifier.remembered(focus, p.key))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Library tiles; focusing one selects it, OK moves down into its content. */
|
||||
@Composable
|
||||
private fun Tiles(state: LibraryState, vm: LibraryViewModel, focus: ScreenFocus, modifier: Modifier = Modifier) {
|
||||
val focusManager = LocalFocusManager.current
|
||||
DefaultScrolling {
|
||||
LazyRow(
|
||||
modifier.focusRestorer(),
|
||||
// The selected tile in view from the start (it can be far right when opened from the rail): only composed
|
||||
// tiles can take the first focus, and focusing another one would switch the library.
|
||||
state = rememberLazyListState((state.tiles.indexOfFirst { it.id == state.selected } - 2).coerceAtLeast(0)),
|
||||
contentPadding = PaddingValues(horizontal = AppDimens.CONTENT_START.dp, vertical = 8.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(AppDimens.ROW_GAP.dp),
|
||||
) {
|
||||
items(state.tiles, key = { it.key }, contentType = { "tile" }) { tile ->
|
||||
LibraryTileCard(
|
||||
tile,
|
||||
selected = tile.id == state.selected,
|
||||
onFocus = { vm.focusLibrary(tile.id) },
|
||||
onClick = { focusManager.moveFocus(FocusDirection.Down) },
|
||||
modifier = Modifier.remembered(focus, tile.key, isDefault = tile.id == state.selected),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LibraryTileCard(tile: LibraryTile, selected: Boolean, onFocus: () -> Unit, onClick: () -> Unit, modifier: Modifier = Modifier) {
|
||||
val shape = RoundedCornerShape(LibraryLayout.TILE_CORNER.dp)
|
||||
val sizePx = with(LocalDensity.current) { IntSize(LibraryLayout.TILE_W.dp.roundToPx(), LibraryLayout.TILE_H.dp.roundToPx()) }
|
||||
val art by rememberTileArt(tile.art, sizePx)
|
||||
Box(
|
||||
modifier
|
||||
.focusCard(TileFocus)
|
||||
.size(LibraryLayout.TILE_W.dp, LibraryLayout.TILE_H.dp)
|
||||
.then(
|
||||
art?.let { a ->
|
||||
val image = if (selected) a.selected else a.normal
|
||||
Modifier.drawBehind { drawImage(image, dstSize = IntSize(size.width.toInt(), size.height.toInt()), dstOffset = IntOffset.Zero) }
|
||||
} ?: Modifier.background(AppColors.Surface, shape),
|
||||
)
|
||||
.border(0.5.dp, if (selected) AppColors.SelectedBorder else TileBorder, shape)
|
||||
.onFocusChanged { if (it.isFocused) onFocus() }
|
||||
.clickable(interactionSource = null, indication = null, onClick = onClick),
|
||||
) {
|
||||
Row(
|
||||
Modifier.padding(start = 15.dp, top = 14.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(5.dp),
|
||||
) {
|
||||
Box(Modifier.size(6.dp).background(tile.dot, CircleShape))
|
||||
BasicText("БИБЛИОТЕКА", style = AppType.MonoLabel.copy(fontSize = 8.5.sp, color = AppColors.TextDim))
|
||||
}
|
||||
Column(Modifier.align(Alignment.BottomStart).padding(start = 15.dp, end = 15.dp, bottom = 15.dp)) {
|
||||
BasicText(tile.name, style = AppType.Hero.copy(fontSize = 23.sp), maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
// Reserved while the count loads, so the name doesn't jump.
|
||||
BasicText(tile.stats ?: " ", Modifier.padding(top = 4.dp), style = AppType.MonoMeta.copy(fontSize = 10.sp, color = AppColors.TextMuted), maxLines = 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** "Все сериалы 171 [А–Я] [Добавлено] [Год] [Рейтинг] [Непросмотренное]" */
|
||||
@Composable
|
||||
private fun AllHeader(content: LibraryContent, vm: LibraryViewModel, focus: ScreenFocus, modifier: Modifier = Modifier) {
|
||||
Row(
|
||||
modifier.padding(top = 22.dp, bottom = 16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
BasicText(content.allTitle, style = AppType.Section)
|
||||
content.allCount?.let { BasicText(it.toString(), Modifier.padding(start = 8.dp, top = 3.dp), style = AppType.MonoCount) }
|
||||
Row(Modifier.padding(start = 16.dp).focusRestorer(), horizontalArrangement = Arrangement.spacedBy(5.dp)) {
|
||||
for (chip in vm.sortChips) {
|
||||
Chip(chip.label, chip.sort == content.sort, { vm.setSort(chip.sort) }, Modifier.remembered(focus, chip.key))
|
||||
}
|
||||
Chip(
|
||||
(if (content.unwatched) "✓ " else "") + "Непросмотренное",
|
||||
content.unwatched,
|
||||
vm::toggleUnwatched,
|
||||
Modifier.remembered(focus, LibraryViewModel.UNWATCHED_KEY),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Asks for the next page when the grid's last loaded cards come into view. */
|
||||
@Composable
|
||||
private fun LoadMore(grid: LazyGridState, vm: LibraryViewModel) {
|
||||
LaunchedEffect(grid) {
|
||||
snapshotFlow {
|
||||
val info = grid.layoutInfo
|
||||
// With the item count, so a page that still leaves the end in view asks for the next one.
|
||||
((info.visibleItemsInfo.lastOrNull()?.index ?: 0) >= info.totalItemsCount - PREFETCH) to info.totalItemsCount
|
||||
}.distinctUntilChanged().collect { (nearEnd, _) -> if (nearEnd) vm.loadMore() }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Note(text: String) {
|
||||
BasicText(text, Modifier.padding(top = 8.dp), style = AppType.Body)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Message(text: String) {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { BasicText(text, style = AppType.Body) }
|
||||
}
|
||||
|
||||
private val Side = AppDimens.CONTENT_START.dp
|
||||
private val RowGap = (AppDimens.SECTION_GAP - 14).dp
|
||||
private val TileBorder = Color(0xFF262A3A)
|
||||
private val TileFocus = FocusStyle.Tile.copy(corner = LibraryLayout.TILE_CORNER.dp)
|
||||
|
||||
/** Next page is asked for this many cards before the end (two rows of the grid). */
|
||||
private const val PREFETCH = 12
|
||||
@@ -0,0 +1,364 @@
|
||||
package ru.stenfredd.sleepyowl.ui.library
|
||||
|
||||
import android.os.SystemClock
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import ru.stenfredd.sleepyowl.core.data.LibraryRepository
|
||||
import ru.stenfredd.sleepyowl.core.data.SessionCache
|
||||
import ru.stenfredd.sleepyowl.core.data.WatchRepository
|
||||
import ru.stenfredd.sleepyowl.core.data.model.ItemKind
|
||||
import ru.stenfredd.sleepyowl.core.data.model.ItemQuery
|
||||
import ru.stenfredd.sleepyowl.core.data.model.ItemSort
|
||||
import ru.stenfredd.sleepyowl.core.data.model.ItemSummary
|
||||
import ru.stenfredd.sleepyowl.core.data.model.LibraryType
|
||||
import ru.stenfredd.sleepyowl.core.data.model.LibraryView
|
||||
import ru.stenfredd.sleepyowl.core.data.model.itemKinds
|
||||
import ru.stenfredd.sleepyowl.core.jellyfin.JellyfinSession
|
||||
import ru.stenfredd.sleepyowl.ui.Screen
|
||||
import ru.stenfredd.sleepyowl.ui.components.PosterModel
|
||||
import ru.stenfredd.sleepyowl.ui.components.WideModel
|
||||
import ru.stenfredd.sleepyowl.ui.model.CardMapper
|
||||
import ru.stenfredd.sleepyowl.ui.model.daysAgo
|
||||
import ru.stenfredd.sleepyowl.ui.model.plural
|
||||
import ru.stenfredd.sleepyowl.ui.theme.AppColors
|
||||
import timber.log.Timber
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import javax.inject.Inject
|
||||
|
||||
/** A library tile: name, "158 фильмов · 3 новинки" (once counted) and up to three poster URLs for its mosaic. */
|
||||
@Immutable
|
||||
data class LibraryTile(val key: String, val id: UUID, val name: String, val stats: String?, val dot: Color, val art: List<String>)
|
||||
|
||||
@Immutable
|
||||
data class SortChip(val key: String, val sort: ItemSort, val label: String)
|
||||
|
||||
/** What the screen shows under the tiles for one library. */
|
||||
@Immutable
|
||||
data class LibraryContent(
|
||||
val libraryId: UUID,
|
||||
val continueItems: List<WideModel>,
|
||||
val latest: List<PosterModel>,
|
||||
val history: List<PosterModel>,
|
||||
/** "Все фильмы", "Все сериалы". */
|
||||
val allTitle: String,
|
||||
val allCount: Int?,
|
||||
val sort: ItemSort,
|
||||
val unwatched: Boolean,
|
||||
val grid: List<PosterModel>,
|
||||
/** The grid holds every matching item; false while more pages remain or the first one is loading. */
|
||||
val gridComplete: Boolean,
|
||||
)
|
||||
|
||||
@Immutable
|
||||
data class LibraryState(
|
||||
val loading: Boolean = true,
|
||||
val error: String? = null,
|
||||
val tiles: List<LibraryTile> = emptyList(),
|
||||
/** The library whose content is shown; follows focus on the tiles with a short delay. */
|
||||
val selected: UUID? = null,
|
||||
val content: LibraryContent? = null,
|
||||
val contentError: String? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* The library tab: tiles of the user's libraries and, for the selected one, its continue watching, newest items,
|
||||
* watch history and the whole library as a grid (loaded page by page, sorted and filtered).
|
||||
*
|
||||
* Content of every library visited is kept, so moving between tiles shows it at once and refreshes it in the background.
|
||||
*/
|
||||
@HiltViewModel
|
||||
class LibraryViewModel @Inject constructor(
|
||||
private val session: JellyfinSession,
|
||||
private val library: LibraryRepository,
|
||||
private val watch: WatchRepository,
|
||||
private val cache: SessionCache,
|
||||
private val cards: CardMapper,
|
||||
private val memory: LibraryMemory,
|
||||
) : ViewModel() {
|
||||
private val _state = MutableStateFlow(LibraryState())
|
||||
val state: StateFlow<LibraryState> = _state.asStateFlow()
|
||||
|
||||
val sortChips = listOf(
|
||||
SortChip("sort/name", ItemSort.Name, "А–Я"),
|
||||
SortChip("sort/added", ItemSort.DateAdded, "Добавлено"),
|
||||
SortChip("sort/year", ItemSort.Year, "Год"),
|
||||
SortChip("sort/rating", ItemSort.Rating, "Рейтинг"),
|
||||
)
|
||||
|
||||
/** The loaded part of a library's grid. */
|
||||
private class Grid(val sort: ItemSort, val unwatched: Boolean, val items: List<ItemSummary>, val complete: Boolean)
|
||||
|
||||
private val account = session.requireSession().accountId
|
||||
private var views: List<LibraryView> = emptyList()
|
||||
private val contents = HashMap<UUID, LibraryContent>()
|
||||
private val grids = HashMap<UUID, Grid>()
|
||||
/** Card key → where it leads, for every library loaded. */
|
||||
private val targets = ConcurrentHashMap<String, Screen>()
|
||||
private var selectJob: Job? = null
|
||||
private var loadJob: Job? = null
|
||||
private var pageJob: Job? = null
|
||||
|
||||
init {
|
||||
viewModelScope.launch { loadViews() }
|
||||
viewModelScope.launch { cache.invalidations.collect { _state.value.selected?.let { load(it, refresh = true) } } }
|
||||
}
|
||||
|
||||
fun target(key: String): Screen? = targets[key]
|
||||
|
||||
/** Focus moved to a tile: show that library once focus stays there a moment (fast moves skip libraries). */
|
||||
fun focusLibrary(id: UUID) {
|
||||
selectJob?.cancel()
|
||||
if (id == _state.value.selected) return
|
||||
selectJob = viewModelScope.launch {
|
||||
delay(SELECT_DELAY_MS)
|
||||
select(id)
|
||||
}
|
||||
}
|
||||
|
||||
fun setSort(sort: ItemSort) {
|
||||
val content = _state.value.content ?: return
|
||||
if (content.sort != sort) restartGrid(content.libraryId, sort, content.unwatched)
|
||||
}
|
||||
|
||||
fun toggleUnwatched() {
|
||||
val content = _state.value.content ?: return
|
||||
restartGrid(content.libraryId, content.sort, !content.unwatched)
|
||||
}
|
||||
|
||||
/** The grid shows its last cards: fetch the next page. */
|
||||
fun loadMore() {
|
||||
val id = _state.value.selected ?: return
|
||||
val grid = grids[id] ?: return
|
||||
if (grid.complete || pageJob?.isActive == true) return
|
||||
pageJob = viewModelScope.launch {
|
||||
try {
|
||||
val page = fetchGrid(id, grid.sort, grid.unwatched, grid.items.size, PAGE)
|
||||
if (grids[id] !== grid) return@launch
|
||||
grids[id] = Grid(grid.sort, grid.unwatched, grid.items + page, page.size < PAGE)
|
||||
publishGrid(id)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Timber.w(e, "Library page failed")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun loadViews() {
|
||||
try {
|
||||
views = library.views()
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Timber.w(e, "Libraries failed")
|
||||
_state.value = LibraryState(loading = false, error = e.message ?: e.toString())
|
||||
return
|
||||
}
|
||||
val selected = memory.library(account)?.takeIf { id -> views.any { it.id == id } } ?: views.firstOrNull()?.id
|
||||
_state.value = LibraryState(
|
||||
loading = false,
|
||||
tiles = views.mapIndexed { i, v -> LibraryTile(tileKey(v.id), v.id, v.name, null, TILE_DOTS[i % TILE_DOTS.size], emptyList()) },
|
||||
selected = selected,
|
||||
)
|
||||
selected?.let { load(it, refresh = false) }
|
||||
views.forEach { view -> viewModelScope.launch { tileStats(view) } }
|
||||
}
|
||||
|
||||
private fun select(id: UUID) {
|
||||
memory.setLibrary(account, id)
|
||||
_state.update { it.copy(selected = id, content = contents[id], contentError = null) }
|
||||
load(id, refresh = false)
|
||||
}
|
||||
|
||||
/** Count and newest items of a library for its tile. Both are shared with home through the cache. */
|
||||
private suspend fun tileStats(view: LibraryView) = coroutineScope {
|
||||
val total = async { runCatching { library.count(countQuery(view.id, unwatched = false)) }.getOrNull() }
|
||||
val latest = async { runCatching { library.latest(view.id) }.getOrNull().orEmpty() }
|
||||
val newCount = latest.await().count(cards::isNew)
|
||||
val stats = total.await()?.let { n ->
|
||||
val unit = when (view.type) {
|
||||
LibraryType.Movies -> plural(n, "фильм", "фильма", "фильмов")
|
||||
LibraryType.Shows -> plural(n, "сериал", "сериала", "сериалов")
|
||||
else -> plural(n, "тайтл", "тайтла", "тайтлов")
|
||||
}
|
||||
"$n $unit" + if (newCount > 0) " · $newCount ${plural(newCount, "новинка", "новинки", "новинок")}" else ""
|
||||
}
|
||||
val art = latest.await().take(3).mapNotNull { item ->
|
||||
val ref = (if (item.kind == ItemKind.Episode) item.seriesPrimary else null) ?: item.primary
|
||||
cards.image(ref, LibraryLayout.ART_POSTER_W, LibraryLayout.ART_POSTER_H)?.url
|
||||
}
|
||||
_state.update { s -> s.copy(tiles = s.tiles.map { if (it.id == view.id) it.copy(stats = stats, art = art) else it }) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the rows and the first page(s) of the grid of library [id]. [refresh] (watch state changed) keeps the
|
||||
* grid's sort and reloads as many items as were loaded, so the focused card stays.
|
||||
*/
|
||||
private fun load(id: UUID, refresh: Boolean) {
|
||||
loadJob?.cancel()
|
||||
loadJob = viewModelScope.launch {
|
||||
val started = SystemClock.elapsedRealtime()
|
||||
val view = views.firstOrNull { it.id == id } ?: return@launch
|
||||
val (sort, unwatched) = grids[id]?.let { it.sort to it.unwatched } ?: memory.sort(id)
|
||||
val loaded = if (refresh) grids[id]?.items?.size ?: 0 else 0
|
||||
try {
|
||||
coroutineScope {
|
||||
val cont = async { watch.continueWatching(ROW_LIMIT, id) }
|
||||
val latest = async { library.latest(id) }
|
||||
val history = async { watch.history(0, HISTORY_FETCH, id).items }
|
||||
val count = async { runCatching { library.count(countQuery(id, unwatched)) }.getOrNull() }
|
||||
val limit = maxOf(PAGE, loaded)
|
||||
val grid = fetchGrid(id, sort, unwatched, 0, limit)
|
||||
grids[id] = Grid(sort, unwatched, grid, grid.size < limit)
|
||||
val content = withContext(Dispatchers.Default) {
|
||||
build(view, sort, unwatched, cont.await(), latest.await(), history.await(), grids[id]!!, count.await())
|
||||
}
|
||||
contents[id] = content
|
||||
if (_state.value.selected == id) _state.update { it.copy(content = content, contentError = null) }
|
||||
}
|
||||
Timber.i("Library loaded in %d ms", SystemClock.elapsedRealtime() - started)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Timber.w(e, "Library content failed")
|
||||
if (_state.value.selected == id && contents[id] == null) _state.update { it.copy(contentError = e.message ?: e.toString()) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun restartGrid(id: UUID, sort: ItemSort, unwatched: Boolean) {
|
||||
memory.setSort(id, sort, unwatched)
|
||||
pageJob?.cancel()
|
||||
val grid = Grid(sort, unwatched, emptyList(), complete = false)
|
||||
grids[id] = grid
|
||||
// The chips switch at once; the old cards stay until the new ones come (~0.1 s): an emptied grid would
|
||||
// shorten the screen and make it jump.
|
||||
contents[id]?.let { contents[id] = it.copy(sort = sort, unwatched = unwatched) }
|
||||
_state.update { it.copy(content = contents[id]) }
|
||||
pageJob = viewModelScope.launch {
|
||||
try {
|
||||
coroutineScope {
|
||||
launch {
|
||||
val count = runCatching { library.count(countQuery(id, unwatched)) }.getOrNull() ?: return@launch
|
||||
val content = contents[id]?.takeIf { it.sort == sort && it.unwatched == unwatched } ?: return@launch
|
||||
contents[id] = content.copy(allCount = count)
|
||||
if (_state.value.selected == id) _state.update { it.copy(content = contents[id]) }
|
||||
}
|
||||
val page = fetchGrid(id, sort, unwatched, 0, PAGE)
|
||||
if (grids[id] !== grid) return@coroutineScope
|
||||
grids[id] = Grid(sort, unwatched, page, page.size < PAGE)
|
||||
publishGrid(id)
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Timber.w(e, "Library grid failed")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun publishGrid(id: UUID) {
|
||||
val grid = grids[id] ?: return
|
||||
val posters = withContext(Dispatchers.Default) { grid.items.map { gridPoster(id, it) } }
|
||||
val content = contents[id] ?: return
|
||||
val updated = content.copy(grid = posters, gridComplete = grid.complete)
|
||||
contents[id] = updated
|
||||
if (_state.value.selected == id) _state.update { it.copy(content = updated) }
|
||||
}
|
||||
|
||||
private suspend fun fetchGrid(id: UUID, sort: ItemSort, unwatched: Boolean, start: Int, limit: Int): List<ItemSummary> =
|
||||
// Without the total: counting costs the server ~0.2 s per request, the count comes from [LibraryRepository.count].
|
||||
library.items(gridQuery(id, sort, unwatched), start, limit, withTotal = false).items
|
||||
|
||||
private fun gridQuery(id: UUID, sort: ItemSort, unwatched: Boolean) = ItemQuery(
|
||||
parentId = id,
|
||||
kinds = kinds(id),
|
||||
sort = sort,
|
||||
descending = sort != ItemSort.Name,
|
||||
unplayedOnly = unwatched,
|
||||
)
|
||||
|
||||
/** Same query as home's library count, so it is shared through the cache. */
|
||||
private fun countQuery(id: UUID, unwatched: Boolean) = ItemQuery(parentId = id, kinds = kinds(id), unplayedOnly = unwatched)
|
||||
|
||||
private fun kinds(id: UUID) = views.firstOrNull { it.id == id }?.itemKinds ?: ItemQuery().kinds
|
||||
|
||||
private fun build(
|
||||
view: LibraryView,
|
||||
sort: ItemSort,
|
||||
unwatched: Boolean,
|
||||
cont: List<ItemSummary>,
|
||||
latest: List<ItemSummary>,
|
||||
history: List<ItemSummary>,
|
||||
grid: Grid,
|
||||
count: Int?,
|
||||
): LibraryContent {
|
||||
val id = view.id
|
||||
fun row(name: String) = "$id/$name"
|
||||
return LibraryContent(
|
||||
libraryId = id,
|
||||
continueItems = cont.map { item -> remember(row("cont"), item) { cards.wide(item, row("cont")) } },
|
||||
latest = latest.map { item -> remember(row("new"), item) { cards.poster(item, row("new")) } },
|
||||
// An episode stands for its series: one card per series, at its latest watched episode.
|
||||
history = history.distinctBy { it.seriesId ?: it.id }.take(ROW_LIMIT).map { item ->
|
||||
remember(row("hist"), item) { cards.poster(item, row("hist")).copy(caption = item.user.lastPlayedAt?.let(::daysAgo)) }
|
||||
},
|
||||
allTitle = when (view.type) {
|
||||
LibraryType.Movies -> "Все фильмы"
|
||||
LibraryType.Shows -> "Все сериалы"
|
||||
else -> "Всё"
|
||||
},
|
||||
allCount = count,
|
||||
sort = sort,
|
||||
unwatched = unwatched,
|
||||
grid = grid.items.map { gridPoster(id, it) },
|
||||
gridComplete = grid.complete,
|
||||
)
|
||||
}
|
||||
|
||||
private fun gridPoster(id: UUID, item: ItemSummary): PosterModel {
|
||||
val row = "$id/all"
|
||||
return remember(row, item) {
|
||||
val poster = cards.poster(item, row)
|
||||
if (item.kind != ItemKind.Series) return@remember poster
|
||||
// A series: "3 сезона · ★ 8.2" instead of the year.
|
||||
val seasons = item.childCount?.takeIf { it > 0 }?.let { "$it ${plural(it, "сезон", "сезона", "сезонов")}" }
|
||||
poster.copy(caption = listOfNotNull(seasons, item.communityRating?.let { "★ %.1f".format(it) }).joinToString(" · ").ifEmpty { null })
|
||||
}
|
||||
}
|
||||
|
||||
private inline fun <T> remember(row: String, item: ItemSummary, card: () -> T): T {
|
||||
targets[cards.key(row, item)] = cards.target(item)
|
||||
return card()
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun tileKey(id: UUID) = "tile/$id"
|
||||
const val UNWATCHED_KEY = "filter/unwatched"
|
||||
|
||||
private const val PAGE = 60
|
||||
private const val ROW_LIMIT = 20
|
||||
/** History comes as episodes; fetched with room for several episodes of one series. */
|
||||
private const val HISTORY_FETCH = 60
|
||||
private const val SELECT_DELAY_MS = 300L
|
||||
|
||||
/** Tile dots in the mockup's order: blue, lilac, pink. */
|
||||
private val TILE_DOTS = listOf(AppColors.Blue, Color(0xFFB39DDB), AppColors.Pink)
|
||||
}
|
||||
}
|
||||
124
app/src/main/kotlin/ru/stenfredd/sleepyowl/ui/library/TileArt.kt
Normal file
124
app/src/main/kotlin/ru/stenfredd/sleepyowl/ui/library/TileArt.kt
Normal file
@@ -0,0 +1,124 @@
|
||||
package ru.stenfredd.sleepyowl.ui.library
|
||||
|
||||
import android.content.Context
|
||||
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.Path
|
||||
import android.graphics.RectF
|
||||
import android.graphics.Shader
|
||||
import android.util.LruCache
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import coil3.imageLoader
|
||||
import coil3.request.ImageRequest
|
||||
import coil3.request.SuccessResult
|
||||
import coil3.request.allowHardware
|
||||
import coil3.toBitmap
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.withContext
|
||||
import ru.stenfredd.sleepyowl.ui.theme.AppColors
|
||||
|
||||
/** A tile's background in its two looks: the selected library's mosaic is brighter. */
|
||||
@Immutable
|
||||
class TileArt(val selected: ImageBitmap, val normal: ImageBitmap)
|
||||
|
||||
/**
|
||||
* The library tile's background from the mockup — dark fill, three posters tilted on the right, a fade from the left —
|
||||
* baked into one bitmap (rounded corners included), so on screen a tile is a single image draw: on the test TV every
|
||||
* translucent layer costs GPU time on every frame. Null until the posters are loaded.
|
||||
*/
|
||||
@Composable
|
||||
fun rememberTileArt(urls: List<String>, size: IntSize): State<TileArt?> {
|
||||
val context = LocalContext.current
|
||||
val k = LocalDensity.current.density / 2f
|
||||
return produceState(Baked.get(key(urls, size)), urls, size) {
|
||||
if (value != null || urls.isEmpty() || size == IntSize.Zero) return@produceState
|
||||
val posters = loadPosters(context, urls, (POSTER_W * k).toInt(), (POSTER_H * k).toInt())
|
||||
if (posters.isEmpty()) return@produceState
|
||||
val art = withContext(Dispatchers.Default) {
|
||||
TileArt(bake(posters, size, k, SELECTED_ALPHA).asImageBitmap(), bake(posters, size, k, NORMAL_ALPHA).asImageBitmap())
|
||||
}
|
||||
Baked.put(key(urls, size), art)
|
||||
value = art
|
||||
}
|
||||
}
|
||||
|
||||
private val Baked = LruCache<String, TileArt>(12)
|
||||
|
||||
private fun key(urls: List<String>, size: IntSize) = "${size.width}x${size.height}:" + urls.joinToString("|")
|
||||
|
||||
private suspend fun loadPosters(context: Context, urls: List<String>, w: Int, h: Int): List<Bitmap> = coroutineScope {
|
||||
urls.map { url ->
|
||||
async {
|
||||
val request = ImageRequest.Builder(context).data(url).size(w, h).allowHardware(false).build()
|
||||
(context.imageLoader.execute(request) as? SuccessResult)?.image?.toBitmap()
|
||||
}
|
||||
}.awaitAll().filterNotNull()
|
||||
}
|
||||
|
||||
/** Sizes in the mockup's px, [k] = device px per mockup px. */
|
||||
private fun bake(posters: List<Bitmap>, size: IntSize, k: Float, alpha: Float): Bitmap {
|
||||
val w = size.width.toFloat()
|
||||
val h = size.height.toFloat()
|
||||
val output = Bitmap.createBitmap(size.width, size.height, Bitmap.Config.ARGB_8888)
|
||||
val canvas = Canvas(output)
|
||||
val corner = CORNER * k
|
||||
canvas.clipPath(Path().apply { addRoundRect(RectF(0f, 0f, w, h), corner, corner, Path.Direction.CW) })
|
||||
canvas.drawColor(AppColors.Surface.toArgb())
|
||||
|
||||
// `right:-20px; top:-20px; bottom:-20px; display:flex; gap:12px; transform:rotate(8deg)`, posters 120×180 px,
|
||||
// each next one 18 px lower.
|
||||
val pw = POSTER_W * k
|
||||
val ph = POSTER_H * k
|
||||
val gap = 12 * k
|
||||
val rowWidth = posters.size * pw + (posters.size - 1) * gap
|
||||
val left = w + 20 * k - rowWidth
|
||||
val top = -20 * k
|
||||
canvas.save()
|
||||
canvas.rotate(8f, left + rowWidth / 2, h / 2)
|
||||
val paint = Paint(Paint.ANTI_ALIAS_FLAG or Paint.FILTER_BITMAP_FLAG).apply { this.alpha = (alpha * 255).toInt() }
|
||||
posters.forEachIndexed { i, poster ->
|
||||
val x = left + i * (pw + gap)
|
||||
val y = top + i * 18 * k
|
||||
val scale = maxOf(pw / poster.width, ph / poster.height)
|
||||
paint.shader = BitmapShader(poster, Shader.TileMode.CLAMP, Shader.TileMode.CLAMP).apply {
|
||||
setLocalMatrix(
|
||||
Matrix().apply {
|
||||
setScale(scale, scale)
|
||||
postTranslate(x + (pw - poster.width * scale) / 2, y + (ph - poster.height * scale) / 2)
|
||||
},
|
||||
)
|
||||
}
|
||||
canvas.drawRoundRect(RectF(x, y, x + pw, y + ph), 12 * k, 12 * k, paint)
|
||||
}
|
||||
canvas.restore()
|
||||
|
||||
// `linear-gradient(90deg, #12141c 40%, rgba(18,20,28,.2))`
|
||||
val bg = AppColors.Surface.toArgb()
|
||||
val fade = Paint().apply {
|
||||
shader = LinearGradient(0f, 0f, w, 0f, intArrayOf(bg, bg, (bg and 0xFFFFFF) or (0x33 shl 24)), floatArrayOf(0f, 0.4f, 1f), Shader.TileMode.CLAMP)
|
||||
}
|
||||
canvas.drawRect(0f, 0f, w, h, fade)
|
||||
return output
|
||||
}
|
||||
|
||||
private const val POSTER_W = 120f
|
||||
private const val POSTER_H = 180f
|
||||
private const val CORNER = 22f
|
||||
private const val SELECTED_ALPHA = 1f
|
||||
private const val NORMAL_ALPHA = 0.55f
|
||||
@@ -0,0 +1,513 @@
|
||||
package ru.stenfredd.sleepyowl.ui.login
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.Image
|
||||
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.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.BasicText
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.withFrameNanos
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.focusProperties
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.FilterQuality
|
||||
import androidx.compose.ui.graphics.painter.BitmapPainter
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.em
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import kotlinx.coroutines.delay
|
||||
import ru.stenfredd.sleepyowl.ui.components.AppIcon
|
||||
import ru.stenfredd.sleepyowl.ui.components.Avatar
|
||||
import ru.stenfredd.sleepyowl.ui.components.FocusStyle
|
||||
import ru.stenfredd.sleepyowl.ui.components.Icon
|
||||
import ru.stenfredd.sleepyowl.ui.components.KeyboardLift
|
||||
import ru.stenfredd.sleepyowl.ui.components.LocalKeyboardLift
|
||||
import ru.stenfredd.sleepyowl.ui.components.OwlMark
|
||||
import ru.stenfredd.sleepyowl.ui.components.liftForKeyboard
|
||||
import ru.stenfredd.sleepyowl.ui.components.StatusDot
|
||||
import ru.stenfredd.sleepyowl.ui.components.TextInput
|
||||
import ru.stenfredd.sleepyowl.ui.components.Wordmark
|
||||
import ru.stenfredd.sleepyowl.ui.components.dashedBorder
|
||||
import ru.stenfredd.sleepyowl.ui.components.focusCard
|
||||
import ru.stenfredd.sleepyowl.ui.components.rememberTextInputState
|
||||
import ru.stenfredd.sleepyowl.ui.focus.Restore
|
||||
import ru.stenfredd.sleepyowl.ui.focus.ScreenFocus
|
||||
import ru.stenfredd.sleepyowl.ui.focus.remembered
|
||||
import ru.stenfredd.sleepyowl.ui.theme.AppColors
|
||||
import ru.stenfredd.sleepyowl.ui.theme.AppType
|
||||
|
||||
/** Login flow: server → profile → login, see [LoginViewModel]. Sizes are the mockup's px / 2. */
|
||||
@Composable
|
||||
fun LoginScreen(onDone: () -> Unit, vm: LoginViewModel = hiltViewModel()) {
|
||||
val state by vm.state.collectAsStateWithLifecycle()
|
||||
LaunchedEffect(state.done) { if (state.done) onDone() }
|
||||
// Back walks the steps; on the first one it leaves the screen (closes the app, or returns to it from the menu).
|
||||
BackHandler(enabled = state.step != LoginStep.Server) { vm.back() }
|
||||
|
||||
val lift = remember { KeyboardLift() }
|
||||
Box(Modifier.fillMaxSize().liftForKeyboard(lift)) {
|
||||
CompositionLocalProvider(LocalKeyboardLift provides lift) {
|
||||
Logo(Modifier.padding(start = 60.dp, top = 29.dp))
|
||||
StepChain(state.step, Modifier.align(Alignment.TopEnd).padding(end = 60.dp, top = 35.dp))
|
||||
val focus = vm.focus.getValue(state.step)
|
||||
key(state.step) {
|
||||
when (state.step) {
|
||||
LoginStep.Server -> ServerStep(state, vm, focus)
|
||||
LoginStep.Profile -> ProfileStep(state, vm, focus)
|
||||
LoginStep.Login -> LoginStepContent(state, vm, focus)
|
||||
}
|
||||
focus.Restore(ready = true)
|
||||
}
|
||||
BasicText(
|
||||
hint(state),
|
||||
Modifier.align(Alignment.BottomCenter).padding(bottom = 24.dp),
|
||||
style = AppType.MonoCount.copy(fontSize = 10.sp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun hint(state: LoginState) = when (state.step) {
|
||||
LoginStep.Server -> "OK — подключиться · серверы находятся автоматически"
|
||||
LoginStep.Profile -> "Назад — сменить сервер"
|
||||
LoginStep.Login -> "Вход с телефона подтвердится автоматически · Назад — вернуться"
|
||||
}
|
||||
|
||||
// ---- Header ----
|
||||
|
||||
/** Same as the open side rail's. */
|
||||
@Composable
|
||||
private fun Logo(modifier: Modifier = Modifier) {
|
||||
Row(modifier, verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
OwlMark()
|
||||
Wordmark()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StepChain(step: LoginStep, modifier: Modifier = Modifier) {
|
||||
val current = step.ordinal
|
||||
Row(modifier, verticalAlignment = Alignment.CenterVertically) {
|
||||
listOf("Сервер", "Профиль", "Вход").forEachIndexed { i, label ->
|
||||
if (i > 0) Spacer(Modifier.width(20.dp).height(1.dp).background(if (i <= current) AppColors.Accent else Color(0xFF262A3A)))
|
||||
Row(
|
||||
Modifier
|
||||
.background(AppColors.Surface, RoundedCornerShape(5.dp))
|
||||
.border(0.5.dp, if (i == current) AppColors.SelectedBorder else Color(0xFF262A3A), RoundedCornerShape(5.dp))
|
||||
.padding(horizontal = 8.dp, vertical = 5.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(5.dp),
|
||||
) {
|
||||
val dot = when {
|
||||
i < current -> AppColors.Green
|
||||
i == current -> AppColors.Accent
|
||||
else -> Color(0xFF33384D)
|
||||
}
|
||||
Spacer(Modifier.size(5.dp).background(dot, CircleShape))
|
||||
BasicText(
|
||||
label,
|
||||
style = AppType.MonoMeta.copy(fontSize = 9.sp, letterSpacing = 0.08.em, color = if (i <= current) AppColors.Text else Color(0xFF6F7592)),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Title(text: String, subtitle: @Composable () -> Unit = {}, top: Int = 110) {
|
||||
Column(Modifier.fillMaxWidth().padding(top = top.dp), horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
BasicText(text, style = AppType.Display.copy(fontSize = 32.sp, fontWeight = FontWeight.Bold))
|
||||
Spacer(Modifier.height(10.dp))
|
||||
subtitle()
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Step 1: server ----
|
||||
|
||||
@Composable
|
||||
private fun ServerStep(state: LoginState, vm: LoginViewModel, focus: ScreenFocus) {
|
||||
var address by rememberSaveable { mutableStateOf("") }
|
||||
val input = rememberTextInputState()
|
||||
LaunchedEffect(state.manualOpen) {
|
||||
if (state.manualOpen) {
|
||||
withFrameNanos { }
|
||||
input.edit()
|
||||
}
|
||||
}
|
||||
// With nothing found yet focus starts on "Ввести адрес"; the first server found (~1.5 s) takes it over,
|
||||
// unless the user is already typing an address.
|
||||
val firstServer = state.servers.firstOrNull()?.key
|
||||
var waitingForServer by remember { mutableStateOf(firstServer == null) }
|
||||
LaunchedEffect(firstServer) {
|
||||
if (firstServer == null || !waitingForServer) return@LaunchedEffect
|
||||
waitingForServer = false
|
||||
if (focus.lastKey == MANUAL_KEY && !state.manualOpen) {
|
||||
focus.lastKey = firstServer
|
||||
withFrameNanos { }
|
||||
focus.requestFocus()
|
||||
}
|
||||
}
|
||||
|
||||
Title("Выберите сервер", subtitle = {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(7.dp)) {
|
||||
StatusDot(AppColors.Green, pulsing = state.searching)
|
||||
val text = when {
|
||||
state.searching || !state.searched -> "Ищем Jellyfin в локальной сети"
|
||||
state.servers.isEmpty() -> "В локальной сети серверов нет — введите адрес"
|
||||
else -> "Серверы Jellyfin"
|
||||
}
|
||||
BasicText(text, style = AppType.BodyLarge.copy(color = AppColors.TextMuted))
|
||||
}
|
||||
})
|
||||
Column(Modifier.fillMaxWidth().padding(top = 210.dp), horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
state.servers.take(MAX_SERVER_CARDS).forEachIndexed { i, card ->
|
||||
key(card.key) {
|
||||
ServerCard(card, { vm.pickServer(card.key) }, Modifier.remembered(focus, card.key, isDefault = i == 0))
|
||||
}
|
||||
}
|
||||
AddServerCard(vm::openManual, Modifier.remembered(focus, MANUAL_KEY, isDefault = state.servers.isEmpty()))
|
||||
}
|
||||
if (state.manualOpen) {
|
||||
Row(Modifier.padding(top = 30.dp), horizontalArrangement = Arrangement.spacedBy(7.dp)) {
|
||||
TextInput(
|
||||
address,
|
||||
{ address = it },
|
||||
input,
|
||||
Modifier.width(360.dp).remembered(focus, "address"),
|
||||
placeholder = "http://192.168.1.10:8096",
|
||||
textStyle = AppType.MonoMeta.copy(fontSize = 14.sp, color = AppColors.Text),
|
||||
keyboardType = KeyboardType.Uri,
|
||||
imeAction = ImeAction.Go,
|
||||
onAction = { vm.connectManual(address) },
|
||||
)
|
||||
GradientButton(if (state.manualBusy) "Подключение…" else "Подключиться", { vm.connectManual(address) }, Modifier.remembered(focus, "connect"))
|
||||
}
|
||||
state.manualError?.let { ErrorText(it, Modifier.padding(top = 12.dp)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ServerCard(card: ServerCardModel, onClick: () -> Unit, modifier: Modifier = Modifier) {
|
||||
val shape = RoundedCornerShape(10.dp)
|
||||
Column(
|
||||
modifier
|
||||
.focusCard(FocusStyle(scale = 1.04f, corner = 10.dp, ring = 1.dp))
|
||||
.width(230.dp)
|
||||
.height(140.dp)
|
||||
.background(AppColors.Surface, shape)
|
||||
.border(0.5.dp, Color(0xFF262A3A), shape)
|
||||
.clickable(interactionSource = null, indication = null, onClick = onClick),
|
||||
) {
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(26.dp)
|
||||
.background(Color(0xFF181B27), RoundedCornerShape(topStart = 10.dp, topEnd = 10.dp))
|
||||
.padding(horizontal = 11.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
StatusDot(if (card.where == "LAN") AppColors.Blue else AppColors.Pink, pulsing = false)
|
||||
BasicText("СЕРВЕР · ${card.where}", Modifier.weight(1f), style = AppType.MonoCaption.copy(letterSpacing = 0.1.em, color = AppColors.TextMuted))
|
||||
when {
|
||||
card.busy -> BasicText("подключение…", style = AppType.MonoCaption.copy(color = AppColors.Accent))
|
||||
card.ping != null -> BasicText(card.ping, style = AppType.MonoCaption.copy(color = AppColors.Green))
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.fillMaxWidth().height(0.5.dp).background(Color(0xFF232637)))
|
||||
Column(Modifier.padding(horizontal = 12.dp, vertical = 13.dp).fillMaxSize()) {
|
||||
BasicText(card.name, style = AppType.Display.copy(fontSize = 17.sp, fontWeight = FontWeight.Bold, letterSpacing = 0.em), maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
Spacer(Modifier.height(5.dp))
|
||||
BasicText(card.address, style = AppType.MonoMeta.copy(fontSize = 11.sp, color = AppColors.TextMuted), maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
Spacer(Modifier.weight(1f))
|
||||
card.version?.let { BasicText("Jellyfin $it", style = AppType.MonoCaption.copy(fontSize = 9.5.sp, color = Color(0xFF6F7592))) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AddServerCard(onClick: () -> Unit, modifier: Modifier = Modifier) {
|
||||
var focused by rememberSaveable { mutableStateOf(false) }
|
||||
Column(
|
||||
modifier
|
||||
.focusCard(FocusStyle(scale = 1.04f, corner = 10.dp, ring = 1.dp))
|
||||
.width(150.dp)
|
||||
.height(140.dp)
|
||||
.onFocusChanged { focused = it.isFocused }
|
||||
.then(if (focused) Modifier.background(Color(0xFF171A2A), RoundedCornerShape(10.dp)) else Modifier.dashedBorder(Color(0xFF3A3F57), 1.dp, 10.dp))
|
||||
.clickable(interactionSource = null, indication = null, onClick = onClick),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(9.dp, Alignment.CenterVertically),
|
||||
) {
|
||||
Icon(AppIcon.Plus, AppColors.TextSecondary, Modifier.size(24.dp))
|
||||
BasicText("Ввести адрес", style = AppType.BodyLarge.copy(fontWeight = FontWeight.SemiBold, color = AppColors.TextSecondary))
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Step 2: profile ----
|
||||
|
||||
@Composable
|
||||
private fun ProfileStep(state: LoginState, vm: LoginViewModel, focus: ScreenFocus) {
|
||||
Title("Кто смотрит?", subtitle = {
|
||||
BasicText(state.serverLine, style = AppType.MonoMeta.copy(fontSize = 11.sp, color = AppColors.TextDim))
|
||||
})
|
||||
Column(Modifier.fillMaxWidth().padding(top = 210.dp), horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(20.dp)) {
|
||||
state.profiles.forEachIndexed { i, p ->
|
||||
key(p.key) { ProfileTile(p, { vm.pickProfile(p.key) }, Modifier.remembered(focus, p.key, isDefault = i == 0)) }
|
||||
}
|
||||
}
|
||||
state.profileError?.let { ErrorText(it, Modifier.padding(top = 16.dp)) }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ProfileTile(profile: ProfileModel, onClick: () -> Unit, modifier: Modifier = Modifier) {
|
||||
var focused by rememberSaveable { mutableStateOf(false) }
|
||||
Column(
|
||||
modifier
|
||||
.focusCard(FocusStyle(scale = 1.06f, corner = 12.dp, ring = 1.dp))
|
||||
.width(120.dp)
|
||||
.onFocusChanged { focused = it.isFocused }
|
||||
.then(if (focused) Modifier.background(Color(0xFF171A2A), RoundedCornerShape(12.dp)) else Modifier)
|
||||
.clickable(interactionSource = null, indication = null, onClick = onClick)
|
||||
.padding(vertical = 12.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
if (profile.key == LoginViewModel.OTHER_KEY) {
|
||||
Box(Modifier.size(90.dp).dashedBorder(Color(0xFF4A4F68), 1.dp, 45.dp), contentAlignment = Alignment.Center) {
|
||||
Icon(AppIcon.Plus, AppColors.TextMuted, Modifier.size(30.dp), strokeWidth = 1.8f)
|
||||
}
|
||||
} else {
|
||||
Avatar(profile.name, profile.avatarSeed, 90.dp, AppType.Display.copy(fontSize = 38.sp, fontWeight = FontWeight.Bold, letterSpacing = 0.em))
|
||||
}
|
||||
Spacer(Modifier.height(9.dp))
|
||||
BasicText(profile.name, style = AppType.Display.copy(fontSize = 16.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 0.em), maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
Spacer(Modifier.height(5.dp))
|
||||
BasicText(profile.note, style = AppType.MonoCaption.copy(fontSize = 9.5.sp), maxLines = 1)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Step 3: login ----
|
||||
|
||||
@Composable
|
||||
private fun LoginStepContent(state: LoginState, vm: LoginViewModel, focus: ScreenFocus) {
|
||||
var user by rememberSaveable(state.prefillUser) { mutableStateOf(state.prefillUser) }
|
||||
var password by rememberSaveable { mutableStateOf("") }
|
||||
val userInput = rememberTextInputState()
|
||||
val passwordInput = rememberTextInputState()
|
||||
val submit = { vm.loginWithPassword(user, password) }
|
||||
|
||||
Title("Вход в аккаунт", top = 85)
|
||||
Row(Modifier.padding(start = 60.dp, top = 150.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
// As in the mockup, Right from the QR panel goes to the first field, not to the nearest one.
|
||||
QuickConnectPanel(state.quickConnect, Modifier.remembered(focus, "qc", isDefault = true).focusProperties { right = userInput.frame })
|
||||
BasicText("или", Modifier.width(60.dp), style = AppType.MonoMeta.copy(fontSize = 11.sp, color = Color(0xFF6F7592), textAlign = TextAlign.Center))
|
||||
Panel("ПАРОЛЬ", Color(0xFFB39DDB), focused = false) {
|
||||
Column(Modifier.padding(20.dp)) {
|
||||
FieldLabel("Имя пользователя")
|
||||
TextInput(
|
||||
user,
|
||||
{ user = it },
|
||||
userInput,
|
||||
Modifier.fillMaxWidth().remembered(focus, "user"),
|
||||
placeholder = "например, alexey",
|
||||
textStyle = AppType.BodyLarge.copy(fontSize = 14.sp, color = AppColors.Text, fontWeight = FontWeight.Medium),
|
||||
imeAction = ImeAction.Next,
|
||||
onAction = { passwordInput.edit() },
|
||||
)
|
||||
FieldLabel("Пароль", Modifier.padding(top = 12.dp))
|
||||
TextInput(
|
||||
password,
|
||||
{ password = it },
|
||||
passwordInput,
|
||||
Modifier.fillMaxWidth().remembered(focus, LoginViewModel.PASSWORD_KEY),
|
||||
placeholder = "••••••••",
|
||||
textStyle = AppType.BodyLarge.copy(fontSize = 14.sp, color = AppColors.Text, fontWeight = FontWeight.Medium),
|
||||
password = true,
|
||||
imeAction = ImeAction.Done,
|
||||
onAction = submit,
|
||||
)
|
||||
GradientButton(
|
||||
if (state.loginBusy) "Вход…" else "Войти",
|
||||
submit,
|
||||
Modifier.padding(top = 18.dp).fillMaxWidth().remembered(focus, "login"),
|
||||
big = true,
|
||||
)
|
||||
state.loginError?.let { ErrorText(it, Modifier.padding(top = 10.dp)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun QuickConnectPanel(qc: QuickConnectState, modifier: Modifier = Modifier) {
|
||||
var focused by rememberSaveable { mutableStateOf(false) }
|
||||
// The panel itself takes focus (nothing to press in it): it shows where the remote is.
|
||||
Box(
|
||||
modifier
|
||||
.focusCard(FocusStyle(scale = 1f, corner = 12.dp, ring = 1.5.dp, glows = listOf(FocusStyle.Glow(40.dp, 0.dp, Color(0x737C5CFF)))))
|
||||
.onFocusChanged { focused = it.isFocused }
|
||||
.clickable(interactionSource = null, indication = null) {},
|
||||
) {
|
||||
Panel("БЫСТРЫЙ ВХОД С ТЕЛЕФОНА", AppColors.Pink, focused) {
|
||||
when (qc) {
|
||||
QuickConnectState.Loading -> PanelMessage("Получаем код…")
|
||||
QuickConnectState.Disabled -> PanelMessage("Quick Connect выключен на сервере.\nВойдите по паролю.")
|
||||
is QuickConnectState.Failed -> PanelMessage(qc.message)
|
||||
is QuickConnectState.Code -> QuickConnectCode(qc)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun QuickConnectCode(qc: QuickConnectState.Code) {
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
Row(Modifier.padding(20.dp), horizontalArrangement = Arrangement.spacedBy(20.dp)) {
|
||||
Box(Modifier.size(160.dp).background(Color(0xFFF3F1FF), RoundedCornerShape(10.dp)).padding(11.dp)) {
|
||||
Image(BitmapPainter(qc.qr, filterQuality = FilterQuality.None), contentDescription = "QR-код для входа", Modifier.fillMaxSize())
|
||||
}
|
||||
Column(verticalArrangement = Arrangement.spacedBy(11.dp)) {
|
||||
Instruction(1, "Наведите камеру телефона на QR-код")
|
||||
Instruction(2, "Или откройте Jellyfin → Quick Connect и введите код")
|
||||
BasicText(
|
||||
qc.code,
|
||||
Modifier.padding(top = 3.dp),
|
||||
style = AppType.MonoMeta.copy(fontSize = 30.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 0.14.em, color = Color.White),
|
||||
maxLines = 1,
|
||||
softWrap = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
Row(
|
||||
Modifier.align(Alignment.BottomStart).fillMaxWidth().padding(horizontal = 20.dp, vertical = 18.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(7.dp),
|
||||
) {
|
||||
StatusDot(AppColors.Green, pulsing = true)
|
||||
BasicText("Ждём подтверждения", Modifier.weight(1f), style = AppType.Body.copy(fontSize = 11.sp, color = AppColors.TextMuted))
|
||||
Countdown(qc.expiresAt)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** "код действует 9:58", recomposes once a second and only itself. */
|
||||
@Composable
|
||||
private fun Countdown(expiresAt: Long) {
|
||||
var left by rememberSaveable(expiresAt) { mutableStateOf(0L) }
|
||||
LaunchedEffect(expiresAt) {
|
||||
while (true) {
|
||||
left = ((expiresAt - android.os.SystemClock.elapsedRealtime()) / 1000).coerceAtLeast(0)
|
||||
delay(1000)
|
||||
}
|
||||
}
|
||||
BasicText("код действует %d:%02d".format(left / 60, left % 60), style = AppType.MonoCaption.copy(fontSize = 10.sp, color = Color(0xFF6F7592)))
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Instruction(n: Int, text: String) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Box(
|
||||
Modifier.size(20.dp).background(AppColors.Selected, CircleShape).border(0.5.dp, AppColors.SelectedBorder, CircleShape),
|
||||
contentAlignment = Alignment.Center,
|
||||
) { BasicText("$n", style = AppType.MonoMeta.copy(fontSize = 10.sp, color = AppColors.Text)) }
|
||||
BasicText(text, style = AppType.BodyLarge.copy(fontSize = 12.sp, lineHeight = 17.sp, color = AppColors.TextSecondary))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Panel(title: String, dot: Color, focused: Boolean, content: @Composable () -> Unit) {
|
||||
val shape = RoundedCornerShape(12.dp)
|
||||
Column(
|
||||
Modifier
|
||||
.width(390.dp)
|
||||
.height(320.dp)
|
||||
.background(AppColors.Surface, shape)
|
||||
.border(0.5.dp, if (focused) AppColors.SelectedBorder else Color(0xFF262A3A), shape),
|
||||
) {
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(28.dp)
|
||||
.background(Color(0xFF181B27), RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp))
|
||||
.padding(horizontal = 13.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
StatusDot(dot, pulsing = false)
|
||||
BasicText(title, style = AppType.MonoMeta.copy(fontSize = 9.5.sp, letterSpacing = 0.1.em))
|
||||
}
|
||||
Spacer(Modifier.fillMaxWidth().height(0.5.dp).background(Color(0xFF232637)))
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PanelMessage(text: String) {
|
||||
Box(Modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.Center) {
|
||||
BasicText(text, style = AppType.BodyLarge.copy(color = AppColors.TextMuted, textAlign = TextAlign.Center, lineHeight = 19.sp))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FieldLabel(text: String, modifier: Modifier = Modifier) {
|
||||
BasicText(text, modifier.padding(bottom = 7.dp), style = AppType.Body.copy(fontSize = 11.sp, color = AppColors.TextDim))
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ErrorText(text: String, modifier: Modifier = Modifier) {
|
||||
BasicText(text, modifier, style = AppType.Body.copy(fontSize = 12.sp, color = AppColors.Red))
|
||||
}
|
||||
|
||||
/** Gradient action button of the login forms (rounded rectangle, unlike the pill [PrimaryButton]). */
|
||||
@Composable
|
||||
private fun GradientButton(label: String, onClick: () -> Unit, modifier: Modifier = Modifier, big: Boolean = false) {
|
||||
Box(
|
||||
modifier
|
||||
.focusCard(FocusStyle(scale = 1.02f, corner = 8.dp, ring = 1.5.dp, ringColor = Color(0xFFD9D2FF), glows = FocusStyle.ButtonGlows))
|
||||
.height(42.dp)
|
||||
.background(AppColors.PrimaryGradient, RoundedCornerShape(8.dp))
|
||||
.clickable(interactionSource = null, indication = null, onClick = onClick)
|
||||
.padding(horizontal = 17.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
BasicText(label, style = AppType.Button.copy(fontSize = if (big) 14.sp else 13.sp, color = Color.White))
|
||||
}
|
||||
}
|
||||
|
||||
private const val MANUAL_KEY = "manual"
|
||||
private const val MAX_SERVER_CARDS = 3
|
||||
@@ -0,0 +1,433 @@
|
||||
package ru.stenfredd.sleepyowl.ui.login
|
||||
|
||||
import android.os.SystemClock
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.jellyfin.sdk.api.client.exception.InvalidStatusException
|
||||
import ru.stenfredd.sleepyowl.core.jellyfin.Account
|
||||
import ru.stenfredd.sleepyowl.core.jellyfin.JellyfinSession
|
||||
import ru.stenfredd.sleepyowl.core.jellyfin.PublicUser
|
||||
import ru.stenfredd.sleepyowl.core.jellyfin.QuickConnectExpiredException
|
||||
import ru.stenfredd.sleepyowl.core.jellyfin.Server
|
||||
import ru.stenfredd.sleepyowl.core.jellyfin.ServerLogin
|
||||
import ru.stenfredd.sleepyowl.core.jellyfin.ServerNotFoundException
|
||||
import ru.stenfredd.sleepyowl.ui.focus.ScreenFocus
|
||||
import timber.log.Timber
|
||||
import java.time.LocalDate
|
||||
import java.time.ZoneId
|
||||
import java.time.temporal.ChronoUnit
|
||||
import javax.inject.Inject
|
||||
|
||||
enum class LoginStep { Server, Profile, Login }
|
||||
|
||||
@Immutable
|
||||
data class ServerCardModel(
|
||||
val key: String,
|
||||
val name: String,
|
||||
val address: String,
|
||||
/** "LAN" when found on the local network, "ИНТЕРНЕТ" for a saved or typed address. */
|
||||
val where: String,
|
||||
val ping: String?,
|
||||
val version: String?,
|
||||
val busy: Boolean = false,
|
||||
)
|
||||
|
||||
@Immutable
|
||||
data class ProfileModel(val key: String, val name: String, val note: String, val avatarSeed: String)
|
||||
|
||||
@Immutable
|
||||
sealed interface QuickConnectState {
|
||||
data object Loading : QuickConnectState
|
||||
data object Disabled : QuickConnectState
|
||||
data class Failed(val message: String) : QuickConnectState
|
||||
data class Code(val code: String, val qr: ImageBitmap, val expiresAt: Long) : QuickConnectState
|
||||
}
|
||||
|
||||
@Immutable
|
||||
data class LoginState(
|
||||
val step: LoginStep,
|
||||
val servers: List<ServerCardModel> = emptyList(),
|
||||
val searching: Boolean = false,
|
||||
/** A search has finished at least once. */
|
||||
val searched: Boolean = false,
|
||||
val manualOpen: Boolean = false,
|
||||
val manualBusy: Boolean = false,
|
||||
val manualError: String? = null,
|
||||
val serverLine: String = "",
|
||||
val profiles: List<ProfileModel> = emptyList(),
|
||||
val profileError: String? = null,
|
||||
/** Name to put into the login form when a listed user needs a password. */
|
||||
val prefillUser: String = "",
|
||||
val loginBusy: Boolean = false,
|
||||
val loginError: String? = null,
|
||||
val quickConnect: QuickConnectState = QuickConnectState.Loading,
|
||||
/** Logged in: the screen leaves to the app. */
|
||||
val done: Boolean = false,
|
||||
)
|
||||
|
||||
/**
|
||||
* Login flow from the mockup: server (found on the LAN, saved, or typed) → profile ("Кто смотрит?": accounts saved on
|
||||
* this TV and the server's public users) → login (Quick Connect code with QR, or name and password).
|
||||
* Opened from the app's "сменить профиль" it starts at the profile step of the current server.
|
||||
*/
|
||||
@HiltViewModel
|
||||
class LoginViewModel @Inject constructor(
|
||||
private val session: JellyfinSession,
|
||||
private val login: ServerLogin,
|
||||
) : ViewModel() {
|
||||
private val history = ArrayDeque<LoginStep>()
|
||||
private val _state: MutableStateFlow<LoginState>
|
||||
val state: StateFlow<LoginState>
|
||||
|
||||
/** Remembered focus per step, so Back returns to the card that was chosen. */
|
||||
val focus: Map<LoginStep, ScreenFocus> = LoginStep.entries.associateWith { ScreenFocus(null) }
|
||||
|
||||
private var discovered: List<Server> = emptyList()
|
||||
private var probed: Map<String, Server> = emptyMap()
|
||||
private var typed: Server? = null
|
||||
private var server: Server? = null
|
||||
private var publicUsers: List<PublicUser> = emptyList()
|
||||
private var stepJob: Job? = null
|
||||
|
||||
init {
|
||||
val accounts = session.accounts.value
|
||||
val current = session.session.value?.let { s -> accounts.firstOrNull { it.id == s.accountId } } ?: accounts.firstOrNull()
|
||||
_state = MutableStateFlow(LoginState(step = LoginStep.Server))
|
||||
state = _state.asStateFlow()
|
||||
history += LoginStep.Server
|
||||
if (current != null) {
|
||||
// Straight to "Кто смотрит?" for the server used last; Back goes to the server list.
|
||||
val saved = current.toServer()
|
||||
server = saved
|
||||
history += LoginStep.Profile
|
||||
_state.update { it.copy(step = LoginStep.Profile, serverLine = serverLine(saved), profiles = profiles(saved)) }
|
||||
viewModelScope.launch { loadPublicUsers(saved) }
|
||||
// Accounts moved over from the prototype have no server name yet.
|
||||
if (saved.name.isEmpty()) viewModelScope.launch {
|
||||
login.probe(saved.requestUrl)?.let { info ->
|
||||
if (server == saved) _state.update { it.copy(serverLine = serverLine(saved.copy(name = info.name))) }
|
||||
}
|
||||
}
|
||||
}
|
||||
enterStep()
|
||||
}
|
||||
|
||||
/** Back inside the flow; false on the first step (the screen itself closes). */
|
||||
fun back(): Boolean {
|
||||
if (history.size <= 1 || _state.value.loginBusy) return false
|
||||
history.removeLast()
|
||||
_state.update { it.copy(step = history.last(), loginError = null, manualError = null) }
|
||||
enterStep()
|
||||
return true
|
||||
}
|
||||
|
||||
// ---- Server step ----
|
||||
|
||||
fun openManual() {
|
||||
_state.update { it.copy(manualOpen = true, manualError = null) }
|
||||
}
|
||||
|
||||
fun connectManual(address: String) {
|
||||
if (_state.value.manualBusy) return
|
||||
_state.update { it.copy(manualBusy = true, manualError = null) }
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val found = login.find(address)
|
||||
typed = found
|
||||
publishServers()
|
||||
pick(found)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Timber.w(e, "Server not found at %s", address)
|
||||
_state.update {
|
||||
it.copy(manualError = if (e is ServerNotFoundException) "Сервер не найден. Проверьте адрес." else describe(e))
|
||||
}
|
||||
} finally {
|
||||
_state.update { it.copy(manualBusy = false) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun pickServer(key: String) {
|
||||
val target = servers().firstOrNull { cardKey(it) == key } ?: return
|
||||
if (_state.value.servers.any { it.busy }) return
|
||||
viewModelScope.launch { pick(target) }
|
||||
}
|
||||
|
||||
private suspend fun pick(target: Server) {
|
||||
server = target
|
||||
markBusy(cardKey(target))
|
||||
loadPublicUsers(target)
|
||||
markBusy(null)
|
||||
val profiles = profiles(target)
|
||||
_state.update { it.copy(serverLine = serverLine(target), profiles = profiles, prefillUser = "") }
|
||||
// Nothing to choose from (users hidden, none saved): straight to the login form.
|
||||
if (profiles.size == 1) go(LoginStep.Login) else go(LoginStep.Profile)
|
||||
}
|
||||
|
||||
private fun markBusy(key: String?) {
|
||||
_state.update { s -> s.copy(servers = s.servers.map { it.copy(busy = it.key == key) }) }
|
||||
}
|
||||
|
||||
// ---- Profile step ----
|
||||
|
||||
fun pickProfile(key: String) {
|
||||
val target = server ?: return
|
||||
when {
|
||||
key == OTHER_KEY -> {
|
||||
_state.update { it.copy(prefillUser = "") }
|
||||
go(LoginStep.Login)
|
||||
}
|
||||
key.startsWith(ACCOUNT_PREFIX) -> viewModelScope.launch {
|
||||
runCatching { session.switchTo(key.removePrefix(ACCOUNT_PREFIX)) }
|
||||
.onSuccess { _state.update { it.copy(done = true) } }
|
||||
.onFailure { e -> _state.update { it.copy(profileError = describe(e)) } }
|
||||
}
|
||||
key.startsWith(USER_PREFIX) -> {
|
||||
val user = publicUsers.firstOrNull { it.id.toString() == key.removePrefix(USER_PREFIX) } ?: return
|
||||
_state.update { it.copy(prefillUser = user.name) }
|
||||
focus.getValue(LoginStep.Login).lastKey = if (user.hasPassword) PASSWORD_KEY else null
|
||||
if (user.hasPassword) {
|
||||
go(LoginStep.Login)
|
||||
} else {
|
||||
viewModelScope.launch {
|
||||
runCatching { login.login(target, user.name, "") }
|
||||
.onSuccess { _state.update { it.copy(done = true) } }
|
||||
.onFailure { go(LoginStep.Login) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Login step ----
|
||||
|
||||
fun loginWithPassword(userName: String, password: String) {
|
||||
val target = server ?: return
|
||||
if (_state.value.loginBusy) return
|
||||
if (userName.isBlank()) {
|
||||
_state.update { it.copy(loginError = "Введите имя пользователя") }
|
||||
return
|
||||
}
|
||||
_state.update { it.copy(loginBusy = true, loginError = null) }
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
login.login(target, userName, password)
|
||||
_state.update { it.copy(done = true) }
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Timber.w(e, "Login failed")
|
||||
_state.update { it.copy(loginError = describe(e)) }
|
||||
} finally {
|
||||
_state.update { it.copy(loginBusy = false) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun runQuickConnect(target: Server) {
|
||||
while (true) {
|
||||
_state.update { it.copy(quickConnect = QuickConnectState.Loading) }
|
||||
val code = try {
|
||||
login.startQuickConnect(target)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Timber.w(e, "Quick Connect not started")
|
||||
_state.update { it.copy(quickConnect = QuickConnectState.Failed(describe(e))) }
|
||||
delay(RETRY_MS)
|
||||
continue
|
||||
}
|
||||
if (code == null) {
|
||||
_state.update { it.copy(quickConnect = QuickConnectState.Disabled) }
|
||||
return
|
||||
}
|
||||
val qr = withContext(Dispatchers.Default) { qrBitmap(quickConnectUrl(target, code.code)) }
|
||||
val expiresAt = SystemClock.elapsedRealtime() + QUICK_CONNECT_LIFETIME_MS
|
||||
_state.update { it.copy(quickConnect = QuickConnectState.Code(formatCode(code.code), qr, expiresAt)) }
|
||||
// Poll until approved; the server drops the request after its lifetime, then a new code is shown.
|
||||
while (true) {
|
||||
delay(POLL_MS)
|
||||
val approved = try {
|
||||
login.isQuickConnectApproved(target, code.secret)
|
||||
} catch (e: QuickConnectExpiredException) {
|
||||
break
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Timber.d(e, "Quick Connect state not read")
|
||||
if (SystemClock.elapsedRealtime() > expiresAt) break else continue
|
||||
}
|
||||
if (approved) {
|
||||
try {
|
||||
login.loginQuickConnect(target, code.secret)
|
||||
_state.update { it.copy(done = true) }
|
||||
return
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Timber.w(e, "Quick Connect login failed")
|
||||
_state.update { it.copy(loginError = describe(e)) }
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Steps ----
|
||||
|
||||
private fun go(step: LoginStep) {
|
||||
if (history.last() == step) return
|
||||
history += step
|
||||
if (step == LoginStep.Login && _state.value.prefillUser.isEmpty()) focus.getValue(LoginStep.Login).lastKey = null
|
||||
_state.update { it.copy(step = step, loginError = null, profileError = null) }
|
||||
enterStep()
|
||||
}
|
||||
|
||||
/** Starts the background work of the current step: LAN search, or the Quick Connect code. */
|
||||
private fun enterStep() {
|
||||
stepJob?.cancel()
|
||||
stepJob = when (_state.value.step) {
|
||||
LoginStep.Server -> viewModelScope.launch { searchLoop() }
|
||||
LoginStep.Profile -> null
|
||||
LoginStep.Login -> server?.let { target -> viewModelScope.launch { runQuickConnect(target) } }
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun searchLoop() = coroutineScope {
|
||||
publishServers()
|
||||
// Saved servers that weren't found on the LAN: their ping and version through the saved address.
|
||||
launch {
|
||||
for (s in savedServers().filter { it.id !in probed }) {
|
||||
login.probe(s.url)?.let { probed = probed + (s.id to it.copy(id = s.id, name = s.name.ifEmpty { it.name })) }
|
||||
}
|
||||
publishServers()
|
||||
}
|
||||
while (isActive) {
|
||||
_state.update { it.copy(searching = true) }
|
||||
discovered = login.discover()
|
||||
_state.update { it.copy(searching = false, searched = true) }
|
||||
publishServers()
|
||||
delay(SEARCH_INTERVAL_MS)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun loadPublicUsers(target: Server) {
|
||||
publicUsers = try {
|
||||
login.publicUsers(target)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Timber.d(e, "Public users not loaded")
|
||||
emptyList()
|
||||
}
|
||||
if (server == target) _state.update { it.copy(profiles = profiles(target)) }
|
||||
}
|
||||
|
||||
// ---- Models ----
|
||||
|
||||
/** Discovered servers first, then saved ones that weren't found, then a typed one. */
|
||||
private fun servers(): List<Server> {
|
||||
val byId = LinkedHashMap<String, Server>()
|
||||
discovered.forEach { byId[it.id] = it }
|
||||
savedServers().forEach { saved -> if (saved.id !in byId) byId[saved.id] = probed[saved.id]?.copy(url = saved.url) ?: saved }
|
||||
typed?.let { if (it.id !in byId) byId[it.id] = it }
|
||||
return byId.values.toList()
|
||||
}
|
||||
|
||||
private fun savedServers(): List<Server> = session.accounts.value.distinctBy { it.serverId }.map { it.toServer() }
|
||||
|
||||
private fun publishServers() {
|
||||
val busy = _state.value.servers.firstOrNull { it.busy }?.key
|
||||
_state.update { s ->
|
||||
s.copy(servers = servers().map { srv ->
|
||||
val key = cardKey(srv)
|
||||
ServerCardModel(
|
||||
key = key,
|
||||
name = srv.name.ifEmpty { "Jellyfin" },
|
||||
address = displayAddress(srv.localUrl ?: srv.url),
|
||||
where = if (srv.localUrl != null && discovered.any { it.id == srv.id }) "LAN" else "ИНТЕРНЕТ",
|
||||
ping = srv.pingMs?.let { "$it мс" },
|
||||
version = srv.version,
|
||||
busy = key == busy,
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private fun profiles(target: Server): List<ProfileModel> {
|
||||
val currentId = session.session.value?.accountId
|
||||
val saved = session.accounts.value.filter { it.serverId == target.id }
|
||||
val savedUsers = saved.map { it.userId }.toSet()
|
||||
return saved.map { a ->
|
||||
ProfileModel(ACCOUNT_PREFIX + a.id, a.userName, if (a.id == currentId) "сейчас" else lastUsed(a.lastUsedAt), a.userId.toString())
|
||||
} + publicUsers.filter { it.id !in savedUsers }.map { u ->
|
||||
ProfileModel(USER_PREFIX + u.id, u.name, if (u.hasPassword) "пароль" else "без пароля", u.id.toString())
|
||||
} + ProfileModel(OTHER_KEY, "Другой", "QR или пароль", "")
|
||||
}
|
||||
|
||||
private fun Account.toServer() = Server(serverId, serverName, serverUrl, localUrl)
|
||||
|
||||
private fun cardKey(s: Server) = "srv-" + s.id
|
||||
|
||||
private fun serverLine(s: Server) = listOf(s.name, displayAddress(s.url)).filter { it.isNotEmpty() }.joinToString(" · ")
|
||||
|
||||
private fun displayAddress(url: String) = url.removePrefix("http://").removePrefix("https://")
|
||||
|
||||
private fun lastUsed(at: Long): String {
|
||||
if (at <= 0) return ""
|
||||
val zone = ZoneId.systemDefault()
|
||||
val days = ChronoUnit.DAYS.between(java.time.Instant.ofEpochMilli(at).atZone(zone).toLocalDate(), LocalDate.now(zone))
|
||||
return when {
|
||||
days <= 0 -> "сегодня"
|
||||
days == 1L -> "вчера"
|
||||
days in 2..4 -> "$days дня назад"
|
||||
days < 30 -> "$days дней назад"
|
||||
else -> "давно"
|
||||
}
|
||||
}
|
||||
|
||||
/** The phone opens the server's web app on its Quick Connect page with the code filled in. */
|
||||
private fun quickConnectUrl(target: Server, code: String) = "${target.url}/web/#/quickconnect?code=$code"
|
||||
|
||||
private fun formatCode(code: String) = if (code.length == 6) code.substring(0, 3) + " " + code.substring(3) else code
|
||||
|
||||
private fun describe(e: Throwable): String {
|
||||
val status = generateSequence(e) { it.cause }.filterIsInstance<InvalidStatusException>().firstOrNull()?.status
|
||||
return when (status) {
|
||||
401 -> "Неверное имя пользователя или пароль"
|
||||
403 -> "Вход запрещён для этого пользователя"
|
||||
null -> "Нет связи с сервером"
|
||||
else -> "Сервер ответил ошибкой $status"
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val OTHER_KEY = "other"
|
||||
const val PASSWORD_KEY = "password"
|
||||
private const val ACCOUNT_PREFIX = "acc-"
|
||||
private const val USER_PREFIX = "usr-"
|
||||
private const val SEARCH_INTERVAL_MS = 8_000L
|
||||
private const val POLL_MS = 3_000L
|
||||
private const val RETRY_MS = 5_000L
|
||||
/** Jellyfin forgets a Quick Connect request after 10 minutes. */
|
||||
private const val QUICK_CONNECT_LIFETIME_MS = 10 * 60_000L
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package ru.stenfredd.sleepyowl.ui.login
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import com.google.zxing.BarcodeFormat
|
||||
import com.google.zxing.EncodeHintType
|
||||
import com.google.zxing.qrcode.QRCodeWriter
|
||||
import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel
|
||||
|
||||
/**
|
||||
* QR code of [text], one pixel per module, no quiet zone (the white card around it is the margin).
|
||||
* Drawn scaled up without filtering, so the edges stay sharp.
|
||||
*/
|
||||
fun qrBitmap(text: String): ImageBitmap {
|
||||
val hints = mapOf(EncodeHintType.MARGIN to 0, EncodeHintType.ERROR_CORRECTION to ErrorCorrectionLevel.M)
|
||||
val matrix = QRCodeWriter().encode(text, BarcodeFormat.QR_CODE, 0, 0, hints)
|
||||
val pixels = IntArray(matrix.width * matrix.height) { i ->
|
||||
if (matrix.get(i % matrix.width, i / matrix.width)) QR_DARK else QR_LIGHT
|
||||
}
|
||||
return Bitmap.createBitmap(pixels, matrix.width, matrix.height, Bitmap.Config.ARGB_8888).asImageBitmap()
|
||||
}
|
||||
|
||||
private const val QR_DARK = 0xFF0B0C12.toInt()
|
||||
private const val QR_LIGHT = 0xFFF3F1FF.toInt()
|
||||
348
app/src/main/kotlin/ru/stenfredd/sleepyowl/ui/menu/WatchMenu.kt
Normal file
348
app/src/main/kotlin/ru/stenfredd/sleepyowl/ui/menu/WatchMenu.kt
Normal file
@@ -0,0 +1,348 @@
|
||||
package ru.stenfredd.sleepyowl.ui.menu
|
||||
|
||||
import android.view.KeyEvent as AndroidKeyEvent
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
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.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.BasicText
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import androidx.compose.runtime.withFrameNanos
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.key.KeyEventType
|
||||
import androidx.compose.ui.input.key.key
|
||||
import androidx.compose.ui.input.key.nativeKeyCode
|
||||
import androidx.compose.ui.input.key.onKeyEvent
|
||||
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
||||
import androidx.compose.ui.input.key.type
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.lifecycle.ViewModel
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.launch
|
||||
import ru.stenfredd.sleepyowl.core.data.WatchRepository
|
||||
import ru.stenfredd.sleepyowl.core.data.model.ItemKind
|
||||
import ru.stenfredd.sleepyowl.ui.components.AppIcon
|
||||
import ru.stenfredd.sleepyowl.ui.components.FocusStyle
|
||||
import ru.stenfredd.sleepyowl.ui.components.Icon
|
||||
import ru.stenfredd.sleepyowl.ui.components.focusCard
|
||||
import ru.stenfredd.sleepyowl.ui.model.plural
|
||||
import ru.stenfredd.sleepyowl.ui.player.SidePanel
|
||||
import ru.stenfredd.sleepyowl.ui.theme.AppColors
|
||||
import ru.stenfredd.sleepyowl.ui.theme.AppType
|
||||
import timber.log.Timber
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/** What the watched menu marks: a movie, an episode, or every episode of a season or a series. */
|
||||
@Immutable
|
||||
data class WatchTarget(
|
||||
val id: UUID,
|
||||
val kind: ItemKind,
|
||||
/** The movie or series name. */
|
||||
val title: String,
|
||||
/** Which part of the series: "S1 · E4 · Пилот", "Сезон 2"; the year of a movie. */
|
||||
val subtitle: String? = null,
|
||||
/** Watched; of a season or series — every episode. */
|
||||
val played: Boolean,
|
||||
/** A started movie or episode, 0..1. */
|
||||
val progress: Float? = null,
|
||||
/** Episodes of a season or series and how many are watched: known on the series page. */
|
||||
val episodes: Int? = null,
|
||||
val watchedEpisodes: Int? = null,
|
||||
/** Some episode of a season or series is started (has a place to continue from). */
|
||||
val started: Boolean = false,
|
||||
/** Unwatched episodes of a season or series, from a card (the server doesn't tell watched ones there). */
|
||||
val unplayed: Int? = null,
|
||||
) {
|
||||
val isGroup: Boolean get() = kind == ItemKind.Series || kind == ItemKind.Season
|
||||
}
|
||||
|
||||
/** Opens the watched menu; provided by [WatchMenuHost] above every screen. */
|
||||
@Stable
|
||||
class WatchMenu internal constructor() {
|
||||
internal var shown: Shown? by mutableStateOf(null)
|
||||
|
||||
/** Shows the menu for [target]; focus goes back to [returnTo] when it closes. */
|
||||
fun open(target: WatchTarget, returnTo: FocusRequester) {
|
||||
shown = Shown(target, returnTo)
|
||||
}
|
||||
|
||||
/** One opening of the menu (a class: a reply that comes late must not close the next one). */
|
||||
internal class Shown(val target: WatchTarget, val returnTo: FocusRequester)
|
||||
}
|
||||
|
||||
val LocalWatchMenu = staticCompositionLocalOf<WatchMenu?> { null }
|
||||
|
||||
/**
|
||||
* Click of a card or chip that also opens the watched menu for [target]: OK held down (a long press), or the remote's
|
||||
* Menu key where it has one. Without a target it's a plain click. Put it where `clickable` would go.
|
||||
*/
|
||||
@Composable
|
||||
fun Modifier.clickableWithMenu(target: WatchTarget?, onClick: () -> Unit): Modifier {
|
||||
val menu = LocalWatchMenu.current
|
||||
if (target == null || menu == null) return clickable(interactionSource = null, indication = null, onClick = onClick)
|
||||
val self = remember { FocusRequester() }
|
||||
val open = remember(menu, target) { { menu.open(target, self) } }
|
||||
return this
|
||||
.focusRequester(self)
|
||||
.onKeyEvent { e ->
|
||||
(e.key.nativeKeyCode == AndroidKeyEvent.KEYCODE_MENU).also { if (it && e.type == KeyEventType.KeyUp) open() }
|
||||
}
|
||||
.combinedClickable(interactionSource = null, indication = null, onLongClick = open, onClick = onClick)
|
||||
}
|
||||
|
||||
@HiltViewModel
|
||||
class WatchMenuViewModel @Inject constructor(private val watch: WatchRepository) : ViewModel() {
|
||||
/** Screens reload on their own when it's done (the cache tells them the user data changed). */
|
||||
suspend fun setPlayed(id: UUID, played: Boolean) = watch.setPlayed(id, played)
|
||||
}
|
||||
|
||||
/**
|
||||
* Hosts the watched menu over [content]: a panel on the right, opaque (a full-screen scrim is expensive on weak TV
|
||||
* GPUs), as the player's settings panels. Marking a season or series marks every episode on the server.
|
||||
*/
|
||||
@Composable
|
||||
fun WatchMenuHost(content: @Composable () -> Unit) {
|
||||
val menu = remember { WatchMenu() }
|
||||
val vm: WatchMenuViewModel = hiltViewModel()
|
||||
// Not the panel's scope: a request goes on when the panel is closed before the server answers.
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
fun close(shown: WatchMenu.Shown) {
|
||||
if (menu.shown !== shown) return
|
||||
// Focus goes back before the panel goes: a focused panel leaving would hand focus to the page's first button.
|
||||
val back = runCatching { shown.returnTo.requestFocus() }.getOrDefault(false)
|
||||
menu.shown = null
|
||||
if (!back) {
|
||||
scope.launch {
|
||||
withFrameNanos { }
|
||||
runCatching { shown.returnTo.requestFocus() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
CompositionLocalProvider(LocalWatchMenu provides menu) {
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
content()
|
||||
menu.shown?.let { shown ->
|
||||
key(shown) {
|
||||
var saving by remember { mutableStateOf<Boolean?>(null) }
|
||||
var error by remember { mutableStateOf<String?>(null) }
|
||||
WatchPanel(
|
||||
shown.target,
|
||||
saving = saving,
|
||||
error = error,
|
||||
onPick = { played ->
|
||||
if (saving == null) {
|
||||
saving = played
|
||||
error = null
|
||||
scope.launch {
|
||||
try {
|
||||
vm.setPlayed(shown.target.id, played)
|
||||
close(shown)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Timber.w(e, "Watched menu: marking failed")
|
||||
saving = null
|
||||
error = e.message ?: e.toString()
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
onClose = { close(shown) },
|
||||
modifier = Modifier.align(Alignment.CenterEnd),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class MenuAction(val played: Boolean, val icon: AppIcon, val label: String, val detail: String?)
|
||||
|
||||
private val ActionFocus = FocusStyle(scale = 1f, corner = 7.dp, ring = 0.5.dp, glows = listOf(FocusStyle.Glow(15.dp, 0.dp, Color(0x737C5CFF))), animate = false)
|
||||
|
||||
/**
|
||||
* The menu itself: what the item is, how much of it is watched, and the actions. [saving] is the action being sent
|
||||
* (true = watched), [error] why the last one failed.
|
||||
*/
|
||||
@Composable
|
||||
private fun WatchPanel(
|
||||
target: WatchTarget,
|
||||
saving: Boolean?,
|
||||
error: String?,
|
||||
onPick: (Boolean) -> Unit,
|
||||
onClose: () -> Unit,
|
||||
modifier: Modifier,
|
||||
) {
|
||||
val initial = remember { FocusRequester() }
|
||||
// The long press that opened the menu may still be held: its repeats and release must not press an action,
|
||||
// so OK counts only from a fresh press.
|
||||
val okArmed = remember { booleanArrayOf(false) }
|
||||
// Back acts on release, as Android does: closing on the press would send the release to the page, which would leave it.
|
||||
val backDown = remember { booleanArrayOf(false) }
|
||||
SidePanel(
|
||||
title = target.kindLabel(),
|
||||
dot = AppColors.Green,
|
||||
hint = "Назад — закрыть",
|
||||
initial = initial,
|
||||
modifier = modifier.onPreviewKeyEvent {
|
||||
val native = it.nativeKeyEvent
|
||||
when (native.keyCode) {
|
||||
AndroidKeyEvent.KEYCODE_DPAD_CENTER, AndroidKeyEvent.KEYCODE_ENTER, AndroidKeyEvent.KEYCODE_NUMPAD_ENTER -> {
|
||||
if (it.type == KeyEventType.KeyDown && native.repeatCount == 0) okArmed[0] = true
|
||||
!okArmed[0]
|
||||
}
|
||||
AndroidKeyEvent.KEYCODE_BACK, AndroidKeyEvent.KEYCODE_ESCAPE -> {
|
||||
when (it.type) {
|
||||
KeyEventType.KeyDown -> backDown[0] = true
|
||||
KeyEventType.KeyUp -> if (backDown[0]) onClose()
|
||||
}
|
||||
true
|
||||
}
|
||||
else -> false
|
||||
}
|
||||
},
|
||||
) {
|
||||
Column {
|
||||
BasicText(target.title, style = AppType.Section.copy(fontSize = 17.sp, lineHeight = 21.sp), maxLines = 2, overflow = TextOverflow.Ellipsis)
|
||||
target.subtitle?.let {
|
||||
BasicText(it, Modifier.padding(top = 5.dp), style = AppType.MonoMeta, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
target.status()?.let {
|
||||
BasicText(it, Modifier.padding(top = 9.dp), style = AppType.Body.copy(color = if (target.played) AppColors.Green else AppColors.TextMuted))
|
||||
}
|
||||
}
|
||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
target.actions().forEachIndexed { i, action ->
|
||||
ActionRow(
|
||||
action,
|
||||
saving = saving == action.played,
|
||||
onClick = { onPick(action.played) },
|
||||
modifier = if (i == 0) Modifier.focusRequester(initial) else Modifier,
|
||||
)
|
||||
}
|
||||
}
|
||||
error?.let {
|
||||
BasicText("Не удалось сохранить: $it", style = AppType.Body.copy(color = AppColors.Red), maxLines = 3, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** "✓ Отметить просмотренным" with what it touches under it. */
|
||||
@Composable
|
||||
private fun ActionRow(action: MenuAction, saving: Boolean, onClick: () -> Unit, modifier: Modifier) {
|
||||
var focused by remember { mutableStateOf(false) }
|
||||
val shape = RoundedCornerShape(7.dp)
|
||||
val tint = if (action.played) AppColors.Green else AppColors.TextSecondary
|
||||
Row(
|
||||
modifier
|
||||
.focusCard(ActionFocus)
|
||||
.fillMaxWidth()
|
||||
.height(if (action.detail != null) 52.dp else 42.dp)
|
||||
.background(if (focused) Color(0xFF1F2238) else AppColors.SurfaceRaised, shape)
|
||||
.border(0.5.dp, if (focused) AppColors.Accent else Color(0xFF232637), shape)
|
||||
.onFocusChanged { focused = it.isFocused }
|
||||
.clickable(interactionSource = null, indication = null, onClick = onClick)
|
||||
.padding(horizontal = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(11.dp),
|
||||
) {
|
||||
Icon(action.icon, tint, Modifier.size(15.dp))
|
||||
Column(Modifier.weight(1f)) {
|
||||
BasicText(
|
||||
action.label,
|
||||
style = AppType.BodyLarge.copy(fontWeight = FontWeight.Medium, color = AppColors.Text),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
(if (saving) "Сохраняем…" else action.detail)?.let {
|
||||
BasicText(it, Modifier.padding(top = 4.dp), style = AppType.MonoCaption.copy(fontSize = 9.5.sp), maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun WatchTarget.kindLabel(): String = when (kind) {
|
||||
ItemKind.Episode -> "СЕРИЯ"
|
||||
ItemKind.Season -> "СЕЗОН"
|
||||
ItemKind.Series -> "СЕРИАЛ"
|
||||
else -> "ФИЛЬМ"
|
||||
}
|
||||
|
||||
private fun episodesWord(n: Int) = plural(n, "серия", "серии", "серий")
|
||||
|
||||
/** "Просмотрено 12 из 62 серий", "Просмотрено 42%", "Не просмотрен". */
|
||||
private fun WatchTarget.status(): String? {
|
||||
val feminine = kind == ItemKind.Episode
|
||||
if (!isGroup) {
|
||||
return when {
|
||||
played -> if (feminine) "Просмотрена" else "Просмотрен"
|
||||
progress != null -> "Просмотрено ${(progress * 100).roundToInt()}%"
|
||||
else -> if (feminine) "Не просмотрена" else "Не просмотрен"
|
||||
}
|
||||
}
|
||||
return when {
|
||||
played -> "Все серии просмотрены"
|
||||
episodes != null && watchedEpisodes != null -> when (watchedEpisodes) {
|
||||
0 -> "Ни одной серии не просмотрено"
|
||||
else -> "Просмотрено $watchedEpisodes из $episodes ${plural(episodes, "серии", "серий", "серий")}"
|
||||
}
|
||||
unplayed != null && unplayed > 0 -> "Не просмотрено: $unplayed ${episodesWord(unplayed)}"
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Only what changes something: "watched" unless it already is, "unwatched" if anything is watched or started.
|
||||
* A card doesn't tell whether some episodes of a series are watched, so it offers both.
|
||||
*/
|
||||
private fun WatchTarget.actions(): List<MenuAction> = buildList {
|
||||
if (!isGroup) {
|
||||
val ending = if (kind == ItemKind.Episode) "ой" else "ым"
|
||||
if (!played) add(MenuAction(true, AppIcon.Check, "Отметить просмотренн$ending", null))
|
||||
if (played || progress != null) {
|
||||
add(MenuAction(false, AppIcon.Replay, "Отметить непросмотренн$ending", if (progress != null) "Место остановки сбросится" else null))
|
||||
}
|
||||
return@buildList
|
||||
}
|
||||
val noun = if (kind == ItemKind.Season) "сезон" else "сериал"
|
||||
if (!played) {
|
||||
add(MenuAction(true, AppIcon.Check, "Отметить весь $noun просмотренным", episodes?.let { "$it ${episodesWord(it)}" } ?: "Все серии"))
|
||||
}
|
||||
if (played || started || watchedEpisodes == null || watchedEpisodes > 0) {
|
||||
add(MenuAction(false, AppIcon.Replay, "Отметить весь $noun непросмотренным", "Отметки и места остановки всех серий сбросятся"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
package ru.stenfredd.sleepyowl.ui.model
|
||||
|
||||
import android.content.Context
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import ru.stenfredd.sleepyowl.core.data.ImageUrls
|
||||
import ru.stenfredd.sleepyowl.core.data.model.ImageRef
|
||||
import ru.stenfredd.sleepyowl.core.data.model.ItemKind
|
||||
import ru.stenfredd.sleepyowl.core.data.model.ItemSummary
|
||||
import ru.stenfredd.sleepyowl.ui.Screen
|
||||
import ru.stenfredd.sleepyowl.ui.components.FeaturedModel
|
||||
import ru.stenfredd.sleepyowl.ui.components.PosterModel
|
||||
import ru.stenfredd.sleepyowl.ui.components.RemoteImageSpec
|
||||
import ru.stenfredd.sleepyowl.ui.components.WideModel
|
||||
import ru.stenfredd.sleepyowl.ui.menu.WatchTarget
|
||||
import ru.stenfredd.sleepyowl.ui.theme.AppDimens
|
||||
import javax.inject.Inject
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
* Turns server items into card models. Image URLs ask the server for exactly the card's size in pixels.
|
||||
* Card keys are "<row>/<item id>": the same item can appear in several rows, and focus is remembered per key.
|
||||
*/
|
||||
class CardMapper @Inject constructor(
|
||||
private val images: ImageUrls,
|
||||
@ApplicationContext context: Context,
|
||||
) {
|
||||
private val density = context.resources.displayMetrics.density
|
||||
|
||||
fun px(dp: Float) = (dp * density).roundToInt()
|
||||
|
||||
fun key(row: String, item: ItemSummary) = "$row/${item.id}"
|
||||
|
||||
fun poster(item: ItemSummary, row: String): PosterModel {
|
||||
// A single new episode comes as the episode itself: show it as its series, poster included.
|
||||
val episode = item.kind == ItemKind.Episode
|
||||
return PosterModel(
|
||||
key = key(row, item),
|
||||
title = (if (episode) item.seriesName else null) ?: item.name,
|
||||
caption = if (episode) {
|
||||
listOfNotNull(item.seasonNumber?.let { "S$it" }, item.episodeNumber?.let { "E$it" }).joinToString(" · ").ifEmpty { null }
|
||||
} else {
|
||||
listOfNotNull(item.year?.toString(), item.communityRating?.let { "★ %.1f".format(it) }).joinToString(" · ").ifEmpty { null }
|
||||
},
|
||||
image = image(if (episode) item.seriesPrimary ?: item.primary else item.primary, AppDimens.POSTER_W.toFloat(), AppDimens.POSTER_H.toFloat()),
|
||||
badge = item.formatBadge(),
|
||||
progress = item.user.progress,
|
||||
isNew = isNew(item),
|
||||
watched = item.user.played,
|
||||
watch = watchTarget(item),
|
||||
)
|
||||
}
|
||||
|
||||
/** Poster captioned "Фильм · 2021" / "Сериал · 2020": lists that mix movies and series. */
|
||||
fun kindPoster(item: ItemSummary, row: String): PosterModel = poster(item, row).copy(
|
||||
caption = listOfNotNull(
|
||||
when (item.kind) {
|
||||
ItemKind.Movie -> "Фильм"
|
||||
ItemKind.Series -> "Сериал"
|
||||
else -> null
|
||||
},
|
||||
item.year?.toString(),
|
||||
).joinToString(" · ").ifEmpty { null },
|
||||
)
|
||||
|
||||
fun wide(item: ItemSummary, row: String): WideModel = WideModel(
|
||||
key = key(row, item),
|
||||
title = item.seriesName ?: item.name,
|
||||
subtitle = listOfNotNull(item.episodeLabel() ?: item.year?.toString(), item.remaining()).joinToString(" · "),
|
||||
image = image(item.landscape(), AppDimens.WIDE_W.toFloat(), AppDimens.WIDE_H.toFloat()),
|
||||
progress = item.user.progress,
|
||||
watch = watchTarget(item),
|
||||
)
|
||||
|
||||
fun featured(item: ItemSummary, row: String): FeaturedModel {
|
||||
val isEpisode = item.kind == ItemKind.Episode
|
||||
return FeaturedModel(
|
||||
key = key(row, item),
|
||||
label = if (isEpisode) "СЕРИАЛ" else "ФИЛЬМ",
|
||||
title = item.seriesName ?: item.name,
|
||||
subtitle = if (isEpisode) {
|
||||
item.episodeLabel().orEmpty()
|
||||
} else {
|
||||
listOfNotNull(item.year?.toString(), item.genres.take(2).joinToString(", ").ifEmpty { null }).joinToString(" · ")
|
||||
},
|
||||
description = item.overview,
|
||||
image = image(item.backdrop ?: item.landscape(), FEATURED_W, FEATURED_H),
|
||||
progress = item.user.progress ?: 0f,
|
||||
remaining = item.remaining(),
|
||||
watch = watchTarget(item),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a card leads: a movie to its page, a series, season or episode to the series page (on that episode).
|
||||
* Collections and folders still open the old prototype list until the library screen exists.
|
||||
*/
|
||||
fun target(item: ItemSummary): Screen = when (item.kind) {
|
||||
ItemKind.Series -> Screen.Series(item.id)
|
||||
ItemKind.Season -> item.seriesId?.let { Screen.Series(it) } ?: Screen.Items(item.id, item.name)
|
||||
ItemKind.Episode -> item.seriesId?.let { Screen.Series(it, item.id) } ?: Screen.Detail(item.id)
|
||||
ItemKind.BoxSet, ItemKind.Folder -> Screen.Items(item.id, item.name)
|
||||
else -> Screen.Detail(item.id)
|
||||
}
|
||||
|
||||
/** What the card's long press marks watched: the item itself (a single new episode too, though it shows its series). */
|
||||
fun watchTarget(item: ItemSummary): WatchTarget? {
|
||||
val part = item.kind == ItemKind.Episode || item.kind == ItemKind.Season
|
||||
return when (item.kind) {
|
||||
ItemKind.Movie, ItemKind.Video, ItemKind.Episode, ItemKind.Season, ItemKind.Series -> WatchTarget(
|
||||
id = item.id,
|
||||
kind = item.kind,
|
||||
title = (if (part) item.seriesName else null) ?: item.name,
|
||||
subtitle = when (item.kind) {
|
||||
ItemKind.Episode -> listOfNotNull(
|
||||
item.seasonNumber?.let { "S$it" },
|
||||
item.episodeNumber?.let { "E$it" },
|
||||
item.name.takeIf { item.seriesName != null },
|
||||
).joinToString(" · ").ifEmpty { null }
|
||||
ItemKind.Season -> item.name.takeIf { item.seriesName != null }
|
||||
else -> item.year?.toString()
|
||||
},
|
||||
played = item.user.played,
|
||||
progress = item.user.progress,
|
||||
unplayed = item.user.unplayedCount.takeIf { item.kind == ItemKind.Series || item.kind == ItemKind.Season },
|
||||
)
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
fun image(ref: ImageRef?, wDp: Float, hDp: Float): RemoteImageSpec? =
|
||||
ref?.let { RemoteImageSpec(images.url(it, px(wDp), px(hDp)), it.blurHash) }
|
||||
|
||||
private fun ItemSummary.episodeLabel(): String? =
|
||||
if (kind == ItemKind.Episode && seasonNumber != null && episodeNumber != null) "S$seasonNumber · E$episodeNumber · $name" else null
|
||||
|
||||
/** "Новинка": added within the last 14 days. */
|
||||
fun isNew(item: ItemSummary): Boolean = item.dateAddedAt?.let { System.currentTimeMillis() - it < NEW_MS } == true
|
||||
|
||||
private fun ItemSummary.formatBadge(): String? {
|
||||
val w = videoWidth ?: return null
|
||||
return when {
|
||||
w >= 3200 -> "4K"
|
||||
w >= 1800 -> "1080p"
|
||||
w >= 1200 -> "720p"
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
/** "Новинка": added within the last 14 days. */
|
||||
const val NEW_MS = 14 * 24 * 3600_000L
|
||||
const val FEATURED_W = 410f
|
||||
const val FEATURED_H = 230.5f
|
||||
}
|
||||
}
|
||||
|
||||
/** Episode still, else the backdrop or thumb of the item or its series. Also the TV home screen's cards (`LauncherFeed`). */
|
||||
fun ItemSummary.landscape(): ImageRef? = (if (kind == ItemKind.Episode) primary else null) ?: backdrop ?: thumb
|
||||
|
||||
/** "осталось 31 мин" from the item's progress. */
|
||||
fun ItemSummary.remaining(): String? {
|
||||
val runtime = runtimeMs ?: return null
|
||||
val left = runtime * (1 - (user.progress ?: 0f)) / 60_000
|
||||
return "осталось ${left.roundToInt()} мин"
|
||||
}
|
||||
35
app/src/main/kotlin/ru/stenfredd/sleepyowl/ui/model/Words.kt
Normal file
35
app/src/main/kotlin/ru/stenfredd/sleepyowl/ui/model/Words.kt
Normal file
@@ -0,0 +1,35 @@
|
||||
package ru.stenfredd.sleepyowl.ui.model
|
||||
|
||||
import java.time.Instant
|
||||
import java.time.LocalDate
|
||||
import java.time.ZoneId
|
||||
import java.time.temporal.ChronoUnit
|
||||
|
||||
/** Russian plural form for [n]: 1 серия, 2 серии, 5 серий. */
|
||||
internal fun plural(n: Int, one: String, few: String, many: String): String {
|
||||
val mod100 = n % 100
|
||||
val mod10 = n % 10
|
||||
return when {
|
||||
mod100 in 11..14 -> many
|
||||
mod10 == 1 -> one
|
||||
mod10 in 2..4 -> few
|
||||
else -> many
|
||||
}
|
||||
}
|
||||
|
||||
/** How long ago [at] (epoch ms) was, by calendar days: "сегодня", "вчера", "3 дня назад", "2 недели назад", "в прошлом году"… */
|
||||
internal fun daysAgo(at: Long): String {
|
||||
val zone = ZoneId.systemDefault()
|
||||
val days = ChronoUnit.DAYS.between(Instant.ofEpochMilli(at).atZone(zone).toLocalDate(), LocalDate.now(zone)).toInt()
|
||||
return when {
|
||||
days <= 0 -> "сегодня"
|
||||
days == 1 -> "вчера"
|
||||
days < 7 -> "$days ${plural(days, "день", "дня", "дней")} назад"
|
||||
days < 14 -> "неделю назад"
|
||||
days < 30 -> "${days / 7} недели назад"
|
||||
days < 60 -> "месяц назад"
|
||||
days < 365 -> (days / 30).let { "$it ${plural(it, "месяц", "месяца", "месяцев")} назад" }
|
||||
days < 730 -> "год назад"
|
||||
else -> (days / 365).let { "$it ${plural(it, "год", "года", "лет")} назад" }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package ru.stenfredd.sleepyowl.ui.person
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.items
|
||||
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
|
||||
import androidx.compose.foundation.text.BasicText
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import ru.stenfredd.sleepyowl.ui.Screen
|
||||
import ru.stenfredd.sleepyowl.ui.components.AnchoredScrolling
|
||||
import ru.stenfredd.sleepyowl.ui.components.Avatar
|
||||
import ru.stenfredd.sleepyowl.ui.components.PosterCard
|
||||
import ru.stenfredd.sleepyowl.ui.components.RemoteImage
|
||||
import ru.stenfredd.sleepyowl.ui.components.anchorViewport
|
||||
import ru.stenfredd.sleepyowl.ui.components.anchoredRow
|
||||
import ru.stenfredd.sleepyowl.ui.components.fullItem
|
||||
import ru.stenfredd.sleepyowl.ui.focus.Restore
|
||||
import ru.stenfredd.sleepyowl.ui.focus.rememberScreenFocus
|
||||
import ru.stenfredd.sleepyowl.ui.focus.remembered
|
||||
import ru.stenfredd.sleepyowl.ui.theme.AppColors
|
||||
import ru.stenfredd.sleepyowl.ui.theme.AppDimens
|
||||
import ru.stenfredd.sleepyowl.ui.theme.AppType
|
||||
|
||||
/**
|
||||
* A person (not in the mockup, built in its style like the favourites screen): photo, name, "12 фильмов · 3 сериала",
|
||||
* a grid of their movies and series. Full screen without the rail, like the movie page.
|
||||
*/
|
||||
@Composable
|
||||
fun PersonScreen(
|
||||
screen: Screen.Person,
|
||||
onOpen: (Screen) -> Unit,
|
||||
vm: PersonViewModel = hiltViewModel<PersonViewModel, PersonViewModel.Factory>(creationCallback = { it.create(screen.personId) }),
|
||||
) {
|
||||
val state by vm.state.collectAsStateWithLifecycle()
|
||||
val focus = rememberScreenFocus()
|
||||
val grid = rememberLazyGridState()
|
||||
|
||||
when {
|
||||
state.loading -> Box(Modifier.fillMaxSize())
|
||||
state.error != null -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
BasicText("Не удалось загрузить: ${state.error}", style = AppType.Body)
|
||||
}
|
||||
else -> AnchoredScrolling(grid, anchor = AppDimens.CONTENT_TOP.dp) { anchored ->
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.FixedSize(AppDimens.POSTER_W.dp),
|
||||
state = grid,
|
||||
modifier = Modifier.fillMaxSize().anchorViewport(anchored),
|
||||
contentPadding = PaddingValues(start = Side, end = Side, top = AppDimens.CONTENT_TOP.dp, bottom = 200.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(14.dp),
|
||||
) {
|
||||
fullItem("header", "header") {
|
||||
Row(Modifier.padding(bottom = 22.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
val size = PersonLayout.PHOTO.dp
|
||||
if (state.photo != null) {
|
||||
RemoteImage(state.photo, size / 2, Modifier.size(size))
|
||||
} else {
|
||||
Avatar(state.name, state.name, size, AppType.Hero.copy(fontSize = 22.sp), letters = state.letters)
|
||||
}
|
||||
Column {
|
||||
BasicText("ЧЕЛОВЕК", style = AppType.MonoLabel)
|
||||
BasicText(state.name, Modifier.padding(top = 4.dp), style = AppType.Display.copy(fontSize = 28.sp), maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
BasicText(state.summary, Modifier.padding(top = 4.dp), style = AppType.MonoMeta.copy(fontSize = 11.sp, color = AppColors.TextMuted))
|
||||
}
|
||||
}
|
||||
}
|
||||
items(state.items, key = { it.key }, contentType = { "poster" }) { p ->
|
||||
PosterCard(
|
||||
p,
|
||||
{ vm.target(p.key)?.let(onOpen) },
|
||||
Modifier.padding(bottom = 18.dp).anchoredRow(anchored).remembered(focus, p.key, isDefault = p === state.items.first()),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
focus.Restore(ready = !state.loading && state.error == null)
|
||||
}
|
||||
|
||||
private val Side = AppDimens.CONTENT_START.dp
|
||||
@@ -0,0 +1,113 @@
|
||||
package ru.stenfredd.sleepyowl.ui.person
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import ru.stenfredd.sleepyowl.core.data.LibraryRepository
|
||||
import ru.stenfredd.sleepyowl.core.data.SessionCache
|
||||
import ru.stenfredd.sleepyowl.core.data.model.ItemKind
|
||||
import ru.stenfredd.sleepyowl.ui.Screen
|
||||
import ru.stenfredd.sleepyowl.ui.components.PosterModel
|
||||
import ru.stenfredd.sleepyowl.ui.components.RemoteImageSpec
|
||||
import ru.stenfredd.sleepyowl.ui.model.CardMapper
|
||||
import ru.stenfredd.sleepyowl.ui.model.plural
|
||||
import timber.log.Timber
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
@Immutable
|
||||
data class PersonState(
|
||||
val loading: Boolean = true,
|
||||
val error: String? = null,
|
||||
val name: String = "",
|
||||
/** Initials when there is no photo. */
|
||||
val letters: String = "",
|
||||
val photo: RemoteImageSpec? = null,
|
||||
/** "12 фильмов · 3 сериала". */
|
||||
val summary: String = "",
|
||||
val items: List<PosterModel> = emptyList(),
|
||||
)
|
||||
|
||||
/** A person's page: who it is and the movies and series of the server they are in, newest first. */
|
||||
@HiltViewModel(assistedFactory = PersonViewModel.Factory::class)
|
||||
class PersonViewModel @AssistedInject constructor(
|
||||
@Assisted private val personId: UUID,
|
||||
private val library: LibraryRepository,
|
||||
private val cards: CardMapper,
|
||||
cache: SessionCache,
|
||||
) : ViewModel() {
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
fun create(personId: UUID): PersonViewModel
|
||||
}
|
||||
|
||||
private val _state = MutableStateFlow(PersonState())
|
||||
val state: StateFlow<PersonState> = _state.asStateFlow()
|
||||
private val targets = ConcurrentHashMap<String, Screen>()
|
||||
|
||||
init {
|
||||
viewModelScope.launch { load() }
|
||||
viewModelScope.launch { cache.invalidations.collect { load() } }
|
||||
}
|
||||
|
||||
fun target(key: String): Screen? = targets[key]
|
||||
|
||||
private suspend fun load() {
|
||||
try {
|
||||
coroutineScope {
|
||||
val person = async { library.person(personId) }
|
||||
// A guest role in an episode stands for its series, once, unless the series itself is in the list.
|
||||
val all = library.filmography(personId)
|
||||
val seriesIds = all.filter { it.kind == ItemKind.Series }.mapTo(HashSet()) { it.id }
|
||||
val items = all.filter { it.kind != ItemKind.Episode || (it.seriesId != null && it.seriesId !in seriesIds) }
|
||||
.distinctBy { if (it.kind == ItemKind.Episode) it.seriesId else it.id }
|
||||
val p = person.await()
|
||||
val posters = withContext(Dispatchers.Default) {
|
||||
items.map { item ->
|
||||
targets[cards.key(ROW, item)] = cards.target(item)
|
||||
if (item.kind == ItemKind.Episode) cards.poster(item, ROW) else cards.kindPoster(item, ROW)
|
||||
}
|
||||
}
|
||||
val movies = items.count { it.kind == ItemKind.Movie }
|
||||
val series = items.size - movies
|
||||
_state.value = PersonState(
|
||||
loading = false,
|
||||
name = p.name,
|
||||
letters = p.name.split(' ').filter { it.isNotBlank() }.take(2).joinToString("") { it.take(1).uppercase() },
|
||||
photo = cards.image(p.image, PersonLayout.PHOTO, PersonLayout.PHOTO),
|
||||
summary = listOfNotNull(
|
||||
movies.takeIf { it > 0 }?.let { "$it ${plural(it, "фильм", "фильма", "фильмов")}" },
|
||||
series.takeIf { it > 0 }?.let { "$it ${plural(it, "сериал", "сериала", "сериалов")}" },
|
||||
).joinToString(" · ").ifEmpty { "На сервере нет фильмов с этим человеком" },
|
||||
items = posters,
|
||||
)
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Timber.w(e, "Person failed")
|
||||
if (_state.value.loading) _state.value = PersonState(loading = false, error = e.message ?: e.toString())
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val ROW = "person"
|
||||
}
|
||||
}
|
||||
|
||||
internal object PersonLayout {
|
||||
const val PHOTO = 64f
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
package ru.stenfredd.sleepyowl.ui.player
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.tv.material3.Button
|
||||
import androidx.tv.material3.MaterialTheme
|
||||
import androidx.tv.material3.Text
|
||||
import org.jellyfin.sdk.model.api.MediaStreamType
|
||||
import org.jellyfin.sdk.model.api.PlayMethod
|
||||
import ru.stenfredd.sleepyowl.playback.api.Codecs
|
||||
import ru.stenfredd.sleepyowl.playback.api.DolbyVisionSwitchable
|
||||
import ru.stenfredd.sleepyowl.playback.api.EngineId
|
||||
import ru.stenfredd.sleepyowl.playback.api.VideoPathSwitchable
|
||||
import ru.stenfredd.sleepyowl.playback.core.PlaybackSession
|
||||
import ru.stenfredd.sleepyowl.playback.core.PlaybackUiState
|
||||
import ru.stenfredd.sleepyowl.playback.core.describe
|
||||
import ru.stenfredd.sleepyowl.playback.exo.ExoEngineProvider
|
||||
import ru.stenfredd.sleepyowl.playback.vlc.VlcEngineProvider
|
||||
|
||||
/*
|
||||
* Debug builds only: the prototype's numbered playback variants (badge in the corner, digit keys 0–9, MENU opens the
|
||||
* variant/track/engine list, INFO cycles badge → diagnostics → nothing) and the debug tour. Release builds never call
|
||||
* into this file (every use is behind BuildConfig.DEBUG, R8 drops it).
|
||||
*/
|
||||
|
||||
/** Debug tour context for [PlayerScreen] (when the OSD is hidden): ↑ cycles playback variants, ↓ goes to the next file. */
|
||||
class TourInfo(val number: Int, val total: Int, val title: String, val label: String, val onNextFile: () -> Unit)
|
||||
|
||||
/** Variants ↑ steps through in the tour: auto first, then each engine/path, then a server transcode. */
|
||||
internal val TOUR_VARIANTS = listOf(0) + Variant.entries.map { it.number } + TRANSCODE_KEY
|
||||
|
||||
/**
|
||||
* Numbered playback variants for A/B testing on the TV: the badge in the corner shows the number, so the tester can
|
||||
* note "file — variant — impression". 0 returns to the automatic choice. Picked from the ↓ menu or with digit keys.
|
||||
*/
|
||||
private enum class Variant(
|
||||
val number: Int,
|
||||
val label: String,
|
||||
val engine: EngineId,
|
||||
val path: VideoPathSwitchable.Mode? = null,
|
||||
/** For Exo: decode Dolby Vision files through the DV decoder instead of the base layer. */
|
||||
val dolbyVision: Boolean = false,
|
||||
) {
|
||||
EXO_PLANE(1, "Exo · видеослой ТВ", ExoEngineProvider.ID, VideoPathSwitchable.Mode.VIDEO_PLANE),
|
||||
EXO_GPU(2, "Exo · через GPU", ExoEngineProvider.ID, VideoPathSwitchable.Mode.GPU),
|
||||
VLC(3, "VLC", VlcEngineProvider.ID),
|
||||
EXO_DV(4, "Exo · DV-декодер", ExoEngineProvider.ID, VideoPathSwitchable.Mode.VIDEO_PLANE, dolbyVision = true),
|
||||
}
|
||||
|
||||
internal const val TRANSCODE_KEY = 9
|
||||
|
||||
/** Returns false if [number] is not a variant. */
|
||||
internal fun applyVariant(number: Int, session: PlaybackSession): Boolean {
|
||||
when (number) {
|
||||
0 -> session.switchToAuto()
|
||||
TRANSCODE_KEY -> session.forceTranscode()
|
||||
else -> {
|
||||
val v = Variant.entries.firstOrNull { it.number == number } ?: return false
|
||||
val current = session.activeEngine
|
||||
val sameDecoder = (current as? DolbyVisionSwitchable)?.useDolbyVisionDecoder?.let { it == v.dolbyVision } ?: true
|
||||
if (current?.id == v.engine && current is VideoPathSwitchable && v.path != null && sameDecoder) current.setVideoPathMode(v.path)
|
||||
else session.switchEngine(v.engine, videoPath = v.path, dolbyVision = v.dolbyVision.takeIf { v.engine == ExoEngineProvider.ID })
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/** The variant actually playing, e.g. "2" (Exo through GPU) or "3Т" (VLC on a server transcode). */
|
||||
private fun currentVariantCode(state: PlaybackUiState, session: PlaybackSession): String {
|
||||
val engine = session.activeEngine
|
||||
val gpu = (engine as? VideoPathSwitchable)?.usingGpuPath
|
||||
val dv = (engine as? DolbyVisionSwitchable)?.useDolbyVisionDecoder == true
|
||||
val v = Variant.entries.firstOrNull {
|
||||
it.engine == state.engine && it.dolbyVision == dv && (it.path == null || gpu == (it.path == VideoPathSwitchable.Mode.GPU))
|
||||
}
|
||||
val transcode = state.playMethod != null && state.playMethod != PlayMethod.DIRECT_PLAY
|
||||
return (v?.number?.toString() ?: "?") + if (transcode) "Т" else ""
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun VariantBadge(state: PlaybackUiState, session: PlaybackSession, tour: TourInfo?, modifier: Modifier) {
|
||||
val engine = session.activeEngine
|
||||
val path = (engine as? VideoPathSwitchable)?.let { if (it.usingGpuPath) "GPU" else "видеослой" }
|
||||
?.plus(if ((engine as? DolbyVisionSwitchable)?.useDolbyVisionDecoder == true) " · DV" else "")
|
||||
val transcode = state.playMethod != null && state.playMethod != PlayMethod.DIRECT_PLAY
|
||||
val head = listOfNotNull(state.engine?.value?.uppercase(), path, if (transcode) "ТРАНСКОД" else null).joinToString(" · ")
|
||||
val mode = when {
|
||||
state.manualEngine -> "вручную"
|
||||
state.engineReason.startsWith("fallback") || "не умеет" in state.engineReason || "все движки" in state.engineReason -> "авто, запасной"
|
||||
else -> "авто"
|
||||
}
|
||||
Column(
|
||||
modifier.background(Color(0xC0000000)).padding(16.dp, 10.dp),
|
||||
horizontalAlignment = Alignment.End,
|
||||
) {
|
||||
tour?.let {
|
||||
Text("Отладка: файл ${it.number} из ${it.total}", fontSize = 14.sp, color = Color(0xFF80CBC4))
|
||||
}
|
||||
Text(
|
||||
"${currentVariantCode(state, session)} $head",
|
||||
fontSize = 26.sp, fontWeight = FontWeight.Bold, fontFamily = FontFamily.Monospace, color = Color(0xFFFFD54F),
|
||||
)
|
||||
Text(
|
||||
"$mode · ${PlayerLabels.time(state.positionMs)} · пропуски ${state.stats.droppedFrames}",
|
||||
fontSize = 14.sp, fontFamily = FontFamily.Monospace, color = Color.White,
|
||||
)
|
||||
Text(tour?.title ?: state.title, fontSize = if (tour != null) 16.sp else 12.sp, color = Color.LightGray)
|
||||
tour?.let { Text(it.label, fontSize = 14.sp, color = Color.White, modifier = Modifier.widthIn(max = 900.dp)) }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun DebugPanel(state: PlaybackUiState, vm: PlayerViewModel, modifier: Modifier) {
|
||||
val caps = vm.probe.capabilities
|
||||
val lines = buildList {
|
||||
add("Движок: ${state.engine} — ${state.engineReason}")
|
||||
add("Метод: ${state.playMethod?.serialName ?: "?"}" + (state.transcodeReasons?.let { " ($it)" } ?: ""))
|
||||
state.stats.videoDecoder?.let { add("Видеодекодер: $it") }
|
||||
state.stats.videoFormat?.let { add("Видео: $it") }
|
||||
state.stats.audioDecoder?.let { add("Аудиодекодер: $it") }
|
||||
state.stats.audioFormat?.let { add("Аудио: $it") }
|
||||
state.stats.audioOutput?.let { add("Аудиовыход: $it") }
|
||||
add("Пропущено кадров: ${state.stats.droppedFrames}")
|
||||
state.stats.extra.forEach { (k, v) -> add("$k: $v") }
|
||||
state.ranking.forEach { (id, verdict) -> add("· $id: ${verdict.describe()}") }
|
||||
add("Устройство: ${caps.manufacturer} ${caps.model} (${caps.soc}), HDR ${caps.displayHdrTypes}, passthrough ${caps.passthroughEncodings.map(Codecs::encodingName)}")
|
||||
add("— журнал —")
|
||||
addAll(state.history.takeLast(8))
|
||||
}
|
||||
Column(modifier.width(900.dp).background(Color(0xB0000000)).padding(12.dp)) {
|
||||
lines.forEach { Text(it, fontSize = 11.sp, color = Color.White, lineHeight = 13.sp) }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun DebugMenu(
|
||||
state: PlaybackUiState,
|
||||
session: PlaybackSession,
|
||||
vm: PlayerViewModel,
|
||||
modifier: Modifier,
|
||||
close: () -> Unit,
|
||||
) {
|
||||
val source = state.source ?: return
|
||||
fun mark(selected: Boolean) = if (selected) "● " else " "
|
||||
LazyColumn(
|
||||
modifier.fillMaxHeight().width(520.dp).background(Color(0xE0101010)).padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
item { Text("Вариант (сейчас ${currentVariantCode(state, session)})", style = MaterialTheme.typography.titleMedium) }
|
||||
val variants = listOf(0 to "Авто") + Variant.entries.map { it.number to it.label } + listOf(TRANSCODE_KEY to "Транскод сервером (текущим движком)")
|
||||
variants.forEach { (number, label) ->
|
||||
item {
|
||||
Button(onClick = { applyVariant(number, session); close() }) { Text("$number $label") }
|
||||
}
|
||||
}
|
||||
item { Text("Аудио", style = MaterialTheme.typography.titleMedium) }
|
||||
source.mediaStreams.orEmpty().filter { it.type == MediaStreamType.AUDIO }.forEach { s ->
|
||||
item {
|
||||
Button(onClick = { session.selectAudio(s.index); close() }) {
|
||||
Text(mark(state.selection.audioStreamIndex == s.index) + (s.displayTitle ?: s.codec.orEmpty()))
|
||||
}
|
||||
}
|
||||
}
|
||||
item { Text("Субтитры", style = MaterialTheme.typography.titleMedium) }
|
||||
item {
|
||||
Button(onClick = { session.selectSubtitle(null); close() }) { Text(mark(state.selection.subtitleStreamIndex == null) + "Выключены") }
|
||||
}
|
||||
source.mediaStreams.orEmpty().filter { it.type == MediaStreamType.SUBTITLE }.forEach { s ->
|
||||
item {
|
||||
Button(onClick = { session.selectSubtitle(s.index); close() }) {
|
||||
Text(mark(state.selection.subtitleStreamIndex == s.index) + (s.displayTitle ?: s.codec.orEmpty()))
|
||||
}
|
||||
}
|
||||
}
|
||||
item { Text("Движок", style = MaterialTheme.typography.titleMedium) }
|
||||
vm.selector.engines.forEach { p ->
|
||||
item { Button(onClick = { session.switchEngine(p.id); close() }) { Text(mark(state.engine == p.id) + p.displayName) } }
|
||||
}
|
||||
item { Button(onClick = { session.forceTranscode(); close() }) { Text(" Транскод на сервере") } }
|
||||
(session.activeEngine as? VideoPathSwitchable)?.let { engine ->
|
||||
item { Text("Вывод видео", style = MaterialTheme.typography.titleMedium) }
|
||||
val modes = listOf(
|
||||
VideoPathSwitchable.Mode.AUTO to "Авто (сейчас: ${if (engine.usingGpuPath) "через GPU" else "видеослой ТВ"})",
|
||||
VideoPathSwitchable.Mode.VIDEO_PLANE to "Видеослой ТВ (с HDR, обработка ТВ)",
|
||||
VideoPathSwitchable.Mode.GPU to "Через GPU, как VLC (без HDR, до 1080p)",
|
||||
)
|
||||
modes.forEach { (mode, label) ->
|
||||
item {
|
||||
Button(onClick = { engine.setVideoPathMode(mode); close() }) {
|
||||
Text(mark(engine.videoPathMode == mode) + label)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,297 @@
|
||||
package ru.stenfredd.sleepyowl.ui.player
|
||||
|
||||
import android.view.KeyEvent as AndroidKeyEvent
|
||||
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.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
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.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.withFrameNanos
|
||||
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.ui.focus.focusRequester
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.key.KeyEventType
|
||||
import androidx.compose.ui.input.key.key
|
||||
import androidx.compose.ui.input.key.nativeKeyCode
|
||||
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
||||
import androidx.compose.ui.input.key.type
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.em
|
||||
import androidx.compose.ui.unit.sp
|
||||
import ru.stenfredd.sleepyowl.ui.components.AppIcon
|
||||
import ru.stenfredd.sleepyowl.ui.components.Avatar
|
||||
import ru.stenfredd.sleepyowl.ui.components.FocusStyle
|
||||
import ru.stenfredd.sleepyowl.ui.components.Icon
|
||||
import ru.stenfredd.sleepyowl.ui.components.ImageShade
|
||||
import ru.stenfredd.sleepyowl.ui.components.ProgressLine
|
||||
import ru.stenfredd.sleepyowl.ui.components.RemoteImage
|
||||
import ru.stenfredd.sleepyowl.ui.components.focusCard
|
||||
import ru.stenfredd.sleepyowl.ui.theme.AppColors
|
||||
import ru.stenfredd.sleepyowl.ui.theme.AppType
|
||||
import java.util.UUID
|
||||
|
||||
/** Height of the drawer; the controls move up by this much while it is open. */
|
||||
internal const val DRAWER_HEIGHT = 210
|
||||
|
||||
private val DrawerBackground = Color(0xFF0C0D14)
|
||||
private val EpisodeShade = ImageShade(0.6f, listOf(0f to Color(0xB306070C), 1f to Color.Transparent))
|
||||
private val EpisodeFocus = FocusStyle(scale = 1.06f, corner = 8.dp, animate = false)
|
||||
private val PillFocus = FocusStyle(scale = 1f, corner = 15.dp, glows = FocusStyle.ButtonGlows, animate = false)
|
||||
|
||||
/**
|
||||
* Drawer under the controls (↓): "О фильме" for a movie, the season's episodes for an episode (picking one plays it
|
||||
* without leaving the player). ↑ from the header button folds it. [initial] gets focus when it opens.
|
||||
*/
|
||||
@Composable
|
||||
internal fun PlayerDrawer(
|
||||
info: PlayerInfo?,
|
||||
season: SeasonEpisodes?,
|
||||
currentId: UUID?,
|
||||
playedFraction: Float,
|
||||
initial: FocusRequester,
|
||||
onClose: () -> Unit,
|
||||
onPage: () -> Unit,
|
||||
onEpisode: (UUID) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val episodes = season?.takeIf { info?.isEpisode == true }
|
||||
val page = remember { FocusRequester() }
|
||||
LaunchedEffect(Unit) {
|
||||
withFrameNanos { }
|
||||
runCatching { initial.requestFocus() }
|
||||
}
|
||||
Column(
|
||||
modifier
|
||||
.fillMaxWidth()
|
||||
.height(DRAWER_HEIGHT.dp)
|
||||
.background(DrawerBackground, RoundedCornerShape(topStart = 14.dp, topEnd = 14.dp))
|
||||
.drawBehind { drawRect(Color(0xFF2A2E48), size = Size(size.width, 0.5.dp.toPx())) },
|
||||
) {
|
||||
Box(Modifier.fillMaxWidth().padding(top = 6.dp), contentAlignment = Alignment.Center) {
|
||||
Box(Modifier.size(32.dp, 2.5.dp).background(Color(0xFF33384D), RoundedCornerShape(1.5.dp)))
|
||||
}
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(start = 48.dp, end = 48.dp, top = 7.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Box(Modifier.size(6.dp).background(if (episodes != null) AppColors.Blue else AppColors.Accent, CircleShape))
|
||||
BasicText(
|
||||
if (episodes != null) episodes.title else "О ФИЛЬМЕ",
|
||||
style = AppType.MonoLabel.copy(color = AppColors.TextSecondary, letterSpacing = 0.1.em),
|
||||
)
|
||||
BasicText(
|
||||
(if (episodes != null) episodes.stat else info?.title).orEmpty(),
|
||||
Modifier.weight(1f),
|
||||
style = AppType.MonoCaption.copy(fontSize = 9.5.sp, color = Color(0xFF6F7592)),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
BasicText("▲ — свернуть", Modifier.padding(end = 6.dp), style = AppType.MonoCaption.copy(color = AppColors.TextFaint))
|
||||
PagePill(
|
||||
if (episodes != null) "К сериалу" else "Страница фильма",
|
||||
onClick = onPage,
|
||||
modifier = Modifier
|
||||
.focusRequester(page)
|
||||
.then(if (episodes == null) Modifier.focusRequester(initial) else Modifier)
|
||||
.onPreviewKeyEvent {
|
||||
if (it.type == KeyEventType.KeyDown && it.key.nativeKeyCode == AndroidKeyEvent.KEYCODE_DPAD_UP) {
|
||||
onClose()
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
when {
|
||||
// ↑ from the episodes goes to "К сериалу" (the controls above would leave the drawer open behind them).
|
||||
episodes != null -> Box(
|
||||
Modifier.onPreviewKeyEvent {
|
||||
if (it.type == KeyEventType.KeyDown && it.key.nativeKeyCode == AndroidKeyEvent.KEYCODE_DPAD_UP) {
|
||||
runCatching { page.requestFocus() }
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
},
|
||||
) { EpisodeRow(episodes, currentId, playedFraction, initial, onEpisode) }
|
||||
info != null -> AboutMovie(info)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PagePill(label: String, onClick: () -> Unit, modifier: Modifier = Modifier) {
|
||||
var focused by remember { mutableStateOf(false) }
|
||||
val shape = RoundedCornerShape(15.dp)
|
||||
Row(
|
||||
modifier
|
||||
.focusCard(PillFocus)
|
||||
.height(30.dp)
|
||||
.background(if (focused) AppColors.FocusFill else OsdColors.Fill, shape)
|
||||
.border(0.5.dp, if (focused) AppColors.FocusFill else OsdColors.Border, shape)
|
||||
.onFocusChanged { focused = it.isFocused }
|
||||
.clickable(interactionSource = null, indication = null, onClick = onClick)
|
||||
.padding(start = 12.dp, end = 9.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
val color = if (focused) AppColors.OnFocusFill else OsdColors.Text
|
||||
BasicText(label, style = AppType.Button.copy(fontSize = 11.sp, fontWeight = FontWeight.Medium, color = color))
|
||||
Icon(AppIcon.ChevronRight, color, Modifier.size(10.dp), strokeWidth = 2.4f)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AboutMovie(info: PlayerInfo) {
|
||||
Row(Modifier.fillMaxWidth().padding(start = 48.dp, end = 48.dp, top = 14.dp), horizontalArrangement = Arrangement.spacedBy(20.dp)) {
|
||||
RemoteImage(info.poster, 7.dp, Modifier.offset(y = (-3).dp).size(PlayerLayout.POSTER_W.dp, PlayerLayout.POSTER_H.dp))
|
||||
Column(Modifier.weight(1f)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(9.dp)) {
|
||||
val meta = AppType.BodyLarge.copy(fontSize = 11.5.sp, color = AppColors.TextSecondary)
|
||||
info.rating?.let { BasicText(it, style = meta.copy(color = AppColors.Yellow, fontWeight = FontWeight.SemiBold)) }
|
||||
info.age?.let {
|
||||
BasicText(
|
||||
it,
|
||||
Modifier.border(0.5.dp, Color(0xFF4A4F68), RoundedCornerShape(3.dp)).padding(horizontal = 5.dp, vertical = 1.dp),
|
||||
style = meta.copy(fontSize = 9.5.sp),
|
||||
)
|
||||
}
|
||||
listOfNotNull(info.year, info.runtime, info.genres).forEach { BasicText(it, style = meta, maxLines = 1) }
|
||||
}
|
||||
info.description?.let {
|
||||
BasicText(
|
||||
it,
|
||||
Modifier.padding(top = 8.dp),
|
||||
style = AppType.Body.copy(fontSize = 12.sp, lineHeight = 18.sp, color = AppColors.TextSecondary),
|
||||
maxLines = 3,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
info.director?.let {
|
||||
BasicText("Режиссёр: $it", Modifier.padding(top = 7.dp), style = AppType.Body.copy(fontSize = 10.5.sp, color = AppColors.TextDim), maxLines = 1)
|
||||
}
|
||||
}
|
||||
if (info.cast.isNotEmpty()) {
|
||||
Column(Modifier.width(210.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
BasicText("В РОЛЯХ", style = AppType.MonoCaption.copy(color = Color(0xFF6F7592), letterSpacing = 0.1.em))
|
||||
Spacer(Modifier.height(1.dp))
|
||||
for (p in info.cast) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(7.dp)) {
|
||||
if (p.image != null) {
|
||||
RemoteImage(p.image, 12.dp, Modifier.size(PlayerLayout.AVATAR.dp))
|
||||
} else {
|
||||
Avatar(p.name, seed = p.key, size = PlayerLayout.AVATAR.dp, textStyle = AppType.MonoBadge.copy(fontSize = 9.sp, color = Color.White), letters = p.letters)
|
||||
}
|
||||
BasicText(p.name, style = AppType.CardTitle.copy(fontSize = 11.sp, fontWeight = FontWeight.SemiBold), maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EpisodeRow(season: SeasonEpisodes, currentId: UUID?, playedFraction: Float, initial: FocusRequester, onEpisode: (UUID) -> Unit) {
|
||||
val currentIndex = season.episodes.indexOfFirst { it.id == currentId }.coerceAtLeast(0)
|
||||
val listState = rememberLazyListState(initialFirstVisibleItemIndex = (currentIndex - 1).coerceAtLeast(0))
|
||||
LazyRow(
|
||||
Modifier.fillMaxWidth().padding(top = 13.dp),
|
||||
state = listState,
|
||||
contentPadding = PaddingValues(horizontal = 48.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
items(season.episodes, key = { it.key }, contentType = { "episode" }) { e ->
|
||||
val current = e.id == currentId
|
||||
EpisodeCard(
|
||||
e,
|
||||
current = current,
|
||||
progress = if (current) playedFraction else e.progress,
|
||||
onClick = { onEpisode(e.id) },
|
||||
modifier = if (current) Modifier.focusRequester(initial) else Modifier,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EpisodeCard(e: EpisodeModel, current: Boolean, progress: Float?, onClick: () -> Unit, modifier: Modifier = Modifier) {
|
||||
Column(Modifier.width(PlayerLayout.EPISODE_W.dp)) {
|
||||
Box(
|
||||
modifier
|
||||
.focusCard(EpisodeFocus)
|
||||
.size(PlayerLayout.EPISODE_W.dp, PlayerLayout.EPISODE_H.dp)
|
||||
.then(if (current) Modifier.border(1.dp, Color(0xFF8B7DFF), RoundedCornerShape(8.dp)) else Modifier)
|
||||
.clickable(interactionSource = null, indication = null, onClick = onClick),
|
||||
) {
|
||||
RemoteImage(e.image, 8.dp, Modifier.fillMaxSize(), shade = EpisodeShade)
|
||||
BasicText(
|
||||
e.number,
|
||||
Modifier.padding(start = 8.dp, top = 6.dp),
|
||||
style = AppType.MonoMeta.copy(fontSize = 18.sp, fontWeight = FontWeight.SemiBold, color = Color(0xE0FFFFFF)),
|
||||
)
|
||||
val badge = when {
|
||||
current -> Triple("СЕЙЧАС", AppColors.AccentStrong, Color.White)
|
||||
e.watched -> Triple("✓", Color(0xBF0A0B10), AppColors.Green)
|
||||
e.isNew -> Triple("НОВАЯ", Color(0xBF0A0B10), AppColors.Pink)
|
||||
else -> null
|
||||
}
|
||||
badge?.let { (text, bg, fg) ->
|
||||
BasicText(
|
||||
text,
|
||||
Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.padding(7.dp)
|
||||
.background(bg, RoundedCornerShape(3.5.dp))
|
||||
.then(if (current) Modifier else Modifier.border(0.5.dp, fg, RoundedCornerShape(3.5.dp)))
|
||||
.padding(horizontal = 5.dp, vertical = 2.dp),
|
||||
style = AppType.MonoBadge.copy(color = fg),
|
||||
)
|
||||
}
|
||||
e.duration?.let {
|
||||
BasicText(it, Modifier.align(Alignment.BottomStart).padding(start = 8.dp, bottom = 8.dp), style = AppType.MonoMeta.copy(fontSize = 9.sp, color = Color(0xFFD9DCEC)))
|
||||
}
|
||||
progress?.takeIf { it > 0f }?.let {
|
||||
ProgressLine(it, Modifier.align(Alignment.BottomStart).padding(horizontal = 6.dp, vertical = 4.dp).fillMaxWidth().height(3.dp))
|
||||
}
|
||||
}
|
||||
BasicText(
|
||||
e.title,
|
||||
Modifier.padding(top = 7.dp),
|
||||
style = AppType.PosterTitle.copy(color = if (e.watched && !current) AppColors.TextMuted else AppColors.Text),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
package ru.stenfredd.sleepyowl.ui.player
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import org.jellyfin.sdk.model.api.PlayMethod
|
||||
import ru.stenfredd.sleepyowl.core.data.model.MediaInfo
|
||||
import ru.stenfredd.sleepyowl.core.data.model.VideoRange
|
||||
import ru.stenfredd.sleepyowl.playback.api.DolbyVisionSwitchable
|
||||
import ru.stenfredd.sleepyowl.playback.api.PlaybackEngine
|
||||
import ru.stenfredd.sleepyowl.playback.api.VideoPathSwitchable
|
||||
import ru.stenfredd.sleepyowl.playback.core.PlaybackUiState
|
||||
import ru.stenfredd.sleepyowl.ui.details.MediaLabels
|
||||
import ru.stenfredd.sleepyowl.ui.details.TrackOption
|
||||
import ru.stenfredd.sleepyowl.ui.theme.AppColors
|
||||
import java.util.Locale
|
||||
|
||||
/** Audio and subtitle choices of the file being played. Subtitles start with "Выкл" (index null). */
|
||||
@Immutable
|
||||
data class PlayerTracks(val audio: List<TrackOption>, val subtitles: List<TrackOption>) {
|
||||
companion object {
|
||||
fun of(media: MediaInfo?): PlayerTracks {
|
||||
media ?: return PlayerTracks(emptyList(), emptyList())
|
||||
return PlayerTracks(
|
||||
audio = media.audio.map { TrackOption("audio/${it.index}", it.index, MediaLabels.audioLabel(it), MediaLabels.audioDetail(it)) },
|
||||
subtitles = listOf(TrackOption("sub/off", null, "Выкл", null)) +
|
||||
media.subtitles.map { TrackOption("sub/${it.index}", it.index, MediaLabels.subtitleLabel(it), MediaLabels.subtitleDetail(it)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** One line of "Информация о потоке"; [color] is the dot: video blue, audio pink, subtitles yellow, the rest green. */
|
||||
@Immutable
|
||||
data class InfoRow(val key: String, val value: String, val color: Color)
|
||||
|
||||
/** The verdict at the top of "Информация о потоке". */
|
||||
@Immutable
|
||||
data class StreamMode(val direct: Boolean, val title: String, val reason: String)
|
||||
|
||||
internal object PlayerLabels {
|
||||
/** "Русский 5.1"; a track without a language: its own title, else the format ("AC3 5.1"). */
|
||||
fun audioChip(media: MediaInfo?, index: Int?): String {
|
||||
val track = media?.audio?.firstOrNull { it.index == index } ?: return "Аудио"
|
||||
val name = MediaLabels.language(track.language) ?: track.name ?: return MediaLabels.audioDetail(track) ?: "Аудио"
|
||||
return listOfNotNull(name, channels(track.channels)).joinToString(" ")
|
||||
}
|
||||
|
||||
/** "Русский" or "Субтитры выкл" */
|
||||
fun subtitleChip(media: MediaInfo?, index: Int?): String {
|
||||
val track = media?.subtitles?.firstOrNull { it.index == index } ?: return "Субтитры выкл"
|
||||
return MediaLabels.language(track.language) ?: track.name ?: "Субтитры"
|
||||
}
|
||||
|
||||
fun isDirect(state: PlaybackUiState) = state.playMethod == null || state.playMethod == PlayMethod.DIRECT_PLAY
|
||||
|
||||
fun mode(state: PlaybackUiState): StreamMode {
|
||||
if (isDirect(state)) return StreamMode(true, "Прямое воспроизведение", "Файл воспроизводится без изменений")
|
||||
val reasons = state.transcodeReasons?.split(',')?.mapNotNull { reason(it.trim()) }?.distinct().orEmpty()
|
||||
val why = when {
|
||||
reasons.isNotEmpty() -> reasons.joinToString(", ")
|
||||
state.maxBitrate != null -> "ограничение качества"
|
||||
else -> "выбрано вручную"
|
||||
}
|
||||
return StreamMode(false, "Транскодирование", "Причина: $why")
|
||||
}
|
||||
|
||||
/** "ExoPlayer · видеослой ТВ", "VLC · вручную"; the DV decoder is only mentioned for a Dolby Vision file. */
|
||||
fun engine(state: PlaybackUiState, engine: PlaybackEngine?, dolbyVisionFile: Boolean): String {
|
||||
val name = when (val id = state.engine?.value) {
|
||||
"exo" -> "ExoPlayer"
|
||||
"vlc" -> "VLC"
|
||||
null -> "—"
|
||||
else -> id
|
||||
}
|
||||
val path = (engine as? VideoPathSwitchable)?.let { if (it.usingGpuPath) "через GPU" else "видеослой ТВ" }
|
||||
val dv = "DV-декодер".takeIf { dolbyVisionFile && (engine as? DolbyVisionSwitchable)?.useDolbyVisionDecoder == true }
|
||||
return listOfNotNull(name, path, dv, "вручную".takeIf { state.manualEngine }).joinToString(" · ")
|
||||
}
|
||||
|
||||
fun rows(state: PlaybackUiState, media: MediaInfo?, engine: PlaybackEngine?): List<InfoRow> = buildList {
|
||||
val video = media?.video
|
||||
val transcode = !isDirect(state)
|
||||
media?.container?.let { add(InfoRow("Контейнер", it.uppercase() + if (transcode) " → HLS" else "", AppColors.Green)) }
|
||||
if (video != null) {
|
||||
val format = listOfNotNull(
|
||||
MediaLabels.videoCodec(video.codec),
|
||||
video.bitDepth?.takeIf { it > 8 }?.let { "$it бит" },
|
||||
if (video.width != null && video.height != null) "${video.width}×${video.height}" else null,
|
||||
range(video.range, video.dvProfile),
|
||||
).joinToString(" · ")
|
||||
add(InfoRow("Видео", format, AppColors.Blue))
|
||||
video.frameRate?.takeIf { it > 0 }?.let { add(InfoRow("Частота", String.format(Locale.US, "%.3f к/с", it).replace(".000", ""), AppColors.Blue)) }
|
||||
}
|
||||
media?.bitrate?.takeIf { it > 0 }?.let {
|
||||
val cap = state.maxBitrate?.let { c -> " → до " + mbps(c) }.orEmpty()
|
||||
add(InfoRow("Битрейт", String.format(Locale.US, "%.1f Мбит/с", it / 1e6) + cap, AppColors.Green))
|
||||
}
|
||||
media?.audio?.firstOrNull { it.index == state.selection.audioStreamIndex }?.let {
|
||||
add(InfoRow("Аудио", listOfNotNull(MediaLabels.audioDetail(it), MediaLabels.audioLabel(it)).joinToString(" · "), AppColors.Pink))
|
||||
}
|
||||
val subtitle = media?.subtitles?.firstOrNull { it.index == state.selection.subtitleStreamIndex }
|
||||
add(InfoRow("Субтитры", subtitle?.let { listOfNotNull(MediaLabels.subtitleLabel(it), MediaLabels.subtitleDetail(it)).joinToString(" · ") } ?: "выкл", AppColors.Yellow))
|
||||
add(InfoRow("Движок", engine(state, engine, video?.range == VideoRange.DolbyVision), AppColors.Green))
|
||||
state.stats.videoDecoder?.let { add(InfoRow("Декодер", it, AppColors.Blue)) }
|
||||
state.stats.audioOutput?.let { add(InfoRow("Звук", it, AppColors.Pink)) }
|
||||
if (state.bufferedMs > state.positionMs) add(InfoRow("Буфер", "${(state.bufferedMs - state.positionMs) / 1000} с", AppColors.Green))
|
||||
add(InfoRow("Пропущено", "${state.stats.droppedFrames} кадров", AppColors.Blue))
|
||||
}
|
||||
|
||||
/** "20 Мбит/с", "1.5 Мбит/с" */
|
||||
fun mbps(bitrate: Int): String {
|
||||
val m = bitrate / 1e6
|
||||
return if (m >= 10) "${m.toInt()} Мбит/с" else String.format(Locale.US, "%.1f Мбит/с", m)
|
||||
}
|
||||
|
||||
/** "1:24:25" */
|
||||
fun time(ms: Long): String {
|
||||
val total = (ms.coerceAtLeast(0) + 500) / 1000
|
||||
return if (total >= 3600) "%d:%02d:%02d".format(total / 3600, total / 60 % 60, total % 60) else "%d:%02d".format(total / 60, total % 60)
|
||||
}
|
||||
|
||||
private fun channels(n: Int?) = when (n) {
|
||||
null, 0 -> null
|
||||
1 -> "1.0"
|
||||
2 -> "2.0"
|
||||
6 -> "5.1"
|
||||
8 -> "7.1"
|
||||
else -> "${n}ch"
|
||||
}
|
||||
|
||||
private fun range(range: VideoRange, dvProfile: Int?) = when (range) {
|
||||
VideoRange.SDR -> null
|
||||
VideoRange.HDR10 -> "HDR10"
|
||||
VideoRange.HDR10Plus -> "HDR10+"
|
||||
VideoRange.HLG -> "HLG"
|
||||
VideoRange.DolbyVision -> dvProfile?.let { "DV P$it" } ?: "DV"
|
||||
VideoRange.Other -> "HDR"
|
||||
}
|
||||
|
||||
/** Jellyfin's TranscodeReason names in plain Russian; unknown ones are dropped. */
|
||||
private fun reason(name: String): String? = when (name) {
|
||||
"ContainerNotSupported" -> "контейнер не поддерживается"
|
||||
"VideoCodecNotSupported", "VideoCodecTagNotSupported" -> "видеокодек не поддерживается"
|
||||
"AudioCodecNotSupported" -> "аудиокодек не поддерживается"
|
||||
"SubtitleCodecNotSupported" -> "субтитры нужно вшить"
|
||||
"AudioIsExternal" -> "внешняя аудиодорожка"
|
||||
"SecondaryAudioNotSupported" -> "вторая аудиодорожка"
|
||||
"VideoProfileNotSupported" -> "профиль видео"
|
||||
"VideoLevelNotSupported" -> "уровень видео"
|
||||
"VideoResolutionNotSupported" -> "разрешение видео"
|
||||
"VideoBitDepthNotSupported" -> "глубина цвета"
|
||||
"VideoFramerateNotSupported" -> "частота кадров"
|
||||
"RefFramesNotSupported" -> "опорные кадры"
|
||||
"AnamorphicVideoNotSupported" -> "анаморфное видео"
|
||||
"InterlacedVideoNotSupported" -> "чересстрочное видео"
|
||||
"AudioChannelsNotSupported" -> "число каналов звука"
|
||||
"AudioProfileNotSupported" -> "профиль звука"
|
||||
"AudioSampleRateNotSupported" -> "частота дискретизации"
|
||||
"AudioBitDepthNotSupported" -> "разрядность звука"
|
||||
"ContainerBitrateExceedsLimit", "VideoBitrateNotSupported", "AudioBitrateNotSupported" -> "ограничение битрейта"
|
||||
"VideoRangeTypeNotSupported" -> "HDR не поддерживается"
|
||||
"DirectPlayError" -> "ошибка прямого воспроизведения"
|
||||
"UnknownVideoStreamInfo", "UnknownAudioStreamInfo" -> "неизвестный формат потока"
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package ru.stenfredd.sleepyowl.ui.player
|
||||
|
||||
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.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
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.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.draw.drawBehind
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.em
|
||||
import androidx.compose.ui.unit.sp
|
||||
import ru.stenfredd.sleepyowl.ui.components.AppIcon
|
||||
import ru.stenfredd.sleepyowl.ui.components.FocusStyle
|
||||
import ru.stenfredd.sleepyowl.ui.components.Icon
|
||||
import ru.stenfredd.sleepyowl.ui.components.RemoteImage
|
||||
import ru.stenfredd.sleepyowl.ui.components.focusCard
|
||||
import ru.stenfredd.sleepyowl.ui.theme.AppColors
|
||||
import ru.stenfredd.sleepyowl.ui.theme.AppType
|
||||
|
||||
/** Without known credits the card shows this long before the end of an episode that has a next one. */
|
||||
internal const val NEXT_PROMPT_MS = 30_000L
|
||||
|
||||
private val CardColor = Color(0xFF0E1018)
|
||||
private val CardBorder = Color(0xFF6F63FF)
|
||||
private val PromptFocus = FocusStyle(scale = 1f, corner = 17.dp, glows = FocusStyle.ButtonGlows, animate = false)
|
||||
|
||||
/**
|
||||
* "Следующая через N с" at the end of an episode: still and title of the next one, "Смотреть сейчас" and
|
||||
* "Досмотреть титры". Opaque (over video a translucent card costs a blend every frame); the countdown and the bar
|
||||
* move once a second, as the position does. [remainingMs] of [countdownMs] are left until the next episode starts.
|
||||
*/
|
||||
@Composable
|
||||
internal fun NextEpisodeCard(
|
||||
next: NextEpisode,
|
||||
remainingMs: Long,
|
||||
countdownMs: Long,
|
||||
playNow: FocusRequester,
|
||||
onPlay: () -> Unit,
|
||||
onStay: () -> Unit,
|
||||
onFocus: (Boolean) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val shape = RoundedCornerShape(10.dp)
|
||||
val seconds = ((remainingMs + 999) / 1000).coerceAtLeast(0)
|
||||
val fraction = (1f - remainingMs.toFloat() / countdownMs).coerceIn(0f, 1f)
|
||||
Column(
|
||||
modifier
|
||||
.width(320.dp)
|
||||
.background(CardColor, shape)
|
||||
.border(0.5.dp, CardBorder, shape)
|
||||
.onFocusChanged { onFocus(it.hasFocus) },
|
||||
) {
|
||||
Row(Modifier.padding(11.dp), horizontalArrangement = Arrangement.spacedBy(11.dp)) {
|
||||
RemoteImage(next.image, 6.dp, Modifier.size(PlayerLayout.NEXT_W.dp, PlayerLayout.NEXT_H.dp))
|
||||
Column(Modifier.weight(1f)) {
|
||||
BasicText(
|
||||
"СЛЕДУЮЩАЯ ЧЕРЕЗ $seconds С",
|
||||
style = AppType.MonoLabel.copy(fontSize = 9.sp, letterSpacing = 0.08.em),
|
||||
)
|
||||
BasicText(
|
||||
next.title,
|
||||
Modifier.padding(top = 5.dp),
|
||||
style = AppType.Section.copy(fontSize = 13.sp, lineHeight = 16.sp),
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
Row(Modifier.padding(start = 11.dp, end = 11.dp, bottom = 11.dp), horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
PromptButton("Смотреть сейчас", primary = true, onClick = onPlay, modifier = Modifier.weight(1f).focusRequester(playNow))
|
||||
PromptButton("Досмотреть титры", primary = false, onClick = onStay)
|
||||
}
|
||||
// Countdown bar along the bottom edge, inside the rounded corners.
|
||||
Spacer(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(2.5.dp)
|
||||
.padding(horizontal = 10.dp)
|
||||
.drawBehind {
|
||||
drawRect(AppColors.ProgressTrack)
|
||||
drawRect(AppColors.ProgressGradient, Offset.Zero, Size(size.width * fraction, size.height))
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PromptButton(label: String, primary: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier) {
|
||||
var focused by remember { mutableStateOf(false) }
|
||||
val shape = RoundedCornerShape(17.dp)
|
||||
Row(
|
||||
modifier
|
||||
.focusCard(PromptFocus)
|
||||
.height(34.dp)
|
||||
.then(
|
||||
when {
|
||||
primary -> Modifier.background(AppColors.PrimaryGradient, shape)
|
||||
focused -> Modifier.background(AppColors.FocusFill, shape)
|
||||
else -> Modifier.background(OsdColors.Fill, shape).border(0.5.dp, OsdColors.Border, shape)
|
||||
},
|
||||
)
|
||||
.then(if (primary && focused) Modifier.border(1.5.dp, Color(0xFFD9D2FF), shape) else Modifier)
|
||||
.onFocusChanged { focused = it.isFocused }
|
||||
.clickable(interactionSource = null, indication = null, onClick = onClick)
|
||||
.padding(horizontal = 14.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp, Alignment.CenterHorizontally),
|
||||
) {
|
||||
val color = if (!primary && focused) AppColors.OnFocusFill else Color.White
|
||||
if (primary) Icon(AppIcon.Play, color, Modifier.size(11.dp))
|
||||
BasicText(label, style = AppType.Button.copy(fontSize = 12.sp, fontWeight = if (primary) FontWeight.SemiBold else FontWeight.Normal, color = color))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
package ru.stenfredd.sleepyowl.ui.player
|
||||
|
||||
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.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.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.draw.drawWithCache
|
||||
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.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.em
|
||||
import androidx.compose.ui.unit.sp
|
||||
import ru.stenfredd.sleepyowl.core.data.model.MediaSegment
|
||||
import ru.stenfredd.sleepyowl.ui.components.AppIcon
|
||||
import kotlin.math.roundToInt
|
||||
import ru.stenfredd.sleepyowl.ui.components.FocusStyle
|
||||
import ru.stenfredd.sleepyowl.ui.components.Icon
|
||||
import ru.stenfredd.sleepyowl.ui.components.StatusDot
|
||||
import ru.stenfredd.sleepyowl.ui.components.focusCard
|
||||
import ru.stenfredd.sleepyowl.ui.shell.Clock
|
||||
import ru.stenfredd.sleepyowl.ui.theme.AppColors
|
||||
import ru.stenfredd.sleepyowl.ui.theme.AppType
|
||||
|
||||
/*
|
||||
* The player's controls ("OSD") as drawn in the mockup's player screen; sizes are the mockup's px / 2.
|
||||
* Everything here is drawn over the video, which on TVs sits on its own hardware plane: the UI layer is only
|
||||
* redrawn when something in it changes (a second of the clock, a focus move), so the shades cost nothing while
|
||||
* the picture plays. Through the GPU path (SDR on the Xiaomi TV) every video frame redraws them, see APP_PLAN.md.
|
||||
*/
|
||||
|
||||
internal object OsdColors {
|
||||
val Fill = Color(0x14FFFFFF)
|
||||
val Border = Color(0x24FFFFFF)
|
||||
val Text = Color(0xFFE3E5F2)
|
||||
/** `linear-gradient(180deg, rgba(5,6,10,.85), transparent)` */
|
||||
val TopShade = listOf(0f to Color(0xD905060A), 1f to Color(0x0005060A))
|
||||
/** `linear-gradient(0deg, rgba(5,6,10,.97), rgba(5,6,10,.6) 60%, transparent)` */
|
||||
val BottomShade = listOf(0f to Color(0x0005060A), 0.4f to Color(0x9905060A), 1f to Color(0xF705060A))
|
||||
}
|
||||
|
||||
/**
|
||||
* A vertical shade drawn as thin bands of plain colour. On the test TV a real gradient over the video costs ~8 ms of
|
||||
* GPU per frame (HWUI dithers gradients) and a stretched gradient bitmap ~6 ms; plain fills cost about half that.
|
||||
* With bands a few px tall the steps are below what the eye sees over moving video.
|
||||
*/
|
||||
internal fun Modifier.verticalShade(stops: List<Pair<Float, Color>>): Modifier = drawWithCache {
|
||||
val bands = (size.height / SHADE_BAND_DP.dp.toPx()).toInt().coerceIn(2, MAX_SHADE_BANDS)
|
||||
// Whole-pixel edges: a rectangle at a fractional position is drawn antialiased, by a heavier shader.
|
||||
val edges = List(bands + 1) { i -> (size.height * i / bands).roundToInt().toFloat() }
|
||||
val colors = List(bands) { i -> colorAt(stops, (i + 0.5f) / bands) }
|
||||
val width = size.width.roundToInt().toFloat()
|
||||
onDrawBehind {
|
||||
for (i in 0 until bands) {
|
||||
if (colors[i].alpha > 0.004f) drawRect(colors[i], Offset(0f, edges[i]), Size(width, edges[i + 1] - edges[i]))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun colorAt(stops: List<Pair<Float, Color>>, t: Float): Color {
|
||||
val after = stops.indexOfFirst { it.first >= t }.let { if (it < 0) stops.lastIndex else it }
|
||||
if (after == 0) return stops[0].second
|
||||
val (t0, c0) = stops[after - 1]
|
||||
val (t1, c1) = stops[after]
|
||||
return androidx.compose.ui.graphics.lerp(c0, c1, ((t - t0) / (t1 - t0)).coerceIn(0f, 1f))
|
||||
}
|
||||
|
||||
private const val SHADE_BAND_DP = 4f
|
||||
private const val MAX_SHADE_BANDS = 64
|
||||
|
||||
private val ButtonFocus = FocusStyle(scale = 1f, corner = 19.dp, glows = FocusStyle.ButtonGlows, animate = false)
|
||||
|
||||
/** `0 0 0 3px #d9d2ff, 0 0 36px 8px rgba(124,92,255,.7)`, scale 1.06 */
|
||||
private val PlayFocus = FocusStyle(
|
||||
scale = 1.06f,
|
||||
corner = 23.dp,
|
||||
ringColor = Color(0xFFD9D2FF),
|
||||
glows = listOf(FocusStyle.Glow(18.dp, 4.dp, Color(0xB37C5CFF))),
|
||||
animate = false,
|
||||
)
|
||||
|
||||
/** Label, title and episode on the left; clock and "закончится в" on the right. */
|
||||
@Composable
|
||||
internal fun OsdTop(label: String, title: String, subtitle: String?, endsAt: String?, modifier: Modifier = Modifier) {
|
||||
Row(modifier.fillMaxWidth(), verticalAlignment = Alignment.Top) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
BasicText(label, style = AppType.MonoLabel)
|
||||
BasicText(
|
||||
title,
|
||||
Modifier.padding(top = 5.dp),
|
||||
style = AppType.Hero.copy(fontSize = 24.sp, fontWeight = FontWeight.Bold, letterSpacing = 0.em),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
subtitle?.let {
|
||||
BasicText(it, Modifier.padding(top = 4.dp), style = AppType.BodyLarge.copy(color = AppColors.TextSecondary), maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
Column(Modifier.padding(start = 24.dp), horizontalAlignment = Alignment.End) {
|
||||
Clock(style = AppType.Clock.copy(fontSize = 17.sp, color = AppColors.Text))
|
||||
endsAt?.let { BasicText(it, Modifier.padding(top = 3.dp), style = AppType.MonoCaption.copy(fontSize = 10.sp)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Track with the buffered part, skippable parts (intro, credits), the played part and chapter marks; thicker with a
|
||||
* knob when focused. Drawn as plain rectangles in one draw call: no layers, no paths.
|
||||
*/
|
||||
@Composable
|
||||
internal fun Timeline(
|
||||
positionMs: Long,
|
||||
durationMs: Long,
|
||||
bufferedMs: Long,
|
||||
chaptersMs: List<Long>,
|
||||
spans: List<MediaSegment>,
|
||||
focused: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Spacer(
|
||||
modifier
|
||||
.fillMaxWidth()
|
||||
.height(26.dp)
|
||||
.drawBehind {
|
||||
val trackH = (if (focused) 7.dp else 3.dp).toPx()
|
||||
val top = (size.height - trackH) / 2
|
||||
val r = CornerRadius(trackH / 2)
|
||||
val w = size.width
|
||||
fun x(ms: Long) = if (durationMs > 0) (ms.toFloat() / durationMs).coerceIn(0f, 1f) * w else 0f
|
||||
drawRoundRect(Color(0x29FFFFFF), Offset(0f, top), Size(w, trackH), r)
|
||||
if (bufferedMs > positionMs) drawRoundRect(Color(0x2EFFFFFF), Offset(0f, top), Size(x(bufferedMs), trackH), r)
|
||||
for (span in spans) {
|
||||
val from = x(span.startMs)
|
||||
drawRect(SpanColor, Offset(from, top), Size(x(span.endMs) - from, trackH))
|
||||
}
|
||||
val played = x(positionMs)
|
||||
if (played > 0f) {
|
||||
drawRoundRect(
|
||||
Brush.horizontalGradient(listOf(Color(0xFF4D8BFF), Color(0xFF9B6BFF)), endX = played.coerceAtLeast(1f)),
|
||||
Offset(0f, top),
|
||||
Size(played, trackH),
|
||||
r,
|
||||
)
|
||||
}
|
||||
val mark = 1.5.dp.toPx()
|
||||
for (c in chaptersMs) {
|
||||
if (c >= durationMs) continue
|
||||
drawRect(Color(0xD90A0B10), Offset(x(c) - mark / 2, top), Size(mark, trackH))
|
||||
}
|
||||
if (focused) {
|
||||
val center = Offset(played, size.height / 2)
|
||||
drawCircle(Color(0x80A594FF), 9.5.dp.toPx(), center)
|
||||
drawCircle(Color.White, 7.5.dp.toPx(), center)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private val SpanColor = Color(0x598F6BFF)
|
||||
|
||||
/** ⟲ ⟳ ⏮ ⏭: translucent circle, light fill when focused. */
|
||||
@Composable
|
||||
internal fun OsdCircleButton(icon: AppIcon, size: Dp, iconSize: Dp, onClick: () -> Unit, modifier: Modifier = Modifier) {
|
||||
var focused by remember { mutableStateOf(false) }
|
||||
Box(
|
||||
modifier
|
||||
.focusCard(ButtonFocus.copy(corner = size / 2))
|
||||
.size(size)
|
||||
.background(if (focused) AppColors.FocusFill else OsdColors.Fill, CircleShape)
|
||||
.border(0.5.dp, if (focused) AppColors.FocusFill else OsdColors.Border, CircleShape)
|
||||
.onFocusChanged { focused = it.isFocused }
|
||||
.clickable(interactionSource = null, indication = null, onClick = onClick),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(icon, if (focused) AppColors.OnFocusFill else Color.White, Modifier.size(iconSize), strokeWidth = 2.2f)
|
||||
}
|
||||
}
|
||||
|
||||
/** The big gradient ⏯ in the middle of the controls. */
|
||||
@Composable
|
||||
internal fun OsdPlayButton(playing: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier) {
|
||||
Box(
|
||||
modifier
|
||||
.focusCard(PlayFocus)
|
||||
.size(46.dp)
|
||||
.background(AppColors.PrimaryGradient, CircleShape)
|
||||
.clickable(interactionSource = null, indication = null, onClick = onClick),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
if (playing) Icon(AppIcon.Pause, Color.White, Modifier.size(19.dp)) else Icon(AppIcon.Play, Color.White, Modifier.size(20.dp))
|
||||
}
|
||||
}
|
||||
|
||||
@Immutable
|
||||
internal data class OsdChipModel(val key: String, val dot: Color, val label: String)
|
||||
|
||||
/** "● Русский 7.1": opens a panel. */
|
||||
@Composable
|
||||
internal fun OsdChip(chip: OsdChipModel, onClick: () -> Unit, modifier: Modifier = Modifier) {
|
||||
var focused by remember { mutableStateOf(false) }
|
||||
val shape = RoundedCornerShape(17.dp)
|
||||
Row(
|
||||
modifier
|
||||
.focusCard(ButtonFocus.copy(corner = 17.dp))
|
||||
.height(34.dp)
|
||||
.background(if (focused) AppColors.FocusFill else OsdColors.Fill, shape)
|
||||
.border(0.5.dp, if (focused) AppColors.FocusFill else OsdColors.Border, shape)
|
||||
.onFocusChanged { focused = it.isFocused }
|
||||
.clickable(interactionSource = null, indication = null, onClick = onClick)
|
||||
.padding(horizontal = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
Box(Modifier.size(6.dp).background(chip.dot, CircleShape))
|
||||
BasicText(
|
||||
chip.label,
|
||||
Modifier.widthIn(max = 170.dp),
|
||||
style = AppType.Button.copy(fontSize = 11.5.sp, fontWeight = FontWeight.Normal, color = if (focused) AppColors.OnFocusFill else OsdColors.Text),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** "⌄ О фильме" under the controls: ↓ opens the drawer. */
|
||||
@Composable
|
||||
internal fun PeekHint(label: String, modifier: Modifier = Modifier) {
|
||||
Row(modifier, verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(5.dp)) {
|
||||
Icon(AppIcon.ChevronDown, AppColors.TextDim, Modifier.size(9.dp), strokeWidth = 2.4f)
|
||||
BasicText(label, style = AppType.MonoCaption.copy(fontSize = 9.5.sp, letterSpacing = 0.06.em))
|
||||
}
|
||||
}
|
||||
|
||||
/** Shown in the middle while paused with the controls hidden. */
|
||||
@Composable
|
||||
internal fun PausedBadge(modifier: Modifier = Modifier) {
|
||||
Box(
|
||||
modifier
|
||||
.size(75.dp)
|
||||
.background(Color(0x8C0A0B10), CircleShape)
|
||||
.border(0.5.dp, Color(0x33FFFFFF), CircleShape),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(AppIcon.Pause, Color.White, Modifier.size(28.dp))
|
||||
}
|
||||
}
|
||||
|
||||
/** Until the first frame. The dot pulses in steps: a smooth animation would redraw the whole screen every frame. */
|
||||
@Composable
|
||||
internal fun LoadingBadge(modifier: Modifier = Modifier) {
|
||||
Row(
|
||||
modifier
|
||||
.background(Color(0xCC0A0B10), RoundedCornerShape(16.dp))
|
||||
.padding(horizontal = 14.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
StatusDot(AppColors.Accent, pulsing = true)
|
||||
BasicText("ЗАГРУЗКА", style = AppType.MonoLabel.copy(color = AppColors.TextSecondary))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun ErrorCard(message: String, modifier: Modifier = Modifier, actions: @Composable () -> Unit) {
|
||||
Column(
|
||||
modifier
|
||||
.width(420.dp)
|
||||
.background(AppColors.Surface, RoundedCornerShape(12.dp))
|
||||
.border(0.5.dp, AppColors.Border, RoundedCornerShape(12.dp))
|
||||
.padding(20.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
BasicText("Не удалось воспроизвести", style = AppType.Section.copy(fontWeight = FontWeight.Bold))
|
||||
BasicText(message, style = AppType.Body, maxLines = 6, overflow = TextOverflow.Ellipsis)
|
||||
Row(Modifier.padding(top = 6.dp), horizontalArrangement = Arrangement.spacedBy(10.dp)) { actions() }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
package ru.stenfredd.sleepyowl.ui.player
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.focusGroup
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
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.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.BasicText
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Immutable
|
||||
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.runtime.withFrameNanos
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.drawBehind
|
||||
import androidx.compose.ui.focus.FocusDirection
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusProperties
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Shadow
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.em
|
||||
import androidx.compose.ui.unit.sp
|
||||
import ru.stenfredd.sleepyowl.playback.api.SubtitleStyle
|
||||
import ru.stenfredd.sleepyowl.ui.components.FocusStyle
|
||||
import ru.stenfredd.sleepyowl.ui.components.MinimalScrolling
|
||||
import ru.stenfredd.sleepyowl.ui.components.SecondaryButton
|
||||
import ru.stenfredd.sleepyowl.ui.components.focusCard
|
||||
import ru.stenfredd.sleepyowl.ui.details.TrackOption
|
||||
import ru.stenfredd.sleepyowl.ui.theme.AppColors
|
||||
import ru.stenfredd.sleepyowl.ui.theme.AppType
|
||||
|
||||
enum class PlayerPanel { Audio, Subtitles, Quality, Info }
|
||||
|
||||
/** One choice in a panel list or chip row; [onPick] is applied by the screen. */
|
||||
@Immutable
|
||||
internal data class PanelChoice(val key: String, val label: String, val detail: String?, val selected: Boolean, val swatch: Color? = null)
|
||||
|
||||
@Immutable
|
||||
internal data class PanelGroup(val key: String, val title: String, val choices: List<PanelChoice>)
|
||||
|
||||
private object PanelColors {
|
||||
val Background = Color(0xFF10121A)
|
||||
val Header = Color(0xFF161925)
|
||||
val HeaderLine = Color(0xFF232637)
|
||||
val Edge = Color(0xFF262A3A)
|
||||
val Option = Color(0xFF151824)
|
||||
val OptionBorder = Color(0xFF232637)
|
||||
val Selected = Color(0xFF1B1D38)
|
||||
val SelectedBorder = Color(0xFF5F55E6)
|
||||
val Focused = Color(0xFF1F2238)
|
||||
val Ring = Color(0xFF4A4F68)
|
||||
val SectionTitle = Color(0xFF6F7592)
|
||||
}
|
||||
|
||||
/** `border-color: #a594ff; box-shadow: 0 0 0 1px #a594ff, 0 0 30px rgba(124,92,255,.45)` */
|
||||
private val OptionFocus = FocusStyle(scale = 1f, corner = 7.dp, ring = 0.5.dp, glows = listOf(FocusStyle.Glow(15.dp, 0.dp, Color(0x737C5CFF))), animate = false)
|
||||
private val ChipFocus = FocusStyle(scale = 1f, corner = 15.dp, glows = FocusStyle.ButtonGlows, animate = false)
|
||||
|
||||
/**
|
||||
* Settings panel on the right, full height, opaque (a translucent full-screen scrim is expensive on weak TV GPUs).
|
||||
* Focus stays inside: ←/→ at the edge don't leak to the controls behind. [initial] gets focus when it opens.
|
||||
*/
|
||||
@Composable
|
||||
internal fun SidePanel(
|
||||
title: String,
|
||||
dot: Color,
|
||||
hint: String,
|
||||
initial: FocusRequester,
|
||||
modifier: Modifier = Modifier,
|
||||
content: @Composable ColumnScope.() -> Unit,
|
||||
) {
|
||||
LaunchedEffect(Unit) {
|
||||
withFrameNanos { }
|
||||
runCatching { initial.requestFocus() }
|
||||
}
|
||||
Column(
|
||||
modifier
|
||||
.fillMaxHeight()
|
||||
.width(350.dp)
|
||||
.background(PanelColors.Background)
|
||||
.drawBehind { drawRect(PanelColors.Edge, size = androidx.compose.ui.geometry.Size(0.5.dp.toPx(), size.height)) }
|
||||
.focusProperties {
|
||||
onExit = {
|
||||
if (requestedFocusDirection in TRAPPED) cancelFocusChange()
|
||||
}
|
||||
}
|
||||
.focusGroup(),
|
||||
) {
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(36.dp)
|
||||
.background(PanelColors.Header)
|
||||
.drawBehind {
|
||||
val line = 0.5.dp.toPx()
|
||||
drawRect(PanelColors.HeaderLine, Offset(0f, size.height - line), androidx.compose.ui.geometry.Size(size.width, line))
|
||||
}
|
||||
.padding(horizontal = 16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(7.dp),
|
||||
) {
|
||||
Box(Modifier.size(7.dp).background(dot, CircleShape))
|
||||
BasicText(title, Modifier.weight(1f), style = AppType.MonoLabel.copy(color = AppColors.TextSecondary, letterSpacing = 0.1.em))
|
||||
BasicText(hint, style = AppType.MonoCaption.copy(color = PanelColors.SectionTitle))
|
||||
}
|
||||
MinimalScrolling(margin = 14.dp) {
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(15.dp),
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val TRAPPED = setOf(FocusDirection.Left, FocusDirection.Right, FocusDirection.Up, FocusDirection.Down)
|
||||
|
||||
@Composable
|
||||
internal fun PanelSection(title: String, content: @Composable ColumnScope.() -> Unit) {
|
||||
Column {
|
||||
BasicText(title, style = AppType.MonoCaption.copy(color = PanelColors.SectionTitle, letterSpacing = 0.1.em))
|
||||
Column(Modifier.padding(top = 7.dp), verticalArrangement = Arrangement.spacedBy(4.dp), content = content)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun PanelNote(text: String) {
|
||||
BasicText(text, style = AppType.Body.copy(fontSize = 10.5.sp, lineHeight = 15.sp, color = AppColors.TextDim))
|
||||
}
|
||||
|
||||
/** List option with a radio mark: "◉ Русский TrueHD Atmos · 7.1". */
|
||||
@Composable
|
||||
internal fun PanelOption(choice: PanelChoice, onClick: () -> Unit, modifier: Modifier = Modifier) {
|
||||
var focused by remember { mutableStateOf(false) }
|
||||
val shape = RoundedCornerShape(7.dp)
|
||||
Row(
|
||||
modifier
|
||||
.focusCard(OptionFocus)
|
||||
.fillMaxWidth()
|
||||
.height(42.dp)
|
||||
.background(if (focused) PanelColors.Focused else if (choice.selected) PanelColors.Selected else PanelColors.Option, shape)
|
||||
.border(0.5.dp, if (focused) AppColors.Accent else if (choice.selected) PanelColors.SelectedBorder else PanelColors.OptionBorder, shape)
|
||||
.onFocusChanged { focused = it.isFocused }
|
||||
.clickable(interactionSource = null, indication = null, onClick = onClick)
|
||||
.padding(horizontal = 11.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Box(
|
||||
Modifier.size(13.dp).border(1.dp, if (choice.selected) AppColors.Accent else PanelColors.Ring, CircleShape),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
if (choice.selected) Box(Modifier.size(6.dp).background(AppColors.Accent, CircleShape))
|
||||
}
|
||||
BasicText(
|
||||
choice.label,
|
||||
Modifier.weight(1f),
|
||||
style = AppType.BodyLarge.copy(fontWeight = FontWeight.Medium, color = AppColors.Text),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
choice.detail?.let { BasicText(it, style = AppType.MonoCaption.copy(fontSize = 10.sp), maxLines = 1) }
|
||||
}
|
||||
}
|
||||
|
||||
/** Small choice in a row: size "M", colour "● Жёлтый", background "Тень". */
|
||||
@Composable
|
||||
internal fun PanelChip(choice: PanelChoice, onClick: () -> Unit, modifier: Modifier = Modifier) {
|
||||
var focused by remember { mutableStateOf(false) }
|
||||
val shape = RoundedCornerShape(15.dp)
|
||||
Row(
|
||||
modifier
|
||||
.focusCard(ChipFocus)
|
||||
.height(30.dp)
|
||||
.background(if (focused) AppColors.FocusFill else if (choice.selected) PanelColors.Selected else PanelColors.Option, shape)
|
||||
.border(0.5.dp, if (focused) AppColors.FocusFill else if (choice.selected) PanelColors.SelectedBorder else PanelColors.OptionBorder, shape)
|
||||
.onFocusChanged { focused = it.isFocused }
|
||||
.clickable(interactionSource = null, indication = null, onClick = onClick)
|
||||
.padding(horizontal = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(5.dp),
|
||||
) {
|
||||
choice.swatch?.let { Box(Modifier.size(9.dp).background(it, CircleShape).border(0.5.dp, Color(0x4D000000), CircleShape)) }
|
||||
BasicText(choice.label, style = AppType.Button.copy(fontSize = 11.5.sp, fontWeight = FontWeight.Normal, color = if (focused) AppColors.OnFocusFill else OsdColors.Text))
|
||||
}
|
||||
}
|
||||
|
||||
/** A list of [choices]; the selected one (or the first) takes [initial] focus. */
|
||||
@Composable
|
||||
internal fun PanelList(choices: List<PanelChoice>, initial: FocusRequester?, onPick: (PanelChoice) -> Unit) {
|
||||
val target = choices.firstOrNull { it.selected } ?: choices.firstOrNull()
|
||||
for (c in choices) {
|
||||
PanelOption(c, onClick = { onPick(c) }, Modifier.then(if (c === target && initial != null) Modifier.focusRequester(initial) else Modifier))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun PanelChips(choices: List<PanelChoice>, onPick: (PanelChoice) -> Unit) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(5.dp)) {
|
||||
for (c in choices) PanelChip(c, onClick = { onPick(c) })
|
||||
}
|
||||
}
|
||||
|
||||
/** How text subtitles will look: sample line over a dark picture-like box. */
|
||||
@Composable
|
||||
internal fun SubtitlePreview(style: SubtitleStyle, on: Boolean) {
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(85.dp)
|
||||
.background(Brush.linearGradient(listOf(Color(0xFF2B4A66), Color(0xFF16233A), Color(0xFF241B36))), RoundedCornerShape(7.dp))
|
||||
.border(0.5.dp, PanelColors.Edge, RoundedCornerShape(7.dp))
|
||||
.padding(bottom = 11.dp),
|
||||
contentAlignment = Alignment.BottomCenter,
|
||||
) {
|
||||
val box = style.background == SubtitleStyle.Background.BOX
|
||||
BasicText(
|
||||
if (on) "— Ты слышишь? Сигнал снова идёт со дна." else "Субтитры выключены",
|
||||
Modifier
|
||||
.then(if (box) Modifier.background(Color(0xB8000000), RoundedCornerShape(3.dp)) else Modifier)
|
||||
.padding(horizontal = 7.dp, vertical = 2.dp),
|
||||
style = AppType.Body.copy(
|
||||
fontSize = 13.sp * style.size.scale,
|
||||
lineHeight = 16.sp * style.size.scale,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = Color(style.color),
|
||||
textAlign = TextAlign.Center,
|
||||
shadow = if (style.background == SubtitleStyle.Background.SHADOW) Shadow(Color.Black, Offset(0f, 2f), 6f) else null,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** "Прямое воспроизведение / Файл воспроизводится без изменений" in a green (or orange) frame. */
|
||||
@Composable
|
||||
internal fun StreamModeCard(mode: StreamMode) {
|
||||
val color = if (mode.direct) AppColors.Green else AppColors.Orange
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.border(0.5.dp, color, RoundedCornerShape(7.dp))
|
||||
.padding(horizontal = 11.dp, vertical = 10.dp),
|
||||
) {
|
||||
BasicText(mode.title, style = AppType.Section.copy(fontWeight = FontWeight.Bold, color = color))
|
||||
BasicText(mode.reason, Modifier.padding(top = 4.dp), style = AppType.Body.copy(color = AppColors.TextMuted))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun InfoRows(rows: List<InfoRow>) {
|
||||
Column {
|
||||
for (r in rows) {
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.drawBehind {
|
||||
val line = 0.5.dp.toPx()
|
||||
drawRect(AppColors.BorderSubtle, Offset(0f, size.height - line), androidx.compose.ui.geometry.Size(size.width, line))
|
||||
}
|
||||
.padding(vertical = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Box(Modifier.size(5.dp).background(r.color, CircleShape))
|
||||
BasicText(r.key, Modifier.padding(start = 7.dp).width(75.dp), style = AppType.MonoMeta.copy(fontSize = 10.sp, color = PanelColors.SectionTitle))
|
||||
BasicText(r.value, Modifier.weight(1f), style = AppType.MonoMeta.copy(fontSize = 10.sp, color = OsdColors.Text), maxLines = 2, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun PanelCloseButton(onClick: () -> Unit, modifier: Modifier = Modifier) {
|
||||
SecondaryButton("Закрыть", onClick = onClick, modifier = modifier)
|
||||
}
|
||||
|
||||
/** Size, colour and background choices of text subtitles, for the player's panel and the series tray. */
|
||||
internal object SubtitleChoices {
|
||||
private val colors = listOf(SubtitleStyle.WHITE to "Белый", SubtitleStyle.YELLOW to "Жёлтый", SubtitleStyle.LIGHT_BLUE to "Голубой")
|
||||
private val backgrounds = listOf(SubtitleStyle.Background.NONE to "Нет", SubtitleStyle.Background.SHADOW to "Тень", SubtitleStyle.Background.BOX to "Плашка")
|
||||
|
||||
fun sizes(style: SubtitleStyle) = SubtitleStyle.Size.entries.map { PanelChoice("size/$it", it.name, null, it == style.size) }
|
||||
|
||||
fun colors(style: SubtitleStyle) = colors.map { (argb, name) -> PanelChoice("color/$argb", name, null, argb == style.color, Color(argb)) }
|
||||
|
||||
fun backgrounds(style: SubtitleStyle) = backgrounds.map { (bg, name) -> PanelChoice("bg/$bg", name, null, bg == style.background) }
|
||||
|
||||
/** [style] with [choice] (one of the lists above) applied. */
|
||||
fun apply(style: SubtitleStyle, choice: PanelChoice): SubtitleStyle {
|
||||
val value = choice.key.substringAfter('/')
|
||||
return when (choice.key.substringBefore('/')) {
|
||||
"size" -> style.copy(size = SubtitleStyle.Size.valueOf(value))
|
||||
"color" -> style.copy(color = value.toInt())
|
||||
"bg" -> style.copy(background = SubtitleStyle.Background.valueOf(value))
|
||||
else -> style
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal fun List<TrackOption>.toChoices(selected: Int?): List<PanelChoice> =
|
||||
map { PanelChoice(it.key, it.label, it.detail, it.index == selected) }
|
||||
@@ -0,0 +1,828 @@
|
||||
package ru.stenfredd.sleepyowl.ui.player
|
||||
|
||||
import android.view.KeyEvent as AndroidKeyEvent
|
||||
import android.widget.FrameLayout
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.focusable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.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.text.BasicText
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.withFrameNanos
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusDirection
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusProperties
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.key.KeyEvent
|
||||
import androidx.compose.ui.input.key.KeyEventType
|
||||
import androidx.compose.ui.input.key.key
|
||||
import androidx.compose.ui.input.key.nativeKeyCode
|
||||
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.input.key.type
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.compose.LifecycleEventEffect
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import kotlinx.coroutines.delay
|
||||
import ru.stenfredd.sleepyowl.BuildConfig
|
||||
import ru.stenfredd.sleepyowl.core.data.model.MediaInfo
|
||||
import ru.stenfredd.sleepyowl.core.data.model.MediaSegment
|
||||
import ru.stenfredd.sleepyowl.core.data.model.VideoRange
|
||||
import ru.stenfredd.sleepyowl.core.data.toMediaInfo
|
||||
import ru.stenfredd.sleepyowl.playback.api.DolbyVisionSwitchable
|
||||
import ru.stenfredd.sleepyowl.playback.api.EngineId
|
||||
import ru.stenfredd.sleepyowl.playback.api.EngineState
|
||||
import ru.stenfredd.sleepyowl.playback.api.SubtitleStyle
|
||||
import ru.stenfredd.sleepyowl.playback.api.VideoPathSwitchable
|
||||
import ru.stenfredd.sleepyowl.playback.core.PlaybackSession
|
||||
import ru.stenfredd.sleepyowl.playback.core.PlaybackUiState
|
||||
import ru.stenfredd.sleepyowl.playback.exo.ExoEngineProvider
|
||||
import ru.stenfredd.sleepyowl.ui.Screen
|
||||
import ru.stenfredd.sleepyowl.ui.components.AppIcon
|
||||
import ru.stenfredd.sleepyowl.ui.components.SecondaryButton
|
||||
import ru.stenfredd.sleepyowl.ui.theme.AppColors
|
||||
import ru.stenfredd.sleepyowl.ui.theme.AppType
|
||||
import java.time.LocalTime
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* The player: video with the controls of the mockup over it.
|
||||
*
|
||||
* Remote: with the controls hidden ←/→ seek by 10 s (held: 30 s, then 60 s) and show the timeline, OK pauses,
|
||||
* ↓ opens the drawer ("О фильме" / episodes), ↑ shows the controls. Seeks are collected while the keys are pressed
|
||||
* and sent to the engine once they stop. The controls hide after 6 s of no keys while playing.
|
||||
* Back: panel → drawer → controls → leave the player.
|
||||
* Segments from the server (intro, credits …) get "Пропустить …" or are skipped on their own, as set in the settings;
|
||||
* known credits bring the "next episode" card at their start.
|
||||
*/
|
||||
@Composable
|
||||
fun PlayerScreen(
|
||||
screen: Screen.Player,
|
||||
onExit: () -> Unit,
|
||||
onOpenDetails: (UUID) -> Unit = {},
|
||||
onOpenSeries: (seriesId: UUID, episodeId: UUID) -> Unit = { _, _ -> },
|
||||
tour: TourInfo? = null,
|
||||
vm: PlayerViewModel = hiltViewModel(),
|
||||
) {
|
||||
LaunchedEffect(screen) { vm.start(screen) }
|
||||
DisposableEffect(vm) { onDispose { vm.release() } }
|
||||
val session by vm.session.collectAsStateWithLifecycle()
|
||||
Box(Modifier.fillMaxSize().background(Color.Black)) {
|
||||
VideoSurface(session)
|
||||
session?.let { PlayerUi(it, vm, onExit, onOpenDetails, onOpenSeries, tour) }
|
||||
}
|
||||
}
|
||||
|
||||
/** Engines put their own views (surface, subtitles) into this container. */
|
||||
@Composable
|
||||
private fun VideoSurface(session: PlaybackSession?) {
|
||||
var container by remember { mutableStateOf<FrameLayout?>(null) }
|
||||
AndroidView(factory = { ctx -> FrameLayout(ctx).also { container = it } }, modifier = Modifier.fillMaxSize())
|
||||
DisposableEffect(session, container) {
|
||||
val c = container
|
||||
if (session != null && c != null) session.attachVideo(c)
|
||||
onDispose { session?.detachVideo() }
|
||||
}
|
||||
}
|
||||
|
||||
@Stable
|
||||
private class PlayerUiState {
|
||||
var osd by mutableStateOf(true)
|
||||
var drawer by mutableStateOf(false)
|
||||
var panel by mutableStateOf<PlayerPanel?>(null)
|
||||
/** Bumped by every key: restarts the hide timer. */
|
||||
var activity by mutableIntStateOf(0)
|
||||
var timelineFocused by mutableStateOf(false)
|
||||
/** Where the user is seeking to; sent to the engine once the keys stop. */
|
||||
var scrubTarget by mutableStateOf<Long?>(null)
|
||||
var scrubVersion by mutableIntStateOf(0)
|
||||
/** Bumped when a panel action changes engine state the panel shows (video path): re-reads it. */
|
||||
var panelRevision by mutableIntStateOf(0)
|
||||
var debugOverlay by mutableIntStateOf(0)
|
||||
var debugMenu by mutableStateOf(false)
|
||||
var tourStep = 0
|
||||
/** Back went down while the player had focus; it acts on release. */
|
||||
var backPressed = false
|
||||
/** "Досмотреть титры" was chosen for this item: no "next episode" card for it any more. */
|
||||
var nextDismissedFor by mutableStateOf<UUID?>(null)
|
||||
/** The "next episode" card has focus: arrows and OK go to its buttons. */
|
||||
var promptFocused = false
|
||||
/** Where the "next episode" card appeared over known credits: its countdown runs from there. */
|
||||
var nextFrom by mutableStateOf<Long?>(null)
|
||||
/** Segments skipped in this playback (by the button or on their own): not offered again. */
|
||||
var skipped by mutableStateOf(SegmentSet())
|
||||
/** Segments the user seeked into: they went there on purpose, so these are not skipped on their own. */
|
||||
var seekedInto by mutableStateOf(SegmentSet())
|
||||
/** Segments whose button timed out over the bare video: it shows only with the controls. */
|
||||
var skipHidden by mutableStateOf(SegmentSet())
|
||||
/** The skip button has focus: OK goes to it. */
|
||||
var skipFocused = false
|
||||
/** Something in the player has focus (a disappearing button can leave focus nowhere). */
|
||||
var hasFocus = false
|
||||
/** "Заставка пропущена" after an automatic skip. */
|
||||
var notice by mutableStateOf<String?>(null)
|
||||
var noticeVersion by mutableIntStateOf(0)
|
||||
|
||||
val root = FocusRequester()
|
||||
val playPause = FocusRequester()
|
||||
val timeline = FocusRequester()
|
||||
val panelInitial = FocusRequester()
|
||||
val drawerInitial = FocusRequester()
|
||||
val errorButton = FocusRequester()
|
||||
val nextPlay = FocusRequester()
|
||||
val skipButton = FocusRequester()
|
||||
val debugMenuFocus = FocusRequester()
|
||||
val chips = PlayerPanel.entries.associateWith { FocusRequester() }
|
||||
var panelFrom: FocusRequester? = null
|
||||
|
||||
var focusTarget: FocusRequester? = null
|
||||
private set
|
||||
var focusVersion by mutableIntStateOf(0)
|
||||
private set
|
||||
|
||||
/** Focuses [target] once it is composed (next frame). */
|
||||
fun focus(target: FocusRequester) {
|
||||
focusTarget = target
|
||||
focusVersion++
|
||||
}
|
||||
|
||||
fun poke() {
|
||||
activity++
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PlayerUi(
|
||||
session: PlaybackSession,
|
||||
vm: PlayerViewModel,
|
||||
onExit: () -> Unit,
|
||||
onOpenDetails: (UUID) -> Unit,
|
||||
onOpenSeries: (UUID, UUID) -> Unit,
|
||||
tour: TourInfo?,
|
||||
) {
|
||||
val state by session.state.collectAsStateWithLifecycle()
|
||||
val info by vm.info.collectAsStateWithLifecycle()
|
||||
val season by vm.season.collectAsStateWithLifecycle()
|
||||
val style by vm.subtitleStyle.collectAsStateWithLifecycle()
|
||||
val segments by vm.segments.collectAsStateWithLifecycle()
|
||||
val defaults by vm.defaults.collectAsStateWithLifecycle()
|
||||
val ui = remember { PlayerUiState() }
|
||||
val focusManager = LocalFocusManager.current
|
||||
val media = remember(state.source) { state.source?.toMediaInfo() }
|
||||
val tracks = remember(media) { PlayerTracks.of(media) }
|
||||
val playing = state.engineState.playWhenReady
|
||||
val ended = state.engineState.phase == EngineState.Phase.ENDED
|
||||
val shownPosition = ui.scrubTarget ?: state.positionMs
|
||||
val remainingMs = state.durationMs - state.positionMs
|
||||
val itemId = state.itemId
|
||||
val nextEpisode = season?.takeIf { it.currentId == itemId && info?.isEpisode == true }?.nextEpisode
|
||||
val plan = remember(segments, itemId, state.durationMs, defaults, nextEpisode != null) {
|
||||
segments?.takeIf { it.itemId == itemId }?.let { SkipPlan.of(it.segments, state.durationMs, defaults, hasNext = nextEpisode != null) }
|
||||
}
|
||||
val credits = plan?.credits
|
||||
val overVideo = !ended && state.error == null && ui.panel == null && !ui.drawer
|
||||
val nextDismissed = ui.nextDismissedFor == itemId
|
||||
// Known credits with "Сразу": the next episode starts with them, unless the user seeked into them.
|
||||
val creditsAuto = credits != null && plan?.creditsAuto == true && credits !in ui.seekedInto.of(itemId) && !nextDismissed
|
||||
// "Следующая через N с" when the credits start (or near the end), unless dismissed or something is open over the video.
|
||||
val nextWindow = if (credits != null) shownPosition >= credits.startMs else remainingMs in 0..NEXT_PROMPT_MS
|
||||
val next = nextEpisode?.takeIf { state.durationMs > 0 && remainingMs >= 0 && nextWindow && overVideo && !nextDismissed && !creditsAuto }
|
||||
// Its countdown: to the end of the file, or over known credits from where it appeared.
|
||||
val nextCountdownMs = if (credits != null) CREDITS_COUNTDOWN_MS else NEXT_PROMPT_MS
|
||||
val nextLeftMs = if (credits != null) minOf((ui.nextFrom ?: state.positionMs) + CREDITS_COUNTDOWN_MS - state.positionMs, remainingMs) else remainingMs
|
||||
// "Пропустить заставку" while in a skippable part. A part skipped on its own shows the button only when seeked into.
|
||||
val skip = plan?.skipAt(shownPosition)?.takeIf { s ->
|
||||
overVideo && !state.loading && next == null && s.segment !in ui.skipped.of(itemId) &&
|
||||
(!s.auto || s.segment in ui.seekedInto.of(itemId)) && (ui.osd || s.segment !in ui.skipHidden.of(itemId))
|
||||
}
|
||||
val autoSkip = plan?.skipAt(state.positionMs)?.takeIf { s ->
|
||||
s.auto && ui.scrubTarget == null && !state.loading && state.error == null && !ended &&
|
||||
s.segment !in ui.skipped.of(itemId) && s.segment !in ui.seekedInto.of(itemId)
|
||||
}
|
||||
|
||||
fun hideOsd() {
|
||||
runCatching { ui.root.requestFocus() }
|
||||
ui.osd = false
|
||||
ui.drawer = false
|
||||
if (next != null) ui.focus(ui.nextPlay)
|
||||
}
|
||||
|
||||
fun skipPart(part: Skippable, auto: Boolean) {
|
||||
ui.skipped = ui.skipped.plus(itemId, part.segment)
|
||||
session.seekTo(part.segment.endMs)
|
||||
if (auto) {
|
||||
ui.notice = SkipLabels.skipped(part.segment.kind)
|
||||
ui.noticeVersion++
|
||||
}
|
||||
}
|
||||
|
||||
fun showOsd(focus: FocusRequester) {
|
||||
ui.osd = true
|
||||
ui.focus(focus)
|
||||
}
|
||||
|
||||
fun scrub(direction: Int, repeat: Int) {
|
||||
val step = when {
|
||||
repeat < 8 -> 10_000L
|
||||
repeat < 24 -> 30_000L
|
||||
else -> 60_000L
|
||||
}
|
||||
val end = (state.durationMs - 1_000).coerceAtLeast(0)
|
||||
ui.scrubTarget = ((ui.scrubTarget ?: state.positionMs) + direction * step).coerceIn(0, end)
|
||||
ui.scrubVersion++
|
||||
}
|
||||
|
||||
fun openDrawer() {
|
||||
if (info == null) return
|
||||
ui.osd = true
|
||||
ui.drawer = true
|
||||
}
|
||||
|
||||
fun closeDrawer() {
|
||||
ui.drawer = false
|
||||
ui.focus(ui.playPause)
|
||||
}
|
||||
|
||||
fun openPanel(panel: PlayerPanel) {
|
||||
ui.panelFrom = ui.chips[panel]
|
||||
ui.panel = panel
|
||||
}
|
||||
|
||||
fun closePanel() {
|
||||
ui.panel = null
|
||||
ui.panelFrom?.let(ui::focus)
|
||||
}
|
||||
|
||||
fun back() {
|
||||
ui.poke()
|
||||
when {
|
||||
ui.debugMenu -> {
|
||||
ui.debugMenu = false
|
||||
ui.focus(ui.root)
|
||||
}
|
||||
ui.panel != null -> closePanel()
|
||||
ui.drawer -> closeDrawer()
|
||||
ui.osd && state.error == null -> hideOsd()
|
||||
next != null -> {
|
||||
ui.nextDismissedFor = state.itemId
|
||||
ui.focus(ui.root)
|
||||
}
|
||||
skip != null -> ui.skipHidden = ui.skipHidden.plus(itemId, skip.segment)
|
||||
else -> onExit()
|
||||
}
|
||||
}
|
||||
|
||||
fun onKey(e: KeyEvent): Boolean {
|
||||
val code = e.key.nativeKeyCode
|
||||
// Handled here, not only by BackHandler: an unconsumed Back first just clears Compose focus.
|
||||
// Acted on when released (as Android does): leaving on the press would send the release to the next screen.
|
||||
if (code == AndroidKeyEvent.KEYCODE_BACK || code == AndroidKeyEvent.KEYCODE_ESCAPE) {
|
||||
when (e.type) {
|
||||
KeyEventType.KeyDown -> if (e.nativeKeyEvent.repeatCount == 0) ui.backPressed = true
|
||||
KeyEventType.KeyUp -> if (ui.backPressed) {
|
||||
ui.backPressed = false
|
||||
back()
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
if (e.type != KeyEventType.KeyDown) return false
|
||||
ui.poke()
|
||||
val repeat = e.nativeKeyEvent.repeatCount
|
||||
when (code) {
|
||||
AndroidKeyEvent.KEYCODE_MEDIA_PLAY_PAUSE -> session.togglePlayPause()
|
||||
AndroidKeyEvent.KEYCODE_MEDIA_PLAY -> if (!playing) session.togglePlayPause()
|
||||
AndroidKeyEvent.KEYCODE_MEDIA_PAUSE -> session.pause()
|
||||
AndroidKeyEvent.KEYCODE_MEDIA_FAST_FORWARD -> scrub(1, repeat).also { if (!ui.osd) showOsd(ui.timeline) }
|
||||
AndroidKeyEvent.KEYCODE_MEDIA_REWIND -> scrub(-1, repeat).also { if (!ui.osd) showOsd(ui.timeline) }
|
||||
AndroidKeyEvent.KEYCODE_MEDIA_NEXT -> season?.next?.let(vm::playEpisode)
|
||||
AndroidKeyEvent.KEYCODE_MEDIA_PREVIOUS -> season?.previous?.let(vm::playEpisode)
|
||||
// ↑ from the topmost control (the timeline) hides the controls; a held key stops there instead of showing them again.
|
||||
AndroidKeyEvent.KEYCODE_DPAD_UP if ui.osd && !ui.drawer && ui.panel == null && !ui.debugMenu && state.error == null -> {
|
||||
if (!focusManager.moveFocus(FocusDirection.Up) && repeat == 0) hideOsd()
|
||||
}
|
||||
AndroidKeyEvent.KEYCODE_DPAD_UP if !ui.osd && repeat > 0 && tour == null && next == null -> Unit
|
||||
else -> return onNavigationKey(code, repeat, ui, session, tour, ::scrub, ::showOsd, ::openDrawer, promptShown = next != null, skipShown = skip != null)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) { ui.focus(ui.playPause) }
|
||||
LaunchedEffect(ui.focusVersion) {
|
||||
val target = ui.focusTarget ?: return@LaunchedEffect
|
||||
withFrameNanos { }
|
||||
runCatching { target.requestFocus() }
|
||||
}
|
||||
// Hide the controls after a while of no keys, only while playing and nothing is open on top.
|
||||
LaunchedEffect(ui.activity, ui.osd, ui.drawer, ui.panel, playing, state.loading, state.error) {
|
||||
if (!ui.osd || ui.drawer || ui.panel != null || ui.debugMenu || !playing || state.loading || state.error != null) return@LaunchedEffect
|
||||
delay(OSD_TIMEOUT_MS)
|
||||
hideOsd()
|
||||
}
|
||||
LaunchedEffect(ui.scrubVersion) {
|
||||
val target = ui.scrubTarget ?: return@LaunchedEffect
|
||||
delay(SCRUB_COMMIT_MS)
|
||||
plan?.spanAt(target)?.let { ui.seekedInto = ui.seekedInto.plus(state.itemId, it) }
|
||||
session.seekTo(target)
|
||||
// Keep showing the target until the engine reports the new position.
|
||||
delay(SCRUB_SETTLE_MS)
|
||||
ui.scrubTarget = null
|
||||
}
|
||||
// Text subtitles move just above the controls while they are shown (fractions of the 540 dp screen, on top of
|
||||
// the subtitle view's own 8 % bottom padding).
|
||||
LaunchedEffect(session, ui.osd, ui.drawer) {
|
||||
session.setSubtitleLift(
|
||||
when {
|
||||
ui.drawer -> 0.58f
|
||||
ui.osd -> 0.22f
|
||||
else -> 0f
|
||||
},
|
||||
)
|
||||
}
|
||||
LaunchedEffect(state.error) {
|
||||
if (state.error != null) showOsd(ui.errorButton)
|
||||
}
|
||||
LaunchedEffect(ended) {
|
||||
if (!ended) return@LaunchedEffect
|
||||
// The end of an episode plays the next one (the card counted down to it, or was dismissed); else leave.
|
||||
season?.next?.let(vm::playEpisode) ?: onExit()
|
||||
}
|
||||
// Another episode started (⏭, media keys) with the drawer open: fold it, its focus belonged to the old list.
|
||||
LaunchedEffect(state.itemId) {
|
||||
if (ui.drawer) closeDrawer()
|
||||
}
|
||||
LaunchedEffect(autoSkip) {
|
||||
autoSkip?.let { skipPart(it, auto = true) }
|
||||
}
|
||||
val creditsGo = creditsAuto && nextEpisode != null && credits != null && state.positionMs >= credits.startMs &&
|
||||
ui.scrubTarget == null && overVideo && !state.loading
|
||||
val nextCountedDown = next != null && credits != null && nextLeftMs <= 0
|
||||
LaunchedEffect(creditsGo, nextCountedDown) {
|
||||
if (creditsGo || nextCountedDown) nextEpisode?.let { vm.playEpisode(it.id) }
|
||||
}
|
||||
LaunchedEffect(ui.noticeVersion) {
|
||||
if (ui.notice == null) return@LaunchedEffect
|
||||
delay(SKIP_NOTICE_MS)
|
||||
ui.notice = null
|
||||
}
|
||||
// Over the bare video the skip button takes focus and goes after a while (counted without the controls shown).
|
||||
LaunchedEffect(skip?.segment, ui.osd) {
|
||||
val part = skip?.segment ?: return@LaunchedEffect
|
||||
if (ui.osd) return@LaunchedEffect
|
||||
ui.focus(ui.skipButton)
|
||||
delay(SKIP_PROMPT_MS)
|
||||
ui.skipHidden = ui.skipHidden.plus(itemId, part)
|
||||
}
|
||||
val skipShown = skip != null
|
||||
LaunchedEffect(skipShown) {
|
||||
if (skipShown) return@LaunchedEffect
|
||||
withFrameNanos { }
|
||||
if (ui.skipFocused || !ui.hasFocus) {
|
||||
ui.skipFocused = false
|
||||
if (next == null) ui.focus(if (ui.osd) ui.playPause else ui.root)
|
||||
}
|
||||
}
|
||||
// The card takes focus when it appears over the bare video; with the controls shown they keep it.
|
||||
val nextShown = next != null
|
||||
LaunchedEffect(nextShown) {
|
||||
if (nextShown && !ui.osd) ui.focus(ui.nextPlay)
|
||||
if (!nextShown) ui.promptFocused = false
|
||||
ui.nextFrom = if (nextShown) state.positionMs else null
|
||||
}
|
||||
LifecycleEventEffect(Lifecycle.Event.ON_STOP) { session.pauseInBackground() }
|
||||
|
||||
// Back while nothing in the player has focus (the key handler above doesn't see it then).
|
||||
BackHandler { back() }
|
||||
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.focusRequester(ui.root)
|
||||
.onPreviewKeyEvent(::onKey)
|
||||
.onFocusChanged { ui.hasFocus = it.hasFocus }
|
||||
.focusable(),
|
||||
) {
|
||||
if (!ui.osd && !playing && !state.loading && state.error == null && !ended) PausedBadge(Modifier.align(Alignment.Center))
|
||||
if (ui.osd) {
|
||||
OsdLayer(
|
||||
state = state,
|
||||
info = info,
|
||||
season = season,
|
||||
media = media,
|
||||
ui = ui,
|
||||
positionMs = shownPosition,
|
||||
spans = plan?.spans.orEmpty(),
|
||||
onToggle = session::togglePlayPause,
|
||||
onSeek = { scrub(it, 0) },
|
||||
onEpisode = vm::playEpisode,
|
||||
onPanel = ::openPanel,
|
||||
onDrawer = ::openDrawer,
|
||||
)
|
||||
}
|
||||
if (ui.drawer) {
|
||||
PlayerDrawer(
|
||||
info = info,
|
||||
season = season,
|
||||
currentId = state.itemId,
|
||||
playedFraction = if (state.durationMs > 0) shownPosition.toFloat() / state.durationMs else 0f,
|
||||
initial = ui.drawerInitial,
|
||||
onClose = ::closeDrawer,
|
||||
onPage = {
|
||||
val id = state.itemId
|
||||
val seriesId = info?.seriesId
|
||||
when {
|
||||
id == null -> Unit
|
||||
seriesId != null -> onOpenSeries(seriesId, id)
|
||||
else -> onOpenDetails(id)
|
||||
}
|
||||
},
|
||||
onEpisode = { id ->
|
||||
if (id == state.itemId) {
|
||||
closeDrawer()
|
||||
} else {
|
||||
hideOsd()
|
||||
vm.playEpisode(id)
|
||||
}
|
||||
},
|
||||
modifier = Modifier.align(Alignment.BottomCenter),
|
||||
)
|
||||
}
|
||||
// Prompts sit in the corner, above the controls while they are shown.
|
||||
val promptBottom = if (ui.osd) PROMPT_OVER_OSD.dp else 32.dp
|
||||
skip?.let { part ->
|
||||
SkipButton(
|
||||
label = SkipLabels.button(part.segment.kind),
|
||||
left = PlayerLabels.time(part.segment.endMs - shownPosition),
|
||||
focusRequester = ui.skipButton,
|
||||
onClick = {
|
||||
skipPart(part, auto = false)
|
||||
ui.focus(if (ui.osd) ui.playPause else ui.root)
|
||||
},
|
||||
onFocus = { ui.skipFocused = it },
|
||||
modifier = Modifier.align(Alignment.BottomEnd).padding(end = 48.dp, bottom = promptBottom),
|
||||
)
|
||||
}
|
||||
ui.notice?.takeIf { skip == null && next == null }?.let { text ->
|
||||
SkipNotice(text, Modifier.align(Alignment.BottomEnd).padding(end = 48.dp, bottom = promptBottom))
|
||||
}
|
||||
next?.let { n ->
|
||||
NextEpisodeCard(
|
||||
n,
|
||||
remainingMs = nextLeftMs,
|
||||
countdownMs = nextCountdownMs,
|
||||
playNow = ui.nextPlay,
|
||||
onPlay = {
|
||||
hideOsd()
|
||||
ui.focus(ui.root)
|
||||
vm.playEpisode(n.id)
|
||||
},
|
||||
onStay = {
|
||||
ui.nextDismissedFor = state.itemId
|
||||
ui.focus(if (ui.osd) ui.playPause else ui.root)
|
||||
},
|
||||
onFocus = { ui.promptFocused = it },
|
||||
modifier = Modifier.align(Alignment.BottomEnd).padding(end = 48.dp, bottom = promptBottom),
|
||||
)
|
||||
}
|
||||
if (state.loading && state.error == null) LoadingBadge(Modifier.align(Alignment.Center))
|
||||
state.error?.let { error ->
|
||||
ErrorCard(error.lineSequence().first(), Modifier.align(Alignment.Center)) {
|
||||
SecondaryButton("Назад", onClick = onExit, modifier = Modifier.focusRequester(ui.errorButton))
|
||||
}
|
||||
}
|
||||
ui.panel?.let { panel ->
|
||||
PanelLayer(panel, state, info, media, tracks, style, session, vm, ui, ::closePanel, Modifier.align(Alignment.CenterEnd))
|
||||
}
|
||||
if (BuildConfig.DEBUG) {
|
||||
if (ui.debugOverlay == 1) DebugPanel(state, vm, Modifier.align(Alignment.TopStart))
|
||||
if (ui.debugOverlay < 2 && !ui.osd) VariantBadge(state, session, tour, Modifier.align(Alignment.TopEnd).padding(24.dp))
|
||||
if (ui.debugMenu) {
|
||||
DebugMenu(state, session, vm, Modifier.align(Alignment.CenterEnd).focusRequester(ui.debugMenuFocus)) {
|
||||
ui.debugMenu = false
|
||||
ui.focus(ui.root)
|
||||
}
|
||||
LaunchedEffect(Unit) {
|
||||
withFrameNanos { }
|
||||
runCatching { ui.debugMenuFocus.requestFocus() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Keys not handled everywhere: arrows and OK while the controls are hidden, seeking on the timeline, debug keys. */
|
||||
private fun onNavigationKey(
|
||||
code: Int,
|
||||
repeat: Int,
|
||||
ui: PlayerUiState,
|
||||
session: PlaybackSession,
|
||||
tour: TourInfo?,
|
||||
scrub: (Int, Int) -> Unit,
|
||||
showOsd: (FocusRequester) -> Unit,
|
||||
openDrawer: () -> Unit,
|
||||
promptShown: Boolean,
|
||||
skipShown: Boolean,
|
||||
): Boolean {
|
||||
if (BuildConfig.DEBUG && !ui.debugMenu) {
|
||||
when (code) {
|
||||
AndroidKeyEvent.KEYCODE_INFO -> {
|
||||
ui.debugOverlay = (ui.debugOverlay + 1) % 3
|
||||
return true
|
||||
}
|
||||
AndroidKeyEvent.KEYCODE_MENU -> {
|
||||
ui.debugMenu = true
|
||||
return true
|
||||
}
|
||||
in AndroidKeyEvent.KEYCODE_0..AndroidKeyEvent.KEYCODE_9 -> return applyVariant(code - AndroidKeyEvent.KEYCODE_0, session)
|
||||
}
|
||||
}
|
||||
if (ui.panel != null || ui.drawer || ui.debugMenu) return false
|
||||
// The "next episode" card over the bare video: ←/→ and OK work its buttons.
|
||||
if (!ui.osd && promptShown && ui.promptFocused && code in PROMPT_KEYS) return false
|
||||
// The skip button over the bare video: OK presses it, the arrows work as usual.
|
||||
if (!ui.osd && skipShown && ui.skipFocused && code in OK_KEYS) return false
|
||||
if (!ui.osd) {
|
||||
when (code) {
|
||||
AndroidKeyEvent.KEYCODE_DPAD_UP if tour != null -> {
|
||||
ui.tourStep = (ui.tourStep + 1) % TOUR_VARIANTS.size
|
||||
applyVariant(TOUR_VARIANTS[ui.tourStep], session)
|
||||
}
|
||||
AndroidKeyEvent.KEYCODE_DPAD_DOWN if tour != null -> tour.onNextFile()
|
||||
AndroidKeyEvent.KEYCODE_DPAD_LEFT -> {
|
||||
scrub(-1, repeat)
|
||||
showOsd(ui.timeline)
|
||||
}
|
||||
AndroidKeyEvent.KEYCODE_DPAD_RIGHT -> {
|
||||
scrub(1, repeat)
|
||||
showOsd(ui.timeline)
|
||||
}
|
||||
AndroidKeyEvent.KEYCODE_DPAD_CENTER, AndroidKeyEvent.KEYCODE_ENTER, AndroidKeyEvent.KEYCODE_NUMPAD_ENTER -> {
|
||||
session.togglePlayPause()
|
||||
showOsd(ui.playPause)
|
||||
}
|
||||
AndroidKeyEvent.KEYCODE_DPAD_DOWN -> openDrawer()
|
||||
AndroidKeyEvent.KEYCODE_DPAD_UP -> showOsd(ui.playPause)
|
||||
else -> return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
if (ui.timelineFocused) {
|
||||
when (code) {
|
||||
AndroidKeyEvent.KEYCODE_DPAD_LEFT -> scrub(-1, repeat)
|
||||
AndroidKeyEvent.KEYCODE_DPAD_RIGHT -> scrub(1, repeat)
|
||||
AndroidKeyEvent.KEYCODE_DPAD_CENTER, AndroidKeyEvent.KEYCODE_ENTER -> session.togglePlayPause()
|
||||
else -> return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun OsdLayer(
|
||||
state: PlaybackUiState,
|
||||
info: PlayerInfo?,
|
||||
season: SeasonEpisodes?,
|
||||
media: MediaInfo?,
|
||||
ui: PlayerUiState,
|
||||
positionMs: Long,
|
||||
spans: List<MediaSegment>,
|
||||
onToggle: () -> Unit,
|
||||
onSeek: (Int) -> Unit,
|
||||
onEpisode: (UUID) -> Unit,
|
||||
onPanel: (PlayerPanel) -> Unit,
|
||||
onDrawer: () -> Unit,
|
||||
) {
|
||||
val drawer = ui.drawer
|
||||
val episodes = season?.takeIf { info?.isEpisode == true }
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
// The shades are most of the cost of a frame (~11 ms at the mockup's 130 + 230 dp on the test TV): only as tall
|
||||
// as the text over them needs.
|
||||
Box(Modifier.fillMaxWidth().height(TOP_SHADE.dp).verticalShade(OsdColors.TopShade))
|
||||
Box(Modifier.align(Alignment.BottomStart).fillMaxWidth().height((if (drawer) DRAWER_HEIGHT + BOTTOM_SHADE else BOTTOM_SHADE).dp).verticalShade(OsdColors.BottomShade))
|
||||
val remaining = (state.durationMs - positionMs).coerceAtLeast(0)
|
||||
OsdTop(
|
||||
label = info?.label ?: "",
|
||||
title = info?.title ?: state.title,
|
||||
subtitle = info?.subtitle,
|
||||
endsAt = if (state.durationMs > 0) "закончится в " + LocalTime.now().plusSeconds(remaining / 1000).format(EndsFormat) else null,
|
||||
modifier = Modifier.padding(start = 48.dp, end = 48.dp, top = 32.dp),
|
||||
)
|
||||
Column(
|
||||
Modifier
|
||||
.align(Alignment.BottomStart)
|
||||
.fillMaxWidth()
|
||||
.padding(start = 48.dp, end = 48.dp, bottom = if (drawer) (DRAWER_HEIGHT + 16).dp else 32.dp),
|
||||
) {
|
||||
Row(Modifier.fillMaxWidth()) {
|
||||
BasicText(PlayerLabels.time(positionMs), style = AppType.MonoMeta.copy(fontSize = 12.sp))
|
||||
Spacer(Modifier.weight(1f))
|
||||
if (state.durationMs > 0) BasicText("−" + PlayerLabels.time(remaining), style = AppType.MonoMeta.copy(fontSize = 12.sp))
|
||||
}
|
||||
Timeline(
|
||||
positionMs = positionMs,
|
||||
durationMs = state.durationMs,
|
||||
bufferedMs = state.bufferedMs,
|
||||
chaptersMs = info?.chaptersMs.orEmpty(),
|
||||
spans = spans,
|
||||
focused = ui.timelineFocused,
|
||||
modifier = Modifier
|
||||
.padding(top = 3.dp)
|
||||
.focusRequester(ui.timeline)
|
||||
.focusProperties { down = ui.playPause }
|
||||
.onFocusChanged { ui.timelineFocused = it.isFocused }
|
||||
.focusable(),
|
||||
)
|
||||
Row(
|
||||
Modifier
|
||||
.padding(top = 12.dp)
|
||||
.fillMaxWidth()
|
||||
.onPreviewKeyEvent {
|
||||
if (it.type == KeyEventType.KeyDown && it.key.nativeKeyCode == AndroidKeyEvent.KEYCODE_DPAD_DOWN && !drawer) {
|
||||
onDrawer()
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
},
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
episodes?.previous?.let { id -> OsdCircleButton(AppIcon.Previous, 34.dp, 14.dp, onClick = { onEpisode(id) }) }
|
||||
OsdCircleButton(AppIcon.Replay, 38.dp, 17.dp, onClick = { onSeek(-1) })
|
||||
OsdPlayButton(state.engineState.playWhenReady, onClick = onToggle, modifier = Modifier.focusRequester(ui.playPause))
|
||||
OsdCircleButton(AppIcon.Forward, 38.dp, 17.dp, onClick = { onSeek(1) })
|
||||
episodes?.next?.let { id -> OsdCircleButton(AppIcon.Next, 34.dp, 14.dp, onClick = { onEpisode(id) }) }
|
||||
Spacer(Modifier.weight(1f))
|
||||
for ((panel, chip) in chips(state, info, media)) {
|
||||
OsdChip(chip, onClick = { onPanel(panel) }, modifier = Modifier.focusRequester(ui.chips.getValue(panel)))
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!drawer && info != null) {
|
||||
PeekHint(
|
||||
if (episodes != null) "Серии · сезон ${info.seasonNumber ?: ""}".trimEnd() else "О фильме",
|
||||
Modifier.align(Alignment.BottomCenter).padding(bottom = 8.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val EndsFormat = DateTimeFormatter.ofPattern("HH:mm")
|
||||
|
||||
/** Audio, subtitles, quality, Direct/Транскод: only the ones that have something to choose. */
|
||||
private fun chips(state: PlaybackUiState, info: PlayerInfo?, media: MediaInfo?): List<Pair<PlayerPanel, OsdChipModel>> = buildList {
|
||||
val selection = state.selection
|
||||
if ((media?.audio?.size ?: 0) > 0) add(PlayerPanel.Audio to OsdChipModel("audio", AppColors.Pink, PlayerLabels.audioChip(media, selection.audioStreamIndex)))
|
||||
if ((media?.subtitles?.size ?: 0) > 0) {
|
||||
add(PlayerPanel.Subtitles to OsdChipModel("subs", AppColors.Yellow, PlayerLabels.subtitleChip(media, selection.subtitleStreamIndex)))
|
||||
}
|
||||
info?.qualities?.takeIf { it.size > 1 }?.let { options ->
|
||||
val label = options.firstOrNull { it.bitrate == state.maxBitrate }?.label ?: "Оригинал"
|
||||
add(PlayerPanel.Quality to OsdChipModel("quality", AppColors.Blue, label))
|
||||
}
|
||||
val direct = PlayerLabels.isDirect(state)
|
||||
add(PlayerPanel.Info to OsdChipModel("info", if (direct) AppColors.Green else AppColors.Orange, if (direct) "Direct" else "Транскод"))
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PanelLayer(
|
||||
panel: PlayerPanel,
|
||||
state: PlaybackUiState,
|
||||
info: PlayerInfo?,
|
||||
media: MediaInfo?,
|
||||
tracks: PlayerTracks,
|
||||
style: SubtitleStyle,
|
||||
session: PlaybackSession,
|
||||
vm: PlayerViewModel,
|
||||
ui: PlayerUiState,
|
||||
close: () -> Unit,
|
||||
modifier: Modifier,
|
||||
) {
|
||||
val selection = state.selection
|
||||
when (panel) {
|
||||
PlayerPanel.Audio -> SidePanel("АУДИОДОРОЖКА", AppColors.Pink, "Назад — закрыть", ui.panelInitial, modifier) {
|
||||
PanelSection("ДОРОЖКИ" + (info?.title?.let { " · " + it.uppercase() } ?: "")) {
|
||||
PanelList(tracks.audio.toChoices(selection.audioStreamIndex), ui.panelInitial) { c ->
|
||||
tracks.audio.firstOrNull { it.key == c.key }?.index?.let(session::selectAudio)
|
||||
close()
|
||||
}
|
||||
}
|
||||
PanelNote(if (info?.isEpisode == true) "Выбор запоминается для всего сериала." else "Выбор запоминается для этого фильма.")
|
||||
}
|
||||
PlayerPanel.Subtitles -> SidePanel("СУБТИТРЫ", AppColors.Yellow, "Назад — готово", ui.panelInitial, modifier) {
|
||||
val selected = media?.subtitles?.firstOrNull { it.index == selection.subtitleStreamIndex }
|
||||
SubtitlePreview(style, selected != null)
|
||||
PanelSection("ДОРОЖКА") {
|
||||
PanelList(tracks.subtitles.toChoices(selection.subtitleStreamIndex), ui.panelInitial) { c ->
|
||||
session.selectSubtitle(tracks.subtitles.firstOrNull { it.key == c.key }?.index)
|
||||
}
|
||||
}
|
||||
PanelSection("РАЗМЕР") { PanelChips(SubtitleChoices.sizes(style)) { vm.setSubtitleStyle(SubtitleChoices.apply(style, it)) } }
|
||||
PanelSection("ЦВЕТ") { PanelChips(SubtitleChoices.colors(style)) { vm.setSubtitleStyle(SubtitleChoices.apply(style, it)) } }
|
||||
PanelSection("ФОН") { PanelChips(SubtitleChoices.backgrounds(style)) { vm.setSubtitleStyle(SubtitleChoices.apply(style, it)) } }
|
||||
val codec = selected?.codec?.lowercase()
|
||||
when {
|
||||
codec in GRAPHIC_SUBTITLES -> PanelNote("Это графические субтитры (картинки): они показываются как есть, размер и цвет к ним не применяются.")
|
||||
codec == "ass" || codec == "ssa" -> PanelNote("У субтитров ASS своё оформление из файла: размер и цвет здесь меняют только текстовые субтитры (SRT, VTT).")
|
||||
else -> PanelNote("Вид применяется к текстовым субтитрам (SRT, VTT) и запоминается на этом ТВ.")
|
||||
}
|
||||
}
|
||||
PlayerPanel.Quality -> SidePanel("КАЧЕСТВО", AppColors.Blue, "Назад — закрыть", ui.panelInitial, modifier) {
|
||||
val options = info?.qualities.orEmpty()
|
||||
PanelSection("БИТРЕЙТ") {
|
||||
PanelList(options.map { PanelChoice(it.key, it.label, it.detail, it.bitrate == state.maxBitrate) }, ui.panelInitial) { c ->
|
||||
vm.setQuality(options.firstOrNull { it.key == c.key }?.bitrate)
|
||||
close()
|
||||
}
|
||||
}
|
||||
PanelNote("Всё, что ниже оригинала, сервер перекодирует на лету. Это нагружает процессор сервера.")
|
||||
}
|
||||
PlayerPanel.Info -> SidePanel("ИНФОРМАЦИЯ О ПОТОКЕ", AppColors.Green, "Назад — закрыть", ui.panelInitial, modifier) {
|
||||
val engine = session.activeEngine
|
||||
StreamModeCard(PlayerLabels.mode(state))
|
||||
InfoRows(PlayerLabels.rows(state, media, engine))
|
||||
PanelCloseButton(onClick = close, modifier = Modifier.focusRequester(ui.panelInitial))
|
||||
PanelSection("ДОПОЛНИТЕЛЬНО · ДВИЖОК") {
|
||||
val choices = listOf(PanelChoice("engine/auto", "Авто", "выбор по файлу", !state.manualEngine)) +
|
||||
vm.selector.engines.map { PanelChoice("engine/${it.id}", it.displayName, null, state.manualEngine && state.engine == it.id) }
|
||||
PanelList(choices, initial = null) { c ->
|
||||
val id = c.key.substringAfter('/')
|
||||
if (id == "auto") session.switchToAuto() else session.switchEngine(EngineId(id))
|
||||
close()
|
||||
}
|
||||
}
|
||||
// Switching the path only changes the engine (no new state): [PlayerUiState.panelRevision] redraws the marks.
|
||||
(engine as? VideoPathSwitchable)?.let { e -> key(ui.panelRevision) {
|
||||
PanelSection("ВЫВОД ВИДЕО") {
|
||||
val now = if (e.usingGpuPath) "сейчас через GPU" else "сейчас видеослой"
|
||||
val choices = listOf(
|
||||
PanelChoice("path/AUTO", "Авто", now, e.videoPathMode == VideoPathSwitchable.Mode.AUTO),
|
||||
PanelChoice("path/VIDEO_PLANE", "Видеослой ТВ", "HDR, обработка ТВ", e.videoPathMode == VideoPathSwitchable.Mode.VIDEO_PLANE),
|
||||
PanelChoice("path/GPU", "Через GPU", "без HDR, до 1080p", e.videoPathMode == VideoPathSwitchable.Mode.GPU),
|
||||
)
|
||||
PanelList(choices, initial = null) { c ->
|
||||
e.setVideoPathMode(VideoPathSwitchable.Mode.valueOf(c.key.substringAfter('/')))
|
||||
ui.panelRevision++
|
||||
}
|
||||
}
|
||||
} }
|
||||
val dv = engine as? DolbyVisionSwitchable
|
||||
if (dv != null && media?.video?.range == VideoRange.DolbyVision) {
|
||||
PanelSection("DOLBY VISION") {
|
||||
val choices = listOf(
|
||||
PanelChoice("dv/false", "Базовый слой", "HDR10 / HDR10+", !dv.useDolbyVisionDecoder),
|
||||
PanelChoice("dv/true", "DV-декодер", "режим Dolby Vision ТВ", dv.useDolbyVisionDecoder),
|
||||
)
|
||||
PanelList(choices, initial = null) { c ->
|
||||
val on = c.key.endsWith("true")
|
||||
if (on != dv.useDolbyVisionDecoder) {
|
||||
session.switchEngine(ExoEngineProvider.ID, videoPath = (engine as? VideoPathSwitchable)?.videoPathMode, dolbyVision = on)
|
||||
}
|
||||
close()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val GRAPHIC_SUBTITLES = setOf("pgssub", "hdmv_pgs_subtitle", "dvdsub", "dvd_subtitle", "vobsub", "dvbsub")
|
||||
|
||||
private val OK_KEYS = setOf(AndroidKeyEvent.KEYCODE_DPAD_CENTER, AndroidKeyEvent.KEYCODE_ENTER, AndroidKeyEvent.KEYCODE_NUMPAD_ENTER)
|
||||
|
||||
private val PROMPT_KEYS = OK_KEYS + setOf(AndroidKeyEvent.KEYCODE_DPAD_LEFT, AndroidKeyEvent.KEYCODE_DPAD_RIGHT)
|
||||
|
||||
private const val OSD_TIMEOUT_MS = 6_000L
|
||||
/** Bottom of the skip button and the "next episode" card while the controls are shown: clear of the time labels. */
|
||||
private const val PROMPT_OVER_OSD = 150
|
||||
private const val TOP_SHADE = 105
|
||||
private const val BOTTOM_SHADE = 170
|
||||
private const val SCRUB_COMMIT_MS = 600L
|
||||
private const val SCRUB_SETTLE_MS = 1_200L
|
||||
@@ -0,0 +1,176 @@
|
||||
package ru.stenfredd.sleepyowl.ui.player
|
||||
|
||||
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.Row
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
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.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import ru.stenfredd.sleepyowl.core.data.model.MediaSegment
|
||||
import ru.stenfredd.sleepyowl.core.data.model.SegmentKind
|
||||
import ru.stenfredd.sleepyowl.playback.core.PlaybackDefaults
|
||||
import ru.stenfredd.sleepyowl.playback.core.SkipMode
|
||||
import ru.stenfredd.sleepyowl.ui.components.FocusStyle
|
||||
import ru.stenfredd.sleepyowl.ui.components.focusCard
|
||||
import ru.stenfredd.sleepyowl.ui.theme.AppColors
|
||||
import ru.stenfredd.sleepyowl.ui.theme.AppType
|
||||
import java.util.UUID
|
||||
|
||||
/** The server's segments of one item (they arrive after the item starts playing). */
|
||||
@Immutable
|
||||
data class PlayerSegments(val itemId: UUID, val segments: List<MediaSegment>)
|
||||
|
||||
/** A segment the player offers to skip ("Пропустить заставку") or skips on its own ([auto]). */
|
||||
@Immutable
|
||||
internal data class Skippable(val segment: MediaSegment, val auto: Boolean)
|
||||
|
||||
/**
|
||||
* What the player does with an item's segments under the settings.
|
||||
* [credits]: the credits of an episode with a next one, from their start to the end of the file: the "next episode"
|
||||
* card shows from their start instead of 30 s before the end ([creditsAuto]: the next episode starts right away).
|
||||
*/
|
||||
@Immutable
|
||||
internal data class SkipPlan(val skips: List<Skippable>, val credits: MediaSegment?, val creditsAuto: Boolean) {
|
||||
/** Parts drawn on the timeline. */
|
||||
val spans: List<MediaSegment> = skips.map { it.segment } + listOfNotNull(credits)
|
||||
|
||||
/** The skippable part [positionMs] is in (not counting its last second: nothing left to skip there). */
|
||||
fun skipAt(positionMs: Long): Skippable? = skips.firstOrNull { positionMs >= it.segment.startMs && positionMs < it.segment.endMs - SKIP_END_MARGIN_MS }
|
||||
|
||||
/** The part a seek to [positionMs] lands in: such a part is not skipped on its own, the user went there. */
|
||||
fun spanAt(positionMs: Long): MediaSegment? = spans.firstOrNull { positionMs >= it.startMs && positionMs < it.endMs }
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Credits (and previews after them) that run to the end of the file count as the end of the episode: with a
|
||||
* next episode they bring the "next" card, without one (a movie, the last episode) there is nothing to skip to.
|
||||
* Credits followed by more of the episode (an epilogue) get "Пропустить титры".
|
||||
*/
|
||||
fun of(segments: List<MediaSegment>, durationMs: Long, defaults: PlaybackDefaults, hasNext: Boolean): SkipPlan? {
|
||||
if (durationMs <= 0) return null
|
||||
val useful = segments.filter { it.endMs - it.startMs >= MIN_SEGMENT_MS && it.startMs < durationMs }
|
||||
var tail: MediaSegment? = null
|
||||
for (s in useful.filter { it.kind == SegmentKind.Outro || it.kind == SegmentKind.Preview }.sortedByDescending { it.startMs }) {
|
||||
val reaches = tail?.let { s.endMs >= it.startMs - TAIL_GAP_MS } ?: (s.endMs >= durationMs - TAIL_MARGIN_MS)
|
||||
if (!reaches) break
|
||||
tail = MediaSegment(SegmentKind.Outro, s.startMs, durationMs)
|
||||
}
|
||||
val tailStart = tail?.startMs ?: Long.MAX_VALUE
|
||||
val skips = useful.filter { it.startMs < tailStart }.mapNotNull { s ->
|
||||
val mode = if (s.kind == SegmentKind.Outro) defaults.skipCredits else defaults.skipIntro
|
||||
if (mode == SkipMode.Off) null else Skippable(s, auto = mode == SkipMode.Auto)
|
||||
}
|
||||
val credits = tail?.takeIf { hasNext && defaults.skipCredits != SkipMode.Off }
|
||||
return SkipPlan(skips, credits, creditsAuto = defaults.skipCredits == SkipMode.Auto)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Segments of one item something already happened to; an item change forgets them. */
|
||||
@Immutable
|
||||
internal data class SegmentSet(val itemId: UUID? = null, val segments: Set<MediaSegment> = emptySet()) {
|
||||
fun of(id: UUID?): Set<MediaSegment> = if (id == itemId) segments else emptySet()
|
||||
|
||||
fun plus(id: UUID?, segment: MediaSegment): SegmentSet = SegmentSet(id, of(id) + segment)
|
||||
}
|
||||
|
||||
internal object SkipLabels {
|
||||
fun button(kind: SegmentKind): String = when (kind) {
|
||||
SegmentKind.Intro -> "Пропустить заставку"
|
||||
SegmentKind.Recap -> "Пропустить пересказ"
|
||||
SegmentKind.Preview -> "Пропустить анонс"
|
||||
SegmentKind.Outro -> "Пропустить титры"
|
||||
SegmentKind.Commercial -> "Пропустить рекламу"
|
||||
}
|
||||
|
||||
fun skipped(kind: SegmentKind): String = when (kind) {
|
||||
SegmentKind.Intro -> "Заставка пропущена"
|
||||
SegmentKind.Recap -> "Пересказ пропущен"
|
||||
SegmentKind.Preview -> "Анонс пропущен"
|
||||
SegmentKind.Outro -> "Титры пропущены"
|
||||
SegmentKind.Commercial -> "Реклама пропущена"
|
||||
}
|
||||
}
|
||||
|
||||
private val SkipColor = Color(0xFF10121C)
|
||||
private val SkipBorder = Color(0x40FFFFFF)
|
||||
/** `0 0 0 3px #a594ff, 0 0 40px 8px rgba(124,92,255,.65)` */
|
||||
private val SkipFocus = FocusStyle(scale = 1f, corner = 20.dp, glows = listOf(FocusStyle.Glow(20.dp, 4.dp, Color(0xA67C5CFF))), animate = false)
|
||||
|
||||
/**
|
||||
* "Пропустить заставку 1:12" (time left of the part) in the corner where the "next episode" card goes. Opaque, as
|
||||
* that card: a translucent one over video costs a blend every frame.
|
||||
*/
|
||||
@Composable
|
||||
internal fun SkipButton(label: String, left: String, focusRequester: FocusRequester, onClick: () -> Unit, onFocus: (Boolean) -> Unit, modifier: Modifier = Modifier) {
|
||||
var focused by remember { mutableStateOf(false) }
|
||||
val shape = RoundedCornerShape(20.dp)
|
||||
Row(
|
||||
modifier
|
||||
.focusRequester(focusRequester)
|
||||
.focusCard(SkipFocus)
|
||||
.height(40.dp)
|
||||
.background(if (focused) AppColors.FocusFill else SkipColor, shape)
|
||||
.border(0.5.dp, if (focused) AppColors.FocusFill else SkipBorder, shape)
|
||||
.onFocusChanged {
|
||||
focused = it.isFocused
|
||||
onFocus(it.isFocused)
|
||||
}
|
||||
.clickable(interactionSource = null, indication = null, onClick = onClick)
|
||||
.padding(horizontal = 17.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
val color = if (focused) AppColors.OnFocusFill else Color.White
|
||||
BasicText(label, style = AppType.Button.copy(fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = color))
|
||||
BasicText(left, style = AppType.MonoMeta.copy(fontSize = 10.sp, color = color.copy(alpha = 0.7f)))
|
||||
}
|
||||
}
|
||||
|
||||
/** "Заставка пропущена" for a few seconds after an automatic skip; not focusable. */
|
||||
@Composable
|
||||
internal fun SkipNotice(text: String, modifier: Modifier = Modifier) {
|
||||
val shape = RoundedCornerShape(16.dp)
|
||||
BasicText(
|
||||
text,
|
||||
modifier
|
||||
.background(SkipColor, shape)
|
||||
.border(0.5.dp, SkipBorder, shape)
|
||||
.padding(horizontal = 14.dp, vertical = 8.dp),
|
||||
style = AppType.MonoLabel.copy(color = AppColors.TextSecondary),
|
||||
)
|
||||
}
|
||||
|
||||
/** The button over the bare video hides after this long; it comes back with the controls. */
|
||||
internal const val SKIP_PROMPT_MS = 10_000L
|
||||
|
||||
/** With known credits the "next episode" card counts down this long from their start. */
|
||||
internal const val CREDITS_COUNTDOWN_MS = 15_000L
|
||||
|
||||
internal const val SKIP_NOTICE_MS = 3_000L
|
||||
|
||||
private const val SKIP_END_MARGIN_MS = 1_000L
|
||||
/** Shorter segments are detection noise. */
|
||||
private const val MIN_SEGMENT_MS = 2_000L
|
||||
/** Credits ending this close to the end of the file run to the end (black frames after them, rounding). */
|
||||
private const val TAIL_MARGIN_MS = 10_000L
|
||||
/** Credits and a preview this far apart still make one tail. */
|
||||
private const val TAIL_GAP_MS = 2_000L
|
||||
@@ -0,0 +1,338 @@
|
||||
package ru.stenfredd.sleepyowl.ui.player
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import ru.stenfredd.sleepyowl.core.data.LibraryRepository
|
||||
import ru.stenfredd.sleepyowl.core.data.model.ItemDetails
|
||||
import ru.stenfredd.sleepyowl.core.data.model.ItemKind
|
||||
import ru.stenfredd.sleepyowl.core.data.model.ItemSummary
|
||||
import ru.stenfredd.sleepyowl.core.data.model.PersonKind
|
||||
import ru.stenfredd.sleepyowl.playback.api.EngineId
|
||||
import ru.stenfredd.sleepyowl.playback.api.StreamSelection
|
||||
import ru.stenfredd.sleepyowl.playback.api.SubtitleStyle
|
||||
import ru.stenfredd.sleepyowl.playback.core.DeviceCapabilitiesProbe
|
||||
import ru.stenfredd.sleepyowl.playback.core.EngineSelector
|
||||
import ru.stenfredd.sleepyowl.playback.core.PlaybackDefaults
|
||||
import ru.stenfredd.sleepyowl.playback.core.PlaybackPreferences
|
||||
import ru.stenfredd.sleepyowl.playback.core.PlaybackSession
|
||||
import ru.stenfredd.sleepyowl.playback.core.PlaybackSessionFactory
|
||||
import ru.stenfredd.sleepyowl.ui.Screen
|
||||
import ru.stenfredd.sleepyowl.ui.components.RemoteImageSpec
|
||||
import ru.stenfredd.sleepyowl.ui.model.plural
|
||||
import ru.stenfredd.sleepyowl.ui.details.CastModel
|
||||
import ru.stenfredd.sleepyowl.ui.details.MediaLabels
|
||||
import ru.stenfredd.sleepyowl.ui.model.CardMapper
|
||||
import timber.log.Timber
|
||||
import java.util.Locale
|
||||
import java.util.UUID
|
||||
|
||||
/** "Качество": [bitrate] null = the original file. */
|
||||
@Immutable
|
||||
data class QualityOption(val key: String, val bitrate: Int?, val label: String, val detail: String?)
|
||||
|
||||
/** One episode in the player's drawer. */
|
||||
@Immutable
|
||||
data class EpisodeModel(
|
||||
val key: String,
|
||||
val id: UUID,
|
||||
/** "E04" */
|
||||
val number: String,
|
||||
/** "4. Разлом" */
|
||||
val title: String,
|
||||
val duration: String?,
|
||||
val image: RemoteImageSpec?,
|
||||
val progress: Float?,
|
||||
val watched: Boolean,
|
||||
val isNew: Boolean,
|
||||
)
|
||||
|
||||
/** The episode after the one being played: the "Следующая через N с" card. */
|
||||
@Immutable
|
||||
data class NextEpisode(
|
||||
val id: UUID,
|
||||
/** "S1 · E5 · Разлом" */
|
||||
val title: String,
|
||||
val image: RemoteImageSpec?,
|
||||
)
|
||||
|
||||
/** What the controls and the drawer show about the item being played; display strings only. */
|
||||
@Immutable
|
||||
data class PlayerInfo(
|
||||
val id: UUID,
|
||||
val isEpisode: Boolean,
|
||||
val seriesId: UUID?,
|
||||
/** "ФИЛЬМ", "СЕРИАЛ" */
|
||||
val label: String,
|
||||
val title: String,
|
||||
/** "2024 · Драма, Приключения" or "Сезон 2 · Серия 4 · Разлом" */
|
||||
val subtitle: String?,
|
||||
val chaptersMs: List<Long>,
|
||||
val poster: RemoteImageSpec?,
|
||||
val rating: String?,
|
||||
val age: String?,
|
||||
val year: String?,
|
||||
val runtime: String?,
|
||||
val genres: String?,
|
||||
val description: String?,
|
||||
val director: String?,
|
||||
val cast: List<CastModel>,
|
||||
val seasonNumber: Int?,
|
||||
val qualities: List<QualityOption>,
|
||||
)
|
||||
|
||||
/** The season of the episode being played, for the drawer and ⏮/⏭. */
|
||||
@Immutable
|
||||
data class SeasonEpisodes(
|
||||
/** The episode being played: until the next one's season loads, this still describes the previous one. */
|
||||
val currentId: UUID,
|
||||
/** "СЕЗОН 2 · СЕРИИ" */
|
||||
val title: String,
|
||||
/** "10 серий · 3 просмотрено" */
|
||||
val stat: String,
|
||||
val episodes: List<EpisodeModel>,
|
||||
val previous: UUID?,
|
||||
val next: UUID?,
|
||||
val nextEpisode: NextEpisode?,
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
class PlayerViewModel @javax.inject.Inject constructor(
|
||||
private val factory: PlaybackSessionFactory,
|
||||
val selector: EngineSelector,
|
||||
val probe: DeviceCapabilitiesProbe,
|
||||
private val library: LibraryRepository,
|
||||
private val cards: CardMapper,
|
||||
private val prefs: PlaybackPreferences,
|
||||
) : ViewModel() {
|
||||
private val _session = MutableStateFlow<PlaybackSession?>(null)
|
||||
val session: StateFlow<PlaybackSession?> = _session.asStateFlow()
|
||||
|
||||
private val _info = MutableStateFlow<PlayerInfo?>(null)
|
||||
val info: StateFlow<PlayerInfo?> = _info.asStateFlow()
|
||||
|
||||
private val _season = MutableStateFlow<SeasonEpisodes?>(null)
|
||||
val season: StateFlow<SeasonEpisodes?> = _season.asStateFlow()
|
||||
|
||||
private val _segments = MutableStateFlow<PlayerSegments?>(null)
|
||||
/** Intro, credits …: skip buttons and the "next episode" card. */
|
||||
val segments: StateFlow<PlayerSegments?> = _segments.asStateFlow()
|
||||
|
||||
val subtitleStyle: StateFlow<SubtitleStyle> = prefs.subtitleStyle.stateIn(viewModelScope, SharingStarted.Eagerly, SubtitleStyle())
|
||||
|
||||
/** For the skip settings. */
|
||||
val defaults: StateFlow<PlaybackDefaults> = prefs.defaults.stateIn(viewModelScope, SharingStarted.Eagerly, PlaybackDefaults())
|
||||
|
||||
private var startedFor: Screen.Player? = null
|
||||
private var infoJob: Job? = null
|
||||
private var segmentsJob: Job? = null
|
||||
/** Quality chosen in the player: kept for the next episode. Until then the default from the settings applies. */
|
||||
private var maxBitrate: Int? = null
|
||||
private var qualityChosen = false
|
||||
/** Every episode of the series being played, in order. */
|
||||
private var allEpisodes: List<ItemSummary> = emptyList()
|
||||
|
||||
fun start(screen: Screen.Player) {
|
||||
if (screen == startedFor) return
|
||||
startedFor = screen
|
||||
play(screen.itemId, screen.engine, screen.startPositionMs, screen.streams, screen.startFraction)
|
||||
}
|
||||
|
||||
/** Switches to another episode (drawer, ⏮/⏭, end of an episode): from its resume point, with the remembered tracks. */
|
||||
fun playEpisode(id: UUID) {
|
||||
val episode = allEpisodes.firstOrNull { it.id == id } ?: return
|
||||
play(id, null, episode.resumeMs(), streams = null, startFraction = null)
|
||||
}
|
||||
|
||||
private fun ItemSummary.resumeMs(): Long = user.progress?.takeIf { it < RESUME_LIMIT }?.let { user.positionMs } ?: 0
|
||||
|
||||
private fun play(itemId: UUID, engine: EngineId?, startMs: Long, streams: StreamSelection?, startFraction: Double?) {
|
||||
val old = _session.value
|
||||
val session = factory.create(viewModelScope)
|
||||
if (qualityChosen) session.setMaxBitrate(maxBitrate)
|
||||
_session.value = session
|
||||
old?.release()
|
||||
session.start(itemId, engine, startMs, streams, startFraction)
|
||||
loadInfo(itemId)
|
||||
}
|
||||
|
||||
fun setQuality(bitrate: Int?) {
|
||||
maxBitrate = bitrate
|
||||
qualityChosen = true
|
||||
_session.value?.setMaxBitrate(bitrate)
|
||||
}
|
||||
|
||||
fun setSubtitleStyle(style: SubtitleStyle) {
|
||||
_session.value?.setSubtitleStyle(style)
|
||||
viewModelScope.launch { prefs.setSubtitleStyle(style) }
|
||||
}
|
||||
|
||||
/** Releases [target] (default: the current session); a stale screen disposing late can't kill a newer session. */
|
||||
fun release(target: PlaybackSession? = _session.value) {
|
||||
target?.release()
|
||||
if (target === _session.value) {
|
||||
_session.value = null
|
||||
startedFor = null
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCleared() = release()
|
||||
|
||||
private fun loadInfo(itemId: UUID) {
|
||||
loadSegments(itemId)
|
||||
infoJob?.cancel()
|
||||
if (_info.value?.id != itemId) _info.value = null
|
||||
infoJob = viewModelScope.launch {
|
||||
try {
|
||||
val details = library.details(itemId)
|
||||
_info.value = withContext(Dispatchers.Default) { details.toInfo() }
|
||||
val item = details.summary
|
||||
val seriesId = item.seriesId
|
||||
if (item.kind == ItemKind.Episode && seriesId != null) {
|
||||
val all = library.episodes(seriesId)
|
||||
allEpisodes = all
|
||||
_season.value = withContext(Dispatchers.Default) { season(item, all) }
|
||||
} else {
|
||||
_season.value = null
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Timber.w(e, "Player: info failed")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Apart from the info: a server without segments (or failing to give them) must not cost the drawer. */
|
||||
private fun loadSegments(itemId: UUID) {
|
||||
segmentsJob?.cancel()
|
||||
if (_segments.value?.itemId != itemId) _segments.value = null
|
||||
segmentsJob = viewModelScope.launch {
|
||||
try {
|
||||
_segments.value = PlayerSegments(itemId, library.segments(itemId))
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Timber.w(e, "Player: segments failed")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun season(current: ItemSummary, all: List<ItemSummary>): SeasonEpisodes {
|
||||
val index = all.indexOfFirst { it.id == current.id }
|
||||
val inSeason = all.filter { it.seasonId == current.seasonId }
|
||||
val watched = inSeason.count { it.user.played }
|
||||
return SeasonEpisodes(
|
||||
currentId = current.id,
|
||||
title = listOfNotNull(current.seasonNumber?.let { "СЕЗОН $it" }, "СЕРИИ").joinToString(" · "),
|
||||
stat = "${inSeason.size} ${plural(inSeason.size, "серия", "серии", "серий")} · $watched просмотрено",
|
||||
episodes = inSeason.map { e ->
|
||||
val progress = e.user.progress
|
||||
EpisodeModel(
|
||||
key = "ep/${e.id}",
|
||||
id = e.id,
|
||||
number = e.episodeNumber?.let { "E%02d".format(it) } ?: "",
|
||||
title = listOfNotNull(e.episodeNumber?.let { "$it." }, e.name).joinToString(" "),
|
||||
duration = e.runtimeMs?.let { "${it / 60_000} мин" },
|
||||
image = cards.image(e.primary ?: e.thumb ?: e.backdrop, PlayerLayout.EPISODE_W, PlayerLayout.EPISODE_H),
|
||||
progress = progress,
|
||||
watched = e.user.played,
|
||||
isNew = cards.isNew(e),
|
||||
)
|
||||
},
|
||||
previous = all.getOrNull(index - 1)?.id?.takeIf { index > 0 },
|
||||
next = all.getOrNull(index + 1)?.id?.takeIf { index >= 0 },
|
||||
nextEpisode = all.getOrNull(index + 1)?.takeIf { index >= 0 }?.let { n ->
|
||||
NextEpisode(
|
||||
id = n.id,
|
||||
title = listOfNotNull(n.seasonNumber?.let { "S$it" }, n.episodeNumber?.let { "E$it" }, n.name).joinToString(" · "),
|
||||
image = cards.image(n.primary ?: n.thumb ?: n.backdrop, PlayerLayout.NEXT_W, PlayerLayout.NEXT_H),
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private fun ItemDetails.toInfo(): PlayerInfo {
|
||||
val item = summary
|
||||
val episode = item.kind == ItemKind.Episode
|
||||
val media = media
|
||||
return PlayerInfo(
|
||||
id = item.id,
|
||||
isEpisode = episode,
|
||||
seriesId = item.seriesId.takeIf { episode },
|
||||
label = if (episode) "СЕРИАЛ" else "ФИЛЬМ",
|
||||
title = if (episode) item.seriesName ?: item.name else item.name,
|
||||
subtitle = if (episode) {
|
||||
// Episodes without their own title are often named "Серия 3": don't say it twice.
|
||||
val name = item.name.takeUnless { n -> item.episodeNumber != null && n.equals("Серия ${item.episodeNumber}", ignoreCase = true) }
|
||||
listOfNotNull(item.seasonNumber?.let { "Сезон $it" }, item.episodeNumber?.let { "Серия $it" }, name).joinToString(" · ")
|
||||
} else {
|
||||
listOfNotNull(item.year?.toString(), item.genres.take(3).joinToString(", ").ifEmpty { null }).joinToString(" · ").ifEmpty { null }
|
||||
},
|
||||
chaptersMs = chapters.map { it.startMs }.filter { it > 0 },
|
||||
poster = cards.image(if (episode) item.seriesPrimary ?: item.primary else item.primary, PlayerLayout.POSTER_W, PlayerLayout.POSTER_H),
|
||||
rating = item.communityRating?.let { String.format(Locale.US, "★ %.1f", it) },
|
||||
age = item.officialRating?.let { r -> Regex("""(\d{1,2}\+)$""").find(r)?.value ?: r },
|
||||
year = item.year?.toString(),
|
||||
runtime = MediaLabels.runtime(item.runtimeMs),
|
||||
genres = item.genres.take(3).joinToString(", ").ifEmpty { null },
|
||||
description = item.overview,
|
||||
director = people.filter { it.kind == PersonKind.Director }.joinToString(", ") { it.name }.ifEmpty { null },
|
||||
cast = people.filter { it.kind == PersonKind.Actor }.take(CAST_LIMIT).map { p ->
|
||||
CastModel(
|
||||
key = "cast/${p.id}",
|
||||
name = p.name,
|
||||
role = p.role,
|
||||
letters = p.name.split(' ').filter { it.isNotBlank() }.take(2).joinToString("") { it.take(1).uppercase() },
|
||||
image = cards.image(p.image, PlayerLayout.AVATAR, PlayerLayout.AVATAR),
|
||||
)
|
||||
},
|
||||
seasonNumber = item.seasonNumber,
|
||||
qualities = qualities(media?.bitrate, media?.video?.height),
|
||||
)
|
||||
}
|
||||
|
||||
/** "Оригинал" and the transcode caps below the file's own bitrate, labelled with the resulting resolution. */
|
||||
private fun qualities(fileBitrate: Int?, height: Int?): List<QualityOption> {
|
||||
val original = QualityOption("q/original", null, "Оригинал", fileBitrate?.let { PlayerLabels.mbps(it) })
|
||||
val caps = QualityCaps.ALL.filter { (bitrate, _) -> fileBitrate == null || bitrate < fileBitrate * 0.9 }
|
||||
return listOf(original) + caps.map { (bitrate, lines) ->
|
||||
val shown = if (height != null && height in 1 until lines) height else lines
|
||||
QualityOption("q/$bitrate", bitrate, "${shown}p", PlayerLabels.mbps(bitrate))
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val CAST_LIMIT = 4
|
||||
/** An episode watched further than this starts from the beginning when picked in the drawer. */
|
||||
const val RESUME_LIMIT = 0.9f
|
||||
}
|
||||
}
|
||||
|
||||
/** Transcode caps offered by "Качество" (player and settings). */
|
||||
internal object QualityCaps {
|
||||
/** Bitrate cap → resolution the server scales to at that bitrate (its own rule of thumb). */
|
||||
val ALL = listOf(20_000_000 to 1080, 10_000_000 to 1080, 4_000_000 to 720, 1_500_000 to 480)
|
||||
}
|
||||
|
||||
/** Sizes (dp) shared by the layout and the image requests. */
|
||||
internal object PlayerLayout {
|
||||
const val EPISODE_W = 180f
|
||||
const val EPISODE_H = 101f
|
||||
const val POSTER_W = 100f
|
||||
const val POSTER_H = 150f
|
||||
const val AVATAR = 24f
|
||||
const val NEXT_W = 104f
|
||||
const val NEXT_H = 58.5f
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
package ru.stenfredd.sleepyowl.ui.search
|
||||
|
||||
import android.view.KeyEvent as NativeKeyEvent
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.items
|
||||
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.BasicText
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.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.focusRestorer
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.input.key.KeyEventType
|
||||
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
||||
import androidx.compose.ui.input.key.type
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import kotlinx.coroutines.delay
|
||||
import ru.stenfredd.sleepyowl.ui.Screen
|
||||
import ru.stenfredd.sleepyowl.ui.components.AnchoredScrolling
|
||||
import ru.stenfredd.sleepyowl.ui.components.AppIcon
|
||||
import ru.stenfredd.sleepyowl.ui.components.Avatar
|
||||
import ru.stenfredd.sleepyowl.ui.components.Chip
|
||||
import ru.stenfredd.sleepyowl.ui.components.FocusStyle
|
||||
import ru.stenfredd.sleepyowl.ui.components.Icon
|
||||
import ru.stenfredd.sleepyowl.ui.components.PosterCard
|
||||
import ru.stenfredd.sleepyowl.ui.components.RemoteImage
|
||||
import ru.stenfredd.sleepyowl.ui.components.anchorViewport
|
||||
import ru.stenfredd.sleepyowl.ui.components.anchoredRow
|
||||
import ru.stenfredd.sleepyowl.ui.components.focusCard
|
||||
import ru.stenfredd.sleepyowl.ui.components.fullItem
|
||||
import ru.stenfredd.sleepyowl.ui.focus.Restore
|
||||
import ru.stenfredd.sleepyowl.ui.focus.ScreenFocus
|
||||
import ru.stenfredd.sleepyowl.ui.focus.rememberScreenFocus
|
||||
import ru.stenfredd.sleepyowl.ui.focus.remembered
|
||||
import ru.stenfredd.sleepyowl.ui.theme.AppColors
|
||||
import ru.stenfredd.sleepyowl.ui.theme.AppDimens
|
||||
import ru.stenfredd.sleepyowl.ui.theme.AppType
|
||||
|
||||
/**
|
||||
* Search tab (mockup screen «Поиск»): on the left the query and an on-screen keyboard (Russian; one key switches
|
||||
* to Latin letters and digits), on the right recent queries and newest items, or the results by library and people.
|
||||
* Typing on a physical keyboard (or digits on the remote) works anywhere on the screen.
|
||||
*/
|
||||
@Composable
|
||||
fun SearchScreen(onOpen: (Screen) -> Unit, vm: SearchViewModel = hiltViewModel()) {
|
||||
val state by vm.state.collectAsStateWithLifecycle()
|
||||
val focus = rememberScreenFocus()
|
||||
val open = { key: String -> vm.open(key)?.let(onOpen) ?: Unit }
|
||||
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.onPreviewKeyEvent { e ->
|
||||
if (e.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false
|
||||
val native = e.nativeKeyEvent
|
||||
if (native.keyCode == NativeKeyEvent.KEYCODE_DEL) {
|
||||
vm.backspace()
|
||||
return@onPreviewKeyEvent true
|
||||
}
|
||||
val c = native.unicodeChar
|
||||
if (c != 0 && (Character.isLetterOrDigit(c) || c == ' '.code || c == '-'.code)) {
|
||||
vm.type(String(Character.toChars(c)).lowercase())
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
},
|
||||
) {
|
||||
Column(Modifier.padding(start = AppDimens.CONTENT_START.dp, top = Top).width(SearchLayout.KEYBOARD_W.dp)) {
|
||||
QueryField(state.query)
|
||||
Keyboard(state.latin, vm, focus, Modifier.padding(top = 12.dp))
|
||||
BasicText(
|
||||
"Можно печатать и с клавиатуры, цифры — с пульта.",
|
||||
Modifier.padding(top = 11.dp),
|
||||
style = AppType.Body.copy(fontSize = 10.sp, lineHeight = 13.sp, color = Color(0xFF6F7592)),
|
||||
)
|
||||
}
|
||||
Results(state, focus, vm, open, Modifier.padding(start = SearchLayout.RESULTS_START.dp))
|
||||
}
|
||||
focus.Restore(ready = true)
|
||||
}
|
||||
|
||||
/** The query with a blinking caret (two redraws a second, a step, not an animation). */
|
||||
@Composable
|
||||
private fun QueryField(query: String) {
|
||||
var caret by remember { mutableStateOf(true) }
|
||||
LaunchedEffect(query) {
|
||||
caret = true
|
||||
while (true) {
|
||||
delay(500)
|
||||
caret = !caret
|
||||
}
|
||||
}
|
||||
val shape = RoundedCornerShape(9.dp)
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(48.dp)
|
||||
.background(AppColors.Surface, shape)
|
||||
.border(0.5.dp, Color(0xFF2C3044), shape)
|
||||
.padding(horizontal = 13.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(AppIcon.Search, AppColors.Accent, Modifier.size(16.dp), strokeWidth = 2.2f)
|
||||
Spacer(Modifier.width(7.dp))
|
||||
if (query.isNotEmpty()) {
|
||||
BasicText(
|
||||
query.replaceFirstChar { it.uppercase() },
|
||||
Modifier.weight(1f, fill = false),
|
||||
style = AppType.Hero.copy(fontSize = 19.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 0.sp),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.StartEllipsis,
|
||||
)
|
||||
}
|
||||
Box(
|
||||
Modifier
|
||||
.padding(start = 2.dp)
|
||||
.size(1.5.dp, 23.dp)
|
||||
.graphicsLayer { alpha = if (caret) 1f else 0f }
|
||||
.background(Color(0xFF7AA2FF)),
|
||||
)
|
||||
if (query.isEmpty()) {
|
||||
BasicText("Название, жанр, актёр", Modifier.padding(start = 4.dp), style = AppType.Body.copy(fontSize = 15.sp, color = AppColors.TextFaint), maxLines = 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private const val RUSSIAN = "АБВГДЕЁЖЗИЙКЛМНОПРСТУФХЦЧШЩЪЫЬЭЮЯ-.,"
|
||||
private const val LATIN = "ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890"
|
||||
|
||||
/** 6 columns: 36 character keys, then ⌫, space, layout switch. */
|
||||
@Composable
|
||||
private fun Keyboard(latin: Boolean, vm: SearchViewModel, focus: ScreenFocus, modifier: Modifier = Modifier) {
|
||||
val chars = if (latin) LATIN else RUSSIAN
|
||||
Column(modifier.focusRestorer(), verticalArrangement = Arrangement.spacedBy(KeyGap)) {
|
||||
chars.chunked(COLUMNS).forEachIndexed { row, line ->
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(KeyGap)) {
|
||||
line.forEachIndexed { col, c ->
|
||||
// Keys are remembered by position: after switching the layout focus stays where it was.
|
||||
val key = "key/${row * COLUMNS + col}"
|
||||
Key(c.toString(), { vm.type(c.lowercase()) }, Modifier.weight(1f).remembered(focus, key, isDefault = row == 0 && col == 0))
|
||||
}
|
||||
}
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(KeyGap)) {
|
||||
Key("⌫", vm::backspace, Modifier.weight(1f).remembered(focus, "key/del"), onLongClick = vm::clear)
|
||||
Key("Пробел", { vm.type(" ") }, Modifier.weight(3f).remembered(focus, "key/space"), small = true)
|
||||
Key(if (latin) "АБВ" else "ABC·123", vm::toggleLayout, Modifier.weight(2f).remembered(focus, "key/layout"), small = true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val KeyFocus = FocusStyle(scale = 1.06f, corner = 6.dp, ring = 1.dp, glows = FocusStyle.ButtonGlows)
|
||||
|
||||
@Composable
|
||||
private fun Key(label: String, onClick: () -> Unit, modifier: Modifier = Modifier, small: Boolean = false, onLongClick: (() -> Unit)? = null) {
|
||||
var focused by remember { mutableStateOf(false) }
|
||||
val shape = RoundedCornerShape(6.dp)
|
||||
Box(
|
||||
modifier
|
||||
.focusCard(KeyFocus)
|
||||
.height(38.dp)
|
||||
.background(if (focused) AppColors.FocusFill else AppColors.SurfaceRaised, shape)
|
||||
.border(0.5.dp, if (focused) AppColors.FocusFill else Color(0xFF232637), shape)
|
||||
.onFocusChanged { focused = it.isFocused }
|
||||
.combinedClickable(interactionSource = null, indication = null, onLongClick = onLongClick, onClick = onClick),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
BasicText(
|
||||
label,
|
||||
style = AppType.Button.copy(fontSize = if (small) 11.sp else 14.sp, color = if (focused) AppColors.OnFocusFill else Color(0xFFD9DCEC)),
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Results(state: SearchState, focus: ScreenFocus, vm: SearchViewModel, open: (String) -> Unit, modifier: Modifier = Modifier) {
|
||||
val grid = rememberLazyGridState()
|
||||
// A new query starts the results from the top.
|
||||
LaunchedEffect(state.results) { if (grid.firstVisibleItemIndex > 0) grid.scrollToItem(0) }
|
||||
AnchoredScrolling(grid, anchor = Top) { anchored ->
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.FixedSize(AppDimens.POSTER_W.dp),
|
||||
state = grid,
|
||||
modifier = modifier.fillMaxSize().anchorViewport(anchored),
|
||||
contentPadding = PaddingValues(start = 12.dp, end = AppDimens.CONTENT_START.dp, top = Top, bottom = 200.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(14.dp),
|
||||
) {
|
||||
val results = state.results
|
||||
if (results == null) {
|
||||
if (state.recentQueries.isNotEmpty()) {
|
||||
fullItem("recent-q", "label") {
|
||||
Column(Modifier.anchoredRow(anchored).padding(bottom = 22.dp)) {
|
||||
Label("НЕДАВНИЕ ЗАПРОСЫ")
|
||||
FlowRow(Modifier.padding(top = 9.dp), horizontalArrangement = Arrangement.spacedBy(6.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
state.recentQueries.forEachIndexed { i, q ->
|
||||
Chip(q, false, { vm.pickRecent(q) }, Modifier.remembered(focus, "rq/$i"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (state.recentItems.isNotEmpty()) {
|
||||
fullItem("recent-label", "label") { Label("НЕДАВНО ДОБАВЛЕНО", Modifier.padding(bottom = 13.dp)) }
|
||||
posters(state.recentItems, focus, open, anchored)
|
||||
}
|
||||
return@LazyVerticalGrid
|
||||
}
|
||||
fullItem("found", "label") {
|
||||
Label(if (state.searching) "ИЩЕМ…" else "НАЙДЕНО: ${results.sumOf { it.size }}", Modifier.anchoredRow(anchored))
|
||||
}
|
||||
if (results.isEmpty() && !state.searching) {
|
||||
fullItem("none", "text") {
|
||||
BasicText(
|
||||
state.error?.let { "Не удалось найти: $it" } ?: "Ничего не нашлось по «${state.query.trim()}». Попробуйте часть названия или имя актёра.",
|
||||
Modifier.padding(top = 12.dp).widthIn(max = 480.dp),
|
||||
style = AppType.Body.copy(fontSize = 15.sp, lineHeight = 21.sp),
|
||||
)
|
||||
}
|
||||
}
|
||||
for (group in results) {
|
||||
fullItem(group.key, "group") {
|
||||
Row(Modifier.padding(top = 16.dp, bottom = 12.dp), verticalAlignment = Alignment.Bottom, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
BasicText(group.title, style = AppType.Section)
|
||||
BasicText(group.size.toString(), Modifier.padding(bottom = 1.dp), style = AppType.MonoCount)
|
||||
}
|
||||
}
|
||||
posters(group.posters, focus, open, anchored)
|
||||
items(group.people, key = { it.key }, contentType = { "person" }) { p ->
|
||||
PersonCard(p, { open(p.key) }, Modifier.padding(bottom = 18.dp).anchoredRow(anchored).remembered(focus, p.key))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun androidx.compose.foundation.lazy.grid.LazyGridScope.posters(
|
||||
list: List<ru.stenfredd.sleepyowl.ui.components.PosterModel>,
|
||||
focus: ScreenFocus,
|
||||
open: (String) -> Unit,
|
||||
anchored: ru.stenfredd.sleepyowl.ui.components.AnchoredScroll<*>,
|
||||
) {
|
||||
items(list, key = { it.key }, contentType = { "poster" }) { p ->
|
||||
PosterCard(p, { open(p.key) }, Modifier.padding(bottom = 18.dp).anchoredRow(anchored).remembered(focus, p.key))
|
||||
}
|
||||
}
|
||||
|
||||
private val PersonFocus = FocusStyle(scale = 1.05f, corner = (SearchLayout.PERSON_PHOTO / 2).dp, ring = 1.dp)
|
||||
|
||||
/** Round photo (or initials) and the name under it. */
|
||||
@Composable
|
||||
private fun PersonCard(person: PersonCardModel, onClick: () -> Unit, modifier: Modifier = Modifier) {
|
||||
val size = SearchLayout.PERSON_PHOTO.dp
|
||||
Column(modifier.width(AppDimens.POSTER_W.dp), horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Box(
|
||||
Modifier
|
||||
.focusCard(PersonFocus)
|
||||
.size(size)
|
||||
.clickable(interactionSource = null, indication = null, onClick = onClick),
|
||||
) {
|
||||
if (person.photo != null) {
|
||||
RemoteImage(person.photo, size / 2, Modifier.fillMaxSize())
|
||||
} else {
|
||||
Avatar(person.name, person.key, size, AppType.Hero.copy(fontSize = 24.sp), letters = person.letters)
|
||||
}
|
||||
}
|
||||
BasicText(
|
||||
person.name,
|
||||
Modifier.padding(top = 8.dp),
|
||||
style = AppType.PosterTitle.copy(textAlign = TextAlign.Center),
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Label(text: String, modifier: Modifier = Modifier) {
|
||||
BasicText(text, modifier, style = AppType.MonoLabel.copy(color = AppColors.TextDim))
|
||||
}
|
||||
|
||||
private val Top = (AppDimens.CONTENT_TOP + 10).dp
|
||||
private val KeyGap = 5.dp
|
||||
private const val COLUMNS = 6
|
||||
@@ -0,0 +1,172 @@
|
||||
package ru.stenfredd.sleepyowl.ui.search
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import ru.stenfredd.sleepyowl.core.data.LibraryRepository
|
||||
import ru.stenfredd.sleepyowl.core.data.SearchHistory
|
||||
import ru.stenfredd.sleepyowl.core.data.model.Person
|
||||
import ru.stenfredd.sleepyowl.ui.Screen
|
||||
import ru.stenfredd.sleepyowl.ui.components.PosterModel
|
||||
import ru.stenfredd.sleepyowl.ui.components.RemoteImageSpec
|
||||
import ru.stenfredd.sleepyowl.ui.model.CardMapper
|
||||
import timber.log.Timber
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import javax.inject.Inject
|
||||
|
||||
@Immutable
|
||||
data class PersonCardModel(val key: String, val name: String, val letters: String, val photo: RemoteImageSpec?)
|
||||
|
||||
/** Results of one library ("Фильмы 3"), or people. */
|
||||
@Immutable
|
||||
data class SearchGroup(val key: String, val title: String, val posters: List<PosterModel>, val people: List<PersonCardModel>) {
|
||||
val size: Int get() = posters.size + people.size
|
||||
}
|
||||
|
||||
@Immutable
|
||||
data class SearchState(
|
||||
val query: String = "",
|
||||
val latin: Boolean = false,
|
||||
val recentQueries: List<String> = emptyList(),
|
||||
val recentItems: List<PosterModel> = emptyList(),
|
||||
/** Null while the query is empty: the screen shows recent queries and items instead. */
|
||||
val results: List<SearchGroup>? = null,
|
||||
val searching: Boolean = false,
|
||||
val error: String? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* Search tab: the query typed on the on-screen keyboard (or a physical one), results grouped by library plus people,
|
||||
* searched as the user types (after a short pause). Recent queries are kept on the device ([SearchHistory]):
|
||||
* a query is remembered when one of its results is opened.
|
||||
*/
|
||||
@HiltViewModel
|
||||
class SearchViewModel @Inject constructor(
|
||||
private val library: LibraryRepository,
|
||||
private val history: SearchHistory,
|
||||
private val cards: CardMapper,
|
||||
) : ViewModel() {
|
||||
private val _state = MutableStateFlow(SearchState())
|
||||
val state: StateFlow<SearchState> = _state.asStateFlow()
|
||||
|
||||
private val targets = ConcurrentHashMap<String, Screen>()
|
||||
private var searchJob: Job? = null
|
||||
|
||||
init {
|
||||
viewModelScope.launch { history.recent().collect { list -> _state.update { it.copy(recentQueries = list) } } }
|
||||
viewModelScope.launch {
|
||||
runCatching { library.latest().take(RECENT_ITEMS) }
|
||||
.onSuccess { items ->
|
||||
val posters = withContext(Dispatchers.Default) {
|
||||
items.map { item ->
|
||||
targets[cards.key(RECENT_ROW, item)] = cards.target(item)
|
||||
cards.poster(item, RECENT_ROW)
|
||||
}
|
||||
}
|
||||
_state.update { it.copy(recentItems = posters) }
|
||||
}
|
||||
.onFailure { Timber.w(it, "Recent items failed") }
|
||||
}
|
||||
}
|
||||
|
||||
/** Where a card leads; opening a search result remembers the query. */
|
||||
fun open(key: String): Screen? {
|
||||
val query = _state.value.query
|
||||
if (_state.value.results != null && !key.startsWith(RECENT_ROW)) viewModelScope.launch { history.add(query) }
|
||||
return targets[key]
|
||||
}
|
||||
|
||||
fun type(text: String) = setQuery(_state.value.query + text)
|
||||
|
||||
fun backspace() = setQuery(_state.value.query.dropLast(1))
|
||||
|
||||
fun clear() = setQuery("")
|
||||
|
||||
fun pickRecent(query: String) = setQuery(query)
|
||||
|
||||
fun toggleLayout() = _state.update { it.copy(latin = !it.latin) }
|
||||
|
||||
private fun setQuery(query: String) {
|
||||
val q = query.take(MAX_LENGTH)
|
||||
if (q == _state.value.query) return
|
||||
searchJob?.cancel()
|
||||
if (q.isBlank()) {
|
||||
_state.update { it.copy(query = q, results = null, searching = false, error = null) }
|
||||
return
|
||||
}
|
||||
_state.update { it.copy(query = q, searching = true) }
|
||||
searchJob = viewModelScope.launch {
|
||||
delay(TYPING_PAUSE_MS)
|
||||
search(q.trim())
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun search(term: String) {
|
||||
try {
|
||||
val groups = coroutineScope {
|
||||
val people = async { library.searchPeople(term) }
|
||||
val byLibrary = library.views().map { view -> async { view to library.search(term, view) } }.awaitAll()
|
||||
withContext(Dispatchers.Default) {
|
||||
byLibrary.filter { it.second.isNotEmpty() }.map { (view, items) ->
|
||||
val row = "s/${view.id}"
|
||||
SearchGroup(
|
||||
row,
|
||||
view.name,
|
||||
items.map { item ->
|
||||
targets[cards.key(row, item)] = cards.target(item)
|
||||
cards.poster(item, row)
|
||||
},
|
||||
emptyList(),
|
||||
)
|
||||
} + listOfNotNull(people.await().takeIf { it.isNotEmpty() }?.let { list -> SearchGroup("s/people", "Люди", emptyList(), list.map(::personCard)) })
|
||||
}
|
||||
}
|
||||
_state.update { it.copy(results = groups, searching = false, error = null) }
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Timber.w(e, "Search failed")
|
||||
_state.update { it.copy(results = emptyList(), searching = false, error = e.message ?: e.toString()) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun personCard(p: Person): PersonCardModel {
|
||||
val key = "s/people/${p.id}"
|
||||
targets[key] = Screen.Person(p.id)
|
||||
return PersonCardModel(
|
||||
key = key,
|
||||
name = p.name,
|
||||
letters = p.name.split(' ').filter { it.isNotBlank() }.take(2).joinToString("") { it.take(1).uppercase() },
|
||||
photo = cards.image(p.image, SearchLayout.PERSON_PHOTO, SearchLayout.PERSON_PHOTO),
|
||||
)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val RECENT_ROW = "recent"
|
||||
const val RECENT_ITEMS = 8
|
||||
const val TYPING_PAUSE_MS = 350L
|
||||
const val MAX_LENGTH = 60
|
||||
}
|
||||
}
|
||||
|
||||
internal object SearchLayout {
|
||||
const val PERSON_PHOTO = 80f
|
||||
/** Left column: query field and keyboard. */
|
||||
const val KEYBOARD_W = 280
|
||||
/** The results column starts here, from the content's left edge. */
|
||||
const val RESULTS_START = 372
|
||||
}
|
||||
@@ -0,0 +1,691 @@
|
||||
package ru.stenfredd.sleepyowl.ui.series
|
||||
|
||||
import android.view.KeyEvent as AndroidKeyEvent
|
||||
import androidx.activity.compose.BackHandler
|
||||
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.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.wrapContentSize
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
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.LaunchedEffect
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.listSaver
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.withFrameNanos
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.drawBehind
|
||||
import androidx.compose.ui.draw.drawWithCache
|
||||
import androidx.compose.ui.draw.rotate
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Canvas
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.graphics.drawscope.CanvasDrawScope
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.graphics.drawscope.DrawScope
|
||||
import androidx.compose.ui.input.key.KeyEvent
|
||||
import androidx.compose.ui.input.key.KeyEventType
|
||||
import androidx.compose.ui.input.key.key
|
||||
import androidx.compose.ui.input.key.nativeKeyCode
|
||||
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
||||
import androidx.compose.ui.input.key.type
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.em
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import kotlinx.coroutines.launch
|
||||
import ru.stenfredd.sleepyowl.ui.Screen
|
||||
import ru.stenfredd.sleepyowl.ui.components.AppIcon
|
||||
import ru.stenfredd.sleepyowl.ui.components.CircleButton
|
||||
import ru.stenfredd.sleepyowl.ui.components.FocusStyle
|
||||
import ru.stenfredd.sleepyowl.ui.components.FormatBadge
|
||||
import ru.stenfredd.sleepyowl.ui.components.MinimalScrolling
|
||||
import ru.stenfredd.sleepyowl.ui.components.PrimaryButton
|
||||
import ru.stenfredd.sleepyowl.ui.components.ProgressLine
|
||||
import ru.stenfredd.sleepyowl.ui.components.RemoteImage
|
||||
import ru.stenfredd.sleepyowl.ui.components.focusCard
|
||||
import ru.stenfredd.sleepyowl.ui.details.Backdrop
|
||||
import ru.stenfredd.sleepyowl.ui.focus.Restore
|
||||
import ru.stenfredd.sleepyowl.ui.focus.ScreenFocus
|
||||
import ru.stenfredd.sleepyowl.ui.focus.rememberScreenFocus
|
||||
import ru.stenfredd.sleepyowl.ui.focus.remembered
|
||||
import ru.stenfredd.sleepyowl.ui.menu.LocalWatchMenu
|
||||
import ru.stenfredd.sleepyowl.ui.menu.clickableWithMenu
|
||||
import ru.stenfredd.sleepyowl.ui.theme.AppColors
|
||||
import ru.stenfredd.sleepyowl.ui.theme.AppDimens
|
||||
import ru.stenfredd.sleepyowl.ui.theme.AppType
|
||||
import kotlin.math.floor
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
* Series page, the mockup's "Сериал" screen. Full screen, without the side rail, nothing scrolls vertically:
|
||||
* header with "Продолжить S2 E4", the audio/subtitle choice and ♥; the season map; season chips; every episode of
|
||||
* every season in one row (a divider before each season); the focused episode's description at the bottom.
|
||||
*
|
||||
* Remote: ↓ from the buttons → the chip of the current season → its next episode; ↑ from an episode → its season's
|
||||
* chip. Moving along the chips scrolls the row to that season; moving along the row lights up its season.
|
||||
*/
|
||||
@Composable
|
||||
fun SeriesScreen(
|
||||
screen: Screen.Series,
|
||||
onOpen: (Screen) -> Unit,
|
||||
vm: SeriesViewModel = hiltViewModel<SeriesViewModel, SeriesViewModel.Factory>(creationCallback = { it.create(screen.seriesId) }),
|
||||
) {
|
||||
val state by vm.state.collectAsStateWithLifecycle()
|
||||
val focus = rememberScreenFocus()
|
||||
val item = state.item
|
||||
when {
|
||||
item != null -> SeriesPage(screen, item, state, focus, vm, onOpen)
|
||||
state.error != null -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
BasicText("Не удалось загрузить: ${state.error}", style = AppType.Body)
|
||||
}
|
||||
}
|
||||
focus.Restore(ready = item != null)
|
||||
}
|
||||
|
||||
private const val KEY_PLAY = "play"
|
||||
private const val KEY_TRACKS = "tracks"
|
||||
private const val KEY_WATCHED = "watched"
|
||||
|
||||
/**
|
||||
* What the page highlights: the season of the chips and the map, the episode described at the bottom.
|
||||
* [episode] is read only by the parts that show it, so moving along the row doesn't recompose the page.
|
||||
*/
|
||||
@Stable
|
||||
private class SeriesSelection(season: Int, episode: Int) {
|
||||
var season by mutableIntStateOf(season)
|
||||
var episode by mutableIntStateOf(episode)
|
||||
|
||||
companion object {
|
||||
val Saver = listSaver<SeriesSelection, Int>({ listOf(it.season, it.episode) }, { SeriesSelection(it[0], it[1]) })
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SeriesPage(screen: Screen.Series, item: SeriesModel, state: SeriesState, focus: ScreenFocus, vm: SeriesViewModel, onOpen: (Screen) -> Unit) {
|
||||
// Opened on an episode (a new one, continue watching): it is focused and shown first, else the next one to watch.
|
||||
val start = remember(item.id) { item.episodes.indexOfFirst { it.id == screen.episodeId }.takeIf { it >= 0 } }
|
||||
val first = start ?: item.nextUp
|
||||
val selection = rememberSaveable(saver = SeriesSelection.Saver) { SeriesSelection(item.episodes.getOrNull(first)?.season ?: 0, first) }
|
||||
// The data can change on reload (a new episode): stay in range.
|
||||
val seasonIndex = selection.season.coerceIn(0, (item.seasons.size - 1).coerceAtLeast(0))
|
||||
|
||||
val episodesRow = rememberLazyListState(initialFirstVisibleItemIndex = item.episodes.getOrNull(first)?.rowIndex?.minus(1)?.coerceAtLeast(0) ?: 0)
|
||||
val chipsRow = rememberLazyListState(initialFirstVisibleItemIndex = (seasonIndex - 1).coerceAtLeast(0))
|
||||
val scope = rememberCoroutineScope()
|
||||
var trayOpen by rememberSaveable { mutableStateOf(false) }
|
||||
val tracksButton = remember { FocusRequester() }
|
||||
|
||||
/** Focuses the element [key], scrolling [list] to [index] first when it isn't on screen. */
|
||||
fun moveFocus(key: String, list: LazyListState? = null, index: Int = 0) {
|
||||
scope.launch {
|
||||
if (list != null && list.layoutInfo.visibleItemsInfo.none { it.index == index }) list.scrollToItem((index - 1).coerceAtLeast(0))
|
||||
focus.lastKey = key
|
||||
// One frame to compose the element with its new requester, one to lay it out.
|
||||
withFrameNanos { }
|
||||
withFrameNanos { }
|
||||
focus.requestFocus()
|
||||
}
|
||||
}
|
||||
|
||||
fun focusChip(season: Int) = item.seasons.getOrNull(season)?.let { moveFocus(it.key, chipsRow, season) }
|
||||
|
||||
fun focusEpisode(index: Int) = item.episodes.getOrNull(index)?.let { moveFocus(it.key, episodesRow, it.rowIndex) }
|
||||
|
||||
fun onSeasonFocused(season: Int) {
|
||||
if (season == selection.season) return
|
||||
selection.season = season
|
||||
val s = item.seasons[season]
|
||||
selection.episode = s.target
|
||||
// The row follows the chips: the season's divider (or its next episode, deeper in the season) at the start.
|
||||
val target = if (s.target == s.first) s.dividerIndex else item.episodes[s.target].rowIndex - 1
|
||||
scope.launch { episodesRow.animateScrollToItem(target.coerceAtLeast(0)) }
|
||||
}
|
||||
|
||||
fun onEpisodeFocused(index: Int) {
|
||||
selection.episode = index
|
||||
val season = item.episodes[index].season
|
||||
if (season != selection.season) {
|
||||
selection.season = season
|
||||
if (chipsRow.layoutInfo.visibleItemsInfo.none { it.index == season }) scope.launch { chipsRow.animateScrollToItem((season - 1).coerceAtLeast(0)) }
|
||||
}
|
||||
}
|
||||
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
item.backdropUrl?.let { Backdrop(it) }
|
||||
Header(
|
||||
item,
|
||||
state,
|
||||
focus,
|
||||
tracksButton,
|
||||
isDefault = start == null,
|
||||
onPlay = { item.episodes.getOrNull(item.nextUp)?.let { onOpen(vm.player(it)) } },
|
||||
onTracks = { trayOpen = true },
|
||||
onFavorite = vm::toggleFavorite,
|
||||
modifier = Modifier.onPreviewKeyEvent { e -> e.isDown(AndroidKeyEvent.KEYCODE_DPAD_DOWN).also { if (it) focusChip(seasonIndex) } },
|
||||
)
|
||||
SeasonMap(
|
||||
item,
|
||||
seasonIndex,
|
||||
current = { selection.episode },
|
||||
modifier = Modifier.offset(y = 183.dp).padding(horizontal = AppDimens.CONTENT_START.dp).fillMaxWidth(),
|
||||
)
|
||||
SeasonChips(
|
||||
item,
|
||||
seasonIndex,
|
||||
chipsRow,
|
||||
focus,
|
||||
onFocused = ::onSeasonFocused,
|
||||
onClick = { focusEpisode(item.seasons[it].target) },
|
||||
modifier = Modifier
|
||||
.offset(y = 220.dp)
|
||||
.onPreviewKeyEvent { e ->
|
||||
when {
|
||||
e.isDown(AndroidKeyEvent.KEYCODE_DPAD_DOWN) -> true.also { focusEpisode(item.seasons[seasonIndex].target) }
|
||||
e.isDown(AndroidKeyEvent.KEYCODE_DPAD_UP) -> true.also { moveFocus(KEY_PLAY) }
|
||||
else -> false
|
||||
}
|
||||
},
|
||||
)
|
||||
EpisodeRow(
|
||||
item,
|
||||
episodesRow,
|
||||
focus,
|
||||
defaultKey = start?.let { item.episodes[it].key },
|
||||
onFocused = ::onEpisodeFocused,
|
||||
onClick = { onOpen(vm.player(it)) },
|
||||
modifier = Modifier
|
||||
.offset(y = 254.dp)
|
||||
.onPreviewKeyEvent { e -> e.isDown(AndroidKeyEvent.KEYCODE_DPAD_UP).also { if (it) focusChip(item.episodes[selection.episode.coerceIn(item.episodes.indices)].season) } },
|
||||
)
|
||||
SelectedEpisode(item, selection, Modifier.align(Alignment.BottomStart))
|
||||
if (trayOpen) {
|
||||
val style by vm.subtitleStyle.collectAsStateWithLifecycle()
|
||||
fun close() {
|
||||
trayOpen = false
|
||||
// Its own requester: when the tray goes, the system first focuses the page's first button, which
|
||||
// would also take over the remembered key.
|
||||
scope.launch {
|
||||
withFrameNanos { }
|
||||
runCatching { tracksButton.requestFocus() }
|
||||
}
|
||||
}
|
||||
BackHandler { close() }
|
||||
SeriesTray(item.title, state, style, vm, onClose = ::close, modifier = Modifier.align(Alignment.BottomCenter))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun KeyEvent.isDown(code: Int) = type == KeyEventType.KeyDown && key.nativeKeyCode == code
|
||||
|
||||
private val TitleStyle = AppType.Display.copy(fontSize = 36.sp, lineHeight = 38.sp, letterSpacing = (-0.03).em)
|
||||
|
||||
@Composable
|
||||
private fun Header(
|
||||
item: SeriesModel,
|
||||
state: SeriesState,
|
||||
focus: ScreenFocus,
|
||||
tracksButton: FocusRequester,
|
||||
isDefault: Boolean,
|
||||
onPlay: () -> Unit,
|
||||
onTracks: () -> Unit,
|
||||
onFavorite: () -> Unit,
|
||||
modifier: Modifier,
|
||||
) {
|
||||
val left = AppDimens.CONTENT_START.dp
|
||||
RemoteImage(
|
||||
item.poster,
|
||||
8.dp,
|
||||
Modifier
|
||||
.offset(left, 32.dp)
|
||||
.size(SeriesLayout.POSTER_W.dp, SeriesLayout.POSTER_H.dp)
|
||||
.border(0.5.dp, Color(0x1AFFFFFF), RoundedCornerShape(8.dp)),
|
||||
)
|
||||
Column(Modifier.padding(start = left + SeriesLayout.POSTER_W.dp + 20.dp, end = left, top = 32.dp)) {
|
||||
BasicText(item.label, style = AppType.MonoLabel)
|
||||
BasicText(item.title, Modifier.padding(top = 9.dp), style = TitleStyle, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
Meta(item, state.badges, Modifier.padding(top = 9.dp))
|
||||
Row(modifier.padding(top = 13.dp), horizontalArrangement = Arrangement.spacedBy(7.dp)) {
|
||||
PrimaryButton(item.playLabel, onPlay, Modifier.remembered(focus, KEY_PLAY, isDefault = isDefault))
|
||||
if (state.audioLabel != null || state.subtitleLabel != null) {
|
||||
TracksButton(state.audioLabel, state.subtitleLabel, onTracks, Modifier.focusRequester(tracksButton).remembered(focus, KEY_TRACKS))
|
||||
}
|
||||
CircleButton(
|
||||
if (item.favorite) AppIcon.HeartFilled else AppIcon.Heart,
|
||||
onFavorite,
|
||||
Modifier.remembered(focus, "favorite"),
|
||||
tint = if (item.favorite) AppColors.Pink else Color(0xFFE3E5F2),
|
||||
)
|
||||
// The whole series at once, so through the menu (it says how many episodes) rather than a toggle.
|
||||
val menu = LocalWatchMenu.current
|
||||
val watchedButton = remember { FocusRequester() }
|
||||
CircleButton(
|
||||
AppIcon.Check,
|
||||
{ menu?.open(item.watch, watchedButton) },
|
||||
Modifier.focusRequester(watchedButton).remembered(focus, KEY_WATCHED),
|
||||
tint = if (item.watch.played) AppColors.Green else Color(0xFFE3E5F2),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** "★ 8.4 16+ 2022 Фантастика, Драма 4K HDR10 Atmos" */
|
||||
@Composable
|
||||
private fun Meta(item: SeriesModel, badges: List<String>, modifier: Modifier) {
|
||||
val text = AppType.Body.copy(fontSize = 11.5.sp, lineHeight = 12.sp, color = AppColors.TextSecondary)
|
||||
Row(modifier, verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(9.dp)) {
|
||||
item.rating?.let { BasicText(it, style = text.copy(color = AppColors.Yellow, fontWeight = FontWeight.SemiBold)) }
|
||||
item.age?.let {
|
||||
BasicText(
|
||||
it,
|
||||
Modifier.border(0.5.dp, Color(0xFF4A4F68), RoundedCornerShape(3.dp)).padding(horizontal = 5.dp, vertical = 2.dp),
|
||||
style = text.copy(fontSize = 10.sp, lineHeight = 10.sp),
|
||||
)
|
||||
}
|
||||
listOfNotNull(item.year, item.genres).forEach { BasicText(it, style = text, maxLines = 1, overflow = TextOverflow.Ellipsis) }
|
||||
if (badges.isNotEmpty()) Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { badges.forEach { FormatBadge(it) } }
|
||||
}
|
||||
}
|
||||
|
||||
private val TracksFocus = FocusStyle(scale = 1.05f, corner = 21.dp, glows = FocusStyle.ButtonGlows)
|
||||
|
||||
/** "● Русский 5.1 ● без субтитров": opens the tray. */
|
||||
@Composable
|
||||
private fun TracksButton(audio: String?, subtitle: String?, onClick: () -> Unit, modifier: Modifier) {
|
||||
var focused by remember { mutableStateOf(false) }
|
||||
val shape = RoundedCornerShape(21.dp)
|
||||
val color = if (focused) AppColors.OnFocusFill else Color(0xFFE3E5F2)
|
||||
Row(
|
||||
modifier
|
||||
.focusCard(TracksFocus)
|
||||
.height(42.dp)
|
||||
.background(if (focused) AppColors.FocusFill else AppColors.SurfaceRaised, shape)
|
||||
.border(0.5.dp, if (focused) AppColors.FocusFill else AppColors.Border, shape)
|
||||
.onFocusChanged { focused = it.isFocused }
|
||||
.clickable(interactionSource = null, indication = null, onClick = onClick)
|
||||
.padding(horizontal = 14.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
val style = AppType.Button.copy(fontSize = 12.sp, fontWeight = FontWeight.Normal, color = color)
|
||||
audio?.let {
|
||||
Box(Modifier.size(5.dp).background(AppColors.Pink, CircleShape))
|
||||
BasicText(it, Modifier.padding(start = 6.dp), style = style, maxLines = 1)
|
||||
}
|
||||
subtitle?.let {
|
||||
Box(Modifier.padding(start = if (audio != null) 10.dp else 0.dp).size(5.dp).background(AppColors.Yellow, CircleShape))
|
||||
BasicText(it, Modifier.padding(start = 6.dp), style = style, maxLines = 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private object MapColors {
|
||||
val Watched = Color(0xFF3F8F63)
|
||||
val Empty = Color(0xFF33384D)
|
||||
val Label = Color(0xFF6F7592)
|
||||
}
|
||||
|
||||
/**
|
||||
* Season map: a bar per episode — watched green, next violet, new outlined pink — the selected episode tall and
|
||||
* white. Bars shrink for long seasons; if the series still doesn't fit, the seasons around the selected one are shown.
|
||||
*/
|
||||
@Composable
|
||||
private fun SeasonMap(item: SeriesModel, selectedSeason: Int, current: () -> Int, modifier: Modifier) {
|
||||
val layout = remember(item.seasons, selectedSeason) { mapLayout(item.seasons, selectedSeason) }
|
||||
Row(modifier.height(MAP_H.dp), verticalAlignment = Alignment.Bottom) {
|
||||
Row(Modifier.width(MAP_W.dp), horizontalArrangement = Arrangement.spacedBy(MAP_GAP.dp), verticalAlignment = Alignment.Bottom) {
|
||||
if (layout.from > 0) BasicText("…", style = AppType.MonoCaption.copy(color = MapColors.Label))
|
||||
for (s in layout.from until layout.to) {
|
||||
val season = item.seasons[s]
|
||||
val width = (layout.bar + layout.gap) * season.count - layout.gap
|
||||
Column {
|
||||
BasicText(
|
||||
season.mapLabel,
|
||||
style = AppType.MonoCaption.copy(fontSize = 8.5.sp, color = if (s == selectedSeason) AppColors.Text else MapColors.Label),
|
||||
maxLines = 1,
|
||||
)
|
||||
// The selected episode is a separate marker moved by its layer: the bars stay one baked bitmap.
|
||||
Box(Modifier.padding(top = 4.dp).size(width.coerceAtLeast(1f).dp, BAR_TALL.dp)) {
|
||||
Spacer(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.drawWithCache {
|
||||
// Baked once: hundreds of 1 px outline sides drawn every frame cost ~9 ms of GPU on
|
||||
// the test TV (it redraws the whole screen each frame), one bitmap almost nothing.
|
||||
val image = ImageBitmap(size.width.toInt().coerceAtLeast(1), size.height.toInt().coerceAtLeast(1))
|
||||
CanvasDrawScope().draw(this, layoutDirection, Canvas(image), size) { drawBars(item, season, layout) }
|
||||
onDrawBehind { drawImage(image) }
|
||||
},
|
||||
)
|
||||
Spacer(
|
||||
Modifier
|
||||
.size(layout.bar.dp, BAR_TALL.dp)
|
||||
.graphicsLayer {
|
||||
val i = current() - season.first
|
||||
alpha = if (i in 0 until season.count) 1f else 0f
|
||||
translationX = i.coerceAtLeast(0) * floor((layout.bar + layout.gap).dp.toPx()).coerceAtLeast(floor(layout.bar.dp.toPx()).coerceAtLeast(1f) + 1f)
|
||||
}
|
||||
.drawBehind { drawRect(Color.White, size = Size(floor(size.width).coerceAtLeast(1f), size.height)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (layout.to < item.seasons.size) BasicText("…", style = AppType.MonoCaption.copy(color = MapColors.Label))
|
||||
}
|
||||
Spacer(Modifier.weight(1f))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(9.dp)) {
|
||||
val legend = AppType.MonoCaption
|
||||
BasicText("■ просмотрено", style = legend.copy(color = AppColors.Green))
|
||||
BasicText("■ следующая", style = legend.copy(color = AppColors.Accent))
|
||||
BasicText("□ новая", style = legend.copy(color = AppColors.Pink))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Bars of one season: plain rectangles, outlines as four 1 px sides (rounded or stroked shapes are tessellated one by one). */
|
||||
private fun DrawScope.drawBars(item: SeriesModel, season: SeriesSeason, layout: MapLayout) {
|
||||
val bar = floor(layout.bar.dp.toPx()).coerceAtLeast(1f)
|
||||
val step = floor((layout.bar + layout.gap).dp.toPx()).coerceAtLeast(bar + 1f)
|
||||
val low = floor(BAR_H.dp.toPx())
|
||||
val top = size.height - low
|
||||
fun outline(color: Color, x: Float) {
|
||||
drawRect(color, Offset(x, top), Size(bar, 1f))
|
||||
drawRect(color, Offset(x, size.height - 1f), Size(bar, 1f))
|
||||
drawRect(color, Offset(x, top), Size(1f, low))
|
||||
drawRect(color, Offset(x + bar - 1f, top), Size(1f, low))
|
||||
}
|
||||
for (i in 0 until season.count) {
|
||||
val index = season.first + i
|
||||
val x = i * step
|
||||
when (item.episodes[index].state) {
|
||||
EpisodeState.Watched -> drawRect(MapColors.Watched, Offset(x, top), Size(bar, low))
|
||||
EpisodeState.InProgress, EpisodeState.Next -> drawRect(AppColors.Accent, Offset(x, top), Size(bar, low))
|
||||
EpisodeState.New -> outline(AppColors.Pink, x)
|
||||
EpisodeState.None -> if (bar >= 3f) outline(MapColors.Empty, x) else drawRect(MapColors.Empty, Offset(x, top), Size(bar, low))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private const val MAP_W = 600f
|
||||
private const val MAP_H = 32f
|
||||
private const val MAP_GAP = 14f
|
||||
private const val BAR_H = 10f
|
||||
private const val BAR_TALL = 17f
|
||||
/** Width (dp) of one character of the map labels (JetBrains Mono advance is 0.6 em). */
|
||||
private const val LABEL_CHAR = 8.5f * 0.6f
|
||||
|
||||
private data class MapLayout(val bar: Float, val gap: Float, val from: Int, val to: Int)
|
||||
|
||||
/** Bar size that fits all seasons in [MAP_W], else the widest window of seasons around [selected] at the smallest size. */
|
||||
private fun mapLayout(seasons: List<SeriesSeason>, selected: Int): MapLayout {
|
||||
if (seasons.isEmpty()) return MapLayout(7f, 2f, 0, 0)
|
||||
fun width(s: SeriesSeason, bar: Float, gap: Float) = maxOf(s.count * (bar + gap) - gap, s.mapLabel.length * LABEL_CHAR)
|
||||
fun total(range: IntRange, bar: Float, gap: Float) = range.sumOf { width(seasons[it], bar, gap).toDouble() }.toFloat() + MAP_GAP * (range.count() - 1)
|
||||
for ((bar, gap) in listOf(7f to 2f, 5f to 1.5f, 3f to 1f, 2f to 1f)) {
|
||||
if (total(seasons.indices, bar, gap) <= MAP_W) return MapLayout(bar, gap, 0, seasons.size)
|
||||
}
|
||||
val (bar, gap) = 2f to 1f
|
||||
// Room for the "…" marks at both ends.
|
||||
val room = MAP_W - 2 * (MAP_GAP + LABEL_CHAR)
|
||||
var from = selected.coerceIn(seasons.indices)
|
||||
var to = from + 1
|
||||
while (true) {
|
||||
val grown = when {
|
||||
to < seasons.size && total(from..to, bar, gap) <= room -> from until to + 1
|
||||
from > 0 && total(from - 1 until to, bar, gap) <= room -> from - 1 until to
|
||||
else -> break
|
||||
}
|
||||
from = grown.first
|
||||
to = grown.last + 1
|
||||
}
|
||||
// A single season longer than the map: its bars get thinner still.
|
||||
val single = if (to - from == 1) (room / seasons[from].count).coerceIn(1f, bar + gap) else bar + gap
|
||||
return if (to - from == 1 && single < bar + gap) MapLayout(single * 0.6f, single * 0.4f, from, to) else MapLayout(bar, gap, from, to)
|
||||
}
|
||||
|
||||
private val SeasonChipFocus = FocusStyle(scale = 1f, corner = 15.dp, glows = FocusStyle.ButtonGlows)
|
||||
|
||||
@Composable
|
||||
private fun SeasonChips(
|
||||
item: SeriesModel,
|
||||
selected: Int,
|
||||
state: LazyListState,
|
||||
focus: ScreenFocus,
|
||||
onFocused: (Int) -> Unit,
|
||||
onClick: (Int) -> Unit,
|
||||
modifier: Modifier,
|
||||
) {
|
||||
MinimalScrolling(margin = 16.dp) {
|
||||
LazyRow(
|
||||
modifier,
|
||||
state = state,
|
||||
contentPadding = PaddingValues(horizontal = AppDimens.CONTENT_START.dp, vertical = 6.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
itemsIndexed(item.seasons, key = { _, s -> s.key }) { i, s ->
|
||||
SeasonChip(
|
||||
s,
|
||||
i == selected,
|
||||
onClick = { onClick(i) },
|
||||
modifier = Modifier
|
||||
.remembered(focus, s.key)
|
||||
.onFocusChanged { if (it.isFocused) onFocused(i) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SeasonChip(season: SeriesSeason, selected: Boolean, onClick: () -> Unit, modifier: Modifier) {
|
||||
var focused by remember { mutableStateOf(false) }
|
||||
val shape = RoundedCornerShape(15.dp)
|
||||
val color = when {
|
||||
focused -> AppColors.OnFocusFill
|
||||
selected -> AppColors.Text
|
||||
else -> AppColors.TextSecondary
|
||||
}
|
||||
Row(
|
||||
modifier
|
||||
.focusCard(SeasonChipFocus)
|
||||
.height(30.dp)
|
||||
.background(if (focused) AppColors.FocusFill else if (selected) Color(0xFF211D4A) else AppColors.SurfaceRaised, shape)
|
||||
.border(0.5.dp, if (focused) AppColors.FocusFill else if (selected) Color(0xFF7A6CFF) else AppColors.Border, shape)
|
||||
.onFocusChanged { focused = it.isFocused }
|
||||
.clickableWithMenu(season.watch, onClick)
|
||||
.padding(horizontal = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
BasicText(season.label, style = AppType.Button.copy(color = color), maxLines = 1)
|
||||
BasicText(season.stat, style = AppType.MonoCaption.copy(color = color.copy(alpha = 0.75f)), maxLines = 1)
|
||||
}
|
||||
}
|
||||
|
||||
private val EpisodeFocus = FocusStyle(scale = 1.06f, corner = 8.dp)
|
||||
|
||||
/** Every episode of the series, a "СЕЗОН N" divider before each season. */
|
||||
@Composable
|
||||
private fun EpisodeRow(
|
||||
item: SeriesModel,
|
||||
state: LazyListState,
|
||||
focus: ScreenFocus,
|
||||
defaultKey: String?,
|
||||
onFocused: (Int) -> Unit,
|
||||
onClick: (SeriesEpisode) -> Unit,
|
||||
modifier: Modifier,
|
||||
) {
|
||||
MinimalScrolling(margin = 18.dp) {
|
||||
LazyRow(
|
||||
modifier,
|
||||
state = state,
|
||||
contentPadding = PaddingValues(start = AppDimens.CONTENT_START.dp, end = AppDimens.CONTENT_START.dp, top = 12.dp, bottom = 12.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
for (season in item.seasons) {
|
||||
item(key = "div/${season.key}", contentType = "divider") { SeasonDivider(season.label.uppercase()) }
|
||||
itemsIndexed(item.episodes.subList(season.first, season.first + season.count), key = { _, e -> e.key }, contentType = { _, _ -> "episode" }) { i, e ->
|
||||
val index = season.first + i
|
||||
EpisodeCard(
|
||||
e,
|
||||
onClick = { onClick(e) },
|
||||
modifier = Modifier
|
||||
.remembered(focus, e.key, isDefault = e.key == defaultKey)
|
||||
.onFocusChanged { if (it.isFocused) onFocused(index) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Vertical "СЕЗОН 2" on a dashed line. */
|
||||
@Composable
|
||||
private fun SeasonDivider(label: String) {
|
||||
Box(
|
||||
Modifier
|
||||
.size(22.dp, SeriesLayout.EPISODE_H.dp)
|
||||
.drawBehind {
|
||||
// Dashes as plain rectangles (a dashed path effect is a path, slow on this GPU).
|
||||
val dash = 3.dp.toPx().roundToInt().toFloat()
|
||||
var y = 0f
|
||||
while (y < size.height) {
|
||||
drawRect(MapColors.Empty, Offset(0f, y), Size(1f, dash.coerceAtMost(size.height - y)))
|
||||
y += dash * 2
|
||||
}
|
||||
},
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
BasicText(
|
||||
label,
|
||||
Modifier.wrapContentSize(unbounded = true).rotate(-90f),
|
||||
style = AppType.MonoCaption.copy(letterSpacing = 0.14.em),
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EpisodeCard(e: SeriesEpisode, onClick: () -> Unit, modifier: Modifier) {
|
||||
Column(Modifier.width(SeriesLayout.EPISODE_W.dp)) {
|
||||
Box(
|
||||
modifier
|
||||
.focusCard(EpisodeFocus)
|
||||
.size(SeriesLayout.EPISODE_W.dp, SeriesLayout.EPISODE_H.dp)
|
||||
.clickableWithMenu(e.watch, onClick),
|
||||
) {
|
||||
RemoteImage(e.image, 8.dp, Modifier.fillMaxSize())
|
||||
BasicText(
|
||||
e.code,
|
||||
Modifier.padding(start = 8.dp, top = 6.dp),
|
||||
style = AppType.MonoMeta.copy(fontSize = 18.sp, fontWeight = FontWeight.SemiBold, color = Color(0xE0FFFFFF)),
|
||||
)
|
||||
badge(e)?.let { (text, color) ->
|
||||
BasicText(
|
||||
text,
|
||||
Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.padding(7.dp)
|
||||
.background(Color(0xBF0A0B10), RoundedCornerShape(3.5.dp))
|
||||
.border(0.5.dp, color, RoundedCornerShape(3.5.dp))
|
||||
.padding(horizontal = 5.dp, vertical = 2.dp),
|
||||
style = AppType.MonoBadge.copy(fontSize = 8.5.sp, color = color),
|
||||
)
|
||||
}
|
||||
e.progress?.let {
|
||||
ProgressLine(it, Modifier.align(Alignment.BottomStart).padding(horizontal = 6.dp, vertical = 4.dp).fillMaxWidth().height(3.dp))
|
||||
}
|
||||
}
|
||||
BasicText(
|
||||
e.title,
|
||||
Modifier.padding(top = 7.dp),
|
||||
style = AppType.PosterTitle.copy(fontSize = 12.sp, color = if (e.state == EpisodeState.Watched) AppColors.TextMuted else AppColors.Text),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
e.meta?.let { BasicText(it, Modifier.padding(top = 3.dp), style = AppType.MonoCaption, maxLines = 1) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun badge(e: SeriesEpisode): Pair<String, Color>? = when (e.state) {
|
||||
EpisodeState.Watched -> "✓" to AppColors.Green
|
||||
EpisodeState.InProgress -> "${((e.progress ?: 0f) * 100).roundToInt()}%" to AppColors.Accent
|
||||
EpisodeState.Next -> "далее" to AppColors.Accent
|
||||
EpisodeState.New -> "новая" to AppColors.Pink
|
||||
EpisodeState.None -> null
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SelectedEpisode(item: SeriesModel, selection: SeriesSelection, modifier: Modifier) {
|
||||
item.episodes.getOrNull(selection.episode.coerceIn(0, (item.episodes.size - 1).coerceAtLeast(0)))?.let { EpisodeDetail(it, modifier) }
|
||||
}
|
||||
|
||||
/** The selected episode under the row: "СЕЗОН 2 · СЕРИЯ 4", title, description, duration and air date. */
|
||||
@Composable
|
||||
private fun EpisodeDetail(e: SeriesEpisode, modifier: Modifier) {
|
||||
Row(
|
||||
modifier
|
||||
.padding(horizontal = AppDimens.CONTENT_START.dp)
|
||||
.padding(bottom = 22.dp)
|
||||
.fillMaxWidth()
|
||||
.height(66.dp)
|
||||
.drawBehind { drawRect(AppColors.BorderSubtle, size = Size(size.width, 1f)) }
|
||||
.padding(top = 11.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(14.dp),
|
||||
) {
|
||||
BasicText(e.longCode, Modifier.width(126.dp).padding(top = 3.dp), style = AppType.MonoLabel.copy(letterSpacing = 0.06.em))
|
||||
Column(Modifier.weight(1f)) {
|
||||
BasicText(e.title, style = AppType.Section.copy(fontSize = 14.sp), maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
e.overview?.let {
|
||||
BasicText(
|
||||
it,
|
||||
Modifier.padding(top = 4.dp),
|
||||
style = AppType.Body.copy(fontSize = 11.sp, lineHeight = 15.sp),
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
Column(Modifier.padding(top = 2.dp)) {
|
||||
val style = AppType.MonoMeta.copy(fontSize = 10.sp, color = AppColors.TextDim, textAlign = TextAlign.End)
|
||||
e.duration?.let { BasicText(it, Modifier.align(Alignment.End), style = style) }
|
||||
e.airDate?.let { BasicText(it, Modifier.align(Alignment.End).padding(top = 5.dp), style = style) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
package ru.stenfredd.sleepyowl.ui.series
|
||||
|
||||
import android.view.KeyEvent as AndroidKeyEvent
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.focusGroup
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.RowScope
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
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.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.BasicText
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.withFrameNanos
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.drawBehind
|
||||
import androidx.compose.ui.focus.FocusDirection
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusProperties
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.key.KeyEventType
|
||||
import androidx.compose.ui.input.key.key
|
||||
import androidx.compose.ui.input.key.nativeKeyCode
|
||||
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
||||
import androidx.compose.ui.input.key.type
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.em
|
||||
import ru.stenfredd.sleepyowl.playback.api.SubtitleStyle
|
||||
import ru.stenfredd.sleepyowl.ui.components.MinimalScrolling
|
||||
import ru.stenfredd.sleepyowl.ui.player.PanelChips
|
||||
import ru.stenfredd.sleepyowl.ui.player.PanelList
|
||||
import ru.stenfredd.sleepyowl.ui.player.PanelSection
|
||||
import ru.stenfredd.sleepyowl.ui.player.SubtitleChoices
|
||||
import ru.stenfredd.sleepyowl.ui.player.toChoices
|
||||
import ru.stenfredd.sleepyowl.ui.theme.AppColors
|
||||
import ru.stenfredd.sleepyowl.ui.theme.AppType
|
||||
|
||||
/**
|
||||
* "Аудио и субтитры" tray at the bottom of the series page: audio, subtitles and their look, applied at once and
|
||||
* remembered for the whole series (the tracks are those of the next episode's file; other episodes get the closest
|
||||
* match). Opaque, without the mockup's full-screen dimming, which is expensive on weak TV GPUs.
|
||||
*
|
||||
* Focus stays inside; ↓ past the bottom or Back closes it.
|
||||
*/
|
||||
@Composable
|
||||
internal fun SeriesTray(
|
||||
title: String,
|
||||
state: SeriesState,
|
||||
style: SubtitleStyle,
|
||||
vm: SeriesViewModel,
|
||||
onClose: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val initial = remember { FocusRequester() }
|
||||
LaunchedEffect(Unit) {
|
||||
withFrameNanos { }
|
||||
runCatching { initial.requestFocus() }
|
||||
}
|
||||
// Back went down inside the tray; it acts on release.
|
||||
val backDown = remember { booleanArrayOf(false) }
|
||||
Column(
|
||||
modifier
|
||||
.fillMaxWidth()
|
||||
.height(TRAY_H.dp)
|
||||
.background(AppColors.Background)
|
||||
.drawBehind { drawRect(Color(0xFF262A3A), size = Size(size.width, 1f)) }
|
||||
// Back acts on release, as Android does: closing on the press would send the release to the page, which
|
||||
// would leave it.
|
||||
.onPreviewKeyEvent {
|
||||
val code = it.key.nativeKeyCode
|
||||
if (code != AndroidKeyEvent.KEYCODE_BACK && code != AndroidKeyEvent.KEYCODE_ESCAPE) return@onPreviewKeyEvent false
|
||||
when (it.type) {
|
||||
KeyEventType.KeyDown -> backDown[0] = true
|
||||
KeyEventType.KeyUp -> if (backDown[0]) onClose()
|
||||
}
|
||||
true
|
||||
}
|
||||
.focusProperties {
|
||||
onExit = {
|
||||
if (requestedFocusDirection == FocusDirection.Down) onClose()
|
||||
cancelFocusChange()
|
||||
}
|
||||
}
|
||||
.focusGroup()
|
||||
.padding(start = 48.dp, end = 48.dp, top = 12.dp, bottom = 22.dp),
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(7.dp)) {
|
||||
Box(Modifier.size(6.dp).background(AppColors.Accent, CircleShape))
|
||||
BasicText(
|
||||
"АУДИО И СУБТИТРЫ · " + title.uppercase(),
|
||||
Modifier.weight(1f),
|
||||
style = AppType.MonoLabel.copy(color = AppColors.TextSecondary, letterSpacing = 0.1.em),
|
||||
maxLines = 1,
|
||||
)
|
||||
BasicText("Запоминается для всего сериала · Назад или ▼ — закрыть", style = AppType.MonoCaption.copy(color = Color(0xFF6F7592)))
|
||||
}
|
||||
Row(Modifier.padding(top = 10.dp).fillMaxWidth().weight(1f), horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
val audioFirst = state.audio.isNotEmpty()
|
||||
if (state.audio.isNotEmpty()) {
|
||||
TrayColumn("АУДИО", AppColors.Pink) {
|
||||
PanelList(state.audio.toChoices(state.selectedAudio), initial) { c ->
|
||||
vm.selectAudio(state.audio.firstOrNull { it.key == c.key }?.index)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (state.subtitles.isNotEmpty()) {
|
||||
TrayColumn("СУБТИТРЫ", AppColors.Yellow) {
|
||||
PanelList(state.subtitles.toChoices(state.selectedSubtitle), if (audioFirst) null else initial) { c ->
|
||||
vm.selectSubtitle(state.subtitles.firstOrNull { it.key == c.key }?.index)
|
||||
}
|
||||
}
|
||||
}
|
||||
TrayColumn("ВИД СУБТИТРОВ", AppColors.Yellow) {
|
||||
PanelSection("РАЗМЕР") { PanelChips(SubtitleChoices.sizes(style)) { vm.setSubtitleStyle(SubtitleChoices.apply(style, it)) } }
|
||||
PanelSection("ЦВЕТ") { PanelChips(SubtitleChoices.colors(style)) { vm.setSubtitleStyle(SubtitleChoices.apply(style, it)) } }
|
||||
PanelSection("ФОН") { PanelChips(SubtitleChoices.backgrounds(style)) { vm.setSubtitleStyle(SubtitleChoices.apply(style, it)) } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private const val TRAY_H = 262f
|
||||
|
||||
@Composable
|
||||
private fun RowScope.TrayColumn(title: String, dot: Color, spacing: Dp = 4.dp, content: @Composable ColumnScope.() -> Unit) {
|
||||
val shape = RoundedCornerShape(9.dp)
|
||||
Column(
|
||||
Modifier
|
||||
.weight(1f)
|
||||
.fillMaxHeight()
|
||||
.background(Color(0xFF10121A), shape)
|
||||
.border(0.5.dp, Color(0xFF262A3A), shape),
|
||||
) {
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(25.dp)
|
||||
.background(Color(0xFF181B27), RoundedCornerShape(topStart = 9.dp, topEnd = 9.dp))
|
||||
.padding(horizontal = 9.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(5.dp),
|
||||
) {
|
||||
Box(Modifier.size(5.dp).background(dot, CircleShape))
|
||||
BasicText(title, style = AppType.MonoCaption.copy(color = AppColors.TextMuted, letterSpacing = 0.1.em))
|
||||
}
|
||||
MinimalScrolling(margin = 10.dp) {
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(spacing),
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,419 @@
|
||||
package ru.stenfredd.sleepyowl.ui.series
|
||||
|
||||
import android.os.SystemClock
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import ru.stenfredd.sleepyowl.core.data.ImageUrls
|
||||
import ru.stenfredd.sleepyowl.core.data.LibraryRepository
|
||||
import ru.stenfredd.sleepyowl.core.data.SessionCache
|
||||
import ru.stenfredd.sleepyowl.core.data.WatchRepository
|
||||
import ru.stenfredd.sleepyowl.core.data.model.ItemDetails
|
||||
import ru.stenfredd.sleepyowl.core.data.model.ItemKind
|
||||
import ru.stenfredd.sleepyowl.core.data.model.ItemSummary
|
||||
import ru.stenfredd.sleepyowl.core.data.model.MediaInfo
|
||||
import ru.stenfredd.sleepyowl.playback.api.SubtitleStyle
|
||||
import ru.stenfredd.sleepyowl.playback.core.PlaybackPreferences
|
||||
import ru.stenfredd.sleepyowl.ui.Screen
|
||||
import ru.stenfredd.sleepyowl.ui.components.RemoteImageSpec
|
||||
import ru.stenfredd.sleepyowl.ui.details.MediaLabels
|
||||
import ru.stenfredd.sleepyowl.ui.details.TrackChoices
|
||||
import ru.stenfredd.sleepyowl.ui.details.TrackOption
|
||||
import ru.stenfredd.sleepyowl.ui.menu.WatchTarget
|
||||
import ru.stenfredd.sleepyowl.ui.model.CardMapper
|
||||
import ru.stenfredd.sleepyowl.ui.player.PlayerLabels
|
||||
import ru.stenfredd.sleepyowl.ui.model.plural
|
||||
import timber.log.Timber
|
||||
import java.time.Instant
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.util.Locale
|
||||
import java.util.UUID
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/** Where an episode is in watching: the season map colours and the card's corner badge. */
|
||||
enum class EpisodeState { Watched, InProgress, Next, New, None }
|
||||
|
||||
@Immutable
|
||||
data class SeriesEpisode(
|
||||
val key: String,
|
||||
val id: UUID,
|
||||
/** Index of its season in [SeriesModel.seasons]. */
|
||||
val season: Int,
|
||||
/** Position in the episode row, which has a divider before every season. */
|
||||
val rowIndex: Int,
|
||||
/** "S2·E04" */
|
||||
val code: String,
|
||||
/** "СЕЗОН 2 · СЕРИЯ 4" */
|
||||
val longCode: String,
|
||||
val title: String,
|
||||
/** "56 мин · 13.06.2023" */
|
||||
val meta: String?,
|
||||
val duration: String?,
|
||||
val airDate: String?,
|
||||
val overview: String?,
|
||||
val image: RemoteImageSpec?,
|
||||
val progress: Float?,
|
||||
val state: EpisodeState,
|
||||
val resumeMs: Long,
|
||||
/** What a long press on the card marks watched. */
|
||||
val watch: WatchTarget,
|
||||
)
|
||||
|
||||
@Immutable
|
||||
data class SeriesSeason(
|
||||
val key: String,
|
||||
/** "Сезон 2", or the server's name for specials. */
|
||||
val label: String,
|
||||
/** "✓", "новый", "3/10" */
|
||||
val stat: String,
|
||||
/** "S2 · 3/10" over the season map. */
|
||||
val mapLabel: String,
|
||||
/** Its episodes are [first] until [first] + [count] in [SeriesModel.episodes]. */
|
||||
val first: Int,
|
||||
val count: Int,
|
||||
/** The episode ↓ from the season chip goes to: started, next, first unwatched, else the first. */
|
||||
val target: Int,
|
||||
/** Row index of the season's divider. */
|
||||
val dividerIndex: Int,
|
||||
/** The whole season, for a long press on its chip. */
|
||||
val watch: WatchTarget,
|
||||
)
|
||||
|
||||
@Immutable
|
||||
data class SeriesModel(
|
||||
val id: UUID,
|
||||
/** "СЕРИАЛ · 3 СЕЗОНА · 28 СЕРИЙ" */
|
||||
val label: String,
|
||||
val title: String,
|
||||
val rating: String?,
|
||||
val age: String?,
|
||||
val year: String?,
|
||||
val genres: String?,
|
||||
val poster: RemoteImageSpec?,
|
||||
val backdropUrl: String?,
|
||||
val favorite: Boolean,
|
||||
/** The whole series, for the ✓ button; played when every episode is. */
|
||||
val watch: WatchTarget,
|
||||
val seasons: List<SeriesSeason>,
|
||||
val episodes: List<SeriesEpisode>,
|
||||
/** Index of the episode "Продолжить"/"Смотреть" plays. */
|
||||
val nextUp: Int,
|
||||
/** "Продолжить S2 E4 · ещё 32 мин" */
|
||||
val playLabel: String,
|
||||
)
|
||||
|
||||
@Immutable
|
||||
data class SeriesState(
|
||||
val loading: Boolean = true,
|
||||
val error: String? = null,
|
||||
val item: SeriesModel? = null,
|
||||
/** Formats of the next episode's file: "4K", "HDR10", "Atmos". */
|
||||
val badges: List<String> = emptyList(),
|
||||
/** Tracks of the next episode's file, for the "Аудио и субтитры" tray. */
|
||||
val audio: List<TrackOption> = emptyList(),
|
||||
val subtitles: List<TrackOption> = emptyList(),
|
||||
val selectedAudio: Int? = null,
|
||||
val selectedSubtitle: Int? = null,
|
||||
/** "Русский 5.1" */
|
||||
val audioLabel: String? = null,
|
||||
/** "Русский", "без субтитров" */
|
||||
val subtitleLabel: String? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* Series page: header, season map and chips, every episode in one row, and the audio/subtitle choice for the series
|
||||
* (remembered per series, see [PlaybackPreferences]; the player picks it up for every episode).
|
||||
*/
|
||||
@HiltViewModel(assistedFactory = SeriesViewModel.Factory::class)
|
||||
class SeriesViewModel @AssistedInject constructor(
|
||||
@Assisted private val seriesId: UUID,
|
||||
private val library: LibraryRepository,
|
||||
private val watch: WatchRepository,
|
||||
private val images: ImageUrls,
|
||||
private val cards: CardMapper,
|
||||
private val prefs: PlaybackPreferences,
|
||||
cache: SessionCache,
|
||||
) : ViewModel() {
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
fun create(seriesId: UUID): SeriesViewModel
|
||||
}
|
||||
|
||||
private val _state = MutableStateFlow(SeriesState())
|
||||
val state: StateFlow<SeriesState> = _state.asStateFlow()
|
||||
|
||||
val subtitleStyle: StateFlow<SubtitleStyle> = prefs.subtitleStyle.stateIn(viewModelScope, SharingStarted.Eagerly, SubtitleStyle())
|
||||
|
||||
/** File of the next episode: its tracks are what the tray offers. */
|
||||
private var media: MediaInfo? = null
|
||||
|
||||
init {
|
||||
viewModelScope.launch { load() }
|
||||
// Back from the player (progress, next episode) or watched/favourite changed elsewhere: reload.
|
||||
viewModelScope.launch { cache.invalidations.collect { load() } }
|
||||
}
|
||||
|
||||
fun player(episode: SeriesEpisode): Screen.Player = Screen.Player(
|
||||
itemId = episode.id,
|
||||
engine = null,
|
||||
// A watched episode starts over; the tracks come from the series' memory.
|
||||
startPositionMs = if (episode.state == EpisodeState.Watched) 0 else episode.resumeMs,
|
||||
)
|
||||
|
||||
fun selectAudio(index: Int?) {
|
||||
_state.update { it.withTracks(media, index, it.selectedSubtitle) }
|
||||
viewModelScope.launch { TrackChoices.rememberAudio(prefs, seriesId, media, index) }
|
||||
}
|
||||
|
||||
fun selectSubtitle(index: Int?) {
|
||||
_state.update { it.withTracks(media, it.selectedAudio, index) }
|
||||
viewModelScope.launch { TrackChoices.rememberSubtitle(prefs, seriesId, media, index) }
|
||||
}
|
||||
|
||||
fun setSubtitleStyle(style: SubtitleStyle) {
|
||||
viewModelScope.launch { prefs.setSubtitleStyle(style) }
|
||||
}
|
||||
|
||||
/** Shows the new value at once and reverts it if the server refuses. */
|
||||
fun toggleFavorite() {
|
||||
val item = _state.value.item ?: return
|
||||
val value = !item.favorite
|
||||
_state.update { s -> s.copy(item = s.item?.copy(favorite = value)) }
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
watch.setFavorite(seriesId, value)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Timber.w(e, "Series: favourite failed")
|
||||
_state.update { s -> s.copy(item = s.item?.copy(favorite = !value)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun load() {
|
||||
try {
|
||||
coroutineScope {
|
||||
val started = SystemClock.elapsedRealtime()
|
||||
val seasons = async { library.seasons(seriesId) }
|
||||
val episodes = async { library.episodes(seriesId) }
|
||||
val details = library.details(seriesId)
|
||||
val model = withContext(Dispatchers.Default) { model(details, seasons.await(), episodes.await()) }
|
||||
_state.update { it.copy(loading = false, error = null, item = model) }
|
||||
Timber.i("Series loaded in %d ms (%d episodes)", SystemClock.elapsedRealtime() - started, model.episodes.size)
|
||||
// The tray and the badges describe the file of the episode "Продолжить" plays.
|
||||
model.episodes.getOrNull(model.nextUp)?.let { next ->
|
||||
val media = runCatching { library.details(next.id).media }.getOrNull()
|
||||
this@SeriesViewModel.media = media
|
||||
val (audio, subtitle) = media?.let { TrackChoices.initial(prefs, seriesId, it) } ?: (null to null)
|
||||
_state.update { s ->
|
||||
s.copy(badges = MediaLabels.badges(media), audio = TrackChoices.audio(media), subtitles = TrackChoices.subtitles(media))
|
||||
.withTracks(media, audio, subtitle)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Timber.w(e, "Series: load failed")
|
||||
// Keep what is shown when a reload fails.
|
||||
_state.update { if (it.item == null) it.copy(loading = false, error = e.message ?: e.toString()) else it }
|
||||
}
|
||||
}
|
||||
|
||||
private fun SeriesState.withTracks(media: MediaInfo?, audio: Int?, subtitle: Int?) = copy(
|
||||
selectedAudio = audio,
|
||||
selectedSubtitle = subtitle,
|
||||
audioLabel = media?.takeIf { it.audio.isNotEmpty() }?.let { PlayerLabels.audioChip(it, audio) },
|
||||
subtitleLabel = media?.takeIf { it.subtitles.isNotEmpty() }?.let { m ->
|
||||
if (subtitle == null) "без субтитров" else PlayerLabels.subtitleChip(m, subtitle)
|
||||
},
|
||||
)
|
||||
|
||||
private fun model(details: ItemDetails, seasonItems: List<ItemSummary>, episodeItems: List<ItemSummary>): SeriesModel {
|
||||
val item = details.summary
|
||||
// Specials (season 0) go after the regular seasons; episodes of seasons the server didn't list are left out.
|
||||
val ordered = seasonItems.sortedBy { if (it.seasonNumber == 0) 1 else 0 }
|
||||
val bySeason = episodeItems.groupBy { it.seasonId }
|
||||
val groups = ordered.mapNotNull { s -> bySeason[s.id]?.takeIf { it.isNotEmpty() }?.let { s to it } }
|
||||
val all = groups.flatMap { it.second }
|
||||
val nextUp = nextUp(all)
|
||||
|
||||
val episodes = ArrayList<SeriesEpisode>(all.size)
|
||||
val seasons = ArrayList<SeriesSeason>(groups.size)
|
||||
var row = 0
|
||||
groups.forEachIndexed { si, (season, list) ->
|
||||
val first = episodes.size
|
||||
val dividerIndex = row++
|
||||
list.forEachIndexed { i, e ->
|
||||
episodes += episode(e, si, row++, i, season, item.name, isNextUp = e === all.getOrNull(nextUp))
|
||||
}
|
||||
val own = episodes.subList(first, episodes.size)
|
||||
val watched = own.count { it.state == EpisodeState.Watched }
|
||||
val number = season.seasonNumber
|
||||
val label = if (number != null && number > 0) "Сезон $number" else season.name
|
||||
seasons += SeriesSeason(
|
||||
key = "season/${season.id}",
|
||||
label = label,
|
||||
stat = when {
|
||||
watched == own.size -> "✓"
|
||||
watched == 0 && own.all { it.state == EpisodeState.New } -> "новый"
|
||||
else -> "$watched/${own.size}"
|
||||
},
|
||||
mapLabel = "${if (number != null && number > 0) "S$number" else "SP"} · $watched/${own.size}",
|
||||
first = first,
|
||||
count = own.size,
|
||||
target = first + (
|
||||
own.indexOfFirst { it.state == EpisodeState.InProgress || it.state == EpisodeState.Next }.takeIf { it >= 0 }
|
||||
?: own.indexOfFirst { it.state != EpisodeState.Watched }.takeIf { it >= 0 }
|
||||
?: 0
|
||||
),
|
||||
dividerIndex = dividerIndex,
|
||||
watch = groupTarget(season.id, ItemKind.Season, item.name, label, own),
|
||||
)
|
||||
}
|
||||
|
||||
val next = episodes.getOrNull(nextUp)
|
||||
val regularSeasons = groups.count { (it.first.seasonNumber ?: 1) > 0 }
|
||||
return SeriesModel(
|
||||
id = item.id,
|
||||
label = listOf(
|
||||
"СЕРИАЛ",
|
||||
"$regularSeasons ${plural(regularSeasons, "СЕЗОН", "СЕЗОНА", "СЕЗОНОВ")}",
|
||||
"${all.size} ${plural(all.size, "СЕРИЯ", "СЕРИИ", "СЕРИЙ")}",
|
||||
).joinToString(" · "),
|
||||
title = item.name,
|
||||
rating = item.communityRating?.let { String.format(Locale.US, "★ %.1f", it) },
|
||||
age = item.officialRating?.let { r -> Regex("""(\d{1,2}\+)$""").find(r)?.value ?: r },
|
||||
year = item.year?.toString(),
|
||||
genres = item.genres.take(3).joinToString(", ").ifEmpty { null },
|
||||
poster = cards.image(item.primary, SeriesLayout.POSTER_W, SeriesLayout.POSTER_H),
|
||||
backdropUrl = item.backdrop?.let { images.url(it, cards.px(SeriesLayout.BACKDROP_W), cards.px(SeriesLayout.BACKDROP_H), quality = 80) },
|
||||
favorite = item.user.favorite,
|
||||
watch = groupTarget(item.id, ItemKind.Series, item.name, null, episodes),
|
||||
seasons = seasons,
|
||||
episodes = episodes,
|
||||
nextUp = nextUp.coerceAtLeast(0),
|
||||
playLabel = next?.let { playLabel(it, all[nextUp]) } ?: "Смотреть",
|
||||
)
|
||||
}
|
||||
|
||||
private fun episode(e: ItemSummary, season: Int, rowIndex: Int, position: Int, seasonItem: ItemSummary, seriesName: String, isNextUp: Boolean): SeriesEpisode {
|
||||
val seasonNumber = e.seasonNumber ?: seasonItem.seasonNumber ?: 0
|
||||
// Some files have no episode number: their place in the season stands in.
|
||||
val number = e.episodeNumber ?: (position + 1)
|
||||
val numberLabel = e.episodeNumberEnd?.takeIf { it > number }?.let { "$number–$it" } ?: "%02d".format(number)
|
||||
val progress = e.user.progress?.takeIf { !e.user.played }
|
||||
val state = when {
|
||||
e.user.played -> EpisodeState.Watched
|
||||
progress != null -> EpisodeState.InProgress
|
||||
isNextUp -> EpisodeState.Next
|
||||
cards.isNew(e) -> EpisodeState.New
|
||||
else -> EpisodeState.None
|
||||
}
|
||||
val duration = e.runtimeMs?.let { "${(it / 60_000.0).roundToInt()} мин" }
|
||||
val air = e.premiereAt?.let { DateFormat.format(Instant.ofEpochMilli(it).atZone(ZoneId.systemDefault())) }
|
||||
return SeriesEpisode(
|
||||
key = "ep/${e.id}",
|
||||
id = e.id,
|
||||
season = season,
|
||||
rowIndex = rowIndex,
|
||||
code = "S$seasonNumber·E$numberLabel",
|
||||
longCode = "СЕЗОН $seasonNumber · СЕРИЯ ${e.episodeNumber ?: number}",
|
||||
title = e.name,
|
||||
meta = listOfNotNull(duration, air).joinToString(" · ").ifEmpty { null },
|
||||
duration = duration,
|
||||
airDate = air,
|
||||
overview = e.overview,
|
||||
image = cards.image(e.primary ?: e.thumb ?: e.backdrop, SeriesLayout.EPISODE_W, SeriesLayout.EPISODE_H),
|
||||
progress = progress,
|
||||
state = state,
|
||||
resumeMs = if (progress != null) e.user.positionMs else 0,
|
||||
watch = WatchTarget(
|
||||
id = e.id,
|
||||
kind = ItemKind.Episode,
|
||||
title = seriesName,
|
||||
subtitle = "S$seasonNumber · E${e.episodeNumber ?: number} · ${e.name}",
|
||||
played = e.user.played,
|
||||
progress = progress,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/** A season or the whole series: every episode of it, as the page counts them (specials too). */
|
||||
private fun groupTarget(id: UUID, kind: ItemKind, title: String, subtitle: String?, episodes: List<SeriesEpisode>): WatchTarget {
|
||||
val watched = episodes.count { it.state == EpisodeState.Watched }
|
||||
return WatchTarget(
|
||||
id = id,
|
||||
kind = kind,
|
||||
title = title,
|
||||
subtitle = subtitle,
|
||||
played = episodes.isNotEmpty() && watched == episodes.size,
|
||||
episodes = episodes.size,
|
||||
watchedEpisodes = watched,
|
||||
started = episodes.any { it.state == EpisodeState.InProgress },
|
||||
)
|
||||
}
|
||||
|
||||
private fun playLabel(next: SeriesEpisode, item: ItemSummary): String {
|
||||
val code = next.code.replace("·", " ").replace(Regex("""E0(\d)"""), "E$1")
|
||||
return when (next.state) {
|
||||
EpisodeState.InProgress -> {
|
||||
val left = item.runtimeMs?.let { (it * (1 - (next.progress ?: 0f)) / 60_000).roundToInt() }
|
||||
"Продолжить $code" + (left?.let { " · ещё $it мин" } ?: "")
|
||||
}
|
||||
else -> "Смотреть $code" + (next.duration?.let { " · $it" } ?: "")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The episode to continue with: the last one played if it isn't finished (also when it was only started for a few
|
||||
* seconds and the server kept no position), else the first unwatched one after it; nothing played yet — the first
|
||||
* unwatched. Specials only count when the series has nothing else.
|
||||
*/
|
||||
private fun nextUp(all: List<ItemSummary>): Int {
|
||||
if (all.isEmpty()) return -1
|
||||
val regular = all.indices.filter { (all[it].seasonNumber ?: 1) > 0 }.ifEmpty { all.indices.toList() }
|
||||
val last = regular.filter { all[it].user.lastPlayedAt != null }.maxByOrNull { all[it].user.lastPlayedAt!! }
|
||||
if (last != null) {
|
||||
val e = all[last]
|
||||
if (!e.user.played) return last
|
||||
regular.firstOrNull { it > last && !all[it].user.played }?.let { return it }
|
||||
}
|
||||
return regular.firstOrNull { !all[it].user.played } ?: regular.first()
|
||||
}
|
||||
|
||||
private companion object {
|
||||
val DateFormat: DateTimeFormatter = DateTimeFormatter.ofPattern("dd.MM.yyyy")
|
||||
}
|
||||
}
|
||||
|
||||
/** Sizes (dp) shared by the layout, the image requests and the baked backdrop. */
|
||||
internal object SeriesLayout {
|
||||
const val PAGE_W = 960f
|
||||
const val PAGE_H = 540f
|
||||
const val POSTER_W = 90f
|
||||
const val POSTER_H = 135f
|
||||
const val EPISODE_W = 200f
|
||||
const val EPISODE_H = 112.5f
|
||||
/** The backdrop covers 70 % of the page width and height at the top right, as on the movie page. */
|
||||
const val BACKDROP_W = 672f
|
||||
const val BACKDROP_H = 378f
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package ru.stenfredd.sleepyowl.ui.settings
|
||||
|
||||
import android.view.KeyEvent as AndroidKeyEvent
|
||||
import androidx.compose.foundation.focusable
|
||||
import androidx.compose.foundation.gestures.animateScrollBy
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.text.BasicText
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.input.key.KeyEventType
|
||||
import androidx.compose.ui.input.key.nativeKeyCode
|
||||
import androidx.compose.ui.input.key.key
|
||||
import androidx.compose.ui.input.key.onKeyEvent
|
||||
import androidx.compose.ui.input.key.type
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import ru.stenfredd.sleepyowl.ui.Screen
|
||||
import ru.stenfredd.sleepyowl.ui.theme.AppColors
|
||||
import ru.stenfredd.sleepyowl.ui.theme.AppDimens
|
||||
import ru.stenfredd.sleepyowl.ui.theme.AppType
|
||||
|
||||
/** Text of a licence from `assets/licenses`; ↑/↓ scroll it (a page at a time with ←/→ or channel keys). */
|
||||
@Composable
|
||||
fun LicenseScreen(screen: Screen.License) {
|
||||
val context = LocalContext.current
|
||||
var paragraphs by remember { mutableStateOf(emptyList<String>()) }
|
||||
LaunchedEffect(screen.asset) {
|
||||
paragraphs = withContext(Dispatchers.IO) {
|
||||
runCatching { context.assets.open("licenses/${screen.asset}").bufferedReader().use { it.readText() } }
|
||||
.getOrDefault("Текст лицензии не найден.")
|
||||
.split(Regex("""\n\s*\n"""))
|
||||
// Hard-wrapped lines are joined: the screen wraps them itself.
|
||||
.map { p -> p.trim().replace(Regex("""\s*\n\s*"""), " ") }
|
||||
.filter { it.isNotEmpty() }
|
||||
}
|
||||
}
|
||||
|
||||
val list = rememberLazyListState()
|
||||
val scope = rememberCoroutineScope()
|
||||
val requester = remember { FocusRequester() }
|
||||
val step = with(LocalDensity.current) { 120.dp.toPx() }
|
||||
val page = with(LocalDensity.current) { 400.dp.toPx() }
|
||||
LaunchedEffect(Unit) { runCatching { requester.requestFocus() } }
|
||||
|
||||
LazyColumn(
|
||||
state = list,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.focusRequester(requester)
|
||||
.onKeyEvent { e ->
|
||||
if (e.type != KeyEventType.KeyDown) return@onKeyEvent false
|
||||
val by = when (e.key.nativeKeyCode) {
|
||||
AndroidKeyEvent.KEYCODE_DPAD_DOWN -> step
|
||||
AndroidKeyEvent.KEYCODE_DPAD_UP -> -step
|
||||
AndroidKeyEvent.KEYCODE_DPAD_RIGHT, AndroidKeyEvent.KEYCODE_PAGE_DOWN, AndroidKeyEvent.KEYCODE_CHANNEL_DOWN -> page
|
||||
AndroidKeyEvent.KEYCODE_DPAD_LEFT, AndroidKeyEvent.KEYCODE_PAGE_UP, AndroidKeyEvent.KEYCODE_CHANNEL_UP -> -page
|
||||
else -> return@onKeyEvent false
|
||||
}
|
||||
scope.launch { list.animateScrollBy(by) }
|
||||
true
|
||||
}
|
||||
.focusable(),
|
||||
contentPadding = PaddingValues(start = AppDimens.CONTENT_START.dp, end = AppDimens.CONTENT_START.dp, top = AppDimens.CONTENT_TOP.dp, bottom = 80.dp),
|
||||
) {
|
||||
item(key = "title", contentType = "title") {
|
||||
Column(Modifier.padding(bottom = 18.dp)) {
|
||||
BasicText(screen.name, style = AppType.Display.copy(fontSize = 26.sp))
|
||||
BasicText(screen.license, Modifier.padding(top = 6.dp), style = AppType.MonoLabel)
|
||||
}
|
||||
}
|
||||
itemsIndexed(paragraphs, contentType = { _, _ -> "text" }) { _, p ->
|
||||
BasicText(
|
||||
p,
|
||||
Modifier.widthIn(max = 720.dp).padding(bottom = 10.dp),
|
||||
style = AppType.Body.copy(fontSize = 11.5.sp, lineHeight = 17.sp, color = AppColors.TextSecondary),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package ru.stenfredd.sleepyowl.ui.settings
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
|
||||
/** A component shipped in the app and its licence; [asset] is the licence text in `assets/licenses`. */
|
||||
@Immutable
|
||||
data class Component(val name: String, val note: String?, val license: String, val asset: String)
|
||||
|
||||
/** "О приложении": the app itself first, then what it is built from (playback first: it carries the copyleft licences). */
|
||||
internal object Licenses {
|
||||
val app = Component("SleepyOwl", "это приложение", "GPL-3.0", "GPL-3.0.txt")
|
||||
|
||||
val components = listOf(
|
||||
Component("libVLC", "VideoLAN, второй движок воспроизведения", "LGPL-2.1", "LGPL-2.1.txt"),
|
||||
Component("FFmpeg для Media3", "Jellyfin, декодеры звука", "GPL-3.0", "GPL-3.0.txt"),
|
||||
Component("Jellyfin SDK", "Jellyfin, работа с сервером", "LGPL-3.0", "LGPL-3.0.txt"),
|
||||
Component("AndroidX Media3 (ExoPlayer)", "Google, основной движок", "Apache 2.0", "Apache-2.0.txt"),
|
||||
Component("PsExtractor", "из Media3, изменён для DVD", "Apache 2.0", "Apache-2.0.txt"),
|
||||
Component("ass-media", "субтитры ASS в ExoPlayer", "MIT", "MIT.txt"),
|
||||
Component("libass", "внутри ass-media", "ISC", "ISC.txt"),
|
||||
Component("Jetpack Compose, AndroidX", "Google, интерфейс", "Apache 2.0", "Apache-2.0.txt"),
|
||||
Component("Kotlin, kotlinx", "JetBrains", "Apache 2.0", "Apache-2.0.txt"),
|
||||
Component("Dagger Hilt", "Google", "Apache 2.0", "Apache-2.0.txt"),
|
||||
Component("Coil", "загрузка картинок", "Apache 2.0", "Apache-2.0.txt"),
|
||||
Component("OkHttp", "Square, сеть", "Apache 2.0", "Apache-2.0.txt"),
|
||||
Component("ZXing", "QR-код для Quick Connect", "Apache 2.0", "Apache-2.0.txt"),
|
||||
Component("Timber", "журнал", "Apache 2.0", "Apache-2.0.txt"),
|
||||
Component("Шрифт Onest", null, "OFL 1.1", "Onest-OFL.txt"),
|
||||
Component("Шрифт JetBrains Mono", null, "OFL 1.1", "JetBrainsMono-OFL.txt"),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,577 @@
|
||||
package ru.stenfredd.sleepyowl.ui.settings
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.focusGroup
|
||||
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.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.layout.widthIn
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.BasicText
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.withFrameNanos
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusDirection
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.focus.focusRestorer
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.key.Key
|
||||
import androidx.compose.ui.input.key.KeyEventType
|
||||
import androidx.compose.ui.input.key.key
|
||||
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
||||
import androidx.compose.ui.input.key.type
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import ru.stenfredd.sleepyowl.core.data.LanguageSettings
|
||||
import ru.stenfredd.sleepyowl.core.data.SubtitleMode
|
||||
import ru.stenfredd.sleepyowl.core.jellyfin.Account
|
||||
import ru.stenfredd.sleepyowl.playback.api.SubtitleStyle
|
||||
import ru.stenfredd.sleepyowl.playback.api.VideoPathSwitchable
|
||||
import ru.stenfredd.sleepyowl.playback.core.PlaybackDefaults
|
||||
import ru.stenfredd.sleepyowl.playback.core.SkipMode
|
||||
import ru.stenfredd.sleepyowl.ui.Screen
|
||||
import ru.stenfredd.sleepyowl.ui.components.Avatar
|
||||
import ru.stenfredd.sleepyowl.ui.components.Chip
|
||||
import ru.stenfredd.sleepyowl.ui.components.FocusStyle
|
||||
import ru.stenfredd.sleepyowl.ui.components.MinimalScrolling
|
||||
import ru.stenfredd.sleepyowl.ui.components.OwlMark
|
||||
import ru.stenfredd.sleepyowl.ui.components.SecondaryButton
|
||||
import ru.stenfredd.sleepyowl.ui.components.Wordmark
|
||||
import ru.stenfredd.sleepyowl.ui.components.focusCard
|
||||
import ru.stenfredd.sleepyowl.ui.focus.Restore
|
||||
import ru.stenfredd.sleepyowl.ui.focus.ScreenFocus
|
||||
import ru.stenfredd.sleepyowl.ui.focus.rememberScreenFocus
|
||||
import ru.stenfredd.sleepyowl.ui.focus.remembered
|
||||
import ru.stenfredd.sleepyowl.ui.player.PlayerLabels
|
||||
import ru.stenfredd.sleepyowl.ui.player.QualityCaps
|
||||
import ru.stenfredd.sleepyowl.ui.player.SubtitleChoices
|
||||
import ru.stenfredd.sleepyowl.ui.player.SubtitlePreview
|
||||
import ru.stenfredd.sleepyowl.ui.theme.AppColors
|
||||
import ru.stenfredd.sleepyowl.ui.theme.AppDimens
|
||||
import ru.stenfredd.sleepyowl.ui.theme.AppType
|
||||
|
||||
/** Sections on the left; the focused one is shown on the right. */
|
||||
private enum class Section(val title: String, val dot: Color) {
|
||||
Playback("Воспроизведение", AppColors.Blue),
|
||||
Languages("Языки", AppColors.Pink),
|
||||
Subtitles("Вид субтитров", AppColors.Yellow),
|
||||
Account("Аккаунт и сервер", AppColors.Green),
|
||||
About("О приложении", AppColors.TextDim),
|
||||
Debug("Отладка", AppColors.Orange),
|
||||
}
|
||||
|
||||
/** One chip of a setting. */
|
||||
@Immutable
|
||||
private data class Option<T>(val value: T, val label: String, val detail: String? = null, val swatch: Color? = null)
|
||||
|
||||
/**
|
||||
* Settings tab (not in the mockup; built from its parts): sections on the left, the focused section's settings on
|
||||
* the right as rows of chips, like the playback options on a movie page. Every choice applies at once.
|
||||
*/
|
||||
@Composable
|
||||
fun SettingsScreen(
|
||||
onOpen: (Screen) -> Unit,
|
||||
/** Signed out: the app goes to the login screen. */
|
||||
onSignedOut: () -> Unit,
|
||||
/** Another saved account became current: the app restarts on its home screen. */
|
||||
onSwitched: () -> Unit,
|
||||
vm: SettingsViewModel = hiltViewModel(),
|
||||
) {
|
||||
val focus = rememberScreenFocus()
|
||||
var section by rememberSaveable { mutableStateOf(Section.Playback) }
|
||||
val sections = remember(vm.tourAvailable) { Section.entries.filter { it != Section.Debug || vm.tourAvailable } }
|
||||
// The focused section is shown after a short pause: quick scrolling through the list doesn't build every section.
|
||||
var focused by remember { mutableStateOf<Section?>(null) }
|
||||
LaunchedEffect(focused) {
|
||||
val target = focused ?: return@LaunchedEffect
|
||||
if (target != section) {
|
||||
delay(SECTION_DELAY_MS)
|
||||
section = target
|
||||
}
|
||||
}
|
||||
|
||||
Column(Modifier.fillMaxSize().padding(start = AppDimens.CONTENT_START.dp, top = AppDimens.CONTENT_TOP.dp)) {
|
||||
BasicText("Настройки", style = AppType.Display.copy(fontSize = 28.sp))
|
||||
Row(Modifier.padding(top = 22.dp).fillMaxSize()) {
|
||||
val focusManager = LocalFocusManager.current
|
||||
val scope = rememberCoroutineScope()
|
||||
Column(
|
||||
Modifier
|
||||
.width(190.dp)
|
||||
// → before the pause is over: show the focused section now and go into it once it is composed.
|
||||
.onPreviewKeyEvent { e ->
|
||||
val pending = focused?.takeIf { it != section }
|
||||
if (pending == null || e.type != KeyEventType.KeyDown || e.key != Key.DirectionRight) return@onPreviewKeyEvent false
|
||||
section = pending
|
||||
scope.launch {
|
||||
withFrameNanos { }
|
||||
withFrameNanos { }
|
||||
focusManager.moveFocus(FocusDirection.Right)
|
||||
}
|
||||
true
|
||||
}
|
||||
.focusRestorer()
|
||||
.focusGroup(),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
for (s in sections) {
|
||||
SectionItem(
|
||||
s,
|
||||
selected = s == section,
|
||||
onFocused = { focused = s },
|
||||
onClick = { section = s },
|
||||
Modifier.remembered(focus, "section/${s.name}", isDefault = s == Section.Playback),
|
||||
)
|
||||
}
|
||||
}
|
||||
// Each section scrolls on its own and starts from the top.
|
||||
Box(Modifier.weight(1f).fillMaxHeight()) { key(section) {
|
||||
MinimalScrolling(margin = 30.dp) {
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.focusRestorer()
|
||||
.focusGroup()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(start = 40.dp, end = AppDimens.CONTENT_START.dp, top = 6.dp, bottom = 60.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(26.dp),
|
||||
) {
|
||||
when (section) {
|
||||
Section.Playback -> PlaybackSection(vm, focus)
|
||||
Section.Languages -> LanguagesSection(vm, focus)
|
||||
Section.Subtitles -> SubtitlesSection(vm, focus)
|
||||
Section.Account -> AccountSection(vm, focus, onOpen, onSignedOut, onSwitched)
|
||||
Section.About -> AboutSection(vm, focus, onOpen)
|
||||
Section.Debug -> Setting("Режим обхода", "Проигрывает подготовленный список файлов со всеми форматами сервера: проверка движков.") {
|
||||
SecondaryButton("Запустить", { onOpen(Screen.Tour()) }, Modifier.remembered(focus, "debug/tour"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} }
|
||||
}
|
||||
}
|
||||
focus.Restore(ready = true)
|
||||
}
|
||||
|
||||
private const val SECTION_DELAY_MS = 300L
|
||||
|
||||
private val SectionFocus = FocusStyle(scale = 1f, corner = 8.dp, glows = FocusStyle.ButtonGlows)
|
||||
|
||||
/** Section in the left column: light fill when focused, a violet frame for the one shown. */
|
||||
@Composable
|
||||
private fun SectionItem(section: Section, selected: Boolean, onFocused: () -> Unit, onClick: () -> Unit, modifier: Modifier) {
|
||||
var focused by remember { mutableStateOf(false) }
|
||||
val shape = RoundedCornerShape(8.dp)
|
||||
Row(
|
||||
modifier
|
||||
.focusCard(SectionFocus)
|
||||
.fillMaxWidth()
|
||||
.height(36.dp)
|
||||
.background(
|
||||
when {
|
||||
focused -> AppColors.FocusFill
|
||||
selected -> AppColors.Selected
|
||||
else -> Color.Transparent
|
||||
},
|
||||
shape,
|
||||
)
|
||||
.border(0.5.dp, if (selected && !focused) AppColors.SelectedBorder else Color.Transparent, shape)
|
||||
.onFocusChanged {
|
||||
focused = it.isFocused
|
||||
if (it.isFocused) onFocused()
|
||||
}
|
||||
.clickable(interactionSource = null, indication = null, onClick = onClick)
|
||||
.padding(horizontal = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(9.dp),
|
||||
) {
|
||||
Box(Modifier.size(6.dp).background(section.dot, CircleShape))
|
||||
BasicText(
|
||||
section.title,
|
||||
style = AppType.Rail.copy(fontSize = 13.sp, color = if (focused) AppColors.OnFocusFill else if (selected) AppColors.Text else AppColors.TextMuted),
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** A setting: title, an optional explanation, then its controls. */
|
||||
@Composable
|
||||
private fun Setting(title: String, note: String?, content: @Composable () -> Unit) {
|
||||
Column {
|
||||
BasicText(title, style = AppType.Section.copy(fontSize = 14.sp))
|
||||
note?.let { BasicText(it, Modifier.padding(top = 5.dp).widthIn(max = 520.dp), style = AppType.Body) }
|
||||
Box(Modifier.padding(top = 11.dp)) { content() }
|
||||
}
|
||||
}
|
||||
|
||||
/** A row of chips, one of them chosen. Coming into the row from above or below focuses the chosen chip. */
|
||||
@Composable
|
||||
private fun <T> Choices(id: String, options: List<Option<T>>, selected: T, focus: ScreenFocus, onPick: (T) -> Unit) {
|
||||
val chosen = remember { FocusRequester() }
|
||||
Row(Modifier.focusRestorer(chosen).focusGroup(), horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
for ((i, o) in options.withIndex()) {
|
||||
val isSelected = o.value == selected
|
||||
Chip(
|
||||
o.label,
|
||||
isSelected,
|
||||
{ onPick(o.value) },
|
||||
Modifier.then(if (isSelected) Modifier.focusRequester(chosen) else Modifier).remembered(focus, "$id/$i"),
|
||||
detail = o.detail,
|
||||
swatch = o.swatch,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Note(text: String, modifier: Modifier = Modifier, color: Color = AppColors.TextDim) {
|
||||
BasicText(text, modifier.widthIn(max = 520.dp), style = AppType.Body.copy(fontSize = 10.5.sp, lineHeight = 15.sp, color = color))
|
||||
}
|
||||
|
||||
// --- Воспроизведение ---
|
||||
|
||||
@Composable
|
||||
private fun PlaybackSection(vm: SettingsViewModel, focus: ScreenFocus) {
|
||||
val defaults by vm.defaults.collectAsStateWithLifecycle()
|
||||
fun set(d: PlaybackDefaults) = vm.setDefaults(d)
|
||||
|
||||
Setting(
|
||||
"Качество по умолчанию",
|
||||
"Всё, что ниже оригинала, сервер перекодирует на лету — это нагружает его процессор. Помогает при медленной сети. " +
|
||||
"В плеере качество можно сменить для одного просмотра.",
|
||||
) {
|
||||
val options = listOf(Option<Int?>(null, "Оригинал")) +
|
||||
QualityCaps.ALL.map { (bitrate, lines) -> Option<Int?>(bitrate, "${lines}p", PlayerLabels.mbps(bitrate)) }
|
||||
Choices("quality", options, defaults.maxBitrate, focus) { set(defaults.copy(maxBitrate = it)) }
|
||||
}
|
||||
Setting(
|
||||
"Вывод видео",
|
||||
"Видеослой ТВ — с HDR и обработкой изображения телевизора. Через GPU — без HDR и не выше 1080p, но без ошибок " +
|
||||
"видеослоя некоторых ТВ. «Авто» выбирает по файлу. Относится к основному движку (ExoPlayer).",
|
||||
) {
|
||||
val options = listOf(
|
||||
Option(VideoPathSwitchable.Mode.AUTO, "Авто"),
|
||||
Option(VideoPathSwitchable.Mode.VIDEO_PLANE, "Видеослой ТВ"),
|
||||
Option(VideoPathSwitchable.Mode.GPU, "Через GPU"),
|
||||
)
|
||||
Choices("path", options, defaults.videoPath, focus) { set(defaults.copy(videoPath = it)) }
|
||||
}
|
||||
if (vm.dolbyVisionCapable) {
|
||||
Setting(
|
||||
"Dolby Vision",
|
||||
"Файлы Dolby Vision с базовым слоем HDR10 можно показывать как HDR10 или через DV-декодер: тогда ТВ " +
|
||||
"включает свой режим Dolby Vision. Файлы только с Dolby Vision всегда идут через DV-декодер.",
|
||||
) {
|
||||
val options = listOf(Option(false, "Базовый слой", "HDR10"), Option(true, "DV-декодер", "режим ТВ"))
|
||||
Choices("dv", options, defaults.dolbyVision, focus) { set(defaults.copy(dolbyVision = it)) }
|
||||
}
|
||||
}
|
||||
Setting(
|
||||
"Заставки",
|
||||
"Опенинги, пересказ прошлых серий, анонсы. «Кнопка» — в начале заставки появляется «Пропустить заставку», " +
|
||||
"«Сразу» — плеер перескакивает её сам. Работает там, где сервер знает заставки (плагин Intro Skipper).",
|
||||
) {
|
||||
Choices("skip-intro", skipOptions, defaults.skipIntro, focus) { set(defaults.copy(skipIntro = it)) }
|
||||
}
|
||||
Setting(
|
||||
"Титры",
|
||||
"«Кнопка» — с началом титров появляется карточка «Следующая серия» с отсчётом (или «Пропустить титры», если " +
|
||||
"после них ещё что-то есть). «Сразу» — следующая серия включается, как только начались титры. «Не пропускать» — " +
|
||||
"карточка за 30 с до конца серии.",
|
||||
) {
|
||||
Choices("skip-credits", skipOptions, defaults.skipCredits, focus) { set(defaults.copy(skipCredits = it)) }
|
||||
}
|
||||
}
|
||||
|
||||
private val skipOptions = listOf(Option(SkipMode.Button, "Кнопка"), Option(SkipMode.Auto, "Сразу"), Option(SkipMode.Off, "Не пропускать"))
|
||||
|
||||
// --- Языки ---
|
||||
|
||||
private val AudioLanguages = listOf("rus" to "Русский", "eng" to "Английский", "jpn" to "Японский")
|
||||
private val SubtitleLanguages = listOf("rus" to "Русские", "eng" to "Английские")
|
||||
|
||||
@Composable
|
||||
private fun LanguagesSection(vm: SettingsViewModel, focus: ScreenFocus) {
|
||||
val state by vm.languages.collectAsStateWithLifecycle()
|
||||
val settings = state.settings
|
||||
if (settings == null) {
|
||||
if (state.error == null) {
|
||||
Note("Загрузка настроек с сервера…")
|
||||
} else {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Note("Не удалось загрузить настройки с сервера: ${state.error}", color = AppColors.Red)
|
||||
SecondaryButton("Повторить", vm::loadLanguages, Modifier.remembered(focus, "languages/retry"))
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
fun set(s: LanguageSettings) = vm.setLanguages(s)
|
||||
|
||||
Setting("Язык звука", "Какую дорожку включать, если в файле их несколько. «Как в файле» — дорожка, отмеченная в файле основной.") {
|
||||
val options = listOf(Option<String?>(null, "Как в файле")) +
|
||||
(AudioLanguages + listOfNotNull(settings.audio?.takeIf { a -> AudioLanguages.none { it.first == a } }?.let { it to languageName(it) }))
|
||||
.map { (code, name) -> Option<String?>(code, name) }
|
||||
Choices("audio", options, settings.audio, focus) { set(settings.copy(audio = it)) }
|
||||
}
|
||||
Setting("Субтитры", null) {
|
||||
val options = listOf(
|
||||
Option(SubtitleMode.Default, "Как в файле"),
|
||||
Option(SubtitleMode.None, "Выкл"),
|
||||
Option(SubtitleMode.OnlyForced, "Только надписи"),
|
||||
Option(SubtitleMode.Smart, "Если звук не на моём языке"),
|
||||
Option(SubtitleMode.Always, "Всегда"),
|
||||
)
|
||||
Choices("subtitles", options, settings.subtitleMode, focus) { set(settings.copy(subtitleMode = it)) }
|
||||
}
|
||||
if (settings.subtitleMode != SubtitleMode.None) {
|
||||
Setting("Язык субтитров", null) {
|
||||
val options = listOf(Option<String?>(null, "Любой")) +
|
||||
(SubtitleLanguages + listOfNotNull(settings.subtitleLanguage?.takeIf { s -> SubtitleLanguages.none { it.first == s } }?.let { it to languageName(it) }))
|
||||
.map { (code, name) -> Option<String?>(code, name) }
|
||||
Choices("subtitle-language", options, settings.subtitleLanguage, focus) { set(settings.copy(subtitleLanguage = it)) }
|
||||
}
|
||||
}
|
||||
val account = vm.accounts.collectAsStateWithLifecycle().value.current
|
||||
Note(
|
||||
"Языки хранятся на сервере в профиле ${account?.userName.orEmpty()} и действуют во всех приложениях Jellyfin. " +
|
||||
"Дорожки, выбранные вручную для фильма или сериала, запоминаются на этом ТВ и важнее этих настроек.",
|
||||
)
|
||||
state.error?.let { Note(it, color = AppColors.Red) }
|
||||
}
|
||||
|
||||
/** "fra" → "Французский". */
|
||||
private fun languageName(code: String): String =
|
||||
java.util.Locale.forLanguageTag(code).getDisplayLanguage(java.util.Locale.forLanguageTag("ru")).replaceFirstChar { it.titlecase() }.ifEmpty { code }
|
||||
|
||||
// --- Вид субтитров ---
|
||||
|
||||
@Composable
|
||||
private fun SubtitlesSection(vm: SettingsViewModel, focus: ScreenFocus) {
|
||||
val style by vm.subtitleStyle.collectAsStateWithLifecycle()
|
||||
fun set(s: SubtitleStyle) = vm.setSubtitleStyle(s)
|
||||
|
||||
Box(Modifier.widthIn(max = 420.dp)) { SubtitlePreview(style, on = true) }
|
||||
Setting("Размер", null) {
|
||||
Choices("sub-size", SubtitleStyle.Size.entries.map { Option(it, it.name) }, style.size, focus) { set(style.copy(size = it)) }
|
||||
}
|
||||
Setting("Цвет", null) {
|
||||
val options = SubtitleChoices.colors(style).map { Option(it.key.substringAfter('/').toInt(), it.label, swatch = it.swatch) }
|
||||
Choices("sub-color", options, style.color, focus) { set(style.copy(color = it)) }
|
||||
}
|
||||
Setting("Фон", null) {
|
||||
val options = SubtitleChoices.backgrounds(style).map { Option(SubtitleStyle.Background.valueOf(it.key.substringAfter('/')), it.label) }
|
||||
Choices("sub-background", options, style.background, focus) { set(style.copy(background = it)) }
|
||||
}
|
||||
Note(
|
||||
"Для текстовых субтитров (SRT, VTT) на этом ТВ, для всех профилей. У субтитров ASS своё оформление из файла, " +
|
||||
"графические (PGS, DVD) показываются как есть. То же можно настроить в плеере.",
|
||||
)
|
||||
}
|
||||
|
||||
// --- Аккаунт и сервер ---
|
||||
|
||||
@Composable
|
||||
private fun AccountSection(
|
||||
vm: SettingsViewModel,
|
||||
focus: ScreenFocus,
|
||||
onOpen: (Screen) -> Unit,
|
||||
onSignedOut: () -> Unit,
|
||||
onSwitched: () -> Unit,
|
||||
) {
|
||||
val state by vm.accounts.collectAsStateWithLifecycle()
|
||||
val scope = rememberCoroutineScope()
|
||||
// The action waiting for the second OK ("Точно выйти?"); moving focus away cancels it.
|
||||
var confirm by remember { mutableStateOf<String?>(null) }
|
||||
var busy by remember { mutableStateOf(false) }
|
||||
fun Modifier.confirmable(id: String) = onFocusChanged { if (!it.isFocused && confirm == id) confirm = null }
|
||||
|
||||
val current = state.current ?: return
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(14.dp)) {
|
||||
Avatar(current.userName, current.userId.toString(), 48.dp, AppType.Hero.copy(fontSize = 20.sp))
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
BasicText(current.userName, style = AppType.Section.copy(fontSize = 18.sp, fontWeight = FontWeight.Bold))
|
||||
val server = state.server
|
||||
val name = server?.name?.ifEmpty { null } ?: current.serverName.ifEmpty { null }
|
||||
BasicText(
|
||||
listOfNotNull(name?.let { "Сервер «$it»" } ?: "Сервер", server?.version?.let { "Jellyfin $it" }).joinToString(" · "),
|
||||
style = AppType.MonoMeta,
|
||||
)
|
||||
}
|
||||
}
|
||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
InfoLine("Адрес", current.serverUrl)
|
||||
InfoLine("Дома", state.localUrl?.let { "напрямую, $it" } ?: "сервер в локальной сети не найден — через адрес выше")
|
||||
}
|
||||
Row(Modifier.focusRestorer().focusGroup(), horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
SecondaryButton("Сменить профиль", { onOpen(Screen.Login) }, Modifier.remembered(focus, "account/switch"))
|
||||
SecondaryButton(
|
||||
when {
|
||||
busy -> "Выходим…"
|
||||
confirm == "logout" -> "Точно выйти?"
|
||||
else -> "Выйти"
|
||||
},
|
||||
{
|
||||
when {
|
||||
busy -> Unit
|
||||
confirm != "logout" -> confirm = "logout"
|
||||
else -> {
|
||||
busy = true
|
||||
scope.launch {
|
||||
vm.logout()
|
||||
onSignedOut()
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
Modifier.remembered(focus, "account/logout").confirmable("logout"),
|
||||
)
|
||||
}
|
||||
Note("«Выйти» забывает этот профиль на ТВ; войти снова — паролем или Quick Connect.")
|
||||
|
||||
Setting("Другие профили на этом ТВ", if (state.others.isEmpty()) "Других нет. Добавить — «Сменить профиль» → «Другой пользователь»." else null) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
for (a in state.others) {
|
||||
OtherAccount(
|
||||
a,
|
||||
confirmForget = confirm == "forget/${a.id}",
|
||||
onSwitch = {
|
||||
if (!busy) {
|
||||
busy = true
|
||||
scope.launch {
|
||||
vm.switchTo(a.id)
|
||||
onSwitched()
|
||||
}
|
||||
}
|
||||
},
|
||||
onForget = {
|
||||
if (confirm != "forget/${a.id}") {
|
||||
confirm = "forget/${a.id}"
|
||||
} else {
|
||||
confirm = null
|
||||
vm.forget(a.id)
|
||||
// The row goes away with the focused chip: focus the section instead of nothing.
|
||||
scope.launch {
|
||||
focus.lastKey = "section/${Section.Account.name}"
|
||||
withFrameNanos { }
|
||||
withFrameNanos { }
|
||||
focus.requestFocus()
|
||||
}
|
||||
}
|
||||
},
|
||||
focus = focus,
|
||||
modifier = Modifier.confirmable("forget/${a.id}"),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun OtherAccount(
|
||||
account: Account,
|
||||
confirmForget: Boolean,
|
||||
onSwitch: () -> Unit,
|
||||
onForget: () -> Unit,
|
||||
focus: ScreenFocus,
|
||||
modifier: Modifier,
|
||||
) {
|
||||
Row(Modifier.focusRestorer().focusGroup(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Avatar(account.userName, account.userId.toString(), 30.dp, AppType.CardTitle)
|
||||
Column(Modifier.width(190.dp)) {
|
||||
BasicText(account.userName, style = AppType.CardTitle, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
BasicText(account.serverName.ifEmpty { account.serverUrl }, style = AppType.MonoCaption, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
Chip("Переключиться", false, onSwitch, Modifier.remembered(focus, "other/${account.id}/switch"))
|
||||
Chip(if (confirmForget) "Точно забыть?" else "Забыть", false, onForget, modifier.remembered(focus, "other/${account.id}/forget"))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun InfoLine(key: String, value: String) {
|
||||
Row {
|
||||
BasicText(key, Modifier.width(60.dp), style = AppType.MonoMeta.copy(color = AppColors.TextFaint))
|
||||
BasicText(value, style = AppType.MonoMeta, maxLines = 2, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
|
||||
// --- О приложении ---
|
||||
|
||||
@Composable
|
||||
private fun AboutSection(vm: SettingsViewModel, focus: ScreenFocus, onOpen: (Screen) -> Unit) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(5.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
OwlMark()
|
||||
Wordmark()
|
||||
}
|
||||
BasicText("Версия ${vm.about.version}", style = AppType.MonoMeta)
|
||||
BasicText(vm.about.device, style = AppType.MonoMeta.copy(color = AppColors.TextDim))
|
||||
}
|
||||
Setting("Лицензии", "Приложение — свободное ПО под лицензией GPL-3.0. Ниже — из чего оно собрано; OK — текст лицензии.") {
|
||||
Column(Modifier.focusRestorer().focusGroup(), verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
for ((i, c) in (listOf(Licenses.app) + Licenses.components).withIndex()) {
|
||||
LicenseRow(c, { onOpen(Screen.License(c.name, c.license, c.asset)) }, Modifier.remembered(focus, "license/$i"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val RowFocus = FocusStyle(scale = 1f, corner = 7.dp, ring = 0.5.dp, glows = listOf(FocusStyle.Glow(15.dp, 0.dp, Color(0x737C5CFF))))
|
||||
|
||||
@Composable
|
||||
private fun LicenseRow(component: Component, onClick: () -> Unit, modifier: Modifier) {
|
||||
var focused by remember { mutableStateOf(false) }
|
||||
val shape = RoundedCornerShape(7.dp)
|
||||
Row(
|
||||
modifier
|
||||
.focusCard(RowFocus)
|
||||
.widthIn(max = 520.dp)
|
||||
.fillMaxWidth()
|
||||
.height(34.dp)
|
||||
.background(if (focused) AppColors.SurfaceFocused else AppColors.Surface, shape)
|
||||
.border(0.5.dp, if (focused) AppColors.Accent else AppColors.BorderSubtle, shape)
|
||||
.onFocusChanged { focused = it.isFocused }
|
||||
.clickable(interactionSource = null, indication = null, onClick = onClick)
|
||||
.padding(horizontal = 11.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
BasicText(component.name, style = AppType.CardTitle.copy(fontSize = 12.sp, fontWeight = FontWeight.SemiBold), maxLines = 1)
|
||||
component.note?.let {
|
||||
BasicText(it, Modifier.weight(1f), style = AppType.Body.copy(fontSize = 10.5.sp, color = AppColors.TextDim), maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
} ?: Box(Modifier.weight(1f))
|
||||
BasicText(component.license, style = AppType.MonoCaption.copy(color = AppColors.Accent), maxLines = 1)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
package ru.stenfredd.sleepyowl.ui.settings
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.view.Display
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import ru.stenfredd.sleepyowl.BuildConfig
|
||||
import ru.stenfredd.sleepyowl.core.data.LanguageSettings
|
||||
import ru.stenfredd.sleepyowl.core.data.ServerInfo
|
||||
import ru.stenfredd.sleepyowl.core.data.UserSettingsRepository
|
||||
import ru.stenfredd.sleepyowl.core.jellyfin.Account
|
||||
import ru.stenfredd.sleepyowl.core.jellyfin.JellyfinSession
|
||||
import ru.stenfredd.sleepyowl.playback.api.SubtitleStyle
|
||||
import ru.stenfredd.sleepyowl.playback.core.DeviceCapabilitiesProbe
|
||||
import ru.stenfredd.sleepyowl.playback.core.PlaybackDefaults
|
||||
import ru.stenfredd.sleepyowl.playback.core.PlaybackPreferences
|
||||
import ru.stenfredd.sleepyowl.ui.TourEntry
|
||||
import timber.log.Timber
|
||||
import javax.inject.Inject
|
||||
|
||||
/** Server-side language settings: loading, loaded or failed (with the reason). */
|
||||
@Immutable
|
||||
data class LanguagesState(val settings: LanguageSettings? = null, val error: String? = null, val saving: Boolean = false)
|
||||
|
||||
@Immutable
|
||||
data class AccountsState(
|
||||
val current: Account? = null,
|
||||
val others: List<Account> = emptyList(),
|
||||
/** LAN address the app talks to at home, null = through the public address. */
|
||||
val localUrl: String? = null,
|
||||
val server: ServerInfo? = null,
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
class SettingsViewModel @Inject constructor(
|
||||
@ApplicationContext context: Context,
|
||||
private val session: JellyfinSession,
|
||||
private val prefs: PlaybackPreferences,
|
||||
private val userSettings: UserSettingsRepository,
|
||||
private val probe: DeviceCapabilitiesProbe,
|
||||
) : ViewModel() {
|
||||
val defaults: StateFlow<PlaybackDefaults> = prefs.defaults.stateIn(viewModelScope, SharingStarted.Eagerly, PlaybackDefaults())
|
||||
val subtitleStyle: StateFlow<SubtitleStyle> = prefs.subtitleStyle.stateIn(viewModelScope, SharingStarted.Eagerly, SubtitleStyle())
|
||||
|
||||
private val _languages = MutableStateFlow(LanguagesState())
|
||||
val languages: StateFlow<LanguagesState> = _languages.asStateFlow()
|
||||
|
||||
private val server = MutableStateFlow<ServerInfo?>(null)
|
||||
val accounts: StateFlow<AccountsState> = combine(session.session, session.accounts, server) { s, all, info ->
|
||||
AccountsState(
|
||||
current = all.firstOrNull { it.id == s?.accountId },
|
||||
others = all.filter { it.id != s?.accountId },
|
||||
localUrl = session.localUrl,
|
||||
server = info,
|
||||
)
|
||||
}.stateIn(viewModelScope, SharingStarted.Eagerly, AccountsState())
|
||||
|
||||
/** The TV can show Dolby Vision through its own decoder: only then the choice is offered. */
|
||||
var dolbyVisionCapable by mutableStateOf(false)
|
||||
private set
|
||||
|
||||
val tourAvailable = BuildConfig.DEBUG && TourEntry.available(context)
|
||||
|
||||
val about = About(
|
||||
version = "${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE}) · ${BuildConfig.BUILD_TYPE}",
|
||||
device = "${Build.MANUFACTURER} ${Build.MODEL} · Android ${Build.VERSION.RELEASE}",
|
||||
)
|
||||
|
||||
init {
|
||||
loadLanguages()
|
||||
viewModelScope.launch {
|
||||
server.value = runCatching { userSettings.server() }.onFailure { Timber.w(it, "Settings: server info") }.getOrNull()
|
||||
}
|
||||
viewModelScope.launch {
|
||||
dolbyVisionCapable = withContext(Dispatchers.Default) {
|
||||
val device = probe.capabilities
|
||||
device.hardwareVideoDecoders(DV_MIME).isNotEmpty() && device.displaySupportsHdr(Display.HdrCapabilities.HDR_TYPE_DOLBY_VISION)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun loadLanguages() {
|
||||
_languages.value = LanguagesState()
|
||||
viewModelScope.launch {
|
||||
_languages.value = try {
|
||||
LanguagesState(userSettings.languages())
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Timber.w(e, "Settings: languages")
|
||||
LanguagesState(error = e.message ?: e.javaClass.simpleName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Shown at once, saved on the server in the background; on failure the old settings come back with the error. */
|
||||
fun setLanguages(settings: LanguageSettings) {
|
||||
val old = _languages.value.settings ?: return
|
||||
if (settings == old) return
|
||||
_languages.value = LanguagesState(settings, saving = true)
|
||||
viewModelScope.launch {
|
||||
_languages.value = try {
|
||||
userSettings.setLanguages(settings)
|
||||
LanguagesState(settings)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Timber.w(e, "Settings: saving languages")
|
||||
LanguagesState(old, error = "Не удалось сохранить: ${e.message ?: e.javaClass.simpleName}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setDefaults(defaults: PlaybackDefaults) {
|
||||
viewModelScope.launch { prefs.setDefaults(defaults) }
|
||||
}
|
||||
|
||||
fun setSubtitleStyle(style: SubtitleStyle) {
|
||||
viewModelScope.launch { prefs.setSubtitleStyle(style) }
|
||||
}
|
||||
|
||||
suspend fun logout() = session.logout()
|
||||
|
||||
suspend fun switchTo(accountId: String) = session.switchTo(accountId)
|
||||
|
||||
fun forget(accountId: String) {
|
||||
viewModelScope.launch { session.forget(accountId) }
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val DV_MIME = "video/dolby-vision"
|
||||
}
|
||||
}
|
||||
|
||||
@Immutable
|
||||
data class About(val version: String, val device: String)
|
||||
323
app/src/main/kotlin/ru/stenfredd/sleepyowl/ui/shell/Shell.kt
Normal file
323
app/src/main/kotlin/ru/stenfredd/sleepyowl/ui/shell/Shell.kt
Normal file
@@ -0,0 +1,323 @@
|
||||
package ru.stenfredd.sleepyowl.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.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
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.text.TextStyle
|
||||
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.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.unit.dp
|
||||
import androidx.compose.ui.zIndex
|
||||
import kotlinx.coroutines.delay
|
||||
import ru.stenfredd.sleepyowl.ui.components.AppIcon
|
||||
import ru.stenfredd.sleepyowl.ui.components.Avatar
|
||||
import ru.stenfredd.sleepyowl.ui.components.Icon
|
||||
import ru.stenfredd.sleepyowl.ui.components.OwlMark
|
||||
import ru.stenfredd.sleepyowl.ui.components.Wordmark
|
||||
import ru.stenfredd.sleepyowl.ui.theme.AppColors
|
||||
import ru.stenfredd.sleepyowl.ui.theme.AppDimens
|
||||
import ru.stenfredd.sleepyowl.ui.theme.AppType
|
||||
import java.time.LocalTime
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.util.UUID
|
||||
|
||||
enum class Tab(val label: String, val icon: AppIcon) {
|
||||
Search("Поиск", AppIcon.Search),
|
||||
Home("Главная", AppIcon.Home),
|
||||
Library("Медиатека", AppIcon.Library),
|
||||
Favorites("Избранное", AppIcon.Heart),
|
||||
/** Not in the mockup; at the bottom of the rail, above the profile. */
|
||||
Settings("Настройки", AppIcon.Settings),
|
||||
}
|
||||
|
||||
/** A library listed on the rail under the tabs; choosing it opens the library tab on it. */
|
||||
data class RailLibrary(val id: UUID, val name: String, val icon: AppIcon)
|
||||
|
||||
/**
|
||||
* 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,
|
||||
/** Avatar colour seed (user id), see [Avatar]. */
|
||||
userSeed: String,
|
||||
libraries: List<RailLibrary>,
|
||||
onTab: (Tab) -> Unit,
|
||||
onLibrary: (UUID) -> 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,
|
||||
userSeed = userSeed,
|
||||
libraries = libraries,
|
||||
entry = railEntry,
|
||||
onFocusChange = { railFocused = it },
|
||||
onTab = onTab,
|
||||
onLibrary = onLibrary,
|
||||
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,
|
||||
userSeed: String,
|
||||
libraries: List<RailLibrary>,
|
||||
entry: FocusRequester,
|
||||
onFocusChange: (Boolean) -> Unit,
|
||||
onTab: (Tab) -> Unit,
|
||||
onLibrary: (UUID) -> 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 = 1.dp, bottom = 16.dp))
|
||||
@Composable
|
||||
fun item(tab: Tab) = RailItem(
|
||||
label = tab.label,
|
||||
icon = tab.icon,
|
||||
selected = tab == current,
|
||||
progress = progress,
|
||||
onClick = { if (tab != current) onTab(tab) },
|
||||
modifier = if (tab == current) Modifier.focusRequester(entry) else Modifier,
|
||||
)
|
||||
for (tab in Tab.entries) if (tab != Tab.Settings) item(tab)
|
||||
if (libraries.isNotEmpty()) {
|
||||
RailDivider(progress)
|
||||
// Smaller than the tabs, so five fit between them and the settings; more scroll.
|
||||
Column(
|
||||
Modifier.weight(1f).verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||
) {
|
||||
for (library in libraries) {
|
||||
RailItem(
|
||||
label = library.name,
|
||||
icon = library.icon,
|
||||
selected = false,
|
||||
progress = progress,
|
||||
onClick = { onLibrary(library.id) },
|
||||
height = 30.dp,
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Spacer(Modifier.weight(1f))
|
||||
}
|
||||
item(Tab.Settings)
|
||||
ProfileItem(userName, userSeed, progress, onProfile)
|
||||
}
|
||||
}
|
||||
|
||||
/** The owl, centred over the items' icons; the wordmark shows with the open rail. */
|
||||
@Composable
|
||||
private fun Logo(progress: State<Float>, modifier: Modifier = Modifier) {
|
||||
Row(modifier, verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
OwlMark()
|
||||
Wordmark(Modifier.graphicsLayer { alpha = progress.value })
|
||||
}
|
||||
}
|
||||
|
||||
/** 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()))
|
||||
}
|
||||
|
||||
/** Line between the tabs and the libraries, as wide as an item's highlight. */
|
||||
@Composable
|
||||
private fun RailDivider(progress: State<Float>) {
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(11.dp)
|
||||
.drawBehind {
|
||||
val collapsed = (AppDimens.RAIL_COLLAPSED - 18).dp.toPx()
|
||||
val w = lerp(collapsed, size.width, progress.value)
|
||||
drawRect(AppColors.BorderSubtle, topLeft = Offset(0f, (size.height / 2).toInt().toFloat()), size = Size(w, 1.dp.toPx().coerceAtLeast(1f)))
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RailItem(
|
||||
label: String,
|
||||
icon: AppIcon,
|
||||
selected: Boolean,
|
||||
progress: State<Float>,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
height: Dp = 36.dp,
|
||||
) {
|
||||
var focused by remember { mutableStateOf(false) }
|
||||
val content = when {
|
||||
focused -> AppColors.OnFocusFill
|
||||
selected -> AppColors.Text
|
||||
else -> AppColors.TextDim
|
||||
}
|
||||
Box(modifier.fillMaxWidth().height(height)) {
|
||||
// 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(icon, content, Modifier.size(16.dp))
|
||||
BasicText(
|
||||
label,
|
||||
Modifier.graphicsLayer { alpha = progress.value },
|
||||
style = AppType.Rail.copy(color = content),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
softWrap = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ProfileItem(userName: String, userSeed: 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),
|
||||
) {
|
||||
Avatar(userName, userSeed, 24.dp, 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, style: TextStyle = AppType.Clock) {
|
||||
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 = style)
|
||||
}
|
||||
|
||||
private val ClockFormat = DateTimeFormatter.ofPattern("HH:mm")
|
||||
15
app/src/main/kotlin/ru/stenfredd/sleepyowl/ui/theme/Theme.kt
Normal file
15
app/src/main/kotlin/ru/stenfredd/sleepyowl/ui/theme/Theme.kt
Normal file
@@ -0,0 +1,15 @@
|
||||
package ru.stenfredd.sleepyowl.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/sleepyowl/ui/theme/Tokens.kt
Normal file
117
app/src/main/kotlin/ru/stenfredd/sleepyowl/ui/theme/Tokens.kt
Normal file
@@ -0,0 +1,117 @@
|
||||
package ru.stenfredd.sleepyowl.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.sleepyowl.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
|
||||
}
|
||||
Reference in New Issue
Block a user