Publish current app and server
This commit is contained in:
@@ -74,8 +74,8 @@
|
||||
android:theme="@style/Theme.Memby.Fullscreen"
|
||||
tools:ignore="DiscouragedApi" />
|
||||
|
||||
<!-- An APK replacement kills an active Dream process. Reopen our launcher so
|
||||
the TV is never left displaying the old, black Dream surface. -->
|
||||
<!-- Re-enter the normal app lifecycle after replacement. This preserves the
|
||||
stored profile/session and performs the next update check before login. -->
|
||||
<receiver
|
||||
android:name=".update.UpdateRecoveryReceiver"
|
||||
android:exported="false">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<gradient
|
||||
android:angle="90"
|
||||
android:endColor="#F2000000"
|
||||
android:startColor="#00000000"
|
||||
android:centerColor="#28000000"
|
||||
android:endColor="#00000000"
|
||||
android:startColor="#B8000000"
|
||||
android:type="linear" />
|
||||
</shape>
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<gradient
|
||||
android:angle="0"
|
||||
android:endColor="#00000000"
|
||||
android:startColor="#A607090B"
|
||||
android:type="linear" />
|
||||
</shape>
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<solid android:color="#FF20252A" />
|
||||
<corners android:radius="10dp" />
|
||||
<stroke android:width="1dp" android:color="#30FFFFFF" />
|
||||
</shape>
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<gradient
|
||||
android:angle="0"
|
||||
android:endColor="#00000000"
|
||||
android:startColor="#EB07090B"
|
||||
android:type="linear" />
|
||||
</shape>
|
||||
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
|
||||
<corners android:radius="11dp" />
|
||||
<gradient
|
||||
android:angle="0"
|
||||
android:startColor="#FF223139"
|
||||
android:centerColor="#FF182126"
|
||||
android:endColor="#FF0D1114" />
|
||||
</shape>
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
|
||||
<corners android:radius="11dp" />
|
||||
<solid android:color="#00000000" />
|
||||
<stroke android:width="1dp" android:color="#38FFFFFF" />
|
||||
</shape>
|
||||
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
|
||||
<corners android:radius="11dp" />
|
||||
<gradient
|
||||
android:angle="0"
|
||||
android:startColor="#F2070A0C"
|
||||
android:centerColor="#B8070A0C"
|
||||
android:endColor="#33070A0C" />
|
||||
</shape>
|
||||
@@ -2,6 +2,8 @@
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
|
||||
<corners android:radius="12dp" />
|
||||
|
||||
<gradient
|
||||
android:angle="315"
|
||||
android:centerColor="#FF0D1114"
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
|
||||
<corners android:radius="12dp" />
|
||||
<solid android:color="#00000000" />
|
||||
<stroke
|
||||
android:width="2dp"
|
||||
android:color="#4DFFFFFF" />
|
||||
</shape>
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Near-black and barely there: this rolls up over somebody's film, so it reads as a
|
||||
broadcast lower third rather than as a dialog. -->
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
<solid android:color="#E60A0D10" />
|
||||
<corners android:radius="4dp" />
|
||||
</shape>
|
||||
@@ -18,6 +18,18 @@
|
||||
app:surface_type="surface_view"
|
||||
app:use_controller="true" />
|
||||
|
||||
<!-- Channel + programme identity. It is owned by the activity rather than the
|
||||
controller, so it can appear briefly without opening the transport controls. -->
|
||||
<include layout="@layout/player_playback_identity" />
|
||||
|
||||
<!-- The ten-minute cue. Declared before the next-up banner so that banner covers it
|
||||
if the two ever coincide: what comes next matters more than how long is left. -->
|
||||
<include layout="@layout/player_time_remaining" />
|
||||
|
||||
<!-- Finale context has priority over the ordinary start cue and is physically stacked
|
||||
above it. Both remain non-focusable lower thirds. -->
|
||||
<include layout="@layout/player_season_finale" />
|
||||
|
||||
<!-- Above the video, below the loading overlay: a slide that is still starting has
|
||||
nothing to say about what comes next. -->
|
||||
<include layout="@layout/player_next_up_banner" />
|
||||
@@ -29,6 +41,17 @@
|
||||
<!-- Cast is metadata-only and never pauses or rebuilds the player. -->
|
||||
<include layout="@layout/player_cast_overlay" />
|
||||
|
||||
<!-- Service alerts, the same bar the launcher shows. Declared before the loading and
|
||||
error overlays so those cover it: a notice about the library is not what someone
|
||||
staring at a failed stream needs. Never focusable, so it cannot take the remote
|
||||
away from the transport controls. -->
|
||||
<androidx.compose.ui.platform.ComposeView
|
||||
android:id="@+id/player_service_alerts"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="top"
|
||||
android:focusable="false" />
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/playback_loading"
|
||||
android:layout_width="match_parent"
|
||||
@@ -66,7 +89,8 @@
|
||||
android:textSize="14sp" />
|
||||
</LinearLayout>
|
||||
|
||||
<!-- One cheap overlay, no second ExoPlayer. The programme prepares underneath it. -->
|
||||
<!-- The existing PlayerView is temporarily hosted inside this overlay, then returned
|
||||
here at full-screen size after the countdown. No second player or stream is used. -->
|
||||
<include layout="@layout/player_preroll" />
|
||||
|
||||
<!-- Deliberately outside PlayerView's controller hierarchy: a fatal error must remain
|
||||
|
||||
@@ -14,24 +14,31 @@
|
||||
android:background="@android:color/transparent" />
|
||||
|
||||
<View
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="210dp"
|
||||
android:layout_gravity="top"
|
||||
android:background="@drawable/player_osd_top_gradient" />
|
||||
android:layout_width="720dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_gravity="start"
|
||||
android:background="@drawable/player_osd_left_gradient" />
|
||||
|
||||
<View
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="300dp"
|
||||
android:layout_height="230dp"
|
||||
android:layout_gravity="bottom"
|
||||
android:background="@drawable/player_osd_bottom_gradient" />
|
||||
|
||||
<!-- Pause is a deliberate browsing moment, rather than just the ordinary OSD frozen
|
||||
over a frame. It carries the movie context while the transport row below remains
|
||||
the single, familiar place to resume or seek. -->
|
||||
<include layout="@layout/player_pause_overlay" />
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/player_now_playing_group"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="140dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="top|start"
|
||||
android:layout_marginStart="48dp"
|
||||
android:layout_marginTop="30dp"
|
||||
android:gravity="center_vertical"
|
||||
android:clipChildren="false"
|
||||
android:clipToPadding="false"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
@@ -60,6 +67,8 @@
|
||||
android:ellipsize="end"
|
||||
android:maxLines="2"
|
||||
android:maxWidth="600dp"
|
||||
android:includeFontPadding="true"
|
||||
android:lineSpacingExtra="2dp"
|
||||
android:textColor="#FFFFFFFF"
|
||||
android:textSize="28sp"
|
||||
android:textStyle="bold"
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:id="@+id/player_pause_overlay"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:focusable="false"
|
||||
android:visibility="gone">
|
||||
|
||||
<View
|
||||
android:layout_width="820dp"
|
||||
android:layout_height="match_parent"
|
||||
android:background="@drawable/player_pause_scrim" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="760dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="start|center_vertical"
|
||||
android:layout_marginStart="48dp"
|
||||
android:layout_marginBottom="62dp"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/player_pause_poster"
|
||||
android:layout_width="164dp"
|
||||
android:layout_height="246dp"
|
||||
android:background="@drawable/player_pause_poster_background"
|
||||
android:clipToOutline="true"
|
||||
android:contentDescription="@string/player_pause_poster"
|
||||
android:outlineProvider="background"
|
||||
android:scaleType="centerCrop" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="30dp"
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:letterSpacing="0.18"
|
||||
android:text="@string/player_paused"
|
||||
android:textColor="#FF6BCB63"
|
||||
android:textSize="12sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/player_pause_title"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="10dp"
|
||||
android:ellipsize="end"
|
||||
android:maxLines="2"
|
||||
android:textColor="#FFFFFFFF"
|
||||
android:textSize="34sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/player_pause_overview"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="14dp"
|
||||
android:ellipsize="end"
|
||||
android:lineSpacingExtra="3dp"
|
||||
android:maxLines="5"
|
||||
android:textColor="#DDE7EBEE"
|
||||
android:textSize="16sp" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="18dp"
|
||||
android:text="@string/player_pause_resume_hint"
|
||||
android:textColor="#B8FFFFFF"
|
||||
android:textSize="13sp" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
</FrameLayout>
|
||||
@@ -0,0 +1,39 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- A short, non-focusable station ident shown over the first five seconds of content. -->
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:id="@+id/player_playback_identity"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="54dp"
|
||||
android:layout_gravity="start|top"
|
||||
android:layout_marginStart="48dp"
|
||||
android:layout_marginTop="34dp"
|
||||
android:alpha="0"
|
||||
android:focusable="false"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal"
|
||||
android:visibility="gone">
|
||||
|
||||
<ImageView
|
||||
android:layout_width="42dp"
|
||||
android:layout_height="38dp"
|
||||
android:contentDescription="@string/player_preroll_brand_logo"
|
||||
android:scaleType="fitCenter"
|
||||
android:src="@drawable/emby_logo" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/player_playback_identity_title"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_marginStart="14dp"
|
||||
android:ellipsize="end"
|
||||
android:gravity="start|center_vertical"
|
||||
android:maxLines="1"
|
||||
android:maxWidth="520dp"
|
||||
android:shadowColor="#E0000000"
|
||||
android:shadowDx="0"
|
||||
android:shadowDy="2"
|
||||
android:shadowRadius="5"
|
||||
android:textColor="#FFFFFFFF"
|
||||
android:textSize="25sp"
|
||||
android:textStyle="bold" />
|
||||
</LinearLayout>
|
||||
@@ -5,114 +5,152 @@
|
||||
android:layout_height="match_parent"
|
||||
android:background="#FF050708"
|
||||
android:clickable="true"
|
||||
android:clipChildren="false"
|
||||
android:focusable="false"
|
||||
android:visibility="gone">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/player_preroll_brand"
|
||||
android:layout_width="58dp"
|
||||
android:layout_height="50dp"
|
||||
android:layout_gravity="start|top"
|
||||
android:layout_marginStart="44dp"
|
||||
android:layout_marginTop="28dp"
|
||||
android:contentDescription="@string/player_preroll_brand_logo"
|
||||
android:scaleType="fitCenter"
|
||||
android:src="@drawable/emby_logo" />
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/player_preroll_content"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal"
|
||||
android:paddingStart="64dp"
|
||||
android:paddingTop="54dp"
|
||||
android:paddingEnd="64dp"
|
||||
android:paddingBottom="54dp">
|
||||
|
||||
<!-- Reserved for the eventual pre-roll media asset. Keeping this a plain View is
|
||||
virtually free and lets the real programme buffer beneath the overlay. -->
|
||||
<FrameLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_marginEnd="52dp"
|
||||
android:layout_weight="1.62"
|
||||
android:background="@drawable/player_preroll_video_background">
|
||||
|
||||
<ImageView
|
||||
android:layout_width="74dp"
|
||||
android:layout_height="64dp"
|
||||
android:layout_gravity="start|bottom"
|
||||
android:layout_marginStart="28dp"
|
||||
android:layout_marginBottom="25dp"
|
||||
android:alpha="0.24"
|
||||
android:contentDescription="@null"
|
||||
android:scaleType="fitCenter"
|
||||
android:src="@drawable/emby_logo" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/player_preroll_countdown"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="end|bottom"
|
||||
android:layout_marginEnd="28dp"
|
||||
android:layout_marginBottom="28dp"
|
||||
android:background="@drawable/player_status_background"
|
||||
android:paddingStart="18dp"
|
||||
android:paddingTop="10dp"
|
||||
android:paddingEnd="18dp"
|
||||
android:paddingBottom="10dp"
|
||||
android:text="@string/player_preroll_countdown_initial"
|
||||
android:textColor="#FFFFFFFF"
|
||||
android:textSize="15sp"
|
||||
android:textStyle="bold" />
|
||||
</FrameLayout>
|
||||
android:clipChildren="false"
|
||||
android:gravity="center_horizontal"
|
||||
android:orientation="vertical"
|
||||
android:paddingStart="44dp"
|
||||
android:paddingTop="84dp"
|
||||
android:paddingEnd="44dp"
|
||||
android:paddingBottom="22dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical">
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="224dp"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Coming up"
|
||||
android:textColor="#FFFFFFFF"
|
||||
android:textSize="30sp"
|
||||
android:textStyle="bold" />
|
||||
<FrameLayout
|
||||
android:id="@+id/player_preroll_video_host"
|
||||
android:layout_width="398dp"
|
||||
android:layout_height="224dp"
|
||||
android:layout_marginEnd="38dp"
|
||||
android:background="@drawable/player_preroll_video_background"
|
||||
android:clipToOutline="true"
|
||||
android:foreground="@drawable/player_preroll_video_frame"
|
||||
android:outlineProvider="background">
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="5dp"
|
||||
android:text="From your Sonarr calendar"
|
||||
android:textColor="#FF8E979D"
|
||||
android:textSize="13sp" />
|
||||
<LinearLayout
|
||||
android:id="@+id/player_preroll_countdown_container"
|
||||
android:layout_width="80dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="end|bottom"
|
||||
android:layout_marginEnd="18dp"
|
||||
android:layout_marginBottom="16dp"
|
||||
android:gravity="center_horizontal"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/player_preroll_today_label"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="25dp"
|
||||
android:text="AIRING TODAY"
|
||||
android:textAllCaps="true"
|
||||
android:textColor="#FF55B94D"
|
||||
android:textSize="12sp"
|
||||
android:textStyle="bold" />
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="5dp"
|
||||
android:text="@string/player_preroll_starting_in"
|
||||
android:textAllCaps="true"
|
||||
android:textColor="#CCFFFFFF"
|
||||
android:textSize="9sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<com.ponzischeme89.memby.ui.player.PrerollCountdownView
|
||||
android:id="@+id/player_preroll_countdown"
|
||||
android:layout_width="66dp"
|
||||
android:layout_height="66dp"
|
||||
android:contentDescription="@string/player_preroll_countdown_initial" />
|
||||
</LinearLayout>
|
||||
</FrameLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/player_preroll_today"
|
||||
android:layout_width="match_parent"
|
||||
android:id="@+id/player_preroll_schedule_panel"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:orientation="vertical" />
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/player_preroll_week_label"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="22dp"
|
||||
android:text="THIS WEEK"
|
||||
android:textAllCaps="true"
|
||||
android:textColor="#FF8E979D"
|
||||
android:textSize="12sp"
|
||||
android:textStyle="bold" />
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="NOW"
|
||||
android:textColor="#FF64C85A"
|
||||
android:textSize="11sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/player_preroll_week"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:orientation="vertical" />
|
||||
<TextView
|
||||
android:id="@+id/player_preroll_now_title"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="7dp"
|
||||
android:ellipsize="end"
|
||||
android:maxLines="2"
|
||||
android:text="Northbound · The Crossing"
|
||||
android:textColor="#FFFFFFFF"
|
||||
android:textSize="26sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/player_preroll_now_metadata"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:text="S02E04 · 48 mins"
|
||||
android:textColor="#FFD0D6DB"
|
||||
android:textSize="12sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/player_preroll_now_overview"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="10dp"
|
||||
android:ellipsize="end"
|
||||
android:maxLines="3"
|
||||
android:text="The crew follows a signal beyond the last charted crossing and finds the shore waiting for them."
|
||||
android:textColor="#FFB1BAC1"
|
||||
android:textSize="13sp" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal"
|
||||
android:paddingStart="4dp"
|
||||
android:paddingEnd="4dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="UPCOMING"
|
||||
android:textColor="#FF64C85A"
|
||||
android:textSize="11sp"
|
||||
android:textStyle="bold" />
|
||||
</LinearLayout>
|
||||
|
||||
<GridLayout
|
||||
android:id="@+id/player_preroll_calendar"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="170dp"
|
||||
android:layout_marginTop="5dp"
|
||||
android:columnCount="4"
|
||||
android:rowCount="2"
|
||||
android:useDefaultMargins="false" />
|
||||
</LinearLayout>
|
||||
</FrameLayout>
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="80dp"
|
||||
android:background="@drawable/player_preroll_card_background"
|
||||
android:clipToOutline="true"
|
||||
android:outlineProvider="background">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/player_preroll_card_artwork"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:contentDescription="@null"
|
||||
android:scaleType="centerCrop" />
|
||||
|
||||
<View
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="@drawable/player_preroll_card_scrim" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="vertical"
|
||||
android:paddingStart="10dp"
|
||||
android:paddingTop="8dp"
|
||||
android:paddingEnd="9dp"
|
||||
android:paddingBottom="8dp">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/player_preroll_card_label"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:textAllCaps="true"
|
||||
android:textColor="#FF64C85A"
|
||||
android:textSize="8sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/player_preroll_card_title"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="3dp"
|
||||
android:ellipsize="end"
|
||||
android:maxLines="1"
|
||||
android:textColor="#FFFFFFFF"
|
||||
android:textSize="14sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/player_preroll_card_detail"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="2dp"
|
||||
android:ellipsize="end"
|
||||
android:maxLines="1"
|
||||
android:textColor="#FFD0D6DB"
|
||||
android:textSize="9sp" />
|
||||
</LinearLayout>
|
||||
|
||||
<View
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="@drawable/player_preroll_card_frame" />
|
||||
</FrameLayout>
|
||||
@@ -0,0 +1,65 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Finale context is a separate, higher-priority lower third. It sits above the normal
|
||||
start/time cue so both can be read together and never takes focus from playback. -->
|
||||
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:id="@+id/player_season_finale"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="bottom|start"
|
||||
android:layout_marginStart="52dp"
|
||||
android:layout_marginBottom="120dp"
|
||||
android:clipChildren="false"
|
||||
android:clipToPadding="false"
|
||||
android:focusable="false"
|
||||
android:visibility="gone">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="@drawable/time_remaining_cue_background"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal"
|
||||
android:paddingEnd="18dp">
|
||||
|
||||
<View
|
||||
android:layout_width="3dp"
|
||||
android:layout_height="32dp"
|
||||
android:background="#FFFFB454" />
|
||||
|
||||
<ImageView
|
||||
android:layout_width="30dp"
|
||||
android:layout_height="27dp"
|
||||
android:layout_marginStart="10dp"
|
||||
android:contentDescription="@null"
|
||||
android:scaleType="fitCenter"
|
||||
android:src="@drawable/emby_logo" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/player_season_finale_label"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="10dp"
|
||||
android:letterSpacing="0.16"
|
||||
android:paddingTop="9dp"
|
||||
android:paddingBottom="9dp"
|
||||
android:text="@string/player_season_finale_label"
|
||||
android:textColor="#FFFFC778"
|
||||
android:textSize="10sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/player_season_finale_value"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="10dp"
|
||||
android:ellipsize="end"
|
||||
android:maxLines="1"
|
||||
android:paddingTop="9dp"
|
||||
android:paddingBottom="9dp"
|
||||
android:textColor="#FFFFFFFF"
|
||||
android:textSize="14sp"
|
||||
android:textStyle="bold"
|
||||
tools:text="Signal Hill · Season 2 finale" />
|
||||
</LinearLayout>
|
||||
</FrameLayout>
|
||||
@@ -0,0 +1,66 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- The ten-minute lower third. A single non-focusable strip pinned to the lower left,
|
||||
inside the TV overscan margin, that rolls up from below its own baseline and rolls
|
||||
back down again. It never takes the remote and never covers the transport controls. -->
|
||||
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:id="@+id/player_time_remaining"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="bottom|start"
|
||||
android:layout_marginStart="52dp"
|
||||
android:layout_marginBottom="72dp"
|
||||
android:clipChildren="false"
|
||||
android:clipToPadding="false"
|
||||
android:focusable="false"
|
||||
android:visibility="gone">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="@drawable/time_remaining_cue_background"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal"
|
||||
android:paddingStart="0dp"
|
||||
android:paddingEnd="18dp">
|
||||
|
||||
<!-- Keep the cue recognisably Memby even when it appears independently of the
|
||||
transport OSD. The accent rule still distinguishes ordinary timing cues. -->
|
||||
<View
|
||||
android:layout_width="3dp"
|
||||
android:layout_height="26dp"
|
||||
android:background="#FF69CD61" />
|
||||
|
||||
<ImageView
|
||||
android:layout_width="27dp"
|
||||
android:layout_height="24dp"
|
||||
android:layout_marginStart="10dp"
|
||||
android:contentDescription="@null"
|
||||
android:scaleType="fitCenter"
|
||||
android:src="@drawable/emby_logo" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/player_time_remaining_label"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="10dp"
|
||||
android:letterSpacing="0.16"
|
||||
android:paddingTop="7dp"
|
||||
android:paddingBottom="7dp"
|
||||
android:text="@string/player_time_remaining_label"
|
||||
android:textColor="#99FFFFFF"
|
||||
android:textSize="10sp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/player_time_remaining_value"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="10dp"
|
||||
android:paddingTop="7dp"
|
||||
android:paddingBottom="7dp"
|
||||
android:textColor="#FFFFFFFF"
|
||||
android:textSize="14sp"
|
||||
android:textStyle="bold"
|
||||
tools:text="10 mins" />
|
||||
</LinearLayout>
|
||||
</FrameLayout>
|
||||
@@ -14,6 +14,10 @@
|
||||
<item quantity="other">Reconnecting in %1$d seconds…</item>
|
||||
</plurals>
|
||||
<string name="player_now_playing">NOW PLAYING</string>
|
||||
<string name="player_paused">PAUSED</string>
|
||||
<string name="player_pause_poster">Movie poster</string>
|
||||
<string name="player_pause_resume_hint">Press OK to resume</string>
|
||||
<string name="player_pause_overview_fallback">No description is available for this title.</string>
|
||||
<string name="player_position_separator">/</string>
|
||||
<string name="player_loading_duration">Loading duration…</string>
|
||||
<string name="player_live">Live</string>
|
||||
@@ -29,12 +33,24 @@
|
||||
<string name="player_text_size">TEXT SIZE</string>
|
||||
<string name="player_back_to_close">BACK · CLOSE</string>
|
||||
<string name="player_ends_at">Ends at %1$s</string>
|
||||
<string name="player_preroll_countdown_initial">Starting in 5 seconds…</string>
|
||||
<string name="player_preroll_countdown_initial">Starting in 7 seconds…</string>
|
||||
<string name="player_preroll_starting_in">Starting in</string>
|
||||
<string name="player_preroll_brand_logo">Memby logo</string>
|
||||
<plurals name="player_preroll_countdown">
|
||||
<item quantity="one">Starting in %1$d second…</item>
|
||||
<item quantity="other">Starting in %1$d seconds…</item>
|
||||
</plurals>
|
||||
<string name="player_preroll_starting">Starting now…</string>
|
||||
<string name="player_time_remaining_label">TIME REMAINING</string>
|
||||
<string name="player_season_finale_label">SEASON FINALE</string>
|
||||
<string name="player_season_finale_value">%1$s · Season %2$d finale</string>
|
||||
<string name="player_finishes_in_label">FINISHES IN</string>
|
||||
<string name="player_resume_time_left_label">TIME LEFT</string>
|
||||
<string name="player_finishes_in_value">%1$s (%2$s)</string>
|
||||
<plurals name="player_time_remaining_cue">
|
||||
<item quantity="one">%1$d min</item>
|
||||
<item quantity="other">%1$d mins</item>
|
||||
</plurals>
|
||||
<string name="next_up_label">NEXT UP</string>
|
||||
<string name="next_up_play_now">Play now</string>
|
||||
<string name="next_up_dismiss">Dismiss</string>
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
package com.ponzischeme89.memby.data
|
||||
|
||||
import com.ponzischeme89.memby.data.playback.DevicePlaybackCapabilities
|
||||
import com.ponzischeme89.memby.data.playback.VideoDecoderCapabilities
|
||||
import com.ponzischeme89.memby.data.playback.gatewayCapabilityTokens
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class DevicePlaybackCapabilitiesTest {
|
||||
@Test
|
||||
fun detailedH264AndHevcDecoderSupportIsReportedToTheGateway() {
|
||||
val capabilities = DevicePlaybackCapabilities(
|
||||
h264 = VideoDecoderCapabilities(
|
||||
supported = true,
|
||||
profiles = setOf("baseline", "main", "high"),
|
||||
mainLevel = 52,
|
||||
maxWidth = 3840,
|
||||
maxHeight = 2160,
|
||||
),
|
||||
hevc = VideoDecoderCapabilities(
|
||||
supported = true,
|
||||
profiles = setOf("main", "main10"),
|
||||
mainLevel = 153,
|
||||
tenBitLevel = 153,
|
||||
maxWidth = 3840,
|
||||
maxHeight = 2160,
|
||||
hdr10 = true,
|
||||
),
|
||||
)
|
||||
val tokens = capabilities.gatewayCapabilityTokens()
|
||||
|
||||
assertTrue("video_h264_profile_high" in tokens)
|
||||
assertTrue("video_h264_level_52" in tokens)
|
||||
assertTrue("video_h264_max_3840x2160" in tokens)
|
||||
assertTrue("video_hevc_decode" in tokens)
|
||||
assertTrue("video_hevc_profile_main10" in tokens)
|
||||
assertTrue("video_hevc_main10_level_153" in tokens)
|
||||
assertTrue("video_hevc_hdr10" in tokens)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun h264OnlyDeviceDoesNotClaimHevc() {
|
||||
val tokens = DevicePlaybackCapabilities().gatewayCapabilityTokens()
|
||||
|
||||
assertTrue("video_h264_decode" in tokens)
|
||||
assertTrue("video_hevc_decode" !in tokens)
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,14 @@
|
||||
package com.ponzischeme89.memby.data
|
||||
|
||||
import com.ponzischeme89.memby.data.model.GatewayHome
|
||||
import com.ponzischeme89.memby.data.model.GatewayDevices
|
||||
import com.ponzischeme89.memby.data.model.GatewayNextEpisode
|
||||
import com.ponzischeme89.memby.data.model.GatewayPlayback
|
||||
import com.ponzischeme89.memby.data.model.GatewayPlaybackReportResponse
|
||||
import com.ponzischeme89.memby.data.model.GatewaySearchHistory
|
||||
import com.ponzischeme89.memby.data.model.GatewayServiceStatus
|
||||
import com.ponzischeme89.memby.data.model.GatewayFeatures
|
||||
import com.ponzischeme89.memby.data.model.RecommendationOnboarding
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
@@ -19,6 +23,17 @@ import org.junit.Test
|
||||
* side, this fails before a TV ever sees it.
|
||||
*/
|
||||
class GatewayPayloadTest {
|
||||
@Test
|
||||
fun `decodes signed in devices without an allowance`() {
|
||||
val response = json.decodeFromString<GatewayDevices>(
|
||||
"""{"devices":[{"deviceId":"tv-1","deviceName":"Living room","clientVersion":"0.2.4","lastSeenAt":"2026-08-02T10:00:00Z","current":true}]}""",
|
||||
)
|
||||
|
||||
assertEquals(1, response.devices.size)
|
||||
assertEquals("Living room", response.devices.single().deviceName)
|
||||
assertTrue(response.devices.single().current)
|
||||
}
|
||||
|
||||
|
||||
private val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
@@ -38,6 +53,40 @@ class GatewayPayloadTest {
|
||||
assertEquals(1, status.serverProtocol)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `decodes live feature policy and recovery state`() {
|
||||
val status = json.decodeFromString<GatewayServiceStatus>(
|
||||
"""{"featureSchemaVersion":1,"featureRevision":7,"safeMode":true,"features":{"sonarr_preroll":false}}""",
|
||||
)
|
||||
val policy = json.decodeFromString<GatewayFeatures>(
|
||||
"""{"schemaVersion":1,"revision":7,"safeMode":true,"canRollback":true,"features":[{"key":"sonarr_preroll","name":"Sonarr upcoming preroll","enabled":false,"source":"safe_mode","compatible":true}]}""",
|
||||
)
|
||||
|
||||
assertEquals(7L, status.featureRevision)
|
||||
assertEquals(false, status.features["sonarr_preroll"])
|
||||
assertTrue(policy.safeMode)
|
||||
assertTrue(policy.canRollback)
|
||||
assertEquals("safe_mode", policy.features.single().source)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `decodes recommendation onboarding ratings and emby items`() {
|
||||
val onboarding = json.decodeFromString<RecommendationOnboarding>(
|
||||
"""{
|
||||
"completed":false,
|
||||
"ratings":{"movie-1":5},
|
||||
"items":[
|
||||
{"Id":"movie-1","Name":"Arrival","Type":"Movie"},
|
||||
{"Id":"series-1","Name":"Severance","Type":"Series"}
|
||||
]
|
||||
}""",
|
||||
)
|
||||
|
||||
assertEquals(false, onboarding.completed)
|
||||
assertEquals(5, onboarding.ratings["movie-1"])
|
||||
assertEquals(listOf("Arrival", "Severance"), onboarding.items.map { it.name })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `decodes a home payload with emby-shaped items`() {
|
||||
val payload = """
|
||||
@@ -119,12 +168,12 @@ class GatewayPayloadTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `decodes the informational Sonarr schedule row`() {
|
||||
fun `decodes the informational TV schedule row`() {
|
||||
val payload = """
|
||||
{
|
||||
"rows": [{
|
||||
"id":"sonarr-airing-today",
|
||||
"title":"Shows airing today",
|
||||
"title":"Shows airing in the next 5 days",
|
||||
"kind":"schedule",
|
||||
"items":[{
|
||||
"Id":"sonarr:7:42",
|
||||
@@ -134,7 +183,8 @@ class GatewayPayloadTest {
|
||||
"MembySource":"sonarr",
|
||||
"MembyEpisodeTitle":"The Crossing",
|
||||
"MembyEpisodeCode":"S02E04",
|
||||
"MembyAirLabel":"Airs today at 8:00 PM",
|
||||
"MembyAirDayLabel":"Tomorrow",
|
||||
"MembyAirLabel":"Tomorrow: 8:00 PM",
|
||||
"MembyAvailability":"downloading",
|
||||
"MembyAvailabilityText":"Downloading",
|
||||
"MembyPlayable":false
|
||||
@@ -150,12 +200,48 @@ class GatewayPayloadTest {
|
||||
|
||||
val item = json.decodeFromString<GatewayHome>(payload).rows.single().items.single()
|
||||
|
||||
assertTrue(item.isSonarrSchedule)
|
||||
assertTrue(item.isTvSchedule)
|
||||
assertEquals("S02E04", item.membyEpisodeCode)
|
||||
assertEquals("Tomorrow", item.membyAirDayLabel)
|
||||
assertEquals("Downloading", item.membyAvailabilityText)
|
||||
assertEquals(false, item.membyPlayable)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `decodes the informational Radarr digital release row`() {
|
||||
val payload = """
|
||||
{
|
||||
"rows":[{
|
||||
"id":"radarr-upcoming-movies",
|
||||
"title":"Upcoming Movie releases",
|
||||
"kind":"movie-schedule",
|
||||
"items":[{
|
||||
"Id":"radarr:7",
|
||||
"Name":"Arrival",
|
||||
"Type":"MembyRadarrMovie",
|
||||
"ImageTags":{"Primary":"radarr"},
|
||||
"MembySource":"radarr",
|
||||
"MembyAirsAt":"2026-08-01T00:00:00+12:00",
|
||||
"MembyAirDayLabel":"Saturday",
|
||||
"MembyAirLabel":"Digital release Saturday",
|
||||
"MembyAvailability":"upcoming",
|
||||
"MembyAvailabilityText":"Upcoming digital release",
|
||||
"MembyPlayable":false
|
||||
}]
|
||||
}]
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
val row = json.decodeFromString<GatewayHome>(payload).rows.single()
|
||||
val item = row.items.single()
|
||||
|
||||
assertEquals("movie-schedule", row.kind)
|
||||
assertTrue(item.isMovieSchedule)
|
||||
assertTrue(item.isSchedule)
|
||||
assertEquals("Digital release Saturday", item.membyAirLabel)
|
||||
assertEquals(false, item.membyPlayable)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a home payload without rows still decodes`() {
|
||||
// The gateway omits recommendation rows while they are still building, and an
|
||||
@@ -186,13 +272,26 @@ class GatewayPayloadTest {
|
||||
@Test
|
||||
fun `decodes a playback response`() {
|
||||
val playback = json.decodeFromString<GatewayPlayback>(
|
||||
"""{"itemId":"9","title":"Severance – Pilot","url":"https://emby.example/Videos/9/stream?static=true","resumePositionMs":42000}""",
|
||||
"""{"itemId":"9","title":"Severance – Pilot","overview":"Mark returns to the severed floor.","seriesName":"Severance","episodeCode":"S01E01","runtimeMs":3420000,"prerollEnabled":false,"prerollDurationMs":4000,"url":"https://emby.example/Videos/9/stream?static=true","resumePositionMs":42000}""",
|
||||
)
|
||||
assertEquals("9", playback.itemId)
|
||||
assertEquals(42_000L, playback.resumePositionMs)
|
||||
assertEquals("S01E01", playback.episodeCode)
|
||||
assertEquals(3_420_000L, playback.runtimeMs)
|
||||
assertEquals(false, playback.prerollEnabled)
|
||||
assertEquals(4_000L, playback.prerollDurationMs)
|
||||
assertTrue(playback.url.startsWith("https://emby.example/Videos/9/stream"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `decodes a one-time auto-follow acknowledgement`() {
|
||||
val response = json.decodeFromString<GatewayPlaybackReportResponse>(
|
||||
"""{"autoFollowedShowTitle":"Severance"}""",
|
||||
)
|
||||
|
||||
assertEquals("Severance", response.autoFollowedShowTitle)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `decodes the next-episode response the player counts down to`() {
|
||||
val next = json.decodeFromString<GatewayNextEpisode>(
|
||||
@@ -235,13 +334,4 @@ class GatewayPayloadTest {
|
||||
assertNull(next.item.episodeCode)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `device allowance response becomes a specific sign-in error`() {
|
||||
val error = parseDeviceLimit(
|
||||
"""{"error":"device_limit_reached","activeClients":3,"maxClientsPerUser":3}""",
|
||||
)
|
||||
|
||||
assertEquals(3, error?.activeClients)
|
||||
assertEquals(3, error?.maxClients)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,12 +30,14 @@ class GatewayUpdateTest {
|
||||
@Test
|
||||
fun `an optional verdict is dismissable`() {
|
||||
val update = json.decodeFromString<GatewayUpdate>(
|
||||
"""{"status":"optional","version":"0.1.54","downloadUrl":"https://nas/memby.apk"}""",
|
||||
"""{"status":"optional","version":"0.1.54","downloadUrl":"https://nas/memby.apk","sha256":"abc","sizeBytes":123}""",
|
||||
)
|
||||
|
||||
assertTrue(update.isOptional)
|
||||
assertFalse(update.isMandatory)
|
||||
assertTrue(update.isActionable)
|
||||
assertEquals("abc", update.sha256)
|
||||
assertEquals(123L, update.sizeBytes)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
package com.ponzischeme89.memby.data
|
||||
|
||||
import com.ponzischeme89.memby.data.model.MediaSourceInfo
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
class PlaybackDeliveryTest {
|
||||
@Test
|
||||
fun directPlayUsesOriginalOnlyWhenServerMarksItSupported() {
|
||||
val delivery = selectPlaybackDelivery(
|
||||
MediaSourceInfo(
|
||||
supportsDirectPlay = true,
|
||||
directStreamUrl = "/Videos/item/stream.mkv",
|
||||
transcodingUrl = "/Videos/item/master.m3u8",
|
||||
),
|
||||
)
|
||||
|
||||
assertNull(delivery.url)
|
||||
assertEquals("DirectPlay", delivery.playMethod)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun negotiatedDirectStreamIsUsedForContainerCompatibility() {
|
||||
val delivery = selectPlaybackDelivery(
|
||||
MediaSourceInfo(
|
||||
supportsDirectPlay = false,
|
||||
supportsDirectStream = true,
|
||||
directStreamUrl = "/Videos/item/stream.mp4",
|
||||
transcodingUrl = "/Videos/item/master.m3u8",
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals("/Videos/item/stream.mp4", delivery.url)
|
||||
assertEquals("DirectStream", delivery.playMethod)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun forcedFallbackUsesTranscodeEvenWhenDirectPlayWasAdvertised() {
|
||||
val delivery = selectPlaybackDelivery(
|
||||
MediaSourceInfo(
|
||||
supportsDirectPlay = true,
|
||||
transcodingUrl = "/Videos/item/master.m3u8",
|
||||
),
|
||||
forceTranscode = true,
|
||||
)
|
||||
|
||||
assertEquals("/Videos/item/master.m3u8", delivery.url)
|
||||
assertEquals("Transcode", delivery.playMethod)
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,11 @@
|
||||
package com.ponzischeme89.memby.data
|
||||
|
||||
import com.ponzischeme89.memby.data.model.DeviceProfile
|
||||
import com.ponzischeme89.memby.data.playback.DevicePlaybackCapabilities
|
||||
import com.ponzischeme89.memby.data.playback.VideoDecoderCapabilities
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class PlaybackReportMathTest {
|
||||
@@ -26,4 +30,45 @@ class PlaybackReportMathTest {
|
||||
assertEquals("Encode", profiles["pgssub"])
|
||||
assertEquals("Encode", profiles["dvdsub"])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun directPlayProfileAdvertisesHevcOnlyForCapableTvs() {
|
||||
val baseline = DeviceProfile.embyAndroidTv().directPlayProfiles.single()
|
||||
val capable = DeviceProfile.embyAndroidTv(supportsHevc = true).directPlayProfiles.single()
|
||||
|
||||
assertEquals("h264", baseline.videoCodec)
|
||||
assertEquals("h264,hevc", capable.videoCodec)
|
||||
assertEquals("aac,mp3", capable.audioCodec)
|
||||
assertFalse(capable.videoCodec.contains("av1"))
|
||||
assertFalse(capable.audioCodec.contains("eac3"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun detailedProfileAllowsVideoStreamCopyWithinDecoderLimits() {
|
||||
val profile = DeviceProfile.embyAndroidTv(
|
||||
DevicePlaybackCapabilities(
|
||||
h264 = VideoDecoderCapabilities(
|
||||
supported = true,
|
||||
profiles = setOf("baseline", "main", "high"),
|
||||
mainLevel = 52,
|
||||
maxWidth = 3840,
|
||||
maxHeight = 2160,
|
||||
),
|
||||
hevc = VideoDecoderCapabilities(
|
||||
supported = true,
|
||||
profiles = setOf("main", "main10"),
|
||||
mainLevel = 153,
|
||||
tenBitLevel = 153,
|
||||
maxWidth = 3840,
|
||||
maxHeight = 2160,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals("h264,hevc", profile.directPlayProfiles.single().videoCodec)
|
||||
assertEquals("h264,hevc", profile.transcodingProfiles.single().videoCodec)
|
||||
assertTrue(profile.codecProfiles.any { it.codec == "h264" && it.conditions.any { c -> c.value == "52" } })
|
||||
assertTrue(profile.codecProfiles.any { it.codec == "hevc" && it.conditions.any { c -> c.value == "153" } })
|
||||
assertTrue(profile.codecProfiles.any { it.codec == "hevc" && it.conditions.any { c -> c.property == "Width" } })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,4 +53,17 @@ class ProfileSettingsTest {
|
||||
assertEquals(30, family.forYouMinutes)
|
||||
assertEquals(false, family.hasOpenedForYou)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `welcome quote style belongs to each profile`() {
|
||||
val matt = profile.copy(welcomeQuoteStyle = "homicidal")
|
||||
val family = profile.copy(
|
||||
id = "server::family",
|
||||
userId = "family",
|
||||
welcomeQuoteStyle = "positive",
|
||||
)
|
||||
|
||||
assertEquals("homicidal", matt.welcomeQuoteStyle)
|
||||
assertEquals("positive", family.welcomeQuoteStyle)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,6 +73,19 @@ class RowAnalyticsTest {
|
||||
assertEquals(listOf("favorites", "recommended"), impressions.map { it.rowId })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `visible posters receive deduplicated item impressions`() {
|
||||
val collector = analytics(FakeClock())
|
||||
|
||||
collector.rowImpression("recommended", "MOVIES", listOf("one", "two"))
|
||||
collector.rowImpression("recommended", "MOVIES", listOf("one", "two"))
|
||||
|
||||
val itemImpressions = collector.drain().filter {
|
||||
it.event == RowAnalytics.EVENT_IMPRESSION && it.itemId.isNotEmpty()
|
||||
}
|
||||
assertEquals(listOf("one", "two"), itemImpressions.map { it.itemId })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `focusing a row that was never reported still records the impression`() {
|
||||
val collector = analytics(FakeClock())
|
||||
|
||||
@@ -85,5 +85,28 @@ class ServiceAlertTest {
|
||||
assertEquals("sonarr:7:42:aired", alert.id)
|
||||
assertEquals("sonarr:7:42", alert.itemId)
|
||||
assertEquals("sonarr", alert.imageTag)
|
||||
// A gateway that predates server-worded banners sends no label at all.
|
||||
assertEquals("", alert.label)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `status decodes a radarr import alert with its own wording`() {
|
||||
val status = json.decodeFromString<GatewayServiceStatus>(
|
||||
"""
|
||||
{"maintenance":false,"message":"","alerts":[{
|
||||
"id":"radarr:412:file:9001","kind":"radarr-import","label":"NEW MOVIE ADDED",
|
||||
"title":"Mr. Smith Goes to Washington (1939)",
|
||||
"message":"Mr. Smith Goes to Washington will be available in Emby shortly.",
|
||||
"itemId":"radarr:412","imageTag":"radarr","airedAt":"2026-07-31T19:04:00Z"
|
||||
}]}
|
||||
""".trimIndent(),
|
||||
)
|
||||
val alert = status.alerts.single()
|
||||
assertEquals("radarr:412:file:9001", alert.id)
|
||||
assertEquals("NEW MOVIE ADDED", alert.label)
|
||||
// The image proxy serves Radarr covers under this pair, so the banner has a
|
||||
// poster before Emby has scanned the film in.
|
||||
assertEquals("radarr:412", alert.itemId)
|
||||
assertEquals("radarr", alert.imageTag)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.ponzischeme89.memby.data
|
||||
|
||||
import okhttp3.ResponseBody.Companion.toResponseBody
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import retrofit2.HttpException
|
||||
import retrofit2.Response
|
||||
import java.io.IOException
|
||||
|
||||
class SessionValidationTest {
|
||||
@Test
|
||||
fun `server restart preserves saved session`() {
|
||||
assertTrue(shouldPreserveSessionAfterValidationFailure(IOException("offline")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `maintenance response preserves saved session`() {
|
||||
assertTrue(shouldPreserveSessionAfterValidationFailure(httpFailure(503)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `explicit unauthorized response may invalidate saved session`() {
|
||||
assertFalse(shouldPreserveSessionAfterValidationFailure(httpFailure(401)))
|
||||
}
|
||||
|
||||
private fun httpFailure(code: Int): HttpException =
|
||||
HttpException(Response.error<Any>(code, "error".toResponseBody()))
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package com.ponzischeme89.memby.data
|
||||
|
||||
import android.content.Context
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class SettingsStoreProfileRemovalTest {
|
||||
@Test
|
||||
fun `ten minute reminder preference is persisted`() = runBlocking {
|
||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||
val store = SettingsStore(context)
|
||||
|
||||
store.setShowTenMinuteReminder(false)
|
||||
|
||||
assertFalse(store.snapshot().showTenMinuteReminder)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `removing profiles preserves other users and clears only an active session`() = runBlocking {
|
||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||
val store = SettingsStore(context)
|
||||
|
||||
store.saveSession("https://example.test", "token-a", "user-a", "Alex", "server")
|
||||
val alex = store.snapshot().profiles.single()
|
||||
store.saveSession("https://example.test", "token-b", "user-b", "Bailey", "server")
|
||||
val bailey = store.snapshot().profiles.single { it.userId == "user-b" }
|
||||
|
||||
store.removeProfile(alex.id)
|
||||
val afterInactiveRemoval = store.snapshot()
|
||||
assertEquals(listOf(bailey.id), afterInactiveRemoval.profiles.map { it.id })
|
||||
assertTrue(afterInactiveRemoval.isSignedIn)
|
||||
assertEquals("user-b", afterInactiveRemoval.userId)
|
||||
|
||||
store.removeProfile(bailey.id)
|
||||
val afterActiveRemoval = store.snapshot()
|
||||
assertTrue(afterActiveRemoval.profiles.isEmpty())
|
||||
assertFalse(afterActiveRemoval.isSignedIn)
|
||||
}
|
||||
}
|
||||
@@ -4,12 +4,13 @@ import com.ponzischeme89.memby.data.HomeSnapshot
|
||||
import com.ponzischeme89.memby.data.model.BaseItem
|
||||
import com.ponzischeme89.memby.data.model.HomeRow
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class AiringTodayTagsTest {
|
||||
@Test
|
||||
fun `tags matching recommended series from today's Sonarr schedule`() {
|
||||
fun `tags matching recommended series from today's TV schedule`() {
|
||||
val home = HomeSnapshot(
|
||||
rows = listOf(
|
||||
HomeRow(
|
||||
@@ -22,6 +23,7 @@ class AiringTodayTagsTest {
|
||||
name = "The Bear",
|
||||
type = "MembySonarrEpisode",
|
||||
membySource = "sonarr",
|
||||
membyAirDayLabel = "Today",
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -56,6 +58,7 @@ class AiringTodayTagsTest {
|
||||
name = "Marvel's DAREDEVIL",
|
||||
type = "MembySonarrEpisode",
|
||||
membySource = "sonarr",
|
||||
membyAirDayLabel = "Today",
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -85,6 +88,7 @@ class AiringTodayTagsTest {
|
||||
name = "Fargo",
|
||||
type = "MembySonarrEpisode",
|
||||
membySource = "sonarr",
|
||||
membyAirDayLabel = "Today",
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -98,4 +102,80 @@ class AiringTodayTagsTest {
|
||||
|
||||
assertFalse(home.withAiringTodayTags().rows.last().items.single().membyAiringToday)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `only today's scheduled show is propagated to other rows`() {
|
||||
val home = HomeSnapshot(
|
||||
rows = listOf(
|
||||
HomeRow(
|
||||
id = "sonarr-airing-today",
|
||||
title = "Shows airing in the next 5 days",
|
||||
kind = "schedule",
|
||||
items = listOf(
|
||||
BaseItem(
|
||||
id = "sonarr:1:1",
|
||||
name = "Today Show",
|
||||
type = "MembySonarrEpisode",
|
||||
membySource = "sonarr",
|
||||
membyAirDayLabel = "Today",
|
||||
),
|
||||
BaseItem(
|
||||
id = "sonarr:2:1",
|
||||
name = "Tomorrow Show",
|
||||
type = "MembySonarrEpisode",
|
||||
membySource = "sonarr",
|
||||
membyAirDayLabel = "Tomorrow",
|
||||
),
|
||||
),
|
||||
),
|
||||
HomeRow(
|
||||
id = "recommended",
|
||||
title = "Recommended",
|
||||
items = listOf(
|
||||
BaseItem(id = "series-1", name = "Today Show", type = "Series"),
|
||||
BaseItem(id = "series-2", name = "Tomorrow Show", type = "Series"),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
val recommendations = home.withAiringTodayTags().rows.last().items
|
||||
assertTrue(recommendations[0].membyAiringToday)
|
||||
assertFalse(recommendations[1].membyAiringToday)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `schedule poster badges use each server-authored day`() {
|
||||
fun scheduled(day: String) = BaseItem(
|
||||
id = day,
|
||||
name = "Show",
|
||||
type = "MembySonarrEpisode",
|
||||
membySource = "sonarr",
|
||||
membyAirDayLabel = day,
|
||||
membyAiringToday = true,
|
||||
)
|
||||
|
||||
assertEquals("TODAY", airingBadgeLabel(scheduled("Today")))
|
||||
assertEquals("TOMORROW", airingBadgeLabel(scheduled("Tomorrow")))
|
||||
assertEquals("FRIDAY", airingBadgeLabel(scheduled("Friday")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Radarr movie schedule uses its server-authored release day badge`() {
|
||||
val movie = BaseItem(
|
||||
id = "radarr:7",
|
||||
name = "Arrival",
|
||||
type = "MembyRadarrMovie",
|
||||
membySource = "radarr",
|
||||
membyAirDayLabel = "Saturday",
|
||||
)
|
||||
|
||||
assertEquals("SATURDAY", airingBadgeLabel(movie))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `upcoming schedule status does not claim the show airs today`() {
|
||||
assertEquals("UPCOMING", scheduleStatusBadgeLabel("upcoming"))
|
||||
assertEquals("UPCOMING", scheduleStatusBadgeLabel(""))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
package com.ponzischeme89.memby.ui
|
||||
|
||||
import com.ponzischeme89.memby.data.model.BaseItem
|
||||
import com.ponzischeme89.memby.ui.detail.franchiseStart
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
class DetailFranchiseTest {
|
||||
private fun movie(id: String, year: Int, collection: String? = "The Saga") = BaseItem(
|
||||
id = id,
|
||||
name = "Movie $id",
|
||||
type = "Movie",
|
||||
productionYear = year,
|
||||
collectionName = collection,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `earliest movie in the same collection starts the franchise`() {
|
||||
val current = movie("three", 2022)
|
||||
val start = franchiseStart(
|
||||
current,
|
||||
listOf(movie("two", 2018), movie("one", 2014), movie("other", 2001, "Other")),
|
||||
)
|
||||
|
||||
assertEquals("The Saga", start?.name)
|
||||
assertEquals("one", start?.firstMovie?.id)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a collection name without a sibling is not presented as a franchise`() {
|
||||
assertNull(franchiseStart(movie("only", 2020), emptyList()))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package com.ponzischeme89.memby.ui
|
||||
|
||||
import com.ponzischeme89.memby.ui.detail.DetailPosition
|
||||
import com.ponzischeme89.memby.ui.detail.DetailPositionStore
|
||||
import com.ponzischeme89.memby.ui.detail.DetailTab
|
||||
import com.ponzischeme89.memby.ui.detail.DetailZone
|
||||
import com.ponzischeme89.memby.ui.detail.detailTab
|
||||
import com.ponzischeme89.memby.ui.detail.detailTabs
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* The two rules the detail pages depend on and cannot check on a device: the tab strip is
|
||||
* decided by what the item *is*, and a page's position survives being closed.
|
||||
*/
|
||||
class DetailNavigationTest {
|
||||
|
||||
@Test
|
||||
fun `a movie always offers the same three tabs`() {
|
||||
assertEquals(
|
||||
listOf(DetailTab.OVERVIEW, DetailTab.MORE_LIKE_THIS, DetailTab.CAST_DETAILS),
|
||||
detailTabs(isSeries = false),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a series always offers episodes`() {
|
||||
assertEquals(
|
||||
listOf(DetailTab.OVERVIEW, DetailTab.EPISODES, DetailTab.MORE_LIKE_THIS, DetailTab.CAST_DETAILS),
|
||||
detailTabs(isSeries = true),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The regression this replaced: the strip used to be built from what had loaded, so a
|
||||
* movie opened with one tab and grew two more when its metadata arrived — moving the
|
||||
* strip under whatever the viewer was already pressing.
|
||||
*/
|
||||
@Test
|
||||
fun `the strip does not depend on loaded metadata`() {
|
||||
assertEquals(detailTabs(isSeries = false), detailTabs(isSeries = false))
|
||||
assertEquals(detailTabs(isSeries = true), detailTabs(isSeries = true))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an episodes key remembered from a series falls back on a movie`() {
|
||||
assertEquals(
|
||||
DetailTab.OVERVIEW,
|
||||
detailTab(DetailTab.EPISODES.key, detailTabs(isSeries = false)),
|
||||
)
|
||||
assertEquals(
|
||||
DetailTab.EPISODES,
|
||||
detailTab(DetailTab.EPISODES.key, detailTabs(isSeries = true)),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unknown key falls back to overview`() {
|
||||
assertEquals(DetailTab.OVERVIEW, detailTab("nonsense", detailTabs(isSeries = true)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a page reopens where it was left`() {
|
||||
val store = DetailPositionStore()
|
||||
store.update("show-1") {
|
||||
it.copy(tabKey = DetailTab.EPISODES.key, season = 3, zone = DetailZone.CONTENT)
|
||||
}
|
||||
store.update("show-1") { it.copy(episodeIndex = 6) }
|
||||
|
||||
assertEquals(
|
||||
DetailPosition(
|
||||
tabKey = DetailTab.EPISODES.key,
|
||||
season = 3,
|
||||
zone = DetailZone.CONTENT,
|
||||
episodeIndex = 6,
|
||||
),
|
||||
store.get("show-1"),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unvisited page opens on overview with focus on play`() {
|
||||
val position = DetailPositionStore().get("never-opened")
|
||||
|
||||
assertEquals(DetailTab.OVERVIEW.key, position.tabKey)
|
||||
assertEquals(DetailZone.PLAY, position.zone)
|
||||
assertEquals(null, position.season)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `play focus pins the complete hero while lower zones release scrolling`() {
|
||||
assertEquals(0, detailHeroScrollTarget(DetailZone.PLAY))
|
||||
assertEquals(null, detailHeroScrollTarget(DetailZone.TABS))
|
||||
assertEquals(null, detailHeroScrollTarget(DetailZone.CONTENT))
|
||||
assertEquals(null, detailHeroScrollTarget(DetailZone.RELATED))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the store is capped and keeps the most recently used`() {
|
||||
val store = DetailPositionStore(maxEntries = 3)
|
||||
listOf("a", "b", "c").forEach { id ->
|
||||
store.update(id) { it.copy(tabKey = DetailTab.CAST_DETAILS.key) }
|
||||
}
|
||||
// Touching "a" makes it the newest, so "b" is what falls out.
|
||||
store.get("a")
|
||||
store.update("d") { it.copy(tabKey = DetailTab.MORE_LIKE_THIS.key) }
|
||||
|
||||
assertEquals(3, store.size())
|
||||
assertEquals(DetailTab.CAST_DETAILS.key, store.get("a").tabKey)
|
||||
assertEquals(DetailTab.OVERVIEW.key, store.get("b").tabKey)
|
||||
assertEquals(DetailTab.MORE_LIKE_THIS.key, store.get("d").tabKey)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a blank item id is never stored`() {
|
||||
val store = DetailPositionStore()
|
||||
store.update("") { it.copy(tabKey = DetailTab.CAST_DETAILS.key) }
|
||||
|
||||
assertEquals(0, store.size())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,359 @@
|
||||
package com.ponzischeme89.memby.ui
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.onRoot
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import com.github.takahirom.roborazzi.captureRoboImage
|
||||
import com.ponzischeme89.memby.ServiceLocator
|
||||
import com.ponzischeme89.memby.data.RelatedContent
|
||||
import com.ponzischeme89.memby.data.model.BaseItem
|
||||
import com.ponzischeme89.memby.data.model.EmbyPerson
|
||||
import com.ponzischeme89.memby.data.model.MediaStream
|
||||
import com.ponzischeme89.memby.data.model.Studio
|
||||
import com.ponzischeme89.memby.data.model.UserItemData
|
||||
import com.ponzischeme89.memby.ui.detail.creditRows
|
||||
import com.ponzischeme89.memby.ui.detail.technicalSpecs
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
import org.robolectric.annotation.GraphicsMode
|
||||
|
||||
/**
|
||||
* Renders the movie and series detail pages to PNGs under `build/screenshots/`.
|
||||
*
|
||||
* ```powershell
|
||||
* .\gradlew.bat :app:testDebugUnitTest --tests "*DetailPageScreenshotTest"
|
||||
* ```
|
||||
*
|
||||
* There is no network here, so every artwork URL resolves to null and the pages render on
|
||||
* their own scrims. That is the point: it is the check that the poster column, the fact row
|
||||
* and the episode strip hold their shape when Emby has no backdrop and no poster to give —
|
||||
* the worst case, and the one a real library hits often enough to matter.
|
||||
*
|
||||
* Like [ServiceAlertBannerScreenshotTest], this is a `*ScreenshotTest.kt` file and is
|
||||
* allowed the Robolectric dependency; what these pages *say* lives in pure functions in
|
||||
* `ui/detail/DetailFacts.kt` and is covered by [SeriesDetailsTest] in plain JUnit.
|
||||
*/
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||
@Config(sdk = [34], qualifiers = "w960dp-h540dp-television-xhdpi")
|
||||
class DetailPageScreenshotTest {
|
||||
|
||||
@get:Rule
|
||||
val compose = createComposeRule()
|
||||
|
||||
@Before
|
||||
fun locator() {
|
||||
// The hero asks the repository for its artwork URLs; without a token it answers
|
||||
// null, which is exactly the fallback case being captured.
|
||||
ServiceLocator.init(ApplicationProvider.getApplicationContext())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `movie page`() {
|
||||
capture("df_detail-movie") {
|
||||
MediaDetailContent(
|
||||
item = movie,
|
||||
onPlay = {},
|
||||
onToggleFavorite = { _, _ -> },
|
||||
onTogglePlayed = { _, _ -> },
|
||||
related = related,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Part-watched: the progress bar and "Resume" wording only appear in this state. */
|
||||
@Test
|
||||
fun `movie page part watched`() {
|
||||
capture("df_detail-movie-resumable") {
|
||||
MediaDetailContent(
|
||||
item = movie.copy(
|
||||
userData = UserItemData(playbackPositionTicks = 47L * 600_000_000L),
|
||||
),
|
||||
onPlay = {},
|
||||
onToggleFavorite = { _, _ -> },
|
||||
onTogglePlayed = { _, _ -> },
|
||||
related = related,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `series page`() {
|
||||
capture("df_detail-series") {
|
||||
SeriesDetailContent(
|
||||
item = series,
|
||||
episodes = episodes,
|
||||
loadFailed = false,
|
||||
onPlay = {},
|
||||
onToggleFavorite = { _, _ -> },
|
||||
isMyShow = false,
|
||||
onToggleMyShow = { _, _ -> },
|
||||
related = related,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** The first frame, before the episode request lands. The header must already be whole. */
|
||||
@Test
|
||||
fun `series page loading`() {
|
||||
capture("df_detail-series-loading") {
|
||||
SeriesDetailContent(
|
||||
item = series,
|
||||
episodes = null,
|
||||
loadFailed = false,
|
||||
onPlay = {},
|
||||
onToggleFavorite = { _, _ -> },
|
||||
isMyShow = true,
|
||||
onToggleMyShow = { _, _ -> },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The other panes. Clicking a tab is the same path a remote takes — the strip selects
|
||||
* on focus — so this also proves the panes swap without disturbing the header.
|
||||
*/
|
||||
@Test
|
||||
fun `series episodes tab`() {
|
||||
captureTab("df_detail-series-episodes", "Episodes") {
|
||||
SeriesDetailContent(
|
||||
item = series,
|
||||
episodes = episodes,
|
||||
loadFailed = false,
|
||||
onPlay = {},
|
||||
onToggleFavorite = { _, _ -> },
|
||||
isMyShow = false,
|
||||
onToggleMyShow = { _, _ -> },
|
||||
related = related,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `series cast tab`() {
|
||||
captureTab("df_detail-series-cast-details", "Cast & Details") {
|
||||
SeriesDetailContent(
|
||||
item = series,
|
||||
episodes = episodes,
|
||||
loadFailed = false,
|
||||
onPlay = {},
|
||||
onToggleFavorite = { _, _ -> },
|
||||
isMyShow = false,
|
||||
onToggleMyShow = { _, _ -> },
|
||||
related = related,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `movie details tab`() {
|
||||
captureTab("df_detail-movie-more-like-this", "More Like This") {
|
||||
MediaDetailContent(
|
||||
item = movie,
|
||||
onPlay = {},
|
||||
onToggleFavorite = { _, _ -> },
|
||||
onTogglePlayed = { _, _ -> },
|
||||
related = related,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The tab panes at the geometry the scaffold gives them on a 540dp TV — the slot the
|
||||
* audit found clipping every technical spec off the bottom of Cast & Details, which is
|
||||
* the whole reason that tab exists. A tab does not scroll, so everything it has to say
|
||||
* has to be inside this box.
|
||||
*/
|
||||
@Test
|
||||
fun `cast and details pane at its real slot geometry`() {
|
||||
capturePane("df_detail-pane-cast-details") {
|
||||
DetailCastAndDetailsPane(
|
||||
item = movie,
|
||||
credits = creditRows(movie),
|
||||
specs = technicalSpecs(movie),
|
||||
detailsLoaded = true,
|
||||
focusRequester = FocusRequester(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `overview pane at its real slot geometry`() {
|
||||
capturePane("df_detail-pane-overview") {
|
||||
DetailOverviewPane(
|
||||
item = series,
|
||||
credits = creditRows(series),
|
||||
focusRequester = FocusRequester(),
|
||||
supportingText = "Up next S1 E3 · The Long Count",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun capturePane(name: String, pane: @Composable () -> Unit) {
|
||||
compose.setContent {
|
||||
PreviewSurface(alignment = Alignment.TopStart) {
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = DetailSideGutter, vertical = 16.dp)
|
||||
.height(detailPaneHeight(540.dp)),
|
||||
) { pane() }
|
||||
}
|
||||
}
|
||||
compose.onRoot().captureRoboImage("build/screenshots/$name.png")
|
||||
}
|
||||
|
||||
private fun capture(name: String, content: @Composable () -> Unit) {
|
||||
compose.setContent {
|
||||
PreviewSurface(alignment = Alignment.TopStart) { content() }
|
||||
}
|
||||
compose.onRoot().captureRoboImage("build/screenshots/$name.png")
|
||||
}
|
||||
|
||||
private fun captureTab(name: String, tab: String, content: @Composable () -> Unit) {
|
||||
compose.setContent {
|
||||
PreviewSurface(alignment = Alignment.TopStart) { content() }
|
||||
}
|
||||
compose.onNodeWithText(tab).performClick()
|
||||
compose.onRoot().captureRoboImage("build/screenshots/$name.png")
|
||||
}
|
||||
|
||||
/**
|
||||
* What the gateway returns for a warm profile: the reason strip above the tabs and the
|
||||
* carousel under the page. Both are the point of these captures now — they are the two
|
||||
* bands that decide whether the page still fits on one screen.
|
||||
*/
|
||||
private val related = RelatedContent(
|
||||
reasons = listOf(
|
||||
"Because you watch Thriller",
|
||||
"You've watched Aria Vance before",
|
||||
"Well rated (8.4)",
|
||||
),
|
||||
items = List(8) { index ->
|
||||
BaseItem(
|
||||
id = "related-$index",
|
||||
name = listOf(
|
||||
"The Quiet Meridian",
|
||||
"Harbour Lights",
|
||||
"Nine Days of Rain",
|
||||
"The Cartographer's Wife",
|
||||
"Winterline",
|
||||
"A Country of Small Rivers",
|
||||
"The Last Broadcast",
|
||||
"Northbound",
|
||||
)[index],
|
||||
type = "Movie",
|
||||
productionYear = 2019 + index % 5,
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
private val cast = listOf(
|
||||
person("Aria Vance", "Detective Iris Kell"),
|
||||
person("Marcus Oyelaran", "Samuel Reed"),
|
||||
person("Nina Kowalczyk", "Dr. Halvorsen"),
|
||||
person("Tomas Brandt", "The Cartographer"),
|
||||
person("Ines Ferreira", "Captain Ruiz"),
|
||||
person("Daniel Cho", "Weatherman"),
|
||||
)
|
||||
|
||||
private val streams = listOf(
|
||||
MediaStream(
|
||||
type = "Video",
|
||||
codec = "hevc",
|
||||
width = 3840,
|
||||
height = 2160,
|
||||
videoRange = "HDR",
|
||||
videoRangeType = "HDR10",
|
||||
),
|
||||
MediaStream(type = "Audio", codec = "eac3", channels = 6, language = "eng", title = "Dolby Atmos"),
|
||||
MediaStream(type = "Subtitle", codec = "subrip", language = "eng"),
|
||||
MediaStream(type = "Subtitle", codec = "subrip", language = "fra"),
|
||||
)
|
||||
|
||||
private val movie = BaseItem(
|
||||
id = "movie-1",
|
||||
name = "The Longest Northbound Winter",
|
||||
type = "Movie",
|
||||
overview = "A cartographer chasing a river that no longer exists finds the last " +
|
||||
"village on the map still waiting for him, and has to decide whether telling " +
|
||||
"them the truth is a kindness. Shot over four winters in a valley that floods " +
|
||||
"every spring, and assembled from what survived.",
|
||||
taglines = listOf("Some maps are promises."),
|
||||
productionYear = 2024,
|
||||
officialRating = "PG-13",
|
||||
communityRating = 8.4,
|
||||
genres = listOf("Drama", "Adventure", "Mystery"),
|
||||
runTimeTicks = 134L * 600_000_000L,
|
||||
studios = listOf(Studio(name = "Northlight Pictures"), Studio(name = "Kestrel")),
|
||||
mediaStreams = streams,
|
||||
people = cast,
|
||||
)
|
||||
|
||||
private val series = BaseItem(
|
||||
id = "series-1",
|
||||
name = "Signal Hill",
|
||||
type = "Series",
|
||||
overview = "A coastal radio station keeps receiving a broadcast that has not been " +
|
||||
"transmitted yet. Six weeks before the storm, the night operator starts writing " +
|
||||
"down what she hears.",
|
||||
taglines = listOf("Listen closely."),
|
||||
productionYear = 2022,
|
||||
officialRating = "TV-MA",
|
||||
communityRating = 8.9,
|
||||
genres = listOf("Thriller", "Drama"),
|
||||
studios = listOf(Studio(name = "Harbour Line")),
|
||||
mediaStreams = streams,
|
||||
people = cast,
|
||||
)
|
||||
|
||||
private val episodes = listOf(
|
||||
episode(1, 1, "Carrier Wave", played = true),
|
||||
episode(1, 2, "Dead Air", played = true),
|
||||
episode(1, 3, "The Long Count", position = 12L * 600_000_000L),
|
||||
episode(1, 4, "Nightingale"),
|
||||
episode(1, 5, "Six Weeks Out"),
|
||||
episode(2, 1, "Landfall"),
|
||||
)
|
||||
|
||||
private fun episode(
|
||||
season: Int,
|
||||
number: Int,
|
||||
title: String,
|
||||
played: Boolean = false,
|
||||
position: Long = 0L,
|
||||
) = BaseItem(
|
||||
id = "s${season}e$number",
|
||||
name = title,
|
||||
type = "Episode",
|
||||
seriesName = "Signal Hill",
|
||||
parentIndexNumber = season,
|
||||
indexNumber = number,
|
||||
runTimeTicks = 48L * 600_000_000L,
|
||||
overview = "The night shift picks up a voice reading tomorrow's shipping forecast, " +
|
||||
"and the log book from 1974 says the same thing happened before.",
|
||||
userData = UserItemData(played = played, playbackPositionTicks = position),
|
||||
)
|
||||
|
||||
private fun person(name: String, role: String) = EmbyPerson(
|
||||
id = name.filter(Char::isLetter),
|
||||
name = name,
|
||||
role = role,
|
||||
type = "Actor",
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
package com.ponzischeme89.memby.ui
|
||||
|
||||
import android.graphics.BitmapFactory
|
||||
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
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
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.shape.RoundedCornerShape
|
||||
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.graphics.Color
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.onRoot
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.tv.material3.Text
|
||||
import com.github.takahirom.roborazzi.captureRoboImage
|
||||
import com.ponzischeme89.memby.ServiceLocator
|
||||
import com.ponzischeme89.memby.data.model.BaseItem
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
import org.robolectric.annotation.GraphicsMode
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||
@Config(sdk = [34], qualifiers = "w960dp-h540dp-television-xhdpi")
|
||||
class HomeMovieHeroScreenshotTest {
|
||||
@get:Rule
|
||||
val compose = createComposeRule()
|
||||
|
||||
@Before
|
||||
fun locator() {
|
||||
ServiceLocator.init(ApplicationProvider.getApplicationContext())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `home hero with popular and new releases`() {
|
||||
capture("df_home-movie-hero", movies)
|
||||
}
|
||||
|
||||
/**
|
||||
* The case the audit reproduced: with a title long enough to wrap, the card's content
|
||||
* column overflowed its fixed height and the green Play chip — the last thing in the
|
||||
* column — was clipped away entirely. It has to survive here.
|
||||
*/
|
||||
@Test
|
||||
fun `home hero with a wrapping title`() {
|
||||
capture(
|
||||
"df_home-movie-hero-long-title",
|
||||
listOf(
|
||||
HomeHeroPick(
|
||||
movie(
|
||||
"The Longest Northbound Winter",
|
||||
2026,
|
||||
"A cartographer chasing a river that no longer exists finds the last " +
|
||||
"village on the map still waiting for him.",
|
||||
8.4,
|
||||
),
|
||||
"NEW RELEASE",
|
||||
),
|
||||
) + movies.drop(1),
|
||||
)
|
||||
}
|
||||
|
||||
private fun capture(name: String, movies: List<HomeHeroPick>) {
|
||||
val artwork = requireNotNull(javaClass.getResourceAsStream("/home_hero_preview_art.png"))
|
||||
.use(BitmapFactory::decodeStream)
|
||||
.asImageBitmap()
|
||||
val railFocus = FocusRequester()
|
||||
|
||||
compose.setContent {
|
||||
PreviewSurface(alignment = Alignment.TopStart) {
|
||||
Row(Modifier.fillMaxSize()) {
|
||||
TvNavigationRail(
|
||||
selected = BrowseDestination.HOME,
|
||||
expanded = false,
|
||||
navigationFocusRequester = railFocus,
|
||||
onRailFocusChanged = {},
|
||||
onDestinationSelected = {},
|
||||
)
|
||||
Column(Modifier.weight(1f).fillMaxHeight()) {
|
||||
HomeMovieHero(
|
||||
movies = movies,
|
||||
navigationFocusRequester = railFocus,
|
||||
onItemFocused = {},
|
||||
onItemSelected = {},
|
||||
modifier = Modifier.height(homeHeaderHeight(540.dp, showHero = true)),
|
||||
previewArtwork = artwork,
|
||||
)
|
||||
Text(
|
||||
"Recently added movies",
|
||||
color = Color(0xFFF1F3F4),
|
||||
fontSize = 18.sp,
|
||||
modifier = Modifier.padding(start = 36.dp, top = 6.dp, bottom = 9.dp),
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 36.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
repeat(5) { index ->
|
||||
Column(Modifier.weight(1f)) {
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(16f / 9f)
|
||||
.clip(RoundedCornerShape(7.dp))
|
||||
.background(
|
||||
listOf(
|
||||
Color(0xFF27343D), Color(0xFF28302D),
|
||||
Color(0xFF352C37), Color(0xFF392F28), Color(0xFF25343A),
|
||||
)[index],
|
||||
),
|
||||
)
|
||||
Text(
|
||||
movies[index % movies.size].item.name,
|
||||
color = Color.White,
|
||||
fontSize = 13.sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.padding(top = 6.dp),
|
||||
)
|
||||
Text(
|
||||
"2026 • 2h 4m",
|
||||
color = Color(0xFF8F9AA3),
|
||||
fontSize = 11.sp,
|
||||
modifier = Modifier.padding(top = 2.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
compose.onNodeWithText("Play").fetchSemanticsNode()
|
||||
compose.onRoot().captureRoboImage("build/screenshots/$name.png")
|
||||
}
|
||||
|
||||
private val movies = listOf(
|
||||
HomeHeroPick(
|
||||
movie(
|
||||
"The Last Horizon",
|
||||
2026,
|
||||
"Beyond the mapped worlds, one explorer finds an ocean that remembers every visitor.",
|
||||
8.7,
|
||||
),
|
||||
"NEW RELEASE",
|
||||
),
|
||||
HomeHeroPick(
|
||||
movie("Midnight Signal", 2026, "A city hears tomorrow's emergency broadcast.", 8.2),
|
||||
"POPULAR",
|
||||
),
|
||||
HomeHeroPick(
|
||||
movie("Northstar Run", 2025, "The final supply ship takes an impossible route.", 7.9),
|
||||
"NEW RELEASE",
|
||||
),
|
||||
HomeHeroPick(
|
||||
movie("After the Fire", 2026, "Two strangers cross a country waking from winter.", 8.4),
|
||||
"FROM YOUR LIBRARY",
|
||||
),
|
||||
)
|
||||
|
||||
private fun movie(name: String, year: Int, overview: String, rating: Double) = BaseItem(
|
||||
id = name.lowercase().replace(' ', '-'),
|
||||
name = name,
|
||||
type = "Movie",
|
||||
overview = overview,
|
||||
productionYear = year,
|
||||
officialRating = "M",
|
||||
communityRating = rating,
|
||||
runTimeTicks = 124L * 600_000_000L,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package com.ponzischeme89.memby.ui
|
||||
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.ponzischeme89.memby.data.model.BaseItem
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class HomeMovieHeroTest {
|
||||
@Test
|
||||
fun `home hero gives way to focused row metadata`() {
|
||||
assertTrue(shouldShowHomeMovieHero(hasMovies = true, focusedRowId = null))
|
||||
assertTrue(shouldShowHomeMovieHero(hasMovies = true, focusedRowId = HOME_HERO_ROW_ID))
|
||||
assertEquals(false, shouldShowHomeMovieHero(hasMovies = true, focusedRowId = "continue"))
|
||||
assertEquals(false, shouldShowHomeMovieHero(hasMovies = false, focusedRowId = null))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `home hero leaves room for a complete shelf on a 540dp tv`() {
|
||||
val heroHeight = homeHeaderHeight(540.dp, showHero = true)
|
||||
|
||||
assertEquals(248.4f, heroHeight.value, 0.01f)
|
||||
assertTrue(540.dp - heroHeight >= 288.dp)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `hero alternates new releases and popular movies`() {
|
||||
val rows = listOf(
|
||||
row("latest-movies", "Recently Added Movies", "new-1", "new-2", "new-3"),
|
||||
row("popular", "Popular Movies", "popular-1", "popular-2"),
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
listOf("new-1", "popular-1", "new-2", "popular-2"),
|
||||
selectHomeHeroMovies(rows).map { it.item.id },
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The caption used to be the card's slot, so the third card was always "TRENDING"
|
||||
* whatever it was. It now names the row the title was actually drawn from.
|
||||
*/
|
||||
@Test
|
||||
fun `hero labels each pick by the row it came from`() {
|
||||
val rows = listOf(
|
||||
row("latest-movies", "Recently Added Movies", "new-1"),
|
||||
row("popular", "Popular Movies", "popular-1"),
|
||||
row("comedy", "Comedy", "other-1"),
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
listOf("NEW RELEASE", "POPULAR", "FROM YOUR LIBRARY"),
|
||||
selectHomeHeroMovies(rows).map { it.label },
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `hero removes repeated movies across source rows`() {
|
||||
val rows = listOf(
|
||||
row("latest", "New releases", "shared", "new-2"),
|
||||
row("recommended", "Recommended", "shared", "popular-2", "popular-3"),
|
||||
)
|
||||
|
||||
assertEquals(4, selectHomeHeroMovies(rows).size)
|
||||
assertEquals(4, selectHomeHeroMovies(rows).map { it.item.id }.distinct().size)
|
||||
}
|
||||
|
||||
private fun row(id: String, title: String, vararg ids: String) = HomeBrowseRow(
|
||||
id = id,
|
||||
title = title,
|
||||
items = ids.map { BaseItem(id = it, name = it, type = "Movie") },
|
||||
kind = MediaRowKind.MOVIES,
|
||||
emptyMessage = "",
|
||||
)
|
||||
}
|
||||
@@ -28,6 +28,33 @@ class MediaBadgesTest {
|
||||
)
|
||||
}
|
||||
|
||||
/** HDR10+ used to be named in the spec row and collapse to a plain "HDR" badge. */
|
||||
@Test
|
||||
fun `names HDR10+ rather than collapsing it to HDR`() {
|
||||
val item = BaseItem(
|
||||
id = "movie",
|
||||
mediaStreams = listOf(
|
||||
MediaStream(type = "Video", width = 3840, videoRangeType = "HDR10+"),
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(listOf("4K", "HDR10+"), mediaBadges(item))
|
||||
}
|
||||
|
||||
/**
|
||||
* The badge and the spec row's `(4K)` suffix used to disagree — 3800 against 3400 —
|
||||
* so a 3600-wide file was 4K on the detail page and not on the card.
|
||||
*/
|
||||
@Test
|
||||
fun `uses the same 4K threshold as the technical spec row`() {
|
||||
fun badgesAt(width: Int) = mediaBadges(
|
||||
BaseItem(id = "m", mediaStreams = listOf(MediaStream(type = "Video", width = width))),
|
||||
)
|
||||
|
||||
assertEquals(emptyList<String>(), badgesAt(3_600))
|
||||
assertEquals(listOf("4K"), badgesAt(3_840))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `returns no badges when stream metadata is unavailable`() {
|
||||
assertEquals(emptyList<String>(), mediaBadges(BaseItem(id = "unknown")))
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.ponzischeme89.memby.ui
|
||||
|
||||
import com.ponzischeme89.memby.data.model.MyShow
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class MyShowsTest {
|
||||
@Test
|
||||
fun missingNextEpisodeHasFriendlyCopy() {
|
||||
assertEquals("Not announced", formatMyShowDate(null))
|
||||
assertEquals("Not announced", formatMyShowDate("not-a-date"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun cardSubtitlePrioritisesUsefulShowState() {
|
||||
assertEquals(
|
||||
"Cancelled",
|
||||
myShowCardSubtitle(MyShow(itemId = "1", title = "Ended", lifecycle = "Cancelled")),
|
||||
)
|
||||
assertEquals(
|
||||
"Not monitored",
|
||||
myShowCardSubtitle(MyShow(itemId = "2", title = "Paused", sonarrStatus = "Not monitored")),
|
||||
)
|
||||
assertEquals(
|
||||
"Saved show",
|
||||
myShowCardSubtitle(MyShow(itemId = "3", title = "Unknown")),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.ponzischeme89.memby.ui
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class QuickActionsNavigationTest {
|
||||
@Test
|
||||
fun `quick actions require an intentional hold`() {
|
||||
assertTrue(QuickActionsHoldDurationMillis >= 600L)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `down reaches every action and stops at the final action`() {
|
||||
assertEquals(1, quickActionNextIndex(0, 4, QuickActionDirection.DOWN))
|
||||
assertEquals(3, quickActionNextIndex(3, 4, QuickActionDirection.DOWN))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `up returns toward the safe first action and stops there`() {
|
||||
assertEquals(2, quickActionNextIndex(3, 4, QuickActionDirection.UP))
|
||||
assertEquals(0, quickActionNextIndex(0, 4, QuickActionDirection.UP))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `empty menus remain bounded`() {
|
||||
assertEquals(0, quickActionNextIndex(5, 0, QuickActionDirection.DOWN))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package com.ponzischeme89.memby.ui
|
||||
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class ResponsiveRowSizingTest {
|
||||
@Test
|
||||
fun metadataPanelKeepsReadableLineLengthsAcrossTvWidths() {
|
||||
assertEquals(537.6f, metadataPanelContentWidth(640.dp, compact = true).value, 0.01f)
|
||||
assertEquals(720f, metadataPanelContentWidth(1920.dp, compact = false).value, 0.01f)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun standardPosterRowFitsSevenCompleteCards() {
|
||||
val width = responsiveRowCardWidth(
|
||||
availableWidth = 1280.dp,
|
||||
preferredCardsAcross = 7,
|
||||
minWidth = 102.dp,
|
||||
maxWidth = 218.dp,
|
||||
)
|
||||
|
||||
val occupiedWidth = width * 7 + 16.dp * 6 + 36.dp * 2
|
||||
assertEquals(1280f, occupiedWidth.value, 0.01f)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun narrowRowReducesCardCountInsteadOfClippingCards() {
|
||||
val width = responsiveRowCardWidth(
|
||||
availableWidth = 640.dp,
|
||||
preferredCardsAcross = 7,
|
||||
minWidth = 102.dp,
|
||||
maxWidth = 218.dp,
|
||||
)
|
||||
|
||||
// Four 130dp cards, three gaps and both insets fill this viewport exactly.
|
||||
assertEquals(130f, width.value, 0.01f)
|
||||
assertEquals(640f, (width * 4 + 16.dp * 3 + 36.dp * 2).value, 0.01f)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun wideRowAddsCardsRatherThanExceedingMaximumWidth() {
|
||||
val width = responsiveRowCardWidth(
|
||||
availableWidth = 2560.dp,
|
||||
preferredCardsAcross = 4,
|
||||
minWidth = 164.dp,
|
||||
maxWidth = 360.dp,
|
||||
)
|
||||
|
||||
assertTrue(width <= 360.dp)
|
||||
assertEquals(341.71f, width.value, 0.01f)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package com.ponzischeme89.memby.ui
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
class RowNavigationTest {
|
||||
@Test
|
||||
fun `vertical movement skips rows without focusable cards`() {
|
||||
val counts = listOf(6, 0, 0, 4)
|
||||
|
||||
assertEquals(
|
||||
3,
|
||||
adjacentFocusableRowIndex(counts, 0, RowFocusDirection.DOWN),
|
||||
)
|
||||
assertEquals(
|
||||
0,
|
||||
adjacentFocusableRowIndex(counts, 3, RowFocusDirection.UP),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `vertical movement stops at the page boundary`() {
|
||||
val counts = listOf(3, 2)
|
||||
|
||||
assertNull(adjacentFocusableRowIndex(counts, 0, RowFocusDirection.UP))
|
||||
assertNull(adjacentFocusableRowIndex(counts, 1, RowFocusDirection.DOWN))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `vertical movement can traverse beyond the second row`() {
|
||||
val counts = listOf(6, 6, 6, 6, 6)
|
||||
val visited = mutableListOf(0)
|
||||
var current = 0
|
||||
|
||||
while (true) {
|
||||
current = adjacentFocusableRowIndex(
|
||||
counts,
|
||||
current,
|
||||
RowFocusDirection.DOWN,
|
||||
) ?: break
|
||||
visited += current
|
||||
}
|
||||
|
||||
assertEquals(listOf(0, 1, 2, 3, 4), visited)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `first visit keeps horizontal position and clamps to a shorter row`() {
|
||||
assertEquals(4, rowEntryItemIndex(4, destinationItemCount = 8, null))
|
||||
assertEquals(2, rowEntryItemIndex(7, destinationItemCount = 3, null))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `return visit restores the destination row position`() {
|
||||
assertEquals(
|
||||
1,
|
||||
rowEntryItemIndex(
|
||||
sourceIndex = 5,
|
||||
destinationItemCount = 8,
|
||||
rememberedDestinationIndex = 1,
|
||||
),
|
||||
)
|
||||
assertEquals(
|
||||
2,
|
||||
rowEntryItemIndex(
|
||||
sourceIndex = 1,
|
||||
destinationItemCount = 3,
|
||||
rememberedDestinationIndex = 20,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,10 @@ package com.ponzischeme89.memby.ui
|
||||
|
||||
import com.ponzischeme89.memby.data.model.BaseItem
|
||||
import com.ponzischeme89.memby.data.model.UserItemData
|
||||
import com.ponzischeme89.memby.ui.detail.availableSeasons
|
||||
import com.ponzischeme89.memby.ui.detail.defaultSeason
|
||||
import com.ponzischeme89.memby.ui.detail.episodesForSeason
|
||||
import com.ponzischeme89.memby.ui.detail.seasonLabel
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
@@ -55,9 +59,8 @@ class SeriesDetailsTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `detail tabs default safely to episodes`() {
|
||||
assertEquals(SeriesDetailSection.EPISODES, seriesDetailSection("episodes"))
|
||||
assertEquals(SeriesDetailSection.CAST, seriesDetailSection("cast"))
|
||||
assertEquals(SeriesDetailSection.EPISODES, seriesDetailSection("future-section"))
|
||||
fun `season zero is labelled specials`() {
|
||||
assertEquals("Specials", seasonLabel(0))
|
||||
assertEquals("Season 3", seasonLabel(3))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import com.ponzischeme89.memby.data.HomeCache
|
||||
import com.ponzischeme89.memby.data.Settings
|
||||
import com.ponzischeme89.memby.data.model.BaseItem
|
||||
import com.ponzischeme89.memby.data.model.HomeRow
|
||||
import com.ponzischeme89.memby.data.model.UserItemData
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
@@ -15,6 +16,28 @@ import org.junit.Test
|
||||
* the server invented, and surviving a cold start from cache.
|
||||
*/
|
||||
class ServerHomeRowsTest {
|
||||
@Test
|
||||
fun `profile row preferences hide pin and order server rows`() {
|
||||
val serverRows = listOf(
|
||||
HomeRow("continue", "Continue", "continue", emptyList()),
|
||||
HomeRow("recommended", "Recommended", "recommendation", emptyList()),
|
||||
HomeRow("latest-movies", "Latest", "latest", emptyList()),
|
||||
HomeRow("seasonal", "Seasonal", "recommendation", emptyList()),
|
||||
)
|
||||
val settings = Settings(
|
||||
homeRowOrder = "seasonal\nrecommended\ncontinue",
|
||||
homePinnedRows = "recommended",
|
||||
homeHiddenRows = "latest-movies",
|
||||
)
|
||||
|
||||
val result = serverHomeRows(
|
||||
HomeUiState(rows = serverRows, loading = emptySet()),
|
||||
settings,
|
||||
)
|
||||
|
||||
assertEquals(listOf("recommended", "seasonal", "continue"), result.map { it.id })
|
||||
}
|
||||
|
||||
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
@@ -73,10 +96,7 @@ class ServerHomeRowsTest {
|
||||
|
||||
@Test
|
||||
fun `successful login welcome uses authenticated username`() {
|
||||
assertEquals(
|
||||
"You're now logged in as Matt. Welcome to Memby!",
|
||||
loginWelcomeMessage(" Matt "),
|
||||
)
|
||||
assertTrue(loginWelcomeMessage(" Matt ").startsWith("Welcome to Memby, Matt. "))
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -189,7 +209,61 @@ class ServerHomeRowsTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Sonarr schedule rows use show cards and cannot be hidden by old preferences`() {
|
||||
fun `home and library destinations have distinct shelves`() {
|
||||
val state = HomeUiState(
|
||||
rows = serverRows + listOf(
|
||||
row("curated:drama-shows", "shows", "show"),
|
||||
row("curated:movies:genre:drama", "movies", "movie"),
|
||||
),
|
||||
loading = emptySet(),
|
||||
)
|
||||
|
||||
val home = homeRowsFor(BrowseDestination.HOME, state, Settings())
|
||||
val shows = homeRowsFor(BrowseDestination.SHOWS, state, Settings())
|
||||
val movies = homeRowsFor(BrowseDestination.MOVIES, state, Settings())
|
||||
|
||||
assertTrue(home.none { it.id.startsWith("curated:") })
|
||||
assertTrue(shows.any { it.id == "curated:drama-shows" })
|
||||
assertTrue(movies.any { it.id == "curated:movies:genre:drama" })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `discovery shelves do not repeat a continued series or played title`() {
|
||||
val continued = BaseItem(id = "episode", type = "Episode", seriesId = "hard-man")
|
||||
val repeatedSeries = BaseItem(id = "hard-man", type = "Series")
|
||||
val playedMovie = BaseItem(
|
||||
id = "played",
|
||||
type = "Movie",
|
||||
userData = UserItemData(played = true),
|
||||
)
|
||||
val fresh = (1..4).map { BaseItem(id = "fresh-$it", type = "Series") }
|
||||
val rows = deduplicateBrowseRows(
|
||||
listOf(
|
||||
HomeBrowseRow(
|
||||
id = "continue",
|
||||
title = "Continue",
|
||||
items = listOf(continued),
|
||||
kind = MediaRowKind.CONTINUE,
|
||||
emptyMessage = "empty",
|
||||
),
|
||||
HomeBrowseRow(
|
||||
id = "curated:drama-shows",
|
||||
title = "Drama",
|
||||
items = listOf(repeatedSeries, playedMovie) + fresh,
|
||||
kind = MediaRowKind.SHOWS,
|
||||
emptyMessage = "empty",
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
fresh.map { it.id },
|
||||
rows.last().items.map { it.id },
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `TV schedule rows use show cards and cannot be hidden by old preferences`() {
|
||||
val schedule = row("sonarr-airing-today", "schedule", "sonarr:7:42")
|
||||
|
||||
val rows = serverHomeRows(
|
||||
@@ -199,7 +273,24 @@ class ServerHomeRowsTest {
|
||||
|
||||
assertEquals(1, rows.size)
|
||||
assertEquals(MediaRowKind.SHOWS, rows.single().kind)
|
||||
assertEquals("No monitored shows are airing today", rows.single().emptyMessage)
|
||||
assertEquals("No monitored shows are airing in the next 5 days", rows.single().emptyMessage)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Radarr schedule rows use movie cards and explain an empty digital window`() {
|
||||
val schedule = row("radarr-upcoming-movies", "movie-schedule", "radarr:7")
|
||||
|
||||
val rows = serverHomeRows(
|
||||
HomeUiState(rows = listOf(schedule), loading = emptySet()),
|
||||
Settings(homeSections = ""),
|
||||
)
|
||||
|
||||
assertEquals(1, rows.size)
|
||||
assertEquals(MediaRowKind.MOVIES, rows.single().kind)
|
||||
assertEquals(
|
||||
"No monitored movies have a digital release in the next 5 days",
|
||||
rows.single().emptyMessage,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
package com.ponzischeme89.memby.ui
|
||||
|
||||
import android.graphics.BitmapFactory
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.compose.ui.test.onRoot
|
||||
import com.github.takahirom.roborazzi.captureRoboImage
|
||||
@@ -25,8 +32,8 @@ import org.robolectric.annotation.GraphicsMode
|
||||
* composable genuinely cannot be done in plain JUnit, so Robolectric is confined to files
|
||||
* named `*ScreenshotTest.kt`. Logic tests stay pure; keep them that way.
|
||||
*
|
||||
* Qualifiers describe a 1080p TV: 960x540dp at xhdpi. The bar is drawn on the launcher's
|
||||
* own background, flush to the top edge exactly as `MainActivity` places it.
|
||||
* Qualifiers describe a 1080p TV: 960x540dp at xhdpi. The bar is drawn over a realistic
|
||||
* cinematic playback still, flush to the top edge exactly as `PlayerActivity` places it.
|
||||
*
|
||||
* [AlertBanner] is rendered directly rather than [ServiceAlertBanner]: the wrapper's whole
|
||||
* job is the drop-in from above, and a still frame of an animation says nothing. Posters
|
||||
@@ -49,7 +56,6 @@ class ServiceAlertBannerScreenshotTest {
|
||||
id = "sonarr:7:42:aired",
|
||||
title = "Northbound",
|
||||
message = "S02E04 — The Crossing aired at 9:00 PM and will be in Emby soon.",
|
||||
posterUrl = null,
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -62,7 +68,54 @@ class ServiceAlertBannerScreenshotTest {
|
||||
id = "sonarr:8:43:aired",
|
||||
title = "The Long Dark",
|
||||
message = "S01E09 — Winterlight aired at 8:30 PM and is downloading now.",
|
||||
posterUrl = null,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A Radarr import, which is the case with a server-supplied eyebrow: the longest
|
||||
* label the bar is expected to carry has to leave the countdown ring room.
|
||||
*/
|
||||
@Test
|
||||
fun `new movie added`() {
|
||||
capture(
|
||||
"alert-banner-movie-added",
|
||||
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",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/** The service itself talking: a finished refresh, with nothing to illustrate it. */
|
||||
@Test
|
||||
fun `library updated`() {
|
||||
capture(
|
||||
"alert-banner-library-updated",
|
||||
ServiceAlert(
|
||||
id = "library:1785012345",
|
||||
title = "24 titles added or updated",
|
||||
message = "Memby has finished refreshing — it is on the home screen now.",
|
||||
label = "LIBRARY UPDATED",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The alert most likely to be read over a film, and the one whose wording has to work
|
||||
* with the picture already stalled behind it.
|
||||
*/
|
||||
@Test
|
||||
fun `server not responding`() {
|
||||
capture(
|
||||
"alert-banner-server-down",
|
||||
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",
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -78,7 +131,6 @@ class ServiceAlertBannerScreenshotTest {
|
||||
message = "S11E03 — The One Where Absolutely Everything Happens At Once " +
|
||||
"And Then Some More Happens After That aired at 10:30 PM and is " +
|
||||
"downloading now.",
|
||||
posterUrl = null,
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -92,7 +144,6 @@ class ServiceAlertBannerScreenshotTest {
|
||||
id = "sonarr:3:12:aired",
|
||||
title = "Dune",
|
||||
message = "S01E01 aired and will be in Emby soon.",
|
||||
posterUrl = null,
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -110,7 +161,6 @@ class ServiceAlertBannerScreenshotTest {
|
||||
id = "sonarr:7:42:aired",
|
||||
title = "Northbound",
|
||||
message = "S02E04 — The Crossing aired at 9:00 PM and will be in Emby soon.",
|
||||
posterUrl = null,
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -125,7 +175,16 @@ class ServiceAlertBannerScreenshotTest {
|
||||
|
||||
@Composable
|
||||
private fun AlertBannerOnHomeBackground(alert: ServiceAlert) {
|
||||
PreviewSurface(alignment = Alignment.TopCenter) {
|
||||
val playbackStill = requireNotNull(
|
||||
javaClass.getResourceAsStream("/playback_alert_preview_still.png"),
|
||||
).use(BitmapFactory::decodeStream).asImageBitmap()
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.TopCenter) {
|
||||
Image(
|
||||
bitmap = playbackStill,
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
AlertBanner(alert)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
package com.ponzischeme89.memby.ui
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class UserSwitcherNavigationTest {
|
||||
@Test
|
||||
fun `active profile receives initial focus`() {
|
||||
assertEquals(
|
||||
1,
|
||||
userSwitcherInitialIndex(listOf("one", "two", "three"), "two"),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `three profiles lead to pinned manage action`() {
|
||||
val profileCount = 3
|
||||
|
||||
assertEquals(
|
||||
3,
|
||||
userSwitcherNextIndex(2, profileCount, UserSwitcherDirection.DOWN),
|
||||
)
|
||||
assertEquals(
|
||||
3,
|
||||
userSwitcherNextIndex(3, profileCount, UserSwitcherDirection.DOWN),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `up and down navigation stay inside the switcher`() {
|
||||
assertEquals(0, userSwitcherNextIndex(0, 3, UserSwitcherDirection.UP))
|
||||
assertEquals(2, userSwitcherNextIndex(3, 3, UserSwitcherDirection.UP))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `manage remains reachable when no profiles exist`() {
|
||||
assertEquals(0, userSwitcherInitialIndex(emptyList(), null))
|
||||
assertEquals(0, userSwitcherNextIndex(0, 0, UserSwitcherDirection.DOWN))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package com.ponzischeme89.memby.ui
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.compose.ui.test.onRoot
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.tv.material3.Text
|
||||
import com.github.takahirom.roborazzi.captureRoboImage
|
||||
import com.ponzischeme89.memby.ServiceLocator
|
||||
import com.ponzischeme89.memby.data.model.BaseItem
|
||||
import com.ponzischeme89.memby.data.model.UserItemData
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
import org.robolectric.annotation.GraphicsMode
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||
@Config(sdk = [34], qualifiers = "w960dp-h540dp-television-xhdpi")
|
||||
class WatchedVisibilityScreenshotTest {
|
||||
@get:Rule
|
||||
val compose = createComposeRule()
|
||||
|
||||
@Before
|
||||
fun locator() {
|
||||
ServiceLocator.init(ApplicationProvider.getApplicationContext())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `browse cards hide watched movies and count watched episodes`() {
|
||||
val visible = applyWatchedVisibility(listOf(row), enabled = true).single()
|
||||
|
||||
compose.setContent {
|
||||
PreviewSurface {
|
||||
Column(Modifier.fillMaxSize().padding(48.dp)) {
|
||||
Text("Because you like great stories", color = Color.White, fontSize = 22.sp)
|
||||
Row(
|
||||
modifier = Modifier.padding(top = 18.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(18.dp),
|
||||
) {
|
||||
visible.items.forEach { item ->
|
||||
PortraitMediaCard(
|
||||
item = item,
|
||||
availableWidth = 820.dp,
|
||||
showSecondaryMetadata = true,
|
||||
onFocused = {},
|
||||
onClick = {},
|
||||
onLongClick = {},
|
||||
density = "large",
|
||||
showWatchedEpisodeCount = visible.showWatchedEpisodeCount,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
compose.onRoot().captureRoboImage("build/screenshots/watched-visibility-row.png")
|
||||
}
|
||||
|
||||
private val row = HomeBrowseRow(
|
||||
id = "stories",
|
||||
title = "Stories",
|
||||
kind = MediaRowKind.MOVIES,
|
||||
emptyMessage = "Nothing here",
|
||||
items = listOf(
|
||||
BaseItem(
|
||||
id = "seen",
|
||||
name = "Already Seen",
|
||||
type = "Movie",
|
||||
userData = UserItemData(played = true),
|
||||
),
|
||||
BaseItem(
|
||||
id = "signal",
|
||||
name = "Signal House",
|
||||
type = "Series",
|
||||
recursiveItemCount = 10,
|
||||
userData = UserItemData(unplayedItemCount = 2),
|
||||
),
|
||||
BaseItem(
|
||||
id = "winter",
|
||||
name = "A Long Winter",
|
||||
type = "Series",
|
||||
recursiveItemCount = 24,
|
||||
userData = UserItemData(unplayedItemCount = 19),
|
||||
),
|
||||
BaseItem(id = "new", name = "Not Watched Yet", type = "Movie", productionYear = 2026),
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package com.ponzischeme89.memby.ui
|
||||
|
||||
import com.ponzischeme89.memby.data.model.BaseItem
|
||||
import com.ponzischeme89.memby.data.model.UserItemData
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class WatchedVisibilityTest {
|
||||
@Test
|
||||
fun `preference is disabled by default`() {
|
||||
assertFalse(com.ponzischeme89.memby.data.Settings().hideWatchedMovies)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `enabled preference hides only completed movies`() {
|
||||
val watchedMovie = item("movie", "Movie", played = true)
|
||||
val unwatchedMovie = item("new-movie", "Movie")
|
||||
val watchedSeries = item("series", "Series", played = true)
|
||||
val row = row(watchedMovie, unwatchedMovie, watchedSeries)
|
||||
|
||||
val filtered = applyWatchedVisibility(listOf(row), enabled = true).single()
|
||||
|
||||
assertEquals(listOf("new-movie", "series"), filtered.items.map(BaseItem::id))
|
||||
assertTrue(filtered.showWatchedEpisodeCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `disabled preference preserves row content`() {
|
||||
val row = row(item("movie", "Movie", played = true))
|
||||
|
||||
val unchanged = applyWatchedVisibility(listOf(row), enabled = false).single()
|
||||
|
||||
assertEquals(listOf("movie"), unchanged.items.map(BaseItem::id))
|
||||
assertFalse(unchanged.showWatchedEpisodeCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `series progress uses aggregate episode counts`() {
|
||||
val series = BaseItem(
|
||||
id = "series",
|
||||
type = "Series",
|
||||
recursiveItemCount = 10,
|
||||
userData = UserItemData(unplayedItemCount = 2),
|
||||
)
|
||||
|
||||
assertEquals("8 of 10 watched", watchedEpisodeCountLabel(series))
|
||||
assertNull(watchedEpisodeCountLabel(item("movie", "Movie")))
|
||||
}
|
||||
|
||||
private fun item(id: String, type: String, played: Boolean = false) = BaseItem(
|
||||
id = id,
|
||||
type = type,
|
||||
userData = UserItemData(played = played),
|
||||
)
|
||||
|
||||
private fun row(vararg items: BaseItem) = HomeBrowseRow(
|
||||
id = "row",
|
||||
title = "Row",
|
||||
items = items.toList(),
|
||||
kind = MediaRowKind.MOVIES,
|
||||
emptyMessage = "Empty",
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package com.ponzischeme89.memby.ui
|
||||
|
||||
import kotlin.random.Random
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class WelcomeQuotesTest {
|
||||
@Test
|
||||
fun `unknown styles safely fall back to neutral`() {
|
||||
assertEquals(
|
||||
randomWelcomeQuote("neutral", Random(7)),
|
||||
randomWelcomeQuote("something-new", Random(7)),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `each style supplies a welcome line`() {
|
||||
WelcomeQuoteStyle.entries.forEach { style ->
|
||||
assertTrue(randomWelcomeQuote(style.value, Random(3)).isNotBlank())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `login greeting trims the authenticated username`() {
|
||||
assertTrue(
|
||||
loginWelcomeMessage(" Matt ", "positive", Random(1))
|
||||
.startsWith("Welcome to Memby, Matt. "),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package com.ponzischeme89.memby.ui.player
|
||||
|
||||
import android.app.Activity
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.graphics.Color
|
||||
import android.graphics.drawable.BitmapDrawable
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.ImageView
|
||||
import android.widget.TextView
|
||||
import com.github.takahirom.roborazzi.captureRoboImage
|
||||
import com.ponzischeme89.memby.R
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.Robolectric
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
import org.robolectric.annotation.GraphicsMode
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||
@Config(sdk = [34], qualifiers = "w960dp-h540dp-television-xhdpi")
|
||||
class PlaybackIdentityScreenshotTest {
|
||||
@Test
|
||||
fun `colour artwork keeps its original treatment`() {
|
||||
val activity = Robolectric.buildActivity(Activity::class.java).setup().get()
|
||||
val colourLogo = Bitmap.createBitmap(64, 64, Bitmap.Config.ARGB_8888).apply {
|
||||
eraseColor(Color.rgb(82, 181, 75))
|
||||
}
|
||||
|
||||
assertFalse(
|
||||
makeLogoVisibleOnDarkBackground(
|
||||
ImageView(activity),
|
||||
BitmapDrawable(activity.resources, colourLogo),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `black artwork is lightened for dark player surfaces`() {
|
||||
val activity = Robolectric.buildActivity(Activity::class.java).setup().get()
|
||||
val blackLogo = Bitmap.createBitmap(64, 64, Bitmap.Config.ARGB_8888).apply {
|
||||
eraseColor(Color.BLACK)
|
||||
}
|
||||
|
||||
assertTrue(
|
||||
makeLogoVisibleOnDarkBackground(
|
||||
ImageView(activity),
|
||||
BitmapDrawable(activity.resources, blackLogo),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `plain title and emby mark appear over playback for five seconds`() {
|
||||
val activity = Robolectric.buildActivity(Activity::class.java).setup().get()
|
||||
val root = FrameLayout(activity)
|
||||
val backdrop = ImageView(activity).apply {
|
||||
scaleType = ImageView.ScaleType.CENTER_CROP
|
||||
setImageBitmap(
|
||||
javaClass.classLoader
|
||||
?.getResourceAsStream("home_hero_preview_art.png")
|
||||
?.use(BitmapFactory::decodeStream),
|
||||
)
|
||||
}
|
||||
root.addView(
|
||||
backdrop,
|
||||
FrameLayout.LayoutParams(
|
||||
FrameLayout.LayoutParams.MATCH_PARENT,
|
||||
FrameLayout.LayoutParams.MATCH_PARENT,
|
||||
),
|
||||
)
|
||||
|
||||
val identity = LayoutInflater.from(activity)
|
||||
.inflate(R.layout.player_playback_identity, root, false)
|
||||
.apply {
|
||||
visibility = View.VISIBLE
|
||||
alpha = 1f
|
||||
}
|
||||
identity.findViewById<TextView>(R.id.player_playback_identity_title).text = "Dark Matter"
|
||||
root.addView(identity)
|
||||
activity.setContentView(root)
|
||||
|
||||
assertEquals(5_000L, PlayerActivity.PLAYBACK_IDENTITY_VISIBLE_MS)
|
||||
root.captureRoboImage("build/screenshots/player-playback-identity.png")
|
||||
}
|
||||
}
|
||||
@@ -19,13 +19,14 @@ class PlaybackRecoveryTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun decoderFailuresRequireViewerAction() {
|
||||
fun decoderFailuresAutomaticallyRequestCompatibleStream() {
|
||||
val failure = describePlaybackFailure(
|
||||
PlaybackException.ERROR_CODE_DECODING_FORMAT_UNSUPPORTED,
|
||||
)
|
||||
|
||||
assertEquals("Video format not supported", failure.title)
|
||||
assertFalse(failure.canAutoRetry)
|
||||
assertTrue(failure.canAutoRetry)
|
||||
assertTrue(failure.requiresTranscode)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+90
@@ -0,0 +1,90 @@
|
||||
package com.ponzischeme89.memby.ui.player
|
||||
|
||||
import android.app.Activity
|
||||
import android.graphics.BitmapFactory
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.ImageView
|
||||
import android.widget.TextView
|
||||
import com.github.takahirom.roborazzi.captureRoboImage
|
||||
import com.ponzischeme89.memby.R
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.Robolectric
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
import org.robolectric.annotation.GraphicsMode
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||
@Config(sdk = [34], qualifiers = "w960dp-h540dp-television-xhdpi")
|
||||
class PlayerPauseOverlayScreenshotTest {
|
||||
@Test
|
||||
fun `paused movie shows focused poster synopsis and resume controls`() {
|
||||
val (activity, root, controls) = playerSurface()
|
||||
|
||||
controls.findViewById<View>(R.id.player_pause_overlay).visibility = View.VISIBLE
|
||||
controls.findViewById<View>(R.id.player_now_playing_group).visibility = View.GONE
|
||||
controls.findViewById<TextView>(R.id.player_pause_title).text = "The Last Horizon"
|
||||
controls.findViewById<TextView>(R.id.player_pause_overview).text =
|
||||
"A cartographer follows a signal beyond the edge of the known world, " +
|
||||
"where an abandoned observatory may hold the way home."
|
||||
controls.findViewById<ImageView>(R.id.player_pause_poster).setImageBitmap(previewArtwork())
|
||||
controls.findViewById<TextView>(androidx.media3.ui.R.id.exo_position).text = "42:18"
|
||||
controls.findViewById<TextView>(androidx.media3.ui.R.id.exo_duration).text = "1:54:02"
|
||||
controls.findViewById<TextView>(R.id.player_remaining).text = "1h 12m left"
|
||||
controls.findViewById<TextView>(R.id.player_finish_time).text = "Ends at 10:14 PM"
|
||||
|
||||
root.captureRoboImage("build/screenshots/player-paused-movie-overlay.png")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `playing OSD keeps video visible behind controls without black container`() {
|
||||
val (_, root, controls) = playerSurface()
|
||||
|
||||
controls.findViewById<View>(R.id.player_pause_overlay).visibility = View.GONE
|
||||
controls.findViewById<View>(R.id.player_title_logo).visibility = View.GONE
|
||||
controls.findViewById<TextView>(R.id.player_title).apply {
|
||||
text = "The Last Horizon"
|
||||
visibility = View.VISIBLE
|
||||
}
|
||||
controls.findViewById<TextView>(androidx.media3.ui.R.id.exo_position).text = "42:18"
|
||||
controls.findViewById<TextView>(androidx.media3.ui.R.id.exo_duration).text = "1:54:02"
|
||||
controls.findViewById<TextView>(R.id.player_remaining).text = "1h 12m left"
|
||||
controls.findViewById<TextView>(R.id.player_finish_time).text = "Ends at 10:14 PM"
|
||||
|
||||
root.captureRoboImage("build/screenshots/player-osd-no-black-container.png")
|
||||
}
|
||||
|
||||
private fun playerSurface(): Triple<Activity, FrameLayout, View> {
|
||||
val activity = Robolectric.buildActivity(Activity::class.java).setup().get()
|
||||
val root = FrameLayout(activity)
|
||||
root.addView(
|
||||
ImageView(activity).apply {
|
||||
scaleType = ImageView.ScaleType.CENTER_CROP
|
||||
setImageBitmap(previewArtwork())
|
||||
},
|
||||
FrameLayout.LayoutParams(
|
||||
FrameLayout.LayoutParams.MATCH_PARENT,
|
||||
FrameLayout.LayoutParams.MATCH_PARENT,
|
||||
),
|
||||
)
|
||||
val controls = FrameLayout(activity)
|
||||
root.addView(
|
||||
controls,
|
||||
FrameLayout.LayoutParams(
|
||||
FrameLayout.LayoutParams.MATCH_PARENT,
|
||||
FrameLayout.LayoutParams.MATCH_PARENT,
|
||||
),
|
||||
)
|
||||
LayoutInflater.from(activity).inflate(R.layout.memby_player_controls, controls, true)
|
||||
activity.setContentView(root)
|
||||
return Triple(activity, root, controls)
|
||||
}
|
||||
|
||||
private fun previewArtwork() =
|
||||
javaClass.classLoader
|
||||
?.getResourceAsStream("home_hero_preview_art.png")
|
||||
?.use(BitmapFactory::decodeStream)
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.ponzischeme89.memby.ui.player
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
class PlayerTimingTest {
|
||||
@@ -11,4 +12,50 @@ class PlayerTimingTest {
|
||||
assertEquals("1 hr remaining", PlayerActivity.formatRemaining(60L * 60_000L))
|
||||
assertEquals("2 hr 5 min remaining", PlayerActivity.formatRemaining(125L * 60_000L))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun timeRemainingCueStartsStrictlyBelowTenMinutes() {
|
||||
assertNull(PlayerActivity.timeRemainingCueMinutes(10L * 60_000L))
|
||||
assertEquals(10, PlayerActivity.timeRemainingCueMinutes(10L * 60_000L - 1L))
|
||||
assertEquals(2, PlayerActivity.timeRemainingCueMinutes(61_000L))
|
||||
assertEquals(1, PlayerActivity.timeRemainingCueMinutes(1L))
|
||||
assertNull(PlayerActivity.timeRemainingCueMinutes(0L))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun playbackStartCueWaitsForTenSecondsOfPlaying() {
|
||||
assertEquals(false, PlayerActivity.playbackStartCueReady(9_999L))
|
||||
assertEquals(true, PlayerActivity.playbackStartCueReady(10_000L))
|
||||
assertEquals("1 min", PlayerActivity.formatCueDuration(1L))
|
||||
assertEquals("42 mins", PlayerActivity.formatCueDuration(42L * 60_000L))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun resumedPlaybackShowsTimeLeftAfterTwoSecondsOfPlaying() {
|
||||
assertEquals(false, PlayerActivity.playbackStartCueReady(1_999L, resumePositionMs = 1L))
|
||||
assertEquals(true, PlayerActivity.playbackStartCueReady(2_000L, resumePositionMs = 1L))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun prerollRuntimeUsesCompactEpisodeFacts() {
|
||||
assertNull(PlayerActivity.formatPrerollRuntime(0L))
|
||||
assertEquals("48 mins", PlayerActivity.formatPrerollRuntime(48L * 60_000L))
|
||||
assertEquals("1h 42m", PlayerActivity.formatPrerollRuntime(102L * 60_000L))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun completedPlaybackReturnsHomeUnlessAutoplayCanAdvance() {
|
||||
assertEquals(
|
||||
PlaybackCompletionAction.RETURN_HOME,
|
||||
playbackCompletionAction(hasNextEpisode = false, nextUpDismissed = false),
|
||||
)
|
||||
assertEquals(
|
||||
PlaybackCompletionAction.RETURN_HOME,
|
||||
playbackCompletionAction(hasNextEpisode = true, nextUpDismissed = true),
|
||||
)
|
||||
assertEquals(
|
||||
PlaybackCompletionAction.PLAY_NEXT,
|
||||
playbackCompletionAction(hasNextEpisode = true, nextUpDismissed = false),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,8 +3,11 @@ package com.ponzischeme89.memby.ui.player
|
||||
import android.content.Context
|
||||
import android.view.LayoutInflater
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.GridLayout
|
||||
import android.widget.ImageView
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import com.ponzischeme89.memby.R
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
@@ -20,13 +23,26 @@ class PrerollLayoutTest {
|
||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||
|
||||
assertNotNull(context.getDrawable(R.drawable.player_preroll_video_background))
|
||||
assertNotNull(
|
||||
LayoutInflater.from(context).inflate(
|
||||
R.layout.player_preroll,
|
||||
FrameLayout(context),
|
||||
false,
|
||||
),
|
||||
val preroll = LayoutInflater.from(context).inflate(
|
||||
R.layout.player_preroll,
|
||||
FrameLayout(context),
|
||||
false,
|
||||
)
|
||||
val videoHost = preroll.findViewById<FrameLayout>(R.id.player_preroll_video_host)
|
||||
assertNotNull(videoHost)
|
||||
assertEquals(
|
||||
(398 * context.resources.displayMetrics.density).toInt(),
|
||||
videoHost.layoutParams.width,
|
||||
)
|
||||
assertNotNull(preroll.findViewById<PrerollCountdownView>(R.id.player_preroll_countdown))
|
||||
assertNotNull(preroll.findViewById<ImageView>(R.id.player_preroll_brand))
|
||||
assertEquals(
|
||||
"Starting in 7 seconds…",
|
||||
context.getString(R.string.player_preroll_countdown_initial),
|
||||
)
|
||||
assertNotNull(context.getDrawable(R.drawable.player_preroll_video_frame))
|
||||
assertNotNull(preroll.findViewById<GridLayout>(R.id.player_preroll_calendar))
|
||||
assertEquals(6_500L, PlayerActivity.DEFAULT_PREROLL_DURATION_MS)
|
||||
assertNotNull(
|
||||
LayoutInflater.from(context).inflate(
|
||||
R.layout.player_cast_overlay,
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
package com.ponzischeme89.memby.ui.player
|
||||
|
||||
import android.app.Activity
|
||||
import android.graphics.BitmapFactory
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.GridLayout
|
||||
import android.widget.ImageView
|
||||
import android.widget.TextView
|
||||
import com.github.takahirom.roborazzi.captureRoboImage
|
||||
import com.ponzischeme89.memby.R
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.Robolectric
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
import org.robolectric.annotation.GraphicsMode
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||
@Config(sdk = [34], qualifiers = "w960dp-h540dp-television-xhdpi")
|
||||
class PrerollScreenshotTest {
|
||||
@Test
|
||||
fun `sonarr preroll uses hero video and artwork stack`() {
|
||||
val activity = Robolectric.buildActivity(Activity::class.java).setup().get()
|
||||
val root = FrameLayout(activity)
|
||||
val preroll = LayoutInflater.from(activity).inflate(R.layout.player_preroll, root, false)
|
||||
preroll.visibility = View.VISIBLE
|
||||
root.addView(
|
||||
preroll,
|
||||
FrameLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
),
|
||||
)
|
||||
|
||||
val artwork = javaClass.classLoader
|
||||
?.getResourceAsStream("home_hero_preview_art.png")
|
||||
?.use(BitmapFactory::decodeStream)
|
||||
preroll.findViewById<FrameLayout>(R.id.player_preroll_video_host).addView(
|
||||
ImageView(activity).apply {
|
||||
scaleType = ImageView.ScaleType.CENTER_CROP
|
||||
setImageBitmap(artwork)
|
||||
},
|
||||
0,
|
||||
FrameLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
),
|
||||
)
|
||||
|
||||
val samples = listOf(
|
||||
Triple("TODAY · 8:30 PM", "Northbound", "S02E04 · The Crossing"),
|
||||
Triple("TODAY · 9:00 PM", "Harbour", "S03E01 · Home Water"),
|
||||
Triple("TODAY · 9:30 PM", "The Bear", "S04E03 · Bridges"),
|
||||
Triple("TODAY · 10:00 PM", "Slow Horses", "S05E02 · Signals"),
|
||||
Triple("WED · 7:30 PM", "Foundation", "S03E06 · The Mule"),
|
||||
Triple("THU · 8:00 PM", "Severance", "S03E01 · Cold Harbour"),
|
||||
Triple("FRI · 8:30 PM", "Silo", "S03E04 · Legacy"),
|
||||
Triple("SAT · 7:00 PM", "North Shore", "S01E07 · The Dive"),
|
||||
)
|
||||
preroll.findViewById<TextView>(R.id.player_preroll_now_title).text =
|
||||
"Northbound – The Crossing"
|
||||
preroll.findViewById<TextView>(R.id.player_preroll_now_metadata).text =
|
||||
"EPISODE · S02E04 · 48 mins"
|
||||
preroll.findViewById<TextView>(R.id.player_preroll_now_overview).text =
|
||||
"The crew follows a signal beyond the last charted crossing and discovers " +
|
||||
"a settlement that has been waiting for their arrival."
|
||||
val calendar = preroll.findViewById<GridLayout>(R.id.player_preroll_calendar)
|
||||
samples.forEachIndexed { index, (label, title, detail) ->
|
||||
val card = LayoutInflater.from(activity)
|
||||
.inflate(R.layout.player_preroll_schedule_card, calendar, false)
|
||||
card.findViewById<ImageView>(R.id.player_preroll_card_artwork).setImageBitmap(artwork)
|
||||
card.findViewById<TextView>(R.id.player_preroll_card_label).text = label
|
||||
card.findViewById<TextView>(R.id.player_preroll_card_title).text = title
|
||||
card.findViewById<TextView>(R.id.player_preroll_card_detail).text = detail
|
||||
calendar.addView(
|
||||
card,
|
||||
GridLayout.LayoutParams().apply {
|
||||
width = 0
|
||||
height = dp(activity, 80)
|
||||
columnSpec = GridLayout.spec(index % 4, 1f)
|
||||
rowSpec = GridLayout.spec(index / 4)
|
||||
setMargins(dp(activity, 4), dp(activity, 3), dp(activity, 4), dp(activity, 3))
|
||||
},
|
||||
)
|
||||
}
|
||||
val countdown = preroll.findViewById<PrerollCountdownView>(R.id.player_preroll_countdown)
|
||||
countdown.setCountdown(
|
||||
seconds = 7,
|
||||
progress = 1f,
|
||||
description = "Starting in 7 seconds",
|
||||
)
|
||||
|
||||
activity.setContentView(root)
|
||||
root.captureRoboImage("build/screenshots/player-sonarr-preroll.png")
|
||||
|
||||
countdown.setCountdown(seconds = 3, progress = 3f / 6.5f, description = "Starting in 3 seconds")
|
||||
root.captureRoboImage("build/screenshots/player-sonarr-preroll-mid-countdown.png")
|
||||
}
|
||||
|
||||
private fun dp(activity: Activity, value: Int): Int =
|
||||
(value * activity.resources.displayMetrics.density).toInt()
|
||||
}
|
||||
@@ -6,7 +6,24 @@ import org.junit.Test
|
||||
|
||||
class PrerollSequenceTest {
|
||||
@Test
|
||||
fun `handoff waits only for the five second gate`() {
|
||||
fun `fresh playback shows preroll`() {
|
||||
assertTrue(shouldShowPreroll(0L))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `resumed playback skips preroll`() {
|
||||
assertFalse(shouldShowPreroll(1L))
|
||||
assertFalse(shouldShowPreroll(42 * 60_000L))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `server can disable preroll for fresh playback`() {
|
||||
assertFalse(shouldShowPreroll(0L, enabled = false))
|
||||
assertTrue(shouldShowPreroll(0L, enabled = true))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `handoff waits for the video countdown gate`() {
|
||||
assertFalse(prerollCanHandOff(true, false, true))
|
||||
assertTrue(prerollCanHandOff(true, true, true))
|
||||
}
|
||||
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
package com.ponzischeme89.memby.ui.player
|
||||
|
||||
import android.app.Activity
|
||||
import android.graphics.Color
|
||||
import android.graphics.drawable.GradientDrawable
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.TextView
|
||||
import com.github.takahirom.roborazzi.captureRoboImage
|
||||
import com.ponzischeme89.memby.R
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.Robolectric
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
import org.robolectric.annotation.GraphicsMode
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||
@Config(sdk = [34], qualifiers = "w960dp-h540dp-television-xhdpi")
|
||||
class SeasonFinaleLowerThirdScreenshotTest {
|
||||
@Test
|
||||
fun `season finale stacks above playback start cue`() {
|
||||
val activity = Robolectric.buildActivity(Activity::class.java).setup().get()
|
||||
val root = FrameLayout(activity).apply {
|
||||
background = GradientDrawable(
|
||||
GradientDrawable.Orientation.TL_BR,
|
||||
intArrayOf(
|
||||
Color.rgb(17, 37, 48),
|
||||
Color.rgb(12, 20, 27),
|
||||
Color.rgb(3, 7, 10),
|
||||
),
|
||||
)
|
||||
}
|
||||
val inflater = LayoutInflater.from(activity)
|
||||
val timing = inflater.inflate(R.layout.player_time_remaining, root, false).apply {
|
||||
visibility = View.VISIBLE
|
||||
findViewById<TextView>(R.id.player_time_remaining_label).text = "FINISHES IN"
|
||||
findViewById<TextView>(R.id.player_time_remaining_value).text = "52 mins (9:54 PM)"
|
||||
}
|
||||
val finale = inflater.inflate(R.layout.player_season_finale, root, false).apply {
|
||||
visibility = View.VISIBLE
|
||||
findViewById<TextView>(R.id.player_season_finale_value).text =
|
||||
"Signal Hill · Season 2 finale"
|
||||
}
|
||||
root.addView(timing)
|
||||
root.addView(finale)
|
||||
activity.setContentView(root)
|
||||
|
||||
root.captureRoboImage("build/screenshots/player-season-finale-lower-third.png")
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user