Big changes
This commit is contained in:
@@ -0,0 +1,220 @@
|
||||
package com.ponzischeme89.memby.data
|
||||
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.ProcessLifecycleOwner
|
||||
import androidx.lifecycle.repeatOnLifecycle
|
||||
import com.ponzischeme89.memby.data.model.GatewayAlert
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
data class MaintenanceNotice(val message: String)
|
||||
data class CompatibilityNotice(val message: String)
|
||||
|
||||
/**
|
||||
* One informational banner: a show aired, its episode is on its way into Emby. It is
|
||||
* never actionable and never focusable — it slides in, says its piece and goes.
|
||||
*/
|
||||
data class ServiceAlert(
|
||||
val id: String,
|
||||
val title: String,
|
||||
val message: String,
|
||||
val posterUrl: String?,
|
||||
)
|
||||
|
||||
/**
|
||||
* One process-wide control channel shared by every Memby activity.
|
||||
*
|
||||
* A network failure leaves the last confirmed state intact: losing connectivity while a
|
||||
* maintenance notice is showing must not briefly reopen playback. A successful status
|
||||
* response is the only thing that enters or clears maintenance.
|
||||
*
|
||||
* The same poll carries alerts, so news reaches an open app without a second connection.
|
||||
* The gateway keeps offering an alert for as long as it is current and has no idea which
|
||||
* TVs have seen it, so *this* side owns "shown already" — persisted, or every relaunch
|
||||
* would replay yesterday's news.
|
||||
*
|
||||
* The loop runs only while a Memby screen is in the foreground, and an alert counts as
|
||||
* shown only when the banner says so ([alertShown]). Both exist for the same reason: work
|
||||
* done for a screen nobody is looking at is worse than wasted, because it also burns the
|
||||
* one chance to deliver the news.
|
||||
*/
|
||||
class MaintenanceMonitor(
|
||||
private val repository: EmbyRepository,
|
||||
private val settings: SettingsStore,
|
||||
) {
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
private val _notice = MutableStateFlow<MaintenanceNotice?>(null)
|
||||
val notice: StateFlow<MaintenanceNotice?> = _notice.asStateFlow()
|
||||
|
||||
private val _compatibility = MutableStateFlow<CompatibilityNotice?>(null)
|
||||
val compatibility: StateFlow<CompatibilityNotice?> = _compatibility.asStateFlow()
|
||||
|
||||
private val _alert = MutableStateFlow<ServiceAlert?>(null)
|
||||
val alert: StateFlow<ServiceAlert?> = _alert.asStateFlow()
|
||||
|
||||
private val seenAlertIds = mutableSetOf<String>()
|
||||
private var seenAlertsLoaded = false
|
||||
private var shownAlertId: String? = null
|
||||
private var alertTimer: Job? = null
|
||||
|
||||
init {
|
||||
scope.launchStatusLoop()
|
||||
}
|
||||
|
||||
/**
|
||||
* Called by the banner once it is actually on screen. Until this arrives the alert is
|
||||
* only *offered*: nothing is persisted and no timer runs, so an alert that lands while
|
||||
* the screensaver is up or another app is in front survives to be shown later rather
|
||||
* than being consumed by nobody.
|
||||
*
|
||||
* Seen is recorded on display rather than on dismissal, because a banner interrupted
|
||||
* by a crash or a power cut is not worth replaying a day later.
|
||||
*/
|
||||
fun alertShown(id: String) {
|
||||
if (shownAlertId == id) return
|
||||
shownAlertId = id
|
||||
seenAlertIds += id
|
||||
scope.launch { runCatching { settings.markAlertSeen(id) } }
|
||||
|
||||
alertTimer?.cancel()
|
||||
alertTimer = scope.launch {
|
||||
delay(ALERT_VISIBLE_MS)
|
||||
if (_alert.value?.id == id) _alert.value = null
|
||||
}
|
||||
}
|
||||
|
||||
/** Hides the current banner early — the viewer has already read it. */
|
||||
fun dismissAlert() {
|
||||
alertTimer?.cancel()
|
||||
shownAlertId = null
|
||||
_alert.value = null
|
||||
}
|
||||
|
||||
private fun CoroutineScope.launchStatusLoop() = launch {
|
||||
// Only while a Memby screen is in front. Backgrounded, this loop is cancelled
|
||||
// outright rather than left ticking a request every ten seconds at a TV nobody
|
||||
// is looking at; coming back to the foreground restarts it with an immediate
|
||||
// poll, so maintenance is still caught the moment the viewer returns.
|
||||
ProcessLifecycleOwner.get().lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
|
||||
repository.settingsFlow.collectLatest { session ->
|
||||
if (!ServerConfig.isGateway || !session.isSignedIn) {
|
||||
_notice.value = null
|
||||
_compatibility.value = null
|
||||
dismissAlert()
|
||||
return@collectLatest
|
||||
}
|
||||
|
||||
while (isActive) {
|
||||
runCatching { repository.serviceStatus() }
|
||||
.onSuccess { status ->
|
||||
_compatibility.value = if (status.compatible) {
|
||||
null
|
||||
} else {
|
||||
CompatibilityNotice(
|
||||
status.compatibilityMessage.trim().ifEmpty {
|
||||
"This Memby app and server are not compatible. Update the app or contact the server administrator."
|
||||
},
|
||||
)
|
||||
}
|
||||
_notice.value = if (status.maintenance) {
|
||||
MaintenanceNotice(
|
||||
status.message.trim().ifEmpty {
|
||||
"Memby is down for maintenance. Try again shortly."
|
||||
},
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
if (status.maintenance) {
|
||||
// The maintenance screen owns the display; anything
|
||||
// cheerful in front of it would only be confusing.
|
||||
dismissAlert()
|
||||
} else {
|
||||
offerNextAlert(status.alerts)
|
||||
}
|
||||
}
|
||||
.onFailure { error ->
|
||||
if (isUnauthorizedError(error)) {
|
||||
repository.invalidateSession()
|
||||
_notice.value = null
|
||||
_compatibility.value = null
|
||||
dismissAlert()
|
||||
return@collectLatest
|
||||
}
|
||||
}
|
||||
delay(POLL_INTERVAL_MS)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun offerNextAlert(alerts: List<GatewayAlert>) {
|
||||
if (!seenAlertsLoaded) {
|
||||
seenAlertIds += runCatching { settings.seenAlertIds() }.getOrDefault(emptySet())
|
||||
seenAlertsLoaded = true
|
||||
}
|
||||
|
||||
val pending = _alert.value
|
||||
if (pending != null) {
|
||||
if (pendingAlertExpired(pending.id, shownAlertId, alerts)) {
|
||||
_alert.value = null
|
||||
}
|
||||
// One at a time either way; a queued second alert is still unseen next poll.
|
||||
return
|
||||
}
|
||||
|
||||
val next = firstUnseenAlert(alerts, seenAlertIds) ?: return
|
||||
_alert.value = ServiceAlert(
|
||||
id = next.id,
|
||||
title = next.title.trim(),
|
||||
message = next.message.trim(),
|
||||
posterUrl = repository.posterUrl(next.itemId, next.imageTag),
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
internal const val POLL_INTERVAL_MS = 10_000L
|
||||
|
||||
/**
|
||||
* How long one banner stays on screen. The banner draws a ring counting this
|
||||
* down, so the two must agree — take the duration from here rather than
|
||||
* hard-coding it in the UI.
|
||||
*/
|
||||
const val ALERT_VISIBLE_MS = 10_000L
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an alert held for a screen that never appeared should be given up on.
|
||||
*
|
||||
* An offered alert waits indefinitely for a foreground banner, so something has to end
|
||||
* that wait: once the gateway stops listing it, the episode aired long enough ago that
|
||||
* announcing it is no longer news. An alert already shown is left alone — its own
|
||||
* dismissal timer owns it.
|
||||
*/
|
||||
internal fun pendingAlertExpired(
|
||||
pendingId: String,
|
||||
shownId: String?,
|
||||
alerts: List<GatewayAlert>,
|
||||
): Boolean = pendingId != shownId && alerts.none { it.id == pendingId }
|
||||
|
||||
/**
|
||||
* The first alert this TV has not shown before. Alerts arrive newest-first, and anything
|
||||
* missing an id, a title or a message is dropped rather than rendered as an empty card.
|
||||
*/
|
||||
internal fun firstUnseenAlert(alerts: List<GatewayAlert>, seen: Set<String>): GatewayAlert? =
|
||||
alerts.firstOrNull { alert ->
|
||||
alert.id.isNotBlank() &&
|
||||
alert.id !in seen &&
|
||||
alert.title.isNotBlank() &&
|
||||
alert.message.isNotBlank()
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package com.ponzischeme89.memby.data
|
||||
|
||||
import com.ponzischeme89.memby.data.model.MediaStream
|
||||
import kotlinx.serialization.Serializable
|
||||
import java.net.URI
|
||||
import java.net.URLEncoder
|
||||
|
||||
@Serializable
|
||||
data class PlayableSubtitle(
|
||||
val id: String = "",
|
||||
val url: String,
|
||||
val mimeType: String,
|
||||
val language: String? = null,
|
||||
val label: String? = null,
|
||||
val isDefault: Boolean = false,
|
||||
val isForced: Boolean = false,
|
||||
val isHearingImpaired: Boolean = false,
|
||||
val deliveryMethod: String = "External",
|
||||
val codec: String? = null,
|
||||
)
|
||||
|
||||
internal fun subtitleTracks(
|
||||
streams: List<MediaStream>,
|
||||
serverUrl: String,
|
||||
token: String,
|
||||
itemId: String,
|
||||
mediaSourceId: String,
|
||||
): List<PlayableSubtitle> = streams.mapNotNull { stream ->
|
||||
if (!stream.type.equals("Subtitle", true) || stream.index < 0) {
|
||||
return@mapNotNull null
|
||||
}
|
||||
val sourceId = mediaSourceId.ifBlank { itemId }
|
||||
val method = stream.deliveryMethod?.takeIf(String::isNotBlank)
|
||||
?: if (stream.isTextSubtitleStream) "External" else "Encode"
|
||||
val mime = subtitleMimeType(stream.codec, stream.deliveryUrl.orEmpty())
|
||||
val extension = subtitleExtension(stream.codec, stream.deliveryUrl.orEmpty())
|
||||
val canonical = "/Videos/${pathSegment(itemId)}/${pathSegment(sourceId)}" +
|
||||
"/Subtitles/${stream.index}/Stream.$extension"
|
||||
val delivery = stream.deliveryUrl?.takeIf(String::isNotBlank) ?: canonical
|
||||
PlayableSubtitle(
|
||||
id = stream.index.toString(),
|
||||
url = if (method.equals("External", true) && mime != null) {
|
||||
authenticatedDeliveryUrl(serverUrl, delivery, token)
|
||||
} else "",
|
||||
mimeType = mime.orEmpty(),
|
||||
language = stream.language?.trim()?.takeIf(String::isNotEmpty),
|
||||
label = (stream.displayTitle ?: stream.title)?.trim()?.takeIf(String::isNotEmpty),
|
||||
isDefault = stream.isDefault,
|
||||
isForced = stream.isForced,
|
||||
isHearingImpaired = stream.isHearingImpaired || listOfNotNull(stream.title, stream.displayTitle).any {
|
||||
it.contains("sdh", true) || it.contains("hearing", true)
|
||||
},
|
||||
deliveryMethod = method,
|
||||
codec = stream.codec,
|
||||
)
|
||||
}.distinctBy { it.id }
|
||||
|
||||
private fun subtitleExtension(codec: String?, url: String): String =
|
||||
when (codec?.trim()?.lowercase()) {
|
||||
"subrip" -> "srt"
|
||||
"webvtt" -> "vtt"
|
||||
"tx3g" -> "mov_text"
|
||||
else -> codec?.trim()?.lowercase()?.takeIf(String::isNotEmpty)
|
||||
?: url.substringBefore('?').substringAfterLast('.', "vtt").lowercase()
|
||||
}
|
||||
|
||||
private fun pathSegment(value: String): String =
|
||||
URLEncoder.encode(value, "UTF-8").replace("+", "%20")
|
||||
|
||||
internal fun subtitleMimeType(codec: String?, url: String = ""): String? =
|
||||
when ((codec?.trim()?.lowercase()?.takeIf(String::isNotEmpty)
|
||||
?: url.substringBefore('?').substringAfterLast('.', "").lowercase())) {
|
||||
"srt", "subrip" -> "application/x-subrip"
|
||||
"vtt", "webvtt" -> "text/vtt"
|
||||
"ass", "ssa" -> "text/x-ssa"
|
||||
"ttml", "dfxp" -> "application/ttml+xml"
|
||||
"tx3g", "mov_text" -> "application/x-quicktime-tx3g"
|
||||
else -> null
|
||||
}
|
||||
|
||||
internal fun authenticatedDeliveryUrl(serverUrl: String, deliveryUrl: String, token: String): String {
|
||||
val absolute = if (runCatching { URI(deliveryUrl).isAbsolute }.getOrDefault(false)) {
|
||||
deliveryUrl
|
||||
} else {
|
||||
serverUrl.trimEnd('/') + "/" + deliveryUrl.trimStart('/')
|
||||
}
|
||||
if (token.isBlank() || Regex("""(?:[?&])api_key=""", RegexOption.IGNORE_CASE).containsMatchIn(absolute)) {
|
||||
return absolute
|
||||
}
|
||||
val separator = if ('?' in absolute) '&' else '?'
|
||||
return absolute + separator + "api_key=" + URLEncoder.encode(token, "UTF-8")
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package com.ponzischeme89.memby.ui
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.tooling.preview.Devices
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import com.ponzischeme89.memby.ui.theme.MembyTheme
|
||||
|
||||
/**
|
||||
* The one preview shape this app uses: a 1080p TV, landscape, on the launcher's own
|
||||
* near-black. A phone-sized preview would be actively misleading — every layout here is
|
||||
* built for a 10-foot view and D-pad focus.
|
||||
*
|
||||
* Previews render the composable only; they do not run `ServiceLocator`, so anything
|
||||
* previewable has to take its state as parameters rather than reading the repository.
|
||||
* That is worth keeping: it is the same property that makes a composable testable.
|
||||
*/
|
||||
@Preview(
|
||||
name = "TV 1080p",
|
||||
device = Devices.TV_1080p,
|
||||
showBackground = true,
|
||||
backgroundColor = 0xFF090B0D,
|
||||
)
|
||||
annotation class TvPreview
|
||||
|
||||
/** Wraps preview content in the real theme, so type and colours match the running app. */
|
||||
@Composable
|
||||
fun PreviewSurface(
|
||||
alignment: Alignment = Alignment.Center,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
MembyTheme {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color(0xFF090B0D)),
|
||||
contentAlignment = alignment,
|
||||
) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.ponzischeme89.memby.ui
|
||||
|
||||
import androidx.lifecycle.ViewModelStore
|
||||
import androidx.lifecycle.ViewModelStoreOwner
|
||||
|
||||
/**
|
||||
* A lifecycle boundary for state that belongs to one signed-in Emby profile.
|
||||
*
|
||||
* The owner is remembered by the profile screen and cleared when that screen leaves
|
||||
* composition. Clearing it cancels the old profile's ViewModel jobs as well as dropping
|
||||
* its rows and caches before another profile is rendered.
|
||||
*/
|
||||
internal class ProfileViewModelStoreOwner : ViewModelStoreOwner {
|
||||
override val viewModelStore = ViewModelStore()
|
||||
|
||||
fun clear() {
|
||||
viewModelStore.clear()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,574 @@
|
||||
package com.ponzischeme89.memby.ui
|
||||
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusProperties
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.zIndex
|
||||
import androidx.tv.material3.Button
|
||||
import androidx.tv.material3.Icon
|
||||
import androidx.tv.material3.Text
|
||||
import coil.compose.AsyncImage
|
||||
import com.ponzischeme89.memby.ServiceLocator
|
||||
import com.ponzischeme89.memby.data.model.BaseItem
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.CheckCircle
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
private val SeriesGreen = Color(0xFF52B54B)
|
||||
private val SeriesMuted = Color(0xFFD0D6DB)
|
||||
private val SeriesQuiet = Color(0xFFAEB7BF)
|
||||
|
||||
@Composable
|
||||
fun SeriesDetailsOverlay(
|
||||
item: BaseItem,
|
||||
onPlay: (BaseItem) -> Unit,
|
||||
onToggleFavorite: (BaseItem, Boolean) -> Unit,
|
||||
onClose: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val repository = ServiceLocator.repository
|
||||
var episodes by remember(item.id) { mutableStateOf<List<BaseItem>?>(null) }
|
||||
var loadFailed by remember(item.id) { mutableStateOf(false) }
|
||||
var selectedSectionKey by rememberSaveable(item.id) {
|
||||
mutableStateOf(SeriesDetailSection.EPISODES.key)
|
||||
}
|
||||
val selectedSection = seriesDetailSection(selectedSectionKey)
|
||||
|
||||
LaunchedEffect(item.id) {
|
||||
runCatching { repository.getSeriesEpisodes(item.id) }
|
||||
.onSuccess { episodes = it.sortedWith(seriesEpisodeComparator) }
|
||||
.onFailure {
|
||||
loadFailed = true
|
||||
episodes = emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
val seasons = remember(episodes) { availableSeasons(episodes.orEmpty()) }
|
||||
var selectedSeason by rememberSaveable(item.id) { mutableStateOf<Int?>(null) }
|
||||
LaunchedEffect(seasons) {
|
||||
if (selectedSeason !in seasons) {
|
||||
selectedSeason = defaultSeason(episodes.orEmpty())
|
||||
}
|
||||
}
|
||||
val seasonEpisodes = remember(episodes, selectedSeason) {
|
||||
episodesForSeason(episodes.orEmpty(), selectedSeason)
|
||||
}
|
||||
val firstEpisode = remember { FocusRequester() }
|
||||
val seasonFocusRequesters = remember(seasons) {
|
||||
seasons.associateWith { FocusRequester() }
|
||||
}
|
||||
var initialSeasonFocusRequested by remember(item.id) { mutableStateOf(false) }
|
||||
LaunchedEffect(selectedSeason, seasons) {
|
||||
val requester = seasonFocusRequesters[selectedSeason] ?: return@LaunchedEffect
|
||||
if (!initialSeasonFocusRequested) {
|
||||
delay(40)
|
||||
runCatching { requester.requestFocus() }
|
||||
initialSeasonFocusRequested = true
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier
|
||||
.fillMaxSize()
|
||||
.background(Color(0xFF090B0D)),
|
||||
) {
|
||||
BackdropLayer(item, Modifier.fillMaxSize())
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.background(
|
||||
Brush.verticalGradient(
|
||||
0f to Color.Black.copy(alpha = 0.20f),
|
||||
0.42f to Color.Black.copy(alpha = 0.52f),
|
||||
0.72f to Color(0xF5090B0D),
|
||||
1f to Color(0xFF090B0D),
|
||||
),
|
||||
),
|
||||
)
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.background(
|
||||
Brush.horizontalGradient(
|
||||
0f to Color.Black.copy(alpha = 0.72f),
|
||||
0.60f to Color.Black.copy(alpha = 0.18f),
|
||||
1f to Color.Transparent,
|
||||
),
|
||||
),
|
||||
)
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(start = 64.dp, end = 52.dp, top = 42.dp, bottom = 34.dp),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.Top,
|
||||
) {
|
||||
Column(Modifier.fillMaxWidth(0.70f)) {
|
||||
Text(
|
||||
"SERIES",
|
||||
color = SeriesGreen,
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
letterSpacing = 1.4.sp,
|
||||
)
|
||||
Text(
|
||||
item.name,
|
||||
color = Color.White,
|
||||
fontSize = 36.sp,
|
||||
lineHeight = 40.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
val facts = listOfNotNull(
|
||||
item.productionYear?.toString(),
|
||||
seasons.size.takeIf { it > 0 }?.let { count ->
|
||||
"$count ${if (count == 1) "Season" else "Seasons"}"
|
||||
},
|
||||
item.officialRating,
|
||||
item.genres.take(2).joinToString(" · ").takeIf(String::isNotBlank),
|
||||
)
|
||||
if (facts.isNotEmpty()) {
|
||||
Text(
|
||||
facts.joinToString(" • "),
|
||||
color = SeriesMuted,
|
||||
fontSize = 14.sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
item.overview?.takeIf(String::isNotBlank) ?: "No description available.",
|
||||
color = SeriesMuted,
|
||||
fontSize = 15.sp,
|
||||
lineHeight = 20.sp,
|
||||
maxLines = 3,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.padding(top = 8.dp),
|
||||
)
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Button(onClick = { onToggleFavorite(item, !item.isFavorite) }) {
|
||||
Text(if (item.isFavorite) "Remove favourite" else "Add favourite")
|
||||
}
|
||||
Button(onClick = onClose) { Text("Close") }
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(15.dp))
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
SeriesDetailTabs(
|
||||
selected = selectedSection,
|
||||
episodeCount = episodes?.size,
|
||||
castCount = item.cast.size,
|
||||
onSelected = { selectedSectionKey = it.key },
|
||||
)
|
||||
if (selectedSection == SeriesDetailSection.EPISODES && seasons.isNotEmpty()) {
|
||||
Box(
|
||||
Modifier
|
||||
.padding(horizontal = 16.dp)
|
||||
.width(1.dp)
|
||||
.height(30.dp)
|
||||
.background(Color.White.copy(alpha = 0.12f)),
|
||||
)
|
||||
LazyRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(9.dp),
|
||||
modifier = Modifier.weight(1f),
|
||||
) {
|
||||
items(seasons, key = { it }) { season ->
|
||||
SeasonChip(
|
||||
season = season,
|
||||
selected = season == selectedSeason,
|
||||
focusRequester = seasonFocusRequesters.getValue(season),
|
||||
episodeFocusRequester = firstEpisode,
|
||||
onClick = { selectedSeason = season },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
when {
|
||||
selectedSection == SeriesDetailSection.CAST -> {
|
||||
if (item.cast.isEmpty()) {
|
||||
Text(
|
||||
"Cast information is not available for this show.",
|
||||
color = SeriesMuted,
|
||||
fontSize = 15.sp,
|
||||
modifier = Modifier.padding(top = 24.dp),
|
||||
)
|
||||
} else {
|
||||
CastRail(
|
||||
people = item.cast,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f),
|
||||
showTitle = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
episodes == null -> {
|
||||
Text(
|
||||
"Loading seasons…",
|
||||
color = SeriesQuiet,
|
||||
fontSize = 15.sp,
|
||||
modifier = Modifier.padding(top = 24.dp),
|
||||
)
|
||||
}
|
||||
loadFailed -> {
|
||||
Text(
|
||||
"Episodes are temporarily unavailable.",
|
||||
color = SeriesMuted,
|
||||
fontSize = 15.sp,
|
||||
modifier = Modifier.padding(top = 24.dp),
|
||||
)
|
||||
}
|
||||
seasons.isEmpty() -> {
|
||||
Text(
|
||||
"No episodes are available for this show.",
|
||||
color = SeriesMuted,
|
||||
fontSize = 15.sp,
|
||||
modifier = Modifier.padding(top = 24.dp),
|
||||
)
|
||||
}
|
||||
else -> {
|
||||
LazyRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
contentPadding = androidx.compose.foundation.layout.PaddingValues(
|
||||
start = 2.dp,
|
||||
end = 42.dp,
|
||||
top = 7.dp,
|
||||
bottom = 8.dp,
|
||||
),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f),
|
||||
) {
|
||||
items(seasonEpisodes, key = BaseItem::id) { episode ->
|
||||
EpisodeCard(
|
||||
episode = episode,
|
||||
onClick = { onPlay(episode) },
|
||||
seasonFocusRequester = seasonFocusRequesters[selectedSeason]
|
||||
?: FocusRequester.Default,
|
||||
modifier = if (episode.id == seasonEpisodes.first().id) {
|
||||
Modifier
|
||||
.focusRequester(firstEpisode)
|
||||
} else {
|
||||
Modifier
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal enum class SeriesDetailSection(val key: String) {
|
||||
EPISODES("episodes"),
|
||||
CAST("cast"),
|
||||
}
|
||||
|
||||
internal fun seriesDetailSection(key: String): SeriesDetailSection =
|
||||
SeriesDetailSection.entries.firstOrNull { it.key == key } ?: SeriesDetailSection.EPISODES
|
||||
|
||||
@Composable
|
||||
private fun SeriesDetailTabs(
|
||||
selected: SeriesDetailSection,
|
||||
episodeCount: Int?,
|
||||
castCount: Int,
|
||||
onSelected: (SeriesDetailSection) -> Unit,
|
||||
) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
SeriesDetailTab(
|
||||
label = buildString {
|
||||
append("Episodes")
|
||||
episodeCount?.takeIf { it > 0 }?.let { append(" $it") }
|
||||
},
|
||||
selected = selected == SeriesDetailSection.EPISODES,
|
||||
onClick = { onSelected(SeriesDetailSection.EPISODES) },
|
||||
)
|
||||
SeriesDetailTab(
|
||||
label = buildString {
|
||||
append("Cast")
|
||||
castCount.takeIf { it > 0 }?.let { append(" $it") }
|
||||
},
|
||||
selected = selected == SeriesDetailSection.CAST,
|
||||
onClick = { onSelected(SeriesDetailSection.CAST) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SeriesDetailTab(
|
||||
label: String,
|
||||
selected: Boolean,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
var focused by remember { mutableStateOf(false) }
|
||||
val shape = androidx.compose.foundation.shape.RoundedCornerShape(8.dp)
|
||||
val background = when {
|
||||
focused -> Color.White
|
||||
selected -> SeriesGreen.copy(alpha = 0.18f)
|
||||
else -> Color.White.copy(alpha = 0.055f)
|
||||
}
|
||||
val textColor = if (focused) Color.Black else if (selected) Color.White else SeriesQuiet
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier
|
||||
.clip(shape)
|
||||
.background(background)
|
||||
.border(
|
||||
1.dp,
|
||||
when {
|
||||
focused -> Color.White
|
||||
selected -> SeriesGreen.copy(alpha = 0.72f)
|
||||
else -> Color.White.copy(alpha = 0.10f)
|
||||
},
|
||||
shape,
|
||||
)
|
||||
.onFocusChanged { focused = it.isFocused }
|
||||
.clickable(onClick = onClick)
|
||||
.padding(start = 21.dp, top = 10.dp, end = 21.dp, bottom = 8.dp),
|
||||
) {
|
||||
Text(
|
||||
label,
|
||||
color = textColor,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
Box(
|
||||
Modifier
|
||||
.padding(top = 7.dp)
|
||||
.width(34.dp)
|
||||
.height(3.dp)
|
||||
.clip(androidx.compose.foundation.shape.RoundedCornerShape(2.dp))
|
||||
.background(if (selected && !focused) SeriesGreen else Color.Transparent),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SeasonChip(
|
||||
season: Int,
|
||||
selected: Boolean,
|
||||
focusRequester: FocusRequester,
|
||||
episodeFocusRequester: FocusRequester,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
var focused by remember { mutableStateOf(false) }
|
||||
val shape = androidx.compose.foundation.shape.RoundedCornerShape(7.dp)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.clip(shape)
|
||||
.background(
|
||||
when {
|
||||
focused -> Color.White
|
||||
selected -> SeriesGreen
|
||||
else -> Color(0xCC20252A)
|
||||
},
|
||||
)
|
||||
.border(1.dp, Color.White.copy(alpha = if (selected) 0.28f else 0.12f), shape)
|
||||
.focusRequester(focusRequester)
|
||||
.focusProperties { down = episodeFocusRequester }
|
||||
.onFocusChanged { focused = it.isFocused }
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 17.dp, vertical = 9.dp),
|
||||
) {
|
||||
Text(
|
||||
if (season == 0) "Specials" else "Season $season",
|
||||
color = if (focused) Color.Black else Color.White,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EpisodeCard(
|
||||
episode: BaseItem,
|
||||
onClick: () -> Unit,
|
||||
seasonFocusRequester: FocusRequester,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val repository = ServiceLocator.repository
|
||||
var focused by remember { mutableStateOf(false) }
|
||||
val imageUrl = remember(episode.id) {
|
||||
repository.primaryUrl(episode, 360) ?: repository.backdropUrl(episode, 360)
|
||||
}
|
||||
val shape = androidx.compose.foundation.shape.RoundedCornerShape(10.dp)
|
||||
val progress = remember(episode.userData, episode.runTimeTicks) {
|
||||
val runtime = episode.runTimeTicks ?: 0L
|
||||
if (runtime > 0L) {
|
||||
((episode.userData?.playbackPositionTicks ?: 0L).toFloat() / runtime).coerceIn(0f, 1f)
|
||||
} else {
|
||||
0f
|
||||
}
|
||||
}
|
||||
val scale by animateFloatAsState(
|
||||
targetValue = if (focused) 1.045f else 1f,
|
||||
animationSpec = tween(110),
|
||||
label = "episode-card-focus",
|
||||
)
|
||||
Column(
|
||||
modifier = modifier
|
||||
.width(252.dp)
|
||||
.graphicsLayer {
|
||||
scaleX = scale
|
||||
scaleY = scale
|
||||
}
|
||||
.zIndex(if (focused) 1f else 0f)
|
||||
.focusProperties { up = seasonFocusRequester }
|
||||
.onFocusChanged { focused = it.isFocused }
|
||||
.clickable(onClick = onClick)
|
||||
.padding(bottom = 4.dp),
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(16f / 9f)
|
||||
.clip(shape)
|
||||
.background(Color(0xFF20252A)),
|
||||
) {
|
||||
AsyncImage(
|
||||
model = imageUrl,
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.border(
|
||||
width = if (focused) 3.dp else 1.dp,
|
||||
color = if (focused) Color.White else Color.White.copy(alpha = 0.10f),
|
||||
shape = shape,
|
||||
),
|
||||
)
|
||||
if (progress > 0f) {
|
||||
Box(
|
||||
Modifier
|
||||
.align(Alignment.BottomStart)
|
||||
.fillMaxWidth()
|
||||
.height(4.dp)
|
||||
.background(Color.Black.copy(alpha = 0.65f)),
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth(progress)
|
||||
.height(4.dp)
|
||||
.background(SeriesGreen),
|
||||
)
|
||||
}
|
||||
}
|
||||
if (episode.userData?.played == true) {
|
||||
Icon(
|
||||
Icons.Default.CheckCircle,
|
||||
contentDescription = "Watched",
|
||||
tint = SeriesGreen,
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.padding(9.dp)
|
||||
.size(20.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(top = 9.dp),
|
||||
) {
|
||||
Text(
|
||||
text = listOfNotNull(
|
||||
episode.indexNumber?.let { "$it." },
|
||||
episode.name,
|
||||
).joinToString(" "),
|
||||
color = Color.White,
|
||||
fontSize = 15.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
episode.runtimeMinutes?.let {
|
||||
Text(
|
||||
"${it}m",
|
||||
color = SeriesQuiet,
|
||||
fontSize = 12.sp,
|
||||
modifier = Modifier.padding(start = 8.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
Text(
|
||||
episode.overview?.takeIf(String::isNotBlank) ?: "No episode description available.",
|
||||
color = if (focused) SeriesMuted else SeriesQuiet.copy(alpha = 0.72f),
|
||||
fontSize = 12.sp,
|
||||
lineHeight = 16.sp,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.padding(top = 5.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
internal val seriesEpisodeComparator =
|
||||
compareBy<BaseItem>({ it.parentIndexNumber ?: Int.MAX_VALUE }, { it.indexNumber ?: Int.MAX_VALUE }, { it.name })
|
||||
|
||||
internal fun availableSeasons(episodes: List<BaseItem>): List<Int> =
|
||||
episodes.mapNotNull(BaseItem::parentIndexNumber).distinct().sorted()
|
||||
|
||||
internal fun episodesForSeason(episodes: List<BaseItem>, season: Int?): List<BaseItem> =
|
||||
if (season == null) emptyList()
|
||||
else episodes.filter { it.parentIndexNumber == season }.sortedWith(seriesEpisodeComparator)
|
||||
|
||||
internal fun defaultSeason(episodes: List<BaseItem>): Int? =
|
||||
episodes.firstOrNull {
|
||||
it.userData?.played != true || ((it.userData?.playbackPositionTicks ?: 0L) > 0L)
|
||||
}?.parentIndexNumber ?: availableSeasons(episodes).firstOrNull()
|
||||
@@ -0,0 +1,351 @@
|
||||
package com.ponzischeme89.memby.ui
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.core.FastOutSlowInEasing
|
||||
import androidx.compose.animation.core.LinearEasing
|
||||
import androidx.compose.animation.core.RepeatMode
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
import androidx.compose.animation.core.infiniteRepeatable
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.slideInVertically
|
||||
import androidx.compose.animation.slideOutVertically
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.StrokeCap
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.zIndex
|
||||
import androidx.tv.material3.Text
|
||||
import coil.compose.AsyncImage
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.ponzischeme89.memby.ServiceLocator
|
||||
import com.ponzischeme89.memby.data.MaintenanceMonitor
|
||||
import com.ponzischeme89.memby.data.ServiceAlert
|
||||
import kotlin.math.ceil
|
||||
|
||||
private val AlertAccent = Color(0xFF52B54B)
|
||||
private val AlertTitle = Color(0xFFF2F5F7)
|
||||
private val AlertBody = Color(0xFFC3CBD2)
|
||||
|
||||
/** Height of the bar itself, before the rule and fade that blend it into the screen. */
|
||||
private val BannerHeight = 104.dp
|
||||
|
||||
/**
|
||||
* Broadcast-style overscan inset. TVs crop the edges of the picture by a few percent, so
|
||||
* nothing meaningful may sit closer than this to the frame.
|
||||
*/
|
||||
private val SafeAreaHorizontal = 48.dp
|
||||
|
||||
/**
|
||||
* A full-width bar that drops in over the top of the screen to say an episode has aired —
|
||||
* the shape a broadcaster uses for an in-programme notice, rather than a corner toast.
|
||||
*
|
||||
* Deliberately not focusable and not dismissable by D-pad: it must never steal focus from
|
||||
* a row mid-browse, so it times itself out instead of asking the viewer to act. It spans
|
||||
* the navigation rail as well, because for its few seconds it is the top layer of the
|
||||
* screen; it draws above the rows but below the update prompt, which is the one thing
|
||||
* allowed to own the whole display.
|
||||
*/
|
||||
@Composable
|
||||
fun ServiceAlertBanner(suppressed: Boolean, modifier: Modifier = Modifier) {
|
||||
// The alert is collected *here* rather than passed down from the home composable.
|
||||
// Read one scope up and every arriving alert would recompose the entire launcher
|
||||
// body; read here and it recomposes a bar that is usually not even on screen.
|
||||
val current by ServiceLocator.maintenance.alert.collectAsStateWithLifecycle()
|
||||
val alert = current?.takeUnless { suppressed }
|
||||
|
||||
// Tell the monitor the moment this is really on screen. Until it hears that, the
|
||||
// alert is only offered — it starts no dismissal timer and persists nothing — so an
|
||||
// alert arriving behind the screensaver or another app is not silently used up.
|
||||
LaunchedEffect(alert?.id) {
|
||||
alert?.id?.let { ServiceLocator.maintenance.alertShown(it) }
|
||||
}
|
||||
|
||||
// The exit animation still needs something to draw, so hold the last alert until the
|
||||
// slide-out has finished with it.
|
||||
var lastAlert by remember { mutableStateOf<ServiceAlert?>(null) }
|
||||
if (alert != null) lastAlert = alert
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = alert != null,
|
||||
// In from above the frame, out the same way. Slower arriving than leaving:
|
||||
// showing up should be noticed, going away should not.
|
||||
enter = slideInVertically(tween(420, easing = FastOutSlowInEasing)) { -it } +
|
||||
fadeIn(tween(260)),
|
||||
exit = slideOutVertically(tween(320, easing = FastOutSlowInEasing)) { -it } +
|
||||
fadeOut(tween(220)),
|
||||
modifier = modifier.zIndex(8f),
|
||||
) {
|
||||
lastAlert?.let { AlertBanner(it) }
|
||||
}
|
||||
}
|
||||
|
||||
// Internal so the screenshot test can render the bar on its own, without the drop-in
|
||||
// wrapper around it.
|
||||
@Composable
|
||||
internal fun AlertBanner(
|
||||
alert: ServiceAlert,
|
||||
visibleMillis: Long = MaintenanceMonitor.ALERT_VISIBLE_MS,
|
||||
) {
|
||||
// Keyed on the alert id so a second banner arriving restarts the countdown rather
|
||||
// than inheriting whatever was left of the first one's.
|
||||
val remaining = remember(alert.id) { Animatable(1f) }
|
||||
LaunchedEffect(alert.id) {
|
||||
remaining.animateTo(0f, tween(visibleMillis.toInt(), easing = LinearEasing))
|
||||
}
|
||||
// The ring's fraction is handed down as a lambda and the seconds as derived state, so
|
||||
// ten seconds of animation costs ~10 recompositions of one number instead of ~600 of
|
||||
// this whole bar. On a weak TV box that difference is the feature's entire cost.
|
||||
val secondsLeft = remember(alert.id, visibleMillis) {
|
||||
derivedStateOf { ceil(remaining.value * visibleMillis / 1000f).toInt() }
|
||||
}
|
||||
|
||||
Column(Modifier.fillMaxWidth()) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(BannerHeight)
|
||||
.background(
|
||||
// Darkest at the left where the text sits, easing off to the right so
|
||||
// the artwork behind the banner still shows through.
|
||||
Brush.horizontalGradient(
|
||||
0f to Color(0xFF0E1418),
|
||||
0.55f to Color(0xF20E1418),
|
||||
1f to Color(0xD9121A20),
|
||||
),
|
||||
)
|
||||
.padding(horizontal = SafeAreaHorizontal),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
AlertPoster(posterUrl = alert.posterUrl)
|
||||
Spacer(Modifier.width(20.dp))
|
||||
Column(Modifier.weight(1f)) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
LivePip()
|
||||
Text(
|
||||
"JUST AIRED",
|
||||
color = AlertAccent,
|
||||
fontSize = 13.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
letterSpacing = 1.6.sp,
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(3.dp))
|
||||
// Keep the alert readable at TV distance without letting it overpower
|
||||
// the screen content beneath it.
|
||||
Text(
|
||||
alert.title,
|
||||
color = AlertTitle,
|
||||
fontSize = 20.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
alert.message,
|
||||
color = AlertBody,
|
||||
fontSize = 15.sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.width(24.dp))
|
||||
CountdownRing(fraction = { remaining.value }, secondsLeft = secondsLeft)
|
||||
}
|
||||
|
||||
// An accent rule under the bar, then a short fade: without them the banner ends
|
||||
// on a hard line across the artwork, which reads as a rendering seam.
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(2.dp)
|
||||
.background(
|
||||
Brush.horizontalGradient(
|
||||
listOf(AlertAccent, AlertAccent.copy(alpha = 0.35f), Color.Transparent),
|
||||
),
|
||||
),
|
||||
)
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(22.dp)
|
||||
.background(
|
||||
Brush.verticalGradient(listOf(Color(0x99000000), Color.Transparent)),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A ring that empties as the banner's time runs out, with the seconds left inside it.
|
||||
*
|
||||
* This exists to answer "is it about to go, or did it stick?" — the banner cannot be
|
||||
* dismissed by remote, so the one thing the viewer can usefully know is how long they
|
||||
* have to read it. Drawn in a single Canvas: an arc and a track are cheaper than any
|
||||
* progress component, and this animates every frame for ten seconds.
|
||||
*
|
||||
* [fraction] is a lambda and [secondsLeft] a [State] on purpose. Read either one in a
|
||||
* composable body and every frame recomposes; read them inside the draw lambda and in a
|
||||
* derived state, and the sweep costs a redraw only.
|
||||
*/
|
||||
@Composable
|
||||
private fun CountdownRing(fraction: () -> Float, secondsLeft: State<Int>) {
|
||||
Box(Modifier.size(46.dp), contentAlignment = Alignment.Center) {
|
||||
Canvas(Modifier.fillMaxSize()) {
|
||||
val stroke = 3.dp.toPx()
|
||||
val inset = stroke / 2f
|
||||
val arcSize = Size(size.width - stroke, size.height - stroke)
|
||||
drawArc(
|
||||
color = Color.White.copy(alpha = 0.12f),
|
||||
startAngle = 0f,
|
||||
sweepAngle = 360f,
|
||||
useCenter = false,
|
||||
topLeft = Offset(inset, inset),
|
||||
size = arcSize,
|
||||
style = Stroke(width = stroke, cap = StrokeCap.Round),
|
||||
)
|
||||
drawArc(
|
||||
color = AlertAccent,
|
||||
// Twelve o'clock, unwinding clockwise: the direction a clock hand sweeps,
|
||||
// so "less arc left" reads as "less time left" without a legend.
|
||||
startAngle = -90f,
|
||||
sweepAngle = 360f * fraction().coerceIn(0f, 1f),
|
||||
useCenter = false,
|
||||
topLeft = Offset(inset, inset),
|
||||
size = arcSize,
|
||||
style = Stroke(width = stroke, cap = StrokeCap.Round),
|
||||
)
|
||||
}
|
||||
Text(
|
||||
secondsLeft.value.coerceAtLeast(0).toString(),
|
||||
color = AlertBody,
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Poster when the server named one; a quiet accent tile when it did not. */
|
||||
@Composable
|
||||
private fun AlertPoster(posterUrl: String?) {
|
||||
val shape = RoundedCornerShape(6.dp)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(50.dp)
|
||||
.height(74.dp)
|
||||
.clip(shape)
|
||||
.background(Color(0xFF18222B)),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
if (posterUrl != null) {
|
||||
AsyncImage(
|
||||
model = posterUrl,
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
} else {
|
||||
Box(
|
||||
Modifier
|
||||
.size(20.dp)
|
||||
.clip(CircleShape)
|
||||
.background(AlertAccent.copy(alpha = 0.30f)),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A slow pulse — enough motion to read as "news", cheap enough for a TV GPU. */
|
||||
@Composable
|
||||
private fun LivePip() {
|
||||
val alpha = androidx.compose.animation.core.rememberInfiniteTransition(label = "alert-pip")
|
||||
.animateFloat(
|
||||
initialValue = 0.35f,
|
||||
targetValue = 1f,
|
||||
animationSpec = infiniteRepeatable(
|
||||
tween(1_400, easing = LinearEasing),
|
||||
RepeatMode.Reverse,
|
||||
),
|
||||
label = "alert-pip-alpha",
|
||||
)
|
||||
// Deliberately not `by`: the alpha is read inside the draw lambda, so the pulse
|
||||
// repaints eight dp of circle rather than recomposing the row that holds it.
|
||||
Canvas(Modifier.size(8.dp)) {
|
||||
drawCircle(color = AlertAccent.copy(alpha = alpha.value))
|
||||
}
|
||||
}
|
||||
|
||||
// Previews render the bar directly rather than through ServiceAlertBanner: the wrapper's
|
||||
// whole job is the drop-in, and a still frame of an animation in progress says nothing.
|
||||
// Posters are left null on purpose — the preview has no network, so this is also the
|
||||
// fallback tile being checked.
|
||||
|
||||
@TvPreview
|
||||
@Composable
|
||||
private fun ServiceAlertBannerPreview() {
|
||||
PreviewSurface(alignment = Alignment.TopCenter) {
|
||||
AlertBanner(
|
||||
ServiceAlert(
|
||||
id = "sonarr:7:42:aired",
|
||||
title = "Northbound",
|
||||
message = "S02E04 — The Crossing aired at 9:00 PM and will be in Emby soon.",
|
||||
posterUrl = null,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Long strings are common; the single-line ellipsis is the part worth eyeballing. */
|
||||
@TvPreview
|
||||
@Composable
|
||||
private fun ServiceAlertBannerLongTitlePreview() {
|
||||
PreviewSurface(alignment = Alignment.TopCenter) {
|
||||
AlertBanner(
|
||||
ServiceAlert(
|
||||
id = "sonarr:9:88:aired",
|
||||
title = "A Very Long Programme Title That Will Not Fit On One Line At All",
|
||||
message = "S11E03 — The One Where Absolutely Everything Happens At Once " +
|
||||
"aired at 10:30 PM and is downloading now.",
|
||||
posterUrl = null,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
package com.ponzischeme89.memby.ui.detail
|
||||
|
||||
import com.ponzischeme89.memby.data.model.BaseItem
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* Everything the detail pages need to *say*, as pure functions.
|
||||
*
|
||||
* None of this touches Compose, the repository or Android, so the whole vocabulary of the
|
||||
* two screens — which season opens first, what the primary button is called, which
|
||||
* technical facts are worth a line — is unit-testable without a device. The composables in
|
||||
* this package are deliberately thin over these.
|
||||
*/
|
||||
|
||||
/** Emby stores durations and positions as 100-ns ticks. */
|
||||
private const val TICKS_PER_MINUTE = 600_000_000L
|
||||
|
||||
/** "2h 14m", "47m". Never "0m" — callers pass a positive runtime or nothing. */
|
||||
fun formatRuntime(minutes: Int): String {
|
||||
val hours = minutes / 60
|
||||
val remainder = minutes % 60
|
||||
return when {
|
||||
hours > 0 && remainder > 0 -> "${hours}h ${remainder}m"
|
||||
hours > 0 -> "${hours}h"
|
||||
else -> "${remainder}m"
|
||||
}
|
||||
}
|
||||
|
||||
/** Position on the playhead, in the same wording as a runtime. */
|
||||
fun formatPosition(ticks: Long): String =
|
||||
formatRuntime((ticks / TICKS_PER_MINUTE).toInt().coerceAtLeast(0))
|
||||
|
||||
val BaseItem.resumeTicks: Long get() = userData?.playbackPositionTicks ?: 0L
|
||||
|
||||
val BaseItem.isPlayed: Boolean get() = userData?.played == true
|
||||
|
||||
/** True when there is a meaningful position to resume from. */
|
||||
val BaseItem.isResumable: Boolean get() = resumeTicks > 0L
|
||||
|
||||
/** 0f..1f through the item, or 0f when either end is unknown. */
|
||||
fun playbackProgress(item: BaseItem): Float {
|
||||
val runtime = item.runTimeTicks ?: 0L
|
||||
if (runtime <= 0L) return 0f
|
||||
return (item.resumeTicks.toFloat() / runtime).coerceIn(0f, 1f)
|
||||
}
|
||||
|
||||
/** "1h 12m left", or null when the remainder cannot be worked out. */
|
||||
fun remainingLabel(item: BaseItem): String? {
|
||||
val runtime = item.runTimeTicks ?: return null
|
||||
val left = runtime - item.resumeTicks
|
||||
if (item.resumeTicks <= 0L || left <= TICKS_PER_MINUTE) return null
|
||||
return "${formatPosition(left)} left"
|
||||
}
|
||||
|
||||
/** The headline row under a movie title: year, runtime, certificate, score, genres. */
|
||||
fun movieFacts(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.communityRating?.let { add("★ ${String.format(Locale.US, "%.1f", it)}") }
|
||||
item.genres.take(3).joinToString(" · ").takeIf(String::isNotBlank)?.let(::add)
|
||||
}
|
||||
|
||||
/** The same row for a series, where seasons replace runtime. */
|
||||
fun seriesFacts(item: BaseItem, seasonCount: Int): List<String> = buildList {
|
||||
item.productionYear?.let { add(it.toString()) }
|
||||
if (seasonCount > 0) add("$seasonCount ${if (seasonCount == 1) "Season" else "Seasons"}")
|
||||
item.officialRating?.takeIf(String::isNotBlank)?.let(::add)
|
||||
item.communityRating?.let { add("★ ${String.format(Locale.US, "%.1f", it)}") }
|
||||
item.genres.take(3).joinToString(" · ").takeIf(String::isNotBlank)?.let(::add)
|
||||
}
|
||||
|
||||
/** A label/value pair for the quiet technical area. */
|
||||
data class TechnicalSpec(val label: String, val value: String)
|
||||
|
||||
/**
|
||||
* Resolution, codecs and studio — the things a viewer checks before settling in, kept out
|
||||
* of the headline because none of them decide what to watch.
|
||||
*/
|
||||
fun technicalSpecs(item: BaseItem): List<TechnicalSpec> {
|
||||
val video = item.mediaStreams.firstOrNull { it.type.equals("Video", ignoreCase = true) }
|
||||
val audio = item.mediaStreams.firstOrNull { it.type.equals("Audio", ignoreCase = true) }
|
||||
val subtitles = item.mediaStreams.count { it.type.equals("Subtitle", ignoreCase = true) }
|
||||
return buildList {
|
||||
video?.let { stream ->
|
||||
val width = stream.width
|
||||
val height = stream.height
|
||||
if (width != null && height != null) {
|
||||
add(TechnicalSpec("Video", "$width × $height${resolutionSuffix(width)}"))
|
||||
}
|
||||
listOfNotNull(
|
||||
stream.codec?.uppercase(Locale.US),
|
||||
dynamicRangeLabel(stream.videoRange, stream.videoRangeType, stream.title),
|
||||
).takeIf(List<String>::isNotEmpty)?.let {
|
||||
add(TechnicalSpec("Codec", it.joinToString(" · ")))
|
||||
}
|
||||
}
|
||||
audio?.let { stream ->
|
||||
listOfNotNull(
|
||||
stream.codec?.uppercase(Locale.US),
|
||||
stream.channels?.let(::channelLabel),
|
||||
stream.language?.takeIf(String::isNotBlank),
|
||||
).takeIf(List<String>::isNotEmpty)?.let {
|
||||
add(TechnicalSpec("Audio", it.joinToString(" · ")))
|
||||
}
|
||||
}
|
||||
if (subtitles > 0) {
|
||||
add(TechnicalSpec("Subtitles", "$subtitles ${if (subtitles == 1) "track" else "tracks"}"))
|
||||
}
|
||||
item.studios.map { it.name }.filter(String::isNotBlank).take(2)
|
||||
.takeIf(List<String>::isNotEmpty)
|
||||
?.let { add(TechnicalSpec("Studio", it.joinToString(", "))) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun resolutionSuffix(width: Int): String = when {
|
||||
width >= 3_400 -> " (4K)"
|
||||
width >= 2_500 -> " (1440p)"
|
||||
width >= 1_800 -> " (1080p)"
|
||||
width >= 1_200 -> " (720p)"
|
||||
else -> ""
|
||||
}
|
||||
|
||||
private fun dynamicRangeLabel(range: String?, rangeType: String?, title: String?): String? {
|
||||
val haystack = listOfNotNull(range, rangeType, title).joinToString(" ").lowercase(Locale.US)
|
||||
return when {
|
||||
"dolby vision" in haystack || "dovi" in haystack -> "Dolby Vision"
|
||||
"hdr10+" in haystack -> "HDR10+"
|
||||
"hdr" in haystack -> "HDR"
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
private fun channelLabel(channels: Int): String = when (channels) {
|
||||
1 -> "Mono"
|
||||
2 -> "Stereo"
|
||||
6 -> "5.1"
|
||||
8 -> "7.1"
|
||||
else -> "${channels}ch"
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Series structure
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Running order: season, then episode, then title for anything unnumbered. */
|
||||
val seriesEpisodeComparator = compareBy<BaseItem>(
|
||||
{ it.parentIndexNumber ?: Int.MAX_VALUE },
|
||||
{ it.indexNumber ?: Int.MAX_VALUE },
|
||||
{ it.name },
|
||||
)
|
||||
|
||||
/** Season numbers present in the library, specials (0) first. */
|
||||
fun availableSeasons(episodes: List<BaseItem>): List<Int> =
|
||||
episodes.mapNotNull(BaseItem::parentIndexNumber).distinct().sorted()
|
||||
|
||||
fun episodesForSeason(episodes: List<BaseItem>, season: Int?): List<BaseItem> =
|
||||
if (season == null) emptyList()
|
||||
else episodes.filter { it.parentIndexNumber == season }.sortedWith(seriesEpisodeComparator)
|
||||
|
||||
/**
|
||||
* What the viewer should watch next: whatever is part-watched, else the first unwatched
|
||||
* episode. Specials are skipped while any numbered season exists — season 0 sorts first
|
||||
* but is almost never where someone is up to.
|
||||
*/
|
||||
fun nextEpisodeToWatch(episodes: List<BaseItem>): BaseItem? {
|
||||
if (episodes.isEmpty()) return null
|
||||
val ordered = episodes.sortedWith(seriesEpisodeComparator)
|
||||
val hasNumberedSeason = ordered.any { (it.parentIndexNumber ?: 0) > 0 }
|
||||
val candidates = if (hasNumberedSeason) {
|
||||
ordered.filter { (it.parentIndexNumber ?: 0) > 0 }
|
||||
} else {
|
||||
ordered
|
||||
}
|
||||
return candidates.firstOrNull { it.isResumable && !it.isPlayed }
|
||||
?: candidates.firstOrNull { !it.isPlayed }
|
||||
}
|
||||
|
||||
/** The season to open on: the one holding [nextEpisodeToWatch], else the earliest. */
|
||||
fun defaultSeason(episodes: List<BaseItem>): Int? =
|
||||
nextEpisodeToWatch(episodes)?.parentIndexNumber ?: availableSeasons(episodes).firstOrNull()
|
||||
|
||||
/** How many episodes are still unwatched, for the series badge. */
|
||||
fun unwatchedCount(episodes: List<BaseItem>): Int = episodes.count { !it.isPlayed }
|
||||
|
||||
/** "Season 3", or "Specials" for season 0. */
|
||||
fun seasonLabel(season: Int): String = if (season == 0) "Specials" else "Season $season"
|
||||
|
||||
/** "S2 E4" — shorter than [BaseItem.episodeCode] and used where space is tight. */
|
||||
fun episodeLabel(episode: BaseItem): String? {
|
||||
val number = episode.indexNumber ?: return null
|
||||
val season = episode.parentIndexNumber
|
||||
return if (season != null) "S$season E$number" else "E$number"
|
||||
}
|
||||
|
||||
/** "S2 E4 · The Crossing", falling back to whichever half is known. */
|
||||
fun episodeHeadline(episode: BaseItem): String =
|
||||
listOfNotNull(episodeLabel(episode), episode.name.takeIf(String::isNotBlank))
|
||||
.joinToString(" · ")
|
||||
|
||||
/**
|
||||
* The primary button. Series pass their next episode so the button can name it; a movie
|
||||
* passes itself.
|
||||
*/
|
||||
fun primaryActionLabel(item: BaseItem, nextEpisode: BaseItem? = null): String = when {
|
||||
nextEpisode != null && nextEpisode.isResumable -> "Resume ${episodeLabel(nextEpisode) ?: "episode"}"
|
||||
nextEpisode != null -> "Play ${episodeLabel(nextEpisode) ?: "next episode"}"
|
||||
item.isResumable -> "Resume"
|
||||
else -> "Play"
|
||||
}
|
||||
|
||||
/** The supporting line under the primary button, or null when there is nothing to add. */
|
||||
fun primaryActionDetail(item: BaseItem, nextEpisode: BaseItem? = null): String? {
|
||||
val target = nextEpisode ?: item
|
||||
return when {
|
||||
target.isResumable -> "From ${formatPosition(target.resumeTicks)}" +
|
||||
(remainingLabel(target)?.let { " · $it" } ?: "")
|
||||
nextEpisode != null -> nextEpisode.name.takeIf(String::isNotBlank)
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
/** Kicker above the title: what kind of thing this page is about. */
|
||||
fun detailKicker(item: BaseItem): String = when {
|
||||
item.isMovie -> "MOVIE"
|
||||
item.isSeries -> "SERIES"
|
||||
item.isEpisode -> item.seriesName?.uppercase(Locale.US) ?: "EPISODE"
|
||||
else -> item.type.uppercase(Locale.US).ifBlank { "LIBRARY" }
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package com.ponzischeme89.memby.ui.player
|
||||
|
||||
import androidx.media3.common.PlaybackException
|
||||
|
||||
/**
|
||||
* A viewer-facing description of a Media3 failure. Keep this independent from Activity
|
||||
* state so the retry decision remains deterministic and unit-testable.
|
||||
*/
|
||||
internal data class PlaybackFailure(
|
||||
val title: String,
|
||||
val detail: String,
|
||||
val canAutoRetry: Boolean,
|
||||
)
|
||||
|
||||
internal fun describePlaybackFailure(errorCode: Int): PlaybackFailure =
|
||||
when (errorCode) {
|
||||
PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED,
|
||||
PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_TIMEOUT,
|
||||
PlaybackException.ERROR_CODE_IO_UNSPECIFIED,
|
||||
-> PlaybackFailure(
|
||||
title = "Connection interrupted",
|
||||
detail = "Memby couldn’t keep a reliable connection to the media server.",
|
||||
canAutoRetry = true,
|
||||
)
|
||||
|
||||
PlaybackException.ERROR_CODE_IO_BAD_HTTP_STATUS,
|
||||
PlaybackException.ERROR_CODE_IO_FILE_NOT_FOUND,
|
||||
-> PlaybackFailure(
|
||||
title = "Video unavailable",
|
||||
detail = "The media server couldn’t provide this video. It may have moved or be temporarily unavailable.",
|
||||
canAutoRetry = false,
|
||||
)
|
||||
|
||||
PlaybackException.ERROR_CODE_IO_CLEARTEXT_NOT_PERMITTED,
|
||||
PlaybackException.ERROR_CODE_IO_NO_PERMISSION,
|
||||
-> PlaybackFailure(
|
||||
title = "Playback blocked",
|
||||
detail = "The TV is not permitted to open this stream. Check the server address and access settings.",
|
||||
canAutoRetry = false,
|
||||
)
|
||||
|
||||
PlaybackException.ERROR_CODE_DECODER_INIT_FAILED,
|
||||
PlaybackException.ERROR_CODE_DECODING_FAILED,
|
||||
PlaybackException.ERROR_CODE_DECODING_FORMAT_UNSUPPORTED,
|
||||
PlaybackException.ERROR_CODE_DECODING_FORMAT_EXCEEDS_CAPABILITIES,
|
||||
-> PlaybackFailure(
|
||||
title = "Video format not supported",
|
||||
detail = "This TV couldn’t decode the selected video or audio track. Try another track or a transcoded version.",
|
||||
canAutoRetry = false,
|
||||
)
|
||||
|
||||
PlaybackException.ERROR_CODE_PARSING_CONTAINER_MALFORMED,
|
||||
PlaybackException.ERROR_CODE_PARSING_CONTAINER_UNSUPPORTED,
|
||||
PlaybackException.ERROR_CODE_PARSING_MANIFEST_MALFORMED,
|
||||
PlaybackException.ERROR_CODE_PARSING_MANIFEST_UNSUPPORTED,
|
||||
-> PlaybackFailure(
|
||||
title = "Video file couldn’t be read",
|
||||
detail = "The stream format is damaged or unsupported by this TV.",
|
||||
canAutoRetry = false,
|
||||
)
|
||||
|
||||
else -> PlaybackFailure(
|
||||
title = "Playback stopped",
|
||||
detail = "Memby hit an unexpected playback problem.",
|
||||
canAutoRetry = false,
|
||||
)
|
||||
}
|
||||
|
||||
internal fun automaticRetryDelayMs(attempt: Int): Long? =
|
||||
when (attempt) {
|
||||
1 -> 1_000L
|
||||
2 -> 3_000L
|
||||
else -> null
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package com.ponzischeme89.memby.ui.player
|
||||
|
||||
import android.content.Context
|
||||
import androidx.work.CoroutineWorker
|
||||
import androidx.work.Data
|
||||
import androidx.work.ExistingWorkPolicy
|
||||
import androidx.work.OneTimeWorkRequestBuilder
|
||||
import androidx.work.WorkManager
|
||||
import androidx.work.WorkerParameters
|
||||
import com.ponzischeme89.memby.ServiceLocator
|
||||
import com.ponzischeme89.memby.data.PlaybackSession
|
||||
|
||||
/**
|
||||
* A persisted final check-in. WorkManager keeps this request across process death, so
|
||||
* Back, task removal and background process eviction all converge on the same Emby stop.
|
||||
*/
|
||||
class PlaybackStopWorker(
|
||||
appContext: Context,
|
||||
params: WorkerParameters,
|
||||
) : CoroutineWorker(appContext, params) {
|
||||
override suspend fun doWork(): Result {
|
||||
ServiceLocator.init(applicationContext)
|
||||
val itemId = inputData.getString(ITEM_ID).orEmpty()
|
||||
if (itemId.isBlank()) return Result.failure()
|
||||
val session = PlaybackSession(
|
||||
itemId = itemId,
|
||||
mediaSourceId = inputData.getString(MEDIA_SOURCE_ID).orEmpty().ifBlank { itemId },
|
||||
playSessionId = inputData.getString(PLAY_SESSION_ID).orEmpty(),
|
||||
playMethod = inputData.getString(PLAY_METHOD).orEmpty().ifBlank { "DirectPlay" },
|
||||
)
|
||||
return runCatching {
|
||||
ServiceLocator.repository.reportPlaybackStopped(
|
||||
session,
|
||||
inputData.getLong(POSITION_MS, 0L),
|
||||
)
|
||||
}.fold(
|
||||
onSuccess = { Result.success() },
|
||||
onFailure = { if (runAttemptCount < MAX_RETRIES) Result.retry() else Result.failure() },
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val ITEM_ID = "item_id"
|
||||
private const val MEDIA_SOURCE_ID = "media_source_id"
|
||||
private const val PLAY_SESSION_ID = "play_session_id"
|
||||
private const val PLAY_METHOD = "play_method"
|
||||
private const val POSITION_MS = "position_ms"
|
||||
private const val MAX_RETRIES = 5
|
||||
|
||||
fun enqueue(context: Context, session: PlaybackSession, positionMs: Long) {
|
||||
val data = Data.Builder()
|
||||
.putString(ITEM_ID, session.itemId)
|
||||
.putString(MEDIA_SOURCE_ID, session.mediaSourceId)
|
||||
.putString(PLAY_SESSION_ID, session.playSessionId)
|
||||
.putString(PLAY_METHOD, session.playMethod)
|
||||
.putLong(POSITION_MS, positionMs.coerceAtLeast(0L))
|
||||
.build()
|
||||
val request = OneTimeWorkRequestBuilder<PlaybackStopWorker>()
|
||||
.setInputData(data)
|
||||
.build()
|
||||
val key = session.playSessionId.ifBlank { session.itemId }
|
||||
WorkManager.getInstance(context.applicationContext).enqueueUniqueWork(
|
||||
"emby-playback-stop-$key",
|
||||
ExistingWorkPolicy.REPLACE,
|
||||
request,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,905 @@
|
||||
@file:OptIn(androidx.compose.ui.ExperimentalComposeUiApi::class)
|
||||
|
||||
package com.ponzischeme89.memby.ui.search
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Intent
|
||||
import android.speech.RecognizerIntent
|
||||
import android.speech.SpeechRecognizer
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.animation.core.RepeatMode
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
import androidx.compose.animation.core.infiniteRepeatable
|
||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.itemsIndexed as rowItemsIndexed
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.Backspace
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.AutoAwesome
|
||||
import androidx.compose.material.icons.filled.LiveTv
|
||||
import androidx.compose.material.icons.filled.Movie
|
||||
import androidx.compose.material.icons.filled.PlayCircleFilled
|
||||
import androidx.compose.material.icons.filled.SentimentVerySatisfied
|
||||
import androidx.compose.material.icons.filled.TheaterComedy
|
||||
import androidx.compose.material.icons.filled.Mic
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material.icons.filled.SpaceBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.drawBehind
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusProperties
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.input.key.KeyEventType
|
||||
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
||||
import androidx.compose.ui.input.key.type
|
||||
import androidx.compose.ui.input.key.utf16CodePoint
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.tv.material3.Icon
|
||||
import androidx.tv.material3.Text
|
||||
import coil.imageLoader
|
||||
import coil.request.ImageRequest
|
||||
import com.ponzischeme89.memby.ServiceLocator
|
||||
import com.ponzischeme89.memby.data.model.BaseItem
|
||||
import com.ponzischeme89.memby.ui.FocusScaleContainer
|
||||
import com.ponzischeme89.memby.ui.PosterGridCard
|
||||
|
||||
private val PaneBackground = Color(0xFF0C1014)
|
||||
private val KeyIdle = Color(0xFF1A2129)
|
||||
private val KeyFocused = Color(0xFF52B54B)
|
||||
private val KeyLabel = Color(0xFFE8EDF1)
|
||||
private val KeyLabelFocused = Color(0xFF06240A)
|
||||
private val Heading = Color(0xFFF2F5F7)
|
||||
private val Muted = Color(0xFFB7C0C8)
|
||||
private val Accent = Color(0xFF52B54B)
|
||||
|
||||
/**
|
||||
* Six columns of six, then a row of actions. Rectangular on purpose: every key has a
|
||||
* neighbour directly above and below it, so D-pad movement is predictable in a way a
|
||||
* QWERTY layout with ragged rows never is.
|
||||
*/
|
||||
private val KeyboardRows = listOf(
|
||||
"ABCDEF",
|
||||
"GHIJKL",
|
||||
"MNOPQR",
|
||||
"STUVWX",
|
||||
"YZ0123",
|
||||
"456789",
|
||||
)
|
||||
|
||||
/** Left pane share of the width. Wide enough for six comfortable keys at TV distance. */
|
||||
private const val KEYBOARD_PANE_FRACTION = 0.35f
|
||||
|
||||
/** Posters prefetched as soon as results land, so the first visible row is never blank. */
|
||||
private const val PREFETCHED_POSTERS = 8
|
||||
|
||||
/**
|
||||
* Full-screen search: keyboard on the left, results on the right, updating as you type.
|
||||
*
|
||||
* The two panes are the whole point — the results never disappear behind a keyboard, and
|
||||
* no query is ever "submitted". Everything below is in service of that: focus that moves
|
||||
* predictably between the panes, and a result pane that updates without flashing.
|
||||
*/
|
||||
@Composable
|
||||
fun SearchScreen(
|
||||
navigationFocusRequester: FocusRequester,
|
||||
contentFocusRequester: FocusRequester,
|
||||
discoveryItems: List<BaseItem>,
|
||||
returnFocusItemId: String?,
|
||||
returnFocusRequester: FocusRequester,
|
||||
initialQuery: String? = null,
|
||||
onInitialQueryConsumed: () -> Unit = {},
|
||||
onItemFocused: (BaseItem) -> Unit,
|
||||
onItemSelected: (BaseItem) -> Unit,
|
||||
onContentFocused: () -> Unit,
|
||||
onExit: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val repository = ServiceLocator.repository
|
||||
val viewModel: SearchViewModel = viewModel(
|
||||
factory = remember(repository) { SearchViewModelFactory(repository) },
|
||||
)
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
|
||||
LaunchedEffect(discoveryItems) { viewModel.setDiscoveryItems(discoveryItems) }
|
||||
LaunchedEffect(initialQuery) {
|
||||
initialQuery?.takeIf { it.isNotBlank() }?.let {
|
||||
viewModel.onQueryChanged(it)
|
||||
onInitialQueryConsumed()
|
||||
}
|
||||
}
|
||||
|
||||
val resultsEntry = remember { FocusRequester() }
|
||||
// The rail and the screen agree on one entry target: Search opens on the keyboard,
|
||||
// just as browse destinations open on their primary content action.
|
||||
val keyboardEntry = contentFocusRequester
|
||||
// Where "left out of the results" lands. It follows the last key used, so coming back
|
||||
// returns the viewer to where they were typing rather than to a fixed corner.
|
||||
val keyboardReturn = remember { FocusRequester() }
|
||||
var lastKeyIndex by remember { mutableIntStateOf(0) }
|
||||
var focusInResults by remember { mutableStateOf(false) }
|
||||
val hasResultsTarget = when {
|
||||
state.errorMessage != null && state.results.isEmpty() -> true
|
||||
state.isDiscovery -> discoveryItems.isNotEmpty() ||
|
||||
state.suggestions.any { it.kind == SearchSuggestion.Kind.GENRE }
|
||||
else -> state.results.isNotEmpty()
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) { runCatching { keyboardEntry.requestFocus() } }
|
||||
|
||||
BackHandler {
|
||||
when {
|
||||
// Results → keyboard → clear → leave. Each press does one obvious thing, and
|
||||
// none of them can loop back to the previous state.
|
||||
focusInResults -> runCatching { keyboardReturn.requestFocus() }
|
||||
.onFailure { runCatching { keyboardEntry.requestFocus() } }
|
||||
state.query.isNotEmpty() -> viewModel.clearQuery()
|
||||
else -> onExit()
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
// A USB keyboard, or a phone remote app sending key events, feeds exactly the
|
||||
// same query state as the on-screen keys. Only printable characters and
|
||||
// backspace are consumed; D-pad and Back must fall through untouched.
|
||||
.onPreviewKeyEvent { event ->
|
||||
if (event.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false
|
||||
val code = event.utf16CodePoint
|
||||
when {
|
||||
code == BACKSPACE_CODE -> {
|
||||
viewModel.backspace(); true
|
||||
}
|
||||
code >= FIRST_PRINTABLE_CODE -> {
|
||||
viewModel.appendToQuery(code.toChar().toString()); true
|
||||
}
|
||||
else -> false
|
||||
}
|
||||
},
|
||||
) {
|
||||
SearchPane(
|
||||
state = state,
|
||||
navigationFocusRequester = navigationFocusRequester,
|
||||
resultsEntry = resultsEntry,
|
||||
keyboardEntry = keyboardEntry,
|
||||
keyboardReturn = keyboardReturn,
|
||||
lastKeyIndex = lastKeyIndex,
|
||||
hasResultsTarget = hasResultsTarget,
|
||||
onKeyFocused = { index ->
|
||||
lastKeyIndex = index
|
||||
focusInResults = false
|
||||
onContentFocused()
|
||||
},
|
||||
onCharacter = viewModel::appendToQuery,
|
||||
onBackspace = viewModel::backspace,
|
||||
onClear = viewModel::clearQuery,
|
||||
onVoiceResult = viewModel::onQueryChanged,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(KEYBOARD_PANE_FRACTION)
|
||||
.fillMaxHeight(),
|
||||
)
|
||||
ResultsPane(
|
||||
state = state,
|
||||
resultsEntry = resultsEntry,
|
||||
keyboardReturn = keyboardReturn,
|
||||
returnFocusItemId = returnFocusItemId,
|
||||
returnFocusRequester = returnFocusRequester,
|
||||
discoveryItems = discoveryItems,
|
||||
onItemFocused = { item ->
|
||||
focusInResults = true
|
||||
onContentFocused()
|
||||
onItemFocused(item)
|
||||
},
|
||||
onItemSelected = onItemSelected,
|
||||
onRetry = viewModel::retry,
|
||||
onSuggestionSelected = viewModel::onQueryChanged,
|
||||
modifier = Modifier.fillMaxHeight(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SearchPane(
|
||||
state: SearchUiState,
|
||||
navigationFocusRequester: FocusRequester,
|
||||
resultsEntry: FocusRequester,
|
||||
keyboardEntry: FocusRequester,
|
||||
keyboardReturn: FocusRequester,
|
||||
lastKeyIndex: Int,
|
||||
hasResultsTarget: Boolean,
|
||||
onKeyFocused: (Int) -> Unit,
|
||||
onCharacter: (String) -> Unit,
|
||||
onBackspace: () -> Unit,
|
||||
onClear: () -> Unit,
|
||||
onVoiceResult: (String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.background(PaneBackground)
|
||||
.padding(start = 28.dp, end = 20.dp, top = 24.dp, bottom = 16.dp),
|
||||
) {
|
||||
Text("Search", color = Heading, fontSize = 26.sp, fontWeight = FontWeight.SemiBold)
|
||||
Spacer(Modifier.height(10.dp))
|
||||
QueryField(
|
||||
query = state.query,
|
||||
loading = state.isLoading,
|
||||
onVoiceResult = onVoiceResult,
|
||||
)
|
||||
Spacer(Modifier.height(14.dp))
|
||||
TvKeyboard(
|
||||
navigationFocusRequester = navigationFocusRequester,
|
||||
resultsEntry = resultsEntry,
|
||||
keyboardEntry = keyboardEntry,
|
||||
keyboardReturn = keyboardReturn,
|
||||
lastKeyIndex = lastKeyIndex,
|
||||
hasResultsTarget = hasResultsTarget,
|
||||
onKeyFocused = onKeyFocused,
|
||||
onCharacter = onCharacter,
|
||||
onBackspace = onBackspace,
|
||||
onClear = onClear,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The query as typed, plus the two things that belong beside it: a quiet progress dot
|
||||
* while a search is in flight, and voice input when the device offers it.
|
||||
*/
|
||||
@Composable
|
||||
private fun QueryField(
|
||||
query: String,
|
||||
loading: Boolean,
|
||||
onVoiceResult: (String) -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val voiceAvailable = remember { SpeechRecognizer.isRecognitionAvailable(context) }
|
||||
val voiceLauncher = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.StartActivityForResult(),
|
||||
) { result ->
|
||||
if (result.resultCode != Activity.RESULT_OK) return@rememberLauncherForActivityResult
|
||||
val spoken = result.data
|
||||
?.getStringArrayListExtra(RecognizerIntent.EXTRA_RESULTS)
|
||||
?.firstOrNull()
|
||||
?.trim()
|
||||
// Replaces rather than appends: dictating a title after typing half of another
|
||||
// one almost always means "no, this instead".
|
||||
if (!spoken.isNullOrEmpty()) onVoiceResult(spoken)
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(10.dp))
|
||||
.background(Color(0xFF151C23))
|
||||
.border(1.dp, Color.White.copy(alpha = 0.10f), RoundedCornerShape(10.dp))
|
||||
.padding(horizontal = 12.dp, vertical = 9.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Search,
|
||||
contentDescription = null,
|
||||
tint = if (query.isEmpty()) Muted else Accent,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
Spacer(Modifier.width(10.dp))
|
||||
Text(
|
||||
text = query.ifEmpty { "Search movies, shows and episodes" },
|
||||
color = if (query.isEmpty()) Muted else Heading,
|
||||
// Fixed size rather than shrinking as the query grows: readable at three
|
||||
// metres matters more than fitting a long query on one line.
|
||||
fontSize = if (query.isEmpty()) 15.sp else 17.sp,
|
||||
fontWeight = if (query.isEmpty()) FontWeight.Normal else FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
// A long query scrolls off the *start*, so the letters just typed stay visible.
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
if (loading) {
|
||||
Spacer(Modifier.width(8.dp))
|
||||
LoadingDot()
|
||||
}
|
||||
if (voiceAvailable) {
|
||||
Spacer(Modifier.width(8.dp))
|
||||
FocusScaleContainer(
|
||||
onFocused = {},
|
||||
onClick = {
|
||||
val intent = Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply {
|
||||
putExtra(
|
||||
RecognizerIntent.EXTRA_LANGUAGE_MODEL,
|
||||
RecognizerIntent.LANGUAGE_MODEL_FREE_FORM,
|
||||
)
|
||||
putExtra(RecognizerIntent.EXTRA_PROMPT, "Say a title")
|
||||
}
|
||||
// A device can advertise recognition and still have nothing to
|
||||
// launch; failing silently beats crashing the search screen.
|
||||
runCatching { voiceLauncher.launch(intent) }
|
||||
},
|
||||
contentDescription = "Search by voice",
|
||||
modifier = Modifier.clip(RoundedCornerShape(8.dp)),
|
||||
) { focused ->
|
||||
Icon(
|
||||
Icons.Default.Mic,
|
||||
contentDescription = null,
|
||||
tint = if (focused) KeyLabelFocused else KeyLabel,
|
||||
modifier = Modifier
|
||||
.background(if (focused) KeyFocused else KeyIdle)
|
||||
.padding(6.dp)
|
||||
.size(18.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TvKeyboard(
|
||||
navigationFocusRequester: FocusRequester,
|
||||
resultsEntry: FocusRequester,
|
||||
keyboardEntry: FocusRequester,
|
||||
keyboardReturn: FocusRequester,
|
||||
lastKeyIndex: Int,
|
||||
hasResultsTarget: Boolean,
|
||||
onKeyFocused: (Int) -> Unit,
|
||||
onCharacter: (String) -> Unit,
|
||||
onBackspace: () -> Unit,
|
||||
onClear: () -> Unit,
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
KeyboardRows.forEachIndexed { rowIndex, row ->
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
row.forEachIndexed { columnIndex, character ->
|
||||
val index = rowIndex * KEYBOARD_COLUMNS + columnIndex
|
||||
KeyboardKey(
|
||||
label = character.toString(),
|
||||
contentDescription = "Type ${character}",
|
||||
onClick = { onCharacter(character.toString()) },
|
||||
onFocused = { onKeyFocused(index) },
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
// Explicit edges. Left of the first column is the navigation
|
||||
// rail, right of the last is the results grid: focus can
|
||||
// always get out of this pane, and never falls off it.
|
||||
.focusProperties {
|
||||
if (columnIndex == 0) left = navigationFocusRequester
|
||||
if (columnIndex == KEYBOARD_COLUMNS - 1) {
|
||||
right = if (hasResultsTarget) resultsEntry else FocusRequester.Cancel
|
||||
}
|
||||
}
|
||||
.then(
|
||||
if (index == 0) Modifier.focusRequester(keyboardEntry) else Modifier,
|
||||
)
|
||||
.then(
|
||||
if (index == lastKeyIndex) {
|
||||
Modifier.focusRequester(keyboardReturn)
|
||||
} else {
|
||||
Modifier
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(2.dp))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
ActionKey(
|
||||
icon = Icons.Default.SpaceBar,
|
||||
label = "Space",
|
||||
contentDescription = "Insert a space",
|
||||
onClick = { onCharacter(" ") },
|
||||
onFocused = { onKeyFocused(ACTION_ROW_INDEX) },
|
||||
modifier = Modifier
|
||||
.weight(2f)
|
||||
.focusProperties { left = navigationFocusRequester }
|
||||
.then(
|
||||
if (lastKeyIndex == ACTION_ROW_INDEX) {
|
||||
Modifier.focusRequester(keyboardReturn)
|
||||
} else {
|
||||
Modifier
|
||||
},
|
||||
),
|
||||
)
|
||||
ActionKey(
|
||||
icon = Icons.AutoMirrored.Filled.Backspace,
|
||||
label = "Delete",
|
||||
contentDescription = "Delete the last character",
|
||||
onClick = onBackspace,
|
||||
onFocused = { onKeyFocused(ACTION_ROW_INDEX + 1) },
|
||||
modifier = Modifier
|
||||
.weight(2f)
|
||||
.then(
|
||||
if (lastKeyIndex == ACTION_ROW_INDEX + 1) {
|
||||
Modifier.focusRequester(keyboardReturn)
|
||||
} else {
|
||||
Modifier
|
||||
},
|
||||
),
|
||||
)
|
||||
ActionKey(
|
||||
icon = Icons.Default.Close,
|
||||
label = "Clear",
|
||||
contentDescription = "Clear the whole query",
|
||||
onClick = onClear,
|
||||
onFocused = { onKeyFocused(ACTION_ROW_INDEX + 2) },
|
||||
modifier = Modifier
|
||||
.weight(2f)
|
||||
.focusProperties {
|
||||
right = if (hasResultsTarget) resultsEntry else FocusRequester.Cancel
|
||||
}
|
||||
.then(
|
||||
if (lastKeyIndex == ACTION_ROW_INDEX + 2) {
|
||||
Modifier.focusRequester(keyboardReturn)
|
||||
} else {
|
||||
Modifier
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun KeyboardKey(
|
||||
label: String,
|
||||
contentDescription: String,
|
||||
onClick: () -> Unit,
|
||||
onFocused: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
FocusScaleContainer(
|
||||
onFocused = onFocused,
|
||||
onClick = onClick,
|
||||
contentDescription = contentDescription,
|
||||
modifier = modifier.clip(RoundedCornerShape(8.dp)),
|
||||
) { focused ->
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
// 44dp of target at six columns across a third of a 1080p screen: large
|
||||
// enough to hit reliably while glancing at the results, not the keyboard.
|
||||
.height(36.dp)
|
||||
.background(if (focused) KeyFocused else KeyIdle),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
label,
|
||||
color = if (focused) KeyLabelFocused else KeyLabel,
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ActionKey(
|
||||
icon: ImageVector,
|
||||
label: String,
|
||||
contentDescription: String,
|
||||
onClick: () -> Unit,
|
||||
onFocused: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
FocusScaleContainer(
|
||||
onFocused = onFocused,
|
||||
onClick = onClick,
|
||||
contentDescription = contentDescription,
|
||||
modifier = modifier.clip(RoundedCornerShape(8.dp)),
|
||||
) { focused ->
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(36.dp)
|
||||
.background(if (focused) KeyFocused else KeyIdle),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
icon,
|
||||
contentDescription = null,
|
||||
tint = if (focused) KeyLabelFocused else KeyLabel,
|
||||
modifier = Modifier.size(17.dp),
|
||||
)
|
||||
Spacer(Modifier.width(7.dp))
|
||||
Text(
|
||||
label,
|
||||
color = if (focused) KeyLabelFocused else KeyLabel,
|
||||
fontSize = 13.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A quiet pulse beside the query. Deliberately not a full-screen spinner. */
|
||||
@Composable
|
||||
private fun LoadingDot() {
|
||||
val alpha = rememberInfiniteTransition(label = "search-loading").animateFloat(
|
||||
initialValue = 0.25f,
|
||||
targetValue = 1f,
|
||||
animationSpec = infiniteRepeatable(tween(620), RepeatMode.Reverse),
|
||||
label = "search-loading-alpha",
|
||||
)
|
||||
// Read in the draw lambda: a spinner beside the query must not recompose the pane
|
||||
// that holds the keyboard.
|
||||
Box(
|
||||
Modifier
|
||||
.size(9.dp)
|
||||
.drawBehind { drawCircle(color = Accent.copy(alpha = alpha.value)) }
|
||||
.semantics { contentDescription = "Searching" },
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ResultsPane(
|
||||
state: SearchUiState,
|
||||
resultsEntry: FocusRequester,
|
||||
keyboardReturn: FocusRequester,
|
||||
returnFocusItemId: String?,
|
||||
returnFocusRequester: FocusRequester,
|
||||
discoveryItems: List<BaseItem>,
|
||||
onItemFocused: (BaseItem) -> Unit,
|
||||
onItemSelected: (BaseItem) -> Unit,
|
||||
onRetry: () -> Unit,
|
||||
onSuggestionSelected: (String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
BoxWithConstraints(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 28.dp, end = 32.dp, top = 28.dp, bottom = 12.dp),
|
||||
) {
|
||||
val columns = if (maxWidth > 760.dp) 5 else 4
|
||||
val cardWidth = ((maxWidth - CARD_SPACING * (columns - 1)) / columns)
|
||||
.coerceIn(120.dp, 200.dp)
|
||||
|
||||
// Discovery, results, error and "no matches" all share this pane. Only the
|
||||
// heading and the item source change, so the grid never unmounts and remounts.
|
||||
val showingDiscovery = state.isDiscovery
|
||||
val items = if (showingDiscovery) discoveryItems else state.results
|
||||
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
ResultsHeading(state = state, showingDiscovery = showingDiscovery)
|
||||
val genres = state.suggestions.filter { it.kind == SearchSuggestion.Kind.GENRE }
|
||||
val recent = state.suggestions.filter { it.kind == SearchSuggestion.Kind.RECENT }
|
||||
if (showingDiscovery && genres.isNotEmpty()) {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
GenreTiles(
|
||||
genres = genres,
|
||||
resultsEntry = resultsEntry,
|
||||
keyboardReturn = keyboardReturn,
|
||||
onSelected = onSuggestionSelected,
|
||||
)
|
||||
}
|
||||
if (showingDiscovery && recent.isNotEmpty()) {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
"RECENT SEARCHES",
|
||||
color = Muted,
|
||||
fontSize = 11.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
letterSpacing = 0.8.sp,
|
||||
)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
SuggestionChips(
|
||||
suggestions = recent,
|
||||
resultsEntry = null,
|
||||
keyboardReturn = keyboardReturn,
|
||||
onSelected = onSuggestionSelected,
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(12.dp))
|
||||
|
||||
when {
|
||||
state.errorMessage != null && state.results.isEmpty() -> SearchError(
|
||||
message = state.errorMessage,
|
||||
onRetry = onRetry,
|
||||
resultsEntry = resultsEntry,
|
||||
keyboardReturn = keyboardReturn,
|
||||
)
|
||||
items.isEmpty() -> SearchEmptyMessage(state = state, showingDiscovery = showingDiscovery)
|
||||
else -> ResultsGrid(
|
||||
items = items,
|
||||
columns = columns,
|
||||
cardWidth = cardWidth,
|
||||
resultsEntry = resultsEntry,
|
||||
keyboardReturn = keyboardReturn,
|
||||
returnFocusItemId = returnFocusItemId,
|
||||
returnFocusRequester = returnFocusRequester,
|
||||
onItemFocused = onItemFocused,
|
||||
onItemSelected = onItemSelected,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ResultsHeading(state: SearchUiState, showingDiscovery: Boolean) {
|
||||
val title = when {
|
||||
showingDiscovery -> "Browse your library"
|
||||
state.isEmptyResult -> "Search results — no matches"
|
||||
else -> "Search results for “${state.query.trim()}”"
|
||||
}
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
title,
|
||||
color = Heading,
|
||||
fontSize = 22.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f, fill = false),
|
||||
)
|
||||
if (!showingDiscovery && state.results.isNotEmpty()) {
|
||||
Spacer(Modifier.width(10.dp))
|
||||
Text("${state.results.size}", color = Muted, fontSize = 16.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ResultsGrid(
|
||||
items: List<BaseItem>,
|
||||
columns: Int,
|
||||
cardWidth: Dp,
|
||||
resultsEntry: FocusRequester,
|
||||
keyboardReturn: FocusRequester,
|
||||
returnFocusItemId: String?,
|
||||
returnFocusRequester: FocusRequester,
|
||||
onItemFocused: (BaseItem) -> Unit,
|
||||
onItemSelected: (BaseItem) -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val density = LocalDensity.current
|
||||
val gridState = rememberLazyGridState()
|
||||
|
||||
// Warm the first screenful so the grid does not fill in card by card. Keyed on the
|
||||
// ids rather than the list, so an unchanged result set never re-fetches.
|
||||
val prefetchKey = remember(items) { items.take(PREFETCHED_POSTERS).joinToString("|") { it.id } }
|
||||
LaunchedEffect(prefetchKey, cardWidth) {
|
||||
val repo = ServiceLocator.repository
|
||||
val widthPx = with(density) { cardWidth.roundToPx() }.coerceIn(180, 720)
|
||||
items.take(PREFETCHED_POSTERS).forEach { item ->
|
||||
val url = repo.primaryUrl(item, widthPx) ?: repo.backdropUrl(item, widthPx)
|
||||
?: return@forEach
|
||||
context.imageLoader.execute(
|
||||
ImageRequest.Builder(context)
|
||||
.data(url)
|
||||
.size(widthPx, (widthPx * 3f / 2f).toInt())
|
||||
.allowHardware(true)
|
||||
.crossfade(false)
|
||||
.build(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Fixed(columns),
|
||||
state = gridState,
|
||||
horizontalArrangement = Arrangement.spacedBy(CARD_SPACING),
|
||||
verticalArrangement = Arrangement.spacedBy(18.dp),
|
||||
modifier = Modifier
|
||||
.fillMaxSize(),
|
||||
) {
|
||||
// itemsIndexed, not items + indexOf: BaseItem is a data class, so indexOf would
|
||||
// run a deep equals per card per composition on the app's hottest new screen.
|
||||
itemsIndexed(items, key = { _, item -> item.id }, contentType = { _, _ -> "search-result" }) { index, item ->
|
||||
PosterGridCard(
|
||||
item = item,
|
||||
width = cardWidth,
|
||||
onFocused = { onItemFocused(item) },
|
||||
onClick = { onItemSelected(item) },
|
||||
onLongClick = { onItemSelected(item) },
|
||||
modifier = Modifier
|
||||
.then(if (index == 0) Modifier.focusRequester(resultsEntry) else Modifier)
|
||||
.then(
|
||||
if (item.id == returnFocusItemId) {
|
||||
Modifier.focusRequester(returnFocusRequester)
|
||||
} else {
|
||||
Modifier
|
||||
},
|
||||
)
|
||||
// Leftmost column goes back to the keyboard rather than nowhere.
|
||||
.focusProperties {
|
||||
if (index % columns == 0) left = keyboardReturn
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val GenreColors = listOf(
|
||||
Color(0xFFB85C38), Color(0xFF5578C8), Color(0xFF7B5AB6),
|
||||
Color(0xFF2F8F83), Color(0xFFD18A32), Color(0xFFB44E76),
|
||||
)
|
||||
|
||||
private fun genreIcon(label: String): ImageVector = when {
|
||||
label.contains("comedy", true) -> Icons.Default.TheaterComedy
|
||||
label.contains("music", true) -> Icons.Default.LiveTv
|
||||
label.contains("children", true) || label.contains("family", true) -> Icons.Default.SentimentVerySatisfied
|
||||
label.contains("sport", true) -> Icons.Default.PlayCircleFilled
|
||||
label.contains("document", true) -> Icons.Default.Movie
|
||||
else -> Icons.Default.AutoAwesome
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GenreTiles(
|
||||
genres: List<SearchSuggestion>,
|
||||
resultsEntry: FocusRequester,
|
||||
keyboardReturn: FocusRequester,
|
||||
onSelected: (String) -> Unit,
|
||||
) {
|
||||
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
rowItemsIndexed(genres.take(6), key = { _, item -> item.label }) { index, genre ->
|
||||
val color = GenreColors[index % GenreColors.size]
|
||||
FocusScaleContainer(
|
||||
onFocused = {},
|
||||
onClick = { onSelected(genre.label) },
|
||||
contentDescription = "Search the ${genre.label} genre",
|
||||
modifier = Modifier
|
||||
.width(126.dp)
|
||||
.height(72.dp)
|
||||
.clip(RoundedCornerShape(10.dp))
|
||||
.then(if (index == 0) Modifier.focusRequester(resultsEntry) else Modifier)
|
||||
.focusProperties { if (index == 0) left = keyboardReturn },
|
||||
) { focused ->
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.background(if (focused) color.copy(alpha = 0.95f) else color),
|
||||
) {
|
||||
Icon(
|
||||
genreIcon(genre.label),
|
||||
contentDescription = null,
|
||||
tint = Color.White.copy(alpha = 0.9f),
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.padding(10.dp)
|
||||
.size(30.dp),
|
||||
)
|
||||
Text(
|
||||
genre.label,
|
||||
color = Color.White,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.align(Alignment.BottomStart).padding(10.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SuggestionChips(
|
||||
suggestions: List<SearchSuggestion>,
|
||||
resultsEntry: FocusRequester?,
|
||||
keyboardReturn: FocusRequester,
|
||||
onSelected: (String) -> Unit,
|
||||
) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
suggestions.take(6).forEachIndexed { index, suggestion ->
|
||||
FocusScaleContainer(
|
||||
onFocused = {},
|
||||
onClick = { onSelected(suggestion.label) },
|
||||
contentDescription = when (suggestion.kind) {
|
||||
SearchSuggestion.Kind.RECENT -> "Search again for ${suggestion.label}"
|
||||
SearchSuggestion.Kind.GENRE -> "Search the ${suggestion.label} genre"
|
||||
},
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(999.dp))
|
||||
.then(if (index == 0 && resultsEntry != null) Modifier.focusRequester(resultsEntry) else Modifier)
|
||||
.focusProperties { if (index == 0) left = keyboardReturn },
|
||||
) { focused ->
|
||||
Text(
|
||||
suggestion.label.uppercase(),
|
||||
color = if (focused) KeyLabelFocused else KeyLabel,
|
||||
fontSize = 11.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 1,
|
||||
modifier = Modifier
|
||||
.background(if (focused) KeyFocused else KeyIdle)
|
||||
.padding(horizontal = 12.dp, vertical = 7.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SearchEmptyMessage(state: SearchUiState, showingDiscovery: Boolean) {
|
||||
val message = when {
|
||||
showingDiscovery -> "Type a couple of letters to search, or pick up where the home screen left off."
|
||||
state.isLoading -> "Searching…"
|
||||
else -> "Nothing in this library matches that. Try fewer letters, or a different spelling."
|
||||
}
|
||||
Text(message, color = Muted, fontSize = 17.sp, modifier = Modifier.padding(top = 40.dp))
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SearchError(
|
||||
message: String,
|
||||
onRetry: () -> Unit,
|
||||
resultsEntry: FocusRequester,
|
||||
keyboardReturn: FocusRequester,
|
||||
) {
|
||||
Column(Modifier.padding(top = 36.dp)) {
|
||||
Text(message, color = Heading, fontSize = 18.sp)
|
||||
Spacer(Modifier.height(14.dp))
|
||||
FocusScaleContainer(
|
||||
onFocused = {},
|
||||
onClick = onRetry,
|
||||
contentDescription = "Try the search again",
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(10.dp))
|
||||
.focusRequester(resultsEntry)
|
||||
.focusProperties { left = keyboardReturn },
|
||||
) { focused ->
|
||||
Text(
|
||||
"Try again",
|
||||
color = if (focused) KeyLabelFocused else KeyLabel,
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
modifier = Modifier
|
||||
.background(if (focused) KeyFocused else KeyIdle)
|
||||
.padding(horizontal = 22.dp, vertical = 11.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val CARD_SPACING = 16.dp
|
||||
private const val KEYBOARD_COLUMNS = 6
|
||||
private const val ACTION_ROW_INDEX = 36
|
||||
private const val BACKSPACE_CODE = 8
|
||||
private const val FIRST_PRINTABLE_CODE = 32
|
||||
@@ -0,0 +1,255 @@
|
||||
package com.ponzischeme89.memby.ui.search
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.ponzischeme89.memby.data.EmbyRepository
|
||||
import com.ponzischeme89.memby.data.friendlyEmbyError
|
||||
import com.ponzischeme89.memby.data.model.BaseItem
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.debounce
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Something to offer when there is nothing to search for yet. Kept deliberately cheap:
|
||||
* both kinds are built from data the app already has in memory, because an empty search
|
||||
* box is not worth a server request.
|
||||
*/
|
||||
data class SearchSuggestion(val label: String, val kind: Kind) {
|
||||
enum class Kind { RECENT, GENRE }
|
||||
}
|
||||
|
||||
data class SearchUiState(
|
||||
val query: String = "",
|
||||
val results: List<BaseItem> = emptyList(),
|
||||
val suggestions: List<SearchSuggestion> = emptyList(),
|
||||
val isLoading: Boolean = false,
|
||||
/** True once a query has run to completion, so "no matches" is distinguishable from "not yet". */
|
||||
val hasSearched: Boolean = false,
|
||||
val errorMessage: String? = null,
|
||||
) {
|
||||
/** The query is long enough to search but nothing came back. */
|
||||
val isEmptyResult: Boolean
|
||||
get() = hasSearched && !isLoading && errorMessage == null && results.isEmpty()
|
||||
|
||||
/** Nothing typed yet: the pane shows discovery rather than results. */
|
||||
val isDiscovery: Boolean
|
||||
get() = !shouldSearch(query)
|
||||
}
|
||||
|
||||
/**
|
||||
* Instant search.
|
||||
*
|
||||
* Typing feeds [onQueryChanged]; a debounce, a `distinctUntilChanged` and a
|
||||
* `collectLatest` do the rest. The last of those is what makes rapid typing safe: it
|
||||
* cancels the in-flight request when a newer query arrives, so a slow response for "bre"
|
||||
* can never overwrite the results for "break".
|
||||
*/
|
||||
@OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class)
|
||||
class SearchViewModel(private val repository: EmbyRepository) : ViewModel() {
|
||||
|
||||
private val _state = MutableStateFlow(SearchUiState())
|
||||
val state: StateFlow<SearchUiState> = _state.asStateFlow()
|
||||
|
||||
private val queryFlow = MutableStateFlow("")
|
||||
|
||||
/**
|
||||
* Results for queries typed earlier in this session. Bounded and access-ordered, so
|
||||
* backspacing through a word redraws instantly instead of re-querying every prefix.
|
||||
*/
|
||||
private val cache = object : LinkedHashMap<String, List<BaseItem>>(16, 0.75f, true) {
|
||||
override fun removeEldestEntry(
|
||||
eldest: MutableMap.MutableEntry<String, List<BaseItem>>?,
|
||||
): Boolean = size > CACHE_ENTRIES
|
||||
}
|
||||
|
||||
private val recentQueries = ArrayDeque<String>()
|
||||
private var genreSuggestions: List<String> = emptyList()
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
queryFlow
|
||||
.debounce(DEBOUNCE_MS)
|
||||
.map { it.trim() }
|
||||
.distinctUntilChanged()
|
||||
// collectLatest rather than flatMapLatest: the search is a one-shot
|
||||
// suspend call, not a flow, and this cancels the previous one on the
|
||||
// same terms while keeping the repository's plain suspend signature.
|
||||
.collectLatest { term -> runSearch(term) }
|
||||
}
|
||||
viewModelScope.launch {
|
||||
repository.getRecentSearches().forEach { term ->
|
||||
if (recentQueries.none { it.equals(term, ignoreCase = true) }) {
|
||||
recentQueries.addLast(term)
|
||||
}
|
||||
}
|
||||
while (recentQueries.size > MAX_RECENT_QUERIES) recentQueries.removeLast()
|
||||
refreshSuggestions()
|
||||
}
|
||||
refreshSuggestions()
|
||||
}
|
||||
|
||||
/** Every keystroke, from the on-screen keyboard, a USB keyboard or voice. */
|
||||
fun onQueryChanged(query: String) {
|
||||
// The visible field updates immediately; only the *search* is debounced.
|
||||
_state.update { it.copy(query = query) }
|
||||
queryFlow.value = query
|
||||
}
|
||||
|
||||
fun appendToQuery(text: String) = onQueryChanged(state.value.query + text)
|
||||
|
||||
fun backspace() = onQueryChanged(state.value.query.dropLast(1))
|
||||
|
||||
fun clearQuery() {
|
||||
// Clear immediately so results from a genre tile cannot remain visible while the
|
||||
// debounced empty-query transition is pending.
|
||||
_state.update { it.copy(query = "", results = emptyList(), isLoading = false, hasSearched = false, errorMessage = null) }
|
||||
queryFlow.value = ""
|
||||
}
|
||||
|
||||
/** Retry after an error, without disturbing the query or the keyboard. */
|
||||
fun retry() {
|
||||
val term = state.value.query.trim()
|
||||
if (!shouldSearch(term)) return
|
||||
cache.remove(term)
|
||||
viewModelScope.launch { runSearch(term) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Genre chips for the empty state, taken from items the home screen already loaded.
|
||||
* Nothing is fetched: if home has no data yet, the chips simply do not appear.
|
||||
*/
|
||||
fun setDiscoveryItems(items: List<BaseItem>) {
|
||||
val genres = items.asSequence()
|
||||
.flatMap { it.genres.asSequence() }
|
||||
.map { it.trim() }
|
||||
.filter { it.isNotEmpty() }
|
||||
.groupingBy { it }
|
||||
.eachCount()
|
||||
.entries
|
||||
.sortedByDescending { it.value }
|
||||
.take(MAX_GENRE_SUGGESTIONS)
|
||||
.map { it.key }
|
||||
if (genres != genreSuggestions) {
|
||||
genreSuggestions = genres
|
||||
refreshSuggestions()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun runSearch(term: String) {
|
||||
if (!shouldSearch(term)) {
|
||||
// Back to the discovery state, but the previous results are dropped rather
|
||||
// than left behind a shorter query they no longer match.
|
||||
_state.update {
|
||||
it.copy(results = emptyList(), isLoading = false, hasSearched = false, errorMessage = null)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
cache[term]?.let { cached ->
|
||||
_state.update {
|
||||
it.copy(results = cached, isLoading = false, hasSearched = true, errorMessage = null)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Previous results stay on screen underneath the spinner: a flash of empty pane
|
||||
// between two letters reads as breakage, not as progress.
|
||||
_state.update { it.copy(isLoading = true, errorMessage = null) }
|
||||
runCatching { repository.search(term) }
|
||||
.onSuccess { items ->
|
||||
val ranked = rankSearchResults(term, items)
|
||||
cache[term] = ranked
|
||||
rememberQuery(term)
|
||||
viewModelScope.launch { repository.recordSearch(term) }
|
||||
_state.update {
|
||||
it.copy(results = ranked, isLoading = false, hasSearched = true, errorMessage = null)
|
||||
}
|
||||
}
|
||||
.onFailure { error ->
|
||||
// A cancelled search is the normal case while typing, not a failure.
|
||||
if (error is kotlinx.coroutines.CancellationException) throw error
|
||||
_state.update {
|
||||
it.copy(isLoading = false, hasSearched = true, errorMessage = friendlyEmbyError(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun rememberQuery(term: String) {
|
||||
recentQueries.removeAll { it.equals(term, ignoreCase = true) }
|
||||
recentQueries.addFirst(term)
|
||||
while (recentQueries.size > MAX_RECENT_QUERIES) recentQueries.removeLast()
|
||||
refreshSuggestions()
|
||||
}
|
||||
|
||||
private fun refreshSuggestions() {
|
||||
val suggestions = recentQueries.map { SearchSuggestion(it, SearchSuggestion.Kind.RECENT) } +
|
||||
genreSuggestions.map { SearchSuggestion(it, SearchSuggestion.Kind.GENRE) }
|
||||
_state.update { it.copy(suggestions = suggestions) }
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val DEBOUNCE_MS = 250L
|
||||
const val MIN_QUERY_LENGTH = 2
|
||||
private const val CACHE_ENTRIES = 24
|
||||
private const val MAX_RECENT_QUERIES = 6
|
||||
private const val MAX_GENRE_SUGGESTIONS = 6
|
||||
}
|
||||
}
|
||||
|
||||
class SearchViewModelFactory(private val repository: EmbyRepository) : ViewModelProvider.Factory {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T = SearchViewModel(repository) as T
|
||||
}
|
||||
|
||||
/**
|
||||
* Two characters, not one.
|
||||
*
|
||||
* A single letter matches a large fraction of any library, so the request is slow, the
|
||||
* results are noise, and it fires on the way to every real query. Multi-word queries are
|
||||
* untouched — only the trimmed length matters.
|
||||
*/
|
||||
fun shouldSearch(query: String): Boolean = query.trim().length >= SearchViewModel.MIN_QUERY_LENGTH
|
||||
|
||||
/**
|
||||
* Orders results so the obvious answer is first.
|
||||
*
|
||||
* The backend already sorts by its own relevance (Postgres `ts_rank`, or Emby's own
|
||||
* ordering), and that ordering is *preserved within each tier* — this only lifts the
|
||||
* matches a person would be annoyed to find below the fold. Sorting is stable, so a
|
||||
* backend that already got it right is not reshuffled.
|
||||
*/
|
||||
fun rankSearchResults(query: String, items: List<BaseItem>): List<BaseItem> {
|
||||
val term = query.trim().lowercase()
|
||||
if (term.isEmpty()) return items
|
||||
return items.sortedBy { item -> matchTier(term, item) }
|
||||
}
|
||||
|
||||
private fun matchTier(term: String, item: BaseItem): Int {
|
||||
val name = item.name.trim().lowercase()
|
||||
val series = item.seriesName.orEmpty().trim().lowercase()
|
||||
return when {
|
||||
name == term -> 0
|
||||
series == term -> 1
|
||||
name.startsWith(term) -> 2
|
||||
series.startsWith(term) -> 3
|
||||
name.containsWordStartingWith(term) -> 4
|
||||
name.contains(term) -> 5
|
||||
series.contains(term) -> 6
|
||||
// Everything else the backend returned: genre, year or overview matches. Kept,
|
||||
// because "no exact match but here is the related thing" beats an empty pane.
|
||||
else -> 7
|
||||
}
|
||||
}
|
||||
|
||||
/** "star" should rank higher in "Lone Star" than in "Costar". */
|
||||
private fun String.containsWordStartingWith(term: String): Boolean =
|
||||
split(' ', '-', ':', '.', '\'').any { it.startsWith(term) }
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<selector xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<!-- The focused pill is white, so its label has to go dark to stay readable. -->
|
||||
<item android:state_focused="true" android:color="#FF0B0E11" />
|
||||
<item android:color="#FFFFFFFF" />
|
||||
</selector>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<selector xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:color="#FF090B0D" android:state_focused="true" />
|
||||
<item android:color="#FFFFFFFF" />
|
||||
</selector>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<selector xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:color="#FF090B0D" android:state_focused="true" />
|
||||
<item android:color="#FFFFFFFF" />
|
||||
</selector>
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M20,11H7.83l5.59,-5.59L12,4l-8,8 8,8 1.42,-1.41L7.83,13H20v-2z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M7.41,8.59L12,13.17l4.59,-4.58L18,10l-6,6 -6,-6z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
<solid android:color="#F2101418" />
|
||||
<corners android:radius="14dp" />
|
||||
<stroke
|
||||
android:width="1dp"
|
||||
android:color="#26FFFFFF" />
|
||||
</shape>
|
||||
@@ -0,0 +1,17 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Focused reads as a filled white pill, matching the transport controls: on a TV the
|
||||
focused item has to be unmistakable from across the room. -->
|
||||
<selector xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:state_focused="true">
|
||||
<shape android:shape="rectangle">
|
||||
<solid android:color="#FFFFFFFF" />
|
||||
<corners android:radius="8dp" />
|
||||
</shape>
|
||||
</item>
|
||||
<item>
|
||||
<shape android:shape="rectangle">
|
||||
<solid android:color="#FF52B54B" />
|
||||
<corners android:radius="8dp" />
|
||||
</shape>
|
||||
</item>
|
||||
</selector>
|
||||
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<selector xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:state_focused="true">
|
||||
<shape android:shape="rectangle">
|
||||
<solid android:color="#FFFFFFFF" />
|
||||
<corners android:radius="8dp" />
|
||||
</shape>
|
||||
</item>
|
||||
<item>
|
||||
<shape android:shape="rectangle">
|
||||
<solid android:color="#28FFFFFF" />
|
||||
<corners android:radius="8dp" />
|
||||
<stroke
|
||||
android:width="1dp"
|
||||
android:color="#30FFFFFF" />
|
||||
</shape>
|
||||
</item>
|
||||
</selector>
|
||||
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
<solid android:color="#FF24292E" />
|
||||
<stroke
|
||||
android:width="1dp"
|
||||
android:color="#24FFFFFF" />
|
||||
<corners android:radius="10dp" />
|
||||
</shape>
|
||||
@@ -0,0 +1,21 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<selector xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:state_pressed="true">
|
||||
<shape android:shape="oval">
|
||||
<solid android:color="#FF52B54B" />
|
||||
</shape>
|
||||
</item>
|
||||
<item android:state_focused="true">
|
||||
<shape android:shape="oval">
|
||||
<solid android:color="#FFFFFFFF" />
|
||||
</shape>
|
||||
</item>
|
||||
<item>
|
||||
<shape android:shape="oval">
|
||||
<solid android:color="#28FFFFFF" />
|
||||
<stroke
|
||||
android:width="1dp"
|
||||
android:color="#30FFFFFF" />
|
||||
</shape>
|
||||
</item>
|
||||
</selector>
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<gradient
|
||||
android:angle="90"
|
||||
android:endColor="#F2000000"
|
||||
android:startColor="#00000000"
|
||||
android:type="linear" />
|
||||
</shape>
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<gradient
|
||||
android:angle="270"
|
||||
android:endColor="#00000000"
|
||||
android:startColor="#D9000000"
|
||||
android:type="linear" />
|
||||
</shape>
|
||||
@@ -0,0 +1,27 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<selector xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:state_focused="true">
|
||||
<shape android:shape="rectangle">
|
||||
<solid android:color="#FFFFFFFF" />
|
||||
<corners android:radius="12dp" />
|
||||
</shape>
|
||||
</item>
|
||||
<item android:state_selected="true">
|
||||
<shape android:shape="rectangle">
|
||||
<solid android:color="#2E52B54B" />
|
||||
<stroke
|
||||
android:width="1dp"
|
||||
android:color="#B852B54B" />
|
||||
<corners android:radius="12dp" />
|
||||
</shape>
|
||||
</item>
|
||||
<item>
|
||||
<shape android:shape="rectangle">
|
||||
<solid android:color="#16FFFFFF" />
|
||||
<stroke
|
||||
android:width="1dp"
|
||||
android:color="#20FFFFFF" />
|
||||
<corners android:radius="12dp" />
|
||||
</shape>
|
||||
</item>
|
||||
</selector>
|
||||
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
<solid android:color="#FA0B0E11" />
|
||||
<stroke
|
||||
android:width="1dp"
|
||||
android:color="#24FFFFFF" />
|
||||
<corners
|
||||
android:bottomLeftRadius="24dp"
|
||||
android:topLeftRadius="24dp" />
|
||||
</shape>
|
||||
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
|
||||
<gradient
|
||||
android:angle="315"
|
||||
android:centerColor="#FF0D1114"
|
||||
android:endColor="#FF050708"
|
||||
android:startColor="#FF171D21"
|
||||
android:type="linear" />
|
||||
</shape>
|
||||
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
<solid android:color="#38000000" />
|
||||
<stroke
|
||||
android:width="1dp"
|
||||
android:color="#28FFFFFF" />
|
||||
<corners android:radius="14dp" />
|
||||
</shape>
|
||||
@@ -0,0 +1,161 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- <merge> rather than a FrameLayout root: setContentView already gives us a full-screen
|
||||
FrameLayout to merge into, so this saves a redundant level in the view hierarchy. -->
|
||||
<merge xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto">
|
||||
|
||||
<androidx.media3.ui.PlayerView
|
||||
android:id="@+id/player_view"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="#000000"
|
||||
app:auto_show="true"
|
||||
app:controller_layout_id="@layout/memby_player_controls"
|
||||
app:hide_on_touch="true"
|
||||
app:keep_content_on_player_reset="true"
|
||||
app:resize_mode="fit"
|
||||
app:show_buffering="never"
|
||||
app:surface_type="surface_view"
|
||||
app:use_controller="true" />
|
||||
|
||||
<!-- Above the video, below the loading overlay: a slide that is still starting has
|
||||
nothing to say about what comes next. -->
|
||||
<include layout="@layout/player_next_up_banner" />
|
||||
|
||||
<!-- App-owned subtitle controls. ExoPlayer supplies tracks and rendering only; this
|
||||
overlay deliberately stays in Memby's TV design language. -->
|
||||
<include layout="@layout/player_subtitle_overlay" />
|
||||
|
||||
<!-- Cast is metadata-only and never pauses or rebuilds the player. -->
|
||||
<include layout="@layout/player_cast_overlay" />
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/playback_loading"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="#F2050708"
|
||||
android:focusable="false"
|
||||
android:gravity="center"
|
||||
android:orientation="vertical">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/playback_loading_logo"
|
||||
android:layout_width="82dp"
|
||||
android:layout_height="70dp"
|
||||
android:contentDescription="@string/playback_loading"
|
||||
android:scaleType="fitCenter"
|
||||
android:src="@drawable/emby_logo" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/playback_loading_title"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="18dp"
|
||||
android:text="@string/playback_loading"
|
||||
android:textColor="#FFFFFFFF"
|
||||
android:textSize="20sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/playback_loading_hint"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="7dp"
|
||||
android:text="@string/playback_loading_hint"
|
||||
android:textColor="#FF929AA0"
|
||||
android:textSize="14sp" />
|
||||
</LinearLayout>
|
||||
|
||||
<!-- One cheap overlay, no second ExoPlayer. The programme prepares underneath it. -->
|
||||
<include layout="@layout/player_preroll" />
|
||||
|
||||
<!-- Deliberately outside PlayerView's controller hierarchy: a fatal error must remain
|
||||
actionable after Media3 hides its transport controls. -->
|
||||
<FrameLayout
|
||||
android:id="@+id/playback_error"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="#F2050708"
|
||||
android:focusable="false"
|
||||
android:visibility="gone">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="600dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center"
|
||||
android:gravity="center_horizontal"
|
||||
android:orientation="vertical"
|
||||
android:padding="36dp">
|
||||
|
||||
<ImageView
|
||||
android:layout_width="72dp"
|
||||
android:layout_height="62dp"
|
||||
android:contentDescription="@null"
|
||||
android:scaleType="fitCenter"
|
||||
android:src="@drawable/emby_logo" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/playback_error_title"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="24dp"
|
||||
android:gravity="center"
|
||||
android:textColor="#FFFFFFFF"
|
||||
android:textSize="28sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/playback_error_detail"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="10dp"
|
||||
android:gravity="center"
|
||||
android:lineSpacingExtra="3dp"
|
||||
android:textColor="#BFFFFFFF"
|
||||
android:textSize="17sp" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="28dp"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<Button
|
||||
android:id="@+id/playback_error_retry"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="@drawable/next_up_primary_button"
|
||||
android:focusable="true"
|
||||
android:minWidth="0dp"
|
||||
android:paddingStart="28dp"
|
||||
android:paddingTop="10dp"
|
||||
android:paddingEnd="28dp"
|
||||
android:paddingBottom="10dp"
|
||||
android:stateListAnimator="@null"
|
||||
android:text="@string/playback_try_again"
|
||||
android:textAllCaps="false"
|
||||
android:textColor="@color/next_up_button_text"
|
||||
android:textSize="16sp" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/playback_error_exit"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="12dp"
|
||||
android:background="@drawable/next_up_secondary_button"
|
||||
android:focusable="true"
|
||||
android:minWidth="0dp"
|
||||
android:paddingStart="28dp"
|
||||
android:paddingTop="10dp"
|
||||
android:paddingEnd="28dp"
|
||||
android:paddingBottom="10dp"
|
||||
android:stateListAnimator="@null"
|
||||
android:text="@string/playback_back_to_memby"
|
||||
android:textAllCaps="false"
|
||||
android:textColor="@color/next_up_button_text"
|
||||
android:textSize="16sp" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
</FrameLayout>
|
||||
|
||||
</merge>
|
||||
@@ -0,0 +1,246 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- This is a PlayerView controller layout (app:controller_layout_id), so it is bound to
|
||||
media3-ui's exo_* ids by contract. The transport buttons reuse media3's own icons and
|
||||
descriptions for the same reason; they are marked private, hence the tools:ignore. If a
|
||||
media3 upgrade ever drops one, copy it into res/ here rather than un-suppressing. -->
|
||||
<merge xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<View
|
||||
android:id="@id/exo_controls_background"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="@android:color/transparent" />
|
||||
|
||||
<View
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="210dp"
|
||||
android:layout_gravity="top"
|
||||
android:background="@drawable/player_osd_top_gradient" />
|
||||
|
||||
<View
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="300dp"
|
||||
android:layout_gravity="bottom"
|
||||
android:background="@drawable/player_osd_bottom_gradient" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="140dp"
|
||||
android:layout_gravity="top|start"
|
||||
android:layout_marginStart="48dp"
|
||||
android:layout_marginTop="30dp"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:letterSpacing="0.14"
|
||||
android:text="@string/player_now_playing"
|
||||
android:textColor="#B8FFFFFF"
|
||||
android:textSize="11sp" />
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/player_title_logo"
|
||||
android:layout_width="320dp"
|
||||
android:layout_height="92dp"
|
||||
android:layout_marginTop="7dp"
|
||||
android:adjustViewBounds="true"
|
||||
android:contentDescription="@string/player_title_logo"
|
||||
android:scaleType="fitStart"
|
||||
android:visibility="invisible" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/player_title"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:ellipsize="end"
|
||||
android:maxLines="2"
|
||||
android:maxWidth="600dp"
|
||||
android:textColor="#FFFFFFFF"
|
||||
android:textSize="28sp"
|
||||
android:textStyle="bold"
|
||||
android:visibility="gone" />
|
||||
</LinearLayout>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/player_stream_status"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="top|end"
|
||||
android:layout_marginTop="44dp"
|
||||
android:layout_marginEnd="48dp"
|
||||
android:background="@drawable/player_status_background"
|
||||
android:letterSpacing="0.08"
|
||||
android:paddingStart="13dp"
|
||||
android:paddingTop="7dp"
|
||||
android:paddingEnd="13dp"
|
||||
android:paddingBottom="7dp"
|
||||
android:textColor="#DFFFFFFF"
|
||||
android:textSize="11sp"
|
||||
android:visibility="gone" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="210dp"
|
||||
android:layout_gravity="bottom"
|
||||
android:gravity="bottom"
|
||||
android:orientation="vertical"
|
||||
android:paddingStart="48dp"
|
||||
android:paddingEnd="48dp"
|
||||
android:paddingBottom="28dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<TextView
|
||||
android:id="@id/exo_position"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:textColor="#FFFFFFFF"
|
||||
android:textSize="15sp" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:paddingStart="6dp"
|
||||
android:paddingEnd="6dp"
|
||||
android:text="@string/player_position_separator"
|
||||
android:textColor="#80FFFFFF"
|
||||
android:textSize="14sp" />
|
||||
|
||||
<TextView
|
||||
android:id="@id/exo_duration"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:textColor="#AFFFFFFF"
|
||||
android:textSize="14sp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/player_remaining"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="16dp"
|
||||
android:text="@string/player_loading_duration"
|
||||
android:textColor="#BFFFFFFF"
|
||||
android:textSize="13sp" />
|
||||
</LinearLayout>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/player_finish_time"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:textColor="#FFFFFFFF"
|
||||
android:textSize="14sp" />
|
||||
</LinearLayout>
|
||||
|
||||
<androidx.media3.ui.DefaultTimeBar
|
||||
android:id="@id/exo_progress"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="34dp"
|
||||
android:focusable="true"
|
||||
app:ad_marker_color="#80FFFFFF"
|
||||
app:bar_height="4dp"
|
||||
app:buffered_color="#70FFFFFF"
|
||||
app:played_ad_marker_color="#FFFFFFFF"
|
||||
app:played_color="#FF52B54B"
|
||||
app:scrubber_color="#FFFFFFFF"
|
||||
app:scrubber_dragged_size="18dp"
|
||||
app:scrubber_enabled_size="14dp"
|
||||
app:touch_target_height="28dp"
|
||||
app:unplayed_color="#40FFFFFF" />
|
||||
|
||||
<FrameLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="72dp"
|
||||
android:focusable="false">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_gravity="start|center_vertical"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/player_exit"
|
||||
style="@style/MembyPlayerControlButton"
|
||||
android:contentDescription="@string/player_back"
|
||||
android:src="@drawable/ic_player_back" />
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/player_hide_controls"
|
||||
style="@style/MembyPlayerControlButton"
|
||||
android:contentDescription="@string/player_hide_controls"
|
||||
android:src="@drawable/ic_player_hide" />
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:id="@id/exo_center_controls"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_gravity="center"
|
||||
android:gravity="center"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<ImageButton
|
||||
android:id="@id/exo_rew"
|
||||
style="@style/MembyPlayerControlButton"
|
||||
android:contentDescription="@string/exo_controls_rewind_description"
|
||||
android:src="@drawable/exo_icon_rewind"
|
||||
tools:ignore="PrivateResource" />
|
||||
|
||||
<ImageButton
|
||||
android:id="@id/exo_play_pause"
|
||||
style="@style/MembyPlayerPrimaryControlButton"
|
||||
android:contentDescription="@string/exo_controls_play_description"
|
||||
android:focusedByDefault="true"
|
||||
android:src="@drawable/exo_icon_play"
|
||||
tools:ignore="PrivateResource,UnusedAttribute" />
|
||||
|
||||
<ImageButton
|
||||
android:id="@id/exo_ffwd"
|
||||
style="@style/MembyPlayerControlButton"
|
||||
android:contentDescription="@string/exo_controls_fastforward_description"
|
||||
android:src="@drawable/exo_icon_fastforward"
|
||||
tools:ignore="PrivateResource" />
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_gravity="end|center_vertical"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<ImageButton
|
||||
android:id="@id/exo_subtitle"
|
||||
style="@style/MembyPlayerControlButton"
|
||||
android:contentDescription="@string/exo_controls_cc_enabled_description"
|
||||
android:src="@drawable/exo_ic_subtitle_on"
|
||||
tools:ignore="PrivateResource" />
|
||||
|
||||
<ImageButton
|
||||
android:id="@id/exo_settings"
|
||||
style="@style/MembyPlayerControlButton"
|
||||
android:contentDescription="@string/exo_controls_settings_description"
|
||||
android:src="@drawable/exo_ic_settings"
|
||||
tools:ignore="PrivateResource" />
|
||||
</LinearLayout>
|
||||
</FrameLayout>
|
||||
</LinearLayout>
|
||||
|
||||
</merge>
|
||||
@@ -0,0 +1,73 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:id="@+id/player_cast_overlay"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="#70000000"
|
||||
android:clickable="true"
|
||||
android:focusable="true"
|
||||
android:visibility="gone">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="340dp"
|
||||
android:layout_gravity="bottom"
|
||||
android:background="@drawable/player_overlay_panel_background"
|
||||
android:orientation="vertical"
|
||||
android:paddingStart="48dp"
|
||||
android:paddingTop="25dp"
|
||||
android:paddingEnd="48dp"
|
||||
android:paddingBottom="22dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:letterSpacing="0.14"
|
||||
android:text="@string/player_now_playing"
|
||||
android:textColor="#FF52B54B"
|
||||
android:textSize="11sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="4dp"
|
||||
android:text="@string/player_cast"
|
||||
android:textColor="#FFFFFFFF"
|
||||
android:textSize="28sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/player_cast_status"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="18dp"
|
||||
android:text="@string/player_cast_loading"
|
||||
android:textColor="#AFFFFFFF"
|
||||
android:textSize="14sp" />
|
||||
|
||||
<HorizontalScrollView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_marginTop="14dp"
|
||||
android:layout_weight="1"
|
||||
android:clipToPadding="false"
|
||||
android:fillViewport="false"
|
||||
android:overScrollMode="never">
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/player_cast_people"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="match_parent"
|
||||
android:gravity="top"
|
||||
android:orientation="horizontal" />
|
||||
</HorizontalScrollView>
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/player_back_to_close"
|
||||
android:textColor="#78FFFFFF"
|
||||
android:textSize="12sp" />
|
||||
</LinearLayout>
|
||||
</FrameLayout>
|
||||
@@ -0,0 +1,111 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- A shallow native end-of-episode overlay. The outgoing PlayerView is scaled into the
|
||||
open left side while this single prefetched episode card fades in. -->
|
||||
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:id="@+id/player_next_up"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:focusable="false"
|
||||
android:visibility="gone">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="420dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center_vertical|end"
|
||||
android:layout_marginEnd="50dp"
|
||||
android:background="@drawable/next_up_banner_background"
|
||||
android:orientation="vertical"
|
||||
android:padding="20dp">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/player_next_up_image"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="214dp"
|
||||
android:background="#FF1B2026"
|
||||
android:contentDescription="@null"
|
||||
android:scaleType="centerCrop" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="18dp"
|
||||
android:letterSpacing="0.14"
|
||||
android:text="@string/next_up_label"
|
||||
android:textColor="#FF69CD61"
|
||||
android:textSize="11sp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/player_next_up_title"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="6dp"
|
||||
android:ellipsize="end"
|
||||
android:maxLines="2"
|
||||
android:textColor="#FFFFFFFF"
|
||||
android:textSize="24sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/player_next_up_meta"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="3dp"
|
||||
android:ellipsize="end"
|
||||
android:maxLines="1"
|
||||
android:textColor="#BFFFFFFF"
|
||||
android:textSize="13sp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/player_next_up_countdown"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="12dp"
|
||||
android:textColor="#FF69CD61"
|
||||
android:textSize="16sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="16dp"
|
||||
android:orientation="horizontal"
|
||||
tools:ignore="ButtonStyle">
|
||||
|
||||
<Button
|
||||
android:id="@+id/player_next_up_play"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="@drawable/next_up_primary_button"
|
||||
android:focusable="true"
|
||||
android:minWidth="0dp"
|
||||
android:paddingStart="22dp"
|
||||
android:paddingTop="9dp"
|
||||
android:paddingEnd="22dp"
|
||||
android:paddingBottom="9dp"
|
||||
android:stateListAnimator="@null"
|
||||
android:text="@string/next_up_play_now"
|
||||
android:textAllCaps="false"
|
||||
android:textColor="@color/next_up_button_text"
|
||||
android:textSize="15sp" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/player_next_up_dismiss"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="10dp"
|
||||
android:background="@drawable/next_up_secondary_button"
|
||||
android:focusable="true"
|
||||
android:minWidth="0dp"
|
||||
android:paddingStart="22dp"
|
||||
android:paddingTop="9dp"
|
||||
android:paddingEnd="22dp"
|
||||
android:paddingBottom="9dp"
|
||||
android:stateListAnimator="@null"
|
||||
android:text="@string/next_up_dismiss"
|
||||
android:textAllCaps="false"
|
||||
android:textColor="@color/next_up_button_text"
|
||||
android:textSize="15sp" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
</FrameLayout>
|
||||
@@ -0,0 +1,118 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:id="@+id/player_preroll"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="#FF050708"
|
||||
android:clickable="true"
|
||||
android:focusable="false"
|
||||
android:visibility="gone">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal"
|
||||
android:paddingStart="64dp"
|
||||
android:paddingTop="54dp"
|
||||
android:paddingEnd="64dp"
|
||||
android:paddingBottom="54dp">
|
||||
|
||||
<!-- Reserved for the eventual pre-roll media asset. Keeping this a plain View is
|
||||
virtually free and lets the real programme buffer beneath the overlay. -->
|
||||
<FrameLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_marginEnd="52dp"
|
||||
android:layout_weight="1.62"
|
||||
android:background="@drawable/player_preroll_video_background">
|
||||
|
||||
<ImageView
|
||||
android:layout_width="74dp"
|
||||
android:layout_height="64dp"
|
||||
android:layout_gravity="start|bottom"
|
||||
android:layout_marginStart="28dp"
|
||||
android:layout_marginBottom="25dp"
|
||||
android:alpha="0.24"
|
||||
android:contentDescription="@null"
|
||||
android:scaleType="fitCenter"
|
||||
android:src="@drawable/emby_logo" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/player_preroll_countdown"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="end|bottom"
|
||||
android:layout_marginEnd="28dp"
|
||||
android:layout_marginBottom="28dp"
|
||||
android:background="@drawable/player_status_background"
|
||||
android:paddingStart="18dp"
|
||||
android:paddingTop="10dp"
|
||||
android:paddingEnd="18dp"
|
||||
android:paddingBottom="10dp"
|
||||
android:text="@string/player_preroll_countdown_initial"
|
||||
android:textColor="#FFFFFFFF"
|
||||
android:textSize="15sp"
|
||||
android:textStyle="bold" />
|
||||
</FrameLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Coming up"
|
||||
android:textColor="#FFFFFFFF"
|
||||
android:textSize="30sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="5dp"
|
||||
android:text="From your Sonarr calendar"
|
||||
android:textColor="#FF8E979D"
|
||||
android:textSize="13sp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/player_preroll_today_label"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="25dp"
|
||||
android:text="AIRING TODAY"
|
||||
android:textAllCaps="true"
|
||||
android:textColor="#FF55B94D"
|
||||
android:textSize="12sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/player_preroll_today"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:orientation="vertical" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/player_preroll_week_label"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="22dp"
|
||||
android:text="THIS WEEK"
|
||||
android:textAllCaps="true"
|
||||
android:textColor="#FF8E979D"
|
||||
android:textSize="12sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/player_preroll_week"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:orientation="vertical" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
</FrameLayout>
|
||||
@@ -0,0 +1,107 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:id="@+id/player_subtitle_overlay"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="#70000000"
|
||||
android:clickable="true"
|
||||
android:focusable="true"
|
||||
android:visibility="gone">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="520dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_gravity="end"
|
||||
android:layout_marginStart="30dp"
|
||||
android:background="@drawable/player_overlay_panel_background"
|
||||
android:orientation="vertical"
|
||||
android:paddingStart="34dp"
|
||||
android:paddingTop="32dp"
|
||||
android:paddingEnd="34dp"
|
||||
android:paddingBottom="26dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:letterSpacing="0.14"
|
||||
android:text="@string/player_playback_options"
|
||||
android:textColor="#FF52B54B"
|
||||
android:textSize="11sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="7dp"
|
||||
android:text="@string/player_subtitles"
|
||||
android:textColor="#FFFFFFFF"
|
||||
android:textSize="30sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="7dp"
|
||||
android:lineSpacingExtra="2dp"
|
||||
android:text="@string/player_subtitle_overlay_hint"
|
||||
android:textColor="#AFFFFFFF"
|
||||
android:textSize="14sp" />
|
||||
|
||||
<ScrollView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_marginTop="24dp"
|
||||
android:layout_weight="1"
|
||||
android:clipToPadding="false"
|
||||
android:fillViewport="true"
|
||||
android:overScrollMode="never">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:letterSpacing="0.12"
|
||||
android:text="@string/player_subtitle_track"
|
||||
android:textColor="#8FFFFFFF"
|
||||
android:textSize="11sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/player_subtitle_tracks"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="9dp"
|
||||
android:orientation="vertical" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="25dp"
|
||||
android:letterSpacing="0.12"
|
||||
android:text="@string/player_text_size"
|
||||
android:textColor="#8FFFFFFF"
|
||||
android:textSize="11sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/player_subtitle_sizes"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="9dp"
|
||||
android:orientation="vertical" />
|
||||
</LinearLayout>
|
||||
</ScrollView>
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="14dp"
|
||||
android:text="@string/player_back_to_close"
|
||||
android:textColor="#78FFFFFF"
|
||||
android:textSize="12sp" />
|
||||
</LinearLayout>
|
||||
</FrameLayout>
|
||||
@@ -0,0 +1,19 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<style name="MembyPlayerControlButton">
|
||||
<item name="android:layout_width">48dp</item>
|
||||
<item name="android:layout_height">48dp</item>
|
||||
<item name="android:layout_marginEnd">10dp</item>
|
||||
<item name="android:background">@drawable/player_control_button_background</item>
|
||||
<item name="android:focusable">true</item>
|
||||
<item name="android:padding">13dp</item>
|
||||
<item name="android:tint">@color/player_control_tint</item>
|
||||
</style>
|
||||
|
||||
<style name="MembyPlayerPrimaryControlButton" parent="MembyPlayerControlButton">
|
||||
<item name="android:layout_width">60dp</item>
|
||||
<item name="android:layout_height">60dp</item>
|
||||
<item name="android:layout_marginEnd">14dp</item>
|
||||
<item name="android:padding">15dp</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -0,0 +1,48 @@
|
||||
package com.ponzischeme89.memby.data
|
||||
|
||||
import com.ponzischeme89.memby.data.model.BaseItem
|
||||
import com.ponzischeme89.memby.data.model.EmbyPerson
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class CastMetadataTest {
|
||||
|
||||
@Test
|
||||
fun `cast contains actors and preserves Emby order`() {
|
||||
val item = BaseItem(
|
||||
id = "movie",
|
||||
people = listOf(
|
||||
EmbyPerson(id = "director", name = "Director", type = "Director"),
|
||||
EmbyPerson(id = "lead", name = "Lead", role = "Detective", type = "Actor"),
|
||||
EmbyPerson(id = "support", name = "Support", role = "Doctor", type = "Actor"),
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(listOf("lead", "support"), item.cast.map(EmbyPerson::id))
|
||||
assertEquals("Detective", item.cast.first().role)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `decodes emby people and portrait tags`() {
|
||||
val item = Json { ignoreUnknownKeys = true }.decodeFromString<BaseItem>(
|
||||
"""
|
||||
{
|
||||
"Id":"movie",
|
||||
"Name":"Example",
|
||||
"Type":"Movie",
|
||||
"People":[{
|
||||
"Id":"person-1",
|
||||
"Name":"Alex Actor",
|
||||
"Role":"Morgan",
|
||||
"Type":"Actor",
|
||||
"PrimaryImageTag":"portrait-tag"
|
||||
}]
|
||||
}
|
||||
""".trimIndent(),
|
||||
)
|
||||
|
||||
assertEquals("person-1", item.cast.single().id)
|
||||
assertEquals("portrait-tag", item.cast.single().primaryImageTag)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package com.ponzischeme89.memby.data
|
||||
|
||||
import com.ponzischeme89.memby.data.model.GatewayAlert
|
||||
import com.ponzischeme89.memby.data.model.GatewayServiceStatus
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class ServiceAlertTest {
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
private fun alert(id: String, title: String = "Northbound", message: String = "S02E04 aired") =
|
||||
GatewayAlert(id = id, kind = "sonarr-aired", title = title, message = message)
|
||||
|
||||
@Test
|
||||
fun `picks the first alert this tv has not seen`() {
|
||||
val chosen = firstUnseenAlert(
|
||||
listOf(alert("a"), alert("b"), alert("c")),
|
||||
seen = setOf("a", "b"),
|
||||
)
|
||||
assertEquals("c", chosen?.id)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an alert already shown never comes back`() {
|
||||
assertNull(firstUnseenAlert(listOf(alert("a")), seen = setOf("a")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `incomplete alerts are dropped rather than drawn empty`() {
|
||||
val alerts = listOf(
|
||||
alert(id = ""),
|
||||
alert(id = "b", title = " "),
|
||||
alert(id = "c", message = ""),
|
||||
alert(id = "d"),
|
||||
)
|
||||
assertEquals("d", firstUnseenAlert(alerts, seen = emptySet())?.id)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an alert still on offer keeps waiting for a screen`() {
|
||||
assertFalse(
|
||||
pendingAlertExpired(
|
||||
pendingId = "a",
|
||||
shownId = null,
|
||||
alerts = listOf(alert("a"), alert("b")),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unshown alert is given up on once the gateway stops offering it`() {
|
||||
assertTrue(pendingAlertExpired(pendingId = "a", shownId = null, alerts = listOf(alert("b"))))
|
||||
assertTrue(pendingAlertExpired(pendingId = "a", shownId = null, alerts = emptyList()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an alert already on screen is left to its own timer`() {
|
||||
assertFalse(pendingAlertExpired(pendingId = "a", shownId = "a", alerts = emptyList()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `status decodes without alerts for a gateway that predates them`() {
|
||||
val status = json.decodeFromString<GatewayServiceStatus>(
|
||||
"""{"maintenance":false,"message":""}""",
|
||||
)
|
||||
assertTrue(status.alerts.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `status decodes the alert payload the gateway sends`() {
|
||||
val status = json.decodeFromString<GatewayServiceStatus>(
|
||||
"""
|
||||
{"maintenance":false,"message":"","alerts":[{
|
||||
"id":"sonarr:7:42:aired","kind":"sonarr-aired","title":"Northbound",
|
||||
"message":"S02E04 aired at 9:00 PM and will be in Emby soon.",
|
||||
"itemId":"sonarr:7:42","imageTag":"sonarr","airedAt":"2026-07-27T21:00:00+12:00"
|
||||
}]}
|
||||
""".trimIndent(),
|
||||
)
|
||||
val alert = status.alerts.single()
|
||||
assertEquals("sonarr:7:42:aired", alert.id)
|
||||
assertEquals("sonarr:7:42", alert.itemId)
|
||||
assertEquals("sonarr", alert.imageTag)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package com.ponzischeme89.memby.data
|
||||
|
||||
import com.ponzischeme89.memby.data.model.MediaStream
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class SubtitleSupportTest {
|
||||
@Test
|
||||
fun `normalizes relative delivery urls and preserves selection metadata`() {
|
||||
val tracks = subtitleTracks(
|
||||
streams = listOf(
|
||||
MediaStream(
|
||||
index = 3,
|
||||
type = "Subtitle",
|
||||
codec = "srt",
|
||||
displayTitle = "English SDH",
|
||||
language = "eng",
|
||||
isDefault = true,
|
||||
isForced = true,
|
||||
isTextSubtitleStream = true,
|
||||
deliveryUrl = "/Videos/a/Subtitles/3/Stream.srt?x=1",
|
||||
),
|
||||
),
|
||||
serverUrl = "https://emby.example",
|
||||
token = "a b",
|
||||
itemId = "item",
|
||||
mediaSourceId = "source",
|
||||
)
|
||||
|
||||
assertEquals(1, tracks.size)
|
||||
assertEquals(
|
||||
"https://emby.example/Videos/a/Subtitles/3/Stream.srt?x=1&api_key=a+b",
|
||||
tracks.single().url,
|
||||
)
|
||||
assertEquals("application/x-subrip", tracks.single().mimeType)
|
||||
assertEquals("eng", tracks.single().language)
|
||||
assertTrue(tracks.single().isDefault)
|
||||
assertTrue(tracks.single().isForced)
|
||||
assertTrue(tracks.single().isHearingImpaired)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `keeps supported text subtitles and rejects bitmap formats`() {
|
||||
assertEquals("text/vtt", subtitleMimeType("webvtt"))
|
||||
assertEquals("text/x-ssa", subtitleMimeType(null, "https://host/subtitle.ass?token=x"))
|
||||
assertEquals(null, subtitleMimeType("pgssub"))
|
||||
assertEquals(null, subtitleMimeType("dvdsub"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `does not duplicate authentication already present`() {
|
||||
assertEquals(
|
||||
"https://host/sub.srt?api_key=existing",
|
||||
authenticatedDeliveryUrl("https://other", "https://host/sub.srt?api_key=existing", "new"),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `image subtitles remain visible as encode choices without an overlay url`() {
|
||||
val track = subtitleTracks(
|
||||
streams = listOf(
|
||||
MediaStream(
|
||||
index = 7,
|
||||
type = "Subtitle",
|
||||
codec = "pgssub",
|
||||
language = "eng",
|
||||
isForced = true,
|
||||
deliveryMethod = "Encode",
|
||||
),
|
||||
),
|
||||
serverUrl = "https://emby.example",
|
||||
token = "token",
|
||||
itemId = "item",
|
||||
mediaSourceId = "source",
|
||||
).single()
|
||||
|
||||
assertEquals("Encode", track.deliveryMethod)
|
||||
assertEquals("", track.url)
|
||||
assertEquals("", track.mimeType)
|
||||
assertTrue(track.isForced)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package com.ponzischeme89.memby.ui
|
||||
|
||||
import com.ponzischeme89.memby.data.HomeSnapshot
|
||||
import com.ponzischeme89.memby.data.model.BaseItem
|
||||
import com.ponzischeme89.memby.data.model.HomeRow
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class AiringTodayTagsTest {
|
||||
@Test
|
||||
fun `tags matching recommended series from today's Sonarr schedule`() {
|
||||
val home = HomeSnapshot(
|
||||
rows = listOf(
|
||||
HomeRow(
|
||||
id = "sonarr-airing-today",
|
||||
title = "Shows airing today",
|
||||
kind = "schedule",
|
||||
items = listOf(
|
||||
BaseItem(
|
||||
id = "sonarr:7:42",
|
||||
name = "The Bear",
|
||||
type = "MembySonarrEpisode",
|
||||
membySource = "sonarr",
|
||||
),
|
||||
),
|
||||
),
|
||||
HomeRow(
|
||||
id = "recommended",
|
||||
title = "Recommended",
|
||||
kind = "recommended",
|
||||
items = listOf(
|
||||
BaseItem(id = "series-1", name = "The Bear", type = "Series"),
|
||||
BaseItem(id = "series-2", name = "Severance", type = "Series"),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
val recommendations = home.withAiringTodayTags().rows.last().items
|
||||
|
||||
assertTrue(recommendations[0].membyAiringToday)
|
||||
assertFalse(recommendations[1].membyAiringToday)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `show matching ignores punctuation spacing and case`() {
|
||||
val home = HomeSnapshot(
|
||||
rows = listOf(
|
||||
HomeRow(
|
||||
id = "sonarr-airing-today",
|
||||
title = "Shows airing today",
|
||||
items = listOf(
|
||||
BaseItem(
|
||||
id = "sonarr:1:2",
|
||||
name = "Marvel's DAREDEVIL",
|
||||
type = "MembySonarrEpisode",
|
||||
membySource = "sonarr",
|
||||
),
|
||||
),
|
||||
),
|
||||
HomeRow(
|
||||
id = "recommended",
|
||||
title = "Recommended",
|
||||
items = listOf(
|
||||
BaseItem(id = "series-1", name = "Marvels Daredevil", type = "Series"),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
assertTrue(home.withAiringTodayTags().rows.last().items.single().membyAiringToday)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `does not tag movies with a matching title`() {
|
||||
val home = HomeSnapshot(
|
||||
rows = listOf(
|
||||
HomeRow(
|
||||
id = "sonarr-airing-today",
|
||||
title = "Shows airing today",
|
||||
items = listOf(
|
||||
BaseItem(
|
||||
id = "sonarr:1:2",
|
||||
name = "Fargo",
|
||||
type = "MembySonarrEpisode",
|
||||
membySource = "sonarr",
|
||||
),
|
||||
),
|
||||
),
|
||||
HomeRow(
|
||||
id = "recommended",
|
||||
title = "Recommended",
|
||||
items = listOf(BaseItem(id = "movie-1", name = "Fargo", type = "Movie")),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
assertFalse(home.withAiringTodayTags().rows.last().items.single().membyAiringToday)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package com.ponzischeme89.memby.ui
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotSame
|
||||
import org.junit.Assert.assertSame
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class ProfileViewModelStoreOwnerTest {
|
||||
private val factory = object : ViewModelProvider.Factory {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T =
|
||||
ProbeViewModel() as T
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `profiles have isolated view models and clearing cancels the old one`() {
|
||||
val mattOwner = ProfileViewModelStoreOwner()
|
||||
val familyOwner = ProfileViewModelStoreOwner()
|
||||
|
||||
val matt = ViewModelProvider(mattOwner, factory)[ProbeViewModel::class.java]
|
||||
val mattAgain = ViewModelProvider(mattOwner, factory)[ProbeViewModel::class.java]
|
||||
val family = ViewModelProvider(familyOwner, factory)[ProbeViewModel::class.java]
|
||||
|
||||
assertSame(matt, mattAgain)
|
||||
assertNotSame(matt, family)
|
||||
assertFalse(matt.cleared)
|
||||
assertFalse(family.cleared)
|
||||
|
||||
mattOwner.clear()
|
||||
|
||||
assertTrue(matt.cleared)
|
||||
assertFalse(family.cleared)
|
||||
}
|
||||
|
||||
private class ProbeViewModel : ViewModel() {
|
||||
var cleared = false
|
||||
private set
|
||||
|
||||
override fun onCleared() {
|
||||
cleared = true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package com.ponzischeme89.memby.ui
|
||||
|
||||
import com.ponzischeme89.memby.data.model.BaseItem
|
||||
import com.ponzischeme89.memby.data.model.UserItemData
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class SeriesDetailsTest {
|
||||
private fun episode(
|
||||
id: String,
|
||||
season: Int,
|
||||
number: Int,
|
||||
played: Boolean = false,
|
||||
) = BaseItem(
|
||||
id = id,
|
||||
name = "Episode $number",
|
||||
type = "Episode",
|
||||
parentIndexNumber = season,
|
||||
indexNumber = number,
|
||||
userData = UserItemData(played = played),
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `seasons are distinct and ordered with specials first`() {
|
||||
val episodes = listOf(
|
||||
episode("s2e1", 2, 1),
|
||||
episode("s0e1", 0, 1),
|
||||
episode("s1e1", 1, 1),
|
||||
episode("s2e2", 2, 2),
|
||||
)
|
||||
|
||||
assertEquals(listOf(0, 1, 2), availableSeasons(episodes))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `season list is ordered by episode number`() {
|
||||
val episodes = listOf(
|
||||
episode("e3", 1, 3),
|
||||
episode("e1", 1, 1),
|
||||
episode("e2", 1, 2),
|
||||
)
|
||||
|
||||
assertEquals(listOf("e1", "e2", "e3"), episodesForSeason(episodes, 1).map(BaseItem::id))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `default season contains the first unwatched episode`() {
|
||||
val episodes = listOf(
|
||||
episode("s1e1", 1, 1, played = true),
|
||||
episode("s1e2", 1, 2, played = true),
|
||||
episode("s2e1", 2, 1),
|
||||
)
|
||||
|
||||
assertEquals(2, defaultSeason(episodes))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `detail tabs default safely to episodes`() {
|
||||
assertEquals(SeriesDetailSection.EPISODES, seriesDetailSection("episodes"))
|
||||
assertEquals(SeriesDetailSection.CAST, seriesDetailSection("cast"))
|
||||
assertEquals(SeriesDetailSection.EPISODES, seriesDetailSection("future-section"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package com.ponzischeme89.memby.ui
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.compose.ui.test.onRoot
|
||||
import com.github.takahirom.roborazzi.captureRoboImage
|
||||
import com.ponzischeme89.memby.data.ServiceAlert
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
import org.robolectric.annotation.GraphicsMode
|
||||
|
||||
/**
|
||||
* Renders the aired banner to PNGs under `build/screenshots/`, so its layout can be
|
||||
* looked at without deploying to a TV.
|
||||
*
|
||||
* ```powershell
|
||||
* .\gradlew.bat :app:testDebugUnitTest --tests "*ServiceAlertBannerScreenshotTest"
|
||||
* ```
|
||||
*
|
||||
* This is the **only** part of `app/src/test` that touches Android — rendering a
|
||||
* composable genuinely cannot be done in plain JUnit, so Robolectric is confined to files
|
||||
* named `*ScreenshotTest.kt`. Logic tests stay pure; keep them that way.
|
||||
*
|
||||
* Qualifiers describe a 1080p TV: 960x540dp at xhdpi. The bar is drawn on the launcher's
|
||||
* own background, flush to the top edge exactly as `MainActivity` places it.
|
||||
*
|
||||
* [AlertBanner] is rendered directly rather than [ServiceAlertBanner]: the wrapper's whole
|
||||
* job is the drop-in from above, and a still frame of an animation says nothing. Posters
|
||||
* are null because there is no network here — which makes these the check on the fallback
|
||||
* tile too.
|
||||
*/
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||
@Config(sdk = [34], qualifiers = "w960dp-h540dp-television-xhdpi")
|
||||
class ServiceAlertBannerScreenshotTest {
|
||||
|
||||
@get:Rule
|
||||
val compose = createComposeRule()
|
||||
|
||||
@Test
|
||||
fun `awaiting download`() {
|
||||
capture(
|
||||
"alert-banner-awaiting",
|
||||
ServiceAlert(
|
||||
id = "sonarr:7:42:aired",
|
||||
title = "Northbound",
|
||||
message = "S02E04 — The Crossing aired at 9:00 PM and will be in Emby soon.",
|
||||
posterUrl = null,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `downloading now`() {
|
||||
capture(
|
||||
"alert-banner-downloading",
|
||||
ServiceAlert(
|
||||
id = "sonarr:8:43:aired",
|
||||
title = "The Long Dark",
|
||||
message = "S01E09 — Winterlight aired at 8:30 PM and is downloading now.",
|
||||
posterUrl = null,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/** Both lines ellipsise at one line; this is the case that proves it. */
|
||||
@Test
|
||||
fun `long title and message`() {
|
||||
capture(
|
||||
"alert-banner-long-text",
|
||||
ServiceAlert(
|
||||
id = "sonarr:9:88:aired",
|
||||
title = "A Very Long Programme Title That Will Not Fit In One Line",
|
||||
message = "S11E03 — The One Where Absolutely Everything Happens At Once " +
|
||||
"And Then Some More Happens After That aired at 10:30 PM and is " +
|
||||
"downloading now.",
|
||||
posterUrl = null,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/** Shortest plausible content: the bar keeps its height and the text stays put. */
|
||||
@Test
|
||||
fun `short title and message`() {
|
||||
capture(
|
||||
"alert-banner-short-text",
|
||||
ServiceAlert(
|
||||
id = "sonarr:3:12:aired",
|
||||
title = "Dune",
|
||||
message = "S01E01 aired and will be in Emby soon.",
|
||||
posterUrl = null,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The countdown ring is full at t=0, which tells you nothing about whether it
|
||||
* empties. Holding the clock and stepping it forward captures it mid-sweep.
|
||||
*/
|
||||
@Test
|
||||
fun `countdown part way through`() {
|
||||
compose.mainClock.autoAdvance = false
|
||||
compose.setContent {
|
||||
AlertBannerOnHomeBackground(
|
||||
ServiceAlert(
|
||||
id = "sonarr:7:42:aired",
|
||||
title = "Northbound",
|
||||
message = "S02E04 — The Crossing aired at 9:00 PM and will be in Emby soon.",
|
||||
posterUrl = null,
|
||||
),
|
||||
)
|
||||
}
|
||||
compose.mainClock.advanceTimeBy(6_500L)
|
||||
compose.onRoot().captureRoboImage("build/screenshots/alert-banner-countdown.png")
|
||||
}
|
||||
|
||||
private fun capture(name: String, alert: ServiceAlert) {
|
||||
compose.setContent { AlertBannerOnHomeBackground(alert) }
|
||||
compose.onRoot().captureRoboImage("build/screenshots/$name.png")
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AlertBannerOnHomeBackground(alert: ServiceAlert) {
|
||||
PreviewSurface(alignment = Alignment.TopCenter) {
|
||||
AlertBanner(alert)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.ponzischeme89.memby.ui.player
|
||||
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class PictureSizingTest {
|
||||
|
||||
@Test
|
||||
fun `auto fills classic four by three video`() {
|
||||
assertTrue(shouldZoomVideo("auto", 640, 480, 1f))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `auto accounts for anamorphic pixel aspect ratio`() {
|
||||
assertTrue(shouldZoomVideo("auto", 720, 480, 8f / 9f))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `auto preserves modern widescreen video`() {
|
||||
assertFalse(shouldZoomVideo("auto", 1920, 1080, 1f))
|
||||
assertFalse(shouldZoomVideo("auto", 1920, 800, 1f))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `manual modes are deterministic`() {
|
||||
assertFalse(shouldZoomVideo("original", 640, 480, 1f))
|
||||
assertTrue(shouldZoomVideo("fill", 1920, 1080, 1f))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package com.ponzischeme89.memby.ui.player
|
||||
|
||||
import androidx.media3.common.PlaybackException
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class PlaybackRecoveryTest {
|
||||
@Test
|
||||
fun networkFailuresAreSafeToRetry() {
|
||||
val failure = describePlaybackFailure(
|
||||
PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED,
|
||||
)
|
||||
|
||||
assertEquals("Connection interrupted", failure.title)
|
||||
assertTrue(failure.canAutoRetry)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun decoderFailuresRequireViewerAction() {
|
||||
val failure = describePlaybackFailure(
|
||||
PlaybackException.ERROR_CODE_DECODING_FORMAT_UNSUPPORTED,
|
||||
)
|
||||
|
||||
assertEquals("Video format not supported", failure.title)
|
||||
assertFalse(failure.canAutoRetry)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun automaticRetriesAreBoundedAndBackOff() {
|
||||
assertEquals(1_000L, automaticRetryDelayMs(1))
|
||||
assertEquals(3_000L, automaticRetryDelayMs(2))
|
||||
assertNull(automaticRetryDelayMs(3))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.ponzischeme89.memby.ui.player
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class PlayerTimingTest {
|
||||
@Test
|
||||
fun formatsRemainingTimeForViewerFriendlyDisplay() {
|
||||
assertEquals("Less than a minute remaining", PlayerActivity.formatRemaining(30_000L))
|
||||
assertEquals("42 min remaining", PlayerActivity.formatRemaining(42L * 60_000L))
|
||||
assertEquals("1 hr remaining", PlayerActivity.formatRemaining(60L * 60_000L))
|
||||
assertEquals("2 hr 5 min remaining", PlayerActivity.formatRemaining(125L * 60_000L))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package com.ponzischeme89.memby.ui.player
|
||||
|
||||
import android.content.Context
|
||||
import android.view.LayoutInflater
|
||||
import android.widget.FrameLayout
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import com.ponzischeme89.memby.R
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34])
|
||||
class PrerollLayoutTest {
|
||||
|
||||
@Test
|
||||
fun `player preroll and its background inflate`() {
|
||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||
|
||||
assertNotNull(context.getDrawable(R.drawable.player_preroll_video_background))
|
||||
assertNotNull(
|
||||
LayoutInflater.from(context).inflate(
|
||||
R.layout.player_preroll,
|
||||
FrameLayout(context),
|
||||
false,
|
||||
),
|
||||
)
|
||||
assertNotNull(
|
||||
LayoutInflater.from(context).inflate(
|
||||
R.layout.player_cast_overlay,
|
||||
FrameLayout(context),
|
||||
false,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.ponzischeme89.memby.ui.player
|
||||
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class PrerollSequenceTest {
|
||||
@Test
|
||||
fun `handoff waits only for the five second gate`() {
|
||||
assertFalse(prerollCanHandOff(true, false, true))
|
||||
assertTrue(prerollCanHandOff(true, true, true))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `handoff never starts playback in background`() {
|
||||
assertFalse(prerollCanHandOff(true, true, false))
|
||||
assertFalse(prerollCanHandOff(false, true, true))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package com.ponzischeme89.memby.ui.search
|
||||
|
||||
import com.ponzischeme89.memby.data.model.BaseItem
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class SearchRankingTest {
|
||||
|
||||
private fun item(name: String, series: String? = null, id: String = name) =
|
||||
BaseItem(id = id, name = name, seriesName = series)
|
||||
|
||||
private fun names(items: List<BaseItem>) = items.map { it.name }
|
||||
|
||||
@Test
|
||||
fun `an exact title beats a title that merely starts with the query`() {
|
||||
val ranked = rankSearchResults(
|
||||
"dune",
|
||||
listOf(item("Dune: Part Two"), item("Dunes of Mars"), item("Dune")),
|
||||
)
|
||||
assertEquals("Dune", ranked.first().name)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `titles starting with the query come before mid-word matches`() {
|
||||
val ranked = rankSearchResults(
|
||||
"star",
|
||||
listOf(item("Costar"), item("Lone Star"), item("Stargate")),
|
||||
)
|
||||
// Prefix first, then the word-boundary match, then the buried one.
|
||||
assertEquals(listOf("Stargate", "Lone Star", "Costar"), names(ranked))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an episode is found by its series name when its own title does not match`() {
|
||||
val ranked = rankSearchResults(
|
||||
"severance",
|
||||
listOf(item("Defiant Jazz", series = "Severance"), item("Severed Ties")),
|
||||
)
|
||||
assertEquals("Defiant Jazz", ranked.first().name)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `weaker matches are kept rather than dropped`() {
|
||||
// A genre or overview match the backend returned: last, but still there, because
|
||||
// "no exact match but here is something related" beats an empty pane.
|
||||
val ranked = rankSearchResults("comedy", listOf(item("Some Unrelated Title")))
|
||||
assertEquals(1, ranked.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ranking is stable so the backend's own relevance order survives within a tier`() {
|
||||
val backendOrder = listOf(item("Alien 3"), item("Alien"), item("Aliens"))
|
||||
val ranked = rankSearchResults("alien", backendOrder)
|
||||
// "Alien" is the exact match and is lifted; the other two keep the order the
|
||||
// server chose rather than being re-sorted alphabetically.
|
||||
assertEquals(listOf("Alien", "Alien 3", "Aliens"), names(ranked))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an empty query leaves the list untouched`() {
|
||||
val items = listOf(item("B"), item("A"))
|
||||
assertEquals(items, rankSearchResults(" ", items))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `searching starts at two characters`() {
|
||||
assertFalse(shouldSearch(""))
|
||||
assertFalse(shouldSearch("a"))
|
||||
assertFalse(shouldSearch(" a "))
|
||||
assertTrue(shouldSearch("ab"))
|
||||
assertTrue(shouldSearch(" the wire "))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package com.ponzischeme89.memby.ui.settings
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.compose.ui.test.onRoot
|
||||
import com.github.takahirom.roborazzi.captureRoboImage
|
||||
import com.ponzischeme89.memby.ui.PreviewSurface
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
import org.robolectric.annotation.GraphicsMode
|
||||
|
||||
/**
|
||||
* Captures the real stateless Settings panel at TV size. The fixture deliberately mixes
|
||||
* enabled and disabled values so the screenshot proves state is legible without focus;
|
||||
* the first row also owns focus to prove the white focus ring and green active state are
|
||||
* visually distinct.
|
||||
*/
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||
@Config(sdk = [34], qualifiers = "w960dp-h540dp-television-xhdpi")
|
||||
class SettingsSheetScreenshotTest {
|
||||
|
||||
@get:Rule
|
||||
val compose = createComposeRule()
|
||||
|
||||
@Test
|
||||
fun `active inactive and focused controls`() {
|
||||
compose.setContent { SettingsPreviewFixture(overlay = false) }
|
||||
compose.onRoot().captureRoboImage("build/screenshots/settings-panel.png")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `home overlay width`() {
|
||||
compose.setContent { SettingsPreviewFixture(overlay = true) }
|
||||
compose.onRoot().captureRoboImage("build/screenshots/settings-overlay.png")
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SettingsPreviewFixture(overlay: Boolean) {
|
||||
val firstFocus = remember { FocusRequester() }
|
||||
LaunchedEffect(Unit) { firstFocus.requestFocus() }
|
||||
PreviewSurface(alignment = if (overlay) Alignment.CenterEnd else Alignment.Center) {
|
||||
SettingsPanelContent(
|
||||
state = SettingsPanelState(
|
||||
showLogo = true,
|
||||
autoPlayNext = false,
|
||||
ringColor = "52B54B",
|
||||
homeSections = setOf("continue", "latest"),
|
||||
cardDensity = "standard",
|
||||
showCardMetadata = false,
|
||||
editableServer = true,
|
||||
baseUrl = "https://mserver.example/releases/latest.json",
|
||||
installedVersion = "0.1.60",
|
||||
),
|
||||
actions = SettingsPanelActions(),
|
||||
overlay = overlay,
|
||||
firstFocusRequester = firstFocus,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user