Big changes
This commit is contained in:
@@ -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 it’ll 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 |
@@ -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
|
||||
|
||||
@@ -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 couldn’t 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 & TV backdrops</string>
|
||||
<string name="developer_name">ponzischeme89</string>
|
||||
</resources>
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user