From e81f5485c6f426b6d32f3de71d095e8ca47cbcbe Mon Sep 17 00:00:00 2001
From: beyond
Date: Mon, 31 Aug 2026 23:38:23 +0800
Subject: [PATCH] =?UTF-8?q?=E9=A6=96=E9=A1=B5=E5=92=8C=E8=AF=A6=E6=83=85?=
=?UTF-8?q?=E9=A1=B5=E5=A2=9E=E5=8A=A0=E4=B8=8B=E6=8B=89=E5=88=B7=E6=96=B0?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../myapplication/screens/HomeScreen.kt | 118 +++++++++++-------
.../screens/MediaDetailScreen.kt | 47 ++++++-
2 files changed, 112 insertions(+), 53 deletions(-)
diff --git a/app/src/main/kotlin/com/example/myapplication/screens/HomeScreen.kt b/app/src/main/kotlin/com/example/myapplication/screens/HomeScreen.kt
index 57d142c..5603488 100644
--- a/app/src/main/kotlin/com/example/myapplication/screens/HomeScreen.kt
+++ b/app/src/main/kotlin/com/example/myapplication/screens/HomeScreen.kt
@@ -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 = 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))
+ }
}
}
}
diff --git a/app/src/main/kotlin/com/example/myapplication/screens/MediaDetailScreen.kt b/app/src/main/kotlin/com/example/myapplication/screens/MediaDetailScreen.kt
index 98ed087..1dc2c6f 100644
--- a/app/src/main/kotlin/com/example/myapplication/screens/MediaDetailScreen.kt
+++ b/app/src/main/kotlin/com/example/myapplication/screens/MediaDetailScreen.kt
@@ -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>(emptyList()) }
var selectedSeasonId by rememberSaveable(item.id) { mutableStateOf(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