This commit is contained in:
ponzischeme89
2026-08-11 17:18:04 +12:00
parent 5e5849f985
commit 226136b4e7
16 changed files with 287 additions and 104 deletions
+9
View File
@@ -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 ## 0.2.50 — 2026-08-11
- Fixed: Playback reports are now serialised, and final stop positions are protected from out-of-order requests. - 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. - Fixed: Home, preferences, themes and detail screens now recover automatically with bounded retries.
+1 -1
View File
@@ -42,7 +42,7 @@ val projectNoticeText =
// A release workflow can derive the app version from its Git tag without editing the // 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. // source tree. Local builds keep using the checked-in default.
val defaultVersionName = "0.2.50" val defaultVersionName = "0.2.51"
val membyVersionName: String = val membyVersionName: String =
(project.findProperty("memby.versionName") as String?) (project.findProperty("memby.versionName") as String?)
?.trim() ?.trim()
@@ -31,7 +31,6 @@ import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.material.icons.filled.Tv import androidx.compose.material.icons.filled.Tv
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember 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.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.tv.material3.Icon import androidx.tv.material3.Icon
import androidx.tv.material3.Text import androidx.tv.material3.Text
import com.ponzischeme89.memby.ServiceLocator import com.ponzischeme89.memby.ServiceLocator
@@ -92,7 +92,9 @@ fun EpisodeDetailsOverlay(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val repository = ServiceLocator.repository 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<List<BaseItem>?>(null) } var episodes by remember(item.seriesId) { mutableStateOf<List<BaseItem>?>(null) }
var loadFailed by remember(item.seriesId) { mutableStateOf(false) } var loadFailed by remember(item.seriesId) { mutableStateOf(false) }
var ratings by remember(item.id) { mutableStateOf<List<MediaRating>>(emptyList()) } var ratings by remember(item.id) { mutableStateOf<List<MediaRating>>(emptyList()) }
@@ -45,6 +45,7 @@ import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
@@ -1450,6 +1451,9 @@ internal fun MediaRow(
?.takeIf { it.rowId == row.id && row.items.isNotEmpty() } ?.takeIf { it.rowId == row.id && row.items.isNotEmpty() }
?.itemIndex ?.itemIndex
?.coerceIn(0, row.items.lastIndex) ?.coerceIn(0, row.items.lastIndex)
val currentOnVerticalFocusRequestConsumed by rememberUpdatedState(
onVerticalFocusRequestConsumed,
)
LaunchedEffect(verticalFocusRequest?.requestId, requestedEntryIndex) { LaunchedEffect(verticalFocusRequest?.requestId, requestedEntryIndex) {
val request = verticalFocusRequest val request = verticalFocusRequest
?.takeIf { it.rowId == row.id && requestedEntryIndex != null } ?.takeIf { it.rowId == row.id && requestedEntryIndex != null }
@@ -1463,11 +1467,11 @@ internal fun MediaRow(
repeat(6) { repeat(6) {
kotlinx.coroutines.delay(16L) kotlinx.coroutines.delay(16L)
if (runCatching { verticalEntryFocusRequester.requestFocus() }.isSuccess) { if (runCatching { verticalEntryFocusRequester.requestFocus() }.isSuccess) {
onVerticalFocusRequestConsumed(request.requestId) currentOnVerticalFocusRequestConsumed(request.requestId)
return@LaunchedEffect return@LaunchedEffect
} }
} }
onVerticalFocusRequestConsumed(request.requestId) currentOnVerticalFocusRequestConsumed(request.requestId)
} }
// firstVisibleItemIndex changes on every scroll frame; read it through derivedStateOf so // firstVisibleItemIndex changes on every scroll frame; read it through derivedStateOf so
// only the button's enabled/disabled flip recomposes the row header. // only the button's enabled/disabled flip recomposes the row header.
@@ -2277,6 +2281,7 @@ fun FocusScaleContainer(
val pressScope = rememberCoroutineScope() val pressScope = rememberCoroutineScope()
var holdJob by remember { mutableStateOf<Job?>(null) } var holdJob by remember { mutableStateOf<Job?>(null) }
var remoteLongPressHandled by remember { mutableStateOf(false) } var remoteLongPressHandled by remember { mutableStateOf(false) }
val currentOnLongClick by rememberUpdatedState(onLongClick)
val scale = animateFloatAsState( val scale = animateFloatAsState(
targetValue = if (focused) 1.025f else 1f, targetValue = if (focused) 1.025f else 1f,
animationSpec = tween(95), animationSpec = tween(95),
@@ -2320,7 +2325,7 @@ fun FocusScaleContainer(
delay(QuickActionsHoldDurationMillis) delay(QuickActionsHoldDurationMillis)
remoteLongPressHandled = true remoteLongPressHandled = true
holdJob = null holdJob = null
onLongClick() currentOnLongClick?.invoke()
} }
true true
} }
@@ -224,10 +224,28 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
} }
fun loadForYou(availableMinutes: Int = _forYou.value.availableMinutes) { 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) val minutes = availableMinutes.coerceIn(0, 360)
if (forYouJob?.isActive == true && _forYou.value.availableMinutes == minutes) {
if (clearFocus) _focusedItem.value = null
return
}
val requestId = ++forYouRequestId val requestId = ++forYouRequestId
forYouJob?.cancel() forYouJob?.cancel()
_focusedItem.value = null if (clearFocus) _focusedItem.value = null
_forYou.update { it.copy(availableMinutes = minutes, loading = true, error = null) } _forYou.update { it.copy(availableMinutes = minutes, loading = true, error = null) }
forYouJob = viewModelScope.launch(Dispatchers.IO) { forYouJob = viewModelScope.launch(Dispatchers.IO) {
runCatching { repository.getForYou(minutes) } runCatching { repository.getForYou(minutes) }
@@ -239,7 +257,9 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
availableMinutes = minutes, availableMinutes = minutes,
loading = false, loading = false,
) )
rows.firstNotNullOfOrNull { it.items.firstOrNull() }?.let(::focusItem) if (clearFocus) {
rows.firstNotNullOfOrNull { it.items.firstOrNull() }?.let(::focusItem)
}
} }
.onFailure { error -> .onFailure { error ->
if (error is kotlinx.coroutines.CancellationException) throw error if (error is kotlinx.coroutines.CancellationException) throw error
@@ -312,12 +332,14 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
if (_focusedItem.value == null) { if (_focusedItem.value == null) {
initialFocusedItem(_state.value)?.let(::focusItem) initialFocusedItem(_state.value)?.let(::focusItem)
} }
preloadForYou(repository.currentSettings.forYouMinutes)
// Home may have been cached just before the background recommendation // Home may have been cached just before the background recommendation
// build completed. Pull the dedicated endpoint after the fast home draw // build completed. Pull the dedicated endpoint after the fast home draw
// so personalized Shows shelves appear on this visit, not a minute later. // so personalized Shows shelves appear on this visit, not a minute later.
refreshRecommendationRows() refreshRecommendationRows()
} }
.onFailure { error -> .onFailure { error ->
if (error is kotlinx.coroutines.CancellationException) throw error
val maintenance = isMaintenanceError(error) val maintenance = isMaintenanceError(error)
_state.update { _state.update {
it.copy( it.copy(
@@ -331,7 +353,11 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
} }
private suspend fun refreshRecommendationRows() { 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 -> _state.update { state ->
val airingTodayKeys = state.rows.airingTodayShowKeys() val airingTodayKeys = state.rows.airingTodayShowKeys()
val taggedFresh = fresh.withAiringTodayRowTags(airingTodayKeys) val taggedFresh = fresh.withAiringTodayRowTags(airingTodayKeys)
@@ -18,6 +18,7 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@@ -60,6 +61,7 @@ fun InstallPermissionScreen(onContinue: () -> Unit) {
val lifecycleOwner = LocalLifecycleOwner.current val lifecycleOwner = LocalLifecycleOwner.current
var noScreenAvailable by remember { mutableStateOf(false) } var noScreenAvailable by remember { mutableStateOf(false) }
var asked 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 // 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 // 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) { DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, event -> val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME && asked && InstallPermission.granted(context)) { if (event == Lifecycle.Event.ON_RESUME && asked && InstallPermission.granted(context)) {
onContinue() currentOnContinue()
} }
} }
lifecycleOwner.lifecycle.addObserver(observer) lifecycleOwner.lifecycle.addObserver(observer)
@@ -45,7 +45,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@@ -404,8 +403,13 @@ private fun AppRoot(onCloseSettings: () -> Unit) {
if (!initialUpdateCheckComplete || appUpdate != null) return@LaunchedEffect if (!initialUpdateCheckComplete || appUpdate != null) return@LaunchedEffect
val token = settings?.token?.takeIf { it.isNotBlank() } ?: return@LaunchedEffect val token = settings?.token?.takeIf { it.isNotBlank() } ?: return@LaunchedEffect
if (validatedToken == token) 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 validatedToken = token
if (!repo.validateSession()) { if (!valid) {
repo.invalidateSession() repo.invalidateSession()
} }
} }
@@ -474,12 +478,16 @@ private fun AppRoot(onCloseSettings: () -> Unit) {
) )
when (decision) { when (decision) {
is WhatsNewDecision.Notify -> { 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( Toast.makeText(
context, context,
context.getString(R.string.app_updated_to_version, decision.version), context.getString(R.string.app_updated_to_version, decision.version),
Toast.LENGTH_LONG, Toast.LENGTH_LONG,
).show() ).show()
ServiceLocator.settings.markWhatsNewSeen(decision.version)
} }
is WhatsNewDecision.MarkSeen -> is WhatsNewDecision.MarkSeen ->
ServiceLocator.settings.markWhatsNewSeen(decision.version) ServiceLocator.settings.markWhatsNewSeen(decision.version)
@@ -601,7 +609,7 @@ private fun AppRoot(onCloseSettings: () -> Unit) {
// the only thing that recomposes when December starts. // the only thing that recomposes when December starts.
SeasonalDecorations( SeasonalDecorations(
decoration = ServiceLocator.themeSync.theme decoration = ServiceLocator.themeSync.theme
.collectAsState().value?.decoration.orEmpty(), .collectAsStateWithLifecycle().value?.decoration.orEmpty(),
) )
} }
loaded.profiles.isNotEmpty() -> ProfileEntryScreen( loaded.profiles.isNotEmpty() -> ProfileEntryScreen(
@@ -1994,10 +2002,16 @@ private fun HomeScreen(
notificationsLoading = true notificationsLoading = true
runCatching { repo.getMyShows() } runCatching { repo.getMyShows() }
.onSuccess { myShows = it; myShowsError = null } .onSuccess { myShows = it; myShowsError = null }
.onFailure { myShowsError = friendlyEmbyError(it) } .onFailure {
if (it is kotlinx.coroutines.CancellationException) throw it
myShowsError = friendlyEmbyError(it)
}
runCatching { repo.getNotifications() } runCatching { repo.getNotifications() }
.onSuccess { notificationState = it; notificationsError = null } .onSuccess { notificationState = it; notificationsError = null }
.onFailure { notificationsError = friendlyEmbyError(it) } .onFailure {
if (it is kotlinx.coroutines.CancellationException) throw it
notificationsError = friendlyEmbyError(it)
}
myShowsLoading = false myShowsLoading = false
notificationsLoading = false notificationsLoading = false
kotlinx.coroutines.delay(32L) kotlinx.coroutines.delay(32L)
@@ -2018,10 +2032,16 @@ private fun HomeScreen(
notificationsLoading = true notificationsLoading = true
runCatching { repo.getMyShows() } runCatching { repo.getMyShows() }
.onSuccess { myShows = it; myShowsError = null } .onSuccess { myShows = it; myShowsError = null }
.onFailure { myShowsError = friendlyEmbyError(it) } .onFailure {
if (it is kotlinx.coroutines.CancellationException) throw it
myShowsError = friendlyEmbyError(it)
}
runCatching { repo.getNotifications() } runCatching { repo.getNotifications() }
.onSuccess { notificationState = it; notificationsError = null } .onSuccess { notificationState = it; notificationsError = null }
.onFailure { notificationsError = friendlyEmbyError(it) } .onFailure {
if (it is kotlinx.coroutines.CancellationException) throw it
notificationsError = friendlyEmbyError(it)
}
myShowsLoading = false myShowsLoading = false
notificationsLoading = false notificationsLoading = false
} }
@@ -2204,9 +2224,16 @@ private fun HomeScreen(
if (selectedDestination == BrowseDestination.FAVORITES) { if (selectedDestination == BrowseDestination.FAVORITES) {
recentSearches = repo.getRecentSearches() 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) homeViewModel.loadForYou(settings.forYouMinutes)
} }
}
LaunchedEffect(selectedDestination, settings.hasOpenedForYou) {
if (selectedDestination == BrowseDestination.FOR_YOU && !settings.hasOpenedForYou) { if (selectedDestination == BrowseDestination.FOR_YOU && !settings.hasOpenedForYou) {
repo.markForYouOpened() repo.markForYouOpened()
} }
@@ -35,6 +35,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@@ -128,6 +129,7 @@ fun MaintenanceScreen(
) )
var secondsLeft by remember { mutableIntStateOf(RETRY_SECONDS) } var secondsLeft by remember { mutableIntStateOf(RETRY_SECONDS) }
val currentOnRetry by rememberUpdatedState(onRetry)
LaunchedEffect(message) { LaunchedEffect(message) {
// Restarts whenever the message changes, so a failed retry resets the clock. // Restarts whenever the message changes, so a failed retry resets the clock.
secondsLeft = RETRY_SECONDS secondsLeft = RETRY_SECONDS
@@ -135,7 +137,7 @@ fun MaintenanceScreen(
delay(1_000) delay(1_000)
secondsLeft -= 1 secondsLeft -= 1
if (secondsLeft <= 0) { if (secondsLeft <= 0) {
onRetry() currentOnRetry()
secondsLeft = RETRY_SECONDS secondsLeft = RETRY_SECONDS
} }
} }
@@ -9,7 +9,6 @@ import androidx.compose.material.icons.filled.FirstPage
import androidx.compose.material.icons.filled.Movie import androidx.compose.material.icons.filled.Movie
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@@ -18,6 +17,7 @@ import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
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.ServiceLocator
import com.ponzischeme89.memby.data.RelatedContent import com.ponzischeme89.memby.data.RelatedContent
import com.ponzischeme89.memby.data.model.BaseItem import com.ponzischeme89.memby.data.model.BaseItem
@@ -54,7 +54,7 @@ fun MediaDetailsOverlay(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val settings by ServiceLocator.repository.settingsFlow val settings by ServiceLocator.repository.settingsFlow
.collectAsState(initial = ServiceLocator.repository.currentSettings) .collectAsStateWithLifecycle(initialValue = ServiceLocator.repository.currentSettings)
var related by remember(item.id) { mutableStateOf<RelatedContent?>(null) } var related by remember(item.id) { mutableStateOf<RelatedContent?>(null) }
var trailer by remember(item.id) { mutableStateOf<BaseItem?>(null) } var trailer by remember(item.id) { mutableStateOf<BaseItem?>(null) }
var ratings by remember(item.id) { mutableStateOf<List<MediaRating>>(emptyList()) } var ratings by remember(item.id) { mutableStateOf<List<MediaRating>>(emptyList()) }
@@ -13,7 +13,6 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember 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.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.tv.material3.Text import androidx.tv.material3.Text
import com.ponzischeme89.memby.R import com.ponzischeme89.memby.R
import com.ponzischeme89.memby.ServiceLocator import com.ponzischeme89.memby.ServiceLocator
import com.ponzischeme89.memby.data.Settings
import com.ponzischeme89.memby.data.model.BaseItem import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.data.model.MediaRating import com.ponzischeme89.memby.data.model.MediaRating
import com.ponzischeme89.memby.data.model.displayable import com.ponzischeme89.memby.data.model.displayable
@@ -118,7 +117,9 @@ fun ItemRatingsStrip(
reserveSpace: Boolean = true, reserveSpace: Boolean = true,
compact: Boolean = false, 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) } var ratings by remember(item.id) { mutableStateOf(item.membyRatings) }
// A focused home item is replaced in-place by its full Emby details. Include the // 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 // carried ratings in the key so an item can pick them up from the settled response
@@ -35,7 +35,6 @@ import androidx.compose.material.icons.filled.Movie
import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember 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.Color
import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -113,7 +113,9 @@ fun SeriesDetailsOverlay(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val repository = ServiceLocator.repository 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<List<BaseItem>?>(null) } var episodes by remember(item.id) { mutableStateOf<List<BaseItem>?>(null) }
var loadFailed by remember(item.id) { mutableStateOf(false) } var loadFailed by remember(item.id) { mutableStateOf(false) }
var related by remember(item.id) { mutableStateOf<RelatedContent?>(null) } var related by remember(item.id) { mutableStateOf<RelatedContent?>(null) }
@@ -1,6 +1,7 @@
package com.ponzischeme89.memby.ui package com.ponzischeme89.memby.ui
import android.os.Build import android.os.Build
import android.content.Context
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.RepeatMode import androidx.compose.animation.core.RepeatMode
@@ -36,7 +37,6 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@@ -58,6 +58,10 @@ import androidx.compose.ui.unit.sp
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner 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.Icon
import androidx.tv.material3.Text import androidx.tv.material3.Text
import com.ponzischeme89.memby.data.model.GatewayUpdate 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.AppInstall
import com.ponzischeme89.memby.update.InstallPermissionRequiredException import com.ponzischeme89.memby.update.InstallPermissionRequiredException
import com.ponzischeme89.memby.update.UpdateChecker 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 import kotlinx.coroutines.launch
private val UpdateAccent: Color get() = MembyAccent private val UpdateAccent: Color get() = MembyAccent
@@ -80,6 +90,78 @@ private val UpdateTitle: Color get() = MembyOnSurface
private val UpdateBody: Color get() = MembyMutedText private val UpdateBody: Color get() = MembyMutedText
private val UpdateFaint: Color get() = MembyQuietText 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 ?: "Couldnt 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, * 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. * so no focused media card or playback action exists behind its buttons.
@@ -101,37 +183,18 @@ fun UpdateScreen(
) { ) {
val context = LocalContext.current val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current val lifecycleOwner = LocalLifecycleOwner.current
val scope = rememberCoroutineScope() val installViewModel: UpdateInstallViewModel = viewModel()
val checker = remember { UpdateChecker(context) } val retainedInstallState by installViewModel.state.collectAsStateWithLifecycle()
val installState = retainedInstallState.takeIf { it.version == update.version }
var installing by remember { mutableStateOf(false) } ?: UpdateInstallState(version = update.version)
var message by remember { mutableStateOf<String?>(null) } val installing = installState.installing
var waitingForInstallPermission by remember { mutableStateOf(false) } val message = installState.message
val waitingForInstallPermission = installState.waitingForInstallPermission
fun beginInstall() { fun beginInstall() {
if (installing) return installViewModel.begin(context, update)
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…"
}
} }
// 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 // Android's permission screen pauses Memby. Once the viewer grants permission and
// returns, continue automatically instead of making them discover they must press // returns, continue automatically instead of making them discover they must press
// Update now for a second time. // Update now for a second time.
@@ -142,8 +205,7 @@ fun UpdateScreen(
(Build.VERSION.SDK_INT < Build.VERSION_CODES.O || (Build.VERSION.SDK_INT < Build.VERSION_CODES.O ||
context.packageManager.canRequestPackageInstalls()) context.packageManager.canRequestPackageInstalls())
) { ) {
waitingForInstallPermission = false installViewModel.resumeAfterPermission(context, update)
beginInstall()
} }
} }
lifecycleOwner.lifecycle.addObserver(observer) lifecycleOwner.lifecycle.addObserver(observer)
@@ -93,6 +93,7 @@ import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.ui.useTextTitleForLogo import com.ponzischeme89.memby.ui.useTextTitleForLogo
import com.ponzischeme89.memby.ui.visibleWithWatchedPreference import com.ponzischeme89.memby.ui.visibleWithWatchedPreference
import com.ponzischeme89.memby.ui.settings.SettingsSheet import com.ponzischeme89.memby.ui.settings.SettingsSheet
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.text.DateFormat import java.text.DateFormat
@@ -175,6 +176,8 @@ private fun Slideshow(
val repo = ServiceLocator.repository val repo = ServiceLocator.repository
val context = LocalContext.current val context = LocalContext.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val currentOnPlay by rememberUpdatedState(onPlay)
val currentHideWatchedMovies by rememberUpdatedState(hideWatchedMovies)
var items by remember { mutableStateOf<List<BaseItem>>(emptyList()) } var items by remember { mutableStateOf<List<BaseItem>>(emptyList()) }
var index by remember { mutableIntStateOf(0) } var index by remember { mutableIntStateOf(0) }
@@ -219,7 +222,14 @@ private fun Slideshow(
} }
loading = false 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. // Do not wait for the full 200-item queue before showing a first real backdrop.
@@ -236,6 +246,7 @@ private fun Slideshow(
loading = false loading = false
} }
} }
.onFailure { if (it is CancellationException) throw it }
} }
// Fetches another random batch in the background as the user nears the end of // Fetches another random batch in the background as the user nears the end of
@@ -253,7 +264,7 @@ private fun Slideshow(
runCatching { repo.getScreensaverItems() } runCatching { repo.getScreensaverItems() }
.onFailure { prefetchError = it } .onFailure { prefetchError = it }
.getOrDefault(emptyList()), .getOrDefault(emptyList()),
hideWatchedMovies, currentHideWatchedMovies,
) )
if (more.isNotEmpty()) { if (more.isNotEmpty()) {
val existing = items.mapTo(HashSet()) { it.id } val existing = items.mapTo(HashSet()) { it.id }
@@ -388,7 +399,7 @@ private fun Slideshow(
toast = "The trailer is unavailable right now." toast = "The trailer is unavailable right now."
playbackLaunching = false playbackLaunching = false
} else if (runCatching { } else if (runCatching {
onPlay(playable.url, "${target.name} trailer") currentOnPlay(playable.url, "${target.name} trailer")
}.isFailure }.isFailure
) { ) {
toast = "Couldnt start the trailer." toast = "Couldnt start the trailer."
@@ -62,8 +62,10 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.snapshotFlow
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
@@ -196,7 +198,9 @@ fun SearchScreen(
var lastKeyIndex by remember { mutableIntStateOf(0) } var lastKeyIndex by remember { mutableIntStateOf(0) }
var focusInResults by remember { mutableStateOf(false) } var focusInResults by remember { mutableStateOf(false) }
var restoreGenreChipFocus 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) { LaunchedEffect(state.query) {
if (state.query.isNotBlank() && !searchTracked) { if (state.query.isNotBlank() && !searchTracked) {
searchTracked = true searchTracked = true
@@ -873,6 +877,7 @@ private fun ResultsGrid(
val context = LocalContext.current val context = LocalContext.current
val density = LocalDensity.current val density = LocalDensity.current
val gridState = rememberLazyGridState() val gridState = rememberLazyGridState()
val currentOnLoadMore by rememberUpdatedState(onLoadMore)
// Infinite scroll. Read in a snapshotFlow rather than from the composable body: the // 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 // 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 } snapshotFlow { gridState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: -1 }
.distinctUntilChanged() .distinctUntilChanged()
.collect { last -> .collect { last ->
if (last >= items.size - columns * LOAD_MORE_ROWS_AHEAD) onLoadMore() if (last >= items.size - columns * LOAD_MORE_ROWS_AHEAD) currentOnLoadMore()
} }
} }
} }
@@ -417,6 +417,13 @@ class SearchViewModel(private val repository: EmbyRepository) : ViewModel() {
_state.update { it.copy(isLoading = true, errorMessage = null) } _state.update { it.copy(isLoading = true, errorMessage = null) }
runCatching { repository.search(term) } runCatching { repository.search(term) }
.onSuccess { found -> .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 // 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 // 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 // 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 -> .onFailure { error ->
// A cancelled search is the normal case while typing, not a failure. // A cancelled search is the normal case while typing, not a failure.
if (error is kotlinx.coroutines.CancellationException) throw error if (error is kotlinx.coroutines.CancellationException) throw error
if (_state.value.genre != null || _state.value.query.trim() != term) {
return@onFailure
}
_state.update { _state.update {
it.copy(isLoading = false, hasSearched = true, errorMessage = friendlyEmbyError(error)) it.copy(isLoading = false, hasSearched = true, errorMessage = friendlyEmbyError(error))
} }
@@ -48,10 +48,10 @@ import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Storage import androidx.compose.material.icons.filled.Storage
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
@@ -87,6 +87,7 @@ import androidx.compose.ui.unit.sp
import androidx.tv.material3.Button import androidx.tv.material3.Button
import androidx.tv.material3.Icon import androidx.tv.material3.Icon
import androidx.tv.material3.Text import androidx.tv.material3.Text
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.ponzischeme89.memby.R import com.ponzischeme89.memby.R
import com.ponzischeme89.memby.ServiceLocator import com.ponzischeme89.memby.ServiceLocator
import com.ponzischeme89.memby.data.DEFAULT_SEEK_INTERVAL_SECONDS 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.ui.theme.MembySurface
import com.ponzischeme89.memby.update.UpdateChecker import com.ponzischeme89.memby.update.UpdateChecker
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@@ -327,14 +330,22 @@ fun SettingsSheet(
val context = LocalContext.current val context = LocalContext.current
val store = ServiceLocator.settings val store = ServiceLocator.settings
val scope = rememberCoroutineScope() 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 // Kept only for the version this TV is running, which About prints. Nothing here checks
// for an update: see the note on SettingsPage. // for an update: see the note on SettingsPage.
val checker = remember { UpdateChecker(context) } val checker = remember { UpdateChecker(context) }
// Start with the repository's real in-memory snapshot. Settings.EMPTY briefly selects // 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 // 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. // and then watch the stored "Posters" value appear to revert their choice.
val settings by ServiceLocator.repository.settingsFlow.collectAsState( val settings by ServiceLocator.repository.settingsFlow.collectAsStateWithLifecycle(
initial = ServiceLocator.repository.currentSettings, initialValue = ServiceLocator.repository.currentSettings,
) )
var showLogo by rememberSaveable { mutableStateOf(settings.showTitleLogo) } 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 // 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 // sync that owns them. Collected here rather than in SettingsPanelContent so the content
// stays parameter-driven and screenshot-testable with no server. // stays parameter-driven and screenshot-testable with no server.
val resolvedTheme by ServiceLocator.themeSync.theme.collectAsState() val resolvedTheme by ServiceLocator.themeSync.theme.collectAsStateWithLifecycle()
val availableThemes by ServiceLocator.themeSync.available.collectAsState() val availableThemes by ServiceLocator.themeSync.available.collectAsStateWithLifecycle()
val gatewayVersion by ServiceLocator.maintenance.gatewayVersion.collectAsState() val gatewayVersion by ServiceLocator.maintenance.gatewayVersion.collectAsStateWithLifecycle()
var selectedPage by rememberSaveable { mutableStateOf(SettingsPage.APPEARANCE) } var selectedPage by rememberSaveable { mutableStateOf(SettingsPage.APPEARANCE) }
var devices by remember { mutableStateOf<List<GatewayDevice>>(emptyList()) } var devices by remember { mutableStateOf<List<GatewayDevice>>(emptyList()) }
var devicesLoading by remember { mutableStateOf(false) } 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) { LaunchedEffect(selectedPage, settings.token) {
deviceJob?.cancel() deviceJob?.cancelAndJoin()
pendingRemovalDeviceId = null pendingRemovalDeviceId = null
if (selectedPage == SettingsPage.DEVICES && settings.isSignedIn) { if (selectedPage == SettingsPage.DEVICES && settings.isSignedIn) {
refreshDevices() refreshDevices()
@@ -502,32 +524,32 @@ fun SettingsSheet(
onShowLogoChanged = { onShowLogoChanged = {
onAnalyticsEvent("title_logo", "toggle") onAnalyticsEvent("title_logo", "toggle")
showLogo = it showLogo = it
scope.launch { store.setShowTitleLogo(it) } persistSetting { store.setShowTitleLogo(it) }
}, },
onAutoPlayNextChanged = { onAutoPlayNextChanged = {
onAnalyticsEvent("auto_play_next", "toggle") onAnalyticsEvent("auto_play_next", "toggle")
autoPlayNext = it autoPlayNext = it
scope.launch { store.setAutoPlayNextEpisode(it) } persistSetting { store.setAutoPlayNextEpisode(it) }
}, },
onShowTenMinuteReminderChanged = { onShowTenMinuteReminderChanged = {
onAnalyticsEvent("playback_reminder", "toggle") onAnalyticsEvent("playback_reminder", "toggle")
showTenMinuteReminder = it showTenMinuteReminder = it
scope.launch { store.setShowTenMinuteReminder(it) } persistSetting { store.setShowTenMinuteReminder(it) }
}, },
onSeekIntervalChanged = { onSeekIntervalChanged = {
onAnalyticsEvent("seek_interval", "change") onAnalyticsEvent("seek_interval", "change")
seekInterval = it seekInterval = it
scope.launch { store.setSeekIntervalSeconds(it) } persistSetting { store.setSeekIntervalSeconds(it) }
}, },
onSkipIntroModeChanged = { onSkipIntroModeChanged = {
onAnalyticsEvent("skip_intro", "change") onAnalyticsEvent("skip_intro", "change")
skipIntroMode = it skipIntroMode = it
scope.launch { store.setSkipIntroMode(it) } persistSetting { store.setSkipIntroMode(it) }
}, },
onSpeedUpCreditsChanged = { onSpeedUpCreditsChanged = {
onAnalyticsEvent("speed_up_credits", "toggle") onAnalyticsEvent("speed_up_credits", "toggle")
speedUpCredits = it speedUpCredits = it
scope.launch { store.setSpeedUpCredits(it) } persistSetting { store.setSpeedUpCredits(it) }
}, },
onAudioPassthroughModeChanged = { mode -> onAudioPassthroughModeChanged = { mode ->
onAnalyticsEvent("audio_passthrough", "change") onAnalyticsEvent("audio_passthrough", "change")
@@ -538,7 +560,8 @@ fun SettingsSheet(
if (mode == AudioPassthroughMode.MANUAL && audioPassthroughCodecs.isEmpty()) { if (mode == AudioPassthroughMode.MANUAL && audioPassthroughCodecs.isEmpty()) {
audioPassthroughCodecs = deviceAudioCapabilities.passthrough audioPassthroughCodecs = deviceAudioCapabilities.passthrough
} }
scope.launch { store.setAudioPassthrough(mode, audioPassthroughCodecs) } val selectedCodecs = audioPassthroughCodecs
persistSetting { store.setAudioPassthrough(mode, selectedCodecs) }
}, },
onAudioPassthroughCodecChanged = { codec, enabled -> onAudioPassthroughCodecChanged = { codec, enabled ->
onAnalyticsEvent("audio_passthrough", "toggle") onAnalyticsEvent("audio_passthrough", "toggle")
@@ -547,41 +570,39 @@ fun SettingsSheet(
} else { } else {
audioPassthroughCodecs - codec audioPassthroughCodecs - codec
} }
scope.launch { val selectedMode = audioPassthroughMode
store.setAudioPassthrough(audioPassthroughMode, audioPassthroughCodecs) val selectedCodecs = audioPassthroughCodecs
} persistSetting { store.setAudioPassthrough(selectedMode, selectedCodecs) }
}, },
onRingColorChanged = { onRingColorChanged = {
onAnalyticsEvent("focus_colour", "change") onAnalyticsEvent("focus_colour", "change")
ringColor = it ringColor = it
scope.launch { store.setRingColor(it) } persistSetting { store.setRingColor(it) }
}, },
onHomeSectionChanged = { key, enabled -> onHomeSectionChanged = { key, enabled ->
onAnalyticsEvent("home_sections", "toggle") onAnalyticsEvent("home_sections", "toggle")
homeSections = if (enabled) homeSections + key else homeSections - key homeSections = if (enabled) homeSections + key else homeSections - key
scope.launch { store.setHomeSections(homeSections.toList()) } val selectedSections = homeSections.toList()
persistSetting { store.setHomeSections(selectedSections) }
}, },
onCardDensityChanged = { onCardDensityChanged = {
onAnalyticsEvent("card_density", "change") onAnalyticsEvent("card_density", "change")
cardDensity = it cardDensity = it
scope.launch { store.setHomeCardDensity(it) } persistSetting { store.setHomeCardDensity(it) }
}, },
onArtworkStyleChanged = { onArtworkStyleChanged = {
onAnalyticsEvent("artwork_style", "change") onAnalyticsEvent("artwork_style", "change")
artworkStyle = it artworkStyle = it
scope.launch { persistSetting { store.setHomeArtworkStyle(it) }
// 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) }
}
}, },
onRestoreHiddenRows = { onRestoreHiddenRows = {
onAnalyticsEvent("hidden_rows", "change") 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( store.setHomeRowPreferences(
settings.homeRowOrder.lineSequence().filter { it.isNotBlank() }.toList(), rowOrder,
settings.homePinnedRows.lineSequence().filter { it.isNotBlank() }.toSet(), pinnedRows,
emptySet(), emptySet(),
) )
} }
@@ -589,22 +610,22 @@ fun SettingsSheet(
onShowCardMetadataChanged = { onShowCardMetadataChanged = {
onAnalyticsEvent("card_metadata", "toggle") onAnalyticsEvent("card_metadata", "toggle")
showCardMetadata = it showCardMetadata = it
scope.launch { store.setShowHomeCardMetadata(it) } persistSetting { store.setShowHomeCardMetadata(it) }
}, },
onShowRatingsStripChanged = { onShowRatingsStripChanged = {
onAnalyticsEvent("ratings_strip", "toggle") onAnalyticsEvent("ratings_strip", "toggle")
showRatingsStrip = it showRatingsStrip = it
scope.launch { store.setShowRatingsStrip(it) } persistSetting { store.setShowRatingsStrip(it) }
}, },
onHideWatchedMoviesChanged = { onHideWatchedMoviesChanged = {
onAnalyticsEvent("hide_watched_movies", "toggle") onAnalyticsEvent("hide_watched_movies", "toggle")
hideWatchedMovies = it hideWatchedMovies = it
scope.launch { store.setHideWatchedMovies(it) } persistSetting { store.setHideWatchedMovies(it) }
}, },
onConfirmExitMembyChanged = { onConfirmExitMembyChanged = {
onAnalyticsEvent("confirm_exit", "toggle") onAnalyticsEvent("confirm_exit", "toggle")
confirmExitMemby = it confirmExitMemby = it
scope.launch { store.setConfirmExitMemby(it) } persistSetting { store.setConfirmExitMemby(it) }
}, },
onThemeChanged = { chosen -> onThemeChanged = { chosen ->
onAnalyticsEvent("theme", "change") onAnalyticsEvent("theme", "change")
@@ -612,20 +633,19 @@ fun SettingsSheet(
// arrives through ThemeSync a moment later. Painting optimistically here would // 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, // show a viewer a scheme that a season, or an allowlist they do not know about,
// is about to take back off them. // is about to take back off them.
scope.launch { store.setThemeId(chosen) } persistSetting { store.setThemeId(chosen) }
}, },
onWelcomeQuoteStyleChanged = { onWelcomeQuoteStyleChanged = {
onAnalyticsEvent("welcome_quote", "change") onAnalyticsEvent("welcome_quote", "change")
welcomeQuoteStyle = it welcomeQuoteStyle = it
scope.launch { store.setWelcomeQuoteStyle(it) } persistSetting { store.setWelcomeQuoteStyle(it) }
}, },
onPageSelected = { onPageSelected = {
onAnalyticsEvent("settings_page_${it.name.lowercase()}", "open") onAnalyticsEvent("settings_page_${it.name.lowercase()}", "open")
selectedPage = it selectedPage = it
}, },
onRefreshDevices = { onRefreshDevices = {
deviceJob?.cancel() launchDeviceJob { refreshDevices() }
deviceJob = scope.launch { refreshDevices() }
}, },
onRenameDevice = { onRenameDevice = {
devicesError = null devicesError = null
@@ -639,8 +659,7 @@ fun SettingsSheet(
} else { } else {
removingDeviceId = device.deviceId removingDeviceId = device.deviceId
devicesError = null devicesError = null
deviceJob?.cancel() launchDeviceJob {
deviceJob = scope.launch {
try { try {
withTimeout(8_000L) { ServiceLocator.repository.removeDevice(device.deviceId) } withTimeout(8_000L) { ServiceLocator.repository.removeDevice(device.deviceId) }
devices = devices.filterNot { it.deviceId == device.deviceId } devices = devices.filterNot { it.deviceId == device.deviceId }
@@ -718,9 +737,8 @@ fun SettingsSheet(
error = devicesError, error = devicesError,
onCancel = { editingDevice = null }, onCancel = { editingDevice = null },
onSave = { name -> onSave = { name ->
deviceJob?.cancel()
devicesError = null devicesError = null
deviceJob = scope.launch { launchDeviceJob {
try { try {
withTimeout(8_000L) { ServiceLocator.repository.renameDevice(device, name) } withTimeout(8_000L) { ServiceLocator.repository.renameDevice(device, name) }
devices = devices.map { devices = devices.map {
@@ -1332,10 +1350,11 @@ private fun SettingsSecondaryRail(
// is drawn. Everything the rail paints reads from this, so nothing about the highlight // is drawn. Everything the rail paints reads from this, so nothing about the highlight
// waits on the settle above. // waits on the settle above.
var focusedPage by remember { mutableStateOf(selected) } var focusedPage by remember { mutableStateOf(selected) }
val currentOnSelected by rememberUpdatedState(onSelected)
LaunchedEffect(focusedPage, selected) { LaunchedEffect(focusedPage, selected) {
if (focusedPage == selected) return@LaunchedEffect if (focusedPage == selected) return@LaunchedEffect
delay(SETTINGS_PAGE_SETTLE_MS) delay(SETTINGS_PAGE_SETTLE_MS)
onSelected(focusedPage) currentOnSelected(focusedPage)
} }
// The home screen remains composed behind this panel. Relying on spatial focus // 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 // search therefore lets a covered media card beat the next rail item when their