This commit is contained in:
ponzischeme89
2026-08-20 15:06:00 +12:00
parent 549f9c5eed
commit f1164db2c5
52 changed files with 5441 additions and 158 deletions
+1 -1
View File
@@ -63,7 +63,7 @@ val projectNoticeText =
// A release workflow can derive the app version from its Git tag without editing the
// source tree. Local builds keep using the checked-in default.
val defaultVersionName = "0.2.81"
val defaultVersionName = "0.2.82"
val membyVersionName: String =
(project.findProperty("memby.versionName") as String?)
?.trim()
@@ -8,6 +8,8 @@ import com.ponzischeme89.memby.data.model.GatewayCalendar
import com.ponzischeme89.memby.data.model.GatewayFlagRequest
import com.ponzischeme89.memby.data.model.GatewayDevice
import com.ponzischeme89.memby.data.model.GatewayDeviceNameRequest
import com.ponzischeme89.memby.data.model.MembyViewer
import com.ponzischeme89.memby.data.model.MembyViewerRequest
import com.ponzischeme89.memby.data.model.GatewayLoginRequest
import com.ponzischeme89.memby.data.model.GatewayPlaybackReport
import com.ponzischeme89.memby.data.model.GatewayPrerollSchedule
@@ -556,6 +558,98 @@ class EmbyRepository internal constructor(
}
}
// --- Viewers -------------------------------------------------------------
/**
* The people under this account, or an empty list where there is nobody to ask.
*
* Gateway-only, like the TV calendar and genre affinity: a viewer's state lives on the
* server, so with no gateway there is exactly one viewer and it is the account. Every
* failure answers empty rather than throwing — the picker is a convenience, and a
* household that cannot reach the gateway has larger problems than not being able to
* change who is watching.
*/
suspend fun viewers(): List<MembyViewer> {
if (!ServerConfig.isGateway) return emptyList()
val response = runCatching { requireGateway().viewers() }.getOrNull() ?: return emptyList()
// The gateway says whom it resolved this request to. A viewer deleted on another
// television is the case this exists for: this set is still sending an id nothing
// recognises, the gateway has quietly fallen back to the account, and without
// adopting that answer the picker would go on showing somebody who no longer
// exists as selected.
if (response.active != snapshot.activeViewerId &&
response.viewers.none { it.id == snapshot.activeViewerId }
) {
val resolved = response.viewers.firstOrNull { it.id == response.active }
settings.setActiveViewer(resolved?.id.orEmpty(), resolved?.name.orEmpty())
observedSettings = settings.snapshot()
}
return response.viewers
}
/**
* Changes who is watching on this television.
*
* Everything cleared here is cleared for the same reason a profile switch clears it:
* these caches hold one person's watched state, resume positions and reasons, and the
* next person must not inherit them. The persisted home cache is *not* cleared — it is
* keyed per viewer, so switching back is instant and each viewer keeps their own rows
* for the next cold start.
*
* Passing a blank id selects the account's own viewer, which is what the header's
* absence means to the gateway.
*/
suspend fun switchViewer(viewer: MembyViewer?) {
val id = if (viewer == null || viewer.isMain) "" else viewer.id
if (id == snapshot.activeViewerId) return
settings.setActiveViewer(id, viewer?.name.orEmpty())
observedSettings = settings.snapshot()
clearPlayableCache()
clearSeriesEpisodeCache()
clearLocalResume()
clearGenreAffinity()
}
suspend fun createViewer(name: String, shortName: String = "", colour: String = ""): MembyViewer? {
if (!ServerConfig.isGateway) return null
val trimmed = name.trim()
require(trimmed.isNotEmpty() && trimmed.length <= 40) { "Invalid viewer name" }
return runCatching {
requireGateway().createViewer(MembyViewerRequest(trimmed, shortName.trim(), colour.trim()))
}.getOrNull()
}
suspend fun renameViewer(viewer: MembyViewer, name: String): MembyViewer? {
if (!ServerConfig.isGateway) return null
val trimmed = name.trim()
require(trimmed.isNotEmpty() && trimmed.length <= 40) { "Invalid viewer name" }
return runCatching {
requireGateway().updateViewer(
viewer.id,
MembyViewerRequest(trimmed, viewer.shortName, viewer.colour),
)
}.getOrNull().also { updated ->
// The label this television prints follows the rename immediately rather than
// waiting for the next list request.
if (updated != null && viewer.id == snapshot.activeViewerId) {
settings.setActiveViewer(updated.id, updated.name)
observedSettings = settings.snapshot()
}
}
}
/**
* Removes a viewer and everything the gateway held for them. Removing whoever is
* watching returns this television to the account's own viewer — leaving it naming a
* person who no longer exists would send a header the gateway refuses on every request.
*/
suspend fun removeViewer(viewer: MembyViewer): Boolean {
if (!ServerConfig.isGateway || viewer.isMain) return false
val removed = runCatching { requireGateway().deleteViewer(viewer.id) }.isSuccess
if (removed && viewer.id == snapshot.activeViewerId) switchViewer(null)
return removed
}
/**
* False only when the gateway explicitly rejects the restored token with 401.
*
@@ -431,6 +431,25 @@ data class Settings(
* what is here rather than wait to be told.
*/
val preferencesRevision: Long = 0,
/**
* Which person under this account is watching on *this* television.
*
* Device state, deliberately, and the one thing about viewers that is: a viewer follows
* the person to every set in the house, but which of them is sitting in front of this
* one is that set's own answer — the lounge and the bedroom are commonly two different
* people at the same moment. Blank means the account's own viewer, which is what every
* television reported before viewers existed, so the header is simply not sent.
*
* Cleared with the session, because the people under one Emby account are not the
* people under another.
*/
val activeViewerId: String = "",
/**
* The active viewer's name, kept so the launcher can say whose evening it is without
* waiting on a request. Purely a label: [activeViewerId] is the identity, and the
* gateway is what validates it.
*/
val activeViewerName: String = "",
val profiles: List<EmbyProfile> = emptyList(),
) {
val isSignedIn: Boolean
@@ -591,6 +610,8 @@ class SettingsStore(private val context: Context) {
val UPDATE_ALERT_READ = booleanPreferencesKey("update_alert_read")
val REQUIRED_UPDATE_VERSION = stringPreferencesKey("required_update_version")
val PREFERENCES_REVISION = longPreferencesKey("preferences_revision")
val ACTIVE_VIEWER_ID = stringPreferencesKey("active_viewer_id")
val ACTIVE_VIEWER_NAME = stringPreferencesKey("active_viewer_name")
}
/**
@@ -647,6 +668,32 @@ class SettingsStore(private val context: Context) {
context.dataStore.edit { it[Keys.CONFIRM_EXIT_MEMBY] = enabled }
}
/**
* Records who is watching on this television.
*
* The id and the name are written in **one** edit, the rule
* [applyRemotePreferences] follows and for the same reason: DataStore rewrites the
* whole file per edit, and a name landing apart from the id it labels would leave the
* launcher greeting one person while every request named another.
*
* A blank id is how the account's own viewer is chosen — the header is then omitted
* entirely, which is what an app predating viewers sends and what the gateway reads as
* the main viewer.
*/
suspend fun setActiveViewer(viewerId: String, viewerName: String) {
val id = viewerId.trim()
val name = viewerName.trim().take(40)
context.dataStore.edit {
if (id.isEmpty()) {
it.remove(Keys.ACTIVE_VIEWER_ID)
it.remove(Keys.ACTIVE_VIEWER_NAME)
} else {
it[Keys.ACTIVE_VIEWER_ID] = id
it[Keys.ACTIVE_VIEWER_NAME] = name
}
}
}
/**
* Ids of alerts already shown on this TV. The gateway keeps offering an alert for as
* long as it is current, so without this an "aired" banner would return every poll —
@@ -1009,6 +1056,7 @@ class SettingsStore(private val context: Context) {
val profileKey = profileHomeCacheKey(
userId = preferences[Keys.USER_ID],
serverUrl = preferences[Keys.SERVER_URL],
viewerId = preferences[Keys.ACTIVE_VIEWER_ID],
)
if (profileKey == null) {
// No active profile to key against; the flat slot is all there is.
@@ -1028,21 +1076,48 @@ class SettingsStore(private val context: Context) {
/**
* Where a given profile's home cache is stored. Null when there is no active profile
* to key it against, in which case only the flat [Keys.HOME_CACHE] is written.
*
* The viewer is part of the key because the rows are theirs — a shadow viewer's
* Continue Watching is a different shelf from the account's, and a cold start drawing
* the cache before the first refresh lands would otherwise open on somebody else's
* evening. **The account's own viewer keys exactly as it always did**, with no suffix
* at all, so every existing install keeps the cache it already has.
*/
private fun profileHomeCacheKey(userId: String?, serverUrl: String?): Preferences.Key<String>? {
private fun profileHomeCacheKey(
userId: String?,
serverUrl: String?,
viewerId: String? = null,
): Preferences.Key<String>? {
if (userId.isNullOrBlank() || serverUrl.isNullOrBlank()) return null
return stringPreferencesKey("home_cache::$userId@$serverUrl")
val base = "home_cache::$userId@$serverUrl"
return stringPreferencesKey(
if (viewerId.isNullOrBlank()) base else "$base#$viewerId",
)
}
/** Every home-cache key belonging to one profile, across all of its viewers. */
private fun profileHomeCacheKeys(
preferences: Preferences,
userId: String?,
serverUrl: String?,
): List<Preferences.Key<String>> {
if (userId.isNullOrBlank() || serverUrl.isNullOrBlank()) return emptyList()
val base = "home_cache::$userId@$serverUrl"
return preferences.asMap().keys
.filter { it.name == base || it.name.startsWith("$base#") }
.map { stringPreferencesKey(it.name) }
}
/**
* The active profile's cached rows. The per-profile key is authoritative; the flat
* [Keys.HOME_CACHE] slot survives only for installs written before the split and for
* the case where there is no profile to key against.
* The active profile's cached rows, for whoever is watching. The per-profile key is
* authoritative; the flat [Keys.HOME_CACHE] slot survives only for installs written
* before the split and for the case where there is no profile to key against.
*/
private fun activeHomeCache(preferences: Preferences): String? =
profileHomeCacheKey(
userId = preferences[Keys.USER_ID],
serverUrl = preferences[Keys.SERVER_URL],
viewerId = preferences[Keys.ACTIVE_VIEWER_ID],
)?.let { preferences[it] } ?: preferences[Keys.HOME_CACHE]
suspend fun setForYouMinutes(minutes: Int) {
@@ -1314,9 +1389,11 @@ class SettingsStore(private val context: Context) {
val profiles = profilesFrom(preferences)
val removed = profiles.firstOrNull { it.id == profileId } ?: return@edit
writeProfiles(preferences, profiles.filterNot { it.id == profileId })
// Forget the departing profile's cached rows too; nothing will read that key
// again and it is the largest single value this store holds.
profileHomeCacheKey(removed.userId, removed.serverUrl)?.let(preferences::remove)
// Forget the departing profile's cached rows too — every viewer's, not only
// the account's; nothing will read those keys again and they are the largest
// single values this store holds.
profileHomeCacheKeys(preferences, removed.userId, removed.serverUrl)
.forEach(preferences::remove)
if (
preferences[Keys.USER_ID] == removed.userId &&
preferences[Keys.SERVER_URL] == removed.serverUrl
@@ -1346,7 +1423,8 @@ class SettingsStore(private val context: Context) {
it.userId == activeUserId && it.serverUrl == activeServer
}
writeProfiles(preferences, remaining)
profileHomeCacheKey(activeUserId, activeServer)?.let(preferences::remove)
profileHomeCacheKeys(preferences, activeUserId, activeServer)
.forEach(preferences::remove)
clearActiveSession(preferences)
}
}
@@ -1384,9 +1462,27 @@ class SettingsStore(private val context: Context) {
preferences.remove(Keys.PROFILE_INITIALS)
preferences.remove(Keys.SHORT_NAME)
preferences.remove(Keys.USERNAME)
// The people under one Emby account are not the people under another, and an id
// carried across would name somebody this account has never heard of. The gateway
// refuses it and falls back to the main viewer, so the consequence is cosmetic
// rather than a leak — but a television claiming to be Alessandra when it is
// signed into a different household is still wrong on its face.
clearActiveViewer(preferences)
}
private fun clearActiveViewer(preferences: MutablePreferences) {
preferences.remove(Keys.ACTIVE_VIEWER_ID)
preferences.remove(Keys.ACTIVE_VIEWER_NAME)
}
private fun applyProfile(preferences: MutablePreferences, profile: EmbyProfile) {
// Switching account resets who is watching, for the same reason clearing the
// session does: the viewer list belongs to the account being left.
if (preferences[Keys.USER_ID] != profile.userId ||
preferences[Keys.SERVER_URL] != profile.serverUrl
) {
clearActiveViewer(preferences)
}
preferences[Keys.SERVER_URL] = profile.serverUrl
preferences[Keys.TOKEN] = profile.token
preferences[Keys.USER_ID] = profile.userId
@@ -1558,6 +1654,8 @@ class SettingsStore(private val context: Context) {
updateAlertRead = preferences[Keys.UPDATE_ALERT_READ] ?: false,
requiredUpdateVersion = preferences[Keys.REQUIRED_UPDATE_VERSION],
preferencesRevision = preferences[Keys.PREFERENCES_REVISION] ?: 0,
activeViewerId = preferences[Keys.ACTIVE_VIEWER_ID].orEmpty(),
activeViewerName = preferences[Keys.ACTIVE_VIEWER_NAME].orEmpty(),
profiles = profiles,
)
}
@@ -87,6 +87,60 @@ data class GatewayTrailerReport(
@Serializable
data class GatewayDeviceNameRequest(val deviceName: String)
/**
* One person under this Memby account.
*
* A device is a television and a profile is a signed-in Emby account; a *viewer* is one of
* the people using them. The main viewer's state is Emby's and its id is the Emby user id,
* which is why nothing here needs a separate notion of "the account's own viewer" — it is
* simply the one whose [kind] is `main`.
*/
@Serializable
data class MembyViewer(
val id: String = "",
val name: String = "",
val shortName: String = "",
val colour: String = "",
val kind: String = "",
val hasPin: Boolean = false,
val createdAt: String = "",
) {
/** True when this viewer's watching is published to Emby. */
val isMain: Boolean get() = kind == KIND_MAIN
/**
* What the picker draws in the avatar. Emby usernames are commonly one word, so the
* first letter alone is what distinguishes them at three metres; a short name the
* household set is preferred because it is the thing they chose to be called.
*/
val initials: String
get() = (shortName.takeIf(String::isNotBlank) ?: name)
.trim()
.takeIf(String::isNotEmpty)
?.take(1)
?.uppercase()
.orEmpty()
companion object {
const val KIND_MAIN = "main"
const val KIND_SHADOW = "shadow"
}
}
@Serializable
data class MembyViewers(
val viewers: List<MembyViewer> = emptyList(),
/** Whom the gateway resolved this request to, which is the answer this TV adopts. */
val active: String = "",
)
@Serializable
data class MembyViewerRequest(
val name: String,
val shortName: String = "",
val colour: String = "",
)
/**
* One horizontal strip, described entirely by the server.
*
@@ -24,6 +24,9 @@ import com.ponzischeme89.memby.data.model.GatewayRows
import com.ponzischeme89.memby.data.model.GatewayRequestLookup
import com.ponzischeme89.memby.data.model.GatewayServiceStatus
import com.ponzischeme89.memby.data.model.GatewayUpdate
import com.ponzischeme89.memby.data.model.MembyViewer
import com.ponzischeme89.memby.data.model.MembyViewerRequest
import com.ponzischeme89.memby.data.model.MembyViewers
import com.ponzischeme89.memby.data.model.RecommendationOnboarding
import com.ponzischeme89.memby.data.model.RecommendationPreferences
import com.ponzischeme89.memby.data.model.UserItemData
@@ -66,6 +69,26 @@ interface GatewayApi {
@Body body: GatewayDeviceNameRequest,
)
/**
* The people under this account. `active` is the gateway's own answer about whom this
* request resolved to, which is what makes a viewer this television no longer has —
* deleted on another set — visibly fall back to the main viewer rather than silently.
*/
@GET("v1/viewers")
suspend fun viewers(): MembyViewers
@POST("v1/viewers")
suspend fun createViewer(@Body body: MembyViewerRequest): MembyViewer
@PUT("v1/viewers/{viewerId}")
suspend fun updateViewer(
@Path("viewerId") viewerId: String,
@Body body: MembyViewerRequest,
): MembyViewer
@DELETE("v1/viewers/{viewerId}")
suspend fun deleteViewer(@Path("viewerId") viewerId: String)
@GET("v1/home")
suspend fun home(@Query("limit") limit: Int): GatewayHome
@@ -119,12 +119,31 @@ private class GatewayAuthInterceptor(private val tokenProvider: () -> String?) :
audioCapabilityTokens()
).joinToString(","),
)
activeViewerId()?.let { builder.header(VIEWER_HEADER, it) }
tokenProvider()?.takeIf { it.isNotBlank() }?.let {
builder.header("Authorization", "Bearer $it")
}
return chain.proceed(builder.build())
}
/**
* Who is watching, as distinct from which account is streaming.
*
* Read on each request rather than captured, for the reason the audio tokens are:
* switching between two people on one television must not rebuild the HTTP client, and
* the answer can change at any moment between two requests. The header is *omitted*
* rather than sent empty when nobody has been chosen — the gateway states that an
* absent header means the account's own viewer, and an empty one would be a value it
* has to have an opinion about.
*
* Guarded like the audio tokens, because this interceptor also runs before the service
* locator exists in a screenshot or instrumentation context, where throwing here would
* fail the request rather than merely decline to name a viewer.
*/
private fun activeViewerId(): String? = runCatching {
ServiceLocator.settings.current?.activeViewerId?.takeIf(String::isNotBlank)
}.getOrNull()
/**
* What this set can put through its speakers, resolved against the viewer's own
* passthrough choice — so a manual override reaches the *server's* device profile too,
@@ -160,6 +179,9 @@ private object RequiredUpdateInterceptor : Interceptor {
}
}
/** Names the person watching. See `X-Memby-Viewer` in the gateway's `api/viewers.go`. */
internal const val VIEWER_HEADER = "X-Memby-Viewer"
internal const val MEMBY_PROTOCOL_VERSION = 1
internal val MEMBY_CAPABILITIES = listOf(
@@ -190,6 +212,10 @@ internal val MEMBY_CAPABILITIES = listOf(
// "processing", the single word the whole span used to be, so an older television reads
// its request page exactly as it always did.
"request_progress_v1",
// Declares that this build can draw the viewer picker and sends X-Memby-Viewer. An
// older app never receives the feature, so the operator cannot switch on a household
// of people that half its televisions have no way of choosing between.
"viewers_v1",
)
internal const val HEVC_DECODE_CAPABILITY = "video_hevc_decode"
@@ -1,5 +1,6 @@
package com.ponzischeme89.memby.ui
import com.ponzischeme89.memby.ui.viewers.viewerMenuLabel
import com.ponzischeme89.memby.ui.theme.MembyIcon
import com.ponzischeme89.memby.ui.theme.mark
import androidx.compose.animation.animateColorAsState
@@ -481,13 +482,24 @@ fun UserSwitcherOverlay(
*/
showRequests: Boolean = false,
onOpenRequests: () -> Unit = {},
/**
* Whether this household has anybody to choose between. False hides the row entirely,
* for the same reason [showRequests] does: on the direct path there is nobody to ask,
* and an account nobody has added a viewer to would be offered a question with one
* answer. See `shouldOfferViewerPicker`.
*/
showViewers: Boolean = false,
activeViewerId: String = "",
activeViewerName: String = "",
onOpenViewers: () -> Unit = {},
) {
val profileIds = profiles.map(EmbyProfile::id)
val actionCount = userSwitcherActionCount(showRequests)
val menuItems = userSwitcherMenuItems(showRequests, showViewers)
val actionCount = menuItems.size
// Re-keyed on the action count as well as the profiles: a permission arriving on a poll
// while this menu is open changes how many rows there are, and a requester list of the
// old length would leave the new row unfocusable.
val focusRequesters = remember(profileIds, actionCount) {
val focusRequesters = remember(profileIds, menuItems) {
List(profiles.size + actionCount) { FocusRequester() }
}
val profileListState = remember(profileIds) { LazyListState() }
@@ -571,7 +583,11 @@ fun UserSwitcherOverlay(
modifier = Modifier.padding(start = 8.dp, top = 7.dp, end = 8.dp, bottom = 5.dp),
)
Text(
"Choose whos watching",
// With viewers in play this list is *accounts*, and the row below it is the
// person — so the panel must stop claiming to be the thing that answers
// "who is watching" when there is now a control directly beneath it that
// does. A household running no viewers keeps the wording it always had.
if (showViewers) "Choose an account" else "Choose whos watching",
color = QuietText,
fontSize = 12.sp,
modifier = Modifier.padding(horizontal = 8.dp).padding(bottom = 8.dp),
@@ -608,69 +624,61 @@ fun UserSwitcherOverlay(
.background(Color.White.copy(alpha = 0.07f)),
)
Spacer(Modifier.height(6.dp))
// Notifications live here rather than on the launcher: these belong to a
// person and follow them between televisions, so the menu that already answers
// "who is watching" is where somebody looks for their own news. The badge is
// what replaces the bell that used to sit in the corner of Home.
UserSwitcherAction(
label = "Notifications",
icon = MembyIcon.Notification.mark,
badge = alertBadgeLabel(alertCount),
modifier = Modifier
.focusRequester(focusRequesters[profiles.size])
.onFocusChanged {
if (it.isFocused) focusedIndex = profiles.size
},
onClick = onOpenAlerts,
)
// Requests sits beside Notifications because both are personal activity.
if (showRequests) {
UserSwitcherAction(
label = "Requests",
icon = MembyIcon.PlaylistAdd.mark,
modifier = Modifier
.focusRequester(focusRequesters[profiles.size + 1])
.onFocusChanged {
if (it.isFocused) focusedIndex = profiles.size + 1
},
onClick = onOpenRequests,
)
// Every row's index comes from its position in [menuItems] rather than being
// written out here. The four hand-computed offsets this replaced were what made
// adding a fifth conditional row unsafe.
menuItems.forEachIndexed { offset, item ->
val index = profiles.size + offset
val modifier = Modifier
.focusRequester(focusRequesters[index])
.onFocusChanged { if (it.isFocused) focusedIndex = index }
when (item) {
// Who is watching sits above the rest because it changes whose menu
// this is: the notifications and requests below belong to whichever
// viewer it selects.
UserSwitcherMenuItem.VIEWERS -> UserSwitcherAction(
label = viewerMenuLabel(activeViewerId, activeViewerName),
icon = MembyIcon.Person.mark,
modifier = modifier,
onClick = onOpenViewers,
)
// Notifications live here rather than on the launcher: these belong to
// a person and follow them between televisions, so the menu that
// already answers "who is watching" is where somebody looks for their
// own news. The badge is what replaces the bell that used to sit in the
// corner of Home.
UserSwitcherMenuItem.NOTIFICATIONS -> UserSwitcherAction(
label = "Notifications",
icon = MembyIcon.Notification.mark,
badge = alertBadgeLabel(alertCount),
modifier = modifier,
onClick = onOpenAlerts,
)
// Requests sits beside Notifications because both are personal activity.
UserSwitcherMenuItem.REQUESTS -> UserSwitcherAction(
label = "Requests",
icon = MembyIcon.PlaylistAdd.mark,
modifier = modifier,
onClick = onOpenRequests,
)
UserSwitcherMenuItem.SETTINGS -> UserSwitcherAction(
label = "Settings",
icon = MembyIcon.Settings.mark,
modifier = modifier,
onClick = onOpenSettings,
)
UserSwitcherMenuItem.MANAGE_USERS -> UserSwitcherAction(
label = "Manage users",
icon = MembyIcon.Grid.mark,
modifier = modifier,
onClick = onManageProfiles,
)
}
}
val settingsIndex = profiles.size + if (showRequests) 2 else 1
UserSwitcherAction(
label = "Settings",
icon = MembyIcon.Settings.mark,
modifier = Modifier
.focusRequester(focusRequesters[settingsIndex])
.onFocusChanged {
if (it.isFocused) focusedIndex = settingsIndex
},
onClick = onOpenSettings,
)
val manageIndex = profiles.size + actionCount - 1
UserSwitcherAction(
label = "Manage users",
icon = MembyIcon.Person.mark,
modifier = Modifier
.focusRequester(focusRequesters[manageIndex])
.onFocusChanged {
if (it.isFocused) focusedIndex = manageIndex
},
onClick = onManageProfiles,
)
}
}
}
/**
* Notifications, then Requests when this viewer may make them, Settings, then Manage users.
*
* Pure and derived in one place because three things read it — the requester list's length,
* the D-pad's lower bound and Manage users' own index — and a count that disagreed with the
* rows actually drawn is how the last item in a menu becomes unreachable.
*/
internal fun userSwitcherActionCount(showRequests: Boolean): Int = if (showRequests) 4 else 3
@Composable
private fun UserSwitcherProfileItem(
profile: EmbyProfile,
@@ -127,6 +127,7 @@ import com.ponzischeme89.memby.data.friendlyEmbyError
import com.ponzischeme89.memby.data.analytics.PlaybackJourney
import com.ponzischeme89.memby.data.analytics.playbackEntryPointFor
import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.data.model.MembyViewer
import com.ponzischeme89.memby.data.model.GatewayUpdate
import com.ponzischeme89.memby.data.model.HomeRow
import com.ponzischeme89.memby.data.model.MyShow
@@ -169,6 +170,13 @@ import com.ponzischeme89.memby.ui.theme.MembyOnSurface
import com.ponzischeme89.memby.ui.theme.MembyOutline
import com.ponzischeme89.memby.ui.theme.MembyQuietText
import com.ponzischeme89.memby.ui.theme.MembySplashTint
import com.ponzischeme89.memby.ui.viewers.MAX_SHADOW_VIEWERS
import com.ponzischeme89.memby.ui.viewers.ViewerManageScreen
import com.ponzischeme89.memby.ui.viewers.ViewerNameEntry
import com.ponzischeme89.memby.ui.viewers.ViewerNameTarget
import com.ponzischeme89.memby.ui.viewers.ViewerPicker
import com.ponzischeme89.memby.ui.viewers.shouldOfferViewerPicker
import com.ponzischeme89.memby.ui.viewers.viewerNameFor
import com.ponzischeme89.memby.ui.theme.MembySurface
import com.ponzischeme89.memby.ui.theme.MembySurfaceRaised
import com.ponzischeme89.memby.ui.theme.MembyTheme
@@ -1975,6 +1983,35 @@ private fun HomeScreen(
) + notificationState.notifications
var showNotifications by remember { mutableStateOf(false) }
var showRequests by remember { mutableStateOf(false) }
// The people under this account. Fetched once the launcher is up rather than on the
// critical path: nothing on the signed-in path may block on a request, and until the
// answer lands this television watches as whoever it watched as last — which is the
// right answer far more often than not.
var viewers by remember { mutableStateOf<List<MembyViewer>>(emptyList()) }
var showViewerPicker by remember { mutableStateOf(false) }
// Managing viewers is a stack over the picker rather than a replacement for it — the
// arrangement the "add another user" sign-in already takes over the manage-users page:
// cancelling a name comes straight back to the list the button was pressed from, with
// nothing to restore because nothing was ever unmounted.
var showViewerManage by remember { mutableStateOf(false) }
var viewerNameTarget by remember { mutableStateOf<ViewerNameTarget?>(null) }
var viewerName by remember { mutableStateOf("") }
var viewerNameFailure by remember { mutableStateOf<String?>(null) }
var viewerSaving by remember { mutableStateOf(false) }
var viewerBusyId by remember { mutableStateOf<String?>(null) }
LaunchedEffect(settings.userId, settings.serverUrl) {
viewers = if (settings.isSignedIn) repo.viewers() else emptyList()
}
// One place the list is re-read, so every mutation ends the same way and none of them
// has to work out what the answer should now be. The gateway is the thing that knows.
val refreshViewers: suspend () -> Unit = {
viewers = runCatching { repo.viewers() }.getOrDefault(viewers)
}
val openViewerName: (ViewerNameTarget) -> Unit = { target ->
viewerName = viewerNameFor(target)
viewerNameFailure = null
viewerNameTarget = target
}
// Two different things: [launchingItem] is the gate that stops a second Play press
// stacking a second player, and stays shut until one comes back. [resolvingItem] is
// the loading screen, and belongs only to a launch that is waiting on the server.
@@ -3398,6 +3435,14 @@ private fun HomeScreen(
notificationsLoading = false
}
},
showViewers = shouldOfferViewerPicker(ServerConfig.isGateway, viewers.size),
activeViewerId = settings.activeViewerId,
activeViewerName = settings.activeViewerName,
onOpenViewers = {
userSwitcherVisible = false
navigationExpanded = false
showViewerPicker = true
},
showRequests = requestsAllowed,
onOpenRequests = {
homeViewModel.trackJourney(
@@ -3417,6 +3462,106 @@ private fun HomeScreen(
},
)
}
if (showViewerPicker) {
val closeViewerPicker: () -> Unit = {
showViewerPicker = false
scope.launch {
kotlinx.coroutines.delay(16L)
runCatching { navigationFocusRequester.requestFocus() }
}
}
BackHandler(onBack = closeViewerPicker)
Box(Modifier.fillMaxSize().zIndex(20f).background(MembySurface)) {
ViewerPicker(
viewers = viewers,
activeViewerId = settings.activeViewerId,
onViewerSelected = { viewer ->
closeViewerPicker()
scope.launch {
// Everything on the launcher belongs to the outgoing viewer, so
// the journey is closed and the rows are refreshed rather than
// left standing under a different person's name.
homeViewModel.endJourneyBeforeProfileSwitch(journeyScreen)
repo.switchViewer(viewer)
homeViewModel.refreshAll()
}
},
// Both open over the picker rather than instead of it, so Back is one
// step out of each and the row of faces is still underneath.
onAddViewer = { openViewerName(ViewerNameTarget.Add) },
onManageViewers = { showViewerManage = true },
canAddViewer = viewers.count { !it.isMain } < MAX_SHADOW_VIEWERS,
)
}
}
if (showViewerManage) {
BackHandler(onBack = { showViewerManage = false })
Box(Modifier.fillMaxSize().zIndex(21f).background(MembySurface)) {
ViewerManageScreen(
viewers = viewers,
onRename = { openViewerName(ViewerNameTarget.Rename(it)) },
onRemove = { viewer ->
viewerBusyId = viewer.id
scope.launch {
// Removing whoever is watching returns this set to the account,
// which the repository does; the launcher has to be told, or it
// goes on drawing the removed person's rows.
val watching = viewer.id == settings.activeViewerId
val removed = repo.removeViewer(viewer)
if (removed && watching) {
homeViewModel.endJourneyBeforeProfileSwitch(journeyScreen)
homeViewModel.refreshAll()
}
refreshViewers()
viewerBusyId = null
}
},
onAdd = { openViewerName(ViewerNameTarget.Add) },
onClose = { showViewerManage = false },
canAddViewer = viewers.count { !it.isMain } < MAX_SHADOW_VIEWERS,
busyViewerId = viewerBusyId,
)
}
}
viewerNameTarget?.let { target ->
val closeViewerName = { viewerNameTarget = null }
BackHandler(onBack = closeViewerName)
Box(Modifier.fillMaxSize().zIndex(22f).background(MembySurface)) {
ViewerNameEntry(
target = target,
name = viewerName,
existing = viewers,
onNameChanged = { viewerName = it; viewerNameFailure = null },
onCancel = closeViewerName,
onConfirm = {
if (!viewerSaving) {
viewerSaving = true
scope.launch {
val saved = when (target) {
ViewerNameTarget.Add -> repo.createViewer(viewerName)
is ViewerNameTarget.Rename ->
repo.renameViewer(target.viewer, viewerName)
}
viewerSaving = false
if (saved == null) {
// The one thing the television can say about a refusal
// it has no wording for. The screen stays up holding
// what was typed, because retyping a name somebody has
// just entered is the worst possible answer to a
// request that failed for a reason nothing here knows.
viewerNameFailure = "That could not be saved. Try again."
} else {
viewerNameTarget = null
refreshViewers()
}
}
}
},
failure = viewerNameFailure,
saving = viewerSaving,
)
}
}
if (userQuickActionsVisible) {
val closeUserQuickActions: () -> Unit = {
userQuickActionsVisible = false
@@ -26,3 +26,32 @@ internal fun userSwitcherNextIndex(
UserSwitcherDirection.DOWN -> (current + 1).coerceAtMost(lastIndex)
}
}
/**
* The pinned rows below the profile list, in the order they are drawn.
*
* It is a list rather than four pieces of arithmetic because five things read it the
* requester list's length, the D-pad's lower bound, and each row's own index and every
* one of those was previously written out by hand (`profiles.size + 1`,
* `if (showRequests) 2 else 1`, `actionCount - 1`). A count that disagreed with the rows
* actually drawn is how the last item in a menu becomes unreachable, and a fifth
* conditional row is exactly the change that breaks it. This is the shape `QuickAction`
* already moved to for the same reason.
*/
internal enum class UserSwitcherMenuItem { VIEWERS, NOTIFICATIONS, REQUESTS, SETTINGS, MANAGE_USERS }
/**
* Who's watching first, because it changes *whose* menu this is: the notifications and
* requests below it belong to whichever viewer it selects, so offering it after them would
* put the answer below the things that depend on it.
*/
internal fun userSwitcherMenuItems(
showRequests: Boolean,
showViewers: Boolean = false,
): List<UserSwitcherMenuItem> = buildList {
if (showViewers) add(UserSwitcherMenuItem.VIEWERS)
add(UserSwitcherMenuItem.NOTIFICATIONS)
if (showRequests) add(UserSwitcherMenuItem.REQUESTS)
add(UserSwitcherMenuItem.SETTINGS)
add(UserSwitcherMenuItem.MANAGE_USERS)
}
@@ -3,6 +3,8 @@ package com.ponzischeme89.memby.ui.theme
import com.composables.icons.fontawesome.FontAwesome
import com.composables.icons.fontawesome.solid.AngleDoubleLeft
import com.composables.icons.fontawesome.solid.ArrowDown
import com.composables.icons.fontawesome.solid.Pen
import com.composables.icons.fontawesome.solid.TrashAlt
import com.composables.icons.fontawesome.solid.ArrowLeft
import com.composables.icons.fontawesome.solid.ArrowRight
import com.composables.icons.fontawesome.solid.ArrowUp
@@ -100,6 +102,8 @@ internal val fontAwesomeIconPack = MembyIconPack(
MembyIcon.Add to { FontAwesome.Solid.Plus },
MembyIcon.Close to { FontAwesome.Solid.Times },
MembyIcon.Refresh to { FontAwesome.Solid.Sync },
MembyIcon.Rename to { FontAwesome.Solid.Pen },
MembyIcon.Remove to { FontAwesome.Solid.TrashAlt },
MembyIcon.ChevronLeft to { FontAwesome.Solid.ChevronLeft },
MembyIcon.ChevronRight to { FontAwesome.Solid.ChevronRight },
MembyIcon.ChevronDown to { FontAwesome.Solid.ChevronDown },
@@ -13,6 +13,8 @@ import com.composables.icons.lucide.Building2
import com.composables.icons.lucide.CalendarClock
import com.composables.icons.lucide.CalendarDays
import com.composables.icons.lucide.Check
import com.composables.icons.lucide.Pencil
import com.composables.icons.lucide.Trash2
import com.composables.icons.lucide.ChevronDown
import com.composables.icons.lucide.ChevronLeft
import com.composables.icons.lucide.ChevronRight
@@ -100,6 +102,8 @@ internal val lucideIconPack = MembyIconPack(
MembyIcon.Add to { Lucide.Plus },
MembyIcon.Close to { Lucide.X },
MembyIcon.Refresh to { Lucide.RefreshCw },
MembyIcon.Rename to { Lucide.Pencil },
MembyIcon.Remove to { Lucide.Trash2 },
MembyIcon.ChevronLeft to { Lucide.ChevronLeft },
MembyIcon.ChevronRight to { Lucide.ChevronRight },
MembyIcon.ChevronDown to { Lucide.ChevronDown },
@@ -24,8 +24,10 @@ import androidx.compose.material.icons.filled.ChevronLeft
import androidx.compose.material.icons.filled.ChevronRight
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Devices
import androidx.compose.material.icons.filled.DeleteOutline
import androidx.compose.material.icons.filled.DoneAll
import androidx.compose.material.icons.filled.Event
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.Favorite
import androidx.compose.material.icons.filled.FavoriteBorder
import androidx.compose.material.icons.filled.FirstPage
@@ -118,6 +120,8 @@ object MaterialIconPack {
MembyIcon.Add to { Icons.Default.Add },
MembyIcon.Close to { Icons.Default.Close },
MembyIcon.Refresh to { Icons.Default.Refresh },
MembyIcon.Rename to { Icons.Default.Edit },
MembyIcon.Remove to { Icons.Default.DeleteOutline },
MembyIcon.ChevronLeft to { Icons.Default.ChevronLeft },
MembyIcon.ChevronRight to { Icons.Default.ChevronRight },
MembyIcon.ChevronDown to { Icons.Default.KeyboardArrowDown },
@@ -53,6 +53,12 @@ enum class MembyIcon {
Close,
Refresh,
// Editing a thing rather than acting on media: the manage-viewers list is what needed
// them, and they are named for the job so a pack answering with a pencil and a pack
// answering with a pen both sit under a name that stays true.
Rename,
Remove,
// Movement
ChevronLeft,
ChevronRight,
@@ -0,0 +1,108 @@
package com.ponzischeme89.memby.ui.viewers
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
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.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.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.tv.material3.Icon
import androidx.tv.material3.Text
import com.ponzischeme89.memby.ui.theme.MembyAccent
import com.ponzischeme89.memby.ui.theme.MembyAccentInk
import com.ponzischeme89.memby.ui.theme.MembyControlSurface
import com.ponzischeme89.memby.ui.theme.MembyDisabledText
import com.ponzischeme89.memby.ui.theme.MembyIcon
import com.ponzischeme89.memby.ui.theme.MembyMutedText
import com.ponzischeme89.memby.ui.theme.MembyOutline
import com.ponzischeme89.memby.ui.theme.MembyPanelCorner
import com.ponzischeme89.memby.ui.theme.mark
/**
* The one button the three viewer screens are built from.
*
* There were nearly three of it the picker's Add and Manage, the manage list's Rename and
* Remove, and the name screen's confirm and cancel all the same shape at the same size in
* the same green, written on three different days. This is the rule the rest of the app
* already follows for Play (`ui/MembyButtons.kt`) and for detail-page cards
* (`Modifier.detailCardFocus`): one language per kind of control, or the copies drift and a
* viewer notices before anybody else does.
*
* [emphasised] marks the answer the screen is *for* adding the person, saving the name
* so that on a row of two the destructive or the neutral one is never the one wearing the
* accent. It is a quiet outline the rest of the time, which is the same distinction
* `ExitConfirmation` draws between staying and closing.
*/
@Composable
internal fun ViewerActionButton(
label: String,
icon: MembyIcon,
onClick: () -> Unit,
modifier: Modifier = Modifier,
emphasised: Boolean = false,
enabled: Boolean = true,
/**
* Draws the button as though the remote were on it. Robolectric's window never takes
* focus and the ring is the whole of what says which answer a press would take, so a
* capture of a two-answer row would otherwise prove nothing the flag `ExitConfirmation`
* carries, for the same reason.
*/
focusedForCapture: Boolean = false,
) {
var hasFocus by remember { mutableStateOf(false) }
val focused = hasFocus || focusedForCapture
val shape = RoundedCornerShape(MembyPanelCorner)
// Disabled is drawn rather than removed only where the control is the point of the
// screen: the confirm button on the name screen is what explains what to do next, and
// a button that appeared once enough had been typed would move the row under a thumb.
// Everywhere the control is optional it is removed instead — see the picker's Add.
val background = when {
!enabled -> Color.Transparent
focused -> MembyAccent
emphasised -> MembyAccent.copy(alpha = 0.16f)
else -> MembyControlSurface
}
val content = when {
!enabled -> MembyDisabledText
focused -> MembyAccentInk
emphasised -> MembyAccent
else -> MembyMutedText
}
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = modifier
.clip(shape)
.background(background)
// The outline survives being disabled: without it a dimmed button has no fill and
// no edge, and reads as a label somebody forgot to finish rather than as a control
// waiting for a name to be typed.
.border(1.dp, if (focused) Color.Transparent else MembyOutline, shape)
.onFocusChanged { hasFocus = it.isFocused }
.clickable(enabled = enabled, onClick = onClick)
.padding(horizontal = 20.dp, vertical = 12.dp),
) {
Icon(
imageVector = icon.mark,
contentDescription = null,
tint = content,
modifier = Modifier.size(20.dp),
)
Spacer(Modifier.width(10.dp))
Text(text = label, color = content, fontSize = 15.sp)
}
}
@@ -0,0 +1,116 @@
package com.ponzischeme89.memby.ui.viewers
import com.ponzischeme89.memby.data.model.MembyViewer
/**
* The rules behind naming a person, kept pure so they can be tested without a television.
*
* Naming is the whole of what this feature needed and did not have: the gateway's routes
* and the repository's calls have existed since viewers shipped, and both the Add and the
* Manage controls opened the *account* list instead, because a name has to be typed and
* this app has one text-entry idiom the search keyboard that nothing else reused.
*/
/**
* The longest name the gateway will accept, in characters rather than bytes, because that
* is what it counts in runes on the other side.
*
* It is enforced here by *refusing the keypress* rather than by rejecting the save. A
* remote types one character at a time and a limit that only announces itself at the end
* is one somebody discovers after typing a sentence.
*/
internal const val MAX_VIEWER_NAME_LENGTH = 40
/** What the name-entry screen is being opened for. */
internal sealed interface ViewerNameTarget {
/** A new person under this account. */
data object Add : ViewerNameTarget
/** Renaming somebody who is already here. */
data class Rename(val viewer: MembyViewer) : ViewerNameTarget
}
/** The name the screen opens holding: empty for a new person, their own for a rename. */
internal fun viewerNameFor(target: ViewerNameTarget): String = when (target) {
ViewerNameTarget.Add -> ""
is ViewerNameTarget.Rename -> target.viewer.name
}
internal fun viewerNameHeading(target: ViewerNameTarget): String = when (target) {
ViewerNameTarget.Add -> "Who is watching?"
is ViewerNameTarget.Rename -> "Rename ${target.viewer.name}"
}
/**
* The button names the *outcome*, not the screen "Add viewer" rather than "Save",
* because on a television the label under the focus ring is commonly the only thing
* saying what a confirm press is about to do.
*/
internal fun viewerNameAction(target: ViewerNameTarget): String = when (target) {
ViewerNameTarget.Add -> "Add viewer"
is ViewerNameTarget.Rename -> "Save name"
}
/**
* Adds one typed character, or refuses it at the limit.
*
* Returning the unchanged name is what makes the refusal silent, which is the right
* failure here: the keyboard is on screen, the name is on screen above it, and a key that
* simply stops writing is self-explanatory in a way an error message about a maximum is
* not.
*/
internal fun viewerNameWith(current: String, typed: String): String =
if (current.length + typed.length > MAX_VIEWER_NAME_LENGTH) current else current + typed
/**
* What is wrong with this name, or null when nothing is.
*
* Two refusals and they fail for different reasons. A blank name is refused because the
* gateway refuses it, and a card with no name on it is not a person anybody could pick.
* A **repeated** name is refused by this app alone the gateway is perfectly happy to
* hold two people called Sam because the picker is a row of faces with a name under
* each, and two identical names is a choice nobody in the household can make.
*
* The comparison ignores case and surrounding space, since "sam" and "Sam " are the same
* answer to "who is this", and a rename skips the person being renamed so that correcting
* somebody's capitalisation is not refused as a duplicate of themselves.
*/
internal fun viewerNameError(
name: String,
existing: List<MembyViewer>,
target: ViewerNameTarget,
): String? {
val trimmed = name.trim()
if (trimmed.isEmpty()) return "Type a name first."
val renaming = (target as? ViewerNameTarget.Rename)?.viewer?.id
val clash = existing.any { it.id != renaming && it.name.trim().equals(trimmed, ignoreCase = true) }
return if (clash) "There is already somebody called $trimmed." else null
}
/** Whether the confirm button does anything yet. */
internal fun viewerNameSubmittable(
name: String,
existing: List<MembyViewer>,
target: ViewerNameTarget,
): Boolean = viewerNameError(name, existing, target) == null
/**
* The people the manage screen can actually change.
*
* The main viewer is listed but never editable: its name is the Emby account's and belongs
* to Emby, which is why `store.UpdateShadowViewer` refuses it and why removing it is not
* offered at all an account with no main viewer has nothing left to fall back to.
*/
internal fun viewerIsEditable(viewer: MembyViewer): Boolean = !viewer.isMain
/**
* Where the manage list puts focus after somebody is removed.
*
* The row that took their place, falling back to the last row when the one removed was at
* the end, and to nothing at all when the list has emptied the rule
* [profileFocusIndexAfterRemoval][com.ponzischeme89.memby.ui.profileFocusIndexAfterRemoval]
* 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.
*/
internal fun viewerFocusIndexAfterRemoval(removedIndex: Int, remainingCount: Int): Int? =
if (remainingCount <= 0) null else removedIndex.coerceIn(0, remainingCount - 1)
@@ -0,0 +1,324 @@
package com.ponzischeme89.memby.ui.viewers
import androidx.activity.compose.BackHandler
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.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.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
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.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
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.tv.material3.Text
import com.ponzischeme89.memby.data.model.MembyViewer
import com.ponzischeme89.memby.ui.distinctForKeys
import com.ponzischeme89.memby.ui.theme.MembyAccent
import com.ponzischeme89.memby.ui.theme.MembyControlSurface
import com.ponzischeme89.memby.ui.theme.MembyControlSurfaceRaised
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.MembySurface
/**
* The people under this account, and what can be done about them.
*
* It is a *list* where the picker is a row of faces, because the two answer different
* questions. The picker asks "who is watching", which is a glance and one press; this asks
* "who is here", which is read one line at a time and acted on per person the same
* distinction the alerts page draws between a badge and its inbox.
*
* Stateless but for the remote's own business: which row a removal is aimed at, and where
* focus goes when that row disappears. The caller owns the list and the requests.
*/
@Composable
fun ViewerManageScreen(
viewers: List<MembyViewer>,
onRename: (MembyViewer) -> Unit,
onRemove: (MembyViewer) -> Unit,
onAdd: () -> Unit,
onClose: () -> Unit,
modifier: Modifier = Modifier,
canAddViewer: Boolean = true,
/** The viewer a request is in flight for, so a second press cannot start a second one. */
busyViewerId: String? = null,
) {
// Ids come off a wire and a keyed list throws on a repeat — see ui/ListKeys.kt.
val people = remember(viewers) { viewers.distinctForKeys(MembyViewer::id) }
var pendingRemoval by remember { mutableStateOf<MembyViewer?>(null) }
var removalReturnIndex by remember { mutableStateOf<Int?>(null) }
val renameFocus = remember(people) { List(people.size) { FocusRequester() } }
val addFocus = remember { FocusRequester() }
// Focus lands on the row that took the removed one's place rather than back at the top,
// because emptying a household of guests is a run of presses and being sent to the top
// between each one loses the viewer's place every time.
LaunchedEffect(people) {
val index = removalReturnIndex?.let { viewerFocusIndexAfterRemoval(it, people.size) }
removalReturnIndex = null
val target = index ?: people.indexOfFirst(::viewerIsEditable).takeIf { it >= 0 }
runCatching {
target?.let { renameFocus.getOrNull(it)?.requestFocus() } ?: addFocus.requestFocus()
}
}
Box(modifier.fillMaxSize().background(MembySurface)) {
Column(
Modifier
.fillMaxSize()
.padding(horizontal = 72.dp, vertical = 40.dp),
) {
Text("VIEWERS", color = MembyAccent, fontSize = 11.sp, fontWeight = FontWeight.Bold)
Spacer(Modifier.height(4.dp))
Text(
text = "Who is under this account",
color = MembyOnSurface,
fontSize = 28.sp,
fontWeight = FontWeight.SemiBold,
)
Spacer(Modifier.height(4.dp))
Text(
text = "Everybody here keeps their own Continue Watching, watched history " +
"and favourites. Only the account itself is synced with Emby.",
color = MembyQuietText,
fontSize = 14.sp,
)
Spacer(Modifier.height(22.dp))
LazyColumn(
modifier = Modifier.fillMaxWidth().weight(1f),
verticalArrangement = Arrangement.spacedBy(10.dp),
contentPadding = PaddingValues(bottom = 12.dp),
) {
itemsIndexed(people, key = { _, viewer -> viewer.id }) { index, viewer ->
ViewerManageRow(
viewer = viewer,
busy = viewer.id == busyViewerId,
onRename = { onRename(viewer) },
onRemove = {
removalReturnIndex = index
pendingRemoval = viewer
},
renameModifier = Modifier.focusRequester(renameFocus[index]),
)
}
}
Spacer(Modifier.height(14.dp))
Row(horizontalArrangement = Arrangement.spacedBy(14.dp)) {
if (canAddViewer) {
ViewerActionButton(
label = "Add viewer",
icon = MembyIcon.Add,
onClick = onAdd,
emphasised = true,
modifier = Modifier.focusRequester(addFocus),
)
}
ViewerActionButton(label = "Done", icon = MembyIcon.Check, onClick = onClose)
}
}
pendingRemoval?.let { viewer ->
ViewerRemovalConfirmation(
viewer = viewer,
onCancel = {
removalReturnIndex = null
pendingRemoval = null
},
onConfirm = {
pendingRemoval = null
onRemove(viewer)
},
)
}
}
}
/**
* One person and the two things that can be done to them.
*
* The row holds **two focus targets** rather than opening a menu, the shape the alerts page
* settled on for the same reason: a remote has one confirm key, and a press that opened a
* list of actions would make renaming somebody three presses deep for no gain. Down still
* reaches the next row from either, so the second target costs nothing to somebody who only
* ever renames.
*
* The **main viewer has neither**. Its name is the Emby account's `UpdateShadowViewer`
* refuses to touch it and removing it would leave the account with nothing to fall back
* to. It is still listed, because a list of the people here that omitted the person whose
* watching actually reaches Emby would be the more confusing of the two.
*/
@Composable
private fun ViewerManageRow(
viewer: MembyViewer,
busy: Boolean,
onRename: () -> Unit,
onRemove: () -> Unit,
renameModifier: Modifier = Modifier,
) {
var hasFocus by remember { mutableStateOf(false) }
val shape = RoundedCornerShape(MembyPanelCorner)
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
// The lit surface is the *row's*, driven by hasFocus rather than isFocused, or
// the row goes dark the moment the remote steps sideways into its own control.
.onFocusChanged { hasFocus = it.hasFocus }
.clip(shape)
.background(if (hasFocus) MembyControlSurfaceRaised else MembyControlSurface)
.border(1.dp, if (hasFocus) MembyAccent else Color.Transparent, shape)
.padding(horizontal = 20.dp, vertical = 14.dp),
) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.size(46.dp)
.clip(CircleShape)
.background(MembySurface)
.border(1.dp, MembyOutline, CircleShape),
) {
Text(
text = viewer.initials,
color = MembyOnSurface,
fontSize = 18.sp,
fontWeight = FontWeight.SemiBold,
)
}
Spacer(Modifier.width(16.dp))
Column(Modifier.weight(1f)) {
Text(
text = viewer.name,
color = MembyOnSurface,
fontSize = 18.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = when {
busy -> "Working…"
viewer.isMain -> "The account itself — named by Emby, and synced with it"
else -> "Watches privately; nothing reaches Emby"
},
color = MembyQuietText,
fontSize = 13.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
if (viewerIsEditable(viewer)) {
Spacer(Modifier.width(16.dp))
ViewerActionButton(
label = "Rename",
icon = MembyIcon.Rename,
onClick = onRename,
enabled = !busy,
modifier = renameModifier,
)
Spacer(Modifier.width(10.dp))
ViewerActionButton(
label = "Remove",
icon = MembyIcon.Remove,
onClick = onRemove,
enabled = !busy,
)
}
}
}
/**
* The full-stop question, because removing somebody deletes everything Memby kept for them
* and there is nothing to undo it with.
*
* It follows `ExitConfirmation`'s rules, which are the app's rules for a question asked over
* the thing it is about: the two answers **do not look alike**, the safe one takes focus
* first, and **Back means keep** it is the key that raised the panel, and pressing it
* again must not be what deletes a person.
*/
@Composable
internal fun ViewerRemovalConfirmation(
viewer: MembyViewer,
onCancel: () -> Unit,
onConfirm: () -> Unit,
/** Draws the keep button lit, so a capture states which answer a press would take. */
focusedForCapture: Boolean = false,
) {
val keepFocus = remember { FocusRequester() }
BackHandler(onBack = onCancel)
LaunchedEffect(viewer.id) { runCatching { keepFocus.requestFocus() } }
Box(
Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.78f)),
contentAlignment = Alignment.Center,
) {
Column(
modifier = Modifier
.width(520.dp)
.background(MembyControlSurface, RoundedCornerShape(18.dp))
.padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Text(
text = "Remove ${viewer.name}?",
color = MembyOnSurface,
fontSize = 24.sp,
fontWeight = FontWeight.SemiBold,
)
Text(
text = "What they were part-way through, what they had watched and their " +
"favourites are deleted, on every television in the house. Nothing on " +
"the Emby account changes.",
color = MembyMutedText,
fontSize = 15.sp,
textAlign = TextAlign.Center,
)
Row(horizontalArrangement = Arrangement.spacedBy(14.dp)) {
ViewerActionButton(
label = "Keep ${viewer.name}",
icon = MembyIcon.Close,
onClick = onCancel,
emphasised = true,
focusedForCapture = focusedForCapture,
modifier = Modifier.focusRequester(keepFocus),
)
ViewerActionButton(
label = "Remove",
icon = MembyIcon.Remove,
onClick = onConfirm,
)
}
}
}
}
@@ -0,0 +1,241 @@
@file:OptIn(androidx.compose.ui.ExperimentalComposeUiApi::class)
package com.ponzischeme89.memby.ui.viewers
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.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.width
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.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.input.key.utf16CodePoint
import androidx.compose.ui.graphics.Color
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.tv.material3.Text
import com.ponzischeme89.memby.data.model.MembyViewer
import com.ponzischeme89.memby.ui.search.BACKSPACE_CODE
import com.ponzischeme89.memby.ui.search.FIRST_PRINTABLE_CODE
import com.ponzischeme89.memby.ui.search.TvKeyboard
import com.ponzischeme89.memby.ui.theme.MembyIcon
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.MembySurface
/**
* What a refusal is printed in. A literal rather than a token, the stance every status
* colour in this app takes: it carries meaning of its own, and a palette that could repaint
* it could make a refusal look like a confirmation.
*/
private val ViewerRefusal = Color(0xFFFF8A80)
/**
* Naming a person, which is the one thing this feature could not do from a television.
*
* It reuses the **search keyboard** rather than growing a second one. Two on-screen
* keyboards in one app is two focus contracts to keep in step, and the one thing a viewer
* must never have to relearn is where the letters are which is the note [TvKeyboard]
* already carries, written before there was a second caller to prove it.
*
* Stateless, the stance the picker and `SignInContent` take: the caller owns the name, the
* request and what happens after it, so the screenshot test can render every state of it
* with no gateway.
*/
@Composable
internal fun ViewerNameEntry(
target: ViewerNameTarget,
name: String,
existing: List<MembyViewer>,
onNameChanged: (String) -> Unit,
onConfirm: () -> Unit,
onCancel: () -> Unit,
modifier: Modifier = Modifier,
/**
* What the gateway said when it refused. It is kept apart from the local rules in
* [viewerNameError] on purpose: one is a sentence about what is typed and is true
* before anything is sent, the other is what came back, and showing them in one slot
* would let a stale server refusal sit under a name that has since been corrected.
*/
failure: String? = null,
saving: Boolean = false,
) {
val keyboardEntry = remember { FocusRequester() }
val keyboardReturn = remember { FocusRequester() }
val confirmFocus = remember { FocusRequester() }
var lastKeyIndex by remember { mutableStateOf(0) }
// The keyboard, not the confirm button: somebody who opened this screen came to type.
LaunchedEffect(target) { runCatching { keyboardEntry.requestFocus() } }
val localError = viewerNameError(name, existing, target)
// The local rule is only worth printing once there is something to be wrong about — a
// screen that opens by telling somebody their empty name is empty is scolding them for
// not having started.
val message = failure ?: localError.takeIf { name.isNotEmpty() }
val canConfirm = localError == null && !saving
Column(
modifier = modifier
.fillMaxSize()
.background(MembySurface)
.padding(horizontal = 48.dp, vertical = 24.dp)
// A USB keyboard, or a phone remote app sending key events, types into the same
// name the on-screen keys do — the Search tab's rule, and the same limits: only
// printable characters and backspace are consumed, so D-pad and Back fall
// through untouched and the screen can still be left.
.onPreviewKeyEvent { event ->
if (event.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false
val code = event.utf16CodePoint
when {
code == BACKSPACE_CODE -> {
onNameChanged(name.dropLast(1)); true
}
code >= FIRST_PRINTABLE_CODE -> {
onNameChanged(viewerNameWith(name, code.toChar().toString())); true
}
else -> false
}
},
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = viewerNameHeading(target),
color = MembyOnSurface,
fontSize = 26.sp,
fontWeight = FontWeight.SemiBold,
)
Spacer(Modifier.height(4.dp))
Text(
text = "They keep their own Continue Watching, watched history and favourites. " +
"Nothing they watch reaches Emby.",
color = MembyQuietText,
fontSize = 13.sp,
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(12.dp))
NamePlate(name = name)
// Reserved rather than conditional, the rule the picker's "Synced with Emby" line
// follows: a message appearing under the plate would push the keyboard down by a
// line at the moment somebody is typing into it.
//
// Deliberately not the accent: every affirmative thing on a Memby screen is green,
// and a refusal wearing the confirmation colour reads at a glance as the name
// having been accepted. This is the red the search tab's own refusals use.
Text(
text = message ?: " ",
color = ViewerRefusal,
fontSize = 13.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(top = 6.dp),
)
Spacer(Modifier.height(6.dp))
// The keyboard is the child that gives way, never the buttons under it.
//
// A Column hands each child what the ones before it left over, so the confirm row —
// being last — was measured from the remainder and rendered as two squeezed slivers
// with their labels pressed out. Weighted children are measured from what the
// *unweighted* ones leave, so this inverts it: the buttons take their natural size
// first and the letters give up a row if a set is short of height. It is the same
// inversion the home hero makes for its Play chip, and it broke here in the same way.
Box(Modifier.width(340.dp).weight(1f, fill = false)) {
TvKeyboard(
// There is no rail beside this screen and no results grid to its right, so
// both edges are dead ends rather than jumps to a distant control: a screen
// this small has nowhere for focus to go sideways that would not be a
// surprise.
navigationFocusRequester = FocusRequester.Cancel,
resultsEntry = FocusRequester.Cancel,
keyboardEntry = keyboardEntry,
keyboardReturn = keyboardReturn,
lastKeyIndex = lastKeyIndex,
hasResultsTarget = false,
onKeyFocused = { lastKeyIndex = it },
onCharacter = { onNameChanged(viewerNameWith(name, it)) },
onBackspace = { onNameChanged(name.dropLast(1)) },
onClear = { onNameChanged("") },
// No submit key. The keyboard's own Search key is a lookup that costs
// something; here the confirm is a decision about a person and belongs
// beside the way out of the screen, not in the middle of the letters.
onSearch = null,
compact = true,
)
}
Spacer(Modifier.height(14.dp))
Row(horizontalArrangement = Arrangement.spacedBy(14.dp)) {
ViewerActionButton(
label = "Cancel",
icon = MembyIcon.Close,
onClick = onCancel,
)
ViewerActionButton(
label = if (saving) "Saving…" else viewerNameAction(target),
icon = MembyIcon.Check,
onClick = { if (canConfirm) onConfirm() },
emphasised = true,
enabled = canConfirm,
modifier = Modifier.focusRequester(confirmFocus),
)
}
}
}
/**
* What has been typed, shown at the size the room can read.
*
* The caret is drawn rather than blinking: nothing on this screen may animate, because an
* animation here would run for as long as somebody takes to type a name, and this app ships
* to boxes with nothing spare. A steady mark says the same thing.
*/
@Composable
private fun NamePlate(name: String) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.width(340.dp)
.height(56.dp)
.background(MembySurface, RoundedCornerShape(MembyPanelCorner))
.border(1.dp, MembyOutline, RoundedCornerShape(MembyPanelCorner))
.padding(horizontal = 16.dp),
) {
Text(
text = if (name.isEmpty()) "Type a name" else name + "|",
color = if (name.isEmpty()) MembyQuietText else MembyOnSurface,
fontSize = 24.sp,
fontWeight = if (name.isEmpty()) FontWeight.Normal else FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.Center,
)
}
}
@@ -0,0 +1,242 @@
package com.ponzischeme89.memby.ui.viewers
import androidx.compose.animation.core.animateFloatAsState
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.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.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.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
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.tv.material3.Text
import com.ponzischeme89.memby.data.model.MembyViewer
import com.ponzischeme89.memby.ui.distinctForKeys
import com.ponzischeme89.memby.ui.theme.MembyAccent
import com.ponzischeme89.memby.ui.theme.MembyAccentInk
import com.ponzischeme89.memby.ui.theme.MembyControlSurface
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.MembyQuietText
import com.ponzischeme89.memby.ui.theme.MembySurface
/**
* "Who's watching?" the people under one Memby account.
*
* It is deliberately a full screen rather than another row in the user switcher. That panel
* lists *accounts* and the actions beside them, and a viewer is a different grain of thing:
* a household picks a person the way they pick one on any television service, by looking at
* a row of faces. Folding them into the same 292dp column would have made two unrelated
* questions look like one list.
*
* Stateless on purpose, the stance `SignInContent` and the detail panes take: everything it
* needs is a parameter, so [ViewerPickerScreenshotTest] can render it with no gateway, and
* the caller owns the requests.
*/
@Composable
fun ViewerPicker(
viewers: List<MembyViewer>,
activeViewerId: String,
onViewerSelected: (MembyViewer) -> Unit,
onAddViewer: () -> Unit,
onManageViewers: () -> Unit,
modifier: Modifier = Modifier,
/**
* Whether an "Add viewer" card is offered. False once the household has reached the
* gateway's limit the control is *removed* rather than dimmed, the stance the two
* optional transport controls take: a remote is driven by a D-pad, and a dead stop on
* the way to the next control is worse than no control at all.
*/
canAddViewer: Boolean = true,
loading: Boolean = false,
) {
// Ids come off a wire and a keyed LazyRow throws on a repeat, taking the screen with
// it. Deduplicate, never disambiguate — see ui/ListKeys.kt.
val people = remember(viewers) { viewers.distinctForKeys(MembyViewer::id) }
val cardFocusers = remember(people) { List(people.size) { FocusRequester() } }
val listState = rememberLazyListState()
// Focus opens on whoever is watching rather than on the first card. A television is
// switched on by the person who last used it far more often than not, so the common
// case is one confirm press instead of a walk along the row.
val initialIndex = remember(people, activeViewerId) {
viewerPickerInitialIndex(people, activeViewerId)
}
LaunchedEffect(people, initialIndex) {
if (people.isEmpty()) return@LaunchedEffect
listState.scrollToItem(viewerPickerScrollIndex(initialIndex))
runCatching { cardFocusers[initialIndex].requestFocus() }
}
Column(
modifier = modifier
.fillMaxSize()
.background(MembySurface)
.padding(horizontal = 48.dp, vertical = 40.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Text(
text = "Who's watching?",
color = MembyOnSurface,
fontSize = 34.sp,
fontWeight = FontWeight.SemiBold,
)
Spacer(Modifier.height(6.dp))
Text(
text = "Everyone keeps their own Continue Watching and their own watched history.",
color = MembyQuietText,
fontSize = 15.sp,
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(34.dp))
if (people.isEmpty()) {
// "Still fetching" and "nobody here" are different things to be told — the
// distinction the player's cast panel makes with its own `loaded` flag.
Text(
text = if (loading) "Loading viewers…" else "No viewers yet.",
color = MembyMutedText,
fontSize = 16.sp,
)
} else {
LazyRow(
state = listState,
horizontalArrangement = Arrangement.spacedBy(20.dp),
contentPadding = PaddingValues(horizontal = 8.dp),
) {
itemsIndexed(people, key = { _, viewer -> viewer.id }) { index, viewer ->
ViewerCard(
viewer = viewer,
selected = viewerIsActive(viewer, activeViewerId),
onClick = { onViewerSelected(viewer) },
modifier = Modifier.focusRequester(cardFocusers[index]),
)
}
}
}
Spacer(Modifier.height(30.dp))
Row {
if (canAddViewer) {
ViewerActionButton(
label = "Add viewer",
icon = MembyIcon.Add,
onClick = onAddViewer,
)
Spacer(Modifier.width(14.dp))
}
ViewerActionButton(
label = "Manage",
icon = MembyIcon.Settings,
onClick = onManageViewers,
)
}
}
}
/**
* One person, as a face and a name.
*
* The animated scale 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.
*/
@Composable
private fun ViewerCard(
viewer: MembyViewer,
selected: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
var focused by remember { mutableStateOf(false) }
val scale by animateFloatAsState(if (focused) 1.06f else 1f, label = "viewerCardScale")
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = modifier
.width(150.dp)
.graphicsLayer {
scaleX = scale
scaleY = scale
}
.onFocusChanged { focused = it.isFocused }
.clickable(onClick = onClick),
) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.size(112.dp)
.clip(CircleShape)
.background(if (focused) MembyAccent else MembyControlSurface)
.border(
width = if (selected) 3.dp else 1.dp,
color = when {
focused -> Color.Transparent
selected -> MembyAccent
else -> MembyOutline
},
shape = CircleShape,
),
) {
Text(
text = viewer.initials,
color = if (focused) MembyAccentInk else MembyOnSurface,
fontSize = 40.sp,
fontWeight = FontWeight.SemiBold,
)
}
Spacer(Modifier.height(12.dp))
Text(
text = viewer.name,
color = if (focused) MembyOnSurface else MembyMutedText,
fontSize = 16.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
textAlign = TextAlign.Center,
)
// The main viewer is the one whose watching reaches Emby, and saying so is most of
// the difference between a household understanding this feature and being puzzled
// by it. The line is *reserved* rather than conditional, or a card without it would
// sit taller than the one beside it — the rule the cast grid's character line
// follows.
Text(
text = if (viewer.isMain) "Synced with Emby" else " ",
color = MembyQuietText,
fontSize = 12.sp,
maxLines = 1,
textAlign = TextAlign.Center,
)
}
}
@@ -0,0 +1,94 @@
package com.ponzischeme89.memby.ui.viewers
import com.ponzischeme89.memby.data.model.MembyViewer
/**
* The rules the picker is built on, kept pure so they can be tested without a television.
*
* All three are about the same awkwardness: a blank active id means "the account's own
* viewer", because that is what the absence of the `X-Memby-Viewer` header means to the
* gateway. Writing the main viewer's id into the setting instead would work equally well
* on the wire and would be worse in one specific way an app that had never been told the
* account's Emby user id could not then express "nobody in particular", which is the state
* every existing install starts in.
*/
/**
* How many shadow viewers an account may hold. Mirrors `store.MaxShadowViewers` on the
* gateway, which is the one that enforces it this copy only decides whether the picker
* offers the control, so that a household at the limit is never handed a button whose only
* possible outcome is a refusal.
*/
internal const val MAX_SHADOW_VIEWERS = 7
/** Whether [viewer] is the one this television is currently watching as. */
internal fun viewerIsActive(viewer: MembyViewer, activeViewerId: String): Boolean =
if (activeViewerId.isEmpty()) viewer.isMain else viewer.id == activeViewerId
/**
* Where the picker opens focus.
*
* On whoever is watching, falling back to the first card. A television is switched on by
* the person who last used it far more often than not, so the common case should be one
* confirm press rather than a walk along the row and an id this account no longer
* recognises must still land somewhere real rather than off the end of the list.
*/
internal fun viewerPickerInitialIndex(viewers: List<MembyViewer>, activeViewerId: String): Int {
if (viewers.isEmpty()) return 0
val index = viewers.indexOfFirst { viewerIsActive(it, activeViewerId) }
return if (index >= 0) index else 0
}
/**
* What the launcher calls whoever is watching.
*
* Empty for the account's own viewer, which is the whole point: a household that has never
* added anybody must not have a name badge appear over its launcher explaining a feature it
* is not using. Only a *shadow* viewer is somebody worth naming, because only then is there
* a question of whose evening it is.
*/
internal fun activeViewerLabel(activeViewerId: String, activeViewerName: String): String =
if (activeViewerId.isEmpty()) "" else activeViewerName.trim()
/**
* Whether this television should offer the picker at all.
*
* Two conditions, and both matter. There is no gateway to ask on the direct path, so there
* is exactly one viewer and it is the account. And an account with a single viewer is one
* nobody has added anybody to offering "Who's watching?" there is a question with one
* answer, which reads as a fault rather than as a feature waiting to be used. The entry
* point that *adds* the first viewer therefore lives with the other account management,
* not behind this.
*/
internal fun shouldOfferViewerPicker(gatewayMode: Boolean, viewerCount: Int): Boolean =
gatewayMode && viewerCount > 1
/**
* What the user menu's row is called.
*
* It names the *person* once somebody other than the account is watching, because that is
* the one thing a household needs to be able to check at a glance "am I about to add this
* to Alessandra's Continue Watching or to mine?" — and the menu is where they would look.
* With the account's own viewer selected there is nobody to name, so it asks the question
* instead.
*/
internal fun viewerMenuLabel(activeViewerId: String, activeViewerName: String): String {
val name = activeViewerLabel(activeViewerId, activeViewerName)
return if (name.isEmpty()) "Who's watching?" else "Watching as $name"
}
/**
* Where the row is scrolled so the picker opens with context.
*
* Scrolling straight to the focused card pins it against the left edge, and the people
* before it disappear with nothing on screen saying they are there a household of six
* whose fourth viewer is watching would open on what looks like a list starting at them.
* Leaving one card visible behind the focused one is the cheapest possible answer: it says
* "there is more this way" without a chevron, a fade or anything else to maintain.
*
* It is separate from [viewerPickerInitialIndex] because they answer different questions
* one is where the remote is, the other is what the eye can see and conflating them would
* mean focus landing on the wrong person to make the scroll look right.
*/
internal fun viewerPickerScrollIndex(focusedIndex: Int): Int =
(focusedIndex - 1).coerceAtLeast(0)
@@ -1,7 +1,8 @@
package com.ponzischeme89.memby.ui.requests
import com.ponzischeme89.memby.ui.userSwitcherActionCount
import com.ponzischeme89.memby.ui.UserSwitcherDirection
import com.ponzischeme89.memby.ui.UserSwitcherMenuItem
import com.ponzischeme89.memby.ui.userSwitcherMenuItems
import com.ponzischeme89.memby.ui.userSwitcherNextIndex
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
@@ -146,38 +147,49 @@ class RequestPresentationTest {
}
@Test
fun `the switcher's action count matches the rows actually drawn`() {
assertEquals(3, userSwitcherActionCount(showRequests = false))
assertEquals(4, userSwitcherActionCount(showRequests = true))
fun `the switcher's rows are exactly the ones its conditions ask for`() {
assertEquals(3, userSwitcherMenuItems(showRequests = false).size)
assertEquals(4, userSwitcherMenuItems(showRequests = true).size)
assertEquals(4, userSwitcherMenuItems(showRequests = false, showViewers = true).size)
assertEquals(5, userSwitcherMenuItems(showRequests = true, showViewers = true).size)
// Who is watching comes first, because the rows below it belong to whichever
// viewer it selects; Manage users stays last, where a menu's escape hatch belongs.
val full = userSwitcherMenuItems(showRequests = true, showViewers = true)
assertEquals(UserSwitcherMenuItem.VIEWERS, full.first())
assertEquals(UserSwitcherMenuItem.MANAGE_USERS, full.last())
// An optional row is absent rather than present-and-dead, so nothing below it
// shifts under a D-pad that has already started travelling.
assertFalse(UserSwitcherMenuItem.VIEWERS in userSwitcherMenuItems(showRequests = true))
assertFalse(UserSwitcherMenuItem.REQUESTS in userSwitcherMenuItems(showRequests = false))
}
@Test
fun `the D-pad reaches the last switcher row whether or not Requests is shown`() {
// The bug this guards is the one that makes Manage users unreachable: an action
// count that disagrees with the number of rows caps the D-pad one row short.
fun `the D-pad reaches the last switcher row whatever is shown`() {
// The bug this guards is the one that makes Manage users unreachable: a row count
// that disagrees with the rows actually drawn caps the D-pad one row short. It is
// run over every combination because each new optional row is a fresh chance to
// reintroduce it.
val profiles = listOf("p1", "p2")
val withRequests = userSwitcherActionCount(showRequests = true)
var index = 0
repeat(10) {
index = userSwitcherNextIndex(
currentIndex = index,
profileCount = profiles.size,
direction = UserSwitcherDirection.DOWN,
actionCount = withRequests,
)
for (requests in listOf(false, true)) {
for (people in listOf(false, true)) {
val rows = userSwitcherMenuItems(requests, people).size
var index = 0
repeat(12) {
index = userSwitcherNextIndex(
currentIndex = index,
profileCount = profiles.size,
direction = UserSwitcherDirection.DOWN,
actionCount = rows,
)
}
assertEquals(
"requests=$requests viewers=$people",
profiles.size + rows - 1,
index,
)
}
}
assertEquals(profiles.size + withRequests - 1, index)
val withoutRequests = userSwitcherActionCount(showRequests = false)
index = 0
repeat(10) {
index = userSwitcherNextIndex(
currentIndex = index,
profileCount = profiles.size,
direction = UserSwitcherDirection.DOWN,
actionCount = withoutRequests,
)
}
assertEquals(profiles.size + withoutRequests - 1, index)
}
}
@@ -0,0 +1,114 @@
package com.ponzischeme89.memby.ui.viewers
import com.ponzischeme89.memby.data.model.MembyViewer
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* The rules behind naming a person. They are pure so they can be pinned here, which matters
* because the failures they prevent are all the same shape: a name that reached the gateway
* and came back refused, after somebody had typed it one character at a time with a remote.
*/
class ViewerEditingTest {
private val household = listOf(
MembyViewer(id = "emby-user-1", name = "Matt", kind = MembyViewer.KIND_MAIN),
MembyViewer(id = "v1", name = "Alessandra", kind = MembyViewer.KIND_SHADOW),
)
@Test
fun `a blank name is refused`() {
assertNotNull(viewerNameError("", household, ViewerNameTarget.Add))
assertNotNull(viewerNameError(" ", household, ViewerNameTarget.Add))
assertFalse(viewerNameSubmittable("", household, ViewerNameTarget.Add))
}
/**
* The gateway is perfectly happy to hold two people called Sam. This app is not: the
* picker is a row of faces with a name under each, and two identical names is a choice
* nobody in the household can make.
*/
@Test
fun `a name already in the house is refused, whatever its case`() {
assertNotNull(viewerNameError("Alessandra", household, ViewerNameTarget.Add))
assertNotNull(viewerNameError(" alessandra ", household, ViewerNameTarget.Add))
// The account's own name counts too — it is a card in the same row.
assertNotNull(viewerNameError("Matt", household, ViewerNameTarget.Add))
assertNull(viewerNameError("Sam", household, ViewerNameTarget.Add))
}
/**
* Correcting somebody's own capitalisation must not be refused as a duplicate of
* themselves, which is the one case a plain "is this name taken" check gets wrong.
*/
@Test
fun `a rename may keep the name it started with`() {
val target = ViewerNameTarget.Rename(household[1])
assertNull(viewerNameError("Alessandra", household, target))
assertNull(viewerNameError("alessandra", household, target))
// Somebody else's name is still taken.
assertNotNull(viewerNameError("Matt", household, target))
}
/**
* The limit is enforced by refusing the keypress rather than by rejecting the save. A
* remote types one character at a time, and a limit that only announces itself at the
* end is one somebody discovers after typing a sentence.
*/
@Test
fun `typing stops at the limit rather than overrunning it`() {
val full = "x".repeat(MAX_VIEWER_NAME_LENGTH)
assertEquals(full, viewerNameWith(full, "y"))
val nearly = "x".repeat(MAX_VIEWER_NAME_LENGTH - 1)
assertEquals(nearly + "y", viewerNameWith(nearly, "y"))
// A paste-sized addition that would overrun is refused whole rather than truncated:
// half of what somebody dictated is worse than none of it.
assertEquals(nearly, viewerNameWith(nearly, "yz"))
}
/** The screen opens holding the name being changed, and nothing for a new person. */
@Test
fun `the screen opens on the right name`() {
assertEquals("", viewerNameFor(ViewerNameTarget.Add))
assertEquals("Alessandra", viewerNameFor(ViewerNameTarget.Rename(household[1])))
}
/**
* The button names the outcome rather than the screen, because on a television the
* label under the focus ring is commonly the only thing saying what a press will do.
*/
@Test
fun `the confirm button names what it will do`() {
assertEquals("Add viewer", viewerNameAction(ViewerNameTarget.Add))
assertEquals("Save name", viewerNameAction(ViewerNameTarget.Rename(household[1])))
}
/**
* The main viewer's name is the Emby account's and belongs to Emby
* `store.UpdateShadowViewer` refuses to touch it so the manage list must not offer
* a control whose only possible outcome is a refusal.
*/
@Test
fun `the account itself cannot be renamed or removed`() {
assertFalse(viewerIsEditable(household[0]))
assertTrue(viewerIsEditable(household[1]))
}
/**
* Where focus lands after somebody is removed. Emptying a household of guests is a run
* of presses, and being sent back to the top between each one loses the viewer's place
* every time.
*/
@Test
fun `focus follows a removal down the list`() {
assertEquals(1, viewerFocusIndexAfterRemoval(removedIndex = 1, remainingCount = 3))
// The last row went; the one above it takes the focus.
assertEquals(1, viewerFocusIndexAfterRemoval(removedIndex = 2, remainingCount = 2))
// Nothing left to focus is a real answer, and the caller sends focus to Add.
assertNull(viewerFocusIndexAfterRemoval(removedIndex = 0, remainingCount = 0))
}
}
@@ -0,0 +1,168 @@
package com.ponzischeme89.memby.ui.viewers
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onRoot
import com.github.takahirom.roborazzi.captureRoboImage
import com.ponzischeme89.memby.data.model.MembyViewer
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
/**
* The two screens that let a household run this feature without an admin console, rendered
* to `build/screenshots/viewers/` with no gateway.
*
* ```powershell
* .\gradlew.bat :app:testDebugUnitTest --tests "*ViewerManagementScreenshotTest"
* ```
*
* These are the captures worth having because the claims they make are not the kind a unit
* test can check. The name screen has to fit a heading, what has been typed, a keyboard and
* two buttons into 540dp with the bottom of the column where overscan bites; the manage list
* has to make "this person is the Emby account and cannot be changed" read as deliberate
* rather than as two buttons that failed to draw; and the removal question has to make the
* safe answer and the destructive one impossible to confuse at three metres.
*/
@RunWith(RobolectricTestRunner::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(sdk = [34], qualifiers = "w960dp-h540dp-television-xhdpi")
class ViewerManagementScreenshotTest {
@get:Rule
val compose = createComposeRule()
private val household = listOf(
MembyViewer(id = "emby-user-1", name = "Matt", kind = MembyViewer.KIND_MAIN),
MembyViewer(id = "v1", name = "Alessandra", kind = MembyViewer.KIND_SHADOW),
MembyViewer(id = "v2", name = "Guest", kind = MembyViewer.KIND_SHADOW),
)
/** A new person, nothing typed. The plate has to read as somewhere to type into. */
@Test
fun `adding somebody, before anything is typed`() {
captureName("viewers-name-empty", ViewerNameTarget.Add, name = "")
}
/** Part-way through. The keyboard must not have moved when the plate filled. */
@Test
fun `adding somebody, part-way through the name`() {
captureName("viewers-name-typed", ViewerNameTarget.Add, name = "ALESS")
}
/**
* A name already in the house. The message sits in the line reserved for it, so
* nothing below it moves which is the whole reason the line is reserved.
*/
@Test
fun `a name the household already has`() {
captureName("viewers-name-clash", ViewerNameTarget.Add, name = "GUEST")
}
/** Renaming: the heading names the person, and the plate opens holding their name. */
@Test
fun `renaming somebody`() {
captureName(
"viewers-name-rename",
ViewerNameTarget.Rename(household[1]),
name = "Alessandra",
)
}
/** The gateway refused. What was typed is still there to be corrected. */
@Test
fun `the gateway refused the name`() {
captureName(
"viewers-name-failed",
ViewerNameTarget.Add,
name = "SAM",
failure = "That could not be saved. Try again.",
)
}
@Test
fun `the manage list`() {
compose.setContent {
MembyTheme {
ViewerManageScreen(
viewers = household,
onRename = {},
onRemove = {},
onAdd = {},
onClose = {},
)
}
}
compose.onRoot().captureRoboImage("build/screenshots/viewers/viewers-manage.png")
}
/**
* At the limit, so Add is gone rather than dimmed the stance the picker takes, and
* worth capturing because removing a control rebalances the row beside it.
*/
@Test
fun `the manage list with the household full`() {
val full = household + (3..7).map {
MembyViewer(id = "v$it", name = "Viewer $it", kind = MembyViewer.KIND_SHADOW)
}
compose.setContent {
MembyTheme {
ViewerManageScreen(
viewers = full,
onRename = {},
onRemove = {},
onAdd = {},
onClose = {},
canAddViewer = false,
busyViewerId = "v1",
)
}
}
compose.onRoot().captureRoboImage("build/screenshots/viewers/viewers-manage-full.png")
}
/**
* The full-stop question. Robolectric's window never takes focus and the focus ring is
* the whole of what says which answer a press would take, so the safe answer is drawn
* lit otherwise the capture would prove nothing about the one thing it exists for.
*/
@Test
fun `removing somebody is asked about first`() {
compose.setContent {
MembyTheme {
ViewerRemovalConfirmation(
viewer = household[1],
onCancel = {},
onConfirm = {},
focusedForCapture = true,
)
}
}
compose.onRoot().captureRoboImage("build/screenshots/viewers/viewers-remove-confirm.png")
}
private fun captureName(
file: String,
target: ViewerNameTarget,
name: String,
failure: String? = null,
) {
compose.setContent {
MembyTheme {
ViewerNameEntry(
target = target,
name = name,
existing = household,
onNameChanged = {},
onConfirm = {},
onCancel = {},
failure = failure,
)
}
}
compose.onRoot().captureRoboImage("build/screenshots/viewers/$file.png")
}
}
@@ -0,0 +1,131 @@
package com.ponzischeme89.memby.ui.viewers
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.data.model.MembyViewer
import com.ponzischeme89.memby.ui.UserSwitcherOverlay
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/viewers/`, so the picker can be
* looked at without a gateway or a television.
*
* ```powershell
* .\gradlew.bat :app:testDebugUnitTest --tests "*ViewerPickerScreenshotTest"
* ```
*
* This is the screen that has to make the whole feature legible in one glance that these
* are people rather than accounts, and that exactly one of them is the one Emby hears about.
* A unit test can check that the "Synced with Emby" caption is produced for the main viewer;
* only a capture says whether a row of near-identical circles reads as a choice at three
* metres.
*/
@RunWith(RobolectricTestRunner::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(sdk = [34], qualifiers = "w960dp-h540dp-television-xhdpi")
class ViewerPickerScreenshotTest {
@get:Rule
val compose = createComposeRule()
private val household = listOf(
MembyViewer(id = "emby-user-1", name = "Matt", kind = MembyViewer.KIND_MAIN),
MembyViewer(id = "v1", name = "Alessandra", kind = MembyViewer.KIND_SHADOW),
MembyViewer(id = "v2", name = "Guest", kind = MembyViewer.KIND_SHADOW),
MembyViewer(id = "v3", name = "Kids", kind = MembyViewer.KIND_SHADOW),
)
@Test
fun `a household of four`() {
capture("viewers-household", household, activeViewerId = "v1")
}
/**
* The account's own viewer selected, which is the state every install starts in and the
* one a blank id represents. The capture is the check that the first card reads as
* chosen rather than as nothing being chosen at all.
*/
@Test
fun `the account itself is watching`() {
capture("viewers-account-selected", household, activeViewerId = "")
}
/**
* At the limit, so the Add control is gone rather than dimmed. Worth a capture because
* removing a control changes the balance of the row underneath the cards, which is the
* kind of thing that only looks wrong once it is drawn.
*/
@Test
fun `the household is full`() {
val full = household + (4..7).map {
MembyViewer(id = "v$it", name = "Viewer $it", kind = MembyViewer.KIND_SHADOW)
}
capture("viewers-full", full, activeViewerId = "v1", canAdd = false)
}
/**
* Still fetching. "Loading" and "nobody here" are different things to be told, and this
* is the state a television shows for a moment on every cold start.
*/
@Test
fun `nothing has arrived yet`() {
capture("viewers-loading", emptyList(), activeViewerId = "", loading = true)
}
/**
* The way in, so the two screens can be looked at together. The row names whoever is
* watching rather than repeating the question, which is the whole reason it is worth
* having a label that changes.
*/
@Test
fun `the user menu names the viewer`() {
compose.setContent {
MembyTheme {
UserSwitcherOverlay(
profiles = listOf(
EmbyProfile("a", "https://memby.example", "t", "u1", "Matt"),
),
activeProfileId = "a",
onProfileSelected = {},
onManageProfiles = {},
onDismiss = {},
showViewers = true,
activeViewerId = "v1",
activeViewerName = "Alessandra",
)
}
}
compose.onRoot().captureRoboImage("build/screenshots/viewers/viewers-menu-row.png")
}
private fun capture(
name: String,
viewers: List<MembyViewer>,
activeViewerId: String,
canAdd: Boolean = true,
loading: Boolean = false,
) {
compose.setContent {
MembyTheme {
ViewerPicker(
viewers = viewers,
activeViewerId = activeViewerId,
onViewerSelected = {},
onAddViewer = {},
onManageViewers = {},
canAddViewer = canAdd,
loading = loading,
)
}
}
compose.onRoot().captureRoboImage("build/screenshots/viewers/$name.png")
}
}
@@ -0,0 +1,122 @@
package com.ponzischeme89.memby.ui.viewers
import com.ponzischeme89.memby.data.model.MembyViewer
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class ViewerSelectionTest {
private fun main(id: String = "emby-user-1", name: String = "Matt") =
MembyViewer(id = id, name = name, kind = MembyViewer.KIND_MAIN)
private fun shadow(id: String, name: String) =
MembyViewer(id = id, name = name, kind = MembyViewer.KIND_SHADOW)
/**
* A blank id means the account's own viewer, because that is what the absence of the
* `X-Memby-Viewer` header means to the gateway. Every rule here has to agree about it
* or the picker will show nobody selected on the state every install starts in.
*/
@Test
fun `a blank active id selects the account's own viewer`() {
val people = listOf(main(), shadow("v1", "Alessandra"))
assertTrue(viewerIsActive(people[0], ""))
assertFalse(viewerIsActive(people[1], ""))
assertEquals(0, viewerPickerInitialIndex(people, ""))
}
@Test
fun `a named viewer is the selected one`() {
val people = listOf(main(), shadow("v1", "Alessandra"), shadow("v2", "Guest"))
assertFalse(viewerIsActive(people[0], "v2"))
assertTrue(viewerIsActive(people[2], "v2"))
assertEquals(2, viewerPickerInitialIndex(people, "v2"))
}
/**
* A viewer deleted on another television leaves this one holding an id nothing
* recognises. Focus must still land on a card that exists rather than off the end of
* the row.
*/
@Test
fun `an unknown viewer still lands focus on a real card`() {
val people = listOf(main(), shadow("v1", "Alessandra"))
assertEquals(0, viewerPickerInitialIndex(people, "v-deleted"))
assertEquals(0, viewerPickerInitialIndex(emptyList(), "v1"))
}
/**
* Two conditions, and both matter. There is nobody to ask on the direct path, and an
* account nobody has added a viewer to would be offered a question with one answer
* which reads as a fault rather than as a feature waiting to be used.
*/
@Test
fun `the picker is offered only where there is a choice to make`() {
assertFalse(shouldOfferViewerPicker(gatewayMode = false, viewerCount = 4))
assertFalse(shouldOfferViewerPicker(gatewayMode = true, viewerCount = 1))
assertFalse(shouldOfferViewerPicker(gatewayMode = true, viewerCount = 0))
assertTrue(shouldOfferViewerPicker(gatewayMode = true, viewerCount = 2))
}
/**
* The label names the person once somebody other than the account is watching, and asks
* the question otherwise a household running no viewers must not have a name badge
* appear over its launcher explaining a feature it is not using.
*/
@Test
fun `the menu row names whoever is watching`() {
assertEquals("Who's watching?", viewerMenuLabel("", ""))
assertEquals("Who's watching?", viewerMenuLabel("", "Matt"))
assertEquals("Watching as Alessandra", viewerMenuLabel("v1", "Alessandra"))
assertEquals("Watching as Guest", viewerMenuLabel("v2", " Guest "))
}
@Test
fun `the launcher names only a shadow viewer`() {
assertEquals("", activeViewerLabel("", "Matt"))
assertEquals("Alessandra", activeViewerLabel("v1", "Alessandra"))
}
/**
* The avatar is the only thing telling two people apart at three metres, so a viewer
* with no usable name must not produce a blank circle beside a lettered one.
*/
@Test
fun `initials prefer the short name and survive an empty one`() {
assertEquals("M", main(name = "Matt").initials)
assertEquals("A", shadow("v1", "alessandra").initials)
assertEquals(
"L",
MembyViewer(id = "v2", name = "Alessandra", shortName = "Less").initials,
)
assertEquals("", MembyViewer(id = "v3", name = " ").initials)
}
@Test
fun `kind decides whether watching reaches Emby`() {
assertTrue(main().isMain)
assertFalse(shadow("v1", "Alessandra").isMain)
// A viewer from a gateway that sent no kind is not treated as the publishing one:
// the failure of guessing wrong in that direction is somebody else's watch history.
assertFalse(MembyViewer(id = "v4", name = "Unknown").isMain)
}
/**
* Focus and scroll answer different questions: one is where the remote is, the other is
* what the eye can see. Keeping a card behind the focused one is what stops the picker
* opening on what looks like a list starting at whoever happens to be watching.
*/
@Test
fun `the row keeps one card of context behind the focused one`() {
assertEquals(0, viewerPickerScrollIndex(0))
assertEquals(0, viewerPickerScrollIndex(1))
assertEquals(2, viewerPickerScrollIndex(3))
// Never negative, whatever it is handed.
assertEquals(0, viewerPickerScrollIndex(-4))
}
}