0.2.84
This commit is contained in:
@@ -1,3 +1,17 @@
|
|||||||
|
## 0.2.84 - 2026-08-21
|
||||||
|
- Improved: Loading and performance of the continue watching row.
|
||||||
|
|
||||||
|
## 0.2.83 - 2026-08-20
|
||||||
|
- Fixed: Turning off "Play the next episode" now turns off the next-episode recap or preview with it. With auto-play off but previews on, an episode still cut away two minutes before the end to play a preview and then rolled into the next episode anyway.
|
||||||
|
- Fixed: "Who's watching?" now appears as soon as your server has viewers switched on, so the first person can actually be added. It was hidden until a second viewer existed, which nothing on the TV could create.
|
||||||
|
- Improved: The user menu now lists the people on this account, so switching to somebody else is one press instead of a row leading to a separate screen. Your Emby sign-ins moved to "Switch account" just below them, and adding a viewer is a row in the same list.
|
||||||
|
- Improved: The account chooser says plainly that it adds a separate Emby sign-in, so it is no longer mistaken for the way to add somebody to the account you are already using.
|
||||||
|
- Improved: "Who’s watching?" is centred, evenly spaced, and the profile under focus is obvious from across the room. Names now line up along one baseline whichever one is selected.
|
||||||
|
- Improved: Removing a user is a small delete mark that appears on the profile you are on — press Down to reach it — rather than a "Remove user" button parked under every profile. It still asks before it removes anything, and cancelling puts you back where you were.
|
||||||
|
|
||||||
|
## 0.2.82 - 2026-08-20
|
||||||
|
- New: Each Memby account can have multiple "profiles" per TV.
|
||||||
|
|
||||||
## 0.2.81 - 2026-08-19
|
## 0.2.81 - 2026-08-19
|
||||||
- Improved: Moved poster media type icon to subtitle on Genres page.
|
- Improved: Moved poster media type icon to subtitle on Genres page.
|
||||||
- Fixed: Homepage heroes now update as soon as an administrator changes them.
|
- Fixed: Homepage heroes now update as soon as an administrator changes them.
|
||||||
|
|||||||
@@ -2220,13 +2220,32 @@ The optional **next-episode recap or preview** extends that same auto-advance pa
|
|||||||
next episode is known, the television searches YouTube in the background using series name,
|
next episode is known, the television searches YouTube in the background using series name,
|
||||||
episode code and title, ranks official previews and recaps above reactions, reviews and
|
episode code and title, ranks official previews and recaps above reactions, reviews and
|
||||||
breakdowns, resolves the best playable native stream, and caches the result. Playback never
|
breakdowns, resolves the best playable native stream, and caches the result. Playback never
|
||||||
waits for this work. If the setting is on and the playhead naturally crosses two minutes
|
waits for this work. If **both** settings are on and the playhead naturally crosses two
|
||||||
remaining, the preview replaces the closing credits and then advances to the episode. Seeking
|
minutes remaining, the preview replaces the closing credits and then advances to the episode.
|
||||||
straight into that window does not trigger it. A failure before the first preview frame
|
Seeking straight into that window does not trigger it. A failure before the first preview
|
||||||
silently restores the current episode at its saved position; a failure after that frame moves
|
frame silently restores the current episode at its saved position; a failure after that frame
|
||||||
on to the next episode, because the outgoing episode has already been closed. Preview time is
|
moves on to the next episode, because the outgoing episode has already been closed. Preview
|
||||||
never reported to Emby as episode progress, and no YouTube account or embedded web player is
|
time is never reported to Emby as episode progress, and no YouTube account or embedded web
|
||||||
involved.
|
player is involved.
|
||||||
|
|
||||||
|
**Both settings**, and that is the one thing to preserve here. This is the exception to
|
||||||
|
`shouldAutoAdvance` being the only reader of `autoPlayNextEpisode`: a preview is not an offer
|
||||||
|
a viewer can decline the way the banner and the credits pane are — it *takes over playback*
|
||||||
|
two minutes from the end and rolls into the next episode on its own — so it is part of the
|
||||||
|
automatic transition and answers to the same switch. It read only
|
||||||
|
`playNextEpisodePreview`, which meant a viewer who had turned automatic advance off still had
|
||||||
|
their episode cut away two minutes early and the next one started for them, which is exactly
|
||||||
|
what "auto-play is off and it still plays the next episode" looks like from the sofa; the
|
||||||
|
toggle's own description in Settings had promised the dependency all along and nothing
|
||||||
|
enforced it. It is checked in three places for three different reasons and all three are
|
||||||
|
needed: the *prefetch*, so a provider search is not spent on something that must never play;
|
||||||
|
`shouldStartNextEpisodePreview`, at the moment of use, because this is a synced per-profile
|
||||||
|
preference and a document pushed from another television or from the admin console arrives
|
||||||
|
mid-episode; and both ways a preview *ends* (`completeNextEpisodePreview`,
|
||||||
|
`resumeEpisodeAfterPreviewFailure` past the outgoing report), which is the setting having been
|
||||||
|
switched off while the preview played — the transition it was the opening of has to go with
|
||||||
|
it. `autoPlayNextEpisodeEnabled` is the one live reader, never a value captured when playback
|
||||||
|
started.
|
||||||
|
|
||||||
**Skipping is Left and Right, and it does not open anything.** `ui/player/SeekControls.kt`
|
**Skipping is Left and Right, and it does not open anything.** `ui/player/SeekControls.kt`
|
||||||
holds the arithmetic and the wording; `PlayerActivity.dispatchKeyEvent` owns the keys and
|
holds the arithmetic and the wording; `PlayerActivity.dispatchKeyEvent` owns the keys and
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ val projectNoticeText =
|
|||||||
|
|
||||||
// A release workflow can derive the app version from its Git tag without editing the
|
// A release workflow can derive the app version from its Git tag without editing the
|
||||||
// source tree. Local builds keep using the checked-in default.
|
// source tree. Local builds keep using the checked-in default.
|
||||||
val defaultVersionName = "0.2.82"
|
val defaultVersionName = "0.2.84"
|
||||||
val membyVersionName: String =
|
val membyVersionName: String =
|
||||||
(project.findProperty("memby.versionName") as String?)
|
(project.findProperty("memby.versionName") as String?)
|
||||||
?.trim()
|
?.trim()
|
||||||
|
|||||||
@@ -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
|
* 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
|
* 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 _installPermissionPrompt = MutableStateFlow(false)
|
||||||
private val _genreBrowserEnabled = MutableStateFlow(false)
|
private val _genreBrowserEnabled = MutableStateFlow(false)
|
||||||
private val _tvCalendarEnabled = MutableStateFlow(false)
|
private val _tvCalendarEnabled = MutableStateFlow(false)
|
||||||
|
private val _continueWatchingEnabled = MutableStateFlow(true)
|
||||||
|
private val _viewersEnabled = MutableStateFlow(false)
|
||||||
private val _requestsAllowed = MutableStateFlow(false)
|
private val _requestsAllowed = MutableStateFlow(false)
|
||||||
private val _gatewayVersion = MutableStateFlow("")
|
private val _gatewayVersion = MutableStateFlow("")
|
||||||
private val _embyVersion = MutableStateFlow("")
|
private val _embyVersion = MutableStateFlow("")
|
||||||
@@ -147,6 +149,27 @@ class MaintenanceMonitor(
|
|||||||
*/
|
*/
|
||||||
val tvCalendarEnabled: StateFlow<Boolean> = _tvCalendarEnabled.asStateFlow()
|
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.
|
* Whether this viewer may ask the household for titles, as of the last poll.
|
||||||
*
|
*
|
||||||
@@ -260,6 +283,8 @@ class MaintenanceMonitor(
|
|||||||
_installPermissionPrompt.value = false
|
_installPermissionPrompt.value = false
|
||||||
_genreBrowserEnabled.value = false
|
_genreBrowserEnabled.value = false
|
||||||
_tvCalendarEnabled.value = false
|
_tvCalendarEnabled.value = false
|
||||||
|
_continueWatchingEnabled.value = true
|
||||||
|
_viewersEnabled.value = false
|
||||||
setRequestsAllowed(false)
|
setRequestsAllowed(false)
|
||||||
_gatewayVersion.value = ""
|
_gatewayVersion.value = ""
|
||||||
dismissAlert()
|
dismissAlert()
|
||||||
@@ -295,6 +320,9 @@ class MaintenanceMonitor(
|
|||||||
status.features[INSTALL_PERMISSION_FEATURE] == true
|
status.features[INSTALL_PERMISSION_FEATURE] == true
|
||||||
_genreBrowserEnabled.value = status.features[GENRE_BROWSER_FEATURE] == true
|
_genreBrowserEnabled.value = status.features[GENRE_BROWSER_FEATURE] == true
|
||||||
_tvCalendarEnabled.value = status.features[TV_CALENDAR_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)
|
setRequestsAllowed(status.requests.allowed)
|
||||||
_gatewayVersion.value = status.gatewayVersion
|
_gatewayVersion.value = status.gatewayVersion
|
||||||
// Emby's state is reported even during maintenance: an
|
// Emby's state is reported even during maintenance: an
|
||||||
@@ -334,6 +362,8 @@ class MaintenanceMonitor(
|
|||||||
_installPermissionPrompt.value = false
|
_installPermissionPrompt.value = false
|
||||||
_genreBrowserEnabled.value = false
|
_genreBrowserEnabled.value = false
|
||||||
_tvCalendarEnabled.value = false
|
_tvCalendarEnabled.value = false
|
||||||
|
_continueWatchingEnabled.value = true
|
||||||
|
_viewersEnabled.value = false
|
||||||
setRequestsAllowed(false)
|
setRequestsAllowed(false)
|
||||||
_gatewayVersion.value = ""
|
_gatewayVersion.value = ""
|
||||||
dismissAlert()
|
dismissAlert()
|
||||||
@@ -441,6 +471,8 @@ class MaintenanceMonitor(
|
|||||||
/** Matches `featureGenreBrowser` in the gateway's feature catalogue. */
|
/** Matches `featureGenreBrowser` in the gateway's feature catalogue. */
|
||||||
internal const val GENRE_BROWSER_FEATURE = "genre_browser"
|
internal const val GENRE_BROWSER_FEATURE = "genre_browser"
|
||||||
internal const val TV_CALENDAR_FEATURE = "tv_calendar"
|
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]. */
|
/** Insertion-ordered ceiling on the in-memory seen set. See [seenAlertIds]. */
|
||||||
private const val MAX_TRACKED_ALERT_IDS = 200
|
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. */
|
/** Trailer failures always advance the provider chain instead of opening the generic error pane. */
|
||||||
internal fun shouldFallbackTrailer(isTrailer: Boolean): Boolean = isTrailer
|
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(
|
internal fun shouldStartNextEpisodePreview(
|
||||||
|
autoPlayEnabled: Boolean,
|
||||||
armed: Boolean,
|
armed: Boolean,
|
||||||
dismissed: Boolean,
|
dismissed: Boolean,
|
||||||
remainingMs: Long,
|
remainingMs: Long,
|
||||||
leadMs: Long,
|
leadMs: Long,
|
||||||
hasPreview: Boolean,
|
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.animation.core.tween
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.border
|
import androidx.compose.foundation.border
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
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.FocusRequester
|
||||||
import androidx.compose.ui.focus.focusProperties
|
import androidx.compose.ui.focus.focusProperties
|
||||||
import androidx.compose.ui.focus.focusRequester
|
import androidx.compose.ui.focus.focusRequester
|
||||||
import androidx.compose.ui.focus.onFocusChanged
|
|
||||||
import androidx.compose.ui.graphics.Brush
|
import androidx.compose.ui.graphics.Brush
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.graphicsLayer
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
@@ -46,19 +44,17 @@ import androidx.compose.ui.unit.sp
|
|||||||
import androidx.compose.ui.zIndex
|
import androidx.compose.ui.zIndex
|
||||||
import androidx.tv.material3.Icon
|
import androidx.tv.material3.Icon
|
||||||
import androidx.tv.material3.Text
|
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.MembyAccentBright
|
||||||
import com.ponzischeme89.memby.ui.theme.MembyAccentMuted
|
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.MembyHairline
|
||||||
import com.ponzischeme89.memby.ui.theme.MembyMutedText
|
import com.ponzischeme89.memby.ui.theme.MembyMutedText
|
||||||
import com.ponzischeme89.memby.ui.theme.MembyOnSurface
|
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.MembyPanelCorner
|
||||||
import com.ponzischeme89.memby.ui.theme.MembyQuietText
|
import com.ponzischeme89.memby.ui.theme.MembyQuietText
|
||||||
import com.ponzischeme89.memby.ui.theme.MembySplashTint
|
import com.ponzischeme89.memby.ui.theme.MembySplashTint
|
||||||
import com.ponzischeme89.memby.ui.theme.MembySurfaceRaised
|
import com.ponzischeme89.memby.ui.theme.MembySurfaceRaised
|
||||||
import kotlinx.coroutines.delay
|
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. */
|
/** The two answers the panel offers, named so a capture can say which one is under focus. */
|
||||||
internal enum class ExitChoice { STAY, CLOSE }
|
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.
|
// dialog leaves a television with nothing focused and no way out of it.
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
appeared = true
|
appeared = true
|
||||||
delay(16L)
|
delay(16.milliseconds)
|
||||||
runCatching { stayFocus.requestFocus() }
|
runCatching { stayFocus.requestFocus() }
|
||||||
}
|
}
|
||||||
Box(
|
Box(
|
||||||
@@ -189,7 +185,7 @@ internal fun ExitMembyConfirmation(
|
|||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
horizontalArrangement = Arrangement.spacedBy(14.dp),
|
horizontalArrangement = Arrangement.spacedBy(14.dp),
|
||||||
) {
|
) {
|
||||||
DialogAction(
|
MembyDialogAction(
|
||||||
label = "Stay in Memby",
|
label = "Stay in Memby",
|
||||||
primary = true,
|
primary = true,
|
||||||
onClick = onStay,
|
onClick = onStay,
|
||||||
@@ -204,7 +200,7 @@ internal fun ExitMembyConfirmation(
|
|||||||
down = FocusRequester.Cancel
|
down = FocusRequester.Cancel
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
DialogAction(
|
MembyDialogAction(
|
||||||
label = "Close Memby",
|
label = "Close Memby",
|
||||||
primary = false,
|
primary = false,
|
||||||
onClick = onExit,
|
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.BuildConfig
|
||||||
import com.ponzischeme89.memby.data.EmbyProfile
|
import com.ponzischeme89.memby.data.EmbyProfile
|
||||||
import com.ponzischeme89.memby.data.model.BaseItem
|
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.data.model.EmbyPerson
|
||||||
import com.ponzischeme89.memby.ui.alerts.alertBadgeLabel
|
import com.ponzischeme89.memby.ui.alerts.alertBadgeLabel
|
||||||
import com.ponzischeme89.memby.ui.detail.UHD_MIN_WIDTH
|
import com.ponzischeme89.memby.ui.detail.UHD_MIN_WIDTH
|
||||||
@@ -483,38 +484,57 @@ fun UserSwitcherOverlay(
|
|||||||
showRequests: Boolean = false,
|
showRequests: Boolean = false,
|
||||||
onOpenRequests: () -> Unit = {},
|
onOpenRequests: () -> Unit = {},
|
||||||
/**
|
/**
|
||||||
* Whether this household has anybody to choose between. False hides the row entirely,
|
* Whether this household runs viewers. True turns the panel's list from *accounts* into
|
||||||
* for the same reason [showRequests] does: on the direct path there is nobody to ask,
|
* *people*: see [userSwitcherRows] for why the answer decides what the one list holds
|
||||||
* and an account nobody has added a viewer to would be offered a question with one
|
* rather than adding a second list beside it. False everywhere the question cannot be
|
||||||
* answer. See `shouldOfferViewerPicker`.
|
* asked — on the direct path there is nobody to ask — as [showRequests] is.
|
||||||
*/
|
*/
|
||||||
showViewers: Boolean = false,
|
showViewers: Boolean = false,
|
||||||
|
viewers: List<MembyViewer> = emptyList(),
|
||||||
activeViewerId: String = "",
|
activeViewerId: String = "",
|
||||||
activeViewerName: 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 menuItems = userSwitcherMenuItems(showRequests, showViewers)
|
||||||
val actionCount = menuItems.size
|
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
|
// 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.
|
// old length would leave the new row unfocusable.
|
||||||
val focusRequesters = remember(profileIds, menuItems) {
|
val focusRequesters = remember(rows, menuItems) {
|
||||||
List(profiles.size + actionCount) { FocusRequester() }
|
List(rows.size + actionCount) { FocusRequester() }
|
||||||
}
|
}
|
||||||
val profileListState = remember(profileIds) { LazyListState() }
|
val profileListState = remember(rows) { LazyListState() }
|
||||||
val focusScope = rememberCoroutineScope()
|
val focusScope = rememberCoroutineScope()
|
||||||
var profileFocusJob by remember(profileIds) { mutableStateOf<kotlinx.coroutines.Job?>(null) }
|
var profileFocusJob by remember(rows) { mutableStateOf<kotlinx.coroutines.Job?>(null) }
|
||||||
var focusedIndex by remember(profileIds) {
|
var focusedIndex by remember(rows) { mutableStateOf(userSwitcherRowFocusIndex(rows)) }
|
||||||
mutableStateOf(userSwitcherInitialIndex(profileIds, activeProfileId))
|
LaunchedEffect(rows) {
|
||||||
}
|
|
||||||
LaunchedEffect(profileIds, activeProfileId) {
|
|
||||||
profileFocusJob?.cancel()
|
profileFocusJob?.cancel()
|
||||||
focusedIndex = userSwitcherInitialIndex(profileIds, activeProfileId)
|
focusedIndex = userSwitcherRowFocusIndex(rows)
|
||||||
if (profiles.isNotEmpty()) {
|
if (rows.isNotEmpty()) {
|
||||||
profileListState.scrollToItem(focusedIndex)
|
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)
|
delay(16)
|
||||||
runCatching { focusRequesters[focusedIndex].requestFocus() }
|
runCatching { focusRequesters[focusedIndex].requestFocus() }
|
||||||
}
|
}
|
||||||
@@ -530,7 +550,12 @@ fun UserSwitcherOverlay(
|
|||||||
.align(Alignment.CenterStart)
|
.align(Alignment.CenterStart)
|
||||||
.padding(start = 52.dp)
|
.padding(start = 52.dp)
|
||||||
.width(292.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))
|
.shadow(12.dp, RoundedCornerShape(MembyPanelCorner))
|
||||||
.clip(RoundedCornerShape(MembyPanelCorner))
|
.clip(RoundedCornerShape(MembyPanelCorner))
|
||||||
.background(MembySurface)
|
.background(MembySurface)
|
||||||
@@ -546,13 +571,13 @@ fun UserSwitcherOverlay(
|
|||||||
}
|
}
|
||||||
val next = userSwitcherNextIndex(
|
val next = userSwitcherNextIndex(
|
||||||
currentIndex = focusedIndex,
|
currentIndex = focusedIndex,
|
||||||
profileCount = profiles.size,
|
profileCount = rows.size,
|
||||||
direction = direction,
|
direction = direction,
|
||||||
actionCount = actionCount,
|
actionCount = actionCount,
|
||||||
)
|
)
|
||||||
focusedIndex = next
|
focusedIndex = next
|
||||||
profileFocusJob?.cancel()
|
profileFocusJob?.cancel()
|
||||||
if (next < profiles.size) {
|
if (next < rows.size) {
|
||||||
profileFocusJob = focusScope.launch {
|
profileFocusJob = focusScope.launch {
|
||||||
profileListState.scrollToItem(next)
|
profileListState.scrollToItem(next)
|
||||||
delay(16)
|
delay(16)
|
||||||
@@ -576,18 +601,22 @@ fun UserSwitcherOverlay(
|
|||||||
.padding(10.dp),
|
.padding(10.dp),
|
||||||
) {
|
) {
|
||||||
Text(
|
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,
|
color = Color.White,
|
||||||
fontSize = 18.sp,
|
fontSize = 18.sp,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
modifier = Modifier.padding(start = 8.dp, top = 7.dp, end = 8.dp, bottom = 5.dp),
|
modifier = Modifier.padding(start = 8.dp, top = 7.dp, end = 8.dp, bottom = 5.dp),
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
// With viewers in play this list is *accounts*, and the row below it is the
|
if (showViewers) {
|
||||||
// person — so the panel must stop claiming to be the thing that answers
|
"Everyone keeps their own Continue Watching."
|
||||||
// "who is watching" when there is now a control directly beneath it that
|
} else {
|
||||||
// does. A household running no viewers keeps the wording it always had.
|
"Choose who’s watching"
|
||||||
if (showViewers) "Choose an account" else "Choose who’s watching",
|
},
|
||||||
color = QuietText,
|
color = QuietText,
|
||||||
fontSize = 12.sp,
|
fontSize = 12.sp,
|
||||||
modifier = Modifier.padding(horizontal = 8.dp).padding(bottom = 8.dp),
|
modifier = Modifier.padding(horizontal = 8.dp).padding(bottom = 8.dp),
|
||||||
@@ -596,24 +625,49 @@ fun UserSwitcherOverlay(
|
|||||||
state = profileListState,
|
state = profileListState,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
// Settings adds one fixed action below this list. Give that row its
|
// Weighted, and therefore measured from what the pinned actions left
|
||||||
// space while keeping the panel's established 400dp maximum; larger
|
// over rather than from a figure typed in here. A fixed cap has to be
|
||||||
// households still reach every profile through the lazy list.
|
// re-derived by hand every time a row is added below it, and the one
|
||||||
.heightIn(max = 208.dp),
|
// 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),
|
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||||
) {
|
) {
|
||||||
itemsIndexed(
|
itemsIndexed(items = rows, key = { _, row -> userSwitcherRowKey(row) }) { index, row ->
|
||||||
items = profiles,
|
val rowModifier = Modifier
|
||||||
key = { _, profile -> profile.id },
|
.focusRequester(focusRequesters[index])
|
||||||
) { index, profile ->
|
.onFocusChanged { if (it.isFocused) focusedIndex = index }
|
||||||
UserSwitcherProfileItem(
|
when (row) {
|
||||||
profile = profile,
|
is UserSwitcherRow.Account -> UserSwitcherPersonItem(
|
||||||
current = profile.id == activeProfileId,
|
name = row.profile.username,
|
||||||
modifier = Modifier
|
initials = profileInitials(
|
||||||
.focusRequester(focusRequesters[index])
|
row.profile.username,
|
||||||
.onFocusChanged { if (it.isFocused) focusedIndex = index },
|
row.profile.profileInitials,
|
||||||
onClick = { onProfileSelected(profile) },
|
),
|
||||||
)
|
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))
|
Spacer(Modifier.height(6.dp))
|
||||||
@@ -628,19 +682,21 @@ fun UserSwitcherOverlay(
|
|||||||
// written out here. The four hand-computed offsets this replaced were what made
|
// written out here. The four hand-computed offsets this replaced were what made
|
||||||
// adding a fifth conditional row unsafe.
|
// adding a fifth conditional row unsafe.
|
||||||
menuItems.forEachIndexed { offset, item ->
|
menuItems.forEachIndexed { offset, item ->
|
||||||
val index = profiles.size + offset
|
val index = rows.size + offset
|
||||||
val modifier = Modifier
|
val modifier = Modifier
|
||||||
.focusRequester(focusRequesters[index])
|
.focusRequester(focusRequesters[index])
|
||||||
.onFocusChanged { if (it.isFocused) focusedIndex = index }
|
.onFocusChanged { if (it.isFocused) focusedIndex = index }
|
||||||
when (item) {
|
when (item) {
|
||||||
// Who is watching sits above the rest because it changes whose menu
|
// The accounts, now that the list above holds people instead. It sits
|
||||||
// this is: the notifications and requests below belong to whichever
|
// above the rest because it changes whose menu this is: the
|
||||||
// viewer it selects.
|
// notifications and requests below belong to whatever it selects. The
|
||||||
UserSwitcherMenuItem.VIEWERS -> UserSwitcherAction(
|
// screen it opens is also where an account is added and removed, which
|
||||||
label = viewerMenuLabel(activeViewerId, activeViewerName),
|
// is why no separate "Manage accounts" row sits beside it.
|
||||||
|
UserSwitcherMenuItem.SWITCH_ACCOUNT -> UserSwitcherAction(
|
||||||
|
label = switchAccountLabel(profiles, activeProfileId),
|
||||||
icon = MembyIcon.Person.mark,
|
icon = MembyIcon.Person.mark,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
onClick = onOpenViewers,
|
onClick = onManageProfiles,
|
||||||
)
|
)
|
||||||
// Notifications live here rather than on the launcher: these belong to
|
// Notifications live here rather than on the launcher: these belong to
|
||||||
// a person and follow them between televisions, so the menu that
|
// a person and follow them between televisions, so the menu that
|
||||||
@@ -667,6 +723,17 @@ fun UserSwitcherOverlay(
|
|||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
onClick = onOpenSettings,
|
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(
|
UserSwitcherMenuItem.MANAGE_USERS -> UserSwitcherAction(
|
||||||
label = "Manage users",
|
label = "Manage users",
|
||||||
icon = MembyIcon.Grid.mark,
|
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
|
@Composable
|
||||||
private fun UserSwitcherProfileItem(
|
private fun UserSwitcherPersonItem(
|
||||||
profile: EmbyProfile,
|
name: String,
|
||||||
|
initials: String,
|
||||||
current: Boolean,
|
current: Boolean,
|
||||||
|
currentDescription: String,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
) {
|
) {
|
||||||
@@ -702,8 +780,7 @@ private fun UserSwitcherProfileItem(
|
|||||||
)
|
)
|
||||||
.clickable(onClick = onClick)
|
.clickable(onClick = onClick)
|
||||||
.semantics {
|
.semantics {
|
||||||
contentDescription =
|
contentDescription = if (current) "$name, $currentDescription" else name
|
||||||
if (current) "${profile.username}, current user" else profile.username
|
|
||||||
}
|
}
|
||||||
.padding(horizontal = 10.dp),
|
.padding(horizontal = 10.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
@@ -716,14 +793,14 @@ private fun UserSwitcherProfileItem(
|
|||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
profileInitials(profile.username, profile.profileInitials),
|
initials,
|
||||||
color = Color.White,
|
color = Color.White,
|
||||||
fontSize = 11.sp,
|
fontSize = 11.sp,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Text(
|
Text(
|
||||||
profile.username,
|
name,
|
||||||
color = if (focused || current) Color.White else MutedText,
|
color = if (focused || current) Color.White else MutedText,
|
||||||
fontSize = 14.sp,
|
fontSize = 14.sp,
|
||||||
fontWeight = if (current) FontWeight.SemiBold else FontWeight.Medium,
|
fontWeight = if (current) FontWeight.SemiBold else FontWeight.Medium,
|
||||||
@@ -1765,23 +1842,25 @@ internal fun MediaRow(
|
|||||||
onContentFocused()
|
onContentFocused()
|
||||||
onItemFocused(item)
|
onItemFocused(item)
|
||||||
}
|
}
|
||||||
when (cardFormat(row.kind, item, artworkStyle)) {
|
val format = cardFormat(row.kind, item, artworkStyle)
|
||||||
// The resume bar belongs to the row, not to the card's shape: a
|
if (row.kind == MediaRowKind.CONTINUE) {
|
||||||
// household that has set artwork to posters still needs to see
|
ContinueWatchingCard(
|
||||||
// how far into something it is.
|
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(
|
MediaCardFormat.PORTRAIT -> PortraitMediaCard(
|
||||||
item, availableWidth, row.showSecondaryMetadata, focused,
|
item, availableWidth, row.showSecondaryMetadata, focused,
|
||||||
{ onItemSelected(item) }, { onItemLongPressed(item) }, cardModifier,
|
{ onItemSelected(item) }, { onItemLongPressed(item) }, cardModifier,
|
||||||
density, row.showWatchedEpisodeCount,
|
density, row.showWatchedEpisodeCount,
|
||||||
showProgress = row.kind == MediaRowKind.CONTINUE,
|
|
||||||
)
|
)
|
||||||
MediaCardFormat.LANDSCAPE -> if (row.kind == MediaRowKind.CONTINUE) {
|
MediaCardFormat.LANDSCAPE -> {
|
||||||
ContinueWatchingCard(
|
|
||||||
item, availableWidth, row.showSecondaryMetadata, focused,
|
|
||||||
{ onItemSelected(item) }, { onItemLongPressed(item) }, cardModifier,
|
|
||||||
density, row.showWatchedEpisodeCount,
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
LandscapeMediaCard(
|
LandscapeMediaCard(
|
||||||
item, availableWidth, row.showSecondaryMetadata, focused,
|
item, availableWidth, row.showSecondaryMetadata, focused,
|
||||||
{ onItemSelected(item) }, { onItemLongPressed(item) }, cardModifier,
|
{ onItemSelected(item) }, { onItemLongPressed(item) }, cardModifier,
|
||||||
@@ -1965,23 +2044,46 @@ fun LandscapeMediaCard(
|
|||||||
fun ContinueWatchingCard(
|
fun ContinueWatchingCard(
|
||||||
item: BaseItem,
|
item: BaseItem,
|
||||||
availableWidth: Dp,
|
availableWidth: Dp,
|
||||||
showSecondaryMetadata: Boolean,
|
portraitArtwork: Boolean,
|
||||||
onFocused: () -> Unit,
|
onFocused: () -> Unit,
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
onLongClick: () -> Unit,
|
onLongClick: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
density: String = "standard",
|
density: String = "standard",
|
||||||
showWatchedEpisodeCount: Boolean = false,
|
|
||||||
) {
|
) {
|
||||||
val cardsAcross = when (density) {
|
val cardsAcross = if (portraitArtwork) {
|
||||||
"compact" -> 5
|
when (density) {
|
||||||
"large" -> 3
|
"compact" -> 8
|
||||||
else -> 4
|
"large" -> 5
|
||||||
|
else -> 7
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
when (density) {
|
||||||
|
"compact" -> 5
|
||||||
|
"large" -> 3
|
||||||
|
else -> 4
|
||||||
|
}
|
||||||
}
|
}
|
||||||
val width = responsiveRowCardWidth(availableWidth, cardsAcross, 164.dp, 360.dp)
|
val width = if (portraitArtwork) {
|
||||||
MediaCard(
|
responsiveRowCardWidth(availableWidth, cardsAcross, 102.dp, 218.dp)
|
||||||
item, width, 16f / 9f, preferPrimary = false, showProgress = true,
|
} else {
|
||||||
showSecondaryMetadata, showWatchedEpisodeCount, onFocused, onClick, onLongClick, modifier,
|
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
|
@Composable
|
||||||
private fun ArtworkLoadingSkeleton(modifier: Modifier = Modifier) {
|
internal fun ArtworkLoadingSkeleton(modifier: Modifier = Modifier) {
|
||||||
Box(
|
Box(
|
||||||
modifier.background(
|
modifier.background(
|
||||||
Brush.linearGradient(
|
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 refreshMutex = Mutex()
|
||||||
private val _state = MutableStateFlow(HomeUiState.from(repository.cachedHome()))
|
private val _state = MutableStateFlow(HomeUiState.from(repository.cachedHome()))
|
||||||
val state: StateFlow<HomeUiState> = _state.asStateFlow()
|
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
|
// HomeScreen is a very large composable, so reading the whole of [state] there meant
|
||||||
// every emission invalidated the launcher: the slow-
|
// 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
|
// A refresh can make a newly imported episode the next playable item for
|
||||||
// an existing series ID. Never launch the pre-refresh negotiated session.
|
// an existing series ID. Never launch the pre-refresh negotiated session.
|
||||||
repository.invalidatePlaybackPrefetch()
|
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) {
|
if (repository.supportsBatchHome) {
|
||||||
loadBatchHome()
|
loadBatchHome()
|
||||||
} else {
|
} else {
|
||||||
coroutineScope {
|
coroutineScope {
|
||||||
launch { loadContinueWatching() }
|
if (_continueWatchingEnabled.value) launch { loadContinueWatching() }
|
||||||
launch { loadFavorites() }
|
launch { loadFavorites() }
|
||||||
launch { loadLatest() }
|
launch { loadLatest() }
|
||||||
}
|
}
|
||||||
@@ -352,16 +359,26 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
|
|||||||
private suspend fun loadBatchHome() {
|
private suspend fun loadBatchHome() {
|
||||||
runCatching { repository.getHome() }
|
runCatching { repository.getHome() }
|
||||||
.onSuccess { home ->
|
.onSuccess { home ->
|
||||||
val taggedHome = home.withAiringTodayTags()
|
val taggedHome = home
|
||||||
|
.withAiringTodayTags()
|
||||||
|
.withLatestPlaybackPositions(_latestPlaybackPositions.value)
|
||||||
_state.update { current ->
|
_state.update { current ->
|
||||||
|
val continueEnabled = _continueWatchingEnabled.value
|
||||||
current.copy(
|
current.copy(
|
||||||
continueWatching = taggedHome.continueWatching.distinctItems(),
|
continueWatching = if (continueEnabled) {
|
||||||
|
taggedHome.continueWatching.distinctItems()
|
||||||
|
} else {
|
||||||
|
emptyList()
|
||||||
|
},
|
||||||
favorites = taggedHome.favorites.distinctItems(),
|
favorites = taggedHome.favorites.distinctItems(),
|
||||||
latestMovies = taggedHome.latestMovies.distinctItems(),
|
latestMovies = taggedHome.latestMovies.distinctItems(),
|
||||||
// Recommendation rows are built in the background by the gateway,
|
// Recommendation rows are built in the background by the gateway,
|
||||||
// so an early response can arrive without them. Keeping the rows
|
// so an early response can arrive without them. Keeping the rows
|
||||||
// we already had stops the strip flickering out and back in.
|
// 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(),
|
loading = emptySet(),
|
||||||
hasRefreshError = taggedHome.partial,
|
hasRefreshError = taggedHome.partial,
|
||||||
statusMessage = null,
|
statusMessage = null,
|
||||||
@@ -584,6 +601,36 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
|
|||||||
viewModelScope.launch { refreshWatching() }
|
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) {
|
fun removeFromContinueWatching(item: BaseItem) {
|
||||||
val previous = _state.value
|
val previous = _state.value
|
||||||
_state.update { state ->
|
_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
|
* 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.
|
* 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
|
* A completed title is removed immediately and kept out if a stale refresh still
|
||||||
* is that it leaves Continue Watching, which is the refresh's answer to give.
|
* returns it; a different Next Up episode from the same series remains eligible.
|
||||||
*/
|
*/
|
||||||
private fun applyPlaybackPosition(position: PlaybackPosition) {
|
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)
|
val ticks = millisecondsToTicks(position.positionMs)
|
||||||
updateUserData(position.itemId) {
|
updateUserData(position.itemId) {
|
||||||
// Never backwards: a stop and the ten-second report before it can arrive in
|
// 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() {
|
private suspend fun refreshWatching() {
|
||||||
|
if (!_continueWatchingEnabled.value) return
|
||||||
refreshMutex.withLock {
|
refreshMutex.withLock {
|
||||||
_state.update { it.copy(loading = it.loading + HomeSection.CONTINUE) }
|
_state.update { it.copy(loading = it.loading + HomeSection.CONTINUE) }
|
||||||
if (repository.supportsBatchHome) {
|
if (repository.supportsBatchHome) {
|
||||||
@@ -666,7 +740,9 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
|
|||||||
|
|
||||||
private suspend fun loadContinueWatching(clearLoading: Boolean = true) =
|
private suspend fun loadContinueWatching(clearLoading: Boolean = true) =
|
||||||
load(HomeSection.CONTINUE, clearLoading, { repository.getContinueWatching() }) { state, items ->
|
load(HomeSection.CONTINUE, clearLoading, { repository.getContinueWatching() }) { state, items ->
|
||||||
state.copy(continueWatching = items)
|
state.copy(
|
||||||
|
continueWatching = items.withLatestPlaybackPositions(_latestPlaybackPositions.value),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun loadFavorites() =
|
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 ANALYTICS_FLUSH_INTERVAL_MS = 20_000L
|
||||||
private const val HOME_RETRY_INITIAL_MS = 2_000L
|
private const val HOME_RETRY_INITIAL_MS = 2_000L
|
||||||
private const val HOME_RETRY_MAX_MS = 60_000L
|
private const val HOME_RETRY_MAX_MS = 60_000L
|
||||||
|
private const val LATEST_PLAYBACK_POSITION_LIMIT = 32
|
||||||
|
|
||||||
private fun initialFocusedItem(state: HomeUiState): BaseItem? =
|
private fun initialFocusedItem(state: HomeUiState): BaseItem? =
|
||||||
state.continueWatching.firstOrNull()
|
state.continueWatching.firstOrNull()
|
||||||
@@ -783,6 +860,54 @@ internal fun mergeFreshHomeRows(
|
|||||||
return (fresh + retainedRecommendations).sanitisedRows()
|
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 =
|
private fun HomeRow.isEngineRecommendationRow(): Boolean =
|
||||||
id == "recommended" || id.startsWith("similar:") || id.startsWith("curated:")
|
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,
|
* - **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.
|
* which is what lets the very next playback still open on a prepared instance.
|
||||||
*/
|
*/
|
||||||
@UnstableApi
|
@OptIn(UnstableApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
internal fun LaunchPrerollVideo(
|
internal fun LaunchPrerollVideo(
|
||||||
modifier: Modifier = Modifier,
|
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.Icon
|
||||||
import androidx.tv.material3.Text
|
import androidx.tv.material3.Text
|
||||||
import com.ponzischeme89.memby.ui.theme.MembyAccent
|
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.MembyCardCorner
|
||||||
|
import com.ponzischeme89.memby.ui.theme.MembyDestructive
|
||||||
import com.ponzischeme89.memby.ui.theme.MembyChipCorner
|
import com.ponzischeme89.memby.ui.theme.MembyChipCorner
|
||||||
import com.ponzischeme89.memby.ui.theme.MembyHairline
|
import com.ponzischeme89.memby.ui.theme.MembyHairline
|
||||||
import com.ponzischeme89.memby.ui.theme.MembyMutedText
|
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
|
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 }
|
internal enum class UserSwitcherDirection { UP, DOWN }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Profiles occupy [0, profileCount); the pinned actions — Notifications, optional Requests,
|
* The scrolling list at the top of the panel occupies [0, rowCount); the pinned actions
|
||||||
* Settings, then Manage users — follow them in order. Keeping this arithmetic outside
|
* follow it in order. Keeping this arithmetic outside Compose makes remote navigation
|
||||||
* Compose makes remote navigation deterministic.
|
* deterministic.
|
||||||
*/
|
*/
|
||||||
internal fun userSwitcherInitialIndex(
|
internal fun userSwitcherInitialIndex(
|
||||||
profileIds: List<String>,
|
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
|
* 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
|
* 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`
|
* conditional row is exactly the change that breaks it. This is the shape `QuickAction`
|
||||||
* already moved to for the same reason.
|
* 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
|
* Whichever of the two lists is *not* on the panel is reached by a row instead, and it is
|
||||||
* requests below it belong to whichever viewer it selects, so offering it after them would
|
* the first row for the same reason "Who's watching?" used to be: it changes whose menu this
|
||||||
* put the answer below the things that depend on it.
|
* 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(
|
internal fun userSwitcherMenuItems(
|
||||||
showRequests: Boolean,
|
showRequests: Boolean,
|
||||||
showViewers: Boolean = false,
|
showViewers: Boolean = false,
|
||||||
): List<UserSwitcherMenuItem> = buildList {
|
): List<UserSwitcherMenuItem> = buildList {
|
||||||
if (showViewers) add(UserSwitcherMenuItem.VIEWERS)
|
if (showViewers) add(UserSwitcherMenuItem.SWITCH_ACCOUNT)
|
||||||
add(UserSwitcherMenuItem.NOTIFICATIONS)
|
add(UserSwitcherMenuItem.NOTIFICATIONS)
|
||||||
if (showRequests) add(UserSwitcherMenuItem.REQUESTS)
|
if (showRequests) add(UserSwitcherMenuItem.REQUESTS)
|
||||||
add(UserSwitcherMenuItem.SETTINGS)
|
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.
|
// Playback may have moved on to another episode while this was in flight.
|
||||||
if (itemId == id) {
|
if (itemId == id) {
|
||||||
updateNextEpisodeButton()
|
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)
|
prefetchNextEpisodePreview(id, resolved)
|
||||||
}
|
}
|
||||||
// Extremely short episodes and hostile latency can reach Ended before the
|
// 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
|
* 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
|
* 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)
|
val remainingMs = (duration - playback.currentPosition).coerceAtLeast(0L)
|
||||||
if (remainingMs > NEXT_EPISODE_PREVIEW_LEAD_MS) previewWindowArmed = true
|
if (remainingMs > NEXT_EPISODE_PREVIEW_LEAD_MS) previewWindowArmed = true
|
||||||
if (shouldStartNextEpisodePreview(
|
if (shouldStartNextEpisodePreview(
|
||||||
|
autoPlayEnabled = autoPlayNextEpisodeEnabled,
|
||||||
armed = previewWindowArmed,
|
armed = previewWindowArmed,
|
||||||
dismissed = nextUpDismissed,
|
dismissed = nextUpDismissed,
|
||||||
remainingMs = remainingMs,
|
remainingMs = remainingMs,
|
||||||
@@ -3478,7 +3498,7 @@ class PlayerActivity : ComponentActivity() {
|
|||||||
if (remainingMs <= NEXT_UP_STREAM_WARM_LEAD_MS) nextUpResolver.warm()
|
if (remainingMs <= NEXT_UP_STREAM_WARM_LEAD_MS) nextUpResolver.warm()
|
||||||
|
|
||||||
val autoAdvance = shouldAutoAdvance(
|
val autoAdvance = shouldAutoAdvance(
|
||||||
autoPlayEnabled = ServiceLocator.repository.currentSettings.autoPlayNextEpisode,
|
autoPlayEnabled = autoPlayNextEpisodeEnabled,
|
||||||
hasNextEpisode = true,
|
hasNextEpisode = true,
|
||||||
dismissed = nextUpDismissed,
|
dismissed = nextUpDismissed,
|
||||||
)
|
)
|
||||||
@@ -3581,7 +3601,13 @@ class PlayerActivity : ComponentActivity() {
|
|||||||
updateNextEpisodeButton()
|
updateNextEpisodeButton()
|
||||||
previewNextEpisode = null
|
previewNextEpisode = null
|
||||||
if (previewOutgoingReported) {
|
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
|
return
|
||||||
}
|
}
|
||||||
playbackTitle = previewResumeTitle
|
playbackTitle = previewResumeTitle
|
||||||
@@ -3605,7 +3631,7 @@ class PlayerActivity : ComponentActivity() {
|
|||||||
if (previewResumeUrl.isBlank()) {
|
if (previewResumeUrl.isBlank()) {
|
||||||
// The original stream should always be known, but losing the optional preview
|
// The original stream should always be known, but losing the optional preview
|
||||||
// must never strand the viewer on a generic error pane.
|
// must never strand the viewer on a generic error pane.
|
||||||
if (next != null) startNextEpisode() else finish()
|
if (next != null && autoPlayNextEpisodeEnabled) startNextEpisode() else finish()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
showPlaybackLoading(title = playbackTitle, hint = "Returning to the episode")
|
showPlaybackLoading(title = playbackTitle, hint = "Returning to the episode")
|
||||||
@@ -3639,7 +3665,14 @@ class PlayerActivity : ComponentActivity() {
|
|||||||
playingNextEpisodePreview = false
|
playingNextEpisodePreview = false
|
||||||
previewNextEpisode = null
|
previewNextEpisode = null
|
||||||
nextEpisodePreview = 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)
|
@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
|
* sought back to the beginning and prepared again after use rather than reconstructed for
|
||||||
* every title.
|
* every title.
|
||||||
*/
|
*/
|
||||||
@UnstableApi
|
@OptIn(UnstableApi::class)
|
||||||
internal object PrerollPreloader {
|
internal object PrerollPreloader {
|
||||||
private val mainHandler = Handler(Looper.getMainLooper())
|
private val mainHandler = Handler(Looper.getMainLooper())
|
||||||
private var applicationContext: Context? = null
|
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.Image
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
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.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.rememberScrollState
|
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.foundation.verticalScroll
|
||||||
|
import androidx.compose.animation.animateColorAsState
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.focus.FocusRequester
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
import androidx.compose.ui.focus.focusProperties
|
import androidx.compose.ui.focus.focusProperties
|
||||||
import androidx.compose.ui.focus.focusRequester
|
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.res.painterResource
|
||||||
|
import androidx.compose.ui.text.TextStyle
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
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.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.tv.material3.Text
|
import androidx.tv.material3.Text
|
||||||
import com.ponzischeme89.memby.R
|
import com.ponzischeme89.memby.R
|
||||||
import com.ponzischeme89.memby.ui.TvTextField
|
|
||||||
import com.ponzischeme89.memby.ui.UpdateButton
|
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.MembyMutedText
|
||||||
import com.ponzischeme89.memby.ui.theme.MembyOnSurface
|
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.MembySurface
|
||||||
|
import com.ponzischeme89.memby.ui.theme.MembySurfaceRaised
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The sign-in form, with no dependency on the repository — so it can be screenshotted and
|
* 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. */
|
/** The one red in the app that means "this went wrong", matched to Settings' own notices. */
|
||||||
private val SignInError = Color(0xFFFF7777)
|
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. */
|
/** 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)
|
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,
|
* 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
|
* 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 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
|
* 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
|
* 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.
|
* 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.
|
* 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
|
* Three conditions. There is nobody to ask on the direct path; the operator must be running
|
||||||
* is exactly one viewer and it is the account. And an account with a single viewer is one
|
* viewers; and the account must have somebody to show, which after a failed list request it
|
||||||
* nobody has added anybody to — offering "Who's watching?" there is a question with one
|
* has not.
|
||||||
* 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,
|
* It deliberately does **not** wait for a *second* viewer to exist. It used to, on the
|
||||||
* not behind this.
|
* 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 =
|
internal fun shouldOfferViewerPicker(
|
||||||
gatewayMode && viewerCount > 1
|
gatewayMode: Boolean,
|
||||||
|
enabled: Boolean,
|
||||||
|
viewerCount: Int,
|
||||||
|
): Boolean = gatewayMode && enabled && viewerCount > 0
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* What the user menu's row is called.
|
* What the user menu's row is called.
|
||||||
|
|||||||
@@ -25,9 +25,23 @@ class TrailerSupportTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun nextEpisodePreviewNeedsANaturalTwoMinuteWindow() {
|
fun nextEpisodePreviewNeedsANaturalTwoMinuteWindow() {
|
||||||
assertTrue(shouldStartNextEpisodePreview(true, false, 120_000, 120_000, true))
|
assertTrue(shouldStartNextEpisodePreview(true, true, false, 120_000, 120_000, true))
|
||||||
assertFalse(shouldStartNextEpisodePreview(false, false, 60_000, 120_000, true))
|
assertFalse(shouldStartNextEpisodePreview(true, false, false, 60_000, 120_000, true))
|
||||||
assertFalse(shouldStartNextEpisodePreview(true, true, 60_000, 120_000, true))
|
assertFalse(shouldStartNextEpisodePreview(true, true, true, 60_000, 120_000, true))
|
||||||
assertFalse(shouldStartNextEpisodePreview(true, false, 120_001, 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.HomeCache
|
||||||
import com.ponzischeme89.memby.data.model.BaseItem
|
import com.ponzischeme89.memby.data.model.BaseItem
|
||||||
import com.ponzischeme89.memby.data.model.HomeRow
|
import com.ponzischeme89.memby.data.model.HomeRow
|
||||||
|
import com.ponzischeme89.memby.data.model.UserItemData
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
import org.junit.Assert.assertFalse
|
import org.junit.Assert.assertFalse
|
||||||
import org.junit.Assert.assertTrue
|
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(
|
private fun row(id: String, itemId: String) = HomeRow(
|
||||||
id = id,
|
id = id,
|
||||||
title = 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
|
package com.ponzischeme89.memby.ui
|
||||||
|
|
||||||
|
import com.ponzischeme89.memby.ui.profiles.profileFocusIndexAfterRemoval
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
import org.junit.Assert.assertNull
|
import org.junit.Assert.assertNull
|
||||||
import org.junit.Test
|
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(4, userSwitcherMenuItems(showRequests = false, showViewers = true).size)
|
||||||
assertEquals(5, userSwitcherMenuItems(showRequests = true, showViewers = true).size)
|
assertEquals(5, userSwitcherMenuItems(showRequests = true, showViewers = true).size)
|
||||||
|
|
||||||
// Who is watching comes first, because the rows below it belong to whichever
|
// Switching account comes first, because the rows below it belong to whatever it
|
||||||
// viewer it selects; Manage users stays last, where a menu's escape hatch belongs.
|
// selects; the managing row stays last, where a menu's escape hatch belongs.
|
||||||
val full = userSwitcherMenuItems(showRequests = true, showViewers = true)
|
val full = userSwitcherMenuItems(showRequests = true, showViewers = true)
|
||||||
assertEquals(UserSwitcherMenuItem.VIEWERS, full.first())
|
assertEquals(UserSwitcherMenuItem.SWITCH_ACCOUNT, full.first())
|
||||||
assertEquals(UserSwitcherMenuItem.MANAGE_USERS, full.last())
|
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
|
// An optional row is absent rather than present-and-dead, so nothing below it
|
||||||
// shifts under a D-pad that has already started travelling.
|
// shifts under a D-pad that has already started travelling.
|
||||||
assertFalse(UserSwitcherMenuItem.VIEWERS in userSwitcherMenuItems(showRequests = true))
|
|
||||||
assertFalse(UserSwitcherMenuItem.REQUESTS in userSwitcherMenuItems(showRequests = false))
|
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
|
* The way in, and the capture worth keeping beside the picker above: with viewers on,
|
||||||
* watching rather than repeating the question, which is the whole reason it is worth
|
* the panel's list *is* the people, so choosing one is a press in the menu rather than a
|
||||||
* having a label that changes.
|
* 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
|
@Test
|
||||||
fun `the user menu names the viewer`() {
|
fun `the user menu lists the people`() {
|
||||||
compose.setContent {
|
compose.setContent {
|
||||||
MembyTheme {
|
MembyTheme {
|
||||||
UserSwitcherOverlay(
|
UserSwitcherOverlay(
|
||||||
@@ -98,6 +99,8 @@ class ViewerPickerScreenshotTest {
|
|||||||
onManageProfiles = {},
|
onManageProfiles = {},
|
||||||
onDismiss = {},
|
onDismiss = {},
|
||||||
showViewers = true,
|
showViewers = true,
|
||||||
|
viewers = household,
|
||||||
|
canAddViewer = true,
|
||||||
activeViewerId = "v1",
|
activeViewerId = "v1",
|
||||||
activeViewerName = "Alessandra",
|
activeViewerName = "Alessandra",
|
||||||
)
|
)
|
||||||
@@ -106,6 +109,32 @@ class ViewerPickerScreenshotTest {
|
|||||||
compose.onRoot().captureRoboImage("build/screenshots/viewers/viewers-menu-row.png")
|
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(
|
private fun capture(
|
||||||
name: String,
|
name: String,
|
||||||
viewers: List<MembyViewer>,
|
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
|
* There is nobody to ask on the direct path, and a gateway that does not do viewers
|
||||||
* account nobody has added a viewer to would be offered a question with one answer —
|
* answers with nothing. A single viewer *is* offered the row: the only control that adds
|
||||||
* which reads as a fault rather than as a feature waiting to be used.
|
* 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
|
@Test
|
||||||
fun `the picker is offered only where there is a choice to make`() {
|
fun `the picker is offered wherever there is somebody to ask about`() {
|
||||||
assertFalse(shouldOfferViewerPicker(gatewayMode = false, viewerCount = 4))
|
assertFalse(shouldOfferViewerPicker(gatewayMode = false, enabled = true, viewerCount = 4))
|
||||||
assertFalse(shouldOfferViewerPicker(gatewayMode = true, viewerCount = 1))
|
assertFalse(shouldOfferViewerPicker(gatewayMode = true, enabled = true, viewerCount = 0))
|
||||||
assertFalse(shouldOfferViewerPicker(gatewayMode = true, viewerCount = 0))
|
assertTrue(shouldOfferViewerPicker(gatewayMode = true, enabled = true, viewerCount = 1))
|
||||||
assertTrue(shouldOfferViewerPicker(gatewayMode = true, viewerCount = 2))
|
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))
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ const (
|
|||||||
featureSeasonalDecorations = "seasonal_decorations"
|
featureSeasonalDecorations = "seasonal_decorations"
|
||||||
featureGenreBrowser = "genre_browser"
|
featureGenreBrowser = "genre_browser"
|
||||||
featureTVCalendar = "tv_calendar"
|
featureTVCalendar = "tv_calendar"
|
||||||
|
featureContinueWatching = "continue_watching"
|
||||||
featureWatchTimeDigest = "watch_time_digest"
|
featureWatchTimeDigest = "watch_time_digest"
|
||||||
featureViewers = "viewers"
|
featureViewers = "viewers"
|
||||||
)
|
)
|
||||||
@@ -135,6 +136,13 @@ var featureCatalogue = []featureDefinition{
|
|||||||
DefaultEnabled: true, MinimumProtocol: 1, Capability: "tv_calendar_v1",
|
DefaultEnabled: true, MinimumProtocol: 1, Capability: "tv_calendar_v1",
|
||||||
Recovery: "Takes effect on the next status poll; the rail entry simply disappears.",
|
Recovery: "Takes effect on the next status poll; the rail entry simply disappears.",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
Key: featureContinueWatching, Name: "Continue Watching", Area: "Home",
|
||||||
|
Description: "Show resumable films and episodes on Home. Turning it off hides the " +
|
||||||
|
"row and stops the gateway loading its resume and Next Up feeds.",
|
||||||
|
DefaultEnabled: true, MinimumProtocol: 1,
|
||||||
|
Recovery: "Takes effect on the next status poll, within ten seconds on an open TV.",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
// No capability, because nothing on the television has to understand this: the
|
// No capability, because nothing on the television has to understand this: the
|
||||||
// summary is an ordinary entry in My Alerts, which every build that has that page
|
// summary is an ordinary entry in My Alerts, which every build that has that page
|
||||||
|
|||||||
@@ -61,6 +61,20 @@ func TestGenreBrowserIsServerControlledAndOffByDefault(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestContinueWatchingIsServerControlledAndOnByDefault(t *testing.T) {
|
||||||
|
definition, ok := knownFeature(featureContinueWatching)
|
||||||
|
if !ok || definition.Area != "Home" {
|
||||||
|
t.Fatalf("Continue Watching feature definition = %+v, found=%v", definition, ok)
|
||||||
|
}
|
||||||
|
if got := evaluateFeature(store.DefaultFeaturePolicy(), definition, ProtocolVersion); !got.Enabled {
|
||||||
|
t.Fatalf("Continue Watching default = %+v, want enabled", got)
|
||||||
|
}
|
||||||
|
disabled := store.FeaturePolicy{Overrides: map[string]bool{featureContinueWatching: false}}
|
||||||
|
if got := evaluateFeature(disabled, definition, ProtocolVersion); got.Enabled || got.Source != "override" {
|
||||||
|
t.Fatalf("Continue Watching override = %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestClientCapabilitiesAreNormalizedAndBounded(t *testing.T) {
|
func TestClientCapabilitiesAreNormalizedAndBounded(t *testing.T) {
|
||||||
req := httptest.NewRequest(http.MethodGet, "/v1/status", nil)
|
req := httptest.NewRequest(http.MethodGet, "/v1/status", nil)
|
||||||
req.Header.Set("X-Memby-Capabilities", " Sonarr_Preroll_V1,server_features_v1,sonarr_preroll_v1,"+
|
req.Header.Set("X-Memby-Capabilities", " Sonarr_Preroll_V1,server_features_v1,sonarr_preroll_v1,"+
|
||||||
|
|||||||
+79
-46
@@ -76,6 +76,13 @@ func (s *Server) handleHome(w http.ResponseWriter, r *http.Request, sess store.S
|
|||||||
radarrSchedule := s.radarrEnabled(r.Context()) && supportsRadarrSchedule(r)
|
radarrSchedule := s.radarrEnabled(r.Context()) && supportsRadarrSchedule(r)
|
||||||
hero := supportsHomeHero(r)
|
hero := supportsHomeHero(r)
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
|
featurePolicy := s.currentFeaturePolicy(ctx)
|
||||||
|
continueDefinition, _ := knownFeature(featureContinueWatching)
|
||||||
|
continueWatching := evaluateFeature(
|
||||||
|
featurePolicy,
|
||||||
|
continueDefinition,
|
||||||
|
ProtocolVersion,
|
||||||
|
).Enabled
|
||||||
// The hero revision is part of the key rather than something to invalidate. An
|
// The hero revision is part of the key rather than something to invalidate. An
|
||||||
// operator's change therefore makes the entries built under the old policy simply
|
// operator's change therefore makes the entries built under the old policy simply
|
||||||
// unreachable, and they age out on their own TTL — where the sweep it replaced dropped
|
// unreachable, and they age out on their own TTL — where the sweep it replaced dropped
|
||||||
@@ -86,6 +93,8 @@ func (s *Server) handleHome(w http.ResponseWriter, r *http.Request, sess store.S
|
|||||||
viewerKeyOf(ctx, sess),
|
viewerKeyOf(ctx, sess),
|
||||||
"home:v4:"+itoa(limit)+":s"+strconv.FormatBool(sonarrSchedule)+
|
"home:v4:"+itoa(limit)+":s"+strconv.FormatBool(sonarrSchedule)+
|
||||||
":r"+strconv.FormatBool(radarrSchedule)+":h"+strconv.FormatBool(hero)+
|
":r"+strconv.FormatBool(radarrSchedule)+":h"+strconv.FormatBool(hero)+
|
||||||
|
":c"+strconv.FormatBool(continueWatching)+
|
||||||
|
":f"+strconv.FormatInt(featurePolicy.Revision, 10)+
|
||||||
":hr"+heroRev+":d"+sess.DeviceID,
|
":hr"+heroRev+":d"+sess.DeviceID,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -104,6 +113,7 @@ func (s *Server) handleHome(w http.ResponseWriter, r *http.Request, sess store.S
|
|||||||
var (
|
var (
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
failures int
|
failures int
|
||||||
|
rowAttempts int
|
||||||
out homeResponse
|
out homeResponse
|
||||||
sonarrRow *recommend.Row
|
sonarrRow *recommend.Row
|
||||||
radarrRow *recommend.Row
|
radarrRow *recommend.Row
|
||||||
@@ -120,6 +130,7 @@ func (s *Server) handleHome(w http.ResponseWriter, r *http.Request, sess store.S
|
|||||||
// on rather than only that it was waiting on Emby. They are concurrent, so a single
|
// on rather than only that it was waiting on Emby. They are concurrent, so a single
|
||||||
// summed `emby=` would be the one number that could not answer that.
|
// summed `emby=` would be the one number that could not answer that.
|
||||||
run := func(name string, dest *[]json.RawMessage, fetch func(context.Context) (*emby.ItemsResult, error)) {
|
run := func(name string, dest *[]json.RawMessage, fetch func(context.Context) (*emby.ItemsResult, error)) {
|
||||||
|
rowAttempts++
|
||||||
wg.Add(1)
|
wg.Add(1)
|
||||||
go func() {
|
go func() {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
@@ -144,16 +155,18 @@ func (s *Server) handleHome(w http.ResponseWriter, r *http.Request, sess store.S
|
|||||||
viewer := viewerOf(ctx, sess)
|
viewer := viewerOf(ctx, sess)
|
||||||
shadow := !viewer.IsMain() && s.store != nil
|
shadow := !viewer.IsMain() && s.store != nil
|
||||||
|
|
||||||
run("resume", &out.ContinueWatching, func(ctx context.Context) (*emby.ItemsResult, error) {
|
if continueWatching {
|
||||||
if shadow {
|
run("resume", &out.ContinueWatching, func(ctx context.Context) (*emby.ItemsResult, error) {
|
||||||
return s.viewerContinueRow(ctx, cred, viewer.ID, limit)
|
if shadow {
|
||||||
}
|
return s.viewerContinueRow(ctx, cred, viewer.ID, limit)
|
||||||
return s.emby.ResumeItems(ctx, cred, rowParams(url.Values{
|
}
|
||||||
"Recursive": {"true"},
|
return s.emby.ResumeItems(ctx, cred, rowParams(url.Values{
|
||||||
"MediaTypes": {"Video"},
|
"Recursive": {"true"},
|
||||||
"Limit": {itoa(limit)},
|
"MediaTypes": {"Video"},
|
||||||
}, fieldsContinue))
|
"Limit": {itoa(limit)},
|
||||||
})
|
}, fieldsContinue))
|
||||||
|
})
|
||||||
|
}
|
||||||
run("favourites", &out.Favorites, func(ctx context.Context) (*emby.ItemsResult, error) {
|
run("favourites", &out.Favorites, func(ctx context.Context) (*emby.ItemsResult, error) {
|
||||||
if shadow {
|
if shadow {
|
||||||
return s.viewerFavouritesRow(ctx, cred, viewer.ID, limit)
|
return s.viewerFavouritesRow(ctx, cred, viewer.ID, limit)
|
||||||
@@ -167,38 +180,42 @@ func (s *Server) handleHome(w http.ResponseWriter, r *http.Request, sess store.S
|
|||||||
"Limit": {itoa(limit)},
|
"Limit": {itoa(limit)},
|
||||||
}, fieldsRow))
|
}, fieldsRow))
|
||||||
})
|
})
|
||||||
run("nextup", &out.NextUp, func(ctx context.Context) (*emby.ItemsResult, error) {
|
if continueWatching {
|
||||||
if shadow {
|
run("nextup", &out.NextUp, func(ctx context.Context) (*emby.ItemsResult, error) {
|
||||||
return s.viewerNextUpRow(ctx, cred, viewer.ID, limit)
|
if shadow {
|
||||||
}
|
return s.viewerNextUpRow(ctx, cred, viewer.ID, limit)
|
||||||
return s.emby.NextUp(ctx, cred, rowParams(url.Values{
|
}
|
||||||
"Limit": {itoa(limit)},
|
return s.emby.NextUp(ctx, cred, rowParams(url.Values{
|
||||||
}, fieldsContinue))
|
"Limit": {itoa(limit)},
|
||||||
})
|
}, fieldsContinue))
|
||||||
|
})
|
||||||
|
}
|
||||||
// What orders the Next Up half of Continue Watching. A failure here is not a failed
|
// What orders the Next Up half of Continue Watching. A failure here is not a failed
|
||||||
// row: the merge falls back to putting the resume items first, which is the order
|
// row: the merge falls back to putting the resume items first, which is the order
|
||||||
// the launcher had before the two rows became one.
|
// the launcher had before the two rows became one.
|
||||||
wg.Add(1)
|
if continueWatching {
|
||||||
go func() {
|
wg.Add(1)
|
||||||
defer wg.Done()
|
go func() {
|
||||||
// What orders the two halves of the merge. For a shadow viewer it is one grouped
|
defer wg.Done()
|
||||||
// query over their own state rather than a lookback over the account's plays —
|
// What orders the two halves of the merge. For a shadow viewer it is one grouped
|
||||||
// the same question, asked of the system that holds the answer.
|
// query over their own state rather than a lookback over the account's plays —
|
||||||
var played map[string]time.Time
|
// the same question, asked of the system that holds the answer.
|
||||||
var err error
|
var played map[string]time.Time
|
||||||
if shadow {
|
var err error
|
||||||
played, err = s.store.ViewerWatchedSeries(ctx, viewer.ID, continuePlayLookback)
|
if shadow {
|
||||||
} else {
|
played, err = s.store.ViewerWatchedSeries(ctx, viewer.ID, continuePlayLookback)
|
||||||
played, err = s.recentlyPlayedSeries(timing.WithLabel(ctx, "emby.recent"), cred)
|
} else {
|
||||||
}
|
played, err = s.recentlyPlayedSeries(timing.WithLabel(ctx, "emby.recent"), cred)
|
||||||
if err != nil {
|
}
|
||||||
s.loggerFor(ctx).Warn("recently played lookup failed", "error", err)
|
if err != nil {
|
||||||
return
|
s.loggerFor(ctx).Warn("recently played lookup failed", "error", err)
|
||||||
}
|
return
|
||||||
mu.Lock()
|
}
|
||||||
seriesPlayed = played
|
mu.Lock()
|
||||||
mu.Unlock()
|
seriesPlayed = played
|
||||||
}()
|
mu.Unlock()
|
||||||
|
}()
|
||||||
|
}
|
||||||
run("latest", &out.LatestMovies, func(ctx context.Context) (*emby.ItemsResult, error) {
|
run("latest", &out.LatestMovies, func(ctx context.Context) (*emby.ItemsResult, error) {
|
||||||
newReleaseDays := s.weightedConfig().NewReleaseDays
|
newReleaseDays := s.weightedConfig().NewReleaseDays
|
||||||
if newReleaseDays < 1 {
|
if newReleaseDays < 1 {
|
||||||
@@ -298,7 +315,7 @@ func (s *Server) handleHome(w http.ResponseWriter, r *http.Request, sess store.S
|
|||||||
wg.Wait()
|
wg.Wait()
|
||||||
upstream()
|
upstream()
|
||||||
|
|
||||||
if failures == 4 {
|
if rowAttempts > 0 && failures == rowAttempts {
|
||||||
writeError(w, http.StatusBadGateway, "could not reach the emby server")
|
writeError(w, http.StatusBadGateway, "could not reach the emby server")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -307,11 +324,15 @@ func (s *Server) handleHome(w http.ResponseWriter, r *http.Request, sess store.S
|
|||||||
// One row, not two: an episode finished a minute ago should be followed by the next
|
// One row, not two: an episode finished a minute ago should be followed by the next
|
||||||
// one at the front of Continue Watching rather than moving the show to a different
|
// one at the front of Continue Watching rather than moving the show to a different
|
||||||
// row. NextUp stays on the wire for televisions that predate the merge.
|
// row. NextUp stays on the wire for televisions that predate the merge.
|
||||||
out.ContinueWatching = mergeContinueWatching(out.ContinueWatching, out.NextUp, seriesPlayed)
|
if continueWatching {
|
||||||
|
out.ContinueWatching = mergeContinueWatching(out.ContinueWatching, out.NextUp, seriesPlayed)
|
||||||
|
}
|
||||||
// A current show with an episode today is more immediately useful than a long-lived
|
// A current show with an episode today is more immediately useful than a long-lived
|
||||||
// resume from an ended rewatch. Keep Emby's order inside both groups; this is a
|
// resume from an ended rewatch. Keep Emby's order inside both groups; this is a
|
||||||
// promotion, not a replacement ranking for Continue Watching.
|
// promotion, not a replacement ranking for Continue Watching.
|
||||||
out.ContinueWatching = prioritizeAiringTodayContinue(out.ContinueWatching, sonarrRow)
|
if continueWatching {
|
||||||
|
out.ContinueWatching = prioritizeAiringTodayContinue(out.ContinueWatching, sonarrRow)
|
||||||
|
}
|
||||||
|
|
||||||
// Recommendations are read from their own long-lived cache. A miss means this
|
// Recommendations are read from their own long-lived cache. A miss means this
|
||||||
// response ships without them and a rebuild starts in the background — the home
|
// response ships without them and a rebuild starts in the background — the home
|
||||||
@@ -321,7 +342,7 @@ func (s *Server) handleHome(w http.ResponseWriter, r *http.Request, sess store.S
|
|||||||
s.refreshRecommendationsInBackground(sess)
|
s.refreshRecommendationsInBackground(sess)
|
||||||
}
|
}
|
||||||
assemble := timing.Start(ctx, timing.StageRows)
|
assemble := timing.Start(ctx, timing.StageRows)
|
||||||
rows := baseRows(out)
|
rows := visibleBaseRows(out, continueWatching)
|
||||||
nearContinue := make([]recommend.Row, 0, len(forYouRows)+2)
|
nearContinue := make([]recommend.Row, 0, len(forYouRows)+2)
|
||||||
if len(forYouRows) > 0 {
|
if len(forYouRows) > 0 {
|
||||||
nearContinue = append(nearContinue, forYouRows...)
|
nearContinue = append(nearContinue, forYouRows...)
|
||||||
@@ -337,9 +358,13 @@ func (s *Server) handleHome(w http.ResponseWriter, r *http.Request, sess store.S
|
|||||||
nearContinue = append(nearContinue, *radarrRow)
|
nearContinue = append(nearContinue, *radarrRow)
|
||||||
}
|
}
|
||||||
if len(nearContinue) > 0 {
|
if len(nearContinue) > 0 {
|
||||||
// Personalised discovery and the upcoming schedule are most useful immediately
|
// Personalised discovery and the upcoming schedule are most useful at the start of
|
||||||
// after Continue Watching, before the broader library collections.
|
// Home: after Continue Watching when it is present, before broader collections.
|
||||||
rows = append(rows[:1], append(nearContinue, rows[1:]...)...)
|
insertAt := 0
|
||||||
|
if continueWatching {
|
||||||
|
insertAt = 1
|
||||||
|
}
|
||||||
|
rows = append(rows[:insertAt], append(nearContinue, rows[insertAt:]...)...)
|
||||||
}
|
}
|
||||||
out.Rows = append(rows, recommendations...)
|
out.Rows = append(rows, recommendations...)
|
||||||
out.Rows = s.filterRecommendationPermissions(ctx, sess, out.Rows)
|
out.Rows = s.filterRecommendationPermissions(ctx, sess, out.Rows)
|
||||||
@@ -733,6 +758,14 @@ func baseRows(h homeResponse) []recommend.Row {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func visibleBaseRows(h homeResponse, continueWatching bool) []recommend.Row {
|
||||||
|
rows := baseRows(h)
|
||||||
|
if continueWatching {
|
||||||
|
return rows
|
||||||
|
}
|
||||||
|
return rows[1:]
|
||||||
|
}
|
||||||
|
|
||||||
type homeForYouWindow struct {
|
type homeForYouWindow struct {
|
||||||
ID string
|
ID string
|
||||||
Title string
|
Title string
|
||||||
|
|||||||
@@ -33,6 +33,18 @@ func TestPersonalizeHomeRowsGraduallyDemotesIgnoredRows(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDisabledContinueWatchingIsNotRendered(t *testing.T) {
|
||||||
|
rows := visibleBaseRows(homeResponse{}, false)
|
||||||
|
for _, row := range rows {
|
||||||
|
if row.Kind == "continue" {
|
||||||
|
t.Fatal("disabled Continue Watching row was rendered")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(rows) != 2 {
|
||||||
|
t.Fatalf("base row count = %d, want favourites and latest only", len(rows))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestContinueWatchingPromotesShowsAiringToday(t *testing.T) {
|
func TestContinueWatchingPromotesShowsAiringToday(t *testing.T) {
|
||||||
raw := func(value string) json.RawMessage { return json.RawMessage(value) }
|
raw := func(value string) json.RawMessage { return json.RawMessage(value) }
|
||||||
items := []json.RawMessage{
|
items := []json.RawMessage{
|
||||||
|
|||||||
Reference in New Issue
Block a user