This commit is contained in:
ponzischeme89
2026-08-19 06:57:59 +12:00
parent 8c847c59b8
commit 2b43b9ef12
94 changed files with 6359 additions and 778 deletions
@@ -20,6 +20,7 @@ import com.ponzischeme89.memby.data.model.GatewaySubtitleFixRequest
import com.ponzischeme89.memby.data.model.GatewayFeatures
import com.ponzischeme89.memby.data.model.HomeRow
import com.ponzischeme89.memby.data.model.PlaybackReport
import com.ponzischeme89.memby.data.model.RadarrMovieDetail
import com.ponzischeme89.memby.data.model.PlaybackInfoRequest
import com.ponzischeme89.memby.data.model.MediaSourceInfo
import com.ponzischeme89.memby.data.model.h264TranscodeFallback
@@ -286,6 +287,32 @@ class EmbyRepository internal constructor(
val showTitleLogo: Boolean get() = snapshot.showTitleLogo
private val _playbackStops = MutableSharedFlow<String>(extraBufferCapacity = 1)
val playbackStops = _playbackStops.asSharedFlow()
/**
* The position the player left a title at, published the instant it is known rather
* than once Emby has accepted the report. It is what lets the launcher show the
* progress a viewer just made on the card they are standing on, without waiting for a
* round trip that [playbackStops] triggers afterwards.
*/
private val _playbackPositions = MutableSharedFlow<PlaybackPosition>(extraBufferCapacity = 4)
val playbackPositions = _playbackPositions.asSharedFlow()
/**
* Where this television last left each title. Consulted by [launchResumePositionMs],
* which explains why it has to exist: a card is only as fresh as the last home refresh,
* and neither backend re-reads Emby at launch, so without a record here a viewer who
* watched for twenty seconds and pressed Play again immediately is sent back to where
* they started.
*
* In memory and bounded, the [playableCache] arrangement: it covers the minutes between
* leaving the player and the next refresh, which is the entire window the defect lives
* in, and a process that died in between has WorkManager delivering the stop and a
* fresh set of rows to come back to.
*/
private val localResume = object : LinkedHashMap<String, LocalResume>(16, 0.75f, true) {
override fun removeEldestEntry(eldest: MutableMap.MutableEntry<String, LocalResume>?): Boolean =
size > LOCAL_RESUME_CACHE_SIZE
}
/** Emby session reports must arrive in order; an older progress request cannot follow Stop. */
private val playbackReportMutex = Mutex()
private val playableMutex = Mutex()
@@ -334,6 +361,16 @@ class EmbyRepository internal constructor(
private val extrasCache =
LinkedHashMap<String, List<BaseItem>>(EXTRAS_CACHE_SIZE, 0.75f, true)
private val extrasInFlight = mutableMapOf<String, Deferred<List<BaseItem>?>>()
private val radarrMovieMutex = Mutex()
/**
* The Radarr-only detail pages this session has opened. Small, because it is bounded by
* how many upcoming films a household is tracking, and worth keeping because walking
* Back and pressing the same card again is the ordinary way somebody browses a shelf of
* things that are not out yet.
*/
private val radarrMovieCache =
LinkedHashMap<String, RadarrMovieDetail>(RADARR_MOVIE_CACHE_SIZE, 0.75f, true)
private val radarrMovieInFlight = mutableMapOf<String, Deferred<RadarrMovieDetail?>>()
fun cachedHome(): HomeCache? = settings.homeCache(snapshot)
@@ -433,6 +470,7 @@ class EmbyRepository internal constructor(
): String {
clearPlayableCache()
clearSeriesEpisodeCache()
clearLocalResume()
settings.ensureDeviceId()
observedSettings = settings.snapshot() // pick up the freshly-generated device id
@@ -524,6 +562,7 @@ class EmbyRepository internal constructor(
cachedBaseUrl = null
clearPlayableCache()
clearSeriesEpisodeCache()
clearLocalResume()
}
suspend fun signOut() {
@@ -538,6 +577,7 @@ class EmbyRepository internal constructor(
cachedBaseUrl = null
clearPlayableCache()
clearSeriesEpisodeCache()
clearLocalResume()
}
suspend fun switchProfile(profile: EmbyProfile) {
@@ -547,6 +587,7 @@ class EmbyRepository internal constructor(
cachedBaseUrl = null
clearPlayableCache()
clearSeriesEpisodeCache()
clearLocalResume()
}
suspend fun removeProfile(profile: EmbyProfile) {
@@ -563,6 +604,7 @@ class EmbyRepository internal constructor(
cachedBaseUrl = null
clearPlayableCache()
clearSeriesEpisodeCache()
clearLocalResume()
}
}
@@ -1122,6 +1164,17 @@ class EmbyRepository internal constructor(
if (ServerConfig.isGateway) requireGateway().updateNotification(id, "read")
}
/**
* Puts a notification back to new.
*
* Its own route rather than a flag on [markNotificationRead], because the two are not the
* same kind of event: read is set by the page focusing a row, unread is only ever somebody
* pressing the toggle on it.
*/
suspend fun markNotificationUnread(id: Long) {
if (ServerConfig.isGateway) requireGateway().updateNotification(id, "unread")
}
suspend fun dismissNotification(id: Long) {
if (ServerConfig.isGateway) requireGateway().updateNotification(id, "dismiss")
}
@@ -1528,6 +1581,9 @@ class EmbyRepository internal constructor(
/** Sets watched state explicitly and returns the value confirmed by Emby. */
suspend fun setPlayed(itemId: String, played: Boolean): Boolean {
// Watched or unwatched, this decides the title's position from outside playback, so
// whatever the player last recorded no longer describes anything.
forgetLocalResume(itemId)
if (ServerConfig.isGateway) {
return requireGateway().setPlayed(itemId, GatewayFlagRequest(played)).played
}
@@ -1543,6 +1599,8 @@ class EmbyRepository internal constructor(
/** Removes a title from Continue Watching without changing its watched state. */
suspend fun removeFromContinueWatching(itemId: String) {
// Taking a title off the shelf is a statement that its playhead no longer matters.
forgetLocalResume(itemId)
if (ServerConfig.isGateway) {
requireGateway().hideFromResume(itemId)
return
@@ -1735,6 +1793,56 @@ class EmbyRepository internal constructor(
return CachedTrailer(requireApi().getLocalTrailers(userId, itemId).items.firstOrNull())
}
/**
* The Radarr-only page for a film the household is tracking and has no copy of.
*
* Single-flighted on the repository's own scope like [getExtras], for the same reason:
* the caller is a page that can be left before its answer lands, and a request tied to
* the caller would be abandoned by exactly the navigation about to want it back.
*
* Never throws. There is no gateway on the direct path, an older container answers 404
* and a film Radarr has since forgotten answers nothing — all three are "no page", and
* the card stays where it was rather than opening onto an error.
*
* The **successful** answer is cached and a failure is not, so one bad minute does not
* leave a card inert for the session. It is deliberately not refreshed while it is
* held: the facts on it — a release date, a certificate, a studio — move on the scale of
* weeks, and the one that does not is [RadarrMovieDetail.embyItemId], which the home
* row's own refresh reports first anyway.
*/
suspend fun getRadarrMovie(itemId: String): RadarrMovieDetail? {
if (itemId.isBlank() || !ServerConfig.isGateway) return null
val inFlight = radarrMovieMutex.withLock {
radarrMovieCache[itemId]?.let { return it }
radarrMovieInFlight[itemId] ?: newRadarrMovieRequest(itemId)
}
return inFlight.await()
}
private fun newRadarrMovieRequest(itemId: String): Deferred<RadarrMovieDetail?> {
val request = scope.async(start = CoroutineStart.LAZY) {
try {
val loaded = runCatching { requireGateway().radarrMovie(itemId) }.getOrNull()
?: return@async null
radarrMovieMutex.withLock {
radarrMovieCache[itemId] = loaded
while (radarrMovieCache.size > RADARR_MOVIE_CACHE_SIZE) {
radarrMovieCache.entries.iterator().run {
next()
remove()
}
}
loaded
}
} finally {
radarrMovieMutex.withLock { radarrMovieInFlight.remove(itemId) }
}
}
radarrMovieInFlight[itemId] = request
request.start()
return request
}
/**
* A title's extras: trailers, featurettes, deleted scenes, behind-the-scenes material.
*
@@ -1910,7 +2018,14 @@ class EmbyRepository internal constructor(
else -> null
},
isSeries = item.isSeries,
resumePositionMs = item.resumePositionMs,
// The card is only as fresh as the last home refresh, so the ledger corrects it
// here — at the one funnel every launch passes through, which is also what the
// player is opened with and what the gateway is given as its resume hint.
resumePositionMs = launchResumePositionMs(
resolvedPositionMs = 0L,
requestedPositionMs = item.resumePositionMs,
localPositionMs = localResumePositionMs(item.id),
),
logoUrl = logoUrl(item),
overview = item.overview,
episodeCode = episodeCode(item),
@@ -1937,6 +2052,7 @@ class EmbyRepository internal constructor(
resumePositionMs = launchResumePositionMs(
resolvedPositionMs = entry.playable.resumePositionMs,
requestedPositionMs = request.resumePositionMs,
localPositionMs = localResumePositionMs(request.itemId),
),
)
} finally {
@@ -1970,6 +2086,7 @@ class EmbyRepository internal constructor(
resumePositionMs = launchResumePositionMs(
resolvedPositionMs = cached.resumePositionMs,
requestedPositionMs = request.resumePositionMs,
localPositionMs = localResumePositionMs(request.itemId),
),
)
}
@@ -1984,6 +2101,7 @@ class EmbyRepository internal constructor(
resumePositionMs = launchResumePositionMs(
resolvedPositionMs = resolved.resumePositionMs,
requestedPositionMs = request.resumePositionMs,
localPositionMs = localResumePositionMs(request.itemId),
),
).also {
playableMutex.withLock { playableCache.remove(request.itemId) }
@@ -2273,6 +2391,11 @@ class EmbyRepository internal constructor(
/** Drop negotiated playback state when a catalogue refresh can change episode selection. */
suspend fun invalidatePlaybackPrefetch() = clearPlayableCache()
/**
* Deliberately does not touch [localResume]: a playback stop clears this cache, and the
* whole point of the ledger is to outlive that and answer the launch that follows.
* Session changes call [clearLocalResume] beside this one.
*/
private suspend fun clearPlayableCache() {
playableMutex.withLock {
playableCache.clear()
@@ -2313,6 +2436,62 @@ class EmbyRepository internal constructor(
extrasInFlight.values.forEach { it.cancel() }
extrasInFlight.clear()
}
radarrMovieMutex.withLock {
radarrMovieCache.clear()
radarrMovieInFlight.values.forEach { it.cancel() }
radarrMovieInFlight.clear()
}
}
/**
* Remembers where the player left [itemId], or forgets it where the title was finished
* — a completed title has its position reset by the server, so a record kept past that
* would send somebody back into the closing minutes of something they had deliberately
* started again.
*
* Called before the report goes out rather than after it lands, because the failure this
* exists for is a viewer pressing Play again inside the second or two the round trip
* takes, and a record written on success would not be there yet.
*/
private fun recordLocalResume(itemId: String, positionMs: Long, durationMs: Long) {
if (itemId.isBlank()) return
synchronized(localResume) {
if (positionMs <= 0L || playbackCompletesItem(positionMs, durationMs)) {
localResume.remove(itemId)
} else {
localResume[itemId] = LocalResume(positionMs, System.currentTimeMillis())
}
}
}
/** The remembered playhead for [itemId], or zero where there is nothing current to say. */
private fun localResumePositionMs(itemId: String): Long {
if (itemId.isBlank()) return 0L
val now = System.currentTimeMillis()
return synchronized(localResume) {
val entry = localResume[itemId] ?: return@synchronized 0L
if (isFreshLocalResume(entry.recordedAtMs, now)) {
entry.positionMs
} else {
localResume.remove(itemId)
0L
}
}
}
/**
* Drops the remembered playhead for [itemId]. Marking a title watched or unwatched sets
* its position from outside playback entirely, so a record made before that decision has
* nothing left to describe.
*/
private fun forgetLocalResume(itemId: String) {
if (itemId.isBlank()) return
synchronized(localResume) { localResume.remove(itemId) }
}
/** Another viewer's playheads are not this one's; cleared wherever the session changes. */
private fun clearLocalResume() {
synchronized(localResume) { localResume.clear() }
}
suspend fun reportPlaybackStarted(session: PlaybackSession, positionMs: Long) {
@@ -2332,6 +2511,10 @@ class EmbyRepository internal constructor(
eventName: String,
durationMs: Long = 0L,
): String? = playbackReportMutex.withLock {
// The heartbeat is not what the resume point depends on any more, but it is free
// evidence: a process killed between two reports still leaves the ledger describing
// a position within ten seconds of the truth.
recordLocalResume(session.itemId, positionMs, durationMs)
if (ServerConfig.isGateway) {
requireGateway().report(
"progress",
@@ -2345,13 +2528,24 @@ class EmbyRepository internal constructor(
}
}
suspend fun reportPlaybackStopped(session: PlaybackSession, positionMs: Long) {
suspend fun reportPlaybackStopped(
session: PlaybackSession,
positionMs: Long,
durationMs: Long = 0L,
) {
// Recorded and published before the report is even attempted. Everything after this
// line can fail, be retried by WorkManager or simply be slower than the viewer, and
// the position they left at is still the one the next launch starts from.
publishFinalPosition(session.itemId, positionMs, durationMs)
try {
playbackReportMutex.withLock {
if (ServerConfig.isGateway) {
// Stopping is also what drops the gateway's cached rows for this user,
// so Continue Watching reflects the new position on the next home load.
requireGateway().report("stopped", session.gatewayReport(positionMs, true, null))
requireGateway().report(
"stopped",
session.gatewayReport(positionMs, true, null, durationMs),
)
} else {
requireApi().reportPlaybackStopped(playbackReport(session, positionMs, true, null))
}
@@ -2364,13 +2558,31 @@ class EmbyRepository internal constructor(
}
}
/**
* The final playhead, taken as authoritative the moment the player reports it: written
* to the local ledger and announced to the launcher. It is idempotent, which matters
* because the durable WorkManager fallback replays the same stop.
*/
private fun publishFinalPosition(itemId: String, positionMs: Long, durationMs: Long) {
if (itemId.isBlank()) return
recordLocalResume(itemId, positionMs, durationMs)
_playbackPositions.tryEmit(
PlaybackPosition(itemId, positionMs.coerceAtLeast(0L), durationMs.coerceAtLeast(0L)),
)
}
fun enqueuePlaybackStopped(
session: PlaybackSession,
positionMs: Long,
durationMs: Long = 0L,
onSuccess: () -> Unit = {},
) {
// Synchronously, before the coroutine is even scheduled: leaving the player and
// pressing Play again is a couple of hundred milliseconds, and the ledger has to be
// right by then rather than whenever the dispatcher gets round to it.
publishFinalPosition(session.itemId, positionMs, durationMs)
scope.launch {
runCatching { reportPlaybackStopped(session, positionMs) }
runCatching { reportPlaybackStopped(session, positionMs, durationMs) }
.onSuccess { onSuccess() }
}
}
@@ -3066,6 +3278,22 @@ data class PlaybackSession(
val playMethod: String = "DirectPlay",
)
/** Where the player left a title, and when this set recorded that. */
internal data class LocalResume(val positionMs: Long, val recordedAtMs: Long)
/**
* A playhead this television is sure of, published as it leaves the player so the launcher
* can move the card's progress bar without waiting on the server.
*/
data class PlaybackPosition(
val itemId: String,
val positionMs: Long,
/** Zero where the runtime was not known; only a positive value can say a title finished. */
val durationMs: Long = 0L,
) {
val completed: Boolean get() = playbackCompletesItem(positionMs, durationMs)
}
private data class PlaybackDiscovery(
val subtitles: List<PlayableSubtitle> = emptyList(),
val mediaSourceId: String = "",
@@ -3205,6 +3433,9 @@ private const val CONTINUE_PLAY_LOOKBACK = 120
*/
private const val TRICKPLAY_CACHE_SIZE = 12
/** Enough titles to cover an evening's browsing; the record only has to outlive one refresh. */
private const val LOCAL_RESUME_CACHE_SIZE = 32
// A season's worth, so working through one show in an evening never asks twice.
private const val INTRO_CACHE_SIZE = 24
@@ -3240,6 +3471,7 @@ private const val RELATED_LIMIT = 12
*/
private const val TRAILER_CACHE_SIZE = 64
private const val EXTRAS_CACHE_SIZE = 64
private const val RADARR_MOVIE_CACHE_SIZE = 32
private const val MAX_TRAILER_CANDIDATES = 12
// Long enough that walking back and forth between a row and a detail page never re-asks,
@@ -3252,9 +3484,61 @@ internal fun millisecondsToTicks(milliseconds: Long): Long =
internal fun isFreshPlayablePrefetch(resolvedAtMs: Long, nowMs: Long): Boolean =
resolvedAtMs <= nowMs && nowMs - resolvedAtMs <= PLAYABLE_PREFETCH_MAX_AGE_MS
/** A positive position on the pressed card outranks an older prefetched zero. */
internal fun launchResumePositionMs(resolvedPositionMs: Long, requestedPositionMs: Long): Long =
if (requestedPositionMs > 0L) requestedPositionMs else resolvedPositionMs.coerceAtLeast(0L)
/** Emby's own rule for a finished title; a stop past it resets the position rather than saving it. */
internal const val PLAYBACK_COMPLETION_FRACTION = 0.9
/** How long the television argues with a stale card before deferring to the server again. */
internal const val LOCAL_RESUME_MAX_AGE_MS = 12L * 60L * 60L * 1_000L
/**
* Where a launch starts from.
*
* The television's own record of where it last left this title outranks both of the
* others, and that is the whole of the fix for a short session losing its progress.
* Neither of the other two can be trusted to be current: [requestedPositionMs] is read
* off the card, which is only as fresh as the last home refresh, and [resolvedPositionMs]
* is no better, because the direct path resolves nothing at all and the gateway accepts
* the client's position as a hint rather than reading Emby again. So the position the
* player left at has to be remembered here, or a viewer who exits and presses Play again
* before the refresh lands is sent back to where they were before they watched.
*
* It is a **greatest**, never simply a preference, which is what retires the record with
* no bookkeeping at all: once a refresh brings the card back carrying that position — or a
* further one, watched on another set — the card is at least as current and the local
* record can no longer change the answer.
*/
internal fun launchResumePositionMs(
resolvedPositionMs: Long,
requestedPositionMs: Long,
localPositionMs: Long = 0L,
): Long {
val known = maxOf(requestedPositionMs, localPositionMs)
return if (known > 0L) known else resolvedPositionMs.coerceAtLeast(0L)
}
/**
* Whether a title stopped at [positionMs] has been finished, in which case there is no
* resume point worth remembering: the server resets a completed title's position, and a
* record kept past that would drop somebody back into the closing minutes of something
* they had chosen to watch again from the start.
*/
internal fun playbackCompletesItem(
positionMs: Long,
durationMs: Long,
completedFraction: Double = PLAYBACK_COMPLETION_FRACTION,
): Boolean = durationMs > 0L && positionMs >= (durationMs * completedFraction).toLong()
/**
* Whether a locally recorded resume point may still speak for a title. The backstop is
* deliberately generous — the record retires itself as soon as a refreshed card catches up
* with it — but not unbounded, or a set that recorded a position and was then left alone
* for a week would still be arguing with the server about it.
*/
internal fun isFreshLocalResume(
recordedAtMs: Long,
nowMs: Long,
maxAgeMs: Long = LOCAL_RESUME_MAX_AGE_MS,
): Boolean = recordedAtMs in 1L..nowMs && nowMs - recordedAtMs <= maxAgeMs
private val BaseItem.resumePositionMs: Long
get() = ((userData?.playbackPositionTicks ?: 0L) / 10_000L).coerceAtLeast(0L)
@@ -304,6 +304,8 @@ data class Settings(
val username: String? = null,
/** Admin-defined user-switcher avatar text; blank uses the username-derived fallback. */
val profileInitials: String = "",
/** Admin-defined friendly name for the launcher's greeting; blank uses [username]. */
val shortName: String = "",
val deviceId: String = "",
val deviceName: String = "",
val rotationIntervalSeconds: Int = DEFAULT_ROTATION_SECONDS,
@@ -479,6 +481,7 @@ data class EmbyProfile(
val userId: String,
val username: String,
val profileInitials: String = "",
val shortName: String = "",
val serverId: String? = null,
val homeCacheJson: String? = null,
val forYouMinutes: Int = 0,
@@ -574,6 +577,7 @@ class SettingsStore(private val context: Context) {
val HOME_HIDDEN_ROWS = stringPreferencesKey("home_hidden_rows")
val WELCOME_QUOTE_STYLE = stringPreferencesKey("welcome_quote_style")
val PROFILE_INITIALS = stringPreferencesKey("profile_initials")
val SHORT_NAME = stringPreferencesKey("short_name")
val THEME_ID = stringPreferencesKey("theme_id")
val THEME_PALETTE = stringPreferencesKey("theme_palette")
val THEME_ICON_SET = stringPreferencesKey("theme_icon_set")
@@ -806,6 +810,7 @@ class SettingsStore(private val context: Context) {
context.dataStore.edit { store ->
store[Keys.HOME_SECTIONS] = sections
store[Keys.PROFILE_INITIALS] = preferences.profileInitials
store[Keys.SHORT_NAME] = preferences.shortName
store[Keys.HOME_CARD_DENSITY] = preferences.homeCardDensity
store[Keys.HOME_ARTWORK_STYLE] = preferences.homeArtworkStyle
store[Keys.SHOW_HOME_CARD_METADATA] = preferences.showHomeCardMetadata
@@ -830,6 +835,7 @@ class SettingsStore(private val context: Context) {
updateActiveProfile(store) {
it.copy(
profileInitials = preferences.profileInitials,
shortName = preferences.shortName,
homeSections = sections,
homeCardDensity = preferences.homeCardDensity,
homeArtworkStyle = preferences.homeArtworkStyle,
@@ -1080,10 +1086,18 @@ class SettingsStore(private val context: Context) {
}
}
suspend fun markUpdateAlertRead() {
/**
* The local update notice's read flag, both ways.
*
* This alert is the one row on the Notifications page the gateway knows nothing about —
* it is a property of the APK on *this* set — so its seen toggle has to be written here
* rather than posted. Guarded on the version still being recorded: a flag left behind by
* an alert somebody has already dismissed describes nothing.
*/
suspend fun setUpdateAlertRead(read: Boolean) {
context.dataStore.edit { preferences ->
if (!preferences[Keys.UPDATE_ALERT_VERSION].isNullOrBlank()) {
preferences[Keys.UPDATE_ALERT_READ] = true
preferences[Keys.UPDATE_ALERT_READ] = read
}
}
}
@@ -1246,6 +1260,7 @@ class SettingsStore(private val context: Context) {
userId = userId,
username = username,
profileInitials = previous?.profileInitials.orEmpty(),
shortName = previous?.shortName.orEmpty(),
serverId = serverId,
homeCacheJson = previous?.homeCacheJson,
forYouMinutes = previous?.forYouMinutes ?: 0,
@@ -1367,6 +1382,7 @@ class SettingsStore(private val context: Context) {
// this TV believe it had already synced settings it has never seen.
preferences.remove(Keys.PREFERENCES_REVISION)
preferences.remove(Keys.PROFILE_INITIALS)
preferences.remove(Keys.SHORT_NAME)
preferences.remove(Keys.USERNAME)
}
@@ -1376,6 +1392,7 @@ class SettingsStore(private val context: Context) {
preferences[Keys.USER_ID] = profile.userId
preferences[Keys.USERNAME] = profile.username
preferences[Keys.PROFILE_INITIALS] = profile.profileInitials
preferences[Keys.SHORT_NAME] = profile.shortName
if (profile.serverId.isNullOrBlank()) preferences.remove(Keys.SERVER_ID)
else preferences[Keys.SERVER_ID] = profile.serverId
// Prefer the profile's dedicated cache key; fall back to a copy embedded in the
@@ -1442,6 +1459,7 @@ class SettingsStore(private val context: Context) {
userId = userId,
username = username,
profileInitials = preferences[Keys.PROFILE_INITIALS].orEmpty(),
shortName = preferences[Keys.SHORT_NAME].orEmpty(),
serverId = preferences[Keys.SERVER_ID],
homeCacheJson = activeHomeCache(preferences),
forYouMinutes = preferences[Keys.FOR_YOU_MINUTES] ?: 0,
@@ -1491,6 +1509,7 @@ class SettingsStore(private val context: Context) {
serverId = preferences[Keys.SERVER_ID],
username = preferences[Keys.USERNAME],
profileInitials = preferences[Keys.PROFILE_INITIALS].orEmpty(),
shortName = preferences[Keys.SHORT_NAME].orEmpty(),
deviceId = preferences[Keys.DEVICE_ID].orEmpty(),
deviceName = preferences[Keys.DEVICE_NAME].orEmpty(),
rotationIntervalSeconds = preferences[Keys.ROTATION_SECONDS] ?: Settings.DEFAULT_ROTATION_SECONDS,
@@ -25,6 +25,12 @@ import kotlinx.serialization.json.putJsonArray
data class UserPreferences(
/** Admin-defined avatar text; blank keeps the name-derived fallback. */
val profileInitials: String = "",
/**
* The friendly name the launcher greets this person by — "Matt" for an account called
* MattCohen. It is not a second username: nothing is keyed on it and nothing signs in
* with it, so blank is the ordinary state and the account name stands in.
*/
val shortName: String = "",
val homeSections: List<String> = DEFAULT_SECTIONS,
val homeCardDensity: String = Settings.DEFAULT_HOME_CARD_DENSITY,
val homeArtworkStyle: String = Settings.DEFAULT_HOME_ARTWORK_STYLE,
@@ -78,6 +84,7 @@ data class UserPreferences(
*/
fun Settings.toUserPreferences(): UserPreferences = UserPreferences(
profileInitials = profileInitials,
shortName = shortName,
homeSections = homeSections.decodeCommaList(),
homeCardDensity = homeCardDensity,
homeArtworkStyle = homeArtworkStyle,
@@ -120,6 +127,7 @@ fun decodeUserPreferences(
fallback: UserPreferences = UserPreferences(),
): UserPreferences = UserPreferences(
profileInitials = json.string("profileInitials", fallback.profileInitials),
shortName = json.string("shortName", fallback.shortName),
homeSections = json.stringList("homeSections", fallback.homeSections)
.ifEmpty { fallback.homeSections },
homeCardDensity = json.string("homeCardDensity", fallback.homeCardDensity),
@@ -155,6 +163,7 @@ fun decodeUserPreferences(
/** The document as the gateway expects it. The server normalises whatever arrives. */
fun UserPreferences.encode(): JsonObject = buildJsonObject {
put("profileInitials", profileInitials)
put("shortName", shortName)
putJsonArray("homeSections") { homeSections.forEach { add(JsonPrimitive(it)) } }
put("homeCardDensity", homeCardDensity)
put("homeArtworkStyle", homeArtworkStyle)
@@ -457,6 +457,10 @@ data class BaseItem(
// The Emby series a schedule card stands for, when the library holds it. Absent for a
// show Sonarr follows but Emby has never imported, so the card stays informational.
@SerialName("MembySeriesItemId") val membySeriesItemId: String? = null,
// The Emby film a movie-schedule card stands for, when the library holds it. Absent for
// a film Radarr is tracking but Emby has never imported, which is what sends the card to
// the Radarr-only detail page instead of to the ordinary one.
@SerialName("MembyMovieItemId") val membyMovieItemId: String? = null,
// Derived by the TV from the weekly schedule row and retained in the local home cache.
@SerialName("MembyAiringToday") val membyAiringToday: Boolean = false,
// Explainability supplied only by the gateway's dedicated For You endpoint.
@@ -491,6 +495,14 @@ data class BaseItem(
val isMovieSchedule: Boolean get() = membySource == "radarr"
val isSchedule: Boolean get() = isTvSchedule || isMovieSchedule
/**
* A film Radarr is tracking that Emby has no copy of, which is the one card in the app
* with a page of its own rather than an Emby one. The moment the library imports it the
* gateway attaches [membyMovieItemId] and this is false, so a title stops being a
* Radarr card without anything having to be invalidated.
*/
val isRadarrOnly: Boolean get() = isMovieSchedule && membyMovieItemId.isNullOrBlank()
/**
* Whether more episodes are expected. Sonarr's answer wins where the gateway attached
* one, since it knows about a season announced but not yet imported; Emby's own
@@ -417,6 +417,59 @@ data class GatewayActiveHero(
val rows: List<HomeRow> = emptyList(),
)
/**
* A film Radarr is tracking that Emby has never imported.
*
* Deliberately not a [BaseItem]. There is no Emby record behind it, no user data, and
* nothing to play, so dressing it as one would put a Play button, a watched tick and a
* progress bar on a page where all three would be lies. When Emby does hold the film,
* [embyItemId] arrives and the television opens the ordinary detail page instead.
*
* Every word on it is the gateway's — the state treatment, the expected-release wording and
* the date labels alike — the arrangement the schedule cards and lifecycle tags already
* take, so a phrasing invented on the server next month reads correctly here.
*/
@Serializable
data class RadarrMovieDetail(
val id: String = "",
val title: String = "",
val originalTitle: String = "",
val overview: String = "",
val year: Int = 0,
val runtimeMinutes: Int = 0,
val genres: List<String> = emptyList(),
val studio: String = "",
val certificate: String = "",
val monitored: Boolean = false,
val lifecycle: String = "",
val lifecycleText: String = "",
val stateLabel: String = "",
val stateDetail: String = "",
val expectedLabel: String = "",
val releaseDates: List<RadarrReleaseDate> = emptyList(),
val availabilityNotice: String = "",
/**
* Whether to offer Trailer at all. Decided by the gateway, which is what knows whether
* there is a candidate to resolve — a button that fails after being pressed is the one
* outcome this page must not produce.
*/
val trailerAvailable: Boolean = false,
val ratings: List<MediaRating> = emptyList(),
/**
* Emby's own id for the film, once the library holds it. Its arrival is what retires
* this page for that title, with nothing to invalidate on either side.
*/
val embyItemId: String = "",
)
/** One of Radarr's three dates: `kind` is a lookup key, `label` and `value` are prose. */
@Serializable
data class RadarrReleaseDate(
val kind: String = "",
val label: String = "",
val value: String = "",
)
/** Normalised third-party rating shared by cards, banners, and detail pages. */
@Serializable
data class MediaRating(
@@ -220,6 +220,18 @@ interface GatewayApi {
@GET("v1/people/{id}/filmography")
suspend fun personFilmography(@Path("id") personId: String): GatewayItems
/**
* Everything the Radarr-only detail page draws, for a film the household is tracking but
* has no copy of. Its own route rather than `v1/items/{id}` because there is no Emby
* item to ask about: the answer is Radarr's catalogue, the ratings store and whether
* Emby has since imported it. A gateway that predates the route answers 404, which the
* repository reads as "no page to open" rather than as an error.
*/
@GET("v1/radarr/movies/{id}")
suspend fun radarrMovie(
@Path("id") movieId: String,
): com.ponzischeme89.memby.data.model.RadarrMovieDetail
/** Optional, server-filtered external movie ratings. Empty is always a valid result. */
@GET("v1/items/{id}/ratings")
suspend fun movieRatings(@Path("id") itemId: String): GatewayMovieRatings
@@ -1027,6 +1027,21 @@ private fun SkeletonBlock(width: Dp, height: Dp) {
)
}
/**
* One entry in the long-press menu.
*
* The menu is built as a list rather than as a fixed sequence of blocks with hand-written
* indices, because what belongs on it depends on the card: a film Radarr is tracking has no
* Emby record to favourite or mark watched, and it has a trailer where an ordinary card
* does not. Deriving the focus indices from the list is what keeps that from being four
* pieces of arithmetic to hold in step.
*/
private data class QuickAction(
val label: String,
val icon: ImageVector,
val onClick: () -> Unit,
)
@Composable
fun MediaQuickActionsOverlay(
item: BaseItem,
@@ -1034,6 +1049,13 @@ fun MediaQuickActionsOverlay(
onSetFavorite: (BaseItem, Boolean) -> Unit,
onSetPlayed: (BaseItem, Boolean) -> Unit,
onRemoveFromContinueWatching: (() -> Unit)? = null,
/**
* Offered from the row for a film that is not in the library yet, which is the one card
* whose trailer is the only thing there is to play. Pressing the card itself still opens
* its page — a long press is where a shortcut belongs, not where the ordinary action is
* replaced by it.
*/
onPlayTrailer: (() -> Unit)? = null,
rowTitle: String? = null,
rowPinned: Boolean = false,
onToggleRowPinned: (() -> Unit)? = null,
@@ -1045,10 +1067,56 @@ fun MediaQuickActionsOverlay(
onToggleRowPinned != null &&
onHideRow != null &&
onMoveRow != null
val rowActionStartIndex = 3 + if (onRemoveFromContinueWatching != null) 1 else 0
val actionCount = 4 + (if (onRemoveFromContinueWatching != null) 1 else 0) +
(if (hasRowActions) 4 else 0)
val focusRequesters = remember(item.id) { List(actionCount) { FocusRequester() } }
// Library state belongs to an Emby item. A card standing for a film the household does
// not hold has none, and a "Mark watched" that answers 404 is worse than no entry.
val hasLibraryActions = !item.isRadarrOnly
val itemActions = buildList {
add(QuickAction("View details", MembyIcon.Info.mark) { onOpenDetails(item) })
onPlayTrailer?.let { play ->
add(QuickAction("Play trailer", MembyIcon.Movie.mark) { play() })
}
if (hasLibraryActions) {
add(
QuickAction(
label = if (item.isFavorite) "Remove from favourites" else "Add to favourites",
icon = if (item.isFavorite) MembyIcon.Favourite.mark else MembyIcon.FavouriteOutline.mark,
) {
onSetFavorite(item, !item.isFavorite)
onClose()
},
)
add(
QuickAction(
label = if (item.userData?.played == true) "Mark unwatched" else "Mark watched",
icon = MembyIcon.CheckCircle.mark,
) {
onSetPlayed(item, item.userData?.played != true)
onClose()
},
)
}
onRemoveFromContinueWatching?.let {
add(QuickAction("Remove from Continue Watching", MembyIcon.PlaylistRemove.mark, it))
}
}
val rowActions = if (!hasRowActions) {
emptyList()
} else {
listOf(
QuickAction(
label = if (rowPinned) "Unpin row" else "Pin row to top",
icon = MembyIcon.Pin.mark,
onClick = onToggleRowPinned!!,
),
QuickAction("Move row up", MembyIcon.ArrowUp.mark) { onMoveRow!!(-1) },
QuickAction("Move row down", MembyIcon.ArrowDown.mark) { onMoveRow!!(1) },
QuickAction("Hide this row", MembyIcon.HideWatched.mark, onHideRow!!),
)
}
val actions = itemActions + rowActions +
QuickAction("Close", MembyIcon.ChevronLeft.mark, onClose)
val actionCount = actions.size
val focusRequesters = remember(item.id, actionCount) { List(actionCount) { FocusRequester() } }
var focusedIndex by remember(item.id) { mutableStateOf(0) }
// The menu can appear while OK is still physically held. Until that opening press
// is released, consume all activation events so it cannot trigger the first action.
@@ -1127,118 +1195,53 @@ fun MediaQuickActionsOverlay(
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(horizontal = 8.dp).padding(bottom = 8.dp),
)
QuickActionMenuItem(
label = "View details",
icon = MembyIcon.Info.mark,
modifier = Modifier
.focusRequester(focusRequesters[0])
.onFocusChanged { if (it.isFocused) focusedIndex = 0 },
onClick = { onOpenDetails(item) },
)
Spacer(Modifier.height(2.dp))
QuickActionMenuItem(
label = if (item.isFavorite) "Remove from favourites" else "Add to favourites",
icon = if (item.isFavorite) MembyIcon.Favourite.mark else MembyIcon.FavouriteOutline.mark,
modifier = Modifier
.focusRequester(focusRequesters[1])
.onFocusChanged { if (it.isFocused) focusedIndex = 1 },
onClick = {
onSetFavorite(item, !item.isFavorite)
onClose()
},
)
Spacer(Modifier.height(2.dp))
QuickActionMenuItem(
label = if (item.userData?.played == true) "Mark unwatched" else "Mark watched",
icon = MembyIcon.CheckCircle.mark,
modifier = Modifier
.focusRequester(focusRequesters[2])
.onFocusChanged { if (it.isFocused) focusedIndex = 2 },
onClick = {
onSetPlayed(item, item.userData?.played != true)
onClose()
},
)
if (onRemoveFromContinueWatching != null) {
Spacer(Modifier.height(2.dp))
actions.forEachIndexed { index, action ->
// The row actions are about the shelf rather than about the title, and
// Close is about neither, so each is introduced by its own rule.
when {
rowActions.isNotEmpty() && index == itemActions.size -> {
Spacer(Modifier.height(6.dp))
QuickActionDivider()
Text(
rowTitle.orEmpty(),
color = QuietText,
fontSize = 11.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier
.padding(start = 8.dp, top = 7.dp, end = 8.dp, bottom = 3.dp),
)
}
index == actionCount - 1 -> {
Spacer(Modifier.height(6.dp))
QuickActionDivider()
Spacer(Modifier.height(6.dp))
}
index > 0 -> Spacer(Modifier.height(2.dp))
}
QuickActionMenuItem(
label = "Remove from Continue Watching",
icon = MembyIcon.PlaylistRemove.mark,
label = action.label,
icon = action.icon,
modifier = Modifier
.focusRequester(focusRequesters[3])
.onFocusChanged { if (it.isFocused) focusedIndex = 3 },
onClick = onRemoveFromContinueWatching,
.focusRequester(focusRequesters[index])
.onFocusChanged { if (it.isFocused) focusedIndex = index },
onClick = action.onClick,
)
}
if (hasRowActions) {
Spacer(Modifier.height(6.dp))
Box(
Modifier
.fillMaxWidth()
.height(1.dp)
.background(Color.White.copy(alpha = 0.07f)),
)
Text(
rowTitle.orEmpty(),
color = QuietText,
fontSize = 11.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(start = 8.dp, top = 7.dp, end = 8.dp, bottom = 3.dp),
)
QuickActionMenuItem(
label = if (rowPinned) "Unpin row" else "Pin row to top",
icon = MembyIcon.Pin.mark,
modifier = Modifier
.focusRequester(focusRequesters[rowActionStartIndex])
.onFocusChanged { if (it.isFocused) focusedIndex = rowActionStartIndex },
onClick = onToggleRowPinned!!,
)
QuickActionMenuItem(
label = "Move row up",
icon = MembyIcon.ArrowUp.mark,
modifier = Modifier
.focusRequester(focusRequesters[rowActionStartIndex + 1])
.onFocusChanged { if (it.isFocused) focusedIndex = rowActionStartIndex + 1 },
onClick = { onMoveRow!!(-1) },
)
QuickActionMenuItem(
label = "Move row down",
icon = MembyIcon.ArrowDown.mark,
modifier = Modifier
.focusRequester(focusRequesters[rowActionStartIndex + 2])
.onFocusChanged { if (it.isFocused) focusedIndex = rowActionStartIndex + 2 },
onClick = { onMoveRow!!(1) },
)
QuickActionMenuItem(
label = "Hide this row",
icon = MembyIcon.HideWatched.mark,
modifier = Modifier
.focusRequester(focusRequesters[rowActionStartIndex + 3])
.onFocusChanged { if (it.isFocused) focusedIndex = rowActionStartIndex + 3 },
onClick = onHideRow!!,
)
}
Spacer(Modifier.height(6.dp))
Box(
Modifier
.fillMaxWidth()
.height(1.dp)
.background(Color.White.copy(alpha = 0.07f)),
)
Spacer(Modifier.height(6.dp))
QuickActionMenuItem(
label = "Close",
icon = MembyIcon.ChevronLeft.mark,
modifier = Modifier
.focusRequester(focusRequesters[actionCount - 1])
.onFocusChanged { if (it.isFocused) focusedIndex = actionCount - 1 },
onClick = onClose,
)
}
}
}
@Composable
private fun QuickActionDivider() {
Box(
Modifier
.fillMaxWidth()
.height(1.dp)
.background(Color.White.copy(alpha = 0.07f)),
)
}
@Composable
private fun QuickActionMenuItem(
label: String,
@@ -24,6 +24,19 @@ internal fun homeGreetingPeriod(hourOfDay: Int): HomeGreetingPeriod = when (hour
* It still goes away further down the launcher: by then somebody is looking for something
* to watch rather than being welcomed.
*/
/**
* The name Memby addresses somebody by, on the launcher and anywhere else it speaks to
* them directly.
*
* The short name is an operator's answer and wins where there is one, because it is the
* only one of the two a person actually chose to be called. Everything else falls back to
* [friendlyProfileName]'s reading of the account name, so a household that has never set
* one is greeted exactly as it was before — a blank or whitespace-only value is the
* ordinary state, not a name.
*/
internal fun greetingName(shortName: String?, username: String?): String? =
shortName?.trim()?.takeIf(String::isNotEmpty) ?: friendlyProfileName(username)
internal fun shouldShowHomeGreeting(
hasHero: Boolean,
focusedRowId: String?,
@@ -6,12 +6,14 @@ import androidx.lifecycle.viewModelScope
import com.ponzischeme89.memby.data.EmbyRepository
import com.ponzischeme89.memby.data.HomeCache
import com.ponzischeme89.memby.data.HomeSnapshot
import com.ponzischeme89.memby.data.PlaybackPosition
import com.ponzischeme89.memby.data.analytics.RowAnalytics
import com.ponzischeme89.memby.data.analytics.JourneyAnalytics
import com.ponzischeme89.memby.data.analytics.JourneySink
import com.ponzischeme89.memby.data.analytics.JourneyTracker
import com.ponzischeme89.memby.data.friendlyEmbyError
import com.ponzischeme89.memby.data.isMaintenanceError
import com.ponzischeme89.memby.data.millisecondsToTicks
import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.data.model.HomeRow
import com.ponzischeme89.memby.data.model.UserItemData
@@ -181,6 +183,12 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
init {
refreshAll()
viewModelScope.launch {
// Arrives as the player exits, ahead of the report and well ahead of the rows
// coming back, so the card a viewer is standing on already shows the progress
// they just made rather than the progress bar they left home with.
repository.playbackPositions.collect(::applyPlaybackPosition)
}
viewModelScope.launch {
repository.playbackStops.collect { refreshWatching() }
}
@@ -423,6 +431,14 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
* of the request.
*/
private suspend fun warmDetailPage(item: BaseItem) {
// A movie-schedule card whose film is not in the library opens a page of its own,
// and that page is one request. Warming it here is what makes it open on the press
// rather than a moment after it, and it is the only warm a schedule card has any
// use for — there is no Emby item behind it to fetch anything else about.
if (item.isRadarrOnly) {
runCatching { repository.getRadarrMovie(item.id) }
return
}
if (item.isSchedule) return
// Focus settling on a playable card is the best warning of a Play press this app
// gets. Opening the connection to Emby now means the press pays for bytes rather
@@ -556,6 +572,26 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
}
}
/**
* Moves a card's playhead to where the player just left it, before anything is asked of
* the server. [refreshWatching] follows and replaces this with the server's own answer;
* this is what stands in for it in the meantime, which is exactly the window a viewer
* who exits and presses Play again is inside.
*
* A completed title is left alone rather than pushed to its own end: what happens to it
* is that it leaves Continue Watching, which is the refresh's answer to give.
*/
private fun applyPlaybackPosition(position: PlaybackPosition) {
if (position.itemId.isBlank() || position.completed) return
val ticks = millisecondsToTicks(position.positionMs)
updateUserData(position.itemId) {
// Never backwards: a stop and the ten-second report before it can arrive in
// either order, and the card must not step back to the earlier of the two.
if (ticks > it.playbackPositionTicks) it.copy(playbackPositionTicks = ticks) else it
}
viewModelScope.launch { persistCurrentHome() }
}
private suspend fun refreshWatching() {
refreshMutex.withLock {
_state.update { it.copy(loading = it.loading + HomeSection.CONTINUE) }
@@ -140,6 +140,7 @@ import com.ponzischeme89.memby.data.remoteconfig.MembyRemoteConfig
import com.ponzischeme89.memby.ui.alerts.MyAlertsPage
import com.ponzischeme89.memby.ui.detail.AiringNotice
import com.ponzischeme89.memby.ui.detail.airingNoticeFor
import com.ponzischeme89.memby.ui.detail.scheduleMovieStub
import com.ponzischeme89.memby.ui.detail.scheduleSeriesStub
import com.ponzischeme89.memby.ui.calendar.CalendarScreen
import com.ponzischeme89.memby.ui.requests.RequestsScreen
@@ -1942,6 +1943,10 @@ private fun HomeScreen(
// same series reached from Favourites or a search never claims a schedule.
var detailsAiringNotice by remember { mutableStateOf<AiringNotice?>(null) }
var quickMenuItem by remember { mutableStateOf<BaseItem?>(null) }
// Whether the long-press menu may offer a trailer for the Radarr card it is open on.
// Asked once, when the menu opens, and false until answered: a row entry that appears
// and then fails is worse than one that arrives a moment late.
var quickMenuTrailerAvailable by remember { mutableStateOf(false) }
var quickMenuRowId by remember { mutableStateOf<String?>(null) }
var focusedHomeRowId by remember { mutableStateOf<String?>(null) }
var sectionHeroRows by remember(settings.userId) {
@@ -3216,13 +3221,22 @@ private fun HomeScreen(
// the viewer asked for is the show — carrying the air
// time across, since that is why they pressed it.
val seriesStub = scheduleSeriesStub(item)
// A movie-schedule card whose film Emby has since
// imported is the ordinary movie page; one whose film
// is still only Radarr's opens its own. Neither is
// inert, which is what the card used to be.
val movieStub = scheduleMovieStub(item)
if (seriesStub != null) {
detailsAiringNotice = airingNoticeFor(item)
homeViewModel.focusItem(seriesStub)
detailsItem = seriesStub
} else if (movieStub != null) {
detailsAiringNotice = null
homeViewModel.focusItem(movieStub)
detailsItem = movieStub
} else {
homeViewModel.focusItem(item)
if (item.membyPlayable) {
if (item.membyPlayable || item.isRadarrOnly) {
detailsAiringNotice = null
detailsItem = item
}
@@ -3233,7 +3247,13 @@ private fun HomeScreen(
returnRowKind = row.kind.name
returnItemId = item.id
homeViewModel.focusItem(item)
if (item.membyPlayable) {
if (item.membyPlayable || item.isRadarrOnly) {
// Cleared here rather than only in the effect that
// answers it: the effect runs after the menu's
// first frame, and the previous card's answer
// showing on it would be an entry that appears and
// then vanishes.
quickMenuTrailerAvailable = false
quickMenuRowId = row.id
quickMenuItem = item
}
@@ -3269,6 +3289,7 @@ private fun HomeScreen(
HomeClock(
showGreeting = showHomeGreeting,
username = settings.username,
shortName = settings.shortName,
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(end = 24.dp, bottom = 18.dp),
@@ -3524,6 +3545,14 @@ private fun HomeScreen(
// and should not: it was news, and it has been read.
detailsAiringNotice = null
},
onOpenEmbyItem = { embyItem ->
// Not a step in the trail: the Radarr page and the Emby page are two
// answers about one title, so Back from here still belongs where the
// card was pressed rather than on the page it replaced.
homeViewModel.focusItem(embyItem)
detailsAiringNotice = null
detailsItem = embyItem
},
onPlay = {
// Kept, not discarded: this is what the viewer comes back to when the
// film ends or they press Back out of the player.
@@ -3780,61 +3809,50 @@ private fun HomeScreen(
notificationsLoading = false
}
},
onToggleEnabled = {
if (notificationsMutationBusy) return@MyAlertsPage
notificationsMutationBusy = true
scope.launch {
val updated = notificationState.preferences.copy(
enabled = !notificationState.preferences.enabled,
)
runCatching { repo.setNotificationPreferences(updated) }
.onSuccess { notificationState = it }
.onFailure { notificationsError = friendlyEmbyError(it) }
notificationsMutationBusy = false
}
},
onToggleShowReturns = {
if (notificationsMutationBusy) return@MyAlertsPage
notificationsMutationBusy = true
scope.launch {
val updated = notificationState.preferences.copy(
showReturnAlerts = !notificationState.preferences.showReturnAlerts,
)
runCatching { repo.setNotificationPreferences(updated) }
.onSuccess { notificationState = it }
.onFailure { notificationsError = friendlyEmbyError(it) }
notificationsMutationBusy = false
}
},
// Marked read locally first. This fires on *focus*, so on a slow
// connection walking down the list and back up would send the same row's
// request once per pass — clearing the flag immediately is what makes the
// row stop asking.
onRead = onRead@{ notification ->
// The seen toggle, and the only thing that moves a row between Inbox and
// Seen — nothing is marked read merely by being looked at any more, because
// with the two halves split that would empty the Inbox under the remote.
//
// Optimistic and reversed on failure, like the dismissal below: the flag is
// the only thing that changed, so a row that sat unmoved while its request
// was in flight is one pressed a second time. The locally-held update notice
// has no server row to post, so its flag is written to this television's own
// settings instead — it is a fact about the APK on this set.
onToggleSeen = onToggleSeen@{ notification ->
val markingSeen = notification.unread
if (notification.id == MEMBY_UPDATE_NOTIFICATION_ID) {
scope.launch { ServiceLocator.settings.markUpdateAlertRead() }
return@onRead
scope.launch { ServiceLocator.settings.setUpdateAlertRead(markingSeen) }
return@onToggleSeen
}
val previousReadAt = notification.readAt
notificationState = notificationState.copy(
notifications = notificationState.notifications.map {
if (it.id == notification.id) it.copy(readAt = "now") else it
if (it.id == notification.id) {
it.copy(readAt = if (markingSeen) "now" else null)
} else {
it
}
},
)
scope.launch {
runCatching { repo.markNotificationRead(notification.id) }
.onFailure { failure ->
notificationState = notificationState.copy(
notifications = notificationState.notifications.map {
if (it.id == notification.id) {
it.copy(readAt = previousReadAt)
} else {
it
}
},
)
notificationsError = friendlyEmbyError(failure)
runCatching {
if (markingSeen) {
repo.markNotificationRead(notification.id)
} else {
repo.markNotificationUnread(notification.id)
}
}.onFailure { failure ->
notificationState = notificationState.copy(
notifications = notificationState.notifications.map {
if (it.id == notification.id) {
it.copy(readAt = previousReadAt)
} else {
it
}
},
)
notificationsError = friendlyEmbyError(failure)
}
}
},
// Optimistic, for the reason "Dismiss all" beneath it already is: this
@@ -3868,17 +3886,28 @@ private fun HomeScreen(
// list is emptied optimistically: the page is judged on emptying itself,
// and a row that lingered while its request was in flight would be pressed
// a second time.
onDismissAll = {
onDismissAll = { pending ->
if (notificationsMutationBusy) return@MyAlertsPage
notificationsMutationBusy = true
val previous = notificationState
val pending = notificationState.notifications.map(UserNotification::id)
val dismissLocalUpdate = settings.updateAlertVersion != null
notificationState = notificationState.copy(notifications = emptyList())
// Only the half on screen. The page dismisses what it is showing, so
// emptying Seen must not also throw away an Inbox the viewer has not
// read — a bulk action nobody can see the extent of is one nobody presses.
val pendingIds = pending.map(UserNotification::id).toSet()
val dismissLocalUpdate = MEMBY_UPDATE_NOTIFICATION_ID in pendingIds &&
settings.updateAlertVersion != null
notificationState = notificationState.copy(
notifications = notificationState.notifications.filterNot {
it.id in pendingIds
},
)
scope.launch {
if (dismissLocalUpdate) ServiceLocator.settings.dismissUpdateAlert()
val failed = pending.filter { id ->
runCatching { repo.dismissNotification(id) }.isFailure
// The local update notice has no server row, so asking the gateway to
// dismiss it would be one guaranteed failure per pass.
val failed = pendingIds.filter { id ->
id != MEMBY_UPDATE_NOTIFICATION_ID &&
runCatching { repo.dismissNotification(id) }.isFailure
}
runCatching { repo.getNotifications() }
.onSuccess { notificationState = it }
@@ -3896,6 +3925,10 @@ private fun HomeScreen(
)
}
quickMenuItem?.let { selected ->
LaunchedEffect(selected.id) {
quickMenuTrailerAvailable = selected.isRadarrOnly &&
repo.getRadarrMovie(selected.id)?.trailerAvailable == true
}
val closeQuickActions: (Boolean) -> Unit = { originWillDisappear ->
quickMenuItem = null
quickMenuRowId = null
@@ -3925,6 +3958,18 @@ private fun HomeScreen(
},
onSetFavorite = homeViewModel::setFavorite,
onSetPlayed = homeViewModel::setPlayed,
// Only for a film with no page to play from, and only when the gateway has
// a candidate to resolve — the same answer the detail page's button waits
// for, so the two can never disagree about whether there is a trailer.
onPlayTrailer = if (selected.isRadarrOnly && quickMenuTrailerAvailable) {
{
quickMenuItem = null
quickMenuRowId = null
playTrailer(selected)
}
} else {
null
},
onRemoveFromContinueWatching = if (
rows.firstOrNull { it.id == quickMenuRowId }?.kind == MediaRowKind.CONTINUE
) {
@@ -4021,6 +4066,7 @@ private fun HomeScreen(
!settings.hasOpenedForYou &&
liveMaintenance == null,
username = settings.username,
shortName = settings.shortName,
modifier = Modifier.align(Alignment.TopCenter),
)
// Emby has stopped answering. Persistent, unlike the news bar below it, because
@@ -4170,6 +4216,7 @@ private fun RecentSearchesRow(
private fun HomeClock(
showGreeting: Boolean,
username: String?,
shortName: String?,
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
@@ -4189,7 +4236,7 @@ private fun HomeClock(
val period = homeGreetingPeriod(
Calendar.getInstance().apply { time = currentTime }.get(Calendar.HOUR_OF_DAY),
)
val name = friendlyProfileName(username)
val name = greetingName(shortName, username)
Row(
modifier = modifier,
horizontalArrangement = Arrangement.End,
@@ -4353,11 +4400,27 @@ private fun FocusedDetailsOverlay(
onTogglePlayed: (BaseItem, Boolean) -> Unit,
onClose: () -> Unit,
onOpenItem: (BaseItem) -> Unit,
/**
* Where a Radarr card goes once Emby has imported the film. The row is cached for the
* day on the gateway, so the card can still arrive without an Emby id long after the
* import; the detail request is what notices, and this is what acts on it.
*/
onOpenEmbyItem: (BaseItem) -> Unit = {},
airingNotice: AiringNotice? = null,
) {
val focusedItem by homeViewModel.focusedItem.collectAsStateWithLifecycle()
val item = focusedItem?.takeIf { it.id == selected.id } ?: selected
if (item.isSeries) {
if (item.isRadarrOnly) {
// A film Radarr is tracking that Emby has never imported. It is the one card with a
// page of its own rather than an Emby one — see [RadarrMovieDetailsOverlay] for why
// it is not the movie page with its playable parts taken away.
RadarrMovieDetailsOverlay(
card = item,
onPlayTrailer = onPlayTrailer,
onClose = onClose,
onOpenEmbyItem = onOpenEmbyItem,
)
} else if (item.isSeries) {
SeriesDetailsOverlay(
item = item,
onPlay = onPlay,
@@ -4405,6 +4468,7 @@ private fun FocusedQuickActionsOverlay(
onSetFavorite: (BaseItem, Boolean) -> Unit,
onSetPlayed: (BaseItem, Boolean) -> Unit,
onRemoveFromContinueWatching: (() -> Unit)?,
onPlayTrailer: (() -> Unit)?,
rowTitle: String?,
rowPinned: Boolean,
onToggleRowPinned: (() -> Unit)?,
@@ -4419,6 +4483,7 @@ private fun FocusedQuickActionsOverlay(
onSetFavorite = onSetFavorite,
onSetPlayed = onSetPlayed,
onRemoveFromContinueWatching = onRemoveFromContinueWatching,
onPlayTrailer = onPlayTrailer,
rowTitle = rowTitle,
rowPinned = rowPinned,
onToggleRowPinned = onToggleRowPinned,
@@ -4777,9 +4842,12 @@ private fun ForYouTimeBudget(
private fun ForYouNudgeBanner(
visible: Boolean,
username: String?,
shortName: String?,
modifier: Modifier = Modifier,
) {
val name = friendlyProfileName(username)
// The same name the hero greeting uses: two places addressing one person by two
// different names is worse than neither of them being personalised.
val name = greetingName(shortName, username)
androidx.compose.animation.AnimatedVisibility(
visible = visible,
enter = androidx.compose.animation.fadeIn(tween(220)),
@@ -38,6 +38,7 @@ import com.ponzischeme89.memby.ui.theme.MembyCardCorner
import com.ponzischeme89.memby.ui.theme.MembyChipCorner
import com.ponzischeme89.memby.ui.theme.MembyHairline
import com.ponzischeme89.memby.ui.theme.MembyMutedText
import com.ponzischeme89.memby.ui.theme.MembyOutline
/**
* One green Play button, in two sizes.
@@ -153,6 +154,45 @@ private fun PrimaryActionSurface(
}
}
/**
* The quiet counterpart to [MembyPlayButton]: an action that is available but is not what
* the screen is for. It is an outline rather than a fill, so the primary action stays the
* only green thing on the page and the two are told apart at three metres.
*/
@Composable
internal fun MembySecondaryButton(
label: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
compact: Boolean = false,
) {
var focused by remember { mutableStateOf(false) }
val scale by animateFloatAsState(if (focused) 1.055f else 1f, tween(100), label = "secondary-focus")
val shape = RoundedCornerShape(MembyCardCorner)
Box(
modifier = modifier
.graphicsLayer { scaleX = scale; scaleY = scale; translationY = if (focused) -3f else 0f }
.clip(shape)
.background(if (focused) MembyOutline else Color.Transparent)
.border(if (focused) 2.dp else 1.dp, if (focused) Color.White else MembyOutline, shape)
.onFocusChanged { focused = it.isFocused }
.clickable(onClick = onClick)
.padding(
horizontal = if (compact) 14.dp else 23.dp,
vertical = if (compact) 8.dp else 13.dp,
),
contentAlignment = Alignment.Center,
) {
Text(
label,
color = if (focused) Color.White else MembyMutedText,
fontSize = if (compact) 13.sp else 16.sp,
fontWeight = FontWeight.Bold,
maxLines = 1,
)
}
}
/**
* A selectable chip for a small set of mutually exclusive choices. The tick is a real icon
* on the selected chip rather than a character in the label, so the chip does not change
@@ -0,0 +1,377 @@
package com.ponzischeme89.memby.ui
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.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.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.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.tv.material3.Text
import coil.compose.AsyncImage
import com.ponzischeme89.memby.ServiceLocator
import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.data.model.RadarrMovieDetail
import com.ponzischeme89.memby.ui.detail.formatRuntime
import com.ponzischeme89.memby.ui.theme.MembyAccent
import com.ponzischeme89.memby.ui.theme.MembyAccentMuted
import com.ponzischeme89.memby.ui.theme.MembyCardCorner
import com.ponzischeme89.memby.ui.theme.MembyChipCorner
import com.ponzischeme89.memby.ui.theme.MembyControlSurface
import com.ponzischeme89.memby.ui.theme.MembyHairline
import com.ponzischeme89.memby.ui.theme.MembyMutedText
import com.ponzischeme89.memby.ui.theme.MembyOnSurface
import com.ponzischeme89.memby.ui.theme.MembyQuietText
import com.ponzischeme89.memby.ui.theme.ValueSeparator
import kotlinx.coroutines.delay
/**
* The page for a film Radarr is tracking that Emby has no copy of.
*
* It is its own page rather than the movie page with the playable parts removed, because
* the two answer different questions. The ordinary page's whole shape Play, a progress
* bar, watched state, tabs of cast and extras and similar titles is built around a file
* that exists, and none of it is true here. This one answers "when can I watch this, and
* what is it", which is three facts and a trailer, so it is one screen with no tabs and
* nothing to scroll.
*
* It also never manufactures an Emby item to get here. [card] is the schedule card exactly
* as the row received it used for artwork and for the title before the request lands, so
* the page appears immediately and everything else is [RadarrMovieDetail], which is a
* separate type on purpose. When Emby does import the film,
* [RadarrMovieDetail.embyItemId] arrives and [onOpenEmbyItem] takes the viewer to the
* ordinary page instead, with nothing on either side needing to be invalidated.
*/
@Composable
fun RadarrMovieDetailsOverlay(
card: BaseItem,
onPlayTrailer: (BaseItem) -> Unit,
onClose: () -> Unit,
onOpenEmbyItem: (BaseItem) -> Unit,
modifier: Modifier = Modifier,
) {
var detail by remember(card.id) { mutableStateOf<RadarrMovieDetail?>(null) }
LaunchedEffect(card.id) {
detail = ServiceLocator.repository.getRadarrMovie(card.id)
}
// The row is cached for the day on the gateway, so a film imported since it was built
// still arrives here wearing no Emby id. The detail answer is live, and it is the one
// that gets the viewer to the page they actually wanted.
val embyItem = remember(card.id, detail?.embyItemId) {
detail?.let { radarrEmbyStub(card, it) }
}
LaunchedEffect(embyItem?.id) {
embyItem?.let(onOpenEmbyItem)
}
if (embyItem != null) return
RadarrMovieDetailContent(
card = card,
detail = detail,
onPlayTrailer = { onPlayTrailer(card) },
onClose = onClose,
modifier = modifier,
)
}
/**
* The layout, with everything it draws as a parameter, so it can be screenshotted without a
* gateway behind it. [detail] is null while the request is still in flight the page draws
* the artwork and the title it already has rather than a spinner, because the card that was
* pressed is most of what the viewer came to look at.
*/
@Composable
internal fun RadarrMovieDetailContent(
card: BaseItem,
detail: RadarrMovieDetail?,
onPlayTrailer: () -> Unit,
onClose: () -> Unit,
modifier: Modifier = Modifier,
) {
val repository = ServiceLocator.repository
val poster = remember(card.id, card.imageTags) { repository.primaryUrl(card, maxWidth = 500) }
val title = detail?.title?.takeIf(String::isNotBlank) ?: card.name
val facts = remember(detail, card.productionYear, card.runTimeTicks) {
radarrMovieFacts(card, detail)
}
val back = remember(card.id) { FocusRequester() }
val trailer = remember(card.id) { FocusRequester() }
// Trailer takes the focus when there is one — it is the only thing on this page anybody
// came to press. Back is what claims it otherwise, so a page with no trailer still has
// somewhere for the remote to be.
val trailerOffered = detail?.trailerAvailable == true
LaunchedEffect(card.id, trailerOffered) {
delay(32L)
runCatching { if (trailerOffered) trailer.requestFocus() else back.requestFocus() }
}
Box(modifier.fillMaxSize()) {
DetailBackdrop(card, Modifier.fillMaxSize())
Row(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = RadarrPageGutter, vertical = 46.dp),
verticalAlignment = Alignment.CenterVertically,
) {
RadarrPoster(poster, title)
Spacer(Modifier.width(38.dp))
Column(Modifier.weight(1f)) {
RadarrStatusRow(detail)
Spacer(Modifier.height(14.dp))
Text(
text = title,
color = Color.White,
fontSize = 40.sp,
lineHeight = 44.sp,
fontWeight = FontWeight.Bold,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
detail?.originalTitle?.takeIf(String::isNotBlank)?.let { original ->
Spacer(Modifier.height(4.dp))
Text(original, color = MembyQuietText, fontSize = 14.sp, maxLines = 1)
}
if (facts.isNotEmpty()) {
Spacer(Modifier.height(10.dp))
DetailFactRow(facts)
}
detail?.genres?.filter(String::isNotBlank)?.takeIf(List<String>::isNotEmpty)
?.let { genres ->
Spacer(Modifier.height(8.dp))
Text(
text = genres.take(4).joinToString(ValueSeparator),
color = MembyMutedText,
fontSize = 13.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
detail?.ratings?.takeIf(List<*>::isNotEmpty)?.let { ratings ->
Spacer(Modifier.height(12.dp))
RatingsStrip(ratings, visible = true, modifier = Modifier.fillMaxWidth(0.8f))
}
Spacer(Modifier.height(16.dp))
RadarrReleaseBand(detail)
val overview = detail?.overview?.takeIf(String::isNotBlank)
?: card.overview?.takeIf(String::isNotBlank)
if (overview != null) {
Spacer(Modifier.height(16.dp))
Text(
text = overview,
color = MembyMutedText,
fontSize = 15.sp,
lineHeight = 22.sp,
maxLines = 4,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.fillMaxWidth(0.86f),
)
}
detail?.releaseDates?.takeIf(List<*>::isNotEmpty)?.let { dates ->
Spacer(Modifier.height(16.dp))
Row(horizontalArrangement = Arrangement.spacedBy(28.dp)) {
dates.forEach { date ->
Column {
Text(
text = date.label.uppercase(),
color = MembyQuietText,
fontSize = 10.sp,
fontWeight = FontWeight.Bold,
letterSpacing = 1.2.sp,
maxLines = 1,
)
Spacer(Modifier.height(3.dp))
Text(
text = date.value,
color = MembyOnSurface,
fontSize = 14.sp,
fontWeight = FontWeight.Medium,
maxLines = 1,
)
}
}
}
}
Spacer(Modifier.height(26.dp))
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(14.dp),
) {
// Offered only when the gateway has a candidate to resolve. A Trailer
// button that fails after being pressed is the one outcome this page
// must not produce, and the answer is known before it is drawn.
if (trailerOffered) {
MembyPlayButton(
label = "Play trailer",
onClick = onPlayTrailer,
onFocused = {},
modifier = Modifier.focusRequester(trailer),
)
}
MembySecondaryButton(
label = "Back",
onClick = onClose,
modifier = Modifier.focusRequester(back),
)
}
}
}
}
}
/** The gutter is the detail pages'; this page sits in the same column as the others. */
private val RadarrPageGutter = DetailSideGutter
private val RadarrPosterWidth = 236.dp
@Composable
private fun RadarrPoster(url: String?, title: String) {
val shape = RoundedCornerShape(MembyCardCorner)
Box(
Modifier
.width(RadarrPosterWidth)
.aspectRatio(2f / 3f)
.clip(shape)
.background(MembyControlSurface)
.border(1.dp, MembyHairline, shape),
) {
if (url != null) {
AsyncImage(
model = url,
contentDescription = title,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize(),
)
}
}
}
/**
* The status treatment: what this film is doing, and Radarr's own word for where it is in
* its life. Both are the gateway's wording nothing here is derived on the television, so
* a phrasing added on the server next month reads correctly on this build.
*/
@Composable
private fun RadarrStatusRow(detail: RadarrMovieDetail?) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = detail?.stateLabel?.takeIf(String::isNotBlank)?.uppercase() ?: "NOT IN MEMBY",
color = MembyAccent,
fontSize = 11.sp,
fontWeight = FontWeight.Bold,
letterSpacing = 1.8.sp,
maxLines = 1,
modifier = Modifier
.clip(RoundedCornerShape(MembyChipCorner))
.background(MembyAccentMuted)
.padding(horizontal = 10.dp, vertical = 5.dp),
)
val lifecycle = detail?.lifecycleText?.takeIf(String::isNotBlank)
if (lifecycle != null) {
Spacer(Modifier.width(8.dp))
LifecycleBadge(detail.lifecycle, lifecycle)
}
// What the state means for somebody who wanted to watch this tonight. It belongs
// beside the word it explains rather than under the date, which answers "when".
val stateDetail = detail?.stateDetail?.takeIf(String::isNotBlank)
if (stateDetail != null) {
Spacer(Modifier.width(10.dp))
Text(
text = stateDetail,
color = MembyQuietText,
fontSize = 12.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
/**
* The one date the page leads with, and under it the sentence saying this cannot be watched
* here yet which is the whole reason somebody is on this page rather than the other one.
*
* The band carries the notice and not [RadarrMovieDetail.stateDetail], which sits with the
* status treatment it explains: the two are one sentence apart on an unannounced film
* ("Release date not yet announced" over "Release date not yet announced"), and a page that
* says a thing twice reads as one that has lost track of what it has said.
*/
@Composable
private fun RadarrReleaseBand(detail: RadarrMovieDetail?) {
val expected = detail?.expectedLabel?.takeIf(String::isNotBlank) ?: return
val notice = detail.availabilityNotice.takeIf(String::isNotBlank).orEmpty()
Column(
Modifier
.clip(RoundedCornerShape(MembyChipCorner))
.background(MembyControlSurface)
.padding(horizontal = 16.dp, vertical = 12.dp),
) {
Text(
text = expected,
color = Color.White,
fontSize = 20.sp,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
if (notice.isNotEmpty()) {
Spacer(Modifier.height(4.dp))
Text(notice, color = MembyMutedText, fontSize = 13.sp, maxLines = 2)
}
}
}
/**
* The hero fact line: year, runtime, certificate, studio.
*
* Radarr's answer wins over the card's where it has one, since the card carries only what
* the schedule row needed, but the card is what is there on the opening frame so the line
* is drawn from whichever of the two knows, rather than waiting for the request.
*/
/**
* The ordinary Emby page this card should have opened, once the gateway reports that the
* library holds the film or null while it does not, which is the whole of this page's
* reason to exist. Pure, so the one decision that retires this page is testable.
*/
internal fun radarrEmbyStub(card: BaseItem, detail: RadarrMovieDetail): BaseItem? {
val embyItemId = detail.embyItemId.trim().takeIf(String::isNotEmpty) ?: return null
return BaseItem(
id = embyItemId,
name = detail.title.takeIf(String::isNotBlank) ?: card.name,
type = "Movie",
genres = detail.genres.ifEmpty { card.genres },
productionYear = detail.year.takeIf { it > 0 } ?: card.productionYear,
)
}
internal fun radarrMovieFacts(card: BaseItem, detail: RadarrMovieDetail?): List<String> =
buildList {
val year = detail?.year?.takeIf { it > 0 } ?: card.productionYear?.takeIf { it > 0 }
year?.let { add(it.toString()) }
val runtime = detail?.runtimeMinutes?.takeIf { it > 0 } ?: card.runtimeMinutes
runtime?.takeIf { it > 0 }?.let { add(formatRuntime(it)) }
detail?.certificate?.takeIf(String::isNotBlank)?.let(::add)
detail?.studio?.takeIf(String::isNotBlank)?.let(::add)
}
@@ -1,5 +1,7 @@
package com.ponzischeme89.memby.ui.alerts
import com.ponzischeme89.memby.data.model.UserNotification
/**
* The wording and the counting behind Notifications, kept pure so the badge a viewer sees in the
* user picker and the summary line on the page itself are the same arithmetic tested once.
@@ -32,3 +34,39 @@ internal fun alertsSummary(total: Int, unread: Int): String = when {
"$notifications · $unread new"
}
}
/**
* The two halves of the page: what is waiting, and what has been dealt with.
*
* The split is what replaced the on/off switch. A viewer who could turn notifications off was
* being offered a way to make the page permanently useless and the reason to reach for it
* was that a list mixing new news with everything already read never emptied. Two named
* halves with their counts on them is the same relief without the off switch: the Inbox is
* the short list somebody has to do something about, and Seen is where it goes.
*
* Membership is the *read* flag and nothing else, which is what lets both counts and both
* panes be derived from one list the caller already holds there is no third state to keep
* in step, and dismissing a row still removes it from the page entirely.
*/
enum class AlertsTab(val label: String) { INBOX("Inbox"), SEEN("Seen") }
/** The alerts [tab] holds, in the order the caller gave them. */
internal fun alertsForTab(
tab: AlertsTab,
notifications: List<UserNotification>,
): List<UserNotification> = notifications.filter { (tab == AlertsTab.INBOX) == it.unread }
/** Above this a tab states the cap rather than widening past the tab beside it. */
internal const val AlertTabCountMax = 99
/**
* The count printed on a tab.
*
* Zero is printed rather than hidden: a tab whose count disappeared when it emptied would
* read as a tab that had failed to count, and "Seen 0" is a useful thing to be told.
*/
internal fun alertTabCountLabel(count: Int): String = when {
count <= 0 -> "0"
count > AlertTabCountMax -> "$AlertTabCountMax+"
else -> count.toString()
}
@@ -41,6 +41,7 @@ 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.focusProperties
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.geometry.Offset
@@ -61,10 +62,10 @@ import androidx.tv.material3.Icon
import androidx.tv.material3.Text
import com.ponzischeme89.memby.data.model.NotificationPreferences
import com.ponzischeme89.memby.data.model.UserNotification
import com.ponzischeme89.memby.ui.MembyChoiceChip
import com.ponzischeme89.memby.ui.formatMyShowDate
import com.ponzischeme89.memby.ui.theme.MembyAccent
import com.ponzischeme89.memby.ui.theme.MembyCardCorner
import com.ponzischeme89.memby.ui.theme.MembyChipCorner
import com.ponzischeme89.memby.ui.theme.MembyHairline
import com.ponzischeme89.memby.ui.theme.MembyMutedText
import com.ponzischeme89.memby.ui.theme.MembyQuietText
@@ -81,14 +82,26 @@ import kotlinx.coroutines.delay
* drawn on Home, and cost a focus target on every set in the house whether or not there was
* anything behind it.
*
* It reads like Settings on purpose black canvas, flat rows on a shared 16dp inset with
* hairlines between them, and the row under focus the only lit surface on the page.
* It is laid out as My Requests is the marked heading, a tab strip under it, one pane at a
* time because the two pages answer the same shape of question about a person's own list,
* and a household should not have to learn two of them.
*
* **There is no off switch.** Turning notifications off was the page offering a way to make
* itself permanently useless, and the reason to reach for it was that a single list mixing
* new news with everything already read never emptied. [AlertsTab] is the answer instead:
* Inbox is the short list to do something about, Seen is where it goes, and both wear their
* count so a viewer can see from the strip whether there is anything to open. The stored
* preferences are still honoured they are just no longer the viewer's to switch from here,
* which is why the empty state still says so when nothing is arriving.
*
* Two things are worth preserving. **A press dismisses**, with the focused row saying so, and
* the hint is what makes that safe: this is the only page whose whole job is emptying itself,
* and a second confirmation press on every alert is what made the old panel not worth
* opening. And **focus marks read** a row can only be read by being looked at, so nothing
* has to be pressed to clear the "new" flag on it.
* opening. And **nothing moves under the remote by being looked at** focus used to mark a
* row read, which was harmless while the list was one list and would now empty the Inbox
* merely by somebody scrolling it. Seen is a state a viewer puts a row into, with the toggle
* beside it, and focus lands back on that toggle afterwards so a run of them is a run of one
* press.
*
* Stateless by design: the caller owns the list and the requests, so this can be previewed
* and screenshotted with no server.
@@ -100,48 +113,74 @@ fun MyAlertsPage(
loading: Boolean = false,
errorMessage: String? = null,
onRetry: () -> Unit = {},
onToggleEnabled: () -> Unit,
onToggleShowReturns: () -> Unit,
onRead: (UserNotification) -> Unit,
onToggleSeen: (UserNotification) -> Unit = {},
onDismiss: (UserNotification) -> Unit,
onDismissAll: () -> Unit,
onDismissAll: (List<UserNotification>) -> Unit,
onClose: () -> Unit,
modifier: Modifier = Modifier,
) {
val actionsFocusRequester = remember { FocusRequester() }
val notificationIds = notifications.map(UserNotification::id)
val rowFocusRequesters = remember(notificationIds) {
List(notificationIds.size) { FocusRequester() }
}
var tab by remember { mutableStateOf(AlertsTab.INBOX) }
var page by remember { mutableStateOf(0) }
val inbox = remember(notifications) { alertsForTab(AlertsTab.INBOX, notifications) }
val seen = remember(notifications) { alertsForTab(AlertsTab.SEEN, notifications) }
val tabNotifications = if (tab == AlertsTab.INBOX) inbox else seen
val pageCount = alertPageCount(tabNotifications.size)
// Derived rather than only corrected in an effect: an effect runs after the frame, so a
// list that shrank under a viewer standing on the last page would draw one empty frame
// before the correction landed. [page] is written back below to keep the two in step.
val safePage = alertPageAfterChange(page, tabNotifications.size)
val pageNotifications = alertPageItems(tabNotifications, safePage)
val pageIds = pageNotifications.map(UserNotification::id)
// Two requesters per row, because a row holds two focus targets and which of them a list
// change should land on depends on what the viewer just pressed — see [pendingFocusToggle].
val rowFocusRequesters = remember(pageIds) { List(pageIds.size) { FocusRequester() } }
val toggleFocusRequesters = remember(pageIds) { List(pageIds.size) { FocusRequester() } }
val tabsFocusRequester = remember { FocusRequester() }
val listState = rememberLazyListState()
var pendingFocusIndex by remember { mutableStateOf<Int?>(null) }
val hasAlerts = notifications.isNotEmpty()
LaunchedEffect(Unit) {
// One frame for the list to place its first row; an empty page has nothing below
// the actions to land on, so the chips take the remote instead.
var pendingFocusToggle by remember { mutableStateOf(false) }
var pendingFocusPage by remember { mutableStateOf(0) }
val hasAlerts = tabNotifications.isNotEmpty()
LaunchedEffect(safePage) { page = safePage }
// Opening the page, and every tab press after it. A tab press moves focus into the pane it
// opened, the stance My Requests takes: the strip is what Back returns to, so leaving
// focus on it would cost a press before anything could be read.
LaunchedEffect(tab) {
// One frame for the list to place its first row; an empty pane has nothing below the
// strip to land on, so the strip keeps the remote instead.
delay(16)
runCatching {
if (hasAlerts) rowFocusRequesters.first().requestFocus() else actionsFocusRequester.requestFocus()
val first = rowFocusRequesters.firstOrNull()
if (first != null) first.requestFocus() else tabsFocusRequester.requestFocus()
}
}
LaunchedEffect(notificationIds) {
if (notifications.isEmpty()) {
LaunchedEffect(pageIds) {
if (!hasAlerts) {
pendingFocusIndex = null
delay(16)
runCatching { actionsFocusRequester.requestFocus() }
runCatching { tabsFocusRequester.requestFocus() }
return@LaunchedEffect
}
val requestedIndex = pendingFocusIndex ?: return@LaunchedEffect
val wantsToggle = pendingFocusToggle
// Spent on this list change however it turns out. Left set, a request that could
// not be honoured — an empty list, a dismissal the server refused and put back —
// would be honoured against the *next* change instead, which is commonly an alert
// arriving on its own: focus would jump for a press made minutes ago.
pendingFocusIndex = null
val targetIndex = alertFocusIndexAfterRemoval(requestedIndex, notifications.size)
?: return@LaunchedEffect
val targetIndex = if (safePage != pendingFocusPage) {
// The last row of a page went, so the pager stepped back one. The row the eye is
// already nearest is the bottom of the page now on screen, not its top.
pageIds.lastIndex.takeIf { it >= 0 }
} else {
alertFocusIndexAfterRemoval(requestedIndex, pageIds.size)
} ?: return@LaunchedEffect
runCatching { listState.scrollToItem(targetIndex) }
delay(16)
runCatching { rowFocusRequesters.getOrNull(targetIndex)?.requestFocus() }
runCatching {
val targets = if (wantsToggle) toggleFocusRequesters else rowFocusRequesters
targets.getOrNull(targetIndex)?.requestFocus()
}
}
Box(modifier.fillMaxSize().zIndex(9f).background(MembySurface)) {
@@ -149,69 +188,98 @@ fun MyAlertsPage(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 56.dp)
.padding(top = 40.dp, bottom = 28.dp),
.padding(top = 32.dp, bottom = 24.dp),
) {
AlertsHeader(
total = notifications.size,
unread = notifications.count(UserNotification::unread),
)
Spacer(Modifier.height(20.dp))
AlertsHeader(total = notifications.size, unread = inbox.size)
Spacer(Modifier.height(16.dp))
Row(
modifier = Modifier.fillMaxWidth().focusGroup(),
horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
MembyChoiceChip(
label = if (preferences.enabled) "Notifications on" else "Notifications off",
selected = preferences.enabled,
onClick = onToggleEnabled,
modifier = Modifier.focusRequester(actionsFocusRequester),
)
MembyChoiceChip(
label = if (preferences.showReturnAlerts) "Show returns on" else "Show returns off",
selected = preferences.enabled && preferences.showReturnAlerts,
onClick = { if (preferences.enabled) onToggleShowReturns() },
)
Spacer(Modifier.width(1.dp))
AlertsTab.entries.forEach { entry ->
AlertsTabChip(
tab = entry,
selected = entry == tab,
count = if (entry == AlertsTab.INBOX) inbox.size else seen.size,
// Both anchors hang off the selected tab rather than off a fixed
// index: it is where the page opens and where Back returns to.
focusRequester = tabsFocusRequester.takeIf { entry == tab },
// Only ever pointed at a row that is actually placed this frame.
paneFocusRequester = rowFocusRequesters.firstOrNull(),
onClick = {
if (entry != tab) {
page = 0
pendingFocusIndex = null
tab = entry
}
},
)
}
Spacer(Modifier.weight(1f))
if (hasAlerts) {
MembyChoiceChip(
AlertsPillButton(
label = "Dismiss all",
selected = false,
onClick = onDismissAll,
icon = MembyIcon.PlaylistRemove.mark,
// What this pane is showing, not the whole page: emptying Seen must
// not take an unread Inbox with it.
onClick = { onDismissAll(tabNotifications) },
)
}
if (errorMessage != null) {
MembyChoiceChip(label = "Try again", selected = false, onClick = onRetry)
AlertsPillButton(
label = "Try again",
icon = MembyIcon.Refresh.mark,
onClick = onRetry,
)
}
Spacer(Modifier.weight(1f))
MembyChoiceChip(label = "Close", selected = false, onClick = onClose)
AlertsPillButton(
label = "Close",
icon = MembyIcon.Close.mark,
onClick = onClose,
)
}
Spacer(Modifier.height(18.dp))
Spacer(Modifier.height(14.dp))
Box(Modifier.fillMaxWidth().height(1.dp).background(MembyHairline))
if (loading && !hasAlerts) {
if (loading && notifications.isEmpty()) {
AlertsNotice("Loading notifications…")
} else if (errorMessage != null && !hasAlerts) {
} else if (errorMessage != null && notifications.isEmpty()) {
AlertsNotice(errorMessage)
} else if (!hasAlerts) {
AlertsEmptyState(enabled = preferences.enabled)
AlertsEmptyState(tab = tab, listening = preferences.enabled)
} else {
LazyColumn(
state = listState,
modifier = Modifier.fillMaxWidth().weight(1f),
contentPadding = PaddingValues(vertical = 6.dp),
) {
itemsIndexed(notifications, key = { _, notification -> notification.id }) {
itemsIndexed(pageNotifications, key = { _, notification -> notification.id }) {
index, notification ->
AlertRow(
notification = notification,
modifier = Modifier.focusRequester(rowFocusRequesters[index]),
onFocused = { if (notification.unread) onRead(notification) },
onClick = {
focusRequester = rowFocusRequesters[index],
toggleFocusRequester = toggleFocusRequesters[index],
// Up out of the top row reaches the strip. Only the first row
// states it; the rest are found by the ordinary focus search.
upFocusRequester = tabsFocusRequester.takeIf { index == 0 },
onToggleSeen = {
// The row leaves this pane for the other one, so it needs the
// same re-aim a dismissal does — landing on the toggle rather
// than the body, or a run of "Mark as seen" presses would put
// the remote on something that dismisses.
pendingFocusIndex = index
pendingFocusToggle = true
pendingFocusPage = safePage
onToggleSeen(notification)
},
onDismiss = {
pendingFocusIndex = index
pendingFocusToggle = false
pendingFocusPage = safePage
onDismiss(notification)
},
)
if (notification.id != notifications.last().id) {
if (notification.id != pageNotifications.last().id) {
Box(
Modifier
.fillMaxWidth()
@@ -222,11 +290,188 @@ fun MyAlertsPage(
}
}
}
if (pageCount > 1) {
AlertsPager(
page = safePage,
pageCount = pageCount,
onPrevious = { page = (safePage - 1).coerceAtLeast(0) },
onNext = { page = (safePage + 1).coerceAtMost(pageCount - 1) },
)
}
}
}
}
}
/**
* One tab, wearing its count.
*
* The count is the whole reason the strip is worth its band: a viewer can see from here
* whether the Inbox has anything in it without opening it, which is the question they came to
* the page with. Focus is *not* selection pressing a tab moves the remote into the pane it
* opened, so following the D-pad across the strip would throw somebody out of the list they
* were reading on the way to Close.
*/
@Composable
private fun AlertsTabChip(
tab: AlertsTab,
selected: Boolean,
count: Int,
focusRequester: FocusRequester?,
paneFocusRequester: FocusRequester?,
onClick: () -> Unit,
) {
var focused by remember { mutableStateOf(false) }
val shape = RoundedCornerShape(MembyChipCorner)
Row(
modifier = Modifier
.then(if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier)
.focusProperties { if (paneFocusRequester != null) down = paneFocusRequester }
.clip(shape)
.background(
when {
focused -> Color.White
selected -> MembyAccent.copy(alpha = 0.18f)
else -> Color.White.copy(alpha = 0.05f)
},
)
.border(
1.dp,
when {
focused -> Color.Transparent
selected -> MembyAccent.copy(alpha = 0.55f)
else -> MembyHairline
},
shape,
)
.onFocusChanged { focused = it.isFocused }
.clickable(onClick = onClick)
.semantics { contentDescription = "${tab.label}, $count" }
.padding(horizontal = 16.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(9.dp),
) {
Icon(
if (tab == AlertsTab.INBOX) MembyIcon.Inbox.mark else MembyIcon.CheckCircle.mark,
contentDescription = null,
tint = if (focused) MembySurface else MembyAccent,
modifier = Modifier.size(15.dp),
)
Text(
tab.label,
color = if (focused) MembySurface else Color.White,
fontSize = 14.sp,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
)
Text(
alertTabCountLabel(count),
color = if (focused) MembySurface else MembyQuietText,
fontSize = 12.sp,
fontWeight = FontWeight.Bold,
maxLines = 1,
modifier = Modifier
.clip(RoundedCornerShape(6.dp))
.background(
if (focused) Color.Black.copy(alpha = 0.10f) else Color.White.copy(alpha = 0.08f),
)
.padding(horizontal = 6.dp, vertical = 1.dp),
)
}
}
@Composable
private fun AlertsPager(
page: Int,
pageCount: Int,
onPrevious: () -> Unit,
onNext: () -> Unit,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(top = 6.dp, start = 16.dp, end = 16.dp)
.focusGroup(),
verticalAlignment = Alignment.CenterVertically,
) {
Box(Modifier.width(AlertsPagerSlotWidth), contentAlignment = Alignment.CenterStart) {
if (page > 0) {
AlertsPillButton(
label = "Previous",
icon = MembyIcon.ChevronLeft.mark,
onClick = onPrevious,
)
}
}
Box(Modifier.weight(1f), contentAlignment = Alignment.Center) {
Text(
alertPageLabel(page, pageCount),
color = MembyQuietText,
fontSize = 12.sp,
fontWeight = FontWeight.SemiBold,
letterSpacing = 0.6.sp,
maxLines = 1,
)
}
Box(Modifier.width(AlertsPagerSlotWidth), contentAlignment = Alignment.CenterEnd) {
if (page < pageCount - 1) {
AlertsPillButton(
label = "Next",
icon = MembyIcon.ChevronRight.mark,
iconLeading = false,
onClick = onNext,
)
}
}
}
}
/** Wide enough for "Previous" and its mark, so neither end of the pager reflows. */
private val AlertsPagerSlotWidth = 122.dp
/**
* The small focusable control this page uses for both pager arrows and the seen toggle on a
* row. One button language rather than two: they sit within a few centimetres of each other,
* and a viewer travelling between them by remote should not be able to tell they were written
* on different days.
*/
@Composable
private fun AlertsPillButton(
label: String,
icon: ImageVector,
onClick: () -> Unit,
modifier: Modifier = Modifier,
iconLeading: Boolean = true,
accented: Boolean = false,
) {
var focused by remember { mutableStateOf(false) }
val shape = RoundedCornerShape(MembyChipCorner)
val content = when {
focused -> Color.Black
accented -> MembyAccent
else -> MembyMutedText
}
Row(
modifier = modifier
.clip(shape)
.background(if (focused) Color.White else Color.White.copy(alpha = 0.07f))
.border(1.dp, if (focused) Color.Transparent else MembyHairline, shape)
.onFocusChanged { focused = it.isFocused }
.clickable(onClick = onClick)
.semantics { contentDescription = label }
.padding(horizontal = 10.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
if (iconLeading) {
Icon(icon, contentDescription = null, tint = content, modifier = Modifier.size(14.dp))
}
Text(label, color = content, fontSize = 12.sp, fontWeight = FontWeight.SemiBold, maxLines = 1)
if (!iconLeading) {
Icon(icon, contentDescription = null, tint = content, modifier = Modifier.size(14.dp))
}
}
}
@Composable
private fun AlertsNotice(message: String) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
@@ -238,33 +483,68 @@ private fun AlertsNotice(message: String) {
internal fun alertFocusIndexAfterRemoval(removedIndex: Int, remainingCount: Int): Int? =
if (remainingCount <= 0) null else removedIndex.coerceIn(0, remainingCount - 1)
/**
* The marked heading My Requests wears, so the two pages a person opens about their own list
* are recognisably the same page. The summary sits at the end of the row rather than under
* the title: it is a caption for the whole page, and the strip below it already accounts for
* each half.
*/
@Composable
private fun AlertsHeader(total: Int, unread: Int) {
Column(
modifier = Modifier.padding(start = 16.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Text("Notifications", color = Color.White, fontSize = 32.sp, fontWeight = FontWeight.Bold)
Text(alertsSummary(total, unread), color = MembyQuietText, fontSize = 14.sp)
Row(modifier = Modifier.padding(start = 16.dp), verticalAlignment = Alignment.CenterVertically) {
Box(
Modifier.size(38.dp).background(MembyAccent.copy(alpha = 0.14f), CircleShape),
contentAlignment = Alignment.Center,
) {
Icon(
MembyIcon.NotificationActive.mark,
contentDescription = null,
tint = MembyAccent,
modifier = Modifier.size(21.dp),
)
}
Spacer(Modifier.width(12.dp))
Column(Modifier.weight(1f)) {
Text("NOTIFICATIONS", color = MembyAccent, fontSize = 11.sp, fontWeight = FontWeight.Bold)
Text("Your news", color = Color.White, fontSize = 26.sp, fontWeight = FontWeight.SemiBold)
}
Text(alertsSummary(total, unread), color = MembyQuietText, fontSize = 13.sp)
}
}
/**
* The empty state, which is the state this page is usually in its whole job is emptying
* itself, so two lines of grey text in the middle of a black screen would read as a screen
* that failed to load rather than as good news.
*
* An empty Inbox and an empty Seen pane are different pieces of news and say so: one is
* "nothing to deal with", the other is "you have not put anything here yet". Notifications
* being switched off is no longer something a viewer did the page has no such switch any
* more but it is still true when the household has them off, and an Inbox that will never
* fill is worth explaining rather than leaving as an unexplained silence.
*/
@Composable
private fun AlertsEmptyState(enabled: Boolean) {
private fun AlertsEmptyState(tab: AlertsTab, listening: Boolean) {
val inbox = tab == AlertsTab.INBOX
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
AlertsEmptyMark(listening = enabled)
AlertsEmptyMark(listening = listening && inbox)
Spacer(Modifier.height(18.dp))
Text("Youre all caught up.", color = MembyMutedText, fontSize = 20.sp, fontWeight = FontWeight.SemiBold)
Text(
if (inbox) "Youre all caught up." else "Nothing marked as seen.",
color = MembyMutedText,
fontSize = 20.sp,
fontWeight = FontWeight.SemiBold,
)
Spacer(Modifier.height(8.dp))
Text(
if (enabled) {
"Notifications about the shows you follow will show up here."
} else {
"Notifications are switched off, so nothing new will arrive here."
when {
!inbox -> "Alerts you mark as seen wait here until you dismiss them."
listening -> "Notifications about the shows you follow will show up here."
else -> "Notifications are switched off for this profile, so nothing new will arrive here."
},
color = MembyQuietText,
fontSize = 14.sp,
@@ -399,98 +679,136 @@ private fun ringSwing(progress: Float): Float {
return (kotlin.math.sin(phase * 3f * TWO_PI) * (1.0 - phase) * 5.0).toFloat()
}
/**
* One notification.
*
* The row holds **two** focus targets rather than one, and the split is what makes a seen
* toggle possible at all on a remote with a single confirm key. The body keeps the press it
* always had OK dismisses, with the hint stated on the row about to go and Right reaches
* a toggle beside it. Down still moves to the next row from either, so the second target
* costs nothing to somebody walking the list who never wants it.
*
* The lit surface belongs to the whole row, driven by `hasFocus` rather than `isFocused`, so
* a row does not go dark the moment the remote steps sideways into its own toggle.
*/
@Composable
private fun AlertRow(
notification: UserNotification,
onFocused: () -> Unit,
onClick: () -> Unit,
focusRequester: FocusRequester,
toggleFocusRequester: FocusRequester,
upFocusRequester: FocusRequester?,
onToggleSeen: () -> Unit,
onDismiss: () -> Unit,
modifier: Modifier = Modifier,
) {
var focused by remember { mutableStateOf(false) }
var rowHasFocus by remember { mutableStateOf(false) }
var bodyFocused by remember { mutableStateOf(false) }
val shape = RoundedCornerShape(MembyCardCorner)
Row(
modifier = modifier
.fillMaxWidth()
.onFocusChanged {
focused = it.isFocused
if (it.isFocused) onFocused()
}
// Observer before the group it observes: `onFocusChanged` reports the state of
// the focus target that follows it in the chain, so the two the other way round
// leave the row's own lit surface permanently dark.
.onFocusChanged { rowHasFocus = it.hasFocus }
.focusProperties { if (upFocusRequester != null) up = upFocusRequester }
.focusGroup()
.clip(shape)
.background(if (focused) Color.White.copy(alpha = 0.11f) else Color.Transparent)
.background(if (rowHasFocus) Color.White.copy(alpha = 0.11f) else Color.Transparent)
.border(
width = if (focused) 2.dp else 1.dp,
color = if (focused) Color.White.copy(alpha = 0.88f) else Color.Transparent,
width = if (rowHasFocus) 2.dp else 1.dp,
color = if (rowHasFocus) Color.White.copy(alpha = 0.88f) else Color.Transparent,
shape = shape,
)
.clickable(onClick = onClick)
.semantics {
contentDescription = "${notification.title}. ${notification.message}. Press to dismiss."
}
.padding(horizontal = 16.dp, vertical = 15.dp),
.padding(horizontal = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Box(
Modifier.size(38.dp).clip(CircleShape).background(
if (notification.unread) MembyAccent.copy(alpha = 0.18f) else Color.White.copy(alpha = 0.05f),
),
contentAlignment = Alignment.Center,
Row(
modifier = Modifier
.weight(1f)
.focusRequester(focusRequester)
.onFocusChanged { bodyFocused = it.isFocused }
.clip(RoundedCornerShape(MembyCardCorner))
.clickable(onClick = onDismiss)
.semantics {
contentDescription =
"${notification.title}. ${notification.message}. Press to dismiss."
}
.padding(horizontal = 6.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(14.dp),
) {
Icon(
alertIcon(notification.kind),
contentDescription = null,
tint = if (notification.unread) MembyAccent else MembyQuietText,
modifier = Modifier.size(19.dp),
)
}
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(9.dp)) {
Box(
Modifier.size(34.dp).clip(CircleShape).background(
if (notification.unread) MembyAccent.copy(alpha = 0.18f) else Color.White.copy(alpha = 0.05f),
),
contentAlignment = Alignment.Center,
) {
Icon(
alertIcon(notification.kind),
contentDescription = null,
tint = if (notification.unread) MembyAccent else MembyQuietText,
modifier = Modifier.size(17.dp),
)
}
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(9.dp),
) {
// Weighted, so the date and the NEW flag are measured first and pinned to
// the end while the title takes whatever is left. A title long enough to
// reach them ellipsises rather than pushing them off the row.
Text(
notification.title,
color = Color.White,
fontSize = 16.sp,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
// On the title's line rather than under the message: a page of four rows
// has no line to spare for a date, and this is where the eye already is.
notification.eventAt?.takeIf { it.isNotBlank() }?.let {
Text(formatMyShowDate(it), color = MembyQuietText, fontSize = 11.sp, maxLines = 1)
}
}
Text(
notification.title,
color = Color.White,
fontSize = 17.sp,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
notification.message,
color = MembyMutedText,
fontSize = 13.sp,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
if (notification.unread) {
}
// The hint is the whole reason a single press is allowed to dismiss: it is stated
// on the row about to go, and only while the body itself holds the remote — with
// focus on the toggle beside it, OK does something else entirely.
Box(Modifier.width(96.dp), contentAlignment = Alignment.CenterEnd) {
if (bodyFocused) {
Text(
"NEW",
color = MembyAccent,
fontSize = 9.sp,
"OK to dismiss",
color = Color.White,
fontSize = 12.sp,
fontWeight = FontWeight.Bold,
letterSpacing = 1.sp,
modifier = Modifier
.clip(RoundedCornerShape(4.dp))
.background(MembyAccent.copy(alpha = 0.14f))
.padding(horizontal = 5.dp, vertical = 2.dp),
maxLines = 1,
)
}
}
Text(
notification.message,
color = MembyMutedText,
fontSize = 14.sp,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
notification.eventAt?.takeIf { it.isNotBlank() }?.let {
Text(formatMyShowDate(it), color = MembyQuietText, fontSize = 12.sp, maxLines = 1)
}
}
// The hint is the whole reason a single press is allowed to dismiss: it is stated on
// the row about to go, and only on the row under focus.
Box(Modifier.width(112.dp), contentAlignment = Alignment.CenterEnd) {
if (focused) {
Text(
"OK to dismiss",
color = Color.White,
fontSize = 12.sp,
fontWeight = FontWeight.Bold,
maxLines = 1,
)
}
}
AlertsPillButton(
modifier = Modifier.focusRequester(toggleFocusRequester),
label = alertSeenActionLabel(notification.unread),
icon = if (notification.unread) {
MembyIcon.CheckCircle.mark
} else {
MembyIcon.NotificationActive.mark
},
onClick = onToggleSeen,
accented = notification.unread,
)
}
}
@@ -0,0 +1,76 @@
package com.ponzischeme89.memby.ui.alerts
/**
* How Notifications is cut into pages, kept pure so the pager's wording, the rows a page
* holds and where focus lands after a dismissal are the same arithmetic tested once.
*
* **Paged on the television, not on the wire.** The gateway answers with the whole
* undismissed list and this cuts it up locally, which is deliberate: a page flip then costs
* nothing on a weak box, the locally-held update notice merges into page one without making
* the server's own page boundaries lie about it, and dismissing a row stays optimistic
* instead of needing the page it left refetched. What is paged is presentation, so it lives
* where the presentation is.
*/
/**
* Rows to a page.
*
* Four, because a television is 540dp tall and this page spends a third of that on its
* heading and its controls a page whose last row is below the fold is one somebody has to
* scroll *and* page through, which is worse than either on its own.
*/
internal const val AlertsPageSize = 4
/** Pages [total] rows fill. Zero for an empty list: no list is no pages, not one blank one. */
internal fun alertPageCount(total: Int, pageSize: Int = AlertsPageSize): Int =
if (total <= 0 || pageSize <= 0) 0 else (total + pageSize - 1) / pageSize
/** The index into the whole list that [page] begins at. */
internal fun alertPageFirstIndex(page: Int, pageSize: Int = AlertsPageSize): Int =
if (page <= 0 || pageSize <= 0) 0 else page * pageSize
/** The rows [page] holds, or nothing when it lies past the end of [items]. */
internal fun <T> alertPageItems(
items: List<T>,
page: Int,
pageSize: Int = AlertsPageSize,
): List<T> {
if (pageSize <= 0) return items
val start = alertPageFirstIndex(page, pageSize)
if (start >= items.size) return emptyList()
return items.subList(start, minOf(start + pageSize, items.size))
}
/**
* The page to stand on once the list holds [remainingTotal] rows.
*
* Clamping rather than resetting is the whole of it: this page's job is emptying itself, so
* the common change is the last row of the last page going away, and a viewer sent back to
* page one for it would lose their place every time they finished a page. They step back one
* page and carry on. An emptied list answers 0, which is the page the empty state occupies.
*/
internal fun alertPageAfterChange(
page: Int,
remainingTotal: Int,
pageSize: Int = AlertsPageSize,
): Int {
val count = alertPageCount(remainingTotal, pageSize)
return if (count <= 0) 0 else page.coerceIn(0, count - 1)
}
/**
* The pager's own line. One-based, because it is read aloud by a person and nobody counts
* pages from zero; empty when there is no pager to label.
*/
internal fun alertPageLabel(page: Int, pageCount: Int): String =
if (pageCount <= 0) "" else "Page ${page.coerceIn(0, pageCount - 1) + 1} of $pageCount"
/**
* What the seen toggle on a row says.
*
* It names the action rather than the state "Mark as seen" on a new row because it is a
* button, and a button labelled with the state it is already in reads as a claim rather than
* as something to press.
*/
internal fun alertSeenActionLabel(unread: Boolean): String =
if (unread) "Mark as seen" else "Mark as new"
@@ -71,6 +71,29 @@ fun scheduleSeriesStub(card: BaseItem): BaseItem? {
)
}
/**
* The Emby film a movie-schedule card stands for, or null when the library has no copy.
*
* The sibling of [scheduleSeriesStub], and the same substitution: what the viewer pressed is
* the film, and once Emby holds it the ordinary movie page is the page they wanted. A card
* with no Emby id is the Radarr-only case and is answered by its own page instead, which is
* why this returns null rather than something inert.
*
* No airing notice goes with it. A digital release date is not an air time, and the film is
* there to be played the schedule the card came from has stopped being news about it.
*/
fun scheduleMovieStub(card: BaseItem): BaseItem? {
if (!card.isMovieSchedule) return null
val movieId = card.membyMovieItemId?.trim()?.takeIf(String::isNotEmpty) ?: return null
return BaseItem(
id = movieId,
name = card.name,
type = "Movie",
genres = card.genres,
productionYear = card.productionYear,
)
}
private fun airingNoticeLabel(day: String, airLabel: String, availability: String?): String = when {
// The episode is already on the server, so "airing" would send someone to wait for
// something they could watch now.
@@ -0,0 +1,106 @@
package com.ponzischeme89.memby.ui.player
/**
* The station ident the short identity treatment shown over the opening seconds of a
* programme and the rule that keeps it out of the transport's way.
*
* There are two places in this player that draw "what is playing", and they had no idea
* about each other: this ident (activity-owned, top-start, above the PlayerView) and the
* transport controller's own `player_now_playing_group` (the same logo, the same title, at
* the same corner four density pixels away). Whenever the controller happened to be up
* inside the ident's five seconds a remote press, a pause, closing the cast or subtitle
* overlay, media3's own `auto_show` both drew, and the result read as one ident rendered
* twice. Nothing was ever shown twice; two different surfaces answered the same question in
* the same place.
*
* So the region has one owner at a time, chosen by [playerIdentitySlot], and the ident is a
* one-shot per programme: it opens once, and the transport appearing *ends* it rather than
* being drawn over it.
*/
internal enum class PlayerIdentitySlot {
/** The station ident owns the corner: the transport is down and playback is running. */
IDENT,
/** The transport is up, so its own now-playing block is the identity on screen. */
TRANSPORT,
/**
* Nobody draws it. Paused is this case: the pause overlay carries the poster, the title
* and the synopsis, and a logo in the corner above it is the same programme said twice.
*/
NONE,
}
/**
* Who may draw the identity, given the ident's own window and what the player is doing.
*
* Pause outranks everything, then the transport, then the ident stated in one pure rule
* so the two surfaces cannot disagree about which of them is on screen.
*/
internal fun playerIdentitySlot(
identWindowOpen: Boolean,
transportVisible: Boolean,
paused: Boolean,
): PlayerIdentitySlot = when {
paused -> PlayerIdentitySlot.NONE
transportVisible -> PlayerIdentitySlot.TRANSPORT
identWindowOpen -> PlayerIdentitySlot.IDENT
else -> PlayerIdentitySlot.NONE
}
/**
* The ident's phase for one programme. It is deliberately not a boolean: "has not opened
* yet" and "has already had its turn" are different answers to whether an arriving playback
* event should raise it, and conflating them is what let a re-prepare mid-programme open a
* second one.
*/
internal enum class PlaybackIdentityPhase { PENDING, SHOWING, DONE }
/**
* Whether an arriving "playback has started" should raise the ident.
*
* Every path into the player reports that at least once and several report it more than
* once a first frame, a pre-roll hand-off, a recovery re-prepare so the answer has to be
* a function of the phase rather than of the event.
*/
internal fun shouldRaiseIdent(
phase: PlaybackIdentityPhase,
transportVisible: Boolean,
paused: Boolean,
): Boolean = phase == PlaybackIdentityPhase.PENDING &&
playerIdentitySlot(identWindowOpen = true, transportVisible = transportVisible, paused = paused) ==
PlayerIdentitySlot.IDENT
/** Separator between the episode code and the episode's own title. */
private const val EPISODE_SEPARATOR = ""
/**
* Episode wording for the station ident, separate from the logo/fallback presentation.
*
* The logo belongs to the *series*, so this is the only thing on the ident that says which
* episode it is: `S01E01 Bob Smith`. A title that is just the show's name again, or the
* show's name with the episode appended, is reduced to the part that adds something.
*/
internal fun playbackIdentityEpisodeLabel(
title: String,
seriesName: String?,
episodeCode: String?,
): String? {
val code = episodeCode?.trim().orEmpty().uppercase()
if (code.isEmpty()) return null
val series = seriesName?.trim().orEmpty()
var episodeTitle = title.trim()
if (series.isNotEmpty()) {
// Emby and the gateway have both been seen to hand over "Series Episode"; the
// dash is whichever one the metadata carried.
for (dash in listOf(" ", "", " - ")) {
val prefix = series + dash
if (episodeTitle.startsWith(prefix, ignoreCase = true)) {
episodeTitle = episodeTitle.removePrefix(prefix).trim()
break
}
}
}
if (episodeTitle.isEmpty() || episodeTitle.equals(series, ignoreCase = true)) return code
return code + EPISODE_SEPARATOR + episodeTitle
}
@@ -41,6 +41,7 @@ class PlaybackStopWorker(
ServiceLocator.repository.reportPlaybackStopped(
session,
inputData.getLong(POSITION_MS, 0L),
inputData.getLong(DURATION_MS, 0L),
)
}.fold(
onSuccess = { Result.success() },
@@ -54,19 +55,32 @@ class PlaybackStopWorker(
private const val PLAY_SESSION_ID = "play_session_id"
private const val PLAY_METHOD = "play_method"
private const val POSITION_MS = "position_ms"
private const val DURATION_MS = "duration_ms"
private const val ENQUEUED_AT_MS = "enqueued_at_ms"
private const val MAX_RETRIES = 5
private fun workName(session: PlaybackSession): String =
"emby-playback-stop-${session.playSessionId.ifBlank { session.itemId }}"
fun enqueue(context: Context, session: PlaybackSession, positionMs: Long) {
/**
* [durationMs] is the title's own length where the player knows it, and it is
* carried for one reason: a stop past the end of a title is a completion, and a
* completed title has no resume point to remember. Zero simply means the runtime
* was not known, never that the title is zero long.
*/
fun enqueue(
context: Context,
session: PlaybackSession,
positionMs: Long,
durationMs: Long = 0L,
) {
val data = Data.Builder()
.putString(ITEM_ID, session.itemId)
.putString(MEDIA_SOURCE_ID, session.mediaSourceId)
.putString(PLAY_SESSION_ID, session.playSessionId)
.putString(PLAY_METHOD, session.playMethod)
.putLong(POSITION_MS, positionMs.coerceAtLeast(0L))
.putLong(DURATION_MS, durationMs.coerceAtLeast(0L))
.putLong(ENQUEUED_AT_MS, System.currentTimeMillis())
.build()
val request = OneTimeWorkRequestBuilder<PlaybackStopWorker>()
@@ -81,7 +95,7 @@ class PlaybackStopWorker(
// WorkManager is the process-death fallback, not the ordinary delivery path.
// Send now from the repository's process scope, which survives Activity
// destruction, then cancel this exact fallback request once Emby accepts it.
ServiceLocator.repository.enqueuePlaybackStopped(session, positionMs) {
ServiceLocator.repository.enqueuePlaybackStopped(session, positionMs, durationMs) {
workManager.cancelWorkById(request.id)
}
}
@@ -129,21 +129,6 @@ internal fun passthroughOsdSummary(preference: AudioPassthroughPreference): Stri
else -> "${preference.codecs.size} formats"
}
/** Episode wording for the station ident, separate from the logo/fallback presentation. */
internal fun playbackIdentityEpisodeLabel(
title: String,
seriesName: String?,
episodeCode: String?,
): String? {
val code = episodeCode?.trim().orEmpty()
if (code.isEmpty()) return null
val episodeTitle = title.trim()
.removePrefix(seriesName?.trim().orEmpty() + " ")
.trim()
.takeUnless { it.isEmpty() || it == seriesName?.trim() }
return listOfNotNull(code, episodeTitle).joinToString(" · ")
}
/**
* Fullscreen Media3 player with native stream-track selection. Press Menu while
* playing to choose an audio or subtitle track; the subtitle controller button
@@ -251,7 +236,19 @@ class PlayerActivity : ComponentActivity() {
private var nowPlayingGroup: View? = null
private var playbackIdentityView: View? = null
private var playbackIdentityHideJob: Job? = null
private var playbackIdentityShown = false
/**
* The ident is one-shot per programme, so its phase never a boolean is what decides
* whether an arriving playback event may raise it. See [PlaybackIdentity].
*/
private var playbackIdentityPhase = PlaybackIdentityPhase.PENDING
/**
* What the transport is doing, as media3 reports it. The ident and the transport's own
* now-playing block occupy the same corner, so this is the input that keeps exactly one
* of them on screen.
*/
private var transportVisible = false
private var bufferingStartedAtMs: Long? = null
private var totalBufferingMs = 0L
private var bufferingCount = 0
@@ -641,6 +638,15 @@ class PlayerActivity : ComponentActivity() {
controllerShowTimeoutMs = CONTROLLER_TIMEOUT_MS
}
applySubtitleAppearance(view)
// The ident and the transport's own now-playing block share the top-start corner,
// so the player has to say which of them is on screen rather than each deciding for
// itself. This is the only thing that reports the transport's real visibility —
// media3 raises it for reasons the activity never hears about, auto_show among them.
view.setControllerVisibilityListener(
PlayerView.ControllerVisibilityListener { visibility ->
onTransportVisibilityChanged(visibility == View.VISIBLE)
},
)
playerView = view
view.findViewById<View>(androidx.media3.ui.R.id.exo_subtitle)?.setOnClickListener {
showSubtitleOverlay()
@@ -873,6 +879,7 @@ class PlayerActivity : ComponentActivity() {
this@PlayerActivity,
playbackSession(completedId),
previewResumeDurationMs,
previewResumeDurationMs,
)
}
}
@@ -1047,6 +1054,7 @@ class PlayerActivity : ComponentActivity() {
playMethod = playable.playMethod
playbackTitle = playable.title.ifBlank { request.title + " trailer" }
bindTitleArtwork(playbackTitle, logoUrl)
resetPlaybackIdentity()
setUpPlaybackIdentity(playbackTitle, null, null, logoUrl)
startMedia(playable.url, emptyList(), 0L, playWhenReady = true)
}
@@ -2250,6 +2258,15 @@ class PlayerActivity : ComponentActivity() {
}
}
/**
* Binds what the ident says. Never shows it: raising it belongs to
* [showPlaybackIdentity] alone, so re-binding for a corrected title mid-launch which
* [adoptPlayable] does on every gateway launch cannot open a second one.
*
* The logo and its text fallback are mutually exclusive and both start hidden, so the
* corner is never briefly the series name *and* the series logo while Coil is still
* fetching the artwork.
*/
private fun setUpPlaybackIdentity(
title: String,
seriesName: String?,
@@ -2262,8 +2279,9 @@ class PlayerActivity : ComponentActivity() {
text = seriesName?.takeIf(String::isNotBlank) ?: title.ifBlank { "Now playing" }
}
findViewById<TextView>(R.id.player_playback_identity_episode).apply {
text = playbackIdentityEpisodeLabel(title, seriesName, episodeCode).orEmpty()
visibility = if (text.isNullOrBlank()) View.GONE else View.VISIBLE
val label = playbackIdentityEpisodeLabel(title, seriesName, episodeCode)
text = label.orEmpty()
visibility = if (label.isNullOrBlank()) View.GONE else View.VISIBLE
}
if (logoUrl.isNullOrBlank()) {
logo.clearColorFilter()
@@ -2272,6 +2290,7 @@ class PlayerActivity : ComponentActivity() {
fallback.visibility = View.VISIBLE
return
}
fallback.visibility = View.GONE
logo.load(logoUrl) {
crossfade(false)
listener(
@@ -2288,10 +2307,54 @@ class PlayerActivity : ComponentActivity() {
}
}
/**
* Forgets that this programme has had its ident. Called wherever the subject of the
* player changes underneath a session that never went back to the launcher an episode
* advance, a trailer resolving, a next-episode preview and the return from one so the
* incoming title gets its own ident and never inherits the outgoing title's.
*/
private fun resetPlaybackIdentity() {
playbackIdentityPhase = PlaybackIdentityPhase.PENDING
playbackIdentityHideJob?.cancel()
playbackIdentityHideJob = null
playbackIdentityView?.apply {
animate().cancel()
alpha = 0f
visibility = View.GONE
}
applyIdentityRegion()
}
/**
* Raises the ident, once, if the corner is actually free.
*
* Every path into playback reports "started" at least once and several report it more
* than once, so the guard is the phase rather than the event; and the transport being up
* means the identity is already on screen in its own block, so the ident stands down
* rather than drawing a second copy of it four density pixels away.
*/
private fun showPlaybackIdentity() {
if (playbackIdentityShown) return
val identity = playbackIdentityView ?: return
playbackIdentityShown = true
val paused = pauseOverlay?.visibility == View.VISIBLE
if (!shouldRaiseIdent(playbackIdentityPhase, transportVisible, paused)) {
MembyDiagnostics.debug(
"station_ident_withheld",
"playback" to playSessionId,
"item" to itemId,
"phase" to playbackIdentityPhase.name,
"transport_visible" to transportVisible,
"paused" to paused,
)
// Whatever is on screen is already saying it. Spend the ident here rather than
// leaving it armed to appear when the controls time out, seconds into the
// programme.
if (playbackIdentityPhase == PlaybackIdentityPhase.PENDING) {
playbackIdentityPhase = PlaybackIdentityPhase.DONE
}
return
}
playbackIdentityPhase = PlaybackIdentityPhase.SHOWING
applyIdentityRegion()
playbackIdentityHideJob?.cancel()
identity.animate().cancel()
identity.alpha = 0f
@@ -2300,14 +2363,75 @@ class PlayerActivity : ComponentActivity() {
.alpha(1f)
.setDuration(PLAYBACK_IDENTITY_FADE_MS)
.start()
MembyDiagnostics.info(
"station_ident_shown",
"playback" to playSessionId,
"item" to itemId,
"visible_ms" to PLAYBACK_IDENTITY_VISIBLE_MS,
)
playbackIdentityHideJob = lifecycleScope.launch {
delay(PLAYBACK_IDENTITY_VISIBLE_MS - PLAYBACK_IDENTITY_FADE_MS)
identity.animate()
dismissPlaybackIdentity("elapsed")
}
}
/**
* Ends the ident's turn. It never comes back for this programme: the identity is an
* opening announcement, and one that reappeared when the transport timed out would be a
* second ident for a title already minutes in.
*/
private fun dismissPlaybackIdentity(reason: String) {
playbackIdentityHideJob?.cancel()
playbackIdentityHideJob = null
val phase = playbackIdentityPhase
playbackIdentityPhase = PlaybackIdentityPhase.DONE
if (phase != PlaybackIdentityPhase.SHOWING) {
applyIdentityRegion()
return
}
MembyDiagnostics.debug(
"station_ident_dismissed",
"playback" to playSessionId,
"item" to itemId,
"reason" to reason,
)
playbackIdentityView?.apply {
animate().cancel()
animate()
.alpha(0f)
.setDuration(PLAYBACK_IDENTITY_FADE_MS)
.withEndAction { identity.visibility = View.GONE }
.withEndAction { visibility = View.GONE }
.start()
}
applyIdentityRegion()
}
/**
* Hands the top-start corner to whichever surface owns it right now.
*
* The ident and the transport's own now-playing block are the same information in the
* same place, and this is the one place that decides between them the defect this
* replaces was each of them deciding for itself.
*/
private fun applyIdentityRegion() {
val slot = playerIdentitySlot(
identWindowOpen = playbackIdentityPhase == PlaybackIdentityPhase.SHOWING,
transportVisible = transportVisible,
paused = pauseOverlay?.visibility == View.VISIBLE,
)
nowPlayingGroup?.visibility =
if (slot == PlayerIdentitySlot.TRANSPORT) View.VISIBLE else View.GONE
}
/**
* Media3 tells us when the transport comes and goes. The transport appearing is what
* ends the ident the alternative rules (moving it, fading it, letting the transport
* draw over it) all leave two answers to "what is playing" on screen at once.
*/
private fun onTransportVisibilityChanged(visible: Boolean) {
if (transportVisible == visible) return
transportVisible = visible
if (visible) dismissPlaybackIdentity("transport_shown") else applyIdentityRegion()
}
private fun updatePlaybackTiming(playback: Player) {
@@ -3428,6 +3552,7 @@ class PlayerActivity : ComponentActivity() {
pausePosterUrl = next.imageUrl
pauseOverview = next.overview
bindTitleArtwork(playbackTitle, logoUrl)
resetPlaybackIdentity()
setUpPlaybackIdentity(playbackTitle, playbackSeriesName, next.episodeCode, logoUrl)
renderedFirstFrame = false
showPlaybackLoading(title = "Finding the next episode…", hint = "Starting recap or preview")
@@ -3469,6 +3594,7 @@ class PlayerActivity : ComponentActivity() {
playbackStarted = previewResumePlaybackStarted
stopReported = previewResumeStopReported
bindTitleArtwork(playbackTitle, logoUrl)
resetPlaybackIdentity()
setUpPlaybackIdentity(
playbackTitle,
playbackSeriesName,
@@ -3958,6 +4084,7 @@ class PlayerActivity : ComponentActivity() {
this,
playbackSession(completedItemId),
playback?.currentPosition ?: 0L,
knownDurationMs(playback),
)
}
finish()
@@ -4065,6 +4192,7 @@ class PlayerActivity : ComponentActivity() {
this,
playbackSession(previousId),
playback?.currentPosition ?: 0L,
knownDurationMs(playback),
)
}
@@ -4100,13 +4228,9 @@ class PlayerActivity : ComponentActivity() {
itemName = nextTitle(next),
itemType = "Episode",
)
playbackIdentityShown = false
playbackIdentityHideJob?.cancel()
playbackIdentityView?.apply {
animate().cancel()
alpha = 0f
visibility = View.GONE
}
// The incoming episode gets its own ident; nothing of the outgoing one's is left
// armed, showing, or counted as already spent.
resetPlaybackIdentity()
initialResumePositionMs = next.resumePositionMs.coerceAtLeast(0L)
renderedFirstFrame = false
automaticRetryAttempt = 0
@@ -4198,6 +4322,9 @@ class PlayerActivity : ComponentActivity() {
private fun bindPauseOverlay(view: PlayerView) {
pauseOverlay = view.findViewById(R.id.player_pause_overlay)
nowPlayingGroup = view.findViewById(R.id.player_now_playing_group)
// The group is visible in the layout, so state it here too: nothing else runs before
// the transport is first raised, and the ident's five seconds are inside that window.
applyIdentityRegion()
pauseOverlay?.findViewById<TextView>(R.id.player_pause_title)?.text = playbackTitle
pauseOverlay?.findViewById<TextView>(R.id.player_pause_overview)?.apply {
text = pauseOverview.ifBlank { getString(R.string.player_pause_overview_fallback) }
@@ -4217,7 +4344,11 @@ class PlayerActivity : ComponentActivity() {
val paused = playbackStarted && !prerollActive &&
playback.playbackState == Player.STATE_READY && !playback.isPlaying
pauseOverlay?.visibility = if (paused) View.VISIBLE else View.GONE
nowPlayingGroup?.visibility = if (paused) View.GONE else View.VISIBLE
// Pausing during the ident hands the corner to the pause overlay, which carries the
// poster, the title and the synopsis: an ident over the top of that is the same
// programme announced twice, in two type sizes, in overlapping space.
if (paused) dismissPlaybackIdentity("paused")
applyIdentityRegion()
if (paused) playerView?.showController()
}
@@ -5271,7 +5402,12 @@ class PlayerActivity : ComponentActivity() {
stopReported = true
stoppedInBackground = true
itemId?.takeIf(String::isNotBlank)?.let { id ->
PlaybackStopWorker.enqueue(this, playbackSession(id), it.currentPosition)
PlaybackStopWorker.enqueue(
this,
playbackSession(id),
it.currentPosition,
knownDurationMs(it),
)
}
}
}
@@ -5327,6 +5463,7 @@ class PlayerActivity : ComponentActivity() {
this,
playbackSession(itemId!!),
playback?.currentPosition ?: 0L,
knownDurationMs(playback),
)
}
playerView?.player = null
@@ -5431,7 +5568,7 @@ class PlayerActivity : ComponentActivity() {
)
if (changed && playbackStarted && oldSession != null) {
stopProgressUploading()
PlaybackStopWorker.enqueue(this, oldSession, positionMs)
PlaybackStopWorker.enqueue(this, oldSession, positionMs, knownDurationMs())
playbackStarted = false
stopReported = false
stoppedInBackground = false
@@ -5442,6 +5579,15 @@ class PlayerActivity : ComponentActivity() {
playMethod = newPlayMethod.ifBlank { "DirectPlay" }
}
/**
* The title's own length, or zero where media3 does not yet know it. Carried with every
* stop so the resume ledger can tell a title somebody left part-way through from one
* they finished a completed title has its position reset, and remembering a playhead
* for it would drop the next viewing into the closing minutes.
*/
private fun knownDurationMs(playback: Player? = player): Long =
playback?.duration?.takeIf { it != C.TIME_UNSET && it > 0L } ?: 0L
private fun playbackSession(id: String) = PlaybackSession(
itemId = id,
mediaSourceId = mediaSourceId.ifBlank { id },
@@ -34,6 +34,7 @@ import com.composables.icons.fontawesome.solid.Heart
import com.composables.icons.fontawesome.solid.Home
import com.composables.icons.fontawesome.solid.Image
import com.composables.icons.fontawesome.solid.Inbox
import com.composables.icons.fontawesome.solid.Sync
import com.composables.icons.fontawesome.solid.InfoCircle
import com.composables.icons.fontawesome.solid.Magic
import com.composables.icons.fontawesome.solid.Medal
@@ -98,6 +99,7 @@ internal val fontAwesomeIconPack = MembyIconPack(
MembyIcon.CheckAll to { FontAwesome.Solid.CheckDouble },
MembyIcon.Add to { FontAwesome.Solid.Plus },
MembyIcon.Close to { FontAwesome.Solid.Times },
MembyIcon.Refresh to { FontAwesome.Solid.Sync },
MembyIcon.ChevronLeft to { FontAwesome.Solid.ChevronLeft },
MembyIcon.ChevronRight to { FontAwesome.Solid.ChevronRight },
MembyIcon.ChevronDown to { FontAwesome.Solid.ChevronDown },
@@ -31,6 +31,7 @@ import com.composables.icons.lucide.Heart
import com.composables.icons.lucide.House
import com.composables.icons.lucide.ImageOff
import com.composables.icons.lucide.Inbox
import com.composables.icons.lucide.RefreshCw
import com.composables.icons.lucide.Info
import com.composables.icons.lucide.LayoutGrid
import com.composables.icons.lucide.LibraryBig
@@ -98,6 +99,7 @@ internal val lucideIconPack = MembyIconPack(
MembyIcon.Check to { Lucide.Check },
MembyIcon.Add to { Lucide.Plus },
MembyIcon.Close to { Lucide.X },
MembyIcon.Refresh to { Lucide.RefreshCw },
MembyIcon.ChevronLeft to { Lucide.ChevronLeft },
MembyIcon.ChevronRight to { Lucide.ChevronRight },
MembyIcon.ChevronDown to { Lucide.ChevronDown },
@@ -33,6 +33,7 @@ import androidx.compose.material.icons.filled.Gavel
import androidx.compose.material.icons.filled.GridView
import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.filled.Inbox
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.material.icons.filled.Landscape
@@ -116,6 +117,7 @@ object MaterialIconPack {
MembyIcon.CheckAll to { Icons.Default.DoneAll },
MembyIcon.Add to { Icons.Default.Add },
MembyIcon.Close to { Icons.Default.Close },
MembyIcon.Refresh to { Icons.Default.Refresh },
MembyIcon.ChevronLeft to { Icons.Default.ChevronLeft },
MembyIcon.ChevronRight to { Icons.Default.ChevronRight },
MembyIcon.ChevronDown to { Icons.Default.KeyboardArrowDown },
@@ -51,6 +51,7 @@ enum class MembyIcon {
CheckAll,
Add,
Close,
Refresh,
// Movement
ChevronLeft,
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- The plate behind the episode line of the station ident. Deliberately quiet: the series
logo above it is the thing being announced, and this only has to stay readable over
whatever frame the programme opens on. -->
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="#A6000000" />
<corners android:radius="7dp" />
</shape>
@@ -1,6 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- A short, non-focusable station ident shown over the first five seconds of content.
For television, the programme logo leads and the episode sits directly beneath it. -->
<!-- The station ident: a short, non-focusable identity treatment over the opening seconds
of a programme. The series (or film) logo leads; for television a compact episode line
sits directly beneath it, subordinate to the logo and readable over any frame.
The logo occupies a fixed box so the episode line sits at the same place whatever the
artwork's proportions are, and so a title with no logo at all does not shift it either.
It shares the top-start corner with the transport's own now-playing block, which is why
only one of the two is ever on screen — see PlaybackIdentity.kt. -->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/player_playback_identity"
android:layout_width="560dp"
@@ -14,43 +21,54 @@
android:orientation="vertical"
android:visibility="gone">
<ImageView
android:id="@+id/player_playback_identity_logo"
android:layout_width="300dp"
android:layout_height="82dp"
android:adjustViewBounds="true"
android:contentDescription="@string/player_title_logo"
android:scaleType="fitStart"
android:visibility="gone" />
<TextView
android:id="@+id/player_playback_identity_title"
<FrameLayout
android:id="@+id/player_playback_identity_mark"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:ellipsize="end"
android:maxLines="1"
android:maxWidth="540dp"
android:shadowColor="#E0000000"
android:shadowDx="0"
android:shadowDy="2"
android:shadowRadius="5"
android:textColor="#FFFFFFFF"
android:textSize="26sp"
android:textStyle="bold" />
android:layout_height="82dp">
<ImageView
android:id="@+id/player_playback_identity_logo"
android:layout_width="300dp"
android:layout_height="match_parent"
android:layout_gravity="start|bottom"
android:adjustViewBounds="true"
android:contentDescription="@string/player_title_logo"
android:scaleType="fitStart"
android:visibility="gone" />
<TextView
android:id="@+id/player_playback_identity_title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="start|bottom"
android:ellipsize="end"
android:maxLines="1"
android:maxWidth="540dp"
android:shadowColor="#E0000000"
android:shadowDx="0"
android:shadowDy="2"
android:shadowRadius="5"
android:textColor="#FFFFFFFF"
android:textSize="26sp"
android:textStyle="bold"
android:visibility="gone" />
</FrameLayout>
<TextView
android:id="@+id/player_playback_identity_episode"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="7dp"
android:layout_marginTop="10dp"
android:background="@drawable/player_identity_episode_background"
android:ellipsize="end"
android:letterSpacing="0.02"
android:maxLines="1"
android:maxWidth="540dp"
android:shadowColor="#E0000000"
android:shadowDx="0"
android:shadowDy="2"
android:shadowRadius="5"
android:textColor="#E6FFFFFF"
android:textSize="19sp"
android:maxWidth="500dp"
android:paddingStart="11dp"
android:paddingTop="5dp"
android:paddingEnd="11dp"
android:paddingBottom="6dp"
android:textColor="#FFFFFFFF"
android:textSize="16sp"
android:visibility="gone" />
</LinearLayout>
@@ -18,6 +18,7 @@ import com.ponzischeme89.memby.data.model.GatewayTrailerPlayback
import com.ponzischeme89.memby.data.model.RecommendationOnboarding
import kotlinx.serialization.json.Json
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
@@ -453,6 +454,29 @@ class GatewayPayloadTest {
assertEquals("incinemas", item.membyLifecycle)
assertEquals("IN CINEMAS", item.membyLifecycleText)
assertEquals(false, item.membyPlayable)
// No Emby id: the household has no copy, which is what sends this card to the
// Radarr-only page rather than to an ordinary movie one.
assertTrue(item.isRadarrOnly)
}
@Test
fun `a Radarr card names the Emby film once the library holds it`() {
val payload = """
{
"Id":"radarr:7",
"Name":"Arrival",
"Type":"MembyRadarrMovie",
"MembySource":"radarr",
"MembyPlayable":false,
"MembyMovieItemId":"emby-4821"
}
""".trimIndent()
val item = json.decodeFromString<BaseItem>(payload)
assertEquals("emby-4821", item.membyMovieItemId)
assertTrue(item.isMovieSchedule)
assertFalse(item.isRadarrOnly)
}
@Test
@@ -19,6 +19,7 @@ class UserPreferencesTest {
fun `encoding and decoding is a fixed point`() {
val original = UserPreferences(
profileInitials = "MC",
shortName = "Matt",
homeSections = listOf("latest", "continue"),
homeCardDensity = "large",
homeArtworkStyle = "poster",
@@ -1,10 +1,15 @@
package com.ponzischeme89.memby.ui
import com.ponzischeme89.memby.data.LOCAL_RESUME_MAX_AGE_MS
import com.ponzischeme89.memby.data.isFreshLocalResume
import com.ponzischeme89.memby.data.launchResumePositionMs
import com.ponzischeme89.memby.data.playbackCompletesItem
import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.data.model.UserItemData
import com.ponzischeme89.memby.ui.detail.primaryActionLabel
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class ContinueWatchingResumeTest {
@@ -41,4 +46,81 @@ class ContinueWatchingResumeTest {
launchResumePositionMs(resolvedPositionMs = 42_000L, requestedPositionMs = 0L),
)
}
@Test
fun `the position the player left at outranks a card the refresh has not reached yet`() {
// The reported defect: twenty seconds watched from 10:00, exit, press Play again
// before Continue Watching has been refreshed. Both of the other answers still
// describe the launch before this one.
assertEquals(
620_000L,
launchResumePositionMs(
resolvedPositionMs = 600_000L,
requestedPositionMs = 600_000L,
localPositionMs = 620_000L,
),
)
}
@Test
fun `a refreshed card retires the local record by catching up with it`() {
// No bookkeeping retires the ledger; being outranked does. A card carrying the
// recorded position, or a later one watched on another set, simply wins.
assertEquals(
620_000L,
launchResumePositionMs(
resolvedPositionMs = 0L,
requestedPositionMs = 620_000L,
localPositionMs = 620_000L,
),
)
assertEquals(
900_000L,
launchResumePositionMs(
resolvedPositionMs = 0L,
requestedPositionMs = 900_000L,
localPositionMs = 620_000L,
),
)
}
@Test
fun `with nothing known locally the launch is unchanged`() {
assertEquals(
36_000L,
launchResumePositionMs(
resolvedPositionMs = 0L,
requestedPositionMs = 36_000L,
localPositionMs = 0L,
),
)
assertEquals(
0L,
launchResumePositionMs(
resolvedPositionMs = -1L,
requestedPositionMs = 0L,
localPositionMs = 0L,
),
)
}
@Test
fun `a finished title has no resume point to remember`() {
val runtime = 45L * 60L * 1_000L
assertTrue(playbackCompletesItem(positionMs = runtime - 60_000L, durationMs = runtime))
assertFalse(playbackCompletesItem(positionMs = 20_000L, durationMs = runtime))
// Zero means the runtime was not known, never that the title is zero long.
assertFalse(playbackCompletesItem(positionMs = runtime, durationMs = 0L))
}
@Test
fun `a local record is trusted for hours but not indefinitely`() {
val recordedAt = 1_000_000L
assertTrue(isFreshLocalResume(recordedAt, recordedAt + 60_000L))
assertTrue(isFreshLocalResume(recordedAt, recordedAt + LOCAL_RESUME_MAX_AGE_MS))
assertFalse(isFreshLocalResume(recordedAt, recordedAt + LOCAL_RESUME_MAX_AGE_MS + 1L))
// A clock that moved backwards is no evidence at all.
assertFalse(isFreshLocalResume(recordedAt, recordedAt - 1L))
assertFalse(isFreshLocalResume(0L, recordedAt))
}
}
@@ -2,10 +2,25 @@ package com.ponzischeme89.memby.ui
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class HomeGreetingTest {
@Test
fun `short name is preferred and falls back to the account name`() {
assertEquals("Matt", greetingName("Matt", "MattCohen"))
// The account-name reading is unchanged for a household that has never set one,
// and blank or whitespace is the ordinary state rather than a name.
assertEquals("MattCohen", greetingName(null, "MattCohen"))
assertEquals("MattCohen", greetingName("", "MattCohen"))
assertEquals("Peter", greetingName(" ", "PeterC"))
// A short name stands on its own where there is no account name to fall back to.
assertEquals("Matt", greetingName(" Matt ", null))
assertNull(greetingName(null, null))
assertNull(greetingName("", " "))
}
@Test
fun `time of day selects the expected greeting`() {
assertEquals(HomeGreetingPeriod.EVENING, homeGreetingPeriod(4))
@@ -0,0 +1,167 @@
package com.ponzischeme89.memby.ui
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onRoot
import androidx.test.core.app.ApplicationProvider
import com.github.takahirom.roborazzi.captureRoboImage
import com.ponzischeme89.memby.ServiceLocator
import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.data.model.MediaRating
import com.ponzischeme89.memby.data.model.RadarrMovieDetail
import com.ponzischeme89.memby.data.model.RadarrReleaseDate
import org.junit.Before
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 Radarr-only movie page, to `build/screenshots/radarr-movie/`.
*
* ```powershell
* .\gradlew.bat :app:testDebugUnitTest --tests "*RadarrMovieDetailScreenshotTest"
* ```
*
* The claim this page makes is one a unit test cannot check: that a film nobody can watch
* yet reads as *deliberately* unavailable rather than as a page that failed to load its
* Play button. What is worth looking at is whether the status treatment and the expected
* date carry that on their own, and whether the three states below a full record, a film
* with no date announced, and the moment before the request lands are all recognisably
* the same page.
*/
@RunWith(RobolectricTestRunner::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(sdk = [34], qualifiers = "w960dp-h540dp-television-xhdpi")
class RadarrMovieDetailScreenshotTest {
@get:Rule
val compose = createComposeRule()
@Before
fun locator() {
ServiceLocator.init(ApplicationProvider.getApplicationContext())
}
/** Everything the gateway can answer with: three dates, scores, a certificate, a trailer. */
@Test
fun `a film with a published release date`() {
capture("radarr-coming-soon") {
RadarrMovieDetailContent(card = card, detail = full, onPlayTrailer = {}, onClose = {})
}
}
/**
* The precision case. Radarr knows only that it was in cinemas, so the page names a
* month rather than a day and there is no trailer, so the only button is Back.
*/
@Test
fun `a film whose date is only estimated`() {
capture("radarr-estimated") {
RadarrMovieDetailContent(
card = card,
detail = full.copy(
expectedLabel = "Expected November 2026",
stateDetail = "Not released yet",
trailerAvailable = false,
ratings = emptyList(),
releaseDates = listOf(
RadarrReleaseDate("cinema", "In cinemas", "2 October 2026"),
),
),
onPlayTrailer = {},
onClose = {},
)
}
}
/** Nothing announced at all, which must read as a fact rather than as a missing value. */
@Test
fun `a film with no date announced`() {
capture("radarr-unannounced") {
RadarrMovieDetailContent(
card = card.copy(overview = null),
detail = RadarrMovieDetail(
id = "radarr:412",
title = "Untitled Kōwhai Project",
stateLabel = "Awaiting Release",
stateDetail = "Nothing to download until a date is announced",
expectedLabel = "Release date not yet announced",
availabilityNotice = "Not available to watch in Memby yet",
lifecycle = "tba",
lifecycleText = "TBA",
),
onPlayTrailer = {},
onClose = {},
)
}
}
/**
* The opening frame, before the request answers. The page is drawn from the card that
* was pressed, so what matters is that it is already recognisably this film rather than
* an empty frame that fills in.
*/
@Test
fun `the frame before the answer arrives`() {
capture("radarr-opening") {
RadarrMovieDetailContent(card = card, detail = null, onPlayTrailer = {}, onClose = {})
}
}
private fun capture(name: String, content: @Composable () -> Unit) {
compose.setContent {
PreviewSurface(alignment = Alignment.TopStart) { content() }
}
compose.onRoot().captureRoboImage("build/screenshots/radarr-movie/$name.png")
}
// ---------------------------------------------------------------------------
private val card = BaseItem(
id = "radarr:412",
name = "The Quiet Coast",
type = "MembyRadarrMovie",
overview = "A harbour town in winter, and the constable who has stopped " +
"pretending the tide brings anything back.",
productionYear = 2026,
runTimeTicks = 118L * 600_000_000L,
genres = listOf("Drama", "Mystery"),
membySource = "radarr",
membyPlayable = false,
membyAvailability = "upcoming",
membyAvailabilityText = "Upcoming digital release",
)
private val full = RadarrMovieDetail(
id = "radarr:412",
title = "The Quiet Coast",
overview = "A harbour town in winter, and the constable who has stopped " +
"pretending the tide brings anything back. Adapted from the novel.",
year = 2026,
runtimeMinutes = 118,
genres = listOf("Drama", "Mystery", "Thriller"),
studio = "Kōwhai Pictures",
certificate = "M",
monitored = true,
lifecycle = "announced",
lifecycleText = "ANNOUNCED",
stateLabel = "Coming Soon",
stateDetail = "Not released yet",
expectedLabel = "Expected 14 November 2026",
releaseDates = listOf(
RadarrReleaseDate("cinema", "In cinemas", "2 October 2026"),
RadarrReleaseDate("digital", "Digital release", "14 November 2026"),
RadarrReleaseDate("physical", "Physical release", "5 December 2026"),
),
availabilityNotice = "Not available to watch in Memby yet",
trailerAvailable = true,
ratings = listOf(
MediaRating(source = "imdb", name = "IMDb", score = "7.8", scale = "/10"),
MediaRating(source = "tomatoes", name = "Rotten Tomatoes", score = "91", scale = "%"),
),
)
}
@@ -0,0 +1,144 @@
package com.ponzischeme89.memby.ui
import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.data.model.RadarrMovieDetail
import com.ponzischeme89.memby.ui.detail.scheduleMovieStub
import kotlinx.serialization.json.Json
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* The rules that decide *which* page a movie-schedule card opens, and what its own page
* prints before the request lands. Everything the page says about a release is the
* gateway's wording and is pinned in `radarr_detail_test.go`; these are the television's
* half, which is the routing.
*/
class RadarrMovieDetailTest {
private val json = Json { ignoreUnknownKeys = true }
private fun card(embyItemId: String? = null) = BaseItem(
id = "radarr:412",
name = "The Quiet Coast",
type = "MembyRadarrMovie",
membySource = "radarr",
membyPlayable = false,
membyMovieItemId = embyItemId,
productionYear = 2026,
genres = listOf("Drama"),
)
@Test
fun `a card Emby has no copy of is the Radarr-only case`() {
assertTrue(card().isRadarrOnly)
assertNull(scheduleMovieStub(card()))
}
@Test
fun `a card Emby has imported opens the ordinary movie page`() {
val withCopy = card(embyItemId = "emby-99")
assertFalse(withCopy.isRadarrOnly)
val stub = scheduleMovieStub(withCopy)
assertEquals("emby-99", stub?.id)
assertEquals("Movie", stub?.type)
assertEquals("The Quiet Coast", stub?.name)
assertEquals(2026, stub?.productionYear)
}
@Test
fun `a blank Emby id is no id at all`() {
// The gateway omits the field; a build or a cache that writes an empty string
// instead must not be read as a film the library holds.
val blank = card(embyItemId = " ")
assertTrue(blank.isRadarrOnly)
assertNull(scheduleMovieStub(blank))
}
@Test
fun `a TV schedule card is not a movie one`() {
val episode = BaseItem(id = "sonarr:3", name = "Some Show", membySource = "sonarr")
assertFalse(episode.isRadarrOnly)
assertNull(scheduleMovieStub(episode))
}
@Test
fun `the page steps aside the moment Emby holds the film`() {
val detail = RadarrMovieDetail(id = "radarr:412", title = "The Quiet Coast", year = 2026)
assertNull(radarrEmbyStub(card(), detail))
val imported = detail.copy(embyItemId = "emby-99", genres = listOf("Drama", "Mystery"))
val stub = radarrEmbyStub(card(), imported)
assertEquals("emby-99", stub?.id)
assertEquals("Movie", stub?.type)
assertEquals(listOf("Drama", "Mystery"), stub?.genres)
assertEquals(2026, stub?.productionYear)
}
@Test
fun `the fact line is drawn from the card until the request answers`() {
// The card is what exists on the opening frame, and a page that printed nothing
// until the gateway answered would be one that visibly assembles itself.
val fromCard = radarrMovieFacts(card().copy(runTimeTicks = 118L * 600_000_000L), null)
assertEquals(listOf("2026", "1h 58m"), fromCard)
val detail = RadarrMovieDetail(
year = 2026,
runtimeMinutes = 118,
certificate = "M",
studio = "Kōwhai Pictures",
)
assertEquals(
listOf("2026", "1h 58m", "M", "Kōwhai Pictures"),
radarrMovieFacts(card(), detail),
)
}
@Test
fun `a fact nothing knows is omitted rather than printed empty`() {
val bare = BaseItem(id = "radarr:9", name = "Untitled", membySource = "radarr")
assertEquals(emptyList<String>(), radarrMovieFacts(bare, RadarrMovieDetail()))
}
@Test
fun `the gateway's answer decodes, including the fields an older one omits`() {
val payload = """
{
"id":"radarr:412",
"title":"The Quiet Coast",
"overview":"A harbour town in winter.",
"year":2026,
"runtimeMinutes":118,
"genres":["Drama","Mystery"],
"studio":"Kōwhai Pictures",
"certificate":"M",
"monitored":true,
"lifecycle":"announced",
"lifecycleText":"ANNOUNCED",
"stateLabel":"Coming Soon",
"stateDetail":"Not released yet",
"expectedLabel":"Expected 14 November 2026",
"releaseDates":[
{"kind":"cinema","label":"In cinemas","value":"2 October 2026"},
{"kind":"digital","label":"Digital release","value":"14 November 2026"}
],
"availabilityNotice":"Not available to watch in Memby yet",
"trailerAvailable":true,
"ratings":[{"source":"imdb","name":"IMDb","score":"7.8","scale":"/10"}]
}
""".trimIndent()
val detail = json.decodeFromString<RadarrMovieDetail>(payload)
assertEquals("Expected 14 November 2026", detail.expectedLabel)
assertEquals("Coming Soon", detail.stateLabel)
assertEquals(2, detail.releaseDates.size)
assertEquals("digital", detail.releaseDates[1].kind)
assertTrue(detail.trailerAvailable)
assertEquals("IMDb", detail.ratings.single().name)
// Absent because the library has no copy — which is the whole reason this page is
// the one that opened.
assertEquals("", detail.embyItemId)
}
}
@@ -1,5 +1,6 @@
package com.ponzischeme89.memby.ui.alerts
import com.ponzischeme89.memby.data.model.UserNotification
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
@@ -38,4 +39,66 @@ class AlertsFormatTest {
assertEquals("4 notifications · 2 new", alertsSummary(total = 4, unread = 2))
assertEquals("1 notification · 1 new", alertsSummary(total = 1, unread = 1))
}
@Test
fun `the two tabs are the read flag and nothing else`() {
val notifications = listOf(
notification(id = 1, readAt = null),
notification(id = 2, readAt = "2026-08-06T09:00:00Z"),
notification(id = 3, readAt = null),
)
assertEquals(
listOf(1L, 3L),
alertsForTab(AlertsTab.INBOX, notifications).map { it.id },
)
assertEquals(
listOf(2L),
alertsForTab(AlertsTab.SEEN, notifications).map { it.id },
)
}
/** Every alert is in exactly one half, or the counts on the strip could not add up. */
@Test
fun `every alert lands in one half`() {
val notifications = (1..7).map {
notification(id = it.toLong(), readAt = if (it % 2 == 0) "2026-08-06T09:00:00Z" else null)
}
val inbox = alertsForTab(AlertsTab.INBOX, notifications)
val seen = alertsForTab(AlertsTab.SEEN, notifications)
assertEquals(notifications.size, inbox.size + seen.size)
assertEquals(emptyList<Long>(), inbox.map { it.id }.intersect(seen.map { it.id }.toSet()).toList())
}
/** The order the caller gave is kept: both panes page the same way the one list did. */
@Test
fun `a tab keeps the order it was given`() {
val notifications = listOf(
notification(id = 9, readAt = null),
notification(id = 4, readAt = null),
notification(id = 6, readAt = null),
)
assertEquals(listOf(9L, 4L, 6L), alertsForTab(AlertsTab.INBOX, notifications).map { it.id })
}
@Test
fun `a tab count is drawn as itself, zero included`() {
assertEquals("0", alertTabCountLabel(0))
assertEquals("0", alertTabCountLabel(-3))
assertEquals("1", alertTabCountLabel(1))
assertEquals("99", alertTabCountLabel(99))
}
@Test
fun `a large tab count states the cap rather than widening the tab`() {
assertEquals("99+", alertTabCountLabel(100))
assertEquals("99+", alertTabCountLabel(4210))
}
private fun notification(id: Long, readAt: String?) = UserNotification(
id = id,
kind = "series_return",
title = "Northbound returns",
message = "Season 3 starts on Thursday.",
readAt = readAt,
)
}
@@ -1,7 +1,9 @@
package com.ponzischeme89.memby.ui.alerts
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onRoot
import androidx.compose.ui.test.performClick
import com.github.takahirom.roborazzi.captureRoboImage
import com.ponzischeme89.memby.data.EmbyProfile
import com.ponzischeme89.memby.data.model.NotificationPreferences
@@ -40,18 +42,79 @@ class AlertsPageScreenshotTest {
capture("my-alerts-populated", sampleAlerts)
}
/** Nothing new: the "NEW" flags are gone and the rows read as a list, not as news. */
/**
* Everything already read, which on this page means an empty Inbox the pane the page
* opens on. The capture is the check that the strip still says where the three alerts
* went: an empty half beside a Seen tab reading 3 is good news, where an empty page with
* no counts on it reads as a list that lost them.
*/
@Test
fun `everything already read`() {
capture("my-alerts-all-read", sampleAlerts.map { it.copy(readAt = "2026-08-06T09:00:00Z") })
}
/**
* More alerts than a page holds, which is what puts the pager on screen. The capture is
* the check that four rows and the pager under them fit the 540dp a television has
* a page whose last row is below the fold would be one somebody has to scroll *and*
* page through.
*/
@Test
fun `paged`() {
capture("my-alerts-paged", manyAlerts)
}
/**
* One alert past a full page. The capture opens on page one, so what it shows is the
* pager appearing for a list barely long enough to need it the case where a pager
* that took a row's worth of height would not have earned it.
*/
@Test
fun `just past one page`() {
capture("my-alerts-paged-shallow", manyAlerts.take(AlertsPageSize + 1))
}
/**
* A full page of the tallest row this page can draw every message wrapping onto a
* second line. This is the capture the four-rows-to-a-page figure is answerable to: the
* pager is anchored to the bottom edge, so the thing to look at is whether the last row
* still clears it.
*/
@Test
fun `a crowded page`() {
capture(
"my-alerts-paged-crowded",
manyAlerts.take(AlertsPageSize + 2).map {
it.copy(
message = "Season 4 of this show returns on Thursday, and the first two " +
"episodes will be in Emby that morning if the download lands.",
)
},
)
}
/**
* The other half, reached the way a viewer reaches it by pressing the tab. The strip is
* the only thing on the page saying which half is open, so a capture that set the pane
* some other way would not be a picture of what a television shows.
*/
@Test
fun `the seen half`() {
capture("my-alerts-seen", manyAlerts) {
compose.onNodeWithContentDescription("Seen, 3").performClick()
}
}
@Test
fun `nothing waiting`() {
capture("my-alerts-empty", emptyList())
}
/** Alerts switched off has its own empty wording — and both toggles read as off. */
/**
* Notifications switched off for the profile. The page has no switch for it any more, so
* this is the capture that the empty state still explains why nothing is arriving the
* only place a viewer can now be told.
*/
@Test
fun `alerts switched off`() {
capture(
@@ -125,24 +188,37 @@ class AlertsPageScreenshotTest {
name: String,
notifications: List<UserNotification>,
preferences: NotificationPreferences = NotificationPreferences(),
act: () -> Unit = {},
) {
compose.setContent {
MembyTheme {
MyAlertsPage(
notifications = notifications,
preferences = preferences,
onToggleEnabled = {},
onToggleShowReturns = {},
onRead = {},
onDismiss = {},
onDismissAll = {},
onClose = {},
)
}
}
act()
compose.onRoot().captureRoboImage("build/screenshots/my-alerts/$name.png")
}
/**
* Eleven alerts: three pages, the last of them part-filled. Built from the samples so the
* paged captures and the single-page ones cannot drift into looking like different pages.
*/
private val manyAlerts: List<UserNotification>
get() = (0 until 11).map { index ->
val sample = sampleAlerts[index % sampleAlerts.size]
sample.copy(
id = index + 1L,
title = sample.title + " (" + (index + 1) + ")",
readAt = if (index % 3 == 2) "2026-08-05T11:00:00Z" else null,
)
}
private val sampleAlerts = listOf(
UserNotification(
id = 1,
@@ -0,0 +1,102 @@
package com.ponzischeme89.memby.ui.alerts
import org.junit.Assert.assertEquals
import org.junit.Test
/**
* The arithmetic behind the Notifications pager. It is worth pinning because every one of
* these answers is reached while a viewer is holding a remote at a list that is emptying
* itself underneath them the cases that go wrong are the ones nobody reproduces by hand.
*/
class AlertsPagingTest {
@Test
fun `an empty list is no pages at all`() {
assertEquals(0, alertPageCount(0, 4))
assertEquals(0, alertPageCount(-3, 4))
}
@Test
fun `a part-filled page still counts`() {
assertEquals(1, alertPageCount(1, 4))
assertEquals(1, alertPageCount(4, 4))
assertEquals(2, alertPageCount(5, 4))
assertEquals(25, alertPageCount(100, 4))
}
@Test
fun `a page holds its own slice and the last one holds the remainder`() {
val items = (1..9).toList()
assertEquals(listOf(1, 2, 3, 4), alertPageItems(items, 0, 4))
assertEquals(listOf(5, 6, 7, 8), alertPageItems(items, 1, 4))
assertEquals(listOf(9), alertPageItems(items, 2, 4))
}
/** Reached for one frame whenever the list shrinks under somebody on the last page. */
@Test
fun `a page past the end holds nothing rather than throwing`() {
assertEquals(emptyList<Int>(), alertPageItems(listOf(1, 2), 5, 4))
assertEquals(emptyList<Int>(), alertPageItems(emptyList<Int>(), 0, 4))
}
/**
* The property the whole feature rests on: every row appears on exactly one page, and the
* pages together are the list in order.
*/
@Test
fun `the pages reassemble the list`() {
for (total in 0..40) {
val items = (1..total).toList()
val pages = (0 until alertPageCount(items.size, 4)).flatMap {
alertPageItems(items, it, 4)
}
assertEquals("total=$total", items, pages)
}
}
@Test
fun `dismissing the last row of the last page steps back a page`() {
// Five alerts on two pages; the viewer is on page 1 holding its only row.
assertEquals(0, alertPageAfterChange(page = 1, remainingTotal = 4, pageSize = 4))
}
@Test
fun `a page that still has rows is kept rather than reset`() {
assertEquals(2, alertPageAfterChange(page = 2, remainingTotal = 11, pageSize = 4))
}
@Test
fun `an emptied list stands on the page the empty state occupies`() {
assertEquals(0, alertPageAfterChange(page = 7, remainingTotal = 0, pageSize = 4))
}
@Test
fun `the label counts from one`() {
assertEquals("Page 1 of 3", alertPageLabel(0, 3))
assertEquals("Page 3 of 3", alertPageLabel(2, 3))
}
@Test
fun `there is nothing to label without pages`() {
assertEquals("", alertPageLabel(0, 0))
}
/** A page out of range is clamped rather than printed, or the pager contradicts itself. */
@Test
fun `the label never reports a page past the end`() {
assertEquals("Page 2 of 2", alertPageLabel(9, 2))
}
/** The toggle names what pressing it does, not the state the row is already in. */
@Test
fun `the seen toggle names its action`() {
assertEquals("Mark as seen", alertSeenActionLabel(unread = true))
assertEquals("Mark as new", alertSeenActionLabel(unread = false))
}
/** The page size is what the layout was measured against; changing it is a layout change. */
@Test
fun `a page holds four rows`() {
assertEquals(4, AlertsPageSize)
}
}
@@ -88,7 +88,7 @@ class PlaybackIdentityScreenshotTest {
}
identity.findViewById<TextView>(R.id.player_playback_identity_title).visibility = View.GONE
identity.findViewById<TextView>(R.id.player_playback_identity_episode).apply {
text = "S02E04 · The Other You"
text = "S02E04 The Other You"
visibility = View.VISIBLE
}
root.addView(identity)
@@ -100,6 +100,60 @@ class PlaybackIdentityScreenshotTest {
)
}
@Test
fun `a film shows its logo and nothing beneath it`() {
val activity = Robolectric.buildActivity(Activity::class.java).setup().get()
val root = backdropRoot(activity)
val identity = LayoutInflater.from(activity)
.inflate(R.layout.player_playback_identity, root, false)
.apply {
visibility = View.VISIBLE
alpha = 1f
}
identity.findViewById<ImageView>(R.id.player_playback_identity_logo).apply {
setImageBitmap(colourLogo())
visibility = View.VISIBLE
}
identity.findViewById<TextView>(R.id.player_playback_identity_title).visibility = View.GONE
// A film has no episode line at all — the plate must not be reserved for one.
identity.findViewById<TextView>(R.id.player_playback_identity_episode).visibility = View.GONE
root.addView(identity)
activity.setContentView(root)
root.captureRoboImage(
"build/screenshots/playback-identity/player-playback-identity-movie.png",
)
}
@Test
fun `a long episode title is held to one line beside the show logo`() {
val activity = Robolectric.buildActivity(Activity::class.java).setup().get()
val root = backdropRoot(activity)
val identity = LayoutInflater.from(activity)
.inflate(R.layout.player_playback_identity, root, false)
.apply {
visibility = View.VISIBLE
alpha = 1f
}
// No logo: the fallback heading stands in, and the episode line must sit in the
// same place under it as it does under artwork.
identity.findViewById<ImageView>(R.id.player_playback_identity_logo).visibility = View.GONE
identity.findViewById<TextView>(R.id.player_playback_identity_title).apply {
text = "Friends"
visibility = View.VISIBLE
}
identity.findViewById<TextView>(R.id.player_playback_identity_episode).apply {
text = "S04E08 — The One Where They All Go To A Wedding And Nobody Says Anything"
visibility = View.VISIBLE
}
root.addView(identity)
activity.setContentView(root)
root.captureRoboImage(
"build/screenshots/playback-identity/player-playback-identity-long-episode.png",
)
}
@Test
fun `loading keeps the selected backdrop visible`() {
val activity = Robolectric.buildActivity(Activity::class.java).setup().get()
@@ -120,7 +174,7 @@ class PlaybackIdentityScreenshotTest {
@Test
fun `episode ident separates the series from the episode`() {
assertEquals(
"S02E04 · The Other You",
"S02E04 The Other You",
playbackIdentityEpisodeLabel(
title = "Dark Matter The Other You",
seriesName = "Dark Matter",
@@ -130,6 +184,95 @@ class PlaybackIdentityScreenshotTest {
assertEquals(null, playbackIdentityEpisodeLabel("Arrival", null, null))
}
@Test
fun `an episode named after its show is announced by its code alone`() {
assertEquals(
"S01E01",
playbackIdentityEpisodeLabel(
title = "Dark Matter",
seriesName = "Dark Matter",
episodeCode = "s01e01",
),
)
}
@Test
fun `a long episode title survives to the view, which ellipsises it`() {
val label = playbackIdentityEpisodeLabel(
title = "The One Where They All Go To A Wedding And Nobody Says Anything",
seriesName = "Friends",
episodeCode = "S04E08",
)
assertEquals(
"S04E08 — The One Where They All Go To A Wedding And Nobody Says Anything",
label,
)
}
@Test
fun `the transport owns the corner while it is up, and pause owns it outright`() {
// The ident and the transport's now-playing block are the same information in the
// same place: exactly one of them may draw.
assertEquals(
PlayerIdentitySlot.IDENT,
playerIdentitySlot(identWindowOpen = true, transportVisible = false, paused = false),
)
assertEquals(
PlayerIdentitySlot.TRANSPORT,
playerIdentitySlot(identWindowOpen = true, transportVisible = true, paused = false),
)
assertEquals(
PlayerIdentitySlot.NONE,
playerIdentitySlot(identWindowOpen = true, transportVisible = true, paused = true),
)
assertEquals(
PlayerIdentitySlot.NONE,
playerIdentitySlot(identWindowOpen = false, transportVisible = false, paused = false),
)
}
@Test
fun `the ident opens once and is never raised a second time`() {
assertTrue(
shouldRaiseIdent(PlaybackIdentityPhase.PENDING, transportVisible = false, paused = false),
)
// A re-prepare, a recovery retry or a second report of "playback started" arrives
// with the ident already spent, and must not open another.
assertFalse(
shouldRaiseIdent(PlaybackIdentityPhase.SHOWING, transportVisible = false, paused = false),
)
assertFalse(
shouldRaiseIdent(PlaybackIdentityPhase.DONE, transportVisible = false, paused = false),
)
// And it is withheld outright where something else already answers the question.
assertFalse(
shouldRaiseIdent(PlaybackIdentityPhase.PENDING, transportVisible = true, paused = false),
)
assertFalse(
shouldRaiseIdent(PlaybackIdentityPhase.PENDING, transportVisible = false, paused = true),
)
}
private fun backdropRoot(activity: Activity): FrameLayout {
val root = FrameLayout(activity)
val backdrop = ImageView(activity).apply {
scaleType = ImageView.ScaleType.CENTER_CROP
setImageBitmap(
javaClass.classLoader
?.getResourceAsStream("home_hero_preview_art.png")
?.use(BitmapFactory::decodeStream),
)
}
root.addView(
backdrop,
FrameLayout.LayoutParams(
FrameLayout.LayoutParams.MATCH_PARENT,
FrameLayout.LayoutParams.MATCH_PARENT,
),
)
return root
}
private fun colourLogo(): Bitmap = Bitmap.createBitmap(420, 120, Bitmap.Config.ARGB_8888).apply {
eraseColor(Color.rgb(82, 181, 75))
}