0.2.51
This commit is contained in:
@@ -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 ?: "Couldn’t start the update.",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun resumeAfterPermission(context: Context, update: GatewayUpdate) {
|
||||
if (_state.value.version != update.version || !_state.value.waitingForInstallPermission) return
|
||||
_state.value = _state.value.copy(waitingForInstallPermission = false)
|
||||
begin(context, update)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The app-level update gate. AppRoot composes this instead of login, profiles, or Home,
|
||||
* so no focused media card or playback action exists behind its buttons.
|
||||
@@ -101,37 +183,18 @@ fun UpdateScreen(
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val checker = remember { UpdateChecker(context) }
|
||||
|
||||
var installing by remember { mutableStateOf(false) }
|
||||
var message by remember { mutableStateOf<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 = "Couldn’t 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
|
||||
|
||||
Reference in New Issue
Block a user