diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml new file mode 100644 index 0000000..9818763 --- /dev/null +++ b/.gitea/workflows/release.yml @@ -0,0 +1,72 @@ +name: Publish Memby release + +on: + push: + tags: + - "v*.*.*" + +permissions: + contents: read + +jobs: + release: + runs-on: ubuntu-latest + steps: + - name: Check out source + uses: actions/checkout@v4 + + - name: Set up Java 17 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "17" + + - name: Set up Android SDK + uses: android-actions/setup-android@v3 + + - name: Install Android 35 build tools + shell: bash + run: sdkmanager "platforms;android-35" "build-tools;35.0.0" + + - name: Read version from tag + shell: bash + run: | + VERSION="${GITEA_REF_NAME#v}" + if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "Tag must look like v0.1.54" >&2 + exit 1 + fi + echo "VERSION=$VERSION" >> "$GITEA_ENV" + + - name: Restore release keystore + shell: bash + env: + KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }} + run: | + printf '%s' "$KEYSTORE_BASE64" | base64 --decode > "$GITEA_WORKSPACE/memby-release.jks" + + - name: Test and build signed APK + shell: bash + env: + MEMBY_KEYSTORE: ${{ gitea.workspace }}/memby-release.jks + MEMBY_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} + MEMBY_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }} + MEMBY_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }} + run: | + chmod +x ./gradlew + ./gradlew --console=plain testDebugUnitTest assembleRelease \ + -Pmemby.versionName="$VERSION" + + - name: Publish to Memby gateway + shell: bash + env: + RELEASE_TOKEN: ${{ secrets.MEMBY_RELEASE_PUBLISH_TOKEN }} + run: | + NOTES="$(git log -1 --pretty=%B)" + curl --fail-with-body --show-error --silent \ + -X POST \ + -H "Authorization: Bearer $RELEASE_TOKEN" \ + -F "version=$VERSION" \ + -F "notes=$NOTES" \ + -F "apk=@app/build/outputs/apk/release/app-release.apk;type=application/vnd.android.package-archive" \ + https://mserver.sublogue.com/admin/api/release diff --git a/app/src/main/java/com/ponzischeme89/memby/data/MaintenanceMonitor.kt b/app/src/main/java/com/ponzischeme89/memby/data/MaintenanceMonitor.kt new file mode 100644 index 0000000..f610880 --- /dev/null +++ b/app/src/main/java/com/ponzischeme89/memby/data/MaintenanceMonitor.kt @@ -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(null) + val notice: StateFlow = _notice.asStateFlow() + + private val _compatibility = MutableStateFlow(null) + val compatibility: StateFlow = _compatibility.asStateFlow() + + private val _alert = MutableStateFlow(null) + val alert: StateFlow = _alert.asStateFlow() + + private val seenAlertIds = mutableSetOf() + 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) { + 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, +): 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, seen: Set): GatewayAlert? = + alerts.firstOrNull { alert -> + alert.id.isNotBlank() && + alert.id !in seen && + alert.title.isNotBlank() && + alert.message.isNotBlank() + } diff --git a/app/src/main/java/com/ponzischeme89/memby/data/SubtitleSupport.kt b/app/src/main/java/com/ponzischeme89/memby/data/SubtitleSupport.kt new file mode 100644 index 0000000..d312a2e --- /dev/null +++ b/app/src/main/java/com/ponzischeme89/memby/data/SubtitleSupport.kt @@ -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, + serverUrl: String, + token: String, + itemId: String, + mediaSourceId: String, +): List = 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") +} diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/PreviewSupport.kt b/app/src/main/java/com/ponzischeme89/memby/ui/PreviewSupport.kt new file mode 100644 index 0000000..d8588b5 --- /dev/null +++ b/app/src/main/java/com/ponzischeme89/memby/ui/PreviewSupport.kt @@ -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() + } + } +} diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/ProfileViewModelStoreOwner.kt b/app/src/main/java/com/ponzischeme89/memby/ui/ProfileViewModelStoreOwner.kt new file mode 100644 index 0000000..f571eaf --- /dev/null +++ b/app/src/main/java/com/ponzischeme89/memby/ui/ProfileViewModelStoreOwner.kt @@ -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() + } +} diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/SeriesDetailsOverlay.kt b/app/src/main/java/com/ponzischeme89/memby/ui/SeriesDetailsOverlay.kt new file mode 100644 index 0000000..dce8efb --- /dev/null +++ b/app/src/main/java/com/ponzischeme89/memby/ui/SeriesDetailsOverlay.kt @@ -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?>(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(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({ it.parentIndexNumber ?: Int.MAX_VALUE }, { it.indexNumber ?: Int.MAX_VALUE }, { it.name }) + +internal fun availableSeasons(episodes: List): List = + episodes.mapNotNull(BaseItem::parentIndexNumber).distinct().sorted() + +internal fun episodesForSeason(episodes: List, season: Int?): List = + if (season == null) emptyList() + else episodes.filter { it.parentIndexNumber == season }.sortedWith(seriesEpisodeComparator) + +internal fun defaultSeason(episodes: List): Int? = + episodes.firstOrNull { + it.userData?.played != true || ((it.userData?.playbackPositionTicks ?: 0L) > 0L) + }?.parentIndexNumber ?: availableSeasons(episodes).firstOrNull() diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/ServiceAlertBanner.kt b/app/src/main/java/com/ponzischeme89/memby/ui/ServiceAlertBanner.kt new file mode 100644 index 0000000..51b8bd3 --- /dev/null +++ b/app/src/main/java/com/ponzischeme89/memby/ui/ServiceAlertBanner.kt @@ -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(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) { + 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, + ), + ) + } +} diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/detail/DetailFacts.kt b/app/src/main/java/com/ponzischeme89/memby/ui/detail/DetailFacts.kt new file mode 100644 index 0000000..9c078b9 --- /dev/null +++ b/app/src/main/java/com/ponzischeme89/memby/ui/detail/DetailFacts.kt @@ -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 = 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 = 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 { + 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::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::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::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( + { 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): List = + episodes.mapNotNull(BaseItem::parentIndexNumber).distinct().sorted() + +fun episodesForSeason(episodes: List, season: Int?): List = + 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? { + 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): Int? = + nextEpisodeToWatch(episodes)?.parentIndexNumber ?: availableSeasons(episodes).firstOrNull() + +/** How many episodes are still unwatched, for the series badge. */ +fun unwatchedCount(episodes: List): 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" } +} diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/player/PlaybackRecovery.kt b/app/src/main/java/com/ponzischeme89/memby/ui/player/PlaybackRecovery.kt new file mode 100644 index 0000000..9a522ed --- /dev/null +++ b/app/src/main/java/com/ponzischeme89/memby/ui/player/PlaybackRecovery.kt @@ -0,0 +1,74 @@ +package com.ponzischeme89.memby.ui.player + +import androidx.media3.common.PlaybackException + +/** + * A viewer-facing description of a Media3 failure. Keep this independent from Activity + * state so the retry decision remains deterministic and unit-testable. + */ +internal data class PlaybackFailure( + val title: String, + val detail: String, + val canAutoRetry: Boolean, +) + +internal fun describePlaybackFailure(errorCode: Int): PlaybackFailure = + when (errorCode) { + PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED, + PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_TIMEOUT, + PlaybackException.ERROR_CODE_IO_UNSPECIFIED, + -> PlaybackFailure( + title = "Connection interrupted", + detail = "Memby couldn’t keep a reliable connection to the media server.", + canAutoRetry = true, + ) + + PlaybackException.ERROR_CODE_IO_BAD_HTTP_STATUS, + PlaybackException.ERROR_CODE_IO_FILE_NOT_FOUND, + -> PlaybackFailure( + title = "Video unavailable", + detail = "The media server couldn’t provide this video. It may have moved or be temporarily unavailable.", + canAutoRetry = false, + ) + + PlaybackException.ERROR_CODE_IO_CLEARTEXT_NOT_PERMITTED, + PlaybackException.ERROR_CODE_IO_NO_PERMISSION, + -> PlaybackFailure( + title = "Playback blocked", + detail = "The TV is not permitted to open this stream. Check the server address and access settings.", + canAutoRetry = false, + ) + + PlaybackException.ERROR_CODE_DECODER_INIT_FAILED, + PlaybackException.ERROR_CODE_DECODING_FAILED, + PlaybackException.ERROR_CODE_DECODING_FORMAT_UNSUPPORTED, + PlaybackException.ERROR_CODE_DECODING_FORMAT_EXCEEDS_CAPABILITIES, + -> PlaybackFailure( + title = "Video format not supported", + detail = "This TV couldn’t decode the selected video or audio track. Try another track or a transcoded version.", + canAutoRetry = false, + ) + + PlaybackException.ERROR_CODE_PARSING_CONTAINER_MALFORMED, + PlaybackException.ERROR_CODE_PARSING_CONTAINER_UNSUPPORTED, + PlaybackException.ERROR_CODE_PARSING_MANIFEST_MALFORMED, + PlaybackException.ERROR_CODE_PARSING_MANIFEST_UNSUPPORTED, + -> PlaybackFailure( + title = "Video file couldn’t be read", + detail = "The stream format is damaged or unsupported by this TV.", + canAutoRetry = false, + ) + + else -> PlaybackFailure( + title = "Playback stopped", + detail = "Memby hit an unexpected playback problem.", + canAutoRetry = false, + ) + } + +internal fun automaticRetryDelayMs(attempt: Int): Long? = + when (attempt) { + 1 -> 1_000L + 2 -> 3_000L + else -> null + } diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/player/PlaybackStopWorker.kt b/app/src/main/java/com/ponzischeme89/memby/ui/player/PlaybackStopWorker.kt new file mode 100644 index 0000000..ecb3850 --- /dev/null +++ b/app/src/main/java/com/ponzischeme89/memby/ui/player/PlaybackStopWorker.kt @@ -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() + .setInputData(data) + .build() + val key = session.playSessionId.ifBlank { session.itemId } + WorkManager.getInstance(context.applicationContext).enqueueUniqueWork( + "emby-playback-stop-$key", + ExistingWorkPolicy.REPLACE, + request, + ) + } + } +} diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/search/SearchScreen.kt b/app/src/main/java/com/ponzischeme89/memby/ui/search/SearchScreen.kt new file mode 100644 index 0000000..9ef088c --- /dev/null +++ b/app/src/main/java/com/ponzischeme89/memby/ui/search/SearchScreen.kt @@ -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, + 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, + 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, + 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, + 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, + 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 diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/search/SearchViewModel.kt b/app/src/main/java/com/ponzischeme89/memby/ui/search/SearchViewModel.kt new file mode 100644 index 0000000..d1af0aa --- /dev/null +++ b/app/src/main/java/com/ponzischeme89/memby/ui/search/SearchViewModel.kt @@ -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 = emptyList(), + val suggestions: List = 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 = _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>(16, 0.75f, true) { + override fun removeEldestEntry( + eldest: MutableMap.MutableEntry>?, + ): Boolean = size > CACHE_ENTRIES + } + + private val recentQueries = ArrayDeque() + private var genreSuggestions: List = 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) { + 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 create(modelClass: Class): 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): List { + 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) } diff --git a/app/src/main/res/color/next_up_button_text.xml b/app/src/main/res/color/next_up_button_text.xml new file mode 100644 index 0000000..efaf9fa --- /dev/null +++ b/app/src/main/res/color/next_up_button_text.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/src/main/res/color/player_control_tint.xml b/app/src/main/res/color/player_control_tint.xml new file mode 100644 index 0000000..a8e6d04 --- /dev/null +++ b/app/src/main/res/color/player_control_tint.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/color/player_overlay_option_text.xml b/app/src/main/res/color/player_overlay_option_text.xml new file mode 100644 index 0000000..a8e6d04 --- /dev/null +++ b/app/src/main/res/color/player_overlay_option_text.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_player_back.xml b/app/src/main/res/drawable/ic_player_back.xml new file mode 100644 index 0000000..2e4c1bf --- /dev/null +++ b/app/src/main/res/drawable/ic_player_back.xml @@ -0,0 +1,10 @@ + + + + diff --git a/app/src/main/res/drawable/ic_player_hide.xml b/app/src/main/res/drawable/ic_player_hide.xml new file mode 100644 index 0000000..cdc39d5 --- /dev/null +++ b/app/src/main/res/drawable/ic_player_hide.xml @@ -0,0 +1,10 @@ + + + + diff --git a/app/src/main/res/drawable/next_up_banner_background.xml b/app/src/main/res/drawable/next_up_banner_background.xml new file mode 100644 index 0000000..1e64680 --- /dev/null +++ b/app/src/main/res/drawable/next_up_banner_background.xml @@ -0,0 +1,9 @@ + + + + + + diff --git a/app/src/main/res/drawable/next_up_primary_button.xml b/app/src/main/res/drawable/next_up_primary_button.xml new file mode 100644 index 0000000..17cd8b1 --- /dev/null +++ b/app/src/main/res/drawable/next_up_primary_button.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/next_up_secondary_button.xml b/app/src/main/res/drawable/next_up_secondary_button.xml new file mode 100644 index 0000000..16bca8b --- /dev/null +++ b/app/src/main/res/drawable/next_up_secondary_button.xml @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/player_cast_portrait_background.xml b/app/src/main/res/drawable/player_cast_portrait_background.xml new file mode 100644 index 0000000..a5677e8 --- /dev/null +++ b/app/src/main/res/drawable/player_cast_portrait_background.xml @@ -0,0 +1,9 @@ + + + + + + diff --git a/app/src/main/res/drawable/player_control_button_background.xml b/app/src/main/res/drawable/player_control_button_background.xml new file mode 100644 index 0000000..c4fd8ab --- /dev/null +++ b/app/src/main/res/drawable/player_control_button_background.xml @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/player_osd_bottom_gradient.xml b/app/src/main/res/drawable/player_osd_bottom_gradient.xml new file mode 100644 index 0000000..fece60d --- /dev/null +++ b/app/src/main/res/drawable/player_osd_bottom_gradient.xml @@ -0,0 +1,8 @@ + + + + diff --git a/app/src/main/res/drawable/player_osd_top_gradient.xml b/app/src/main/res/drawable/player_osd_top_gradient.xml new file mode 100644 index 0000000..03e6f76 --- /dev/null +++ b/app/src/main/res/drawable/player_osd_top_gradient.xml @@ -0,0 +1,8 @@ + + + + diff --git a/app/src/main/res/drawable/player_overlay_option_background.xml b/app/src/main/res/drawable/player_overlay_option_background.xml new file mode 100644 index 0000000..14f4bb7 --- /dev/null +++ b/app/src/main/res/drawable/player_overlay_option_background.xml @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/player_overlay_panel_background.xml b/app/src/main/res/drawable/player_overlay_panel_background.xml new file mode 100644 index 0000000..313c21f --- /dev/null +++ b/app/src/main/res/drawable/player_overlay_panel_background.xml @@ -0,0 +1,11 @@ + + + + + + diff --git a/app/src/main/res/drawable/player_preroll_video_background.xml b/app/src/main/res/drawable/player_preroll_video_background.xml new file mode 100644 index 0000000..5fb52a5 --- /dev/null +++ b/app/src/main/res/drawable/player_preroll_video_background.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/app/src/main/res/drawable/player_status_background.xml b/app/src/main/res/drawable/player_status_background.xml new file mode 100644 index 0000000..24dbccb --- /dev/null +++ b/app/src/main/res/drawable/player_status_background.xml @@ -0,0 +1,9 @@ + + + + + + diff --git a/app/src/main/res/layout/activity_player.xml b/app/src/main/res/layout/activity_player.xml new file mode 100644 index 0000000..667505b --- /dev/null +++ b/app/src/main/res/layout/activity_player.xml @@ -0,0 +1,161 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +