0.2.84
This commit is contained in:
@@ -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) "Who’s watching?" else "Switch user",
|
||||
color = Color.White,
|
||||
fontSize = 18.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.padding(start = 8.dp, top = 7.dp, end = 8.dp, bottom = 5.dp),
|
||||
)
|
||||
Text(
|
||||
// With viewers in play this list is *accounts*, and the row below it is the
|
||||
// person — so the panel must stop claiming to be the thing that answers
|
||||
// "who is watching" when there is now a control directly beneath it that
|
||||
// does. A household running no viewers keeps the wording it always had.
|
||||
if (showViewers) "Choose an account" else "Choose who’s watching",
|
||||
if (showViewers) {
|
||||
"Everyone keeps their own Continue Watching."
|
||||
} else {
|
||||
"Choose who’s watching"
|
||||
},
|
||||
color = QuietText,
|
||||
fontSize = 12.sp,
|
||||
modifier = Modifier.padding(horizontal = 8.dp).padding(bottom = 8.dp),
|
||||
@@ -596,24 +625,49 @@ fun UserSwitcherOverlay(
|
||||
state = profileListState,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
// Settings adds one fixed action below this list. Give that row its
|
||||
// space while keeping the panel's established 400dp maximum; larger
|
||||
// households still reach every profile through the lazy list.
|
||||
.heightIn(max = 208.dp),
|
||||
// Weighted, and therefore measured from what the pinned actions left
|
||||
// over rather than from a figure typed in here. A fixed cap has to be
|
||||
// re-derived by hand every time a row is added below it, and the one
|
||||
// that was here had stopped being right the moment the list could hold
|
||||
// eight people instead of two accounts. `fill = false` so a household
|
||||
// of one still gets a panel the size of its contents.
|
||||
.weight(1f, fill = false),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||
) {
|
||||
itemsIndexed(
|
||||
items = profiles,
|
||||
key = { _, profile -> profile.id },
|
||||
) { index, profile ->
|
||||
UserSwitcherProfileItem(
|
||||
profile = profile,
|
||||
current = profile.id == activeProfileId,
|
||||
modifier = Modifier
|
||||
.focusRequester(focusRequesters[index])
|
||||
.onFocusChanged { if (it.isFocused) focusedIndex = index },
|
||||
onClick = { onProfileSelected(profile) },
|
||||
)
|
||||
itemsIndexed(items = rows, key = { _, row -> userSwitcherRowKey(row) }) { index, row ->
|
||||
val rowModifier = Modifier
|
||||
.focusRequester(focusRequesters[index])
|
||||
.onFocusChanged { if (it.isFocused) focusedIndex = index }
|
||||
when (row) {
|
||||
is UserSwitcherRow.Account -> UserSwitcherPersonItem(
|
||||
name = row.profile.username,
|
||||
initials = profileInitials(
|
||||
row.profile.username,
|
||||
row.profile.profileInitials,
|
||||
),
|
||||
current = row.active,
|
||||
currentDescription = "current user",
|
||||
modifier = rowModifier,
|
||||
onClick = { onProfileSelected(row.profile) },
|
||||
)
|
||||
is UserSwitcherRow.Viewer -> UserSwitcherPersonItem(
|
||||
name = row.viewer.name,
|
||||
initials = row.viewer.initials,
|
||||
current = row.active,
|
||||
currentDescription = "currently watching",
|
||||
modifier = rowModifier,
|
||||
onClick = { onViewerSelected(row.viewer) },
|
||||
)
|
||||
// Inside the list rather than below the pinned actions, because it
|
||||
// belongs to the question the list is asking — and it is the only
|
||||
// way an account with nobody added yet ever gains a first person.
|
||||
UserSwitcherRow.AddViewer -> UserSwitcherAction(
|
||||
label = "Add viewer",
|
||||
icon = MembyIcon.Add.mark,
|
||||
modifier = rowModifier,
|
||||
onClick = onAddViewer,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(6.dp))
|
||||
@@ -628,19 +682,21 @@ fun UserSwitcherOverlay(
|
||||
// written out here. The four hand-computed offsets this replaced were what made
|
||||
// adding a fifth conditional row unsafe.
|
||||
menuItems.forEachIndexed { offset, item ->
|
||||
val index = profiles.size + offset
|
||||
val index = rows.size + offset
|
||||
val modifier = Modifier
|
||||
.focusRequester(focusRequesters[index])
|
||||
.onFocusChanged { if (it.isFocused) focusedIndex = index }
|
||||
when (item) {
|
||||
// Who is watching sits above the rest because it changes whose menu
|
||||
// this is: the notifications and requests below belong to whichever
|
||||
// viewer it selects.
|
||||
UserSwitcherMenuItem.VIEWERS -> UserSwitcherAction(
|
||||
label = viewerMenuLabel(activeViewerId, activeViewerName),
|
||||
// The accounts, now that the list above holds people instead. It sits
|
||||
// above the rest because it changes whose menu this is: the
|
||||
// notifications and requests below belong to whatever it selects. The
|
||||
// screen it opens is also where an account is added and removed, which
|
||||
// is why no separate "Manage accounts" row sits beside it.
|
||||
UserSwitcherMenuItem.SWITCH_ACCOUNT -> UserSwitcherAction(
|
||||
label = switchAccountLabel(profiles, activeProfileId),
|
||||
icon = MembyIcon.Person.mark,
|
||||
modifier = modifier,
|
||||
onClick = onOpenViewers,
|
||||
onClick = onManageProfiles,
|
||||
)
|
||||
// Notifications live here rather than on the launcher: these belong to
|
||||
// a person and follow them between televisions, so the menu that
|
||||
@@ -667,6 +723,17 @@ fun UserSwitcherOverlay(
|
||||
modifier = modifier,
|
||||
onClick = onOpenSettings,
|
||||
)
|
||||
// Renaming and removing the people in the list above. Choosing one of
|
||||
// them is a press in that list; this is the rarer, editing half, which
|
||||
// is why it is at the bottom rather than competing with the names.
|
||||
UserSwitcherMenuItem.MANAGE_VIEWERS -> UserSwitcherAction(
|
||||
label = "Manage viewers",
|
||||
icon = MembyIcon.Grid.mark,
|
||||
modifier = modifier,
|
||||
onClick = onManageViewers,
|
||||
)
|
||||
// The same row where viewers are not in play: there the list above is
|
||||
// the accounts, and this is what edits it.
|
||||
UserSwitcherMenuItem.MANAGE_USERS -> UserSwitcherAction(
|
||||
label = "Manage users",
|
||||
icon = MembyIcon.Grid.mark,
|
||||
@@ -679,10 +746,21 @@ fun UserSwitcherOverlay(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One name in the panel's list, whether that list holds accounts or people.
|
||||
*
|
||||
* It is one composable rather than two because the two lists are never on screen together:
|
||||
* the row a household reads every evening should look the same whichever of the two
|
||||
* questions their gateway has them answering. [currentDescription] is the only thing that
|
||||
* differs, and it differs because "current user" and "currently watching" are genuinely
|
||||
* different claims.
|
||||
*/
|
||||
@Composable
|
||||
private fun UserSwitcherProfileItem(
|
||||
profile: EmbyProfile,
|
||||
private fun UserSwitcherPersonItem(
|
||||
name: String,
|
||||
initials: String,
|
||||
current: Boolean,
|
||||
currentDescription: String,
|
||||
modifier: Modifier = Modifier,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
@@ -702,8 +780,7 @@ private fun UserSwitcherProfileItem(
|
||||
)
|
||||
.clickable(onClick = onClick)
|
||||
.semantics {
|
||||
contentDescription =
|
||||
if (current) "${profile.username}, current user" else profile.username
|
||||
contentDescription = if (current) "$name, $currentDescription" else name
|
||||
}
|
||||
.padding(horizontal = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
@@ -716,14 +793,14 @@ private fun UserSwitcherProfileItem(
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
profileInitials(profile.username, profile.profileInitials),
|
||||
initials,
|
||||
color = Color.White,
|
||||
fontSize = 11.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
profile.username,
|
||||
name,
|
||||
color = if (focused || current) Color.White else MutedText,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = if (current) FontWeight.SemiBold else FontWeight.Medium,
|
||||
@@ -1765,23 +1842,25 @@ internal fun MediaRow(
|
||||
onContentFocused()
|
||||
onItemFocused(item)
|
||||
}
|
||||
when (cardFormat(row.kind, item, artworkStyle)) {
|
||||
// The resume bar belongs to the row, not to the card's shape: a
|
||||
// household that has set artwork to posters still needs to see
|
||||
// how far into something it is.
|
||||
val format = cardFormat(row.kind, item, artworkStyle)
|
||||
if (row.kind == MediaRowKind.CONTINUE) {
|
||||
ContinueWatchingCard(
|
||||
item = item,
|
||||
availableWidth = availableWidth,
|
||||
portraitArtwork = format == MediaCardFormat.PORTRAIT,
|
||||
onFocused = focused,
|
||||
onClick = { onItemSelected(item) },
|
||||
onLongClick = { onItemLongPressed(item) },
|
||||
modifier = cardModifier,
|
||||
density = density,
|
||||
)
|
||||
} else when (format) {
|
||||
MediaCardFormat.PORTRAIT -> PortraitMediaCard(
|
||||
item, availableWidth, row.showSecondaryMetadata, focused,
|
||||
{ onItemSelected(item) }, { onItemLongPressed(item) }, cardModifier,
|
||||
density, row.showWatchedEpisodeCount,
|
||||
showProgress = row.kind == MediaRowKind.CONTINUE,
|
||||
)
|
||||
MediaCardFormat.LANDSCAPE -> if (row.kind == MediaRowKind.CONTINUE) {
|
||||
ContinueWatchingCard(
|
||||
item, availableWidth, row.showSecondaryMetadata, focused,
|
||||
{ onItemSelected(item) }, { onItemLongPressed(item) }, cardModifier,
|
||||
density, row.showWatchedEpisodeCount,
|
||||
)
|
||||
} else {
|
||||
MediaCardFormat.LANDSCAPE -> {
|
||||
LandscapeMediaCard(
|
||||
item, availableWidth, row.showSecondaryMetadata, focused,
|
||||
{ onItemSelected(item) }, { onItemLongPressed(item) }, cardModifier,
|
||||
@@ -1965,23 +2044,46 @@ fun LandscapeMediaCard(
|
||||
fun ContinueWatchingCard(
|
||||
item: BaseItem,
|
||||
availableWidth: Dp,
|
||||
showSecondaryMetadata: Boolean,
|
||||
portraitArtwork: Boolean,
|
||||
onFocused: () -> Unit,
|
||||
onClick: () -> Unit,
|
||||
onLongClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
density: String = "standard",
|
||||
showWatchedEpisodeCount: Boolean = false,
|
||||
) {
|
||||
val cardsAcross = when (density) {
|
||||
"compact" -> 5
|
||||
"large" -> 3
|
||||
else -> 4
|
||||
val cardsAcross = if (portraitArtwork) {
|
||||
when (density) {
|
||||
"compact" -> 8
|
||||
"large" -> 5
|
||||
else -> 7
|
||||
}
|
||||
} else {
|
||||
when (density) {
|
||||
"compact" -> 5
|
||||
"large" -> 3
|
||||
else -> 4
|
||||
}
|
||||
}
|
||||
val width = responsiveRowCardWidth(availableWidth, cardsAcross, 164.dp, 360.dp)
|
||||
MediaCard(
|
||||
item, width, 16f / 9f, preferPrimary = false, showProgress = true,
|
||||
showSecondaryMetadata, showWatchedEpisodeCount, onFocused, onClick, onLongClick, modifier,
|
||||
val width = if (portraitArtwork) {
|
||||
responsiveRowCardWidth(availableWidth, cardsAcross, 102.dp, 218.dp)
|
||||
} else {
|
||||
responsiveRowCardWidth(availableWidth, cardsAcross, 164.dp, 360.dp)
|
||||
}
|
||||
val repository = ServiceLocator.repository
|
||||
val model = remember(item) {
|
||||
item.toResumableMediaCardModel(
|
||||
backdropUrl = repository.backdropUrl(item, 720),
|
||||
primaryUrl = repository.primaryUrl(item, 480),
|
||||
)
|
||||
}
|
||||
ResumableMediaCard(
|
||||
model = model,
|
||||
width = width,
|
||||
portraitArtwork = portraitArtwork,
|
||||
onFocused = onFocused,
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2367,7 +2469,7 @@ fun TvLoadingPlaceholder(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ArtworkLoadingSkeleton(modifier: Modifier = Modifier) {
|
||||
internal fun ArtworkLoadingSkeleton(modifier: Modifier = Modifier) {
|
||||
Box(
|
||||
modifier.background(
|
||||
Brush.linearGradient(
|
||||
|
||||
@@ -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 "Who’s watching?",
|
||||
color = MembyOnSurface,
|
||||
fontSize = 36.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
when {
|
||||
removingProfileId != null -> "Removing user…"
|
||||
switchingProfileId != null -> "Switching profile…"
|
||||
managing ->
|
||||
"Each one signs in to Emby separately. " +
|
||||
"To add somebody to this account, use Who’s watching?"
|
||||
else -> "Choose a profile to continue"
|
||||
},
|
||||
color = MembyQuietText,
|
||||
fontSize = 17.sp,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Spacer(Modifier.height(26.dp))
|
||||
|
||||
LazyRow(
|
||||
state = listState,
|
||||
modifier = Modifier.fillMaxWidth().focusGroup(),
|
||||
// Centred *by the arrangement*, so the row keeps the screen's centre line as
|
||||
// the household gains and loses accounts. A start arrangement with content
|
||||
// padding — what this was — leaves two profiles hard against the left edge
|
||||
// of a 1080p television.
|
||||
horizontalArrangement = Arrangement.spacedBy(
|
||||
ProfileChooserMetrics.CardGap,
|
||||
Alignment.CenterHorizontally,
|
||||
),
|
||||
contentPadding = PaddingValues(horizontal = 12.dp),
|
||||
verticalAlignment = Alignment.Top,
|
||||
) {
|
||||
itemsIndexed(orderedProfiles, key = { _, profile -> profile.id }) { index, profile ->
|
||||
val revealed = !busy && (
|
||||
focusedCard == index ||
|
||||
revealedCard == index ||
|
||||
focusedCardForCapture == index
|
||||
)
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier
|
||||
.width(ProfileChooserMetrics.CardWidth)
|
||||
// Observer before the group it observes: `onFocusChanged`
|
||||
// reports the state of the focus target that follows it in the
|
||||
// chain, so the two the other way round never fire at all.
|
||||
.onFocusChanged { state ->
|
||||
if (state.hasFocus) {
|
||||
focusedCard = index
|
||||
lastRowFocus = index
|
||||
} else if (focusedCard == index) {
|
||||
focusedCard = null
|
||||
}
|
||||
}
|
||||
.focusGroup(),
|
||||
) {
|
||||
ProfileTile(
|
||||
name = profile.username,
|
||||
symbol = profileInitials(profile.username, profile.profileInitials),
|
||||
current = profile.id == currentProfileId,
|
||||
enabled = !busy,
|
||||
focusedForCapture = focusedCardForCapture == index,
|
||||
onClick = { onSelect(profile) },
|
||||
modifier = Modifier
|
||||
.focusRequester(tileFocus[index])
|
||||
.focusProperties {
|
||||
up = FocusRequester.Cancel
|
||||
left = if (index > 0) {
|
||||
tileFocus[index - 1]
|
||||
} else {
|
||||
FocusRequester.Cancel
|
||||
}
|
||||
right = if (index < orderedProfiles.lastIndex) {
|
||||
tileFocus[index + 1]
|
||||
} else {
|
||||
addFocus
|
||||
}
|
||||
// Named only while the control is actually composed.
|
||||
down = if (revealed) {
|
||||
removeFocus[index]
|
||||
} else {
|
||||
FocusRequester.Cancel
|
||||
}
|
||||
},
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier.height(ProfileChooserMetrics.RemoveSlot),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
if (revealed) {
|
||||
ProfileRemoveButton(
|
||||
profileName = profile.username,
|
||||
onClick = { pendingRemoval = profile },
|
||||
focusedForCapture = false,
|
||||
modifier = Modifier
|
||||
.focusRequester(removeFocus[index])
|
||||
.focusProperties {
|
||||
up = tileFocus[index]
|
||||
// Sideways stays put: the card beside this one
|
||||
// has no remove control drawn, so Left and Right
|
||||
// would be a jump to somewhere invisible.
|
||||
left = FocusRequester.Cancel
|
||||
right = FocusRequester.Cancel
|
||||
down = if (managing) {
|
||||
backFocus
|
||||
} else {
|
||||
FocusRequester.Cancel
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
item(key = "add-profile") {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier
|
||||
.width(ProfileChooserMetrics.CardWidth)
|
||||
.onFocusChanged { if (it.hasFocus) lastRowFocus = -1 },
|
||||
) {
|
||||
ProfileTile(
|
||||
// Named for what it actually does. It signs a second Emby
|
||||
// account in, which is not what "add another user" means to a
|
||||
// household that has just been offered viewers by the menu above.
|
||||
name = if (managing) "Add an Emby account" else "Add another user",
|
||||
symbol = "+",
|
||||
current = false,
|
||||
enabled = !busy,
|
||||
outlined = true,
|
||||
focusedForCapture = focusedCardForCapture == orderedProfiles.size,
|
||||
onClick = onAddProfile,
|
||||
modifier = Modifier
|
||||
.focusRequester(addFocus)
|
||||
.focusProperties {
|
||||
up = FocusRequester.Cancel
|
||||
left = tileFocus.lastOrNull() ?: FocusRequester.Cancel
|
||||
right = FocusRequester.Cancel
|
||||
down = if (managing) backFocus else FocusRequester.Cancel
|
||||
},
|
||||
)
|
||||
// The Add card has no remove control and still owes the row its
|
||||
// height, or it would sit proud of every profile beside it.
|
||||
Spacer(Modifier.height(ProfileChooserMetrics.RemoveSlot))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(12.dp))
|
||||
// Reserved, never conditional. A control that only appears under focus is
|
||||
// invisible to somebody who has never pressed Down, and this line is the only
|
||||
// thing on the screen that tells them it is there — but a line that *appeared*
|
||||
// as focus reached a card would shift everything under it on every press.
|
||||
Text(
|
||||
text = profileRemoveHint(
|
||||
cardFocused = focusedCard != null || focusedCardForCapture != null,
|
||||
busy = busy,
|
||||
),
|
||||
color = MembyQuietText,
|
||||
fontSize = 13.sp,
|
||||
textAlign = TextAlign.Center,
|
||||
maxLines = 1,
|
||||
modifier = Modifier.height(ProfileChooserMetrics.CaptionLine),
|
||||
)
|
||||
|
||||
if (onClose != null) {
|
||||
Spacer(Modifier.height(16.dp))
|
||||
MembySecondaryButton(
|
||||
label = "Back to Memby",
|
||||
onClick = { if (!busy) onClose() },
|
||||
modifier = Modifier
|
||||
.focusRequester(backFocus)
|
||||
.focusProperties {
|
||||
// Back to whichever card the remote came down from, rather than
|
||||
// to a remove control that is no longer drawn.
|
||||
up = if (lastRowFocus >= 0) {
|
||||
tileFocus.getOrNull(lastRowFocus) ?: addFocus
|
||||
} else {
|
||||
addFocus
|
||||
}
|
||||
left = FocusRequester.Cancel
|
||||
right = FocusRequester.Cancel
|
||||
down = FocusRequester.Cancel
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
pendingRemoval?.let { profile ->
|
||||
ProfileRemovalConfirmation(
|
||||
profile = profile,
|
||||
onCancel = {
|
||||
val index = profileIds.indexOf(profile.id)
|
||||
pendingRemoval = null
|
||||
if (index >= 0) revealedCard = index
|
||||
},
|
||||
onConfirm = {
|
||||
removalReturnIndex = profileIds.indexOf(profile.id).takeIf { it >= 0 }
|
||||
pendingRemoval = null
|
||||
onRemove(profile)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The row's order: whoever is signed in first, then the rest as stored, with any repeated id
|
||||
* dropped.
|
||||
*
|
||||
* Pure, so the rule that decides *where focus opens* can be tested — the screen focuses index
|
||||
* 0 and nothing else, which is only the right answer while this puts the signed-in account
|
||||
* there. Deduplication is the `ui/ListKeys.kt` rule and belongs here rather than in the
|
||||
* composable: a keyed `LazyRow` throws on a repeated key and takes the screen with it, and
|
||||
* the profiles list is written by a television that has been reinstalled onto.
|
||||
*/
|
||||
internal fun profileChooserOrder(
|
||||
profiles: List<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"))
|
||||
}
|
||||
}
|
||||
+143
@@ -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))
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user