首页和详情页增加下拉刷新
This commit is contained in:
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
|
||||
|
||||
Reference in new issue
Block a user