Library screen

This commit is contained in:
2026-10-14 17:42:04 +03:00
parent 1032fe3747
commit 757ef213f8
16 changed files with 3254 additions and 564 deletions

View File

@@ -36,6 +36,7 @@ import ru.stenfredd.movies.ui.Screen
import ru.stenfredd.movies.ui.TourScreen
import ru.stenfredd.movies.ui.details.DetailsScreen
import ru.stenfredd.movies.ui.home.HomeScreen
import ru.stenfredd.movies.ui.library.LibraryScreen
import ru.stenfredd.movies.ui.player.PlayerScreen
import ru.stenfredd.movies.ui.series.SeriesScreen
import ru.stenfredd.movies.ui.login.LoginScreen
@@ -138,9 +139,10 @@ class MainActivity : ComponentActivity() {
) {
when (tab) {
Tab.Home -> HomeScreen(onOpen = onOpen)
// Until stage 7 the library tab is the old prototype: libraries, text search, debug tour, logout.
Tab.Library -> Legacy { PrototypeScreen(onOpen = onOpen, onLogout = onLogout) }
Tab.Search, Tab.Favorites -> Placeholder("${tab.label}: скоро")
Tab.Library -> LibraryScreen(onOpen = onOpen)
// Until stage 8 the search tab is the old prototype: text search, debug tour, logout.
Tab.Search -> Legacy { PrototypeScreen(onOpen = onOpen, onLogout = onLogout) }
Tab.Favorites -> Placeholder("${tab.label}: скоро")
}
}
}

View File

@@ -63,6 +63,7 @@ fun PrototypeScreen(onOpen: (Screen) -> Unit, onLogout: () -> Unit, vm: BrowseVi
var query by remember { mutableStateOf("") }
val searchInput = rememberTextInputState()
var error by remember { mutableStateOf<String?>(null) }
var confirmLogout by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
val context = LocalContext.current
val tourAvailable = remember { TourEntry.available(context) }
@@ -83,12 +84,17 @@ fun PrototypeScreen(onOpen: (Screen) -> Unit, onLogout: () -> Unit, vm: BrowseVi
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
views.forEach { v -> Button(onClick = { onOpen(Screen.Items(v.id, v.name.orEmpty())) }) { Text(v.name.orEmpty()) } }
if (tourAvailable) Button(onClick = { onOpen(Screen.Tour()) }) { Text("Режим отладки") }
// Focus may land here when the tab opens: the first OK only asks, the second one signs out.
Button(onClick = {
scope.launch {
vm.session.logout()
onLogout()
if (!confirmLogout) {
confirmLogout = true
} else {
scope.launch {
vm.session.logout()
onLogout()
}
}
}) { Text("Выйти") }
}) { Text(if (confirmLogout) "Точно выйти?" else "Выйти") }
}
}
item {

View File

@@ -3,7 +3,8 @@ package ru.stenfredd.movies.ui.components
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.tween
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.ScrollState
import androidx.compose.foundation.gestures.ScrollableState
import androidx.compose.foundation.gestures.animateScrollBy
import androidx.compose.foundation.gestures.BringIntoViewSpec
import androidx.compose.foundation.gestures.LocalBringIntoViewSpec
import androidx.compose.runtime.Composable
@@ -23,7 +24,7 @@ import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
/**
* Vertical scrolling of a screen of rows, done by hand: Compose's own bring-into-view animation can't be
* 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
@@ -31,7 +32,7 @@ import kotlinx.coroutines.launch
* bring-into-view is switched off inside [content]; rows keep the platform horizontal behaviour, see [DefaultScrolling].
*/
@Stable
class AnchoredScroll internal constructor(val scroll: ScrollState, private val scope: CoroutineScope, private val anchorPx: Float) {
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
@@ -40,9 +41,10 @@ class AnchoredScroll internal constructor(val scroll: ScrollState, private val s
if (!row.isAttached) return
val bounds = vp.localBoundingBoxOf(row, clipBounds = false)
if (bounds.top >= 0f && bounds.bottom <= vp.size.height) return
val target = (scroll.value + bounds.top - anchorPx).toInt().coerceIn(0, scroll.maxValue)
// 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.animateScrollTo(target, tween(ScrollMillis, easing = FastOutSlowInEasing)) }
job = scope.launch { scroll.animateScrollBy(delta, tween(ScrollMillis, easing = FastOutSlowInEasing)) }
}
private companion object {
@@ -52,7 +54,7 @@ class AnchoredScroll internal constructor(val scroll: ScrollState, private val s
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun AnchoredScrolling(scroll: ScrollState, anchor: Dp, content: @Composable (AnchoredScroll) -> Unit) {
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() }
@@ -63,10 +65,10 @@ fun AnchoredScrolling(scroll: ScrollState, anchor: Dp, content: @Composable (Anc
}
/** The scroll container's viewport: put it before `verticalScroll`. */
fun Modifier.anchorViewport(state: AnchoredScroll): Modifier = onPlaced { state.viewport = it }
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 {
fun Modifier.anchoredRow(state: AnchoredScroll<*>): Modifier {
var coords: LayoutCoordinates? = null
return onPlaced { coords = it }.onFocusChanged { if (it.hasFocus) coords?.let(state::bring) }
}

View File

@@ -0,0 +1,43 @@
package ru.stenfredd.movies.ui.library
import ru.stenfredd.movies.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,297 @@
package ru.stenfredd.movies.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.GridItemSpan
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.layout.layout
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.movies.ui.Screen
import ru.stenfredd.movies.ui.components.AnchoredScroll
import ru.stenfredd.movies.ui.components.AnchoredScrolling
import ru.stenfredd.movies.ui.components.Chip
import ru.stenfredd.movies.ui.components.ContentRow
import ru.stenfredd.movies.ui.components.DefaultScrolling
import ru.stenfredd.movies.ui.components.FocusStyle
import ru.stenfredd.movies.ui.components.PosterCard
import ru.stenfredd.movies.ui.components.WideCard
import ru.stenfredd.movies.ui.components.anchorViewport
import ru.stenfredd.movies.ui.components.anchoredRow
import ru.stenfredd.movies.ui.components.focusCard
import ru.stenfredd.movies.ui.focus.Restore
import ru.stenfredd.movies.ui.focus.ScreenFocus
import ru.stenfredd.movies.ui.focus.rememberScreenFocus
import ru.stenfredd.movies.ui.focus.remembered
import ru.stenfredd.movies.ui.theme.AppColors
import ru.stenfredd.movies.ui.theme.AppDimens
import ru.stenfredd.movies.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).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).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).padding(top = RowGap).anchoredRow(anchored)) {
items(posters, key = { it.key }, contentType = { "poster" }) { p ->
PosterCard(p, { open(p.key) }, Modifier.remembered(focus, p.key))
}
}
}
}
}
private fun androidx.compose.foundation.lazy.grid.LazyGridScope.fullItem(key: String, type: String, content: @Composable () -> Unit) =
item(key = key, span = { GridItemSpan(maxLineSpan) }, contentType = type) { content() }
/** 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(),
state = rememberLazyListState(),
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() }
}
}
/**
* A full-width item of the grid laid out [width] wide from the screen's content edge, not inside the grid's side
* padding: rows of cards scroll from edge to edge, as on home.
*/
private fun Modifier.bleed(width: 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) }
}
@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.movies.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.movies.core.data.LibraryRepository
import ru.stenfredd.movies.core.data.SessionCache
import ru.stenfredd.movies.core.data.WatchRepository
import ru.stenfredd.movies.core.data.model.ItemKind
import ru.stenfredd.movies.core.data.model.ItemQuery
import ru.stenfredd.movies.core.data.model.ItemSort
import ru.stenfredd.movies.core.data.model.ItemSummary
import ru.stenfredd.movies.core.data.model.LibraryType
import ru.stenfredd.movies.core.data.model.LibraryView
import ru.stenfredd.movies.core.data.model.itemKinds
import ru.stenfredd.movies.core.jellyfin.JellyfinSession
import ru.stenfredd.movies.ui.Screen
import ru.stenfredd.movies.ui.components.PosterModel
import ru.stenfredd.movies.ui.components.WideModel
import ru.stenfredd.movies.ui.model.CardMapper
import ru.stenfredd.movies.ui.model.daysAgo
import ru.stenfredd.movies.ui.model.plural
import ru.stenfredd.movies.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.movies.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.movies.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,35 @@
package ru.stenfredd.movies.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

@@ -29,6 +29,7 @@ import ru.stenfredd.movies.playback.core.PlaybackSession
import ru.stenfredd.movies.playback.core.PlaybackSessionFactory
import ru.stenfredd.movies.ui.Screen
import ru.stenfredd.movies.ui.components.RemoteImageSpec
import ru.stenfredd.movies.ui.model.plural
import ru.stenfredd.movies.ui.details.CastModel
import ru.stenfredd.movies.ui.details.MediaLabels
import ru.stenfredd.movies.ui.model.CardMapper
@@ -291,17 +292,6 @@ class PlayerViewModel @javax.inject.Inject constructor(
}
}
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
}
}
/** Sizes (dp) shared by the layout and the image requests. */
internal object PlayerLayout {
const val EPISODE_W = 180f

View File

@@ -36,7 +36,7 @@ import ru.stenfredd.movies.ui.details.TrackChoices
import ru.stenfredd.movies.ui.details.TrackOption
import ru.stenfredd.movies.ui.model.CardMapper
import ru.stenfredd.movies.ui.player.PlayerLabels
import ru.stenfredd.movies.ui.player.plural
import ru.stenfredd.movies.ui.model.plural
import timber.log.Timber
import java.time.Instant
import java.time.ZoneId

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff