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