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
- 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.
+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
// 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()
@@ -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<List<BaseItem>?>(null) }
var loadFailed by remember(item.seriesId) { mutableStateOf(false) }
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.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<Job?>(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
}
@@ -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)
@@ -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)
@@ -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()
}
@@ -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
}
}
@@ -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<RelatedContent?>(null) }
var trailer by remember(item.id) { mutableStateOf<BaseItem?>(null) }
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.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
@@ -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<List<BaseItem>?>(null) }
var loadFailed by remember(item.id) { mutableStateOf(false) }
var related by remember(item.id) { mutableStateOf<RelatedContent?>(null) }
@@ -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 ?: "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,
* 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<String?>(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)
@@ -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<List<BaseItem>>(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 = "Couldnt start the trailer."
@@ -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()
}
}
}
@@ -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))
}
@@ -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<List<GatewayDevice>>(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