From 5e5849f985607b9de950a17458680f9c2ecafc11 Mon Sep 17 00:00:00 2001 From: ponzischeme89 Date: Tue, 11 Aug 2026 15:18:26 +1200 Subject: [PATCH] 0.2.50 --- CHANGELOG.md | 20 + app/build.gradle.kts | 2 +- .../memby/data/EmbyRepository.kt | 62 +- .../memby/data/MaintenanceMonitor.kt | 12 +- .../memby/data/PreferencesSync.kt | 41 +- .../com/ponzischeme89/memby/data/ThemeSync.kt | 28 +- .../memby/data/model/EmbyModels.kt | 4 +- .../memby/data/model/GatewayModels.kt | 14 +- .../memby/ui/EpisodeDetailsOverlay.kt | 22 +- .../ponzischeme89/memby/ui/HomeViewModel.kt | 54 +- .../com/ponzischeme89/memby/ui/ListKeys.kt | 5 +- .../ponzischeme89/memby/ui/MainActivity.kt | 124 +++- .../memby/ui/MyShowsNotifications.kt | 29 +- .../memby/ui/SeriesDetailsOverlay.kt | 15 +- .../memby/ui/alerts/AlertsPage.kt | 19 +- .../memby/ui/genre/GenreBrowseViewModel.kt | 1 + .../memby/ui/player/PlaybackRecovery.kt | 1 - .../memby/ui/player/PlaybackStopWorker.kt | 16 +- .../memby/ui/player/PlayerActivity.kt | 600 ++++++++++++++---- .../memby/ui/player/PrerollPreloader.kt | 6 +- .../ui/screensaver/ScreensaverContent.kt | 39 +- .../memby/ui/search/SearchScreen.kt | 18 + .../memby/ui/search/SearchViewModel.kt | 38 +- .../memby/data/GatewayPayloadTest.kt | 20 +- .../ponzischeme89/memby/ui/ListKeysTest.kt | 8 + .../memby/ui/player/PlaybackRecoveryTest.kt | 23 + .../memby/ui/player/PlayerTimingTest.kt | 8 + server/internal/api/api.go | 8 + server/internal/api/api_test.go | 35 + server/internal/api/playback.go | 57 +- server/internal/api/playback_report_test.go | 39 ++ server/internal/api/requests.go | 7 +- server/internal/emby/client.go | 9 +- 33 files changed, 1143 insertions(+), 241 deletions(-) create mode 100644 server/internal/api/playback_report_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 64cfc09..3da59cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,23 @@ +## 0.2.50 — 2026-08-11 +- Fixed: Playback reports are now serialised, and final stop positions are protected from out-of-order requests. +- Fixed: Home, preferences, themes and detail screens now recover automatically with bounded retries. +- Fixed: Retries can no longer cause request storms, duplicate media requests or repeated alert actions. +- Fixed: Useful cached content remains visible when refreshes or later result pages fail. +- Fixed: Network requests now have explicit loading, success, empty and failure states, with retry actions where appropriate. +- Fixed: HTTP 429 responses now respect the server's Retry-After delay. +- Fixed: Malformed and partially populated objects are safely filtered before reaching the interface. +- Fixed: Rate limits, partial payloads and durable playback stops now have regression coverage. +- Fixed: Resuming playback now cancels delayed background stop work before reporting the session as active again. +- Fixed: Refreshed streams and subtitle-driven stream changes now close superseded playback sessions and start their replacements in order. +- Fixed: Cancelled or stale stream, retry and subtitle requests can no longer overwrite a newer playback attempt. +- Fixed: Enabling burned-in subtitles no longer lets the hidden video continue and then jumps back to an earlier position when the stream is ready. +- Fixed: Playback now waits for a slow next-episode lookup, and auto-advance resets subtitle, preview, intro, credits, artwork and session state for the new episode. +- Fixed: Player construction and synchronous media preparation failures now show recoverable Retry and Exit actions instead of closing the player. +- Fixed: The pre-roll player remains idle while the main programme is playing, avoiding a second prepared decoder after hand-off. +- Fixed: Repeated screensaver Play presses are gated, and a new single-task player intent now deliberately replaces the current playback. +- Fixed: Playback position, stream identity, selected tracks and episode details now survive activity and process recreation. +- Fixed: Natural playback completion now returns to the screen that launched the player instead of clearing the task back to Home. + ## 0.2.49 — 2026-08-11 - Fixed: D-pad focus now stays visible and predictable across My Shows, Settings, profile management, the TV calendar, detail pages and changing home rows. diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 140e3b2..723ec90 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -42,7 +42,7 @@ val projectNoticeText = // A release workflow can derive the app version from its Git tag without editing the // source tree. Local builds keep using the checked-in default. -val defaultVersionName = "0.2.49" +val defaultVersionName = "0.2.50" val membyVersionName: String = (project.findProperty("memby.versionName") as String?) ?.trim() diff --git a/app/src/main/java/com/ponzischeme89/memby/data/EmbyRepository.kt b/app/src/main/java/com/ponzischeme89/memby/data/EmbyRepository.kt index 48d5fc7..5c5c276 100644 --- a/app/src/main/java/com/ponzischeme89/memby/data/EmbyRepository.kt +++ b/app/src/main/java/com/ponzischeme89/memby/data/EmbyRepository.kt @@ -74,6 +74,9 @@ data class NextEpisode( val seriesName: String, val episodeCode: String?, val imageUrl: String?, + val logoUrl: String? = null, + val overview: String = "", + val runtimeMs: Long = 0L, val url: String, val resumePositionMs: Long = 0L, val subtitles: List = emptyList(), @@ -82,6 +85,10 @@ data class NextEpisode( val mediaSourceId: String = "", val playSessionId: String = "", val playMethod: String = "DirectPlay", + val subtitleDownloadAvailable: Boolean = false, + val trickplayAvailable: Boolean = false, + val skipIntroAvailable: Boolean = false, + val endCreditsAvailable: Boolean = false, ) /** A resolved, directly playable stream. */ @@ -492,7 +499,9 @@ class EmbyRepository(private val settings: SettingsStore) { /** A shuffled set of movies & shows that actually have a backdrop image. */ suspend fun getScreensaverItems(limit: Int = 200): List { if (ServerConfig.isGateway) { - return requireGateway().screensaver(limit).items.filter { hasBackdrop(it) } + return requireGateway().screensaver(limit).items.filter { + it.id.isNotBlank() && hasBackdrop(it) + } } val userId = snapshot.userId ?: error("Not connected") val result = requireApi().getItems( @@ -508,7 +517,7 @@ class EmbyRepository(private val settings: SettingsStore) { "EnableUserData" to "true", ), ) - return result.items.filter { hasBackdrop(it) } + return result.items.filter { it.id.isNotBlank() && hasBackdrop(it) } } /** @@ -841,7 +850,12 @@ class EmbyRepository(private val settings: SettingsStore) { */ suspend fun getCalendarMonth(month: String = ""): GatewayCalendar { if (!ServerConfig.isGateway) return GatewayCalendar() - return requireGateway().calendar(month.trim().takeIf { it.isNotEmpty() }) + val calendar = requireGateway().calendar(month.trim().takeIf { it.isNotEmpty() }) + return calendar.copy( + days = calendar.days.map { day -> + day.copy(items = day.items.filter { it.id.isNotBlank() }.distinctBy(BaseItem::id)) + }, + ) } suspend fun lookupMediaRequests(term: String): List { @@ -900,7 +914,11 @@ class EmbyRepository(private val settings: SettingsStore) { } suspend fun getMyShows(): List = - if (ServerConfig.isGateway) requireGateway().myShows().shows else emptyList() + if (ServerConfig.isGateway) { + requireGateway().myShows().shows.filter { it.itemId.isNotBlank() } + } else { + emptyList() + } suspend fun saveMyShow(item: BaseItem): List { if (!ServerConfig.isGateway || !item.isSeries) return getMyShows() @@ -911,7 +929,7 @@ class EmbyRepository(private val settings: SettingsStore) { year = item.productionYear, imageTag = item.imageTags["Primary"].orEmpty(), ), - ).shows + ).shows.filter { it.itemId.isNotBlank() } } suspend fun removeMyShow(itemId: String) { @@ -920,7 +938,9 @@ class EmbyRepository(private val settings: SettingsStore) { suspend fun getNotifications(): com.ponzischeme89.memby.data.model.NotificationsResponse = if (ServerConfig.isGateway) { - requireGateway().notifications() + requireGateway().notifications().let { response -> + response.copy(notifications = response.notifications.filter { it.id > 0 }) + } } else { com.ponzischeme89.memby.data.model.NotificationsResponse() } @@ -928,7 +948,9 @@ class EmbyRepository(private val settings: SettingsStore) { suspend fun setNotificationPreferences( value: com.ponzischeme89.memby.data.model.NotificationPreferences, ): com.ponzischeme89.memby.data.model.NotificationsResponse = - requireGateway().setNotificationPreferences(value) + requireGateway().setNotificationPreferences(value).let { response -> + response.copy(notifications = response.notifications.filter { it.id > 0 }) + } suspend fun markNotificationRead(id: Long) { if (ServerConfig.isGateway) requireGateway().updateNotification(id, "read") @@ -1281,7 +1303,7 @@ class EmbyRepository(private val settings: SettingsStore) { "Limit" to "1000", ), ).items - }).distinctBy(BaseItem::id) + }).filter { it.id.isNotBlank() }.distinctBy(BaseItem::id) seriesEpisodesMutex.withLock { seriesEpisodesCache[seriesId] = CachedSeriesEpisodes( episodes = loaded, @@ -2191,6 +2213,8 @@ class EmbyRepository(private val settings: SettingsStore) { response.item, response.url, response.resumePositionMs, resolveSubtitleUrls(response.subtitles), response.subtitlesEnabled, response.selectedSubtitleId, response.mediaSourceId, response.playSessionId, response.playMethod, + response.subtitleDownloadAvailable, response.trickplayAvailable, + response.skipIntroAvailable, response.endCreditsAvailable, ) } @@ -2218,6 +2242,7 @@ class EmbyRepository(private val settings: SettingsStore) { next, discovery.url ?: buildStreamUrl(next.id), next.resumePositionMs, discovery.subtitles, snapshot.subtitlesEnabled, selectedSubtitleId(discovery.subtitles), discovery.mediaSourceId, discovery.playSessionId, discovery.playMethod, + true, true, true, true, ) } @@ -2231,12 +2256,19 @@ class EmbyRepository(private val settings: SettingsStore) { mediaSourceId: String, playSessionId: String, playMethod: String, + subtitleDownloadAvailable: Boolean, + trickplayAvailable: Boolean, + skipIntroAvailable: Boolean, + endCreditsAvailable: Boolean, ) = NextEpisode( itemId = item.id, title = item.name, seriesName = item.seriesName.orEmpty(), episodeCode = item.episodeCode, imageUrl = primaryUrl(item, maxWidth = 400), + logoUrl = logoUrl(item), + overview = item.overview.orEmpty(), + runtimeMs = item.runTimeTicks?.div(10_000L) ?: 0L, url = url, resumePositionMs = resumePositionMs, subtitles = subtitles, @@ -2245,6 +2277,10 @@ class EmbyRepository(private val settings: SettingsStore) { mediaSourceId = mediaSourceId, playSessionId = playSessionId, playMethod = playMethod, + subtitleDownloadAvailable = subtitleDownloadAvailable, + trickplayAvailable = trickplayAvailable, + skipIntroAvailable = skipIntroAvailable, + endCreditsAvailable = endCreditsAvailable, ) /** @@ -2753,6 +2789,7 @@ fun friendlyEmbyError(t: Throwable): String = when (t) { 401 -> "Session expired. Open Memby to sign in again." 403 -> "Access denied by the server." 404 -> "Not found on the server." + 429 -> retryAfterMessage(t) // The gateway answers 503 when an operator has deliberately taken Memby down. // Showing their message beats a generic "server problem" the viewer can do // nothing about. @@ -2763,6 +2800,15 @@ fun friendlyEmbyError(t: Throwable): String = when (t) { else -> "Something went wrong. Try again." } +private fun retryAfterMessage(t: HttpException): String { + val seconds = t.response()?.headers()?.get("Retry-After")?.trim()?.toLongOrNull() + return if (seconds != null && seconds > 0) { + "The server is busy. Try again in ${seconds.coerceAtMost(3_600)} seconds." + } else { + "The server is busy. Try again shortly." + } +} + private fun maintenanceMessage(t: HttpException): String? = runCatching { parseMaintenanceMessage(t.response()?.errorBody()?.string().orEmpty()) }.getOrNull() diff --git a/app/src/main/java/com/ponzischeme89/memby/data/MaintenanceMonitor.kt b/app/src/main/java/com/ponzischeme89/memby/data/MaintenanceMonitor.kt index b7b7436..8c9e575 100644 --- a/app/src/main/java/com/ponzischeme89/memby/data/MaintenanceMonitor.kt +++ b/app/src/main/java/com/ponzischeme89/memby/data/MaintenanceMonitor.kt @@ -211,6 +211,7 @@ class MaintenanceMonitor( } while (isActive) { + var nextPollDelayMs = POLL_INTERVAL_MS runCatching { repository.serviceStatus() } .onSuccess { status -> _compatibility.value = if (status.compatible) { @@ -251,6 +252,13 @@ class MaintenanceMonitor( } } .onFailure { error -> + if (error is retrofit2.HttpException && error.code() == 429) { + nextPollDelayMs = error.response()?.headers()?.get("Retry-After") + ?.trim()?.toLongOrNull() + ?.coerceIn(1L, MAX_RATE_LIMIT_SECONDS) + ?.times(1_000L) + ?: RATE_LIMIT_FALLBACK_MS + } if (isUnauthorizedError(error)) { repository.invalidateSession() _notice.value = null @@ -266,7 +274,7 @@ class MaintenanceMonitor( return@collectLatest } } - delay(POLL_INTERVAL_MS) + delay(nextPollDelayMs) } } } @@ -358,6 +366,8 @@ class MaintenanceMonitor( companion object { internal const val POLL_INTERVAL_MS = 10_000L + internal const val RATE_LIMIT_FALLBACK_MS = 60_000L + internal const val MAX_RATE_LIMIT_SECONDS = 3_600L /** Matches `featureInstallPermission` in the gateway's feature catalogue. */ internal const val INSTALL_PERMISSION_FEATURE = "install_permission_prompt" diff --git a/app/src/main/java/com/ponzischeme89/memby/data/PreferencesSync.kt b/app/src/main/java/com/ponzischeme89/memby/data/PreferencesSync.kt index 4f96cf9..b74ed8f 100644 --- a/app/src/main/java/com/ponzischeme89/memby/data/PreferencesSync.kt +++ b/app/src/main/java/com/ponzischeme89/memby/data/PreferencesSync.kt @@ -6,8 +6,12 @@ import androidx.lifecycle.repeatOnLifecycle import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch @@ -68,11 +72,7 @@ class PreferencesSync( // and be considered for a push. .map { (session, revision) -> SyncTrigger(session, revision) } .distinctUntilChanged() - // Plain collect, not collectLatest: a reconcile that is cancelled halfway - // could leave [lastSynced] describing a document that was never written. - // These are short and serialised by the mutex, so waiting is cheaper than - // reasoning about a half-applied sync. - .collect(::reconcile) + .collectLatest(::reconcile) } } @@ -95,21 +95,31 @@ class PreferencesSync( private suspend fun reconcile(trigger: SyncTrigger) { if (!ServerConfig.isGateway || !trigger.signedIn) return - mutex.withLock { + var retryDelayMs = INITIAL_RETRY_MS + while (currentCoroutineContext().isActive) { + val settled = mutex.withLock { reconcileOnce(trigger) } + if (settled) return + delay(retryDelayMs) + retryDelayMs = (retryDelayMs * 2).coerceAtMost(MAX_RETRY_MS) + } + } + + /** One reconciliation attempt. False means the network failed and this trigger remains due. */ + private suspend fun reconcileOnce(trigger: SyncTrigger): Boolean { val agreed = lastSynced?.takeIf { it.first == trigger.profileKey }?.second // Never synced on this TV, or the server has moved on without us. Pull first: // the server's copy is the shared truth, and a push here would overwrite a // change made on another television with this one's defaults. if (agreed == null || trigger.remoteRevision > trigger.localRevision) { - if (pull(trigger)) return + if (pull(trigger)) return true + return false } // Only a genuine local edit gets pushed. Anything else is either the document // just adopted or an unrelated part of Settings changing. val current = lastSynced?.takeIf { it.first == trigger.profileKey }?.second - if (current != null && current != trigger.local) push(trigger) - } + return current == null || current == trigger.local || push(trigger) } /** @@ -123,8 +133,7 @@ class PreferencesSync( // so they are pushed up rather than replaced by catalogue defaults. if (remote.revision == 0L) { lastSynced = trigger.profileKey to UserPreferences() - push(trigger) - return true + return push(trigger) } val decoded = decodeUserPreferences(remote.preferences, fallback = trigger.local) lastSynced = trigger.profileKey to decoded @@ -135,10 +144,10 @@ class PreferencesSync( return true } - private suspend fun push(trigger: SyncTrigger) { + private suspend fun push(trigger: SyncTrigger): Boolean { val stored = runCatching { repository.saveUserPreferences(trigger.localRevision, trigger.local.encode()) - }.getOrNull() ?: return + }.getOrNull() ?: return false // A 409 comes back here as an ordinary result carrying somebody else's document — // an operator's push, nearly always. Adopting it is the correct outcome: this TV @@ -160,5 +169,11 @@ class PreferencesSync( .onFailure { lastSynced = null } } } + return true + } + + private companion object { + const val INITIAL_RETRY_MS = 1_000L + const val MAX_RETRY_MS = 60_000L } } diff --git a/app/src/main/java/com/ponzischeme89/memby/data/ThemeSync.kt b/app/src/main/java/com/ponzischeme89/memby/data/ThemeSync.kt index f6fa0fa..2d578f5 100644 --- a/app/src/main/java/com/ponzischeme89/memby/data/ThemeSync.kt +++ b/app/src/main/java/com/ponzischeme89/memby/data/ThemeSync.kt @@ -10,10 +10,14 @@ import com.ponzischeme89.memby.ui.theme.applyMembyPalette import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex @@ -100,9 +104,7 @@ class ThemeSync( SyncTrigger(session, status) } .distinctUntilChanged() - // Plain collect rather than collectLatest: a fetch cancelled halfway could - // leave the stored revision describing a palette that was never written. - .collect(::reconcile) + .collectLatest(::reconcile) } } @@ -139,12 +141,18 @@ class ThemeSync( ) { return } - mutex.withLock { fetch(trigger.serverRevision) } + var retryDelayMs = INITIAL_RETRY_MS + while (currentCoroutineContext().isActive) { + val fetched = mutex.withLock { fetch(trigger.serverRevision) } + if (fetched) return + delay(retryDelayMs) + retryDelayMs = (retryDelayMs * 2).coerceAtMost(MAX_RETRY_MS) + } } - private suspend fun fetch(expectedRevision: String) { - if (appliedRevision == expectedRevision) return - val document = runCatching { repository.theme() }.getOrNull() ?: return + private suspend fun fetch(expectedRevision: String): Boolean { + if (appliedRevision == expectedRevision) return true + val document = runCatching { repository.theme() }.getOrNull() ?: return false val resolved = document.theme _theme.value = resolved _available.value = document.available @@ -165,6 +173,7 @@ class ThemeSync( // now and pays one extra fetch on its next cold start. Clearing the applied // revision would instead make it refetch on every poll. } + return true } /** @@ -181,4 +190,9 @@ class ThemeSync( }.getOrNull() ?: return applyMembyPalette(palette.toMembyPalette()) } + + private companion object { + const val INITIAL_RETRY_MS = 1_000L + const val MAX_RETRY_MS = 60_000L + } } diff --git a/app/src/main/java/com/ponzischeme89/memby/data/model/EmbyModels.kt b/app/src/main/java/com/ponzischeme89/memby/data/model/EmbyModels.kt index 5ff7dd2..dc239e3 100644 --- a/app/src/main/java/com/ponzischeme89/memby/data/model/EmbyModels.kt +++ b/app/src/main/java/com/ponzischeme89/memby/data/model/EmbyModels.kt @@ -377,7 +377,9 @@ data class EmbyChapter( @Serializable data class BaseItem( - @SerialName("Id") val id: String, + // Defaulted so one partially populated upstream object does not reject an otherwise + // useful list. Entry points discard blank ids before handing data to keyed TV lists. + @SerialName("Id") val id: String = "", @SerialName("Name") val name: String = "", @SerialName("Type") val type: String = "", @SerialName("Overview") val overview: String? = null, diff --git a/app/src/main/java/com/ponzischeme89/memby/data/model/GatewayModels.kt b/app/src/main/java/com/ponzischeme89/memby/data/model/GatewayModels.kt index 7754068..b693e09 100644 --- a/app/src/main/java/com/ponzischeme89/memby/data/model/GatewayModels.kt +++ b/app/src/main/java/com/ponzischeme89/memby/data/model/GatewayModels.kt @@ -56,8 +56,8 @@ data class GatewayDeviceNameRequest(val deviceName: String) */ @Serializable data class HomeRow( - val id: String, - val title: String, + val id: String = "", + val title: String = "", val kind: String = "", val items: List = emptyList(), ) @@ -636,6 +636,10 @@ data class GatewayNextEpisode( val mediaSourceId: String = "", val playSessionId: String = "", val playMethod: String = "DirectPlay", + val subtitleDownloadAvailable: Boolean = false, + val trickplayAvailable: Boolean = false, + val skipIntroAvailable: Boolean = false, + val endCreditsAvailable: Boolean = false, ) @Serializable @@ -653,8 +657,8 @@ data class GatewayFlagRequest( @Serializable data class MyShow( - val itemId: String, - val title: String, + val itemId: String = "", + val title: String = "", val year: Int? = null, val imageTag: String = "", val addedAt: String = "", @@ -686,7 +690,7 @@ data class NotificationPreferences( @Serializable data class UserNotification( - val id: Long, + val id: Long = 0, val kind: String = "", val itemId: String = "", val title: String = "", diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/EpisodeDetailsOverlay.kt b/app/src/main/java/com/ponzischeme89/memby/ui/EpisodeDetailsOverlay.kt index 58ab1af..bd0b9a2 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/EpisodeDetailsOverlay.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/EpisodeDetailsOverlay.kt @@ -105,12 +105,22 @@ fun EpisodeDetailsOverlay( episodes = emptyList() return@LaunchedEffect } - runCatching { repository.getSeriesEpisodes(seriesId) } - .onSuccess { episodes = it.sortedWith(seriesEpisodeComparator) } - .onFailure { - loadFailed = true - episodes = emptyList() - } + var retryDelayMs = 2_000L + while (true) { + runCatching { repository.getSeriesEpisodes(seriesId) } + .onSuccess { + loadFailed = false + episodes = it.sortedWith(seriesEpisodeComparator) + return@LaunchedEffect + } + .onFailure { error -> + if (error is kotlinx.coroutines.CancellationException) throw error + loadFailed = true + episodes = emptyList() + } + delay(retryDelayMs) + retryDelayMs = (retryDelayMs * 2).coerceAtMost(60_000L) + } } LaunchedEffect(item.id, settings.showRatingsStrip) { ratings = if (settings.showRatingsStrip) repository.getRatings(item) else emptyList() diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/HomeViewModel.kt b/app/src/main/java/com/ponzischeme89/memby/ui/HomeViewModel.kt index 9c9cf60..8858473 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/HomeViewModel.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/HomeViewModel.kt @@ -160,6 +160,9 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() { private val _forYou = MutableStateFlow(ForYouUiState()) val forYou: StateFlow = _forYou.asStateFlow() private var metadataJob: Job? = null + private var refreshJob: Job? = null + private var forYouJob: Job? = null + private var forYouRequestId = 0L private val metadataCache = object : LinkedHashMap(32, 0.75f, true) { override fun removeEldestEntry(eldest: MutableMap.MutableEntry?): Boolean = size > 32 } @@ -222,11 +225,14 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() { fun loadForYou(availableMinutes: Int = _forYou.value.availableMinutes) { val minutes = availableMinutes.coerceIn(0, 360) + val requestId = ++forYouRequestId + forYouJob?.cancel() _focusedItem.value = null _forYou.update { it.copy(availableMinutes = minutes, loading = true, error = null) } - viewModelScope.launch(Dispatchers.IO) { + forYouJob = viewModelScope.launch(Dispatchers.IO) { runCatching { repository.getForYou(minutes) } .onSuccess { built -> + if (requestId != forYouRequestId) return@onSuccess val rows = built.sanitisedRows() _forYou.value = ForYouUiState( rows = rows, @@ -235,7 +241,9 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() { ) rows.firstNotNullOfOrNull { it.items.firstOrNull() }?.let(::focusItem) } - .onFailure { + .onFailure { error -> + if (error is kotlinx.coroutines.CancellationException) throw error + if (requestId != forYouRequestId) return@onFailure _forYou.update { it.copy( loading = false, @@ -247,23 +255,32 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() { } fun refreshAll() { - viewModelScope.launch(Dispatchers.IO) { - refreshMutex.withLock { - // A refresh can make a newly imported episode the next playable item for - // an existing series ID. Never launch the pre-refresh negotiated session. - repository.invalidatePlaybackPrefetch() - _state.update { it.copy(loading = HomeSection.entries.toSet(), hasRefreshError = false) } - if (repository.supportsBatchHome) { - loadBatchHome() - } else { - coroutineScope { - launch { loadContinueWatching() } - launch { loadFavorites() } - launch { loadLatest() } + // One recovery loop owns refreshes. Repeated Select presses while a request is + // slow therefore do not queue a burst that hits the server after it recovers. + if (refreshJob?.isActive == true) return + refreshJob = viewModelScope.launch(Dispatchers.IO) { + var retryDelayMs = HOME_RETRY_INITIAL_MS + do { + refreshMutex.withLock { + // A refresh can make a newly imported episode the next playable item for + // an existing series ID. Never launch the pre-refresh negotiated session. + repository.invalidatePlaybackPrefetch() + _state.update { it.copy(loading = HomeSection.entries.toSet(), hasRefreshError = false) } + if (repository.supportsBatchHome) { + loadBatchHome() + } else { + coroutineScope { + launch { loadContinueWatching() } + launch { loadFavorites() } + launch { loadLatest() } + } } + persistCurrentHome() } - persistCurrentHome() - } + if (!_state.value.hasRefreshError) return@launch + delay(retryDelayMs) + retryDelayMs = (retryDelayMs * 2).coerceAtMost(HOME_RETRY_MAX_MS) + } while (true) } } @@ -602,12 +619,15 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() { */ private const val DETAIL_PREFETCH_DELAY_MS = 450L private const val ANALYTICS_FLUSH_INTERVAL_MS = 20_000L + private const val HOME_RETRY_INITIAL_MS = 2_000L + private const val HOME_RETRY_MAX_MS = 60_000L private fun initialFocusedItem(state: HomeUiState): BaseItem? = state.continueWatching.firstOrNull() ?: state.latestMovies.firstOrNull() ?: state.favorites.firstOrNull() } + } /** diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/ListKeys.kt b/app/src/main/java/com/ponzischeme89/memby/ui/ListKeys.kt index d647877..42eafee 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/ListKeys.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/ListKeys.kt @@ -39,7 +39,8 @@ internal inline fun List.distinctForKeys(selector: (T) -> K): List } /** [distinctForKeys] for the one shape most of these lists have. */ -internal fun List.distinctItems(): List = distinctForKeys(BaseItem::id) +internal fun List.distinctItems(): List = + filter { it.id.isNotBlank() }.distinctForKeys(BaseItem::id) /** * A home payload safe to render: rows unique by row id, and each row's cards unique by @@ -52,7 +53,7 @@ internal fun List.distinctItems(): List = distinctForKeys(Ba * whichever row holds them, taking the launcher down just the same. */ internal fun List.sanitisedRows(): List = - distinctForKeys(HomeRow::id).map { row -> + filter { it.id.isNotBlank() }.distinctForKeys(HomeRow::id).map { row -> val items = row.items.distinctItems() if (items.size == row.items.size) row else row.copy(items = items) } diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/MainActivity.kt b/app/src/main/java/com/ponzischeme89/memby/ui/MainActivity.kt index f3244bb..d5b1f16 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/MainActivity.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/MainActivity.kt @@ -122,6 +122,7 @@ import com.ponzischeme89.memby.BuildConfig import com.ponzischeme89.memby.ServiceLocator import com.ponzischeme89.memby.data.Settings import com.ponzischeme89.memby.data.EmbyProfile +import com.ponzischeme89.memby.data.friendlyEmbyError import com.ponzischeme89.memby.data.model.BaseItem import com.ponzischeme89.memby.data.model.GatewayUpdate import com.ponzischeme89.memby.data.model.HomeRow @@ -1898,10 +1899,16 @@ private fun HomeScreen( var quickMenuRowId by remember { mutableStateOf(null) } var focusedHomeRowId by remember { mutableStateOf(null) } var myShows by remember(settings.userId) { mutableStateOf>(emptyList()) } + var myShowsLoading by remember(settings.userId) { mutableStateOf(true) } + var myShowsError by remember(settings.userId) { mutableStateOf(null) } + var myShowsMutationBusy by remember(settings.userId) { mutableStateOf(false) } var selectedMyShow by remember { mutableStateOf(null) } var myShowReturnItemId by remember { mutableStateOf(null) } var removingMyShow by remember { mutableStateOf(false) } var notificationState by remember(settings.userId) { mutableStateOf(NotificationsResponse()) } + var notificationsLoading by remember(settings.userId) { mutableStateOf(true) } + var notificationsError by remember(settings.userId) { mutableStateOf(null) } + var notificationsMutationBusy by remember(settings.userId) { mutableStateOf(false) } var showNotifications by remember { mutableStateOf(false) } // Two different things: [launchingItem] is the gate that stops a second Play press // stacking a second player, and stays shut until one comes back. [resolvingItem] is @@ -1983,8 +1990,16 @@ private fun HomeScreen( // routes into the player, including the one that hands over without waiting. launchingItem = null scope.launch { - runCatching { repo.getMyShows() }.onSuccess { myShows = it } - runCatching { repo.getNotifications() }.onSuccess { notificationState = it } + myShowsLoading = true + notificationsLoading = true + runCatching { repo.getMyShows() } + .onSuccess { myShows = it; myShowsError = null } + .onFailure { myShowsError = friendlyEmbyError(it) } + runCatching { repo.getNotifications() } + .onSuccess { notificationState = it; notificationsError = null } + .onFailure { notificationsError = friendlyEmbyError(it) } + myShowsLoading = false + notificationsLoading = false kotlinx.coroutines.delay(32L) if (returnRowId != null && returnItemId != null) { requestFirstAvailableFocus( @@ -1999,8 +2014,16 @@ private fun HomeScreen( } LaunchedEffect(settings.userId) { - runCatching { repo.getMyShows() }.onSuccess { myShows = it } - runCatching { repo.getNotifications() }.onSuccess { notificationState = it } + myShowsLoading = true + notificationsLoading = true + runCatching { repo.getMyShows() } + .onSuccess { myShows = it; myShowsError = null } + .onFailure { myShowsError = friendlyEmbyError(it) } + runCatching { repo.getNotifications() } + .onSuccess { notificationState = it; notificationsError = null } + .onFailure { notificationsError = friendlyEmbyError(it) } + myShowsLoading = false + notificationsLoading = false } LaunchedEffect(liveMaintenance) { @@ -2093,8 +2116,9 @@ private fun HomeScreen( playable } .onSuccess { playable -> - playbackLauncher.launch( - PlayerActivity.intent( + val launched = runCatching { + playbackLauncher.launch( + PlayerActivity.intent( context = context, itemId = playable.itemId, url = playable.url, @@ -2118,8 +2142,13 @@ private fun HomeScreen( playSessionId = playable.playSessionId, playMethod = playable.playMethod, requestStartedAtMs = playbackRequestedAtMs, - ), - ) + ), + ) + } + if (launched.isFailure) { + Toast.makeText(context, "Couldn’t start playback", Toast.LENGTH_SHORT).show() + launchingItem = null + } } .onFailure { error -> // A cancellation is the viewer having pressed Back out of the @@ -2665,6 +2694,18 @@ private fun HomeScreen( item(key = "my-shows", contentType = "my-shows") { MyShowsStrip( shows = myShows, + loading = myShowsLoading, + errorMessage = myShowsError, + onRetry = { + if (!myShowsLoading) scope.launch { + myShowsLoading = true + myShowsError = null + runCatching { repo.getMyShows() } + .onSuccess { myShows = it } + .onFailure { myShowsError = friendlyEmbyError(it) } + myShowsLoading = false + } + }, repository = repo, availableWidth = contentWidth, density = settings.homeCardDensity, @@ -2958,8 +2999,12 @@ private fun HomeScreen( navigationExpanded = false showNotifications = true scope.launch { + notificationsLoading = true + notificationsError = null runCatching { repo.getNotifications() } .onSuccess { notificationState = it } + .onFailure { notificationsError = friendlyEmbyError(it) } + notificationsLoading = false } }, onDismiss = { @@ -3144,6 +3189,8 @@ private fun HomeScreen( // — a request the viewer has no reason to sit through watching an // unchanged button. The row that lands a moment later replaces this // placeholder; a failure puts the button back where it was. + if (myShowsMutationBusy) return@FocusedDetailsOverlay + myShowsMutationBusy = true val previous = myShows myShows = if (saved) { myShows.filterNot { it.itemId == item.id } + myShowStub(item) @@ -3166,6 +3213,7 @@ private fun HomeScreen( runCatching { repo.removeMyShow(item.id) } .onFailure { myShows = previous } } + myShowsMutationBusy = false } }, onTogglePlayed = { item, played -> @@ -3235,22 +3283,42 @@ private fun HomeScreen( MyAlertsPage( notifications = notificationState.notifications, preferences = notificationState.preferences, + loading = notificationsLoading, + errorMessage = notificationsError, + onRetry = { + if (!notificationsLoading) scope.launch { + notificationsLoading = true + notificationsError = null + runCatching { repo.getNotifications() } + .onSuccess { notificationState = it } + .onFailure { notificationsError = friendlyEmbyError(it) } + notificationsLoading = false + } + }, onToggleEnabled = { + if (notificationsMutationBusy) return@MyAlertsPage + notificationsMutationBusy = true scope.launch { val updated = notificationState.preferences.copy( enabled = !notificationState.preferences.enabled, ) runCatching { repo.setNotificationPreferences(updated) } .onSuccess { notificationState = it } + .onFailure { notificationsError = friendlyEmbyError(it) } + notificationsMutationBusy = false } }, onToggleShowReturns = { + if (notificationsMutationBusy) return@MyAlertsPage + notificationsMutationBusy = true scope.launch { val updated = notificationState.preferences.copy( showReturnAlerts = !notificationState.preferences.showReturnAlerts, ) runCatching { repo.setNotificationPreferences(updated) } .onSuccess { notificationState = it } + .onFailure { notificationsError = friendlyEmbyError(it) } + notificationsMutationBusy = false } }, // Marked read locally first. This fires on *focus*, so on a slow @@ -3258,12 +3326,27 @@ private fun HomeScreen( // request once per pass — clearing the flag immediately is what makes the // row stop asking. onRead = { notification -> + val previousReadAt = notification.readAt notificationState = notificationState.copy( notifications = notificationState.notifications.map { if (it.id == notification.id) it.copy(readAt = "now") else it }, ) - scope.launch { runCatching { repo.markNotificationRead(notification.id) } } + scope.launch { + runCatching { repo.markNotificationRead(notification.id) } + .onFailure { failure -> + notificationState = notificationState.copy( + notifications = notificationState.notifications.map { + if (it.id == notification.id) { + it.copy(readAt = previousReadAt) + } else { + it + } + }, + ) + notificationsError = friendlyEmbyError(failure) + } + } }, // Optimistic, for the reason "Dismiss all" beneath it already is: this // page is judged entirely on emptying itself, and a row that stayed put @@ -3271,6 +3354,8 @@ private fun HomeScreen( // page also re-aims where focus lands afterwards. A failure puts the row // back where it was rather than quietly losing somebody's alert. onDismiss = { notification -> + if (notificationsMutationBusy) return@MyAlertsPage + notificationsMutationBusy = true val previous = notificationState notificationState = notificationState.copy( notifications = notificationState.notifications.filterNot { @@ -3279,7 +3364,11 @@ private fun HomeScreen( ) scope.launch { runCatching { repo.dismissNotification(notification.id) } - .onFailure { notificationState = previous } + .onFailure { failure -> + notificationState = previous + notificationsError = friendlyEmbyError(failure) + } + notificationsMutationBusy = false } }, // The gateway has no bulk route, so this is the same call per alert. The @@ -3287,12 +3376,25 @@ private fun HomeScreen( // and a row that lingered while its request was in flight would be pressed // a second time. onDismissAll = { + if (notificationsMutationBusy) return@MyAlertsPage + notificationsMutationBusy = true + val previous = notificationState val pending = notificationState.notifications.map(UserNotification::id) notificationState = notificationState.copy(notifications = emptyList()) scope.launch { - pending.forEach { id -> runCatching { repo.dismissNotification(id) } } + val failed = pending.filter { id -> + runCatching { repo.dismissNotification(id) }.isFailure + } runCatching { repo.getNotifications() } .onSuccess { notificationState = it } + .onFailure { failure -> + notificationState = previous + notificationsError = friendlyEmbyError(failure) + } + if (failed.isNotEmpty()) { + notificationsError = "Some alerts couldn’t be dismissed. Try again." + } + notificationsMutationBusy = false } }, onClose = closeAlerts, diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/MyShowsNotifications.kt b/app/src/main/java/com/ponzischeme89/memby/ui/MyShowsNotifications.kt index b79110a..1703da2 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/MyShowsNotifications.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/MyShowsNotifications.kt @@ -56,6 +56,9 @@ import java.time.format.DateTimeFormatter @Composable internal fun MyShowsStrip( shows: List, + loading: Boolean = false, + errorMessage: String? = null, + onRetry: () -> Unit = {}, repository: EmbyRepository, availableWidth: Dp, density: String, @@ -96,7 +99,23 @@ internal fun MyShowsStrip( ) } } - if (shows.isEmpty()) { + if (shows.isEmpty() && loading) { + Text( + "Loading My Shows…", + color = MembyQuietText, + fontSize = 14.sp, + modifier = Modifier.padding(horizontal = 36.dp, vertical = 18.dp), + ) + } else if (shows.isEmpty() && errorMessage != null) { + Row( + modifier = Modifier.padding(horizontal = 36.dp, vertical = 10.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text(errorMessage, color = MembyQuietText, fontSize = 14.sp) + MembyChoiceChip(label = "Try again", selected = false, onClick = onRetry) + } + } else if (shows.isEmpty()) { Text( "Open any series and choose “Add to My Shows”.", color = MembyQuietText, @@ -104,6 +123,14 @@ internal fun MyShowsStrip( modifier = Modifier.padding(horizontal = 36.dp, vertical = 18.dp), ) } else { + if (loading || errorMessage != null) { + Text( + if (loading) "Refreshing My Shows…" else "Couldn’t refresh My Shows — showing saved results.", + color = MembyQuietText, + fontSize = 12.sp, + modifier = Modifier.padding(horizontal = 36.dp), + ) + } val cardsAcross = when (density) { "compact" -> 8 "large" -> 5 diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/SeriesDetailsOverlay.kt b/app/src/main/java/com/ponzischeme89/memby/ui/SeriesDetailsOverlay.kt index 9a2d8ca..20fcacc 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/SeriesDetailsOverlay.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/SeriesDetailsOverlay.kt @@ -121,12 +121,21 @@ fun SeriesDetailsOverlay( var ratings by remember(item.id) { mutableStateOf>(emptyList()) } LaunchedEffect(item.id) { - runCatching { repository.getSeriesEpisodes(item.id) } - .onSuccess { episodes = it.sortedWith(seriesEpisodeComparator) } - .onFailure { + var retryDelayMs = 2_000L + while (true) { + val loaded = runCatching { repository.getSeriesEpisodes(item.id) } + loaded.onSuccess { + loadFailed = false + episodes = it.sortedWith(seriesEpisodeComparator) + return@LaunchedEffect + }.onFailure { error -> + if (error is kotlinx.coroutines.CancellationException) throw error loadFailed = true episodes = emptyList() } + kotlinx.coroutines.delay(retryDelayMs) + retryDelayMs = (retryDelayMs * 2).coerceAtMost(60_000L) + } } // Separate from the episode request on purpose: the two are independent, and the page // must not wait on "more like this" to show a show's own episodes. diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/alerts/AlertsPage.kt b/app/src/main/java/com/ponzischeme89/memby/ui/alerts/AlertsPage.kt index 0076f74..ac61871 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/alerts/AlertsPage.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/alerts/AlertsPage.kt @@ -85,6 +85,9 @@ import kotlinx.coroutines.delay fun MyAlertsPage( notifications: List, preferences: NotificationPreferences, + loading: Boolean = false, + errorMessage: String? = null, + onRetry: () -> Unit = {}, onToggleEnabled: () -> Unit, onToggleShowReturns: () -> Unit, onRead: (UserNotification) -> Unit, @@ -165,12 +168,19 @@ fun MyAlertsPage( onClick = onDismissAll, ) } + if (errorMessage != null) { + MembyChoiceChip(label = "Try again", selected = false, onClick = onRetry) + } Spacer(Modifier.weight(1f)) MembyChoiceChip(label = "Close", selected = false, onClick = onClose) } Spacer(Modifier.height(18.dp)) Box(Modifier.fillMaxWidth().height(1.dp).background(MembyHairline)) - if (!hasAlerts) { + if (loading && !hasAlerts) { + AlertsNotice("Loading alerts…") + } else if (errorMessage != null && !hasAlerts) { + AlertsNotice(errorMessage) + } else if (!hasAlerts) { AlertsEmptyState(enabled = preferences.enabled) } else { LazyColumn( @@ -205,6 +215,13 @@ fun MyAlertsPage( } } +@Composable +private fun AlertsNotice(message: String) { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text(message, color = MembyQuietText, fontSize = 15.sp) + } +} + /** The row now occupying the removed row's place, or the preceding row at the end. */ internal fun alertFocusIndexAfterRemoval(removedIndex: Int, remainingCount: Int): Int? = if (remainingCount <= 0) null else removedIndex.coerceIn(0, remainingCount - 1) diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/genre/GenreBrowseViewModel.kt b/app/src/main/java/com/ponzischeme89/memby/ui/genre/GenreBrowseViewModel.kt index 04419a5..3897194 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/genre/GenreBrowseViewModel.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/genre/GenreBrowseViewModel.kt @@ -170,6 +170,7 @@ class GenreBrowseViewModel( ) } }.onFailure { error -> + if (error is kotlinx.coroutines.CancellationException) throw error _state.update { current -> if (current.selectedCategoryId != category.id) current else current.copy( isLoading = false, 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 index 76034ff..635b44d 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/player/PlaybackRecovery.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/player/PlaybackRecovery.kt @@ -56,7 +56,6 @@ internal fun describePlaybackFailure(errorCode: Int): PlaybackFailure = requiresTranscode = true, ) - PlaybackException.ERROR_CODE_PARSING_CONTAINER_MALFORMED, PlaybackException.ERROR_CODE_PARSING_CONTAINER_UNSUPPORTED, PlaybackException.ERROR_CODE_PARSING_MANIFEST_UNSUPPORTED, -> PlaybackFailure( 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 index ecb3850..4f2892a 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/player/PlaybackStopWorker.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/player/PlaybackStopWorker.kt @@ -7,6 +7,8 @@ import androidx.work.ExistingWorkPolicy import androidx.work.OneTimeWorkRequestBuilder import androidx.work.WorkManager import androidx.work.WorkerParameters +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import com.ponzischeme89.memby.ServiceLocator import com.ponzischeme89.memby.data.PlaybackSession @@ -47,6 +49,9 @@ class PlaybackStopWorker( private const val POSITION_MS = "position_ms" private const val MAX_RETRIES = 5 + private fun workName(session: PlaybackSession): String = + "emby-playback-stop-${session.playSessionId.ifBlank { session.itemId }}" + fun enqueue(context: Context, session: PlaybackSession, positionMs: Long) { val data = Data.Builder() .putString(ITEM_ID, session.itemId) @@ -58,12 +63,19 @@ class PlaybackStopWorker( val request = OneTimeWorkRequestBuilder() .setInputData(data) .build() - val key = session.playSessionId.ifBlank { session.itemId } WorkManager.getInstance(context.applicationContext).enqueueUniqueWork( - "emby-playback-stop-$key", + workName(session), ExistingWorkPolicy.REPLACE, request, ) } + + /** A resumed session must not be stopped later by work queued while backgrounded. */ + suspend fun cancel(context: Context, session: PlaybackSession) = withContext(Dispatchers.IO) { + WorkManager.getInstance(context.applicationContext) + .cancelUniqueWork(workName(session)) + .result + .get() + } } } diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/player/PlayerActivity.kt b/app/src/main/java/com/ponzischeme89/memby/ui/player/PlayerActivity.kt index 58fbaac..59fcce4 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/player/PlayerActivity.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/player/PlayerActivity.kt @@ -92,6 +92,7 @@ import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay +import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.first import kotlinx.coroutines.isActive @@ -113,6 +114,9 @@ class PlayerActivity : ComponentActivity() { private var player: ExoPlayer? = null private var playerView: PlayerView? = null private var progressJob: Job? = null + private var playbackStartReportJob: Job? = null + private var progressUploadJob: Job? = null + private val progressReports = Channel(Channel.CONFLATED) private var playbackStarted = false private var initialResumePositionMs = 0L private var stopReported = false @@ -138,6 +142,7 @@ class PlayerActivity : ComponentActivity() { private var errorDetailView: TextView? = null private var streamStatusView: TextView? = null private var retryJob: Job? = null + private var retryGeneration = 0L private var stablePlaybackJob: Job? = null private var prolongedRebufferJob: Job? = null private var prolongedRebufferRecoveryAttempted = false @@ -154,6 +159,7 @@ class PlayerActivity : ComponentActivity() { */ private var pendingRequest: PlaybackRequest? = null private var pendingResolveJob: Job? = null + private var pendingResolveGeneration = 0L private var serviceAlertsMounted = false // Open trace spans, or -1 for "not open". Held so they can be closed on destroy: a @@ -257,10 +263,17 @@ class PlayerActivity : ComponentActivity() { private var nextEpisode: NextEpisode? = null private var returningHomeAfterCompletion = false private var nextUpJob: Job? = null + private var nextEpisodeLookupJob: Job? = null private var nextUpBanner: View? = null private var nextUpCountdown: TextView? = null private var nextUpDismissed = false private var advancing = false + private var encodedSubtitleJob: Job? = null + private var subtitleStreamGeneration = 0L + private var restoredPositionMs: Long? = null + private var restoredPlayWhenReady: Boolean? = null + private var relaunchingForNewIntent = false + private var retryAfterResume = false // Skipping the opening titles. [skipIntroSegment] is where Emby says they are, fetched // once playback has settled; the rest is what this episode's viewer has done about it. @@ -390,7 +403,8 @@ class PlayerActivity : ComponentActivity() { // had a fresh prefetch in hand), or with the request that resolves one. The second // form exists so this activity, its layout and its decoder start while the server // is still being asked — see [PlaybackRequest]. - val url = intent.getStringExtra(EXTRA_URL)?.takeIf(String::isNotBlank) + val url = savedInstanceState?.getString(STATE_URL)?.takeIf(String::isNotBlank) + ?: intent.getStringExtra(EXTRA_URL)?.takeIf(String::isNotBlank) val request = decodeRequest(intent.getStringExtra(EXTRA_PLAYBACK_REQUEST)) pendingRequest = request.takeIf { url == null } if (url == null && request == null) { @@ -398,11 +412,21 @@ class PlayerActivity : ComponentActivity() { return } - itemId = intent.getStringExtra(EXTRA_ITEM_ID) - mediaSourceId = intent.getStringExtra(EXTRA_MEDIA_SOURCE_ID).orEmpty() - playSessionId = intent.getStringExtra(EXTRA_PLAY_SESSION_ID).orEmpty() - playMethod = intent.getStringExtra(EXTRA_PLAY_METHOD) ?: "DirectPlay" - val resumePositionMs = intent.getLongExtra(EXTRA_RESUME_POSITION_MS, 0L) + itemId = savedInstanceState?.getString(STATE_ITEM_ID) + ?: intent.getStringExtra(EXTRA_ITEM_ID) + mediaSourceId = savedInstanceState?.getString(STATE_MEDIA_SOURCE_ID) + ?: intent.getStringExtra(EXTRA_MEDIA_SOURCE_ID).orEmpty() + playSessionId = savedInstanceState?.getString(STATE_PLAY_SESSION_ID) + ?: intent.getStringExtra(EXTRA_PLAY_SESSION_ID).orEmpty() + playMethod = savedInstanceState?.getString(STATE_PLAY_METHOD) + ?: intent.getStringExtra(EXTRA_PLAY_METHOD) + ?: "DirectPlay" + restoredPositionMs = savedInstanceState?.takeIf { it.containsKey(STATE_POSITION_MS) } + ?.getLong(STATE_POSITION_MS) + restoredPlayWhenReady = savedInstanceState?.takeIf { it.containsKey(STATE_PLAY_WHEN_READY) } + ?.getBoolean(STATE_PLAY_WHEN_READY) + val resumePositionMs = restoredPositionMs + ?: intent.getLongExtra(EXTRA_RESUME_POSITION_MS, 0L) initialResumePositionMs = resumePositionMs.coerceAtLeast(0L) val prerollEnabled = intent.getBooleanExtra(EXTRA_PREROLL_ENABLED, true) configuredPrerollDurationMs = intent.getLongExtra( @@ -411,26 +435,36 @@ class PlayerActivity : ComponentActivity() { ).coerceIn(MIN_PREROLL_DURATION_MS, MAX_PREROLL_DURATION_MS) prerollDurationMs = configuredPrerollDurationMs val showPreroll = shouldShowPreroll(resumePositionMs, prerollEnabled) - val subtitles = decodeSubtitles(intent.getStringExtra(EXTRA_SUBTITLES)) + val subtitles = decodeSubtitles( + savedInstanceState?.getString(STATE_SUBTITLES) + ?: intent.getStringExtra(EXTRA_SUBTITLES), + ) availableSubtitles = subtitles // Falling back to this TV's copy of the setting rather than to `true` matters for // the request path, where the intent carries no answer yet: somebody who turned // subtitles off must not get them back for the seconds before one is resolved. - subtitlePreference = intent.getBooleanExtra( - EXTRA_SUBTITLES_ENABLED, - ServiceLocator.settings.current?.subtitlesEnabled ?: true, - ) - serverSubtitleId = intent.getStringExtra(EXTRA_SELECTED_SUBTITLE_ID).orEmpty() + subtitlePreference = savedInstanceState?.takeIf { it.containsKey(STATE_SUBTITLES_ENABLED) } + ?.getBoolean(STATE_SUBTITLES_ENABLED) + ?: intent.getBooleanExtra( + EXTRA_SUBTITLES_ENABLED, + ServiceLocator.settings.current?.subtitlesEnabled ?: true, + ) + serverSubtitleId = savedInstanceState?.getString(STATE_SELECTED_SUBTITLE_ID) + ?: intent.getStringExtra(EXTRA_SELECTED_SUBTITLE_ID).orEmpty() // Default false, not true: a missing extra must never conjure a section whose // only row leads to a request the backend cannot answer. - subtitleDownloadAvailable = intent.getBooleanExtra(EXTRA_SUBTITLE_DOWNLOAD, false) + subtitleDownloadAvailable = savedInstanceState?.getBoolean(STATE_SUBTITLE_DOWNLOAD) + ?: intent.getBooleanExtra(EXTRA_SUBTITLE_DOWNLOAD, false) // Same rule, and the same default, for the three that decide whether the previews, // the skip button and the credits pane are offered at all. On the request form of // the launch these are corrected by adoptPlayable once the server settles; on this // form the intent is the only thing that will ever say. - trickplayAvailable = intent.getBooleanExtra(EXTRA_TRICKPLAY, false) - skipIntroAvailable = intent.getBooleanExtra(EXTRA_SKIP_INTRO, false) - endCreditsAvailable = intent.getBooleanExtra(EXTRA_END_CREDITS, false) + trickplayAvailable = savedInstanceState?.getBoolean(STATE_TRICKPLAY) + ?: intent.getBooleanExtra(EXTRA_TRICKPLAY, false) + skipIntroAvailable = savedInstanceState?.getBoolean(STATE_SKIP_INTRO) + ?: intent.getBooleanExtra(EXTRA_SKIP_INTRO, false) + endCreditsAvailable = savedInstanceState?.getBoolean(STATE_END_CREDITS) + ?: intent.getBooleanExtra(EXTRA_END_CREDITS, false) Log.i(PLAYBACK_LOG_TAG, "event=subtitle_configs item=${itemId.orEmpty()} count=${subtitles.size}") setContentView(R.layout.activity_player) @@ -475,13 +509,23 @@ class PlayerActivity : ComponentActivity() { remainingView = view.findViewById(R.id.player_remaining) finishTimeView = view.findViewById(R.id.player_finish_time) streamStatusView = view.findViewById(R.id.player_stream_status) - logoUrl = intent.getStringExtra(EXTRA_LOGO_URL) - playbackTitle = intent.getStringExtra(EXTRA_TITLE).orEmpty() - pauseOverview = intent.getStringExtra(EXTRA_OVERVIEW).orEmpty() - prerollEpisodeCode = intent.getStringExtra(EXTRA_EPISODE_CODE).orEmpty() - prerollRuntimeMs = intent.getLongExtra(EXTRA_RUNTIME_MS, 0L).coerceAtLeast(0L) - pausePosterUrl = intent.getStringExtra(EXTRA_POSTER_URL) + logoUrl = savedInstanceState?.getString(STATE_LOGO_URL) + ?: intent.getStringExtra(EXTRA_LOGO_URL) + playbackTitle = savedInstanceState?.getString(STATE_TITLE) + ?: intent.getStringExtra(EXTRA_TITLE).orEmpty() + pauseOverview = savedInstanceState?.getString(STATE_OVERVIEW) + ?: intent.getStringExtra(EXTRA_OVERVIEW).orEmpty() + prerollEpisodeCode = savedInstanceState?.getString(STATE_EPISODE_CODE) + ?: intent.getStringExtra(EXTRA_EPISODE_CODE).orEmpty() + prerollRuntimeMs = if (savedInstanceState?.containsKey(STATE_RUNTIME_MS) == true) { + savedInstanceState.getLong(STATE_RUNTIME_MS).coerceAtLeast(0L) + } else { + intent.getLongExtra(EXTRA_RUNTIME_MS, 0L).coerceAtLeast(0L) + } + pausePosterUrl = savedInstanceState?.getString(STATE_POSTER_URL) + ?: intent.getStringExtra(EXTRA_POSTER_URL) if (showPreroll) startPreroll() else startWithoutPreroll() + setUpPlaybackError() // --- The critical path ------------------------------------------------------ // Everything above this point is what the player needs in order to open the @@ -493,8 +537,23 @@ class PlayerActivity : ComponentActivity() { // first one cannot arrive until onCreate has returned. // Surround passthrough is settled before the sink is built, not after: an audio // sink cannot change its mind about bitstreaming a format once a track is open. - player = PlayerEngine.create(this, ServiceLocator.settings.current.audioPassthroughPreference) - .also { playback -> + val createdPlayer = runCatching { + PlayerEngine.create(this, ServiceLocator.settings.current.audioPassthroughPreference) + }.getOrElse { error -> + Log.e(PLAYBACK_LOG_TAG, "event=player_create_failed", error) + prerollActive = false + disposeLocalPreroll(reuse = false) + prerollView?.visibility = View.GONE + showPlaybackError( + PlaybackFailure( + title = "Couldn’t start the video player", + detail = "Memby couldn’t initialise this TV’s video decoder. Try again or return to Memby.", + canAutoRetry = false, + ), + ) + return + } + player = createdPlayer.also { playback -> view.player = playback trace.mark(PlaybackTrace.PLAYER_BUILT) playback.addListener(object : Player.Listener { @@ -530,7 +589,7 @@ class PlayerActivity : ComponentActivity() { } override fun onIsPlayingChanged(isPlaying: Boolean) { - if (playbackStarted) { + if (playbackStarted && !stopReported) { reportProgress( playback.currentPosition, isPaused = !isPlaying, @@ -548,7 +607,7 @@ class PlayerActivity : ComponentActivity() { // only place that hears about it — without it the OSD it raised // would sit on the picture for the rest of the film. if (player.playbackState == Player.STATE_READY) endSeekBuffering() - if (playbackStarted && events.contains(Player.EVENT_POSITION_DISCONTINUITY)) { + if (playbackStarted && !stopReported && events.contains(Player.EVENT_POSITION_DISCONTINUITY)) { reportProgress(player.currentPosition, !player.isPlaying, "TimeUpdate") } } @@ -591,11 +650,32 @@ class PlayerActivity : ComponentActivity() { // Fresh playback waits in the pre-roll frame. A resumed item is already in its // full-screen parent and should begin as soon as it is ready. - if (url != null) { - trace.mark(PlaybackTrace.STREAM_RESOLVED) - startMedia(url, subtitles, resumePositionMs, playWhenReady = !showPreroll) + val startResolvedPlayback = { + if (url != null) { + trace.mark(PlaybackTrace.STREAM_RESOLVED) + startMedia( + url, + subtitles, + resumePositionMs, + playWhenReady = restoredPlayWhenReady ?: !showPreroll, + ) + } else { + resolvePendingStream( + requireNotNull(request), + playWhenReady = restoredPlayWhenReady ?: !showPreroll, + ) + } + } + val restoredId = itemId?.takeIf(String::isNotBlank).takeIf { savedInstanceState != null } + if (restoredId != null) { + showPlaybackLoading() + val restoredSession = playbackSession(restoredId) + lifecycleScope.launch { + runCatching { PlaybackStopWorker.cancel(this@PlayerActivity, restoredSession) } + if (!isFinishing && !isDestroyed && itemId == restoredId) startResolvedPlayback() + } } else { - resolvePendingStream(requireNotNull(request), playWhenReady = !showPreroll) + startResolvedPlayback() } // --- Decoration ------------------------------------------------------------- @@ -609,7 +689,6 @@ class PlayerActivity : ComponentActivity() { setUpEndCredits() setUpTimeRemainingCue() setUpSeasonFinaleCue() - setUpPlaybackError() lifecycleScope.launch { repeatOnLifecycle(Lifecycle.State.STARTED) { @@ -628,9 +707,24 @@ class PlayerActivity : ComponentActivity() { playWhenReady: Boolean, ) { val playback = player ?: return - playback.setMediaItem(mediaItem(url, subtitles), positionMs.coerceAtLeast(0L)) - playback.playWhenReady = playWhenReady - playback.prepare() + val prepared = runCatching { + require(url.isNotBlank()) { "Playback URL is blank" } + playback.setMediaItem(mediaItem(url, subtitles), positionMs.coerceAtLeast(0L)) + playback.playWhenReady = playWhenReady + playback.prepare() + } + if (prepared.isFailure) { + Log.e(PLAYBACK_LOG_TAG, "event=media_prepare_failed item=${itemId.orEmpty()}", prepared.exceptionOrNull()) + showPlaybackError( + PlaybackFailure( + title = getString(R.string.playback_server_unreachable), + detail = getString(R.string.playback_server_unreachable_detail), + canAutoRetry = false, + requiresFreshStream = true, + ), + ) + return + } trace.mark(PlaybackTrace.PREPARED) endFirstFrameTrace() firstFrameTraceCookie = PlaybackTraceSections.nextCookie() @@ -663,11 +757,14 @@ class PlayerActivity : ComponentActivity() { */ private fun resolvePendingStream(request: PlaybackRequest, playWhenReady: Boolean) { showPlaybackLoading() + val generation = ++pendingResolveGeneration pendingResolveJob?.cancel() pendingResolveJob = lifecycleScope.launch { runCatching { ServiceLocator.repository.resolvePlayableForLaunch(request) } .onSuccess { playable -> - if (isFinishing || isDestroyed) return@onSuccess + if (generation != pendingResolveGeneration || isFinishing || isDestroyed) { + return@onSuccess + } // A resolution with no stream in it is a failure wearing a success's // shape: handed to the player it becomes an empty URI, and what the // viewer is told is whatever media3 makes of that rather than that the @@ -693,12 +790,15 @@ class PlayerActivity : ComponentActivity() { startMedia( url = playable.url, subtitles = playable.subtitles, - positionMs = playable.resumePositionMs, + positionMs = restoredPositionMs ?: playable.resumePositionMs, playWhenReady = playWhenReady, ) } .onFailure { error -> - if (isFinishing || isDestroyed) return@onFailure + if (error is kotlinx.coroutines.CancellationException) throw error + if (generation != pendingResolveGeneration || isFinishing || isDestroyed) { + return@onFailure + } Log.e( PLAYBACK_LOG_TAG, "event=stream_resolve_failed item=${request.itemId}", @@ -1197,6 +1297,10 @@ class PlayerActivity : ComponentActivity() { errorTitleView = overlay.findViewById(R.id.playback_error_title) errorDetailView = overlay.findViewById(R.id.playback_error_detail) overlay.findViewById(R.id.playback_error_retry).setOnClickListener { + if (player == null) { + recreate() + return@setOnClickListener + } automaticRetryAttempt = 0 retryPlayback(refreshSource = true) } @@ -1214,6 +1318,7 @@ class PlayerActivity : ComponentActivity() { prerollView?.visibility = View.GONE playerView?.useController = true } + retryGeneration += 1 retryJob?.cancel() prolongedRebufferJob?.cancel() prolongedRebufferJob = null @@ -1260,6 +1365,7 @@ class PlayerActivity : ComponentActivity() { } private fun retryPlayback(refreshSource: Boolean, forceTranscode: Boolean = false) { + val generation = ++retryGeneration retryJob?.cancel() prolongedRebufferJob?.cancel() prolongedRebufferJob = null @@ -1301,7 +1407,9 @@ class PlayerActivity : ComponentActivity() { forceTranscode = forceTranscode, ) }.onSuccess { refreshed -> - if (isFinishing || isDestroyed) return@onSuccess + if (generation != retryGeneration || itemId != id || isFinishing || isDestroyed) { + return@onSuccess + } // Same rule as the launch path: a refresh that came back without a stream // is reported as the server being unreachable rather than re-prepared as // an empty URI, which would fail as a source error and be retried on the @@ -1318,23 +1426,32 @@ class PlayerActivity : ComponentActivity() { ) return@onSuccess } - itemId = refreshed.itemId - mediaSourceId = refreshed.mediaSourceId - playSessionId = refreshed.playSessionId - playMethod = refreshed.playMethod + adoptReplacementSession( + newItemId = refreshed.itemId, + newMediaSourceId = refreshed.mediaSourceId, + newPlaySessionId = refreshed.playSessionId, + newPlayMethod = refreshed.playMethod, + positionMs = positionMs, + ) availableSubtitles = refreshed.subtitles subtitlePreference = refreshed.subtitlesEnabled serverSubtitleId = refreshed.selectedSubtitleId + subtitleDownloadAvailable = refreshed.subtitleDownloadAvailable + trickplayAvailable = refreshed.trickplayAvailable + skipIntroAvailable = refreshed.skipIntroAvailable + endCreditsAvailable = refreshed.endCreditsAvailable if (refreshed.title.isNotBlank()) playbackTitle = refreshed.title subtitleAutoSelectionAttempted = false Log.i( PLAYBACK_LOG_TAG, "event=subtitle_configs item=${refreshed.itemId} count=${refreshed.subtitles.size} source=refresh", ) - playback.setMediaItem(mediaItem(refreshed.url, refreshed.subtitles), positionMs) - playback.playWhenReady = true - playback.prepare() + startMedia(refreshed.url, refreshed.subtitles, positionMs, playWhenReady = true) }.onFailure { refreshError -> + if (refreshError is kotlinx.coroutines.CancellationException) throw refreshError + if (generation != retryGeneration || itemId != id || isFinishing || isDestroyed) { + return@onFailure + } Log.e( PLAYBACK_LOG_TAG, "event=stream_refresh_failed item=$id attempt=$automaticRetryAttempt", @@ -2363,18 +2480,22 @@ class PlayerActivity : ComponentActivity() { * here; the banner only ever appears when there is something real to show. */ private fun prefetchNextEpisode() { + nextEpisodeLookupJob?.cancel() nextEpisode = null val id = itemId?.takeIf { it.isNotBlank() } ?: return - lifecycleScope.launch { + nextEpisodeLookupJob = lifecycleScope.launch { val enabled = ServiceLocator.repository.settingsFlow.first().autoPlayNextEpisode - if (!enabled) return@launch + val resolved = if (enabled) { // A next episode with no stream behind it is not a next episode. Kept as one // it would put up a banner and a countdown promising something that cannot be // played, and then — because the countdown runs itself out — swap it in // automatically and fail, with a viewer who pressed nothing having their // programme replaced by an error. - val resolved = ServiceLocator.repository.nextEpisode(id, seriesId = null) - ?.takeIf { it.url.isNotBlank() } + ServiceLocator.repository.nextEpisode(id, seriesId = null) + ?.takeIf { it.url.isNotBlank() } + } else { + null + } resolved?.imageUrl?.let { imageUrl -> imageLoader.enqueue( ImageRequest.Builder(this@PlayerActivity) @@ -2386,9 +2507,14 @@ class PlayerActivity : ComponentActivity() { // Playback may have moved on to another episode while this was in flight. if (itemId == id) { nextEpisode = resolved - // Extremely short episodes can end before the background lookup returns. - if (player?.playbackState == Player.STATE_ENDED && !nextUpDismissed) { - resolved?.let(::playNext) + // Extremely short episodes and hostile latency can reach Ended before the + // lookup. The ended frame waits for this answer rather than leaving Home. + if (player?.playbackState == Player.STATE_ENDED) { + when (playbackCompletionAction(resolved != null, nextUpDismissed)) { + PlaybackCompletionAction.PLAY_NEXT -> resolved?.let(::playNext) + PlaybackCompletionAction.RETURN_HOME -> returnHomeAfterCompletion() + PlaybackCompletionAction.WAIT_FOR_NEXT -> Unit + } } } } @@ -2781,22 +2907,26 @@ class PlayerActivity : ComponentActivity() { } private fun handlePlaybackEnded() { - when (playbackCompletionAction(nextEpisode != null, nextUpDismissed)) { + when (playbackCompletionAction( + hasNextEpisode = nextEpisode != null, + nextUpDismissed = nextUpDismissed, + nextLookupInFlight = nextEpisodeLookupJob?.isActive == true, + )) { PlaybackCompletionAction.PLAY_NEXT -> nextEpisode?.let(::playNext) PlaybackCompletionAction.RETURN_HOME -> returnHomeAfterCompletion() + PlaybackCompletionAction.WAIT_FOR_NEXT -> showPlaybackLoading( + title = "Finding the next episode…", + hint = "Playback will continue as soon as the server answers.", + ) } } - /** - * A terminal item should never leave a still frame or black player surface behind. - * Starting a fresh Home task also covers playback launched from the screensaver and - * deliberately clears any detail/search overlay that was behind PlayerActivity. - */ + /** A terminal item returns to the exact screen that launched playback. */ private fun returnHomeAfterCompletion() { if (returningHomeAfterCompletion || isFinishing || isDestroyed) return returningHomeAfterCompletion = true nextUpJob?.cancel() - progressJob?.cancel() + stopProgressUploading() hideNextUp() val playback = player @@ -2809,13 +2939,6 @@ class PlayerActivity : ComponentActivity() { playback?.currentPosition ?: 0L, ) } - startActivity( - Intent(this, MainActivity::class.java).addFlags( - Intent.FLAG_ACTIVITY_NEW_TASK or - Intent.FLAG_ACTIVITY_CLEAR_TASK or - Intent.FLAG_ACTIVITY_CLEAR_TOP, - ), - ) finish() } @@ -2828,7 +2951,19 @@ class PlayerActivity : ComponentActivity() { if (advancing || returningHomeAfterCompletion) return advancing = true nextUpJob?.cancel() - progressJob?.cancel() + nextEpisodeLookupJob?.cancel() + nextEpisodeLookupJob = null + retryGeneration += 1 + retryJob?.cancel() + subtitleStreamGeneration += 1 + subtitleSearchJob?.cancel() + encodedSubtitleJob?.cancel() + subtitleRequestInFlight = false + subtitleCandidates = emptyList() + subtitleDownloadStatus = "" + subtitleDownloadExpanded = false + subtitleOverlay?.visibility = View.GONE + stopProgressUploading() hideNextUp() val playback = player @@ -2849,6 +2984,11 @@ class PlayerActivity : ComponentActivity() { availableSubtitles = next.subtitles subtitlePreference = next.subtitlesEnabled serverSubtitleId = next.selectedSubtitleId + subtitleDownloadAvailable = next.subtitleDownloadAvailable + trickplayAvailable = next.trickplayAvailable + skipIntroAvailable = next.skipIntroAvailable + endCreditsAvailable = next.endCreditsAvailable + encodedSubtitleId = null stopReported = false playbackStarted = false playbackIdentityShown = false @@ -2883,8 +3023,26 @@ class PlayerActivity : ComponentActivity() { // The cast reloads with the rest of the new episode's session, once it is playing. playbackTitle = nextTitle(next) + logoUrl = next.logoUrl + pausePosterUrl = next.imageUrl + pauseOverview = next.overview + prerollEpisodeCode = next.episodeCode.orEmpty() + prerollRuntimeMs = next.runtimeMs.coerceAtLeast(0L) bindTitleArtwork(title = playbackTitle, logoUrl = logoUrl) setUpPlaybackIdentity(title = playbackTitle) + pauseOverlay?.findViewById(R.id.player_pause_title)?.text = playbackTitle + pauseOverlay?.findViewById(R.id.player_pause_overview)?.text = + pauseOverview.ifBlank { getString(R.string.player_pause_overview_fallback) } + pauseOverlay?.findViewById(R.id.player_pause_poster)?.apply { + val poster = pausePosterUrl + if (poster.isNullOrBlank()) { + visibility = View.GONE + setImageDrawable(null) + } else { + visibility = View.VISIBLE + load(poster) { crossfade(true) } + } + } hidePlaybackError() showPlaybackLoading() if (playback != null) { @@ -3479,6 +3637,7 @@ class PlayerActivity : ComponentActivity() { private fun searchForSubtitles() { if (subtitleRequestInFlight) return val id = itemId?.takeIf(String::isNotBlank) ?: return + val generation = ++subtitleStreamGeneration subtitleCandidates = emptyList() subtitleDownloadStatus = getString(R.string.player_subtitle_searching) subtitleDownloadExpanded = true @@ -3493,6 +3652,7 @@ class PlayerActivity : ComponentActivity() { itemId = id, language = ServiceLocator.settings.current?.subtitleLanguage, ) + if (generation != subtitleStreamGeneration || itemId != id) return@launch subtitleCandidates = found.results subtitleDownloadStatus = if (found.results.isEmpty()) { found.message.ifBlank { getString(R.string.player_subtitle_search_empty) } @@ -3503,8 +3663,9 @@ class PlayerActivity : ComponentActivity() { } finally { // In a finally so a cancelled search — Back out of the section, or the // activity going away — cannot leave every row permanently unpressable. - subtitleRequestInFlight = false + if (generation == subtitleStreamGeneration) subtitleRequestInFlight = false } + if (generation != subtitleStreamGeneration || itemId != id) return@launch redrawSubtitleOverlay(focusDownload = if (subtitleCandidates.isEmpty()) 0 else 1) } } @@ -3521,6 +3682,7 @@ class PlayerActivity : ComponentActivity() { if (subtitleRequestInFlight) return val playback = player ?: return val id = itemId?.takeIf(String::isNotBlank) ?: return + val generation = ++subtitleStreamGeneration subtitleDownloadStatus = getString( R.string.player_subtitle_downloading, candidate.languageLabel.ifBlank { candidate.language }, @@ -3528,20 +3690,29 @@ class PlayerActivity : ComponentActivity() { subtitleRequestInFlight = true subtitleSearchJob = lifecycleScope.launch { redrawSubtitleOverlay() - val result = try { - ServiceLocator.repository.downloadSubtitle(id, candidate) - } finally { - subtitleRequestInFlight = false - } + val result = ServiceLocator.repository.downloadSubtitle(id, candidate) + if (generation != subtitleStreamGeneration || itemId != id) return@launch + subtitleRequestInFlight = false if (result == null) { subtitleDownloadStatus = getString(R.string.player_subtitle_download_failed) redrawSubtitleOverlay(focusDownload = 0) return@launch } val position = playback.currentPosition.coerceAtLeast(0L) + val resumePlaying = playback.playWhenReady + if (result.url.isBlank()) { + subtitleDownloadStatus = getString(R.string.player_subtitle_download_failed) + redrawSubtitleOverlay(focusDownload = 0) + return@launch + } + adoptReplacementSession( + newItemId = id, + newMediaSourceId = result.mediaSourceId.ifBlank { mediaSourceId }, + newPlaySessionId = result.playSessionId.ifBlank { playSessionId }, + newPlayMethod = playMethod, + positionMs = position, + ) availableSubtitles = result.subtitles - mediaSourceId = result.mediaSourceId.ifBlank { mediaSourceId } - playSessionId = result.playSessionId.ifBlank { playSessionId } serverSubtitleId = result.selectedSubtitleId subtitlePreference = true encodedSubtitleId = null @@ -3551,9 +3722,7 @@ class PlayerActivity : ComponentActivity() { subtitleAutoSelectionAttempted = false rememberSubtitleChoice(enabled = true, language = candidate.language) pendingSubtitleConfirmation = candidate.languageLabel.ifBlank { candidate.language } - playback.setMediaItem(mediaItem(result.url, result.subtitles), position) - playback.playWhenReady = true - playback.prepare() + startMedia(result.url, result.subtitles, position, playWhenReady = resumePlaying) Log.i(PLAYBACK_LOG_TAG, "event=subtitle_downloaded item=$id language=${candidate.language}") subtitleCandidates = emptyList() subtitleDownloadStatus = result.message @@ -3570,6 +3739,7 @@ class PlayerActivity : ComponentActivity() { * back an hour later presses a row that quietly fails. A search is one press away. */ private fun collapseSubtitleDownloads() { + subtitleStreamGeneration += 1 subtitleSearchJob?.cancel() subtitleRequestInFlight = false subtitleDownloadExpanded = false @@ -3655,10 +3825,14 @@ class PlayerActivity : ComponentActivity() { val id = itemId?.takeIf(String::isNotBlank) ?: return val index = subtitle.id.toIntOrNull() ?: return val position = playback.currentPosition.coerceAtLeast(0L) + val resumePlaying = playback.playWhenReady + val generation = ++subtitleStreamGeneration + encodedSubtitleJob?.cancel() dismissSubtitleOverlayAfterSelection() reportProgress(position, !playback.isPlaying, "SubtitleTrackChange") + playback.pause() showPlaybackLoading(getString(R.string.playback_loading), "Preparing burned-in subtitles…") - lifecycleScope.launch { + encodedSubtitleJob = lifecycleScope.launch { runCatching { ServiceLocator.repository.selectEncodedSubtitle( playbackSession(id), @@ -3667,26 +3841,41 @@ class PlayerActivity : ComponentActivity() { position, ) }.onSuccess { selected -> - mediaSourceId = selected.mediaSourceId - playSessionId = selected.playSessionId - playMethod = selected.playMethod + if (generation != subtitleStreamGeneration || itemId != id || isFinishing || isDestroyed) { + return@onSuccess + } + if (selected.url.isBlank()) { + hidePlaybackLoading() + if (resumePlaying) playback.play() + Toast.makeText(this@PlayerActivity, "Couldn’t enable subtitles", Toast.LENGTH_SHORT).show() + return@onSuccess + } + adoptReplacementSession( + newItemId = selected.itemId, + newMediaSourceId = selected.mediaSourceId, + newPlaySessionId = selected.playSessionId, + newPlayMethod = selected.playMethod, + positionMs = position, + ) availableSubtitles = selected.subtitles encodedSubtitleId = subtitle.id subtitlePreference = true + subtitleDownloadAvailable = selected.subtitleDownloadAvailable + trickplayAvailable = selected.trickplayAvailable + skipIntroAvailable = selected.skipIntroAvailable + endCreditsAvailable = selected.endCreditsAvailable rememberSubtitleChoice(enabled = true, language = subtitle.language) subtitleAutoSelectionAttempted = true pendingSubtitleConfirmation = subtitleDisplayLabel(subtitle) - playback.setMediaItem(mediaItem(selected.url, selected.subtitles), position) - playback.playWhenReady = true - playback.prepare() - }.onFailure { - showPlaybackError( - PlaybackFailure( - title = "Couldn’t enable subtitles", - detail = "The Emby server could not prepare this image subtitle track.", - canAutoRetry = false, - ), - ) + startMedia(selected.url, selected.subtitles, position, playWhenReady = resumePlaying) + }.onFailure { error -> + if (error is kotlinx.coroutines.CancellationException) throw error + if (generation != subtitleStreamGeneration || itemId != id || isFinishing || isDestroyed) { + return@onFailure + } + hidePlaybackLoading() + if (resumePlaying) playback.play() + Toast.makeText(this@PlayerActivity, "Couldn’t enable subtitles", Toast.LENGTH_SHORT).show() } } } @@ -3762,6 +3951,44 @@ class PlayerActivity : ComponentActivity() { return if (traits.isEmpty()) name else "$name · ${traits.joinToString(" · ")}" } + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + // PlayerActivity is singleTask. A defensive second launch must replace the current + // programme deliberately rather than being delivered to an activity that ignores it. + relaunchingForNewIntent = true + setIntent(intent) + recreate() + } + + override fun onSaveInstanceState(outState: Bundle) { + player?.takeUnless { relaunchingForNewIntent }?.let { playback -> + outState.putLong(STATE_POSITION_MS, playback.currentPosition.coerceAtLeast(0L)) + outState.putBoolean(STATE_PLAY_WHEN_READY, playback.playWhenReady) + playback.currentMediaItem?.localConfiguration?.uri?.toString()?.takeIf(String::isNotBlank) + ?.let { outState.putString(STATE_URL, it) } + itemId?.let { outState.putString(STATE_ITEM_ID, it) } + outState.putString(STATE_MEDIA_SOURCE_ID, mediaSourceId) + outState.putString(STATE_PLAY_SESSION_ID, playSessionId) + outState.putString(STATE_PLAY_METHOD, playMethod) + if (availableSubtitles.isNotEmpty()) { + outState.putString(STATE_SUBTITLES, playerJson.encodeToString(availableSubtitles)) + } + outState.putBoolean(STATE_SUBTITLES_ENABLED, subtitlePreference == true) + outState.putString(STATE_SELECTED_SUBTITLE_ID, serverSubtitleId) + outState.putBoolean(STATE_SUBTITLE_DOWNLOAD, subtitleDownloadAvailable) + outState.putBoolean(STATE_TRICKPLAY, trickplayAvailable) + outState.putBoolean(STATE_SKIP_INTRO, skipIntroAvailable) + outState.putBoolean(STATE_END_CREDITS, endCreditsAvailable) + outState.putString(STATE_TITLE, playbackTitle) + outState.putString(STATE_LOGO_URL, logoUrl) + outState.putString(STATE_OVERVIEW, pauseOverview) + outState.putString(STATE_POSTER_URL, pausePosterUrl) + outState.putString(STATE_EPISODE_CODE, prerollEpisodeCode) + outState.putLong(STATE_RUNTIME_MS, prerollRuntimeMs) + } + super.onSaveInstanceState(outState) + } + override fun onStart() { super.onStart() if (prerollActive) { @@ -3770,10 +3997,29 @@ class PlayerActivity : ComponentActivity() { localPrerollPlayer?.play() ?: run { player?.playWhenReady = true } } beginContentWhenReady() + pendingRequest?.takeIf { + player?.currentMediaItem == null && pendingResolveJob?.isActive != true + }?.let { request -> + resolvePendingStream(request, playWhenReady = true) + } + if (retryAfterResume) { + retryAfterResume = false + retryPlayback(refreshSource = true) + } if (stoppedInBackground && playbackStarted) { stoppedInBackground = false stopReported = false - player?.let { reportStarted(it.currentPosition) } + player?.let { playback -> + itemId?.takeIf(String::isNotBlank)?.let { id -> + val session = playbackSession(id) + lifecycleScope.launch { + runCatching { PlaybackStopWorker.cancel(this@PlayerActivity, session) } + if (itemId != id || isFinishing || isDestroyed) return@launch + reportStarted(playback.currentPosition) + startProgressReporting() + } + } + } } } @@ -3781,9 +4027,22 @@ class PlayerActivity : ComponentActivity() { // Land any pending skip first, or the position reported to Emby — and so where // this title resumes from — is the one the viewer had already skipped past. commitSeek() + pendingResolveGeneration += 1 + pendingResolveJob?.cancel() + if (retryJob?.isActive == true) { + retryAfterResume = true + retryGeneration += 1 + retryJob?.cancel() + } + subtitleStreamGeneration += 1 + encodedSubtitleJob?.cancel() + subtitleSearchJob?.cancel() + subtitleRequestInFlight = false player?.let { if (playbackStarted && !stopReported) { - reportProgress(it.currentPosition, isPaused = true, eventName = "Pause") + // The durable stop carries this same position. Cancel advisory progress + // first so a high-latency older report cannot arrive after it. + stopProgressUploading() stopReported = true stoppedInBackground = true itemId?.takeIf(String::isNotBlank)?.let { id -> @@ -3800,7 +4059,7 @@ class PlayerActivity : ComponentActivity() { // A launch that never reached a frame still has to close its spans. endFirstFrameTrace() endLaunchTrace() - progressJob?.cancel() + stopProgressUploading() pendingResolveJob?.cancel() prerollTimerJob?.cancel() prerollScheduleJob?.cancel() @@ -3816,7 +4075,9 @@ class PlayerActivity : ComponentActivity() { castJob?.cancel() castPersonJob?.cancel() subtitleSearchJob?.cancel() + encodedSubtitleJob?.cancel() nextUpJob?.cancel() + nextEpisodeLookupJob?.cancel() creditsSpeedJob?.cancel() creditsView?.animate()?.cancel() retryJob?.cancel() @@ -3848,7 +4109,8 @@ class PlayerActivity : ComponentActivity() { private fun reportStarted(positionMs: Long) { val id = itemId?.takeIf { it.isNotBlank() } ?: return - lifecycleScope.launch { + playbackStartReportJob?.cancel() + playbackStartReportJob = lifecycleScope.launch { runCatching { ServiceLocator.repository.reportPlaybackStarted(playbackSession(id), positionMs) } @@ -3857,31 +4119,96 @@ class PlayerActivity : ComponentActivity() { private fun reportProgress(positionMs: Long, isPaused: Boolean, eventName: String) { val id = itemId?.takeIf { it.isNotBlank() } ?: return - lifecycleScope.launch { - val durationMs = player?.duration?.takeIf { it != C.TIME_UNSET && it > 0L } ?: 0L - val evaluatesAutoFollow = !autoFollowEvaluated && - durationMs > 0L && positionMs >= (durationMs + 1L) / 2L - runCatching { - ServiceLocator.repository.reportPlaybackProgress( - playbackSession(id), - positionMs, - isPaused, - eventName, - durationMs.takeUnless { autoFollowEvaluated } ?: 0L, - ) - }.onSuccess { showTitle -> - if (evaluatesAutoFollow) autoFollowEvaluated = true - showTitle?.let { - Toast.makeText( - this@PlayerActivity, - "$it was added to My Shows", - Toast.LENGTH_LONG, - ).show() + val durationMs = player?.duration?.takeIf { it != C.TIME_UNSET && it > 0L } ?: 0L + progressReports.trySend( + PendingProgressReport(id, positionMs, isPaused, eventName, durationMs), + ) + if (progressUploadJob?.isActive == true) return + progressUploadJob = lifecycleScope.launch { + // A hostilely slow start response must still precede the first progress event. + playbackStartReportJob?.join() + for (report in progressReports) { + // One report at a time, with the channel retaining only the newest report + // that arrived during the wait. A slow earlier position can therefore + // never land at Emby after a newer one and move the saved playhead back. + if (itemId != report.itemId) continue + val evaluatesAutoFollow = !autoFollowEvaluated && + report.durationMs > 0L && + report.positionMs >= (report.durationMs + 1L) / 2L + runCatching { + ServiceLocator.repository.reportPlaybackProgress( + playbackSession(report.itemId), + report.positionMs, + report.isPaused, + report.eventName, + report.durationMs.takeUnless { autoFollowEvaluated } ?: 0L, + ) + }.onSuccess { showTitle -> + if (evaluatesAutoFollow) autoFollowEvaluated = true + showTitle?.let { + Toast.makeText( + this@PlayerActivity, + "$it was added to My Shows", + Toast.LENGTH_LONG, + ).show() + } } } } } + /** Stops advisory uploads before a durable final position is queued. */ + private fun stopProgressUploading() { + progressJob?.cancel() + progressJob = null + playbackStartReportJob?.cancel() + playbackStartReportJob = null + progressUploadJob?.cancel() + progressUploadJob = null + while (progressReports.tryReceive().isSuccess) { + // Discard reports for the session that is stopping. + } + } + + private data class PendingProgressReport( + val itemId: String, + val positionMs: Long, + val isPaused: Boolean, + val eventName: String, + val durationMs: Long, + ) + + /** Closes the old Emby session before a refreshed media source takes its place. */ + private fun adoptReplacementSession( + newItemId: String, + newMediaSourceId: String, + newPlaySessionId: String, + newPlayMethod: String, + positionMs: Long, + ) { + val oldItemId = itemId + val oldSession = oldItemId?.takeIf(String::isNotBlank)?.let(::playbackSession) + val changed = playbackSessionChanged( + oldItemId = oldItemId, + oldMediaSourceId = mediaSourceId, + oldPlaySessionId = playSessionId, + newItemId = newItemId, + newMediaSourceId = newMediaSourceId, + newPlaySessionId = newPlaySessionId, + ) + if (changed && playbackStarted && oldSession != null) { + stopProgressUploading() + PlaybackStopWorker.enqueue(this, oldSession, positionMs) + playbackStarted = false + stopReported = false + stoppedInBackground = false + } + itemId = newItemId + mediaSourceId = newMediaSourceId.ifBlank { newItemId } + playSessionId = newPlaySessionId + playMethod = newPlayMethod.ifBlank { "DirectPlay" } + } + private fun playbackSession(id: String) = PlaybackSession( itemId = id, mediaSourceId = mediaSourceId.ifBlank { id }, @@ -3958,6 +4285,26 @@ class PlayerActivity : ComponentActivity() { private const val EXTRA_PLAY_SESSION_ID = "extra_play_session_id" private const val EXTRA_PLAY_METHOD = "extra_play_method" private const val EXTRA_PLAYBACK_REQUEST = "extra_playback_request" + private const val STATE_POSITION_MS = "state_position_ms" + private const val STATE_PLAY_WHEN_READY = "state_play_when_ready" + private const val STATE_URL = "state_url" + private const val STATE_ITEM_ID = "state_item_id" + private const val STATE_MEDIA_SOURCE_ID = "state_media_source_id" + private const val STATE_PLAY_SESSION_ID = "state_play_session_id" + private const val STATE_PLAY_METHOD = "state_play_method" + private const val STATE_SUBTITLES = "state_subtitles" + private const val STATE_SUBTITLES_ENABLED = "state_subtitles_enabled" + private const val STATE_SELECTED_SUBTITLE_ID = "state_selected_subtitle_id" + private const val STATE_SUBTITLE_DOWNLOAD = "state_subtitle_download" + private const val STATE_TRICKPLAY = "state_trickplay" + private const val STATE_SKIP_INTRO = "state_skip_intro" + private const val STATE_END_CREDITS = "state_end_credits" + private const val STATE_TITLE = "state_title" + private const val STATE_LOGO_URL = "state_logo_url" + private const val STATE_OVERVIEW = "state_overview" + private const val STATE_POSTER_URL = "state_poster_url" + private const val STATE_EPISODE_CODE = "state_episode_code" + private const val STATE_RUNTIME_MS = "state_runtime_ms" private const val PLAYER_PREFERENCES = "player_preferences" private const val SUBTITLE_SIZE_KEY = "subtitle_size" private const val PICTURE_MODE_KEY = "picture_mode" @@ -4254,19 +4601,38 @@ internal fun shouldShowPreroll(resumePositionMs: Long, enabled: Boolean = true): internal enum class PlaybackCompletionAction { PLAY_NEXT, + WAIT_FOR_NEXT, RETURN_HOME, } internal fun playbackCompletionAction( hasNextEpisode: Boolean, nextUpDismissed: Boolean, + nextLookupInFlight: Boolean = false, ): PlaybackCompletionAction = if (hasNextEpisode && !nextUpDismissed) { PlaybackCompletionAction.PLAY_NEXT + } else if (!nextUpDismissed && nextLookupInFlight) { + PlaybackCompletionAction.WAIT_FOR_NEXT } else { PlaybackCompletionAction.RETURN_HOME } +internal fun playbackSessionChanged( + oldItemId: String?, + oldMediaSourceId: String, + oldPlaySessionId: String, + newItemId: String, + newMediaSourceId: String, + newPlaySessionId: String, +): Boolean = oldItemId != newItemId || if ( + oldPlaySessionId.isNotBlank() || newPlaySessionId.isNotBlank() +) { + oldPlaySessionId != newPlaySessionId +} else { + oldMediaSourceId != newMediaSourceId +} + internal fun shouldZoomVideo( modeKey: String, width: Int, diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/player/PrerollPreloader.kt b/app/src/main/java/com/ponzischeme89/memby/ui/player/PrerollPreloader.kt index d3c7a93..916a3e2 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/player/PrerollPreloader.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/player/PrerollPreloader.kt @@ -98,10 +98,8 @@ internal object PrerollPreloader { player.normalised() cachedPlayer?.release() cachedPlayer = player - // Stopping the player leaves it idle, so the next borrower would pay for the local - // resource read itself. Re-arm the idle prepare here rather than depending on the - // caller to remember: the launcher is not the only thing that returns one now. - scheduleAtMainQueueIdle() + // Keep it idle while the programme owns the decoder. Home calls [start] when it is + // visible again, which prepares this cached instance without competing with video. } /** Drops a failed or otherwise unusable instance; the next Home/start call replaces it. */ diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/screensaver/ScreensaverContent.kt b/app/src/main/java/com/ponzischeme89/memby/ui/screensaver/ScreensaverContent.kt index e656a1d..f2b9244 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/screensaver/ScreensaverContent.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/screensaver/ScreensaverContent.kt @@ -182,6 +182,7 @@ private fun Slideshow( var settingsOpen by remember { mutableStateOf(false) } var loading by remember { mutableStateOf(true) } var loadingMore by remember { mutableStateOf(false) } + var playbackLaunching by remember { mutableStateOf(false) } var loadError by remember { mutableStateOf(null) } var toast by remember(startupMessage) { mutableStateOf(startupMessage) } var reloadKey by remember { mutableIntStateOf(0) } @@ -247,8 +248,11 @@ private fun Slideshow( loadingMore = true toast = "Finding more from your library…" scope.launch { + var prefetchError: Throwable? = null val more = visibleWithWatchedPreference( - runCatching { repo.getScreensaverItems() }.getOrDefault(emptyList()), + runCatching { repo.getScreensaverItems() } + .onFailure { prefetchError = it } + .getOrDefault(emptyList()), hideWatchedMovies, ) if (more.isNotEmpty()) { @@ -270,7 +274,7 @@ private fun Slideshow( "Refreshed the queue with a new order" } } else { - toast = "No additional titles found" + toast = prefetchError?.let(::friendlyEmbyError) ?: "No additional titles found" } loadingMore = false } @@ -367,20 +371,45 @@ private fun Slideshow( } fun playTrailer(item: BaseItem?) { + if (playbackLaunching) return val target = item ?: return + playbackLaunching = true toast = "Finding trailer…" scope.launch { runCatching { repo.getLocalTrailer(target.id) } .onSuccess { trailer -> if (trailer == null) { toast = "No trailer is available for ${target.name}." + playbackLaunching = false } else { runCatching { repo.resolvePlayable(trailer) } - .onSuccess { onPlay(it.url, "${target.name} trailer") } - .onFailure { toast = friendlyEmbyError(it) } + .onSuccess { playable -> + if (playable.url.isBlank()) { + toast = "The trailer is unavailable right now." + playbackLaunching = false + } else if (runCatching { + onPlay(playable.url, "${target.name} trailer") + }.isFailure + ) { + toast = "Couldn’t start the trailer." + playbackLaunching = false + } else { + // Keep rapid Select/media-key repeats gated while the + // activity hand-off occurs, then re-arm on return. + delay(1_000L) + playbackLaunching = false + } + } + .onFailure { + toast = friendlyEmbyError(it) + playbackLaunching = false + } } } - .onFailure { toast = friendlyEmbyError(it) } + .onFailure { + toast = friendlyEmbyError(it) + playbackLaunching = false + } } } 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 index b5ede69..b60ac77 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/search/SearchScreen.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/search/SearchScreen.kt @@ -99,6 +99,7 @@ import com.ponzischeme89.memby.ServiceLocator import com.ponzischeme89.memby.data.model.BaseItem import com.ponzischeme89.memby.data.model.GatewayRequestCandidate import com.ponzischeme89.memby.ui.FocusScaleContainer +import com.ponzischeme89.memby.ui.MembyChoiceChip import com.ponzischeme89.memby.ui.PosterGridCard import com.ponzischeme89.memby.ui.theme.MembyAccent import com.ponzischeme89.memby.ui.theme.MembyAccentInk @@ -786,6 +787,8 @@ private fun ResultsPane( // listener running for nothing. paging = state.genre != null && (state.canLoadMore || state.isLoadingMore), loadingMore = state.isLoadingMore, + pagingErrorMessage = state.pagingErrorMessage, + onRetryPage = onRetry, onLoadMore = onLoadMore, ) } @@ -863,6 +866,8 @@ private fun ResultsGrid( onItemSelected: (BaseItem) -> Unit, paging: Boolean = false, loadingMore: Boolean = false, + pagingErrorMessage: String? = null, + onRetryPage: () -> Unit = {}, onLoadMore: () -> Unit = {}, ) { val context = LocalContext.current @@ -951,6 +956,19 @@ private fun ResultsGrid( } } } + if (pagingErrorMessage != null) { + item(span = { GridItemSpan(maxLineSpan) }, contentType = "search-paging-error") { + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = 12.dp), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + Text("Couldn’t load more titles.", color = Muted, fontSize = 13.sp) + Spacer(Modifier.width(10.dp)) + MembyChoiceChip(label = "Try again", selected = false, onClick = onRetryPage) + } + } + } } } 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 index 741ff47..1554bc4 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/search/SearchViewModel.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/search/SearchViewModel.kt @@ -64,6 +64,7 @@ data class SearchUiState( /** True once a query has run to completion, so "no matches" is distinguishable from "not yet". */ val hasSearched: Boolean = false, val errorMessage: String? = null, + val pagingErrorMessage: String? = null, val requestCandidates: List = emptyList(), val requestLookupLoading: Boolean = false, val requestingCandidateKey: String? = null, @@ -156,6 +157,7 @@ class SearchViewModel(private val repository: EmbyRepository) : ViewModel() { requestLookupLoading = false, requestMessage = null, requestMessageIsError = false, + pagingErrorMessage = null, ) } queryFlow.value = query @@ -174,6 +176,7 @@ class SearchViewModel(private val repository: EmbyRepository) : ViewModel() { query = "", results = emptyList(), genreOffset = 0, isLoading = false, hasSearched = false, errorMessage = null, requestCandidates = emptyList(), + pagingErrorMessage = null, requestLookupLoading = false, requestMessage = null, requestMessageIsError = false, genre = null, isLoadingMore = false, canLoadMore = false, @@ -196,6 +199,7 @@ class SearchViewModel(private val repository: EmbyRepository) : ViewModel() { isLoading = true, hasSearched = false, errorMessage = null, isLoadingMore = false, canLoadMore = false, requestCandidates = emptyList(), + pagingErrorMessage = null, requestLookupLoading = false, requestMessage = null, requestMessageIsError = false, ) @@ -214,6 +218,7 @@ class SearchViewModel(private val repository: EmbyRepository) : ViewModel() { genre = null, results = emptyList(), genreOffset = 0, isLoading = false, hasSearched = false, errorMessage = null, isLoadingMore = false, canLoadMore = false, + pagingErrorMessage = null, ) } } @@ -227,7 +232,7 @@ class SearchViewModel(private val repository: EmbyRepository) : ViewModel() { val current = state.value val genre = current.genre ?: return if (!current.canLoadMore || current.isLoadingMore || current.isLoading) return - _state.update { it.copy(isLoadingMore = true) } + _state.update { it.copy(isLoadingMore = true, pagingErrorMessage = null) } genrePageJob = viewModelScope.launch { loadGenrePage(genre, offset = current.genreOffset) } } @@ -236,7 +241,14 @@ class SearchViewModel(private val repository: EmbyRepository) : ViewModel() { val current = state.value current.genre?.let { genre -> genrePageJob?.cancel() - _state.update { it.copy(isLoading = true, errorMessage = null) } + _state.update { + it.copy( + isLoading = it.results.isEmpty(), + isLoadingMore = it.results.isNotEmpty(), + errorMessage = null, + pagingErrorMessage = null, + ) + } genrePageJob = viewModelScope.launch { loadGenrePage(genre, offset = current.genreOffset) } @@ -251,6 +263,8 @@ class SearchViewModel(private val repository: EmbyRepository) : ViewModel() { fun request(candidate: GatewayRequestCandidate) { if (candidate.alreadyAdded || state.value.requestingCandidateKey != null) return val candidateKey = "${candidate.mediaType}:${candidate.foreignId}" + val queryAtRequest = state.value.query.trim() + val genreAtRequest = state.value.genre _state.update { it.copy(requestingCandidateKey = candidateKey, requestMessage = null, requestMessageIsError = false) } @@ -258,24 +272,32 @@ class SearchViewModel(private val repository: EmbyRepository) : ViewModel() { runCatching { repository.requestMedia(candidate) } .onSuccess { title -> _state.update { + if (it.requestingCandidateKey != candidateKey) return@update it + val stillShowingRequest = it.query.trim() == queryAtRequest && + it.genre == genreAtRequest it.copy( requestingCandidateKey = null, - requestCandidates = it.requestCandidates.map { option -> + requestCandidates = if (stillShowingRequest) it.requestCandidates.map { option -> if (option.mediaType == candidate.mediaType && option.foreignId == candidate.foreignId ) option.copy(alreadyAdded = true) else option - }, - requestMessage = "${title.ifBlank { candidate.title }} was requested.", + } else it.requestCandidates, + requestMessage = if (stillShowingRequest) { + "${title.ifBlank { candidate.title }} was requested." + } else null, requestMessageIsError = false, ) } } .onFailure { error -> _state.update { + if (it.requestingCandidateKey != candidateKey) return@update it + val stillShowingRequest = it.query.trim() == queryAtRequest && + it.genre == genreAtRequest it.copy( requestingCandidateKey = null, - requestMessage = friendlyEmbyError(error), - requestMessageIsError = true, + requestMessage = if (stillShowingRequest) friendlyEmbyError(error) else null, + requestMessageIsError = stillShowingRequest, ) } } @@ -333,6 +355,7 @@ class SearchViewModel(private val repository: EmbyRepository) : ViewModel() { isLoadingMore = false, hasSearched = true, errorMessage = null, + pagingErrorMessage = null, canLoadMore = hasMoreGenreItems( loaded = read, total = page.total, @@ -354,6 +377,7 @@ class SearchViewModel(private val repository: EmbyRepository) : ViewModel() { // there and simply stops: the viewer has plenty on screen, and // replacing it with an error would take away what was working. errorMessage = if (current.results.isEmpty()) friendlyEmbyError(error) else null, + pagingErrorMessage = if (current.results.isNotEmpty()) friendlyEmbyError(error) else null, canLoadMore = false, ) } diff --git a/app/src/test/java/com/ponzischeme89/memby/data/GatewayPayloadTest.kt b/app/src/test/java/com/ponzischeme89/memby/data/GatewayPayloadTest.kt index d7b80c4..f8c12b9 100644 --- a/app/src/test/java/com/ponzischeme89/memby/data/GatewayPayloadTest.kt +++ b/app/src/test/java/com/ponzischeme89/memby/data/GatewayPayloadTest.kt @@ -28,6 +28,16 @@ import org.junit.Test * side, this fails before a TV ever sees it. */ class GatewayPayloadTest { + @Test + fun `partially populated items decode without inventing an identity`() { + val item = json.decodeFromString( + """{"Name":"Incomplete","Type":"Movie"}""", + ) + + assertEquals("", item.id) + assertEquals("Incomplete", item.name) + } + @Test fun `decodes server formatted movie rating sources and scales`() { val response = json.decodeFromString( @@ -545,7 +555,11 @@ class GatewayPayloadTest { }, "title": "Westworld – The Bicameral Mind", "url": "https://emby.example/Videos/11/stream?static=true", - "resumePositionMs": 0 + "resumePositionMs": 0, + "subtitleDownloadAvailable": true, + "trickplayAvailable": true, + "skipIntroAvailable": true, + "endCreditsAvailable": true } """.trimIndent(), ) @@ -556,6 +570,10 @@ class GatewayPayloadTest { assertEquals("S2 · E5", next.item.episodeCode) assertEquals(0L, next.resumePositionMs) assertTrue(next.url.startsWith("https://emby.example/Videos/11/stream")) + assertTrue(next.subtitleDownloadAvailable) + assertTrue(next.trickplayAvailable) + assertTrue(next.skipIntroAvailable) + assertTrue(next.endCreditsAvailable) } @Test diff --git a/app/src/test/java/com/ponzischeme89/memby/ui/ListKeysTest.kt b/app/src/test/java/com/ponzischeme89/memby/ui/ListKeysTest.kt index 844e317..ea0cab7 100644 --- a/app/src/test/java/com/ponzischeme89/memby/ui/ListKeysTest.kt +++ b/app/src/test/java/com/ponzischeme89/memby/ui/ListKeysTest.kt @@ -40,6 +40,14 @@ class ListKeysTest { assertEquals(emptyList(), emptyList().distinctForKeys(BaseItem::id)) } + @Test + fun `items without identities are removed before lazy rendering`() { + val sanitised = listOf(row("row", BaseItem(name = "Incomplete"), item("valid"))) + .sanitisedRows() + + assertEquals(listOf("valid"), sanitised.single().items.map(BaseItem::id)) + } + // Two rows under one id crash the launcher's own LazyColumn — which the recommendation // refresh could produce by appending a freshly built row beside the one it was meant to // replace. diff --git a/app/src/test/java/com/ponzischeme89/memby/ui/player/PlaybackRecoveryTest.kt b/app/src/test/java/com/ponzischeme89/memby/ui/player/PlaybackRecoveryTest.kt index 139183d..1fec374 100644 --- a/app/src/test/java/com/ponzischeme89/memby/ui/player/PlaybackRecoveryTest.kt +++ b/app/src/test/java/com/ponzischeme89/memby/ui/player/PlaybackRecoveryTest.kt @@ -48,6 +48,16 @@ class PlaybackRecoveryTest { assertTrue(failure.canAutoRetry) } + @Test + fun malformedContainersDoNotLoopThroughTranscodingRetries() { + val failure = describePlaybackFailure( + PlaybackException.ERROR_CODE_PARSING_CONTAINER_MALFORMED, + ) + + assertFalse(failure.canAutoRetry) + assertFalse(failure.requiresTranscode) + } + @Test fun automaticRetriesAreBoundedAndBackOff() { assertEquals(1_000L, automaticRetryDelayMs(1)) @@ -63,4 +73,17 @@ class PlaybackRecoveryTest { assertFalse(shouldRecoverProlongedRebuffer(true, false, true, false)) assertFalse(shouldRecoverProlongedRebuffer(true, false, false, true)) } + + @Test + fun replacementSessionDetectionKeepsSameSessionStreamSwapsTogether() { + assertFalse( + playbackSessionChanged("episode", "source-a", "session", "episode", "source-b", "session"), + ) + assertTrue( + playbackSessionChanged("episode", "source-a", "old", "episode", "source-b", "new"), + ) + assertTrue( + playbackSessionChanged("episode", "source-a", "", "episode", "source-b", ""), + ) + } } diff --git a/app/src/test/java/com/ponzischeme89/memby/ui/player/PlayerTimingTest.kt b/app/src/test/java/com/ponzischeme89/memby/ui/player/PlayerTimingTest.kt index f19a502..f9be6e1 100644 --- a/app/src/test/java/com/ponzischeme89/memby/ui/player/PlayerTimingTest.kt +++ b/app/src/test/java/com/ponzischeme89/memby/ui/player/PlayerTimingTest.kt @@ -57,5 +57,13 @@ class PlayerTimingTest { PlaybackCompletionAction.PLAY_NEXT, playbackCompletionAction(hasNextEpisode = true, nextUpDismissed = false), ) + assertEquals( + PlaybackCompletionAction.WAIT_FOR_NEXT, + playbackCompletionAction( + hasNextEpisode = false, + nextUpDismissed = false, + nextLookupInFlight = true, + ), + ) } } diff --git a/server/internal/api/api.go b/server/internal/api/api.go index 1bce62e..034c7f3 100644 --- a/server/internal/api/api.go +++ b/server/internal/api/api.go @@ -566,6 +566,14 @@ func (s *Server) writeUpstreamError( case apiErr.StatusCode == http.StatusNotFound: writeError(w, http.StatusNotFound, "not found on the emby server") return + case apiErr.StatusCode == http.StatusTooManyRequests: + retryAfter := strings.TrimSpace(apiErr.RetryAfter) + if retryAfter == "" { + retryAfter = "60" + } + w.Header().Set("Retry-After", retryAfter) + writeError(w, http.StatusTooManyRequests, "the emby server is receiving too many requests") + return } } s.loggerFor(ctx).Error(message, "error", err) diff --git a/server/internal/api/api_test.go b/server/internal/api/api_test.go index df4e94f..29b3b19 100644 --- a/server/internal/api/api_test.go +++ b/server/internal/api/api_test.go @@ -38,6 +38,41 @@ func TestBearerTokenSources(t *testing.T) { }) } +func TestWriteUpstreamErrorPreservesRateLimitDelay(t *testing.T) { + s := &Server{} + rec := httptest.NewRecorder() + + s.writeUpstreamError( + context.Background(), + rec, + &emby.APIError{StatusCode: http.StatusTooManyRequests, RetryAfter: "37"}, + "could not reach emby", + ) + + if rec.Code != http.StatusTooManyRequests { + t.Fatalf("status = %d, want 429", rec.Code) + } + if got := rec.Header().Get("Retry-After"); got != "37" { + t.Fatalf("Retry-After = %q, want 37", got) + } +} + +func TestWriteUpstreamErrorDefaultsMissingRateLimitDelay(t *testing.T) { + s := &Server{} + rec := httptest.NewRecorder() + + s.writeUpstreamError( + context.Background(), + rec, + &emby.APIError{StatusCode: http.StatusTooManyRequests}, + "could not reach emby", + ) + + if got := rec.Header().Get("Retry-After"); got != "60" { + t.Fatalf("Retry-After = %q, want 60", got) + } +} + func TestHashTokenIsStable(t *testing.T) { a, b := hashToken("token"), hashToken("token") if string(a) != string(b) { diff --git a/server/internal/api/playback.go b/server/internal/api/playback.go index f10b9d4..0ad1e64 100644 --- a/server/internal/api/playback.go +++ b/server/internal/api/playback.go @@ -92,16 +92,20 @@ type playbackReportResponse struct { // Emby's own item JSON, forwarded verbatim like every other item the gateway returns, so // the client decodes it into the same BaseItem it uses everywhere else. type nextEpisodeResponse struct { - Item json.RawMessage `json:"item"` - Title string `json:"title"` - URL string `json:"url"` - ResumePositionMs int64 `json:"resumePositionMs"` - Subtitles []playableSubtitle `json:"subtitles"` - SubtitlesEnabled bool `json:"subtitlesEnabled"` - SelectedSubtitleID string `json:"selectedSubtitleId,omitempty"` - MediaSourceID string `json:"mediaSourceId"` - PlaySessionID string `json:"playSessionId"` - PlayMethod string `json:"playMethod"` + Item json.RawMessage `json:"item"` + Title string `json:"title"` + URL string `json:"url"` + ResumePositionMs int64 `json:"resumePositionMs"` + Subtitles []playableSubtitle `json:"subtitles"` + SubtitlesEnabled bool `json:"subtitlesEnabled"` + SelectedSubtitleID string `json:"selectedSubtitleId,omitempty"` + MediaSourceID string `json:"mediaSourceId"` + PlaySessionID string `json:"playSessionId"` + PlayMethod string `json:"playMethod"` + SubtitleDownloadAvailable bool `json:"subtitleDownloadAvailable"` + TrickplayAvailable bool `json:"trickplayAvailable"` + SkipIntroAvailable bool `json:"skipIntroAvailable"` + EndCreditsAvailable bool `json:"endCreditsAvailable"` } // handlePlayback resolves what to actually play. @@ -366,16 +370,20 @@ func (s *Server) handleNextEpisode(w http.ResponseWriter, r *http.Request, sess ) writeJSON(w, http.StatusOK, nextEpisodeResponse{ - Item: raw, - Title: title, - URL: streamURL, - ResumePositionMs: max64(next.UserData.PlaybackPositionTicks/ticksPerMillisecond, 0), - Subtitles: subtitles, - SubtitlesEnabled: subtitlesEnabled, - SelectedSubtitleID: selectedSubtitleID, - MediaSourceID: mediaSourceID, - PlaySessionID: playSessionID, - PlayMethod: playMethod, + Item: raw, + Title: title, + URL: streamURL, + ResumePositionMs: max64(next.UserData.PlaybackPositionTicks/ticksPerMillisecond, 0), + Subtitles: subtitles, + SubtitlesEnabled: subtitlesEnabled, + SelectedSubtitleID: selectedSubtitleID, + MediaSourceID: mediaSourceID, + PlaySessionID: playSessionID, + PlayMethod: playMethod, + SubtitleDownloadAvailable: s.subtitleDownloadAvailable(ctx), + TrickplayAvailable: s.trickplayEnabled(ctx), + SkipIntroAvailable: s.skipIntroEnabled(ctx), + EndCreditsAvailable: s.endCreditsEnabled(ctx), }) } @@ -729,8 +737,15 @@ func (s *Server) handlePlaybackReport(w http.ResponseWriter, r *http.Request, se max64(report.PositionMs, 0)*ticksPerMillisecond, report.IsPaused, ) if err != nil { - // A dropped progress report is not worth failing playback over; log and accept. log.Warn("playback report failed", "phase", phase, "error", err) + // Progress is advisory and another reading follows in ten seconds. A final stop is + // different: the television persists it in WorkManager specifically so an outage + // cannot lose the final resume position. Returning success here would consume that + // durable work and disable its bounded backoff. + if phase == "stopped" { + s.writeUpstreamError(r.Context(), w, err, "could not report playback stopped") + return + } } // Start and stop are the shape of an evening's viewing and belong in the normal log. diff --git a/server/internal/api/playback_report_test.go b/server/internal/api/playback_report_test.go new file mode 100644 index 0000000..e548d64 --- /dev/null +++ b/server/internal/api/playback_report_test.go @@ -0,0 +1,39 @@ +package api + +import ( + "io" + "log/slog" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/ponzischeme89/memby/server/internal/emby" + "github.com/ponzischeme89/memby/server/internal/store" +) + +func TestStoppedPlaybackReportRemainsRetryableWhenEmbyIsDown(t *testing.T) { + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + http.Error(w, "temporarily unavailable", http.StatusServiceUnavailable) + })) + defer upstream.Close() + + s := &Server{ + emby: emby.New(upstream.URL, upstream.URL, "Memby test", time.Second), + log: slog.New(slog.NewTextHandler(io.Discard, nil)), + } + req := httptest.NewRequest( + http.MethodPost, + "/v1/playback/stopped", + strings.NewReader(`{"itemId":"episode-1","positionMs":42000}`), + ) + req.SetPathValue("phase", "stopped") + rec := httptest.NewRecorder() + + s.handlePlaybackReport(rec, req, store.Session{EmbyUserID: "user-1", EmbyToken: "token"}) + + if rec.Code != http.StatusBadGateway { + t.Fatalf("status = %d, want 502 so the TV retries the durable stop", rec.Code) + } +} diff --git a/server/internal/api/requests.go b/server/internal/api/requests.go index b71a8cf..f8db8e2 100644 --- a/server/internal/api/requests.go +++ b/server/internal/api/requests.go @@ -180,7 +180,10 @@ func (s *Server) handleRequest(w http.ResponseWriter, r *http.Request, sess stor continue } if movie.ID > 0 { - writeError(w, http.StatusConflict, "this movie is already in Radarr") + // Idempotent under a lost response: OkHttp may replay a repeatable POST after + // a connection reset. If the first request already added it, the retry is the + // same successful action rather than an error shown to the viewer. + writeJSON(w, http.StatusOK, map[string]any{"status": "already_added", "title": movie.Title}) return } added, err := s.radarr.AddUnmonitored(r.Context(), movie) @@ -207,7 +210,7 @@ func (s *Server) handleRequest(w http.ResponseWriter, r *http.Request, sess stor continue } if show.ID > 0 { - writeError(w, http.StatusConflict, "this series is already in Sonarr") + writeJSON(w, http.StatusOK, map[string]any{"status": "already_added", "title": show.Title}) return } added, err := s.sonarr.AddUnmonitored(r.Context(), show) diff --git a/server/internal/emby/client.go b/server/internal/emby/client.go index 0796b49..fbab5ba 100644 --- a/server/internal/emby/client.go +++ b/server/internal/emby/client.go @@ -126,6 +126,7 @@ type MediaStream struct { type APIError struct { StatusCode int Body string + RetryAfter string } func (e *APIError) Error() string { @@ -476,7 +477,7 @@ func (c *Client) ImageResponse(ctx context.Context, cred Credentials, itemID, im if resp.StatusCode >= 400 { body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048)) resp.Body.Close() - return nil, &APIError{StatusCode: resp.StatusCode, Body: string(body)} + return nil, &APIError{StatusCode: resp.StatusCode, Body: string(body), RetryAfter: resp.Header.Get("Retry-After")} } return resp, nil } @@ -509,7 +510,7 @@ func (c *Client) TrickplayBytes( defer resp.Body.Close() if resp.StatusCode >= 400 { body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048)) - return nil, &APIError{StatusCode: resp.StatusCode, Body: string(body)} + return nil, &APIError{StatusCode: resp.StatusCode, Body: string(body), RetryAfter: resp.Header.Get("Retry-After")} } // Cap the read at what was asked for. A 200 means the range was ignored and the whole // file is on its way, which must not become a multi-megabyte read on a seek. @@ -548,7 +549,7 @@ func (c *Client) SubtitleBytes( defer resp.Body.Close() if resp.StatusCode >= 400 { body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048)) - return nil, &APIError{StatusCode: resp.StatusCode, Body: string(body)} + return nil, &APIError{StatusCode: resp.StatusCode, Body: string(body), RetryAfter: resp.Header.Get("Retry-After")} } return io.ReadAll(io.LimitReader(resp.Body, MaxSubtitleBytes)) } @@ -689,7 +690,7 @@ func (c *Client) do(req *http.Request, out any) error { // Emby error bodies can echo request details; cap what we keep and never log it // alongside a token. body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048)) - return &APIError{StatusCode: resp.StatusCode, Body: string(body)} + return &APIError{StatusCode: resp.StatusCode, Body: string(body), RetryAfter: resp.Header.Get("Retry-After")} } if out == nil { _, _ = io.Copy(io.Discard, resp.Body)