diff --git a/app/build.gradle.kts b/app/build.gradle.kts index a3cb0a1..b198b09 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -64,7 +64,7 @@ val projectNoticeText = // A release workflow can derive the app version from its Git tag without editing the // source tree. Local builds keep using the checked-in default. -val defaultVersionName = "0.2.95" +val defaultVersionName = "0.2.96" val membyVersionName: String = (project.findProperty("memby.versionName") as String?) ?.trim() diff --git a/app/src/main/java/com/ponzischeme89/memby/ServiceLocator.kt b/app/src/main/java/com/ponzischeme89/memby/ServiceLocator.kt index 3c2a31b..8bb109e 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ServiceLocator.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ServiceLocator.kt @@ -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 diff --git a/app/src/main/java/com/ponzischeme89/memby/data/EmbyRepository.kt b/app/src/main/java/com/ponzischeme89/memby/data/EmbyRepository.kt index 734b287..a5c90b7 100644 --- a/app/src/main/java/com/ponzischeme89/memby/data/EmbyRepository.kt +++ b/app/src/main/java/com/ponzischeme89/memby/data/EmbyRepository.kt @@ -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(RADARR_MOVIE_CACHE_SIZE, 0.75f, true) private val radarrMovieInFlight = mutableMapOf>() - /** - * 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? = 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 { - 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 { - 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) } /** diff --git a/app/src/main/java/com/ponzischeme89/memby/data/SearchRepository.kt b/app/src/main/java/com/ponzischeme89/memby/data/SearchRepository.kt new file mode 100644 index 0000000..b539f95 --- /dev/null +++ b/app/src/main/java/com/ponzischeme89/memby/data/SearchRepository.kt @@ -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? = null + + /** Library search, preserving whichever relevance order the active backend chose. */ + suspend fun search(term: String, limit: Int = 40): List { + 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 { + 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, + fields: String, + imageTypes: String, + includeUserData: Boolean, + ): List { + 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" + } +} diff --git a/app/src/main/java/com/ponzischeme89/memby/data/UserPreferences.kt b/app/src/main/java/com/ponzischeme89/memby/data/UserPreferences.kt index 09db26d..9872690 100644 --- a/app/src/main/java/com/ponzischeme89/memby/data/UserPreferences.kt +++ b/app/src/main/java/com/ponzischeme89/memby/data/UserPreferences.kt @@ -75,7 +75,7 @@ data class UserPreferences( ) { companion object { val DEFAULT_SECTIONS: List = 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") } } diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/home/HomeMetadataPanel.kt b/app/src/main/java/com/ponzischeme89/memby/ui/home/HomeMetadataPanel.kt index ba76935..8f0000c 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/home/HomeMetadataPanel.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/home/HomeMetadataPanel.kt @@ -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): List = - 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 = 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( diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/home/HomeNavigation.kt b/app/src/main/java/com/ponzischeme89/memby/ui/home/HomeNavigation.kt index 4728332..622da93 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/home/HomeNavigation.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/home/HomeNavigation.kt @@ -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, diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/player/NextUpPipeline.kt b/app/src/main/java/com/ponzischeme89/memby/ui/player/NextUpPipeline.kt index 408d918..7cf106c 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/player/NextUpPipeline.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/player/NextUpPipeline.kt @@ -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) diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/player/PlayerActivity.kt b/app/src/main/java/com/ponzischeme89/memby/ui/player/PlayerActivity.kt index 5c7a246..18d6455 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/player/PlayerActivity.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/player/PlayerActivity.kt @@ -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(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(androidx.media3.ui.R.id.exo_subtitle)?.setOnClickListener { - showSubtitleOverlay() + view.findViewById(androidx.media3.ui.R.id.exo_subtitle)?.apply { + isVisible = enhancementsAvailable + if (enhancementsAvailable) setOnClickListener { showSubtitleOverlay() } + } + view.findViewById(R.id.player_cast)?.apply { + isVisible = enhancementsAvailable + if (enhancementsAvailable) setOnClickListener { showCastOverlay() } } - view.findViewById(R.id.player_cast)?.setOnClickListener { showCastOverlay() } nextEpisodeButton = view.findViewById(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(R.id.player_subtitle_tracks) diff --git a/app/src/main/res/layout/memby_player_controls.xml b/app/src/main/res/layout/memby_player_controls.xml index 214822d..efaa21c 100644 --- a/app/src/main/res/layout/memby_player_controls.xml +++ b/app/src/main/res/layout/memby_player_controls.xml @@ -247,9 +247,9 @@ android:gravity="center_vertical" android:orientation="horizontal"> - + 0 { kept := []string{} for _, entry := range selected { + // Genre used to be a separate metadata-hero block. It now lives inside + // Metadata information with runtime and the sound/video badges. Map the + // stored token instead of silently removing that block from existing users. + if definition.Key == "metadataHeroContentOrder" && entry == "genres" { + entry = "facts" + } if hasOption(definition.Options, entry) && !slices.Contains(kept, entry) { kept = append(kept, entry) } diff --git a/server/internal/api/preferences_test.go b/server/internal/api/preferences_test.go index 51f528a..6dca168 100644 --- a/server/internal/api/preferences_test.go +++ b/server/internal/api/preferences_test.go @@ -126,6 +126,16 @@ func TestNormalizePreferencesKeepsMultiOrderAndDedupes(t *testing.T) { } } +func TestNormalizePreferencesFoldsLegacyGenreIntoMetadataInformation(t *testing.T) { + result := normalizePreferences(map[string]any{ + "metadataHeroContentOrder": []any{"title", "ratings", "genres", "summary"}, + }) + want := []string{"title", "ratings", "facts", "summary"} + if !reflect.DeepEqual(result["metadataHeroContentOrder"], want) { + t.Errorf("metadataHeroContentOrder = %v, want %v", result["metadataHeroContentOrder"], want) + } +} + // Free-form row ids are stored newline-separated on the television, so an id containing // one would come back as two rows on the next sync. func TestNormalizePreferencesRejectsNewlinesInRowIds(t *testing.T) {