首页和详情页增加下拉刷新

This commit is contained in:
beyond committed 2026-08-31 23:38:23 +08:00
1 parent b37cf69068
commit e81f5485c6
2 files changed
+112 -53

No files matched your search

@@ -33,6 +33,8 @@ import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
@@ -466,15 +468,20 @@ internal fun continueWatchingDetailId(item: EmbyItem): String =
}
@Composable
private fun LazyLibrarySection(folder: EmbyMediaFolder, activeServer: EmbyServer, onItemClick: (EmbyItem) -> Unit = {}) {
private fun LazyLibrarySection(
folder: EmbyMediaFolder,
activeServer: EmbyServer,
refreshToken: Int,
onItemClick: (EmbyItem) -> Unit = {},
) {
val cacheKey = remember(activeServer.id, activeServer.fullUrl, activeServer.userId) {
homeCacheKey(activeServer)
}
var page by remember(cacheKey, folder.id) {
var page by remember(cacheKey, folder.id, refreshToken) {
mutableStateOf(HomeContentCache.libraryPage(cacheKey, folder.id))
}
LaunchedEffect(cacheKey, folder.id) {
LaunchedEffect(cacheKey, folder.id, refreshToken) {
if (page == null) {
page = try {
EmbyRepository.getItemsForLibrary(activeServer, folder.id, limit = 8)
@@ -707,7 +714,7 @@ private fun RainbowNumber(
)
}
@OptIn(ExperimentalAnimationApi::class)
@OptIn(ExperimentalAnimationApi::class, ExperimentalMaterial3Api::class)
@Composable
fun HomeScreen(
servers: List<EmbyServer> = emptyList(),
@@ -724,6 +731,7 @@ fun HomeScreen(
var resourceStats by remember(homeKey) { mutableStateOf(EmbyResourceStats()) }
var loading by remember(homeKey) { mutableStateOf(activeServer != null) }
var loadError by remember(homeKey) { mutableStateOf(false) }
var refreshing by remember(homeKey) { mutableStateOf(false) }
var reloadToken by remember(homeKey) { mutableIntStateOf(0) }
var showResourceStats by remember(homeKey) { mutableStateOf(false) }
var serverMenuVisible by remember(homeKey) { mutableStateOf(false) }
@@ -740,11 +748,20 @@ fun HomeScreen(
val immersiveBackground = rememberPosterImmersiveBackground(heroImageUrl(activeHeroItem, activeServer?.fullUrl.orEmpty()))
val hasHomeContent = heroItems.isNotEmpty() || resumeItems.isNotEmpty() || libraries.isNotEmpty()
val useImmersiveHomeBackground = activeServer != null && hasHomeContent
val requestReload: () -> Unit = {
homeKey?.let { cacheKey ->
HomeContentCache.invalidate(cacheKey)
if (hasHomeContent) refreshing = true else loading = true
reloadToken++
}
Unit
}
LaunchedEffect(homeKey, reloadToken) {
val server = activeServer
val cacheKey = homeKey
if (server == null || server.apiKey.isBlank() || cacheKey == null) return@LaunchedEffect
val inPlaceRefresh = reloadToken > 0 && hasHomeContent
if (reloadToken == 0) {
HomeContentCache.snapshot(cacheKey)?.let { snapshot ->
@@ -760,7 +777,7 @@ fun HomeScreen(
HomeContentCache.invalidate(cacheKey)
}
loading = true
if (inPlaceRefresh) refreshing = true else loading = true
try {
val libsDeferred = async { EmbyRepository.getLibraries(server) }
val latestDeferred = async { EmbyRepository.getLatestItems(server, limit = 10) }
@@ -786,6 +803,7 @@ fun HomeScreen(
loadError = true
} finally {
loading = false
refreshing = false
}
}
@@ -855,7 +873,7 @@ fun HomeScreen(
title = stringResource(Res.string.error_content_title),
description = stringResource(Res.string.error_content_sub),
retryLabel = stringResource(Res.string.retry),
onRetry = { reloadToken++ },
onRetry = requestReload,
)
}
} else if (!hasHomeContent) {
@@ -866,59 +884,65 @@ fun HomeScreen(
)
} else {
val server = activeServer
LazyColumn(
state = homeListState,
PullToRefreshBox(
isRefreshing = refreshing,
onRefresh = requestReload,
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(bottom = bottomBarContentPadding().calculateBottomPadding())
) {
if (heroItems.isNotEmpty()) {
item {
HeroCarousel(
items = heroItems,
serverUrl = server.fullUrl,
pagerState = heroPagerState,
heroHeight = heroHeight,
immersiveBackground = immersiveBackground,
onItemClick = { onOpenDetail(it.id) }
)
LazyColumn(
state = homeListState,
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(bottom = bottomBarContentPadding().calculateBottomPadding())
) {
if (heroItems.isNotEmpty()) {
item {
HeroCarousel(
items = heroItems,
serverUrl = server.fullUrl,
pagerState = heroPagerState,
heroHeight = heroHeight,
immersiveBackground = immersiveBackground,
onItemClick = { onOpenDetail(it.id) }
)
}
}
}
item {
Column(
Modifier
.fillMaxWidth()
.padding(start = LumaSpacing.md, end = LumaSpacing.md, top = LumaSpacing.md),
verticalArrangement = Arrangement.spacedBy(LumaSpacing.xl),
) {
if (resumeItems.isNotEmpty()) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
SectionTitle(title = stringResource(Res.string.continue_watching))
LazyRow(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
items(resumeItems) { item ->
ContinueWatchingPoster(item, server.fullUrl) {
onOpenDetail(continueWatchingDetailId(it))
item {
Column(
Modifier
.fillMaxWidth()
.padding(start = LumaSpacing.md, end = LumaSpacing.md, top = LumaSpacing.md),
verticalArrangement = Arrangement.spacedBy(LumaSpacing.xl),
) {
if (resumeItems.isNotEmpty()) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
SectionTitle(title = stringResource(Res.string.continue_watching))
LazyRow(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
items(resumeItems) { item ->
ContinueWatchingPoster(item, server.fullUrl) {
onOpenDetail(continueWatchingDetailId(it))
}
}
}
}
}
}
if (libraries.isNotEmpty()) {
MyMediaGrid(libraries, server.fullUrl) { folder ->
// Handle selection
if (libraries.isNotEmpty()) {
MyMediaGrid(libraries, server.fullUrl) { folder ->
// Handle selection
}
}
}
libraries.forEach { folder ->
LazyLibrarySection(folder, server) { onOpenDetail(it.id) }
}
libraries.forEach { folder ->
LazyLibrarySection(folder, server, refreshToken = reloadToken) { onOpenDetail(it.id) }
}
ResourceStatsCard(
stats = resourceStats,
onClick = { showResourceStats = true },
)
Spacer(Modifier.height(LumaSpacing.md))
ResourceStatsCard(
stats = resourceStats,
onClick = { showResourceStats = true },
)
Spacer(Modifier.height(LumaSpacing.md))
}
}
}
}
@@ -30,10 +30,13 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
@@ -113,6 +116,7 @@ private data class DetailCastMember(
private val DetailPrimaryButtonWidth = 188.dp
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MediaDetailScreen(
item: EmbyItem,
@@ -127,6 +131,7 @@ fun MediaDetailScreen(
var loadingFullDetail by remember(item.id) {
mutableStateOf(server != null && server.apiKey.isNotBlank())
}
var refreshToken by remember(item.id) { mutableIntStateOf(0) }
var seasons by remember(item.id) { mutableStateOf<List<EmbyItem>>(emptyList()) }
var selectedSeasonId by rememberSaveable(item.id) { mutableStateOf<String?>(null) }
@@ -151,7 +156,7 @@ fun MediaDetailScreen(
var isWatching by remember(item.id, server?.id) { mutableStateOf(false) }
val episodes = selectedSeasonId?.let(episodesBySeason::get).orEmpty()
LaunchedEffect(item.id, server?.id, server?.apiKey) {
LaunchedEffect(item.id, server?.id, server?.apiKey, refreshToken) {
val currentServer = server
if (currentServer == null || currentServer.apiKey.isBlank()) {
detail = item
@@ -171,7 +176,7 @@ fun MediaDetailScreen(
}
// Load favorite status
LaunchedEffect(item.id, server?.id) {
LaunchedEffect(item.id, server?.id, refreshToken) {
val sId = server?.id
if (sId != null) {
isFavorited = kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) {
@@ -189,7 +194,7 @@ fun MediaDetailScreen(
}
}
LaunchedEffect(item.id, server?.id, server?.apiKey) {
LaunchedEffect(item.id, server?.id, server?.apiKey, refreshToken) {
if (server == null || server.apiKey.isBlank()) return@LaunchedEffect
if (detail.type != "Series") {
loadingSeasons = false
@@ -212,7 +217,7 @@ fun MediaDetailScreen(
}
}
LaunchedEffect(item.id, server?.id, server?.apiKey, detail.type) {
LaunchedEffect(item.id, server?.id, server?.apiKey, detail.type, refreshToken) {
if (detail.type != "Series") {
loadingPlayTarget = false
return@LaunchedEffect
@@ -230,7 +235,7 @@ fun MediaDetailScreen(
}
}
LaunchedEffect(server?.id, detail.type, selectedSeasonId) {
LaunchedEffect(server?.id, detail.type, selectedSeasonId, refreshToken) {
val currentServer = server ?: return@LaunchedEffect
val seriesId = item.id
val seasonId = selectedSeasonId ?: return@LaunchedEffect
@@ -298,6 +303,7 @@ fun MediaDetailScreen(
selectedSeasonId,
server?.id,
servers.map { "${it.id}:${it.apiKey}:${it.isOnline}" },
refreshToken,
) {
val currentServer = server ?: return@LaunchedEffect
val sourceItem = selectedPlayableItem ?: return@LaunchedEffect
@@ -342,7 +348,7 @@ fun MediaDetailScreen(
.firstOrNull { it.key.equals("Tmdb", ignoreCase = true) }
?.value
val supportsTmdbExtras = detail.type == "Series" || detail.type == "Movie"
LaunchedEffect(detail.id, detail.type, detail.name, detail.productionYear, tmdbId, loadingFullDetail) {
LaunchedEffect(detail.id, detail.type, detail.name, detail.productionYear, tmdbId, loadingFullDetail, refreshToken) {
if (!TmdbRepository.isConfigured || !supportsTmdbExtras) {
loadingTmdb = false
return@LaunchedEffect
@@ -410,6 +416,29 @@ fun MediaDetailScreen(
val detailBorderColor = if (lightContent) Color.Black.copy(alpha = 0.10f) else Color.White.copy(alpha = 0.10f)
val detailAccentColor = if (lightContent) Color(0xFF1D4ED8) else Color(0xFFBFDBFE)
val detailBackdrop = rememberLumaLayerBackdrop()
val isRefreshing = refreshToken > 0 && (loadingFullDetail || loadingSeasons || loadingEpisodes ||
loadingMoreEpisodes || loadingPlayTarget || loadingResources || loadingTmdb
)
val requestRefresh = {
val currentServer = server
val initialLoadInProgress = refreshToken == 0 &&
(loadingFullDetail || loadingSeasons || loadingEpisodes || loadingMoreEpisodes || loadingPlayTarget || loadingResources || loadingTmdb)
if (!isRefreshing && !initialLoadInProgress && currentServer != null && currentServer.apiKey.isNotBlank()) {
loadingFullDetail = true
if (detail.type == "Series") {
loadingSeasons = true
loadingPlayTarget = true
loadingEpisodes = selectedSeasonId != null
completedSeasonIds = emptySet()
episodesBySeason = emptyMap()
selectedEpisodeId = null
}
if (TmdbRepository.isConfigured && (detail.type == "Series" || detail.type == "Movie")) {
loadingTmdb = true
}
refreshToken++
}
}
CompositionLocalProvider(
LocalDetailText provides detailTextColor,
@@ -435,6 +464,11 @@ fun MediaDetailScreen(
)
CompositionLocalProvider(LocalGlassBackdrop provides detailBackdrop) {
Box(Modifier.fillMaxSize()) {
PullToRefreshBox(
isRefreshing = isRefreshing,
onRefresh = requestRefresh,
modifier = Modifier.fillMaxSize(),
) {
Column(
modifier = Modifier
.fillMaxSize()
@@ -692,6 +726,7 @@ fun MediaDetailScreen(
Spacer(Modifier.height(38.dp))
}
}
}
// Pinned above the scrolling content so it stays visible while browsing episodes.
Box(
Modifier