Big changes

This commit is contained in:
ponzischeme89
2026-07-29 15:26:27 +12:00
parent 8d6cf2f5a1
commit 70914400b4
62 changed files with 7501 additions and 744 deletions
+31 -1
View File
@@ -17,7 +17,7 @@ val membyGatewayUrl: String = (project.findProperty("memby.gatewayUrl") as Strin
// A release workflow can derive the app version from its Git tag without editing the
// source tree. Local builds keep using the checked-in default.
val defaultVersionName = "0.1.54"
val defaultVersionName = "0.1.69"
val membyVersionName: String =
(project.findProperty("memby.versionName") as String?)
?.trim()
@@ -116,6 +116,21 @@ android {
abortOnError = false
}
testOptions {
unitTests {
// Robolectric needs the merged resources to inflate anything; only the
// screenshot tests use them.
isIncludeAndroidResources = true
// Roborazzi writes PNGs only in record mode. These images are artifacts to
// look at, not checked-in goldens to diff against, so recording is always on
// — a screenshot test that silently captures nothing is worse than none.
all {
it.systemProperty("roborazzi.test.record", "true")
}
}
}
packaging {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
@@ -133,6 +148,10 @@ dependencies {
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.7")
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7")
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.7")
implementation("androidx.work:work-runtime-ktx:2.10.0")
// ProcessLifecycleOwner: lets the status poll stop while no Memby screen is on top,
// instead of hitting the gateway every ten seconds for as long as the process lives.
implementation("androidx.lifecycle:lifecycle-process:2.8.7")
implementation("androidx.savedstate:savedstate-ktx:1.2.1")
// Measurement only: JankStats is enabled by PerformanceMonitor for debug builds.
implementation("androidx.metrics:metrics-performance:1.0.0")
@@ -167,4 +186,15 @@ dependencies {
debugImplementation("androidx.compose.ui:ui-tooling")
testImplementation("junit:junit:4.13.2")
// Screenshot rendering only. Everything else under app/src/test stays plain JUnit
// with no Android on the classpath — see the note in CLAUDE.md. Rendering a
// composable is the one thing that genuinely cannot be done that way, and these are
// confined to *ScreenshotTest.kt files.
testImplementation("org.robolectric:robolectric:4.14.1")
testImplementation("androidx.test.ext:junit:1.2.1")
testImplementation("io.github.takahirom.roborazzi:roborazzi:1.32.2")
testImplementation("io.github.takahirom.roborazzi:roborazzi-compose:1.32.2")
testImplementation("androidx.compose.ui:ui-test-junit4")
debugImplementation("androidx.compose.ui:ui-test-manifest")
}
+35 -5
View File
@@ -7,6 +7,30 @@
<!-- Needed to hand a downloaded APK to the system installer (in-app updates). -->
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<!-- Package visibility (Android 11+). Without these, resolveActivity() returns null and
"Play in Emby" / "Screensaver settings" silently do nothing. -->
<queries>
<!-- EmbyAppLauncher hands an item to an installed Emby client. -->
<package android:name="com.mb.android" />
<package android:name="tv.emby.embyatv" />
<!-- openScreensaverSettings probes the TV's own settings screens, best first. -->
<intent>
<action android:name="android.settings.DREAM_SETTINGS" />
</intent>
<intent>
<action android:name="android.settings.DISPLAY_SETTINGS" />
</intent>
<intent>
<action android:name="android.settings.SETTINGS" />
</intent>
<!-- Search's microphone button. Without this, SpeechRecognizer.isRecognitionAvailable
reports false on Android 11+ even where a recogniser exists, and the button
would be hidden on devices that support voice perfectly well. -->
<intent>
<action android:name="android.speech.RecognitionService" />
</intent>
</queries>
<!-- This is a TV app: no touchscreen, uses the Leanback launcher. -->
<uses-feature
android:name="android.hardware.touchscreen"
@@ -19,18 +43,22 @@
android:name=".MembyApp"
android:allowBackup="true"
android:banner="@drawable/app_banner"
android:icon="@drawable/app_banner"
android:icon="@drawable/emby_logo"
android:label="@string/app_name"
android:roundIcon="@drawable/emby_logo"
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. -->
<!-- Home / setup screen. Registered on the TV (Leanback) launcher.
Televisions are fixed landscape, so the DiscouragedApi advice about adapting to
other orientations does not apply to any device that can install this app. -->
<activity
android:name=".ui.MainActivity"
android:exported="true"
android:screenOrientation="landscape"
tools:ignore="DiscouragedApi"
android:configChanges="keyboard|keyboardHidden|navigation|screenSize|smallestScreenSize|screenLayout|orientation|uiMode">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
@@ -43,7 +71,8 @@
android:name=".ui.screensaver.ScreensaverActivity"
android:exported="false"
android:screenOrientation="landscape"
android:theme="@style/Theme.Memby.Fullscreen" />
android:theme="@style/Theme.Memby.Fullscreen"
tools:ignore="DiscouragedApi" />
<!-- An APK replacement kills an active Dream process. Reopen our launcher so
the TV is never left displaying the old, black Dream surface. -->
@@ -62,14 +91,15 @@
android:screenOrientation="landscape"
android:launchMode="singleTask"
android:configChanges="keyboard|keyboardHidden|navigation|screenSize|orientation"
android:theme="@style/Theme.Memby.Fullscreen" />
android:theme="@style/Theme.Memby.Fullscreen"
tools:ignore="DiscouragedApi" />
<!-- 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:icon="@drawable/emby_logo"
android:label="@string/screensaver_name"
android:permission="android.permission.BIND_DREAM_SERVICE">
<intent-filter>
@@ -1,5 +1,6 @@
package com.ponzischeme89.memby
import android.annotation.SuppressLint
import android.content.Context
import com.ponzischeme89.memby.data.EmbyRepository
import com.ponzischeme89.memby.data.MaintenanceMonitor
@@ -11,6 +12,9 @@ import com.ponzischeme89.memby.data.SettingsStore
* settings instance without pulling in a DI framework.
*/
object ServiceLocator {
// [init] only ever stores an applicationContext, whose lifetime is the process, so this
// holds nothing that could outlive its owner. Keep it that way.
@SuppressLint("StaticFieldLeak")
lateinit var settings: SettingsStore
private set
lateinit var repository: EmbyRepository
@@ -22,6 +26,6 @@ object ServiceLocator {
if (::repository.isInitialized) return
settings = SettingsStore(context.applicationContext)
repository = EmbyRepository(settings)
maintenance = MaintenanceMonitor(repository)
maintenance = MaintenanceMonitor(repository, settings)
}
}
@@ -7,12 +7,14 @@ import com.ponzischeme89.memby.data.model.GatewayAuthError
import com.ponzischeme89.memby.data.model.GatewayAuthPolicy
import com.ponzischeme89.memby.data.model.GatewayLoginRequest
import com.ponzischeme89.memby.data.model.GatewayPlaybackReport
import com.ponzischeme89.memby.data.model.GatewayPrerollSchedule
import com.ponzischeme89.memby.data.model.GatewayRowEvent
import com.ponzischeme89.memby.data.model.GatewayRowEvents
import com.ponzischeme89.memby.data.model.GatewayServiceStatus
import com.ponzischeme89.memby.data.model.GatewayUpdate
import com.ponzischeme89.memby.data.model.HomeRow
import com.ponzischeme89.memby.data.model.PlaybackReport
import com.ponzischeme89.memby.data.model.PlaybackInfoRequest
import com.ponzischeme89.memby.data.remote.EmbyApi
import com.ponzischeme89.memby.data.remote.EmbyServiceFactory
import com.ponzischeme89.memby.data.remote.GatewayApi
@@ -49,6 +51,24 @@ data class HomeSnapshot(
val partial: Boolean = false,
)
/**
* The episode that follows the one being watched, with everything the "next up" banner
* needs to render itself and then start playing without a second round trip.
*/
data class NextEpisode(
val itemId: String,
val title: String,
val seriesName: String,
val episodeCode: String?,
val imageUrl: String?,
val url: String,
val resumePositionMs: Long = 0L,
val subtitles: List<PlayableSubtitle> = emptyList(),
val mediaSourceId: String = "",
val playSessionId: String = "",
val playMethod: String = "DirectPlay",
)
/** A resolved, directly playable stream. */
data class Playable(
val itemId: String,
@@ -56,6 +76,10 @@ data class Playable(
val url: String,
val resumePositionMs: Long = 0L,
val logoUrl: String? = null,
val subtitles: List<PlayableSubtitle> = emptyList(),
val mediaSourceId: String = "",
val playSessionId: String = "",
val playMethod: String = "DirectPlay",
)
class EmbyRepository(private val settings: SettingsStore) {
@@ -82,6 +106,10 @@ class EmbyRepository(private val settings: SettingsStore) {
suspend fun cacheHome(content: HomeCache) = settings.setHomeCache(content)
suspend fun setForYouMinutes(minutes: Int) = settings.setForYouMinutes(minutes)
suspend fun markForYouOpened() = settings.markForYouOpened()
// --- API instance caching (rebuilt only when the server URL changes) -----
private var cachedApi: EmbyApi? = null
@@ -133,7 +161,12 @@ class EmbyRepository(private val settings: SettingsStore) {
* 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, deviceName: String) {
suspend fun authenticate(
serverUrl: String,
username: String,
password: String,
deviceName: String,
): String {
clearPlayableCache()
settings.ensureDeviceId()
observedSettings = settings.snapshot() // pick up the freshly-generated device id
@@ -159,16 +192,17 @@ class EmbyRepository(private val settings: SettingsStore) {
require(result.token.isNotBlank() && result.userId.isNotBlank()) {
"Gateway did not return a session"
}
val authenticatedUsername = result.username.ifBlank { username }
settings.saveSession(
gateway,
result.token,
result.userId,
result.username.ifBlank { username },
authenticatedUsername,
result.serverId.takeIf { it.isNotBlank() },
)
settings.setDeviceName(deviceName)
observedSettings = settings.snapshot()
return
return authenticatedUsername
}
val base = resolveServerUrl(ServerConfig.hardwiredUrl, serverUrl)
@@ -183,11 +217,30 @@ class EmbyRepository(private val settings: SettingsStore) {
settings.saveSession(base, token, userId, username, result.serverId)
settings.setDeviceName(deviceName)
observedSettings = settings.snapshot()
return username
}
suspend fun authPolicy(): GatewayAuthPolicy? =
if (ServerConfig.isGateway) requireGateway().authPolicy() else null
/** True only when the gateway still recognises the token restored from storage. */
suspend fun validateSession(): Boolean {
if (!ServerConfig.isGateway || snapshot.token.isNullOrBlank()) return true
return runCatching { requireGateway().session() }.isSuccess
}
/**
* A 401 is authoritative: cached content must not masquerade as a slow connection.
* Remove only the rejected profile, then the root UI naturally returns to sign-in.
*/
suspend fun invalidateSession() {
settings.invalidateActiveSession()
observedSettings = settings.snapshot()
cachedApi = null
cachedBaseUrl = null
clearPlayableCache()
}
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.
@@ -301,19 +354,19 @@ class EmbyRepository(private val settings: SettingsStore) {
/** 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",
val userId = snapshot.userId ?: error("Not connected")
return requireApi().getResumeItems(
userId,
mapOf(
"Recursive" to "true",
"SortBy" to "DatePlayed",
"SortOrder" to "Descending",
"MediaTypes" to "Video",
"Limit" to limit.toString(),
"Fields" to "RunTimeTicks,SeriesName,PrimaryImageAspectRatio",
"ImageTypeLimit" to "1",
"EnableImageTypes" to "Backdrop,Primary,Logo",
"EnableUserData" to "true",
),
fields = "RunTimeTicks,SeriesName,PrimaryImageAspectRatio",
imageTypes = "Backdrop,Primary,Logo",
includeUserData = true,
)
).items
}
/** Episodes the server recommends playing next, excluding resumable duplicates in the UI. */
@@ -351,8 +404,44 @@ class EmbyRepository(private val settings: SettingsStore) {
}
/** 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
/**
* Library search, dual-path like everything else.
*
* The gateway answers from its imported Postgres copy (falling back to Emby before
* the first import finishes); direct mode asks Emby itself. Both return whatever
* relevance order the backend chose — the caller re-ranks locally rather than
* re-sorting here, so the ordering rule stays a pure, testable function.
*/
suspend fun search(term: String, limit: Int = 40): List<BaseItem> {
val trimmed = term.trim()
if (trimmed.isEmpty()) return emptyList()
if (ServerConfig.isGateway) return requireGateway().search(trimmed, limit).items
return getHomeItems(
params = mapOf(
"SearchTerm" to trimmed,
"IncludeItemTypes" to "Movie,Series,Episode",
"Recursive" to "true",
"Limit" to limit.toString(),
),
fields = "ProductionYear,RunTimeTicks,SeriesName,PrimaryImageAspectRatio",
imageTypes = "Backdrop,Primary,Logo",
includeUserData = true,
)
}
/** Records a successful gateway search without affecting the direct Emby path. */
suspend fun recordSearch(term: String) {
if (ServerConfig.isGateway && term.trim().length >= 2) {
runCatching { requireGateway().recordSearch(mapOf("query" to term.trim())) }
}
}
/** Memby's database has no search history; this is a persisted Memby gateway feature. */
suspend fun getRecentSearches(): List<String> {
if (!ServerConfig.isGateway) return emptyList()
return runCatching { requireGateway().recentSearches().queries }
.getOrDefault(emptyList())
}
/**
* Recommendation rows on their own, forcing the gateway to build them synchronously
@@ -361,6 +450,12 @@ class EmbyRepository(private val settings: SettingsStore) {
*/
suspend fun getRecommendations(): List<HomeRow> = requireGateway().recommendations().rows
/** Tracearr-powered, request-scoped picks for the dedicated TV destination. */
suspend fun getForYou(availableMinutes: Int): List<HomeRow> {
if (!ServerConfig.isGateway) return emptyList()
return requireGateway().forYou(availableMinutes.coerceIn(0, 360)).rows
}
/**
* The gateway's verdict on this build. Null on the direct path, where nobody is in a
* position to decide, and null on failure — an unreachable gateway must never leave
@@ -383,10 +478,34 @@ class EmbyRepository(private val settings: SettingsStore) {
return requireApi().getItem(
userId = userId,
itemId = itemId,
fields = "Overview,Genres,MediaStreams,ProductionYear,OfficialRating,CommunityRating,RunTimeTicks,PrimaryImageAspectRatio",
fields = "Overview,Genres,MediaStreams,People,ProductionYear,OfficialRating,CommunityRating,RunTimeTicks,PrimaryImageAspectRatio",
)
}
/**
* All episodes for one show in a single request. Season switching is then a local
* list filter, keeping the detail screen immediate after its first load.
*/
suspend fun getSeriesEpisodes(seriesId: String): List<BaseItem> {
if (seriesId.isBlank()) return emptyList()
if (ServerConfig.isGateway) {
return requireGateway().seriesEpisodes(seriesId).items
}
val userId = snapshot.userId ?: error("Not connected")
return requireApi().getEpisodes(
seriesId,
mapOf(
"UserId" to userId,
"Fields" to "Overview,RunTimeTicks,SeriesName,PrimaryImageAspectRatio",
"EnableUserData" to "true",
"EnableImages" to "true",
"EnableImageTypes" to "Primary,Thumb,Backdrop",
"ImageTypeLimit" to "1",
"Limit" to "1000",
),
).items
}
/** Tight list endpoint shape: detail-only fields are never fetched on home. */
private suspend fun getHomeItems(
params: Map<String, String>,
@@ -466,6 +585,18 @@ class EmbyRepository(private val settings: SettingsStore) {
}
}
/**
* Optional decoration for the player pre-roll. Failure or direct-to-Emby mode returns
* an empty schedule immediately; playback never depends on this request.
*/
suspend fun prerollSchedule(): GatewayPrerollSchedule {
if (!ServerConfig.isGateway || snapshot.token.isNullOrBlank()) {
return GatewayPrerollSchedule()
}
return runCatching { requireGateway().prerollSchedule() }
.getOrDefault(GatewayPrerollSchedule())
}
/** Backdrop rotation interval, clamped to a sane range. */
fun rotationIntervalMillis(): Long =
snapshot.rotationIntervalSeconds.coerceIn(4, 600).toLong() * 1000L
@@ -498,6 +629,97 @@ class EmbyRepository(private val settings: SettingsStore) {
resolvePlayable(item)
}
/**
* Resolves a new URL for an item that was already selected for playback. This bypasses
* the launch cache: recovery must not hand the player the same potentially stale URL.
*
* Passing no type hint makes the gateway ask Emby for the authoritative item. That is
* one extra request only on recovery, and avoids accidentally treating a movie as an
* episode (or resolving a series a second time).
*/
suspend fun refreshPlayableStream(
itemId: String,
title: String,
resumePositionMs: Long,
): Playable {
require(itemId.isNotBlank()) { "A media item is required to refresh playback" }
if (!ServerConfig.isGateway) {
val discovery = directPlayback(itemId, resumePositionMs)
return Playable(
itemId = itemId,
title = title,
url = buildStreamUrl(itemId),
resumePositionMs = resumePositionMs.coerceAtLeast(0L),
subtitles = discovery.subtitles,
mediaSourceId = discovery.mediaSourceId,
playSessionId = discovery.playSessionId,
playMethod = discovery.playMethod,
)
}
val playback = requireGateway().playback(
itemId = itemId,
itemType = "",
title = title,
resumePositionMs = resumePositionMs.coerceAtLeast(0L),
)
return Playable(
itemId = playback.itemId,
title = playback.title.ifBlank { title },
url = playback.url,
// Recovery follows the local playhead. The server's persisted position may be
// up to one progress interval behind and would visibly jump the viewer back.
resumePositionMs = resumePositionMs.coerceAtLeast(0L),
subtitles = playback.subtitles,
mediaSourceId = playback.mediaSourceId,
playSessionId = playback.playSessionId,
playMethod = playback.playMethod,
)
}
suspend fun selectEncodedSubtitle(
session: PlaybackSession,
subtitleIndex: Int,
title: String,
positionMs: Long,
): Playable {
if (ServerConfig.isGateway) {
val playback = requireGateway().playback(
itemId = session.itemId,
itemType = "",
title = title,
resumePositionMs = positionMs.coerceAtLeast(0L),
subtitleIndex = subtitleIndex,
)
return Playable(
itemId = playback.itemId,
title = playback.title.ifBlank { title },
url = playback.url,
resumePositionMs = positionMs.coerceAtLeast(0L),
subtitles = playback.subtitles,
mediaSourceId = playback.mediaSourceId,
playSessionId = playback.playSessionId,
playMethod = playback.playMethod,
)
}
val discovery = directPlayback(
itemId = session.itemId,
positionMs = positionMs,
subtitleStreamIndex = subtitleIndex,
currentPlaySessionId = session.playSessionId,
)
return Playable(
itemId = session.itemId,
title = title,
url = discovery.url ?: buildStreamUrl(session.itemId),
resumePositionMs = positionMs.coerceAtLeast(0L),
subtitles = discovery.subtitles,
mediaSourceId = discovery.mediaSourceId,
playSessionId = discovery.playSessionId,
playMethod = discovery.playMethod,
)
}
private fun newPlayableRequest(item: BaseItem): Deferred<Playable> {
val request = scope.async(start = CoroutineStart.LAZY) {
try {
@@ -539,6 +761,10 @@ class EmbyRepository(private val settings: SettingsStore) {
url = playback.url,
resumePositionMs = playback.resumePositionMs,
logoUrl = logoUrl(item),
subtitles = playback.subtitles,
mediaSourceId = playback.mediaSourceId,
playSessionId = playback.playSessionId,
playMethod = playback.playMethod,
)
}
if (item.isSeries) {
@@ -549,20 +775,30 @@ class EmbyRepository(private val settings: SettingsStore) {
append(item.name)
episode.name.takeIf { it.isNotBlank() }?.let { append(" $it") }
}
val discovery = directPlayback(episode.id, episode.resumePositionMs)
return Playable(
episode.id,
title,
buildStreamUrl(episode.id),
episode.resumePositionMs,
logoUrl(item),
discovery.subtitles,
discovery.mediaSourceId,
discovery.playSessionId,
discovery.playMethod,
)
}
val discovery = directPlayback(item.id, item.resumePositionMs)
return Playable(
item.id,
item.name,
buildStreamUrl(item.id),
item.resumePositionMs,
logoUrl(item),
discovery.subtitles,
discovery.mediaSourceId,
discovery.playSessionId,
discovery.playMethod,
)
}
@@ -574,43 +810,160 @@ class EmbyRepository(private val settings: SettingsStore) {
}
}
suspend fun reportPlaybackStarted(itemId: String, positionMs: Long) {
suspend fun reportPlaybackStarted(session: PlaybackSession, positionMs: Long) {
if (ServerConfig.isGateway) {
requireGateway().report("started", GatewayPlaybackReport(itemId, positionMs))
requireGateway().report("started", session.gatewayReport(positionMs, false, null))
return
}
requireApi().reportPlaybackStarted(playbackReport(itemId, positionMs, isPaused = false))
requireApi().reportPlaybackStarted(playbackReport(session, positionMs, false, null))
}
suspend fun reportPlaybackProgress(itemId: String, positionMs: Long, isPaused: Boolean) {
suspend fun reportPlaybackProgress(
session: PlaybackSession,
positionMs: Long,
isPaused: Boolean,
eventName: String,
) {
if (ServerConfig.isGateway) {
requireGateway().report("progress", GatewayPlaybackReport(itemId, positionMs, isPaused))
requireGateway().report("progress", session.gatewayReport(positionMs, isPaused, eventName))
return
}
requireApi().reportPlaybackProgress(playbackReport(itemId, positionMs, isPaused))
requireApi().reportPlaybackProgress(playbackReport(session, positionMs, isPaused, eventName))
}
suspend fun reportPlaybackStopped(itemId: String, positionMs: Long) {
suspend fun reportPlaybackStopped(session: PlaybackSession, 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))
requireGateway().report("stopped", session.gatewayReport(positionMs, true, null))
} else {
requireApi().reportPlaybackStopped(playbackReport(itemId, positionMs, isPaused = true))
requireApi().reportPlaybackStopped(playbackReport(session, positionMs, true, null))
}
} finally {
clearPlayableCache()
_playbackStops.tryEmit(itemId)
_playbackStops.tryEmit(session.itemId)
}
}
fun enqueuePlaybackStopped(itemId: String, positionMs: Long) {
fun enqueuePlaybackStopped(session: PlaybackSession, positionMs: Long) {
scope.launch {
runCatching { reportPlaybackStopped(itemId, positionMs) }
runCatching { reportPlaybackStopped(session, positionMs) }
}
}
/**
* The episode after [itemId], or null when nothing follows it — a movie, a series
* finale, or simply a server that would not answer. "No next episode" is an ordinary
* outcome here, so failures are swallowed: the player just shows no banner.
*/
suspend fun nextEpisode(itemId: String, seriesId: String?): NextEpisode? {
if (itemId.isBlank()) return null
return runCatching {
if (ServerConfig.isGateway) gatewayNextEpisode(itemId, seriesId)
else directNextEpisode(itemId, seriesId)
}.getOrNull()
}
private suspend fun gatewayNextEpisode(itemId: String, seriesId: String?): NextEpisode {
val response = requireGateway().nextEpisode(itemId, seriesId.orEmpty())
return nextEpisodeOf(
response.item, response.url, response.resumePositionMs, response.subtitles,
response.mediaSourceId, response.playSessionId, response.playMethod,
)
}
private suspend fun directNextEpisode(itemId: String, seriesId: String?): NextEpisode? {
val userId = snapshot.userId ?: error("Not connected")
val series = seriesId?.takeIf { it.isNotBlank() }
?: getItemDetails(itemId).seriesId
?: return null
// AdjacentTo gives back [previous, current, next] in running order, minus whichever
// ends do not exist — so the current episode's position identifies the next one.
val episodes = requireApi().getEpisodes(
series,
mapOf(
"UserId" to userId,
"AdjacentTo" to itemId,
"Fields" to "RunTimeTicks,Overview,SeriesName",
"EnableUserData" to "true",
"EnableImageTypes" to "Primary,Thumb",
),
).items
val current = episodes.indexOfFirst { it.id == itemId }.takeIf { it >= 0 } ?: return null
val next = episodes.getOrNull(current + 1) ?: return null
val discovery = directPlayback(next.id, next.resumePositionMs)
return nextEpisodeOf(
next, buildStreamUrl(next.id), next.resumePositionMs, discovery.subtitles,
discovery.mediaSourceId, discovery.playSessionId, discovery.playMethod,
)
}
private fun nextEpisodeOf(
item: BaseItem,
url: String,
resumePositionMs: Long,
subtitles: List<PlayableSubtitle>,
mediaSourceId: String,
playSessionId: String,
playMethod: String,
) = NextEpisode(
itemId = item.id,
title = item.name,
seriesName = item.seriesName.orEmpty(),
episodeCode = item.episodeCode,
imageUrl = primaryUrl(item, maxWidth = 400),
url = url,
resumePositionMs = resumePositionMs,
subtitles = subtitles,
mediaSourceId = mediaSourceId,
playSessionId = playSessionId,
playMethod = playMethod,
)
private suspend fun directPlayback(
itemId: String,
positionMs: Long,
subtitleStreamIndex: Int? = null,
currentPlaySessionId: String? = null,
): PlaybackDiscovery {
val userId = snapshot.userId ?: return PlaybackDiscovery(mediaSourceId = itemId)
val serverUrl = activeServerUrl ?: return PlaybackDiscovery(mediaSourceId = itemId)
val token = snapshot.token.orEmpty()
return runCatching {
val info = requireApi().getPlaybackInfo(
itemId,
userId,
body = PlaybackInfoRequest(
id = itemId,
userId = userId,
startTimeTicks = millisecondsToTicks(positionMs),
subtitleStreamIndex = subtitleStreamIndex,
currentPlaySessionId = currentPlaySessionId,
),
)
info.mediaSources.firstOrNull()?.let { source ->
PlaybackDiscovery(
subtitles = subtitleTracks(
streams = source.mediaStreams,
serverUrl = serverUrl,
token = token,
itemId = itemId,
mediaSourceId = source.id,
),
mediaSourceId = source.id.ifBlank { itemId },
playSessionId = info.playSessionId,
playMethod = if (subtitleStreamIndex != null && !source.transcodingUrl.isNullOrBlank()) {
"Transcode"
} else "DirectPlay",
url = source.transcodingUrl
?.takeIf { subtitleStreamIndex != null }
?.let { authenticatedDeliveryUrl(serverUrl, it, token) },
)
} ?: PlaybackDiscovery(mediaSourceId = itemId)
}.getOrElse { PlaybackDiscovery(mediaSourceId = itemId) }
}
private suspend fun firstNextUpEpisode(userId: String, seriesId: String): BaseItem? =
runCatching {
requireApi().getNextUp(
@@ -671,6 +1024,20 @@ class EmbyRepository(private val settings: SettingsStore) {
return imageUrl(item.id, "Primary", tag, maxWidth)
}
/**
* Poster for an item the app never received as a [BaseItem] — a service alert names
* its subject by id and tag only, and the Sonarr ids it carries resolve through the
* gateway's image proxy like any other.
*/
fun posterUrl(itemId: String, tag: String, maxWidth: Int = 300): String? {
if (itemId.isBlank() || tag.isBlank()) return null
return imageUrl(itemId, "Primary", tag, maxWidth)
}
/** Emby stores cast portraits as the person's Primary image. */
fun personImageUrl(person: com.ponzischeme89.memby.data.model.EmbyPerson, maxWidth: Int = 240): String? =
posterUrl(person.id, person.primaryImageTag.orEmpty(), maxWidth)
/**
* Builds an artwork URL for whichever backend this build uses.
*
@@ -725,16 +1092,51 @@ class EmbyRepository(private val settings: SettingsStore) {
"&DeviceId=${encode(deviceId)}"
}
private fun playbackReport(itemId: String, positionMs: Long, isPaused: Boolean) =
private fun playbackReport(
session: PlaybackSession,
positionMs: Long,
isPaused: Boolean,
eventName: String?,
) =
PlaybackReport(
itemId = itemId,
itemId = session.itemId,
mediaSourceId = session.mediaSourceId,
playSessionId = session.playSessionId,
positionTicks = millisecondsToTicks(positionMs),
isPaused = isPaused,
playMethod = session.playMethod,
eventName = eventName,
)
private fun encode(value: String): String = URLEncoder.encode(value, "UTF-8")
}
data class PlaybackSession(
val itemId: String,
val mediaSourceId: String,
val playSessionId: String,
val playMethod: String = "DirectPlay",
)
private data class PlaybackDiscovery(
val subtitles: List<PlayableSubtitle> = emptyList(),
val mediaSourceId: String = "",
val playSessionId: String = "",
val playMethod: String = "DirectPlay",
val url: String? = null,
)
private fun PlaybackSession.gatewayReport(positionMs: Long, isPaused: Boolean, eventName: String?) =
GatewayPlaybackReport(
itemId = itemId,
positionMs = positionMs,
isPaused = isPaused,
mediaSourceId = mediaSourceId,
playSessionId = playSessionId,
playMethod = playMethod,
eventName = eventName,
)
private data class CachedPlayable(
val playable: Playable,
val expiresAtMs: Long,
@@ -754,11 +1156,13 @@ class DeviceLimitException(
val maxClients: Int,
) : Exception("Memby device allowance reached")
/** Shared across the error-body parsers below; building a Json format per call is costly. */
private val errorBodyJson = Json { ignoreUnknownKeys = true }
internal fun parseDeviceLimit(body: String): DeviceLimitException? {
if (body.isBlank()) return null
return runCatching {
val parsed = Json { ignoreUnknownKeys = true }
.decodeFromString<GatewayAuthError>(body)
val parsed = errorBodyJson.decodeFromString<GatewayAuthError>(body)
parsed.takeIf {
it.error == "device_limit_reached" && it.maxClientsPerUser > 0
}?.let {
@@ -802,8 +1206,7 @@ private fun maintenanceMessage(t: HttpException): String? = runCatching {
internal fun parseMaintenanceMessage(body: String): String? {
if (body.isBlank()) return null
return runCatching {
val parsed = Json { ignoreUnknownKeys = true }
.decodeFromString<MaintenanceResponse>(body)
val parsed = errorBodyJson.decodeFromString<MaintenanceResponse>(body)
parsed.message?.trim()?.takeIf { parsed.maintenance && it.isNotBlank() }?.take(160)
}.getOrNull()
}
@@ -811,6 +1214,9 @@ internal fun parseMaintenanceMessage(body: String): String? {
/** True when this failure is the gateway reporting a deliberate outage. */
fun isMaintenanceError(t: Throwable): Boolean = t is HttpException && t.code() == 503
/** True when the gateway has rejected the persisted session token. */
fun isUnauthorizedError(t: Throwable): Boolean = t is HttpException && t.code() == 401
@Serializable
private data class MaintenanceResponse(
val maintenance: Boolean = false,
@@ -44,12 +44,18 @@ data class Settings(
val updateToken: String? = null,
// Show each item's Emby "Logo" image in place of the plain-text title.
val showTitleLogo: Boolean = true,
// Slide up a "next up" banner near the end of an episode and roll into the next one.
val autoPlayNextEpisode: Boolean = true,
// 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,
/** Last duration chosen in For You, scoped to the active Emby profile on this TV. */
val forYouMinutes: Int = 0,
/** Whether this profile has discovered the dedicated For You destination. */
val hasOpenedForYou: Boolean = false,
val homeCardDensity: String = DEFAULT_HOME_CARD_DENSITY,
val showHomeCardMetadata: Boolean = true,
val profiles: List<EmbyProfile> = emptyList(),
@@ -80,6 +86,8 @@ data class EmbyProfile(
val username: String,
val serverId: String? = null,
val homeCacheJson: String? = null,
val forYouMinutes: Int = 0,
val hasOpenedForYou: Boolean = false,
)
class SettingsStore(private val context: Context) {
@@ -91,6 +99,7 @@ class SettingsStore(private val context: Context) {
get() = latestSettings
private object Keys {
const val MAX_SEEN_ALERTS = 40
val SERVER_URL = stringPreferencesKey("server_url")
val TOKEN = stringPreferencesKey("token")
val USER_ID = stringPreferencesKey("user_id")
@@ -103,13 +112,17 @@ class SettingsStore(private val context: Context) {
val UPDATE_REPO = stringPreferencesKey("update_repo")
val UPDATE_TOKEN = stringPreferencesKey("update_token")
val SHOW_TITLE_LOGO = booleanPreferencesKey("show_title_logo")
val AUTO_PLAY_NEXT = booleanPreferencesKey("auto_play_next_episode")
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 FOR_YOU_MINUTES = intPreferencesKey("for_you_minutes")
val HAS_OPENED_FOR_YOU = booleanPreferencesKey("has_opened_for_you")
val HOME_CARD_DENSITY = stringPreferencesKey("home_card_density")
val SHOW_HOME_CARD_METADATA = booleanPreferencesKey("show_home_card_metadata")
val PROFILES = stringPreferencesKey("profiles")
val SEEN_ALERTS = stringPreferencesKey("seen_alert_ids")
}
/**
@@ -145,10 +158,37 @@ class SettingsStore(private val context: Context) {
}
}
/**
* Ids of alerts already shown on this TV. The gateway keeps offering an alert for as
* long as it is current, so without this an "aired" banner would return every poll —
* and again after every relaunch. Only the most recent ids are kept; older ones have
* long since fallen out of the server's window.
*/
suspend fun seenAlertIds(): Set<String> =
decodeAlertIds(context.dataStore.data.first()[Keys.SEEN_ALERTS]).toSet()
suspend fun markAlertSeen(id: String) {
val trimmed = id.trim()
if (trimmed.isEmpty()) return
context.dataStore.edit { preferences ->
val ids = decodeAlertIds(preferences[Keys.SEEN_ALERTS]).filterNot { it == trimmed }
preferences[Keys.SEEN_ALERTS] = (ids + trimmed)
.takeLast(Keys.MAX_SEEN_ALERTS)
.joinToString("\n")
}
}
private fun decodeAlertIds(raw: String?): List<String> =
raw?.split('\n')?.filter { it.isNotBlank() }.orEmpty()
suspend fun setShowTitleLogo(enabled: Boolean) {
context.dataStore.edit { it[Keys.SHOW_TITLE_LOGO] = enabled }
}
suspend fun setAutoPlayNextEpisode(enabled: Boolean) {
context.dataStore.edit { it[Keys.AUTO_PLAY_NEXT] = enabled }
}
suspend fun setRingColor(hex: String) {
context.dataStore.edit { it[Keys.RING_COLOR] = hex }
}
@@ -194,6 +234,39 @@ class SettingsStore(private val context: Context) {
}
}
suspend fun setForYouMinutes(minutes: Int) {
val selected = minutes.takeIf { it in setOf(0, 30, 60, 120) } ?: 0
context.dataStore.edit { preferences ->
preferences[Keys.FOR_YOU_MINUTES] = selected
updateActiveProfile(preferences) { it.copy(forYouMinutes = selected) }
}
}
suspend fun markForYouOpened() {
context.dataStore.edit { preferences ->
preferences[Keys.HAS_OPENED_FOR_YOU] = true
updateActiveProfile(preferences) { it.copy(hasOpenedForYou = true) }
}
}
private fun updateActiveProfile(
preferences: MutablePreferences,
transform: (EmbyProfile) -> EmbyProfile,
) {
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) {
transform(profile)
} 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()
}
@@ -227,6 +300,8 @@ class SettingsStore(private val context: Context) {
username = username,
serverId = serverId,
homeCacheJson = previous?.homeCacheJson,
forYouMinutes = previous?.forYouMinutes ?: 0,
hasOpenedForYou = previous?.hasOpenedForYou ?: false,
)
profiles.removeAll { it.id == id }
profiles.add(profile)
@@ -248,17 +323,40 @@ class SettingsStore(private val context: Context) {
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)
clearActiveSession(it)
// Intentionally keep DEVICE_ID stable across sign-outs.
}
}
/**
* Drops a server-rejected profile as well as the active session. Keeping the stale
* profile would let the profile picker immediately restore the same dead token and
* trap the viewer in a 401 loop.
*/
suspend fun invalidateActiveSession() {
context.dataStore.edit { preferences ->
val activeUserId = preferences[Keys.USER_ID]
val activeServer = preferences[Keys.SERVER_URL]
val remaining = profilesFrom(preferences).filterNot {
it.userId == activeUserId && it.serverUrl == activeServer
}
preferences[Keys.PROFILES] = Json.encodeToString(remaining)
clearActiveSession(preferences)
}
}
private fun clearActiveSession(preferences: MutablePreferences) {
preferences.remove(Keys.SERVER_URL)
preferences.remove(Keys.TOKEN)
preferences.remove(Keys.USER_ID)
preferences.remove(Keys.SERVER_ID)
preferences.remove(Keys.LAST_BACKDROP_URL)
preferences.remove(Keys.HOME_CACHE)
preferences.remove(Keys.FOR_YOU_MINUTES)
preferences.remove(Keys.HAS_OPENED_FOR_YOU)
preferences.remove(Keys.USERNAME)
}
private fun applyProfile(preferences: MutablePreferences, profile: EmbyProfile) {
preferences[Keys.SERVER_URL] = profile.serverUrl
preferences[Keys.TOKEN] = profile.token
@@ -268,6 +366,8 @@ class SettingsStore(private val context: Context) {
else preferences[Keys.SERVER_ID] = profile.serverId
if (profile.homeCacheJson.isNullOrBlank()) preferences.remove(Keys.HOME_CACHE)
else preferences[Keys.HOME_CACHE] = profile.homeCacheJson
preferences[Keys.FOR_YOU_MINUTES] = profile.forYouMinutes
preferences[Keys.HAS_OPENED_FOR_YOU] = profile.hasOpenedForYou
preferences.remove(Keys.LAST_BACKDROP_URL)
}
@@ -284,6 +384,8 @@ class SettingsStore(private val context: Context) {
username = username,
serverId = preferences[Keys.SERVER_ID],
homeCacheJson = preferences[Keys.HOME_CACHE],
forYouMinutes = preferences[Keys.FOR_YOU_MINUTES] ?: 0,
hasOpenedForYou = preferences[Keys.HAS_OPENED_FOR_YOU] ?: false,
)
}
@@ -310,10 +412,13 @@ class SettingsStore(private val context: Context) {
updateRepo = preferences[Keys.UPDATE_REPO],
updateToken = preferences[Keys.UPDATE_TOKEN],
showTitleLogo = preferences[Keys.SHOW_TITLE_LOGO] ?: true,
autoPlayNextEpisode = preferences[Keys.AUTO_PLAY_NEXT] ?: true,
ringColorHex = preferences[Keys.RING_COLOR] ?: Settings.DEFAULT_RING_COLOR,
lastBackdropUrl = preferences[Keys.LAST_BACKDROP_URL],
homeSections = preferences[Keys.HOME_SECTIONS] ?: Settings.DEFAULT_HOME_SECTIONS,
homeCacheJson = preferences[Keys.HOME_CACHE],
forYouMinutes = preferences[Keys.FOR_YOU_MINUTES] ?: 0,
hasOpenedForYou = preferences[Keys.HAS_OPENED_FOR_YOU] ?: false,
homeCardDensity = preferences[Keys.HOME_CARD_DENSITY] ?: Settings.DEFAULT_HOME_CARD_DENSITY,
showHomeCardMetadata = preferences[Keys.SHOW_HOME_CARD_METADATA] ?: true,
profiles = profiles,
@@ -38,11 +38,84 @@ data class UserItemData(
@Serializable
data class PlaybackReport(
@SerialName("ItemId") val itemId: String,
@SerialName("MediaSourceId") val mediaSourceId: String,
@SerialName("PlaySessionId") val playSessionId: 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",
@SerialName("EventName") val eventName: String? = null,
)
@Serializable
data class PlaybackInfoRequest(
@SerialName("Id") val id: String,
@SerialName("UserId") val userId: String,
@SerialName("IsPlayback") val isPlayback: Boolean = true,
@SerialName("StartTimeTicks") val startTimeTicks: Long = 0,
@SerialName("SubtitleStreamIndex") val subtitleStreamIndex: Int? = null,
@SerialName("CurrentPlaySessionId") val currentPlaySessionId: String? = null,
@SerialName("DeviceProfile") val deviceProfile: DeviceProfile = DeviceProfile.embyAndroidTv(),
)
@Serializable
data class DeviceProfile(
@SerialName("Name") val name: String,
@SerialName("SupportedMediaTypes") val supportedMediaTypes: String = "Video",
@SerialName("SubtitleProfiles") val subtitleProfiles: List<SubtitleProfile>,
@SerialName("DirectPlayProfiles") val directPlayProfiles: List<DirectPlayProfile>,
@SerialName("TranscodingProfiles") val transcodingProfiles: List<TranscodingProfile>,
) {
companion object {
fun embyAndroidTv() = DeviceProfile(
name = "Memby Android TV",
subtitleProfiles = listOf(
"srt", "subrip", "ass", "ssa", "vtt", "webvtt", "mov_text", "tx3g",
).map { SubtitleProfile(it, "External") } + listOf(
"pgs", "pgssub", "sup", "vobsub", "dvdsub", "dvbsub",
).map { SubtitleProfile(it, "Encode") },
directPlayProfiles = listOf(
DirectPlayProfile(
container = "mkv,mp4,m4v,mov,webm,ts,mpegts,avi",
videoCodec = "h264,hevc,vp8,vp9,av1,mpeg2video,mpeg4",
audioCodec = "aac,ac3,eac3,mp3,opus,vorbis,flac,pcm",
),
),
transcodingProfiles = listOf(
TranscodingProfile(
container = "ts",
videoCodec = "h264",
audioCodec = "aac",
protocol = "hls",
),
),
)
}
}
@Serializable
data class DirectPlayProfile(
@SerialName("Container") val container: String,
@SerialName("VideoCodec") val videoCodec: String,
@SerialName("AudioCodec") val audioCodec: String,
@SerialName("Type") val type: String = "Video",
)
@Serializable
data class TranscodingProfile(
@SerialName("Container") val container: String,
@SerialName("VideoCodec") val videoCodec: String,
@SerialName("AudioCodec") val audioCodec: String,
@SerialName("Protocol") val protocol: String,
@SerialName("Type") val type: String = "Video",
@SerialName("Context") val context: String = "Streaming",
)
@Serializable
data class SubtitleProfile(
@SerialName("Format") val format: String,
@SerialName("Method") val method: String,
)
@Serializable
@@ -52,9 +125,20 @@ data class Studio(
@Serializable
data class MediaStream(
@SerialName("Index") val index: Int = -1,
@SerialName("Type") val type: String = "",
@SerialName("Codec") val codec: String? = null,
@SerialName("Title") val title: String? = null,
@SerialName("DisplayTitle") val displayTitle: String? = null,
@SerialName("Language") val language: String? = null,
@SerialName("IsDefault") val isDefault: Boolean = false,
@SerialName("IsForced") val isForced: Boolean = false,
@SerialName("IsHearingImpaired") val isHearingImpaired: Boolean = false,
@SerialName("IsExternal") val isExternal: Boolean = false,
@SerialName("IsTextSubtitleStream") val isTextSubtitleStream: Boolean = false,
@SerialName("SupportsExternalStream") val supportsExternalStream: Boolean = false,
@SerialName("DeliveryUrl") val deliveryUrl: String? = null,
@SerialName("DeliveryMethod") val deliveryMethod: String? = null,
@SerialName("Width") val width: Int? = null,
@SerialName("Height") val height: Int? = null,
@SerialName("VideoRange") val videoRange: String? = null,
@@ -62,6 +146,31 @@ data class MediaStream(
@SerialName("Channels") val channels: Int? = null,
)
@Serializable
data class PlaybackInfo(
@SerialName("MediaSources") val mediaSources: List<MediaSourceInfo> = emptyList(),
@SerialName("PlaySessionId") val playSessionId: String = "",
)
@Serializable
data class MediaSourceInfo(
@SerialName("Id") val id: String = "",
@SerialName("MediaStreams") val mediaStreams: List<MediaStream> = emptyList(),
@SerialName("DirectStreamUrl") val directStreamUrl: String? = null,
@SerialName("TranscodingUrl") val transcodingUrl: String? = null,
)
@Serializable
data class EmbyPerson(
@SerialName("Id") val id: String = "",
@SerialName("Name") val name: String = "",
@SerialName("Role") val role: String? = null,
@SerialName("Type") val type: String = "",
@SerialName("PrimaryImageTag") val primaryImageTag: String? = null,
) {
val isCastMember: Boolean get() = type.equals("Actor", ignoreCase = true)
}
@Serializable
data class BaseItem(
@SerialName("Id") val id: String,
@@ -76,11 +185,15 @@ data class BaseItem(
@SerialName("RunTimeTicks") val runTimeTicks: Long? = null,
@SerialName("Genres") val genres: List<String> = emptyList(),
@SerialName("MediaStreams") val mediaStreams: List<MediaStream> = emptyList(),
@SerialName("People") val people: List<EmbyPerson> = 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,
// Emby returns these on episodes without being asked, so they cost no extra Fields.
@SerialName("IndexNumber") val indexNumber: Int? = null,
@SerialName("ParentIndexNumber") val parentIndexNumber: Int? = null,
@SerialName("ParentBackdropItemId") val parentBackdropItemId: String? = null,
@SerialName("ParentBackdropImageTags") val parentBackdropImageTags: List<String> = emptyList(),
@SerialName("ParentLogoItemId") val parentLogoItemId: String? = null,
@@ -96,12 +209,26 @@ data class BaseItem(
@SerialName("MembyAvailability") val membyAvailability: String? = null,
@SerialName("MembyAvailabilityText") val membyAvailabilityText: String? = null,
@SerialName("MembyPlayable") val membyPlayable: Boolean = true,
// Derived by the TV from the Sonarr schedule row and retained in the local home cache.
@SerialName("MembyAiringToday") val membyAiringToday: Boolean = false,
// Explainability supplied only by the gateway's dedicated For You endpoint.
@SerialName("MembyRecommendationReason") val membyRecommendationReason: String? = null,
@SerialName("MembyCompatibility") val membyCompatibility: String? = null,
) {
val isMovie: Boolean get() = type.equals("Movie", ignoreCase = true)
val isSeries: Boolean get() = type.equals("Series", ignoreCase = true)
val isEpisode: Boolean get() = type.equals("Episode", ignoreCase = true)
val isFavorite: Boolean get() = userData?.isFavorite == true
val isSonarrSchedule: Boolean get() = membySource == "sonarr"
val cast: List<EmbyPerson> get() = people.filter(EmbyPerson::isCastMember)
/** "S2 · E5" when the season is known, "E5" when only the episode is, else null. */
val episodeCode: String?
get() {
val episode = indexNumber ?: return null
val season = parentIndexNumber
return if (season != null) "S$season · E$episode" else "E$episode"
}
/** Runtime in whole minutes, or null when unknown. */
val runtimeMinutes: Int?
@@ -104,6 +104,29 @@ data class GatewayUpdate(
data class GatewayServiceStatus(
val maintenance: Boolean = false,
val message: String = "",
val alerts: List<GatewayAlert> = emptyList(),
val compatible: Boolean = true,
val compatibilityMessage: String = "",
val clientVersion: String = "",
val clientProtocol: String = "",
val serverProtocol: Int = 0,
)
/**
* An informational nudge riding along on the status poll — currently "this episode aired
* and is on its way into Emby". It is never actionable: the banner slides in, states the
* news and leaves. Unknown [kind] values still render, so the server can add one without
* an app release.
*/
@Serializable
data class GatewayAlert(
val id: String = "",
val kind: String = "",
val title: String = "",
val message: String = "",
val itemId: String = "",
val imageTag: String = "",
val airedAt: String = "",
)
/** Response of `GET /v1/recommendations`. */
@@ -117,12 +140,52 @@ data class GatewayItems(
val items: List<BaseItem> = emptyList(),
)
@Serializable
data class GatewaySearchHistory(
val queries: List<String> = emptyList(),
)
@Serializable
data class GatewayPrerollSchedule(
val today: List<GatewayPrerollEntry> = emptyList(),
val thisWeek: List<GatewayPrerollEntry> = emptyList(),
)
@Serializable
data class GatewayPrerollEntry(
val series: String = "",
val episode: String = "",
val episodeCode: String = "",
val schedule: String = "",
val availability: String = "",
)
@Serializable
data class GatewayPlayback(
val itemId: String,
val title: String = "",
val url: String,
val resumePositionMs: Long = 0,
val subtitles: List<com.ponzischeme89.memby.data.PlayableSubtitle> = emptyList(),
val mediaSourceId: String = "",
val playSessionId: String = "",
val playMethod: String = "DirectPlay",
)
/**
* The episode that follows the one being watched. [item] is Emby's item JSON forwarded
* verbatim, so it decodes into the same [BaseItem] used everywhere else.
*/
@Serializable
data class GatewayNextEpisode(
val item: BaseItem,
val title: String = "",
val url: String,
val resumePositionMs: Long = 0,
val subtitles: List<com.ponzischeme89.memby.data.PlayableSubtitle> = emptyList(),
val mediaSourceId: String = "",
val playSessionId: String = "",
val playMethod: String = "DirectPlay",
)
@Serializable
@@ -151,4 +214,8 @@ data class GatewayPlaybackReport(
val itemId: String,
val positionMs: Long,
val isPaused: Boolean = false,
val mediaSourceId: String = "",
val playSessionId: String = "",
val playMethod: String = "DirectPlay",
val eventName: String? = null,
)
@@ -4,6 +4,8 @@ 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.PlaybackInfo
import com.ponzischeme89.memby.data.model.PlaybackInfoRequest
import com.ponzischeme89.memby.data.model.UserItemData
import retrofit2.http.Body
import retrofit2.http.DELETE
@@ -31,6 +33,20 @@ interface EmbyApi {
@Query("Fields") fields: String,
): com.ponzischeme89.memby.data.model.BaseItem
@POST("Items/{itemId}/PlaybackInfo")
suspend fun getPlaybackInfo(
@Path("itemId") itemId: String,
@Query("UserId") userId: String,
@Query("IsPlayback") isPlayback: Boolean = true,
@Body body: PlaybackInfoRequest,
): PlaybackInfo
@GET("Users/{userId}/Items/Resume")
suspend fun getResumeItems(
@Path("userId") userId: String,
@QueryMap params: Map<String, String>,
): ItemsResult
@GET("Users/{userId}/Items/{itemId}/LocalTrailers")
suspend fun getLocalTrailers(
@Path("userId") userId: String,
@@ -7,10 +7,13 @@ 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.GatewayNextEpisode
import com.ponzischeme89.memby.data.model.GatewayPlayback
import com.ponzischeme89.memby.data.model.GatewayPlaybackReport
import com.ponzischeme89.memby.data.model.GatewayPrerollSchedule
import com.ponzischeme89.memby.data.model.GatewayRowEvents
import com.ponzischeme89.memby.data.model.GatewayRows
import com.ponzischeme89.memby.data.model.GatewaySearchHistory
import com.ponzischeme89.memby.data.model.GatewayServiceStatus
import com.ponzischeme89.memby.data.model.GatewayUpdate
import com.ponzischeme89.memby.data.model.UserItemData
@@ -38,6 +41,10 @@ interface GatewayApi {
@POST("v1/auth/logout")
suspend fun logout()
/** Confirms that a token restored from TV storage still exists on the gateway. */
@GET("v1/auth/session")
suspend fun session(): GatewayLoginResponse
@GET("v1/home")
suspend fun home(@Query("limit") limit: Int): GatewayHome
@@ -47,10 +54,22 @@ interface GatewayApi {
@GET("v1/search")
suspend fun search(@Query("q") term: String, @Query("limit") limit: Int): GatewayItems
@POST("v1/search/history")
suspend fun recordSearch(@Body body: Map<String, String>)
@GET("v1/search/history")
suspend fun recentSearches(): GatewaySearchHistory
/** Recommendation rows on their own. `/v1/home` already embeds these when warm. */
@GET("v1/recommendations")
suspend fun recommendations(): GatewayRows
@GET("v1/for-you")
suspend fun forYou(@Query("minutes") availableMinutes: Int): GatewayRows
@GET("v1/preroll")
suspend fun prerollSchedule(): GatewayPrerollSchedule
/**
* Whether this build should update. The version travels as a header on every request
* (see GatewayServiceFactory), so there is nothing to pass here.
@@ -65,14 +84,26 @@ interface GatewayApi {
@GET("v1/items/{id}")
suspend fun item(@Path("id") itemId: String): BaseItem
/** All episodes for a series in display order; the client groups them into seasons. */
@GET("v1/items/{id}/episodes")
suspend fun seriesEpisodes(@Path("id") seriesId: String): GatewayItems
@GET("v1/items/{id}/playback")
suspend fun playback(
@Path("id") itemId: String,
@Query("type") itemType: String,
@Query("title") title: String,
@Query("resumePositionMs") resumePositionMs: Long,
@Query("subtitleIndex") subtitleIndex: Int? = null,
): GatewayPlayback
/** 404 when nothing follows this item: a movie, or a series finale. */
@GET("v1/items/{id}/next")
suspend fun nextEpisode(
@Path("id") itemId: String,
@Query("seriesId") seriesId: String,
): GatewayNextEpisode
@GET("v1/items/{id}/trailer")
suspend fun trailer(@Path("id") itemId: String): BaseItem
@@ -51,9 +51,12 @@ private class GatewayAuthInterceptor(private val tokenProvider: () -> String?) :
// The gateway decides whether this build needs updating, so every request
// says which build it is.
.header("X-Memby-Version", BuildConfig.VERSION_NAME)
.header("X-Memby-Protocol", MEMBY_PROTOCOL_VERSION.toString())
tokenProvider()?.takeIf { it.isNotBlank() }?.let {
builder.header("Authorization", "Bearer $it")
}
return chain.proceed(builder.build())
}
}
internal const val MEMBY_PROTOCOL_VERSION = 1
@@ -3,12 +3,14 @@ package com.ponzischeme89.memby.ui
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.tween
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.focusGroup
import androidx.compose.foundation.focusable
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -26,14 +28,16 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.requiredWidth
import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -44,17 +48,18 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.focusRestorer
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.layout
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
@@ -76,12 +81,14 @@ import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.ChevronLeft
import androidx.compose.material.icons.filled.ChevronRight
import androidx.compose.material.icons.filled.Favorite
import androidx.compose.material.icons.filled.FavoriteBorder
import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.filled.LiveTv
import androidx.compose.material.icons.filled.Movie
import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.PlayCircleFilled
import androidx.compose.material.icons.filled.Recommend
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.SentimentVerySatisfied
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.SkipNext
@@ -95,21 +102,27 @@ import coil.compose.AsyncImage
import coil.request.ImageRequest
import com.ponzischeme89.memby.ServiceLocator
import com.ponzischeme89.memby.R
import com.ponzischeme89.memby.BuildConfig
import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.data.model.EmbyPerson
import java.util.Locale
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
private val EmbyGreen = Color(0xFF52B54B)
private val RailSurface = Color(0xF20C0F12)
private val MutedText = Color(0xFFB7BDC3)
private val QuietText = Color(0xFF8C949B)
// High-contrast neutrals tuned for the app's near-black surfaces. Quiet remains visibly
// secondary from TV distance without falling into low-contrast grey-on-black.
private val MutedText = Color(0xFFD0D6DB)
private val QuietText = Color(0xFFAEB7BF)
internal val TvRailCollapsedWidth = 54.dp
internal val TvRailExpandedWidth = 184.dp
internal val TvRailContentShift = 112.dp
enum class BrowseDestination(val label: String, val icon: ImageVector) {
HOME("Home", Icons.Default.Home),
FOR_YOU("For You", Icons.Default.AutoAwesome),
SEARCH("Search", Icons.Default.Search),
MOVIES("Movies", Icons.Default.Movie),
SHOWS("TV Shows", Icons.Default.Tv),
FAVORITES("Favourites", Icons.Default.Favorite),
@@ -131,53 +144,44 @@ data class HomeBrowseRow(
private data class HomeRowVisual(
val icon: ImageVector,
val colors: List<Color>,
)
private fun homeRowVisual(row: HomeBrowseRow): HomeRowVisual = when {
row.id == "continue" -> HomeRowVisual(
Icons.Default.PlayCircleFilled,
listOf(Color(0xFF3A7BD5), Color(0xFF6C4DFF)),
)
row.id == "next-up" -> HomeRowVisual(
Icons.Default.SkipNext,
listOf(Color(0xFF00A896), Color(0xFF28C2A0)),
)
row.kind == MediaRowKind.FAVORITES -> HomeRowVisual(
Icons.Default.Favorite,
listOf(Color(0xFFFF5C8A), Color(0xFFB84DFF)),
)
row.id == "latest-movies" -> HomeRowVisual(
Icons.Default.Movie,
listOf(Color(0xFFFF8A3D), Color(0xFFE34B6F)),
)
row.id == "sonarr-airing-today" -> HomeRowVisual(
Icons.Default.CalendarMonth,
listOf(Color(0xFF00A9CE), Color(0xFF4169E1)),
)
row.id == "curated:apple-tv" -> HomeRowVisual(
Icons.Default.LiveTv,
listOf(Color(0xFF252A31), Color(0xFF66717E)),
)
row.id == "curated:drama-shows" -> HomeRowVisual(
Icons.Default.TheaterComedy,
listOf(Color(0xFF6A3FB5), Color(0xFFB64272)),
)
row.id == "curated:comedy-shows" -> HomeRowVisual(
Icons.Default.SentimentVerySatisfied,
listOf(Color(0xFFFFB52E), Color(0xFFFF7433)),
)
row.id.startsWith("similar:") -> HomeRowVisual(
Icons.Default.AutoAwesome,
listOf(Color(0xFF8A5CF6), Color(0xFFE458A3)),
)
row.id == "recommended" -> HomeRowVisual(
Icons.Default.Recommend,
listOf(Color(0xFF45B649), Color(0xFF00A896)),
)
row.id.startsWith("for-you:") -> HomeRowVisual(
Icons.Default.AutoAwesome,
)
else -> HomeRowVisual(
Icons.Default.VideoLibrary,
listOf(Color(0xFF536976), Color(0xFF738A96)),
)
}
@@ -186,12 +190,34 @@ fun TvNavigationRail(
selected: BrowseDestination,
expanded: Boolean,
navigationFocusRequester: FocusRequester,
contentFocusRequester: FocusRequester,
onRailFocusChanged: (Boolean) -> Unit,
onDestinationSelected: (BrowseDestination) -> Unit,
modifier: Modifier = Modifier,
) {
val railWidth by animateDpAsState(
var railHasFocus by remember { mutableStateOf(false) }
val logoScale = remember { Animatable(0.72f) }
val logoAlpha = remember { Animatable(0f) }
val logoRotation = remember { Animatable(0f) }
LaunchedEffect(Unit) {
kotlinx.coroutines.coroutineScope {
launch { logoScale.animateTo(1f, tween(620)) }
launch { logoAlpha.animateTo(1f, tween(420)) }
}
}
LaunchedEffect(selected) {
if (selected == BrowseDestination.HOME) {
logoRotation.snapTo(0f)
logoRotation.animateTo(360f, tween(850))
}
}
LaunchedEffect(railHasFocus) {
if (railHasFocus) {
// Spatial focus may initially choose the first rail item (Home). Correct that
// only as focus enters the rail; subsequent up/down movement must remain free.
runCatching { navigationFocusRequester.requestFocus() }
}
}
val railWidth = animateDpAsState(
targetValue = if (expanded) TvRailExpandedWidth else TvRailCollapsedWidth,
animationSpec = tween(160),
label = "navigation-rail-width",
@@ -207,10 +233,30 @@ fun TvNavigationRail(
// The parent deliberately reports only the collapsed footprint to the
// home Row. requiredWidth lets the focused surface draw outward without
// remeasuring gallery cards or clipping their labels to 54dp.
.wrapContentSize(Alignment.TopStart, unbounded = true)
.requiredWidth(railWidth)
// Only width may escape the collapsed 54dp footprint. Unbounding height
// here would make fillMaxHeight lose the screen constraint and stop the
// rail surface at its final child (the version label).
.wrapContentWidth(Alignment.Start, unbounded = true)
// Read in the measure lambda, not the modifier chain: expanding the rail
// is the most frequent animation in the app, and reading the animated Dp
// during composition recomposes the rail and every item in it per frame.
.layout { measurable, constraints ->
val width = railWidth.value.roundToPx()
val placeable = measurable.measure(
constraints.copy(minWidth = width, maxWidth = width),
)
layout(placeable.width, placeable.height) { placeable.place(0, 0) }
}
.fillMaxHeight()
.background(RailSurface)
.onFocusChanged { focusState ->
val hasFocus = focusState.hasFocus
if (railHasFocus != hasFocus) {
railHasFocus = hasFocus
onRailFocusChanged(hasFocus)
}
}
.focusGroup()
.padding(horizontal = 5.dp, vertical = 15.dp),
horizontalAlignment = Alignment.Start,
) {
@@ -222,7 +268,15 @@ fun TvNavigationRail(
Image(
painter = painterResource(R.drawable.emby_logo),
contentDescription = "Memby",
modifier = Modifier.width(32.dp).height(27.dp),
modifier = Modifier
.width(32.dp)
.height(27.dp)
.graphicsLayer {
scaleX = logoScale.value
scaleY = logoScale.value
alpha = logoAlpha.value
rotationZ = logoRotation.value
},
)
if (expanded) {
Column(verticalArrangement = Arrangement.Center) {
@@ -251,12 +305,20 @@ fun TvNavigationRail(
selected = destination == selected,
expanded = expanded,
modifier = if (destination == selected) Modifier.focusRequester(navigationFocusRequester) else Modifier,
contentFocusRequester = contentFocusRequester,
onFocused = { onRailFocusChanged(true) },
onFocused = {},
onClick = { onDestinationSelected(destination) },
)
Spacer(Modifier.height(4.dp))
}
Spacer(Modifier.weight(1f))
Text(
if (expanded) "Version ${BuildConfig.VERSION_NAME}" else "v${BuildConfig.VERSION_NAME}",
color = QuietText,
fontSize = if (expanded) 10.sp else 8.sp,
fontWeight = FontWeight.Medium,
maxLines = 1,
modifier = Modifier.padding(horizontal = if (expanded) 8.dp else 4.dp),
)
}
}
}
@@ -266,13 +328,14 @@ fun ExpandableNavigationItem(
destination: BrowseDestination,
selected: Boolean,
expanded: Boolean,
contentFocusRequester: FocusRequester,
onFocused: () -> Unit,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
var focused by remember { mutableStateOf(false) }
val background by animateColorAsState(
// Not `by`: both colours are read in the draw phase / at the point of use, so the
// 85ms focus transition repaints this row rather than recomposing it.
val background = animateColorAsState(
targetValue = when {
focused -> Color.White.copy(alpha = 0.13f)
selected -> EmbyGreen.copy(alpha = 0.10f)
@@ -281,7 +344,7 @@ fun ExpandableNavigationItem(
animationSpec = tween(85),
label = "navigation-item-background",
)
val foreground by animateColorAsState(
val foreground = animateColorAsState(
targetValue = when {
focused -> Color.White
selected -> EmbyGreen
@@ -294,13 +357,12 @@ fun ExpandableNavigationItem(
modifier = modifier
.fillMaxWidth()
.height(44.dp)
.focusProperties { right = contentFocusRequester }
.onFocusChanged {
focused = it.isFocused
if (it.isFocused) onFocused()
}
.clip(RoundedCornerShape(8.dp))
.background(background)
.drawBehind { drawRect(background.value) }
.clickable(onClick = onClick)
.semantics { contentDescription = destination.label }
.padding(horizontal = 8.dp),
@@ -308,7 +370,7 @@ fun ExpandableNavigationItem(
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Box(Modifier.width(28.dp), contentAlignment = Alignment.Center) {
Icon(destination.icon, contentDescription = null, tint = foreground, modifier = Modifier.size(21.dp))
Icon(destination.icon, contentDescription = null, tint = foreground.value, modifier = Modifier.size(21.dp))
if (selected) {
Box(
Modifier
@@ -322,7 +384,7 @@ fun ExpandableNavigationItem(
if (expanded) {
Text(
destination.label,
color = foreground,
color = foreground.value,
fontSize = 15.sp,
fontWeight = if (focused || selected) FontWeight.SemiBold else FontWeight.Medium,
maxLines = 1,
@@ -390,7 +452,6 @@ fun MediaMetadataPanel(
item: BaseItem?,
loading: Boolean,
sectionLabel: String,
contentFocusRequester: FocusRequester,
navigationFocusRequester: FocusRequester,
onPlay: (BaseItem) -> Unit,
onContentFocused: () -> Unit,
@@ -414,7 +475,6 @@ fun MediaMetadataPanel(
onClick = { item?.let(onPlay) },
enabled = item != null,
modifier = Modifier
.focusRequester(contentFocusRequester)
.focusProperties { left = navigationFocusRequester }
.onFocusChanged { if (it.isFocused) onContentFocused() },
) {
@@ -477,7 +537,7 @@ fun MediaDetailsOverlay(
modifier = Modifier
.fillMaxHeight()
.fillMaxWidth(0.68f)
.padding(start = 72.dp, end = 36.dp, top = 60.dp, bottom = 48.dp),
.padding(start = 72.dp, end = 36.dp, top = 38.dp, bottom = 32.dp),
verticalArrangement = Arrangement.Center,
) {
Text(
@@ -517,16 +577,24 @@ fun MediaDetailsOverlay(
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
if (item.membyAiringToday) {
Spacer(Modifier.height(8.dp))
MediaBadge("AIRING TODAY")
}
Spacer(Modifier.height(16.dp))
Text(
item.overview?.takeIf(String::isNotBlank) ?: "No description available.",
color = Color(0xFFD8DCDF),
fontSize = 17.sp,
lineHeight = 23.sp,
maxLines = 4,
maxLines = 3,
overflow = TextOverflow.Ellipsis,
)
Spacer(Modifier.height(22.dp))
if (item.cast.isNotEmpty()) {
Spacer(Modifier.height(14.dp))
CastRail(item.cast, compact = true)
}
Spacer(Modifier.height(16.dp))
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Button(
onClick = { onPlay(item) },
@@ -636,7 +704,21 @@ private fun MetadataContent(item: BaseItem, sectionLabel: String) {
if (facts.isNotEmpty()) {
Text(facts.joinToString(""), color = MutedText, fontSize = 14.sp, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
val badges = mediaBadges(item)
val badges = buildList {
if (item.membyAiringToday) add("AIRING TODAY")
addAll(mediaBadges(item))
}
item.membyRecommendationReason?.takeIf(String::isNotBlank)?.let {
Text(
text = it,
color = EmbyGreen,
fontSize = 14.sp,
fontWeight = FontWeight.SemiBold,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.fillMaxWidth(0.78f),
)
}
if (badges.isNotEmpty()) {
Row(horizontalArrangement = Arrangement.spacedBy(7.dp)) {
badges.forEach { MediaBadge(it) }
@@ -785,6 +867,11 @@ fun MediaRow(
) {
val rowState = rememberSaveable(row.id, saver = LazyListState.Saver) { LazyListState() }
val scope = rememberCoroutineScope()
// firstVisibleItemIndex changes on every scroll frame; read it through derivedStateOf so
// only the button's enabled/disabled flip recomposes the row header.
val canScrollBack by remember(rowState) {
derivedStateOf { rowState.firstVisibleItemIndex > 0 }
}
val pageSize = if (
row.items.firstOrNull()?.let { cardFormat(row.kind, it) } == MediaCardFormat.PORTRAIT
) 6 else 4
@@ -798,8 +885,8 @@ fun MediaRow(
modifier = Modifier
.size(28.dp)
.clip(CircleShape)
.background(Brush.linearGradient(visual.colors))
.border(1.dp, Color.White.copy(alpha = 0.20f), CircleShape),
.background(Color.White.copy(alpha = 0.08f))
.border(1.dp, Color.White.copy(alpha = 0.18f), CircleShape),
contentAlignment = Alignment.Center,
) {
Icon(
@@ -814,13 +901,13 @@ fun MediaRow(
row.title,
color = Color(0xFFF1F3F4),
fontSize = 20.sp,
fontWeight = FontWeight.SemiBold,
fontWeight = FontWeight.Bold,
)
Spacer(Modifier.weight(1f))
if (row.items.isNotEmpty()) {
GalleryJumpButton(
forward = false,
enabled = rowState.firstVisibleItemIndex > 0,
enabled = canScrollBack,
onClick = {
val target = (rowState.firstVisibleItemIndex - pageSize).coerceAtLeast(0)
scope.launch { rowState.scrollToItem(target) }
@@ -852,6 +939,12 @@ fun MediaRow(
}
}
}
row.items.isEmpty() && row.id == "favourite-shows" -> {
FavoriteShowsEmptyState(
navigationFocusRequester = navigationFocusRequester,
onContentFocused = onContentFocused,
)
}
row.items.isEmpty() -> {
Text(
row.emptyMessage,
@@ -865,7 +958,11 @@ fun MediaRow(
state = rowState,
contentPadding = PaddingValues(horizontal = 36.dp, vertical = 10.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp),
modifier = Modifier.fillMaxWidth().focusGroup().focusRestorer(),
// focusRestorer pins a lazy item. Replacing a For You result set
// can dispose that item during a focus transfer and make Compose
// release the same pin twice. LazyListState already preserves the
// row position; native TV spatial search safely handles row changes.
modifier = Modifier.fillMaxWidth().focusGroup(),
) {
itemsIndexed(
row.items,
@@ -874,7 +971,6 @@ fun MediaRow(
) { index, item ->
var cardModifier: Modifier = Modifier
if (index == 0) {
cardModifier = cardModifier.focusProperties { left = navigationFocusRequester }
if (contentEntryFocusRequester != null) {
cardModifier = cardModifier.focusRequester(contentEntryFocusRequester)
}
@@ -910,6 +1006,172 @@ fun MediaRow(
}
}
@Composable
internal fun CastRail(
people: List<EmbyPerson>,
modifier: Modifier = Modifier,
compact: Boolean = false,
showTitle: Boolean = true,
) {
val cast = remember(people) { people.filter(EmbyPerson::isCastMember).take(16) }
if (cast.isEmpty()) return
Column(modifier) {
if (showTitle) {
Text(
"Cast",
color = Color.White,
fontSize = if (compact) 16.sp else 21.sp,
fontWeight = FontWeight.SemiBold,
)
}
LazyRow(
horizontalArrangement = Arrangement.spacedBy(if (compact) 10.dp else 16.dp),
contentPadding = PaddingValues(
top = if (showTitle) 7.dp else 3.dp,
end = 24.dp,
bottom = 4.dp,
),
) {
items(cast, key = { "${it.id}:${it.name}" }) { person ->
CastCard(person, compact)
}
}
}
}
@Composable
private fun CastCard(person: EmbyPerson, compact: Boolean) {
val repository = ServiceLocator.repository
val portrait = remember(person.id, person.primaryImageTag) {
repository.personImageUrl(person, if (compact) 180 else 280)
}
var focused by remember { mutableStateOf(false) }
val width = if (compact) 72.dp else 112.dp
val height = if (compact) 76.dp else 142.dp
val shape = RoundedCornerShape(if (compact) 8.dp else 10.dp)
val scale by animateFloatAsState(
targetValue = if (focused) 1.045f else 1f,
animationSpec = tween(110),
label = "cast-card-focus",
)
Column(
Modifier
.width(width)
.graphicsLayer {
scaleX = scale
scaleY = scale
}
.onFocusChanged { focused = it.isFocused }
.focusable(),
) {
Box(
Modifier
.fillMaxWidth()
.height(height)
.clip(shape)
.background(Color(0xFF24292E))
.border(
if (focused) 2.dp else 1.dp,
if (focused) Color.White else Color.White.copy(alpha = 0.10f),
shape,
),
contentAlignment = Alignment.Center,
) {
if (portrait != null) {
AsyncImage(
model = portrait,
contentDescription = person.name,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize(),
)
} else {
Icon(
Icons.Default.Person,
contentDescription = null,
tint = QuietText,
modifier = Modifier.size(if (compact) 28.dp else 40.dp),
)
}
}
Text(
person.name,
color = Color.White,
fontSize = if (compact) 11.sp else 14.sp,
fontWeight = FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(top = 5.dp),
)
person.role?.takeIf(String::isNotBlank)?.let { role ->
Text(
role,
color = QuietText,
fontSize = if (compact) 9.sp else 11.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
@Composable
private fun FavoriteShowsEmptyState(
navigationFocusRequester: FocusRequester,
onContentFocused: () -> Unit,
) {
var focused by remember { mutableStateOf(false) }
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 36.dp, vertical = 10.dp)
.clip(RoundedCornerShape(14.dp))
.background(Color.White.copy(alpha = if (focused) 0.11f else 0.055f))
.border(
2.dp,
if (focused) Color.White else Color.White.copy(alpha = 0.14f),
RoundedCornerShape(14.dp),
)
.focusProperties { left = navigationFocusRequester }
.onFocusChanged {
focused = it.isFocused
if (it.isFocused) onContentFocused()
}
.focusable()
.padding(horizontal = 22.dp, vertical = 20.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(18.dp),
) {
Box(
modifier = Modifier
.size(46.dp)
.clip(CircleShape)
.background(Color.White.copy(alpha = 0.09f)),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = Icons.Default.FavoriteBorder,
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(25.dp),
)
}
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(
"No favorite shows yet",
color = Color.White,
fontSize = 17.sp,
fontWeight = FontWeight.Bold,
)
Text(
"Mark a series as a favorite and itll be waiting here.",
color = MutedText,
fontSize = 14.sp,
fontWeight = FontWeight.Medium,
)
}
}
}
@Composable
private fun GalleryJumpButton(
forward: Boolean,
@@ -971,6 +1233,28 @@ fun PortraitMediaCard(
)
}
/**
* A poster card at an explicit width, for grids rather than rows.
*
* [PortraitMediaCard] derives its width from the row's available width, which is the
* wrong input for a grid whose column count is already decided. Same card underneath —
* same artwork loading, badges and focus treatment.
*/
@Composable
fun PosterGridCard(
item: BaseItem,
width: Dp,
onFocused: () -> Unit,
onClick: () -> Unit,
onLongClick: () -> Unit,
modifier: Modifier = Modifier,
) {
MediaCard(
item, width, 2f / 3f, preferPrimary = true, showProgress = false,
showSecondaryMetadata = true, onFocused, onClick, onLongClick, modifier,
)
}
@Composable
fun LandscapeMediaCard(
item: BaseItem,
@@ -1138,12 +1422,18 @@ private fun MediaCard(
modifier = Modifier.align(Alignment.TopEnd).padding(8.dp),
)
}
if (item.membyAiringToday) {
MediaBadge(
"AIRING TODAY",
modifier = Modifier.align(Alignment.TopStart).padding(8.dp),
)
}
}
Text(
item.name,
color = if (focused) Color.White else Color(0xFFD1D5D8),
color = if (focused) Color.White else Color(0xFFE1E5E8),
fontSize = 14.sp,
fontWeight = if (focused) FontWeight.SemiBold else FontWeight.Medium,
fontWeight = if (focused) FontWeight.Bold else FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(top = 7.dp).fillMaxWidth(),
@@ -1153,6 +1443,7 @@ private fun MediaCard(
cardSubtitle(item, showProgress, position),
color = QuietText,
fontSize = 12.sp,
fontWeight = FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(top = 2.dp).fillMaxWidth(),
@@ -1190,14 +1481,14 @@ private fun ScheduleStatusBadge(status: String, modifier: Modifier = Modifier) {
fun FocusScaleContainer(
onFocused: () -> Unit,
onClick: () -> Unit,
onLongClick: (() -> Unit)? = null,
contentDescription: String,
modifier: Modifier = Modifier,
onLongClick: (() -> Unit)? = null,
content: @Composable BoxScope.(focused: Boolean) -> Unit,
) {
var focused by remember { mutableStateOf(false) }
var remoteLongPressHandled by remember { mutableStateOf(false) }
val scale by animateFloatAsState(
val scale = animateFloatAsState(
targetValue = if (focused) 1.025f else 1f,
animationSpec = tween(95),
label = "media-card-focus",
@@ -1205,16 +1496,13 @@ fun FocusScaleContainer(
Box(
modifier = modifier
.zIndex(if (focused) 1f else 0f)
.then(
if (focused || scale != 1f) {
Modifier.graphicsLayer {
scaleX = scale
scaleY = scale
}
} else {
Modifier
},
)
// Always a graphicsLayer, and the scale read inside it. Branching on
// `scale != 1f` meant reading the animated value during composition, so
// every card recomposed on each frame of its own focus animation.
.graphicsLayer {
scaleX = scale.value
scaleY = scale.value
}
.onFocusChanged {
focused = it.isFocused
if (it.isFocused) onFocused()
@@ -1309,6 +1597,7 @@ private const val BACKDROP_SETTLE_DELAY_MS = 240L
private fun cardSubtitle(item: BaseItem, showProgress: Boolean, positionTicks: Long): String = when {
showProgress && positionTicks > 0L -> "Resume at ${formatTvPosition(positionTicks)}"
!item.membyRecommendationReason.isNullOrBlank() -> item.membyRecommendationReason
item.isSonarrSchedule -> item.membyAirLabel ?: "Airing today"
item.isEpisode -> item.seriesName ?: "Up next"
item.productionYear != null && item.runtimeMinutes != null ->
@@ -5,6 +5,7 @@ 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.HomeSnapshot
import com.ponzischeme89.memby.data.analytics.RowAnalytics
import com.ponzischeme89.memby.data.friendlyEmbyError
import com.ponzischeme89.memby.data.isMaintenanceError
@@ -83,12 +84,21 @@ data class HomeUiState(
}
}
data class ForYouUiState(
val rows: List<HomeRow> = emptyList(),
val availableMinutes: Int = 0,
val loading: Boolean = false,
val error: String? = null,
)
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 val _forYou = MutableStateFlow(ForYouUiState())
val forYou: StateFlow<ForYouUiState> = _forYou.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
@@ -163,6 +173,31 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
repository.reportRowEvents(analytics.drain())
}
fun loadForYou(availableMinutes: Int = _forYou.value.availableMinutes) {
val minutes = availableMinutes.coerceIn(0, 360)
_focusedItem.value = null
_forYou.update { it.copy(availableMinutes = minutes, loading = true, error = null) }
viewModelScope.launch(Dispatchers.IO) {
runCatching { repository.getForYou(minutes) }
.onSuccess { rows ->
_forYou.value = ForYouUiState(
rows = rows,
availableMinutes = minutes,
loading = false,
)
rows.firstNotNullOfOrNull { it.items.firstOrNull() }?.let(::focusItem)
}
.onFailure {
_forYou.update {
it.copy(
loading = false,
error = "For You is temporarily unavailable",
)
}
}
}
}
fun refreshAll() {
viewModelScope.launch(Dispatchers.IO) {
refreshMutex.withLock {
@@ -189,18 +224,19 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
private suspend fun loadBatchHome() {
runCatching { repository.getHome() }
.onSuccess { home ->
val taggedHome = home.withAiringTodayTags()
_state.update { current ->
current.copy(
continueWatching = home.continueWatching,
nextUp = home.nextUp,
favorites = home.favorites,
latestMovies = home.latestMovies,
continueWatching = taggedHome.continueWatching,
nextUp = taggedHome.nextUp,
favorites = taggedHome.favorites,
latestMovies = taggedHome.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 },
rows = taggedHome.rows.ifEmpty { current.rows },
loading = emptySet(),
hasRefreshError = home.partial,
hasRefreshError = taggedHome.partial,
statusMessage = null,
// A successful response is the only thing that clears the
// maintenance screen, so a retry that fails keeps it up.
@@ -210,6 +246,10 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
if (_focusedItem.value == null) {
initialFocusedItem(_state.value)?.let(::focusItem)
}
// Home may have been cached just before the background recommendation
// build completed. Pull the dedicated endpoint after the fast home draw
// so personalized Shows shelves appear on this visit, not a minute later.
refreshRecommendationRows()
}
.onFailure { error ->
val maintenance = isMaintenanceError(error)
@@ -224,13 +264,30 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
}
}
private suspend fun refreshRecommendationRows() {
val fresh = runCatching { repository.getRecommendations() }.getOrNull() ?: return
_state.update { state ->
val airingTodayKeys = state.rows.airingTodayShowKeys()
val taggedFresh = fresh.withAiringTodayRowTags(airingTodayKeys)
val fixedRows = state.rows.filterNot { row ->
row.id == "recommended" ||
row.id.startsWith("similar:") ||
row.id.startsWith("curated:")
}
state.copy(rows = fixedRows + taggedFresh)
}
}
/**
* 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
val focused = (cached ?: item).copy(
membyAiringToday = item.membyAiringToday || cached?.membyAiringToday == true,
)
_focusedItem.value = focused
metadataJob?.cancel()
metadataJob = viewModelScope.launch(Dispatchers.IO) {
delay(FOCUS_METADATA_DEBOUNCE_MS)
@@ -246,9 +303,14 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
val details = runCatching {
repository.getItemDetails(item.id)
}.getOrNull() ?: return@launch
synchronized(metadataCache) { metadataCache[item.id] = details }
val taggedDetails = details.copy(
membyAiringToday = focused.membyAiringToday,
membyRecommendationReason = focused.membyRecommendationReason,
membyCompatibility = focused.membyCompatibility,
)
synchronized(metadataCache) { metadataCache[item.id] = taggedDetails }
if (_focusedItem.value?.id == item.id) {
_focusedItem.value = details
_focusedItem.value = taggedDetails
}
}
}
@@ -411,6 +473,40 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
}
}
internal fun HomeSnapshot.withAiringTodayTags(): HomeSnapshot {
val airingTodayKeys = rows.airingTodayShowKeys()
if (airingTodayKeys.isEmpty()) return this
return copy(
rows = rows.withAiringTodayRowTags(airingTodayKeys),
continueWatching = continueWatching.withAiringTodayItemTags(airingTodayKeys),
nextUp = nextUp.withAiringTodayItemTags(airingTodayKeys),
favorites = favorites.withAiringTodayItemTags(airingTodayKeys),
latestMovies = latestMovies.withAiringTodayItemTags(airingTodayKeys),
)
}
private fun List<HomeRow>.airingTodayShowKeys(): Set<String> =
firstOrNull { it.id == "sonarr-airing-today" }
?.items
.orEmpty()
.mapTo(mutableSetOf()) { it.name.showMatchKey() }
.filterTo(mutableSetOf(), String::isNotEmpty)
private fun List<HomeRow>.withAiringTodayRowTags(keys: Set<String>): List<HomeRow> =
map { row -> row.copy(items = row.items.withAiringTodayItemTags(keys)) }
private fun List<BaseItem>.withAiringTodayItemTags(keys: Set<String>): List<BaseItem> =
map { item ->
if (!item.isSonarrSchedule && item.isSeries && item.name.showMatchKey() in keys) {
item.copy(membyAiringToday = true)
} else {
item
}
}
private fun String.showMatchKey(): String =
lowercase().filter(Char::isLetterOrDigit)
class HomeViewModelFactory(private val repository: EmbyRepository) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T {
File diff suppressed because it is too large Load Diff
@@ -32,6 +32,7 @@ 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.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
@@ -58,7 +59,7 @@ import kotlinx.coroutines.delay
private val MaintenanceAccent = Color(0xFF52B54B)
private val MaintenanceTitle = Color(0xFFF2F5F7)
private val MaintenanceBody = Color(0xFFAEB7BF)
private val MaintenanceFaint = Color(0xFF7E888F)
private val MaintenanceFaint = Color(0xFFA2ADB5)
/** How long between automatic retries while the gateway is down. */
private const val RETRY_SECONDS = 30
@@ -117,7 +118,7 @@ fun MaintenanceScreen(
label = "maintenance-entrance",
)
var secondsLeft by remember { mutableStateOf(RETRY_SECONDS) }
var secondsLeft by remember { mutableIntStateOf(RETRY_SECONDS) }
LaunchedEffect(message) {
// Restarts whenever the message changes, so a failed retry resets the clock.
secondsLeft = RETRY_SECONDS
@@ -194,6 +195,21 @@ fun MaintenanceScreen(
}
}
// The animations here are infinite, so a preview catches them mid-phase. That is fine for
// checking layout and colour; the motion itself only reads correctly on a device.
@TvPreview
@Composable
private fun MaintenanceScreenPreview() {
PreviewSurface {
MaintenanceScreen(
message = "Back after dinner — the library is being reorganised.",
contentFocusRequester = remember { FocusRequester() },
navigationFocusRequester = remember { FocusRequester() },
onRetry = {},
)
}
}
/** Vertical wash plus a radial glow that drifts, so the screen is never quite static. */
@Composable
private fun MaintenanceBackdrop(glow: Float) {
@@ -60,7 +60,7 @@ import kotlinx.coroutines.launch
private val UpdateAccent = Color(0xFF52B54B)
private val UpdateTitle = Color(0xFFF2F5F7)
private val UpdateBody = Color(0xFFAEB7BF)
private val UpdateFaint = Color(0xFF7E888F)
private val UpdateFaint = Color(0xFFA2ADB5)
/**
* The update prompt, shown over the home screen.
File diff suppressed because it is too large Load Diff
@@ -2,7 +2,7 @@ package com.ponzischeme89.memby.ui.screensaver
import android.content.Context
import android.content.Intent
import android.net.Uri
import androidx.core.net.toUri
/** Opens an item in Emby's installed Android or Android TV client. */
internal object EmbyAppLauncher {
@@ -19,7 +19,7 @@ internal object EmbyAppLauncher {
)
for (packageName in packageCandidates) {
for (link in links) {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(link))
val intent = Intent(Intent.ACTION_VIEW, link.toUri())
.setPackage(packageName)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
if (intent.resolveActivity(context.packageManager) != null) {
@@ -42,6 +42,7 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
@@ -79,6 +80,7 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.graphics.drawable.toBitmap
import androidx.core.graphics.get
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.repeatOnLifecycle
@@ -625,7 +627,7 @@ private fun Slideshow(
/** 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()) }
var now by remember { mutableLongStateOf(System.currentTimeMillis()) }
LaunchedEffect(Unit) {
while (true) {
now = System.currentTimeMillis()
@@ -890,7 +892,7 @@ private fun isPredominantlyDarkLogo(bitmap: android.graphics.Bitmap): Boolean {
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)
val pixel = bitmap[x, y]
if (android.graphics.Color.alpha(pixel) < 48) continue
opaquePixels++
val luminance = (
File diff suppressed because it is too large Load Diff
@@ -3,7 +3,8 @@ 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.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.em
import androidx.tv.material3.LocalTextStyle
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.darkColorScheme
@@ -17,11 +18,23 @@ private val EmbyColors = darkColorScheme(
@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))
// Use Android's complete Roboto-backed sans family rather than pinning every label
// to sans-serif-medium. The generic family lets Compose select real regular,
// medium, semibold and bold faces from each Text's FontWeight, restoring hierarchy
// and making body copy substantially easier to scan from TV distance.
//
// A platform family also has every script Android supports, adds no APK weight, and
// cannot flash or fail while a downloadable font is fetched.
val appTextStyle = LocalTextStyle.current.copy(
fontFamily = FontFamily.SansSerif,
fontWeight = FontWeight.Normal,
// Slightly open tracking and leading keep small metadata and multi-line
// summaries from feeling cramped without making large headings look loose.
letterSpacing = 0.006.em,
lineHeight = 1.22.em,
)
MaterialTheme(colorScheme = EmbyColors) {
CompositionLocalProvider(LocalTextStyle provides LocalTextStyle.current.copy(fontFamily = tvFont)) {
CompositionLocalProvider(LocalTextStyle provides appTextStyle) {
content()
}
}
@@ -2,10 +2,10 @@ 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 androidx.core.net.toUri
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
@@ -165,7 +165,7 @@ class UpdateChecker(private val context: Context) {
runCatching {
val intent = Intent(
Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES,
Uri.parse("package:${context.packageName}"),
"package:${context.packageName}".toUri(),
).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
context.startActivity(intent)
}

Before

Width:  |  Height:  |  Size: 849 KiB

After

Width:  |  Height:  |  Size: 849 KiB

+4
View File
@@ -1,7 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- 320x180 is the size the Leanback launcher requires of a banner, so the generic
"keep vectors under 200x200" advice does not apply here. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:width="320dp"
android:height="180dp"
tools:ignore="VectorRaster"
android:viewportWidth="320"
android:viewportHeight="180">
<path
+38 -1
View File
@@ -2,8 +2,45 @@
<string name="player_title_logo">Title logo</string>
<string name="playback_loading">Starting playback…</string>
<string name="playback_loading_hint">Connecting directly to Emby</string>
<string name="playback_reconnecting">Connection interrupted</string>
<string name="playback_retrying_now">Trying the stream again…</string>
<string name="playback_refreshing_stream">Requesting a fresh stream from Emby…</string>
<string name="playback_server_unreachable">Media server unavailable</string>
<string name="playback_server_unreachable_detail">Memby couldnt request a fresh stream. Check that the server is online, then try again.</string>
<string name="playback_try_again">Try again</string>
<string name="playback_back_to_memby">Back to Memby</string>
<plurals name="playback_retrying_in">
<item quantity="one">Reconnecting in %1$d second…</item>
<item quantity="other">Reconnecting in %1$d seconds…</item>
</plurals>
<string name="player_now_playing">NOW PLAYING</string>
<string name="player_position_separator">/</string>
<string name="player_loading_duration">Loading duration…</string>
<string name="player_live">Live</string>
<string name="player_back">Back to previous screen</string>
<string name="player_hide_controls">Hide controls</string>
<string name="player_playback_options">PLAYBACK OPTIONS</string>
<string name="player_subtitles">Subtitles</string>
<string name="player_cast">Cast</string>
<string name="player_cast_loading">Loading cast…</string>
<string name="player_cast_empty">No cast information is available.</string>
<string name="player_subtitle_overlay_hint">Choose a track and tune the text size without leaving playback.</string>
<string name="player_subtitle_track">SUBTITLE TRACK</string>
<string name="player_text_size">TEXT SIZE</string>
<string name="player_back_to_close">BACK · CLOSE</string>
<string name="player_ends_at">Ends at %1$s</string>
<string name="player_preroll_countdown_initial">Starting in 5 seconds…</string>
<plurals name="player_preroll_countdown">
<item quantity="one">Starting in %1$d second…</item>
<item quantity="other">Starting in %1$d seconds…</item>
</plurals>
<string name="player_preroll_starting">Starting now…</string>
<string name="next_up_label">NEXT UP</string>
<string name="next_up_play_now">Play now</string>
<string name="next_up_dismiss">Dismiss</string>
<string name="next_up_starting_in">Starting in %1$ds</string>
<string name="next_up_starting_now">Starting now…</string>
<string name="app_name">Memby</string>
<string name="screensaver_name">Memby Screensaver</string>
<string name="dream_description">Memby movie &amp; TV backdrops</string>
<string name="developer_name">ponzischeme89</string>
</resources>
+2
View File
@@ -1,5 +1,7 @@
<resources>
<style name="Theme.Memby" parent="@android:style/Theme.Material.NoActionBar">
<!-- Match the Compose type system in PlayerActivity and every platform widget. -->
<item name="android:fontFamily">sans-serif</item>
<item name="android:windowBackground">@android:color/black</item>
<item name="android:statusBarColor">@android:color/black</item>
<item name="android:navigationBarColor">@android:color/black</item>
@@ -1,9 +1,13 @@
package com.ponzischeme89.memby.data
import com.ponzischeme89.memby.data.model.GatewayHome
import com.ponzischeme89.memby.data.model.GatewayNextEpisode
import com.ponzischeme89.memby.data.model.GatewayPlayback
import com.ponzischeme89.memby.data.model.GatewaySearchHistory
import com.ponzischeme89.memby.data.model.GatewayServiceStatus
import kotlinx.serialization.json.Json
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
@@ -23,6 +27,17 @@ class GatewayPayloadTest {
explicitNulls = false
}
@Test
fun `decodes explicit client server protocol mismatch`() {
val status = json.decodeFromString<GatewayServiceStatus>(
"""{"maintenance":false,"compatible":false,"compatibilityMessage":"App protocol 2, server protocol 1.","clientVersion":"0.9.1","clientProtocol":"2","serverProtocol":1}""",
)
assertEquals(false, status.compatible)
assertEquals("App protocol 2, server protocol 1.", status.compatibilityMessage)
assertEquals(1, status.serverProtocol)
}
@Test
fun `decodes a home payload with emby-shaped items`() {
val payload = """
@@ -79,6 +94,30 @@ class GatewayPayloadTest {
assertEquals("Solaris", home.rows.last().items.single().name)
}
@Test
fun `decodes explainable For You metadata`() {
val payload = """
{
"rows":[{
"id":"for-you:picks",
"title":"Top picks that fit in 60 minutes",
"kind":"for-you",
"items":[{
"Id":"9","Name":"Pilot","Type":"Episode",
"MembyRecommendationReason":"Matches your Drama viewing · fits your 60-minute window",
"MembyCompatibility":"Direct plays well on this TV"
}]
}]
}
""".trimIndent()
val item = json.decodeFromString<com.ponzischeme89.memby.data.model.GatewayRows>(payload)
.rows.single().items.single()
assertTrue(item.membyRecommendationReason!!.contains("60-minute"))
assertEquals("Direct plays well on this TV", item.membyCompatibility)
}
@Test
fun `decodes the informational Sonarr schedule row`() {
val payload = """
@@ -135,6 +174,15 @@ class GatewayPayloadTest {
assertTrue(home.partial)
}
@Test
fun `decodes persisted recent searches`() {
val history = json.decodeFromString<GatewaySearchHistory>(
"""{"queries":["severance","slow horses","arrival"]}""",
)
assertEquals(listOf("severance", "slow horses", "arrival"), history.queries)
}
@Test
fun `decodes a playback response`() {
val playback = json.decodeFromString<GatewayPlayback>(
@@ -145,6 +193,48 @@ class GatewayPayloadTest {
assertTrue(playback.url.startsWith("https://emby.example/Videos/9/stream"))
}
@Test
fun `decodes the next-episode response the player counts down to`() {
val next = json.decodeFromString<GatewayNextEpisode>(
"""
{
"item": {
"Id":"11","Name":"The Bicameral Mind","Type":"Episode",
"SeriesName":"Westworld","SeriesId":"7",
"IndexNumber":5,"ParentIndexNumber":2,
"ImageTags":{"Primary":"abc"}
},
"title": "Westworld The Bicameral Mind",
"url": "https://emby.example/Videos/11/stream?static=true",
"resumePositionMs": 0
}
""".trimIndent(),
)
assertEquals("11", next.item.id)
assertEquals("Westworld", next.item.seriesName)
// The banner's subtitle is built from these, so they have to survive the wire.
assertEquals("S2 · E5", next.item.episodeCode)
assertEquals(0L, next.resumePositionMs)
assertTrue(next.url.startsWith("https://emby.example/Videos/11/stream"))
}
@Test
fun `an episode without a season still gets a usable code`() {
val next = json.decodeFromString<GatewayNextEpisode>(
"""{"item":{"Id":"12","Name":"Special","Type":"Episode","IndexNumber":3},"url":"https://e/x"}""",
)
assertEquals("E3", next.item.episodeCode)
}
@Test
fun `an item with no episode numbering has no code`() {
val next = json.decodeFromString<GatewayNextEpisode>(
"""{"item":{"Id":"13","Name":"Arrival","Type":"Movie"},"url":"https://e/x"}""",
)
assertNull(next.item.episodeCode)
}
@Test
fun `device allowance response becomes a specific sign-in error`() {
val error = parseDeviceLimit(
@@ -1,5 +1,6 @@
package com.ponzischeme89.memby.data
import com.ponzischeme89.memby.data.model.DeviceProfile
import org.junit.Assert.assertEquals
import org.junit.Test
@@ -13,4 +14,16 @@ class PlaybackReportMathTest {
fun negativePositionsAreClamped() {
assertEquals(0L, millisecondsToTicks(-1L))
}
@Test
fun deviceProfileExternalizesTextAndEncodesBitmapSubtitles() {
val profiles = DeviceProfile.embyAndroidTv().subtitleProfiles.associate {
it.format to it.method
}
assertEquals("External", profiles["srt"])
assertEquals("External", profiles["ass"])
assertEquals("External", profiles["mov_text"])
assertEquals("Encode", profiles["pgssub"])
assertEquals("Encode", profiles["dvdsub"])
}
}
@@ -36,4 +36,21 @@ class ProfileSettingsTest {
assertNull(settings.activeProfileId)
}
@Test
fun `for you choices belong to each profile`() {
val matt = profile.copy(forYouMinutes = 60, hasOpenedForYou = true)
val family = profile.copy(
id = "server::family",
userId = "family",
username = "FamilyTV",
forYouMinutes = 30,
hasOpenedForYou = false,
)
assertEquals(60, matt.forYouMinutes)
assertEquals(true, matt.hasOpenedForYou)
assertEquals(30, family.forYouMinutes)
assertEquals(false, family.hasOpenedForYou)
}
}
@@ -16,6 +16,8 @@ import org.junit.Test
*/
class ServerHomeRowsTest {
private val json = Json { ignoreUnknownKeys = true }
private fun row(id: String, kind: String, vararg itemIds: String) = HomeRow(
id = id,
title = id.replaceFirstChar(Char::uppercase),
@@ -69,6 +71,14 @@ class ServerHomeRowsTest {
assertEquals("Favorites", personalizedFavoritesTitle(null))
}
@Test
fun `successful login welcome uses authenticated username`() {
assertEquals(
"You're now logged in as Matt. Welcome to Memby!",
loginWelcomeMessage(" Matt "),
)
}
@Test
fun `disabling a section hides its rows but never the recommendations`() {
val settings = Settings(homeSections = "continue")
@@ -93,6 +103,91 @@ class ServerHomeRowsTest {
assertEquals(MediaRowKind.MOVIES, rows.getValue("similar:sev").kind)
}
@Test
fun `For You destination keeps reasons visible and exposes loading placeholder`() {
val loading = forYouBrowseRows(ForYouUiState(loading = true))
assertEquals(1, loading.size)
assertTrue(loading.single().loading)
val rows = forYouBrowseRows(
ForYouUiState(
rows = listOf(
row("for-you:picks", "for-you", "pick"),
row("for-you:because:six-feet-under", "for-you", "because"),
row("for-you:genre:drama", "for-you", "genre"),
),
availableMinutes = 60,
),
)
assertEquals(
listOf(
"for-you:picks",
"for-you:because:six-feet-under",
"for-you:genre:drama",
),
rows.map { it.id },
)
assertTrue(rows.all { it.kind == MediaRowKind.MOVIES })
assertTrue(rows.all { it.showSecondaryMetadata })
}
@Test
fun `shows destination includes empty favorites and ranked server shelves`() {
val rankedRows = listOf(
row("curated:comedy-shows", "shows", "c1"),
row("curated:horror-shows", "shows", "h1"),
row("curated:drama-shows", "shows", "d1"),
)
val state = HomeUiState(
rows = serverRows + rankedRows,
favorites = emptyList(),
loading = emptySet(),
)
val rows = homeRowsFor(BrowseDestination.SHOWS, state, Settings())
assertEquals(
listOf(
"next-up",
"continue-shows",
"favourite-shows",
"curated:comedy-shows",
"curated:horror-shows",
"curated:drama-shows",
),
rows.map { it.id },
)
assertTrue(rows.first { it.id == "favourite-shows" }.items.isEmpty())
}
@Test
fun `movies destination uses personalised genre and studio shelves`() {
val rankedRows = listOf(
row("curated:movies:studio:pixar", "movies", "p1"),
row("curated:movies:genre:animation", "movies", "a1"),
row("curated:drama-shows", "shows", "s1"),
row("recommended", "recommended", "mixed"),
)
val state = HomeUiState(
rows = serverRows + rankedRows,
favorites = listOf(BaseItem(id = "f1", type = "Movie")),
latestMovies = listOf(BaseItem(id = "latest", type = "Movie")),
loading = emptySet(),
)
val rows = homeRowsFor(BrowseDestination.MOVIES, state, Settings())
assertEquals(
listOf(
"curated:movies:studio:pixar",
"curated:movies:genre:animation",
"favourite-movies",
),
rows.map { it.id },
)
assertTrue(rows.none { it.id == "latest-movies" || it.id == "recommended" })
}
@Test
fun `Sonarr schedule rows use show cards and cannot be hidden by old preferences`() {
val schedule = row("sonarr-airing-today", "schedule", "sonarr:7:42")
@@ -160,7 +255,7 @@ class ServerHomeRowsTest {
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))
val restored = HomeUiState.from(json.decodeFromString<HomeCache>(legacy))
assertTrue(restored.rows.isEmpty())
assertEquals("a", restored.continueWatching.single().id)