0.2.82
This commit is contained in:
@@ -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 who’s 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 who’s 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))
|
||||
}
|
||||
}
|
||||
+168
@@ -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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user