This commit is contained in:
ponzischeme89
2026-08-21 22:40:31 +12:00
parent 32019f2e20
commit c85fab4459
15 changed files with 417 additions and 248 deletions
@@ -5,6 +5,7 @@ import android.content.Context
import com.ponzischeme89.memby.data.EmbyRepository
import com.ponzischeme89.memby.data.MaintenanceMonitor
import com.ponzischeme89.memby.data.PreferencesSync
import com.ponzischeme89.memby.data.SearchRepository
import com.ponzischeme89.memby.data.SettingsStore
import com.ponzischeme89.memby.data.ThemeSync
import com.ponzischeme89.memby.data.playback.installAudioCapabilityProbe
@@ -25,6 +26,8 @@ object ServiceLocator {
private set
lateinit var repository: EmbyRepository
private set
lateinit var searchRepository: SearchRepository
private set
lateinit var maintenance: MaintenanceMonitor
private set
lateinit var remoteConfig: RemoteConfigManager
@@ -77,7 +80,8 @@ object ServiceLocator {
// Constructed before the repository so the very first playback resolution of the
// process has somewhere to report Emby's address.
streamWarmer = StreamWarmer(context.applicationContext)
repository = EmbyRepository(settings, streamWarmer)
searchRepository = SearchRepository(settings)
repository = EmbyRepository(settings, streamWarmer, searchRepository)
maintenance = MaintenanceMonitor(repository, settings)
// Takes the revision channel from the status poll rather than polling itself: the
// app already asks the gateway a question every ten seconds, and settings do not
@@ -2,7 +2,6 @@ package com.ponzischeme89.memby.data
import com.ponzischeme89.memby.data.model.AuthRequest
import com.ponzischeme89.memby.data.model.BaseItem
import android.os.SystemClock
import com.ponzischeme89.memby.data.model.GenreAffinity
import com.ponzischeme89.memby.data.model.GatewayCalendar
import com.ponzischeme89.memby.data.model.GatewayFlagRequest
@@ -243,6 +242,7 @@ data class PlaybackRequest(
class EmbyRepository internal constructor(
private val settings: SettingsStore,
private val streamWarmer: StreamWarmer? = null,
private val searchRepository: SearchRepository,
) {
private val clientTrailerResolver = ClientTrailerResolver()
@@ -376,27 +376,6 @@ class EmbyRepository internal constructor(
LinkedHashMap<String, RadarrMovieDetail>(RADARR_MOVIE_CACHE_SIZE, 0.75f, true)
private val radarrMovieInFlight = mutableMapOf<String, Deferred<RadarrMovieDetail?>>()
/**
* What this viewer watches, for the order of the Genres browser's rail.
*
* Held as a plain volatile rather than behind a suspend call because the reader is a
* ViewModel's constructor — the same reason [snapshot] exists. A screen that had to
* await this before drawing its rail would be a screen whose genre list appears late,
* and re-ordering a rail somebody is already looking at is worse than not personalising
* it at all.
*/
@Volatile
private var genreAffinity: GenreAffinity = GenreAffinity()
/**
* When that reading was taken, in [SystemClock.elapsedRealtime], so a television
* correcting its clock cannot make an hour-old answer look like a fresh one.
*/
@Volatile
private var genreAffinityReadAt = 0L
private val genreAffinityMutex = Mutex()
private var genreAffinityInFlight: Deferred<GenreAffinity?>? = null
fun cachedHome(): HomeCache? = settings.homeCache(snapshot)
suspend fun cacheHome(content: HomeCache) = settings.setHomeCache(content)
@@ -938,20 +917,7 @@ class EmbyRepository internal constructor(
* re-sorting here, so the ordering rule stays a pure, testable function.
*/
suspend fun search(term: String, limit: Int = 40): List<BaseItem> {
val trimmed = term.trim()
if (trimmed.isEmpty()) return emptyList()
if (ServerConfig.isGateway) return requireGateway().search(trimmed, limit).items
return getHomeItems(
params = mapOf(
"SearchTerm" to trimmed,
"IncludeItemTypes" to "Movie,Series,Episode",
"Recursive" to "true",
"Limit" to limit.toString(),
),
fields = "ProductionYear,RunTimeTicks,SeriesName,PrimaryImageAspectRatio",
imageTypes = "Backdrop,Primary,Logo",
includeUserData = true,
)
return searchRepository.search(term, limit)
}
/**
@@ -975,62 +941,7 @@ class EmbyRepository internal constructor(
limit: Int = GENRE_PAGE_SIZE,
itemType: String? = null,
): GenrePage {
val trimmed = genre.trim()
if (trimmed.isEmpty()) return GenrePage(emptyList(), offset, 0)
val embyItemType = when (itemType?.trim()?.lowercase()) {
null, "" -> "Movie,Series"
"movie" -> "Movie"
"series" -> "Series"
else -> "Movie,Series"
}
if (ServerConfig.isGateway) {
runCatching {
requireGateway().genreItems(
trimmed,
offset,
limit,
embyItemType.takeUnless { it == "Movie,Series" },
)
}
.onSuccess { page ->
return GenrePage(page.items, offset, page.total)
}
.onFailure { error ->
if (error is kotlinx.coroutines.CancellationException) throw error
// Only the first page falls back. A gateway that answered page one and
// failed on page two is having trouble, not missing the route, and a
// search's results pasted onto the end of a genre would be nonsense.
if (offset > 0) throw error
// An older gateway understands neither this route nor Emby's pipe
// syntax. Searching the first member at least leaves a useful shelf;
// sending "Action|Adventure" as prose would usually return nothing.
val items = search(trimmed.substringBefore('|'), limit).filter { candidate ->
embyItemType == "Movie,Series" || candidate.type.equals(embyItemType, ignoreCase = true)
}
return GenrePage(items, offset, items.size)
}
}
val items = getHomeItems(
params = mapOf(
"Genres" to trimmed,
"IncludeItemTypes" to embyItemType,
"Recursive" to "true",
"StartIndex" to offset.toString(),
"Limit" to limit.toString(),
// The second sort key is what makes paging safe: with only a date, two
// titles sharing one could swap places between requests and the scroll
// would repeat one card and never show the other.
"SortBy" to "PremiereDate,SortName",
"SortOrder" to "Descending",
),
fields = "ProductionYear,RunTimeTicks,SeriesName,PrimaryImageAspectRatio",
imageTypes = "Backdrop,Primary,Logo",
includeUserData = true,
)
// Emby's count is turned off for these list calls, so the page itself is the only
// evidence: a full page means there may be more, a short one is the end.
val total = offset + items.size + if (items.size >= limit) 1 else 0
return GenrePage(items, offset, total)
return searchRepository.browseGenre(genre, offset, limit, itemType)
}
/**
@@ -1054,14 +965,7 @@ class EmbyRepository internal constructor(
* are "use the catalogue's own order", which is a perfectly good rail.
*/
suspend fun getGenreAffinity(): GenreAffinity {
if (!ServerConfig.isGateway) return GenreAffinity()
val inFlight = genreAffinityMutex.withLock {
if (!genreAffinityStale()) return genreAffinity
genreAffinityInFlight ?: newGenreAffinityRequest()
}
// A failed reading leaves the previous one standing rather than clearing it: an
// unreachable gateway is no evidence that somebody's taste has changed.
return inFlight.await() ?: genreAffinity
return searchRepository.getGenreAffinity()
}
/**
@@ -1073,7 +977,7 @@ class EmbyRepository internal constructor(
* than one that was never personalised, and the warm below is what makes it almost
* always already here.
*/
fun genreAffinitySnapshot(): GenreAffinity = genreAffinity
fun genreAffinitySnapshot(): GenreAffinity = searchRepository.genreAffinitySnapshot()
/**
* Fetches the reading if it is missing or stale, and returns immediately.
@@ -1083,37 +987,12 @@ class EmbyRepository internal constructor(
* opened this has long since landed. The cost is one request per six hours per set.
*/
fun warmGenreAffinity() {
if (!ServerConfig.isGateway) return
scope.launch { runCatching { getGenreAffinity() } }
}
private fun genreAffinityStale(): Boolean =
genreAffinityReadAt == 0L ||
SystemClock.elapsedRealtime() - genreAffinityReadAt >= GENRE_AFFINITY_REFRESH_MS
private fun newGenreAffinityRequest(): Deferred<GenreAffinity?> {
val request = scope.async(start = CoroutineStart.LAZY) {
try {
val loaded = runCatching { requireGateway().genreAffinity() }.getOrNull()
?: return@async null
genreAffinityMutex.withLock {
genreAffinity = loaded
genreAffinityReadAt = SystemClock.elapsedRealtime()
loaded
}
} finally {
genreAffinityMutex.withLock { genreAffinityInFlight = null }
}
}
genreAffinityInFlight = request
request.start()
return request
searchRepository.warmGenreAffinity()
}
/** Another viewer's taste is not this one's; cleared wherever the session changes. */
private fun clearGenreAffinity() {
genreAffinity = GenreAffinity()
genreAffinityReadAt = 0L
searchRepository.clearGenreAffinity()
}
/**
@@ -1130,62 +1009,12 @@ class EmbyRepository internal constructor(
limit: Int = GENRE_PAGE_SIZE,
itemType: String?,
): GenrePage {
val embyItemType = when (itemType?.trim()?.lowercase()) {
"movie" -> "Movie"
"series" -> "Series"
else -> "Movie,Series"
}
if (ServerConfig.isGateway) {
runCatching {
// The mixed shelf is the route's own default, so it is asked for by saying
// nothing rather than by naming both types.
requireGateway().libraryItems(
offset,
limit,
embyItemType.takeUnless { it == "Movie,Series" }.orEmpty(),
)
}
.onSuccess { page -> return GenrePage(page.items, offset, page.total) }
.onFailure { error ->
if (error is kotlinx.coroutines.CancellationException) throw error
if (offset > 0) throw error
// An older gateway has no whole-library route, and one older still
// refuses the mixed type. Its home response is a useful first shelf
// and, crucially, is not claimed to be pageable.
val home = getHome(limit)
val types = embyItemType.split(',')
val items = (home.rows.asSequence().flatMap { it.items.asSequence() } +
home.continueWatching.asSequence() + home.nextUp.asSequence() +
home.favorites.asSequence() + home.latestMovies.asSequence())
.filter { candidate -> types.any { candidate.type.equals(it, ignoreCase = true) } }
.distinctBy(BaseItem::id)
.take(limit)
.toList()
return GenrePage(items, offset, items.size)
}
}
val items = getHomeItems(
params = mapOf(
"IncludeItemTypes" to embyItemType,
"Recursive" to "true",
"StartIndex" to offset.toString(),
"Limit" to limit.toString(),
"SortBy" to "PremiereDate,SortName",
"SortOrder" to "Descending",
),
fields = "ProductionYear,RunTimeTicks,SeriesName,PrimaryImageAspectRatio",
imageTypes = "Backdrop,Primary,Logo",
includeUserData = true,
)
val total = offset + items.size + if (items.size >= limit) 1 else 0
return GenrePage(items, offset, total)
return searchRepository.browseLibrary(offset, limit, itemType)
}
/** Records a successful gateway search without affecting the direct Emby path. */
suspend fun recordSearch(term: String) {
if (ServerConfig.isGateway && term.trim().length >= 2) {
runCatching { requireGateway().recordSearch(mapOf("query" to term.trim())) }
}
searchRepository.recordSearch(term)
}
/**
@@ -0,0 +1,272 @@
package com.ponzischeme89.memby.data
import android.os.SystemClock
import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.data.model.GenreAffinity
import com.ponzischeme89.memby.data.remote.EmbyApi
import com.ponzischeme89.memby.data.remote.EmbyServiceFactory
import com.ponzischeme89.memby.data.remote.GatewayApi
import com.ponzischeme89.memby.data.remote.GatewayServiceFactory
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.async
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
/**
* Owns library discovery: text search, genre and library paging, search history, and the
* viewer's genre-affinity snapshot. [EmbyRepository] remains the compatibility facade while
* callers migrate one concern at a time.
*/
class SearchRepository internal constructor(
private val settings: SettingsStore,
) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val snapshot: Settings
get() = settings.current ?: Settings.EMPTY
private var cachedApi: EmbyApi? = null
private var cachedBaseUrl: String? = null
private val gatewayApi: GatewayApi? by lazy {
ServerConfig.gatewayUrl?.let { url ->
GatewayServiceFactory.create(url) { snapshot.token }
}
}
@Volatile
private var genreAffinity: GenreAffinity = GenreAffinity()
@Volatile
private var genreAffinityReadAt = 0L
private val genreAffinityMutex = Mutex()
private var genreAffinityInFlight: Deferred<GenreAffinity?>? = null
/** Library search, preserving whichever relevance order the active backend chose. */
suspend fun search(term: String, limit: Int = 40): List<BaseItem> {
val trimmed = term.trim()
if (trimmed.isEmpty()) return emptyList()
if (ServerConfig.isGateway) return requireGateway().search(trimmed, limit).items
return directItems(
params = mapOf(
"SearchTerm" to trimmed,
"IncludeItemTypes" to "Movie,Series,Episode",
"Recursive" to "true",
"Limit" to limit.toString(),
),
fields = "ProductionYear,RunTimeTicks,SeriesName,PrimaryImageAspectRatio",
imageTypes = "Backdrop,Primary,Logo",
includeUserData = true,
)
}
/** One stable page filtered by genre; episodes are excluded deliberately. */
suspend fun browseGenre(
genre: String,
offset: Int = 0,
limit: Int = GENRE_PAGE_SIZE,
itemType: String? = null,
): GenrePage {
val trimmed = genre.trim()
if (trimmed.isEmpty()) return GenrePage(emptyList(), offset, 0)
val embyItemType = itemTypes(itemType)
if (ServerConfig.isGateway) {
runCatching {
requireGateway().genreItems(
trimmed,
offset,
limit,
embyItemType.takeUnless { it == ALL_ITEM_TYPES },
)
}
.onSuccess { page -> return GenrePage(page.items, offset, page.total) }
.onFailure { error ->
if (error is kotlinx.coroutines.CancellationException) throw error
// Only the first page may use the compatibility fallback. A later
// failure is backend trouble, not evidence that the route is absent.
if (offset > 0) throw error
val items = search(trimmed.substringBefore('|'), limit).filter { candidate ->
embyItemType == ALL_ITEM_TYPES ||
candidate.type.equals(embyItemType, ignoreCase = true)
}
return GenrePage(items, offset, items.size)
}
}
val items = directItems(
params = mapOf(
"Genres" to trimmed,
"IncludeItemTypes" to embyItemType,
"Recursive" to "true",
"StartIndex" to offset.toString(),
"Limit" to limit.toString(),
"SortBy" to "PremiereDate,SortName",
"SortOrder" to "Descending",
),
fields = "ProductionYear,RunTimeTicks,SeriesName,PrimaryImageAspectRatio",
imageTypes = "Backdrop,Primary,Logo",
includeUserData = true,
)
return GenrePage(items, offset, inferredTotal(offset, limit, items.size))
}
/** One stable page of all films, all series, or both. */
suspend fun browseLibrary(
offset: Int = 0,
limit: Int = GENRE_PAGE_SIZE,
itemType: String?,
): GenrePage {
val embyItemType = itemTypes(itemType)
if (ServerConfig.isGateway) {
runCatching {
requireGateway().libraryItems(
offset,
limit,
embyItemType.takeUnless { it == ALL_ITEM_TYPES }.orEmpty(),
)
}
.onSuccess { page -> return GenrePage(page.items, offset, page.total) }
.onFailure { error ->
if (error is kotlinx.coroutines.CancellationException) throw error
if (offset > 0) throw error
val home = requireGateway().home(limit)
val types = embyItemType.split(',')
val items = (home.rows.asSequence().flatMap { it.items.asSequence() } +
home.continueWatching.asSequence() + home.nextUp.asSequence() +
home.favorites.asSequence() + home.latestMovies.asSequence())
.filter { candidate ->
types.any { candidate.type.equals(it, ignoreCase = true) }
}
.distinctBy(BaseItem::id)
.take(limit)
.toList()
return GenrePage(items, offset, items.size)
}
}
val items = directItems(
params = mapOf(
"IncludeItemTypes" to embyItemType,
"Recursive" to "true",
"StartIndex" to offset.toString(),
"Limit" to limit.toString(),
"SortBy" to "PremiereDate,SortName",
"SortOrder" to "Descending",
),
fields = "ProductionYear,RunTimeTicks,SeriesName,PrimaryImageAspectRatio",
imageTypes = "Backdrop,Primary,Logo",
includeUserData = true,
)
return GenrePage(items, offset, inferredTotal(offset, limit, items.size))
}
/** Records a successful gateway search without affecting the direct Emby path. */
suspend fun recordSearch(term: String) {
val trimmed = term.trim()
if (ServerConfig.isGateway && trimmed.length >= 2) {
runCatching { requireGateway().recordSearch(mapOf("query" to trimmed)) }
}
}
/** Returns the latest affinity reading, retaining an older reading across failures. */
suspend fun getGenreAffinity(): GenreAffinity {
if (!ServerConfig.isGateway) return GenreAffinity()
val inFlight = genreAffinityMutex.withLock {
if (!genreAffinityStale()) return genreAffinity
genreAffinityInFlight ?: newGenreAffinityRequest()
}
return inFlight.await() ?: genreAffinity
}
/** Returns the already-loaded affinity without delaying the genre rail. */
fun genreAffinitySnapshot(): GenreAffinity = genreAffinity
/** Warms the affinity snapshot in the background when it is missing or stale. */
fun warmGenreAffinity() {
if (!ServerConfig.isGateway) return
scope.launch { runCatching { getGenreAffinity() } }
}
/** Another viewer's taste is not this one's. */
internal fun clearGenreAffinity() {
genreAffinity = GenreAffinity()
genreAffinityReadAt = 0L
}
private fun itemTypes(itemType: String?): String = when (itemType?.trim()?.lowercase()) {
"movie" -> "Movie"
"series" -> "Series"
else -> ALL_ITEM_TYPES
}
private fun inferredTotal(offset: Int, limit: Int, itemCount: Int): Int =
offset + itemCount + if (itemCount >= limit) 1 else 0
private fun genreAffinityStale(): Boolean =
genreAffinityReadAt == 0L ||
SystemClock.elapsedRealtime() - genreAffinityReadAt >= GENRE_AFFINITY_REFRESH_MS
private fun newGenreAffinityRequest(): Deferred<GenreAffinity?> {
val request = scope.async(start = CoroutineStart.LAZY) {
try {
val loaded = runCatching { requireGateway().genreAffinity() }.getOrNull()
?: return@async null
genreAffinityMutex.withLock {
genreAffinity = loaded
genreAffinityReadAt = SystemClock.elapsedRealtime()
loaded
}
} finally {
genreAffinityMutex.withLock { genreAffinityInFlight = null }
}
}
genreAffinityInFlight = request
request.start()
return request
}
private suspend fun directItems(
params: Map<String, String>,
fields: String,
imageTypes: String,
includeUserData: Boolean,
): List<BaseItem> {
val userId = snapshot.userId ?: error("Not connected")
return requireApi().getItems(
userId,
params + mapOf(
"Fields" to fields,
"ImageTypeLimit" to "1",
"EnableImages" to "true",
"EnableImageTypes" to imageTypes,
"EnableTotalRecordCount" to "false",
"EnableUserData" to includeUserData.toString(),
),
).items
}
private fun requireGateway(): GatewayApi =
gatewayApi ?: error("No Memby gateway configured")
private fun requireApi(): EmbyApi {
val serverUrl = ServerConfig.hardwiredUrl ?: snapshot.serverUrl
?: error("Not connected to a server")
val baseUrl = normalizeServerUrl(serverUrl)
cachedApi?.let { if (cachedBaseUrl == baseUrl) return it }
return EmbyServiceFactory.create(
baseUrl = baseUrl,
deviceIdProvider = { snapshot.deviceId.ifEmpty { "memby" } },
tokenProvider = { snapshot.token },
).also { api ->
cachedApi = api
cachedBaseUrl = baseUrl
}
}
private companion object {
const val ALL_ITEM_TYPES = "Movie,Series"
}
}
@@ -75,7 +75,7 @@ data class UserPreferences(
) {
companion object {
val DEFAULT_SECTIONS: List<String> = Settings.DEFAULT_HOME_SECTIONS.split(",")
val DEFAULT_METADATA_HERO_CONTENT_ORDER = listOf("title", "ratings", "facts", "genres", "summary")
val DEFAULT_METADATA_HERO_CONTENT_ORDER = listOf("title", "ratings", "facts", "summary")
}
}
@@ -213,16 +213,11 @@ private fun MetadataContent(
if (repository.showTitleLogo) repository.logoUrl(item) else null
}
val logo = logoUrl.takeIf { !useTextTitleForLogo(it) }
val facts = buildList {
item.productionYear?.let { add(it.toString()) }
item.runtimeMinutes?.let { add(formatRuntime(it)) }
item.officialRating?.takeIf(String::isNotBlank)?.let(::add)
}
val facts = metadataHeroFacts(item)
val badges = buildList {
airingBadgeLabel(item)?.let(::add)
addAll(mediaBadges(item))
}
val genres = item.genres.take(2).joinToString(ValueSeparator)
Column(
modifier = Modifier.width(contentWidth).fillMaxHeight(),
verticalArrangement = Arrangement.spacedBy(if (compact) 5.dp else 8.dp),
@@ -234,7 +229,8 @@ private fun MetadataContent(
item = item, load = true, compact = true, modifier = Modifier.fillMaxWidth(),
)
MetadataHeroSection.Facts -> {
// Ratings identify their provider in the strip; this line is deliberately factual only.
// One information block between ratings and plot: catalogue facts on
// the first line, then the file's picture and sound options beneath.
if (facts.isNotEmpty()) {
Text(facts.joinToString(FactSeparator), color = MutedText, fontSize = 13.sp,
maxLines = 1, overflow = TextOverflow.Ellipsis)
@@ -246,10 +242,6 @@ private fun MetadataContent(
badges.forEach { MediaBadge(it) }
}
}
MetadataHeroSection.Genres -> if (genres.isNotBlank()) Text(
genres, color = Color.White, fontSize = 14.sp, fontWeight = FontWeight.Bold,
maxLines = 1, overflow = TextOverflow.Ellipsis,
)
MetadataHeroSection.Summary -> Text(
item.overview?.takeIf(String::isNotBlank) ?: "No description available.",
color = MembyMutedText, fontSize = 14.sp, lineHeight = 18.sp,
@@ -266,15 +258,29 @@ private object MetadataHeroSection {
const val Title = "title"
const val Ratings = "ratings"
const val Facts = "facts"
const val Genres = "genres"
const val Summary = "summary"
val all = listOf(Title, Ratings, Facts, Genres, Summary)
val all = listOf(Title, Ratings, Facts, Summary)
}
internal fun metadataHeroContentOrder(order: List<String>): List<String> =
order.filter { it in MetadataHeroSection.all }.distinct()
order
// Older gateways exposed Genre as its own block. It now belongs in the metadata
// information block, so preserve its position when Facts itself was omitted.
.map { if (it == "genres") MetadataHeroSection.Facts else it }
.filter { it in MetadataHeroSection.all }
.distinct()
.ifEmpty { MetadataHeroSection.all }
/** Catalogue information shown as one block between the ratings strip and the plot. */
internal fun metadataHeroFacts(item: BaseItem): List<String> = buildList {
item.productionYear?.let { add(it.toString()) }
item.runtimeMinutes?.let { add(formatRuntime(it)) }
item.officialRating?.takeIf(String::isNotBlank)?.let(::add)
item.genres.take(2).joinToString(ValueSeparator)
.takeIf(String::isNotBlank)
?.let(::add)
}
@Composable
private fun MetadataHeroTitle(item: BaseItem, logoUrl: String?, logo: String?, compact: Boolean) {
Box(
@@ -78,7 +78,6 @@ import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.onLongClick
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
@@ -135,11 +134,9 @@ private val EmbyGreen: Color get() = MembyAccent
private val RailSurface: Color get() = MembySurface.copy(alpha = 0.95f)
private val MutedText: Color get() = MembyMutedText
private val QuietText: Color get() = MembyQuietText
internal val TvRailCollapsedWidth = 104.dp
internal val TvRailCollapsedWidth = 54.dp
internal val TvRailExpandedWidth = 184.dp
internal val HomeContentHorizontalInset = 48.dp
private val TvRailCollapsedItemWidth = 72.dp
private val TvRailHomeFootprint = 54.dp
/**
* The rail's order, which is this declaration order — see [navigationRailItems], the only
@@ -233,19 +230,16 @@ fun TvNavigationRail(
)
Box(
modifier = modifier
// Home content begins after this slim footprint, then applies its own 48dp
// reading inset. Together they meet the 104dp rail edge without preserving
// a second, invisible gutter from the old sidebar layout.
.width(TvRailHomeFootprint)
.width(TvRailCollapsedWidth)
.fillMaxHeight()
.zIndex(8f),
) {
Column(
modifier = Modifier
// The parent deliberately reports only the Home footprint to the Row.
// The unbounded width lets the collapsed and focused surfaces draw outward
// without remeasuring gallery cards or clipping their labels to 104dp.
// Only width may escape the collapsed 104dp footprint. Unbinding height
// The parent deliberately reports only the collapsed footprint to the
// home Row. requiredWidth lets the focused surface draw outward without
// remeasuring gallery cards or clipping their labels to 54dp.
// Only width may escape the collapsed 54dp footprint. Unbinding height
// here would make fillMaxHeight lose the screen constraint and stop the
// rail surface at its final child (the version label).
.wrapContentWidth(Alignment.Start, unbounded = true)
@@ -270,16 +264,12 @@ fun TvNavigationRail(
}
.focusGroup()
.padding(horizontal = 5.dp, vertical = 15.dp),
horizontalAlignment = Alignment.CenterHorizontally,
horizontalAlignment = Alignment.Start,
) {
Row(
modifier = Modifier.fillMaxWidth().height(42.dp).padding(horizontal = 6.dp),
modifier = Modifier.height(42.dp).padding(horizontal = 6.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = if (expanded) {
Arrangement.spacedBy(12.dp)
} else {
Arrangement.Center
},
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
// Memby's own icon rather than Emby's mark: the rail is Memby's chrome, and
// the one place the app names itself should not be somebody else's logo.
@@ -392,10 +382,7 @@ fun TvNavigationRail(
fontSize = if (expanded) 10.sp else 8.sp,
fontWeight = FontWeight.Medium,
maxLines = 1,
textAlign = if (expanded) TextAlign.Start else TextAlign.Center,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = if (expanded) 8.dp else 4.dp),
modifier = Modifier.padding(horizontal = if (expanded) 8.dp else 4.dp),
)
}
}
@@ -869,10 +856,7 @@ fun ExpandableNavigationItem(
)
Row(
modifier = modifier
.then(
if (expanded) Modifier.fillMaxWidth()
else Modifier.width(TvRailCollapsedItemWidth),
)
.fillMaxWidth()
.height(44.dp)
.onFocusChanged {
focused = it.isFocused
@@ -890,13 +874,9 @@ fun ExpandableNavigationItem(
}
}
}
.padding(horizontal = if (expanded) 8.dp else 0.dp),
.padding(horizontal = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = if (expanded) {
Arrangement.spacedBy(12.dp)
} else {
Arrangement.Center
},
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Box(Modifier.width(28.dp), contentAlignment = Alignment.Center) {
if (avatarInitials != null) {
@@ -929,7 +909,7 @@ fun ExpandableNavigationItem(
)
}
// Over the icon rather than after the label: the rail spends most of its life
// collapsed, where there is no label to sit beside.
// collapsed to 54dp, where there is no label to sit beside.
if (badge != null) {
Text(
badge,
@@ -197,6 +197,9 @@ internal fun shouldOfferNextEpisodeButton(
playingPreview: Boolean,
): Boolean = hasNextEpisode && !advancing && !playingPreview
/** Memby-owned actions do not belong to a trailer supplied by an external provider. */
internal fun membyPlayerEnhancementsAvailable(playingTrailer: Boolean): Boolean = !playingTrailer
/**
* Whether the Magic control belongs on the transport row.
*
@@ -211,4 +214,6 @@ internal fun shouldOfferMagicButton(
magicAvailable: Boolean,
advancing: Boolean,
playingPreview: Boolean,
): Boolean = magicAvailable && !isEpisode && !advancing && !playingPreview
playingTrailer: Boolean = false,
): Boolean = magicAvailable && !isEpisode && !advancing && !playingPreview &&
membyPlayerEnhancementsAvailable(playingTrailer)
@@ -132,7 +132,8 @@ internal fun passthroughOsdSummary(preference: AudioPassthroughPreference): Stri
/**
* Fullscreen Media3 player with native stream-track selection. Press Menu while
* playing to choose an audio or subtitle track; the subtitle controller button
* remains available in the regular transport controls too.
* remains available in the regular transport controls too. Trailers omit Memby's
* subtitle, cast and Magic enhancements because their experience belongs to the provider.
*/
class PlayerActivity : ComponentActivity() {
@@ -205,6 +206,8 @@ class PlayerActivity : ComponentActivity() {
*/
private var pendingRequest: PlaybackRequest? = null
private var pendingTrailerRequest: TrailerPlaybackRequest? = null
private val playingTrailer: Boolean
get() = pendingTrailerRequest != null
private var trailerStartupTimeoutJob: Job? = null
private var currentTrailerCandidateId = ""
private var currentTrailerProvider = ""
@@ -632,9 +635,12 @@ class PlayerActivity : ComponentActivity() {
Log.i(PLAYBACK_LOG_TAG, "event=subtitle_configs item=${itemId.orEmpty()} count=${subtitles.size}")
setContentView(R.layout.activity_player)
val enhancementsAvailable = membyPlayerEnhancementsAvailable(
playingTrailer = playingTrailer,
)
val view = findViewById<PlayerView>(R.id.player_view).apply {
useController = false
setShowSubtitleButton(true)
setShowSubtitleButton(enhancementsAvailable)
controllerShowTimeoutMs = CONTROLLER_TIMEOUT_MS
}
applySubtitleAppearance(view)
@@ -648,10 +654,14 @@ class PlayerActivity : ComponentActivity() {
},
)
playerView = view
view.findViewById<View>(androidx.media3.ui.R.id.exo_subtitle)?.setOnClickListener {
showSubtitleOverlay()
view.findViewById<View>(androidx.media3.ui.R.id.exo_subtitle)?.apply {
isVisible = enhancementsAvailable
if (enhancementsAvailable) setOnClickListener { showSubtitleOverlay() }
}
view.findViewById<View>(R.id.player_cast)?.apply {
isVisible = enhancementsAvailable
if (enhancementsAvailable) setOnClickListener { showCastOverlay() }
}
view.findViewById<View>(R.id.player_cast)?.setOnClickListener { showCastOverlay() }
nextEpisodeButton = view.findViewById<View>(R.id.player_next_episode)?.apply {
setOnClickListener {
// The same resolved answer the credits pane and the countdown use, and the
@@ -3346,6 +3356,7 @@ class PlayerActivity : ComponentActivity() {
magicAvailable = magicAvailable,
advancing = advancing,
playingPreview = playingNextEpisodePreview,
playingTrailer = playingTrailer,
)
}
@@ -3359,6 +3370,7 @@ class PlayerActivity : ComponentActivity() {
* player that knows what it has already put in front of this viewer.
*/
private fun playSomethingElse() {
if (playingTrailer) return
if (magicJob?.isActive == true || advanceRequested || advancing) return
val current = itemId?.takeIf { it.isNotBlank() }
PlaybackJourney.magicRequested(JourneyTracker)
@@ -4484,6 +4496,7 @@ class PlayerActivity : ComponentActivity() {
@OptIn(UnstableApi::class)
private fun showTrackMenu() {
val playback = player ?: return
val enhancementsAvailable = membyPlayerEnhancementsAvailable(playingTrailer)
val audio = playback.currentTracks.groups.any { group ->
group.type == C.TRACK_TYPE_AUDIO && (0 until group.mediaTrackGroup.length).any(group::isTrackSupported)
}
@@ -4494,7 +4507,9 @@ class PlayerActivity : ComponentActivity() {
val pictureOption = "Picture size · ${selectedPictureMode().shortLabel}"
val options = buildList {
if (audio) add("Audio")
add("Subtitles & appearance")
// The provider owns a trailer's captions; offering Memby's subtitle surface
// here would contradict the transport row that deliberately withholds it.
if (enhancementsAvailable) add("Subtitles & appearance")
add(passthroughOption)
add(pictureOption)
}
@@ -4714,6 +4729,7 @@ class PlayerActivity : ComponentActivity() {
}
private fun showCastOverlay() {
if (playingTrailer) return
playerView?.hideController()
castOverlay?.visibility = View.VISIBLE
bindCastOverlay()
@@ -4824,6 +4840,7 @@ class PlayerActivity : ComponentActivity() {
focusSize: Int? = null,
focusDownload: Int? = null,
) {
if (playingTrailer) return
val playback = player ?: return
val overlay = subtitleOverlay ?: return
val tracksContainer = overlay.findViewById<LinearLayout>(R.id.player_subtitle_tracks)
@@ -247,9 +247,9 @@
android:gravity="center_vertical"
android:orientation="horizontal">
<!-- Withheld for episodic content: Next Episode already answers "what now?"
there, and the server's picker is films only. Gone rather than disabled
for the same reason as Next Episode. -->
<!-- Withheld for episodic content and trailers: Next Episode already answers
"what now?" for a programme, while a trailer's experience belongs to
its provider. Gone rather than disabled for the same reason as Next Episode. -->
<ImageButton
android:id="@+id/player_magic"
style="@style/MembyPlayerControlButton"
@@ -1,5 +1,7 @@
package com.ponzischeme89.memby.ui
import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.data.model.MediaStream
import org.junit.Assert.assertEquals
import org.junit.Test
@@ -16,8 +18,31 @@ class MetadataHeroOrderTest {
@Test
fun `unknown sections fall back to the default layout`() {
assertEquals(
listOf("title", "ratings", "facts", "genres", "summary"),
listOf("title", "ratings", "facts", "summary"),
metadataHeroContentOrder(listOf("unknown")),
)
}
@Test
fun `legacy genre block folds into metadata information`() {
assertEquals(
listOf("title", "ratings", "facts", "summary"),
metadataHeroContentOrder(listOf("title", "ratings", "facts", "genres", "summary")),
)
}
@Test
fun `metadata information includes genres while sound remains in its badge row`() {
val item = BaseItem(
id = "film",
productionYear = 2026,
runTimeTicks = 124L * 60L * 10_000_000L,
officialRating = "M",
genres = listOf("Drama", "Adventure"),
mediaStreams = listOf(MediaStream(type = "Audio", channels = 2)),
)
assertEquals(listOf("2026", "2h 4m", "M", "Drama · Adventure"), metadataHeroFacts(item))
assertEquals(listOf("STEREO"), mediaBadges(item))
}
}
@@ -8,8 +8,8 @@ import org.junit.Test
class NavigationRailTest {
@Test
fun `collapsed rail stays within the slim TV target`() {
assertTrue(TvRailCollapsedWidth in 96.dp..112.dp)
fun `collapsed rail keeps its slim home footprint`() {
assertEquals(54.dp, TvRailCollapsedWidth)
}
@Test
@@ -81,6 +81,12 @@ class NextUpPipelineTest {
// --- Magic ---------------------------------------------------------------------------
@Test
fun `trailers withhold Memby-owned player enhancements`() {
assertFalse(membyPlayerEnhancementsAvailable(playingTrailer = true))
assertTrue(membyPlayerEnhancementsAvailable(playingTrailer = false))
}
@Test
fun `magic belongs to films and stands aside for next episode`() {
assertTrue(
@@ -100,6 +106,16 @@ class NextUpPipelineTest {
isEpisode = false, magicAvailable = false, advancing = false, playingPreview = false,
),
)
assertFalse(
"a trailer is not a Memby-managed viewing experience",
shouldOfferMagicButton(
isEpisode = false,
magicAvailable = true,
advancing = false,
playingPreview = false,
playingTrailer = true,
),
)
}
// --- The resolver --------------------------------------------------------------------