App v0.2.26 and gateway 0.1.20

Client: seek controls, Bazarr subtitle download and cast panel in the
player; MDBList ratings strip; episode and schedule detail pages; series
pace estimate; what's new panel; install-permission onboarding step;
synced per-profile preferences; Emby outage banner.

Gateway: rebuilt admin console (one fragment per page), preference
history and restore, merged Continue Watching, Emby health probe,
subtitle selection and Bazarr download, structured request logging with
per-request identity, and embedded build version.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
ponzischeme89
2026-08-06 22:33:56 +12:00
co-authored by Claude Opus 5
parent 2675e6d82b
commit 4a4df7a73c
257 changed files with 24868 additions and 3108 deletions
+11 -1
View File
@@ -32,6 +32,9 @@ fun buildConfigString(value: String): String =
.replace("\r\n", "\\n")
.replace("\n", "\\n") + "\""
// The About page's version history. Kept as one checked-in document rather than a Kotlin
// list so a release only edits CHANGELOG.md, and the TV shows the history offline.
val changelogText = rootProject.file("CHANGELOG.md").readText()
val gplLicenseText = rootProject.file("LICENSE").readText()
val projectNoticeText =
rootProject.file("NOTICE").readText()
@@ -39,7 +42,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.10"
val defaultVersionName = "0.2.26"
val membyVersionName: String =
(project.findProperty("memby.versionName") as String?)
?.trim()
@@ -81,6 +84,7 @@ android {
buildConfigField("String", "SOURCE_CODE_URL", buildConfigString(membySourceUrl))
buildConfigField("String", "GPL_LICENSE_TEXT", buildConfigString(gplLicenseText))
buildConfigField("String", "PROJECT_NOTICE_TEXT", buildConfigString(projectNoticeText))
buildConfigField("String", "CHANGELOG_TEXT", buildConfigString(changelogText))
}
// Release signing. Android identifies an app by (applicationId, signing key), so
@@ -189,6 +193,9 @@ dependencies {
// Installs the baseline profile below. Without it the profile is only honoured on
// API 31+; a TV box on Android 9-11 — most of the installed base — would get nothing.
implementation("androidx.profileinstaller:profileinstaller:1.4.1")
// Names the playback launch phases in a systrace so :benchmark can measure them.
// Free when tracing is off, which is every build a viewer ever runs.
implementation("androidx.tracing:tracing-ktx:1.2.0")
// Compose (versions from BOM)
implementation("androidx.compose.ui:ui")
@@ -216,6 +223,9 @@ dependencies {
implementation("androidx.media3:media3-exoplayer:1.5.1")
implementation("androidx.media3:media3-exoplayer-hls:1.5.1")
implementation("androidx.media3:media3-ui:1.5.1")
// Lets the player pull its bytes through the app's one OkHttp stack instead of
// media3's own HttpURLConnection client — see ui/player/PlayerEngine.kt.
implementation("androidx.media3:media3-datasource-okhttp:1.5.1")
debugImplementation("androidx.compose.ui:ui-tooling")
+19 -10
View File
@@ -6,6 +6,10 @@
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<!-- Needed to hand a downloaded APK to the system installer (in-app updates). -->
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<!-- Lets a self-update apply without a confirmation screen once Memby is its own
installer of record (Android 12+). Ignored before that, and never required: the
install session falls back to asking. -->
<uses-permission android:name="android.permission.UPDATE_PACKAGES_WITHOUT_USER_ACTION" />
<!-- Package visibility (Android 11+). Without these, resolveActivity() returns null and
"Play in Emby" / "Screensaver settings" silently do nothing. -->
@@ -13,6 +17,12 @@
<!-- EmbyAppLauncher hands an item to an installed Emby client. -->
<package android:name="com.mb.android" />
<package android:name="tv.emby.embyatv" />
<!-- The per-app "install unknown apps" screen. Many TV builds do not implement it;
declaring it here is what lets the updater tell the difference between "the
viewer has not granted this yet" and "this TV has nowhere to grant it". -->
<intent>
<action android:name="android.settings.MANAGE_UNKNOWN_APP_SOURCES" />
</intent>
<!-- openScreensaverSettings probes the TV's own settings screens, best first. -->
<intent>
<action android:name="android.settings.DREAM_SETTINGS" />
@@ -111,16 +121,15 @@
android:resource="@xml/emby_dream" />
</service>
<!-- Serves the downloaded update APK to the system package installer. -->
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
<!-- Outcome of a self-update's install session. Not exported: the only sender is
the system, through a PendingIntent this app created. -->
<receiver
android:name=".update.InstallResultReceiver"
android:exported="false">
<intent-filter>
<action android:name="com.ponzischeme89.memby.INSTALL_RESULT" />
</intent-filter>
</receiver>
</application>
</manifest>
@@ -4,6 +4,7 @@ import android.annotation.SuppressLint
import android.content.Context
import com.ponzischeme89.memby.data.EmbyRepository
import com.ponzischeme89.memby.data.MaintenanceMonitor
import com.ponzischeme89.memby.data.PreferencesSync
import com.ponzischeme89.memby.data.SettingsStore
/**
@@ -22,10 +23,22 @@ object ServiceLocator {
lateinit var maintenance: MaintenanceMonitor
private set
/**
* Held rather than discarded because it is a long-lived collector, not a service
* anything calls: it starts working when it is constructed and must not be collected
* while a viewer's settings are half-synced.
*/
lateinit var preferencesSync: PreferencesSync
private set
fun init(context: Context) {
if (::repository.isInitialized) return
settings = SettingsStore(context.applicationContext)
repository = EmbyRepository(settings)
maintenance = MaintenanceMonitor(repository, settings)
// Takes the revision channel from the status poll rather than polling itself: the
// app already asks the gateway a question every ten seconds, and settings do not
// deserve a second connection.
preferencesSync = PreferencesSync(repository, settings, maintenance.preferencesRevision)
}
}
@@ -0,0 +1,86 @@
package com.ponzischeme89.memby.data
import com.ponzischeme89.memby.data.model.BaseItem
/**
* The direct-to-Emby copy of the gateway's `mergeContinueWatching`.
*
* The rule exists twice on purpose, like the subtitle rule does: with no gateway there is
* nobody to ask, and Continue Watching must not hold different cards depending on whether
* the container is up. `ContinueWatchingTest` and `continue_watching_test.go` are
* deliberately parallel — change one and change the other.
*
* Resume items and Next Up episodes are one row because they answer one question. Both
* lists arrive most-recently-watched first, so this is a merge of two sorted lists and
* never a sort of their union: Emby's order within each is the useful part. What places a
* Next Up episode is when its *series* was last watched, since the episode itself is
* unwatched and carries no date of its own.
*/
internal fun mergeContinueWatching(
resume: List<BaseItem>,
nextUp: List<BaseItem>,
seriesLastPlayed: Map<String, String>,
): List<BaseItem> {
val seenItems = mutableSetOf<String>()
val seenSeries = mutableSetOf<String>()
val inProgress = resume.map { item ->
seenItems += item.id
item.continueSeriesKey()?.let { seenSeries += it }
item to item.continuePlayedAt(seriesLastPlayed)
}
// A series being watched right now is represented by the episode it is part-way
// through, not by the one after it.
val upNext = nextUp
.filter { item ->
val series = item.continueSeriesKey()
item.id !in seenItems && (series == null || seenSeries.add(series))
}
.map { item -> item to item.continuePlayedAt(seriesLastPlayed) }
val merged = ArrayList<BaseItem>(inProgress.size + upNext.size)
var left = 0
var right = 0
while (left < inProgress.size && right < upNext.size) {
val (nextItem, nextAt) = upNext[right]
val (currentItem, currentAt) = inProgress[left]
// An undated card never displaces a dated one, and falls back to the resume half:
// that is the list which is definitely in progress.
if (nextAt != null && (currentAt == null || nextAt > currentAt)) {
merged += nextItem
right++
} else {
merged += currentItem
left++
}
}
while (left < inProgress.size) merged += inProgress[left++].first
while (right < upNext.size) merged += upNext[right++].first
return merged
}
private fun BaseItem.continueSeriesKey(): String? = seriesId?.takeIf(String::isNotBlank)
private fun BaseItem.continuePlayedAt(seriesLastPlayed: Map<String, String>): String? =
normalizePlayedAt(userData?.lastPlayedDate)
?: continueSeriesKey()?.let { normalizePlayedAt(seriesLastPlayed[it]) }
/**
* Emby writes UTC timestamps with .NET's seven fractional digits
* (`2026-08-06T21:04:05.1234567Z`), so trimming to whole seconds makes them fixed-width
* and directly comparable as strings — `java.time` is not available at this minSdk.
* Anything that is not a recognisable timestamp is undated rather than mis-ordered.
*/
internal fun normalizePlayedAt(value: String?): String? {
val trimmed = value?.trim().orEmpty()
if (trimmed.length < 19) return null
val stamp = trimmed.take(19)
val shaped = stamp[4] == '-' && stamp[7] == '-' && stamp[10] == 'T' &&
stamp[13] == ':' && stamp[16] == ':'
if (!shaped) return null
val digits = stamp.filterIndexed { index, _ -> index !in setOf(4, 7, 10, 13, 16) }
if (!digits.all(Char::isDigit)) return null
// Emby's "never played" sentinel. A real date it is not, and treating it as one would
// sort a card by a year nobody watched anything in.
if (stamp.startsWith("0001-01-01")) return null
return stamp
}
@@ -11,6 +11,8 @@ import com.ponzischeme89.memby.data.model.GatewayPrerollSchedule
import com.ponzischeme89.memby.data.model.GatewayRowEvent
import com.ponzischeme89.memby.data.model.GatewayRowEvents
import com.ponzischeme89.memby.data.model.GatewayServiceStatus
import com.ponzischeme89.memby.data.model.GatewaySubtitleCandidate
import com.ponzischeme89.memby.data.model.GatewaySubtitleDownloadRequest
import com.ponzischeme89.memby.data.model.GatewayFeatures
import com.ponzischeme89.memby.data.model.HomeRow
import com.ponzischeme89.memby.data.model.PlaybackReport
@@ -28,6 +30,7 @@ import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.asSharedFlow
@@ -67,6 +70,8 @@ data class NextEpisode(
val url: String,
val resumePositionMs: Long = 0L,
val subtitles: List<PlayableSubtitle> = emptyList(),
val subtitlesEnabled: Boolean = true,
val selectedSubtitleId: String = "",
val mediaSourceId: String = "",
val playSessionId: String = "",
val playMethod: String = "DirectPlay",
@@ -80,6 +85,13 @@ data class Playable(
val resumePositionMs: Long = 0L,
val logoUrl: String? = null,
val subtitles: List<PlayableSubtitle> = emptyList(),
/**
* Which subtitle track to turn on, decided from the viewer's synced settings. On the
* gateway path the server decides it, because that is what asks Emby which tracks
* exist; on the direct path the same rule runs here. Blank means "none suitable".
*/
val subtitlesEnabled: Boolean = true,
val selectedSubtitleId: String = "",
val mediaSourceId: String = "",
val playSessionId: String = "",
val playMethod: String = "DirectPlay",
@@ -88,6 +100,67 @@ data class Playable(
val runtimeMs: Long = 0L,
val prerollEnabled: Boolean = true,
val prerollDurationMs: Long = 6_500L,
/**
* Whether the backend can fetch a subtitle this title does not have. Only the gateway
* can - the direct path has nobody to ask - and it stays false when an operator has
* turned the feature off, so the player never offers a row that cannot do anything.
*/
val subtitleDownloadAvailable: Boolean = false,
)
/**
* What a subtitle search came back with.
*
* An empty list is an ordinary answer, not a failure - the providers may simply have
* nothing for this release - so [message] is what the drop-up prints in its place. It
* comes from the gateway when the gateway answered, because "nothing was found" and
* "Memby could not work out which title this is" are different things to be told.
*/
data class SubtitleSearch(
val results: List<GatewaySubtitleCandidate> = emptyList(),
val message: String = "",
)
/**
* A downloaded subtitle, with the item's tracks as Emby reports them after being told to
* look again. The player swaps its media item to these rather than re-resolving the
* stream, so the new track comes on without leaving the film.
*/
data class SubtitleDownload(
val message: String,
val subtitles: List<PlayableSubtitle>,
val selectedSubtitleId: String,
val mediaSourceId: String,
val playSessionId: String,
val url: String,
)
/**
* Everything needed to resolve a stream, plus everything the player needs to dress its
* loading screen while that resolution is still in flight.
*
* It exists so that pressing Play can open the player *before* the server has answered.
* Starting an activity, inflating its layout and initialising a video decoder all cost
* real time on a television, and there is no reason for them to queue up behind a network
* round trip that they do not depend on. Everything here is already known to the launcher
* from the card the viewer was looking at.
*
* [resumePositionMs] is the client's last known resume point, which is what the card
* displayed; the server's answer remains authoritative for where playback actually starts.
* It is carried because the pre-roll decision cannot wait for that answer — see
* `shouldShowPreroll`.
*/
@Serializable
data class PlaybackRequest(
val itemId: String,
val itemType: String = "",
val title: String = "",
val isSeries: Boolean = false,
val resumePositionMs: Long = 0L,
val logoUrl: String? = null,
val overview: String? = null,
val episodeCode: String? = null,
val runtimeMs: Long = 0L,
)
class EmbyRepository(private val settings: SettingsStore) {
@@ -122,6 +195,7 @@ class EmbyRepository(private val settings: SettingsStore) {
private val relatedMutex = Mutex()
private val relatedCache =
LinkedHashMap<String, CachedRelated>(RELATED_CACHE_SIZE, 0.75f, true)
private val relatedInFlight = mutableMapOf<String, Deferred<RelatedContent?>>()
fun cachedHome(): HomeCache? = settings.homeCache(snapshot)
@@ -412,25 +486,73 @@ class EmbyRepository(private val settings: SettingsStore) {
)
}
/** Unfinished movies and episodes for the current Emby user. */
/**
* Everything in progress: unfinished movies and episodes, interleaved with the episode
* that follows each show somebody has just finished.
*
* They are one row — see [mergeContinueWatching]. The gateway does this merge itself;
* this is the direct path's copy, and the three requests run together because an
* episode's place in the row depends on when its series was last watched.
*/
suspend fun getContinueWatching(limit: Int = 24): List<BaseItem> {
if (ServerConfig.isGateway) return getHome(limit).continueWatching
val userId = snapshot.userId ?: error("Not connected")
return requireApi().getResumeItems(
userId,
mapOf(
"Recursive" to "true",
"MediaTypes" to "Video",
"Limit" to limit.toString(),
"Fields" to "RunTimeTicks,SeriesName,PrimaryImageAspectRatio",
"ImageTypeLimit" to "1",
"EnableImageTypes" to "Backdrop,Primary,Logo",
"EnableUserData" to "true",
),
).items
return coroutineScope {
val resume = async {
requireApi().getResumeItems(
userId,
mapOf(
"Recursive" to "true",
"MediaTypes" to "Video",
"Limit" to limit.toString(),
"Fields" to "RunTimeTicks,SeriesId,SeriesName,PrimaryImageAspectRatio",
"ImageTypeLimit" to "1",
"EnableImageTypes" to "Backdrop,Primary,Logo",
"EnableUserData" to "true",
),
).items
}
val nextUp = async { runCatching { getNextUp(limit) }.getOrDefault(emptyList()) }
// What orders the Next Up half. A failure here is not a failed row: the merge
// falls back to the resume items first.
val played = async { runCatching { recentlyPlayedSeries() }.getOrDefault(emptyMap()) }
mergeContinueWatching(resume.await(), nextUp.await(), played.await())
}
}
/** Episodes the server recommends playing next, excluding resumable duplicates in the UI. */
/**
* When anything in each series was last played, keyed by series id. A Next Up episode
* is unwatched and so carries no date of its own; this is what places it in the row.
*/
private suspend fun recentlyPlayedSeries(): Map<String, String> {
val userId = snapshot.userId ?: error("Not connected")
val played = requireApi().getItems(
userId,
mapOf(
"IncludeItemTypes" to "Episode",
"Recursive" to "true",
"Filters" to "IsPlayed",
"SortBy" to "DatePlayed",
"SortOrder" to "Descending",
"Limit" to CONTINUE_PLAY_LOOKBACK.toString(),
"Fields" to "SeriesId",
"EnableImages" to "false",
"EnableUserData" to "true",
"EnableTotalRecordCount" to "false",
),
).items
// Emby answers most-recently-played first, so the first entry seen for a series is
// the one that counts.
val recent = LinkedHashMap<String, String>()
for (item in played) {
val seriesId = item.seriesId?.takeIf(String::isNotBlank) ?: continue
val at = normalizePlayedAt(item.userData?.lastPlayedDate) ?: continue
recent.putIfAbsent(seriesId, at)
}
return recent
}
/** Episodes Emby recommends playing next. Merged into Continue Watching for display. */
suspend fun getNextUp(limit: Int = 24): List<BaseItem> {
if (ServerConfig.isGateway) return getHome(limit).nextUp
val userId = snapshot.userId ?: error("Not connected")
@@ -438,7 +560,7 @@ class EmbyRepository(private val settings: SettingsStore) {
mapOf(
"UserId" to userId,
"Limit" to limit.toString(),
"Fields" to "Overview,ProductionYear,RunTimeTicks,SeriesName,PrimaryImageAspectRatio",
"Fields" to "Overview,ProductionYear,RunTimeTicks,SeriesId,SeriesName,PrimaryImageAspectRatio",
"ImageTypeLimit" to "1",
"EnableImageTypes" to "Backdrop,Primary,Logo",
"EnableTotalRecordCount" to "false",
@@ -542,11 +664,19 @@ class EmbyRepository(private val settings: SettingsStore) {
return requireGateway().recommendationPreferences()
}
suspend fun saveRecommendationRatings(ratings: Map<String, Int>) {
suspend fun saveRecommendationRatings(
ratings: Map<String, Int>,
actors: List<String> = emptyList(),
actresses: List<String> = emptyList(),
directors: List<String> = emptyList(),
) {
if (!ServerConfig.isGateway) return
requireGateway().saveRecommendationPreferences(
com.ponzischeme89.memby.data.model.RecommendationPreferences(
ratings = ratings.filterValues { it in 1..5 },
actors = actors,
actresses = actresses,
directors = directors,
),
)
}
@@ -600,6 +730,40 @@ class EmbyRepository(private val settings: SettingsStore) {
suspend fun serverFeatures(): GatewayFeatures = requireGateway().features()
/** This viewer's server-held settings. */
suspend fun userPreferences(): com.ponzischeme89.memby.data.model.GatewayPreferences =
requireGateway().preferences()
/**
* Writes them back, at the revision this TV believes it is editing.
*
* A 409 is not an error here and not a reason to retry: somebody else changed the
* document, and on this endpoint that is nearly always the operator pushing settings
* from the admin console. Their copy comes back in the conflict body, so it is
* returned as an ordinary result and the caller adopts it. Retrying would undo them.
*/
suspend fun saveUserPreferences(
revision: Long,
preferences: kotlinx.serialization.json.JsonObject,
): com.ponzischeme89.memby.data.model.GatewayPreferences = try {
requireGateway().savePreferences(
com.ponzischeme89.memby.data.model.GatewayPreferencesRequest(revision, preferences),
)
} catch (conflict: HttpException) {
if (conflict.code() != 409) throw conflict
parsePreferencesConflict(conflict) ?: throw conflict
}
/**
* Whether Emby answers, on the direct path where there is no gateway to ask.
*
* `System/Info/Public` needs no credentials and returns a few bytes, which is what
* makes it usable as a repeated probe — anything under `Users/{id}` would fail for a
* stale token and be reported as an outage that is really a sign-in problem.
*/
suspend fun pingEmby(): Boolean =
runCatching { requireApi().systemInfoPublic().close() }.isSuccess
/** Full item metadata, requested only after focus settles on an item. */
suspend fun getItemDetails(itemId: String): BaseItem {
if (ServerConfig.isGateway) return requireGateway().item(itemId)
@@ -607,22 +771,40 @@ class EmbyRepository(private val settings: SettingsStore) {
return requireApi().getItem(
userId = userId,
itemId = itemId,
fields = "Overview,Genres,MediaStreams,People,ProductionYear,OfficialRating,CommunityRating,RunTimeTicks,PrimaryImageAspectRatio,CollectionName",
fields = "Overview,Genres,MediaStreams,People,ProductionYear,OfficialRating,CommunityRating,RunTimeTicks,SeriesId,SeriesName,Status,ParentIndexNumber,IndexNumber,PrimaryImageAspectRatio,CollectionName",
)
}
/**
* Optional movie ratings authored entirely by the gateway. Direct Emby mode has no
* MDBList configuration, and any gateway failure is deliberately indistinguishable
* from a movie with no external ratings.
*/
suspend fun getMovieRatings(
itemId: String,
): List<com.ponzischeme89.memby.data.model.GatewayMovieRating> {
if (!ServerConfig.isGateway || itemId.isBlank()) return emptyList()
return runCatching { requireGateway().movieRatings(itemId).ratings }
.getOrDefault(emptyList())
.filter { it.name.isNotBlank() && it.score.isNotBlank() && it.scale.isNotBlank() }
private val ratingsCache = java.util.concurrent.ConcurrentHashMap<String, List<com.ponzischeme89.memby.data.model.MediaRating>>()
/** Optional ratings that never block essential metadata. The gateway owns a durable
* cache; this process cache makes repeated focus/detail visits free on the TV. */
suspend fun getRatings(item: BaseItem): List<com.ponzischeme89.memby.data.model.MediaRating> {
// Rows and detail payloads now carry whatever the gateway already had stored, so
// the common case costs no request at all. Seed the process cache with it: the
// same title reached from search or a related rail then needs nothing either.
item.membyRatings.takeIf { it.isNotEmpty() }?.let { carried ->
ratingsCache[item.id] = carried
return carried
}
ratingsCache[item.id]?.let { return it }
// Emby's own community rating is deliberately not a fallback. It arrives with no
// provider behind it, so standing it in for a real one meant a card claiming a
// TMDb score TMDb had never been asked for — and the strip exists to say where a
// number came from. A title MDBList cannot answer for shows no strip at all.
val result = if (ServerConfig.isGateway && item.id.isNotBlank()) {
runCatching { requireGateway().movieRatings(item.id).ratings }.getOrDefault(emptyList())
} else emptyList()
// Do not make an empty result permanent: the gateway fills its cache behind the
// viewer, so a title it could not decorate now can answer a moment later.
if (result.isNotEmpty()) ratingsCache[item.id] = result
return result
}
@Deprecated("Use getRatings(BaseItem)")
suspend fun getMovieRatings(itemId: String): List<com.ponzischeme89.memby.data.model.MediaRating> {
val item = runCatching { getItemDetails(itemId) }.getOrNull() ?: return emptyList()
return getRatings(item)
}
/** Whether this episode closes its season; failures are non-fatal playback metadata. */
@@ -660,56 +842,79 @@ class EmbyRepository(private val settings: SettingsStore) {
* own history, while direct mode has only Emby's similarity ranking and therefore no
* reasons at all. An empty result is normal, never an error — a detail page that
* cannot explain itself still has to open.
*
* Two things keep this from being noisy. It is **single-flighted on the repository's
* own scope**, because the caller is usually [HomeViewModel.focusItem] warming the
* page while the card is focused: that job is cancelled the moment the D-pad moves
* on, and a request cancelled at the socket is one the gateway logs as a failure and
* one nobody keeps the answer of — so the shared request outlives the caller that
* started it, and opening Details a moment later awaits it rather than asking again.
* And **only a real answer is cached**; a failure is not, or one bad minute would
* leave the page unable to explain itself for the next ten.
*/
suspend fun getRelated(item: BaseItem, limit: Int = RELATED_LIMIT): RelatedContent {
if (item.id.isBlank()) return RelatedContent()
val now = System.currentTimeMillis()
relatedMutex.withLock {
val inFlight = relatedMutex.withLock {
relatedCache[item.id]
?.takeIf { it.expiresAtMs > now }
?.let { return it.content }
relatedCache.remove(item.id)
relatedInFlight[item.id] ?: newRelatedRequest(item, limit)
}
return inFlight.await() ?: RelatedContent()
}
val loaded = runCatching {
if (ServerConfig.isGateway) {
val response = requireGateway().related(item.id)
RelatedContent(
reasons = response.reasons.filter(String::isNotBlank),
items = response.items.filter { it.id != item.id },
)
} else {
val userId = snapshot.userId ?: error("Not connected")
RelatedContent(
items = requireApi().getSimilar(
itemId = item.id,
params = mapOf(
"UserId" to userId,
"Limit" to limit.toString(),
"Fields" to "ProductionYear,RunTimeTicks,CommunityRating,PrimaryImageAspectRatio,CollectionName",
"EnableUserData" to "true",
"EnableImages" to "true",
"EnableImageTypes" to "Primary,Backdrop,Logo",
"ImageTypeLimit" to "1",
),
).items.filter { it.id != item.id },
)
}
}.getOrElse { RelatedContent() }
relatedMutex.withLock {
relatedCache[item.id] = CachedRelated(
content = loaded.copy(items = loaded.items.take(limit)),
expiresAtMs = now + RELATED_CACHE_TTL_MS,
)
while (relatedCache.size > RELATED_CACHE_SIZE) {
relatedCache.entries.iterator().run {
next()
remove()
private fun newRelatedRequest(item: BaseItem, limit: Int): Deferred<RelatedContent?> {
val request = scope.async(start = CoroutineStart.LAZY) {
try {
val loaded = runCatching { loadRelated(item, limit) }.getOrNull()
?: return@async null
relatedMutex.withLock {
relatedCache[item.id] = CachedRelated(
content = loaded.copy(items = loaded.items.take(limit)),
expiresAtMs = System.currentTimeMillis() + RELATED_CACHE_TTL_MS,
)
while (relatedCache.size > RELATED_CACHE_SIZE) {
relatedCache.entries.iterator().run {
next()
remove()
}
}
relatedCache.getValue(item.id).content
}
} finally {
relatedMutex.withLock { relatedInFlight.remove(item.id) }
}
return relatedCache.getValue(item.id).content
}
relatedInFlight[item.id] = request
request.start()
return request
}
private suspend fun loadRelated(item: BaseItem, limit: Int): RelatedContent {
if (ServerConfig.isGateway) {
val response = requireGateway().related(item.id)
return RelatedContent(
reasons = response.reasons.filter(String::isNotBlank),
items = response.items.filter { it.id != item.id },
)
}
val userId = snapshot.userId ?: error("Not connected")
return RelatedContent(
items = requireApi().getSimilar(
itemId = item.id,
params = mapOf(
"UserId" to userId,
"Limit" to limit.toString(),
"Fields" to "ProductionYear,RunTimeTicks,CommunityRating,PrimaryImageAspectRatio,CollectionName",
"EnableUserData" to "true",
"EnableImages" to "true",
"EnableImageTypes" to "Primary,Backdrop,Logo",
"ImageTypeLimit" to "1",
),
).items.filter { it.id != item.id },
)
}
/**
@@ -735,7 +940,7 @@ class EmbyRepository(private val settings: SettingsStore) {
seriesId,
mapOf(
"UserId" to userId,
"Fields" to "Overview,RunTimeTicks,SeriesName,PrimaryImageAspectRatio",
"Fields" to "Overview,RunTimeTicks,SeriesName,PremiereDate,PrimaryImageAspectRatio",
"EnableUserData" to "true",
"EnableImages" to "true",
"EnableImageTypes" to "Primary,Thumb,Backdrop",
@@ -873,6 +1078,7 @@ class EmbyRepository(private val settings: SettingsStore) {
*/
suspend fun resolvePlayable(item: BaseItem): Playable {
require(item.membyPlayable) { "This item is informational and cannot be played" }
val playback = playbackRequest(item)
val now = System.currentTimeMillis()
val (cached, request) = playableMutex.withLock {
val ready = playableCache[item.id]?.takeIf { it.expiresAtMs > now }?.playable
@@ -880,12 +1086,81 @@ class EmbyRepository(private val settings: SettingsStore) {
ready to null
} else {
playableCache.remove(item.id)
null to (playableInFlight[item.id] ?: newPlayableRequest(item))
null to (playableInFlight[item.id] ?: newPlayableRequest(playback))
}
}
return cached ?: requireNotNull(request).await()
}
/**
* The launch-time description of [item] — what the launcher already knows, in the form
* the player and [resolvePlayableForLaunch] both take. Built here because the artwork
* and episode-code helpers it needs are the repository's.
*/
fun playbackRequest(item: BaseItem): PlaybackRequest = PlaybackRequest(
itemId = item.id,
itemType = item.type,
title = item.name,
isSeries = item.isSeries,
resumePositionMs = item.resumePositionMs,
logoUrl = logoUrl(item),
overview = item.overview,
episodeCode = episodeCode(item),
runtimeMs = item.runTimeTicks?.div(10_000L)?.coerceAtLeast(0L) ?: 0L,
)
/**
* Returns a prefetched stream if one is sitting ready for [itemId], without suspending
* and without starting a request. This is what lets the launcher tell instantly whether
* pressing Play can hand the player a URL or must let it resolve one for itself, so
* that decision never costs a frame of its own.
*/
fun readyPlayableForLaunch(itemId: String): Playable? {
// tryLock rather than a blocking wait, and failing to take it is a legitimate
// answer: the lock is held exactly when a resolution is in flight, and the caller's
// fallback — letting the player await it — is what should happen then anyway.
if (!playableMutex.tryLock()) return null
try {
val entry = playableCache[itemId] ?: return null
if (!isFreshPlayablePrefetch(entry.resolvedAtMs, System.currentTimeMillis())) return null
// Consumed: negotiated session state must never be handed out twice.
playableCache.remove(itemId)
return entry.playable
} finally {
playableMutex.unlock()
}
}
/**
* Resolves playback for an actual Play action. A prefetched PlaybackInfo response owns
* negotiated session state, so it is safe only for the brief focus-to-click interval
* it was created to cover. Consume that result once; an older or already-consumed
* prefetch is replaced with a fresh server request.
*/
suspend fun resolvePlayableForLaunch(item: BaseItem): Playable {
require(item.membyPlayable) { "This item is informational and cannot be played" }
return resolvePlayableForLaunch(playbackRequest(item))
}
suspend fun resolvePlayableForLaunch(request: PlaybackRequest): Playable {
require(request.itemId.isNotBlank()) { "A media item is required to start playback" }
val now = System.currentTimeMillis()
val (cached, inFlight) = playableMutex.withLock {
val entry = playableCache.remove(request.itemId)
val ready = entry
?.takeIf { isFreshPlayablePrefetch(it.resolvedAtMs, now) }
?.playable
ready to if (ready == null) playableInFlight[request.itemId] else null
}
if (cached != null) return cached
if (inFlight != null) {
return inFlight.await().also {
playableMutex.withLock { playableCache.remove(request.itemId) }
}
}
return resolvePlayableUncached(request)
}
/** Resolves the likely stream after focus settles, without opening or buffering it. */
suspend fun prefetchPlayable(item: BaseItem) {
if (!item.membyPlayable) return
@@ -915,6 +1190,8 @@ class EmbyRepository(private val settings: SettingsStore) {
url = discovery.url ?: buildStreamUrl(itemId),
resumePositionMs = resumePositionMs.coerceAtLeast(0L),
subtitles = discovery.subtitles,
subtitlesEnabled = snapshot.subtitlesEnabled,
selectedSubtitleId = selectedSubtitleId(discovery.subtitles),
mediaSourceId = discovery.mediaSourceId,
playSessionId = discovery.playSessionId,
playMethod = discovery.playMethod,
@@ -936,9 +1213,12 @@ class EmbyRepository(private val settings: SettingsStore) {
// up to one progress interval behind and would visibly jump the viewer back.
resumePositionMs = resumePositionMs.coerceAtLeast(0L),
subtitles = playback.subtitles,
subtitlesEnabled = playback.subtitlesEnabled,
selectedSubtitleId = playback.selectedSubtitleId,
mediaSourceId = playback.mediaSourceId,
playSessionId = playback.playSessionId,
playMethod = playback.playMethod,
subtitleDownloadAvailable = playback.subtitleDownloadAvailable,
)
}
@@ -962,12 +1242,15 @@ class EmbyRepository(private val settings: SettingsStore) {
url = playback.url,
resumePositionMs = positionMs.coerceAtLeast(0L),
subtitles = playback.subtitles,
subtitlesEnabled = playback.subtitlesEnabled,
selectedSubtitleId = playback.selectedSubtitleId,
mediaSourceId = playback.mediaSourceId,
playSessionId = playback.playSessionId,
playMethod = playback.playMethod,
overview = playback.overview.ifBlank { null },
episodeCode = playback.episodeCode.ifBlank { null },
runtimeMs = playback.runtimeMs,
subtitleDownloadAvailable = playback.subtitleDownloadAvailable,
)
}
val discovery = directPlayback(
@@ -982,19 +1265,24 @@ class EmbyRepository(private val settings: SettingsStore) {
url = discovery.url ?: buildStreamUrl(session.itemId),
resumePositionMs = positionMs.coerceAtLeast(0L),
subtitles = discovery.subtitles,
// The viewer just chose this track by hand and Emby is burning it into the
// stream, so there is no text track left for the player to select.
subtitlesEnabled = true,
selectedSubtitleId = subtitleIndex.toString(),
mediaSourceId = discovery.mediaSourceId,
playSessionId = discovery.playSessionId,
playMethod = discovery.playMethod,
)
}
private fun newPlayableRequest(item: BaseItem): Deferred<Playable> {
private fun newPlayableRequest(item: PlaybackRequest): Deferred<Playable> {
val request = scope.async(start = CoroutineStart.LAZY) {
try {
resolvePlayableUncached(item).also { playable ->
playableMutex.withLock {
playableCache[item.id] = CachedPlayable(
playableCache[item.itemId] = CachedPlayable(
playable = playable,
resolvedAtMs = System.currentTimeMillis(),
expiresAtMs = System.currentTimeMillis() + PLAYABLE_CACHE_TTL_MS,
)
while (playableCache.size > PLAYABLE_CACHE_SIZE) {
@@ -1006,47 +1294,49 @@ class EmbyRepository(private val settings: SettingsStore) {
}
}
} finally {
playableMutex.withLock { playableInFlight.remove(item.id) }
playableMutex.withLock { playableInFlight.remove(item.itemId) }
}
}
playableInFlight[item.id] = request
playableInFlight[item.itemId] = request
request.start()
return request
}
private suspend fun resolvePlayableUncached(item: BaseItem): Playable {
private suspend fun resolvePlayableUncached(item: PlaybackRequest): Playable {
if (ServerConfig.isGateway) {
// Episode selection for a series is the gateway's job now.
val playback = requireGateway().playback(
itemId = item.id,
itemType = item.type,
title = item.name,
itemId = item.itemId,
itemType = item.itemType,
title = item.title,
resumePositionMs = item.resumePositionMs,
)
return Playable(
itemId = playback.itemId,
title = playback.title.ifBlank { item.name },
title = playback.title.ifBlank { item.title },
url = playback.url,
resumePositionMs = playback.resumePositionMs,
logoUrl = logoUrl(item),
logoUrl = item.logoUrl,
subtitles = playback.subtitles,
subtitlesEnabled = playback.subtitlesEnabled,
selectedSubtitleId = playback.selectedSubtitleId,
mediaSourceId = playback.mediaSourceId,
playSessionId = playback.playSessionId,
playMethod = playback.playMethod,
overview = playback.overview.ifBlank { item.overview },
episodeCode = playback.episodeCode.ifBlank { episodeCode(item) },
runtimeMs = playback.runtimeMs.takeIf { it > 0L }
?: item.runTimeTicks?.div(10_000L)?.coerceAtLeast(0L) ?: 0L,
episodeCode = playback.episodeCode.ifBlank { item.episodeCode },
runtimeMs = playback.runtimeMs.takeIf { it > 0L } ?: item.runtimeMs,
prerollEnabled = playback.prerollEnabled,
prerollDurationMs = playback.prerollDurationMs,
subtitleDownloadAvailable = playback.subtitleDownloadAvailable,
)
}
if (item.isSeries) {
val userId = snapshot.userId ?: error("Not connected")
val episode = firstNextUpEpisode(userId, item.id) ?: firstEpisode(userId, item.id)
requireNotNull(episode) { "No episodes found for ${item.name}" }
val episode = firstNextUpEpisode(userId, item.itemId) ?: firstEpisode(userId, item.itemId)
requireNotNull(episode) { "No episodes found for ${item.title}" }
val title = buildString {
append(item.name)
append(item.title)
episode.name.takeIf { it.isNotBlank() }?.let { append(" $it") }
}
val discovery = directPlayback(episode.id, episode.resumePositionMs)
@@ -1055,8 +1345,10 @@ class EmbyRepository(private val settings: SettingsStore) {
title = title,
url = discovery.url ?: buildStreamUrl(episode.id),
resumePositionMs = episode.resumePositionMs,
logoUrl = logoUrl(item),
logoUrl = item.logoUrl,
subtitles = discovery.subtitles,
subtitlesEnabled = snapshot.subtitlesEnabled,
selectedSubtitleId = selectedSubtitleId(discovery.subtitles),
mediaSourceId = discovery.mediaSourceId,
playSessionId = discovery.playSessionId,
playMethod = discovery.playMethod,
@@ -1065,23 +1357,28 @@ class EmbyRepository(private val settings: SettingsStore) {
runtimeMs = episode.runTimeTicks?.div(10_000L)?.coerceAtLeast(0L) ?: 0L,
)
}
val discovery = directPlayback(item.id, item.resumePositionMs)
val discovery = directPlayback(item.itemId, item.resumePositionMs)
return Playable(
itemId = item.id,
title = item.name,
url = discovery.url ?: buildStreamUrl(item.id),
itemId = item.itemId,
title = item.title,
url = discovery.url ?: buildStreamUrl(item.itemId),
resumePositionMs = item.resumePositionMs,
logoUrl = logoUrl(item),
logoUrl = item.logoUrl,
subtitles = discovery.subtitles,
subtitlesEnabled = snapshot.subtitlesEnabled,
selectedSubtitleId = selectedSubtitleId(discovery.subtitles),
mediaSourceId = discovery.mediaSourceId,
playSessionId = discovery.playSessionId,
playMethod = discovery.playMethod,
overview = item.overview,
episodeCode = episodeCode(item),
runtimeMs = item.runTimeTicks?.div(10_000L)?.coerceAtLeast(0L) ?: 0L,
episodeCode = item.episodeCode,
runtimeMs = item.runtimeMs,
)
}
/** Drop negotiated playback state when a catalogue refresh can change episode selection. */
suspend fun invalidatePlaybackPrefetch() = clearPlayableCache()
private suspend fun clearPlayableCache() {
playableMutex.withLock {
playableCache.clear()
@@ -1095,8 +1392,11 @@ class EmbyRepository(private val settings: SettingsStore) {
seriesEpisodesCache.clear()
}
relatedMutex.withLock {
// Reasons are personal: another profile must never inherit this one's.
// Reasons are personal: another profile must never inherit this one's, and a
// request already in the air was made with the outgoing profile's session.
relatedCache.clear()
relatedInFlight.values.forEach { it.cancel() }
relatedInFlight.clear()
}
}
@@ -1161,10 +1461,60 @@ class EmbyRepository(private val settings: SettingsStore) {
}.getOrNull()
}
/**
* Ask the gateway for subtitles this title does not have.
*
* Gateway-only, and deliberately so: fetching a subtitle means talking to Bazarr, which
* means holding its API key, and a television is the wrong place for that. With no
* gateway the player simply never offers the option, which is what
* [Playable.subtitleDownloadAvailable] already tells it.
*
* A failure comes back as an empty result with a sentence saying so rather than as an
* exception. This is a live query against subtitle providers — slow, and allowed to
* find nothing — so "it did not work" is an ordinary answer the drop-up has to render
* either way, and there is nothing useful for a caller to do with a throw.
*/
suspend fun searchSubtitles(itemId: String, language: String? = null): SubtitleSearch {
if (itemId.isBlank() || !ServerConfig.isGateway) {
return SubtitleSearch(emptyList(), "Subtitle downloads are not available.")
}
return runCatching {
val response = requireGateway().searchSubtitles(itemId, language?.takeIf { it.isNotBlank() })
SubtitleSearch(response.results, response.message)
}.getOrElse { error -> SubtitleSearch(emptyList(), friendlyEmbyError(error)) }
}
/**
* Fetch one of those subtitles. Success carries the item's tracks re-read from Emby
* after it was told to look again, so the player can put the new track on without a
* second round trip; null means it did not arrive.
*/
suspend fun downloadSubtitle(
itemId: String,
candidate: GatewaySubtitleCandidate,
): SubtitleDownload? {
if (itemId.isBlank() || !ServerConfig.isGateway || candidate.token.isBlank()) return null
return runCatching {
val response = requireGateway().downloadSubtitle(
itemId,
GatewaySubtitleDownloadRequest(candidate),
)
SubtitleDownload(
message = response.message,
subtitles = response.subtitles,
selectedSubtitleId = response.selectedSubtitleId,
mediaSourceId = response.mediaSourceId,
playSessionId = response.playSessionId,
url = response.url,
)
}.getOrNull()
}
private suspend fun gatewayNextEpisode(itemId: String, seriesId: String?): NextEpisode {
val response = requireGateway().nextEpisode(itemId, seriesId.orEmpty())
return nextEpisodeOf(
response.item, response.url, response.resumePositionMs, response.subtitles,
response.subtitlesEnabled, response.selectedSubtitleId,
response.mediaSourceId, response.playSessionId, response.playMethod,
)
}
@@ -1191,6 +1541,7 @@ class EmbyRepository(private val settings: SettingsStore) {
val discovery = directPlayback(next.id, next.resumePositionMs)
return nextEpisodeOf(
next, discovery.url ?: buildStreamUrl(next.id), next.resumePositionMs, discovery.subtitles,
snapshot.subtitlesEnabled, selectedSubtitleId(discovery.subtitles),
discovery.mediaSourceId, discovery.playSessionId, discovery.playMethod,
)
}
@@ -1200,6 +1551,8 @@ class EmbyRepository(private val settings: SettingsStore) {
url: String,
resumePositionMs: Long,
subtitles: List<PlayableSubtitle>,
subtitlesEnabled: Boolean,
selectedSubtitleId: String,
mediaSourceId: String,
playSessionId: String,
playMethod: String,
@@ -1212,11 +1565,29 @@ class EmbyRepository(private val settings: SettingsStore) {
url = url,
resumePositionMs = resumePositionMs,
subtitles = subtitles,
subtitlesEnabled = subtitlesEnabled,
selectedSubtitleId = selectedSubtitleId,
mediaSourceId = mediaSourceId,
playSessionId = playSessionId,
playMethod = playMethod,
)
/**
* The track this viewer's settings choose out of [subtitles], on the direct path.
*
* In gateway mode the server answers this - it is the thing that asks Emby what tracks
* exist, and answering it there is what lets one decision follow a person between
* televisions. With no gateway there is nobody to ask, so the same rule
* ([selectSubtitleId]) runs here against the same synced settings, and a viewer gets
* the same subtitles either way.
*/
private fun selectedSubtitleId(subtitles: List<PlayableSubtitle>): String =
selectSubtitleId(
candidates = subtitles.map(PlayableSubtitle::asCandidate),
enabled = snapshot.subtitlesEnabled,
language = snapshot.subtitleLanguage,
).orEmpty()
private suspend fun directPlayback(
itemId: String,
positionMs: Long,
@@ -1513,6 +1884,7 @@ private fun episodeCode(item: BaseItem): String? {
private data class CachedPlayable(
val playable: Playable,
val resolvedAtMs: Long,
val expiresAtMs: Long,
)
@@ -1541,6 +1913,13 @@ private data class CachedRelated(
private const val PLAYABLE_CACHE_SIZE = 16
private const val PLAYABLE_CACHE_TTL_MS = 5L * 60L * 1_000L
internal const val PLAYABLE_PREFETCH_MAX_AGE_MS = 15_000L
// How far back to look for the play that places a Next Up episode in Continue Watching.
// This is a household's recent viewing, not its history: a series nobody has touched in
// this many plays is not competing for the front of the row anyway. Mirrors the gateway's
// continuePlayLookback.
private const val CONTINUE_PLAY_LOOKBACK = 120
private const val SERIES_EPISODE_CACHE_SIZE = 6
private const val SERIES_EPISODE_CACHE_TTL_MS = 5L * 60L * 1_000L
private const val RELATED_CACHE_SIZE = 12
@@ -1553,6 +1932,9 @@ private const val RELATED_CACHE_TTL_MS = 10L * 60L * 1_000L
internal fun millisecondsToTicks(milliseconds: Long): Long =
milliseconds.coerceAtLeast(0L) * 10_000L
internal fun isFreshPlayablePrefetch(resolvedAtMs: Long, nowMs: Long): Boolean =
resolvedAtMs <= nowMs && nowMs - resolvedAtMs <= PLAYABLE_PREFETCH_MAX_AGE_MS
private val BaseItem.resumePositionMs: Long
get() = ((userData?.playbackPositionTicks ?: 0L) / 10_000L).coerceAtLeast(0L)
@@ -1596,6 +1978,19 @@ internal fun parseMaintenanceMessage(body: String): String? {
}.getOrNull()
}
/**
* The server's copy of a viewer's settings, carried in the body of a 409 so adopting it
* costs no second request. Returns null if the body is not one, in which case the caller
* lets the original failure stand rather than inventing a document.
*/
private fun parsePreferencesConflict(
conflict: HttpException,
): com.ponzischeme89.memby.data.model.GatewayPreferences? = runCatching {
val body = conflict.response()?.errorBody()?.string().orEmpty()
if (body.isBlank()) return@runCatching null
errorBodyJson.decodeFromString<com.ponzischeme89.memby.data.model.GatewayPreferences>(body)
}.getOrNull()
/** True when this failure is the gateway reporting a deliberate outage. */
fun isMaintenanceError(t: Throwable): Boolean = t is HttpException && t.code() == 503
@@ -0,0 +1,40 @@
package com.ponzischeme89.memby.data
/**
* Local-day arithmetic, shared by everything that has to answer "what day is it here?".
*
* It lived in `HomeMovieHero` while the daily hero rotation was the only caller; the series
* pace estimate counts in local days for the same reason and must not carry a second copy.
*/
internal const val DAY_MS = 24L * 60L * 60L * 1000L
/**
* The day the television is living in, counted in local days since the epoch.
*
* Local rather than UTC, because "resets at midnight" means the viewer's midnight and not
* Greenwich's. The zone offset is a parameter rather than something read in here so the
* arithmetic stays pure and a test can put itself either side of the date line.
*/
internal fun localEpochDay(nowMs: Long, zoneOffsetMs: Int): Long =
floorDiv(nowMs + zoneOffsetMs, DAY_MS)
/**
* How long until the local day rolls over. Never zero and never negative: this drives a
* timer, and a zero would spin it. Exactly at midnight the answer is a whole day, because
* the day that has just begun is not the one being waited for.
*/
internal fun millisUntilNextLocalDay(nowMs: Long, zoneOffsetMs: Int): Long {
val local = nowMs + zoneOffsetMs
return DAY_MS - (local - floorDiv(local, DAY_MS) * DAY_MS)
}
// Math.floorDiv/floorMod for longs arrived in API 24 and this app still ships to 23, so
// the two lines they would have saved are written out instead.
internal fun floorDiv(value: Long, divisor: Long): Long {
val quotient = value / divisor
return if (value % divisor != 0L && (value xor divisor) < 0L) quotient - 1L else quotient
}
internal fun floorMod(value: Long, divisor: Long): Long =
value - floorDiv(value, divisor) * divisor
@@ -19,6 +19,22 @@ import kotlinx.coroutines.launch
data class MaintenanceNotice(val message: String)
data class CompatibilityNotice(val message: String)
/**
* Emby is not answering, and something is still trying.
*
* Distinct from [MaintenanceNotice], which is Memby being taken down deliberately: this is
* the media server behind it going quiet. It matters to the viewer for one specific
* reason — video direct-plays from Emby, so during an outage a film stops with no
* explanation at all, while Memby itself stays up and can say why.
*
* [nextAttemptAtMillis] is elapsed-realtime, not wall clock, so the countdown cannot be
* thrown by a TV correcting its clock over the network mid-outage.
*/
data class EmbyOutage(
val nextAttemptAtMillis: Long,
val retryIntervalSeconds: Int,
)
/**
* One informational banner: a show aired, a film was added, the library finished
* refreshing, the server stopped answering. It is never actionable and never focusable —
@@ -66,6 +82,30 @@ class MaintenanceMonitor(
private val _alert = MutableStateFlow<ServiceAlert?>(null)
val alert: StateFlow<ServiceAlert?> = _alert.asStateFlow()
private val _embyOutage = MutableStateFlow<EmbyOutage?>(null)
/** Null whenever Emby is answering, or when nothing is watching it. */
val embyOutage: StateFlow<EmbyOutage?> = _embyOutage.asStateFlow()
private val _preferencesRevision = MutableStateFlow(0L)
private val _installPermissionPrompt = MutableStateFlow(false)
/**
* The viewer's server-held settings revision, as of the last successful poll. This is
* how an operator's push reaches a television: the number changes, [PreferencesSync]
* notices and fetches the document. Carrying the revision rather than the settings
* themselves keeps a poll that runs every ten seconds on every open TV to one integer.
*/
val preferencesRevision: StateFlow<Long> = _preferencesRevision.asStateFlow()
/**
* Whether the operator wants TVs that cannot install their own updates to be asked for
* the permission. Pushed through the same poll as everything else, and false whenever
* the server has not said otherwise — a screen that appears because a field was missing
* would be the wrong way round.
*/
val installPermissionPrompt: StateFlow<Boolean> = _installPermissionPrompt.asStateFlow()
private val seenAlertIds = mutableSetOf<String>()
private var seenAlertsLoaded = false
private var shownAlertId: String? = null
@@ -73,6 +113,7 @@ class MaintenanceMonitor(
init {
scope.launchStatusLoop()
scope.launchDirectEmbyProbe()
}
/**
@@ -114,6 +155,11 @@ class MaintenanceMonitor(
if (!ServerConfig.isGateway || !session.isSignedIn) {
_notice.value = null
_compatibility.value = null
// The direct-path probe owns the outage bar when there is no gateway;
// clearing it here would fight that loop for the same flow.
if (ServerConfig.isGateway) _embyOutage.value = null
_preferencesRevision.value = 0
_installPermissionPrompt.value = false
dismissAlert()
return@collectLatest
}
@@ -139,6 +185,13 @@ class MaintenanceMonitor(
} else {
null
}
_preferencesRevision.value = status.preferencesRevision
_installPermissionPrompt.value =
status.features[INSTALL_PERMISSION_FEATURE] == true
// Emby's state is reported even during maintenance: an
// operator taking Memby down while Emby is also unreachable
// should not have that fact disappear from the poll.
_embyOutage.value = outageFrom(status.emby)
if (status.maintenance) {
// The maintenance screen owns the display; anything
// cheerful in front of it would only be confusing.
@@ -152,6 +205,9 @@ class MaintenanceMonitor(
repository.invalidateSession()
_notice.value = null
_compatibility.value = null
_embyOutage.value = null
_preferencesRevision.value = 0
_installPermissionPrompt.value = false
dismissAlert()
return@collectLatest
}
@@ -162,6 +218,63 @@ class MaintenanceMonitor(
}
}
/**
* Turns the gateway's reading into a countdown this TV can render.
*
* The next attempt is computed from *this device's* elapsed clock rather than from the
* server's timestamps, because the two are not the same clock and the difference would
* show up as a countdown that jumps. `checkedAt` is deliberately unused for that
* reason: it says when the probe ran on the gateway, which is only a lower bound on
* when this poll heard about it.
*/
private fun outageFrom(health: com.ponzischeme89.memby.data.model.GatewayEmbyHealth): EmbyOutage? =
nextOutageState(health, _embyOutage.value, elapsedRealtime())
/**
* The direct path's own probe. With no gateway there is nobody to ask, so the app asks
* Emby itself — on the same minute the gateway would have used, so the bar reads
* identically either way.
*
* It runs only while signed in and only on the direct path, and only in the
* foreground: a background loop hitting a server every minute for a screen nobody is
* looking at is exactly what the status poll already refuses to do.
*/
private fun CoroutineScope.launchDirectEmbyProbe() = launch {
if (ServerConfig.isGateway) return@launch
ProcessLifecycleOwner.get().lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
repository.settingsFlow.collectLatest { session ->
if (!session.isSignedIn) {
_embyOutage.value = null
return@collectLatest
}
var failures = 0
while (isActive) {
if (repository.pingEmby()) {
failures = 0
_embyOutage.value = null
} else {
failures++
// Same threshold the gateway applies, and for the same reason:
// one failed request is a hiccup, and a red bar for it is worse
// than a moment of silence. Once past it the countdown is re-armed
// on every attempt, because the next attempt really is a minute
// from now.
if (failures >= DIRECT_OUTAGE_THRESHOLD) {
_embyOutage.value = EmbyOutage(
nextAttemptAtMillis =
elapsedRealtime() + DEFAULT_RETRY_SECONDS * 1000L,
retryIntervalSeconds = DEFAULT_RETRY_SECONDS,
)
}
}
delay(DEFAULT_RETRY_SECONDS * 1000L)
}
}
}
}
private fun elapsedRealtime(): Long = android.os.SystemClock.elapsedRealtime()
private suspend fun offerNextAlert(alerts: List<GatewayAlert>) {
if (!seenAlertsLoaded) {
seenAlertIds += runCatching { settings.seenAlertIds() }.getOrDefault(emptySet())
@@ -192,6 +305,19 @@ class MaintenanceMonitor(
companion object {
internal const val POLL_INTERVAL_MS = 10_000L
/** Matches `featureInstallPermission` in the gateway's feature catalogue. */
internal const val INSTALL_PERMISSION_FEATURE = "install_permission_prompt"
/**
* How often Emby is retried during an outage. It matches the gateway's own
* default (MEMBY_EMBY_HEALTH_INTERVAL), so the countdown on the bar means the
* same thing whether the probe is running here or there.
*/
internal const val DEFAULT_RETRY_SECONDS = 60
/** Consecutive direct-path failures before the bar appears. */
internal const val DIRECT_OUTAGE_THRESHOLD = 2
/**
* How long one banner stays on screen. The banner draws a ring counting this
* down, so the two must agree — take the duration from here rather than
@@ -201,6 +327,33 @@ class MaintenanceMonitor(
}
}
/**
* The outage state to publish after a status poll, given the one already showing.
*
* The rule worth keeping is that an outage already on screen keeps its countdown until it
* actually runs out. The status poll is six times faster than the retry it is describing,
* so recomputing the deadline every time would reset the number every ten seconds and the
* viewer would watch a counter that never reaches zero.
*
* Pure, and takes [nowMillis] as a parameter, so the two properties that matter — a live
* countdown survives a poll, an expired one is re-armed — can be tested without waiting a
* minute for each.
*/
internal fun nextOutageState(
health: com.ponzischeme89.memby.data.model.GatewayEmbyHealth,
existing: EmbyOutage?,
nowMillis: Long,
): EmbyOutage? {
if (!health.isOutage) return null
if (existing != null && existing.nextAttemptAtMillis > nowMillis) return existing
val retrySeconds = health.retrySeconds.takeIf { it > 0 }
?: MaintenanceMonitor.DEFAULT_RETRY_SECONDS
return EmbyOutage(
nextAttemptAtMillis = nowMillis + retrySeconds * 1000L,
retryIntervalSeconds = retrySeconds,
)
}
/**
* Whether an alert held for a screen that never appeared should be given up on.
*
@@ -0,0 +1,155 @@
package com.ponzischeme89.memby.data
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.ProcessLifecycleOwner
import androidx.lifecycle.repeatOnLifecycle
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
/**
* Keeps this television's settings and the viewer's server-held document the same thing.
*
* Three events move settings, and all three go through here:
*
* - **The viewer changes something on this TV.** The local write already happened —
* nothing in the UI waits on the network — and the new document is pushed up after it.
* - **The viewer changes something on another TV, or an operator pushes from the admin
* console.** The revision on the status poll goes up, this notices and pulls.
* - **A profile signs in somewhere for the first time.** The server's copy wins if it has
* one; otherwise this TV's settings become the starting document.
*
* The failure mode being designed against is a *loop*: a pull that looks like a local
* change and triggers a push, which bumps the revision, which looks like a remote change.
* [lastSynced] is what breaks it — it records the exact document both ends agreed on, and
* a push happens only when the local one differs from it. Adopting a pull sets it, so the
* write that follows never looks like news.
*
* Everything here fails silently and retries on the next event. Settings are not worth an
* error message on a television, and the state they are in — local values intact, server
* copy intact — is a correct one to be left in until the next poll.
*/
class PreferencesSync(
private val repository: EmbyRepository,
private val settings: SettingsStore,
private val remoteRevision: StateFlow<Long>,
private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO),
) {
/**
* The document last known to be identical on both ends, per profile. A local state
* matching this is not a change and must not be pushed.
*/
private var lastSynced: Pair<String, UserPreferences>? = null
/** One sync at a time: a pull and a push racing would decide the revision twice. */
private val mutex = Mutex()
init {
scope.launch { run() }
}
private suspend fun run() {
// Nothing to sync for a TV nobody is looking at, and the status poll this rides
// beside stops in the background too, so the revision would go stale anyway.
ProcessLifecycleOwner.get().lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
combine(
repository.settingsFlow,
remoteRevision,
) { session, revision -> session to revision }
// The launcher rewrites the home cache constantly and it is part of
// Settings; without this, every refresh would look like a settings change
// and be considered for a push.
.map { (session, revision) -> SyncTrigger(session, revision) }
.distinctUntilChanged()
// Plain collect, not collectLatest: a reconcile that is cancelled halfway
// could leave [lastSynced] describing a document that was never written.
// These are short and serialised by the mutex, so waiting is cheaper than
// reasoning about a half-applied sync.
.collect(::reconcile)
}
}
/** The only parts of the session a sync depends on. */
private data class SyncTrigger(
val profileKey: String,
val signedIn: Boolean,
val localRevision: Long,
val remoteRevision: Long,
val local: UserPreferences,
) {
constructor(session: Settings, remoteRevision: Long) : this(
profileKey = "${session.userId.orEmpty()}@${session.serverUrl.orEmpty()}",
signedIn = session.isSignedIn,
localRevision = session.preferencesRevision,
remoteRevision = remoteRevision,
local = session.toUserPreferences(),
)
}
private suspend fun reconcile(trigger: SyncTrigger) {
if (!ServerConfig.isGateway || !trigger.signedIn) return
mutex.withLock {
val agreed = lastSynced?.takeIf { it.first == trigger.profileKey }?.second
// Never synced on this TV, or the server has moved on without us. Pull first:
// the server's copy is the shared truth, and a push here would overwrite a
// change made on another television with this one's defaults.
if (agreed == null || trigger.remoteRevision > trigger.localRevision) {
if (pull(trigger)) return
}
// Only a genuine local edit gets pushed. Anything else is either the document
// just adopted or an unrelated part of Settings changing.
val current = lastSynced?.takeIf { it.first == trigger.profileKey }?.second
if (current != null && current != trigger.local) push(trigger)
}
}
/**
* Takes the server's copy. Returns true when the local document was replaced, so the
* caller knows not to treat the write it just made as a local edit.
*/
private suspend fun pull(trigger: SyncTrigger): Boolean {
val remote = runCatching { repository.userPreferences() }.getOrNull() ?: return false
// Revision 0 means the server has never been told anything about this viewer. The
// settings already on this television are then the best starting point there is,
// so they are pushed up rather than replaced by catalogue defaults.
if (remote.revision == 0L) {
lastSynced = trigger.profileKey to UserPreferences()
push(trigger)
return true
}
val decoded = decodeUserPreferences(remote.preferences, fallback = trigger.local)
lastSynced = trigger.profileKey to decoded
// Written even when the values match, because the revision must advance: without
// it the next poll sees the same gap and pulls again, every ten seconds, forever.
runCatching { settings.applyRemotePreferences(decoded, remote.revision) }
.onFailure { lastSynced = null }
return true
}
private suspend fun push(trigger: SyncTrigger) {
val stored = runCatching {
repository.saveUserPreferences(trigger.localRevision, trigger.local.encode())
}.getOrNull() ?: return
// A 409 comes back here as an ordinary result carrying somebody else's document —
// an operator's push, nearly always. Adopting it is the correct outcome: this TV
// was editing a revision that no longer exists, and retrying would revert them.
val decoded = decodeUserPreferences(stored.preferences, fallback = trigger.local)
lastSynced = trigger.profileKey to decoded
if (decoded == trigger.local) {
runCatching { settings.setPreferencesRevision(stored.revision) }
} else {
runCatching { settings.applyRemotePreferences(decoded, stored.revision) }
.onFailure { lastSynced = null }
}
}
}
@@ -0,0 +1,21 @@
package com.ponzischeme89.memby.data
/**
* How far one press of Left or Right moves the film.
*
* The vocabulary lives beside the settings it is stored in rather than in the player,
* because three things read it — the store, the settings row and the player — and the
* gateway's own catalogue (`seekIntervalSeconds` in `internal/api/preferences.go`) holds
* the matching list. Keep the two in step: a value this build does not recognise is
* normalised to the default rather than honoured, so an operator pushing an interval a
* television has never heard of costs that set the default and never an unexplained skip.
*/
/** The intervals a viewer may choose between, in the order the settings row offers them. */
val SEEK_INTERVAL_SECONDS: List<Int> = listOf(10, 20, 30)
/** Ten seconds is the smallest offered, and the one a mis-press costs least to undo. */
const val DEFAULT_SEEK_INTERVAL_SECONDS: Int = 10
fun normalizeSeekIntervalSeconds(seconds: Int): Int =
if (seconds in SEEK_INTERVAL_SECONDS) seconds else DEFAULT_SEEK_INTERVAL_SECONDS
@@ -0,0 +1,258 @@
package com.ponzischeme89.memby.data
import com.ponzischeme89.memby.data.model.BaseItem
import kotlin.math.ceil
/**
* When a viewer is likely to finish the episodes of a series they already have.
*
* Everything here is derived from the episode list the detail page already holds — Emby's
* `UserData.Played` and `UserData.LastPlayedDate`, one per episode — so there is no new
* storage, nothing to invalidate, and no second implementation on the direct path. It
* recalculates for free: finishing an episode, marking one watched, a history sync from
* another Emby client and a newly imported episode all change that list and nothing else.
* Because the state is Emby's per-user data, it is per viewer and per series by
* construction, including for two people sharing one television.
*
* It is deliberately apart from the UI. [estimateSeriesPace] answers with numbers and
* [seriesPaceLabel] turns them into a sentence, so a "finish this weekend" row or a
* completion reminder can use the first without inheriting the second's wording.
*
* The honest answer is often *nothing*. Every guard below returns null rather than a
* confident-looking date: a viewer told they will finish on a day they will not is worse
* off than one told nothing at all.
*/
data class SeriesPaceEstimate(
/** Unwatched episodes the household actually holds. Never fewer than two. */
val remainingEpisodes: Int,
/** The measured rate over the recent window, in episodes per local day. */
val episodesPerDay: Double,
/** Local days from today until the finish. 0 is today, 1 tomorrow. */
val daysAway: Int,
/** The finish, in local days since the epoch — the form the calendar wording needs. */
val finishEpochDay: Long,
/**
* Whether the show is still being made. It changes the verb, because "finish" is a
* claim about a series and only "catch up" is true of one that is still running.
*/
val catchUp: Boolean,
)
// The most recent completions worth measuring. Old enough behaviour is not this viewer's
// current pace — somebody who took a year over season one and is now watching nightly is
// watching nightly.
private const val PACE_WINDOW_EPISODES = 10
private const val PACE_WINDOW_DAYS = 30L
/**
* A gap this long ends the window. Somebody returning to a show after a fortnight away is
* establishing a new pace, and averaging the silence in would predict a finish years out.
*/
private const val PACE_BREAK_DAYS = 14L
/** Nothing watched in this long and there is no current pace to project at all. */
private const val PACE_STALE_DAYS = 30L
private const val PACE_MIN_EPISODES = 3
/**
* Two completions are enough only when they are on separate days *and* close enough
* together to read as a rhythm rather than as two unrelated evenings.
*/
private const val PACE_PAIR_MAX_SPAN_DAYS = 7L
/** Beyond a year the arithmetic is still valid and the answer is still useless. */
private const val PACE_MAX_HORIZON_DAYS = 365L
/**
* The estimate, or null when there is nothing trustworthy to say.
*
* [episodes] is the whole series as the library holds it — which is what makes the answer
* "the episodes available to you", since Emby lists nothing it has not imported. [ongoing]
* says the show is still in production; see [SeriesPaceEstimate.catchUp].
*/
fun estimateSeriesPace(
episodes: List<BaseItem>,
nowMs: Long,
zoneOffsetMs: Int,
ongoing: Boolean = false,
): SeriesPaceEstimate? {
if (episodes.isEmpty()) return null
// Specials are bonus material, not the thing being caught up on: counting them would
// put a finish date beyond the last real episode for a viewer who is never going to
// watch the making-of. The same rule `nextEpisodeToWatch` follows — unless specials
// are all the library has, in which case they are the show.
val numbered = episodes.filter { (it.parentIndexNumber ?: 0) > 0 }
val considered = if (numbered.isNotEmpty()) numbered else episodes
// A part-watched episode is not a completion: it counts as remaining and contributes
// nothing to the pace, which is exactly what "has not crossed the threshold" means.
val remaining = considered.count { it.userData?.played != true }
// One episode left needs no date, and none left needs no estimate.
if (remaining <= 1) return null
val completions = considered
.filter { it.userData?.played == true }
.mapNotNull { playedAtMillis(it.userData?.lastPlayedDate) }
.sorted()
if (completions.isEmpty()) return null
val mostRecent = completions.last()
// A stamp in the future is a clock that has not been set, not a viewing habit.
if (mostRecent - nowMs > DAY_MS) return null
if (nowMs - mostRecent > PACE_STALE_DAYS * DAY_MS) return null
val recent = completions
.takeLast(PACE_WINDOW_EPISODES)
.filter { mostRecent - it <= PACE_WINDOW_DAYS * DAY_MS }
val window = sinceLastBreak(recent)
val days = window.map { localEpochDay(it, zoneOffsetMs) }
// Everything known happening on one day is one sitting, and a sitting has no daily
// rate: three episodes on a Sunday would read as three a day and promise a finish
// this week. This is the guard that stops the opening of a binge projecting one.
val distinctDays = days.distinct().size
if (distinctDays < 2) return null
val spanDays = days.last() - days.first() + 1
if (window.size < PACE_MIN_EPISODES && spanDays > PACE_PAIR_MAX_SPAN_DAYS) return null
// Episodes per day over the whole span, inclusive of both ends — the count of viewing
// days is what a rate is measured against, so a binge of three on Saturday and three
// on Sunday is three a day rather than six.
val perDay = window.size.toDouble() / spanDays.toDouble()
if (perDay <= 0.0) return null
val daysNeeded = ceil(remaining / perDay).toLong()
if (daysNeeded > PACE_MAX_HORIZON_DAYS) return null
// The first of those days is today: at one a day with three left, the third is two
// days from now, not three.
val daysAway = (daysNeeded - 1L).coerceAtLeast(0L)
return SeriesPaceEstimate(
remainingEpisodes = remaining,
episodesPerDay = perDay,
daysAway = daysAway.toInt(),
finishEpochDay = localEpochDay(nowMs, zoneOffsetMs) + daysAway,
catchUp = ongoing,
)
}
/**
* The trailing run of [completions] with no [PACE_BREAK_DAYS] gap in it.
*
* Walking back from the most recent rather than forward from the oldest is the point: what
* is wanted is the pace the viewer is on *now*, so the window ends at the present and stops
* at whatever break precedes it.
*/
private fun sinceLastBreak(completions: List<Long>): List<Long> {
if (completions.size <= 1) return completions
var start = completions.size - 1
while (start > 0 && completions[start] - completions[start - 1] < PACE_BREAK_DAYS * DAY_MS) {
start--
}
return completions.subList(start, completions.size)
}
/**
* The sentence the detail page shows, or null for an absent estimate.
*
* Near dates are named, because "18 August" is something a viewer can hold a weekend up
* against. Far ones are rounded to weeks or months instead: a pace measured over a
* fortnight cannot honestly pick a day four months out, and printing one anyway is false
* precision dressed as helpfulness.
*/
fun seriesPaceLabel(estimate: SeriesPaceEstimate?): String? {
if (estimate == null) return null
val verb = if (estimate.catchUp) "catch up" else "finish"
return when {
estimate.daysAway == 0 -> "You'll likely $verb today"
estimate.daysAway == 1 -> "You'll likely $verb tomorrow"
estimate.daysAway <= 30 -> {
val date = formatPaceDate(estimate.finishEpochDay)
if (estimate.catchUp) "You'll catch up around $date" else "Estimated finish: $date"
}
else -> {
val tail = if (estimate.catchUp) "to catch up" else "remaining"
"At your current pace: about ${roughDuration(estimate.daysAway)} $tail"
}
}
}
/** "6 weeks", "3 months" — the coarse end of the wording, never a day count. */
private fun roughDuration(days: Int): String {
val months = (days + 15) / 30
if (days >= 70 && months >= 2) return "$months months"
val weeks = (days + 3) / 7
return if (weeks == 1) "1 week" else "$weeks weeks"
}
/**
* "18 August". Day first and no ordinal suffix, which is how a date is written here, and
* with the month named from this table rather than from the platform's locale — a set
* configured in US English must not start printing "August 18" into New Zealand copy.
*
* No year: this is only reached inside a month, where the next 18 August is unambiguous.
*/
internal fun formatPaceDate(epochDay: Long): String {
val (_, month, day) = civilFromEpochDay(epochDay)
return "$day ${MONTH_NAMES[month - 1]}"
}
private val MONTH_NAMES = listOf(
"January", "February", "March", "April", "May", "June",
"July", "August", "September", "October", "November", "December",
)
/**
* Year, month and day from a count of days since 1970-01-01, by the usual civil-calendar
* algorithm. Hand-rolled because `java.time` needs API 26 and this app ships to 23, and
* because a `Calendar` would drag the device's default zone into a pure function that has
* already been handed the offset it needs.
*/
private fun civilFromEpochDay(epochDay: Long): Triple<Int, Int, Int> {
// Shift the epoch to 0000-03-01 so leap days land at the end of the cycle.
val shifted = epochDay + 719_468L
val era = floorDiv(shifted, 146_097L)
val dayOfEra = shifted - era * 146_097L
val yearOfEra = (dayOfEra - dayOfEra / 1460L + dayOfEra / 36_524L - dayOfEra / 146_096L) / 365L
val year = yearOfEra + era * 400L
val dayOfYear = dayOfEra - (365L * yearOfEra + yearOfEra / 4L - yearOfEra / 100L)
val monthPrime = (5L * dayOfYear + 2L) / 153L
val day = (dayOfYear - (153L * monthPrime + 2L) / 5L + 1L).toInt()
val month = (if (monthPrime < 10L) monthPrime + 3L else monthPrime - 9L).toInt()
return Triple((if (month <= 2) year + 1L else year).toInt(), month, day)
}
/**
* Emby's UTC timestamp as epoch milliseconds, or null for anything unrecognisable.
*
* [normalizePlayedAt] already rejects the malformed and the "never played" sentinel and
* hands back a fixed-width `yyyy-MM-ddTHH:mm:ss`; all that is left is to read the digits.
* Doing it by hand rather than with `SimpleDateFormat` keeps the function pure and free of
* that class's thread-safety problem, which the analytics buffer has to work around.
*/
internal fun playedAtMillis(value: String?): Long? {
val stamp = normalizePlayedAt(value) ?: return null
val year = stamp.substring(0, 4).toIntOrNull() ?: return null
val month = stamp.substring(5, 7).toIntOrNull() ?: return null
val day = stamp.substring(8, 10).toIntOrNull() ?: return null
val hour = stamp.substring(11, 13).toIntOrNull() ?: return null
val minute = stamp.substring(14, 16).toIntOrNull() ?: return null
val second = stamp.substring(17, 19).toIntOrNull() ?: return null
if (month !in 1..12 || day !in 1..31) return null
return epochDayFromCivil(year, month, day) * DAY_MS +
(hour * 3_600L + minute * 60L + second) * 1_000L
}
/** The inverse of [civilFromEpochDay], by the same algorithm. */
private fun epochDayFromCivil(year: Int, month: Int, day: Int): Long {
val y = (if (month <= 2) year - 1 else year).toLong()
val era = floorDiv(y, 400L)
val yearOfEra = y - era * 400L
val monthPrime = if (month > 2) month - 3 else month + 9
val dayOfYear = (153L * monthPrime + 2L) / 5L + day - 1L
val dayOfEra = yearOfEra * 365L + yearOfEra / 4L - yearOfEra / 100L + dayOfYear
return era * 146_097L + dayOfEra - 719_468L
}
@@ -1,6 +1,7 @@
package com.ponzischeme89.memby.data
import android.content.Context
import androidx.datastore.core.DataMigration
import androidx.datastore.core.DataStore
import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler
import androidx.datastore.preferences.core.Preferences
@@ -9,6 +10,7 @@ import androidx.datastore.preferences.core.MutablePreferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.longPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.core.stringSetPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
@@ -27,8 +29,250 @@ import kotlinx.serialization.Serializable
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.decodeFromJsonElement
import java.security.MessageDigest
import java.util.UUID
private val settingsJson = Json {
// A newer build may have written fields this build does not know about (for example
// after a rollback). Unknown fields must not make every saved profile disappear.
ignoreUnknownKeys = true
coerceInputValues = true
}
private const val CURRENT_SETTINGS_SCHEMA = 2
private val settingsSchemaKey = intPreferencesKey("settings_schema_version")
private val migrationProfilesKey = stringPreferencesKey("profiles")
private val migrationProfilesRecoveryKey = stringPreferencesKey("profiles_recovery_v0")
/** The known-bad `ANDROID_ID` a batch of old devices all shipped with. */
private const val SHARED_ANDROID_ID = "9774d56d682e549c"
/**
* Derives this television's device id from `ANDROID_ID`, hashed so the platform
* identifier itself is never sent to a server, and prefixed so the value reads as ours in
* Emby's devices list.
*
* Falls back to a random id when the platform has nothing usable — null on a device that
* has not finished setting up, blank, or the one value a batch of early devices all
* reported, which would make every one of them the same television. A random id is worse
* only in that it does not survive a reinstall; sharing one identity between two sets
* would be wrong rather than merely inconvenient.
*/
internal fun deviceIdFor(androidId: String?): String {
val trimmed = androidId?.trim().orEmpty()
if (trimmed.length < 16 || trimmed == SHARED_ANDROID_ID || trimmed.all { it == '0' }) {
return UUID.randomUUID().toString()
}
val digest = MessageDigest.getInstance("SHA-256")
.digest("memby:$trimmed".toByteArray(Charsets.UTF_8))
return "memby-" + digest.take(10).joinToString("") { "%02x".format(it) }
}
private data class DecodedProfiles(
val profiles: List<EmbyProfile>,
val hadErrors: Boolean,
)
/**
* Decode profiles independently. One damaged or future-incompatible entry must not hide
* every other account on the TV, which decoding the complete list in one call would do.
*/
private fun decodeProfilesSafely(value: String?): DecodedProfiles {
if (value.isNullOrBlank()) return DecodedProfiles(emptyList(), false)
return runCatching {
val elements = settingsJson.parseToJsonElement(value) as? JsonArray
?: return@runCatching DecodedProfiles(emptyList(), true)
val decoded = elements.mapNotNull { element ->
runCatching { settingsJson.decodeFromJsonElement<EmbyProfile>(element) }
.getOrNull()
?.takeIf {
it.serverUrl.isNotBlank() && it.token.isNotBlank() &&
it.userId.isNotBlank() && it.username.isNotBlank()
}
}
DecodedProfiles(decoded.distinctBy(EmbyProfile::id), decoded.size != elements.size)
}.getOrElse { DecodedProfiles(emptyList(), true) }
}
/** Versioned and idempotent transformations applied before DataStore emits its first value. */
internal object SettingsMigrationLogic {
fun migrateToCurrent(source: Preferences): Preferences {
val migrated = source.toMutablePreferences()
var version = runCatching { source[settingsSchemaKey] ?: 0 }.getOrDefault(0)
if (version > CURRENT_SETTINGS_SCHEMA) return source // safe downgrade
while (version < CURRENT_SETTINGS_SCHEMA) {
// Each version is a separate step so future changes can be appended without
// changing an already-shipped transformation.
when (version) {
0 -> migrateFromUnversioned(migrated)
1 -> migrateDeviceTogglesIntoProfiles(migrated)
}
version += 1
migrated[settingsSchemaKey] = version
}
return migrated
}
private fun migrateFromUnversioned(preferences: MutablePreferences) {
val rawProfiles = preferences[migrationProfilesKey]
val decoded = decodeProfilesSafely(rawProfiles)
if (decoded.hadErrors && !rawProfiles.isNullOrBlank()) {
// Keep the original payload for a later release to recover more from. The app
// uses the repaired value below, so this backup can never block startup.
preferences[migrationProfilesRecoveryKey] = rawProfiles
}
val legacy = legacyProfileForMigration(preferences)
val profiles = decoded.profiles.toMutableList()
if (legacy != null) {
val existingIndex = profiles.indexOfFirst {
it.userId == legacy.userId && it.serverUrl.trimEnd('/') == legacy.serverUrl.trimEnd('/')
}
if (existingIndex < 0) {
profiles += legacy
} else {
// Flat keys describe the currently authenticated session and are the best
// source for credentials; retain the per-profile UI choices from the blob.
val existing = profiles[existingIndex]
profiles[existingIndex] = existing.copy(
id = legacy.id,
serverUrl = legacy.serverUrl,
token = legacy.token,
userId = legacy.userId,
username = legacy.username,
serverId = legacy.serverId,
)
}
}
if (rawProfiles != null || profiles.isNotEmpty()) {
val stripped = profiles.map { profile ->
profile.homeCacheJson?.takeIf(String::isNotBlank)?.let { cache ->
val cacheKey = stringPreferencesKey("home_cache::${profile.userId}@${profile.serverUrl}")
if (preferences[cacheKey] == null) preferences[cacheKey] = cache
}
if (profile.homeCacheJson == null) profile else profile.copy(homeCacheJson = null)
}
preferences[migrationProfilesKey] = settingsJson.encodeToString(stripped)
}
}
/**
* Folds the three toggles that used to be device-wide into every stored profile.
*
* `showTitleLogo`, `autoPlayNextEpisode` and `showTenMinuteReminder` became
* per-profile when settings moved to the server. On an install that predates that,
* the flat keys hold the viewer's real choices while the saved profiles carry none —
* so they decode as the class defaults, all three on. Nothing looks wrong at first,
* because the flat keys are what the app renders from. But [applyProfile] copies a
* profile's values *into* those flat keys, so the first profile switch after upgrading
* would silently switch all three back on, and the sync would then push that to the
* server as a deliberate choice.
*
* Copying one device-wide value into every profile is exactly right rather than a
* compromise: while it was device-wide, that value genuinely was in force for all of
* them.
*
* Overwriting whatever a profile carries is safe because at schema 1 it cannot carry
* anything — the three fields did not exist on [EmbyProfile] then. It is also the only
* option available: the encoder omits default values, so a stored profile that chose
* `true` is byte-identical to one that never chose at all. The schema version is what
* makes this run exactly once.
*/
private fun migrateDeviceTogglesIntoProfiles(preferences: MutablePreferences) {
val profiles = decodeProfilesSafely(preferences[migrationProfilesKey]).profiles
if (profiles.isEmpty()) return
val titleLogo = preferences[booleanPreferencesKey("show_title_logo")] ?: true
val autoPlayNext = preferences[booleanPreferencesKey("auto_play_next_episode")] ?: true
val tenMinutes = preferences[booleanPreferencesKey("show_ten_minute_reminder")] ?: true
// All three at their defaults means there is nothing a viewer chose to preserve,
// and rewriting the blob would be a whole-file fsync for no change.
if (titleLogo && autoPlayNext && tenMinutes) return
preferences[migrationProfilesKey] = settingsJson.encodeToString(
profiles.map {
it.copy(
showTitleLogo = titleLogo,
autoPlayNextEpisode = autoPlayNext,
showTenMinuteReminder = tenMinutes,
)
},
)
}
private fun legacyProfileForMigration(preferences: Preferences): EmbyProfile? {
val serverUrl = preferences[stringPreferencesKey("server_url")]?.takeIf(String::isNotBlank)
?: return null
val token = preferences[stringPreferencesKey("token")]?.takeIf(String::isNotBlank)
?: return null
val userId = preferences[stringPreferencesKey("user_id")]?.takeIf(String::isNotBlank)
?: return null
val username = preferences[stringPreferencesKey("username")]
?.takeIf(String::isNotBlank) ?: "Memby user"
return EmbyProfile(
id = "${serverUrl.trimEnd('/')}::$userId",
serverUrl = serverUrl,
token = token,
userId = userId,
username = username,
serverId = preferences[stringPreferencesKey("server_id")],
homeCacheJson = preferences[
stringPreferencesKey("home_cache::$userId@$serverUrl")
] ?: preferences[stringPreferencesKey("home_cache")],
forYouMinutes = preferences[intPreferencesKey("for_you_minutes")] ?: 0,
hasOpenedForYou = preferences[booleanPreferencesKey("has_opened_for_you")] ?: false,
welcomeQuoteStyle = preferences[stringPreferencesKey("welcome_quote_style")]
?: Settings.DEFAULT_WELCOME_QUOTE_STYLE,
homeSections = preferences[stringPreferencesKey("home_sections")]
?: Settings.DEFAULT_HOME_SECTIONS,
homeCardDensity = preferences[stringPreferencesKey("home_card_density")]
?: Settings.DEFAULT_HOME_CARD_DENSITY,
homeArtworkStyle = preferences[stringPreferencesKey("home_artwork_style")]
?: Settings.DEFAULT_HOME_ARTWORK_STYLE,
showHomeCardMetadata = preferences[
booleanPreferencesKey("show_home_card_metadata")
] ?: true,
showRatingsStrip = preferences[booleanPreferencesKey("show_ratings_strip")] ?: true,
hideWatchedMovies = preferences[booleanPreferencesKey("hide_watched_movies")] ?: false,
homeRowOrder = preferences[stringPreferencesKey("home_row_order")].orEmpty(),
homePinnedRows = preferences[stringPreferencesKey("home_pinned_rows")].orEmpty(),
homeHiddenRows = preferences[stringPreferencesKey("home_hidden_rows")].orEmpty(),
preferencesRevision = preferences[longPreferencesKey("preferences_revision")] ?: 0,
// These were device-wide before settings moved to the server, so the values
// this install already had become the first profile's. Defaulting them here
// instead would silently turn three settings back on for everyone upgrading.
showTitleLogo = preferences[booleanPreferencesKey("show_title_logo")] ?: true,
autoPlayNextEpisode = preferences[
booleanPreferencesKey("auto_play_next_episode")
] ?: true,
showTenMinuteReminder = preferences[
booleanPreferencesKey("show_ten_minute_reminder")
] ?: true,
)
}
}
/** The built-in launcher rows a viewer may choose between. */
internal val VALID_HOME_SECTIONS = setOf("continue", "favorites", "latest")
private class SettingsPreferencesMigration : DataMigration<Preferences> {
override suspend fun shouldMigrate(currentData: Preferences): Boolean =
runCatching { (currentData[settingsSchemaKey] ?: 0) < CURRENT_SETTINGS_SCHEMA }
.getOrDefault(true)
override suspend fun migrate(currentData: Preferences): Preferences =
// Migration code must never turn into a permanent startup/edit failure. All known
// data remains readable on the current code path, and leaving the version alone
// lets the next process start retry a transiently failed migration.
runCatching { SettingsMigrationLogic.migrateToCurrent(currentData) }
.getOrElse { currentData }
override suspend fun cleanUp() = Unit
}
/**
* The corruption handler is not optional here. Without one, a Preferences file that fails
* to parse makes [DataStore.data] throw on every read for the life of the install — and
@@ -42,6 +286,7 @@ import java.util.UUID
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(
name = "emby_settings",
corruptionHandler = ReplaceFileCorruptionHandler { emptyPreferences() },
produceMigrations = { listOf(SettingsPreferencesMigration()) },
)
/** Persisted connection state. */
@@ -65,6 +310,13 @@ data class Settings(
val autoPlayNextEpisode: Boolean = true,
// Show the compact lower-third when playback crosses ten minutes remaining.
val showTenMinuteReminder: Boolean = true,
// Turn a subtitle track on automatically, and which language wins when one is chosen.
// Per-profile and synced: subtitles are a personal choice, not a property of the room.
val subtitlesEnabled: Boolean = true,
val subtitleLanguage: String = SUBTITLE_LANGUAGE_AUTO,
// How far one press of Left or Right moves the film. Per-profile and synced for the
// same reason subtitles are: it is a habit, not a property of the room.
val seekIntervalSeconds: Int = DEFAULT_SEEK_INTERVAL_SECONDS,
// Foreground colour of the slide-progress ring, as an RRGGBB hex string.
val ringColorHex: String = DEFAULT_RING_COLOR,
val lastBackdropUrl: String? = null,
@@ -79,6 +331,8 @@ data class Settings(
/** Card image selection on browse rows: automatic, poster, or backdrop. */
val homeArtworkStyle: String = DEFAULT_HOME_ARTWORK_STYLE,
val showHomeCardMetadata: Boolean = true,
/** Show third-party ratings on browse and detail surfaces. Profile-specific. */
val showRatingsStrip: Boolean = true,
/** Hide fully watched movies from browse rows and hero selections. */
val hideWatchedMovies: Boolean = false,
/** Newline-separated server row ids. These are profile-specific home customisations. */
@@ -95,6 +349,19 @@ data class Settings(
* and remains authoritative for anyone not listed here.
*/
val onboardedUserIds: Set<String> = emptySet(),
/**
* The app version whose release notes this television has already shown. Deliberately
* device state rather than a synced preference: what is new is a property of the APK
* sitting on this set, and a viewer who signs into a second TV that is still a version
* behind has not seen that build's notes.
*/
val whatsNewSeenVersion: String? = null,
/**
* Revision of the active profile's settings as last agreed with the gateway. Zero
* means this TV has never synced them, which is what tells [PreferencesSync] to push
* what is here rather than wait to be told.
*/
val preferencesRevision: Long = 0,
val profiles: List<EmbyProfile> = emptyList(),
) {
val isSignedIn: Boolean
@@ -136,10 +403,20 @@ data class EmbyProfile(
val homeCardDensity: String = Settings.DEFAULT_HOME_CARD_DENSITY,
val homeArtworkStyle: String = Settings.DEFAULT_HOME_ARTWORK_STYLE,
val showHomeCardMetadata: Boolean = true,
val showRatingsStrip: Boolean = true,
val hideWatchedMovies: Boolean = false,
val homeRowOrder: String = "",
val homePinnedRows: String = "",
val homeHiddenRows: String = "",
/** Per profile, because two people on one TV have separate documents on the server. */
val preferencesRevision: Long = 0,
/** Playback and presentation choices, which sync alongside the home ones. */
val showTitleLogo: Boolean = true,
val autoPlayNextEpisode: Boolean = true,
val showTenMinuteReminder: Boolean = true,
val subtitlesEnabled: Boolean = true,
val subtitleLanguage: String = SUBTITLE_LANGUAGE_AUTO,
val seekIntervalSeconds: Int = DEFAULT_SEEK_INTERVAL_SECONDS,
)
class SettingsStore(private val context: Context) {
@@ -174,6 +451,9 @@ class SettingsStore(private val context: Context) {
val SHOW_TITLE_LOGO = booleanPreferencesKey("show_title_logo")
val AUTO_PLAY_NEXT = booleanPreferencesKey("auto_play_next_episode")
val SHOW_TEN_MINUTE_REMINDER = booleanPreferencesKey("show_ten_minute_reminder")
val SUBTITLES_ENABLED = booleanPreferencesKey("subtitles_enabled")
val SUBTITLE_LANGUAGE = stringPreferencesKey("subtitle_language")
val SEEK_INTERVAL_SECONDS = intPreferencesKey("seek_interval_seconds")
val RING_COLOR = stringPreferencesKey("ring_color")
val LAST_BACKDROP_URL = stringPreferencesKey("last_backdrop_url")
val HOME_SECTIONS = stringPreferencesKey("home_sections")
@@ -183,6 +463,7 @@ class SettingsStore(private val context: Context) {
val HOME_CARD_DENSITY = stringPreferencesKey("home_card_density")
val HOME_ARTWORK_STYLE = stringPreferencesKey("home_artwork_style")
val SHOW_HOME_CARD_METADATA = booleanPreferencesKey("show_home_card_metadata")
val SHOW_RATINGS_STRIP = booleanPreferencesKey("show_ratings_strip")
val HIDE_WATCHED_MOVIES = booleanPreferencesKey("hide_watched_movies")
val HOME_ROW_ORDER = stringPreferencesKey("home_row_order")
val HOME_PINNED_ROWS = stringPreferencesKey("home_pinned_rows")
@@ -191,6 +472,8 @@ class SettingsStore(private val context: Context) {
val PROFILES = stringPreferencesKey("profiles")
val SEEN_ALERTS = stringPreferencesKey("seen_alert_ids")
val ONBOARDED_USERS = stringSetPreferencesKey("onboarded_user_ids")
val WHATS_NEW_VERSION = stringPreferencesKey("whats_new_seen_version")
val PREFERENCES_REVISION = longPreferencesKey("preferences_revision")
}
/**
@@ -266,16 +549,137 @@ class SettingsStore(private val context: Context) {
private fun decodeAlertIds(raw: String?): List<String> =
raw?.split('\n')?.filter { it.isNotBlank() }.orEmpty()
// These three used to be device-wide. They are per-profile now for the same reason
// they sync: they describe how a *person* wants Memby to behave, so two viewers
// sharing a television must be able to disagree about them — and a device-wide value
// would mean whoever signed in last pushed their choice into everyone's account.
suspend fun setShowTitleLogo(enabled: Boolean) {
context.dataStore.edit { it[Keys.SHOW_TITLE_LOGO] = enabled }
context.dataStore.edit { preferences ->
preferences[Keys.SHOW_TITLE_LOGO] = enabled
updateActiveProfile(preferences) { it.copy(showTitleLogo = enabled) }
}
}
suspend fun setAutoPlayNextEpisode(enabled: Boolean) {
context.dataStore.edit { it[Keys.AUTO_PLAY_NEXT] = enabled }
context.dataStore.edit { preferences ->
preferences[Keys.AUTO_PLAY_NEXT] = enabled
updateActiveProfile(preferences) { it.copy(autoPlayNextEpisode = enabled) }
}
}
suspend fun setShowTenMinuteReminder(enabled: Boolean) {
context.dataStore.edit { it[Keys.SHOW_TEN_MINUTE_REMINDER] = enabled }
context.dataStore.edit { preferences ->
preferences[Keys.SHOW_TEN_MINUTE_REMINDER] = enabled
updateActiveProfile(preferences) { it.copy(showTenMinuteReminder = enabled) }
}
}
/**
* Records what the viewer just did in the player's subtitle menu.
*
* Both halves move together because the menu only ever produces one of two answers:
* "off", or a specific track — and a track that has no language of its own must not
* quietly overwrite the language somebody chose earlier, which is why a blank language
* leaves that half alone.
*/
suspend fun setSubtitlePreference(enabled: Boolean, language: String?) {
val normalized = normalizeSubtitleLanguage(language)
context.dataStore.edit { preferences ->
preferences[Keys.SUBTITLES_ENABLED] = enabled
if (normalized.isNotEmpty()) preferences[Keys.SUBTITLE_LANGUAGE] = normalized
updateActiveProfile(preferences) {
it.copy(
subtitlesEnabled = enabled,
subtitleLanguage = if (normalized.isEmpty()) it.subtitleLanguage else normalized,
)
}
}
}
/** Normalised on the way in, so a bad value can never reach the player as a step size. */
suspend fun setSeekIntervalSeconds(seconds: Int) {
val interval = normalizeSeekIntervalSeconds(seconds)
context.dataStore.edit { preferences ->
preferences[Keys.SEEK_INTERVAL_SECONDS] = interval
updateActiveProfile(preferences) { it.copy(seekIntervalSeconds = interval) }
}
}
/**
* Adopts the server's document for the signed-in viewer, in one write.
*
* One write is the point. Preferences DataStore rewrites and fsyncs the whole file per
* edit, and this touches seventeen keys plus the profiles blob — doing it through the
* individual setters would be seventeen rewrites for one sync, on a TV that has just
* started up.
*
* The revision is stored in the same edit as the values it describes. If they could
* come apart, a TV could believe it was up to date while holding something else, and
* nothing would ever correct it: the status poll only compares numbers.
*/
suspend fun applyRemotePreferences(preferences: UserPreferences, revision: Long) {
val sections = preferences.homeSections
.filter { it in VALID_HOME_SECTIONS }
.distinct()
.ifEmpty { listOf("favorites") }
.joinToString(",")
context.dataStore.edit { store ->
store[Keys.HOME_SECTIONS] = sections
store[Keys.HOME_CARD_DENSITY] = preferences.homeCardDensity
store[Keys.HOME_ARTWORK_STYLE] = preferences.homeArtworkStyle
store[Keys.SHOW_HOME_CARD_METADATA] = preferences.showHomeCardMetadata
store[Keys.SHOW_RATINGS_STRIP] = preferences.showRatingsStrip
store[Keys.HIDE_WATCHED_MOVIES] = preferences.hideWatchedMovies
store[Keys.SHOW_TITLE_LOGO] = preferences.showTitleLogo
store[Keys.WELCOME_QUOTE_STYLE] = preferences.welcomeQuoteStyle
store[Keys.AUTO_PLAY_NEXT] = preferences.autoPlayNextEpisode
store[Keys.SHOW_TEN_MINUTE_REMINDER] = preferences.showTenMinuteReminder
store[Keys.SUBTITLES_ENABLED] = preferences.subtitlesEnabled
store[Keys.SUBTITLE_LANGUAGE] = preferences.subtitleLanguage
store[Keys.SEEK_INTERVAL_SECONDS] =
normalizeSeekIntervalSeconds(preferences.seekIntervalSeconds)
store[Keys.FOR_YOU_MINUTES] = preferences.forYouMinutes
store[Keys.HOME_ROW_ORDER] = preferences.homeRowOrder.joinToString("\n")
store[Keys.HOME_PINNED_ROWS] = preferences.homePinnedRows.joinToString("\n")
store[Keys.HOME_HIDDEN_ROWS] = preferences.homeHiddenRows.joinToString("\n")
store[Keys.PREFERENCES_REVISION] = revision
updateActiveProfile(store) {
it.copy(
homeSections = sections,
homeCardDensity = preferences.homeCardDensity,
homeArtworkStyle = preferences.homeArtworkStyle,
showHomeCardMetadata = preferences.showHomeCardMetadata,
showRatingsStrip = preferences.showRatingsStrip,
hideWatchedMovies = preferences.hideWatchedMovies,
showTitleLogo = preferences.showTitleLogo,
welcomeQuoteStyle = preferences.welcomeQuoteStyle,
autoPlayNextEpisode = preferences.autoPlayNextEpisode,
showTenMinuteReminder = preferences.showTenMinuteReminder,
subtitlesEnabled = preferences.subtitlesEnabled,
subtitleLanguage = preferences.subtitleLanguage,
seekIntervalSeconds =
normalizeSeekIntervalSeconds(preferences.seekIntervalSeconds),
forYouMinutes = preferences.forYouMinutes,
homeRowOrder = preferences.homeRowOrder.joinToString("\n"),
homePinnedRows = preferences.homePinnedRows.joinToString("\n"),
homeHiddenRows = preferences.homeHiddenRows.joinToString("\n"),
preferencesRevision = revision,
)
}
}
}
/**
* Records the revision a successful push was stored under, without touching values.
* Separate from [applyRemotePreferences] because after a push this TV already holds
* what the server does — rewriting the values would be a redundant file rewrite and,
* worse, could clobber an edit made in the moment the request was in flight.
*/
suspend fun setPreferencesRevision(revision: Long) {
context.dataStore.edit { preferences ->
preferences[Keys.PREFERENCES_REVISION] = revision
updateActiveProfile(preferences) { it.copy(preferencesRevision = revision) }
}
}
suspend fun setRingColor(hex: String) {
@@ -287,7 +691,7 @@ class SettingsStore(private val context: Context) {
}
suspend fun setHomeSections(sections: List<String>) {
val valid = sections.filter { it in setOf("continue", "favorites", "latest") }.distinct()
val valid = sections.filter { it in VALID_HOME_SECTIONS }.distinct()
val selected = valid.ifEmpty { listOf("favorites") }.joinToString(",")
context.dataStore.edit { preferences ->
preferences[Keys.HOME_SECTIONS] = selected
@@ -320,6 +724,13 @@ class SettingsStore(private val context: Context) {
}
}
suspend fun setShowRatingsStrip(show: Boolean) {
context.dataStore.edit { preferences ->
preferences[Keys.SHOW_RATINGS_STRIP] = show
updateActiveProfile(preferences) { it.copy(showRatingsStrip = show) }
}
}
suspend fun setHideWatchedMovies(hide: Boolean) {
context.dataStore.edit { preferences ->
preferences[Keys.HIDE_WATCHED_MOVIES] = hide
@@ -378,7 +789,7 @@ class SettingsStore(private val context: Context) {
* profiles blob carries none, so a refresh writes one value and the blob stays small.
*/
suspend fun setHomeCache(cache: HomeCache) {
val encodedCache = Json.encodeToString(cache)
val encodedCache = settingsJson.encodeToString(cache)
// Most refreshes find nothing new — a scheduled poll, or a playback stop on a row
// that did not move. Skipping the write means skipping the whole file rewrite.
if (encodedCache == lastPersistedHomeCache) return
@@ -445,6 +856,20 @@ class SettingsStore(private val context: Context) {
}
}
/**
* Records the version whose release notes have been shown on this television, so the
* "what's new" panel appears exactly once per update. Written on dismissal, and also
* written silently on a fresh install so the first sign-in is not greeted by notes for
* a build the viewer has never been without.
*/
suspend fun markWhatsNewSeen(version: String) {
val trimmed = version.trim()
if (trimmed.isEmpty()) return
context.dataStore.edit { preferences ->
preferences[Keys.WHATS_NEW_VERSION] = trimmed
}
}
suspend fun markForYouOpened() {
context.dataStore.edit { preferences ->
preferences[Keys.HAS_OPENED_FOR_YOU] = true
@@ -489,7 +914,7 @@ class SettingsStore(private val context: Context) {
}
if (profile.homeCacheJson == null) profile else profile.copy(homeCacheJson = null)
}
preferences[Keys.PROFILES] = Json.encodeToString(stripped)
preferences[Keys.PROFILES] = settingsJson.encodeToString(stripped)
}
/**
@@ -514,7 +939,7 @@ class SettingsStore(private val context: Context) {
}
private fun decodeHomeCache(json: String): HomeCache? =
runCatching { Json.decodeFromString<HomeCache>(json) }.getOrNull()
runCatching { settingsJson.decodeFromString<HomeCache>(json) }.getOrNull()
/**
* Reads DataStore directly instead of taking the replayed flow value. This matters
@@ -523,11 +948,32 @@ class SettingsStore(private val context: Context) {
suspend fun snapshot(): Settings = settingsFrom(context.dataStore.data.first())
.also { latestSettings = it }
/** Returns the stable device id, generating and persisting one on first use. */
/**
* Returns the stable device id, deriving and persisting one on first use.
*
* This id *is* the television's identity: Emby's devices list and Settings → Devices
* both key on it, and signing in with one Emby has seen replaces that entry rather
* than adding a second. So it has to outlive the app's own storage, which on these
* sets it does not — every APK is sideloaded, an install that will not go over the
* old one is done by hand as an uninstall and reinstall, and the DataStore's
* corruption handler replaces the file outright after a process killed mid-write.
* A random id turned each of those into a second television, one per version, that
* had to be removed by hand. `ANDROID_ID` survives all of them and is scoped to this
* app's signing key, so the same set signs back in over its own entry.
*
* An id already stored is kept: it is a working identity, and replacing it is the
* duplicate this exists to avoid.
*/
suspend fun ensureDeviceId(): String {
val existing = context.dataStore.data.first()[Keys.DEVICE_ID]
if (!existing.isNullOrBlank()) return existing
val generated = UUID.randomUUID().toString()
val androidId = runCatching {
android.provider.Settings.Secure.getString(
context.contentResolver,
android.provider.Settings.Secure.ANDROID_ID,
)
}.getOrNull()
val generated = deviceIdFor(androidId)
context.dataStore.edit { it[Keys.DEVICE_ID] = generated }
return generated
}
@@ -554,10 +1000,21 @@ class SettingsStore(private val context: Context) {
homeArtworkStyle = previous?.homeArtworkStyle
?: Settings.DEFAULT_HOME_ARTWORK_STYLE,
showHomeCardMetadata = previous?.showHomeCardMetadata ?: true,
showRatingsStrip = previous?.showRatingsStrip ?: true,
hideWatchedMovies = previous?.hideWatchedMovies ?: false,
homeRowOrder = previous?.homeRowOrder.orEmpty(),
homePinnedRows = previous?.homePinnedRows.orEmpty(),
homeHiddenRows = previous?.homeHiddenRows.orEmpty(),
// Carried over so a re-sign-in on a TV that already knows this profile
// does not look like a first sync and push stale values back up.
preferencesRevision = previous?.preferencesRevision ?: 0,
showTitleLogo = previous?.showTitleLogo ?: true,
autoPlayNextEpisode = previous?.autoPlayNextEpisode ?: true,
showTenMinuteReminder = previous?.showTenMinuteReminder ?: true,
subtitlesEnabled = previous?.subtitlesEnabled ?: true,
subtitleLanguage = previous?.subtitleLanguage ?: SUBTITLE_LANGUAGE_AUTO,
seekIntervalSeconds = previous?.seekIntervalSeconds
?: DEFAULT_SEEK_INTERVAL_SECONDS,
)
profiles.removeAll { it.id == id }
profiles.add(profile)
@@ -635,10 +1092,20 @@ class SettingsStore(private val context: Context) {
preferences.remove(Keys.HOME_CARD_DENSITY)
preferences.remove(Keys.HOME_ARTWORK_STYLE)
preferences.remove(Keys.SHOW_HOME_CARD_METADATA)
preferences.remove(Keys.SHOW_RATINGS_STRIP)
preferences.remove(Keys.HIDE_WATCHED_MOVIES)
preferences.remove(Keys.HOME_ROW_ORDER)
preferences.remove(Keys.HOME_PINNED_ROWS)
preferences.remove(Keys.HOME_HIDDEN_ROWS)
preferences.remove(Keys.SHOW_TITLE_LOGO)
preferences.remove(Keys.AUTO_PLAY_NEXT)
preferences.remove(Keys.SHOW_TEN_MINUTE_REMINDER)
preferences.remove(Keys.SUBTITLES_ENABLED)
preferences.remove(Keys.SUBTITLE_LANGUAGE)
preferences.remove(Keys.SEEK_INTERVAL_SECONDS)
// Cleared with the rest: a revision left behind would make the next profile on
// this TV believe it had already synced settings it has never seen.
preferences.remove(Keys.PREFERENCES_REVISION)
preferences.remove(Keys.USERNAME)
}
@@ -675,10 +1142,19 @@ class SettingsStore(private val context: Context) {
preferences[Keys.HOME_CARD_DENSITY] = profile.homeCardDensity
preferences[Keys.HOME_ARTWORK_STYLE] = profile.homeArtworkStyle
preferences[Keys.SHOW_HOME_CARD_METADATA] = profile.showHomeCardMetadata
preferences[Keys.SHOW_RATINGS_STRIP] = profile.showRatingsStrip
preferences[Keys.HIDE_WATCHED_MOVIES] = profile.hideWatchedMovies
preferences[Keys.HOME_ROW_ORDER] = profile.homeRowOrder
preferences[Keys.HOME_PINNED_ROWS] = profile.homePinnedRows
preferences[Keys.HOME_HIDDEN_ROWS] = profile.homeHiddenRows
preferences[Keys.SHOW_TITLE_LOGO] = profile.showTitleLogo
preferences[Keys.AUTO_PLAY_NEXT] = profile.autoPlayNextEpisode
preferences[Keys.SHOW_TEN_MINUTE_REMINDER] = profile.showTenMinuteReminder
preferences[Keys.SUBTITLES_ENABLED] = profile.subtitlesEnabled
preferences[Keys.SUBTITLE_LANGUAGE] = profile.subtitleLanguage
preferences[Keys.SEEK_INTERVAL_SECONDS] =
normalizeSeekIntervalSeconds(profile.seekIntervalSeconds)
preferences[Keys.PREFERENCES_REVISION] = profile.preferencesRevision
preferences.remove(Keys.LAST_BACKDROP_URL)
}
@@ -705,15 +1181,25 @@ class SettingsStore(private val context: Context) {
homeArtworkStyle = preferences[Keys.HOME_ARTWORK_STYLE]
?: Settings.DEFAULT_HOME_ARTWORK_STYLE,
showHomeCardMetadata = preferences[Keys.SHOW_HOME_CARD_METADATA] ?: true,
showRatingsStrip = preferences[Keys.SHOW_RATINGS_STRIP] ?: true,
hideWatchedMovies = preferences[Keys.HIDE_WATCHED_MOVIES] ?: false,
homeRowOrder = preferences[Keys.HOME_ROW_ORDER].orEmpty(),
homePinnedRows = preferences[Keys.HOME_PINNED_ROWS].orEmpty(),
homeHiddenRows = preferences[Keys.HOME_HIDDEN_ROWS].orEmpty(),
preferencesRevision = preferences[Keys.PREFERENCES_REVISION] ?: 0,
showTitleLogo = preferences[Keys.SHOW_TITLE_LOGO] ?: true,
autoPlayNextEpisode = preferences[Keys.AUTO_PLAY_NEXT] ?: true,
showTenMinuteReminder = preferences[Keys.SHOW_TEN_MINUTE_REMINDER] ?: true,
subtitlesEnabled = preferences[Keys.SUBTITLES_ENABLED] ?: true,
subtitleLanguage = preferences[Keys.SUBTITLE_LANGUAGE] ?: SUBTITLE_LANGUAGE_AUTO,
seekIntervalSeconds = normalizeSeekIntervalSeconds(
preferences[Keys.SEEK_INTERVAL_SECONDS] ?: DEFAULT_SEEK_INTERVAL_SECONDS,
),
)
}
private fun decodeProfiles(value: String?): List<EmbyProfile> =
value?.let { runCatching { Json.decodeFromString<List<EmbyProfile>>(it) }.getOrNull() }.orEmpty()
decodeProfilesSafely(value).profiles
private fun settingsFrom(preferences: Preferences): Settings {
val storedProfiles = decodeProfiles(preferences[Keys.PROFILES])
@@ -737,7 +1223,12 @@ class SettingsStore(private val context: Context) {
showTitleLogo = preferences[Keys.SHOW_TITLE_LOGO] ?: true,
autoPlayNextEpisode = preferences[Keys.AUTO_PLAY_NEXT] ?: true,
showTenMinuteReminder = preferences[Keys.SHOW_TEN_MINUTE_REMINDER] ?: true,
ringColorHex = preferences[Keys.RING_COLOR] ?: Settings.DEFAULT_RING_COLOR,
subtitlesEnabled = preferences[Keys.SUBTITLES_ENABLED] ?: true,
subtitleLanguage = preferences[Keys.SUBTITLE_LANGUAGE] ?: SUBTITLE_LANGUAGE_AUTO,
seekIntervalSeconds = normalizeSeekIntervalSeconds(
preferences[Keys.SEEK_INTERVAL_SECONDS] ?: DEFAULT_SEEK_INTERVAL_SECONDS,
),
ringColorHex =preferences[Keys.RING_COLOR] ?: Settings.DEFAULT_RING_COLOR,
lastBackdropUrl = preferences[Keys.LAST_BACKDROP_URL],
homeSections = preferences[Keys.HOME_SECTIONS] ?: Settings.DEFAULT_HOME_SECTIONS,
homeCacheJson = activeHomeCache(preferences),
@@ -747,6 +1238,7 @@ class SettingsStore(private val context: Context) {
homeArtworkStyle = preferences[Keys.HOME_ARTWORK_STYLE]
?: Settings.DEFAULT_HOME_ARTWORK_STYLE,
showHomeCardMetadata = preferences[Keys.SHOW_HOME_CARD_METADATA] ?: true,
showRatingsStrip = preferences[Keys.SHOW_RATINGS_STRIP] ?: true,
hideWatchedMovies = preferences[Keys.HIDE_WATCHED_MOVIES] ?: false,
homeRowOrder = preferences[Keys.HOME_ROW_ORDER].orEmpty(),
homePinnedRows = preferences[Keys.HOME_PINNED_ROWS].orEmpty(),
@@ -754,6 +1246,8 @@ class SettingsStore(private val context: Context) {
welcomeQuoteStyle = preferences[Keys.WELCOME_QUOTE_STYLE]
?: Settings.DEFAULT_WELCOME_QUOTE_STYLE,
onboardedUserIds = preferences[Keys.ONBOARDED_USERS].orEmpty(),
whatsNewSeenVersion = preferences[Keys.WHATS_NEW_VERSION],
preferencesRevision = preferences[Keys.PREFERENCES_REVISION] ?: 0,
profiles = profiles,
)
}
@@ -770,6 +1264,11 @@ class SettingsStore(private val context: Context) {
@Serializable
data class HomeCache(
val continueWatching: List<com.ponzischeme89.memby.data.model.BaseItem> = emptyList(),
/**
* Only ever read now, never written: a cache from the build that still had two rows is
* what a TV draws before its first refresh lands, so `HomeUiState.from` folds these
* into [continueWatching] rather than losing them for that first frame.
*/
val nextUp: List<com.ponzischeme89.memby.data.model.BaseItem> = emptyList(),
val favorites: List<com.ponzischeme89.memby.data.model.BaseItem> = emptyList(),
val latestMovies: List<com.ponzischeme89.memby.data.model.BaseItem> = emptyList(),
@@ -55,6 +55,131 @@ internal fun subtitleTracks(
)
}.distinctBy { it.id }
/**
* "No preference": keep the flag-driven behaviour somebody who has never opened the
* subtitle menu should still get.
*/
const val SUBTITLE_LANGUAGE_AUTO = "auto"
/**
* ISO 639-1 codes are the vocabulary, because that is what media3 reports for a track after
* its own normalisation. The aliases are the three-letter forms Emby actually writes into a
* stream's language field — for several languages, two of them for the same thing.
*
* The same table exists in the gateway (`internal/api/subtitles.go`), which is what makes
* the two paths agree on what "Italian" means. Add a language to one and add it to both.
*/
private val subtitleLanguageAliases: Map<String, String> = buildMap {
fun language(code: String, vararg aliases: String) {
put(code, code)
aliases.forEach { put(it, code) }
}
language("en", "eng")
language("it", "ita")
language("es", "spa", "esp")
language("fr", "fre", "fra")
language("de", "ger", "deu")
language("pt", "por")
language("nl", "dut", "nld")
language("sv", "swe")
language("da", "dan")
language("no", "nor", "nob", "nno")
language("fi", "fin")
language("pl", "pol")
language("cs", "cze", "ces")
language("hu", "hun")
language("ro", "rum", "ron")
language("el", "gre", "ell")
language("ru", "rus")
language("uk", "ukr")
language("tr", "tur")
language("ar", "ara")
language("he", "heb", "iw")
language("hi", "hin")
language("ja", "jpn")
language("ko", "kor")
language("zh", "chi", "zho", "cmn", "yue")
language("th", "tha")
language("vi", "vie")
}
/**
* Folds a track's language onto that vocabulary. An unrecognised code survives as itself
* rather than becoming empty, so an exact match on a language the table has no entry for
* still works.
*/
fun normalizeSubtitleLanguage(raw: String?): String {
val value = raw?.trim()?.lowercase().orEmpty()
if (value.isEmpty()) return ""
val base = value.takeWhile { it != '-' && it != '_' }
return subtitleLanguageAliases[base] ?: base
}
/** The parts of a subtitle track [selectSubtitleId] needs, whatever produced it. */
data class SubtitleCandidate(
val id: String,
val language: String?,
val isDefault: Boolean = false,
val isForced: Boolean = false,
val isHearingImpaired: Boolean = false,
)
/**
* Which track to turn on, or null for none. The rules mirror `selectSubtitle` in the
* gateway exactly, because the direct-to-Emby path has no gateway to ask and a viewer must
* not get different subtitles depending on whether the container is up:
*
* - Off means off.
* - A chosen language wins, and within it a plain full track beats a forced or
* hearing-impaired one — somebody who picked Italian wants the dialogue, not the
* signs-only track that happens to come first.
* - A chosen language the title does not have falls back to a *forced* track only. Forced
* subtitles translate what is foreign to the film's own audio, so they are wanted either
* way; falling through to English instead would put a language nobody asked for on screen.
* - With no language chosen: forced, then default, then the first track — which is what an
* install that has never touched the setting keeps.
*/
fun selectSubtitleId(
candidates: List<SubtitleCandidate>,
enabled: Boolean,
language: String,
): String? {
if (!enabled || candidates.isEmpty()) return null
val preferred = normalizeSubtitleLanguage(language)
if (preferred.isNotEmpty() && preferred != SUBTITLE_LANGUAGE_AUTO) {
bestSubtitleInLanguage(candidates, preferred)?.let { return it }
return candidates.firstOrNull { it.isForced }?.id
}
return candidates.firstOrNull { it.isForced }?.id
?: candidates.firstOrNull { it.isDefault }?.id
?: candidates.first().id
}
// Forced sorts last within a language precisely because it is not a substitute for the full
// track somebody asked for.
private fun bestSubtitleInLanguage(
candidates: List<SubtitleCandidate>,
language: String,
): String? = candidates
.filter { normalizeSubtitleLanguage(it.language) == language }
.maxByOrNull {
when {
it.isForced -> 1
it.isHearingImpaired -> 2
it.isDefault -> 5
else -> 4
}
}
?.id
internal fun PlayableSubtitle.asCandidate(): SubtitleCandidate = SubtitleCandidate(
id = id,
language = language,
isDefault = isDefault,
isForced = isForced,
isHearingImpaired = isHearingImpaired,
)
private fun subtitleExtension(codec: String?, url: String): String =
when (codec?.trim()?.lowercase()) {
"subrip" -> "srt"
@@ -0,0 +1,164 @@
package com.ponzischeme89.memby.data
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.booleanOrNull
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.intOrNull
import kotlinx.serialization.json.put
import kotlinx.serialization.json.putJsonArray
/**
* The settings that belong to a *person* rather than to a television, in the shape both
* ends of the sync agree on.
*
* What is here and what is not is the whole design. These follow a viewer to any set they
* sign into and are the ones an operator can push. Deliberately absent: the device name,
* the update source and token, the screensaver's rotation interval and ring colour, and
* the last backdrop — those describe the box in the living room, and carrying them across
* would rename someone's other television the moment they signed in on it.
*
* The class is a plain data holder with no Android or DataStore dependency so the
* conversions either side of it stay unit-testable.
*/
data class UserPreferences(
val homeSections: List<String> = DEFAULT_SECTIONS,
val homeCardDensity: String = Settings.DEFAULT_HOME_CARD_DENSITY,
val homeArtworkStyle: String = Settings.DEFAULT_HOME_ARTWORK_STYLE,
val showHomeCardMetadata: Boolean = true,
val showRatingsStrip: Boolean = true,
val hideWatchedMovies: Boolean = false,
val showTitleLogo: Boolean = true,
val welcomeQuoteStyle: String = Settings.DEFAULT_WELCOME_QUOTE_STYLE,
val autoPlayNextEpisode: Boolean = true,
val showTenMinuteReminder: Boolean = true,
/**
* Whether a subtitle track is turned on automatically, and which language wins when it
* is. These sync for the same reason the rest do — turning subtitles off in the bedroom
* should not leave them on in the living room — and the gateway is what applies them,
* since it is the thing that asks Emby which tracks exist.
*/
val subtitlesEnabled: Boolean = true,
val subtitleLanguage: String = SUBTITLE_LANGUAGE_AUTO,
/** How far Left and Right move the film, in seconds. One of [SEEK_INTERVAL_SECONDS]. */
val seekIntervalSeconds: Int = DEFAULT_SEEK_INTERVAL_SECONDS,
val forYouMinutes: Int = 0,
val homeRowOrder: List<String> = emptyList(),
val homePinnedRows: List<String> = emptyList(),
val homeHiddenRows: List<String> = emptyList(),
) {
companion object {
val DEFAULT_SECTIONS: List<String> = Settings.DEFAULT_HOME_SECTIONS.split(",")
}
}
/**
* What this television currently believes, ready to be pushed up.
*
* Reads the flat active-profile keys rather than the profile list: those are the values
* every screen actually renders from, so this is the state a viewer would recognise.
*/
fun Settings.toUserPreferences(): UserPreferences = UserPreferences(
homeSections = homeSections.decodeCommaList(),
homeCardDensity = homeCardDensity,
homeArtworkStyle = homeArtworkStyle,
showHomeCardMetadata = showHomeCardMetadata,
showRatingsStrip = showRatingsStrip,
hideWatchedMovies = hideWatchedMovies,
showTitleLogo = showTitleLogo,
welcomeQuoteStyle = welcomeQuoteStyle,
autoPlayNextEpisode = autoPlayNextEpisode,
showTenMinuteReminder = showTenMinuteReminder,
subtitlesEnabled = subtitlesEnabled,
subtitleLanguage = subtitleLanguage,
seekIntervalSeconds = normalizeSeekIntervalSeconds(seekIntervalSeconds),
forYouMinutes = forYouMinutes,
homeRowOrder = homeRowOrder.decodeLineList(),
homePinnedRows = homePinnedRows.decodeLineList(),
homeHiddenRows = homeHiddenRows.decodeLineList(),
)
internal fun String.decodeCommaList(): List<String> =
split(',').map(String::trim).filter(String::isNotEmpty)
internal fun String.decodeLineList(): List<String> =
split('\n').map(String::trim).filter(String::isNotEmpty)
/**
* Decodes the server's document, keeping this build's value for anything it does not
* recognise or cannot read.
*
* [fallback] is the current local state rather than the class defaults, and that
* distinction matters: a server response missing a key must leave that setting alone, not
* silently reset it. That is what makes it safe for the gateway to grow a setting before
* every television in the house has the release that knows about it.
*/
fun decodeUserPreferences(
json: JsonObject,
fallback: UserPreferences = UserPreferences(),
): UserPreferences = UserPreferences(
homeSections = json.stringList("homeSections", fallback.homeSections)
.ifEmpty { fallback.homeSections },
homeCardDensity = json.string("homeCardDensity", fallback.homeCardDensity),
homeArtworkStyle = json.string("homeArtworkStyle", fallback.homeArtworkStyle),
showHomeCardMetadata = json.boolean("showHomeCardMetadata", fallback.showHomeCardMetadata),
showRatingsStrip = json.boolean("showRatingsStrip", fallback.showRatingsStrip),
hideWatchedMovies = json.boolean("hideWatchedMovies", fallback.hideWatchedMovies),
showTitleLogo = json.boolean("showTitleLogo", fallback.showTitleLogo),
welcomeQuoteStyle = json.string("welcomeQuoteStyle", fallback.welcomeQuoteStyle),
autoPlayNextEpisode = json.boolean("autoPlayNextEpisode", fallback.autoPlayNextEpisode),
showTenMinuteReminder = json.boolean("showTenMinuteReminder", fallback.showTenMinuteReminder),
subtitlesEnabled = json.boolean("subtitlesEnabled", fallback.subtitlesEnabled),
subtitleLanguage = json.string("subtitleLanguage", fallback.subtitleLanguage),
// Normalised rather than trusted: the catalogue on the gateway may offer an interval
// this build has no vocabulary for, and a skip of an unknown length is worse than the
// default. A key the response does not carry leaves this television's value alone.
seekIntervalSeconds = normalizeSeekIntervalSeconds(
json.int("seekIntervalSeconds", fallback.seekIntervalSeconds),
),
forYouMinutes = json.int("forYouMinutes", fallback.forYouMinutes),
homeRowOrder = json.stringList("homeRowOrder", fallback.homeRowOrder),
homePinnedRows = json.stringList("homePinnedRows", fallback.homePinnedRows),
homeHiddenRows = json.stringList("homeHiddenRows", fallback.homeHiddenRows),
)
/** The document as the gateway expects it. The server normalises whatever arrives. */
fun UserPreferences.encode(): JsonObject = buildJsonObject {
putJsonArray("homeSections") { homeSections.forEach { add(JsonPrimitive(it)) } }
put("homeCardDensity", homeCardDensity)
put("homeArtworkStyle", homeArtworkStyle)
put("showHomeCardMetadata", showHomeCardMetadata)
put("showRatingsStrip", showRatingsStrip)
put("hideWatchedMovies", hideWatchedMovies)
put("showTitleLogo", showTitleLogo)
put("welcomeQuoteStyle", welcomeQuoteStyle)
put("autoPlayNextEpisode", autoPlayNextEpisode)
put("showTenMinuteReminder", showTenMinuteReminder)
put("subtitlesEnabled", subtitlesEnabled)
put("subtitleLanguage", subtitleLanguage)
put("seekIntervalSeconds", seekIntervalSeconds)
put("forYouMinutes", forYouMinutes)
putJsonArray("homeRowOrder") { homeRowOrder.forEach { add(JsonPrimitive(it)) } }
putJsonArray("homePinnedRows") { homePinnedRows.forEach { add(JsonPrimitive(it)) } }
putJsonArray("homeHiddenRows") { homeHiddenRows.forEach { add(JsonPrimitive(it)) } }
}
private fun JsonObject.string(key: String, fallback: String): String =
(this[key] as? JsonPrimitive)?.takeIf { it.isString }?.content ?: fallback
// A JSON string is not a boolean here: "false" arriving as text must fall back rather than
// be read as some truthy value, which is why isString is checked before the conversion.
private fun JsonObject.boolean(key: String, fallback: Boolean): Boolean =
(this[key] as? JsonPrimitive)?.takeUnless { it.isString }?.booleanOrNull ?: fallback
private fun JsonObject.int(key: String, fallback: Int): Int =
(this[key] as? JsonPrimitive)?.takeUnless { it.isString }?.intOrNull ?: fallback
private fun JsonObject.stringList(key: String, fallback: List<String>): List<String> {
val array = this[key] as? JsonArray ?: return fallback
return array.mapNotNull { element ->
(element as? JsonPrimitive)?.takeIf { it.isString }?.content?.trim()?.takeIf(String::isNotEmpty)
}.distinct()
}
@@ -37,6 +37,8 @@ data class UserItemData(
@SerialName("Played") val played: Boolean = false,
@SerialName("PlaybackPositionTicks") val playbackPositionTicks: Long = 0,
@SerialName("UnplayedItemCount") val unplayedItemCount: Int? = null,
/** When this item was last played. What orders the merged Continue Watching row. */
@SerialName("LastPlayedDate") val lastPlayedDate: String? = null,
)
@Serializable
@@ -314,6 +316,10 @@ data class BaseItem(
@SerialName("Overview") val overview: String? = null,
@SerialName("Taglines") val taglines: List<String> = emptyList(),
@SerialName("ProductionYear") val productionYear: Int? = null,
// The day a title first aired or was released, as Emby's ISO-8601 string. An episode's
// is the one date that distinguishes it from its neighbours in a list, so it is asked
// for by name in every episode query — it is not a default field.
@SerialName("PremiereDate") val premiereDate: String? = null,
@SerialName("OfficialRating") val officialRating: String? = null,
@SerialName("CommunityRating") val communityRating: Double? = null,
@SerialName("Studios") val studios: List<Studio> = emptyList(),
@@ -328,6 +334,12 @@ data class BaseItem(
@SerialName("ImageTags") val imageTags: Map<String, String> = emptyMap(),
@SerialName("SeriesId") val seriesId: String? = null,
@SerialName("SeriesName") val seriesName: String? = null,
/**
* Emby's production status for a series — "Continuing" or "Ended". Only the detail
* call asks for it; a row item carries none, which is why [isOngoingSeries] treats
* absence as "not known to be running" rather than guessing either way.
*/
@SerialName("Status") val status: String? = null,
// Emby returns these on episodes without being asked, so they cost no extra Fields.
@SerialName("IndexNumber") val indexNumber: Int? = null,
@SerialName("ParentIndexNumber") val parentIndexNumber: Int? = null,
@@ -346,7 +358,16 @@ data class BaseItem(
@SerialName("MembyAirLabel") val membyAirLabel: String? = null,
@SerialName("MembyAvailability") val membyAvailability: String? = null,
@SerialName("MembyAvailabilityText") val membyAvailabilityText: String? = null,
// Sonarr's or Radarr's own lifecycle for the title — whether more episodes are coming,
// whether the film has actually been released. Distinct from availability, which is
// about the household's copy. The slug is what the badge colours by; the text is the
// gateway's wording, so a status this build predates still reads correctly.
@SerialName("MembyLifecycle") val membyLifecycle: String? = null,
@SerialName("MembyLifecycleText") val membyLifecycleText: String? = null,
@SerialName("MembyPlayable") val membyPlayable: Boolean = true,
// 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,
// 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.
@@ -360,6 +381,11 @@ data class BaseItem(
@SerialName("MembyRecommendationReasonCodes")
val membyRecommendationReasonCodes: List<String> = emptyList(),
@SerialName("MembyExploration") val membyExploration: Boolean = false,
// External ratings the gateway already had stored for this title. They ride on the
// card so a row can draw its scores as it appears rather than when focus reaches it;
// an item the gateway has never looked up carries none and the dedicated ratings
// request still fills it in. Defaulted, so a cached home payload decodes unchanged.
@SerialName("MembyRatings") val membyRatings: List<MediaRating> = emptyList(),
) {
val isMovie: Boolean get() = type.equals("Movie", ignoreCase = true)
val isSeries: Boolean get() = type.equals("Series", ignoreCase = true)
@@ -368,6 +394,18 @@ data class BaseItem(
val isTvSchedule: Boolean get() = membySource == "sonarr"
val isMovieSchedule: Boolean get() = membySource == "radarr"
val isSchedule: Boolean get() = isTvSchedule || isMovieSchedule
/**
* 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
* [status] is the fallback and the only source the direct path has. Neither present
* means no, which keeps the pace estimate saying "finish" — the weaker claim.
*/
val isOngoingSeries: Boolean
get() = when {
membyLifecycle != null -> membyLifecycle.equals("continuing", ignoreCase = true)
else -> status.equals("Continuing", ignoreCase = true)
}
val cast: List<EmbyPerson> get() = people.filter(EmbyPerson::isCastMember)
/** "S2 · E5" when the season is known, "E5" when only the episode is, else null. */
@@ -67,7 +67,13 @@ data class HomeRow(
data class GatewayHome(
/** Server-composed rows, in display order. */
val rows: List<HomeRow> = emptyList(),
/** In progress, including the episode after one just finished — they are one row. */
val continueWatching: List<BaseItem> = emptyList(),
/**
* Still sent, and still merged into [continueWatching] by the gateway. It remains on
* the wire for televisions running a build that predates the merge, which compose a
* Next Up row of their own from it.
*/
val nextUp: List<BaseItem> = emptyList(),
val favorites: List<BaseItem> = emptyList(),
val latestMovies: List<BaseItem> = emptyList(),
@@ -118,6 +124,67 @@ data class GatewayServiceStatus(
val featureRevision: Long = 0,
val safeMode: Boolean = false,
val features: Map<String, Boolean> = emptyMap(),
/**
* Whether *Emby* is answering, as opposed to whether the gateway is. Absent from a
* server older than this field, which is why it defaults to a healthy, unmonitored
* reading rather than to null — an app that cannot tell must not claim an outage.
*/
val emby: GatewayEmbyHealth = GatewayEmbyHealth(),
/**
* Revision of this viewer's server-held settings. The app compares it with what it
* has and fetches the document only when they differ, so an operator's push arrives
* on the poll the app is already making.
*/
val preferencesRevision: Long = 0,
)
/**
* The gateway's live reading of its own connection to Emby.
*
* [monitored] is the load-bearing field: with the probe switched off nothing updates
* [reachable], so a client that ignored this would show a permanent, wrong red bar. The
* server also sends `reachable = true` in that case, but the app should not depend on both
* halves of a defence.
*/
@Serializable
data class GatewayEmbyHealth(
val monitored: Boolean = false,
val reachable: Boolean = true,
/** When the current state began, RFC3339. */
val since: String = "",
/** When the last probe ran, RFC3339. The retry countdown is measured from here. */
val checkedAt: String = "",
val retrySeconds: Int = 0,
) {
/** An outage worth telling the viewer about: monitored, and currently failing. */
val isOutage: Boolean get() = monitored && !reachable
}
/**
* A viewer's settings as the gateway holds them, so they follow the person to whichever
* television they sit in front of.
*
* [preferences] stays a [JsonObject] on the wire rather than a typed class on purpose: a
* server that has learned a new setting must not make this response undecodable on an app
* that has not. [com.ponzischeme89.memby.data.UserPreferences] is where it becomes typed,
* and everything it does not recognise is carried through untouched.
*/
@Serializable
data class GatewayPreferences(
val schemaVersion: Int = 0,
val revision: Long = 0,
val updatedAt: String = "",
/** "device" or "admin" — who wrote it last. */
val source: String = "",
val preferences: kotlinx.serialization.json.JsonObject =
kotlinx.serialization.json.JsonObject(emptyMap()),
)
/** A write of [GatewayPreferences]. [revision] is the one being edited, for conflict detection. */
@Serializable
data class GatewayPreferencesRequest(
val revision: Long,
val preferences: kotlinx.serialization.json.JsonObject,
)
@Serializable
@@ -168,10 +235,9 @@ data class GatewayRows(
val rows: List<HomeRow> = emptyList(),
)
/** Server-formatted external movie rating. The TV renders these fields verbatim and
* never needs to know which providers are enabled or how their scales work. */
/** Normalised third-party rating shared by cards, banners, and detail pages. */
@Serializable
data class GatewayMovieRating(
data class MediaRating(
val source: String = "",
val name: String = "",
val score: String = "",
@@ -180,19 +246,40 @@ data class GatewayMovieRating(
@Serializable
data class GatewayMovieRatings(
val ratings: List<GatewayMovieRating> = emptyList(),
val ratings: List<MediaRating> = emptyList(),
)
@Deprecated("Use MediaRating")
typealias GatewayMovieRating = MediaRating
@Serializable
data class RecommendationOnboarding(
val completed: Boolean = false,
// Defaults true for compatibility with gateways released before prompting became
// server-controlled; current gateways always send the field explicitly.
val prompted: Boolean = true,
val ratings: Map<String, Int> = emptyMap(),
val items: List<BaseItem> = emptyList(),
val movies: List<BaseItem> = emptyList(),
val shows: List<BaseItem> = emptyList(),
val actors: List<RecommendationPerson> = emptyList(),
val actresses: List<RecommendationPerson> = emptyList(),
val directors: List<RecommendationPerson> = emptyList(),
)
@Serializable
data class RecommendationPreferences(
val ratings: Map<String, Int> = emptyMap(),
val actors: List<String> = emptyList(),
val actresses: List<String> = emptyList(),
val directors: List<String> = emptyList(),
)
@Serializable
data class RecommendationPerson(
val id: String = "",
val name: String = "",
val imageTag: String = "",
)
@Serializable
@@ -277,9 +364,64 @@ data class GatewayPlayback(
val url: String,
val resumePositionMs: Long = 0,
val subtitles: List<com.ponzischeme89.memby.data.PlayableSubtitle> = emptyList(),
// Which of those tracks the gateway decided to turn on, from the viewer's synced
// settings. Absent on an older gateway, which is what the defaults describe: subtitles
// on, nothing chosen, so the television falls back to its own flag-driven pick.
val subtitlesEnabled: Boolean = true,
val selectedSubtitleId: String = "",
val mediaSourceId: String = "",
val playSessionId: String = "",
val playMethod: String = "DirectPlay",
// Whether this gateway can fetch a subtitle the title does not have. It rides on the
// playback response rather than the status poll because the drop-up is the only thing
// that asks and it already holds this. Absent on an older gateway, and the default
// must stay false: a missing field must never conjure a row that cannot do anything.
val subtitleDownloadAvailable: Boolean = false,
)
/** One subtitle a viewer can choose to download, as the gateway offers it. */
@Serializable
data class GatewaySubtitleCandidate(
// Bazarr's opaque provider handle. It round-trips untouched — nothing on this side
// parses it, and reconstructing it from the other fields would break the download.
val token: String = "",
val language: String = "",
val languageLabel: String = "",
val provider: String = "",
val score: Int = 0,
val forced: Boolean = false,
val hearingImpaired: Boolean = false,
val originalFormat: Boolean = false,
// What the row prints. Composed by the gateway so an app that predates a new wording
// still renders it correctly, the same reason alert labels are the gateway's.
val label: String = "",
)
@Serializable
data class GatewaySubtitleSearch(
val results: List<GatewaySubtitleCandidate> = emptyList(),
// Why there are none, when there are none. "Nothing was found" and "Memby could not
// work out which title this is" are different answers and a viewer deserves to know
// which one they got.
val message: String = "",
)
@Serializable
data class GatewaySubtitleDownloadRequest(val candidate: GatewaySubtitleCandidate)
/**
* The result of a download: the item's tracks re-read from Emby after it was told to look
* again, so the player can swap its media item and turn the new track on without a second
* round trip.
*/
@Serializable
data class GatewaySubtitleDownload(
val message: String = "",
val subtitles: List<com.ponzischeme89.memby.data.PlayableSubtitle> = emptyList(),
val selectedSubtitleId: String = "",
val mediaSourceId: String = "",
val playSessionId: String = "",
val url: String = "",
)
/**
@@ -293,6 +435,8 @@ data class GatewayNextEpisode(
val url: String,
val resumePositionMs: Long = 0,
val subtitles: List<com.ponzischeme89.memby.data.PlayableSubtitle> = emptyList(),
val subtitlesEnabled: Boolean = true,
val selectedSubtitleId: String = "",
val mediaSourceId: String = "",
val playSessionId: String = "",
val playMethod: String = "DirectPlay",
@@ -0,0 +1,35 @@
package com.ponzischeme89.memby.data.model
fun MediaRating.wordmark(): String = when (source.lowercase()) {
"tomatoes" -> "RT"
"audience" -> "RT Audience"
"metacritic" -> "Metacritic"
"letterboxd" -> "Letterboxd"
"mal" -> "MAL"
"anilist" -> "AniList"
"anidb" -> "AniDB"
"kitsu" -> "Kitsu"
"imdb" -> "IMDb"
"tmdb" -> "TMDb"
"trakt" -> "Trakt"
else -> name.trim()
}
fun MediaRating.formattedScore(): String = when (source.lowercase()) {
"tomatoes", "audience", "trakt", "anilist", "kitsu" -> "$score%"
else -> score
}
fun List<MediaRating>.displayable(): List<MediaRating> = filter { rating ->
rating.wordmark().isNotBlank() && rating.score.toDoubleOrNull()?.let { it > 0.0 } == true
}.distinctBy { it.source.lowercase() }
fun ratingDisplayLimit(widthDp: Int): Int = when {
widthDp < 120 -> 1
widthDp < 220 -> 2
widthDp < 360 -> 3
else -> Int.MAX_VALUE
}
fun ratingsStripVisible(enabled: Boolean, ratings: List<MediaRating>): Boolean =
enabled && ratings.displayable().isNotEmpty()
@@ -20,6 +20,14 @@ interface EmbyApi {
@POST("Users/AuthenticateByName")
suspend fun authenticate(@Body body: AuthRequest): AuthResult
/**
* The reachability probe on the direct path. Unauthenticated and tiny by design: a
* probe that needed a token would report a stale session as a server outage, and the
* response is discarded — only whether it arrived is the answer.
*/
@GET("System/Info/Public")
suspend fun systemInfoPublic(): okhttp3.ResponseBody
@GET("Users/{userId}/Items")
suspend fun getItems(
@Path("userId") userId: String,
@@ -61,8 +61,11 @@ private class EmbyAuthInterceptor(
override fun intercept(chain: Interceptor.Chain): Response {
val deviceId = deviceIdProvider()
// Version comes from the build, so Emby's device list shows which release a TV
// is actually running.
val authHeader = "MediaBrowser Client=\"Memby\", " +
// is actually running. The client name is deliberately not the app's own — this
// header leaves the house with whatever Emby does with its logs — and must match
// what the gateway sends (MEMBY_CLIENT_NAME), or one television signing in both
// ways would appear as two clients.
val authHeader = "MediaBrowser Client=\"MbyATV\", " +
"Device=\"Android TV\", DeviceId=\"$deviceId\", Version=\"${BuildConfig.VERSION_NAME}\""
val builder = chain.request().newBuilder()
@@ -141,6 +141,19 @@ interface GatewayApi {
@GET("v1/features")
suspend fun features(): GatewayFeatures
/** This viewer's settings as the server holds them, for whichever TV they sit at. */
@GET("v1/preferences")
suspend fun preferences(): com.ponzischeme89.memby.data.model.GatewayPreferences
/**
* Writes them back. A 409 means somebody else — usually the operator — got there
* first; the response body is their document, so the caller adopts rather than retries.
*/
@PUT("v1/preferences")
suspend fun savePreferences(
@Body body: com.ponzischeme89.memby.data.model.GatewayPreferencesRequest,
): com.ponzischeme89.memby.data.model.GatewayPreferences
@GET("v1/items/{id}")
suspend fun item(@Path("id") itemId: String): BaseItem
@@ -173,6 +186,24 @@ interface GatewayApi {
@Query("forceTranscode") forceTranscode: Boolean = false,
): GatewayPlayback
/**
* Ask the subtitle service for tracks this title does not have. This is a live query
* against subtitle providers, so it is slow by nature — seconds, not milliseconds.
* 404 when the gateway has no subtitle service or the operator has turned it off.
*/
@GET("v1/items/{id}/subtitles/search")
suspend fun searchSubtitles(
@Path("id") itemId: String,
@Query("language") language: String? = null,
): com.ponzischeme89.memby.data.model.GatewaySubtitleSearch
/** Fetch one of those tracks. The gateway waits for Emby to notice the new file. */
@POST("v1/items/{id}/subtitles/download")
suspend fun downloadSubtitle(
@Path("id") itemId: String,
@Body body: com.ponzischeme89.memby.data.model.GatewaySubtitleDownloadRequest,
): com.ponzischeme89.memby.data.model.GatewaySubtitleDownload
/** 404 when nothing follows this item: a movie, or a series finale. */
@GET("v1/items/{id}/next")
suspend fun nextEpisode(
@@ -74,6 +74,9 @@ internal val MEMBY_CAPABILITIES = listOf(
"live_feature_refresh_v1",
"sonarr_preroll_v1",
"auto_my_shows_v1",
// Declares that this build can show the install-permission step. An older app never
// receives the feature, so the operator cannot push a screen it does not have.
"install_permission_v1",
)
internal const val HEVC_DECODE_CAPABILITY = "video_hevc_decode"
@@ -55,6 +55,11 @@ 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.input.key.Key
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
@@ -74,12 +79,12 @@ 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.GatewayMovieRating
import com.ponzischeme89.memby.data.model.MediaRating
import com.ponzischeme89.memby.ui.detail.AiringNotice
import com.ponzischeme89.memby.ui.detail.DetailTab
import com.ponzischeme89.memby.ui.detail.DetailZone
import com.ponzischeme89.memby.ui.detail.TechnicalSpec
import com.ponzischeme89.memby.ui.detail.formatRuntime
import com.ponzischeme89.memby.ui.detail.ratingLabel
import com.ponzischeme89.memby.ui.theme.FactSeparator
import com.ponzischeme89.memby.ui.theme.MembyAccent
import com.ponzischeme89.memby.ui.theme.MembyCardCorner
@@ -88,7 +93,6 @@ import com.ponzischeme89.memby.ui.theme.MembyMutedText
import com.ponzischeme89.memby.ui.theme.MembyOnSurface
import com.ponzischeme89.memby.ui.theme.MembyPanelCorner
import com.ponzischeme89.memby.ui.theme.MembyQuietText
import com.ponzischeme89.memby.ui.theme.MembyScore
import com.ponzischeme89.memby.ui.theme.MembySurface
import com.ponzischeme89.memby.ui.theme.ValueSeparator
import kotlinx.coroutines.launch
@@ -102,7 +106,14 @@ internal val DetailMutedText = MembyMutedText
internal val DetailQuietText = MembyQuietText
internal val DetailHairline = MembyHairline
internal val DetailSideGutter = 58.dp
private val DetailTabHeight = 66.dp
/**
* The band anchored under the hero: the tab strip on a movie or series page, the season
* scroller on an episode's. One height for both, because the fold it defines is the same
* fold, and a page whose content starts at a different place depending on what it is about
* reads as two designs.
*/
internal val DetailStripHeight = 66.dp
/**
* How much of the content pane is left showing under the tab strip.
@@ -125,6 +136,44 @@ private val DetailFoldPeek = 34.dp
internal fun detailPaneHeight(viewportHeight: Dp): Dp =
(viewportHeight - 132.dp).coerceIn(250.dp, 420.dp)
/**
* Hands focus to the first target that is actually on screen, and says whether any took it.
*
* `focusProperties { down = … }` names exactly one node and throws when that node is not
* attached — which on these pages is an ordinary state rather than an error. An episode
* page has no season chips until the seasons arrive, a season scrolled out of its LazyRow
* is not composed, a tab can be selected before its pane holds anything focusable. Down
* has to do the obvious thing in every one of those cases, so the direction keys name a
* *list* of places to try and fall through to the next rather than going dead under
* somebody's thumb.
*/
internal fun focusFirstAvailable(vararg targets: FocusRequester?): Boolean {
targets.forEach { target ->
if (target != null && runCatching { target.requestFocus() }.isSuccess) return true
}
return false
}
/**
* Vertical navigation stated as intent rather than as a destination.
*
* Deliberately `onKeyEvent` and not the preview: a press is offered to whatever holds
* focus first, so a control that means something of its own by Up or Down keeps it, and
* only an otherwise unhandled press is routed. Returning false leaves Compose's own focus
* search to try, which is the right last resort. Left and Right are never touched.
*/
internal fun Modifier.onVerticalNavigation(
up: (() -> Boolean)? = null,
down: (() -> Boolean)? = null,
): Modifier = onKeyEvent { event ->
if (event.type != KeyEventType.KeyDown) return@onKeyEvent false
when (event.key) {
Key.DirectionUp -> up?.invoke() ?: false
Key.DirectionDown -> down?.invoke() ?: false
else -> false
}
}
internal data class DetailHeroAction(
val icon: ImageVector,
val description: String,
@@ -179,19 +228,58 @@ internal fun DetailPageScaffold(
item: BaseItem,
facts: List<String>,
badges: List<String>,
tabs: List<DetailTab>,
selectedTab: DetailTab,
onSelectTab: (DetailTab) -> Unit,
playLabel: String,
onPlay: () -> Unit,
playFocusRequester: FocusRequester,
tabFocusRequester: FocusRequester,
contentFocusRequester: FocusRequester,
modifier: Modifier = Modifier,
tabs: List<DetailTab> = emptyList(),
selectedTab: DetailTab = DetailTab.OVERVIEW,
onSelectTab: (DetailTab) -> Unit = {},
/**
* Replaces the tab strip in the band under the hero. An episode page has one thing to
* navigate — the seasons — so it supplies its own scroller rather than pretending to be
* a set of tabs; the frame, the fold and the focus contract stay identical either way.
* It is handed the requester to hand focus back to above it, the one to send focus to
* below it, and the callback that pins the page to the strip.
*/
strip: (@Composable (
heroFocusRequester: FocusRequester,
contentFocusRequester: FocusRequester,
onFocused: () -> Unit,
) -> Unit)? = null,
eyebrow: String? = null,
subtitle: String? = null,
/**
* The heading when the item has no logo to show. An episode's page is *about* the
* episode but *headed* by the show, so it passes the series name here — without it the
* fallback prints the episode's title twice, once as the heading and once under the
* number.
*/
title: String? = null,
progress: Float = 0f,
progressLabel: String? = null,
/**
* "Estimated finish: 18 August" — how the viewer's own pace projects onto what is left
* of a series. It sits with the progress information rather than beside it, and is
* deliberately quiet: it is a nicety on a page whose job is Play.
*
* Its own line rather than an addition to [progressLabel], because the progress bar
* appears only for a part-watched episode and the estimate is at its most useful for
* somebody who finished one last night and has not started the next.
*/
paceLabel: String? = null,
reasons: List<String> = emptyList(),
ratings: List<GatewayMovieRating> = emptyList(),
/**
* Set only when the page was opened from the "Shows airing" row, and it takes the
* accent line the recommendation reason would otherwise have: someone who arrived by
* pressing "Thursday, 9pm" came for the schedule, not for why the engine likes the
* show. Never focusable, like the reason it replaces.
*/
airingNotice: AiringNotice? = null,
ratings: List<MediaRating> = emptyList(),
showRatingsStrip: Boolean = true,
heroActions: List<DetailHeroAction> = emptyList(),
confirmation: String? = null,
pageListState: LazyListState = remember(item.id) { LazyListState() },
@@ -212,6 +300,17 @@ internal fun DetailPageScaffold(
} else {
playFocusRequester
}
// The band and the pane as a whole, so a press can reach them when the one node they
// would rather land on — the selected tab, the season being watched, the first
// episode card — is not composed on this frame.
val stripEntryRequester = remember(item.id) { FocusRequester() }
val contentEntryRequester = remember(item.id) { FocusRequester() }
// Down out of the hero: the strip, and failing that the content under it. A band with
// nothing in it yet is a thing to pass through, not a thing to stop at.
val enterStripFromHero = {
focusFirstAvailable(tabFocusRequester, stripEntryRequester, contentFocusRequester, contentEntryRequester)
}
val enterContent = { focusFirstAvailable(contentFocusRequester, contentEntryRequester) }
fun reveal(index: Int, offset: Int = 0) {
scope.launch { pageListState.animateScrollToItem(index, offset) }
}
@@ -243,7 +342,7 @@ internal fun DetailPageScaffold(
// The opening composition is one deliberate TV frame: hero above, tabs anchored
// to its bottom edge. Content begins below the fold and only enters when the
// viewer presses Down from the tabs.
val heroHeight = (maxHeight - DetailTabHeight - DetailFoldPeek).coerceAtLeast(340.dp)
val heroHeight = (maxHeight - DetailStripHeight - DetailFoldPeek).coerceAtLeast(340.dp)
val paneHeight = detailPaneHeight(maxHeight)
LazyColumn(
state = pageListState,
@@ -254,14 +353,20 @@ internal fun DetailPageScaffold(
item = item,
facts = facts,
badges = badges,
eyebrow = eyebrow,
subtitle = subtitle,
title = title ?: item.name,
playLabel = playLabel,
onPlay = onPlay,
playFocusRequester = playFocusRequester,
tabFocusRequester = tabFocusRequester,
onNavigateDown = enterStripFromHero,
progress = progress,
progressLabel = progressLabel,
paceLabel = paceLabel,
reasons = reasons,
airingNotice = airingNotice,
ratings = ratings,
showRatingsStrip = showRatingsStrip,
actions = heroActions,
actionRequesters = actionRequesters,
height = heroHeight,
@@ -280,19 +385,36 @@ internal fun DetailPageScaffold(
)
}
item(key = "tabs") {
DetailTabStrip(
tabs = tabs,
selected = selectedTab,
onSelect = onSelectTab,
selectedFocusRequester = tabFocusRequester,
heroFocusRequester = heroReturn,
contentFocusRequester = contentFocusRequester,
onFocused = {
focusedZone = DetailZone.TABS
onZoneFocused(DetailZone.TABS)
reveal(1, -18)
},
)
val onStripFocused = {
focusedZone = DetailZone.TABS
onZoneFocused(DetailZone.TABS)
reveal(1, -18)
}
// The band as one focus group, so a press that cannot reach the exact
// stop it wanted still arrives somewhere in the strip.
Box(
Modifier
.focusRequester(stripEntryRequester)
.focusGroup()
.onVerticalNavigation(
up = { focusFirstAvailable(heroReturn, playFocusRequester) },
down = enterContent,
),
) {
if (strip != null) {
strip(heroReturn, contentFocusRequester, onStripFocused)
} else {
DetailTabStrip(
tabs = tabs,
selected = selectedTab,
onSelect = onSelectTab,
selectedFocusRequester = tabFocusRequester,
onExitUp = { focusFirstAvailable(heroReturn, playFocusRequester) },
onExitDown = enterContent,
onFocused = onStripFocused,
)
}
}
}
item(key = "content") {
AnimatedContent(
@@ -310,6 +432,12 @@ internal fun DetailPageScaffold(
reveal(2, -72)
}
}
.focusRequester(contentEntryRequester)
.focusGroup()
// Deliberately a focus property and not a key handler: panes
// navigate vertically inside themselves (an episode list, its
// season chips) and override this where they do. A blanket
// handler here would take Up off every card in that list.
.focusProperties { up = tabFocusRequester },
) { visibleTab ->
Box(Modifier.fillMaxSize()) { content(visibleTab) }
@@ -345,14 +473,20 @@ private fun DetailHero(
item: BaseItem,
facts: List<String>,
badges: List<String>,
eyebrow: String?,
subtitle: String?,
title: String,
playLabel: String,
onPlay: () -> Unit,
playFocusRequester: FocusRequester,
tabFocusRequester: FocusRequester,
onNavigateDown: () -> Boolean,
progress: Float,
progressLabel: String?,
paceLabel: String?,
reasons: List<String>,
ratings: List<GatewayMovieRating>,
airingNotice: AiringNotice?,
ratings: List<MediaRating>,
showRatingsStrip: Boolean,
actions: List<DetailHeroAction>,
actionRequesters: List<FocusRequester>,
height: Dp,
@@ -367,7 +501,9 @@ private fun DetailHero(
if (repository.showTitleLogo) repository.logoUrl(item, 720) else null
}
val logo = logoUrl.takeIf { !useTextTitleForLogo(it) }
Box(Modifier.fillMaxWidth().height(height)) {
// Down belongs to the hero as a whole, not to the row of buttons inside it: whatever
// in here holds focus, the press means "take me to the band under this".
Box(Modifier.fillMaxWidth().height(height).onVerticalNavigation(down = onNavigateDown)) {
DetailBackdrop(item, Modifier.fillMaxSize())
Column(
modifier = Modifier
@@ -385,7 +521,7 @@ private fun DetailHero(
)
} else {
Text(
text = item.name,
text = title,
color = Color.White,
fontSize = 38.sp,
lineHeight = 42.sp,
@@ -394,12 +530,38 @@ private fun DetailHero(
overflow = TextOverflow.Ellipsis,
)
}
Spacer(Modifier.height(10.dp))
DetailFactRow(facts = facts, badges = badges, rating = ratingLabel(item))
if (ratings.isNotEmpty()) {
Spacer(Modifier.height(8.dp))
DetailMovieRatings(ratings)
// An episode's logo belongs to its *series*, so without this the page would
// name the show and never say which episode it is about. The number goes
// first and the episode's own title second: the viewer already knows the
// show they picked, and "Season 3 · Episode 4" is what they came to confirm.
if (eyebrow != null) {
Spacer(Modifier.height(10.dp))
Text(
text = eyebrow,
color = DetailAccent,
fontSize = 13.sp,
fontWeight = FontWeight.Bold,
letterSpacing = 1.6.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
if (subtitle != null) {
Spacer(Modifier.height(6.dp))
Text(
text = subtitle,
color = Color.White,
fontSize = 26.sp,
lineHeight = 30.sp,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
Spacer(Modifier.height(10.dp))
DetailFactRow(facts = facts, badges = badges)
if (showRatingsStrip) Spacer(Modifier.height(8.dp))
RatingsStrip(ratings, visible = showRatingsStrip, reserveSpace = true)
if (item.genres.isNotEmpty()) {
Spacer(Modifier.height(8.dp))
Text(
@@ -423,7 +585,22 @@ private fun DetailHero(
Spacer(Modifier.height(12.dp))
DetailProgress(progress, progressLabel)
}
if (reasons.isNotEmpty()) {
paceLabel?.let {
// Tighter under the bar than under the synopsis: with a bar above it this
// is the second half of one thought, without one it is a line of its own.
Spacer(Modifier.height(if (progress > 0f) 6.dp else 12.dp))
Text(
text = it,
color = DetailQuietText,
fontSize = 12.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
if (airingNotice != null) {
Spacer(Modifier.height(10.dp))
AiringNoticeBand(airingNotice)
} else if (reasons.isNotEmpty()) {
Spacer(Modifier.height(10.dp))
Text(
text = reasons.first(),
@@ -438,7 +615,7 @@ private fun DetailHero(
Row(
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.focusGroup().focusProperties { down = tabFocusRequester },
modifier = Modifier.focusGroup(),
) {
MembyPlayButton(
label = playLabel,
@@ -458,49 +635,67 @@ private fun DetailHero(
}
}
/** Informational only: rating chips deliberately do not take focus, so a remote's
* navigation path remains Play/actions -> tabs. FlowRow wraps narrow layouts while
* keeping every configured and available source visible. */
@OptIn(ExperimentalLayoutApi::class)
/**
* The schedule the viewer pressed, restated on the page it opened.
*
* A tinted band rather than one more accent line: it has to be findable in the half-second
* after the page appears, by someone who chose this show *because* of when it airs. It sits
* where the recommendation reason does, immediately above the actions, so nothing below the
* hero moves.
*/
@Composable
internal fun DetailMovieRatings(
ratings: List<GatewayMovieRating>,
modifier: Modifier = Modifier,
) {
FlowRow(
modifier = modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(7.dp),
verticalArrangement = Arrangement.spacedBy(5.dp),
private fun AiringNoticeBand(notice: AiringNotice) {
val shape = RoundedCornerShape(MembyCardCorner)
Column(
modifier = Modifier
.clip(shape)
.background(DetailAccent.copy(alpha = 0.13f))
.border(1.dp, DetailAccent.copy(alpha = 0.45f), shape)
.padding(horizontal = 14.dp, vertical = 9.dp),
) {
ratings.forEach { rating ->
Row(
modifier = Modifier
.clip(RoundedCornerShape(6.dp))
.background(Color(0xB31B2025))
.border(1.dp, Color.White.copy(alpha = 0.16f), RoundedCornerShape(6.dp))
.padding(horizontal = 8.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(5.dp),
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = notice.label,
color = DetailAccent,
fontSize = 11.sp,
fontWeight = FontWeight.Bold,
letterSpacing = 1.4.sp,
maxLines = 1,
)
if (notice.headline.isNotEmpty()) {
Text(
text = rating.name,
color = DetailQuietText,
fontSize = 11.sp,
fontWeight = FontWeight.Medium,
maxLines = 1,
)
Text(
text = rating.score + rating.scale,
color = DetailText,
fontSize = 12.sp,
fontWeight = FontWeight.Bold,
text = notice.headline,
color = Color.White,
fontSize = 14.sp,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(start = 12.dp),
)
}
}
if (notice.detail.isNotEmpty()) {
Spacer(Modifier.height(3.dp))
Text(
text = notice.detail,
color = DetailText,
fontSize = 13.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
/** Compatibility entry point for previews/tests; all rendering lives in RatingsStrip. */
@Composable
internal fun DetailMovieRatings(
ratings: List<MediaRating>,
modifier: Modifier = Modifier,
) {
RatingsStrip(ratings, visible = true, modifier = modifier)
}
@Composable
private fun DetailCircularAction(
action: DetailHeroAction,
@@ -536,17 +731,12 @@ private fun DetailCircularAction(
internal fun DetailFactRow(
facts: List<String>,
badges: List<String> = emptyList(),
rating: String? = null,
modifier: Modifier = Modifier,
) {
Row(modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
val values = buildList {
addAll(facts)
rating?.let { add("$it") }
}
values.forEachIndexed { index, value ->
facts.forEachIndexed { index, value ->
if (index > 0) Text(FactSeparator, color = DetailQuietText, fontSize = 13.sp)
Text(value, color = if (value.startsWith("")) MembyScore else DetailMutedText, fontSize = 14.sp, fontWeight = FontWeight.Medium)
Text(value, color = DetailMutedText, fontSize = 14.sp, fontWeight = FontWeight.Medium)
}
// Four, not three: a 4K/HDR/HEVC movie used up the whole allowance and dropped
// the airing badge appended after them, which is the one that is news.
@@ -563,14 +753,14 @@ private fun DetailTabStrip(
selected: DetailTab,
onSelect: (DetailTab) -> Unit,
selectedFocusRequester: FocusRequester,
heroFocusRequester: FocusRequester,
contentFocusRequester: FocusRequester,
onExitUp: () -> Boolean,
onExitDown: () -> Boolean,
onFocused: () -> Unit,
) {
Box(
Modifier
.fillMaxWidth()
.height(DetailTabHeight)
.height(DetailStripHeight)
.background(DetailBackground)
.padding(horizontal = DetailSideGutter),
) {
@@ -579,16 +769,21 @@ private fun DetailTabStrip(
modifier = Modifier
.fillMaxSize()
.focusGroup()
.focusProperties { up = heroFocusRequester; down = contentFocusRequester },
.onVerticalNavigation(up = onExitUp, down = onExitDown),
horizontalArrangement = Arrangement.spacedBy(34.dp),
verticalAlignment = Alignment.Bottom,
) {
// Whatever else happens, one tab carries the requester the rest of the page
// aims Up and Down at. A selected tab that is not in the list — a key
// remembered from the other kind of item, for the one frame before it is
// resolved — must not leave the band with nothing attached to land on.
val anchor = tabs.firstOrNull { it == selected } ?: tabs.firstOrNull()
tabs.forEach { tab ->
var focused by remember(tab) { mutableStateOf(false) }
Column(
modifier = Modifier
.width(IntrinsicSize.Max)
.then(if (tab == selected) Modifier.focusRequester(selectedFocusRequester) else Modifier)
.then(if (tab == anchor) Modifier.focusRequester(selectedFocusRequester) else Modifier)
.testTag("detail-tab-${tab.key}")
.onFocusChanged {
focused = it.isFocused
@@ -0,0 +1,255 @@
package com.ponzischeme89.memby.ui
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.InfiniteRepeatableSpec
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
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.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
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.compose.ui.zIndex
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.tv.material3.Text
import com.ponzischeme89.memby.ServiceLocator
import com.ponzischeme89.memby.data.EmbyOutage
import kotlinx.coroutines.delay
import kotlin.math.ceil
private val OutageRed = Color(0xFFD6403A)
private val OutageTitle = Color(0xFFFFF1F0)
private val OutageBody = Color(0xFFF2C9C6)
/**
* Slightly taller than the news bar, because unlike that one this does not go away by
* itself and has a second line to carry. Still a strip rather than a panel: it appears
* over playback too, and whatever it says it is covering somebody's film.
*/
private val BannerHeight = 60.dp
/** The same broadcast overscan inset the alert bar uses, so the two line up. */
private val SafeAreaHorizontal = 48.dp
/**
* A red bar across the top of the screen saying Emby is not answering, with the time until
* the next attempt.
*
* It exists because of one specific asymmetry: video direct-plays from Emby while
* everything else comes from the gateway, so when Emby goes quiet the film stops and
* nothing on screen explains it — and the gateway is still up and able to say why. The
* counterpart alert in [ServiceAlertBanner] announces the *moment* it happened; this is
* the standing state, which is what a television switched on midway through an outage
* needs instead.
*
* Never focusable, and never dismissable. There is nothing for the viewer to do, and a bar
* that stole D-pad focus mid-browse would be worse than the outage it is reporting. It
* disappears on its own when Emby answers.
*/
@Composable
fun EmbyOutageBanner(suppressed: Boolean = false, modifier: Modifier = Modifier) {
// Collected here rather than passed in, for the reason the whole UI-conventions note
// gives: read one scope up and every poll recomposes the launcher.
val current by ServiceLocator.maintenance.embyOutage.collectAsStateWithLifecycle()
val outage = current?.takeUnless { suppressed }
// Hold the last value so the slide-out has something to draw.
var lastOutage by remember { mutableStateOf<EmbyOutage?>(null) }
if (outage != null) lastOutage = outage
AnimatedVisibility(
visible = outage != null,
enter = slideInVertically(tween(520, easing = FastOutSlowInEasing)) { -it } +
fadeIn(tween(420)),
// Leaves faster than it arrives, and for a happier reason: Emby came back.
exit = slideOutVertically(tween(320, easing = FastOutSlowInEasing)) { -it } +
fadeOut(tween(240)),
// Above the news bar (8f) — an outage outranks an announcement — and below the
// mandatory update screen, which owns the whole display.
modifier = modifier.zIndex(9f),
) {
lastOutage?.let { OutageBanner(it) }
}
}
/**
* Internal so previews and the screenshot test can render the bar without the drop-in
* wrapper, whose entire job is an animation a still frame says nothing about.
*/
@Composable
internal fun OutageBanner(outage: EmbyOutage, nowMillis: () -> Long = { elapsedRealtime() }) {
// One tick a second, not a frame clock: this is a number that changes once a second
// and animating it would recompose the bar sixty times for every time it changes.
val remaining by produceState(
initialValue = secondsUntil(outage.nextAttemptAtMillis, nowMillis()),
outage.nextAttemptAtMillis,
) {
while (true) {
value = secondsUntil(outage.nextAttemptAtMillis, nowMillis())
delay(1_000L)
}
}
Column(Modifier.fillMaxWidth()) {
Row(
modifier = Modifier
.fillMaxWidth()
.height(BannerHeight)
.background(
// Red, and deepest under the text. Over a paused film this bar has to
// supply its own contrast rather than borrow the picture's.
Brush.horizontalGradient(
0f to Color(0xFF7A1512),
0.55f to Color(0xF08E1A16),
1f to Color(0xD9601210),
),
)
.padding(horizontal = SafeAreaHorizontal),
verticalAlignment = Alignment.CenterVertically,
) {
OutagePulse()
Spacer(Modifier.width(16.dp))
Box(Modifier.width(3.dp).height(30.dp).background(OutageRed))
Spacer(Modifier.width(14.dp))
Column(Modifier.weight(1f)) {
Text(
"Emby Server failed to respond",
color = OutageTitle,
fontSize = 10.sp,
fontWeight = FontWeight.Bold,
letterSpacing = 1.6.sp,
maxLines = 1,
)
Spacer(Modifier.height(3.dp))
Text(
"Memby failed to connect to the Emby instance. Playback unavailable.",
color = OutageTitle,
fontSize = 14.sp,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
Spacer(Modifier.width(16.dp))
// Read directly rather than through an animated float: this changes once a
// second, so the recomposition it costs is one per second of a 60dp bar.
Text(
retryLabel(remaining),
color = OutageBody,
fontSize = 12.sp,
fontWeight = FontWeight.Medium,
maxLines = 1,
)
}
// The same rule-and-fade the news bar ends on, so the two read as one system.
Box(
Modifier
.fillMaxWidth()
.height(1.dp)
.background(
Brush.horizontalGradient(
listOf(OutageRed, OutageRed.copy(alpha = 0.35f), Color.Transparent),
),
),
)
Box(
Modifier
.fillMaxWidth()
.height(10.dp)
.background(Brush.verticalGradient(listOf(Color(0x77000000), Color.Transparent))),
)
}
}
/**
* A slow pulse in place of the Emby mark the news bar shows. It says "still trying"
* without a spinner, which on a television reads as the app being busy rather than the
* server being away.
*
* The animated value is read inside the [Canvas] lambda and never in the composable body —
* read it here and this whole bar would recompose every frame, permanently, for as long as
* the outage lasts. That is the one thing this app cannot afford on a weak TV box.
*/
@Composable
private fun OutagePulse() {
val transition = rememberInfiniteTransition(label = "outage-pulse")
val pulse = transition.animateFloat(
initialValue = 0.35f,
targetValue = 1f,
animationSpec = InfiniteRepeatableSpec(
animation = tween(1_400, easing = FastOutSlowInEasing),
repeatMode = RepeatMode.Reverse,
),
label = "outage-pulse-alpha",
)
Canvas(Modifier.size(14.dp)) {
drawCircle(color = OutageRed.copy(alpha = pulse.value), radius = size.minDimension / 2f)
}
}
/** Whole seconds until the next attempt, never negative. */
internal fun secondsUntil(deadlineMillis: Long, nowMillis: Long): Int =
ceil((deadlineMillis - nowMillis).coerceAtLeast(0L) / 1000.0).toInt()
/**
* The countdown's wording. It says what is about to happen rather than counting for its
* own sake — the only useful thing a viewer can take from this bar is that something is
* still trying and roughly when.
*/
internal fun retryLabel(secondsRemaining: Int): String = when {
secondsRemaining <= 0 -> "Retrying now…"
secondsRemaining == 1 -> "Retrying in 1s"
else -> "Retrying in ${secondsRemaining}s"
}
private fun elapsedRealtime(): Long = android.os.SystemClock.elapsedRealtime()
@TvPreview
@Composable
private fun EmbyOutageBannerPreview() {
PreviewSurface(alignment = Alignment.TopCenter) {
OutageBanner(
EmbyOutage(nextAttemptAtMillis = 42_000L, retryIntervalSeconds = 60),
nowMillis = { 0L },
)
}
}
/** The last second before the next attempt, which is a different string. */
@TvPreview
@Composable
private fun EmbyOutageBannerRetryingPreview() {
PreviewSurface(alignment = Alignment.TopCenter) {
OutageBanner(
EmbyOutage(nextAttemptAtMillis = 0L, retryIntervalSeconds = 60),
nowMillis = { 0L },
)
}
}
@@ -0,0 +1,531 @@
package com.ponzischeme89.memby.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.focusGroup
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.DoneAll
import androidx.compose.material.icons.filled.Favorite
import androidx.compose.material.icons.filled.FavoriteBorder
import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.material.icons.filled.Tv
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.testTag
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.Icon
import androidx.tv.material3.Text
import com.ponzischeme89.memby.ServiceLocator
import com.ponzischeme89.memby.data.Settings
import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.data.model.MediaRating
import com.ponzischeme89.memby.ui.detail.DetailZone
import com.ponzischeme89.memby.ui.detail.SeasonMarker
import com.ponzischeme89.memby.ui.detail.SeasonProgress
import com.ponzischeme89.memby.ui.detail.episodeAfter
import com.ponzischeme89.memby.ui.detail.episodeEyebrow
import com.ponzischeme89.memby.ui.detail.episodeHeadline
import com.ponzischeme89.memby.ui.detail.episodesForSeason
import com.ponzischeme89.memby.ui.detail.detailPositions
import com.ponzischeme89.memby.ui.detail.heroFacts
import com.ponzischeme89.memby.ui.detail.playbackProgress
import com.ponzischeme89.memby.ui.detail.primaryActionLabel
import com.ponzischeme89.memby.ui.detail.remainingLabel
import com.ponzischeme89.memby.ui.detail.seasonMarkers
import com.ponzischeme89.memby.ui.detail.seasonProgressLabel
import com.ponzischeme89.memby.ui.detail.seriesEpisodeComparator
import com.ponzischeme89.memby.ui.detail.seriesProgressLabel
import kotlinx.coroutines.delay
/**
* A single episode, opened from Continue Watching or from a show's episode list.
*
* It is the same page as a movie's and a series' — [DetailPageScaffold], the same hero, the
* same fold — with the two substitutions an episode needs. The logo belongs to the *series*,
* so the season and episode number go directly under it with the episode's own title
* beneath. And where a movie has tabs, this has the season scroller: the one thing worth
* navigating from an episode is the rest of the show, and it doubles as the answer to "where
* am I up to", which is the question somebody resuming a series actually has.
*/
@Composable
fun EpisodeDetailsOverlay(
item: BaseItem,
onPlay: (BaseItem) -> Unit,
onToggleFavorite: (BaseItem, Boolean) -> Unit,
onTogglePlayed: (BaseItem, Boolean) -> Unit,
onClose: () -> Unit,
onOpenItem: (BaseItem) -> Unit = {},
modifier: Modifier = Modifier,
) {
val repository = ServiceLocator.repository
val settings by repository.settingsFlow.collectAsState(initial = Settings.EMPTY)
var episodes by remember(item.seriesId) { mutableStateOf<List<BaseItem>?>(null) }
var loadFailed by remember(item.seriesId) { mutableStateOf(false) }
var ratings by remember(item.id) { mutableStateOf<List<MediaRating>>(emptyList()) }
// Keyed on the series, not the episode: walking from one episode's page to the next
// within the same show must not re-fetch the whole catalogue of it.
LaunchedEffect(item.seriesId) {
val seriesId = item.seriesId
if (seriesId.isNullOrBlank()) {
episodes = emptyList()
return@LaunchedEffect
}
runCatching { repository.getSeriesEpisodes(seriesId) }
.onSuccess { episodes = it.sortedWith(seriesEpisodeComparator) }
.onFailure {
loadFailed = true
episodes = emptyList()
}
}
LaunchedEffect(item.id, settings.showRatingsStrip) {
ratings = if (settings.showRatingsStrip) repository.getRatings(item) else emptyList()
}
EpisodeDetailContent(
item = item,
episodes = episodes,
loadFailed = loadFailed,
onPlay = onPlay,
onToggleFavorite = onToggleFavorite,
onTogglePlayed = onTogglePlayed,
onOpenItem = onOpenItem,
ratings = ratings,
showRatingsStrip = settings.showRatingsStrip,
modifier = modifier,
)
}
/** [episodes] is null while the series' episode list is still coming. */
@Composable
internal fun EpisodeDetailContent(
item: BaseItem,
episodes: List<BaseItem>?,
loadFailed: Boolean,
onPlay: (BaseItem) -> Unit,
onToggleFavorite: (BaseItem, Boolean) -> Unit,
onTogglePlayed: (BaseItem, Boolean) -> Unit,
modifier: Modifier = Modifier,
ratings: List<MediaRating> = emptyList(),
showRatingsStrip: Boolean = true,
onOpenItem: (BaseItem) -> Unit = {},
) {
val currentSeason = item.parentIndexNumber
val markers = remember(episodes, currentSeason) {
seasonMarkers(episodes.orEmpty(), currentSeason)
}
// The scroller opens on the season this episode is in and stays wherever the viewer
// moves it. Selecting a season browses; it never changes which episode the page is about.
var selectedSeason by remember(item.id) { mutableStateOf(currentSeason) }
LaunchedEffect(markers) {
if (markers.none { it.season == selectedSeason }) {
selectedSeason = currentSeason ?: markers.firstOrNull()?.season
}
}
val seasonEpisodes = remember(episodes, selectedSeason) {
episodesForSeason(episodes.orEmpty(), selectedSeason)
}
val upNext = remember(episodes, item.id) { episodeAfter(episodes.orEmpty(), item) }
val seriesProgress = remember(markers, currentSeason) {
seriesProgressLabel(markers, currentSeason)
}
val play = remember(item.id) { FocusRequester() }
val firstEpisode = remember(item.id) { FocusRequester() }
val emptyPane = remember(item.id) { FocusRequester() }
val seasonRequesters = remember(markers) { markers.associate { it.season to FocusRequester() } }
val episodeListState = rememberLazyListState()
val seasonListState = rememberLazyListState()
// Every focus target has to be attached on the frame the press lands. While the episode
// request is in flight there are no cards and no chips, so both directions resolve to
// whatever is actually placed and the rest falls back to ordinary focus search.
val hasEpisodeCards = seasonEpisodes.isNotEmpty()
val contentEntry = if (hasEpisodeCards) firstEpisode else emptyPane
// The strip's entry point is the selected season's own stop, not a requester of the
// scaffold's — until the episode list lands there is no stop to land on, and Down from
// Play has to fall back to ordinary focus search rather than throw.
val selectedSeasonChip = seasonRequesters[selectedSeason]
val stripEntry = selectedSeasonChip ?: FocusRequester.Default
LaunchedEffect(markers, selectedSeason) {
val index = markers.indexOfFirst { it.season == selectedSeason }
if (index >= 0) runCatching { seasonListState.scrollToItem(index) }
}
// Open the list on the episode the page is about rather than at the top of the season.
// Episode 9 of a twelve-part season is otherwise nine presses below the fold, on the
// one screen where the viewer has already said which episode they mean.
LaunchedEffect(seasonEpisodes, item.id) {
val index = seasonEpisodes.indexOfFirst { it.id == item.id }
if (index > 0) runCatching { episodeListState.scrollToItem(index) }
}
RestoreDetailFocus(
itemId = item.id,
zone = DetailZone.PLAY,
play = play,
tabStrip = stripEntry,
related = firstEpisode,
relatedReady = hasEpisodeCards,
content = contentEntry,
contentReady = true,
)
var confirmation by remember(item.id) { mutableStateOf<String?>(null) }
LaunchedEffect(confirmation) {
if (confirmation != null) {
delay(1_800L)
confirmation = null
}
}
DetailPageScaffold(
item = item,
facts = heroFacts(item),
badges = mediaBadges(item),
eyebrow = episodeEyebrow(item),
subtitle = item.name.takeIf(String::isNotBlank),
title = item.seriesName?.takeIf(String::isNotBlank) ?: item.name,
playLabel = primaryActionLabel(item),
onPlay = { onPlay(item) },
playFocusRequester = play,
tabFocusRequester = stripEntry,
contentFocusRequester = contentEntry,
modifier = modifier,
progress = playbackProgress(item),
progressLabel = remainingLabel(item),
// An episode inherits no explanation of its own; what the page can say about what
// follows is more use than a taste the engine learned about the show.
reasons = listOfNotNull(upNext?.let { "Up next ${episodeHeadline(it)}" }),
ratings = ratings,
showRatingsStrip = showRatingsStrip,
confirmation = confirmation,
onZoneFocused = { zone -> detailPositions.update(item.id) { it.copy(zone = zone) } },
strip = { _, _, onFocused ->
SeasonScroller(
markers = markers,
selectedSeason = selectedSeason,
currentSeason = currentSeason,
seriesProgress = seriesProgress,
selectedFocusRequester = selectedSeasonChip,
seasonFocusRequesters = seasonRequesters,
listState = seasonListState,
onSelect = { selectedSeason = it },
onFocused = onFocused,
)
},
heroActions = buildList {
add(
DetailHeroAction(
// A heart, not a tick: "Mark watched" beside it is a tick as well. The
// heart is what a home card and the screensaver already use.
icon = if (item.isFavorite) Icons.Default.Favorite else Icons.Default.FavoriteBorder,
description = if (item.isFavorite) "Remove from Favourites" else "Add to Favourites",
active = item.isFavorite,
onClick = {
val desired = !item.isFavorite
onToggleFavorite(item, desired)
confirmation = if (desired) "Added to Favourites" else "Removed from Favourites"
},
),
)
add(
DetailHeroAction(
icon = Icons.Default.DoneAll,
description = if (item.userData?.played == true) "Mark unwatched" else "Mark watched",
active = item.userData?.played == true,
onClick = { onTogglePlayed(item, item.userData?.played != true) },
),
)
// The show this episode belongs to, so the full series page is one press away
// rather than a walk back through Continue Watching.
item.seriesId?.takeIf(String::isNotBlank)?.let { seriesId ->
add(
DetailHeroAction(
icon = Icons.Default.Tv,
description = "Open ${item.seriesName ?: "the series"}",
onClick = {
onOpenItem(
BaseItem(
id = seriesId,
name = item.seriesName.orEmpty(),
type = "Series",
),
)
},
),
)
}
},
) {
EpisodeSeasonPane(
episodes = episodes,
seasonEpisodes = seasonEpisodes,
currentEpisodeId = item.id,
loadFailed = loadFailed,
firstEpisodeFocusRequester = firstEpisode,
emptyFocusRequester = emptyPane,
aboveEpisodes = stripEntry,
listState = episodeListState,
onPlay = onPlay,
)
}
}
/**
* The band under the hero: every season the library holds, with the viewer's place in the
* show marked on it.
*
* A season behind the one being watched is dimmed and ticked. That is the whole point of
* the component — it answers "where am I up to" at a glance, which is what somebody
* resuming a long show is actually asking, and it does it without a tab strip's promise
* that each stop is a different *kind* of content.
*/
@Composable
private fun SeasonScroller(
markers: List<SeasonMarker>,
selectedSeason: Int?,
currentSeason: Int?,
seriesProgress: String?,
selectedFocusRequester: FocusRequester?,
seasonFocusRequesters: Map<Int, FocusRequester>,
listState: LazyListState,
onSelect: (Int) -> Unit,
onFocused: () -> Unit,
) {
Box(
Modifier
.fillMaxWidth()
.height(DetailStripHeight)
.background(DetailBackground)
.padding(horizontal = DetailSideGutter),
) {
Box(Modifier.align(Alignment.BottomStart).fillMaxWidth().height(1.dp).background(DetailHairline))
Row(
modifier = Modifier.fillMaxSize(),
verticalAlignment = Alignment.Bottom,
) {
if (markers.isEmpty()) {
Text(
text = "Loading seasons…",
color = DetailQuietText,
fontSize = 13.sp,
modifier = Modifier.weight(1f).padding(bottom = 16.dp),
)
} else {
LazyRow(
state = listState,
// Up and Down out of the band belong to the scaffold, which knows
// where to fall back to when the exact stop it wants is not composed
// — a season chip scrolled out of this row is an ordinary state.
modifier = Modifier
.weight(1f)
.fillMaxSize()
.focusGroup(),
horizontalArrangement = Arrangement.spacedBy(28.dp),
verticalAlignment = Alignment.Bottom,
) {
items(markers, key = { it.season }) { marker ->
SeasonStop(
marker = marker,
selected = marker.season == selectedSeason,
watching = marker.season == currentSeason,
focusRequester = if (marker.season == selectedSeason) {
selectedFocusRequester
} else {
seasonFocusRequesters[marker.season]
},
onSelect = { onSelect(marker.season) },
onFocused = onFocused,
)
}
}
}
seriesProgress?.let {
Text(
text = it,
color = DetailQuietText,
fontSize = 12.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(start = 18.dp, bottom = 15.dp),
)
}
// Same caption on the Down key the tab strip carries; never focusable.
Icon(
Icons.Default.KeyboardArrowDown,
contentDescription = null,
tint = DetailQuietText,
modifier = Modifier.padding(start = 12.dp, bottom = 14.dp).size(18.dp),
)
}
}
}
@Composable
private fun SeasonStop(
marker: SeasonMarker,
selected: Boolean,
watching: Boolean,
focusRequester: FocusRequester?,
onSelect: () -> Unit,
onFocused: () -> Unit,
) {
var focused by remember(marker.season) { mutableStateOf(false) }
val done = marker.progress == SeasonProgress.WATCHED
val labelColour = when {
selected || focused -> Color.White
// Behind the viewer: stated, not hidden. The tick beside it is what says why it
// is quieter than the rest, or a dimmed season reads as one that failed to load.
done -> DetailQuietText.copy(alpha = 0.55f)
else -> DetailQuietText
}
Column(
modifier = Modifier
.width(IntrinsicSize.Max)
.then(if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier)
.testTag("season-stop-${marker.season}")
.onFocusChanged {
focused = it.isFocused
if (it.isFocused) { onSelect(); onFocused() }
}
.clickable(onClick = onSelect),
horizontalAlignment = Alignment.Start,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(start = 4.dp, end = 4.dp),
) {
if (done) {
Icon(
Icons.Default.Check,
contentDescription = "Watched",
tint = if (selected || focused) DetailAccent else DetailAccent.copy(alpha = 0.6f),
modifier = Modifier.size(14.dp).padding(end = 1.dp),
)
Spacer(Modifier.width(5.dp))
}
Text(
text = marker.label,
color = labelColour,
fontSize = 15.sp,
fontWeight = if (selected || focused) FontWeight.Bold else FontWeight.Medium,
maxLines = 1,
)
if (watching) {
Spacer(Modifier.width(7.dp))
Text(
text = "WATCHING",
color = DetailAccent,
fontSize = 9.sp,
fontWeight = FontWeight.Bold,
letterSpacing = 0.8.sp,
maxLines = 1,
modifier = Modifier
.background(DetailAccent.copy(alpha = 0.16f), androidx.compose.foundation.shape.RoundedCornerShape(4.dp))
.padding(horizontal = 5.dp, vertical = 2.dp),
)
}
}
seasonProgressLabel(marker)?.let {
Text(
text = it,
color = if (done) DetailQuietText.copy(alpha = 0.5f) else DetailQuietText,
fontSize = 10.sp,
maxLines = 1,
modifier = Modifier.padding(start = 4.dp, end = 4.dp, top = 2.dp, bottom = 11.dp),
)
}
Box(
Modifier.fillMaxWidth().height(3.dp).background(
if (selected || focused) DetailAccent else Color.Transparent,
),
)
}
}
/**
* The pane under the scroller: the selected season's episodes, this one flagged.
*
* Internal so a screenshot test can render it at [detailPaneHeight] on its own — it lives
* below the fold, where a capture of the whole page shows nothing but its top edge.
*/
@Composable
internal fun EpisodeSeasonPane(
episodes: List<BaseItem>?,
seasonEpisodes: List<BaseItem>,
currentEpisodeId: String,
loadFailed: Boolean,
firstEpisodeFocusRequester: FocusRequester,
emptyFocusRequester: FocusRequester,
aboveEpisodes: FocusRequester,
listState: LazyListState,
onPlay: (BaseItem) -> Unit,
) {
when {
episodes == null -> DetailFocusablePane(emptyFocusRequester) {
Text("Loading episodes…", color = DetailQuietText, fontSize = 15.sp)
}
loadFailed -> DetailFocusablePane(emptyFocusRequester) {
Text("Episodes are temporarily unavailable.", color = DetailQuietText, fontSize = 15.sp)
}
seasonEpisodes.isEmpty() -> DetailFocusablePane(emptyFocusRequester) {
Text("No episodes are available for this season.", color = DetailQuietText, fontSize = 15.sp)
}
else -> LazyColumn(
state = listState,
verticalArrangement = Arrangement.spacedBy(10.dp),
contentPadding = PaddingValues(end = 12.dp, bottom = 18.dp),
modifier = Modifier.fillMaxSize(),
) {
itemsIndexed(seasonEpisodes, key = { _, episode -> episode.id }) { index, episode ->
EpisodeCard(
episode = episode,
onClick = { onPlay(episode) },
seasonFocusRequester = aboveEpisodes,
isFirst = index == 0,
isCurrent = episode.id == currentEpisodeId,
modifier = if (index == 0) {
Modifier.focusRequester(firstEpisodeFocusRequester)
} else {
Modifier
},
)
}
}
}
}
@@ -121,7 +121,6 @@ import com.ponzischeme89.memby.data.model.EmbyPerson
import com.ponzischeme89.memby.ui.detail.UHD_MIN_WIDTH
import com.ponzischeme89.memby.ui.detail.dynamicRangeLabel
import com.ponzischeme89.memby.ui.detail.formatRuntime
import com.ponzischeme89.memby.ui.detail.ratingLabel
import com.ponzischeme89.memby.ui.theme.FactSeparator
import com.ponzischeme89.memby.ui.theme.MembyAccent
import com.ponzischeme89.memby.ui.theme.MembyCardCorner
@@ -129,7 +128,6 @@ import com.ponzischeme89.memby.ui.theme.MembyChipCorner
import com.ponzischeme89.memby.ui.theme.MembyMutedText
import com.ponzischeme89.memby.ui.theme.MembyPanelCorner
import com.ponzischeme89.memby.ui.theme.MembyQuietText
import com.ponzischeme89.memby.ui.theme.MembyScore
import com.ponzischeme89.memby.ui.theme.ValueSeparator
import java.util.Locale
import kotlinx.coroutines.Job
@@ -158,7 +156,8 @@ enum class BrowseDestination(val label: String, val icon: ImageVector) {
SETTINGS("Settings", Icons.Default.Settings),
}
enum class MediaRowKind { CONTINUE, NEXT_UP, MOVIES, SHOWS, FAVORITES }
// No NEXT_UP: those episodes are part of CONTINUE, which is one row.
enum class MediaRowKind { CONTINUE, MOVIES, SHOWS, FAVORITES }
data class HomeBrowseRow(
val id: String,
@@ -179,8 +178,8 @@ private fun homeRowVisual(row: HomeBrowseRow): HomeRowVisual = when {
row.id == "continue" -> HomeRowVisual(
Icons.Default.PlayCircleFilled,
)
row.id == "next-up" -> HomeRowVisual(
Icons.Default.SkipNext,
row.id == "continue-shows" -> HomeRowVisual(
Icons.Default.PlayCircleFilled,
)
row.kind == MediaRowKind.FAVORITES -> HomeRowVisual(
Icons.Default.Favorite,
@@ -1041,33 +1040,30 @@ private fun MetadataContent(
Text(item.name, color = MutedText, fontSize = 14.sp, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
val facts = buildList {
item.episodeCode?.let(::add)
item.productionYear?.let { add(it.toString()) }
item.runtimeMinutes?.let { add(formatRuntime(it)) }
item.officialRating?.takeIf(String::isNotBlank)?.let(::add)
item.genres.take(2).takeIf { it.isNotEmpty() }?.let { add(it.joinToString(ValueSeparator)) }
}
// The score is its own run of text so it can carry the same gold as the detail
// page. Joined into the grey fact line it was the one rating in the app that
// changed colour depending on which screen you were looking at.
val score = ratingLabel(item)
if (facts.isNotEmpty() || score != null) {
Row(verticalAlignment = Alignment.CenterVertically) {
if (facts.isNotEmpty()) {
Text(
facts.joinToString(FactSeparator),
color = MutedText,
fontSize = 13.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f, fill = false),
)
}
score?.let {
if (facts.isNotEmpty()) Text(FactSeparator, color = QuietText, fontSize = 13.sp)
Text("$it", color = MembyScore, fontSize = 13.sp, fontWeight = FontWeight.SemiBold)
}
}
// No score on this line. Emby's community rating stood beside these facts in gold
// with nothing saying whose number it was; the strip below says that for every
// score it shows, and two ratings in one panel only invited the comparison.
if (facts.isNotEmpty()) {
Text(
facts.joinToString(FactSeparator),
color = MutedText,
fontSize = 13.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
ItemRatingsStrip(
item = item,
load = true,
compact = compact,
modifier = Modifier.fillMaxWidth(),
)
val badges = buildList {
airingBadgeLabel(item)?.let(::add)
addAll(mediaBadges(item))
@@ -1430,10 +1426,14 @@ internal fun MediaRow(
onItemFocused(item)
}
when (cardFormat(row.kind, item, artworkStyle)) {
// The resume bar belongs to the row, not to the card's shape: a
// household that has set artwork to posters still needs to see
// how far into something it is.
MediaCardFormat.PORTRAIT -> PortraitMediaCard(
item, availableWidth, row.showSecondaryMetadata, focused,
{ onItemSelected(item) }, { onItemLongPressed(item) }, cardModifier,
density, row.showWatchedEpisodeCount,
showProgress = row.kind == MediaRowKind.CONTINUE,
)
MediaCardFormat.LANDSCAPE -> if (row.kind == MediaRowKind.CONTINUE) {
ContinueWatchingCard(
@@ -1708,7 +1708,7 @@ private fun cardFormat(
): MediaCardFormat = when {
artworkStyle == "poster" -> MediaCardFormat.PORTRAIT
artworkStyle == "backdrop" -> MediaCardFormat.LANDSCAPE
kind == MediaRowKind.CONTINUE || kind == MediaRowKind.NEXT_UP -> MediaCardFormat.LANDSCAPE
kind == MediaRowKind.CONTINUE -> MediaCardFormat.LANDSCAPE
item.isEpisode -> MediaCardFormat.LANDSCAPE
else -> MediaCardFormat.PORTRAIT
}
@@ -1724,6 +1724,7 @@ fun PortraitMediaCard(
modifier: Modifier = Modifier,
density: String = "standard",
showWatchedEpisodeCount: Boolean = false,
showProgress: Boolean = false,
) {
val cardsAcross = when (density) {
"compact" -> 8
@@ -1732,7 +1733,7 @@ fun PortraitMediaCard(
}
val width = responsiveRowCardWidth(availableWidth, cardsAcross, 102.dp, 218.dp)
MediaCard(
item, width, 2f / 3f, preferPrimary = true, showProgress = false,
item, width, 2f / 3f, preferPrimary = true, showProgress = showProgress,
showSecondaryMetadata, showWatchedEpisodeCount, onFocused, onClick, onLongClick, modifier,
)
}
@@ -1941,10 +1942,23 @@ private fun MediaCard(
}
}
if (item.isSchedule) {
ScheduleStatusBadge(
status = item.membyAvailability.orEmpty(),
modifier = Modifier.align(Alignment.TopEnd).padding(8.dp),
)
scheduleStatusBadgeLabel(item.membyAvailability.orEmpty())?.let {
ScheduleStatusBadge(
status = item.membyAvailability.orEmpty(),
label = it,
modifier = Modifier.align(Alignment.TopEnd).padding(8.dp),
)
}
// Bottom-left, because the two top corners already carry the air day and
// this episode's availability, and the progress bar owns the bottom edge
// only on cards that can be resumed — which a schedule card cannot.
lifecycleBadgeLabel(item)?.let {
LifecycleBadge(
status = item.membyLifecycle.orEmpty(),
label = it,
modifier = Modifier.align(Alignment.BottomStart).padding(8.dp),
)
}
}
airingBadgeLabel(item)?.let { label ->
MediaBadge(
@@ -1975,13 +1989,18 @@ private fun MediaCard(
} else {
Spacer(Modifier.height(3.dp))
}
ItemRatingsStrip(
item = item,
load = focused,
compact = true,
modifier = Modifier.padding(top = 2.dp).fillMaxWidth(),
)
}
}
}
@Composable
private fun ScheduleStatusBadge(status: String, modifier: Modifier = Modifier) {
val label = scheduleStatusBadgeLabel(status)
private fun ScheduleStatusBadge(status: String, label: String, modifier: Modifier = Modifier) {
val color = when (status) {
"available" -> EmbyGreen
"downloading" -> Color(0xFF5DA9FF)
@@ -2002,12 +2021,53 @@ private fun ScheduleStatusBadge(status: String, modifier: Modifier = Modifier) {
)
}
internal fun scheduleStatusBadgeLabel(status: String): String = when (status) {
internal fun scheduleStatusBadgeLabel(status: String): String? = when (status) {
"available" -> "ADDED"
"downloading" -> "DOWNLOADING"
"awaiting" -> "AWAITING"
"unmonitored" -> "UNMONITORED"
else -> "UPCOMING"
// The day badge and air-time subtitle already say when a future episode airs.
// Repeating "upcoming" adds no state and can collide with the more useful day.
else -> null
}
/**
* The show's or film's lifecycle, as Sonarr and Radarr word it — CONTINUING, ENDED, IN
* CINEMAS. It answers a different question from the availability badge above it: that one
* is about the household's copy of this episode, this one is about whether there will be
* any more of them.
*
* The wording is the gateway's, taken verbatim off the card, so a status a build predates
* still reads correctly rather than falling back to a slug. A card carrying no lifecycle —
* an older gateway, a cached row, a show *arr has no status for — simply wears no tag.
*/
internal fun lifecycleBadgeLabel(item: BaseItem): String? =
item.membyLifecycleText?.trim()?.takeIf(String::isNotEmpty)?.uppercase()
@Composable
internal fun LifecycleBadge(status: String, label: String, modifier: Modifier = Modifier) {
// One colour per status, matching what the same word is coloured in *arr: still being
// made is green, over is red, not out yet is blue, in cinemas is amber.
val (background, foreground) = when (status) {
"continuing", "released" -> EmbyGreen to Color(0xFF071008)
"upcoming", "announced" -> Color(0xFF5DA9FF) to Color(0xFF090B0D)
"incinemas" -> Color(0xFFFFB454) to Color(0xFF090B0D)
"ended" -> Color(0xFFE04747) to Color.White
else -> Color(0xFF3A4249) to Color(0xFFE1E5E8)
}
Text(
text = label,
color = foreground,
fontSize = 9.sp,
fontWeight = FontWeight.Bold,
letterSpacing = 0.5.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = modifier
.clip(RoundedCornerShape(4.dp))
.background(background)
.padding(horizontal = 7.dp, vertical = 4.dp),
)
}
@Composable
@@ -2199,6 +2259,7 @@ private fun cardDescription(
if (item.isSchedule) {
item.membyAirLabel?.let { append(", ").append(it) }
item.membyAvailabilityText?.let { append(", ").append(it) }
item.membyLifecycleText?.let { append(", ").append(it) }
}
}
@@ -21,8 +21,10 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
@@ -45,25 +47,58 @@ import androidx.tv.material3.Icon
import androidx.tv.material3.Text
import coil.compose.AsyncImage
import com.ponzischeme89.memby.ServiceLocator
import com.ponzischeme89.memby.data.floorMod
import com.ponzischeme89.memby.data.localEpochDay
import com.ponzischeme89.memby.data.millisUntilNextLocalDay
import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.ui.detail.heroFacts
import com.ponzischeme89.memby.ui.detail.ratingLabel
import com.ponzischeme89.memby.ui.theme.FactSeparator
import com.ponzischeme89.memby.ui.theme.MembyAccent
import com.ponzischeme89.memby.ui.theme.MembyMutedText
import com.ponzischeme89.memby.ui.theme.MembyPanelCorner
import com.ponzischeme89.memby.ui.theme.MembyQuietText
import com.ponzischeme89.memby.ui.theme.MembyScore
import kotlinx.coroutines.delay
import java.util.TimeZone
internal const val HOME_HERO_ROW_ID = "home-movie-hero"
/**
* The movie feature owns the header only while focus is still in that feature. Once the
* viewer moves into a home shelf, the header becomes the same focused-item metadata panel
* used by Movies, Shows and the other browse destinations.
* The current local day, re-read when the clock passes midnight.
*
* A television is not a phone: it is quite normal for this launcher to still be on screen
* at one in the morning, or to be woken from the screensaver into a composition that
* started yesterday. Reading the day once at composition would mean the "new releases" a
* household sees are the ones that were new whenever they last cold-started the app. The
* timer sleeps until the next local midnight rather than polling, so the cost of this is
* one suspended coroutine.
*/
internal fun shouldShowHomeMovieHero(hasMovies: Boolean, focusedRowId: String?): Boolean =
hasMovies && (focusedRowId == null || focusedRowId == HOME_HERO_ROW_ID)
@Composable
internal fun rememberHomeHeroDay(): Long {
var day by remember { mutableStateOf(currentLocalDay()) }
LaunchedEffect(Unit) {
while (true) {
val now = System.currentTimeMillis()
// A floor keeps a clock correction that lands exactly on the boundary from
// turning this into a busy loop.
delay(
millisUntilNextLocalDay(now, TimeZone.getDefault().getOffset(now))
.coerceAtLeast(MIN_DAY_TICK_MS),
)
day = currentLocalDay()
}
}
return day
}
private fun currentLocalDay(): Long = System.currentTimeMillis().let { now ->
localEpochDay(now, TimeZone.getDefault().getOffset(now))
}
private const val MIN_DAY_TICK_MS = 1_000L
/** The movie feature owns the header whenever the Home shelves are back at their top. */
internal fun shouldShowHomeMovieHero(hasMovies: Boolean, listAtTop: Boolean): Boolean =
hasMovies && listAtTop
/**
* A hero card and the reason it is there.
@@ -79,8 +114,22 @@ private const val LABEL_NEW = "NEW RELEASE"
private const val LABEL_POPULAR = "POPULAR"
private const val LABEL_LIBRARY = "FROM YOUR LIBRARY"
/** Picks a deliberate mix of fresh and popular movies while preserving server ranking. */
internal fun selectHomeHeroMovies(rows: List<HomeBrowseRow>): List<HomeHeroPick> {
/**
* Picks a deliberate mix of fresh and popular movies while preserving server ranking.
*
* [day] is a count of local days (see [localEpochDay]) and rotates the starting point in
* each candidate list, so a household that leaves the launcher on the same four films for
* a fortnight instead sees a different set every morning. It is a rotation rather than a
* shuffle on purpose: the server's ranking is still the order, so the titles it thinks are
* worth leading with keep coming round, and yesterday's hero is merely one place further
* down rather than somewhere unpredictable. Passing the same day twice always produces the
* same four cards, which is what keeps the launcher from reshuffling under a viewer who
* simply walked back into the room.
*/
internal fun selectHomeHeroMovies(
rows: List<HomeBrowseRow>,
day: Long = 0L,
): List<HomeHeroPick> {
fun HomeBrowseRow.matches(vararg words: String): Boolean {
val label = "$id $title".lowercase()
return words.any(label::contains)
@@ -90,11 +139,13 @@ internal fun selectHomeHeroMovies(rows: List<HomeBrowseRow>): List<HomeHeroPick>
.filter { it.matches("latest", "recent", "new release", "just added") }
.flatMap(HomeBrowseRow::items)
.filter(BaseItem::isMovie)
.rotatedBy(day)
val popular = rows
.filter { it.matches("popular", "trending", "recommended", "top pick") }
.flatMap(HomeBrowseRow::items)
.filter(BaseItem::isMovie)
val everyMovie = rows.flatMap(HomeBrowseRow::items).filter(BaseItem::isMovie)
.rotatedBy(day)
val everyMovie = rows.flatMap(HomeBrowseRow::items).filter(BaseItem::isMovie).rotatedBy(day)
fun List<BaseItem>.labelled(label: String) = map { HomeHeroPick(it, label) }
@@ -119,6 +170,18 @@ internal fun selectHomeHeroMovies(rows: List<HomeBrowseRow>): List<HomeHeroPick>
}.distinctBy { it.item.id }.take(4)
}
/**
* Moves the start of the list on by [by] places, wrapping. A negative day is as valid as a
* positive one a television whose clock has not yet been set can report an instant before
* the epoch, and the launcher must still draw four cards rather than throw.
*/
private fun <T> List<T>.rotatedBy(by: Long): List<T> {
if (size <= 1) return this
val offset = floorMod(by, size.toLong()).toInt()
if (offset == 0) return this
return subList(offset, size) + subList(0, offset)
}
@Composable
internal fun HomeMovieHero(
movies: List<HomeHeroPick>,
@@ -222,45 +285,65 @@ private fun FeaturedMovieCard(
),
),
)
// The card is a fixed height and this column is centred in it, so anything
// over budget is lost equally top and bottom — and the action, being last, went
// first. A wrapped title costs exactly what the synopsis is worth, so the
// synopsis is what stands down; the button is never the thing that is cut.
// Play is measured before the words, and that is the whole layout.
//
// This column is the height of a fixed-height card. A Column hands each child
// the height left after the ones before it, so the chip — being last — was
// given whatever a two-line title had not already taken, and rendered as a
// green sliver with its label squeezed out of it. Not clipped: *compressed*,
// which is why it looked malformed rather than missing.
//
// Putting the text in a `weight(1f, fill = false)` child inverts the order:
// weighted children are measured from what is left over, so the spacer and the
// chip take their natural size first and the prose is what gives way. The
// synopsis still stands down on its own when the title wraps, so in practice
// nothing has to be cut at all — but the button can no longer be the thing that
// pays for a long title, whatever the artwork, the ratings strip or the
// viewport do.
var titleLines by remember(item.id) { mutableIntStateOf(1) }
Column(
modifier = Modifier.align(Alignment.CenterStart).fillMaxWidth(0.58f).padding(22.dp),
modifier = Modifier
.align(Alignment.CenterStart)
.fillMaxWidth(0.58f)
.fillMaxHeight()
.padding(22.dp),
verticalArrangement = Arrangement.Center,
) {
Text(
pick.label,
color = MembyAccent,
fontSize = 11.sp,
fontWeight = FontWeight.Bold,
letterSpacing = 1.4.sp,
)
Spacer(Modifier.height(8.dp))
Text(
item.name,
color = Color.White,
fontSize = 30.sp,
lineHeight = 32.sp,
fontWeight = FontWeight.Bold,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
onTextLayout = { titleLines = it.lineCount },
)
Spacer(Modifier.height(9.dp))
HeroFactLine(item)
if (titleLines == 1) {
item.overview?.takeIf(String::isNotBlank)?.let { overview ->
Spacer(Modifier.height(9.dp))
Text(
overview,
color = MembyMutedText,
fontSize = 13.sp,
lineHeight = 17.sp,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
Column(Modifier.weight(1f, fill = false)) {
// No eyebrow on the featured card. "NEW RELEASE" over a fact line that
// already prints the year was a whole line of the card's height spent
// on the least specific thing on it — and it is the line that pushed a
// wrapped title into the button. The three minis beside it keep theirs:
// they have no fact line, and there the label is the only reason given.
Text(
item.name,
color = Color.White,
fontSize = 30.sp,
lineHeight = 32.sp,
fontWeight = FontWeight.Bold,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
onTextLayout = { titleLines = it.lineCount },
)
Spacer(Modifier.height(9.dp))
HeroFactLine(item)
ItemRatingsStrip(
item = item,
load = true,
modifier = Modifier.padding(top = 6.dp).fillMaxWidth(),
)
if (titleLines == 1) {
item.overview?.takeIf(String::isNotBlank)?.let { overview ->
Spacer(Modifier.height(9.dp))
Text(
overview,
color = MembyMutedText,
fontSize = 13.sp,
lineHeight = 17.sp,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
}
}
Spacer(Modifier.height(14.dp))
@@ -271,32 +354,23 @@ private fun FeaturedMovieCard(
}
/**
* Year, length, certificate and score the same wording, order and gold as the card
* directly beneath it and the detail page it opens. The hero used to carry its own
* formatter and print "2026 • M • 124m" over a row printing "2026 • 2h 4m".
* Year, length and certificate the same wording and order as the card directly beneath
* it and the detail page it opens. The hero used to carry its own formatter and print
* "2026 • M • 124m" over a row printing "2026 • 2h 4m". Scores are not here: they belong
* to the ratings strip, which names the provider behind each one.
*/
@Composable
private fun HeroFactLine(item: BaseItem) {
val facts = heroFacts(item)
val score = ratingLabel(item)
if (facts.isEmpty() && score == null) return
Row(verticalAlignment = Alignment.CenterVertically) {
if (facts.isNotEmpty()) {
Text(
facts.joinToString(FactSeparator),
color = MembyMutedText,
fontSize = 13.sp,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f, fill = false),
)
}
score?.let {
if (facts.isNotEmpty()) Text(FactSeparator, color = MembyQuietText, fontSize = 13.sp)
Text("$it", color = MembyScore, fontSize = 13.sp, fontWeight = FontWeight.SemiBold)
}
}
if (facts.isEmpty()) return
Text(
facts.joinToString(FactSeparator),
color = MembyMutedText,
fontSize = 13.sp,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
@Composable
@@ -28,11 +28,15 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
enum class HomeSection { CONTINUE, NEXT_UP, FAVORITES, LATEST }
enum class HomeSection { CONTINUE, FAVORITES, LATEST }
data class HomeUiState(
/**
* Everything in progress, including the episode that follows one just finished:
* Continue Watching and Next Up are one row. The merge happens on the gateway, or in
* `EmbyRepository.getContinueWatching` on the direct path.
*/
val continueWatching: List<BaseItem> = emptyList(),
val nextUp: List<BaseItem> = emptyList(),
val favorites: List<BaseItem> = emptyList(),
val latestMovies: List<BaseItem> = emptyList(),
/**
@@ -54,9 +58,6 @@ data class HomeUiState(
*/
val maintenanceMessage: String? = null,
) {
val watchingAndNextUp: List<BaseItem>
get() = (continueWatching + nextUp).distinctBy(BaseItem::id)
/**
* This state with everything that is *not* a row blanked out. Paired with
* `distinctUntilChanged`, it turns [HomeViewModel.content] into a flow that only
@@ -78,7 +79,6 @@ data class HomeUiState(
fun toCache() = HomeCache(
continueWatching = continueWatching,
nextUp = nextUp,
favorites = favorites,
latestMovies = latestMovies,
rows = rows,
@@ -86,14 +86,16 @@ data class HomeUiState(
companion object {
fun from(cache: HomeCache?) = HomeUiState(
continueWatching = cache?.continueWatching.orEmpty(),
nextUp = cache?.nextUp.orEmpty(),
// A cache written before the two rows merged still carries its Next Up items
// separately; folding them in keeps the cold-start row complete until the
// first refresh replaces it with a properly interleaved one.
continueWatching = (cache?.continueWatching.orEmpty() + cache?.nextUp.orEmpty())
.distinctBy(BaseItem::id),
favorites = cache?.favorites.orEmpty(),
latestMovies = cache?.latestMovies.orEmpty(),
rows = cache?.rows.orEmpty(),
loading = buildSet {
if (cache?.continueWatching.isNullOrEmpty()) add(HomeSection.CONTINUE)
if (cache?.nextUp.isNullOrEmpty()) add(HomeSection.NEXT_UP)
if (cache?.favorites.isNullOrEmpty()) add(HomeSection.FAVORITES)
if (cache?.latestMovies.isNullOrEmpty()) add(HomeSection.LATEST)
},
@@ -214,13 +216,15 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
fun refreshAll() {
viewModelScope.launch(Dispatchers.IO) {
refreshMutex.withLock {
// A refresh can make a newly imported episode the next playable item for
// an existing series ID. Never launch the pre-refresh negotiated session.
repository.invalidatePlaybackPrefetch()
_state.update { it.copy(loading = HomeSection.entries.toSet(), hasRefreshError = false) }
if (repository.supportsBatchHome) {
loadBatchHome()
} else {
coroutineScope {
launch { loadContinueWatching() }
launch { loadNextUp() }
launch { loadFavorites() }
launch { loadLatest() }
}
@@ -241,7 +245,6 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
_state.update { current ->
current.copy(
continueWatching = taggedHome.continueWatching,
nextUp = taggedHome.nextUp,
favorites = taggedHome.favorites,
latestMovies = taggedHome.latestMovies,
// Recommendation rows are built in the background by the gateway,
@@ -393,7 +396,6 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
_state.update {
it.copy(
continueWatching = it.continueWatching.map(BaseItem::updated),
nextUp = it.nextUp.map(BaseItem::updated),
favorites = it.favorites.map(BaseItem::updated),
latestMovies = it.latestMovies.map(BaseItem::updated),
// Server rows hold their own copies of the same items, so an optimistic
@@ -410,18 +412,14 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
private suspend fun refreshWatching() {
refreshMutex.withLock {
_state.update { it.copy(loading = it.loading + setOf(HomeSection.CONTINUE, HomeSection.NEXT_UP)) }
_state.update { it.copy(loading = it.loading + HomeSection.CONTINUE) }
if (repository.supportsBatchHome) {
// One request is cheaper than two here as well, and playback just
// invalidated this user's rows on the gateway anyway.
// Playback just invalidated this user's rows on the gateway anyway.
loadBatchHome()
} else {
coroutineScope {
launch { loadContinueWatching(clearLoading = false) }
launch { loadNextUp(clearLoading = false) }
}
loadContinueWatching(clearLoading = false)
}
_state.update { it.copy(loading = it.loading - setOf(HomeSection.CONTINUE, HomeSection.NEXT_UP)) }
_state.update { it.copy(loading = it.loading - HomeSection.CONTINUE) }
persistCurrentHome()
}
}
@@ -431,11 +429,6 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
state.copy(continueWatching = items)
}
private suspend fun loadNextUp(clearLoading: Boolean = true) =
load(HomeSection.NEXT_UP, clearLoading, { repository.getNextUp() }) { state, items ->
state.copy(nextUp = items)
}
private suspend fun loadFavorites() =
load(HomeSection.FAVORITES, true, { repository.getFavorites() }) { state, items ->
state.copy(favorites = items)
@@ -487,7 +480,7 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
private const val ANALYTICS_FLUSH_INTERVAL_MS = 20_000L
private fun initialFocusedItem(state: HomeUiState): BaseItem? =
state.watchingAndNextUp.firstOrNull()
state.continueWatching.firstOrNull()
?: state.latestMovies.firstOrNull()
?: state.favorites.firstOrNull()
}
@@ -496,10 +489,18 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
internal fun HomeSnapshot.withAiringTodayTags(): HomeSnapshot {
val airingTodayKeys = rows.airingTodayShowKeys()
if (airingTodayKeys.isEmpty()) return this
val taggedContinue = continueWatching
.withAiringTodayItemTags(airingTodayKeys)
.prioritizeAiringToday()
return copy(
rows = rows.withAiringTodayRowTags(airingTodayKeys),
continueWatching = continueWatching.withAiringTodayItemTags(airingTodayKeys),
nextUp = nextUp.withAiringTodayItemTags(airingTodayKeys),
rows = rows.withAiringTodayRowTags(airingTodayKeys).map { row ->
if (row.id == "continue") {
row.copy(items = row.items.prioritizeAiringToday())
} else {
row
}
},
continueWatching = taggedContinue,
favorites = favorites.withAiringTodayItemTags(airingTodayKeys),
latestMovies = latestMovies.withAiringTodayItemTags(airingTodayKeys),
)
@@ -518,13 +519,22 @@ private fun List<HomeRow>.withAiringTodayRowTags(keys: Set<String>): List<HomeRo
private fun List<BaseItem>.withAiringTodayItemTags(keys: Set<String>): List<BaseItem> =
map { item ->
if (!item.isTvSchedule && item.isSeries && item.name.showMatchKey() in keys) {
val showName = when {
item.isEpisode -> item.seriesName
item.isSeries -> item.name
else -> null
}
if (!item.isTvSchedule && showName != null && showName.showMatchKey() in keys) {
item.copy(membyAiringToday = true)
} else {
item
}
}
/** Stable partition: today's shows move forward without disturbing recency within groups. */
private fun List<BaseItem>.prioritizeAiringToday(): List<BaseItem> =
filter(BaseItem::membyAiringToday) + filterNot(BaseItem::membyAiringToday)
private fun String.showMatchKey(): String =
lowercase().filter(Char::isLetterOrDigit)
@@ -0,0 +1,194 @@
package com.ponzischeme89.memby.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.tv.material3.Text
import com.ponzischeme89.memby.ui.theme.MembyAccent
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.MembySurface
import com.ponzischeme89.memby.update.InstallPermission
/**
* The setup step that asks for permission to install updates.
*
* Every TV running Memby was sideloaded, which means the app that installed it holds
* Android's per-app install permission and Memby does not. Without this step nobody is ever
* asked: the permission is discovered to be missing months later, in the middle of an update
* that then cannot proceed, and the household's answer becomes "reinstall it by hand" once
* per release, forever.
*
* It is deliberately **skippable**. A fresh install must never be blocked by a permission
* that only matters later, and on a TV with no permission screen to open there would be
* nothing the viewer could do to satisfy it. Skipping costs them the same prompt at update
* time, which is where it used to live.
*/
@Composable
fun InstallPermissionScreen(onContinue: () -> Unit) {
val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current
var noScreenAvailable by remember { mutableStateOf(false) }
var asked by remember { mutableStateOf(false) }
// Granting happens in Android's settings, which pauses Memby. Coming back with the
// permission in hand should simply carry on rather than leave the viewer looking at a
// step they have already completed.
DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME && asked && InstallPermission.granted(context)) {
onContinue()
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
}
InstallPermissionContent(
noScreenAvailable = noScreenAvailable,
onOpenSettings = {
asked = true
noScreenAvailable = !InstallPermission.requestScreen(context)
},
onSkip = onContinue,
)
}
/**
* The screen itself, stateless so it can be screenshotted in both of its states
* (`OnboardingScreenshotTest`).
*
* The instructions are the substance here. Android's own screen is a bare list of app names
* with switches and no explanation of why anyone was sent there, and on a television the
* viewer has a remote, no back button they trust, and no idea what "unknown sources" means.
* Naming the steps *find Memby, turn it on, press Back* is the difference between a
* permission that gets granted and one that gets abandoned halfway.
*/
@Composable
fun InstallPermissionContent(
noScreenAvailable: Boolean,
onOpenSettings: () -> Unit,
onSkip: () -> Unit,
modifier: Modifier = Modifier,
) {
val allowFocus = remember { FocusRequester() }
val skipFocus = remember { FocusRequester() }
LaunchedEffect(Unit) { runCatching { allowFocus.requestFocus() } }
Box(
modifier.fillMaxSize().background(MembySurface),
contentAlignment = Alignment.Center,
) {
Column(
modifier = Modifier.widthIn(max = 780.dp).padding(horizontal = 56.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
"Let Memby update itself",
color = MembyOnSurface,
fontSize = 34.sp,
fontWeight = FontWeight.SemiBold,
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(14.dp))
Text(
"Memby installs its own updates. Because it was installed from a file rather " +
"than a store, Android needs your permission once before it will let it.",
color = MembyMutedText,
fontSize = 17.sp,
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(26.dp))
if (noScreenAvailable) {
PermissionStep(1, "Open ${InstallPermission.MANUAL_PATH} on this TV.")
PermissionStep(2, "Find Memby in the list and turn it on.")
PermissionStep(3, "Come back here and carry on.")
} else {
PermissionStep(1, "Choose Open settings below.")
PermissionStep(2, "Turn on the switch for Memby.")
PermissionStep(3, "Press Back on your remote — Memby carries on by itself.")
}
Spacer(Modifier.height(18.dp))
Text(
"You can skip this. Memby will ask again the first time an update needs it, " +
"and until then nothing changes.",
color = MembyQuietText,
fontSize = 14.sp,
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(30.dp))
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
UpdateButton(
label = if (noScreenAvailable) "Try again" else "Open settings",
primary = true,
enabled = true,
onClick = onOpenSettings,
modifier = Modifier
.focusRequester(allowFocus)
.focusProperties { right = skipFocus },
)
UpdateButton(
label = "Skip for now",
primary = false,
enabled = true,
onClick = onSkip,
modifier = Modifier
.focusRequester(skipFocus)
.focusProperties { left = allowFocus },
)
}
}
}
}
/** One numbered instruction. Never focusable — reading matter, not a control. */
@Composable
private fun PermissionStep(number: Int, text: String) {
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 5.dp),
horizontalArrangement = Arrangement.spacedBy(14.dp),
) {
Text(
"$number",
color = MembyAccent,
fontSize = 16.sp,
fontWeight = FontWeight.Bold,
modifier = Modifier.width(20.dp),
textAlign = TextAlign.End,
)
Text(text, color = MembyOnSurface.copy(alpha = 0.88f), fontSize = 16.sp)
}
}
File diff suppressed because it is too large Load Diff
@@ -2,9 +2,9 @@ package com.ponzischeme89.memby.ui
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.DoneAll
import androidx.compose.material.icons.filled.Favorite
import androidx.compose.material.icons.filled.FavoriteBorder
import androidx.compose.material.icons.filled.FirstPage
import androidx.compose.material.icons.filled.Movie
import androidx.compose.runtime.Composable
@@ -22,7 +22,7 @@ import com.ponzischeme89.memby.ServiceLocator
import com.ponzischeme89.memby.data.RelatedContent
import com.ponzischeme89.memby.data.Settings
import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.data.model.GatewayMovieRating
import com.ponzischeme89.memby.data.model.MediaRating
import com.ponzischeme89.memby.ui.detail.DetailTab
import com.ponzischeme89.memby.ui.detail.DetailZone
import com.ponzischeme89.memby.ui.detail.creditRows
@@ -56,19 +56,15 @@ fun MediaDetailsOverlay(
val settings by ServiceLocator.repository.settingsFlow.collectAsState(initial = Settings.EMPTY)
var related by remember(item.id) { mutableStateOf<RelatedContent?>(null) }
var trailer by remember(item.id) { mutableStateOf<BaseItem?>(null) }
var ratings by remember(item.id) { mutableStateOf<List<GatewayMovieRating>>(emptyList()) }
var ratings by remember(item.id) { mutableStateOf<List<MediaRating>>(emptyList()) }
LaunchedEffect(item.id) {
related = ServiceLocator.repository.getRelated(item)
}
LaunchedEffect(item.id) {
trailer = ServiceLocator.repository.getLocalTrailer(item.id)
}
LaunchedEffect(item.id, item.isMovie) {
ratings = if (item.isMovie) {
ServiceLocator.repository.getMovieRatings(item.id)
} else {
emptyList()
}
LaunchedEffect(item.id, settings.showRatingsStrip) {
ratings = if (settings.showRatingsStrip) ServiceLocator.repository.getRatings(item) else emptyList()
}
MediaDetailContent(
item = item,
@@ -79,6 +75,7 @@ fun MediaDetailsOverlay(
related = related,
trailer = trailer,
ratings = ratings,
showRatingsStrip = settings.showRatingsStrip,
hideWatchedMovies = settings.hideWatchedMovies,
modifier = modifier,
)
@@ -97,7 +94,8 @@ internal fun MediaDetailContent(
modifier: Modifier = Modifier,
related: RelatedContent? = null,
trailer: BaseItem? = null,
ratings: List<GatewayMovieRating> = emptyList(),
ratings: List<MediaRating> = emptyList(),
showRatingsStrip: Boolean = true,
hideWatchedMovies: Boolean = false,
onOpenItem: (BaseItem) -> Unit = {},
) {
@@ -179,12 +177,15 @@ internal fun MediaDetailContent(
reasons = related?.reasons?.takeIf(List<String>::isNotEmpty)
?: listOfNotNull(item.membyRecommendationReason?.takeIf(String::isNotBlank)),
ratings = ratings,
showRatingsStrip = showRatingsStrip,
confirmation = confirmation,
onZoneFocused = { zone -> detailPositions.update(item.id) { it.copy(zone = zone) } },
heroActions = buildList {
add(
DetailHeroAction(
icon = if (item.isFavorite) Icons.Default.Check else Icons.Default.Add,
// A heart, not a tick: "Mark watched" two buttons along is a tick as
// well. The heart is what a home card and the screensaver already use.
icon = if (item.isFavorite) Icons.Default.Favorite else Icons.Default.FavoriteBorder,
description = if (item.isFavorite) "Remove from Favourites" else "Add to Favourites",
active = item.isFavorite,
onClick = {
@@ -177,19 +177,11 @@ private fun MyShowCard(
contentScale = ContentScale.Fit,
modifier = Modifier.fillMaxSize(),
)
myShowBadge(show)?.let { (label, color) ->
Text(
label,
color = Color(0xFF090B0D),
fontSize = 9.sp,
fontWeight = FontWeight.Bold,
letterSpacing = 0.5.sp,
modifier = Modifier
.align(Alignment.TopStart)
.padding(8.dp)
.clip(RoundedCornerShape(4.dp))
.background(color)
.padding(horizontal = 7.dp, vertical = 4.dp),
myShowBadge(show)?.let { (status, label) ->
LifecycleBadge(
status = status,
label = label,
modifier = Modifier.align(Alignment.TopStart).padding(8.dp),
)
}
}
@@ -215,10 +207,21 @@ private fun MyShowCard(
}
}
private fun myShowBadge(show: MyShow): Pair<String, Color>? = when {
!show.nextEpisode.isNullOrBlank() -> "UPCOMING" to Color(0xFF52B54B)
show.sonarrStatus == "Not monitored" -> "UNMONITORED" to Color(0xFFFFB454)
show.lifecycle == "Cancelled" -> "CANCELLED" to Color(0xFFAEB7BF)
/**
* The tag a followed show wears, as a `status to label` pair for [LifecycleBadge] the same
* vocabulary and therefore the same colours as the Sonarr and Radarr schedule rows, so one
* word means one thing wherever it appears on the launcher.
*
* The order is the priority. A cancelled show wins outright: nothing else on the card
* matters as much as "there will be no more of this", which is why it is the one tag drawn
* in red. Sonarr not monitoring it comes next, then a dated next episode, which is more
* useful than the general fact that the show continues.
*/
internal fun myShowBadge(show: MyShow): Pair<String, String>? = when {
show.lifecycle == "Cancelled" -> "ended" to "CANCELLED"
show.sonarrStatus == "Not monitored" -> "unmonitored" to "UNMONITORED"
!show.nextEpisode.isNullOrBlank() -> "upcoming" to "UPCOMING"
show.lifecycle == "Continuing" -> "continuing" to "CONTINUING"
else -> null
}
@@ -0,0 +1,201 @@
package com.ponzischeme89.memby.ui
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
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.draw.clipToBounds
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
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 com.ponzischeme89.memby.R
import com.ponzischeme89.memby.ServiceLocator
import com.ponzischeme89.memby.data.Settings
import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.data.model.MediaRating
import com.ponzischeme89.memby.data.model.displayable
import com.ponzischeme89.memby.data.model.formattedScore
import com.ponzischeme89.memby.data.model.ratingDisplayLimit
import com.ponzischeme89.memby.data.model.wordmark
import com.ponzischeme89.memby.ui.theme.MembyMutedText
import com.ponzischeme89.memby.ui.theme.MembyOnSurface
import com.ponzischeme89.memby.ui.theme.MembyRatingsSurface
import kotlin.math.roundToInt
private val RatingStripHeight = 27.dp
private val RatingMarkHeight = 15.dp
private val RatingMarkHeightCompact = 13.dp
/** One non-focusable, layout-stable presentation for ratings everywhere in the app. */
@Composable
fun RatingsStrip(
ratings: List<MediaRating>,
visible: Boolean,
modifier: Modifier = Modifier,
reserveSpace: Boolean = false,
compact: Boolean = false,
) {
if (!visible) return
val valid = remember(ratings) { ratings.displayable() }
if (valid.isEmpty()) {
if (reserveSpace) Spacer(modifier.height(RatingStripHeight))
return
}
BoxWithConstraints(
modifier = modifier
.then(if (reserveSpace) Modifier.height(RatingStripHeight) else Modifier)
.fillMaxWidth()
.clipToBounds()
.testTag("ratings-strip"),
contentAlignment = Alignment.CenterStart,
) {
// The responsive limit is based on the usable width inside the capsule.
val contentWidth = (maxWidth.value.roundToInt() - 20).coerceAtLeast(0)
val shown = valid.take(ratingDisplayLimit(contentWidth))
Row(
modifier = Modifier
.clip(RoundedCornerShape(percent = 50))
.background(MembyRatingsSurface)
.padding(horizontal = 10.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(if (compact) 8.dp else 13.dp),
) {
shown.forEach { rating ->
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
RatingMark(rating, compact)
Text(
rating.formattedScore(),
color = MembyOnSurface,
fontSize = if (compact) 11.sp else 13.sp,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
)
}
}
}
}
}
/**
* Lazy item adapter.
*
* A card whose payload already carries ratings draws them with the row, costing nothing:
* the gateway attaches what it has stored to every item it sends. Only a title it has
* never looked up falls back to the dedicated request, which cards make on focus and
* heroes and detail pages make immediately.
*/
@Composable
fun ItemRatingsStrip(
item: BaseItem,
load: Boolean,
modifier: Modifier = Modifier,
reserveSpace: Boolean = true,
compact: Boolean = false,
) {
val settings by ServiceLocator.repository.settingsFlow.collectAsState(initial = Settings.EMPTY)
var ratings by remember(item.id) { mutableStateOf(item.membyRatings) }
// A focused home item is replaced in-place by its full Emby details. Include the
// carried ratings in the key so an item can pick them up from the settled response
// without requiring the viewer to move focus away and back.
LaunchedEffect(item.id, item.membyRatings, load, settings.showRatingsStrip) {
if (item.membyRatings.isNotEmpty()) {
ratings = item.membyRatings
return@LaunchedEffect
}
if (load && settings.showRatingsStrip) ratings = ServiceLocator.repository.getRatings(item)
}
RatingsStrip(
ratings = ratings,
visible = settings.showRatingsStrip,
modifier = modifier,
reserveSpace = reserveSpace,
compact = compact,
)
}
/**
* A provider is shown by its own mark where one exists and by its wordmark where it does
* not the strip has to stay readable for a source no build anticipated, and the score
* beside a bare wordmark says nothing about where it came from.
*/
@Composable
private fun RatingMark(rating: MediaRating, compact: Boolean) {
val icon = ratingIcon(rating.source)
if (icon != null) {
val height = (if (compact) RatingMarkHeightCompact else RatingMarkHeight) *
markScale(rating.source)
Image(
painter = painterResource(icon),
contentDescription = rating.wordmark(),
contentScale = ContentScale.Fit,
modifier = Modifier.height(height),
)
} else {
Text(
rating.wordmark(),
color = providerColor(rating.source),
fontSize = if (compact) 10.sp else 12.sp,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Clip,
)
}
}
/**
* Only the critics' tomato is drawn as a tomato: the audience score is a different
* measurement and wearing the same mark would claim it was the same number.
*/
private fun ratingIcon(source: String): Int? = when (source.lowercase()) {
"tomatoes" -> R.drawable.ic_rating_tomatoes
"metacritic" -> R.drawable.ic_rating_metacritic
"imdb" -> R.drawable.ic_rating_imdb
"letterboxd" -> R.drawable.ic_rating_letterboxd
"tmdb" -> R.drawable.ic_rating_tmdb
else -> null
}
/**
* Marks are set to a common height, which is the wrong measure for artwork that stacks
* its type on two lines: TMDb's lettering ends up half the size of everyone else's. The
* scale brings its letters back to the same cap height rather than its box.
*/
private fun markScale(source: String): Float =
if (source.lowercase() == "tmdb") 1.25f else 1f
private fun providerColor(source: String): Color = when (source.lowercase()) {
"imdb" -> Color(0xFFF5C518)
"tomatoes", "audience" -> Color(0xFFFA5252)
"metacritic" -> Color(0xFFFFCC34)
"letterboxd" -> Color(0xFF40BCF4)
"tmdb" -> Color(0xFF90CEA1)
"trakt" -> Color(0xFFED1C24)
"mal", "anilist", "anidb", "kitsu" -> Color(0xFF74A8FF)
else -> MembyMutedText
}
@@ -26,11 +26,11 @@ import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.BookmarkAdd
import androidx.compose.material.icons.filled.BookmarkAdded
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Bookmark
import androidx.compose.material.icons.filled.BookmarkBorder
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Favorite
import androidx.compose.material.icons.filled.FavoriteBorder
import androidx.compose.material.icons.filled.Movie
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.runtime.Composable
@@ -63,7 +63,11 @@ import com.ponzischeme89.memby.ServiceLocator
import com.ponzischeme89.memby.data.RelatedContent
import com.ponzischeme89.memby.data.ServerConfig
import com.ponzischeme89.memby.data.Settings
import com.ponzischeme89.memby.data.estimateSeriesPace
import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.data.model.MediaRating
import com.ponzischeme89.memby.data.seriesPaceLabel
import com.ponzischeme89.memby.ui.detail.AiringNotice
import com.ponzischeme89.memby.ui.detail.DetailTab
import com.ponzischeme89.memby.ui.detail.DetailZone
import com.ponzischeme89.memby.ui.detail.availableSeasons
@@ -74,6 +78,7 @@ import com.ponzischeme89.memby.ui.detail.detailTab
import com.ponzischeme89.memby.ui.detail.detailTabs
import com.ponzischeme89.memby.ui.detail.episodeHeadline
import com.ponzischeme89.memby.ui.detail.episodesForSeason
import com.ponzischeme89.memby.ui.detail.formatAirDate
import com.ponzischeme89.memby.ui.detail.heroFacts
import com.ponzischeme89.memby.ui.detail.isPlayed
import com.ponzischeme89.memby.ui.detail.nextEpisodeToWatch
@@ -84,6 +89,7 @@ import com.ponzischeme89.memby.ui.detail.seasonLabel
import com.ponzischeme89.memby.ui.detail.seriesEpisodeComparator
import com.ponzischeme89.memby.ui.detail.technicalSpecs
import com.ponzischeme89.memby.ui.detail.unwatchedCount
import java.util.TimeZone
/**
* A series. Loads its episodes and hands them to [SeriesDetailContent], which is where the
@@ -99,6 +105,7 @@ fun SeriesDetailsOverlay(
onToggleMyShow: (BaseItem, Boolean) -> Unit,
onClose: () -> Unit,
onOpenItem: (BaseItem) -> Unit = {},
airingNotice: AiringNotice? = null,
modifier: Modifier = Modifier,
) {
val repository = ServiceLocator.repository
@@ -107,6 +114,7 @@ fun SeriesDetailsOverlay(
var loadFailed by remember(item.id) { mutableStateOf(false) }
var related by remember(item.id) { mutableStateOf<RelatedContent?>(null) }
var trailer by remember(item.id) { mutableStateOf<BaseItem?>(null) }
var ratings by remember(item.id) { mutableStateOf<List<MediaRating>>(emptyList()) }
LaunchedEffect(item.id) {
runCatching { repository.getSeriesEpisodes(item.id) }
@@ -124,6 +132,9 @@ fun SeriesDetailsOverlay(
LaunchedEffect(item.id) {
trailer = repository.getLocalTrailer(item.id)
}
LaunchedEffect(item.id, settings.showRatingsStrip) {
ratings = if (settings.showRatingsStrip) repository.getRatings(item) else emptyList()
}
SeriesDetailContent(
item = item,
@@ -135,8 +146,11 @@ fun SeriesDetailsOverlay(
onToggleMyShow = onToggleMyShow,
related = related,
trailer = trailer,
ratings = ratings,
showRatingsStrip = settings.showRatingsStrip,
hideWatchedMovies = settings.hideWatchedMovies,
onOpenItem = onOpenItem,
airingNotice = airingNotice,
modifier = modifier,
)
}
@@ -154,8 +168,11 @@ internal fun SeriesDetailContent(
modifier: Modifier = Modifier,
related: RelatedContent? = null,
trailer: BaseItem? = null,
ratings: List<MediaRating> = emptyList(),
showRatingsStrip: Boolean = true,
hideWatchedMovies: Boolean = false,
onOpenItem: (BaseItem) -> Unit = {},
airingNotice: AiringNotice? = null,
) {
val remembered = remember(item.id) { detailPositions.get(item.id) }
val seasons = remember(episodes) { availableSeasons(episodes.orEmpty()) }
@@ -170,6 +187,20 @@ internal fun SeriesDetailContent(
}
val nextEpisode = remember(episodes) { nextEpisodeToWatch(episodes.orEmpty()) }
val remaining = remember(episodes) { unwatchedCount(episodes.orEmpty()) }
// Recalculated from the episode list itself, so finishing an episode, marking one
// watched and a newly imported episode all move it with no cache to invalidate. The
// day is read once per composition of this page rather than on a timer: an estimate
// that ticks over at midnight under a page nobody is looking at is not worth a clock.
val paceLabel = remember(episodes, item.id) {
seriesPaceLabel(
estimateSeriesPace(
episodes = episodes.orEmpty(),
nowMs = System.currentTimeMillis(),
zoneOffsetMs = TimeZone.getDefault().getOffset(System.currentTimeMillis()),
ongoing = item.isOngoingSeries,
),
)
}
val specs = remember(item.id, item.mediaStreams) { technicalSpecs(item) }
val credits = remember(item.id, item.people, item.genres) { creditRows(item) }
val visibleRelated = remember(related, hideWatchedMovies) {
@@ -266,14 +297,22 @@ internal fun SeriesDetailContent(
modifier = modifier,
progress = nextEpisode?.let(::playbackProgress) ?: 0f,
progressLabel = nextEpisode?.let(::remainingLabel),
paceLabel = paceLabel,
reasons = related?.reasons?.takeIf(List<String>::isNotEmpty)
?: listOfNotNull(item.membyRecommendationReason?.takeIf(String::isNotBlank)),
airingNotice = airingNotice,
confirmation = confirmation,
ratings = ratings,
showRatingsStrip = showRatingsStrip,
onZoneFocused = { zone -> detailPositions.update(item.id) { it.copy(zone = zone) } },
heroActions = buildList {
add(
DetailHeroAction(
icon = if (item.isFavorite) Icons.Default.Check else Icons.Default.Add,
// A heart, not a plus: the bookmark beside it is also a "+" glyph, and
// two adjacent circles that both read as "add this" say nothing about
// which list is which. The heart is already what a home card, the
// screensaver and the Favourites row header use for this.
icon = if (item.isFavorite) Icons.Default.Favorite else Icons.Default.FavoriteBorder,
description = if (item.isFavorite) "Remove from Favourites" else "Add to Favourites",
active = item.isFavorite,
onClick = {
@@ -285,7 +324,10 @@ internal fun SeriesDetailContent(
)
if (ServerConfig.isGateway) {
add(DetailHeroAction(
icon = if (isMyShow) Icons.Default.BookmarkAdded else Icons.Default.BookmarkAdd,
// Filled/outline says on-or-off for both toggles, so the two differ by
// silhouette alone — heart against bookmark. The +/✓ variants said it
// a second way and borrowed the glyphs the other buttons use.
icon = if (isMyShow) Icons.Default.Bookmark else Icons.Default.BookmarkBorder,
description = if (isMyShow) "Remove from My Shows" else "Add to My Shows",
active = isMyShow,
onClick = { onToggleMyShow(item, !isMyShow) },
@@ -468,14 +510,21 @@ private fun SeasonChip(
}
}
/** A minimal D-pad row: the focused episode becomes the unmistakable selection. */
/**
* A minimal D-pad row: the focused episode becomes the unmistakable selection.
*
* Shared with the episode page, which passes [isCurrent] for the one the page is about
* the season's list is otherwise identical there, and two copies of a row this fiddly is
* how the two screens start disagreeing about what a watched episode looks like.
*/
@Composable
private fun EpisodeCard(
internal fun EpisodeCard(
episode: BaseItem,
onClick: () -> Unit,
seasonFocusRequester: FocusRequester,
isFirst: Boolean,
modifier: Modifier = Modifier,
isCurrent: Boolean = false,
) {
val repository = ServiceLocator.repository
var focused by remember { mutableStateOf(false) }
@@ -500,10 +549,20 @@ private fun EpisodeCard(
}
.zIndex(if (focused) 1f else 0f)
.clip(shape)
.background(if (focused) Color(0xFF23282C) else Color.White.copy(alpha = 0.035f))
.background(
when {
focused -> Color(0xFF23282C)
isCurrent -> DetailAccent.copy(alpha = 0.10f)
else -> Color.White.copy(alpha = 0.035f)
},
)
.border(
width = if (focused) 2.dp else 1.dp,
color = if (focused) Color.White.copy(alpha = 0.30f) else Color.White.copy(alpha = 0.08f),
color = when {
focused -> Color.White.copy(alpha = 0.30f)
isCurrent -> DetailAccent.copy(alpha = 0.55f)
else -> Color.White.copy(alpha = 0.08f)
},
shape = shape,
)
.focusProperties {
@@ -602,6 +661,29 @@ private fun EpisodeCard(
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
if (isCurrent) {
Text(
text = "THIS EPISODE",
color = DetailAccent,
fontSize = 10.sp,
fontWeight = FontWeight.Bold,
maxLines = 1,
modifier = Modifier
.padding(start = 10.dp)
.clip(RoundedCornerShape(5.dp))
.background(DetailAccent.copy(alpha = 0.16f))
.padding(horizontal = 7.dp, vertical = 3.dp),
)
}
formatAirDate(episode.premiereDate)?.let {
Text(
text = it,
color = if (focused) Color(0xFFB7C0C6) else DetailQuietText,
fontSize = 12.sp,
maxLines = 1,
modifier = Modifier.padding(start = 12.dp),
)
}
episode.runtimeMinutes?.let {
Text(
text = "$it min",
@@ -61,6 +61,7 @@ import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.tv.material3.Icon
import androidx.tv.material3.Text
import com.ponzischeme89.memby.data.model.GatewayUpdate
import com.ponzischeme89.memby.update.AppInstall
import com.ponzischeme89.memby.update.InstallPermissionRequiredException
import com.ponzischeme89.memby.update.UpdateChecker
import kotlinx.coroutines.launch
@@ -117,6 +118,11 @@ fun UpdateScreen(
}
}
// The install happens in the system installer, so its outcome arrives here rather than
// as the result of the call above. On a mandatory update this is the only thing that
// can tell a viewer why the screen they cannot leave is still there.
LaunchedEffect(Unit) { AppInstall.messages.collect { message = it } }
// Android's permission screen pauses Memby. Once the viewer grants permission and
// returns, continue automatically instead of making them discover they must press
// Update now for a second time.
@@ -310,7 +316,7 @@ fun UpdateScreen(
}
@Composable
private fun UpdateButton(
internal fun UpdateButton(
label: String,
primary: Boolean,
enabled: Boolean,
@@ -0,0 +1,82 @@
package com.ponzischeme89.memby.ui.detail
import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.ui.theme.FactSeparator
import java.util.Locale
/**
* What a detail page says when it was opened from the "Shows airing in the next 5 days"
* row: which episode is due, and when.
*
* It exists because that row's cards stand for an *episode* while the page they open is
* the *show*. Without it the viewer picks "Thursday, 9pm" from the launcher and arrives at
* a page that says nothing about Thursday. It is deliberately a property of the route
* taken, not of the show the same page reached from Favourites or a search carries no
* notice, because nothing there promised one.
*
* Every field is wording the gateway already authored ([BaseItem.membyAirLabel] and
* friends), so the TV never computes an air time from a timestamp and can never contradict
* the card the viewer just pressed.
*/
data class AiringNotice(
/** The eyebrow: "AIRING TOMORROW", "AIRED TODAY", "NEW EPISODE READY". */
val label: String,
/** "S03E04 • The Crossing", or empty when Sonarr named neither. */
val headline: String,
/** "In 8 hours (4:00 PM) • Awaiting download". */
val detail: String,
)
/**
* The notice for a schedule card, or null for anything else including a movie-schedule
* card, which has no episode to announce and opens no series page.
*/
fun airingNoticeFor(item: BaseItem): AiringNotice? {
if (!item.isTvSchedule) return null
val day = item.membyAirDayLabel?.trim().orEmpty()
val airLabel = item.membyAirLabel?.trim().orEmpty()
val availability = item.membyAvailabilityText?.trim().orEmpty()
val headline = listOfNotNull(
item.membyEpisodeCode?.trim()?.takeIf(String::isNotEmpty),
item.membyEpisodeTitle?.trim()?.takeIf(String::isNotEmpty),
).joinToString(FactSeparator)
val detail = listOf(airLabel, availability)
.filter(String::isNotEmpty)
.joinToString(FactSeparator)
// A notice with nothing to say is worse than none: it would claim a schedule the page
// cannot name.
if (headline.isEmpty() && detail.isEmpty()) return null
return AiringNotice(label = airingNoticeLabel(day, airLabel, item.membyAvailability), headline, detail)
}
/**
* The Emby series a schedule card stands for, as much of it as the card itself knows, or
* null when the library has never imported the show.
*
* The page opens on this and fills in from Emby a moment later, the same way the launcher
* draws its cached rows before the network answers waiting on one item request before
* anything appears is the one thing that would make the row feel broken. The episode's own
* overview and artwork are deliberately left behind: they belong to the episode, and the
* page this becomes is about the show.
*/
fun scheduleSeriesStub(card: BaseItem): BaseItem? {
if (!card.isTvSchedule) return null
val seriesId = card.membySeriesItemId?.trim()?.takeIf(String::isNotEmpty) ?: return null
return BaseItem(
id = seriesId,
name = card.name,
type = "Series",
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.
availability == "available" -> "NEW EPISODE READY"
airLabel.startsWith("Aired", ignoreCase = true) ->
"AIRED ${day.ifEmpty { "TODAY" }.uppercase(Locale.ROOT)}"
day.isEmpty() || day.equals("Upcoming", ignoreCase = true) -> "UPCOMING EPISODE"
else -> "AIRING ${day.uppercase(Locale.ROOT)}"
}
@@ -1,6 +1,7 @@
package com.ponzischeme89.memby.ui.detail
import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.ui.theme.FactSeparator
import com.ponzischeme89.memby.ui.theme.ValueSeparator
import java.util.Locale
@@ -79,9 +80,29 @@ fun heroFacts(item: BaseItem, seasonCount: Int = 0): List<String> = buildList {
item.officialRating?.takeIf(String::isNotBlank)?.let(::add)
}
/** "8.9" for the score beside the title, or null when Emby has no community rating. */
fun ratingLabel(item: BaseItem): String? =
item.communityRating?.let { String.format(Locale.US, "%.1f", it) }
/**
* "12 Mar 2024" from Emby's `PremiereDate`, or null when there is no usable date.
*
* Only the date half of the timestamp is read, deliberately. Emby writes a premiere as
* midnight UTC, so resolving it against the set's own zone moves an episode a day earlier
* everywhere west of Greenwich and the day a programme aired is a fact about the
* broadcast, not an instant to be converted. Parsing the literal `YYYY-MM-DD` prefix also
* keeps this free of `java.time`, which this app's `minSdk` 23 cannot use.
*/
fun formatAirDate(raw: String?): String? {
val date = raw?.trim()?.takeIf { it.length >= 10 } ?: return null
if (date[4] != '-' || date[7] != '-') return null
val year = date.substring(0, 4).toIntOrNull() ?: return null
val month = date.substring(5, 7).toIntOrNull() ?: return null
val day = date.substring(8, 10).toIntOrNull() ?: return null
if (month !in 1..12 || day !in 1..31) return null
return "$day ${MONTH_ABBREVIATIONS[month - 1]} $year"
}
private val MONTH_ABBREVIATIONS = listOf(
"Jan", "Feb", "Mar", "Apr", "May", "Jun",
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
)
/** A label/value pair for the quiet technical area. */
data class TechnicalSpec(val label: String, val value: String)
@@ -301,20 +322,170 @@ fun episodeLabel(episode: BaseItem): String? {
return if (season != null) "S$season E$number" else "E$number"
}
/**
* "S04E04" the zero-padded form, used on the primary button and nowhere else.
*
* A button is read at a glance from across a room, and the padded code is the shape a
* viewer recognises without parsing it; [episodeLabel]'s spaced "S4 E4" is for running
* prose like [episodeHeadline], where it sits beside a title. Null when Emby numbered
* neither half, which is when the button has to fall back to a bare verb.
*/
fun paddedEpisodeCode(episode: BaseItem): String? {
val number = episode.indexNumber ?: return null
val season = episode.parentIndexNumber
?: return String.format(Locale.US, "E%02d", number)
return String.format(Locale.US, "S%02dE%02d", season, number)
}
/** "S2 E4 · The Crossing", falling back to whichever half is known. */
fun episodeHeadline(episode: BaseItem): String =
listOfNotNull(episodeLabel(episode), episode.name.takeIf(String::isNotBlank))
.joinToString(ValueSeparator)
// ---------------------------------------------------------------------------
// Episode pages
// ---------------------------------------------------------------------------
/**
* "SEASON 3 · EPISODE 4" the line under the series logo on an episode's own page.
*
* Spelled out rather than [episodeLabel]'s "S3 E4": this is the page's heading, where the
* short form is a code the viewer has to decode, and there is width for the words. Null
* when Emby numbered neither half, which is when the episode title has to carry the page
* on its own.
*/
fun episodeEyebrow(episode: BaseItem): String? {
val season = episode.parentIndexNumber?.let { if (it == 0) "SPECIALS" else "SEASON $it" }
val number = episode.indexNumber?.let { "EPISODE $it" }
return listOfNotNull(season, number).takeIf(List<String>::isNotEmpty)?.joinToString(ValueSeparator)
}
/** Where a season sits relative to the one the viewer is up to. */
enum class SeasonProgress { WATCHED, CURRENT, UPCOMING }
/**
* One stop on the season scroller: what to call it, how far through it the viewer is, and
* whether it is behind them, under them or ahead.
*/
data class SeasonMarker(
val season: Int,
val label: String,
val progress: SeasonProgress,
val watchedEpisodes: Int,
val totalEpisodes: Int,
) {
/** True when every episode the library holds for this season has been played. */
val complete: Boolean get() = totalEpisodes > 0 && watchedEpisodes == totalEpisodes
}
/**
* The scroller that replaces the tab strip on an episode page.
*
* A season counts as [SeasonProgress.WATCHED] when it is *behind* the episode being viewed
* or when nothing in it is left unplayed the two ways a household is done with a season.
* Being behind is enough on its own deliberately: someone who skipped an episode of season
* one and is now three seasons along has moved on, and greying it is the honest summary of
* where they are. Everything after the current season stays
* [SeasonProgress.UPCOMING] however much of it has been sampled, because a scroller that
* dims what is still to come says the show is finished when it is not.
*/
fun seasonMarkers(episodes: List<BaseItem>, currentSeason: Int?): List<SeasonMarker> =
availableSeasons(episodes).map { season ->
val inSeason = episodes.filter { it.parentIndexNumber == season }
val watched = inSeason.count(BaseItem::isPlayed)
val progress = when {
currentSeason != null && season == currentSeason -> SeasonProgress.CURRENT
inSeason.isNotEmpty() && watched == inSeason.size -> SeasonProgress.WATCHED
// Specials sort first but are not on the way to anywhere, so being "before"
// the current season earns them nothing: a tick on a special nobody has
// watched is the one claim this scroller must not make.
currentSeason != null && season in 1 until currentSeason -> SeasonProgress.WATCHED
else -> SeasonProgress.UPCOMING
}
SeasonMarker(
season = season,
label = seasonLabel(season),
progress = progress,
watchedEpisodes = watched,
totalEpisodes = inSeason.size,
)
}
/** "5 of 8 watched", or null for a season the library has nothing for. */
fun seasonProgressLabel(marker: SeasonMarker): String? = when {
marker.totalEpisodes <= 0 -> null
marker.complete && marker.totalEpisodes == 1 -> "Watched"
marker.complete -> "All ${marker.totalEpisodes} watched"
marker.watchedEpisodes == 0 && marker.totalEpisodes == 1 -> "1 episode"
marker.watchedEpisodes == 0 -> "${marker.totalEpisodes} episodes"
else -> "${marker.watchedEpisodes} of ${marker.totalEpisodes} watched"
}
/**
* The whole-series line beside the scroller: "Season 3 of 5 · 12 episodes left".
*
* Null when there is nothing worth saying a one-season show with everything watched has
* no progress to report, and a bar that says so anyway is noise on every episode page.
*/
fun seriesProgressLabel(markers: List<SeasonMarker>, currentSeason: Int?): String? {
if (markers.isEmpty()) return null
// Counted over the numbered seasons only. "Season 3 of 5" on a four-season show with a
// making-of special is wrong in the one place the viewer is most likely to read it.
val numbered = markers.filter { it.season > 0 }
val position = numbered.indexOfFirst { it.season == currentSeason }
val place = if (position >= 0 && numbered.size > 1) {
"${numbered[position].label} of ${numbered.size}"
} else {
null
}
val left = markers.sumOf { it.totalEpisodes - it.watchedEpisodes }
val remaining = when {
left <= 0 -> null
left == 1 -> "1 episode left"
else -> "$left episodes left"
}
return listOfNotNull(place, remaining).takeIf(List<String>::isNotEmpty)?.joinToString(FactSeparator)
}
/**
* The episode that follows [episode] in the same library, or null at the end of the show.
*
* Position in running order decides it, never the length of the list the same rule the
* player's auto-advance follows, so the page's "Up next" and what actually plays cannot
* disagree.
*/
fun episodeAfter(episodes: List<BaseItem>, episode: BaseItem): BaseItem? {
val ordered = episodes.sortedWith(seriesEpisodeComparator)
val index = ordered.indexOfFirst { it.id == episode.id }
return if (index < 0) null else ordered.getOrNull(index + 1)
}
/**
* The primary button. Series pass their next episode so the button can name it; a movie
* passes itself.
* passes itself; an episode names itself without being asked.
*
* An episode's button says "Play S04E04" because that page is reached from Continue
* Watching, where the viewer chose a specific episode and the one thing the button has to
* confirm is that it is the one they meant. A bare "Play" on a page headed by the series
* logo could plausibly mean the show.
*/
fun primaryActionLabel(item: BaseItem, nextEpisode: BaseItem? = null): String = when {
nextEpisode != null && nextEpisode.isResumable -> "Resume ${episodeLabel(nextEpisode) ?: "episode"}"
nextEpisode != null -> "Play ${episodeLabel(nextEpisode) ?: "next episode"}"
item.isResumable -> "Resume"
else -> "Play"
fun primaryActionLabel(item: BaseItem, nextEpisode: BaseItem? = null): String {
val episode = nextEpisode ?: item.takeIf(BaseItem::isEpisode)
if (episode != null) {
val verb = if (episode.isResumable) "Resume" else "Play"
val code = paddedEpisodeCode(episode)
return when {
code != null -> "$verb $code"
// An unnumbered episode of somebody else's show still has to say something.
nextEpisode != null -> "$verb next episode"
else -> verb
}
}
return when {
item.isResumable -> "Resume"
item.isMovie && item.isPlayed -> "Rewatch"
else -> "Play"
}
}
/** The supporting line under the primary button, or null when there is nothing to add. */
@@ -0,0 +1,185 @@
package com.ponzischeme89.memby.ui.player
import android.content.Context
import android.graphics.Color
import android.graphics.Typeface
import android.text.TextUtils
import android.view.Gravity
import android.view.View
import android.view.ViewGroup
import android.widget.FrameLayout
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.TextView
import androidx.core.view.isVisible
import com.ponzischeme89.memby.R
/** One face in the cast panel. [role] is the character, which is what a viewer is asking. */
data class CastMember(
val name: String,
val role: String? = null,
val imageUrl: String? = null,
)
/**
* What the panel is showing.
*
* [loaded] is separate from an empty [members] list because "still fetching" and "this
* title has no cast recorded" are different things to be told, and a spinner that never
* resolves is the worse of the two to leave on screen.
*/
data class CastPanelState(
val title: String = "",
val members: List<CastMember> = emptyList(),
val loaded: Boolean = false,
)
/**
* Fills the cast overlay from a plain state.
*
* Like [bindSubtitleMenu], it lives apart from [PlayerActivity] so `CastPanelScreenshotTest`
* can render the real panel these cards, these colours, this layout with no player, no
* Emby server and no network. [loadImage] is injected for the same reason: artwork is the
* one thing a screenshot test cannot fetch, and passing it in means the test renders the
* initials fallback rather than a blank where every portrait should be.
*/
fun bindCastPanel(
overlay: View,
state: CastPanelState,
loadImage: (ImageView, String) -> Unit = { _, _ -> },
) {
val context = overlay.context
overlay.findViewById<TextView>(R.id.player_cast_title).apply {
text = state.title
isVisible = state.title.isNotBlank()
}
overlay.findViewById<TextView>(R.id.player_cast_status).apply {
text = when {
!state.loaded -> context.getString(R.string.player_cast_loading)
state.members.isEmpty() -> context.getString(R.string.player_cast_empty)
else -> ""
}
isVisible = !text.isNullOrEmpty()
}
val people = overlay.findViewById<LinearLayout>(R.id.player_cast_people)
people.removeAllViews()
state.members.forEach { member -> people.addView(castCard(context, member, loadImage)) }
overlay.findViewById<View>(R.id.player_cast_scroller).isVisible = state.members.isNotEmpty()
}
private fun castCard(
context: Context,
member: CastMember,
loadImage: (ImageView, String) -> Unit,
): View {
val density = context.resources.displayMetrics.density
fun dp(value: Int) = (value * density).toInt()
return LinearLayout(context).apply {
orientation = LinearLayout.VERTICAL
isFocusable = true
isClickable = true
// Nothing happens on a press. The panel is a reference, not a destination — there
// is no person page to open — but the card still has to be focusable, or a D-pad
// cannot scroll the row at all.
setOnClickListener { }
clipChildren = false
layoutParams = LinearLayout.LayoutParams(dp(132), ViewGroup.LayoutParams.WRAP_CONTENT).apply {
marginEnd = dp(18)
}
setOnFocusChangeListener { view, focused ->
view.animate()
.scaleX(if (focused) 1.06f else 1f)
.scaleY(if (focused) 1.06f else 1f)
.setDuration(120L)
.start()
}
addView(castPortrait(context, member, loadImage, ::dp))
addView(
TextView(context).apply {
text = member.name
setTextColor(Color.WHITE)
textSize = 14f
typeface = Typeface.create("sans-serif", Typeface.BOLD)
maxLines = 1
ellipsize = TextUtils.TruncateAt.END
setPadding(0, dp(9), 0, 0)
},
)
member.role?.takeIf(String::isNotBlank)?.let { role ->
addView(
TextView(context).apply {
text = role
setTextColor(Color.rgb(158, 168, 178))
textSize = 11f
maxLines = 1
ellipsize = TextUtils.TruncateAt.END
setPadding(0, dp(2), 0, 0)
},
)
}
}
}
/**
* The portrait, with the person's initials underneath it.
*
* Emby has no photo for a good part of a typical cast, and a row of identical grey
* rectangles tells a viewer nothing about which name is which. The initials sit *behind*
* the image rather than replacing it, so nothing has to decide in advance whether the
* artwork will arrive when it does, it simply covers them.
*/
private fun castPortrait(
context: Context,
member: CastMember,
loadImage: (ImageView, String) -> Unit,
dp: (Int) -> Int,
): View = FrameLayout(context).apply {
layoutParams = LinearLayout.LayoutParams(dp(132), dp(176))
background = context.getDrawable(R.drawable.player_cast_portrait_background)
// The ring is drawn over the artwork, and the frame follows the card's focus rather
// than its own — the card is what takes focus, the portrait is never focusable itself.
foreground = context.getDrawable(R.drawable.player_cast_portrait_frame)
isDuplicateParentStateEnabled = true
clipToOutline = true
addView(
TextView(context).apply {
layoutParams = FrameLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT,
)
text = castInitials(member.name)
setTextColor(Color.rgb(126, 138, 148))
textSize = 34f
typeface = Typeface.create("sans-serif-light", Typeface.NORMAL)
gravity = Gravity.CENTER
},
)
addView(
ImageView(context).apply {
layoutParams = FrameLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT,
)
scaleType = ImageView.ScaleType.CENTER_CROP
contentDescription = member.name
member.imageUrl?.takeIf(String::isNotBlank)?.let { loadImage(this, it) }
},
)
}
/**
* Up to two initials from a name. Pure, so it can be tested without a view: the cases that
* matter are a mononym, a name with a middle name (the *last* initial is the useful one,
* not the second) and a blank, which yields nothing rather than a stray character.
*/
internal fun castInitials(name: String): String {
val parts = name.trim().split(Regex("\\s+")).filter(String::isNotBlank)
return when (parts.size) {
0 -> ""
1 -> parts[0].take(1).uppercase()
else -> (parts.first().take(1) + parts.last().take(1)).uppercase()
}
}
@@ -11,6 +11,7 @@ internal data class PlaybackFailure(
val detail: String,
val canAutoRetry: Boolean,
val requiresTranscode: Boolean = false,
val requiresFreshStream: Boolean = false,
)
internal fun describePlaybackFailure(errorCode: Int): PlaybackFailure =
@@ -18,6 +19,7 @@ internal fun describePlaybackFailure(errorCode: Int): PlaybackFailure =
PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED,
PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_TIMEOUT,
PlaybackException.ERROR_CODE_IO_UNSPECIFIED,
PlaybackException.ERROR_CODE_TIMEOUT,
-> PlaybackFailure(
title = "Connection interrupted",
detail = "Memby couldnt keep a reliable connection to the media server.",
@@ -26,10 +28,13 @@ internal fun describePlaybackFailure(errorCode: Int): PlaybackFailure =
PlaybackException.ERROR_CODE_IO_BAD_HTTP_STATUS,
PlaybackException.ERROR_CODE_IO_FILE_NOT_FOUND,
PlaybackException.ERROR_CODE_IO_INVALID_HTTP_CONTENT_TYPE,
PlaybackException.ERROR_CODE_IO_READ_POSITION_OUT_OF_RANGE,
-> PlaybackFailure(
title = "Video unavailable",
detail = "The media server couldnt provide this video. It may have moved or be temporarily unavailable.",
canAutoRetry = false,
canAutoRetry = true,
requiresFreshStream = true,
)
PlaybackException.ERROR_CODE_IO_CLEARTEXT_NOT_PERMITTED,
@@ -0,0 +1,67 @@
package com.ponzischeme89.memby.ui.player
/**
* Times the stages between pressing Play and seeing a frame.
*
* "Playback is slow" is not something that can be acted on: the wait is shared between
* negotiating a stream with the server, starting the activity, opening the file over HTTP
* and initialising a decoder, and on a weak television any one of them can dominate.
* Cumulative marks make the log say which.
*
* Marks are cumulative from the moment the viewer pressed Play the interesting number is
* always "how long had they been waiting", not how long one step took in isolation and
* [summary] prints the per-stage deltas beside them so both readings are available. Pure
* and clock-injected so the arithmetic is unit-testable; [PlayerActivity] supplies
* `SystemClock.elapsedRealtime`.
*/
internal class PlaybackTrace(
private val startedAtMs: Long,
private val clock: () -> Long,
) {
private val marks = LinkedHashMap<String, Long>()
/** Records [stage] at the current time and returns milliseconds since Play was pressed. */
fun mark(stage: String): Long {
val elapsed = (clock() - startedAtMs).coerceAtLeast(0L)
// A stage only ever happens once per launch; a repeat is a retry, and the first
// time the viewer reached that point is the one that describes their wait.
marks.putIfAbsent(stage, elapsed)
return marks.getValue(stage)
}
fun elapsedMs(): Long = (clock() - startedAtMs).coerceAtLeast(0L)
/**
* One line naming every stage reached, as `stage=cumulative(+delta)`. Stages that never
* happened are simply absent rather than reported as zero on the resume path there is
* no pre-roll, and a launch that reused a prefetched stream never resolved one.
*/
fun summary(): String {
var previous = 0L
return marks.entries.joinToString(" ") { (stage, elapsed) ->
val delta = (elapsed - previous).coerceAtLeast(0L)
previous = elapsed
"$stage=${elapsed}ms(+${delta}ms)"
}
}
companion object {
/** The viewer's Play press has been handled and the player activity is alive. */
const val ACTIVITY_CREATED = "activity"
/** The ExoPlayer instance exists and its renderers are built. */
const val PLAYER_BUILT = "player"
/** A stream URL is in hand, whether from the launch prefetch or a fresh request. */
const val STREAM_RESOLVED = "stream"
/** [androidx.media3.exoplayer.ExoPlayer.prepare] has been called. */
const val PREPARED = "prepared"
/** The player reported it has buffered enough to play. */
const val READY = "ready"
/** A frame is on the screen. This is the number the viewer actually experiences. */
const val FIRST_FRAME = "first_frame"
}
}
@@ -0,0 +1,49 @@
package com.ponzischeme89.memby.ui.player
import androidx.tracing.Trace
import java.util.concurrent.atomic.AtomicInteger
/**
* Names the two spans of a playback launch in a systrace, so `:benchmark` can measure
* what [PlaybackTrace] can only log.
*
* The log line is for reading after the fact; these are for a machine that has to compare
* a hundred launches and tell you whether a change made things worse. They measure the same
* two things, from the same three points in [PlayerActivity], which is why the names live
* here beside each other rather than being typed into a benchmark and a player separately
* `:benchmark` imports [LAUNCH] and [FIRST_FRAME] by reference, so a rename cannot quietly
* turn the benchmark into one that measures nothing and reports zero.
*
* These are *async* sections deliberately: a launch begins on the main thread in `onCreate`
* and ends in a media3 callback, and a synchronous begin/end pair cannot span that.
*
* Everything here is a no-op unless a trace is being captured, so this costs a released
* build an atomic increment and a boolean check per playback.
*/
internal object PlaybackTraceSections {
/** The viewer pressed Play until there is a picture. What they actually experience. */
const val LAUNCH = "Memby.playbackLaunch"
/**
* `prepare()` until there is a picture: opening the file over HTTP, seeking to the
* resume point, and initialising a decoder. Measured separately because it is by far
* the largest share of a resume, and the one that a change to the player's HTTP stack
* or buffering would move.
*/
const val FIRST_FRAME = "Memby.playbackFirstFrame"
private val cookies = AtomicInteger()
/** A cookie distinguishing overlapping sections; auto-advance can start one while the
* previous episode's is still open. */
fun nextCookie(): Int = cookies.incrementAndGet()
fun begin(name: String, cookie: Int) {
if (Trace.isEnabled()) Trace.beginAsyncSection(name, cookie)
}
fun end(name: String, cookie: Int) {
if (Trace.isEnabled()) Trace.endAsyncSection(name, cookie)
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,108 @@
package com.ponzischeme89.memby.ui.player
import android.content.Context
import androidx.media3.common.AudioAttributes
import androidx.media3.common.C
import androidx.media3.common.util.UnstableApi
import androidx.media3.datasource.DefaultDataSource
import androidx.media3.datasource.okhttp.OkHttpDataSource
import androidx.media3.exoplayer.DefaultLoadControl
import androidx.media3.exoplayer.DefaultRenderersFactory
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
import androidx.media3.exoplayer.trackselection.DefaultTrackSelector
import androidx.media3.extractor.DefaultExtractorsFactory
import com.ponzischeme89.memby.data.remote.HttpStack
import java.util.concurrent.TimeUnit
/**
* Builds the one ExoPlayer this app plays video with.
*
* It lives apart from [PlayerActivity] because construction is on the critical path of
* every launch and wants to happen before any of the activity's decoration: the activity
* builds this first, hands it the stream, and only then wires up its overlays.
*
* The configuration here is aimed squarely at *time to first frame on a resume*, which is
* the slowest thing the player does a resume is a seek, and a seek over HTTP is several
* more requests before a single frame is decoded. Two of the choices are borrowed from
* Wholphin (https://github.com/damontecres/Wholphin), an Android TV Jellyfin client under
* the same GPL-2.0 licence as this app: constant-bitrate seeking, and pulling media bytes
* through the app's own OkHttp client rather than media3's default.
*/
@UnstableApi
internal object PlayerEngine {
fun create(context: Context): ExoPlayer = ExoPlayer.Builder(context)
.setMediaSourceFactory(mediaSourceFactory(context))
.setRenderersFactory(
DefaultRenderersFactory(context)
// Some Android TV firmwares advertise a preferred hardware decoder which
// fails only after initialization. Let Media3 try another installed decoder
// before declaring the file unsupported.
.setEnableDecoderFallback(true),
)
.setTrackSelector(DefaultTrackSelector(context))
.setLoadControl(loadControl())
.build()
.apply {
setAudioAttributes(
AudioAttributes.Builder().setContentType(C.AUDIO_CONTENT_TYPE_MOVIE).build(),
/* handleAudioFocus = */ false,
)
}
private fun mediaSourceFactory(context: Context) = DefaultMediaSourceFactory(
// DefaultDataSource still handles the non-HTTP schemes (file:, asset:, content:);
// only the HTTP half is swapped for ours.
DefaultDataSource.Factory(context, OkHttpDataSource.Factory(streamClient)),
extractorsFactory(),
)
/**
* Derived from the shared stack, so the stream, the artwork and the API calls share one
* connection pool. That is what a resume is really paying for: opening a file part-way
* through means a request for the container header, usually another for the seek index
* (in a Matroska file the cues commonly sit at the *end*), and then a third at the
* offset the viewer actually stopped at. On the default client each of those is a fresh
* connection and over HTTPS, a fresh TLS handshake before any bytes arrive.
*
* The read timeout is generous rather than absent: a stalled stream must eventually
* fail so [PlaybackRecovery] can re-negotiate it, but it must not fail on a slow seek.
* No call timeout is set, and none should be that would cap the length of the film.
*/
private val streamClient by lazy {
HttpStack.base.newBuilder()
.connectTimeout(STREAM_CONNECT_TIMEOUT_SECONDS, TimeUnit.SECONDS)
.readTimeout(STREAM_READ_TIMEOUT_SECONDS, TimeUnit.SECONDS)
.build()
}
/**
* Constant-bitrate seeking is the difference between a resume that is a byte-offset
* calculation and one that is a scan through the file. Media3 only uses it for
* containers with no seek table of their own, so switching it on cannot make an indexed
* MP4 or MKV any less accurate; it only rescues the formats that would otherwise have
* to read their way to the position. `AlwaysEnabled` extends that to streams that
* declare a seek table but cannot use it.
*/
private fun extractorsFactory() = DefaultExtractorsFactory()
.setConstantBitrateSeekingEnabled(true)
.setConstantBitrateSeekingAlwaysEnabled(true)
private fun loadControl() = DefaultLoadControl.Builder()
.setBufferDurationsMs(
MIN_BUFFER_MS,
MAX_BUFFER_MS,
BUFFER_FOR_PLAYBACK_MS,
BUFFER_AFTER_REBUFFER_MS,
)
.setPrioritizeTimeOverSizeThresholds(true)
.build()
private const val STREAM_CONNECT_TIMEOUT_SECONDS = 15L
private const val STREAM_READ_TIMEOUT_SECONDS = 30L
private const val MIN_BUFFER_MS = 10_000
private const val MAX_BUFFER_MS = 30_000
private const val BUFFER_FOR_PLAYBACK_MS = 750
private const val BUFFER_AFTER_REBUFFER_MS = 1_500
}
@@ -0,0 +1,100 @@
package com.ponzischeme89.memby.ui.player
import kotlin.math.abs
/**
* The arithmetic and the wording behind Left/Right skipping, kept apart from
* [PlayerActivity] so it can be unit-tested without a player, a decoder or a server.
*
* The shape that matters is [SeekPreview]: a press does not seek. It moves a *target* that
* the OSD renders, and the seek is committed once the presses stop. Seeking over HTTP
* costs several requests before a frame is decoded (see "Time to first frame" in
* CLAUDE.md), so four quick presses of Right must be one seek of two minutes rather than
* four seeks the viewer waits through in turn which is also what makes the number on
* screen honest while they are still pressing.
*/
/**
* The last second of a title is not a place to land: seeking there ends playback, which on
* an episode rolls into the next one. Fast-forward stops short of the end instead.
*/
private const val SEEK_END_GUARD_MS: Long = 1_000L
/**
* Where the next commit will land, and how far that is from where the burst of presses
* started. [offsetMs] is deliberately cumulative rather than one step: the viewer is
* reading a running total ("Forward 1 min 30 secs"), not the last button they pressed.
*/
data class SeekPreview(val targetMs: Long, val offsetMs: Long)
/**
* Adds one step to an in-flight preview, or starts one from the playhead.
*
* [previous] is the base when there is one, never the live position: the film keeps
* playing while somebody holds down Right, and measuring each step from the playhead
* would quietly swallow part of every press after the first.
*/
fun accumulateSeek(
previous: SeekPreview?,
positionMs: Long,
durationMs: Long,
stepMs: Long,
forward: Boolean,
): SeekPreview {
val origin = previous?.let { it.targetMs - it.offsetMs } ?: positionMs.coerceAtLeast(0L)
val base = previous?.targetMs ?: positionMs.coerceAtLeast(0L)
val step = if (forward) stepMs else -stepMs
val target = clampSeekTarget(base + step, durationMs)
return SeekPreview(targetMs = target, offsetMs = target - origin)
}
/** Bounds a target to the title: never before the start, never onto its final second. */
fun clampSeekTarget(targetMs: Long, durationMs: Long): Long {
if (durationMs <= 0L) return targetMs.coerceAtLeast(0L)
val ceiling = (durationMs - SEEK_END_GUARD_MS).coerceAtLeast(0L)
return targetMs.coerceIn(0L, ceiling)
}
/**
* "30 seconds", "1 min 30 secs" the amount alone, with no sign. Direction is said by the
* glyph and by the wording around it ("Back …" / "Forward …"), because a minus sign read
* across a room at the size this is drawn is not a difference anybody can see.
*/
fun seekAmountLabel(offsetMs: Long): String {
val totalSeconds = (abs(offsetMs) + 500L) / 1_000L
val minutes = totalSeconds / 60L
val seconds = totalSeconds % 60L
return when {
minutes == 0L -> if (seconds == 1L) "1 second" else "$seconds seconds"
seconds == 0L -> if (minutes == 1L) "1 min" else "$minutes mins"
else -> "${if (minutes == 1L) "1 min" else "$minutes mins"} " +
if (seconds == 1L) "1 sec" else "$seconds secs"
}
}
/** Clock for the OSD: hours only when the title has them, so a 42-minute episode reads short. */
fun formatSeekClock(positionMs: Long): String {
val totalSeconds = positionMs.coerceAtLeast(0L) / 1_000L
val hours = totalSeconds / 3_600L
val minutes = (totalSeconds % 3_600L) / 60L
val seconds = totalSeconds % 60L
return if (hours > 0L) {
"%d:%02d:%02d".format(hours, minutes, seconds)
} else {
"%d:%02d".format(minutes, seconds)
}
}
/**
* "12:34 / 1:45:00". Both halves are formatted against the *duration*, so a position under
* an hour in a two-hour film still reads with the hours field its total has.
*/
fun seekPositionLabel(targetMs: Long, durationMs: Long): String {
if (durationMs <= 0L) return formatSeekClock(targetMs)
val position = if (durationMs >= 3_600_000L && targetMs < 3_600_000L) {
"0:" + formatSeekClock(targetMs).padStart(5, '0')
} else {
formatSeekClock(targetMs)
}
return "$position / ${formatSeekClock(durationMs)}"
}
@@ -0,0 +1,141 @@
package com.ponzischeme89.memby.ui.player
import android.content.Context
import android.graphics.Typeface
import android.text.TextUtils
import android.view.Gravity
import android.view.View
import android.view.ViewGroup
import android.widget.LinearLayout
import android.widget.TextView
import androidx.core.view.isVisible
import com.ponzischeme89.memby.R
/**
* One entry in the subtitle drop-up: what it says, whether it is the choice in force, and
* whether it can be pressed. [enabled] is false while a search is running the row is
* still drawn, because removing it would move everything under the viewer's thumb, but it
* cannot take focus and so cannot start a second search.
*/
data class SubtitleMenuEntry(
val label: String,
val selected: Boolean,
val enabled: Boolean = true,
)
/**
* The download section's whole state.
*
* [available] is the gateway's answer to "can this backend fetch a subtitle at all", and
* false hides the section rather than disabling it: a row that can never do anything is
* worse than no row. [status] is the sentence printed above the list it carries both the
* waiting message and the reason there is nothing, which are the two things a viewer
* standing in front of the set needs and cannot get anywhere else.
*/
data class SubtitleDownloadState(
val available: Boolean = false,
val status: String = "",
val entries: List<SubtitleMenuEntry> = emptyList(),
/**
* Whether the panel has switched to being *about* downloading, which hides the track
* list and the text-size chips. It is not decoration: the drop-up is 344dp wide by
* about a third of a 720p screen, and holding a track list, a size row and a list of
* search results at once squeezed the tracks down to a single visible row. One question
* at a time, and Back is what steps out of this one.
*/
val expanded: Boolean = false,
)
/**
* Fills the drop-up's containers from plain lists.
*
* It lives apart from [PlayerActivity] so `SubtitleMenuScreenshotTest` can render the real
* menu these rows, these colours, this layout without a player, an Emby server or a
* decoder, the same property that makes the detail page's content composables previewable.
* Deciding *what* the entries are stays with the activity, which is the only thing that can
* read media3's tracks and the only thing that can talk to the repository.
*/
fun bindSubtitleMenu(
overlay: View,
tracks: List<SubtitleMenuEntry>,
sizes: List<SubtitleMenuEntry>,
downloads: SubtitleDownloadState = SubtitleDownloadState(),
onTrack: (Int) -> Unit = {},
onSize: (Int) -> Unit = {},
onDownload: (Int) -> Unit = {},
) {
val context = overlay.context
val trackContainer = overlay.findViewById<LinearLayout>(R.id.player_subtitle_tracks)
val sizeContainer = overlay.findViewById<LinearLayout>(R.id.player_subtitle_sizes)
trackContainer.removeAllViews()
sizeContainer.removeAllViews()
tracks.forEachIndexed { index, entry ->
trackContainer.addView(subtitleMenuOption(context, entry) { onTrack(index) })
}
sizes.forEachIndexed { index, entry ->
sizeContainer.addView(subtitleMenuOption(context, entry, chip = true) { onSize(index) })
}
bindDownloadSection(overlay, downloads, onDownload)
}
private fun bindDownloadSection(
overlay: View,
downloads: SubtitleDownloadState,
onDownload: (Int) -> Unit,
) {
val section = overlay.findViewById<View>(R.id.player_subtitle_download_section) ?: return
section.isVisible = downloads.available
// The two halves swap: expanded means the panel is about downloading and nothing else.
// The rule goes with the menu, or it draws a line under the panel's own top edge.
val expanded = downloads.available && downloads.expanded
overlay.findViewById<View>(R.id.player_subtitle_main_section).isVisible = !expanded
overlay.findViewById<View>(R.id.player_subtitle_download_rule).isVisible = !expanded
if (!downloads.available) return
overlay.findViewById<TextView>(R.id.player_subtitle_download_status).apply {
text = downloads.status
isVisible = downloads.status.isNotBlank()
}
val container = overlay.findViewById<LinearLayout>(R.id.player_subtitle_downloads)
container.removeAllViews()
downloads.entries.forEachIndexed { index, entry ->
container.addView(subtitleMenuOption(overlay.context, entry) { onDownload(index) })
}
}
/**
* One row of the drop-up. A [chip] sizes itself to its label for the text-size row.
* Nothing carries a tick: the current choice is the plate and the green label, and a tick on
* only some rows shifted every other label along by its width.
*/
private fun subtitleMenuOption(
context: Context,
entry: SubtitleMenuEntry,
chip: Boolean = false,
onClick: () -> Unit,
): TextView = TextView(context).apply {
val density = context.resources.displayMetrics.density
fun dp(value: Int) = (value * density).toInt()
layoutParams = LinearLayout.LayoutParams(
if (chip) ViewGroup.LayoutParams.WRAP_CONTENT else ViewGroup.LayoutParams.MATCH_PARENT,
dp(if (chip) 40 else 46),
).apply {
if (chip) marginEnd = dp(6) else bottomMargin = dp(2)
}
background = context.getDrawable(R.drawable.player_overlay_option_background)
setTextColor(context.getColorStateList(R.color.player_overlay_option_text))
gravity = if (chip) Gravity.CENTER else Gravity.CENTER_VERTICAL
setPadding(dp(14), 0, dp(14), 0)
text = entry.label
maxLines = 1
ellipsize = TextUtils.TruncateAt.END
textSize = if (chip) 14f else 15f
typeface = Typeface.create("sans-serif", if (entry.selected) Typeface.BOLD else Typeface.NORMAL)
// A disabled row keeps its place and its label and only stops being reachable, so a
// search in flight cannot be started twice and nothing moves while it runs.
isFocusable = entry.enabled
isClickable = entry.enabled
alpha = if (entry.enabled) 1f else 0.45f
isSelected = entry.selected
setOnClickListener { onClick() }
}
@@ -16,6 +16,7 @@ import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
@@ -39,7 +40,9 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Backspace
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.AutoAwesome
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.LiveTv
import androidx.compose.material.icons.filled.Movie
import androidx.compose.material.icons.filled.PlayCircleFilled
@@ -63,7 +66,9 @@ import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.key.type
@@ -82,6 +87,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.tv.material3.Icon
import androidx.tv.material3.Text
import coil.imageLoader
import coil.compose.AsyncImage
import coil.request.ImageRequest
import com.ponzischeme89.memby.ServiceLocator
import com.ponzischeme89.memby.data.model.BaseItem
@@ -653,6 +659,7 @@ private fun ResultsPane(
resultsEntry = resultsEntry,
keyboardReturn = keyboardReturn,
onRequest = onRequest,
modifier = Modifier.weight(1f),
)
items.isEmpty() -> SearchEmptyMessage(state = state, showingDiscovery = showingDiscovery)
else -> ResultsGrid(
@@ -772,45 +779,253 @@ private fun RequestOptions(
resultsEntry: FocusRequester,
keyboardReturn: FocusRequester,
onRequest: (GatewayRequestCandidate) -> Unit,
modifier: Modifier = Modifier,
previewArtwork: ImageBitmap? = null,
) {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
Text(
"Not in your library? Request it:",
color = Muted,
fontSize = 16.sp,
)
state.requestCandidates.take(6).forEachIndexed { index, candidate ->
val busy = state.requestingCandidateKey == "${candidate.mediaType}:${candidate.foreignId}"
val label = when {
candidate.alreadyAdded -> "${candidate.title} — already requested"
busy -> "Requesting ${candidate.title}"
else -> "Request ${if (candidate.mediaType == "movie") "movie" else "show"}: " +
candidate.title + candidate.year.takeIf { it > 0 }?.let { " ($it)" }.orEmpty()
Column(
modifier = modifier
.fillMaxSize()
.clip(RoundedCornerShape(16.dp))
.background(Color(0xFF11171C))
.border(1.dp, Color.White.copy(alpha = 0.08f), RoundedCornerShape(16.dp))
.padding(20.dp),
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Box(
Modifier
.size(42.dp)
.clip(RoundedCornerShape(11.dp))
.background(Accent.copy(alpha = 0.16f)),
contentAlignment = Alignment.Center,
) {
Icon(Icons.Default.Add, contentDescription = null, tint = Accent, modifier = Modifier.size(24.dp))
}
FocusScaleContainer(
onFocused = {},
onClick = { onRequest(candidate) },
contentDescription = label,
modifier = Modifier
.clip(RoundedCornerShape(10.dp))
.then(if (index == 0) Modifier.focusRequester(resultsEntry) else Modifier)
.focusProperties { left = keyboardReturn },
) { focused ->
Spacer(Modifier.width(13.dp))
Column(Modifier.weight(1f)) {
Text("Request something new", color = Heading, fontSize = 20.sp, fontWeight = FontWeight.Bold)
Text(
label,
color = if (focused) KeyLabelFocused else KeyLabel,
fontSize = 15.sp,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
"We found a few close matches. Choose the exact movie or series you want added.",
color = Muted,
fontSize = 13.sp,
maxLines = 2,
)
}
Text(
"${state.requestCandidates.take(6).size} MATCH${if (state.requestCandidates.size == 1) "" else "ES"}",
color = Accent,
fontSize = 11.sp,
fontWeight = FontWeight.Bold,
)
}
Spacer(Modifier.height(16.dp))
LazyVerticalGrid(
columns = GridCells.Fixed(2),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.weight(1f),
) {
itemsIndexed(
state.requestCandidates.take(6),
key = { _, candidate -> "${candidate.mediaType}:${candidate.foreignId}" },
) { index, candidate ->
RequestCandidateCard(
candidate = candidate,
busy = state.requestingCandidateKey == "${candidate.mediaType}:${candidate.foreignId}",
onRequest = { onRequest(candidate) },
previewArtwork = previewArtwork,
modifier = Modifier
.fillMaxWidth()
.background(if (focused) KeyFocused else KeyIdle)
.padding(horizontal = 18.dp, vertical = 12.dp),
.then(if (index == 0) Modifier.focusRequester(resultsEntry) else Modifier)
.focusProperties { if (index % 2 == 0) left = keyboardReturn },
)
}
}
state.requestMessage?.let { Text(it, color = Muted, fontSize = 14.sp) }
state.requestMessage?.let { message ->
val statusColor = if (state.requestMessageIsError) Color(0xFFFF8A80) else Accent
Spacer(Modifier.height(12.dp))
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(10.dp))
.background(statusColor.copy(alpha = 0.12f))
.padding(horizontal = 14.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
if (state.requestMessageIsError) Icons.Default.Close else Icons.Default.CheckCircle,
contentDescription = null,
tint = statusColor,
modifier = Modifier.size(18.dp),
)
Spacer(Modifier.width(9.dp))
Text(message, color = KeyLabel, fontSize = 14.sp, fontWeight = FontWeight.Medium)
}
}
}
}
@Composable
private fun RequestCandidateCard(
candidate: GatewayRequestCandidate,
busy: Boolean,
onRequest: () -> Unit,
modifier: Modifier = Modifier,
previewArtwork: ImageBitmap? = null,
) {
val mediaLabel = if (candidate.mediaType.equals("movie", ignoreCase = true)) "MOVIE" else "TV SERIES"
val actionLabel = when {
candidate.alreadyAdded -> "REQUESTED"
busy -> "REQUESTING…"
else -> "REQUEST"
}
FocusScaleContainer(
onFocused = {},
onClick = { if (!candidate.alreadyAdded && !busy) onRequest() },
contentDescription = "${candidate.title}, $mediaLabel, $actionLabel",
modifier = modifier,
) { focused ->
Row(
modifier = Modifier
.fillMaxWidth()
.height(140.dp)
.clip(RoundedCornerShape(12.dp))
.background(
when {
focused -> Color(0xFF203228)
candidate.alreadyAdded -> Color(0xFF17231C)
else -> Color(0xFF1A2127)
},
)
.border(
width = if (focused) 2.dp else 1.dp,
color = when {
focused -> Accent
candidate.alreadyAdded -> Accent.copy(alpha = 0.45f)
else -> Color.White.copy(alpha = 0.10f)
},
shape = RoundedCornerShape(12.dp),
)
.padding(9.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(
modifier = Modifier
.width(76.dp)
.fillMaxHeight()
.clip(RoundedCornerShape(8.dp))
.background(Color(0xFF0B0F12)),
contentAlignment = Alignment.Center,
) {
if (previewArtwork != null) {
Image(
bitmap = previewArtwork,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize(),
)
} else if (candidate.posterUrl.isNotBlank()) {
AsyncImage(
model = candidate.posterUrl,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize(),
)
} else {
Icon(
if (mediaLabel == "MOVIE") Icons.Default.Movie else Icons.Default.LiveTv,
contentDescription = null,
tint = Muted.copy(alpha = 0.65f),
modifier = Modifier.size(30.dp),
)
}
}
Spacer(Modifier.width(12.dp))
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(5.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(mediaLabel, color = Accent, fontSize = 10.sp, fontWeight = FontWeight.Bold)
candidate.year.takeIf { it > 0 }?.let { year ->
Text("$year", color = Muted, fontSize = 11.sp)
}
}
Text(
candidate.title,
color = Heading,
fontSize = 16.sp,
fontWeight = FontWeight.Bold,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
if (candidate.overview.isNotBlank()) {
Text(
candidate.overview,
color = Muted,
fontSize = 11.sp,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
Spacer(Modifier.weight(1f))
Row(verticalAlignment = Alignment.CenterVertically) {
if (candidate.alreadyAdded) {
Icon(Icons.Default.CheckCircle, contentDescription = null, tint = Accent, modifier = Modifier.size(14.dp))
Spacer(Modifier.width(5.dp))
}
Text(
actionLabel,
color = if (candidate.alreadyAdded || focused) Accent else KeyLabel,
fontSize = 11.sp,
fontWeight = FontWeight.Bold,
)
if (!candidate.alreadyAdded && !busy) Text("", color = Accent, fontSize = 13.sp)
}
}
}
}
}
/** Stable, data-free fixture used by the screenshot test and design reviews. */
@Composable
internal fun RecommendationRequestPreview(previewArtwork: ImageBitmap? = null) {
val firstCardFocus = remember { FocusRequester() }
LaunchedEffect(Unit) { runCatching { firstCardFocus.requestFocus() } }
Box(
Modifier
.fillMaxSize()
.background(Color(0xFF080B0E))
.padding(28.dp),
) {
RequestOptions(
state = SearchUiState(
query = "the last voyage",
hasSearched = true,
requestCandidates = listOf(
GatewayRequestCandidate(
mediaType = "movie", foreignId = 101, title = "The Last Voyage",
year = 2026,
overview = "A lone cartographer follows a signal beyond the edge of every known map.",
),
GatewayRequestCandidate(
mediaType = "series", foreignId = 102, title = "Voyagers",
year = 2024,
overview = "Six strangers wake aboard a ship already halfway to another world.",
),
GatewayRequestCandidate(
mediaType = "movie", foreignId = 103, title = "The Long Way Home",
year = 2022,
overview = "A rescue pilot gets one final chance to cross the storm.",
),
GatewayRequestCandidate(
mediaType = "series", foreignId = 104, title = "Beyond the Horizon",
year = 2025,
overview = "An observatory discovers that tomorrow is broadcasting back.",
alreadyAdded = true,
),
),
),
resultsEntry = firstCardFocus,
keyboardReturn = remember { FocusRequester() },
onRequest = {},
previewArtwork = previewArtwork,
)
}
}
@@ -40,6 +40,7 @@ data class SearchUiState(
val requestLookupLoading: Boolean = false,
val requestingCandidateKey: String? = null,
val requestMessage: String? = null,
val requestMessageIsError: Boolean = false,
) {
/** The query is long enough to search but nothing came back. */
val isEmptyResult: Boolean
@@ -111,6 +112,7 @@ class SearchViewModel(private val repository: EmbyRepository) : ViewModel() {
requestCandidates = emptyList(),
requestLookupLoading = false,
requestMessage = null,
requestMessageIsError = false,
)
}
queryFlow.value = query
@@ -128,6 +130,7 @@ class SearchViewModel(private val repository: EmbyRepository) : ViewModel() {
query = "", results = emptyList(), isLoading = false, hasSearched = false,
errorMessage = null, requestCandidates = emptyList(),
requestLookupLoading = false, requestMessage = null,
requestMessageIsError = false,
)
}
queryFlow.value = ""
@@ -145,7 +148,7 @@ class SearchViewModel(private val repository: EmbyRepository) : ViewModel() {
if (candidate.alreadyAdded || state.value.requestingCandidateKey != null) return
val candidateKey = "${candidate.mediaType}:${candidate.foreignId}"
_state.update {
it.copy(requestingCandidateKey = candidateKey, requestMessage = null)
it.copy(requestingCandidateKey = candidateKey, requestMessage = null, requestMessageIsError = false)
}
viewModelScope.launch {
runCatching { repository.requestMedia(candidate) }
@@ -159,6 +162,7 @@ class SearchViewModel(private val repository: EmbyRepository) : ViewModel() {
) option.copy(alreadyAdded = true) else option
},
requestMessage = "${title.ifBlank { candidate.title }} was requested.",
requestMessageIsError = false,
)
}
}
@@ -167,6 +171,7 @@ class SearchViewModel(private val repository: EmbyRepository) : ViewModel() {
it.copy(
requestingCandidateKey = null,
requestMessage = friendlyEmbyError(error),
requestMessageIsError = true,
)
}
}
@@ -203,6 +208,7 @@ class SearchViewModel(private val repository: EmbyRepository) : ViewModel() {
results = emptyList(), isLoading = false, hasSearched = false,
errorMessage = null, requestCandidates = emptyList(),
requestLookupLoading = false, requestMessage = null,
requestMessageIsError = false,
)
}
return
@@ -40,7 +40,6 @@ import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Devices
import androidx.compose.material.icons.filled.Description
import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.Palette
@@ -59,6 +58,9 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.focus.focusRequester
@@ -68,8 +70,8 @@ import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.style.TextOverflow
@@ -83,11 +85,14 @@ import androidx.compose.foundation.text.KeyboardOptions
import com.ponzischeme89.memby.R
import com.ponzischeme89.memby.BuildConfig
import com.ponzischeme89.memby.ServiceLocator
import com.ponzischeme89.memby.data.DEFAULT_SEEK_INTERVAL_SECONDS
import com.ponzischeme89.memby.data.SEEK_INTERVAL_SECONDS
import com.ponzischeme89.memby.data.Settings
import com.ponzischeme89.memby.data.model.GatewayDevice
import com.ponzischeme89.memby.ui.PreviewSurface
import com.ponzischeme89.memby.ui.TvPreview
import com.ponzischeme89.memby.ui.WelcomeQuoteStyle
import com.ponzischeme89.memby.update.AppInstall
import com.ponzischeme89.memby.update.UpdateChecker
import com.ponzischeme89.memby.update.UpdateStatus
import kotlinx.coroutines.delay
@@ -110,6 +115,12 @@ private val DensityOptions = listOf(
ChoiceOption("large", "Large"),
)
// Values are the seconds themselves, as text, so the chip row and the stored integer
// cannot drift: the vocabulary is SEEK_INTERVAL_SECONDS and nothing else names it twice.
private val SeekIntervalOptions = SEEK_INTERVAL_SECONDS.map {
ChoiceOption(it.toString(), "$it seconds")
}
private val ArtworkOptions = listOf(
ChoiceOption("automatic", "Automatic"),
ChoiceOption("poster", "Posters"),
@@ -126,37 +137,50 @@ internal enum class SettingsPage(
val icon: ImageVector,
val showInRail: Boolean = true,
) {
APPEARANCE("Appearance", "Artwork and accents", Icons.Default.Palette),
PLAYBACK("Playback", "Reminders and episodes", Icons.Default.PlayArrow),
HOME("Home screen", "Rows and cards", Icons.Default.Home),
WELCOME("Welcome", "Greeting personality", Icons.Default.TagFaces),
UPDATES("Updates", "Version status", Icons.Default.SystemUpdate),
DEVICES("Devices", "Signed-in TVs", Icons.Default.Devices),
ABOUT("About", "Memby for Android TV", Icons.Default.Info),
LICENSES("Licences", "Source and open-source terms", Icons.Default.Description, showInRail = false),
APPEARANCE("Appearance", "How Memby looks", Icons.Default.Palette),
PLAYBACK("Playback", "What happens while you watch", Icons.Default.PlayArrow),
HOME("Home screen", "What you see when Memby opens", Icons.Default.Home),
WELCOME("Welcome", "The line you get when you sign in", Icons.Default.TagFaces),
UPDATES("Updates", "Keep this TV up to date", Icons.Default.SystemUpdate),
DEVICES("Devices", "TVs signed in to your account", Icons.Default.Devices),
ABOUT("About", "Version and source code", Icons.Default.Info),
}
// Black, and one lit thing at a time.
//
// Settings used to stack four surfaces to show two switches: the page, the rail, a titled
// section card, and the rows inside it. On a television that reads as grey boxes on grey
// boxes, and none of it carried information — the rail already says which page you are on
// and the header already names it. So the canvas and the rail are black, the section card
// is gone, and rows sit flat on it separated by a hairline. The only surface that lights up
// is the row under focus, which is the one thing a viewer actually needs to find.
//
// The controls are deliberately untouched: the green pill toggle and the chip row are what
// make this screen feel like Memby, and they read better against black than they did
// against a card.
private val EmbyGreen = Color(0xFF52B54B)
private val Canvas = Color(0xFF090B0D)
private val Panel = Color(0xFF0D1114)
private val SectionSurface = Color(0xFF12171B)
private val RowFocused = Color(0xFF20282F)
private val ControlIdle = Color(0xFF252D34)
private val Canvas = Color(0xFF000000)
private val Panel = Color(0xFF040506)
private val RowFocused = Color(0xFF1B2228)
private val ControlIdle = Color(0xFF1E252B)
private val TextPrimary = Color(0xFFF2F5F7)
private val TextSecondary = Color(0xFFC2CBD2)
private val TextQuiet = Color(0xFF8F9AA3)
private val Hairline = Color.White.copy(alpha = 0.09f)
private val TextSecondary = Color(0xFFAFB8BF)
private val TextQuiet = Color(0xFF7C868E)
private val Hairline = Color.White.copy(alpha = 0.08f)
private val RailEdge = Color.White.copy(alpha = 0.10f)
internal data class SettingsPanelState(
val showLogo: Boolean = true,
val autoPlayNext: Boolean = true,
val showTenMinuteReminder: Boolean = true,
val seekIntervalSeconds: Int = DEFAULT_SEEK_INTERVAL_SECONDS,
val ringColor: String = "52B54B",
val homeSections: Set<String> = setOf("continue", "favorites", "latest"),
val cardDensity: String = "standard",
val artworkStyle: String = "automatic",
val hiddenHomeRowCount: Int = 0,
val showCardMetadata: Boolean = true,
val showRatingsStrip: Boolean = true,
val hideWatchedMovies: Boolean = false,
val welcomeQuoteStyle: String = WelcomeQuoteStyle.NEUTRAL.value,
val selectedPage: SettingsPage = SettingsPage.APPEARANCE,
@@ -164,6 +188,7 @@ internal data class SettingsPanelState(
val updateStatus: UpdateStatus? = null,
val installMessage: String? = null,
val installedVersion: String = "",
val releaseHistory: List<ReleaseNote> = MembyReleaseHistory,
val devices: List<GatewayDevice> = emptyList(),
val devicesLoading: Boolean = false,
val devicesError: String? = null,
@@ -176,12 +201,14 @@ internal data class SettingsPanelActions(
val onShowLogoChanged: (Boolean) -> Unit = {},
val onAutoPlayNextChanged: (Boolean) -> Unit = {},
val onShowTenMinuteReminderChanged: (Boolean) -> Unit = {},
val onSeekIntervalChanged: (Int) -> Unit = {},
val onRingColorChanged: (String) -> Unit = {},
val onHomeSectionChanged: (String, Boolean) -> Unit = { _, _ -> },
val onCardDensityChanged: (String) -> Unit = {},
val onArtworkStyleChanged: (String) -> Unit = {},
val onRestoreHiddenRows: () -> Unit = {},
val onShowCardMetadataChanged: (Boolean) -> Unit = {},
val onShowRatingsStripChanged: (Boolean) -> Unit = {},
val onHideWatchedMoviesChanged: (Boolean) -> Unit = {},
val onWelcomeQuoteStyleChanged: (String) -> Unit = {},
val onPageSelected: (SettingsPage) -> Unit = {},
@@ -192,7 +219,6 @@ internal data class SettingsPanelActions(
val onRemoveDevice: (GatewayDevice) -> Unit = {},
val onCancelDeviceRemoval: () -> Unit = {},
val onOpenSourceCode: () -> Unit = {},
val onOpenLicenses: () -> Unit = {},
)
/**
@@ -206,6 +232,7 @@ fun SettingsSheet(
modifier: Modifier = Modifier,
overlay: Boolean = false,
onInstallerLaunched: (() -> Unit)? = null,
navigationFocusRequester: FocusRequester? = null,
) {
val context = LocalContext.current
val store = ServiceLocator.settings
@@ -216,17 +243,21 @@ fun SettingsSheet(
var showLogo by rememberSaveable { mutableStateOf(settings.showTitleLogo) }
var autoPlayNext by rememberSaveable { mutableStateOf(settings.autoPlayNextEpisode) }
var showTenMinuteReminder by rememberSaveable { mutableStateOf(settings.showTenMinuteReminder) }
var seekInterval by rememberSaveable { mutableStateOf(settings.seekIntervalSeconds) }
var ringColor by rememberSaveable { mutableStateOf(settings.ringColorHex) }
var homeSections by rememberSaveable { mutableStateOf(settings.homeSections.split(',').toSet()) }
var cardDensity by rememberSaveable { mutableStateOf(settings.homeCardDensity) }
var artworkStyle by rememberSaveable { mutableStateOf(settings.homeArtworkStyle) }
var showCardMetadata by rememberSaveable { mutableStateOf(settings.showHomeCardMetadata) }
var showRatingsStrip by rememberSaveable { mutableStateOf(settings.showRatingsStrip) }
var hideWatchedMovies by rememberSaveable { mutableStateOf(settings.hideWatchedMovies) }
var welcomeQuoteStyle by rememberSaveable { mutableStateOf(settings.welcomeQuoteStyle) }
var selectedPage by rememberSaveable { mutableStateOf(SettingsPage.APPEARANCE) }
var checking by remember { mutableStateOf(false) }
var status by remember { mutableStateOf<UpdateStatus?>(null) }
var installMessage by remember { mutableStateOf<String?>(null) }
// The system installer reports back here, not to downloadAndInstall's caller.
LaunchedEffect(Unit) { AppInstall.messages.collect { installMessage = it } }
var devices by remember { mutableStateOf<List<GatewayDevice>>(emptyList()) }
var devicesLoading by remember { mutableStateOf(false) }
var devicesError by remember { mutableStateOf<String?>(null) }
@@ -235,10 +266,6 @@ fun SettingsSheet(
var editingDevice by remember { mutableStateOf<GatewayDevice?>(null) }
var deviceJob by remember { mutableStateOf<Job?>(null) }
BackHandler(enabled = selectedPage == SettingsPage.LICENSES) {
selectedPage = SettingsPage.ABOUT
}
suspend fun refreshDevices() {
devicesLoading = true
devicesError = null
@@ -271,19 +298,23 @@ fun SettingsSheet(
settings.homeCardDensity,
settings.homeArtworkStyle,
settings.showHomeCardMetadata,
settings.showRatingsStrip,
settings.hideWatchedMovies,
settings.autoPlayNextEpisode,
settings.showTenMinuteReminder,
settings.seekIntervalSeconds,
settings.welcomeQuoteStyle,
) {
showLogo = settings.showTitleLogo
autoPlayNext = settings.autoPlayNextEpisode
showTenMinuteReminder = settings.showTenMinuteReminder
seekInterval = settings.seekIntervalSeconds
ringColor = settings.ringColorHex
homeSections = settings.homeSections.split(',').toSet()
cardDensity = settings.homeCardDensity
artworkStyle = settings.homeArtworkStyle
showCardMetadata = settings.showHomeCardMetadata
showRatingsStrip = settings.showRatingsStrip
hideWatchedMovies = settings.hideWatchedMovies
welcomeQuoteStyle = settings.welcomeQuoteStyle
}
@@ -300,12 +331,14 @@ fun SettingsSheet(
showLogo = showLogo,
autoPlayNext = autoPlayNext,
showTenMinuteReminder = showTenMinuteReminder,
seekIntervalSeconds = seekInterval,
ringColor = ringColor,
homeSections = homeSections,
cardDensity = cardDensity,
artworkStyle = artworkStyle,
hiddenHomeRowCount = settings.homeHiddenRows.lineSequence().count { it.isNotBlank() },
showCardMetadata = showCardMetadata,
showRatingsStrip = showRatingsStrip,
hideWatchedMovies = hideWatchedMovies,
welcomeQuoteStyle = welcomeQuoteStyle,
selectedPage = selectedPage,
@@ -333,6 +366,10 @@ fun SettingsSheet(
showTenMinuteReminder = it
scope.launch { store.setShowTenMinuteReminder(it) }
},
onSeekIntervalChanged = {
seekInterval = it
scope.launch { store.setSeekIntervalSeconds(it) }
},
onRingColorChanged = {
ringColor = it
scope.launch { store.setRingColor(it) }
@@ -362,6 +399,10 @@ fun SettingsSheet(
showCardMetadata = it
scope.launch { store.setShowHomeCardMetadata(it) }
},
onShowRatingsStripChanged = {
showRatingsStrip = it
scope.launch { store.setShowRatingsStrip(it) }
},
onHideWatchedMoviesChanged = {
hideWatchedMovies = it
scope.launch { store.setHideWatchedMovies(it) }
@@ -443,7 +484,6 @@ fun SettingsSheet(
)
}
},
onOpenLicenses = { selectedPage = SettingsPage.LICENSES },
)
Box(modifier = modifier.fillMaxSize()) {
@@ -478,6 +518,7 @@ fun SettingsSheet(
actions = actions,
overlay = overlay,
firstFocusRequester = firstFocus,
navigationFocusRequester = navigationFocusRequester,
)
}
editingDevice?.let { device ->
@@ -515,7 +556,14 @@ internal fun SettingsPanelContent(
overlay: Boolean,
modifier: Modifier = Modifier,
firstFocusRequester: FocusRequester? = null,
navigationFocusRequester: FocusRequester? = null,
) {
val contentScrollState = rememberScrollState()
LaunchedEffect(state.selectedPage) {
contentScrollState.scrollTo(0)
}
Row(
modifier = modifier
.then(if (overlay) Modifier.width(760.dp) else Modifier.fillMaxWidth())
@@ -534,6 +582,7 @@ internal fun SettingsPanelContent(
onSelected = actions.onPageSelected,
onClose = actions.onClose,
firstFocusRequester = firstFocusRequester,
navigationFocusRequester = navigationFocusRequester,
compact = overlay,
modifier = Modifier
.width(if (overlay) 190.dp else 224.dp)
@@ -543,7 +592,7 @@ internal fun SettingsPanelContent(
modifier = Modifier
.weight(1f)
.fillMaxHeight()
.verticalScroll(rememberScrollState())
.verticalScroll(contentScrollState)
.padding(
start = if (overlay) 26.dp else 42.dp,
end = if (overlay) 28.dp else 64.dp,
@@ -557,54 +606,55 @@ internal fun SettingsPanelContent(
version = state.installedVersion,
)
when (state.selectedPage) {
SettingsPage.APPEARANCE -> SettingsSection(
title = "Appearance",
description = "Artwork and playback accents",
icon = Icons.Default.Palette,
) {
SettingsPage.APPEARANCE -> SettingsGroup {
SettingsToggleRow(
title = "Title artwork",
description = "Use each film or show logo when artwork is available.",
title = "Show logos",
description = "Use a film or show's own logo instead of plain text.",
checked = state.showLogo,
onCheckedChange = actions.onShowLogoChanged,
)
SettingDivider()
SettingsChoiceRow(
title = "Progress ring",
description = "Colour used for resume and countdown progress.",
title = "Progress colour",
description = "The colour of progress bars and countdowns.",
options = RingOptions,
selected = state.ringColor,
onSelected = actions.onRingColorChanged,
)
}
SettingsPage.PLAYBACK -> SettingsSection(
title = "Playback",
description = "Reminders and episode behaviour",
icon = Icons.Default.PlayArrow,
) {
SettingsPage.PLAYBACK -> SettingsGroup {
SettingsToggleRow(
title = "10-minute reminder",
description = "Show a brief reminder when a film or episode has ten minutes left.",
title = "Ten minutes left",
description = "A small reminder near the end of what you're watching.",
checked = state.showTenMinuteReminder,
onCheckedChange = actions.onShowTenMinuteReminderChanged,
)
SettingDivider()
SettingsToggleRow(
title = "Play the next episode",
description = "Show a countdown near the end, then continue automatically.",
description = "Roll straight into the next one when an episode ends.",
checked = state.autoPlayNext,
onCheckedChange = actions.onAutoPlayNextChanged,
)
SettingDivider()
SettingsChoiceRow(
title = "Skip with left and right",
description = "How far one press moves what you are watching.",
options = SeekIntervalOptions,
selected = state.seekIntervalSeconds.toString(),
onSelected = { value ->
value.toIntOrNull()?.let(actions.onSeekIntervalChanged)
},
)
}
SettingsPage.HOME -> SettingsSection(
title = "Home screen",
description = "Choose what appears when Memby opens",
icon = Icons.Default.Home,
) {
SettingsPage.HOME -> SettingsGroup {
listOf(
"continue" to ("Continue watching" to "Resume films and episodes in progress."),
"favorites" to ("Favourites" to "Keep starred films and shows close by."),
"latest" to ("Latest movies" to "Show recently added films."),
"continue" to (
"Continue watching" to
"Pick up where you left off, and the next episode when you finish one."
),
"favorites" to ("Favourites" to "Films and shows you have starred."),
"latest" to ("Latest movies" to "Films added to the library recently."),
).forEachIndexed { index, (key, copy) ->
if (index > 0) SettingDivider()
SettingsToggleRow(
@@ -616,52 +666,55 @@ internal fun SettingsPanelContent(
}
SettingDivider()
SettingsToggleRow(
title = "Hide watched movies",
description = "Remove completed films from browse rows and the Home hero.",
title = "Hide films you have seen",
description = "Keep finished films out of the rows on Home.",
checked = state.hideWatchedMovies,
onCheckedChange = actions.onHideWatchedMoviesChanged,
)
SettingDivider()
SettingsChoiceRow(
title = "Card size",
description = "How much content fits across each row.",
description = "How big the artwork is on each row.",
options = DensityOptions,
selected = state.cardDensity,
onSelected = actions.onCardDensityChanged,
)
SettingDivider()
SettingsChoiceRow(
title = "Artwork style",
description = "Prefer portrait posters, wide backdrops, or let Memby choose.",
title = "Card shape",
description = "Tall posters, wide images, or let Memby pick.",
options = ArtworkOptions,
selected = state.artworkStyle,
onSelected = actions.onArtworkStyleChanged,
)
SettingDivider()
SettingsToggleRow(
title = "Card details",
description = "Show episode, runtime and resume information below artwork.",
title = "Text under cards",
description = "Show the year, length and episode under each card.",
checked = state.showCardMetadata,
onCheckedChange = actions.onShowCardMetadataChanged,
)
SettingDivider()
SettingsToggleRow(
title = "Ratings",
description = "Show IMDb and Rotten Tomatoes scores.",
checked = state.showRatingsStrip,
onCheckedChange = actions.onShowRatingsStripChanged,
)
if (state.hiddenHomeRowCount > 0) {
SettingDivider()
SettingsActionRow(
title = "Restore hidden rows",
description = "Make all rows available on Home again.",
title = "Bring back hidden rows",
description = "Show every row on Home again.",
badge = "${state.hiddenHomeRowCount} HIDDEN",
onClick = actions.onRestoreHiddenRows,
)
}
}
SettingsPage.WELCOME -> SettingsSection(
title = "Welcome messages",
description = "Choose Membys mood when you arrive",
icon = Icons.Default.TagFaces,
) {
SettingsPage.WELCOME -> SettingsGroup {
SettingsChoiceRow(
title = "Quote style",
description = "A different line is picked after sign-in and while Memby loads.",
title = "Tone",
description = "A different line is picked each time Memby opens.",
options = WelcomeOptions,
selected = state.welcomeQuoteStyle,
onSelected = actions.onWelcomeQuoteStyleChanged,
@@ -675,15 +728,11 @@ internal fun SettingsPanelContent(
positive = true,
)
}
SettingsPage.UPDATES -> SettingsSection(
title = "Updates",
description = "Keep this TV on the current Memby release",
icon = Icons.Default.SystemUpdate,
) {
VersionRow("Current version", state.installedVersion)
SettingsPage.UPDATES -> SettingsGroup {
VersionRow("On this TV", state.installedVersion)
SettingDivider()
VersionRow(
"New version",
"Newest release",
when (val update = state.updateStatus) {
is UpdateStatus.Available -> update.version
is UpdateStatus.UpToDate -> update.version
@@ -693,16 +742,16 @@ internal fun SettingsPanelContent(
)
SettingDivider()
SettingsActionRow(
title = if (state.checking) "Checking for updates" else "Check for updates",
description = "Ask the configured Memby release server.",
title = if (state.checking) "Checking…" else "Check for updates",
description = "Ask whether a newer build is available.",
badge = if (state.checking) "WORKING" else "CHECK NOW",
onClick = actions.onCheckForUpdates,
)
when (val update = state.updateStatus) {
is UpdateStatus.UpToDate -> SettingsNotice("Memby is up to date.", positive = true)
is UpdateStatus.UpToDate -> SettingsNotice("This TV is up to date.", positive = true)
is UpdateStatus.Error -> SettingsNotice(update.message, positive = false)
is UpdateStatus.Available -> {
SettingsNotice("Version ${update.version} is ready.", positive = true)
SettingsNotice("Version ${update.version} is ready to install.", positive = true)
if (update.notes.isNotBlank()) {
Text(
update.notes,
@@ -714,8 +763,8 @@ internal fun SettingsPanelContent(
)
}
SettingsActionRow(
title = "Download and install",
description = "Android will ask for confirmation.",
title = "Install it",
description = "Android will ask you to confirm.",
badge = "INSTALL",
onClick = { actions.onInstallUpdate(update) },
)
@@ -724,16 +773,12 @@ internal fun SettingsPanelContent(
}
state.installMessage?.let { SettingsNotice(it, positive = true) }
}
SettingsPage.DEVICES -> SettingsSection(
title = "Signed-in devices",
description = "Review and remove TVs connected to this account",
icon = Icons.Default.Devices,
) {
SettingsPage.DEVICES -> SettingsGroup {
when {
state.devicesLoading && state.devices.isEmpty() ->
SettingsNotice("Loading signed-in devices…", positive = true)
SettingsNotice("Looking for signed-in TVs…", positive = true)
state.devices.isEmpty() ->
SettingsNotice("No signed-in devices were found.", positive = false)
SettingsNotice("No other TVs are signed in.", positive = false)
else -> state.devices.forEachIndexed { index, device ->
if (index > 0) SettingDivider()
DeviceManagementRow(
@@ -748,17 +793,13 @@ internal fun SettingsPanelContent(
}
state.devicesError?.let { SettingsNotice(it, positive = false) }
SettingsActionRow(
title = if (state.devicesLoading) "Refreshing devices" else "Refresh devices",
description = "Check the gateway for TVs that are currently signed in.",
title = if (state.devicesLoading) "Refreshing…" else "Refresh the list",
description = "Check which TVs are signed in right now.",
badge = "REFRESH",
onClick = actions.onRefreshDevices,
)
}
SettingsPage.ABOUT -> SettingsSection(
title = "About",
description = "Memby for Android TV",
icon = Icons.Default.Info,
) {
SettingsPage.ABOUT -> SettingsGroup {
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
@@ -771,7 +812,7 @@ internal fun SettingsPanelContent(
fontWeight = FontWeight.Bold,
)
Text(
"Made by ${stringResource(R.string.developer_name)}",
"Memby (Matt's Emby) is built for Android TV, because the default client sucks...",
color = TextSecondary,
fontSize = 13.sp,
)
@@ -779,7 +820,7 @@ internal fun SettingsPanelContent(
Text(
"v${state.installedVersion}",
color = EmbyGreen,
fontSize = 13.sp,
fontSize = 12.sp,
fontWeight = FontWeight.Bold,
)
}
@@ -790,31 +831,14 @@ internal fun SettingsPanelContent(
badge = "OPEN",
onClick = actions.onOpenSourceCode,
)
SettingDivider()
SettingsActionRow(
title = "Open-source licences",
description = "Memby's GPLv2 terms and third-party acknowledgements.",
badge = "VIEW",
onClick = actions.onOpenLicenses,
)
}
SettingsPage.LICENSES -> SettingsSection(
title = "Open-source licences",
description = "Available offline with every Memby installation",
icon = Icons.Default.Description,
) {
LegalTextBlock(
title = "Memby and acknowledgements",
body = BuildConfig.PROJECT_NOTICE_TEXT,
)
SettingDivider()
LegalTextBlock(
title = "GNU General Public License v2",
body = BuildConfig.GPL_LICENSE_TEXT,
monospace = true,
)
}
}
if (state.selectedPage == SettingsPage.ABOUT) {
VersionHistorySection(
releases = state.releaseHistory,
installedVersion = state.installedVersion,
)
}
Spacer(Modifier.height(12.dp))
}
}
@@ -984,11 +1008,12 @@ private fun SettingsSecondaryRail(
onSelected: (SettingsPage) -> Unit,
onClose: () -> Unit,
firstFocusRequester: FocusRequester?,
navigationFocusRequester: FocusRequester?,
compact: Boolean,
modifier: Modifier = Modifier,
) {
val pages = SettingsPage.entries.filter { it.showInRail }
val selectedRailPage = selected.takeIf { it.showInRail } ?: SettingsPage.ABOUT
val selectedRailPage = selected
// The home screen remains composed behind this panel. Relying on spatial focus
// search therefore lets a covered media card beat the next rail item when their
// bounds happen to be closer (notably below Playback on a 540p viewport). Give
@@ -1010,8 +1035,17 @@ private fun SettingsSecondaryRail(
}
Column(
modifier = modifier
.background(Color(0xFF0A0E11))
.border(width = 1.dp, color = Hairline)
.background(Canvas)
// A single hairline down the right edge instead of a box around the rail. The
// rail is not a card; it is the left edge of the screen.
.drawBehind {
drawRect(
color = RailEdge,
topLeft = Offset(size.width - 1f, 0f),
size = Size(1f, size.height),
)
}
.verticalScroll(rememberScrollState())
.padding(
horizontal = if (compact) 14.dp else 18.dp,
vertical = if (compact) 28.dp else 38.dp,
@@ -1031,6 +1065,7 @@ private fun SettingsSecondaryRail(
onClick = onClose,
focusRequester = railFocusRequesters[0],
downFocusRequester = railFocusRequesters[1],
leftFocusRequester = navigationFocusRequester,
)
pages.forEachIndexed { index, page ->
var focused by remember { mutableStateOf(false) }
@@ -1063,9 +1098,17 @@ private fun SettingsSecondaryRail(
} else {
railFocusRequesters[index + 2]
}
left = FocusRequester.Cancel
// Left leaves for the app's main rail when it is beside us, and is
// cancelled rather than left to spatial search otherwise: the home
// screen stays composed behind this panel and a covered card can
// otherwise win the focus search.
left = navigationFocusRequester ?: FocusRequester.Cancel
}
.onFocusChanged { focused = it.isFocused }
.onFocusChanged {
focused = it.isFocused
if (it.isFocused && page != selected) onSelected(page)
}
.testTag("settings-rail-${page.name.lowercase()}")
.clickable { onSelected(page) }
.padding(horizontal = 12.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
@@ -1090,9 +1133,9 @@ private fun SettingsSecondaryRail(
)
}
}
Spacer(Modifier.weight(1f))
Spacer(Modifier.height(16.dp))
Text(
"Use ↑ ↓ to move\nbetween pages",
"↑ ↓ moves between pages",
color = TextQuiet,
fontSize = 11.sp,
lineHeight = 16.sp,
@@ -1107,6 +1150,7 @@ private fun SettingsExitRailItem(
onClick: () -> Unit,
focusRequester: FocusRequester,
downFocusRequester: FocusRequester,
leftFocusRequester: FocusRequester?,
) {
var focused by remember { mutableStateOf(false) }
Row(
@@ -1123,7 +1167,7 @@ private fun SettingsExitRailItem(
.focusProperties {
up = FocusRequester.Cancel
down = downFocusRequester
left = FocusRequester.Cancel
left = leftFocusRequester ?: FocusRequester.Cancel
}
.onFocusChanged { focused = it.isFocused }
.clickable(onClick = onClick)
@@ -1149,20 +1193,24 @@ private fun SettingsExitRailItem(
@Composable
private fun SettingsHeader(page: SettingsPage, version: String) {
Row(verticalAlignment = Alignment.CenterVertically) {
Column(verticalArrangement = Arrangement.spacedBy(3.dp)) {
Text(
"MEMBY · TV",
color = EmbyGreen,
fontSize = 11.sp,
fontWeight = FontWeight.Bold,
letterSpacing = 1.8.sp,
)
Text(page.label, color = TextPrimary, fontSize = 38.sp, fontWeight = FontWeight.Bold)
Row(
verticalAlignment = Alignment.CenterVertically,
// Indented to the rows' own text rather than to the panel edge. With the section
// card gone, a row's padding is what insets its focus highlight from its label, and
// a header sitting 16dp left of every setting under it reads as a second column.
modifier = Modifier
.padding(horizontal = 16.dp)
.testTag("settings-page-${page.name.lowercase()}"),
) {
// No eyebrow above the title. It said "MEMBY · TV" on every page of a sheet that is
// already inside Memby on a television, so it named the one thing nobody could be in
// doubt about while taking the vertical space the settings themselves want.
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(page.label, color = TextPrimary, fontSize = 26.sp, fontWeight = FontWeight.Bold)
Text(
page.description,
color = TextSecondary,
fontSize = 15.sp,
fontSize = 13.sp,
)
}
Spacer(Modifier.weight(1f))
@@ -1186,42 +1234,31 @@ private fun VersionRow(label: String, version: String) {
}
}
/**
* A run of settings on the black canvas.
*
* There is no card and no icon chip. Every page but About holds exactly one of these, and
* the header above it already names the page a titled box inside a titled page said the
* same word twice and cost two nested surfaces to do it. [label] exists only for the pages
* that genuinely have two groups, and it is a quiet caption rather than a second heading.
*/
@Composable
private fun SettingsSection(
title: String,
description: String,
icon: ImageVector,
private fun SettingsGroup(
label: String? = null,
content: @Composable ColumnScope.() -> Unit,
) {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Row(
modifier = Modifier.padding(horizontal = 4.dp, vertical = 2.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
Box(
modifier = Modifier
.size(32.dp)
.clip(RoundedCornerShape(9.dp))
.background(EmbyGreen.copy(alpha = 0.13f)),
contentAlignment = Alignment.Center,
) {
Icon(icon, contentDescription = null, tint = EmbyGreen, modifier = Modifier.size(18.dp))
}
Column(verticalArrangement = Arrangement.spacedBy(1.dp)) {
Text(title, color = TextPrimary, fontSize = 17.sp, fontWeight = FontWeight.Bold)
Text(description, color = TextQuiet, fontSize = 12.sp)
}
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
label?.let {
Text(
it.uppercase(),
color = TextQuiet,
fontSize = 10.sp,
fontWeight = FontWeight.Bold,
letterSpacing = 1.2.sp,
modifier = Modifier.padding(start = 16.dp, top = 10.dp),
)
}
Column(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(14.dp))
.background(SectionSurface)
.border(1.dp, Color.White.copy(alpha = 0.065f), RoundedCornerShape(14.dp))
.padding(7.dp),
content = content,
)
Column(modifier = Modifier.fillMaxWidth(), content = content)
}
}
@@ -1374,10 +1411,11 @@ private fun SettingsActionRow(
description: String,
badge: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
var focused by remember { mutableStateOf(false) }
Row(
modifier = Modifier
modifier = modifier
.fillMaxWidth()
.clip(RoundedCornerShape(10.dp))
.background(if (focused) RowFocused else Color.Transparent)
@@ -1419,6 +1457,9 @@ private fun SettingsNotice(text: String, positive: Boolean) {
fontWeight = FontWeight.SemiBold,
modifier = Modifier
.fillMaxWidth()
// Inset to the same 16dp as the rows and the dividers, so the one tinted band on
// a black page lines up with everything above it instead of hanging off both edges.
.padding(horizontal = 16.dp, vertical = 4.dp)
.clip(RoundedCornerShape(8.dp))
.background(
if (positive) EmbyGreen.copy(alpha = 0.10f) else Color(0xFFE75852).copy(alpha = 0.12f),
@@ -1427,35 +1468,116 @@ private fun SettingsNotice(text: String, positive: Boolean) {
)
}
/**
* The release history, as a list of collapsed releases that open in place. A remote has
* no scrollbar to aim at, so the whole changelog rendered flat would be a very long blind
* scroll; one focusable row per release makes Down mean "next release" until the viewer
* asks for a particular one. The newest is open on arrival because that is the one being
* looked for.
*/
@Composable
private fun LegalTextBlock(
title: String,
body: String,
monospace: Boolean = false,
private fun VersionHistorySection(
releases: List<ReleaseNote>,
installedVersion: String,
) {
var expandedVersion by rememberSaveable(releases.firstOrNull()?.version) {
mutableStateOf(releases.firstOrNull()?.version)
}
SettingsGroup(label = "What changed in each release") {
if (releases.isEmpty()) {
SettingsNotice("No release history shipped with this build.", positive = false)
return@SettingsGroup
}
releases.forEachIndexed { index, release ->
if (index > 0) SettingDivider()
ReleaseHistoryRow(
release = release,
installed = release.version == installedVersion,
expanded = release.version == expandedVersion,
onToggle = {
expandedVersion = if (expandedVersion == release.version) null else release.version
},
)
}
}
}
@Composable
private fun ReleaseHistoryRow(
release: ReleaseNote,
installed: Boolean,
expanded: Boolean,
onToggle: () -> Unit,
) {
var focused by remember { mutableStateOf(false) }
Column(
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 14.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(10.dp))
.background(if (focused) RowFocused else Color.Transparent)
.border(
width = if (focused) 2.dp else 1.dp,
color = if (focused) Color.White.copy(alpha = 0.88f) else Color.Transparent,
shape = RoundedCornerShape(10.dp),
)
.onFocusChanged { focused = it.isFocused }
.clickable(onClick = onToggle)
.testTag("settings-release-${release.version}")
.padding(horizontal = 16.dp, vertical = 13.dp),
verticalArrangement = Arrangement.spacedBy(9.dp),
) {
Text(
title,
color = TextPrimary,
fontSize = 16.sp,
fontWeight = FontWeight.Bold,
)
Text(
body,
color = TextSecondary,
fontSize = if (monospace) 11.sp else 13.sp,
lineHeight = if (monospace) 16.sp else 18.sp,
fontFamily = if (monospace) FontFamily.Monospace else FontFamily.Default,
)
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
Text(
"v${release.version}",
color = if (installed) EmbyGreen else TextPrimary,
fontSize = 16.sp,
fontWeight = FontWeight.Bold,
)
if (installed) {
Text(
"INSTALLED",
color = EmbyGreen,
fontSize = 9.sp,
fontWeight = FontWeight.Bold,
letterSpacing = 0.6.sp,
)
}
Spacer(Modifier.weight(1f))
release.date.takeIf(String::isNotBlank)?.let {
Text(it, color = TextQuiet, fontSize = 12.sp)
}
Text(
if (expanded) "HIDE" else "${release.changes.size} CHANGES",
color = if (focused) Color(0xFF062307) else EmbyGreen,
fontSize = 10.sp,
fontWeight = FontWeight.Bold,
letterSpacing = 0.7.sp,
modifier = Modifier
.clip(RoundedCornerShape(999.dp))
.background(if (focused) EmbyGreen else EmbyGreen.copy(alpha = 0.13f))
.padding(horizontal = 11.dp, vertical = 7.dp),
)
}
AnimatedVisibility(visible = expanded) {
Column(verticalArrangement = Arrangement.spacedBy(7.dp)) {
release.changes.forEach { change ->
Row(horizontalArrangement = Arrangement.spacedBy(9.dp)) {
Text("", color = EmbyGreen, fontSize = 13.sp, fontWeight = FontWeight.Bold)
Text(change, color = TextSecondary, fontSize = 13.sp, lineHeight = 19.sp)
}
}
}
}
}
}
@Composable
private fun SettingDivider() {
Box(Modifier.fillMaxWidth().padding(horizontal = 14.dp).height(1.dp).background(Hairline))
Box(Modifier.fillMaxWidth().padding(horizontal = 16.dp).height(1.dp).background(Hairline))
}
@TvPreview
@@ -0,0 +1,61 @@
/*
* Copyright (C) 2026 Memby contributors
*
* SPDX-License-Identifier: GPL-2.0-only
*/
package com.ponzischeme89.memby.ui.settings
/** One release as the About page shows it. */
internal data class ReleaseNote(
val version: String,
val date: String,
val changes: List<String>,
)
private val HeadingPattern = Regex("""^##\s+v?(\d+\.\d+\.\d+)\s*(?:[—–-]\s*(.+))?$""")
/**
* Parses CHANGELOG.md into the release list. Pure so it can be unit-tested, and
* deliberately forgiving: anything that is not a `## <version>` heading or a `- ` bullet
* under one is prose (the file's own format notes) and is skipped rather than rendered.
*
* Order is the file's own newest first by convention, never re-sorted here, because a
* version string is not reliably comparable once a release carries a suffix.
*/
internal fun parseChangelog(markdown: String): List<ReleaseNote> {
val releases = mutableListOf<ReleaseNote>()
var version: String? = null
var date = ""
var changes = mutableListOf<String>()
fun flush() {
version?.let { releases += ReleaseNote(it, date, changes.toList()) }
}
markdown.lineSequence().forEach { rawLine ->
val line = rawLine.trim()
val heading = HeadingPattern.find(line)
when {
heading != null -> {
flush()
version = heading.groupValues[1]
date = heading.groupValues[2].trim()
changes = mutableListOf()
}
version == null -> Unit
line.startsWith("- ") || line.startsWith("* ") -> changes += line.drop(2).trim()
// A wrapped bullet continues the previous one rather than starting a new
// entry: the file is written to a column width, not to one line per change.
line.isNotEmpty() && changes.isNotEmpty() ->
changes[changes.lastIndex] = "${changes.last()} $line"
}
}
flush()
return releases
}
/** The catalogue Memby ships with, parsed once. */
internal val MembyReleaseHistory: List<ReleaseNote> by lazy {
parseChangelog(com.ponzischeme89.memby.BuildConfig.CHANGELOG_TEXT)
}
@@ -0,0 +1,160 @@
package com.ponzischeme89.memby.ui.setup
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.tv.material3.Text
import com.ponzischeme89.memby.R
import com.ponzischeme89.memby.ui.TvTextField
import com.ponzischeme89.memby.ui.UpdateButton
import com.ponzischeme89.memby.ui.theme.MembyMutedText
import com.ponzischeme89.memby.ui.theme.MembyOnSurface
import com.ponzischeme89.memby.ui.theme.MembySurface
/**
* The sign-in form, with no dependency on the repository so it can be screenshotted and
* looked at without a server (`OnboardingScreenshotTest`). `SetupScreen` owns the
* authentication and hands the state in.
*
* It sits on flat [MembySurface] like every other full-screen surface in the app. It used to
* be a bordered translucent card floating on a radial gradient, which was the only place in
* Memby that looked like that and it is the *first* thing anyone sees, so it set an
* expectation the rest of the app then contradicted. The panel earned nothing: there is
* nothing behind it to be raised above.
*/
@Composable
fun SignInContent(
username: String,
password: String,
onUsernameChange: (String) -> Unit,
onPasswordChange: (String) -> Unit,
onSubmit: () -> Unit,
connecting: Boolean,
error: String?,
onBack: (() -> Unit)?,
modifier: Modifier = Modifier,
usernameFocus: FocusRequester = remember { FocusRequester() },
passwordFocus: FocusRequester = remember { FocusRequester() },
signInFocus: FocusRequester = remember { FocusRequester() },
backFocus: FocusRequester = remember { FocusRequester() },
) {
val addingViewer = onBack != null
Box(
modifier = modifier
.fillMaxSize()
.background(MembySurface)
.padding(horizontal = 72.dp, vertical = 40.dp),
contentAlignment = Alignment.Center,
) {
Column(
// The form is taller than the safe content area on some Android TVs, and a
// scroll container is also what lets Compose lift the focused field above the
// on-screen keyboard instead of clipping it.
modifier = Modifier.width(560.dp).verticalScroll(rememberScrollState()),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Image(
painter = painterResource(R.drawable.emby_logo),
contentDescription = null,
modifier = Modifier.size(52.dp),
)
Spacer(Modifier.height(20.dp))
Text(
if (addingViewer) "Add another viewer" else "Welcome to Memby",
color = MembyOnSurface,
fontSize = 30.sp,
fontWeight = FontWeight.SemiBold,
)
Spacer(Modifier.height(8.dp))
Text(
if (addingViewer) {
"Their account is saved as another profile on this TV."
} else {
"Sign in with your Emby account."
},
color = MembyMutedText,
fontSize = 16.sp,
)
Spacer(Modifier.height(30.dp))
TvTextField(
label = "Username",
value = username,
onValueChange = onUsernameChange,
focusRequester = usernameFocus,
onNext = { passwordFocus.requestFocus() },
)
Spacer(Modifier.height(16.dp))
TvTextField(
label = "Password",
value = password,
onValueChange = onPasswordChange,
isPassword = true,
focusRequester = passwordFocus,
downFocusRequester = signInFocus,
onDone = onSubmit,
)
error?.let {
Spacer(Modifier.height(16.dp))
Text(it, color = SignInError, fontSize = 15.sp)
}
Spacer(Modifier.height(28.dp))
Row(
modifier = Modifier.fillMaxWidth(),
// Centred under a centred form. End-aligned buttons were the last trace of
// the card this screen used to be, and read as belonging to a dialog.
horizontalArrangement = Arrangement.spacedBy(14.dp, Alignment.CenterHorizontally),
) {
if (onBack != null) {
UpdateButton(
label = "Back",
primary = false,
enabled = !connecting,
onClick = onBack,
modifier = Modifier
.focusRequester(backFocus)
.focusProperties { right = signInFocus },
)
}
UpdateButton(
label = if (connecting) "Signing in…" else "Sign in",
primary = true,
enabled = !connecting,
onClick = onSubmit,
modifier = Modifier
.focusRequester(signInFocus)
.focusProperties { if (onBack != null) left = backFocus },
)
}
}
}
}
/** The one red in the app that means "this went wrong", matched to Settings' own notices. */
private val SignInError = Color(0xFFFF7777)
@@ -35,8 +35,8 @@ val MembyQuietText = Color(0xFFAEB7BF)
/** Hairline rules and unfocused borders. */
val MembyHairline = Color(0x28FFFFFF)
/** The community score, wherever it is rendered as its own run of text. */
val MembyScore = Color(0xFFF5C518)
/** A quiet neutral capsule behind third-party ratings. */
val MembyRatingsSurface = Color(0xFF20252A)
// --- Shape ---------------------------------------------------------------------------
// Three steps, largest last. Anything that needs a radius picks the nearest one rather
@@ -0,0 +1,75 @@
/*
* Copyright (C) 2026 Memby contributors
*
* SPDX-License-Identifier: GPL-2.0-only
*/
package com.ponzischeme89.memby.ui.whatsnew
import com.ponzischeme89.memby.ui.settings.ReleaseNote
/** What a launch should do about the release notes for the build that is running. */
internal sealed interface WhatsNewDecision {
/** Show these notes, then record the version once the viewer dismisses them. */
data class Show(val release: ReleaseNote) : WhatsNewDecision
/** Record the version without showing anything. */
data class MarkSeen(val version: String) : WhatsNewDecision
/** Neither — this launch has nothing to say and nothing to write. */
data object Nothing : WhatsNewDecision
}
/**
* Decides whether a television that has just been updated should be told what changed.
*
* Pure, because the interesting part is not the panel but which launches it must stay out
* of. Three cases are deliberately not "show":
*
* - **Already recorded.** The whole contract is once per update; every later launch of the
* same build is silent, which is what [seenVersion] exists for.
* - **A fresh install** (no record at all, and nobody signed in yet). Everything in this
* build is new to that TV, so a list of changes since a version it never ran is noise at
* the worst moment. It is marked seen during setup so the first launcher is clean.
* - **A build the changelog does not describe.** An unreleased or locally-built version has
* no entry, and an empty panel is worse than none it is marked seen instead, so the
* next real update still lands.
*
* Signed out with a record already present is [Nothing] rather than [MarkSeen]: the notes
* belong over the launcher, so that launch simply waits for whoever is about to sign in.
*/
internal fun whatsNewDecision(
installedVersion: String,
seenVersion: String?,
isSignedIn: Boolean,
releases: List<ReleaseNote>,
): WhatsNewDecision {
val version = installedVersion.trim()
if (version.isEmpty()) return WhatsNewDecision.Nothing
if (seenVersion?.trim() == version) return WhatsNewDecision.Nothing
if (seenVersion == null && !isSignedIn) return WhatsNewDecision.MarkSeen(version)
if (!isSignedIn) return WhatsNewDecision.Nothing
val release = releases.firstOrNull { it.version == version && it.changes.isNotEmpty() }
?: return WhatsNewDecision.MarkSeen(version)
return WhatsNewDecision.Show(release)
}
/** One changelog bullet, split into its optional leading label and the sentence itself. */
internal data class ChangeLine(val tag: String?, val text: String)
/**
* The labels the changelog is written with. A closed set on purpose: any other colon in a
* bullet ("Fixed: Settings → About: …") is part of the sentence, and lifting it into a
* chip would break the line in half.
*/
private val ChangeTags = setOf("Added", "Changed", "Fixed", "Removed")
/** Splits `"Added: Subtitles are saved"` into the chip and the copy beside it. */
internal fun changeLine(raw: String): ChangeLine {
val text = raw.trim()
val colon = text.indexOf(':')
if (colon <= 0) return ChangeLine(null, text)
val tag = text.take(colon)
if (tag !in ChangeTags) return ChangeLine(null, text)
return ChangeLine(tag.uppercase(), text.drop(colon + 1).trim())
}
@@ -0,0 +1,238 @@
/*
* Copyright (C) 2026 Memby contributors
*
* SPDX-License-Identifier: GPL-2.0-only
*/
package com.ponzischeme89.memby.ui.whatsnew
import androidx.activity.compose.BackHandler
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
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.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.zIndex
import androidx.tv.material3.Text
import com.ponzischeme89.memby.ui.UpdateButton
import com.ponzischeme89.memby.ui.settings.ReleaseNote
import com.ponzischeme89.memby.ui.theme.MembyAccent
import com.ponzischeme89.memby.ui.theme.MembyChipCorner
import com.ponzischeme89.memby.ui.theme.MembyMutedText
import com.ponzischeme89.memby.ui.theme.MembyOnSurface
import com.ponzischeme89.memby.ui.theme.MembyPanelCorner
import com.ponzischeme89.memby.ui.theme.MembyQuietText
import com.ponzischeme89.memby.ui.theme.MembySurfaceRaised
import kotlinx.coroutines.delay
/**
* The most bullets the panel will print even on a tall screen. A release with more is not
* losing anything the full history is in Settings About, which the closing line points
* at but past half a dozen the panel stops being a note and becomes a document.
*/
private const val MAX_CHANGES = 6
/** Everything above and below the bullets: eyebrow, version, date, button and padding. */
private const val CHROME_HEIGHT_DP = 320
/** One bullet's budget, allowing for a sentence that wraps onto a second line. */
private const val CHANGE_ROW_HEIGHT_DP = 52
/**
* How many bullets fit on this screen. Derived rather than fixed, for the same reason the
* detail pages derive their pane height: a panel that runs off the bottom of a 720p set has
* no scrollbar to aim at and no way for the remote to reach the button under it. If a
* release needs more room than the screen has, it loses bullets never the button.
*/
internal fun maxChangesFor(availableHeightDp: Int): Int =
((availableHeightDp - CHROME_HEIGHT_DP) / CHANGE_ROW_HEIGHT_DP).coerceIn(1, MAX_CHANGES)
/** The label column, fixed so every sentence starts at the same place down the list. */
private val TagColumnWidth = 88.dp
private val Scrim = Color(0xE60A0C0F)
/**
* What changed in the build that has just been installed, shown once over the launcher.
*
* Deliberately an overlay rather than a screen: the rows are already drawn behind it from
* the home cache, so the viewer sees their television working and this as a note on top of
* it, not another wait before it. Stateless [whatsNewDecision] decides whether it appears
* and `SettingsStore.markWhatsNewSeen` records that it did so it can be screenshotted
* with no store, no gateway and no session.
*/
@Composable
internal fun WhatsNewOverlay(
release: ReleaseNote,
onDismiss: () -> Unit,
modifier: Modifier = Modifier,
) {
val dismissFocus = remember { FocusRequester() }
var focused by remember { mutableStateOf(false) }
// The launcher requests focus for its first row on the same frame, and on a slow set
// that request can land after this one. Ask again until it sticks rather than leaving a
// panel on screen with the remote still driving the rows behind it.
LaunchedEffect(release.version) {
repeat(6) {
if (focused) return@LaunchedEffect
runCatching { dismissFocus.requestFocus() }
delay(100)
}
}
BackHandler(enabled = true, onBack = onDismiss)
var entered by remember { mutableStateOf(false) }
LaunchedEffect(Unit) { entered = true }
val entrance by animateFloatAsState(
targetValue = if (entered) 1f else 0f,
animationSpec = tween(280),
label = "whats-new-entrance",
)
BoxWithConstraints(
modifier = modifier
.fillMaxSize()
.zIndex(8f)
.background(Scrim)
.testTag("whats-new"),
contentAlignment = Alignment.Center,
) {
val shown = maxChangesFor(maxHeight.value.toInt())
Column(
modifier = Modifier
.graphicsLayer {
alpha = entrance
translationY = (1f - entrance) * 18.dp.toPx()
}
.widthIn(max = 760.dp)
.clip(RoundedCornerShape(MembyPanelCorner))
.background(MembySurfaceRaised)
.border(1.dp, Color.White.copy(alpha = 0.10f), RoundedCornerShape(MembyPanelCorner))
.padding(horizontal = 40.dp, vertical = 34.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
"WHAT'S NEW",
color = MembyAccent,
fontSize = 13.sp,
fontWeight = FontWeight.SemiBold,
letterSpacing = 2.sp,
)
Spacer(Modifier.height(10.dp))
Text(
"Memby ${release.version}",
color = MembyOnSurface,
fontSize = 30.sp,
fontWeight = FontWeight.SemiBold,
)
if (release.date.isNotBlank()) {
Spacer(Modifier.height(6.dp))
Text("Installed on this TV · ${release.date}", color = MembyQuietText, fontSize = 14.sp)
}
Spacer(Modifier.height(22.dp))
Column(
modifier = Modifier.widthIn(max = 620.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
release.changes.take(shown).forEach { ChangeRow(changeLine(it)) }
}
if (release.changes.size > shown) {
Spacer(Modifier.height(16.dp))
Text(
"And more — the full history is in Settings → About.",
color = MembyQuietText,
fontSize = 14.sp,
)
}
Spacer(Modifier.height(28.dp))
UpdateButton(
label = "Continue",
primary = true,
enabled = true,
onClick = onDismiss,
modifier = Modifier
.focusRequester(dismissFocus)
// The only focusable thing on screen, over a launcher full of them.
// Every direction points back here, or one press of Down walks into
// rows the viewer cannot see behind the scrim.
.focusProperties {
up = dismissFocus
down = dismissFocus
left = dismissFocus
right = dismissFocus
}
.onFocusChanged { focused = it.isFocused }
.testTag("whats-new-continue"),
)
}
}
}
@Composable
private fun ChangeRow(line: ChangeLine) {
Row(verticalAlignment = Alignment.Top, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
// Fixed column: the labels are different lengths, and ragged sentence starts read
// as a list that has not been laid out rather than one that has.
Box(modifier = Modifier.width(TagColumnWidth), contentAlignment = Alignment.TopStart) {
if (line.tag != null) {
Text(
line.tag,
color = MembyAccent,
fontSize = 11.sp,
fontWeight = FontWeight.SemiBold,
letterSpacing = 1.sp,
modifier = Modifier
.clip(RoundedCornerShape(MembyChipCorner))
.background(MembyAccent.copy(alpha = 0.12f))
.padding(horizontal = 9.dp, vertical = 4.dp),
)
} else {
Box(
modifier = Modifier
.padding(top = 8.dp)
.size(6.dp)
.clip(CircleShape)
.background(MembyAccent.copy(alpha = 0.7f)),
)
}
}
Text(line.text, color = MembyMutedText, fontSize = 16.sp)
}
}
@@ -0,0 +1,43 @@
package com.ponzischeme89.memby.update
import android.content.Context
import android.content.Intent
import android.os.Build
import android.provider.Settings
import androidx.core.net.toUri
/**
* The per-app "install unknown apps" permission, which is what actually decides whether
* Memby can ever update itself.
*
* Every television here is sideloaded, in practice through Downloader which means
* *Downloader* holds this permission and Memby does not. Nothing in a fresh install asks for
* it, so the first time anyone finds out is halfway through an update that then refuses to
* proceed. Asking during setup, while somebody is sitting in front of the TV expecting to be
* asked things, is the whole point of [com.ponzischeme89.memby.ui.InstallPermissionScreen].
*/
object InstallPermission {
/** Where the setting lives on a television, for when there is no screen to open. */
const val MANUAL_PATH =
"Settings → Device Preferences → Security & restrictions → Unknown sources"
fun granted(context: Context): Boolean =
Build.VERSION.SDK_INT < Build.VERSION_CODES.O ||
context.packageManager.canRequestPackageInstalls()
/**
* Opens Android's per-app permission screen.
*
* Returns false when this TV has no such screen several do not, and the difference
* between "not granted yet" and "nowhere to grant it" is the difference between a
* prompt that works and one that silently does nothing.
*/
fun requestScreen(context: Context): Boolean {
val intent = Intent(
Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES,
"package:${context.packageName}".toUri(),
).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
return runCatching { context.startActivity(intent) }.isSuccess
}
}
@@ -0,0 +1,118 @@
package com.ponzischeme89.memby.update
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.pm.PackageInstaller
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
/**
* Where a self-update ends up.
*
* The install itself is asynchronous and happens in the system installer, not in Memby, so
* the only way the app learns that an update failed is this broadcast. Without it the TV
* says "Opening the installer…" and then never mentions it again which on a *mandatory*
* update leaves a blocking screen whose button appears to do nothing, and a viewer whose
* only remaining idea is to sideload the APK by hand.
*/
object AppInstall {
private val _messages = MutableSharedFlow<String>(
replay = 1,
extraBufferCapacity = 1,
)
/** Progress and failure text for whichever screen started the install. */
val messages: SharedFlow<String> = _messages
internal fun report(message: String) {
_messages.tryEmit(message)
}
}
/**
* Receives the [PackageInstaller] session result.
*
* [PackageInstaller.STATUS_PENDING_USER_ACTION] is the ordinary case and is not a failure:
* the system hands back an intent for its own confirmation screen and expects the app to
* launch it. That indirection is the reason this path is used instead of an
* `ACTION_VIEW` handoff the confirmation activity arrives as an explicit intent from the
* system, so it does not depend on a TV build exposing an APK viewer or on package
* visibility rules.
*/
class InstallResultReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (intent.action != ACTION) return
val status = intent.getIntExtra(
PackageInstaller.EXTRA_STATUS,
PackageInstaller.STATUS_FAILURE,
)
if (status == PackageInstaller.STATUS_PENDING_USER_ACTION) {
val confirm = confirmationIntent(intent)
if (confirm == null) {
AppInstall.report(installStatusMessage(PackageInstaller.STATUS_FAILURE, null))
return
}
confirm.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
val launched = runCatching { context.startActivity(confirm) }.isSuccess
AppInstall.report(
if (launched) {
"Confirm the update when your TV asks."
} else {
"This TV would not open Android's install screen. " +
"Reinstall Memby from the install page to get this version."
},
)
return
}
AppInstall.report(
installStatusMessage(
status,
intent.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE),
),
)
}
@Suppress("DEPRECATION")
private fun confirmationIntent(intent: Intent): Intent? =
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.TIRAMISU) {
intent.getParcelableExtra(Intent.EXTRA_INTENT, Intent::class.java)
} else {
intent.getParcelableExtra(Intent.EXTRA_INTENT)
}
companion object {
const val ACTION = "com.ponzischeme89.memby.INSTALL_RESULT"
}
}
/**
* Turns a [PackageInstaller] status into something worth putting on a television.
*
* Pure so it can be tested without an installer session. The system's own
* `EXTRA_STATUS_MESSAGE` is developer-facing ("INSTALL_FAILED_VERSION_DOWNGRADE"), so it is
* used only as a last resort, and every case that a viewer can actually act on says what
* to do rather than what went wrong.
*/
internal fun installStatusMessage(status: Int, systemMessage: String?): String = when (status) {
PackageInstaller.STATUS_SUCCESS ->
"Update installed. Memby will restart."
PackageInstaller.STATUS_FAILURE_ABORTED ->
"The update was cancelled. Press Update now to try again."
PackageInstaller.STATUS_FAILURE_STORAGE ->
"There is not enough free space on this TV to install the update."
PackageInstaller.STATUS_FAILURE_BLOCKED ->
"Your TV blocked the install. Allow Memby under Settings → Device Preferences → " +
"Security & restrictions → Unknown sources, then try again."
PackageInstaller.STATUS_FAILURE_CONFLICT ->
"Android refused the update because the installed copy of Memby does not match it. " +
"Uninstall Memby and install this version from the install page."
PackageInstaller.STATUS_FAILURE_INCOMPATIBLE ->
"This update is not compatible with this TV."
PackageInstaller.STATUS_FAILURE_INVALID ->
"The downloaded update was rejected as damaged. Try again."
else -> systemMessage?.takeIf { it.isNotBlank() }?.let { "The update could not be installed ($it)." }
?: "The update could not be installed."
}
@@ -1,12 +1,13 @@
package com.ponzischeme89.memby.update
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.content.pm.PackageInfo
import android.content.pm.PackageInstaller
import android.content.pm.PackageManager
import android.os.Build
import android.provider.Settings
import androidx.core.content.FileProvider
import androidx.core.content.pm.PackageInfoCompat
import androidx.core.net.toUri
import kotlinx.coroutines.Dispatchers
@@ -157,9 +158,12 @@ class UpdateChecker(private val context: Context) {
}
/**
* Downloads the APK and launches the system installer. On Android O+ the app
* needs the "install unknown apps" permission; if it's missing we send the user
* to that settings screen and return a message asking them to retry.
* Downloads the APK, verifies it, and commits it to a [PackageInstaller] session. On
* Android O+ the app needs the "install unknown apps" permission; if it's missing we
* send the user to that settings screen and return a message asking them to retry.
*
* Success here means the *session was committed*, not that the update is installed
* the rest of the story arrives on [AppInstall.messages] via [InstallResultReceiver].
*/
suspend fun downloadAndInstall(
apkUrl: String,
@@ -170,19 +174,24 @@ class UpdateChecker(private val context: Context) {
): Result<Unit> =
withContext(Dispatchers.IO) {
// Gate on the install-unknown-apps permission before spending a download.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O &&
!context.packageManager.canRequestPackageInstalls()
) {
runCatching {
val intent = Intent(
Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES,
"package:${context.packageName}".toUri(),
).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
context.startActivity(intent)
}
//
// Sending the viewer to Android's per-app permission screen is best, but many
// television builds do not implement it. When it is missing, saying so and
// giving the path through the TV's own settings is the only thing that gets
// anyone unstuck — before this, the activity failed to start, the failure was
// swallowed, and the screen said "the update will continue when you return"
// about a screen that never opened.
if (!InstallPermission.granted(context)) {
val opened = InstallPermission.requestScreen(context)
return@withContext Result.failure(
InstallPermissionRequiredException(
"Allow Memby to install apps. The update will continue when you return.",
if (opened) {
"Allow Memby to install apps. The update will continue when you return."
} else {
"Memby needs permission to install updates. On this TV, open " +
"${InstallPermission.MANUAL_PATH} and turn on Memby, then press " +
"Update again."
},
),
)
}
@@ -250,24 +259,76 @@ class UpdateChecker(private val context: Context) {
partial.delete()
error("The verified update could not be prepared.")
}
val uri = FileProvider.getUriForFile(
context, "${context.packageName}.fileprovider", file,
)
val install = Intent(Intent.ACTION_VIEW).apply {
setDataAndType(uri, "application/vnd.android.package-archive")
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
context.startActivity(install)
commitInstallSession(file)
}
}
/**
* Hands the verified APK to [PackageInstaller].
*
* The previous implementation sent an `ACTION_VIEW` intent at the APK's content URI.
* That is the phone idiom and it fails quietly on televisions: the implicit intent is
* subject to package visibility on Android 11+, several TV builds expose no activity
* for the package-archive MIME type at all, and either way the app learns nothing about
* what the installer then did. A session is explicit, needs no viewer activity, and
* reports its outcome to [InstallResultReceiver].
*/
private fun commitInstallSession(file: File) {
val installer = context.packageManager.packageInstaller
// An abandoned session from an earlier attempt holds its staged copy of the APK
// and counts against the per-app session limit, so a TV that failed a few times
// would eventually stop being able to start one at all.
installer.mySessions.forEach { runCatching { installer.abandonSession(it.sessionId) } }
val params = PackageInstaller.SessionParams(
PackageInstaller.SessionParams.MODE_FULL_INSTALL,
).apply {
setAppPackageName(context.packageName)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
// Honoured only once Memby is its own installer of record — that is, from
// the second self-update onwards — and ignored otherwise, in which case the
// system asks as usual. Nothing here depends on it being granted.
setRequireUserAction(PackageInstaller.SessionParams.USER_ACTION_NOT_REQUIRED)
}
}
val sessionId = installer.createSession(params)
var committed = false
try {
installer.openSession(sessionId).use { session ->
session.openWrite(SESSION_ENTRY, 0, file.length()).use { out ->
file.inputStream().use { it.copyTo(out) }
session.fsync(out)
}
session.commit(installStatusSender(sessionId))
committed = true
}
} finally {
if (!committed) runCatching { installer.abandonSession(sessionId) }
}
}
/** Where the system reports the session's outcome. Mutable: the system fills in extras. */
private fun installStatusSender(sessionId: Int) = PendingIntent.getBroadcast(
context,
sessionId,
Intent(context, InstallResultReceiver::class.java)
.setAction(InstallResultReceiver.ACTION)
.setPackage(context.packageName),
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE
} else {
PendingIntent.FLAG_UPDATE_CURRENT
},
).intentSender
@Suppress("DEPRECATION")
private fun verifyApk(file: File, expectedVersion: String) {
val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
PackageManager.GET_SIGNING_CERTIFICATES
} else {
PackageManager.GET_SIGNATURES
}
// Both flags, on both sides, on every API level. GET_SIGNING_CERTIFICATES alone is
// correct for an *installed* package but not for an archive: getPackageArchiveInfo
// leaves signingInfo null on several Android versions, and asking only for it meant
// the digests came back empty and a correctly signed update was rejected as forged.
val flags = PackageManager.GET_SIGNING_CERTIFICATES or PackageManager.GET_SIGNATURES
val archive = context.packageManager.getPackageArchiveInfo(file.absolutePath, flags)
?: error("The downloaded file is not a valid Android app.")
if (archive.packageName != context.packageName) {
@@ -285,8 +346,8 @@ class UpdateChecker(private val context: Context) {
) {
error("Android requires an update with a higher version code.")
}
if (signerDigests(archive) != signerDigests(installed) ||
signerDigests(archive).isEmpty()
if (signerVerdict(signerDigests(archive), signerDigests(installed)) ==
SignerVerdict.MISMATCH
) {
error("The update was not signed by Membys trusted release key.")
}
@@ -294,12 +355,16 @@ class UpdateChecker(private val context: Context) {
@Suppress("DEPRECATION")
private fun signerDigests(info: PackageInfo): Set<String> {
val signatures = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
info.signingInfo?.apkContentsSigners.orEmpty()
// Prefer the modern accessor and fall back, rather than choosing by SDK level: which
// of the two a given Android version fills in depends on whether this PackageInfo
// describes an installed package or a file on disk, not only on the API level.
val modern = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
info.signingInfo?.apkContentsSigners
} else {
info.signatures.orEmpty()
null
}
return signatures.mapTo(linkedSetOf()) { signature ->
val signatures = modern?.takeIf { it.isNotEmpty() } ?: info.signatures.orEmpty()
return signatures.filterNotNull().mapTo(linkedSetOf()) { signature ->
MessageDigest.getInstance("SHA-256")
.digest(signature.toByteArray())
.joinToString("") { "%02x".format(it.toInt() and 0xff) }
@@ -325,11 +390,37 @@ class UpdateChecker(private val context: Context) {
companion object {
private const val MAX_APK_BYTES = 250L * 1024L * 1024L
private const val SESSION_ENTRY = "memby-update.apk"
}
}
class InstallPermissionRequiredException(message: String) : IllegalStateException(message)
/** What comparing an update's signers against the installed app's could establish. */
internal enum class SignerVerdict { MATCH, MISMATCH, UNVERIFIABLE }
/**
* Decides whether a downloaded update may proceed to the installer.
*
* The distinction that matters is between *knowing* the key is wrong and *not being able to
* read* it. Treating the second as the first is what stopped every in-app update on the TVs
* where `getPackageArchiveInfo` returns no signing information: the APK was correctly signed
* with the same key it always had, and the app said it was not, so the only way forward was
* a manual reinstall.
*
* Proceeding when the read fails is safe because this check is a courtesy, not the defence.
* The APK has already been matched against the published SHA-256, its package name and its
* version; and Android itself enforces signature identity at install time and cannot be
* talked out of it. All this check can honestly add is a clearer message when it *can* see
* a genuine mismatch which is why an unreadable archive falls through to the installer,
* where a real mismatch surfaces as STATUS_FAILURE_CONFLICT.
*/
internal fun signerVerdict(archive: Set<String>, installed: Set<String>): SignerVerdict = when {
archive.isEmpty() || installed.isEmpty() -> SignerVerdict.UNVERIFIABLE
archive == installed -> SignerVerdict.MATCH
else -> SignerVerdict.MISMATCH
}
/**
* A `.json` update URL means "static manifest"; anything else is treated as a Gitea host.
* Chosen by URL shape rather than a mode switch: one fewer setting to get wrong on a TV
@@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:color="#FF090B0D" android:state_focused="true" />
<item android:color="#FFFFFFFF" />
<item android:color="#FF07090A" android:state_focused="true" />
<item android:color="#FF52B54B" android:state_selected="true" />
<item android:color="#FFE9EDF0" />
</selector>
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Two people, for the cast panel. Deliberately not Android's "cast" glyph (the screen
with waves), which on a television means sending the picture somewhere else and is the
one thing this button must not be mistaken for. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M16,11c1.66,0 2.99,-1.34 2.99,-3S17.66,5 16,5c-1.66,0 -3,1.34 -3,3s1.34,3 3,3zM8,11c1.66,0 2.99,-1.34 2.99,-3S9.66,5 8,5C6.34,5 5,6.34 5,8s1.34,3 3,3zM8,13c-2.33,0 -7,1.17 -7,3.5V19h14v-2.5c0,-2.33 -4.67,-3.5 -7,-3.5zM16,13c-0.29,0 -0.62,0.02 -0.97,0.05 1.16,0.84 1.97,1.97 1.97,3.45V19h6v-2.5c0,-2.33 -4.67,-3.5 -7,-3.5z" />
</vector>
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- The mirror of ic_player_rewind, so the pair reads as one control rather than two. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FF69CD61"
android:pathData="M4,18l8.5,-6L4,6v12zM13,6v12l8.5,-6L13,6z" />
</vector>
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Two chevrons pointing back. Deliberately not a clock or a circular arrow: the OSD
already prints the number of seconds, and the glyph only has to say which way. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FF69CD61"
android:pathData="M11,18V6l-8.5,6L11,18zM11.5,12l8.5,6V6L11.5,12z" />
</vector>
@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Drawn over the portrait, not behind it, so the accent ring reads on the artwork rather
than being covered by it. A remote has no hover: the ring plus the scale is the only
thing saying which face is selected. -->
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:state_focused="true">
<shape android:shape="rectangle">
<solid android:color="#00000000" />
<stroke
android:width="2dp"
android:color="#FF52B54B" />
<corners android:radius="12dp" />
</shape>
</item>
<item>
<shape android:shape="rectangle">
<solid android:color="#00000000" />
<stroke
android:width="1dp"
android:color="#1FFFFFFF" />
<corners android:radius="12dp" />
</shape>
</item>
</selector>
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- The cast panel has no card edge. It sits on somebody's film, so it fades into the
picture from the bottom rather than dropping a box over it: the scene stays visible
above the names, which is the whole reason the panel was opened. -->
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<gradient
android:angle="90"
android:centerColor="#D40A0C0F"
android:centerY="0.35"
android:endColor="#00000000"
android:startColor="#FA07080A"
android:type="linear" />
</shape>
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- The subtitle drop-up sits over somebody's film, so it is near-black rather than a lit
panel: a bright surface at night is the thing that hurts, and the option under focus is
the only lit thing on it. -->
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="#F7000000" />
<stroke
android:width="1dp"
android:color="#1FFFFFFF" />
<corners android:radius="14dp" />
</shape>
@@ -1,27 +1,23 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Focus is the only fill on the menu; the current choice is a quiet grey plate, so an
unfocused row never competes with the one the remote is on. -->
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:state_focused="true">
<shape android:shape="rectangle">
<solid android:color="#FFFFFFFF" />
<corners android:radius="12dp" />
<solid android:color="#FF52B54B" />
<corners android:radius="10dp" />
</shape>
</item>
<item android:state_selected="true">
<shape android:shape="rectangle">
<solid android:color="#2E52B54B" />
<stroke
android:width="1dp"
android:color="#B852B54B" />
<corners android:radius="12dp" />
<solid android:color="#1AFFFFFF" />
<corners android:radius="10dp" />
</shape>
</item>
<item>
<shape android:shape="rectangle">
<solid android:color="#16FFFFFF" />
<stroke
android:width="1dp"
android:color="#20FFFFFF" />
<corners android:radius="12dp" />
<solid android:color="#00000000" />
<corners android:radius="10dp" />
</shape>
</item>
</selector>
@@ -22,6 +22,11 @@
controller, so it can appear briefly without opening the transport controls. -->
<include layout="@layout/player_playback_identity" />
<!-- Skipping. Declared before the timing cues so those sit above it: a lower third on
the left and this centred chip do not overlap, and if they ever do, the cue that
appears once matters more than one that appears on every press. -->
<include layout="@layout/player_seek_indicator" />
<!-- The ten-minute cue. Declared before the next-up banner so that banner covers it
if the two ever coincide: what comes next matters more than how long is left. -->
<include layout="@layout/player_time_remaining" />
@@ -242,6 +242,15 @@
android:src="@drawable/exo_ic_subtitle_on"
tools:ignore="PrivateResource" />
<!-- Beside the subtitle picker rather than buried in the settings list:
"who is this?" is asked mid-scene, and a question that has to survive a
dialog and four menu rows is one nobody asks twice. -->
<ImageButton
android:id="@+id/player_cast"
style="@style/MembyPlayerControlButton"
android:contentDescription="@string/player_cast_open"
android:src="@drawable/ic_player_cast" />
<ImageButton
android:id="@id/exo_settings"
style="@style/MembyPlayerControlButton"
+35 -18
View File
@@ -1,64 +1,79 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- The cast panel. It covers the bottom of somebody's film, so it is a fade rather than a
card: the scrim carries the names and the scene stays legible above them. The paddings
match the transport row's 48dp side inset, so opening this does not shift the column
the title and controls are read in. -->
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/player_cast_overlay"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#70000000"
android:background="@drawable/player_cast_scrim"
android:clickable="true"
android:clipChildren="false"
android:focusable="true"
android:visibility="gone">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="340dp"
android:layout_height="wrap_content"
android:layout_gravity="bottom"
android:background="@drawable/player_overlay_panel_background"
android:clipChildren="false"
android:clipToPadding="false"
android:orientation="vertical"
android:paddingStart="48dp"
android:paddingTop="25dp"
android:paddingTop="20dp"
android:paddingEnd="48dp"
android:paddingBottom="22dp">
android:paddingBottom="34dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:letterSpacing="0.14"
android:text="@string/player_now_playing"
android:text="@string/player_cast_eyebrow"
android:textColor="#FF52B54B"
android:textSize="11sp"
android:textStyle="bold" />
<!-- The title, not the word "Cast". The eyebrow above already says what the panel
is, and a viewer who opened this mid-scene wants confirmation of what they are
watching more than a heading repeating the button they just pressed. -->
<TextView
android:layout_width="wrap_content"
android:id="@+id/player_cast_title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="@string/player_cast"
android:ellipsize="end"
android:maxLines="1"
android:textColor="#FFFFFFFF"
android:textSize="28sp"
android:textSize="26sp"
android:textStyle="bold" />
<TextView
android:id="@+id/player_cast_status"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="18dp"
android:layout_marginTop="14dp"
android:text="@string/player_cast_loading"
android:textColor="#AFFFFFFF"
android:textSize="14sp" />
<HorizontalScrollView
android:id="@+id/player_cast_scroller"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_marginTop="14dp"
android:layout_weight="1"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:clipChildren="false"
android:clipToPadding="false"
android:fillViewport="false"
android:overScrollMode="never">
android:overScrollMode="never"
android:paddingTop="6dp"
android:paddingBottom="6dp"
android:scrollbars="none">
<LinearLayout
android:id="@+id/player_cast_people"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:layout_height="wrap_content"
android:clipChildren="false"
android:gravity="top"
android:orientation="horizontal" />
</HorizontalScrollView>
@@ -66,8 +81,10 @@
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:letterSpacing="0.08"
android:text="@string/player_back_to_close"
android:textColor="#78FFFFFF"
android:textSize="12sp" />
android:textColor="#6EFFFFFF"
android:textSize="11sp" />
</LinearLayout>
</FrameLayout>
@@ -0,0 +1,60 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- What a press of Left or Right just did, and where it lands. It sits over somebody's
film, so it is near-black, non-focusable and centred low on the screen where the
transport controls would be — the viewer is already looking there. It never opens the
transport OSD: skipping is meant to cost one press, not one press plus a menu. -->
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/player_seek_indicator"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="bottom|center_horizontal"
android:layout_marginBottom="104dp"
android:focusable="false"
android:visibility="gone">
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="@drawable/time_remaining_cue_background"
android:gravity="center_vertical"
android:orientation="horizontal"
android:paddingStart="18dp"
android:paddingTop="12dp"
android:paddingEnd="22dp"
android:paddingBottom="12dp">
<ImageView
android:id="@+id/player_seek_glyph"
android:layout_width="26dp"
android:layout_height="26dp"
android:contentDescription="@null"
android:scaleType="fitCenter"
android:src="@drawable/ic_player_forward" />
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="14dp"
android:orientation="vertical">
<TextView
android:id="@+id/player_seek_amount"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="#FFFFFFFF"
android:textSize="18sp"
android:textStyle="bold"
tools:text="Forward 30 seconds" />
<TextView
android:id="@+id/player_seek_position"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:textColor="#99FFFFFF"
android:textSize="13sp"
tools:text="12:34 / 1:45:00" />
</LinearLayout>
</LinearLayout>
</FrameLayout>
@@ -1,107 +1,157 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- A drop-up over the subtitle button rather than a full-height panel: the film keeps
playing behind it, so there is no scrim and nothing bigger than the question being
asked. The margins line the panel up with the transport row's end inset (48dp) and
clear its 72dp button strip plus the controls' 28dp bottom padding; the top margin is
what caps how tall the track list can grow before it scrolls.
The panel holds two screens, not three sections. Choosing a track and fetching one the
title does not have are separate questions, and at 344dp by a third of a 720p screen
there is not room to ask both — stacking them squeezed the track list to a single row.
So `player_subtitle_main_section` and the results half of the download section swap:
the search row is the way in, Back is the way out, one press per level. -->
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/player_subtitle_overlay"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#70000000"
android:background="@android:color/transparent"
android:clickable="true"
android:focusable="true"
android:visibility="gone">
<LinearLayout
android:layout_width="520dp"
android:layout_height="match_parent"
android:layout_gravity="end"
android:layout_marginStart="30dp"
android:background="@drawable/player_overlay_panel_background"
android:id="@+id/player_subtitle_menu"
android:layout_width="344dp"
android:layout_height="wrap_content"
android:layout_gravity="bottom|end"
android:layout_marginTop="96dp"
android:layout_marginEnd="44dp"
android:layout_marginBottom="112dp"
android:background="@drawable/player_menu_background"
android:orientation="vertical"
android:paddingStart="34dp"
android:paddingTop="32dp"
android:paddingEnd="34dp"
android:paddingBottom="26dp">
android:paddingStart="10dp"
android:paddingTop="14dp"
android:paddingEnd="10dp"
android:paddingBottom="12dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:letterSpacing="0.14"
android:text="@string/player_playback_options"
android:textColor="#FF52B54B"
android:textSize="11sp"
android:textStyle="bold" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="7dp"
android:text="@string/player_subtitles"
android:textColor="#FFFFFFFF"
android:textSize="30sp"
android:textStyle="bold" />
<TextView
<LinearLayout
android:id="@+id/player_subtitle_main_section"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="7dp"
android:lineSpacingExtra="2dp"
android:text="@string/player_subtitle_overlay_hint"
android:textColor="#AFFFFFFF"
android:textSize="14sp" />
android:orientation="vertical">
<ScrollView
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_marginTop="24dp"
android:layout_weight="1"
android:clipToPadding="false"
android:fillViewport="true"
android:overScrollMode="never">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="14dp"
android:letterSpacing="0.12"
android:text="@string/player_subtitle_track"
android:textColor="#7AFFFFFF"
android:textSize="11sp"
android:textStyle="bold" />
<LinearLayout
<ScrollView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:letterSpacing="0.12"
android:text="@string/player_subtitle_track"
android:textColor="#8FFFFFFF"
android:textSize="11sp"
android:textStyle="bold" />
android:layout_marginTop="8dp"
android:layout_weight="1"
android:fillViewport="false"
android:overScrollMode="never"
android:scrollbars="none">
<LinearLayout
android:id="@+id/player_subtitle_tracks"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="9dp"
android:orientation="vertical" />
</ScrollView>
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="25dp"
android:letterSpacing="0.12"
android:text="@string/player_text_size"
android:textColor="#8FFFFFFF"
android:textSize="11sp"
android:textStyle="bold" />
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:layout_marginStart="14dp"
android:layout_marginTop="10dp"
android:layout_marginEnd="14dp"
android:background="#14FFFFFF" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="14dp"
android:layout_marginTop="12dp"
android:letterSpacing="0.12"
android:text="@string/player_text_size"
android:textColor="#7AFFFFFF"
android:textSize="11sp"
android:textStyle="bold" />
<LinearLayout
android:id="@+id/player_subtitle_sizes"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:orientation="horizontal" />
</LinearLayout>
<!-- Fetching a subtitle the title does not have. The whole group is GONE on a
backend that cannot do it, rather than showing a row that leads nowhere. -->
<LinearLayout
android:id="@+id/player_subtitle_download_section"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical"
android:visibility="gone">
<!-- Hidden with the section above it: with the menu swapped out this heading is
the first thing on the panel, and a rule above the first thing is a line
under the panel's own top edge. -->
<View
android:id="@+id/player_subtitle_download_rule"
android:layout_width="match_parent"
android:layout_height="1dp"
android:layout_marginStart="14dp"
android:layout_marginTop="12dp"
android:layout_marginEnd="14dp"
android:background="#14FFFFFF" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="14dp"
android:layout_marginTop="12dp"
android:letterSpacing="0.12"
android:text="@string/player_subtitle_download"
android:textColor="#7AFFFFFF"
android:textSize="11sp"
android:textStyle="bold" />
<TextView
android:id="@+id/player_subtitle_download_status"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="14dp"
android:layout_marginTop="6dp"
android:layout_marginEnd="14dp"
android:textColor="#96FFFFFF"
android:textSize="12sp"
android:visibility="gone" />
<ScrollView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:layout_weight="1"
android:fillViewport="false"
android:overScrollMode="never"
android:scrollbars="none">
<LinearLayout
android:id="@+id/player_subtitle_sizes"
android:id="@+id/player_subtitle_downloads"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="9dp"
android:orientation="vertical" />
</LinearLayout>
</ScrollView>
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="14dp"
android:text="@string/player_back_to_close"
android:textColor="#78FFFFFF"
android:textSize="12sp" />
</ScrollView>
</LinearLayout>
</LinearLayout>
</FrameLayout>
+12 -4
View File
@@ -23,14 +23,20 @@
<string name="player_live">Live</string>
<string name="player_back">Back to previous screen</string>
<string name="player_hide_controls">Hide controls</string>
<string name="player_playback_options">PLAYBACK OPTIONS</string>
<string name="player_subtitles">Subtitles</string>
<string name="player_cast">Cast</string>
<string name="player_cast_eyebrow">CAST</string>
<string name="player_cast_loading">Loading cast…</string>
<string name="player_cast_empty">No cast information is available.</string>
<string name="player_subtitle_overlay_hint">Choose a track and tune the text size without leaving playback.</string>
<string name="player_subtitle_track">SUBTITLE TRACK</string>
<string name="player_subtitle_track">SUBTITLES</string>
<string name="player_text_size">TEXT SIZE</string>
<string name="player_subtitle_download">GET SUBTITLES</string>
<string name="player_subtitle_search">Search for subtitles…</string>
<string name="player_subtitle_search_again">Search again</string>
<string name="player_subtitle_searching">Looking for subtitles. This can take a moment.</string>
<string name="player_subtitle_search_empty">No subtitles were found for this release.</string>
<string name="player_subtitle_downloading">Downloading %1$s subtitles…</string>
<string name="player_subtitle_download_failed">That subtitle could not be downloaded. Try another.</string>
<string name="player_cast_open">Cast</string>
<string name="player_back_to_close">BACK · CLOSE</string>
<string name="player_ends_at">Ends at %1$s</string>
<string name="player_preroll_countdown_initial">Starting in 7 seconds…</string>
@@ -51,6 +57,8 @@
<item quantity="one">%1$d min</item>
<item quantity="other">%1$d mins</item>
</plurals>
<string name="player_seek_forward">Forward %1$s</string>
<string name="player_seek_back">Back %1$s</string>
<string name="next_up_label">NEXT UP</string>
<string name="next_up_play_now">Play now</string>
<string name="next_up_dismiss">Dismiss</string>
-5
View File
@@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<!-- Matches context.cacheDir, where the update APK is downloaded. -->
<cache-path name="apk_cache" path="." />
</paths>
@@ -0,0 +1,105 @@
package com.ponzischeme89.memby.data
import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.data.model.UserItemData
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
/**
* Deliberately parallel to the gateway's `continue_watching_test.go`. Continue Watching
* must hold the same cards in the same order whether or not the container is up.
*/
class ContinueWatchingTest {
private fun resume(id: String, seriesId: String? = null, playedAt: String? = null) =
BaseItem(
id = id,
seriesId = seriesId,
userData = UserItemData(lastPlayedDate = playedAt),
)
private fun upNext(id: String, seriesId: String) =
BaseItem(id = id, seriesId = seriesId, userData = UserItemData())
/**
* The case the merged row exists for: an episode was just finished, so it has left the
* resume list, and the next one must be the first card rather than sitting in a second
* row further down the launcher.
*/
@Test
fun leadsWithTheJustFinishedShow() {
val merged = mergeContinueWatching(
resume = listOf(resume("film", playedAt = "2026-08-01T20:00:00.0000000Z")),
nextUp = listOf(upNext("s2e5", "severance")),
seriesLastPlayed = mapOf("severance" to "2026-08-06T21:00:00.0000000Z"),
)
assertEquals(listOf("s2e5", "film"), merged.map(BaseItem::id))
}
/** A show being watched right now appears once, as the episode it is part-way through. */
@Test
fun prefersTheResumeEpisodeOfASeries() {
val merged = mergeContinueWatching(
resume = listOf(resume("s1e3", "show", "2026-08-06T19:00:00Z")),
nextUp = listOf(upNext("s1e4", "show"), upNext("other-e1", "other")),
seriesLastPlayed = mapOf(
"show" to "2026-08-06T19:00:00Z",
"other" to "2026-08-02T19:00:00Z",
),
)
assertEquals(listOf("s1e3", "other-e1"), merged.map(BaseItem::id))
}
/**
* Each source keeps its own order. Emby's ordering within a list is the useful part of
* both, so this is a merge of two sorted lists and never a sort of their union.
*/
@Test
fun keepsEachSourceOrder() {
val merged = mergeContinueWatching(
resume = listOf(
resume("r1", playedAt = "2026-08-06T10:00:00Z"),
resume("r2", playedAt = "2026-08-04T10:00:00Z"),
resume("r3", playedAt = "2026-08-01T10:00:00Z"),
),
nextUp = listOf(upNext("n1", "alpha"), upNext("n2", "beta")),
seriesLastPlayed = mapOf(
"alpha" to "2026-08-05T10:00:00Z",
"beta" to "2026-08-03T10:00:00Z",
),
)
assertEquals(listOf("r1", "n1", "r2", "n2", "r3"), merged.map(BaseItem::id))
}
/**
* With no play dates to go on the lookup failed, or Emby wrote something
* unparseable the row is the resume list followed by Next Up, which is the order the
* launcher had when they were two rows. Nothing is dropped.
*/
@Test
fun fallsBackToResumeFirst() {
val merged = mergeContinueWatching(
resume = listOf(resume("r1", playedAt = "not a date"), resume("r2")),
nextUp = listOf(upNext("n1", "alpha"), upNext("n2", "beta")),
seriesLastPlayed = emptyMap(),
)
assertEquals(listOf("r1", "r2", "n1", "n2"), merged.map(BaseItem::id))
}
@Test
fun normalizesEmbyTimestampsAndRejectsEverythingElse() {
assertEquals("2026-08-06T21:04:05", normalizePlayedAt("2026-08-06T21:04:05.1234567Z"))
assertEquals("2026-08-06T21:04:05", normalizePlayedAt("2026-08-06T21:04:05Z"))
assertEquals("2026-08-06T21:04:05", normalizePlayedAt(" 2026-08-06T21:04:05 "))
assertNull(normalizePlayedAt(null))
assertNull(normalizePlayedAt(""))
assertNull(normalizePlayedAt("2026-08-06"))
assertNull(normalizePlayedAt("yesterday afternoon"))
// Emby's "never played" sentinel is not a date to order a card by.
assertNull(normalizePlayedAt("0001-01-01T00:00:00.0000000Z"))
}
}
@@ -0,0 +1,47 @@
package com.ponzischeme89.memby.data
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* The device id is the television's identity in Emby's devices list and in
* Settings Devices. What these pin is that reinstalling the app reproduces it that is
* the whole reason it is derived rather than random and that the platform values which
* cannot identify a set do not silently make two televisions into one.
*/
class DeviceIdTest {
@Test
fun `same android id yields the same device id`() {
assertEquals(deviceIdFor("a1b2c3d4e5f60718"), deviceIdFor("a1b2c3d4e5f60718"))
}
@Test
fun `different android ids yield different device ids`() {
assertNotEquals(deviceIdFor("a1b2c3d4e5f60718"), deviceIdFor("18f7e6f5d4c3b2a1"))
}
@Test
fun `device id never carries the platform identifier`() {
val id = deviceIdFor("a1b2c3d4e5f60718")
assertTrue(id.startsWith("memby-"))
assertTrue(!id.contains("a1b2c3d4e5f60718"))
}
@Test
fun `surrounding whitespace does not change the identity`() {
assertEquals(deviceIdFor("a1b2c3d4e5f60718"), deviceIdFor(" a1b2c3d4e5f60718 "))
}
@Test
fun `unusable android ids fall back to a unique id`() {
// A null, blank, short or all-zero value identifies nothing, and the id a batch of
// early devices shared would make every one of them the same television. Two calls
// must not agree: one duplicate entry is better than two sets sharing a session.
for (value in listOf(null, "", " ", "9774d56d682e549c", "0000000000000000", "abc")) {
assertNotEquals("android id $value", deviceIdFor(value), deviceIdFor(value))
}
}
}
@@ -0,0 +1,90 @@
package com.ponzischeme89.memby.data
import com.ponzischeme89.memby.data.model.GatewayEmbyHealth
import com.ponzischeme89.memby.ui.retryLabel
import com.ponzischeme89.memby.ui.secondsUntil
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertSame
import org.junit.Test
class EmbyOutageTest {
private val down = GatewayEmbyHealth(monitored = true, reachable = false, retrySeconds = 60)
private val up = GatewayEmbyHealth(monitored = true, reachable = true, retrySeconds = 60)
/**
* With the probe switched off nothing updates `reachable`, so trusting it would mean a
* permanent red bar on a server that is working perfectly well.
*/
@Test
fun `an unmonitored server never raises the bar`() {
val unmonitored = GatewayEmbyHealth(monitored = false, reachable = false)
assertNull(nextOutageState(unmonitored, existing = null, nowMillis = 0L))
}
@Test
fun `a reachable server clears the bar`() {
val existing = EmbyOutage(nextAttemptAtMillis = 60_000L, retryIntervalSeconds = 60)
assertNull(nextOutageState(up, existing, nowMillis = 0L))
}
@Test
fun `an outage arms a countdown from the retry interval`() {
val outage = nextOutageState(down, existing = null, nowMillis = 5_000L)
assertNotNull(outage)
assertEquals(65_000L, outage!!.nextAttemptAtMillis)
assertEquals(60, outage.retryIntervalSeconds)
}
/**
* The status poll runs six times per retry. If each one re-armed the deadline the
* counter would reset every ten seconds and never reach zero, which reads as an app
* that is stuck rather than a server that is being retried.
*/
@Test
fun `a live countdown survives the polls in the middle of it`() {
val armed = nextOutageState(down, existing = null, nowMillis = 0L)!!
var current = armed
listOf(10_000L, 20_000L, 30_000L, 40_000L, 50_000L).forEach { now ->
current = nextOutageState(down, current, now)!!
}
assertSame(armed, current)
}
/** Once the attempt is due, the next one is a fresh interval away. */
@Test
fun `an expired countdown is re-armed`() {
val armed = nextOutageState(down, existing = null, nowMillis = 0L)!!
val next = nextOutageState(down, armed, nowMillis = 60_000L)!!
assertEquals(120_000L, next.nextAttemptAtMillis)
}
/** A server too old to send an interval still has to produce a sane countdown. */
@Test
fun `a missing retry interval falls back to the shared default`() {
val outage = nextOutageState(
GatewayEmbyHealth(monitored = true, reachable = false),
existing = null,
nowMillis = 0L,
)!!
assertEquals(MaintenanceMonitor.DEFAULT_RETRY_SECONDS, outage.retryIntervalSeconds)
}
@Test
fun `the countdown never runs negative`() {
assertEquals(0, secondsUntil(deadlineMillis = 1_000L, nowMillis = 9_000L))
assertEquals(9, secondsUntil(deadlineMillis = 9_000L, nowMillis = 0L))
// A part-second still has time left in it, so it rounds up rather than to zero.
assertEquals(1, secondsUntil(deadlineMillis = 400L, nowMillis = 0L))
}
@Test
fun `the countdown says what is about to happen`() {
assertEquals("Retrying now…", retryLabel(0))
assertEquals("Retrying now…", retryLabel(-3))
assertEquals("Retrying in 1s", retryLabel(1))
assertEquals("Retrying in 42s", retryLabel(42))
}
}
@@ -1,5 +1,6 @@
package com.ponzischeme89.memby.data
import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.data.model.GatewayHome
import com.ponzischeme89.memby.data.model.GatewayDevices
import com.ponzischeme89.memby.data.model.GatewayNextEpisode
@@ -7,6 +8,7 @@ import com.ponzischeme89.memby.data.model.GatewayMovieRatings
import com.ponzischeme89.memby.data.model.GatewayPlayback
import com.ponzischeme89.memby.data.model.GatewayPlaybackReportResponse
import com.ponzischeme89.memby.data.model.GatewaySearchHistory
import com.ponzischeme89.memby.data.model.GatewayPreferences
import com.ponzischeme89.memby.data.model.GatewayServiceStatus
import com.ponzischeme89.memby.data.model.GatewayFeatures
import com.ponzischeme89.memby.data.model.RecommendationOnboarding
@@ -46,6 +48,21 @@ class GatewayPayloadTest {
}
@Test
fun `decodes ratings carried on a row item and defaults them when absent`() {
val carried = json.decodeFromString<BaseItem>(
"""{"Id":"7","Name":"Arrival","Type":"Movie","MembyRatings":[{"source":"imdb","name":"IMDb","score":"7.9","scale":"/10"}]}""",
)
assertEquals(listOf("IMDb"), carried.membyRatings.map { it.name })
assertEquals("7.9", carried.membyRatings.single().score)
// A gateway that has never looked the title up, an older build, and every cached
// home payload written before this field existed all decode to no ratings rather
// than failing — the card then falls back to the dedicated request on focus.
val bare = json.decodeFromString<BaseItem>("""{"Id":"7","Name":"Arrival","Type":"Movie"}""")
assertTrue(bare.membyRatings.isEmpty())
}
private val json = Json {
ignoreUnknownKeys = true
coerceInputValues = true
@@ -80,22 +97,92 @@ class GatewayPayloadTest {
assertEquals("safe_mode", policy.features.single().source)
}
@Test
fun `decodes the live emby reachability the outage bar renders`() {
val status = json.decodeFromString<GatewayServiceStatus>(
"""{"maintenance":false,"emby":{"monitored":true,"reachable":false,"since":"2026-08-04T19:04:00Z","checkedAt":"2026-08-04T19:05:00Z","retrySeconds":60}}""",
)
assertTrue(status.emby.isOutage)
assertEquals(60, status.emby.retrySeconds)
}
/**
* A server predating the field must not produce a red bar. The default is a healthy,
* unmonitored reading precisely so an app that cannot tell says nothing.
*/
@Test
fun `a status payload without emby health reports no outage`() {
val status = json.decodeFromString<GatewayServiceStatus>("""{"maintenance":false}""")
assertEquals(false, status.emby.isOutage)
assertEquals(0L, status.preferencesRevision)
}
@Test
fun `decodes the settings revision an operator push arrives as`() {
val status = json.decodeFromString<GatewayServiceStatus>(
"""{"maintenance":false,"preferencesRevision":12}""",
)
assertEquals(12L, status.preferencesRevision)
}
/**
* The document stays a JsonObject on the wire so a server that has learned a setting
* this build has never heard of is still decodable the unknown key is simply carried
* past by [decodeUserPreferences].
*/
@Test
fun `decodes a settings document containing an unknown setting`() {
val payload = json.decodeFromString<GatewayPreferences>(
"""{"schemaVersion":1,"revision":4,"source":"admin","preferences":{"homeCardDensity":"large","hideWatchedMovies":true,"somethingNewer":"value"}}""",
)
val decoded = decodeUserPreferences(payload.preferences)
assertEquals(4L, payload.revision)
assertEquals("admin", payload.source)
assertEquals("large", decoded.homeCardDensity)
assertTrue(decoded.hideWatchedMovies)
}
@Test
fun `decodes recommendation onboarding ratings and emby items`() {
val onboarding = json.decodeFromString<RecommendationOnboarding>(
"""{
"completed":false,
"prompted":true,
"ratings":{"movie-1":5},
"items":[
{"Id":"movie-1","Name":"Arrival","Type":"Movie"},
{"Id":"series-1","Name":"Severance","Type":"Series"}
]
],
"movies":[{"Id":"movie-1","Name":"Arrival","Type":"Movie"}],
"shows":[{"Id":"series-1","Name":"Severance","Type":"Series"}],
"actors":[{"id":"actor-1","name":"Jeremy Renner","imageTag":"portrait-1"}],
"actresses":[{"id":"actor-2","name":"Amy Adams","imageTag":"portrait-2"}],
"directors":[{"id":"director-1","name":"Denis Villeneuve"}]
}""",
)
assertEquals(false, onboarding.completed)
assertTrue(onboarding.prompted)
assertEquals(5, onboarding.ratings["movie-1"])
assertEquals(listOf("Arrival", "Severance"), onboarding.items.map { it.name })
assertEquals(listOf("Arrival"), onboarding.movies.map { it.name })
assertEquals(listOf("Severance"), onboarding.shows.map { it.name })
assertEquals("Jeremy Renner", onboarding.actors.single().name)
assertEquals("Amy Adams", onboarding.actresses.single().name)
assertEquals("Denis Villeneuve", onboarding.directors.single().name)
}
@Test
fun `legacy onboarding response remains promptable`() {
val onboarding = json.decodeFromString<RecommendationOnboarding>(
"""{"completed":false,"items":[]}""",
)
assertTrue(onboarding.prompted)
}
@Test
@@ -198,7 +285,10 @@ class GatewayPayloadTest {
"MembyAirLabel":"Tomorrow: 8:00 PM",
"MembyAvailability":"downloading",
"MembyAvailabilityText":"Downloading",
"MembyPlayable":false
"MembyLifecycle":"continuing",
"MembyLifecycleText":"CONTINUING",
"MembyPlayable":false,
"MembySeriesItemId":"emby-1041"
}]
}],
"continueWatching":[],
@@ -215,7 +305,41 @@ class GatewayPayloadTest {
assertEquals("S02E04", item.membyEpisodeCode)
assertEquals("Tomorrow", item.membyAirDayLabel)
assertEquals("Downloading", item.membyAvailabilityText)
// Sonarr's lifecycle for the show, which is a different fact from this episode's
// availability and wears its own badge.
assertEquals("continuing", item.membyLifecycle)
assertEquals("CONTINUING", item.membyLifecycleText)
assertEquals(false, item.membyPlayable)
// The link that lets an unplayable card open the show's own page.
assertEquals("emby-1041", item.membySeriesItemId)
}
@Test
fun `a schedule card for a show Emby has not imported carries no series link`() {
val payload = """
{
"rows":[{
"id":"sonarr-airing-today",
"title":"Shows airing in the next 5 days",
"kind":"schedule",
"items":[{
"Id":"sonarr:9:11",
"Name":"Unimported",
"Type":"MembySonarrEpisode",
"MembySource":"sonarr",
"MembyAirDayLabel":"Friday",
"MembyAirLabel":"Friday: 8:00 PM",
"MembyPlayable":false
}]
}]
}
""".trimIndent()
val item = json.decodeFromString<GatewayHome>(payload).rows.single().items.single()
assertTrue(item.isTvSchedule)
assertNull(item.membySeriesItemId)
assertNull(item.membyLifecycle)
}
@Test
@@ -237,6 +361,8 @@ class GatewayPayloadTest {
"MembyAirLabel":"Digital release Saturday",
"MembyAvailability":"upcoming",
"MembyAvailabilityText":"Upcoming digital release",
"MembyLifecycle":"incinemas",
"MembyLifecycleText":"IN CINEMAS",
"MembyPlayable":false
}]
}]
@@ -250,6 +376,8 @@ class GatewayPayloadTest {
assertTrue(item.isMovieSchedule)
assertTrue(item.isSchedule)
assertEquals("Digital release Saturday", item.membyAirLabel)
assertEquals("incinemas", item.membyLifecycle)
assertEquals("IN CINEMAS", item.membyLifecycleText)
assertEquals(false, item.membyPlayable)
}
@@ -286,6 +414,10 @@ class GatewayPayloadTest {
"""{"itemId":"9","title":"Severance Pilot","overview":"Mark returns to the severed floor.","seriesName":"Severance","episodeCode":"S01E01","runtimeMs":3420000,"prerollEnabled":false,"prerollDurationMs":4000,"url":"https://emby.example/Videos/9/stream?static=true","resumePositionMs":42000}""",
)
assertEquals("9", playback.itemId)
// Absent on an older gateway: subtitles on, nothing chosen, so the television falls
// back to its own pick rather than being told to turn them off.
assertTrue(playback.subtitlesEnabled)
assertEquals("", playback.selectedSubtitleId)
assertEquals(42_000L, playback.resumePositionMs)
assertEquals("S01E01", playback.episodeCode)
assertEquals(3_420_000L, playback.runtimeMs)
@@ -0,0 +1,16 @@
package com.ponzischeme89.memby.data
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class PlayablePrefetchTest {
@Test
fun `launch accepts only a recent focus prefetch`() {
val now = 100_000L
assertTrue(isFreshPlayablePrefetch(now - PLAYABLE_PREFETCH_MAX_AGE_MS, now))
assertFalse(isFreshPlayablePrefetch(now - PLAYABLE_PREFETCH_MAX_AGE_MS - 1L, now))
assertFalse(isFreshPlayablePrefetch(now + 1L, now))
}
}
@@ -0,0 +1,55 @@
package com.ponzischeme89.memby.data
import com.ponzischeme89.memby.data.model.MediaRating
import com.ponzischeme89.memby.data.model.displayable
import com.ponzischeme89.memby.data.model.formattedScore
import com.ponzischeme89.memby.data.model.ratingDisplayLimit
import com.ponzischeme89.memby.data.model.ratingsStripVisible
import com.ponzischeme89.memby.data.model.wordmark
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class RatingsTest {
@Test fun `sources retain familiar compact presentation`() {
val imdb = MediaRating("imdb", "IMDb", "8.2", "/10")
val rt = MediaRating("tomatoes", "Rotten Tomatoes", "91", "%")
val letterboxd = MediaRating("letterboxd", "Letterboxd", "4.1", "/5")
assertEquals("IMDb", imdb.wordmark())
assertEquals("8.2", imdb.formattedScore())
assertEquals("RT", rt.wordmark())
assertEquals("91%", rt.formattedScore())
assertEquals("4.1", letterboxd.formattedScore())
}
@Test fun `missing invalid and duplicate scores are omitted`() {
val ratings = listOf(
MediaRating("imdb", "IMDb", "", "/10"),
MediaRating("tmdb", "TMDb", "0", "/10"),
MediaRating("trakt", "Trakt", "80", "%"),
MediaRating("trakt", "Trakt", "81", "%"),
)
assertEquals(listOf("80"), ratings.displayable().map(MediaRating::score))
}
@Test fun `visibility setting hides the whole strip`() {
val ratings = listOf(MediaRating("imdb", "IMDb", "8.2", "/10"))
assertTrue(ratingsStripVisible(true, ratings))
assertFalse(ratingsStripVisible(false, ratings))
assertFalse(ratingsStripVisible(true, emptyList()))
}
@Test fun `narrow layouts truncate progressively`() {
assertEquals(1, ratingDisplayLimit(100))
assertEquals(2, ratingDisplayLimit(180))
assertEquals(3, ratingDisplayLimit(300))
assertEquals(Int.MAX_VALUE, ratingDisplayLimit(500))
}
@Test fun `ratings preference belongs to each profile`() {
val profile = EmbyProfile("id", "server", "token", "user", "name", showRatingsStrip = false)
assertFalse(profile.showRatingsStrip)
assertTrue(profile.copy(id = "other", showRatingsStrip = true).showRatingsStrip)
}
}
@@ -0,0 +1,364 @@
package com.ponzischeme89.memby.data
import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.data.model.UserItemData
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* When Memby is willing to guess a finish date, and when it holds its tongue.
*
* The guards matter more than the arithmetic here: an estimate is a promise made to
* somebody about their own evening, so most of these tests are about the cases that must
* produce nothing at all rather than about the ones that produce a date.
*/
class SeriesPaceTest {
// A fixed "now" so every case reads as a calendar rather than as offsets: midday on
// Wednesday 5 August 2026, UTC, with the television in UTC too.
private val now = playedAtMillis("2026-08-05T12:00:00Z")!!
private val utc = 0
// ---------------------------------------------------------------------------
// Ordinary viewing
// ---------------------------------------------------------------------------
@Test
fun `an episode a night projects a night per remaining episode`() {
val estimate = estimateSeriesPace(nightly(watched = 4, remaining = 5), now, utc)
assertNotNull(estimate)
assertEquals(5, estimate!!.remainingEpisodes)
assertEquals(1.0, estimate.episodesPerDay, 0.001)
// Five left at one a day: one tonight and the last four days from now.
assertEquals(4, estimate.daysAway)
assertEquals("Estimated finish: 9 August", seriesPaceLabel(estimate))
}
@Test
fun `a binge across days is measured as a binge`() {
// Three on Monday, three on Tuesday: three a day, not six.
val episodes = library(
watched = listOf(
"2026-08-03T19:00:00Z", "2026-08-03T20:00:00Z", "2026-08-03T21:00:00Z",
"2026-08-04T19:00:00Z", "2026-08-04T20:00:00Z", "2026-08-04T21:00:00Z",
),
remaining = 6,
)
val estimate = estimateSeriesPace(episodes, now, utc)!!
assertEquals(3.0, estimate.episodesPerDay, 0.001)
assertEquals(1, estimate.daysAway)
assertEquals("You'll likely finish tomorrow", seriesPaceLabel(estimate))
}
@Test
fun `a fast pace with little left finishes today`() {
val episodes = library(
watched = listOf(
"2026-08-03T19:00:00Z", "2026-08-03T20:00:00Z",
"2026-08-04T19:00:00Z", "2026-08-04T20:00:00Z",
),
remaining = 2,
)
assertEquals("You'll likely finish today", seriesPaceLabel(estimateSeriesPace(episodes, now, utc)))
}
@Test
fun `a slow pace over a long tail is rounded to weeks rather than dated`() {
// One a week, forty left: naming a Tuesday nine months out would be a fiction.
val episodes = library(
watched = listOf("2026-07-15T20:00:00Z", "2026-07-22T20:00:00Z", "2026-07-29T20:00:00Z"),
remaining = 8,
)
val label = seriesPaceLabel(estimateSeriesPace(episodes, now, utc))
assertEquals("At your current pace: about 6 weeks remaining", label)
}
// ---------------------------------------------------------------------------
// Not enough to go on
// ---------------------------------------------------------------------------
@Test
fun `one episode is never a pace`() {
val episodes = library(watched = listOf("2026-08-04T20:00:00Z"), remaining = 6)
assertNull(estimateSeriesPace(episodes, now, utc))
}
@Test
fun `the opening of a binge does not project one`() {
// Three episodes, one evening. There is no daily rate in a single sitting, and
// reading one off it would promise a finish this week.
val episodes = library(
watched = listOf(
"2026-08-04T19:00:00Z", "2026-08-04T20:00:00Z", "2026-08-04T21:00:00Z",
),
remaining = 20,
)
assertNull(estimateSeriesPace(episodes, now, utc))
}
@Test
fun `two episodes on separate days are enough when they are close together`() {
val episodes = library(
watched = listOf("2026-08-03T20:00:00Z", "2026-08-04T20:00:00Z"),
remaining = 4,
)
val estimate = estimateSeriesPace(episodes, now, utc)
assertNotNull(estimate)
assertEquals(1.0, estimate!!.episodesPerDay, 0.001)
}
@Test
fun `two episodes a fortnight apart are not a rhythm`() {
val episodes = library(
watched = listOf("2026-07-24T20:00:00Z", "2026-08-04T20:00:00Z"),
remaining = 4,
)
assertNull(estimateSeriesPace(episodes, now, utc))
}
@Test
fun `nothing watched at all says nothing`() {
assertNull(estimateSeriesPace(library(watched = emptyList(), remaining = 8), now, utc))
}
// ---------------------------------------------------------------------------
// Breaks and returns
// ---------------------------------------------------------------------------
@Test
fun `a long silence is not averaged into the current pace`() {
// A season watched last summer, then a return this week. Including the gap would
// put the finish years out; only the return counts.
val episodes = library(
watched = listOf(
"2025-08-01T20:00:00Z", "2025-08-02T20:00:00Z", "2025-08-03T20:00:00Z",
"2026-08-03T20:00:00Z", "2026-08-04T20:00:00Z",
),
remaining = 4,
)
val estimate = estimateSeriesPace(episodes, now, utc)!!
assertEquals(1.0, estimate.episodesPerDay, 0.001)
assertEquals(3, estimate.daysAway)
}
@Test
fun `a viewer who has not come back has no current pace`() {
val episodes = library(
watched = listOf(
"2026-05-01T20:00:00Z", "2026-05-02T20:00:00Z", "2026-05-03T20:00:00Z",
),
remaining = 6,
)
assertNull(estimateSeriesPace(episodes, now, utc))
}
@Test
fun `speeding up moves the date in`() {
val steady = library(
watched = listOf("2026-08-01T20:00:00Z", "2026-08-02T20:00:00Z", "2026-08-03T20:00:00Z"),
remaining = 6,
)
val quickened = library(
watched = listOf(
"2026-08-03T18:00:00Z", "2026-08-03T20:00:00Z", "2026-08-03T22:00:00Z",
"2026-08-04T18:00:00Z", "2026-08-04T20:00:00Z", "2026-08-04T22:00:00Z",
),
remaining = 6,
)
val before = estimateSeriesPace(steady, now, utc)!!
val after = estimateSeriesPace(quickened, now, utc)!!
assertTrue(after.episodesPerDay > before.episodesPerDay)
assertTrue(after.daysAway < before.daysAway)
}
// ---------------------------------------------------------------------------
// Nothing worth saying
// ---------------------------------------------------------------------------
@Test
fun `a finished series has no estimate`() {
assertNull(estimateSeriesPace(nightly(watched = 4, remaining = 0), now, utc))
}
@Test
fun `one episode left needs no date`() {
assertNull(estimateSeriesPace(nightly(watched = 4, remaining = 1), now, utc))
}
@Test
fun `a horizon beyond a year is not offered`() {
// An episode every couple of weeks against a very long show. The arithmetic is
// sound and the answer — some day in 2031 — is not one to put on a television.
val episodes = library(
watched = listOf("2026-07-08T20:00:00Z", "2026-07-21T20:00:00Z", "2026-08-03T20:00:00Z"),
remaining = 200,
)
assertNull(estimateSeriesPace(episodes, now, utc))
}
@Test
fun `a label is null when the estimate is`() {
assertNull(seriesPaceLabel(null))
}
// ---------------------------------------------------------------------------
// Ongoing shows
// ---------------------------------------------------------------------------
@Test
fun `a show still in production is caught up with, not finished`() {
val estimate = estimateSeriesPace(nightly(watched = 4, remaining = 5), now, utc, ongoing = true)!!
assertTrue(estimate.catchUp)
assertEquals("You'll catch up around 9 August", seriesPaceLabel(estimate))
}
@Test
fun `catching up tomorrow is worded as catching up`() {
val episodes = library(
watched = listOf(
"2026-08-03T19:00:00Z", "2026-08-03T21:00:00Z",
"2026-08-04T19:00:00Z", "2026-08-04T21:00:00Z",
),
remaining = 4,
)
assertEquals(
"You'll likely catch up tomorrow",
seriesPaceLabel(estimateSeriesPace(episodes, now, utc, ongoing = true)),
)
}
// ---------------------------------------------------------------------------
// Awkward data
// ---------------------------------------------------------------------------
@Test
fun `specials count towards neither the pace nor what is left`() {
val episodes = nightly(watched = 4, remaining = 5) +
listOf(
episode(season = 0, number = 1, playedAt = "2026-08-04T23:00:00Z"),
episode(season = 0, number = 2, playedAt = null),
episode(season = 0, number = 3, playedAt = null),
)
val estimate = estimateSeriesPace(episodes, now, utc)!!
assertEquals(5, estimate.remainingEpisodes)
assertEquals(1.0, estimate.episodesPerDay, 0.001)
}
@Test
fun `a library of nothing but specials is treated as the show`() {
val episodes = listOf(
episode(season = 0, number = 1, playedAt = "2026-08-03T20:00:00Z"),
episode(season = 0, number = 2, playedAt = "2026-08-04T20:00:00Z"),
episode(season = 0, number = 3, playedAt = null),
episode(season = 0, number = 4, playedAt = null),
)
assertNotNull(estimateSeriesPace(episodes, now, utc))
}
@Test
fun `a part-watched episode is remaining and is not a completion`() {
val episodes = nightly(watched = 3, remaining = 3).map { item ->
if (item.indexNumber == 4) {
item.copy(userData = UserItemData(played = false, playbackPositionTicks = 5_000_000_000L))
} else {
item
}
}
val estimate = estimateSeriesPace(episodes, now, utc)!!
// Three unwatched, one of them the episode the viewer is part-way through, and a
// pace measured only from the three that were actually completed.
assertEquals(3, estimate.remainingEpisodes)
assertEquals(1.0, estimate.episodesPerDay, 0.001)
}
@Test
fun `out of order and unstamped history is tolerated`() {
// Emby's "never played" sentinel on one watched episode, and the list shuffled.
val episodes = listOf(
episode(1, 3, "2026-08-04T20:00:00Z"),
episode(1, 1, "2026-08-02T20:00:00Z"),
episode(1, 5, null),
episode(1, 2, "0001-01-01T00:00:00Z", played = true),
episode(1, 4, "2026-08-03T20:00:00Z"),
episode(1, 6, null),
episode(1, 7, null),
)
val estimate = estimateSeriesPace(episodes, now, utc)!!
assertEquals(3, estimate.remainingEpisodes)
// Three dated completions across three days.
assertEquals(1.0, estimate.episodesPerDay, 0.001)
}
@Test
fun `a timestamp from the future is a clock, not a habit`() {
val episodes = library(
watched = listOf("2026-08-03T20:00:00Z", "2026-08-04T20:00:00Z", "2027-01-01T20:00:00Z"),
remaining = 4,
)
assertNull(estimateSeriesPace(episodes, now, utc))
}
@Test
fun `an empty library says nothing`() {
assertNull(estimateSeriesPace(emptyList(), now, utc))
}
// ---------------------------------------------------------------------------
// The viewer's own midnight
// ---------------------------------------------------------------------------
@Test
fun `days are counted in the television's zone, not Greenwich`() {
// Two episodes at 13:00 UTC on consecutive days. In UTC+13 that is one o'clock the
// following morning, still two separate local days — but the *finish* is counted
// from the local today, which is already 6 August in Auckland.
val nz = 13 * 60 * 60 * 1000
val episodes = library(
watched = listOf("2026-08-03T13:00:00Z", "2026-08-04T13:00:00Z"),
remaining = 3,
)
val here = estimateSeriesPace(episodes, now, utc)!!
val there = estimateSeriesPace(episodes, now, nz)!!
assertEquals(here.finishEpochDay + 1, there.finishEpochDay)
}
@Test
fun `a date is written day first with the month named`() {
// 1 March 2028 — a leap year, which is where the calendar arithmetic goes wrong.
assertEquals("1 March", formatPaceDate(playedAtMillis("2028-03-01T00:00:00Z")!! / 86_400_000L))
assertEquals("29 February", formatPaceDate(playedAtMillis("2028-02-29T00:00:00Z")!! / 86_400_000L))
assertEquals("31 December", formatPaceDate(playedAtMillis("2026-12-31T00:00:00Z")!! / 86_400_000L))
}
// ---------------------------------------------------------------------------
/** [watched] episodes on consecutive nights ending yesterday, then [remaining] unwatched. */
private fun nightly(watched: Int, remaining: Int): List<BaseItem> {
val stamps = (0 until watched).map { index ->
val day = 4 - (watched - 1 - index)
"2026-08-%02dT20:00:00Z".format(day)
}
return library(stamps, remaining)
}
private fun library(watched: List<String>, remaining: Int): List<BaseItem> =
watched.mapIndexed { index, at -> episode(1, index + 1, at) } +
(0 until remaining).map { episode(1, watched.size + it + 1, null) }
private fun episode(
season: Int,
number: Int,
playedAt: String?,
played: Boolean = playedAt != null,
) = BaseItem(
id = "s${season}e$number",
name = "Episode $number",
type = "Episode",
parentIndexNumber = season,
indexNumber = number,
userData = UserItemData(played = played, lastPlayedDate = playedAt),
)
}
@@ -0,0 +1,217 @@
package com.ponzischeme89.memby.data
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.mutablePreferencesOf
import androidx.datastore.preferences.core.stringPreferencesKey
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Test
/** Mirrors the private constant, so a schema bump that forgets these tests fails loudly. */
private const val CURRENT_SETTINGS_SCHEMA_FOR_TEST = 2
class SettingsMigrationTest {
private val json = Json { ignoreUnknownKeys = true }
@Test
fun `migration repairs profiles individually and retains malformed source`() {
val valid = EmbyProfile(
id = "https://emby::alex",
serverUrl = "https://emby",
token = "token",
userId = "alex",
username = "Alex",
)
val raw = "[${json.encodeToString(valid)},{\"id\":17}]"
val source = mutablePreferencesOf(stringPreferencesKey("profiles") to raw)
val migrated = SettingsMigrationLogic.migrateToCurrent(source)
val repaired = json.decodeFromString<List<EmbyProfile>>(
migrated[stringPreferencesKey("profiles")]!!,
)
assertEquals(listOf(valid), repaired)
assertEquals(raw, migrated[stringPreferencesKey("profiles_recovery_v0")])
assertEquals(
CURRENT_SETTINGS_SCHEMA_FOR_TEST,
migrated[intPreferencesKey("settings_schema_version")],
)
}
@Test
fun `migration recovers a complete profile from legacy flat settings`() {
val source = mutablePreferencesOf(
stringPreferencesKey("server_url") to "https://emby/",
stringPreferencesKey("token") to "token",
stringPreferencesKey("user_id") to "alex",
intPreferencesKey("for_you_minutes") to 45,
booleanPreferencesKey("has_opened_for_you") to true,
booleanPreferencesKey("show_ratings_strip") to false,
)
val migrated = SettingsMigrationLogic.migrateToCurrent(source)
val profile = json.decodeFromString<List<EmbyProfile>>(
migrated[stringPreferencesKey("profiles")]!!,
).single()
assertEquals("https://emby::alex", profile.id)
assertEquals("Memby user", profile.username)
assertEquals(45, profile.forYouMinutes)
assertTrue(profile.hasOpenedForYou)
assertFalse(profile.showRatingsStrip)
}
@Test
fun `migration accepts profiles written by a newer app version`() {
val raw = """[{"id":"server::user","serverUrl":"server","token":"token","userId":"user","username":"User","futureSetting":"kept-compatible"}]"""
val migrated = SettingsMigrationLogic.migrateToCurrent(
mutablePreferencesOf(stringPreferencesKey("profiles") to raw),
)
val profiles = json.decodeFromString<List<EmbyProfile>>(
migrated[stringPreferencesKey("profiles")]!!,
)
assertEquals("user", profiles.single().userId)
assertEquals(null, migrated[stringPreferencesKey("profiles_recovery_v0")])
}
/**
* The regression this schema step exists for. Before settings moved to the server the
* three toggles were device-wide, so an existing install holds them in the flat keys
* and in no profile at all. Nothing looks wrong until the viewer switches profile
* `applyProfile` copies profile values into the flat keys, so all three would come
* back on, and the sync would then push that up as a deliberate choice.
*/
@Test
fun `device-wide toggles are carried into every stored profile`() {
val alex = EmbyProfile(
id = "https://emby::alex", serverUrl = "https://emby",
token = "token", userId = "alex", username = "Alex",
)
val sam = EmbyProfile(
id = "https://emby::sam", serverUrl = "https://emby",
token = "token", userId = "sam", username = "Sam",
)
// Written by the previous build: schema 1, and the profiles carry no toggles.
val stored = """[${json.encodeToString(alex)},${json.encodeToString(sam)}]"""
.replace(Regex(""","showTitleLogo":[a-z]+"""), "")
val source = mutablePreferencesOf(
intPreferencesKey("settings_schema_version") to 1,
stringPreferencesKey("profiles") to stored,
booleanPreferencesKey("show_title_logo") to false,
booleanPreferencesKey("auto_play_next_episode") to false,
booleanPreferencesKey("show_ten_minute_reminder") to true,
)
val migrated = SettingsMigrationLogic.migrateToCurrent(source)
val profiles = json.decodeFromString<List<EmbyProfile>>(
migrated[stringPreferencesKey("profiles")]!!,
)
assertEquals(2, profiles.size)
profiles.forEach { profile ->
assertFalse("${profile.userId} lost the title logo choice", profile.showTitleLogo)
assertFalse("${profile.userId} lost the auto-play choice", profile.autoPlayNextEpisode)
assertTrue(profile.showTenMinuteReminder)
}
assertEquals(2, migrated[intPreferencesKey("settings_schema_version")])
}
/**
* The migration must run exactly once, and the schema version is the only thing making
* that true it cannot detect its own work, because the encoder omits default values
* and so a profile that chose `true` is byte-identical to one that never chose.
*/
@Test
fun `the toggle migration does not run a second time`() {
val profile = EmbyProfile(
id = "https://emby::alex", serverUrl = "https://emby",
token = "token", userId = "alex", username = "Alex",
)
val source = mutablePreferencesOf(
intPreferencesKey("settings_schema_version") to 1,
stringPreferencesKey("profiles") to "[${json.encodeToString(profile)}]",
booleanPreferencesKey("show_title_logo") to false,
)
val once = SettingsMigrationLogic.migrateToCurrent(source)
// The viewer turns it back on afterwards; the device-wide key is stale from here
// on, and must never be applied again.
val afterEdit = once.toMutablePreferences().apply {
this[stringPreferencesKey("profiles")] =
"[${json.encodeToString(profile.copy(showTitleLogo = true))}]"
}
val twice = SettingsMigrationLogic.migrateToCurrent(afterEdit)
assertTrue(
json.decodeFromString<List<EmbyProfile>>(
twice[stringPreferencesKey("profiles")]!!,
).single().showTitleLogo,
)
}
/** Nothing chosen means nothing to preserve, and no whole-file rewrite to pay for. */
@Test
fun `an install with default toggles is left alone`() {
val profile = EmbyProfile(
id = "https://emby::alex", serverUrl = "https://emby",
token = "token", userId = "alex", username = "Alex",
)
val stored = "[${json.encodeToString(profile)}]"
val source = mutablePreferencesOf(
intPreferencesKey("settings_schema_version") to 1,
stringPreferencesKey("profiles") to stored,
)
val migrated = SettingsMigrationLogic.migrateToCurrent(source)
assertEquals(stored, migrated[stringPreferencesKey("profiles")])
assertEquals(2, migrated[intPreferencesKey("settings_schema_version")])
}
/**
* A first-ever launch of the new build on an install that predates the profile list
* has to come out at the current schema in one pass, not stop halfway.
*/
@Test
fun `an unversioned install migrates all the way to the current schema`() {
val source = mutablePreferencesOf(
stringPreferencesKey("server_url") to "https://emby/",
stringPreferencesKey("token") to "token",
stringPreferencesKey("user_id") to "alex",
booleanPreferencesKey("auto_play_next_episode") to false,
)
val migrated = SettingsMigrationLogic.migrateToCurrent(source)
val profile = json.decodeFromString<List<EmbyProfile>>(
migrated[stringPreferencesKey("profiles")]!!,
).single()
assertEquals(CURRENT_SETTINGS_SCHEMA_FOR_TEST, migrated[intPreferencesKey("settings_schema_version")])
assertFalse(profile.autoPlayNextEpisode)
// Never synced, so the first sync pushes these values up rather than pulling
// defaults down over them.
assertEquals(0L, profile.preferencesRevision)
}
@Test
fun `migration is idempotent and does not alter a future schema`() {
val current = SettingsMigrationLogic.migrateToCurrent(mutablePreferencesOf())
assertEquals(current, SettingsMigrationLogic.migrateToCurrent(current))
val future = mutablePreferencesOf(
intPreferencesKey("settings_schema_version") to 99,
stringPreferencesKey("profiles") to "future-format",
)
val unchanged = SettingsMigrationLogic.migrateToCurrent(future)
assertNotNull(unchanged)
assertEquals("future-format", unchanged[stringPreferencesKey("profiles")])
assertEquals(99, unchanged[intPreferencesKey("settings_schema_version")])
}
}
@@ -0,0 +1,100 @@
package com.ponzischeme89.memby.data
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
/**
* The direct path's copy of the gateway's subtitle rule. It exists so the two agree the
* matching Go tests are in `server/internal/api/subtitles_test.go`, and the cases here are
* deliberately the same ones.
*/
class SubtitlePreferenceTest {
private fun track(
id: String,
language: String?,
default: Boolean = false,
forced: Boolean = false,
hearingImpaired: Boolean = false,
) = SubtitleCandidate(id, language, default, forced, hearingImpaired)
@Test
fun `folds the codes Emby writes onto one vocabulary`() {
assertEquals("it", normalizeSubtitleLanguage("ita"))
assertEquals("it", normalizeSubtitleLanguage(" ITA "))
assertEquals("fr", normalizeSubtitleLanguage("fra"))
assertEquals("fr", normalizeSubtitleLanguage("fre"))
assertEquals("pt", normalizeSubtitleLanguage("pt-BR"))
assertEquals("", normalizeSubtitleLanguage(null))
// An unrecognised code survives as itself, so an exact match still works.
assertEquals("klingon", normalizeSubtitleLanguage("Klingon"))
}
@Test
fun `subtitles turned off means nothing is selected`() {
val tracks = listOf(track("1", "eng", default = true))
assertNull(selectSubtitleId(tracks, enabled = false, language = "en"))
}
@Test
fun `a chosen language takes the full track, not the forced or SDH one`() {
val tracks = listOf(
track("1", "eng", default = true),
track("2", "ita", forced = true),
track("3", "ita", hearingImpaired = true),
track("4", "ita"),
)
assertEquals("4", selectSubtitleId(tracks, enabled = true, language = "it"))
}
@Test
fun `a language the title does not have falls back to forced only`() {
val withForced = listOf(
track("1", "eng", default = true),
track("2", "eng", forced = true),
)
assertEquals("2", selectSubtitleId(withForced, enabled = true, language = "it"))
// Nothing rather than English, which the viewer did not ask for.
val withoutForced = listOf(track("1", "eng", default = true))
assertNull(selectSubtitleId(withoutForced, enabled = true, language = "it"))
}
@Test
fun `no chosen language keeps the flag order an untouched install had`() {
val tracks = listOf(
track("1", "eng"),
track("2", "eng", default = true),
track("3", "eng", forced = true),
)
assertEquals("3", selectSubtitleId(tracks, enabled = true, SUBTITLE_LANGUAGE_AUTO))
assertEquals("2", selectSubtitleId(tracks.take(2), enabled = true, language = ""))
assertEquals("1", selectSubtitleId(tracks.take(1), enabled = true, SUBTITLE_LANGUAGE_AUTO))
assertNull(selectSubtitleId(emptyList(), enabled = true, SUBTITLE_LANGUAGE_AUTO))
}
@Test
fun `the choice rides the synced settings document`() {
val local = UserPreferences(subtitlesEnabled = false, subtitleLanguage = "it")
val encoded = local.encode()
assertEquals(false, encoded["subtitlesEnabled"].toString().toBoolean())
assertEquals(local, decodeUserPreferences(encoded))
}
@Test
fun `a gateway that omits the keys leaves this TV's choice alone`() {
// The whole point of the fallback: an older server growing the setting later must
// not reset somebody who has already chosen one.
val local = UserPreferences(subtitlesEnabled = false, subtitleLanguage = "it")
val decoded = decodeUserPreferences(buildJsonObject { put("showTitleLogo", true) }, local)
assertEquals(false, decoded.subtitlesEnabled)
assertEquals("it", decoded.subtitleLanguage)
}
}
@@ -0,0 +1,171 @@
package com.ponzischeme89.memby.data
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
private fun json(text: String): JsonObject = Json.parseToJsonElement(text) as JsonObject
class UserPreferencesTest {
/**
* The document has to survive the round trip unchanged, or two televisions could
* disagree about settings they had just agreed on and the disagreement would push
* itself back and forth forever, since a difference is what triggers a push.
*/
@Test
fun `encoding and decoding is a fixed point`() {
val original = UserPreferences(
homeSections = listOf("latest", "continue"),
homeCardDensity = "large",
homeArtworkStyle = "poster",
showHomeCardMetadata = false,
showRatingsStrip = false,
hideWatchedMovies = true,
showTitleLogo = false,
welcomeQuoteStyle = "homicidal",
autoPlayNextEpisode = false,
showTenMinuteReminder = false,
seekIntervalSeconds = 30,
forYouMinutes = 60,
homeRowOrder = listOf("recommended", "latest"),
homePinnedRows = listOf("continue"),
homeHiddenRows = listOf("studio-a24"),
)
assertEquals(original, decodeUserPreferences(original.encode()))
}
/**
* A key the server did not send must leave that setting alone. This is what lets the
* gateway grow a setting before every television in the house has the release that
* knows about it the alternative is a silent reset of anything not yet understood.
*/
@Test
fun `a missing key keeps the local value`() {
val local = UserPreferences(homeCardDensity = "compact", hideWatchedMovies = true)
val decoded = decodeUserPreferences(json("""{"showRatingsStrip":false}"""), local)
assertEquals("compact", decoded.homeCardDensity)
assertTrue(decoded.hideWatchedMovies)
assertEquals(false, decoded.showRatingsStrip)
}
/** A value of the wrong type is as good as absent, and must not become a reset. */
@Test
fun `a wrongly typed value keeps the local value`() {
val local = UserPreferences(showTitleLogo = false, forYouMinutes = 120)
val decoded = decodeUserPreferences(
json("""{"showTitleLogo":"false","forYouMinutes":"120"}"""),
local,
)
assertEquals(false, decoded.showTitleLogo)
assertEquals(120, decoded.forYouMinutes)
}
/**
* The skip interval is the one number both ends normalise. A value this build has no
* vocabulary for must become the default rather than reaching the player as a step
* size the gateway's catalogue is allowed to grow one before every TV understands it.
*/
@Test
fun `an unknown skip interval falls back to the default`() {
assertEquals(
DEFAULT_SEEK_INTERVAL_SECONDS,
decodeUserPreferences(json("""{"seekIntervalSeconds":45}""")).seekIntervalSeconds,
)
assertEquals(
30,
decodeUserPreferences(json("""{"seekIntervalSeconds":30}""")).seekIntervalSeconds,
)
assertEquals(
DEFAULT_SEEK_INTERVAL_SECONDS,
Settings(seekIntervalSeconds = 45).toUserPreferences().seekIntervalSeconds,
)
}
/** An empty row selection would be a launcher with nothing on it. */
@Test
fun `an empty section list falls back rather than emptying the launcher`() {
val local = UserPreferences(homeSections = listOf("favorites"))
val decoded = decodeUserPreferences(json("""{"homeSections":[]}"""), local)
assertEquals(listOf("favorites"), decoded.homeSections)
}
/** Row ids are stored newline-separated, so a blank entry would become a phantom row. */
@Test
fun `blank and duplicate row ids are dropped`() {
val decoded = decodeUserPreferences(
json("""{"homeRowOrder":["recommended"," ","recommended","latest",""]}"""),
)
assertEquals(listOf("recommended", "latest"), decoded.homeRowOrder)
}
/**
* The flat active-profile keys are what every screen renders from, so they are what a
* push must carry including the encoding used for the row lists.
*/
@Test
fun `settings project onto the document the server holds`() {
val settings = Settings(
homeSections = "continue,latest",
homeCardDensity = "compact",
homeArtworkStyle = "backdrop",
showHomeCardMetadata = false,
showRatingsStrip = false,
hideWatchedMovies = true,
showTitleLogo = false,
welcomeQuoteStyle = "positive",
autoPlayNextEpisode = false,
showTenMinuteReminder = false,
forYouMinutes = 30,
homeRowOrder = "recommended\nlatest",
homePinnedRows = "continue",
homeHiddenRows = "",
)
assertEquals(
UserPreferences(
homeSections = listOf("continue", "latest"),
homeCardDensity = "compact",
homeArtworkStyle = "backdrop",
showHomeCardMetadata = false,
showRatingsStrip = false,
hideWatchedMovies = true,
showTitleLogo = false,
welcomeQuoteStyle = "positive",
autoPlayNextEpisode = false,
showTenMinuteReminder = false,
forYouMinutes = 30,
homeRowOrder = listOf("recommended", "latest"),
homePinnedRows = listOf("continue"),
homeHiddenRows = emptyList(),
),
settings.toUserPreferences(),
)
}
/**
* Nothing that identifies a *television* may reach the wire. Carrying the device name
* or the update token across would rename someone's other set, or hand a private
* credential to whichever server the document came from.
*/
@Test
fun `device identity never enters the document`() {
val encoded = Settings(
deviceName = "Living room",
deviceId = "device-1",
updateToken = "gitea-secret",
updateBaseUrl = "https://git.example",
token = "session-token",
ringColorHex = "FF0000",
rotationIntervalSeconds = 45,
).toUserPreferences().encode().toString()
listOf("Living room", "device-1", "gitea-secret", "git.example", "session-token")
.forEach { assertTrue("$it reached the wire", it !in encoded) }
}
}
@@ -0,0 +1,38 @@
package com.ponzischeme89.memby.ui
import com.ponzischeme89.memby.ui.detail.formatAirDate
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class AirDateTest {
@Test
fun `an Emby premiere date reads as a day, month and year`() {
assertEquals("12 Mar 2024", formatAirDate("2024-03-12T00:00:00.0000000Z"))
assertEquals("1 Jan 2019", formatAirDate("2019-01-01T00:00:00Z"))
assertEquals("31 Dec 2026", formatAirDate("2026-12-31"))
}
/**
* Emby writes a premiere as midnight UTC. Reading the date half literally is what stops
* an episode broadcast on the 12th being listed as the 11th on every set west of
* Greenwich, so a timestamp late in the day must still yield its own date.
*/
@Test
fun `the date is taken literally, never shifted into the set's own zone`() {
assertEquals("12 Mar 2024", formatAirDate("2024-03-12T23:30:00Z"))
}
@Test
fun `anything unusable is no date rather than a wrong one`() {
assertNull(formatAirDate(null))
assertNull(formatAirDate(""))
assertNull(formatAirDate(" "))
assertNull(formatAirDate("2024"))
assertNull(formatAirDate("not a date at all"))
assertNull(formatAirDate("2024/03/12"))
assertNull(formatAirDate("2024-13-01T00:00:00Z"))
assertNull(formatAirDate("2024-03-32T00:00:00Z"))
assertNull(formatAirDate("20xx-03-12"))
}
}
@@ -0,0 +1,125 @@
package com.ponzischeme89.memby.ui
import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.ui.detail.airingNoticeFor
import com.ponzischeme89.memby.ui.detail.scheduleSeriesStub
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class AiringNoticeTest {
private fun card(
availability: String = "upcoming",
availabilityText: String = "Upcoming",
dayLabel: String = "Tomorrow",
airLabel: String = "Tomorrow: 9:00 PM",
source: String? = "sonarr",
seriesItemId: String? = "emby-42",
) = BaseItem(
id = "sonarr:7:42",
name = "Northbound",
type = "MembySonarrEpisode",
genres = listOf("Drama"),
productionYear = 2024,
membySource = source,
membyEpisodeTitle = "The Crossing",
membyEpisodeCode = "S02E04",
membyAirDayLabel = dayLabel,
membyAirLabel = airLabel,
membyAvailability = availability,
membyAvailabilityText = availabilityText,
membyPlayable = false,
membySeriesItemId = seriesItemId,
)
@Test
fun `notice names the episode and repeats the card's own wording`() {
val notice = requireNotNull(airingNoticeFor(card()))
assertEquals("AIRING TOMORROW", notice.label)
assertTrue(notice.headline.startsWith("S02E04"))
assertTrue(notice.headline.endsWith("The Crossing"))
assertTrue(notice.detail.contains("Tomorrow: 9:00 PM"))
assertTrue(notice.detail.contains("Upcoming"))
}
@Test
fun `an episode already on the server is not announced as airing`() {
val notice = requireNotNull(
airingNoticeFor(
card(
availability = "available",
availabilityText = "Added at 8:12 PM",
dayLabel = "Today",
airLabel = "Aired today at 8:00 PM",
),
),
)
assertEquals("NEW EPISODE READY", notice.label)
}
@Test
fun `an episode that has aired but not landed says so`() {
val notice = requireNotNull(
airingNoticeFor(
card(
availability = "awaiting",
availabilityText = "Awaiting download",
dayLabel = "Today",
airLabel = "Aired today at 8:00 PM",
),
),
)
assertEquals("AIRED TODAY", notice.label)
}
@Test
fun `an air time the gateway could not date is not given one`() {
val notice = requireNotNull(
airingNoticeFor(card(dayLabel = "Upcoming", airLabel = "Coming up")),
)
assertEquals("UPCOMING EPISODE", notice.label)
}
@Test
fun `only schedule cards carry a notice`() {
assertNull(airingNoticeFor(card(source = null)))
assertNull(airingNoticeFor(card(source = "radarr")))
assertNull(airingNoticeFor(BaseItem(id = "series", name = "Northbound", type = "Series")))
}
@Test
fun `a card with nothing to say produces no notice`() {
val silent = BaseItem(
id = "sonarr:7:42",
name = "Northbound",
type = "MembySonarrEpisode",
membySource = "sonarr",
)
assertNull(airingNoticeFor(silent))
}
@Test
fun `the stub opens the show, not the episode that was pressed`() {
val stub = requireNotNull(scheduleSeriesStub(card()))
assertEquals("emby-42", stub.id)
assertEquals("Northbound", stub.name)
assertTrue(stub.isSeries)
assertEquals(listOf("Drama"), stub.genres)
// The card's overview is the *episode's*; the page it opens is about the show.
assertNull(stub.overview)
}
@Test
fun `a show Emby has never imported opens nothing`() {
assertNull(scheduleSeriesStub(card(seriesItemId = null)))
assertNull(scheduleSeriesStub(card(seriesItemId = " ")))
assertNull(scheduleSeriesStub(BaseItem(id = "movie", name = "Film", type = "Movie")))
}
}
@@ -3,12 +3,49 @@ package com.ponzischeme89.memby.ui
import com.ponzischeme89.memby.data.HomeSnapshot
import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.data.model.HomeRow
import com.ponzischeme89.memby.data.model.MyShow
import org.junit.Assert.assertFalse
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class AiringTodayTagsTest {
@Test
fun `continue watching promotes episodes from shows airing today`() {
val ended = BaseItem(
id = "ended", name = "Old episode", type = "Episode", seriesName = "Ended Rewatch",
)
val pausedMovie = BaseItem(id = "movie", name = "Paused Movie", type = "Movie")
val airing = BaseItem(
id = "airing", name = "New episode", type = "Episode", seriesName = "The Bear",
)
val home = HomeSnapshot(
continueWatching = listOf(ended, pausedMovie, airing),
rows = listOf(
HomeRow(
id = "continue", title = "Continue Watching",
items = listOf(ended, pausedMovie, airing),
),
HomeRow(
id = "sonarr-airing-today", title = "Shows airing today", kind = "schedule",
items = listOf(
BaseItem(
id = "sonarr:7:42", name = "The Bear", type = "MembySonarrEpisode",
membySource = "sonarr", membyAirDayLabel = "Today",
),
),
),
),
)
val ranked = home.withAiringTodayTags()
assertEquals(listOf("airing", "ended", "movie"), ranked.continueWatching.map { it.id })
assertEquals(listOf("airing", "ended", "movie"), ranked.rows.first().items.map { it.id })
assertTrue(ranked.continueWatching.first().membyAiringToday)
assertFalse(ranked.continueWatching[1].membyAiringToday)
}
@Test
fun `tags matching recommended series from today's TV schedule`() {
val home = HomeSnapshot(
@@ -174,8 +211,55 @@ class AiringTodayTagsTest {
}
@Test
fun `upcoming schedule status does not claim the show airs today`() {
assertEquals("UPCOMING", scheduleStatusBadgeLabel("upcoming"))
assertEquals("UPCOMING", scheduleStatusBadgeLabel(""))
fun `upcoming schedule status leaves the air day unobstructed`() {
assertEquals(null, scheduleStatusBadgeLabel("upcoming"))
assertEquals(null, scheduleStatusBadgeLabel(""))
assertEquals("DOWNLOADING", scheduleStatusBadgeLabel("downloading"))
}
@Test
fun `a schedule card wears the lifecycle the gateway worded`() {
fun card(lifecycle: String?, text: String?) = BaseItem(
id = "sonarr:1:2", name = "Show", type = "MembySonarrEpisode",
membySource = "sonarr", membyLifecycle = lifecycle, membyLifecycleText = text,
)
assertEquals("CONTINUING", lifecycleBadgeLabel(card("continuing", "CONTINUING")))
assertEquals("IN CINEMAS", lifecycleBadgeLabel(card("incinemas", "IN CINEMAS")))
// A status this build predates still reads correctly, because the wording is the
// server's rather than derived from the slug.
assertEquals("PILOT ORDERED", lifecycleBadgeLabel(card("pilot", "Pilot ordered")))
// An older gateway, or a show *arr has no status for: no tag rather than a blank one.
assertEquals(null, lifecycleBadgeLabel(card(null, null)))
assertEquals(null, lifecycleBadgeLabel(card("continuing", " ")))
}
@Test
fun `a cancelled show outranks everything else on its My Shows card`() {
val cancelled = MyShow(
itemId = "1", title = "Ended Show", lifecycle = "Cancelled",
sonarrStatus = "Monitored", nextEpisode = "2026-08-09T20:00:00Z",
)
// Red, and the same word Sonarr uses, is worth more than a next-episode date on a
// show that will not have one.
assertEquals("ended" to "CANCELLED", myShowBadge(cancelled))
}
@Test
fun `a continuing show says so`() {
val continuing = MyShow(
itemId = "1", title = "Severance", lifecycle = "Continuing", sonarrStatus = "Monitored",
)
assertEquals("continuing" to "CONTINUING", myShowBadge(continuing))
val dated = continuing.copy(nextEpisode = "2026-08-09T20:00:00Z")
assertEquals("upcoming" to "UPCOMING", myShowBadge(dated))
val unmonitored = continuing.copy(sonarrStatus = "Not monitored")
assertEquals("unmonitored" to "UNMONITORED", myShowBadge(unmonitored))
val unknown = continuing.copy(lifecycle = "Unknown", sonarrStatus = "Not found")
assertEquals(null, myShowBadge(unknown))
}
}
@@ -22,6 +22,7 @@ import com.ponzischeme89.memby.data.model.EmbyPerson
import com.ponzischeme89.memby.data.model.MediaStream
import com.ponzischeme89.memby.data.model.Studio
import com.ponzischeme89.memby.data.model.UserItemData
import com.ponzischeme89.memby.ui.detail.AiringNotice
import com.ponzischeme89.memby.ui.detail.creditRows
import com.ponzischeme89.memby.ui.detail.technicalSpecs
import org.junit.Before
@@ -33,7 +34,7 @@ import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
/**
* Renders the movie and series detail pages to PNGs under `build/screenshots/`.
* Renders the movie and series detail pages to PNGs under `build/screenshots/detail-page/`.
*
* ```powershell
* .\gradlew.bat :app:testDebugUnitTest --tests "*DetailPageScreenshotTest"
@@ -108,6 +109,32 @@ class DetailPageScreenshotTest {
}
}
/**
* Opened from the "Shows airing in the next 5 days" row. The band takes the accent line
* the recommendation reason holds on every other route into this page compare against
* `df_detail-series`, which is the same show reached any other way.
*/
@Test
fun `series page opened from the airing row`() {
capture("df_detail-series-airing") {
SeriesDetailContent(
item = series,
episodes = episodes,
loadFailed = false,
onPlay = {},
onToggleFavorite = { _, _ -> },
isMyShow = false,
onToggleMyShow = { _, _ -> },
related = related,
airingNotice = AiringNotice(
label = "AIRING TOMORROW",
headline = "S02E04 • The Crossing",
detail = "Tomorrow: 9:00 PM • Awaiting download",
),
)
}
}
/** The first frame, before the episode request lands. The header must already be whole. */
@Test
fun `series page loading`() {
@@ -215,14 +242,14 @@ class DetailPageScreenshotTest {
) { pane() }
}
}
compose.onRoot().captureRoboImage("build/screenshots/$name.png")
compose.onRoot().captureRoboImage("build/screenshots/detail-page/$name.png")
}
private fun capture(name: String, content: @Composable () -> Unit) {
compose.setContent {
PreviewSurface(alignment = Alignment.TopStart) { content() }
}
compose.onRoot().captureRoboImage("build/screenshots/$name.png")
compose.onRoot().captureRoboImage("build/screenshots/detail-page/$name.png")
}
private fun captureTab(name: String, tab: String, content: @Composable () -> Unit) {
@@ -230,7 +257,7 @@ class DetailPageScreenshotTest {
PreviewSurface(alignment = Alignment.TopStart) { content() }
}
compose.onNodeWithText(tab).performClick()
compose.onRoot().captureRoboImage("build/screenshots/$name.png")
compose.onRoot().captureRoboImage("build/screenshots/detail-page/$name.png")
}
/**
@@ -0,0 +1,99 @@
package com.ponzischeme89.memby.ui
import android.graphics.BitmapFactory
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onRoot
import com.github.takahirom.roborazzi.captureRoboImage
import com.ponzischeme89.memby.data.EmbyOutage
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
/**
* Renders the Emby outage bar to PNGs under `build/screenshots/emby-outage-banner/`.
*
* ```powershell
* .\gradlew.bat :app:testDebugUnitTest --tests "*EmbyOutageBannerScreenshotTest"
* ```
*
* Drawn over the same playback still the alert bar uses, because that is the situation the
* red bar is really for: the film has stalled and this is the only thing on screen that
* explains why. Seeing the two against the same frame is also how their two reds and their
* shared rule-and-fade stay a deliberate pair rather than a coincidence.
*
* [OutageBanner] is rendered rather than [EmbyOutageBanner]: the wrapper's job is the
* slide-in, and the clock is passed in so the countdown is a chosen value instead of
* whatever the test machine's uptime happened to be.
*/
@RunWith(RobolectricTestRunner::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(sdk = [34], qualifiers = "w960dp-h540dp-television-xhdpi")
class EmbyOutageBannerScreenshotTest {
@get:Rule
val compose = createComposeRule()
/** The state the bar spends nearly all of an outage in. */
@Test
fun `waiting for the next attempt`() {
capture("outage-banner-waiting", nextAttemptInMillis = 42_000L)
}
/** Freshly armed: the longest number the countdown ever shows. */
@Test
fun `full interval remaining`() {
capture("outage-banner-full-interval", nextAttemptInMillis = 60_000L)
}
/** The moment the attempt is due, which is a different string entirely. */
@Test
fun `retrying now`() {
capture("outage-banner-retrying", nextAttemptInMillis = 0L)
}
/** Single digits, where the label is at its narrowest and the layout can shift. */
@Test
fun `last seconds`() {
capture("outage-banner-last-seconds", nextAttemptInMillis = 1_000L)
}
private fun capture(name: String, nextAttemptInMillis: Long) {
compose.setContent {
OutageBannerOnPlaybackStill(
EmbyOutage(
nextAttemptAtMillis = nextAttemptInMillis,
retryIntervalSeconds = 60,
),
)
}
compose.onRoot().captureRoboImage("build/screenshots/emby-outage-banner/$name.png")
}
@Composable
private fun OutageBannerOnPlaybackStill(outage: EmbyOutage) {
val playbackStill = requireNotNull(
javaClass.getResourceAsStream("/playback_alert_preview_still.png"),
).use(BitmapFactory::decodeStream).asImageBitmap()
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.TopCenter) {
Image(
bitmap = playbackStill,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize(),
)
// A fixed clock, so each capture shows the countdown value it is named for.
OutageBanner(outage, nowMillis = { 0L })
}
}
}
@@ -0,0 +1,256 @@
package com.ponzischeme89.memby.ui
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.unit.dp
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onRoot
import androidx.compose.ui.test.performClick
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.EmbyPerson
import com.ponzischeme89.memby.data.model.MediaStream
import com.ponzischeme89.memby.data.model.Studio
import com.ponzischeme89.memby.data.model.UserItemData
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
/**
* Renders an episode's own detail page to PNGs under `build/screenshots/episode-detail/`.
*
* ```powershell
* .\gradlew.bat :app:testDebugUnitTest --tests "*EpisodeDetailScreenshotTest"
* ```
*
* The viewer here is part-way through season 3 of a four-season show, which is the state
* the season scroller exists for: seasons 1 and 2 dimmed and ticked, 3 selected and
* flagged, 4 still ahead. As in [DetailPageScreenshotTest] there is no network, so every
* artwork URL resolves to null and the page renders on its own scrims the worst case and
* the one worth looking at.
*/
@RunWith(RobolectricTestRunner::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(sdk = [34], qualifiers = "w960dp-h540dp-television-xhdpi")
class EpisodeDetailScreenshotTest {
@get:Rule
val compose = createComposeRule()
@Before
fun locator() {
ServiceLocator.init(ApplicationProvider.getApplicationContext())
}
/** The opening frame: series logo, season and episode number, episode title. */
@Test
fun `episode page`() {
capture("df_episode-detail") { EpisodePage() }
}
/** Part-watched, which is how an episode reaches this page from Continue Watching. */
@Test
fun `episode page resuming`() {
capture("df_episode-detail-resuming") {
EpisodePage(item = current.copy(userData = UserItemData(playbackPositionTicks = 21L * TICKS_PER_MINUTE)))
}
}
/** The first frame, before the series' episode list lands. The hero must be whole. */
@Test
fun `episode page loading`() {
capture("df_episode-detail-loading") { EpisodePage(episodes = null) }
}
/** Season 1: behind the viewer, so its stop is ticked and its episodes all watched. */
@Test
fun `episode page browsing an earlier season`() {
captureSeason("df_episode-detail-earlier-season", season = 1) { EpisodePage() }
}
/** Season 4: still ahead, and nothing in it is dimmed. */
@Test
fun `episode page browsing a later season`() {
captureSeason("df_episode-detail-later-season", season = 4) { EpisodePage() }
}
/** Specials, which sort first and are the one stop that is not "Season n". */
@Test
fun `episode page browsing specials`() {
captureSeason("df_episode-detail-specials", season = 0) {
EpisodePage(episodes = library + episode(0, 1, "The Making of Signal Hill"))
}
}
/**
* The pane at the geometry the scaffold gives it the band the D-pad reaches by
* pressing Down twice, and the only place the episode this page is about is flagged.
*/
@Test
fun `episode list pane at its real slot geometry`() {
capturePane("df_episode-detail-pane") {
EpisodeSeasonPane(
episodes = library,
seasonEpisodes = library.filter { it.parentIndexNumber == 3 },
currentEpisodeId = current.id,
loadFailed = false,
firstEpisodeFocusRequester = FocusRequester(),
emptyFocusRequester = FocusRequester(),
aboveEpisodes = FocusRequester.Default,
// Where the page opens the list: on the episode it is about, not at the
// top of the season.
listState = LazyListState(firstVisibleItemIndex = 3),
onPlay = {},
)
}
}
/** A season the household is done with: every row ticked, none of them flagged. */
@Test
fun `finished season pane`() {
capturePane("df_episode-detail-pane-watched") {
EpisodeSeasonPane(
episodes = library,
seasonEpisodes = library.filter { it.parentIndexNumber == 1 },
currentEpisodeId = current.id,
loadFailed = false,
firstEpisodeFocusRequester = FocusRequester(),
emptyFocusRequester = FocusRequester(),
aboveEpisodes = FocusRequester.Default,
listState = LazyListState(),
onPlay = {},
)
}
}
@Composable
private fun EpisodePage(
item: BaseItem = current,
episodes: List<BaseItem>? = library,
) {
EpisodeDetailContent(
item = item,
episodes = episodes,
loadFailed = false,
onPlay = {},
onToggleFavorite = { _, _ -> },
onTogglePlayed = { _, _ -> },
)
}
private fun capturePane(name: String, pane: @Composable () -> Unit) {
compose.setContent {
PreviewSurface(alignment = Alignment.TopStart) {
Box(
Modifier
.fillMaxWidth()
.padding(horizontal = DetailSideGutter, vertical = 16.dp)
.height(detailPaneHeight(540.dp)),
) { pane() }
}
}
compose.onRoot().captureRoboImage("$DIR/$name.png")
}
private fun capture(name: String, content: @Composable () -> Unit) {
compose.setContent { PreviewSurface(alignment = Alignment.TopStart) { content() } }
compose.onRoot().captureRoboImage("$DIR/$name.png")
}
private fun captureSeason(name: String, season: Int, content: @Composable () -> Unit) {
compose.setContent { PreviewSurface(alignment = Alignment.TopStart) { content() } }
compose.onNodeWithTag("season-stop-$season").performClick()
compose.onRoot().captureRoboImage("$DIR/$name.png")
}
private val cast = listOf(
person("Aria Vance", "Detective Iris Kell"),
person("Marcus Oyelaran", "Samuel Reed"),
person("Nina Kowalczyk", "Dr. Halvorsen"),
)
private val streams = listOf(
MediaStream(type = "Video", codec = "hevc", width = 3840, height = 2160, videoRange = "HDR", videoRangeType = "HDR10"),
MediaStream(type = "Audio", codec = "eac3", channels = 6, language = "eng"),
MediaStream(type = "Subtitle", codec = "subrip", language = "eng"),
)
private val current = episode(
season = 3,
number = 4,
title = "The Long Count",
overview = "The night shift picks up a voice reading tomorrow's shipping forecast, " +
"and the log book from 1974 says the same thing happened before. Iris takes the " +
"tape to the only person who was on the hill that winter.",
).copy(
productionYear = 2024,
officialRating = "TV-MA",
communityRating = 8.7,
genres = listOf("Thriller", "Drama"),
studios = listOf(Studio(name = "Harbour Line")),
mediaStreams = streams,
people = cast,
)
private val seasonOneTitles = listOf(
"Carrier Wave", "Dead Air", "Nightingale", "Six Weeks Out", "The Hill", "Landfall",
)
private val seasonThreeTitles = listOf(
"Shortwave", "Ground Truth", "The Operator", "The Long Count",
"Blackout", "Tape Nine", "What the Log Says", "Signal Hill",
)
private val library = buildList {
(1..6).forEach { add(episode(1, it, seasonOneTitles[it - 1], played = true)) }
(1..8).forEach { add(episode(2, it, "Season Two, Part $it", played = it <= 6)) }
(1..8).forEach { add(episode(3, it, seasonThreeTitles[it - 1], played = it < 4)) }
(1..6).forEach { add(episode(4, it, "Season Four, Part $it")) }
}
private fun episode(
season: Int,
number: Int,
title: String,
played: Boolean = false,
overview: String = "A coastal radio station keeps receiving a broadcast that has " +
"not been transmitted yet, and the night operator has started writing it down.",
) = BaseItem(
id = "s${season}e$number",
name = title,
type = "Episode",
seriesId = "series-1",
seriesName = "Signal Hill",
parentIndexNumber = season,
indexNumber = number,
runTimeTicks = 48L * TICKS_PER_MINUTE,
overview = overview,
userData = UserItemData(played = played),
)
private fun person(name: String, role: String) = EmbyPerson(
id = name.filter(Char::isLetter),
name = name,
role = role,
type = "Actor",
)
private companion object {
const val DIR = "build/screenshots/episode-detail"
const val TICKS_PER_MINUTE = 600_000_000L
}
}
@@ -0,0 +1,159 @@
package com.ponzischeme89.memby.ui
import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.data.model.UserItemData
import com.ponzischeme89.memby.ui.detail.SeasonProgress
import com.ponzischeme89.memby.ui.detail.episodeAfter
import com.ponzischeme89.memby.ui.detail.episodeEyebrow
import com.ponzischeme89.memby.ui.detail.seasonMarkers
import com.ponzischeme89.memby.ui.detail.seasonProgressLabel
import com.ponzischeme89.memby.ui.detail.seriesProgressLabel
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
/**
* What an episode's own detail page says, in plain JUnit.
*
* The season scroller's whole job is to be right about where the household is up to, so the
* marking rules are pinned here rather than left to a screenshot to notice.
*/
class EpisodeDetailTest {
@Test
fun `eyebrow spells the season and episode out`() {
assertEquals("SEASON 3 · EPISODE 4", episodeEyebrow(episode(3, 4)))
}
@Test
fun `eyebrow names specials rather than season zero`() {
assertEquals("SPECIALS · EPISODE 1", episodeEyebrow(episode(0, 1)))
}
@Test
fun `an unnumbered episode has no eyebrow to show`() {
assertNull(episodeEyebrow(BaseItem(id = "x", name = "Pilot", type = "Episode")))
}
@Test
fun `seasons behind the one being watched are marked watched`() {
val markers = seasonMarkers(library, currentSeason = 3)
assertEquals(
listOf(SeasonProgress.WATCHED, SeasonProgress.WATCHED, SeasonProgress.CURRENT, SeasonProgress.UPCOMING),
markers.map { it.progress },
)
}
/** Skipping an episode of season one does not put the viewer back in season one. */
@Test
fun `an earlier season with unwatched episodes is still behind the viewer`() {
val markers = seasonMarkers(library, currentSeason = 3)
val secondSeason = markers.single { it.season == 2 }
assertEquals(SeasonProgress.WATCHED, secondSeason.progress)
assertEquals(1, secondSeason.watchedEpisodes)
assertEquals(2, secondSeason.totalEpisodes)
}
/** A season ahead that has been sampled must not read as finished. */
@Test
fun `later seasons stay upcoming`() {
val markers = seasonMarkers(library, currentSeason = 1)
assertEquals(SeasonProgress.UPCOMING, markers.single { it.season == 4 }.progress)
}
@Test
fun `a fully watched season is marked watched without a current season`() {
val markers = seasonMarkers(library, currentSeason = null)
assertEquals(SeasonProgress.WATCHED, markers.single { it.season == 1 }.progress)
assertEquals(SeasonProgress.UPCOMING, markers.single { it.season == 2 }.progress)
}
@Test
fun `season progress reads as a count`() {
val markers = seasonMarkers(library, currentSeason = 3)
assertEquals("All 2 watched", seasonProgressLabel(markers.single { it.season == 1 }))
assertEquals("1 of 2 watched", seasonProgressLabel(markers.single { it.season == 2 }))
assertEquals("2 episodes", seasonProgressLabel(markers.single { it.season == 4 }))
}
@Test
fun `the series line places the season and counts what is left`() {
val markers = seasonMarkers(library, currentSeason = 3)
assertEquals("Season 3 of 4 • 4 episodes left", seriesProgressLabel(markers, 3))
}
@Test
fun `a finished show reports nothing left`() {
val watched = library.map { it.copy(userData = UserItemData(played = true)) }
val markers = seasonMarkers(watched, currentSeason = 4)
assertEquals("Season 4 of 4", seriesProgressLabel(markers, 4))
}
/** A special nobody has watched must not be ticked just for sorting first. */
@Test
fun `specials are not watched merely by being before the current season`() {
val withSpecials = library + episode(0, 1)
val markers = seasonMarkers(withSpecials, currentSeason = 3)
assertEquals(SeasonProgress.UPCOMING, markers.single { it.season == 0 }.progress)
assertEquals("1 episode", seasonProgressLabel(markers.single { it.season == 0 }))
}
@Test
fun `a watched special is still marked watched`() {
val markers = seasonMarkers(library + episode(0, 1, played = true), currentSeason = 3)
assertEquals(SeasonProgress.WATCHED, markers.single { it.season == 0 }.progress)
assertEquals("Watched", seasonProgressLabel(markers.single { it.season == 0 }))
}
/** Specials are not a season, and counting them displaces every numbered one. */
@Test
fun `the series line counts numbered seasons only`() {
val markers = seasonMarkers(library + episode(0, 1), currentSeason = 3)
assertEquals("Season 3 of 4 • 5 episodes left", seriesProgressLabel(markers, 3))
}
@Test
fun `no seasons means no progress line`() {
assertNull(seriesProgressLabel(emptyList(), 1))
}
/** Position decides what is next, so a season boundary is not a stopping point. */
@Test
fun `the next episode crosses into the following season`() {
val last = library.single { it.parentIndexNumber == 1 && it.indexNumber == 2 }
assertEquals("s2e1", episodeAfter(library, last)?.id)
}
@Test
fun `the last episode of the show has nothing after it`() {
val last = library.single { it.parentIndexNumber == 4 && it.indexNumber == 2 }
assertNull(episodeAfter(library, last))
}
@Test
fun `an episode the library does not hold resolves to nothing`() {
assertNull(episodeAfter(library, episode(9, 9)))
}
private val library = listOf(
episode(1, 1, played = true),
episode(1, 2, played = true),
episode(2, 1, played = true),
episode(2, 2),
episode(3, 1, played = true),
episode(3, 2),
episode(4, 1),
episode(4, 2),
)
private fun episode(season: Int, number: Int, played: Boolean = false) = BaseItem(
id = "s${season}e$number",
name = "Episode $number",
type = "Episode",
seriesId = "series-1",
seriesName = "Signal Hill",
parentIndexNumber = season,
indexNumber = number,
userData = UserItemData(played = played),
)
}
@@ -151,7 +151,7 @@ class HomeMovieHeroScreenshotTest {
}
compose.onNodeWithText("Play").fetchSemanticsNode()
compose.onRoot().captureRoboImage("build/screenshots/$name.png")
compose.onRoot().captureRoboImage("build/screenshots/home-movie-hero/$name.png")
}
private val movies = listOf(
@@ -1,6 +1,8 @@
package com.ponzischeme89.memby.ui
import androidx.compose.ui.unit.dp
import com.ponzischeme89.memby.data.localEpochDay
import com.ponzischeme89.memby.data.millisUntilNextLocalDay
import com.ponzischeme89.memby.data.model.BaseItem
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
@@ -8,11 +10,10 @@ import org.junit.Test
class HomeMovieHeroTest {
@Test
fun `home hero gives way to focused row metadata`() {
assertTrue(shouldShowHomeMovieHero(hasMovies = true, focusedRowId = null))
assertTrue(shouldShowHomeMovieHero(hasMovies = true, focusedRowId = HOME_HERO_ROW_ID))
assertEquals(false, shouldShowHomeMovieHero(hasMovies = true, focusedRowId = "continue"))
assertEquals(false, shouldShowHomeMovieHero(hasMovies = false, focusedRowId = null))
fun `home hero follows the vertical scroll position`() {
assertTrue(shouldShowHomeMovieHero(hasMovies = true, listAtTop = true))
assertEquals(false, shouldShowHomeMovieHero(hasMovies = true, listAtTop = false))
assertEquals(false, shouldShowHomeMovieHero(hasMovies = false, listAtTop = true))
}
@Test
@@ -65,6 +66,120 @@ class HomeMovieHeroTest {
assertEquals(4, selectHomeHeroMovies(rows).map { it.item.id }.distinct().size)
}
// --- Daily variants ----------------------------------------------------------
@Test
fun `each day leads with a different new release`() {
val rows = listOf(
row("latest-movies", "Recently Added Movies", "new-1", "new-2", "new-3"),
row("popular", "Popular Movies", "popular-1", "popular-2"),
)
assertEquals("new-1", selectHomeHeroMovies(rows, day = 0).first().item.id)
assertEquals("new-2", selectHomeHeroMovies(rows, day = 1).first().item.id)
assertEquals("new-3", selectHomeHeroMovies(rows, day = 2).first().item.id)
}
/** The pool is finite, so it has to come round rather than run out. */
@Test
fun `the rotation wraps once the pool is exhausted`() {
val rows = listOf(row("latest-movies", "Recently Added", "new-1", "new-2", "new-3"))
assertEquals(
selectHomeHeroMovies(rows, day = 0).map { it.item.id },
selectHomeHeroMovies(rows, day = 3).map { it.item.id },
)
}
/**
* The launcher rebuilds constantly every home refresh, every focus change. Asking
* twice within a day has to give the same four cards or the hero would churn under a
* viewer who was only walking past.
*/
@Test
fun `the same day always picks the same cards`() {
val rows = listOf(
row("latest-movies", "Recently Added Movies", "new-1", "new-2", "new-3"),
row("popular", "Popular Movies", "popular-1", "popular-2"),
)
assertEquals(
selectHomeHeroMovies(rows, day = 19_000).map { it.item.id },
selectHomeHeroMovies(rows, day = 19_000).map { it.item.id },
)
}
/** A clock that has not been set yet reports an instant before the epoch. */
@Test
fun `a negative day still fills the hero`() {
val rows = listOf(row("latest-movies", "Recently Added", "new-1", "new-2", "new-3"))
assertEquals(3, selectHomeHeroMovies(rows, day = -1).size)
assertEquals("new-3", selectHomeHeroMovies(rows, day = -1).first().item.id)
}
@Test
fun `rotation never drops or duplicates a title`() {
val rows = listOf(
row("latest-movies", "Recently Added Movies", "new-1", "new-2", "new-3"),
row("popular", "Popular Movies", "popular-1", "popular-2"),
)
(0L..7L).forEach { day ->
val picked = selectHomeHeroMovies(rows, day).map { it.item.id }
assertEquals("day $day", picked.size, picked.distinct().size)
}
}
// --- Midnight ----------------------------------------------------------------
/** Local, not UTC: the day must turn over at the viewer's midnight. */
@Test
fun `the day is counted in the viewer's own time zone`() {
val newYearUtc = 1_735_689_600_000L // 2025-01-01T00:00:00Z
// An hour ahead: already the 1st. An hour behind: still New Year's Eve.
assertEquals(
localEpochDay(newYearUtc, HOUR_MS.toInt()) - 1,
localEpochDay(newYearUtc, -HOUR_MS.toInt()),
)
}
@Test
fun `midnight is a whole day away from itself, never zero`() {
val midnightUtc = 1_735_689_600_000L
assertEquals(DAY_MS, millisUntilNextLocalDay(midnightUtc, 0))
}
@Test
fun `the wait shortens as the evening goes on`() {
val midnightUtc = 1_735_689_600_000L
assertEquals(DAY_MS - HOUR_MS, millisUntilNextLocalDay(midnightUtc + HOUR_MS, 0))
assertEquals(HOUR_MS, millisUntilNextLocalDay(midnightUtc + 23 * HOUR_MS, 0))
}
/** Waiting the reported time must always land on the following day, in any zone. */
@Test
fun `waiting the reported time advances the day exactly once`() {
val offsets = listOf(0, HOUR_MS.toInt(), -5 * HOUR_MS.toInt(), 13 * HOUR_MS.toInt())
val instants = listOf(0L, 1_735_689_600_000L, 1_735_689_600_000L + 37L, -86_400_001L)
offsets.forEach { offset ->
instants.forEach { now ->
val before = localEpochDay(now, offset)
val after = localEpochDay(now + millisUntilNextLocalDay(now, offset), offset)
assertEquals("offset=$offset now=$now", before + 1, after)
}
}
}
private companion object {
const val HOUR_MS = 60L * 60L * 1000L
const val DAY_MS = 24L * HOUR_MS
}
private fun row(id: String, title: String, vararg ids: String) = HomeBrowseRow(
id = id,
title = title,
@@ -8,20 +8,6 @@ import org.junit.Assert.assertTrue
import org.junit.Test
class HomeUiStateTest {
@Test
fun combinedWatchingRowPreservesOrderAndRemovesDuplicates() {
val resumable = BaseItem(id = "resume", name = "Resume")
val duplicate = BaseItem(id = "same", name = "Resume copy")
val next = BaseItem(id = "next", name = "Next")
val state = HomeUiState(
continueWatching = listOf(resumable, duplicate),
nextUp = listOf(duplicate.copy(name = "Next copy"), next),
)
assertEquals(listOf("resume", "same", "next"), state.watchingAndNextUp.map { it.id })
}
@Test
fun cachedContentIsShownWhileOnlyMissingRowsLoad() {
val cache = HomeCache(
@@ -32,8 +18,27 @@ class HomeUiStateTest {
val state = HomeUiState.from(cache)
assertFalse(HomeSection.CONTINUE in state.loading)
assertTrue(HomeSection.NEXT_UP in state.loading)
assertFalse(HomeSection.FAVORITES in state.loading)
assertTrue(HomeSection.LATEST in state.loading)
}
/**
* A cache written by the build that still had two rows must not lose its Next Up
* episodes on the first launch after the update that cache is what the launcher
* draws before any network response arrives.
*/
@Test
fun cachedNextUpItemsFoldIntoContinueWatching() {
val cache = HomeCache(
continueWatching = listOf(BaseItem(id = "resume"), BaseItem(id = "same")),
nextUp = listOf(BaseItem(id = "same", name = "Next copy"), BaseItem(id = "next")),
)
val state = HomeUiState.from(cache)
assertEquals(
listOf("resume", "same", "next"),
state.continueWatching.map(BaseItem::id),
)
}
}
@@ -0,0 +1,132 @@
package com.ponzischeme89.memby.ui
import androidx.compose.runtime.Composable
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onRoot
import com.github.takahirom.roborazzi.captureRoboImage
import com.ponzischeme89.memby.ui.setup.SignInContent
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
/**
* Every screen a new television shows before it reaches the launcher, to
* `build/screenshots/onboarding/`.
*
* ```powershell
* .\gradlew.bat :app:testDebugUnitTest --tests "*OnboardingScreenshotTest"
* ```
*
* This sequence is the one nobody on the team sees twice it happens once per TV, usually
* while somebody else is setting it up in another room and it is the sequence that decides
* whether that TV can ever update itself. Being able to look at it without reinstalling the
* app on hardware is the whole point.
*
* The composables here take their state as parameters, which is what allows this: none of
* them reaches for `ServiceLocator`, so there is no repository, no gateway and no session
* behind any of these frames.
*/
@RunWith(RobolectricTestRunner::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(sdk = [34], qualifiers = "w960dp-h540dp-television-xhdpi")
class OnboardingScreenshotTest {
@get:Rule
val compose = createComposeRule()
@Test
fun `first run`() {
capture("01-first-run") { FirstRunScreen(onGetStarted = {}) }
}
/** The permission step as almost everyone meets it: a TV that has the settings screen. */
@Test
fun `install permission`() {
capture("02-install-permission") {
InstallPermissionContent(
noScreenAvailable = false,
onOpenSettings = {},
onSkip = {},
)
}
}
/**
* The same step on a television with no per-app permission screen. This is the state
* that used to be a dead end the button did nothing and said nothing so it is worth
* being able to see that the instructions now stand on their own.
*/
@Test
fun `install permission with no settings screen`() {
capture("03-install-permission-manual") {
InstallPermissionContent(
noScreenAvailable = true,
onOpenSettings = {},
onSkip = {},
)
}
}
@Test
fun `sign in`() {
capture("04-sign-in") { SignIn(username = "", password = "") }
}
@Test
fun `sign in with credentials typed`() {
capture("05-sign-in-filled") { SignIn(username = "matt", password = "hunter2") }
}
/** The failure everyone hits at least once: it must read as fixable, not as broken. */
@Test
fun `sign in rejected`() {
capture("06-sign-in-error") {
SignIn(
username = "matt",
password = "wrong",
error = "Memby couldn't sign in. Check the username and password and try again.",
)
}
}
@Test
fun `sign in while connecting`() {
capture("07-sign-in-connecting") {
SignIn(username = "matt", password = "hunter2", connecting = true)
}
}
/** The same form reached from Settings, which is the only variant with a Back button. */
@Test
fun `adding another viewer`() {
capture("08-add-viewer") { SignIn(username = "", password = "", onBack = {}) }
}
@Composable
private fun SignIn(
username: String,
password: String,
connecting: Boolean = false,
error: String? = null,
onBack: (() -> Unit)? = null,
) {
SignInContent(
username = username,
password = password,
onUsernameChange = {},
onPasswordChange = {},
onSubmit = {},
connecting = connecting,
error = error,
onBack = onBack,
)
}
private fun capture(name: String, content: @Composable () -> Unit) {
compose.setContent(content)
compose.onRoot().captureRoboImage("build/screenshots/onboarding/$name.png")
}
}
@@ -0,0 +1,76 @@
package com.ponzischeme89.memby.ui
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.Test
class PrimaryActionLabelTest {
@Test
fun `watched movie can be rewatched`() {
val movie = BaseItem(
id = "movie",
name = "Movie",
type = "Movie",
userData = UserItemData(played = true),
)
assertEquals("Rewatch", primaryActionLabel(movie))
}
@Test
fun `part watched movie still resumes`() {
val movie = BaseItem(
id = "movie",
name = "Movie",
type = "Movie",
userData = UserItemData(played = true, playbackPositionTicks = 1),
)
assertEquals("Resume", primaryActionLabel(movie))
}
@Test
fun `unwatched movie still plays`() {
val movie = BaseItem(id = "movie", name = "Movie", type = "Movie")
assertEquals("Play", primaryActionLabel(movie))
}
/** The episode page is reached from Continue Watching; the button confirms which one. */
@Test
fun `an episode names itself on the button`() {
assertEquals("Play S04E04", primaryActionLabel(episode(4, 4)))
}
@Test
fun `a part watched episode resumes by name`() {
val resuming = episode(4, 4).copy(userData = UserItemData(playbackPositionTicks = 1))
assertEquals("Resume S04E04", primaryActionLabel(resuming))
}
/** A series' button names its next episode in the same form the episode page uses. */
@Test
fun `a series names its next episode`() {
val series = BaseItem(id = "series", name = "Series", type = "Series")
assertEquals("Play S01E02", primaryActionLabel(series, episode(1, 2)))
}
@Test
fun `an unnumbered episode falls back to a bare verb`() {
val unnumbered = BaseItem(id = "e", name = "Pilot", type = "Episode")
assertEquals("Play", primaryActionLabel(unnumbered))
}
private fun episode(season: Int, number: Int) = BaseItem(
id = "s${season}e$number",
name = "Episode $number",
type = "Episode",
parentIndexNumber = season,
indexNumber = number,
)
}
@@ -0,0 +1,121 @@
package com.ponzischeme89.memby.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onRoot
import androidx.compose.ui.unit.dp
import com.github.takahirom.roborazzi.captureRoboImage
import com.ponzischeme89.memby.data.model.MediaRating
import com.ponzischeme89.memby.ui.theme.MembySurface
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
/**
* Renders the ratings strip to PNGs under `build/screenshots/ratings-strip/`.
*
* ```powershell
* .\gradlew.bat :app:testDebugUnitTest --tests "*RatingsStripScreenshotTest"
* ```
*
* The marks are the reason this exists: three of them are supplied artwork and one is
* drawn, they have different aspect ratios, and whether they sit level with the score at
* both sizes is a thing to look at rather than assert.
*/
@RunWith(RobolectricTestRunner::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(sdk = [34], qualifiers = "w960dp-h540dp-television-xhdpi")
class RatingsStripScreenshotTest {
@get:Rule
val compose = createComposeRule()
/** The detail page's case: every provider MDBList returns, at full size. */
@Test
fun `detail page strip`() {
capture("ratings-strip-detail", ALL, width = 640)
}
/** The home hero and card case, where the strip is compact. */
@Test
fun `compact strip`() {
capture("ratings-strip-compact", ALL, width = 480, compact = true)
}
/** A poster card: the width limit cuts it to the first three. */
@Test
fun `narrow card strip`() {
capture("ratings-strip-narrow", ALL, width = 220, compact = true)
}
/** The mark for a provider no build has artwork for still has to read. */
@Test
fun `wordmark fallback`() {
capture(
"ratings-strip-wordmarks",
listOf(
MediaRating(source = "trakt", name = "Trakt", score = "83"),
MediaRating(source = "mal", name = "MyAnimeList", score = "8.1"),
MediaRating(source = "audience", name = "RT Audience", score = "91"),
),
width = 640,
)
}
/** One provider only, which is what an episode usually has. */
@Test
fun `single provider`() {
capture(
"ratings-strip-single",
listOf(MediaRating(source = "imdb", name = "IMDb", score = "8.7")),
width = 640,
)
}
private fun capture(
name: String,
ratings: List<MediaRating>,
width: Int,
compact: Boolean = false,
) {
compose.setContent { OnLauncherBackground(ratings, width, compact) }
compose.onRoot().captureRoboImage("build/screenshots/ratings-strip/$name.png")
}
@Composable
private fun OnLauncherBackground(
ratings: List<MediaRating>,
width: Int,
compact: Boolean,
) {
Box(Modifier.fillMaxSize().background(MembySurface)) {
Column(
modifier = Modifier.padding(40.dp).width(width.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
RatingsStrip(ratings = ratings, visible = true, compact = compact)
}
}
}
private companion object {
val ALL = listOf(
MediaRating(source = "imdb", name = "IMDb", score = "8.2"),
MediaRating(source = "tomatoes", name = "Rotten Tomatoes", score = "94"),
MediaRating(source = "metacritic", name = "Metacritic", score = "81"),
MediaRating(source = "letterboxd", name = "Letterboxd", score = "4.1"),
MediaRating(source = "tmdb", name = "TMDb", score = "7.4"),
)
}
}
@@ -0,0 +1,64 @@
package com.ponzischeme89.memby.ui
import android.graphics.BitmapFactory
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.onRoot
import androidx.test.core.app.ApplicationProvider
import com.github.takahirom.roborazzi.captureRoboImage
import com.ponzischeme89.memby.ServiceLocator
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
@RunWith(RobolectricTestRunner::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(sdk = [34], qualifiers = "w960dp-h540dp-television-xhdpi")
class RecommendationOnboardingScreenshotTest {
@get:Rule
val compose = createComposeRule()
@Before
fun locator() {
ServiceLocator.init(ApplicationProvider.getApplicationContext())
}
@Test
fun `movie taste selection`() = capture(
stageIndex = 0,
heading = "Choose movies you love",
fileName = "recommendation-onboarding-movies",
)
@Test
fun `actor taste selection`() = capture(
stageIndex = 2,
heading = "Pick actors you enjoy watching",
fileName = "recommendation-onboarding-actors",
)
private fun capture(stageIndex: Int, heading: String, fileName: String) {
val previewFocus = FocusRequester()
val artwork = requireNotNull(javaClass.getResourceAsStream("/home_hero_preview_art.png"))
.use(BitmapFactory::decodeStream)
.asImageBitmap()
compose.setContent {
RecommendationOnboardingPreview(
initialStageIndex = stageIndex,
previewArtwork = artwork,
previewFocusRequester = previewFocus,
)
}
compose.runOnIdle { previewFocus.requestFocus() }
compose.onNodeWithText(heading).fetchSemanticsNode()
compose.onRoot().captureRoboImage(
"build/screenshots/recommendation-onboarding/$fileName.png",
)
}
}
@@ -0,0 +1,217 @@
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.Studio
import com.ponzischeme89.memby.data.model.UserItemData
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
import java.text.SimpleDateFormat
import java.util.Locale
import java.util.TimeZone
/**
* The finish-date estimate as it actually sits on a series hero, to
* `build/screenshots/series-pace/`.
*
* ```powershell
* .\gradlew.bat :app:testDebugUnitTest --tests "*SeriesPaceScreenshotTest"
* ```
*
* The line is deliberately quiet it is a nicety on a page whose job is Play and quiet
* is the one property a unit test cannot check. What is worth looking at here is whether it
* reads as part of the progress information or as a stray sentence under the synopsis, and
* whether the two spacing cases (with a progress bar above it and without) both hold.
*
* The watch history is built relative to the clock rather than pinned, because the estimate
* is a projection from *now* and a fixture dated 2026 would fall out of the recency window
* and capture the empty case by accident. That makes the date in the image move with the
* day it was rendered on; these are artifacts to look at, not checked-in goldens.
*/
@RunWith(RobolectricTestRunner::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(sdk = [34], qualifiers = "w960dp-h540dp-television-xhdpi")
class SeriesPaceScreenshotTest {
@get:Rule
val compose = createComposeRule()
@Before
fun locator() {
ServiceLocator.init(ApplicationProvider.getApplicationContext())
}
/**
* An episode a night for the past four nights, five left, and nothing part-watched
* so there is no progress bar and the estimate is a line of its own. This is the state
* the feature exists for: somebody who finished one last night and has not started the
* next, whom the page previously told nothing at all.
*/
@Test
fun `an episode a night`() {
capture("sp_series-finish") {
SeriesDetailContent(
item = endedSeries,
episodes = nightly(watched = 4, remaining = 5),
loadFailed = false,
onPlay = {},
onToggleFavorite = { _, _ -> },
isMyShow = false,
onToggleMyShow = { _, _ -> },
)
}
}
/** The other spacing case: part-way through an episode, so the bar is above the line. */
@Test
fun `part way through tonight's episode`() {
val episodes = nightly(watched = 4, remaining = 5).map { item ->
if (item.indexNumber == 5) {
item.copy(userData = UserItemData(playbackPositionTicks = 14L * 600_000_000L))
} else {
item
}
}
capture("sp_series-finish-resuming") {
SeriesDetailContent(
item = endedSeries,
episodes = episodes,
loadFailed = false,
onPlay = {},
onToggleFavorite = { _, _ -> },
isMyShow = false,
onToggleMyShow = { _, _ -> },
)
}
}
/** A show still in production is caught up with, never finished. */
@Test
fun `a show still being made`() {
capture("sp_series-catch-up") {
SeriesDetailContent(
item = endedSeries.copy(status = "Continuing"),
episodes = nightly(watched = 4, remaining = 5),
loadFailed = false,
onPlay = {},
onToggleFavorite = { _, _ -> },
isMyShow = true,
onToggleMyShow = { _, _ -> },
)
}
}
/** Three a night across two nights, six left: the near end of the wording. */
@Test
fun `a binge finishes tomorrow`() {
val watched = listOf(
stamp(daysAgo = 2, hour = 19), stamp(daysAgo = 2, hour = 20), stamp(daysAgo = 2, hour = 21),
stamp(daysAgo = 1, hour = 19), stamp(daysAgo = 1, hour = 20), stamp(daysAgo = 1, hour = 21),
)
capture("sp_series-tomorrow") {
SeriesDetailContent(
item = endedSeries,
episodes = library(watched, remaining = 6),
loadFailed = false,
onPlay = {},
onToggleFavorite = { _, _ -> },
isMyShow = false,
onToggleMyShow = { _, _ -> },
)
}
}
/**
* The control, and the case that matters most: one episode watched is not a pace, so
* the page says nothing. Compare against `sp_series-finish` the hero must be
* identical apart from the missing line, with nothing left holding its space.
*/
@Test
fun `not enough history to say`() {
capture("sp_series-none") {
SeriesDetailContent(
item = endedSeries,
episodes = nightly(watched = 1, remaining = 8),
loadFailed = false,
onPlay = {},
onToggleFavorite = { _, _ -> },
isMyShow = false,
onToggleMyShow = { _, _ -> },
)
}
}
private fun capture(name: String, content: @Composable () -> Unit) {
compose.setContent {
PreviewSurface(alignment = Alignment.TopStart) { content() }
}
compose.onRoot().captureRoboImage("build/screenshots/series-pace/$name.png")
}
// ---------------------------------------------------------------------------
/** [watched] episodes on consecutive nights ending last night, then [remaining] unwatched. */
private fun nightly(watched: Int, remaining: Int): List<BaseItem> =
library((0 until watched).map { stamp(daysAgo = watched - it, hour = 20) }, remaining)
private fun library(watched: List<String>, remaining: Int): List<BaseItem> =
watched.mapIndexed { index, at -> episode(index + 1, at) } +
(0 until remaining).map { episode(watched.size + it + 1, null) }
private fun episode(number: Int, playedAt: String?) = BaseItem(
id = "s1e$number",
name = EPISODE_TITLES[(number - 1) % EPISODE_TITLES.size],
type = "Episode",
seriesName = "Signal Hill",
parentIndexNumber = 1,
indexNumber = number,
runTimeTicks = 48L * 600_000_000L,
overview = "The night shift picks up a voice reading tomorrow's shipping forecast, " +
"and the log book from 1974 says the same thing happened before.",
userData = UserItemData(played = playedAt != null, lastPlayedDate = playedAt),
)
/** Emby's UTC stamp for [hour] o'clock, [daysAgo] days back. */
private fun stamp(daysAgo: Int, hour: Int): String {
val at = System.currentTimeMillis() - daysAgo * 86_400_000L
val midnight = at / 86_400_000L * 86_400_000L
return iso.format(java.util.Date(midnight + hour * 3_600_000L))
}
private val iso = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US).apply {
timeZone = TimeZone.getTimeZone("UTC")
}
private val endedSeries = BaseItem(
id = "series-1",
name = "Signal Hill",
type = "Series",
overview = "A coastal radio station keeps receiving a broadcast that has not been " +
"transmitted yet. Six weeks before the storm, the night operator starts writing " +
"down what she hears.",
productionYear = 2022,
officialRating = "TV-MA",
genres = listOf("Thriller", "Drama"),
studios = listOf(Studio(name = "Harbour Line")),
status = "Ended",
)
private companion object {
val EPISODE_TITLES = listOf(
"Carrier Wave", "Dead Air", "The Long Count", "Nightingale", "Six Weeks Out",
"Landfall", "The Shipping Forecast", "Quiet Hours", "Storm Glass", "Last Transmission",
"Harbour Line", "The Night Operator", "Signal Hill",
)
}
}
@@ -50,7 +50,6 @@ class ServerHomeRowsTest {
private val serverRows = listOf(
row("continue", "continue", "a"),
row("next-up", "nextup", "b"),
row("favorites", "favorites", "c"),
row("latest-movies", "latest", "d"),
row("similar:sev", "similar", "e"),
@@ -62,12 +61,58 @@ class ServerHomeRowsTest {
val rows = serverHomeRows(HomeUiState(rows = serverRows, loading = emptySet()), Settings())
assertEquals(
listOf("continue", "next-up", "favorites", "latest-movies", "similar:sev", "recommended"),
listOf("continue", "favorites", "latest-movies", "similar:sev", "recommended"),
rows.map { it.id },
)
assertEquals("Recommended", rows.last().title)
}
/**
* A gateway that predates the merge or, far more commonly, the home cache written by
* the previous build, which is what a TV draws before its first refresh lands still
* carries a `nextup` row. Its episodes belong on the end of Continue Watching rather
* than in a row of their own.
*/
@Test
fun `a legacy next up row folds into continue watching`() {
val legacy = listOf(
row("continue", "continue", "a"),
row("next-up", "nextup", "b"),
row("favorites", "favorites", "c"),
)
val rows = serverHomeRows(HomeUiState(rows = legacy, loading = emptySet()), Settings())
assertEquals(listOf("continue", "favorites"), rows.map { it.id })
assertEquals(listOf("a", "b"), rows.first().items.map { it.id })
}
/** A show already part-way through must not also appear as the episode after it. */
@Test
fun `folding a legacy next up row drops shows already in progress`() {
val legacy = listOf(
HomeRow(
id = "continue",
title = "Continue Watching",
kind = "continue",
items = listOf(BaseItem(id = "s1e3", seriesId = "show")),
),
HomeRow(
id = "next-up",
title = "Next Up",
kind = "nextup",
items = listOf(
BaseItem(id = "s1e4", seriesId = "show"),
BaseItem(id = "other-e1", seriesId = "other"),
),
),
)
val rows = serverHomeRows(HomeUiState(rows = legacy, loading = emptySet()), Settings())
assertEquals(listOf("s1e3", "other-e1"), rows.single().items.map { it.id })
}
@Test
fun `favorites row uses the friendly profile name`() {
val peter = serverHomeRows(
@@ -79,8 +124,8 @@ class ServerHomeRowsTest {
Settings(username = "PaulR"),
)
assertEquals("Peter's Favorites", peter.first { it.id == "favorites" }.title)
assertEquals("Paul's Favorites", paul.first { it.id == "favorites" }.title)
assertEquals("Peter's Favourites", peter.first { it.id == "favorites" }.title)
assertEquals("Paul's Favourites", paul.first { it.id == "favorites" }.title)
}
@Test
@@ -90,8 +135,8 @@ class ServerHomeRowsTest {
assertEquals("MattCohen", friendlyProfileName("MattCohen"))
assertEquals("CHRIS", friendlyProfileName("CHRIS"))
assertEquals("PJ", friendlyProfileName("PJ"))
assertEquals("Chris' Favorites", personalizedFavoritesTitle("Chris"))
assertEquals("Favorites", personalizedFavoritesTitle(null))
assertEquals("Chris' Favourites", personalisedFavouritesTitle("Chris"))
assertEquals("Favourites", personalisedFavouritesTitle(null))
}
@Test
@@ -106,7 +151,7 @@ class ServerHomeRowsTest {
val rows = serverHomeRows(HomeUiState(rows = serverRows, loading = emptySet()), settings)
assertEquals(
listOf("continue", "next-up", "similar:sev", "recommended"),
listOf("continue", "similar:sev", "recommended"),
rows.map { it.id },
)
}
@@ -117,7 +162,6 @@ class ServerHomeRowsTest {
.associateBy { it.id }
assertEquals(MediaRowKind.CONTINUE, rows.getValue("continue").kind)
assertEquals(MediaRowKind.NEXT_UP, rows.getValue("next-up").kind)
assertEquals(MediaRowKind.FAVORITES, rows.getValue("favorites").kind)
assertEquals(MediaRowKind.MOVIES, rows.getValue("recommended").kind)
assertEquals(MediaRowKind.MOVIES, rows.getValue("similar:sev").kind)
@@ -168,7 +212,6 @@ class ServerHomeRowsTest {
assertEquals(
listOf(
"next-up",
"continue-shows",
"favourite-shows",
"curated:comedy-shows",
@@ -276,6 +319,31 @@ class ServerHomeRowsTest {
assertEquals("No monitored shows are airing in the next 5 days", rows.single().emptyMessage)
}
@Test
fun `TV schedule cards are ordered by their air date`() {
val friday = BaseItem(
id = "friday",
membyAirsAt = "2026-08-07T20:00:00+12:00",
)
val wednesday = BaseItem(
id = "wednesday",
membyAirsAt = "2026-08-05T20:00:00+12:00",
)
val schedule = HomeRow(
id = "sonarr-airing-today",
title = "Shows airing in the next 5 days",
kind = "schedule",
items = listOf(friday, wednesday),
)
val cards = serverHomeRows(
HomeUiState(rows = listOf(schedule), loading = emptySet()),
Settings(),
).single().items
assertEquals(listOf("wednesday", "friday"), cards.map { it.id })
}
@Test
fun `Radarr schedule rows use movie cards and explain an empty digital window`() {
val schedule = row("radarr-upcoming-movies", "movie-schedule", "radarr:7")

Some files were not shown because too many files have changed in this diff Show More