loading: drop the snapshot cache, one loading card per thing (#25)
The saved-list snapshot (issue #9) painted the last session's rows instantly, then the network refresh swapped them in and the tiles jumped as newer videos appeared — jarring. Drop the snapshot instant-paint on Home, the video lists and the Channels screen; images stay fast because Coil already disk-caches them. Screens now show the loading card while the first page loads instead of stale content: one card per Home row (not a fake full row), a single card at the end of a growing grid, and a trailing card when a Home row loads its next page. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
e485caa855
commit
4176edfd69
4 changed files with 48 additions and 156 deletions
|
|
@ -10,7 +10,6 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import net.jeena.jtubetv.AppGraph
|
import net.jeena.jtubetv.AppGraph
|
||||||
import net.jeena.jtubetv.data.PeerTubeRepository
|
import net.jeena.jtubetv.data.PeerTubeRepository
|
||||||
import net.jeena.jtubetv.ui.list.Snapshots
|
|
||||||
|
|
||||||
/** Where the list's content comes from. */
|
/** Where the list's content comes from. */
|
||||||
sealed interface ListSource {
|
sealed interface ListSource {
|
||||||
|
|
@ -56,16 +55,6 @@ class VideoListViewModel(
|
||||||
var videoTotal by androidx.compose.runtime.mutableStateOf(0L)
|
var videoTotal by androidx.compose.runtime.mutableStateOf(0L)
|
||||||
private set
|
private set
|
||||||
|
|
||||||
// Snapshot caching (issue #9): live content lists only — Search results and
|
|
||||||
// History rows (which carry extra watch metadata) must not persist.
|
|
||||||
private val shouldSkipSnapshot =
|
|
||||||
source is ListSource.Search || source is ListSource.History
|
|
||||||
|
|
||||||
private fun cacheKey(): String = when (val s = source) {
|
|
||||||
is ListSource.Channel -> "channel-" + s.handle
|
|
||||||
else -> s::class.java.simpleName
|
|
||||||
}
|
|
||||||
|
|
||||||
init {
|
init {
|
||||||
// Probe whether the instance allows the search-index ("Vidiverse") scope.
|
// Probe whether the instance allows the search-index ("Vidiverse") scope.
|
||||||
if (source is ListSource.Search) {
|
if (source is ListSource.Search) {
|
||||||
|
|
@ -74,24 +63,10 @@ class VideoListViewModel(
|
||||||
repository.serverConfig().getOrNull()?.search?.searchIndex?.enabled == true
|
repository.serverConfig().getOrNull()?.search?.searchIndex?.enabled == true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val context = AppGraph.appContext
|
// No snapshot instant-paint any more (issue #25): the saved-list cache
|
||||||
if (context != null && !shouldSkipSnapshot) {
|
// repainted stale rows that then jumped when the fresh page landed with
|
||||||
viewModelScope.launch(kotlinx.coroutines.Dispatchers.IO) {
|
// newer videos. Screens now show the loading card until the first page
|
||||||
val videos = Snapshots.loadVideos(context, cacheKey()) ?: return@launch
|
// arrives; images stay fast via Coil's own disk cache.
|
||||||
kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.Main) {
|
|
||||||
showInstant(videos.map { VideoListItem.Video(it) })
|
|
||||||
}
|
|
||||||
// Channel page banner: restore the saved one instantly too.
|
|
||||||
val s = source
|
|
||||||
if (s is ListSource.Channel) {
|
|
||||||
Snapshots.loadChannelHeader(context, s.handle)?.let { header ->
|
|
||||||
kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.Main) {
|
|
||||||
channelHeader = header
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onChunkLoaded(chunk: List<VideoListItem>) {
|
override fun onChunkLoaded(chunk: List<VideoListItem>) {
|
||||||
|
|
@ -103,20 +78,6 @@ class VideoListViewModel(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
// Persist the freshly loaded content so the next app start paints
|
|
||||||
// instantly (issue #9).
|
|
||||||
if (!shouldSkipSnapshot && chunk.all { it is VideoListItem.Video }) {
|
|
||||||
val context = AppGraph.appContext
|
|
||||||
if (context != null) {
|
|
||||||
viewModelScope.launch(kotlinx.coroutines.Dispatchers.IO) {
|
|
||||||
Snapshots.saveVideos(
|
|
||||||
context,
|
|
||||||
cacheKey(),
|
|
||||||
items.filterIsInstance<VideoListItem.Video>().take(50).map { it.video },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun fetch(start: Long, count: Int): Result<List<VideoListItem>> {
|
override suspend fun fetch(start: Long, count: Int): Result<List<VideoListItem>> {
|
||||||
|
|
@ -150,22 +111,9 @@ class VideoListViewModel(
|
||||||
fun loadChannelHeader(repository: PeerTubeRepository) {
|
fun loadChannelHeader(repository: PeerTubeRepository) {
|
||||||
val s = source
|
val s = source
|
||||||
if (s is ListSource.Channel) {
|
if (s is ListSource.Channel) {
|
||||||
// Instant restore from the snapshot, then a fresh fetch (issue #9).
|
|
||||||
val context = AppGraph.appContext
|
|
||||||
if (context != null) {
|
|
||||||
Snapshots.loadChannelHeader(context, s.handle)?.let { snap ->
|
|
||||||
if (channelHeader == null) channelHeader = snap
|
|
||||||
}
|
|
||||||
}
|
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
repository.videoChannel(s.handle).getOrNull()?.let { header ->
|
repository.videoChannel(s.handle).getOrNull()?.let { header ->
|
||||||
channelHeader = header
|
channelHeader = header
|
||||||
val ctx = AppGraph.appContext
|
|
||||||
if (ctx != null) {
|
|
||||||
kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) {
|
|
||||||
Snapshots.saveChannelHeader(ctx, s.handle, header)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -94,10 +94,6 @@ data class UserGroup(
|
||||||
|
|
||||||
class ChannelsViewModel(private val store: TokenStore) : ViewModel() {
|
class ChannelsViewModel(private val store: TokenStore) : ViewModel() {
|
||||||
|
|
||||||
private val snapshotContext = AppGraph.appContext
|
|
||||||
private val snapshotKey = "channels-" + (store.baseUrl ?: "").replace(Regex("[^A-Za-z0-9._-]"), "_")
|
|
||||||
private val snapshotRows = snapshotContext?.let { Snapshots.loadUserGroups(it, snapshotKey) }
|
|
||||||
|
|
||||||
var user by mutableStateOf<UserDto?>(null)
|
var user by mutableStateOf<UserDto?>(null)
|
||||||
private set
|
private set
|
||||||
var userChannels by mutableStateOf<List<VideoChannelDto>>(emptyList())
|
var userChannels by mutableStateOf<List<VideoChannelDto>>(emptyList())
|
||||||
|
|
@ -127,20 +123,9 @@ class ChannelsViewModel(private val store: TokenStore) : ViewModel() {
|
||||||
/** SystemClock at the last successful fast-path pass — re-entry freshness. */
|
/** SystemClock at the last successful fast-path pass — re-entry freshness. */
|
||||||
private var lastLoadedAt = 0L
|
private var lastLoadedAt = 0L
|
||||||
|
|
||||||
init {
|
// No snapshot instant-paint any more (issue #25): the Channels screen shows
|
||||||
// Snapshot restore (issue #9): paint the last session's channel rows
|
// its shimmer placeholder rows until the (fast-path, few-request) load lands,
|
||||||
// instantly, including the per-channel stats (the "minutes of loading"
|
// instead of painting a stale snapshot that reshuffled as stats streamed in.
|
||||||
// complaint) — the streaming reload below then updates them in place.
|
|
||||||
if (snapshotRows != null) {
|
|
||||||
userRows = snapshotRows
|
|
||||||
loading = false
|
|
||||||
}
|
|
||||||
if (snapshotContext != null) {
|
|
||||||
statsMap.putAll(Snapshots.loadChannelStats(snapshotContext, snapshotKey))
|
|
||||||
if (statsMap.isNotEmpty()) stats = statsMap.toMap()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
fun load(force: Boolean = false) {
|
fun load(force: Boolean = false) {
|
||||||
// Re-entry (destination switch away + back) inside the freshness window:
|
// Re-entry (destination switch away + back) inside the freshness window:
|
||||||
|
|
@ -225,11 +210,6 @@ class ChannelsViewModel(private val store: TokenStore) : ViewModel() {
|
||||||
stats = statsMap.toMap()
|
stats = statsMap.toMap()
|
||||||
rebuildRows(host)
|
rebuildRows(host)
|
||||||
lastLoadedAt = android.os.SystemClock.elapsedRealtime()
|
lastLoadedAt = android.os.SystemClock.elapsedRealtime()
|
||||||
snapshotContext?.let { ctx ->
|
|
||||||
viewModelScope.launch(kotlinx.coroutines.Dispatchers.IO) {
|
|
||||||
Snapshots.saveChannelStats(ctx, snapshotKey, statsMap.toMap())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ImagePrefetcher.channelArt(allChannels + userChannels)
|
ImagePrefetcher.channelArt(allChannels + userChannels)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -303,12 +283,6 @@ class ChannelsViewModel(private val store: TokenStore) : ViewModel() {
|
||||||
.thenByDescending { g -> g.channels.sumOf { statsMap[it.name + "@" + it.host]?.videos ?: 0 } }
|
.thenByDescending { g -> g.channels.sumOf { statsMap[it.name + "@" + it.host]?.videos ?: 0 } }
|
||||||
.thenByDescending { g -> g.channels.sumOf { it.followersCount } },
|
.thenByDescending { g -> g.channels.sumOf { it.followersCount } },
|
||||||
)
|
)
|
||||||
// Persist for the next cold start (issue #9).
|
|
||||||
snapshotContext?.let { ctx ->
|
|
||||||
viewModelScope.launch(kotlinx.coroutines.Dispatchers.IO) {
|
|
||||||
Snapshots.saveUserGroups(ctx, snapshotKey, userRows)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Log.w(
|
Log.w(
|
||||||
"JTube",
|
"JTube",
|
||||||
"order now: " + userRows.take(6).joinToString(" | ") { g ->
|
"order now: " + userRows.take(6).joinToString(" | ") { g ->
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,6 @@ import net.jeena.jtubetv.AppGraph
|
||||||
import net.jeena.jtubetv.data.PeerTubeRepository
|
import net.jeena.jtubetv.data.PeerTubeRepository
|
||||||
import net.jeena.jtubetv.data.api.VideoDto
|
import net.jeena.jtubetv.data.api.VideoDto
|
||||||
import net.jeena.jtubetv.ui.list.PagedListViewModel
|
import net.jeena.jtubetv.ui.list.PagedListViewModel
|
||||||
import net.jeena.jtubetv.ui.list.Snapshots
|
|
||||||
import net.jeena.jtubetv.ui.screens.list.ShimmerCard
|
import net.jeena.jtubetv.ui.screens.list.ShimmerCard
|
||||||
import net.jeena.jtubetv.ui.screens.list.VideoCard
|
import net.jeena.jtubetv.ui.screens.list.VideoCard
|
||||||
|
|
||||||
|
|
@ -58,8 +57,6 @@ data class ContinueItem(val video: VideoDto, val progress: Float?)
|
||||||
|
|
||||||
class HomeRowsViewModel(private val repository: PeerTubeRepository) : ViewModel() {
|
class HomeRowsViewModel(private val repository: PeerTubeRepository) : ViewModel() {
|
||||||
|
|
||||||
private fun contextOrNull() = AppGraph.appContext
|
|
||||||
|
|
||||||
var continueItems by mutableStateOf<List<ContinueItem>>(emptyList())
|
var continueItems by mutableStateOf<List<ContinueItem>>(emptyList())
|
||||||
private set
|
private set
|
||||||
var continueLoading by mutableStateOf(true)
|
var continueLoading by mutableStateOf(true)
|
||||||
|
|
@ -76,36 +73,24 @@ class HomeRowsViewModel(private val repository: PeerTubeRepository) : ViewModel(
|
||||||
private var continueInFlight = false
|
private var continueInFlight = false
|
||||||
var continueExhausted by mutableStateOf(false)
|
var continueExhausted by mutableStateOf(false)
|
||||||
private set
|
private set
|
||||||
|
/** True while the next continue page is loading — drives a trailing loading card (#25). */
|
||||||
|
var continueLoadingMore by mutableStateOf(false)
|
||||||
|
private set
|
||||||
private var subsStart = 0L
|
private var subsStart = 0L
|
||||||
private var subsInFlight = false
|
private var subsInFlight = false
|
||||||
var subsExhausted by mutableStateOf(false)
|
var subsExhausted by mutableStateOf(false)
|
||||||
private set
|
private set
|
||||||
|
var subsLoadingMore by mutableStateOf(false)
|
||||||
|
private set
|
||||||
/** Exposed for the shimmer placeholders (issue #19). */
|
/** Exposed for the shimmer placeholders (issue #19). */
|
||||||
var subsLoading = false
|
var subsLoading = false
|
||||||
private set
|
private set
|
||||||
|
|
||||||
val recent = RecentVideosViewModel(repository)
|
val recent = RecentVideosViewModel(repository)
|
||||||
|
|
||||||
init {
|
// No snapshot instant-paint any more (issue #25): Home shows one loading card
|
||||||
// Snapshot restore (issue #9): Home paints from the last session's rows
|
// per row until the network pages land, instead of painting a stale snapshot
|
||||||
// immediately; the loads below freshen them from the network.
|
// that then jumped as newer videos arrived.
|
||||||
val context = contextOrNull()
|
|
||||||
if (context != null) {
|
|
||||||
viewModelScope.launch(kotlinx.coroutines.Dispatchers.IO) {
|
|
||||||
val savedContinue = Snapshots.loadContinue(context, "home-continue")
|
|
||||||
val savedSubs = Snapshots.loadVideos(context, "home-subs")
|
|
||||||
kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.Main) {
|
|
||||||
if (continueItems.isEmpty() && savedContinue != null) {
|
|
||||||
continueItems = savedContinue
|
|
||||||
continueLoading = false
|
|
||||||
}
|
|
||||||
if (subItems.isEmpty() && savedSubs != null) {
|
|
||||||
subItems = savedSubs
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun fetchSubs() {
|
private fun fetchSubs() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
|
|
@ -116,12 +101,6 @@ class HomeRowsViewModel(private val repository: PeerTubeRepository) : ViewModel(
|
||||||
subItems = subs
|
subItems = subs
|
||||||
subsLoading = false
|
subsLoading = false
|
||||||
net.jeena.jtubetv.ui.list.ImagePrefetcher.videoThumbnails(subs)
|
net.jeena.jtubetv.ui.list.ImagePrefetcher.videoThumbnails(subs)
|
||||||
// Snapshot for the next cold start (issue #9).
|
|
||||||
contextOrNull()?.let { ctx ->
|
|
||||||
kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) {
|
|
||||||
Snapshots.saveVideos(ctx, "home-subs", subs)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -177,6 +156,7 @@ class HomeRowsViewModel(private val repository: PeerTubeRepository) : ViewModel(
|
||||||
fun continueMore() {
|
fun continueMore() {
|
||||||
if (continueInFlight || continueExhausted || continueItems.isEmpty()) return
|
if (continueInFlight || continueExhausted || continueItems.isEmpty()) return
|
||||||
continueInFlight = true
|
continueInFlight = true
|
||||||
|
continueLoadingMore = true
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val page = fetchContinuePage()
|
val page = fetchContinuePage()
|
||||||
if (page.isNotEmpty()) {
|
if (page.isNotEmpty()) {
|
||||||
|
|
@ -184,6 +164,7 @@ class HomeRowsViewModel(private val repository: PeerTubeRepository) : ViewModel(
|
||||||
net.jeena.jtubetv.ui.list.ImagePrefetcher.videoThumbnails(page.map { it.video })
|
net.jeena.jtubetv.ui.list.ImagePrefetcher.videoThumbnails(page.map { it.video })
|
||||||
}
|
}
|
||||||
continueInFlight = false
|
continueInFlight = false
|
||||||
|
continueLoadingMore = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -191,6 +172,7 @@ class HomeRowsViewModel(private val repository: PeerTubeRepository) : ViewModel(
|
||||||
fun subsMore() {
|
fun subsMore() {
|
||||||
if (subsInFlight || subsExhausted || subItems.isEmpty()) return
|
if (subsInFlight || subsExhausted || subItems.isEmpty()) return
|
||||||
subsInFlight = true
|
subsInFlight = true
|
||||||
|
subsLoadingMore = true
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val page = repository.subscriptionVideos(subsStart, SUB_PAGE).getOrNull()?.data ?: emptyList()
|
val page = repository.subscriptionVideos(subsStart, SUB_PAGE).getOrNull()?.data ?: emptyList()
|
||||||
subsStart += page.size
|
subsStart += page.size
|
||||||
|
|
@ -200,6 +182,7 @@ class HomeRowsViewModel(private val repository: PeerTubeRepository) : ViewModel(
|
||||||
net.jeena.jtubetv.ui.list.ImagePrefetcher.videoThumbnails(page)
|
net.jeena.jtubetv.ui.list.ImagePrefetcher.videoThumbnails(page)
|
||||||
}
|
}
|
||||||
subsInFlight = false
|
subsInFlight = false
|
||||||
|
subsLoadingMore = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -211,12 +194,6 @@ class HomeRowsViewModel(private val repository: PeerTubeRepository) : ViewModel(
|
||||||
continueItems = items
|
continueItems = items
|
||||||
continueLoading = false
|
continueLoading = false
|
||||||
net.jeena.jtubetv.ui.list.ImagePrefetcher.videoThumbnails(items.map { it.video })
|
net.jeena.jtubetv.ui.list.ImagePrefetcher.videoThumbnails(items.map { it.video })
|
||||||
// Snapshot for the next cold start (issue #9).
|
|
||||||
contextOrNull()?.let { ctx ->
|
|
||||||
kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) {
|
|
||||||
Snapshots.saveContinue(ctx, "home-continue", items)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -236,28 +213,8 @@ class HomeRowsViewModel(private val repository: PeerTubeRepository) : ViewModel(
|
||||||
/** Paged "New videos" row content. */
|
/** Paged "New videos" row content. */
|
||||||
class RecentVideosViewModel(private val repository: PeerTubeRepository) : PagedListViewModel<VideoDto>() {
|
class RecentVideosViewModel(private val repository: PeerTubeRepository) : PagedListViewModel<VideoDto>() {
|
||||||
|
|
||||||
// Instant restore from the last session (issue #9); showInstant() refreshes
|
|
||||||
// the first page in the background.
|
|
||||||
init {
|
|
||||||
val context = AppGraph.appContext
|
|
||||||
if (context != null) {
|
|
||||||
viewModelScope.launch(kotlinx.coroutines.Dispatchers.IO) {
|
|
||||||
Snapshots.loadVideos(context, "RecentVideos")?.let { snapshot ->
|
|
||||||
kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.Main) {
|
|
||||||
showInstant(snapshot)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onChunkLoaded(chunk: List<VideoDto>) {
|
override fun onChunkLoaded(chunk: List<VideoDto>) {
|
||||||
net.jeena.jtubetv.ui.list.ImagePrefetcher.videoThumbnails(chunk)
|
net.jeena.jtubetv.ui.list.ImagePrefetcher.videoThumbnails(chunk)
|
||||||
AppGraph.appContext?.let { ctx ->
|
|
||||||
viewModelScope.launch(kotlinx.coroutines.Dispatchers.IO) {
|
|
||||||
Snapshots.saveVideos(ctx, "RecentVideos", items.take(50))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun fetch(start: Long, count: Int): Result<List<VideoDto>> =
|
override suspend fun fetch(start: Long, count: Int): Result<List<VideoDto>> =
|
||||||
|
|
@ -315,9 +272,10 @@ fun HomeRowsScreen(
|
||||||
}
|
}
|
||||||
item {
|
item {
|
||||||
if (viewModel.continueLoading && viewModel.continueItems.isEmpty()) {
|
if (viewModel.continueLoading && viewModel.continueItems.isEmpty()) {
|
||||||
// Dummy cards with the faint shimmer while the row loads (#19);
|
// One placeholder card per row while it loads (#25): a single card
|
||||||
|
// reads as "this row is coming", not a fake full row of content.
|
||||||
// "Watch something first" only when there is genuinely no history.
|
// "Watch something first" only when there is genuinely no history.
|
||||||
ShimmerRow()
|
ShimmerRow(size = 1)
|
||||||
} else if (viewModel.continueItems.isEmpty()) {
|
} else if (viewModel.continueItems.isEmpty()) {
|
||||||
Box(Modifier.fillParentMaxWidth(0f).padding(start = 16.dp)) {
|
Box(Modifier.fillParentMaxWidth(0f).padding(start = 16.dp)) {
|
||||||
Text(
|
Text(
|
||||||
|
|
@ -339,6 +297,7 @@ fun HomeRowsScreen(
|
||||||
autoFocusOnLoad = true,
|
autoFocusOnLoad = true,
|
||||||
autoFocusAllowed = { !userNavigated },
|
autoFocusAllowed = { !userNavigated },
|
||||||
refocusPulse = drawerPulse,
|
refocusPulse = drawerPulse,
|
||||||
|
trailingLoading = viewModel.continueLoadingMore,
|
||||||
) { index, video ->
|
) { index, video ->
|
||||||
// Grow the row on demand (issue #18), same hook as the recent row.
|
// Grow the row on demand (issue #18), same hook as the recent row.
|
||||||
if (index >= viewModel.continueItems.size - PagedListViewModel.LOOKAHEAD) {
|
if (index >= viewModel.continueItems.size - PagedListViewModel.LOOKAHEAD) {
|
||||||
|
|
@ -358,7 +317,7 @@ fun HomeRowsScreen(
|
||||||
item {
|
item {
|
||||||
if (recentItems.isEmpty()) {
|
if (recentItems.isEmpty()) {
|
||||||
LaunchedEffect("kick-recent") { viewModel.recent.loadMore() }
|
LaunchedEffect("kick-recent") { viewModel.recent.loadMore() }
|
||||||
ShimmerRow()
|
ShimmerRow(size = 1)
|
||||||
} else {
|
} else {
|
||||||
FocusScrollRow(
|
FocusScrollRow(
|
||||||
videos = recentItems,
|
videos = recentItems,
|
||||||
|
|
@ -368,6 +327,7 @@ fun HomeRowsScreen(
|
||||||
clickedIndex = recentRestore,
|
clickedIndex = recentRestore,
|
||||||
restoreToken = restoreToken,
|
restoreToken = restoreToken,
|
||||||
refocusPulse = drawerPulse,
|
refocusPulse = drawerPulse,
|
||||||
|
trailingLoading = viewModel.recent.loadingMore,
|
||||||
) { index, video ->
|
) { index, video ->
|
||||||
if (index >= recentItems.size - PagedListViewModel.LOOKAHEAD) {
|
if (index >= recentItems.size - PagedListViewModel.LOOKAHEAD) {
|
||||||
LaunchedEffect(index) { viewModel.recent.loadMore() }
|
LaunchedEffect(index) { viewModel.recent.loadMore() }
|
||||||
|
|
@ -386,7 +346,7 @@ fun HomeRowsScreen(
|
||||||
}
|
}
|
||||||
item {
|
item {
|
||||||
if (viewModel.subItems.isEmpty() && viewModel.subsLoading) {
|
if (viewModel.subItems.isEmpty() && viewModel.subsLoading) {
|
||||||
ShimmerRow()
|
ShimmerRow(size = 1)
|
||||||
} else {
|
} else {
|
||||||
FocusScrollRow(
|
FocusScrollRow(
|
||||||
videos = viewModel.subItems,
|
videos = viewModel.subItems,
|
||||||
|
|
@ -396,6 +356,7 @@ fun HomeRowsScreen(
|
||||||
clickedIndex = subRestore,
|
clickedIndex = subRestore,
|
||||||
restoreToken = restoreToken,
|
restoreToken = restoreToken,
|
||||||
refocusPulse = drawerPulse,
|
refocusPulse = drawerPulse,
|
||||||
|
trailingLoading = viewModel.subsLoadingMore,
|
||||||
) { index, video ->
|
) { index, video ->
|
||||||
if (index >= viewModel.subItems.size - PagedListViewModel.LOOKAHEAD) {
|
if (index >= viewModel.subItems.size - PagedListViewModel.LOOKAHEAD) {
|
||||||
LaunchedEffect(index) { viewModel.subsMore() }
|
LaunchedEffect(index) { viewModel.subsMore() }
|
||||||
|
|
@ -444,6 +405,9 @@ fun FocusScrollRow(
|
||||||
// NOTE: stays before [afterItem] — the trailing lambda binds to the last
|
// NOTE: stays before [afterItem] — the trailing lambda binds to the last
|
||||||
// declared parameter.
|
// declared parameter.
|
||||||
refocusPulse: Int = 0,
|
refocusPulse: Int = 0,
|
||||||
|
// A trailing loading card at the row's end while the next page loads (#25),
|
||||||
|
// so it's clear more is coming when scrolling right.
|
||||||
|
trailingLoading: Boolean = false,
|
||||||
afterItem: @Composable (index: Int, video: VideoDto) -> Unit = { _, _ -> },
|
afterItem: @Composable (index: Int, video: VideoDto) -> Unit = { _, _ -> },
|
||||||
) {
|
) {
|
||||||
val rowState = rememberLazyListState()
|
val rowState = rememberLazyListState()
|
||||||
|
|
@ -518,5 +482,14 @@ fun FocusScrollRow(
|
||||||
)
|
)
|
||||||
afterItem(index, video)
|
afterItem(index, video)
|
||||||
}
|
}
|
||||||
|
if (trailingLoading) {
|
||||||
|
item(key = "trailing-loading") {
|
||||||
|
ShimmerCard(
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(horizontal = 4.dp)
|
||||||
|
.width(rowCardWidth()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -239,22 +239,19 @@ fun VideoListScreen(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (viewModel.loadingMore) {
|
if (viewModel.loadingMore) {
|
||||||
// Growing the list: placeholder row (issue #19) instead of the
|
// Growing the list: a single loading card at the end (#25) so it's
|
||||||
// permanent spinner chip that used to sit under every list.
|
// clear more is coming, without faking a whole row of content.
|
||||||
item(key = "load-more-placeholders") {
|
item(key = "load-more-placeholder") {
|
||||||
androidx.compose.foundation.layout.Row {
|
|
||||||
repeat(cardsPerRow) {
|
|
||||||
ShimmerCard(
|
ShimmerCard(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.padding(6.dp)
|
.padding(6.dp)
|
||||||
.width(rowCardWidth()),
|
.width(rowCardWidth() - 12.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun SearchHeader(viewModel: VideoListViewModel, onOpenDrawer: () -> Unit = {}) {
|
private fun SearchHeader(viewModel: VideoListViewModel, onOpenDrawer: () -> Unit = {}) {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue