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