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) }