This commit is contained in:
ponzischeme89
2026-08-21 09:54:44 +12:00
parent f1164db2c5
commit 5467fba0eb
39 changed files with 7589 additions and 5537 deletions
@@ -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<String, PlaybackPosition> {
val now = System.currentTimeMillis()
return synchronized(localResume) {
val positions = linkedMapOf<String, PlaybackPosition>()
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
@@ -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<Boolean> = _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<Boolean> = _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<Boolean> = _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
@@ -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
@@ -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<GatewayUpdate?>(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<String?>(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<Unit>(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<String?>(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<String?>(null) }
var recommendationOnboarding by remember {
mutableStateOf(
initialSettings
?.takeIf { it.hasCompletedOnboarding || !it.homeCacheJson.isNullOrBlank() }
?.let { RecommendationOnboarding(completed = true) },
)
}
var onboardingToken by remember { mutableStateOf<String?>(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.
@@ -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,
)
}
}
@@ -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<MembyViewer> = 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<kotlinx.coroutines.Job?>(null) }
var focusedIndex by remember(profileIds) {
mutableStateOf(userSwitcherInitialIndex(profileIds, activeProfileId))
}
LaunchedEffect(profileIds, activeProfileId) {
var profileFocusJob by remember(rows) { mutableStateOf<kotlinx.coroutines.Job?>(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) "Whos 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 whos watching",
if (showViewers) {
"Everyone keeps their own Continue Watching."
} else {
"Choose whos 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(
@@ -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<HomeBrowseRow>,
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<BaseItem>,
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<HomeBrowseRow> {
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<HomeRow>.foldNextUpIntoContinue(): List<HomeRow> {
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<BaseItem>.sortedByScheduleDate(): List<BaseItem> =
sortedWith(
compareBy<BaseItem> { it.membyAirsAt.isNullOrBlank() }
.thenBy { it.membyAirsAt.orEmpty() },
)
internal fun String.decodeRowIds(): List<String> =
lineSequence().map(String::trim).filter(String::isNotEmpty).distinct().toList()
internal fun applyHomeRowPreferences(
rows: List<HomeBrowseRow>,
settings: Settings,
): List<HomeBrowseRow> {
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<IndexedValue<HomeBrowseRow>>(
{ if (it.value.id in pinned) 0 else 1 },
{ order[it.value.id] ?: Int.MAX_VALUE },
{ it.index },
),
)
.map(IndexedValue<HomeBrowseRow>::value)
}
internal fun homeRowsFor(
destination: BrowseDestination,
state: HomeUiState,
settings: Settings,
): List<HomeBrowseRow> {
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<HomeBrowseRow>): List<HomeBrowseRow> {
val used = mutableSetOf<String>()
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<HomeBrowseRow>,
enabled: Boolean,
): List<HomeBrowseRow> = rows.map { row ->
row.copy(
items = visibleWithWatchedPreference(row.items, enabled),
showWatchedEpisodeCount = enabled,
)
}
internal fun visibleWithWatchedPreference(
items: List<BaseItem>,
enabled: Boolean,
): List<BaseItem> = 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<HomeBrowseRow> {
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
}
File diff suppressed because it is too large Load Diff
@@ -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<HomeUiState> = _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<HomeRow>.withoutContinueWatching(): List<HomeRow> =
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<BaseItem>.withLatestPlaybackPositions(
positions: Map<String, PlaybackPosition>,
): List<BaseItem> = 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<String, PlaybackPosition>,
): 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:")
@@ -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,
File diff suppressed because it is too large Load Diff
@@ -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,
)
}
}
@@ -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<String?>(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<String?>(null) }
var removingProfileId by remember { mutableStateOf<String?>(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<String, Int>, List<String>, List<String>, List<String>) -> Unit,
onSkip: () -> Unit = {},
initialStageIndex: Int = 0,
previewArtwork: ImageBitmap? = null,
) {
val repo = ServiceLocator.repository
val scope = rememberCoroutineScope()
val ratings = remember(onboarding.ratings) {
mutableStateMapOf<String, Int>().apply { putAll(onboarding.ratings) }
}
val selectedActors = remember { mutableStateMapOf<String, Boolean>() }
val selectedActresses = remember { mutableStateMapOf<String, Boolean>() }
val selectedDirectors = remember { mutableStateMapOf<String, Boolean>() }
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<String?>(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<BaseItem>,
ratings: MutableMap<String, Int>,
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<RecommendationPerson>,
selectedPeople: MutableMap<String, Boolean>,
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,
)
}
}
@@ -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
@@ -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<String>,
@@ -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<EmbyProfile>,
viewers: List<MembyViewer>,
activeProfileId: String?,
activeViewerId: String,
showViewers: Boolean,
canAddViewer: Boolean,
): List<UserSwitcherRow> = 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<UserSwitcherRow>): 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<UserSwitcherMenuItem> = 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<EmbyProfile>, 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"
}
@@ -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)
@@ -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
@@ -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<EmbyProfile>,
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<EmbyProfile?>(null) }
var removalReturnIndex by remember { mutableStateOf<Int?>(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<Int?>(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<Int?>(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 "Whos 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 Whos 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<EmbyProfile>,
currentProfileId: String?,
): List<EmbyProfile> = 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,
)
}
}
}
@@ -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 },
)
}
}
}
@@ -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
@@ -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.
*/
@@ -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.
@@ -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))
}
}
@@ -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,
@@ -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
}
}
@@ -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
@@ -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"))
}
}
@@ -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<EmbyProfile>,
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")
}
}
@@ -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))
}
}
@@ -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))
}
@@ -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<MembyViewer>,
@@ -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))
}
/**