Rename to SleepyOwl, add logo

This commit is contained in:
2026-10-25 00:53:19 +03:00
parent 33643707f1
commit f63b09a9d3
130 changed files with 8010 additions and 7797 deletions

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

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

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

View File

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

View 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(" · ")
}

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View 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

View File

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

View File

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

View File

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

View 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 непросмотренным", "Отметки и места остановки всех серий сбросятся"))
}
}

View File

@@ -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()} мин"
}

View 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, "год", "года", "лет")} назад" }
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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")

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

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