0.2.77
This commit is contained in:
@@ -63,7 +63,7 @@ val projectNoticeText =
|
||||
|
||||
// A release workflow can derive the app version from its Git tag without editing the
|
||||
// source tree. Local builds keep using the checked-in default.
|
||||
val defaultVersionName = "0.2.76"
|
||||
val defaultVersionName = "0.2.77"
|
||||
val membyVersionName: String =
|
||||
(project.findProperty("memby.versionName") as String?)
|
||||
?.trim()
|
||||
|
||||
@@ -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("You’re all caught up.", color = MembyMutedText, fontSize = 20.sp, fontWeight = FontWeight.SemiBold)
|
||||
Text(
|
||||
if (inbox) "You’re 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)
|
||||
}
|
||||
}
|
||||
+145
-2
@@ -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))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user