Big changes

This commit is contained in:
ponzischeme89
2026-07-29 15:26:55 +12:00
parent 70914400b4
commit a265636139
73 changed files with 9593 additions and 0 deletions
@@ -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 couldnt 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 couldnt 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 couldnt 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 couldnt 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>
+161
View File
@@ -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>
+118
View File
@@ -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>
+19
View File
@@ -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,
)
}
}
}