Publish current app and server

This commit is contained in:
ponzischeme89
2026-08-02 22:10:19 +12:00
parent a265636139
commit 1ed180c739
203 changed files with 23933 additions and 2788 deletions
+2 -2
View File
@@ -74,8 +74,8 @@
android:theme="@style/Theme.Memby.Fullscreen"
tools:ignore="DiscouragedApi" />
<!-- An APK replacement kills an active Dream process. Reopen our launcher so
the TV is never left displaying the old, black Dream surface. -->
<!-- Re-enter the normal app lifecycle after replacement. This preserves the
stored profile/session and performs the next update check before login. -->
<receiver
android:name=".update.UpdateRecoveryReceiver"
android:exported="false">
@@ -5,15 +5,31 @@ import coil.Coil
import coil.ImageLoader
import coil.disk.DiskCache
import coil.memory.MemoryCache
import com.ponzischeme89.memby.data.remote.HttpStack
import okhttp3.OkHttpClient
import java.util.concurrent.TimeUnit
class MembyApp : Application() {
override fun onCreate() {
super.onCreate()
Coil.setImageLoader(
ImageLoader.Builder(this)
// Coil builds its own OkHttpClient when not given one, which would mean a
// third connection pool alongside the Emby and gateway APIs. In gateway
// mode artwork is proxied by the same HTTPS host that serves /v1/home, so
// sharing the stack lets every poster resume the connection the home
// request already opened rather than repeating the TLS handshake.
.okHttpClient { artworkHttpClient() }
.memoryCache {
MemoryCache.Builder(this)
.maxSizePercent(0.08)
// A backdrop is requested at 1280x720 — 3.7MB as ARGB_8888 — and
// it is replaced on every focus change. At the previous 8% the
// cache held barely two of them before a poster could be cached
// at all, so ordinary D-pad movement re-decoded artwork it had
// just evicted. These are hardware bitmaps (allowHardware is on
// at every call site), so the extra headroom is graphics memory
// rather than Java heap.
.maxSizePercent(0.25)
.build()
}
.diskCache {
@@ -31,4 +47,14 @@ class MembyApp : Application() {
)
ServiceLocator.init(this)
}
/**
* Artwork tolerates a shorter read timeout than the API does: a poster that has not
* arrived in ten seconds has already missed the moment it was wanted for, and the
* card falls back to its placeholder.
*/
private fun artworkHttpClient(): OkHttpClient = HttpStack.base.newBuilder()
.connectTimeout(10, TimeUnit.SECONDS)
.readTimeout(10, TimeUnit.SECONDS)
.build()
}
@@ -3,24 +3,27 @@ package com.ponzischeme89.memby.data
import com.ponzischeme89.memby.data.model.AuthRequest
import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.data.model.GatewayFlagRequest
import com.ponzischeme89.memby.data.model.GatewayAuthError
import com.ponzischeme89.memby.data.model.GatewayAuthPolicy
import com.ponzischeme89.memby.data.model.GatewayDevice
import com.ponzischeme89.memby.data.model.GatewayDeviceNameRequest
import com.ponzischeme89.memby.data.model.GatewayLoginRequest
import com.ponzischeme89.memby.data.model.GatewayPlaybackReport
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.GatewayUpdate
import com.ponzischeme89.memby.data.model.GatewayFeatures
import com.ponzischeme89.memby.data.model.HomeRow
import com.ponzischeme89.memby.data.model.PlaybackReport
import com.ponzischeme89.memby.data.model.PlaybackInfoRequest
import com.ponzischeme89.memby.data.model.MediaSourceInfo
import com.ponzischeme89.memby.data.remote.EmbyApi
import com.ponzischeme89.memby.data.remote.EmbyServiceFactory
import com.ponzischeme89.memby.data.remote.GatewayApi
import com.ponzischeme89.memby.data.remote.GatewayServiceFactory
import com.ponzischeme89.memby.data.playback.devicePlaybackCapabilities
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
@@ -80,6 +83,11 @@ data class Playable(
val mediaSourceId: String = "",
val playSessionId: String = "",
val playMethod: String = "DirectPlay",
val overview: String? = null,
val episodeCode: String? = null,
val runtimeMs: Long = 0L,
val prerollEnabled: Boolean = true,
val prerollDurationMs: Long = 6_500L,
)
class EmbyRepository(private val settings: SettingsStore) {
@@ -96,11 +104,24 @@ class EmbyRepository(private val settings: SettingsStore) {
}
val settingsFlow: Flow<Settings> get() = settings.settingsFlow
/**
* Read synchronously off the snapshot, like [rotationIntervalMillis], because the
* composables that decide between logo artwork and a text title do so while building a
* layout and must not collect a flow to answer a question that changes once a year.
*/
val showTitleLogo: Boolean get() = snapshot.showTitleLogo
private val _playbackStops = MutableSharedFlow<String>(extraBufferCapacity = 1)
val playbackStops = _playbackStops.asSharedFlow()
private val playableMutex = Mutex()
private val playableCache = LinkedHashMap<String, CachedPlayable>(16, 0.75f, true)
private val playableInFlight = mutableMapOf<String, Deferred<Playable>>()
private val seriesEpisodesMutex = Mutex()
private val seriesEpisodesCache =
LinkedHashMap<String, CachedSeriesEpisodes>(SERIES_EPISODE_CACHE_SIZE, 0.75f, true)
private val relatedMutex = Mutex()
private val relatedCache =
LinkedHashMap<String, CachedRelated>(RELATED_CACHE_SIZE, 0.75f, true)
fun cachedHome(): HomeCache? = settings.homeCache(snapshot)
@@ -168,14 +189,14 @@ class EmbyRepository(private val settings: SettingsStore) {
deviceName: String,
): String {
clearPlayableCache()
clearSeriesEpisodeCache()
settings.ensureDeviceId()
observedSettings = settings.snapshot() // pick up the freshly-generated device id
ServerConfig.gatewayUrl?.let { gateway ->
// The gateway holds the Emby token; this device only ever stores the gateway
// token, so the same `token` slot in DataStore serves both modes.
val result = try {
requireGateway().login(
val result = requireGateway().login(
GatewayLoginRequest(
username = username,
password = password,
@@ -183,12 +204,6 @@ class EmbyRepository(private val settings: SettingsStore) {
deviceName = deviceName.trim(),
),
)
} catch (error: HttpException) {
if (error.code() == 409) {
parseDeviceLimit(error.response()?.errorBody()?.string().orEmpty())?.let { throw it }
}
throw error
}
require(result.token.isNotBlank() && result.userId.isNotBlank()) {
"Gateway did not return a session"
}
@@ -220,13 +235,39 @@ class EmbyRepository(private val settings: SettingsStore) {
return username
}
suspend fun authPolicy(): GatewayAuthPolicy? =
if (ServerConfig.isGateway) requireGateway().authPolicy() else null
suspend fun devices(): List<GatewayDevice> =
if (ServerConfig.isGateway) requireGateway().devices().devices else emptyList()
/** True only when the gateway still recognises the token restored from storage. */
suspend fun removeDevice(deviceId: String) {
if (ServerConfig.isGateway) requireGateway().removeDevice(deviceId)
}
suspend fun renameDevice(device: GatewayDevice, deviceName: String) {
val trimmed = deviceName.trim()
require(trimmed.isNotEmpty() && trimmed.length <= 80) { "Invalid device name" }
if (ServerConfig.isGateway) {
requireGateway().renameDevice(device.deviceId, GatewayDeviceNameRequest(trimmed))
if (device.current) settings.setDeviceName(trimmed)
}
}
/**
* False only when the gateway explicitly rejects the restored token with 401.
*
* A deployment, maintenance window, or disconnected TV is not evidence that a
* viewer's credentials are invalid. Treating every network failure as rejection used
* to delete the active profile precisely when the server was being updated.
*/
suspend fun validateSession(): Boolean {
if (!ServerConfig.isGateway || snapshot.token.isNullOrBlank()) return true
return runCatching { requireGateway().session() }.isSuccess
return try {
requireGateway().session()
true
} catch (cancelled: CancellationException) {
throw cancelled
} catch (failure: Throwable) {
shouldPreserveSessionAfterValidationFailure(failure)
}
}
/**
@@ -239,6 +280,7 @@ class EmbyRepository(private val settings: SettingsStore) {
cachedApi = null
cachedBaseUrl = null
clearPlayableCache()
clearSeriesEpisodeCache()
}
suspend fun signOut() {
@@ -252,6 +294,7 @@ class EmbyRepository(private val settings: SettingsStore) {
cachedApi = null
cachedBaseUrl = null
clearPlayableCache()
clearSeriesEpisodeCache()
}
suspend fun switchProfile(profile: EmbyProfile) {
@@ -260,6 +303,24 @@ class EmbyRepository(private val settings: SettingsStore) {
cachedApi = null
cachedBaseUrl = null
clearPlayableCache()
clearSeriesEpisodeCache()
}
suspend fun removeProfile(profile: EmbyProfile) {
val removingActiveProfile = snapshot.activeProfileId == profile.id
if (removingActiveProfile && ServerConfig.isGateway && !snapshot.token.isNullOrBlank()) {
// Retire the active gateway session where possible. Local removal still
// succeeds when this TV is offline.
runCatching { requireGateway().logout() }
}
settings.removeProfile(profile.id)
observedSettings = settings.snapshot()
if (removingActiveProfile) {
cachedApi = null
cachedBaseUrl = null
clearPlayableCache()
clearSeriesEpisodeCache()
}
}
// --- Content -------------------------------------------------------------
@@ -345,7 +406,7 @@ class EmbyRepository(private val settings: SettingsStore) {
"SortOrder" to "Ascending",
"Limit" to limit.toString(),
),
fields = "ProductionYear,RunTimeTicks,PrimaryImageAspectRatio",
fields = "ProductionYear,RunTimeTicks,RecursiveItemCount,PrimaryImageAspectRatio",
imageTypes = "Backdrop,Primary,Logo",
includeUserData = true,
)
@@ -443,6 +504,23 @@ class EmbyRepository(private val settings: SettingsStore) {
.getOrDefault(emptyList())
}
suspend fun lookupMediaRequests(term: String): List<com.ponzischeme89.memby.data.model.GatewayRequestCandidate> {
if (!ServerConfig.isGateway) return emptyList()
return requireGateway().requestLookup(term.trim()).candidates
}
suspend fun requestMedia(
candidate: com.ponzischeme89.memby.data.model.GatewayRequestCandidate,
): String {
check(ServerConfig.isGateway) { "Media requests require the Memby gateway" }
return requireGateway().requestMedia(
com.ponzischeme89.memby.data.model.GatewayMediaRequest(
mediaType = candidate.mediaType,
foreignId = candidate.foreignId,
),
).title
}
/**
* Recommendation rows on their own, forcing the gateway to build them synchronously
* if its cache is cold. [getHome] already carries them once warm, so this is only
@@ -456,21 +534,72 @@ class EmbyRepository(private val settings: SettingsStore) {
return requireGateway().forYou(availableMinutes.coerceIn(0, 360)).rows
}
/**
* The gateway's verdict on this build. Null on the direct path, where nobody is in a
* position to decide, and null on failure — an unreachable gateway must never leave
* a TV stuck behind a blocking update prompt.
*/
suspend fun checkAppUpdate(): GatewayUpdate? {
if (!ServerConfig.isGateway) return null
return runCatching { requireGateway().updateStatus() }
.getOrNull()
?.takeIf { it.isActionable }
suspend fun getRecommendationOnboarding():
com.ponzischeme89.memby.data.model.RecommendationOnboarding {
if (!ServerConfig.isGateway) {
return com.ponzischeme89.memby.data.model.RecommendationOnboarding(completed = true)
}
return requireGateway().recommendationPreferences()
}
suspend fun saveRecommendationRatings(ratings: Map<String, Int>) {
if (!ServerConfig.isGateway) return
requireGateway().saveRecommendationPreferences(
com.ponzischeme89.memby.data.model.RecommendationPreferences(
ratings = ratings.filterValues { it in 1..5 },
),
)
}
suspend fun getMyShows(): List<com.ponzischeme89.memby.data.model.MyShow> =
if (ServerConfig.isGateway) requireGateway().myShows().shows else emptyList()
suspend fun saveMyShow(item: BaseItem): List<com.ponzischeme89.memby.data.model.MyShow> {
if (!ServerConfig.isGateway || !item.isSeries) return getMyShows()
return requireGateway().saveMyShow(
com.ponzischeme89.memby.data.model.SaveMyShowRequest(
itemId = item.id,
title = item.name,
year = item.productionYear,
imageTag = item.imageTags["Primary"].orEmpty(),
),
).shows
}
suspend fun removeMyShow(itemId: String) {
if (ServerConfig.isGateway) requireGateway().removeMyShow(itemId)
}
suspend fun getNotifications(): com.ponzischeme89.memby.data.model.NotificationsResponse =
if (ServerConfig.isGateway) {
requireGateway().notifications()
} else {
com.ponzischeme89.memby.data.model.NotificationsResponse()
}
suspend fun setNotificationPreferences(
value: com.ponzischeme89.memby.data.model.NotificationPreferences,
): com.ponzischeme89.memby.data.model.NotificationsResponse =
requireGateway().setNotificationPreferences(value)
suspend fun markNotificationRead(id: Long) {
if (ServerConfig.isGateway) requireGateway().updateNotification(id, "read")
}
suspend fun dismissNotification(id: Long) {
if (ServerConfig.isGateway) requireGateway().updateNotification(id, "dismiss")
}
fun myShowImageUrl(itemId: String, imageTag: String, maxWidth: Int = 320): String? =
imageTag.takeIf(String::isNotBlank)?.let {
imageUrl(itemId, "Primary", it, maxWidth)
}
/** Live gateway state. Unlike content routes, this remains available in maintenance. */
suspend fun serviceStatus(): GatewayServiceStatus = requireGateway().serviceStatus()
suspend fun serverFeatures(): GatewayFeatures = requireGateway().features()
/** Full item metadata, requested only after focus settles on an item. */
suspend fun getItemDetails(itemId: String): BaseItem {
if (ServerConfig.isGateway) return requireGateway().item(itemId)
@@ -478,32 +607,142 @@ class EmbyRepository(private val settings: SettingsStore) {
return requireApi().getItem(
userId = userId,
itemId = itemId,
fields = "Overview,Genres,MediaStreams,People,ProductionYear,OfficialRating,CommunityRating,RunTimeTicks,PrimaryImageAspectRatio",
fields = "Overview,Genres,MediaStreams,People,ProductionYear,OfficialRating,CommunityRating,RunTimeTicks,PrimaryImageAspectRatio,CollectionName",
)
}
/** Whether this episode closes its season; failures are non-fatal playback metadata. */
suspend fun seasonFinale(itemId: String): com.ponzischeme89.memby.data.model.GatewaySeasonFinale {
if (itemId.isBlank()) return com.ponzischeme89.memby.data.model.GatewaySeasonFinale()
if (ServerConfig.isGateway) return requireGateway().seasonFinale(itemId)
// Direct mode has no Sonarr catalogue. Use Emby's full episode list as the best
// available fallback; gateway mode remains authoritative because it sees future
// episodes and cannot confuse the newest download with a finale.
val current = getItemDetails(itemId)
val season = current.parentIndexNumber ?: return com.ponzischeme89.memby.data.model.GatewaySeasonFinale()
val episode = current.indexNumber ?: return com.ponzischeme89.memby.data.model.GatewaySeasonFinale()
val seriesId = current.seriesId ?: return com.ponzischeme89.memby.data.model.GatewaySeasonFinale()
val lastEpisode = getSeriesEpisodes(seriesId)
.filter { it.parentIndexNumber == season }
.mapNotNull(BaseItem::indexNumber)
.maxOrNull()
if (season <= 0 || lastEpisode == null || episode != lastEpisode) {
return com.ponzischeme89.memby.data.model.GatewaySeasonFinale()
}
return com.ponzischeme89.memby.data.model.GatewaySeasonFinale(
seasonFinale = true,
seriesName = current.seriesName.orEmpty(),
seasonNumber = season,
episodeNumber = episode,
)
}
/**
* The detail page's "why you might like this" strip and its related carousel.
*
* Dual-path like everything else, but the two paths differ in what they can say: the
* gateway runs the recommendation engine and returns reasons drawn from this user's
* 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.
*/
suspend fun getRelated(item: BaseItem, limit: Int = RELATED_LIMIT): RelatedContent {
if (item.id.isBlank()) return RelatedContent()
val now = System.currentTimeMillis()
relatedMutex.withLock {
relatedCache[item.id]
?.takeIf { it.expiresAtMs > now }
?.let { return it.content }
relatedCache.remove(item.id)
}
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()
}
}
return relatedCache.getValue(item.id).content
}
}
/**
* All episodes for one show in a single request. Season switching is then a local
* list filter, keeping the detail screen immediate after its first load.
*/
suspend fun getSeriesEpisodes(seriesId: String): List<BaseItem> {
if (seriesId.isBlank()) return emptyList()
if (ServerConfig.isGateway) {
return requireGateway().seriesEpisodes(seriesId).items
val now = System.currentTimeMillis()
seriesEpisodesMutex.withLock {
seriesEpisodesCache[seriesId]
?.takeIf { it.expiresAtMs > now }
?.episodes
?.let { return it }
seriesEpisodesCache.remove(seriesId)
}
val userId = snapshot.userId ?: error("Not connected")
return requireApi().getEpisodes(
seriesId,
mapOf(
"UserId" to userId,
"Fields" to "Overview,RunTimeTicks,SeriesName,PrimaryImageAspectRatio",
"EnableUserData" to "true",
"EnableImages" to "true",
"EnableImageTypes" to "Primary,Thumb,Backdrop",
"ImageTypeLimit" to "1",
"Limit" to "1000",
),
).items
val loaded = if (ServerConfig.isGateway) {
requireGateway().seriesEpisodes(seriesId).items
} else {
val userId = snapshot.userId ?: error("Not connected")
requireApi().getEpisodes(
seriesId,
mapOf(
"UserId" to userId,
"Fields" to "Overview,RunTimeTicks,SeriesName,PrimaryImageAspectRatio",
"EnableUserData" to "true",
"EnableImages" to "true",
"EnableImageTypes" to "Primary,Thumb,Backdrop",
"ImageTypeLimit" to "1",
"Limit" to "1000",
),
).items
}
seriesEpisodesMutex.withLock {
seriesEpisodesCache[seriesId] = CachedSeriesEpisodes(
episodes = loaded,
expiresAtMs = now + SERIES_EPISODE_CACHE_TTL_MS,
)
while (seriesEpisodesCache.size > SERIES_EPISODE_CACHE_SIZE) {
seriesEpisodesCache.entries.iterator().run {
next()
remove()
}
}
}
return loaded
}
/** Tight list endpoint shape: detail-only fields are never fetched on home. */
@@ -597,6 +836,16 @@ class EmbyRepository(private val settings: SettingsStore) {
.getOrDefault(GatewayPrerollSchedule())
}
fun prerollArtworkUrl(itemId: String, imageType: String, maxWidth: Int): String? {
if (!ServerConfig.isGateway || itemId.isBlank() || imageType.isBlank()) return null
return imageUrl(
itemId = itemId,
imageType = imageType,
tag = "sonarr",
maxWidth = maxWidth,
)
}
/** Backdrop rotation interval, clamped to a sane range. */
fun rotationIntervalMillis(): Long =
snapshot.rotationIntervalSeconds.coerceIn(4, 600).toLong() * 1000L
@@ -641,14 +890,15 @@ class EmbyRepository(private val settings: SettingsStore) {
itemId: String,
title: String,
resumePositionMs: Long,
forceTranscode: Boolean = false,
): Playable {
require(itemId.isNotBlank()) { "A media item is required to refresh playback" }
if (!ServerConfig.isGateway) {
val discovery = directPlayback(itemId, resumePositionMs)
val discovery = directPlayback(itemId, resumePositionMs, forceTranscode = forceTranscode)
return Playable(
itemId = itemId,
title = title,
url = buildStreamUrl(itemId),
url = discovery.url ?: buildStreamUrl(itemId),
resumePositionMs = resumePositionMs.coerceAtLeast(0L),
subtitles = discovery.subtitles,
mediaSourceId = discovery.mediaSourceId,
@@ -662,6 +912,7 @@ class EmbyRepository(private val settings: SettingsStore) {
itemType = "",
title = title,
resumePositionMs = resumePositionMs.coerceAtLeast(0L),
forceTranscode = forceTranscode,
)
return Playable(
itemId = playback.itemId,
@@ -700,6 +951,9 @@ class EmbyRepository(private val settings: SettingsStore) {
mediaSourceId = playback.mediaSourceId,
playSessionId = playback.playSessionId,
playMethod = playback.playMethod,
overview = playback.overview.ifBlank { null },
episodeCode = playback.episodeCode.ifBlank { null },
runtimeMs = playback.runtimeMs,
)
}
val discovery = directPlayback(
@@ -765,6 +1019,12 @@ class EmbyRepository(private val settings: SettingsStore) {
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,
prerollEnabled = playback.prerollEnabled,
prerollDurationMs = playback.prerollDurationMs,
)
}
if (item.isSeries) {
@@ -777,28 +1037,34 @@ class EmbyRepository(private val settings: SettingsStore) {
}
val discovery = directPlayback(episode.id, episode.resumePositionMs)
return Playable(
episode.id,
title,
buildStreamUrl(episode.id),
episode.resumePositionMs,
logoUrl(item),
discovery.subtitles,
discovery.mediaSourceId,
discovery.playSessionId,
discovery.playMethod,
itemId = episode.id,
title = title,
url = discovery.url ?: buildStreamUrl(episode.id),
resumePositionMs = episode.resumePositionMs,
logoUrl = logoUrl(item),
subtitles = discovery.subtitles,
mediaSourceId = discovery.mediaSourceId,
playSessionId = discovery.playSessionId,
playMethod = discovery.playMethod,
overview = episode.overview,
episodeCode = episodeCode(episode),
runtimeMs = episode.runTimeTicks?.div(10_000L)?.coerceAtLeast(0L) ?: 0L,
)
}
val discovery = directPlayback(item.id, item.resumePositionMs)
return Playable(
item.id,
item.name,
buildStreamUrl(item.id),
item.resumePositionMs,
logoUrl(item),
discovery.subtitles,
discovery.mediaSourceId,
discovery.playSessionId,
discovery.playMethod,
itemId = item.id,
title = item.name,
url = discovery.url ?: buildStreamUrl(item.id),
resumePositionMs = item.resumePositionMs,
logoUrl = logoUrl(item),
subtitles = 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,
)
}
@@ -810,6 +1076,16 @@ class EmbyRepository(private val settings: SettingsStore) {
}
}
private suspend fun clearSeriesEpisodeCache() {
seriesEpisodesMutex.withLock {
seriesEpisodesCache.clear()
}
relatedMutex.withLock {
// Reasons are personal: another profile must never inherit this one's.
relatedCache.clear()
}
}
suspend fun reportPlaybackStarted(session: PlaybackSession, positionMs: Long) {
if (ServerConfig.isGateway) {
requireGateway().report("started", session.gatewayReport(positionMs, false, null))
@@ -823,12 +1099,16 @@ class EmbyRepository(private val settings: SettingsStore) {
positionMs: Long,
isPaused: Boolean,
eventName: String,
) {
durationMs: Long = 0L,
): String? {
if (ServerConfig.isGateway) {
requireGateway().report("progress", session.gatewayReport(positionMs, isPaused, eventName))
return
return requireGateway().report(
"progress",
session.gatewayReport(positionMs, isPaused, eventName, durationMs),
).autoFollowedShowTitle.takeIf(String::isNotBlank)
}
requireApi().reportPlaybackProgress(playbackReport(session, positionMs, isPaused, eventName))
return null
}
suspend fun reportPlaybackStopped(session: PlaybackSession, positionMs: Long) {
@@ -842,6 +1122,8 @@ class EmbyRepository(private val settings: SettingsStore) {
}
} finally {
clearPlayableCache()
// Episode progress and watched badges may have changed during playback.
clearSeriesEpisodeCache()
_playbackStops.tryEmit(session.itemId)
}
}
@@ -894,7 +1176,7 @@ class EmbyRepository(private val settings: SettingsStore) {
val next = episodes.getOrNull(current + 1) ?: return null
val discovery = directPlayback(next.id, next.resumePositionMs)
return nextEpisodeOf(
next, buildStreamUrl(next.id), next.resumePositionMs, discovery.subtitles,
next, discovery.url ?: buildStreamUrl(next.id), next.resumePositionMs, discovery.subtitles,
discovery.mediaSourceId, discovery.playSessionId, discovery.playMethod,
)
}
@@ -926,6 +1208,7 @@ class EmbyRepository(private val settings: SettingsStore) {
positionMs: Long,
subtitleStreamIndex: Int? = null,
currentPlaySessionId: String? = null,
forceTranscode: Boolean = false,
): PlaybackDiscovery {
val userId = snapshot.userId ?: return PlaybackDiscovery(mediaSourceId = itemId)
val serverUrl = activeServerUrl ?: return PlaybackDiscovery(mediaSourceId = itemId)
@@ -938,11 +1221,27 @@ class EmbyRepository(private val settings: SettingsStore) {
id = itemId,
userId = userId,
startTimeTicks = millisecondsToTicks(positionMs),
enableDirectPlay = !forceTranscode,
enableDirectStream = !forceTranscode,
subtitleStreamIndex = subtitleStreamIndex,
currentPlaySessionId = currentPlaySessionId,
deviceProfile = if (forceTranscode) {
com.ponzischeme89.memby.data.model.DeviceProfile.embyAndroidTv(
capabilities = devicePlaybackCapabilities,
)
.copy(directPlayProfiles = emptyList())
} else {
com.ponzischeme89.memby.data.model.DeviceProfile.embyAndroidTv(
capabilities = devicePlaybackCapabilities,
)
},
),
)
info.mediaSources.firstOrNull()?.let { source ->
val delivery = selectPlaybackDelivery(
source = source,
forceTranscode = forceTranscode || subtitleStreamIndex != null,
)
PlaybackDiscovery(
subtitles = subtitleTracks(
streams = source.mediaStreams,
@@ -953,12 +1252,8 @@ class EmbyRepository(private val settings: SettingsStore) {
),
mediaSourceId = source.id.ifBlank { itemId },
playSessionId = info.playSessionId,
playMethod = if (subtitleStreamIndex != null && !source.transcodingUrl.isNullOrBlank()) {
"Transcode"
} else "DirectPlay",
url = source.transcodingUrl
?.takeIf { subtitleStreamIndex != null }
?.let { authenticatedDeliveryUrl(serverUrl, it, token) },
playMethod = delivery.playMethod,
url = delivery.url?.let { authenticatedDeliveryUrl(serverUrl, it, token) },
)
} ?: PlaybackDiscovery(mediaSourceId = itemId)
}.getOrElse { PlaybackDiscovery(mediaSourceId = itemId) }
@@ -1026,7 +1321,7 @@ class EmbyRepository(private val settings: SettingsStore) {
/**
* Poster for an item the app never received as a [BaseItem] — a service alert names
* its subject by id and tag only, and the Sonarr ids it carries resolve through the
* its subject by id and tag only, and the TV schedule ids it carries resolve through the
* gateway's image proxy like any other.
*/
fun posterUrl(itemId: String, tag: String, maxWidth: Int = 300): String? {
@@ -1060,7 +1355,7 @@ class EmbyRepository(private val settings: SettingsStore) {
append(gateway.trimEnd('/'))
append("/v1/images/").append(itemId).append('/').append(imageType.lowercase())
append("?maxWidth=").append(maxWidth)
append("&quality=90")
append("&quality=").append(ARTWORK_QUALITY)
append("&tag=").append(encode(tag))
append("&t=").append(encode(token))
}
@@ -1072,7 +1367,7 @@ class EmbyRepository(private val settings: SettingsStore) {
append("/Items/").append(itemId).append("/Images/").append(imageType)
if (directIndex) append("/0")
append("?maxWidth=").append(maxWidth)
append("&quality=90")
append("&quality=").append(ARTWORK_QUALITY)
append("&tag=").append(encode(tag))
token?.let { append("&api_key=").append(encode(it)) }
}
@@ -1109,6 +1404,22 @@ class EmbyRepository(private val settings: SettingsStore) {
)
private fun encode(value: String): String = URLEncoder.encode(value, "UTF-8")
private companion object {
/**
* JPEG quality asked of Emby (or of the gateway's image proxy) for every poster,
* backdrop and logo.
*
* 80 rather than 90: at the distance a television is watched from the two are
* indistinguishable, and it is roughly a third fewer bytes on every cold image —
* which is time-to-poster on a launcher that paints fifteen of them at once, and
* a disk cache that holds correspondingly more.
*
* Note this is part of the image URL, and the URL is Coil's cache key, so
* changing it makes every existing install re-fetch its artwork once.
*/
const val ARTWORK_QUALITY = 80
}
}
data class PlaybackSession(
@@ -1126,10 +1437,52 @@ private data class PlaybackDiscovery(
val url: String? = null,
)
private fun PlaybackSession.gatewayReport(positionMs: Long, isPaused: Boolean, eventName: String?) =
internal data class PlaybackDelivery(
val url: String?,
val playMethod: String,
)
/**
* Honour Emby's negotiated delivery instead of blindly opening the original file.
* A null URL means the original static stream is genuinely suitable for direct play.
*/
internal fun selectPlaybackDelivery(
source: MediaSourceInfo,
forceTranscode: Boolean = false,
): PlaybackDelivery {
val transcode = source.transcodingUrl?.takeIf(String::isNotBlank)
val directStream = source.directStreamUrl?.takeIf(String::isNotBlank)
if (forceTranscode && transcode != null) {
return PlaybackDelivery(transcode, "Transcode")
}
if (source.supportsDirectPlay == true) {
return PlaybackDelivery(null, "DirectPlay")
}
if (source.supportsDirectStream != false && directStream != null) {
return PlaybackDelivery(directStream, "DirectStream")
}
if (source.supportsTranscoding != false && transcode != null) {
return PlaybackDelivery(transcode, "Transcode")
}
if (directStream != null) {
return PlaybackDelivery(directStream, "DirectStream")
}
if (transcode != null) {
return PlaybackDelivery(transcode, "Transcode")
}
return PlaybackDelivery(null, "DirectPlay")
}
private fun PlaybackSession.gatewayReport(
positionMs: Long,
isPaused: Boolean,
eventName: String?,
durationMs: Long = 0L,
) =
GatewayPlaybackReport(
itemId = itemId,
positionMs = positionMs,
durationMs = durationMs.coerceAtLeast(0L),
isPaused = isPaused,
mediaSourceId = mediaSourceId,
playSessionId = playSessionId,
@@ -1137,13 +1490,51 @@ private fun PlaybackSession.gatewayReport(positionMs: Long, isPaused: Boolean, e
eventName = eventName,
)
private fun episodeCode(item: BaseItem): String? {
val season = item.parentIndexNumber ?: return null
val episode = item.indexNumber ?: return null
if (season < 0 || episode <= 0) return null
return "S${season.toString().padStart(2, '0')}E${episode.toString().padStart(2, '0')}"
}
private data class CachedPlayable(
val playable: Playable,
val expiresAtMs: Long,
)
private data class CachedSeriesEpisodes(
val episodes: List<BaseItem>,
val expiresAtMs: Long,
)
/**
* What a detail page can add beyond the item itself.
*
* [reasons] is the recommendation engine's explanation of this title *to this viewer* and
* is empty on the direct path, which has no profile to reason from.
*/
data class RelatedContent(
val reasons: List<String> = emptyList(),
val items: List<BaseItem> = emptyList(),
) {
val isEmpty: Boolean get() = reasons.isEmpty() && items.isEmpty()
}
private data class CachedRelated(
val content: RelatedContent,
val expiresAtMs: Long,
)
private const val PLAYABLE_CACHE_SIZE = 16
private const val PLAYABLE_CACHE_TTL_MS = 5L * 60L * 1_000L
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
private const val RELATED_LIMIT = 12
// Long enough that walking back and forth between a row and a detail page never re-asks,
// short enough that a newly watched title drops out of "more like this" the same evening.
private const val RELATED_CACHE_TTL_MS = 10L * 60L * 1_000L
internal fun millisecondsToTicks(milliseconds: Long): Long =
milliseconds.coerceAtLeast(0L) * 10_000L
@@ -1151,29 +1542,9 @@ internal fun millisecondsToTicks(milliseconds: Long): Long =
private val BaseItem.resumePositionMs: Long
get() = ((userData?.playbackPositionTicks ?: 0L) / 10_000L).coerceAtLeast(0L)
class DeviceLimitException(
val activeClients: Int,
val maxClients: Int,
) : Exception("Memby device allowance reached")
/** Shared across the error-body parsers below; building a Json format per call is costly. */
private val errorBodyJson = Json { ignoreUnknownKeys = true }
internal fun parseDeviceLimit(body: String): DeviceLimitException? {
if (body.isBlank()) return null
return runCatching {
val parsed = errorBodyJson.decodeFromString<GatewayAuthError>(body)
parsed.takeIf {
it.error == "device_limit_reached" && it.maxClientsPerUser > 0
}?.let {
DeviceLimitException(
activeClients = it.activeClients.coerceAtLeast(0),
maxClients = it.maxClientsPerUser,
)
}
}.getOrNull()
}
/**
* Maps an exception to a short, TV-readable message. Never surfaces raw HTTP
* bodies or stack traces (which could contain tokens) to the screen.
@@ -1217,6 +1588,10 @@ fun isMaintenanceError(t: Throwable): Boolean = t is HttpException && t.code() =
/** True when the gateway has rejected the persisted session token. */
fun isUnauthorizedError(t: Throwable): Boolean = t is HttpException && t.code() == 401
/** Only an authoritative authentication rejection permits deleting a saved profile. */
internal fun shouldPreserveSessionAfterValidationFailure(t: Throwable): Boolean =
!isUnauthorizedError(t)
@Serializable
private data class MaintenanceResponse(
val maintenance: Boolean = false,
@@ -20,14 +20,19 @@ data class MaintenanceNotice(val message: String)
data class CompatibilityNotice(val message: String)
/**
* One informational banner: a show aired, its episode is on its way into Emby. It is
* never actionable and never focusable — it slides in, says its piece and goes.
* 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 —
* it slides in, says its piece and goes, over the launcher or over playback alike.
*
* [label] is the eyebrow above the title and comes from the server, so a kind of news
* this build has never heard of still reads correctly; a server that sends none gets the
* original wording back.
*/
data class ServiceAlert(
val id: String,
val title: String,
val message: String,
val posterUrl: String?,
val label: String = "",
)
/**
@@ -177,7 +182,10 @@ class MaintenanceMonitor(
id = next.id,
title = next.title.trim(),
message = next.message.trim(),
posterUrl = repository.posterUrl(next.itemId, next.imageTag),
// The banner shows the Emby mark rather than item artwork — half these
// alerts (a refresh, an outage) have no artwork — so the itemId and
// imageTag the gateway still sends are deliberately unused here.
label = next.label.trim(),
)
}
@@ -2,15 +2,19 @@ package com.ponzischeme89.memby.data
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.emptyPreferences
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.stringPreferencesKey
import androidx.datastore.preferences.core.stringSetPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
@@ -25,7 +29,20 @@ import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import java.util.UUID
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "emby_settings")
/**
* 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
* because [SettingsStore.settingsFlow] is the gate the launcher waits behind, that shows
* up as "Opening Memby…" forever, surviving every relaunch. Preferences DataStore rewrites
* and fsyncs the whole file on each edit, and this app edits it on every home refresh, so
* a process killed mid-write (backgrounding a TV app is exactly when that happens) is a
* real possibility rather than a theoretical one. Starting again from empty preferences
* costs a sign-in; throwing costs the app.
*/
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(
name = "emby_settings",
corruptionHandler = ReplaceFileCorruptionHandler { emptyPreferences() },
)
/** Persisted connection state. */
data class Settings(
@@ -46,6 +63,8 @@ data class Settings(
val showTitleLogo: Boolean = true,
// Slide up a "next up" banner near the end of an episode and roll into the next one.
val autoPlayNextEpisode: Boolean = true,
// Show the compact lower-third when playback crosses ten minutes remaining.
val showTenMinuteReminder: Boolean = true,
// Foreground colour of the slide-progress ring, as an RRGGBB hex string.
val ringColorHex: String = DEFAULT_RING_COLOR,
val lastBackdropUrl: String? = null,
@@ -57,7 +76,25 @@ data class Settings(
/** Whether this profile has discovered the dedicated For You destination. */
val hasOpenedForYou: Boolean = false,
val homeCardDensity: String = DEFAULT_HOME_CARD_DENSITY,
/** Card image selection on browse rows: automatic, poster, or backdrop. */
val homeArtworkStyle: String = DEFAULT_HOME_ARTWORK_STYLE,
val showHomeCardMetadata: 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. */
val homeRowOrder: String = "",
val homePinnedRows: String = "",
val homeHiddenRows: String = "",
/** Tone used for the short welcome line after sign-in and during startup. */
val welcomeQuoteStyle: String = DEFAULT_WELCOME_QUOTE_STYLE,
/**
* User ids known to have finished recommendation onboarding on this TV. Cold start
* consults this instead of waiting on the gateway: onboarding is a one-time,
* monotonic fact, and blocking the launcher on a round-trip to re-learn it defeats
* the whole point of [homeCacheJson]. The gateway is still asked in the background
* and remains authoritative for anyone not listed here.
*/
val onboardedUserIds: Set<String> = emptySet(),
val profiles: List<EmbyProfile> = emptyList(),
) {
val isSignedIn: Boolean
@@ -68,11 +105,17 @@ data class Settings(
it.userId == userId && it.serverUrl == serverUrl
}?.id
/** True once this TV has seen the active profile finish onboarding. */
val hasCompletedOnboarding: Boolean
get() = userId?.let { it in onboardedUserIds } == true
companion object {
const val DEFAULT_ROTATION_SECONDS = 15
const val DEFAULT_RING_COLOR = "FFFFFF"
const val DEFAULT_HOME_SECTIONS = "continue,favorites,latest"
const val DEFAULT_HOME_CARD_DENSITY = "standard"
const val DEFAULT_HOME_ARTWORK_STYLE = "automatic"
const val DEFAULT_WELCOME_QUOTE_STYLE = "neutral"
val EMPTY = Settings()
}
}
@@ -88,6 +131,15 @@ data class EmbyProfile(
val homeCacheJson: String? = null,
val forYouMinutes: Int = 0,
val hasOpenedForYou: Boolean = false,
val welcomeQuoteStyle: String = Settings.DEFAULT_WELCOME_QUOTE_STYLE,
val homeSections: String = Settings.DEFAULT_HOME_SECTIONS,
val homeCardDensity: String = Settings.DEFAULT_HOME_CARD_DENSITY,
val homeArtworkStyle: String = Settings.DEFAULT_HOME_ARTWORK_STYLE,
val showHomeCardMetadata: Boolean = true,
val hideWatchedMovies: Boolean = false,
val homeRowOrder: String = "",
val homePinnedRows: String = "",
val homeHiddenRows: String = "",
)
class SettingsStore(private val context: Context) {
@@ -95,6 +147,14 @@ class SettingsStore(private val context: Context) {
@Volatile
private var latestSettings: Settings? = null
/** Last home cache actually written, so an unchanged refresh skips the disk entirely. */
@Volatile
private var lastPersistedHomeCache: String? = null
/** The decoded home cache, paired with the JSON it came from. See [primeHomeCache]. */
@Volatile
private var decodedHomeCache: Pair<String, HomeCache?>? = null
val current: Settings?
get() = latestSettings
@@ -113,6 +173,7 @@ class SettingsStore(private val context: Context) {
val UPDATE_TOKEN = stringPreferencesKey("update_token")
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 RING_COLOR = stringPreferencesKey("ring_color")
val LAST_BACKDROP_URL = stringPreferencesKey("last_backdrop_url")
val HOME_SECTIONS = stringPreferencesKey("home_sections")
@@ -120,9 +181,16 @@ class SettingsStore(private val context: Context) {
val FOR_YOU_MINUTES = intPreferencesKey("for_you_minutes")
val HAS_OPENED_FOR_YOU = booleanPreferencesKey("has_opened_for_you")
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 HIDE_WATCHED_MOVIES = booleanPreferencesKey("hide_watched_movies")
val HOME_ROW_ORDER = stringPreferencesKey("home_row_order")
val HOME_PINNED_ROWS = stringPreferencesKey("home_pinned_rows")
val HOME_HIDDEN_ROWS = stringPreferencesKey("home_hidden_rows")
val WELCOME_QUOTE_STYLE = stringPreferencesKey("welcome_quote_style")
val PROFILES = stringPreferencesKey("profiles")
val SEEN_ALERTS = stringPreferencesKey("seen_alert_ids")
val ONBOARDED_USERS = stringSetPreferencesKey("onboarded_user_ids")
}
/**
@@ -134,7 +202,24 @@ class SettingsStore(private val context: Context) {
val settingsFlow: Flow<Settings> = context.dataStore.data
.map(::settingsFrom)
.distinctUntilChanged()
.onEach { latestSettings = it }
.onEach {
latestSettings = it
// Decode the launcher's cached rows here, on this store's IO scope, rather
// than leaving it for HomeViewModel's constructor — which Compose runs on the
// main thread during the first composition, parsing a blob that can hold
// every row and every item in it. AppRoot does not compose HomeScreen until
// this flow has emitted, so by the time the view model asks, the answer is
// already in memory.
primeHomeCache(it)
}
// A throw here would complete the sharing coroutine, and a SharedFlow that has
// completed never emits again — every collector for the rest of the process waits
// forever, which the launcher renders as its loading screen. Nothing this pipeline
// does is worth that, so a failed read degrades to defaults and the app carries on
// to sign-in instead of hanging. The corruption handler on [dataStore] covers the
// parse failure; this covers everything else, including an I/O error on a TV whose
// storage is briefly unavailable.
.catch { emit(Settings.EMPTY.also { latestSettings = it }) }
.shareIn(scope, started = SharingStarted.Eagerly, replay = 1)
suspend fun setRotationIntervalSeconds(seconds: Int) {
@@ -189,6 +274,10 @@ class SettingsStore(private val context: Context) {
context.dataStore.edit { it[Keys.AUTO_PLAY_NEXT] = enabled }
}
suspend fun setShowTenMinuteReminder(enabled: Boolean) {
context.dataStore.edit { it[Keys.SHOW_TEN_MINUTE_REMINDER] = enabled }
}
suspend fun setRingColor(hex: String) {
context.dataStore.edit { it[Keys.RING_COLOR] = hex }
}
@@ -199,41 +288,140 @@ class SettingsStore(private val context: Context) {
suspend fun setHomeSections(sections: List<String>) {
val valid = sections.filter { it in setOf("continue", "favorites", "latest") }.distinct()
context.dataStore.edit {
it[Keys.HOME_SECTIONS] = valid.ifEmpty { listOf("favorites") }.joinToString(",")
val selected = valid.ifEmpty { listOf("favorites") }.joinToString(",")
context.dataStore.edit { preferences ->
preferences[Keys.HOME_SECTIONS] = selected
updateActiveProfile(preferences) { it.copy(homeSections = selected) }
}
}
suspend fun setHomeCardDensity(density: String) {
context.dataStore.edit {
it[Keys.HOME_CARD_DENSITY] = density.takeIf { value -> value in setOf("compact", "standard", "large") }
?: Settings.DEFAULT_HOME_CARD_DENSITY
val selected = density.takeIf { it in setOf("compact", "standard", "large") }
?: Settings.DEFAULT_HOME_CARD_DENSITY
context.dataStore.edit { preferences ->
preferences[Keys.HOME_CARD_DENSITY] = selected
updateActiveProfile(preferences) { it.copy(homeCardDensity = selected) }
}
}
suspend fun setHomeArtworkStyle(style: String) {
val selected = style.takeIf { it in setOf("automatic", "poster", "backdrop") }
?: Settings.DEFAULT_HOME_ARTWORK_STYLE
context.dataStore.edit { preferences ->
preferences[Keys.HOME_ARTWORK_STYLE] = selected
updateActiveProfile(preferences) { it.copy(homeArtworkStyle = selected) }
}
}
suspend fun setShowHomeCardMetadata(show: Boolean) {
context.dataStore.edit { it[Keys.SHOW_HOME_CARD_METADATA] = show }
context.dataStore.edit { preferences ->
preferences[Keys.SHOW_HOME_CARD_METADATA] = show
updateActiveProfile(preferences) { it.copy(showHomeCardMetadata = show) }
}
}
suspend fun setHomeCache(cache: HomeCache) {
suspend fun setHideWatchedMovies(hide: Boolean) {
context.dataStore.edit { preferences ->
val encodedCache = Json.encodeToString(cache)
preferences[Keys.HOME_CACHE] = encodedCache
val activeUserId = preferences[Keys.USER_ID]
val activeServer = preferences[Keys.SERVER_URL]
val profiles = profilesFrom(preferences).map { profile ->
if (profile.userId == activeUserId && profile.serverUrl == activeServer) {
profile.copy(homeCacheJson = encodedCache)
} else {
profile
}
}
if (profiles.isNotEmpty()) {
preferences[Keys.PROFILES] = Json.encodeToString(profiles)
preferences[Keys.HIDE_WATCHED_MOVIES] = hide
updateActiveProfile(preferences) { it.copy(hideWatchedMovies = hide) }
}
}
suspend fun setHomeRowPreferences(
order: List<String>,
pinned: Set<String>,
hidden: Set<String>,
) {
fun encode(values: Iterable<String>): String = values
.map(String::trim)
.filter { it.isNotEmpty() && '\n' !in it }
.distinct()
.joinToString("\n")
val encodedOrder = encode(order)
val encodedPinned = encode(pinned)
val encodedHidden = encode(hidden - pinned)
context.dataStore.edit { preferences ->
preferences[Keys.HOME_ROW_ORDER] = encodedOrder
preferences[Keys.HOME_PINNED_ROWS] = encodedPinned
preferences[Keys.HOME_HIDDEN_ROWS] = encodedHidden
updateActiveProfile(preferences) {
it.copy(
homeRowOrder = encodedOrder,
homePinnedRows = encodedPinned,
homeHiddenRows = encodedHidden,
)
}
}
}
suspend fun setWelcomeQuoteStyle(style: String) {
val selected = style.takeIf { it in setOf("neutral", "positive", "homicidal") }
?: Settings.DEFAULT_WELCOME_QUOTE_STYLE
context.dataStore.edit { preferences ->
preferences[Keys.WELCOME_QUOTE_STYLE] = selected
updateActiveProfile(preferences) { it.copy(welcomeQuoteStyle = selected) }
}
}
/**
* Persists the launcher's rows so the next cold start can draw before the network
* answers.
*
* This used to encode the cache, then re-encode the entire profiles blob with a copy
* of that same JSON embedded in the matching profile — and Preferences DataStore
* rewrites and fsyncs the whole file on every edit, so each call wrote the cache
* twice over, plus a copy for every other profile that was already carrying one. It
* runs on every home refresh and again on every playback stop, which is exactly when
* the viewer is watching the launcher redraw.
*
* Now each profile's cache lives under its own key ([profileHomeCacheKey]) and the
* 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)
// 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
context.dataStore.edit { preferences ->
val profileKey = profileHomeCacheKey(
userId = preferences[Keys.USER_ID],
serverUrl = preferences[Keys.SERVER_URL],
)
if (profileKey == null) {
// No active profile to key against; the flat slot is all there is.
preferences[Keys.HOME_CACHE] = encodedCache
} else {
// Exactly one copy. Writing the flat key as well put the largest value
// this store holds into the file twice, and DataStore rewrites and fsyncs
// the whole file on every edit — so each home refresh was paying double
// for a value nothing read twice. [activeHomeCache] reads this key first.
preferences[profileKey] = encodedCache
preferences.remove(Keys.HOME_CACHE)
}
}
lastPersistedHomeCache = encodedCache
}
/**
* Where a given profile's home cache is stored. Null when there is no active profile
* to key it against, in which case only the flat [Keys.HOME_CACHE] is written.
*/
private fun profileHomeCacheKey(userId: String?, serverUrl: String?): Preferences.Key<String>? {
if (userId.isNullOrBlank() || serverUrl.isNullOrBlank()) return null
return stringPreferencesKey("home_cache::$userId@$serverUrl")
}
/**
* The active profile's cached rows. The per-profile key is authoritative; the flat
* [Keys.HOME_CACHE] slot survives only for installs written before the split and for
* the case where there is no profile to key against.
*/
private fun activeHomeCache(preferences: Preferences): String? =
profileHomeCacheKey(
userId = preferences[Keys.USER_ID],
serverUrl = preferences[Keys.SERVER_URL],
)?.let { preferences[it] } ?: preferences[Keys.HOME_CACHE]
suspend fun setForYouMinutes(minutes: Int) {
val selected = minutes.takeIf { it in setOf(0, 30, 60, 120) } ?: 0
context.dataStore.edit { preferences ->
@@ -242,6 +430,21 @@ class SettingsStore(private val context: Context) {
}
}
/**
* Records that a profile has finished recommendation onboarding, so the next cold
* start can go straight to the launcher instead of waiting on the gateway to say so.
* Additive only — nothing here ever un-onboards a user, because the failure mode of a
* wrongly-cleared flag (a returning viewer shown the rating screen again) is worse
* than the failure mode of a stale one (a request the gateway answers anyway).
*/
suspend fun markOnboardingCompleted(userId: String) {
val trimmed = userId.trim()
if (trimmed.isEmpty()) return
context.dataStore.edit { preferences ->
preferences[Keys.ONBOARDED_USERS] = preferences[Keys.ONBOARDED_USERS].orEmpty() + trimmed
}
}
suspend fun markForYouOpened() {
context.dataStore.edit { preferences ->
preferences[Keys.HAS_OPENED_FOR_YOU] = true
@@ -263,14 +466,56 @@ class SettingsStore(private val context: Context) {
}
}
if (profiles.isNotEmpty()) {
preferences[Keys.PROFILES] = Json.encodeToString(profiles)
writeProfiles(preferences, profiles)
}
}
fun homeCache(settings: Settings): HomeCache? = settings.homeCacheJson?.let {
runCatching { Json.decodeFromString<HomeCache>(it) }.getOrNull()
/**
* The single writer for the profiles blob. Strips each profile's home cache out into
* its own key, so this blob stays small enough that the many settings toggles which
* rewrite it — every one of which rewrites the whole DataStore file — cost nothing
* proportional to the size of the library.
*
* The strip doubles as the migration: an install whose blob still embeds a cache has
* it moved across the first time anything rewrites the blob, so a profile switch
* still restores that profile's rows.
*/
private fun writeProfiles(preferences: MutablePreferences, profiles: List<EmbyProfile>) {
val stripped = profiles.map { profile ->
profile.homeCacheJson?.takeIf { it.isNotBlank() }?.let { embedded ->
profileHomeCacheKey(profile.userId, profile.serverUrl)?.let { key ->
if (preferences[key] == null) preferences[key] = embedded
}
}
if (profile.homeCacheJson == null) profile else profile.copy(homeCacheJson = null)
}
preferences[Keys.PROFILES] = Json.encodeToString(stripped)
}
/**
* The launcher's cached rows. Returns the copy decoded by [primeHomeCache] when the
* JSON has not changed since, so the common path is a reference comparison rather
* than a parse — see the note on [settingsFlow].
*/
fun homeCache(settings: Settings): HomeCache? {
val json = settings.homeCacheJson ?: return null
decodedHomeCache?.let { (source, decoded) -> if (source == json) return decoded }
return decodeHomeCache(json).also { decodedHomeCache = json to it }
}
private fun primeHomeCache(settings: Settings) {
val json = settings.homeCacheJson
if (json == null) {
decodedHomeCache = null
return
}
if (decodedHomeCache?.first == json) return
decodedHomeCache = json to decodeHomeCache(json)
}
private fun decodeHomeCache(json: String): HomeCache? =
runCatching { Json.decodeFromString<HomeCache>(json) }.getOrNull()
/**
* Reads DataStore directly instead of taking the replayed flow value. This matters
* immediately after an edit, when the replay slot may still contain the prior value.
@@ -302,10 +547,21 @@ class SettingsStore(private val context: Context) {
homeCacheJson = previous?.homeCacheJson,
forYouMinutes = previous?.forYouMinutes ?: 0,
hasOpenedForYou = previous?.hasOpenedForYou ?: false,
welcomeQuoteStyle = previous?.welcomeQuoteStyle
?: Settings.DEFAULT_WELCOME_QUOTE_STYLE,
homeSections = previous?.homeSections ?: Settings.DEFAULT_HOME_SECTIONS,
homeCardDensity = previous?.homeCardDensity ?: Settings.DEFAULT_HOME_CARD_DENSITY,
homeArtworkStyle = previous?.homeArtworkStyle
?: Settings.DEFAULT_HOME_ARTWORK_STYLE,
showHomeCardMetadata = previous?.showHomeCardMetadata ?: true,
hideWatchedMovies = previous?.hideWatchedMovies ?: false,
homeRowOrder = previous?.homeRowOrder.orEmpty(),
homePinnedRows = previous?.homePinnedRows.orEmpty(),
homeHiddenRows = previous?.homeHiddenRows.orEmpty(),
)
profiles.removeAll { it.id == id }
profiles.add(profile)
preferences[Keys.PROFILES] = Json.encodeToString(profiles)
writeProfiles(preferences, profiles)
applyProfile(preferences, profile)
}
}
@@ -315,12 +571,30 @@ class SettingsStore(private val context: Context) {
val profiles = profilesFrom(preferences).toMutableList()
if (profiles.none { it.id == profile.id }) {
profiles.add(profile)
preferences[Keys.PROFILES] = Json.encodeToString(profiles)
writeProfiles(preferences, profiles)
}
applyProfile(preferences, profile)
}
}
/** Forgets a saved profile on this device. */
suspend fun removeProfile(profileId: String) {
context.dataStore.edit { preferences ->
val profiles = profilesFrom(preferences)
val removed = profiles.firstOrNull { it.id == profileId } ?: return@edit
writeProfiles(preferences, profiles.filterNot { it.id == profileId })
// Forget the departing profile's cached rows too; nothing will read that key
// again and it is the largest single value this store holds.
profileHomeCacheKey(removed.userId, removed.serverUrl)?.let(preferences::remove)
if (
preferences[Keys.USER_ID] == removed.userId &&
preferences[Keys.SERVER_URL] == removed.serverUrl
) {
clearActiveSession(preferences)
}
}
}
suspend fun clearSession() {
context.dataStore.edit {
clearActiveSession(it)
@@ -340,7 +614,8 @@ class SettingsStore(private val context: Context) {
val remaining = profilesFrom(preferences).filterNot {
it.userId == activeUserId && it.serverUrl == activeServer
}
preferences[Keys.PROFILES] = Json.encodeToString(remaining)
writeProfiles(preferences, remaining)
profileHomeCacheKey(activeUserId, activeServer)?.let(preferences::remove)
clearActiveSession(preferences)
}
}
@@ -352,8 +627,18 @@ class SettingsStore(private val context: Context) {
preferences.remove(Keys.SERVER_ID)
preferences.remove(Keys.LAST_BACKDROP_URL)
preferences.remove(Keys.HOME_CACHE)
lastPersistedHomeCache = null
preferences.remove(Keys.FOR_YOU_MINUTES)
preferences.remove(Keys.HAS_OPENED_FOR_YOU)
preferences.remove(Keys.WELCOME_QUOTE_STYLE)
preferences.remove(Keys.HOME_SECTIONS)
preferences.remove(Keys.HOME_CARD_DENSITY)
preferences.remove(Keys.HOME_ARTWORK_STYLE)
preferences.remove(Keys.SHOW_HOME_CARD_METADATA)
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.USERNAME)
}
@@ -364,10 +649,36 @@ class SettingsStore(private val context: Context) {
preferences[Keys.USERNAME] = profile.username
if (profile.serverId.isNullOrBlank()) preferences.remove(Keys.SERVER_ID)
else preferences[Keys.SERVER_ID] = profile.serverId
if (profile.homeCacheJson.isNullOrBlank()) preferences.remove(Keys.HOME_CACHE)
else preferences[Keys.HOME_CACHE] = profile.homeCacheJson
// Prefer the profile's dedicated cache key; fall back to a copy embedded in the
// profiles blob, which is how installs predating the split stored it — and move
// that copy across, so the migration completes here too.
val profileKey = profileHomeCacheKey(profile.userId, profile.serverUrl)
val cached = profileKey?.let { preferences[it] } ?: profile.homeCacheJson
if (profileKey != null) {
if (preferences[profileKey] == null && !cached.isNullOrBlank()) {
preferences[profileKey] = cached
}
// Reads go through [activeHomeCache], which prefers the profile key; keeping a
// second copy in the flat slot only inflates the file.
preferences.remove(Keys.HOME_CACHE)
} else if (cached.isNullOrBlank()) {
preferences.remove(Keys.HOME_CACHE)
} else {
preferences[Keys.HOME_CACHE] = cached
}
// The active cache belongs to a different profile now.
lastPersistedHomeCache = cached
preferences[Keys.FOR_YOU_MINUTES] = profile.forYouMinutes
preferences[Keys.HAS_OPENED_FOR_YOU] = profile.hasOpenedForYou
preferences[Keys.WELCOME_QUOTE_STYLE] = profile.welcomeQuoteStyle
preferences[Keys.HOME_SECTIONS] = profile.homeSections
preferences[Keys.HOME_CARD_DENSITY] = profile.homeCardDensity
preferences[Keys.HOME_ARTWORK_STYLE] = profile.homeArtworkStyle
preferences[Keys.SHOW_HOME_CARD_METADATA] = profile.showHomeCardMetadata
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.remove(Keys.LAST_BACKDROP_URL)
}
@@ -383,9 +694,21 @@ class SettingsStore(private val context: Context) {
userId = userId,
username = username,
serverId = preferences[Keys.SERVER_ID],
homeCacheJson = preferences[Keys.HOME_CACHE],
homeCacheJson = activeHomeCache(preferences),
forYouMinutes = preferences[Keys.FOR_YOU_MINUTES] ?: 0,
hasOpenedForYou = preferences[Keys.HAS_OPENED_FOR_YOU] ?: false,
welcomeQuoteStyle = preferences[Keys.WELCOME_QUOTE_STYLE]
?: Settings.DEFAULT_WELCOME_QUOTE_STYLE,
homeSections = preferences[Keys.HOME_SECTIONS] ?: Settings.DEFAULT_HOME_SECTIONS,
homeCardDensity = preferences[Keys.HOME_CARD_DENSITY]
?: Settings.DEFAULT_HOME_CARD_DENSITY,
homeArtworkStyle = preferences[Keys.HOME_ARTWORK_STYLE]
?: Settings.DEFAULT_HOME_ARTWORK_STYLE,
showHomeCardMetadata = preferences[Keys.SHOW_HOME_CARD_METADATA] ?: 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(),
)
}
@@ -413,14 +736,24 @@ class SettingsStore(private val context: Context) {
updateToken = preferences[Keys.UPDATE_TOKEN],
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,
lastBackdropUrl = preferences[Keys.LAST_BACKDROP_URL],
homeSections = preferences[Keys.HOME_SECTIONS] ?: Settings.DEFAULT_HOME_SECTIONS,
homeCacheJson = preferences[Keys.HOME_CACHE],
homeCacheJson = activeHomeCache(preferences),
forYouMinutes = preferences[Keys.FOR_YOU_MINUTES] ?: 0,
hasOpenedForYou = preferences[Keys.HAS_OPENED_FOR_YOU] ?: false,
homeCardDensity = preferences[Keys.HOME_CARD_DENSITY] ?: Settings.DEFAULT_HOME_CARD_DENSITY,
homeArtworkStyle = preferences[Keys.HOME_ARTWORK_STYLE]
?: Settings.DEFAULT_HOME_ARTWORK_STYLE,
showHomeCardMetadata = preferences[Keys.SHOW_HOME_CARD_METADATA] ?: 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(),
welcomeQuoteStyle = preferences[Keys.WELCOME_QUOTE_STYLE]
?: Settings.DEFAULT_WELCOME_QUOTE_STYLE,
onboardedUserIds = preferences[Keys.ONBOARDED_USERS].orEmpty(),
profiles = profiles,
)
}
@@ -27,16 +27,31 @@ class RowAnalytics(
private val lock = Any()
private val buffer = ArrayList<GatewayRowEvent>()
private val impressed = HashSet<String>()
private val impressedItems = HashSet<String>()
private var focusedRowId: String? = null
private var focusedRowKind: String = ""
private var focusStartedAt: Long = 0
/** Records that a row was drawn. Repeats are ignored until [reset]. */
fun rowImpression(rowId: String, rowKind: String) {
fun rowImpression(rowId: String, rowKind: String, visibleItemIds: List<String> = emptyList()) {
synchronized(lock) {
if (!impressed.add(rowId)) return
add(GatewayRowEvent(rowId = rowId, rowKind = rowKind, event = EVENT_IMPRESSION, occurredAt = timestamp()))
if (impressed.add(rowId)) {
add(GatewayRowEvent(rowId = rowId, rowKind = rowKind, event = EVENT_IMPRESSION, occurredAt = timestamp()))
}
visibleItemIds.filter(String::isNotBlank).forEach { itemId ->
if (impressedItems.add("$rowId:$itemId")) {
add(
GatewayRowEvent(
rowId = rowId,
rowKind = rowKind,
event = EVENT_IMPRESSION,
itemId = itemId,
occurredAt = timestamp(),
),
)
}
}
}
}
@@ -99,6 +114,7 @@ class RowAnalytics(
synchronized(lock) {
buffer.clear()
impressed.clear()
impressedItems.clear()
focusedRowId = null
}
}
@@ -1,5 +1,8 @@
package com.ponzischeme89.memby.data.model
import com.ponzischeme89.memby.data.playback.DevicePlaybackCapabilities
import com.ponzischeme89.memby.data.playback.VideoDecoderCapabilities
import com.ponzischeme89.memby.data.playback.devicePlaybackCapabilities
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@@ -33,6 +36,7 @@ data class UserItemData(
@SerialName("IsFavorite") val isFavorite: Boolean = false,
@SerialName("Played") val played: Boolean = false,
@SerialName("PlaybackPositionTicks") val playbackPositionTicks: Long = 0,
@SerialName("UnplayedItemCount") val unplayedItemCount: Int? = null,
)
@Serializable
@@ -53,6 +57,11 @@ data class PlaybackInfoRequest(
@SerialName("Id") val id: String,
@SerialName("UserId") val userId: String,
@SerialName("IsPlayback") val isPlayback: Boolean = true,
@SerialName("EnableDirectPlay") val enableDirectPlay: Boolean = true,
@SerialName("EnableDirectStream") val enableDirectStream: Boolean = true,
@SerialName("EnableTranscoding") val enableTranscoding: Boolean = true,
@SerialName("AllowVideoStreamCopy") val allowVideoStreamCopy: Boolean = true,
@SerialName("AllowAudioStreamCopy") val allowAudioStreamCopy: Boolean = true,
@SerialName("StartTimeTicks") val startTimeTicks: Long = 0,
@SerialName("SubtitleStreamIndex") val subtitleStreamIndex: Int? = null,
@SerialName("CurrentPlaySessionId") val currentPlaySessionId: String? = null,
@@ -66,9 +75,12 @@ data class DeviceProfile(
@SerialName("SubtitleProfiles") val subtitleProfiles: List<SubtitleProfile>,
@SerialName("DirectPlayProfiles") val directPlayProfiles: List<DirectPlayProfile>,
@SerialName("TranscodingProfiles") val transcodingProfiles: List<TranscodingProfile>,
@SerialName("CodecProfiles") val codecProfiles: List<CodecProfile> = emptyList(),
) {
companion object {
fun embyAndroidTv() = DeviceProfile(
fun embyAndroidTv(
capabilities: DevicePlaybackCapabilities = devicePlaybackCapabilities,
) = DeviceProfile(
name = "Memby Android TV",
subtitleProfiles = listOf(
"srt", "subrip", "ass", "ssa", "vtt", "webvtt", "mov_text", "tx3g",
@@ -77,23 +89,143 @@ data class DeviceProfile(
).map { SubtitleProfile(it, "Encode") },
directPlayProfiles = listOf(
DirectPlayProfile(
container = "mkv,mp4,m4v,mov,webm,ts,mpegts,avi",
videoCodec = "h264,hevc,vp8,vp9,av1,mpeg2video,mpeg4",
audioCodec = "aac,ac3,eac3,mp3,opus,vorbis,flac,pcm",
// The broad codec declaration is bounded by CodecProfiles below.
container = "mkv,mp4,m4v,mov,ts,mpegts",
videoCodec = directPlayVideoCodecs(capabilities),
audioCodec = "aac,mp3",
),
),
transcodingProfiles = listOf(
TranscodingProfile(
container = "ts",
videoCodec = "h264",
// Permits Emby to remux a supported HEVC/H.264 video stream while
// converting only incompatible audio or subtitles.
videoCodec = directPlayVideoCodecs(capabilities),
audioCodec = "aac",
protocol = "hls",
),
),
codecProfiles = codecProfiles(capabilities),
)
/** Compatibility for callers and older tests that only know the HEVC boolean. */
fun embyAndroidTv(supportsHevc: Boolean): DeviceProfile = embyAndroidTv(
DevicePlaybackCapabilities(
hevc = VideoDecoderCapabilities(
supported = supportsHevc,
profiles = if (supportsHevc) setOf("main") else emptySet(),
),
),
)
private fun directPlayVideoCodecs(capabilities: DevicePlaybackCapabilities): String =
if (capabilities.hevc.supported) "h264,hevc" else "h264"
private fun codecProfiles(capabilities: DevicePlaybackCapabilities): List<CodecProfile> =
buildList {
addVideoProfiles("h264", capabilities.h264)
addVideoProfiles("hevc", capabilities.hevc)
}
private fun MutableList<CodecProfile>.addVideoProfiles(
codec: String,
capability: VideoDecoderCapabilities,
) {
if (!capability.supported) return
val allowedProfiles = when (codec) {
"h264" -> capability.profiles.mapNotNull {
when (it) {
"baseline" -> "baseline"
"constrained_baseline" -> "constrained baseline"
"main" -> "main"
"high" -> "high"
"high10" -> "high 10"
else -> null
}
}
else -> capability.profiles.mapNotNull {
when (it) {
"main" -> "main"
"main10" -> "main 10"
else -> null
}
}
}
if (allowedProfiles.isNotEmpty()) {
add(
CodecProfile(
codec = codec,
conditions = listOf(
ProfileCondition("EqualsAny", "VideoProfile", allowedProfiles.joinToString("|")),
),
),
)
}
if (capability.mainLevel > 0) {
add(
CodecProfile(
codec = codec,
conditions = listOf(
ProfileCondition("LessThanEqual", "VideoLevel", capability.mainLevel.toString()),
),
applyConditions = listOf(
ProfileCondition(
"EqualsAny",
"VideoProfile",
if (codec == "h264") "baseline|constrained baseline|main|high" else "main",
),
),
),
)
}
if (capability.tenBitLevel > 0) {
add(
CodecProfile(
codec = codec,
conditions = listOf(
ProfileCondition("LessThanEqual", "VideoLevel", capability.tenBitLevel.toString()),
),
applyConditions = listOf(
ProfileCondition(
"Equals",
"VideoProfile",
if (codec == "h264") "high 10" else "main 10",
),
),
),
)
}
if (capability.maxWidth > 0 && capability.maxHeight > 0) {
add(
CodecProfile(
codec = codec,
conditions = listOf(
ProfileCondition("LessThanEqual", "Width", capability.maxWidth.toString()),
ProfileCondition("LessThanEqual", "Height", capability.maxHeight.toString()),
),
),
)
}
}
}
}
@Serializable
data class CodecProfile(
@SerialName("Type") val type: String = "Video",
@SerialName("Codec") val codec: String,
@SerialName("Conditions") val conditions: List<ProfileCondition>,
@SerialName("ApplyConditions") val applyConditions: List<ProfileCondition> = emptyList(),
)
@Serializable
data class ProfileCondition(
@SerialName("Condition") val condition: String,
@SerialName("Property") val property: String,
@SerialName("Value") val value: String,
@SerialName("IsRequired") val isRequired: Boolean = false,
)
@Serializable
data class DirectPlayProfile(
@SerialName("Container") val container: String,
@@ -156,6 +288,9 @@ data class PlaybackInfo(
data class MediaSourceInfo(
@SerialName("Id") val id: String = "",
@SerialName("MediaStreams") val mediaStreams: List<MediaStream> = emptyList(),
@SerialName("SupportsDirectPlay") val supportsDirectPlay: Boolean? = null,
@SerialName("SupportsDirectStream") val supportsDirectStream: Boolean? = null,
@SerialName("SupportsTranscoding") val supportsTranscoding: Boolean? = null,
@SerialName("DirectStreamUrl") val directStreamUrl: String? = null,
@SerialName("TranscodingUrl") val transcodingUrl: String? = null,
)
@@ -183,7 +318,9 @@ data class BaseItem(
@SerialName("CommunityRating") val communityRating: Double? = null,
@SerialName("Studios") val studios: List<Studio> = emptyList(),
@SerialName("RunTimeTicks") val runTimeTicks: Long? = null,
@SerialName("RecursiveItemCount") val recursiveItemCount: Int? = null,
@SerialName("Genres") val genres: List<String> = emptyList(),
@SerialName("CollectionName") val collectionName: String? = null,
@SerialName("MediaStreams") val mediaStreams: List<MediaStream> = emptyList(),
@SerialName("People") val people: List<EmbyPerson> = emptyList(),
@SerialName("PrimaryImageAspectRatio") val primaryImageAspectRatio: Double? = null,
@@ -205,21 +342,32 @@ data class BaseItem(
@SerialName("MembyEpisodeCode") val membyEpisodeCode: String? = null,
@SerialName("MembyAirsAt") val membyAirsAt: String? = null,
@SerialName("MembyAddedAt") val membyAddedAt: String? = null,
@SerialName("MembyAirDayLabel") val membyAirDayLabel: String? = null,
@SerialName("MembyAirLabel") val membyAirLabel: String? = null,
@SerialName("MembyAvailability") val membyAvailability: String? = null,
@SerialName("MembyAvailabilityText") val membyAvailabilityText: String? = null,
@SerialName("MembyPlayable") val membyPlayable: Boolean = true,
// Derived by the TV from the Sonarr schedule row and retained in the local home cache.
// 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.
@SerialName("MembyRecommendationReason") val membyRecommendationReason: String? = null,
@SerialName("MembyCompatibility") val membyCompatibility: String? = null,
// Backend diagnostics for the shared explainable ranker. These remain optional so
// direct-to-Emby mode and older cached payloads decode unchanged.
@SerialName("MembyRecommendationScore") val membyRecommendationScore: Double? = null,
@SerialName("MembyRecommendationComponents")
val membyRecommendationComponents: Map<String, Double> = emptyMap(),
@SerialName("MembyRecommendationReasonCodes")
val membyRecommendationReasonCodes: List<String> = emptyList(),
@SerialName("MembyExploration") val membyExploration: Boolean = false,
) {
val isMovie: Boolean get() = type.equals("Movie", ignoreCase = true)
val isSeries: Boolean get() = type.equals("Series", ignoreCase = true)
val isEpisode: Boolean get() = type.equals("Episode", ignoreCase = true)
val isFavorite: Boolean get() = userData?.isFavorite == true
val isSonarrSchedule: Boolean get() = membySource == "sonarr"
val isTvSchedule: Boolean get() = membySource == "sonarr"
val isMovieSchedule: Boolean get() = membySource == "radarr"
val isSchedule: Boolean get() = isTvSchedule || isMovieSchedule
val cast: List<EmbyPerson> get() = people.filter(EmbyPerson::isCastMember)
/** "S2 · E5" when the season is known, "E5" when only the episode is, else null. */
@@ -24,23 +24,25 @@ data class GatewayLoginResponse(
val userId: String = "",
val username: String = "",
val serverId: String = "",
val activeClients: Int = 0,
val maxClientsPerUser: Int = 0,
)
@Serializable
data class GatewayAuthPolicy(
val maxClientsPerUser: Int = 0,
data class GatewayDevice(
val deviceId: String,
val deviceName: String,
val clientVersion: String = "",
val lastSeenAt: String = "",
val current: Boolean = false,
)
@Serializable
data class GatewayAuthError(
val error: String = "",
val message: String = "",
val activeClients: Int = 0,
val maxClientsPerUser: Int = 0,
data class GatewayDevices(
val devices: List<GatewayDevice> = emptyList(),
)
@Serializable
data class GatewayDeviceNameRequest(val deviceName: String)
/**
* One horizontal strip, described entirely by the server.
*
@@ -76,8 +78,8 @@ data class GatewayHome(
/**
* The gateway's verdict on this build, from `GET /v1/update`.
*
* [status] is `none`, `optional` or `mandatory`. Mandatory blocks the home screen — the
* operator has decided this version may no longer be used.
* [status] is `none`, `optional` or `mandatory`. Mandatory blocks the app before login or
* home is composed — the operator has decided this version may no longer be used.
*/
@Serializable
data class GatewayUpdate(
@@ -85,6 +87,8 @@ data class GatewayUpdate(
val version: String = "",
val notes: String = "",
val downloadUrl: String = "",
val sha256: String = "",
val sizeBytes: Long = 0,
) {
val isMandatory: Boolean get() = status == STATUS_MANDATORY
val isOptional: Boolean get() = status == STATUS_OPTIONAL
@@ -110,18 +114,47 @@ data class GatewayServiceStatus(
val clientVersion: String = "",
val clientProtocol: String = "",
val serverProtocol: Int = 0,
val featureSchemaVersion: Int = 0,
val featureRevision: Long = 0,
val safeMode: Boolean = false,
val features: Map<String, Boolean> = emptyMap(),
)
@Serializable
data class GatewayFeature(
val key: String,
val name: String = "",
val description: String = "",
val area: String = "",
val enabled: Boolean = false,
val source: String = "default",
val compatible: Boolean = true,
val minimumProtocol: Int = 0,
val capability: String = "",
val recovery: String = "",
)
@Serializable
data class GatewayFeatures(
val schemaVersion: Int = 0,
val revision: Long = 0,
val safeMode: Boolean = false,
val canRollback: Boolean = false,
val features: List<GatewayFeature> = emptyList(),
)
/**
* An informational nudge riding along on the status poll — currently "this episode aired
* and is on its way into Emby". It is never actionable: the banner slides in, states the
* news and leaves. Unknown [kind] values still render, so the server can add one without
* an app release.
* An informational nudge riding along on the status poll — "this episode aired and is on
* its way into Emby", or "Radarr just imported this film". It is never actionable: the
* banner slides in, states the news and leaves. Unknown [kind] values still render, and
* the server supplies the wording, so a new kind of news needs no app release.
*/
@Serializable
data class GatewayAlert(
val id: String = "",
val kind: String = "",
/** Eyebrow text for the banner ("JUST AIRED", "NEW MOVIE ADDED"). May be absent. */
val label: String = "",
val title: String = "",
val message: String = "",
val itemId: String = "",
@@ -135,16 +168,70 @@ data class GatewayRows(
val rows: List<HomeRow> = emptyList(),
)
@Serializable
data class RecommendationOnboarding(
val completed: Boolean = false,
val ratings: Map<String, Int> = emptyMap(),
val items: List<BaseItem> = emptyList(),
)
@Serializable
data class RecommendationPreferences(
val ratings: Map<String, Int> = emptyMap(),
)
@Serializable
data class GatewayItems(
val items: List<BaseItem> = emptyList(),
)
/**
* Response of `GET /v1/items/{id}/related` — the detail page's two additions.
*
* [reasons] are short phrases from the recommendation engine explaining *this viewer's*
* relationship to the title ("Because you watch Thriller"); [items] is the carousel of
* what else is like it. Either half may be empty: a cold profile has no reasons to give,
* and an obscure title has nothing beside it.
*/
@Serializable
data class GatewayRelated(
val reasons: List<String> = emptyList(),
val items: List<BaseItem> = emptyList(),
)
@Serializable
data class GatewaySearchHistory(
val queries: List<String> = emptyList(),
)
@Serializable
data class GatewayRequestCandidate(
val mediaType: String,
val foreignId: Int,
val title: String,
val year: Int = 0,
val overview: String = "",
val posterUrl: String = "",
val alreadyAdded: Boolean = false,
)
@Serializable
data class GatewayRequestLookup(
val candidates: List<GatewayRequestCandidate> = emptyList(),
)
@Serializable
data class GatewayMediaRequest(
val mediaType: String,
val foreignId: Int,
)
@Serializable
data class GatewayMediaRequestResult(
val status: String = "",
val title: String = "",
)
@Serializable
data class GatewayPrerollSchedule(
val today: List<GatewayPrerollEntry> = emptyList(),
@@ -153,6 +240,8 @@ data class GatewayPrerollSchedule(
@Serializable
data class GatewayPrerollEntry(
val itemId: String = "",
val imageType: String = "",
val series: String = "",
val episode: String = "",
val episodeCode: String = "",
@@ -164,6 +253,12 @@ data class GatewayPrerollEntry(
data class GatewayPlayback(
val itemId: String,
val title: String = "",
val overview: String = "",
val seriesName: String = "",
val episodeCode: String = "",
val runtimeMs: Long = 0,
val prerollEnabled: Boolean = true,
val prerollDurationMs: Long = 6_500L,
val url: String,
val resumePositionMs: Long = 0,
val subtitles: List<com.ponzischeme89.memby.data.PlayableSubtitle> = emptyList(),
@@ -188,11 +283,72 @@ data class GatewayNextEpisode(
val playMethod: String = "DirectPlay",
)
@Serializable
data class GatewaySeasonFinale(
val seasonFinale: Boolean = false,
val seriesName: String = "",
val seasonNumber: Int = 0,
val episodeNumber: Int = 0,
)
@Serializable
data class GatewayFlagRequest(
val value: Boolean,
)
@Serializable
data class MyShow(
val itemId: String,
val title: String,
val year: Int? = null,
val imageTag: String = "",
val addedAt: String = "",
val sonarrStatus: String = "Not found",
val nextEpisode: String? = null,
val lifecycle: String = "Unknown",
val monitored: Boolean = false,
)
@Serializable
data class MyShowsResponse(
val shows: List<MyShow> = emptyList(),
)
@Serializable
data class SaveMyShowRequest(
val itemId: String,
val title: String,
val year: Int? = null,
val imageTag: String = "",
)
@Serializable
data class NotificationPreferences(
val enabled: Boolean = true,
val showReturnAlerts: Boolean = true,
val leadDays: Int = 7,
)
@Serializable
data class UserNotification(
val id: Long,
val kind: String = "",
val itemId: String = "",
val title: String = "",
val message: String = "",
val eventAt: String? = null,
val createdAt: String = "",
val readAt: String? = null,
) {
val unread: Boolean get() = readAt.isNullOrBlank()
}
@Serializable
data class NotificationsResponse(
val notifications: List<UserNotification> = emptyList(),
val preferences: NotificationPreferences = NotificationPreferences(),
)
/** One row-engagement event. See `data/analytics/RowAnalytics.kt`. */
@Serializable
data class GatewayRowEvent(
@@ -213,9 +369,15 @@ data class GatewayRowEvents(
data class GatewayPlaybackReport(
val itemId: String,
val positionMs: Long,
val durationMs: Long = 0,
val isPaused: Boolean = false,
val mediaSourceId: String = "",
val playSessionId: String = "",
val playMethod: String = "DirectPlay",
val eventName: String? = null,
)
@Serializable
data class GatewayPlaybackReportResponse(
val autoFollowedShowTitle: String = "",
)
@@ -0,0 +1,224 @@
/*
* Playback capability probing adapted from Wholphin's
* MediaCodecCapabilitiesTest.kt, which is itself derived from Jellyfin Android TV.
*
* Wholphin: https://github.com/damontecres/Wholphin
* Jellyfin Android TV: https://github.com/jellyfin/jellyfin-androidtv
*
* Modifications Copyright (C) 2026 Memby contributors
* SPDX-License-Identifier: GPL-2.0-only
*/
package com.ponzischeme89.memby.data.playback
import android.media.MediaCodecInfo.CodecProfileLevel
import android.media.MediaCodecList
import android.media.MediaFormat
import android.os.Build
data class VideoDecoderCapabilities(
val supported: Boolean = false,
val profiles: Set<String> = emptySet(),
val mainLevel: Int = 0,
val tenBitLevel: Int = 0,
val maxWidth: Int = 0,
val maxHeight: Int = 0,
val hdr10: Boolean = false,
val hdr10Plus: Boolean = false,
val dolbyVision: Boolean = false,
)
data class DevicePlaybackCapabilities(
val h264: VideoDecoderCapabilities = VideoDecoderCapabilities(supported = true),
val hevc: VideoDecoderCapabilities = VideoDecoderCapabilities(),
)
/**
* The expensive platform query is performed once, off the UI thread on first network or
* direct-play negotiation. A single immutable result then describes this installed build
* to both the Memby gateway and Emby itself.
*/
val devicePlaybackCapabilities: DevicePlaybackCapabilities by lazy {
runCatching { AndroidVideoCapabilityProbe().probe() }
// H.264 is Android's baseline playback format and remains the safe fallback when
// a vendor codec exposes incomplete or broken MediaCodec metadata.
.getOrElse { DevicePlaybackCapabilities() }
}
internal fun DevicePlaybackCapabilities.gatewayCapabilityTokens(): List<String> = buildList {
if (h264.supported) add("video_h264_decode")
h264.profiles.sorted().forEach { add("video_h264_profile_$it") }
if (h264.mainLevel > 0) add("video_h264_level_${h264.mainLevel}")
if (h264.tenBitLevel > 0) add("video_h264_high10_level_${h264.tenBitLevel}")
addResolution("video_h264", h264)
if (hevc.supported) add("video_hevc_decode")
hevc.profiles.sorted().forEach { add("video_hevc_profile_$it") }
if (hevc.mainLevel > 0) add("video_hevc_main_level_${hevc.mainLevel}")
if (hevc.tenBitLevel > 0) add("video_hevc_main10_level_${hevc.tenBitLevel}")
addResolution("video_hevc", hevc)
if (hevc.hdr10) add("video_hevc_hdr10")
if (hevc.hdr10Plus) add("video_hevc_hdr10plus")
if (hevc.dolbyVision) add("video_hevc_dolby_vision")
}
private fun MutableList<String>.addResolution(prefix: String, codec: VideoDecoderCapabilities) {
if (codec.maxWidth > 0 && codec.maxHeight > 0) {
add("${prefix}_max_${codec.maxWidth}x${codec.maxHeight}")
}
}
private class AndroidVideoCapabilityProbe {
private val codecInfos by lazy { MediaCodecList(MediaCodecList.REGULAR_CODECS).codecInfos }
fun probe(): DevicePlaybackCapabilities = DevicePlaybackCapabilities(
h264 = probeH264(),
hevc = probeHevc(),
)
private fun probeH264(): VideoDecoderCapabilities {
val mime = MediaFormat.MIMETYPE_VIDEO_AVC
val supported = hasCodecForMime(mime)
// As in Wholphin, ordinary AVC support covers the backwards-compatible
// baseline/main/high family even when vendor metadata lists only its highest
// profile. High10 remains opt-in because it is not universally supported.
val high10Level = mappedLevel(
mime,
setOf(CodecProfileLevel.AVCProfileHigh10),
AVC_LEVELS,
)
val profiles = buildSet {
if (supported) addAll(listOf("baseline", "constrained_baseline", "main", "high"))
if (high10Level > 0) add("high10")
}
val (maxWidth, maxHeight) = maxResolution(mime)
return VideoDecoderCapabilities(
supported = supported,
profiles = profiles,
mainLevel = mappedLevel(
mime,
setOf(
CodecProfileLevel.AVCProfileBaseline,
CodecProfileLevel.AVCProfileMain,
CodecProfileLevel.AVCProfileHigh,
),
AVC_LEVELS,
),
tenBitLevel = high10Level,
maxWidth = maxWidth,
maxHeight = maxHeight,
)
}
private fun probeHevc(): VideoDecoderCapabilities {
val mime = MediaFormat.MIMETYPE_VIDEO_HEVC
val supported = hasCodecForMime(mime)
val mainLevel = mappedLevel(
mime,
setOf(CodecProfileLevel.HEVCProfileMain),
HEVC_LEVELS,
)
val main10Level = mappedLevel(
mime,
setOf(CodecProfileLevel.HEVCProfileMain10),
HEVC_LEVELS,
)
val (maxWidth, maxHeight) = maxResolution(mime)
return VideoDecoderCapabilities(
supported = supported,
profiles = buildSet {
if (supported) add("main")
if (main10Level > 0) add("main10")
},
mainLevel = mainLevel,
tenBitLevel = main10Level,
maxWidth = maxWidth,
maxHeight = maxHeight,
hdr10 = Build.VERSION.SDK_INT >= Build.VERSION_CODES.N &&
hasProfile(mime, CodecProfileLevel.HEVCProfileMain10HDR10),
hdr10Plus = Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q &&
hasProfile(mime, CodecProfileLevel.HEVCProfileMain10HDR10Plus),
dolbyVision = Build.VERSION.SDK_INT >= Build.VERSION_CODES.N &&
hasCodecForMime(MediaFormat.MIMETYPE_VIDEO_DOLBY_VISION),
)
}
private fun hasCodecForMime(mime: String): Boolean = codecInfos.any { info ->
!info.isEncoder && info.supportedTypes.any { it.equals(mime, ignoreCase = true) }
}
private fun hasProfile(mime: String, profile: Int): Boolean =
maxPlatformLevel(mime, setOf(profile)) > 0
private fun mappedLevel(
mime: String,
profiles: Set<Int>,
levels: List<Pair<Int, Int>>,
): Int {
val platformLevel = maxPlatformLevel(mime, profiles)
return levels.asReversed().firstOrNull { platformLevel >= it.first }?.second ?: 0
}
private fun maxPlatformLevel(mime: String, profiles: Set<Int>): Int {
var maximum = 0
codecInfos.asSequence().filterNot { it.isEncoder }.forEach { info ->
runCatching { info.getCapabilitiesForType(mime) }.getOrNull()
?.profileLevels
?.filter { it.profile in profiles }
?.forEach { maximum = maxOf(maximum, it.level) }
}
return maximum
}
private fun maxResolution(mime: String): Pair<Int, Int> {
var maxWidth = 0
var maxHeight = 0
codecInfos.asSequence().filterNot { it.isEncoder }.forEach { info ->
val video = runCatching { info.getCapabilitiesForType(mime).videoCapabilities }.getOrNull()
?: return@forEach
maxWidth = maxOf(maxWidth, video.supportedWidths?.upper ?: 0)
maxHeight = maxOf(maxHeight, video.supportedHeights?.upper ?: 0)
}
return maxWidth to maxHeight
}
companion object {
// ffprobe/Emby represent AVC levels multiplied by 10 (4.1 -> 41).
private val AVC_LEVELS = listOf(
CodecProfileLevel.AVCLevel1b to 9,
CodecProfileLevel.AVCLevel1 to 10,
CodecProfileLevel.AVCLevel11 to 11,
CodecProfileLevel.AVCLevel12 to 12,
CodecProfileLevel.AVCLevel13 to 13,
CodecProfileLevel.AVCLevel2 to 20,
CodecProfileLevel.AVCLevel21 to 21,
CodecProfileLevel.AVCLevel22 to 22,
CodecProfileLevel.AVCLevel3 to 30,
CodecProfileLevel.AVCLevel31 to 31,
CodecProfileLevel.AVCLevel32 to 32,
CodecProfileLevel.AVCLevel4 to 40,
CodecProfileLevel.AVCLevel41 to 41,
CodecProfileLevel.AVCLevel42 to 42,
CodecProfileLevel.AVCLevel5 to 50,
CodecProfileLevel.AVCLevel51 to 51,
CodecProfileLevel.AVCLevel52 to 52,
)
// ffprobe/Emby represent HEVC levels multiplied by 30 (4.1 -> 123).
private val HEVC_LEVELS = listOf(
CodecProfileLevel.HEVCMainTierLevel1 to 30,
CodecProfileLevel.HEVCMainTierLevel2 to 60,
CodecProfileLevel.HEVCMainTierLevel21 to 63,
CodecProfileLevel.HEVCMainTierLevel3 to 90,
CodecProfileLevel.HEVCMainTierLevel31 to 93,
CodecProfileLevel.HEVCMainTierLevel4 to 120,
CodecProfileLevel.HEVCMainTierLevel41 to 123,
CodecProfileLevel.HEVCMainTierLevel5 to 150,
CodecProfileLevel.HEVCMainTierLevel51 to 153,
CodecProfileLevel.HEVCMainTierLevel52 to 156,
CodecProfileLevel.HEVCMainTierLevel6 to 180,
CodecProfileLevel.HEVCMainTierLevel61 to 183,
CodecProfileLevel.HEVCMainTierLevel62 to 186,
)
}
}
@@ -53,6 +53,16 @@ interface EmbyApi {
@Path("itemId") itemId: String,
): ItemsResult
/**
* Emby's own similarity ranking. The direct path has no recommendation engine behind
* it, so this is the whole of "More like this" when the gateway is not in play.
*/
@GET("Items/{itemId}/Similar")
suspend fun getSimilar(
@Path("itemId") itemId: String,
@QueryMap params: Map<String, String>,
): ItemsResult
@GET("Shows/NextUp")
suspend fun getNextUp(@QueryMap params: Map<String, String>): ItemsResult
@@ -5,7 +5,6 @@ import com.ponzischeme89.memby.BuildConfig
import kotlinx.serialization.json.Json
import okhttp3.Interceptor
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Response
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit
@@ -36,7 +35,9 @@ object EmbyServiceFactory {
redactHeader("X-Emby-Authorization")
}
val client = OkHttpClient.Builder()
// Derived from the shared stack, so this keeps the one connection pool and
// dispatcher the artwork loader also uses. See HttpStack.
val client = HttpStack.base.newBuilder()
.connectTimeout(15, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.addInterceptor(EmbyAuthInterceptor(deviceIdProvider, tokenProvider))
@@ -2,11 +2,15 @@ package com.ponzischeme89.memby.data.remote
import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.data.model.GatewayFlagRequest
import com.ponzischeme89.memby.data.model.GatewayAuthPolicy
import com.ponzischeme89.memby.data.model.GatewayFeatures
import com.ponzischeme89.memby.data.model.GatewayDevices
import com.ponzischeme89.memby.data.model.GatewayDeviceNameRequest
import com.ponzischeme89.memby.data.model.GatewayHome
import com.ponzischeme89.memby.data.model.GatewayItems
import com.ponzischeme89.memby.data.model.GatewayLoginRequest
import com.ponzischeme89.memby.data.model.GatewayLoginResponse
import com.ponzischeme89.memby.data.model.GatewayMediaRequest
import com.ponzischeme89.memby.data.model.GatewayMediaRequestResult
import com.ponzischeme89.memby.data.model.GatewayNextEpisode
import com.ponzischeme89.memby.data.model.GatewayPlayback
import com.ponzischeme89.memby.data.model.GatewayPlaybackReport
@@ -14,12 +18,17 @@ import com.ponzischeme89.memby.data.model.GatewayPrerollSchedule
import com.ponzischeme89.memby.data.model.GatewayRowEvents
import com.ponzischeme89.memby.data.model.GatewayRows
import com.ponzischeme89.memby.data.model.GatewaySearchHistory
import com.ponzischeme89.memby.data.model.GatewayRequestLookup
import com.ponzischeme89.memby.data.model.GatewayServiceStatus
import com.ponzischeme89.memby.data.model.GatewayUpdate
import com.ponzischeme89.memby.data.model.RecommendationOnboarding
import com.ponzischeme89.memby.data.model.RecommendationPreferences
import com.ponzischeme89.memby.data.model.UserItemData
import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.DELETE
import retrofit2.http.POST
import retrofit2.http.PUT
import retrofit2.http.Path
import retrofit2.http.Query
@@ -35,9 +44,6 @@ interface GatewayApi {
@POST("v1/auth/login")
suspend fun login(@Body body: GatewayLoginRequest): GatewayLoginResponse
@GET("v1/auth/policy")
suspend fun authPolicy(): GatewayAuthPolicy
@POST("v1/auth/logout")
suspend fun logout()
@@ -45,6 +51,18 @@ interface GatewayApi {
@GET("v1/auth/session")
suspend fun session(): GatewayLoginResponse
@GET("v1/auth/devices")
suspend fun devices(): GatewayDevices
@DELETE("v1/auth/devices/{deviceId}")
suspend fun removeDevice(@Path("deviceId") deviceId: String)
@PUT("v1/auth/devices/{deviceId}")
suspend fun renameDevice(
@Path("deviceId") deviceId: String,
@Body body: GatewayDeviceNameRequest,
)
@GET("v1/home")
suspend fun home(@Query("limit") limit: Int): GatewayHome
@@ -60,6 +78,12 @@ interface GatewayApi {
@GET("v1/search/history")
suspend fun recentSearches(): GatewaySearchHistory
@GET("v1/requests/lookup")
suspend fun requestLookup(@Query("q") term: String): GatewayRequestLookup
@POST("v1/requests")
suspend fun requestMedia(@Body body: GatewayMediaRequest): GatewayMediaRequestResult
/** Recommendation rows on their own. `/v1/home` already embeds these when warm. */
@GET("v1/recommendations")
suspend fun recommendations(): GatewayRows
@@ -67,6 +91,12 @@ interface GatewayApi {
@GET("v1/for-you")
suspend fun forYou(@Query("minutes") availableMinutes: Int): GatewayRows
@GET("v1/recommendations/preferences")
suspend fun recommendationPreferences(): RecommendationOnboarding
@PUT("v1/recommendations/preferences")
suspend fun saveRecommendationPreferences(@Body body: RecommendationPreferences)
@GET("v1/preroll")
suspend fun prerollSchedule(): GatewayPrerollSchedule
@@ -77,17 +107,57 @@ interface GatewayApi {
@GET("v1/update")
suspend fun updateStatus(): GatewayUpdate
@GET("v1/my-shows")
suspend fun myShows(): com.ponzischeme89.memby.data.model.MyShowsResponse
@POST("v1/my-shows")
suspend fun saveMyShow(
@Body body: com.ponzischeme89.memby.data.model.SaveMyShowRequest,
): com.ponzischeme89.memby.data.model.MyShowsResponse
@DELETE("v1/my-shows/{id}")
suspend fun removeMyShow(@Path("id") itemId: String)
@GET("v1/notifications")
suspend fun notifications(): com.ponzischeme89.memby.data.model.NotificationsResponse
@PUT("v1/notifications")
suspend fun setNotificationPreferences(
@Body body: com.ponzischeme89.memby.data.model.NotificationPreferences,
): com.ponzischeme89.memby.data.model.NotificationsResponse
@POST("v1/notifications/{id}/{action}")
suspend fun updateNotification(
@Path("id") id: Long,
@Path("action") action: String,
)
/** Available during maintenance so an open app can be interrupted immediately. */
@GET("v1/status")
suspend fun serviceStatus(): GatewayServiceStatus
/** Versioned server control-plane document; unknown flags remain safely ignorable. */
@GET("v1/features")
suspend fun features(): GatewayFeatures
@GET("v1/items/{id}")
suspend fun item(@Path("id") itemId: String): BaseItem
@GET("v1/items/{id}/season-finale")
suspend fun seasonFinale(
@Path("id") itemId: String,
): com.ponzischeme89.memby.data.model.GatewaySeasonFinale
/** All episodes for a series in display order; the client groups them into seasons. */
@GET("v1/items/{id}/episodes")
suspend fun seriesEpisodes(@Path("id") seriesId: String): GatewayItems
/** Why this viewer might enjoy the item, and what else in the library is like it. */
@GET("v1/items/{id}/related")
suspend fun related(
@Path("id") itemId: String,
): com.ponzischeme89.memby.data.model.GatewayRelated
@GET("v1/items/{id}/playback")
suspend fun playback(
@Path("id") itemId: String,
@@ -95,6 +165,7 @@ interface GatewayApi {
@Query("title") title: String,
@Query("resumePositionMs") resumePositionMs: Long,
@Query("subtitleIndex") subtitleIndex: Int? = null,
@Query("forceTranscode") forceTranscode: Boolean = false,
): GatewayPlayback
/** 404 when nothing follows this item: a movie, or a series finale. */
@@ -114,7 +185,10 @@ interface GatewayApi {
suspend fun setPlayed(@Path("id") itemId: String, @Body body: GatewayFlagRequest): UserItemData
@POST("v1/playback/{phase}")
suspend fun report(@Path("phase") phase: String, @Body body: GatewayPlaybackReport)
suspend fun report(
@Path("phase") phase: String,
@Body body: GatewayPlaybackReport,
): com.ponzischeme89.memby.data.model.GatewayPlaybackReportResponse
/** Row engagement, uploaded in batches. Fire-and-forget: failures are not retried. */
@POST("v1/analytics/rows")
@@ -2,10 +2,11 @@ package com.ponzischeme89.memby.data.remote
import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory
import com.ponzischeme89.memby.BuildConfig
import com.ponzischeme89.memby.data.playback.devicePlaybackCapabilities
import com.ponzischeme89.memby.data.playback.gatewayCapabilityTokens
import kotlinx.serialization.json.Json
import okhttp3.Interceptor
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Response
import retrofit2.Retrofit
import java.util.concurrent.TimeUnit
@@ -23,7 +24,9 @@ object GatewayServiceFactory {
fun create(baseUrl: String, tokenProvider: () -> String?): GatewayApi {
val contentType = "application/json".toMediaType()
val client = OkHttpClient.Builder()
// Derived from the shared stack: artwork in gateway mode is proxied by this very
// host, so the poster fetches reuse the connection this client established.
val client = HttpStack.base.newBuilder()
.connectTimeout(10, TimeUnit.SECONDS)
// The gateway answers home from Redis in single-digit milliseconds; a long
// read timeout here only ever means Emby itself is struggling behind it.
@@ -52,6 +55,11 @@ private class GatewayAuthInterceptor(private val tokenProvider: () -> String?) :
// says which build it is.
.header("X-Memby-Version", BuildConfig.VERSION_NAME)
.header("X-Memby-Protocol", MEMBY_PROTOCOL_VERSION.toString())
.header(
"X-Memby-Capabilities",
(MEMBY_CAPABILITIES + devicePlaybackCapabilities.gatewayCapabilityTokens())
.joinToString(","),
)
tokenProvider()?.takeIf { it.isNotBlank() }?.let {
builder.header("Authorization", "Bearer $it")
}
@@ -60,3 +68,12 @@ private class GatewayAuthInterceptor(private val tokenProvider: () -> String?) :
}
internal const val MEMBY_PROTOCOL_VERSION = 1
internal val MEMBY_CAPABILITIES = listOf(
"server_features_v1",
"live_feature_refresh_v1",
"sonarr_preroll_v1",
"auto_my_shows_v1",
)
internal const val HEVC_DECODE_CAPABILITY = "video_hevc_decode"
@@ -0,0 +1,53 @@
package com.ponzischeme89.memby.data.remote
import okhttp3.ConnectionPool
import okhttp3.Dispatcher
import okhttp3.OkHttpClient
import java.util.concurrent.TimeUnit
/**
* The one HTTP stack in the app. Everything that talks to the network — the Emby API, the
* gateway API and Coil's artwork loader — is built from [base], so they share a single
* connection pool, dispatcher and thread pool.
*
* Sharing matters most for artwork. In gateway mode the images are proxied by the same
* HTTPS host that serves `/v1/home`, so a poster fetched on a client of its own would
* open a fresh TCP connection and repeat the TLS handshake that the home request had just
* finished — perhaps a couple of hundred milliseconds per cold image over a domestic
* connection, multiplied by the fifteen-odd posters a launcher paints at once. Reusing
* the pool makes those fetches resume an established connection instead.
*
* Timeouts and auth headers still differ per caller, so each factory calls
* [base].newBuilder() and adds its own. That is the intended way to specialise an OkHttp
* client: the derived client keeps the shared pool and dispatcher.
*/
internal object HttpStack {
/**
* A launcher paints far more than the default five concurrent requests per host, and
* in gateway mode every one of them is the same host. Five means posters arrive in
* visible waves; this lets a screenful start together while staying well short of
* what would swamp a TV's radio.
*/
private const val MAX_REQUESTS_PER_HOST = 15
private val connectionPool = ConnectionPool(
maxIdleConnections = 8,
keepAliveDuration = 5,
timeUnit = TimeUnit.MINUTES,
)
private val dispatcher = Dispatcher().apply {
maxRequests = 32
maxRequestsPerHost = MAX_REQUESTS_PER_HOST
}
val base: OkHttpClient = OkHttpClient.Builder()
.connectionPool(connectionPool)
.dispatcher(dispatcher)
// Sensible floor; every caller overrides these to suit what it is fetching.
.connectTimeout(15, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.retryOnConnectionFailure(true)
.build()
}
@@ -0,0 +1,719 @@
package com.ponzischeme89.memby.ui
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.focusGroup
import androidx.compose.foundation.focusable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.BoxWithConstraints
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.aspectRatio
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.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.runtime.withFrameNanos
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
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.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
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.Dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.zIndex
import androidx.tv.material3.Icon
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.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
import com.ponzischeme89.memby.ui.theme.MembyHairline
import com.ponzischeme89.memby.ui.theme.MembyMutedText
import com.ponzischeme89.memby.ui.theme.MembyOnSurface
import com.ponzischeme89.memby.ui.theme.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
// The detail page's names for the shared tokens. The neutrals used to be a shade darker
// here than on the launcher, which is visible the moment a page opens from a row.
internal val DetailBackground = MembySurface
internal val DetailAccent = MembyAccent
internal val DetailText = MembyOnSurface
internal val DetailMutedText = MembyMutedText
internal val DetailQuietText = MembyQuietText
internal val DetailHairline = MembyHairline
internal val DetailSideGutter = 58.dp
private val DetailTabHeight = 66.dp
/**
* How much of the content pane is left showing under the tab strip.
*
* It does two jobs. The strip used to be anchored to the very bottom of the screen, where a
* TV's overscan ate the selection underline and part of the labels — this is the safe-area
* inset the rest of the app already keeps. And because what fills the gap is the top of the
* pane rather than more background, it is the one thing on screen saying that Down from the
* strip reveals something.
*/
private val DetailFoldPeek = 34.dp
/**
* The height one tab's pane gets, from the viewport it has to fit inside.
*
* It was a hard 250dp, and `technicalSpecs()` — Video, Codec, Audio, Subtitles, Studio —
* fell off the bottom of a pane that deliberately cannot scroll. The budget is what is left
* of the screen once the pane has been scrolled to its resting position under the strip.
*/
internal fun detailPaneHeight(viewportHeight: Dp): Dp =
(viewportHeight - 132.dp).coerceIn(250.dp, 420.dp)
internal data class DetailHeroAction(
val icon: ImageVector,
val description: String,
val active: Boolean = false,
val label: String? = null,
val onClick: () -> Unit,
)
/** Full-bleed artwork with a protected reading area on the left and at the fold. */
@Composable
internal fun DetailBackdrop(item: BaseItem, modifier: Modifier = Modifier) {
val repository = ServiceLocator.repository
val artwork = remember(item.id, item.backdropImageTags, item.imageTags) {
repository.backdropUrl(item, 1920) ?: repository.primaryUrl(item, 1280)
}
Box(modifier.background(DetailBackground)) {
if (artwork != null) {
AsyncImage(
model = artwork,
contentDescription = null,
contentScale = ContentScale.Crop,
alignment = Alignment.TopCenter,
modifier = Modifier.fillMaxSize(),
)
}
Box(
Modifier.fillMaxSize().background(
Brush.horizontalGradient(
0f to Color(0xF2080A0C),
0.42f to Color(0xCC080A0C),
0.72f to Color(0x38080A0C),
1f to Color(0x10080A0C),
),
),
)
Box(
Modifier.fillMaxSize().background(
Brush.verticalGradient(
0f to Color(0x18000000),
0.48f to Color(0x26080A0C),
0.78f to Color(0xE6080A0C),
1f to DetailBackground,
),
),
)
}
}
/** Shared movie/series frame. The list owns vertical motion so focused bands stay visible. */
@Composable
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,
progress: Float = 0f,
progressLabel: String? = null,
reasons: List<String> = emptyList(),
heroActions: List<DetailHeroAction> = emptyList(),
confirmation: String? = null,
pageListState: LazyListState = remember(item.id) { LazyListState() },
onZoneFocused: (DetailZone) -> Unit = {},
content: @Composable BoxScope.(DetailTab) -> Unit,
) {
val scope = rememberCoroutineScope()
// Keep the same requesters when an async trailer action appears. Replacing the list
// while the viewer is already on Favourites would detach the focused node.
val allActionRequesters = remember(item.id) {
List(6) { FocusRequester() }
}
val actionRequesters = allActionRequesters.take(heroActions.size)
var lastHeroIndex by remember(item.id) { mutableIntStateOf(-1) }
var focusedZone by remember(item.id) { mutableStateOf(DetailZone.PLAY) }
val heroReturn = if (lastHeroIndex in actionRequesters.indices) {
actionRequesters[lastHeroIndex]
} else {
playFocusRequester
}
fun reveal(index: Int, offset: Int = 0) {
scope.launch { pageListState.animateScrollToItem(index, offset) }
}
fun revealHero() {
scope.launch {
// LazyColumn also scrolls a newly focused descendant into view. That request
// can land after onFocusChanged and used to win over reveal(0), leaving Play
// focused with the logo and metadata above the viewport. Snap once now and
// once after focus relocation has completed so hero focus always means the
// complete opening frame.
pageListState.scrollToItem(0)
withFrameNanos { }
pageListState.scrollToItem(0)
}
}
// Focus relocation belongs to LazyColumn and may run after the focus callback. Keep
// the opening frame pinned for as long as focus remains in the hero, regardless of
// which relocation wins a particular frame. Moving to Tabs or Content releases it.
androidx.compose.runtime.LaunchedEffect(pageListState, focusedZone) {
if (detailHeroScrollTarget(focusedZone) == null) return@LaunchedEffect
snapshotFlow {
pageListState.firstVisibleItemIndex to pageListState.firstVisibleItemScrollOffset
}.collect { (index, offset) ->
if (index != 0 || offset != 0) pageListState.scrollToItem(0)
}
}
BoxWithConstraints(modifier.fillMaxSize().background(DetailBackground)) {
// 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 paneHeight = detailPaneHeight(maxHeight)
LazyColumn(
state = pageListState,
modifier = Modifier.fillMaxSize(),
) {
item(key = "hero") {
DetailHero(
item = item,
facts = facts,
badges = badges,
playLabel = playLabel,
onPlay = onPlay,
playFocusRequester = playFocusRequester,
tabFocusRequester = tabFocusRequester,
progress = progress,
progressLabel = progressLabel,
reasons = reasons,
actions = heroActions,
actionRequesters = actionRequesters,
height = heroHeight,
onActionFocused = { index ->
lastHeroIndex = index
focusedZone = DetailZone.PLAY
onZoneFocused(DetailZone.PLAY)
revealHero()
},
onPlayFocused = {
lastHeroIndex = -1
focusedZone = DetailZone.PLAY
onZoneFocused(DetailZone.PLAY)
revealHero()
},
)
}
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)
},
)
}
item(key = "content") {
AnimatedContent(
targetState = selectedTab,
transitionSpec = { fadeIn(tween(110)) togetherWith fadeOut(tween(80)) },
label = "detail-tab-content",
modifier = Modifier
.fillMaxWidth()
.padding(start = DetailSideGutter, end = DetailSideGutter, top = 16.dp, bottom = 64.dp)
.height(paneHeight)
.onFocusChanged {
if (it.hasFocus) {
focusedZone = DetailZone.CONTENT
onZoneFocused(DetailZone.CONTENT)
reveal(2, -72)
}
}
.focusProperties { up = tabFocusRequester },
) { visibleTab ->
Box(Modifier.fillMaxSize()) { content(visibleTab) }
}
}
}
confirmation?.let {
Text(
text = it,
color = Color.White,
fontSize = 14.sp,
fontWeight = FontWeight.SemiBold,
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(bottom = 24.dp)
.shadow(16.dp, RoundedCornerShape(MembyPanelCorner))
.clip(RoundedCornerShape(MembyPanelCorner))
.background(Color(0xEE20252A))
.border(1.dp, Color.White.copy(alpha = 0.16f), RoundedCornerShape(MembyPanelCorner))
.padding(horizontal = 20.dp, vertical = 10.dp),
)
}
}
}
/** A focused hero is always the complete opening frame, never a scrolled Play-only crop. */
internal fun detailHeroScrollTarget(zone: DetailZone): Int? =
if (zone == DetailZone.PLAY) 0 else null
@Composable
private fun DetailHero(
item: BaseItem,
facts: List<String>,
badges: List<String>,
playLabel: String,
onPlay: () -> Unit,
playFocusRequester: FocusRequester,
tabFocusRequester: FocusRequester,
progress: Float,
progressLabel: String?,
reasons: List<String>,
actions: List<DetailHeroAction>,
actionRequesters: List<FocusRequester>,
height: Dp,
onPlayFocused: () -> Unit,
onActionFocused: (Int) -> Unit,
) {
// Settings promises the logo preference applies to titles generally; only the
// screensaver honoured it. The dark-logo fallback comes with it — a black title
// treatment on this near-black scrim is an invisible heading.
val repository = ServiceLocator.repository
val logoUrl = remember(item.id, item.imageTags, repository.showTitleLogo) {
if (repository.showTitleLogo) repository.logoUrl(item, 720) else null
}
val logo = logoUrl.takeIf { !useTextTitleForLogo(it) }
Box(Modifier.fillMaxWidth().height(height)) {
DetailBackdrop(item, Modifier.fillMaxSize())
Column(
modifier = Modifier
.align(Alignment.BottomStart)
.padding(start = DetailSideGutter, end = DetailSideGutter, bottom = 30.dp)
.fillMaxWidth(0.58f),
) {
if (logo != null) {
AsyncImage(
model = logo,
contentDescription = item.name,
contentScale = ContentScale.Fit,
alignment = Alignment.CenterStart,
modifier = Modifier.width(330.dp).height(92.dp),
)
} else {
Text(
text = item.name,
color = Color.White,
fontSize = 38.sp,
lineHeight = 42.sp,
fontWeight = FontWeight.Bold,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
Spacer(Modifier.height(10.dp))
DetailFactRow(facts = facts, badges = badges, rating = ratingLabel(item))
if (item.genres.isNotEmpty()) {
Spacer(Modifier.height(8.dp))
Text(
text = item.genres.take(4).joinToString(ValueSeparator),
color = DetailMutedText,
fontSize = 14.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
Spacer(Modifier.height(10.dp))
Text(
text = item.overview?.takeIf(String::isNotBlank) ?: "No description available.",
color = DetailText,
fontSize = 15.sp,
lineHeight = 20.sp,
maxLines = 3,
overflow = TextOverflow.Ellipsis,
)
if (progress > 0f) {
Spacer(Modifier.height(12.dp))
DetailProgress(progress, progressLabel)
}
if (reasons.isNotEmpty()) {
Spacer(Modifier.height(10.dp))
Text(
text = reasons.first(),
color = DetailAccent,
fontSize = 13.sp,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
Spacer(Modifier.height(16.dp))
Row(
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.focusGroup().focusProperties { down = tabFocusRequester },
) {
MembyPlayButton(
label = playLabel,
onClick = onPlay,
onFocused = onPlayFocused,
modifier = Modifier.testTag("detail-play").focusRequester(playFocusRequester),
)
actions.forEachIndexed { index, action ->
DetailCircularAction(
action = action,
onFocused = { onActionFocused(index) },
modifier = Modifier.focusRequester(actionRequesters[index]),
)
}
}
}
}
}
@Composable
private fun DetailCircularAction(
action: DetailHeroAction,
onFocused: () -> Unit,
modifier: Modifier = Modifier,
) {
var focused by remember { mutableStateOf(false) }
val scale by animateFloatAsState(if (focused) 1.1f else 1f, tween(100), label = "hero-action-focus")
Row(
modifier = modifier
.then(if (action.label == null) Modifier.size(48.dp) else Modifier.height(48.dp))
.graphicsLayer { scaleX = scale; scaleY = scale; translationY = if (focused) -3f else 0f }
.shadow(if (focused) 16.dp else 5.dp, CircleShape)
.clip(CircleShape)
.background(if (action.active) DetailAccent else Color(0xB3262B30))
.border(if (focused) 2.dp else 1.dp, if (focused) Color.White else Color.White.copy(alpha = 0.38f), CircleShape)
.semantics { contentDescription = action.description }
.onFocusChanged { focused = it.isFocused; if (it.isFocused) onFocused() }
.clickable(onClick = action.onClick)
.padding(horizontal = if (action.label == null) 0.dp else 16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center,
) {
Icon(action.icon, action.description, tint = Color.White, modifier = Modifier.size(22.dp))
action.label?.let { label ->
Spacer(Modifier.width(8.dp))
Text(label, color = Color.White, fontSize = 14.sp, fontWeight = FontWeight.Bold, maxLines = 1)
}
}
}
@Composable
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 ->
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)
}
// 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.
badges.take(4).forEach { badge ->
Spacer(Modifier.width(8.dp))
MediaBadge(badge)
}
}
}
@Composable
private fun DetailTabStrip(
tabs: List<DetailTab>,
selected: DetailTab,
onSelect: (DetailTab) -> Unit,
selectedFocusRequester: FocusRequester,
heroFocusRequester: FocusRequester,
contentFocusRequester: FocusRequester,
onFocused: () -> Unit,
) {
Box(
Modifier
.fillMaxWidth()
.height(DetailTabHeight)
.background(DetailBackground)
.padding(horizontal = DetailSideGutter),
) {
Box(Modifier.align(Alignment.BottomStart).fillMaxWidth().height(1.dp).background(DetailHairline))
Row(
modifier = Modifier
.fillMaxSize()
.focusGroup()
.focusProperties { up = heroFocusRequester; down = contentFocusRequester },
horizontalArrangement = Arrangement.spacedBy(34.dp),
verticalAlignment = Alignment.Bottom,
) {
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)
.testTag("detail-tab-${tab.key}")
.onFocusChanged {
focused = it.isFocused
if (it.isFocused) { onSelect(tab); onFocused() }
}
.clickable { onSelect(tab) },
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
tab.label,
color = if (selected == tab || focused) Color.White else DetailQuietText,
fontSize = 15.sp,
fontWeight = if (selected == tab || focused) FontWeight.Bold else FontWeight.Medium,
maxLines = 1,
modifier = Modifier.padding(start = 4.dp, end = 4.dp, bottom = 13.dp),
)
Box(
Modifier.fillMaxWidth().height(3.dp).background(
if (selected == tab || focused) DetailAccent else Color.Transparent,
),
)
}
}
Spacer(Modifier.weight(1f))
// The tab's content begins below the fold by design. Nothing said so; the
// peek under this strip and this chevron are what say it. Never focusable —
// it is a caption on the Down key, not another thing to land on.
Icon(
Icons.Default.KeyboardArrowDown,
contentDescription = null,
tint = DetailQuietText,
modifier = Modifier.size(18.dp).padding(bottom = 2.dp),
)
}
}
}
@Composable
internal fun DetailProgress(progress: Float, label: String?, modifier: Modifier = Modifier) {
Row(modifier, verticalAlignment = Alignment.CenterVertically) {
Box(Modifier.width(250.dp).height(5.dp).clip(CircleShape).background(Color.White.copy(alpha = 0.22f))) {
Box(Modifier.fillMaxWidth(progress.coerceIn(0f, 1f)).height(5.dp).background(DetailAccent))
}
label?.let { Text(it, color = DetailMutedText, fontSize = 12.sp, modifier = Modifier.padding(start = 12.dp)) }
}
}
@Composable
internal fun DetailFocusablePane(
focusRequester: FocusRequester,
modifier: Modifier = Modifier,
content: @Composable () -> Unit,
) {
var focused by remember { mutableStateOf(false) }
Box(
modifier
.fillMaxSize()
.focusRequester(focusRequester)
.onFocusChanged { focused = it.isFocused }
.focusable()
.clip(RoundedCornerShape(MembyCardCorner))
.border(if (focused) 2.dp else 1.dp, if (focused) Color.White.copy(alpha = 0.8f) else Color.Transparent, RoundedCornerShape(MembyCardCorner))
.background(if (focused) Color.White.copy(alpha = 0.035f) else Color.Transparent)
.padding(16.dp),
) { content() }
}
@Composable
internal fun DetailMetaRows(rows: List<TechnicalSpec>, modifier: Modifier = Modifier, labelWidth: androidx.compose.ui.unit.Dp = 110.dp) {
Column(modifier, verticalArrangement = Arrangement.spacedBy(9.dp)) {
rows.forEach { row ->
Row(Modifier.fillMaxWidth()) {
Text(row.label, color = DetailQuietText, fontSize = 13.sp, fontWeight = FontWeight.Medium, modifier = Modifier.width(labelWidth))
Text(row.value, color = DetailText, fontSize = 14.sp, lineHeight = 19.sp, maxLines = 2, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f))
}
}
}
}
@Composable
internal fun DetailOverviewPane(
item: BaseItem,
credits: List<TechnicalSpec>,
focusRequester: FocusRequester,
modifier: Modifier = Modifier,
supportingText: String? = null,
) {
DetailFocusablePane(focusRequester, modifier) {
Column {
Text(item.overview?.takeIf(String::isNotBlank) ?: "No description available.", color = DetailText, fontSize = 16.sp, lineHeight = 23.sp, maxLines = 5, overflow = TextOverflow.Ellipsis)
supportingText?.let { Spacer(Modifier.height(10.dp)); Text(it, color = DetailAccent, fontSize = 14.sp, fontWeight = FontWeight.SemiBold) }
Spacer(Modifier.height(16.dp))
DetailMetaRows(credits.take(4))
}
}
}
@Composable
internal fun DetailCastAndDetailsPane(
item: BaseItem,
credits: List<TechnicalSpec>,
specs: List<TechnicalSpec>,
detailsLoaded: Boolean,
focusRequester: FocusRequester,
modifier: Modifier = Modifier,
) {
val releaseAndTechnical = remember(item.id, specs, credits) {
val alreadyCredited = credits.map(TechnicalSpec::label).toSet()
buildList {
item.productionYear?.let { add(TechnicalSpec("Released", it.toString())) }
item.officialRating?.takeIf(String::isNotBlank)?.let { add(TechnicalSpec("Certificate", it)) }
item.runtimeMinutes?.let { add(TechnicalSpec("Runtime", formatRuntime(it))) }
// Studio is in both vocabularies. It only became visible as a duplicate once
// the pane stopped clipping its own second half.
addAll(specs.filterNot { it.label in alreadyCredited })
}
}
DetailFocusablePane(focusRequester, modifier) {
if (!detailsLoaded && item.people.isEmpty() && specs.isEmpty()) {
Text("Loading cast and details…", color = DetailQuietText, fontSize = 15.sp)
} else {
Column {
if (item.cast.isNotEmpty()) CastRail(people = item.cast, compact = true, showTitle = true)
Spacer(Modifier.height(12.dp))
Row(horizontalArrangement = Arrangement.spacedBy(42.dp)) {
DetailMetaRows(credits, Modifier.weight(1f))
DetailMetaRows(releaseAndTechnical, Modifier.weight(1f))
}
}
}
}
}
@Composable
internal fun DetailMoreLikeThisPane(
items: List<BaseItem>,
loading: Boolean,
onSelect: (BaseItem) -> Unit,
firstFocusRequester: FocusRequester,
listState: LazyListState,
modifier: Modifier = Modifier,
) {
when {
loading -> DetailFocusablePane(firstFocusRequester, modifier) { Text("Finding similar titles…", color = DetailQuietText, fontSize = 15.sp) }
items.isEmpty() -> DetailFocusablePane(firstFocusRequester, modifier) { Text("No similar titles are available.", color = DetailQuietText, fontSize = 15.sp) }
else -> LazyRow(
state = listState,
modifier = modifier.fillMaxSize().focusGroup(),
horizontalArrangement = Arrangement.spacedBy(18.dp),
contentPadding = PaddingValues(horizontal = 7.dp, vertical = 7.dp),
) {
itemsIndexed(items, key = { _, it -> it.id }) { index, related ->
DetailPosterCard(
item = related,
onClick = { onSelect(related) },
modifier = if (index == 0) Modifier.focusRequester(firstFocusRequester) else Modifier,
)
}
}
}
}
@Composable
private fun DetailPosterCard(item: BaseItem, onClick: () -> Unit, modifier: Modifier = Modifier) {
val artwork = remember(item.id) { ServiceLocator.repository.primaryUrl(item, 420) ?: ServiceLocator.repository.backdropUrl(item, 420) }
var focused by remember { mutableStateOf(false) }
val scale by animateFloatAsState(if (focused) 1.06f else 1f, tween(100), label = "related-poster-focus")
Column(
modifier.width(128.dp).graphicsLayer { scaleX = scale; scaleY = scale; translationY = if (focused) -3f else 0f }.zIndex(if (focused) 1f else 0f).onFocusChanged { focused = it.isFocused }.clickable(onClick = onClick),
) {
Box(Modifier.fillMaxWidth().aspectRatio(2f / 3f).clip(RoundedCornerShape(MembyCardCorner)).background(Color(0xFF171B1F)).border(if (focused) 2.dp else 1.dp, if (focused) Color.White else DetailHairline, RoundedCornerShape(MembyCardCorner))) {
if (artwork != null) AsyncImage(artwork, null, Modifier.fillMaxSize(), contentScale = ContentScale.Crop)
}
Text(item.name, color = if (focused) Color.White else DetailText, fontSize = 13.sp, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.padding(top = 7.dp))
}
}
@Composable
internal fun DetailPanePlaceholder(message: String, modifier: Modifier = Modifier) {
Box(modifier.fillMaxSize(), contentAlignment = Alignment.TopStart) { Text(message, color = DetailQuietText, fontSize = 15.sp) }
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,409 @@
package com.ponzischeme89.memby.ui
import androidx.compose.foundation.Image
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.fillMaxHeight
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.shape.CircleShape
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.getValue
import androidx.compose.runtime.mutableIntStateOf
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.shadow
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalDensity
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 coil.compose.AsyncImage
import com.ponzischeme89.memby.ServiceLocator
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
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.
*/
internal fun shouldShowHomeMovieHero(hasMovies: Boolean, focusedRowId: String?): Boolean =
hasMovies && (focusedRowId == null || focusedRowId == HOME_HERO_ROW_ID)
/**
* A hero card and the reason it is there.
*
* The label used to be the card's *position* — `listOf("POPULAR", "NEW RELEASE",
* "TRENDING")[index]` — while the selection below interleaves two sources and then falls
* back to every movie in the response, so a 2025 title was captioned NEW RELEASE and a 2026
* one POPULAR. A caption that can be wrong is worse than no caption.
*/
internal data class HomeHeroPick(val item: BaseItem, val label: String)
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> {
fun HomeBrowseRow.matches(vararg words: String): Boolean {
val label = "$id $title".lowercase()
return words.any(label::contains)
}
val newReleases = rows
.filter { it.matches("latest", "recent", "new release", "just added") }
.flatMap(HomeBrowseRow::items)
.filter(BaseItem::isMovie)
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)
fun List<BaseItem>.labelled(label: String) = map { HomeHeroPick(it, label) }
return buildList {
addAll(
listOfNotNull(
newReleases.getOrNull(0)?.let { HomeHeroPick(it, LABEL_NEW) },
popular.getOrNull(0)?.let { HomeHeroPick(it, LABEL_POPULAR) },
),
)
addAll(
listOfNotNull(
newReleases.getOrNull(1)?.let { HomeHeroPick(it, LABEL_NEW) },
popular.getOrNull(1)?.let { HomeHeroPick(it, LABEL_POPULAR) },
),
)
addAll(newReleases.labelled(LABEL_NEW))
addAll(popular.labelled(LABEL_POPULAR))
addAll(everyMovie.labelled(LABEL_LIBRARY))
// distinctBy keeps the first appearance, so a title that is both new and popular
// keeps the label of the row it was drawn from first.
}.distinctBy { it.item.id }.take(4)
}
@Composable
internal fun HomeMovieHero(
movies: List<HomeHeroPick>,
navigationFocusRequester: FocusRequester,
contentEntryFocusRequester: FocusRequester? = null,
returnFocusItemId: String? = null,
returnFocusRequester: FocusRequester? = null,
onItemFocused: (BaseItem) -> Unit,
onItemSelected: (BaseItem) -> Unit,
modifier: Modifier = Modifier,
previewArtwork: ImageBitmap? = null,
) {
if (movies.isEmpty()) return
BoxWithConstraints(modifier.fillMaxWidth()) {
val miniWidth = (maxWidth * 0.27f).coerceIn(184.dp, 326.dp)
Row(
modifier = Modifier.fillMaxSize().padding(start = 36.dp, end = 36.dp, top = 16.dp, bottom = 10.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
val featured = movies.first()
var featuredModifier: Modifier = Modifier
.weight(1f)
.fillMaxHeight()
.focusProperties { left = navigationFocusRequester }
if (contentEntryFocusRequester != null) {
featuredModifier = featuredModifier.focusRequester(contentEntryFocusRequester)
}
if (featured.item.id == returnFocusItemId && returnFocusRequester != null) {
featuredModifier = featuredModifier.focusRequester(returnFocusRequester)
}
FeaturedMovieCard(
pick = featured,
onFocused = { onItemFocused(featured.item) },
onClick = { onItemSelected(featured.item) },
modifier = featuredModifier,
previewArtwork = previewArtwork,
)
Column(
modifier = Modifier.width(miniWidth).fillMaxHeight(),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
movies.drop(1).take(3).forEach { pick ->
var miniModifier: Modifier = Modifier.weight(1f).fillMaxWidth()
if (pick.item.id == returnFocusItemId && returnFocusRequester != null) {
miniModifier = miniModifier.focusRequester(returnFocusRequester)
}
MiniMovieCard(
pick = pick,
onFocused = { onItemFocused(pick.item) },
onClick = { onItemSelected(pick.item) },
modifier = miniModifier,
previewArtwork = previewArtwork,
)
}
}
}
}
}
/** A wash over the artwork, keyed to why the card is on the shelf rather than to its slot. */
private fun labelTint(label: String): Color = when (label) {
LABEL_NEW -> Color(0x667253B7)
LABEL_POPULAR -> Color(0x66499BD5)
else -> Color(0x66C67A42)
}
@Composable
private fun FeaturedMovieCard(
pick: HomeHeroPick,
onFocused: () -> Unit,
onClick: () -> Unit,
modifier: Modifier,
previewArtwork: ImageBitmap?,
) {
val item = pick.item
FocusScaleContainer(
onFocused = onFocused,
onClick = onClick,
contentDescription = "Featured movie, ${item.name}",
modifier = modifier.clip(RoundedCornerShape(MembyPanelCorner)),
) { focused ->
Box(Modifier.fillMaxSize().background(Color(0xFF151B20))) {
HeroArtwork(item, previewArtwork, Modifier.fillMaxSize())
Box(
Modifier.fillMaxSize().background(
Brush.horizontalGradient(
0f to Color(0xF20A0D10),
0.48f to Color(0xA80A0D10),
1f to Color(0x160A0D10),
),
),
)
Box(
Modifier.fillMaxSize().background(
Brush.verticalGradient(
0f to Color.Transparent,
0.72f to Color.Transparent,
1f to Color(0xD9000000),
),
),
)
// 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.
var titleLines by remember(item.id) { mutableIntStateOf(1) }
Column(
modifier = Modifier.align(Alignment.CenterStart).fillMaxWidth(0.58f).padding(22.dp),
) {
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,
)
}
}
Spacer(Modifier.height(14.dp))
MembyPlayChip(label = "Play", focused = focused)
}
}
}
}
/**
* 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".
*/
@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)
}
}
}
@Composable
private fun MiniMovieCard(
pick: HomeHeroPick,
onFocused: () -> Unit,
onClick: () -> Unit,
modifier: Modifier,
previewArtwork: ImageBitmap?,
) {
val item = pick.item
FocusScaleContainer(
onFocused = onFocused,
onClick = onClick,
contentDescription = "${pick.label} movie, ${item.name}",
modifier = modifier.clip(RoundedCornerShape(MembyPanelCorner)),
) { focused ->
Box(Modifier.fillMaxSize().background(Color(0xFF192027))) {
HeroArtwork(item, previewArtwork, Modifier.fillMaxSize())
Box(Modifier.fillMaxSize().background(labelTint(pick.label)))
Box(
Modifier.fillMaxSize().background(
Brush.horizontalGradient(
0f to Color(0xE80A0D10),
0.78f to Color(0x850A0D10),
1f to Color(0x300A0D10),
),
),
)
Column(Modifier.align(Alignment.CenterStart).padding(horizontal = 14.dp, vertical = 10.dp)) {
Text(
pick.label,
color = if (focused) MembyAccent else MembyQuietText,
fontSize = 9.sp,
fontWeight = FontWeight.Bold,
letterSpacing = 0.8.sp,
)
Spacer(Modifier.height(4.dp))
Text(
item.name,
color = Color.White,
fontSize = 15.sp,
lineHeight = 17.sp,
fontWeight = FontWeight.Bold,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
item.productionYear?.let { year ->
Spacer(Modifier.height(3.dp))
Text(year.toString(), color = MembyQuietText, fontSize = 10.sp)
}
}
if (focused) {
Box(
modifier = Modifier
.align(Alignment.CenterEnd)
.padding(end = 16.dp)
.size(38.dp)
.shadow(14.dp, CircleShape)
.clip(CircleShape)
.background(MembyAccent)
.border(2.dp, Color.White, CircleShape),
contentAlignment = Alignment.Center,
) {
Icon(
Icons.Default.PlayArrow,
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(24.dp),
)
}
}
}
}
}
@Composable
private fun HeroArtwork(item: BaseItem, previewArtwork: ImageBitmap?, modifier: Modifier) {
if (previewArtwork != null) {
Image(
bitmap = previewArtwork,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = modifier,
)
return
}
val repo = ServiceLocator.repository
val density = LocalDensity.current
val artworkWidth = with(density) { 760.dp.roundToPx() }.coerceIn(480, 1280)
val artwork = remember(item.id, artworkWidth) {
repo.backdropUrl(item, artworkWidth) ?: repo.primaryUrl(item, artworkWidth)
}
if (artwork != null) {
AsyncImage(
model = artwork,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = modifier,
)
} else {
Box(
modifier.background(
Brush.linearGradient(
listOf(Color(0xFF172830), Color(0xFF26343A), Color(0xFF12171B)),
),
),
)
}
}
@@ -10,7 +10,6 @@ import com.ponzischeme89.memby.data.analytics.RowAnalytics
import com.ponzischeme89.memby.data.friendlyEmbyError
import com.ponzischeme89.memby.data.isMaintenanceError
import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.data.model.GatewayUpdate
import com.ponzischeme89.memby.data.model.HomeRow
import com.ponzischeme89.memby.data.model.UserItemData
import kotlinx.coroutines.Dispatchers
@@ -18,8 +17,12 @@ import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
@@ -50,15 +53,29 @@ data class HomeUiState(
* rows behind it would be stale and unusable anyway.
*/
val maintenanceMessage: String? = null,
/**
* The gateway's update verdict. A mandatory one blocks the home screen; an optional
* one shows a prompt the viewer can dismiss for this session.
*/
val update: GatewayUpdate? = 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
* emits when something changed about what is on the rows — see the note there.
*
* Read rows and [loading] from this; the blanked fields are meaningless in it.
*/
fun contentSlice(): HomeUiState = copy(
hasRefreshError = false,
statusMessage = null,
maintenanceMessage = null,
)
fun statusSlice(): HomeStatus = HomeStatus(
hasRefreshError = hasRefreshError,
statusMessage = statusMessage,
maintenanceMessage = maintenanceMessage,
)
fun toCache() = HomeCache(
continueWatching = continueWatching,
nextUp = nextUp,
@@ -84,6 +101,16 @@ data class HomeUiState(
}
}
/**
* The connection-health slice of [HomeUiState]: what the banner and the maintenance
* screen need, and nothing that would drag the rows into a recomposition with it.
*/
data class HomeStatus(
val hasRefreshError: Boolean = false,
val statusMessage: String? = null,
val maintenanceMessage: String? = null,
)
data class ForYouUiState(
val rows: List<HomeRow> = emptyList(),
val availableMinutes: Int = 0,
@@ -95,6 +122,25 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
private val refreshMutex = Mutex()
private val _state = MutableStateFlow(HomeUiState.from(repository.cachedHome()))
val state: StateFlow<HomeUiState> = _state.asStateFlow()
// HomeScreen is a very large composable, so reading the whole of [state] there meant
// every emission invalidated the launcher: the slow-
// connection banner and each of the four section loads all recomposed the rows, the
// rail and every overlay, and rebuilt the row list with them. These three narrow
// projections let it subscribe only to what each part actually renders.
/** Rows and their loading flags. Does not emit for banner changes. */
val content: StateFlow<HomeUiState> = _state
.map(HomeUiState::contentSlice)
.distinctUntilChanged()
.stateIn(viewModelScope, SharingStarted.Eagerly, _state.value.contentSlice())
/** Connection health. Does not emit when rows change. */
val status: StateFlow<HomeStatus> = _state
.map(HomeUiState::statusSlice)
.distinctUntilChanged()
.stateIn(viewModelScope, SharingStarted.Eagerly, _state.value.statusSlice())
private val _focusedItem = MutableStateFlow<BaseItem?>(initialFocusedItem(_state.value))
val focusedItem: StateFlow<BaseItem?> = _focusedItem.asStateFlow()
private val _forYou = MutableStateFlow(ForYouUiState())
@@ -107,20 +153,8 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
/** Row engagement, buffered here and uploaded in batches. */
private val analytics = RowAnalytics()
/** Optional prompt the viewer waved away; forgotten when the app restarts. */
private var dismissedUpdateVersion: String? = null
init {
refreshAll()
checkForAppUpdate()
viewModelScope.launch {
// A TV can leave Memby open for days. Keep checking at a deliberately slow
// cadence so a newly published release appears without requiring a restart.
while (true) {
delay(UPDATE_CHECK_INTERVAL_MS)
checkForAppUpdate()
}
}
viewModelScope.launch {
repository.playbackStops.collect { refreshWatching() }
}
@@ -134,29 +168,8 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
}
}
/**
* Asks the gateway whether this build is still allowed. Runs on every launch, so an
* operator can retire a version without waiting for anyone to open Settings.
*/
fun checkForAppUpdate() {
viewModelScope.launch(Dispatchers.IO) {
val update = repository.checkAppUpdate() ?: return@launch
// A dismissed optional prompt stays dismissed for this session; a mandatory
// one always reasserts itself.
if (update.isOptional && dismissedUpdateVersion == update.version) return@launch
_state.update { it.copy(update = update) }
}
}
/** Dismisses an optional prompt. Mandatory updates ignore this by construction. */
fun dismissUpdatePrompt() {
val current = _state.value.update ?: return
if (current.isMandatory) return
dismissedUpdateVersion = current.version
_state.update { it.copy(update = null) }
}
fun trackRowImpression(rowId: String, rowKind: String) = analytics.rowImpression(rowId, rowKind)
fun trackRowImpression(rowId: String, rowKind: String, visibleItemIds: List<String> = emptyList()) =
analytics.rowImpression(rowId, rowKind, visibleItemIds)
fun trackRowFocused(rowId: String, rowKind: String, itemId: String) =
analytics.rowFocused(rowId, rowKind, itemId)
@@ -286,6 +299,9 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
val cached = synchronized(metadataCache) { metadataCache[item.id] }
val focused = (cached ?: item).copy(
membyAiringToday = item.membyAiringToday || cached?.membyAiringToday == true,
membyRecommendationReason = item.membyRecommendationReason
?: cached?.membyRecommendationReason,
membyCompatibility = item.membyCompatibility ?: cached?.membyCompatibility,
)
_focusedItem.value = focused
metadataJob?.cancel()
@@ -298,7 +314,12 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
if (item.membyPlayable) {
launch { runCatching { repository.prefetchPlayable(cached ?: item) } }
}
if (cached == null && !item.isSonarrSchedule) {
// Warm the explanation and franchise siblings while the card is already
// focused, so opening Details does not add a reason line a frame later.
if (!item.isSchedule && (item.isMovie || item.isSeries)) {
launch { runCatching { repository.getRelated(focused) } }
}
if (cached == null && !item.isSchedule) {
launch {
val details = runCatching {
repository.getItemDetails(item.id)
@@ -464,7 +485,6 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
companion object {
private const val FOCUS_METADATA_DEBOUNCE_MS = 140L
private const val ANALYTICS_FLUSH_INTERVAL_MS = 20_000L
private const val UPDATE_CHECK_INTERVAL_MS = 60L * 60L * 1_000L
private fun initialFocusedItem(state: HomeUiState): BaseItem? =
state.watchingAndNextUp.firstOrNull()
@@ -489,6 +509,7 @@ private fun List<HomeRow>.airingTodayShowKeys(): Set<String> =
firstOrNull { it.id == "sonarr-airing-today" }
?.items
.orEmpty()
.filter { it.membyAirDayLabel.equals("Today", ignoreCase = true) }
.mapTo(mutableSetOf()) { it.name.showMatchKey() }
.filterTo(mutableSetOf(), String::isNotEmpty)
@@ -497,7 +518,7 @@ private fun List<HomeRow>.withAiringTodayRowTags(keys: Set<String>): List<HomeRo
private fun List<BaseItem>.withAiringTodayItemTags(keys: Set<String>): List<BaseItem> =
map { item ->
if (!item.isSonarrSchedule && item.isSeries && item.name.showMatchKey() in keys) {
if (!item.isTvSchedule && item.isSeries && item.name.showMatchKey() in keys) {
item.copy(membyAiringToday = true)
} else {
item
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,297 @@
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.FirstPage
import androidx.compose.material.icons.filled.Movie
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.runtime.snapshotFlow
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
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.ui.detail.DetailTab
import com.ponzischeme89.memby.ui.detail.DetailZone
import com.ponzischeme89.memby.ui.detail.creditRows
import com.ponzischeme89.memby.ui.detail.detailPositions
import com.ponzischeme89.memby.ui.detail.detailTab
import com.ponzischeme89.memby.ui.detail.detailTabs
import com.ponzischeme89.memby.ui.detail.franchiseStart
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.technicalSpecs
import kotlinx.coroutines.delay
/**
* A movie, or any single playable item that is not a series.
*
* Everything structural lives in [DetailPageScaffold]; this file only decides which tabs a
* movie has and what goes in each one.
*/
@Composable
fun MediaDetailsOverlay(
item: BaseItem,
onPlay: (BaseItem) -> Unit,
onToggleFavorite: (BaseItem, Boolean) -> Unit,
onTogglePlayed: (BaseItem, Boolean) -> Unit,
onClose: () -> Unit,
onOpenItem: (BaseItem) -> Unit = {},
modifier: Modifier = Modifier,
) {
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) }
LaunchedEffect(item.id) {
related = ServiceLocator.repository.getRelated(item)
}
LaunchedEffect(item.id) {
trailer = ServiceLocator.repository.getLocalTrailer(item.id)
}
MediaDetailContent(
item = item,
onPlay = onPlay,
onToggleFavorite = onToggleFavorite,
onTogglePlayed = onTogglePlayed,
onOpenItem = onOpenItem,
related = related,
trailer = trailer,
hideWatchedMovies = settings.hideWatchedMovies,
modifier = modifier,
)
}
/**
* The layout, with everything it renders as a parameter — so it can be screenshotted and
* previewed without a repository behind it. [related] is null while it is still coming.
*/
@Composable
internal fun MediaDetailContent(
item: BaseItem,
onPlay: (BaseItem) -> Unit,
onToggleFavorite: (BaseItem, Boolean) -> Unit,
onTogglePlayed: (BaseItem, Boolean) -> Unit,
modifier: Modifier = Modifier,
related: RelatedContent? = null,
trailer: BaseItem? = null,
hideWatchedMovies: Boolean = false,
onOpenItem: (BaseItem) -> Unit = {},
) {
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) {
visibleWithWatchedPreference(related?.items.orEmpty(), hideWatchedMovies)
}
val badges = remember(item.id, item.mediaStreams) { mediaBadges(item) }
val franchise = remember(item.id, item.collectionName, related?.items) {
franchiseStart(item, related?.items.orEmpty())
}
val tabs = remember(item.id) { detailTabs(isSeries = false) }
// The item arrives with whatever a home row asked for and is replaced by the full
// record moments later. Panes say "loading" rather than "nothing here" until then.
val detailsLoaded = item.people.isNotEmpty() || item.mediaStreams.isNotEmpty()
val remembered = remember(item.id) { detailPositions.get(item.id) }
// Every newly opened title starts as a complete hero frame. Remembering a content
// tab also restored its focus and caused the page to reopen below the artwork.
var tabKey by remember(item.id) { mutableStateOf(DetailTab.OVERVIEW.key) }
val selectedTab = detailTab(tabKey, tabs)
val play = remember(item.id) { FocusRequester() }
val tabStrip = remember(item.id) { FocusRequester() }
// One requester per pane. Sharing a single "information pane" requester between the
// Overview and Cast & Details panes attached it to two nodes at once for the 80ms
// AnimatedContent spends fading the outgoing pane out, and a Down press landing in that
// window could request focus on the pane that is disappearing.
val overviewPane = remember(item.id) { FocusRequester() }
val castPane = remember(item.id) { FocusRequester() }
val firstRelated = remember(item.id) { FocusRequester() }
val relatedListState = rememberLazyListState(remembered.relatedIndex)
val contentEntry = when (selectedTab) {
DetailTab.MORE_LIKE_THIS -> firstRelated
DetailTab.CAST_DETAILS -> castPane
else -> overviewPane
}
DetailPositionMemory(
itemId = item.id,
tabKey = tabKey,
relatedIndex = { relatedListState.firstVisibleItemIndex },
)
RestoreDetailFocus(
itemId = item.id,
zone = DetailZone.PLAY,
play = play,
tabStrip = tabStrip,
related = firstRelated,
relatedReady = visibleRelated.isNotEmpty(),
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 = badges + listOfNotNull(airingBadgeLabel(item)),
tabs = tabs,
selectedTab = selectedTab,
onSelectTab = { tabKey = it.key },
playLabel = primaryActionLabel(item),
onPlay = { onPlay(item) },
playFocusRequester = play,
tabFocusRequester = tabStrip,
contentFocusRequester = contentEntry,
modifier = modifier,
progress = playbackProgress(item),
progressLabel = remainingLabel(item),
reasons = related?.reasons?.takeIf(List<String>::isNotEmpty)
?: listOfNotNull(item.membyRecommendationReason?.takeIf(String::isNotBlank)),
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,
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"
},
),
)
trailer?.let {
add(DetailHeroAction(Icons.Default.Movie, "Play trailer", onClick = { onPlay(it) }))
}
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) },
),
)
// Appended because related content arrives asynchronously. Existing action
// indices (and therefore their attached FocusRequesters) must not move.
franchise?.takeIf { it.firstMovie.id != item.id }?.let { start ->
add(
DetailHeroAction(
icon = Icons.Default.FirstPage,
description = "Open the first ${start.name} movie, ${start.firstMovie.name}",
label = "Start with ${start.firstMovie.name}",
onClick = { onOpenItem(start.firstMovie) },
),
)
}
},
) { visibleTab ->
when (visibleTab) {
DetailTab.OVERVIEW -> DetailOverviewPane(item, credits, overviewPane)
DetailTab.MORE_LIKE_THIS -> DetailMoreLikeThisPane(
items = visibleRelated,
loading = related == null,
onSelect = onOpenItem,
firstFocusRequester = firstRelated,
listState = relatedListState,
)
DetailTab.CAST_DETAILS -> DetailCastAndDetailsPane(
item = item,
credits = credits,
specs = specs,
detailsLoaded = detailsLoaded,
focusRequester = castPane,
)
DetailTab.EPISODES -> Unit
}
}
}
/**
* Writes the page's position back to [detailPositions] as it changes.
*
* Kept out of the pages themselves because both need exactly this and getting it slightly
* different in two places is how "it remembered last time" bugs start. The zone is written
* by the scaffold's focus callbacks; this covers the two things focus does not report.
*/
@Composable
internal fun DetailPositionMemory(
itemId: String,
tabKey: String,
season: Int? = null,
episodeIndex: () -> Int = { 0 },
relatedIndex: () -> Int = { 0 },
) {
LaunchedEffect(itemId, tabKey, season) {
detailPositions.update(itemId) { it.copy(tabKey = tabKey, season = season ?: it.season) }
}
LaunchedEffect(itemId) {
snapshotFlow { episodeIndex() to relatedIndex() }.collect { (episode, relatedCard) ->
detailPositions.update(itemId) {
it.copy(episodeIndex = episode, relatedIndex = relatedCard)
}
}
}
}
/**
* Puts focus back where the viewer left it.
*
* Play is focused first regardless: it exists on the first frame, so the remote is live
* while the rest of the page is still arriving. Only then does focus move on to the band
* the viewer was actually in — and only if that band has something to land on, because a
* request against an unplaced [FocusRequester] throws.
*/
@Composable
internal fun RestoreDetailFocus(
itemId: String,
zone: DetailZone,
play: FocusRequester,
tabStrip: FocusRequester,
related: FocusRequester,
relatedReady: Boolean,
content: FocusRequester? = null,
contentReady: Boolean = false,
) {
LaunchedEffect(itemId) {
delay(32L)
runCatching { play.requestFocus() }
}
// One shot. The readiness flags flip when the network lands, and re-running then would
// haul focus out from under a viewer who has already started moving around the page.
var restored by remember(itemId) { mutableStateOf(zone == DetailZone.PLAY) }
LaunchedEffect(itemId, relatedReady, contentReady) {
if (restored) return@LaunchedEffect
val target = when (zone) {
DetailZone.PLAY -> null
DetailZone.TABS -> tabStrip
DetailZone.CONTENT -> content?.takeIf { contentReady }
DetailZone.RELATED -> related.takeIf { relatedReady }
} ?: return@LaunchedEffect
// After Play, so the restore lands second and wins; a frame either way is
// invisible, but the order is not.
delay(96L)
if (runCatching { target.requestFocus() }.isSuccess) restored = true
}
}
@@ -0,0 +1,180 @@
package com.ponzischeme89.memby.ui
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.clickable
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.tv.material3.Icon
import androidx.tv.material3.Text
import com.ponzischeme89.memby.ui.theme.MembyAccent
import com.ponzischeme89.memby.ui.theme.MembyCardCorner
import com.ponzischeme89.memby.ui.theme.MembyChipCorner
import com.ponzischeme89.memby.ui.theme.MembyHairline
import com.ponzischeme89.memby.ui.theme.MembyMutedText
/**
* One green Play button, in two sizes.
*
* The app had three button languages: this one, a hand-rolled copy in the home hero with
* the same look and different metrics, and raw `androidx.tv.material3.Button`s carrying
* glyphs in their labels ("▶ Resume", "✓ 30 min"), which picked up theme colours nothing
* else on those screens uses. A remote user learns one shape and one green; drawing it
* three ways teaches them nothing and makes every metric a separate decision.
*
* [MembyPlayChip] is the same surface without focus behaviour when the whole hero card is
* already the focusable node.
*/
@Composable
internal fun MembyPlayButton(
label: String,
onClick: () -> Unit,
onFocused: () -> Unit,
modifier: Modifier = Modifier,
compact: Boolean = false,
) {
var focused by remember { mutableStateOf(false) }
val scale by animateFloatAsState(if (focused) 1.055f else 1f, tween(100), label = "play-focus")
PrimaryActionSurface(
label = label,
icon = Icons.Default.PlayArrow,
focused = focused,
compact = compact,
modifier = modifier
.graphicsLayer { scaleX = scale; scaleY = scale; translationY = if (focused) -3f else 0f }
.onFocusChanged { focused = it.isFocused; if (it.isFocused) onFocused() }
.clickable(onClick = onClick),
)
}
@Composable
internal fun MembyPlayChip(
label: String,
focused: Boolean,
modifier: Modifier = Modifier,
compact: Boolean = true,
) {
PrimaryActionSurface(
label = label,
icon = Icons.Default.PlayArrow,
focused = focused,
compact = compact,
modifier = modifier,
)
}
@Composable
private fun PrimaryActionSurface(
label: String,
icon: ImageVector,
focused: Boolean,
compact: Boolean,
modifier: Modifier,
) {
val shape = RoundedCornerShape(MembyCardCorner)
Row(
modifier = modifier
.shadow(if (focused) 18.dp else 7.dp, shape)
.clip(shape)
.background(MembyAccent)
.border(if (focused) 2.dp else 1.dp, if (focused) Color.White else MembyAccent, shape)
.padding(
horizontal = if (compact) 14.dp else 23.dp,
vertical = if (compact) 7.dp else 12.dp,
),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
icon,
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(if (compact) 18.dp else 23.dp),
)
Spacer(Modifier.width(if (compact) 6.dp else 8.dp))
Text(
label,
color = Color.White,
fontSize = if (compact) 13.sp else 16.sp,
fontWeight = FontWeight.Bold,
maxLines = 1,
)
}
}
/**
* A selectable chip for a small set of mutually exclusive choices. The tick is a real icon
* on the selected chip rather than a character in the label, so the chip does not change
* width when the choice moves.
*/
@Composable
internal fun MembyChoiceChip(
label: String,
selected: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
var focused by remember { mutableStateOf(false) }
val shape = RoundedCornerShape(MembyChipCorner)
Row(
modifier = modifier
.clip(shape)
.background(
when {
focused -> Color.White
selected -> MembyAccent
else -> Color.White.copy(alpha = 0.07f)
},
)
.border(1.dp, if (selected || focused) Color.Transparent else MembyHairline, shape)
.onFocusChanged { focused = it.isFocused }
.clickable(onClick = onClick)
.padding(horizontal = 14.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
if (selected) {
Icon(
Icons.Default.Check,
contentDescription = null,
tint = if (focused) Color.Black else Color.White,
modifier = Modifier.size(15.dp),
)
Spacer(Modifier.width(6.dp))
}
Text(
label,
color = when {
focused -> Color.Black
selected -> Color.White
else -> MembyMutedText
},
fontSize = 13.sp,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
)
}
}
@@ -0,0 +1,442 @@
package com.ponzischeme89.memby.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Bookmark
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material.icons.filled.NotificationsOff
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
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.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.zIndex
import androidx.tv.material3.Button
import androidx.tv.material3.Card
import androidx.tv.material3.Icon
import androidx.tv.material3.Text
import coil.compose.AsyncImage
import com.ponzischeme89.memby.data.EmbyRepository
import com.ponzischeme89.memby.data.model.MyShow
import com.ponzischeme89.memby.data.model.NotificationPreferences
import com.ponzischeme89.memby.data.model.UserNotification
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
@Composable
internal fun MyShowsStrip(
shows: List<MyShow>,
repository: EmbyRepository,
availableWidth: Dp,
density: String,
navigationFocusRequester: FocusRequester,
contentFocusRequester: FocusRequester?,
onShowSelected: (MyShow) -> Unit,
onContentFocused: () -> Unit,
) {
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(HomeRowHeaderSpacing),
) {
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 36.dp),
verticalAlignment = Alignment.CenterVertically,
) {
HomeRowHeaderIcon(Icons.Default.Bookmark)
Spacer(Modifier.width(HomeRowHeaderIconGap))
Text(
"My Shows",
color = Color(0xFFF1F3F4),
fontSize = 20.sp,
fontWeight = FontWeight.Bold,
)
if (shows.isNotEmpty()) {
Text(
shows.size.toString(),
color = Color(0xFFAEB7BF),
fontSize = 12.sp,
fontWeight = FontWeight.SemiBold,
modifier = Modifier
.padding(start = 10.dp)
.clip(RoundedCornerShape(20.dp))
.background(Color.White.copy(alpha = 0.08f))
.padding(horizontal = 8.dp, vertical = 3.dp),
)
}
}
if (shows.isEmpty()) {
Text(
"Open any series and choose “Add to My Shows”.",
color = Color(0xFFAEB7BF),
fontSize = 14.sp,
modifier = Modifier.padding(horizontal = 36.dp, vertical = 18.dp),
)
} else {
val cardsAcross = when (density) {
"compact" -> 8
"large" -> 5
else -> 7
}
val cardWidth = responsiveRowCardWidth(availableWidth, cardsAcross, 102.dp, 218.dp)
LazyRow(
contentPadding = PaddingValues(horizontal = 36.dp, vertical = 10.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
items(shows, key = MyShow::itemId) { show ->
MyShowCard(
show = show,
repository = repository,
width = cardWidth,
onClick = { onShowSelected(show) },
modifier = Modifier
.then(
if (show.itemId == shows.first().itemId && contentFocusRequester != null) {
Modifier.focusRequester(contentFocusRequester)
} else {
Modifier
},
)
.focusProperties { left = navigationFocusRequester }
.onFocusChanged { if (it.hasFocus) onContentFocused() },
)
}
}
}
}
}
@Composable
private fun MyShowCard(
show: MyShow,
repository: EmbyRepository,
width: Dp,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
FocusScaleContainer(
onFocused = {},
onClick = onClick,
contentDescription = "${show.title}, ${myShowCardSubtitle(show)}",
modifier = modifier.width(width),
) { focused ->
Column {
Box(
modifier = Modifier
.width(width)
.aspectRatio(2f / 3f)
.shadow(if (focused) 7.dp else 0.dp, RoundedCornerShape(9.dp))
.clip(RoundedCornerShape(9.dp))
.background(Color(0xFF20252A))
.border(
2.dp,
if (focused) Color.White else Color.White.copy(alpha = 0.07f),
RoundedCornerShape(9.dp),
),
contentAlignment = Alignment.Center,
) {
AsyncImage(
model = repository.myShowImageUrl(show.itemId, show.imageTag),
contentDescription = null,
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),
)
}
}
Text(
show.title,
color = if (focused) Color.White else Color(0xFFE1E5E8),
fontSize = 14.sp,
fontWeight = if (focused) FontWeight.Bold else FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(top = 7.dp).fillMaxWidth(),
)
Text(
myShowCardSubtitle(show),
color = Color(0xFFAEB7BF),
fontSize = 12.sp,
fontWeight = FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(top = 2.dp).fillMaxWidth(),
)
}
}
}
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)
else -> null
}
internal fun myShowCardSubtitle(show: MyShow): String {
if (!show.nextEpisode.isNullOrBlank()) {
val date = runCatching {
DateTimeFormatter.ofPattern("EEE, d MMM")
.format(Instant.parse(show.nextEpisode).atZone(ZoneId.systemDefault()))
}.getOrNull()
if (date != null) return "Next episode · $date"
}
return when {
show.lifecycle != "Unknown" -> show.lifecycle
show.sonarrStatus != "Not found" -> show.sonarrStatus
else -> "Saved show"
}
}
@Composable
internal fun MyShowDetailsOverlay(
show: MyShow,
repository: EmbyRepository,
removing: Boolean,
onRemove: () -> Unit,
onClose: () -> Unit,
) {
Box(
Modifier.fillMaxSize().zIndex(8f).background(Color(0xF5090B0D)),
contentAlignment = Alignment.Center,
) {
Row(
modifier = Modifier
.fillMaxWidth(0.72f)
.background(Color(0xFF151A1E), RoundedCornerShape(20.dp))
.padding(34.dp),
horizontalArrangement = Arrangement.spacedBy(30.dp),
verticalAlignment = Alignment.CenterVertically,
) {
AsyncImage(
model = repository.myShowImageUrl(show.itemId, show.imageTag, 500),
contentDescription = null,
contentScale = ContentScale.Fit,
modifier = Modifier
.width(170.dp)
.aspectRatio(2f / 3f)
.clip(RoundedCornerShape(12.dp))
.background(Color(0xFF20252A))
.border(1.dp, Color.White.copy(alpha = 0.12f), RoundedCornerShape(12.dp)),
)
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(10.dp)) {
Text(show.title, color = Color.White, fontSize = 30.sp, fontWeight = FontWeight.Bold)
StatusLine("Sonarr", show.sonarrStatus)
StatusLine("Next episode", formatMyShowDate(show.nextEpisode))
StatusLine("Series status", show.lifecycle)
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Button(onClick = onRemove, enabled = !removing) {
Text(if (removing) "Removing…" else "Remove from My Shows")
}
Button(onClick = onClose) { Text("Close") }
}
}
}
}
}
@Composable
private fun StatusLine(label: String, value: String) {
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Text("$label:", color = Color(0xFFAEB7BF), fontSize = 16.sp)
Text(value, color = Color.White, fontSize = 16.sp, fontWeight = FontWeight.SemiBold)
}
}
@Composable
internal fun NotificationBell(
unreadCount: Int,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
var focused by remember { mutableStateOf(false) }
Card(
onClick = onClick,
modifier = modifier
.size(52.dp)
.onFocusChanged { focused = it.hasFocus }
.graphicsLayer {
scaleX = if (focused) 1.08f else 1f
scaleY = if (focused) 1.08f else 1f
},
) {
Box(
Modifier.fillMaxSize().background(if (focused) Color.White else Color(0xCC20262B)),
contentAlignment = Alignment.Center,
) {
Icon(
Icons.Default.Notifications,
contentDescription = "Notifications",
tint = if (focused) Color.Black else Color.White,
modifier = Modifier.size(25.dp),
)
if (unreadCount > 0) {
Text(
unreadCount.coerceAtMost(99).toString(),
color = Color.White,
fontSize = 10.sp,
fontWeight = FontWeight.Bold,
modifier = Modifier
.align(Alignment.TopEnd)
.background(Color(0xFFE04747), CircleShape)
.padding(horizontal = 5.dp, vertical = 2.dp),
)
}
}
}
}
@Composable
internal fun NotificationsOverlay(
notifications: List<UserNotification>,
preferences: NotificationPreferences,
onToggleEnabled: () -> Unit,
onToggleShowReturns: () -> Unit,
onRead: (UserNotification) -> Unit,
onDismissNotification: (UserNotification) -> Unit,
onClose: () -> Unit,
) {
Box(Modifier.fillMaxSize().zIndex(8f).background(Color(0xF5090B0D))) {
Column(
modifier = Modifier.fillMaxSize().padding(horizontal = 72.dp, vertical = 48.dp),
verticalArrangement = Arrangement.spacedBy(18.dp),
) {
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column {
Text("Notifications", color = Color.White, fontSize = 32.sp, fontWeight = FontWeight.Bold)
Text(
"Show-return alerts are saved for this user on every Memby TV.",
color = Color(0xFFAEB7BF),
fontSize = 15.sp,
)
}
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Button(onClick = onToggleEnabled) {
Icon(
if (preferences.enabled) Icons.Default.Notifications else Icons.Default.NotificationsOff,
contentDescription = null,
)
Spacer(Modifier.width(8.dp))
Text(if (preferences.enabled) "Alerts on" else "Alerts off")
}
Button(onClick = onToggleShowReturns, enabled = preferences.enabled) {
Text(
if (preferences.showReturnAlerts) {
"Return alerts on"
} else {
"Return alerts off"
},
)
}
Button(onClick = onClose) {
Icon(Icons.Default.Close, contentDescription = null)
Spacer(Modifier.width(6.dp))
Text("Close")
}
}
}
if (notifications.isEmpty()) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text("Youre all caught up.", color = Color(0xFFD0D6DB), fontSize = 20.sp)
}
} else {
LazyColumn(verticalArrangement = Arrangement.spacedBy(10.dp)) {
items(notifications, key = UserNotification::id) { notification ->
Row(
modifier = Modifier
.fillMaxWidth()
.background(
if (notification.unread) Color(0xFF202B25) else Color(0xFF171B1E),
RoundedCornerShape(12.dp),
)
.padding(18.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(18.dp),
) {
Box(
Modifier.size(10.dp).background(
if (notification.unread) Color(0xFF52B54B) else Color.Transparent,
CircleShape,
),
)
Column(Modifier.weight(1f)) {
Text(
notification.title,
color = Color.White,
fontSize = 17.sp,
fontWeight = FontWeight.SemiBold,
)
Text(notification.message, color = Color(0xFFD0D6DB), fontSize = 15.sp)
}
if (notification.unread) {
Button(onClick = { onRead(notification) }) { Text("Mark read") }
}
Button(onClick = { onDismissNotification(notification) }) { Text("Dismiss") }
}
}
}
}
}
}
}
internal fun formatMyShowDate(value: String?): String {
if (value.isNullOrBlank()) return "Not announced"
return runCatching {
DateTimeFormatter.ofPattern("EEE, d MMM yyyy · h:mm a")
.format(Instant.parse(value).atZone(ZoneId.systemDefault()))
}.getOrDefault("Not announced")
}
@@ -0,0 +1,18 @@
package com.ponzischeme89.memby.ui
internal const val QuickActionsHoldDurationMillis = 650L
internal enum class QuickActionDirection { UP, DOWN }
internal fun quickActionNextIndex(
currentIndex: Int,
actionCount: Int,
direction: QuickActionDirection,
): Int {
if (actionCount <= 0) return 0
val current = currentIndex.coerceIn(0, actionCount - 1)
return when (direction) {
QuickActionDirection.UP -> (current - 1).coerceAtLeast(0)
QuickActionDirection.DOWN -> (current + 1).coerceAtMost(actionCount - 1)
}
}
@@ -0,0 +1,44 @@
package com.ponzischeme89.memby.ui
internal enum class RowFocusDirection {
UP,
DOWN,
}
internal data class RowFocusRequest(
val rowId: String,
val itemIndex: Int,
val requestId: Int,
)
/**
* Finds the next row that can actually receive card focus. Loading and empty rows are
* deliberately skipped so one D-pad press always produces visible movement.
*/
internal fun adjacentFocusableRowIndex(
itemCounts: List<Int>,
currentIndex: Int,
direction: RowFocusDirection,
): Int? {
if (currentIndex !in itemCounts.indices) return null
val step = if (direction == RowFocusDirection.DOWN) 1 else -1
var candidate = currentIndex + step
while (candidate in itemCounts.indices) {
if (itemCounts[candidate] > 0) return candidate
candidate += step
}
return null
}
/**
* Returning to a row restores the card last used there. On a first visit, preserve the
* viewer's horizontal position as far as the shorter destination row permits.
*/
internal fun rowEntryItemIndex(
sourceIndex: Int,
destinationItemCount: Int,
rememberedDestinationIndex: Int?,
): Int {
if (destinationItemCount <= 0) return 0
return (rememberedDestinationIndex ?: sourceIndex).coerceIn(0, destinationItemCount - 1)
}
File diff suppressed because it is too large Load Diff
@@ -3,18 +3,15 @@ package com.ponzischeme89.memby.ui
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
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.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
@@ -25,8 +22,6 @@ 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.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.State
@@ -37,7 +32,6 @@ 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.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Brush
@@ -45,14 +39,15 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.layout.ContentScale
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.compose.ui.zIndex
import androidx.tv.material3.Text
import coil.compose.AsyncImage
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.ponzischeme89.memby.R
import com.ponzischeme89.memby.ServiceLocator
import com.ponzischeme89.memby.data.MaintenanceMonitor
import com.ponzischeme89.memby.data.ServiceAlert
@@ -62,8 +57,13 @@ private val AlertAccent = Color(0xFF52B54B)
private val AlertTitle = Color(0xFFF2F5F7)
private val AlertBody = Color(0xFFC3CBD2)
/** Height of the bar itself, before the rule and fade that blend it into the screen. */
private val BannerHeight = 104.dp
/**
* Height of the bar itself, before the rule and fade that blend it into the screen.
*
* Kept to a strip rather than a panel because this now appears over playback as well as
* over the launcher: whatever it says, it is covering somebody's film while it says it.
*/
private val BannerHeight = 48.dp
/**
* Broadcast-style overscan inset. TVs crop the edges of the picture by a few percent, so
@@ -103,12 +103,14 @@ fun ServiceAlertBanner(suppressed: Boolean, modifier: Modifier = Modifier) {
AnimatedVisibility(
visible = alert != null,
// In from above the frame, out the same way. Slower arriving than leaving:
// showing up should be noticed, going away should not.
enter = slideInVertically(tween(420, easing = FastOutSlowInEasing)) { -it } +
fadeIn(tween(260)),
exit = slideOutVertically(tween(320, easing = FastOutSlowInEasing)) { -it } +
fadeOut(tween(220)),
// In from above the frame, out the same way, both unhurried: over a film the
// arrival is the intrusion, so it eases in rather than snapping down. Still
// slower arriving than leaving — showing up should be noticed, going away
// should not.
enter = slideInVertically(tween(680, easing = FastOutSlowInEasing)) { -it } +
fadeIn(tween(520)),
exit = slideOutVertically(tween(420, easing = FastOutSlowInEasing)) { -it } +
fadeOut(tween(300)),
modifier = modifier.zIndex(8f),
) {
lastAlert?.let { AlertBanner(it) }
@@ -141,53 +143,52 @@ internal fun AlertBanner(
.fillMaxWidth()
.height(BannerHeight)
.background(
// Darkest at the left where the text sits, easing off to the right so
// the artwork behind the banner still shows through.
// Near-black, and darkest at the left where the text sits. Over a
// film almost anything can be behind this, so the bar supplies its
// own contrast rather than relying on the picture underneath.
Brush.horizontalGradient(
0f to Color(0xFF0E1418),
0.55f to Color(0xF20E1418),
1f to Color(0xD9121A20),
0f to Color(0xFF04070A),
0.55f to Color(0xF504070A),
1f to Color(0xE0070B0F),
),
)
.padding(horizontal = SafeAreaHorizontal),
verticalAlignment = Alignment.CenterVertically,
) {
AlertPoster(posterUrl = alert.posterUrl)
Spacer(Modifier.width(20.dp))
Column(Modifier.weight(1f)) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
LivePip()
Text(
"JUST AIRED",
color = AlertAccent,
fontSize = 13.sp,
fontWeight = FontWeight.Bold,
letterSpacing = 1.6.sp,
)
}
Spacer(Modifier.height(3.dp))
// Keep the alert readable at TV distance without letting it overpower
// the screen content beneath it.
Text(
alert.title,
color = AlertTitle,
fontSize = 20.sp,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
alert.message,
color = AlertBody,
fontSize = 15.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
Spacer(Modifier.width(24.dp))
AlertMark()
Spacer(Modifier.width(14.dp))
// The same short green rule used by the ten-minute reminder. It makes the
// alert feel like part of the player instead of a separate notification UI.
Box(Modifier.width(3.dp).height(26.dp).background(AlertAccent))
Spacer(Modifier.width(14.dp))
Text(
alertLabel(alert.label),
color = AlertAccent,
fontSize = 10.sp,
fontWeight = FontWeight.Bold,
letterSpacing = 1.6.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Spacer(Modifier.width(12.dp))
Text(
alert.title,
color = AlertTitle,
fontSize = 14.sp,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Spacer(Modifier.width(10.dp))
Text(
alert.message,
color = AlertBody,
fontSize = 12.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
Spacer(Modifier.width(16.dp))
CountdownRing(fraction = { remaining.value }, secondsLeft = secondsLeft)
}
@@ -196,7 +197,7 @@ internal fun AlertBanner(
Box(
Modifier
.fillMaxWidth()
.height(2.dp)
.height(1.dp)
.background(
Brush.horizontalGradient(
listOf(AlertAccent, AlertAccent.copy(alpha = 0.35f), Color.Transparent),
@@ -206,14 +207,25 @@ internal fun AlertBanner(
Box(
Modifier
.fillMaxWidth()
.height(22.dp)
.height(10.dp)
.background(
Brush.verticalGradient(listOf(Color(0x99000000), Color.Transparent)),
Brush.verticalGradient(listOf(Color(0x66000000), Color.Transparent)),
),
)
}
}
/**
* The eyebrow above the title. The server picks the wording so it can announce something
* this build has no name for; a bare alert or one from a server older than the field
* keeps the original episode wording, and an over-long label is cut rather than allowed
* to push the countdown off the bar.
*/
internal fun alertLabel(label: String): String =
label.trim().ifEmpty { "JUST AIRED" }.take(MaxAlertLabelChars).uppercase()
private const val MaxAlertLabelChars = 24
/**
* A ring that empties as the banner's time runs out, with the seconds left inside it.
*
@@ -228,9 +240,9 @@ internal fun AlertBanner(
*/
@Composable
private fun CountdownRing(fraction: () -> Float, secondsLeft: State<Int>) {
Box(Modifier.size(46.dp), contentAlignment = Alignment.Center) {
Box(Modifier.size(26.dp), contentAlignment = Alignment.Center) {
Canvas(Modifier.fillMaxSize()) {
val stroke = 3.dp.toPx()
val stroke = 2.dp.toPx()
val inset = stroke / 2f
val arcSize = Size(size.width - stroke, size.height - stroke)
drawArc(
@@ -257,60 +269,30 @@ private fun CountdownRing(fraction: () -> Float, secondsLeft: State<Int>) {
Text(
secondsLeft.value.coerceAtLeast(0).toString(),
color = AlertBody,
fontSize = 16.sp,
fontSize = 9.sp,
fontWeight = FontWeight.Medium,
)
}
}
/** Poster when the server named one; a quiet accent tile when it did not. */
/**
* The Emby mark, which is what every one of these banners is really speaking for
* a new film, a finished refresh, a server that stopped answering.
*
* It replaced the item poster deliberately: artwork made each alert look like a different
* feature, and half of them (a library refresh, an outage) have no artwork to show. One
* constant mark says "this is your server talking" in the width of a thumbnail.
*/
@Composable
private fun AlertPoster(posterUrl: String?) {
val shape = RoundedCornerShape(6.dp)
Box(
private fun AlertMark() {
Image(
painter = painterResource(R.drawable.emby_logo),
contentDescription = null,
contentScale = ContentScale.Fit,
modifier = Modifier
.width(50.dp)
.height(74.dp)
.clip(shape)
.background(Color(0xFF18222B)),
contentAlignment = Alignment.Center,
) {
if (posterUrl != null) {
AsyncImage(
model = posterUrl,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize(),
)
} else {
Box(
Modifier
.size(20.dp)
.clip(CircleShape)
.background(AlertAccent.copy(alpha = 0.30f)),
)
}
}
}
/** A slow pulse — enough motion to read as "news", cheap enough for a TV GPU. */
@Composable
private fun LivePip() {
val alpha = androidx.compose.animation.core.rememberInfiniteTransition(label = "alert-pip")
.animateFloat(
initialValue = 0.35f,
targetValue = 1f,
animationSpec = infiniteRepeatable(
tween(1_400, easing = LinearEasing),
RepeatMode.Reverse,
),
label = "alert-pip-alpha",
)
// Deliberately not `by`: the alpha is read inside the draw lambda, so the pulse
// repaints eight dp of circle rather than recomposing the row that holds it.
Canvas(Modifier.size(8.dp)) {
drawCircle(color = AlertAccent.copy(alpha = alpha.value))
}
.width(30.dp)
.height(26.dp),
)
}
// Previews render the bar directly rather than through ServiceAlertBanner: the wrapper's
@@ -327,7 +309,38 @@ private fun ServiceAlertBannerPreview() {
id = "sonarr:7:42:aired",
title = "Northbound",
message = "S02E04 — The Crossing aired at 9:00 PM and will be in Emby soon.",
posterUrl = null,
),
)
}
}
/** The other news the bar carries: Radarr finished importing a film. */
@TvPreview
@Composable
private fun ServiceAlertBannerMovieAddedPreview() {
PreviewSurface(alignment = Alignment.TopCenter) {
AlertBanner(
ServiceAlert(
id = "radarr:412:file:9001",
title = "Mr. Smith Goes to Washington (1939)",
message = "Mr. Smith Goes to Washington will be available in Emby shortly.",
label = "NEW MOVIE ADDED",
),
)
}
}
/** News about the service rather than the catalogue — the kind seen during a film. */
@TvPreview
@Composable
private fun ServiceAlertBannerServerDownPreview() {
PreviewSurface(alignment = Alignment.TopCenter) {
AlertBanner(
ServiceAlert(
id = "emby:down:1785012345",
title = "Emby has stopped communicating",
message = "Playback may stop until it is back. Memby will say when it returns.",
label = "SERVER NOT RESPONDING",
),
)
}
@@ -344,7 +357,6 @@ private fun ServiceAlertBannerLongTitlePreview() {
title = "A Very Long Programme Title That Will Not Fit On One Line At All",
message = "S11E03 — The One Where Absolutely Everything Happens At Once " +
"aired at 10:30 PM and is downloading now.",
posterUrl = null,
),
)
}
@@ -0,0 +1,60 @@
package com.ponzischeme89.memby.ui
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState
import androidx.compose.ui.platform.LocalContext
import androidx.core.graphics.get
import androidx.core.graphics.drawable.toBitmap
import coil.imageLoader
import coil.request.ImageRequest
import coil.request.SuccessResult
/**
* Whether a title's logo artwork should be replaced by its plain-text name.
*
* Transparent Emby logos are commonly black. They disappear over a dark backdrop, so a
* small decoded copy is inspected and the text title retained when the visible pixels are
* overwhelmingly dark. Until the image has been inspected, text is the safe default a
* title that arrives a frame late is better than a title that never appears.
*
* Shared by the screensaver and the detail hero: both draw a title over a near-black scrim,
* and a logo that is invisible on one is invisible on the other.
*/
@Composable
internal fun useTextTitleForLogo(logoUrl: String?): Boolean {
if (logoUrl == null) return true
val context = LocalContext.current
val isDark by produceState(initialValue = true, logoUrl) {
value = runCatching {
val result = context.imageLoader.execute(
ImageRequest.Builder(context)
.data(logoUrl)
.allowHardware(false)
.size(64, 64)
.build(),
) as? SuccessResult ?: return@runCatching true
isPredominantlyDarkLogo(result.drawable.toBitmap(width = 64, height = 64))
}.getOrDefault(true)
}
return isDark
}
private fun isPredominantlyDarkLogo(bitmap: android.graphics.Bitmap): Boolean {
var opaquePixels = 0
var darkPixels = 0
for (y in 0 until bitmap.height step 2) {
for (x in 0 until bitmap.width step 2) {
val pixel = bitmap[x, y]
if (android.graphics.Color.alpha(pixel) < 48) continue
opaquePixels++
val luminance = (
android.graphics.Color.red(pixel) * 0.2126f +
android.graphics.Color.green(pixel) * 0.7152f +
android.graphics.Color.blue(pixel) * 0.0722f
)
if (luminance < 58f) darkPixels++
}
}
return opaquePixels < 12 || darkPixels.toFloat() / opaquePixels > 0.82f
}
@@ -1,5 +1,6 @@
package com.ponzischeme89.memby.ui
import android.os.Build
import androidx.activity.compose.BackHandler
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.RepeatMode
@@ -30,6 +31,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowDownward
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
@@ -40,20 +42,26 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.key.onPreviewKeyEvent
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.Icon
import androidx.tv.material3.Text
import com.ponzischeme89.memby.data.model.GatewayUpdate
import com.ponzischeme89.memby.update.InstallPermissionRequiredException
import com.ponzischeme89.memby.update.UpdateChecker
import kotlinx.coroutines.launch
@@ -63,7 +71,8 @@ private val UpdateBody = Color(0xFFAEB7BF)
private val UpdateFaint = Color(0xFFA2ADB5)
/**
* The update prompt, shown over the home screen.
* The app-level update gate. AppRoot composes this instead of login, profiles, or Home,
* so no focused media card or playback action exists behind its buttons.
*
* A **mandatory** update covers everything and cannot be dismissed: Back is swallowed and
* there is one button. The operator has decided this build may no longer be used, so
@@ -81,13 +90,53 @@ fun UpdateScreen(
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current
val scope = rememberCoroutineScope()
val checker = remember { UpdateChecker(context) }
var installing by remember { mutableStateOf(false) }
var message by remember { mutableStateOf<String?>(null) }
var waitingForInstallPermission by remember { mutableStateOf(false) }
fun beginInstall() {
if (installing) return
installing = true
message = null
scope.launch {
val result = checker.downloadAndInstall(
apkUrl = update.downloadUrl,
token = "",
expectedVersion = update.version,
expectedSHA256 = update.sha256,
expectedSizeBytes = update.sizeBytes,
)
installing = false
waitingForInstallPermission =
result.exceptionOrNull() is InstallPermissionRequiredException
message = result.exceptionOrNull()?.message ?: "Opening the installer…"
}
}
// 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.
DisposableEffect(lifecycleOwner, waitingForInstallPermission, update.version) {
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME &&
waitingForInstallPermission &&
(Build.VERSION.SDK_INT < Build.VERSION_CODES.O ||
context.packageManager.canRequestPackageInstalls())
) {
waitingForInstallPermission = false
beginInstall()
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
}
val primaryFocus = remember { FocusRequester() }
val laterFocus = remember { FocusRequester() }
LaunchedEffect(update.version) { runCatching { primaryFocus.requestFocus() } }
// Swallow Back entirely while an update is required. For an optional prompt, Back is
@@ -120,13 +169,7 @@ fun UpdateScreen(
modifier = modifier
.fillMaxSize()
// Opaque, not a scrim: a required update is not a dialog over usable content.
.background(Color(0xFF0B0E11))
// Consumes clicks so nothing behind can be reached.
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
onClick = {},
),
.background(Color(0xFF0B0E11)),
) {
Canvas(Modifier.fillMaxSize()) {
val centre = Offset(size.width * (0.5f + 0.06f * drift), size.height * 0.34f)
@@ -209,7 +252,7 @@ fun UpdateScreen(
// easy to back out of by accident.
"Choose Update now — Memby downloads the new version, then your TV asks you " +
"to confirm the install. If it asks permission to install apps, allow it and " +
"the update continues.",
"the update continues. Your profiles and sign-in stay on this TV.",
color = UpdateFaint,
fontSize = 14.sp,
textAlign = TextAlign.Center,
@@ -222,21 +265,23 @@ fun UpdateScreen(
label = if (installing) "Downloading…" else "Update now",
primary = true,
enabled = !installing,
onClick = {
if (installing) return@UpdateButton
installing = true
message = null
scope.launch {
val result = checker.downloadAndInstall(update.downloadUrl, token = "")
installing = false
message = result.exceptionOrNull()?.message
?: "Opening the installer…"
}
},
modifier = Modifier.focusRequester(primaryFocus),
onClick = ::beginInstall,
modifier = Modifier
.focusRequester(primaryFocus)
.focusProperties {
if (!update.isMandatory) right = laterFocus
},
)
if (!update.isMandatory) {
UpdateButton(label = "Later", primary = false, enabled = true, onClick = onDismiss)
UpdateButton(
label = "Not now",
primary = false,
enabled = true,
onClick = onDismiss,
modifier = Modifier
.focusRequester(laterFocus)
.focusProperties { left = primaryFocus },
)
}
}
@@ -252,6 +297,13 @@ fun UpdateScreen(
color = UpdateFaint.copy(alpha = 0.75f),
fontSize = 13.sp,
)
} else {
Spacer(Modifier.height(18.dp))
Text(
"Press Back or choose Not now to dismiss this update.",
color = UpdateFaint.copy(alpha = 0.82f),
fontSize = 13.sp,
)
}
}
}
@@ -290,6 +342,25 @@ private fun UpdateButton(
shape = RoundedCornerShape(10.dp),
)
.onFocusChanged { focused = it.isFocused }
// Some Android TV launchers/remotes do not turn a Foundation click target's
// centre key into a click consistently. Consume the activation keys here and
// invoke once on key-up, while retaining clickable for accessibility/pointer
// input.
.onPreviewKeyEvent { event ->
val native = event.nativeKeyEvent
val activationKey =
native.keyCode == android.view.KeyEvent.KEYCODE_DPAD_CENTER ||
native.keyCode == android.view.KeyEvent.KEYCODE_ENTER ||
native.keyCode == android.view.KeyEvent.KEYCODE_NUMPAD_ENTER
if (!activationKey) {
false
} else {
if (enabled && native.action == android.view.KeyEvent.ACTION_UP) {
onClick()
}
true
}
}
.focusable(interactionSource = remember { MutableInteractionSource() })
.clickable(enabled = enabled, onClick = onClick)
.padding(horizontal = 30.dp, vertical = 13.dp),
@@ -0,0 +1,25 @@
package com.ponzischeme89.memby.ui
internal enum class UserSwitcherDirection { UP, DOWN }
/**
* Profiles occupy [0, profileCount); the pinned Manage users action is always the final
* index. Keeping this arithmetic outside Compose makes remote navigation deterministic.
*/
internal fun userSwitcherInitialIndex(
profileIds: List<String>,
activeProfileId: String?,
): Int = profileIds.indexOf(activeProfileId).takeIf { it >= 0 } ?: 0
internal fun userSwitcherNextIndex(
currentIndex: Int,
profileCount: Int,
direction: UserSwitcherDirection,
): Int {
val manageIndex = profileCount.coerceAtLeast(0)
val current = currentIndex.coerceIn(0, manageIndex)
return when (direction) {
UserSwitcherDirection.UP -> (current - 1).coerceAtLeast(0)
UserSwitcherDirection.DOWN -> (current + 1).coerceAtMost(manageIndex)
}
}
@@ -0,0 +1,59 @@
package com.ponzischeme89.memby.ui
import kotlin.random.Random
internal enum class WelcomeQuoteStyle(
val value: String,
val label: String,
) {
NEUTRAL("neutral", "Neutral"),
POSITIVE("positive", "Positive"),
HOMICIDAL("homicidal", "Homicidal"),
;
companion object {
fun from(value: String?): WelcomeQuoteStyle =
entries.firstOrNull { it.value.equals(value, ignoreCase = true) } ?: NEUTRAL
}
}
private val WelcomeQuotes = mapOf(
WelcomeQuoteStyle.NEUTRAL to listOf(
"The sofa has been expecting you.",
"Your watchlist remains impressively optimistic.",
"Everything is ready. Decision-making is now your problem.",
"Welcome back. The pixels have been briefed.",
"No judgement. Even if you pick that again.",
),
WelcomeQuoteStyle.POSITIVE to listOf(
"Excellent choice showing up. The rest should be easy.",
"You bring the snacks; Memby will bring the good bits.",
"Tonight has strong main-character energy.",
"Your next favourite thing might be one click away.",
"Settle in. Youve earned the good seat.",
),
WelcomeQuoteStyle.HOMICIDAL to listOf(
"Welcome back. I kept your spot. Nobody argued twice.",
"Pick something cheerful. Ive already hidden the evidence.",
"The remote knows what it did.",
"Your watchlist is safe. The witnesses, less so.",
"Relax. Everything is under control, allegedly.",
),
)
internal fun randomWelcomeQuote(
styleValue: String?,
random: Random = Random.Default,
): String {
val quotes = WelcomeQuotes.getValue(WelcomeQuoteStyle.from(styleValue))
return quotes[random.nextInt(quotes.size)]
}
internal fun loginWelcomeMessage(
username: String,
styleValue: String? = WelcomeQuoteStyle.NEUTRAL.value,
random: Random = Random.Default,
): String {
val name = username.trim().ifBlank { "there" }
return "Welcome to Memby, $name. ${randomWelcomeQuote(styleValue, random)}"
}
@@ -1,6 +1,7 @@
package com.ponzischeme89.memby.ui.detail
import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.ui.theme.ValueSeparator
import java.util.Locale
/**
@@ -15,6 +16,15 @@ import java.util.Locale
/** Emby stores durations and positions as 100-ns ticks. */
private const val TICKS_PER_MINUTE = 600_000_000L
/**
* The video width at which a file is called 4K, by the spec row and the card badge alike.
*
* UHD is 3840 wide and stays 3840 wide however hard the frame is cropped for scope, so the
* threshold sits just under it. The badge and the `(4K)` suffix used to disagree 3800 and
* 3400 and a 3600-wide file was 4K on one screen and not the other.
*/
const val UHD_MIN_WIDTH = 3_800
/** "2h 14m", "47m". Never "0m" — callers pass a positive runtime or nothing. */
fun formatRuntime(minutes: Int): String {
val hours = minutes / 60
@@ -52,27 +62,100 @@ fun remainingLabel(item: BaseItem): String? {
return "${formatPosition(left)} left"
}
/** The headline row under a movie title: year, runtime, certificate, score, genres. */
fun movieFacts(item: BaseItem): List<String> = buildList {
/**
* The quiet line directly under the title: year, length, certificate. Deliberately short
* the score sits beside the title and the genres are a credit row, so this stays at three
* items and never has to compete for the width.
*
* [seasonCount] replaces the runtime for a series; pass 0 for anything else.
*/
fun heroFacts(item: BaseItem, seasonCount: Int = 0): List<String> = buildList {
item.productionYear?.let { add(it.toString()) }
item.runtimeMinutes?.let { add(formatRuntime(it)) }
if (seasonCount > 0) {
add("$seasonCount ${if (seasonCount == 1) "Season" else "Seasons"}")
} else {
item.runtimeMinutes?.let { add(formatRuntime(it)) }
}
item.officialRating?.takeIf(String::isNotBlank)?.let(::add)
item.communityRating?.let { add("${String.format(Locale.US, "%.1f", it)}") }
item.genres.take(3).joinToString(" · ").takeIf(String::isNotBlank)?.let(::add)
}
/** The same row for a series, where seasons replace runtime. */
fun seriesFacts(item: BaseItem, seasonCount: Int): List<String> = buildList {
item.productionYear?.let { add(it.toString()) }
if (seasonCount > 0) add("$seasonCount ${if (seasonCount == 1) "Season" else "Seasons"}")
item.officialRating?.takeIf(String::isNotBlank)?.let(::add)
item.communityRating?.let { add("${String.format(Locale.US, "%.1f", it)}") }
item.genres.take(3).joinToString(" · ").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) }
/** A label/value pair for the quiet technical area. */
data class TechnicalSpec(val label: String, val value: String)
/**
* The label/value credits block: who is in it, who made it, what it is. Kept separate from
* [technicalSpecs] because these answer "do I want this?" and those answer "will it play?".
*/
fun creditRows(item: BaseItem): List<TechnicalSpec> = buildList {
peopleNamed(item, "Actor", limit = 4)?.let { add(TechnicalSpec("Starring", it)) }
val directors = peopleNamed(item, "Director", limit = 2)
val creators = peopleNamed(item, "Writer", limit = 2)
when {
directors != null -> add(TechnicalSpec("Directed by", directors))
creators != null -> add(TechnicalSpec("Written by", creators))
else -> Unit
}
item.genres.take(3).joinToString(", ").takeIf(String::isNotBlank)
?.let { add(TechnicalSpec("Genre", it)) }
item.studios.map { it.name }.filter(String::isNotBlank).take(2)
.takeIf(List<String>::isNotEmpty)
?.let { add(TechnicalSpec("Studio", it.joinToString(", "))) }
}
private fun peopleNamed(item: BaseItem, type: String, limit: Int): String? =
item.people
.filter { it.type.equals(type, ignoreCase = true) }
.map { it.name }
.filter(String::isNotBlank)
.distinct()
.take(limit)
.takeIf(List<String>::isNotEmpty)
?.joinToString(", ")
/**
* The sections a detail page can show, in the order the strip lists them.
*
* Overview is always present and always first: it is the only one guaranteed to have
* something in it, so it is what the page can safely open on.
*/
enum class DetailTab(val key: String, val label: String) {
OVERVIEW("overview", "Overview"),
MORE_LIKE_THIS("more-like-this", "More Like This"),
EPISODES("episodes", "Episodes"),
CAST_DETAILS("cast-details", "Cast & Details"),
}
/**
* The strip for one item, decided by *what the item is* never by what has arrived from
* the network so far.
*
* This used to offer only the sections that already had content, on the reasoning that an
* empty tab is worse than a missing one. On a real TV it was worse than either: a movie
* opens with its list-row metadata, so Cast and Details appeared a second later and shoved
* the strip sideways under the viewer's thumb, and a series that failed to load its
* episodes lost a tab the household knows is there. A strip that depends only on
* [isSeries] is decided on the first frame and never moves again; a section with nothing
* in it yet says so in its own pane, where saying so costs nobody a keypress.
*/
fun detailTabs(isSeries: Boolean): List<DetailTab> = buildList {
add(DetailTab.OVERVIEW)
if (isSeries) add(DetailTab.EPISODES)
add(DetailTab.MORE_LIKE_THIS)
add(DetailTab.CAST_DETAILS)
}
/**
* Resolves a remembered tab key against what is on offer. The strip no longer changes
* under a page, so this only has to catch a key remembered from an item of the other kind
* a series' Episodes tab carried over to a movie.
*/
fun detailTab(key: String, available: List<DetailTab>): DetailTab =
available.firstOrNull { it.key == key } ?: DetailTab.OVERVIEW
/**
* Resolution, codecs and studio the things a viewer checks before settling in, kept out
* of the headline because none of them decide what to watch.
@@ -92,7 +175,7 @@ fun technicalSpecs(item: BaseItem): List<TechnicalSpec> {
stream.codec?.uppercase(Locale.US),
dynamicRangeLabel(stream.videoRange, stream.videoRangeType, stream.title),
).takeIf(List<String>::isNotEmpty)?.let {
add(TechnicalSpec("Codec", it.joinToString(" · ")))
add(TechnicalSpec("Codec", it.joinToString(ValueSeparator)))
}
}
audio?.let { stream ->
@@ -101,7 +184,7 @@ fun technicalSpecs(item: BaseItem): List<TechnicalSpec> {
stream.channels?.let(::channelLabel),
stream.language?.takeIf(String::isNotBlank),
).takeIf(List<String>::isNotEmpty)?.let {
add(TechnicalSpec("Audio", it.joinToString(" · ")))
add(TechnicalSpec("Audio", it.joinToString(ValueSeparator)))
}
}
if (subtitles > 0) {
@@ -114,14 +197,19 @@ fun technicalSpecs(item: BaseItem): List<TechnicalSpec> {
}
private fun resolutionSuffix(width: Int): String = when {
width >= 3_400 -> " (4K)"
width >= UHD_MIN_WIDTH -> " (4K)"
width >= 2_500 -> " (1440p)"
width >= 1_800 -> " (1080p)"
width >= 1_200 -> " (720p)"
else -> ""
}
private fun dynamicRangeLabel(range: String?, rangeType: String?, title: String?): String? {
/**
* "Dolby Vision", "HDR10+" or "HDR" from whatever Emby happened to fill in, or null for an
* ordinary SDR file. Shared with the card badges so the two never name the same file
* differently HDR10+ used to collapse to a plain "HDR" badge.
*/
internal fun dynamicRangeLabel(range: String?, rangeType: String?, title: String?): String? {
val haystack = listOfNotNull(range, rangeType, title).joinToString(" ").lowercase(Locale.US)
return when {
"dolby vision" in haystack || "dovi" in haystack -> "Dolby Vision"
@@ -139,6 +227,26 @@ private fun channelLabel(channels: Int): String = when (channels) {
else -> "${channels}ch"
}
/** The canonical first film in an Emby collection, when the collection has siblings. */
data class FranchiseStart(val name: String, val firstMovie: BaseItem)
fun franchiseStart(item: BaseItem, related: List<BaseItem>): FranchiseStart? {
if (!item.isMovie) return null
val collection = item.collectionName?.trim()?.takeIf(String::isNotEmpty) ?: return null
val movies = (listOf(item) + related)
.asSequence()
.filter { candidate ->
candidate.isMovie && candidate.collectionName?.trim().equals(collection, ignoreCase = true)
}
.distinctBy(BaseItem::id)
.toList()
if (movies.size < 2) return null
val first = movies.minWithOrNull(
compareBy<BaseItem>({ it.productionYear ?: Int.MAX_VALUE }, { it.name.lowercase(Locale.US) }),
) ?: return null
return FranchiseStart(collection, first)
}
// ---------------------------------------------------------------------------
// Series structure
// ---------------------------------------------------------------------------
@@ -196,7 +304,7 @@ fun episodeLabel(episode: BaseItem): String? {
/** "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(" · ")
.joinToString(ValueSeparator)
/**
* The primary button. Series pass their next episode so the button can name it; a movie
@@ -0,0 +1,69 @@
package com.ponzischeme89.memby.ui.detail
/**
* Where a viewer was on a detail page, so that leaving and coming back does not undo it.
*
* A detail page is an overlay: closing it removes the composable entirely, which takes
* `rememberSaveable` with it. Without somewhere outside the composition to keep this,
* pressing Back on episode 9 of season 3 and opening the show again lands on Overview,
* season 1, with focus on Play every single time.
*
* Deliberately process-scoped rather than persisted. It is a convenience within a sitting;
* a TV switched on the next morning should open a show where the *show* is up to, which is
* what [defaultSeason] already decides.
*/
data class DetailPosition(
val tabKey: String = DetailTab.OVERVIEW.key,
/** Null means "not chosen yet" — [defaultSeason] still gets to pick. */
val season: Int? = null,
val zone: DetailZone = DetailZone.PLAY,
val episodeIndex: Int = 0,
val relatedIndex: Int = 0,
)
/**
* Which band of the page held focus. Restoring the *tab* without the band is worse than
* restoring neither: the viewer left with focus on an episode and comes back to a page
* that looks identical but answers Down differently.
*/
enum class DetailZone { PLAY, TABS, CONTENT, RELATED }
/**
* A small, capped, most-recently-used store of positions keyed by item id.
*
* Capped because a long browse would otherwise accumulate one entry per poster the viewer
* pressed OK on, and none of them matter once they have scrolled out of memory.
*/
class DetailPositionStore(private val maxEntries: Int = DEFAULT_MAX_ENTRIES) {
private val positions = LinkedHashMap<String, DetailPosition>(maxEntries, 0.75f, true)
@Synchronized
fun get(itemId: String): DetailPosition = positions[itemId] ?: DetailPosition()
@Synchronized
fun update(itemId: String, transform: (DetailPosition) -> DetailPosition) {
if (itemId.isBlank()) return
positions[itemId] = transform(positions[itemId] ?: DetailPosition())
while (positions.size > maxEntries) {
val oldest = positions.keys.firstOrNull() ?: break
positions.remove(oldest)
}
}
@Synchronized
fun clear() = positions.clear()
@Synchronized
fun size(): Int = positions.size
companion object {
const val DEFAULT_MAX_ENTRIES = 16
}
}
/**
* The one store the detail pages share. A plain global rather than a `ServiceLocator`
* entry: it holds nothing a preview, a screenshot test or a signed-out user could misuse,
* and every reader of it is a composable that already runs without injection.
*/
val detailPositions = DetailPositionStore()
@@ -0,0 +1,47 @@
package com.ponzischeme89.memby.ui.player
import android.graphics.Bitmap
import android.graphics.Color
import android.graphics.PorterDuff
import android.graphics.drawable.Drawable
import android.widget.ImageView
import androidx.core.graphics.drawable.toBitmap
/**
* Applies a white treatment only to artwork whose visible pixels are overwhelmingly dark.
* Transparent padding is ignored, so sparse black wordmarks are detected reliably without
* flattening colourful or deliberately two-tone artwork.
*/
internal fun makeLogoVisibleOnDarkBackground(imageView: ImageView, drawable: Drawable): Boolean {
imageView.setImageDrawable(drawable)
imageView.clearColorFilter()
val tintWhite = isPredominantlyDarkLogo(drawable)
if (tintWhite) imageView.setColorFilter(Color.WHITE, PorterDuff.Mode.SRC_IN)
return tintWhite
}
internal fun isPredominantlyDarkLogo(drawable: Drawable): Boolean {
val bitmap = runCatching {
drawable.toBitmap(width = 96, height = 64, config = Bitmap.Config.ARGB_8888)
}.getOrNull() ?: return false
val xStep = (bitmap.width / 48).coerceAtLeast(1)
val yStep = (bitmap.height / 32).coerceAtLeast(1)
var visible = 0
var dark = 0
var bright = 0
for (y in 0 until bitmap.height step yStep) {
for (x in 0 until bitmap.width step xStep) {
val pixel = bitmap.getPixel(x, y)
if (Color.alpha(pixel) < 32) continue
visible++
val luminance = (
Color.red(pixel) * 0.2126f +
Color.green(pixel) * 0.7152f +
Color.blue(pixel) * 0.0722f
) / 255f
if (luminance < 0.32f) dark++
if (luminance > 0.72f) bright++
}
}
return visible > 0 && dark * 100 >= visible * 68 && bright * 100 < visible * 12
}
@@ -10,6 +10,7 @@ internal data class PlaybackFailure(
val title: String,
val detail: String,
val canAutoRetry: Boolean,
val requiresTranscode: Boolean = false,
)
internal fun describePlaybackFailure(errorCode: Int): PlaybackFailure =
@@ -45,17 +46,26 @@ internal fun describePlaybackFailure(errorCode: Int): PlaybackFailure =
PlaybackException.ERROR_CODE_DECODING_FORMAT_EXCEEDS_CAPABILITIES,
-> PlaybackFailure(
title = "Video format not supported",
detail = "This TV couldnt decode the selected video or audio track. Try another track or a transcoded version.",
canAutoRetry = false,
detail = "This TV couldnt decode the original format. Memby will request a compatible stream.",
canAutoRetry = true,
requiresTranscode = true,
)
PlaybackException.ERROR_CODE_PARSING_CONTAINER_MALFORMED,
PlaybackException.ERROR_CODE_PARSING_CONTAINER_UNSUPPORTED,
PlaybackException.ERROR_CODE_PARSING_MANIFEST_MALFORMED,
PlaybackException.ERROR_CODE_PARSING_MANIFEST_UNSUPPORTED,
-> PlaybackFailure(
title = "Video file couldnt be read",
detail = "The stream format is damaged or unsupported by this TV.",
detail = "This TV couldnt read the original stream. Memby will request a compatible format.",
canAutoRetry = true,
requiresTranscode = true,
)
PlaybackException.ERROR_CODE_PARSING_CONTAINER_MALFORMED,
PlaybackException.ERROR_CODE_PARSING_MANIFEST_MALFORMED,
-> PlaybackFailure(
title = "Video file couldnt be read",
detail = "The media server returned a malformed stream.",
canAutoRetry = false,
)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,75 @@
package com.ponzischeme89.memby.ui.player
import android.content.Context
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.RectF
import android.util.AttributeSet
import android.util.TypedValue
import android.view.View
import kotlin.math.min
/**
* Compact TV-safe countdown that does not depend on a continuously running animator.
* PlayerActivity advances it only while video is actually playing, keeping the ring and
* the pre-roll gate on the same clock.
*/
class PrerollCountdownView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0,
) : View(context, attrs, defStyleAttr) {
private val density = resources.displayMetrics.density
private val ringBounds = RectF()
private val trackPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.argb(72, 255, 255, 255)
style = Paint.Style.STROKE
strokeCap = Paint.Cap.ROUND
strokeWidth = 5f * density
}
private val progressPaint = Paint(trackPaint).apply {
color = Color.rgb(82, 190, 75)
}
private val numberPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.WHITE
textAlign = Paint.Align.CENTER
textSize = TypedValue.applyDimension(
TypedValue.COMPLEX_UNIT_SP,
30f,
resources.displayMetrics,
)
typeface = android.graphics.Typeface.create("sans-serif", android.graphics.Typeface.BOLD)
}
private var seconds = 7
private var progress = 1f
fun setCountdown(seconds: Int, progress: Float, description: String) {
this.seconds = seconds.coerceAtLeast(0)
this.progress = progress.coerceIn(0f, 1f)
contentDescription = description
invalidate()
}
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
val strokeInset = trackPaint.strokeWidth / 2f
val diameter = min(width, height).toFloat()
val left = (width - diameter) / 2f + strokeInset
val top = (height - diameter) / 2f + strokeInset
ringBounds.set(
left,
top,
left + diameter - trackPaint.strokeWidth,
top + diameter - trackPaint.strokeWidth,
)
canvas.drawOval(ringBounds, trackPaint)
if (progress > 0f) {
canvas.drawArc(ringBounds, -90f, 360f * progress, false, progressPaint)
}
val baseline = height / 2f - (numberPaint.ascent() + numberPaint.descent()) / 2f
canvas.drawText(seconds.toString(), width / 2f, baseline, numberPaint)
}
}
@@ -45,7 +45,6 @@ import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
@@ -79,8 +78,6 @@ import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.graphics.drawable.toBitmap
import androidx.core.graphics.get
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.repeatOnLifecycle
@@ -88,13 +85,13 @@ import androidx.tv.material3.Button
import androidx.tv.material3.Icon
import androidx.tv.material3.Text
import coil.compose.AsyncImage
import coil.imageLoader
import coil.request.ImageRequest
import coil.request.SuccessResult
import com.ponzischeme89.memby.R
import com.ponzischeme89.memby.ServiceLocator
import com.ponzischeme89.memby.data.friendlyEmbyError
import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.ui.useTextTitleForLogo
import com.ponzischeme89.memby.ui.visibleWithWatchedPreference
import com.ponzischeme89.memby.ui.settings.SettingsSheet
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@@ -157,6 +154,7 @@ fun ScreensaverContent(
ringColor = ringColorFromHex(settings?.ringColorHex),
embyServerId = settings?.serverId,
warmBackdropUrl = settings?.lastBackdropUrl,
hideWatchedMovies = settings?.hideWatchedMovies == true,
startupMessage = startupMessage,
)
}
@@ -171,6 +169,7 @@ private fun Slideshow(
ringColor: Color,
embyServerId: String?,
warmBackdropUrl: String?,
hideWatchedMovies: Boolean,
startupMessage: String?,
) {
val repo = ServiceLocator.repository
@@ -200,12 +199,12 @@ private fun Slideshow(
val slideProgress = remember { mutableFloatStateOf(0f) }
val lifecycleOwner = LocalLifecycleOwner.current
LaunchedEffect(reloadKey) {
LaunchedEffect(reloadKey, hideWatchedMovies) {
loading = true
loadError = null
runCatching { repo.getScreensaverItems() }
.onSuccess { fetched ->
val queue = fetched.shuffled()
val queue = visibleWithWatchedPreference(fetched, hideWatchedMovies).shuffled()
// The lightweight startup request may already be on-screen. Keep that
// exact item at the front rather than swapping through several results
// as competing requests finish; the rest of the random queue follows it.
@@ -224,10 +223,14 @@ private fun Slideshow(
// Do not wait for the full 200-item queue before showing a first real backdrop.
// This runs alongside it and normally wins on a cold launch.
LaunchedEffect(reloadKey) {
LaunchedEffect(reloadKey, hideWatchedMovies) {
runCatching { repo.getStartupBackdropMovie() }
.onSuccess { movie ->
if (movie != null && items.isEmpty()) {
if (
movie != null &&
visibleWithWatchedPreference(listOf(movie), hideWatchedMovies).isNotEmpty() &&
items.isEmpty()
) {
items = listOf(movie)
loading = false
}
@@ -244,7 +247,10 @@ private fun Slideshow(
loadingMore = true
toast = "Finding more from your library…"
scope.launch {
val more = runCatching { repo.getScreensaverItems() }.getOrDefault(emptyList())
val more = visibleWithWatchedPreference(
runCatching { repo.getScreensaverItems() }.getOrDefault(emptyList()),
hideWatchedMovies,
)
if (more.isNotEmpty()) {
val existing = items.mapTo(HashSet()) { it.id }
val fresh = more.filter { it.id !in existing }
@@ -616,7 +622,6 @@ private fun Slideshow(
if (settingsOpen) {
SettingsSheet(
editableServer = true,
onClose = { settingsOpen = false },
onInstallerLaunched = onExit,
)
@@ -863,49 +868,6 @@ private fun InfoAndActions(
}
}
/**
* Transparent Emby logos are commonly black. They disappear over a dark backdrop, so
* inspect a small decoded copy and retain the text title when its visible pixels are
* overwhelmingly dark. Until the image has been inspected, text is the safe default.
*/
@Composable
private fun useTextTitleForLogo(logoUrl: String?): Boolean {
if (logoUrl == null) return true
val context = LocalContext.current
val isDark by produceState(initialValue = true, logoUrl) {
value = runCatching {
val result = context.imageLoader.execute(
ImageRequest.Builder(context)
.data(logoUrl)
.allowHardware(false)
.size(64, 64)
.build(),
) as? SuccessResult ?: return@runCatching true
isPredominantlyDarkLogo(result.drawable.toBitmap(width = 64, height = 64))
}.getOrDefault(true)
}
return isDark
}
private fun isPredominantlyDarkLogo(bitmap: android.graphics.Bitmap): Boolean {
var opaquePixels = 0
var darkPixels = 0
for (y in 0 until bitmap.height step 2) {
for (x in 0 until bitmap.width step 2) {
val pixel = bitmap[x, y]
if (android.graphics.Color.alpha(pixel) < 48) continue
opaquePixels++
val luminance = (
android.graphics.Color.red(pixel) * 0.2126f +
android.graphics.Color.green(pixel) * 0.7152f +
android.graphics.Color.blue(pixel) * 0.0722f
)
if (luminance < 58f) darkPixels++
}
}
return opaquePixels < 12 || darkPixels.toFloat() / opaquePixels > 0.82f
}
@Composable
private fun StatusChips(item: BaseItem, isFavorite: Boolean) {
val chips = buildList {
@@ -85,6 +85,7 @@ import coil.imageLoader
import coil.request.ImageRequest
import com.ponzischeme89.memby.ServiceLocator
import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.data.model.GatewayRequestCandidate
import com.ponzischeme89.memby.ui.FocusScaleContainer
import com.ponzischeme89.memby.ui.PosterGridCard
@@ -166,7 +167,7 @@ fun SearchScreen(
state.errorMessage != null && state.results.isEmpty() -> true
state.isDiscovery -> discoveryItems.isNotEmpty() ||
state.suggestions.any { it.kind == SearchSuggestion.Kind.GENRE }
else -> state.results.isNotEmpty()
else -> state.results.isNotEmpty() || state.requestCandidates.isNotEmpty()
}
LaunchedEffect(Unit) { runCatching { keyboardEntry.requestFocus() } }
@@ -237,6 +238,7 @@ fun SearchScreen(
},
onItemSelected = onItemSelected,
onRetry = viewModel::retry,
onRequest = viewModel::request,
onSuggestionSelected = viewModel::onQueryChanged,
modifier = Modifier.fillMaxHeight(),
)
@@ -588,6 +590,7 @@ private fun ResultsPane(
onItemFocused: (BaseItem) -> Unit,
onItemSelected: (BaseItem) -> Unit,
onRetry: () -> Unit,
onRequest: (GatewayRequestCandidate) -> Unit,
onSuggestionSelected: (String) -> Unit,
modifier: Modifier = Modifier,
) {
@@ -644,6 +647,13 @@ private fun ResultsPane(
resultsEntry = resultsEntry,
keyboardReturn = keyboardReturn,
)
!showingDiscovery && state.results.isEmpty() && state.requestCandidates.isNotEmpty() ->
RequestOptions(
state = state,
resultsEntry = resultsEntry,
keyboardReturn = keyboardReturn,
onRequest = onRequest,
)
items.isEmpty() -> SearchEmptyMessage(state = state, showingDiscovery = showingDiscovery)
else -> ResultsGrid(
items = items,
@@ -756,6 +766,54 @@ private fun ResultsGrid(
}
}
@Composable
private fun RequestOptions(
state: SearchUiState,
resultsEntry: FocusRequester,
keyboardReturn: FocusRequester,
onRequest: (GatewayRequestCandidate) -> Unit,
) {
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()
}
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 ->
Text(
label,
color = if (focused) KeyLabelFocused else KeyLabel,
fontSize = 15.sp,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier
.fillMaxWidth()
.background(if (focused) KeyFocused else KeyIdle)
.padding(horizontal = 18.dp, vertical = 12.dp),
)
}
}
state.requestMessage?.let { Text(it, color = Muted, fontSize = 14.sp) }
}
}
private val GenreColors = listOf(
Color(0xFFB85C38), Color(0xFF5578C8), Color(0xFF7B5AB6),
Color(0xFF2F8F83), Color(0xFFD18A32), Color(0xFFB44E76),
@@ -861,6 +919,7 @@ private fun SearchEmptyMessage(state: SearchUiState, showingDiscovery: Boolean)
val message = when {
showingDiscovery -> "Type a couple of letters to search, or pick up where the home screen left off."
state.isLoading -> "Searching…"
state.requestLookupLoading -> "Nothing in the library. Checking available movies and shows…"
else -> "Nothing in this library matches that. Try fewer letters, or a different spelling."
}
Text(message, color = Muted, fontSize = 17.sp, modifier = Modifier.padding(top = 40.dp))
@@ -6,6 +6,7 @@ import androidx.lifecycle.viewModelScope
import com.ponzischeme89.memby.data.EmbyRepository
import com.ponzischeme89.memby.data.friendlyEmbyError
import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.data.model.GatewayRequestCandidate
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.MutableStateFlow
@@ -35,6 +36,10 @@ data class SearchUiState(
/** True once a query has run to completion, so "no matches" is distinguishable from "not yet". */
val hasSearched: Boolean = false,
val errorMessage: String? = null,
val requestCandidates: List<GatewayRequestCandidate> = emptyList(),
val requestLookupLoading: Boolean = false,
val requestingCandidateKey: String? = null,
val requestMessage: String? = null,
) {
/** The query is long enough to search but nothing came back. */
val isEmptyResult: Boolean
@@ -100,7 +105,14 @@ class SearchViewModel(private val repository: EmbyRepository) : ViewModel() {
/** Every keystroke, from the on-screen keyboard, a USB keyboard or voice. */
fun onQueryChanged(query: String) {
// The visible field updates immediately; only the *search* is debounced.
_state.update { it.copy(query = query) }
_state.update {
it.copy(
query = query,
requestCandidates = emptyList(),
requestLookupLoading = false,
requestMessage = null,
)
}
queryFlow.value = query
}
@@ -111,7 +123,13 @@ class SearchViewModel(private val repository: EmbyRepository) : ViewModel() {
fun clearQuery() {
// Clear immediately so results from a genre tile cannot remain visible while the
// debounced empty-query transition is pending.
_state.update { it.copy(query = "", results = emptyList(), isLoading = false, hasSearched = false, errorMessage = null) }
_state.update {
it.copy(
query = "", results = emptyList(), isLoading = false, hasSearched = false,
errorMessage = null, requestCandidates = emptyList(),
requestLookupLoading = false, requestMessage = null,
)
}
queryFlow.value = ""
}
@@ -123,6 +141,38 @@ class SearchViewModel(private val repository: EmbyRepository) : ViewModel() {
viewModelScope.launch { runSearch(term) }
}
fun request(candidate: GatewayRequestCandidate) {
if (candidate.alreadyAdded || state.value.requestingCandidateKey != null) return
val candidateKey = "${candidate.mediaType}:${candidate.foreignId}"
_state.update {
it.copy(requestingCandidateKey = candidateKey, requestMessage = null)
}
viewModelScope.launch {
runCatching { repository.requestMedia(candidate) }
.onSuccess { title ->
_state.update {
it.copy(
requestingCandidateKey = null,
requestCandidates = it.requestCandidates.map { option ->
if (option.mediaType == candidate.mediaType &&
option.foreignId == candidate.foreignId
) option.copy(alreadyAdded = true) else option
},
requestMessage = "${title.ifBlank { candidate.title }} was requested.",
)
}
}
.onFailure { error ->
_state.update {
it.copy(
requestingCandidateKey = null,
requestMessage = friendlyEmbyError(error),
)
}
}
}
}
/**
* Genre chips for the empty state, taken from items the home screen already loaded.
* Nothing is fetched: if home has no data yet, the chips simply do not appear.
@@ -149,7 +199,11 @@ class SearchViewModel(private val repository: EmbyRepository) : ViewModel() {
// Back to the discovery state, but the previous results are dropped rather
// than left behind a shorter query they no longer match.
_state.update {
it.copy(results = emptyList(), isLoading = false, hasSearched = false, errorMessage = null)
it.copy(
results = emptyList(), isLoading = false, hasSearched = false,
errorMessage = null, requestCandidates = emptyList(),
requestLookupLoading = false, requestMessage = null,
)
}
return
}
@@ -158,6 +212,7 @@ class SearchViewModel(private val repository: EmbyRepository) : ViewModel() {
_state.update {
it.copy(results = cached, isLoading = false, hasSearched = true, errorMessage = null)
}
if (cached.isEmpty()) loadRequestCandidates(term)
return
}
@@ -166,13 +221,20 @@ class SearchViewModel(private val repository: EmbyRepository) : ViewModel() {
_state.update { it.copy(isLoading = true, errorMessage = null) }
runCatching { repository.search(term) }
.onSuccess { items ->
val ranked = rankSearchResults(term, items)
// Gateway payloads carry the backend ranker's score. Preserve that
// ordering exactly; direct-to-Emby mode keeps the local textual fallback.
val ranked = if (items.any { it.membyRecommendationScore != null }) {
items
} else {
rankSearchResults(term, items)
}
cache[term] = ranked
rememberQuery(term)
viewModelScope.launch { repository.recordSearch(term) }
_state.update {
it.copy(results = ranked, isLoading = false, hasSearched = true, errorMessage = null)
}
if (ranked.isEmpty()) loadRequestCandidates(term)
}
.onFailure { error ->
// A cancelled search is the normal case while typing, not a failure.
@@ -183,6 +245,20 @@ class SearchViewModel(private val repository: EmbyRepository) : ViewModel() {
}
}
private suspend fun loadRequestCandidates(term: String) {
_state.update { it.copy(requestLookupLoading = true) }
val candidates = runCatching { repository.lookupMediaRequests(term) }
.getOrElse { error ->
if (error is kotlinx.coroutines.CancellationException) throw error
emptyList()
}
if (_state.value.query.trim() == term) {
_state.update {
it.copy(requestCandidates = candidates, requestLookupLoading = false)
}
}
}
private fun rememberQuery(term: String) {
recentQueries.removeAll { it.equals(term, ignoreCase = true) }
recentQueries.addFirst(term)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,63 @@
package com.ponzischeme89.memby.ui.theme
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
/**
* The one vocabulary of colour, shape and punctuation shared by the launcher and the detail
* pages.
*
* These two surfaces sit next to each other the moment a detail page opens from a home row,
* and they had drifted into four near-blacks, four greens, two secondary-text greys and
* eight corner radii. Nothing here is new design it is the values that were already
* winning, named once so a change lands on both screens at the same time.
*/
/** The near-black every full-screen surface is drawn on. */
val MembySurface = Color(0xFF090B0D)
/** One step up, for panels and sheets that need to read as raised off [MembySurface]. */
val MembySurfaceRaised = Color(0xFF101418)
/** Emby's green. The only accent in the app. */
val MembyAccent = Color(0xFF52B54B)
/** Primary body copy. Not pure white — that vibrates on a TV panel at this size. */
val MembyOnSurface = Color(0xFFE2E5E8)
/**
* Secondary and tertiary copy, raised for TV viewing distance: quiet still reads as
* secondary without falling into low-contrast grey-on-black.
*/
val MembyMutedText = Color(0xFFD0D6DB)
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)
// --- Shape ---------------------------------------------------------------------------
// Three steps, largest last. Anything that needs a radius picks the nearest one rather
// than inventing a fourth.
/** Chips, badges and small controls. */
val MembyChipCorner = 8.dp
/** Cards: posters, episode rows, buttons. */
val MembyCardCorner = 10.dp
/** Full panels: heroes, overlays, confirmation toasts. */
val MembyPanelCorner = 14.dp
// --- Punctuation ---------------------------------------------------------------------
/**
* Between one fact and the next year, runtime, certificate. One separator everywhere,
* because the same line is drawn by the home hero, the metadata panel and the detail page.
*/
const val FactSeparator = ""
/** Within a single fact that happens to hold a list: genres, codecs, audio attributes. */
const val ValueSeparator = " · "
@@ -9,11 +9,14 @@ import androidx.tv.material3.LocalTextStyle
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.darkColorScheme
// The scheme is the same near-blacks the screens actually paint (see DesignTokens.kt), so
// a component that falls back to a theme colour lands on the surface it is sitting on
// rather than one shade beside it.
private val EmbyColors = darkColorScheme(
primary = androidx.compose.ui.graphics.Color(0xFF52B54B),
primary = MembyAccent,
onPrimary = androidx.compose.ui.graphics.Color.White,
surface = androidx.compose.ui.graphics.Color(0xFF101418),
background = androidx.compose.ui.graphics.Color(0xFF0B0E11),
surface = MembySurfaceRaised,
background = MembySurface,
)
@Composable
@@ -0,0 +1,37 @@
package com.ponzischeme89.memby.update
import com.ponzischeme89.memby.data.model.GatewayUpdate
import com.ponzischeme89.memby.data.remote.GatewayServiceFactory
import kotlinx.coroutines.CancellationException
/**
* App-level update client, deliberately independent of profiles and saved sessions.
*
* The gateway endpoint is public and the Retrofit client below never receives a bearer
* token. This makes an update check safe before sign-in and prevents update failures from
* invalidating, replacing, or otherwise coupling themselves to a viewer's credentials.
*/
class ServerUpdateService private constructor(
private val checkRemote: (suspend () -> GatewayUpdate)?,
) {
suspend fun check(): Result<GatewayUpdate?> {
val remote = checkRemote ?: return Result.success(null)
return try {
Result.success(remote().takeIf { it.isActionable })
} catch (cancelled: CancellationException) {
throw cancelled
} catch (failure: Throwable) {
Result.failure(failure)
}
}
companion object {
fun create(gatewayUrl: String?): ServerUpdateService {
val api = gatewayUrl?.let { GatewayServiceFactory.create(it) { null } }
return ServerUpdateService(api?.let { { it.updateStatus() } })
}
internal fun forTest(check: suspend () -> GatewayUpdate): ServerUpdateService =
ServerUpdateService(check)
}
}
@@ -2,9 +2,12 @@ package com.ponzischeme89.memby.update
import android.content.Context
import android.content.Intent
import android.content.pm.PackageInfo
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
import kotlinx.coroutines.withContext
@@ -12,6 +15,8 @@ import kotlinx.serialization.json.Json
import okhttp3.OkHttpClient
import okhttp3.Request
import java.io.File
import java.io.FileOutputStream
import java.security.MessageDigest
import java.util.concurrent.TimeUnit
/** Result of a "check for updates" against the configured Gitea release. */
@@ -156,7 +161,13 @@ class UpdateChecker(private val context: Context) {
* 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.
*/
suspend fun downloadAndInstall(apkUrl: String, token: String): Result<Unit> =
suspend fun downloadAndInstall(
apkUrl: String,
token: String,
expectedVersion: String = "",
expectedSHA256: String = "",
expectedSizeBytes: Long = 0,
): 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 &&
@@ -170,21 +181,75 @@ class UpdateChecker(private val context: Context) {
context.startActivity(intent)
}
return@withContext Result.failure(
IllegalStateException("Allow Memby to install apps, then check again.")
InstallPermissionRequiredException(
"Allow Memby to install apps. The update will continue when you return.",
),
)
}
runCatching {
val file = File(context.cacheDir, "memby-update.apk")
val updateDir = File(context.cacheDir, "updates").apply { mkdirs() }
val partial = File(updateDir, "memby-update.part")
val file = File(updateDir, "memby-update.apk")
partial.delete()
val req = Request.Builder().url(apkUrl).apply {
if (token.isNotBlank()) header("Authorization", "token ${token.trim()}")
}.build()
http.newCall(req).execute().use { resp ->
if (!resp.isSuccessful) error("Download failed (${resp.code}).")
val body = resp.body ?: error("Empty download.")
file.outputStream().use { out -> body.byteStream().copyTo(out) }
val declaredSize = body.contentLength()
if (declaredSize > MAX_APK_BYTES) error("The update is unexpectedly large.")
if (expectedSizeBytes > 0 && declaredSize >= 0 &&
declaredSize != expectedSizeBytes
) {
error("The update size does not match the published release.")
}
val digest = MessageDigest.getInstance("SHA-256")
var written = 0L
try {
FileOutputStream(partial).use { out ->
body.byteStream().use { input ->
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
while (true) {
val read = input.read(buffer)
if (read < 0) break
written += read
if (written > MAX_APK_BYTES) {
error("The update is unexpectedly large.")
}
digest.update(buffer, 0, read)
out.write(buffer, 0, read)
}
}
out.fd.sync()
}
} catch (error: Throwable) {
partial.delete()
throw error
}
if (written == 0L || (expectedSizeBytes > 0 && written != expectedSizeBytes)) {
partial.delete()
error("The update download was incomplete.")
}
val actualSHA256 = digest.digest().joinToString("") {
"%02x".format(it.toInt() and 0xff)
}
if (expectedSHA256.isNotBlank() &&
!actualSHA256.equals(expectedSHA256.trim(), ignoreCase = true)
) {
partial.delete()
error("The update failed its integrity check.")
}
}
verifyApk(partial, expectedVersion)
file.delete()
if (!partial.renameTo(file)) {
partial.delete()
error("The verified update could not be prepared.")
}
val uri = FileProvider.getUriForFile(
context, "${context.packageName}.fileprovider", file,
)
@@ -196,6 +261,51 @@ class UpdateChecker(private val context: Context) {
}
}
@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
}
val archive = context.packageManager.getPackageArchiveInfo(file.absolutePath, flags)
?: error("The downloaded file is not a valid Android app.")
if (archive.packageName != context.packageName) {
error("The update belongs to a different app.")
}
if (expectedVersion.isNotBlank() &&
normalizeVersion(archive.versionName.orEmpty()) != normalizeVersion(expectedVersion)
) {
error("The downloaded app version does not match the published release.")
}
val installed = context.packageManager.getPackageInfo(context.packageName, flags)
if (PackageInfoCompat.getLongVersionCode(archive) <=
PackageInfoCompat.getLongVersionCode(installed)
) {
error("Android requires an update with a higher version code.")
}
if (signerDigests(archive) != signerDigests(installed) ||
signerDigests(archive).isEmpty()
) {
error("The update was not signed by Membys trusted release key.")
}
}
@Suppress("DEPRECATION")
private fun signerDigests(info: PackageInfo): Set<String> {
val signatures = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
info.signingInfo?.apkContentsSigners.orEmpty()
} else {
info.signatures.orEmpty()
}
return signatures.mapTo(linkedSetOf()) { signature ->
MessageDigest.getInstance("SHA-256")
.digest(signature.toByteArray())
.joinToString("") { "%02x".format(it.toInt() and 0xff) }
}
}
/** True when [remote] parses to a strictly higher version than [installed]. */
private fun isNewer(remote: String, installed: String): Boolean {
val r = parseVersion(remote)
@@ -212,8 +322,14 @@ class UpdateChecker(private val context: Context) {
normalizeVersion(v).split('.', '-', ' ', '+').mapNotNull { it.toIntOrNull() }
private fun normalizeVersion(v: String): String = v.trim().trimStart('v', 'V')
companion object {
private const val MAX_APK_BYTES = 250L * 1024L * 1024L
}
}
class InstallPermissionRequiredException(message: String) : IllegalStateException(message)
/**
* 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
@@ -3,20 +3,19 @@ package com.ponzischeme89.memby.update
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import com.ponzischeme89.memby.ui.screensaver.ScreensaverActivity
import com.ponzischeme89.memby.ui.MainActivity
/**
* Reopens the launcher entry point when this package is replaced in place.
* Reopens the normal app entry point when this package is replaced in place.
*
* Replacing an APK kills its process, including an active Dream's render process. On
* TV that can leave the old Dream surface black. Android sends this broadcast to the
* newly installed package, giving us a chance to present a real UI instead.
* The normal entry point runs the public update check and then reuses the existing saved
* session. Nothing in this recovery path clears app data, profiles, or credentials.
*/
class UpdateRecoveryReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (intent.action != Intent.ACTION_MY_PACKAGE_REPLACED) return
val launch = ScreensaverActivity.restartAfterUpdateIntent(context).apply {
val launch = Intent(context, MainActivity::class.java).apply {
addFlags(
Intent.FLAG_ACTIVITY_NEW_TASK or
Intent.FLAG_ACTIVITY_CLEAR_TOP or
@@ -2,7 +2,8 @@
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<gradient
android:angle="90"
android:endColor="#F2000000"
android:startColor="#00000000"
android:centerColor="#28000000"
android:endColor="#00000000"
android:startColor="#B8000000"
android:type="linear" />
</shape>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<gradient
android:angle="0"
android:endColor="#00000000"
android:startColor="#A607090B"
android:type="linear" />
</shape>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="#FF20252A" />
<corners android:radius="10dp" />
<stroke android:width="1dp" android:color="#30FFFFFF" />
</shape>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<gradient
android:angle="0"
android:endColor="#00000000"
android:startColor="#EB07090B"
android:type="linear" />
</shape>
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
<corners android:radius="11dp" />
<gradient
android:angle="0"
android:startColor="#FF223139"
android:centerColor="#FF182126"
android:endColor="#FF0D1114" />
</shape>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
<corners android:radius="11dp" />
<solid android:color="#00000000" />
<stroke android:width="1dp" android:color="#38FFFFFF" />
</shape>
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
<corners android:radius="11dp" />
<gradient
android:angle="0"
android:startColor="#F2070A0C"
android:centerColor="#B8070A0C"
android:endColor="#33070A0C" />
</shape>
@@ -2,6 +2,8 @@
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<corners android:radius="12dp" />
<gradient
android:angle="315"
android:centerColor="#FF0D1114"
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<corners android:radius="12dp" />
<solid android:color="#00000000" />
<stroke
android:width="2dp"
android:color="#4DFFFFFF" />
</shape>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Near-black and barely there: this rolls up over somebody's film, so it reads as a
broadcast lower third rather than as a dialog. -->
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="#E60A0D10" />
<corners android:radius="4dp" />
</shape>
+25 -1
View File
@@ -18,6 +18,18 @@
app:surface_type="surface_view"
app:use_controller="true" />
<!-- Channel + programme identity. It is owned by the activity rather than the
controller, so it can appear briefly without opening the transport controls. -->
<include layout="@layout/player_playback_identity" />
<!-- 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" />
<!-- Finale context has priority over the ordinary start cue and is physically stacked
above it. Both remain non-focusable lower thirds. -->
<include layout="@layout/player_season_finale" />
<!-- Above the video, below the loading overlay: a slide that is still starting has
nothing to say about what comes next. -->
<include layout="@layout/player_next_up_banner" />
@@ -29,6 +41,17 @@
<!-- Cast is metadata-only and never pauses or rebuilds the player. -->
<include layout="@layout/player_cast_overlay" />
<!-- Service alerts, the same bar the launcher shows. Declared before the loading and
error overlays so those cover it: a notice about the library is not what someone
staring at a failed stream needs. Never focusable, so it cannot take the remote
away from the transport controls. -->
<androidx.compose.ui.platform.ComposeView
android:id="@+id/player_service_alerts"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="top"
android:focusable="false" />
<LinearLayout
android:id="@+id/playback_loading"
android:layout_width="match_parent"
@@ -66,7 +89,8 @@
android:textSize="14sp" />
</LinearLayout>
<!-- One cheap overlay, no second ExoPlayer. The programme prepares underneath it. -->
<!-- The existing PlayerView is temporarily hosted inside this overlay, then returned
here at full-screen size after the countdown. No second player or stream is used. -->
<include layout="@layout/player_preroll" />
<!-- Deliberately outside PlayerView's controller hierarchy: a fatal error must remain
@@ -14,24 +14,31 @@
android:background="@android:color/transparent" />
<View
android:layout_width="match_parent"
android:layout_height="210dp"
android:layout_gravity="top"
android:background="@drawable/player_osd_top_gradient" />
android:layout_width="720dp"
android:layout_height="match_parent"
android:layout_gravity="start"
android:background="@drawable/player_osd_left_gradient" />
<View
android:layout_width="match_parent"
android:layout_height="300dp"
android:layout_height="230dp"
android:layout_gravity="bottom"
android:background="@drawable/player_osd_bottom_gradient" />
<!-- Pause is a deliberate browsing moment, rather than just the ordinary OSD frozen
over a frame. It carries the movie context while the transport row below remains
the single, familiar place to resume or seek. -->
<include layout="@layout/player_pause_overlay" />
<LinearLayout
android:id="@+id/player_now_playing_group"
android:layout_width="wrap_content"
android:layout_height="140dp"
android:layout_height="wrap_content"
android:layout_gravity="top|start"
android:layout_marginStart="48dp"
android:layout_marginTop="30dp"
android:gravity="center_vertical"
android:clipChildren="false"
android:clipToPadding="false"
android:orientation="vertical">
<TextView
@@ -60,6 +67,8 @@
android:ellipsize="end"
android:maxLines="2"
android:maxWidth="600dp"
android:includeFontPadding="true"
android:lineSpacingExtra="2dp"
android:textColor="#FFFFFFFF"
android:textSize="28sp"
android:textStyle="bold"
@@ -0,0 +1,80 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/player_pause_overlay"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:focusable="false"
android:visibility="gone">
<View
android:layout_width="820dp"
android:layout_height="match_parent"
android:background="@drawable/player_pause_scrim" />
<LinearLayout
android:layout_width="760dp"
android:layout_height="wrap_content"
android:layout_gravity="start|center_vertical"
android:layout_marginStart="48dp"
android:layout_marginBottom="62dp"
android:gravity="center_vertical"
android:orientation="horizontal">
<ImageView
android:id="@+id/player_pause_poster"
android:layout_width="164dp"
android:layout_height="246dp"
android:background="@drawable/player_pause_poster_background"
android:clipToOutline="true"
android:contentDescription="@string/player_pause_poster"
android:outlineProvider="background"
android:scaleType="centerCrop" />
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="30dp"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:letterSpacing="0.18"
android:text="@string/player_paused"
android:textColor="#FF6BCB63"
android:textSize="12sp"
android:textStyle="bold" />
<TextView
android:id="@+id/player_pause_title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:ellipsize="end"
android:maxLines="2"
android:textColor="#FFFFFFFF"
android:textSize="34sp"
android:textStyle="bold" />
<TextView
android:id="@+id/player_pause_overview"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="14dp"
android:ellipsize="end"
android:lineSpacingExtra="3dp"
android:maxLines="5"
android:textColor="#DDE7EBEE"
android:textSize="16sp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="18dp"
android:text="@string/player_pause_resume_hint"
android:textColor="#B8FFFFFF"
android:textSize="13sp" />
</LinearLayout>
</LinearLayout>
</FrameLayout>
@@ -0,0 +1,39 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- A short, non-focusable station ident shown over the first five seconds of content. -->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/player_playback_identity"
android:layout_width="wrap_content"
android:layout_height="54dp"
android:layout_gravity="start|top"
android:layout_marginStart="48dp"
android:layout_marginTop="34dp"
android:alpha="0"
android:focusable="false"
android:gravity="center_vertical"
android:orientation="horizontal"
android:visibility="gone">
<ImageView
android:layout_width="42dp"
android:layout_height="38dp"
android:contentDescription="@string/player_preroll_brand_logo"
android:scaleType="fitCenter"
android:src="@drawable/emby_logo" />
<TextView
android:id="@+id/player_playback_identity_title"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:layout_marginStart="14dp"
android:ellipsize="end"
android:gravity="start|center_vertical"
android:maxLines="1"
android:maxWidth="520dp"
android:shadowColor="#E0000000"
android:shadowDx="0"
android:shadowDy="2"
android:shadowRadius="5"
android:textColor="#FFFFFFFF"
android:textSize="25sp"
android:textStyle="bold" />
</LinearLayout>
+130 -92
View File
@@ -5,114 +5,152 @@
android:layout_height="match_parent"
android:background="#FF050708"
android:clickable="true"
android:clipChildren="false"
android:focusable="false"
android:visibility="gone">
<ImageView
android:id="@+id/player_preroll_brand"
android:layout_width="58dp"
android:layout_height="50dp"
android:layout_gravity="start|top"
android:layout_marginStart="44dp"
android:layout_marginTop="28dp"
android:contentDescription="@string/player_preroll_brand_logo"
android:scaleType="fitCenter"
android:src="@drawable/emby_logo" />
<LinearLayout
android:id="@+id/player_preroll_content"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center_vertical"
android:orientation="horizontal"
android:paddingStart="64dp"
android:paddingTop="54dp"
android:paddingEnd="64dp"
android:paddingBottom="54dp">
<!-- Reserved for the eventual pre-roll media asset. Keeping this a plain View is
virtually free and lets the real programme buffer beneath the overlay. -->
<FrameLayout
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_marginEnd="52dp"
android:layout_weight="1.62"
android:background="@drawable/player_preroll_video_background">
<ImageView
android:layout_width="74dp"
android:layout_height="64dp"
android:layout_gravity="start|bottom"
android:layout_marginStart="28dp"
android:layout_marginBottom="25dp"
android:alpha="0.24"
android:contentDescription="@null"
android:scaleType="fitCenter"
android:src="@drawable/emby_logo" />
<TextView
android:id="@+id/player_preroll_countdown"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="end|bottom"
android:layout_marginEnd="28dp"
android:layout_marginBottom="28dp"
android:background="@drawable/player_status_background"
android:paddingStart="18dp"
android:paddingTop="10dp"
android:paddingEnd="18dp"
android:paddingBottom="10dp"
android:text="@string/player_preroll_countdown_initial"
android:textColor="#FFFFFFFF"
android:textSize="15sp"
android:textStyle="bold" />
</FrameLayout>
android:clipChildren="false"
android:gravity="center_horizontal"
android:orientation="vertical"
android:paddingStart="44dp"
android:paddingTop="84dp"
android:paddingEnd="44dp"
android:paddingBottom="22dp">
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical">
android:layout_width="match_parent"
android:layout_height="224dp"
android:gravity="center_vertical"
android:orientation="horizontal">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Coming up"
android:textColor="#FFFFFFFF"
android:textSize="30sp"
android:textStyle="bold" />
<FrameLayout
android:id="@+id/player_preroll_video_host"
android:layout_width="398dp"
android:layout_height="224dp"
android:layout_marginEnd="38dp"
android:background="@drawable/player_preroll_video_background"
android:clipToOutline="true"
android:foreground="@drawable/player_preroll_video_frame"
android:outlineProvider="background">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="5dp"
android:text="From your Sonarr calendar"
android:textColor="#FF8E979D"
android:textSize="13sp" />
<LinearLayout
android:id="@+id/player_preroll_countdown_container"
android:layout_width="80dp"
android:layout_height="wrap_content"
android:layout_gravity="end|bottom"
android:layout_marginEnd="18dp"
android:layout_marginBottom="16dp"
android:gravity="center_horizontal"
android:orientation="vertical">
<TextView
android:id="@+id/player_preroll_today_label"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="25dp"
android:text="AIRING TODAY"
android:textAllCaps="true"
android:textColor="#FF55B94D"
android:textSize="12sp"
android:textStyle="bold" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginBottom="5dp"
android:text="@string/player_preroll_starting_in"
android:textAllCaps="true"
android:textColor="#CCFFFFFF"
android:textSize="9sp"
android:textStyle="bold" />
<com.ponzischeme89.memby.ui.player.PrerollCountdownView
android:id="@+id/player_preroll_countdown"
android:layout_width="66dp"
android:layout_height="66dp"
android:contentDescription="@string/player_preroll_countdown_initial" />
</LinearLayout>
</FrameLayout>
<LinearLayout
android:id="@+id/player_preroll_today"
android:layout_width="match_parent"
android:id="@+id/player_preroll_schedule_panel"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:orientation="vertical" />
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:id="@+id/player_preroll_week_label"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="22dp"
android:text="THIS WEEK"
android:textAllCaps="true"
android:textColor="#FF8E979D"
android:textSize="12sp"
android:textStyle="bold" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="NOW"
android:textColor="#FF64C85A"
android:textSize="11sp"
android:textStyle="bold" />
<LinearLayout
android:id="@+id/player_preroll_week"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:orientation="vertical" />
<TextView
android:id="@+id/player_preroll_now_title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="7dp"
android:ellipsize="end"
android:maxLines="2"
android:text="Northbound · The Crossing"
android:textColor="#FFFFFFFF"
android:textSize="26sp"
android:textStyle="bold" />
<TextView
android:id="@+id/player_preroll_now_metadata"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="S02E04 · 48 mins"
android:textColor="#FFD0D6DB"
android:textSize="12sp"
android:textStyle="bold" />
<TextView
android:id="@+id/player_preroll_now_overview"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:ellipsize="end"
android:maxLines="3"
android:text="The crew follows a signal beyond the last charted crossing and finds the shore waiting for them."
android:textColor="#FFB1BAC1"
android:textSize="13sp" />
</LinearLayout>
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:gravity="center_vertical"
android:orientation="horizontal"
android:paddingStart="4dp"
android:paddingEnd="4dp">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="UPCOMING"
android:textColor="#FF64C85A"
android:textSize="11sp"
android:textStyle="bold" />
</LinearLayout>
<GridLayout
android:id="@+id/player_preroll_calendar"
android:layout_width="match_parent"
android:layout_height="170dp"
android:layout_marginTop="5dp"
android:columnCount="4"
android:rowCount="2"
android:useDefaultMargins="false" />
</LinearLayout>
</FrameLayout>
@@ -0,0 +1,66 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="80dp"
android:background="@drawable/player_preroll_card_background"
android:clipToOutline="true"
android:outlineProvider="background">
<ImageView
android:id="@+id/player_preroll_card_artwork"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:contentDescription="@null"
android:scaleType="centerCrop" />
<View
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@drawable/player_preroll_card_scrim" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center_vertical"
android:orientation="vertical"
android:paddingStart="10dp"
android:paddingTop="8dp"
android:paddingEnd="9dp"
android:paddingBottom="8dp">
<TextView
android:id="@+id/player_preroll_card_label"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAllCaps="true"
android:textColor="#FF64C85A"
android:textSize="8sp"
android:textStyle="bold" />
<TextView
android:id="@+id/player_preroll_card_title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="3dp"
android:ellipsize="end"
android:maxLines="1"
android:textColor="#FFFFFFFF"
android:textSize="14sp"
android:textStyle="bold" />
<TextView
android:id="@+id/player_preroll_card_detail"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:ellipsize="end"
android:maxLines="1"
android:textColor="#FFD0D6DB"
android:textSize="9sp" />
</LinearLayout>
<View
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@drawable/player_preroll_card_frame" />
</FrameLayout>
@@ -0,0 +1,65 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Finale context is a separate, higher-priority lower third. It sits above the normal
start/time cue so both can be read together and never takes focus from playback. -->
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/player_season_finale"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="bottom|start"
android:layout_marginStart="52dp"
android:layout_marginBottom="120dp"
android:clipChildren="false"
android:clipToPadding="false"
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:paddingEnd="18dp">
<View
android:layout_width="3dp"
android:layout_height="32dp"
android:background="#FFFFB454" />
<ImageView
android:layout_width="30dp"
android:layout_height="27dp"
android:layout_marginStart="10dp"
android:contentDescription="@null"
android:scaleType="fitCenter"
android:src="@drawable/emby_logo" />
<TextView
android:id="@+id/player_season_finale_label"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="10dp"
android:letterSpacing="0.16"
android:paddingTop="9dp"
android:paddingBottom="9dp"
android:text="@string/player_season_finale_label"
android:textColor="#FFFFC778"
android:textSize="10sp"
android:textStyle="bold" />
<TextView
android:id="@+id/player_season_finale_value"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="10dp"
android:ellipsize="end"
android:maxLines="1"
android:paddingTop="9dp"
android:paddingBottom="9dp"
android:textColor="#FFFFFFFF"
android:textSize="14sp"
android:textStyle="bold"
tools:text="Signal Hill · Season 2 finale" />
</LinearLayout>
</FrameLayout>
@@ -0,0 +1,66 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- The ten-minute lower third. A single non-focusable strip pinned to the lower left,
inside the TV overscan margin, that rolls up from below its own baseline and rolls
back down again. It never takes the remote and never covers the transport controls. -->
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/player_time_remaining"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="bottom|start"
android:layout_marginStart="52dp"
android:layout_marginBottom="72dp"
android:clipChildren="false"
android:clipToPadding="false"
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="0dp"
android:paddingEnd="18dp">
<!-- Keep the cue recognisably Memby even when it appears independently of the
transport OSD. The accent rule still distinguishes ordinary timing cues. -->
<View
android:layout_width="3dp"
android:layout_height="26dp"
android:background="#FF69CD61" />
<ImageView
android:layout_width="27dp"
android:layout_height="24dp"
android:layout_marginStart="10dp"
android:contentDescription="@null"
android:scaleType="fitCenter"
android:src="@drawable/emby_logo" />
<TextView
android:id="@+id/player_time_remaining_label"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="10dp"
android:letterSpacing="0.16"
android:paddingTop="7dp"
android:paddingBottom="7dp"
android:text="@string/player_time_remaining_label"
android:textColor="#99FFFFFF"
android:textSize="10sp" />
<TextView
android:id="@+id/player_time_remaining_value"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="10dp"
android:paddingTop="7dp"
android:paddingBottom="7dp"
android:textColor="#FFFFFFFF"
android:textSize="14sp"
android:textStyle="bold"
tools:text="10 mins" />
</LinearLayout>
</FrameLayout>
+17 -1
View File
@@ -14,6 +14,10 @@
<item quantity="other">Reconnecting in %1$d seconds…</item>
</plurals>
<string name="player_now_playing">NOW PLAYING</string>
<string name="player_paused">PAUSED</string>
<string name="player_pause_poster">Movie poster</string>
<string name="player_pause_resume_hint">Press OK to resume</string>
<string name="player_pause_overview_fallback">No description is available for this title.</string>
<string name="player_position_separator">/</string>
<string name="player_loading_duration">Loading duration…</string>
<string name="player_live">Live</string>
@@ -29,12 +33,24 @@
<string name="player_text_size">TEXT SIZE</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 5 seconds…</string>
<string name="player_preroll_countdown_initial">Starting in 7 seconds…</string>
<string name="player_preroll_starting_in">Starting in</string>
<string name="player_preroll_brand_logo">Memby logo</string>
<plurals name="player_preroll_countdown">
<item quantity="one">Starting in %1$d second…</item>
<item quantity="other">Starting in %1$d seconds…</item>
</plurals>
<string name="player_preroll_starting">Starting now…</string>
<string name="player_time_remaining_label">TIME REMAINING</string>
<string name="player_season_finale_label">SEASON FINALE</string>
<string name="player_season_finale_value">%1$s · Season %2$d finale</string>
<string name="player_finishes_in_label">FINISHES IN</string>
<string name="player_resume_time_left_label">TIME LEFT</string>
<string name="player_finishes_in_value">%1$s (%2$s)</string>
<plurals name="player_time_remaining_cue">
<item quantity="one">%1$d min</item>
<item quantity="other">%1$d mins</item>
</plurals>
<string name="next_up_label">NEXT UP</string>
<string name="next_up_play_now">Play now</string>
<string name="next_up_dismiss">Dismiss</string>
@@ -0,0 +1,48 @@
package com.ponzischeme89.memby.data
import com.ponzischeme89.memby.data.playback.DevicePlaybackCapabilities
import com.ponzischeme89.memby.data.playback.VideoDecoderCapabilities
import com.ponzischeme89.memby.data.playback.gatewayCapabilityTokens
import org.junit.Assert.assertTrue
import org.junit.Test
class DevicePlaybackCapabilitiesTest {
@Test
fun detailedH264AndHevcDecoderSupportIsReportedToTheGateway() {
val capabilities = DevicePlaybackCapabilities(
h264 = VideoDecoderCapabilities(
supported = true,
profiles = setOf("baseline", "main", "high"),
mainLevel = 52,
maxWidth = 3840,
maxHeight = 2160,
),
hevc = VideoDecoderCapabilities(
supported = true,
profiles = setOf("main", "main10"),
mainLevel = 153,
tenBitLevel = 153,
maxWidth = 3840,
maxHeight = 2160,
hdr10 = true,
),
)
val tokens = capabilities.gatewayCapabilityTokens()
assertTrue("video_h264_profile_high" in tokens)
assertTrue("video_h264_level_52" in tokens)
assertTrue("video_h264_max_3840x2160" in tokens)
assertTrue("video_hevc_decode" in tokens)
assertTrue("video_hevc_profile_main10" in tokens)
assertTrue("video_hevc_main10_level_153" in tokens)
assertTrue("video_hevc_hdr10" in tokens)
}
@Test
fun h264OnlyDeviceDoesNotClaimHevc() {
val tokens = DevicePlaybackCapabilities().gatewayCapabilityTokens()
assertTrue("video_h264_decode" in tokens)
assertTrue("video_hevc_decode" !in tokens)
}
}
@@ -1,10 +1,14 @@
package com.ponzischeme89.memby.data
import com.ponzischeme89.memby.data.model.GatewayHome
import com.ponzischeme89.memby.data.model.GatewayDevices
import com.ponzischeme89.memby.data.model.GatewayNextEpisode
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.GatewayServiceStatus
import com.ponzischeme89.memby.data.model.GatewayFeatures
import com.ponzischeme89.memby.data.model.RecommendationOnboarding
import kotlinx.serialization.json.Json
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
@@ -19,6 +23,17 @@ import org.junit.Test
* side, this fails before a TV ever sees it.
*/
class GatewayPayloadTest {
@Test
fun `decodes signed in devices without an allowance`() {
val response = json.decodeFromString<GatewayDevices>(
"""{"devices":[{"deviceId":"tv-1","deviceName":"Living room","clientVersion":"0.2.4","lastSeenAt":"2026-08-02T10:00:00Z","current":true}]}""",
)
assertEquals(1, response.devices.size)
assertEquals("Living room", response.devices.single().deviceName)
assertTrue(response.devices.single().current)
}
private val json = Json {
ignoreUnknownKeys = true
@@ -38,6 +53,40 @@ class GatewayPayloadTest {
assertEquals(1, status.serverProtocol)
}
@Test
fun `decodes live feature policy and recovery state`() {
val status = json.decodeFromString<GatewayServiceStatus>(
"""{"featureSchemaVersion":1,"featureRevision":7,"safeMode":true,"features":{"sonarr_preroll":false}}""",
)
val policy = json.decodeFromString<GatewayFeatures>(
"""{"schemaVersion":1,"revision":7,"safeMode":true,"canRollback":true,"features":[{"key":"sonarr_preroll","name":"Sonarr upcoming preroll","enabled":false,"source":"safe_mode","compatible":true}]}""",
)
assertEquals(7L, status.featureRevision)
assertEquals(false, status.features["sonarr_preroll"])
assertTrue(policy.safeMode)
assertTrue(policy.canRollback)
assertEquals("safe_mode", policy.features.single().source)
}
@Test
fun `decodes recommendation onboarding ratings and emby items`() {
val onboarding = json.decodeFromString<RecommendationOnboarding>(
"""{
"completed":false,
"ratings":{"movie-1":5},
"items":[
{"Id":"movie-1","Name":"Arrival","Type":"Movie"},
{"Id":"series-1","Name":"Severance","Type":"Series"}
]
}""",
)
assertEquals(false, onboarding.completed)
assertEquals(5, onboarding.ratings["movie-1"])
assertEquals(listOf("Arrival", "Severance"), onboarding.items.map { it.name })
}
@Test
fun `decodes a home payload with emby-shaped items`() {
val payload = """
@@ -119,12 +168,12 @@ class GatewayPayloadTest {
}
@Test
fun `decodes the informational Sonarr schedule row`() {
fun `decodes the informational TV schedule row`() {
val payload = """
{
"rows": [{
"id":"sonarr-airing-today",
"title":"Shows airing today",
"title":"Shows airing in the next 5 days",
"kind":"schedule",
"items":[{
"Id":"sonarr:7:42",
@@ -134,7 +183,8 @@ class GatewayPayloadTest {
"MembySource":"sonarr",
"MembyEpisodeTitle":"The Crossing",
"MembyEpisodeCode":"S02E04",
"MembyAirLabel":"Airs today at 8:00 PM",
"MembyAirDayLabel":"Tomorrow",
"MembyAirLabel":"Tomorrow: 8:00 PM",
"MembyAvailability":"downloading",
"MembyAvailabilityText":"Downloading",
"MembyPlayable":false
@@ -150,12 +200,48 @@ class GatewayPayloadTest {
val item = json.decodeFromString<GatewayHome>(payload).rows.single().items.single()
assertTrue(item.isSonarrSchedule)
assertTrue(item.isTvSchedule)
assertEquals("S02E04", item.membyEpisodeCode)
assertEquals("Tomorrow", item.membyAirDayLabel)
assertEquals("Downloading", item.membyAvailabilityText)
assertEquals(false, item.membyPlayable)
}
@Test
fun `decodes the informational Radarr digital release row`() {
val payload = """
{
"rows":[{
"id":"radarr-upcoming-movies",
"title":"Upcoming Movie releases",
"kind":"movie-schedule",
"items":[{
"Id":"radarr:7",
"Name":"Arrival",
"Type":"MembyRadarrMovie",
"ImageTags":{"Primary":"radarr"},
"MembySource":"radarr",
"MembyAirsAt":"2026-08-01T00:00:00+12:00",
"MembyAirDayLabel":"Saturday",
"MembyAirLabel":"Digital release Saturday",
"MembyAvailability":"upcoming",
"MembyAvailabilityText":"Upcoming digital release",
"MembyPlayable":false
}]
}]
}
""".trimIndent()
val row = json.decodeFromString<GatewayHome>(payload).rows.single()
val item = row.items.single()
assertEquals("movie-schedule", row.kind)
assertTrue(item.isMovieSchedule)
assertTrue(item.isSchedule)
assertEquals("Digital release Saturday", item.membyAirLabel)
assertEquals(false, item.membyPlayable)
}
@Test
fun `a home payload without rows still decodes`() {
// The gateway omits recommendation rows while they are still building, and an
@@ -186,13 +272,26 @@ class GatewayPayloadTest {
@Test
fun `decodes a playback response`() {
val playback = json.decodeFromString<GatewayPlayback>(
"""{"itemId":"9","title":"Severance Pilot","url":"https://emby.example/Videos/9/stream?static=true","resumePositionMs":42000}""",
"""{"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)
assertEquals(42_000L, playback.resumePositionMs)
assertEquals("S01E01", playback.episodeCode)
assertEquals(3_420_000L, playback.runtimeMs)
assertEquals(false, playback.prerollEnabled)
assertEquals(4_000L, playback.prerollDurationMs)
assertTrue(playback.url.startsWith("https://emby.example/Videos/9/stream"))
}
@Test
fun `decodes a one-time auto-follow acknowledgement`() {
val response = json.decodeFromString<GatewayPlaybackReportResponse>(
"""{"autoFollowedShowTitle":"Severance"}""",
)
assertEquals("Severance", response.autoFollowedShowTitle)
}
@Test
fun `decodes the next-episode response the player counts down to`() {
val next = json.decodeFromString<GatewayNextEpisode>(
@@ -235,13 +334,4 @@ class GatewayPayloadTest {
assertNull(next.item.episodeCode)
}
@Test
fun `device allowance response becomes a specific sign-in error`() {
val error = parseDeviceLimit(
"""{"error":"device_limit_reached","activeClients":3,"maxClientsPerUser":3}""",
)
assertEquals(3, error?.activeClients)
assertEquals(3, error?.maxClients)
}
}
@@ -30,12 +30,14 @@ class GatewayUpdateTest {
@Test
fun `an optional verdict is dismissable`() {
val update = json.decodeFromString<GatewayUpdate>(
"""{"status":"optional","version":"0.1.54","downloadUrl":"https://nas/memby.apk"}""",
"""{"status":"optional","version":"0.1.54","downloadUrl":"https://nas/memby.apk","sha256":"abc","sizeBytes":123}""",
)
assertTrue(update.isOptional)
assertFalse(update.isMandatory)
assertTrue(update.isActionable)
assertEquals("abc", update.sha256)
assertEquals(123L, update.sizeBytes)
}
@Test
@@ -0,0 +1,51 @@
package com.ponzischeme89.memby.data
import com.ponzischeme89.memby.data.model.MediaSourceInfo
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class PlaybackDeliveryTest {
@Test
fun directPlayUsesOriginalOnlyWhenServerMarksItSupported() {
val delivery = selectPlaybackDelivery(
MediaSourceInfo(
supportsDirectPlay = true,
directStreamUrl = "/Videos/item/stream.mkv",
transcodingUrl = "/Videos/item/master.m3u8",
),
)
assertNull(delivery.url)
assertEquals("DirectPlay", delivery.playMethod)
}
@Test
fun negotiatedDirectStreamIsUsedForContainerCompatibility() {
val delivery = selectPlaybackDelivery(
MediaSourceInfo(
supportsDirectPlay = false,
supportsDirectStream = true,
directStreamUrl = "/Videos/item/stream.mp4",
transcodingUrl = "/Videos/item/master.m3u8",
),
)
assertEquals("/Videos/item/stream.mp4", delivery.url)
assertEquals("DirectStream", delivery.playMethod)
}
@Test
fun forcedFallbackUsesTranscodeEvenWhenDirectPlayWasAdvertised() {
val delivery = selectPlaybackDelivery(
MediaSourceInfo(
supportsDirectPlay = true,
transcodingUrl = "/Videos/item/master.m3u8",
),
forceTranscode = true,
)
assertEquals("/Videos/item/master.m3u8", delivery.url)
assertEquals("Transcode", delivery.playMethod)
}
}
@@ -1,7 +1,11 @@
package com.ponzischeme89.memby.data
import com.ponzischeme89.memby.data.model.DeviceProfile
import com.ponzischeme89.memby.data.playback.DevicePlaybackCapabilities
import com.ponzischeme89.memby.data.playback.VideoDecoderCapabilities
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class PlaybackReportMathTest {
@@ -26,4 +30,45 @@ class PlaybackReportMathTest {
assertEquals("Encode", profiles["pgssub"])
assertEquals("Encode", profiles["dvdsub"])
}
@Test
fun directPlayProfileAdvertisesHevcOnlyForCapableTvs() {
val baseline = DeviceProfile.embyAndroidTv().directPlayProfiles.single()
val capable = DeviceProfile.embyAndroidTv(supportsHevc = true).directPlayProfiles.single()
assertEquals("h264", baseline.videoCodec)
assertEquals("h264,hevc", capable.videoCodec)
assertEquals("aac,mp3", capable.audioCodec)
assertFalse(capable.videoCodec.contains("av1"))
assertFalse(capable.audioCodec.contains("eac3"))
}
@Test
fun detailedProfileAllowsVideoStreamCopyWithinDecoderLimits() {
val profile = DeviceProfile.embyAndroidTv(
DevicePlaybackCapabilities(
h264 = VideoDecoderCapabilities(
supported = true,
profiles = setOf("baseline", "main", "high"),
mainLevel = 52,
maxWidth = 3840,
maxHeight = 2160,
),
hevc = VideoDecoderCapabilities(
supported = true,
profiles = setOf("main", "main10"),
mainLevel = 153,
tenBitLevel = 153,
maxWidth = 3840,
maxHeight = 2160,
),
),
)
assertEquals("h264,hevc", profile.directPlayProfiles.single().videoCodec)
assertEquals("h264,hevc", profile.transcodingProfiles.single().videoCodec)
assertTrue(profile.codecProfiles.any { it.codec == "h264" && it.conditions.any { c -> c.value == "52" } })
assertTrue(profile.codecProfiles.any { it.codec == "hevc" && it.conditions.any { c -> c.value == "153" } })
assertTrue(profile.codecProfiles.any { it.codec == "hevc" && it.conditions.any { c -> c.property == "Width" } })
}
}
@@ -53,4 +53,17 @@ class ProfileSettingsTest {
assertEquals(30, family.forYouMinutes)
assertEquals(false, family.hasOpenedForYou)
}
@Test
fun `welcome quote style belongs to each profile`() {
val matt = profile.copy(welcomeQuoteStyle = "homicidal")
val family = profile.copy(
id = "server::family",
userId = "family",
welcomeQuoteStyle = "positive",
)
assertEquals("homicidal", matt.welcomeQuoteStyle)
assertEquals("positive", family.welcomeQuoteStyle)
}
}
@@ -73,6 +73,19 @@ class RowAnalyticsTest {
assertEquals(listOf("favorites", "recommended"), impressions.map { it.rowId })
}
@Test
fun `visible posters receive deduplicated item impressions`() {
val collector = analytics(FakeClock())
collector.rowImpression("recommended", "MOVIES", listOf("one", "two"))
collector.rowImpression("recommended", "MOVIES", listOf("one", "two"))
val itemImpressions = collector.drain().filter {
it.event == RowAnalytics.EVENT_IMPRESSION && it.itemId.isNotEmpty()
}
assertEquals(listOf("one", "two"), itemImpressions.map { it.itemId })
}
@Test
fun `focusing a row that was never reported still records the impression`() {
val collector = analytics(FakeClock())
@@ -85,5 +85,28 @@ class ServiceAlertTest {
assertEquals("sonarr:7:42:aired", alert.id)
assertEquals("sonarr:7:42", alert.itemId)
assertEquals("sonarr", alert.imageTag)
// A gateway that predates server-worded banners sends no label at all.
assertEquals("", alert.label)
}
@Test
fun `status decodes a radarr import alert with its own wording`() {
val status = json.decodeFromString<GatewayServiceStatus>(
"""
{"maintenance":false,"message":"","alerts":[{
"id":"radarr:412:file:9001","kind":"radarr-import","label":"NEW MOVIE ADDED",
"title":"Mr. Smith Goes to Washington (1939)",
"message":"Mr. Smith Goes to Washington will be available in Emby shortly.",
"itemId":"radarr:412","imageTag":"radarr","airedAt":"2026-07-31T19:04:00Z"
}]}
""".trimIndent(),
)
val alert = status.alerts.single()
assertEquals("radarr:412:file:9001", alert.id)
assertEquals("NEW MOVIE ADDED", alert.label)
// The image proxy serves Radarr covers under this pair, so the banner has a
// poster before Emby has scanned the film in.
assertEquals("radarr:412", alert.itemId)
assertEquals("radarr", alert.imageTag)
}
}
@@ -0,0 +1,29 @@
package com.ponzischeme89.memby.data
import okhttp3.ResponseBody.Companion.toResponseBody
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import retrofit2.HttpException
import retrofit2.Response
import java.io.IOException
class SessionValidationTest {
@Test
fun `server restart preserves saved session`() {
assertTrue(shouldPreserveSessionAfterValidationFailure(IOException("offline")))
}
@Test
fun `maintenance response preserves saved session`() {
assertTrue(shouldPreserveSessionAfterValidationFailure(httpFailure(503)))
}
@Test
fun `explicit unauthorized response may invalidate saved session`() {
assertFalse(shouldPreserveSessionAfterValidationFailure(httpFailure(401)))
}
private fun httpFailure(code: Int): HttpException =
HttpException(Response.error<Any>(code, "error".toResponseBody()))
}
@@ -0,0 +1,46 @@
package com.ponzischeme89.memby.data
import android.content.Context
import androidx.test.core.app.ApplicationProvider
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
@RunWith(RobolectricTestRunner::class)
class SettingsStoreProfileRemovalTest {
@Test
fun `ten minute reminder preference is persisted`() = runBlocking {
val context = ApplicationProvider.getApplicationContext<Context>()
val store = SettingsStore(context)
store.setShowTenMinuteReminder(false)
assertFalse(store.snapshot().showTenMinuteReminder)
}
@Test
fun `removing profiles preserves other users and clears only an active session`() = runBlocking {
val context = ApplicationProvider.getApplicationContext<Context>()
val store = SettingsStore(context)
store.saveSession("https://example.test", "token-a", "user-a", "Alex", "server")
val alex = store.snapshot().profiles.single()
store.saveSession("https://example.test", "token-b", "user-b", "Bailey", "server")
val bailey = store.snapshot().profiles.single { it.userId == "user-b" }
store.removeProfile(alex.id)
val afterInactiveRemoval = store.snapshot()
assertEquals(listOf(bailey.id), afterInactiveRemoval.profiles.map { it.id })
assertTrue(afterInactiveRemoval.isSignedIn)
assertEquals("user-b", afterInactiveRemoval.userId)
store.removeProfile(bailey.id)
val afterActiveRemoval = store.snapshot()
assertTrue(afterActiveRemoval.profiles.isEmpty())
assertFalse(afterActiveRemoval.isSignedIn)
}
}
@@ -4,12 +4,13 @@ import com.ponzischeme89.memby.data.HomeSnapshot
import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.data.model.HomeRow
import org.junit.Assert.assertFalse
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class AiringTodayTagsTest {
@Test
fun `tags matching recommended series from today's Sonarr schedule`() {
fun `tags matching recommended series from today's TV schedule`() {
val home = HomeSnapshot(
rows = listOf(
HomeRow(
@@ -22,6 +23,7 @@ class AiringTodayTagsTest {
name = "The Bear",
type = "MembySonarrEpisode",
membySource = "sonarr",
membyAirDayLabel = "Today",
),
),
),
@@ -56,6 +58,7 @@ class AiringTodayTagsTest {
name = "Marvel's DAREDEVIL",
type = "MembySonarrEpisode",
membySource = "sonarr",
membyAirDayLabel = "Today",
),
),
),
@@ -85,6 +88,7 @@ class AiringTodayTagsTest {
name = "Fargo",
type = "MembySonarrEpisode",
membySource = "sonarr",
membyAirDayLabel = "Today",
),
),
),
@@ -98,4 +102,80 @@ class AiringTodayTagsTest {
assertFalse(home.withAiringTodayTags().rows.last().items.single().membyAiringToday)
}
@Test
fun `only today's scheduled show is propagated to other rows`() {
val home = HomeSnapshot(
rows = listOf(
HomeRow(
id = "sonarr-airing-today",
title = "Shows airing in the next 5 days",
kind = "schedule",
items = listOf(
BaseItem(
id = "sonarr:1:1",
name = "Today Show",
type = "MembySonarrEpisode",
membySource = "sonarr",
membyAirDayLabel = "Today",
),
BaseItem(
id = "sonarr:2:1",
name = "Tomorrow Show",
type = "MembySonarrEpisode",
membySource = "sonarr",
membyAirDayLabel = "Tomorrow",
),
),
),
HomeRow(
id = "recommended",
title = "Recommended",
items = listOf(
BaseItem(id = "series-1", name = "Today Show", type = "Series"),
BaseItem(id = "series-2", name = "Tomorrow Show", type = "Series"),
),
),
),
)
val recommendations = home.withAiringTodayTags().rows.last().items
assertTrue(recommendations[0].membyAiringToday)
assertFalse(recommendations[1].membyAiringToday)
}
@Test
fun `schedule poster badges use each server-authored day`() {
fun scheduled(day: String) = BaseItem(
id = day,
name = "Show",
type = "MembySonarrEpisode",
membySource = "sonarr",
membyAirDayLabel = day,
membyAiringToday = true,
)
assertEquals("TODAY", airingBadgeLabel(scheduled("Today")))
assertEquals("TOMORROW", airingBadgeLabel(scheduled("Tomorrow")))
assertEquals("FRIDAY", airingBadgeLabel(scheduled("Friday")))
}
@Test
fun `Radarr movie schedule uses its server-authored release day badge`() {
val movie = BaseItem(
id = "radarr:7",
name = "Arrival",
type = "MembyRadarrMovie",
membySource = "radarr",
membyAirDayLabel = "Saturday",
)
assertEquals("SATURDAY", airingBadgeLabel(movie))
}
@Test
fun `upcoming schedule status does not claim the show airs today`() {
assertEquals("UPCOMING", scheduleStatusBadgeLabel("upcoming"))
assertEquals("UPCOMING", scheduleStatusBadgeLabel(""))
}
}
@@ -0,0 +1,34 @@
package com.ponzischeme89.memby.ui
import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.ui.detail.franchiseStart
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class DetailFranchiseTest {
private fun movie(id: String, year: Int, collection: String? = "The Saga") = BaseItem(
id = id,
name = "Movie $id",
type = "Movie",
productionYear = year,
collectionName = collection,
)
@Test
fun `earliest movie in the same collection starts the franchise`() {
val current = movie("three", 2022)
val start = franchiseStart(
current,
listOf(movie("two", 2018), movie("one", 2014), movie("other", 2001, "Other")),
)
assertEquals("The Saga", start?.name)
assertEquals("one", start?.firstMovie?.id)
}
@Test
fun `a collection name without a sibling is not presented as a franchise`() {
assertNull(franchiseStart(movie("only", 2020), emptyList()))
}
}
@@ -0,0 +1,121 @@
package com.ponzischeme89.memby.ui
import com.ponzischeme89.memby.ui.detail.DetailPosition
import com.ponzischeme89.memby.ui.detail.DetailPositionStore
import com.ponzischeme89.memby.ui.detail.DetailTab
import com.ponzischeme89.memby.ui.detail.DetailZone
import com.ponzischeme89.memby.ui.detail.detailTab
import com.ponzischeme89.memby.ui.detail.detailTabs
import org.junit.Assert.assertEquals
import org.junit.Test
/**
* The two rules the detail pages depend on and cannot check on a device: the tab strip is
* decided by what the item *is*, and a page's position survives being closed.
*/
class DetailNavigationTest {
@Test
fun `a movie always offers the same three tabs`() {
assertEquals(
listOf(DetailTab.OVERVIEW, DetailTab.MORE_LIKE_THIS, DetailTab.CAST_DETAILS),
detailTabs(isSeries = false),
)
}
@Test
fun `a series always offers episodes`() {
assertEquals(
listOf(DetailTab.OVERVIEW, DetailTab.EPISODES, DetailTab.MORE_LIKE_THIS, DetailTab.CAST_DETAILS),
detailTabs(isSeries = true),
)
}
/**
* The regression this replaced: the strip used to be built from what had loaded, so a
* movie opened with one tab and grew two more when its metadata arrived moving the
* strip under whatever the viewer was already pressing.
*/
@Test
fun `the strip does not depend on loaded metadata`() {
assertEquals(detailTabs(isSeries = false), detailTabs(isSeries = false))
assertEquals(detailTabs(isSeries = true), detailTabs(isSeries = true))
}
@Test
fun `an episodes key remembered from a series falls back on a movie`() {
assertEquals(
DetailTab.OVERVIEW,
detailTab(DetailTab.EPISODES.key, detailTabs(isSeries = false)),
)
assertEquals(
DetailTab.EPISODES,
detailTab(DetailTab.EPISODES.key, detailTabs(isSeries = true)),
)
}
@Test
fun `an unknown key falls back to overview`() {
assertEquals(DetailTab.OVERVIEW, detailTab("nonsense", detailTabs(isSeries = true)))
}
@Test
fun `a page reopens where it was left`() {
val store = DetailPositionStore()
store.update("show-1") {
it.copy(tabKey = DetailTab.EPISODES.key, season = 3, zone = DetailZone.CONTENT)
}
store.update("show-1") { it.copy(episodeIndex = 6) }
assertEquals(
DetailPosition(
tabKey = DetailTab.EPISODES.key,
season = 3,
zone = DetailZone.CONTENT,
episodeIndex = 6,
),
store.get("show-1"),
)
}
@Test
fun `an unvisited page opens on overview with focus on play`() {
val position = DetailPositionStore().get("never-opened")
assertEquals(DetailTab.OVERVIEW.key, position.tabKey)
assertEquals(DetailZone.PLAY, position.zone)
assertEquals(null, position.season)
}
@Test
fun `play focus pins the complete hero while lower zones release scrolling`() {
assertEquals(0, detailHeroScrollTarget(DetailZone.PLAY))
assertEquals(null, detailHeroScrollTarget(DetailZone.TABS))
assertEquals(null, detailHeroScrollTarget(DetailZone.CONTENT))
assertEquals(null, detailHeroScrollTarget(DetailZone.RELATED))
}
@Test
fun `the store is capped and keeps the most recently used`() {
val store = DetailPositionStore(maxEntries = 3)
listOf("a", "b", "c").forEach { id ->
store.update(id) { it.copy(tabKey = DetailTab.CAST_DETAILS.key) }
}
// Touching "a" makes it the newest, so "b" is what falls out.
store.get("a")
store.update("d") { it.copy(tabKey = DetailTab.MORE_LIKE_THIS.key) }
assertEquals(3, store.size())
assertEquals(DetailTab.CAST_DETAILS.key, store.get("a").tabKey)
assertEquals(DetailTab.OVERVIEW.key, store.get("b").tabKey)
assertEquals(DetailTab.MORE_LIKE_THIS.key, store.get("d").tabKey)
}
@Test
fun `a blank item id is never stored`() {
val store = DetailPositionStore()
store.update("") { it.copy(tabKey = DetailTab.CAST_DETAILS.key) }
assertEquals(0, store.size())
}
}
@@ -0,0 +1,359 @@
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.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.onNodeWithText
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.RelatedContent
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 com.ponzischeme89.memby.ui.detail.creditRows
import com.ponzischeme89.memby.ui.detail.technicalSpecs
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 the movie and series detail pages to PNGs under `build/screenshots/`.
*
* ```powershell
* .\gradlew.bat :app:testDebugUnitTest --tests "*DetailPageScreenshotTest"
* ```
*
* There is no network here, so every artwork URL resolves to null and the pages render on
* their own scrims. That is the point: it is the check that the poster column, the fact row
* and the episode strip hold their shape when Emby has no backdrop and no poster to give
* the worst case, and the one a real library hits often enough to matter.
*
* Like [ServiceAlertBannerScreenshotTest], this is a `*ScreenshotTest.kt` file and is
* allowed the Robolectric dependency; what these pages *say* lives in pure functions in
* `ui/detail/DetailFacts.kt` and is covered by [SeriesDetailsTest] in plain JUnit.
*/
@RunWith(RobolectricTestRunner::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(sdk = [34], qualifiers = "w960dp-h540dp-television-xhdpi")
class DetailPageScreenshotTest {
@get:Rule
val compose = createComposeRule()
@Before
fun locator() {
// The hero asks the repository for its artwork URLs; without a token it answers
// null, which is exactly the fallback case being captured.
ServiceLocator.init(ApplicationProvider.getApplicationContext())
}
@Test
fun `movie page`() {
capture("df_detail-movie") {
MediaDetailContent(
item = movie,
onPlay = {},
onToggleFavorite = { _, _ -> },
onTogglePlayed = { _, _ -> },
related = related,
)
}
}
/** Part-watched: the progress bar and "Resume" wording only appear in this state. */
@Test
fun `movie page part watched`() {
capture("df_detail-movie-resumable") {
MediaDetailContent(
item = movie.copy(
userData = UserItemData(playbackPositionTicks = 47L * 600_000_000L),
),
onPlay = {},
onToggleFavorite = { _, _ -> },
onTogglePlayed = { _, _ -> },
related = related,
)
}
}
@Test
fun `series page`() {
capture("df_detail-series") {
SeriesDetailContent(
item = series,
episodes = episodes,
loadFailed = false,
onPlay = {},
onToggleFavorite = { _, _ -> },
isMyShow = false,
onToggleMyShow = { _, _ -> },
related = related,
)
}
}
/** The first frame, before the episode request lands. The header must already be whole. */
@Test
fun `series page loading`() {
capture("df_detail-series-loading") {
SeriesDetailContent(
item = series,
episodes = null,
loadFailed = false,
onPlay = {},
onToggleFavorite = { _, _ -> },
isMyShow = true,
onToggleMyShow = { _, _ -> },
)
}
}
/**
* The other panes. Clicking a tab is the same path a remote takes the strip selects
* on focus so this also proves the panes swap without disturbing the header.
*/
@Test
fun `series episodes tab`() {
captureTab("df_detail-series-episodes", "Episodes") {
SeriesDetailContent(
item = series,
episodes = episodes,
loadFailed = false,
onPlay = {},
onToggleFavorite = { _, _ -> },
isMyShow = false,
onToggleMyShow = { _, _ -> },
related = related,
)
}
}
@Test
fun `series cast tab`() {
captureTab("df_detail-series-cast-details", "Cast & Details") {
SeriesDetailContent(
item = series,
episodes = episodes,
loadFailed = false,
onPlay = {},
onToggleFavorite = { _, _ -> },
isMyShow = false,
onToggleMyShow = { _, _ -> },
related = related,
)
}
}
@Test
fun `movie details tab`() {
captureTab("df_detail-movie-more-like-this", "More Like This") {
MediaDetailContent(
item = movie,
onPlay = {},
onToggleFavorite = { _, _ -> },
onTogglePlayed = { _, _ -> },
related = related,
)
}
}
/**
* The tab panes at the geometry the scaffold gives them on a 540dp TV the slot the
* audit found clipping every technical spec off the bottom of Cast & Details, which is
* the whole reason that tab exists. A tab does not scroll, so everything it has to say
* has to be inside this box.
*/
@Test
fun `cast and details pane at its real slot geometry`() {
capturePane("df_detail-pane-cast-details") {
DetailCastAndDetailsPane(
item = movie,
credits = creditRows(movie),
specs = technicalSpecs(movie),
detailsLoaded = true,
focusRequester = FocusRequester(),
)
}
}
@Test
fun `overview pane at its real slot geometry`() {
capturePane("df_detail-pane-overview") {
DetailOverviewPane(
item = series,
credits = creditRows(series),
focusRequester = FocusRequester(),
supportingText = "Up next S1 E3 · The Long Count",
)
}
}
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("build/screenshots/$name.png")
}
private fun capture(name: String, content: @Composable () -> Unit) {
compose.setContent {
PreviewSurface(alignment = Alignment.TopStart) { content() }
}
compose.onRoot().captureRoboImage("build/screenshots/$name.png")
}
private fun captureTab(name: String, tab: String, content: @Composable () -> Unit) {
compose.setContent {
PreviewSurface(alignment = Alignment.TopStart) { content() }
}
compose.onNodeWithText(tab).performClick()
compose.onRoot().captureRoboImage("build/screenshots/$name.png")
}
/**
* What the gateway returns for a warm profile: the reason strip above the tabs and the
* carousel under the page. Both are the point of these captures now they are the two
* bands that decide whether the page still fits on one screen.
*/
private val related = RelatedContent(
reasons = listOf(
"Because you watch Thriller",
"You've watched Aria Vance before",
"Well rated (8.4)",
),
items = List(8) { index ->
BaseItem(
id = "related-$index",
name = listOf(
"The Quiet Meridian",
"Harbour Lights",
"Nine Days of Rain",
"The Cartographer's Wife",
"Winterline",
"A Country of Small Rivers",
"The Last Broadcast",
"Northbound",
)[index],
type = "Movie",
productionYear = 2019 + index % 5,
)
},
)
private val cast = listOf(
person("Aria Vance", "Detective Iris Kell"),
person("Marcus Oyelaran", "Samuel Reed"),
person("Nina Kowalczyk", "Dr. Halvorsen"),
person("Tomas Brandt", "The Cartographer"),
person("Ines Ferreira", "Captain Ruiz"),
person("Daniel Cho", "Weatherman"),
)
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", title = "Dolby Atmos"),
MediaStream(type = "Subtitle", codec = "subrip", language = "eng"),
MediaStream(type = "Subtitle", codec = "subrip", language = "fra"),
)
private val movie = BaseItem(
id = "movie-1",
name = "The Longest Northbound Winter",
type = "Movie",
overview = "A cartographer chasing a river that no longer exists finds the last " +
"village on the map still waiting for him, and has to decide whether telling " +
"them the truth is a kindness. Shot over four winters in a valley that floods " +
"every spring, and assembled from what survived.",
taglines = listOf("Some maps are promises."),
productionYear = 2024,
officialRating = "PG-13",
communityRating = 8.4,
genres = listOf("Drama", "Adventure", "Mystery"),
runTimeTicks = 134L * 600_000_000L,
studios = listOf(Studio(name = "Northlight Pictures"), Studio(name = "Kestrel")),
mediaStreams = streams,
people = cast,
)
private val series = 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.",
taglines = listOf("Listen closely."),
productionYear = 2022,
officialRating = "TV-MA",
communityRating = 8.9,
genres = listOf("Thriller", "Drama"),
studios = listOf(Studio(name = "Harbour Line")),
mediaStreams = streams,
people = cast,
)
private val episodes = listOf(
episode(1, 1, "Carrier Wave", played = true),
episode(1, 2, "Dead Air", played = true),
episode(1, 3, "The Long Count", position = 12L * 600_000_000L),
episode(1, 4, "Nightingale"),
episode(1, 5, "Six Weeks Out"),
episode(2, 1, "Landfall"),
)
private fun episode(
season: Int,
number: Int,
title: String,
played: Boolean = false,
position: Long = 0L,
) = BaseItem(
id = "s${season}e$number",
name = title,
type = "Episode",
seriesName = "Signal Hill",
parentIndexNumber = season,
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 = played, playbackPositionTicks = position),
)
private fun person(name: String, role: String) = EmbyPerson(
id = name.filter(Char::isLetter),
name = name,
role = role,
type = "Actor",
)
}
@@ -0,0 +1,191 @@
package com.ponzischeme89.memby.ui
import android.graphics.BitmapFactory
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.aspectRatio
import androidx.compose.foundation.layout.fillMaxHeight
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.shape.RoundedCornerShape
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.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.onRoot
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.test.core.app.ApplicationProvider
import androidx.tv.material3.Text
import com.github.takahirom.roborazzi.captureRoboImage
import com.ponzischeme89.memby.ServiceLocator
import com.ponzischeme89.memby.data.model.BaseItem
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 HomeMovieHeroScreenshotTest {
@get:Rule
val compose = createComposeRule()
@Before
fun locator() {
ServiceLocator.init(ApplicationProvider.getApplicationContext())
}
@Test
fun `home hero with popular and new releases`() {
capture("df_home-movie-hero", movies)
}
/**
* The case the audit reproduced: with a title long enough to wrap, the card's content
* column overflowed its fixed height and the green Play chip the last thing in the
* column was clipped away entirely. It has to survive here.
*/
@Test
fun `home hero with a wrapping title`() {
capture(
"df_home-movie-hero-long-title",
listOf(
HomeHeroPick(
movie(
"The Longest Northbound Winter",
2026,
"A cartographer chasing a river that no longer exists finds the last " +
"village on the map still waiting for him.",
8.4,
),
"NEW RELEASE",
),
) + movies.drop(1),
)
}
private fun capture(name: String, movies: List<HomeHeroPick>) {
val artwork = requireNotNull(javaClass.getResourceAsStream("/home_hero_preview_art.png"))
.use(BitmapFactory::decodeStream)
.asImageBitmap()
val railFocus = FocusRequester()
compose.setContent {
PreviewSurface(alignment = Alignment.TopStart) {
Row(Modifier.fillMaxSize()) {
TvNavigationRail(
selected = BrowseDestination.HOME,
expanded = false,
navigationFocusRequester = railFocus,
onRailFocusChanged = {},
onDestinationSelected = {},
)
Column(Modifier.weight(1f).fillMaxHeight()) {
HomeMovieHero(
movies = movies,
navigationFocusRequester = railFocus,
onItemFocused = {},
onItemSelected = {},
modifier = Modifier.height(homeHeaderHeight(540.dp, showHero = true)),
previewArtwork = artwork,
)
Text(
"Recently added movies",
color = Color(0xFFF1F3F4),
fontSize = 18.sp,
modifier = Modifier.padding(start = 36.dp, top = 6.dp, bottom = 9.dp),
)
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 36.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
repeat(5) { index ->
Column(Modifier.weight(1f)) {
Box(
Modifier
.fillMaxWidth()
.aspectRatio(16f / 9f)
.clip(RoundedCornerShape(7.dp))
.background(
listOf(
Color(0xFF27343D), Color(0xFF28302D),
Color(0xFF352C37), Color(0xFF392F28), Color(0xFF25343A),
)[index],
),
)
Text(
movies[index % movies.size].item.name,
color = Color.White,
fontSize = 13.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(top = 6.dp),
)
Text(
"2026 • 2h 4m",
color = Color(0xFF8F9AA3),
fontSize = 11.sp,
modifier = Modifier.padding(top = 2.dp),
)
}
}
}
}
}
}
}
compose.onNodeWithText("Play").fetchSemanticsNode()
compose.onRoot().captureRoboImage("build/screenshots/$name.png")
}
private val movies = listOf(
HomeHeroPick(
movie(
"The Last Horizon",
2026,
"Beyond the mapped worlds, one explorer finds an ocean that remembers every visitor.",
8.7,
),
"NEW RELEASE",
),
HomeHeroPick(
movie("Midnight Signal", 2026, "A city hears tomorrow's emergency broadcast.", 8.2),
"POPULAR",
),
HomeHeroPick(
movie("Northstar Run", 2025, "The final supply ship takes an impossible route.", 7.9),
"NEW RELEASE",
),
HomeHeroPick(
movie("After the Fire", 2026, "Two strangers cross a country waking from winter.", 8.4),
"FROM YOUR LIBRARY",
),
)
private fun movie(name: String, year: Int, overview: String, rating: Double) = BaseItem(
id = name.lowercase().replace(' ', '-'),
name = name,
type = "Movie",
overview = overview,
productionYear = year,
officialRating = "M",
communityRating = rating,
runTimeTicks = 124L * 600_000_000L,
)
}
@@ -0,0 +1,75 @@
package com.ponzischeme89.memby.ui
import androidx.compose.ui.unit.dp
import com.ponzischeme89.memby.data.model.BaseItem
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
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))
}
@Test
fun `home hero leaves room for a complete shelf on a 540dp tv`() {
val heroHeight = homeHeaderHeight(540.dp, showHero = true)
assertEquals(248.4f, heroHeight.value, 0.01f)
assertTrue(540.dp - heroHeight >= 288.dp)
}
@Test
fun `hero alternates new releases and popular movies`() {
val rows = listOf(
row("latest-movies", "Recently Added Movies", "new-1", "new-2", "new-3"),
row("popular", "Popular Movies", "popular-1", "popular-2"),
)
assertEquals(
listOf("new-1", "popular-1", "new-2", "popular-2"),
selectHomeHeroMovies(rows).map { it.item.id },
)
}
/**
* The caption used to be the card's slot, so the third card was always "TRENDING"
* whatever it was. It now names the row the title was actually drawn from.
*/
@Test
fun `hero labels each pick by the row it came from`() {
val rows = listOf(
row("latest-movies", "Recently Added Movies", "new-1"),
row("popular", "Popular Movies", "popular-1"),
row("comedy", "Comedy", "other-1"),
)
assertEquals(
listOf("NEW RELEASE", "POPULAR", "FROM YOUR LIBRARY"),
selectHomeHeroMovies(rows).map { it.label },
)
}
@Test
fun `hero removes repeated movies across source rows`() {
val rows = listOf(
row("latest", "New releases", "shared", "new-2"),
row("recommended", "Recommended", "shared", "popular-2", "popular-3"),
)
assertEquals(4, selectHomeHeroMovies(rows).size)
assertEquals(4, selectHomeHeroMovies(rows).map { it.item.id }.distinct().size)
}
private fun row(id: String, title: String, vararg ids: String) = HomeBrowseRow(
id = id,
title = title,
items = ids.map { BaseItem(id = it, name = it, type = "Movie") },
kind = MediaRowKind.MOVIES,
emptyMessage = "",
)
}
@@ -28,6 +28,33 @@ class MediaBadgesTest {
)
}
/** HDR10+ used to be named in the spec row and collapse to a plain "HDR" badge. */
@Test
fun `names HDR10+ rather than collapsing it to HDR`() {
val item = BaseItem(
id = "movie",
mediaStreams = listOf(
MediaStream(type = "Video", width = 3840, videoRangeType = "HDR10+"),
),
)
assertEquals(listOf("4K", "HDR10+"), mediaBadges(item))
}
/**
* The badge and the spec row's `(4K)` suffix used to disagree 3800 against 3400
* so a 3600-wide file was 4K on the detail page and not on the card.
*/
@Test
fun `uses the same 4K threshold as the technical spec row`() {
fun badgesAt(width: Int) = mediaBadges(
BaseItem(id = "m", mediaStreams = listOf(MediaStream(type = "Video", width = width))),
)
assertEquals(emptyList<String>(), badgesAt(3_600))
assertEquals(listOf("4K"), badgesAt(3_840))
}
@Test
fun `returns no badges when stream metadata is unavailable`() {
assertEquals(emptyList<String>(), mediaBadges(BaseItem(id = "unknown")))
@@ -0,0 +1,29 @@
package com.ponzischeme89.memby.ui
import com.ponzischeme89.memby.data.model.MyShow
import org.junit.Assert.assertEquals
import org.junit.Test
class MyShowsTest {
@Test
fun missingNextEpisodeHasFriendlyCopy() {
assertEquals("Not announced", formatMyShowDate(null))
assertEquals("Not announced", formatMyShowDate("not-a-date"))
}
@Test
fun cardSubtitlePrioritisesUsefulShowState() {
assertEquals(
"Cancelled",
myShowCardSubtitle(MyShow(itemId = "1", title = "Ended", lifecycle = "Cancelled")),
)
assertEquals(
"Not monitored",
myShowCardSubtitle(MyShow(itemId = "2", title = "Paused", sonarrStatus = "Not monitored")),
)
assertEquals(
"Saved show",
myShowCardSubtitle(MyShow(itemId = "3", title = "Unknown")),
)
}
}
@@ -0,0 +1,29 @@
package com.ponzischeme89.memby.ui
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class QuickActionsNavigationTest {
@Test
fun `quick actions require an intentional hold`() {
assertTrue(QuickActionsHoldDurationMillis >= 600L)
}
@Test
fun `down reaches every action and stops at the final action`() {
assertEquals(1, quickActionNextIndex(0, 4, QuickActionDirection.DOWN))
assertEquals(3, quickActionNextIndex(3, 4, QuickActionDirection.DOWN))
}
@Test
fun `up returns toward the safe first action and stops there`() {
assertEquals(2, quickActionNextIndex(3, 4, QuickActionDirection.UP))
assertEquals(0, quickActionNextIndex(0, 4, QuickActionDirection.UP))
}
@Test
fun `empty menus remain bounded`() {
assertEquals(0, quickActionNextIndex(5, 0, QuickActionDirection.DOWN))
}
}
@@ -0,0 +1,54 @@
package com.ponzischeme89.memby.ui
import androidx.compose.ui.unit.dp
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class ResponsiveRowSizingTest {
@Test
fun metadataPanelKeepsReadableLineLengthsAcrossTvWidths() {
assertEquals(537.6f, metadataPanelContentWidth(640.dp, compact = true).value, 0.01f)
assertEquals(720f, metadataPanelContentWidth(1920.dp, compact = false).value, 0.01f)
}
@Test
fun standardPosterRowFitsSevenCompleteCards() {
val width = responsiveRowCardWidth(
availableWidth = 1280.dp,
preferredCardsAcross = 7,
minWidth = 102.dp,
maxWidth = 218.dp,
)
val occupiedWidth = width * 7 + 16.dp * 6 + 36.dp * 2
assertEquals(1280f, occupiedWidth.value, 0.01f)
}
@Test
fun narrowRowReducesCardCountInsteadOfClippingCards() {
val width = responsiveRowCardWidth(
availableWidth = 640.dp,
preferredCardsAcross = 7,
minWidth = 102.dp,
maxWidth = 218.dp,
)
// Four 130dp cards, three gaps and both insets fill this viewport exactly.
assertEquals(130f, width.value, 0.01f)
assertEquals(640f, (width * 4 + 16.dp * 3 + 36.dp * 2).value, 0.01f)
}
@Test
fun wideRowAddsCardsRatherThanExceedingMaximumWidth() {
val width = responsiveRowCardWidth(
availableWidth = 2560.dp,
preferredCardsAcross = 4,
minWidth = 164.dp,
maxWidth = 360.dp,
)
assertTrue(width <= 360.dp)
assertEquals(341.71f, width.value, 0.01f)
}
}
@@ -0,0 +1,73 @@
package com.ponzischeme89.memby.ui
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class RowNavigationTest {
@Test
fun `vertical movement skips rows without focusable cards`() {
val counts = listOf(6, 0, 0, 4)
assertEquals(
3,
adjacentFocusableRowIndex(counts, 0, RowFocusDirection.DOWN),
)
assertEquals(
0,
adjacentFocusableRowIndex(counts, 3, RowFocusDirection.UP),
)
}
@Test
fun `vertical movement stops at the page boundary`() {
val counts = listOf(3, 2)
assertNull(adjacentFocusableRowIndex(counts, 0, RowFocusDirection.UP))
assertNull(adjacentFocusableRowIndex(counts, 1, RowFocusDirection.DOWN))
}
@Test
fun `vertical movement can traverse beyond the second row`() {
val counts = listOf(6, 6, 6, 6, 6)
val visited = mutableListOf(0)
var current = 0
while (true) {
current = adjacentFocusableRowIndex(
counts,
current,
RowFocusDirection.DOWN,
) ?: break
visited += current
}
assertEquals(listOf(0, 1, 2, 3, 4), visited)
}
@Test
fun `first visit keeps horizontal position and clamps to a shorter row`() {
assertEquals(4, rowEntryItemIndex(4, destinationItemCount = 8, null))
assertEquals(2, rowEntryItemIndex(7, destinationItemCount = 3, null))
}
@Test
fun `return visit restores the destination row position`() {
assertEquals(
1,
rowEntryItemIndex(
sourceIndex = 5,
destinationItemCount = 8,
rememberedDestinationIndex = 1,
),
)
assertEquals(
2,
rowEntryItemIndex(
sourceIndex = 1,
destinationItemCount = 3,
rememberedDestinationIndex = 20,
),
)
}
}
@@ -2,6 +2,10 @@ 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.availableSeasons
import com.ponzischeme89.memby.ui.detail.defaultSeason
import com.ponzischeme89.memby.ui.detail.episodesForSeason
import com.ponzischeme89.memby.ui.detail.seasonLabel
import org.junit.Assert.assertEquals
import org.junit.Test
@@ -55,9 +59,8 @@ class SeriesDetailsTest {
}
@Test
fun `detail tabs default safely to episodes`() {
assertEquals(SeriesDetailSection.EPISODES, seriesDetailSection("episodes"))
assertEquals(SeriesDetailSection.CAST, seriesDetailSection("cast"))
assertEquals(SeriesDetailSection.EPISODES, seriesDetailSection("future-section"))
fun `season zero is labelled specials`() {
assertEquals("Specials", seasonLabel(0))
assertEquals("Season 3", seasonLabel(3))
}
}
@@ -4,6 +4,7 @@ import com.ponzischeme89.memby.data.HomeCache
import com.ponzischeme89.memby.data.Settings
import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.data.model.HomeRow
import com.ponzischeme89.memby.data.model.UserItemData
import kotlinx.serialization.json.Json
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
@@ -15,6 +16,28 @@ import org.junit.Test
* the server invented, and surviving a cold start from cache.
*/
class ServerHomeRowsTest {
@Test
fun `profile row preferences hide pin and order server rows`() {
val serverRows = listOf(
HomeRow("continue", "Continue", "continue", emptyList()),
HomeRow("recommended", "Recommended", "recommendation", emptyList()),
HomeRow("latest-movies", "Latest", "latest", emptyList()),
HomeRow("seasonal", "Seasonal", "recommendation", emptyList()),
)
val settings = Settings(
homeRowOrder = "seasonal\nrecommended\ncontinue",
homePinnedRows = "recommended",
homeHiddenRows = "latest-movies",
)
val result = serverHomeRows(
HomeUiState(rows = serverRows, loading = emptySet()),
settings,
)
assertEquals(listOf("recommended", "seasonal", "continue"), result.map { it.id })
}
private val json = Json { ignoreUnknownKeys = true }
@@ -73,10 +96,7 @@ class ServerHomeRowsTest {
@Test
fun `successful login welcome uses authenticated username`() {
assertEquals(
"You're now logged in as Matt. Welcome to Memby!",
loginWelcomeMessage(" Matt "),
)
assertTrue(loginWelcomeMessage(" Matt ").startsWith("Welcome to Memby, Matt. "))
}
@Test
@@ -189,7 +209,61 @@ class ServerHomeRowsTest {
}
@Test
fun `Sonarr schedule rows use show cards and cannot be hidden by old preferences`() {
fun `home and library destinations have distinct shelves`() {
val state = HomeUiState(
rows = serverRows + listOf(
row("curated:drama-shows", "shows", "show"),
row("curated:movies:genre:drama", "movies", "movie"),
),
loading = emptySet(),
)
val home = homeRowsFor(BrowseDestination.HOME, state, Settings())
val shows = homeRowsFor(BrowseDestination.SHOWS, state, Settings())
val movies = homeRowsFor(BrowseDestination.MOVIES, state, Settings())
assertTrue(home.none { it.id.startsWith("curated:") })
assertTrue(shows.any { it.id == "curated:drama-shows" })
assertTrue(movies.any { it.id == "curated:movies:genre:drama" })
}
@Test
fun `discovery shelves do not repeat a continued series or played title`() {
val continued = BaseItem(id = "episode", type = "Episode", seriesId = "hard-man")
val repeatedSeries = BaseItem(id = "hard-man", type = "Series")
val playedMovie = BaseItem(
id = "played",
type = "Movie",
userData = UserItemData(played = true),
)
val fresh = (1..4).map { BaseItem(id = "fresh-$it", type = "Series") }
val rows = deduplicateBrowseRows(
listOf(
HomeBrowseRow(
id = "continue",
title = "Continue",
items = listOf(continued),
kind = MediaRowKind.CONTINUE,
emptyMessage = "empty",
),
HomeBrowseRow(
id = "curated:drama-shows",
title = "Drama",
items = listOf(repeatedSeries, playedMovie) + fresh,
kind = MediaRowKind.SHOWS,
emptyMessage = "empty",
),
),
)
assertEquals(
fresh.map { it.id },
rows.last().items.map { it.id },
)
}
@Test
fun `TV schedule rows use show cards and cannot be hidden by old preferences`() {
val schedule = row("sonarr-airing-today", "schedule", "sonarr:7:42")
val rows = serverHomeRows(
@@ -199,7 +273,24 @@ class ServerHomeRowsTest {
assertEquals(1, rows.size)
assertEquals(MediaRowKind.SHOWS, rows.single().kind)
assertEquals("No monitored shows are airing today", rows.single().emptyMessage)
assertEquals("No monitored shows are airing in the next 5 days", rows.single().emptyMessage)
}
@Test
fun `Radarr schedule rows use movie cards and explain an empty digital window`() {
val schedule = row("radarr-upcoming-movies", "movie-schedule", "radarr:7")
val rows = serverHomeRows(
HomeUiState(rows = listOf(schedule), loading = emptySet()),
Settings(homeSections = ""),
)
assertEquals(1, rows.size)
assertEquals(MediaRowKind.MOVIES, rows.single().kind)
assertEquals(
"No monitored movies have a digital release in the next 5 days",
rows.single().emptyMessage,
)
}
@Test
@@ -1,7 +1,14 @@
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
@@ -25,8 +32,8 @@ import org.robolectric.annotation.GraphicsMode
* composable genuinely cannot be done in plain JUnit, so Robolectric is confined to files
* named `*ScreenshotTest.kt`. Logic tests stay pure; keep them that way.
*
* Qualifiers describe a 1080p TV: 960x540dp at xhdpi. The bar is drawn on the launcher's
* own background, flush to the top edge exactly as `MainActivity` places it.
* Qualifiers describe a 1080p TV: 960x540dp at xhdpi. The bar is drawn over a realistic
* cinematic playback still, flush to the top edge exactly as `PlayerActivity` places it.
*
* [AlertBanner] is rendered directly rather than [ServiceAlertBanner]: the wrapper's whole
* job is the drop-in from above, and a still frame of an animation says nothing. Posters
@@ -49,7 +56,6 @@ class ServiceAlertBannerScreenshotTest {
id = "sonarr:7:42:aired",
title = "Northbound",
message = "S02E04 — The Crossing aired at 9:00 PM and will be in Emby soon.",
posterUrl = null,
),
)
}
@@ -62,7 +68,54 @@ class ServiceAlertBannerScreenshotTest {
id = "sonarr:8:43:aired",
title = "The Long Dark",
message = "S01E09 — Winterlight aired at 8:30 PM and is downloading now.",
posterUrl = null,
),
)
}
/**
* A Radarr import, which is the case with a server-supplied eyebrow: the longest
* label the bar is expected to carry has to leave the countdown ring room.
*/
@Test
fun `new movie added`() {
capture(
"alert-banner-movie-added",
ServiceAlert(
id = "radarr:412:file:9001",
title = "Mr. Smith Goes to Washington (1939)",
message = "Mr. Smith Goes to Washington will be available in Emby shortly.",
label = "NEW MOVIE ADDED",
),
)
}
/** The service itself talking: a finished refresh, with nothing to illustrate it. */
@Test
fun `library updated`() {
capture(
"alert-banner-library-updated",
ServiceAlert(
id = "library:1785012345",
title = "24 titles added or updated",
message = "Memby has finished refreshing — it is on the home screen now.",
label = "LIBRARY UPDATED",
),
)
}
/**
* The alert most likely to be read over a film, and the one whose wording has to work
* with the picture already stalled behind it.
*/
@Test
fun `server not responding`() {
capture(
"alert-banner-server-down",
ServiceAlert(
id = "emby:down:1785012345",
title = "Emby has stopped communicating",
message = "Playback may stop until it is back. Memby will say when it returns.",
label = "SERVER NOT RESPONDING",
),
)
}
@@ -78,7 +131,6 @@ class ServiceAlertBannerScreenshotTest {
message = "S11E03 — The One Where Absolutely Everything Happens At Once " +
"And Then Some More Happens After That aired at 10:30 PM and is " +
"downloading now.",
posterUrl = null,
),
)
}
@@ -92,7 +144,6 @@ class ServiceAlertBannerScreenshotTest {
id = "sonarr:3:12:aired",
title = "Dune",
message = "S01E01 aired and will be in Emby soon.",
posterUrl = null,
),
)
}
@@ -110,7 +161,6 @@ class ServiceAlertBannerScreenshotTest {
id = "sonarr:7:42:aired",
title = "Northbound",
message = "S02E04 — The Crossing aired at 9:00 PM and will be in Emby soon.",
posterUrl = null,
),
)
}
@@ -125,7 +175,16 @@ class ServiceAlertBannerScreenshotTest {
@Composable
private fun AlertBannerOnHomeBackground(alert: ServiceAlert) {
PreviewSurface(alignment = Alignment.TopCenter) {
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(),
)
AlertBanner(alert)
}
}
@@ -0,0 +1,40 @@
package com.ponzischeme89.memby.ui
import org.junit.Assert.assertEquals
import org.junit.Test
class UserSwitcherNavigationTest {
@Test
fun `active profile receives initial focus`() {
assertEquals(
1,
userSwitcherInitialIndex(listOf("one", "two", "three"), "two"),
)
}
@Test
fun `three profiles lead to pinned manage action`() {
val profileCount = 3
assertEquals(
3,
userSwitcherNextIndex(2, profileCount, UserSwitcherDirection.DOWN),
)
assertEquals(
3,
userSwitcherNextIndex(3, profileCount, UserSwitcherDirection.DOWN),
)
}
@Test
fun `up and down navigation stay inside the switcher`() {
assertEquals(0, userSwitcherNextIndex(0, 3, UserSwitcherDirection.UP))
assertEquals(2, userSwitcherNextIndex(3, 3, UserSwitcherDirection.UP))
}
@Test
fun `manage remains reachable when no profiles exist`() {
assertEquals(0, userSwitcherInitialIndex(emptyList(), null))
assertEquals(0, userSwitcherNextIndex(0, 0, UserSwitcherDirection.DOWN))
}
}
@@ -0,0 +1,101 @@
package com.ponzischeme89.memby.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onRoot
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.test.core.app.ApplicationProvider
import androidx.tv.material3.Text
import com.github.takahirom.roborazzi.captureRoboImage
import com.ponzischeme89.memby.ServiceLocator
import com.ponzischeme89.memby.data.model.BaseItem
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
@RunWith(RobolectricTestRunner::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(sdk = [34], qualifiers = "w960dp-h540dp-television-xhdpi")
class WatchedVisibilityScreenshotTest {
@get:Rule
val compose = createComposeRule()
@Before
fun locator() {
ServiceLocator.init(ApplicationProvider.getApplicationContext())
}
@Test
fun `browse cards hide watched movies and count watched episodes`() {
val visible = applyWatchedVisibility(listOf(row), enabled = true).single()
compose.setContent {
PreviewSurface {
Column(Modifier.fillMaxSize().padding(48.dp)) {
Text("Because you like great stories", color = Color.White, fontSize = 22.sp)
Row(
modifier = Modifier.padding(top = 18.dp),
horizontalArrangement = Arrangement.spacedBy(18.dp),
) {
visible.items.forEach { item ->
PortraitMediaCard(
item = item,
availableWidth = 820.dp,
showSecondaryMetadata = true,
onFocused = {},
onClick = {},
onLongClick = {},
density = "large",
showWatchedEpisodeCount = visible.showWatchedEpisodeCount,
)
}
}
}
}
}
compose.onRoot().captureRoboImage("build/screenshots/watched-visibility-row.png")
}
private val row = HomeBrowseRow(
id = "stories",
title = "Stories",
kind = MediaRowKind.MOVIES,
emptyMessage = "Nothing here",
items = listOf(
BaseItem(
id = "seen",
name = "Already Seen",
type = "Movie",
userData = UserItemData(played = true),
),
BaseItem(
id = "signal",
name = "Signal House",
type = "Series",
recursiveItemCount = 10,
userData = UserItemData(unplayedItemCount = 2),
),
BaseItem(
id = "winter",
name = "A Long Winter",
type = "Series",
recursiveItemCount = 24,
userData = UserItemData(unplayedItemCount = 19),
),
BaseItem(id = "new", name = "Not Watched Yet", type = "Movie", productionYear = 2026),
),
)
}
@@ -0,0 +1,66 @@
package com.ponzischeme89.memby.ui
import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.data.model.UserItemData
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class WatchedVisibilityTest {
@Test
fun `preference is disabled by default`() {
assertFalse(com.ponzischeme89.memby.data.Settings().hideWatchedMovies)
}
@Test
fun `enabled preference hides only completed movies`() {
val watchedMovie = item("movie", "Movie", played = true)
val unwatchedMovie = item("new-movie", "Movie")
val watchedSeries = item("series", "Series", played = true)
val row = row(watchedMovie, unwatchedMovie, watchedSeries)
val filtered = applyWatchedVisibility(listOf(row), enabled = true).single()
assertEquals(listOf("new-movie", "series"), filtered.items.map(BaseItem::id))
assertTrue(filtered.showWatchedEpisodeCount)
}
@Test
fun `disabled preference preserves row content`() {
val row = row(item("movie", "Movie", played = true))
val unchanged = applyWatchedVisibility(listOf(row), enabled = false).single()
assertEquals(listOf("movie"), unchanged.items.map(BaseItem::id))
assertFalse(unchanged.showWatchedEpisodeCount)
}
@Test
fun `series progress uses aggregate episode counts`() {
val series = BaseItem(
id = "series",
type = "Series",
recursiveItemCount = 10,
userData = UserItemData(unplayedItemCount = 2),
)
assertEquals("8 of 10 watched", watchedEpisodeCountLabel(series))
assertNull(watchedEpisodeCountLabel(item("movie", "Movie")))
}
private fun item(id: String, type: String, played: Boolean = false) = BaseItem(
id = id,
type = type,
userData = UserItemData(played = played),
)
private fun row(vararg items: BaseItem) = HomeBrowseRow(
id = "row",
title = "Row",
items = items.toList(),
kind = MediaRowKind.MOVIES,
emptyMessage = "Empty",
)
}
@@ -0,0 +1,31 @@
package com.ponzischeme89.memby.ui
import kotlin.random.Random
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class WelcomeQuotesTest {
@Test
fun `unknown styles safely fall back to neutral`() {
assertEquals(
randomWelcomeQuote("neutral", Random(7)),
randomWelcomeQuote("something-new", Random(7)),
)
}
@Test
fun `each style supplies a welcome line`() {
WelcomeQuoteStyle.entries.forEach { style ->
assertTrue(randomWelcomeQuote(style.value, Random(3)).isNotBlank())
}
}
@Test
fun `login greeting trims the authenticated username`() {
assertTrue(
loginWelcomeMessage(" Matt ", "positive", Random(1))
.startsWith("Welcome to Memby, Matt. "),
)
}
}
@@ -0,0 +1,92 @@
package com.ponzischeme89.memby.ui.player
import android.app.Activity
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.Color
import android.graphics.drawable.BitmapDrawable
import android.view.LayoutInflater
import android.view.View
import android.widget.FrameLayout
import android.widget.ImageView
import android.widget.TextView
import com.github.takahirom.roborazzi.captureRoboImage
import com.ponzischeme89.memby.R
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.Robolectric
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 PlaybackIdentityScreenshotTest {
@Test
fun `colour artwork keeps its original treatment`() {
val activity = Robolectric.buildActivity(Activity::class.java).setup().get()
val colourLogo = Bitmap.createBitmap(64, 64, Bitmap.Config.ARGB_8888).apply {
eraseColor(Color.rgb(82, 181, 75))
}
assertFalse(
makeLogoVisibleOnDarkBackground(
ImageView(activity),
BitmapDrawable(activity.resources, colourLogo),
),
)
}
@Test
fun `black artwork is lightened for dark player surfaces`() {
val activity = Robolectric.buildActivity(Activity::class.java).setup().get()
val blackLogo = Bitmap.createBitmap(64, 64, Bitmap.Config.ARGB_8888).apply {
eraseColor(Color.BLACK)
}
assertTrue(
makeLogoVisibleOnDarkBackground(
ImageView(activity),
BitmapDrawable(activity.resources, blackLogo),
),
)
}
@Test
fun `plain title and emby mark appear over playback for five seconds`() {
val activity = Robolectric.buildActivity(Activity::class.java).setup().get()
val root = FrameLayout(activity)
val backdrop = ImageView(activity).apply {
scaleType = ImageView.ScaleType.CENTER_CROP
setImageBitmap(
javaClass.classLoader
?.getResourceAsStream("home_hero_preview_art.png")
?.use(BitmapFactory::decodeStream),
)
}
root.addView(
backdrop,
FrameLayout.LayoutParams(
FrameLayout.LayoutParams.MATCH_PARENT,
FrameLayout.LayoutParams.MATCH_PARENT,
),
)
val identity = LayoutInflater.from(activity)
.inflate(R.layout.player_playback_identity, root, false)
.apply {
visibility = View.VISIBLE
alpha = 1f
}
identity.findViewById<TextView>(R.id.player_playback_identity_title).text = "Dark Matter"
root.addView(identity)
activity.setContentView(root)
assertEquals(5_000L, PlayerActivity.PLAYBACK_IDENTITY_VISIBLE_MS)
root.captureRoboImage("build/screenshots/player-playback-identity.png")
}
}
@@ -19,13 +19,14 @@ class PlaybackRecoveryTest {
}
@Test
fun decoderFailuresRequireViewerAction() {
fun decoderFailuresAutomaticallyRequestCompatibleStream() {
val failure = describePlaybackFailure(
PlaybackException.ERROR_CODE_DECODING_FORMAT_UNSUPPORTED,
)
assertEquals("Video format not supported", failure.title)
assertFalse(failure.canAutoRetry)
assertTrue(failure.canAutoRetry)
assertTrue(failure.requiresTranscode)
}
@Test
@@ -0,0 +1,90 @@
package com.ponzischeme89.memby.ui.player
import android.app.Activity
import android.graphics.BitmapFactory
import android.view.LayoutInflater
import android.view.View
import android.widget.FrameLayout
import android.widget.ImageView
import android.widget.TextView
import com.github.takahirom.roborazzi.captureRoboImage
import com.ponzischeme89.memby.R
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.Robolectric
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 PlayerPauseOverlayScreenshotTest {
@Test
fun `paused movie shows focused poster synopsis and resume controls`() {
val (activity, root, controls) = playerSurface()
controls.findViewById<View>(R.id.player_pause_overlay).visibility = View.VISIBLE
controls.findViewById<View>(R.id.player_now_playing_group).visibility = View.GONE
controls.findViewById<TextView>(R.id.player_pause_title).text = "The Last Horizon"
controls.findViewById<TextView>(R.id.player_pause_overview).text =
"A cartographer follows a signal beyond the edge of the known world, " +
"where an abandoned observatory may hold the way home."
controls.findViewById<ImageView>(R.id.player_pause_poster).setImageBitmap(previewArtwork())
controls.findViewById<TextView>(androidx.media3.ui.R.id.exo_position).text = "42:18"
controls.findViewById<TextView>(androidx.media3.ui.R.id.exo_duration).text = "1:54:02"
controls.findViewById<TextView>(R.id.player_remaining).text = "1h 12m left"
controls.findViewById<TextView>(R.id.player_finish_time).text = "Ends at 10:14 PM"
root.captureRoboImage("build/screenshots/player-paused-movie-overlay.png")
}
@Test
fun `playing OSD keeps video visible behind controls without black container`() {
val (_, root, controls) = playerSurface()
controls.findViewById<View>(R.id.player_pause_overlay).visibility = View.GONE
controls.findViewById<View>(R.id.player_title_logo).visibility = View.GONE
controls.findViewById<TextView>(R.id.player_title).apply {
text = "The Last Horizon"
visibility = View.VISIBLE
}
controls.findViewById<TextView>(androidx.media3.ui.R.id.exo_position).text = "42:18"
controls.findViewById<TextView>(androidx.media3.ui.R.id.exo_duration).text = "1:54:02"
controls.findViewById<TextView>(R.id.player_remaining).text = "1h 12m left"
controls.findViewById<TextView>(R.id.player_finish_time).text = "Ends at 10:14 PM"
root.captureRoboImage("build/screenshots/player-osd-no-black-container.png")
}
private fun playerSurface(): Triple<Activity, FrameLayout, View> {
val activity = Robolectric.buildActivity(Activity::class.java).setup().get()
val root = FrameLayout(activity)
root.addView(
ImageView(activity).apply {
scaleType = ImageView.ScaleType.CENTER_CROP
setImageBitmap(previewArtwork())
},
FrameLayout.LayoutParams(
FrameLayout.LayoutParams.MATCH_PARENT,
FrameLayout.LayoutParams.MATCH_PARENT,
),
)
val controls = FrameLayout(activity)
root.addView(
controls,
FrameLayout.LayoutParams(
FrameLayout.LayoutParams.MATCH_PARENT,
FrameLayout.LayoutParams.MATCH_PARENT,
),
)
LayoutInflater.from(activity).inflate(R.layout.memby_player_controls, controls, true)
activity.setContentView(root)
return Triple(activity, root, controls)
}
private fun previewArtwork() =
javaClass.classLoader
?.getResourceAsStream("home_hero_preview_art.png")
?.use(BitmapFactory::decodeStream)
}
@@ -1,6 +1,7 @@
package com.ponzischeme89.memby.ui.player
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class PlayerTimingTest {
@@ -11,4 +12,50 @@ class PlayerTimingTest {
assertEquals("1 hr remaining", PlayerActivity.formatRemaining(60L * 60_000L))
assertEquals("2 hr 5 min remaining", PlayerActivity.formatRemaining(125L * 60_000L))
}
@Test
fun timeRemainingCueStartsStrictlyBelowTenMinutes() {
assertNull(PlayerActivity.timeRemainingCueMinutes(10L * 60_000L))
assertEquals(10, PlayerActivity.timeRemainingCueMinutes(10L * 60_000L - 1L))
assertEquals(2, PlayerActivity.timeRemainingCueMinutes(61_000L))
assertEquals(1, PlayerActivity.timeRemainingCueMinutes(1L))
assertNull(PlayerActivity.timeRemainingCueMinutes(0L))
}
@Test
fun playbackStartCueWaitsForTenSecondsOfPlaying() {
assertEquals(false, PlayerActivity.playbackStartCueReady(9_999L))
assertEquals(true, PlayerActivity.playbackStartCueReady(10_000L))
assertEquals("1 min", PlayerActivity.formatCueDuration(1L))
assertEquals("42 mins", PlayerActivity.formatCueDuration(42L * 60_000L))
}
@Test
fun resumedPlaybackShowsTimeLeftAfterTwoSecondsOfPlaying() {
assertEquals(false, PlayerActivity.playbackStartCueReady(1_999L, resumePositionMs = 1L))
assertEquals(true, PlayerActivity.playbackStartCueReady(2_000L, resumePositionMs = 1L))
}
@Test
fun prerollRuntimeUsesCompactEpisodeFacts() {
assertNull(PlayerActivity.formatPrerollRuntime(0L))
assertEquals("48 mins", PlayerActivity.formatPrerollRuntime(48L * 60_000L))
assertEquals("1h 42m", PlayerActivity.formatPrerollRuntime(102L * 60_000L))
}
@Test
fun completedPlaybackReturnsHomeUnlessAutoplayCanAdvance() {
assertEquals(
PlaybackCompletionAction.RETURN_HOME,
playbackCompletionAction(hasNextEpisode = false, nextUpDismissed = false),
)
assertEquals(
PlaybackCompletionAction.RETURN_HOME,
playbackCompletionAction(hasNextEpisode = true, nextUpDismissed = true),
)
assertEquals(
PlaybackCompletionAction.PLAY_NEXT,
playbackCompletionAction(hasNextEpisode = true, nextUpDismissed = false),
)
}
}
@@ -3,8 +3,11 @@ package com.ponzischeme89.memby.ui.player
import android.content.Context
import android.view.LayoutInflater
import android.widget.FrameLayout
import android.widget.GridLayout
import android.widget.ImageView
import androidx.test.core.app.ApplicationProvider
import com.ponzischeme89.memby.R
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Test
import org.junit.runner.RunWith
@@ -20,13 +23,26 @@ class PrerollLayoutTest {
val context = ApplicationProvider.getApplicationContext<Context>()
assertNotNull(context.getDrawable(R.drawable.player_preroll_video_background))
assertNotNull(
LayoutInflater.from(context).inflate(
R.layout.player_preroll,
FrameLayout(context),
false,
),
val preroll = LayoutInflater.from(context).inflate(
R.layout.player_preroll,
FrameLayout(context),
false,
)
val videoHost = preroll.findViewById<FrameLayout>(R.id.player_preroll_video_host)
assertNotNull(videoHost)
assertEquals(
(398 * context.resources.displayMetrics.density).toInt(),
videoHost.layoutParams.width,
)
assertNotNull(preroll.findViewById<PrerollCountdownView>(R.id.player_preroll_countdown))
assertNotNull(preroll.findViewById<ImageView>(R.id.player_preroll_brand))
assertEquals(
"Starting in 7 seconds…",
context.getString(R.string.player_preroll_countdown_initial),
)
assertNotNull(context.getDrawable(R.drawable.player_preroll_video_frame))
assertNotNull(preroll.findViewById<GridLayout>(R.id.player_preroll_calendar))
assertEquals(6_500L, PlayerActivity.DEFAULT_PREROLL_DURATION_MS)
assertNotNull(
LayoutInflater.from(context).inflate(
R.layout.player_cast_overlay,
@@ -0,0 +1,106 @@
package com.ponzischeme89.memby.ui.player
import android.app.Activity
import android.graphics.BitmapFactory
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.FrameLayout
import android.widget.GridLayout
import android.widget.ImageView
import android.widget.TextView
import com.github.takahirom.roborazzi.captureRoboImage
import com.ponzischeme89.memby.R
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.Robolectric
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 PrerollScreenshotTest {
@Test
fun `sonarr preroll uses hero video and artwork stack`() {
val activity = Robolectric.buildActivity(Activity::class.java).setup().get()
val root = FrameLayout(activity)
val preroll = LayoutInflater.from(activity).inflate(R.layout.player_preroll, root, false)
preroll.visibility = View.VISIBLE
root.addView(
preroll,
FrameLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT,
),
)
val artwork = javaClass.classLoader
?.getResourceAsStream("home_hero_preview_art.png")
?.use(BitmapFactory::decodeStream)
preroll.findViewById<FrameLayout>(R.id.player_preroll_video_host).addView(
ImageView(activity).apply {
scaleType = ImageView.ScaleType.CENTER_CROP
setImageBitmap(artwork)
},
0,
FrameLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT,
),
)
val samples = listOf(
Triple("TODAY · 8:30 PM", "Northbound", "S02E04 · The Crossing"),
Triple("TODAY · 9:00 PM", "Harbour", "S03E01 · Home Water"),
Triple("TODAY · 9:30 PM", "The Bear", "S04E03 · Bridges"),
Triple("TODAY · 10:00 PM", "Slow Horses", "S05E02 · Signals"),
Triple("WED · 7:30 PM", "Foundation", "S03E06 · The Mule"),
Triple("THU · 8:00 PM", "Severance", "S03E01 · Cold Harbour"),
Triple("FRI · 8:30 PM", "Silo", "S03E04 · Legacy"),
Triple("SAT · 7:00 PM", "North Shore", "S01E07 · The Dive"),
)
preroll.findViewById<TextView>(R.id.player_preroll_now_title).text =
"Northbound The Crossing"
preroll.findViewById<TextView>(R.id.player_preroll_now_metadata).text =
"EPISODE · S02E04 · 48 mins"
preroll.findViewById<TextView>(R.id.player_preroll_now_overview).text =
"The crew follows a signal beyond the last charted crossing and discovers " +
"a settlement that has been waiting for their arrival."
val calendar = preroll.findViewById<GridLayout>(R.id.player_preroll_calendar)
samples.forEachIndexed { index, (label, title, detail) ->
val card = LayoutInflater.from(activity)
.inflate(R.layout.player_preroll_schedule_card, calendar, false)
card.findViewById<ImageView>(R.id.player_preroll_card_artwork).setImageBitmap(artwork)
card.findViewById<TextView>(R.id.player_preroll_card_label).text = label
card.findViewById<TextView>(R.id.player_preroll_card_title).text = title
card.findViewById<TextView>(R.id.player_preroll_card_detail).text = detail
calendar.addView(
card,
GridLayout.LayoutParams().apply {
width = 0
height = dp(activity, 80)
columnSpec = GridLayout.spec(index % 4, 1f)
rowSpec = GridLayout.spec(index / 4)
setMargins(dp(activity, 4), dp(activity, 3), dp(activity, 4), dp(activity, 3))
},
)
}
val countdown = preroll.findViewById<PrerollCountdownView>(R.id.player_preroll_countdown)
countdown.setCountdown(
seconds = 7,
progress = 1f,
description = "Starting in 7 seconds",
)
activity.setContentView(root)
root.captureRoboImage("build/screenshots/player-sonarr-preroll.png")
countdown.setCountdown(seconds = 3, progress = 3f / 6.5f, description = "Starting in 3 seconds")
root.captureRoboImage("build/screenshots/player-sonarr-preroll-mid-countdown.png")
}
private fun dp(activity: Activity, value: Int): Int =
(value * activity.resources.displayMetrics.density).toInt()
}
@@ -6,7 +6,24 @@ import org.junit.Test
class PrerollSequenceTest {
@Test
fun `handoff waits only for the five second gate`() {
fun `fresh playback shows preroll`() {
assertTrue(shouldShowPreroll(0L))
}
@Test
fun `resumed playback skips preroll`() {
assertFalse(shouldShowPreroll(1L))
assertFalse(shouldShowPreroll(42 * 60_000L))
}
@Test
fun `server can disable preroll for fresh playback`() {
assertFalse(shouldShowPreroll(0L, enabled = false))
assertTrue(shouldShowPreroll(0L, enabled = true))
}
@Test
fun `handoff waits for the video countdown gate`() {
assertFalse(prerollCanHandOff(true, false, true))
assertTrue(prerollCanHandOff(true, true, true))
}
@@ -0,0 +1,53 @@
package com.ponzischeme89.memby.ui.player
import android.app.Activity
import android.graphics.Color
import android.graphics.drawable.GradientDrawable
import android.view.LayoutInflater
import android.view.View
import android.widget.FrameLayout
import android.widget.TextView
import com.github.takahirom.roborazzi.captureRoboImage
import com.ponzischeme89.memby.R
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.Robolectric
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 SeasonFinaleLowerThirdScreenshotTest {
@Test
fun `season finale stacks above playback start cue`() {
val activity = Robolectric.buildActivity(Activity::class.java).setup().get()
val root = FrameLayout(activity).apply {
background = GradientDrawable(
GradientDrawable.Orientation.TL_BR,
intArrayOf(
Color.rgb(17, 37, 48),
Color.rgb(12, 20, 27),
Color.rgb(3, 7, 10),
),
)
}
val inflater = LayoutInflater.from(activity)
val timing = inflater.inflate(R.layout.player_time_remaining, root, false).apply {
visibility = View.VISIBLE
findViewById<TextView>(R.id.player_time_remaining_label).text = "FINISHES IN"
findViewById<TextView>(R.id.player_time_remaining_value).text = "52 mins (9:54 PM)"
}
val finale = inflater.inflate(R.layout.player_season_finale, root, false).apply {
visibility = View.VISIBLE
findViewById<TextView>(R.id.player_season_finale_value).text =
"Signal Hill · Season 2 finale"
}
root.addView(timing)
root.addView(finale)
activity.setContentView(root)
root.captureRoboImage("build/screenshots/player-season-finale-lower-third.png")
}
}

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