Publish current app and server
This commit is contained in:
@@ -5,15 +5,31 @@ import coil.Coil
|
||||
import coil.ImageLoader
|
||||
import coil.disk.DiskCache
|
||||
import coil.memory.MemoryCache
|
||||
import com.ponzischeme89.memby.data.remote.HttpStack
|
||||
import okhttp3.OkHttpClient
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class MembyApp : Application() {
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
Coil.setImageLoader(
|
||||
ImageLoader.Builder(this)
|
||||
// Coil builds its own OkHttpClient when not given one, which would mean a
|
||||
// third connection pool alongside the Emby and gateway APIs. In gateway
|
||||
// mode artwork is proxied by the same HTTPS host that serves /v1/home, so
|
||||
// sharing the stack lets every poster resume the connection the home
|
||||
// request already opened rather than repeating the TLS handshake.
|
||||
.okHttpClient { artworkHttpClient() }
|
||||
.memoryCache {
|
||||
MemoryCache.Builder(this)
|
||||
.maxSizePercent(0.08)
|
||||
// A backdrop is requested at 1280x720 — 3.7MB as ARGB_8888 — and
|
||||
// it is replaced on every focus change. At the previous 8% the
|
||||
// cache held barely two of them before a poster could be cached
|
||||
// at all, so ordinary D-pad movement re-decoded artwork it had
|
||||
// just evicted. These are hardware bitmaps (allowHardware is on
|
||||
// at every call site), so the extra headroom is graphics memory
|
||||
// rather than Java heap.
|
||||
.maxSizePercent(0.25)
|
||||
.build()
|
||||
}
|
||||
.diskCache {
|
||||
@@ -31,4 +47,14 @@ class MembyApp : Application() {
|
||||
)
|
||||
ServiceLocator.init(this)
|
||||
}
|
||||
|
||||
/**
|
||||
* Artwork tolerates a shorter read timeout than the API does: a poster that has not
|
||||
* arrived in ten seconds has already missed the moment it was wanted for, and the
|
||||
* card falls back to its placeholder.
|
||||
*/
|
||||
private fun artworkHttpClient(): OkHttpClient = HttpStack.base.newBuilder()
|
||||
.connectTimeout(10, TimeUnit.SECONDS)
|
||||
.readTimeout(10, TimeUnit.SECONDS)
|
||||
.build()
|
||||
}
|
||||
|
||||
@@ -3,24 +3,27 @@ package com.ponzischeme89.memby.data
|
||||
import com.ponzischeme89.memby.data.model.AuthRequest
|
||||
import com.ponzischeme89.memby.data.model.BaseItem
|
||||
import com.ponzischeme89.memby.data.model.GatewayFlagRequest
|
||||
import com.ponzischeme89.memby.data.model.GatewayAuthError
|
||||
import com.ponzischeme89.memby.data.model.GatewayAuthPolicy
|
||||
import com.ponzischeme89.memby.data.model.GatewayDevice
|
||||
import com.ponzischeme89.memby.data.model.GatewayDeviceNameRequest
|
||||
import com.ponzischeme89.memby.data.model.GatewayLoginRequest
|
||||
import com.ponzischeme89.memby.data.model.GatewayPlaybackReport
|
||||
import com.ponzischeme89.memby.data.model.GatewayPrerollSchedule
|
||||
import com.ponzischeme89.memby.data.model.GatewayRowEvent
|
||||
import com.ponzischeme89.memby.data.model.GatewayRowEvents
|
||||
import com.ponzischeme89.memby.data.model.GatewayServiceStatus
|
||||
import com.ponzischeme89.memby.data.model.GatewayUpdate
|
||||
import com.ponzischeme89.memby.data.model.GatewayFeatures
|
||||
import com.ponzischeme89.memby.data.model.HomeRow
|
||||
import com.ponzischeme89.memby.data.model.PlaybackReport
|
||||
import com.ponzischeme89.memby.data.model.PlaybackInfoRequest
|
||||
import com.ponzischeme89.memby.data.model.MediaSourceInfo
|
||||
import com.ponzischeme89.memby.data.remote.EmbyApi
|
||||
import com.ponzischeme89.memby.data.remote.EmbyServiceFactory
|
||||
import com.ponzischeme89.memby.data.remote.GatewayApi
|
||||
import com.ponzischeme89.memby.data.remote.GatewayServiceFactory
|
||||
import com.ponzischeme89.memby.data.playback.devicePlaybackCapabilities
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.CoroutineStart
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Deferred
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
@@ -80,6 +83,11 @@ data class Playable(
|
||||
val mediaSourceId: String = "",
|
||||
val playSessionId: String = "",
|
||||
val playMethod: String = "DirectPlay",
|
||||
val overview: String? = null,
|
||||
val episodeCode: String? = null,
|
||||
val runtimeMs: Long = 0L,
|
||||
val prerollEnabled: Boolean = true,
|
||||
val prerollDurationMs: Long = 6_500L,
|
||||
)
|
||||
|
||||
class EmbyRepository(private val settings: SettingsStore) {
|
||||
@@ -96,11 +104,24 @@ class EmbyRepository(private val settings: SettingsStore) {
|
||||
}
|
||||
|
||||
val settingsFlow: Flow<Settings> get() = settings.settingsFlow
|
||||
|
||||
/**
|
||||
* Read synchronously off the snapshot, like [rotationIntervalMillis], because the
|
||||
* composables that decide between logo artwork and a text title do so while building a
|
||||
* layout and must not collect a flow to answer a question that changes once a year.
|
||||
*/
|
||||
val showTitleLogo: Boolean get() = snapshot.showTitleLogo
|
||||
private val _playbackStops = MutableSharedFlow<String>(extraBufferCapacity = 1)
|
||||
val playbackStops = _playbackStops.asSharedFlow()
|
||||
private val playableMutex = Mutex()
|
||||
private val playableCache = LinkedHashMap<String, CachedPlayable>(16, 0.75f, true)
|
||||
private val playableInFlight = mutableMapOf<String, Deferred<Playable>>()
|
||||
private val seriesEpisodesMutex = Mutex()
|
||||
private val seriesEpisodesCache =
|
||||
LinkedHashMap<String, CachedSeriesEpisodes>(SERIES_EPISODE_CACHE_SIZE, 0.75f, true)
|
||||
private val relatedMutex = Mutex()
|
||||
private val relatedCache =
|
||||
LinkedHashMap<String, CachedRelated>(RELATED_CACHE_SIZE, 0.75f, true)
|
||||
|
||||
fun cachedHome(): HomeCache? = settings.homeCache(snapshot)
|
||||
|
||||
@@ -168,14 +189,14 @@ class EmbyRepository(private val settings: SettingsStore) {
|
||||
deviceName: String,
|
||||
): String {
|
||||
clearPlayableCache()
|
||||
clearSeriesEpisodeCache()
|
||||
settings.ensureDeviceId()
|
||||
observedSettings = settings.snapshot() // pick up the freshly-generated device id
|
||||
|
||||
ServerConfig.gatewayUrl?.let { gateway ->
|
||||
// The gateway holds the Emby token; this device only ever stores the gateway
|
||||
// token, so the same `token` slot in DataStore serves both modes.
|
||||
val result = try {
|
||||
requireGateway().login(
|
||||
val result = requireGateway().login(
|
||||
GatewayLoginRequest(
|
||||
username = username,
|
||||
password = password,
|
||||
@@ -183,12 +204,6 @@ class EmbyRepository(private val settings: SettingsStore) {
|
||||
deviceName = deviceName.trim(),
|
||||
),
|
||||
)
|
||||
} catch (error: HttpException) {
|
||||
if (error.code() == 409) {
|
||||
parseDeviceLimit(error.response()?.errorBody()?.string().orEmpty())?.let { throw it }
|
||||
}
|
||||
throw error
|
||||
}
|
||||
require(result.token.isNotBlank() && result.userId.isNotBlank()) {
|
||||
"Gateway did not return a session"
|
||||
}
|
||||
@@ -220,13 +235,39 @@ class EmbyRepository(private val settings: SettingsStore) {
|
||||
return username
|
||||
}
|
||||
|
||||
suspend fun authPolicy(): GatewayAuthPolicy? =
|
||||
if (ServerConfig.isGateway) requireGateway().authPolicy() else null
|
||||
suspend fun devices(): List<GatewayDevice> =
|
||||
if (ServerConfig.isGateway) requireGateway().devices().devices else emptyList()
|
||||
|
||||
/** True only when the gateway still recognises the token restored from storage. */
|
||||
suspend fun removeDevice(deviceId: String) {
|
||||
if (ServerConfig.isGateway) requireGateway().removeDevice(deviceId)
|
||||
}
|
||||
|
||||
suspend fun renameDevice(device: GatewayDevice, deviceName: String) {
|
||||
val trimmed = deviceName.trim()
|
||||
require(trimmed.isNotEmpty() && trimmed.length <= 80) { "Invalid device name" }
|
||||
if (ServerConfig.isGateway) {
|
||||
requireGateway().renameDevice(device.deviceId, GatewayDeviceNameRequest(trimmed))
|
||||
if (device.current) settings.setDeviceName(trimmed)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* False only when the gateway explicitly rejects the restored token with 401.
|
||||
*
|
||||
* A deployment, maintenance window, or disconnected TV is not evidence that a
|
||||
* viewer's credentials are invalid. Treating every network failure as rejection used
|
||||
* to delete the active profile precisely when the server was being updated.
|
||||
*/
|
||||
suspend fun validateSession(): Boolean {
|
||||
if (!ServerConfig.isGateway || snapshot.token.isNullOrBlank()) return true
|
||||
return runCatching { requireGateway().session() }.isSuccess
|
||||
return try {
|
||||
requireGateway().session()
|
||||
true
|
||||
} catch (cancelled: CancellationException) {
|
||||
throw cancelled
|
||||
} catch (failure: Throwable) {
|
||||
shouldPreserveSessionAfterValidationFailure(failure)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -239,6 +280,7 @@ class EmbyRepository(private val settings: SettingsStore) {
|
||||
cachedApi = null
|
||||
cachedBaseUrl = null
|
||||
clearPlayableCache()
|
||||
clearSeriesEpisodeCache()
|
||||
}
|
||||
|
||||
suspend fun signOut() {
|
||||
@@ -252,6 +294,7 @@ class EmbyRepository(private val settings: SettingsStore) {
|
||||
cachedApi = null
|
||||
cachedBaseUrl = null
|
||||
clearPlayableCache()
|
||||
clearSeriesEpisodeCache()
|
||||
}
|
||||
|
||||
suspend fun switchProfile(profile: EmbyProfile) {
|
||||
@@ -260,6 +303,24 @@ class EmbyRepository(private val settings: SettingsStore) {
|
||||
cachedApi = null
|
||||
cachedBaseUrl = null
|
||||
clearPlayableCache()
|
||||
clearSeriesEpisodeCache()
|
||||
}
|
||||
|
||||
suspend fun removeProfile(profile: EmbyProfile) {
|
||||
val removingActiveProfile = snapshot.activeProfileId == profile.id
|
||||
if (removingActiveProfile && ServerConfig.isGateway && !snapshot.token.isNullOrBlank()) {
|
||||
// Retire the active gateway session where possible. Local removal still
|
||||
// succeeds when this TV is offline.
|
||||
runCatching { requireGateway().logout() }
|
||||
}
|
||||
settings.removeProfile(profile.id)
|
||||
observedSettings = settings.snapshot()
|
||||
if (removingActiveProfile) {
|
||||
cachedApi = null
|
||||
cachedBaseUrl = null
|
||||
clearPlayableCache()
|
||||
clearSeriesEpisodeCache()
|
||||
}
|
||||
}
|
||||
|
||||
// --- Content -------------------------------------------------------------
|
||||
@@ -345,7 +406,7 @@ class EmbyRepository(private val settings: SettingsStore) {
|
||||
"SortOrder" to "Ascending",
|
||||
"Limit" to limit.toString(),
|
||||
),
|
||||
fields = "ProductionYear,RunTimeTicks,PrimaryImageAspectRatio",
|
||||
fields = "ProductionYear,RunTimeTicks,RecursiveItemCount,PrimaryImageAspectRatio",
|
||||
imageTypes = "Backdrop,Primary,Logo",
|
||||
includeUserData = true,
|
||||
)
|
||||
@@ -443,6 +504,23 @@ class EmbyRepository(private val settings: SettingsStore) {
|
||||
.getOrDefault(emptyList())
|
||||
}
|
||||
|
||||
suspend fun lookupMediaRequests(term: String): List<com.ponzischeme89.memby.data.model.GatewayRequestCandidate> {
|
||||
if (!ServerConfig.isGateway) return emptyList()
|
||||
return requireGateway().requestLookup(term.trim()).candidates
|
||||
}
|
||||
|
||||
suspend fun requestMedia(
|
||||
candidate: com.ponzischeme89.memby.data.model.GatewayRequestCandidate,
|
||||
): String {
|
||||
check(ServerConfig.isGateway) { "Media requests require the Memby gateway" }
|
||||
return requireGateway().requestMedia(
|
||||
com.ponzischeme89.memby.data.model.GatewayMediaRequest(
|
||||
mediaType = candidate.mediaType,
|
||||
foreignId = candidate.foreignId,
|
||||
),
|
||||
).title
|
||||
}
|
||||
|
||||
/**
|
||||
* Recommendation rows on their own, forcing the gateway to build them synchronously
|
||||
* if its cache is cold. [getHome] already carries them once warm, so this is only
|
||||
@@ -456,21 +534,72 @@ class EmbyRepository(private val settings: SettingsStore) {
|
||||
return requireGateway().forYou(availableMinutes.coerceIn(0, 360)).rows
|
||||
}
|
||||
|
||||
/**
|
||||
* The gateway's verdict on this build. Null on the direct path, where nobody is in a
|
||||
* position to decide, and null on failure — an unreachable gateway must never leave
|
||||
* a TV stuck behind a blocking update prompt.
|
||||
*/
|
||||
suspend fun checkAppUpdate(): GatewayUpdate? {
|
||||
if (!ServerConfig.isGateway) return null
|
||||
return runCatching { requireGateway().updateStatus() }
|
||||
.getOrNull()
|
||||
?.takeIf { it.isActionable }
|
||||
suspend fun getRecommendationOnboarding():
|
||||
com.ponzischeme89.memby.data.model.RecommendationOnboarding {
|
||||
if (!ServerConfig.isGateway) {
|
||||
return com.ponzischeme89.memby.data.model.RecommendationOnboarding(completed = true)
|
||||
}
|
||||
return requireGateway().recommendationPreferences()
|
||||
}
|
||||
|
||||
suspend fun saveRecommendationRatings(ratings: Map<String, Int>) {
|
||||
if (!ServerConfig.isGateway) return
|
||||
requireGateway().saveRecommendationPreferences(
|
||||
com.ponzischeme89.memby.data.model.RecommendationPreferences(
|
||||
ratings = ratings.filterValues { it in 1..5 },
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun getMyShows(): List<com.ponzischeme89.memby.data.model.MyShow> =
|
||||
if (ServerConfig.isGateway) requireGateway().myShows().shows else emptyList()
|
||||
|
||||
suspend fun saveMyShow(item: BaseItem): List<com.ponzischeme89.memby.data.model.MyShow> {
|
||||
if (!ServerConfig.isGateway || !item.isSeries) return getMyShows()
|
||||
return requireGateway().saveMyShow(
|
||||
com.ponzischeme89.memby.data.model.SaveMyShowRequest(
|
||||
itemId = item.id,
|
||||
title = item.name,
|
||||
year = item.productionYear,
|
||||
imageTag = item.imageTags["Primary"].orEmpty(),
|
||||
),
|
||||
).shows
|
||||
}
|
||||
|
||||
suspend fun removeMyShow(itemId: String) {
|
||||
if (ServerConfig.isGateway) requireGateway().removeMyShow(itemId)
|
||||
}
|
||||
|
||||
suspend fun getNotifications(): com.ponzischeme89.memby.data.model.NotificationsResponse =
|
||||
if (ServerConfig.isGateway) {
|
||||
requireGateway().notifications()
|
||||
} else {
|
||||
com.ponzischeme89.memby.data.model.NotificationsResponse()
|
||||
}
|
||||
|
||||
suspend fun setNotificationPreferences(
|
||||
value: com.ponzischeme89.memby.data.model.NotificationPreferences,
|
||||
): com.ponzischeme89.memby.data.model.NotificationsResponse =
|
||||
requireGateway().setNotificationPreferences(value)
|
||||
|
||||
suspend fun markNotificationRead(id: Long) {
|
||||
if (ServerConfig.isGateway) requireGateway().updateNotification(id, "read")
|
||||
}
|
||||
|
||||
suspend fun dismissNotification(id: Long) {
|
||||
if (ServerConfig.isGateway) requireGateway().updateNotification(id, "dismiss")
|
||||
}
|
||||
|
||||
fun myShowImageUrl(itemId: String, imageTag: String, maxWidth: Int = 320): String? =
|
||||
imageTag.takeIf(String::isNotBlank)?.let {
|
||||
imageUrl(itemId, "Primary", it, maxWidth)
|
||||
}
|
||||
|
||||
/** Live gateway state. Unlike content routes, this remains available in maintenance. */
|
||||
suspend fun serviceStatus(): GatewayServiceStatus = requireGateway().serviceStatus()
|
||||
|
||||
suspend fun serverFeatures(): GatewayFeatures = requireGateway().features()
|
||||
|
||||
/** Full item metadata, requested only after focus settles on an item. */
|
||||
suspend fun getItemDetails(itemId: String): BaseItem {
|
||||
if (ServerConfig.isGateway) return requireGateway().item(itemId)
|
||||
@@ -478,32 +607,142 @@ class EmbyRepository(private val settings: SettingsStore) {
|
||||
return requireApi().getItem(
|
||||
userId = userId,
|
||||
itemId = itemId,
|
||||
fields = "Overview,Genres,MediaStreams,People,ProductionYear,OfficialRating,CommunityRating,RunTimeTicks,PrimaryImageAspectRatio",
|
||||
fields = "Overview,Genres,MediaStreams,People,ProductionYear,OfficialRating,CommunityRating,RunTimeTicks,PrimaryImageAspectRatio,CollectionName",
|
||||
)
|
||||
}
|
||||
|
||||
/** Whether this episode closes its season; failures are non-fatal playback metadata. */
|
||||
suspend fun seasonFinale(itemId: String): com.ponzischeme89.memby.data.model.GatewaySeasonFinale {
|
||||
if (itemId.isBlank()) return com.ponzischeme89.memby.data.model.GatewaySeasonFinale()
|
||||
if (ServerConfig.isGateway) return requireGateway().seasonFinale(itemId)
|
||||
|
||||
// Direct mode has no Sonarr catalogue. Use Emby's full episode list as the best
|
||||
// available fallback; gateway mode remains authoritative because it sees future
|
||||
// episodes and cannot confuse the newest download with a finale.
|
||||
val current = getItemDetails(itemId)
|
||||
val season = current.parentIndexNumber ?: return com.ponzischeme89.memby.data.model.GatewaySeasonFinale()
|
||||
val episode = current.indexNumber ?: return com.ponzischeme89.memby.data.model.GatewaySeasonFinale()
|
||||
val seriesId = current.seriesId ?: return com.ponzischeme89.memby.data.model.GatewaySeasonFinale()
|
||||
val lastEpisode = getSeriesEpisodes(seriesId)
|
||||
.filter { it.parentIndexNumber == season }
|
||||
.mapNotNull(BaseItem::indexNumber)
|
||||
.maxOrNull()
|
||||
if (season <= 0 || lastEpisode == null || episode != lastEpisode) {
|
||||
return com.ponzischeme89.memby.data.model.GatewaySeasonFinale()
|
||||
}
|
||||
return com.ponzischeme89.memby.data.model.GatewaySeasonFinale(
|
||||
seasonFinale = true,
|
||||
seriesName = current.seriesName.orEmpty(),
|
||||
seasonNumber = season,
|
||||
episodeNumber = episode,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The detail page's "why you might like this" strip and its related carousel.
|
||||
*
|
||||
* Dual-path like everything else, but the two paths differ in what they can say: the
|
||||
* gateway runs the recommendation engine and returns reasons drawn from this user's
|
||||
* own history, while direct mode has only Emby's similarity ranking and therefore no
|
||||
* reasons at all. An empty result is normal, never an error — a detail page that
|
||||
* cannot explain itself still has to open.
|
||||
*/
|
||||
suspend fun getRelated(item: BaseItem, limit: Int = RELATED_LIMIT): RelatedContent {
|
||||
if (item.id.isBlank()) return RelatedContent()
|
||||
val now = System.currentTimeMillis()
|
||||
relatedMutex.withLock {
|
||||
relatedCache[item.id]
|
||||
?.takeIf { it.expiresAtMs > now }
|
||||
?.let { return it.content }
|
||||
relatedCache.remove(item.id)
|
||||
}
|
||||
|
||||
val loaded = runCatching {
|
||||
if (ServerConfig.isGateway) {
|
||||
val response = requireGateway().related(item.id)
|
||||
RelatedContent(
|
||||
reasons = response.reasons.filter(String::isNotBlank),
|
||||
items = response.items.filter { it.id != item.id },
|
||||
)
|
||||
} else {
|
||||
val userId = snapshot.userId ?: error("Not connected")
|
||||
RelatedContent(
|
||||
items = requireApi().getSimilar(
|
||||
itemId = item.id,
|
||||
params = mapOf(
|
||||
"UserId" to userId,
|
||||
"Limit" to limit.toString(),
|
||||
"Fields" to "ProductionYear,RunTimeTicks,CommunityRating,PrimaryImageAspectRatio,CollectionName",
|
||||
"EnableUserData" to "true",
|
||||
"EnableImages" to "true",
|
||||
"EnableImageTypes" to "Primary,Backdrop,Logo",
|
||||
"ImageTypeLimit" to "1",
|
||||
),
|
||||
).items.filter { it.id != item.id },
|
||||
)
|
||||
}
|
||||
}.getOrElse { RelatedContent() }
|
||||
|
||||
relatedMutex.withLock {
|
||||
relatedCache[item.id] = CachedRelated(
|
||||
content = loaded.copy(items = loaded.items.take(limit)),
|
||||
expiresAtMs = now + RELATED_CACHE_TTL_MS,
|
||||
)
|
||||
while (relatedCache.size > RELATED_CACHE_SIZE) {
|
||||
relatedCache.entries.iterator().run {
|
||||
next()
|
||||
remove()
|
||||
}
|
||||
}
|
||||
return relatedCache.getValue(item.id).content
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* All episodes for one show in a single request. Season switching is then a local
|
||||
* list filter, keeping the detail screen immediate after its first load.
|
||||
*/
|
||||
suspend fun getSeriesEpisodes(seriesId: String): List<BaseItem> {
|
||||
if (seriesId.isBlank()) return emptyList()
|
||||
if (ServerConfig.isGateway) {
|
||||
return requireGateway().seriesEpisodes(seriesId).items
|
||||
val now = System.currentTimeMillis()
|
||||
seriesEpisodesMutex.withLock {
|
||||
seriesEpisodesCache[seriesId]
|
||||
?.takeIf { it.expiresAtMs > now }
|
||||
?.episodes
|
||||
?.let { return it }
|
||||
seriesEpisodesCache.remove(seriesId)
|
||||
}
|
||||
val userId = snapshot.userId ?: error("Not connected")
|
||||
return requireApi().getEpisodes(
|
||||
seriesId,
|
||||
mapOf(
|
||||
"UserId" to userId,
|
||||
"Fields" to "Overview,RunTimeTicks,SeriesName,PrimaryImageAspectRatio",
|
||||
"EnableUserData" to "true",
|
||||
"EnableImages" to "true",
|
||||
"EnableImageTypes" to "Primary,Thumb,Backdrop",
|
||||
"ImageTypeLimit" to "1",
|
||||
"Limit" to "1000",
|
||||
),
|
||||
).items
|
||||
|
||||
val loaded = if (ServerConfig.isGateway) {
|
||||
requireGateway().seriesEpisodes(seriesId).items
|
||||
} else {
|
||||
val userId = snapshot.userId ?: error("Not connected")
|
||||
requireApi().getEpisodes(
|
||||
seriesId,
|
||||
mapOf(
|
||||
"UserId" to userId,
|
||||
"Fields" to "Overview,RunTimeTicks,SeriesName,PrimaryImageAspectRatio",
|
||||
"EnableUserData" to "true",
|
||||
"EnableImages" to "true",
|
||||
"EnableImageTypes" to "Primary,Thumb,Backdrop",
|
||||
"ImageTypeLimit" to "1",
|
||||
"Limit" to "1000",
|
||||
),
|
||||
).items
|
||||
}
|
||||
seriesEpisodesMutex.withLock {
|
||||
seriesEpisodesCache[seriesId] = CachedSeriesEpisodes(
|
||||
episodes = loaded,
|
||||
expiresAtMs = now + SERIES_EPISODE_CACHE_TTL_MS,
|
||||
)
|
||||
while (seriesEpisodesCache.size > SERIES_EPISODE_CACHE_SIZE) {
|
||||
seriesEpisodesCache.entries.iterator().run {
|
||||
next()
|
||||
remove()
|
||||
}
|
||||
}
|
||||
}
|
||||
return loaded
|
||||
}
|
||||
|
||||
/** Tight list endpoint shape: detail-only fields are never fetched on home. */
|
||||
@@ -597,6 +836,16 @@ class EmbyRepository(private val settings: SettingsStore) {
|
||||
.getOrDefault(GatewayPrerollSchedule())
|
||||
}
|
||||
|
||||
fun prerollArtworkUrl(itemId: String, imageType: String, maxWidth: Int): String? {
|
||||
if (!ServerConfig.isGateway || itemId.isBlank() || imageType.isBlank()) return null
|
||||
return imageUrl(
|
||||
itemId = itemId,
|
||||
imageType = imageType,
|
||||
tag = "sonarr",
|
||||
maxWidth = maxWidth,
|
||||
)
|
||||
}
|
||||
|
||||
/** Backdrop rotation interval, clamped to a sane range. */
|
||||
fun rotationIntervalMillis(): Long =
|
||||
snapshot.rotationIntervalSeconds.coerceIn(4, 600).toLong() * 1000L
|
||||
@@ -641,14 +890,15 @@ class EmbyRepository(private val settings: SettingsStore) {
|
||||
itemId: String,
|
||||
title: String,
|
||||
resumePositionMs: Long,
|
||||
forceTranscode: Boolean = false,
|
||||
): Playable {
|
||||
require(itemId.isNotBlank()) { "A media item is required to refresh playback" }
|
||||
if (!ServerConfig.isGateway) {
|
||||
val discovery = directPlayback(itemId, resumePositionMs)
|
||||
val discovery = directPlayback(itemId, resumePositionMs, forceTranscode = forceTranscode)
|
||||
return Playable(
|
||||
itemId = itemId,
|
||||
title = title,
|
||||
url = buildStreamUrl(itemId),
|
||||
url = discovery.url ?: buildStreamUrl(itemId),
|
||||
resumePositionMs = resumePositionMs.coerceAtLeast(0L),
|
||||
subtitles = discovery.subtitles,
|
||||
mediaSourceId = discovery.mediaSourceId,
|
||||
@@ -662,6 +912,7 @@ class EmbyRepository(private val settings: SettingsStore) {
|
||||
itemType = "",
|
||||
title = title,
|
||||
resumePositionMs = resumePositionMs.coerceAtLeast(0L),
|
||||
forceTranscode = forceTranscode,
|
||||
)
|
||||
return Playable(
|
||||
itemId = playback.itemId,
|
||||
@@ -700,6 +951,9 @@ class EmbyRepository(private val settings: SettingsStore) {
|
||||
mediaSourceId = playback.mediaSourceId,
|
||||
playSessionId = playback.playSessionId,
|
||||
playMethod = playback.playMethod,
|
||||
overview = playback.overview.ifBlank { null },
|
||||
episodeCode = playback.episodeCode.ifBlank { null },
|
||||
runtimeMs = playback.runtimeMs,
|
||||
)
|
||||
}
|
||||
val discovery = directPlayback(
|
||||
@@ -765,6 +1019,12 @@ class EmbyRepository(private val settings: SettingsStore) {
|
||||
mediaSourceId = playback.mediaSourceId,
|
||||
playSessionId = playback.playSessionId,
|
||||
playMethod = playback.playMethod,
|
||||
overview = playback.overview.ifBlank { item.overview },
|
||||
episodeCode = playback.episodeCode.ifBlank { episodeCode(item) },
|
||||
runtimeMs = playback.runtimeMs.takeIf { it > 0L }
|
||||
?: item.runTimeTicks?.div(10_000L)?.coerceAtLeast(0L) ?: 0L,
|
||||
prerollEnabled = playback.prerollEnabled,
|
||||
prerollDurationMs = playback.prerollDurationMs,
|
||||
)
|
||||
}
|
||||
if (item.isSeries) {
|
||||
@@ -777,28 +1037,34 @@ class EmbyRepository(private val settings: SettingsStore) {
|
||||
}
|
||||
val discovery = directPlayback(episode.id, episode.resumePositionMs)
|
||||
return Playable(
|
||||
episode.id,
|
||||
title,
|
||||
buildStreamUrl(episode.id),
|
||||
episode.resumePositionMs,
|
||||
logoUrl(item),
|
||||
discovery.subtitles,
|
||||
discovery.mediaSourceId,
|
||||
discovery.playSessionId,
|
||||
discovery.playMethod,
|
||||
itemId = episode.id,
|
||||
title = title,
|
||||
url = discovery.url ?: buildStreamUrl(episode.id),
|
||||
resumePositionMs = episode.resumePositionMs,
|
||||
logoUrl = logoUrl(item),
|
||||
subtitles = discovery.subtitles,
|
||||
mediaSourceId = discovery.mediaSourceId,
|
||||
playSessionId = discovery.playSessionId,
|
||||
playMethod = discovery.playMethod,
|
||||
overview = episode.overview,
|
||||
episodeCode = episodeCode(episode),
|
||||
runtimeMs = episode.runTimeTicks?.div(10_000L)?.coerceAtLeast(0L) ?: 0L,
|
||||
)
|
||||
}
|
||||
val discovery = directPlayback(item.id, item.resumePositionMs)
|
||||
return Playable(
|
||||
item.id,
|
||||
item.name,
|
||||
buildStreamUrl(item.id),
|
||||
item.resumePositionMs,
|
||||
logoUrl(item),
|
||||
discovery.subtitles,
|
||||
discovery.mediaSourceId,
|
||||
discovery.playSessionId,
|
||||
discovery.playMethod,
|
||||
itemId = item.id,
|
||||
title = item.name,
|
||||
url = discovery.url ?: buildStreamUrl(item.id),
|
||||
resumePositionMs = item.resumePositionMs,
|
||||
logoUrl = logoUrl(item),
|
||||
subtitles = discovery.subtitles,
|
||||
mediaSourceId = discovery.mediaSourceId,
|
||||
playSessionId = discovery.playSessionId,
|
||||
playMethod = discovery.playMethod,
|
||||
overview = item.overview,
|
||||
episodeCode = episodeCode(item),
|
||||
runtimeMs = item.runTimeTicks?.div(10_000L)?.coerceAtLeast(0L) ?: 0L,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -810,6 +1076,16 @@ class EmbyRepository(private val settings: SettingsStore) {
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun clearSeriesEpisodeCache() {
|
||||
seriesEpisodesMutex.withLock {
|
||||
seriesEpisodesCache.clear()
|
||||
}
|
||||
relatedMutex.withLock {
|
||||
// Reasons are personal: another profile must never inherit this one's.
|
||||
relatedCache.clear()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun reportPlaybackStarted(session: PlaybackSession, positionMs: Long) {
|
||||
if (ServerConfig.isGateway) {
|
||||
requireGateway().report("started", session.gatewayReport(positionMs, false, null))
|
||||
@@ -823,12 +1099,16 @@ class EmbyRepository(private val settings: SettingsStore) {
|
||||
positionMs: Long,
|
||||
isPaused: Boolean,
|
||||
eventName: String,
|
||||
) {
|
||||
durationMs: Long = 0L,
|
||||
): String? {
|
||||
if (ServerConfig.isGateway) {
|
||||
requireGateway().report("progress", session.gatewayReport(positionMs, isPaused, eventName))
|
||||
return
|
||||
return requireGateway().report(
|
||||
"progress",
|
||||
session.gatewayReport(positionMs, isPaused, eventName, durationMs),
|
||||
).autoFollowedShowTitle.takeIf(String::isNotBlank)
|
||||
}
|
||||
requireApi().reportPlaybackProgress(playbackReport(session, positionMs, isPaused, eventName))
|
||||
return null
|
||||
}
|
||||
|
||||
suspend fun reportPlaybackStopped(session: PlaybackSession, positionMs: Long) {
|
||||
@@ -842,6 +1122,8 @@ class EmbyRepository(private val settings: SettingsStore) {
|
||||
}
|
||||
} finally {
|
||||
clearPlayableCache()
|
||||
// Episode progress and watched badges may have changed during playback.
|
||||
clearSeriesEpisodeCache()
|
||||
_playbackStops.tryEmit(session.itemId)
|
||||
}
|
||||
}
|
||||
@@ -894,7 +1176,7 @@ class EmbyRepository(private val settings: SettingsStore) {
|
||||
val next = episodes.getOrNull(current + 1) ?: return null
|
||||
val discovery = directPlayback(next.id, next.resumePositionMs)
|
||||
return nextEpisodeOf(
|
||||
next, buildStreamUrl(next.id), next.resumePositionMs, discovery.subtitles,
|
||||
next, discovery.url ?: buildStreamUrl(next.id), next.resumePositionMs, discovery.subtitles,
|
||||
discovery.mediaSourceId, discovery.playSessionId, discovery.playMethod,
|
||||
)
|
||||
}
|
||||
@@ -926,6 +1208,7 @@ class EmbyRepository(private val settings: SettingsStore) {
|
||||
positionMs: Long,
|
||||
subtitleStreamIndex: Int? = null,
|
||||
currentPlaySessionId: String? = null,
|
||||
forceTranscode: Boolean = false,
|
||||
): PlaybackDiscovery {
|
||||
val userId = snapshot.userId ?: return PlaybackDiscovery(mediaSourceId = itemId)
|
||||
val serverUrl = activeServerUrl ?: return PlaybackDiscovery(mediaSourceId = itemId)
|
||||
@@ -938,11 +1221,27 @@ class EmbyRepository(private val settings: SettingsStore) {
|
||||
id = itemId,
|
||||
userId = userId,
|
||||
startTimeTicks = millisecondsToTicks(positionMs),
|
||||
enableDirectPlay = !forceTranscode,
|
||||
enableDirectStream = !forceTranscode,
|
||||
subtitleStreamIndex = subtitleStreamIndex,
|
||||
currentPlaySessionId = currentPlaySessionId,
|
||||
deviceProfile = if (forceTranscode) {
|
||||
com.ponzischeme89.memby.data.model.DeviceProfile.embyAndroidTv(
|
||||
capabilities = devicePlaybackCapabilities,
|
||||
)
|
||||
.copy(directPlayProfiles = emptyList())
|
||||
} else {
|
||||
com.ponzischeme89.memby.data.model.DeviceProfile.embyAndroidTv(
|
||||
capabilities = devicePlaybackCapabilities,
|
||||
)
|
||||
},
|
||||
),
|
||||
)
|
||||
info.mediaSources.firstOrNull()?.let { source ->
|
||||
val delivery = selectPlaybackDelivery(
|
||||
source = source,
|
||||
forceTranscode = forceTranscode || subtitleStreamIndex != null,
|
||||
)
|
||||
PlaybackDiscovery(
|
||||
subtitles = subtitleTracks(
|
||||
streams = source.mediaStreams,
|
||||
@@ -953,12 +1252,8 @@ class EmbyRepository(private val settings: SettingsStore) {
|
||||
),
|
||||
mediaSourceId = source.id.ifBlank { itemId },
|
||||
playSessionId = info.playSessionId,
|
||||
playMethod = if (subtitleStreamIndex != null && !source.transcodingUrl.isNullOrBlank()) {
|
||||
"Transcode"
|
||||
} else "DirectPlay",
|
||||
url = source.transcodingUrl
|
||||
?.takeIf { subtitleStreamIndex != null }
|
||||
?.let { authenticatedDeliveryUrl(serverUrl, it, token) },
|
||||
playMethod = delivery.playMethod,
|
||||
url = delivery.url?.let { authenticatedDeliveryUrl(serverUrl, it, token) },
|
||||
)
|
||||
} ?: PlaybackDiscovery(mediaSourceId = itemId)
|
||||
}.getOrElse { PlaybackDiscovery(mediaSourceId = itemId) }
|
||||
@@ -1026,7 +1321,7 @@ class EmbyRepository(private val settings: SettingsStore) {
|
||||
|
||||
/**
|
||||
* Poster for an item the app never received as a [BaseItem] — a service alert names
|
||||
* its subject by id and tag only, and the Sonarr ids it carries resolve through the
|
||||
* its subject by id and tag only, and the TV schedule ids it carries resolve through the
|
||||
* gateway's image proxy like any other.
|
||||
*/
|
||||
fun posterUrl(itemId: String, tag: String, maxWidth: Int = 300): String? {
|
||||
@@ -1060,7 +1355,7 @@ class EmbyRepository(private val settings: SettingsStore) {
|
||||
append(gateway.trimEnd('/'))
|
||||
append("/v1/images/").append(itemId).append('/').append(imageType.lowercase())
|
||||
append("?maxWidth=").append(maxWidth)
|
||||
append("&quality=90")
|
||||
append("&quality=").append(ARTWORK_QUALITY)
|
||||
append("&tag=").append(encode(tag))
|
||||
append("&t=").append(encode(token))
|
||||
}
|
||||
@@ -1072,7 +1367,7 @@ class EmbyRepository(private val settings: SettingsStore) {
|
||||
append("/Items/").append(itemId).append("/Images/").append(imageType)
|
||||
if (directIndex) append("/0")
|
||||
append("?maxWidth=").append(maxWidth)
|
||||
append("&quality=90")
|
||||
append("&quality=").append(ARTWORK_QUALITY)
|
||||
append("&tag=").append(encode(tag))
|
||||
token?.let { append("&api_key=").append(encode(it)) }
|
||||
}
|
||||
@@ -1109,6 +1404,22 @@ class EmbyRepository(private val settings: SettingsStore) {
|
||||
)
|
||||
|
||||
private fun encode(value: String): String = URLEncoder.encode(value, "UTF-8")
|
||||
|
||||
private companion object {
|
||||
/**
|
||||
* JPEG quality asked of Emby (or of the gateway's image proxy) for every poster,
|
||||
* backdrop and logo.
|
||||
*
|
||||
* 80 rather than 90: at the distance a television is watched from the two are
|
||||
* indistinguishable, and it is roughly a third fewer bytes on every cold image —
|
||||
* which is time-to-poster on a launcher that paints fifteen of them at once, and
|
||||
* a disk cache that holds correspondingly more.
|
||||
*
|
||||
* Note this is part of the image URL, and the URL is Coil's cache key, so
|
||||
* changing it makes every existing install re-fetch its artwork once.
|
||||
*/
|
||||
const val ARTWORK_QUALITY = 80
|
||||
}
|
||||
}
|
||||
|
||||
data class PlaybackSession(
|
||||
@@ -1126,10 +1437,52 @@ private data class PlaybackDiscovery(
|
||||
val url: String? = null,
|
||||
)
|
||||
|
||||
private fun PlaybackSession.gatewayReport(positionMs: Long, isPaused: Boolean, eventName: String?) =
|
||||
internal data class PlaybackDelivery(
|
||||
val url: String?,
|
||||
val playMethod: String,
|
||||
)
|
||||
|
||||
/**
|
||||
* Honour Emby's negotiated delivery instead of blindly opening the original file.
|
||||
* A null URL means the original static stream is genuinely suitable for direct play.
|
||||
*/
|
||||
internal fun selectPlaybackDelivery(
|
||||
source: MediaSourceInfo,
|
||||
forceTranscode: Boolean = false,
|
||||
): PlaybackDelivery {
|
||||
val transcode = source.transcodingUrl?.takeIf(String::isNotBlank)
|
||||
val directStream = source.directStreamUrl?.takeIf(String::isNotBlank)
|
||||
if (forceTranscode && transcode != null) {
|
||||
return PlaybackDelivery(transcode, "Transcode")
|
||||
}
|
||||
if (source.supportsDirectPlay == true) {
|
||||
return PlaybackDelivery(null, "DirectPlay")
|
||||
}
|
||||
if (source.supportsDirectStream != false && directStream != null) {
|
||||
return PlaybackDelivery(directStream, "DirectStream")
|
||||
}
|
||||
if (source.supportsTranscoding != false && transcode != null) {
|
||||
return PlaybackDelivery(transcode, "Transcode")
|
||||
}
|
||||
if (directStream != null) {
|
||||
return PlaybackDelivery(directStream, "DirectStream")
|
||||
}
|
||||
if (transcode != null) {
|
||||
return PlaybackDelivery(transcode, "Transcode")
|
||||
}
|
||||
return PlaybackDelivery(null, "DirectPlay")
|
||||
}
|
||||
|
||||
private fun PlaybackSession.gatewayReport(
|
||||
positionMs: Long,
|
||||
isPaused: Boolean,
|
||||
eventName: String?,
|
||||
durationMs: Long = 0L,
|
||||
) =
|
||||
GatewayPlaybackReport(
|
||||
itemId = itemId,
|
||||
positionMs = positionMs,
|
||||
durationMs = durationMs.coerceAtLeast(0L),
|
||||
isPaused = isPaused,
|
||||
mediaSourceId = mediaSourceId,
|
||||
playSessionId = playSessionId,
|
||||
@@ -1137,13 +1490,51 @@ private fun PlaybackSession.gatewayReport(positionMs: Long, isPaused: Boolean, e
|
||||
eventName = eventName,
|
||||
)
|
||||
|
||||
private fun episodeCode(item: BaseItem): String? {
|
||||
val season = item.parentIndexNumber ?: return null
|
||||
val episode = item.indexNumber ?: return null
|
||||
if (season < 0 || episode <= 0) return null
|
||||
return "S${season.toString().padStart(2, '0')}E${episode.toString().padStart(2, '0')}"
|
||||
}
|
||||
|
||||
private data class CachedPlayable(
|
||||
val playable: Playable,
|
||||
val expiresAtMs: Long,
|
||||
)
|
||||
|
||||
private data class CachedSeriesEpisodes(
|
||||
val episodes: List<BaseItem>,
|
||||
val expiresAtMs: Long,
|
||||
)
|
||||
|
||||
/**
|
||||
* What a detail page can add beyond the item itself.
|
||||
*
|
||||
* [reasons] is the recommendation engine's explanation of this title *to this viewer* and
|
||||
* is empty on the direct path, which has no profile to reason from.
|
||||
*/
|
||||
data class RelatedContent(
|
||||
val reasons: List<String> = emptyList(),
|
||||
val items: List<BaseItem> = emptyList(),
|
||||
) {
|
||||
val isEmpty: Boolean get() = reasons.isEmpty() && items.isEmpty()
|
||||
}
|
||||
|
||||
private data class CachedRelated(
|
||||
val content: RelatedContent,
|
||||
val expiresAtMs: Long,
|
||||
)
|
||||
|
||||
private const val PLAYABLE_CACHE_SIZE = 16
|
||||
private const val PLAYABLE_CACHE_TTL_MS = 5L * 60L * 1_000L
|
||||
private const val SERIES_EPISODE_CACHE_SIZE = 6
|
||||
private const val SERIES_EPISODE_CACHE_TTL_MS = 5L * 60L * 1_000L
|
||||
private const val RELATED_CACHE_SIZE = 12
|
||||
private const val RELATED_LIMIT = 12
|
||||
|
||||
// Long enough that walking back and forth between a row and a detail page never re-asks,
|
||||
// short enough that a newly watched title drops out of "more like this" the same evening.
|
||||
private const val RELATED_CACHE_TTL_MS = 10L * 60L * 1_000L
|
||||
|
||||
internal fun millisecondsToTicks(milliseconds: Long): Long =
|
||||
milliseconds.coerceAtLeast(0L) * 10_000L
|
||||
@@ -1151,29 +1542,9 @@ internal fun millisecondsToTicks(milliseconds: Long): Long =
|
||||
private val BaseItem.resumePositionMs: Long
|
||||
get() = ((userData?.playbackPositionTicks ?: 0L) / 10_000L).coerceAtLeast(0L)
|
||||
|
||||
class DeviceLimitException(
|
||||
val activeClients: Int,
|
||||
val maxClients: Int,
|
||||
) : Exception("Memby device allowance reached")
|
||||
|
||||
/** Shared across the error-body parsers below; building a Json format per call is costly. */
|
||||
private val errorBodyJson = Json { ignoreUnknownKeys = true }
|
||||
|
||||
internal fun parseDeviceLimit(body: String): DeviceLimitException? {
|
||||
if (body.isBlank()) return null
|
||||
return runCatching {
|
||||
val parsed = errorBodyJson.decodeFromString<GatewayAuthError>(body)
|
||||
parsed.takeIf {
|
||||
it.error == "device_limit_reached" && it.maxClientsPerUser > 0
|
||||
}?.let {
|
||||
DeviceLimitException(
|
||||
activeClients = it.activeClients.coerceAtLeast(0),
|
||||
maxClients = it.maxClientsPerUser,
|
||||
)
|
||||
}
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps an exception to a short, TV-readable message. Never surfaces raw HTTP
|
||||
* bodies or stack traces (which could contain tokens) to the screen.
|
||||
@@ -1217,6 +1588,10 @@ fun isMaintenanceError(t: Throwable): Boolean = t is HttpException && t.code() =
|
||||
/** True when the gateway has rejected the persisted session token. */
|
||||
fun isUnauthorizedError(t: Throwable): Boolean = t is HttpException && t.code() == 401
|
||||
|
||||
/** Only an authoritative authentication rejection permits deleting a saved profile. */
|
||||
internal fun shouldPreserveSessionAfterValidationFailure(t: Throwable): Boolean =
|
||||
!isUnauthorizedError(t)
|
||||
|
||||
@Serializable
|
||||
private data class MaintenanceResponse(
|
||||
val maintenance: Boolean = false,
|
||||
|
||||
@@ -20,14 +20,19 @@ data class MaintenanceNotice(val message: String)
|
||||
data class CompatibilityNotice(val message: String)
|
||||
|
||||
/**
|
||||
* One informational banner: a show aired, its episode is on its way into Emby. It is
|
||||
* never actionable and never focusable — it slides in, says its piece and goes.
|
||||
* One informational banner: a show aired, a film was added, the library finished
|
||||
* refreshing, the server stopped answering. It is never actionable and never focusable —
|
||||
* it slides in, says its piece and goes, over the launcher or over playback alike.
|
||||
*
|
||||
* [label] is the eyebrow above the title and comes from the server, so a kind of news
|
||||
* this build has never heard of still reads correctly; a server that sends none gets the
|
||||
* original wording back.
|
||||
*/
|
||||
data class ServiceAlert(
|
||||
val id: String,
|
||||
val title: String,
|
||||
val message: String,
|
||||
val posterUrl: String?,
|
||||
val label: String = "",
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -177,7 +182,10 @@ class MaintenanceMonitor(
|
||||
id = next.id,
|
||||
title = next.title.trim(),
|
||||
message = next.message.trim(),
|
||||
posterUrl = repository.posterUrl(next.itemId, next.imageTag),
|
||||
// The banner shows the Emby mark rather than item artwork — half these
|
||||
// alerts (a refresh, an outage) have no artwork — so the itemId and
|
||||
// imageTag the gateway still sends are deliberately unused here.
|
||||
label = next.label.trim(),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -2,15 +2,19 @@ package com.ponzischeme89.memby.data
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.emptyPreferences
|
||||
import androidx.datastore.preferences.core.MutablePreferences
|
||||
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.intPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringSetPreferencesKey
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.map
|
||||
@@ -25,7 +29,20 @@ import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.util.UUID
|
||||
|
||||
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "emby_settings")
|
||||
/**
|
||||
* The corruption handler is not optional here. Without one, a Preferences file that fails
|
||||
* to parse makes [DataStore.data] throw on every read for the life of the install — and
|
||||
* because [SettingsStore.settingsFlow] is the gate the launcher waits behind, that shows
|
||||
* up as "Opening Memby…" forever, surviving every relaunch. Preferences DataStore rewrites
|
||||
* and fsyncs the whole file on each edit, and this app edits it on every home refresh, so
|
||||
* a process killed mid-write (backgrounding a TV app is exactly when that happens) is a
|
||||
* real possibility rather than a theoretical one. Starting again from empty preferences
|
||||
* costs a sign-in; throwing costs the app.
|
||||
*/
|
||||
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(
|
||||
name = "emby_settings",
|
||||
corruptionHandler = ReplaceFileCorruptionHandler { emptyPreferences() },
|
||||
)
|
||||
|
||||
/** Persisted connection state. */
|
||||
data class Settings(
|
||||
@@ -46,6 +63,8 @@ data class Settings(
|
||||
val showTitleLogo: Boolean = true,
|
||||
// Slide up a "next up" banner near the end of an episode and roll into the next one.
|
||||
val autoPlayNextEpisode: Boolean = true,
|
||||
// Show the compact lower-third when playback crosses ten minutes remaining.
|
||||
val showTenMinuteReminder: Boolean = true,
|
||||
// Foreground colour of the slide-progress ring, as an RRGGBB hex string.
|
||||
val ringColorHex: String = DEFAULT_RING_COLOR,
|
||||
val lastBackdropUrl: String? = null,
|
||||
@@ -57,7 +76,25 @@ data class Settings(
|
||||
/** Whether this profile has discovered the dedicated For You destination. */
|
||||
val hasOpenedForYou: Boolean = false,
|
||||
val homeCardDensity: String = DEFAULT_HOME_CARD_DENSITY,
|
||||
/** Card image selection on browse rows: automatic, poster, or backdrop. */
|
||||
val homeArtworkStyle: String = DEFAULT_HOME_ARTWORK_STYLE,
|
||||
val showHomeCardMetadata: Boolean = true,
|
||||
/** Hide fully watched movies from browse rows and hero selections. */
|
||||
val hideWatchedMovies: Boolean = false,
|
||||
/** Newline-separated server row ids. These are profile-specific home customisations. */
|
||||
val homeRowOrder: String = "",
|
||||
val homePinnedRows: String = "",
|
||||
val homeHiddenRows: String = "",
|
||||
/** Tone used for the short welcome line after sign-in and during startup. */
|
||||
val welcomeQuoteStyle: String = DEFAULT_WELCOME_QUOTE_STYLE,
|
||||
/**
|
||||
* User ids known to have finished recommendation onboarding on this TV. Cold start
|
||||
* consults this instead of waiting on the gateway: onboarding is a one-time,
|
||||
* monotonic fact, and blocking the launcher on a round-trip to re-learn it defeats
|
||||
* the whole point of [homeCacheJson]. The gateway is still asked in the background
|
||||
* and remains authoritative for anyone not listed here.
|
||||
*/
|
||||
val onboardedUserIds: Set<String> = emptySet(),
|
||||
val profiles: List<EmbyProfile> = emptyList(),
|
||||
) {
|
||||
val isSignedIn: Boolean
|
||||
@@ -68,11 +105,17 @@ data class Settings(
|
||||
it.userId == userId && it.serverUrl == serverUrl
|
||||
}?.id
|
||||
|
||||
/** True once this TV has seen the active profile finish onboarding. */
|
||||
val hasCompletedOnboarding: Boolean
|
||||
get() = userId?.let { it in onboardedUserIds } == true
|
||||
|
||||
companion object {
|
||||
const val DEFAULT_ROTATION_SECONDS = 15
|
||||
const val DEFAULT_RING_COLOR = "FFFFFF"
|
||||
const val DEFAULT_HOME_SECTIONS = "continue,favorites,latest"
|
||||
const val DEFAULT_HOME_CARD_DENSITY = "standard"
|
||||
const val DEFAULT_HOME_ARTWORK_STYLE = "automatic"
|
||||
const val DEFAULT_WELCOME_QUOTE_STYLE = "neutral"
|
||||
val EMPTY = Settings()
|
||||
}
|
||||
}
|
||||
@@ -88,6 +131,15 @@ data class EmbyProfile(
|
||||
val homeCacheJson: String? = null,
|
||||
val forYouMinutes: Int = 0,
|
||||
val hasOpenedForYou: Boolean = false,
|
||||
val welcomeQuoteStyle: String = Settings.DEFAULT_WELCOME_QUOTE_STYLE,
|
||||
val homeSections: String = Settings.DEFAULT_HOME_SECTIONS,
|
||||
val homeCardDensity: String = Settings.DEFAULT_HOME_CARD_DENSITY,
|
||||
val homeArtworkStyle: String = Settings.DEFAULT_HOME_ARTWORK_STYLE,
|
||||
val showHomeCardMetadata: Boolean = true,
|
||||
val hideWatchedMovies: Boolean = false,
|
||||
val homeRowOrder: String = "",
|
||||
val homePinnedRows: String = "",
|
||||
val homeHiddenRows: String = "",
|
||||
)
|
||||
|
||||
class SettingsStore(private val context: Context) {
|
||||
@@ -95,6 +147,14 @@ class SettingsStore(private val context: Context) {
|
||||
@Volatile
|
||||
private var latestSettings: Settings? = null
|
||||
|
||||
/** Last home cache actually written, so an unchanged refresh skips the disk entirely. */
|
||||
@Volatile
|
||||
private var lastPersistedHomeCache: String? = null
|
||||
|
||||
/** The decoded home cache, paired with the JSON it came from. See [primeHomeCache]. */
|
||||
@Volatile
|
||||
private var decodedHomeCache: Pair<String, HomeCache?>? = null
|
||||
|
||||
val current: Settings?
|
||||
get() = latestSettings
|
||||
|
||||
@@ -113,6 +173,7 @@ class SettingsStore(private val context: Context) {
|
||||
val UPDATE_TOKEN = stringPreferencesKey("update_token")
|
||||
val SHOW_TITLE_LOGO = booleanPreferencesKey("show_title_logo")
|
||||
val AUTO_PLAY_NEXT = booleanPreferencesKey("auto_play_next_episode")
|
||||
val SHOW_TEN_MINUTE_REMINDER = booleanPreferencesKey("show_ten_minute_reminder")
|
||||
val RING_COLOR = stringPreferencesKey("ring_color")
|
||||
val LAST_BACKDROP_URL = stringPreferencesKey("last_backdrop_url")
|
||||
val HOME_SECTIONS = stringPreferencesKey("home_sections")
|
||||
@@ -120,9 +181,16 @@ class SettingsStore(private val context: Context) {
|
||||
val FOR_YOU_MINUTES = intPreferencesKey("for_you_minutes")
|
||||
val HAS_OPENED_FOR_YOU = booleanPreferencesKey("has_opened_for_you")
|
||||
val HOME_CARD_DENSITY = stringPreferencesKey("home_card_density")
|
||||
val HOME_ARTWORK_STYLE = stringPreferencesKey("home_artwork_style")
|
||||
val SHOW_HOME_CARD_METADATA = booleanPreferencesKey("show_home_card_metadata")
|
||||
val HIDE_WATCHED_MOVIES = booleanPreferencesKey("hide_watched_movies")
|
||||
val HOME_ROW_ORDER = stringPreferencesKey("home_row_order")
|
||||
val HOME_PINNED_ROWS = stringPreferencesKey("home_pinned_rows")
|
||||
val HOME_HIDDEN_ROWS = stringPreferencesKey("home_hidden_rows")
|
||||
val WELCOME_QUOTE_STYLE = stringPreferencesKey("welcome_quote_style")
|
||||
val PROFILES = stringPreferencesKey("profiles")
|
||||
val SEEN_ALERTS = stringPreferencesKey("seen_alert_ids")
|
||||
val ONBOARDED_USERS = stringSetPreferencesKey("onboarded_user_ids")
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -134,7 +202,24 @@ class SettingsStore(private val context: Context) {
|
||||
val settingsFlow: Flow<Settings> = context.dataStore.data
|
||||
.map(::settingsFrom)
|
||||
.distinctUntilChanged()
|
||||
.onEach { latestSettings = it }
|
||||
.onEach {
|
||||
latestSettings = it
|
||||
// Decode the launcher's cached rows here, on this store's IO scope, rather
|
||||
// than leaving it for HomeViewModel's constructor — which Compose runs on the
|
||||
// main thread during the first composition, parsing a blob that can hold
|
||||
// every row and every item in it. AppRoot does not compose HomeScreen until
|
||||
// this flow has emitted, so by the time the view model asks, the answer is
|
||||
// already in memory.
|
||||
primeHomeCache(it)
|
||||
}
|
||||
// A throw here would complete the sharing coroutine, and a SharedFlow that has
|
||||
// completed never emits again — every collector for the rest of the process waits
|
||||
// forever, which the launcher renders as its loading screen. Nothing this pipeline
|
||||
// does is worth that, so a failed read degrades to defaults and the app carries on
|
||||
// to sign-in instead of hanging. The corruption handler on [dataStore] covers the
|
||||
// parse failure; this covers everything else, including an I/O error on a TV whose
|
||||
// storage is briefly unavailable.
|
||||
.catch { emit(Settings.EMPTY.also { latestSettings = it }) }
|
||||
.shareIn(scope, started = SharingStarted.Eagerly, replay = 1)
|
||||
|
||||
suspend fun setRotationIntervalSeconds(seconds: Int) {
|
||||
@@ -189,6 +274,10 @@ class SettingsStore(private val context: Context) {
|
||||
context.dataStore.edit { it[Keys.AUTO_PLAY_NEXT] = enabled }
|
||||
}
|
||||
|
||||
suspend fun setShowTenMinuteReminder(enabled: Boolean) {
|
||||
context.dataStore.edit { it[Keys.SHOW_TEN_MINUTE_REMINDER] = enabled }
|
||||
}
|
||||
|
||||
suspend fun setRingColor(hex: String) {
|
||||
context.dataStore.edit { it[Keys.RING_COLOR] = hex }
|
||||
}
|
||||
@@ -199,41 +288,140 @@ class SettingsStore(private val context: Context) {
|
||||
|
||||
suspend fun setHomeSections(sections: List<String>) {
|
||||
val valid = sections.filter { it in setOf("continue", "favorites", "latest") }.distinct()
|
||||
context.dataStore.edit {
|
||||
it[Keys.HOME_SECTIONS] = valid.ifEmpty { listOf("favorites") }.joinToString(",")
|
||||
val selected = valid.ifEmpty { listOf("favorites") }.joinToString(",")
|
||||
context.dataStore.edit { preferences ->
|
||||
preferences[Keys.HOME_SECTIONS] = selected
|
||||
updateActiveProfile(preferences) { it.copy(homeSections = selected) }
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun setHomeCardDensity(density: String) {
|
||||
context.dataStore.edit {
|
||||
it[Keys.HOME_CARD_DENSITY] = density.takeIf { value -> value in setOf("compact", "standard", "large") }
|
||||
?: Settings.DEFAULT_HOME_CARD_DENSITY
|
||||
val selected = density.takeIf { it in setOf("compact", "standard", "large") }
|
||||
?: Settings.DEFAULT_HOME_CARD_DENSITY
|
||||
context.dataStore.edit { preferences ->
|
||||
preferences[Keys.HOME_CARD_DENSITY] = selected
|
||||
updateActiveProfile(preferences) { it.copy(homeCardDensity = selected) }
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun setHomeArtworkStyle(style: String) {
|
||||
val selected = style.takeIf { it in setOf("automatic", "poster", "backdrop") }
|
||||
?: Settings.DEFAULT_HOME_ARTWORK_STYLE
|
||||
context.dataStore.edit { preferences ->
|
||||
preferences[Keys.HOME_ARTWORK_STYLE] = selected
|
||||
updateActiveProfile(preferences) { it.copy(homeArtworkStyle = selected) }
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun setShowHomeCardMetadata(show: Boolean) {
|
||||
context.dataStore.edit { it[Keys.SHOW_HOME_CARD_METADATA] = show }
|
||||
context.dataStore.edit { preferences ->
|
||||
preferences[Keys.SHOW_HOME_CARD_METADATA] = show
|
||||
updateActiveProfile(preferences) { it.copy(showHomeCardMetadata = show) }
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun setHomeCache(cache: HomeCache) {
|
||||
suspend fun setHideWatchedMovies(hide: Boolean) {
|
||||
context.dataStore.edit { preferences ->
|
||||
val encodedCache = Json.encodeToString(cache)
|
||||
preferences[Keys.HOME_CACHE] = encodedCache
|
||||
val activeUserId = preferences[Keys.USER_ID]
|
||||
val activeServer = preferences[Keys.SERVER_URL]
|
||||
val profiles = profilesFrom(preferences).map { profile ->
|
||||
if (profile.userId == activeUserId && profile.serverUrl == activeServer) {
|
||||
profile.copy(homeCacheJson = encodedCache)
|
||||
} else {
|
||||
profile
|
||||
}
|
||||
}
|
||||
if (profiles.isNotEmpty()) {
|
||||
preferences[Keys.PROFILES] = Json.encodeToString(profiles)
|
||||
preferences[Keys.HIDE_WATCHED_MOVIES] = hide
|
||||
updateActiveProfile(preferences) { it.copy(hideWatchedMovies = hide) }
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun setHomeRowPreferences(
|
||||
order: List<String>,
|
||||
pinned: Set<String>,
|
||||
hidden: Set<String>,
|
||||
) {
|
||||
fun encode(values: Iterable<String>): String = values
|
||||
.map(String::trim)
|
||||
.filter { it.isNotEmpty() && '\n' !in it }
|
||||
.distinct()
|
||||
.joinToString("\n")
|
||||
val encodedOrder = encode(order)
|
||||
val encodedPinned = encode(pinned)
|
||||
val encodedHidden = encode(hidden - pinned)
|
||||
context.dataStore.edit { preferences ->
|
||||
preferences[Keys.HOME_ROW_ORDER] = encodedOrder
|
||||
preferences[Keys.HOME_PINNED_ROWS] = encodedPinned
|
||||
preferences[Keys.HOME_HIDDEN_ROWS] = encodedHidden
|
||||
updateActiveProfile(preferences) {
|
||||
it.copy(
|
||||
homeRowOrder = encodedOrder,
|
||||
homePinnedRows = encodedPinned,
|
||||
homeHiddenRows = encodedHidden,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun setWelcomeQuoteStyle(style: String) {
|
||||
val selected = style.takeIf { it in setOf("neutral", "positive", "homicidal") }
|
||||
?: Settings.DEFAULT_WELCOME_QUOTE_STYLE
|
||||
context.dataStore.edit { preferences ->
|
||||
preferences[Keys.WELCOME_QUOTE_STYLE] = selected
|
||||
updateActiveProfile(preferences) { it.copy(welcomeQuoteStyle = selected) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Persists the launcher's rows so the next cold start can draw before the network
|
||||
* answers.
|
||||
*
|
||||
* This used to encode the cache, then re-encode the entire profiles blob with a copy
|
||||
* of that same JSON embedded in the matching profile — and Preferences DataStore
|
||||
* rewrites and fsyncs the whole file on every edit, so each call wrote the cache
|
||||
* twice over, plus a copy for every other profile that was already carrying one. It
|
||||
* runs on every home refresh and again on every playback stop, which is exactly when
|
||||
* the viewer is watching the launcher redraw.
|
||||
*
|
||||
* Now each profile's cache lives under its own key ([profileHomeCacheKey]) and the
|
||||
* profiles blob carries none, so a refresh writes one value and the blob stays small.
|
||||
*/
|
||||
suspend fun setHomeCache(cache: HomeCache) {
|
||||
val encodedCache = Json.encodeToString(cache)
|
||||
// Most refreshes find nothing new — a scheduled poll, or a playback stop on a row
|
||||
// that did not move. Skipping the write means skipping the whole file rewrite.
|
||||
if (encodedCache == lastPersistedHomeCache) return
|
||||
context.dataStore.edit { preferences ->
|
||||
val profileKey = profileHomeCacheKey(
|
||||
userId = preferences[Keys.USER_ID],
|
||||
serverUrl = preferences[Keys.SERVER_URL],
|
||||
)
|
||||
if (profileKey == null) {
|
||||
// No active profile to key against; the flat slot is all there is.
|
||||
preferences[Keys.HOME_CACHE] = encodedCache
|
||||
} else {
|
||||
// Exactly one copy. Writing the flat key as well put the largest value
|
||||
// this store holds into the file twice, and DataStore rewrites and fsyncs
|
||||
// the whole file on every edit — so each home refresh was paying double
|
||||
// for a value nothing read twice. [activeHomeCache] reads this key first.
|
||||
preferences[profileKey] = encodedCache
|
||||
preferences.remove(Keys.HOME_CACHE)
|
||||
}
|
||||
}
|
||||
lastPersistedHomeCache = encodedCache
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a given profile's home cache is stored. Null when there is no active profile
|
||||
* to key it against, in which case only the flat [Keys.HOME_CACHE] is written.
|
||||
*/
|
||||
private fun profileHomeCacheKey(userId: String?, serverUrl: String?): Preferences.Key<String>? {
|
||||
if (userId.isNullOrBlank() || serverUrl.isNullOrBlank()) return null
|
||||
return stringPreferencesKey("home_cache::$userId@$serverUrl")
|
||||
}
|
||||
|
||||
/**
|
||||
* The active profile's cached rows. The per-profile key is authoritative; the flat
|
||||
* [Keys.HOME_CACHE] slot survives only for installs written before the split and for
|
||||
* the case where there is no profile to key against.
|
||||
*/
|
||||
private fun activeHomeCache(preferences: Preferences): String? =
|
||||
profileHomeCacheKey(
|
||||
userId = preferences[Keys.USER_ID],
|
||||
serverUrl = preferences[Keys.SERVER_URL],
|
||||
)?.let { preferences[it] } ?: preferences[Keys.HOME_CACHE]
|
||||
|
||||
suspend fun setForYouMinutes(minutes: Int) {
|
||||
val selected = minutes.takeIf { it in setOf(0, 30, 60, 120) } ?: 0
|
||||
context.dataStore.edit { preferences ->
|
||||
@@ -242,6 +430,21 @@ class SettingsStore(private val context: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Records that a profile has finished recommendation onboarding, so the next cold
|
||||
* start can go straight to the launcher instead of waiting on the gateway to say so.
|
||||
* Additive only — nothing here ever un-onboards a user, because the failure mode of a
|
||||
* wrongly-cleared flag (a returning viewer shown the rating screen again) is worse
|
||||
* than the failure mode of a stale one (a request the gateway answers anyway).
|
||||
*/
|
||||
suspend fun markOnboardingCompleted(userId: String) {
|
||||
val trimmed = userId.trim()
|
||||
if (trimmed.isEmpty()) return
|
||||
context.dataStore.edit { preferences ->
|
||||
preferences[Keys.ONBOARDED_USERS] = preferences[Keys.ONBOARDED_USERS].orEmpty() + trimmed
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun markForYouOpened() {
|
||||
context.dataStore.edit { preferences ->
|
||||
preferences[Keys.HAS_OPENED_FOR_YOU] = true
|
||||
@@ -263,14 +466,56 @@ class SettingsStore(private val context: Context) {
|
||||
}
|
||||
}
|
||||
if (profiles.isNotEmpty()) {
|
||||
preferences[Keys.PROFILES] = Json.encodeToString(profiles)
|
||||
writeProfiles(preferences, profiles)
|
||||
}
|
||||
}
|
||||
|
||||
fun homeCache(settings: Settings): HomeCache? = settings.homeCacheJson?.let {
|
||||
runCatching { Json.decodeFromString<HomeCache>(it) }.getOrNull()
|
||||
/**
|
||||
* The single writer for the profiles blob. Strips each profile's home cache out into
|
||||
* its own key, so this blob stays small enough that the many settings toggles which
|
||||
* rewrite it — every one of which rewrites the whole DataStore file — cost nothing
|
||||
* proportional to the size of the library.
|
||||
*
|
||||
* The strip doubles as the migration: an install whose blob still embeds a cache has
|
||||
* it moved across the first time anything rewrites the blob, so a profile switch
|
||||
* still restores that profile's rows.
|
||||
*/
|
||||
private fun writeProfiles(preferences: MutablePreferences, profiles: List<EmbyProfile>) {
|
||||
val stripped = profiles.map { profile ->
|
||||
profile.homeCacheJson?.takeIf { it.isNotBlank() }?.let { embedded ->
|
||||
profileHomeCacheKey(profile.userId, profile.serverUrl)?.let { key ->
|
||||
if (preferences[key] == null) preferences[key] = embedded
|
||||
}
|
||||
}
|
||||
if (profile.homeCacheJson == null) profile else profile.copy(homeCacheJson = null)
|
||||
}
|
||||
preferences[Keys.PROFILES] = Json.encodeToString(stripped)
|
||||
}
|
||||
|
||||
/**
|
||||
* The launcher's cached rows. Returns the copy decoded by [primeHomeCache] when the
|
||||
* JSON has not changed since, so the common path is a reference comparison rather
|
||||
* than a parse — see the note on [settingsFlow].
|
||||
*/
|
||||
fun homeCache(settings: Settings): HomeCache? {
|
||||
val json = settings.homeCacheJson ?: return null
|
||||
decodedHomeCache?.let { (source, decoded) -> if (source == json) return decoded }
|
||||
return decodeHomeCache(json).also { decodedHomeCache = json to it }
|
||||
}
|
||||
|
||||
private fun primeHomeCache(settings: Settings) {
|
||||
val json = settings.homeCacheJson
|
||||
if (json == null) {
|
||||
decodedHomeCache = null
|
||||
return
|
||||
}
|
||||
if (decodedHomeCache?.first == json) return
|
||||
decodedHomeCache = json to decodeHomeCache(json)
|
||||
}
|
||||
|
||||
private fun decodeHomeCache(json: String): HomeCache? =
|
||||
runCatching { Json.decodeFromString<HomeCache>(json) }.getOrNull()
|
||||
|
||||
/**
|
||||
* Reads DataStore directly instead of taking the replayed flow value. This matters
|
||||
* immediately after an edit, when the replay slot may still contain the prior value.
|
||||
@@ -302,10 +547,21 @@ class SettingsStore(private val context: Context) {
|
||||
homeCacheJson = previous?.homeCacheJson,
|
||||
forYouMinutes = previous?.forYouMinutes ?: 0,
|
||||
hasOpenedForYou = previous?.hasOpenedForYou ?: false,
|
||||
welcomeQuoteStyle = previous?.welcomeQuoteStyle
|
||||
?: Settings.DEFAULT_WELCOME_QUOTE_STYLE,
|
||||
homeSections = previous?.homeSections ?: Settings.DEFAULT_HOME_SECTIONS,
|
||||
homeCardDensity = previous?.homeCardDensity ?: Settings.DEFAULT_HOME_CARD_DENSITY,
|
||||
homeArtworkStyle = previous?.homeArtworkStyle
|
||||
?: Settings.DEFAULT_HOME_ARTWORK_STYLE,
|
||||
showHomeCardMetadata = previous?.showHomeCardMetadata ?: true,
|
||||
hideWatchedMovies = previous?.hideWatchedMovies ?: false,
|
||||
homeRowOrder = previous?.homeRowOrder.orEmpty(),
|
||||
homePinnedRows = previous?.homePinnedRows.orEmpty(),
|
||||
homeHiddenRows = previous?.homeHiddenRows.orEmpty(),
|
||||
)
|
||||
profiles.removeAll { it.id == id }
|
||||
profiles.add(profile)
|
||||
preferences[Keys.PROFILES] = Json.encodeToString(profiles)
|
||||
writeProfiles(preferences, profiles)
|
||||
applyProfile(preferences, profile)
|
||||
}
|
||||
}
|
||||
@@ -315,12 +571,30 @@ class SettingsStore(private val context: Context) {
|
||||
val profiles = profilesFrom(preferences).toMutableList()
|
||||
if (profiles.none { it.id == profile.id }) {
|
||||
profiles.add(profile)
|
||||
preferences[Keys.PROFILES] = Json.encodeToString(profiles)
|
||||
writeProfiles(preferences, profiles)
|
||||
}
|
||||
applyProfile(preferences, profile)
|
||||
}
|
||||
}
|
||||
|
||||
/** Forgets a saved profile on this device. */
|
||||
suspend fun removeProfile(profileId: String) {
|
||||
context.dataStore.edit { preferences ->
|
||||
val profiles = profilesFrom(preferences)
|
||||
val removed = profiles.firstOrNull { it.id == profileId } ?: return@edit
|
||||
writeProfiles(preferences, profiles.filterNot { it.id == profileId })
|
||||
// Forget the departing profile's cached rows too; nothing will read that key
|
||||
// again and it is the largest single value this store holds.
|
||||
profileHomeCacheKey(removed.userId, removed.serverUrl)?.let(preferences::remove)
|
||||
if (
|
||||
preferences[Keys.USER_ID] == removed.userId &&
|
||||
preferences[Keys.SERVER_URL] == removed.serverUrl
|
||||
) {
|
||||
clearActiveSession(preferences)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun clearSession() {
|
||||
context.dataStore.edit {
|
||||
clearActiveSession(it)
|
||||
@@ -340,7 +614,8 @@ class SettingsStore(private val context: Context) {
|
||||
val remaining = profilesFrom(preferences).filterNot {
|
||||
it.userId == activeUserId && it.serverUrl == activeServer
|
||||
}
|
||||
preferences[Keys.PROFILES] = Json.encodeToString(remaining)
|
||||
writeProfiles(preferences, remaining)
|
||||
profileHomeCacheKey(activeUserId, activeServer)?.let(preferences::remove)
|
||||
clearActiveSession(preferences)
|
||||
}
|
||||
}
|
||||
@@ -352,8 +627,18 @@ class SettingsStore(private val context: Context) {
|
||||
preferences.remove(Keys.SERVER_ID)
|
||||
preferences.remove(Keys.LAST_BACKDROP_URL)
|
||||
preferences.remove(Keys.HOME_CACHE)
|
||||
lastPersistedHomeCache = null
|
||||
preferences.remove(Keys.FOR_YOU_MINUTES)
|
||||
preferences.remove(Keys.HAS_OPENED_FOR_YOU)
|
||||
preferences.remove(Keys.WELCOME_QUOTE_STYLE)
|
||||
preferences.remove(Keys.HOME_SECTIONS)
|
||||
preferences.remove(Keys.HOME_CARD_DENSITY)
|
||||
preferences.remove(Keys.HOME_ARTWORK_STYLE)
|
||||
preferences.remove(Keys.SHOW_HOME_CARD_METADATA)
|
||||
preferences.remove(Keys.HIDE_WATCHED_MOVIES)
|
||||
preferences.remove(Keys.HOME_ROW_ORDER)
|
||||
preferences.remove(Keys.HOME_PINNED_ROWS)
|
||||
preferences.remove(Keys.HOME_HIDDEN_ROWS)
|
||||
preferences.remove(Keys.USERNAME)
|
||||
}
|
||||
|
||||
@@ -364,10 +649,36 @@ class SettingsStore(private val context: Context) {
|
||||
preferences[Keys.USERNAME] = profile.username
|
||||
if (profile.serverId.isNullOrBlank()) preferences.remove(Keys.SERVER_ID)
|
||||
else preferences[Keys.SERVER_ID] = profile.serverId
|
||||
if (profile.homeCacheJson.isNullOrBlank()) preferences.remove(Keys.HOME_CACHE)
|
||||
else preferences[Keys.HOME_CACHE] = profile.homeCacheJson
|
||||
// Prefer the profile's dedicated cache key; fall back to a copy embedded in the
|
||||
// profiles blob, which is how installs predating the split stored it — and move
|
||||
// that copy across, so the migration completes here too.
|
||||
val profileKey = profileHomeCacheKey(profile.userId, profile.serverUrl)
|
||||
val cached = profileKey?.let { preferences[it] } ?: profile.homeCacheJson
|
||||
if (profileKey != null) {
|
||||
if (preferences[profileKey] == null && !cached.isNullOrBlank()) {
|
||||
preferences[profileKey] = cached
|
||||
}
|
||||
// Reads go through [activeHomeCache], which prefers the profile key; keeping a
|
||||
// second copy in the flat slot only inflates the file.
|
||||
preferences.remove(Keys.HOME_CACHE)
|
||||
} else if (cached.isNullOrBlank()) {
|
||||
preferences.remove(Keys.HOME_CACHE)
|
||||
} else {
|
||||
preferences[Keys.HOME_CACHE] = cached
|
||||
}
|
||||
// The active cache belongs to a different profile now.
|
||||
lastPersistedHomeCache = cached
|
||||
preferences[Keys.FOR_YOU_MINUTES] = profile.forYouMinutes
|
||||
preferences[Keys.HAS_OPENED_FOR_YOU] = profile.hasOpenedForYou
|
||||
preferences[Keys.WELCOME_QUOTE_STYLE] = profile.welcomeQuoteStyle
|
||||
preferences[Keys.HOME_SECTIONS] = profile.homeSections
|
||||
preferences[Keys.HOME_CARD_DENSITY] = profile.homeCardDensity
|
||||
preferences[Keys.HOME_ARTWORK_STYLE] = profile.homeArtworkStyle
|
||||
preferences[Keys.SHOW_HOME_CARD_METADATA] = profile.showHomeCardMetadata
|
||||
preferences[Keys.HIDE_WATCHED_MOVIES] = profile.hideWatchedMovies
|
||||
preferences[Keys.HOME_ROW_ORDER] = profile.homeRowOrder
|
||||
preferences[Keys.HOME_PINNED_ROWS] = profile.homePinnedRows
|
||||
preferences[Keys.HOME_HIDDEN_ROWS] = profile.homeHiddenRows
|
||||
preferences.remove(Keys.LAST_BACKDROP_URL)
|
||||
}
|
||||
|
||||
@@ -383,9 +694,21 @@ class SettingsStore(private val context: Context) {
|
||||
userId = userId,
|
||||
username = username,
|
||||
serverId = preferences[Keys.SERVER_ID],
|
||||
homeCacheJson = preferences[Keys.HOME_CACHE],
|
||||
homeCacheJson = activeHomeCache(preferences),
|
||||
forYouMinutes = preferences[Keys.FOR_YOU_MINUTES] ?: 0,
|
||||
hasOpenedForYou = preferences[Keys.HAS_OPENED_FOR_YOU] ?: false,
|
||||
welcomeQuoteStyle = preferences[Keys.WELCOME_QUOTE_STYLE]
|
||||
?: Settings.DEFAULT_WELCOME_QUOTE_STYLE,
|
||||
homeSections = preferences[Keys.HOME_SECTIONS] ?: Settings.DEFAULT_HOME_SECTIONS,
|
||||
homeCardDensity = preferences[Keys.HOME_CARD_DENSITY]
|
||||
?: Settings.DEFAULT_HOME_CARD_DENSITY,
|
||||
homeArtworkStyle = preferences[Keys.HOME_ARTWORK_STYLE]
|
||||
?: Settings.DEFAULT_HOME_ARTWORK_STYLE,
|
||||
showHomeCardMetadata = preferences[Keys.SHOW_HOME_CARD_METADATA] ?: true,
|
||||
hideWatchedMovies = preferences[Keys.HIDE_WATCHED_MOVIES] ?: false,
|
||||
homeRowOrder = preferences[Keys.HOME_ROW_ORDER].orEmpty(),
|
||||
homePinnedRows = preferences[Keys.HOME_PINNED_ROWS].orEmpty(),
|
||||
homeHiddenRows = preferences[Keys.HOME_HIDDEN_ROWS].orEmpty(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -413,14 +736,24 @@ class SettingsStore(private val context: Context) {
|
||||
updateToken = preferences[Keys.UPDATE_TOKEN],
|
||||
showTitleLogo = preferences[Keys.SHOW_TITLE_LOGO] ?: true,
|
||||
autoPlayNextEpisode = preferences[Keys.AUTO_PLAY_NEXT] ?: true,
|
||||
showTenMinuteReminder = preferences[Keys.SHOW_TEN_MINUTE_REMINDER] ?: true,
|
||||
ringColorHex = preferences[Keys.RING_COLOR] ?: Settings.DEFAULT_RING_COLOR,
|
||||
lastBackdropUrl = preferences[Keys.LAST_BACKDROP_URL],
|
||||
homeSections = preferences[Keys.HOME_SECTIONS] ?: Settings.DEFAULT_HOME_SECTIONS,
|
||||
homeCacheJson = preferences[Keys.HOME_CACHE],
|
||||
homeCacheJson = activeHomeCache(preferences),
|
||||
forYouMinutes = preferences[Keys.FOR_YOU_MINUTES] ?: 0,
|
||||
hasOpenedForYou = preferences[Keys.HAS_OPENED_FOR_YOU] ?: false,
|
||||
homeCardDensity = preferences[Keys.HOME_CARD_DENSITY] ?: Settings.DEFAULT_HOME_CARD_DENSITY,
|
||||
homeArtworkStyle = preferences[Keys.HOME_ARTWORK_STYLE]
|
||||
?: Settings.DEFAULT_HOME_ARTWORK_STYLE,
|
||||
showHomeCardMetadata = preferences[Keys.SHOW_HOME_CARD_METADATA] ?: true,
|
||||
hideWatchedMovies = preferences[Keys.HIDE_WATCHED_MOVIES] ?: false,
|
||||
homeRowOrder = preferences[Keys.HOME_ROW_ORDER].orEmpty(),
|
||||
homePinnedRows = preferences[Keys.HOME_PINNED_ROWS].orEmpty(),
|
||||
homeHiddenRows = preferences[Keys.HOME_HIDDEN_ROWS].orEmpty(),
|
||||
welcomeQuoteStyle = preferences[Keys.WELCOME_QUOTE_STYLE]
|
||||
?: Settings.DEFAULT_WELCOME_QUOTE_STYLE,
|
||||
onboardedUserIds = preferences[Keys.ONBOARDED_USERS].orEmpty(),
|
||||
profiles = profiles,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -27,16 +27,31 @@ class RowAnalytics(
|
||||
private val lock = Any()
|
||||
private val buffer = ArrayList<GatewayRowEvent>()
|
||||
private val impressed = HashSet<String>()
|
||||
private val impressedItems = HashSet<String>()
|
||||
|
||||
private var focusedRowId: String? = null
|
||||
private var focusedRowKind: String = ""
|
||||
private var focusStartedAt: Long = 0
|
||||
|
||||
/** Records that a row was drawn. Repeats are ignored until [reset]. */
|
||||
fun rowImpression(rowId: String, rowKind: String) {
|
||||
fun rowImpression(rowId: String, rowKind: String, visibleItemIds: List<String> = emptyList()) {
|
||||
synchronized(lock) {
|
||||
if (!impressed.add(rowId)) return
|
||||
add(GatewayRowEvent(rowId = rowId, rowKind = rowKind, event = EVENT_IMPRESSION, occurredAt = timestamp()))
|
||||
if (impressed.add(rowId)) {
|
||||
add(GatewayRowEvent(rowId = rowId, rowKind = rowKind, event = EVENT_IMPRESSION, occurredAt = timestamp()))
|
||||
}
|
||||
visibleItemIds.filter(String::isNotBlank).forEach { itemId ->
|
||||
if (impressedItems.add("$rowId:$itemId")) {
|
||||
add(
|
||||
GatewayRowEvent(
|
||||
rowId = rowId,
|
||||
rowKind = rowKind,
|
||||
event = EVENT_IMPRESSION,
|
||||
itemId = itemId,
|
||||
occurredAt = timestamp(),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,6 +114,7 @@ class RowAnalytics(
|
||||
synchronized(lock) {
|
||||
buffer.clear()
|
||||
impressed.clear()
|
||||
impressedItems.clear()
|
||||
focusedRowId = null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
package com.ponzischeme89.memby.data.model
|
||||
|
||||
import com.ponzischeme89.memby.data.playback.DevicePlaybackCapabilities
|
||||
import com.ponzischeme89.memby.data.playback.VideoDecoderCapabilities
|
||||
import com.ponzischeme89.memby.data.playback.devicePlaybackCapabilities
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@@ -33,6 +36,7 @@ data class UserItemData(
|
||||
@SerialName("IsFavorite") val isFavorite: Boolean = false,
|
||||
@SerialName("Played") val played: Boolean = false,
|
||||
@SerialName("PlaybackPositionTicks") val playbackPositionTicks: Long = 0,
|
||||
@SerialName("UnplayedItemCount") val unplayedItemCount: Int? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
@@ -53,6 +57,11 @@ data class PlaybackInfoRequest(
|
||||
@SerialName("Id") val id: String,
|
||||
@SerialName("UserId") val userId: String,
|
||||
@SerialName("IsPlayback") val isPlayback: Boolean = true,
|
||||
@SerialName("EnableDirectPlay") val enableDirectPlay: Boolean = true,
|
||||
@SerialName("EnableDirectStream") val enableDirectStream: Boolean = true,
|
||||
@SerialName("EnableTranscoding") val enableTranscoding: Boolean = true,
|
||||
@SerialName("AllowVideoStreamCopy") val allowVideoStreamCopy: Boolean = true,
|
||||
@SerialName("AllowAudioStreamCopy") val allowAudioStreamCopy: Boolean = true,
|
||||
@SerialName("StartTimeTicks") val startTimeTicks: Long = 0,
|
||||
@SerialName("SubtitleStreamIndex") val subtitleStreamIndex: Int? = null,
|
||||
@SerialName("CurrentPlaySessionId") val currentPlaySessionId: String? = null,
|
||||
@@ -66,9 +75,12 @@ data class DeviceProfile(
|
||||
@SerialName("SubtitleProfiles") val subtitleProfiles: List<SubtitleProfile>,
|
||||
@SerialName("DirectPlayProfiles") val directPlayProfiles: List<DirectPlayProfile>,
|
||||
@SerialName("TranscodingProfiles") val transcodingProfiles: List<TranscodingProfile>,
|
||||
@SerialName("CodecProfiles") val codecProfiles: List<CodecProfile> = emptyList(),
|
||||
) {
|
||||
companion object {
|
||||
fun embyAndroidTv() = DeviceProfile(
|
||||
fun embyAndroidTv(
|
||||
capabilities: DevicePlaybackCapabilities = devicePlaybackCapabilities,
|
||||
) = DeviceProfile(
|
||||
name = "Memby Android TV",
|
||||
subtitleProfiles = listOf(
|
||||
"srt", "subrip", "ass", "ssa", "vtt", "webvtt", "mov_text", "tx3g",
|
||||
@@ -77,23 +89,143 @@ data class DeviceProfile(
|
||||
).map { SubtitleProfile(it, "Encode") },
|
||||
directPlayProfiles = listOf(
|
||||
DirectPlayProfile(
|
||||
container = "mkv,mp4,m4v,mov,webm,ts,mpegts,avi",
|
||||
videoCodec = "h264,hevc,vp8,vp9,av1,mpeg2video,mpeg4",
|
||||
audioCodec = "aac,ac3,eac3,mp3,opus,vorbis,flac,pcm",
|
||||
// The broad codec declaration is bounded by CodecProfiles below.
|
||||
container = "mkv,mp4,m4v,mov,ts,mpegts",
|
||||
videoCodec = directPlayVideoCodecs(capabilities),
|
||||
audioCodec = "aac,mp3",
|
||||
),
|
||||
),
|
||||
transcodingProfiles = listOf(
|
||||
TranscodingProfile(
|
||||
container = "ts",
|
||||
videoCodec = "h264",
|
||||
// Permits Emby to remux a supported HEVC/H.264 video stream while
|
||||
// converting only incompatible audio or subtitles.
|
||||
videoCodec = directPlayVideoCodecs(capabilities),
|
||||
audioCodec = "aac",
|
||||
protocol = "hls",
|
||||
),
|
||||
),
|
||||
codecProfiles = codecProfiles(capabilities),
|
||||
)
|
||||
|
||||
/** Compatibility for callers and older tests that only know the HEVC boolean. */
|
||||
fun embyAndroidTv(supportsHevc: Boolean): DeviceProfile = embyAndroidTv(
|
||||
DevicePlaybackCapabilities(
|
||||
hevc = VideoDecoderCapabilities(
|
||||
supported = supportsHevc,
|
||||
profiles = if (supportsHevc) setOf("main") else emptySet(),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
private fun directPlayVideoCodecs(capabilities: DevicePlaybackCapabilities): String =
|
||||
if (capabilities.hevc.supported) "h264,hevc" else "h264"
|
||||
|
||||
private fun codecProfiles(capabilities: DevicePlaybackCapabilities): List<CodecProfile> =
|
||||
buildList {
|
||||
addVideoProfiles("h264", capabilities.h264)
|
||||
addVideoProfiles("hevc", capabilities.hevc)
|
||||
}
|
||||
|
||||
private fun MutableList<CodecProfile>.addVideoProfiles(
|
||||
codec: String,
|
||||
capability: VideoDecoderCapabilities,
|
||||
) {
|
||||
if (!capability.supported) return
|
||||
val allowedProfiles = when (codec) {
|
||||
"h264" -> capability.profiles.mapNotNull {
|
||||
when (it) {
|
||||
"baseline" -> "baseline"
|
||||
"constrained_baseline" -> "constrained baseline"
|
||||
"main" -> "main"
|
||||
"high" -> "high"
|
||||
"high10" -> "high 10"
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
else -> capability.profiles.mapNotNull {
|
||||
when (it) {
|
||||
"main" -> "main"
|
||||
"main10" -> "main 10"
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
}
|
||||
if (allowedProfiles.isNotEmpty()) {
|
||||
add(
|
||||
CodecProfile(
|
||||
codec = codec,
|
||||
conditions = listOf(
|
||||
ProfileCondition("EqualsAny", "VideoProfile", allowedProfiles.joinToString("|")),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
if (capability.mainLevel > 0) {
|
||||
add(
|
||||
CodecProfile(
|
||||
codec = codec,
|
||||
conditions = listOf(
|
||||
ProfileCondition("LessThanEqual", "VideoLevel", capability.mainLevel.toString()),
|
||||
),
|
||||
applyConditions = listOf(
|
||||
ProfileCondition(
|
||||
"EqualsAny",
|
||||
"VideoProfile",
|
||||
if (codec == "h264") "baseline|constrained baseline|main|high" else "main",
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
if (capability.tenBitLevel > 0) {
|
||||
add(
|
||||
CodecProfile(
|
||||
codec = codec,
|
||||
conditions = listOf(
|
||||
ProfileCondition("LessThanEqual", "VideoLevel", capability.tenBitLevel.toString()),
|
||||
),
|
||||
applyConditions = listOf(
|
||||
ProfileCondition(
|
||||
"Equals",
|
||||
"VideoProfile",
|
||||
if (codec == "h264") "high 10" else "main 10",
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
if (capability.maxWidth > 0 && capability.maxHeight > 0) {
|
||||
add(
|
||||
CodecProfile(
|
||||
codec = codec,
|
||||
conditions = listOf(
|
||||
ProfileCondition("LessThanEqual", "Width", capability.maxWidth.toString()),
|
||||
ProfileCondition("LessThanEqual", "Height", capability.maxHeight.toString()),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class CodecProfile(
|
||||
@SerialName("Type") val type: String = "Video",
|
||||
@SerialName("Codec") val codec: String,
|
||||
@SerialName("Conditions") val conditions: List<ProfileCondition>,
|
||||
@SerialName("ApplyConditions") val applyConditions: List<ProfileCondition> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ProfileCondition(
|
||||
@SerialName("Condition") val condition: String,
|
||||
@SerialName("Property") val property: String,
|
||||
@SerialName("Value") val value: String,
|
||||
@SerialName("IsRequired") val isRequired: Boolean = false,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class DirectPlayProfile(
|
||||
@SerialName("Container") val container: String,
|
||||
@@ -156,6 +288,9 @@ data class PlaybackInfo(
|
||||
data class MediaSourceInfo(
|
||||
@SerialName("Id") val id: String = "",
|
||||
@SerialName("MediaStreams") val mediaStreams: List<MediaStream> = emptyList(),
|
||||
@SerialName("SupportsDirectPlay") val supportsDirectPlay: Boolean? = null,
|
||||
@SerialName("SupportsDirectStream") val supportsDirectStream: Boolean? = null,
|
||||
@SerialName("SupportsTranscoding") val supportsTranscoding: Boolean? = null,
|
||||
@SerialName("DirectStreamUrl") val directStreamUrl: String? = null,
|
||||
@SerialName("TranscodingUrl") val transcodingUrl: String? = null,
|
||||
)
|
||||
@@ -183,7 +318,9 @@ data class BaseItem(
|
||||
@SerialName("CommunityRating") val communityRating: Double? = null,
|
||||
@SerialName("Studios") val studios: List<Studio> = emptyList(),
|
||||
@SerialName("RunTimeTicks") val runTimeTicks: Long? = null,
|
||||
@SerialName("RecursiveItemCount") val recursiveItemCount: Int? = null,
|
||||
@SerialName("Genres") val genres: List<String> = emptyList(),
|
||||
@SerialName("CollectionName") val collectionName: String? = null,
|
||||
@SerialName("MediaStreams") val mediaStreams: List<MediaStream> = emptyList(),
|
||||
@SerialName("People") val people: List<EmbyPerson> = emptyList(),
|
||||
@SerialName("PrimaryImageAspectRatio") val primaryImageAspectRatio: Double? = null,
|
||||
@@ -205,21 +342,32 @@ data class BaseItem(
|
||||
@SerialName("MembyEpisodeCode") val membyEpisodeCode: String? = null,
|
||||
@SerialName("MembyAirsAt") val membyAirsAt: String? = null,
|
||||
@SerialName("MembyAddedAt") val membyAddedAt: String? = null,
|
||||
@SerialName("MembyAirDayLabel") val membyAirDayLabel: String? = null,
|
||||
@SerialName("MembyAirLabel") val membyAirLabel: String? = null,
|
||||
@SerialName("MembyAvailability") val membyAvailability: String? = null,
|
||||
@SerialName("MembyAvailabilityText") val membyAvailabilityText: String? = null,
|
||||
@SerialName("MembyPlayable") val membyPlayable: Boolean = true,
|
||||
// Derived by the TV from the Sonarr schedule row and retained in the local home cache.
|
||||
// Derived by the TV from the weekly schedule row and retained in the local home cache.
|
||||
@SerialName("MembyAiringToday") val membyAiringToday: Boolean = false,
|
||||
// Explainability supplied only by the gateway's dedicated For You endpoint.
|
||||
@SerialName("MembyRecommendationReason") val membyRecommendationReason: String? = null,
|
||||
@SerialName("MembyCompatibility") val membyCompatibility: String? = null,
|
||||
// Backend diagnostics for the shared explainable ranker. These remain optional so
|
||||
// direct-to-Emby mode and older cached payloads decode unchanged.
|
||||
@SerialName("MembyRecommendationScore") val membyRecommendationScore: Double? = null,
|
||||
@SerialName("MembyRecommendationComponents")
|
||||
val membyRecommendationComponents: Map<String, Double> = emptyMap(),
|
||||
@SerialName("MembyRecommendationReasonCodes")
|
||||
val membyRecommendationReasonCodes: List<String> = emptyList(),
|
||||
@SerialName("MembyExploration") val membyExploration: Boolean = false,
|
||||
) {
|
||||
val isMovie: Boolean get() = type.equals("Movie", ignoreCase = true)
|
||||
val isSeries: Boolean get() = type.equals("Series", ignoreCase = true)
|
||||
val isEpisode: Boolean get() = type.equals("Episode", ignoreCase = true)
|
||||
val isFavorite: Boolean get() = userData?.isFavorite == true
|
||||
val isSonarrSchedule: Boolean get() = membySource == "sonarr"
|
||||
val isTvSchedule: Boolean get() = membySource == "sonarr"
|
||||
val isMovieSchedule: Boolean get() = membySource == "radarr"
|
||||
val isSchedule: Boolean get() = isTvSchedule || isMovieSchedule
|
||||
val cast: List<EmbyPerson> get() = people.filter(EmbyPerson::isCastMember)
|
||||
|
||||
/** "S2 · E5" when the season is known, "E5" when only the episode is, else null. */
|
||||
|
||||
@@ -24,23 +24,25 @@ data class GatewayLoginResponse(
|
||||
val userId: String = "",
|
||||
val username: String = "",
|
||||
val serverId: String = "",
|
||||
val activeClients: Int = 0,
|
||||
val maxClientsPerUser: Int = 0,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class GatewayAuthPolicy(
|
||||
val maxClientsPerUser: Int = 0,
|
||||
data class GatewayDevice(
|
||||
val deviceId: String,
|
||||
val deviceName: String,
|
||||
val clientVersion: String = "",
|
||||
val lastSeenAt: String = "",
|
||||
val current: Boolean = false,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class GatewayAuthError(
|
||||
val error: String = "",
|
||||
val message: String = "",
|
||||
val activeClients: Int = 0,
|
||||
val maxClientsPerUser: Int = 0,
|
||||
data class GatewayDevices(
|
||||
val devices: List<GatewayDevice> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class GatewayDeviceNameRequest(val deviceName: String)
|
||||
|
||||
/**
|
||||
* One horizontal strip, described entirely by the server.
|
||||
*
|
||||
@@ -76,8 +78,8 @@ data class GatewayHome(
|
||||
/**
|
||||
* The gateway's verdict on this build, from `GET /v1/update`.
|
||||
*
|
||||
* [status] is `none`, `optional` or `mandatory`. Mandatory blocks the home screen — the
|
||||
* operator has decided this version may no longer be used.
|
||||
* [status] is `none`, `optional` or `mandatory`. Mandatory blocks the app before login or
|
||||
* home is composed — the operator has decided this version may no longer be used.
|
||||
*/
|
||||
@Serializable
|
||||
data class GatewayUpdate(
|
||||
@@ -85,6 +87,8 @@ data class GatewayUpdate(
|
||||
val version: String = "",
|
||||
val notes: String = "",
|
||||
val downloadUrl: String = "",
|
||||
val sha256: String = "",
|
||||
val sizeBytes: Long = 0,
|
||||
) {
|
||||
val isMandatory: Boolean get() = status == STATUS_MANDATORY
|
||||
val isOptional: Boolean get() = status == STATUS_OPTIONAL
|
||||
@@ -110,18 +114,47 @@ data class GatewayServiceStatus(
|
||||
val clientVersion: String = "",
|
||||
val clientProtocol: String = "",
|
||||
val serverProtocol: Int = 0,
|
||||
val featureSchemaVersion: Int = 0,
|
||||
val featureRevision: Long = 0,
|
||||
val safeMode: Boolean = false,
|
||||
val features: Map<String, Boolean> = emptyMap(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class GatewayFeature(
|
||||
val key: String,
|
||||
val name: String = "",
|
||||
val description: String = "",
|
||||
val area: String = "",
|
||||
val enabled: Boolean = false,
|
||||
val source: String = "default",
|
||||
val compatible: Boolean = true,
|
||||
val minimumProtocol: Int = 0,
|
||||
val capability: String = "",
|
||||
val recovery: String = "",
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class GatewayFeatures(
|
||||
val schemaVersion: Int = 0,
|
||||
val revision: Long = 0,
|
||||
val safeMode: Boolean = false,
|
||||
val canRollback: Boolean = false,
|
||||
val features: List<GatewayFeature> = emptyList(),
|
||||
)
|
||||
|
||||
/**
|
||||
* An informational nudge riding along on the status poll — currently "this episode aired
|
||||
* and is on its way into Emby". It is never actionable: the banner slides in, states the
|
||||
* news and leaves. Unknown [kind] values still render, so the server can add one without
|
||||
* an app release.
|
||||
* An informational nudge riding along on the status poll — "this episode aired and is on
|
||||
* its way into Emby", or "Radarr just imported this film". It is never actionable: the
|
||||
* banner slides in, states the news and leaves. Unknown [kind] values still render, and
|
||||
* the server supplies the wording, so a new kind of news needs no app release.
|
||||
*/
|
||||
@Serializable
|
||||
data class GatewayAlert(
|
||||
val id: String = "",
|
||||
val kind: String = "",
|
||||
/** Eyebrow text for the banner ("JUST AIRED", "NEW MOVIE ADDED"). May be absent. */
|
||||
val label: String = "",
|
||||
val title: String = "",
|
||||
val message: String = "",
|
||||
val itemId: String = "",
|
||||
@@ -135,16 +168,70 @@ data class GatewayRows(
|
||||
val rows: List<HomeRow> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class RecommendationOnboarding(
|
||||
val completed: Boolean = false,
|
||||
val ratings: Map<String, Int> = emptyMap(),
|
||||
val items: List<BaseItem> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class RecommendationPreferences(
|
||||
val ratings: Map<String, Int> = emptyMap(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class GatewayItems(
|
||||
val items: List<BaseItem> = emptyList(),
|
||||
)
|
||||
|
||||
/**
|
||||
* Response of `GET /v1/items/{id}/related` — the detail page's two additions.
|
||||
*
|
||||
* [reasons] are short phrases from the recommendation engine explaining *this viewer's*
|
||||
* relationship to the title ("Because you watch Thriller"); [items] is the carousel of
|
||||
* what else is like it. Either half may be empty: a cold profile has no reasons to give,
|
||||
* and an obscure title has nothing beside it.
|
||||
*/
|
||||
@Serializable
|
||||
data class GatewayRelated(
|
||||
val reasons: List<String> = emptyList(),
|
||||
val items: List<BaseItem> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class GatewaySearchHistory(
|
||||
val queries: List<String> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class GatewayRequestCandidate(
|
||||
val mediaType: String,
|
||||
val foreignId: Int,
|
||||
val title: String,
|
||||
val year: Int = 0,
|
||||
val overview: String = "",
|
||||
val posterUrl: String = "",
|
||||
val alreadyAdded: Boolean = false,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class GatewayRequestLookup(
|
||||
val candidates: List<GatewayRequestCandidate> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class GatewayMediaRequest(
|
||||
val mediaType: String,
|
||||
val foreignId: Int,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class GatewayMediaRequestResult(
|
||||
val status: String = "",
|
||||
val title: String = "",
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class GatewayPrerollSchedule(
|
||||
val today: List<GatewayPrerollEntry> = emptyList(),
|
||||
@@ -153,6 +240,8 @@ data class GatewayPrerollSchedule(
|
||||
|
||||
@Serializable
|
||||
data class GatewayPrerollEntry(
|
||||
val itemId: String = "",
|
||||
val imageType: String = "",
|
||||
val series: String = "",
|
||||
val episode: String = "",
|
||||
val episodeCode: String = "",
|
||||
@@ -164,6 +253,12 @@ data class GatewayPrerollEntry(
|
||||
data class GatewayPlayback(
|
||||
val itemId: String,
|
||||
val title: String = "",
|
||||
val overview: String = "",
|
||||
val seriesName: String = "",
|
||||
val episodeCode: String = "",
|
||||
val runtimeMs: Long = 0,
|
||||
val prerollEnabled: Boolean = true,
|
||||
val prerollDurationMs: Long = 6_500L,
|
||||
val url: String,
|
||||
val resumePositionMs: Long = 0,
|
||||
val subtitles: List<com.ponzischeme89.memby.data.PlayableSubtitle> = emptyList(),
|
||||
@@ -188,11 +283,72 @@ data class GatewayNextEpisode(
|
||||
val playMethod: String = "DirectPlay",
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class GatewaySeasonFinale(
|
||||
val seasonFinale: Boolean = false,
|
||||
val seriesName: String = "",
|
||||
val seasonNumber: Int = 0,
|
||||
val episodeNumber: Int = 0,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class GatewayFlagRequest(
|
||||
val value: Boolean,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class MyShow(
|
||||
val itemId: String,
|
||||
val title: String,
|
||||
val year: Int? = null,
|
||||
val imageTag: String = "",
|
||||
val addedAt: String = "",
|
||||
val sonarrStatus: String = "Not found",
|
||||
val nextEpisode: String? = null,
|
||||
val lifecycle: String = "Unknown",
|
||||
val monitored: Boolean = false,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class MyShowsResponse(
|
||||
val shows: List<MyShow> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class SaveMyShowRequest(
|
||||
val itemId: String,
|
||||
val title: String,
|
||||
val year: Int? = null,
|
||||
val imageTag: String = "",
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class NotificationPreferences(
|
||||
val enabled: Boolean = true,
|
||||
val showReturnAlerts: Boolean = true,
|
||||
val leadDays: Int = 7,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class UserNotification(
|
||||
val id: Long,
|
||||
val kind: String = "",
|
||||
val itemId: String = "",
|
||||
val title: String = "",
|
||||
val message: String = "",
|
||||
val eventAt: String? = null,
|
||||
val createdAt: String = "",
|
||||
val readAt: String? = null,
|
||||
) {
|
||||
val unread: Boolean get() = readAt.isNullOrBlank()
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class NotificationsResponse(
|
||||
val notifications: List<UserNotification> = emptyList(),
|
||||
val preferences: NotificationPreferences = NotificationPreferences(),
|
||||
)
|
||||
|
||||
/** One row-engagement event. See `data/analytics/RowAnalytics.kt`. */
|
||||
@Serializable
|
||||
data class GatewayRowEvent(
|
||||
@@ -213,9 +369,15 @@ data class GatewayRowEvents(
|
||||
data class GatewayPlaybackReport(
|
||||
val itemId: String,
|
||||
val positionMs: Long,
|
||||
val durationMs: Long = 0,
|
||||
val isPaused: Boolean = false,
|
||||
val mediaSourceId: String = "",
|
||||
val playSessionId: String = "",
|
||||
val playMethod: String = "DirectPlay",
|
||||
val eventName: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class GatewayPlaybackReportResponse(
|
||||
val autoFollowedShowTitle: String = "",
|
||||
)
|
||||
|
||||
+224
@@ -0,0 +1,224 @@
|
||||
/*
|
||||
* Playback capability probing adapted from Wholphin's
|
||||
* MediaCodecCapabilitiesTest.kt, which is itself derived from Jellyfin Android TV.
|
||||
*
|
||||
* Wholphin: https://github.com/damontecres/Wholphin
|
||||
* Jellyfin Android TV: https://github.com/jellyfin/jellyfin-androidtv
|
||||
*
|
||||
* Modifications Copyright (C) 2026 Memby contributors
|
||||
* SPDX-License-Identifier: GPL-2.0-only
|
||||
*/
|
||||
|
||||
package com.ponzischeme89.memby.data.playback
|
||||
|
||||
import android.media.MediaCodecInfo.CodecProfileLevel
|
||||
import android.media.MediaCodecList
|
||||
import android.media.MediaFormat
|
||||
import android.os.Build
|
||||
|
||||
data class VideoDecoderCapabilities(
|
||||
val supported: Boolean = false,
|
||||
val profiles: Set<String> = emptySet(),
|
||||
val mainLevel: Int = 0,
|
||||
val tenBitLevel: Int = 0,
|
||||
val maxWidth: Int = 0,
|
||||
val maxHeight: Int = 0,
|
||||
val hdr10: Boolean = false,
|
||||
val hdr10Plus: Boolean = false,
|
||||
val dolbyVision: Boolean = false,
|
||||
)
|
||||
|
||||
data class DevicePlaybackCapabilities(
|
||||
val h264: VideoDecoderCapabilities = VideoDecoderCapabilities(supported = true),
|
||||
val hevc: VideoDecoderCapabilities = VideoDecoderCapabilities(),
|
||||
)
|
||||
|
||||
/**
|
||||
* The expensive platform query is performed once, off the UI thread on first network or
|
||||
* direct-play negotiation. A single immutable result then describes this installed build
|
||||
* to both the Memby gateway and Emby itself.
|
||||
*/
|
||||
val devicePlaybackCapabilities: DevicePlaybackCapabilities by lazy {
|
||||
runCatching { AndroidVideoCapabilityProbe().probe() }
|
||||
// H.264 is Android's baseline playback format and remains the safe fallback when
|
||||
// a vendor codec exposes incomplete or broken MediaCodec metadata.
|
||||
.getOrElse { DevicePlaybackCapabilities() }
|
||||
}
|
||||
|
||||
internal fun DevicePlaybackCapabilities.gatewayCapabilityTokens(): List<String> = buildList {
|
||||
if (h264.supported) add("video_h264_decode")
|
||||
h264.profiles.sorted().forEach { add("video_h264_profile_$it") }
|
||||
if (h264.mainLevel > 0) add("video_h264_level_${h264.mainLevel}")
|
||||
if (h264.tenBitLevel > 0) add("video_h264_high10_level_${h264.tenBitLevel}")
|
||||
addResolution("video_h264", h264)
|
||||
|
||||
if (hevc.supported) add("video_hevc_decode")
|
||||
hevc.profiles.sorted().forEach { add("video_hevc_profile_$it") }
|
||||
if (hevc.mainLevel > 0) add("video_hevc_main_level_${hevc.mainLevel}")
|
||||
if (hevc.tenBitLevel > 0) add("video_hevc_main10_level_${hevc.tenBitLevel}")
|
||||
addResolution("video_hevc", hevc)
|
||||
if (hevc.hdr10) add("video_hevc_hdr10")
|
||||
if (hevc.hdr10Plus) add("video_hevc_hdr10plus")
|
||||
if (hevc.dolbyVision) add("video_hevc_dolby_vision")
|
||||
}
|
||||
|
||||
private fun MutableList<String>.addResolution(prefix: String, codec: VideoDecoderCapabilities) {
|
||||
if (codec.maxWidth > 0 && codec.maxHeight > 0) {
|
||||
add("${prefix}_max_${codec.maxWidth}x${codec.maxHeight}")
|
||||
}
|
||||
}
|
||||
|
||||
private class AndroidVideoCapabilityProbe {
|
||||
private val codecInfos by lazy { MediaCodecList(MediaCodecList.REGULAR_CODECS).codecInfos }
|
||||
|
||||
fun probe(): DevicePlaybackCapabilities = DevicePlaybackCapabilities(
|
||||
h264 = probeH264(),
|
||||
hevc = probeHevc(),
|
||||
)
|
||||
|
||||
private fun probeH264(): VideoDecoderCapabilities {
|
||||
val mime = MediaFormat.MIMETYPE_VIDEO_AVC
|
||||
val supported = hasCodecForMime(mime)
|
||||
// As in Wholphin, ordinary AVC support covers the backwards-compatible
|
||||
// baseline/main/high family even when vendor metadata lists only its highest
|
||||
// profile. High10 remains opt-in because it is not universally supported.
|
||||
val high10Level = mappedLevel(
|
||||
mime,
|
||||
setOf(CodecProfileLevel.AVCProfileHigh10),
|
||||
AVC_LEVELS,
|
||||
)
|
||||
val profiles = buildSet {
|
||||
if (supported) addAll(listOf("baseline", "constrained_baseline", "main", "high"))
|
||||
if (high10Level > 0) add("high10")
|
||||
}
|
||||
val (maxWidth, maxHeight) = maxResolution(mime)
|
||||
return VideoDecoderCapabilities(
|
||||
supported = supported,
|
||||
profiles = profiles,
|
||||
mainLevel = mappedLevel(
|
||||
mime,
|
||||
setOf(
|
||||
CodecProfileLevel.AVCProfileBaseline,
|
||||
CodecProfileLevel.AVCProfileMain,
|
||||
CodecProfileLevel.AVCProfileHigh,
|
||||
),
|
||||
AVC_LEVELS,
|
||||
),
|
||||
tenBitLevel = high10Level,
|
||||
maxWidth = maxWidth,
|
||||
maxHeight = maxHeight,
|
||||
)
|
||||
}
|
||||
|
||||
private fun probeHevc(): VideoDecoderCapabilities {
|
||||
val mime = MediaFormat.MIMETYPE_VIDEO_HEVC
|
||||
val supported = hasCodecForMime(mime)
|
||||
val mainLevel = mappedLevel(
|
||||
mime,
|
||||
setOf(CodecProfileLevel.HEVCProfileMain),
|
||||
HEVC_LEVELS,
|
||||
)
|
||||
val main10Level = mappedLevel(
|
||||
mime,
|
||||
setOf(CodecProfileLevel.HEVCProfileMain10),
|
||||
HEVC_LEVELS,
|
||||
)
|
||||
val (maxWidth, maxHeight) = maxResolution(mime)
|
||||
return VideoDecoderCapabilities(
|
||||
supported = supported,
|
||||
profiles = buildSet {
|
||||
if (supported) add("main")
|
||||
if (main10Level > 0) add("main10")
|
||||
},
|
||||
mainLevel = mainLevel,
|
||||
tenBitLevel = main10Level,
|
||||
maxWidth = maxWidth,
|
||||
maxHeight = maxHeight,
|
||||
hdr10 = Build.VERSION.SDK_INT >= Build.VERSION_CODES.N &&
|
||||
hasProfile(mime, CodecProfileLevel.HEVCProfileMain10HDR10),
|
||||
hdr10Plus = Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q &&
|
||||
hasProfile(mime, CodecProfileLevel.HEVCProfileMain10HDR10Plus),
|
||||
dolbyVision = Build.VERSION.SDK_INT >= Build.VERSION_CODES.N &&
|
||||
hasCodecForMime(MediaFormat.MIMETYPE_VIDEO_DOLBY_VISION),
|
||||
)
|
||||
}
|
||||
|
||||
private fun hasCodecForMime(mime: String): Boolean = codecInfos.any { info ->
|
||||
!info.isEncoder && info.supportedTypes.any { it.equals(mime, ignoreCase = true) }
|
||||
}
|
||||
|
||||
private fun hasProfile(mime: String, profile: Int): Boolean =
|
||||
maxPlatformLevel(mime, setOf(profile)) > 0
|
||||
|
||||
private fun mappedLevel(
|
||||
mime: String,
|
||||
profiles: Set<Int>,
|
||||
levels: List<Pair<Int, Int>>,
|
||||
): Int {
|
||||
val platformLevel = maxPlatformLevel(mime, profiles)
|
||||
return levels.asReversed().firstOrNull { platformLevel >= it.first }?.second ?: 0
|
||||
}
|
||||
|
||||
private fun maxPlatformLevel(mime: String, profiles: Set<Int>): Int {
|
||||
var maximum = 0
|
||||
codecInfos.asSequence().filterNot { it.isEncoder }.forEach { info ->
|
||||
runCatching { info.getCapabilitiesForType(mime) }.getOrNull()
|
||||
?.profileLevels
|
||||
?.filter { it.profile in profiles }
|
||||
?.forEach { maximum = maxOf(maximum, it.level) }
|
||||
}
|
||||
return maximum
|
||||
}
|
||||
|
||||
private fun maxResolution(mime: String): Pair<Int, Int> {
|
||||
var maxWidth = 0
|
||||
var maxHeight = 0
|
||||
codecInfos.asSequence().filterNot { it.isEncoder }.forEach { info ->
|
||||
val video = runCatching { info.getCapabilitiesForType(mime).videoCapabilities }.getOrNull()
|
||||
?: return@forEach
|
||||
maxWidth = maxOf(maxWidth, video.supportedWidths?.upper ?: 0)
|
||||
maxHeight = maxOf(maxHeight, video.supportedHeights?.upper ?: 0)
|
||||
}
|
||||
return maxWidth to maxHeight
|
||||
}
|
||||
|
||||
companion object {
|
||||
// ffprobe/Emby represent AVC levels multiplied by 10 (4.1 -> 41).
|
||||
private val AVC_LEVELS = listOf(
|
||||
CodecProfileLevel.AVCLevel1b to 9,
|
||||
CodecProfileLevel.AVCLevel1 to 10,
|
||||
CodecProfileLevel.AVCLevel11 to 11,
|
||||
CodecProfileLevel.AVCLevel12 to 12,
|
||||
CodecProfileLevel.AVCLevel13 to 13,
|
||||
CodecProfileLevel.AVCLevel2 to 20,
|
||||
CodecProfileLevel.AVCLevel21 to 21,
|
||||
CodecProfileLevel.AVCLevel22 to 22,
|
||||
CodecProfileLevel.AVCLevel3 to 30,
|
||||
CodecProfileLevel.AVCLevel31 to 31,
|
||||
CodecProfileLevel.AVCLevel32 to 32,
|
||||
CodecProfileLevel.AVCLevel4 to 40,
|
||||
CodecProfileLevel.AVCLevel41 to 41,
|
||||
CodecProfileLevel.AVCLevel42 to 42,
|
||||
CodecProfileLevel.AVCLevel5 to 50,
|
||||
CodecProfileLevel.AVCLevel51 to 51,
|
||||
CodecProfileLevel.AVCLevel52 to 52,
|
||||
)
|
||||
|
||||
// ffprobe/Emby represent HEVC levels multiplied by 30 (4.1 -> 123).
|
||||
private val HEVC_LEVELS = listOf(
|
||||
CodecProfileLevel.HEVCMainTierLevel1 to 30,
|
||||
CodecProfileLevel.HEVCMainTierLevel2 to 60,
|
||||
CodecProfileLevel.HEVCMainTierLevel21 to 63,
|
||||
CodecProfileLevel.HEVCMainTierLevel3 to 90,
|
||||
CodecProfileLevel.HEVCMainTierLevel31 to 93,
|
||||
CodecProfileLevel.HEVCMainTierLevel4 to 120,
|
||||
CodecProfileLevel.HEVCMainTierLevel41 to 123,
|
||||
CodecProfileLevel.HEVCMainTierLevel5 to 150,
|
||||
CodecProfileLevel.HEVCMainTierLevel51 to 153,
|
||||
CodecProfileLevel.HEVCMainTierLevel52 to 156,
|
||||
CodecProfileLevel.HEVCMainTierLevel6 to 180,
|
||||
CodecProfileLevel.HEVCMainTierLevel61 to 183,
|
||||
CodecProfileLevel.HEVCMainTierLevel62 to 186,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -53,6 +53,16 @@ interface EmbyApi {
|
||||
@Path("itemId") itemId: String,
|
||||
): ItemsResult
|
||||
|
||||
/**
|
||||
* Emby's own similarity ranking. The direct path has no recommendation engine behind
|
||||
* it, so this is the whole of "More like this" when the gateway is not in play.
|
||||
*/
|
||||
@GET("Items/{itemId}/Similar")
|
||||
suspend fun getSimilar(
|
||||
@Path("itemId") itemId: String,
|
||||
@QueryMap params: Map<String, String>,
|
||||
): ItemsResult
|
||||
|
||||
@GET("Shows/NextUp")
|
||||
suspend fun getNextUp(@QueryMap params: Map<String, String>): ItemsResult
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ import com.ponzischeme89.memby.BuildConfig
|
||||
import kotlinx.serialization.json.Json
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Response
|
||||
import okhttp3.logging.HttpLoggingInterceptor
|
||||
import retrofit2.Retrofit
|
||||
@@ -36,7 +35,9 @@ object EmbyServiceFactory {
|
||||
redactHeader("X-Emby-Authorization")
|
||||
}
|
||||
|
||||
val client = OkHttpClient.Builder()
|
||||
// Derived from the shared stack, so this keeps the one connection pool and
|
||||
// dispatcher the artwork loader also uses. See HttpStack.
|
||||
val client = HttpStack.base.newBuilder()
|
||||
.connectTimeout(15, TimeUnit.SECONDS)
|
||||
.readTimeout(30, TimeUnit.SECONDS)
|
||||
.addInterceptor(EmbyAuthInterceptor(deviceIdProvider, tokenProvider))
|
||||
|
||||
@@ -2,11 +2,15 @@ package com.ponzischeme89.memby.data.remote
|
||||
|
||||
import com.ponzischeme89.memby.data.model.BaseItem
|
||||
import com.ponzischeme89.memby.data.model.GatewayFlagRequest
|
||||
import com.ponzischeme89.memby.data.model.GatewayAuthPolicy
|
||||
import com.ponzischeme89.memby.data.model.GatewayFeatures
|
||||
import com.ponzischeme89.memby.data.model.GatewayDevices
|
||||
import com.ponzischeme89.memby.data.model.GatewayDeviceNameRequest
|
||||
import com.ponzischeme89.memby.data.model.GatewayHome
|
||||
import com.ponzischeme89.memby.data.model.GatewayItems
|
||||
import com.ponzischeme89.memby.data.model.GatewayLoginRequest
|
||||
import com.ponzischeme89.memby.data.model.GatewayLoginResponse
|
||||
import com.ponzischeme89.memby.data.model.GatewayMediaRequest
|
||||
import com.ponzischeme89.memby.data.model.GatewayMediaRequestResult
|
||||
import com.ponzischeme89.memby.data.model.GatewayNextEpisode
|
||||
import com.ponzischeme89.memby.data.model.GatewayPlayback
|
||||
import com.ponzischeme89.memby.data.model.GatewayPlaybackReport
|
||||
@@ -14,12 +18,17 @@ import com.ponzischeme89.memby.data.model.GatewayPrerollSchedule
|
||||
import com.ponzischeme89.memby.data.model.GatewayRowEvents
|
||||
import com.ponzischeme89.memby.data.model.GatewayRows
|
||||
import com.ponzischeme89.memby.data.model.GatewaySearchHistory
|
||||
import com.ponzischeme89.memby.data.model.GatewayRequestLookup
|
||||
import com.ponzischeme89.memby.data.model.GatewayServiceStatus
|
||||
import com.ponzischeme89.memby.data.model.GatewayUpdate
|
||||
import com.ponzischeme89.memby.data.model.RecommendationOnboarding
|
||||
import com.ponzischeme89.memby.data.model.RecommendationPreferences
|
||||
import com.ponzischeme89.memby.data.model.UserItemData
|
||||
import retrofit2.http.Body
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.DELETE
|
||||
import retrofit2.http.POST
|
||||
import retrofit2.http.PUT
|
||||
import retrofit2.http.Path
|
||||
import retrofit2.http.Query
|
||||
|
||||
@@ -35,9 +44,6 @@ interface GatewayApi {
|
||||
@POST("v1/auth/login")
|
||||
suspend fun login(@Body body: GatewayLoginRequest): GatewayLoginResponse
|
||||
|
||||
@GET("v1/auth/policy")
|
||||
suspend fun authPolicy(): GatewayAuthPolicy
|
||||
|
||||
@POST("v1/auth/logout")
|
||||
suspend fun logout()
|
||||
|
||||
@@ -45,6 +51,18 @@ interface GatewayApi {
|
||||
@GET("v1/auth/session")
|
||||
suspend fun session(): GatewayLoginResponse
|
||||
|
||||
@GET("v1/auth/devices")
|
||||
suspend fun devices(): GatewayDevices
|
||||
|
||||
@DELETE("v1/auth/devices/{deviceId}")
|
||||
suspend fun removeDevice(@Path("deviceId") deviceId: String)
|
||||
|
||||
@PUT("v1/auth/devices/{deviceId}")
|
||||
suspend fun renameDevice(
|
||||
@Path("deviceId") deviceId: String,
|
||||
@Body body: GatewayDeviceNameRequest,
|
||||
)
|
||||
|
||||
@GET("v1/home")
|
||||
suspend fun home(@Query("limit") limit: Int): GatewayHome
|
||||
|
||||
@@ -60,6 +78,12 @@ interface GatewayApi {
|
||||
@GET("v1/search/history")
|
||||
suspend fun recentSearches(): GatewaySearchHistory
|
||||
|
||||
@GET("v1/requests/lookup")
|
||||
suspend fun requestLookup(@Query("q") term: String): GatewayRequestLookup
|
||||
|
||||
@POST("v1/requests")
|
||||
suspend fun requestMedia(@Body body: GatewayMediaRequest): GatewayMediaRequestResult
|
||||
|
||||
/** Recommendation rows on their own. `/v1/home` already embeds these when warm. */
|
||||
@GET("v1/recommendations")
|
||||
suspend fun recommendations(): GatewayRows
|
||||
@@ -67,6 +91,12 @@ interface GatewayApi {
|
||||
@GET("v1/for-you")
|
||||
suspend fun forYou(@Query("minutes") availableMinutes: Int): GatewayRows
|
||||
|
||||
@GET("v1/recommendations/preferences")
|
||||
suspend fun recommendationPreferences(): RecommendationOnboarding
|
||||
|
||||
@PUT("v1/recommendations/preferences")
|
||||
suspend fun saveRecommendationPreferences(@Body body: RecommendationPreferences)
|
||||
|
||||
@GET("v1/preroll")
|
||||
suspend fun prerollSchedule(): GatewayPrerollSchedule
|
||||
|
||||
@@ -77,17 +107,57 @@ interface GatewayApi {
|
||||
@GET("v1/update")
|
||||
suspend fun updateStatus(): GatewayUpdate
|
||||
|
||||
@GET("v1/my-shows")
|
||||
suspend fun myShows(): com.ponzischeme89.memby.data.model.MyShowsResponse
|
||||
|
||||
@POST("v1/my-shows")
|
||||
suspend fun saveMyShow(
|
||||
@Body body: com.ponzischeme89.memby.data.model.SaveMyShowRequest,
|
||||
): com.ponzischeme89.memby.data.model.MyShowsResponse
|
||||
|
||||
@DELETE("v1/my-shows/{id}")
|
||||
suspend fun removeMyShow(@Path("id") itemId: String)
|
||||
|
||||
@GET("v1/notifications")
|
||||
suspend fun notifications(): com.ponzischeme89.memby.data.model.NotificationsResponse
|
||||
|
||||
@PUT("v1/notifications")
|
||||
suspend fun setNotificationPreferences(
|
||||
@Body body: com.ponzischeme89.memby.data.model.NotificationPreferences,
|
||||
): com.ponzischeme89.memby.data.model.NotificationsResponse
|
||||
|
||||
@POST("v1/notifications/{id}/{action}")
|
||||
suspend fun updateNotification(
|
||||
@Path("id") id: Long,
|
||||
@Path("action") action: String,
|
||||
)
|
||||
|
||||
/** Available during maintenance so an open app can be interrupted immediately. */
|
||||
@GET("v1/status")
|
||||
suspend fun serviceStatus(): GatewayServiceStatus
|
||||
|
||||
/** Versioned server control-plane document; unknown flags remain safely ignorable. */
|
||||
@GET("v1/features")
|
||||
suspend fun features(): GatewayFeatures
|
||||
|
||||
@GET("v1/items/{id}")
|
||||
suspend fun item(@Path("id") itemId: String): BaseItem
|
||||
|
||||
@GET("v1/items/{id}/season-finale")
|
||||
suspend fun seasonFinale(
|
||||
@Path("id") itemId: String,
|
||||
): com.ponzischeme89.memby.data.model.GatewaySeasonFinale
|
||||
|
||||
/** All episodes for a series in display order; the client groups them into seasons. */
|
||||
@GET("v1/items/{id}/episodes")
|
||||
suspend fun seriesEpisodes(@Path("id") seriesId: String): GatewayItems
|
||||
|
||||
/** Why this viewer might enjoy the item, and what else in the library is like it. */
|
||||
@GET("v1/items/{id}/related")
|
||||
suspend fun related(
|
||||
@Path("id") itemId: String,
|
||||
): com.ponzischeme89.memby.data.model.GatewayRelated
|
||||
|
||||
@GET("v1/items/{id}/playback")
|
||||
suspend fun playback(
|
||||
@Path("id") itemId: String,
|
||||
@@ -95,6 +165,7 @@ interface GatewayApi {
|
||||
@Query("title") title: String,
|
||||
@Query("resumePositionMs") resumePositionMs: Long,
|
||||
@Query("subtitleIndex") subtitleIndex: Int? = null,
|
||||
@Query("forceTranscode") forceTranscode: Boolean = false,
|
||||
): GatewayPlayback
|
||||
|
||||
/** 404 when nothing follows this item: a movie, or a series finale. */
|
||||
@@ -114,7 +185,10 @@ interface GatewayApi {
|
||||
suspend fun setPlayed(@Path("id") itemId: String, @Body body: GatewayFlagRequest): UserItemData
|
||||
|
||||
@POST("v1/playback/{phase}")
|
||||
suspend fun report(@Path("phase") phase: String, @Body body: GatewayPlaybackReport)
|
||||
suspend fun report(
|
||||
@Path("phase") phase: String,
|
||||
@Body body: GatewayPlaybackReport,
|
||||
): com.ponzischeme89.memby.data.model.GatewayPlaybackReportResponse
|
||||
|
||||
/** Row engagement, uploaded in batches. Fire-and-forget: failures are not retried. */
|
||||
@POST("v1/analytics/rows")
|
||||
|
||||
@@ -2,10 +2,11 @@ package com.ponzischeme89.memby.data.remote
|
||||
|
||||
import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory
|
||||
import com.ponzischeme89.memby.BuildConfig
|
||||
import com.ponzischeme89.memby.data.playback.devicePlaybackCapabilities
|
||||
import com.ponzischeme89.memby.data.playback.gatewayCapabilityTokens
|
||||
import kotlinx.serialization.json.Json
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Response
|
||||
import retrofit2.Retrofit
|
||||
import java.util.concurrent.TimeUnit
|
||||
@@ -23,7 +24,9 @@ object GatewayServiceFactory {
|
||||
fun create(baseUrl: String, tokenProvider: () -> String?): GatewayApi {
|
||||
val contentType = "application/json".toMediaType()
|
||||
|
||||
val client = OkHttpClient.Builder()
|
||||
// Derived from the shared stack: artwork in gateway mode is proxied by this very
|
||||
// host, so the poster fetches reuse the connection this client established.
|
||||
val client = HttpStack.base.newBuilder()
|
||||
.connectTimeout(10, TimeUnit.SECONDS)
|
||||
// The gateway answers home from Redis in single-digit milliseconds; a long
|
||||
// read timeout here only ever means Emby itself is struggling behind it.
|
||||
@@ -52,6 +55,11 @@ private class GatewayAuthInterceptor(private val tokenProvider: () -> String?) :
|
||||
// says which build it is.
|
||||
.header("X-Memby-Version", BuildConfig.VERSION_NAME)
|
||||
.header("X-Memby-Protocol", MEMBY_PROTOCOL_VERSION.toString())
|
||||
.header(
|
||||
"X-Memby-Capabilities",
|
||||
(MEMBY_CAPABILITIES + devicePlaybackCapabilities.gatewayCapabilityTokens())
|
||||
.joinToString(","),
|
||||
)
|
||||
tokenProvider()?.takeIf { it.isNotBlank() }?.let {
|
||||
builder.header("Authorization", "Bearer $it")
|
||||
}
|
||||
@@ -60,3 +68,12 @@ private class GatewayAuthInterceptor(private val tokenProvider: () -> String?) :
|
||||
}
|
||||
|
||||
internal const val MEMBY_PROTOCOL_VERSION = 1
|
||||
|
||||
internal val MEMBY_CAPABILITIES = listOf(
|
||||
"server_features_v1",
|
||||
"live_feature_refresh_v1",
|
||||
"sonarr_preroll_v1",
|
||||
"auto_my_shows_v1",
|
||||
)
|
||||
|
||||
internal const val HEVC_DECODE_CAPABILITY = "video_hevc_decode"
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
package com.ponzischeme89.memby.data.remote
|
||||
|
||||
import okhttp3.ConnectionPool
|
||||
import okhttp3.Dispatcher
|
||||
import okhttp3.OkHttpClient
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* The one HTTP stack in the app. Everything that talks to the network — the Emby API, the
|
||||
* gateway API and Coil's artwork loader — is built from [base], so they share a single
|
||||
* connection pool, dispatcher and thread pool.
|
||||
*
|
||||
* Sharing matters most for artwork. In gateway mode the images are proxied by the same
|
||||
* HTTPS host that serves `/v1/home`, so a poster fetched on a client of its own would
|
||||
* open a fresh TCP connection and repeat the TLS handshake that the home request had just
|
||||
* finished — perhaps a couple of hundred milliseconds per cold image over a domestic
|
||||
* connection, multiplied by the fifteen-odd posters a launcher paints at once. Reusing
|
||||
* the pool makes those fetches resume an established connection instead.
|
||||
*
|
||||
* Timeouts and auth headers still differ per caller, so each factory calls
|
||||
* [base].newBuilder() and adds its own. That is the intended way to specialise an OkHttp
|
||||
* client: the derived client keeps the shared pool and dispatcher.
|
||||
*/
|
||||
internal object HttpStack {
|
||||
|
||||
/**
|
||||
* A launcher paints far more than the default five concurrent requests per host, and
|
||||
* in gateway mode every one of them is the same host. Five means posters arrive in
|
||||
* visible waves; this lets a screenful start together while staying well short of
|
||||
* what would swamp a TV's radio.
|
||||
*/
|
||||
private const val MAX_REQUESTS_PER_HOST = 15
|
||||
|
||||
private val connectionPool = ConnectionPool(
|
||||
maxIdleConnections = 8,
|
||||
keepAliveDuration = 5,
|
||||
timeUnit = TimeUnit.MINUTES,
|
||||
)
|
||||
|
||||
private val dispatcher = Dispatcher().apply {
|
||||
maxRequests = 32
|
||||
maxRequestsPerHost = MAX_REQUESTS_PER_HOST
|
||||
}
|
||||
|
||||
val base: OkHttpClient = OkHttpClient.Builder()
|
||||
.connectionPool(connectionPool)
|
||||
.dispatcher(dispatcher)
|
||||
// Sensible floor; every caller overrides these to suit what it is fetching.
|
||||
.connectTimeout(15, TimeUnit.SECONDS)
|
||||
.readTimeout(30, TimeUnit.SECONDS)
|
||||
.retryOnConnectionFailure(true)
|
||||
.build()
|
||||
}
|
||||
@@ -0,0 +1,719 @@
|
||||
package com.ponzischeme89.memby.ui
|
||||
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.togetherWith
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.focusGroup
|
||||
import androidx.compose.foundation.focusable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxScope
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.IntrinsicSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.KeyboardArrowDown
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.runtime.withFrameNanos
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusProperties
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.zIndex
|
||||
import androidx.tv.material3.Icon
|
||||
import androidx.tv.material3.Text
|
||||
import coil.compose.AsyncImage
|
||||
import com.ponzischeme89.memby.ServiceLocator
|
||||
import com.ponzischeme89.memby.data.model.BaseItem
|
||||
import com.ponzischeme89.memby.ui.detail.DetailTab
|
||||
import com.ponzischeme89.memby.ui.detail.DetailZone
|
||||
import com.ponzischeme89.memby.ui.detail.TechnicalSpec
|
||||
import com.ponzischeme89.memby.ui.detail.formatRuntime
|
||||
import com.ponzischeme89.memby.ui.detail.ratingLabel
|
||||
import com.ponzischeme89.memby.ui.theme.FactSeparator
|
||||
import com.ponzischeme89.memby.ui.theme.MembyAccent
|
||||
import com.ponzischeme89.memby.ui.theme.MembyCardCorner
|
||||
import com.ponzischeme89.memby.ui.theme.MembyHairline
|
||||
import com.ponzischeme89.memby.ui.theme.MembyMutedText
|
||||
import com.ponzischeme89.memby.ui.theme.MembyOnSurface
|
||||
import com.ponzischeme89.memby.ui.theme.MembyPanelCorner
|
||||
import com.ponzischeme89.memby.ui.theme.MembyQuietText
|
||||
import com.ponzischeme89.memby.ui.theme.MembyScore
|
||||
import com.ponzischeme89.memby.ui.theme.MembySurface
|
||||
import com.ponzischeme89.memby.ui.theme.ValueSeparator
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
// The detail page's names for the shared tokens. The neutrals used to be a shade darker
|
||||
// here than on the launcher, which is visible the moment a page opens from a row.
|
||||
internal val DetailBackground = MembySurface
|
||||
internal val DetailAccent = MembyAccent
|
||||
internal val DetailText = MembyOnSurface
|
||||
internal val DetailMutedText = MembyMutedText
|
||||
internal val DetailQuietText = MembyQuietText
|
||||
internal val DetailHairline = MembyHairline
|
||||
internal val DetailSideGutter = 58.dp
|
||||
private val DetailTabHeight = 66.dp
|
||||
|
||||
/**
|
||||
* How much of the content pane is left showing under the tab strip.
|
||||
*
|
||||
* It does two jobs. The strip used to be anchored to the very bottom of the screen, where a
|
||||
* TV's overscan ate the selection underline and part of the labels — this is the safe-area
|
||||
* inset the rest of the app already keeps. And because what fills the gap is the top of the
|
||||
* pane rather than more background, it is the one thing on screen saying that Down from the
|
||||
* strip reveals something.
|
||||
*/
|
||||
private val DetailFoldPeek = 34.dp
|
||||
|
||||
/**
|
||||
* The height one tab's pane gets, from the viewport it has to fit inside.
|
||||
*
|
||||
* It was a hard 250dp, and `technicalSpecs()` — Video, Codec, Audio, Subtitles, Studio —
|
||||
* fell off the bottom of a pane that deliberately cannot scroll. The budget is what is left
|
||||
* of the screen once the pane has been scrolled to its resting position under the strip.
|
||||
*/
|
||||
internal fun detailPaneHeight(viewportHeight: Dp): Dp =
|
||||
(viewportHeight - 132.dp).coerceIn(250.dp, 420.dp)
|
||||
|
||||
internal data class DetailHeroAction(
|
||||
val icon: ImageVector,
|
||||
val description: String,
|
||||
val active: Boolean = false,
|
||||
val label: String? = null,
|
||||
val onClick: () -> Unit,
|
||||
)
|
||||
|
||||
/** Full-bleed artwork with a protected reading area on the left and at the fold. */
|
||||
@Composable
|
||||
internal fun DetailBackdrop(item: BaseItem, modifier: Modifier = Modifier) {
|
||||
val repository = ServiceLocator.repository
|
||||
val artwork = remember(item.id, item.backdropImageTags, item.imageTags) {
|
||||
repository.backdropUrl(item, 1920) ?: repository.primaryUrl(item, 1280)
|
||||
}
|
||||
Box(modifier.background(DetailBackground)) {
|
||||
if (artwork != null) {
|
||||
AsyncImage(
|
||||
model = artwork,
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
alignment = Alignment.TopCenter,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
Box(
|
||||
Modifier.fillMaxSize().background(
|
||||
Brush.horizontalGradient(
|
||||
0f to Color(0xF2080A0C),
|
||||
0.42f to Color(0xCC080A0C),
|
||||
0.72f to Color(0x38080A0C),
|
||||
1f to Color(0x10080A0C),
|
||||
),
|
||||
),
|
||||
)
|
||||
Box(
|
||||
Modifier.fillMaxSize().background(
|
||||
Brush.verticalGradient(
|
||||
0f to Color(0x18000000),
|
||||
0.48f to Color(0x26080A0C),
|
||||
0.78f to Color(0xE6080A0C),
|
||||
1f to DetailBackground,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Shared movie/series frame. The list owns vertical motion so focused bands stay visible. */
|
||||
@Composable
|
||||
internal fun DetailPageScaffold(
|
||||
item: BaseItem,
|
||||
facts: List<String>,
|
||||
badges: List<String>,
|
||||
tabs: List<DetailTab>,
|
||||
selectedTab: DetailTab,
|
||||
onSelectTab: (DetailTab) -> Unit,
|
||||
playLabel: String,
|
||||
onPlay: () -> Unit,
|
||||
playFocusRequester: FocusRequester,
|
||||
tabFocusRequester: FocusRequester,
|
||||
contentFocusRequester: FocusRequester,
|
||||
modifier: Modifier = Modifier,
|
||||
progress: Float = 0f,
|
||||
progressLabel: String? = null,
|
||||
reasons: List<String> = emptyList(),
|
||||
heroActions: List<DetailHeroAction> = emptyList(),
|
||||
confirmation: String? = null,
|
||||
pageListState: LazyListState = remember(item.id) { LazyListState() },
|
||||
onZoneFocused: (DetailZone) -> Unit = {},
|
||||
content: @Composable BoxScope.(DetailTab) -> Unit,
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
// Keep the same requesters when an async trailer action appears. Replacing the list
|
||||
// while the viewer is already on Favourites would detach the focused node.
|
||||
val allActionRequesters = remember(item.id) {
|
||||
List(6) { FocusRequester() }
|
||||
}
|
||||
val actionRequesters = allActionRequesters.take(heroActions.size)
|
||||
var lastHeroIndex by remember(item.id) { mutableIntStateOf(-1) }
|
||||
var focusedZone by remember(item.id) { mutableStateOf(DetailZone.PLAY) }
|
||||
val heroReturn = if (lastHeroIndex in actionRequesters.indices) {
|
||||
actionRequesters[lastHeroIndex]
|
||||
} else {
|
||||
playFocusRequester
|
||||
}
|
||||
fun reveal(index: Int, offset: Int = 0) {
|
||||
scope.launch { pageListState.animateScrollToItem(index, offset) }
|
||||
}
|
||||
fun revealHero() {
|
||||
scope.launch {
|
||||
// LazyColumn also scrolls a newly focused descendant into view. That request
|
||||
// can land after onFocusChanged and used to win over reveal(0), leaving Play
|
||||
// focused with the logo and metadata above the viewport. Snap once now and
|
||||
// once after focus relocation has completed so hero focus always means the
|
||||
// complete opening frame.
|
||||
pageListState.scrollToItem(0)
|
||||
withFrameNanos { }
|
||||
pageListState.scrollToItem(0)
|
||||
}
|
||||
}
|
||||
// Focus relocation belongs to LazyColumn and may run after the focus callback. Keep
|
||||
// the opening frame pinned for as long as focus remains in the hero, regardless of
|
||||
// which relocation wins a particular frame. Moving to Tabs or Content releases it.
|
||||
androidx.compose.runtime.LaunchedEffect(pageListState, focusedZone) {
|
||||
if (detailHeroScrollTarget(focusedZone) == null) return@LaunchedEffect
|
||||
snapshotFlow {
|
||||
pageListState.firstVisibleItemIndex to pageListState.firstVisibleItemScrollOffset
|
||||
}.collect { (index, offset) ->
|
||||
if (index != 0 || offset != 0) pageListState.scrollToItem(0)
|
||||
}
|
||||
}
|
||||
|
||||
BoxWithConstraints(modifier.fillMaxSize().background(DetailBackground)) {
|
||||
// The opening composition is one deliberate TV frame: hero above, tabs anchored
|
||||
// to its bottom edge. Content begins below the fold and only enters when the
|
||||
// viewer presses Down from the tabs.
|
||||
val heroHeight = (maxHeight - DetailTabHeight - DetailFoldPeek).coerceAtLeast(340.dp)
|
||||
val paneHeight = detailPaneHeight(maxHeight)
|
||||
LazyColumn(
|
||||
state = pageListState,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
) {
|
||||
item(key = "hero") {
|
||||
DetailHero(
|
||||
item = item,
|
||||
facts = facts,
|
||||
badges = badges,
|
||||
playLabel = playLabel,
|
||||
onPlay = onPlay,
|
||||
playFocusRequester = playFocusRequester,
|
||||
tabFocusRequester = tabFocusRequester,
|
||||
progress = progress,
|
||||
progressLabel = progressLabel,
|
||||
reasons = reasons,
|
||||
actions = heroActions,
|
||||
actionRequesters = actionRequesters,
|
||||
height = heroHeight,
|
||||
onActionFocused = { index ->
|
||||
lastHeroIndex = index
|
||||
focusedZone = DetailZone.PLAY
|
||||
onZoneFocused(DetailZone.PLAY)
|
||||
revealHero()
|
||||
},
|
||||
onPlayFocused = {
|
||||
lastHeroIndex = -1
|
||||
focusedZone = DetailZone.PLAY
|
||||
onZoneFocused(DetailZone.PLAY)
|
||||
revealHero()
|
||||
},
|
||||
)
|
||||
}
|
||||
item(key = "tabs") {
|
||||
DetailTabStrip(
|
||||
tabs = tabs,
|
||||
selected = selectedTab,
|
||||
onSelect = onSelectTab,
|
||||
selectedFocusRequester = tabFocusRequester,
|
||||
heroFocusRequester = heroReturn,
|
||||
contentFocusRequester = contentFocusRequester,
|
||||
onFocused = {
|
||||
focusedZone = DetailZone.TABS
|
||||
onZoneFocused(DetailZone.TABS)
|
||||
reveal(1, -18)
|
||||
},
|
||||
)
|
||||
}
|
||||
item(key = "content") {
|
||||
AnimatedContent(
|
||||
targetState = selectedTab,
|
||||
transitionSpec = { fadeIn(tween(110)) togetherWith fadeOut(tween(80)) },
|
||||
label = "detail-tab-content",
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = DetailSideGutter, end = DetailSideGutter, top = 16.dp, bottom = 64.dp)
|
||||
.height(paneHeight)
|
||||
.onFocusChanged {
|
||||
if (it.hasFocus) {
|
||||
focusedZone = DetailZone.CONTENT
|
||||
onZoneFocused(DetailZone.CONTENT)
|
||||
reveal(2, -72)
|
||||
}
|
||||
}
|
||||
.focusProperties { up = tabFocusRequester },
|
||||
) { visibleTab ->
|
||||
Box(Modifier.fillMaxSize()) { content(visibleTab) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
confirmation?.let {
|
||||
Text(
|
||||
text = it,
|
||||
color = Color.White,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.padding(bottom = 24.dp)
|
||||
.shadow(16.dp, RoundedCornerShape(MembyPanelCorner))
|
||||
.clip(RoundedCornerShape(MembyPanelCorner))
|
||||
.background(Color(0xEE20252A))
|
||||
.border(1.dp, Color.White.copy(alpha = 0.16f), RoundedCornerShape(MembyPanelCorner))
|
||||
.padding(horizontal = 20.dp, vertical = 10.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A focused hero is always the complete opening frame, never a scrolled Play-only crop. */
|
||||
internal fun detailHeroScrollTarget(zone: DetailZone): Int? =
|
||||
if (zone == DetailZone.PLAY) 0 else null
|
||||
|
||||
@Composable
|
||||
private fun DetailHero(
|
||||
item: BaseItem,
|
||||
facts: List<String>,
|
||||
badges: List<String>,
|
||||
playLabel: String,
|
||||
onPlay: () -> Unit,
|
||||
playFocusRequester: FocusRequester,
|
||||
tabFocusRequester: FocusRequester,
|
||||
progress: Float,
|
||||
progressLabel: String?,
|
||||
reasons: List<String>,
|
||||
actions: List<DetailHeroAction>,
|
||||
actionRequesters: List<FocusRequester>,
|
||||
height: Dp,
|
||||
onPlayFocused: () -> Unit,
|
||||
onActionFocused: (Int) -> Unit,
|
||||
) {
|
||||
// Settings promises the logo preference applies to titles generally; only the
|
||||
// screensaver honoured it. The dark-logo fallback comes with it — a black title
|
||||
// treatment on this near-black scrim is an invisible heading.
|
||||
val repository = ServiceLocator.repository
|
||||
val logoUrl = remember(item.id, item.imageTags, repository.showTitleLogo) {
|
||||
if (repository.showTitleLogo) repository.logoUrl(item, 720) else null
|
||||
}
|
||||
val logo = logoUrl.takeIf { !useTextTitleForLogo(it) }
|
||||
Box(Modifier.fillMaxWidth().height(height)) {
|
||||
DetailBackdrop(item, Modifier.fillMaxSize())
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomStart)
|
||||
.padding(start = DetailSideGutter, end = DetailSideGutter, bottom = 30.dp)
|
||||
.fillMaxWidth(0.58f),
|
||||
) {
|
||||
if (logo != null) {
|
||||
AsyncImage(
|
||||
model = logo,
|
||||
contentDescription = item.name,
|
||||
contentScale = ContentScale.Fit,
|
||||
alignment = Alignment.CenterStart,
|
||||
modifier = Modifier.width(330.dp).height(92.dp),
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
text = item.name,
|
||||
color = Color.White,
|
||||
fontSize = 38.sp,
|
||||
lineHeight = 42.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(10.dp))
|
||||
DetailFactRow(facts = facts, badges = badges, rating = ratingLabel(item))
|
||||
if (item.genres.isNotEmpty()) {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
text = item.genres.take(4).joinToString(ValueSeparator),
|
||||
color = DetailMutedText,
|
||||
fontSize = 14.sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(10.dp))
|
||||
Text(
|
||||
text = item.overview?.takeIf(String::isNotBlank) ?: "No description available.",
|
||||
color = DetailText,
|
||||
fontSize = 15.sp,
|
||||
lineHeight = 20.sp,
|
||||
maxLines = 3,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
if (progress > 0f) {
|
||||
Spacer(Modifier.height(12.dp))
|
||||
DetailProgress(progress, progressLabel)
|
||||
}
|
||||
if (reasons.isNotEmpty()) {
|
||||
Spacer(Modifier.height(10.dp))
|
||||
Text(
|
||||
text = reasons.first(),
|
||||
color = DetailAccent,
|
||||
fontSize = 13.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.focusGroup().focusProperties { down = tabFocusRequester },
|
||||
) {
|
||||
MembyPlayButton(
|
||||
label = playLabel,
|
||||
onClick = onPlay,
|
||||
onFocused = onPlayFocused,
|
||||
modifier = Modifier.testTag("detail-play").focusRequester(playFocusRequester),
|
||||
)
|
||||
actions.forEachIndexed { index, action ->
|
||||
DetailCircularAction(
|
||||
action = action,
|
||||
onFocused = { onActionFocused(index) },
|
||||
modifier = Modifier.focusRequester(actionRequesters[index]),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DetailCircularAction(
|
||||
action: DetailHeroAction,
|
||||
onFocused: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
var focused by remember { mutableStateOf(false) }
|
||||
val scale by animateFloatAsState(if (focused) 1.1f else 1f, tween(100), label = "hero-action-focus")
|
||||
Row(
|
||||
modifier = modifier
|
||||
.then(if (action.label == null) Modifier.size(48.dp) else Modifier.height(48.dp))
|
||||
.graphicsLayer { scaleX = scale; scaleY = scale; translationY = if (focused) -3f else 0f }
|
||||
.shadow(if (focused) 16.dp else 5.dp, CircleShape)
|
||||
.clip(CircleShape)
|
||||
.background(if (action.active) DetailAccent else Color(0xB3262B30))
|
||||
.border(if (focused) 2.dp else 1.dp, if (focused) Color.White else Color.White.copy(alpha = 0.38f), CircleShape)
|
||||
.semantics { contentDescription = action.description }
|
||||
.onFocusChanged { focused = it.isFocused; if (it.isFocused) onFocused() }
|
||||
.clickable(onClick = action.onClick)
|
||||
.padding(horizontal = if (action.label == null) 0.dp else 16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
) {
|
||||
Icon(action.icon, action.description, tint = Color.White, modifier = Modifier.size(22.dp))
|
||||
action.label?.let { label ->
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(label, color = Color.White, fontSize = 14.sp, fontWeight = FontWeight.Bold, maxLines = 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun DetailFactRow(
|
||||
facts: List<String>,
|
||||
badges: List<String> = emptyList(),
|
||||
rating: String? = null,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Row(modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||
val values = buildList {
|
||||
addAll(facts)
|
||||
rating?.let { add("★ $it") }
|
||||
}
|
||||
values.forEachIndexed { index, value ->
|
||||
if (index > 0) Text(FactSeparator, color = DetailQuietText, fontSize = 13.sp)
|
||||
Text(value, color = if (value.startsWith("★")) MembyScore else DetailMutedText, fontSize = 14.sp, fontWeight = FontWeight.Medium)
|
||||
}
|
||||
// Four, not three: a 4K/HDR/HEVC movie used up the whole allowance and dropped
|
||||
// the airing badge appended after them, which is the one that is news.
|
||||
badges.take(4).forEach { badge ->
|
||||
Spacer(Modifier.width(8.dp))
|
||||
MediaBadge(badge)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DetailTabStrip(
|
||||
tabs: List<DetailTab>,
|
||||
selected: DetailTab,
|
||||
onSelect: (DetailTab) -> Unit,
|
||||
selectedFocusRequester: FocusRequester,
|
||||
heroFocusRequester: FocusRequester,
|
||||
contentFocusRequester: FocusRequester,
|
||||
onFocused: () -> Unit,
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(DetailTabHeight)
|
||||
.background(DetailBackground)
|
||||
.padding(horizontal = DetailSideGutter),
|
||||
) {
|
||||
Box(Modifier.align(Alignment.BottomStart).fillMaxWidth().height(1.dp).background(DetailHairline))
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.focusGroup()
|
||||
.focusProperties { up = heroFocusRequester; down = contentFocusRequester },
|
||||
horizontalArrangement = Arrangement.spacedBy(34.dp),
|
||||
verticalAlignment = Alignment.Bottom,
|
||||
) {
|
||||
tabs.forEach { tab ->
|
||||
var focused by remember(tab) { mutableStateOf(false) }
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.width(IntrinsicSize.Max)
|
||||
.then(if (tab == selected) Modifier.focusRequester(selectedFocusRequester) else Modifier)
|
||||
.testTag("detail-tab-${tab.key}")
|
||||
.onFocusChanged {
|
||||
focused = it.isFocused
|
||||
if (it.isFocused) { onSelect(tab); onFocused() }
|
||||
}
|
||||
.clickable { onSelect(tab) },
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Text(
|
||||
tab.label,
|
||||
color = if (selected == tab || focused) Color.White else DetailQuietText,
|
||||
fontSize = 15.sp,
|
||||
fontWeight = if (selected == tab || focused) FontWeight.Bold else FontWeight.Medium,
|
||||
maxLines = 1,
|
||||
modifier = Modifier.padding(start = 4.dp, end = 4.dp, bottom = 13.dp),
|
||||
)
|
||||
Box(
|
||||
Modifier.fillMaxWidth().height(3.dp).background(
|
||||
if (selected == tab || focused) DetailAccent else Color.Transparent,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.weight(1f))
|
||||
// The tab's content begins below the fold by design. Nothing said so; the
|
||||
// peek under this strip and this chevron are what say it. Never focusable —
|
||||
// it is a caption on the Down key, not another thing to land on.
|
||||
Icon(
|
||||
Icons.Default.KeyboardArrowDown,
|
||||
contentDescription = null,
|
||||
tint = DetailQuietText,
|
||||
modifier = Modifier.size(18.dp).padding(bottom = 2.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun DetailProgress(progress: Float, label: String?, modifier: Modifier = Modifier) {
|
||||
Row(modifier, verticalAlignment = Alignment.CenterVertically) {
|
||||
Box(Modifier.width(250.dp).height(5.dp).clip(CircleShape).background(Color.White.copy(alpha = 0.22f))) {
|
||||
Box(Modifier.fillMaxWidth(progress.coerceIn(0f, 1f)).height(5.dp).background(DetailAccent))
|
||||
}
|
||||
label?.let { Text(it, color = DetailMutedText, fontSize = 12.sp, modifier = Modifier.padding(start = 12.dp)) }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun DetailFocusablePane(
|
||||
focusRequester: FocusRequester,
|
||||
modifier: Modifier = Modifier,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
var focused by remember { mutableStateOf(false) }
|
||||
Box(
|
||||
modifier
|
||||
.fillMaxSize()
|
||||
.focusRequester(focusRequester)
|
||||
.onFocusChanged { focused = it.isFocused }
|
||||
.focusable()
|
||||
.clip(RoundedCornerShape(MembyCardCorner))
|
||||
.border(if (focused) 2.dp else 1.dp, if (focused) Color.White.copy(alpha = 0.8f) else Color.Transparent, RoundedCornerShape(MembyCardCorner))
|
||||
.background(if (focused) Color.White.copy(alpha = 0.035f) else Color.Transparent)
|
||||
.padding(16.dp),
|
||||
) { content() }
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun DetailMetaRows(rows: List<TechnicalSpec>, modifier: Modifier = Modifier, labelWidth: androidx.compose.ui.unit.Dp = 110.dp) {
|
||||
Column(modifier, verticalArrangement = Arrangement.spacedBy(9.dp)) {
|
||||
rows.forEach { row ->
|
||||
Row(Modifier.fillMaxWidth()) {
|
||||
Text(row.label, color = DetailQuietText, fontSize = 13.sp, fontWeight = FontWeight.Medium, modifier = Modifier.width(labelWidth))
|
||||
Text(row.value, color = DetailText, fontSize = 14.sp, lineHeight = 19.sp, maxLines = 2, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun DetailOverviewPane(
|
||||
item: BaseItem,
|
||||
credits: List<TechnicalSpec>,
|
||||
focusRequester: FocusRequester,
|
||||
modifier: Modifier = Modifier,
|
||||
supportingText: String? = null,
|
||||
) {
|
||||
DetailFocusablePane(focusRequester, modifier) {
|
||||
Column {
|
||||
Text(item.overview?.takeIf(String::isNotBlank) ?: "No description available.", color = DetailText, fontSize = 16.sp, lineHeight = 23.sp, maxLines = 5, overflow = TextOverflow.Ellipsis)
|
||||
supportingText?.let { Spacer(Modifier.height(10.dp)); Text(it, color = DetailAccent, fontSize = 14.sp, fontWeight = FontWeight.SemiBold) }
|
||||
Spacer(Modifier.height(16.dp))
|
||||
DetailMetaRows(credits.take(4))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun DetailCastAndDetailsPane(
|
||||
item: BaseItem,
|
||||
credits: List<TechnicalSpec>,
|
||||
specs: List<TechnicalSpec>,
|
||||
detailsLoaded: Boolean,
|
||||
focusRequester: FocusRequester,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val releaseAndTechnical = remember(item.id, specs, credits) {
|
||||
val alreadyCredited = credits.map(TechnicalSpec::label).toSet()
|
||||
buildList {
|
||||
item.productionYear?.let { add(TechnicalSpec("Released", it.toString())) }
|
||||
item.officialRating?.takeIf(String::isNotBlank)?.let { add(TechnicalSpec("Certificate", it)) }
|
||||
item.runtimeMinutes?.let { add(TechnicalSpec("Runtime", formatRuntime(it))) }
|
||||
// Studio is in both vocabularies. It only became visible as a duplicate once
|
||||
// the pane stopped clipping its own second half.
|
||||
addAll(specs.filterNot { it.label in alreadyCredited })
|
||||
}
|
||||
}
|
||||
DetailFocusablePane(focusRequester, modifier) {
|
||||
if (!detailsLoaded && item.people.isEmpty() && specs.isEmpty()) {
|
||||
Text("Loading cast and details…", color = DetailQuietText, fontSize = 15.sp)
|
||||
} else {
|
||||
Column {
|
||||
if (item.cast.isNotEmpty()) CastRail(people = item.cast, compact = true, showTitle = true)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(42.dp)) {
|
||||
DetailMetaRows(credits, Modifier.weight(1f))
|
||||
DetailMetaRows(releaseAndTechnical, Modifier.weight(1f))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun DetailMoreLikeThisPane(
|
||||
items: List<BaseItem>,
|
||||
loading: Boolean,
|
||||
onSelect: (BaseItem) -> Unit,
|
||||
firstFocusRequester: FocusRequester,
|
||||
listState: LazyListState,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
when {
|
||||
loading -> DetailFocusablePane(firstFocusRequester, modifier) { Text("Finding similar titles…", color = DetailQuietText, fontSize = 15.sp) }
|
||||
items.isEmpty() -> DetailFocusablePane(firstFocusRequester, modifier) { Text("No similar titles are available.", color = DetailQuietText, fontSize = 15.sp) }
|
||||
else -> LazyRow(
|
||||
state = listState,
|
||||
modifier = modifier.fillMaxSize().focusGroup(),
|
||||
horizontalArrangement = Arrangement.spacedBy(18.dp),
|
||||
contentPadding = PaddingValues(horizontal = 7.dp, vertical = 7.dp),
|
||||
) {
|
||||
itemsIndexed(items, key = { _, it -> it.id }) { index, related ->
|
||||
DetailPosterCard(
|
||||
item = related,
|
||||
onClick = { onSelect(related) },
|
||||
modifier = if (index == 0) Modifier.focusRequester(firstFocusRequester) else Modifier,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DetailPosterCard(item: BaseItem, onClick: () -> Unit, modifier: Modifier = Modifier) {
|
||||
val artwork = remember(item.id) { ServiceLocator.repository.primaryUrl(item, 420) ?: ServiceLocator.repository.backdropUrl(item, 420) }
|
||||
var focused by remember { mutableStateOf(false) }
|
||||
val scale by animateFloatAsState(if (focused) 1.06f else 1f, tween(100), label = "related-poster-focus")
|
||||
Column(
|
||||
modifier.width(128.dp).graphicsLayer { scaleX = scale; scaleY = scale; translationY = if (focused) -3f else 0f }.zIndex(if (focused) 1f else 0f).onFocusChanged { focused = it.isFocused }.clickable(onClick = onClick),
|
||||
) {
|
||||
Box(Modifier.fillMaxWidth().aspectRatio(2f / 3f).clip(RoundedCornerShape(MembyCardCorner)).background(Color(0xFF171B1F)).border(if (focused) 2.dp else 1.dp, if (focused) Color.White else DetailHairline, RoundedCornerShape(MembyCardCorner))) {
|
||||
if (artwork != null) AsyncImage(artwork, null, Modifier.fillMaxSize(), contentScale = ContentScale.Crop)
|
||||
}
|
||||
Text(item.name, color = if (focused) Color.White else DetailText, fontSize = 13.sp, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.padding(top = 7.dp))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun DetailPanePlaceholder(message: String, modifier: Modifier = Modifier) {
|
||||
Box(modifier.fillMaxSize(), contentAlignment = Alignment.TopStart) { Text(message, color = DetailQuietText, fontSize = 15.sp) }
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,409 @@
|
||||
package com.ponzischeme89.memby.ui
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusProperties
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.tv.material3.Icon
|
||||
import androidx.tv.material3.Text
|
||||
import coil.compose.AsyncImage
|
||||
import com.ponzischeme89.memby.ServiceLocator
|
||||
import com.ponzischeme89.memby.data.model.BaseItem
|
||||
import com.ponzischeme89.memby.ui.detail.heroFacts
|
||||
import com.ponzischeme89.memby.ui.detail.ratingLabel
|
||||
import com.ponzischeme89.memby.ui.theme.FactSeparator
|
||||
import com.ponzischeme89.memby.ui.theme.MembyAccent
|
||||
import com.ponzischeme89.memby.ui.theme.MembyMutedText
|
||||
import com.ponzischeme89.memby.ui.theme.MembyPanelCorner
|
||||
import com.ponzischeme89.memby.ui.theme.MembyQuietText
|
||||
import com.ponzischeme89.memby.ui.theme.MembyScore
|
||||
|
||||
internal const val HOME_HERO_ROW_ID = "home-movie-hero"
|
||||
|
||||
/**
|
||||
* The movie feature owns the header only while focus is still in that feature. Once the
|
||||
* viewer moves into a home shelf, the header becomes the same focused-item metadata panel
|
||||
* used by Movies, Shows and the other browse destinations.
|
||||
*/
|
||||
internal fun shouldShowHomeMovieHero(hasMovies: Boolean, focusedRowId: String?): Boolean =
|
||||
hasMovies && (focusedRowId == null || focusedRowId == HOME_HERO_ROW_ID)
|
||||
|
||||
/**
|
||||
* A hero card and the reason it is there.
|
||||
*
|
||||
* The label used to be the card's *position* — `listOf("POPULAR", "NEW RELEASE",
|
||||
* "TRENDING")[index]` — while the selection below interleaves two sources and then falls
|
||||
* back to every movie in the response, so a 2025 title was captioned NEW RELEASE and a 2026
|
||||
* one POPULAR. A caption that can be wrong is worse than no caption.
|
||||
*/
|
||||
internal data class HomeHeroPick(val item: BaseItem, val label: String)
|
||||
|
||||
private const val LABEL_NEW = "NEW RELEASE"
|
||||
private const val LABEL_POPULAR = "POPULAR"
|
||||
private const val LABEL_LIBRARY = "FROM YOUR LIBRARY"
|
||||
|
||||
/** Picks a deliberate mix of fresh and popular movies while preserving server ranking. */
|
||||
internal fun selectHomeHeroMovies(rows: List<HomeBrowseRow>): List<HomeHeroPick> {
|
||||
fun HomeBrowseRow.matches(vararg words: String): Boolean {
|
||||
val label = "$id $title".lowercase()
|
||||
return words.any(label::contains)
|
||||
}
|
||||
|
||||
val newReleases = rows
|
||||
.filter { it.matches("latest", "recent", "new release", "just added") }
|
||||
.flatMap(HomeBrowseRow::items)
|
||||
.filter(BaseItem::isMovie)
|
||||
val popular = rows
|
||||
.filter { it.matches("popular", "trending", "recommended", "top pick") }
|
||||
.flatMap(HomeBrowseRow::items)
|
||||
.filter(BaseItem::isMovie)
|
||||
val everyMovie = rows.flatMap(HomeBrowseRow::items).filter(BaseItem::isMovie)
|
||||
|
||||
fun List<BaseItem>.labelled(label: String) = map { HomeHeroPick(it, label) }
|
||||
|
||||
return buildList {
|
||||
addAll(
|
||||
listOfNotNull(
|
||||
newReleases.getOrNull(0)?.let { HomeHeroPick(it, LABEL_NEW) },
|
||||
popular.getOrNull(0)?.let { HomeHeroPick(it, LABEL_POPULAR) },
|
||||
),
|
||||
)
|
||||
addAll(
|
||||
listOfNotNull(
|
||||
newReleases.getOrNull(1)?.let { HomeHeroPick(it, LABEL_NEW) },
|
||||
popular.getOrNull(1)?.let { HomeHeroPick(it, LABEL_POPULAR) },
|
||||
),
|
||||
)
|
||||
addAll(newReleases.labelled(LABEL_NEW))
|
||||
addAll(popular.labelled(LABEL_POPULAR))
|
||||
addAll(everyMovie.labelled(LABEL_LIBRARY))
|
||||
// distinctBy keeps the first appearance, so a title that is both new and popular
|
||||
// keeps the label of the row it was drawn from first.
|
||||
}.distinctBy { it.item.id }.take(4)
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun HomeMovieHero(
|
||||
movies: List<HomeHeroPick>,
|
||||
navigationFocusRequester: FocusRequester,
|
||||
contentEntryFocusRequester: FocusRequester? = null,
|
||||
returnFocusItemId: String? = null,
|
||||
returnFocusRequester: FocusRequester? = null,
|
||||
onItemFocused: (BaseItem) -> Unit,
|
||||
onItemSelected: (BaseItem) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
previewArtwork: ImageBitmap? = null,
|
||||
) {
|
||||
if (movies.isEmpty()) return
|
||||
|
||||
BoxWithConstraints(modifier.fillMaxWidth()) {
|
||||
val miniWidth = (maxWidth * 0.27f).coerceIn(184.dp, 326.dp)
|
||||
Row(
|
||||
modifier = Modifier.fillMaxSize().padding(start = 36.dp, end = 36.dp, top = 16.dp, bottom = 10.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
val featured = movies.first()
|
||||
var featuredModifier: Modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxHeight()
|
||||
.focusProperties { left = navigationFocusRequester }
|
||||
if (contentEntryFocusRequester != null) {
|
||||
featuredModifier = featuredModifier.focusRequester(contentEntryFocusRequester)
|
||||
}
|
||||
if (featured.item.id == returnFocusItemId && returnFocusRequester != null) {
|
||||
featuredModifier = featuredModifier.focusRequester(returnFocusRequester)
|
||||
}
|
||||
FeaturedMovieCard(
|
||||
pick = featured,
|
||||
onFocused = { onItemFocused(featured.item) },
|
||||
onClick = { onItemSelected(featured.item) },
|
||||
modifier = featuredModifier,
|
||||
previewArtwork = previewArtwork,
|
||||
)
|
||||
|
||||
Column(
|
||||
modifier = Modifier.width(miniWidth).fillMaxHeight(),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
movies.drop(1).take(3).forEach { pick ->
|
||||
var miniModifier: Modifier = Modifier.weight(1f).fillMaxWidth()
|
||||
if (pick.item.id == returnFocusItemId && returnFocusRequester != null) {
|
||||
miniModifier = miniModifier.focusRequester(returnFocusRequester)
|
||||
}
|
||||
MiniMovieCard(
|
||||
pick = pick,
|
||||
onFocused = { onItemFocused(pick.item) },
|
||||
onClick = { onItemSelected(pick.item) },
|
||||
modifier = miniModifier,
|
||||
previewArtwork = previewArtwork,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A wash over the artwork, keyed to why the card is on the shelf rather than to its slot. */
|
||||
private fun labelTint(label: String): Color = when (label) {
|
||||
LABEL_NEW -> Color(0x667253B7)
|
||||
LABEL_POPULAR -> Color(0x66499BD5)
|
||||
else -> Color(0x66C67A42)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FeaturedMovieCard(
|
||||
pick: HomeHeroPick,
|
||||
onFocused: () -> Unit,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier,
|
||||
previewArtwork: ImageBitmap?,
|
||||
) {
|
||||
val item = pick.item
|
||||
FocusScaleContainer(
|
||||
onFocused = onFocused,
|
||||
onClick = onClick,
|
||||
contentDescription = "Featured movie, ${item.name}",
|
||||
modifier = modifier.clip(RoundedCornerShape(MembyPanelCorner)),
|
||||
) { focused ->
|
||||
Box(Modifier.fillMaxSize().background(Color(0xFF151B20))) {
|
||||
HeroArtwork(item, previewArtwork, Modifier.fillMaxSize())
|
||||
Box(
|
||||
Modifier.fillMaxSize().background(
|
||||
Brush.horizontalGradient(
|
||||
0f to Color(0xF20A0D10),
|
||||
0.48f to Color(0xA80A0D10),
|
||||
1f to Color(0x160A0D10),
|
||||
),
|
||||
),
|
||||
)
|
||||
Box(
|
||||
Modifier.fillMaxSize().background(
|
||||
Brush.verticalGradient(
|
||||
0f to Color.Transparent,
|
||||
0.72f to Color.Transparent,
|
||||
1f to Color(0xD9000000),
|
||||
),
|
||||
),
|
||||
)
|
||||
// The card is a fixed height and this column is centred in it, so anything
|
||||
// over budget is lost equally top and bottom — and the action, being last, went
|
||||
// first. A wrapped title costs exactly what the synopsis is worth, so the
|
||||
// synopsis is what stands down; the button is never the thing that is cut.
|
||||
var titleLines by remember(item.id) { mutableIntStateOf(1) }
|
||||
Column(
|
||||
modifier = Modifier.align(Alignment.CenterStart).fillMaxWidth(0.58f).padding(22.dp),
|
||||
) {
|
||||
Text(
|
||||
pick.label,
|
||||
color = MembyAccent,
|
||||
fontSize = 11.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
letterSpacing = 1.4.sp,
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
item.name,
|
||||
color = Color.White,
|
||||
fontSize = 30.sp,
|
||||
lineHeight = 32.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
onTextLayout = { titleLines = it.lineCount },
|
||||
)
|
||||
Spacer(Modifier.height(9.dp))
|
||||
HeroFactLine(item)
|
||||
if (titleLines == 1) {
|
||||
item.overview?.takeIf(String::isNotBlank)?.let { overview ->
|
||||
Spacer(Modifier.height(9.dp))
|
||||
Text(
|
||||
overview,
|
||||
color = MembyMutedText,
|
||||
fontSize = 13.sp,
|
||||
lineHeight = 17.sp,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(14.dp))
|
||||
MembyPlayChip(label = "Play", focused = focused)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Year, length, certificate and score — the same wording, order and gold as the card
|
||||
* directly beneath it and the detail page it opens. The hero used to carry its own
|
||||
* formatter and print "2026 • M • 124m" over a row printing "2026 • 2h 4m".
|
||||
*/
|
||||
@Composable
|
||||
private fun HeroFactLine(item: BaseItem) {
|
||||
val facts = heroFacts(item)
|
||||
val score = ratingLabel(item)
|
||||
if (facts.isEmpty() && score == null) return
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
if (facts.isNotEmpty()) {
|
||||
Text(
|
||||
facts.joinToString(FactSeparator),
|
||||
color = MembyMutedText,
|
||||
fontSize = 13.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f, fill = false),
|
||||
)
|
||||
}
|
||||
score?.let {
|
||||
if (facts.isNotEmpty()) Text(FactSeparator, color = MembyQuietText, fontSize = 13.sp)
|
||||
Text("★ $it", color = MembyScore, fontSize = 13.sp, fontWeight = FontWeight.SemiBold)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MiniMovieCard(
|
||||
pick: HomeHeroPick,
|
||||
onFocused: () -> Unit,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier,
|
||||
previewArtwork: ImageBitmap?,
|
||||
) {
|
||||
val item = pick.item
|
||||
FocusScaleContainer(
|
||||
onFocused = onFocused,
|
||||
onClick = onClick,
|
||||
contentDescription = "${pick.label} movie, ${item.name}",
|
||||
modifier = modifier.clip(RoundedCornerShape(MembyPanelCorner)),
|
||||
) { focused ->
|
||||
Box(Modifier.fillMaxSize().background(Color(0xFF192027))) {
|
||||
HeroArtwork(item, previewArtwork, Modifier.fillMaxSize())
|
||||
Box(Modifier.fillMaxSize().background(labelTint(pick.label)))
|
||||
Box(
|
||||
Modifier.fillMaxSize().background(
|
||||
Brush.horizontalGradient(
|
||||
0f to Color(0xE80A0D10),
|
||||
0.78f to Color(0x850A0D10),
|
||||
1f to Color(0x300A0D10),
|
||||
),
|
||||
),
|
||||
)
|
||||
Column(Modifier.align(Alignment.CenterStart).padding(horizontal = 14.dp, vertical = 10.dp)) {
|
||||
Text(
|
||||
pick.label,
|
||||
color = if (focused) MembyAccent else MembyQuietText,
|
||||
fontSize = 9.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
letterSpacing = 0.8.sp,
|
||||
)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
item.name,
|
||||
color = Color.White,
|
||||
fontSize = 15.sp,
|
||||
lineHeight = 17.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
item.productionYear?.let { year ->
|
||||
Spacer(Modifier.height(3.dp))
|
||||
Text(year.toString(), color = MembyQuietText, fontSize = 10.sp)
|
||||
}
|
||||
}
|
||||
if (focused) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.CenterEnd)
|
||||
.padding(end = 16.dp)
|
||||
.size(38.dp)
|
||||
.shadow(14.dp, CircleShape)
|
||||
.clip(CircleShape)
|
||||
.background(MembyAccent)
|
||||
.border(2.dp, Color.White, CircleShape),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.PlayArrow,
|
||||
contentDescription = null,
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(24.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HeroArtwork(item: BaseItem, previewArtwork: ImageBitmap?, modifier: Modifier) {
|
||||
if (previewArtwork != null) {
|
||||
Image(
|
||||
bitmap = previewArtwork,
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = modifier,
|
||||
)
|
||||
return
|
||||
}
|
||||
val repo = ServiceLocator.repository
|
||||
val density = LocalDensity.current
|
||||
val artworkWidth = with(density) { 760.dp.roundToPx() }.coerceIn(480, 1280)
|
||||
val artwork = remember(item.id, artworkWidth) {
|
||||
repo.backdropUrl(item, artworkWidth) ?: repo.primaryUrl(item, artworkWidth)
|
||||
}
|
||||
if (artwork != null) {
|
||||
AsyncImage(
|
||||
model = artwork,
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = modifier,
|
||||
)
|
||||
} else {
|
||||
Box(
|
||||
modifier.background(
|
||||
Brush.linearGradient(
|
||||
listOf(Color(0xFF172830), Color(0xFF26343A), Color(0xFF12171B)),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,6 @@ import com.ponzischeme89.memby.data.analytics.RowAnalytics
|
||||
import com.ponzischeme89.memby.data.friendlyEmbyError
|
||||
import com.ponzischeme89.memby.data.isMaintenanceError
|
||||
import com.ponzischeme89.memby.data.model.BaseItem
|
||||
import com.ponzischeme89.memby.data.model.GatewayUpdate
|
||||
import com.ponzischeme89.memby.data.model.HomeRow
|
||||
import com.ponzischeme89.memby.data.model.UserItemData
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -18,8 +17,12 @@ import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
@@ -50,15 +53,29 @@ data class HomeUiState(
|
||||
* rows behind it would be stale and unusable anyway.
|
||||
*/
|
||||
val maintenanceMessage: String? = null,
|
||||
/**
|
||||
* The gateway's update verdict. A mandatory one blocks the home screen; an optional
|
||||
* one shows a prompt the viewer can dismiss for this session.
|
||||
*/
|
||||
val update: GatewayUpdate? = null,
|
||||
) {
|
||||
val watchingAndNextUp: List<BaseItem>
|
||||
get() = (continueWatching + nextUp).distinctBy(BaseItem::id)
|
||||
|
||||
/**
|
||||
* This state with everything that is *not* a row blanked out. Paired with
|
||||
* `distinctUntilChanged`, it turns [HomeViewModel.content] into a flow that only
|
||||
* emits when something changed about what is on the rows — see the note there.
|
||||
*
|
||||
* Read rows and [loading] from this; the blanked fields are meaningless in it.
|
||||
*/
|
||||
fun contentSlice(): HomeUiState = copy(
|
||||
hasRefreshError = false,
|
||||
statusMessage = null,
|
||||
maintenanceMessage = null,
|
||||
)
|
||||
|
||||
fun statusSlice(): HomeStatus = HomeStatus(
|
||||
hasRefreshError = hasRefreshError,
|
||||
statusMessage = statusMessage,
|
||||
maintenanceMessage = maintenanceMessage,
|
||||
)
|
||||
|
||||
fun toCache() = HomeCache(
|
||||
continueWatching = continueWatching,
|
||||
nextUp = nextUp,
|
||||
@@ -84,6 +101,16 @@ data class HomeUiState(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The connection-health slice of [HomeUiState]: what the banner and the maintenance
|
||||
* screen need, and nothing that would drag the rows into a recomposition with it.
|
||||
*/
|
||||
data class HomeStatus(
|
||||
val hasRefreshError: Boolean = false,
|
||||
val statusMessage: String? = null,
|
||||
val maintenanceMessage: String? = null,
|
||||
)
|
||||
|
||||
data class ForYouUiState(
|
||||
val rows: List<HomeRow> = emptyList(),
|
||||
val availableMinutes: Int = 0,
|
||||
@@ -95,6 +122,25 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
|
||||
private val refreshMutex = Mutex()
|
||||
private val _state = MutableStateFlow(HomeUiState.from(repository.cachedHome()))
|
||||
val state: StateFlow<HomeUiState> = _state.asStateFlow()
|
||||
|
||||
// HomeScreen is a very large composable, so reading the whole of [state] there meant
|
||||
// every emission invalidated the launcher: the slow-
|
||||
// connection banner and each of the four section loads all recomposed the rows, the
|
||||
// rail and every overlay, and rebuilt the row list with them. These three narrow
|
||||
// projections let it subscribe only to what each part actually renders.
|
||||
|
||||
/** Rows and their loading flags. Does not emit for banner changes. */
|
||||
val content: StateFlow<HomeUiState> = _state
|
||||
.map(HomeUiState::contentSlice)
|
||||
.distinctUntilChanged()
|
||||
.stateIn(viewModelScope, SharingStarted.Eagerly, _state.value.contentSlice())
|
||||
|
||||
/** Connection health. Does not emit when rows change. */
|
||||
val status: StateFlow<HomeStatus> = _state
|
||||
.map(HomeUiState::statusSlice)
|
||||
.distinctUntilChanged()
|
||||
.stateIn(viewModelScope, SharingStarted.Eagerly, _state.value.statusSlice())
|
||||
|
||||
private val _focusedItem = MutableStateFlow<BaseItem?>(initialFocusedItem(_state.value))
|
||||
val focusedItem: StateFlow<BaseItem?> = _focusedItem.asStateFlow()
|
||||
private val _forYou = MutableStateFlow(ForYouUiState())
|
||||
@@ -107,20 +153,8 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
|
||||
/** Row engagement, buffered here and uploaded in batches. */
|
||||
private val analytics = RowAnalytics()
|
||||
|
||||
/** Optional prompt the viewer waved away; forgotten when the app restarts. */
|
||||
private var dismissedUpdateVersion: String? = null
|
||||
|
||||
init {
|
||||
refreshAll()
|
||||
checkForAppUpdate()
|
||||
viewModelScope.launch {
|
||||
// A TV can leave Memby open for days. Keep checking at a deliberately slow
|
||||
// cadence so a newly published release appears without requiring a restart.
|
||||
while (true) {
|
||||
delay(UPDATE_CHECK_INTERVAL_MS)
|
||||
checkForAppUpdate()
|
||||
}
|
||||
}
|
||||
viewModelScope.launch {
|
||||
repository.playbackStops.collect { refreshWatching() }
|
||||
}
|
||||
@@ -134,29 +168,8 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Asks the gateway whether this build is still allowed. Runs on every launch, so an
|
||||
* operator can retire a version without waiting for anyone to open Settings.
|
||||
*/
|
||||
fun checkForAppUpdate() {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val update = repository.checkAppUpdate() ?: return@launch
|
||||
// A dismissed optional prompt stays dismissed for this session; a mandatory
|
||||
// one always reasserts itself.
|
||||
if (update.isOptional && dismissedUpdateVersion == update.version) return@launch
|
||||
_state.update { it.copy(update = update) }
|
||||
}
|
||||
}
|
||||
|
||||
/** Dismisses an optional prompt. Mandatory updates ignore this by construction. */
|
||||
fun dismissUpdatePrompt() {
|
||||
val current = _state.value.update ?: return
|
||||
if (current.isMandatory) return
|
||||
dismissedUpdateVersion = current.version
|
||||
_state.update { it.copy(update = null) }
|
||||
}
|
||||
|
||||
fun trackRowImpression(rowId: String, rowKind: String) = analytics.rowImpression(rowId, rowKind)
|
||||
fun trackRowImpression(rowId: String, rowKind: String, visibleItemIds: List<String> = emptyList()) =
|
||||
analytics.rowImpression(rowId, rowKind, visibleItemIds)
|
||||
|
||||
fun trackRowFocused(rowId: String, rowKind: String, itemId: String) =
|
||||
analytics.rowFocused(rowId, rowKind, itemId)
|
||||
@@ -286,6 +299,9 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
|
||||
val cached = synchronized(metadataCache) { metadataCache[item.id] }
|
||||
val focused = (cached ?: item).copy(
|
||||
membyAiringToday = item.membyAiringToday || cached?.membyAiringToday == true,
|
||||
membyRecommendationReason = item.membyRecommendationReason
|
||||
?: cached?.membyRecommendationReason,
|
||||
membyCompatibility = item.membyCompatibility ?: cached?.membyCompatibility,
|
||||
)
|
||||
_focusedItem.value = focused
|
||||
metadataJob?.cancel()
|
||||
@@ -298,7 +314,12 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
|
||||
if (item.membyPlayable) {
|
||||
launch { runCatching { repository.prefetchPlayable(cached ?: item) } }
|
||||
}
|
||||
if (cached == null && !item.isSonarrSchedule) {
|
||||
// Warm the explanation and franchise siblings while the card is already
|
||||
// focused, so opening Details does not add a reason line a frame later.
|
||||
if (!item.isSchedule && (item.isMovie || item.isSeries)) {
|
||||
launch { runCatching { repository.getRelated(focused) } }
|
||||
}
|
||||
if (cached == null && !item.isSchedule) {
|
||||
launch {
|
||||
val details = runCatching {
|
||||
repository.getItemDetails(item.id)
|
||||
@@ -464,7 +485,6 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
|
||||
companion object {
|
||||
private const val FOCUS_METADATA_DEBOUNCE_MS = 140L
|
||||
private const val ANALYTICS_FLUSH_INTERVAL_MS = 20_000L
|
||||
private const val UPDATE_CHECK_INTERVAL_MS = 60L * 60L * 1_000L
|
||||
|
||||
private fun initialFocusedItem(state: HomeUiState): BaseItem? =
|
||||
state.watchingAndNextUp.firstOrNull()
|
||||
@@ -489,6 +509,7 @@ private fun List<HomeRow>.airingTodayShowKeys(): Set<String> =
|
||||
firstOrNull { it.id == "sonarr-airing-today" }
|
||||
?.items
|
||||
.orEmpty()
|
||||
.filter { it.membyAirDayLabel.equals("Today", ignoreCase = true) }
|
||||
.mapTo(mutableSetOf()) { it.name.showMatchKey() }
|
||||
.filterTo(mutableSetOf(), String::isNotEmpty)
|
||||
|
||||
@@ -497,7 +518,7 @@ private fun List<HomeRow>.withAiringTodayRowTags(keys: Set<String>): List<HomeRo
|
||||
|
||||
private fun List<BaseItem>.withAiringTodayItemTags(keys: Set<String>): List<BaseItem> =
|
||||
map { item ->
|
||||
if (!item.isSonarrSchedule && item.isSeries && item.name.showMatchKey() in keys) {
|
||||
if (!item.isTvSchedule && item.isSeries && item.name.showMatchKey() in keys) {
|
||||
item.copy(membyAiringToday = true)
|
||||
} else {
|
||||
item
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,297 @@
|
||||
package com.ponzischeme89.memby.ui
|
||||
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.DoneAll
|
||||
import androidx.compose.material.icons.filled.FirstPage
|
||||
import androidx.compose.material.icons.filled.Movie
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import com.ponzischeme89.memby.ServiceLocator
|
||||
import com.ponzischeme89.memby.data.RelatedContent
|
||||
import com.ponzischeme89.memby.data.Settings
|
||||
import com.ponzischeme89.memby.data.model.BaseItem
|
||||
import com.ponzischeme89.memby.ui.detail.DetailTab
|
||||
import com.ponzischeme89.memby.ui.detail.DetailZone
|
||||
import com.ponzischeme89.memby.ui.detail.creditRows
|
||||
import com.ponzischeme89.memby.ui.detail.detailPositions
|
||||
import com.ponzischeme89.memby.ui.detail.detailTab
|
||||
import com.ponzischeme89.memby.ui.detail.detailTabs
|
||||
import com.ponzischeme89.memby.ui.detail.franchiseStart
|
||||
import com.ponzischeme89.memby.ui.detail.heroFacts
|
||||
import com.ponzischeme89.memby.ui.detail.playbackProgress
|
||||
import com.ponzischeme89.memby.ui.detail.primaryActionLabel
|
||||
import com.ponzischeme89.memby.ui.detail.remainingLabel
|
||||
import com.ponzischeme89.memby.ui.detail.technicalSpecs
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
/**
|
||||
* A movie, or any single playable item that is not a series.
|
||||
*
|
||||
* Everything structural lives in [DetailPageScaffold]; this file only decides which tabs a
|
||||
* movie has and what goes in each one.
|
||||
*/
|
||||
@Composable
|
||||
fun MediaDetailsOverlay(
|
||||
item: BaseItem,
|
||||
onPlay: (BaseItem) -> Unit,
|
||||
onToggleFavorite: (BaseItem, Boolean) -> Unit,
|
||||
onTogglePlayed: (BaseItem, Boolean) -> Unit,
|
||||
onClose: () -> Unit,
|
||||
onOpenItem: (BaseItem) -> Unit = {},
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val settings by ServiceLocator.repository.settingsFlow.collectAsState(initial = Settings.EMPTY)
|
||||
var related by remember(item.id) { mutableStateOf<RelatedContent?>(null) }
|
||||
var trailer by remember(item.id) { mutableStateOf<BaseItem?>(null) }
|
||||
LaunchedEffect(item.id) {
|
||||
related = ServiceLocator.repository.getRelated(item)
|
||||
}
|
||||
LaunchedEffect(item.id) {
|
||||
trailer = ServiceLocator.repository.getLocalTrailer(item.id)
|
||||
}
|
||||
MediaDetailContent(
|
||||
item = item,
|
||||
onPlay = onPlay,
|
||||
onToggleFavorite = onToggleFavorite,
|
||||
onTogglePlayed = onTogglePlayed,
|
||||
onOpenItem = onOpenItem,
|
||||
related = related,
|
||||
trailer = trailer,
|
||||
hideWatchedMovies = settings.hideWatchedMovies,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The layout, with everything it renders as a parameter — so it can be screenshotted and
|
||||
* previewed without a repository behind it. [related] is null while it is still coming.
|
||||
*/
|
||||
@Composable
|
||||
internal fun MediaDetailContent(
|
||||
item: BaseItem,
|
||||
onPlay: (BaseItem) -> Unit,
|
||||
onToggleFavorite: (BaseItem, Boolean) -> Unit,
|
||||
onTogglePlayed: (BaseItem, Boolean) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
related: RelatedContent? = null,
|
||||
trailer: BaseItem? = null,
|
||||
hideWatchedMovies: Boolean = false,
|
||||
onOpenItem: (BaseItem) -> Unit = {},
|
||||
) {
|
||||
val specs = remember(item.id, item.mediaStreams) { technicalSpecs(item) }
|
||||
val credits = remember(item.id, item.people, item.genres) { creditRows(item) }
|
||||
val visibleRelated = remember(related, hideWatchedMovies) {
|
||||
visibleWithWatchedPreference(related?.items.orEmpty(), hideWatchedMovies)
|
||||
}
|
||||
val badges = remember(item.id, item.mediaStreams) { mediaBadges(item) }
|
||||
val franchise = remember(item.id, item.collectionName, related?.items) {
|
||||
franchiseStart(item, related?.items.orEmpty())
|
||||
}
|
||||
val tabs = remember(item.id) { detailTabs(isSeries = false) }
|
||||
// The item arrives with whatever a home row asked for and is replaced by the full
|
||||
// record moments later. Panes say "loading" rather than "nothing here" until then.
|
||||
val detailsLoaded = item.people.isNotEmpty() || item.mediaStreams.isNotEmpty()
|
||||
|
||||
val remembered = remember(item.id) { detailPositions.get(item.id) }
|
||||
// Every newly opened title starts as a complete hero frame. Remembering a content
|
||||
// tab also restored its focus and caused the page to reopen below the artwork.
|
||||
var tabKey by remember(item.id) { mutableStateOf(DetailTab.OVERVIEW.key) }
|
||||
val selectedTab = detailTab(tabKey, tabs)
|
||||
|
||||
val play = remember(item.id) { FocusRequester() }
|
||||
val tabStrip = remember(item.id) { FocusRequester() }
|
||||
// One requester per pane. Sharing a single "information pane" requester between the
|
||||
// Overview and Cast & Details panes attached it to two nodes at once for the 80ms
|
||||
// AnimatedContent spends fading the outgoing pane out, and a Down press landing in that
|
||||
// window could request focus on the pane that is disappearing.
|
||||
val overviewPane = remember(item.id) { FocusRequester() }
|
||||
val castPane = remember(item.id) { FocusRequester() }
|
||||
val firstRelated = remember(item.id) { FocusRequester() }
|
||||
val relatedListState = rememberLazyListState(remembered.relatedIndex)
|
||||
val contentEntry = when (selectedTab) {
|
||||
DetailTab.MORE_LIKE_THIS -> firstRelated
|
||||
DetailTab.CAST_DETAILS -> castPane
|
||||
else -> overviewPane
|
||||
}
|
||||
|
||||
DetailPositionMemory(
|
||||
itemId = item.id,
|
||||
tabKey = tabKey,
|
||||
relatedIndex = { relatedListState.firstVisibleItemIndex },
|
||||
)
|
||||
RestoreDetailFocus(
|
||||
itemId = item.id,
|
||||
zone = DetailZone.PLAY,
|
||||
play = play,
|
||||
tabStrip = tabStrip,
|
||||
related = firstRelated,
|
||||
relatedReady = visibleRelated.isNotEmpty(),
|
||||
content = contentEntry,
|
||||
contentReady = true,
|
||||
)
|
||||
|
||||
var confirmation by remember(item.id) { mutableStateOf<String?>(null) }
|
||||
LaunchedEffect(confirmation) {
|
||||
if (confirmation != null) {
|
||||
delay(1_800L)
|
||||
confirmation = null
|
||||
}
|
||||
}
|
||||
|
||||
DetailPageScaffold(
|
||||
item = item,
|
||||
facts = heroFacts(item),
|
||||
badges = badges + listOfNotNull(airingBadgeLabel(item)),
|
||||
tabs = tabs,
|
||||
selectedTab = selectedTab,
|
||||
onSelectTab = { tabKey = it.key },
|
||||
playLabel = primaryActionLabel(item),
|
||||
onPlay = { onPlay(item) },
|
||||
playFocusRequester = play,
|
||||
tabFocusRequester = tabStrip,
|
||||
contentFocusRequester = contentEntry,
|
||||
modifier = modifier,
|
||||
progress = playbackProgress(item),
|
||||
progressLabel = remainingLabel(item),
|
||||
reasons = related?.reasons?.takeIf(List<String>::isNotEmpty)
|
||||
?: listOfNotNull(item.membyRecommendationReason?.takeIf(String::isNotBlank)),
|
||||
confirmation = confirmation,
|
||||
onZoneFocused = { zone -> detailPositions.update(item.id) { it.copy(zone = zone) } },
|
||||
heroActions = buildList {
|
||||
add(
|
||||
DetailHeroAction(
|
||||
icon = if (item.isFavorite) Icons.Default.Check else Icons.Default.Add,
|
||||
description = if (item.isFavorite) "Remove from Favourites" else "Add to Favourites",
|
||||
active = item.isFavorite,
|
||||
onClick = {
|
||||
val desired = !item.isFavorite
|
||||
onToggleFavorite(item, desired)
|
||||
confirmation = if (desired) "Added to Favourites" else "Removed from Favourites"
|
||||
},
|
||||
),
|
||||
)
|
||||
trailer?.let {
|
||||
add(DetailHeroAction(Icons.Default.Movie, "Play trailer", onClick = { onPlay(it) }))
|
||||
}
|
||||
add(
|
||||
DetailHeroAction(
|
||||
icon = Icons.Default.DoneAll,
|
||||
description = if (item.userData?.played == true) "Mark unwatched" else "Mark watched",
|
||||
active = item.userData?.played == true,
|
||||
onClick = { onTogglePlayed(item, item.userData?.played != true) },
|
||||
),
|
||||
)
|
||||
// Appended because related content arrives asynchronously. Existing action
|
||||
// indices (and therefore their attached FocusRequesters) must not move.
|
||||
franchise?.takeIf { it.firstMovie.id != item.id }?.let { start ->
|
||||
add(
|
||||
DetailHeroAction(
|
||||
icon = Icons.Default.FirstPage,
|
||||
description = "Open the first ${start.name} movie, ${start.firstMovie.name}",
|
||||
label = "Start with ${start.firstMovie.name}",
|
||||
onClick = { onOpenItem(start.firstMovie) },
|
||||
),
|
||||
)
|
||||
}
|
||||
},
|
||||
) { visibleTab ->
|
||||
when (visibleTab) {
|
||||
DetailTab.OVERVIEW -> DetailOverviewPane(item, credits, overviewPane)
|
||||
DetailTab.MORE_LIKE_THIS -> DetailMoreLikeThisPane(
|
||||
items = visibleRelated,
|
||||
loading = related == null,
|
||||
onSelect = onOpenItem,
|
||||
firstFocusRequester = firstRelated,
|
||||
listState = relatedListState,
|
||||
)
|
||||
DetailTab.CAST_DETAILS -> DetailCastAndDetailsPane(
|
||||
item = item,
|
||||
credits = credits,
|
||||
specs = specs,
|
||||
detailsLoaded = detailsLoaded,
|
||||
focusRequester = castPane,
|
||||
)
|
||||
DetailTab.EPISODES -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes the page's position back to [detailPositions] as it changes.
|
||||
*
|
||||
* Kept out of the pages themselves because both need exactly this and getting it slightly
|
||||
* different in two places is how "it remembered last time" bugs start. The zone is written
|
||||
* by the scaffold's focus callbacks; this covers the two things focus does not report.
|
||||
*/
|
||||
@Composable
|
||||
internal fun DetailPositionMemory(
|
||||
itemId: String,
|
||||
tabKey: String,
|
||||
season: Int? = null,
|
||||
episodeIndex: () -> Int = { 0 },
|
||||
relatedIndex: () -> Int = { 0 },
|
||||
) {
|
||||
LaunchedEffect(itemId, tabKey, season) {
|
||||
detailPositions.update(itemId) { it.copy(tabKey = tabKey, season = season ?: it.season) }
|
||||
}
|
||||
LaunchedEffect(itemId) {
|
||||
snapshotFlow { episodeIndex() to relatedIndex() }.collect { (episode, relatedCard) ->
|
||||
detailPositions.update(itemId) {
|
||||
it.copy(episodeIndex = episode, relatedIndex = relatedCard)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Puts focus back where the viewer left it.
|
||||
*
|
||||
* Play is focused first regardless: it exists on the first frame, so the remote is live
|
||||
* while the rest of the page is still arriving. Only then does focus move on to the band
|
||||
* the viewer was actually in — and only if that band has something to land on, because a
|
||||
* request against an unplaced [FocusRequester] throws.
|
||||
*/
|
||||
@Composable
|
||||
internal fun RestoreDetailFocus(
|
||||
itemId: String,
|
||||
zone: DetailZone,
|
||||
play: FocusRequester,
|
||||
tabStrip: FocusRequester,
|
||||
related: FocusRequester,
|
||||
relatedReady: Boolean,
|
||||
content: FocusRequester? = null,
|
||||
contentReady: Boolean = false,
|
||||
) {
|
||||
LaunchedEffect(itemId) {
|
||||
delay(32L)
|
||||
runCatching { play.requestFocus() }
|
||||
}
|
||||
// One shot. The readiness flags flip when the network lands, and re-running then would
|
||||
// haul focus out from under a viewer who has already started moving around the page.
|
||||
var restored by remember(itemId) { mutableStateOf(zone == DetailZone.PLAY) }
|
||||
LaunchedEffect(itemId, relatedReady, contentReady) {
|
||||
if (restored) return@LaunchedEffect
|
||||
val target = when (zone) {
|
||||
DetailZone.PLAY -> null
|
||||
DetailZone.TABS -> tabStrip
|
||||
DetailZone.CONTENT -> content?.takeIf { contentReady }
|
||||
DetailZone.RELATED -> related.takeIf { relatedReady }
|
||||
} ?: return@LaunchedEffect
|
||||
// After Play, so the restore lands second and wins; a frame either way is
|
||||
// invisible, but the order is not.
|
||||
delay(96L)
|
||||
if (runCatching { target.requestFocus() }.isSuccess) restored = true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
package com.ponzischeme89.memby.ui
|
||||
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.tv.material3.Icon
|
||||
import androidx.tv.material3.Text
|
||||
import com.ponzischeme89.memby.ui.theme.MembyAccent
|
||||
import com.ponzischeme89.memby.ui.theme.MembyCardCorner
|
||||
import com.ponzischeme89.memby.ui.theme.MembyChipCorner
|
||||
import com.ponzischeme89.memby.ui.theme.MembyHairline
|
||||
import com.ponzischeme89.memby.ui.theme.MembyMutedText
|
||||
|
||||
/**
|
||||
* One green Play button, in two sizes.
|
||||
*
|
||||
* The app had three button languages: this one, a hand-rolled copy in the home hero with
|
||||
* the same look and different metrics, and raw `androidx.tv.material3.Button`s carrying
|
||||
* glyphs in their labels ("▶ Resume", "✓ 30 min"), which picked up theme colours nothing
|
||||
* else on those screens uses. A remote user learns one shape and one green; drawing it
|
||||
* three ways teaches them nothing and makes every metric a separate decision.
|
||||
*
|
||||
* [MembyPlayChip] is the same surface without focus behaviour when the whole hero card is
|
||||
* already the focusable node.
|
||||
*/
|
||||
@Composable
|
||||
internal fun MembyPlayButton(
|
||||
label: String,
|
||||
onClick: () -> Unit,
|
||||
onFocused: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
compact: Boolean = false,
|
||||
) {
|
||||
var focused by remember { mutableStateOf(false) }
|
||||
val scale by animateFloatAsState(if (focused) 1.055f else 1f, tween(100), label = "play-focus")
|
||||
PrimaryActionSurface(
|
||||
label = label,
|
||||
icon = Icons.Default.PlayArrow,
|
||||
focused = focused,
|
||||
compact = compact,
|
||||
modifier = modifier
|
||||
.graphicsLayer { scaleX = scale; scaleY = scale; translationY = if (focused) -3f else 0f }
|
||||
.onFocusChanged { focused = it.isFocused; if (it.isFocused) onFocused() }
|
||||
.clickable(onClick = onClick),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun MembyPlayChip(
|
||||
label: String,
|
||||
focused: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
compact: Boolean = true,
|
||||
) {
|
||||
PrimaryActionSurface(
|
||||
label = label,
|
||||
icon = Icons.Default.PlayArrow,
|
||||
focused = focused,
|
||||
compact = compact,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PrimaryActionSurface(
|
||||
label: String,
|
||||
icon: ImageVector,
|
||||
focused: Boolean,
|
||||
compact: Boolean,
|
||||
modifier: Modifier,
|
||||
) {
|
||||
val shape = RoundedCornerShape(MembyCardCorner)
|
||||
Row(
|
||||
modifier = modifier
|
||||
.shadow(if (focused) 18.dp else 7.dp, shape)
|
||||
.clip(shape)
|
||||
.background(MembyAccent)
|
||||
.border(if (focused) 2.dp else 1.dp, if (focused) Color.White else MembyAccent, shape)
|
||||
.padding(
|
||||
horizontal = if (compact) 14.dp else 23.dp,
|
||||
vertical = if (compact) 7.dp else 12.dp,
|
||||
),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
icon,
|
||||
contentDescription = null,
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(if (compact) 18.dp else 23.dp),
|
||||
)
|
||||
Spacer(Modifier.width(if (compact) 6.dp else 8.dp))
|
||||
Text(
|
||||
label,
|
||||
color = Color.White,
|
||||
fontSize = if (compact) 13.sp else 16.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A selectable chip for a small set of mutually exclusive choices. The tick is a real icon
|
||||
* on the selected chip rather than a character in the label, so the chip does not change
|
||||
* width when the choice moves.
|
||||
*/
|
||||
@Composable
|
||||
internal fun MembyChoiceChip(
|
||||
label: String,
|
||||
selected: Boolean,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
var focused by remember { mutableStateOf(false) }
|
||||
val shape = RoundedCornerShape(MembyChipCorner)
|
||||
Row(
|
||||
modifier = modifier
|
||||
.clip(shape)
|
||||
.background(
|
||||
when {
|
||||
focused -> Color.White
|
||||
selected -> MembyAccent
|
||||
else -> Color.White.copy(alpha = 0.07f)
|
||||
},
|
||||
)
|
||||
.border(1.dp, if (selected || focused) Color.Transparent else MembyHairline, shape)
|
||||
.onFocusChanged { focused = it.isFocused }
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 14.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
if (selected) {
|
||||
Icon(
|
||||
Icons.Default.Check,
|
||||
contentDescription = null,
|
||||
tint = if (focused) Color.Black else Color.White,
|
||||
modifier = Modifier.size(15.dp),
|
||||
)
|
||||
Spacer(Modifier.width(6.dp))
|
||||
}
|
||||
Text(
|
||||
label,
|
||||
color = when {
|
||||
focused -> Color.Black
|
||||
selected -> Color.White
|
||||
else -> MembyMutedText
|
||||
},
|
||||
fontSize = 13.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,442 @@
|
||||
package com.ponzischeme89.memby.ui
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Bookmark
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Notifications
|
||||
import androidx.compose.material.icons.filled.NotificationsOff
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusProperties
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.zIndex
|
||||
import androidx.tv.material3.Button
|
||||
import androidx.tv.material3.Card
|
||||
import androidx.tv.material3.Icon
|
||||
import androidx.tv.material3.Text
|
||||
import coil.compose.AsyncImage
|
||||
import com.ponzischeme89.memby.data.EmbyRepository
|
||||
import com.ponzischeme89.memby.data.model.MyShow
|
||||
import com.ponzischeme89.memby.data.model.NotificationPreferences
|
||||
import com.ponzischeme89.memby.data.model.UserNotification
|
||||
import java.time.Instant
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
|
||||
@Composable
|
||||
internal fun MyShowsStrip(
|
||||
shows: List<MyShow>,
|
||||
repository: EmbyRepository,
|
||||
availableWidth: Dp,
|
||||
density: String,
|
||||
navigationFocusRequester: FocusRequester,
|
||||
contentFocusRequester: FocusRequester?,
|
||||
onShowSelected: (MyShow) -> Unit,
|
||||
onContentFocused: () -> Unit,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(HomeRowHeaderSpacing),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 36.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
HomeRowHeaderIcon(Icons.Default.Bookmark)
|
||||
Spacer(Modifier.width(HomeRowHeaderIconGap))
|
||||
Text(
|
||||
"My Shows",
|
||||
color = Color(0xFFF1F3F4),
|
||||
fontSize = 20.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
if (shows.isNotEmpty()) {
|
||||
Text(
|
||||
shows.size.toString(),
|
||||
color = Color(0xFFAEB7BF),
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
modifier = Modifier
|
||||
.padding(start = 10.dp)
|
||||
.clip(RoundedCornerShape(20.dp))
|
||||
.background(Color.White.copy(alpha = 0.08f))
|
||||
.padding(horizontal = 8.dp, vertical = 3.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
if (shows.isEmpty()) {
|
||||
Text(
|
||||
"Open any series and choose “Add to My Shows”.",
|
||||
color = Color(0xFFAEB7BF),
|
||||
fontSize = 14.sp,
|
||||
modifier = Modifier.padding(horizontal = 36.dp, vertical = 18.dp),
|
||||
)
|
||||
} else {
|
||||
val cardsAcross = when (density) {
|
||||
"compact" -> 8
|
||||
"large" -> 5
|
||||
else -> 7
|
||||
}
|
||||
val cardWidth = responsiveRowCardWidth(availableWidth, cardsAcross, 102.dp, 218.dp)
|
||||
LazyRow(
|
||||
contentPadding = PaddingValues(horizontal = 36.dp, vertical = 10.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
items(shows, key = MyShow::itemId) { show ->
|
||||
MyShowCard(
|
||||
show = show,
|
||||
repository = repository,
|
||||
width = cardWidth,
|
||||
onClick = { onShowSelected(show) },
|
||||
modifier = Modifier
|
||||
.then(
|
||||
if (show.itemId == shows.first().itemId && contentFocusRequester != null) {
|
||||
Modifier.focusRequester(contentFocusRequester)
|
||||
} else {
|
||||
Modifier
|
||||
},
|
||||
)
|
||||
.focusProperties { left = navigationFocusRequester }
|
||||
.onFocusChanged { if (it.hasFocus) onContentFocused() },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MyShowCard(
|
||||
show: MyShow,
|
||||
repository: EmbyRepository,
|
||||
width: Dp,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
FocusScaleContainer(
|
||||
onFocused = {},
|
||||
onClick = onClick,
|
||||
contentDescription = "${show.title}, ${myShowCardSubtitle(show)}",
|
||||
modifier = modifier.width(width),
|
||||
) { focused ->
|
||||
Column {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(width)
|
||||
.aspectRatio(2f / 3f)
|
||||
.shadow(if (focused) 7.dp else 0.dp, RoundedCornerShape(9.dp))
|
||||
.clip(RoundedCornerShape(9.dp))
|
||||
.background(Color(0xFF20252A))
|
||||
.border(
|
||||
2.dp,
|
||||
if (focused) Color.White else Color.White.copy(alpha = 0.07f),
|
||||
RoundedCornerShape(9.dp),
|
||||
),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
AsyncImage(
|
||||
model = repository.myShowImageUrl(show.itemId, show.imageTag),
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Fit,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
myShowBadge(show)?.let { (label, color) ->
|
||||
Text(
|
||||
label,
|
||||
color = Color(0xFF090B0D),
|
||||
fontSize = 9.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
letterSpacing = 0.5.sp,
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopStart)
|
||||
.padding(8.dp)
|
||||
.clip(RoundedCornerShape(4.dp))
|
||||
.background(color)
|
||||
.padding(horizontal = 7.dp, vertical = 4.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
Text(
|
||||
show.title,
|
||||
color = if (focused) Color.White else Color(0xFFE1E5E8),
|
||||
fontSize = 14.sp,
|
||||
fontWeight = if (focused) FontWeight.Bold else FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.padding(top = 7.dp).fillMaxWidth(),
|
||||
)
|
||||
Text(
|
||||
myShowCardSubtitle(show),
|
||||
color = Color(0xFFAEB7BF),
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.padding(top = 2.dp).fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun myShowBadge(show: MyShow): Pair<String, Color>? = when {
|
||||
!show.nextEpisode.isNullOrBlank() -> "UPCOMING" to Color(0xFF52B54B)
|
||||
show.sonarrStatus == "Not monitored" -> "UNMONITORED" to Color(0xFFFFB454)
|
||||
show.lifecycle == "Cancelled" -> "CANCELLED" to Color(0xFFAEB7BF)
|
||||
else -> null
|
||||
}
|
||||
|
||||
internal fun myShowCardSubtitle(show: MyShow): String {
|
||||
if (!show.nextEpisode.isNullOrBlank()) {
|
||||
val date = runCatching {
|
||||
DateTimeFormatter.ofPattern("EEE, d MMM")
|
||||
.format(Instant.parse(show.nextEpisode).atZone(ZoneId.systemDefault()))
|
||||
}.getOrNull()
|
||||
if (date != null) return "Next episode · $date"
|
||||
}
|
||||
return when {
|
||||
show.lifecycle != "Unknown" -> show.lifecycle
|
||||
show.sonarrStatus != "Not found" -> show.sonarrStatus
|
||||
else -> "Saved show"
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun MyShowDetailsOverlay(
|
||||
show: MyShow,
|
||||
repository: EmbyRepository,
|
||||
removing: Boolean,
|
||||
onRemove: () -> Unit,
|
||||
onClose: () -> Unit,
|
||||
) {
|
||||
Box(
|
||||
Modifier.fillMaxSize().zIndex(8f).background(Color(0xF5090B0D)),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(0.72f)
|
||||
.background(Color(0xFF151A1E), RoundedCornerShape(20.dp))
|
||||
.padding(34.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(30.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
AsyncImage(
|
||||
model = repository.myShowImageUrl(show.itemId, show.imageTag, 500),
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Fit,
|
||||
modifier = Modifier
|
||||
.width(170.dp)
|
||||
.aspectRatio(2f / 3f)
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(Color(0xFF20252A))
|
||||
.border(1.dp, Color.White.copy(alpha = 0.12f), RoundedCornerShape(12.dp)),
|
||||
)
|
||||
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Text(show.title, color = Color.White, fontSize = 30.sp, fontWeight = FontWeight.Bold)
|
||||
StatusLine("Sonarr", show.sonarrStatus)
|
||||
StatusLine("Next episode", formatMyShowDate(show.nextEpisode))
|
||||
StatusLine("Series status", show.lifecycle)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Button(onClick = onRemove, enabled = !removing) {
|
||||
Text(if (removing) "Removing…" else "Remove from My Shows")
|
||||
}
|
||||
Button(onClick = onClose) { Text("Close") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StatusLine(label: String, value: String) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Text("$label:", color = Color(0xFFAEB7BF), fontSize = 16.sp)
|
||||
Text(value, color = Color.White, fontSize = 16.sp, fontWeight = FontWeight.SemiBold)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun NotificationBell(
|
||||
unreadCount: Int,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
var focused by remember { mutableStateOf(false) }
|
||||
Card(
|
||||
onClick = onClick,
|
||||
modifier = modifier
|
||||
.size(52.dp)
|
||||
.onFocusChanged { focused = it.hasFocus }
|
||||
.graphicsLayer {
|
||||
scaleX = if (focused) 1.08f else 1f
|
||||
scaleY = if (focused) 1.08f else 1f
|
||||
},
|
||||
) {
|
||||
Box(
|
||||
Modifier.fillMaxSize().background(if (focused) Color.White else Color(0xCC20262B)),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Notifications,
|
||||
contentDescription = "Notifications",
|
||||
tint = if (focused) Color.Black else Color.White,
|
||||
modifier = Modifier.size(25.dp),
|
||||
)
|
||||
if (unreadCount > 0) {
|
||||
Text(
|
||||
unreadCount.coerceAtMost(99).toString(),
|
||||
color = Color.White,
|
||||
fontSize = 10.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.background(Color(0xFFE04747), CircleShape)
|
||||
.padding(horizontal = 5.dp, vertical = 2.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun NotificationsOverlay(
|
||||
notifications: List<UserNotification>,
|
||||
preferences: NotificationPreferences,
|
||||
onToggleEnabled: () -> Unit,
|
||||
onToggleShowReturns: () -> Unit,
|
||||
onRead: (UserNotification) -> Unit,
|
||||
onDismissNotification: (UserNotification) -> Unit,
|
||||
onClose: () -> Unit,
|
||||
) {
|
||||
Box(Modifier.fillMaxSize().zIndex(8f).background(Color(0xF5090B0D))) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize().padding(horizontal = 72.dp, vertical = 48.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(18.dp),
|
||||
) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column {
|
||||
Text("Notifications", color = Color.White, fontSize = 32.sp, fontWeight = FontWeight.Bold)
|
||||
Text(
|
||||
"Show-return alerts are saved for this user on every Memby TV.",
|
||||
color = Color(0xFFAEB7BF),
|
||||
fontSize = 15.sp,
|
||||
)
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Button(onClick = onToggleEnabled) {
|
||||
Icon(
|
||||
if (preferences.enabled) Icons.Default.Notifications else Icons.Default.NotificationsOff,
|
||||
contentDescription = null,
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(if (preferences.enabled) "Alerts on" else "Alerts off")
|
||||
}
|
||||
Button(onClick = onToggleShowReturns, enabled = preferences.enabled) {
|
||||
Text(
|
||||
if (preferences.showReturnAlerts) {
|
||||
"Return alerts on"
|
||||
} else {
|
||||
"Return alerts off"
|
||||
},
|
||||
)
|
||||
}
|
||||
Button(onClick = onClose) {
|
||||
Icon(Icons.Default.Close, contentDescription = null)
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text("Close")
|
||||
}
|
||||
}
|
||||
}
|
||||
if (notifications.isEmpty()) {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Text("You’re all caught up.", color = Color(0xFFD0D6DB), fontSize = 20.sp)
|
||||
}
|
||||
} else {
|
||||
LazyColumn(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
items(notifications, key = UserNotification::id) { notification ->
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(
|
||||
if (notification.unread) Color(0xFF202B25) else Color(0xFF171B1E),
|
||||
RoundedCornerShape(12.dp),
|
||||
)
|
||||
.padding(18.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(18.dp),
|
||||
) {
|
||||
Box(
|
||||
Modifier.size(10.dp).background(
|
||||
if (notification.unread) Color(0xFF52B54B) else Color.Transparent,
|
||||
CircleShape,
|
||||
),
|
||||
)
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(
|
||||
notification.title,
|
||||
color = Color.White,
|
||||
fontSize = 17.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
Text(notification.message, color = Color(0xFFD0D6DB), fontSize = 15.sp)
|
||||
}
|
||||
if (notification.unread) {
|
||||
Button(onClick = { onRead(notification) }) { Text("Mark read") }
|
||||
}
|
||||
Button(onClick = { onDismissNotification(notification) }) { Text("Dismiss") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal fun formatMyShowDate(value: String?): String {
|
||||
if (value.isNullOrBlank()) return "Not announced"
|
||||
return runCatching {
|
||||
DateTimeFormatter.ofPattern("EEE, d MMM yyyy · h:mm a")
|
||||
.format(Instant.parse(value).atZone(ZoneId.systemDefault()))
|
||||
}.getOrDefault("Not announced")
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.ponzischeme89.memby.ui
|
||||
|
||||
internal const val QuickActionsHoldDurationMillis = 650L
|
||||
|
||||
internal enum class QuickActionDirection { UP, DOWN }
|
||||
|
||||
internal fun quickActionNextIndex(
|
||||
currentIndex: Int,
|
||||
actionCount: Int,
|
||||
direction: QuickActionDirection,
|
||||
): Int {
|
||||
if (actionCount <= 0) return 0
|
||||
val current = currentIndex.coerceIn(0, actionCount - 1)
|
||||
return when (direction) {
|
||||
QuickActionDirection.UP -> (current - 1).coerceAtLeast(0)
|
||||
QuickActionDirection.DOWN -> (current + 1).coerceAtMost(actionCount - 1)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package com.ponzischeme89.memby.ui
|
||||
|
||||
internal enum class RowFocusDirection {
|
||||
UP,
|
||||
DOWN,
|
||||
}
|
||||
|
||||
internal data class RowFocusRequest(
|
||||
val rowId: String,
|
||||
val itemIndex: Int,
|
||||
val requestId: Int,
|
||||
)
|
||||
|
||||
/**
|
||||
* Finds the next row that can actually receive card focus. Loading and empty rows are
|
||||
* deliberately skipped so one D-pad press always produces visible movement.
|
||||
*/
|
||||
internal fun adjacentFocusableRowIndex(
|
||||
itemCounts: List<Int>,
|
||||
currentIndex: Int,
|
||||
direction: RowFocusDirection,
|
||||
): Int? {
|
||||
if (currentIndex !in itemCounts.indices) return null
|
||||
val step = if (direction == RowFocusDirection.DOWN) 1 else -1
|
||||
var candidate = currentIndex + step
|
||||
while (candidate in itemCounts.indices) {
|
||||
if (itemCounts[candidate] > 0) return candidate
|
||||
candidate += step
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Returning to a row restores the card last used there. On a first visit, preserve the
|
||||
* viewer's horizontal position as far as the shorter destination row permits.
|
||||
*/
|
||||
internal fun rowEntryItemIndex(
|
||||
sourceIndex: Int,
|
||||
destinationItemCount: Int,
|
||||
rememberedDestinationIndex: Int?,
|
||||
): Int {
|
||||
if (destinationItemCount <= 0) return 0
|
||||
return (rememberedDestinationIndex ?: sourceIndex).coerceIn(0, destinationItemCount - 1)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,18 +3,15 @@ package com.ponzischeme89.memby.ui
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.core.FastOutSlowInEasing
|
||||
import androidx.compose.animation.core.LinearEasing
|
||||
import androidx.compose.animation.core.RepeatMode
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
import androidx.compose.animation.core.infiniteRepeatable
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.slideInVertically
|
||||
import androidx.compose.animation.slideOutVertically
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
@@ -25,8 +22,6 @@ import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.State
|
||||
@@ -37,7 +32,6 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
@@ -45,14 +39,15 @@ import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.StrokeCap
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.zIndex
|
||||
import androidx.tv.material3.Text
|
||||
import coil.compose.AsyncImage
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.ponzischeme89.memby.R
|
||||
import com.ponzischeme89.memby.ServiceLocator
|
||||
import com.ponzischeme89.memby.data.MaintenanceMonitor
|
||||
import com.ponzischeme89.memby.data.ServiceAlert
|
||||
@@ -62,8 +57,13 @@ private val AlertAccent = Color(0xFF52B54B)
|
||||
private val AlertTitle = Color(0xFFF2F5F7)
|
||||
private val AlertBody = Color(0xFFC3CBD2)
|
||||
|
||||
/** Height of the bar itself, before the rule and fade that blend it into the screen. */
|
||||
private val BannerHeight = 104.dp
|
||||
/**
|
||||
* Height of the bar itself, before the rule and fade that blend it into the screen.
|
||||
*
|
||||
* Kept to a strip rather than a panel because this now appears over playback as well as
|
||||
* over the launcher: whatever it says, it is covering somebody's film while it says it.
|
||||
*/
|
||||
private val BannerHeight = 48.dp
|
||||
|
||||
/**
|
||||
* Broadcast-style overscan inset. TVs crop the edges of the picture by a few percent, so
|
||||
@@ -103,12 +103,14 @@ fun ServiceAlertBanner(suppressed: Boolean, modifier: Modifier = Modifier) {
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = alert != null,
|
||||
// In from above the frame, out the same way. Slower arriving than leaving:
|
||||
// showing up should be noticed, going away should not.
|
||||
enter = slideInVertically(tween(420, easing = FastOutSlowInEasing)) { -it } +
|
||||
fadeIn(tween(260)),
|
||||
exit = slideOutVertically(tween(320, easing = FastOutSlowInEasing)) { -it } +
|
||||
fadeOut(tween(220)),
|
||||
// In from above the frame, out the same way, both unhurried: over a film the
|
||||
// arrival is the intrusion, so it eases in rather than snapping down. Still
|
||||
// slower arriving than leaving — showing up should be noticed, going away
|
||||
// should not.
|
||||
enter = slideInVertically(tween(680, easing = FastOutSlowInEasing)) { -it } +
|
||||
fadeIn(tween(520)),
|
||||
exit = slideOutVertically(tween(420, easing = FastOutSlowInEasing)) { -it } +
|
||||
fadeOut(tween(300)),
|
||||
modifier = modifier.zIndex(8f),
|
||||
) {
|
||||
lastAlert?.let { AlertBanner(it) }
|
||||
@@ -141,53 +143,52 @@ internal fun AlertBanner(
|
||||
.fillMaxWidth()
|
||||
.height(BannerHeight)
|
||||
.background(
|
||||
// Darkest at the left where the text sits, easing off to the right so
|
||||
// the artwork behind the banner still shows through.
|
||||
// Near-black, and darkest at the left where the text sits. Over a
|
||||
// film almost anything can be behind this, so the bar supplies its
|
||||
// own contrast rather than relying on the picture underneath.
|
||||
Brush.horizontalGradient(
|
||||
0f to Color(0xFF0E1418),
|
||||
0.55f to Color(0xF20E1418),
|
||||
1f to Color(0xD9121A20),
|
||||
0f to Color(0xFF04070A),
|
||||
0.55f to Color(0xF504070A),
|
||||
1f to Color(0xE0070B0F),
|
||||
),
|
||||
)
|
||||
.padding(horizontal = SafeAreaHorizontal),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
AlertPoster(posterUrl = alert.posterUrl)
|
||||
Spacer(Modifier.width(20.dp))
|
||||
Column(Modifier.weight(1f)) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
LivePip()
|
||||
Text(
|
||||
"JUST AIRED",
|
||||
color = AlertAccent,
|
||||
fontSize = 13.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
letterSpacing = 1.6.sp,
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(3.dp))
|
||||
// Keep the alert readable at TV distance without letting it overpower
|
||||
// the screen content beneath it.
|
||||
Text(
|
||||
alert.title,
|
||||
color = AlertTitle,
|
||||
fontSize = 20.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
alert.message,
|
||||
color = AlertBody,
|
||||
fontSize = 15.sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.width(24.dp))
|
||||
AlertMark()
|
||||
Spacer(Modifier.width(14.dp))
|
||||
// The same short green rule used by the ten-minute reminder. It makes the
|
||||
// alert feel like part of the player instead of a separate notification UI.
|
||||
Box(Modifier.width(3.dp).height(26.dp).background(AlertAccent))
|
||||
Spacer(Modifier.width(14.dp))
|
||||
Text(
|
||||
alertLabel(alert.label),
|
||||
color = AlertAccent,
|
||||
fontSize = 10.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
letterSpacing = 1.6.sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Text(
|
||||
alert.title,
|
||||
color = AlertTitle,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Spacer(Modifier.width(10.dp))
|
||||
Text(
|
||||
alert.message,
|
||||
color = AlertBody,
|
||||
fontSize = 12.sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Spacer(Modifier.width(16.dp))
|
||||
CountdownRing(fraction = { remaining.value }, secondsLeft = secondsLeft)
|
||||
}
|
||||
|
||||
@@ -196,7 +197,7 @@ internal fun AlertBanner(
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(2.dp)
|
||||
.height(1.dp)
|
||||
.background(
|
||||
Brush.horizontalGradient(
|
||||
listOf(AlertAccent, AlertAccent.copy(alpha = 0.35f), Color.Transparent),
|
||||
@@ -206,14 +207,25 @@ internal fun AlertBanner(
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(22.dp)
|
||||
.height(10.dp)
|
||||
.background(
|
||||
Brush.verticalGradient(listOf(Color(0x99000000), Color.Transparent)),
|
||||
Brush.verticalGradient(listOf(Color(0x66000000), Color.Transparent)),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The eyebrow above the title. The server picks the wording so it can announce something
|
||||
* this build has no name for; a bare alert — or one from a server older than the field —
|
||||
* keeps the original episode wording, and an over-long label is cut rather than allowed
|
||||
* to push the countdown off the bar.
|
||||
*/
|
||||
internal fun alertLabel(label: String): String =
|
||||
label.trim().ifEmpty { "JUST AIRED" }.take(MaxAlertLabelChars).uppercase()
|
||||
|
||||
private const val MaxAlertLabelChars = 24
|
||||
|
||||
/**
|
||||
* A ring that empties as the banner's time runs out, with the seconds left inside it.
|
||||
*
|
||||
@@ -228,9 +240,9 @@ internal fun AlertBanner(
|
||||
*/
|
||||
@Composable
|
||||
private fun CountdownRing(fraction: () -> Float, secondsLeft: State<Int>) {
|
||||
Box(Modifier.size(46.dp), contentAlignment = Alignment.Center) {
|
||||
Box(Modifier.size(26.dp), contentAlignment = Alignment.Center) {
|
||||
Canvas(Modifier.fillMaxSize()) {
|
||||
val stroke = 3.dp.toPx()
|
||||
val stroke = 2.dp.toPx()
|
||||
val inset = stroke / 2f
|
||||
val arcSize = Size(size.width - stroke, size.height - stroke)
|
||||
drawArc(
|
||||
@@ -257,60 +269,30 @@ private fun CountdownRing(fraction: () -> Float, secondsLeft: State<Int>) {
|
||||
Text(
|
||||
secondsLeft.value.coerceAtLeast(0).toString(),
|
||||
color = AlertBody,
|
||||
fontSize = 16.sp,
|
||||
fontSize = 9.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Poster when the server named one; a quiet accent tile when it did not. */
|
||||
/**
|
||||
* The Emby mark, which is what every one of these banners is really speaking for —
|
||||
* a new film, a finished refresh, a server that stopped answering.
|
||||
*
|
||||
* It replaced the item poster deliberately: artwork made each alert look like a different
|
||||
* feature, and half of them (a library refresh, an outage) have no artwork to show. One
|
||||
* constant mark says "this is your server talking" in the width of a thumbnail.
|
||||
*/
|
||||
@Composable
|
||||
private fun AlertPoster(posterUrl: String?) {
|
||||
val shape = RoundedCornerShape(6.dp)
|
||||
Box(
|
||||
private fun AlertMark() {
|
||||
Image(
|
||||
painter = painterResource(R.drawable.emby_logo),
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Fit,
|
||||
modifier = Modifier
|
||||
.width(50.dp)
|
||||
.height(74.dp)
|
||||
.clip(shape)
|
||||
.background(Color(0xFF18222B)),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
if (posterUrl != null) {
|
||||
AsyncImage(
|
||||
model = posterUrl,
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
} else {
|
||||
Box(
|
||||
Modifier
|
||||
.size(20.dp)
|
||||
.clip(CircleShape)
|
||||
.background(AlertAccent.copy(alpha = 0.30f)),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A slow pulse — enough motion to read as "news", cheap enough for a TV GPU. */
|
||||
@Composable
|
||||
private fun LivePip() {
|
||||
val alpha = androidx.compose.animation.core.rememberInfiniteTransition(label = "alert-pip")
|
||||
.animateFloat(
|
||||
initialValue = 0.35f,
|
||||
targetValue = 1f,
|
||||
animationSpec = infiniteRepeatable(
|
||||
tween(1_400, easing = LinearEasing),
|
||||
RepeatMode.Reverse,
|
||||
),
|
||||
label = "alert-pip-alpha",
|
||||
)
|
||||
// Deliberately not `by`: the alpha is read inside the draw lambda, so the pulse
|
||||
// repaints eight dp of circle rather than recomposing the row that holds it.
|
||||
Canvas(Modifier.size(8.dp)) {
|
||||
drawCircle(color = AlertAccent.copy(alpha = alpha.value))
|
||||
}
|
||||
.width(30.dp)
|
||||
.height(26.dp),
|
||||
)
|
||||
}
|
||||
|
||||
// Previews render the bar directly rather than through ServiceAlertBanner: the wrapper's
|
||||
@@ -327,7 +309,38 @@ private fun ServiceAlertBannerPreview() {
|
||||
id = "sonarr:7:42:aired",
|
||||
title = "Northbound",
|
||||
message = "S02E04 — The Crossing aired at 9:00 PM and will be in Emby soon.",
|
||||
posterUrl = null,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** The other news the bar carries: Radarr finished importing a film. */
|
||||
@TvPreview
|
||||
@Composable
|
||||
private fun ServiceAlertBannerMovieAddedPreview() {
|
||||
PreviewSurface(alignment = Alignment.TopCenter) {
|
||||
AlertBanner(
|
||||
ServiceAlert(
|
||||
id = "radarr:412:file:9001",
|
||||
title = "Mr. Smith Goes to Washington (1939)",
|
||||
message = "Mr. Smith Goes to Washington will be available in Emby shortly.",
|
||||
label = "NEW MOVIE ADDED",
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** News about the service rather than the catalogue — the kind seen during a film. */
|
||||
@TvPreview
|
||||
@Composable
|
||||
private fun ServiceAlertBannerServerDownPreview() {
|
||||
PreviewSurface(alignment = Alignment.TopCenter) {
|
||||
AlertBanner(
|
||||
ServiceAlert(
|
||||
id = "emby:down:1785012345",
|
||||
title = "Emby has stopped communicating",
|
||||
message = "Playback may stop until it is back. Memby will say when it returns.",
|
||||
label = "SERVER NOT RESPONDING",
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -344,7 +357,6 @@ private fun ServiceAlertBannerLongTitlePreview() {
|
||||
title = "A Very Long Programme Title That Will Not Fit On One Line At All",
|
||||
message = "S11E03 — The One Where Absolutely Everything Happens At Once " +
|
||||
"aired at 10:30 PM and is downloading now.",
|
||||
posterUrl = null,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
package com.ponzischeme89.memby.ui
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.core.graphics.get
|
||||
import androidx.core.graphics.drawable.toBitmap
|
||||
import coil.imageLoader
|
||||
import coil.request.ImageRequest
|
||||
import coil.request.SuccessResult
|
||||
|
||||
/**
|
||||
* Whether a title's logo artwork should be replaced by its plain-text name.
|
||||
*
|
||||
* Transparent Emby logos are commonly black. They disappear over a dark backdrop, so a
|
||||
* small decoded copy is inspected and the text title retained when the visible pixels are
|
||||
* overwhelmingly dark. Until the image has been inspected, text is the safe default — a
|
||||
* title that arrives a frame late is better than a title that never appears.
|
||||
*
|
||||
* Shared by the screensaver and the detail hero: both draw a title over a near-black scrim,
|
||||
* and a logo that is invisible on one is invisible on the other.
|
||||
*/
|
||||
@Composable
|
||||
internal fun useTextTitleForLogo(logoUrl: String?): Boolean {
|
||||
if (logoUrl == null) return true
|
||||
val context = LocalContext.current
|
||||
val isDark by produceState(initialValue = true, logoUrl) {
|
||||
value = runCatching {
|
||||
val result = context.imageLoader.execute(
|
||||
ImageRequest.Builder(context)
|
||||
.data(logoUrl)
|
||||
.allowHardware(false)
|
||||
.size(64, 64)
|
||||
.build(),
|
||||
) as? SuccessResult ?: return@runCatching true
|
||||
isPredominantlyDarkLogo(result.drawable.toBitmap(width = 64, height = 64))
|
||||
}.getOrDefault(true)
|
||||
}
|
||||
return isDark
|
||||
}
|
||||
|
||||
private fun isPredominantlyDarkLogo(bitmap: android.graphics.Bitmap): Boolean {
|
||||
var opaquePixels = 0
|
||||
var darkPixels = 0
|
||||
for (y in 0 until bitmap.height step 2) {
|
||||
for (x in 0 until bitmap.width step 2) {
|
||||
val pixel = bitmap[x, y]
|
||||
if (android.graphics.Color.alpha(pixel) < 48) continue
|
||||
opaquePixels++
|
||||
val luminance = (
|
||||
android.graphics.Color.red(pixel) * 0.2126f +
|
||||
android.graphics.Color.green(pixel) * 0.7152f +
|
||||
android.graphics.Color.blue(pixel) * 0.0722f
|
||||
)
|
||||
if (luminance < 58f) darkPixels++
|
||||
}
|
||||
}
|
||||
return opaquePixels < 12 || darkPixels.toFloat() / opaquePixels > 0.82f
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.ponzischeme89.memby.ui
|
||||
|
||||
import android.os.Build
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.animation.core.LinearEasing
|
||||
import androidx.compose.animation.core.RepeatMode
|
||||
@@ -30,6 +31,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ArrowDownward
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
@@ -40,20 +42,26 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusProperties
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import androidx.tv.material3.Icon
|
||||
import androidx.tv.material3.Text
|
||||
import com.ponzischeme89.memby.data.model.GatewayUpdate
|
||||
import com.ponzischeme89.memby.update.InstallPermissionRequiredException
|
||||
import com.ponzischeme89.memby.update.UpdateChecker
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@@ -63,7 +71,8 @@ private val UpdateBody = Color(0xFFAEB7BF)
|
||||
private val UpdateFaint = Color(0xFFA2ADB5)
|
||||
|
||||
/**
|
||||
* The update prompt, shown over the home screen.
|
||||
* The app-level update gate. AppRoot composes this instead of login, profiles, or Home,
|
||||
* so no focused media card or playback action exists behind its buttons.
|
||||
*
|
||||
* A **mandatory** update covers everything and cannot be dismissed: Back is swallowed and
|
||||
* there is one button. The operator has decided this build may no longer be used, so
|
||||
@@ -81,13 +90,53 @@ fun UpdateScreen(
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val checker = remember { UpdateChecker(context) }
|
||||
|
||||
var installing by remember { mutableStateOf(false) }
|
||||
var message by remember { mutableStateOf<String?>(null) }
|
||||
var waitingForInstallPermission by remember { mutableStateOf(false) }
|
||||
|
||||
fun beginInstall() {
|
||||
if (installing) return
|
||||
installing = true
|
||||
message = null
|
||||
scope.launch {
|
||||
val result = checker.downloadAndInstall(
|
||||
apkUrl = update.downloadUrl,
|
||||
token = "",
|
||||
expectedVersion = update.version,
|
||||
expectedSHA256 = update.sha256,
|
||||
expectedSizeBytes = update.sizeBytes,
|
||||
)
|
||||
installing = false
|
||||
waitingForInstallPermission =
|
||||
result.exceptionOrNull() is InstallPermissionRequiredException
|
||||
message = result.exceptionOrNull()?.message ?: "Opening the installer…"
|
||||
}
|
||||
}
|
||||
|
||||
// Android's permission screen pauses Memby. Once the viewer grants permission and
|
||||
// returns, continue automatically instead of making them discover they must press
|
||||
// Update now for a second time.
|
||||
DisposableEffect(lifecycleOwner, waitingForInstallPermission, update.version) {
|
||||
val observer = LifecycleEventObserver { _, event ->
|
||||
if (event == Lifecycle.Event.ON_RESUME &&
|
||||
waitingForInstallPermission &&
|
||||
(Build.VERSION.SDK_INT < Build.VERSION_CODES.O ||
|
||||
context.packageManager.canRequestPackageInstalls())
|
||||
) {
|
||||
waitingForInstallPermission = false
|
||||
beginInstall()
|
||||
}
|
||||
}
|
||||
lifecycleOwner.lifecycle.addObserver(observer)
|
||||
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
|
||||
}
|
||||
|
||||
val primaryFocus = remember { FocusRequester() }
|
||||
val laterFocus = remember { FocusRequester() }
|
||||
LaunchedEffect(update.version) { runCatching { primaryFocus.requestFocus() } }
|
||||
|
||||
// Swallow Back entirely while an update is required. For an optional prompt, Back is
|
||||
@@ -120,13 +169,7 @@ fun UpdateScreen(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
// Opaque, not a scrim: a required update is not a dialog over usable content.
|
||||
.background(Color(0xFF0B0E11))
|
||||
// Consumes clicks so nothing behind can be reached.
|
||||
.clickable(
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = null,
|
||||
onClick = {},
|
||||
),
|
||||
.background(Color(0xFF0B0E11)),
|
||||
) {
|
||||
Canvas(Modifier.fillMaxSize()) {
|
||||
val centre = Offset(size.width * (0.5f + 0.06f * drift), size.height * 0.34f)
|
||||
@@ -209,7 +252,7 @@ fun UpdateScreen(
|
||||
// easy to back out of by accident.
|
||||
"Choose Update now — Memby downloads the new version, then your TV asks you " +
|
||||
"to confirm the install. If it asks permission to install apps, allow it and " +
|
||||
"the update continues.",
|
||||
"the update continues. Your profiles and sign-in stay on this TV.",
|
||||
color = UpdateFaint,
|
||||
fontSize = 14.sp,
|
||||
textAlign = TextAlign.Center,
|
||||
@@ -222,21 +265,23 @@ fun UpdateScreen(
|
||||
label = if (installing) "Downloading…" else "Update now",
|
||||
primary = true,
|
||||
enabled = !installing,
|
||||
onClick = {
|
||||
if (installing) return@UpdateButton
|
||||
installing = true
|
||||
message = null
|
||||
scope.launch {
|
||||
val result = checker.downloadAndInstall(update.downloadUrl, token = "")
|
||||
installing = false
|
||||
message = result.exceptionOrNull()?.message
|
||||
?: "Opening the installer…"
|
||||
}
|
||||
},
|
||||
modifier = Modifier.focusRequester(primaryFocus),
|
||||
onClick = ::beginInstall,
|
||||
modifier = Modifier
|
||||
.focusRequester(primaryFocus)
|
||||
.focusProperties {
|
||||
if (!update.isMandatory) right = laterFocus
|
||||
},
|
||||
)
|
||||
if (!update.isMandatory) {
|
||||
UpdateButton(label = "Later", primary = false, enabled = true, onClick = onDismiss)
|
||||
UpdateButton(
|
||||
label = "Not now",
|
||||
primary = false,
|
||||
enabled = true,
|
||||
onClick = onDismiss,
|
||||
modifier = Modifier
|
||||
.focusRequester(laterFocus)
|
||||
.focusProperties { left = primaryFocus },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -252,6 +297,13 @@ fun UpdateScreen(
|
||||
color = UpdateFaint.copy(alpha = 0.75f),
|
||||
fontSize = 13.sp,
|
||||
)
|
||||
} else {
|
||||
Spacer(Modifier.height(18.dp))
|
||||
Text(
|
||||
"Press Back or choose Not now to dismiss this update.",
|
||||
color = UpdateFaint.copy(alpha = 0.82f),
|
||||
fontSize = 13.sp,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -290,6 +342,25 @@ private fun UpdateButton(
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
)
|
||||
.onFocusChanged { focused = it.isFocused }
|
||||
// Some Android TV launchers/remotes do not turn a Foundation click target's
|
||||
// centre key into a click consistently. Consume the activation keys here and
|
||||
// invoke once on key-up, while retaining clickable for accessibility/pointer
|
||||
// input.
|
||||
.onPreviewKeyEvent { event ->
|
||||
val native = event.nativeKeyEvent
|
||||
val activationKey =
|
||||
native.keyCode == android.view.KeyEvent.KEYCODE_DPAD_CENTER ||
|
||||
native.keyCode == android.view.KeyEvent.KEYCODE_ENTER ||
|
||||
native.keyCode == android.view.KeyEvent.KEYCODE_NUMPAD_ENTER
|
||||
if (!activationKey) {
|
||||
false
|
||||
} else {
|
||||
if (enabled && native.action == android.view.KeyEvent.ACTION_UP) {
|
||||
onClick()
|
||||
}
|
||||
true
|
||||
}
|
||||
}
|
||||
.focusable(interactionSource = remember { MutableInteractionSource() })
|
||||
.clickable(enabled = enabled, onClick = onClick)
|
||||
.padding(horizontal = 30.dp, vertical = 13.dp),
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.ponzischeme89.memby.ui
|
||||
|
||||
internal enum class UserSwitcherDirection { UP, DOWN }
|
||||
|
||||
/**
|
||||
* Profiles occupy [0, profileCount); the pinned Manage users action is always the final
|
||||
* index. Keeping this arithmetic outside Compose makes remote navigation deterministic.
|
||||
*/
|
||||
internal fun userSwitcherInitialIndex(
|
||||
profileIds: List<String>,
|
||||
activeProfileId: String?,
|
||||
): Int = profileIds.indexOf(activeProfileId).takeIf { it >= 0 } ?: 0
|
||||
|
||||
internal fun userSwitcherNextIndex(
|
||||
currentIndex: Int,
|
||||
profileCount: Int,
|
||||
direction: UserSwitcherDirection,
|
||||
): Int {
|
||||
val manageIndex = profileCount.coerceAtLeast(0)
|
||||
val current = currentIndex.coerceIn(0, manageIndex)
|
||||
return when (direction) {
|
||||
UserSwitcherDirection.UP -> (current - 1).coerceAtLeast(0)
|
||||
UserSwitcherDirection.DOWN -> (current + 1).coerceAtMost(manageIndex)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package com.ponzischeme89.memby.ui
|
||||
|
||||
import kotlin.random.Random
|
||||
|
||||
internal enum class WelcomeQuoteStyle(
|
||||
val value: String,
|
||||
val label: String,
|
||||
) {
|
||||
NEUTRAL("neutral", "Neutral"),
|
||||
POSITIVE("positive", "Positive"),
|
||||
HOMICIDAL("homicidal", "Homicidal"),
|
||||
;
|
||||
|
||||
companion object {
|
||||
fun from(value: String?): WelcomeQuoteStyle =
|
||||
entries.firstOrNull { it.value.equals(value, ignoreCase = true) } ?: NEUTRAL
|
||||
}
|
||||
}
|
||||
|
||||
private val WelcomeQuotes = mapOf(
|
||||
WelcomeQuoteStyle.NEUTRAL to listOf(
|
||||
"The sofa has been expecting you.",
|
||||
"Your watchlist remains impressively optimistic.",
|
||||
"Everything is ready. Decision-making is now your problem.",
|
||||
"Welcome back. The pixels have been briefed.",
|
||||
"No judgement. Even if you pick that again.",
|
||||
),
|
||||
WelcomeQuoteStyle.POSITIVE to listOf(
|
||||
"Excellent choice showing up. The rest should be easy.",
|
||||
"You bring the snacks; Memby will bring the good bits.",
|
||||
"Tonight has strong main-character energy.",
|
||||
"Your next favourite thing might be one click away.",
|
||||
"Settle in. You’ve earned the good seat.",
|
||||
),
|
||||
WelcomeQuoteStyle.HOMICIDAL to listOf(
|
||||
"Welcome back. I kept your spot. Nobody argued twice.",
|
||||
"Pick something cheerful. I’ve already hidden the evidence.",
|
||||
"The remote knows what it did.",
|
||||
"Your watchlist is safe. The witnesses, less so.",
|
||||
"Relax. Everything is under control, allegedly.",
|
||||
),
|
||||
)
|
||||
|
||||
internal fun randomWelcomeQuote(
|
||||
styleValue: String?,
|
||||
random: Random = Random.Default,
|
||||
): String {
|
||||
val quotes = WelcomeQuotes.getValue(WelcomeQuoteStyle.from(styleValue))
|
||||
return quotes[random.nextInt(quotes.size)]
|
||||
}
|
||||
|
||||
internal fun loginWelcomeMessage(
|
||||
username: String,
|
||||
styleValue: String? = WelcomeQuoteStyle.NEUTRAL.value,
|
||||
random: Random = Random.Default,
|
||||
): String {
|
||||
val name = username.trim().ifBlank { "there" }
|
||||
return "Welcome to Memby, $name. ${randomWelcomeQuote(styleValue, random)}"
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.ponzischeme89.memby.ui.detail
|
||||
|
||||
import com.ponzischeme89.memby.data.model.BaseItem
|
||||
import com.ponzischeme89.memby.ui.theme.ValueSeparator
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
@@ -15,6 +16,15 @@ import java.util.Locale
|
||||
/** Emby stores durations and positions as 100-ns ticks. */
|
||||
private const val TICKS_PER_MINUTE = 600_000_000L
|
||||
|
||||
/**
|
||||
* The video width at which a file is called 4K, by the spec row and the card badge alike.
|
||||
*
|
||||
* UHD is 3840 wide and stays 3840 wide however hard the frame is cropped for scope, so the
|
||||
* threshold sits just under it. The badge and the `(4K)` suffix used to disagree — 3800 and
|
||||
* 3400 — and a 3600-wide file was 4K on one screen and not the other.
|
||||
*/
|
||||
const val UHD_MIN_WIDTH = 3_800
|
||||
|
||||
/** "2h 14m", "47m". Never "0m" — callers pass a positive runtime or nothing. */
|
||||
fun formatRuntime(minutes: Int): String {
|
||||
val hours = minutes / 60
|
||||
@@ -52,27 +62,100 @@ fun remainingLabel(item: BaseItem): String? {
|
||||
return "${formatPosition(left)} left"
|
||||
}
|
||||
|
||||
/** The headline row under a movie title: year, runtime, certificate, score, genres. */
|
||||
fun movieFacts(item: BaseItem): List<String> = buildList {
|
||||
/**
|
||||
* The quiet line directly under the title: year, length, certificate. Deliberately short —
|
||||
* the score sits beside the title and the genres are a credit row, so this stays at three
|
||||
* items and never has to compete for the width.
|
||||
*
|
||||
* [seasonCount] replaces the runtime for a series; pass 0 for anything else.
|
||||
*/
|
||||
fun heroFacts(item: BaseItem, seasonCount: Int = 0): List<String> = buildList {
|
||||
item.productionYear?.let { add(it.toString()) }
|
||||
item.runtimeMinutes?.let { add(formatRuntime(it)) }
|
||||
if (seasonCount > 0) {
|
||||
add("$seasonCount ${if (seasonCount == 1) "Season" else "Seasons"}")
|
||||
} else {
|
||||
item.runtimeMinutes?.let { add(formatRuntime(it)) }
|
||||
}
|
||||
item.officialRating?.takeIf(String::isNotBlank)?.let(::add)
|
||||
item.communityRating?.let { add("★ ${String.format(Locale.US, "%.1f", it)}") }
|
||||
item.genres.take(3).joinToString(" · ").takeIf(String::isNotBlank)?.let(::add)
|
||||
}
|
||||
|
||||
/** The same row for a series, where seasons replace runtime. */
|
||||
fun seriesFacts(item: BaseItem, seasonCount: Int): List<String> = buildList {
|
||||
item.productionYear?.let { add(it.toString()) }
|
||||
if (seasonCount > 0) add("$seasonCount ${if (seasonCount == 1) "Season" else "Seasons"}")
|
||||
item.officialRating?.takeIf(String::isNotBlank)?.let(::add)
|
||||
item.communityRating?.let { add("★ ${String.format(Locale.US, "%.1f", it)}") }
|
||||
item.genres.take(3).joinToString(" · ").takeIf(String::isNotBlank)?.let(::add)
|
||||
}
|
||||
/** "8.9" for the score beside the title, or null when Emby has no community rating. */
|
||||
fun ratingLabel(item: BaseItem): String? =
|
||||
item.communityRating?.let { String.format(Locale.US, "%.1f", it) }
|
||||
|
||||
/** A label/value pair for the quiet technical area. */
|
||||
data class TechnicalSpec(val label: String, val value: String)
|
||||
|
||||
/**
|
||||
* The label/value credits block: who is in it, who made it, what it is. Kept separate from
|
||||
* [technicalSpecs] because these answer "do I want this?" and those answer "will it play?".
|
||||
*/
|
||||
fun creditRows(item: BaseItem): List<TechnicalSpec> = buildList {
|
||||
peopleNamed(item, "Actor", limit = 4)?.let { add(TechnicalSpec("Starring", it)) }
|
||||
val directors = peopleNamed(item, "Director", limit = 2)
|
||||
val creators = peopleNamed(item, "Writer", limit = 2)
|
||||
when {
|
||||
directors != null -> add(TechnicalSpec("Directed by", directors))
|
||||
creators != null -> add(TechnicalSpec("Written by", creators))
|
||||
else -> Unit
|
||||
}
|
||||
item.genres.take(3).joinToString(", ").takeIf(String::isNotBlank)
|
||||
?.let { add(TechnicalSpec("Genre", it)) }
|
||||
item.studios.map { it.name }.filter(String::isNotBlank).take(2)
|
||||
.takeIf(List<String>::isNotEmpty)
|
||||
?.let { add(TechnicalSpec("Studio", it.joinToString(", "))) }
|
||||
}
|
||||
|
||||
private fun peopleNamed(item: BaseItem, type: String, limit: Int): String? =
|
||||
item.people
|
||||
.filter { it.type.equals(type, ignoreCase = true) }
|
||||
.map { it.name }
|
||||
.filter(String::isNotBlank)
|
||||
.distinct()
|
||||
.take(limit)
|
||||
.takeIf(List<String>::isNotEmpty)
|
||||
?.joinToString(", ")
|
||||
|
||||
/**
|
||||
* The sections a detail page can show, in the order the strip lists them.
|
||||
*
|
||||
* Overview is always present and always first: it is the only one guaranteed to have
|
||||
* something in it, so it is what the page can safely open on.
|
||||
*/
|
||||
enum class DetailTab(val key: String, val label: String) {
|
||||
OVERVIEW("overview", "Overview"),
|
||||
MORE_LIKE_THIS("more-like-this", "More Like This"),
|
||||
EPISODES("episodes", "Episodes"),
|
||||
CAST_DETAILS("cast-details", "Cast & Details"),
|
||||
}
|
||||
|
||||
/**
|
||||
* The strip for one item, decided by *what the item is* — never by what has arrived from
|
||||
* the network so far.
|
||||
*
|
||||
* This used to offer only the sections that already had content, on the reasoning that an
|
||||
* empty tab is worse than a missing one. On a real TV it was worse than either: a movie
|
||||
* opens with its list-row metadata, so Cast and Details appeared a second later and shoved
|
||||
* the strip sideways under the viewer's thumb, and a series that failed to load its
|
||||
* episodes lost a tab the household knows is there. A strip that depends only on
|
||||
* [isSeries] is decided on the first frame and never moves again; a section with nothing
|
||||
* in it yet says so in its own pane, where saying so costs nobody a keypress.
|
||||
*/
|
||||
fun detailTabs(isSeries: Boolean): List<DetailTab> = buildList {
|
||||
add(DetailTab.OVERVIEW)
|
||||
if (isSeries) add(DetailTab.EPISODES)
|
||||
add(DetailTab.MORE_LIKE_THIS)
|
||||
add(DetailTab.CAST_DETAILS)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a remembered tab key against what is on offer. The strip no longer changes
|
||||
* under a page, so this only has to catch a key remembered from an item of the other kind
|
||||
* — a series' Episodes tab carried over to a movie.
|
||||
*/
|
||||
fun detailTab(key: String, available: List<DetailTab>): DetailTab =
|
||||
available.firstOrNull { it.key == key } ?: DetailTab.OVERVIEW
|
||||
|
||||
/**
|
||||
* Resolution, codecs and studio — the things a viewer checks before settling in, kept out
|
||||
* of the headline because none of them decide what to watch.
|
||||
@@ -92,7 +175,7 @@ fun technicalSpecs(item: BaseItem): List<TechnicalSpec> {
|
||||
stream.codec?.uppercase(Locale.US),
|
||||
dynamicRangeLabel(stream.videoRange, stream.videoRangeType, stream.title),
|
||||
).takeIf(List<String>::isNotEmpty)?.let {
|
||||
add(TechnicalSpec("Codec", it.joinToString(" · ")))
|
||||
add(TechnicalSpec("Codec", it.joinToString(ValueSeparator)))
|
||||
}
|
||||
}
|
||||
audio?.let { stream ->
|
||||
@@ -101,7 +184,7 @@ fun technicalSpecs(item: BaseItem): List<TechnicalSpec> {
|
||||
stream.channels?.let(::channelLabel),
|
||||
stream.language?.takeIf(String::isNotBlank),
|
||||
).takeIf(List<String>::isNotEmpty)?.let {
|
||||
add(TechnicalSpec("Audio", it.joinToString(" · ")))
|
||||
add(TechnicalSpec("Audio", it.joinToString(ValueSeparator)))
|
||||
}
|
||||
}
|
||||
if (subtitles > 0) {
|
||||
@@ -114,14 +197,19 @@ fun technicalSpecs(item: BaseItem): List<TechnicalSpec> {
|
||||
}
|
||||
|
||||
private fun resolutionSuffix(width: Int): String = when {
|
||||
width >= 3_400 -> " (4K)"
|
||||
width >= UHD_MIN_WIDTH -> " (4K)"
|
||||
width >= 2_500 -> " (1440p)"
|
||||
width >= 1_800 -> " (1080p)"
|
||||
width >= 1_200 -> " (720p)"
|
||||
else -> ""
|
||||
}
|
||||
|
||||
private fun dynamicRangeLabel(range: String?, rangeType: String?, title: String?): String? {
|
||||
/**
|
||||
* "Dolby Vision", "HDR10+" or "HDR" from whatever Emby happened to fill in, or null for an
|
||||
* ordinary SDR file. Shared with the card badges so the two never name the same file
|
||||
* differently — HDR10+ used to collapse to a plain "HDR" badge.
|
||||
*/
|
||||
internal fun dynamicRangeLabel(range: String?, rangeType: String?, title: String?): String? {
|
||||
val haystack = listOfNotNull(range, rangeType, title).joinToString(" ").lowercase(Locale.US)
|
||||
return when {
|
||||
"dolby vision" in haystack || "dovi" in haystack -> "Dolby Vision"
|
||||
@@ -139,6 +227,26 @@ private fun channelLabel(channels: Int): String = when (channels) {
|
||||
else -> "${channels}ch"
|
||||
}
|
||||
|
||||
/** The canonical first film in an Emby collection, when the collection has siblings. */
|
||||
data class FranchiseStart(val name: String, val firstMovie: BaseItem)
|
||||
|
||||
fun franchiseStart(item: BaseItem, related: List<BaseItem>): FranchiseStart? {
|
||||
if (!item.isMovie) return null
|
||||
val collection = item.collectionName?.trim()?.takeIf(String::isNotEmpty) ?: return null
|
||||
val movies = (listOf(item) + related)
|
||||
.asSequence()
|
||||
.filter { candidate ->
|
||||
candidate.isMovie && candidate.collectionName?.trim().equals(collection, ignoreCase = true)
|
||||
}
|
||||
.distinctBy(BaseItem::id)
|
||||
.toList()
|
||||
if (movies.size < 2) return null
|
||||
val first = movies.minWithOrNull(
|
||||
compareBy<BaseItem>({ it.productionYear ?: Int.MAX_VALUE }, { it.name.lowercase(Locale.US) }),
|
||||
) ?: return null
|
||||
return FranchiseStart(collection, first)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Series structure
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -196,7 +304,7 @@ fun episodeLabel(episode: BaseItem): String? {
|
||||
/** "S2 E4 · The Crossing", falling back to whichever half is known. */
|
||||
fun episodeHeadline(episode: BaseItem): String =
|
||||
listOfNotNull(episodeLabel(episode), episode.name.takeIf(String::isNotBlank))
|
||||
.joinToString(" · ")
|
||||
.joinToString(ValueSeparator)
|
||||
|
||||
/**
|
||||
* The primary button. Series pass their next episode so the button can name it; a movie
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
package com.ponzischeme89.memby.ui.detail
|
||||
|
||||
/**
|
||||
* Where a viewer was on a detail page, so that leaving and coming back does not undo it.
|
||||
*
|
||||
* A detail page is an overlay: closing it removes the composable entirely, which takes
|
||||
* `rememberSaveable` with it. Without somewhere outside the composition to keep this,
|
||||
* pressing Back on episode 9 of season 3 and opening the show again lands on Overview,
|
||||
* season 1, with focus on Play — every single time.
|
||||
*
|
||||
* Deliberately process-scoped rather than persisted. It is a convenience within a sitting;
|
||||
* a TV switched on the next morning should open a show where the *show* is up to, which is
|
||||
* what [defaultSeason] already decides.
|
||||
*/
|
||||
data class DetailPosition(
|
||||
val tabKey: String = DetailTab.OVERVIEW.key,
|
||||
/** Null means "not chosen yet" — [defaultSeason] still gets to pick. */
|
||||
val season: Int? = null,
|
||||
val zone: DetailZone = DetailZone.PLAY,
|
||||
val episodeIndex: Int = 0,
|
||||
val relatedIndex: Int = 0,
|
||||
)
|
||||
|
||||
/**
|
||||
* Which band of the page held focus. Restoring the *tab* without the band is worse than
|
||||
* restoring neither: the viewer left with focus on an episode and comes back to a page
|
||||
* that looks identical but answers Down differently.
|
||||
*/
|
||||
enum class DetailZone { PLAY, TABS, CONTENT, RELATED }
|
||||
|
||||
/**
|
||||
* A small, capped, most-recently-used store of positions keyed by item id.
|
||||
*
|
||||
* Capped because a long browse would otherwise accumulate one entry per poster the viewer
|
||||
* pressed OK on, and none of them matter once they have scrolled out of memory.
|
||||
*/
|
||||
class DetailPositionStore(private val maxEntries: Int = DEFAULT_MAX_ENTRIES) {
|
||||
private val positions = LinkedHashMap<String, DetailPosition>(maxEntries, 0.75f, true)
|
||||
|
||||
@Synchronized
|
||||
fun get(itemId: String): DetailPosition = positions[itemId] ?: DetailPosition()
|
||||
|
||||
@Synchronized
|
||||
fun update(itemId: String, transform: (DetailPosition) -> DetailPosition) {
|
||||
if (itemId.isBlank()) return
|
||||
positions[itemId] = transform(positions[itemId] ?: DetailPosition())
|
||||
while (positions.size > maxEntries) {
|
||||
val oldest = positions.keys.firstOrNull() ?: break
|
||||
positions.remove(oldest)
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun clear() = positions.clear()
|
||||
|
||||
@Synchronized
|
||||
fun size(): Int = positions.size
|
||||
|
||||
companion object {
|
||||
const val DEFAULT_MAX_ENTRIES = 16
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The one store the detail pages share. A plain global rather than a `ServiceLocator`
|
||||
* entry: it holds nothing a preview, a screenshot test or a signed-out user could misuse,
|
||||
* and every reader of it is a composable that already runs without injection.
|
||||
*/
|
||||
val detailPositions = DetailPositionStore()
|
||||
@@ -0,0 +1,47 @@
|
||||
package com.ponzischeme89.memby.ui.player
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Color
|
||||
import android.graphics.PorterDuff
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.widget.ImageView
|
||||
import androidx.core.graphics.drawable.toBitmap
|
||||
|
||||
/**
|
||||
* Applies a white treatment only to artwork whose visible pixels are overwhelmingly dark.
|
||||
* Transparent padding is ignored, so sparse black wordmarks are detected reliably without
|
||||
* flattening colourful or deliberately two-tone artwork.
|
||||
*/
|
||||
internal fun makeLogoVisibleOnDarkBackground(imageView: ImageView, drawable: Drawable): Boolean {
|
||||
imageView.setImageDrawable(drawable)
|
||||
imageView.clearColorFilter()
|
||||
val tintWhite = isPredominantlyDarkLogo(drawable)
|
||||
if (tintWhite) imageView.setColorFilter(Color.WHITE, PorterDuff.Mode.SRC_IN)
|
||||
return tintWhite
|
||||
}
|
||||
|
||||
internal fun isPredominantlyDarkLogo(drawable: Drawable): Boolean {
|
||||
val bitmap = runCatching {
|
||||
drawable.toBitmap(width = 96, height = 64, config = Bitmap.Config.ARGB_8888)
|
||||
}.getOrNull() ?: return false
|
||||
val xStep = (bitmap.width / 48).coerceAtLeast(1)
|
||||
val yStep = (bitmap.height / 32).coerceAtLeast(1)
|
||||
var visible = 0
|
||||
var dark = 0
|
||||
var bright = 0
|
||||
for (y in 0 until bitmap.height step yStep) {
|
||||
for (x in 0 until bitmap.width step xStep) {
|
||||
val pixel = bitmap.getPixel(x, y)
|
||||
if (Color.alpha(pixel) < 32) continue
|
||||
visible++
|
||||
val luminance = (
|
||||
Color.red(pixel) * 0.2126f +
|
||||
Color.green(pixel) * 0.7152f +
|
||||
Color.blue(pixel) * 0.0722f
|
||||
) / 255f
|
||||
if (luminance < 0.32f) dark++
|
||||
if (luminance > 0.72f) bright++
|
||||
}
|
||||
}
|
||||
return visible > 0 && dark * 100 >= visible * 68 && bright * 100 < visible * 12
|
||||
}
|
||||
@@ -10,6 +10,7 @@ internal data class PlaybackFailure(
|
||||
val title: String,
|
||||
val detail: String,
|
||||
val canAutoRetry: Boolean,
|
||||
val requiresTranscode: Boolean = false,
|
||||
)
|
||||
|
||||
internal fun describePlaybackFailure(errorCode: Int): PlaybackFailure =
|
||||
@@ -45,17 +46,26 @@ internal fun describePlaybackFailure(errorCode: Int): PlaybackFailure =
|
||||
PlaybackException.ERROR_CODE_DECODING_FORMAT_EXCEEDS_CAPABILITIES,
|
||||
-> PlaybackFailure(
|
||||
title = "Video format not supported",
|
||||
detail = "This TV couldn’t decode the selected video or audio track. Try another track or a transcoded version.",
|
||||
canAutoRetry = false,
|
||||
detail = "This TV couldn’t decode the original format. Memby will request a compatible stream.",
|
||||
canAutoRetry = true,
|
||||
requiresTranscode = true,
|
||||
)
|
||||
|
||||
PlaybackException.ERROR_CODE_PARSING_CONTAINER_MALFORMED,
|
||||
PlaybackException.ERROR_CODE_PARSING_CONTAINER_UNSUPPORTED,
|
||||
PlaybackException.ERROR_CODE_PARSING_MANIFEST_MALFORMED,
|
||||
PlaybackException.ERROR_CODE_PARSING_MANIFEST_UNSUPPORTED,
|
||||
-> PlaybackFailure(
|
||||
title = "Video file couldn’t be read",
|
||||
detail = "The stream format is damaged or unsupported by this TV.",
|
||||
detail = "This TV couldn’t read the original stream. Memby will request a compatible format.",
|
||||
canAutoRetry = true,
|
||||
requiresTranscode = true,
|
||||
)
|
||||
|
||||
PlaybackException.ERROR_CODE_PARSING_CONTAINER_MALFORMED,
|
||||
PlaybackException.ERROR_CODE_PARSING_MANIFEST_MALFORMED,
|
||||
-> PlaybackFailure(
|
||||
title = "Video file couldn’t be read",
|
||||
detail = "The media server returned a malformed stream.",
|
||||
canAutoRetry = false,
|
||||
)
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,75 @@
|
||||
package com.ponzischeme89.memby.ui.player
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Color
|
||||
import android.graphics.Paint
|
||||
import android.graphics.RectF
|
||||
import android.util.AttributeSet
|
||||
import android.util.TypedValue
|
||||
import android.view.View
|
||||
import kotlin.math.min
|
||||
|
||||
/**
|
||||
* Compact TV-safe countdown that does not depend on a continuously running animator.
|
||||
* PlayerActivity advances it only while video is actually playing, keeping the ring and
|
||||
* the pre-roll gate on the same clock.
|
||||
*/
|
||||
class PrerollCountdownView @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null,
|
||||
defStyleAttr: Int = 0,
|
||||
) : View(context, attrs, defStyleAttr) {
|
||||
|
||||
private val density = resources.displayMetrics.density
|
||||
private val ringBounds = RectF()
|
||||
private val trackPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
color = Color.argb(72, 255, 255, 255)
|
||||
style = Paint.Style.STROKE
|
||||
strokeCap = Paint.Cap.ROUND
|
||||
strokeWidth = 5f * density
|
||||
}
|
||||
private val progressPaint = Paint(trackPaint).apply {
|
||||
color = Color.rgb(82, 190, 75)
|
||||
}
|
||||
private val numberPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
color = Color.WHITE
|
||||
textAlign = Paint.Align.CENTER
|
||||
textSize = TypedValue.applyDimension(
|
||||
TypedValue.COMPLEX_UNIT_SP,
|
||||
30f,
|
||||
resources.displayMetrics,
|
||||
)
|
||||
typeface = android.graphics.Typeface.create("sans-serif", android.graphics.Typeface.BOLD)
|
||||
}
|
||||
|
||||
private var seconds = 7
|
||||
private var progress = 1f
|
||||
|
||||
fun setCountdown(seconds: Int, progress: Float, description: String) {
|
||||
this.seconds = seconds.coerceAtLeast(0)
|
||||
this.progress = progress.coerceIn(0f, 1f)
|
||||
contentDescription = description
|
||||
invalidate()
|
||||
}
|
||||
|
||||
override fun onDraw(canvas: Canvas) {
|
||||
super.onDraw(canvas)
|
||||
val strokeInset = trackPaint.strokeWidth / 2f
|
||||
val diameter = min(width, height).toFloat()
|
||||
val left = (width - diameter) / 2f + strokeInset
|
||||
val top = (height - diameter) / 2f + strokeInset
|
||||
ringBounds.set(
|
||||
left,
|
||||
top,
|
||||
left + diameter - trackPaint.strokeWidth,
|
||||
top + diameter - trackPaint.strokeWidth,
|
||||
)
|
||||
canvas.drawOval(ringBounds, trackPaint)
|
||||
if (progress > 0f) {
|
||||
canvas.drawArc(ringBounds, -90f, 360f * progress, false, progressPaint)
|
||||
}
|
||||
val baseline = height / 2f - (numberPaint.ascent() + numberPaint.descent()) / 2f
|
||||
canvas.drawText(seconds.toString(), width / 2f, baseline, numberPaint)
|
||||
}
|
||||
}
|
||||
@@ -45,7 +45,6 @@ import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableLongStateOf
|
||||
import androidx.compose.runtime.mutableStateMapOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
@@ -79,8 +78,6 @@ import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.core.graphics.drawable.toBitmap
|
||||
import androidx.core.graphics.get
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import androidx.lifecycle.repeatOnLifecycle
|
||||
@@ -88,13 +85,13 @@ import androidx.tv.material3.Button
|
||||
import androidx.tv.material3.Icon
|
||||
import androidx.tv.material3.Text
|
||||
import coil.compose.AsyncImage
|
||||
import coil.imageLoader
|
||||
import coil.request.ImageRequest
|
||||
import coil.request.SuccessResult
|
||||
import com.ponzischeme89.memby.R
|
||||
import com.ponzischeme89.memby.ServiceLocator
|
||||
import com.ponzischeme89.memby.data.friendlyEmbyError
|
||||
import com.ponzischeme89.memby.data.model.BaseItem
|
||||
import com.ponzischeme89.memby.ui.useTextTitleForLogo
|
||||
import com.ponzischeme89.memby.ui.visibleWithWatchedPreference
|
||||
import com.ponzischeme89.memby.ui.settings.SettingsSheet
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -157,6 +154,7 @@ fun ScreensaverContent(
|
||||
ringColor = ringColorFromHex(settings?.ringColorHex),
|
||||
embyServerId = settings?.serverId,
|
||||
warmBackdropUrl = settings?.lastBackdropUrl,
|
||||
hideWatchedMovies = settings?.hideWatchedMovies == true,
|
||||
startupMessage = startupMessage,
|
||||
)
|
||||
}
|
||||
@@ -171,6 +169,7 @@ private fun Slideshow(
|
||||
ringColor: Color,
|
||||
embyServerId: String?,
|
||||
warmBackdropUrl: String?,
|
||||
hideWatchedMovies: Boolean,
|
||||
startupMessage: String?,
|
||||
) {
|
||||
val repo = ServiceLocator.repository
|
||||
@@ -200,12 +199,12 @@ private fun Slideshow(
|
||||
val slideProgress = remember { mutableFloatStateOf(0f) }
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
|
||||
LaunchedEffect(reloadKey) {
|
||||
LaunchedEffect(reloadKey, hideWatchedMovies) {
|
||||
loading = true
|
||||
loadError = null
|
||||
runCatching { repo.getScreensaverItems() }
|
||||
.onSuccess { fetched ->
|
||||
val queue = fetched.shuffled()
|
||||
val queue = visibleWithWatchedPreference(fetched, hideWatchedMovies).shuffled()
|
||||
// The lightweight startup request may already be on-screen. Keep that
|
||||
// exact item at the front rather than swapping through several results
|
||||
// as competing requests finish; the rest of the random queue follows it.
|
||||
@@ -224,10 +223,14 @@ private fun Slideshow(
|
||||
|
||||
// Do not wait for the full 200-item queue before showing a first real backdrop.
|
||||
// This runs alongside it and normally wins on a cold launch.
|
||||
LaunchedEffect(reloadKey) {
|
||||
LaunchedEffect(reloadKey, hideWatchedMovies) {
|
||||
runCatching { repo.getStartupBackdropMovie() }
|
||||
.onSuccess { movie ->
|
||||
if (movie != null && items.isEmpty()) {
|
||||
if (
|
||||
movie != null &&
|
||||
visibleWithWatchedPreference(listOf(movie), hideWatchedMovies).isNotEmpty() &&
|
||||
items.isEmpty()
|
||||
) {
|
||||
items = listOf(movie)
|
||||
loading = false
|
||||
}
|
||||
@@ -244,7 +247,10 @@ private fun Slideshow(
|
||||
loadingMore = true
|
||||
toast = "Finding more from your library…"
|
||||
scope.launch {
|
||||
val more = runCatching { repo.getScreensaverItems() }.getOrDefault(emptyList())
|
||||
val more = visibleWithWatchedPreference(
|
||||
runCatching { repo.getScreensaverItems() }.getOrDefault(emptyList()),
|
||||
hideWatchedMovies,
|
||||
)
|
||||
if (more.isNotEmpty()) {
|
||||
val existing = items.mapTo(HashSet()) { it.id }
|
||||
val fresh = more.filter { it.id !in existing }
|
||||
@@ -616,7 +622,6 @@ private fun Slideshow(
|
||||
|
||||
if (settingsOpen) {
|
||||
SettingsSheet(
|
||||
editableServer = true,
|
||||
onClose = { settingsOpen = false },
|
||||
onInstallerLaunched = onExit,
|
||||
)
|
||||
@@ -863,49 +868,6 @@ private fun InfoAndActions(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Transparent Emby logos are commonly black. They disappear over a dark backdrop, so
|
||||
* inspect a small decoded copy and retain the text title when its visible pixels are
|
||||
* overwhelmingly dark. Until the image has been inspected, text is the safe default.
|
||||
*/
|
||||
@Composable
|
||||
private fun useTextTitleForLogo(logoUrl: String?): Boolean {
|
||||
if (logoUrl == null) return true
|
||||
val context = LocalContext.current
|
||||
val isDark by produceState(initialValue = true, logoUrl) {
|
||||
value = runCatching {
|
||||
val result = context.imageLoader.execute(
|
||||
ImageRequest.Builder(context)
|
||||
.data(logoUrl)
|
||||
.allowHardware(false)
|
||||
.size(64, 64)
|
||||
.build(),
|
||||
) as? SuccessResult ?: return@runCatching true
|
||||
isPredominantlyDarkLogo(result.drawable.toBitmap(width = 64, height = 64))
|
||||
}.getOrDefault(true)
|
||||
}
|
||||
return isDark
|
||||
}
|
||||
|
||||
private fun isPredominantlyDarkLogo(bitmap: android.graphics.Bitmap): Boolean {
|
||||
var opaquePixels = 0
|
||||
var darkPixels = 0
|
||||
for (y in 0 until bitmap.height step 2) {
|
||||
for (x in 0 until bitmap.width step 2) {
|
||||
val pixel = bitmap[x, y]
|
||||
if (android.graphics.Color.alpha(pixel) < 48) continue
|
||||
opaquePixels++
|
||||
val luminance = (
|
||||
android.graphics.Color.red(pixel) * 0.2126f +
|
||||
android.graphics.Color.green(pixel) * 0.7152f +
|
||||
android.graphics.Color.blue(pixel) * 0.0722f
|
||||
)
|
||||
if (luminance < 58f) darkPixels++
|
||||
}
|
||||
}
|
||||
return opaquePixels < 12 || darkPixels.toFloat() / opaquePixels > 0.82f
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StatusChips(item: BaseItem, isFavorite: Boolean) {
|
||||
val chips = buildList {
|
||||
|
||||
@@ -85,6 +85,7 @@ import coil.imageLoader
|
||||
import coil.request.ImageRequest
|
||||
import com.ponzischeme89.memby.ServiceLocator
|
||||
import com.ponzischeme89.memby.data.model.BaseItem
|
||||
import com.ponzischeme89.memby.data.model.GatewayRequestCandidate
|
||||
import com.ponzischeme89.memby.ui.FocusScaleContainer
|
||||
import com.ponzischeme89.memby.ui.PosterGridCard
|
||||
|
||||
@@ -166,7 +167,7 @@ fun SearchScreen(
|
||||
state.errorMessage != null && state.results.isEmpty() -> true
|
||||
state.isDiscovery -> discoveryItems.isNotEmpty() ||
|
||||
state.suggestions.any { it.kind == SearchSuggestion.Kind.GENRE }
|
||||
else -> state.results.isNotEmpty()
|
||||
else -> state.results.isNotEmpty() || state.requestCandidates.isNotEmpty()
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) { runCatching { keyboardEntry.requestFocus() } }
|
||||
@@ -237,6 +238,7 @@ fun SearchScreen(
|
||||
},
|
||||
onItemSelected = onItemSelected,
|
||||
onRetry = viewModel::retry,
|
||||
onRequest = viewModel::request,
|
||||
onSuggestionSelected = viewModel::onQueryChanged,
|
||||
modifier = Modifier.fillMaxHeight(),
|
||||
)
|
||||
@@ -588,6 +590,7 @@ private fun ResultsPane(
|
||||
onItemFocused: (BaseItem) -> Unit,
|
||||
onItemSelected: (BaseItem) -> Unit,
|
||||
onRetry: () -> Unit,
|
||||
onRequest: (GatewayRequestCandidate) -> Unit,
|
||||
onSuggestionSelected: (String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
@@ -644,6 +647,13 @@ private fun ResultsPane(
|
||||
resultsEntry = resultsEntry,
|
||||
keyboardReturn = keyboardReturn,
|
||||
)
|
||||
!showingDiscovery && state.results.isEmpty() && state.requestCandidates.isNotEmpty() ->
|
||||
RequestOptions(
|
||||
state = state,
|
||||
resultsEntry = resultsEntry,
|
||||
keyboardReturn = keyboardReturn,
|
||||
onRequest = onRequest,
|
||||
)
|
||||
items.isEmpty() -> SearchEmptyMessage(state = state, showingDiscovery = showingDiscovery)
|
||||
else -> ResultsGrid(
|
||||
items = items,
|
||||
@@ -756,6 +766,54 @@ private fun ResultsGrid(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RequestOptions(
|
||||
state: SearchUiState,
|
||||
resultsEntry: FocusRequester,
|
||||
keyboardReturn: FocusRequester,
|
||||
onRequest: (GatewayRequestCandidate) -> Unit,
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Text(
|
||||
"Not in your library? Request it:",
|
||||
color = Muted,
|
||||
fontSize = 16.sp,
|
||||
)
|
||||
state.requestCandidates.take(6).forEachIndexed { index, candidate ->
|
||||
val busy = state.requestingCandidateKey == "${candidate.mediaType}:${candidate.foreignId}"
|
||||
val label = when {
|
||||
candidate.alreadyAdded -> "${candidate.title} — already requested"
|
||||
busy -> "Requesting ${candidate.title}…"
|
||||
else -> "Request ${if (candidate.mediaType == "movie") "movie" else "show"}: " +
|
||||
candidate.title + candidate.year.takeIf { it > 0 }?.let { " ($it)" }.orEmpty()
|
||||
}
|
||||
FocusScaleContainer(
|
||||
onFocused = {},
|
||||
onClick = { onRequest(candidate) },
|
||||
contentDescription = label,
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(10.dp))
|
||||
.then(if (index == 0) Modifier.focusRequester(resultsEntry) else Modifier)
|
||||
.focusProperties { left = keyboardReturn },
|
||||
) { focused ->
|
||||
Text(
|
||||
label,
|
||||
color = if (focused) KeyLabelFocused else KeyLabel,
|
||||
fontSize = 15.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(if (focused) KeyFocused else KeyIdle)
|
||||
.padding(horizontal = 18.dp, vertical = 12.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
state.requestMessage?.let { Text(it, color = Muted, fontSize = 14.sp) }
|
||||
}
|
||||
}
|
||||
|
||||
private val GenreColors = listOf(
|
||||
Color(0xFFB85C38), Color(0xFF5578C8), Color(0xFF7B5AB6),
|
||||
Color(0xFF2F8F83), Color(0xFFD18A32), Color(0xFFB44E76),
|
||||
@@ -861,6 +919,7 @@ private fun SearchEmptyMessage(state: SearchUiState, showingDiscovery: Boolean)
|
||||
val message = when {
|
||||
showingDiscovery -> "Type a couple of letters to search, or pick up where the home screen left off."
|
||||
state.isLoading -> "Searching…"
|
||||
state.requestLookupLoading -> "Nothing in the library. Checking available movies and shows…"
|
||||
else -> "Nothing in this library matches that. Try fewer letters, or a different spelling."
|
||||
}
|
||||
Text(message, color = Muted, fontSize = 17.sp, modifier = Modifier.padding(top = 40.dp))
|
||||
|
||||
@@ -6,6 +6,7 @@ import androidx.lifecycle.viewModelScope
|
||||
import com.ponzischeme89.memby.data.EmbyRepository
|
||||
import com.ponzischeme89.memby.data.friendlyEmbyError
|
||||
import com.ponzischeme89.memby.data.model.BaseItem
|
||||
import com.ponzischeme89.memby.data.model.GatewayRequestCandidate
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
@@ -35,6 +36,10 @@ data class SearchUiState(
|
||||
/** True once a query has run to completion, so "no matches" is distinguishable from "not yet". */
|
||||
val hasSearched: Boolean = false,
|
||||
val errorMessage: String? = null,
|
||||
val requestCandidates: List<GatewayRequestCandidate> = emptyList(),
|
||||
val requestLookupLoading: Boolean = false,
|
||||
val requestingCandidateKey: String? = null,
|
||||
val requestMessage: String? = null,
|
||||
) {
|
||||
/** The query is long enough to search but nothing came back. */
|
||||
val isEmptyResult: Boolean
|
||||
@@ -100,7 +105,14 @@ class SearchViewModel(private val repository: EmbyRepository) : ViewModel() {
|
||||
/** Every keystroke, from the on-screen keyboard, a USB keyboard or voice. */
|
||||
fun onQueryChanged(query: String) {
|
||||
// The visible field updates immediately; only the *search* is debounced.
|
||||
_state.update { it.copy(query = query) }
|
||||
_state.update {
|
||||
it.copy(
|
||||
query = query,
|
||||
requestCandidates = emptyList(),
|
||||
requestLookupLoading = false,
|
||||
requestMessage = null,
|
||||
)
|
||||
}
|
||||
queryFlow.value = query
|
||||
}
|
||||
|
||||
@@ -111,7 +123,13 @@ class SearchViewModel(private val repository: EmbyRepository) : ViewModel() {
|
||||
fun clearQuery() {
|
||||
// Clear immediately so results from a genre tile cannot remain visible while the
|
||||
// debounced empty-query transition is pending.
|
||||
_state.update { it.copy(query = "", results = emptyList(), isLoading = false, hasSearched = false, errorMessage = null) }
|
||||
_state.update {
|
||||
it.copy(
|
||||
query = "", results = emptyList(), isLoading = false, hasSearched = false,
|
||||
errorMessage = null, requestCandidates = emptyList(),
|
||||
requestLookupLoading = false, requestMessage = null,
|
||||
)
|
||||
}
|
||||
queryFlow.value = ""
|
||||
}
|
||||
|
||||
@@ -123,6 +141,38 @@ class SearchViewModel(private val repository: EmbyRepository) : ViewModel() {
|
||||
viewModelScope.launch { runSearch(term) }
|
||||
}
|
||||
|
||||
fun request(candidate: GatewayRequestCandidate) {
|
||||
if (candidate.alreadyAdded || state.value.requestingCandidateKey != null) return
|
||||
val candidateKey = "${candidate.mediaType}:${candidate.foreignId}"
|
||||
_state.update {
|
||||
it.copy(requestingCandidateKey = candidateKey, requestMessage = null)
|
||||
}
|
||||
viewModelScope.launch {
|
||||
runCatching { repository.requestMedia(candidate) }
|
||||
.onSuccess { title ->
|
||||
_state.update {
|
||||
it.copy(
|
||||
requestingCandidateKey = null,
|
||||
requestCandidates = it.requestCandidates.map { option ->
|
||||
if (option.mediaType == candidate.mediaType &&
|
||||
option.foreignId == candidate.foreignId
|
||||
) option.copy(alreadyAdded = true) else option
|
||||
},
|
||||
requestMessage = "${title.ifBlank { candidate.title }} was requested.",
|
||||
)
|
||||
}
|
||||
}
|
||||
.onFailure { error ->
|
||||
_state.update {
|
||||
it.copy(
|
||||
requestingCandidateKey = null,
|
||||
requestMessage = friendlyEmbyError(error),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Genre chips for the empty state, taken from items the home screen already loaded.
|
||||
* Nothing is fetched: if home has no data yet, the chips simply do not appear.
|
||||
@@ -149,7 +199,11 @@ class SearchViewModel(private val repository: EmbyRepository) : ViewModel() {
|
||||
// Back to the discovery state, but the previous results are dropped rather
|
||||
// than left behind a shorter query they no longer match.
|
||||
_state.update {
|
||||
it.copy(results = emptyList(), isLoading = false, hasSearched = false, errorMessage = null)
|
||||
it.copy(
|
||||
results = emptyList(), isLoading = false, hasSearched = false,
|
||||
errorMessage = null, requestCandidates = emptyList(),
|
||||
requestLookupLoading = false, requestMessage = null,
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -158,6 +212,7 @@ class SearchViewModel(private val repository: EmbyRepository) : ViewModel() {
|
||||
_state.update {
|
||||
it.copy(results = cached, isLoading = false, hasSearched = true, errorMessage = null)
|
||||
}
|
||||
if (cached.isEmpty()) loadRequestCandidates(term)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -166,13 +221,20 @@ class SearchViewModel(private val repository: EmbyRepository) : ViewModel() {
|
||||
_state.update { it.copy(isLoading = true, errorMessage = null) }
|
||||
runCatching { repository.search(term) }
|
||||
.onSuccess { items ->
|
||||
val ranked = rankSearchResults(term, items)
|
||||
// Gateway payloads carry the backend ranker's score. Preserve that
|
||||
// ordering exactly; direct-to-Emby mode keeps the local textual fallback.
|
||||
val ranked = if (items.any { it.membyRecommendationScore != null }) {
|
||||
items
|
||||
} else {
|
||||
rankSearchResults(term, items)
|
||||
}
|
||||
cache[term] = ranked
|
||||
rememberQuery(term)
|
||||
viewModelScope.launch { repository.recordSearch(term) }
|
||||
_state.update {
|
||||
it.copy(results = ranked, isLoading = false, hasSearched = true, errorMessage = null)
|
||||
}
|
||||
if (ranked.isEmpty()) loadRequestCandidates(term)
|
||||
}
|
||||
.onFailure { error ->
|
||||
// A cancelled search is the normal case while typing, not a failure.
|
||||
@@ -183,6 +245,20 @@ class SearchViewModel(private val repository: EmbyRepository) : ViewModel() {
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun loadRequestCandidates(term: String) {
|
||||
_state.update { it.copy(requestLookupLoading = true) }
|
||||
val candidates = runCatching { repository.lookupMediaRequests(term) }
|
||||
.getOrElse { error ->
|
||||
if (error is kotlinx.coroutines.CancellationException) throw error
|
||||
emptyList()
|
||||
}
|
||||
if (_state.value.query.trim() == term) {
|
||||
_state.update {
|
||||
it.copy(requestCandidates = candidates, requestLookupLoading = false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun rememberQuery(term: String) {
|
||||
recentQueries.removeAll { it.equals(term, ignoreCase = true) }
|
||||
recentQueries.addFirst(term)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,63 @@
|
||||
package com.ponzischeme89.memby.ui.theme
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
/**
|
||||
* The one vocabulary of colour, shape and punctuation shared by the launcher and the detail
|
||||
* pages.
|
||||
*
|
||||
* These two surfaces sit next to each other the moment a detail page opens from a home row,
|
||||
* and they had drifted into four near-blacks, four greens, two secondary-text greys and
|
||||
* eight corner radii. Nothing here is new design — it is the values that were already
|
||||
* winning, named once so a change lands on both screens at the same time.
|
||||
*/
|
||||
|
||||
/** The near-black every full-screen surface is drawn on. */
|
||||
val MembySurface = Color(0xFF090B0D)
|
||||
|
||||
/** One step up, for panels and sheets that need to read as raised off [MembySurface]. */
|
||||
val MembySurfaceRaised = Color(0xFF101418)
|
||||
|
||||
/** Emby's green. The only accent in the app. */
|
||||
val MembyAccent = Color(0xFF52B54B)
|
||||
|
||||
/** Primary body copy. Not pure white — that vibrates on a TV panel at this size. */
|
||||
val MembyOnSurface = Color(0xFFE2E5E8)
|
||||
|
||||
/**
|
||||
* Secondary and tertiary copy, raised for TV viewing distance: quiet still reads as
|
||||
* secondary without falling into low-contrast grey-on-black.
|
||||
*/
|
||||
val MembyMutedText = Color(0xFFD0D6DB)
|
||||
val MembyQuietText = Color(0xFFAEB7BF)
|
||||
|
||||
/** Hairline rules and unfocused borders. */
|
||||
val MembyHairline = Color(0x28FFFFFF)
|
||||
|
||||
/** The community score, wherever it is rendered as its own run of text. */
|
||||
val MembyScore = Color(0xFFF5C518)
|
||||
|
||||
// --- Shape ---------------------------------------------------------------------------
|
||||
// Three steps, largest last. Anything that needs a radius picks the nearest one rather
|
||||
// than inventing a fourth.
|
||||
|
||||
/** Chips, badges and small controls. */
|
||||
val MembyChipCorner = 8.dp
|
||||
|
||||
/** Cards: posters, episode rows, buttons. */
|
||||
val MembyCardCorner = 10.dp
|
||||
|
||||
/** Full panels: heroes, overlays, confirmation toasts. */
|
||||
val MembyPanelCorner = 14.dp
|
||||
|
||||
// --- Punctuation ---------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Between one fact and the next — year, runtime, certificate. One separator everywhere,
|
||||
* because the same line is drawn by the home hero, the metadata panel and the detail page.
|
||||
*/
|
||||
const val FactSeparator = " • "
|
||||
|
||||
/** Within a single fact that happens to hold a list: genres, codecs, audio attributes. */
|
||||
const val ValueSeparator = " · "
|
||||
@@ -9,11 +9,14 @@ import androidx.tv.material3.LocalTextStyle
|
||||
import androidx.tv.material3.MaterialTheme
|
||||
import androidx.tv.material3.darkColorScheme
|
||||
|
||||
// The scheme is the same near-blacks the screens actually paint (see DesignTokens.kt), so
|
||||
// a component that falls back to a theme colour lands on the surface it is sitting on
|
||||
// rather than one shade beside it.
|
||||
private val EmbyColors = darkColorScheme(
|
||||
primary = androidx.compose.ui.graphics.Color(0xFF52B54B),
|
||||
primary = MembyAccent,
|
||||
onPrimary = androidx.compose.ui.graphics.Color.White,
|
||||
surface = androidx.compose.ui.graphics.Color(0xFF101418),
|
||||
background = androidx.compose.ui.graphics.Color(0xFF0B0E11),
|
||||
surface = MembySurfaceRaised,
|
||||
background = MembySurface,
|
||||
)
|
||||
|
||||
@Composable
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
package com.ponzischeme89.memby.update
|
||||
|
||||
import com.ponzischeme89.memby.data.model.GatewayUpdate
|
||||
import com.ponzischeme89.memby.data.remote.GatewayServiceFactory
|
||||
import kotlinx.coroutines.CancellationException
|
||||
|
||||
/**
|
||||
* App-level update client, deliberately independent of profiles and saved sessions.
|
||||
*
|
||||
* The gateway endpoint is public and the Retrofit client below never receives a bearer
|
||||
* token. This makes an update check safe before sign-in and prevents update failures from
|
||||
* invalidating, replacing, or otherwise coupling themselves to a viewer's credentials.
|
||||
*/
|
||||
class ServerUpdateService private constructor(
|
||||
private val checkRemote: (suspend () -> GatewayUpdate)?,
|
||||
) {
|
||||
suspend fun check(): Result<GatewayUpdate?> {
|
||||
val remote = checkRemote ?: return Result.success(null)
|
||||
return try {
|
||||
Result.success(remote().takeIf { it.isActionable })
|
||||
} catch (cancelled: CancellationException) {
|
||||
throw cancelled
|
||||
} catch (failure: Throwable) {
|
||||
Result.failure(failure)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun create(gatewayUrl: String?): ServerUpdateService {
|
||||
val api = gatewayUrl?.let { GatewayServiceFactory.create(it) { null } }
|
||||
return ServerUpdateService(api?.let { { it.updateStatus() } })
|
||||
}
|
||||
|
||||
internal fun forTest(check: suspend () -> GatewayUpdate): ServerUpdateService =
|
||||
ServerUpdateService(check)
|
||||
}
|
||||
}
|
||||
@@ -2,9 +2,12 @@ package com.ponzischeme89.memby.update
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageInfo
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import android.provider.Settings
|
||||
import androidx.core.content.FileProvider
|
||||
import androidx.core.content.pm.PackageInfoCompat
|
||||
import androidx.core.net.toUri
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
@@ -12,6 +15,8 @@ import kotlinx.serialization.json.Json
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.security.MessageDigest
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/** Result of a "check for updates" against the configured Gitea release. */
|
||||
@@ -156,7 +161,13 @@ class UpdateChecker(private val context: Context) {
|
||||
* needs the "install unknown apps" permission; if it's missing we send the user
|
||||
* to that settings screen and return a message asking them to retry.
|
||||
*/
|
||||
suspend fun downloadAndInstall(apkUrl: String, token: String): Result<Unit> =
|
||||
suspend fun downloadAndInstall(
|
||||
apkUrl: String,
|
||||
token: String,
|
||||
expectedVersion: String = "",
|
||||
expectedSHA256: String = "",
|
||||
expectedSizeBytes: Long = 0,
|
||||
): Result<Unit> =
|
||||
withContext(Dispatchers.IO) {
|
||||
// Gate on the install-unknown-apps permission before spending a download.
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O &&
|
||||
@@ -170,21 +181,75 @@ class UpdateChecker(private val context: Context) {
|
||||
context.startActivity(intent)
|
||||
}
|
||||
return@withContext Result.failure(
|
||||
IllegalStateException("Allow Memby to install apps, then check again.")
|
||||
InstallPermissionRequiredException(
|
||||
"Allow Memby to install apps. The update will continue when you return.",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
runCatching {
|
||||
val file = File(context.cacheDir, "memby-update.apk")
|
||||
val updateDir = File(context.cacheDir, "updates").apply { mkdirs() }
|
||||
val partial = File(updateDir, "memby-update.part")
|
||||
val file = File(updateDir, "memby-update.apk")
|
||||
partial.delete()
|
||||
val req = Request.Builder().url(apkUrl).apply {
|
||||
if (token.isNotBlank()) header("Authorization", "token ${token.trim()}")
|
||||
}.build()
|
||||
http.newCall(req).execute().use { resp ->
|
||||
if (!resp.isSuccessful) error("Download failed (${resp.code}).")
|
||||
val body = resp.body ?: error("Empty download.")
|
||||
file.outputStream().use { out -> body.byteStream().copyTo(out) }
|
||||
val declaredSize = body.contentLength()
|
||||
if (declaredSize > MAX_APK_BYTES) error("The update is unexpectedly large.")
|
||||
if (expectedSizeBytes > 0 && declaredSize >= 0 &&
|
||||
declaredSize != expectedSizeBytes
|
||||
) {
|
||||
error("The update size does not match the published release.")
|
||||
}
|
||||
|
||||
val digest = MessageDigest.getInstance("SHA-256")
|
||||
var written = 0L
|
||||
try {
|
||||
FileOutputStream(partial).use { out ->
|
||||
body.byteStream().use { input ->
|
||||
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
|
||||
while (true) {
|
||||
val read = input.read(buffer)
|
||||
if (read < 0) break
|
||||
written += read
|
||||
if (written > MAX_APK_BYTES) {
|
||||
error("The update is unexpectedly large.")
|
||||
}
|
||||
digest.update(buffer, 0, read)
|
||||
out.write(buffer, 0, read)
|
||||
}
|
||||
}
|
||||
out.fd.sync()
|
||||
}
|
||||
} catch (error: Throwable) {
|
||||
partial.delete()
|
||||
throw error
|
||||
}
|
||||
if (written == 0L || (expectedSizeBytes > 0 && written != expectedSizeBytes)) {
|
||||
partial.delete()
|
||||
error("The update download was incomplete.")
|
||||
}
|
||||
val actualSHA256 = digest.digest().joinToString("") {
|
||||
"%02x".format(it.toInt() and 0xff)
|
||||
}
|
||||
if (expectedSHA256.isNotBlank() &&
|
||||
!actualSHA256.equals(expectedSHA256.trim(), ignoreCase = true)
|
||||
) {
|
||||
partial.delete()
|
||||
error("The update failed its integrity check.")
|
||||
}
|
||||
}
|
||||
|
||||
verifyApk(partial, expectedVersion)
|
||||
file.delete()
|
||||
if (!partial.renameTo(file)) {
|
||||
partial.delete()
|
||||
error("The verified update could not be prepared.")
|
||||
}
|
||||
val uri = FileProvider.getUriForFile(
|
||||
context, "${context.packageName}.fileprovider", file,
|
||||
)
|
||||
@@ -196,6 +261,51 @@ class UpdateChecker(private val context: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
private fun verifyApk(file: File, expectedVersion: String) {
|
||||
val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
||||
PackageManager.GET_SIGNING_CERTIFICATES
|
||||
} else {
|
||||
PackageManager.GET_SIGNATURES
|
||||
}
|
||||
val archive = context.packageManager.getPackageArchiveInfo(file.absolutePath, flags)
|
||||
?: error("The downloaded file is not a valid Android app.")
|
||||
if (archive.packageName != context.packageName) {
|
||||
error("The update belongs to a different app.")
|
||||
}
|
||||
if (expectedVersion.isNotBlank() &&
|
||||
normalizeVersion(archive.versionName.orEmpty()) != normalizeVersion(expectedVersion)
|
||||
) {
|
||||
error("The downloaded app version does not match the published release.")
|
||||
}
|
||||
|
||||
val installed = context.packageManager.getPackageInfo(context.packageName, flags)
|
||||
if (PackageInfoCompat.getLongVersionCode(archive) <=
|
||||
PackageInfoCompat.getLongVersionCode(installed)
|
||||
) {
|
||||
error("Android requires an update with a higher version code.")
|
||||
}
|
||||
if (signerDigests(archive) != signerDigests(installed) ||
|
||||
signerDigests(archive).isEmpty()
|
||||
) {
|
||||
error("The update was not signed by Memby’s trusted release key.")
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
private fun signerDigests(info: PackageInfo): Set<String> {
|
||||
val signatures = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
||||
info.signingInfo?.apkContentsSigners.orEmpty()
|
||||
} else {
|
||||
info.signatures.orEmpty()
|
||||
}
|
||||
return signatures.mapTo(linkedSetOf()) { signature ->
|
||||
MessageDigest.getInstance("SHA-256")
|
||||
.digest(signature.toByteArray())
|
||||
.joinToString("") { "%02x".format(it.toInt() and 0xff) }
|
||||
}
|
||||
}
|
||||
|
||||
/** True when [remote] parses to a strictly higher version than [installed]. */
|
||||
private fun isNewer(remote: String, installed: String): Boolean {
|
||||
val r = parseVersion(remote)
|
||||
@@ -212,8 +322,14 @@ class UpdateChecker(private val context: Context) {
|
||||
normalizeVersion(v).split('.', '-', ' ', '+').mapNotNull { it.toIntOrNull() }
|
||||
|
||||
private fun normalizeVersion(v: String): String = v.trim().trimStart('v', 'V')
|
||||
|
||||
companion object {
|
||||
private const val MAX_APK_BYTES = 250L * 1024L * 1024L
|
||||
}
|
||||
}
|
||||
|
||||
class InstallPermissionRequiredException(message: String) : IllegalStateException(message)
|
||||
|
||||
/**
|
||||
* A `.json` update URL means "static manifest"; anything else is treated as a Gitea host.
|
||||
* Chosen by URL shape rather than a mode switch: one fewer setting to get wrong on a TV
|
||||
|
||||
@@ -3,20 +3,19 @@ package com.ponzischeme89.memby.update
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import com.ponzischeme89.memby.ui.screensaver.ScreensaverActivity
|
||||
import com.ponzischeme89.memby.ui.MainActivity
|
||||
|
||||
/**
|
||||
* Reopens the launcher entry point when this package is replaced in place.
|
||||
* Reopens the normal app entry point when this package is replaced in place.
|
||||
*
|
||||
* Replacing an APK kills its process, including an active Dream's render process. On
|
||||
* TV that can leave the old Dream surface black. Android sends this broadcast to the
|
||||
* newly installed package, giving us a chance to present a real UI instead.
|
||||
* The normal entry point runs the public update check and then reuses the existing saved
|
||||
* session. Nothing in this recovery path clears app data, profiles, or credentials.
|
||||
*/
|
||||
class UpdateRecoveryReceiver : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
if (intent.action != Intent.ACTION_MY_PACKAGE_REPLACED) return
|
||||
|
||||
val launch = ScreensaverActivity.restartAfterUpdateIntent(context).apply {
|
||||
val launch = Intent(context, MainActivity::class.java).apply {
|
||||
addFlags(
|
||||
Intent.FLAG_ACTIVITY_NEW_TASK or
|
||||
Intent.FLAG_ACTIVITY_CLEAR_TOP or
|
||||
|
||||
Reference in New Issue
Block a user