From 5467fba0eb71eecd807b2e141ea1b963f2a47073 Mon Sep 17 00:00:00 2001 From: ponzischeme89 Date: Fri, 21 Aug 2026 09:54:44 +1200 Subject: [PATCH] 0.2.84 --- CHANGELOG.md | 14 + CLAUDE.md | 33 +- app/build.gradle.kts | 2 +- .../memby/data/EmbyRepository.kt | 22 + .../memby/data/MaintenanceMonitor.kt | 32 + .../memby/data/TrailerSupport.kt | 19 +- .../com/ponzischeme89/memby/ui/AppRoot.kt | 470 ++ .../memby/ui/ExitConfirmation.kt | 79 +- .../ponzischeme89/memby/ui/HomeComponents.kt | 260 +- .../ponzischeme89/memby/ui/HomeOverlays.kt | 881 +++ .../com/ponzischeme89/memby/ui/HomeScreen.kt | 2730 +++++++++ .../ponzischeme89/memby/ui/HomeViewModel.kt | 143 +- .../ponzischeme89/memby/ui/LaunchPreroll.kt | 2 +- .../ponzischeme89/memby/ui/MainActivity.kt | 5272 +---------------- .../ponzischeme89/memby/ui/MembyButtons.kt | 81 + .../com/ponzischeme89/memby/ui/Onboarding.kt | 929 +++ .../memby/ui/ResumableMediaCard.kt | 346 ++ .../memby/ui/UserSwitcherNavigation.kt | 139 +- .../memby/ui/player/PlayerActivity.kt | 43 +- .../memby/ui/player/PrerollPreloader.kt | 2 +- .../memby/ui/profiles/ProfileChooser.kt | 781 +++ .../memby/ui/setup/SignInContent.kt | 99 +- .../memby/ui/theme/DesignTokens.kt | 12 + .../memby/ui/viewers/ViewerEditing.kt | 2 +- .../memby/ui/viewers/ViewerSelection.kt | 31 +- .../memby/data/TrailerSupportTest.kt | 22 +- .../ponzischeme89/memby/ui/HomeUiStateTest.kt | 32 + .../memby/ui/ResumableMediaCardTest.kt | 66 + .../memby/ui/UserSwitcherNavigationTest.kt | 1 + .../memby/ui/UserSwitcherRowsTest.kt | 120 + .../profiles/ProfileChooserScreenshotTest.kt | 143 + .../memby/ui/profiles/ProfileChooserTest.kt | 78 + .../ui/requests/RequestPresentationTest.kt | 15 +- .../ui/viewers/ViewerPickerScreenshotTest.kt | 37 +- .../memby/ui/viewers/ViewerSelectionTest.kt | 29 +- server/internal/api/features.go | 8 + server/internal/api/features_test.go | 14 + server/internal/api/home.go | 125 +- server/internal/api/home_rows_test.go | 12 + 39 files changed, 7589 insertions(+), 5537 deletions(-) create mode 100644 app/src/main/java/com/ponzischeme89/memby/ui/AppRoot.kt create mode 100644 app/src/main/java/com/ponzischeme89/memby/ui/HomeOverlays.kt create mode 100644 app/src/main/java/com/ponzischeme89/memby/ui/HomeScreen.kt create mode 100644 app/src/main/java/com/ponzischeme89/memby/ui/Onboarding.kt create mode 100644 app/src/main/java/com/ponzischeme89/memby/ui/ResumableMediaCard.kt create mode 100644 app/src/main/java/com/ponzischeme89/memby/ui/profiles/ProfileChooser.kt create mode 100644 app/src/test/java/com/ponzischeme89/memby/ui/ResumableMediaCardTest.kt create mode 100644 app/src/test/java/com/ponzischeme89/memby/ui/UserSwitcherRowsTest.kt create mode 100644 app/src/test/java/com/ponzischeme89/memby/ui/profiles/ProfileChooserScreenshotTest.kt create mode 100644 app/src/test/java/com/ponzischeme89/memby/ui/profiles/ProfileChooserTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 18c13ac..d030cb6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,17 @@ +## 0.2.84 - 2026-08-21 +- Improved: Loading and performance of the continue watching row. + +## 0.2.83 - 2026-08-20 +- Fixed: Turning off "Play the next episode" now turns off the next-episode recap or preview with it. With auto-play off but previews on, an episode still cut away two minutes before the end to play a preview and then rolled into the next episode anyway. +- Fixed: "Who's watching?" now appears as soon as your server has viewers switched on, so the first person can actually be added. It was hidden until a second viewer existed, which nothing on the TV could create. +- Improved: The user menu now lists the people on this account, so switching to somebody else is one press instead of a row leading to a separate screen. Your Emby sign-ins moved to "Switch account" just below them, and adding a viewer is a row in the same list. +- Improved: The account chooser says plainly that it adds a separate Emby sign-in, so it is no longer mistaken for the way to add somebody to the account you are already using. +- Improved: "Who’s watching?" is centred, evenly spaced, and the profile under focus is obvious from across the room. Names now line up along one baseline whichever one is selected. +- Improved: Removing a user is a small delete mark that appears on the profile you are on — press Down to reach it — rather than a "Remove user" button parked under every profile. It still asks before it removes anything, and cancelling puts you back where you were. + +## 0.2.82 - 2026-08-20 +- New: Each Memby account can have multiple "profiles" per TV. + ## 0.2.81 - 2026-08-19 - Improved: Moved poster media type icon to subtitle on Genres page. - Fixed: Homepage heroes now update as soon as an administrator changes them. diff --git a/CLAUDE.md b/CLAUDE.md index c51e6de..39804d8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2220,13 +2220,32 @@ The optional **next-episode recap or preview** extends that same auto-advance pa next episode is known, the television searches YouTube in the background using series name, episode code and title, ranks official previews and recaps above reactions, reviews and breakdowns, resolves the best playable native stream, and caches the result. Playback never -waits for this work. If the setting is on and the playhead naturally crosses two minutes -remaining, the preview replaces the closing credits and then advances to the episode. Seeking -straight into that window does not trigger it. A failure before the first preview frame -silently restores the current episode at its saved position; a failure after that frame moves -on to the next episode, because the outgoing episode has already been closed. Preview time is -never reported to Emby as episode progress, and no YouTube account or embedded web player is -involved. +waits for this work. If **both** settings are on and the playhead naturally crosses two +minutes remaining, the preview replaces the closing credits and then advances to the episode. +Seeking straight into that window does not trigger it. A failure before the first preview +frame silently restores the current episode at its saved position; a failure after that frame +moves on to the next episode, because the outgoing episode has already been closed. Preview +time is never reported to Emby as episode progress, and no YouTube account or embedded web +player is involved. + +**Both settings**, and that is the one thing to preserve here. This is the exception to +`shouldAutoAdvance` being the only reader of `autoPlayNextEpisode`: a preview is not an offer +a viewer can decline the way the banner and the credits pane are — it *takes over playback* +two minutes from the end and rolls into the next episode on its own — so it is part of the +automatic transition and answers to the same switch. It read only +`playNextEpisodePreview`, which meant a viewer who had turned automatic advance off still had +their episode cut away two minutes early and the next one started for them, which is exactly +what "auto-play is off and it still plays the next episode" looks like from the sofa; the +toggle's own description in Settings had promised the dependency all along and nothing +enforced it. It is checked in three places for three different reasons and all three are +needed: the *prefetch*, so a provider search is not spent on something that must never play; +`shouldStartNextEpisodePreview`, at the moment of use, because this is a synced per-profile +preference and a document pushed from another television or from the admin console arrives +mid-episode; and both ways a preview *ends* (`completeNextEpisodePreview`, +`resumeEpisodeAfterPreviewFailure` past the outgoing report), which is the setting having been +switched off while the preview played — the transition it was the opening of has to go with +it. `autoPlayNextEpisodeEnabled` is the one live reader, never a value captured when playback +started. **Skipping is Left and Right, and it does not open anything.** `ui/player/SeekControls.kt` holds the arithmetic and the wording; `PlayerActivity.dispatchKeyEvent` owns the keys and diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 196b9fb..982bab5 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -63,7 +63,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.82" +val defaultVersionName = "0.2.84" val membyVersionName: String = (project.findProperty("memby.versionName") as String?) ?.trim() diff --git a/app/src/main/java/com/ponzischeme89/memby/data/EmbyRepository.kt b/app/src/main/java/com/ponzischeme89/memby/data/EmbyRepository.kt index d38478c..734b287 100644 --- a/app/src/main/java/com/ponzischeme89/memby/data/EmbyRepository.kt +++ b/app/src/main/java/com/ponzischeme89/memby/data/EmbyRepository.kt @@ -2725,6 +2725,28 @@ class EmbyRepository internal constructor( } } + /** + * Fresh playheads recorded by this television, for a launcher state holder recreated + * after playback. Reading the snapshot costs no request and prevents a missed transient + * emission from letting an older home response move a card backwards. + */ + internal fun localPlaybackPositions(): Map { + val now = System.currentTimeMillis() + return synchronized(localResume) { + val positions = linkedMapOf() + val entries = localResume.entries.iterator() + while (entries.hasNext()) { + val (itemId, entry) = entries.next() + if (isFreshLocalResume(entry.recordedAtMs, now)) { + positions[itemId] = PlaybackPosition(itemId, entry.positionMs) + } else { + entries.remove() + } + } + positions + } + } + /** * Drops the remembered playhead for [itemId]. Marking a title watched or unwatched sets * its position from outside playback entirely, so a record made before that decision has diff --git a/app/src/main/java/com/ponzischeme89/memby/data/MaintenanceMonitor.kt b/app/src/main/java/com/ponzischeme89/memby/data/MaintenanceMonitor.kt index 30b28eb..aeaafc1 100644 --- a/app/src/main/java/com/ponzischeme89/memby/data/MaintenanceMonitor.kt +++ b/app/src/main/java/com/ponzischeme89/memby/data/MaintenanceMonitor.kt @@ -94,6 +94,8 @@ class MaintenanceMonitor( private val _installPermissionPrompt = MutableStateFlow(false) private val _genreBrowserEnabled = MutableStateFlow(false) private val _tvCalendarEnabled = MutableStateFlow(false) + private val _continueWatchingEnabled = MutableStateFlow(true) + private val _viewersEnabled = MutableStateFlow(false) private val _requestsAllowed = MutableStateFlow(false) private val _gatewayVersion = MutableStateFlow("") private val _embyVersion = MutableStateFlow("") @@ -147,6 +149,27 @@ class MaintenanceMonitor( */ val tvCalendarEnabled: StateFlow = _tvCalendarEnabled.asStateFlow() + /** + * Whether Continue Watching belongs on Home. True when the field is absent so a direct + * Emby connection or an older gateway keeps the long-standing default. + */ + val continueWatchingEnabled: StateFlow = _continueWatchingEnabled.asStateFlow() + + /** + * Whether this household is running viewers — the people under one Emby account. + * + * The list alone cannot answer it. A gateway with the feature switched off still + * answers `/v1/viewers` with the account's own viewer, deliberately, so a television + * counting what it was sent reads a switched-off household and an account nobody has + * added anybody to the same way. Only one of those may be offered "Who's watching?": + * the Add behind it is refused with 403 on the other, which is a control whose only + * possible outcome is a refusal. + * + * False whenever the server has not said otherwise — the stance every flag on this poll + * takes. + */ + val viewersEnabled: StateFlow = _viewersEnabled.asStateFlow() + /** * Whether this viewer may ask the household for titles, as of the last poll. * @@ -260,6 +283,8 @@ class MaintenanceMonitor( _installPermissionPrompt.value = false _genreBrowserEnabled.value = false _tvCalendarEnabled.value = false + _continueWatchingEnabled.value = true + _viewersEnabled.value = false setRequestsAllowed(false) _gatewayVersion.value = "" dismissAlert() @@ -295,6 +320,9 @@ class MaintenanceMonitor( status.features[INSTALL_PERMISSION_FEATURE] == true _genreBrowserEnabled.value = status.features[GENRE_BROWSER_FEATURE] == true _tvCalendarEnabled.value = status.features[TV_CALENDAR_FEATURE] == true + _continueWatchingEnabled.value = + status.features[CONTINUE_WATCHING_FEATURE] ?: true + _viewersEnabled.value = status.features[VIEWERS_FEATURE] == true setRequestsAllowed(status.requests.allowed) _gatewayVersion.value = status.gatewayVersion // Emby's state is reported even during maintenance: an @@ -334,6 +362,8 @@ class MaintenanceMonitor( _installPermissionPrompt.value = false _genreBrowserEnabled.value = false _tvCalendarEnabled.value = false + _continueWatchingEnabled.value = true + _viewersEnabled.value = false setRequestsAllowed(false) _gatewayVersion.value = "" dismissAlert() @@ -441,6 +471,8 @@ class MaintenanceMonitor( /** Matches `featureGenreBrowser` in the gateway's feature catalogue. */ internal const val GENRE_BROWSER_FEATURE = "genre_browser" internal const val TV_CALENDAR_FEATURE = "tv_calendar" + internal const val CONTINUE_WATCHING_FEATURE = "continue_watching" + internal const val VIEWERS_FEATURE = "viewers" /** Insertion-ordered ceiling on the in-memory seen set. See [seenAlertIds]. */ private const val MAX_TRACKED_ALERT_IDS = 200 diff --git a/app/src/main/java/com/ponzischeme89/memby/data/TrailerSupport.kt b/app/src/main/java/com/ponzischeme89/memby/data/TrailerSupport.kt index ad49c68..ea4dbad 100644 --- a/app/src/main/java/com/ponzischeme89/memby/data/TrailerSupport.kt +++ b/app/src/main/java/com/ponzischeme89/memby/data/TrailerSupport.kt @@ -15,10 +15,27 @@ internal fun afterTrailerCandidate( /** Trailer failures always advance the provider chain instead of opening the generic error pane. */ internal fun shouldFallbackTrailer(isTrailer: Boolean): Boolean = isTrailer +/** + * Whether to replace the closing of this episode with the next one's recap or preview. + * + * [autoPlayEnabled] is first because it is the load-bearing one, and it was missing. The + * preview is not an offer a viewer can decline the way the banner and the credits pane are — + * it *takes over playback* two minutes from the end and then rolls into the next episode + * when it finishes. That is the automatic transition, so somebody who has turned automatic + * advance off must not get it: with the setting off and a preview resolved, the player cut + * away from the ending of the episode and then advanced anyway, which is exactly what + * "auto-play is off and it still plays the next episode" looks like from the sofa. The + * setting's own description in Settings already promised this ("With auto-play on, …"); + * nothing enforced it. + * + * It is read at the moment of use rather than when the preview was fetched, because a synced + * preference can arrive from another television or from the admin console mid-episode. + */ internal fun shouldStartNextEpisodePreview( + autoPlayEnabled: Boolean, armed: Boolean, dismissed: Boolean, remainingMs: Long, leadMs: Long, hasPreview: Boolean, -): Boolean = armed && !dismissed && hasPreview && remainingMs in 1L..leadMs +): Boolean = autoPlayEnabled && armed && !dismissed && hasPreview && remainingMs in 1L..leadMs diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/AppRoot.kt b/app/src/main/java/com/ponzischeme89/memby/ui/AppRoot.kt new file mode 100644 index 0000000..ed96477 --- /dev/null +++ b/app/src/main/java/com/ponzischeme89/memby/ui/AppRoot.kt @@ -0,0 +1,470 @@ +package com.ponzischeme89.memby.ui + +import android.widget.Toast +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.runtime.key +import androidx.compose.runtime.withFrameNanos +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.ponzischeme89.memby.R +import com.ponzischeme89.memby.BuildConfig +import com.ponzischeme89.memby.ServiceLocator +import com.ponzischeme89.memby.data.Settings +import com.ponzischeme89.memby.data.model.GatewayUpdate +import com.ponzischeme89.memby.data.model.RecommendationOnboarding +import com.ponzischeme89.memby.data.ServerConfig +import com.ponzischeme89.memby.data.remoteconfig.MembyRemoteConfig +import com.ponzischeme89.memby.ui.whatsnew.WhatsNewDecision +import com.ponzischeme89.memby.ui.seasonal.SeasonalDecorations +import com.ponzischeme89.memby.ui.whatsnew.whatsNewDecision +import com.ponzischeme89.memby.ui.theme.MembySurface +import com.ponzischeme89.memby.update.InstallPermission +import com.ponzischeme89.memby.update.RequiredUpdateSignal +import com.ponzischeme89.memby.update.nextUpdateCheckDelay +import com.ponzischeme89.memby.update.requiredUpdateSatisfied +import com.ponzischeme89.memby.update.ServerUpdateService +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.delay +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeoutOrNull +import java.time.Instant +import kotlin.time.Duration.Companion.milliseconds + +/** + * How long the launcher will wait for the gateway's onboarding verdict before opening the + * home screen anyway. Short on purpose: this is the one request left on the signed-in + * startup path, and everything it gates is already on disk. + */ +private val ONBOARDING_CHECK_TIMEOUT = 2_500.milliseconds +private val UPDATE_CHECK_TIMEOUT = 2_500.milliseconds +// Update policy is live operator state, just like maintenance. The verdict is an in-memory +// gateway read, so keeping this close to the maintenance poll means a set that was already +// open sees a newly-required release promptly instead of as much as an hour later. +private const val UPDATE_CHECK_INTERVAL_MS = 30_000L + +/** + * How long to wait before asking again while the gateway has this build retired. + * + * There is no attempt budget any more, and that is the point: a television in this state + * has been signed out and every screen it could otherwise be shown is one the gateway will + * refuse, so giving up used to mean handing the viewer a sign-in form as the *final* + * answer. It asks quickly at first, in case the verdict is merely a moment behind the + * refusal, then settles onto [UPDATE_REQUIRED_BACKOFF_MS] so an unreachable gateway is not + * polled every few seconds for as long as the set is switched on. + */ +private const val UPDATE_REQUIRED_RETRY_MS = 5_000L +private const val UPDATE_REQUIRED_BACKOFF_MS = 60_000L +private const val UPDATE_REQUIRED_FAST_ATTEMPTS = 4 + +@Composable +internal fun AppRoot( + remoteConfig: MembyRemoteConfig, + onCloseSettings: () -> Unit, + onHomeInteractive: () -> Unit, +) { + val repo = ServiceLocator.repository + val context = LocalContext.current + // This client intentionally has no token provider and no dependency on the active + // profile. Updates are an app lifecycle concern, checked before login/session work. + val updateService = remember { ServerUpdateService.create(ServerConfig.gatewayUrl) } + var appUpdate by remember { mutableStateOf(null) } + var initialUpdateCheckComplete by remember { mutableStateOf(false) } + // The refusal as it arrives, before the write of it has come back round through the + // settings flow. Both halves are needed: this one closes the window in which the + // sign-out has already landed and the persisted flag has not, and the persisted one + // (below) is what remembers the refusal across a restart. + var reportedRequiredUpdate by remember { mutableStateOf(null) } + LaunchedEffect(Unit) { + RequiredUpdateSignal.required.collect { + // Same rule the persisted copy applies: a refusal this build already satisfies + // describes an APK that is no longer here. + if (!requiredUpdateSatisfied(BuildConfig.VERSION_NAME, it)) { + reportedRequiredUpdate = it + } + } + } + // Wakes the check loop: a refusal arriving, or the viewer pressing Try again on the + // retired-build screen. Conflated because the only thing either says is "ask now". + val updateWake = remember { Channel(Channel.CONFLATED) } + // Held for the length of Memby's opening clip plus its pause, once per process. Not a + // rememberSaveable: an activity recreated behind the viewer (returning from the TV home + // screen, a configuration change) is not an app launch, and LaunchIntro carries that + // across it. + var introHolding by remember { mutableStateOf(!LaunchIntro.played) } + LaunchedEffect(Unit) { + if (!introHolding) return@LaunchedEffect + // The clip is decoration; the rows are the app. Whatever the player is doing, this + // is the longest it may ever stand in front of them. + delay(LAUNCH_INTRO_MAX_MS.milliseconds) + LaunchIntro.played = true + introHolding = false + } + var dismissedUpdateVersion by rememberSaveable { mutableStateOf(null) } + var confirmingExit by rememberSaveable { mutableStateOf(false) } + // SettingsStore starts eagerly in Application.onCreate. Reuse its in-memory value when + // Android recreates this activity after the viewer returns from the TV home screen; + // starting from null needlessly painted "Opening Memby..." while the replayed value + // made a round trip through a new collector. + val initialSettings = ServiceLocator.settings.current + var settings by remember { mutableStateOf(initialSettings) } + var addingProfile by rememberSaveable { mutableStateOf(false) } + var startingFirstRun by rememberSaveable { mutableStateOf(false) } + var installPermissionHandled by rememberSaveable { mutableStateOf(false) } + // Collected here rather than deeper because it decides which screen AppRoot shows at + // all. It is one boolean off the poll every TV already makes. + val installPermissionPrompt by ServiceLocator.maintenance.installPermissionPrompt + .collectAsStateWithLifecycle() + var validatedToken by remember { mutableStateOf(null) } + var recommendationOnboarding by remember { + mutableStateOf( + initialSettings + ?.takeIf { it.hasCompletedOnboarding || !it.homeCacheJson.isNullOrBlank() } + ?.let { RecommendationOnboarding(completed = true) }, + ) + } + var onboardingToken by remember { mutableStateOf(null) } + LaunchedEffect(updateService) { + // Every refusal wakes the loop, including a repeat of a version already handled: + // the 401 that retires the session and the 426 that refuses the sign-in after it + // name the same version, and the second is the one a viewer is standing in front + // of. Filtering those out is what left a television on a form it could not get + // through until the next hourly check. + launch { RequiredUpdateSignal.required.collect { updateWake.trySend(Unit) } } + var firstCheck = true + // How many checks in a row have been made because this build is retired. Loop + // state rather than app state: nothing outside this effect decides when to ask. + var requiredAttempts = 0 + // A launch timeout used to be treated like a successful "nothing to say" verdict, + // which put the next attempt an hour away. A television's first DNS/TLS connection + // is often the slowest one it makes, so failed or timed-out checks retry on the same + // bounded schedule as a retired build until the gateway gives a real answer. + var failedAttempts = 0 + while (true) { + // What the gateway has already said about this build, read from disk rather + // than from this loop's memory — the refusal is commonly one process old. + val retired = ServiceLocator.settings.current + ?.requiredUpdateVersion?.takeIf { it.isNotBlank() } + ?: reportedRequiredUpdate + val result = if (firstCheck && retired == null) { + // A disconnected server must not strand an otherwise usable TV at boot. + // A retired one is not usable, so it waits for the real answer instead of + // being hurried past it onto a sign-in form. + withTimeoutOrNull(UPDATE_CHECK_TIMEOUT) { updateService.check() } + } else { + updateService.check() + } + result?.onSuccess { decision -> + appUpdate = decision?.takeUnless { + it.isOptional && dismissedUpdateVersion == it.version + } + // Only the gateway may withdraw a refusal, and this is it answering. A + // verdict that does not require an update is the evidence that whatever + // retired this build no longer applies — a failed check is not, which is + // why nothing outside this branch clears the flag. + if (decision?.isMandatory != true) { + reportedRequiredUpdate = null + RequiredUpdateSignal.clear() + ServiceLocator.settings.clearRequiredUpdate() + } + } + if (firstCheck) { + initialUpdateCheckComplete = true + firstCheck = false + } + failedAttempts = if (result == null || result.isFailure) { + failedAttempts + 1 + } else { + 0 + } + requiredAttempts = if (retired != null && appUpdate == null) { + requiredAttempts + 1 + } else { + 0 + } + // Whichever comes first: the next scheduled check, or something asking for one + // now. While this build is retired and no verdict has arrived, that schedule is + // seconds rather than an hour — the alternative is a television sitting on a + // screen it cannot leave, waiting on an answer nobody has asked for. + val wait = nextUpdateCheckDelay( + requiredAttempts = requiredAttempts, + failedAttempts = failedAttempts, + fastAttempts = UPDATE_REQUIRED_FAST_ATTEMPTS, + fastRetryMillis = UPDATE_REQUIRED_RETRY_MS, + backoffMillis = UPDATE_REQUIRED_BACKOFF_MS, + regularPollMillis = UPDATE_CHECK_INTERVAL_MS, + ) + withTimeoutOrNull(wait.milliseconds) { updateWake.receive() } + } + } + LaunchedEffect(repo) { + repo.settingsFlow.collect { settings = it } + } + LaunchedEffect(settings?.token, initialUpdateCheckComplete, appUpdate?.version) { + 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 (!valid) { + repo.invalidateSession() + } + } + LaunchedEffect( + settings?.token, + settings?.userId, + initialUpdateCheckComplete, + appUpdate?.version, + ) { + if (!initialUpdateCheckComplete || appUpdate != null) return@LaunchedEffect + val loaded = settings ?: return@LaunchedEffect + val token = loaded.token?.takeIf { loaded.isSignedIn && it.isNotBlank() } + ?: run { + recommendationOnboarding = null + onboardingToken = null + return@LaunchedEffect + } + // The guard stops a settings emission that did not change the session from asking + // the gateway again. It must never be able to leave the answer unresolved: this + // effect is cancelled and restarted whenever the token or user id changes, so a + // restart that arrived while the previous run was still in flight would otherwise + // find its own token already recorded, return early, and strand the launcher on + // the loading screen for good. + if (onboardingToken == token && recommendationOnboarding != null) return@LaunchedEffect + onboardingToken = token + // Existing viewers can open their cached home while this small control-plane + // request runs. The server still gets the final say: an administrator may have + // queued a prompt since this TV last saw the profile, or reset a completed setup. + // A genuinely new profile has no useful home to show and waits for the answer. + if (!loaded.hasCompletedOnboarding && loaded.homeCacheJson.isNullOrBlank()) { + recommendationOnboarding = null + } + // This request decides which screen opens, so the launcher waits on it — which + // makes its worst case the app's worst case. Left unbounded that is the gateway + // client's connect plus read timeout, half a minute of "Opening Memby…" on a TV + // whose cached rows were ready the whole time. Past this budget the viewer goes + // to their rows and a profile that genuinely has not onboarded is asked again on + // the next launch, which is the cheaper mistake by a wide margin. + val answered = runCatching { + withTimeoutOrNull(ONBOARDING_CHECK_TIMEOUT) { repo.getRecommendationOnboarding() } + }.getOrNull() + // runCatching swallows cancellation too, so say so explicitly: a run abandoned + // because the session changed must not write its verdict over the one the + // restarted effect is about to produce. + currentCoroutineContext().ensureActive() + // Only a completion the gateway actually reported is remembered. Falling back to + // "completed" keeps a viewer out of onboarding during an outage, but persisting + // that guess would silently retire the rating screen for someone who has never + // seen it, on nothing more than one slow response. + if (answered?.completed == true) { + loaded.userId?.let { ServiceLocator.settings.markOnboardingCompleted(it) } + } + // Do not trap an existing viewer behind onboarding during a gateway outage. + recommendationOnboarding = answered ?: RecommendationOnboarding(completed = true) + } + + // Whether this TV has already been told about the build it is running. Keyed + // on the recorded version and the session, because a fresh install records the current + // version during setup and a sign-in is what turns "waiting" into "announce it now". + LaunchedEffect(settings?.whatsNewSeenVersion, settings?.isSignedIn) { + val loaded = settings ?: return@LaunchedEffect + val decision = whatsNewDecision( + installedVersion = BuildConfig.VERSION_NAME, + seenVersion = loaded.whatsNewSeenVersion, + isSignedIn = loaded.isSignedIn, + ) + 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, + updateAlertAt = Instant.now().toString(), + ) + currentCoroutineContext().ensureActive() + Toast.makeText( + context, + context.getString(R.string.app_updated_to_version, decision.version), + Toast.LENGTH_LONG, + ).show() + } + is WhatsNewDecision.MarkSeen -> + ServiceLocator.settings.markWhatsNewSeen(decision.version) + WhatsNewDecision.Nothing -> Unit + } + } + + Box(Modifier.fillMaxSize().background(MembySurface)) { + val loaded = settings + val update = appUpdate + val onboarding = recommendationOnboarding + // The gateway has refused this build. Until it answers with a verdict — or with a + // verdict that no longer requires one — this is the only screen the television may + // show: its session is gone, and sign-in, profiles and the launcher are all things + // the server will refuse. + val retiredVersion = loaded?.requiredUpdateVersion?.takeIf { it.isNotBlank() } + ?: reportedRequiredUpdate + // The three states below that mean "still opening" used to be three separate calls + // to MembyLoadingScreen, and Compose identifies a composable by where it is called + // from — so passing between them disposed the screen and built it again. That was + // only a wasted animation before; now it hands the pre-roll player back and borrows + // it a moment later, twice, during the one stretch of a launch that is busiest. + // Hoisted to one call site, the screen and its clip survive the whole cold start. + val openingQuoteStyle = when { + // A required update is a service gate, not launcher content. Once the gateway + // answers, uncover it immediately instead of making the viewer finish the + // decorative opening clip first. Optional prompts may wait for the intro. + update?.isMandatory == true -> null + // Memby's own clip runs to its end before anything else is drawn. It is the one + // thing the app owns and it was, in practice, never seen: the launcher uncovered + // it whenever it happened to be ready, which on a warm start was a fraction of a + // second. LAUNCH_INTRO_MAX_MS below is the outer bound — the rows are never more + // than that away, whatever the player does. + introHolding -> loaded?.welcomeQuoteStyle.orEmpty() + !initialUpdateCheckComplete -> loaded?.welcomeQuoteStyle.orEmpty() + update != null -> null + // Retired, verdict not here yet. The retired-build screen takes the frame + // rather than the opening screen: it says what has happened and offers the one + // thing there is to do about it, where a loading screen with nothing behind it + // reads as a television that has stopped working. + retiredVersion != null -> null + loaded == null -> ServiceLocator.settings.current?.welcomeQuoteStyle.orEmpty() + // Ordered exactly as the screens below are: adding a profile outranks waiting + // on the onboarding answer, and swapping the two would put the loading screen + // over a sign-in form somebody is halfway through. + addingProfile -> null + loaded.isSignedIn && onboarding == null -> + loaded.welcomeQuoteStyle.orEmpty() + else -> null + } + if (openingQuoteStyle != null) { + MembyLoadingScreen( + quoteStyle = openingQuoteStyle, + onIntroFinished = { + LaunchIntro.played = true + introHolding = false + }, + ) + } + when { + openingQuoteStyle != null -> Unit + update != null -> UpdateScreen( + update = update, + onDismiss = { + val current = appUpdate ?: return@UpdateScreen + if (!current.isMandatory) { + dismissedUpdateVersion = current.version + appUpdate = null + } + }, + ) + // Ahead of every remaining screen, and of the null-settings case: a television + // whose build has been retired must not reach sign-in, profiles or the + // launcher, whatever else is true of it. + retiredVersion != null -> RetiredBuildScreen( + requiredVersion = retiredVersion, + onRetry = { updateWake.trySend(Unit) }, + ) + loaded == null -> Unit + addingProfile -> SetupScreen( + onCancel = { addingProfile = false }, + onSignedIn = { addingProfile = false }, + ) + loaded.isSignedIn && onboarding?.completed == false && onboarding.prompted -> { + RecommendationOnboardingScreen( + onboarding = onboarding, + onSkip = { + // Skip is for this visit only. Do not mark or save completion: the + // gateway may offer onboarding again on a later launch. + recommendationOnboarding = onboarding.copy(completed = true) + }, + onComplete = { ratings, actors, actresses, directors -> + repo.saveRecommendationRatings(ratings, actors, actresses, directors) + // Record it locally as well, so this profile's next cold start + // skips the gateway check entirely. + loaded.userId?.let { ServiceLocator.settings.markOnboardingCompleted(it) } + recommendationOnboarding = onboarding.copy( + completed = true, + ratings = ratings, + ) + }, + ) + } + // Pushed by the operator, and only ever seen by a TV that actually lacks the + // permission — the condition clears itself the moment it is granted. It sits + // after sign-in rather than before because the household this matters to is the + // one already using Memby: those sets were installed from Downloader, were never + // asked, and cannot take an update today. + loaded.isSignedIn && !installPermissionHandled && installPermissionPrompt && + !InstallPermission.granted(LocalContext.current) -> + InstallPermissionScreen(onContinue = { installPermissionHandled = true }) + loaded.isSignedIn -> { + BackHandler { + if (loaded.confirmExitMemby) confirmingExit = true else onCloseSettings() + } + key(loaded.userId, loaded.serverUrl) { + HomeScreen(settings = loaded, remoteConfig = remoteConfig) + } + LaunchedEffect(loaded.userId, loaded.serverUrl) { + // Report after the launcher has submitted a frame, not merely when its + // composable was entered. StartupTimingMetric can now distinguish the + // quick opening surface from the point at which D-pad content is live. + withFrameNanos { } + onHomeInteractive() + } + // Snow, bats or blossom for the few days a year a season is on, over the + // launcher and nowhere else. Not over playback — a film is the one thing + // nothing may drift across — and not over the settings sheet, which is a + // page of small text. It is collected here rather than inside HomeScreen so + // an arriving theme cannot invalidate the rows: this is a sibling node, and + // the only thing that recomposes when December starts. + SeasonalDecorations( + decoration = ServiceLocator.themeSync.theme + .collectAsStateWithLifecycle().value?.decoration.orEmpty(), + ) + } + loaded.profiles.isNotEmpty() -> ProfileEntryScreen( + settings = loaded, + onAddProfile = { addingProfile = true }, + ) + // Asked before sign-in, on a fresh install only: this is the one moment somebody + // is certainly in front of the TV and expecting to be asked things. Skippable — + // see InstallPermissionScreen for why it must never gate a new install. + startingFirstRun && !installPermissionHandled && + !InstallPermission.granted(LocalContext.current) -> + InstallPermissionScreen(onContinue = { installPermissionHandled = true }) + startingFirstRun -> SetupScreen( + onCancel = { startingFirstRun = false }, + onSignedIn = { startingFirstRun = false }, + ) + else -> FirstRunScreen(onGetStarted = { startingFirstRun = true }) + } + if (confirmingExit) { + ExitMembyConfirmation( + onStay = { confirmingExit = false }, + onExit = onCloseSettings, + ) + } + } +} + +// ExitMembyConfirmation lives in ui/ExitConfirmation.kt — it is drawn from the design +// tokens rather than from raw Material buttons, and is screenshot-tested on its own. + diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/ExitConfirmation.kt b/app/src/main/java/com/ponzischeme89/memby/ui/ExitConfirmation.kt index a9c9555..6c85b4f 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/ExitConfirmation.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/ExitConfirmation.kt @@ -7,7 +7,6 @@ import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween import androidx.compose.foundation.background import androidx.compose.foundation.border -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -35,7 +34,6 @@ import androidx.compose.ui.draw.shadow import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusProperties import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer @@ -46,19 +44,17 @@ import androidx.compose.ui.unit.sp import androidx.compose.ui.zIndex import androidx.tv.material3.Icon import androidx.tv.material3.Text -import com.ponzischeme89.memby.ui.theme.MembyAccent import com.ponzischeme89.memby.ui.theme.MembyAccentBright import com.ponzischeme89.memby.ui.theme.MembyAccentMuted -import com.ponzischeme89.memby.ui.theme.MembyCardCorner import com.ponzischeme89.memby.ui.theme.MembyHairline import com.ponzischeme89.memby.ui.theme.MembyMutedText import com.ponzischeme89.memby.ui.theme.MembyOnSurface -import com.ponzischeme89.memby.ui.theme.MembyOutline import com.ponzischeme89.memby.ui.theme.MembyPanelCorner import com.ponzischeme89.memby.ui.theme.MembyQuietText import com.ponzischeme89.memby.ui.theme.MembySplashTint import com.ponzischeme89.memby.ui.theme.MembySurfaceRaised import kotlinx.coroutines.delay +import kotlin.time.Duration.Companion.milliseconds /** The two answers the panel offers, named so a capture can say which one is under focus. */ internal enum class ExitChoice { STAY, CLOSE } @@ -113,7 +109,7 @@ internal fun ExitMembyConfirmation( // dialog leaves a television with nothing focused and no way out of it. LaunchedEffect(Unit) { appeared = true - delay(16L) + delay(16.milliseconds) runCatching { stayFocus.requestFocus() } } Box( @@ -189,7 +185,7 @@ internal fun ExitMembyConfirmation( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(14.dp), ) { - DialogAction( + MembyDialogAction( label = "Stay in Memby", primary = true, onClick = onStay, @@ -204,7 +200,7 @@ internal fun ExitMembyConfirmation( down = FocusRequester.Cancel }, ) - DialogAction( + MembyDialogAction( label = "Close Memby", primary = false, onClick = onExit, @@ -230,70 +226,3 @@ internal fun ExitMembyConfirmation( } } } - -/** - * One dialog action. The same corner, lift and focus ring as [MembyPlayButton], because a - * viewer should not have to learn a second focus language for the panel that appears over - * the one they were just using. - */ -@Composable -private fun DialogAction( - label: String, - primary: Boolean, - onClick: () -> Unit, - modifier: Modifier = Modifier, - focusedForCapture: Boolean = false, -) { - var hasFocus by remember { mutableStateOf(false) } - val focused = hasFocus || focusedForCapture - val lift by animateFloatAsState( - targetValue = if (focused) 1f else 0f, - animationSpec = tween(durationMillis = 110), - label = "exit-dialog-action", - ) - val shape = RoundedCornerShape(MembyCardCorner) - Box( - modifier = modifier - .graphicsLayer { - val t = lift - scaleX = 1f + 0.04f * t - scaleY = 1f + 0.04f * t - translationY = -3f * t - } - .shadow(if (focused) 16.dp else 0.dp, shape) - .clip(shape) - .background( - when { - focused && primary -> MembyAccent - focused -> MembyOutline - primary -> MembyAccentMuted - else -> Color.Transparent - }, - ) - .border( - width = if (focused) 2.dp else 1.dp, - color = when { - focused -> Color.White - primary -> MembyAccent.copy(alpha = 0.55f) - else -> MembyOutline - }, - shape = shape, - ) - .onFocusChanged { hasFocus = it.isFocused } - .clickable(onClick = onClick) - .padding(horizontal = 20.dp, vertical = 13.dp), - contentAlignment = Alignment.Center, - ) { - Text( - label, - color = when { - focused -> Color.White - primary -> MembyAccentBright - else -> MembyMutedText - }, - fontSize = 16.sp, - fontWeight = FontWeight.Bold, - maxLines = 1, - ) - } -} diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/HomeComponents.kt b/app/src/main/java/com/ponzischeme89/memby/ui/HomeComponents.kt index a6cf528..dfeae08 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/HomeComponents.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/HomeComponents.kt @@ -94,6 +94,7 @@ import com.ponzischeme89.memby.R import com.ponzischeme89.memby.BuildConfig import com.ponzischeme89.memby.data.EmbyProfile import com.ponzischeme89.memby.data.model.BaseItem +import com.ponzischeme89.memby.data.model.MembyViewer import com.ponzischeme89.memby.data.model.EmbyPerson import com.ponzischeme89.memby.ui.alerts.alertBadgeLabel import com.ponzischeme89.memby.ui.detail.UHD_MIN_WIDTH @@ -483,38 +484,57 @@ fun UserSwitcherOverlay( showRequests: Boolean = false, onOpenRequests: () -> Unit = {}, /** - * Whether this household has anybody to choose between. False hides the row entirely, - * for the same reason [showRequests] does: on the direct path there is nobody to ask, - * and an account nobody has added a viewer to would be offered a question with one - * answer. See `shouldOfferViewerPicker`. + * Whether this household runs viewers. True turns the panel's list from *accounts* into + * *people*: see [userSwitcherRows] for why the answer decides what the one list holds + * rather than adding a second list beside it. False everywhere the question cannot be + * asked — on the direct path there is nobody to ask — as [showRequests] is. */ showViewers: Boolean = false, + viewers: List = emptyList(), activeViewerId: String = "", activeViewerName: String = "", - onOpenViewers: () -> Unit = {}, + onViewerSelected: (MembyViewer) -> Unit = {}, + onAddViewer: () -> Unit = {}, + /** False at the gateway's limit: the row is removed rather than dimmed. */ + canAddViewer: Boolean = false, + onManageViewers: () -> Unit = {}, ) { - val profileIds = profiles.map(EmbyProfile::id) val menuItems = userSwitcherMenuItems(showRequests, showViewers) val actionCount = menuItems.size - // Re-keyed on the action count as well as the profiles: a permission arriving on a poll + val rows = remember( + profiles, + viewers, + activeProfileId, + activeViewerId, + showViewers, + canAddViewer, + ) { + userSwitcherRows( + profiles = profiles, + viewers = viewers, + activeProfileId = activeProfileId, + activeViewerId = activeViewerId, + showViewers = showViewers, + canAddViewer = canAddViewer, + ) + } + // Re-keyed on the action count as well as the rows: a permission arriving on a poll // while this menu is open changes how many rows there are, and a requester list of the // old length would leave the new row unfocusable. - val focusRequesters = remember(profileIds, menuItems) { - List(profiles.size + actionCount) { FocusRequester() } + val focusRequesters = remember(rows, menuItems) { + List(rows.size + actionCount) { FocusRequester() } } - val profileListState = remember(profileIds) { LazyListState() } + val profileListState = remember(rows) { LazyListState() } val focusScope = rememberCoroutineScope() - var profileFocusJob by remember(profileIds) { mutableStateOf(null) } - var focusedIndex by remember(profileIds) { - mutableStateOf(userSwitcherInitialIndex(profileIds, activeProfileId)) - } - LaunchedEffect(profileIds, activeProfileId) { + var profileFocusJob by remember(rows) { mutableStateOf(null) } + var focusedIndex by remember(rows) { mutableStateOf(userSwitcherRowFocusIndex(rows)) } + LaunchedEffect(rows) { profileFocusJob?.cancel() - focusedIndex = userSwitcherInitialIndex(profileIds, activeProfileId) - if (profiles.isNotEmpty()) { + focusedIndex = userSwitcherRowFocusIndex(rows) + if (rows.isNotEmpty()) { profileListState.scrollToItem(focusedIndex) } - // Let a lazily composed off-screen profile attach its FocusRequester first. + // Let a lazily composed off-screen row attach its FocusRequester first. delay(16) runCatching { focusRequesters[focusedIndex].requestFocus() } } @@ -530,7 +550,12 @@ fun UserSwitcherOverlay( .align(Alignment.CenterStart) .padding(start = 52.dp) .width(292.dp) - .heightIn(max = 400.dp) + // 460dp of a 540dp television, so the panel still reads as a panel over the + // launcher rather than as a screen. It went up from 400dp when the list + // became the *people*: an account list is one or two rows and a household + // is up to eight, and the actions below were being clipped off the bottom + // — including the row that manages the very list doing the clipping. + .heightIn(max = 460.dp) .shadow(12.dp, RoundedCornerShape(MembyPanelCorner)) .clip(RoundedCornerShape(MembyPanelCorner)) .background(MembySurface) @@ -546,13 +571,13 @@ fun UserSwitcherOverlay( } val next = userSwitcherNextIndex( currentIndex = focusedIndex, - profileCount = profiles.size, + profileCount = rows.size, direction = direction, actionCount = actionCount, ) focusedIndex = next profileFocusJob?.cancel() - if (next < profiles.size) { + if (next < rows.size) { profileFocusJob = focusScope.launch { profileListState.scrollToItem(next) delay(16) @@ -576,18 +601,22 @@ fun UserSwitcherOverlay( .padding(10.dp), ) { Text( - "Switch user", + // The panel is named for what its list holds. With viewers in play that is + // people, and the accounts are one row below — so this is the only place + // the question is asked, rather than a heading and a row below it both + // claiming to answer it. + if (showViewers) "Who’s watching?" else "Switch user", color = Color.White, fontSize = 18.sp, fontWeight = FontWeight.Bold, modifier = Modifier.padding(start = 8.dp, top = 7.dp, end = 8.dp, bottom = 5.dp), ) Text( - // With viewers in play this list is *accounts*, and the row below it is the - // person — so the panel must stop claiming to be the thing that answers - // "who is watching" when there is now a control directly beneath it that - // does. A household running no viewers keeps the wording it always had. - if (showViewers) "Choose an account" else "Choose who’s watching", + if (showViewers) { + "Everyone keeps their own Continue Watching." + } else { + "Choose who’s watching" + }, color = QuietText, fontSize = 12.sp, modifier = Modifier.padding(horizontal = 8.dp).padding(bottom = 8.dp), @@ -596,24 +625,49 @@ fun UserSwitcherOverlay( state = profileListState, modifier = Modifier .fillMaxWidth() - // Settings adds one fixed action below this list. Give that row its - // space while keeping the panel's established 400dp maximum; larger - // households still reach every profile through the lazy list. - .heightIn(max = 208.dp), + // Weighted, and therefore measured from what the pinned actions left + // over rather than from a figure typed in here. A fixed cap has to be + // re-derived by hand every time a row is added below it, and the one + // that was here had stopped being right the moment the list could hold + // eight people instead of two accounts. `fill = false` so a household + // of one still gets a panel the size of its contents. + .weight(1f, fill = false), verticalArrangement = Arrangement.spacedBy(2.dp), ) { - itemsIndexed( - items = profiles, - key = { _, profile -> profile.id }, - ) { index, profile -> - UserSwitcherProfileItem( - profile = profile, - current = profile.id == activeProfileId, - modifier = Modifier - .focusRequester(focusRequesters[index]) - .onFocusChanged { if (it.isFocused) focusedIndex = index }, - onClick = { onProfileSelected(profile) }, - ) + itemsIndexed(items = rows, key = { _, row -> userSwitcherRowKey(row) }) { index, row -> + val rowModifier = Modifier + .focusRequester(focusRequesters[index]) + .onFocusChanged { if (it.isFocused) focusedIndex = index } + when (row) { + is UserSwitcherRow.Account -> UserSwitcherPersonItem( + name = row.profile.username, + initials = profileInitials( + row.profile.username, + row.profile.profileInitials, + ), + current = row.active, + currentDescription = "current user", + modifier = rowModifier, + onClick = { onProfileSelected(row.profile) }, + ) + is UserSwitcherRow.Viewer -> UserSwitcherPersonItem( + name = row.viewer.name, + initials = row.viewer.initials, + current = row.active, + currentDescription = "currently watching", + modifier = rowModifier, + onClick = { onViewerSelected(row.viewer) }, + ) + // Inside the list rather than below the pinned actions, because it + // belongs to the question the list is asking — and it is the only + // way an account with nobody added yet ever gains a first person. + UserSwitcherRow.AddViewer -> UserSwitcherAction( + label = "Add viewer", + icon = MembyIcon.Add.mark, + modifier = rowModifier, + onClick = onAddViewer, + ) + } } } Spacer(Modifier.height(6.dp)) @@ -628,19 +682,21 @@ fun UserSwitcherOverlay( // written out here. The four hand-computed offsets this replaced were what made // adding a fifth conditional row unsafe. menuItems.forEachIndexed { offset, item -> - val index = profiles.size + offset + val index = rows.size + offset val modifier = Modifier .focusRequester(focusRequesters[index]) .onFocusChanged { if (it.isFocused) focusedIndex = index } when (item) { - // Who is watching sits above the rest because it changes whose menu - // this is: the notifications and requests below belong to whichever - // viewer it selects. - UserSwitcherMenuItem.VIEWERS -> UserSwitcherAction( - label = viewerMenuLabel(activeViewerId, activeViewerName), + // The accounts, now that the list above holds people instead. It sits + // above the rest because it changes whose menu this is: the + // notifications and requests below belong to whatever it selects. The + // screen it opens is also where an account is added and removed, which + // is why no separate "Manage accounts" row sits beside it. + UserSwitcherMenuItem.SWITCH_ACCOUNT -> UserSwitcherAction( + label = switchAccountLabel(profiles, activeProfileId), icon = MembyIcon.Person.mark, modifier = modifier, - onClick = onOpenViewers, + onClick = onManageProfiles, ) // Notifications live here rather than on the launcher: these belong to // a person and follow them between televisions, so the menu that @@ -667,6 +723,17 @@ fun UserSwitcherOverlay( modifier = modifier, onClick = onOpenSettings, ) + // Renaming and removing the people in the list above. Choosing one of + // them is a press in that list; this is the rarer, editing half, which + // is why it is at the bottom rather than competing with the names. + UserSwitcherMenuItem.MANAGE_VIEWERS -> UserSwitcherAction( + label = "Manage viewers", + icon = MembyIcon.Grid.mark, + modifier = modifier, + onClick = onManageViewers, + ) + // The same row where viewers are not in play: there the list above is + // the accounts, and this is what edits it. UserSwitcherMenuItem.MANAGE_USERS -> UserSwitcherAction( label = "Manage users", icon = MembyIcon.Grid.mark, @@ -679,10 +746,21 @@ fun UserSwitcherOverlay( } } +/** + * One name in the panel's list, whether that list holds accounts or people. + * + * It is one composable rather than two because the two lists are never on screen together: + * the row a household reads every evening should look the same whichever of the two + * questions their gateway has them answering. [currentDescription] is the only thing that + * differs, and it differs because "current user" and "currently watching" are genuinely + * different claims. + */ @Composable -private fun UserSwitcherProfileItem( - profile: EmbyProfile, +private fun UserSwitcherPersonItem( + name: String, + initials: String, current: Boolean, + currentDescription: String, modifier: Modifier = Modifier, onClick: () -> Unit, ) { @@ -702,8 +780,7 @@ private fun UserSwitcherProfileItem( ) .clickable(onClick = onClick) .semantics { - contentDescription = - if (current) "${profile.username}, current user" else profile.username + contentDescription = if (current) "$name, $currentDescription" else name } .padding(horizontal = 10.dp), verticalAlignment = Alignment.CenterVertically, @@ -716,14 +793,14 @@ private fun UserSwitcherProfileItem( contentAlignment = Alignment.Center, ) { Text( - profileInitials(profile.username, profile.profileInitials), + initials, color = Color.White, fontSize = 11.sp, fontWeight = FontWeight.Bold, ) } Text( - profile.username, + name, color = if (focused || current) Color.White else MutedText, fontSize = 14.sp, fontWeight = if (current) FontWeight.SemiBold else FontWeight.Medium, @@ -1765,23 +1842,25 @@ internal fun MediaRow( onContentFocused() onItemFocused(item) } - when (cardFormat(row.kind, item, artworkStyle)) { - // The resume bar belongs to the row, not to the card's shape: a - // household that has set artwork to posters still needs to see - // how far into something it is. + val format = cardFormat(row.kind, item, artworkStyle) + if (row.kind == MediaRowKind.CONTINUE) { + ContinueWatchingCard( + item = item, + availableWidth = availableWidth, + portraitArtwork = format == MediaCardFormat.PORTRAIT, + onFocused = focused, + onClick = { onItemSelected(item) }, + onLongClick = { onItemLongPressed(item) }, + modifier = cardModifier, + density = density, + ) + } else when (format) { MediaCardFormat.PORTRAIT -> PortraitMediaCard( item, availableWidth, row.showSecondaryMetadata, focused, { onItemSelected(item) }, { onItemLongPressed(item) }, cardModifier, density, row.showWatchedEpisodeCount, - showProgress = row.kind == MediaRowKind.CONTINUE, ) - MediaCardFormat.LANDSCAPE -> if (row.kind == MediaRowKind.CONTINUE) { - ContinueWatchingCard( - item, availableWidth, row.showSecondaryMetadata, focused, - { onItemSelected(item) }, { onItemLongPressed(item) }, cardModifier, - density, row.showWatchedEpisodeCount, - ) - } else { + MediaCardFormat.LANDSCAPE -> { LandscapeMediaCard( item, availableWidth, row.showSecondaryMetadata, focused, { onItemSelected(item) }, { onItemLongPressed(item) }, cardModifier, @@ -1965,23 +2044,46 @@ fun LandscapeMediaCard( fun ContinueWatchingCard( item: BaseItem, availableWidth: Dp, - showSecondaryMetadata: Boolean, + portraitArtwork: Boolean, onFocused: () -> Unit, onClick: () -> Unit, onLongClick: () -> Unit, modifier: Modifier = Modifier, density: String = "standard", - showWatchedEpisodeCount: Boolean = false, ) { - val cardsAcross = when (density) { - "compact" -> 5 - "large" -> 3 - else -> 4 + val cardsAcross = if (portraitArtwork) { + when (density) { + "compact" -> 8 + "large" -> 5 + else -> 7 + } + } else { + when (density) { + "compact" -> 5 + "large" -> 3 + else -> 4 + } } - val width = responsiveRowCardWidth(availableWidth, cardsAcross, 164.dp, 360.dp) - MediaCard( - item, width, 16f / 9f, preferPrimary = false, showProgress = true, - showSecondaryMetadata, showWatchedEpisodeCount, onFocused, onClick, onLongClick, modifier, + val width = if (portraitArtwork) { + responsiveRowCardWidth(availableWidth, cardsAcross, 102.dp, 218.dp) + } else { + responsiveRowCardWidth(availableWidth, cardsAcross, 164.dp, 360.dp) + } + val repository = ServiceLocator.repository + val model = remember(item) { + item.toResumableMediaCardModel( + backdropUrl = repository.backdropUrl(item, 720), + primaryUrl = repository.primaryUrl(item, 480), + ) + } + ResumableMediaCard( + model = model, + width = width, + portraitArtwork = portraitArtwork, + onFocused = onFocused, + onClick = onClick, + onLongClick = onLongClick, + modifier = modifier, ) } @@ -2367,7 +2469,7 @@ fun TvLoadingPlaceholder( } @Composable -private fun ArtworkLoadingSkeleton(modifier: Modifier = Modifier) { +internal fun ArtworkLoadingSkeleton(modifier: Modifier = Modifier) { Box( modifier.background( Brush.linearGradient( diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/HomeOverlays.kt b/app/src/main/java/com/ponzischeme89/memby/ui/HomeOverlays.kt new file mode 100644 index 0000000..6faa6ec --- /dev/null +++ b/app/src/main/java/com/ponzischeme89/memby/ui/HomeOverlays.kt @@ -0,0 +1,881 @@ +package com.ponzischeme89.memby.ui + +import com.ponzischeme89.memby.ui.theme.MembyIcon +import com.ponzischeme89.memby.ui.theme.mark +import android.os.Build +import android.text.format.DateFormat +import androidx.compose.foundation.background +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.semantics.contentDescription +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.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.zIndex +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.repeatOnLifecycle +import coil.imageLoader +import coil.request.ImageRequest +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.HomeRow +import com.ponzischeme89.memby.ui.detail.AiringNotice +import com.ponzischeme89.memby.ui.theme.MembyAccent +import com.ponzischeme89.memby.ui.theme.MembyAccentBright +import com.ponzischeme89.memby.ui.theme.MembyMutedText +import com.ponzischeme89.memby.ui.theme.MembyOnSurface +import com.ponzischeme89.memby.ui.theme.MembyQuietText +import com.ponzischeme89.memby.ui.theme.MembySplashTint +import com.ponzischeme89.memby.ui.theme.MembySurface +import com.ponzischeme89.memby.ui.theme.MembySurfaceRaised +import androidx.tv.material3.Card +import androidx.tv.material3.Icon +import androidx.tv.material3.Text +import kotlinx.coroutines.delay +import java.util.Date +import java.util.Calendar +import kotlin.time.Duration.Companion.milliseconds + +@Composable +internal fun PlaybackLaunchOverlay(item: BaseItem, modifier: Modifier = Modifier) { + val rotation by rememberInfiniteTransition(label = "playback-launch").animateFloat( + initialValue = 0f, + targetValue = 360f, + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = 850, easing = LinearEasing), + repeatMode = RepeatMode.Restart, + ), + label = "playback-logo-rotation", + ) + Box( + modifier = modifier.background(MembySurface.copy(alpha = 0.93f)), + contentAlignment = Alignment.Center, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Image( + painter = painterResource(R.drawable.emby_logo), + contentDescription = null, + contentScale = ContentScale.Fit, + modifier = Modifier + .width(82.dp) + .height(70.dp) + .graphicsLayer { rotationZ = rotation }, + ) + Text( + "Starting ${item.name}", + color = Color.White, + fontSize = 21.sp, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + "Preparing direct playback…", + color = MembyQuietText, + fontSize = 14.sp, + ) + } + } +} + +/** Row id under which the search grid records its return-focus target. */ +internal const val SEARCH_ROW_ID = "search-results" +internal const val GENRE_BROWSER_ROW_ID = "genre-browser-results" + +@Composable +internal fun HomeClock( + showGreeting: Boolean, + username: String?, + shortName: String?, + modifier: Modifier = Modifier, +) { + val context = LocalContext.current + val timeFormatter = remember(context) { DateFormat.getTimeFormat(context) } + var currentTime by remember { mutableStateOf(Date()) } + + LaunchedEffect(Unit) { + while (true) { + currentTime = Date() + // Wake on the next minute boundary instead of drifting by a few seconds + // every time the app is left open. + val untilNextMinute = 60_000L - (System.currentTimeMillis() % 60_000L) + delay(untilNextMinute.milliseconds) + } + } + + val period = homeGreetingPeriod( + Calendar.getInstance().apply { time = currentTime }.get(Calendar.HOUR_OF_DAY), + ) + val name = greetingName(shortName, username) + Row( + modifier = modifier, + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically, + ) { + AnimatedVisibility( + visible = showGreeting && name != null, + enter = fadeIn(tween(180)), + exit = fadeOut(tween(120)), + ) { + Row( + modifier = Modifier + .clip(RoundedCornerShape(8.dp)) + .background(MembySurface.copy(alpha = 0.70f)) + .padding(horizontal = 12.dp, vertical = 7.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = when (period) { + HomeGreetingPeriod.MORNING -> MembyIcon.Sunrise.mark + HomeGreetingPeriod.AFTERNOON -> MembyIcon.Sun.mark + HomeGreetingPeriod.EVENING -> MembyIcon.Night.mark + }, + contentDescription = null, + tint = MembyAccent, + modifier = Modifier.size(18.dp), + ) + Spacer(Modifier.width(8.dp)) + Text( + text = "${period.words}, $name", + color = Color.White.copy(alpha = 0.9f), + fontSize = 16.sp, + fontWeight = FontWeight.Medium, + ) + } + } + if (showGreeting && name != null) Spacer(Modifier.width(8.dp)) + Text( + text = timeFormatter.format(currentTime), + color = Color.White.copy(alpha = 0.86f), + fontSize = 18.sp, + fontWeight = FontWeight.Medium, + modifier = Modifier + .clip(RoundedCornerShape(8.dp)) + .background(MembySurface.copy(alpha = 0.70f)) + .padding(horizontal = 12.dp, vertical = 7.dp), + ) + } +} + +@Composable +internal fun HomeArtworkPreloader( + rows: List, + availableWidth: Dp, +) { + val context = LocalContext.current + val lifecycleOwner = LocalLifecycleOwner.current + val density = LocalDensity.current + val repo = ServiceLocator.repository + val discovered = remember(rows) { + // Warm the leading posters across several rows instead of exhausting the + // budget on the first shelf. Vertical navigation is then far less likely to + // compete with image fetch/decode as the next row enters the viewport. + val perRow = rows.take(6).map { row -> + row.items.take(2).map { row.kind to it } + } + buildList { + val depth = perRow.maxOfOrNull { it.size } ?: 0 + for (itemIndex in 0 until depth) { + perRow.forEach { candidates -> + candidates.getOrNull(itemIndex)?.let(::add) + } + } + } + .distinctBy { it.second.id } + .take(12) + } + val signature = remember(discovered) { discovered.joinToString("|") { it.second.id } } + LaunchedEffect(signature, availableWidth, lifecycleOwner) { + lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) { + // Let visible cards win the first network/decode slots. Limiting the warm to + // the leading pair from the next six shelves covers the likely D-pad path + // without decoding a second screenful nobody may visit. + delay(350.milliseconds) + discovered.forEach { (kind, item) -> + val landscape = kind == MediaRowKind.CONTINUE || item.isEpisode + val width = if (landscape) { + (availableWidth / 4.25f).coerceIn(184.dp, 316.dp) + } else { + (availableWidth / 6.8f).coerceIn(116.dp, 184.dp) + } + val widthPx = with(density) { width.roundToPx() }.coerceIn(180, 720) + val heightPx = if (landscape) (widthPx * 9f / 16f).toInt() else (widthPx * 3f / 2f).toInt() + val url = if (landscape) { + repo.backdropUrl(item, widthPx) ?: repo.primaryUrl(item, widthPx) + } else { + repo.primaryUrl(item, widthPx) ?: repo.backdropUrl(item, widthPx) + } ?: return@forEach + context.imageLoader.execute( + ImageRequest.Builder(context) + .data(url) + .size(widthPx, heightPx) + .allowHardware(true) + .crossfade(false) + .build(), + ) + } + } + } +} + +@Composable +internal fun FocusedHomeBackdrop(homeViewModel: HomeViewModel) { + val focusedItem by homeViewModel.focusedItem.collectAsStateWithLifecycle() + BackdropLayer(item = focusedItem, modifier = Modifier.fillMaxSize()) +} + +@Composable +internal fun FocusedHomeMetadata( + homeViewModel: HomeViewModel, + sectionLabel: String, + modifier: Modifier = Modifier, +) { + val focusedItem by homeViewModel.focusedItem.collectAsStateWithLifecycle() + // Only the loading flags matter here, so take the content projection rather than the + // whole state: this panel sits beside the hero and redraws on every focus change as + // it is. + val homeContent by homeViewModel.content.collectAsStateWithLifecycle() + MediaMetadataPanel( + item = focusedItem, + loading = homeContent.loading.isNotEmpty(), + sectionLabel = sectionLabel, + modifier = modifier, + ) +} + +/** + * The detail page a playback was launched from, held for the length of that playback. + * + * The trail comes with it, so pressing Back after the film still walks the "More like this" + * chain the viewer arrived by rather than dropping straight to the launcher; the airing + * notice comes with it because it belonged to the route into the page and that route has + * not changed. + */ +internal data class DetailsReturn( + val item: BaseItem, + val trail: List, + val airingNotice: AiringNotice?, +) + +@Composable +internal fun FocusedDetailsOverlay( + homeViewModel: HomeViewModel, + selected: BaseItem, + restorePosition: Boolean, + onPlay: (BaseItem) -> Unit, + onPlayTrailer: (BaseItem) -> Unit, + onToggleFavorite: (BaseItem, Boolean) -> Unit, + isMyShow: Boolean, + onToggleMyShow: (BaseItem, Boolean) -> Unit, + onTogglePlayed: (BaseItem, Boolean) -> Unit, + onClose: () -> Unit, + onOpenItem: (BaseItem) -> Unit, + /** + * Where a Radarr card goes once Emby has imported the film. The row is cached for the + * day on the gateway, so the card can still arrive without an Emby id long after the + * import; the detail request is what notices, and this is what acts on it. + */ + onOpenEmbyItem: (BaseItem) -> Unit = {}, + airingNotice: AiringNotice? = null, +) { + val focusedItem by homeViewModel.focusedItem.collectAsStateWithLifecycle() + val item = focusedItem?.takeIf { it.id == selected.id } ?: selected + if (item.isRadarrOnly) { + // A film Radarr is tracking that Emby has never imported. It is the one card with a + // page of its own rather than an Emby one — see [RadarrMovieDetailsOverlay] for why + // it is not the movie page with its playable parts taken away. + RadarrMovieDetailsOverlay( + card = item, + onPlayTrailer = onPlayTrailer, + onClose = onClose, + onOpenEmbyItem = onOpenEmbyItem, + ) + } else if (item.isSeries) { + SeriesDetailsOverlay( + item = item, + onPlay = onPlay, + onPlayTrailer = onPlayTrailer, + onToggleFavorite = onToggleFavorite, + isMyShow = isMyShow, + onToggleMyShow = onToggleMyShow, + onSeriesPlayedChanged = { series, played -> + homeViewModel.trackJourney( + category = "library", action = if (played) "mark_played" else "mark_unplayed", + screen = "details", feature = "played_status", itemName = series.name, + itemType = series.type, outcome = "success", + ) + homeViewModel.applySeriesPlayed(series, played) + }, + onSeriesPlayedSettled = homeViewModel::refreshContinueWatching, + onClose = onClose, + onOpenItem = onOpenItem, + restorePosition = restorePosition, + airingNotice = airingNotice, + ) + } else if (item.isEpisode) { + // An episode arrives here from Continue Watching, where it *is* the thing the + // viewer chose. It used to open the movie page, which named the + // episode with no way to tell which one it was or where in the show it sat. + EpisodeDetailsOverlay( + item = item, + onPlay = onPlay, + onToggleFavorite = onToggleFavorite, + onTogglePlayed = onTogglePlayed, + onClose = onClose, + onOpenItem = onOpenItem, + restorePosition = restorePosition, + ) + } else { + MediaDetailsOverlay( + item = item, + onPlay = onPlay, + onPlayTrailer = onPlayTrailer, + onToggleFavorite = onToggleFavorite, + onTogglePlayed = onTogglePlayed, + onClose = onClose, + onOpenItem = onOpenItem, + restorePosition = restorePosition, + ) + } +} + +@Composable +internal fun FocusedQuickActionsOverlay( + homeViewModel: HomeViewModel, + selected: BaseItem, + onOpenDetails: (BaseItem) -> Unit, + onSetFavorite: (BaseItem, Boolean) -> Unit, + onSetPlayed: (BaseItem, Boolean) -> Unit, + onRemoveFromContinueWatching: (() -> Unit)?, + onPlayTrailer: (() -> Unit)?, + rowTitle: String?, + rowPinned: Boolean, + onToggleRowPinned: (() -> Unit)?, + onHideRow: (() -> Unit)?, + onMoveRow: ((Int) -> Unit)?, + onClose: () -> Unit, +) { + val focusedItem by homeViewModel.focusedItem.collectAsStateWithLifecycle() + MediaQuickActionsOverlay( + item = focusedItem?.takeIf { it.id == selected.id } ?: selected, + onOpenDetails = onOpenDetails, + onSetFavorite = onSetFavorite, + onSetPlayed = onSetPlayed, + onRemoveFromContinueWatching = onRemoveFromContinueWatching, + onPlayTrailer = onPlayTrailer, + rowTitle = rowTitle, + rowPinned = rowPinned, + onToggleRowPinned = onToggleRowPinned, + onHideRow = onHideRow, + onMoveRow = onMoveRow, + onClose = onClose, + ) +} + +/** + * Maps the gateway's rows onto the TV's row model. + * + * The user's section preferences still apply to the four fixed rows — turning off + * "Favourites" must keep working — but anything the server invented (recommendations, + * and whatever it grows next) is always shown, since the user never opted out of a row + * that did not exist when they last opened Settings. + */ +internal fun serverHomeRows(state: HomeUiState, settings: Settings): List { + val enabledSections = settings.homeSections.split(',').map(String::trim).toSet() + val stillLoading = state.loading.isNotEmpty() + + val mapped = state.rows + .foldNextUpIntoContinue() + .filter { row -> + when (row.kind) { + "continue", "nextup" -> "continue" in enabledSections + "favorites" -> "favorites" in enabledSections + "latest" -> "latest" in enabledSections + // The hero row is the four featured cards. It is drawn above the shelves + // by HomeMovieHero, so letting it through here would print the same four + // titles a second time as an unnamed row of posters directly beneath it. + SERVER_HERO_ROW_KIND -> false + else -> true + } + } + .map { row -> + HomeBrowseRow( + id = row.id, + title = if (row.kind == "favorites") { + personalisedFavouritesTitle(settings.username) + } else { + row.title + }, + items = if (row.kind == "schedule" || row.kind == "movie-schedule") { + row.items.sortedByScheduleDate() + } else { + row.items + }, + kind = when (row.kind) { + "continue" -> MediaRowKind.CONTINUE + "favorites" -> MediaRowKind.FAVORITES + "shows" -> MediaRowKind.SHOWS + "schedule" -> MediaRowKind.SHOWS + "movie-schedule" -> MediaRowKind.MOVIES + // Recommendation strips, "latest", and any kind a future server + // sends get poster cards, which suit mixed movie/series rows. + else -> MediaRowKind.MOVIES + }, + loading = stillLoading && row.items.isEmpty(), + emptyMessage = when (row.kind) { + "continue" -> "Nothing in progress" + "favorites" -> "Your favourites will appear here" + "latest" -> "No recent movies found" + "schedule" -> "No monitored shows are airing in the next 5 days" + "movie-schedule" -> "No monitored movies have a digital release in the next 5 days" + else -> "Nothing to show here yet" + }, + showSecondaryMetadata = settings.showHomeCardMetadata, + ) + } + return applyHomeRowPreferences(mapped, settings) +} + +/** + * Continue Watching and Next Up are one row. This gateway no longer sends a `nextup` row, + * but two things still do: the home cache written by the previous build, which is what a + * TV draws before its first refresh lands, and a gateway that has not been deployed yet. + * Its episodes belong on the end of Continue Watching rather than in a row of their own. + */ +internal fun List.foldNextUpIntoContinue(): List { + val nextUp = filter { it.kind == "nextup" }.flatMap(HomeRow::items) + if (nextUp.isEmpty()) return filterNot { it.kind == "nextup" } + val seenSeries = firstOrNull { it.kind == "continue" } + ?.items + .orEmpty() + .mapNotNullTo(mutableSetOf()) { it.seriesId?.takeIf(String::isNotBlank) } + return filterNot { it.kind == "nextup" }.map { row -> + if (row.kind != "continue") { + row + } else { + val unseen = nextUp.filterNot { item -> + val series = item.seriesId.orEmpty() + series.isNotBlank() && series in seenSeries + } + row.copy(items = (row.items + unseen).distinctBy(BaseItem::id)) + } + } +} + +/** + * Schedule timestamps are server-authored RFC 3339 values in the configured local + * timezone. Sorting their fixed-width local representation keeps the nearest calendar + * date first and also repairs an out-of-order cached payload from an older gateway. + */ +internal fun List.sortedByScheduleDate(): List = + sortedWith( + compareBy { it.membyAirsAt.isNullOrBlank() } + .thenBy { it.membyAirsAt.orEmpty() }, + ) + +internal fun String.decodeRowIds(): List = + lineSequence().map(String::trim).filter(String::isNotEmpty).distinct().toList() + +internal fun applyHomeRowPreferences( + rows: List, + settings: Settings, +): List { + val hidden = settings.homeHiddenRows.decodeRowIds().toSet() + val pinned = settings.homePinnedRows.decodeRowIds().toSet() + val order = settings.homeRowOrder.decodeRowIds().withIndex().associate { it.value to it.index } + return rows + .filterNot { it.id in hidden } + .withIndex() + .sortedWith( + compareBy>( + { if (it.value.id in pinned) 0 else 1 }, + { order[it.value.id] ?: Int.MAX_VALUE }, + { it.index }, + ), + ) + .map(IndexedValue::value) +} + +internal fun homeRowsFor( + destination: BrowseDestination, + state: HomeUiState, + settings: Settings, +): List { + val continueRow = HomeBrowseRow( + id = "continue", + title = "Continue Watching", + items = state.continueWatching, + kind = MediaRowKind.CONTINUE, + loading = HomeSection.CONTINUE in state.loading, + emptyMessage = "Nothing in progress", + showSecondaryMetadata = settings.showHomeCardMetadata, + ) + val latestMovies = HomeBrowseRow( + id = "latest-movies", + title = "Recently Added Movies", + items = state.latestMovies, + kind = MediaRowKind.MOVIES, + loading = HomeSection.LATEST in state.loading, + emptyMessage = "No recent movies found", + showSecondaryMetadata = settings.showHomeCardMetadata, + ) + val favorites = HomeBrowseRow( + id = "favorites", + title = personalisedFavouritesTitle(settings.username), + items = state.favorites, + kind = MediaRowKind.FAVORITES, + loading = HomeSection.FAVORITES in state.loading, + emptyMessage = "Your favourites will appear here", + showSecondaryMetadata = settings.showHomeCardMetadata, + ) + val destinationRows = when (destination) { + // The gateway composes the home screen — including rows this app has no concept + // of, like "Because you watched …" — so when it sends rows, they win. + BrowseDestination.HOME -> if (state.rows.isNotEmpty()) { + serverHomeRows(state, settings) + // Genre/studio shelves have dedicated Movies and TV destinations. Home + // keeps personalised, current and new-release rows so each screen has a + // genuinely different browsing character. + .filterNot { it.id.startsWith("curated:") } + } else { + settings.homeSections + .split(',') + .map(String::trim) + .distinct() + .flatMap { section -> + when (section) { + "continue" -> listOf(continueRow) + "latest" -> listOf(latestMovies) + "favorites" -> listOf(favorites) + else -> emptyList() + } + } + } + BrowseDestination.MOVIES -> serverHomeRows(state, settings) + // Movie discovery is authored by the server from this profile's genre and + // studio affinity. Broad latest/library dumps belong on Home, not here. + .filter { row -> + row.kind == MediaRowKind.MOVIES && + row.id.startsWith("curated:movies:") + } + .distinctBy(HomeBrowseRow::id) + listOf( + favorites.copy( + id = "favourite-movies", + title = "Favourite Movies", + items = state.favorites.filter(BaseItem::isMovie), + ), + ) + BrowseDestination.SHOWS -> listOf( + // The merged row, narrowed to television: what is part-way through and what + // comes next are the same shelf here too. + continueRow.copy( + id = "continue-shows", + items = state.continueWatching.filter(BaseItem::isEpisode), + ), + favorites.copy( + id = "favourite-shows", + title = "Favourite Shows", + items = state.favorites.filter { it.isSeries || it.isEpisode }, + ), + ) + serverHomeRows(state, settings) + // These shelves are authored and ordered by the per-user recommendation + // engine. Keep their server order: it is the user's affinity ranking. + .filter { row -> + row.kind == MediaRowKind.SHOWS && row.id.startsWith("curated:") + } + .distinctBy(HomeBrowseRow::id) + BrowseDestination.FAVORITES -> listOf(favorites) + BrowseDestination.FOR_YOU -> emptyList() + // Search draws its own pane; the rail destinations that open an overlay have no + // rows of their own either. + BrowseDestination.SEARCH -> emptyList() + BrowseDestination.GENRES -> emptyList() + BrowseDestination.CALENDAR -> emptyList() + BrowseDestination.PROFILES -> emptyList() + BrowseDestination.SETTINGS -> emptyList() + } + val deduplicated = deduplicateBrowseRows(destinationRows) + // The television page carries two fixed shelves it may have nothing for — a household + // part-way through no episodes, or with no series favourited — and a heading with an + // apology under it is a row the D-pad already refuses to enter, so it is a stop on the + // way to the shelves that do have something. A row still *loading* is kept: it has + // something arriving, and withdrawing it would move every shelf below it a moment later. + val populated = if (destination == BrowseDestination.SHOWS) { + deduplicated.filter { it.items.isNotEmpty() || it.loading } + } else { + deduplicated + } + return if (destination == BrowseDestination.HOME && state.rows.isEmpty()) { + applyHomeRowPreferences(populated, settings) + } else { + populated + } +} + +/** + * A card gets one place on a screen. Episodes collapse to their parent series so a show + * in Continue Watching cannot immediately reappear as a genre recommendation, and live + * UserData is a final guard against a stale recommendation cache showing something seen. + */ +internal fun deduplicateBrowseRows(rows: List): List { + val used = mutableSetOf() + return rows.map { row -> + val discovery = row.id.startsWith("curated:") || + row.id.startsWith("similar:") || + row.id.startsWith("for-you:") || + row.id == "recommended" + val items = row.items.filter { item -> + if (discovery && ( + item.userData?.played == true || + (item.userData?.playbackPositionTicks ?: 0L) > 0L + ) + ) { + return@filter false + } + used.add(browseDeduplicationKey(item)) + } + row.copy(items = items) + } +} + +/** Applies the opt-in watched-content policy after every destination has built its rows. */ +internal fun applyWatchedVisibility( + rows: List, + enabled: Boolean, +): List = rows.map { row -> + row.copy( + items = visibleWithWatchedPreference(row.items, enabled), + showWatchedEpisodeCount = enabled, + ) +} + +internal fun visibleWithWatchedPreference( + items: List, + enabled: Boolean, +): List = if (enabled) { + items.filterNot { item -> item.isMovie && item.userData?.played == true } +} else { + items +} + +private fun browseDeduplicationKey(item: BaseItem): String { + item.seriesId?.trim()?.takeIf(String::isNotEmpty)?.let { return "series:$it" } + if (item.isSeries) return "series:${item.id}" + return "item:${item.id}" +} + +internal fun forYouBrowseRows( + state: ForYouUiState, +): List { + if (state.rows.isEmpty()) { + return listOf( + HomeBrowseRow( + id = "for-you:picks", + title = "Top picks for you", + items = emptyList(), + kind = MediaRowKind.MOVIES, + loading = state.loading, + emptyMessage = state.error ?: "Watch and browse a little more to shape your picks", + showSecondaryMetadata = true, + ), + ) + } + return state.rows.map { row -> + HomeBrowseRow( + id = row.id, + title = row.title, + items = row.items, + kind = MediaRowKind.MOVIES, + loading = state.loading && row.items.isEmpty(), + emptyMessage = state.error ?: "No picks fit this time window yet", + // Reasons are the primary secondary metadata in this destination. + showSecondaryMetadata = true, + ) + } +} + +@Composable +internal fun ForYouTimeBudget( + selectedMinutes: Int, + loading: Boolean, + error: String?, + onSelected: (Int) -> Unit, +) { + Column( + modifier = Modifier.fillMaxWidth().padding(horizontal = 36.dp, vertical = 4.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + "How much time do you have?", + color = MembyMutedText, + fontSize = 15.sp, + fontWeight = FontWeight.SemiBold, + ) + Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { + listOf(30 to "30 min", 60 to "1 hour", 120 to "2 hours", 0 to "Any length") + .forEach { (minutes, label) -> + MembyChoiceChip( + label = label, + selected = minutes == selectedMinutes, + onClick = { if (!loading && minutes != selectedMinutes) onSelected(minutes) }, + ) + } + } + error?.let { + Text(it, color = Color(0xFFFFB454), fontSize = 13.sp) + } + } +} + +@Composable +internal fun ForYouNudgeBanner( + visible: Boolean, + username: String?, + shortName: String?, + modifier: Modifier = Modifier, +) { + // The same name the hero greeting uses: two places addressing one person by two + // different names is worse than neither of them being personalised. + val name = greetingName(shortName, username) + AnimatedVisibility( + visible = visible, + enter = fadeIn(tween(220)), + exit = fadeOut(tween(260)), + modifier = modifier.zIndex(7f), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .height(92.dp) + .background( + Brush.horizontalGradient( + 0f to MembySplashTint, + 0.62f to MembySplashTint.copy(alpha = 0.95f), + 1f to MembySurfaceRaised.copy(alpha = 0.88f), + ), + ) + // Keep copy inside television overscan and clear of the collapsed rail. + .padding(start = 72.dp, end = 48.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + Modifier + .size(10.dp) + .clip(CircleShape) + .background(MembyAccent), + ) + Spacer(Modifier.width(16.dp)) + Column(verticalArrangement = Arrangement.spacedBy(3.dp)) { + Text( + "FOR YOU", + color = MembyAccentBright, + fontSize = 13.sp, + fontWeight = FontWeight.Bold, + letterSpacing = 1.4.sp, + ) + Text( + text = if (name == null) { + "Your personalised picks are ready" + } else { + "$name, your personalised picks are ready" + }, + color = MembyOnSurface, + fontSize = 21.sp, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } +} + +/** + * Turns Emby account-style usernames into a friendlier home-screen name. + * + * Household accounts commonly use a trailing capital as a disambiguating surname + * initial (PeterC, PaulR). Only that clear camel-case shape is trimmed, so ordinary + * usernames such as PJ, CHRIS, alice, or MattCohen are left alone. + */ +internal fun friendlyProfileName(username: String?): String? { + val name = username?.trim().orEmpty() + if (name.isEmpty()) return null + return if ( + name.length >= 3 && + name.last().isUpperCase() && + name[name.lastIndex - 1].isLowerCase() + ) { + name.dropLast(1) + } else { + name + } +} + +internal fun personalisedFavouritesTitle(username: String?): String { + val name = friendlyProfileName(username) ?: return "Favourites" + val possessive = if (name.endsWith("s", ignoreCase = true)) "$name'" else "$name's" + return "$possessive Favourites" +} + +/** Requests the first focus target that is both attached and willing to accept focus. */ +internal fun requestFirstAvailableFocus(vararg requesters: FocusRequester): Boolean { + requesters.forEach { requester -> + if (runCatching { requester.requestFocus() }.isSuccess) return true + } + return false +} + + + + + diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/HomeScreen.kt b/app/src/main/java/com/ponzischeme89/memby/ui/HomeScreen.kt new file mode 100644 index 0000000..807b6ba --- /dev/null +++ b/app/src/main/java/com/ponzischeme89/memby/ui/HomeScreen.kt @@ -0,0 +1,2730 @@ +package com.ponzischeme89.memby.ui + +import com.ponzischeme89.memby.ui.theme.mark +import android.content.Intent +import android.os.SystemClock +import android.widget.Toast +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.compose.BackHandler +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.items +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.DisposableEffect +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.rememberUpdatedState +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.saveable.listSaver +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.setValue +import androidx.compose.runtime.key +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.tween +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.zIndex +import androidx.lifecycle.compose.LifecycleResumeEffect +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.repeatOnLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import com.ponzischeme89.memby.ServiceLocator +import com.ponzischeme89.memby.data.Settings +import com.ponzischeme89.memby.data.friendlyEmbyError +import com.ponzischeme89.memby.data.analytics.PlaybackJourney +import com.ponzischeme89.memby.data.analytics.playbackEntryPointFor +import com.ponzischeme89.memby.data.model.BaseItem +import com.ponzischeme89.memby.data.model.MembyViewer +import com.ponzischeme89.memby.data.model.HomeRow +import com.ponzischeme89.memby.data.model.MyShow +import com.ponzischeme89.memby.data.model.NotificationsResponse +import com.ponzischeme89.memby.data.model.UserNotification +import com.ponzischeme89.memby.data.model.TrailerPlaybackRequest +import com.ponzischeme89.memby.data.ServerConfig +import com.ponzischeme89.memby.data.remoteconfig.MembyRemoteConfig +import com.ponzischeme89.memby.ui.alerts.MyAlertsPage +import com.ponzischeme89.memby.ui.detail.AiringNotice +import com.ponzischeme89.memby.ui.detail.airingNoticeFor +import com.ponzischeme89.memby.ui.detail.scheduleMovieStub +import com.ponzischeme89.memby.ui.detail.scheduleSeriesStub +import com.ponzischeme89.memby.ui.calendar.CalendarScreen +import com.ponzischeme89.memby.ui.requests.RequestsScreen +import com.ponzischeme89.memby.ui.requests.RequestsViewModel +import com.ponzischeme89.memby.ui.requests.RequestsViewModelFactory +import com.ponzischeme89.memby.ui.genre.GenreBrowseScreen +import com.ponzischeme89.memby.ui.genre.ALL_MEDIA_CATEGORY_ID +import com.ponzischeme89.memby.ui.genre.ALL_MEDIA_ITEM_TYPE +import com.ponzischeme89.memby.ui.player.PlayerActivity +import com.ponzischeme89.memby.performance.StartupTrace +import com.ponzischeme89.memby.ui.search.SearchScreen +import com.ponzischeme89.memby.ui.settings.SettingsSheet +import com.ponzischeme89.memby.ui.theme.MembyMutedText +import com.ponzischeme89.memby.ui.viewers.MAX_SHADOW_VIEWERS +import com.ponzischeme89.memby.ui.viewers.ViewerManageScreen +import com.ponzischeme89.memby.ui.viewers.ViewerNameEntry +import com.ponzischeme89.memby.ui.viewers.ViewerNameTarget +import com.ponzischeme89.memby.ui.viewers.ViewerPicker +import com.ponzischeme89.memby.ui.viewers.shouldOfferViewerPicker +import com.ponzischeme89.memby.ui.viewers.viewerNameFor +import com.ponzischeme89.memby.ui.profiles.ProfileChooser +import com.ponzischeme89.memby.ui.theme.MembySurface +import androidx.tv.material3.Text +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch +import kotlinx.coroutines.Job +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.withTimeout +import java.util.Calendar +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds + +private val PLAYBACK_SOURCE_RESOLUTION_TIMEOUT = 15.seconds + +/** The maximum time one row-to-row focus move may hold the D-pad. */ +private val ROW_FOCUS_MOVE_TIMEOUT = 1_200.milliseconds + +internal const val MEMBY_UPDATE_NOTIFICATION_ID = -1L + +internal fun membyUpdateNotification( + version: String?, + occurredAt: String?, + read: Boolean, +): UserNotification? { + val installed = version?.trim()?.takeIf(String::isNotEmpty) ?: return null + return UserNotification( + id = MEMBY_UPDATE_NOTIFICATION_ID, + kind = "app-update", + title = "Memby updated", + message = "This TV is now running Memby $installed.", + eventAt = occurredAt, + createdAt = occurredAt.orEmpty(), + readAt = if (read) occurredAt ?: "read" else null, + ) +} + +/** + * The followed show a press stands for, as much of it as the card already knows. + * + * The button flips against this rather than against the gateway's answer, which is the + * whole list decorated with Sonarr's lifecycle for every entry on it. Everything Sonarr + * would fill in is left at its default — "Not found", "Unknown" — because that is honestly + * what this television knows for the moment, and the real row replaces it as soon as the + * save returns. The same promise `scheduleSeriesStub` makes about a detail page. + */ +private fun myShowStub(item: BaseItem): MyShow = + MyShow( + itemId = item.id, + title = item.name, + year = item.productionYear, + imageTag = item.imageTags["Primary"].orEmpty(), + ) + +/** Leaves enough of a TV viewport for a complete shelf, including card title metadata. */ +internal fun homeHeaderHeight(viewportHeight: Dp, showHero: Boolean): Dp = + if (showHero) { + (viewportHeight * 0.46f).coerceIn(232.dp, 300.dp) + } else { + (viewportHeight * 0.42f).coerceIn(220.dp, 300.dp) + } + +private val LazyListStateMapSaver: Saver, Any> = listSaver( + save = { states -> + states.flatMap { (key, state) -> + listOf(key, state.firstVisibleItemIndex, state.firstVisibleItemScrollOffset) + } + }, + restore = { values -> + mutableMapOf().apply { + values.chunked(3).forEach { saved -> + put( + saved[0] as String, + LazyListState(saved[1] as Int, saved[2] as Int), + ) + } + } + }, +) + +@Composable +internal fun HomeScreen( + settings: Settings, + remoteConfig: MembyRemoteConfig, +) { + val repo = ServiceLocator.repository + val context = LocalContext.current + val scope = rememberCoroutineScope() + val factory = remember(repo) { HomeViewModelFactory(repo) } + // HomeViewModel owns user-scoped rows, recommendation state, metadata caches and + // in-flight requests. The Activity's default ViewModelStore outlives a Compose key, + // so using it here leaks the previous profile's state across a profile switch. + val profileViewModelOwner = remember( + settings.activeProfileId, + settings.serverUrl, + settings.userId, + ) { ProfileViewModelStoreOwner() } + DisposableEffect(profileViewModelOwner) { + onDispose { profileViewModelOwner.clear() } + } + val homeViewModel: HomeViewModel = viewModel( + viewModelStoreOwner = profileViewModelOwner, + factory = factory, + ) + // Three narrow projections rather than the whole HomeUiState: this function is large + // enough that subscribing to every field made an arriving update verdict or a banner + // change recompose the launcher and rebuild every row. See HomeViewModel.content. + val homeContent by homeViewModel.content.collectAsStateWithLifecycle() + val homeStatus by homeViewModel.status.collectAsStateWithLifecycle() + val forYouState by homeViewModel.forYou.collectAsStateWithLifecycle() + val favoriteChanges by homeViewModel.favoriteChanges.collectAsStateWithLifecycle() + val playedChanges by homeViewModel.playedChanges.collectAsStateWithLifecycle() + val liveMaintenance by ServiceLocator.maintenance.notice.collectAsStateWithLifecycle() + val compatibilityNotice by ServiceLocator.maintenance.compatibility.collectAsStateWithLifecycle() + val genreBrowserEnabled by ServiceLocator.maintenance.genreBrowserEnabled.collectAsStateWithLifecycle() + val tvCalendarEnabled by ServiceLocator.maintenance.tvCalendarEnabled.collectAsStateWithLifecycle() + val continueWatchingEnabled by + ServiceLocator.maintenance.continueWatchingEnabled.collectAsStateWithLifecycle() + // Per viewer rather than per household, so it cannot ride the feature map: it decides + // whether the Requests entry is in the switcher at all. + val requestsAllowed by ServiceLocator.maintenance.requestsAllowed.collectAsStateWithLifecycle() + // Whether the household runs viewers at all. The list alone cannot say — a gateway with + // the feature off still answers with the account's own viewer — and only this separates + // "nobody added yet", which is offered an Add, from "switched off", which the gateway + // refuses. See MaintenanceMonitor.viewersEnabled. + val viewersEnabled by ServiceLocator.maintenance.viewersEnabled.collectAsStateWithLifecycle() + // Only whether there is one, not its countdown — that is collected inside the banner + // so a ticking second never reaches the launcher. This is read here purely to decide + // which of the two top bars gets the strip. + val embyOutage by ServiceLocator.maintenance.embyOutage.collectAsStateWithLifecycle() + + var showSettings by remember { mutableStateOf(false) } + var showProfiles by remember { mutableStateOf(false) } + // Reached only from the manage-users page, which is itself two presses in behind the + // user picker — so the "+" is where somebody looking to add a viewer is already + // standing, and it is nowhere near the launcher. + var addingProfile by remember { mutableStateOf(false) } + var userSwitcherVisible by remember { mutableStateOf(false) } + // The shortcut menu a hold on the rail's user item raises. Kept beside the picker rather + // than inside it: the two are alternative answers to the same control, and only one of + // them may ever be on screen. + var userQuickActionsVisible by remember { mutableStateOf(false) } + var switchingProfileId by remember { mutableStateOf(null) } + var removingProfileId by remember { mutableStateOf(null) } + var selectedDestination by rememberSaveable { mutableStateOf(BrowseDestination.HOME) } + var navigationExpanded by rememberSaveable { mutableStateOf(false) } + var restoreRailAfterSettings by remember { mutableStateOf(false) } + var detailsItem by remember { mutableStateOf(null) } + // Detail pages can now open one another through the related rail. Back walks the trail + // home one page at a time; without this it would drop three levels to the launcher. + var detailsTrail by remember { mutableStateOf>(emptyList()) } + var restoreDetailPosition by remember { mutableStateOf(false) } + // The page playback was started from, so returning from the player comes back to it + // rather than to the launcher. + // + // Deliberately a *reopen* rather than leaving the page composed under the player. The + // page is what tells the viewer where they were, and it has to tell them the truth: the + // episode they just finished is now watched, the film is now part-way through, and the + // next episode is a different one. Reopening runs the same restore path a Back press + // through the trail already uses (`DetailPosition` plus `RestoreDetailFocus`), so the + // tab, the season, the grid offset and the band that held focus all come back with it — + // and focus is re-requested by that path rather than depending on Compose having held + // it across another activity's window. + var detailsResumeTarget by remember { mutableStateOf(null) } + // Belongs to the *route*, not to the show: set only when a page is opened from the + // "Shows airing" row, and dropped the moment the viewer moves anywhere else, so the + // same series reached from Favourites or a search never claims a schedule. + var detailsAiringNotice by remember { mutableStateOf(null) } + var quickMenuItem by remember { mutableStateOf(null) } + // Whether the long-press menu may offer a trailer for the Radarr card it is open on. + // Asked once, when the menu opens, and false until answered: a row entry that appears + // and then fails is worse than one that arrives a moment late. + var quickMenuTrailerAvailable by remember { mutableStateOf(false) } + var quickMenuRowId by remember { mutableStateOf(null) } + var focusedHomeRowId by remember { mutableStateOf(null) } + var sectionHeroRows by remember(settings.userId) { + mutableStateOf>>(emptyMap()) + } + var myShows by remember(settings.userId) { mutableStateOf>(emptyList()) } + var myShowsLoading by remember(settings.userId) { mutableStateOf(true) } + var myShowsError by remember(settings.userId) { mutableStateOf(null) } + var myShowsMutationBusy by remember(settings.userId) { mutableStateOf(false) } + var selectedMyShow by remember { mutableStateOf(null) } + var myShowReturnItemId by remember { mutableStateOf(null) } + var removingMyShow by remember { mutableStateOf(false) } + var notificationState by remember(settings.userId) { mutableStateOf(NotificationsResponse()) } + var notificationsLoading by remember(settings.userId) { mutableStateOf(true) } + var notificationsError by remember(settings.userId) { mutableStateOf(null) } + var notificationsMutationBusy by remember(settings.userId) { mutableStateOf(false) } + val displayedNotifications = listOfNotNull( + membyUpdateNotification( + settings.updateAlertVersion, + settings.updateAlertAt, + settings.updateAlertRead, + ), + ) + notificationState.notifications + var showNotifications by remember { mutableStateOf(false) } + var showRequests by remember { mutableStateOf(false) } + // The people under this account. Fetched once the launcher is up rather than on the + // critical path: nothing on the signed-in path may block on a request, and until the + // answer lands this television watches as whoever it watched as last — which is the + // right answer far more often than not. + var viewers by remember { mutableStateOf>(emptyList()) } + var showViewerPicker by remember { mutableStateOf(false) } + // Managing viewers is a stack over the picker rather than a replacement for it — the + // arrangement the "add another user" sign-in already takes over the manage-users page: + // cancelling a name comes straight back to the list the button was pressed from, with + // nothing to restore because nothing was ever unmounted. + var showViewerManage by remember { mutableStateOf(false) } + var viewerNameTarget by remember { mutableStateOf(null) } + var viewerName by remember { mutableStateOf("") } + var viewerNameFailure by remember { mutableStateOf(null) } + var viewerSaving by remember { mutableStateOf(false) } + var viewerBusyId by remember { mutableStateOf(null) } + LaunchedEffect(settings.userId, settings.serverUrl) { + viewers = if (settings.isSignedIn) repo.viewers() else emptyList() + } + // One place the list is re-read, so every mutation ends the same way and none of them + // has to work out what the answer should now be. The gateway is the thing that knows. + val refreshViewers: suspend () -> Unit = { + viewers = runCatching { repo.viewers() }.getOrDefault(viewers) + } + val openViewerName: (ViewerNameTarget) -> Unit = { target -> + viewerName = viewerNameFor(target) + viewerNameFailure = null + viewerNameTarget = target + } + // Two different things: [launchingItem] is the gate that stops a second Play press + // stacking a second player, and stays shut until one comes back. [resolvingItem] is + // the loading screen, and belongs only to a launch that is waiting on the server. + var launchingItem by remember { mutableStateOf(null) } + var resolvingItem by remember { mutableStateOf(null) } + var returnRowId by rememberSaveable { mutableStateOf(null) } + var returnItemId by rememberSaveable { mutableStateOf(null) } + // The shelf a Play press can be traced back to, as an entry point rather than as a row + // id. Kept beside [returnRowId] and written wherever it is, because the row's *kind* is + // the reliable half — a row id is the gateway's, differs on the direct path, and a + // recommendation strip invents a new one daily — and because by the time `playItem` runs + // the row list is out of scope. Saved with the rest so a recreate mid-browse does not + // file the next resume under nothing. + var returnRowKind by rememberSaveable { mutableStateOf(null) } + // Destination and row list states live above the conditional content branches. + // Opening Search/Settings/details therefore never creates a new list at position 0. + val verticalStates = rememberSaveable(saver = LazyListStateMapSaver) { + mutableMapOf() + } + val horizontalStates = rememberSaveable(saver = LazyListStateMapSaver) { + mutableMapOf() + } + val rememberedRowFocus = remember { + mutableMapOf>() + } + val destinationFocus = remember { + mutableMapOf>() + } + var showForYouNudge by rememberSaveable(settings.activeProfileId) { + mutableStateOf(!settings.hasOpenedForYou) + } + // Each destination owns a stable requester. Moving one requester between rail items + // can leave a key event pointing at the previously selected item for a frame. + val navigationFocusRequesters = remember { + BrowseDestination.entries.associateWith { FocusRequester() } + } + var railFocusDestination by rememberSaveable { mutableStateOf(selectedDestination) } + val navigationFocusRequester = navigationFocusRequesters.getValue(railFocusDestination) + // The Requests page covers the launcher, so it mounts a rail of its own. Kept apart + // from the launcher's because both are composed at once while that page is open, and a + // requester attached to two live nodes focuses whichever Compose reaches first. + val requestsRailFocusRequester = remember { FocusRequester() } + var requestsRailExpanded by remember { mutableStateOf(false) } + val contentFocusRequester = remember { FocusRequester() } + val cardReturnFocusRequester = remember { FocusRequester() } + val myShowReturnFocusRequester = remember { FocusRequester() } + // Down out of the hero, stated rather than left to Compose's spatial search. The + // featured card is wide, so its centre sits nearer the second card of the shelf below + // than the first, and the search obligingly skipped past the thing somebody had just + // been reading about in Continue Watching. + val heroRowEntryFocusRequester = remember { FocusRequester() } + // My Shows sits above the shelves on the television page, so it is the first stop + // below the hero and the thing Up out of the topmost shelf must return to. It is its + // own requester rather than the rail's entry one for the same reason the shelves' hero + // entry is: the entry requester belongs to whichever band the rail hands focus to. + val myShowsEntryFocusRequester = remember { FocusRequester() } + // Feature flags can remove the node that currently owns focus. Move the route first, + // wait for its replacement to compose, then establish an explicit remote target. + // A viewer standing on the Requests page when an operator withdraws access is moved + // off it, the same rule the calendar destination follows: a page nothing behind it will + // answer must not be somewhere a remote is left sitting. + LaunchedEffect(requestsAllowed) { + if (!requestsAllowed) showRequests = false + } + LaunchedEffect(continueWatchingEnabled) { + homeViewModel.setContinueWatchingEnabled(continueWatchingEnabled) + } + LaunchedEffect(tvCalendarEnabled) { + if (!tvCalendarEnabled && selectedDestination == BrowseDestination.CALENDAR) { + selectedDestination = BrowseDestination.HOME + railFocusDestination = BrowseDestination.HOME + delay(16.milliseconds) + requestFirstAvailableFocus( + contentFocusRequester, + navigationFocusRequesters.getValue(BrowseDestination.HOME), + ) + } + } + LaunchedEffect(genreBrowserEnabled) { + if (genreBrowserEnabled) return@LaunchedEffect + // A set standing on the Genres destination when the operator switches it off is + // moved to Home, the stance the calendar takes: the rail entry goes with the + // feature, so leaving it there would strand the viewer on a page nothing can + // navigate back to. + if (selectedDestination == BrowseDestination.GENRES) { + selectedDestination = BrowseDestination.HOME + railFocusDestination = BrowseDestination.HOME + delay(16.milliseconds) + requestFirstAvailableFocus(contentFocusRequester, navigationFocusRequester) + } + } + var initialFocusRequested by remember { mutableStateOf(false) } + // Incremented when a rail selection needs to restore a card in the lazy row list. + // The list owns the scroll state, so it also owns the actual restoration below. + var rowListFocusRestoreRequest by remember { mutableStateOf(0) } + val playbackLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.StartActivityForResult(), + ) { result -> + if (PlayerActivity.trailerUnavailable(result.resultCode, result.data)) { + Toast.makeText(context, "No playable trailer is available", Toast.LENGTH_SHORT).show() + } + homeViewModel.trackJourney( + category = "playback", action = "stop", screen = "player", + feature = "playback", target = selectedDestination.name.lowercase(), + ) + homeViewModel.resumeAnalyticsAfterPlayback() + // PlayerActivity has finished and this activity owns the window again. Compose + // needs one frame to reattach the saved card's focus node before it can receive + // focus, especially when playback progress refreshed the row behind the player. + // Clearing the launch gate here rather than when the intent was fired covers both + // routes into the player, including the one that hands over without waiting. + launchingItem = null + scope.launch { + myShowsLoading = true + notificationsLoading = true + coroutineScope { + launch { + runCatching { repo.getMyShows() } + .onSuccess { myShows = it; myShowsError = null } + .onFailure { + if (it is CancellationException) throw it + myShowsError = friendlyEmbyError(it) + } + myShowsLoading = false + } + launch { + runCatching { repo.getNotifications() } + .onSuccess { notificationState = it; notificationsError = null } + .onFailure { + if (it is CancellationException) throw it + notificationsError = friendlyEmbyError(it) + } + notificationsLoading = false + } + } + delay(32.milliseconds) + // Trailer playback leaves its detail page composed. Let Compose restore the + // exact hero action instead of moving focus to the home card behind it. + if (detailsItem != null) return@launch + if (returnRowId != null && returnItemId != null) { + requestFirstAvailableFocus( + cardReturnFocusRequester, + contentFocusRequester, + navigationFocusRequester, + ) + } else { + requestFirstAvailableFocus(contentFocusRequester, navigationFocusRequester) + } + } + } + + LaunchedEffect(settings.userId) { + myShowsLoading = true + notificationsLoading = true + coroutineScope { + launch { + runCatching { repo.getMyShows() } + .onSuccess { myShows = it; myShowsError = null } + .onFailure { + if (it is CancellationException) throw it + myShowsError = friendlyEmbyError(it) + } + myShowsLoading = false + } + launch { + runCatching { repo.getNotifications() } + .onSuccess { notificationState = it; notificationsError = null } + .onFailure { + if (it is CancellationException) throw it + notificationsError = friendlyEmbyError(it) + } + notificationsLoading = false + } + } + } + + LaunchedEffect(liveMaintenance) { + if (liveMaintenance != null) { + // Maintenance is an app-wide interruption, not another overlay in the stack. + showSettings = false + showProfiles = false + userSwitcherVisible = false + userQuickActionsVisible = false + showNotifications = false + showRequests = false + detailsItem = null + detailsAiringNotice = null + quickMenuItem = null + quickMenuRowId = null + } else if (homeStatus.maintenanceMessage != null) { + // A successful live status is authoritative; refresh content immediately + // instead of waiting for the maintenance screen's slower retry timer. + homeViewModel.refreshAll() + } + } + // The job resolving a stream, so the overlay it raises can be called off. Without it, + // a Play press against a server that has stopped answering holds a full-screen loading + // overlay for the length of the HTTP timeout with no way to leave it. + var resolveJob by remember { mutableStateOf(null) } + val abandonLaunch: () -> Unit = { + resolveJob?.cancel() + resolveJob = null + resolvingItem = null + launchingItem = null + homeViewModel.resumeAnalyticsAfterPlayback() + } + val playItem: (BaseItem) -> Unit = playItem@{ item -> + if (launchingItem != null || !item.membyPlayable) return@playItem + // The shelf this press traces back to, resolved once and then carried all the way + // into the player, so the step the launcher records and the step the player records + // agree about where the viewer came from. `source` used to be the raw row id, which + // the console could not group on: Continue Watching arrived as `continue` from the + // gateway, as `nextup` from a cached row, and as a recommendation id nobody had seen + // before every other day. + val entryPoint = playbackEntryPointFor(returnRowId, returnRowKind) + PlaybackJourney.requested( + sink = homeViewModel.journeySink, + entryPoint = entryPoint, + screen = selectedDestination.name.lowercase(), + itemId = item.id, + itemName = item.name, + itemType = item.type, + ) + // Nothing here uploads: the buffer is drained once the player hands the window back. + homeViewModel.pauseAnalyticsForPlayback() + // Every way a launch can die before the player exists. The player records its own + // failures once it has one; these are the ones it would never hear about, and + // without them a journey ends on a request that appears simply to have gone nowhere. + val playbackScreen = selectedDestination.name.lowercase() + val recordPlaybackFailed = { + PlaybackJourney.failed( + sink = homeViewModel.journeySink, + entryPoint = entryPoint, + screen = playbackScreen, + itemId = item.id, + itemName = item.name, + itemType = item.type, + ) + } + launchingItem = item + val playbackRequestedAtMs = SystemClock.elapsedRealtime() + // Resuming: open the player now and let it resolve the stream while it starts. + // Waiting here would spend the negotiation on a still launcher and only then begin + // the activity, the layout and the decoder, none of which needed the answer. A cold + // start still resolves first — the pre-roll it opens with needs a stream to run + // behind it, and whether there is one to show is part of the same answer. + // Everything up to the hand-over runs inside a catch that reopens the gate. The + // gate is otherwise cleared only by the player coming back, so anything that + // throws before one is started — a malformed cached item, an activity result + // registry that has already been torn down — would leave Play dead for the rest + // of the session with nothing on screen to say why. + val prepared = runCatching { + val request = repo.playbackRequest(item) + request to repo.readyPlayableForLaunch(request) + }.getOrElse { + recordPlaybackFailed() + Toast.makeText(context, "Couldn’t start playback", Toast.LENGTH_SHORT).show() + launchingItem = null + homeViewModel.resumeAnalyticsAfterPlayback() + return@playItem + } + val request = prepared.first + val ready = prepared.second + if (ready == null && request.resumePositionMs > 0L) { + val launched = runCatching { + playbackLauncher.launch( + PlayerActivity.intent( + context = context, + request = request, + posterUrl = repo.primaryUrl(item, maxWidth = 500), + backdropUrl = repo.backdropUrl(item, maxWidth = 1920) + ?: repo.primaryUrl(item, maxWidth = 1920), + requestStartedAtMs = playbackRequestedAtMs, + journeySource = entryPoint.id, + ), + ) + } + if (launched.isFailure) { + recordPlaybackFailed() + Toast.makeText(context, "Couldn’t start playback", Toast.LENGTH_SHORT).show() + launchingItem = null + homeViewModel.resumeAnalyticsAfterPlayback() + } + return@playItem + } + // Only the route that waits on the server shows the launcher's own loading screen. + // The route that hands over immediately would only be showing it behind the player. + resolvingItem = item + resolveJob = scope.launch { + try { + runCatching { + val playable = ready ?: run { + var timeout: TimeoutCancellationException? = null + repeat(2) { + try { + return@run withTimeout(PLAYBACK_SOURCE_RESOLUTION_TIMEOUT) { + repo.resolvePlayableForLaunch(item) + } + } catch (error: TimeoutCancellationException) { + timeout = error + } + } + throw requireNotNull(timeout) + } + // A resolution that came back without a stream is a failure with a + // success's shape. Handed on, PlayerActivity finds no URL and no + // request to resolve one from, closes itself in onCreate, and the + // viewer sees the screen flicker and nothing else — the one playback + // failure that arrives with no explanation at all. + check(playable.url.isNotBlank()) { "resolved playable has no stream" } + playable + } + .onSuccess { playable -> + val launched = runCatching { + playbackLauncher.launch( + PlayerActivity.intent( + context = context, + itemId = playable.itemId, + url = playable.url, + title = playable.title, + resumePositionMs = playable.resumePositionMs, + logoUrl = playable.logoUrl, + backdropUrl = repo.backdropUrl(item, maxWidth = 1920) + ?: repo.primaryUrl(item, maxWidth = 1920), + seriesName = playable.seriesName, + overview = playable.overview ?: item.overview, + episodeCode = playable.episodeCode, + runtimeMs = playable.runtimeMs, + prerollEnabled = playable.prerollEnabled, + prerollDurationMs = playable.prerollDurationMs, + posterUrl = repo.primaryUrl(item, maxWidth = 500), + subtitles = playable.subtitles, + subtitlesEnabled = playable.subtitlesEnabled, + selectedSubtitleId = playable.selectedSubtitleId, + subtitleDownloadAvailable = playable.subtitleDownloadAvailable, + trickplayAvailable = playable.trickplayAvailable, + skipIntroAvailable = playable.skipIntroAvailable, + endCreditsAvailable = playable.endCreditsAvailable, + mediaSourceId = playable.mediaSourceId, + playSessionId = playable.playSessionId, + playMethod = playable.playMethod, + requestStartedAtMs = playbackRequestedAtMs, + journeySource = entryPoint.id, + ), + ) + } + if (launched.isFailure) { + recordPlaybackFailed() + Toast.makeText(context, "Couldn’t start playback", Toast.LENGTH_SHORT).show() + launchingItem = null + homeViewModel.resumeAnalyticsAfterPlayback() + } + } + .onFailure { error -> + // A cancellation is the viewer having pressed Back out of the + // wait, which has already reopened the gate and said so on screen. + if (error is CancellationException) throw error + recordPlaybackFailed() + Toast.makeText(context, "Couldn’t start playback", Toast.LENGTH_SHORT).show() + // Nothing was launched, so nothing will come back to reopen the gate. + launchingItem = null + homeViewModel.resumeAnalyticsAfterPlayback() + } + } finally { + resolvingItem = null + resolveJob = null + } + } + } + val playTrailer: (BaseItem) -> Unit = playTrailer@{ item -> + if (launchingItem != null) return@playTrailer + launchingItem = item + homeViewModel.trackJourney( + category = "playback", action = "trailer", screen = "details", + feature = "trailer", source = "details", target = "player", + itemName = item.name, itemType = item.type, + ) + homeViewModel.pauseAnalyticsForPlayback() + val launched = runCatching { + playbackLauncher.launch( + PlayerActivity.trailerIntent( + context, + TrailerPlaybackRequest( + subjectId = item.id, + title = item.name, + posterUrl = repo.primaryUrl(item, maxWidth = 500), + logoUrl = repo.logoUrl(item), + ), + ), + ) + } + if (launched.isFailure) { + launchingItem = null + homeViewModel.resumeAnalyticsAfterPlayback() + Toast.makeText(context, "Couldn’t open the trailer", Toast.LENGTH_SHORT).show() + } + } + + val rows = remember( + homeContent, + forYouState, + selectedDestination, + settings.homeSections, + settings.showHomeCardMetadata, + settings.hideWatchedMovies, + ) { + val destinationRows = if (selectedDestination == BrowseDestination.FOR_YOU) { + forYouBrowseRows(forYouState) + } else { + homeRowsFor(selectedDestination, homeContent, settings) + } + applyWatchedVisibility(destinationRows, settings.hideWatchedMovies) + } + // The three launch milestones, recorded from effects rather than from the composable + // body so that measuring the launcher can never be a reason the launcher recomposes. + // Each is keyed on the condition that decides it, so it runs when that condition + // changes rather than on every pass; StartupTrace ignores a repeat in any case. + LaunchedEffect(Unit) { StartupTrace.mark(StartupTrace.HOME_VISIBLE) } + val hasPopulatedRow = rows.any { it.items.isNotEmpty() } + LaunchedEffect(hasPopulatedRow) { + if (hasPopulatedRow) StartupTrace.mark(StartupTrace.FIRST_ROW_VISIBLE) + } + // Interactive means nothing is still arriving. A cached launcher reaches this almost at + // once, which is the whole promise HomeCache makes; a cold one reaches it when the last + // section lands. + val homeSettled = hasPopulatedRow && homeContent.loading.isEmpty() + LaunchedEffect(homeSettled) { + if (homeSettled) StartupTrace.mark(StartupTrace.HOME_INTERACTIVE) + } + // Keyed on the day as well as the rows, so the feature changes when the date does and + // not merely when the launcher happens to be rebuilt. + val heroDay = rememberHomeHeroDay() + // The server rows are passed in *unfiltered*, beside the browse rows the launcher + // draws: serverHomeRows drops the hero row, because it is consumed here rather than + // rendered as a shelf, so this is the only thing that can still see it. + // Warm both section heroes while Home is being read. Navigation then normally draws + // from memory, while the gateway's user/placement cache keeps this cheap across TVs. + // + // The same effect is what makes an operator's hero change arrive on a set that is + // already switched on: the gateway announces the configuration behind the heroes as a + // revision on the status poll, so this waits on that flow rather than on a timer, and + // refetches only when it moves. Collected inside the effect rather than read as state + // in the body, because the launcher must not recompose merely to be told a number. + LaunchedEffect(settings.userId) { + var appliedHeroRevision: String? = null + ServiceLocator.maintenance.heroRevision.collectLatest { revision -> + val moved = appliedHeroRevision != null && + revision.isNotBlank() && revision != appliedHeroRevision + if (revision.isNotBlank()) appliedHeroRevision = revision + coroutineScope { + // Home's hero arrives with the home rows, so it is asked for separately + // only once the configuration behind it has actually moved. Fetching it at + // launch would replace a hero already on screen with a second composition + // of the same thing, which is a flash for nothing. + listOfNotNull( + BrowseDestination.MOVIES to "movies", + BrowseDestination.SHOWS to "tv_shows", + (BrowseDestination.HOME to "home").takeIf { moved }, + ).forEach { (destination, placement) -> + if (!moved && sectionHeroRows.containsKey(destination)) return@forEach + launch { + runCatching { repo.getActiveHero(placement) }.onSuccess { resolved -> + // Replaced one placement at a time, never cleared first: the + // cards that did not change keep the artwork already decoded + // for them and the row never blanks between the two states. + sectionHeroRows = sectionHeroRows + (destination to resolved) + } + } + } + } + } + } + LaunchedEffect(selectedDestination, settings.userId) { + val placement = when (selectedDestination) { + BrowseDestination.MOVIES -> "movies" + BrowseDestination.SHOWS -> "tv_shows" + else -> null + } ?: return@LaunchedEffect + if (sectionHeroRows.containsKey(selectedDestination)) return@LaunchedEffect + runCatching { repo.getActiveHero(placement) }.onSuccess { resolved -> + sectionHeroRows = sectionHeroRows + (selectedDestination to resolved) + } + } + val contextualHeroItems = remember(rows, homeContent.rows, sectionHeroRows, selectedDestination, heroDay) { + when (selectedDestination) { + // The separately fetched home hero wins where there is one, which is only + // after the configuration moved under a set sitting on this screen; otherwise + // the hero row that came with the home payload is still the right answer. + BrowseDestination.HOME -> selectHomeHeroMovies( + rows, + heroDay, + sectionHeroRows[BrowseDestination.HOME]?.takeIf(List::isNotEmpty) + ?: homeContent.rows, + ) + BrowseDestination.MOVIES, BrowseDestination.SHOWS -> + serverHeroPicks(sectionHeroRows[selectedDestination].orEmpty()) + else -> emptyList() + } + } + val showHomeGreeting = selectedDestination == BrowseDestination.HOME && + shouldShowHomeGreeting( + hasHero = contextualHeroItems.isNotEmpty(), + focusedRowId = focusedHomeRowId, + rowIds = rows.map(HomeBrowseRow::id), + ) + LaunchedEffect(selectedDestination) { + focusedHomeRowId = null + } + 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() + } + } + val journeyScreen = when { + showSettings -> "settings" + showRequests -> "requests" + showNotifications -> "notifications" + userSwitcherVisible || showProfiles -> "profiles" + detailsItem != null -> "details" + selectedMyShow != null -> "my_show_details" + else -> selectedDestination.name.lowercase() + } + LaunchedEffect(journeyScreen) { + homeViewModel.trackJourney( + category = "navigation", action = "screen_view", screen = journeyScreen, + feature = journeyScreen, target = journeyScreen, + ) + } + val latestJourneyScreen by rememberUpdatedState(journeyScreen) + LaunchedEffect(showForYouNudge, selectedDestination, settings.hasOpenedForYou) { + if ( + showForYouNudge && + selectedDestination == BrowseDestination.HOME && + !settings.hasOpenedForYou + ) { + delay(5.seconds) + showForYouNudge = false + } + } + LaunchedEffect( + selectedDestination, + contextualHeroItems.firstOrNull()?.item?.id, + rows.firstOrNull()?.items?.firstOrNull()?.id, + ) { + val firstItem = contextualHeroItems.firstOrNull()?.item + ?: rows.firstNotNullOfOrNull { it.items.firstOrNull() } + firstItem?.let(homeViewModel::focusItem) + if (firstItem != null && !initialFocusRequested) { + delay(16.milliseconds) + if (runCatching { contentFocusRequester.requestFocus() }.isSuccess) { + initialFocusRequested = true + } + } + } + + // Engagement is uploaded on a timer, but a viewer who opens the player after sitting + // on one row for a minute would otherwise lose that minute. Flushing on ON_STOP (and + // on dispose) closes the open dwell measurement while it still means something. + val lifecycleOwner = LocalLifecycleOwner.current + DisposableEffect(lifecycleOwner, homeViewModel) { + val observer = LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_STOP) homeViewModel.flushAnalytics() + } + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { + lifecycleOwner.lifecycle.removeObserver(observer) + homeViewModel.endJourney(latestJourneyScreen) + } + } + + val contentShift by animateDpAsState( + targetValue = if (navigationExpanded) remoteConfig.navigation.contentShiftDp.dp else 0.dp, + animationSpec = tween(150), + label = "navigation-content-shift", + ) + Box(Modifier.fillMaxSize().background(MembySurface)) { + Row(Modifier.fillMaxSize()) { + TvNavigationRail( + config = remoteConfig.navigation, + selected = if (userSwitcherVisible) { + BrowseDestination.PROFILES + } else { + selectedDestination + }, + focusDestination = railFocusDestination, + expanded = navigationExpanded, + navigationFocusRequester = navigationFocusRequester, + onRailFocusChanged = { + navigationExpanded = it + }, + onDestinationSelected = { destination -> + homeViewModel.trackJourney( + category = "navigation", action = "select", + screen = journeyScreen, feature = destination.name.lowercase(), + source = journeyScreen, target = destination.name.lowercase(), + ) + when (destination) { + BrowseDestination.SETTINGS -> { + railFocusDestination = BrowseDestination.SETTINGS + restoreRailAfterSettings = true + navigationExpanded = false + userSwitcherVisible = false + showSettings = true + } + BrowseDestination.PROFILES -> { + railFocusDestination = BrowseDestination.PROFILES + userSwitcherVisible = true + // The picker and the shortcut menu are two answers to one control; only + // one of them may ever be on screen. + userQuickActionsVisible = false + navigationExpanded = false + // The rail stays live beside Settings, so a destination + // chosen from there has to close it or it would sit over + // whatever was just asked for. + showSettings = false + restoreRailAfterSettings = false + } + else -> { + navigationExpanded = false + userSwitcherVisible = false + showSettings = false + restoreRailAfterSettings = false + selectedDestination = destination + railFocusDestination = destination + val savedFocus = destinationFocus[destination] + savedFocus?.let { (rowId, itemId) -> + returnRowId = rowId + returnItemId = itemId + // Only the id was remembered, so the kind is cleared rather + // than left carrying whichever row was focused on the + // destination being left; the id alone still names the + // shelves that matter. + returnRowKind = null + } + if ( + savedFocus != null && + savedFocus.first != HOME_HERO_ROW_ID && + savedFocus.first != SEARCH_ROW_ID + ) { + // A low shelf may have fallen out of LazyColumn composition + // while the rail owned focus. Let the list scroll it back in + // before asking its card for focus. + rowListFocusRestoreRequest += 1 + } else { + scope.launch { + // Let the destination compose and attach its entry target + // before transferring focus out of the rail. + delay(16.milliseconds) + if (savedFocus != null) { + requestFirstAvailableFocus( + cardReturnFocusRequester, + contentFocusRequester, + navigationFocusRequester, + ) + } else { + requestFirstAvailableFocus(contentFocusRequester, navigationFocusRequester) + } + } + } + } + } + }, + alertCount = displayedNotifications.size, + activeUsername = settings.username.orEmpty(), + activeProfileInitials = settings.profileInitials, + calendarEnabled = tvCalendarEnabled, + genresEnabled = genreBrowserEnabled, + // A hold opens the shortcut menu; the ordinary press still opens the user + // picker, so nothing a viewer already knows how to do has moved. + onUserLongPressed = { + homeViewModel.trackJourney( + category = "navigation", action = "open", screen = journeyScreen, + feature = "user_switcher_shortcuts", source = journeyScreen, + target = "user_shortcuts", + ) + userSwitcherVisible = false + navigationExpanded = false + // So closing the menu hands focus back to the item that was held, rather + // than to whichever destination the rail was last aimed at. + railFocusDestination = BrowseDestination.PROFILES + userQuickActionsVisible = true + }, + ) + BoxWithConstraints( + modifier = Modifier + .weight(1f) + .fillMaxSize() + .offset { IntOffset(x = contentShift.roundToPx(), y = 0) }, + ) { + val maintenanceMessage = liveMaintenance?.message ?: homeStatus.maintenanceMessage + if (maintenanceMessage != null) { + // The rows are gone but the rail is not: Settings and the active user are + // local, so there is no reason to strand the viewer here. + MaintenanceScreen( + message = maintenanceMessage, + contentFocusRequester = contentFocusRequester, + navigationFocusRequester = navigationFocusRequester, + onRetry = homeViewModel::refreshAll, + ) + LaunchedEffect(Unit) { + delay(450.milliseconds) + runCatching { contentFocusRequester.requestFocus() } + } + return@BoxWithConstraints + } + + if (selectedDestination == BrowseDestination.SEARCH) { + // Remembered, or this list is a fresh instance on every recomposition + // and the screen re-derives its genre chips each time. + val discovery = remember(homeContent.rows, homeContent.latestMovies, homeContent.continueWatching) { + homeContent.rows.flatMap { it.items } + .ifEmpty { homeContent.continueWatching + homeContent.latestMovies } + .distinctBy(BaseItem::id) + } + // Its own pane rather than a row list: the keyboard and the results + // need the whole content area. The rail stays beside it, and results + // open the same details overlay the rows do. + SearchScreen( + navigationFocusRequester = navigationFocusRequester, + contentFocusRequester = contentFocusRequester, + // Already in memory from the home response — the empty state + // costs nothing to fill. + discoveryItems = discovery, + returnFocusItemId = returnItemId.takeIf { returnRowId == SEARCH_ROW_ID }, + returnFocusRequester = cardReturnFocusRequester, + onSearchStarted = { + homeViewModel.trackJourney( + category = "search", action = "start", screen = "search", + feature = "search", source = "search", target = "search_results", + ) + }, + onItemFocused = homeViewModel::focusItem, + onItemSelected = { item -> + returnRowId = SEARCH_ROW_ID + returnRowKind = null + returnItemId = item.id + destinationFocus[BrowseDestination.SEARCH] = SEARCH_ROW_ID to item.id + homeViewModel.focusItem(item) + if (item.membyPlayable) { + homeViewModel.trackJourney( + category = "content", action = "open", screen = "search", + feature = "search", source = "search_results", target = "details", + itemName = item.name, itemType = item.type, + ) + detailsAiringNotice = null + detailsItem = item + } + }, + onContentFocused = { navigationExpanded = false }, + onExit = { + selectedDestination = BrowseDestination.HOME + scope.launch { + delay(16.milliseconds) + runCatching { contentFocusRequester.requestFocus() } + } + }, + ) + return@BoxWithConstraints + } + + if (selectedDestination == BrowseDestination.CALENDAR) { + // Its own pane rather than a row list: a month grid needs the whole + // content area, and there is no shelf shape that answers "what is on + // in three weeks". The rail stays beside it as it does for Search. + CalendarScreen( + navigationFocusRequester = navigationFocusRequester, + contentFocusRequester = contentFocusRequester, + onItemFocused = homeViewModel::focusItem, + onItemSelected = { item -> + homeViewModel.trackJourney( + category = "content", action = "open", screen = "calendar", + feature = "tv_calendar", source = "calendar_day", + target = "details", itemName = item.name, itemType = item.type, + ) + // The same substitution the schedule row makes: a calendar card + // is an episode that has not aired, so what was asked for is the + // show, carrying the air time across because that is why it was + // pressed. + val seriesStub = scheduleSeriesStub(item) + if (seriesStub != null) { + detailsAiringNotice = airingNoticeFor(item) + homeViewModel.focusItem(seriesStub) + detailsItem = seriesStub + } else if (item.membyPlayable) { + detailsAiringNotice = null + homeViewModel.focusItem(item) + detailsItem = item + } + }, + onExit = { + selectedDestination = BrowseDestination.HOME + scope.launch { + delay(16.milliseconds) + runCatching { contentFocusRequester.requestFocus() } + } + }, + ) + return@BoxWithConstraints + } + + // The rail's own Genres destination browses the catalogue, films and shows + // together — a household browses "Comedy", not "comedy films". It is the + // one way in: the Movies and TV Series pages carry no genre row of their + // own, so neither of those grids can cross media types. + if (selectedDestination == BrowseDestination.GENRES) { + GenreBrowseScreen( + itemType = ALL_MEDIA_ITEM_TYPE, + initialCategoryId = ALL_MEDIA_CATEGORY_ID, + favouriteStates = favoriteChanges, + playedStates = playedChanges, + navigationFocusRequester = navigationFocusRequester, + contentFocusRequester = contentFocusRequester, + returnFocusItemId = returnItemId.takeIf { returnRowId == GENRE_BROWSER_ROW_ID }, + returnFocusRequester = cardReturnFocusRequester, + onItemFocused = homeViewModel::focusItem, + onItemSelected = { item -> + returnRowId = GENRE_BROWSER_ROW_ID + returnRowKind = null + returnItemId = item.id + destinationFocus[BrowseDestination.GENRES] = + GENRE_BROWSER_ROW_ID to item.id + homeViewModel.focusItem(item) + homeViewModel.trackJourney( + category = "content", action = "open", screen = "genres", + feature = "genre_browse", source = "genre_results", target = "details", + itemName = item.name, itemType = item.type, + ) + detailsAiringNotice = null + detailsItem = item + }, + onContentFocused = { navigationExpanded = false }, + onClose = { + selectedDestination = BrowseDestination.HOME + railFocusDestination = BrowseDestination.HOME + scope.launch { + delay(16.milliseconds) + runCatching { contentFocusRequester.requestFocus() } + } + }, + ) + return@BoxWithConstraints + } + + FocusedHomeBackdrop(homeViewModel) + val verticalState = verticalStates.getOrPut(selectedDestination.name) { + LazyListState() + } + val homeListAtTop by remember(verticalState) { + derivedStateOf { + verticalState.firstVisibleItemIndex == 0 && + verticalState.firstVisibleItemScrollOffset == 0 + } + } + val hasContextualHero = contextualHeroItems.isNotEmpty() + // Set by a card in a shelf taking focus and cleared by the hero taking it + // back. Keyed on the destination so arriving at Home never inherits where + // focus happened to be on another one. + var rowFocusedBelowHero by remember(selectedDestination) { mutableStateOf(false) } + val showHomeHero = shouldShowHomeMovieHero( + hasMovies = hasContextualHero, + listAtTop = homeListAtTop, + rowFocused = rowFocusedBelowHero, + ) + val metadataHeight = homeHeaderHeight(maxHeight, showHomeHero) + val contentWidth = maxWidth + HomeArtworkPreloader(rows = rows, availableWidth = contentWidth) + val rowFocusPositions = rememberedRowFocus.getOrPut(selectedDestination) { + mutableMapOf() + } + val rowIndexes = remember(rows) { + rows.mapIndexed { index, row -> row.id to index }.toMap() + } + val rowItemCounts = remember(rows) { rows.map { it.items.size } } + var pendingRowFocus by remember(selectedDestination) { + mutableStateOf(null) + } + var rowFocusMoving by remember(selectedDestination) { mutableStateOf(false) } + var rowFocusRequestId by remember(selectedDestination) { mutableStateOf(0) } + // The latch above is what stops a held D-pad stacking one move on top of + // another, and until now the only thing that lifted it was the destination + // row reporting the request consumed. A request nothing consumes therefore + // took every later Up and Down press with it — the row list stopped moving + // where it stood and the only way out was the rail. Nothing may consume it + // when the destination row was not composed by the scroll, when that scroll + // was interrupted by another one, or when the row is replaced by an arriving + // refresh while the request is in flight. So the latch is bounded rather + // than trusted: whatever happened, one press can hold vertical navigation + // for ROW_FOCUS_MOVE_TIMEOUT and no longer. + LaunchedEffect(pendingRowFocus?.requestId, rowFocusMoving) { + if (!rowFocusMoving) return@LaunchedEffect + delay(ROW_FOCUS_MOVE_TIMEOUT) + pendingRowFocus = null + rowFocusMoving = false + } + val firstPopulatedRowId = remember(rows) { + rows.firstOrNull { candidate -> candidate.items.isNotEmpty() }?.id + } + // My Shows is a band of its own above the shelves, so on this page it is the + // first stop below the hero and the thing Up out of the topmost shelf has to + // return to. Both moves used to jump over it: the hero pointed Down straight + // at the first shelf, and Up out of that shelf found no shelf above it and + // went to the hero. + val myShowsStripFocusable = + selectedDestination == BrowseDestination.SHOWS && myShows.isNotEmpty() + // Must agree exactly with the items placed above the rows in the LazyColumn + // below, because it is what turns a row index into a scroll target. + // Counting one that is not placed scrolls a row short of the destination, + // which lands the requested card outside the composed window and leaves + // the move with nothing to complete it. + val leadingItemCount = + (if (selectedDestination == BrowseDestination.SHOWS) 1 else 0) + + (if (selectedDestination == BrowseDestination.FOR_YOU) 1 else 0) + LaunchedEffect(rowListFocusRestoreRequest, selectedDestination) { + if (rowListFocusRestoreRequest == 0) return@LaunchedEffect + val rowId = returnRowId ?: run { + rowListFocusRestoreRequest = 0 + return@LaunchedEffect + } + val rowIndex = rows.indexOfFirst { it.id == rowId } + if (rowIndex < 0) { + rowListFocusRestoreRequest = 0 + delay(16.milliseconds) + runCatching { contentFocusRequester.requestFocus() } + return@LaunchedEffect + } + // FocusRequester cannot focus a lazy child that is not composed. The + // airing shelf is commonly just beyond the retained viewport, which + // made focus fall back to its still-attached neighbour above. + verticalState.scrollToItem(leadingItemCount + rowIndex) + repeat(3) { + delay(16.milliseconds) + if (runCatching { cardReturnFocusRequester.requestFocus() }.isSuccess) { + rowListFocusRestoreRequest = 0 + return@LaunchedEffect + } + } + rowListFocusRestoreRequest = 0 + } + // Leaving My Shows vertically. Neither direction can be a plain + // focusProperties target: the shelf below is a lazy child composed by the + // scroll rather than something already there to receive the press, and the + // hero is not composed at all while a shelf holds focus. + val enterFirstPopulatedRow: () -> Boolean = enter@{ + if (rowFocusMoving) return@enter true + val destinationRowIndex = rows.indexOfFirst { it.items.isNotEmpty() } + if (destinationRowIndex < 0) return@enter false + val destinationRow = rows[destinationRowIndex] + val destinationItemIndex = rowEntryItemIndex( + sourceIndex = 0, + destinationItemCount = destinationRow.items.size, + rememberedDestinationIndex = rowFocusPositions[destinationRow.id], + ) + rowFocusPositions[destinationRow.id] = destinationItemIndex + rowFocusRequestId += 1 + val request = RowFocusRequest( + rowId = destinationRow.id, + itemIndex = destinationItemIndex, + requestId = rowFocusRequestId, + ) + rowFocusMoving = true + scope.launch { + try { + verticalState.animateScrollToItem( + leadingItemCount + destinationRowIndex, + ) + } catch (cancelled: CancellationException) { + // Same bargain the shelves' own moves strike: ask for the card + // anyway, and let the request expire if nothing consumes it. + pendingRowFocus = request + throw cancelled + } + pendingRowFocus = request + } + true + } + val returnToMyShows: () -> Boolean = ret@{ + if (!myShowsStripFocusable) return@ret false + // The strip sits above the shelves with the hero, so coming back up to + // it brings the hero back with it. + rowFocusedBelowHero = false + scope.launch { + verticalState.animateScrollToItem(0) + delay(16.milliseconds) + runCatching { myShowsEntryFocusRequester.requestFocus() } + } + true + } + Column(Modifier.fillMaxSize()) { + if (showHomeHero) { + HomeMovieHero( + movies = contextualHeroItems, + navigationFocusRequester = navigationFocusRequester, + contentEntryFocusRequester = contentFocusRequester, + returnFocusItemId = returnItemId.takeIf { returnRowId == HOME_HERO_ROW_ID }, + returnFocusRequester = cardReturnFocusRequester, + // Only when there is a card down there to attach it to: a + // requester naming nothing throws the moment Down is pressed. + // My Shows first where there is one, then the first shelf with + // cards in it. Only ever a requester with a card behind it: one + // naming nothing throws the moment Down is pressed. + downFocusRequester = if (myShowsStripFocusable) { + myShowsEntryFocusRequester + } else { + heroRowEntryFocusRequester.takeIf { firstPopulatedRowId != null } + }, + onItemFocused = { item -> + navigationExpanded = false + rowFocusedBelowHero = false + focusedHomeRowId = null + destinationFocus[selectedDestination] = HOME_HERO_ROW_ID to item.id + returnRowId = HOME_HERO_ROW_ID + returnRowKind = null + returnItemId = item.id + homeViewModel.focusItem(item) + }, + onItemSelected = { item -> + returnRowId = HOME_HERO_ROW_ID + returnRowKind = null + returnItemId = item.id + homeViewModel.focusItem(item) + homeViewModel.trackJourney( + category = "content", action = "open", screen = selectedDestination.name.lowercase(), + feature = "hero", source = HOME_HERO_ROW_ID, target = "details", + itemName = item.name, itemType = item.type, + ) + detailsAiringNotice = null + detailsItem = item + }, + modifier = Modifier.height(metadataHeight), + ) + } else { + FocusedHomeMetadata( + homeViewModel = homeViewModel, + sectionLabel = selectedDestination.label, + modifier = Modifier + .height(metadataHeight) + .padding(start = 36.dp, end = 36.dp, top = 24.dp, bottom = 10.dp), + ) + } + AnimatedVisibility( + visible = homeStatus.hasRefreshError, + enter = fadeIn(tween(180)), + exit = fadeOut(tween(120)), + ) { + Text( + // The gateway's maintenance notice when it sent one, and the + // generic wording otherwise. + homeStatus.statusMessage ?: "Connection is slow — showing available content", + color = MembyMutedText, + fontSize = 14.sp, + modifier = Modifier.padding(horizontal = 36.dp, vertical = 4.dp), + ) + } + LazyColumn( + state = verticalState, + modifier = Modifier + .weight(1f) + .fillMaxWidth(), + contentPadding = PaddingValues(bottom = 96.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + if (selectedDestination == BrowseDestination.SHOWS) { + item(key = "my-shows", contentType = "my-shows") { + MyShowsStrip( + shows = myShows, + loading = myShowsLoading, + errorMessage = myShowsError, + onRetry = { + if (!myShowsLoading) scope.launch { + myShowsLoading = true + myShowsError = null + runCatching { repo.getMyShows() } + .onSuccess { myShows = it } + .onFailure { myShowsError = friendlyEmbyError(it) } + myShowsLoading = false + } + }, + repository = repo, + availableWidth = contentWidth, + density = settings.homeCardDensity, + navigationFocusRequester = navigationFocusRequester, + // My Shows is the first reachable content on this page — + // unless the hero is up, which owns the rail's entry + // target the way it does on every other page. Keyed on + // the hero actually being drawn rather than on there + // being one: a requester attached to two live nodes is + // ambiguous, and one attached to a hero that is not + // composed would leave the rail with nowhere to hand + // focus to. + contentFocusRequester = contentFocusRequester.takeIf { + !showHomeHero + }, + entryFocusRequester = myShowsEntryFocusRequester, + returnFocusItemId = myShowReturnItemId, + returnFocusRequester = myShowReturnFocusRequester, + onMoveVertical = { direction -> + when (direction) { + RowFocusDirection.DOWN -> enterFirstPopulatedRow() + RowFocusDirection.UP -> if (hasContextualHero) { + rowFocusedBelowHero = false + scope.launch { + verticalState.animateScrollToItem(0) + // Let the hero compose and attach its + // entry target before focus is handed up. + delay(16.milliseconds) + runCatching { + contentFocusRequester.requestFocus() + } + } + true + } else { + false + } + } + }, + onShowSelected = { + homeViewModel.trackJourney( + category = "content", action = "open", screen = "shows", + feature = "my_shows", source = "my_shows", target = "my_show_details", + itemName = it.title, + ) + myShowReturnItemId = it.itemId + selectedMyShow = it + }, + onContentFocused = { navigationExpanded = false }, + ) + } + } + if (selectedDestination == BrowseDestination.FOR_YOU) { + item(key = "for-you-time", contentType = "for-you-time") { + ForYouTimeBudget( + selectedMinutes = forYouState.availableMinutes, + loading = forYouState.loading, + error = forYouState.error, + onSelected = { minutes -> + homeViewModel.trackJourney( + category = "recommendations", action = "change", screen = "for_you", + feature = "for_you_time", outcome = "success", + ) + homeViewModel.loadForYou(minutes) + scope.launch { repo.setForYouMinutes(minutes) } + }, + ) + } + } + items( + items = rows, + key = { it.id }, + contentType = { "media-row-${it.kind}" }, + ) { row -> + // LazyColumn composes a row as it scrolls into view, which is + // as close to "the viewer saw it" as the TV can observe. + LaunchedEffect(row.id) { + // The first eight posters cover the initial TV viewport + // with headroom across density settings. Later posters + // only become evidence when focus actually reaches them. + homeViewModel.trackRowImpression( + row.id, + row.kind.name, + row.items.take(8).map { it.id }, + ) + } + MediaRow( + row = row, + availableWidth = contentWidth, + navigationFocusRequester = navigationFocusRequester, + contentEntryFocusRequester = contentFocusRequester.takeIf { + !hasContextualHero && + (selectedDestination != BrowseDestination.SHOWS || + myShows.isEmpty()) && + row.id == firstPopulatedRowId + }, + heroEntryFocusRequester = heroRowEntryFocusRequester.takeIf { + hasContextualHero && row.id == firstPopulatedRowId + }, + returnFocusItemId = returnItemId.takeIf { returnRowId == row.id }, + returnFocusRequester = cardReturnFocusRequester, + verticalFocusRequest = pendingRowFocus, + onVerticalFocusRequestConsumed = { requestId -> + if (pendingRowFocus?.requestId == requestId) { + pendingRowFocus = null + rowFocusMoving = false + } + }, + onMoveVertical = moveVertical@{ sourceItemIndex, direction -> + if (rowFocusMoving) return@moveVertical true + val sourceRowIndex = rowIndexes[row.id] ?: return@moveVertical false + val destinationRowIndex = adjacentFocusableRowIndex( + itemCounts = rowItemCounts, + currentIndex = sourceRowIndex, + direction = direction, + ) ?: run { + // Up out of the topmost shelf is the way back to the + // hero, and it has to be handled here: the hero is + // not composed while a row holds focus, so there is + // nothing above for Compose's own focus search to + // find and the press would otherwise be dead. + // My Shows is the band directly above the shelves, + // so it answers this press before the hero does. + if (direction == RowFocusDirection.UP && returnToMyShows()) { + return@moveVertical true + } + if (direction == RowFocusDirection.UP && hasContextualHero) { + rowFocusedBelowHero = false + scope.launch { + verticalState.animateScrollToItem(0) + // Let the hero compose and attach its entry + // target before focus is handed to it. + delay(16.milliseconds) + runCatching { contentFocusRequester.requestFocus() } + } + return@moveVertical true + } + return@moveVertical false + } + val destinationRow = rows[destinationRowIndex] + val destinationItemIndex = rowEntryItemIndex( + sourceIndex = sourceItemIndex, + destinationItemCount = destinationRow.items.size, + rememberedDestinationIndex = rowFocusPositions[destinationRow.id], + ) + rowFocusPositions[row.id] = sourceItemIndex + rowFocusPositions[destinationRow.id] = destinationItemIndex + rowFocusRequestId += 1 + val request = RowFocusRequest( + rowId = destinationRow.id, + itemIndex = destinationItemIndex, + requestId = rowFocusRequestId, + ) + rowFocusMoving = true + scope.launch { + try { + // Compose the destination row before its MediaRow + // tries to attach the requested card's focus node. + verticalState.animateScrollToItem( + leadingItemCount + destinationRowIndex, + ) + } catch (cancelled: CancellationException) { + // Another scroll took the list over mid-animation, so + // this move is never going to post its request and + // nothing downstream would ever lift the latch. Ask + // for the card anyway — the row it is in is where the + // list has been left, and the worst case is the + // request expiring like any other. + pendingRowFocus = request + throw cancelled + } + pendingRowFocus = request + } + true + }, + onContentFocused = { navigationExpanded = false }, + onItemFocused = { item -> + val itemIndex = row.items.indexOfFirst { it.id == item.id } + if (itemIndex >= 0) rowFocusPositions[row.id] = itemIndex + rowFocusedBelowHero = true + if (selectedDestination == BrowseDestination.HOME) { + focusedHomeRowId = row.id + } + destinationFocus[selectedDestination] = row.id to item.id + returnRowId = row.id + returnRowKind = row.kind.name + returnItemId = item.id + homeViewModel.focusItem(item) + homeViewModel.trackRowFocused(row.id, row.kind.name, item.id) + }, + onItemSelected = { item -> + returnRowId = row.id + returnRowKind = row.kind.name + returnItemId = item.id + homeViewModel.trackRowSelected(row.id, row.kind.name, item.id) + homeViewModel.trackJourney( + category = "content", action = "open", + screen = selectedDestination.name.lowercase(), feature = row.kind.name.lowercase(), + source = row.id, target = if (item.membyPlayable) "details" else "content_action", + itemName = item.name, itemType = item.type, + ) + // A schedule card is an episode that has not aired, so + // it is not playable and has no page of its own. What + // the viewer asked for is the show — carrying the air + // time across, since that is why they pressed it. + val seriesStub = scheduleSeriesStub(item) + // A movie-schedule card whose film Emby has since + // imported is the ordinary movie page; one whose film + // is still only Radarr's opens its own. Neither is + // inert, which is what the card used to be. + val movieStub = scheduleMovieStub(item) + if (seriesStub != null) { + detailsAiringNotice = airingNoticeFor(item) + homeViewModel.focusItem(seriesStub) + detailsItem = seriesStub + } else if (movieStub != null) { + detailsAiringNotice = null + homeViewModel.focusItem(movieStub) + detailsItem = movieStub + } else { + homeViewModel.focusItem(item) + if (item.membyPlayable || item.isRadarrOnly) { + detailsAiringNotice = null + detailsItem = item + } + } + }, + onItemLongPressed = { item -> + returnRowId = row.id + returnRowKind = row.kind.name + returnItemId = item.id + homeViewModel.focusItem(item) + if (item.membyPlayable || item.isRadarrOnly) { + // Cleared here rather than only in the effect that + // answers it: the effect runs after the menu's + // first frame, and the previous card's answer + // showing on it would be an entry that appears and + // then vanishes. + quickMenuTrailerAvailable = false + quickMenuRowId = row.id + quickMenuItem = item + } + }, + density = settings.homeCardDensity, + artworkStyle = settings.homeArtworkStyle, + horizontalState = horizontalStates.getOrPut( + "${selectedDestination.name}:${row.id}", + ) { LazyListState() }, + ) + } + } + } + } + } + AnimatedVisibility( + visible = compatibilityNotice != null, + modifier = Modifier.align(Alignment.TopCenter), + enter = fadeIn(tween(180)), + exit = fadeOut(tween(120)), + ) { + Text( + text = compatibilityNotice?.message.orEmpty(), + color = Color.White, + fontSize = 16.sp, + fontWeight = FontWeight.SemiBold, + modifier = Modifier + .fillMaxWidth() + .background(Color(0xFFD6403A)) + .padding(horizontal = 36.dp, vertical = 12.dp), + ) + } + HomeClock( + showGreeting = showHomeGreeting, + username = settings.username, + shortName = settings.shortName, + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(end = 24.dp, bottom = 18.dp), + ) + if (userSwitcherVisible) { + BackHandler { + userSwitcherVisible = false + scope.launch { + delay(16.milliseconds) + runCatching { navigationFocusRequester.requestFocus() } + } + } + UserSwitcherOverlay( + profiles = settings.profiles, + activeProfileId = settings.activeProfileId, + onProfileSelected = { profile -> + userSwitcherVisible = false + navigationExpanded = false + if (profile.id != settings.activeProfileId) { + switchingProfileId = profile.id + scope.launch { + homeViewModel.endJourneyBeforeProfileSwitch(journeyScreen) + runCatching { repo.switchProfile(profile) } + .onFailure { switchingProfileId = null } + } + } else { + scope.launch { + delay(16.milliseconds) + runCatching { navigationFocusRequester.requestFocus() } + } + } + }, + onManageProfiles = { + userSwitcherVisible = false + navigationExpanded = false + showProfiles = true + }, + alertCount = displayedNotifications.size, + onOpenSettings = { + homeViewModel.trackJourney( + category = "navigation", action = "select", + screen = journeyScreen, feature = "settings", + source = journeyScreen, target = "settings", + ) + userSwitcherVisible = false + navigationExpanded = false + railFocusDestination = BrowseDestination.PROFILES + restoreRailAfterSettings = true + showSettings = true + }, + onOpenAlerts = { + homeViewModel.trackJourney( + category = "notifications", action = "open", screen = journeyScreen, + feature = "notifications", target = "notifications", + ) + userSwitcherVisible = false + navigationExpanded = false + showNotifications = true + scope.launch { + notificationsLoading = true + notificationsError = null + runCatching { repo.getNotifications() } + .onSuccess { notificationState = it } + .onFailure { notificationsError = friendlyEmbyError(it) } + notificationsLoading = false + } + }, + showViewers = shouldOfferViewerPicker( + gatewayMode = ServerConfig.isGateway, + enabled = viewersEnabled, + viewerCount = viewers.size, + ), + viewers = viewers, + activeViewerId = settings.activeViewerId, + activeViewerName = settings.activeViewerName, + // Selecting a person is the panel's own press now rather than a screen + // reached from it, which is the whole point: this is the thing a household + // changes nightly. Everything on the launcher belongs to the outgoing + // viewer, so the journey is closed and the rows refreshed rather than left + // standing under a different person's name — the same work the full picker + // does, because it is the same switch. + onViewerSelected = { viewer -> + userSwitcherVisible = false + navigationExpanded = false + scope.launch { + delay(16.milliseconds) + runCatching { navigationFocusRequester.requestFocus() } + } + scope.launch { + homeViewModel.endJourneyBeforeProfileSwitch(journeyScreen) + repo.switchViewer(viewer) + homeViewModel.refreshAll() + } + }, + onAddViewer = { + userSwitcherVisible = false + navigationExpanded = false + openViewerName(ViewerNameTarget.Add) + }, + canAddViewer = viewers.count { !it.isMain } < MAX_SHADOW_VIEWERS, + onManageViewers = { + userSwitcherVisible = false + navigationExpanded = false + showViewerPicker = true + }, + showRequests = requestsAllowed, + onOpenRequests = { + homeViewModel.trackJourney( + category = "requests", action = "open", screen = journeyScreen, + feature = "requests", target = "requests", + ) + userSwitcherVisible = false + navigationExpanded = false + showRequests = true + }, + onDismiss = { + userSwitcherVisible = false + scope.launch { + delay(16.milliseconds) + runCatching { navigationFocusRequester.requestFocus() } + } + }, + ) + } + if (showViewerPicker) { + val closeViewerPicker: () -> Unit = { + showViewerPicker = false + scope.launch { + delay(16.milliseconds) + runCatching { navigationFocusRequester.requestFocus() } + } + } + BackHandler(onBack = closeViewerPicker) + Box(Modifier.fillMaxSize().zIndex(20f).background(MembySurface)) { + ViewerPicker( + viewers = viewers, + activeViewerId = settings.activeViewerId, + onViewerSelected = { viewer -> + closeViewerPicker() + scope.launch { + // Everything on the launcher belongs to the outgoing viewer, so + // the journey is closed and the rows are refreshed rather than + // left standing under a different person's name. + homeViewModel.endJourneyBeforeProfileSwitch(journeyScreen) + repo.switchViewer(viewer) + homeViewModel.refreshAll() + } + }, + // Both open over the picker rather than instead of it, so Back is one + // step out of each and the row of faces is still underneath. + onAddViewer = { openViewerName(ViewerNameTarget.Add) }, + onManageViewers = { showViewerManage = true }, + canAddViewer = viewers.count { !it.isMain } < MAX_SHADOW_VIEWERS, + ) + } + } + if (showViewerManage) { + BackHandler(onBack = { showViewerManage = false }) + Box(Modifier.fillMaxSize().zIndex(21f).background(MembySurface)) { + ViewerManageScreen( + viewers = viewers, + onRename = { openViewerName(ViewerNameTarget.Rename(it)) }, + onRemove = { viewer -> + viewerBusyId = viewer.id + scope.launch { + // Removing whoever is watching returns this set to the account, + // which the repository does; the launcher has to be told, or it + // goes on drawing the removed person's rows. + val watching = viewer.id == settings.activeViewerId + val removed = repo.removeViewer(viewer) + if (removed && watching) { + homeViewModel.endJourneyBeforeProfileSwitch(journeyScreen) + homeViewModel.refreshAll() + } + refreshViewers() + viewerBusyId = null + } + }, + onAdd = { openViewerName(ViewerNameTarget.Add) }, + onClose = { showViewerManage = false }, + canAddViewer = viewers.count { !it.isMain } < MAX_SHADOW_VIEWERS, + busyViewerId = viewerBusyId, + ) + } + } + viewerNameTarget?.let { target -> + val closeViewerName = { viewerNameTarget = null } + BackHandler(onBack = closeViewerName) + Box(Modifier.fillMaxSize().zIndex(22f).background(MembySurface)) { + ViewerNameEntry( + target = target, + name = viewerName, + existing = viewers, + onNameChanged = { viewerName = it; viewerNameFailure = null }, + onCancel = closeViewerName, + onConfirm = { + if (!viewerSaving) { + viewerSaving = true + scope.launch { + val saved = when (target) { + ViewerNameTarget.Add -> repo.createViewer(viewerName) + is ViewerNameTarget.Rename -> + repo.renameViewer(target.viewer, viewerName) + } + viewerSaving = false + if (saved == null) { + // The one thing the television can say about a refusal + // it has no wording for. The screen stays up holding + // what was typed, because retyping a name somebody has + // just entered is the worst possible answer to a + // request that failed for a reason nothing here knows. + viewerNameFailure = "That could not be saved. Try again." + } else { + viewerNameTarget = null + refreshViewers() + } + } + } + }, + failure = viewerNameFailure, + saving = viewerSaving, + ) + } + } + if (userQuickActionsVisible) { + val closeUserQuickActions: () -> Unit = { + userQuickActionsVisible = false + scope.launch { + delay(16.milliseconds) + runCatching { navigationFocusRequester.requestFocus() } + } + } + BackHandler(onBack = closeUserQuickActions) + UserQuickActionsOverlay( + username = settings.username.orEmpty(), + alertCount = displayedNotifications.size, + busy = notificationsMutationBusy, + // Clearing from here never takes the viewer anywhere: the menu closes, the + // badge on the rail behind it empties, and a toast says what happened. The + // list is emptied optimistically for the reason the alerts page already is — + // a badge that lingered while the request was in flight is one somebody + // clears twice — and put back untouched if the gateway refuses. + onClearNotifications = { + if (!notificationsMutationBusy) { + notificationsMutationBusy = true + val previous = notificationState + val offered = displayedNotifications.size + val hadLocalUpdate = settings.updateAlertVersion != null + notificationState = notificationState.copy(notifications = emptyList()) + closeUserQuickActions() + scope.launch { + // The update notice is this television's own — there is no server + // row behind it, so it is dismissed locally and counted here. + if (hadLocalUpdate) ServiceLocator.settings.dismissUpdateAlert() + runCatching { repo.clearNotifications() } + .onSuccess { cleared -> + val total = cleared + if (hadLocalUpdate) 1 else 0 + Toast.makeText( + context, + clearedNotificationsMessage(total), + Toast.LENGTH_SHORT, + ).show() + homeViewModel.trackJourney( + category = "notifications", action = "dismiss", + screen = journeyScreen, + feature = "user_switcher_clear_notifications", + source = "user_switcher", target = "notifications", + // The one free-text field, and the count is the only + // thing that separates one use of this shortcut from + // the next. + itemName = "$total cleared", + outcome = "success", + ) + } + .onFailure { + // Put the list back and say so here rather than writing + // notificationsError: this menu never opens the page, and + // an error banner waiting on a page nobody has opened is + // one they meet later with no idea what it is about. + notificationState = previous + Toast.makeText( + context, + "Couldn’t clear notifications", + Toast.LENGTH_SHORT, + ).show() + homeViewModel.trackJourney( + category = "notifications", action = "dismiss", + screen = journeyScreen, + feature = "user_switcher_clear_notifications", + source = "user_switcher", target = "notifications", + itemName = "$offered offered", + outcome = "failure", + ) + } + notificationsMutationBusy = false + } + } + }, + onDismiss = closeUserQuickActions, + ) + } + if (showSettings) { + val closeSettings: () -> Unit = { + showSettings = false + scope.launch { + delay(16.milliseconds) + if (restoreRailAfterSettings) { + navigationExpanded = true + runCatching { navigationFocusRequester.requestFocus() } + restoreRailAfterSettings = false + } else if (returnItemId != null) { + requestFirstAvailableFocus( + cardReturnFocusRequester, + contentFocusRequester, + navigationFocusRequester, + ) + } else { + runCatching { contentFocusRequester.requestFocus() } + } + } + } + BackHandler(onBack = closeSettings) + // Settings is a destination, not a takeover: it leaves the main rail visible + // and reachable so Home, Search and the active user are one Left press away, the + // same as from every other page. The rail below is the live one — this Row + // only reserves its collapsed footprint and takes the same expand shift the + // content area does, so an expanded rail slides Settings aside rather than + // covering its own page list. + // The full expansion, not the home screen's TvRailContentShift: the rows + // tolerate the rail drawing over their left edge, but this panel is painted + // above the rail, so anything short of the whole width would clip the + // labels off the very rail the viewer is moving through. + val settingsShift by animateDpAsState( + targetValue = if (navigationExpanded) { + TvRailExpandedWidth - TvRailCollapsedWidth + } else { + 0.dp + }, + animationSpec = tween(150), + label = "settings-content-shift", + ) + Row(Modifier.fillMaxSize()) { + Spacer(Modifier.width(TvRailCollapsedWidth)) + SettingsSheet( + onClose = closeSettings, + overlay = false, + navigationFocusRequester = navigationFocusRequester, + onAnalyticsEvent = { feature, action -> + homeViewModel.trackJourney( + category = "settings", action = action, screen = "settings", + feature = feature, outcome = "success", + ) + }, + modifier = Modifier + .weight(1f) + .offset { IntOffset(x = settingsShift.roundToPx(), y = 0) }, + ) + } + } + if (showProfiles) { + BackHandler(onBack = { showProfiles = false }) + ProfileChooser( + profiles = settings.profiles, + currentProfileId = settings.activeProfileId, + switchingProfileId = switchingProfileId, + removingProfileId = removingProfileId, + onSelect = { profile -> + if (profile.id == settings.activeProfileId) { + showProfiles = false + } else { + switchingProfileId = profile.id + scope.launch { + homeViewModel.endJourneyBeforeProfileSwitch(journeyScreen) + runCatching { repo.switchProfile(profile) } + .onFailure { switchingProfileId = null } + } + } + }, + onRemove = { profile -> + removingProfileId = profile.id + scope.launch { + runCatching { repo.removeProfile(profile) } + removingProfileId = null + } + }, + onAddProfile = { + scope.launch { + homeViewModel.endJourneyBeforeProfileSwitch(journeyScreen) + addingProfile = true + } + }, + onClose = { showProfiles = false }, + ) + } + // Over the manage-users page rather than instead of it: cancelling comes straight + // back to the list the "+" was pressed from. A successful sign-in makes the new + // viewer active, which re-keys HomeScreen and takes both flags with it. + if (addingProfile) { + SetupScreen( + onCancel = { addingProfile = false }, + onSignedIn = { addingProfile = false }, + ) + } + // Coming back from the player. The page is reopened rather than restored from + // under the player — see [detailsResumeTarget] — and the item is re-requested on + // the way in, so what appears has this playback's progress on it rather than the + // record the row handed over before the film started. + LifecycleResumeEffect(Unit) { + detailsResumeTarget?.let { target -> + detailsResumeTarget = null + if (detailsItem == null) { + homeViewModel.focusItem(target.item) + detailsTrail = target.trail + detailsAiringNotice = target.airingNotice + restoreDetailPosition = true + detailsItem = target.item + } + } + onPauseOrDispose { } + } + detailsItem?.let { selected -> + // A detail page is opened by a press that sets `detailsItem`, so the composition + // this runs in *is* the frame that press caused — which makes it the earliest + // point that can honestly stand for "the viewer asked for this page". Keyed on + // the item, so walking a "More like this" trail times each page separately. + // Debug-only: on a release build both calls return before allocating. + remember(selected.id) { StartupTrace.beginSpan(StartupTrace.DETAIL) } + LaunchedEffect(selected.id) { StartupTrace.endSpan(StartupTrace.DETAIL) } + BackHandler { + val previous = detailsTrail.lastOrNull() + if (previous != null) { + detailsTrail = detailsTrail.dropLast(1) + restoreDetailPosition = true + detailsItem = previous + } else { + restoreDetailPosition = false + detailsItem = null + detailsAiringNotice = null + requestFirstAvailableFocus( + cardReturnFocusRequester, + contentFocusRequester, + navigationFocusRequester, + ) + } + } + FocusedDetailsOverlay( + homeViewModel = homeViewModel, + selected = selected, + restorePosition = restoreDetailPosition, + airingNotice = detailsAiringNotice, + onOpenItem = { related -> + homeViewModel.trackJourney( + category = "recommendations", action = "open", screen = "details", + feature = "related", source = "related", target = "details", + itemName = related.name, itemType = related.type, + ) + detailsTrail = detailsTrail + selected + restoreDetailPosition = false + // Ask for the full record the same way a focused card does. Some + // routes here hand over a stub rather than a row item — the episode + // page's "Show" button knows only the series' id and name — and + // without this the page opens with no logo, no backdrop and no facts, + // because nothing else on this path ever fetches the item. + homeViewModel.focusItem(related) + detailsItem = related + // The notice was about the show that was pressed in the row, not about + // whatever "More like this" leads to. Walking Back does not restore it, + // and should not: it was news, and it has been read. + detailsAiringNotice = null + }, + onOpenEmbyItem = { embyItem -> + // Not a step in the trail: the Radarr page and the Emby page are two + // answers about one title, so Back from here still belongs where the + // card was pressed rather than on the page it replaced. + homeViewModel.focusItem(embyItem) + detailsAiringNotice = null + detailsItem = embyItem + }, + onPlay = { + // Kept, not discarded: this is what the viewer comes back to when the + // film ends or they press Back out of the player. + detailsResumeTarget = DetailsReturn(selected, detailsTrail, detailsAiringNotice) + detailsItem = null + detailsTrail = emptyList() + restoreDetailPosition = false + detailsAiringNotice = null + playItem(it) + }, + onPlayTrailer = playTrailer, + onToggleFavorite = { item, saved -> + homeViewModel.trackJourney( + category = "library", action = if (saved) "favourite" else "unfavourite", + screen = "details", feature = "favorites", itemName = item.name, itemType = item.type, + outcome = "success", + ) + homeViewModel.setFavorite(item, saved) + }, + isMyShow = myShows.any { it.itemId == selected.id }, + onToggleMyShow = { item, saved -> + homeViewModel.trackJourney( + category = "library", action = if (saved) "follow" else "unfollow", + screen = "details", feature = "my_shows", itemName = item.name, itemType = item.type, + outcome = "success", + ) + // Optimistic, the way a favourite already is. Following a show is a + // press with an obvious outcome, and the server's answer to it is the + // *whole* list decorated with Sonarr's lifecycle for every show on it + // — a request the viewer has no reason to sit through watching an + // unchanged button. The row that lands a moment later replaces this + // placeholder; a failure puts the button back where it was. + if (myShowsMutationBusy) return@FocusedDetailsOverlay + myShowsMutationBusy = true + val previous = myShows + myShows = if (saved) { + myShows.filterNot { it.itemId == item.id } + myShowStub(item) + } else { + myShows.filterNot { it.itemId == item.id } + } + if (saved) { + Toast.makeText( + context, + "${item.name} added to My Shows", + Toast.LENGTH_SHORT, + ).show() + } + scope.launch { + if (saved) { + runCatching { repo.saveMyShow(item) } + .onSuccess { myShows = it } + .onFailure { myShows = previous } + } else { + runCatching { repo.removeMyShow(item.id) } + .onFailure { myShows = previous } + } + myShowsMutationBusy = false + } + }, + onTogglePlayed = { item, played -> + homeViewModel.trackJourney( + category = "library", action = if (played) "mark_played" else "mark_unplayed", + screen = "details", feature = "played_status", itemName = item.name, itemType = item.type, + outcome = "success", + ) + homeViewModel.setPlayed(item, played) + }, + onClose = { + detailsItem = null + detailsTrail = emptyList() + restoreDetailPosition = false + detailsAiringNotice = null + requestFirstAvailableFocus( + cardReturnFocusRequester, + contentFocusRequester, + navigationFocusRequester, + ) + }, + ) + } + selectedMyShow?.let { show -> + val closeMyShow: (Boolean) -> Unit = { removed -> + selectedMyShow = null + scope.launch { + delay(16.milliseconds) + val exactStillExists = !removed && myShows.any { it.itemId == myShowReturnItemId } + val restored = exactStillExists && runCatching { + myShowReturnFocusRequester.requestFocus() + }.isSuccess + if (!restored && runCatching { contentFocusRequester.requestFocus() }.isFailure) { + runCatching { navigationFocusRequester.requestFocus() } + } + } + } + BackHandler { closeMyShow(false) } + MyShowDetailsOverlay( + show = show, + repository = repo, + removing = removingMyShow, + onRemove = { + removingMyShow = true + scope.launch { + runCatching { repo.removeMyShow(show.itemId) }.onSuccess { + myShows = myShows.filterNot { it.itemId == show.itemId } + closeMyShow(true) + } + removingMyShow = false + } + }, + onClose = { closeMyShow(false) }, + ) + } + if (showRequests) { + // Reached from the user picker, so leaving it returns to the rail rather than to + // whatever card held focus on the launcher behind it — the alerts page's rule. + val closeRequests: () -> Unit = { + showRequests = false + scope.launch { + delay(16.milliseconds) + runCatching { navigationFocusRequester.requestFocus() } + } + } + // Keyed on the profile like every other page here, so switching viewer cannot + // leave one person's requests on screen under somebody else's name. + val requestsViewModel: RequestsViewModel = viewModel( + viewModelStoreOwner = profileViewModelOwner, + factory = RequestsViewModelFactory(repo), + ) + val requestsState by requestsViewModel.state.collectAsStateWithLifecycle() + // The page keeps itself current while it is on screen — it is the one screen + // whose cards move without anybody touching anything. The loop is hung off the + // composition rather than off the view model, which is keyed on the profile and + // outlives this block, and off STARTED rather than run unconditionally, so a + // television left on the launcher or switched to another app stops asking + // entirely. Whether there is anything worth asking about is the view model's + // own judgement — see pollWhileVisible. + val requestsLifecycleOwner = LocalLifecycleOwner.current + LaunchedEffect(requestsViewModel, requestsLifecycleOwner) { + requestsLifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) { + requestsViewModel.pollWhileVisible() + } + } + // The page covers the launcher, rail and all, so it carries its own rather than + // leaving Left pointing at one nobody can see. Its own FocusRequester, because + // the launcher's is still attached to the rail underneath and one requester on + // two live nodes lands on whichever Compose reaches first. + // + // Selecting a destination from here leaves the page — this rail is how you get + // *out* of Requests, not a second way to browse behind it. + Row( + Modifier + .fillMaxSize() + .zIndex(6f) + // The rail's own surface is 95% opaque, so without this the launcher + // shows faintly through the one column of the page it does not paint. + .background(MembySurface), + ) { + TvNavigationRail( + config = remoteConfig.navigation, + selected = if (userSwitcherVisible) { + BrowseDestination.PROFILES + } else { + selectedDestination + }, + expanded = requestsRailExpanded, + navigationFocusRequester = requestsRailFocusRequester, + onRailFocusChanged = { requestsRailExpanded = it }, + onDestinationSelected = { destination -> + requestsRailExpanded = false + showRequests = false + when (destination) { + BrowseDestination.SETTINGS -> { + railFocusDestination = BrowseDestination.SETTINGS + restoreRailAfterSettings = true + navigationExpanded = false + userSwitcherVisible = false + showSettings = true + } + BrowseDestination.PROFILES -> { + railFocusDestination = BrowseDestination.PROFILES + userSwitcherVisible = true + // The picker and the shortcut menu are two answers to one control; only + // one of them may ever be on screen. + userQuickActionsVisible = false + navigationExpanded = false + showSettings = false + restoreRailAfterSettings = false + } + else -> { + selectedDestination = destination + railFocusDestination = destination + navigationExpanded = false + userSwitcherVisible = false + showSettings = false + restoreRailAfterSettings = false + scope.launch { + // Let the destination compose and attach its entry + // target before focus is transferred to it. + delay(16.milliseconds) + requestFirstAvailableFocus( + contentFocusRequester, + navigationFocusRequester, + ) + } + } + } + }, + alertCount = displayedNotifications.size, + activeUsername = settings.username.orEmpty(), + activeProfileInitials = settings.profileInitials, + calendarEnabled = tvCalendarEnabled, + ) + RequestsScreen( + state = requestsState, + navigationFocusRequester = requestsRailFocusRequester, + contentFocusRequester = contentFocusRequester, + onSelectTab = requestsViewModel::selectTab, + onQueryChanged = requestsViewModel::onQueryChanged, + onAppendToQuery = requestsViewModel::appendToQuery, + onBackspace = requestsViewModel::backspace, + onClearQuery = requestsViewModel::clearQuery, + onSubmitSearch = requestsViewModel::submitSearch, + onRequest = requestsViewModel::request, + onRemove = requestsViewModel::remove, + onOpenItem = { itemId -> + // A request that has arrived opens the thing it became. The stub is + // filled in by focusItem the way a schedule card's is, so the page + // appears at once instead of waiting on an item request. + scope.launch { + runCatching { repo.getItemDetails(itemId) } + .onSuccess { item -> + closeRequests() + detailsAiringNotice = null + detailsTrail = emptyList() + detailsItem = item + } + } + }, + onRetry = requestsViewModel::refresh, + onExit = closeRequests, + posterUrlFor = { it.takeIf(String::isNotBlank) }, + modifier = Modifier.weight(1f).fillMaxHeight(), + ) + } + } + if (showNotifications) { + // Reached from the user picker, so leaving it goes back to the rail rather than + // to whatever card happened to hold focus on the launcher behind it. + val closeAlerts: () -> Unit = { + showNotifications = false + scope.launch { + delay(16.milliseconds) + runCatching { navigationFocusRequester.requestFocus() } + } + } + BackHandler(onBack = closeAlerts) + MyAlertsPage( + notifications = displayedNotifications, + preferences = notificationState.preferences, + loading = notificationsLoading, + errorMessage = notificationsError, + onRetry = { + if (!notificationsLoading) scope.launch { + notificationsLoading = true + notificationsError = null + runCatching { repo.getNotifications() } + .onSuccess { notificationState = it } + .onFailure { notificationsError = friendlyEmbyError(it) } + notificationsLoading = false + } + }, + // The seen toggle, and the only thing that moves a row between Inbox and + // Seen — nothing is marked read merely by being looked at any more, because + // with the two halves split that would empty the Inbox under the remote. + // + // Optimistic and reversed on failure, like the dismissal below: the flag is + // the only thing that changed, so a row that sat unmoved while its request + // was in flight is one pressed a second time. The locally-held update notice + // has no server row to post, so its flag is written to this television's own + // settings instead — it is a fact about the APK on this set. + onToggleSeen = onToggleSeen@{ notification -> + val markingSeen = notification.unread + if (notification.id == MEMBY_UPDATE_NOTIFICATION_ID) { + scope.launch { ServiceLocator.settings.setUpdateAlertRead(markingSeen) } + return@onToggleSeen + } + val previousReadAt = notification.readAt + notificationState = notificationState.copy( + notifications = notificationState.notifications.map { + if (it.id == notification.id) { + it.copy(readAt = if (markingSeen) "now" else null) + } else { + it + } + }, + ) + scope.launch { + runCatching { + if (markingSeen) { + repo.markNotificationRead(notification.id) + } else { + repo.markNotificationUnread(notification.id) + } + }.onFailure { failure -> + notificationState = notificationState.copy( + notifications = notificationState.notifications.map { + if (it.id == notification.id) { + it.copy(readAt = previousReadAt) + } else { + it + } + }, + ) + notificationsError = friendlyEmbyError(failure) + } + } + }, + // Optimistic, for the reason "Dismiss all" beneath it already is: this + // page is judged entirely on emptying itself, and a row that stayed put + // while its request was in flight is a row pressed again — which on this + // page also re-aims where focus lands afterwards. A failure puts the row + // back where it was rather than quietly losing somebody's alert. + onDismiss = onDismiss@{ notification -> + if (notificationsMutationBusy) return@onDismiss + if (notification.id == MEMBY_UPDATE_NOTIFICATION_ID) { + scope.launch { ServiceLocator.settings.dismissUpdateAlert() } + return@onDismiss + } + notificationsMutationBusy = true + val previous = notificationState + notificationState = notificationState.copy( + notifications = notificationState.notifications.filterNot { + it.id == notification.id + }, + ) + scope.launch { + runCatching { repo.dismissNotification(notification.id) } + .onFailure { failure -> + notificationState = previous + notificationsError = friendlyEmbyError(failure) + } + notificationsMutationBusy = false + } + }, + // The gateway has no bulk route, so this is the same call per alert. The + // list is emptied optimistically: the page is judged on emptying itself, + // and a row that lingered while its request was in flight would be pressed + // a second time. + onDismissAll = { pending -> + if (notificationsMutationBusy) return@MyAlertsPage + notificationsMutationBusy = true + val previous = notificationState + // Only the half on screen. The page dismisses what it is showing, so + // emptying Seen must not also throw away an Inbox the viewer has not + // read — a bulk action nobody can see the extent of is one nobody presses. + val pendingIds = pending.map(UserNotification::id).toSet() + val dismissLocalUpdate = MEMBY_UPDATE_NOTIFICATION_ID in pendingIds && + settings.updateAlertVersion != null + notificationState = notificationState.copy( + notifications = notificationState.notifications.filterNot { + it.id in pendingIds + }, + ) + scope.launch { + if (dismissLocalUpdate) ServiceLocator.settings.dismissUpdateAlert() + // The local update notice has no server row, so asking the gateway to + // dismiss it would be one guaranteed failure per pass. + val failed = pendingIds.filter { id -> + id != MEMBY_UPDATE_NOTIFICATION_ID && + runCatching { repo.dismissNotification(id) }.isFailure + } + runCatching { repo.getNotifications() } + .onSuccess { notificationState = it } + .onFailure { failure -> + notificationState = previous + notificationsError = friendlyEmbyError(failure) + } + if (failed.isNotEmpty()) { + notificationsError = "Some alerts couldn’t be dismissed. Try again." + } + notificationsMutationBusy = false + } + }, + onClose = closeAlerts, + ) + } + quickMenuItem?.let { selected -> + LaunchedEffect(selected.id) { + quickMenuTrailerAvailable = selected.isRadarrOnly && + repo.getRadarrMovie(selected.id)?.trailerAvailable == true + } + val closeQuickActions: (Boolean) -> Unit = { originWillDisappear -> + quickMenuItem = null + quickMenuRowId = null + scope.launch { + // The overlay owns focus until it leaves composition. Restore the + // exact originating card after its focus node is available again. + delay(16.milliseconds) + if (originWillDisappear) { + requestFirstAvailableFocus(contentFocusRequester, navigationFocusRequester) + } else { + requestFirstAvailableFocus( + cardReturnFocusRequester, + contentFocusRequester, + navigationFocusRequester, + ) + } + } + } + BackHandler { closeQuickActions(false) } + FocusedQuickActionsOverlay( + homeViewModel = homeViewModel, + selected = selected, + onOpenDetails = { + quickMenuItem = null + detailsAiringNotice = null + detailsItem = it + }, + onSetFavorite = homeViewModel::setFavorite, + onSetPlayed = homeViewModel::setPlayed, + // Only for a film with no page to play from, and only when the gateway has + // a candidate to resolve — the same answer the detail page's button waits + // for, so the two can never disagree about whether there is a trailer. + onPlayTrailer = if (selected.isRadarrOnly && quickMenuTrailerAvailable) { + { + quickMenuItem = null + quickMenuRowId = null + playTrailer(selected) + } + } else { + null + }, + onRemoveFromContinueWatching = if ( + rows.firstOrNull { it.id == quickMenuRowId }?.kind == MediaRowKind.CONTINUE + ) { + { + quickMenuItem = null + quickMenuRowId = null + homeViewModel.removeFromContinueWatching(selected) + scope.launch { + delay(16.milliseconds) + val removalFocusRequester = if ( + contextualHeroItems.isNotEmpty() + ) { + heroRowEntryFocusRequester + } else { + contentFocusRequester + } + requestFirstAvailableFocus( + removalFocusRequester, + contentFocusRequester, + navigationFocusRequester, + ) + } + } + } else { + null + }, + rowTitle = rows.firstOrNull { it.id == quickMenuRowId }?.title, + rowPinned = quickMenuRowId in settings.homePinnedRows.decodeRowIds(), + onToggleRowPinned = quickMenuRowId?.let { rowId -> + { + val pinned = settings.homePinnedRows.decodeRowIds().toMutableSet() + if (!pinned.add(rowId)) pinned.remove(rowId) + scope.launch { + ServiceLocator.settings.setHomeRowPreferences( + settings.homeRowOrder.decodeRowIds(), + pinned, + settings.homeHiddenRows.decodeRowIds().toSet(), + ) + } + closeQuickActions(false) + } + }, + onHideRow = quickMenuRowId?.let { rowId -> + { + val hidden = settings.homeHiddenRows.decodeRowIds().toMutableSet() + hidden.add(rowId) + scope.launch { + ServiceLocator.settings.setHomeRowPreferences( + settings.homeRowOrder.decodeRowIds(), + settings.homePinnedRows.decodeRowIds().toSet(), + hidden, + ) + closeQuickActions(true) + } + } + }, + onMoveRow = quickMenuRowId?.let { rowId -> + { direction -> + val currentIds = rows.map(HomeBrowseRow::id).toMutableList() + val index = currentIds.indexOf(rowId) + val target = (index + direction).coerceIn(0, currentIds.lastIndex) + if (index >= 0 && target != index) { + currentIds.removeAt(index) + currentIds.add(target, rowId) + scope.launch { + ServiceLocator.settings.setHomeRowPreferences( + currentIds, + settings.homePinnedRows.decodeRowIds().toSet(), + settings.homeHiddenRows.decodeRowIds().toSet(), + ) + } + } + closeQuickActions(false) + } + }, + onClose = { closeQuickActions(false) }, + ) + } + resolvingItem?.let { item -> + // Back gets out of the wait. This overlay covers the whole screen while the + // gateway is asked for a stream, and against a server that has stopped + // answering that is the length of an HTTP timeout — long enough that a viewer + // reaches for the remote, and until now long enough that nothing answered + // them. Cancelling is safe: nothing has been launched and nothing reported. + BackHandler(onBack = abandonLaunch) + PlaybackLaunchOverlay( + item = item, + modifier = Modifier.fillMaxSize().zIndex(9f), + ) + } + ForYouNudgeBanner( + visible = showForYouNudge && + selectedDestination == BrowseDestination.HOME && + !settings.hasOpenedForYou && + liveMaintenance == null, + username = settings.username, + shortName = settings.shortName, + modifier = Modifier.align(Alignment.TopCenter), + ) + // Emby has stopped answering. Persistent, unlike the news bar below it, because + // it describes a state rather than an event: a set switched on midway through an + // outage was never told, and this is the only thing that says why nothing plays. + // Suppressed under maintenance, which owns the screen and is its own explanation. + EmbyOutageBanner( + suppressed = liveMaintenance != null, + modifier = Modifier.align(Alignment.TopCenter), + ) + // News about the library, not about the app: it sits above the rows but is + // suppressed whenever something more important owns the screen. + ServiceAlertBanner( + // The alert itself is collected inside the banner, so an arriving one does + // not recompose this whole function. Only the suppression conditions — both + // already read here for other reasons — cross the boundary. + // The outage bar takes the same strip of screen and outranks any + // announcement, including the one announcing this very outage. + suppressed = liveMaintenance != null || embyOutage != null, + // Flush to the top edge and spanning the rail: for its few seconds this is + // the top layer of the screen, the way a broadcast notice is. + modifier = Modifier.align(Alignment.TopCenter), + ) + } +} + + + + diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/HomeViewModel.kt b/app/src/main/java/com/ponzischeme89/memby/ui/HomeViewModel.kt index 29e22cb..ca74122 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/HomeViewModel.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/HomeViewModel.kt @@ -133,6 +133,8 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() { private val refreshMutex = Mutex() private val _state = MutableStateFlow(HomeUiState.from(repository.cachedHome())) val state: StateFlow = _state.asStateFlow() + private val _continueWatchingEnabled = MutableStateFlow(true) + private val _latestPlaybackPositions = MutableStateFlow(repository.localPlaybackPositions()) // HomeScreen is a very large composable, so reading the whole of [state] there meant // every emission invalidated the launcher: the slow- @@ -326,12 +328,17 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() { // A refresh can make a newly imported episode the next playable item for // an existing series ID. Never launch the pre-refresh negotiated session. repository.invalidatePlaybackPrefetch() - _state.update { it.copy(loading = HomeSection.entries.toSet(), hasRefreshError = false) } + val loading = if (_continueWatchingEnabled.value) { + HomeSection.entries.toSet() + } else { + HomeSection.entries.toSet() - HomeSection.CONTINUE + } + _state.update { it.copy(loading = loading, hasRefreshError = false) } if (repository.supportsBatchHome) { loadBatchHome() } else { coroutineScope { - launch { loadContinueWatching() } + if (_continueWatchingEnabled.value) launch { loadContinueWatching() } launch { loadFavorites() } launch { loadLatest() } } @@ -352,16 +359,26 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() { private suspend fun loadBatchHome() { runCatching { repository.getHome() } .onSuccess { home -> - val taggedHome = home.withAiringTodayTags() + val taggedHome = home + .withAiringTodayTags() + .withLatestPlaybackPositions(_latestPlaybackPositions.value) _state.update { current -> + val continueEnabled = _continueWatchingEnabled.value current.copy( - continueWatching = taggedHome.continueWatching.distinctItems(), + continueWatching = if (continueEnabled) { + taggedHome.continueWatching.distinctItems() + } else { + emptyList() + }, favorites = taggedHome.favorites.distinctItems(), latestMovies = taggedHome.latestMovies.distinctItems(), // Recommendation rows are built in the background by the gateway, // so an early response can arrive without them. Keeping the rows // we already had stops the strip flickering out and back in. - rows = mergeFreshHomeRows(current.rows, taggedHome.rows), + rows = mergeFreshHomeRows(current.rows, taggedHome.rows) + .let { rows -> + if (continueEnabled) rows else rows.withoutContinueWatching() + }, loading = emptySet(), hasRefreshError = taggedHome.partial, statusMessage = null, @@ -584,6 +601,36 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() { viewModelScope.launch { refreshWatching() } } + /** Applies the gateway's live feature switch without waiting for another client build. */ + fun setContinueWatchingEnabled(enabled: Boolean) { + if (_continueWatchingEnabled.value == enabled) return + _continueWatchingEnabled.value = enabled + if (!enabled) { + val removedItemIds = _state.value.continueWatching.mapTo(mutableSetOf(), BaseItem::id) + _state.value.rows + .filter { it.kind == "continue" || it.kind == "nextup" } + .flatMap(HomeRow::items) + .mapTo(removedItemIds, BaseItem::id) + _state.update { state -> + state.copy( + continueWatching = emptyList(), + rows = state.rows.withoutContinueWatching(), + loading = state.loading - HomeSection.CONTINUE, + ) + } + _focusedItem.update { focused -> + if (focused != null && focused.id in removedItemIds) { + initialFocusedItem(_state.value) + } else { + focused + } + } + viewModelScope.launch { persistCurrentHome() } + } else { + viewModelScope.launch { refreshWatching() } + } + } + fun removeFromContinueWatching(item: BaseItem) { val previous = _state.value _state.update { state -> @@ -636,11 +683,37 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() { * this is what stands in for it in the meantime, which is exactly the window a viewer * who exits and presses Play again is inside. * - * A completed title is left alone rather than pushed to its own end: what happens to it - * is that it leaves Continue Watching, which is the refresh's answer to give. + * A completed title is removed immediately and kept out if a stale refresh still + * returns it; a different Next Up episode from the same series remains eligible. */ private fun applyPlaybackPosition(position: PlaybackPosition) { - if (position.itemId.isBlank() || position.completed) return + if (position.itemId.isBlank()) return + _latestPlaybackPositions.update { current -> + val previous = current[position.itemId] + val latest = if ( + previous != null && + !previous.completed && + !position.completed && + previous.positionMs > position.positionMs + ) { + previous + } else { + position + } + LinkedHashMap(current).apply { + remove(position.itemId) + put(position.itemId, latest) + while (size > LATEST_PLAYBACK_POSITION_LIMIT) remove(entries.first().key) + } + } + if (position.completed) { + _state.update { state -> state.withoutContinueWatchingItem(position.itemId) } + _focusedItem.update { focused -> + if (focused?.id == position.itemId) initialFocusedItem(_state.value) else focused + } + viewModelScope.launch { persistCurrentHome() } + return + } val ticks = millisecondsToTicks(position.positionMs) updateUserData(position.itemId) { // Never backwards: a stop and the ten-second report before it can arrive in @@ -651,6 +724,7 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() { } private suspend fun refreshWatching() { + if (!_continueWatchingEnabled.value) return refreshMutex.withLock { _state.update { it.copy(loading = it.loading + HomeSection.CONTINUE) } if (repository.supportsBatchHome) { @@ -666,7 +740,9 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() { private suspend fun loadContinueWatching(clearLoading: Boolean = true) = load(HomeSection.CONTINUE, clearLoading, { repository.getContinueWatching() }) { state, items -> - state.copy(continueWatching = items) + state.copy( + continueWatching = items.withLatestPlaybackPositions(_latestPlaybackPositions.value), + ) } private suspend fun loadFavorites() = @@ -735,6 +811,7 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() { private const val ANALYTICS_FLUSH_INTERVAL_MS = 20_000L private const val HOME_RETRY_INITIAL_MS = 2_000L private const val HOME_RETRY_MAX_MS = 60_000L + private const val LATEST_PLAYBACK_POSITION_LIMIT = 32 private fun initialFocusedItem(state: HomeUiState): BaseItem? = state.continueWatching.firstOrNull() @@ -783,6 +860,54 @@ internal fun mergeFreshHomeRows( return (fresh + retainedRecommendations).sanitisedRows() } +private fun List.withoutContinueWatching(): List = + filterNot { it.kind == "continue" || it.kind == "nextup" } + +private fun HomeUiState.withoutContinueWatchingItem(itemId: String): HomeUiState = copy( + continueWatching = continueWatching.filterNot { it.id == itemId }, + rows = rows.map { row -> + if (row.kind == "continue" || row.kind == "nextup") { + row.copy(items = row.items.filterNot { it.id == itemId }) + } else { + row + } + }, +) + +/** + * Keeps the player's locally observed playhead authoritative over an older response while + * retaining the response's fresher row membership and metadata. + */ +internal fun List.withLatestPlaybackPositions( + positions: Map, +): List = mapNotNull { item -> + val latest = positions[item.id] ?: return@mapNotNull item + if (latest.completed) return@mapNotNull null + val latestTicks = millisecondsToTicks(latest.positionMs) + if (latestTicks <= (item.userData?.playbackPositionTicks ?: 0L)) { + item + } else { + item.copy( + userData = (item.userData ?: UserItemData()).copy( + playbackPositionTicks = latestTicks, + ), + ) + } +} + +private fun HomeSnapshot.withLatestPlaybackPositions( + positions: Map, +): HomeSnapshot = copy( + continueWatching = continueWatching.withLatestPlaybackPositions(positions), + rows = rows.map { row -> + if (row.kind == "continue" || row.kind == "nextup") { + row.copy(items = row.items.withLatestPlaybackPositions(positions)) + } else { + row + } + }, +) + private fun HomeRow.isEngineRecommendationRow(): Boolean = id == "recommended" || id.startsWith("similar:") || id.startsWith("curated:") diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/LaunchPreroll.kt b/app/src/main/java/com/ponzischeme89/memby/ui/LaunchPreroll.kt index 7fbb077..3c2e50e 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/LaunchPreroll.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/LaunchPreroll.kt @@ -77,7 +77,7 @@ internal object LaunchIntro { * - **The player is returned, not released.** It goes back to the process cache on dispose, * which is what lets the very next playback still open on a prepared instance. */ -@UnstableApi +@OptIn(UnstableApi::class) @Composable internal fun LaunchPrerollVideo( modifier: Modifier = Modifier, diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/MainActivity.kt b/app/src/main/java/com/ponzischeme89/memby/ui/MainActivity.kt index ccc5d21..df61b5c 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/MainActivity.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/MainActivity.kt @@ -1,269 +1,12 @@ package com.ponzischeme89.memby.ui -import com.ponzischeme89.memby.ui.theme.MembyIcon -import com.ponzischeme89.memby.ui.theme.mark -import android.content.Intent -import android.os.Build import android.os.Bundle -import android.os.SystemClock -import android.provider.Settings as AndroidSettings -import android.text.format.DateFormat -import android.widget.Toast import androidx.activity.ComponentActivity -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.compose.BackHandler import androidx.activity.compose.setContent -import androidx.activity.result.contract.ActivityResultContracts -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.clickable -import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.focusGroup -import androidx.compose.foundation.focusable -import androidx.compose.foundation.Image -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.offset -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.lazy.LazyRow -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.LazyListState -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.text.BasicTextField -import androidx.compose.foundation.text.KeyboardActions -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.runtime.Composable -import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateMapOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.rememberUpdatedState -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.saveable.listSaver -import androidx.compose.runtime.setValue -import androidx.compose.runtime.key -import androidx.compose.runtime.withFrameNanos -import androidx.compose.animation.core.RepeatMode -import androidx.compose.animation.core.LinearEasing -import androidx.compose.animation.core.Spring -import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.animation.core.animateDpAsState -import androidx.compose.animation.core.animateFloat -import androidx.compose.animation.core.infiniteRepeatable -import androidx.compose.animation.core.rememberInfiniteTransition -import androidx.compose.animation.core.spring -import androidx.compose.animation.core.tween -import androidx.compose.animation.animateColorAsState -import androidx.compose.ui.Alignment -import androidx.compose.ui.ExperimentalComposeUiApi -import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusProperties -import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.focus.onFocusChanged -import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.drawBehind -import androidx.compose.ui.draw.drawWithContent -import androidx.compose.ui.geometry.CornerRadius -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.geometry.Size -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.ImageBitmap -import androidx.compose.ui.graphics.drawscope.Stroke -import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.SolidColor -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalConfiguration -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.semantics.contentDescription -import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.text.input.ImeAction -import androidx.compose.ui.text.input.PasswordVisualTransformation -import androidx.compose.ui.text.input.VisualTransformation -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.IntOffset -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import androidx.compose.ui.zIndex -import androidx.lifecycle.compose.LifecycleResumeEffect -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.repeatOnLifecycle -import androidx.lifecycle.viewmodel.compose.viewModel -import coil.compose.AsyncImage -import coil.imageLoader -import coil.request.ImageRequest -import com.ponzischeme89.memby.R -import com.ponzischeme89.memby.BuildConfig import com.ponzischeme89.memby.ServiceLocator -import com.ponzischeme89.memby.data.Settings -import com.ponzischeme89.memby.data.EmbyProfile -import com.ponzischeme89.memby.data.friendlyEmbyError -import com.ponzischeme89.memby.data.analytics.PlaybackJourney -import com.ponzischeme89.memby.data.analytics.playbackEntryPointFor -import com.ponzischeme89.memby.data.model.BaseItem -import com.ponzischeme89.memby.data.model.MembyViewer -import com.ponzischeme89.memby.data.model.GatewayUpdate -import com.ponzischeme89.memby.data.model.HomeRow -import com.ponzischeme89.memby.data.model.MyShow -import com.ponzischeme89.memby.data.model.NotificationsResponse -import com.ponzischeme89.memby.data.model.RecommendationOnboarding -import com.ponzischeme89.memby.data.model.UserNotification -import com.ponzischeme89.memby.data.model.TrailerPlaybackRequest -import com.ponzischeme89.memby.data.model.RecommendationPerson -import com.ponzischeme89.memby.data.ServerConfig -import com.ponzischeme89.memby.data.remoteconfig.MembyRemoteConfig -import com.ponzischeme89.memby.ui.alerts.MyAlertsPage -import com.ponzischeme89.memby.ui.detail.AiringNotice -import com.ponzischeme89.memby.ui.detail.airingNoticeFor -import com.ponzischeme89.memby.ui.detail.scheduleMovieStub -import com.ponzischeme89.memby.ui.detail.scheduleSeriesStub -import com.ponzischeme89.memby.ui.calendar.CalendarScreen -import com.ponzischeme89.memby.ui.requests.RequestsScreen -import com.ponzischeme89.memby.ui.requests.RequestsViewModel -import com.ponzischeme89.memby.ui.requests.RequestsViewModelFactory -import com.ponzischeme89.memby.ui.genre.GenreBrowseScreen -import com.ponzischeme89.memby.ui.player.PlayerActivity -import com.ponzischeme89.memby.ui.player.PrerollPreloader import com.ponzischeme89.memby.performance.PerformanceMonitor -import com.ponzischeme89.memby.performance.StartupTrace -import com.ponzischeme89.memby.ui.search.SearchScreen -import com.ponzischeme89.memby.ui.settings.SettingsSheet -import com.ponzischeme89.memby.ui.whatsnew.WhatsNewDecision -import com.ponzischeme89.memby.ui.seasonal.SeasonalDecorations -import com.ponzischeme89.memby.ui.whatsnew.whatsNewDecision -import com.ponzischeme89.memby.ui.theme.MembyAccent -import com.ponzischeme89.memby.ui.theme.MembyAccentBright -import com.ponzischeme89.memby.ui.theme.MembyAccentInk -import com.ponzischeme89.memby.ui.theme.MembyAccentMuted -import com.ponzischeme89.memby.ui.theme.MembyChipCorner -import com.ponzischeme89.memby.ui.theme.MembyControlSurface -import com.ponzischeme89.memby.ui.theme.MembyControlSurfaceRaised -import com.ponzischeme89.memby.ui.theme.MembyDisabledText -import com.ponzischeme89.memby.ui.theme.MembyMutedText -import com.ponzischeme89.memby.ui.theme.MembyOnSurface -import com.ponzischeme89.memby.ui.theme.MembyOutline -import com.ponzischeme89.memby.ui.theme.MembyQuietText -import com.ponzischeme89.memby.ui.theme.MembySplashTint -import com.ponzischeme89.memby.ui.viewers.MAX_SHADOW_VIEWERS -import com.ponzischeme89.memby.ui.viewers.ViewerManageScreen -import com.ponzischeme89.memby.ui.viewers.ViewerNameEntry -import com.ponzischeme89.memby.ui.viewers.ViewerNameTarget -import com.ponzischeme89.memby.ui.viewers.ViewerPicker -import com.ponzischeme89.memby.ui.viewers.shouldOfferViewerPicker -import com.ponzischeme89.memby.ui.viewers.viewerNameFor -import com.ponzischeme89.memby.ui.theme.MembySurface -import com.ponzischeme89.memby.ui.theme.MembySurfaceRaised +import com.ponzischeme89.memby.ui.player.PrerollPreloader import com.ponzischeme89.memby.ui.theme.MembyTheme -import com.ponzischeme89.memby.ui.setup.SignInContent -import com.ponzischeme89.memby.update.InstallPermission -import com.ponzischeme89.memby.update.RequiredUpdateSignal -import com.ponzischeme89.memby.update.nextUpdateCheckDelay -import com.ponzischeme89.memby.update.requiredUpdateSatisfied -import com.ponzischeme89.memby.update.ServerUpdateService -import androidx.tv.material3.Button -import androidx.tv.material3.Card -import androidx.tv.material3.Icon -import androidx.tv.material3.Text -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.currentCoroutineContext -import kotlinx.coroutines.ensureActive -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.launch -import kotlinx.coroutines.TimeoutCancellationException -import kotlinx.coroutines.withTimeout -import kotlinx.coroutines.withTimeoutOrNull -import java.util.Date -import java.util.Calendar -import java.time.Instant - -internal const val MEMBY_UPDATE_NOTIFICATION_ID = -1L - -internal fun membyUpdateNotification( - version: String?, - occurredAt: String?, - read: Boolean, -): UserNotification? { - val installed = version?.trim()?.takeIf(String::isNotEmpty) ?: return null - return UserNotification( - id = MEMBY_UPDATE_NOTIFICATION_ID, - kind = "app-update", - title = "Memby updated", - message = "This TV is now running Memby $installed.", - eventAt = occurredAt, - createdAt = occurredAt.orEmpty(), - readAt = if (read) occurredAt ?: "read" else null, - ) -} - -/** - * The followed show a press stands for, as much of it as the card already knows. - * - * The button flips against this rather than against the gateway's answer, which is the - * whole list decorated with Sonarr's lifecycle for every entry on it. Everything Sonarr - * would fill in is left at its default — "Not found", "Unknown" — because that is honestly - * what this television knows for the moment, and the real row replaces it as soon as the - * save returns. The same promise `scheduleSeriesStub` makes about a detail page. - */ -private fun myShowStub(item: BaseItem): com.ponzischeme89.memby.data.model.MyShow = - com.ponzischeme89.memby.data.model.MyShow( - itemId = item.id, - title = item.name, - year = item.productionYear, - imageTag = item.imageTags["Primary"].orEmpty(), - ) - -/** Leaves enough of a TV viewport for a complete shelf, including card title metadata. */ -internal fun homeHeaderHeight(viewportHeight: Dp, showHero: Boolean): Dp = - if (showHero) { - (viewportHeight * 0.46f).coerceIn(232.dp, 300.dp) - } else { - (viewportHeight * 0.42f).coerceIn(220.dp, 300.dp) - } - -private val LazyListStateMapSaver = listSaver, Any>( - save = { states -> - states.flatMap { (key, state) -> - listOf(key, state.firstVisibleItemIndex, state.firstVisibleItemScrollOffset) - } - }, - restore = { values -> - mutableMapOf().apply { - values.chunked(3).forEach { saved -> - put( - saved[0] as String, - LazyListState(saved[1] as Int, saved[2] as Int), - ) - } - } - }, -) class MainActivity : ComponentActivity() { private var homeInteractiveReported = false @@ -295,5019 +38,8 @@ class MainActivity : ComponentActivity() { override fun onStart() { super.onStart() // Also runs when playback returns to Home. A used preroll player is parked while - // the programme runs so it does not retain a second decoder, then prepared again + // the program runs so it does not retain a second decoder, then prepared again // during the launcher's next idle window. PrerollPreloader.start(this) } } - -/** - * How long the launcher will wait for the gateway's onboarding verdict before opening the - * home screen anyway. Short on purpose: this is the one request left on the signed-in - * startup path, and everything it gates is already on disk. - */ -private const val ONBOARDING_CHECK_TIMEOUT_MS = 2_500L -private const val PLAYBACK_SOURCE_RESOLUTION_TIMEOUT_MS = 15_000L -private const val UPDATE_CHECK_TIMEOUT_MS = 2_500L -// Update policy is live operator state, just like maintenance. The verdict is an in-memory -// gateway read, so keeping this close to the maintenance poll means a set that was already -// open sees a newly-required release promptly instead of as much as an hour later. -private const val UPDATE_CHECK_INTERVAL_MS = 30_000L - -/** - * How long to wait before asking again while the gateway has this build retired. - * - * There is no attempt budget any more, and that is the point: a television in this state - * has been signed out and every screen it could otherwise be shown is one the gateway will - * refuse, so giving up used to mean handing the viewer a sign-in form as the *final* - * answer. It asks quickly at first, in case the verdict is merely a moment behind the - * refusal, then settles onto [UPDATE_REQUIRED_BACKOFF_MS] so an unreachable gateway is not - * polled every few seconds for as long as the set is switched on. - */ -private const val UPDATE_REQUIRED_RETRY_MS = 5_000L -private const val UPDATE_REQUIRED_BACKOFF_MS = 60_000L -private const val UPDATE_REQUIRED_FAST_ATTEMPTS = 4 - -/** - * How long one row-to-row focus move may hold the D-pad before the launcher assumes - * nothing is going to answer for it. - * - * Comfortably longer than the scroll it waits on plus the destination row's own focus - * retries, so an ordinary move is never cut short — and short enough that a move nothing - * completes costs a press rather than the rest of the session's vertical navigation. - */ -private const val ROW_FOCUS_MOVE_TIMEOUT_MS = 1_200L - -@Composable -private fun AppRoot( - remoteConfig: MembyRemoteConfig, - onCloseSettings: () -> Unit, - onHomeInteractive: () -> Unit, -) { - val repo = ServiceLocator.repository - val context = LocalContext.current - // This client intentionally has no token provider and no dependency on the active - // profile. Updates are an app lifecycle concern, checked before login/session work. - val updateService = remember { ServerUpdateService.create(ServerConfig.gatewayUrl) } - var appUpdate by remember { mutableStateOf(null) } - var initialUpdateCheckComplete by remember { mutableStateOf(false) } - // The refusal as it arrives, before the write of it has come back round through the - // settings flow. Both halves are needed: this one closes the window in which the - // sign-out has already landed and the persisted flag has not, and the persisted one - // (below) is what remembers the refusal across a restart. - var reportedRequiredUpdate by remember { mutableStateOf(null) } - LaunchedEffect(Unit) { - RequiredUpdateSignal.required.collect { - // Same rule the persisted copy applies: a refusal this build already satisfies - // describes an APK that is no longer here. - if (!requiredUpdateSatisfied(BuildConfig.VERSION_NAME, it)) { - reportedRequiredUpdate = it - } - } - } - // Wakes the check loop: a refusal arriving, or the viewer pressing Try again on the - // retired-build screen. Conflated because the only thing either says is "ask now". - val updateWake = remember { Channel(Channel.CONFLATED) } - // Held for the length of Memby's opening clip plus its pause, once per process. Not a - // rememberSaveable: an activity recreated behind the viewer (returning from the TV home - // screen, a configuration change) is not an app launch, and LaunchIntro carries that - // across it. - var introHolding by remember { mutableStateOf(!LaunchIntro.played) } - LaunchedEffect(Unit) { - if (!introHolding) return@LaunchedEffect - // The clip is decoration; the rows are the app. Whatever the player is doing, this - // is the longest it may ever stand in front of them. - kotlinx.coroutines.delay(LAUNCH_INTRO_MAX_MS) - LaunchIntro.played = true - introHolding = false - } - var dismissedUpdateVersion by rememberSaveable { mutableStateOf(null) } - var confirmingExit by rememberSaveable { mutableStateOf(false) } - // SettingsStore starts eagerly in Application.onCreate. Reuse its in-memory value when - // Android recreates this activity after the viewer returns from the TV home screen; - // starting from null needlessly painted "Opening Memby..." while the replayed value - // made a round trip through a new collector. - val initialSettings = ServiceLocator.settings.current - var settings by remember { mutableStateOf(initialSettings) } - var addingProfile by rememberSaveable { mutableStateOf(false) } - var startingFirstRun by rememberSaveable { mutableStateOf(false) } - var installPermissionHandled by rememberSaveable { mutableStateOf(false) } - // Collected here rather than deeper because it decides which screen AppRoot shows at - // all. It is one boolean off the poll every TV already makes. - val installPermissionPrompt by ServiceLocator.maintenance.installPermissionPrompt - .collectAsStateWithLifecycle() - var validatedToken by remember { mutableStateOf(null) } - var recommendationOnboarding by remember { - mutableStateOf( - initialSettings - ?.takeIf { it.hasCompletedOnboarding || !it.homeCacheJson.isNullOrBlank() } - ?.let { RecommendationOnboarding(completed = true) }, - ) - } - var onboardingToken by remember { mutableStateOf(null) } - LaunchedEffect(updateService) { - // Every refusal wakes the loop, including a repeat of a version already handled: - // the 401 that retires the session and the 426 that refuses the sign-in after it - // name the same version, and the second is the one a viewer is standing in front - // of. Filtering those out is what left a television on a form it could not get - // through until the next hourly check. - launch { RequiredUpdateSignal.required.collect { updateWake.trySend(Unit) } } - var firstCheck = true - // How many checks in a row have been made because this build is retired. Loop - // state rather than app state: nothing outside this effect decides when to ask. - var requiredAttempts = 0 - // A launch timeout used to be treated like a successful "nothing to say" verdict, - // which put the next attempt an hour away. A television's first DNS/TLS connection - // is often the slowest one it makes, so failed or timed-out checks retry on the same - // bounded schedule as a retired build until the gateway gives a real answer. - var failedAttempts = 0 - while (true) { - // What the gateway has already said about this build, read from disk rather - // than from this loop's memory — the refusal is commonly one process old. - val retired = ServiceLocator.settings.current - ?.requiredUpdateVersion?.takeIf { it.isNotBlank() } - ?: reportedRequiredUpdate - val result = if (firstCheck && retired == null) { - // A disconnected server must not strand an otherwise usable TV at boot. - // A retired one is not usable, so it waits for the real answer instead of - // being hurried past it onto a sign-in form. - withTimeoutOrNull(UPDATE_CHECK_TIMEOUT_MS) { updateService.check() } - } else { - updateService.check() - } - result?.onSuccess { decision -> - appUpdate = decision?.takeUnless { - it.isOptional && dismissedUpdateVersion == it.version - } - // Only the gateway may withdraw a refusal, and this is it answering. A - // verdict that does not require an update is the evidence that whatever - // retired this build no longer applies — a failed check is not, which is - // why nothing outside this branch clears the flag. - if (decision?.isMandatory != true) { - reportedRequiredUpdate = null - RequiredUpdateSignal.clear() - ServiceLocator.settings.clearRequiredUpdate() - } - } - if (firstCheck) { - initialUpdateCheckComplete = true - firstCheck = false - } - failedAttempts = if (result == null || result.isFailure) { - failedAttempts + 1 - } else { - 0 - } - requiredAttempts = if (retired != null && appUpdate == null) { - requiredAttempts + 1 - } else { - 0 - } - // Whichever comes first: the next scheduled check, or something asking for one - // now. While this build is retired and no verdict has arrived, that schedule is - // seconds rather than an hour — the alternative is a television sitting on a - // screen it cannot leave, waiting on an answer nobody has asked for. - val wait = nextUpdateCheckDelay( - requiredAttempts = requiredAttempts, - failedAttempts = failedAttempts, - fastAttempts = UPDATE_REQUIRED_FAST_ATTEMPTS, - fastRetryMillis = UPDATE_REQUIRED_RETRY_MS, - backoffMillis = UPDATE_REQUIRED_BACKOFF_MS, - regularPollMillis = UPDATE_CHECK_INTERVAL_MS, - ) - withTimeoutOrNull(wait) { updateWake.receive() } - } - } - LaunchedEffect(repo) { - repo.settingsFlow.collect { settings = it } - } - LaunchedEffect(settings?.token, initialUpdateCheckComplete, appUpdate?.version) { - 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 (!valid) { - repo.invalidateSession() - } - } - LaunchedEffect( - settings?.token, - settings?.userId, - initialUpdateCheckComplete, - appUpdate?.version, - ) { - if (!initialUpdateCheckComplete || appUpdate != null) return@LaunchedEffect - val loaded = settings ?: return@LaunchedEffect - val token = loaded.token?.takeIf { loaded.isSignedIn && it.isNotBlank() } - ?: run { - recommendationOnboarding = null - onboardingToken = null - return@LaunchedEffect - } - // The guard stops a settings emission that did not change the session from asking - // the gateway again. It must never be able to leave the answer unresolved: this - // effect is cancelled and restarted whenever the token or user id changes, so a - // restart that arrived while the previous run was still in flight would otherwise - // find its own token already recorded, return early, and strand the launcher on - // the loading screen for good. - if (onboardingToken == token && recommendationOnboarding != null) return@LaunchedEffect - onboardingToken = token - // Existing viewers can open their cached home while this small control-plane - // request runs. The server still gets the final say: an administrator may have - // queued a prompt since this TV last saw the profile, or reset a completed setup. - // A genuinely new profile has no useful home to show and waits for the answer. - if (!loaded.hasCompletedOnboarding && loaded.homeCacheJson.isNullOrBlank()) { - recommendationOnboarding = null - } - // This request decides which screen opens, so the launcher waits on it — which - // makes its worst case the app's worst case. Left unbounded that is the gateway - // client's connect plus read timeout, half a minute of "Opening Memby…" on a TV - // whose cached rows were ready the whole time. Past this budget the viewer goes - // to their rows and a profile that genuinely has not onboarded is asked again on - // the next launch, which is the cheaper mistake by a wide margin. - val answered = runCatching { - withTimeoutOrNull(ONBOARDING_CHECK_TIMEOUT_MS) { repo.getRecommendationOnboarding() } - }.getOrNull() - // runCatching swallows cancellation too, so say so explicitly: a run abandoned - // because the session changed must not write its verdict over the one the - // restarted effect is about to produce. - currentCoroutineContext().ensureActive() - // Only a completion the gateway actually reported is remembered. Falling back to - // "completed" keeps a viewer out of onboarding during an outage, but persisting - // that guess would silently retire the rating screen for someone who has never - // seen it, on nothing more than one slow response. - if (answered?.completed == true) { - loaded.userId?.let { ServiceLocator.settings.markOnboardingCompleted(it) } - } - // Do not trap an existing viewer behind onboarding during a gateway outage. - recommendationOnboarding = answered ?: RecommendationOnboarding(completed = true) - } - - // Whether this TV has already been told about the build it is running. Keyed - // on the recorded version and the session, because a fresh install records the current - // version during setup and a sign-in is what turns "waiting" into "announce it now". - LaunchedEffect(settings?.whatsNewSeenVersion, settings?.isSignedIn) { - val loaded = settings ?: return@LaunchedEffect - val decision = whatsNewDecision( - installedVersion = BuildConfig.VERSION_NAME, - seenVersion = loaded.whatsNewSeenVersion, - isSignedIn = loaded.isSignedIn, - ) - 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, - updateAlertAt = Instant.now().toString(), - ) - currentCoroutineContext().ensureActive() - Toast.makeText( - context, - context.getString(R.string.app_updated_to_version, decision.version), - Toast.LENGTH_LONG, - ).show() - } - is WhatsNewDecision.MarkSeen -> - ServiceLocator.settings.markWhatsNewSeen(decision.version) - WhatsNewDecision.Nothing -> Unit - } - } - - Box(Modifier.fillMaxSize().background(MembySurface)) { - val loaded = settings - // The gateway has refused this build. Until it answers with a verdict — or with a - // verdict that no longer requires one — this is the only screen the television may - // show: its session is gone, and sign-in, profiles and the launcher are all things - // the server will refuse. - val retiredVersion = loaded?.requiredUpdateVersion?.takeIf { it.isNotBlank() } - ?: reportedRequiredUpdate - // The three states below that mean "still opening" used to be three separate calls - // to MembyLoadingScreen, and Compose identifies a composable by where it is called - // from — so passing between them disposed the screen and built it again. That was - // only a wasted animation before; now it hands the pre-roll player back and borrows - // it a moment later, twice, during the one stretch of a launch that is busiest. - // Hoisted to one call site, the screen and its clip survive the whole cold start. - val openingQuoteStyle = when { - // A required update is a service gate, not launcher content. Once the gateway - // answers, uncover it immediately instead of making the viewer finish the - // decorative opening clip first. Optional prompts may wait for the intro. - appUpdate?.isMandatory == true -> null - // Memby's own clip runs to its end before anything else is drawn. It is the one - // thing the app owns and it was, in practice, never seen: the launcher uncovered - // it whenever it happened to be ready, which on a warm start was a fraction of a - // second. LAUNCH_INTRO_MAX_MS below is the outer bound — the rows are never more - // than that away, whatever the player does. - introHolding -> loaded?.welcomeQuoteStyle.orEmpty() - !initialUpdateCheckComplete -> loaded?.welcomeQuoteStyle.orEmpty() - appUpdate != null -> null - // Retired, verdict not here yet. The retired-build screen takes the frame - // rather than the opening screen: it says what has happened and offers the one - // thing there is to do about it, where a loading screen with nothing behind it - // reads as a television that has stopped working. - retiredVersion != null -> null - loaded == null -> ServiceLocator.settings.current?.welcomeQuoteStyle.orEmpty() - // Ordered exactly as the screens below are: adding a profile outranks waiting - // on the onboarding answer, and swapping the two would put the loading screen - // over a sign-in form somebody is halfway through. - addingProfile -> null - loaded.isSignedIn && recommendationOnboarding == null -> - loaded.welcomeQuoteStyle.orEmpty() - else -> null - } - if (openingQuoteStyle != null) { - MembyLoadingScreen( - quoteStyle = openingQuoteStyle, - onIntroFinished = { - LaunchIntro.played = true - introHolding = false - }, - ) - } - when { - openingQuoteStyle != null -> Unit - appUpdate != null -> UpdateScreen( - update = appUpdate!!, - onDismiss = { - val current = appUpdate ?: return@UpdateScreen - if (!current.isMandatory) { - dismissedUpdateVersion = current.version - appUpdate = null - } - }, - ) - // Ahead of every remaining screen, and of the null-settings case: a television - // whose build has been retired must not reach sign-in, profiles or the - // launcher, whatever else is true of it. - retiredVersion != null -> RetiredBuildScreen( - requiredVersion = retiredVersion, - onRetry = { updateWake.trySend(Unit) }, - ) - loaded == null -> Unit - addingProfile -> SetupScreen( - onCancel = { addingProfile = false }, - onSignedIn = { addingProfile = false }, - ) - loaded.isSignedIn && recommendationOnboarding?.completed == false && - recommendationOnboarding?.prompted == true -> { - RecommendationOnboardingScreen( - onboarding = recommendationOnboarding!!, - onSkip = { - // Skip is for this visit only. Do not mark or save completion: the - // gateway may offer onboarding again on a later launch. - recommendationOnboarding = recommendationOnboarding!!.copy(completed = true) - }, - onComplete = { ratings, actors, actresses, directors -> - repo.saveRecommendationRatings(ratings, actors, actresses, directors) - // Record it locally as well, so this profile's next cold start - // skips the gateway check entirely. - loaded.userId?.let { ServiceLocator.settings.markOnboardingCompleted(it) } - recommendationOnboarding = recommendationOnboarding!!.copy( - completed = true, - ratings = ratings, - ) - }, - ) - } - // Pushed by the operator, and only ever seen by a TV that actually lacks the - // permission — the condition clears itself the moment it is granted. It sits - // after sign-in rather than before because the household this matters to is the - // one already using Memby: those sets were installed from Downloader, were never - // asked, and cannot take an update today. - loaded.isSignedIn && !installPermissionHandled && installPermissionPrompt && - !InstallPermission.granted(LocalContext.current) -> - InstallPermissionScreen(onContinue = { installPermissionHandled = true }) - loaded.isSignedIn -> { - BackHandler { - if (loaded.confirmExitMemby) confirmingExit = true else onCloseSettings() - } - key(loaded.userId, loaded.serverUrl) { - HomeScreen(settings = loaded, remoteConfig = remoteConfig) - } - LaunchedEffect(loaded.userId, loaded.serverUrl) { - // Report after the launcher has submitted a frame, not merely when its - // composable was entered. StartupTimingMetric can now distinguish the - // quick opening surface from the point at which D-pad content is live. - withFrameNanos { } - onHomeInteractive() - } - // Snow, bats or blossom for the few days a year a season is on, over the - // launcher and nowhere else. Not over playback — a film is the one thing - // nothing may drift across — and not over the settings sheet, which is a - // page of small text. It is collected here rather than inside HomeScreen so - // an arriving theme cannot invalidate the rows: this is a sibling node, and - // the only thing that recomposes when December starts. - SeasonalDecorations( - decoration = ServiceLocator.themeSync.theme - .collectAsStateWithLifecycle().value?.decoration.orEmpty(), - ) - } - loaded.profiles.isNotEmpty() -> ProfileEntryScreen( - settings = loaded, - onAddProfile = { addingProfile = true }, - ) - // Asked before sign-in, on a fresh install only: this is the one moment somebody - // is certainly in front of the TV and expecting to be asked things. Skippable — - // see InstallPermissionScreen for why it must never gate a new install. - startingFirstRun && !installPermissionHandled && - !InstallPermission.granted(LocalContext.current) -> - InstallPermissionScreen(onContinue = { installPermissionHandled = true }) - startingFirstRun -> SetupScreen( - onCancel = { startingFirstRun = false }, - onSignedIn = { startingFirstRun = false }, - ) - else -> FirstRunScreen(onGetStarted = { startingFirstRun = true }) - } - if (confirmingExit) { - ExitMembyConfirmation( - onStay = { confirmingExit = false }, - onExit = onCloseSettings, - ) - } - } -} - -// ExitMembyConfirmation lives in ui/ExitConfirmation.kt — it is drawn from the design -// tokens rather than from raw Material buttons, and is screenshot-tested on its own. - -@androidx.media3.common.util.UnstableApi -@Composable -private fun MembyLoadingScreen( - quoteStyle: String? = null, - onIntroFinished: () -> Unit = {}, -) { - val welcomeQuote = remember(quoteStyle) { randomWelcomeQuote(quoteStyle) } - // Whether Memby's own clip is actually on screen behind this. Set from the player's - // first rendered frame rather than from having asked it to play, so the logo only gets - // out of the way once there is something to get out of the way for. - var prerollVisible by remember { mutableStateOf(false) } - // The clip has played its length and been held. Everything below it comes back. - var introDone by remember { mutableStateOf(false) } - // ...and a moment later the video node goes, which is what hands the player back to the - // process cache. Removing it on the same frame as the fade would cut to black. - var introRemoved by remember { mutableStateOf(false) } - val prerollAlpha by animateFloatAsState( - targetValue = if (prerollVisible && !introDone) 1f else 0f, - animationSpec = tween(420), - label = "cold-start-preroll-alpha", - ) - // While the clip runs it is the whole screen: the pulsing mark and the welcome line are - // the *waiting* screen, and printing them over a four-second sting says two things at - // once. They fade in behind it if the app is still opening when it ends. - val chromeAlpha by animateFloatAsState( - targetValue = if (prerollVisible && !introDone) 0f else 1f, - animationSpec = tween(420), - label = "cold-start-chrome-alpha", - ) - LaunchedEffect(introDone) { - if (!introDone) return@LaunchedEffect - kotlinx.coroutines.delay(460L) - introRemoved = true - } - // Not keyed on the quote style: the headline says what the app is doing, and rerolling - // it when the settings flow arrives with a tone would change the line under the viewer - // mid-launch. Once per appearance of this screen is the intent. - val headline = remember { randomLoadingHeadline() } - val transition = rememberInfiniteTransition(label = "cold-start") - val pulse by transition.animateFloat( - initialValue = 0.96f, - targetValue = 1.04f, - animationSpec = infiniteRepeatable( - animation = tween(750), - repeatMode = RepeatMode.Reverse, - ), - label = "cold-start-logo-pulse", - ) - val glow by transition.animateFloat( - initialValue = 0.35f, - targetValue = 0.78f, - animationSpec = infiniteRepeatable( - animation = tween(750), - repeatMode = RepeatMode.Reverse, - ), - label = "cold-start-glow", - ) - - Box( - modifier = Modifier - .fillMaxSize() - .background( - Brush.radialGradient( - colors = listOf(MembySplashTint, MembySurface), - radius = 1_100f, - ), - ), - contentAlignment = Alignment.Center, - ) { - if (!introRemoved) { - LaunchPrerollVideo( - modifier = Modifier - .fillMaxSize() - .graphicsLayer { alpha = prerollAlpha }, - onVisible = { prerollVisible = true }, - onFinished = { - if (!introDone) { - introDone = true - onIntroFinished() - } - }, - ) - } - // The scrim that used to hold this copy legible over the clip is gone with the - // overlap it existed for: the waiting screen and the clip no longer share the frame, - // so darkening Memby's own mark by four fifths would be for nobody's benefit. - Column( - modifier = Modifier.graphicsLayer { alpha = chromeAlpha }, - verticalArrangement = Arrangement.spacedBy(14.dp), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - // The clip is Memby's own mark moving; the still logo above it would be the - // same thing said twice, so the whole column gives way while it runs. - Image( - painter = painterResource(R.drawable.emby_logo), - contentDescription = "Memby", - modifier = Modifier - .width(82.dp) - .height(68.dp) - .graphicsLayer { - scaleX = pulse - scaleY = pulse - alpha = 0.82f + (glow * 0.18f) - }, - ) - Text( - headline, - color = Color.White.copy(alpha = 0.88f), - fontSize = 17.sp, - fontWeight = FontWeight.Medium, - ) - Text( - welcomeQuote, - color = Color.White.copy(alpha = 0.58f), - fontSize = 13.sp, - maxLines = 2, - textAlign = TextAlign.Center, - modifier = Modifier.width(440.dp), - ) - Box( - Modifier - .width(92.dp) - .height(2.dp) - .clip(CircleShape) - .background(Color.White.copy(alpha = 0.10f)), - ) { - // Drawn, not measured: `fillMaxWidth(glow)` would recompose this screen - // every frame — during cold start, which is the one moment the device has - // nothing to spare. - Box( - Modifier - .fillMaxWidth() - .height(2.dp) - .drawBehind { - drawRoundRect( - color = MembyAccent, - size = Size(size.width * glow, size.height), - cornerRadius = CornerRadius(size.height / 2f), - ) - }, - ) - } - } - } -} - -@Composable -internal fun FirstRunScreen(onGetStarted: () -> Unit) { - val getStartedFocus = remember { FocusRequester() } - var entered by remember { mutableStateOf(false) } - val contentAlpha by animateFloatAsState( - targetValue = if (entered) 1f else 0f, - animationSpec = tween(420), - label = "first-run-content-alpha", - ) - val contentOffset by animateDpAsState( - targetValue = if (entered) 0.dp else 18.dp, - animationSpec = tween(420), - label = "first-run-content-offset", - ) - val ambient = rememberInfiniteTransition(label = "first-run-ambient") - val logoScale by ambient.animateFloat( - initialValue = 0.97f, - targetValue = 1.04f, - animationSpec = infiniteRepeatable( - animation = tween(1_800), - repeatMode = RepeatMode.Reverse, - ), - label = "first-run-logo-scale", - ) - val haloAlpha by ambient.animateFloat( - initialValue = 0.10f, - targetValue = 0.22f, - animationSpec = infiniteRepeatable( - animation = tween(1_800), - repeatMode = RepeatMode.Reverse, - ), - label = "first-run-halo", - ) - - LaunchedEffect(Unit) { - entered = true - kotlinx.coroutines.delay(180L) - runCatching { getStartedFocus.requestFocus() } - } - - Box( - modifier = Modifier - .fillMaxSize() - .background( - Brush.linearGradient( - colors = listOf( - MembySurface, - MembySplashTint, - MembySurface, - ), - ), - ), - ) { - Box( - modifier = Modifier - .align(Alignment.CenterEnd) - .offset(x = 70.dp) - .size(560.dp) - .graphicsLayer { alpha = haloAlpha } - .background( - Brush.radialGradient( - colors = listOf(MembyAccent, Color.Transparent), - ), - CircleShape, - ), - ) - - Row( - modifier = Modifier - .fillMaxSize() - .padding(horizontal = 88.dp, vertical = 64.dp) - .graphicsLayer { alpha = contentAlpha } - .offset { IntOffset(x = 0, y = contentOffset.roundToPx()) }, - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, - ) { - Column( - modifier = Modifier.width(590.dp), - verticalArrangement = Arrangement.spacedBy(18.dp), - ) { - Text( - "MEMBY", - color = MembyAccentBright, - fontSize = 14.sp, - fontWeight = FontWeight.Bold, - letterSpacing = 2.sp, - ) - // Two lines, both of them literal. "Who's watching?" invited a household - // that most of these TVs do not have, and the line under it listed three - // nouns to say one thing. - Text( - "Welcome to Memby - Let's get started", - color = Color.White, - fontSize = 48.sp, - fontWeight = FontWeight.Bold, - ) - Text( - "Enter your username & password on the next screen. Ensure to allow permissions for app updates.", - color = MembyMutedText, - fontSize = 20.sp, - lineHeight = 29.sp, - ) - Spacer(Modifier.height(8.dp)) - Button( - onClick = onGetStarted, - modifier = Modifier.focusRequester(getStartedFocus), - ) { - Text("Next") - } - } - - Box( - modifier = Modifier.size(330.dp), - contentAlignment = Alignment.Center, - ) { - Box( - modifier = Modifier - .size(250.dp) - .graphicsLayer { - scaleX = logoScale - scaleY = logoScale - alpha = haloAlpha - } - .background(MembyAccent, CircleShape), - ) - Image( - painter = painterResource(R.drawable.emby_logo), - contentDescription = null, - modifier = Modifier - .width(178.dp) - .height(148.dp) - .graphicsLayer { - scaleX = logoScale - scaleY = logoScale - }, - ) - } - } - } -} - -@Composable -private fun SetupScreen( - onCancel: (() -> Unit)? = null, - onSignedIn: () -> Unit = {}, -) { - val repo = ServiceLocator.repository - val context = LocalContext.current - val scope = rememberCoroutineScope() - - val automaticDeviceName = remember(context) { - ServiceLocator.settings.current?.deviceName - ?.takeIf { it.isNotBlank() } - ?: suggestedDeviceName(context) - } - var username by rememberSaveable { mutableStateOf("") } - var password by rememberSaveable { mutableStateOf("") } - var connecting by remember { mutableStateOf(false) } - var error by remember { mutableStateOf(null) } - - val usernameFocus = remember { FocusRequester() } - val passwordFocus = remember { FocusRequester() } - val signInFocus = remember { FocusRequester() } - val backFocus = remember { FocusRequester() } - LaunchedEffect(Unit) { - kotlinx.coroutines.delay(100L) - runCatching { usernameFocus.requestFocus() } - } - if (onCancel != null) { - // The visible Back button is disabled while authentication is running. Hardware - // Back must obey the same contract and, importantly, must not hide a form whose - // coroutine can still complete and switch the active viewer afterwards. - BackHandler { if (!connecting) onCancel() } - } - - val submit: () -> Unit = { - if (username.isBlank()) { - error = "Username is required." - } else if (!connecting) { - connecting = true - error = null - scope.launch { - runCatching { - repo.authenticate( - serverUrl = "", - username = username.trim(), - password = password, - deviceName = automaticDeviceName, - ) - } - .onSuccess { authenticatedUsername -> - val quoteStyle = ServiceLocator.settings.snapshot().welcomeQuoteStyle - Toast.makeText( - context, - loginWelcomeMessage(authenticatedUsername, quoteStyle), - Toast.LENGTH_LONG, - ).show() - onSignedIn() - } - .onFailure { failure -> - error = "Memby couldn't sign in. Check the username and password and try again." - } - connecting = false - } - } - } - - SignInContent( - username = username, - password = password, - onUsernameChange = { username = it; error = null }, - onPasswordChange = { password = it; error = null }, - onSubmit = submit, - connecting = connecting, - error = error, - onBack = onCancel, - usernameFocus = usernameFocus, - passwordFocus = passwordFocus, - signInFocus = signInFocus, - backFocus = backFocus, - ) -} - -internal fun suggestedDeviceName(context: android.content.Context): String { - val systemName = runCatching { - AndroidSettings.Global.getString(context.contentResolver, "device_name") - }.getOrNull()?.trim().orEmpty() - if (systemName.isNotBlank()) return systemName.take(80) - - val manufacturer = Build.MANUFACTURER - ?.trim() - .orEmpty() - .takeUnless { it.equals("unknown", ignoreCase = true) } - .orEmpty() - val model = Build.MODEL - ?.trim() - .orEmpty() - .takeUnless { it.equals("unknown", ignoreCase = true) } - .orEmpty() - val inferred = listOf(manufacturer, model) - .filter { it.isNotBlank() } - .distinctBy { it.lowercase() } - .joinToString(" ") - return inferred.ifBlank { "Living room TV" }.take(80) -} - -@Composable -private fun ProfileEntryScreen( - settings: Settings, - onAddProfile: () -> Unit, -) { - val repo = ServiceLocator.repository - val scope = rememberCoroutineScope() - var switchingProfileId by remember { mutableStateOf(null) } - var removingProfileId by remember { mutableStateOf(null) } - - ProfileChooser( - profiles = settings.profiles, - currentProfileId = null, - switchingProfileId = switchingProfileId, - removingProfileId = removingProfileId, - onSelect = { profile -> - switchingProfileId = profile.id - scope.launch { - runCatching { repo.switchProfile(profile) } - .onFailure { switchingProfileId = null } - } - }, - onRemove = { profile -> - removingProfileId = profile.id - scope.launch { - runCatching { repo.removeProfile(profile) } - removingProfileId = null - } - }, - onAddProfile = onAddProfile, - onClose = null, - ) -} - -@Composable -@OptIn(ExperimentalComposeUiApi::class) -private fun ProfileChooser( - profiles: List, - currentProfileId: String?, - switchingProfileId: String?, - removingProfileId: String?, - onSelect: (EmbyProfile) -> Unit, - onRemove: (EmbyProfile) -> Unit, - onAddProfile: () -> Unit, - onClose: (() -> Unit)?, -) { - val addFocus = remember { FocusRequester() } - val backFocus = remember { FocusRequester() } - var pendingRemoval by remember { mutableStateOf(null) } - var removalReturnIndex by remember { mutableStateOf(null) } - val orderedProfiles = remember(profiles, currentProfileId) { - profiles.sortedByDescending { it.id == currentProfileId } - } - val profileIds = orderedProfiles.map(EmbyProfile::id) - val tileFocus = remember(profileIds) { List(orderedProfiles.size) { FocusRequester() } } - val removeFocus = remember(profileIds) { List(orderedProfiles.size) { FocusRequester() } } - LaunchedEffect(profileIds) { - kotlinx.coroutines.delay(16L) - val targetIndex = removalReturnIndex?.let { - profileFocusIndexAfterRemoval(it, orderedProfiles.size) - } ?: if (orderedProfiles.isEmpty()) null else 0 - removalReturnIndex = null - if (targetIndex == null) addFocus.requestFocus() - else tileFocus.getOrNull(targetIndex)?.requestFocus() ?: addFocus.requestFocus() - } - Box( - modifier = Modifier - .fillMaxSize() - .background(MembySurface), - ) { - Column( - modifier = Modifier - .fillMaxSize() - .padding(horizontal = 72.dp, vertical = 48.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, - ) { - Image( - painter = painterResource(R.drawable.emby_logo), - contentDescription = "Memby", - modifier = Modifier.width(72.dp).height(60.dp), - ) - Spacer(Modifier.height(20.dp)) - Text("Who’s watching?", color = Color.White, fontSize = 36.sp, fontWeight = FontWeight.SemiBold) - Text( - when { - removingProfileId != null -> "Removing user…" - switchingProfileId != null -> "Switching profile…" - else -> "Choose a profile to continue" - }, - color = MembyQuietText, - fontSize = 17.sp, - modifier = Modifier.padding(top = 8.dp, bottom = 30.dp), - ) - LazyRow( - modifier = Modifier.fillMaxWidth().focusGroup(), - contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 24.dp), - horizontalArrangement = Arrangement.spacedBy(24.dp), - verticalAlignment = Alignment.Top, - ) { - itemsIndexed(orderedProfiles, key = { _, profile -> profile.id }) { index, profile -> - Column( - modifier = Modifier.width(154.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - ProfileTile( - name = profile.username, - symbol = profileInitials(profile.username, profile.profileInitials), - current = profile.id == currentProfileId, - enabled = switchingProfileId == null && removingProfileId == null, - onClick = { onSelect(profile) }, - modifier = Modifier - .focusRequester(tileFocus[index]) - .focusProperties { - left = if (index > 0) tileFocus[index - 1] else FocusRequester.Cancel - right = if (index < orderedProfiles.lastIndex) { - tileFocus[index + 1] - } else { - addFocus - } - down = removeFocus[index] - }, - ) - ProfileDeleteButton( - profileName = profile.username, - enabled = switchingProfileId == null && removingProfileId == null, - onClick = { pendingRemoval = profile }, - modifier = Modifier - .focusRequester(removeFocus[index]) - .focusProperties { - up = tileFocus[index] - left = if (index > 0) removeFocus[index - 1] else FocusRequester.Cancel - right = if (index < orderedProfiles.lastIndex) { - removeFocus[index + 1] - } else { - FocusRequester.Cancel - } - down = if (onClose != null) backFocus else FocusRequester.Cancel - }, - ) - } - } - item(key = "add-profile") { - ProfileTile( - name = "Add another user", - current = false, - enabled = switchingProfileId == null && removingProfileId == null, - symbol = "+", - onClick = onAddProfile, - modifier = Modifier - .focusRequester(addFocus) - .focusProperties { - left = tileFocus.lastOrNull() ?: FocusRequester.Cancel - right = FocusRequester.Cancel - down = if (onClose != null) backFocus else FocusRequester.Cancel - }, - ) - } - } - if (onClose != null) { - Spacer(Modifier.height(30.dp)) - Button( - onClick = onClose, - enabled = switchingProfileId == null && removingProfileId == null, - modifier = Modifier - .focusRequester(backFocus) - .focusProperties { - up = removeFocus.firstOrNull() ?: addFocus - }, - ) { Text("Back to Memby") } - } - } - pendingRemoval?.let { profile -> - ProfileRemovalConfirmation( - profile = profile, - onCancel = { pendingRemoval = null }, - onConfirm = { - removalReturnIndex = profileIds.indexOf(profile.id).takeIf { it >= 0 } - pendingRemoval = null - onRemove(profile) - }, - ) - } - } -} - -internal fun profileFocusIndexAfterRemoval(removedIndex: Int, remainingCount: Int): Int? = - if (remainingCount <= 0) null else removedIndex.coerceIn(0, remainingCount - 1) - -@Composable -@OptIn(ExperimentalComposeUiApi::class) -private fun RecommendationOnboardingScreen( - onboarding: RecommendationOnboarding, - onComplete: suspend (Map, List, List, List) -> Unit, - onSkip: () -> Unit = {}, - initialStageIndex: Int = 0, - previewArtwork: ImageBitmap? = null, -) { - val repo = ServiceLocator.repository - val scope = rememberCoroutineScope() - val ratings = remember(onboarding.ratings) { - mutableStateMapOf().apply { putAll(onboarding.ratings) } - } - val selectedActors = remember { mutableStateMapOf() } - val selectedActresses = remember { mutableStateMapOf() } - val selectedDirectors = remember { mutableStateMapOf() } - val movies = onboarding.movies.ifEmpty { onboarding.items.filter(BaseItem::isMovie) } - val shows = onboarding.shows.ifEmpty { onboarding.items.filter(BaseItem::isSeries) } - val stages = remember(movies, shows, onboarding.actors, onboarding.actresses, onboarding.directors) { - buildList { - if (movies.isNotEmpty()) add("Movies") - if (shows.isNotEmpty()) add("TV shows") - if (onboarding.actors.isNotEmpty()) add("Actors") - if (onboarding.actresses.isNotEmpty()) add("Actresses") - if (onboarding.directors.isNotEmpty()) add("Directors") - } - } - var stageIndex by rememberSaveable { mutableStateOf(initialStageIndex.coerceIn(0, (stages.size - 1).coerceAtLeast(0))) } - var saving by remember { mutableStateOf(false) } - var error by remember { mutableStateOf(null) } - var confirmingSkip by rememberSaveable { mutableStateOf(false) } - val stage = stages.getOrNull(stageIndex) - val choiceFocus = remember(stageIndex) { FocusRequester() } - val backFocus = remember(stageIndex) { FocusRequester() } - val primaryFocus = remember(stageIndex) { FocusRequester() } - - LaunchedEffect(stageIndex, stage) { - kotlinx.coroutines.delay(16L) - runCatching { - if (stage == null) primaryFocus.requestFocus() else choiceFocus.requestFocus() - } - } - BackHandler { - when { - confirmingSkip -> confirmingSkip = false - stageIndex > 0 -> stageIndex-- - else -> confirmingSkip = true - } - } - - val finish: () -> Unit = { - if (!saving) { - saving = true - error = null - scope.launch { - runCatching { - onComplete( - ratings.toMap(), - selectedActors.filterValues { it }.keys.toList(), - selectedActresses.filterValues { it }.keys.toList(), - selectedDirectors.filterValues { it }.keys.toList(), - ) - } - .onFailure { - error = "Memby couldn't save those ratings. Please try again." - saving = false - } - } - } - } - - Box( - Modifier - .fillMaxSize() - .background( - Brush.radialGradient( - listOf(MembySplashTint, MembySurfaceRaised, MembySurface), - center = Offset(260f, 180f), - radius = 1_500f, - ), - ) - .padding(horizontal = 64.dp, vertical = 34.dp), - ) { - Column( - modifier = Modifier.fillMaxSize(), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { - Column(Modifier.weight(1f)) { - Text("TUNE YOUR TASTE", color = MembyAccentBright, fontSize = 13.sp, fontWeight = FontWeight.Bold) - Text("Make Memby yours", color = Color.White, fontSize = 34.sp, fontWeight = FontWeight.Bold) - } - Row(horizontalArrangement = Arrangement.spacedBy(7.dp)) { - stages.forEachIndexed { index, _ -> - Box( - Modifier - .width(if (index == stageIndex) 34.dp else 12.dp) - .height(5.dp) - .clip(CircleShape) - .background(if (index <= stageIndex) MembyAccentBright else MembyControlSurfaceRaised), - ) - } - } - } - Text( - when (stage) { - "Movies" -> "Choose movies you love" - "TV shows" -> "Choose shows you would recommend" - "Actors" -> "Pick actors you enjoy watching" - "Actresses" -> "Pick actresses you enjoy watching" - "Directors" -> "Pick directors whose work you seek out" - else -> "A few quick picks" - }, - color = Color.White, - fontSize = 25.sp, - fontWeight = FontWeight.SemiBold, - ) - Text( - "Select as many as you like, or skip this step. Every choice gives your recommendations a stronger starting point.", - color = MembyMutedText, fontSize = 16.sp, - ) - Spacer(Modifier.height(8.dp)) - if (stage == null) { - Spacer(Modifier.weight(1f)) - Text("Your library is still being prepared. You can start watching now and Memby will learn as you go.", color = MembyMutedText) - Spacer(Modifier.weight(1f)) - } else { - when (stage) { - "Movies" -> OnboardingTitleRow( - movies, ratings, choiceFocus, primaryFocus, previewArtwork, - ) - "TV shows" -> OnboardingTitleRow( - shows, ratings, choiceFocus, primaryFocus, previewArtwork, - ) - "Actors" -> OnboardingPeopleRow( - onboarding.actors, selectedActors, choiceFocus, primaryFocus, previewArtwork, - ) - "Actresses" -> OnboardingPeopleRow( - onboarding.actresses, selectedActresses, choiceFocus, primaryFocus, previewArtwork, - ) - "Directors" -> OnboardingPeopleRow( - onboarding.directors, selectedDirectors, choiceFocus, primaryFocus, previewArtwork, - ) - } - } - Spacer(Modifier.weight(1f)) - Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { - Text( - "${ratings.count { it.value >= 4 } + selectedActors.count { it.value } + selectedActresses.count { it.value } + selectedDirectors.count { it.value }} selected", - color = MembyQuietText, fontSize = 14.sp, modifier = Modifier.weight(1f), - ) - error?.let { Text(it, color = Color(0xFFFF7777), fontSize = 14.sp, modifier = Modifier.padding(end = 16.dp)) } - if (stageIndex > 0) { - Button( - onClick = { stageIndex-- }, - enabled = !saving, - modifier = Modifier - .focusRequester(backFocus) - .focusProperties { - up = if (stage == null) FocusRequester.Cancel else choiceFocus - right = primaryFocus - }, - ) { Text("Back") } - } - Spacer(Modifier.width(10.dp)) - if (stageIndex < stages.lastIndex) { - Button( - onClick = { stageIndex++ }, - enabled = !saving, - modifier = Modifier - .focusRequester(primaryFocus) - .focusProperties { - up = if (stage == null) FocusRequester.Cancel else choiceFocus - left = if (stageIndex > 0) backFocus else FocusRequester.Cancel - }, - ) { Text("Next") } - } else { - Button( - onClick = finish, - enabled = !saving, - modifier = Modifier - .focusRequester(primaryFocus) - .focusProperties { - up = if (stage == null) FocusRequester.Cancel else choiceFocus - left = if (stageIndex > 0) backFocus else FocusRequester.Cancel - }, - ) { Text(if (saving) "Saving…" else "Start watching") } - } - } - } - if (confirmingSkip) { - OnboardingSkipConfirmation( - onKeepChoosing = { confirmingSkip = false }, - onSkip = onSkip, - ) - } - } -} - -@Composable -@OptIn(ExperimentalComposeUiApi::class) -private fun OnboardingSkipConfirmation( - onKeepChoosing: () -> Unit, - onSkip: () -> Unit, -) { - val keepFocus = remember { FocusRequester() } - val skipFocus = remember { FocusRequester() } - LaunchedEffect(Unit) { - kotlinx.coroutines.delay(16L) - runCatching { keepFocus.requestFocus() } - } - Box( - Modifier.fillMaxSize().zIndex(20f).background(Color.Black.copy(alpha = 0.82f)), - contentAlignment = Alignment.Center, - ) { - Column( - modifier = Modifier - .width(480.dp) - .background(MembyControlSurface, RoundedCornerShape(18.dp)) - .padding(32.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(18.dp), - ) { - Text( - "Skip taste setup for now?", - color = Color.White, - fontSize = 24.sp, - fontWeight = FontWeight.SemiBold, - ) - Text( - "You can start watching now. Memby may offer these choices again later.", - color = MembyMutedText, - fontSize = 16.sp, - textAlign = TextAlign.Center, - ) - Row(horizontalArrangement = Arrangement.spacedBy(14.dp)) { - Button( - onClick = onKeepChoosing, - modifier = Modifier - .focusRequester(keepFocus) - .focusProperties { - left = FocusRequester.Cancel - right = skipFocus - up = FocusRequester.Cancel - down = FocusRequester.Cancel - }, - ) { Text("Keep choosing") } - Button( - onClick = onSkip, - modifier = Modifier - .focusRequester(skipFocus) - .focusProperties { - left = keepFocus - right = FocusRequester.Cancel - up = FocusRequester.Cancel - down = FocusRequester.Cancel - }, - ) { Text("Skip for now") } - } - } - } -} - -@Composable -private fun OnboardingTitleRow( - items: List, - ratings: MutableMap, - entryFocus: FocusRequester, - footerFocus: FocusRequester, - previewArtwork: ImageBitmap? = null, -) { - val repo = ServiceLocator.repository - val distinctItems = remember(items) { items.distinctItems() } - LazyRow(horizontalArrangement = Arrangement.spacedBy(18.dp), modifier = Modifier.fillMaxWidth()) { - itemsIndexed(distinctItems, key = { _, item -> item.id }) { index, item -> - val selected = ratings[item.id] == 5 - FocusScaleContainer( - onFocused = {}, - onClick = { if (selected) ratings.remove(item.id) else ratings[item.id] = 5 }, - contentDescription = "${item.name}${if (selected) ", selected" else ""}", - modifier = Modifier - .width(148.dp) - .then(if (index == 0) Modifier.focusRequester(entryFocus) else Modifier) - .focusProperties { down = footerFocus }, - ) { focused -> - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - Box( - Modifier - .width(148.dp).height(222.dp) - .clip(RoundedCornerShape(12.dp)) - .background(MembyControlSurface) - .border(if (focused || selected) 3.dp else 1.dp, if (selected) MembyAccentBright else if (focused) Color.White else MembyOutline, RoundedCornerShape(12.dp)), - ) { - if (previewArtwork != null) { - Image(bitmap = previewArtwork, contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize()) - } else { - AsyncImage(model = repo.primaryUrl(item, 320), contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize()) - } - if (selected) Text("✓ PICKED", color = Color.White, fontSize = 11.sp, fontWeight = FontWeight.Bold, modifier = Modifier.align(Alignment.TopEnd).padding(8.dp).background(MembyAccent.copy(alpha = 0.91f), CircleShape).padding(horizontal = 9.dp, vertical = 5.dp)) - } - Text(item.name, color = Color.White, fontSize = 14.sp, fontWeight = FontWeight.Medium, maxLines = 1, overflow = TextOverflow.Ellipsis) - Text(listOfNotNull(item.productionYear?.toString(), item.genres.firstOrNull()).joinToString(" · "), color = MembyQuietText, fontSize = 11.sp, maxLines = 1) - } - } - } - } -} - -@Composable -private fun OnboardingPeopleRow( - people: List, - selectedPeople: MutableMap, - entryFocus: FocusRequester, - footerFocus: FocusRequester, - previewArtwork: ImageBitmap? = null, -) { - val repo = ServiceLocator.repository - // Keyed by name because that is also what the selection map is keyed by — which means - // two people sharing one would be one selection *and* a duplicate LazyRow key, and the - // second of those is a crash on the first screen a new television ever draws. - val distinctPeople = remember(people) { people.distinctForKeys(RecommendationPerson::name) } - LazyRow(horizontalArrangement = Arrangement.spacedBy(18.dp), modifier = Modifier.fillMaxWidth()) { - itemsIndexed(distinctPeople, key = { _, person -> person.name }) { index, person -> - val selected = selectedPeople[person.name] == true - FocusScaleContainer( - onFocused = {}, - onClick = { selectedPeople[person.name] = !selected }, - contentDescription = "${person.name}${if (selected) ", selected" else ""}", - modifier = Modifier - .width(148.dp) - .then(if (index == 0) Modifier.focusRequester(entryFocus) else Modifier) - .focusProperties { down = footerFocus }, - ) { focused -> - Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(10.dp)) { - Box( - Modifier.size(148.dp).clip(CircleShape).background(MembyAccentMuted) - .border(if (focused || selected) 4.dp else 1.dp, if (selected) MembyAccentBright else if (focused) Color.White else MembyOutline, CircleShape), - contentAlignment = Alignment.Center, - ) { - if (previewArtwork != null) { - Image(bitmap = previewArtwork, contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize()) - } else if (person.id.isNotBlank() && person.imageTag.isNotBlank()) { - AsyncImage(model = repo.posterUrl(person.id, person.imageTag, 280), contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize()) - } else { - Text(person.name.take(1).uppercase(), color = MembyOnSurface, fontSize = 46.sp, fontWeight = FontWeight.Light) - } - if (selected) Text("✓", color = Color.White, fontSize = 17.sp, fontWeight = FontWeight.Bold, modifier = Modifier.align(Alignment.BottomEnd).background(MembyAccent, CircleShape).padding(horizontal = 9.dp, vertical = 5.dp)) - } - Text(person.name, color = Color.White, fontSize = 14.sp, fontWeight = FontWeight.Medium, maxLines = 2, overflow = TextOverflow.Ellipsis, textAlign = TextAlign.Center) - } - } - } - } -} - -/** Stable fixtures for screenshot reviews of the recommendation taste journey. */ -@Composable -internal fun RecommendationOnboardingPreview( - initialStageIndex: Int = 0, - previewArtwork: ImageBitmap? = null, - previewFocusRequester: FocusRequester? = null, -) { - val localPreviewFocus = remember { FocusRequester() } - val previewFocus = previewFocusRequester ?: localPreviewFocus - LaunchedEffect(Unit) { runCatching { previewFocus.requestFocus() } } - val titles = listOf( - BaseItem(id = "arrival", name = "Arrival", type = "Movie", productionYear = 2016, genres = listOf("Science Fiction")), - BaseItem(id = "parasite", name = "Parasite", type = "Movie", productionYear = 2019, genres = listOf("Thriller")), - BaseItem(id = "spirited-away", name = "Spirited Away", type = "Movie", productionYear = 2001, genres = listOf("Animation")), - BaseItem(id = "moonlight", name = "Moonlight", type = "Movie", productionYear = 2016, genres = listOf("Drama")), - BaseItem(id = "mad-max", name = "Mad Max: Fury Road", type = "Movie", productionYear = 2015, genres = listOf("Action")), - BaseItem(id = "hunt-wilderpeople", name = "Hunt for the Wilderpeople", type = "Movie", productionYear = 2016, genres = listOf("Comedy")), - ) - val shows = listOf( - BaseItem(id = "severance", name = "Severance", type = "Series", productionYear = 2022, genres = listOf("Drama")), - BaseItem(id = "atlanta", name = "Atlanta", type = "Series", productionYear = 2016, genres = listOf("Comedy")), - ) - val people = listOf( - RecommendationPerson(id = "p1", name = "Mahershala Ali", imageTag = "portrait"), - RecommendationPerson(id = "p2", name = "Dev Patel", imageTag = "portrait"), - RecommendationPerson(id = "p3", name = "Steven Yeun", imageTag = "portrait"), - RecommendationPerson(id = "p4", name = "Oscar Isaac", imageTag = "portrait"), - RecommendationPerson(id = "p5", name = "Daniel Kaluuya", imageTag = "portrait"), - RecommendationPerson(id = "p6", name = "Taika Waititi", imageTag = "portrait"), - ) - Box(Modifier.fillMaxSize().focusRequester(previewFocus).focusable()) { - RecommendationOnboardingScreen( - onboarding = RecommendationOnboarding( - ratings = mapOf("arrival" to 5, "moonlight" to 5), - movies = titles, - shows = shows, - actors = people, - actresses = people.mapIndexed { index, person -> person.copy(id = "a$index", name = listOf("Michelle Yeoh", "Viola Davis", "Emma Stone", "Ayo Edebiri", "Melanie Lynskey", "Sandra Oh")[index]) }, - directors = people.mapIndexed { index, person -> person.copy(id = "d$index", name = listOf("Denis Villeneuve", "Bong Joon-ho", "Greta Gerwig", "Jordan Peele", "Chloé Zhao", "Jane Campion")[index]) }, - ), - onComplete = { _, _, _, _ -> }, - initialStageIndex = initialStageIndex, - previewArtwork = previewArtwork, - ) - } -} - -@Composable -private fun ProfileDeleteButton( - profileName: String, - enabled: Boolean, - onClick: () -> Unit, - modifier: Modifier = Modifier, -) { - var focused by remember { mutableStateOf(false) } - Row( - modifier = modifier - .width(124.dp) - .height(34.dp) - .zIndex(2f) - .clip(RoundedCornerShape(9.dp)) - .background( - when { - !enabled -> MembyControlSurfaceRaised - focused -> Color(0xFFE34B4B) - else -> MembyControlSurface.copy(alpha = 0.90f) - }, - ) - .border( - width = if (focused) 2.dp else 1.dp, - color = if (focused) Color.White else Color.White.copy(alpha = 0.55f), - shape = RoundedCornerShape(9.dp), - ) - .onFocusChanged { focused = it.isFocused } - .clickable(enabled = enabled, onClick = onClick) - .semantics { contentDescription = "Remove $profileName" }, - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Center, - ) { - Text( - text = "Remove user", - color = if (enabled) Color.White else MembyDisabledText, - fontSize = 12.sp, - fontWeight = FontWeight.SemiBold, - ) - } -} - -@Composable -@OptIn(ExperimentalComposeUiApi::class) -private fun ProfileRemovalConfirmation( - profile: EmbyProfile, - onCancel: () -> Unit, - onConfirm: () -> Unit, -) { - val cancelFocus = remember { FocusRequester() } - val removeFocus = remember { FocusRequester() } - BackHandler(onBack = onCancel) - LaunchedEffect(profile.id) { cancelFocus.requestFocus() } - Box( - Modifier - .fillMaxSize() - .background(Color.Black.copy(alpha = 0.78f)), - contentAlignment = Alignment.Center, - ) { - Column( - modifier = Modifier - .width(460.dp) - .background(MembyControlSurface, RoundedCornerShape(18.dp)) - .padding(32.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(18.dp), - ) { - Text( - "Remove ${profile.username} from this TV?", - color = Color.White, - fontSize = 24.sp, - fontWeight = FontWeight.SemiBold, - ) - Text( - "Their saved sign-in will be forgotten on this device.", - color = MembyMutedText, - fontSize = 16.sp, - ) - Row(horizontalArrangement = Arrangement.spacedBy(14.dp)) { - Button( - onClick = onCancel, - modifier = Modifier - .focusRequester(cancelFocus) - .focusProperties { - left = FocusRequester.Cancel - right = removeFocus - up = FocusRequester.Cancel - down = FocusRequester.Cancel - }, - ) { Text("Cancel") } - Button( - onClick = onConfirm, - modifier = Modifier - .focusRequester(removeFocus) - .focusProperties { - left = cancelFocus - right = FocusRequester.Cancel - up = FocusRequester.Cancel - down = FocusRequester.Cancel - }, - ) { Text("Remove user") } - } - } - } -} - -@Composable -private fun ProfileTile( - name: String, - current: Boolean, - enabled: Boolean, - onClick: () -> Unit, - modifier: Modifier = Modifier, - symbol: String = name.trim().firstOrNull()?.uppercase() ?: "?", -) { - FocusScaleContainer( - onFocused = {}, - onClick = { if (enabled) onClick() }, - contentDescription = if (current) "$name, current profile" else name, - modifier = modifier.width(154.dp), - ) { focused -> - Column(horizontalAlignment = Alignment.CenterHorizontally) { - Box( - modifier = Modifier - .size(112.dp) - .clip(CircleShape) - .background(if (focused) MembyAccent else MembyControlSurfaceRaised) - .border( - width = if (current) 4.dp else if (focused) 3.dp else 1.dp, - color = if (current) MembyAccent else if (focused) Color.White else MembyDisabledText, - shape = CircleShape, - ), - contentAlignment = Alignment.Center, - ) { - Text(symbol, color = Color.White, fontSize = 38.sp, fontWeight = FontWeight.Bold) - } - Text( - name, - color = if (focused) Color.White else MembyMutedText, - fontSize = 16.sp, - fontWeight = if (focused || current) FontWeight.SemiBold else FontWeight.Medium, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.padding(top = 12.dp), - ) - if (current) { - Text("Current", color = MembyAccentBright, fontSize = 13.sp, modifier = Modifier.padding(top = 3.dp)) - } - } - } -} - -@Composable -private fun HomeScreen( - settings: Settings, - remoteConfig: MembyRemoteConfig, -) { - val repo = ServiceLocator.repository - val context = LocalContext.current - val scope = rememberCoroutineScope() - val factory = remember(repo) { HomeViewModelFactory(repo) } - // HomeViewModel owns user-scoped rows, recommendation state, metadata caches and - // in-flight requests. The Activity's default ViewModelStore outlives a Compose key, - // so using it here leaks the previous profile's state across a profile switch. - val profileViewModelOwner = remember( - settings.activeProfileId, - settings.serverUrl, - settings.userId, - ) { ProfileViewModelStoreOwner() } - DisposableEffect(profileViewModelOwner) { - onDispose { profileViewModelOwner.clear() } - } - val homeViewModel: HomeViewModel = viewModel( - viewModelStoreOwner = profileViewModelOwner, - factory = factory, - ) - // Three narrow projections rather than the whole HomeUiState: this function is large - // enough that subscribing to every field made an arriving update verdict or a banner - // change recompose the launcher and rebuild every row. See HomeViewModel.content. - val homeContent by homeViewModel.content.collectAsStateWithLifecycle() - val homeStatus by homeViewModel.status.collectAsStateWithLifecycle() - val forYouState by homeViewModel.forYou.collectAsStateWithLifecycle() - val favoriteChanges by homeViewModel.favoriteChanges.collectAsStateWithLifecycle() - val playedChanges by homeViewModel.playedChanges.collectAsStateWithLifecycle() - val liveMaintenance by ServiceLocator.maintenance.notice.collectAsStateWithLifecycle() - val compatibilityNotice by ServiceLocator.maintenance.compatibility.collectAsStateWithLifecycle() - val genreBrowserEnabled by ServiceLocator.maintenance.genreBrowserEnabled.collectAsStateWithLifecycle() - val tvCalendarEnabled by ServiceLocator.maintenance.tvCalendarEnabled.collectAsStateWithLifecycle() - // Per viewer rather than per household, so it cannot ride the feature map: it decides - // whether the Requests entry is in the switcher at all. - val requestsAllowed by ServiceLocator.maintenance.requestsAllowed.collectAsStateWithLifecycle() - // Only whether there is one, not its countdown — that is collected inside the banner - // so a ticking second never reaches the launcher. This is read here purely to decide - // which of the two top bars gets the strip. - val embyOutage by ServiceLocator.maintenance.embyOutage.collectAsStateWithLifecycle() - - var showSettings by remember { mutableStateOf(false) } - var showProfiles by remember { mutableStateOf(false) } - // Reached only from the manage-users page, which is itself two presses in behind the - // user picker — so the "+" is where somebody looking to add a viewer is already - // standing, and it is nowhere near the launcher. - var addingProfile by remember { mutableStateOf(false) } - var userSwitcherVisible by remember { mutableStateOf(false) } - // The shortcut menu a hold on the rail's user item raises. Kept beside the picker rather - // than inside it: the two are alternative answers to the same control, and only one of - // them may ever be on screen. - var userQuickActionsVisible by remember { mutableStateOf(false) } - var switchingProfileId by remember { mutableStateOf(null) } - var removingProfileId by remember { mutableStateOf(null) } - var selectedDestination by rememberSaveable { mutableStateOf(BrowseDestination.HOME) } - var navigationExpanded by rememberSaveable { mutableStateOf(false) } - var restoreRailAfterSettings by remember { mutableStateOf(false) } - var detailsItem by remember { mutableStateOf(null) } - // Detail pages can now open one another through the related rail. Back walks the trail - // home one page at a time; without this it would drop three levels to the launcher. - var detailsTrail by remember { mutableStateOf>(emptyList()) } - var restoreDetailPosition by remember { mutableStateOf(false) } - // The page playback was started from, so returning from the player comes back to it - // rather than to the launcher. - // - // Deliberately a *reopen* rather than leaving the page composed under the player. The - // page is what tells the viewer where they were, and it has to tell them the truth: the - // episode they just finished is now watched, the film is now part-way through, and the - // next episode is a different one. Reopening runs the same restore path a Back press - // through the trail already uses (`DetailPosition` plus `RestoreDetailFocus`), so the - // tab, the season, the grid offset and the band that held focus all come back with it — - // and focus is re-requested by that path rather than depending on Compose having held - // it across another activity's window. - var detailsResumeTarget by remember { mutableStateOf(null) } - // Belongs to the *route*, not to the show: set only when a page is opened from the - // "Shows airing" row, and dropped the moment the viewer moves anywhere else, so the - // same series reached from Favourites or a search never claims a schedule. - var detailsAiringNotice by remember { mutableStateOf(null) } - var quickMenuItem by remember { mutableStateOf(null) } - // Whether the long-press menu may offer a trailer for the Radarr card it is open on. - // Asked once, when the menu opens, and false until answered: a row entry that appears - // and then fails is worse than one that arrives a moment late. - var quickMenuTrailerAvailable by remember { mutableStateOf(false) } - var quickMenuRowId by remember { mutableStateOf(null) } - var focusedHomeRowId by remember { mutableStateOf(null) } - var sectionHeroRows by remember(settings.userId) { - mutableStateOf>>(emptyMap()) - } - var myShows by remember(settings.userId) { mutableStateOf>(emptyList()) } - var myShowsLoading by remember(settings.userId) { mutableStateOf(true) } - var myShowsError by remember(settings.userId) { mutableStateOf(null) } - var myShowsMutationBusy by remember(settings.userId) { mutableStateOf(false) } - var selectedMyShow by remember { mutableStateOf(null) } - var myShowReturnItemId by remember { mutableStateOf(null) } - var removingMyShow by remember { mutableStateOf(false) } - var notificationState by remember(settings.userId) { mutableStateOf(NotificationsResponse()) } - var notificationsLoading by remember(settings.userId) { mutableStateOf(true) } - var notificationsError by remember(settings.userId) { mutableStateOf(null) } - var notificationsMutationBusy by remember(settings.userId) { mutableStateOf(false) } - val displayedNotifications = listOfNotNull( - membyUpdateNotification( - settings.updateAlertVersion, - settings.updateAlertAt, - settings.updateAlertRead, - ), - ) + notificationState.notifications - var showNotifications by remember { mutableStateOf(false) } - var showRequests by remember { mutableStateOf(false) } - // The people under this account. Fetched once the launcher is up rather than on the - // critical path: nothing on the signed-in path may block on a request, and until the - // answer lands this television watches as whoever it watched as last — which is the - // right answer far more often than not. - var viewers by remember { mutableStateOf>(emptyList()) } - var showViewerPicker by remember { mutableStateOf(false) } - // Managing viewers is a stack over the picker rather than a replacement for it — the - // arrangement the "add another user" sign-in already takes over the manage-users page: - // cancelling a name comes straight back to the list the button was pressed from, with - // nothing to restore because nothing was ever unmounted. - var showViewerManage by remember { mutableStateOf(false) } - var viewerNameTarget by remember { mutableStateOf(null) } - var viewerName by remember { mutableStateOf("") } - var viewerNameFailure by remember { mutableStateOf(null) } - var viewerSaving by remember { mutableStateOf(false) } - var viewerBusyId by remember { mutableStateOf(null) } - LaunchedEffect(settings.userId, settings.serverUrl) { - viewers = if (settings.isSignedIn) repo.viewers() else emptyList() - } - // One place the list is re-read, so every mutation ends the same way and none of them - // has to work out what the answer should now be. The gateway is the thing that knows. - val refreshViewers: suspend () -> Unit = { - viewers = runCatching { repo.viewers() }.getOrDefault(viewers) - } - val openViewerName: (ViewerNameTarget) -> Unit = { target -> - viewerName = viewerNameFor(target) - viewerNameFailure = null - viewerNameTarget = target - } - // Two different things: [launchingItem] is the gate that stops a second Play press - // stacking a second player, and stays shut until one comes back. [resolvingItem] is - // the loading screen, and belongs only to a launch that is waiting on the server. - var launchingItem by remember { mutableStateOf(null) } - var resolvingItem by remember { mutableStateOf(null) } - var returnRowId by rememberSaveable { mutableStateOf(null) } - var returnItemId by rememberSaveable { mutableStateOf(null) } - // The shelf a Play press can be traced back to, as an entry point rather than as a row - // id. Kept beside [returnRowId] and written wherever it is, because the row's *kind* is - // the reliable half — a row id is the gateway's, differs on the direct path, and a - // recommendation strip invents a new one daily — and because by the time `playItem` runs - // the row list is out of scope. Saved with the rest so a recreate mid-browse does not - // file the next resume under nothing. - var returnRowKind by rememberSaveable { mutableStateOf(null) } - // Destination and row list states live above the conditional content branches. - // Opening Search/Settings/details therefore never creates a new list at position 0. - val verticalStates = rememberSaveable(saver = LazyListStateMapSaver) { - mutableMapOf() - } - val horizontalStates = rememberSaveable(saver = LazyListStateMapSaver) { - mutableMapOf() - } - val rememberedRowFocus = remember { - mutableMapOf>() - } - val destinationFocus = remember { - mutableMapOf>() - } - var showForYouNudge by rememberSaveable(settings.activeProfileId) { - mutableStateOf(!settings.hasOpenedForYou) - } - // Each destination owns a stable requester. Moving one requester between rail items - // can leave a key event pointing at the previously selected item for a frame. - val navigationFocusRequesters = remember { - BrowseDestination.entries.associateWith { FocusRequester() } - } - var railFocusDestination by rememberSaveable { mutableStateOf(selectedDestination) } - val navigationFocusRequester = navigationFocusRequesters.getValue(railFocusDestination) - // The Requests page covers the launcher, so it mounts a rail of its own. Kept apart - // from the launcher's because both are composed at once while that page is open, and a - // requester attached to two live nodes focuses whichever Compose reaches first. - val requestsRailFocusRequester = remember { FocusRequester() } - var requestsRailExpanded by remember { mutableStateOf(false) } - val contentFocusRequester = remember { FocusRequester() } - val cardReturnFocusRequester = remember { FocusRequester() } - val myShowReturnFocusRequester = remember { FocusRequester() } - // Down out of the hero, stated rather than left to Compose's spatial search. The - // featured card is wide, so its centre sits nearer the second card of the shelf below - // than the first, and the search obligingly skipped past the thing somebody had just - // been reading about in Continue Watching. - val heroRowEntryFocusRequester = remember { FocusRequester() } - // My Shows sits above the shelves on the television page, so it is the first stop - // below the hero and the thing Up out of the topmost shelf must return to. It is its - // own requester rather than the rail's entry one for the same reason the shelves' hero - // entry is: the entry requester belongs to whichever band the rail hands focus to. - val myShowsEntryFocusRequester = remember { FocusRequester() } - // Feature flags can remove the node that currently owns focus. Move the route first, - // wait for its replacement to compose, then establish an explicit remote target. - // A viewer standing on the Requests page when an operator withdraws access is moved - // off it, the same rule the calendar destination follows: a page nothing behind it will - // answer must not be somewhere a remote is left sitting. - LaunchedEffect(requestsAllowed) { - if (!requestsAllowed) showRequests = false - } - LaunchedEffect(tvCalendarEnabled) { - if (!tvCalendarEnabled && selectedDestination == BrowseDestination.CALENDAR) { - selectedDestination = BrowseDestination.HOME - railFocusDestination = BrowseDestination.HOME - kotlinx.coroutines.delay(16L) - requestFirstAvailableFocus( - contentFocusRequester, - navigationFocusRequesters.getValue(BrowseDestination.HOME), - ) - } - } - LaunchedEffect(genreBrowserEnabled) { - if (genreBrowserEnabled) return@LaunchedEffect - // A set standing on the Genres destination when the operator switches it off is - // moved to Home, the stance the calendar takes: the rail entry goes with the - // feature, so leaving it there would strand the viewer on a page nothing can - // navigate back to. - if (selectedDestination == BrowseDestination.GENRES) { - selectedDestination = BrowseDestination.HOME - railFocusDestination = BrowseDestination.HOME - kotlinx.coroutines.delay(16L) - requestFirstAvailableFocus(contentFocusRequester, navigationFocusRequester) - } - } - var initialFocusRequested by remember { mutableStateOf(false) } - // Incremented when a rail selection needs to restore a card in the lazy row list. - // The list owns the scroll state, so it also owns the actual restoration below. - var rowListFocusRestoreRequest by remember { mutableStateOf(0) } - val playbackLauncher = rememberLauncherForActivityResult( - contract = ActivityResultContracts.StartActivityForResult(), - ) { result -> - if (PlayerActivity.trailerUnavailable(result.resultCode, result.data)) { - Toast.makeText(context, "No playable trailer is available", Toast.LENGTH_SHORT).show() - } - homeViewModel.trackJourney( - category = "playback", action = "stop", screen = "player", - feature = "playback", target = selectedDestination.name.lowercase(), - ) - homeViewModel.resumeAnalyticsAfterPlayback() - // PlayerActivity has finished and this activity owns the window again. Compose - // needs one frame to reattach the saved card's focus node before it can receive - // focus, especially when playback progress refreshed the row behind the player. - // Clearing the launch gate here rather than when the intent was fired covers both - // routes into the player, including the one that hands over without waiting. - launchingItem = null - scope.launch { - myShowsLoading = true - notificationsLoading = true - kotlinx.coroutines.coroutineScope { - launch { - runCatching { repo.getMyShows() } - .onSuccess { myShows = it; myShowsError = null } - .onFailure { - if (it is kotlinx.coroutines.CancellationException) throw it - myShowsError = friendlyEmbyError(it) - } - myShowsLoading = false - } - launch { - runCatching { repo.getNotifications() } - .onSuccess { notificationState = it; notificationsError = null } - .onFailure { - if (it is kotlinx.coroutines.CancellationException) throw it - notificationsError = friendlyEmbyError(it) - } - notificationsLoading = false - } - } - kotlinx.coroutines.delay(32L) - // Trailer playback leaves its detail page composed. Let Compose restore the - // exact hero action instead of moving focus to the home card behind it. - if (detailsItem != null) return@launch - if (returnRowId != null && returnItemId != null) { - requestFirstAvailableFocus( - cardReturnFocusRequester, - contentFocusRequester, - navigationFocusRequester, - ) - } else { - requestFirstAvailableFocus(contentFocusRequester, navigationFocusRequester) - } - } - } - - LaunchedEffect(settings.userId) { - myShowsLoading = true - notificationsLoading = true - kotlinx.coroutines.coroutineScope { - launch { - runCatching { repo.getMyShows() } - .onSuccess { myShows = it; myShowsError = null } - .onFailure { - if (it is kotlinx.coroutines.CancellationException) throw it - myShowsError = friendlyEmbyError(it) - } - myShowsLoading = false - } - launch { - runCatching { repo.getNotifications() } - .onSuccess { notificationState = it; notificationsError = null } - .onFailure { - if (it is kotlinx.coroutines.CancellationException) throw it - notificationsError = friendlyEmbyError(it) - } - notificationsLoading = false - } - } - } - - LaunchedEffect(liveMaintenance) { - if (liveMaintenance != null) { - // Maintenance is an app-wide interruption, not another overlay in the stack. - showSettings = false - showProfiles = false - userSwitcherVisible = false - userQuickActionsVisible = false - showNotifications = false - showRequests = false - detailsItem = null - detailsAiringNotice = null - quickMenuItem = null - quickMenuRowId = null - } else if (homeStatus.maintenanceMessage != null) { - // A successful live status is authoritative; refresh content immediately - // instead of waiting for the maintenance screen's slower retry timer. - homeViewModel.refreshAll() - } - } - // The job resolving a stream, so the overlay it raises can be called off. Without it, - // a Play press against a server that has stopped answering holds a full-screen loading - // overlay for the length of the HTTP timeout with no way to leave it. - var resolveJob by remember { mutableStateOf(null) } - val abandonLaunch: () -> Unit = { - resolveJob?.cancel() - resolveJob = null - resolvingItem = null - launchingItem = null - homeViewModel.resumeAnalyticsAfterPlayback() - } - val playItem: (BaseItem) -> Unit = playItem@{ item -> - if (launchingItem != null || !item.membyPlayable) return@playItem - // The shelf this press traces back to, resolved once and then carried all the way - // into the player, so the step the launcher records and the step the player records - // agree about where the viewer came from. `source` used to be the raw row id, which - // the console could not group on: Continue Watching arrived as `continue` from the - // gateway, as `nextup` from a cached row, and as a recommendation id nobody had seen - // before every other day. - val entryPoint = playbackEntryPointFor(returnRowId, returnRowKind) - PlaybackJourney.requested( - sink = homeViewModel.journeySink, - entryPoint = entryPoint, - screen = selectedDestination.name.lowercase(), - itemId = item.id, - itemName = item.name, - itemType = item.type, - ) - // Nothing here uploads: the buffer is drained once the player hands the window back. - homeViewModel.pauseAnalyticsForPlayback() - // Every way a launch can die before the player exists. The player records its own - // failures once it has one; these are the ones it would never hear about, and - // without them a journey ends on a request that appears simply to have gone nowhere. - val playbackScreen = selectedDestination.name.lowercase() - val recordPlaybackFailed = { - PlaybackJourney.failed( - sink = homeViewModel.journeySink, - entryPoint = entryPoint, - screen = playbackScreen, - itemId = item.id, - itemName = item.name, - itemType = item.type, - ) - } - launchingItem = item - val playbackRequestedAtMs = SystemClock.elapsedRealtime() - // Resuming: open the player now and let it resolve the stream while it starts. - // Waiting here would spend the negotiation on a still launcher and only then begin - // the activity, the layout and the decoder, none of which needed the answer. A cold - // start still resolves first — the pre-roll it opens with needs a stream to run - // behind it, and whether there is one to show is part of the same answer. - // Everything up to the hand-over runs inside a catch that reopens the gate. The - // gate is otherwise cleared only by the player coming back, so anything that - // throws before one is started — a malformed cached item, an activity result - // registry that has already been torn down — would leave Play dead for the rest - // of the session with nothing on screen to say why. - val prepared = runCatching { - val request = repo.playbackRequest(item) - request to repo.readyPlayableForLaunch(request) - }.getOrElse { - recordPlaybackFailed() - Toast.makeText(context, "Couldn’t start playback", Toast.LENGTH_SHORT).show() - launchingItem = null - homeViewModel.resumeAnalyticsAfterPlayback() - return@playItem - } - val request = prepared.first - val ready = prepared.second - if (ready == null && request.resumePositionMs > 0L) { - val launched = runCatching { - playbackLauncher.launch( - PlayerActivity.intent( - context = context, - request = request, - posterUrl = repo.primaryUrl(item, maxWidth = 500), - backdropUrl = repo.backdropUrl(item, maxWidth = 1920) - ?: repo.primaryUrl(item, maxWidth = 1920), - requestStartedAtMs = playbackRequestedAtMs, - journeySource = entryPoint.id, - ), - ) - } - if (launched.isFailure) { - recordPlaybackFailed() - Toast.makeText(context, "Couldn’t start playback", Toast.LENGTH_SHORT).show() - launchingItem = null - homeViewModel.resumeAnalyticsAfterPlayback() - } - return@playItem - } - // Only the route that waits on the server shows the launcher's own loading screen. - // The route that hands over immediately would only be showing it behind the player. - resolvingItem = item - resolveJob = scope.launch { - try { - runCatching { - val playable = ready ?: run { - var timeout: TimeoutCancellationException? = null - repeat(2) { - try { - return@run withTimeout(PLAYBACK_SOURCE_RESOLUTION_TIMEOUT_MS) { - repo.resolvePlayableForLaunch(item) - } - } catch (error: TimeoutCancellationException) { - timeout = error - } - } - throw requireNotNull(timeout) - } - // A resolution that came back without a stream is a failure with a - // success's shape. Handed on, PlayerActivity finds no URL and no - // request to resolve one from, closes itself in onCreate, and the - // viewer sees the screen flicker and nothing else — the one playback - // failure that arrives with no explanation at all. - check(playable.url.isNotBlank()) { "resolved playable has no stream" } - playable - } - .onSuccess { playable -> - val launched = runCatching { - playbackLauncher.launch( - PlayerActivity.intent( - context = context, - itemId = playable.itemId, - url = playable.url, - title = playable.title, - resumePositionMs = playable.resumePositionMs, - logoUrl = playable.logoUrl, - backdropUrl = repo.backdropUrl(item, maxWidth = 1920) - ?: repo.primaryUrl(item, maxWidth = 1920), - seriesName = playable.seriesName, - overview = playable.overview ?: item.overview, - episodeCode = playable.episodeCode, - runtimeMs = playable.runtimeMs, - prerollEnabled = playable.prerollEnabled, - prerollDurationMs = playable.prerollDurationMs, - posterUrl = repo.primaryUrl(item, maxWidth = 500), - subtitles = playable.subtitles, - subtitlesEnabled = playable.subtitlesEnabled, - selectedSubtitleId = playable.selectedSubtitleId, - subtitleDownloadAvailable = playable.subtitleDownloadAvailable, - trickplayAvailable = playable.trickplayAvailable, - skipIntroAvailable = playable.skipIntroAvailable, - endCreditsAvailable = playable.endCreditsAvailable, - mediaSourceId = playable.mediaSourceId, - playSessionId = playable.playSessionId, - playMethod = playable.playMethod, - requestStartedAtMs = playbackRequestedAtMs, - journeySource = entryPoint.id, - ), - ) - } - if (launched.isFailure) { - recordPlaybackFailed() - Toast.makeText(context, "Couldn’t start playback", Toast.LENGTH_SHORT).show() - launchingItem = null - homeViewModel.resumeAnalyticsAfterPlayback() - } - } - .onFailure { error -> - // A cancellation is the viewer having pressed Back out of the - // wait, which has already reopened the gate and said so on screen. - if (error is kotlinx.coroutines.CancellationException) throw error - recordPlaybackFailed() - Toast.makeText(context, "Couldn’t start playback", Toast.LENGTH_SHORT).show() - // Nothing was launched, so nothing will come back to reopen the gate. - launchingItem = null - homeViewModel.resumeAnalyticsAfterPlayback() - } - } finally { - resolvingItem = null - resolveJob = null - } - } - } - val playTrailer: (BaseItem) -> Unit = playTrailer@{ item -> - if (launchingItem != null) return@playTrailer - launchingItem = item - homeViewModel.trackJourney( - category = "playback", action = "trailer", screen = "details", - feature = "trailer", source = "details", target = "player", - itemName = item.name, itemType = item.type, - ) - homeViewModel.pauseAnalyticsForPlayback() - val launched = runCatching { - playbackLauncher.launch( - PlayerActivity.trailerIntent( - context, - TrailerPlaybackRequest( - subjectId = item.id, - title = item.name, - posterUrl = repo.primaryUrl(item, maxWidth = 500), - logoUrl = repo.logoUrl(item), - ), - ), - ) - } - if (launched.isFailure) { - launchingItem = null - homeViewModel.resumeAnalyticsAfterPlayback() - Toast.makeText(context, "Couldn’t open the trailer", Toast.LENGTH_SHORT).show() - } - } - - val rows = remember( - homeContent, - forYouState, - selectedDestination, - settings.homeSections, - settings.showHomeCardMetadata, - settings.hideWatchedMovies, - ) { - val destinationRows = if (selectedDestination == BrowseDestination.FOR_YOU) { - forYouBrowseRows(forYouState) - } else { - homeRowsFor(selectedDestination, homeContent, settings) - } - applyWatchedVisibility(destinationRows, settings.hideWatchedMovies) - } - // The three launch milestones, recorded from effects rather than from the composable - // body so that measuring the launcher can never be a reason the launcher recomposes. - // Each is keyed on the condition that decides it, so it runs when that condition - // changes rather than on every pass; StartupTrace ignores a repeat in any case. - LaunchedEffect(Unit) { StartupTrace.mark(StartupTrace.HOME_VISIBLE) } - val hasPopulatedRow = rows.any { it.items.isNotEmpty() } - LaunchedEffect(hasPopulatedRow) { - if (hasPopulatedRow) StartupTrace.mark(StartupTrace.FIRST_ROW_VISIBLE) - } - // Interactive means nothing is still arriving. A cached launcher reaches this almost at - // once, which is the whole promise HomeCache makes; a cold one reaches it when the last - // section lands. - val homeSettled = hasPopulatedRow && homeContent.loading.isEmpty() - LaunchedEffect(homeSettled) { - if (homeSettled) StartupTrace.mark(StartupTrace.HOME_INTERACTIVE) - } - // Keyed on the day as well as the rows, so the feature changes when the date does and - // not merely when the launcher happens to be rebuilt. - val heroDay = rememberHomeHeroDay() - // The server rows are passed in *unfiltered*, beside the browse rows the launcher - // draws: serverHomeRows drops the hero row, because it is consumed here rather than - // rendered as a shelf, so this is the only thing that can still see it. - // Warm both section heroes while Home is being read. Navigation then normally draws - // from memory, while the gateway's user/placement cache keeps this cheap across TVs. - // - // The same effect is what makes an operator's hero change arrive on a set that is - // already switched on: the gateway announces the configuration behind the heroes as a - // revision on the status poll, so this waits on that flow rather than on a timer, and - // refetches only when it moves. Collected inside the effect rather than read as state - // in the body, because the launcher must not recompose merely to be told a number. - LaunchedEffect(settings.userId) { - var appliedHeroRevision: String? = null - ServiceLocator.maintenance.heroRevision.collectLatest { revision -> - val moved = appliedHeroRevision != null && - revision.isNotBlank() && revision != appliedHeroRevision - if (revision.isNotBlank()) appliedHeroRevision = revision - coroutineScope { - // Home's hero arrives with the home rows, so it is asked for separately - // only once the configuration behind it has actually moved. Fetching it at - // launch would replace a hero already on screen with a second composition - // of the same thing, which is a flash for nothing. - listOfNotNull( - BrowseDestination.MOVIES to "movies", - BrowseDestination.SHOWS to "tv_shows", - (BrowseDestination.HOME to "home").takeIf { moved }, - ).forEach { (destination, placement) -> - if (!moved && sectionHeroRows.containsKey(destination)) return@forEach - launch { - runCatching { repo.getActiveHero(placement) }.onSuccess { resolved -> - // Replaced one placement at a time, never cleared first: the - // cards that did not change keep the artwork already decoded - // for them and the row never blanks between the two states. - sectionHeroRows = sectionHeroRows + (destination to resolved) - } - } - } - } - } - } - LaunchedEffect(selectedDestination, settings.userId) { - val placement = when (selectedDestination) { - BrowseDestination.MOVIES -> "movies" - BrowseDestination.SHOWS -> "tv_shows" - else -> null - } ?: return@LaunchedEffect - if (sectionHeroRows.containsKey(selectedDestination)) return@LaunchedEffect - runCatching { repo.getActiveHero(placement) }.onSuccess { resolved -> - sectionHeroRows = sectionHeroRows + (selectedDestination to resolved) - } - } - val contextualHeroItems = remember(rows, homeContent.rows, sectionHeroRows, selectedDestination, heroDay) { - when (selectedDestination) { - // The separately fetched home hero wins where there is one, which is only - // after the configuration moved under a set sitting on this screen; otherwise - // the hero row that came with the home payload is still the right answer. - BrowseDestination.HOME -> selectHomeHeroMovies( - rows, - heroDay, - sectionHeroRows[BrowseDestination.HOME]?.takeIf(List::isNotEmpty) - ?: homeContent.rows, - ) - BrowseDestination.MOVIES, BrowseDestination.SHOWS -> - serverHeroPicks(sectionHeroRows[selectedDestination].orEmpty()) - else -> emptyList() - } - } - val showHomeGreeting = selectedDestination == BrowseDestination.HOME && - shouldShowHomeGreeting( - hasHero = contextualHeroItems.isNotEmpty(), - focusedRowId = focusedHomeRowId, - rowIds = rows.map(HomeBrowseRow::id), - ) - LaunchedEffect(selectedDestination) { - focusedHomeRowId = null - } - 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() - } - } - val journeyScreen = when { - showSettings -> "settings" - showRequests -> "requests" - showNotifications -> "notifications" - userSwitcherVisible || showProfiles -> "profiles" - detailsItem != null -> "details" - selectedMyShow != null -> "my_show_details" - else -> selectedDestination.name.lowercase() - } - LaunchedEffect(journeyScreen) { - homeViewModel.trackJourney( - category = "navigation", action = "screen_view", screen = journeyScreen, - feature = journeyScreen, target = journeyScreen, - ) - } - val latestJourneyScreen by rememberUpdatedState(journeyScreen) - LaunchedEffect(showForYouNudge, selectedDestination, settings.hasOpenedForYou) { - if ( - showForYouNudge && - selectedDestination == BrowseDestination.HOME && - !settings.hasOpenedForYou - ) { - kotlinx.coroutines.delay(5_000L) - showForYouNudge = false - } - } - LaunchedEffect( - selectedDestination, - contextualHeroItems.firstOrNull()?.item?.id, - rows.firstOrNull()?.items?.firstOrNull()?.id, - ) { - val firstItem = contextualHeroItems.firstOrNull()?.item - ?: rows.firstNotNullOfOrNull { it.items.firstOrNull() } - firstItem?.let(homeViewModel::focusItem) - if (firstItem != null && !initialFocusRequested) { - kotlinx.coroutines.delay(16L) - if (runCatching { contentFocusRequester.requestFocus() }.isSuccess) { - initialFocusRequested = true - } - } - } - - // Engagement is uploaded on a timer, but a viewer who opens the player after sitting - // on one row for a minute would otherwise lose that minute. Flushing on ON_STOP (and - // on dispose) closes the open dwell measurement while it still means something. - val lifecycleOwner = androidx.lifecycle.compose.LocalLifecycleOwner.current - DisposableEffect(lifecycleOwner, homeViewModel) { - val observer = androidx.lifecycle.LifecycleEventObserver { _, event -> - if (event == androidx.lifecycle.Lifecycle.Event.ON_STOP) homeViewModel.flushAnalytics() - } - lifecycleOwner.lifecycle.addObserver(observer) - onDispose { - lifecycleOwner.lifecycle.removeObserver(observer) - homeViewModel.endJourney(latestJourneyScreen) - } - } - - val contentShift by animateDpAsState( - targetValue = if (navigationExpanded) remoteConfig.navigation.contentShiftDp.dp else 0.dp, - animationSpec = tween(150), - label = "navigation-content-shift", - ) - Box(Modifier.fillMaxSize().background(MembySurface)) { - Row(Modifier.fillMaxSize()) { - TvNavigationRail( - config = remoteConfig.navigation, - selected = if (userSwitcherVisible) { - BrowseDestination.PROFILES - } else { - selectedDestination - }, - focusDestination = railFocusDestination, - expanded = navigationExpanded, - navigationFocusRequester = navigationFocusRequester, - onRailFocusChanged = { - navigationExpanded = it - }, - onDestinationSelected = { destination -> - homeViewModel.trackJourney( - category = "navigation", action = "select", - screen = journeyScreen, feature = destination.name.lowercase(), - source = journeyScreen, target = destination.name.lowercase(), - ) - when (destination) { - BrowseDestination.SETTINGS -> { - railFocusDestination = BrowseDestination.SETTINGS - restoreRailAfterSettings = true - navigationExpanded = false - userSwitcherVisible = false - showSettings = true - } - BrowseDestination.PROFILES -> { - railFocusDestination = BrowseDestination.PROFILES - userSwitcherVisible = true - // The picker and the shortcut menu are two answers to one control; only - // one of them may ever be on screen. - userQuickActionsVisible = false - navigationExpanded = false - // The rail stays live beside Settings, so a destination - // chosen from there has to close it or it would sit over - // whatever was just asked for. - showSettings = false - restoreRailAfterSettings = false - } - else -> { - navigationExpanded = false - userSwitcherVisible = false - showSettings = false - restoreRailAfterSettings = false - selectedDestination = destination - railFocusDestination = destination - val savedFocus = destinationFocus[destination] - savedFocus?.let { (rowId, itemId) -> - returnRowId = rowId - returnItemId = itemId - // Only the id was remembered, so the kind is cleared rather - // than left carrying whichever row was focused on the - // destination being left; the id alone still names the - // shelves that matter. - returnRowKind = null - } - if ( - savedFocus != null && - savedFocus.first != HOME_HERO_ROW_ID && - savedFocus.first != SEARCH_ROW_ID - ) { - // A low shelf may have fallen out of LazyColumn composition - // while the rail owned focus. Let the list scroll it back in - // before asking its card for focus. - rowListFocusRestoreRequest += 1 - } else { - scope.launch { - // Let the destination compose and attach its entry target - // before transferring focus out of the rail. - kotlinx.coroutines.delay(16L) - if (savedFocus != null) { - requestFirstAvailableFocus( - cardReturnFocusRequester, - contentFocusRequester, - navigationFocusRequester, - ) - } else { - requestFirstAvailableFocus(contentFocusRequester, navigationFocusRequester) - } - } - } - } - } - }, - alertCount = displayedNotifications.size, - activeUsername = settings.username.orEmpty(), - activeProfileInitials = settings.profileInitials, - calendarEnabled = tvCalendarEnabled, - genresEnabled = genreBrowserEnabled, - // A hold opens the shortcut menu; the ordinary press still opens the user - // picker, so nothing a viewer already knows how to do has moved. - onUserLongPressed = { - homeViewModel.trackJourney( - category = "navigation", action = "open", screen = journeyScreen, - feature = "user_switcher_shortcuts", source = journeyScreen, - target = "user_shortcuts", - ) - userSwitcherVisible = false - navigationExpanded = false - // So closing the menu hands focus back to the item that was held, rather - // than to whichever destination the rail was last aimed at. - railFocusDestination = BrowseDestination.PROFILES - userQuickActionsVisible = true - }, - ) - androidx.compose.foundation.layout.BoxWithConstraints( - modifier = Modifier - .weight(1f) - .fillMaxSize() - .offset { IntOffset(x = contentShift.roundToPx(), y = 0) }, - ) { - val maintenanceMessage = liveMaintenance?.message ?: homeStatus.maintenanceMessage - if (maintenanceMessage != null) { - // The rows are gone but the rail is not: Settings and the active user are - // local, so there is no reason to strand the viewer here. - MaintenanceScreen( - message = maintenanceMessage, - contentFocusRequester = contentFocusRequester, - navigationFocusRequester = navigationFocusRequester, - onRetry = homeViewModel::refreshAll, - ) - LaunchedEffect(Unit) { - kotlinx.coroutines.delay(450L) - runCatching { contentFocusRequester.requestFocus() } - } - return@BoxWithConstraints - } - - if (selectedDestination == BrowseDestination.SEARCH) { - // Remembered, or this list is a fresh instance on every recomposition - // and the screen re-derives its genre chips each time. - val discovery = remember(homeContent.rows, homeContent.latestMovies, homeContent.continueWatching) { - homeContent.rows.flatMap { it.items } - .ifEmpty { homeContent.continueWatching + homeContent.latestMovies } - .distinctBy(BaseItem::id) - } - // Its own pane rather than a row list: the keyboard and the results - // need the whole content area. The rail stays beside it, and results - // open the same details overlay the rows do. - SearchScreen( - navigationFocusRequester = navigationFocusRequester, - contentFocusRequester = contentFocusRequester, - // Already in memory from the home response — the empty state - // costs nothing to fill. - discoveryItems = discovery, - returnFocusItemId = returnItemId.takeIf { returnRowId == SEARCH_ROW_ID }, - returnFocusRequester = cardReturnFocusRequester, - onSearchStarted = { - homeViewModel.trackJourney( - category = "search", action = "start", screen = "search", - feature = "search", source = "search", target = "search_results", - ) - }, - onItemFocused = homeViewModel::focusItem, - onItemSelected = { item -> - returnRowId = SEARCH_ROW_ID - returnRowKind = null - returnItemId = item.id - destinationFocus[BrowseDestination.SEARCH] = SEARCH_ROW_ID to item.id - homeViewModel.focusItem(item) - if (item.membyPlayable) { - homeViewModel.trackJourney( - category = "content", action = "open", screen = "search", - feature = "search", source = "search_results", target = "details", - itemName = item.name, itemType = item.type, - ) - detailsAiringNotice = null - detailsItem = item - } - }, - onContentFocused = { navigationExpanded = false }, - onExit = { - selectedDestination = BrowseDestination.HOME - scope.launch { - kotlinx.coroutines.delay(16L) - runCatching { contentFocusRequester.requestFocus() } - } - }, - ) - return@BoxWithConstraints - } - - if (selectedDestination == BrowseDestination.CALENDAR) { - // Its own pane rather than a row list: a month grid needs the whole - // content area, and there is no shelf shape that answers "what is on - // in three weeks". The rail stays beside it as it does for Search. - CalendarScreen( - navigationFocusRequester = navigationFocusRequester, - contentFocusRequester = contentFocusRequester, - onItemFocused = homeViewModel::focusItem, - onItemSelected = { item -> - homeViewModel.trackJourney( - category = "content", action = "open", screen = "calendar", - feature = "tv_calendar", source = "calendar_day", - target = "details", itemName = item.name, itemType = item.type, - ) - // The same substitution the schedule row makes: a calendar card - // is an episode that has not aired, so what was asked for is the - // show, carrying the air time across because that is why it was - // pressed. - val seriesStub = scheduleSeriesStub(item) - if (seriesStub != null) { - detailsAiringNotice = airingNoticeFor(item) - homeViewModel.focusItem(seriesStub) - detailsItem = seriesStub - } else if (item.membyPlayable) { - detailsAiringNotice = null - homeViewModel.focusItem(item) - detailsItem = item - } - }, - onExit = { - selectedDestination = BrowseDestination.HOME - scope.launch { - kotlinx.coroutines.delay(16L) - runCatching { contentFocusRequester.requestFocus() } - } - }, - ) - return@BoxWithConstraints - } - - // The rail's own Genres destination browses the catalogue, films and shows - // together — a household browses "Comedy", not "comedy films". It is the - // one way in: the Movies and TV Series pages carry no genre row of their - // own, so neither of those grids can cross media types. - if (selectedDestination == BrowseDestination.GENRES) { - GenreBrowseScreen( - itemType = com.ponzischeme89.memby.ui.genre.ALL_MEDIA_ITEM_TYPE, - initialCategoryId = com.ponzischeme89.memby.ui.genre.ALL_MEDIA_CATEGORY_ID, - favouriteStates = favoriteChanges, - playedStates = playedChanges, - navigationFocusRequester = navigationFocusRequester, - contentFocusRequester = contentFocusRequester, - returnFocusItemId = returnItemId.takeIf { returnRowId == GENRE_BROWSER_ROW_ID }, - returnFocusRequester = cardReturnFocusRequester, - onItemFocused = homeViewModel::focusItem, - onItemSelected = { item -> - returnRowId = GENRE_BROWSER_ROW_ID - returnRowKind = null - returnItemId = item.id - destinationFocus[BrowseDestination.GENRES] = - GENRE_BROWSER_ROW_ID to item.id - homeViewModel.focusItem(item) - homeViewModel.trackJourney( - category = "content", action = "open", screen = "genres", - feature = "genre_browse", source = "genre_results", target = "details", - itemName = item.name, itemType = item.type, - ) - detailsAiringNotice = null - detailsItem = item - }, - onContentFocused = { navigationExpanded = false }, - onClose = { - selectedDestination = BrowseDestination.HOME - railFocusDestination = BrowseDestination.HOME - scope.launch { - kotlinx.coroutines.delay(16L) - runCatching { contentFocusRequester.requestFocus() } - } - }, - ) - return@BoxWithConstraints - } - - FocusedHomeBackdrop(homeViewModel) - val verticalState = verticalStates.getOrPut(selectedDestination.name) { - LazyListState() - } - val homeListAtTop by remember(verticalState) { - derivedStateOf { - verticalState.firstVisibleItemIndex == 0 && - verticalState.firstVisibleItemScrollOffset == 0 - } - } - val hasContextualHero = contextualHeroItems.isNotEmpty() - // Set by a card in a shelf taking focus and cleared by the hero taking it - // back. Keyed on the destination so arriving at Home never inherits where - // focus happened to be on another one. - var rowFocusedBelowHero by remember(selectedDestination) { mutableStateOf(false) } - val showHomeHero = shouldShowHomeMovieHero( - hasMovies = hasContextualHero, - listAtTop = homeListAtTop, - rowFocused = rowFocusedBelowHero, - ) - val metadataHeight = homeHeaderHeight(maxHeight, showHomeHero) - val contentWidth = maxWidth - HomeArtworkPreloader(rows = rows, availableWidth = contentWidth) - val rowFocusPositions = rememberedRowFocus.getOrPut(selectedDestination) { - mutableMapOf() - } - val rowIndexes = remember(rows) { - rows.mapIndexed { index, row -> row.id to index }.toMap() - } - val rowItemCounts = remember(rows) { rows.map { it.items.size } } - var pendingRowFocus by remember(selectedDestination) { - mutableStateOf(null) - } - var rowFocusMoving by remember(selectedDestination) { mutableStateOf(false) } - var rowFocusRequestId by remember(selectedDestination) { mutableStateOf(0) } - // The latch above is what stops a held D-pad stacking one move on top of - // another, and until now the only thing that lifted it was the destination - // row reporting the request consumed. A request nothing consumes therefore - // took every later Up and Down press with it — the row list stopped moving - // where it stood and the only way out was the rail. Nothing may consume it - // when the destination row was not composed by the scroll, when that scroll - // was interrupted by another one, or when the row is replaced by an arriving - // refresh while the request is in flight. So the latch is bounded rather - // than trusted: whatever happened, one press can hold vertical navigation - // for ROW_FOCUS_MOVE_TIMEOUT_MS and no longer. - LaunchedEffect(pendingRowFocus?.requestId, rowFocusMoving) { - if (!rowFocusMoving) return@LaunchedEffect - kotlinx.coroutines.delay(ROW_FOCUS_MOVE_TIMEOUT_MS) - pendingRowFocus = null - rowFocusMoving = false - } - val firstPopulatedRowId = remember(rows) { - rows.firstOrNull { candidate -> candidate.items.isNotEmpty() }?.id - } - // My Shows is a band of its own above the shelves, so on this page it is the - // first stop below the hero and the thing Up out of the topmost shelf has to - // return to. Both moves used to jump over it: the hero pointed Down straight - // at the first shelf, and Up out of that shelf found no shelf above it and - // went to the hero. - val myShowsStripFocusable = - selectedDestination == BrowseDestination.SHOWS && myShows.isNotEmpty() - // Must agree exactly with the items placed above the rows in the LazyColumn - // below, because it is what turns a row index into a scroll target. - // Counting one that is not placed scrolls a row short of the destination, - // which lands the requested card outside the composed window and leaves - // the move with nothing to complete it. - val leadingItemCount = - (if (selectedDestination == BrowseDestination.SHOWS) 1 else 0) + - (if (selectedDestination == BrowseDestination.FOR_YOU) 1 else 0) - LaunchedEffect(rowListFocusRestoreRequest, selectedDestination) { - if (rowListFocusRestoreRequest == 0) return@LaunchedEffect - val rowId = returnRowId ?: run { - rowListFocusRestoreRequest = 0 - return@LaunchedEffect - } - val rowIndex = rows.indexOfFirst { it.id == rowId } - if (rowIndex < 0) { - rowListFocusRestoreRequest = 0 - kotlinx.coroutines.delay(16L) - runCatching { contentFocusRequester.requestFocus() } - return@LaunchedEffect - } - // FocusRequester cannot focus a lazy child that is not composed. The - // airing shelf is commonly just beyond the retained viewport, which - // made focus fall back to its still-attached neighbour above. - verticalState.scrollToItem(leadingItemCount + rowIndex) - repeat(3) { - kotlinx.coroutines.delay(16L) - if (runCatching { cardReturnFocusRequester.requestFocus() }.isSuccess) { - rowListFocusRestoreRequest = 0 - return@LaunchedEffect - } - } - rowListFocusRestoreRequest = 0 - } - // Leaving My Shows vertically. Neither direction can be a plain - // focusProperties target: the shelf below is a lazy child composed by the - // scroll rather than something already there to receive the press, and the - // hero is not composed at all while a shelf holds focus. - val enterFirstPopulatedRow: () -> Boolean = enter@{ - if (rowFocusMoving) return@enter true - val destinationRowIndex = rows.indexOfFirst { it.items.isNotEmpty() } - if (destinationRowIndex < 0) return@enter false - val destinationRow = rows[destinationRowIndex] - val destinationItemIndex = rowEntryItemIndex( - sourceIndex = 0, - destinationItemCount = destinationRow.items.size, - rememberedDestinationIndex = rowFocusPositions[destinationRow.id], - ) - rowFocusPositions[destinationRow.id] = destinationItemIndex - rowFocusRequestId += 1 - val request = RowFocusRequest( - rowId = destinationRow.id, - itemIndex = destinationItemIndex, - requestId = rowFocusRequestId, - ) - rowFocusMoving = true - scope.launch { - try { - verticalState.animateScrollToItem( - leadingItemCount + destinationRowIndex, - ) - } catch (cancelled: kotlinx.coroutines.CancellationException) { - // Same bargain the shelves' own moves strike: ask for the card - // anyway, and let the request expire if nothing consumes it. - pendingRowFocus = request - throw cancelled - } - pendingRowFocus = request - } - true - } - val returnToMyShows: () -> Boolean = ret@{ - if (!myShowsStripFocusable) return@ret false - // The strip sits above the shelves with the hero, so coming back up to - // it brings the hero back with it. - rowFocusedBelowHero = false - scope.launch { - verticalState.animateScrollToItem(0) - kotlinx.coroutines.delay(16L) - runCatching { myShowsEntryFocusRequester.requestFocus() } - } - true - } - Column(Modifier.fillMaxSize()) { - if (showHomeHero) { - HomeMovieHero( - movies = contextualHeroItems, - navigationFocusRequester = navigationFocusRequester, - contentEntryFocusRequester = contentFocusRequester, - returnFocusItemId = returnItemId.takeIf { returnRowId == HOME_HERO_ROW_ID }, - returnFocusRequester = cardReturnFocusRequester, - // Only when there is a card down there to attach it to: a - // requester naming nothing throws the moment Down is pressed. - // My Shows first where there is one, then the first shelf with - // cards in it. Only ever a requester with a card behind it: one - // naming nothing throws the moment Down is pressed. - downFocusRequester = if (myShowsStripFocusable) { - myShowsEntryFocusRequester - } else { - heroRowEntryFocusRequester.takeIf { firstPopulatedRowId != null } - }, - onItemFocused = { item -> - navigationExpanded = false - rowFocusedBelowHero = false - focusedHomeRowId = null - destinationFocus[selectedDestination] = HOME_HERO_ROW_ID to item.id - returnRowId = HOME_HERO_ROW_ID - returnRowKind = null - returnItemId = item.id - homeViewModel.focusItem(item) - }, - onItemSelected = { item -> - returnRowId = HOME_HERO_ROW_ID - returnRowKind = null - returnItemId = item.id - homeViewModel.focusItem(item) - homeViewModel.trackJourney( - category = "content", action = "open", screen = selectedDestination.name.lowercase(), - feature = "hero", source = HOME_HERO_ROW_ID, target = "details", - itemName = item.name, itemType = item.type, - ) - detailsAiringNotice = null - detailsItem = item - }, - modifier = Modifier.height(metadataHeight), - ) - } else { - FocusedHomeMetadata( - homeViewModel = homeViewModel, - sectionLabel = selectedDestination.label, - modifier = Modifier - .height(metadataHeight) - .padding(start = 36.dp, end = 36.dp, top = 24.dp, bottom = 10.dp), - ) - } - androidx.compose.animation.AnimatedVisibility( - visible = homeStatus.hasRefreshError, - enter = androidx.compose.animation.fadeIn(tween(180)), - exit = androidx.compose.animation.fadeOut(tween(120)), - ) { - Text( - // The gateway's maintenance notice when it sent one, and the - // generic wording otherwise. - homeStatus.statusMessage ?: "Connection is slow — showing available content", - color = MembyMutedText, - fontSize = 14.sp, - modifier = Modifier.padding(horizontal = 36.dp, vertical = 4.dp), - ) - } - LazyColumn( - state = verticalState, - modifier = Modifier - .weight(1f) - .fillMaxWidth(), - contentPadding = androidx.compose.foundation.layout.PaddingValues(bottom = 96.dp), - verticalArrangement = Arrangement.spacedBy(14.dp), - ) { - if (selectedDestination == BrowseDestination.SHOWS) { - item(key = "my-shows", contentType = "my-shows") { - MyShowsStrip( - shows = myShows, - loading = myShowsLoading, - errorMessage = myShowsError, - onRetry = { - if (!myShowsLoading) scope.launch { - myShowsLoading = true - myShowsError = null - runCatching { repo.getMyShows() } - .onSuccess { myShows = it } - .onFailure { myShowsError = friendlyEmbyError(it) } - myShowsLoading = false - } - }, - repository = repo, - availableWidth = contentWidth, - density = settings.homeCardDensity, - navigationFocusRequester = navigationFocusRequester, - // My Shows is the first reachable content on this page — - // unless the hero is up, which owns the rail's entry - // target the way it does on every other page. Keyed on - // the hero actually being drawn rather than on there - // being one: a requester attached to two live nodes is - // ambiguous, and one attached to a hero that is not - // composed would leave the rail with nowhere to hand - // focus to. - contentFocusRequester = contentFocusRequester.takeIf { - !showHomeHero - }, - entryFocusRequester = myShowsEntryFocusRequester, - returnFocusItemId = myShowReturnItemId, - returnFocusRequester = myShowReturnFocusRequester, - onMoveVertical = { direction -> - when (direction) { - RowFocusDirection.DOWN -> enterFirstPopulatedRow() - RowFocusDirection.UP -> if (hasContextualHero) { - rowFocusedBelowHero = false - scope.launch { - verticalState.animateScrollToItem(0) - // Let the hero compose and attach its - // entry target before focus is handed up. - kotlinx.coroutines.delay(16L) - runCatching { - contentFocusRequester.requestFocus() - } - } - true - } else { - false - } - } - }, - onShowSelected = { - homeViewModel.trackJourney( - category = "content", action = "open", screen = "shows", - feature = "my_shows", source = "my_shows", target = "my_show_details", - itemName = it.title, - ) - myShowReturnItemId = it.itemId - selectedMyShow = it - }, - onContentFocused = { navigationExpanded = false }, - ) - } - } - if (selectedDestination == BrowseDestination.FOR_YOU) { - item(key = "for-you-time", contentType = "for-you-time") { - ForYouTimeBudget( - selectedMinutes = forYouState.availableMinutes, - loading = forYouState.loading, - error = forYouState.error, - onSelected = { minutes -> - homeViewModel.trackJourney( - category = "recommendations", action = "change", screen = "for_you", - feature = "for_you_time", outcome = "success", - ) - homeViewModel.loadForYou(minutes) - scope.launch { repo.setForYouMinutes(minutes) } - }, - ) - } - } - items( - items = rows, - key = { it.id }, - contentType = { "media-row-${it.kind}" }, - ) { row -> - // LazyColumn composes a row as it scrolls into view, which is - // as close to "the viewer saw it" as the TV can observe. - LaunchedEffect(row.id) { - // The first eight posters cover the initial TV viewport - // with headroom across density settings. Later posters - // only become evidence when focus actually reaches them. - homeViewModel.trackRowImpression( - row.id, - row.kind.name, - row.items.take(8).map { it.id }, - ) - } - MediaRow( - row = row, - availableWidth = contentWidth, - navigationFocusRequester = navigationFocusRequester, - contentEntryFocusRequester = contentFocusRequester.takeIf { - !hasContextualHero && - (selectedDestination != BrowseDestination.SHOWS || - myShows.isEmpty()) && - row.id == firstPopulatedRowId - }, - heroEntryFocusRequester = heroRowEntryFocusRequester.takeIf { - hasContextualHero && row.id == firstPopulatedRowId - }, - returnFocusItemId = returnItemId.takeIf { returnRowId == row.id }, - returnFocusRequester = cardReturnFocusRequester, - verticalFocusRequest = pendingRowFocus, - onVerticalFocusRequestConsumed = { requestId -> - if (pendingRowFocus?.requestId == requestId) { - pendingRowFocus = null - rowFocusMoving = false - } - }, - onMoveVertical = moveVertical@{ sourceItemIndex, direction -> - if (rowFocusMoving) return@moveVertical true - val sourceRowIndex = rowIndexes[row.id] ?: return@moveVertical false - val destinationRowIndex = adjacentFocusableRowIndex( - itemCounts = rowItemCounts, - currentIndex = sourceRowIndex, - direction = direction, - ) ?: run { - // Up out of the topmost shelf is the way back to the - // hero, and it has to be handled here: the hero is - // not composed while a row holds focus, so there is - // nothing above for Compose's own focus search to - // find and the press would otherwise be dead. - // My Shows is the band directly above the shelves, - // so it answers this press before the hero does. - if (direction == RowFocusDirection.UP && returnToMyShows()) { - return@moveVertical true - } - if (direction == RowFocusDirection.UP && hasContextualHero) { - rowFocusedBelowHero = false - scope.launch { - verticalState.animateScrollToItem(0) - // Let the hero compose and attach its entry - // target before focus is handed to it. - kotlinx.coroutines.delay(16L) - runCatching { contentFocusRequester.requestFocus() } - } - return@moveVertical true - } - return@moveVertical false - } - val destinationRow = rows[destinationRowIndex] - val destinationItemIndex = rowEntryItemIndex( - sourceIndex = sourceItemIndex, - destinationItemCount = destinationRow.items.size, - rememberedDestinationIndex = rowFocusPositions[destinationRow.id], - ) - rowFocusPositions[row.id] = sourceItemIndex - rowFocusPositions[destinationRow.id] = destinationItemIndex - rowFocusRequestId += 1 - val request = RowFocusRequest( - rowId = destinationRow.id, - itemIndex = destinationItemIndex, - requestId = rowFocusRequestId, - ) - rowFocusMoving = true - scope.launch { - try { - // Compose the destination row before its MediaRow - // tries to attach the requested card's focus node. - verticalState.animateScrollToItem( - leadingItemCount + destinationRowIndex, - ) - } catch (cancelled: kotlinx.coroutines.CancellationException) { - // Another scroll took the list over mid-animation, so - // this move is never going to post its request and - // nothing downstream would ever lift the latch. Ask - // for the card anyway — the row it is in is where the - // list has been left, and the worst case is the - // request expiring like any other. - pendingRowFocus = request - throw cancelled - } - pendingRowFocus = request - } - true - }, - onContentFocused = { navigationExpanded = false }, - onItemFocused = { item -> - val itemIndex = row.items.indexOfFirst { it.id == item.id } - if (itemIndex >= 0) rowFocusPositions[row.id] = itemIndex - rowFocusedBelowHero = true - if (selectedDestination == BrowseDestination.HOME) { - focusedHomeRowId = row.id - } - destinationFocus[selectedDestination] = row.id to item.id - returnRowId = row.id - returnRowKind = row.kind.name - returnItemId = item.id - homeViewModel.focusItem(item) - homeViewModel.trackRowFocused(row.id, row.kind.name, item.id) - }, - onItemSelected = { item -> - returnRowId = row.id - returnRowKind = row.kind.name - returnItemId = item.id - homeViewModel.trackRowSelected(row.id, row.kind.name, item.id) - homeViewModel.trackJourney( - category = "content", action = "open", - screen = selectedDestination.name.lowercase(), feature = row.kind.name.lowercase(), - source = row.id, target = if (item.membyPlayable) "details" else "content_action", - itemName = item.name, itemType = item.type, - ) - // A schedule card is an episode that has not aired, so - // it is not playable and has no page of its own. What - // the viewer asked for is the show — carrying the air - // time across, since that is why they pressed it. - val seriesStub = scheduleSeriesStub(item) - // A movie-schedule card whose film Emby has since - // imported is the ordinary movie page; one whose film - // is still only Radarr's opens its own. Neither is - // inert, which is what the card used to be. - val movieStub = scheduleMovieStub(item) - if (seriesStub != null) { - detailsAiringNotice = airingNoticeFor(item) - homeViewModel.focusItem(seriesStub) - detailsItem = seriesStub - } else if (movieStub != null) { - detailsAiringNotice = null - homeViewModel.focusItem(movieStub) - detailsItem = movieStub - } else { - homeViewModel.focusItem(item) - if (item.membyPlayable || item.isRadarrOnly) { - detailsAiringNotice = null - detailsItem = item - } - } - }, - onItemLongPressed = { item -> - returnRowId = row.id - returnRowKind = row.kind.name - returnItemId = item.id - homeViewModel.focusItem(item) - if (item.membyPlayable || item.isRadarrOnly) { - // Cleared here rather than only in the effect that - // answers it: the effect runs after the menu's - // first frame, and the previous card's answer - // showing on it would be an entry that appears and - // then vanishes. - quickMenuTrailerAvailable = false - quickMenuRowId = row.id - quickMenuItem = item - } - }, - density = settings.homeCardDensity, - artworkStyle = settings.homeArtworkStyle, - horizontalState = horizontalStates.getOrPut( - "${selectedDestination.name}:${row.id}", - ) { LazyListState() }, - ) - } - } - } - } - } - androidx.compose.animation.AnimatedVisibility( - visible = compatibilityNotice != null, - modifier = Modifier.align(Alignment.TopCenter), - enter = androidx.compose.animation.fadeIn(tween(180)), - exit = androidx.compose.animation.fadeOut(tween(120)), - ) { - Text( - text = compatibilityNotice?.message.orEmpty(), - color = Color.White, - fontSize = 16.sp, - fontWeight = FontWeight.SemiBold, - modifier = Modifier - .fillMaxWidth() - .background(Color(0xFFD6403A)) - .padding(horizontal = 36.dp, vertical = 12.dp), - ) - } - HomeClock( - showGreeting = showHomeGreeting, - username = settings.username, - shortName = settings.shortName, - modifier = Modifier - .align(Alignment.BottomEnd) - .padding(end = 24.dp, bottom = 18.dp), - ) - if (userSwitcherVisible) { - BackHandler { - userSwitcherVisible = false - scope.launch { - kotlinx.coroutines.delay(16L) - runCatching { navigationFocusRequester.requestFocus() } - } - } - UserSwitcherOverlay( - profiles = settings.profiles, - activeProfileId = settings.activeProfileId, - onProfileSelected = { profile -> - userSwitcherVisible = false - navigationExpanded = false - if (profile.id != settings.activeProfileId) { - switchingProfileId = profile.id - scope.launch { - homeViewModel.endJourneyBeforeProfileSwitch(journeyScreen) - runCatching { repo.switchProfile(profile) } - .onFailure { switchingProfileId = null } - } - } else { - scope.launch { - kotlinx.coroutines.delay(16L) - runCatching { navigationFocusRequester.requestFocus() } - } - } - }, - onManageProfiles = { - userSwitcherVisible = false - navigationExpanded = false - showProfiles = true - }, - alertCount = displayedNotifications.size, - onOpenSettings = { - homeViewModel.trackJourney( - category = "navigation", action = "select", - screen = journeyScreen, feature = "settings", - source = journeyScreen, target = "settings", - ) - userSwitcherVisible = false - navigationExpanded = false - railFocusDestination = BrowseDestination.PROFILES - restoreRailAfterSettings = true - showSettings = true - }, - onOpenAlerts = { - homeViewModel.trackJourney( - category = "notifications", action = "open", screen = journeyScreen, - feature = "notifications", target = "notifications", - ) - userSwitcherVisible = false - navigationExpanded = false - showNotifications = true - scope.launch { - notificationsLoading = true - notificationsError = null - runCatching { repo.getNotifications() } - .onSuccess { notificationState = it } - .onFailure { notificationsError = friendlyEmbyError(it) } - notificationsLoading = false - } - }, - showViewers = shouldOfferViewerPicker(ServerConfig.isGateway, viewers.size), - activeViewerId = settings.activeViewerId, - activeViewerName = settings.activeViewerName, - onOpenViewers = { - userSwitcherVisible = false - navigationExpanded = false - showViewerPicker = true - }, - showRequests = requestsAllowed, - onOpenRequests = { - homeViewModel.trackJourney( - category = "requests", action = "open", screen = journeyScreen, - feature = "requests", target = "requests", - ) - userSwitcherVisible = false - navigationExpanded = false - showRequests = true - }, - onDismiss = { - userSwitcherVisible = false - scope.launch { - kotlinx.coroutines.delay(16L) - runCatching { navigationFocusRequester.requestFocus() } - } - }, - ) - } - if (showViewerPicker) { - val closeViewerPicker: () -> Unit = { - showViewerPicker = false - scope.launch { - kotlinx.coroutines.delay(16L) - runCatching { navigationFocusRequester.requestFocus() } - } - } - BackHandler(onBack = closeViewerPicker) - Box(Modifier.fillMaxSize().zIndex(20f).background(MembySurface)) { - ViewerPicker( - viewers = viewers, - activeViewerId = settings.activeViewerId, - onViewerSelected = { viewer -> - closeViewerPicker() - scope.launch { - // Everything on the launcher belongs to the outgoing viewer, so - // the journey is closed and the rows are refreshed rather than - // left standing under a different person's name. - homeViewModel.endJourneyBeforeProfileSwitch(journeyScreen) - repo.switchViewer(viewer) - homeViewModel.refreshAll() - } - }, - // Both open over the picker rather than instead of it, so Back is one - // step out of each and the row of faces is still underneath. - onAddViewer = { openViewerName(ViewerNameTarget.Add) }, - onManageViewers = { showViewerManage = true }, - canAddViewer = viewers.count { !it.isMain } < MAX_SHADOW_VIEWERS, - ) - } - } - if (showViewerManage) { - BackHandler(onBack = { showViewerManage = false }) - Box(Modifier.fillMaxSize().zIndex(21f).background(MembySurface)) { - ViewerManageScreen( - viewers = viewers, - onRename = { openViewerName(ViewerNameTarget.Rename(it)) }, - onRemove = { viewer -> - viewerBusyId = viewer.id - scope.launch { - // Removing whoever is watching returns this set to the account, - // which the repository does; the launcher has to be told, or it - // goes on drawing the removed person's rows. - val watching = viewer.id == settings.activeViewerId - val removed = repo.removeViewer(viewer) - if (removed && watching) { - homeViewModel.endJourneyBeforeProfileSwitch(journeyScreen) - homeViewModel.refreshAll() - } - refreshViewers() - viewerBusyId = null - } - }, - onAdd = { openViewerName(ViewerNameTarget.Add) }, - onClose = { showViewerManage = false }, - canAddViewer = viewers.count { !it.isMain } < MAX_SHADOW_VIEWERS, - busyViewerId = viewerBusyId, - ) - } - } - viewerNameTarget?.let { target -> - val closeViewerName = { viewerNameTarget = null } - BackHandler(onBack = closeViewerName) - Box(Modifier.fillMaxSize().zIndex(22f).background(MembySurface)) { - ViewerNameEntry( - target = target, - name = viewerName, - existing = viewers, - onNameChanged = { viewerName = it; viewerNameFailure = null }, - onCancel = closeViewerName, - onConfirm = { - if (!viewerSaving) { - viewerSaving = true - scope.launch { - val saved = when (target) { - ViewerNameTarget.Add -> repo.createViewer(viewerName) - is ViewerNameTarget.Rename -> - repo.renameViewer(target.viewer, viewerName) - } - viewerSaving = false - if (saved == null) { - // The one thing the television can say about a refusal - // it has no wording for. The screen stays up holding - // what was typed, because retyping a name somebody has - // just entered is the worst possible answer to a - // request that failed for a reason nothing here knows. - viewerNameFailure = "That could not be saved. Try again." - } else { - viewerNameTarget = null - refreshViewers() - } - } - } - }, - failure = viewerNameFailure, - saving = viewerSaving, - ) - } - } - if (userQuickActionsVisible) { - val closeUserQuickActions: () -> Unit = { - userQuickActionsVisible = false - scope.launch { - kotlinx.coroutines.delay(16L) - runCatching { navigationFocusRequester.requestFocus() } - } - } - BackHandler(onBack = closeUserQuickActions) - UserQuickActionsOverlay( - username = settings.username.orEmpty(), - alertCount = displayedNotifications.size, - busy = notificationsMutationBusy, - // Clearing from here never takes the viewer anywhere: the menu closes, the - // badge on the rail behind it empties, and a toast says what happened. The - // list is emptied optimistically for the reason the alerts page already is — - // a badge that lingered while the request was in flight is one somebody - // clears twice — and put back untouched if the gateway refuses. - onClearNotifications = { - if (!notificationsMutationBusy) { - notificationsMutationBusy = true - val previous = notificationState - val offered = displayedNotifications.size - val hadLocalUpdate = settings.updateAlertVersion != null - notificationState = notificationState.copy(notifications = emptyList()) - closeUserQuickActions() - scope.launch { - // The update notice is this television's own — there is no server - // row behind it, so it is dismissed locally and counted here. - if (hadLocalUpdate) ServiceLocator.settings.dismissUpdateAlert() - runCatching { repo.clearNotifications() } - .onSuccess { cleared -> - val total = cleared + if (hadLocalUpdate) 1 else 0 - Toast.makeText( - context, - clearedNotificationsMessage(total), - Toast.LENGTH_SHORT, - ).show() - homeViewModel.trackJourney( - category = "notifications", action = "dismiss", - screen = journeyScreen, - feature = "user_switcher_clear_notifications", - source = "user_switcher", target = "notifications", - // The one free-text field, and the count is the only - // thing that separates one use of this shortcut from - // the next. - itemName = "$total cleared", - outcome = "success", - ) - } - .onFailure { - // Put the list back and say so here rather than writing - // notificationsError: this menu never opens the page, and - // an error banner waiting on a page nobody has opened is - // one they meet later with no idea what it is about. - notificationState = previous - Toast.makeText( - context, - "Couldn’t clear notifications", - Toast.LENGTH_SHORT, - ).show() - homeViewModel.trackJourney( - category = "notifications", action = "dismiss", - screen = journeyScreen, - feature = "user_switcher_clear_notifications", - source = "user_switcher", target = "notifications", - itemName = "$offered offered", - outcome = "failure", - ) - } - notificationsMutationBusy = false - } - } - }, - onDismiss = closeUserQuickActions, - ) - } - if (showSettings) { - val closeSettings: () -> Unit = { - showSettings = false - scope.launch { - kotlinx.coroutines.delay(16L) - if (restoreRailAfterSettings) { - navigationExpanded = true - runCatching { navigationFocusRequester.requestFocus() } - restoreRailAfterSettings = false - } else if (returnItemId != null) { - requestFirstAvailableFocus( - cardReturnFocusRequester, - contentFocusRequester, - navigationFocusRequester, - ) - } else { - runCatching { contentFocusRequester.requestFocus() } - } - } - } - BackHandler(onBack = closeSettings) - // Settings is a destination, not a takeover: it leaves the main rail visible - // and reachable so Home, Search and the active user are one Left press away, the - // same as from every other page. The rail below is the live one — this Row - // only reserves its collapsed footprint and takes the same expand shift the - // content area does, so an expanded rail slides Settings aside rather than - // covering its own page list. - // The full expansion, not the home screen's TvRailContentShift: the rows - // tolerate the rail drawing over their left edge, but this panel is painted - // above the rail, so anything short of the whole width would clip the - // labels off the very rail the viewer is moving through. - val settingsShift by animateDpAsState( - targetValue = if (navigationExpanded) { - TvRailExpandedWidth - TvRailCollapsedWidth - } else { - 0.dp - }, - animationSpec = tween(150), - label = "settings-content-shift", - ) - Row(Modifier.fillMaxSize()) { - Spacer(Modifier.width(TvRailCollapsedWidth)) - SettingsSheet( - onClose = closeSettings, - overlay = false, - navigationFocusRequester = navigationFocusRequester, - onAnalyticsEvent = { feature, action -> - homeViewModel.trackJourney( - category = "settings", action = action, screen = "settings", - feature = feature, outcome = "success", - ) - }, - modifier = Modifier - .weight(1f) - .offset { IntOffset(x = settingsShift.roundToPx(), y = 0) }, - ) - } - } - if (showProfiles) { - BackHandler(onBack = { showProfiles = false }) - ProfileChooser( - profiles = settings.profiles, - currentProfileId = settings.activeProfileId, - switchingProfileId = switchingProfileId, - removingProfileId = removingProfileId, - onSelect = { profile -> - if (profile.id == settings.activeProfileId) { - showProfiles = false - } else { - switchingProfileId = profile.id - scope.launch { - homeViewModel.endJourneyBeforeProfileSwitch(journeyScreen) - runCatching { repo.switchProfile(profile) } - .onFailure { switchingProfileId = null } - } - } - }, - onRemove = { profile -> - removingProfileId = profile.id - scope.launch { - runCatching { repo.removeProfile(profile) } - removingProfileId = null - } - }, - onAddProfile = { - scope.launch { - homeViewModel.endJourneyBeforeProfileSwitch(journeyScreen) - addingProfile = true - } - }, - onClose = { showProfiles = false }, - ) - } - // Over the manage-users page rather than instead of it: cancelling comes straight - // back to the list the "+" was pressed from. A successful sign-in makes the new - // viewer active, which re-keys HomeScreen and takes both flags with it. - if (addingProfile) { - SetupScreen( - onCancel = { addingProfile = false }, - onSignedIn = { addingProfile = false }, - ) - } - // Coming back from the player. The page is reopened rather than restored from - // under the player — see [detailsResumeTarget] — and the item is re-requested on - // the way in, so what appears has this playback's progress on it rather than the - // record the row handed over before the film started. - LifecycleResumeEffect(Unit) { - detailsResumeTarget?.let { target -> - detailsResumeTarget = null - if (detailsItem == null) { - homeViewModel.focusItem(target.item) - detailsTrail = target.trail - detailsAiringNotice = target.airingNotice - restoreDetailPosition = true - detailsItem = target.item - } - } - onPauseOrDispose { } - } - detailsItem?.let { selected -> - // A detail page is opened by a press that sets `detailsItem`, so the composition - // this runs in *is* the frame that press caused — which makes it the earliest - // point that can honestly stand for "the viewer asked for this page". Keyed on - // the item, so walking a "More like this" trail times each page separately. - // Debug-only: on a release build both calls return before allocating. - remember(selected.id) { StartupTrace.beginSpan(StartupTrace.DETAIL) } - LaunchedEffect(selected.id) { StartupTrace.endSpan(StartupTrace.DETAIL) } - BackHandler { - val previous = detailsTrail.lastOrNull() - if (previous != null) { - detailsTrail = detailsTrail.dropLast(1) - restoreDetailPosition = true - detailsItem = previous - } else { - restoreDetailPosition = false - detailsItem = null - detailsAiringNotice = null - requestFirstAvailableFocus( - cardReturnFocusRequester, - contentFocusRequester, - navigationFocusRequester, - ) - } - } - FocusedDetailsOverlay( - homeViewModel = homeViewModel, - selected = selected, - restorePosition = restoreDetailPosition, - airingNotice = detailsAiringNotice, - onOpenItem = { related -> - homeViewModel.trackJourney( - category = "recommendations", action = "open", screen = "details", - feature = "related", source = "related", target = "details", - itemName = related.name, itemType = related.type, - ) - detailsTrail = detailsTrail + selected - restoreDetailPosition = false - // Ask for the full record the same way a focused card does. Some - // routes here hand over a stub rather than a row item — the episode - // page's "Show" button knows only the series' id and name — and - // without this the page opens with no logo, no backdrop and no facts, - // because nothing else on this path ever fetches the item. - homeViewModel.focusItem(related) - detailsItem = related - // The notice was about the show that was pressed in the row, not about - // whatever "More like this" leads to. Walking Back does not restore it, - // and should not: it was news, and it has been read. - detailsAiringNotice = null - }, - onOpenEmbyItem = { embyItem -> - // Not a step in the trail: the Radarr page and the Emby page are two - // answers about one title, so Back from here still belongs where the - // card was pressed rather than on the page it replaced. - homeViewModel.focusItem(embyItem) - detailsAiringNotice = null - detailsItem = embyItem - }, - onPlay = { - // Kept, not discarded: this is what the viewer comes back to when the - // film ends or they press Back out of the player. - detailsResumeTarget = DetailsReturn(selected, detailsTrail, detailsAiringNotice) - detailsItem = null - detailsTrail = emptyList() - restoreDetailPosition = false - detailsAiringNotice = null - playItem(it) - }, - onPlayTrailer = playTrailer, - onToggleFavorite = { item, saved -> - homeViewModel.trackJourney( - category = "library", action = if (saved) "favourite" else "unfavourite", - screen = "details", feature = "favorites", itemName = item.name, itemType = item.type, - outcome = "success", - ) - homeViewModel.setFavorite(item, saved) - }, - isMyShow = myShows.any { it.itemId == selected.id }, - onToggleMyShow = { item, saved -> - homeViewModel.trackJourney( - category = "library", action = if (saved) "follow" else "unfollow", - screen = "details", feature = "my_shows", itemName = item.name, itemType = item.type, - outcome = "success", - ) - // Optimistic, the way a favourite already is. Following a show is a - // press with an obvious outcome, and the server's answer to it is the - // *whole* list decorated with Sonarr's lifecycle for every show on it - // — a request the viewer has no reason to sit through watching an - // unchanged button. The row that lands a moment later replaces this - // placeholder; a failure puts the button back where it was. - if (myShowsMutationBusy) return@FocusedDetailsOverlay - myShowsMutationBusy = true - val previous = myShows - myShows = if (saved) { - myShows.filterNot { it.itemId == item.id } + myShowStub(item) - } else { - myShows.filterNot { it.itemId == item.id } - } - if (saved) { - Toast.makeText( - context, - "${item.name} added to My Shows", - Toast.LENGTH_SHORT, - ).show() - } - scope.launch { - if (saved) { - runCatching { repo.saveMyShow(item) } - .onSuccess { myShows = it } - .onFailure { myShows = previous } - } else { - runCatching { repo.removeMyShow(item.id) } - .onFailure { myShows = previous } - } - myShowsMutationBusy = false - } - }, - onTogglePlayed = { item, played -> - homeViewModel.trackJourney( - category = "library", action = if (played) "mark_played" else "mark_unplayed", - screen = "details", feature = "played_status", itemName = item.name, itemType = item.type, - outcome = "success", - ) - homeViewModel.setPlayed(item, played) - }, - onClose = { - detailsItem = null - detailsTrail = emptyList() - restoreDetailPosition = false - detailsAiringNotice = null - requestFirstAvailableFocus( - cardReturnFocusRequester, - contentFocusRequester, - navigationFocusRequester, - ) - }, - ) - } - selectedMyShow?.let { show -> - val closeMyShow: (Boolean) -> Unit = { removed -> - selectedMyShow = null - scope.launch { - kotlinx.coroutines.delay(16L) - val exactStillExists = !removed && myShows.any { it.itemId == myShowReturnItemId } - val restored = exactStillExists && runCatching { - myShowReturnFocusRequester.requestFocus() - }.isSuccess - if (!restored && runCatching { contentFocusRequester.requestFocus() }.isFailure) { - runCatching { navigationFocusRequester.requestFocus() } - } - } - } - BackHandler { closeMyShow(false) } - MyShowDetailsOverlay( - show = show, - repository = repo, - removing = removingMyShow, - onRemove = { - removingMyShow = true - scope.launch { - runCatching { repo.removeMyShow(show.itemId) }.onSuccess { - myShows = myShows.filterNot { it.itemId == show.itemId } - closeMyShow(true) - } - removingMyShow = false - } - }, - onClose = { closeMyShow(false) }, - ) - } - if (showRequests) { - // Reached from the user picker, so leaving it returns to the rail rather than to - // whatever card held focus on the launcher behind it — the alerts page's rule. - val closeRequests: () -> Unit = { - showRequests = false - scope.launch { - kotlinx.coroutines.delay(16L) - runCatching { navigationFocusRequester.requestFocus() } - } - } - // Keyed on the profile like every other page here, so switching viewer cannot - // leave one person's requests on screen under somebody else's name. - val requestsViewModel: RequestsViewModel = viewModel( - viewModelStoreOwner = profileViewModelOwner, - factory = RequestsViewModelFactory(repo), - ) - val requestsState by requestsViewModel.state.collectAsStateWithLifecycle() - // The page keeps itself current while it is on screen — it is the one screen - // whose cards move without anybody touching anything. The loop is hung off the - // composition rather than off the view model, which is keyed on the profile and - // outlives this block, and off STARTED rather than run unconditionally, so a - // television left on the launcher or switched to another app stops asking - // entirely. Whether there is anything worth asking about is the view model's - // own judgement — see pollWhileVisible. - val requestsLifecycleOwner = androidx.lifecycle.compose.LocalLifecycleOwner.current - LaunchedEffect(requestsViewModel, requestsLifecycleOwner) { - requestsLifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) { - requestsViewModel.pollWhileVisible() - } - } - // The page covers the launcher, rail and all, so it carries its own rather than - // leaving Left pointing at one nobody can see. Its own FocusRequester, because - // the launcher's is still attached to the rail underneath and one requester on - // two live nodes lands on whichever Compose reaches first. - // - // Selecting a destination from here leaves the page — this rail is how you get - // *out* of Requests, not a second way to browse behind it. - Row( - Modifier - .fillMaxSize() - .zIndex(6f) - // The rail's own surface is 95% opaque, so without this the launcher - // shows faintly through the one column of the page it does not paint. - .background(MembySurface), - ) { - TvNavigationRail( - config = remoteConfig.navigation, - selected = if (userSwitcherVisible) { - BrowseDestination.PROFILES - } else { - selectedDestination - }, - expanded = requestsRailExpanded, - navigationFocusRequester = requestsRailFocusRequester, - onRailFocusChanged = { requestsRailExpanded = it }, - onDestinationSelected = { destination -> - requestsRailExpanded = false - showRequests = false - when (destination) { - BrowseDestination.SETTINGS -> { - railFocusDestination = BrowseDestination.SETTINGS - restoreRailAfterSettings = true - navigationExpanded = false - userSwitcherVisible = false - showSettings = true - } - BrowseDestination.PROFILES -> { - railFocusDestination = BrowseDestination.PROFILES - userSwitcherVisible = true - // The picker and the shortcut menu are two answers to one control; only - // one of them may ever be on screen. - userQuickActionsVisible = false - navigationExpanded = false - showSettings = false - restoreRailAfterSettings = false - } - else -> { - selectedDestination = destination - railFocusDestination = destination - navigationExpanded = false - userSwitcherVisible = false - showSettings = false - restoreRailAfterSettings = false - scope.launch { - // Let the destination compose and attach its entry - // target before focus is transferred to it. - kotlinx.coroutines.delay(16L) - requestFirstAvailableFocus( - contentFocusRequester, - navigationFocusRequester, - ) - } - } - } - }, - alertCount = displayedNotifications.size, - activeUsername = settings.username.orEmpty(), - activeProfileInitials = settings.profileInitials, - calendarEnabled = tvCalendarEnabled, - ) - RequestsScreen( - state = requestsState, - navigationFocusRequester = requestsRailFocusRequester, - contentFocusRequester = contentFocusRequester, - onSelectTab = requestsViewModel::selectTab, - onQueryChanged = requestsViewModel::onQueryChanged, - onAppendToQuery = requestsViewModel::appendToQuery, - onBackspace = requestsViewModel::backspace, - onClearQuery = requestsViewModel::clearQuery, - onSubmitSearch = requestsViewModel::submitSearch, - onRequest = requestsViewModel::request, - onRemove = requestsViewModel::remove, - onOpenItem = { itemId -> - // A request that has arrived opens the thing it became. The stub is - // filled in by focusItem the way a schedule card's is, so the page - // appears at once instead of waiting on an item request. - scope.launch { - runCatching { repo.getItemDetails(itemId) } - .onSuccess { item -> - closeRequests() - detailsAiringNotice = null - detailsTrail = emptyList() - detailsItem = item - } - } - }, - onRetry = requestsViewModel::refresh, - onExit = closeRequests, - posterUrlFor = { it.takeIf(String::isNotBlank) }, - modifier = Modifier.weight(1f).fillMaxHeight(), - ) - } - } - if (showNotifications) { - // Reached from the user picker, so leaving it goes back to the rail rather than - // to whatever card happened to hold focus on the launcher behind it. - val closeAlerts: () -> Unit = { - showNotifications = false - scope.launch { - kotlinx.coroutines.delay(16L) - runCatching { navigationFocusRequester.requestFocus() } - } - } - BackHandler(onBack = closeAlerts) - MyAlertsPage( - notifications = displayedNotifications, - preferences = notificationState.preferences, - loading = notificationsLoading, - errorMessage = notificationsError, - onRetry = { - if (!notificationsLoading) scope.launch { - notificationsLoading = true - notificationsError = null - runCatching { repo.getNotifications() } - .onSuccess { notificationState = it } - .onFailure { notificationsError = friendlyEmbyError(it) } - notificationsLoading = false - } - }, - // The seen toggle, and the only thing that moves a row between Inbox and - // Seen — nothing is marked read merely by being looked at any more, because - // with the two halves split that would empty the Inbox under the remote. - // - // Optimistic and reversed on failure, like the dismissal below: the flag is - // the only thing that changed, so a row that sat unmoved while its request - // was in flight is one pressed a second time. The locally-held update notice - // has no server row to post, so its flag is written to this television's own - // settings instead — it is a fact about the APK on this set. - onToggleSeen = onToggleSeen@{ notification -> - val markingSeen = notification.unread - if (notification.id == MEMBY_UPDATE_NOTIFICATION_ID) { - scope.launch { ServiceLocator.settings.setUpdateAlertRead(markingSeen) } - return@onToggleSeen - } - val previousReadAt = notification.readAt - notificationState = notificationState.copy( - notifications = notificationState.notifications.map { - if (it.id == notification.id) { - it.copy(readAt = if (markingSeen) "now" else null) - } else { - it - } - }, - ) - scope.launch { - runCatching { - if (markingSeen) { - repo.markNotificationRead(notification.id) - } else { - repo.markNotificationUnread(notification.id) - } - }.onFailure { failure -> - notificationState = notificationState.copy( - notifications = notificationState.notifications.map { - if (it.id == notification.id) { - it.copy(readAt = previousReadAt) - } else { - it - } - }, - ) - notificationsError = friendlyEmbyError(failure) - } - } - }, - // Optimistic, for the reason "Dismiss all" beneath it already is: this - // page is judged entirely on emptying itself, and a row that stayed put - // while its request was in flight is a row pressed again — which on this - // page also re-aims where focus lands afterwards. A failure puts the row - // back where it was rather than quietly losing somebody's alert. - onDismiss = onDismiss@{ notification -> - if (notificationsMutationBusy) return@onDismiss - if (notification.id == MEMBY_UPDATE_NOTIFICATION_ID) { - scope.launch { ServiceLocator.settings.dismissUpdateAlert() } - return@onDismiss - } - notificationsMutationBusy = true - val previous = notificationState - notificationState = notificationState.copy( - notifications = notificationState.notifications.filterNot { - it.id == notification.id - }, - ) - scope.launch { - runCatching { repo.dismissNotification(notification.id) } - .onFailure { failure -> - notificationState = previous - notificationsError = friendlyEmbyError(failure) - } - notificationsMutationBusy = false - } - }, - // The gateway has no bulk route, so this is the same call per alert. The - // list is emptied optimistically: the page is judged on emptying itself, - // and a row that lingered while its request was in flight would be pressed - // a second time. - onDismissAll = { pending -> - if (notificationsMutationBusy) return@MyAlertsPage - notificationsMutationBusy = true - val previous = notificationState - // Only the half on screen. The page dismisses what it is showing, so - // emptying Seen must not also throw away an Inbox the viewer has not - // read — a bulk action nobody can see the extent of is one nobody presses. - val pendingIds = pending.map(UserNotification::id).toSet() - val dismissLocalUpdate = MEMBY_UPDATE_NOTIFICATION_ID in pendingIds && - settings.updateAlertVersion != null - notificationState = notificationState.copy( - notifications = notificationState.notifications.filterNot { - it.id in pendingIds - }, - ) - scope.launch { - if (dismissLocalUpdate) ServiceLocator.settings.dismissUpdateAlert() - // The local update notice has no server row, so asking the gateway to - // dismiss it would be one guaranteed failure per pass. - val failed = pendingIds.filter { id -> - id != MEMBY_UPDATE_NOTIFICATION_ID && - runCatching { repo.dismissNotification(id) }.isFailure - } - runCatching { repo.getNotifications() } - .onSuccess { notificationState = it } - .onFailure { failure -> - notificationState = previous - notificationsError = friendlyEmbyError(failure) - } - if (failed.isNotEmpty()) { - notificationsError = "Some alerts couldn’t be dismissed. Try again." - } - notificationsMutationBusy = false - } - }, - onClose = closeAlerts, - ) - } - quickMenuItem?.let { selected -> - LaunchedEffect(selected.id) { - quickMenuTrailerAvailable = selected.isRadarrOnly && - repo.getRadarrMovie(selected.id)?.trailerAvailable == true - } - val closeQuickActions: (Boolean) -> Unit = { originWillDisappear -> - quickMenuItem = null - quickMenuRowId = null - scope.launch { - // The overlay owns focus until it leaves composition. Restore the - // exact originating card after its focus node is available again. - kotlinx.coroutines.delay(16L) - if (originWillDisappear) { - requestFirstAvailableFocus(contentFocusRequester, navigationFocusRequester) - } else { - requestFirstAvailableFocus( - cardReturnFocusRequester, - contentFocusRequester, - navigationFocusRequester, - ) - } - } - } - BackHandler { closeQuickActions(false) } - FocusedQuickActionsOverlay( - homeViewModel = homeViewModel, - selected = selected, - onOpenDetails = { - quickMenuItem = null - detailsAiringNotice = null - detailsItem = it - }, - onSetFavorite = homeViewModel::setFavorite, - onSetPlayed = homeViewModel::setPlayed, - // Only for a film with no page to play from, and only when the gateway has - // a candidate to resolve — the same answer the detail page's button waits - // for, so the two can never disagree about whether there is a trailer. - onPlayTrailer = if (selected.isRadarrOnly && quickMenuTrailerAvailable) { - { - quickMenuItem = null - quickMenuRowId = null - playTrailer(selected) - } - } else { - null - }, - onRemoveFromContinueWatching = if ( - rows.firstOrNull { it.id == quickMenuRowId }?.kind == MediaRowKind.CONTINUE - ) { - { - quickMenuItem = null - quickMenuRowId = null - homeViewModel.removeFromContinueWatching(selected) - scope.launch { - kotlinx.coroutines.delay(16L) - val removalFocusRequester = if ( - contextualHeroItems.isNotEmpty() - ) { - heroRowEntryFocusRequester - } else { - contentFocusRequester - } - requestFirstAvailableFocus( - removalFocusRequester, - contentFocusRequester, - navigationFocusRequester, - ) - } - } - } else { - null - }, - rowTitle = rows.firstOrNull { it.id == quickMenuRowId }?.title, - rowPinned = quickMenuRowId in settings.homePinnedRows.decodeRowIds(), - onToggleRowPinned = quickMenuRowId?.let { rowId -> - { - val pinned = settings.homePinnedRows.decodeRowIds().toMutableSet() - if (!pinned.add(rowId)) pinned.remove(rowId) - scope.launch { - ServiceLocator.settings.setHomeRowPreferences( - settings.homeRowOrder.decodeRowIds(), - pinned, - settings.homeHiddenRows.decodeRowIds().toSet(), - ) - } - closeQuickActions(false) - } - }, - onHideRow = quickMenuRowId?.let { rowId -> - { - val hidden = settings.homeHiddenRows.decodeRowIds().toMutableSet() - hidden.add(rowId) - scope.launch { - ServiceLocator.settings.setHomeRowPreferences( - settings.homeRowOrder.decodeRowIds(), - settings.homePinnedRows.decodeRowIds().toSet(), - hidden, - ) - closeQuickActions(true) - } - } - }, - onMoveRow = quickMenuRowId?.let { rowId -> - { direction -> - val currentIds = rows.map(HomeBrowseRow::id).toMutableList() - val index = currentIds.indexOf(rowId) - val target = (index + direction).coerceIn(0, currentIds.lastIndex) - if (index >= 0 && target != index) { - currentIds.removeAt(index) - currentIds.add(target, rowId) - scope.launch { - ServiceLocator.settings.setHomeRowPreferences( - currentIds, - settings.homePinnedRows.decodeRowIds().toSet(), - settings.homeHiddenRows.decodeRowIds().toSet(), - ) - } - } - closeQuickActions(false) - } - }, - onClose = { closeQuickActions(false) }, - ) - } - resolvingItem?.let { item -> - // Back gets out of the wait. This overlay covers the whole screen while the - // gateway is asked for a stream, and against a server that has stopped - // answering that is the length of an HTTP timeout — long enough that a viewer - // reaches for the remote, and until now long enough that nothing answered - // them. Cancelling is safe: nothing has been launched and nothing reported. - BackHandler(onBack = abandonLaunch) - PlaybackLaunchOverlay( - item = item, - modifier = Modifier.fillMaxSize().zIndex(9f), - ) - } - ForYouNudgeBanner( - visible = showForYouNudge && - selectedDestination == BrowseDestination.HOME && - !settings.hasOpenedForYou && - liveMaintenance == null, - username = settings.username, - shortName = settings.shortName, - modifier = Modifier.align(Alignment.TopCenter), - ) - // Emby has stopped answering. Persistent, unlike the news bar below it, because - // it describes a state rather than an event: a set switched on midway through an - // outage was never told, and this is the only thing that says why nothing plays. - // Suppressed under maintenance, which owns the screen and is its own explanation. - EmbyOutageBanner( - suppressed = liveMaintenance != null, - modifier = Modifier.align(Alignment.TopCenter), - ) - // News about the library, not about the app: it sits above the rows but is - // suppressed whenever something more important owns the screen. - ServiceAlertBanner( - // The alert itself is collected inside the banner, so an arriving one does - // not recompose this whole function. Only the suppression conditions — both - // already read here for other reasons — cross the boundary. - // The outage bar takes the same strip of screen and outranks any - // announcement, including the one announcing this very outage. - suppressed = liveMaintenance != null || embyOutage != null, - // Flush to the top edge and spanning the rail: for its few seconds this is - // the top layer of the screen, the way a broadcast notice is. - modifier = Modifier.align(Alignment.TopCenter), - ) - } -} - -@Composable -private fun PlaybackLaunchOverlay(item: BaseItem, modifier: Modifier = Modifier) { - val rotation by rememberInfiniteTransition(label = "playback-launch").animateFloat( - initialValue = 0f, - targetValue = 360f, - animationSpec = infiniteRepeatable( - animation = tween(durationMillis = 850, easing = LinearEasing), - repeatMode = RepeatMode.Restart, - ), - label = "playback-logo-rotation", - ) - Box( - modifier = modifier.background(MembySurface.copy(alpha = 0.93f)), - contentAlignment = Alignment.Center, - ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { - Image( - painter = painterResource(R.drawable.emby_logo), - contentDescription = null, - contentScale = ContentScale.Fit, - modifier = Modifier - .width(82.dp) - .height(70.dp) - .graphicsLayer { rotationZ = rotation }, - ) - Text( - "Starting ${item.name}", - color = Color.White, - fontSize = 21.sp, - fontWeight = FontWeight.SemiBold, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - Text( - "Preparing direct playback…", - color = MembyQuietText, - fontSize = 14.sp, - ) - } - } -} - -/** Row id under which the search grid records its return-focus target. */ -private const val SEARCH_ROW_ID = "search-results" -private const val GENRE_BROWSER_ROW_ID = "genre-browser-results" - -@Composable -private fun HomeClock( - showGreeting: Boolean, - username: String?, - shortName: String?, - modifier: Modifier = Modifier, -) { - val context = LocalContext.current - val timeFormatter = remember(context) { DateFormat.getTimeFormat(context) } - var currentTime by remember { mutableStateOf(Date()) } - - LaunchedEffect(Unit) { - while (true) { - currentTime = Date() - // Wake on the next minute boundary instead of drifting by a few seconds - // every time the app is left open. - val untilNextMinute = 60_000L - (System.currentTimeMillis() % 60_000L) - kotlinx.coroutines.delay(untilNextMinute) - } - } - - val period = homeGreetingPeriod( - Calendar.getInstance().apply { time = currentTime }.get(Calendar.HOUR_OF_DAY), - ) - val name = greetingName(shortName, username) - Row( - modifier = modifier, - horizontalArrangement = Arrangement.End, - verticalAlignment = Alignment.CenterVertically, - ) { - androidx.compose.animation.AnimatedVisibility( - visible = showGreeting && name != null, - enter = androidx.compose.animation.fadeIn(tween(180)), - exit = androidx.compose.animation.fadeOut(tween(120)), - ) { - Row( - modifier = Modifier - .clip(RoundedCornerShape(8.dp)) - .background(MembySurface.copy(alpha = 0.70f)) - .padding(horizontal = 12.dp, vertical = 7.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Icon( - imageVector = when (period) { - HomeGreetingPeriod.MORNING -> MembyIcon.Sunrise.mark - HomeGreetingPeriod.AFTERNOON -> MembyIcon.Sun.mark - HomeGreetingPeriod.EVENING -> MembyIcon.Night.mark - }, - contentDescription = null, - tint = MembyAccent, - modifier = Modifier.size(18.dp), - ) - Spacer(Modifier.width(8.dp)) - Text( - text = "${period.words}, $name", - color = Color.White.copy(alpha = 0.9f), - fontSize = 16.sp, - fontWeight = FontWeight.Medium, - ) - } - } - if (showGreeting && name != null) Spacer(Modifier.width(8.dp)) - Text( - text = timeFormatter.format(currentTime), - color = Color.White.copy(alpha = 0.86f), - fontSize = 18.sp, - fontWeight = FontWeight.Medium, - modifier = Modifier - .clip(RoundedCornerShape(8.dp)) - .background(MembySurface.copy(alpha = 0.70f)) - .padding(horizontal = 12.dp, vertical = 7.dp), - ) - } -} - -@Composable -private fun HomeArtworkPreloader( - rows: List, - availableWidth: androidx.compose.ui.unit.Dp, -) { - val context = LocalContext.current - val lifecycleOwner = androidx.lifecycle.compose.LocalLifecycleOwner.current - val density = LocalDensity.current - val repo = ServiceLocator.repository - val discovered = remember(rows) { - // Warm the leading posters across several rows instead of exhausting the - // budget on the first shelf. Vertical navigation is then far less likely to - // compete with image fetch/decode as the next row enters the viewport. - val perRow = rows.take(6).map { row -> - row.items.take(2).map { row.kind to it } - } - buildList { - val depth = perRow.maxOfOrNull { it.size } ?: 0 - for (itemIndex in 0 until depth) { - perRow.forEach { candidates -> - candidates.getOrNull(itemIndex)?.let(::add) - } - } - } - .distinctBy { it.second.id } - .take(12) - } - val signature = remember(discovered) { discovered.joinToString("|") { it.second.id } } - LaunchedEffect(signature, availableWidth, lifecycleOwner) { - lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) { - // Let visible cards win the first network/decode slots. Limiting the warm to - // the leading pair from the next six shelves covers the likely D-pad path - // without decoding a second screenful nobody may visit. - kotlinx.coroutines.delay(350L) - discovered.forEach { (kind, item) -> - val landscape = kind == MediaRowKind.CONTINUE || item.isEpisode - val width = if (landscape) { - (availableWidth / 4.25f).coerceIn(184.dp, 316.dp) - } else { - (availableWidth / 6.8f).coerceIn(116.dp, 184.dp) - } - val widthPx = with(density) { width.roundToPx() }.coerceIn(180, 720) - val heightPx = if (landscape) (widthPx * 9f / 16f).toInt() else (widthPx * 3f / 2f).toInt() - val url = if (landscape) { - repo.backdropUrl(item, widthPx) ?: repo.primaryUrl(item, widthPx) - } else { - repo.primaryUrl(item, widthPx) ?: repo.backdropUrl(item, widthPx) - } ?: return@forEach - context.imageLoader.execute( - ImageRequest.Builder(context) - .data(url) - .size(widthPx, heightPx) - .allowHardware(true) - .crossfade(false) - .build(), - ) - } - } - } -} - -@Composable -private fun FocusedHomeBackdrop(homeViewModel: HomeViewModel) { - val focusedItem by homeViewModel.focusedItem.collectAsStateWithLifecycle() - BackdropLayer(item = focusedItem, modifier = Modifier.fillMaxSize()) -} - -@Composable -private fun FocusedHomeMetadata( - homeViewModel: HomeViewModel, - sectionLabel: String, - modifier: Modifier = Modifier, -) { - val focusedItem by homeViewModel.focusedItem.collectAsStateWithLifecycle() - // Only the loading flags matter here, so take the content projection rather than the - // whole state: this panel sits beside the hero and redraws on every focus change as - // it is. - val homeContent by homeViewModel.content.collectAsStateWithLifecycle() - MediaMetadataPanel( - item = focusedItem, - loading = homeContent.loading.isNotEmpty(), - sectionLabel = sectionLabel, - modifier = modifier, - ) -} - -/** - * The detail page a playback was launched from, held for the length of that playback. - * - * The trail comes with it, so pressing Back after the film still walks the "More like this" - * chain the viewer arrived by rather than dropping straight to the launcher; the airing - * notice comes with it because it belonged to the route into the page and that route has - * not changed. - */ -private data class DetailsReturn( - val item: BaseItem, - val trail: List, - val airingNotice: AiringNotice?, -) - -@Composable -private fun FocusedDetailsOverlay( - homeViewModel: HomeViewModel, - selected: BaseItem, - restorePosition: Boolean, - onPlay: (BaseItem) -> Unit, - onPlayTrailer: (BaseItem) -> Unit, - onToggleFavorite: (BaseItem, Boolean) -> Unit, - isMyShow: Boolean, - onToggleMyShow: (BaseItem, Boolean) -> Unit, - onTogglePlayed: (BaseItem, Boolean) -> Unit, - onClose: () -> Unit, - onOpenItem: (BaseItem) -> Unit, - /** - * Where a Radarr card goes once Emby has imported the film. The row is cached for the - * day on the gateway, so the card can still arrive without an Emby id long after the - * import; the detail request is what notices, and this is what acts on it. - */ - onOpenEmbyItem: (BaseItem) -> Unit = {}, - airingNotice: AiringNotice? = null, -) { - val focusedItem by homeViewModel.focusedItem.collectAsStateWithLifecycle() - val item = focusedItem?.takeIf { it.id == selected.id } ?: selected - if (item.isRadarrOnly) { - // A film Radarr is tracking that Emby has never imported. It is the one card with a - // page of its own rather than an Emby one — see [RadarrMovieDetailsOverlay] for why - // it is not the movie page with its playable parts taken away. - RadarrMovieDetailsOverlay( - card = item, - onPlayTrailer = onPlayTrailer, - onClose = onClose, - onOpenEmbyItem = onOpenEmbyItem, - ) - } else if (item.isSeries) { - SeriesDetailsOverlay( - item = item, - onPlay = onPlay, - onPlayTrailer = onPlayTrailer, - onToggleFavorite = onToggleFavorite, - isMyShow = isMyShow, - onToggleMyShow = onToggleMyShow, - onSeriesPlayedChanged = { series, played -> - homeViewModel.trackJourney( - category = "library", action = if (played) "mark_played" else "mark_unplayed", - screen = "details", feature = "played_status", itemName = series.name, - itemType = series.type, outcome = "success", - ) - homeViewModel.applySeriesPlayed(series, played) - }, - onSeriesPlayedSettled = homeViewModel::refreshContinueWatching, - onClose = onClose, - onOpenItem = onOpenItem, - restorePosition = restorePosition, - airingNotice = airingNotice, - ) - } else if (item.isEpisode) { - // An episode arrives here from Continue Watching, where it *is* the thing the - // viewer chose. It used to open the movie page, which named the - // episode with no way to tell which one it was or where in the show it sat. - EpisodeDetailsOverlay( - item = item, - onPlay = onPlay, - onToggleFavorite = onToggleFavorite, - onTogglePlayed = onTogglePlayed, - onClose = onClose, - onOpenItem = onOpenItem, - restorePosition = restorePosition, - ) - } else { - MediaDetailsOverlay( - item = item, - onPlay = onPlay, - onPlayTrailer = onPlayTrailer, - onToggleFavorite = onToggleFavorite, - onTogglePlayed = onTogglePlayed, - onClose = onClose, - onOpenItem = onOpenItem, - restorePosition = restorePosition, - ) - } -} - -@Composable -private fun FocusedQuickActionsOverlay( - homeViewModel: HomeViewModel, - selected: BaseItem, - onOpenDetails: (BaseItem) -> Unit, - onSetFavorite: (BaseItem, Boolean) -> Unit, - onSetPlayed: (BaseItem, Boolean) -> Unit, - onRemoveFromContinueWatching: (() -> Unit)?, - onPlayTrailer: (() -> Unit)?, - rowTitle: String?, - rowPinned: Boolean, - onToggleRowPinned: (() -> Unit)?, - onHideRow: (() -> Unit)?, - onMoveRow: ((Int) -> Unit)?, - onClose: () -> Unit, -) { - val focusedItem by homeViewModel.focusedItem.collectAsStateWithLifecycle() - MediaQuickActionsOverlay( - item = focusedItem?.takeIf { it.id == selected.id } ?: selected, - onOpenDetails = onOpenDetails, - onSetFavorite = onSetFavorite, - onSetPlayed = onSetPlayed, - onRemoveFromContinueWatching = onRemoveFromContinueWatching, - onPlayTrailer = onPlayTrailer, - rowTitle = rowTitle, - rowPinned = rowPinned, - onToggleRowPinned = onToggleRowPinned, - onHideRow = onHideRow, - onMoveRow = onMoveRow, - onClose = onClose, - ) -} - -/** - * Maps the gateway's rows onto the TV's row model. - * - * The user's section preferences still apply to the four fixed rows — turning off - * "Favourites" must keep working — but anything the server invented (recommendations, - * and whatever it grows next) is always shown, since the user never opted out of a row - * that did not exist when they last opened Settings. - */ -internal fun serverHomeRows(state: HomeUiState, settings: Settings): List { - val enabledSections = settings.homeSections.split(',').map(String::trim).toSet() - val stillLoading = state.loading.isNotEmpty() - - val mapped = state.rows - .foldNextUpIntoContinue() - .filter { row -> - when (row.kind) { - "continue", "nextup" -> "continue" in enabledSections - "favorites" -> "favorites" in enabledSections - "latest" -> "latest" in enabledSections - // The hero row is the four featured cards. It is drawn above the shelves - // by HomeMovieHero, so letting it through here would print the same four - // titles a second time as an unnamed row of posters directly beneath it. - SERVER_HERO_ROW_KIND -> false - else -> true - } - } - .map { row -> - HomeBrowseRow( - id = row.id, - title = if (row.kind == "favorites") { - personalisedFavouritesTitle(settings.username) - } else { - row.title - }, - items = if (row.kind == "schedule" || row.kind == "movie-schedule") { - row.items.sortedByScheduleDate() - } else { - row.items - }, - kind = when (row.kind) { - "continue" -> MediaRowKind.CONTINUE - "favorites" -> MediaRowKind.FAVORITES - "shows" -> MediaRowKind.SHOWS - "schedule" -> MediaRowKind.SHOWS - "movie-schedule" -> MediaRowKind.MOVIES - // Recommendation strips, "latest", and any kind a future server - // sends get poster cards, which suit mixed movie/series rows. - else -> MediaRowKind.MOVIES - }, - loading = stillLoading && row.items.isEmpty(), - emptyMessage = when (row.kind) { - "continue" -> "Nothing in progress" - "favorites" -> "Your favourites will appear here" - "latest" -> "No recent movies found" - "schedule" -> "No monitored shows are airing in the next 5 days" - "movie-schedule" -> "No monitored movies have a digital release in the next 5 days" - else -> "Nothing to show here yet" - }, - showSecondaryMetadata = settings.showHomeCardMetadata, - ) - } - return applyHomeRowPreferences(mapped, settings) -} - -/** - * Continue Watching and Next Up are one row. This gateway no longer sends a `nextup` row, - * but two things still do: the home cache written by the previous build, which is what a - * TV draws before its first refresh lands, and a gateway that has not been deployed yet. - * Its episodes belong on the end of Continue Watching rather than in a row of their own. - */ -internal fun List.foldNextUpIntoContinue(): List { - val nextUp = filter { it.kind == "nextup" }.flatMap(HomeRow::items) - if (nextUp.isEmpty()) return filterNot { it.kind == "nextup" } - val seenSeries = firstOrNull { it.kind == "continue" } - ?.items - .orEmpty() - .mapNotNullTo(mutableSetOf()) { it.seriesId?.takeIf(String::isNotBlank) } - return filterNot { it.kind == "nextup" }.map { row -> - if (row.kind != "continue") { - row - } else { - val unseen = nextUp.filterNot { item -> - val series = item.seriesId.orEmpty() - series.isNotBlank() && series in seenSeries - } - row.copy(items = (row.items + unseen).distinctBy(BaseItem::id)) - } - } -} - -/** - * Schedule timestamps are server-authored RFC 3339 values in the configured local - * timezone. Sorting their fixed-width local representation keeps the nearest calendar - * date first and also repairs an out-of-order cached payload from an older gateway. - */ -internal fun List.sortedByScheduleDate(): List = - sortedWith( - compareBy { it.membyAirsAt.isNullOrBlank() } - .thenBy { it.membyAirsAt.orEmpty() }, - ) - -internal fun String.decodeRowIds(): List = - lineSequence().map(String::trim).filter(String::isNotEmpty).distinct().toList() - -internal fun applyHomeRowPreferences( - rows: List, - settings: Settings, -): List { - val hidden = settings.homeHiddenRows.decodeRowIds().toSet() - val pinned = settings.homePinnedRows.decodeRowIds().toSet() - val order = settings.homeRowOrder.decodeRowIds().withIndex().associate { it.value to it.index } - return rows - .filterNot { it.id in hidden } - .withIndex() - .sortedWith( - compareBy>( - { if (it.value.id in pinned) 0 else 1 }, - { order[it.value.id] ?: Int.MAX_VALUE }, - { it.index }, - ), - ) - .map(IndexedValue::value) -} - -internal fun homeRowsFor( - destination: BrowseDestination, - state: HomeUiState, - settings: Settings, -): List { - val continueRow = HomeBrowseRow( - id = "continue", - title = "Continue Watching", - items = state.continueWatching, - kind = MediaRowKind.CONTINUE, - loading = HomeSection.CONTINUE in state.loading, - emptyMessage = "Nothing in progress", - showSecondaryMetadata = settings.showHomeCardMetadata, - ) - val latestMovies = HomeBrowseRow( - id = "latest-movies", - title = "Recently Added Movies", - items = state.latestMovies, - kind = MediaRowKind.MOVIES, - loading = HomeSection.LATEST in state.loading, - emptyMessage = "No recent movies found", - showSecondaryMetadata = settings.showHomeCardMetadata, - ) - val favorites = HomeBrowseRow( - id = "favorites", - title = personalisedFavouritesTitle(settings.username), - items = state.favorites, - kind = MediaRowKind.FAVORITES, - loading = HomeSection.FAVORITES in state.loading, - emptyMessage = "Your favourites will appear here", - showSecondaryMetadata = settings.showHomeCardMetadata, - ) - val destinationRows = when (destination) { - // The gateway composes the home screen — including rows this app has no concept - // of, like "Because you watched …" — so when it sends rows, they win. - BrowseDestination.HOME -> if (state.rows.isNotEmpty()) { - serverHomeRows(state, settings) - // Genre/studio shelves have dedicated Movies and TV destinations. Home - // keeps personalised, current and new-release rows so each screen has a - // genuinely different browsing character. - .filterNot { it.id.startsWith("curated:") } - } else { - settings.homeSections - .split(',') - .map(String::trim) - .distinct() - .flatMap { section -> - when (section) { - "continue" -> listOf(continueRow) - "latest" -> listOf(latestMovies) - "favorites" -> listOf(favorites) - else -> emptyList() - } - } - } - BrowseDestination.MOVIES -> serverHomeRows(state, settings) - // Movie discovery is authored by the server from this profile's genre and - // studio affinity. Broad latest/library dumps belong on Home, not here. - .filter { row -> - row.kind == MediaRowKind.MOVIES && - row.id.startsWith("curated:movies:") - } - .distinctBy(HomeBrowseRow::id) + listOf( - favorites.copy( - id = "favourite-movies", - title = "Favourite Movies", - items = state.favorites.filter(BaseItem::isMovie), - ), - ) - BrowseDestination.SHOWS -> listOf( - // The merged row, narrowed to television: what is part-way through and what - // comes next are the same shelf here too. - continueRow.copy( - id = "continue-shows", - items = state.continueWatching.filter(BaseItem::isEpisode), - ), - favorites.copy( - id = "favourite-shows", - title = "Favourite Shows", - items = state.favorites.filter { it.isSeries || it.isEpisode }, - ), - ) + serverHomeRows(state, settings) - // These shelves are authored and ordered by the per-user recommendation - // engine. Keep their server order: it is the user's affinity ranking. - .filter { row -> - row.kind == MediaRowKind.SHOWS && row.id.startsWith("curated:") - } - .distinctBy(HomeBrowseRow::id) - BrowseDestination.FAVORITES -> listOf(favorites) - BrowseDestination.FOR_YOU -> emptyList() - // Search draws its own pane; the rail destinations that open an overlay have no - // rows of their own either. - BrowseDestination.SEARCH -> emptyList() - BrowseDestination.GENRES -> emptyList() - BrowseDestination.CALENDAR -> emptyList() - BrowseDestination.PROFILES -> emptyList() - BrowseDestination.SETTINGS -> emptyList() - } - val deduplicated = deduplicateBrowseRows(destinationRows) - // The television page carries two fixed shelves it may have nothing for — a household - // part-way through no episodes, or with no series favourited — and a heading with an - // apology under it is a row the D-pad already refuses to enter, so it is a stop on the - // way to the shelves that do have something. A row still *loading* is kept: it has - // something arriving, and withdrawing it would move every shelf below it a moment later. - val populated = if (destination == BrowseDestination.SHOWS) { - deduplicated.filter { it.items.isNotEmpty() || it.loading } - } else { - deduplicated - } - return if (destination == BrowseDestination.HOME && state.rows.isEmpty()) { - applyHomeRowPreferences(populated, settings) - } else { - populated - } -} - -/** - * A card gets one place on a screen. Episodes collapse to their parent series so a show - * in Continue Watching cannot immediately reappear as a genre recommendation, and live - * UserData is a final guard against a stale recommendation cache showing something seen. - */ -internal fun deduplicateBrowseRows(rows: List): List { - val used = mutableSetOf() - return rows.map { row -> - val discovery = row.id.startsWith("curated:") || - row.id.startsWith("similar:") || - row.id.startsWith("for-you:") || - row.id == "recommended" - val items = row.items.filter { item -> - if (discovery && ( - item.userData?.played == true || - (item.userData?.playbackPositionTicks ?: 0L) > 0L - ) - ) { - return@filter false - } - used.add(browseDeduplicationKey(item)) - } - row.copy(items = items) - } -} - -/** Applies the opt-in watched-content policy after every destination has built its rows. */ -internal fun applyWatchedVisibility( - rows: List, - enabled: Boolean, -): List = rows.map { row -> - row.copy( - items = visibleWithWatchedPreference(row.items, enabled), - showWatchedEpisodeCount = enabled, - ) -} - -internal fun visibleWithWatchedPreference( - items: List, - enabled: Boolean, -): List = if (enabled) { - items.filterNot { item -> item.isMovie && item.userData?.played == true } -} else { - items -} - -private fun browseDeduplicationKey(item: BaseItem): String { - item.seriesId?.trim()?.takeIf(String::isNotEmpty)?.let { return "series:$it" } - if (item.isSeries) return "series:${item.id}" - return "item:${item.id}" -} - -internal fun forYouBrowseRows( - state: ForYouUiState, -): List { - if (state.rows.isEmpty()) { - return listOf( - HomeBrowseRow( - id = "for-you:picks", - title = "Top picks for you", - items = emptyList(), - kind = MediaRowKind.MOVIES, - loading = state.loading, - emptyMessage = state.error ?: "Watch and browse a little more to shape your picks", - showSecondaryMetadata = true, - ), - ) - } - return state.rows.map { row -> - HomeBrowseRow( - id = row.id, - title = row.title, - items = row.items, - kind = MediaRowKind.MOVIES, - loading = state.loading && row.items.isEmpty(), - emptyMessage = state.error ?: "No picks fit this time window yet", - // Reasons are the primary secondary metadata in this destination. - showSecondaryMetadata = true, - ) - } -} - -@Composable -private fun ForYouTimeBudget( - selectedMinutes: Int, - loading: Boolean, - error: String?, - onSelected: (Int) -> Unit, -) { - Column( - modifier = Modifier.fillMaxWidth().padding(horizontal = 36.dp, vertical = 4.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - Text( - "How much time do you have?", - color = MembyMutedText, - fontSize = 15.sp, - fontWeight = FontWeight.SemiBold, - ) - Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { - listOf(30 to "30 min", 60 to "1 hour", 120 to "2 hours", 0 to "Any length") - .forEach { (minutes, label) -> - MembyChoiceChip( - label = label, - selected = minutes == selectedMinutes, - onClick = { if (!loading && minutes != selectedMinutes) onSelected(minutes) }, - ) - } - } - error?.let { - Text(it, color = Color(0xFFFFB454), fontSize = 13.sp) - } - } -} - -@Composable -private fun ForYouNudgeBanner( - visible: Boolean, - username: String?, - shortName: String?, - modifier: Modifier = Modifier, -) { - // The same name the hero greeting uses: two places addressing one person by two - // different names is worse than neither of them being personalised. - val name = greetingName(shortName, username) - androidx.compose.animation.AnimatedVisibility( - visible = visible, - enter = androidx.compose.animation.fadeIn(tween(220)), - exit = androidx.compose.animation.fadeOut(tween(260)), - modifier = modifier.zIndex(7f), - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .height(92.dp) - .background( - Brush.horizontalGradient( - 0f to MembySplashTint, - 0.62f to MembySplashTint.copy(alpha = 0.95f), - 1f to MembySurfaceRaised.copy(alpha = 0.88f), - ), - ) - // Keep copy inside television overscan and clear of the collapsed rail. - .padding(start = 72.dp, end = 48.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Box( - Modifier - .size(10.dp) - .clip(CircleShape) - .background(MembyAccent), - ) - Spacer(Modifier.width(16.dp)) - Column(verticalArrangement = Arrangement.spacedBy(3.dp)) { - Text( - "FOR YOU", - color = MembyAccentBright, - fontSize = 13.sp, - fontWeight = FontWeight.Bold, - letterSpacing = 1.4.sp, - ) - Text( - text = if (name == null) { - "Your personalised picks are ready" - } else { - "$name, your personalised picks are ready" - }, - color = MembyOnSurface, - fontSize = 21.sp, - fontWeight = FontWeight.SemiBold, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - } - } -} - -/** - * Turns Emby account-style usernames into a friendlier home-screen name. - * - * Household accounts commonly use a trailing capital as a disambiguating surname - * initial (PeterC, PaulR). Only that clear camel-case shape is trimmed, so ordinary - * usernames such as PJ, CHRIS, alice, or MattCohen are left alone. - */ -internal fun friendlyProfileName(username: String?): String? { - val name = username?.trim().orEmpty() - if (name.isEmpty()) return null - return if ( - name.length >= 3 && - name.last().isUpperCase() && - name[name.lastIndex - 1].isLowerCase() - ) { - name.dropLast(1) - } else { - name - } -} - -internal fun personalisedFavouritesTitle(username: String?): String { - val name = friendlyProfileName(username) ?: return "Favourites" - val possessive = if (name.endsWith("s", ignoreCase = true)) "$name'" else "$name's" - return "$possessive Favourites" -} - -@Composable -private fun HeaderAction(label: String, onClick: () -> Unit) { - var focused by remember { mutableStateOf(false) } - val textColor by animateColorAsState( - targetValue = if (focused) Color.White else MembyMutedText, - animationSpec = tween(100), - label = "header-action-text", - ) - val backgroundColor by animateColorAsState( - targetValue = if (focused) Color.White.copy(alpha = 0.10f) else Color.Transparent, - animationSpec = tween(100), - label = "header-action-background", - ) - Text( - text = label, - color = textColor, - fontSize = 14.sp, - fontWeight = FontWeight.Medium, - modifier = Modifier - .border( - width = if (focused) 1.dp else 0.dp, - color = if (focused) Color.White else Color.Transparent, - shape = RoundedCornerShape(6.dp), - ) - .background(backgroundColor, RoundedCornerShape(6.dp)) - .onFocusChanged { focused = it.isFocused } - .clickable(onClick = onClick) - .padding(horizontal = 14.dp, vertical = 8.dp), - ) -} - -@Composable -private fun FavoriteCard(item: BaseItem, onClick: () -> Unit) { - val repo = ServiceLocator.repository - val imageUrl = repo.backdropUrl(item, maxWidth = 640) ?: repo.primaryUrl(item) - Card(onClick = onClick, modifier = Modifier.width(320.dp)) { - Column { - Box(Modifier.width(320.dp).height(180.dp).background(MembyControlSurface)) { - if (imageUrl != null) { - AsyncImage( - model = imageUrl, - contentDescription = item.name, - contentScale = ContentScale.Crop, - modifier = Modifier.fillMaxSize(), - ) - } - } - Text( - text = item.name, - color = Color.White, - fontSize = 16.sp, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.padding(10.dp), - ) - } - } -} - -@Composable -internal fun TvTextField( - label: String, - value: String, - onValueChange: (String) -> Unit, - modifier: Modifier = Modifier, - isPassword: Boolean = false, - keyboardType: KeyboardType = KeyboardType.Text, - focusRequester: FocusRequester? = null, - downFocusRequester: FocusRequester? = null, - onNext: (() -> Unit)? = null, - onDone: (() -> Unit)? = null, -) { - var focused by remember { mutableStateOf(false) } - val borderColor by animateColorAsState( - targetValue = if (focused) MembyAccentBright else MembyOutline, - animationSpec = tween(120), - label = "login-field-border", - ) - val backgroundColor by animateColorAsState( - targetValue = if (focused) MembyAccentMuted else MembySurfaceRaised, - animationSpec = tween(120), - label = "login-field-background", - ) - Column( - modifier = modifier, - verticalArrangement = Arrangement.spacedBy(6.dp), - ) { - Text( - text = if (focused) "$label — selected" else label, - color = if (focused) MembyAccentBright else MembyQuietText, - fontSize = 15.sp, - fontWeight = if (focused) FontWeight.Bold else FontWeight.Medium, - ) - Box( - modifier = Modifier - .fillMaxWidth() - .border(if (focused) 3.dp else 1.dp, borderColor, RoundedCornerShape(10.dp)) - .background(backgroundColor, RoundedCornerShape(10.dp)) - .padding(horizontal = 18.dp, vertical = 16.dp), - ) { - BasicTextField( - value = value, - onValueChange = onValueChange, - singleLine = true, - textStyle = TextStyle(color = Color.White, fontSize = 20.sp), - cursorBrush = SolidColor(MembyAccentBright), - visualTransformation = if (isPassword) PasswordVisualTransformation() else VisualTransformation.None, - keyboardOptions = KeyboardOptions( - keyboardType = if (isPassword) KeyboardType.Password else keyboardType, - imeAction = if (onNext != null) ImeAction.Next else ImeAction.Done, - ), - keyboardActions = KeyboardActions( - onNext = { onNext?.invoke() }, - onDone = { onDone?.invoke() }, - ), - modifier = Modifier - .fillMaxWidth() - .then( - if (downFocusRequester != null) { - Modifier.focusProperties { down = downFocusRequester } - } else { - Modifier - }, - ) - .then( - if (focusRequester != null) Modifier.focusRequester(focusRequester) - else Modifier, - ) - .onFocusChanged { focused = it.isFocused }, - ) - } - } -} - -/** Requests the first focus target that is both attached and willing to accept focus. */ -private fun requestFirstAvailableFocus(vararg requesters: FocusRequester): Boolean { - requesters.forEach { requester -> - if (runCatching { requester.requestFocus() }.isSuccess) return true - } - return false -} - -private fun openScreensaverSettings(context: android.content.Context) { - val candidates = listOf( - AndroidSettings.ACTION_DREAM_SETTINGS, - AndroidSettings.ACTION_DISPLAY_SETTINGS, - AndroidSettings.ACTION_SETTINGS, - ) - for (action in candidates) { - val intent = Intent(action).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - if (intent.resolveActivity(context.packageManager) != null) { - context.startActivity(intent) - return - } - } -} diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/MembyButtons.kt b/app/src/main/java/com/ponzischeme89/memby/ui/MembyButtons.kt index e51d377..729bbda 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/MembyButtons.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/MembyButtons.kt @@ -34,7 +34,10 @@ import androidx.compose.ui.unit.sp import androidx.tv.material3.Icon import androidx.tv.material3.Text import com.ponzischeme89.memby.ui.theme.MembyAccent +import com.ponzischeme89.memby.ui.theme.MembyAccentBright +import com.ponzischeme89.memby.ui.theme.MembyAccentMuted import com.ponzischeme89.memby.ui.theme.MembyCardCorner +import com.ponzischeme89.memby.ui.theme.MembyDestructive import com.ponzischeme89.memby.ui.theme.MembyChipCorner import com.ponzischeme89.memby.ui.theme.MembyHairline import com.ponzischeme89.memby.ui.theme.MembyMutedText @@ -245,3 +248,81 @@ internal fun MembyChoiceChip( ) } } + +/** + * One answer in a full-stop dialog: the shape [ExitMembyConfirmation] draws and the shape + * every panel that asks a yes/no question over the app should draw. + * + * The two answers must never look alike — a remote has no pointer, so the loud shape is the + * whole of what says which one the viewer probably wants, and on a screen read from three + * metres by somebody who pressed a key by accident that distinction is the safety. + * [primary] is the answer the panel expects; [destructive] is the answer that cannot be + * taken back, and it stays a quiet outline until focus reaches it rather than sitting there + * in red inviting a press. + * + * [focusedForCapture] draws it as though the remote were on it: Robolectric's window never + * takes focus and the ring is the whole of what says which action a press would take. + */ +@Composable +internal fun MembyDialogAction( + label: String, + primary: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, + destructive: Boolean = false, + focusedForCapture: Boolean = false, +) { + var hasFocus by remember { mutableStateOf(false) } + val focused = hasFocus || focusedForCapture + val lift by animateFloatAsState( + targetValue = if (focused) 1f else 0f, + animationSpec = tween(durationMillis = 110), + label = "dialog-action", + ) + val shape = RoundedCornerShape(MembyCardCorner) + Box( + modifier = modifier + .graphicsLayer { + val t = lift + scaleX = 1f + 0.04f * t + scaleY = 1f + 0.04f * t + translationY = -3f * t + } + .shadow(if (focused) 16.dp else 0.dp, shape) + .clip(shape) + .background( + when { + focused && primary -> MembyAccent + focused && destructive -> MembyDestructive + focused -> MembyOutline + primary -> MembyAccentMuted + else -> Color.Transparent + }, + ) + .border( + width = if (focused) 2.dp else 1.dp, + color = when { + focused -> Color.White + primary -> MembyAccent.copy(alpha = 0.55f) + else -> MembyOutline + }, + shape = shape, + ) + .onFocusChanged { hasFocus = it.isFocused } + .clickable(onClick = onClick) + .padding(horizontal = 20.dp, vertical = 13.dp), + contentAlignment = Alignment.Center, + ) { + Text( + label, + color = when { + focused -> Color.White + primary -> MembyAccentBright + else -> MembyMutedText + }, + fontSize = 16.sp, + fontWeight = FontWeight.Bold, + maxLines = 1, + ) + } +} diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/Onboarding.kt b/app/src/main/java/com/ponzischeme89/memby/ui/Onboarding.kt new file mode 100644 index 0000000..2705018 --- /dev/null +++ b/app/src/main/java/com/ponzischeme89/memby/ui/Onboarding.kt @@ -0,0 +1,929 @@ +package com.ponzischeme89.memby.ui + +import android.content.Context +import android.os.Build +import android.provider.Settings as AndroidSettings +import android.widget.Toast +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.focusable +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.runtime.key +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusProperties +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.zIndex +import coil.compose.AsyncImage +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.RecommendationOnboarding +import com.ponzischeme89.memby.data.model.RecommendationPerson +import com.ponzischeme89.memby.ui.theme.MembyAccent +import com.ponzischeme89.memby.ui.theme.MembyAccentBright +import com.ponzischeme89.memby.ui.theme.MembyAccentMuted +import com.ponzischeme89.memby.ui.theme.MembyControlSurface +import com.ponzischeme89.memby.ui.theme.MembyControlSurfaceRaised +import com.ponzischeme89.memby.ui.theme.MembyMutedText +import com.ponzischeme89.memby.ui.theme.MembyOnSurface +import com.ponzischeme89.memby.ui.theme.MembyOutline +import com.ponzischeme89.memby.ui.theme.MembyQuietText +import com.ponzischeme89.memby.ui.theme.MembySplashTint +import com.ponzischeme89.memby.ui.profiles.ProfileChooser +import com.ponzischeme89.memby.ui.theme.MembySurface +import com.ponzischeme89.memby.ui.theme.MembySurfaceRaised +import com.ponzischeme89.memby.ui.setup.SignInContent +import androidx.tv.material3.Button +import androidx.tv.material3.Text +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlin.time.Duration.Companion.milliseconds + +@Composable +internal fun MembyLoadingScreen( + quoteStyle: String? = null, + onIntroFinished: () -> Unit = {}, +) { + val welcomeQuote = remember(quoteStyle) { randomWelcomeQuote(quoteStyle) } + // Whether Memby's own clip is actually on screen behind this. Set from the player's + // first rendered frame rather than from having asked it to play, so the logo only gets + // out of the way once there is something to get out of the way for. + var prerollVisible by remember { mutableStateOf(false) } + // The clip has played its length and been held. Everything below it comes back. + var introDone by remember { mutableStateOf(false) } + // ...and a moment later the video node goes, which is what hands the player back to the + // process cache. Removing it on the same frame as the fade would cut to black. + var introRemoved by remember { mutableStateOf(false) } + val prerollAlpha by animateFloatAsState( + targetValue = if (prerollVisible && !introDone) 1f else 0f, + animationSpec = tween(420), + label = "cold-start-preroll-alpha", + ) + // While the clip runs it is the whole screen: the pulsing mark and the welcome line are + // the *waiting* screen, and printing them over a four-second sting says two things at + // once. They fade in behind it if the app is still opening when it ends. + val chromeAlpha by animateFloatAsState( + targetValue = if (prerollVisible && !introDone) 0f else 1f, + animationSpec = tween(420), + label = "cold-start-chrome-alpha", + ) + LaunchedEffect(introDone) { + if (!introDone) return@LaunchedEffect + delay(460.milliseconds) + introRemoved = true + } + // Not keyed on the quote style: the headline says what the app is doing, and rerolling + // it when the settings flow arrives with a tone would change the line under the viewer + // mid-launch. Once per appearance of this screen is the intent. + val headline = remember { randomLoadingHeadline() } + val transition = rememberInfiniteTransition(label = "cold-start") + val pulse by transition.animateFloat( + initialValue = 0.96f, + targetValue = 1.04f, + animationSpec = infiniteRepeatable( + animation = tween(750), + repeatMode = RepeatMode.Reverse, + ), + label = "cold-start-logo-pulse", + ) + val glow by transition.animateFloat( + initialValue = 0.35f, + targetValue = 0.78f, + animationSpec = infiniteRepeatable( + animation = tween(750), + repeatMode = RepeatMode.Reverse, + ), + label = "cold-start-glow", + ) + + Box( + modifier = Modifier + .fillMaxSize() + .background( + Brush.radialGradient( + colors = listOf(MembySplashTint, MembySurface), + radius = 1_100f, + ), + ), + contentAlignment = Alignment.Center, + ) { + if (!introRemoved) { + LaunchPrerollVideo( + modifier = Modifier + .fillMaxSize() + .graphicsLayer { alpha = prerollAlpha }, + onVisible = { prerollVisible = true }, + onFinished = { + if (!introDone) { + introDone = true + onIntroFinished() + } + }, + ) + } + // The scrim that used to hold this copy legible over the clip is gone with the + // overlap it existed for: the waiting screen and the clip no longer share the frame, + // so darkening Memby's own mark by four fifths would be for nobody's benefit. + Column( + modifier = Modifier.graphicsLayer { alpha = chromeAlpha }, + verticalArrangement = Arrangement.spacedBy(14.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + // The clip is Memby's own mark moving; the still logo above it would be the + // same thing said twice, so the whole column gives way while it runs. + Image( + painter = painterResource(R.drawable.emby_logo), + contentDescription = "Memby", + modifier = Modifier + .width(82.dp) + .height(68.dp) + .graphicsLayer { + scaleX = pulse + scaleY = pulse + alpha = 0.82f + (glow * 0.18f) + }, + ) + Text( + headline, + color = Color.White.copy(alpha = 0.88f), + fontSize = 17.sp, + fontWeight = FontWeight.Medium, + ) + Text( + welcomeQuote, + color = Color.White.copy(alpha = 0.58f), + fontSize = 13.sp, + maxLines = 2, + textAlign = TextAlign.Center, + modifier = Modifier.width(440.dp), + ) + Box( + Modifier + .width(92.dp) + .height(2.dp) + .clip(CircleShape) + .background(Color.White.copy(alpha = 0.10f)), + ) { + // Drawn, not measured: `fillMaxWidth(glow)` would recompose this screen + // every frame — during cold start, which is the one moment the device has + // nothing to spare. + Box( + Modifier + .fillMaxWidth() + .height(2.dp) + .drawBehind { + drawRoundRect( + color = MembyAccent, + size = Size(size.width * glow, size.height), + cornerRadius = CornerRadius(size.height / 2f), + ) + }, + ) + } + } + } +} + +@Composable +internal fun FirstRunScreen(onGetStarted: () -> Unit) { + val getStartedFocus = remember { FocusRequester() } + var entered by remember { mutableStateOf(false) } + val contentAlpha by animateFloatAsState( + targetValue = if (entered) 1f else 0f, + animationSpec = tween(420), + label = "first-run-content-alpha", + ) + val contentOffset by animateDpAsState( + targetValue = if (entered) 0.dp else 18.dp, + animationSpec = tween(420), + label = "first-run-content-offset", + ) + val ambient = rememberInfiniteTransition(label = "first-run-ambient") + val logoScale by ambient.animateFloat( + initialValue = 0.97f, + targetValue = 1.04f, + animationSpec = infiniteRepeatable( + animation = tween(1_800), + repeatMode = RepeatMode.Reverse, + ), + label = "first-run-logo-scale", + ) + val haloAlpha by ambient.animateFloat( + initialValue = 0.10f, + targetValue = 0.22f, + animationSpec = infiniteRepeatable( + animation = tween(1_800), + repeatMode = RepeatMode.Reverse, + ), + label = "first-run-halo", + ) + + LaunchedEffect(Unit) { + entered = true + delay(180.milliseconds) + runCatching { getStartedFocus.requestFocus() } + } + + Box( + modifier = Modifier + .fillMaxSize() + .background( + Brush.linearGradient( + colors = listOf( + MembySurface, + MembySplashTint, + MembySurface, + ), + ), + ), + ) { + Box( + modifier = Modifier + .align(Alignment.CenterEnd) + .offset(x = 70.dp) + .size(560.dp) + .graphicsLayer { alpha = haloAlpha } + .background( + Brush.radialGradient( + colors = listOf(MembyAccent, Color.Transparent), + ), + CircleShape, + ), + ) + + Row( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 88.dp, vertical = 64.dp) + .graphicsLayer { alpha = contentAlpha } + .offset { IntOffset(x = 0, y = contentOffset.roundToPx()) }, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Column( + modifier = Modifier.width(590.dp), + verticalArrangement = Arrangement.spacedBy(18.dp), + ) { + Text( + "MEMBY", + color = MembyAccentBright, + fontSize = 14.sp, + fontWeight = FontWeight.Bold, + letterSpacing = 2.sp, + ) + // Two lines, both of them literal. "Who's watching?" invited a household + // that most of these TVs do not have, and the line under it listed three + // nouns to say one thing. + Text( + "Welcome to Memby - Let's get started", + color = Color.White, + fontSize = 48.sp, + fontWeight = FontWeight.Bold, + ) + Text( + "Enter your username & password on the next screen. Ensure to allow permissions for app updates.", + color = MembyMutedText, + fontSize = 20.sp, + lineHeight = 29.sp, + ) + Spacer(Modifier.height(8.dp)) + Button( + onClick = onGetStarted, + modifier = Modifier.focusRequester(getStartedFocus), + ) { + Text("Next") + } + } + + Box( + modifier = Modifier.size(330.dp), + contentAlignment = Alignment.Center, + ) { + Box( + modifier = Modifier + .size(250.dp) + .graphicsLayer { + scaleX = logoScale + scaleY = logoScale + alpha = haloAlpha + } + .background(MembyAccent, CircleShape), + ) + Image( + painter = painterResource(R.drawable.emby_logo), + contentDescription = null, + modifier = Modifier + .width(178.dp) + .height(148.dp) + .graphicsLayer { + scaleX = logoScale + scaleY = logoScale + }, + ) + } + } + } +} + +@Composable +internal fun SetupScreen( + onCancel: (() -> Unit)? = null, + onSignedIn: () -> Unit = {}, +) { + val repo = ServiceLocator.repository + val context = LocalContext.current + val scope = rememberCoroutineScope() + + val automaticDeviceName = remember(context) { + ServiceLocator.settings.current?.deviceName + ?.takeIf { it.isNotBlank() } + ?: suggestedDeviceName(context) + } + var username by rememberSaveable { mutableStateOf("") } + var password by rememberSaveable { mutableStateOf("") } + var connecting by remember { mutableStateOf(false) } + var error by remember { mutableStateOf(null) } + + val usernameFocus = remember { FocusRequester() } + val passwordFocus = remember { FocusRequester() } + val signInFocus = remember { FocusRequester() } + val backFocus = remember { FocusRequester() } + LaunchedEffect(Unit) { + delay(100.milliseconds) + runCatching { usernameFocus.requestFocus() } + } + if (onCancel != null) { + // The visible Back button is disabled while authentication is running. Hardware + // Back must obey the same contract and, importantly, must not hide a form whose + // coroutine can still complete and switch the active viewer afterwards. + BackHandler { if (!connecting) onCancel() } + } + + val submit: () -> Unit = { + if (username.isBlank()) { + error = "Username is required." + } else if (!connecting) { + connecting = true + error = null + scope.launch { + runCatching { + repo.authenticate( + serverUrl = "", + username = username.trim(), + password = password, + deviceName = automaticDeviceName, + ) + } + .onSuccess { authenticatedUsername -> + val quoteStyle = ServiceLocator.settings.snapshot().welcomeQuoteStyle + Toast.makeText( + context, + loginWelcomeMessage(authenticatedUsername, quoteStyle), + Toast.LENGTH_LONG, + ).show() + onSignedIn() + } + .onFailure { failure -> + error = "Memby couldn't sign in. Check the username and password and try again." + } + connecting = false + } + } + } + + SignInContent( + username = username, + password = password, + onUsernameChange = { username = it; error = null }, + onPasswordChange = { password = it; error = null }, + onSubmit = submit, + connecting = connecting, + error = error, + onBack = onCancel, + usernameFocus = usernameFocus, + passwordFocus = passwordFocus, + signInFocus = signInFocus, + backFocus = backFocus, + ) +} + +internal fun suggestedDeviceName(context: Context): String { + val systemName = runCatching { + AndroidSettings.Global.getString(context.contentResolver, "device_name") + }.getOrNull()?.trim().orEmpty() + if (systemName.isNotBlank()) return systemName.take(80) + + val manufacturer = Build.MANUFACTURER + ?.trim() + .orEmpty() + .takeUnless { it.equals("unknown", ignoreCase = true) } + .orEmpty() + val model = Build.MODEL + ?.trim() + .orEmpty() + .takeUnless { it.equals("unknown", ignoreCase = true) } + .orEmpty() + val inferred = listOf(manufacturer, model) + .filter { it.isNotBlank() } + .distinctBy { it.lowercase() } + .joinToString(" ") + return inferred.ifBlank { "Living room TV" }.take(80) +} + +@Composable +internal fun ProfileEntryScreen( + settings: Settings, + onAddProfile: () -> Unit, +) { + val repo = ServiceLocator.repository + val scope = rememberCoroutineScope() + var switchingProfileId by remember { mutableStateOf(null) } + var removingProfileId by remember { mutableStateOf(null) } + + ProfileChooser( + profiles = settings.profiles, + currentProfileId = null, + switchingProfileId = switchingProfileId, + removingProfileId = removingProfileId, + onSelect = { profile -> + switchingProfileId = profile.id + scope.launch { + runCatching { repo.switchProfile(profile) } + .onFailure { switchingProfileId = null } + } + }, + onRemove = { profile -> + removingProfileId = profile.id + scope.launch { + runCatching { repo.removeProfile(profile) } + removingProfileId = null + } + }, + onAddProfile = onAddProfile, + onClose = null, + ) +} + +@Composable +@OptIn(ExperimentalComposeUiApi::class) +internal fun RecommendationOnboardingScreen( + onboarding: RecommendationOnboarding, + onComplete: suspend (Map, List, List, List) -> Unit, + onSkip: () -> Unit = {}, + initialStageIndex: Int = 0, + previewArtwork: ImageBitmap? = null, +) { + val repo = ServiceLocator.repository + val scope = rememberCoroutineScope() + val ratings = remember(onboarding.ratings) { + mutableStateMapOf().apply { putAll(onboarding.ratings) } + } + val selectedActors = remember { mutableStateMapOf() } + val selectedActresses = remember { mutableStateMapOf() } + val selectedDirectors = remember { mutableStateMapOf() } + val movies = onboarding.movies.ifEmpty { onboarding.items.filter(BaseItem::isMovie) } + val shows = onboarding.shows.ifEmpty { onboarding.items.filter(BaseItem::isSeries) } + val stages = remember(movies, shows, onboarding.actors, onboarding.actresses, onboarding.directors) { + buildList { + if (movies.isNotEmpty()) add("Movies") + if (shows.isNotEmpty()) add("TV shows") + if (onboarding.actors.isNotEmpty()) add("Actors") + if (onboarding.actresses.isNotEmpty()) add("Actresses") + if (onboarding.directors.isNotEmpty()) add("Directors") + } + } + var stageIndex by rememberSaveable { mutableStateOf(initialStageIndex.coerceIn(0, (stages.size - 1).coerceAtLeast(0))) } + var saving by remember { mutableStateOf(false) } + var error by remember { mutableStateOf(null) } + var confirmingSkip by rememberSaveable { mutableStateOf(false) } + val stage = stages.getOrNull(stageIndex) + val choiceFocus = remember(stageIndex) { FocusRequester() } + val backFocus = remember(stageIndex) { FocusRequester() } + val primaryFocus = remember(stageIndex) { FocusRequester() } + + LaunchedEffect(stageIndex, stage) { + delay(16.milliseconds) + runCatching { + if (stage == null) primaryFocus.requestFocus() else choiceFocus.requestFocus() + } + } + BackHandler { + when { + confirmingSkip -> confirmingSkip = false + stageIndex > 0 -> stageIndex-- + else -> confirmingSkip = true + } + } + + val finish: () -> Unit = { + if (!saving) { + saving = true + error = null + scope.launch { + runCatching { + onComplete( + ratings.toMap(), + selectedActors.filterValues { it }.keys.toList(), + selectedActresses.filterValues { it }.keys.toList(), + selectedDirectors.filterValues { it }.keys.toList(), + ) + } + .onFailure { + error = "Memby couldn't save those ratings. Please try again." + saving = false + } + } + } + } + + Box( + Modifier + .fillMaxSize() + .background( + Brush.radialGradient( + listOf(MembySplashTint, MembySurfaceRaised, MembySurface), + center = Offset(260f, 180f), + radius = 1_500f, + ), + ) + .padding(horizontal = 64.dp, vertical = 34.dp), + ) { + Column( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Column(Modifier.weight(1f)) { + Text("TUNE YOUR TASTE", color = MembyAccentBright, fontSize = 13.sp, fontWeight = FontWeight.Bold) + Text("Make Memby yours", color = Color.White, fontSize = 34.sp, fontWeight = FontWeight.Bold) + } + Row(horizontalArrangement = Arrangement.spacedBy(7.dp)) { + stages.forEachIndexed { index, _ -> + Box( + Modifier + .width(if (index == stageIndex) 34.dp else 12.dp) + .height(5.dp) + .clip(CircleShape) + .background(if (index <= stageIndex) MembyAccentBright else MembyControlSurfaceRaised), + ) + } + } + } + Text( + when (stage) { + "Movies" -> "Choose movies you love" + "TV shows" -> "Choose shows you would recommend" + "Actors" -> "Pick actors you enjoy watching" + "Actresses" -> "Pick actresses you enjoy watching" + "Directors" -> "Pick directors whose work you seek out" + else -> "A few quick picks" + }, + color = Color.White, + fontSize = 25.sp, + fontWeight = FontWeight.SemiBold, + ) + Text( + "Select as many as you like, or skip this step. Every choice gives your recommendations a stronger starting point.", + color = MembyMutedText, fontSize = 16.sp, + ) + Spacer(Modifier.height(8.dp)) + if (stage == null) { + Spacer(Modifier.weight(1f)) + Text("Your library is still being prepared. You can start watching now and Memby will learn as you go.", color = MembyMutedText) + Spacer(Modifier.weight(1f)) + } else { + when (stage) { + "Movies" -> OnboardingTitleRow( + movies, ratings, choiceFocus, primaryFocus, previewArtwork, + ) + "TV shows" -> OnboardingTitleRow( + shows, ratings, choiceFocus, primaryFocus, previewArtwork, + ) + "Actors" -> OnboardingPeopleRow( + onboarding.actors, selectedActors, choiceFocus, primaryFocus, previewArtwork, + ) + "Actresses" -> OnboardingPeopleRow( + onboarding.actresses, selectedActresses, choiceFocus, primaryFocus, previewArtwork, + ) + "Directors" -> OnboardingPeopleRow( + onboarding.directors, selectedDirectors, choiceFocus, primaryFocus, previewArtwork, + ) + } + } + Spacer(Modifier.weight(1f)) + Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Text( + "${ratings.count { it.value >= 4 } + selectedActors.count { it.value } + selectedActresses.count { it.value } + selectedDirectors.count { it.value }} selected", + color = MembyQuietText, fontSize = 14.sp, modifier = Modifier.weight(1f), + ) + error?.let { Text(it, color = Color(0xFFFF7777), fontSize = 14.sp, modifier = Modifier.padding(end = 16.dp)) } + if (stageIndex > 0) { + Button( + onClick = { stageIndex-- }, + enabled = !saving, + modifier = Modifier + .focusRequester(backFocus) + .focusProperties { + up = if (stage == null) FocusRequester.Cancel else choiceFocus + right = primaryFocus + }, + ) { Text("Back") } + } + Spacer(Modifier.width(10.dp)) + if (stageIndex < stages.lastIndex) { + Button( + onClick = { stageIndex++ }, + enabled = !saving, + modifier = Modifier + .focusRequester(primaryFocus) + .focusProperties { + up = if (stage == null) FocusRequester.Cancel else choiceFocus + left = if (stageIndex > 0) backFocus else FocusRequester.Cancel + }, + ) { Text("Next") } + } else { + Button( + onClick = finish, + enabled = !saving, + modifier = Modifier + .focusRequester(primaryFocus) + .focusProperties { + up = if (stage == null) FocusRequester.Cancel else choiceFocus + left = if (stageIndex > 0) backFocus else FocusRequester.Cancel + }, + ) { Text(if (saving) "Saving…" else "Start watching") } + } + } + } + if (confirmingSkip) { + OnboardingSkipConfirmation( + onKeepChoosing = { confirmingSkip = false }, + onSkip = onSkip, + ) + } + } +} + +@Composable +@OptIn(ExperimentalComposeUiApi::class) +private fun OnboardingSkipConfirmation( + onKeepChoosing: () -> Unit, + onSkip: () -> Unit, +) { + val keepFocus = remember { FocusRequester() } + val skipFocus = remember { FocusRequester() } + LaunchedEffect(Unit) { + delay(16.milliseconds) + runCatching { keepFocus.requestFocus() } + } + Box( + Modifier.fillMaxSize().zIndex(20f).background(Color.Black.copy(alpha = 0.82f)), + contentAlignment = Alignment.Center, + ) { + Column( + modifier = Modifier + .width(480.dp) + .background(MembyControlSurface, RoundedCornerShape(18.dp)) + .padding(32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(18.dp), + ) { + Text( + "Skip taste setup for now?", + color = Color.White, + fontSize = 24.sp, + fontWeight = FontWeight.SemiBold, + ) + Text( + "You can start watching now. Memby may offer these choices again later.", + color = MembyMutedText, + fontSize = 16.sp, + textAlign = TextAlign.Center, + ) + Row(horizontalArrangement = Arrangement.spacedBy(14.dp)) { + Button( + onClick = onKeepChoosing, + modifier = Modifier + .focusRequester(keepFocus) + .focusProperties { + left = FocusRequester.Cancel + right = skipFocus + up = FocusRequester.Cancel + down = FocusRequester.Cancel + }, + ) { Text("Keep choosing") } + Button( + onClick = onSkip, + modifier = Modifier + .focusRequester(skipFocus) + .focusProperties { + left = keepFocus + right = FocusRequester.Cancel + up = FocusRequester.Cancel + down = FocusRequester.Cancel + }, + ) { Text("Skip for now") } + } + } + } +} + +@Composable +private fun OnboardingTitleRow( + items: List, + ratings: MutableMap, + entryFocus: FocusRequester, + footerFocus: FocusRequester, + previewArtwork: ImageBitmap? = null, +) { + val repo = ServiceLocator.repository + val distinctItems = remember(items) { items.distinctItems() } + LazyRow(horizontalArrangement = Arrangement.spacedBy(18.dp), modifier = Modifier.fillMaxWidth()) { + itemsIndexed(distinctItems, key = { _, item -> item.id }) { index, item -> + val selected = ratings[item.id] == 5 + FocusScaleContainer( + onFocused = {}, + onClick = { if (selected) ratings.remove(item.id) else ratings[item.id] = 5 }, + contentDescription = "${item.name}${if (selected) ", selected" else ""}", + modifier = Modifier + .width(148.dp) + .then(if (index == 0) Modifier.focusRequester(entryFocus) else Modifier) + .focusProperties { down = footerFocus }, + ) { focused -> + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Box( + Modifier + .width(148.dp).height(222.dp) + .clip(RoundedCornerShape(12.dp)) + .background(MembyControlSurface) + .border(if (focused || selected) 3.dp else 1.dp, if (selected) MembyAccentBright else if (focused) Color.White else MembyOutline, RoundedCornerShape(12.dp)), + ) { + if (previewArtwork != null) { + Image(bitmap = previewArtwork, contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize()) + } else { + AsyncImage(model = repo.primaryUrl(item, 320), contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize()) + } + if (selected) Text("✓ PICKED", color = Color.White, fontSize = 11.sp, fontWeight = FontWeight.Bold, modifier = Modifier.align(Alignment.TopEnd).padding(8.dp).background(MembyAccent.copy(alpha = 0.91f), CircleShape).padding(horizontal = 9.dp, vertical = 5.dp)) + } + Text(item.name, color = Color.White, fontSize = 14.sp, fontWeight = FontWeight.Medium, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text(listOfNotNull(item.productionYear?.toString(), item.genres.firstOrNull()).joinToString(" · "), color = MembyQuietText, fontSize = 11.sp, maxLines = 1) + } + } + } + } +} + +@Composable +private fun OnboardingPeopleRow( + people: List, + selectedPeople: MutableMap, + entryFocus: FocusRequester, + footerFocus: FocusRequester, + previewArtwork: ImageBitmap? = null, +) { + val repo = ServiceLocator.repository + // Keyed by name because that is also what the selection map is keyed by — which means + // two people sharing one would be one selection *and* a duplicate LazyRow key, and the + // second of those is a crash on the first screen a new television ever draws. + val distinctPeople = remember(people) { people.distinctForKeys(RecommendationPerson::name) } + LazyRow(horizontalArrangement = Arrangement.spacedBy(18.dp), modifier = Modifier.fillMaxWidth()) { + itemsIndexed(distinctPeople, key = { _, person -> person.name }) { index, person -> + val selected = selectedPeople[person.name] == true + FocusScaleContainer( + onFocused = {}, + onClick = { selectedPeople[person.name] = !selected }, + contentDescription = "${person.name}${if (selected) ", selected" else ""}", + modifier = Modifier + .width(148.dp) + .then(if (index == 0) Modifier.focusRequester(entryFocus) else Modifier) + .focusProperties { down = footerFocus }, + ) { focused -> + Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(10.dp)) { + Box( + Modifier.size(148.dp).clip(CircleShape).background(MembyAccentMuted) + .border(if (focused || selected) 4.dp else 1.dp, if (selected) MembyAccentBright else if (focused) Color.White else MembyOutline, CircleShape), + contentAlignment = Alignment.Center, + ) { + if (previewArtwork != null) { + Image(bitmap = previewArtwork, contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize()) + } else if (person.id.isNotBlank() && person.imageTag.isNotBlank()) { + AsyncImage(model = repo.posterUrl(person.id, person.imageTag, 280), contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize()) + } else { + Text(person.name.take(1).uppercase(), color = MembyOnSurface, fontSize = 46.sp, fontWeight = FontWeight.Light) + } + if (selected) Text("✓", color = Color.White, fontSize = 17.sp, fontWeight = FontWeight.Bold, modifier = Modifier.align(Alignment.BottomEnd).background(MembyAccent, CircleShape).padding(horizontal = 9.dp, vertical = 5.dp)) + } + Text(person.name, color = Color.White, fontSize = 14.sp, fontWeight = FontWeight.Medium, maxLines = 2, overflow = TextOverflow.Ellipsis, textAlign = TextAlign.Center) + } + } + } + } +} + +/** Stable fixtures for screenshot reviews of the recommendation taste journey. */ +@Composable +internal fun RecommendationOnboardingPreview( + initialStageIndex: Int = 0, + previewArtwork: ImageBitmap? = null, + previewFocusRequester: FocusRequester? = null, +) { + val localPreviewFocus = remember { FocusRequester() } + val previewFocus = previewFocusRequester ?: localPreviewFocus + LaunchedEffect(Unit) { runCatching { previewFocus.requestFocus() } } + val titles = listOf( + BaseItem(id = "arrival", name = "Arrival", type = "Movie", productionYear = 2016, genres = listOf("Science Fiction")), + BaseItem(id = "parasite", name = "Parasite", type = "Movie", productionYear = 2019, genres = listOf("Thriller")), + BaseItem(id = "spirited-away", name = "Spirited Away", type = "Movie", productionYear = 2001, genres = listOf("Animation")), + BaseItem(id = "moonlight", name = "Moonlight", type = "Movie", productionYear = 2016, genres = listOf("Drama")), + BaseItem(id = "mad-max", name = "Mad Max: Fury Road", type = "Movie", productionYear = 2015, genres = listOf("Action")), + BaseItem(id = "hunt-wilderpeople", name = "Hunt for the Wilderpeople", type = "Movie", productionYear = 2016, genres = listOf("Comedy")), + ) + val shows = listOf( + BaseItem(id = "severance", name = "Severance", type = "Series", productionYear = 2022, genres = listOf("Drama")), + BaseItem(id = "atlanta", name = "Atlanta", type = "Series", productionYear = 2016, genres = listOf("Comedy")), + ) + val people = listOf( + RecommendationPerson(id = "p1", name = "Mahershala Ali", imageTag = "portrait"), + RecommendationPerson(id = "p2", name = "Dev Patel", imageTag = "portrait"), + RecommendationPerson(id = "p3", name = "Steven Yeun", imageTag = "portrait"), + RecommendationPerson(id = "p4", name = "Oscar Isaac", imageTag = "portrait"), + RecommendationPerson(id = "p5", name = "Daniel Kaluuya", imageTag = "portrait"), + RecommendationPerson(id = "p6", name = "Taika Waititi", imageTag = "portrait"), + ) + Box(Modifier.fillMaxSize().focusRequester(previewFocus).focusable()) { + RecommendationOnboardingScreen( + onboarding = RecommendationOnboarding( + ratings = mapOf("arrival" to 5, "moonlight" to 5), + movies = titles, + shows = shows, + actors = people, + actresses = people.mapIndexed { index, person -> person.copy(id = "a$index", name = listOf("Michelle Yeoh", "Viola Davis", "Emma Stone", "Ayo Edebiri", "Melanie Lynskey", "Sandra Oh")[index]) }, + directors = people.mapIndexed { index, person -> person.copy(id = "d$index", name = listOf("Denis Villeneuve", "Bong Joon-ho", "Greta Gerwig", "Jordan Peele", "Chloé Zhao", "Jane Campion")[index]) }, + ), + onComplete = { _, _, _, _ -> }, + initialStageIndex = initialStageIndex, + previewArtwork = previewArtwork, + ) + } +} + + + diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/ResumableMediaCard.kt b/app/src/main/java/com/ponzischeme89/memby/ui/ResumableMediaCard.kt new file mode 100644 index 0000000..3d3577e --- /dev/null +++ b/app/src/main/java/com/ponzischeme89/memby/ui/ResumableMediaCard.kt @@ -0,0 +1,346 @@ +package com.ponzischeme89.memby.ui + +import android.text.format.DateFormat +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +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.dp +import androidx.compose.ui.unit.sp +import androidx.tv.material3.Icon +import androidx.tv.material3.Text +import coil.compose.AsyncImage +import coil.request.ImageRequest +import com.ponzischeme89.memby.data.model.BaseItem +import com.ponzischeme89.memby.ui.theme.MembyAccent +import com.ponzischeme89.memby.ui.theme.MembyCardCorner +import com.ponzischeme89.memby.ui.theme.MembyIcon +import com.ponzischeme89.memby.ui.theme.MembyMutedText +import com.ponzischeme89.memby.ui.theme.MembyOnSurface +import com.ponzischeme89.memby.ui.theme.MembyQuietText +import com.ponzischeme89.memby.ui.theme.MembySurfaceRaised +import com.ponzischeme89.memby.ui.theme.mark +import java.util.Date +import kotlinx.coroutines.delay + +/** + * Everything a resumable-media card needs to render, with no screen or repository + * dependency. The adapter from [BaseItem] is deliberately separate so another Memby + * surface can supply the same contract from cached or locally produced media data. + */ +data class ResumableMediaCardModel( + val id: String, + val title: String, + val episodeName: String? = null, + val seasonNumber: Int? = null, + val episodeNumber: Int? = null, + val playbackPositionTicks: Long? = null, + val runtimeTicks: Long? = null, + val backdropUrl: String? = null, + val primaryUrl: String? = null, + val played: Boolean = false, + val favourite: Boolean = false, +) { + val episodeLabel: String? get() = episodeLabel(seasonNumber, episodeNumber, episodeName) + val progress: Float get() = resumableProgress(playbackPositionTicks, runtimeTicks) +} + +internal fun BaseItem.toResumableMediaCardModel( + backdropUrl: String?, + primaryUrl: String?, +): ResumableMediaCardModel { + val episodeName = name.trim().takeIf(String::isNotEmpty) + val seriesTitle = seriesName?.trim().orEmpty() + return ResumableMediaCardModel( + id = id, + title = if (isEpisode) { + seriesTitle.ifEmpty { episodeName ?: "Episode" } + } else { + name.trim().ifEmpty { "Unknown title" } + }, + episodeName = episodeName.takeIf { isEpisode && it != seriesTitle }, + seasonNumber = parentIndexNumber.takeIf { isEpisode }, + episodeNumber = indexNumber.takeIf { isEpisode }, + playbackPositionTicks = userData?.playbackPositionTicks, + runtimeTicks = runTimeTicks, + backdropUrl = backdropUrl, + primaryUrl = primaryUrl, + played = userData?.played == true, + favourite = isFavorite, + ) +} + +internal fun episodeLabel(season: Int?, episode: Int?, name: String?): String? { + val cleanName = name?.trim().orEmpty() + val code = if (season != null && season >= 0 && episode != null && episode >= 0) { + "S${season.toString().padStart(2, '0')}E${episode.toString().padStart(2, '0')}" + } else { + null + } + return when { + code != null && cleanName.isNotEmpty() -> "$code - $cleanName" + code != null -> code + cleanName.isNotEmpty() -> cleanName + else -> null + } +} + +internal fun resumableProgress(positionTicks: Long?, runtimeTicks: Long?): Float { + val runtime = runtimeTicks ?: return 0f + if (runtime <= 0L) return 0f + return ((positionTicks ?: 0L).coerceAtLeast(0L).toDouble() / runtime.toDouble()) + .coerceIn(0.0, 1.0) + .toFloat() +} + +/** The local-clock instant at which this title would finish if resumed now. */ +internal fun expectedFinishEpochMillis( + nowEpochMillis: Long, + positionTicks: Long?, + runtimeTicks: Long?, +): Long? { + val runtime = runtimeTicks ?: return null + if (runtime <= 0L) return null + val position = (positionTicks ?: 0L).coerceIn(0L, runtime) + val remainingMillis = (runtime - position) / TICKS_PER_MILLISECOND + if (remainingMillis <= 0L) return null + return if (Long.MAX_VALUE - nowEpochMillis < remainingMillis) { + Long.MAX_VALUE + } else { + nowEpochMillis + remainingMillis + } +} + +/** + * A self-contained card for anything that can be resumed. + * + * The component owns the progress, episode identity and expected-finish presentation; + * callers own placement, artwork shape and actions. + */ +@Composable +fun ResumableMediaCard( + model: ResumableMediaCardModel, + width: Dp, + portraitArtwork: Boolean, + modifier: Modifier = Modifier, + onFocused: () -> Unit, + onClick: () -> Unit, + onLongClick: (() -> Unit)? = null, +) { + val context = LocalContext.current + val artworkUrl = if (portraitArtwork) { + model.primaryUrl ?: model.backdropUrl + } else { + model.backdropUrl ?: model.primaryUrl + } + val usesPrimaryArtwork = artworkUrl != null && artworkUrl == model.primaryUrl + val nowEpochMillis by produceState(initialValue = System.currentTimeMillis()) { + while (true) { + val now = System.currentTimeMillis() + value = now + delay(MINUTE_MILLIS - now % MINUTE_MILLIS) + } + } + val finishAt = remember(nowEpochMillis, model.playbackPositionTicks, model.runtimeTicks) { + expectedFinishEpochMillis( + nowEpochMillis = nowEpochMillis, + positionTicks = model.playbackPositionTicks, + runtimeTicks = model.runtimeTicks, + ) + } + val endsAt = remember(finishAt, context) { + finishAt?.let { + val localTime = DateFormat.getTimeFormat(context) + .format(Date(it)) + .replace("am", "AM", ignoreCase = true) + .replace("pm", "PM", ignoreCase = true) + "Ends at: $localTime" + } + } + val description = remember(model, endsAt) { + listOfNotNull( + model.title.takeIf(String::isNotBlank), + model.episodeLabel, + endsAt, + model.progress.takeIf { it > 0f }?.let { "${(it * 100).toInt()} percent watched" }, + ).joinToString(", ") + } + val aspectRatio = if (portraitArtwork) 2f / 3f else 16f / 9f + var failed by remember(model.id, artworkUrl) { mutableStateOf(false) } + var loading by remember(model.id, artworkUrl) { mutableStateOf(artworkUrl != null) } + val imageRequest = remember(artworkUrl, context) { + artworkUrl?.let { + ImageRequest.Builder(context) + .data(it) + .allowHardware(true) + .crossfade(false) + .build() + } + } + + FocusScaleContainer( + onFocused = onFocused, + onClick = onClick, + onLongClick = onLongClick, + contentDescription = description, + modifier = modifier.width(width), + ) { focused -> + Column { + Box( + modifier = Modifier + .width(width) + .aspectRatio(aspectRatio) + .shadow( + elevation = if (focused) 7.dp else 0.dp, + shape = RoundedCornerShape(MembyCardCorner), + ) + .clip(RoundedCornerShape(MembyCardCorner)) + .background(MembySurfaceRaised) + .border( + width = 2.dp, + color = if (focused) Color.White else Color.White.copy(alpha = 0.07f), + shape = RoundedCornerShape(MembyCardCorner), + ), + contentAlignment = Alignment.Center, + ) { + if (loading) ArtworkLoadingSkeleton(Modifier.fillMaxSize()) + if (imageRequest != null) { + AsyncImage( + model = imageRequest, + contentDescription = null, + contentScale = if (usesPrimaryArtwork) ContentScale.Fit else ContentScale.Crop, + onLoading = { loading = true }, + onSuccess = { + failed = false + loading = false + }, + onError = { + failed = true + loading = false + }, + modifier = Modifier.fillMaxSize(), + ) + } + if (artworkUrl == null || failed) { + Icon( + MembyIcon.BrokenImage.mark, + contentDescription = "Artwork unavailable", + tint = MembyQuietText, + modifier = Modifier.size(30.dp), + ) + } + if (model.progress > 0f) { + Box( + Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth() + .height(5.dp) + .background(Color.Black.copy(alpha = 0.65f)), + ) { + Box( + Modifier + .fillMaxWidth(model.progress) + .height(5.dp) + .background(MembyAccent), + ) + } + } + if (model.played || model.favourite) { + Row( + modifier = Modifier.align(Alignment.TopEnd).padding(8.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + if (model.played) { + ResumableStatusIcon(MembyIcon.CheckCircle.mark, "Watched", MembyAccent) + } + if (model.favourite) { + ResumableStatusIcon( + MembyIcon.Favourite.mark, + "Favourite", + Color(0xFFFF6B81), + ) + } + } + } + if (focused) MembyArtworkPlayCue(Modifier.align(Alignment.Center)) + } + Text( + text = model.title, + color = if (focused) Color.White else MembyOnSurface, + fontSize = 14.sp, + fontWeight = if (focused) FontWeight.Bold else FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(top = 7.dp).fillMaxWidth(), + ) + model.episodeLabel?.let { label -> + Text( + text = label, + color = MembyMutedText, + fontSize = 12.sp, + fontWeight = FontWeight.Medium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(top = 2.dp).fillMaxWidth(), + ) + } + endsAt?.let { label -> + Text( + text = label, + color = MembyQuietText, + fontSize = 12.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(top = 2.dp).fillMaxWidth(), + ) + } + } + } +} + +@Composable +private fun ResumableStatusIcon( + icon: androidx.compose.ui.graphics.vector.ImageVector, + description: String, + tint: Color, +) { + Box( + modifier = Modifier + .size(29.dp) + .clip(RoundedCornerShape(10.dp)) + .background(Color.Black.copy(alpha = 0.78f)) + .border(1.dp, Color.White.copy(alpha = 0.16f), RoundedCornerShape(10.dp)), + contentAlignment = Alignment.Center, + ) { + Icon(icon, contentDescription = description, tint = tint, modifier = Modifier.size(18.dp)) + } +} + +private const val TICKS_PER_MILLISECOND = 10_000L +private const val MINUTE_MILLIS = 60_000L diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/UserSwitcherNavigation.kt b/app/src/main/java/com/ponzischeme89/memby/ui/UserSwitcherNavigation.kt index e22e816..cb3deae 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/UserSwitcherNavigation.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/UserSwitcherNavigation.kt @@ -1,11 +1,15 @@ package com.ponzischeme89.memby.ui +import com.ponzischeme89.memby.data.EmbyProfile +import com.ponzischeme89.memby.data.model.MembyViewer +import com.ponzischeme89.memby.ui.viewers.viewerIsActive + internal enum class UserSwitcherDirection { UP, DOWN } /** - * Profiles occupy [0, profileCount); the pinned actions — Notifications, optional Requests, - * Settings, then Manage users — follow them in order. Keeping this arithmetic outside - * Compose makes remote navigation deterministic. + * The scrolling list at the top of the panel occupies [0, rowCount); the pinned actions + * follow it in order. Keeping this arithmetic outside Compose makes remote navigation + * deterministic. */ internal fun userSwitcherInitialIndex( profileIds: List, @@ -28,7 +32,78 @@ internal fun userSwitcherNextIndex( } /** - * The pinned rows below the profile list, in the order they are drawn. + * One row of the panel's scrolling list. + * + * The list holds two quite different things depending on whether the household runs viewers + * — see [userSwitcherRows] — and every piece of the panel derives from this one list: the + * requester count, the D-pad's lower bound, each row's index and what a press does. It is a + * list rather than three parallel pieces of arithmetic for the reason [UserSwitcherMenuItem] + * is: a count that disagrees with the rows actually drawn is how the last row in a menu + * becomes unreachable. + */ +internal sealed interface UserSwitcherRow { + /** An Emby sign-in. Selecting one re-authenticates and reloads everything. */ + data class Account(val profile: EmbyProfile, val active: Boolean) : UserSwitcherRow + + /** A person under the signed-in account. Selecting one changes whose evening it is. */ + data class Viewer(val viewer: MembyViewer, val active: Boolean) : UserSwitcherRow + + /** The one control that creates a person, offered while the account is under its limit. */ + data object AddViewer : UserSwitcherRow +} + +/** + * What the panel's list is a list *of*. + * + * Whichever of the two a household changes daily is the one that belongs here, and that is + * decided entirely by whether they run viewers. An Emby account is a credential: it is + * chosen once when a television is set up and then almost never again. A viewer is a person, + * and which of them is on the sofa changes nightly. The panel used to have this exactly the + * wrong way round — the account was the one-press choice and the person was a row leading to + * a separate full screen — so the frequent action cost three presses and a screen change and + * the rare one cost a single press. + * + * With viewers switched off nothing moves: the list is the accounts it always was, because + * then the account genuinely *is* who is watching. + * + * The two kinds of row are never mixed in one list, and that is the point rather than a + * simplification. They look alike and they are not alike: selecting an account signs in to + * Emby and can be removed with a password behind it, where selecting a viewer changes a + * header on the gateway. One flat list of both is how somebody deletes a sign-in believing + * they removed a person. + */ +internal fun userSwitcherRows( + profiles: List, + viewers: List, + activeProfileId: String?, + activeViewerId: String, + showViewers: Boolean, + canAddViewer: Boolean, +): List = when { + !showViewers -> profiles.map { UserSwitcherRow.Account(it, it.id == activeProfileId) } + else -> buildList { + viewers.forEach { add(UserSwitcherRow.Viewer(it, viewerIsActive(it, activeViewerId))) } + if (canAddViewer) add(UserSwitcherRow.AddViewer) + } +} + +/** + * Where the remote opens in that list: on whoever — or whichever account — is in force, so + * the common case is a glance rather than a walk. The Add row is never it. + */ +internal fun userSwitcherRowFocusIndex(rows: List): Int { + val index = rows.indexOfFirst { + when (it) { + is UserSwitcherRow.Viewer -> it.active + is UserSwitcherRow.Account -> it.active + UserSwitcherRow.AddViewer -> false + } + } + return if (index >= 0) index else 0 +} + +/** + * The pinned rows below the list, in the order they are drawn. * * It is a list rather than four pieces of arithmetic because five things read it — the * requester list's length, the D-pad's lower bound, and each row's own index — and every @@ -38,20 +113,64 @@ internal fun userSwitcherNextIndex( * conditional row is exactly the change that breaks it. This is the shape `QuickAction` * already moved to for the same reason. */ -internal enum class UserSwitcherMenuItem { VIEWERS, NOTIFICATIONS, REQUESTS, SETTINGS, MANAGE_USERS } +internal enum class UserSwitcherMenuItem { + SWITCH_ACCOUNT, + NOTIFICATIONS, + REQUESTS, + SETTINGS, + MANAGE_VIEWERS, + MANAGE_USERS, +} /** - * Who's watching first, because it changes *whose* menu this is: the notifications and - * requests below it belong to whichever viewer it selects, so offering it after them would - * put the answer below the things that depend on it. + * Whichever of the two lists is *not* on the panel is reached by a row instead, and it is + * the first row for the same reason "Who's watching?" used to be: it changes whose menu this + * is, so the notifications and requests below it belong to whatever it selects. + * + * With viewers in play the accounts move behind [UserSwitcherMenuItem.SWITCH_ACCOUNT], which + * opens the account chooser — and that screen already adds and removes accounts, so a + * separate "Manage accounts" row would be a second door onto one room. Managing *people* + * (renaming, removing) keeps its own row at the bottom, where "Manage users" sits when + * viewers are off, so the panel is the same shape either way. */ internal fun userSwitcherMenuItems( showRequests: Boolean, showViewers: Boolean = false, ): List = buildList { - if (showViewers) add(UserSwitcherMenuItem.VIEWERS) + if (showViewers) add(UserSwitcherMenuItem.SWITCH_ACCOUNT) add(UserSwitcherMenuItem.NOTIFICATIONS) if (showRequests) add(UserSwitcherMenuItem.REQUESTS) add(UserSwitcherMenuItem.SETTINGS) - add(UserSwitcherMenuItem.MANAGE_USERS) + add( + if (showViewers) { + UserSwitcherMenuItem.MANAGE_VIEWERS + } else { + UserSwitcherMenuItem.MANAGE_USERS + }, + ) +} + +/** + * What the "Switch account" row says underneath its label. + * + * It names the account in force, because with the list above it full of *people* the one + * thing the row has to establish is which of the two questions it answers. A household with + * one Emby sign-in still sees it: it is the way to add a second, and the way to the screen + * that removes one. + */ +internal fun switchAccountLabel(profiles: List, activeProfileId: String?): String { + val name = profiles.firstOrNull { it.id == activeProfileId }?.username?.trim().orEmpty() + return if (name.isEmpty()) "Switch account" else "Switch account · $name" +} + +/** + * A keyed `LazyColumn` throws on a repeated key and takes the panel with it, and the two + * lists are keyed by ids from two different systems — an Emby profile id and a gateway + * viewer id have no reason not to collide. Prefixing is what keeps them apart, and the Add + * row is a constant because there is only ever one of it. + */ +internal fun userSwitcherRowKey(row: UserSwitcherRow): String = when (row) { + is UserSwitcherRow.Account -> "account:${row.profile.id}" + is UserSwitcherRow.Viewer -> "viewer:${row.viewer.id}" + UserSwitcherRow.AddViewer -> "add-viewer" } diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/player/PlayerActivity.kt b/app/src/main/java/com/ponzischeme89/memby/ui/player/PlayerActivity.kt index 5404008..5c7a246 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/player/PlayerActivity.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/player/PlayerActivity.kt @@ -3214,7 +3214,15 @@ class PlayerActivity : ComponentActivity() { // Playback may have moved on to another episode while this was in flight. if (itemId == id) { updateNextEpisodeButton() - if (resolved != null && playbackSettings.playNextEpisodePreview) { + // Both switches, because a preview *is* the automatic transition: it takes + // over the end of the episode and rolls into the next one. Fetching it for a + // viewer who has automatic advance off would spend a provider search on + // something that must never play. `shouldStartNextEpisodePreview` refuses it + // again at the moment of use, where a preference synced mid-episode is seen. + if (resolved != null && + playbackSettings.playNextEpisodePreview && + playbackSettings.autoPlayNextEpisode + ) { prefetchNextEpisodePreview(id, resolved) } // Extremely short episodes and hostile latency can reach Ended before the @@ -3239,6 +3247,17 @@ class PlayerActivity : ComponentActivity() { } } + /** + * Whether this viewer wants the player to move on by itself. + * + * Read live rather than captured when playback started, because it is a synced + * per-profile preference: switching it off on another television, or an operator pushing + * a document from the console, arrives mid-episode and must be honoured by the very next + * decision this player makes. + */ + private val autoPlayNextEpisodeEnabled: Boolean + get() = ServiceLocator.repository.currentSettings.autoPlayNextEpisode + /** * Whether this is a programme rather than a film. Read off the series name the launcher * handed over, which is set for every episode and blank for everything else, rather than @@ -3456,6 +3475,7 @@ class PlayerActivity : ComponentActivity() { val remainingMs = (duration - playback.currentPosition).coerceAtLeast(0L) if (remainingMs > NEXT_EPISODE_PREVIEW_LEAD_MS) previewWindowArmed = true if (shouldStartNextEpisodePreview( + autoPlayEnabled = autoPlayNextEpisodeEnabled, armed = previewWindowArmed, dismissed = nextUpDismissed, remainingMs = remainingMs, @@ -3478,7 +3498,7 @@ class PlayerActivity : ComponentActivity() { if (remainingMs <= NEXT_UP_STREAM_WARM_LEAD_MS) nextUpResolver.warm() val autoAdvance = shouldAutoAdvance( - autoPlayEnabled = ServiceLocator.repository.currentSettings.autoPlayNextEpisode, + autoPlayEnabled = autoPlayNextEpisodeEnabled, hasNextEpisode = true, dismissed = nextUpDismissed, ) @@ -3581,7 +3601,13 @@ class PlayerActivity : ComponentActivity() { updateNextEpisodeButton() previewNextEpisode = null if (previewOutgoingReported) { - if (next != null) startNextEpisode() else returnHomeAfterCompletion() + // The outgoing episode has already been reported stopped, so there is nothing to + // go back to — but where it goes is still the viewer's setting to decide. + if (next != null && autoPlayNextEpisodeEnabled) { + startNextEpisode() + } else { + returnHomeAfterCompletion() + } return } playbackTitle = previewResumeTitle @@ -3605,7 +3631,7 @@ class PlayerActivity : ComponentActivity() { if (previewResumeUrl.isBlank()) { // The original stream should always be known, but losing the optional preview // must never strand the viewer on a generic error pane. - if (next != null) startNextEpisode() else finish() + if (next != null && autoPlayNextEpisodeEnabled) startNextEpisode() else finish() return } showPlaybackLoading(title = playbackTitle, hint = "Returning to the episode") @@ -3639,7 +3665,14 @@ class PlayerActivity : ComponentActivity() { playingNextEpisodePreview = false previewNextEpisode = null nextEpisodePreview = null - if (next != null) startNextEpisode() else returnHomeAfterCompletion() + // The preview only ever starts with automatic advance on, so this is the setting + // having been switched off — from Settings on this set, or synced from another — + // while it played. The transition it was the opening of must go with it. + if (next != null && autoPlayNextEpisodeEnabled) { + startNextEpisode() + } else { + returnHomeAfterCompletion() + } } @OptIn(UnstableApi::class) diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/player/PrerollPreloader.kt b/app/src/main/java/com/ponzischeme89/memby/ui/player/PrerollPreloader.kt index 916a3e2..f989ba8 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/player/PrerollPreloader.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/player/PrerollPreloader.kt @@ -19,7 +19,7 @@ import com.ponzischeme89.memby.R * sought back to the beginning and prepared again after use rather than reconstructed for * every title. */ -@UnstableApi +@OptIn(UnstableApi::class) internal object PrerollPreloader { private val mainHandler = Handler(Looper.getMainLooper()) private var applicationContext: Context? = null diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/profiles/ProfileChooser.kt b/app/src/main/java/com/ponzischeme89/memby/ui/profiles/ProfileChooser.kt new file mode 100644 index 0000000..e7db32f --- /dev/null +++ b/app/src/main/java/com/ponzischeme89/memby/ui/profiles/ProfileChooser.kt @@ -0,0 +1,781 @@ +package com.ponzischeme89.memby.ui.profiles + +import androidx.activity.compose.BackHandler +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.foundation.focusGroup +import androidx.compose.ui.focus.focusProperties +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.zIndex +import androidx.tv.material3.Icon +import androidx.tv.material3.Text +import com.ponzischeme89.memby.R +import com.ponzischeme89.memby.data.EmbyProfile +import com.ponzischeme89.memby.ui.MembyDialogAction +import com.ponzischeme89.memby.ui.MembySecondaryButton +import com.ponzischeme89.memby.ui.distinctForKeys +import com.ponzischeme89.memby.ui.profileInitials +import com.ponzischeme89.memby.ui.theme.MembyAccent +import com.ponzischeme89.memby.ui.theme.MembyAccentBright +import com.ponzischeme89.memby.ui.theme.MembyAccentInk +import com.ponzischeme89.memby.ui.theme.MembyControlSurface +import com.ponzischeme89.memby.ui.theme.MembyControlSurfaceRaised +import com.ponzischeme89.memby.ui.theme.MembyDestructive +import com.ponzischeme89.memby.ui.theme.MembyHairline +import com.ponzischeme89.memby.ui.theme.MembyIcon +import com.ponzischeme89.memby.ui.theme.MembyMutedText +import com.ponzischeme89.memby.ui.theme.MembyOnSurface +import com.ponzischeme89.memby.ui.theme.MembyOutline +import com.ponzischeme89.memby.ui.theme.MembyPanelCorner +import com.ponzischeme89.memby.ui.theme.MembyQuietText +import com.ponzischeme89.memby.ui.theme.MembySplashTint +import com.ponzischeme89.memby.ui.theme.MembySurface +import com.ponzischeme89.memby.ui.theme.MembySurfaceRaised +import com.ponzischeme89.memby.ui.theme.mark +import kotlinx.coroutines.delay +import kotlin.time.Duration.Companion.milliseconds + +/** + * The metrics every card on this screen is measured against. + * + * They are constants rather than numbers typed into the layout because the whole claim this + * screen makes is that it is *centred* — heading, row, names and the actions under them on + * one line down the middle of the television. A row of cards can only be centred if every + * card is the same width and the same height, and a card can only be the same height as its + * neighbour if the lines that may or may not be there are **reserved**: the caption under a + * name, and the slot the remove control appears in. Without that the row grows and shrinks + * as the remote travels it, which is exactly what the eye reads as "not quite finished". + */ +internal object ProfileChooserMetrics { + val CardWidth = 168.dp + + /** + * 104dp, and the figure is a budget rather than a taste. A television is 540dp tall and + * this Column is centred in it, so everything on the screen has to fit between the two + * 40dp margins or the bottom of it is simply not drawn — which is what happened to the + * manage page's Back action at 116dp: the one control on that screen with anywhere to go + * was the one below the fold. Anything added here has to come out of something else. + */ + val Avatar = 104.dp + val NameLine = 22.dp + val CaptionLine = 18.dp + + /** + * Held open whether or not a remove control is drawn in it. Without the reservation the + * whole row would grow by 44dp as focus arrived on a card and shrink again as it left. + */ + val RemoveSlot = 40.dp + val RemoveButton = 36.dp + val CardGap = 26.dp +} + +/** + * "Who's watching?" — the Emby accounts signed in on this television. + * + * Two screens, one layout, and they answer different questions. At sign-in this is the only + * thing on the set and the question really is who is watching; reached from Manage users it + * sits behind the viewer picker, which asks exactly that, so it heads itself "Accounts on + * this TV" instead. [onClose] is the honest evidence of which one this is: only the page + * reached from the user menu has anywhere to go back to. + * + * Stateless on purpose, the stance `SignInContent`, `ViewerPicker` and the detail panes take + * — everything it needs is a parameter, so `ProfileChooserScreenshotTest` can render it with + * no gateway, and the caller owns the requests. + * + * ## Removing a user + * + * It used to be a 124dp "Remove user" button parked under every card, permanently, bordered + * in white — so the destructive action was as loud as the people it belonged to, and a row of + * four profiles read as a row of four delete buttons. It is now a small circular mark in a + * **reserved** slot, drawn only while the remote is on that card, which is the shape the + * alerts page's seen toggle already takes: + * + * - **The reveal is the card's `hasFocus`, not the tile's `isFocused`.** Down moves focus off + * the tile and onto the control inside the same card; a card that revealed on `isFocused` + * would take the control away in the frame the focus landed on it, which is a trap. + * - **A hidden control is not composed at all.** No invisible focusable nodes: the tile's + * `down` names the remove requester only while that card is revealed and is `Cancel` + * otherwise, so the press does nothing rather than throwing on an unattached requester. + * - **The slot is reserved.** See [ProfileChooserMetrics]. + * - **It still asks.** A remote press is cheap and a forgotten sign-in is not, so + * [ProfileRemovalConfirmation] stands between the two, opens on the safe answer, and takes + * Back as a cancel — the key that raised it must not be the key that removes somebody. + * - **Cancelling puts the remote back where it was**, on the control that raised the panel, + * which is why `revealedCard` exists: nothing in that card holds focus at that moment, so + * the control has to be drawn before it can be asked for. + */ +@Composable +@OptIn(ExperimentalComposeUiApi::class) +internal fun ProfileChooser( + profiles: List, + currentProfileId: String?, + switchingProfileId: String?, + removingProfileId: String?, + onSelect: (EmbyProfile) -> Unit, + onRemove: (EmbyProfile) -> Unit, + onAddProfile: () -> Unit, + onClose: (() -> Unit)?, + /** + * Draws the screen as though the remote were on this card, so a capture can show the + * focused avatar and the revealed remove control at once. Robolectric's window never + * takes focus and the focus state is most of what this screen has to say — the flag + * `ViewerActionButton` and `ExitMembyConfirmation` carry, for the same reason. The index + * one past the last profile is the Add card. + */ + focusedCardForCapture: Int? = null, +) { + val addFocus = remember { FocusRequester() } + val backFocus = remember { FocusRequester() } + var pendingRemoval by remember { mutableStateOf(null) } + var removalReturnIndex by remember { mutableStateOf(null) } + // Whether this is the manage page rather than the sign-in chooser. + val managing = onClose != null + val orderedProfiles = remember(profiles, currentProfileId) { + profileChooserOrder(profiles, currentProfileId) + } + val profileIds = orderedProfiles.map(EmbyProfile::id) + val tileFocus = remember(profileIds) { List(orderedProfiles.size) { FocusRequester() } } + val removeFocus = remember(profileIds) { List(orderedProfiles.size) { FocusRequester() } } + val listState = rememberLazyListState() + val busy = switchingProfileId != null || removingProfileId != null + + // Which card the remote is in. It is the *card's* focus rather than the tile's, so + // stepping down into that card's own remove control does not put the control away. + var focusedCard by remember(profileIds) { mutableStateOf(null) } + // Forces one card open while nothing in it holds focus: the frame between cancelling a + // removal and the focus request landing back on the control that raised it. + var revealedCard by remember(profileIds) { mutableStateOf(null) } + // Where Up goes from the action under the row. Whatever the remote last stood on is the + // predictable answer; -1 is the Add card. + var lastRowFocus by remember(profileIds) { mutableStateOf(-1) } + + // Focus opens on the current profile and never on a remove control. The row is ordered + // with the signed-in account first, so index 0 is that account — the common case being + // one confirm press rather than a walk along the row. + LaunchedEffect(profileIds) { + delay(16.milliseconds) + val targetIndex = removalReturnIndex?.let { + profileFocusIndexAfterRemoval(it, orderedProfiles.size) + } ?: if (orderedProfiles.isEmpty()) null else 0 + removalReturnIndex = null + if (targetIndex == null) { + runCatching { addFocus.requestFocus() } + } else { + listState.scrollToItem(targetIndex) + runCatching { tileFocus[targetIndex].requestFocus() } + .onFailure { runCatching { addFocus.requestFocus() } } + } + } + + LaunchedEffect(revealedCard) { + val index = revealedCard ?: return@LaunchedEffect + delay(16.milliseconds) + runCatching { removeFocus[index].requestFocus() } + .onFailure { runCatching { tileFocus.getOrNull(index)?.requestFocus() } } + // Cleared once the request has landed: the card's own `hasFocus` is what keeps the + // control drawn from here on. + revealedCard = null + } + + Box( + modifier = Modifier + .fillMaxSize() + // The wash the exit panel is drawn on rather than a flat near-black: it is what + // makes a screen of circles on an empty field read as part of Memby. + .background( + Brush.radialGradient( + listOf(MembySplashTint.copy(alpha = 0.55f), MembySurface), + ), + ), + ) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 64.dp, vertical = 40.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Image( + painter = painterResource(R.drawable.emby_logo), + contentDescription = "Memby", + modifier = Modifier.width(52.dp).height(44.dp), + ) + Spacer(Modifier.height(16.dp)) + Text( + if (managing) "Accounts on this TV" else "Who’s watching?", + color = MembyOnSurface, + fontSize = 36.sp, + fontWeight = FontWeight.SemiBold, + textAlign = TextAlign.Center, + ) + Spacer(Modifier.height(8.dp)) + Text( + when { + removingProfileId != null -> "Removing user…" + switchingProfileId != null -> "Switching profile…" + managing -> + "Each one signs in to Emby separately. " + + "To add somebody to this account, use Who’s watching?" + else -> "Choose a profile to continue" + }, + color = MembyQuietText, + fontSize = 17.sp, + textAlign = TextAlign.Center, + ) + Spacer(Modifier.height(26.dp)) + + LazyRow( + state = listState, + modifier = Modifier.fillMaxWidth().focusGroup(), + // Centred *by the arrangement*, so the row keeps the screen's centre line as + // the household gains and loses accounts. A start arrangement with content + // padding — what this was — leaves two profiles hard against the left edge + // of a 1080p television. + horizontalArrangement = Arrangement.spacedBy( + ProfileChooserMetrics.CardGap, + Alignment.CenterHorizontally, + ), + contentPadding = PaddingValues(horizontal = 12.dp), + verticalAlignment = Alignment.Top, + ) { + itemsIndexed(orderedProfiles, key = { _, profile -> profile.id }) { index, profile -> + val revealed = !busy && ( + focusedCard == index || + revealedCard == index || + focusedCardForCapture == index + ) + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .width(ProfileChooserMetrics.CardWidth) + // Observer before the group it observes: `onFocusChanged` + // reports the state of the focus target that follows it in the + // chain, so the two the other way round never fire at all. + .onFocusChanged { state -> + if (state.hasFocus) { + focusedCard = index + lastRowFocus = index + } else if (focusedCard == index) { + focusedCard = null + } + } + .focusGroup(), + ) { + ProfileTile( + name = profile.username, + symbol = profileInitials(profile.username, profile.profileInitials), + current = profile.id == currentProfileId, + enabled = !busy, + focusedForCapture = focusedCardForCapture == index, + onClick = { onSelect(profile) }, + modifier = Modifier + .focusRequester(tileFocus[index]) + .focusProperties { + up = FocusRequester.Cancel + left = if (index > 0) { + tileFocus[index - 1] + } else { + FocusRequester.Cancel + } + right = if (index < orderedProfiles.lastIndex) { + tileFocus[index + 1] + } else { + addFocus + } + // Named only while the control is actually composed. + down = if (revealed) { + removeFocus[index] + } else { + FocusRequester.Cancel + } + }, + ) + Box( + modifier = Modifier.height(ProfileChooserMetrics.RemoveSlot), + contentAlignment = Alignment.Center, + ) { + if (revealed) { + ProfileRemoveButton( + profileName = profile.username, + onClick = { pendingRemoval = profile }, + focusedForCapture = false, + modifier = Modifier + .focusRequester(removeFocus[index]) + .focusProperties { + up = tileFocus[index] + // Sideways stays put: the card beside this one + // has no remove control drawn, so Left and Right + // would be a jump to somewhere invisible. + left = FocusRequester.Cancel + right = FocusRequester.Cancel + down = if (managing) { + backFocus + } else { + FocusRequester.Cancel + } + }, + ) + } + } + } + } + item(key = "add-profile") { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .width(ProfileChooserMetrics.CardWidth) + .onFocusChanged { if (it.hasFocus) lastRowFocus = -1 }, + ) { + ProfileTile( + // Named for what it actually does. It signs a second Emby + // account in, which is not what "add another user" means to a + // household that has just been offered viewers by the menu above. + name = if (managing) "Add an Emby account" else "Add another user", + symbol = "+", + current = false, + enabled = !busy, + outlined = true, + focusedForCapture = focusedCardForCapture == orderedProfiles.size, + onClick = onAddProfile, + modifier = Modifier + .focusRequester(addFocus) + .focusProperties { + up = FocusRequester.Cancel + left = tileFocus.lastOrNull() ?: FocusRequester.Cancel + right = FocusRequester.Cancel + down = if (managing) backFocus else FocusRequester.Cancel + }, + ) + // The Add card has no remove control and still owes the row its + // height, or it would sit proud of every profile beside it. + Spacer(Modifier.height(ProfileChooserMetrics.RemoveSlot)) + } + } + } + + Spacer(Modifier.height(12.dp)) + // Reserved, never conditional. A control that only appears under focus is + // invisible to somebody who has never pressed Down, and this line is the only + // thing on the screen that tells them it is there — but a line that *appeared* + // as focus reached a card would shift everything under it on every press. + Text( + text = profileRemoveHint( + cardFocused = focusedCard != null || focusedCardForCapture != null, + busy = busy, + ), + color = MembyQuietText, + fontSize = 13.sp, + textAlign = TextAlign.Center, + maxLines = 1, + modifier = Modifier.height(ProfileChooserMetrics.CaptionLine), + ) + + if (onClose != null) { + Spacer(Modifier.height(16.dp)) + MembySecondaryButton( + label = "Back to Memby", + onClick = { if (!busy) onClose() }, + modifier = Modifier + .focusRequester(backFocus) + .focusProperties { + // Back to whichever card the remote came down from, rather than + // to a remove control that is no longer drawn. + up = if (lastRowFocus >= 0) { + tileFocus.getOrNull(lastRowFocus) ?: addFocus + } else { + addFocus + } + left = FocusRequester.Cancel + right = FocusRequester.Cancel + down = FocusRequester.Cancel + }, + ) + } + } + + pendingRemoval?.let { profile -> + ProfileRemovalConfirmation( + profile = profile, + onCancel = { + val index = profileIds.indexOf(profile.id) + pendingRemoval = null + if (index >= 0) revealedCard = index + }, + onConfirm = { + removalReturnIndex = profileIds.indexOf(profile.id).takeIf { it >= 0 } + pendingRemoval = null + onRemove(profile) + }, + ) + } + } +} + +/** + * The row's order: whoever is signed in first, then the rest as stored, with any repeated id + * dropped. + * + * Pure, so the rule that decides *where focus opens* can be tested — the screen focuses index + * 0 and nothing else, which is only the right answer while this puts the signed-in account + * there. Deduplication is the `ui/ListKeys.kt` rule and belongs here rather than in the + * composable: a keyed `LazyRow` throws on a repeated key and takes the screen with it, and + * the profiles list is written by a television that has been reinstalled onto. + */ +internal fun profileChooserOrder( + profiles: List, + currentProfileId: String?, +): List = profiles + .distinctForKeys(EmbyProfile::id) + .sortedByDescending { it.id == currentProfileId } + +/** + * Which card takes focus once a removal has actually happened: the one that moved into the + * gap, or the last card when the gap was at the end. Null when nothing is left to focus, in + * which case the caller falls back to Add. + */ +internal fun profileFocusIndexAfterRemoval(removedIndex: Int, remainingCount: Int): Int? = + if (remainingCount <= 0) null else removedIndex.coerceIn(0, remainingCount - 1) + +/** + * What the reserved line under the row says. Pure, because it is the whole of this screen's + * discoverability, and because "say nothing" has to be a *space* rather than an absence — + * the line holds its height either way. + */ +internal fun profileRemoveHint(cardFocused: Boolean, busy: Boolean): String = when { + busy -> " " + cardFocused -> "Press Down for options" + else -> " " +} + +/** + * One account, as a face and a name. + * + * The animated lift is read only inside [graphicsLayer], never in the composable body, so + * travelling the row redraws two cards rather than recomposing every card in it — the rule + * every focus treatment in this app follows. It is a small lift (1.05) rather than a jump: + * the card under focus is already the only accent-filled thing on the screen, which is what + * carries at three metres, and a card that grew noticeably would shove its neighbours about. + */ +@Composable +private fun ProfileTile( + name: String, + symbol: String, + current: Boolean, + enabled: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, + /** The Add card: a quieter disc, so it reads as a slot rather than as a person. */ + outlined: Boolean = false, + focusedForCapture: Boolean = false, +) { + var hasFocus by remember { mutableStateOf(false) } + val focused = hasFocus || focusedForCapture + val lift by animateFloatAsState( + targetValue = if (focused) 1f else 0f, + animationSpec = tween(durationMillis = 110), + label = "profile-tile-focus", + ) + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = modifier + .width(ProfileChooserMetrics.CardWidth) + .zIndex(if (focused) 1f else 0f) + .onFocusChanged { hasFocus = it.isFocused } + .clickable(enabled = enabled, onClick = onClick) + .semantics { + contentDescription = if (current) "$name, current profile" else name + }, + ) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .size(ProfileChooserMetrics.Avatar) + // The lift is the *avatar's*, not the card's. Scaling the whole card moved + // the name with it, so the one card under focus sat its name a few pixels + // below every other name in the row — and a row of names that do not share + // a baseline is the first thing the eye reads as unfinished. A layer scale + // costs the layout nothing, so the disc grows inside a slot that does not. + .graphicsLayer { + val t = lift + scaleX = 1f + 0.06f * t + scaleY = 1f + 0.06f * t + } + .clip(CircleShape) + .background( + when { + focused -> MembyAccent + outlined -> MembyControlSurface + else -> MembyControlSurfaceRaised + }, + ) + // One ring, never two. Under focus the accent fill is the signal and a + // border on top of it is the heavy outline this screen is trying to lose; + // the signed-in account keeps a thin accent ring while the remote is + // elsewhere, which is what says "this is the one you are already using". + .border( + width = if (!focused && current) 2.dp else 1.dp, + color = when { + focused -> Color.Transparent + current -> MembyAccent + else -> MembyOutline + }, + shape = CircleShape, + ), + ) { + Text( + symbol, + color = if (focused) MembyAccentInk else MembyOnSurface, + fontSize = 36.sp, + fontWeight = FontWeight.SemiBold, + ) + } + Spacer(Modifier.height(12.dp)) + Text( + name, + color = if (focused) MembyOnSurface else MembyMutedText, + fontSize = 16.sp, + fontWeight = if (focused || current) FontWeight.SemiBold else FontWeight.Medium, + // One line, so every name in the row sits on one baseline and the caption under + // it lines up across the whole row. A name allowed to wrap onto a second line + // was what made the cards different heights. + maxLines = 1, + overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.Center, + modifier = Modifier.height(ProfileChooserMetrics.NameLine), + ) + // Reserved rather than conditional, or the signed-in account's card would sit taller + // than the ones beside it — the rule the cast grid's character line follows. + Text( + if (current) "Current" else " ", + color = MembyAccentBright, + fontSize = 13.sp, + maxLines = 1, + textAlign = TextAlign.Center, + modifier = Modifier.height(ProfileChooserMetrics.CaptionLine), + ) + } +} + +/** + * The secondary, destructive action on one card — a mark rather than a labelled button. + * + * It is red only under focus. A row of red controls would read as a warning about the people + * on it; what this has to say is "there is something here and it is not the ordinary thing", + * which a quiet outline says perfectly well until the remote arrives. + */ +@Composable +private fun ProfileRemoveButton( + profileName: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + focusedForCapture: Boolean = false, +) { + var hasFocus by remember { mutableStateOf(false) } + val focused = hasFocus || focusedForCapture + val lift by animateFloatAsState( + targetValue = if (focused) 1f else 0f, + animationSpec = tween(durationMillis = 110), + label = "profile-remove-focus", + ) + Box( + contentAlignment = Alignment.Center, + modifier = modifier + .size(ProfileChooserMetrics.RemoveButton) + .graphicsLayer { + val t = lift + scaleX = 1f + 0.08f * t + scaleY = 1f + 0.08f * t + } + .clip(CircleShape) + .background(if (focused) MembyDestructive else Color.Transparent) + .border( + width = if (focused) 2.dp else 1.dp, + color = if (focused) Color.White else MembyOutline, + shape = CircleShape, + ) + .onFocusChanged { hasFocus = it.isFocused } + .clickable(onClick = onClick) + .semantics { contentDescription = "Remove $profileName from this TV" }, + ) { + Icon( + MembyIcon.Remove.mark, + contentDescription = null, + tint = if (focused) Color.White else MembyQuietText, + modifier = Modifier.size(18.dp), + ) + } +} + +/** + * "Remove Matt from this TV?" — the full stop between one accidental press and a sign-in + * somebody has to type back in on a remote. + * + * Drawn from the design tokens in the shape `ExitMembyConfirmation` set, and for the same + * reason: the two answers must not look alike. Keeping them is the accent fill and takes + * focus first; removing is a quiet outline that turns red only once the remote is on it. + * Back cancels — it is the key most likely to be pressed by somebody who did not mean to be + * here at all. + */ +@Composable +@OptIn(ExperimentalComposeUiApi::class) +private fun ProfileRemovalConfirmation( + profile: EmbyProfile, + onCancel: () -> Unit, + onConfirm: () -> Unit, + focusedForCapture: Boolean = false, +) { + val cancelFocus = remember { FocusRequester() } + val removeFocus = remember { FocusRequester() } + BackHandler(onBack = onCancel) + // A FocusRequester attached on the frame it is requested on is not placed yet and the + // request is dropped, which on a dialog leaves a television with no way out of it. + LaunchedEffect(profile.id) { + delay(16.milliseconds) + runCatching { cancelFocus.requestFocus() } + } + Box( + modifier = Modifier + .fillMaxSize() + .zIndex(20f) + .background( + Brush.radialGradient( + listOf( + MembySplashTint.copy(alpha = 0.80f), + Color.Black.copy(alpha = 0.93f), + ), + ), + ), + contentAlignment = Alignment.Center, + ) { + val panelShape = RoundedCornerShape(MembyPanelCorner) + Column( + modifier = Modifier + .width(520.dp) + .clip(panelShape) + .background(MembySurfaceRaised) + .border(1.dp, MembyHairline, panelShape) + .padding(horizontal = 40.dp, vertical = 34.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Box( + modifier = Modifier + .size(58.dp) + .clip(CircleShape) + .background(MembyDestructive.copy(alpha = 0.16f)) + .border(1.dp, MembyDestructive.copy(alpha = 0.45f), CircleShape), + contentAlignment = Alignment.Center, + ) { + Icon( + MembyIcon.Remove.mark, + contentDescription = null, + tint = MembyDestructive, + modifier = Modifier.size(27.dp), + ) + } + Spacer(Modifier.height(20.dp)) + Text( + "Remove ${profile.username} from this TV?", + color = MembyOnSurface, + fontSize = 28.sp, + fontWeight = FontWeight.Bold, + textAlign = TextAlign.Center, + ) + Spacer(Modifier.height(10.dp)) + Text( + "Their saved sign-in will be forgotten on this device. Nothing on the Emby " + + "server changes, and they can sign in again at any time.", + color = MembyMutedText, + fontSize = 16.sp, + lineHeight = 23.sp, + textAlign = TextAlign.Center, + ) + Spacer(Modifier.height(26.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(14.dp), + ) { + MembyDialogAction( + label = "Keep them", + primary = true, + onClick = onCancel, + focusedForCapture = focusedForCapture, + modifier = Modifier + .weight(1f) + .focusRequester(cancelFocus) + .focusProperties { + left = FocusRequester.Cancel + right = removeFocus + up = FocusRequester.Cancel + down = FocusRequester.Cancel + }, + ) + MembyDialogAction( + label = "Remove user", + primary = false, + destructive = true, + onClick = onConfirm, + modifier = Modifier + .weight(1f) + .focusRequester(removeFocus) + .focusProperties { + left = cancelFocus + right = FocusRequester.Cancel + up = FocusRequester.Cancel + down = FocusRequester.Cancel + }, + ) + } + Spacer(Modifier.height(16.dp)) + Text( + "Press Back to keep them", + color = MembyQuietText, + fontSize = 12.sp, + fontWeight = FontWeight.Medium, + ) + } + } +} diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/setup/SignInContent.kt b/app/src/main/java/com/ponzischeme89/memby/ui/setup/SignInContent.kt index 1853628..63341ef 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/setup/SignInContent.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/setup/SignInContent.kt @@ -2,6 +2,7 @@ package com.ponzischeme89.memby.ui.setup import androidx.compose.foundation.Image import androidx.compose.foundation.background +import androidx.compose.foundation.border import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -14,26 +15,46 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.tween import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.graphics.Color import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusProperties import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.tv.material3.Text import com.ponzischeme89.memby.R -import com.ponzischeme89.memby.ui.TvTextField import com.ponzischeme89.memby.ui.UpdateButton +import com.ponzischeme89.memby.ui.theme.MembyAccentBright +import com.ponzischeme89.memby.ui.theme.MembyAccentMuted import com.ponzischeme89.memby.ui.theme.MembyMutedText import com.ponzischeme89.memby.ui.theme.MembyOnSurface +import com.ponzischeme89.memby.ui.theme.MembyOutline +import com.ponzischeme89.memby.ui.theme.MembyQuietText import com.ponzischeme89.memby.ui.theme.MembySurface +import com.ponzischeme89.memby.ui.theme.MembySurfaceRaised /** * The sign-in form, with no dependency on the repository — so it can be screenshotted and @@ -155,6 +176,80 @@ fun SignInContent( } } } - /** The one red in the app that means "this went wrong", matched to Settings' own notices. */ private val SignInError = Color(0xFFFF7777) + +@Composable +private fun TvTextField( + label: String, + value: String, + onValueChange: (String) -> Unit, + modifier: Modifier = Modifier, + isPassword: Boolean = false, + keyboardType: KeyboardType = KeyboardType.Text, + focusRequester: FocusRequester? = null, + downFocusRequester: FocusRequester? = null, + onNext: (() -> Unit)? = null, + onDone: (() -> Unit)? = null, +) { + var focused by remember { mutableStateOf(false) } + val borderColor by animateColorAsState( + targetValue = if (focused) MembyAccentBright else MembyOutline, + animationSpec = tween(120), + label = "login-field-border", + ) + val backgroundColor by animateColorAsState( + targetValue = if (focused) MembyAccentMuted else MembySurfaceRaised, + animationSpec = tween(120), + label = "login-field-background", + ) + Column( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + Text( + text = if (focused) "$label — selected" else label, + color = if (focused) MembyAccentBright else MembyQuietText, + fontSize = 15.sp, + fontWeight = if (focused) FontWeight.Bold else FontWeight.Medium, + ) + Box( + modifier = Modifier + .fillMaxWidth() + .border(if (focused) 3.dp else 1.dp, borderColor, RoundedCornerShape(10.dp)) + .background(backgroundColor, RoundedCornerShape(10.dp)) + .padding(horizontal = 18.dp, vertical = 16.dp), + ) { + BasicTextField( + value = value, + onValueChange = onValueChange, + singleLine = true, + textStyle = TextStyle(color = Color.White, fontSize = 20.sp), + cursorBrush = SolidColor(MembyAccentBright), + visualTransformation = if (isPassword) PasswordVisualTransformation() else VisualTransformation.None, + keyboardOptions = KeyboardOptions( + keyboardType = if (isPassword) KeyboardType.Password else keyboardType, + imeAction = if (onNext != null) ImeAction.Next else ImeAction.Done, + ), + keyboardActions = KeyboardActions( + onNext = { onNext?.invoke() }, + onDone = { onDone?.invoke() }, + ), + modifier = Modifier + .fillMaxWidth() + .then( + if (downFocusRequester != null) { + Modifier.focusProperties { down = downFocusRequester } + } else { + Modifier + }, + ) + .then( + if (focusRequester != null) Modifier.focusRequester(focusRequester) + else Modifier, + ) + .onFocusChanged { focused = it.isFocused }, + ) + } + } +} diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/theme/DesignTokens.kt b/app/src/main/java/com/ponzischeme89/memby/ui/theme/DesignTokens.kt index 57c6fda..7d4d77d 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/theme/DesignTokens.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/theme/DesignTokens.kt @@ -173,6 +173,18 @@ val MembyOutline: Color get() = activePalette.surfaceRaised.lighten(0.24f) /** Copy on a control that cannot be pressed. Quiet text taken down, never grey-on-grey. */ val MembyDisabledText: Color get() = activePalette.quietText.darken(0.42f) +/** + * The one red. An action that cannot be taken back — removing a user from this television — + * wears it, and it is deliberately **not** derived from the palette: a theme that could + * recolour "this deletes something" could make a scheme in which the destructive answer is + * the same green as the safe one. It is the stance the status colours already take. + * + * It is also worn only under focus. A remove control sitting in red on a screen somebody + * merely walked past is one that reads as a warning about the profile rather than as a + * control waiting to be pressed. + */ +val MembyDestructive = Color(0xFFE34B4B) + /** * The wash a full-screen splash is drawn on: the surface with a breath of the accent in it, * falling away to the surface itself. It is what makes the cold-start screen belong to the diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/viewers/ViewerEditing.kt b/app/src/main/java/com/ponzischeme89/memby/ui/viewers/ViewerEditing.kt index 96c5f5e..4eba642 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/viewers/ViewerEditing.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/viewers/ViewerEditing.kt @@ -108,7 +108,7 @@ internal fun viewerIsEditable(viewer: MembyViewer): Boolean = !viewer.isMain * * The row that took their place, falling back to the last row when the one removed was at * the end, and to nothing at all when the list has emptied — the rule - * [profileFocusIndexAfterRemoval][com.ponzischeme89.memby.ui.profileFocusIndexAfterRemoval] + * [profileFocusIndexAfterRemoval][com.ponzischeme89.memby.ui.profiles.profileFocusIndexAfterRemoval] * already follows, for the same reason: a viewer who has just deleted three people in a * row must not be sent back to the top of the list between each one. */ diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/viewers/ViewerSelection.kt b/app/src/main/java/com/ponzischeme89/memby/ui/viewers/ViewerSelection.kt index 8b761f2..222325d 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/viewers/ViewerSelection.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/viewers/ViewerSelection.kt @@ -53,15 +53,30 @@ internal fun activeViewerLabel(activeViewerId: String, activeViewerName: String) /** * Whether this television should offer the picker at all. * - * Two conditions, and both matter. There is no gateway to ask on the direct path, so there - * is exactly one viewer and it is the account. And an account with a single viewer is one - * nobody has added anybody to — offering "Who's watching?" there is a question with one - * answer, which reads as a fault rather than as a feature waiting to be used. The entry - * point that *adds* the first viewer therefore lives with the other account management, - * not behind this. + * Three conditions. There is nobody to ask on the direct path; the operator must be running + * viewers; and the account must have somebody to show, which after a failed list request it + * has not. + * + * It deliberately does **not** wait for a *second* viewer to exist. It used to, on the + * reasoning that "Who's watching?" with one answer reads as a fault, and the entry point + * that adds the first person was said to live "with the other account management" — which + * is Manage users, and Manage users adds an *Emby account*. So an account that had never + * added anybody had no reachable way to add anybody: the only control that opens the name + * screen is on the picker and on the manage list behind it, and both sat behind this rule. + * A household of one meets a picker holding their own face beside "Add viewer", which is + * the feature waiting to be used rather than a question with one answer. + * + * The count is what the switched-off case *cannot* be read from, which is why + * [enabled] is a separate argument rather than an inference: a gateway with the feature off + * still answers with the account's own viewer, so one viewer means both "nobody added yet" + * and "not running viewers", and only the first of those may be offered an Add the gateway + * would refuse. See `MaintenanceMonitor.viewersEnabled`. */ -internal fun shouldOfferViewerPicker(gatewayMode: Boolean, viewerCount: Int): Boolean = - gatewayMode && viewerCount > 1 +internal fun shouldOfferViewerPicker( + gatewayMode: Boolean, + enabled: Boolean, + viewerCount: Int, +): Boolean = gatewayMode && enabled && viewerCount > 0 /** * What the user menu's row is called. diff --git a/app/src/test/java/com/ponzischeme89/memby/data/TrailerSupportTest.kt b/app/src/test/java/com/ponzischeme89/memby/data/TrailerSupportTest.kt index b79c374..97008a0 100644 --- a/app/src/test/java/com/ponzischeme89/memby/data/TrailerSupportTest.kt +++ b/app/src/test/java/com/ponzischeme89/memby/data/TrailerSupportTest.kt @@ -25,9 +25,23 @@ class TrailerSupportTest { @Test fun nextEpisodePreviewNeedsANaturalTwoMinuteWindow() { - assertTrue(shouldStartNextEpisodePreview(true, false, 120_000, 120_000, true)) - assertFalse(shouldStartNextEpisodePreview(false, false, 60_000, 120_000, true)) - assertFalse(shouldStartNextEpisodePreview(true, true, 60_000, 120_000, true)) - assertFalse(shouldStartNextEpisodePreview(true, false, 120_001, 120_000, true)) + assertTrue(shouldStartNextEpisodePreview(true, true, false, 120_000, 120_000, true)) + assertFalse(shouldStartNextEpisodePreview(true, false, false, 60_000, 120_000, true)) + assertFalse(shouldStartNextEpisodePreview(true, true, true, 60_000, 120_000, true)) + assertFalse(shouldStartNextEpisodePreview(true, true, false, 120_001, 120_000, true)) + assertFalse(shouldStartNextEpisodePreview(true, true, false, 60_000, 120_000, false)) + } + + /** + * The one this was missing, and the one a viewer reported: a preview is not an offer + * beside the ending, it *replaces* the ending and then rolls into the next episode. So + * with automatic advance off it must not start at all, however well the window and the + * lookup have gone — otherwise turning the setting off still leaves an episode cutting + * away two minutes early and the next one playing by itself. + */ + @Test + fun noPreviewWhenAutomaticAdvanceIsOff() { + assertFalse(shouldStartNextEpisodePreview(false, true, false, 120_000, 120_000, true)) + assertFalse(shouldStartNextEpisodePreview(false, true, false, 60_000, 120_000, true)) } } diff --git a/app/src/test/java/com/ponzischeme89/memby/ui/HomeUiStateTest.kt b/app/src/test/java/com/ponzischeme89/memby/ui/HomeUiStateTest.kt index 402e820..684db37 100644 --- a/app/src/test/java/com/ponzischeme89/memby/ui/HomeUiStateTest.kt +++ b/app/src/test/java/com/ponzischeme89/memby/ui/HomeUiStateTest.kt @@ -3,6 +3,7 @@ package com.ponzischeme89.memby.ui import com.ponzischeme89.memby.data.HomeCache import com.ponzischeme89.memby.data.model.BaseItem import com.ponzischeme89.memby.data.model.HomeRow +import com.ponzischeme89.memby.data.model.UserItemData import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue @@ -98,6 +99,37 @@ class HomeUiStateTest { ) } + @Test + fun localPlaybackPositionWinsOverAnOlderRefresh() { + val refreshed = listOf( + BaseItem( + id = "episode", + userData = UserItemData(playbackPositionTicks = 10_000L), + ), + ) + + val reconciled = refreshed.withLatestPlaybackPositions( + mapOf("episode" to PlaybackPosition("episode", positionMs = 42_000L)), + ) + + assertEquals(420_000_000L, reconciled.single().userData?.playbackPositionTicks) + } + + @Test + fun locallyCompletedItemIsNotRestoredByAStaleRefresh() { + val reconciled = listOf(BaseItem(id = "finished")).withLatestPlaybackPositions( + mapOf( + "finished" to PlaybackPosition( + itemId = "finished", + positionMs = 60_000L, + durationMs = 60_000L, + ), + ), + ) + + assertTrue(reconciled.isEmpty()) + } + private fun row(id: String, itemId: String) = HomeRow( id = id, title = id, diff --git a/app/src/test/java/com/ponzischeme89/memby/ui/ResumableMediaCardTest.kt b/app/src/test/java/com/ponzischeme89/memby/ui/ResumableMediaCardTest.kt new file mode 100644 index 0000000..aaafceb --- /dev/null +++ b/app/src/test/java/com/ponzischeme89/memby/ui/ResumableMediaCardTest.kt @@ -0,0 +1,66 @@ +package com.ponzischeme89.memby.ui + +import com.ponzischeme89.memby.data.model.BaseItem +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class ResumableMediaCardTest { + @Test + fun episodeModelUsesSeriesAsTitleAndEpisodeAsContext() { + val model = BaseItem( + id = "episode", + name = "Pilot", + type = "Episode", + seriesName = "The Show", + parentIndexNumber = 1, + indexNumber = 1, + ).toResumableMediaCardModel(backdropUrl = null, primaryUrl = null) + + assertEquals("The Show", model.title) + assertEquals("S01E01 - Pilot", model.episodeLabel) + } + + @Test + fun episodeIdentityIncludesName() { + assertEquals("S01E01 - Pilot", episodeLabel(1, 1, "Pilot")) + } + + @Test + fun episodeIdentityDoesNotLeaveMalformedSeparators() { + assertEquals("S02E09", episodeLabel(2, 9, null)) + assertEquals("The Return", episodeLabel(null, null, "The Return")) + assertNull(episodeLabel(null, null, " ")) + } + + @Test + fun finishTimeUsesRemainingRuntime() { + val now = 1_000_000L + val runtime = 60L * 60L * TICKS_PER_MILLISECOND_FOR_TEST + val position = 15L * 60L * TICKS_PER_MILLISECOND_FOR_TEST + + assertEquals( + now + 45L * 60L * 1_000L, + expectedFinishEpochMillis(now, position, runtime), + ) + } + + @Test + fun finishTimeHandlesMissingOrFinishedMedia() { + assertNull(expectedFinishEpochMillis(1_000L, 0L, null)) + assertNull(expectedFinishEpochMillis(1_000L, 100L, 0L)) + assertNull(expectedFinishEpochMillis(1_000L, 100L, 100L)) + } + + @Test + fun progressIsClampedAndMissingProgressStartsAtZero() { + assertEquals(0f, resumableProgress(null, 1_000L)) + assertEquals(0.5f, resumableProgress(500L, 1_000L)) + assertEquals(1f, resumableProgress(2_000L, 1_000L)) + assertEquals(0f, resumableProgress(500L, null)) + } + + private companion object { + const val TICKS_PER_MILLISECOND_FOR_TEST = 10_000L + } +} diff --git a/app/src/test/java/com/ponzischeme89/memby/ui/UserSwitcherNavigationTest.kt b/app/src/test/java/com/ponzischeme89/memby/ui/UserSwitcherNavigationTest.kt index 7886b3e..cf550ea 100644 --- a/app/src/test/java/com/ponzischeme89/memby/ui/UserSwitcherNavigationTest.kt +++ b/app/src/test/java/com/ponzischeme89/memby/ui/UserSwitcherNavigationTest.kt @@ -1,5 +1,6 @@ package com.ponzischeme89.memby.ui +import com.ponzischeme89.memby.ui.profiles.profileFocusIndexAfterRemoval import org.junit.Assert.assertEquals import org.junit.Assert.assertNull import org.junit.Test diff --git a/app/src/test/java/com/ponzischeme89/memby/ui/UserSwitcherRowsTest.kt b/app/src/test/java/com/ponzischeme89/memby/ui/UserSwitcherRowsTest.kt new file mode 100644 index 0000000..ff6bc53 --- /dev/null +++ b/app/src/test/java/com/ponzischeme89/memby/ui/UserSwitcherRowsTest.kt @@ -0,0 +1,120 @@ +package com.ponzischeme89.memby.ui + +import com.ponzischeme89.memby.data.EmbyProfile +import com.ponzischeme89.memby.data.model.MembyViewer +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * What the user switcher's list is a list of, and where the remote opens in it. + * + * The panel had the two the wrong way round: the Emby account, which a household chooses + * once, was the one-press row, and the person — which changes nightly — was a row leading to + * a separate full screen. These are the rules that swap them, and the one thing they must + * never do is put both kinds of row in one list, where selecting the wrong one signs + * somebody out of Emby. + */ +class UserSwitcherRowsTest { + + private fun account(id: String, name: String = id) = + EmbyProfile(id, "https://memby.example", "token", "user-$id", name) + + private fun viewer(id: String, name: String, main: Boolean = false) = + MembyViewer( + id = id, + name = name, + kind = if (main) MembyViewer.KIND_MAIN else MembyViewer.KIND_SHADOW, + ) + + private val accounts = listOf(account("a", "Matt"), account("b", "Guest")) + private val people = listOf(viewer("m", "Matt", main = true), viewer("v1", "Alessandra")) + + @Test + fun `with viewers off the list is the accounts it always was`() { + val rows = userSwitcherRows( + profiles = accounts, + viewers = people, + activeProfileId = "b", + activeViewerId = "v1", + showViewers = false, + canAddViewer = true, + ) + assertEquals(2, rows.size) + assertTrue(rows.all { it is UserSwitcherRow.Account }) + // Focus opens on the account in force rather than at the top of the list. + assertEquals(1, userSwitcherRowFocusIndex(rows)) + } + + @Test + fun `with viewers on the list is the people, and adding one is part of it`() { + val rows = userSwitcherRows( + profiles = accounts, + viewers = people, + activeProfileId = "a", + activeViewerId = "v1", + showViewers = true, + canAddViewer = true, + ) + assertEquals(3, rows.size) + assertTrue(rows[0] is UserSwitcherRow.Viewer) + assertTrue(rows[1] is UserSwitcherRow.Viewer) + assertEquals(UserSwitcherRow.AddViewer, rows[2]) + assertEquals(1, userSwitcherRowFocusIndex(rows)) + } + + /** + * A blank active id means the account's own viewer, which is the state every install + * starts in — so the main viewer is the one focus opens on, not the first shadow. + */ + @Test + fun `nobody chosen means the account's own viewer`() { + val rows = userSwitcherRows( + profiles = accounts, + viewers = people, + activeProfileId = "a", + activeViewerId = "", + showViewers = true, + canAddViewer = false, + ) + assertEquals(2, rows.size) + assertEquals(0, userSwitcherRowFocusIndex(rows)) + } + + /** At the limit the Add row is removed rather than dimmed, so the D-pad never stops on it. */ + @Test + fun `a household at its limit is offered no Add row`() { + val rows = userSwitcherRows( + profiles = accounts, + viewers = people, + activeProfileId = "a", + activeViewerId = "v1", + showViewers = true, + canAddViewer = false, + ) + assertTrue(rows.none { it == UserSwitcherRow.AddViewer }) + } + + /** + * A keyed LazyColumn throws on a repeat and takes the panel with it, and the two ids + * come from two systems that have never agreed on a namespace: an Emby profile id and a + * gateway viewer id are free to be the same string. + */ + @Test + fun `an account and a viewer sharing an id are still distinct keys`() { + val a = userSwitcherRowKey(UserSwitcherRow.Account(account("same"), active = false)) + val v = userSwitcherRowKey(UserSwitcherRow.Viewer(viewer("same", "Matt"), active = false)) + assertTrue(a != v) + } + + /** + * The row names the account it would leave, because with people in the list above it the + * one thing it must establish is which of the two questions it answers. + */ + @Test + fun `the account row names the account in force`() { + assertEquals("Switch account · Matt", switchAccountLabel(accounts, "a")) + assertEquals("Switch account", switchAccountLabel(accounts, null)) + assertEquals("Switch account", switchAccountLabel(emptyList(), "a")) + } +} diff --git a/app/src/test/java/com/ponzischeme89/memby/ui/profiles/ProfileChooserScreenshotTest.kt b/app/src/test/java/com/ponzischeme89/memby/ui/profiles/ProfileChooserScreenshotTest.kt new file mode 100644 index 0000000..1113ba4 --- /dev/null +++ b/app/src/test/java/com/ponzischeme89/memby/ui/profiles/ProfileChooserScreenshotTest.kt @@ -0,0 +1,143 @@ +package com.ponzischeme89.memby.ui.profiles + +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onRoot +import com.github.takahirom.roborazzi.captureRoboImage +import com.ponzischeme89.memby.data.EmbyProfile +import com.ponzischeme89.memby.ui.theme.MembyTheme +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +/** + * Renders "Who's watching?" to PNGs under `build/screenshots/profiles/`. + * + * ```powershell + * .\gradlew.bat :app:testDebugUnitTest --tests "*ProfileChooserScreenshotTest" + * ``` + * + * A unit test can check that the row is ordered with the signed-in account first; it cannot + * check the one thing this screen is actually judged on, which is whether the whole + * composition sits on one centre line and whether the card under focus is obvious from + * across the room. Both are exactly the sort of claim that is true when it is written and + * quietly stops being true when a caption or a control is added underneath. + * + * The pair worth looking at together is [a household of two] and [a household of five]: the + * row is centred by its arrangement rather than padded from the left, so the two captures + * must put their middle card — and their heading, hint and Back action — down the same line. + */ +@RunWith(RobolectricTestRunner::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +@Config(sdk = [34], qualifiers = "w960dp-h540dp-television-xhdpi") +class ProfileChooserScreenshotTest { + + @get:Rule + val compose = createComposeRule() + + private fun profile(id: String, name: String) = + EmbyProfile(id, "https://memby.example", "token", "user-$id", name) + + private val household = listOf( + profile("a", "Matt"), + profile("b", "Alessandra"), + profile("c", "Guest"), + ) + + /** The sign-in chooser as most televisions see it, with the remote on the first card. */ + @Test + fun `a household of three`() { + capture("profiles-household", household, focusedCard = 0) + } + + /** + * Two accounts. The row has far more television than it needs, which is the case a + * left-aligned row got visibly wrong. + */ + @Test + fun `a household of two`() { + capture("profiles-two", household.take(2), focusedCard = 0) + } + + /** Five, so the same capture can be read against the two above it. */ + @Test + fun `a household of five`() { + capture( + "profiles-five", + household + listOf(profile("d", "Kids"), profile("e", "Nan")), + focusedCard = 2, + ) + } + + /** + * One account, which is the state a set that has only ever been signed into once is in. + * Left and Right have one destination between them and nothing on the screen may look + * broken for it. + */ + @Test + fun `a single account`() { + capture("profiles-single", household.take(1), focusedCard = 0) + } + + /** + * The remove control revealed on the focused card. It is the whole of the change to this + * screen: a small mark inside the card the remote is on, rather than a labelled button + * under every card at once. + */ + @Test + fun `the focused card offers its remove control`() { + capture("profiles-remove-revealed", household, focusedCard = 1) + } + + /** Reached from the user menu: a different heading, a different job, and a way back. */ + @Test + fun `the manage page`() { + capture( + "profiles-managing", + household, + focusedCard = 0, + currentProfileId = "b", + managing = true, + ) + } + + /** The Add card under focus, one step right of the last profile. */ + @Test + fun `adding an account`() { + capture("profiles-add", household, focusedCard = household.size) + } + + /** Mid-switch: nothing may be pressed, and nothing offers to be. */ + @Test + fun `switching profile`() { + capture("profiles-switching", household, focusedCard = 0, switchingProfileId = "b") + } + + private fun capture( + name: String, + profiles: List, + focusedCard: Int?, + currentProfileId: String? = null, + managing: Boolean = false, + switchingProfileId: String? = null, + ) { + compose.setContent { + MembyTheme { + ProfileChooser( + profiles = profiles, + currentProfileId = currentProfileId, + switchingProfileId = switchingProfileId, + removingProfileId = null, + onSelect = {}, + onRemove = {}, + onAddProfile = {}, + onClose = if (managing) ({}) else null, + focusedCardForCapture = focusedCard, + ) + } + } + compose.onRoot().captureRoboImage("build/screenshots/profiles/$name.png") + } +} diff --git a/app/src/test/java/com/ponzischeme89/memby/ui/profiles/ProfileChooserTest.kt b/app/src/test/java/com/ponzischeme89/memby/ui/profiles/ProfileChooserTest.kt new file mode 100644 index 0000000..1558a47 --- /dev/null +++ b/app/src/test/java/com/ponzischeme89/memby/ui/profiles/ProfileChooserTest.kt @@ -0,0 +1,78 @@ +package com.ponzischeme89.memby.ui.profiles + +import com.ponzischeme89.memby.data.EmbyProfile +import org.junit.Assert.assertEquals +import org.junit.Test + +/** + * The rules "Who's watching?" cannot draw itself without, kept pure so they can be pinned + * without a television. + * + * Where focus opens is the whole of what makes a screen driven by a D-pad usable, and + * opening on the wrong card costs a walk along the row. The rule for where focus lands + * *after* a removal is `profileFocusIndexAfterRemoval`, pinned next to the switcher's own + * navigation in `UserSwitcherNavigationTest`. + */ +class ProfileChooserTest { + + private fun profile(id: String, name: String = id) = + EmbyProfile(id, "https://memby.example", "token", "user-$id", name) + + /** + * The screen focuses index 0 and nothing else, so this is the rule that decides the set + * opens on whoever is already signed in — one confirm press rather than a walk. + */ + @Test + fun `the signed-in account leads the row`() { + val ordered = profileChooserOrder( + listOf(profile("a"), profile("b"), profile("c")), + currentProfileId = "c", + ) + assertEquals(listOf("c", "a", "b"), ordered.map(EmbyProfile::id)) + } + + /** At sign-in nobody is current, and the stored order is the only order there is. */ + @Test + fun `with nobody signed in the stored order stands`() { + val ordered = profileChooserOrder( + listOf(profile("a"), profile("b")), + currentProfileId = null, + ) + assertEquals(listOf("a", "b"), ordered.map(EmbyProfile::id)) + } + + /** + * A keyed `LazyRow` throws on a repeated key and takes the screen with it, and this list + * is written by a television that has been reinstalled onto. Deduplicate, never + * disambiguate — the position of a card is what has to survive a removal. + */ + @Test + fun `a repeated id is dropped rather than disambiguated`() { + val ordered = profileChooserOrder( + listOf(profile("a", "Matt"), profile("a", "Matt again"), profile("b")), + currentProfileId = null, + ) + assertEquals(listOf("a", "b"), ordered.map(EmbyProfile::id)) + assertEquals("Matt", ordered.first().username) + } + + /** + * The hint is the only thing on screen that says a remove control is a press away, so it + * has to appear the moment the remote is on a card — and it has to hold its line height + * when it says nothing, or every card focus would shift the row above it. + */ + @Test + fun `the hint appears with focus and never empties its line`() { + assertEquals("Press Down for options", profileRemoveHint(cardFocused = true, busy = false)) + assertEquals(" ", profileRemoveHint(cardFocused = false, busy = false)) + } + + /** + * Nothing may be removed while a switch or a removal is already in flight, so the screen + * must not be inviting a press that does nothing. + */ + @Test + fun `a busy screen offers nothing`() { + assertEquals(" ", profileRemoveHint(cardFocused = true, busy = true)) + } +} diff --git a/app/src/test/java/com/ponzischeme89/memby/ui/requests/RequestPresentationTest.kt b/app/src/test/java/com/ponzischeme89/memby/ui/requests/RequestPresentationTest.kt index 0c0bbf2..8b5b7eb 100644 --- a/app/src/test/java/com/ponzischeme89/memby/ui/requests/RequestPresentationTest.kt +++ b/app/src/test/java/com/ponzischeme89/memby/ui/requests/RequestPresentationTest.kt @@ -153,15 +153,20 @@ class RequestPresentationTest { assertEquals(4, userSwitcherMenuItems(showRequests = false, showViewers = true).size) assertEquals(5, userSwitcherMenuItems(showRequests = true, showViewers = true).size) - // Who is watching comes first, because the rows below it belong to whichever - // viewer it selects; Manage users stays last, where a menu's escape hatch belongs. + // Switching account comes first, because the rows below it belong to whatever it + // selects; the managing row stays last, where a menu's escape hatch belongs. val full = userSwitcherMenuItems(showRequests = true, showViewers = true) - assertEquals(UserSwitcherMenuItem.VIEWERS, full.first()) - assertEquals(UserSwitcherMenuItem.MANAGE_USERS, full.last()) + assertEquals(UserSwitcherMenuItem.SWITCH_ACCOUNT, full.first()) + assertEquals(UserSwitcherMenuItem.MANAGE_VIEWERS, full.last()) + + // With viewers off the list at the top *is* the accounts, so nothing leads to them + // and the last row edits them instead. + val plain = userSwitcherMenuItems(showRequests = true) + assertFalse(UserSwitcherMenuItem.SWITCH_ACCOUNT in plain) + assertEquals(UserSwitcherMenuItem.MANAGE_USERS, plain.last()) // An optional row is absent rather than present-and-dead, so nothing below it // shifts under a D-pad that has already started travelling. - assertFalse(UserSwitcherMenuItem.VIEWERS in userSwitcherMenuItems(showRequests = true)) assertFalse(UserSwitcherMenuItem.REQUESTS in userSwitcherMenuItems(showRequests = false)) } diff --git a/app/src/test/java/com/ponzischeme89/memby/ui/viewers/ViewerPickerScreenshotTest.kt b/app/src/test/java/com/ponzischeme89/memby/ui/viewers/ViewerPickerScreenshotTest.kt index df67430..4f902b7 100644 --- a/app/src/test/java/com/ponzischeme89/memby/ui/viewers/ViewerPickerScreenshotTest.kt +++ b/app/src/test/java/com/ponzischeme89/memby/ui/viewers/ViewerPickerScreenshotTest.kt @@ -81,12 +81,13 @@ class ViewerPickerScreenshotTest { } /** - * The way in, so the two screens can be looked at together. The row names whoever is - * watching rather than repeating the question, which is the whole reason it is worth - * having a label that changes. + * The way in, and the capture worth keeping beside the picker above: with viewers on, + * the panel's list *is* the people, so choosing one is a press in the menu rather than a + * row leading to a screen. The account it would otherwise be listing is one row below, + * named, so the two questions are never confusable at a glance. */ @Test - fun `the user menu names the viewer`() { + fun `the user menu lists the people`() { compose.setContent { MembyTheme { UserSwitcherOverlay( @@ -98,6 +99,8 @@ class ViewerPickerScreenshotTest { onManageProfiles = {}, onDismiss = {}, showViewers = true, + viewers = household, + canAddViewer = true, activeViewerId = "v1", activeViewerName = "Alessandra", ) @@ -106,6 +109,32 @@ class ViewerPickerScreenshotTest { compose.onRoot().captureRoboImage("build/screenshots/viewers/viewers-menu-row.png") } + /** + * The same panel on a household running no viewers, which must be exactly the menu it + * has always been: the accounts in the list, no account row above, and "Manage users" + * at the bottom. This is the capture that says the change costs those televisions + * nothing. + */ + @Test + fun `the user menu without viewers is unchanged`() { + compose.setContent { + MembyTheme { + UserSwitcherOverlay( + profiles = listOf( + EmbyProfile("a", "https://memby.example", "t", "u1", "Matt"), + EmbyProfile("b", "https://memby.example", "t", "u2", "Guest"), + ), + activeProfileId = "a", + onProfileSelected = {}, + onManageProfiles = {}, + onDismiss = {}, + showViewers = false, + ) + } + } + compose.onRoot().captureRoboImage("build/screenshots/viewers/viewers-menu-accounts.png") + } + private fun capture( name: String, viewers: List, diff --git a/app/src/test/java/com/ponzischeme89/memby/ui/viewers/ViewerSelectionTest.kt b/app/src/test/java/com/ponzischeme89/memby/ui/viewers/ViewerSelectionTest.kt index 29453c5..d598239 100644 --- a/app/src/test/java/com/ponzischeme89/memby/ui/viewers/ViewerSelectionTest.kt +++ b/app/src/test/java/com/ponzischeme89/memby/ui/viewers/ViewerSelectionTest.kt @@ -51,16 +51,29 @@ class ViewerSelectionTest { } /** - * Two conditions, and both matter. There is nobody to ask on the direct path, and an - * account nobody has added a viewer to would be offered a question with one answer — - * which reads as a fault rather than as a feature waiting to be used. + * There is nobody to ask on the direct path, and a gateway that does not do viewers + * answers with nothing. A single viewer *is* offered the row: the only control that adds + * the first person sits on the picker, so withholding it there left an account with no + * reachable way to add anybody — Manage users beside it adds an Emby account, not a + * viewer. */ @Test - fun `the picker is offered only where there is a choice to make`() { - assertFalse(shouldOfferViewerPicker(gatewayMode = false, viewerCount = 4)) - assertFalse(shouldOfferViewerPicker(gatewayMode = true, viewerCount = 1)) - assertFalse(shouldOfferViewerPicker(gatewayMode = true, viewerCount = 0)) - assertTrue(shouldOfferViewerPicker(gatewayMode = true, viewerCount = 2)) + fun `the picker is offered wherever there is somebody to ask about`() { + assertFalse(shouldOfferViewerPicker(gatewayMode = false, enabled = true, viewerCount = 4)) + assertFalse(shouldOfferViewerPicker(gatewayMode = true, enabled = true, viewerCount = 0)) + assertTrue(shouldOfferViewerPicker(gatewayMode = true, enabled = true, viewerCount = 1)) + assertTrue(shouldOfferViewerPicker(gatewayMode = true, enabled = true, viewerCount = 2)) + } + + /** + * The switched-off household. Its gateway answers with the account's own viewer like + * any other, so the count says nothing — and the Add behind the picker is refused with + * 403, which is the one control this rule exists to keep off the screen. + */ + @Test + fun `a household not running viewers is offered nothing`() { + assertFalse(shouldOfferViewerPicker(gatewayMode = true, enabled = false, viewerCount = 1)) + assertFalse(shouldOfferViewerPicker(gatewayMode = true, enabled = false, viewerCount = 4)) } /** diff --git a/server/internal/api/features.go b/server/internal/api/features.go index 206c606..a06f8e2 100644 --- a/server/internal/api/features.go +++ b/server/internal/api/features.go @@ -26,6 +26,7 @@ const ( featureSeasonalDecorations = "seasonal_decorations" featureGenreBrowser = "genre_browser" featureTVCalendar = "tv_calendar" + featureContinueWatching = "continue_watching" featureWatchTimeDigest = "watch_time_digest" featureViewers = "viewers" ) @@ -135,6 +136,13 @@ var featureCatalogue = []featureDefinition{ DefaultEnabled: true, MinimumProtocol: 1, Capability: "tv_calendar_v1", Recovery: "Takes effect on the next status poll; the rail entry simply disappears.", }, + { + Key: featureContinueWatching, Name: "Continue Watching", Area: "Home", + Description: "Show resumable films and episodes on Home. Turning it off hides the " + + "row and stops the gateway loading its resume and Next Up feeds.", + DefaultEnabled: true, MinimumProtocol: 1, + Recovery: "Takes effect on the next status poll, within ten seconds on an open TV.", + }, { // No capability, because nothing on the television has to understand this: the // summary is an ordinary entry in My Alerts, which every build that has that page diff --git a/server/internal/api/features_test.go b/server/internal/api/features_test.go index f3494ac..1d60d80 100644 --- a/server/internal/api/features_test.go +++ b/server/internal/api/features_test.go @@ -61,6 +61,20 @@ func TestGenreBrowserIsServerControlledAndOffByDefault(t *testing.T) { } } +func TestContinueWatchingIsServerControlledAndOnByDefault(t *testing.T) { + definition, ok := knownFeature(featureContinueWatching) + if !ok || definition.Area != "Home" { + t.Fatalf("Continue Watching feature definition = %+v, found=%v", definition, ok) + } + if got := evaluateFeature(store.DefaultFeaturePolicy(), definition, ProtocolVersion); !got.Enabled { + t.Fatalf("Continue Watching default = %+v, want enabled", got) + } + disabled := store.FeaturePolicy{Overrides: map[string]bool{featureContinueWatching: false}} + if got := evaluateFeature(disabled, definition, ProtocolVersion); got.Enabled || got.Source != "override" { + t.Fatalf("Continue Watching override = %+v", got) + } +} + func TestClientCapabilitiesAreNormalizedAndBounded(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "/v1/status", nil) req.Header.Set("X-Memby-Capabilities", " Sonarr_Preroll_V1,server_features_v1,sonarr_preroll_v1,"+ diff --git a/server/internal/api/home.go b/server/internal/api/home.go index ed0fa74..f06a84f 100644 --- a/server/internal/api/home.go +++ b/server/internal/api/home.go @@ -76,6 +76,13 @@ func (s *Server) handleHome(w http.ResponseWriter, r *http.Request, sess store.S radarrSchedule := s.radarrEnabled(r.Context()) && supportsRadarrSchedule(r) hero := supportsHomeHero(r) now := time.Now() + featurePolicy := s.currentFeaturePolicy(ctx) + continueDefinition, _ := knownFeature(featureContinueWatching) + continueWatching := evaluateFeature( + featurePolicy, + continueDefinition, + ProtocolVersion, + ).Enabled // The hero revision is part of the key rather than something to invalidate. An // operator's change therefore makes the entries built under the old policy simply // unreachable, and they age out on their own TTL — where the sweep it replaced dropped @@ -86,6 +93,8 @@ func (s *Server) handleHome(w http.ResponseWriter, r *http.Request, sess store.S viewerKeyOf(ctx, sess), "home:v4:"+itoa(limit)+":s"+strconv.FormatBool(sonarrSchedule)+ ":r"+strconv.FormatBool(radarrSchedule)+":h"+strconv.FormatBool(hero)+ + ":c"+strconv.FormatBool(continueWatching)+ + ":f"+strconv.FormatInt(featurePolicy.Revision, 10)+ ":hr"+heroRev+":d"+sess.DeviceID, ) @@ -104,6 +113,7 @@ func (s *Server) handleHome(w http.ResponseWriter, r *http.Request, sess store.S var ( mu sync.Mutex failures int + rowAttempts int out homeResponse sonarrRow *recommend.Row radarrRow *recommend.Row @@ -120,6 +130,7 @@ func (s *Server) handleHome(w http.ResponseWriter, r *http.Request, sess store.S // on rather than only that it was waiting on Emby. They are concurrent, so a single // summed `emby=` would be the one number that could not answer that. run := func(name string, dest *[]json.RawMessage, fetch func(context.Context) (*emby.ItemsResult, error)) { + rowAttempts++ wg.Add(1) go func() { defer wg.Done() @@ -144,16 +155,18 @@ func (s *Server) handleHome(w http.ResponseWriter, r *http.Request, sess store.S viewer := viewerOf(ctx, sess) shadow := !viewer.IsMain() && s.store != nil - run("resume", &out.ContinueWatching, func(ctx context.Context) (*emby.ItemsResult, error) { - if shadow { - return s.viewerContinueRow(ctx, cred, viewer.ID, limit) - } - return s.emby.ResumeItems(ctx, cred, rowParams(url.Values{ - "Recursive": {"true"}, - "MediaTypes": {"Video"}, - "Limit": {itoa(limit)}, - }, fieldsContinue)) - }) + if continueWatching { + run("resume", &out.ContinueWatching, func(ctx context.Context) (*emby.ItemsResult, error) { + if shadow { + return s.viewerContinueRow(ctx, cred, viewer.ID, limit) + } + return s.emby.ResumeItems(ctx, cred, rowParams(url.Values{ + "Recursive": {"true"}, + "MediaTypes": {"Video"}, + "Limit": {itoa(limit)}, + }, fieldsContinue)) + }) + } run("favourites", &out.Favorites, func(ctx context.Context) (*emby.ItemsResult, error) { if shadow { return s.viewerFavouritesRow(ctx, cred, viewer.ID, limit) @@ -167,38 +180,42 @@ func (s *Server) handleHome(w http.ResponseWriter, r *http.Request, sess store.S "Limit": {itoa(limit)}, }, fieldsRow)) }) - run("nextup", &out.NextUp, func(ctx context.Context) (*emby.ItemsResult, error) { - if shadow { - return s.viewerNextUpRow(ctx, cred, viewer.ID, limit) - } - return s.emby.NextUp(ctx, cred, rowParams(url.Values{ - "Limit": {itoa(limit)}, - }, fieldsContinue)) - }) + if continueWatching { + run("nextup", &out.NextUp, func(ctx context.Context) (*emby.ItemsResult, error) { + if shadow { + return s.viewerNextUpRow(ctx, cred, viewer.ID, limit) + } + return s.emby.NextUp(ctx, cred, rowParams(url.Values{ + "Limit": {itoa(limit)}, + }, fieldsContinue)) + }) + } // What orders the Next Up half of Continue Watching. A failure here is not a failed // row: the merge falls back to putting the resume items first, which is the order // the launcher had before the two rows became one. - wg.Add(1) - go func() { - defer wg.Done() - // What orders the two halves of the merge. For a shadow viewer it is one grouped - // query over their own state rather than a lookback over the account's plays — - // the same question, asked of the system that holds the answer. - var played map[string]time.Time - var err error - if shadow { - played, err = s.store.ViewerWatchedSeries(ctx, viewer.ID, continuePlayLookback) - } else { - played, err = s.recentlyPlayedSeries(timing.WithLabel(ctx, "emby.recent"), cred) - } - if err != nil { - s.loggerFor(ctx).Warn("recently played lookup failed", "error", err) - return - } - mu.Lock() - seriesPlayed = played - mu.Unlock() - }() + if continueWatching { + wg.Add(1) + go func() { + defer wg.Done() + // What orders the two halves of the merge. For a shadow viewer it is one grouped + // query over their own state rather than a lookback over the account's plays — + // the same question, asked of the system that holds the answer. + var played map[string]time.Time + var err error + if shadow { + played, err = s.store.ViewerWatchedSeries(ctx, viewer.ID, continuePlayLookback) + } else { + played, err = s.recentlyPlayedSeries(timing.WithLabel(ctx, "emby.recent"), cred) + } + if err != nil { + s.loggerFor(ctx).Warn("recently played lookup failed", "error", err) + return + } + mu.Lock() + seriesPlayed = played + mu.Unlock() + }() + } run("latest", &out.LatestMovies, func(ctx context.Context) (*emby.ItemsResult, error) { newReleaseDays := s.weightedConfig().NewReleaseDays if newReleaseDays < 1 { @@ -298,7 +315,7 @@ func (s *Server) handleHome(w http.ResponseWriter, r *http.Request, sess store.S wg.Wait() upstream() - if failures == 4 { + if rowAttempts > 0 && failures == rowAttempts { writeError(w, http.StatusBadGateway, "could not reach the emby server") return } @@ -307,11 +324,15 @@ func (s *Server) handleHome(w http.ResponseWriter, r *http.Request, sess store.S // One row, not two: an episode finished a minute ago should be followed by the next // one at the front of Continue Watching rather than moving the show to a different // row. NextUp stays on the wire for televisions that predate the merge. - out.ContinueWatching = mergeContinueWatching(out.ContinueWatching, out.NextUp, seriesPlayed) + if continueWatching { + out.ContinueWatching = mergeContinueWatching(out.ContinueWatching, out.NextUp, seriesPlayed) + } // A current show with an episode today is more immediately useful than a long-lived // resume from an ended rewatch. Keep Emby's order inside both groups; this is a // promotion, not a replacement ranking for Continue Watching. - out.ContinueWatching = prioritizeAiringTodayContinue(out.ContinueWatching, sonarrRow) + if continueWatching { + out.ContinueWatching = prioritizeAiringTodayContinue(out.ContinueWatching, sonarrRow) + } // Recommendations are read from their own long-lived cache. A miss means this // response ships without them and a rebuild starts in the background — the home @@ -321,7 +342,7 @@ func (s *Server) handleHome(w http.ResponseWriter, r *http.Request, sess store.S s.refreshRecommendationsInBackground(sess) } assemble := timing.Start(ctx, timing.StageRows) - rows := baseRows(out) + rows := visibleBaseRows(out, continueWatching) nearContinue := make([]recommend.Row, 0, len(forYouRows)+2) if len(forYouRows) > 0 { nearContinue = append(nearContinue, forYouRows...) @@ -337,9 +358,13 @@ func (s *Server) handleHome(w http.ResponseWriter, r *http.Request, sess store.S nearContinue = append(nearContinue, *radarrRow) } if len(nearContinue) > 0 { - // Personalised discovery and the upcoming schedule are most useful immediately - // after Continue Watching, before the broader library collections. - rows = append(rows[:1], append(nearContinue, rows[1:]...)...) + // Personalised discovery and the upcoming schedule are most useful at the start of + // Home: after Continue Watching when it is present, before broader collections. + insertAt := 0 + if continueWatching { + insertAt = 1 + } + rows = append(rows[:insertAt], append(nearContinue, rows[insertAt:]...)...) } out.Rows = append(rows, recommendations...) out.Rows = s.filterRecommendationPermissions(ctx, sess, out.Rows) @@ -733,6 +758,14 @@ func baseRows(h homeResponse) []recommend.Row { } } +func visibleBaseRows(h homeResponse, continueWatching bool) []recommend.Row { + rows := baseRows(h) + if continueWatching { + return rows + } + return rows[1:] +} + type homeForYouWindow struct { ID string Title string diff --git a/server/internal/api/home_rows_test.go b/server/internal/api/home_rows_test.go index 9692887..cf6237d 100644 --- a/server/internal/api/home_rows_test.go +++ b/server/internal/api/home_rows_test.go @@ -33,6 +33,18 @@ func TestPersonalizeHomeRowsGraduallyDemotesIgnoredRows(t *testing.T) { } } +func TestDisabledContinueWatchingIsNotRendered(t *testing.T) { + rows := visibleBaseRows(homeResponse{}, false) + for _, row := range rows { + if row.Kind == "continue" { + t.Fatal("disabled Continue Watching row was rendered") + } + } + if len(rows) != 2 { + t.Fatalf("base row count = %d, want favourites and latest only", len(rows)) + } +} + func TestContinueWatchingPromotesShowsAiringToday(t *testing.T) { raw := func(value string) json.RawMessage { return json.RawMessage(value) } items := []json.RawMessage{