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:
@@ -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) }
|
||||
}
|
||||
}
|
||||
@@ -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 |
@@ -0,0 +1,6 @@
|
||||
<resources>
|
||||
<string name="app_name">Memby</string>
|
||||
<string name="screensaver_name">Memby Screensaver</string>
|
||||
<string name="dream_description">Memby movie & TV backdrops</string>
|
||||
<string name="developer_name">ponzischeme89</string>
|
||||
</resources>
|
||||
@@ -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>
|
||||
@@ -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" />
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user