Library screen
This commit is contained in:
@@ -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}: скоро")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 = {
|
||||
if (!confirmLogout) {
|
||||
confirmLogout = true
|
||||
} else {
|
||||
scope.launch {
|
||||
vm.session.logout()
|
||||
onLogout()
|
||||
}
|
||||
}) { Text("Выйти") }
|
||||
}
|
||||
}) { Text(if (confirmLogout) "Точно выйти?" else "Выйти") }
|
||||
}
|
||||
}
|
||||
item {
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
124
app/src/main/kotlin/ru/stenfredd/movies/ui/library/TileArt.kt
Normal file
124
app/src/main/kotlin/ru/stenfredd/movies/ui/library/TileArt.kt
Normal 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
|
||||
35
app/src/main/kotlin/ru/stenfredd/movies/ui/model/Words.kt
Normal file
35
app/src/main/kotlin/ru/stenfredd/movies/ui/model/Words.kt
Normal 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, "год", "года", "лет")} назад" }
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -14,7 +14,8 @@ import org.junit.runner.RunWith
|
||||
* interpreted and JIT-compiled while the user scrolls (first passes over a screen used to drop frames).
|
||||
*
|
||||
* Path: cold start → home (snapshot, then fresh data) → along "Continue watching" → down through the rows, along
|
||||
* each → open a card, down through its page → back. With the instrumentation argument `seriesId` also a series page:
|
||||
* each → open a card, down through its page → back → library tab: along the tiles, down through the rows and the grid
|
||||
* → back up. With the instrumentation argument `seriesId` also a series page:
|
||||
* along the buttons, the season chips, the episode row, the audio/subtitle tray. The device must be logged in.
|
||||
*
|
||||
* Run: `./gradlew :app:generateReleaseBaselineProfile [-Pandroid.testInstrumentationRunnerArguments.seriesId=<id>]`
|
||||
@@ -56,6 +57,28 @@ class BaselineProfileGenerator {
|
||||
press { device.pressBack() }
|
||||
Thread.sleep(1_000)
|
||||
|
||||
// Library tab: from the rail (Back on home focuses the rail at "Главная", the next item is "Медиатека").
|
||||
press { device.pressBack() }
|
||||
press { device.pressDPadDown() }
|
||||
press { device.pressDPadCenter() }
|
||||
check(device.wait(Until.hasObject(By.text("БИБЛИОТЕКА")), 10_000)) { "Library did not show" }
|
||||
Thread.sleep(1_500)
|
||||
// Each tile shows its library once focus rests on it.
|
||||
repeat(2) {
|
||||
press { device.pressDPadRight() }
|
||||
Thread.sleep(1_000)
|
||||
}
|
||||
repeat(2) {
|
||||
press { device.pressDPadLeft() }
|
||||
Thread.sleep(1_000)
|
||||
}
|
||||
// Rows, the sort chips and the grid (its next page loads on the way).
|
||||
repeat(LIBRARY_ROWS) {
|
||||
press { device.pressDPadDown() }
|
||||
press { device.pressDPadRight() }
|
||||
}
|
||||
repeat(LIBRARY_ROWS) { press { device.pressDPadUp() } }
|
||||
|
||||
val seriesId = InstrumentationRegistry.getArguments().getString("seriesId") ?: return@collect
|
||||
startActivityAndWait { it.putExtra("series", seriesId) }
|
||||
Thread.sleep(2_000)
|
||||
@@ -87,5 +110,6 @@ class BaselineProfileGenerator {
|
||||
private companion object {
|
||||
const val PACKAGE = "ru.stenfredd.movies"
|
||||
const val ROWS = 6
|
||||
const val LIBRARY_ROWS = 14
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ import kotlinx.serialization.json.Json
|
||||
import ru.stenfredd.movies.core.data.model.ItemQuery
|
||||
import ru.stenfredd.movies.core.data.model.ItemSummary
|
||||
import ru.stenfredd.movies.core.data.model.LibraryView
|
||||
import ru.stenfredd.movies.core.data.model.itemKinds
|
||||
import ru.stenfredd.movies.core.jellyfin.JellyfinSession
|
||||
import timber.log.Timber
|
||||
import java.io.File
|
||||
@@ -115,7 +116,7 @@ class HomeRepository @Inject constructor(
|
||||
val counted = fresh.copy(
|
||||
libraries = coroutineScope {
|
||||
fresh.libraries.map { row ->
|
||||
async { row.copy(total = runCatching { library.count(ItemQuery(parentId = row.library.id)) }.getOrNull() ?: row.total) }
|
||||
async { row.copy(total = runCatching { library.count(ItemQuery(parentId = row.library.id, kinds = row.library.itemKinds)) }.getOrNull() ?: row.total) }
|
||||
}.awaitAll()
|
||||
},
|
||||
)
|
||||
|
||||
@@ -26,37 +26,38 @@ class WatchRepository @Inject constructor(
|
||||
) {
|
||||
private val userId get() = session.requireSession().userId
|
||||
|
||||
/** Started movies and episodes, most recently watched first. */
|
||||
suspend fun resume(limit: Int = 20): List<ItemSummary> = cache.get("resume:$limit", Kind.UserData) {
|
||||
/** Started movies and episodes, most recently watched first; of one library when [parentId] is given. */
|
||||
suspend fun resume(limit: Int = 20, parentId: UUID? = null): List<ItemSummary> = cache.get("resume:$limit:$parentId", Kind.UserData) {
|
||||
session.call {
|
||||
libraryApi.getResumeItems(userId = userId, limit = limit, mediaTypes = listOf(MediaType.VIDEO), fields = FEATURED_FIELDS).content.items
|
||||
libraryApi.getResumeItems(userId = userId, parentId = parentId, limit = limit, mediaTypes = listOf(MediaType.VIDEO), fields = FEATURED_FIELDS).content.items
|
||||
}.map { it.toSummary() }
|
||||
}
|
||||
|
||||
/** The next unwatched episode of each series in progress. */
|
||||
suspend fun nextUp(limit: Int = 20): List<ItemSummary> = cache.get("nextup:$limit", Kind.UserData) {
|
||||
/** The next unwatched episode of each series in progress; of one library when [parentId] is given. */
|
||||
suspend fun nextUp(limit: Int = 20, parentId: UUID? = null): List<ItemSummary> = cache.get("nextup:$limit:$parentId", Kind.UserData) {
|
||||
session.call {
|
||||
showApi.getNextUp(userId = userId, limit = limit, fields = FEATURED_FIELDS, enableResumable = false, enableRewatching = false).content.items
|
||||
showApi.getNextUp(userId = userId, parentId = parentId, limit = limit, fields = FEATURED_FIELDS, enableResumable = false, enableRewatching = false).content.items
|
||||
}.map { it.toSummary() }
|
||||
}
|
||||
|
||||
/**
|
||||
* "Continue watching": started items first (most recent first), then the next episode of each series in progress
|
||||
* that isn't already there.
|
||||
* that isn't already there. Of one library when [parentId] is given.
|
||||
*/
|
||||
suspend fun continueWatching(limit: Int = 20): List<ItemSummary> = coroutineScope {
|
||||
val resume = async { resume(limit) }
|
||||
val nextUp = async { nextUp(limit) }
|
||||
suspend fun continueWatching(limit: Int = 20, parentId: UUID? = null): List<ItemSummary> = coroutineScope {
|
||||
val resume = async { resume(limit, parentId) }
|
||||
val nextUp = async { nextUp(limit, parentId) }
|
||||
val started = resume.await()
|
||||
val seriesStarted = started.mapNotNullTo(HashSet()) { it.seriesId }
|
||||
(started + nextUp.await().filter { it.seriesId !in seriesStarted && started.none { s -> s.id == it.id } }).take(limit)
|
||||
}
|
||||
|
||||
/** Watched movies and episodes, most recent first. */
|
||||
suspend fun history(start: Int, limit: Int): Page<ItemSummary> = library.items(
|
||||
ItemQuery(kinds = setOf(ItemKind.Movie, ItemKind.Episode), sort = ItemSort.DatePlayed, descending = true, playedOnly = true),
|
||||
/** Watched movies and episodes, most recent first; of one library when [parentId] is given. */
|
||||
suspend fun history(start: Int, limit: Int, parentId: UUID? = null): Page<ItemSummary> = library.items(
|
||||
ItemQuery(parentId = parentId, kinds = setOf(ItemKind.Movie, ItemKind.Episode), sort = ItemSort.DatePlayed, descending = true, playedOnly = true),
|
||||
start,
|
||||
limit,
|
||||
withTotal = false,
|
||||
)
|
||||
|
||||
suspend fun favorites(start: Int, limit: Int, kinds: Set<ItemKind> = setOf(ItemKind.Movie, ItemKind.Series)): Page<ItemSummary> =
|
||||
|
||||
@@ -78,6 +78,17 @@ data class LibraryView(val id: UUID, val name: String, val type: LibraryType, va
|
||||
|
||||
enum class LibraryType { Movies, Shows, Mixed, Other }
|
||||
|
||||
/**
|
||||
* What a library's lists and counts ask for. One type where the library has one: the server takes ~10× longer
|
||||
* (0.2 s instead of 0.02) to list or count "movies and series" than just movies.
|
||||
*/
|
||||
val LibraryView.itemKinds: Set<ItemKind>
|
||||
get() = when (type) {
|
||||
LibraryType.Movies -> setOf(ItemKind.Movie)
|
||||
LibraryType.Shows -> setOf(ItemKind.Series)
|
||||
else -> setOf(ItemKind.Movie, ItemKind.Series)
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class Person(val id: UUID, val name: String, val role: String?, val kind: PersonKind, val image: ImageRef?)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user