Memby v0.1.53: Android TV client plus gateway

Android TV client for Emby (Kotlin, Compose for TV) and the Memby gateway
(Go, Postgres, Redis) that fronts it.

Client:
- Setup, profiles, home rows, Media3 playback, system screensaver (Dream)
- Backend chosen at build time: gateway when memby.gatewayUrl is set,
  otherwise direct to Emby. Both paths stay working.
- Server-composed home rows, rendered verbatim so new row types ship
  without an app release
- Full-screen animated maintenance state, row engagement telemetry

Gateway:
- One request per TV screen; auth, caching, search and row shaping
- Library import from Emby into Postgres (manual, then hourly incremental)
- Recommendations from viewing history (recency-weighted genre affinity)
- Admin page for imports, an offline switch, and per-row analytics
- Video always direct-plays from Emby; only metadata passes through

Identity is com.ponzischeme89.memby throughout, replacing
com.mattcohen.embyclientsname. A changed applicationId installs as a new
app: TVs need a fresh sign-in and the old package uninstalled.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
ponzischeme89
2026-07-27 08:16:20 +12:00
co-authored by Claude Opus 5
commit 2ce405c540
99 changed files with 14433 additions and 0 deletions
+96
View File
@@ -0,0 +1,96 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<!-- Needed to hand a downloaded APK to the system installer (in-app updates). -->
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<!-- This is a TV app: no touchscreen, uses the Leanback launcher. -->
<uses-feature
android:name="android.hardware.touchscreen"
android:required="false" />
<uses-feature
android:name="android.software.leanback"
android:required="true" />
<application
android:name=".MembyApp"
android:allowBackup="true"
android:banner="@drawable/app_banner"
android:icon="@drawable/app_banner"
android:label="@string/app_name"
android:supportsRtl="true"
android:usesCleartextTraffic="true"
android:theme="@style/Theme.Memby">
<profileable android:shell="true" tools:targetApi="q" />
<!-- Home / setup screen. Registered on the TV (Leanback) launcher. -->
<activity
android:name=".ui.MainActivity"
android:exported="true"
android:screenOrientation="landscape"
android:configChanges="keyboard|keyboardHidden|navigation|screenSize|smallestScreenSize|screenLayout|orientation|uiMode">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
</intent-filter>
</activity>
<!-- In-app preview of the screensaver (same UI the Daydream uses). -->
<activity
android:name=".ui.screensaver.ScreensaverActivity"
android:exported="false"
android:screenOrientation="landscape"
android:theme="@style/Theme.Memby.Fullscreen" />
<!-- An APK replacement kills an active Dream process. Reopen our launcher so
the TV is never left displaying the old, black Dream surface. -->
<receiver
android:name=".update.UpdateRecoveryReceiver"
android:exported="false">
<intent-filter>
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
</intent-filter>
</receiver>
<!-- Fullscreen Media3 player. -->
<activity
android:name=".ui.player.PlayerActivity"
android:exported="false"
android:screenOrientation="landscape"
android:launchMode="singleTask"
android:configChanges="keyboard|keyboardHidden|navigation|screenSize|orientation"
android:theme="@style/Theme.Memby.Fullscreen" />
<!-- The system screensaver (Daydream / Ambient mode source).
Interactive: select to open the panel, play, or favourite. -->
<service
android:name=".screensaver.MembyDreamService"
android:exported="true"
android:icon="@drawable/app_banner"
android:label="@string/screensaver_name"
android:permission="android.permission.BIND_DREAM_SERVICE">
<intent-filter>
<action android:name="android.service.dreams.DreamService" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
<meta-data
android:name="android.service.dream"
android:resource="@xml/emby_dream" />
</service>
<!-- Serves the downloaded update APK to the system package installer. -->
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application>
</manifest>
@@ -0,0 +1,34 @@
package com.ponzischeme89.memby
import android.app.Application
import coil.Coil
import coil.ImageLoader
import coil.disk.DiskCache
import coil.memory.MemoryCache
class MembyApp : Application() {
override fun onCreate() {
super.onCreate()
Coil.setImageLoader(
ImageLoader.Builder(this)
.memoryCache {
MemoryCache.Builder(this)
.maxSizePercent(0.08)
.build()
}
.diskCache {
DiskCache.Builder()
.directory(cacheDir.resolve("media_artwork"))
.maxSizeBytes(128L * 1024L * 1024L)
.build()
}
// Emby artwork URLs include an image tag, so changed artwork gets a new
// cache key. Keep tagged thumbnails available even when a server sends
// conservative cache headers.
.respectCacheHeaders(false)
.crossfade(false)
.build(),
)
ServiceLocator.init(this)
}
}
@@ -0,0 +1,23 @@
package com.ponzischeme89.memby
import android.content.Context
import com.ponzischeme89.memby.data.EmbyRepository
import com.ponzischeme89.memby.data.SettingsStore
/**
* Tiny manual dependency container. Initialised once from [MembyApp] so that the
* DreamService, activities and composables can all share a single repository /
* settings instance without pulling in a DI framework.
*/
object ServiceLocator {
lateinit var settings: SettingsStore
private set
lateinit var repository: EmbyRepository
private set
fun init(context: Context) {
if (::repository.isInitialized) return
settings = SettingsStore(context.applicationContext)
repository = EmbyRepository(settings)
}
}
@@ -0,0 +1,678 @@
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.GatewayLoginRequest
import com.ponzischeme89.memby.data.model.GatewayPlaybackReport
import com.ponzischeme89.memby.data.model.GatewayRowEvent
import com.ponzischeme89.memby.data.model.GatewayRowEvents
import com.ponzischeme89.memby.data.model.HomeRow
import com.ponzischeme89.memby.data.model.PlaybackReport
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 kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.launch
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import retrofit2.HttpException
import java.io.IOException
import java.net.URLEncoder
/**
* One batch home response. [partial] means at least one row failed upstream and the rest
* is still worth rendering.
*/
data class HomeSnapshot(
/** Rows exactly as the server composed them, in display order. */
val rows: List<HomeRow> = emptyList(),
val continueWatching: List<BaseItem> = emptyList(),
val nextUp: List<BaseItem> = emptyList(),
val favorites: List<BaseItem> = emptyList(),
val latestMovies: List<BaseItem> = emptyList(),
val partial: Boolean = false,
)
/** A resolved, directly playable stream. */
data class Playable(
val itemId: String,
val title: String,
val url: String,
val resumePositionMs: Long = 0L,
)
class EmbyRepository(private val settings: SettingsStore) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
@Volatile
private var snapshot: Settings = Settings.EMPTY
init {
scope.launch { settings.settingsFlow.collect { snapshot = it } }
}
val settingsFlow: Flow<Settings> get() = settings.settingsFlow
private val _playbackStops = MutableSharedFlow<String>(extraBufferCapacity = 1)
val playbackStops = _playbackStops.asSharedFlow()
fun cachedHome(): HomeCache? = settings.homeCache(snapshot)
suspend fun cacheHome(content: HomeCache) = settings.setHomeCache(content)
// --- API instance caching (rebuilt only when the server URL changes) -----
private var cachedApi: EmbyApi? = null
private var cachedBaseUrl: String? = null
/**
* The Memby gateway, when this build has one. Its address is fixed at build time, so
* unlike the Emby client this never needs rebuilding.
*/
private val gatewayApi: GatewayApi? by lazy {
ServerConfig.gatewayUrl?.let { url ->
GatewayServiceFactory.create(url) { snapshot.token }
}
}
private fun requireGateway(): GatewayApi = gatewayApi ?: error("No Memby gateway configured")
/** True when the backend can return the whole home screen in one request. */
val supportsBatchHome: Boolean get() = ServerConfig.isGateway
private fun apiFor(serverUrl: String): EmbyApi {
val base = normalizeServerUrl(serverUrl)
cachedApi?.let { if (cachedBaseUrl == base) return it }
val api = EmbyServiceFactory.create(
baseUrl = base,
deviceIdProvider = { snapshot.deviceId.ifEmpty { "memby" } },
tokenProvider = { snapshot.token },
)
cachedApi = api
cachedBaseUrl = base
return api
}
/**
* The server all requests and media URLs point at. A build that hardwires an address
* (see [ServerConfig]) always wins, so repointing every install is a property change
* plus a reinstall — no user action, and no stale address left in a saved session.
*/
private val activeServerUrl: String? get() = ServerConfig.hardwiredUrl ?: snapshot.serverUrl
private fun requireApi(): EmbyApi {
val url = activeServerUrl ?: error("Not connected to a server")
return apiFor(url)
}
// --- Authentication ------------------------------------------------------
/**
* Signs in. [serverUrl] is only consulted when the build does not hardwire one —
* with a hardwired address the setup screen never collects it.
*/
suspend fun authenticate(serverUrl: String, username: String, password: String) {
settings.ensureDeviceId()
snapshot = 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 = requireGateway().login(
GatewayLoginRequest(
username = username,
password = password,
deviceId = snapshot.deviceId.ifEmpty { "memby" },
),
)
require(result.token.isNotBlank() && result.userId.isNotBlank()) {
"Gateway did not return a session"
}
settings.saveSession(
gateway,
result.token,
result.userId,
result.username.ifBlank { username },
result.serverId.takeIf { it.isNotBlank() },
)
snapshot = settings.snapshot()
return
}
val base = resolveServerUrl(ServerConfig.hardwiredUrl, serverUrl)
?: error("No Emby server address configured")
val api = apiFor(base)
val result = api.authenticate(AuthRequest(username = username, pw = password))
val token = result.accessToken
val userId = result.user?.id
require(!token.isNullOrBlank() && !userId.isNullOrBlank()) {
"Server did not return an access token"
}
settings.saveSession(base, token, userId, username, result.serverId)
snapshot = settings.snapshot()
}
suspend fun signOut() {
// Retire the gateway token server-side too, so a lost TV can't keep reading the
// library. A failure here must not block the local sign-out.
if (ServerConfig.isGateway && !snapshot.token.isNullOrBlank()) {
runCatching { requireGateway().logout() }
}
settings.clearSession()
snapshot = settings.snapshot()
cachedApi = null
cachedBaseUrl = null
}
suspend fun switchProfile(profile: EmbyProfile) {
settings.switchProfile(profile)
snapshot = settings.snapshot()
cachedApi = null
cachedBaseUrl = null
}
// --- Content -------------------------------------------------------------
/**
* The whole home screen in one request. Only available against a gateway — check
* [supportsBatchHome] first.
*/
suspend fun getHome(limit: Int = 24): HomeSnapshot {
val home = requireGateway().home(limit)
return HomeSnapshot(
rows = home.rows,
continueWatching = home.continueWatching,
nextUp = home.nextUp,
favorites = home.favorites,
latestMovies = home.latestMovies,
partial = home.partial,
)
}
/** A shuffled set of movies & shows that actually have a backdrop image. */
suspend fun getScreensaverItems(limit: Int = 200): List<BaseItem> {
if (ServerConfig.isGateway) {
return requireGateway().screensaver(limit).items.filter { hasBackdrop(it) }
}
val userId = snapshot.userId ?: error("Not connected")
val result = requireApi().getItems(
userId,
mapOf(
"IncludeItemTypes" to "Movie,Series",
"Recursive" to "true",
"SortBy" to "Random",
"Limit" to limit.toString(),
"Fields" to "Overview,Taglines,Genres,ProductionYear,CommunityRating,Studios,OfficialRating,RunTimeTicks",
"ImageTypeLimit" to "1",
"EnableImageTypes" to "Backdrop,Logo",
"EnableUserData" to "true",
),
)
return result.items.filter { hasBackdrop(it) }
}
/**
* A deliberately tiny, movie-only request used during a cold start. It gives the
* UI a genuine Emby backdrop while the larger mixed library queue is still loading.
*/
suspend fun getStartupBackdropMovie(): BaseItem? {
if (ServerConfig.isGateway) {
// The gateway keeps a warm, cached backdrop pool, so the "tiny first query"
// trick the direct path needs is unnecessary here.
return requireGateway().screensaver(limit = 1).items.firstOrNull { hasBackdrop(it) }
}
val userId = snapshot.userId ?: error("Not connected")
return requireApi().getItems(
userId,
mapOf(
"IncludeItemTypes" to "Movie",
"Recursive" to "true",
"Filters" to "HasBackdrop",
"SortBy" to "Random",
"Limit" to "1",
"Fields" to "Overview,Taglines,Genres,ProductionYear,CommunityRating,Studios,RunTimeTicks",
"ImageTypeLimit" to "1",
"EnableImageTypes" to "Backdrop,Logo",
"EnableUserData" to "true",
),
).items.firstOrNull { hasBackdrop(it) }
}
// Against a gateway the per-row getters just slice the batch response, which Redis
// has already answered once for this user. They exist so the direct-to-Emby path and
// any caller that wants a single row keep working unchanged.
/** First home-page slice of favorited movies & shows. */
suspend fun getFavorites(limit: Int = 24): List<BaseItem> {
if (ServerConfig.isGateway) return getHome(limit).favorites
return getHomeItems(
params = mapOf(
"Filters" to "IsFavorite",
"IncludeItemTypes" to "Movie,Series",
"Recursive" to "true",
"SortBy" to "SortName",
"SortOrder" to "Ascending",
"Limit" to limit.toString(),
),
fields = "ProductionYear,RunTimeTicks,PrimaryImageAspectRatio",
imageTypes = "Backdrop,Primary,Logo",
includeUserData = true,
)
}
/** Unfinished movies and episodes for the current Emby user. */
suspend fun getContinueWatching(limit: Int = 24): List<BaseItem> {
if (ServerConfig.isGateway) return getHome(limit).continueWatching
return getHomeItems(
params = mapOf(
"Filters" to "IsResumable",
"IncludeItemTypes" to "Movie,Episode",
"Recursive" to "true",
"SortBy" to "DatePlayed",
"SortOrder" to "Descending",
"Limit" to limit.toString(),
),
fields = "RunTimeTicks,SeriesName,PrimaryImageAspectRatio",
imageTypes = "Backdrop,Primary,Logo",
includeUserData = true,
)
}
/** Episodes the server recommends playing next, excluding resumable duplicates in the UI. */
suspend fun getNextUp(limit: Int = 24): List<BaseItem> {
if (ServerConfig.isGateway) return getHome(limit).nextUp
val userId = snapshot.userId ?: error("Not connected")
return requireApi().getNextUp(
mapOf(
"UserId" to userId,
"Limit" to limit.toString(),
"Fields" to "Overview,ProductionYear,RunTimeTicks,SeriesName,PrimaryImageAspectRatio",
"ImageTypeLimit" to "1",
"EnableImageTypes" to "Backdrop,Primary,Logo",
"EnableTotalRecordCount" to "false",
"EnableUserData" to "true",
),
).items
}
/** Recently added films, used as a compact discovery row on the client home. */
suspend fun getLatestMovies(limit: Int = 24): List<BaseItem> {
if (ServerConfig.isGateway) return getHome(limit).latestMovies
return getHomeItems(
params = mapOf(
"IncludeItemTypes" to "Movie",
"Recursive" to "true",
"SortBy" to "DateCreated",
"SortOrder" to "Descending",
"Limit" to limit.toString(),
),
fields = "ProductionYear,RunTimeTicks,PrimaryImageAspectRatio",
imageTypes = "Backdrop,Primary,Logo",
includeUserData = true,
)
}
/** Library-wide search. Gateway-only: the direct path has no search UI behind it. */
suspend fun search(term: String, limit: Int = 40): List<BaseItem> =
requireGateway().search(term, limit).items
/**
* 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
* needed to pull them in without a full home refresh.
*/
suspend fun getRecommendations(): List<HomeRow> = requireGateway().recommendations().rows
/** Full item metadata, requested only after focus settles on an item. */
suspend fun getItemDetails(itemId: String): BaseItem {
if (ServerConfig.isGateway) return requireGateway().item(itemId)
val userId = snapshot.userId ?: error("Not connected")
return requireApi().getItem(
userId = userId,
itemId = itemId,
fields = "Overview,Genres,MediaStreams,ProductionYear,OfficialRating,CommunityRating,RunTimeTicks,PrimaryImageAspectRatio",
)
}
/** Tight list endpoint shape: detail-only fields are never fetched on home. */
private suspend fun getHomeItems(
params: Map<String, String>,
fields: String,
imageTypes: String,
includeUserData: Boolean = false,
): List<BaseItem> {
val userId = snapshot.userId ?: error("Not connected")
return requireApi().getItems(userId, params + mapOf(
"Fields" to fields,
"ImageTypeLimit" to "1",
"EnableImages" to "true",
"EnableImageTypes" to imageTypes,
"EnableTotalRecordCount" to "false",
"EnableUserData" to includeUserData.toString(),
)).items
}
/** Toggles favorite state, returning the new value. */
suspend fun toggleFavorite(item: BaseItem): Boolean =
setFavorite(item.id, !item.isFavorite)
/**
* Sets favorite state to an explicit [favorite] value and returns the server's
* resulting state. Safer than [toggleFavorite] for optimistic UI, which may
* flip faster than the source item's cached [BaseItem.isFavorite] updates.
*/
suspend fun setFavorite(itemId: String, favorite: Boolean): Boolean {
if (ServerConfig.isGateway) {
return requireGateway().setFavorite(itemId, GatewayFlagRequest(favorite)).isFavorite
}
val userId = snapshot.userId ?: error("Not connected")
val api = requireApi()
val result = if (favorite) {
api.addFavorite(userId, itemId)
} else {
api.removeFavorite(userId, itemId)
}
return result.isFavorite
}
/** Sets watched state explicitly and returns the value confirmed by Emby. */
suspend fun setPlayed(itemId: String, played: Boolean): Boolean {
if (ServerConfig.isGateway) {
return requireGateway().setPlayed(itemId, GatewayFlagRequest(played)).played
}
val userId = snapshot.userId ?: error("Not connected")
val api = requireApi()
val result = if (played) {
api.markPlayed(userId, itemId)
} else {
api.markUnplayed(userId, itemId)
}
return result.played
}
/** Returns Emby's first local trailer for an item, when one is available. */
suspend fun getLocalTrailer(itemId: String): BaseItem? {
if (ServerConfig.isGateway) {
// The gateway answers 404 when an item has no trailer, which is a normal
// outcome here rather than an error worth surfacing.
return runCatching { requireGateway().trailer(itemId) }
.getOrElse { if (it is HttpException && it.code() == 404) null else throw it }
}
val userId = snapshot.userId ?: error("Not connected")
return requireApi().getLocalTrailers(userId, itemId).items.firstOrNull()
}
/**
* Uploads a batch of row-engagement events. Silent on the direct path (nothing is
* listening) and silent on failure — telemetry must never surface on a TV.
*/
fun reportRowEvents(events: List<GatewayRowEvent>) {
if (!ServerConfig.isGateway || events.isEmpty() || snapshot.token.isNullOrBlank()) return
scope.launch {
runCatching { requireGateway().reportRowEvents(GatewayRowEvents(events)) }
}
}
/** Backdrop rotation interval, clamped to a sane range. */
fun rotationIntervalMillis(): Long =
snapshot.rotationIntervalSeconds.coerceIn(4, 600).toLong() * 1000L
/** Whether a usable session is currently persisted. */
fun isSignedIn(): Boolean = snapshot.isSignedIn
/**
* Resolves an item to something ExoPlayer can stream. Movies play directly;
* for a series we play the next-up episode (falling back to the first one).
*/
suspend fun resolvePlayable(item: BaseItem): Playable {
if (ServerConfig.isGateway) {
// Episode selection for a series is the gateway's job now.
val playback = requireGateway().playback(item.id)
return Playable(
itemId = playback.itemId,
title = playback.title.ifBlank { item.name },
url = playback.url,
resumePositionMs = playback.resumePositionMs,
)
}
if (item.isSeries) {
val userId = snapshot.userId ?: error("Not connected")
val episode = firstNextUpEpisode(userId, item.id) ?: firstEpisode(userId, item.id)
requireNotNull(episode) { "No episodes found for ${item.name}" }
val title = buildString {
append(item.name)
episode.name.takeIf { it.isNotBlank() }?.let { append(" $it") }
}
return Playable(episode.id, title, buildStreamUrl(episode.id), episode.resumePositionMs)
}
return Playable(item.id, item.name, buildStreamUrl(item.id), item.resumePositionMs)
}
suspend fun reportPlaybackStarted(itemId: String, positionMs: Long) {
if (ServerConfig.isGateway) {
requireGateway().report("started", GatewayPlaybackReport(itemId, positionMs))
return
}
requireApi().reportPlaybackStarted(playbackReport(itemId, positionMs, isPaused = false))
}
suspend fun reportPlaybackProgress(itemId: String, positionMs: Long, isPaused: Boolean) {
if (ServerConfig.isGateway) {
requireGateway().report("progress", GatewayPlaybackReport(itemId, positionMs, isPaused))
return
}
requireApi().reportPlaybackProgress(playbackReport(itemId, positionMs, isPaused))
}
suspend fun reportPlaybackStopped(itemId: String, positionMs: Long) {
try {
if (ServerConfig.isGateway) {
// Stopping is also what drops the gateway's cached rows for this user,
// so Continue Watching reflects the new position on the next home load.
requireGateway().report("stopped", GatewayPlaybackReport(itemId, positionMs, isPaused = true))
} else {
requireApi().reportPlaybackStopped(playbackReport(itemId, positionMs, isPaused = true))
}
} finally {
_playbackStops.tryEmit(itemId)
}
}
fun enqueuePlaybackStopped(itemId: String, positionMs: Long) {
scope.launch {
runCatching { reportPlaybackStopped(itemId, positionMs) }
}
}
private suspend fun firstNextUpEpisode(userId: String, seriesId: String): BaseItem? =
runCatching {
requireApi().getNextUp(
mapOf(
"UserId" to userId,
"SeriesId" to seriesId,
"Limit" to "1",
"Fields" to "RunTimeTicks",
"EnableUserData" to "true",
),
).items.firstOrNull()
}.getOrNull()
private suspend fun firstEpisode(userId: String, seriesId: String): BaseItem? =
runCatching {
requireApi().getEpisodes(
seriesId,
mapOf(
"UserId" to userId,
"Limit" to "1",
"Fields" to "RunTimeTicks",
"EnableUserData" to "true",
),
).items.firstOrNull()
}.getOrNull()
// --- URL helpers ---------------------------------------------------------
/** Backdrop image URL for an item, or null if it has none. */
fun backdropUrl(item: BaseItem, maxWidth: Int = 1920): String? {
val (id, tag) = when {
item.backdropImageTags.isNotEmpty() -> item.id to item.backdropImageTags.first()
item.parentBackdropItemId != null && item.parentBackdropImageTags.isNotEmpty() ->
item.parentBackdropItemId to item.parentBackdropImageTags.first()
else -> return null
}
return imageUrl(id, "Backdrop", tag, maxWidth, directIndex = true)
}
/**
* The item's "Logo" image (the stylised title treatment) from Emby metadata,
* or null when the item has no logo. Used to show artwork in place of the
* plain-text title in the screensaver.
*/
fun logoUrl(item: BaseItem, maxWidth: Int = 800): String? {
val (id, tag) = when {
item.imageTags["Logo"] != null -> item.id to item.imageTags.getValue("Logo")
item.parentLogoItemId != null && item.parentLogoImageTag != null ->
item.parentLogoItemId to item.parentLogoImageTag
else -> return null
}
return imageUrl(id, "Logo", tag, maxWidth)
}
/** Primary (poster) image URL, used as a card fallback. */
fun primaryUrl(item: BaseItem, maxWidth: Int = 500): String? {
val tag = item.imageTags["Primary"] ?: return null
return imageUrl(item.id, "Primary", tag, maxWidth)
}
/**
* Builds an artwork URL for whichever backend this build uses.
*
* Coil fetches these as plain URLs with no interceptor attached, so the credential
* has to travel in the query string either way: `api_key` for Emby, `t` for the
* gateway. The gateway form is preferable — that token is revocable and grants
* nothing but Memby's own API.
*/
private fun imageUrl(
itemId: String,
imageType: String,
tag: String,
maxWidth: Int,
directIndex: Boolean = false,
): String? {
val token = snapshot.token
ServerConfig.gatewayUrl?.let { gateway ->
if (token.isNullOrBlank()) return null
return buildString {
append(gateway.trimEnd('/'))
append("/v1/images/").append(itemId).append('/').append(imageType.lowercase())
append("?maxWidth=").append(maxWidth)
append("&quality=90")
append("&tag=").append(encode(tag))
append("&t=").append(encode(token))
}
}
val base = activeServerUrl ?: return null
return buildString {
append(base.trimEnd('/'))
append("/Items/").append(itemId).append("/Images/").append(imageType)
if (directIndex) append("/0")
append("?maxWidth=").append(maxWidth)
append("&quality=90")
append("&tag=").append(encode(tag))
token?.let { append("&api_key=").append(encode(it)) }
}
}
fun hasBackdrop(item: BaseItem): Boolean =
item.backdropImageTags.isNotEmpty() ||
(item.parentBackdropItemId != null && item.parentBackdropImageTags.isNotEmpty())
private fun buildStreamUrl(itemId: String): String {
val base = activeServerUrl?.trimEnd('/') ?: error("Not connected")
val token = snapshot.token.orEmpty()
val deviceId = snapshot.deviceId.ifEmpty { "memby" }
return "$base/Videos/$itemId/stream" +
"?static=true" +
"&api_key=${encode(token)}" +
"&DeviceId=${encode(deviceId)}"
}
private fun playbackReport(itemId: String, positionMs: Long, isPaused: Boolean) =
PlaybackReport(
itemId = itemId,
positionTicks = millisecondsToTicks(positionMs),
isPaused = isPaused,
)
private fun encode(value: String): String = URLEncoder.encode(value, "UTF-8")
}
internal fun millisecondsToTicks(milliseconds: Long): Long =
milliseconds.coerceAtLeast(0L) * 10_000L
private val BaseItem.resumePositionMs: Long
get() = ((userData?.playbackPositionTicks ?: 0L) / 10_000L).coerceAtLeast(0L)
/**
* Maps an exception to a short, TV-readable message. Never surfaces raw HTTP
* bodies or stack traces (which could contain tokens) to the screen.
*/
fun friendlyEmbyError(t: Throwable): String = when (t) {
is IOException -> "Can't reach the Emby server. Check your network."
is HttpException -> when (t.code()) {
401 -> "Session expired. Open Memby to sign in again."
403 -> "Access denied by the server."
404 -> "Not found on the server."
// The gateway answers 503 when an operator has deliberately taken Memby down.
// Showing their message beats a generic "server problem" the viewer can do
// nothing about.
503 -> maintenanceMessage(t) ?: "Memby is unavailable right now. Try again shortly."
in 500..599 -> "The Emby server had a problem. Try again."
else -> "Server error (${t.code()})."
}
else -> "Something went wrong. Try again."
}
private fun maintenanceMessage(t: HttpException): String? = runCatching {
parseMaintenanceMessage(t.response()?.errorBody()?.string().orEmpty())
}.getOrNull()
/**
* Reads the operator's message out of a maintenance response, or null when this 503 is
* something else. Never surfaces a raw body: only the known `message` field is trusted,
* and only up to a length that fits on a TV.
*/
internal fun parseMaintenanceMessage(body: String): String? {
if (body.isBlank()) return null
return runCatching {
val parsed = Json { ignoreUnknownKeys = true }
.decodeFromString<MaintenanceResponse>(body)
parsed.message?.trim()?.takeIf { parsed.maintenance && it.isNotBlank() }?.take(160)
}.getOrNull()
}
/** True when this failure is the gateway reporting a deliberate outage. */
fun isMaintenanceError(t: Throwable): Boolean = t is HttpException && t.code() == 503
@Serializable
private data class MaintenanceResponse(
val maintenance: Boolean = false,
val message: String? = null,
)
/** Ensures a scheme and strips a trailing slash for consistent base handling. */
fun normalizeServerUrl(raw: String): String {
var url = raw.trim()
if (!url.startsWith("http://", true) && !url.startsWith("https://", true)) {
url = "http://$url"
}
return url.trimEnd('/')
}
@@ -0,0 +1,52 @@
package com.ponzischeme89.memby.data
import com.ponzischeme89.memby.BuildConfig
/**
* Where this build gets its data.
*
* Two addresses are baked in at build time (see gradle.properties):
*
* - `memby.gatewayUrl` — the Memby gateway container. When set, the client is a thin
* renderer: the gateway owns auth, caching, search and screen shaping.
* - `memby.serverUrl` — the Emby server, used directly when no gateway is configured.
* Keeping this path alive means a gateway outage is a config change away from being
* routed around, and it is what the app falls back to during the migration.
*
* Neither is typed on a TV remote; both are properties of the build.
*/
object ServerConfig {
/** The Memby gateway, normalised, or null when this build talks to Emby directly. */
val gatewayUrl: String? =
BuildConfig.MEMBY_GATEWAY_URL.trim()
.takeIf { it.isNotBlank() }
?.let(::normalizeServerUrl)
/** The hardwired Emby server, normalised, or null when this build doesn't pin one. */
val hardwiredUrl: String? =
BuildConfig.EMBY_SERVER_URL.trim()
.takeIf { it.isNotBlank() }
?.let(::normalizeServerUrl)
val isGateway: Boolean get() = gatewayUrl != null
val isHardwired: Boolean get() = hardwiredUrl != null
/** The address this build actually signs in against. */
val backendUrl: String? get() = gatewayUrl ?: hardwiredUrl
/** Host (and port) of that address, for display on the setup screen. */
val displayHost: String?
get() = backendUrl?.substringAfter("://")?.trimEnd('/')
}
/**
* Resolves the server to use: the hardwired address when this build pins one, otherwise
* whatever the user typed. Returns null when neither is available.
*/
fun resolveServerUrl(hardwired: String?, entered: String): String? = when {
!hardwired.isNullOrBlank() -> normalizeServerUrl(hardwired)
entered.isNotBlank() -> normalizeServerUrl(entered)
else -> null
}
@@ -0,0 +1,308 @@
package com.ponzischeme89.memby.data
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
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.preferencesDataStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.serialization.Serializable
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import java.util.UUID
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "emby_settings")
/** Persisted connection state. */
data class Settings(
val serverUrl: String? = null,
val token: String? = null,
val userId: String? = null,
val serverId: String? = null,
val username: String? = null,
val deviceId: String = "",
val rotationIntervalSeconds: Int = DEFAULT_ROTATION_SECONDS,
// Where "Check for updates" looks: a Gitea host + "owner/repo", plus an access
// token for the (private) repo's release API and asset downloads.
val updateBaseUrl: String? = null,
val updateRepo: String? = null,
val updateToken: String? = null,
// Show each item's Emby "Logo" image in place of the plain-text title.
val showTitleLogo: Boolean = true,
// Foreground colour of the slide-progress ring, as an RRGGBB hex string.
val ringColorHex: String = DEFAULT_RING_COLOR,
val lastBackdropUrl: String? = null,
/** Comma-separated, user-controlled order of rows shown on the client home. */
val homeSections: String = DEFAULT_HOME_SECTIONS,
val homeCacheJson: String? = null,
val homeCardDensity: String = DEFAULT_HOME_CARD_DENSITY,
val showHomeCardMetadata: Boolean = true,
val profiles: List<EmbyProfile> = emptyList(),
) {
val isSignedIn: Boolean
get() = !serverUrl.isNullOrBlank() && !token.isNullOrBlank() && !userId.isNullOrBlank()
val activeProfileId: String?
get() = profiles.firstOrNull {
it.userId == userId && it.serverUrl == serverUrl
}?.id
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"
val EMPTY = Settings()
}
}
@Serializable
data class EmbyProfile(
val id: String,
val serverUrl: String,
val token: String,
val userId: String,
val username: String,
val serverId: String? = null,
val homeCacheJson: String? = null,
)
class SettingsStore(private val context: Context) {
private object Keys {
val SERVER_URL = stringPreferencesKey("server_url")
val TOKEN = stringPreferencesKey("token")
val USER_ID = stringPreferencesKey("user_id")
val SERVER_ID = stringPreferencesKey("server_id")
val USERNAME = stringPreferencesKey("username")
val DEVICE_ID = stringPreferencesKey("device_id")
val ROTATION_SECONDS = intPreferencesKey("rotation_interval_seconds")
val UPDATE_BASE_URL = stringPreferencesKey("update_base_url")
val UPDATE_REPO = stringPreferencesKey("update_repo")
val UPDATE_TOKEN = stringPreferencesKey("update_token")
val SHOW_TITLE_LOGO = booleanPreferencesKey("show_title_logo")
val RING_COLOR = stringPreferencesKey("ring_color")
val LAST_BACKDROP_URL = stringPreferencesKey("last_backdrop_url")
val HOME_SECTIONS = stringPreferencesKey("home_sections")
val HOME_CACHE = stringPreferencesKey("home_cache")
val HOME_CARD_DENSITY = stringPreferencesKey("home_card_density")
val SHOW_HOME_CARD_METADATA = booleanPreferencesKey("show_home_card_metadata")
val PROFILES = stringPreferencesKey("profiles")
}
val settingsFlow: Flow<Settings> = context.dataStore.data.map { p ->
val storedProfiles = decodeProfiles(p[Keys.PROFILES])
val profiles = if (storedProfiles.isEmpty()) {
legacyProfile(p)?.let(::listOf).orEmpty()
} else {
storedProfiles
}
Settings(
serverUrl = p[Keys.SERVER_URL],
token = p[Keys.TOKEN],
userId = p[Keys.USER_ID],
serverId = p[Keys.SERVER_ID],
username = p[Keys.USERNAME],
deviceId = p[Keys.DEVICE_ID].orEmpty(),
rotationIntervalSeconds = p[Keys.ROTATION_SECONDS] ?: Settings.DEFAULT_ROTATION_SECONDS,
updateBaseUrl = p[Keys.UPDATE_BASE_URL],
updateRepo = p[Keys.UPDATE_REPO],
updateToken = p[Keys.UPDATE_TOKEN],
showTitleLogo = p[Keys.SHOW_TITLE_LOGO] ?: true,
ringColorHex = p[Keys.RING_COLOR] ?: Settings.DEFAULT_RING_COLOR,
lastBackdropUrl = p[Keys.LAST_BACKDROP_URL],
homeSections = p[Keys.HOME_SECTIONS] ?: Settings.DEFAULT_HOME_SECTIONS,
homeCacheJson = p[Keys.HOME_CACHE],
homeCardDensity = p[Keys.HOME_CARD_DENSITY] ?: Settings.DEFAULT_HOME_CARD_DENSITY,
showHomeCardMetadata = p[Keys.SHOW_HOME_CARD_METADATA] ?: true,
profiles = profiles,
)
}
suspend fun setRotationIntervalSeconds(seconds: Int) {
context.dataStore.edit { it[Keys.ROTATION_SECONDS] = seconds }
}
/** Persists the Gitea update source. Blank values are cleared. */
suspend fun setUpdateConfig(baseUrl: String, repo: String, token: String) {
context.dataStore.edit {
fun put(key: Preferences.Key<String>, value: String) {
val v = value.trim()
if (v.isEmpty()) it.remove(key) else it[key] = v
}
put(Keys.UPDATE_BASE_URL, baseUrl)
put(Keys.UPDATE_REPO, repo)
put(Keys.UPDATE_TOKEN, token)
}
}
suspend fun setShowTitleLogo(enabled: Boolean) {
context.dataStore.edit { it[Keys.SHOW_TITLE_LOGO] = enabled }
}
suspend fun setRingColor(hex: String) {
context.dataStore.edit { it[Keys.RING_COLOR] = hex }
}
suspend fun setLastBackdropUrl(url: String) {
context.dataStore.edit { it[Keys.LAST_BACKDROP_URL] = url }
}
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(",")
}
}
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
}
}
suspend fun setShowHomeCardMetadata(show: Boolean) {
context.dataStore.edit { it[Keys.SHOW_HOME_CARD_METADATA] = show }
}
suspend fun setHomeCache(cache: HomeCache) {
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)
}
}
}
fun homeCache(settings: Settings): HomeCache? = settings.homeCacheJson?.let {
runCatching { Json.decodeFromString<HomeCache>(it) }.getOrNull()
}
/** Reads a one-shot snapshot of the current settings. */
suspend fun snapshot(): Settings = settingsFlow.first()
/** Returns the stable device id, generating and persisting one on first use. */
suspend fun ensureDeviceId(): String {
val existing = context.dataStore.data.first()[Keys.DEVICE_ID]
if (!existing.isNullOrBlank()) return existing
val generated = UUID.randomUUID().toString()
context.dataStore.edit { it[Keys.DEVICE_ID] = generated }
return generated
}
suspend fun saveSession(serverUrl: String, token: String, userId: String, username: String, serverId: String?) {
context.dataStore.edit { preferences ->
val profiles = profilesFrom(preferences).toMutableList()
val id = profileId(serverUrl, userId)
val previous = profiles.firstOrNull { it.id == id }
val profile = EmbyProfile(
id = id,
serverUrl = serverUrl,
token = token,
userId = userId,
username = username,
serverId = serverId,
homeCacheJson = previous?.homeCacheJson,
)
profiles.removeAll { it.id == id }
profiles.add(profile)
preferences[Keys.PROFILES] = Json.encodeToString(profiles)
applyProfile(preferences, profile)
}
}
suspend fun switchProfile(profile: EmbyProfile) {
context.dataStore.edit { preferences ->
val profiles = profilesFrom(preferences).toMutableList()
if (profiles.none { it.id == profile.id }) {
profiles.add(profile)
preferences[Keys.PROFILES] = Json.encodeToString(profiles)
}
applyProfile(preferences, profile)
}
}
suspend fun clearSession() {
context.dataStore.edit {
it.remove(Keys.SERVER_URL)
it.remove(Keys.TOKEN)
it.remove(Keys.USER_ID)
it.remove(Keys.SERVER_ID)
it.remove(Keys.LAST_BACKDROP_URL)
it.remove(Keys.HOME_CACHE)
it.remove(Keys.USERNAME)
// Intentionally keep DEVICE_ID stable across sign-outs.
}
}
private fun applyProfile(preferences: MutablePreferences, profile: EmbyProfile) {
preferences[Keys.SERVER_URL] = profile.serverUrl
preferences[Keys.TOKEN] = profile.token
preferences[Keys.USER_ID] = profile.userId
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
preferences.remove(Keys.LAST_BACKDROP_URL)
}
private fun legacyProfile(preferences: Preferences): EmbyProfile? {
val serverUrl = preferences[Keys.SERVER_URL] ?: return null
val token = preferences[Keys.TOKEN] ?: return null
val userId = preferences[Keys.USER_ID] ?: return null
val username = preferences[Keys.USERNAME] ?: return null
return EmbyProfile(
id = profileId(serverUrl, userId),
serverUrl = serverUrl,
token = token,
userId = userId,
username = username,
serverId = preferences[Keys.SERVER_ID],
homeCacheJson = preferences[Keys.HOME_CACHE],
)
}
private fun decodeProfiles(value: String?): List<EmbyProfile> =
value?.let { runCatching { Json.decodeFromString<List<EmbyProfile>>(it) }.getOrNull() }.orEmpty()
private fun profilesFrom(preferences: Preferences): List<EmbyProfile> =
decodeProfiles(preferences[Keys.PROFILES]).ifEmpty {
legacyProfile(preferences)?.let(::listOf).orEmpty()
}
private fun profileId(serverUrl: String, userId: String): String = "${serverUrl.trimEnd('/')}::$userId"
}
/** The last successful home response, kept locally for instant launcher startup. */
@Serializable
data class HomeCache(
val continueWatching: List<com.ponzischeme89.memby.data.model.BaseItem> = emptyList(),
val nextUp: List<com.ponzischeme89.memby.data.model.BaseItem> = emptyList(),
val favorites: List<com.ponzischeme89.memby.data.model.BaseItem> = emptyList(),
val latestMovies: List<com.ponzischeme89.memby.data.model.BaseItem> = emptyList(),
/**
* Server-composed rows, including recommendations, so a cold start redraws the exact
* home screen the gateway last sent. Defaulted, so a cache written by an older build
* still decodes.
*/
val rows: List<com.ponzischeme89.memby.data.model.HomeRow> = emptyList(),
)
@@ -0,0 +1,153 @@
package com.ponzischeme89.memby.data.analytics
import com.ponzischeme89.memby.data.model.GatewayRowEvent
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.TimeZone
/**
* Collects what the viewer actually looked at, one row at a time.
*
* Three signals, cheapest to strongest:
*
* - **impression** — the row was composed, so it was on screen at least briefly.
* - **focus** — the remote landed on it, with how long it stayed. This is the number
* worth reading: it separates "a row scrolled past" from "a row someone browsed".
* - **select** — something was opened from it.
*
* Events buffer in memory and are flushed in batches, because a D-pad generates focus
* changes far faster than anything should generate HTTP requests. Nothing here retries or
* persists: losing a batch to a crash costs a little telemetry and nothing else.
*/
class RowAnalytics(
private val now: () -> Long = System::currentTimeMillis,
private val maxBuffered: Int = 200,
) {
private val lock = Any()
private val buffer = ArrayList<GatewayRowEvent>()
private val impressed = 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) {
synchronized(lock) {
if (!impressed.add(rowId)) return
add(GatewayRowEvent(rowId = rowId, rowKind = rowKind, event = EVENT_IMPRESSION, occurredAt = timestamp()))
}
}
/**
* Records focus landing on [rowId]. Moving between cards inside one row extends that
* row's dwell rather than starting a new measurement — the viewer is still reading
* the same strip.
*/
fun rowFocused(rowId: String, rowKind: String, itemId: String) {
synchronized(lock) {
if (focusedRowId == rowId) return
closeOpenFocus()
focusedRowId = rowId
focusedRowKind = rowKind
focusStartedAt = now()
// The impression may not have fired if the row was already on screen when
// this session's collector was created.
if (impressed.add(rowId)) {
add(GatewayRowEvent(rowId = rowId, rowKind = rowKind, event = EVENT_IMPRESSION, occurredAt = timestamp()))
}
lastFocusedItemId = itemId
}
}
/** Records something being opened from a row. */
fun rowSelected(rowId: String, rowKind: String, itemId: String) {
synchronized(lock) {
add(
GatewayRowEvent(
rowId = rowId,
rowKind = rowKind,
event = EVENT_SELECT,
itemId = itemId,
occurredAt = timestamp(),
),
)
}
}
/**
* Closes the open focus measurement — call when leaving the home screen, or before a
* flush, so dwell is not lost while the viewer sits on one row.
*/
fun endFocus() {
synchronized(lock) { closeOpenFocus() }
}
/** Returns everything buffered and clears it. */
fun drain(): List<GatewayRowEvent> = synchronized(lock) {
if (buffer.isEmpty()) return emptyList()
val events = buffer.toList()
buffer.clear()
events
}
fun hasPending(): Boolean = synchronized(lock) { buffer.isNotEmpty() }
/** Forgets which rows have been seen, e.g. after a profile switch. */
fun reset() {
synchronized(lock) {
buffer.clear()
impressed.clear()
focusedRowId = null
}
}
private var lastFocusedItemId: String = ""
private fun closeOpenFocus() {
val rowId = focusedRowId ?: return
val dwell = (now() - focusStartedAt).coerceAtLeast(0)
focusedRowId = null
// Sub-second glances are D-pad travel, not attention. Dropping them keeps the
// numbers meaningful and the batches small.
if (dwell < MIN_DWELL_MS) return
add(
GatewayRowEvent(
rowId = rowId,
rowKind = focusedRowKind,
event = EVENT_FOCUS,
itemId = lastFocusedItemId,
dwellMs = dwell,
occurredAt = timestamp(),
),
)
}
/** Caller already holds the lock. */
private fun add(event: GatewayRowEvent) {
// Drop oldest rather than grow without bound: if flushes are failing, recent
// engagement is the more useful half to keep.
if (buffer.size >= maxBuffered) buffer.removeAt(0)
buffer.add(event)
}
private fun timestamp(): String = iso8601.get()!!.format(Date(now()))
companion object {
const val EVENT_IMPRESSION = "impression"
const val EVENT_FOCUS = "focus"
const val EVENT_SELECT = "select"
/** Below this, a row was passed through rather than looked at. */
const val MIN_DWELL_MS = 400L
// SimpleDateFormat is not thread-safe and minSdk 23 rules out java.time.
private val iso8601 = object : ThreadLocal<SimpleDateFormat>() {
override fun initialValue(): SimpleDateFormat =
SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US).apply {
timeZone = TimeZone.getTimeZone("UTC")
}
}
}
}
@@ -0,0 +1,98 @@
package com.ponzischeme89.memby.data.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class AuthRequest(
@SerialName("Username") val username: String,
@SerialName("Pw") val pw: String,
)
@Serializable
data class AuthResult(
@SerialName("User") val user: EmbyUser? = null,
@SerialName("AccessToken") val accessToken: String? = null,
@SerialName("ServerId") val serverId: String? = null,
)
@Serializable
data class EmbyUser(
@SerialName("Id") val id: String,
@SerialName("Name") val name: String? = null,
)
@Serializable
data class ItemsResult(
@SerialName("Items") val items: List<BaseItem> = emptyList(),
@SerialName("TotalRecordCount") val totalRecordCount: Int = 0,
)
@Serializable
data class UserItemData(
@SerialName("IsFavorite") val isFavorite: Boolean = false,
@SerialName("Played") val played: Boolean = false,
@SerialName("PlaybackPositionTicks") val playbackPositionTicks: Long = 0,
)
@Serializable
data class PlaybackReport(
@SerialName("ItemId") val itemId: String,
@SerialName("PositionTicks") val positionTicks: Long = 0,
@SerialName("IsPaused") val isPaused: Boolean = false,
@SerialName("IsMuted") val isMuted: Boolean = false,
@SerialName("CanSeek") val canSeek: Boolean = true,
@SerialName("PlayMethod") val playMethod: String = "DirectPlay",
)
@Serializable
data class Studio(
@SerialName("Name") val name: String = "",
)
@Serializable
data class MediaStream(
@SerialName("Type") val type: String = "",
@SerialName("Codec") val codec: String? = null,
@SerialName("Title") val title: String? = null,
@SerialName("Width") val width: Int? = null,
@SerialName("Height") val height: Int? = null,
@SerialName("VideoRange") val videoRange: String? = null,
@SerialName("VideoRangeType") val videoRangeType: String? = null,
@SerialName("Channels") val channels: Int? = null,
)
@Serializable
data class BaseItem(
@SerialName("Id") val id: String,
@SerialName("Name") val name: String = "",
@SerialName("Type") val type: String = "",
@SerialName("Overview") val overview: String? = null,
@SerialName("Taglines") val taglines: List<String> = emptyList(),
@SerialName("ProductionYear") val productionYear: Int? = null,
@SerialName("OfficialRating") val officialRating: String? = null,
@SerialName("CommunityRating") val communityRating: Double? = null,
@SerialName("Studios") val studios: List<Studio> = emptyList(),
@SerialName("RunTimeTicks") val runTimeTicks: Long? = null,
@SerialName("Genres") val genres: List<String> = emptyList(),
@SerialName("MediaStreams") val mediaStreams: List<MediaStream> = emptyList(),
@SerialName("PrimaryImageAspectRatio") val primaryImageAspectRatio: Double? = null,
@SerialName("BackdropImageTags") val backdropImageTags: List<String> = emptyList(),
@SerialName("ImageTags") val imageTags: Map<String, String> = emptyMap(),
@SerialName("SeriesId") val seriesId: String? = null,
@SerialName("SeriesName") val seriesName: String? = null,
@SerialName("ParentBackdropItemId") val parentBackdropItemId: String? = null,
@SerialName("ParentBackdropImageTags") val parentBackdropImageTags: List<String> = emptyList(),
@SerialName("ParentLogoItemId") val parentLogoItemId: String? = null,
@SerialName("ParentLogoImageTag") val parentLogoImageTag: String? = null,
@SerialName("UserData") val userData: UserItemData? = null,
) {
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
/** Runtime in whole minutes, or null when unknown. */
val runtimeMinutes: Int?
get() = runTimeTicks?.let { (it / 600_000_000L).toInt() }.takeIf { it != null && it > 0 }
}
@@ -0,0 +1,105 @@
package com.ponzischeme89.memby.data.model
import kotlinx.serialization.Serializable
/**
* Wire types for the Memby gateway.
*
* Item payloads are deliberately still [BaseItem]: the gateway forwards Emby's item JSON
* untouched, so there is exactly one item schema in the system regardless of which
* backend the client is talking to.
*/
@Serializable
data class GatewayLoginRequest(
val username: String,
val password: String,
val deviceId: String,
)
@Serializable
data class GatewayLoginResponse(
val token: String = "",
val userId: String = "",
val username: String = "",
val serverId: String = "",
)
/**
* One horizontal strip, described entirely by the server.
*
* [kind] drives card shape and the empty-state wording on the client; [id] is the stable
* key Compose uses for the row. A server that starts sending a new row — a recommendation
* strip, a seasonal collection — needs no client release, as long as its kind is one the
* app already understands (unknown kinds fall back to poster cards).
*
* The same type is persisted in [com.ponzischeme89.memby.data.HomeCache], so a cold
* start redraws the exact rows the server last sent.
*/
@Serializable
data class HomeRow(
val id: String,
val title: String,
val kind: String = "",
val items: List<BaseItem> = emptyList(),
)
/** Everything the launcher renders, in one response. */
@Serializable
data class GatewayHome(
/** Server-composed rows, in display order. */
val rows: List<HomeRow> = emptyList(),
val continueWatching: List<BaseItem> = emptyList(),
val nextUp: List<BaseItem> = emptyList(),
val favorites: List<BaseItem> = emptyList(),
val latestMovies: List<BaseItem> = emptyList(),
/** True when a row failed upstream; the rest of the payload is still usable. */
val partial: Boolean = false,
)
/** Response of `GET /v1/recommendations`. */
@Serializable
data class GatewayRows(
val rows: List<HomeRow> = emptyList(),
)
@Serializable
data class GatewayItems(
val items: List<BaseItem> = emptyList(),
)
@Serializable
data class GatewayPlayback(
val itemId: String,
val title: String = "",
val url: String,
val resumePositionMs: Long = 0,
)
@Serializable
data class GatewayFlagRequest(
val value: Boolean,
)
/** One row-engagement event. See `data/analytics/RowAnalytics.kt`. */
@Serializable
data class GatewayRowEvent(
val rowId: String,
val rowKind: String = "",
val event: String,
val itemId: String = "",
val dwellMs: Long = 0,
val occurredAt: String = "",
)
@Serializable
data class GatewayRowEvents(
val events: List<GatewayRowEvent>,
)
@Serializable
data class GatewayPlaybackReport(
val itemId: String,
val positionMs: Long,
val isPaused: Boolean = false,
)
@@ -0,0 +1,81 @@
package com.ponzischeme89.memby.data.remote
import com.ponzischeme89.memby.data.model.AuthRequest
import com.ponzischeme89.memby.data.model.AuthResult
import com.ponzischeme89.memby.data.model.ItemsResult
import com.ponzischeme89.memby.data.model.PlaybackReport
import com.ponzischeme89.memby.data.model.UserItemData
import retrofit2.http.Body
import retrofit2.http.DELETE
import retrofit2.http.GET
import retrofit2.http.POST
import retrofit2.http.Path
import retrofit2.http.Query
import retrofit2.http.QueryMap
interface EmbyApi {
@POST("Users/AuthenticateByName")
suspend fun authenticate(@Body body: AuthRequest): AuthResult
@GET("Users/{userId}/Items")
suspend fun getItems(
@Path("userId") userId: String,
@QueryMap params: Map<String, String>,
): ItemsResult
@GET("Users/{userId}/Items/{itemId}")
suspend fun getItem(
@Path("userId") userId: String,
@Path("itemId") itemId: String,
@Query("Fields") fields: String,
): com.ponzischeme89.memby.data.model.BaseItem
@GET("Users/{userId}/Items/{itemId}/LocalTrailers")
suspend fun getLocalTrailers(
@Path("userId") userId: String,
@Path("itemId") itemId: String,
): ItemsResult
@GET("Shows/NextUp")
suspend fun getNextUp(@QueryMap params: Map<String, String>): ItemsResult
@POST("Sessions/Playing")
suspend fun reportPlaybackStarted(@Body body: PlaybackReport)
@POST("Sessions/Playing/Progress")
suspend fun reportPlaybackProgress(@Body body: PlaybackReport)
@POST("Sessions/Playing/Stopped")
suspend fun reportPlaybackStopped(@Body body: PlaybackReport)
@GET("Shows/{seriesId}/Episodes")
suspend fun getEpisodes(
@Path("seriesId") seriesId: String,
@QueryMap params: Map<String, String>,
): ItemsResult
@POST("Users/{userId}/FavoriteItems/{itemId}")
suspend fun addFavorite(
@Path("userId") userId: String,
@Path("itemId") itemId: String,
): UserItemData
@DELETE("Users/{userId}/FavoriteItems/{itemId}")
suspend fun removeFavorite(
@Path("userId") userId: String,
@Path("itemId") itemId: String,
): UserItemData
@POST("Users/{userId}/PlayedItems/{itemId}")
suspend fun markPlayed(
@Path("userId") userId: String,
@Path("itemId") itemId: String,
): UserItemData
@DELETE("Users/{userId}/PlayedItems/{itemId}")
suspend fun markUnplayed(
@Path("userId") userId: String,
@Path("itemId") itemId: String,
): UserItemData
}
@@ -0,0 +1,78 @@
package com.ponzischeme89.memby.data.remote
import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory
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
import java.util.concurrent.TimeUnit
/** Builds a [EmbyApi] bound to a specific server base URL. */
object EmbyServiceFactory {
private val json = Json {
ignoreUnknownKeys = true
coerceInputValues = true
isLenient = true
}
fun create(
baseUrl: String,
deviceIdProvider: () -> String,
tokenProvider: () -> String?,
): EmbyApi {
val contentType = "application/json".toMediaType()
// Kept at NONE so access tokens (carried in the api_key query param and
// X-Emby-Token header) are never written to logs. Raise deliberately for
// local debugging only.
val logging = HttpLoggingInterceptor().apply {
level = HttpLoggingInterceptor.Level.NONE
redactHeader("X-Emby-Token")
redactHeader("X-Emby-Authorization")
}
val client = OkHttpClient.Builder()
.connectTimeout(15, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.addInterceptor(EmbyAuthInterceptor(deviceIdProvider, tokenProvider))
.addInterceptor(logging)
.build()
return Retrofit.Builder()
.baseUrl(baseUrl.ensureTrailingSlash())
.client(client)
.addConverterFactory(json.asConverterFactory(contentType))
.build()
.create(EmbyApi::class.java)
}
}
/** Adds the Emby auth headers to every request. */
private class EmbyAuthInterceptor(
private val deviceIdProvider: () -> String,
private val tokenProvider: () -> String?,
) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val deviceId = deviceIdProvider()
// Version comes from the build, so Emby's device list shows which release a TV
// is actually running.
val authHeader = "MediaBrowser Client=\"Memby\", " +
"Device=\"Android TV\", DeviceId=\"$deviceId\", Version=\"${BuildConfig.VERSION_NAME}\""
val builder = chain.request().newBuilder()
.header("X-Emby-Authorization", authHeader)
.header("Accept", "application/json")
tokenProvider()?.takeIf { it.isNotBlank() }?.let {
builder.header("X-Emby-Token", it)
}
return chain.proceed(builder.build())
}
}
fun String.ensureTrailingSlash(): String = if (endsWith("/")) this else "$this/"
@@ -0,0 +1,69 @@
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.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.GatewayPlayback
import com.ponzischeme89.memby.data.model.GatewayPlaybackReport
import com.ponzischeme89.memby.data.model.GatewayRowEvents
import com.ponzischeme89.memby.data.model.GatewayRows
import com.ponzischeme89.memby.data.model.UserItemData
import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.POST
import retrofit2.http.Path
import retrofit2.http.Query
/**
* The Memby gateway API.
*
* Note how much smaller this is than [EmbyApi]: work the client used to do — fanning out
* four home queries, picking an episode for a series, deciding which fields to request —
* now happens server-side, which is the entire point of the gateway.
*/
interface GatewayApi {
@POST("v1/auth/login")
suspend fun login(@Body body: GatewayLoginRequest): GatewayLoginResponse
@POST("v1/auth/logout")
suspend fun logout()
@GET("v1/home")
suspend fun home(@Query("limit") limit: Int): GatewayHome
@GET("v1/screensaver")
suspend fun screensaver(@Query("limit") limit: Int): GatewayItems
@GET("v1/search")
suspend fun search(@Query("q") term: String, @Query("limit") limit: Int): GatewayItems
/** Recommendation rows on their own. `/v1/home` already embeds these when warm. */
@GET("v1/recommendations")
suspend fun recommendations(): GatewayRows
@GET("v1/items/{id}")
suspend fun item(@Path("id") itemId: String): BaseItem
@GET("v1/items/{id}/playback")
suspend fun playback(@Path("id") itemId: String): GatewayPlayback
@GET("v1/items/{id}/trailer")
suspend fun trailer(@Path("id") itemId: String): BaseItem
@POST("v1/items/{id}/favorite")
suspend fun setFavorite(@Path("id") itemId: String, @Body body: GatewayFlagRequest): UserItemData
@POST("v1/items/{id}/played")
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)
/** Row engagement, uploaded in batches. Fire-and-forget: failures are not retried. */
@POST("v1/analytics/rows")
suspend fun reportRowEvents(@Body body: GatewayRowEvents)
}
@@ -0,0 +1,54 @@
package com.ponzischeme89.memby.data.remote
import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory
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
/** Builds a [GatewayApi] bound to a Memby gateway. */
object GatewayServiceFactory {
private val json = Json {
ignoreUnknownKeys = true
coerceInputValues = true
isLenient = true
explicitNulls = false
}
fun create(baseUrl: String, tokenProvider: () -> String?): GatewayApi {
val contentType = "application/json".toMediaType()
val client = OkHttpClient.Builder()
.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.
.readTimeout(20, TimeUnit.SECONDS)
.addInterceptor(GatewayAuthInterceptor(tokenProvider))
.build()
return Retrofit.Builder()
.baseUrl(baseUrl.ensureTrailingSlash())
.client(client)
.addConverterFactory(json.asConverterFactory(contentType))
.build()
.create(GatewayApi::class.java)
}
}
/**
* Sends the gateway token as a bearer header. Image URLs cannot carry headers, so those
* are built with a `t=` query parameter instead (see EmbyRepository's URL helpers).
*/
private class GatewayAuthInterceptor(private val tokenProvider: () -> String?) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val builder = chain.request().newBuilder().header("Accept", "application/json")
tokenProvider()?.takeIf { it.isNotBlank() }?.let {
builder.header("Authorization", "Bearer $it")
}
return chain.proceed(builder.build())
}
}
@@ -0,0 +1,49 @@
package com.ponzischeme89.memby.performance
import android.app.Activity
import android.os.SystemClock
import android.util.Log
import androidx.metrics.performance.JankStats
/** Debug-only frame telemetry. It does not alter rendering or app state. */
object PerformanceMonitor {
private const val TAG = "EmbyClientPerf"
private var stats: JankStats? = null
private var frameCount = 0
private var jankCount = 0
private var totalFrameMs = 0L
private var windowStartedAt = 0L
fun start(activity: Activity) {
if (!com.ponzischeme89.memby.BuildConfig.DEBUG || stats != null) return
activity.window.decorView.post {
if (stats != null) return@post
windowStartedAt = SystemClock.elapsedRealtime()
stats = JankStats.createAndTrack(activity.window) { frameData ->
frameCount++
totalFrameMs += frameData.frameDurationUiNanos / 1_000_000L
if (frameData.isJank) jankCount++
if (frameCount % 120 == 0) report("window")
}
Log.i(TAG, "tracking started")
}
}
fun mark(name: String) {
if (stats == null) return
report(name)
frameCount = 0
jankCount = 0
totalFrameMs = 0
windowStartedAt = SystemClock.elapsedRealtime()
}
private fun report(name: String) {
if (frameCount == 0) return
val elapsed = SystemClock.elapsedRealtime() - windowStartedAt
Log.i(
TAG,
"$name frames=$frameCount jank=$jankCount avgUiMs=${totalFrameMs / frameCount} elapsedMs=$elapsed",
)
}
}
@@ -0,0 +1,154 @@
package com.ponzischeme89.memby.screensaver
import android.content.Intent
import android.os.Handler
import android.os.Looper
import android.service.dreams.DreamService
import android.view.KeyEvent
import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.platform.ViewCompositionStrategy
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.LifecycleRegistry
import androidx.lifecycle.ViewModelStore
import androidx.lifecycle.ViewModelStoreOwner
import androidx.lifecycle.setViewTreeLifecycleOwner
import androidx.lifecycle.setViewTreeViewModelStoreOwner
import androidx.savedstate.SavedStateRegistry
import androidx.savedstate.SavedStateRegistryController
import androidx.savedstate.SavedStateRegistryOwner
import androidx.savedstate.setViewTreeSavedStateRegistryOwner
import com.ponzischeme89.memby.ui.player.PlayerActivity
import com.ponzischeme89.memby.ui.screensaver.ScreensaverActions
import com.ponzischeme89.memby.ui.screensaver.ScreensaverContent
import com.ponzischeme89.memby.ui.theme.MembyTheme
/**
* The Android TV / Google TV system screensaver (Ambient mode source).
*
* Hosts the shared [ScreensaverContent] composable inside a [ComposeView]. Because
* a DreamService is not a ComponentActivity, we supply the ViewTree owners Compose
* requires via [DreamLifecycleOwner]. The saved Emby session is read from the shared
* repository/DataStore, so this works with no Activity running and after process death.
*
* D-pad navigation, panel toggling and OK-to-act are handled inside the composable
* (via Compose focus + key events). The remote's Play/Pause media key is intercepted
* here in [dispatchKeyEvent] and routed to the composable through [ScreensaverActions].
*/
class MembyDreamService : DreamService() {
private var owner: DreamLifecycleOwner? = null
private var composeView: ComposeView? = null
private val actions = ScreensaverActions()
private val mainHandler = Handler(Looper.getMainLooper())
override fun onAttachedToWindow() {
super.onAttachedToWindow()
isFullscreen = true
isInteractive = true
isScreenBright = true
val lifecycleOwner = DreamLifecycleOwner().also { it.onCreate() }
owner = lifecycleOwner
composeView = ComposeView(this).apply {
setViewTreeLifecycleOwner(lifecycleOwner)
setViewTreeViewModelStoreOwner(lifecycleOwner)
setViewTreeSavedStateRegistryOwner(lifecycleOwner)
setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
setContent {
MembyTheme {
ScreensaverContent(
onPlay = { url, title -> launchPlayback(url, title) },
onExit = { finish() },
actions = actions,
)
}
}
}
setContentView(composeView)
lifecycleOwner.onStart()
}
override fun onDreamingStarted() {
super.onDreamingStarted()
owner?.onResume()
}
override fun onDreamingStopped() {
owner?.onPause()
super.onDreamingStopped()
}
override fun onDetachedFromWindow() {
// Cancels Compose coroutines (rotation timer, network calls) by disposing
// the composition, then tears down the owner and releases callbacks.
actions.playCurrent = null
mainHandler.removeCallbacksAndMessages(null)
owner?.onDestroy()
owner = null
composeView = null
super.onDetachedFromWindow()
}
/** Route the hardware Play/Pause media key to the current item; delegate the rest. */
override fun dispatchKeyEvent(event: KeyEvent): Boolean {
if (event.action == KeyEvent.ACTION_DOWN) {
when (event.keyCode) {
KeyEvent.KEYCODE_MEDIA_PLAY,
KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE -> {
actions.playCurrent?.invoke()
return true
}
}
}
return super.dispatchKeyEvent(event)
}
/**
* Exit the dream, then start playback. Launching the player *after* the dream
* finishes (via a short main-thread post) avoids the "activity started behind
* the dream" race some TV builds exhibit. Uses the application context because
* this service is being torn down.
*/
private fun launchPlayback(url: String, title: String) {
val intent = PlayerActivity.intent(applicationContext, url, title)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
finish()
mainHandler.postDelayed({
runCatching { applicationContext.startActivity(intent) }
}, 150)
}
}
/** Minimal owner bundle so Compose can run inside a DreamService window. */
private class DreamLifecycleOwner : LifecycleOwner, ViewModelStoreOwner, SavedStateRegistryOwner {
private val lifecycleRegistry = LifecycleRegistry(this)
private val store = ViewModelStore()
private val savedStateController = SavedStateRegistryController.create(this)
override val lifecycle: Lifecycle get() = lifecycleRegistry
override val viewModelStore: ViewModelStore get() = store
override val savedStateRegistry: SavedStateRegistry get() = savedStateController.savedStateRegistry
fun onCreate() {
savedStateController.performRestore(null)
lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_CREATE)
}
fun onStart() = lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_START)
fun onResume() = lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_RESUME)
fun onPause() = lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_PAUSE)
fun onDestroy() {
// Only step down from whatever state we're in; guard against double-destroy.
if (lifecycleRegistry.currentState.isAtLeast(Lifecycle.State.STARTED)) {
lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_STOP)
}
lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_DESTROY)
store.clear()
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,366 @@
package com.ponzischeme89.memby.ui
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import com.ponzischeme89.memby.data.EmbyRepository
import com.ponzischeme89.memby.data.HomeCache
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.HomeRow
import com.ponzischeme89.memby.data.model.UserItemData
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
enum class HomeSection { CONTINUE, NEXT_UP, FAVORITES, LATEST }
data class HomeUiState(
val continueWatching: List<BaseItem> = emptyList(),
val nextUp: List<BaseItem> = emptyList(),
val favorites: List<BaseItem> = emptyList(),
val latestMovies: List<BaseItem> = emptyList(),
/**
* Rows as composed by the gateway, including recommendation strips. Empty on the
* direct-to-Emby path, where the client composes rows itself.
*/
val rows: List<HomeRow> = emptyList(),
val loading: Set<HomeSection> = HomeSection.entries.toSet(),
val hasRefreshError: Boolean = false,
/**
* A message worth showing above the rows. Null means "use the generic
* slow-connection wording".
*/
val statusMessage: String? = null,
/**
* Set when the gateway reports a deliberate outage. Distinct from [statusMessage]
* because this replaces the whole content area rather than adding a banner — the
* rows behind it would be stale and unusable anyway.
*/
val maintenanceMessage: String? = null,
) {
val watchingAndNextUp: List<BaseItem>
get() = (continueWatching + nextUp).distinctBy(BaseItem::id)
fun toCache() = HomeCache(
continueWatching = continueWatching,
nextUp = nextUp,
favorites = favorites,
latestMovies = latestMovies,
rows = rows,
)
companion object {
fun from(cache: HomeCache?) = HomeUiState(
continueWatching = cache?.continueWatching.orEmpty(),
nextUp = cache?.nextUp.orEmpty(),
favorites = cache?.favorites.orEmpty(),
latestMovies = cache?.latestMovies.orEmpty(),
rows = cache?.rows.orEmpty(),
loading = buildSet {
if (cache?.continueWatching.isNullOrEmpty()) add(HomeSection.CONTINUE)
if (cache?.nextUp.isNullOrEmpty()) add(HomeSection.NEXT_UP)
if (cache?.favorites.isNullOrEmpty()) add(HomeSection.FAVORITES)
if (cache?.latestMovies.isNullOrEmpty()) add(HomeSection.LATEST)
},
)
}
}
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()
private val _focusedItem = MutableStateFlow<BaseItem?>(initialFocusedItem(_state.value))
val focusedItem: StateFlow<BaseItem?> = _focusedItem.asStateFlow()
private var metadataJob: Job? = null
private val metadataCache = object : LinkedHashMap<String, BaseItem>(32, 0.75f, true) {
override fun removeEldestEntry(eldest: MutableMap.MutableEntry<String, BaseItem>?): Boolean = size > 32
}
/** Row engagement, buffered here and uploaded in batches. */
private val analytics = RowAnalytics()
init {
refreshAll()
viewModelScope.launch {
repository.playbackStops.collect { refreshWatching() }
}
viewModelScope.launch {
// A D-pad produces focus changes far faster than anything should produce
// HTTP requests, so engagement is uploaded on a slow drumbeat instead.
while (true) {
delay(ANALYTICS_FLUSH_INTERVAL_MS)
flushAnalytics()
}
}
}
fun trackRowImpression(rowId: String, rowKind: String) = analytics.rowImpression(rowId, rowKind)
fun trackRowFocused(rowId: String, rowKind: String, itemId: String) =
analytics.rowFocused(rowId, rowKind, itemId)
fun trackRowSelected(rowId: String, rowKind: String, itemId: String) =
analytics.rowSelected(rowId, rowKind, itemId)
/**
* Closes the open dwell measurement and uploads. Called on a timer and when the home
* screen stops, so time spent sitting on one row is not lost.
*/
fun flushAnalytics() {
analytics.endFocus()
repository.reportRowEvents(analytics.drain())
}
fun refreshAll() {
viewModelScope.launch(Dispatchers.IO) {
refreshMutex.withLock {
_state.update { it.copy(loading = HomeSection.entries.toSet(), hasRefreshError = false) }
if (repository.supportsBatchHome) {
loadBatchHome()
} else {
coroutineScope {
launch { loadContinueWatching() }
launch { loadNextUp() }
launch { loadFavorites() }
launch { loadLatest() }
}
}
persistCurrentHome()
}
}
}
/**
* The gateway returns every row in one response, so the four-way fan-out collapses
* into a single request and the rows can no longer arrive out of step with each other.
*/
private suspend fun loadBatchHome() {
runCatching { repository.getHome() }
.onSuccess { home ->
_state.update { current ->
current.copy(
continueWatching = home.continueWatching,
nextUp = home.nextUp,
favorites = home.favorites,
latestMovies = home.latestMovies,
// Recommendation rows are built in the background by the gateway,
// so an early response can arrive without them. Keeping the rows
// we already had stops the strip flickering out and back in.
rows = home.rows.ifEmpty { current.rows },
loading = emptySet(),
hasRefreshError = home.partial,
statusMessage = null,
// A successful response is the only thing that clears the
// maintenance screen, so a retry that fails keeps it up.
maintenanceMessage = null,
)
}
if (_focusedItem.value == null) {
initialFocusedItem(_state.value)?.let(::focusItem)
}
}
.onFailure { error ->
val maintenance = isMaintenanceError(error)
_state.update {
it.copy(
loading = emptySet(),
hasRefreshError = true,
statusMessage = null,
maintenanceMessage = if (maintenance) friendlyEmbyError(error) else null,
)
}
}
}
/**
* Updates local metadata immediately, then enriches it only after focus settles.
* Cancelling the previous job prevents stale responses from winning rapid D-pad navigation.
*/
fun focusItem(item: BaseItem) {
val cached = synchronized(metadataCache) { metadataCache[item.id] }
_focusedItem.value = cached ?: item
metadataJob?.cancel()
if (cached != null) return
metadataJob = viewModelScope.launch(Dispatchers.IO) {
delay(FOCUS_METADATA_DEBOUNCE_MS)
val details = runCatching { repository.getItemDetails(item.id) }.getOrNull() ?: return@launch
synchronized(metadataCache) { metadataCache[item.id] = details }
if (_focusedItem.value?.id == item.id) {
_focusedItem.value = details
}
}
}
fun setFavorite(item: BaseItem, favorite: Boolean) {
updateFavorite(item, favorite)
viewModelScope.launch(Dispatchers.IO) {
runCatching { repository.setFavorite(item.id, favorite) }
.onSuccess { confirmed ->
updateFavorite(item, confirmed)
}
.onFailure {
updateFavorite(item, !favorite)
}
}
}
private fun updateFavorite(item: BaseItem, favorite: Boolean) {
updateUserData(item.id) { it.copy(isFavorite = favorite) }
_state.update { state ->
val updatedItem = item.copy(
userData = (item.userData ?: UserItemData()).copy(isFavorite = favorite),
)
state.copy(
favorites = if (favorite) {
(state.favorites + updatedItem).distinctBy(BaseItem::id)
} else {
state.favorites.filterNot { it.id == item.id }
},
)
}
}
fun setPlayed(item: BaseItem, played: Boolean) {
updateUserData(item.id) {
it.copy(
played = played,
playbackPositionTicks = if (played) 0L else it.playbackPositionTicks,
)
}
viewModelScope.launch(Dispatchers.IO) {
runCatching { repository.setPlayed(item.id, played) }
.onSuccess { confirmed ->
updateUserData(item.id) { it.copy(played = confirmed) }
}
.onFailure {
updateUserData(item.id) { it.copy(played = !played) }
}
}
}
private fun updateUserData(itemId: String, transform: (UserItemData) -> UserItemData) {
fun BaseItem.updated(): BaseItem =
if (id == itemId) copy(userData = transform(userData ?: UserItemData())) else this
_state.update {
it.copy(
continueWatching = it.continueWatching.map(BaseItem::updated),
nextUp = it.nextUp.map(BaseItem::updated),
favorites = it.favorites.map(BaseItem::updated),
latestMovies = it.latestMovies.map(BaseItem::updated),
// Server rows hold their own copies of the same items, so an optimistic
// favourite/watched toggle has to reach into them too or the heart on a
// recommendation card would not light up.
rows = it.rows.map { row -> row.copy(items = row.items.map(BaseItem::updated)) },
)
}
_focusedItem.update { it?.updated() }
synchronized(metadataCache) {
metadataCache[itemId]?.let { metadataCache[itemId] = it.updated() }
}
}
private suspend fun refreshWatching() {
refreshMutex.withLock {
_state.update { it.copy(loading = it.loading + setOf(HomeSection.CONTINUE, HomeSection.NEXT_UP)) }
if (repository.supportsBatchHome) {
// One request is cheaper than two here as well, and playback just
// invalidated this user's rows on the gateway anyway.
loadBatchHome()
} else {
coroutineScope {
launch { loadContinueWatching(clearLoading = false) }
launch { loadNextUp(clearLoading = false) }
}
}
_state.update { it.copy(loading = it.loading - setOf(HomeSection.CONTINUE, HomeSection.NEXT_UP)) }
persistCurrentHome()
}
}
private suspend fun loadContinueWatching(clearLoading: Boolean = true) =
load(HomeSection.CONTINUE, clearLoading, { repository.getContinueWatching() }) { state, items ->
state.copy(continueWatching = items)
}
private suspend fun loadNextUp(clearLoading: Boolean = true) =
load(HomeSection.NEXT_UP, clearLoading, { repository.getNextUp() }) { state, items ->
state.copy(nextUp = items)
}
private suspend fun loadFavorites() =
load(HomeSection.FAVORITES, true, { repository.getFavorites() }) { state, items ->
state.copy(favorites = items)
}
private suspend fun loadLatest() =
load(HomeSection.LATEST, true, { repository.getLatestMovies() }) { state, items ->
state.copy(latestMovies = items)
}
private suspend fun load(
section: HomeSection,
clearLoading: Boolean,
request: suspend () -> List<BaseItem>,
updateItems: (HomeUiState, List<BaseItem>) -> HomeUiState,
) {
runCatching { request() }
.onSuccess { items ->
_state.update { current ->
updateItems(current, items).let {
if (clearLoading) it.copy(loading = it.loading - section) else it
}
}
if (_focusedItem.value == null) {
items.firstOrNull()?.let(::focusItem)
}
}
.onFailure {
_state.update { current ->
current.copy(
loading = if (clearLoading) current.loading - section else current.loading,
hasRefreshError = true,
)
}
}
}
private suspend fun persistCurrentHome() {
runCatching { repository.cacheHome(_state.value.toCache()) }
}
override fun onCleared() {
flushAnalytics()
super.onCleared()
}
companion object {
private const val FOCUS_METADATA_DEBOUNCE_MS = 140L
private const val ANALYTICS_FLUSH_INTERVAL_MS = 20_000L
private fun initialFocusedItem(state: HomeUiState): BaseItem? =
state.watchingAndNextUp.firstOrNull()
?: state.latestMovies.firstOrNull()
?: state.favorites.firstOrNull()
}
}
class HomeViewModelFactory(private val repository: EmbyRepository) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T {
require(modelClass.isAssignableFrom(HomeViewModel::class.java))
return HomeViewModel(repository) as T
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,337 @@
package com.ponzischeme89.memby.ui
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.clickable
import androidx.compose.foundation.focusable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Build
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.tv.material3.Icon
import androidx.tv.material3.Text
import kotlinx.coroutines.delay
private val MaintenanceAccent = Color(0xFF52B54B)
private val MaintenanceTitle = Color(0xFFF2F5F7)
private val MaintenanceBody = Color(0xFFAEB7BF)
private val MaintenanceFaint = Color(0xFF7E888F)
/** How long between automatic retries while the gateway is down. */
private const val RETRY_SECONDS = 30
/**
* Fills the content area while the gateway reports a deliberate outage.
*
* The navigation rail deliberately stays mounted beside this: Settings and Switch user
* are local, so there is no reason to strand the viewer just because content is
* unavailable. Everything here is cheap to draw — a handful of animated floats and plain
* Canvas geometry — because TV GPUs punish blur and layered transparency.
*/
@Composable
fun MaintenanceScreen(
message: String,
contentFocusRequester: FocusRequester,
navigationFocusRequester: FocusRequester,
onRetry: () -> Unit,
modifier: Modifier = Modifier,
) {
val transition = rememberInfiniteTransition(label = "maintenance")
// One slow drift drives the background glow; one fast-ish phase drives the rings.
val glow by transition.animateFloat(
initialValue = 0f,
targetValue = 1f,
animationSpec = infiniteRepeatable(tween(9_000, easing = LinearEasing), RepeatMode.Reverse),
label = "glow",
)
val pulse by transition.animateFloat(
initialValue = 0f,
targetValue = 1f,
animationSpec = infiniteRepeatable(tween(3_200, easing = LinearEasing)),
label = "pulse",
)
val gearRotation by transition.animateFloat(
initialValue = 0f,
targetValue = 360f,
animationSpec = infiniteRepeatable(tween(24_000, easing = LinearEasing)),
label = "gear",
)
val sweep by transition.animateFloat(
initialValue = 0f,
targetValue = 1f,
animationSpec = infiniteRepeatable(tween(1_900, easing = LinearEasing)),
label = "sweep",
)
// Entrance: content settles in rather than snapping, so the switch from rows to this
// screen reads as intentional.
var entered by remember { mutableStateOf(false) }
LaunchedEffect(Unit) { entered = true }
val entrance by animateFloatAsState(
targetValue = if (entered) 1f else 0f,
animationSpec = tween(420),
label = "maintenance-entrance",
)
var secondsLeft by remember { mutableStateOf(RETRY_SECONDS) }
LaunchedEffect(message) {
// Restarts whenever the message changes, so a failed retry resets the clock.
secondsLeft = RETRY_SECONDS
while (true) {
delay(1_000)
secondsLeft -= 1
if (secondsLeft <= 0) {
onRetry()
secondsLeft = RETRY_SECONDS
}
}
}
Box(modifier = modifier.fillMaxSize()) {
MaintenanceBackdrop(glow = glow)
Column(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 56.dp)
.graphicsLayer {
alpha = entrance
translationY = (1f - entrance) * 26.dp.toPx()
},
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
PulsingEmblem(pulse = pulse, gearRotation = gearRotation)
Spacer(Modifier.height(30.dp))
Text(
"Memby is taking a short break",
color = MaintenanceTitle,
fontSize = 34.sp,
fontWeight = FontWeight.SemiBold,
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(12.dp))
Text(
message,
color = MaintenanceBody,
fontSize = 17.sp,
textAlign = TextAlign.Center,
modifier = Modifier.widthIn(max = 620.dp),
)
Spacer(Modifier.height(28.dp))
SweepBar(progress = sweep)
Spacer(Modifier.height(28.dp))
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(18.dp),
) {
RetryButton(
onClick = {
secondsLeft = RETRY_SECONDS
onRetry()
},
modifier = Modifier
.focusRequester(contentFocusRequester)
// Without this the viewer can reach the button but never get
// back to the rail, since nothing else here is focusable.
.focusProperties { left = navigationFocusRequester },
)
Text(
"Checking again in ${secondsLeft}s",
color = MaintenanceFaint,
fontSize = 14.sp,
)
}
}
}
}
/** Vertical wash plus a radial glow that drifts, so the screen is never quite static. */
@Composable
private fun MaintenanceBackdrop(glow: Float) {
Canvas(Modifier.fillMaxSize()) {
drawRect(
brush = Brush.verticalGradient(
listOf(Color(0xFF0B0F14), Color(0xFF121A22), Color(0xFF0A0D11)),
),
)
val centre = Offset(
x = size.width * (0.42f + 0.16f * glow),
y = size.height * (0.38f + 0.10f * (1f - glow)),
)
val radius = size.minDimension * (0.55f + 0.08f * glow)
drawCircle(
brush = Brush.radialGradient(
colors = listOf(MaintenanceAccent.copy(alpha = 0.13f), Color.Transparent),
center = centre,
radius = radius,
),
radius = radius,
center = centre,
)
}
}
/**
* Three rings expanding outward on staggered phases, with a slowly turning gear at the
* centre. Drawn in one Canvas: three composables with their own animations would cost
* three recompositions per frame for the same picture.
*/
@Composable
private fun PulsingEmblem(pulse: Float, gearRotation: Float) {
Box(contentAlignment = Alignment.Center) {
Canvas(Modifier.size(210.dp)) {
val base = size.minDimension * 0.22f
repeat(3) { index ->
// Stagger the phases so the rings never bunch up together.
val phase = (pulse + index / 3f) % 1f
val radius = base * (1f + phase * 1.6f)
drawCircle(
color = MaintenanceAccent.copy(alpha = 0.34f * (1f - phase)),
radius = radius,
center = center,
style = androidx.compose.ui.graphics.drawscope.Stroke(width = 2.dp.toPx()),
)
}
drawCircle(
brush = Brush.radialGradient(
colors = listOf(MaintenanceAccent.copy(alpha = 0.22f), Color.Transparent),
center = center,
radius = base * 1.35f,
),
radius = base * 1.35f,
center = center,
)
}
Box(
modifier = Modifier
.size(96.dp)
.clip(CircleShape)
.background(Color(0xFF16202A))
.border(1.dp, MaintenanceAccent.copy(alpha = 0.35f), CircleShape),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = Icons.Default.Build,
contentDescription = null,
tint = MaintenanceAccent,
modifier = Modifier
.size(40.dp)
.graphicsLayer { rotationZ = gearRotation },
)
}
}
}
/** An indeterminate bar: a highlight sweeping a dim track, looping. */
@Composable
private fun SweepBar(progress: Float) {
Canvas(
Modifier
.width(300.dp)
.height(3.dp),
) {
val corner = androidx.compose.ui.geometry.CornerRadius(size.height / 2f)
drawRoundRect(color = Color.White.copy(alpha = 0.07f), cornerRadius = corner)
// The highlight starts off-screen left and exits right, so the loop point is
// invisible rather than a visible jump back to the start.
val bandWidth = size.width * 0.32f
val x = -bandWidth + (size.width + bandWidth) * progress
drawRoundRect(
brush = Brush.horizontalGradient(
colors = listOf(Color.Transparent, MaintenanceAccent.copy(alpha = 0.85f), Color.Transparent),
startX = x,
endX = x + bandWidth,
),
topLeft = Offset(x.coerceAtLeast(0f), 0f),
size = Size(
width = (x + bandWidth).coerceAtMost(size.width) - x.coerceAtLeast(0f),
height = size.height,
),
cornerRadius = corner,
)
}
}
@Composable
private fun RetryButton(onClick: () -> Unit, modifier: Modifier = Modifier) {
var focused by remember { mutableStateOf(false) }
val scale by animateFloatAsState(
targetValue = if (focused) 1.06f else 1f,
animationSpec = tween(120),
label = "retry-scale",
)
Box(
modifier = modifier
.graphicsLayer { scaleX = scale; scaleY = scale }
.clip(RoundedCornerShape(10.dp))
.background(if (focused) MaintenanceAccent else Color(0xFF1E2833))
.border(
width = if (focused) 0.dp else 1.dp,
color = Color.White.copy(alpha = 0.12f),
shape = RoundedCornerShape(10.dp),
)
.onFocusChanged { focused = it.isFocused }
.focusable(interactionSource = remember { MutableInteractionSource() })
.clickable(onClick = onClick)
.padding(horizontal = 26.dp, vertical = 12.dp),
) {
Text(
"Try again",
color = if (focused) Color(0xFF06240A) else MaintenanceTitle,
fontSize = 16.sp,
fontWeight = FontWeight.SemiBold,
)
}
}
@@ -0,0 +1,228 @@
package com.ponzischeme89.memby.ui.player
import android.app.AlertDialog
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.view.KeyEvent
import android.view.WindowManager
import android.widget.FrameLayout
import androidx.activity.ComponentActivity
import androidx.media3.common.C
import androidx.media3.common.MediaItem
import androidx.media3.common.Player
import androidx.media3.common.TrackGroup
import androidx.media3.common.TrackSelectionOverride
import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.trackselection.DefaultTrackSelector
import androidx.media3.ui.PlayerView
import androidx.lifecycle.lifecycleScope
import com.ponzischeme89.memby.ServiceLocator
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
/**
* Fullscreen Media3 player with native stream-track selection. Press Menu while
* playing to choose an audio or subtitle track; the subtitle controller button
* remains available in the regular transport controls too.
*/
class PlayerActivity : ComponentActivity() {
private var player: ExoPlayer? = null
private var playerView: PlayerView? = null
private var progressJob: Job? = null
private var playbackStarted = false
private var stopReported = false
private var itemId: String? = null
@UnstableApi
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
val url = intent.getStringExtra(EXTRA_URL)
itemId = intent.getStringExtra(EXTRA_ITEM_ID)
val resumePositionMs = intent.getLongExtra(EXTRA_RESUME_POSITION_MS, 0L)
if (url.isNullOrBlank()) {
finish()
return
}
val view = PlayerView(this).apply {
layoutParams = FrameLayout.LayoutParams(
FrameLayout.LayoutParams.MATCH_PARENT,
FrameLayout.LayoutParams.MATCH_PARENT,
)
useController = true
setShowSubtitleButton(true)
}
setContentView(view)
playerView = view
val selector = DefaultTrackSelector(this)
player = ExoPlayer.Builder(this)
.setTrackSelector(selector)
.build()
.also { playback ->
view.player = playback
playback.addListener(object : Player.Listener {
override fun onPlaybackStateChanged(playbackState: Int) {
if (playbackState == Player.STATE_READY && !playbackStarted) {
playbackStarted = true
reportStarted(playback.currentPosition)
startProgressReporting()
}
}
override fun onIsPlayingChanged(isPlaying: Boolean) {
if (playbackStarted) reportProgress(playback.currentPosition, isPaused = !isPlaying)
}
})
playback.setMediaItem(MediaItem.fromUri(url), resumePositionMs)
playback.playWhenReady = true
playback.prepare()
}
}
override fun dispatchKeyEvent(event: KeyEvent): Boolean {
if (event.action == KeyEvent.ACTION_UP && event.keyCode in setOf(KeyEvent.KEYCODE_MENU, KeyEvent.KEYCODE_SETTINGS)) {
showTrackMenu()
return true
}
return super.dispatchKeyEvent(event)
}
private fun showTrackMenu() {
val playback = player ?: return
val audio = playback.currentTracks.groups.any { group ->
group.type == C.TRACK_TYPE_AUDIO && (0 until group.mediaTrackGroup.length).any(group::isTrackSupported)
}
val subtitles = playback.currentTracks.groups.any { group ->
group.type == C.TRACK_TYPE_TEXT && (0 until group.mediaTrackGroup.length).any(group::isTrackSupported)
}
val options = buildList {
if (audio) add("Audio")
if (subtitles) add("Subtitles")
}
if (options.isEmpty()) {
AlertDialog.Builder(this).setMessage("No alternate audio or subtitle tracks are available.")
.setPositiveButton("OK", null).show()
return
}
AlertDialog.Builder(this)
.setTitle(intent.getStringExtra(EXTRA_TITLE) ?: "Playback options")
.setItems(options.toTypedArray()) { _, which ->
showTrackPicker(if (options[which] == "Audio") C.TRACK_TYPE_AUDIO else C.TRACK_TYPE_TEXT)
}
.show()
}
private fun showTrackPicker(trackType: Int) {
val playback = player ?: return
val entries = mutableListOf<TrackChoice>()
playback.currentTracks.groups.forEach { group ->
if (group.type == trackType) {
for (index in 0 until group.mediaTrackGroup.length) {
if (group.isTrackSupported(index)) {
entries += TrackChoice(group.mediaTrackGroup, index, trackLabel(group.mediaTrackGroup, index))
}
}
}
}
val choices = if (trackType == C.TRACK_TYPE_TEXT) listOf(TrackChoice(null, -1, "Off")) + entries else entries
AlertDialog.Builder(this)
.setTitle(if (trackType == C.TRACK_TYPE_AUDIO) "Audio track" else "Subtitles")
.setItems(choices.map { it.label }.toTypedArray()) { _, which ->
val choice = choices[which]
val builder = playback.trackSelectionParameters.buildUpon().clearOverridesOfType(trackType)
if (choice.group != null) builder.setOverrideForType(TrackSelectionOverride(choice.group, listOf(choice.index)))
playback.trackSelectionParameters = builder.build()
}
.show()
}
private fun trackLabel(group: TrackGroup, index: Int): String {
val format = group.getFormat(index)
return format.label?.takeIf { it.isNotBlank() }
?: format.language?.takeIf { it.isNotBlank() }?.replaceFirstChar { it.uppercase() }
?: if (format.channelCount > 0) "${format.channelCount} channel audio" else "Track ${index + 1}"
}
override fun onStop() {
player?.let { if (playbackStarted) reportProgress(it.currentPosition, isPaused = true) }
super.onStop()
player?.pause()
}
override fun onDestroy() {
progressJob?.cancel()
val playback = player
if (!stopReported && playbackStarted && !itemId.isNullOrBlank()) {
stopReported = true
ServiceLocator.repository.enqueuePlaybackStopped(itemId!!, playback?.currentPosition ?: 0L)
}
playerView?.player = null
playback?.release()
player = null
super.onDestroy()
}
private fun reportStarted(positionMs: Long) {
val id = itemId?.takeIf { it.isNotBlank() } ?: return
lifecycleScope.launch {
runCatching { ServiceLocator.repository.reportPlaybackStarted(id, positionMs) }
}
}
private fun reportProgress(positionMs: Long, isPaused: Boolean) {
val id = itemId?.takeIf { it.isNotBlank() } ?: return
lifecycleScope.launch {
runCatching { ServiceLocator.repository.reportPlaybackProgress(id, positionMs, isPaused) }
}
}
private fun startProgressReporting() {
progressJob?.cancel()
progressJob = lifecycleScope.launch {
while (isActive) {
delay(PROGRESS_INTERVAL_MS)
player?.let { reportProgress(it.currentPosition, isPaused = !it.isPlaying) }
}
}
}
private data class TrackChoice(val group: TrackGroup?, val index: Int, val label: String)
companion object {
private const val EXTRA_URL = "extra_url"
private const val EXTRA_ITEM_ID = "extra_item_id"
private const val EXTRA_TITLE = "extra_title"
private const val EXTRA_RESUME_POSITION_MS = "extra_resume_position_ms"
fun intent(
context: Context,
url: String,
title: String?,
resumePositionMs: Long = 0L,
): Intent = intent(context, itemId = null, url = url, title = title, resumePositionMs = resumePositionMs)
fun intent(
context: Context,
itemId: String?,
url: String,
title: String?,
resumePositionMs: Long = 0L,
): Intent =
Intent(context, PlayerActivity::class.java).apply {
itemId?.let { putExtra(EXTRA_ITEM_ID, it) }
putExtra(EXTRA_URL, url)
putExtra(EXTRA_TITLE, title)
putExtra(EXTRA_RESUME_POSITION_MS, resumePositionMs)
}
private const val PROGRESS_INTERVAL_MS = 10_000L
}
}
@@ -0,0 +1,33 @@
package com.ponzischeme89.memby.ui.screensaver
import android.content.Context
import android.content.Intent
import android.net.Uri
/** Opens an item in Emby's installed Android or Android TV client. */
internal object EmbyAppLauncher {
private val packageCandidates = listOf("com.mb.android", "tv.emby.embyatv")
fun play(context: Context, serverId: String?, itemId: String): Boolean {
if (serverId.isNullOrBlank() || itemId.isBlank()) return false
// `play` is supported by recent Emby Android builds. `items` keeps navigation
// useful for older installed clients that only support opening an item page.
val links = listOf(
"emby://play/$serverId/$itemId",
"emby://items/$serverId/$itemId",
)
for (packageName in packageCandidates) {
for (link in links) {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(link))
.setPackage(packageName)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
if (intent.resolveActivity(context.packageManager) != null) {
context.startActivity(intent)
return true
}
}
}
return false
}
}
@@ -0,0 +1,45 @@
package com.ponzischeme89.memby.ui.screensaver
import android.os.Bundle
import android.os.Build
import android.view.WindowManager
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import com.ponzischeme89.memby.ui.player.PlayerActivity
import com.ponzischeme89.memby.ui.theme.MembyTheme
/** In-app preview of the screensaver, launched from the home screen. */
class ScreensaverActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
// A package replacement can leave Android TV in the Dream's ambient/sleeping
// state. The restart destination is an interactive slide, so wake the display
// as this activity becomes visible instead of rendering it behind black.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) {
setTurnScreenOn(true)
} else {
@Suppress("DEPRECATION")
window.addFlags(WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON)
}
setContent {
MembyTheme {
ScreensaverContent(
onPlay = { url, title ->
startActivity(PlayerActivity.intent(this, url, title))
},
onExit = { finish() },
startupMessage = intent.getStringExtra(EXTRA_STARTUP_MESSAGE),
)
}
}
}
companion object {
const val EXTRA_STARTUP_MESSAGE = "com.ponzischeme89.memby.extra.STARTUP_MESSAGE"
fun restartAfterUpdateIntent(context: android.content.Context): android.content.Intent =
android.content.Intent(context, ScreensaverActivity::class.java)
.putExtra(EXTRA_STARTUP_MESSAGE, "Restarting Memby after update…")
}
}
@@ -0,0 +1,997 @@
package com.ponzischeme89.memby.ui.screensaver
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.Crossfade
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.focusable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ChevronLeft
import androidx.compose.material.icons.filled.ChevronRight
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.AccessTime
import androidx.compose.material.icons.filled.Business
import androidx.compose.material.icons.filled.Favorite
import androidx.compose.material.icons.filled.FavoriteBorder
import androidx.compose.material.icons.filled.LiveTv
import androidx.compose.material.icons.filled.Movie
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.LocalOffer
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
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
import androidx.compose.runtime.setValue
import androidx.compose.runtime.withFrameMillis
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shadow
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
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.lifecycle.Lifecycle
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.repeatOnLifecycle
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.settings.SettingsSheet
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import java.text.DateFormat
import java.util.Date
// Background-prefetch tuning for the rotating queue.
private const val PREFETCH_AHEAD = 6 // start fetching this many items before the end
private const val MAX_QUEUE = 600 // cap the in-memory queue…
private const val TRIM_TO = 400 // …trimming already-shown items down to this
/** Soft drop shadow that keeps foreground text legible over bright/white artwork. */
private val TextShadow = Shadow(
color = Color(0xCC000000),
offset = Offset(0f, 3f),
blurRadius = 10f,
)
/**
* A small holder that lets a host (e.g. the DreamService) drive playback from a
* key it intercepts outside Compose focus (the remote's Play/Pause media key).
* The composable registers [playCurrent] for the currently shown item.
*/
class ScreensaverActions {
@Volatile
var playCurrent: (() -> Unit)? = null
}
/**
* The shared backdrop slideshow, used by both the in-app preview activity and the
* system Daydream. Remote model:
* - ◄ / ► previous / next item (when the panel is closed)
* - ▲ / ▼ show / hide the details + actions panel
* - OK/Center open the panel, or perform the focused action when it's open
* - Play/Pause start playback of the current item
* - Back close the panel, or exit if it's already closed
*/
@Composable
fun ScreensaverContent(
onPlay: (url: String, title: String) -> Unit,
onExit: () -> Unit,
actions: ScreensaverActions? = null,
startupMessage: String? = null,
) {
val repo = ServiceLocator.repository
// Nullable initial so we don't flash "not configured" before settings load.
val settings by repo.settingsFlow.collectAsState(initial = null)
when {
settings == null -> MessageScreen(text = "Memby starting up….", onExit = onExit)
settings?.isSignedIn != true -> MessageScreen(
text = "Open “Memby” on this device to configure your server and sign in.",
onExit = onExit,
)
else -> Slideshow(
onPlay = onPlay,
onExit = onExit,
actions = actions,
showTitleLogo = settings?.showTitleLogo ?: true,
ringColor = ringColorFromHex(settings?.ringColorHex),
embyServerId = settings?.serverId,
warmBackdropUrl = settings?.lastBackdropUrl,
startupMessage = startupMessage,
)
}
}
@Composable
private fun Slideshow(
onPlay: (url: String, title: String) -> Unit,
onExit: () -> Unit,
actions: ScreensaverActions?,
showTitleLogo: Boolean,
ringColor: Color,
embyServerId: String?,
warmBackdropUrl: String?,
startupMessage: String?,
) {
val repo = ServiceLocator.repository
val context = LocalContext.current
val scope = rememberCoroutineScope()
var items by remember { mutableStateOf<List<BaseItem>>(emptyList()) }
var index by remember { mutableIntStateOf(0) }
var panelOpen by remember { mutableStateOf(false) }
var settingsOpen by remember { mutableStateOf(false) }
var loading by remember { mutableStateOf(true) }
var loadingMore by remember { mutableStateOf(false) }
var loadError by remember { mutableStateOf<String?>(null) }
var toast by remember(startupMessage) { mutableStateOf(startupMessage) }
var reloadKey by remember { mutableIntStateOf(0) }
val favoriteOverrides = remember { mutableStateMapOf<String, Boolean>() }
val favoriteMutations = remember { mutableStateMapOf<String, Int>() }
val rootFocus = remember { FocusRequester() }
val playFocus = remember { FocusRequester() }
val logoRotation = remember { Animatable(0f) }
val introBrandAlpha = remember { Animatable(0f) }
val slideRevealShade = remember { Animatable(0f) }
// Normalised slide progress in [0f,1f]. A single coroutine (the slide timer below)
// drives both this ring and the slide advance, so they share one clock and can't drift.
val slideProgress = remember { mutableFloatStateOf(0f) }
val lifecycleOwner = LocalLifecycleOwner.current
LaunchedEffect(reloadKey) {
loading = true
loadError = null
runCatching { repo.getScreensaverItems() }
.onSuccess { fetched ->
val queue = fetched.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.
val visible = items.getOrNull(index)
if (visible == null) {
items = queue
index = 0
} else {
items = listOf(visible) + queue.filter { it.id != visible.id }
index = 0
}
loading = false
}
.onFailure { loadError = friendlyEmbyError(it); loading = false }
}
// 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) {
runCatching { repo.getStartupBackdropMovie() }
.onSuccess { movie ->
if (movie != null && items.isEmpty()) {
items = listOf(movie)
loading = false
}
}
}
// Fetches another random batch in the background as the user nears the end of
// the current queue and appends the new (deduped) items — so the screensaver
// keeps surfacing fresh titles instead of looping the first batch forever. The
// queue is trimmed from the front to stay bounded over long uptimes.
fun maybePrefetch() {
val size = items.size
if (loadingMore || size == 0 || index < size - PREFETCH_AHEAD) return
loadingMore = true
toast = "Finding more from your library…"
scope.launch {
val more = runCatching { repo.getScreensaverItems() }.getOrDefault(emptyList())
if (more.isNotEmpty()) {
val existing = items.mapTo(HashSet()) { it.id }
val fresh = more.filter { it.id !in existing }
// If the library has nothing new, re-use the reshuffled batch so it
// still keeps moving (just in a different random order).
val refill = fresh.ifEmpty { more }.shuffled()
var merged = items + refill
if (merged.size > MAX_QUEUE) {
val drop = merged.size - TRIM_TO
merged = merged.drop(drop)
index = (index - drop).coerceAtLeast(0)
}
items = merged
toast = if (fresh.isNotEmpty()) {
"Queued ${fresh.size} more titles"
} else {
"Refreshed the queue with a new order"
}
} else {
toast = "No additional titles found"
}
loadingMore = false
}
}
fun advance() {
val size = items.size
if (size == 0) return
// Loop back only as a fallback if a refill hasn't landed yet.
index = if (index + 1 < size) index + 1 else 0
maybePrefetch()
}
// The one authoritative slide timer is defined below — after `hasContent` and the
// navigation helpers it depends on — so there is a single source of truth.
// Return focus to the root when the panel closes so key events keep flowing.
// Guarded: the Play button lives inside AnimatedVisibility and may attach a
// frame late; requestFocus() on an unattached requester would otherwise throw.
LaunchedEffect(panelOpen, settingsOpen, items.isEmpty()) {
if (settingsOpen) {
// SettingsSheet owns focus while it is on screen.
} else if (panelOpen) {
delay(50)
runCatching { playFocus.requestFocus() }
} else {
runCatching { rootFocus.requestFocus() }
}
}
// Auto-dismiss transient toasts.
LaunchedEffect(toast) {
if (toast != null) { delay(3500); toast = null }
}
val current = items.getOrNull(index)
val currentUpdated by rememberUpdatedState(current)
val isFav = current?.let { favoriteOverrides[it.id] ?: it.isFavorite } ?: false
val hasContent = items.isNotEmpty()
// Keep a real Emby backdrop available for the next cold start. Coil's disk cache
// makes this appear instantly in the usual case, before the fresh queue arrives.
LaunchedEffect(current?.id) {
current?.let { item ->
repo.backdropUrl(item)?.let { ServiceLocator.settings.setLastBackdropUrl(it) }
}
}
// A brief full turn gives the persistent brand mark a small, purposeful cue every
// time the current title changes — including manual previous/next navigation.
LaunchedEffect(index) {
logoRotation.snapTo(0f)
logoRotation.animateTo(360f, animationSpec = tween(durationMillis = 1_100))
}
LaunchedEffect(index) {
slideRevealShade.snapTo(0.38f)
slideRevealShade.animateTo(0f, animationSpec = tween(durationMillis = 1_100))
}
// A small first-launch signature: the app name appears beside the Emby mark and
// quietly disappears, leaving the artwork to take over.
LaunchedEffect(Unit) {
introBrandAlpha.snapTo(0f)
introBrandAlpha.animateTo(0.94f, animationSpec = tween(durationMillis = 500))
delay(1_900)
introBrandAlpha.animateTo(0f, animationSpec = tween(durationMillis = 900))
}
fun next() { if (items.isNotEmpty()) { index = (index + 1) % items.size; maybePrefetch() } }
fun prev() { if (items.isNotEmpty()) index = (index - 1 + items.size) % items.size }
// Single authoritative slide timer. One lifecycle-aware coroutine animates the
// progress ring 0f→1f over the slide duration and then advances, so the ring and
// the slide change share one clock and cannot drift. It restarts (snapping progress
// back to 0) whenever the slide changes (auto or manual), the queue reloads, or the
// panel opens/closes. repeatOnLifecycle pauses it while the Activity/DreamService is
// below RESUMED and resets it on resume; leaving the composition cancels it, so no
// duplicate timers survive a lifecycle change. Not keyed on the items list, so
// background refills don't restart it.
LaunchedEffect(index, panelOpen, settingsOpen, reloadKey, hasContent) {
if (panelOpen || settingsOpen || !hasContent) return@LaunchedEffect
lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) {
val durationMillis = repo.rotationIntervalMillis()
slideProgress.floatValue = 0f
// Drive progress from real elapsed frame time rather than a tween, so each
// slide lasts exactly `durationMillis` and keeps progressing even when the
// system animator-duration scale is 0. withFrameMillis keeps it smooth.
val startMillis = withFrameMillis { it }
var elapsed = 0L
while (elapsed < durationMillis) {
elapsed = withFrameMillis { it } - startMillis
slideProgress.floatValue = (elapsed.toFloat() / durationMillis).coerceIn(0f, 1f)
}
advance()
}
}
fun playTrailer(item: BaseItem?) {
val target = item ?: return
toast = "Finding trailer…"
scope.launch {
runCatching { repo.getLocalTrailer(target.id) }
.onSuccess { trailer ->
if (trailer == null) {
toast = "No trailer is available for ${target.name}."
} else {
runCatching { repo.resolvePlayable(trailer) }
.onSuccess { onPlay(it.url, "${target.name} trailer") }
.onFailure { toast = friendlyEmbyError(it) }
}
}
.onFailure { toast = friendlyEmbyError(it) }
}
}
fun setFavorite(desired: Boolean) {
val item = current ?: return
val previous = favoriteOverrides[item.id] ?: item.isFavorite
val mutation = (favoriteMutations[item.id] ?: 0) + 1
favoriteMutations[item.id] = mutation
favoriteOverrides[item.id] = desired // optimistic
toast = if (desired) "Added to favourites" else "Removed from favourites"
scope.launch {
runCatching { repo.setFavorite(item.id, desired) }
.onSuccess { if (favoriteMutations[item.id] == mutation) favoriteOverrides[item.id] = it }
.onFailure {
if (favoriteMutations[item.id] == mutation) {
favoriteOverrides[item.id] = previous
toast = friendlyEmbyError(it)
}
}
}
}
// Let a host (DreamService) trigger playback from the media key.
DisposableEffect(actions) {
actions?.playCurrent = { playTrailer(currentUpdated) }
onDispose { actions?.playCurrent = null }
}
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black)
.focusRequester(rootFocus)
.focusable()
.onPreviewKeyEvent { event ->
if (event.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false
// The slide root normally owns directional navigation. Once Settings is
// open, leave those keys to the sheet's focused controls; only Back is
// handled here so it reliably dismisses the overlay.
if (settingsOpen) {
return@onPreviewKeyEvent when (event.key) {
Key.Back -> { settingsOpen = false; true }
else -> false
}
}
when (event.key) {
Key.Back -> {
when {
panelOpen -> { panelOpen = false; true }
else -> { onExit(); true }
}
}
Key.MediaPlay, Key.MediaPlayPause -> { playTrailer(current); true }
else -> if (!hasContent) {
// Loading / error state: OK retries, other keys ignored.
when (event.key) {
Key.DirectionCenter, Key.Enter, Key.NumPadEnter -> {
if (loadError != null) reloadKey++; true
}
else -> false
}
} else when (event.key) {
Key.DirectionUp, Key.DirectionDown -> { panelOpen = !panelOpen; true }
Key.DirectionCenter, Key.Enter, Key.NumPadEnter ->
if (!panelOpen) { panelOpen = true; true } else false
Key.DirectionRight -> if (!panelOpen) { next(); true } else false
Key.DirectionLeft -> if (!panelOpen) { prev(); true } else false
else -> false
}
}
},
contentAlignment = Alignment.Center,
) {
if (current == null) {
if (warmBackdropUrl != null) {
AsyncImage(
model = ImageRequest.Builder(context).data(warmBackdropUrl).crossfade(false).build(),
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize(),
)
} else {
// First-ever start: a calm loading surface instead of a blank black frame.
Box(
Modifier.fillMaxSize().background(
Brush.linearGradient(
listOf(Color(0xFF17232B), Color(0xFF0B0E11), Color(0xFF101D17)),
),
),
)
}
}
// Backdrop with a cross-fade between items.
Crossfade(
targetState = current,
animationSpec = tween(durationMillis = 1200),
label = "backdrop",
) { item ->
val url = item?.let { repo.backdropUrl(it) }
if (url != null) {
AsyncImage(
model = ImageRequest.Builder(context).data(url).crossfade(false).build(),
contentDescription = item.name,
contentScale = ContentScale.Crop,
modifier = Modifier
.fillMaxSize()
.graphicsLayer {
// Keep 60fps zoom work in the draw layer, avoiding full
// recomposition of all slide content on every frame.
val p = slideProgress.floatValue.coerceIn(0f, 1f)
val eased = p * p * (3f - 2f * p)
val scale = 1.28f - (0.28f * eased)
scaleX = scale
scaleY = scale
},
)
}
}
// A short shadow reveal makes the backdrop transition more deliberate without
// obscuring the title treatment that follows it.
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = slideRevealShade.value)),
)
// L-shaped scrim (bottom + left) so text stays readable even over bright
// or near-white backdrops. The left gradient anchors the text column; the
// bottom gradient covers the info/actions area.
Box(
modifier = Modifier
.fillMaxSize()
.background(
Brush.verticalGradient(
0.30f to Color.Transparent,
1f to Color(0xF7000000),
)
)
)
Box(
modifier = Modifier
.fillMaxSize()
.background(
Brush.horizontalGradient(
0f to Color(0xC0000000),
0.55f to Color.Transparent,
)
)
)
// Persistent Emby brand mark in the very top-left, shown on every slide. Purely
// decorative — takes no focus and makes no accessibility announcement.
Row(
modifier = Modifier
.align(Alignment.TopStart)
.padding(start = 32.dp, top = 32.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(14.dp),
) {
Image(
painter = painterResource(R.drawable.emby_logo),
contentDescription = null,
modifier = Modifier
.size(64.dp)
.graphicsLayer { rotationZ = logoRotation.value }
.clearAndSetSemantics {},
)
Text(
text = "Memby",
color = Color.White,
fontSize = 25.sp,
fontWeight = FontWeight.SemiBold,
style = TextStyle(shadow = TextShadow),
modifier = Modifier.graphicsLayer { alpha = introBrandAlpha.value },
)
}
if (current != null) {
InfoAndActions(
item = current,
isFavorite = isFav,
panelOpen = panelOpen,
showTitleLogo = showTitleLogo,
playFocus = playFocus,
onPlay = { playTrailer(current) },
onToggleFavorite = { setFavorite(!isFav) },
onNext = ::next,
onPrev = ::prev,
onOpenSettings = { settingsOpen = true },
onExit = onExit,
modifier = Modifier.align(Alignment.BottomStart),
)
}
if (!hasContent) {
Text(
text = when {
loading -> "Memby starting up…."
loadError != null -> "$loadError\n\nPress OK to retry."
else -> "No movies or shows with backdrops were found."
},
color = Color.White,
fontSize = 22.sp,
modifier = Modifier.padding(48.dp),
)
}
toast?.let {
Box(
modifier = Modifier
.align(Alignment.TopEnd)
.padding(32.dp)
.background(Color(0xCC000000), RoundedCornerShape(8.dp))
.padding(horizontal = 20.dp, vertical = 12.dp),
) {
Text(text = it, color = Color.White, fontSize = 16.sp)
}
}
// Subtle circular slide-progress indicator (bottom-right). Hidden while the
// panel is open (slideshow paused) or before content loads. Reads progress in
// the draw phase so it repaints per frame without recomposing the slideshow.
if (hasContent && !panelOpen) {
SlideProgressRing(
progress = { slideProgress.floatValue },
color = ringColor,
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(end = 48.dp, bottom = 43.dp),
)
}
CurrentTime(
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(end = 98.dp, bottom = 41.dp),
)
if (settingsOpen) {
SettingsSheet(
editableServer = true,
onClose = { settingsOpen = false },
onInstallerLaunched = onExit,
)
}
}
}
/** A high-legibility, always-current clock beside the slide-progress indicator. */
@Composable
private fun CurrentTime(modifier: Modifier = Modifier) {
var now by remember { mutableStateOf(System.currentTimeMillis()) }
LaunchedEffect(Unit) {
while (true) {
now = System.currentTimeMillis()
// Tick exactly after the next minute changes, rather than polling each second.
delay(60_000L - (now % 60_000L) + 30L)
}
}
val formatter = remember { DateFormat.getTimeInstance(DateFormat.SHORT) }
Text(
text = formatter.format(Date(now)),
color = Color.White.copy(alpha = 0.92f),
fontSize = 30.sp,
fontWeight = FontWeight.SemiBold,
style = TextStyle(shadow = TextShadow),
modifier = modifier,
)
}
/**
* A small, understated circular slide-progress indicator. Fills clockwise from the
* 12 o'clock position over the slide duration. Purely decorative: it takes no focus
* and makes no accessibility announcements.
*
* [progress] is read lazily inside the draw phase (a `() -> Float`) so the ring
* repaints each frame as the value animates without recomposing its caller.
*/
@Composable
private fun SlideProgressRing(
progress: () -> Float,
color: Color,
modifier: Modifier = Modifier,
) {
Canvas(
modifier = modifier
.size(32.dp)
.clearAndSetSemantics {},
) {
val stroke = 3.dp.toPx()
val inset = stroke / 2f
val arcSize = Size(size.width - stroke, size.height - stroke)
val topLeft = Offset(inset, inset)
// Faint dark disc so the ring stays legible over bright/near-white artwork.
drawCircle(color = Color.Black.copy(alpha = 0.30f), radius = size.minDimension / 2f)
// Thin semi-transparent background ring.
drawArc(
color = Color.White.copy(alpha = 0.28f),
startAngle = 0f,
sweepAngle = 360f,
useCenter = false,
topLeft = topLeft,
size = arcSize,
style = Stroke(width = stroke, cap = StrokeCap.Round),
)
// Foreground arc (the chosen colour) showing elapsed progress, clockwise from
// 12 o'clock.
drawArc(
color = color.copy(alpha = 0.90f),
startAngle = -90f,
sweepAngle = SlideProgressMath.sweepAngle(progress()),
useCenter = false,
topLeft = topLeft,
size = arcSize,
style = Stroke(width = stroke, cap = StrokeCap.Round),
)
}
}
/** Parses an RRGGBB hex string to an opaque [Color], falling back to white. */
internal fun ringColorFromHex(hex: String?): Color =
runCatching { Color(("FF" + (hex ?: "").removePrefix("#").trim()).toLong(16)) }
.getOrDefault(Color.White)
/** Pure geometry for [SlideProgressRing], split out so it is unit-testable. */
internal object SlideProgressMath {
/** Clockwise sweep in degrees for a normalised [progress], clamped to [0f,1f]. */
fun sweepAngle(progress: Float): Float = progress.coerceIn(0f, 1f) * 360f
}
@Composable
private fun InfoAndActions(
item: BaseItem,
isFavorite: Boolean,
panelOpen: Boolean,
showTitleLogo: Boolean,
playFocus: FocusRequester,
onPlay: () -> Unit,
onToggleFavorite: () -> Unit,
onNext: () -> Unit,
onPrev: () -> Unit,
onOpenSettings: () -> Unit,
onExit: () -> Unit,
modifier: Modifier = Modifier,
) {
val tagline = item.taglines
.asSequence()
.map { it.trim().trim('"') }
.firstOrNull { it.length > 2 }
Row(
modifier = modifier
.fillMaxWidth()
// Without a tagline the block is shorter; lift it to retain the same
// visual balance rather than letting the title fall toward the controls.
.padding(start = 56.dp, bottom = 30.dp, end = 56.dp),
horizontalArrangement = Arrangement.spacedBy(28.dp),
verticalAlignment = Alignment.Bottom,
) {
// Poster (only meaningful once the user opens the panel).
if (panelOpen) {
val repo = ServiceLocator.repository
val posterUrl = repo.primaryUrl(item)
if (posterUrl != null) {
AsyncImage(
model = posterUrl,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier
.width(150.dp)
.height(225.dp)
.clip(RoundedCornerShape(10.dp))
.background(Color(0xFF1A2027)),
)
}
}
Column(
// Wider on big screens so titles and the synopsis run further across.
// A little narrower when the panel (and poster) is open to leave room.
modifier = Modifier.fillMaxWidth(if (panelOpen) 0.74f else 0.82f),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
// Prefer the item's Emby "Logo" artwork over a plain-text title, when the
// user has it enabled and this item actually has a logo image.
val logoUrl = if (showTitleLogo) ServiceLocator.repository.logoUrl(item) else null
if (!useTextTitleForLogo(logoUrl)) {
AsyncImage(
model = logoUrl,
contentDescription = item.name,
contentScale = ContentScale.Fit,
alignment = Alignment.CenterStart,
modifier = Modifier
// A fixed logo stage keeps artwork consistently sized even when
// Emby supplies very wide or compact title treatments.
.width(320.dp)
.height(96.dp),
)
} else {
Text(
text = item.name,
color = Color.White,
fontSize = 46.sp,
fontWeight = FontWeight.Bold,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
style = TextStyle(shadow = TextShadow),
)
}
tagline?.let { tagline ->
Text(
text = tagline,
color = Color(0xFFE4E8EC),
fontSize = 21.sp,
fontStyle = FontStyle.Italic,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
style = TextStyle(shadow = TextShadow),
)
}
MediaMetadata(item)
// Keep the compact facts visually distinct from the plot synopsis.
Spacer(Modifier.height(6.dp))
StatusChips(item = item, isFavorite = isFavorite)
item.overview?.takeIf { it.isNotBlank() }?.let {
Text(
text = it,
color = Color(0xFFE4E8EC),
fontSize = 19.sp,
lineHeight = 26.sp,
maxLines = if (panelOpen) 6 else 4,
overflow = TextOverflow.Ellipsis,
style = TextStyle(shadow = TextShadow),
)
}
AnimatedVisibility(
visible = panelOpen,
enter = fadeIn(),
exit = fadeOut(),
) {
Row(
modifier = Modifier.fillMaxWidth().padding(top = 12.dp),
horizontalArrangement = Arrangement.spacedBy(14.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Button(onClick = onPlay, modifier = Modifier.focusRequester(playFocus)) {
Icon(Icons.Default.PlayArrow, contentDescription = null)
Text(text = " Play trailer", modifier = Modifier.padding(start = 4.dp))
}
Button(onClick = onToggleFavorite) {
Icon(
imageVector = if (isFavorite) Icons.Default.Favorite else Icons.Default.FavoriteBorder,
contentDescription = null,
)
Text(
text = if (isFavorite) " Remove favourite" else " Add favourite",
modifier = Modifier.padding(start = 4.dp),
)
}
Spacer(Modifier.weight(1f))
Button(onClick = onPrev) { Icon(Icons.Default.ChevronLeft, contentDescription = "Previous") }
Button(onClick = onNext) { Icon(Icons.Default.ChevronRight, contentDescription = "Next") }
Button(onClick = onOpenSettings) {
Icon(Icons.Default.Settings, contentDescription = "Settings")
}
Button(onClick = onExit) {
Icon(Icons.Default.Close, contentDescription = "Exit screensaver")
}
}
}
if (!panelOpen) {
Text(
text = "▲ options · ◄ ► change · ▶ play",
color = Color(0x99FFFFFF),
fontSize = 15.sp,
modifier = Modifier.padding(top = 6.dp).width(560.dp),
)
}
}
}
}
/**
* 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.getPixel(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 {
if (isFavorite) add("♥ Favourite")
val ud = item.userData
when {
ud?.played == true -> add("✓ Watched")
(ud?.playbackPositionTicks ?: 0L) > 0L -> add("▶ Resume")
}
}
if (chips.isEmpty()) return
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
chips.forEach { label ->
Box(
modifier = Modifier
.background(Color(0x33FFFFFF), RoundedCornerShape(6.dp))
.padding(horizontal = 12.dp, vertical = 5.dp),
) {
Text(text = label, color = Color.White, fontSize = 14.sp)
}
}
}
}
@Composable
private fun MessageScreen(text: String, onExit: () -> Unit) {
val focus = remember { FocusRequester() }
LaunchedEffect(Unit) { runCatching { focus.requestFocus() } }
Box(
modifier = Modifier
.fillMaxSize()
.background(
Brush.linearGradient(
listOf(Color(0xFF17232B), Color(0xFF0B0E11), Color(0xFF101D17)),
),
)
.focusRequester(focus)
.focusable()
.onPreviewKeyEvent { event ->
if (event.type == KeyEventType.KeyDown && event.key == Key.Back) { onExit(); true } else false
},
contentAlignment = Alignment.Center,
) {
Text(
text = text,
color = Color.White,
fontSize = 24.sp,
modifier = Modifier.fillMaxWidth(0.6f).padding(48.dp),
)
}
}
/** Compact, icon-led metadata: the media type remains clear without a bulky text label. */
@Composable
private fun MediaMetadata(item: BaseItem) {
val metadataColor = Color(0xFFC7CED4)
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Icon(
imageVector = if (item.isSeries) Icons.Default.LiveTv else Icons.Default.Movie,
contentDescription = if (item.isSeries) "Series" else "Movie",
tint = metadataColor,
modifier = Modifier.size(20.dp),
)
item.productionYear?.let { MetadataText(it.toString(), metadataColor) }
item.communityRating?.let { MetadataText("${"%.1f".format(it)}", metadataColor) }
item.runtimeMinutes?.let {
Icon(Icons.Default.AccessTime, contentDescription = "Runtime", tint = metadataColor, modifier = Modifier.size(18.dp))
MetadataText("$it min", metadataColor)
}
item.studios.firstOrNull { it.name.isNotBlank() }?.name?.let {
Icon(Icons.Default.Business, contentDescription = "Studio", tint = metadataColor, modifier = Modifier.size(18.dp))
MetadataText(it, metadataColor)
}
item.genres.firstOrNull()?.takeIf { it.isNotBlank() }?.let {
Icon(Icons.Default.LocalOffer, contentDescription = "Genre", tint = metadataColor, modifier = Modifier.size(18.dp))
MetadataText(it, metadataColor)
}
}
}
@Composable
private fun MetadataText(text: String, color: Color) {
Text(
text = text,
color = color,
fontSize = 18.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = TextStyle(shadow = TextShadow),
)
}
@@ -0,0 +1,420 @@
package com.ponzischeme89.memby.ui.settings
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.slideInHorizontally
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.focusable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.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.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
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.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.tv.material3.Button
import androidx.tv.material3.Icon
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import com.ponzischeme89.memby.R
import com.ponzischeme89.memby.ServiceLocator
import com.ponzischeme89.memby.data.Settings
import com.ponzischeme89.memby.update.UpdateChecker
import com.ponzischeme89.memby.update.UpdateStatus
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
private data class SpinnerOption(val label: String, val hex: String, val color: Color)
private val spinnerOptions = listOf(
SpinnerOption("White", "FFFFFF", Color.White),
SpinnerOption("Emby green", "52B54B", Color(0xFF52B54B)),
SpinnerOption("Netflix red", "E50914", Color(0xFFE50914)),
)
private val Muted = Color(0xFF9AA3AC)
private val Faint = Color(0xFFB9C0C7)
private val SettingsSurface = Color(0xFF1A1A1A)
private val FocusSurface = Color(0xFF3D3D3D)
/**
* A polished settings panel that slides out from the right edge over a dimming scrim.
* Shared by the home screen and the in-slideshow overlay so both look and behave the
* same. It reads/writes the shared [Settings] via [ServiceLocator].
*
* Hosts own the Back key (this composable can't assume an OnBackPressedDispatcher — the
* DreamService has none): the home screen wraps it in a BackHandler, the slideshow
* closes it from its own key handler. A visible "Close" button is always provided too.
*
* @param editableServer when true, shows the Gitea URL/repo/token fields (home screen).
* The in-slideshow overlay passes false, since a soft keyboard isn't usable there.
* @param overlay true when shown over a slide; false when it is the launcher activity.
* @param onInstallerLaunched called after Android's package installer has been opened.
* A Dream host uses this to release its window before the APK replacement kills it.
*/
@Composable
fun SettingsSheet(
editableServer: Boolean,
onClose: () -> Unit,
overlay: Boolean = true,
onInstallerLaunched: (() -> Unit)? = null,
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
val store = ServiceLocator.settings
val scope = rememberCoroutineScope()
val checker = remember { UpdateChecker(context) }
val settings by ServiceLocator.repository.settingsFlow.collectAsState(initial = Settings.EMPTY)
var showLogo by rememberSaveable { mutableStateOf(settings.showTitleLogo) }
var ringColor by rememberSaveable { mutableStateOf(settings.ringColorHex) }
var baseUrl by rememberSaveable { mutableStateOf(settings.updateBaseUrl.orEmpty()) }
var repoPath by rememberSaveable { mutableStateOf(settings.updateRepo.orEmpty()) }
var token by rememberSaveable { mutableStateOf(settings.updateToken.orEmpty()) }
var homeSections by rememberSaveable { mutableStateOf(settings.homeSections.split(',')) }
var cardDensity by rememberSaveable { mutableStateOf(settings.homeCardDensity) }
var showCardMetadata by rememberSaveable { mutableStateOf(settings.showHomeCardMetadata) }
var checking by remember { mutableStateOf(false) }
var status by remember { mutableStateOf<UpdateStatus?>(null) }
var installMessage by remember { mutableStateOf<String?>(null) }
// DataStore arrives after the first composition. Mirror its snapshot into the
// editable state so opening the panel always shows the user's actual choices,
// rather than the empty/default placeholder used while it loads.
LaunchedEffect(
settings.showTitleLogo,
settings.ringColorHex,
settings.updateBaseUrl,
settings.updateRepo,
settings.updateToken,
settings.homeSections,
settings.homeCardDensity,
settings.showHomeCardMetadata,
) {
showLogo = settings.showTitleLogo
ringColor = settings.ringColorHex
baseUrl = settings.updateBaseUrl.orEmpty()
repoPath = settings.updateRepo.orEmpty()
token = settings.updateToken.orEmpty()
homeSections = settings.homeSections.split(',')
cardDensity = settings.homeCardDensity
showCardMetadata = settings.showHomeCardMetadata
}
val firstFocus = remember { FocusRequester() }
var shown by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
shown = true
// AnimatedVisibility does not attach its child until the following frame. Waiting
// for the entrance transition means focus moves from the slide to this panel
// reliably on every remote, rather than silently failing on an unattached node.
delay(170)
runCatching { firstFocus.requestFocus() }
}
Box(modifier = modifier.fillMaxSize()) {
if (overlay) {
// Scrim dims the slide behind the panel, while leaving its artwork visible.
Box(
Modifier
.fillMaxSize()
.background(Color(0x99000000))
.clickable(
interactionSource = remember { androidx.compose.foundation.interaction.MutableInteractionSource() },
indication = null,
onClick = onClose,
)
)
} else {
Box(Modifier.fillMaxSize().background(Color(0xFF0B0E11)))
}
AnimatedVisibility(
visible = shown,
enter = slideInHorizontally(animationSpec = tween(150)) { it } + fadeIn(tween(150)),
modifier = Modifier.align(if (overlay) Alignment.CenterEnd else Alignment.Center),
) {
Column(
modifier = Modifier
.width(if (overlay) 620.dp else 900.dp)
.fillMaxHeight()
.background(SettingsSurface)
.verticalScroll(rememberScrollState())
.padding(horizontal = 52.dp, vertical = 44.dp),
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text("Settings", color = Color.White, fontSize = 32.sp, fontWeight = FontWeight.Medium)
Spacer(Modifier.weight(1f))
Button(onClick = onClose) {
Icon(Icons.Default.Close, contentDescription = null)
}
}
SectionLabel("APPEARANCE")
TvSettingsRow(
onClick = {
showLogo = !showLogo
scope.launch { store.setShowTitleLogo(showLogo) }
},
modifier = Modifier.fillMaxWidth().focusRequester(firstFocus),
title = "Title logos",
description = "Use Emby artwork when available",
value = if (showLogo) "On" else "Off",
)
Text("Progress ring colour", color = Faint, fontSize = 15.sp, fontWeight = FontWeight.SemiBold)
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
spinnerOptions.forEach { opt ->
Button(
onClick = {
ringColor = opt.hex
scope.launch { store.setRingColor(opt.hex) }
},
) { Text(if (ringColor.equals(opt.hex, ignoreCase = true)) "${opt.label}" else opt.label) }
}
}
Divider()
SectionLabel("HOME SCREEN")
Text("Choose the rows shown on your home screen", color = Muted, fontSize = 14.sp)
listOf("continue" to "Continue watching", "favorites" to "Favorites", "latest" to "Latest movies").forEach { (key, label) ->
TvSettingsRow(
onClick = {
homeSections = if (key in homeSections) homeSections - key else homeSections + key
scope.launch { store.setHomeSections(homeSections) }
},
title = label,
description = "Show this row on the home screen",
value = if (key in homeSections) "On" else "Off",
)
}
Text("Card size", color = Faint, fontSize = 15.sp, fontWeight = FontWeight.SemiBold)
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
listOf("compact" to "Compact", "standard" to "Standard", "large" to "Large").forEach { (value, label) ->
Button(onClick = {
cardDensity = value
scope.launch { store.setHomeCardDensity(value) }
}) { Text(if (cardDensity == value) "$label" else label) }
}
}
TvSettingsRow(
onClick = {
showCardMetadata = !showCardMetadata
scope.launch { store.setShowHomeCardMetadata(showCardMetadata) }
},
title = "Card details",
description = "Show episode, runtime, and resume information",
value = if (showCardMetadata) "On" else "Off",
)
Divider()
SectionLabel("UPDATES")
if (editableServer) {
SheetTextField(
label = "Gitea URL",
value = baseUrl,
onValueChange = { baseUrl = it; status = null },
keyboardType = KeyboardType.Uri,
)
SheetTextField(
label = "Repository (owner/repo)",
value = repoPath,
onValueChange = { repoPath = it; status = null },
)
SheetTextField(
label = "Access token",
value = token,
onValueChange = { token = it; status = null },
isPassword = true,
)
} else {
Text(
"Set the update server on the home screen to check for updates here.",
color = Muted,
fontSize = 13.sp,
)
}
TvSettingsRow(
onClick = {
if (!checking) {
checking = true
status = null
installMessage = null
scope.launch {
if (editableServer) store.setUpdateConfig(baseUrl, repoPath, token)
val s = settings
status = checker.check(
s.updateBaseUrl.orEmpty().ifEmpty { baseUrl },
s.updateRepo.orEmpty().ifEmpty { repoPath },
s.updateToken.orEmpty().ifEmpty { token },
)
checking = false
}
}
},
title = "Check for updates",
description = "Installed version ${checker.installedVersion}",
value = if (checking) "Checking…" else "",
)
when (val s = status) {
is UpdateStatus.UpToDate ->
Text("You're on the latest version (${s.version}).", color = Color(0xFF7BD88F), fontSize = 14.sp)
is UpdateStatus.Error -> Text(s.message, color = Color(0xFFFF6B6B), fontSize = 14.sp)
is UpdateStatus.Available -> {
Text(
"Update available: ${s.version}",
color = Color(0xFF7BD88F),
fontSize = 16.sp,
fontWeight = FontWeight.SemiBold,
)
if (s.notes.isNotBlank()) Text(s.notes, color = Faint, fontSize = 13.sp)
Button(
onClick = {
installMessage = "Downloading update…"
scope.launch {
val result = checker.downloadAndInstall(s.apkUrl, token.ifEmpty { settings.updateToken.orEmpty() })
result.exceptionOrNull()?.let {
installMessage = it.message
} ?: run {
installMessage = "Opening the installer…"
// The installer is now foreground. Stop an active Dream so
// it cannot retain a black system window during replacement.
onInstallerLaunched?.invoke()
}
}
},
) { Text("Download & install") }
}
null -> {}
}
installMessage?.let { Text(it, color = Faint, fontSize = 13.sp) }
Divider()
SectionLabel("ABOUT")
Text(
"${stringResource(R.string.app_name)} ${checker.installedVersion} · by " +
stringResource(R.string.developer_name),
color = Muted,
fontSize = 13.sp,
)
}
}
}
}
@Composable
private fun SectionLabel(text: String) {
Text(text, color = Muted, fontSize = 12.sp, fontWeight = FontWeight.Bold, letterSpacing = 2.sp)
}
@Composable
private fun Divider() {
Box(Modifier.fillMaxWidth().height(1.dp).background(MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f)))
}
/** A restrained Android TV-style preference row with a clear remote-focus state. */
@Composable
private fun TvSettingsRow(
title: String,
description: String,
value: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
var focused by remember { mutableStateOf(false) }
Row(
modifier = modifier
.fillMaxWidth()
.clip(RoundedCornerShape(4.dp))
.background(if (focused) FocusSurface else Color.Transparent)
.onFocusChanged { focused = it.isFocused }
.focusable()
.clickable(onClick = onClick)
.padding(horizontal = 22.dp, vertical = 18.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Column(Modifier.weight(1f)) {
Text(title, color = Color.White, fontSize = 19.sp)
Text(description, color = Muted, fontSize = 14.sp)
}
if (value.isNotBlank()) Text(value, color = Color.White, fontSize = 17.sp)
}
}
@Composable
private fun SheetTextField(
label: String,
value: String,
onValueChange: (String) -> Unit,
isPassword: Boolean = false,
keyboardType: KeyboardType = KeyboardType.Text,
) {
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text(label, color = Muted, fontSize = 13.sp)
Box(
modifier = Modifier
.fillMaxWidth()
.border(1.dp, MaterialTheme.colorScheme.onSurface.copy(alpha = 0.22f), RoundedCornerShape(8.dp))
.background(MaterialTheme.colorScheme.background, RoundedCornerShape(8.dp))
.padding(horizontal = 14.dp, vertical = 12.dp),
) {
BasicTextField(
value = value,
onValueChange = onValueChange,
singleLine = true,
textStyle = TextStyle(color = Color.White, fontSize = 18.sp),
cursorBrush = SolidColor(Color.White),
visualTransformation = if (isPassword) PasswordVisualTransformation() else VisualTransformation.None,
keyboardOptions = KeyboardOptions(keyboardType = if (isPassword) KeyboardType.Password else keyboardType),
modifier = Modifier.fillMaxWidth(),
)
}
}
}
@@ -0,0 +1,28 @@
package com.ponzischeme89.memby.ui.theme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.text.font.FontFamily
import android.graphics.Typeface
import androidx.tv.material3.LocalTextStyle
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.darkColorScheme
private val EmbyColors = darkColorScheme(
primary = androidx.compose.ui.graphics.Color(0xFF52B54B),
onPrimary = androidx.compose.ui.graphics.Color.White,
surface = androidx.compose.ui.graphics.Color(0xFF101418),
background = androidx.compose.ui.graphics.Color(0xFF0B0E11),
)
@Composable
fun MembyTheme(content: @Composable () -> Unit) {
// Android's native medium face is always available on TV, so it looks refined
// without a downloaded font or a first-render font swap.
val tvFont = FontFamily(Typeface.create("sans-serif-medium", Typeface.NORMAL))
MaterialTheme(colorScheme = EmbyColors) {
CompositionLocalProvider(LocalTextStyle provides LocalTextStyle.current.copy(fontFamily = tvFont)) {
content()
}
}
}
@@ -0,0 +1,155 @@
package com.ponzischeme89.memby.update
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.provider.Settings
import androidx.core.content.FileProvider
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import okhttp3.OkHttpClient
import okhttp3.Request
import java.io.File
import java.util.concurrent.TimeUnit
/** Result of a "check for updates" against the configured Gitea release. */
sealed interface UpdateStatus {
/** A newer release with a downloadable APK is available. */
data class Available(val version: String, val apkUrl: String, val notes: String) : UpdateStatus
/** The latest release is not newer than what's installed. */
data class UpToDate(val version: String) : UpdateStatus
/** Something went wrong; [message] is safe to show on screen. */
data class Error(val message: String) : UpdateStatus
}
/**
* Checks a Gitea repository's latest release for a newer APK and, when the user
* confirms, downloads it and hands it to the system package installer.
*
* Gitea exposes a GitHub-compatible API: GET /api/v1/repos/{owner}/{repo}/releases/latest.
* A personal access token is sent for private repos (both for the API call and the
* asset download).
*/
class UpdateChecker(private val context: Context) {
private val json = Json { ignoreUnknownKeys = true }
private val http = OkHttpClient.Builder()
.connectTimeout(15, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.build()
/** The installed versionName (e.g. "1.0"), or "?" if it can't be read. */
val installedVersion: String
get() = runCatching {
context.packageManager.getPackageInfo(context.packageName, 0).versionName
}.getOrNull() ?: "?"
suspend fun check(baseUrl: String, repo: String, token: String): UpdateStatus =
withContext(Dispatchers.IO) {
val host = baseUrl.trim().trimEnd('/')
val repoPath = repo.trim().trim('/')
if (host.isEmpty() || repoPath.isEmpty()) {
return@withContext UpdateStatus.Error("Set the Gitea URL and repository first.")
}
val url = "$host/api/v1/repos/$repoPath/releases/latest"
val release = runCatching {
val req = Request.Builder().url(url).apply {
if (token.isNotBlank()) header("Authorization", "token ${token.trim()}")
}.build()
http.newCall(req).execute().use { resp ->
if (!resp.isSuccessful) {
return@withContext UpdateStatus.Error(
when (resp.code) {
401, 403 -> "Update check unauthorized — check the access token."
404 -> "No releases found at $repoPath."
else -> "Update server error (${resp.code})."
}
)
}
json.decodeFromString<GiteaRelease>(resp.body?.string().orEmpty())
}
}.getOrElse {
return@withContext UpdateStatus.Error("Couldn't reach the update server.")
}
val apk = release.assets.firstOrNull { it.name.endsWith(".apk", ignoreCase = true) }
?: return@withContext UpdateStatus.Error("Latest release has no APK attached.")
val latest = release.tagName.ifBlank { release.name }
return@withContext if (isNewer(latest, installedVersion)) {
UpdateStatus.Available(
version = normalizeVersion(latest),
apkUrl = apk.browserDownloadUrl,
notes = release.body.trim(),
)
} else {
UpdateStatus.UpToDate(installedVersion)
}
}
/**
* Downloads the APK and launches the system installer. On Android O+ the app
* needs the "install unknown apps" permission; if it's missing we send the user
* to that settings screen and return a message asking them to retry.
*/
suspend fun downloadAndInstall(apkUrl: String, token: String): Result<Unit> =
withContext(Dispatchers.IO) {
// Gate on the install-unknown-apps permission before spending a download.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O &&
!context.packageManager.canRequestPackageInstalls()
) {
runCatching {
val intent = Intent(
Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES,
Uri.parse("package:${context.packageName}"),
).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
context.startActivity(intent)
}
return@withContext Result.failure(
IllegalStateException("Allow Memby to install apps, then check again.")
)
}
runCatching {
val file = File(context.cacheDir, "memby-update.apk")
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 uri = FileProvider.getUriForFile(
context, "${context.packageName}.fileprovider", file,
)
val install = Intent(Intent.ACTION_VIEW).apply {
setDataAndType(uri, "application/vnd.android.package-archive")
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
context.startActivity(install)
}
}
/** True when [remote] parses to a strictly higher version than [installed]. */
private fun isNewer(remote: String, installed: String): Boolean {
val r = parseVersion(remote)
val i = parseVersion(installed)
for (k in 0 until maxOf(r.size, i.size)) {
val rv = r.getOrElse(k) { 0 }
val iv = i.getOrElse(k) { 0 }
if (rv != iv) return rv > iv
}
return false
}
private fun parseVersion(v: String): List<Int> =
normalizeVersion(v).split('.', '-', ' ', '+').mapNotNull { it.toIntOrNull() }
private fun normalizeVersion(v: String): String = v.trim().trimStart('v', 'V')
}
@@ -0,0 +1,21 @@
package com.ponzischeme89.memby.update
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/** Subset of a Gitea (GitHub-compatible) release we care about. */
@Serializable
data class GiteaRelease(
@SerialName("tag_name") val tagName: String = "",
@SerialName("name") val name: String = "",
@SerialName("body") val body: String = "",
@SerialName("draft") val draft: Boolean = false,
@SerialName("prerelease") val prerelease: Boolean = false,
@SerialName("assets") val assets: List<GiteaAsset> = emptyList(),
)
@Serializable
data class GiteaAsset(
@SerialName("name") val name: String = "",
@SerialName("browser_download_url") val browserDownloadUrl: String = "",
)
@@ -0,0 +1,28 @@
package com.ponzischeme89.memby.update
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import com.ponzischeme89.memby.ui.screensaver.ScreensaverActivity
/**
* Reopens the launcher 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.
*/
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 {
addFlags(
Intent.FLAG_ACTIVITY_NEW_TASK or
Intent.FLAG_ACTIVITY_CLEAR_TOP or
Intent.FLAG_ACTIVITY_SINGLE_TOP,
)
}
runCatching { context.startActivity(launch) }
}
}
+16
View File
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="320dp"
android:height="180dp"
android:viewportWidth="320"
android:viewportHeight="180">
<path
android:fillColor="#0B1220"
android:pathData="M0,0 h320 v180 h-320 z" />
<path
android:fillColor="#52B54B"
android:pathData="M130,60 L130,120 L180,90 Z" />
<path
android:fillColor="#FFFFFF"
android:pathData="M40,140 h240 v6 h-240 z" />
</vector>
Binary file not shown.

After

Width:  |  Height:  |  Size: 849 KiB

+6
View File
@@ -0,0 +1,6 @@
<resources>
<string name="app_name">Memby</string>
<string name="screensaver_name">Memby Screensaver</string>
<string name="dream_description">Memby movie &amp; TV backdrops</string>
<string name="developer_name">ponzischeme89</string>
</resources>
+13
View File
@@ -0,0 +1,13 @@
<resources>
<style name="Theme.Memby" parent="@android:style/Theme.Material.NoActionBar">
<item name="android:windowBackground">@android:color/black</item>
<item name="android:statusBarColor">@android:color/black</item>
<item name="android:navigationBarColor">@android:color/black</item>
</style>
<style name="Theme.Memby.Fullscreen" parent="Theme.Memby">
<item name="android:windowFullscreen">true</item>
<item name="android:windowActionBar">false</item>
<item name="android:windowNoTitle">true</item>
</style>
</resources>
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Screensaver metadata. "Customize" in the TV's screensaver settings opens the
app's MainActivity, where the server connection is configured. -->
<dream xmlns:android="http://schemas.android.com/apk/res/android"
android:settingsActivity="com.ponzischeme89.memby/.ui.MainActivity"
android:previewImage="@drawable/app_banner" />
+5
View File
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<!-- Matches context.cacheDir, where the update APK is downloaded. -->
<cache-path name="apk_cache" path="." />
</paths>
@@ -0,0 +1,109 @@
package com.ponzischeme89.memby.data
import com.ponzischeme89.memby.data.model.GatewayHome
import com.ponzischeme89.memby.data.model.GatewayPlayback
import kotlinx.serialization.json.Json
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Contract tests for the gateway wire format.
*
* The fixtures below are the exact shape `server/internal/api` emits: camelCase envelope
* fields wrapping Emby's own PascalCase item JSON. If someone renames a field on either
* side, this fails before a TV ever sees it.
*/
class GatewayPayloadTest {
private val json = Json {
ignoreUnknownKeys = true
coerceInputValues = true
isLenient = true
explicitNulls = false
}
@Test
fun `decodes a home payload with emby-shaped items`() {
val payload = """
{
"continueWatching": [
{"Id":"1","Name":"Dune","Type":"Movie","UserData":{"PlaybackPositionTicks":36000000000}}
],
"nextUp": [
{"Id":"2","Name":"Pilot","Type":"Episode","SeriesName":"Severance"}
],
"favorites": [
{"Id":"3","Name":"Arrival","Type":"Movie","UserData":{"IsFavorite":true}}
],
"latestMovies": [],
"partial": false
}
""".trimIndent()
val home = json.decodeFromString<GatewayHome>(payload)
assertEquals("Dune", home.continueWatching.single().name)
assertEquals(3_600_000L, home.continueWatching.single().userData!!.playbackPositionTicks / 10_000L)
assertEquals("Severance", home.nextUp.single().seriesName)
assertTrue(home.favorites.single().isFavorite)
assertTrue(home.latestMovies.isEmpty())
assertEquals(false, home.partial)
}
@Test
fun `decodes server-composed rows including recommendations`() {
val payload = """
{
"rows": [
{"id":"continue","title":"Continue Watching","kind":"continue","items":[{"Id":"1","Name":"Dune","Type":"Movie"}]},
{"id":"favorites","title":"Favourites","kind":"favorites","items":[{"Id":"3","Name":"Arrival","Type":"Movie"}]},
{"id":"similar:sev","title":"Because you watched Severance","kind":"similar","items":[{"Id":"7","Name":"Devs","Type":"Series"}]},
{"id":"recommended","title":"Recommended from your watching history","kind":"recommended","items":[{"Id":"8","Name":"Solaris","Type":"Movie"}]}
],
"continueWatching": [{"Id":"1","Name":"Dune","Type":"Movie"}],
"nextUp": [],
"favorites": [{"Id":"3","Name":"Arrival","Type":"Movie"}],
"latestMovies": [],
"partial": false
}
""".trimIndent()
val home = json.decodeFromString<GatewayHome>(payload)
assertEquals(
listOf("continue", "favorites", "similar:sev", "recommended"),
home.rows.map { it.id },
)
assertEquals("Because you watched Severance", home.rows[2].title)
assertEquals("Solaris", home.rows.last().items.single().name)
}
@Test
fun `a home payload without rows still decodes`() {
// The gateway omits recommendation rows while they are still building, and an
// older gateway would not send `rows` at all.
val home = json.decodeFromString<GatewayHome>(
"""{"continueWatching":[],"nextUp":[],"favorites":[],"latestMovies":[],"partial":false}""",
)
assertTrue(home.rows.isEmpty())
}
@Test
fun `a partial home payload still decodes`() {
val home = json.decodeFromString<GatewayHome>(
"""{"continueWatching":[],"nextUp":[],"favorites":[],"latestMovies":[],"partial":true}""",
)
assertTrue(home.partial)
}
@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}""",
)
assertEquals("9", playback.itemId)
assertEquals(42_000L, playback.resumePositionMs)
assertTrue(playback.url.startsWith("https://emby.example/Videos/9/stream"))
}
}
@@ -0,0 +1,55 @@
package com.ponzischeme89.memby.data
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
/**
* The gateway's 503 body is the only thing allowed to put words on the maintenance
* screen, so what gets trusted out of it matters.
*/
class MaintenanceMessageTest {
@Test
fun `reads the operator's message`() {
val body = """{"error":"Back at 9pm","maintenance":true,"message":"Back at 9pm"}"""
assertEquals("Back at 9pm", parseMaintenanceMessage(body))
}
@Test
fun `ignores a 503 that is not a maintenance response`() {
// Some proxy or upstream returning its own 503 must not get to write on screen.
assertNull(parseMaintenanceMessage("""{"message":"upstream connect error"}"""))
assertNull(parseMaintenanceMessage("""{"maintenance":false,"message":"nope"}"""))
}
@Test
fun `survives a body that is not the shape we expect`() {
assertNull(parseMaintenanceMessage(""))
assertNull(parseMaintenanceMessage(" "))
assertNull(parseMaintenanceMessage("<html>502 Bad Gateway</html>"))
assertNull(parseMaintenanceMessage("""{"maintenance":true"""))
}
@Test
fun `blank and whitespace-only messages are rejected`() {
assertNull(parseMaintenanceMessage("""{"maintenance":true,"message":" "}"""))
assertNull(parseMaintenanceMessage("""{"maintenance":true}"""))
}
@Test
fun `an over-long message is truncated to something that fits a screen`() {
val long = "x".repeat(400)
val parsed = parseMaintenanceMessage("""{"maintenance":true,"message":"$long"}""")
assertEquals(160, parsed?.length)
}
@Test
fun `unknown fields from a newer gateway are ignored`() {
val body = """{"maintenance":true,"message":"Upgrading","until":"2026-07-27T22:00:00Z"}"""
assertEquals("Upgrading", parseMaintenanceMessage(body))
}
}
@@ -0,0 +1,16 @@
package com.ponzischeme89.memby.data
import org.junit.Assert.assertEquals
import org.junit.Test
class PlaybackReportMathTest {
@Test
fun millisecondsAreConvertedToEmbyTicks() {
assertEquals(12_340_000L, millisecondsToTicks(1_234L))
}
@Test
fun negativePositionsAreClamped() {
assertEquals(0L, millisecondsToTicks(-1L))
}
}
@@ -0,0 +1,39 @@
package com.ponzischeme89.memby.data
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class ProfileSettingsTest {
private val profile = EmbyProfile(
id = "server::user",
serverUrl = "http://emby",
token = "token",
userId = "user",
username = "Matt",
)
@Test
fun `active profile matches both server and user`() {
val settings = Settings(
serverUrl = profile.serverUrl,
token = profile.token,
userId = profile.userId,
profiles = listOf(profile),
)
assertEquals(profile.id, settings.activeProfileId)
}
@Test
fun `profile from another server is not treated as active`() {
val settings = Settings(
serverUrl = "http://another-server",
token = profile.token,
userId = profile.userId,
profiles = listOf(profile),
)
assertNull(settings.activeProfileId)
}
}
@@ -0,0 +1,141 @@
package com.ponzischeme89.memby.data
import com.ponzischeme89.memby.data.analytics.RowAnalytics
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class RowAnalyticsTest {
/** A controllable clock, so dwell assertions are exact rather than timing-dependent. */
private class FakeClock(var millis: Long = 1_700_000_000_000) {
fun advance(by: Long) { millis += by }
}
private fun analytics(clock: FakeClock, maxBuffered: Int = 200) =
RowAnalytics(now = { clock.millis }, maxBuffered = maxBuffered)
@Test
fun `dwell is measured when focus leaves a row`() {
val clock = FakeClock()
val collector = analytics(clock)
collector.rowFocused("recommended", "MOVIES", "item-1")
clock.advance(5_000)
collector.rowFocused("favorites", "FAVORITES", "item-2")
val focus = collector.drain().single { it.event == RowAnalytics.EVENT_FOCUS }
assertEquals("recommended", focus.rowId)
assertEquals(5_000L, focus.dwellMs)
assertEquals("item-1", focus.itemId)
}
@Test
fun `moving between cards inside one row keeps measuring the same dwell`() {
val clock = FakeClock()
val collector = analytics(clock)
collector.rowFocused("recommended", "MOVIES", "item-1")
clock.advance(3_000)
collector.rowFocused("recommended", "MOVIES", "item-2")
clock.advance(3_000)
collector.endFocus()
val focuses = collector.drain().filter { it.event == RowAnalytics.EVENT_FOCUS }
assertEquals(1, focuses.size)
assertEquals(6_000L, focuses.single().dwellMs)
}
@Test
fun `passing through a row is not counted as attention`() {
val clock = FakeClock()
val collector = analytics(clock)
collector.rowFocused("continue", "CONTINUE", "a")
clock.advance(RowAnalytics.MIN_DWELL_MS - 1)
collector.rowFocused("favorites", "FAVORITES", "b")
assertTrue(
"a sub-threshold glance should produce no focus event",
collector.drain().none { it.event == RowAnalytics.EVENT_FOCUS },
)
}
@Test
fun `an impression is recorded once per row`() {
val collector = analytics(FakeClock())
collector.rowImpression("favorites", "FAVORITES")
collector.rowImpression("favorites", "FAVORITES")
collector.rowImpression("recommended", "MOVIES")
val impressions = collector.drain().filter { it.event == RowAnalytics.EVENT_IMPRESSION }
assertEquals(listOf("favorites", "recommended"), impressions.map { it.rowId })
}
@Test
fun `focusing a row that was never reported still records the impression`() {
val collector = analytics(FakeClock())
collector.rowFocused("similar:sev", "MOVIES", "item-1")
val events = collector.drain()
assertEquals(1, events.count { it.event == RowAnalytics.EVENT_IMPRESSION })
assertEquals("similar:sev", events.single().rowId)
}
@Test
fun `selections are recorded with their item`() {
val collector = analytics(FakeClock())
collector.rowSelected("recommended", "MOVIES", "item-9")
val select = collector.drain().single()
assertEquals(RowAnalytics.EVENT_SELECT, select.event)
assertEquals("item-9", select.itemId)
assertEquals("MOVIES", select.rowKind)
}
@Test
fun `draining clears the buffer`() {
val collector = analytics(FakeClock())
collector.rowImpression("favorites", "FAVORITES")
assertEquals(1, collector.drain().size)
assertEquals(0, collector.drain().size)
}
@Test
fun `the buffer is bounded and keeps the most recent events`() {
val collector = analytics(FakeClock(), maxBuffered = 3)
repeat(6) { collector.rowImpression("row-$it", "MOVIES") }
val events = collector.drain()
assertEquals(3, events.size)
assertEquals(listOf("row-3", "row-4", "row-5"), events.map { it.rowId })
}
@Test
fun `timestamps are sent in the format the gateway parses`() {
val collector = analytics(FakeClock())
collector.rowImpression("favorites", "FAVORITES")
val occurredAt = collector.drain().single().occurredAt
assertTrue(
"expected RFC3339 UTC, got $occurredAt",
occurredAt.matches(Regex("""\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z""")),
)
}
@Test
fun `reset forgets buffered events and seen rows`() {
val collector = analytics(FakeClock())
collector.rowImpression("favorites", "FAVORITES")
collector.reset()
collector.rowImpression("favorites", "FAVORITES")
assertEquals(1, collector.drain().size)
}
}
@@ -0,0 +1,31 @@
package com.ponzischeme89.memby.data
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class ServerConfigTest {
@Test
fun `hardwired address wins over anything the user typed`() {
assertEquals(
"http://tv.example.com:8096",
resolveServerUrl("http://tv.example.com:8096/", "http://192.168.1.50:8096"),
)
}
@Test
fun `hardwired address is normalised like a typed one`() {
assertEquals("http://10.0.0.5:8096", resolveServerUrl("10.0.0.5:8096", ""))
}
@Test
fun `falls back to the typed address when the build pins nothing`() {
assertEquals("https://emby.example.com", resolveServerUrl(null, "https://emby.example.com/"))
assertEquals("https://emby.example.com", resolveServerUrl(" ", "https://emby.example.com/"))
}
@Test
fun `returns null when neither source supplies an address`() {
assertNull(resolveServerUrl(null, ""))
}
}
@@ -0,0 +1,39 @@
package com.ponzischeme89.memby.ui
import com.ponzischeme89.memby.data.HomeCache
import com.ponzischeme89.memby.data.model.BaseItem
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class HomeUiStateTest {
@Test
fun combinedWatchingRowPreservesOrderAndRemovesDuplicates() {
val resumable = BaseItem(id = "resume", name = "Resume")
val duplicate = BaseItem(id = "same", name = "Resume copy")
val next = BaseItem(id = "next", name = "Next")
val state = HomeUiState(
continueWatching = listOf(resumable, duplicate),
nextUp = listOf(duplicate.copy(name = "Next copy"), next),
)
assertEquals(listOf("resume", "same", "next"), state.watchingAndNextUp.map { it.id })
}
@Test
fun cachedContentIsShownWhileOnlyMissingRowsLoad() {
val cache = HomeCache(
continueWatching = listOf(BaseItem(id = "resume")),
favorites = listOf(BaseItem(id = "favorite")),
)
val state = HomeUiState.from(cache)
assertFalse(HomeSection.CONTINUE in state.loading)
assertTrue(HomeSection.NEXT_UP in state.loading)
assertFalse(HomeSection.FAVORITES in state.loading)
assertTrue(HomeSection.LATEST in state.loading)
}
}
@@ -0,0 +1,35 @@
package com.ponzischeme89.memby.ui
import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.data.model.MediaStream
import org.junit.Assert.assertEquals
import org.junit.Test
class MediaBadgesTest {
@Test
fun `derives premium video and audio badges without duplicates`() {
val item = BaseItem(
id = "movie",
mediaStreams = listOf(
MediaStream(
type = "Video",
codec = "hevc",
width = 3840,
videoRangeType = "DOVI",
title = "Dolby Vision HEVC",
),
MediaStream(type = "Audio", title = "TrueHD Dolby Atmos"),
),
)
assertEquals(
listOf("4K", "DOLBY VISION", "HEVC", "DOLBY ATMOS"),
mediaBadges(item),
)
}
@Test
fun `returns no badges when stream metadata is unavailable`() {
assertEquals(emptyList<String>(), mediaBadges(BaseItem(id = "unknown")))
}
}
@@ -0,0 +1,128 @@
package com.ponzischeme89.memby.ui
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 kotlinx.serialization.json.Json
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* The home screen is composed by the gateway. These pin the parts of that contract the
* client is responsible for: honouring the user's section toggles, always showing rows
* the server invented, and surviving a cold start from cache.
*/
class ServerHomeRowsTest {
private fun row(id: String, kind: String, vararg itemIds: String) = HomeRow(
id = id,
title = id.replaceFirstChar(Char::uppercase),
kind = kind,
items = itemIds.map { BaseItem(id = it) },
)
private val serverRows = listOf(
row("continue", "continue", "a"),
row("next-up", "nextup", "b"),
row("favorites", "favorites", "c"),
row("latest-movies", "latest", "d"),
row("similar:sev", "similar", "e"),
row("recommended", "recommended", "f"),
)
@Test
fun `server row order and titles are preserved`() {
val rows = serverHomeRows(HomeUiState(rows = serverRows, loading = emptySet()), Settings())
assertEquals(
listOf("continue", "next-up", "favorites", "latest-movies", "similar:sev", "recommended"),
rows.map { it.id },
)
assertEquals("Recommended", rows.last().title)
}
@Test
fun `disabling a section hides its rows but never the recommendations`() {
val settings = Settings(homeSections = "continue")
val rows = serverHomeRows(HomeUiState(rows = serverRows, loading = emptySet()), settings)
assertEquals(
listOf("continue", "next-up", "similar:sev", "recommended"),
rows.map { it.id },
)
}
@Test
fun `recommendation rows render as poster cards`() {
val rows = serverHomeRows(HomeUiState(rows = serverRows, loading = emptySet()), Settings())
.associateBy { it.id }
assertEquals(MediaRowKind.CONTINUE, rows.getValue("continue").kind)
assertEquals(MediaRowKind.NEXT_UP, rows.getValue("next-up").kind)
assertEquals(MediaRowKind.FAVORITES, rows.getValue("favorites").kind)
assertEquals(MediaRowKind.MOVIES, rows.getValue("recommended").kind)
assertEquals(MediaRowKind.MOVIES, rows.getValue("similar:sev").kind)
}
@Test
fun `an unknown row kind from a newer server still renders`() {
val rows = serverHomeRows(
HomeUiState(rows = listOf(row("collection:halloween", "collection", "x")), loading = emptySet()),
Settings(),
)
assertEquals(1, rows.size)
assertEquals(MediaRowKind.MOVIES, rows.single().kind)
}
@Test
fun `only empty rows show a loading state while a refresh is running`() {
val rows = serverHomeRows(
HomeUiState(
rows = listOf(row("continue", "continue", "a"), row("recommended", "recommended")),
loading = setOf(HomeSection.CONTINUE),
),
Settings(),
).associateBy { it.id }
assertEquals(false, rows.getValue("continue").loading)
assertEquals(true, rows.getValue("recommended").loading)
}
@Test
fun `rows survive a round trip through the on-device cache`() {
val state = HomeUiState(rows = serverRows, loading = emptySet())
val encoded = Json.encodeToString(HomeCache.serializer(), state.toCache())
val restored = HomeUiState.from(Json.decodeFromString<HomeCache>(encoded))
assertEquals(serverRows.map { it.id }, restored.rows.map { it.id })
assertEquals("f", restored.rows.last().items.single().id)
}
@Test
fun `maintenance is a distinct state from an ordinary refresh error`() {
val offline = HomeUiState(rows = serverRows, maintenanceMessage = "Back at 9pm", hasRefreshError = true)
val slow = HomeUiState(rows = serverRows, hasRefreshError = true)
// The screen keys off maintenanceMessage; a slow connection must not trigger it.
assertEquals("Back at 9pm", offline.maintenanceMessage)
assertEquals(null, slow.maintenanceMessage)
// Rows survive underneath, so returning from maintenance does not start empty.
assertEquals(serverRows.size, serverHomeRows(offline, Settings()).size)
}
@Test
fun `a cache written before rows existed still decodes`() {
val legacy = """{"continueWatching":[{"Id":"a"}],"favorites":[],"nextUp":[],"latestMovies":[]}"""
val restored = HomeUiState.from(Json { ignoreUnknownKeys = true }.decodeFromString<HomeCache>(legacy))
assertTrue(restored.rows.isEmpty())
assertEquals("a", restored.continueWatching.single().id)
}
}
@@ -0,0 +1,39 @@
package com.ponzischeme89.memby.ui.screensaver
import org.junit.Assert.assertEquals
import org.junit.Test
/**
* Unit tests for the ring's pure geometry. The animation/lifecycle behaviour (progress
* reaching 100% after the slide duration, advancing exactly once, resetting on the next
* slide, restarting on manual navigation, and cancelling on teardown) is driven by
* Compose's [androidx.compose.animation.core.Animatable] + `repeatOnLifecycle` and is
* verified on-device against the DreamService path rather than here, since this module
* has no Compose UI-test / Robolectric harness.
*/
class SlideProgressMathTest {
private val tolerance = 0.0001f
@Test
fun beginsEmpty() {
assertEquals(0f, SlideProgressMath.sweepAngle(0f), tolerance)
}
@Test
fun reachesFullCircleAtCompletion() {
assertEquals(360f, SlideProgressMath.sweepAngle(1f), tolerance)
}
@Test
fun isProportionalMidway() {
assertEquals(180f, SlideProgressMath.sweepAngle(0.5f), tolerance)
assertEquals(90f, SlideProgressMath.sweepAngle(0.25f), tolerance)
}
@Test
fun clampsOutOfRangeValues() {
assertEquals(0f, SlideProgressMath.sweepAngle(-0.5f), tolerance)
assertEquals(360f, SlideProgressMath.sweepAngle(1.5f), tolerance)
}
}