diff --git a/CHANGELOG.md b/CHANGELOG.md index 3da59cc..e11a35b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,12 @@ +## 0.2.51 — 2026-08-11 +- Improved: For You now begins loading after Home arrives, so its personalised rows are usually ready before you open it. +- Fixed: Frequent screen updates can no longer repeat network requests, navigation, analytics, timers or other background work. +- Fixed: Cancelled Home, For You, Search, detail and screensaver requests can no longer replace newer results or show a stale error. +- Fixed: App updates now keep one download and installer attempt across screen and activity recreation instead of starting again. +- Fixed: Settings choices are saved even when you leave Settings immediately after making them. +- Fixed: Delayed D-pad focus, long-press, pagination, retry and permission actions now use the current screen state rather than an older callback. +- Improved: Screen data is now collected with the active lifecycle, avoiding unnecessary work while a screen is stopped. + ## 0.2.50 — 2026-08-11 - Fixed: Playback reports are now serialised, and final stop positions are protected from out-of-order requests. - Fixed: Home, preferences, themes and detail screens now recover automatically with bounded retries. diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 723ec90..1615204 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -42,7 +42,7 @@ val projectNoticeText = // A release workflow can derive the app version from its Git tag without editing the // source tree. Local builds keep using the checked-in default. -val defaultVersionName = "0.2.50" +val defaultVersionName = "0.2.51" val membyVersionName: String = (project.findProperty("memby.versionName") as String?) ?.trim() diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/EpisodeDetailsOverlay.kt b/app/src/main/java/com/ponzischeme89/memby/ui/EpisodeDetailsOverlay.kt index bd0b9a2..829aad1 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/EpisodeDetailsOverlay.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/EpisodeDetailsOverlay.kt @@ -31,7 +31,6 @@ import androidx.compose.material.icons.filled.KeyboardArrowDown import androidx.compose.material.icons.filled.Tv import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -47,6 +46,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.tv.material3.Icon import androidx.tv.material3.Text import com.ponzischeme89.memby.ServiceLocator @@ -92,7 +92,9 @@ fun EpisodeDetailsOverlay( modifier: Modifier = Modifier, ) { val repository = ServiceLocator.repository - val settings by repository.settingsFlow.collectAsState(initial = repository.currentSettings) + val settings by repository.settingsFlow.collectAsStateWithLifecycle( + initialValue = repository.currentSettings, + ) var episodes by remember(item.seriesId) { mutableStateOf?>(null) } var loadFailed by remember(item.seriesId) { mutableStateOf(false) } var ratings by remember(item.id) { mutableStateOf>(emptyList()) } diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/HomeComponents.kt b/app/src/main/java/com/ponzischeme89/memby/ui/HomeComponents.kt index 3810169..a76dd79 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/HomeComponents.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/HomeComponents.kt @@ -45,6 +45,7 @@ import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue @@ -1450,6 +1451,9 @@ internal fun MediaRow( ?.takeIf { it.rowId == row.id && row.items.isNotEmpty() } ?.itemIndex ?.coerceIn(0, row.items.lastIndex) + val currentOnVerticalFocusRequestConsumed by rememberUpdatedState( + onVerticalFocusRequestConsumed, + ) LaunchedEffect(verticalFocusRequest?.requestId, requestedEntryIndex) { val request = verticalFocusRequest ?.takeIf { it.rowId == row.id && requestedEntryIndex != null } @@ -1463,11 +1467,11 @@ internal fun MediaRow( repeat(6) { kotlinx.coroutines.delay(16L) if (runCatching { verticalEntryFocusRequester.requestFocus() }.isSuccess) { - onVerticalFocusRequestConsumed(request.requestId) + currentOnVerticalFocusRequestConsumed(request.requestId) return@LaunchedEffect } } - onVerticalFocusRequestConsumed(request.requestId) + currentOnVerticalFocusRequestConsumed(request.requestId) } // firstVisibleItemIndex changes on every scroll frame; read it through derivedStateOf so // only the button's enabled/disabled flip recomposes the row header. @@ -2277,6 +2281,7 @@ fun FocusScaleContainer( val pressScope = rememberCoroutineScope() var holdJob by remember { mutableStateOf(null) } var remoteLongPressHandled by remember { mutableStateOf(false) } + val currentOnLongClick by rememberUpdatedState(onLongClick) val scale = animateFloatAsState( targetValue = if (focused) 1.025f else 1f, animationSpec = tween(95), @@ -2320,7 +2325,7 @@ fun FocusScaleContainer( delay(QuickActionsHoldDurationMillis) remoteLongPressHandled = true holdJob = null - onLongClick() + currentOnLongClick?.invoke() } true } diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/HomeViewModel.kt b/app/src/main/java/com/ponzischeme89/memby/ui/HomeViewModel.kt index 8858473..8d7125a 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/HomeViewModel.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/HomeViewModel.kt @@ -224,10 +224,28 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() { } fun loadForYou(availableMinutes: Int = _forYou.value.availableMinutes) { + requestForYou(availableMinutes, clearFocus = true) + } + + /** + * Warms the dedicated destination once Home has its essential rows. Starting this + * before the viewer opens For You hides the gateway round trip without making the + * launcher's first response compete with another request. + */ + private fun preloadForYou(availableMinutes: Int) { + if (_forYou.value.rows.isNotEmpty()) return + requestForYou(availableMinutes, clearFocus = false) + } + + private fun requestForYou(availableMinutes: Int, clearFocus: Boolean) { val minutes = availableMinutes.coerceIn(0, 360) + if (forYouJob?.isActive == true && _forYou.value.availableMinutes == minutes) { + if (clearFocus) _focusedItem.value = null + return + } val requestId = ++forYouRequestId forYouJob?.cancel() - _focusedItem.value = null + if (clearFocus) _focusedItem.value = null _forYou.update { it.copy(availableMinutes = minutes, loading = true, error = null) } forYouJob = viewModelScope.launch(Dispatchers.IO) { runCatching { repository.getForYou(minutes) } @@ -239,7 +257,9 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() { availableMinutes = minutes, loading = false, ) - rows.firstNotNullOfOrNull { it.items.firstOrNull() }?.let(::focusItem) + if (clearFocus) { + rows.firstNotNullOfOrNull { it.items.firstOrNull() }?.let(::focusItem) + } } .onFailure { error -> if (error is kotlinx.coroutines.CancellationException) throw error @@ -312,12 +332,14 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() { if (_focusedItem.value == null) { initialFocusedItem(_state.value)?.let(::focusItem) } + preloadForYou(repository.currentSettings.forYouMinutes) // Home may have been cached just before the background recommendation // build completed. Pull the dedicated endpoint after the fast home draw // so personalized Shows shelves appear on this visit, not a minute later. refreshRecommendationRows() } .onFailure { error -> + if (error is kotlinx.coroutines.CancellationException) throw error val maintenance = isMaintenanceError(error) _state.update { it.copy( @@ -331,7 +353,11 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() { } private suspend fun refreshRecommendationRows() { - val fresh = runCatching { repository.getRecommendations() }.getOrNull() ?: return + val fresh = runCatching { repository.getRecommendations() } + .getOrElse { error -> + if (error is kotlinx.coroutines.CancellationException) throw error + return + } _state.update { state -> val airingTodayKeys = state.rows.airingTodayShowKeys() val taggedFresh = fresh.withAiringTodayRowTags(airingTodayKeys) diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/InstallPermissionScreen.kt b/app/src/main/java/com/ponzischeme89/memby/ui/InstallPermissionScreen.kt index 188623b..c90c856 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/InstallPermissionScreen.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/InstallPermissionScreen.kt @@ -18,6 +18,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -60,6 +61,7 @@ fun InstallPermissionScreen(onContinue: () -> Unit) { val lifecycleOwner = LocalLifecycleOwner.current var noScreenAvailable by remember { mutableStateOf(false) } var asked by remember { mutableStateOf(false) } + val currentOnContinue by rememberUpdatedState(onContinue) // Granting happens in Android's settings, which pauses Memby. Coming back with the // permission in hand should simply carry on rather than leave the viewer looking at a @@ -67,7 +69,7 @@ fun InstallPermissionScreen(onContinue: () -> Unit) { DisposableEffect(lifecycleOwner) { val observer = LifecycleEventObserver { _, event -> if (event == Lifecycle.Event.ON_RESUME && asked && InstallPermission.granted(context)) { - onContinue() + currentOnContinue() } } lifecycleOwner.lifecycle.addObserver(observer) diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/MainActivity.kt b/app/src/main/java/com/ponzischeme89/memby/ui/MainActivity.kt index d5b1f16..7d5db4c 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/MainActivity.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/MainActivity.kt @@ -45,7 +45,6 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateOf @@ -404,8 +403,13 @@ private fun AppRoot(onCloseSettings: () -> Unit) { if (!initialUpdateCheckComplete || appUpdate != null) return@LaunchedEffect val token = settings?.token?.takeIf { it.isNotBlank() } ?: return@LaunchedEffect if (validatedToken == token) return@LaunchedEffect + val valid = repo.validateSession() + // Record completion only after the suspend call returns. An update verdict can + // cancel and restart this effect without changing the token; recording it before + // the call left the replacement effect believing an unfinished validation had run. + currentCoroutineContext().ensureActive() validatedToken = token - if (!repo.validateSession()) { + if (!valid) { repo.invalidateSession() } } @@ -474,12 +478,16 @@ private fun AppRoot(onCloseSettings: () -> Unit) { ) when (decision) { is WhatsNewDecision.Notify -> { + // Persist the receipt before producing the external effect. If Android + // recreates the activity immediately after the toast, the new composition + // must not announce the same build a second time. + ServiceLocator.settings.markWhatsNewSeen(decision.version) + currentCoroutineContext().ensureActive() Toast.makeText( context, context.getString(R.string.app_updated_to_version, decision.version), Toast.LENGTH_LONG, ).show() - ServiceLocator.settings.markWhatsNewSeen(decision.version) } is WhatsNewDecision.MarkSeen -> ServiceLocator.settings.markWhatsNewSeen(decision.version) @@ -601,7 +609,7 @@ private fun AppRoot(onCloseSettings: () -> Unit) { // the only thing that recomposes when December starts. SeasonalDecorations( decoration = ServiceLocator.themeSync.theme - .collectAsState().value?.decoration.orEmpty(), + .collectAsStateWithLifecycle().value?.decoration.orEmpty(), ) } loaded.profiles.isNotEmpty() -> ProfileEntryScreen( @@ -1994,10 +2002,16 @@ private fun HomeScreen( notificationsLoading = true runCatching { repo.getMyShows() } .onSuccess { myShows = it; myShowsError = null } - .onFailure { myShowsError = friendlyEmbyError(it) } + .onFailure { + if (it is kotlinx.coroutines.CancellationException) throw it + myShowsError = friendlyEmbyError(it) + } runCatching { repo.getNotifications() } .onSuccess { notificationState = it; notificationsError = null } - .onFailure { notificationsError = friendlyEmbyError(it) } + .onFailure { + if (it is kotlinx.coroutines.CancellationException) throw it + notificationsError = friendlyEmbyError(it) + } myShowsLoading = false notificationsLoading = false kotlinx.coroutines.delay(32L) @@ -2018,10 +2032,16 @@ private fun HomeScreen( notificationsLoading = true runCatching { repo.getMyShows() } .onSuccess { myShows = it; myShowsError = null } - .onFailure { myShowsError = friendlyEmbyError(it) } + .onFailure { + if (it is kotlinx.coroutines.CancellationException) throw it + myShowsError = friendlyEmbyError(it) + } runCatching { repo.getNotifications() } .onSuccess { notificationState = it; notificationsError = null } - .onFailure { notificationsError = friendlyEmbyError(it) } + .onFailure { + if (it is kotlinx.coroutines.CancellationException) throw it + notificationsError = friendlyEmbyError(it) + } myShowsLoading = false notificationsLoading = false } @@ -2204,9 +2224,16 @@ private fun HomeScreen( if (selectedDestination == BrowseDestination.FAVORITES) { recentSearches = repo.getRecentSearches() } - if (selectedDestination == BrowseDestination.FOR_YOU && forYouState.rows.isEmpty()) { + } + LaunchedEffect(selectedDestination, settings.forYouMinutes) { + if ( + selectedDestination == BrowseDestination.FOR_YOU && + (forYouState.rows.isEmpty() || forYouState.availableMinutes != settings.forYouMinutes) + ) { homeViewModel.loadForYou(settings.forYouMinutes) } + } + LaunchedEffect(selectedDestination, settings.hasOpenedForYou) { if (selectedDestination == BrowseDestination.FOR_YOU && !settings.hasOpenedForYou) { repo.markForYouOpened() } diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/MaintenanceScreen.kt b/app/src/main/java/com/ponzischeme89/memby/ui/MaintenanceScreen.kt index cf0778f..711a010 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/MaintenanceScreen.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/MaintenanceScreen.kt @@ -35,6 +35,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -128,6 +129,7 @@ fun MaintenanceScreen( ) var secondsLeft by remember { mutableIntStateOf(RETRY_SECONDS) } + val currentOnRetry by rememberUpdatedState(onRetry) LaunchedEffect(message) { // Restarts whenever the message changes, so a failed retry resets the clock. secondsLeft = RETRY_SECONDS @@ -135,7 +137,7 @@ fun MaintenanceScreen( delay(1_000) secondsLeft -= 1 if (secondsLeft <= 0) { - onRetry() + currentOnRetry() secondsLeft = RETRY_SECONDS } } diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/MediaDetailsOverlay.kt b/app/src/main/java/com/ponzischeme89/memby/ui/MediaDetailsOverlay.kt index f7d014f..763dea7 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/MediaDetailsOverlay.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/MediaDetailsOverlay.kt @@ -9,7 +9,6 @@ import androidx.compose.material.icons.filled.FirstPage import androidx.compose.material.icons.filled.Movie import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -18,6 +17,7 @@ import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.ponzischeme89.memby.ServiceLocator import com.ponzischeme89.memby.data.RelatedContent import com.ponzischeme89.memby.data.model.BaseItem @@ -54,7 +54,7 @@ fun MediaDetailsOverlay( modifier: Modifier = Modifier, ) { val settings by ServiceLocator.repository.settingsFlow - .collectAsState(initial = ServiceLocator.repository.currentSettings) + .collectAsStateWithLifecycle(initialValue = ServiceLocator.repository.currentSettings) var related by remember(item.id) { mutableStateOf(null) } var trailer by remember(item.id) { mutableStateOf(null) } var ratings by remember(item.id) { mutableStateOf>(emptyList()) } diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/RatingsStrip.kt b/app/src/main/java/com/ponzischeme89/memby/ui/RatingsStrip.kt index f243e4c..d04acc1 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/RatingsStrip.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/RatingsStrip.kt @@ -13,7 +13,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -30,10 +29,10 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.tv.material3.Text import com.ponzischeme89.memby.R import com.ponzischeme89.memby.ServiceLocator -import com.ponzischeme89.memby.data.Settings import com.ponzischeme89.memby.data.model.BaseItem import com.ponzischeme89.memby.data.model.MediaRating import com.ponzischeme89.memby.data.model.displayable @@ -118,7 +117,9 @@ fun ItemRatingsStrip( reserveSpace: Boolean = true, compact: Boolean = false, ) { - val settings by ServiceLocator.repository.settingsFlow.collectAsState(initial = Settings.EMPTY) + val settings by ServiceLocator.repository.settingsFlow.collectAsStateWithLifecycle( + initialValue = ServiceLocator.repository.currentSettings, + ) var ratings by remember(item.id) { mutableStateOf(item.membyRatings) } // A focused home item is replaced in-place by its full Emby details. Include the // carried ratings in the key so an item can pick them up from the settled response diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/SeriesDetailsOverlay.kt b/app/src/main/java/com/ponzischeme89/memby/ui/SeriesDetailsOverlay.kt index 20fcacc..15ad65f 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/SeriesDetailsOverlay.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/SeriesDetailsOverlay.kt @@ -35,7 +35,6 @@ import androidx.compose.material.icons.filled.Movie import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -51,6 +50,7 @@ import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.layout.ContentScale +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @@ -113,7 +113,9 @@ fun SeriesDetailsOverlay( modifier: Modifier = Modifier, ) { val repository = ServiceLocator.repository - val settings by repository.settingsFlow.collectAsState(initial = repository.currentSettings) + val settings by repository.settingsFlow.collectAsStateWithLifecycle( + initialValue = repository.currentSettings, + ) var episodes by remember(item.id) { mutableStateOf?>(null) } var loadFailed by remember(item.id) { mutableStateOf(false) } var related by remember(item.id) { mutableStateOf(null) } diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/UpdateScreen.kt b/app/src/main/java/com/ponzischeme89/memby/ui/UpdateScreen.kt index 2ec1150..146b7f0 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/UpdateScreen.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/UpdateScreen.kt @@ -1,6 +1,7 @@ package com.ponzischeme89.memby.ui import android.os.Build +import android.content.Context import androidx.activity.compose.BackHandler import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.RepeatMode @@ -36,7 +37,6 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -58,6 +58,10 @@ import androidx.compose.ui.unit.sp import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.ViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewModelScope +import androidx.lifecycle.viewmodel.compose.viewModel import androidx.tv.material3.Icon import androidx.tv.material3.Text import com.ponzischeme89.memby.data.model.GatewayUpdate @@ -73,6 +77,12 @@ import com.ponzischeme89.memby.ui.theme.MembySurfaceRaised import com.ponzischeme89.memby.update.AppInstall import com.ponzischeme89.memby.update.InstallPermissionRequiredException import com.ponzischeme89.memby.update.UpdateChecker +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Job +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch private val UpdateAccent: Color get() = MembyAccent @@ -80,6 +90,78 @@ private val UpdateTitle: Color get() = MembyOnSurface private val UpdateBody: Color get() = MembyMutedText private val UpdateFaint: Color get() = MembyQuietText +internal data class UpdateInstallState( + val version: String = "", + val installing: Boolean = false, + val waitingForInstallPermission: Boolean = false, + val message: String? = null, +) + +/** + * Owns an update attempt outside the composition that displays it. + * + * A configuration change disposes a composable scope. Downloading into that scope could + * cancel a half-finished APK, forget the launch gate, and let the recreated button start a + * second installer session. The Activity's ViewModelStore survives that change, so one job + * and one gate remain authoritative until the attempt finishes. + */ +internal class UpdateInstallViewModel : ViewModel() { + private val _state = MutableStateFlow(UpdateInstallState()) + val state = _state.asStateFlow() + private var installJob: Job? = null + + init { + viewModelScope.launch { + AppInstall.messages.collect { message -> + if (_state.value.version.isNotBlank()) { + _state.value = _state.value.copy(message = message) + } + } + } + } + + fun begin(context: Context, update: GatewayUpdate) { + if (installJob?.isActive == true && _state.value.version == update.version) return + installJob?.cancel() + _state.value = UpdateInstallState(version = update.version, installing = true) + installJob = viewModelScope.launch { + try { + val result = UpdateChecker(context.applicationContext).downloadAndInstall( + apkUrl = update.downloadUrl, + token = "", + expectedVersion = update.version, + expectedSHA256 = update.sha256, + expectedSizeBytes = update.sizeBytes, + ) + currentCoroutineContext().ensureActive() + if (_state.value.version == update.version) { + _state.value = _state.value.copy( + installing = false, + waitingForInstallPermission = + result.exceptionOrNull() is InstallPermissionRequiredException, + message = result.exceptionOrNull()?.message ?: "Opening the installer…", + ) + } + } catch (cancelled: CancellationException) { + throw cancelled + } catch (error: Throwable) { + if (_state.value.version == update.version) { + _state.value = _state.value.copy( + installing = false, + message = error.message ?: "Couldn’t start the update.", + ) + } + } + } + } + + fun resumeAfterPermission(context: Context, update: GatewayUpdate) { + if (_state.value.version != update.version || !_state.value.waitingForInstallPermission) return + _state.value = _state.value.copy(waitingForInstallPermission = false) + begin(context, update) + } +} + /** * The app-level update gate. AppRoot composes this instead of login, profiles, or Home, * so no focused media card or playback action exists behind its buttons. @@ -101,37 +183,18 @@ fun UpdateScreen( ) { val context = LocalContext.current val lifecycleOwner = LocalLifecycleOwner.current - val scope = rememberCoroutineScope() - val checker = remember { UpdateChecker(context) } - - var installing by remember { mutableStateOf(false) } - var message by remember { mutableStateOf(null) } - var waitingForInstallPermission by remember { mutableStateOf(false) } + val installViewModel: UpdateInstallViewModel = viewModel() + val retainedInstallState by installViewModel.state.collectAsStateWithLifecycle() + val installState = retainedInstallState.takeIf { it.version == update.version } + ?: UpdateInstallState(version = update.version) + val installing = installState.installing + val message = installState.message + val waitingForInstallPermission = installState.waitingForInstallPermission fun beginInstall() { - if (installing) return - installing = true - message = null - scope.launch { - val result = checker.downloadAndInstall( - apkUrl = update.downloadUrl, - token = "", - expectedVersion = update.version, - expectedSHA256 = update.sha256, - expectedSizeBytes = update.sizeBytes, - ) - installing = false - waitingForInstallPermission = - result.exceptionOrNull() is InstallPermissionRequiredException - message = result.exceptionOrNull()?.message ?: "Opening the installer…" - } + installViewModel.begin(context, update) } - // The install happens in the system installer, so its outcome arrives here rather than - // as the result of the call above. On a mandatory update this is the only thing that - // can tell a viewer why the screen they cannot leave is still there. - LaunchedEffect(Unit) { AppInstall.messages.collect { message = it } } - // Android's permission screen pauses Memby. Once the viewer grants permission and // returns, continue automatically instead of making them discover they must press // Update now for a second time. @@ -142,8 +205,7 @@ fun UpdateScreen( (Build.VERSION.SDK_INT < Build.VERSION_CODES.O || context.packageManager.canRequestPackageInstalls()) ) { - waitingForInstallPermission = false - beginInstall() + installViewModel.resumeAfterPermission(context, update) } } lifecycleOwner.lifecycle.addObserver(observer) diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/screensaver/ScreensaverContent.kt b/app/src/main/java/com/ponzischeme89/memby/ui/screensaver/ScreensaverContent.kt index f2b9244..a7ca478 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/screensaver/ScreensaverContent.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/screensaver/ScreensaverContent.kt @@ -93,6 +93,7 @@ import com.ponzischeme89.memby.data.model.BaseItem import com.ponzischeme89.memby.ui.useTextTitleForLogo import com.ponzischeme89.memby.ui.visibleWithWatchedPreference import com.ponzischeme89.memby.ui.settings.SettingsSheet +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.delay import kotlinx.coroutines.launch import java.text.DateFormat @@ -175,6 +176,8 @@ private fun Slideshow( val repo = ServiceLocator.repository val context = LocalContext.current val scope = rememberCoroutineScope() + val currentOnPlay by rememberUpdatedState(onPlay) + val currentHideWatchedMovies by rememberUpdatedState(hideWatchedMovies) var items by remember { mutableStateOf>(emptyList()) } var index by remember { mutableIntStateOf(0) } @@ -219,7 +222,14 @@ private fun Slideshow( } loading = false } - .onFailure { loadError = friendlyEmbyError(it); loading = false } + .onFailure { + // A key change cancels the obsolete request. Treating that cancellation as + // a server failure lets the old effect clear the replacement effect's + // loading state and briefly put an error over its result. + if (it is CancellationException) throw it + loadError = friendlyEmbyError(it) + loading = false + } } // Do not wait for the full 200-item queue before showing a first real backdrop. @@ -236,6 +246,7 @@ private fun Slideshow( loading = false } } + .onFailure { if (it is CancellationException) throw it } } // Fetches another random batch in the background as the user nears the end of @@ -253,7 +264,7 @@ private fun Slideshow( runCatching { repo.getScreensaverItems() } .onFailure { prefetchError = it } .getOrDefault(emptyList()), - hideWatchedMovies, + currentHideWatchedMovies, ) if (more.isNotEmpty()) { val existing = items.mapTo(HashSet()) { it.id } @@ -388,7 +399,7 @@ private fun Slideshow( toast = "The trailer is unavailable right now." playbackLaunching = false } else if (runCatching { - onPlay(playable.url, "${target.name} trailer") + currentOnPlay(playable.url, "${target.name} trailer") }.isFailure ) { toast = "Couldn’t start the trailer." diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/search/SearchScreen.kt b/app/src/main/java/com/ponzischeme89/memby/ui/search/SearchScreen.kt index b60ac77..258d8c3 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/search/SearchScreen.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/search/SearchScreen.kt @@ -62,8 +62,10 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -196,7 +198,9 @@ fun SearchScreen( var lastKeyIndex by remember { mutableIntStateOf(0) } var focusInResults by remember { mutableStateOf(false) } var restoreGenreChipFocus by remember { mutableStateOf(false) } - var searchTracked by remember { mutableStateOf(false) } + // The ViewModel survives an Activity recreation, so this guard must as well. Without + // saveable state the restored non-empty query records a second search-start event. + var searchTracked by rememberSaveable { mutableStateOf(false) } LaunchedEffect(state.query) { if (state.query.isNotBlank() && !searchTracked) { searchTracked = true @@ -873,6 +877,7 @@ private fun ResultsGrid( val context = LocalContext.current val density = LocalDensity.current val gridState = rememberLazyGridState() + val currentOnLoadMore by rememberUpdatedState(onLoadMore) // Infinite scroll. Read in a snapshotFlow rather than from the composable body: the // last visible index changes on every frame of a scroll, and reading it up here would @@ -884,7 +889,7 @@ private fun ResultsGrid( snapshotFlow { gridState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: -1 } .distinctUntilChanged() .collect { last -> - if (last >= items.size - columns * LOAD_MORE_ROWS_AHEAD) onLoadMore() + if (last >= items.size - columns * LOAD_MORE_ROWS_AHEAD) currentOnLoadMore() } } } diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/search/SearchViewModel.kt b/app/src/main/java/com/ponzischeme89/memby/ui/search/SearchViewModel.kt index 1554bc4..bf4a086 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/search/SearchViewModel.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/search/SearchViewModel.kt @@ -417,6 +417,13 @@ class SearchViewModel(private val repository: EmbyRepository) : ViewModel() { _state.update { it.copy(isLoading = true, errorMessage = null) } runCatching { repository.search(term) } .onSuccess { found -> + // collectLatest cancels the preceding request, but cancellation is + // cooperative: an HTTP call that has already returned can still reach + // this non-suspending block. Drop it unless the field still asks the + // exact question that produced the answer. + if (_state.value.genre != null || _state.value.query.trim() != term) { + return@onSuccess + } // The gateway answers from the imported library and falls back to Emby // before the first import has finished, so one title reaching the pane by // both routes is a shape this search genuinely has. The grid is keyed by @@ -440,6 +447,9 @@ class SearchViewModel(private val repository: EmbyRepository) : ViewModel() { .onFailure { error -> // A cancelled search is the normal case while typing, not a failure. if (error is kotlinx.coroutines.CancellationException) throw error + if (_state.value.genre != null || _state.value.query.trim() != term) { + return@onFailure + } _state.update { it.copy(isLoading = false, hasSearched = true, errorMessage = friendlyEmbyError(error)) } diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/settings/SettingsSheet.kt b/app/src/main/java/com/ponzischeme89/memby/ui/settings/SettingsSheet.kt index f980273..222751a 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/settings/SettingsSheet.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/settings/SettingsSheet.kt @@ -48,10 +48,10 @@ import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.Storage import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue @@ -87,6 +87,7 @@ import androidx.compose.ui.unit.sp import androidx.tv.material3.Button import androidx.tv.material3.Icon import androidx.tv.material3.Text +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.ponzischeme89.memby.R import com.ponzischeme89.memby.ServiceLocator import com.ponzischeme89.memby.data.DEFAULT_SEEK_INTERVAL_SECONDS @@ -119,8 +120,10 @@ import com.ponzischeme89.memby.ui.theme.MembyQuietText import com.ponzischeme89.memby.ui.theme.MembySurface import com.ponzischeme89.memby.update.UpdateChecker import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.Job import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -327,14 +330,22 @@ fun SettingsSheet( val context = LocalContext.current val store = ServiceLocator.settings val scope = rememberCoroutineScope() + fun persistSetting(block: suspend () -> Unit) { + // Enter the non-cancellable section before returning to the click handler. Closing + // Settings immediately after an accepted choice must not discard its DataStore + // write when this composition's scope is cancelled. + scope.launch(start = CoroutineStart.UNDISPATCHED) { + withContext(NonCancellable) { block() } + } + } // Kept only for the version this TV is running, which About prints. Nothing here checks // for an update: see the note on SettingsPage. val checker = remember { UpdateChecker(context) } // Start with the repository's real in-memory snapshot. Settings.EMPTY briefly selects // default chips before DataStore emits; a viewer could press "Automatic" in that gap // and then watch the stored "Posters" value appear to revert their choice. - val settings by ServiceLocator.repository.settingsFlow.collectAsState( - initial = ServiceLocator.repository.currentSettings, + val settings by ServiceLocator.repository.settingsFlow.collectAsStateWithLifecycle( + initialValue = ServiceLocator.repository.currentSettings, ) var showLogo by rememberSaveable { mutableStateOf(settings.showTitleLogo) } @@ -361,9 +372,9 @@ fun SettingsSheet( // The resolved theme and the schemes on offer both come from the gateway, through the // sync that owns them. Collected here rather than in SettingsPanelContent so the content // stays parameter-driven and screenshot-testable with no server. - val resolvedTheme by ServiceLocator.themeSync.theme.collectAsState() - val availableThemes by ServiceLocator.themeSync.available.collectAsState() - val gatewayVersion by ServiceLocator.maintenance.gatewayVersion.collectAsState() + val resolvedTheme by ServiceLocator.themeSync.theme.collectAsStateWithLifecycle() + val availableThemes by ServiceLocator.themeSync.available.collectAsStateWithLifecycle() + val gatewayVersion by ServiceLocator.maintenance.gatewayVersion.collectAsStateWithLifecycle() var selectedPage by rememberSaveable { mutableStateOf(SettingsPage.APPEARANCE) } var devices by remember { mutableStateOf>(emptyList()) } var devicesLoading by remember { mutableStateOf(false) } @@ -390,8 +401,19 @@ fun SettingsSheet( } } + fun launchDeviceJob(block: suspend () -> Unit) { + val previous = deviceJob + deviceJob = scope.launch { + // Cancellation alone is not ordering: the cancelled request runs its finally + // block before it exits, and could otherwise clear the replacement request's + // loading/busy state after that replacement had started. + previous?.cancelAndJoin() + block() + } + } + LaunchedEffect(selectedPage, settings.token) { - deviceJob?.cancel() + deviceJob?.cancelAndJoin() pendingRemovalDeviceId = null if (selectedPage == SettingsPage.DEVICES && settings.isSignedIn) { refreshDevices() @@ -502,32 +524,32 @@ fun SettingsSheet( onShowLogoChanged = { onAnalyticsEvent("title_logo", "toggle") showLogo = it - scope.launch { store.setShowTitleLogo(it) } + persistSetting { store.setShowTitleLogo(it) } }, onAutoPlayNextChanged = { onAnalyticsEvent("auto_play_next", "toggle") autoPlayNext = it - scope.launch { store.setAutoPlayNextEpisode(it) } + persistSetting { store.setAutoPlayNextEpisode(it) } }, onShowTenMinuteReminderChanged = { onAnalyticsEvent("playback_reminder", "toggle") showTenMinuteReminder = it - scope.launch { store.setShowTenMinuteReminder(it) } + persistSetting { store.setShowTenMinuteReminder(it) } }, onSeekIntervalChanged = { onAnalyticsEvent("seek_interval", "change") seekInterval = it - scope.launch { store.setSeekIntervalSeconds(it) } + persistSetting { store.setSeekIntervalSeconds(it) } }, onSkipIntroModeChanged = { onAnalyticsEvent("skip_intro", "change") skipIntroMode = it - scope.launch { store.setSkipIntroMode(it) } + persistSetting { store.setSkipIntroMode(it) } }, onSpeedUpCreditsChanged = { onAnalyticsEvent("speed_up_credits", "toggle") speedUpCredits = it - scope.launch { store.setSpeedUpCredits(it) } + persistSetting { store.setSpeedUpCredits(it) } }, onAudioPassthroughModeChanged = { mode -> onAnalyticsEvent("audio_passthrough", "change") @@ -538,7 +560,8 @@ fun SettingsSheet( if (mode == AudioPassthroughMode.MANUAL && audioPassthroughCodecs.isEmpty()) { audioPassthroughCodecs = deviceAudioCapabilities.passthrough } - scope.launch { store.setAudioPassthrough(mode, audioPassthroughCodecs) } + val selectedCodecs = audioPassthroughCodecs + persistSetting { store.setAudioPassthrough(mode, selectedCodecs) } }, onAudioPassthroughCodecChanged = { codec, enabled -> onAnalyticsEvent("audio_passthrough", "toggle") @@ -547,41 +570,39 @@ fun SettingsSheet( } else { audioPassthroughCodecs - codec } - scope.launch { - store.setAudioPassthrough(audioPassthroughMode, audioPassthroughCodecs) - } + val selectedMode = audioPassthroughMode + val selectedCodecs = audioPassthroughCodecs + persistSetting { store.setAudioPassthrough(selectedMode, selectedCodecs) } }, onRingColorChanged = { onAnalyticsEvent("focus_colour", "change") ringColor = it - scope.launch { store.setRingColor(it) } + persistSetting { store.setRingColor(it) } }, onHomeSectionChanged = { key, enabled -> onAnalyticsEvent("home_sections", "toggle") homeSections = if (enabled) homeSections + key else homeSections - key - scope.launch { store.setHomeSections(homeSections.toList()) } + val selectedSections = homeSections.toList() + persistSetting { store.setHomeSections(selectedSections) } }, onCardDensityChanged = { onAnalyticsEvent("card_density", "change") cardDensity = it - scope.launch { store.setHomeCardDensity(it) } + persistSetting { store.setHomeCardDensity(it) } }, onArtworkStyleChanged = { onAnalyticsEvent("artwork_style", "change") artworkStyle = it - scope.launch { - // Back closes this composable and cancels its scope. Once a selection has - // been accepted on screen, its tiny atomic DataStore write must finish even - // if the viewer leaves Settings immediately afterwards. - withContext(NonCancellable) { store.setHomeArtworkStyle(it) } - } + persistSetting { store.setHomeArtworkStyle(it) } }, onRestoreHiddenRows = { onAnalyticsEvent("hidden_rows", "change") - scope.launch { + val rowOrder = settings.homeRowOrder.lineSequence().filter { it.isNotBlank() }.toList() + val pinnedRows = settings.homePinnedRows.lineSequence().filter { it.isNotBlank() }.toSet() + persistSetting { store.setHomeRowPreferences( - settings.homeRowOrder.lineSequence().filter { it.isNotBlank() }.toList(), - settings.homePinnedRows.lineSequence().filter { it.isNotBlank() }.toSet(), + rowOrder, + pinnedRows, emptySet(), ) } @@ -589,22 +610,22 @@ fun SettingsSheet( onShowCardMetadataChanged = { onAnalyticsEvent("card_metadata", "toggle") showCardMetadata = it - scope.launch { store.setShowHomeCardMetadata(it) } + persistSetting { store.setShowHomeCardMetadata(it) } }, onShowRatingsStripChanged = { onAnalyticsEvent("ratings_strip", "toggle") showRatingsStrip = it - scope.launch { store.setShowRatingsStrip(it) } + persistSetting { store.setShowRatingsStrip(it) } }, onHideWatchedMoviesChanged = { onAnalyticsEvent("hide_watched_movies", "toggle") hideWatchedMovies = it - scope.launch { store.setHideWatchedMovies(it) } + persistSetting { store.setHideWatchedMovies(it) } }, onConfirmExitMembyChanged = { onAnalyticsEvent("confirm_exit", "toggle") confirmExitMemby = it - scope.launch { store.setConfirmExitMemby(it) } + persistSetting { store.setConfirmExitMemby(it) } }, onThemeChanged = { chosen -> onAnalyticsEvent("theme", "change") @@ -612,20 +633,19 @@ fun SettingsSheet( // arrives through ThemeSync a moment later. Painting optimistically here would // show a viewer a scheme that a season, or an allowlist they do not know about, // is about to take back off them. - scope.launch { store.setThemeId(chosen) } + persistSetting { store.setThemeId(chosen) } }, onWelcomeQuoteStyleChanged = { onAnalyticsEvent("welcome_quote", "change") welcomeQuoteStyle = it - scope.launch { store.setWelcomeQuoteStyle(it) } + persistSetting { store.setWelcomeQuoteStyle(it) } }, onPageSelected = { onAnalyticsEvent("settings_page_${it.name.lowercase()}", "open") selectedPage = it }, onRefreshDevices = { - deviceJob?.cancel() - deviceJob = scope.launch { refreshDevices() } + launchDeviceJob { refreshDevices() } }, onRenameDevice = { devicesError = null @@ -639,8 +659,7 @@ fun SettingsSheet( } else { removingDeviceId = device.deviceId devicesError = null - deviceJob?.cancel() - deviceJob = scope.launch { + launchDeviceJob { try { withTimeout(8_000L) { ServiceLocator.repository.removeDevice(device.deviceId) } devices = devices.filterNot { it.deviceId == device.deviceId } @@ -718,9 +737,8 @@ fun SettingsSheet( error = devicesError, onCancel = { editingDevice = null }, onSave = { name -> - deviceJob?.cancel() devicesError = null - deviceJob = scope.launch { + launchDeviceJob { try { withTimeout(8_000L) { ServiceLocator.repository.renameDevice(device, name) } devices = devices.map { @@ -1332,10 +1350,11 @@ private fun SettingsSecondaryRail( // is drawn. Everything the rail paints reads from this, so nothing about the highlight // waits on the settle above. var focusedPage by remember { mutableStateOf(selected) } + val currentOnSelected by rememberUpdatedState(onSelected) LaunchedEffect(focusedPage, selected) { if (focusedPage == selected) return@LaunchedEffect delay(SETTINGS_PAGE_SETTLE_MS) - onSelected(focusedPage) + currentOnSelected(focusedPage) } // The home screen remains composed behind this panel. Relying on spatial focus // search therefore lets a covered media card beat the next rail item when their