0.2.96
This commit is contained in:
@@ -64,7 +64,7 @@ val projectNoticeText =
|
|||||||
|
|
||||||
// A release workflow can derive the app version from its Git tag without editing the
|
// 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.
|
// source tree. Local builds keep using the checked-in default.
|
||||||
val defaultVersionName = "0.2.95"
|
val defaultVersionName = "0.2.96"
|
||||||
val membyVersionName: String =
|
val membyVersionName: String =
|
||||||
(project.findProperty("memby.versionName") as String?)
|
(project.findProperty("memby.versionName") as String?)
|
||||||
?.trim()
|
?.trim()
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import android.content.Context
|
|||||||
import com.ponzischeme89.memby.data.EmbyRepository
|
import com.ponzischeme89.memby.data.EmbyRepository
|
||||||
import com.ponzischeme89.memby.data.MaintenanceMonitor
|
import com.ponzischeme89.memby.data.MaintenanceMonitor
|
||||||
import com.ponzischeme89.memby.data.PreferencesSync
|
import com.ponzischeme89.memby.data.PreferencesSync
|
||||||
|
import com.ponzischeme89.memby.data.SearchRepository
|
||||||
import com.ponzischeme89.memby.data.SettingsStore
|
import com.ponzischeme89.memby.data.SettingsStore
|
||||||
import com.ponzischeme89.memby.data.ThemeSync
|
import com.ponzischeme89.memby.data.ThemeSync
|
||||||
import com.ponzischeme89.memby.data.playback.installAudioCapabilityProbe
|
import com.ponzischeme89.memby.data.playback.installAudioCapabilityProbe
|
||||||
@@ -25,6 +26,8 @@ object ServiceLocator {
|
|||||||
private set
|
private set
|
||||||
lateinit var repository: EmbyRepository
|
lateinit var repository: EmbyRepository
|
||||||
private set
|
private set
|
||||||
|
lateinit var searchRepository: SearchRepository
|
||||||
|
private set
|
||||||
lateinit var maintenance: MaintenanceMonitor
|
lateinit var maintenance: MaintenanceMonitor
|
||||||
private set
|
private set
|
||||||
lateinit var remoteConfig: RemoteConfigManager
|
lateinit var remoteConfig: RemoteConfigManager
|
||||||
@@ -77,7 +80,8 @@ object ServiceLocator {
|
|||||||
// Constructed before the repository so the very first playback resolution of the
|
// Constructed before the repository so the very first playback resolution of the
|
||||||
// process has somewhere to report Emby's address.
|
// process has somewhere to report Emby's address.
|
||||||
streamWarmer = StreamWarmer(context.applicationContext)
|
streamWarmer = StreamWarmer(context.applicationContext)
|
||||||
repository = EmbyRepository(settings, streamWarmer)
|
searchRepository = SearchRepository(settings)
|
||||||
|
repository = EmbyRepository(settings, streamWarmer, searchRepository)
|
||||||
maintenance = MaintenanceMonitor(repository, settings)
|
maintenance = MaintenanceMonitor(repository, settings)
|
||||||
// Takes the revision channel from the status poll rather than polling itself: the
|
// 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
|
// 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.AuthRequest
|
||||||
import com.ponzischeme89.memby.data.model.BaseItem
|
import com.ponzischeme89.memby.data.model.BaseItem
|
||||||
import android.os.SystemClock
|
|
||||||
import com.ponzischeme89.memby.data.model.GenreAffinity
|
import com.ponzischeme89.memby.data.model.GenreAffinity
|
||||||
import com.ponzischeme89.memby.data.model.GatewayCalendar
|
import com.ponzischeme89.memby.data.model.GatewayCalendar
|
||||||
import com.ponzischeme89.memby.data.model.GatewayFlagRequest
|
import com.ponzischeme89.memby.data.model.GatewayFlagRequest
|
||||||
@@ -243,6 +242,7 @@ data class PlaybackRequest(
|
|||||||
class EmbyRepository internal constructor(
|
class EmbyRepository internal constructor(
|
||||||
private val settings: SettingsStore,
|
private val settings: SettingsStore,
|
||||||
private val streamWarmer: StreamWarmer? = null,
|
private val streamWarmer: StreamWarmer? = null,
|
||||||
|
private val searchRepository: SearchRepository,
|
||||||
) {
|
) {
|
||||||
private val clientTrailerResolver = ClientTrailerResolver()
|
private val clientTrailerResolver = ClientTrailerResolver()
|
||||||
|
|
||||||
@@ -376,27 +376,6 @@ class EmbyRepository internal constructor(
|
|||||||
LinkedHashMap<String, RadarrMovieDetail>(RADARR_MOVIE_CACHE_SIZE, 0.75f, true)
|
LinkedHashMap<String, RadarrMovieDetail>(RADARR_MOVIE_CACHE_SIZE, 0.75f, true)
|
||||||
private val radarrMovieInFlight = mutableMapOf<String, Deferred<RadarrMovieDetail?>>()
|
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)
|
fun cachedHome(): HomeCache? = settings.homeCache(snapshot)
|
||||||
|
|
||||||
suspend fun cacheHome(content: HomeCache) = settings.setHomeCache(content)
|
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.
|
* re-sorting here, so the ordering rule stays a pure, testable function.
|
||||||
*/
|
*/
|
||||||
suspend fun search(term: String, limit: Int = 40): List<BaseItem> {
|
suspend fun search(term: String, limit: Int = 40): List<BaseItem> {
|
||||||
val trimmed = term.trim()
|
return searchRepository.search(term, limit)
|
||||||
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,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -975,62 +941,7 @@ class EmbyRepository internal constructor(
|
|||||||
limit: Int = GENRE_PAGE_SIZE,
|
limit: Int = GENRE_PAGE_SIZE,
|
||||||
itemType: String? = null,
|
itemType: String? = null,
|
||||||
): GenrePage {
|
): GenrePage {
|
||||||
val trimmed = genre.trim()
|
return searchRepository.browseGenre(genre, offset, limit, itemType)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1054,14 +965,7 @@ class EmbyRepository internal constructor(
|
|||||||
* are "use the catalogue's own order", which is a perfectly good rail.
|
* are "use the catalogue's own order", which is a perfectly good rail.
|
||||||
*/
|
*/
|
||||||
suspend fun getGenreAffinity(): GenreAffinity {
|
suspend fun getGenreAffinity(): GenreAffinity {
|
||||||
if (!ServerConfig.isGateway) return GenreAffinity()
|
return searchRepository.getGenreAffinity()
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1073,7 +977,7 @@ class EmbyRepository internal constructor(
|
|||||||
* than one that was never personalised, and the warm below is what makes it almost
|
* than one that was never personalised, and the warm below is what makes it almost
|
||||||
* always already here.
|
* always already here.
|
||||||
*/
|
*/
|
||||||
fun genreAffinitySnapshot(): GenreAffinity = genreAffinity
|
fun genreAffinitySnapshot(): GenreAffinity = searchRepository.genreAffinitySnapshot()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetches the reading if it is missing or stale, and returns immediately.
|
* 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.
|
* opened this has long since landed. The cost is one request per six hours per set.
|
||||||
*/
|
*/
|
||||||
fun warmGenreAffinity() {
|
fun warmGenreAffinity() {
|
||||||
if (!ServerConfig.isGateway) return
|
searchRepository.warmGenreAffinity()
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Another viewer's taste is not this one's; cleared wherever the session changes. */
|
/** Another viewer's taste is not this one's; cleared wherever the session changes. */
|
||||||
private fun clearGenreAffinity() {
|
private fun clearGenreAffinity() {
|
||||||
genreAffinity = GenreAffinity()
|
searchRepository.clearGenreAffinity()
|
||||||
genreAffinityReadAt = 0L
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1130,62 +1009,12 @@ class EmbyRepository internal constructor(
|
|||||||
limit: Int = GENRE_PAGE_SIZE,
|
limit: Int = GENRE_PAGE_SIZE,
|
||||||
itemType: String?,
|
itemType: String?,
|
||||||
): GenrePage {
|
): GenrePage {
|
||||||
val embyItemType = when (itemType?.trim()?.lowercase()) {
|
return searchRepository.browseLibrary(offset, limit, itemType)
|
||||||
"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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Records a successful gateway search without affecting the direct Emby path. */
|
/** Records a successful gateway search without affecting the direct Emby path. */
|
||||||
suspend fun recordSearch(term: String) {
|
suspend fun recordSearch(term: String) {
|
||||||
if (ServerConfig.isGateway && term.trim().length >= 2) {
|
searchRepository.recordSearch(term)
|
||||||
runCatching { requireGateway().recordSearch(mapOf("query" to term.trim())) }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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 {
|
companion object {
|
||||||
val DEFAULT_SECTIONS: List<String> = Settings.DEFAULT_HOME_SECTIONS.split(",")
|
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
|
if (repository.showTitleLogo) repository.logoUrl(item) else null
|
||||||
}
|
}
|
||||||
val logo = logoUrl.takeIf { !useTextTitleForLogo(it) }
|
val logo = logoUrl.takeIf { !useTextTitleForLogo(it) }
|
||||||
val facts = buildList {
|
val facts = metadataHeroFacts(item)
|
||||||
item.productionYear?.let { add(it.toString()) }
|
|
||||||
item.runtimeMinutes?.let { add(formatRuntime(it)) }
|
|
||||||
item.officialRating?.takeIf(String::isNotBlank)?.let(::add)
|
|
||||||
}
|
|
||||||
val badges = buildList {
|
val badges = buildList {
|
||||||
airingBadgeLabel(item)?.let(::add)
|
airingBadgeLabel(item)?.let(::add)
|
||||||
addAll(mediaBadges(item))
|
addAll(mediaBadges(item))
|
||||||
}
|
}
|
||||||
val genres = item.genres.take(2).joinToString(ValueSeparator)
|
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier.width(contentWidth).fillMaxHeight(),
|
modifier = Modifier.width(contentWidth).fillMaxHeight(),
|
||||||
verticalArrangement = Arrangement.spacedBy(if (compact) 5.dp else 8.dp),
|
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(),
|
item = item, load = true, compact = true, modifier = Modifier.fillMaxWidth(),
|
||||||
)
|
)
|
||||||
MetadataHeroSection.Facts -> {
|
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()) {
|
if (facts.isNotEmpty()) {
|
||||||
Text(facts.joinToString(FactSeparator), color = MutedText, fontSize = 13.sp,
|
Text(facts.joinToString(FactSeparator), color = MutedText, fontSize = 13.sp,
|
||||||
maxLines = 1, overflow = TextOverflow.Ellipsis)
|
maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||||
@@ -246,10 +242,6 @@ private fun MetadataContent(
|
|||||||
badges.forEach { MediaBadge(it) }
|
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(
|
MetadataHeroSection.Summary -> Text(
|
||||||
item.overview?.takeIf(String::isNotBlank) ?: "No description available.",
|
item.overview?.takeIf(String::isNotBlank) ?: "No description available.",
|
||||||
color = MembyMutedText, fontSize = 14.sp, lineHeight = 18.sp,
|
color = MembyMutedText, fontSize = 14.sp, lineHeight = 18.sp,
|
||||||
@@ -266,15 +258,29 @@ private object MetadataHeroSection {
|
|||||||
const val Title = "title"
|
const val Title = "title"
|
||||||
const val Ratings = "ratings"
|
const val Ratings = "ratings"
|
||||||
const val Facts = "facts"
|
const val Facts = "facts"
|
||||||
const val Genres = "genres"
|
|
||||||
const val Summary = "summary"
|
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> =
|
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 }
|
.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
|
@Composable
|
||||||
private fun MetadataHeroTitle(item: BaseItem, logoUrl: String?, logo: String?, compact: Boolean) {
|
private fun MetadataHeroTitle(item: BaseItem, logoUrl: String?, logo: String?, compact: Boolean) {
|
||||||
Box(
|
Box(
|
||||||
|
|||||||
@@ -78,7 +78,6 @@ import androidx.compose.ui.semantics.contentDescription
|
|||||||
import androidx.compose.ui.semantics.onLongClick
|
import androidx.compose.ui.semantics.onLongClick
|
||||||
import androidx.compose.ui.semantics.semantics
|
import androidx.compose.ui.semantics.semantics
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
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.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
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 RailSurface: Color get() = MembySurface.copy(alpha = 0.95f)
|
||||||
private val MutedText: Color get() = MembyMutedText
|
private val MutedText: Color get() = MembyMutedText
|
||||||
private val QuietText: Color get() = MembyQuietText
|
private val QuietText: Color get() = MembyQuietText
|
||||||
internal val TvRailCollapsedWidth = 104.dp
|
internal val TvRailCollapsedWidth = 54.dp
|
||||||
internal val TvRailExpandedWidth = 184.dp
|
internal val TvRailExpandedWidth = 184.dp
|
||||||
internal val HomeContentHorizontalInset = 48.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
|
* The rail's order, which is this declaration order — see [navigationRailItems], the only
|
||||||
@@ -233,19 +230,16 @@ fun TvNavigationRail(
|
|||||||
)
|
)
|
||||||
Box(
|
Box(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
// Home content begins after this slim footprint, then applies its own 48dp
|
.width(TvRailCollapsedWidth)
|
||||||
// reading inset. Together they meet the 104dp rail edge without preserving
|
|
||||||
// a second, invisible gutter from the old sidebar layout.
|
|
||||||
.width(TvRailHomeFootprint)
|
|
||||||
.fillMaxHeight()
|
.fillMaxHeight()
|
||||||
.zIndex(8f),
|
.zIndex(8f),
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
// The parent deliberately reports only the Home footprint to the Row.
|
// The parent deliberately reports only the collapsed footprint to the
|
||||||
// The unbounded width lets the collapsed and focused surfaces draw outward
|
// home Row. requiredWidth lets the focused surface draw outward without
|
||||||
// without remeasuring gallery cards or clipping their labels to 104dp.
|
// remeasuring gallery cards or clipping their labels to 54dp.
|
||||||
// Only width may escape the collapsed 104dp footprint. Unbinding height
|
// Only width may escape the collapsed 54dp footprint. Unbinding height
|
||||||
// here would make fillMaxHeight lose the screen constraint and stop the
|
// here would make fillMaxHeight lose the screen constraint and stop the
|
||||||
// rail surface at its final child (the version label).
|
// rail surface at its final child (the version label).
|
||||||
.wrapContentWidth(Alignment.Start, unbounded = true)
|
.wrapContentWidth(Alignment.Start, unbounded = true)
|
||||||
@@ -270,16 +264,12 @@ fun TvNavigationRail(
|
|||||||
}
|
}
|
||||||
.focusGroup()
|
.focusGroup()
|
||||||
.padding(horizontal = 5.dp, vertical = 15.dp),
|
.padding(horizontal = 5.dp, vertical = 15.dp),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.Start,
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth().height(42.dp).padding(horizontal = 6.dp),
|
modifier = Modifier.height(42.dp).padding(horizontal = 6.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = if (expanded) {
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
Arrangement.spacedBy(12.dp)
|
|
||||||
} else {
|
|
||||||
Arrangement.Center
|
|
||||||
},
|
|
||||||
) {
|
) {
|
||||||
// Memby's own icon rather than Emby's mark: the rail is Memby's chrome, and
|
// 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.
|
// 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,
|
fontSize = if (expanded) 10.sp else 8.sp,
|
||||||
fontWeight = FontWeight.Medium,
|
fontWeight = FontWeight.Medium,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
textAlign = if (expanded) TextAlign.Start else TextAlign.Center,
|
modifier = Modifier.padding(horizontal = if (expanded) 8.dp else 4.dp),
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(horizontal = if (expanded) 8.dp else 4.dp),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -869,10 +856,7 @@ fun ExpandableNavigationItem(
|
|||||||
)
|
)
|
||||||
Row(
|
Row(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.then(
|
.fillMaxWidth()
|
||||||
if (expanded) Modifier.fillMaxWidth()
|
|
||||||
else Modifier.width(TvRailCollapsedItemWidth),
|
|
||||||
)
|
|
||||||
.height(44.dp)
|
.height(44.dp)
|
||||||
.onFocusChanged {
|
.onFocusChanged {
|
||||||
focused = it.isFocused
|
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,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = if (expanded) {
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
Arrangement.spacedBy(12.dp)
|
|
||||||
} else {
|
|
||||||
Arrangement.Center
|
|
||||||
},
|
|
||||||
) {
|
) {
|
||||||
Box(Modifier.width(28.dp), contentAlignment = Alignment.Center) {
|
Box(Modifier.width(28.dp), contentAlignment = Alignment.Center) {
|
||||||
if (avatarInitials != null) {
|
if (avatarInitials != null) {
|
||||||
@@ -929,7 +909,7 @@ fun ExpandableNavigationItem(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
// Over the icon rather than after the label: the rail spends most of its life
|
// 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) {
|
if (badge != null) {
|
||||||
Text(
|
Text(
|
||||||
badge,
|
badge,
|
||||||
|
|||||||
@@ -197,6 +197,9 @@ internal fun shouldOfferNextEpisodeButton(
|
|||||||
playingPreview: Boolean,
|
playingPreview: Boolean,
|
||||||
): Boolean = hasNextEpisode && !advancing && !playingPreview
|
): 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.
|
* Whether the Magic control belongs on the transport row.
|
||||||
*
|
*
|
||||||
@@ -211,4 +214,6 @@ internal fun shouldOfferMagicButton(
|
|||||||
magicAvailable: Boolean,
|
magicAvailable: Boolean,
|
||||||
advancing: Boolean,
|
advancing: Boolean,
|
||||||
playingPreview: 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
|
* Fullscreen Media3 player with native stream-track selection. Press Menu while
|
||||||
* playing to choose an audio or subtitle track; the subtitle controller button
|
* 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() {
|
class PlayerActivity : ComponentActivity() {
|
||||||
|
|
||||||
@@ -205,6 +206,8 @@ class PlayerActivity : ComponentActivity() {
|
|||||||
*/
|
*/
|
||||||
private var pendingRequest: PlaybackRequest? = null
|
private var pendingRequest: PlaybackRequest? = null
|
||||||
private var pendingTrailerRequest: TrailerPlaybackRequest? = null
|
private var pendingTrailerRequest: TrailerPlaybackRequest? = null
|
||||||
|
private val playingTrailer: Boolean
|
||||||
|
get() = pendingTrailerRequest != null
|
||||||
private var trailerStartupTimeoutJob: Job? = null
|
private var trailerStartupTimeoutJob: Job? = null
|
||||||
private var currentTrailerCandidateId = ""
|
private var currentTrailerCandidateId = ""
|
||||||
private var currentTrailerProvider = ""
|
private var currentTrailerProvider = ""
|
||||||
@@ -632,9 +635,12 @@ class PlayerActivity : ComponentActivity() {
|
|||||||
Log.i(PLAYBACK_LOG_TAG, "event=subtitle_configs item=${itemId.orEmpty()} count=${subtitles.size}")
|
Log.i(PLAYBACK_LOG_TAG, "event=subtitle_configs item=${itemId.orEmpty()} count=${subtitles.size}")
|
||||||
|
|
||||||
setContentView(R.layout.activity_player)
|
setContentView(R.layout.activity_player)
|
||||||
|
val enhancementsAvailable = membyPlayerEnhancementsAvailable(
|
||||||
|
playingTrailer = playingTrailer,
|
||||||
|
)
|
||||||
val view = findViewById<PlayerView>(R.id.player_view).apply {
|
val view = findViewById<PlayerView>(R.id.player_view).apply {
|
||||||
useController = false
|
useController = false
|
||||||
setShowSubtitleButton(true)
|
setShowSubtitleButton(enhancementsAvailable)
|
||||||
controllerShowTimeoutMs = CONTROLLER_TIMEOUT_MS
|
controllerShowTimeoutMs = CONTROLLER_TIMEOUT_MS
|
||||||
}
|
}
|
||||||
applySubtitleAppearance(view)
|
applySubtitleAppearance(view)
|
||||||
@@ -648,10 +654,14 @@ class PlayerActivity : ComponentActivity() {
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
playerView = view
|
playerView = view
|
||||||
view.findViewById<View>(androidx.media3.ui.R.id.exo_subtitle)?.setOnClickListener {
|
view.findViewById<View>(androidx.media3.ui.R.id.exo_subtitle)?.apply {
|
||||||
showSubtitleOverlay()
|
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 {
|
nextEpisodeButton = view.findViewById<View>(R.id.player_next_episode)?.apply {
|
||||||
setOnClickListener {
|
setOnClickListener {
|
||||||
// The same resolved answer the credits pane and the countdown use, and the
|
// The same resolved answer the credits pane and the countdown use, and the
|
||||||
@@ -3346,6 +3356,7 @@ class PlayerActivity : ComponentActivity() {
|
|||||||
magicAvailable = magicAvailable,
|
magicAvailable = magicAvailable,
|
||||||
advancing = advancing,
|
advancing = advancing,
|
||||||
playingPreview = playingNextEpisodePreview,
|
playingPreview = playingNextEpisodePreview,
|
||||||
|
playingTrailer = playingTrailer,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3359,6 +3370,7 @@ class PlayerActivity : ComponentActivity() {
|
|||||||
* player that knows what it has already put in front of this viewer.
|
* player that knows what it has already put in front of this viewer.
|
||||||
*/
|
*/
|
||||||
private fun playSomethingElse() {
|
private fun playSomethingElse() {
|
||||||
|
if (playingTrailer) return
|
||||||
if (magicJob?.isActive == true || advanceRequested || advancing) return
|
if (magicJob?.isActive == true || advanceRequested || advancing) return
|
||||||
val current = itemId?.takeIf { it.isNotBlank() }
|
val current = itemId?.takeIf { it.isNotBlank() }
|
||||||
PlaybackJourney.magicRequested(JourneyTracker)
|
PlaybackJourney.magicRequested(JourneyTracker)
|
||||||
@@ -4484,6 +4496,7 @@ class PlayerActivity : ComponentActivity() {
|
|||||||
@OptIn(UnstableApi::class)
|
@OptIn(UnstableApi::class)
|
||||||
private fun showTrackMenu() {
|
private fun showTrackMenu() {
|
||||||
val playback = player ?: return
|
val playback = player ?: return
|
||||||
|
val enhancementsAvailable = membyPlayerEnhancementsAvailable(playingTrailer)
|
||||||
val audio = playback.currentTracks.groups.any { group ->
|
val audio = playback.currentTracks.groups.any { group ->
|
||||||
group.type == C.TRACK_TYPE_AUDIO && (0 until group.mediaTrackGroup.length).any(group::isTrackSupported)
|
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 pictureOption = "Picture size · ${selectedPictureMode().shortLabel}"
|
||||||
val options = buildList {
|
val options = buildList {
|
||||||
if (audio) add("Audio")
|
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(passthroughOption)
|
||||||
add(pictureOption)
|
add(pictureOption)
|
||||||
}
|
}
|
||||||
@@ -4714,6 +4729,7 @@ class PlayerActivity : ComponentActivity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun showCastOverlay() {
|
private fun showCastOverlay() {
|
||||||
|
if (playingTrailer) return
|
||||||
playerView?.hideController()
|
playerView?.hideController()
|
||||||
castOverlay?.visibility = View.VISIBLE
|
castOverlay?.visibility = View.VISIBLE
|
||||||
bindCastOverlay()
|
bindCastOverlay()
|
||||||
@@ -4824,6 +4840,7 @@ class PlayerActivity : ComponentActivity() {
|
|||||||
focusSize: Int? = null,
|
focusSize: Int? = null,
|
||||||
focusDownload: Int? = null,
|
focusDownload: Int? = null,
|
||||||
) {
|
) {
|
||||||
|
if (playingTrailer) return
|
||||||
val playback = player ?: return
|
val playback = player ?: return
|
||||||
val overlay = subtitleOverlay ?: return
|
val overlay = subtitleOverlay ?: return
|
||||||
val tracksContainer = overlay.findViewById<LinearLayout>(R.id.player_subtitle_tracks)
|
val tracksContainer = overlay.findViewById<LinearLayout>(R.id.player_subtitle_tracks)
|
||||||
|
|||||||
@@ -247,9 +247,9 @@
|
|||||||
android:gravity="center_vertical"
|
android:gravity="center_vertical"
|
||||||
android:orientation="horizontal">
|
android:orientation="horizontal">
|
||||||
|
|
||||||
<!-- Withheld for episodic content: Next Episode already answers "what now?"
|
<!-- Withheld for episodic content and trailers: Next Episode already answers
|
||||||
there, and the server's picker is films only. Gone rather than disabled
|
"what now?" for a programme, while a trailer's experience belongs to
|
||||||
for the same reason as Next Episode. -->
|
its provider. Gone rather than disabled for the same reason as Next Episode. -->
|
||||||
<ImageButton
|
<ImageButton
|
||||||
android:id="@+id/player_magic"
|
android:id="@+id/player_magic"
|
||||||
style="@style/MembyPlayerControlButton"
|
style="@style/MembyPlayerControlButton"
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
package com.ponzischeme89.memby.ui
|
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.Assert.assertEquals
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
||||||
@@ -16,8 +18,31 @@ class MetadataHeroOrderTest {
|
|||||||
@Test
|
@Test
|
||||||
fun `unknown sections fall back to the default layout`() {
|
fun `unknown sections fall back to the default layout`() {
|
||||||
assertEquals(
|
assertEquals(
|
||||||
listOf("title", "ratings", "facts", "genres", "summary"),
|
listOf("title", "ratings", "facts", "summary"),
|
||||||
metadataHeroContentOrder(listOf("unknown")),
|
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 {
|
class NavigationRailTest {
|
||||||
@Test
|
@Test
|
||||||
fun `collapsed rail stays within the slim TV target`() {
|
fun `collapsed rail keeps its slim home footprint`() {
|
||||||
assertTrue(TvRailCollapsedWidth in 96.dp..112.dp)
|
assertEquals(54.dp, TvRailCollapsedWidth)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -81,6 +81,12 @@ class NextUpPipelineTest {
|
|||||||
|
|
||||||
// --- Magic ---------------------------------------------------------------------------
|
// --- Magic ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `trailers withhold Memby-owned player enhancements`() {
|
||||||
|
assertFalse(membyPlayerEnhancementsAvailable(playingTrailer = true))
|
||||||
|
assertTrue(membyPlayerEnhancementsAvailable(playingTrailer = false))
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `magic belongs to films and stands aside for next episode`() {
|
fun `magic belongs to films and stands aside for next episode`() {
|
||||||
assertTrue(
|
assertTrue(
|
||||||
@@ -100,6 +106,16 @@ class NextUpPipelineTest {
|
|||||||
isEpisode = false, magicAvailable = false, advancing = false, playingPreview = false,
|
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 --------------------------------------------------------------------
|
// --- The resolver --------------------------------------------------------------------
|
||||||
|
|||||||
@@ -134,14 +134,13 @@ var preferenceCatalogue = []preferenceDefinition{
|
|||||||
// This is operator-owned because the hero is a shared piece of the launcher's
|
// This is operator-owned because the hero is a shared piece of the launcher's
|
||||||
// composition, not a per-television preference. The multi value keeps its order.
|
// composition, not a per-television preference. The multi value keeps its order.
|
||||||
Key: "metadataHeroContentOrder", Name: "Metadata hero content order", Area: "Home layout",
|
Key: "metadataHeroContentOrder", Name: "Metadata hero content order", Area: "Home layout",
|
||||||
Description: "The order of title, ratings, facts, genre and plot summary in the focused metadata hero.",
|
Description: "The order of title, ratings, metadata information and plot summary in the focused metadata hero.",
|
||||||
Kind: preferenceMulti, AdminOnly: true,
|
Kind: preferenceMulti, AdminOnly: true,
|
||||||
Default: []string{"title", "ratings", "facts", "genres", "summary"},
|
Default: []string{"title", "ratings", "facts", "summary"},
|
||||||
Options: []preferenceOption{
|
Options: []preferenceOption{
|
||||||
option("title", "Movie logo or title"),
|
option("title", "Movie logo or title"),
|
||||||
option("ratings", "Ratings"),
|
option("ratings", "Ratings"),
|
||||||
option("facts", "Metadata facts"),
|
option("facts", "Metadata information"),
|
||||||
option("genres", "Genre"),
|
|
||||||
option("summary", "Plot summary"),
|
option("summary", "Plot summary"),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -337,6 +336,12 @@ func normalizePreference(definition preferenceDefinition, value any) any {
|
|||||||
if selected := stringList(value); len(selected) > 0 {
|
if selected := stringList(value); len(selected) > 0 {
|
||||||
kept := []string{}
|
kept := []string{}
|
||||||
for _, entry := range selected {
|
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) {
|
if hasOption(definition.Options, entry) && !slices.Contains(kept, entry) {
|
||||||
kept = append(kept, entry)
|
kept = append(kept, entry)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
// 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.
|
// one would come back as two rows on the next sync.
|
||||||
func TestNormalizePreferencesRejectsNewlinesInRowIds(t *testing.T) {
|
func TestNormalizePreferencesRejectsNewlinesInRowIds(t *testing.T) {
|
||||||
|
|||||||
Reference in New Issue
Block a user