This commit is contained in:
ponzischeme89
2026-08-11 15:18:26 +12:00
parent 594159c0ae
commit 5e5849f985
33 changed files with 1143 additions and 241 deletions
+20
View File
@@ -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 ## 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. - Fixed: D-pad focus now stays visible and predictable across My Shows, Settings, profile management, the TV calendar, detail pages and changing home rows.
+1 -1
View File
@@ -42,7 +42,7 @@ val projectNoticeText =
// A release workflow can derive the app version from its Git tag without editing the // 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. // source tree. Local builds keep using the checked-in default.
val defaultVersionName = "0.2.49" val defaultVersionName = "0.2.50"
val membyVersionName: String = val membyVersionName: String =
(project.findProperty("memby.versionName") as String?) (project.findProperty("memby.versionName") as String?)
?.trim() ?.trim()
@@ -74,6 +74,9 @@ data class NextEpisode(
val seriesName: String, val seriesName: String,
val episodeCode: String?, val episodeCode: String?,
val imageUrl: String?, val imageUrl: String?,
val logoUrl: String? = null,
val overview: String = "",
val runtimeMs: Long = 0L,
val url: String, val url: String,
val resumePositionMs: Long = 0L, val resumePositionMs: Long = 0L,
val subtitles: List<PlayableSubtitle> = emptyList(), val subtitles: List<PlayableSubtitle> = emptyList(),
@@ -82,6 +85,10 @@ data class NextEpisode(
val mediaSourceId: String = "", val mediaSourceId: String = "",
val playSessionId: String = "", val playSessionId: String = "",
val playMethod: String = "DirectPlay", 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. */ /** 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. */ /** A shuffled set of movies & shows that actually have a backdrop image. */
suspend fun getScreensaverItems(limit: Int = 200): List<BaseItem> { suspend fun getScreensaverItems(limit: Int = 200): List<BaseItem> {
if (ServerConfig.isGateway) { 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 userId = snapshot.userId ?: error("Not connected")
val result = requireApi().getItems( val result = requireApi().getItems(
@@ -508,7 +517,7 @@ class EmbyRepository(private val settings: SettingsStore) {
"EnableUserData" to "true", "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 { suspend fun getCalendarMonth(month: String = ""): GatewayCalendar {
if (!ServerConfig.isGateway) return 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<com.ponzischeme89.memby.data.model.GatewayRequestCandidate> { suspend fun lookupMediaRequests(term: String): List<com.ponzischeme89.memby.data.model.GatewayRequestCandidate> {
@@ -900,7 +914,11 @@ class EmbyRepository(private val settings: SettingsStore) {
} }
suspend fun getMyShows(): List<com.ponzischeme89.memby.data.model.MyShow> = suspend fun getMyShows(): List<com.ponzischeme89.memby.data.model.MyShow> =
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<com.ponzischeme89.memby.data.model.MyShow> { suspend fun saveMyShow(item: BaseItem): List<com.ponzischeme89.memby.data.model.MyShow> {
if (!ServerConfig.isGateway || !item.isSeries) return getMyShows() if (!ServerConfig.isGateway || !item.isSeries) return getMyShows()
@@ -911,7 +929,7 @@ class EmbyRepository(private val settings: SettingsStore) {
year = item.productionYear, year = item.productionYear,
imageTag = item.imageTags["Primary"].orEmpty(), imageTag = item.imageTags["Primary"].orEmpty(),
), ),
).shows ).shows.filter { it.itemId.isNotBlank() }
} }
suspend fun removeMyShow(itemId: String) { suspend fun removeMyShow(itemId: String) {
@@ -920,7 +938,9 @@ class EmbyRepository(private val settings: SettingsStore) {
suspend fun getNotifications(): com.ponzischeme89.memby.data.model.NotificationsResponse = suspend fun getNotifications(): com.ponzischeme89.memby.data.model.NotificationsResponse =
if (ServerConfig.isGateway) { if (ServerConfig.isGateway) {
requireGateway().notifications() requireGateway().notifications().let { response ->
response.copy(notifications = response.notifications.filter { it.id > 0 })
}
} else { } else {
com.ponzischeme89.memby.data.model.NotificationsResponse() com.ponzischeme89.memby.data.model.NotificationsResponse()
} }
@@ -928,7 +948,9 @@ class EmbyRepository(private val settings: SettingsStore) {
suspend fun setNotificationPreferences( suspend fun setNotificationPreferences(
value: com.ponzischeme89.memby.data.model.NotificationPreferences, value: com.ponzischeme89.memby.data.model.NotificationPreferences,
): com.ponzischeme89.memby.data.model.NotificationsResponse = ): 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) { suspend fun markNotificationRead(id: Long) {
if (ServerConfig.isGateway) requireGateway().updateNotification(id, "read") if (ServerConfig.isGateway) requireGateway().updateNotification(id, "read")
@@ -1281,7 +1303,7 @@ class EmbyRepository(private val settings: SettingsStore) {
"Limit" to "1000", "Limit" to "1000",
), ),
).items ).items
}).distinctBy(BaseItem::id) }).filter { it.id.isNotBlank() }.distinctBy(BaseItem::id)
seriesEpisodesMutex.withLock { seriesEpisodesMutex.withLock {
seriesEpisodesCache[seriesId] = CachedSeriesEpisodes( seriesEpisodesCache[seriesId] = CachedSeriesEpisodes(
episodes = loaded, episodes = loaded,
@@ -2191,6 +2213,8 @@ class EmbyRepository(private val settings: SettingsStore) {
response.item, response.url, response.resumePositionMs, resolveSubtitleUrls(response.subtitles), response.item, response.url, response.resumePositionMs, resolveSubtitleUrls(response.subtitles),
response.subtitlesEnabled, response.selectedSubtitleId, response.subtitlesEnabled, response.selectedSubtitleId,
response.mediaSourceId, response.playSessionId, response.playMethod, 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, next, discovery.url ?: buildStreamUrl(next.id), next.resumePositionMs, discovery.subtitles,
snapshot.subtitlesEnabled, selectedSubtitleId(discovery.subtitles), snapshot.subtitlesEnabled, selectedSubtitleId(discovery.subtitles),
discovery.mediaSourceId, discovery.playSessionId, discovery.playMethod, discovery.mediaSourceId, discovery.playSessionId, discovery.playMethod,
true, true, true, true,
) )
} }
@@ -2231,12 +2256,19 @@ class EmbyRepository(private val settings: SettingsStore) {
mediaSourceId: String, mediaSourceId: String,
playSessionId: String, playSessionId: String,
playMethod: String, playMethod: String,
subtitleDownloadAvailable: Boolean,
trickplayAvailable: Boolean,
skipIntroAvailable: Boolean,
endCreditsAvailable: Boolean,
) = NextEpisode( ) = NextEpisode(
itemId = item.id, itemId = item.id,
title = item.name, title = item.name,
seriesName = item.seriesName.orEmpty(), seriesName = item.seriesName.orEmpty(),
episodeCode = item.episodeCode, episodeCode = item.episodeCode,
imageUrl = primaryUrl(item, maxWidth = 400), imageUrl = primaryUrl(item, maxWidth = 400),
logoUrl = logoUrl(item),
overview = item.overview.orEmpty(),
runtimeMs = item.runTimeTicks?.div(10_000L) ?: 0L,
url = url, url = url,
resumePositionMs = resumePositionMs, resumePositionMs = resumePositionMs,
subtitles = subtitles, subtitles = subtitles,
@@ -2245,6 +2277,10 @@ class EmbyRepository(private val settings: SettingsStore) {
mediaSourceId = mediaSourceId, mediaSourceId = mediaSourceId,
playSessionId = playSessionId, playSessionId = playSessionId,
playMethod = playMethod, 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." 401 -> "Session expired. Open Memby to sign in again."
403 -> "Access denied by the server." 403 -> "Access denied by the server."
404 -> "Not found on the server." 404 -> "Not found on the server."
429 -> retryAfterMessage(t)
// The gateway answers 503 when an operator has deliberately taken Memby down. // The gateway answers 503 when an operator has deliberately taken Memby down.
// Showing their message beats a generic "server problem" the viewer can do // Showing their message beats a generic "server problem" the viewer can do
// nothing about. // nothing about.
@@ -2763,6 +2800,15 @@ fun friendlyEmbyError(t: Throwable): String = when (t) {
else -> "Something went wrong. Try again." 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 { private fun maintenanceMessage(t: HttpException): String? = runCatching {
parseMaintenanceMessage(t.response()?.errorBody()?.string().orEmpty()) parseMaintenanceMessage(t.response()?.errorBody()?.string().orEmpty())
}.getOrNull() }.getOrNull()
@@ -211,6 +211,7 @@ class MaintenanceMonitor(
} }
while (isActive) { while (isActive) {
var nextPollDelayMs = POLL_INTERVAL_MS
runCatching { repository.serviceStatus() } runCatching { repository.serviceStatus() }
.onSuccess { status -> .onSuccess { status ->
_compatibility.value = if (status.compatible) { _compatibility.value = if (status.compatible) {
@@ -251,6 +252,13 @@ class MaintenanceMonitor(
} }
} }
.onFailure { error -> .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)) { if (isUnauthorizedError(error)) {
repository.invalidateSession() repository.invalidateSession()
_notice.value = null _notice.value = null
@@ -266,7 +274,7 @@ class MaintenanceMonitor(
return@collectLatest return@collectLatest
} }
} }
delay(POLL_INTERVAL_MS) delay(nextPollDelayMs)
} }
} }
} }
@@ -358,6 +366,8 @@ class MaintenanceMonitor(
companion object { companion object {
internal const val POLL_INTERVAL_MS = 10_000L 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. */ /** Matches `featureInstallPermission` in the gateway's feature catalogue. */
internal const val INSTALL_PERMISSION_FEATURE = "install_permission_prompt" internal const val INSTALL_PERMISSION_FEATURE = "install_permission_prompt"
@@ -6,8 +6,12 @@ import androidx.lifecycle.repeatOnLifecycle
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob 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.StateFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -68,11 +72,7 @@ class PreferencesSync(
// and be considered for a push. // and be considered for a push.
.map { (session, revision) -> SyncTrigger(session, revision) } .map { (session, revision) -> SyncTrigger(session, revision) }
.distinctUntilChanged() .distinctUntilChanged()
// Plain collect, not collectLatest: a reconcile that is cancelled halfway .collectLatest(::reconcile)
// 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)
} }
} }
@@ -95,21 +95,31 @@ class PreferencesSync(
private suspend fun reconcile(trigger: SyncTrigger) { private suspend fun reconcile(trigger: SyncTrigger) {
if (!ServerConfig.isGateway || !trigger.signedIn) return 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 val agreed = lastSynced?.takeIf { it.first == trigger.profileKey }?.second
// Never synced on this TV, or the server has moved on without us. Pull first: // 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 // 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. // change made on another television with this one's defaults.
if (agreed == null || trigger.remoteRevision > trigger.localRevision) { 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 // Only a genuine local edit gets pushed. Anything else is either the document
// just adopted or an unrelated part of Settings changing. // just adopted or an unrelated part of Settings changing.
val current = lastSynced?.takeIf { it.first == trigger.profileKey }?.second 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. // so they are pushed up rather than replaced by catalogue defaults.
if (remote.revision == 0L) { if (remote.revision == 0L) {
lastSynced = trigger.profileKey to UserPreferences() lastSynced = trigger.profileKey to UserPreferences()
push(trigger) return push(trigger)
return true
} }
val decoded = decodeUserPreferences(remote.preferences, fallback = trigger.local) val decoded = decodeUserPreferences(remote.preferences, fallback = trigger.local)
lastSynced = trigger.profileKey to decoded lastSynced = trigger.profileKey to decoded
@@ -135,10 +144,10 @@ class PreferencesSync(
return true return true
} }
private suspend fun push(trigger: SyncTrigger) { private suspend fun push(trigger: SyncTrigger): Boolean {
val stored = runCatching { val stored = runCatching {
repository.saveUserPreferences(trigger.localRevision, trigger.local.encode()) 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 — // 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 // an operator's push, nearly always. Adopting it is the correct outcome: this TV
@@ -160,5 +169,11 @@ class PreferencesSync(
.onFailure { lastSynced = null } .onFailure { lastSynced = null }
} }
} }
return true
}
private companion object {
const val INITIAL_RETRY_MS = 1_000L
const val MAX_RETRY_MS = 60_000L
} }
} }
@@ -10,10 +10,14 @@ import com.ponzischeme89.memby.ui.theme.applyMembyPalette
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob 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.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
@@ -100,9 +104,7 @@ class ThemeSync(
SyncTrigger(session, status) SyncTrigger(session, status)
} }
.distinctUntilChanged() .distinctUntilChanged()
// Plain collect rather than collectLatest: a fetch cancelled halfway could .collectLatest(::reconcile)
// leave the stored revision describing a palette that was never written.
.collect(::reconcile)
} }
} }
@@ -139,12 +141,18 @@ class ThemeSync(
) { ) {
return 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) { private suspend fun fetch(expectedRevision: String): Boolean {
if (appliedRevision == expectedRevision) return if (appliedRevision == expectedRevision) return true
val document = runCatching { repository.theme() }.getOrNull() ?: return val document = runCatching { repository.theme() }.getOrNull() ?: return false
val resolved = document.theme val resolved = document.theme
_theme.value = resolved _theme.value = resolved
_available.value = document.available _available.value = document.available
@@ -165,6 +173,7 @@ class ThemeSync(
// now and pays one extra fetch on its next cold start. Clearing the applied // now and pays one extra fetch on its next cold start. Clearing the applied
// revision would instead make it refetch on every poll. // revision would instead make it refetch on every poll.
} }
return true
} }
/** /**
@@ -181,4 +190,9 @@ class ThemeSync(
}.getOrNull() ?: return }.getOrNull() ?: return
applyMembyPalette(palette.toMembyPalette()) applyMembyPalette(palette.toMembyPalette())
} }
private companion object {
const val INITIAL_RETRY_MS = 1_000L
const val MAX_RETRY_MS = 60_000L
}
} }
@@ -377,7 +377,9 @@ data class EmbyChapter(
@Serializable @Serializable
data class BaseItem( 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("Name") val name: String = "",
@SerialName("Type") val type: String = "", @SerialName("Type") val type: String = "",
@SerialName("Overview") val overview: String? = null, @SerialName("Overview") val overview: String? = null,
@@ -56,8 +56,8 @@ data class GatewayDeviceNameRequest(val deviceName: String)
*/ */
@Serializable @Serializable
data class HomeRow( data class HomeRow(
val id: String, val id: String = "",
val title: String, val title: String = "",
val kind: String = "", val kind: String = "",
val items: List<BaseItem> = emptyList(), val items: List<BaseItem> = emptyList(),
) )
@@ -636,6 +636,10 @@ data class GatewayNextEpisode(
val mediaSourceId: String = "", val mediaSourceId: String = "",
val playSessionId: String = "", val playSessionId: String = "",
val playMethod: String = "DirectPlay", val playMethod: String = "DirectPlay",
val subtitleDownloadAvailable: Boolean = false,
val trickplayAvailable: Boolean = false,
val skipIntroAvailable: Boolean = false,
val endCreditsAvailable: Boolean = false,
) )
@Serializable @Serializable
@@ -653,8 +657,8 @@ data class GatewayFlagRequest(
@Serializable @Serializable
data class MyShow( data class MyShow(
val itemId: String, val itemId: String = "",
val title: String, val title: String = "",
val year: Int? = null, val year: Int? = null,
val imageTag: String = "", val imageTag: String = "",
val addedAt: String = "", val addedAt: String = "",
@@ -686,7 +690,7 @@ data class NotificationPreferences(
@Serializable @Serializable
data class UserNotification( data class UserNotification(
val id: Long, val id: Long = 0,
val kind: String = "", val kind: String = "",
val itemId: String = "", val itemId: String = "",
val title: String = "", val title: String = "",
@@ -105,12 +105,22 @@ fun EpisodeDetailsOverlay(
episodes = emptyList() episodes = emptyList()
return@LaunchedEffect return@LaunchedEffect
} }
runCatching { repository.getSeriesEpisodes(seriesId) } var retryDelayMs = 2_000L
.onSuccess { episodes = it.sortedWith(seriesEpisodeComparator) } while (true) {
.onFailure { runCatching { repository.getSeriesEpisodes(seriesId) }
loadFailed = true .onSuccess {
episodes = emptyList() 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) { LaunchedEffect(item.id, settings.showRatingsStrip) {
ratings = if (settings.showRatingsStrip) repository.getRatings(item) else emptyList() ratings = if (settings.showRatingsStrip) repository.getRatings(item) else emptyList()
@@ -160,6 +160,9 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
private val _forYou = MutableStateFlow(ForYouUiState()) private val _forYou = MutableStateFlow(ForYouUiState())
val forYou: StateFlow<ForYouUiState> = _forYou.asStateFlow() val forYou: StateFlow<ForYouUiState> = _forYou.asStateFlow()
private var metadataJob: Job? = null private var metadataJob: Job? = null
private var refreshJob: Job? = null
private var forYouJob: Job? = null
private var forYouRequestId = 0L
private val metadataCache = object : LinkedHashMap<String, BaseItem>(32, 0.75f, true) { private val metadataCache = object : LinkedHashMap<String, BaseItem>(32, 0.75f, true) {
override fun removeEldestEntry(eldest: MutableMap.MutableEntry<String, BaseItem>?): Boolean = size > 32 override fun removeEldestEntry(eldest: MutableMap.MutableEntry<String, BaseItem>?): Boolean = size > 32
} }
@@ -222,11 +225,14 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
fun loadForYou(availableMinutes: Int = _forYou.value.availableMinutes) { fun loadForYou(availableMinutes: Int = _forYou.value.availableMinutes) {
val minutes = availableMinutes.coerceIn(0, 360) val minutes = availableMinutes.coerceIn(0, 360)
val requestId = ++forYouRequestId
forYouJob?.cancel()
_focusedItem.value = null _focusedItem.value = null
_forYou.update { it.copy(availableMinutes = minutes, loading = true, error = null) } _forYou.update { it.copy(availableMinutes = minutes, loading = true, error = null) }
viewModelScope.launch(Dispatchers.IO) { forYouJob = viewModelScope.launch(Dispatchers.IO) {
runCatching { repository.getForYou(minutes) } runCatching { repository.getForYou(minutes) }
.onSuccess { built -> .onSuccess { built ->
if (requestId != forYouRequestId) return@onSuccess
val rows = built.sanitisedRows() val rows = built.sanitisedRows()
_forYou.value = ForYouUiState( _forYou.value = ForYouUiState(
rows = rows, rows = rows,
@@ -235,7 +241,9 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
) )
rows.firstNotNullOfOrNull { it.items.firstOrNull() }?.let(::focusItem) 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 { _forYou.update {
it.copy( it.copy(
loading = false, loading = false,
@@ -247,23 +255,32 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
} }
fun refreshAll() { fun refreshAll() {
viewModelScope.launch(Dispatchers.IO) { // One recovery loop owns refreshes. Repeated Select presses while a request is
refreshMutex.withLock { // slow therefore do not queue a burst that hits the server after it recovers.
// A refresh can make a newly imported episode the next playable item for if (refreshJob?.isActive == true) return
// an existing series ID. Never launch the pre-refresh negotiated session. refreshJob = viewModelScope.launch(Dispatchers.IO) {
repository.invalidatePlaybackPrefetch() var retryDelayMs = HOME_RETRY_INITIAL_MS
_state.update { it.copy(loading = HomeSection.entries.toSet(), hasRefreshError = false) } do {
if (repository.supportsBatchHome) { refreshMutex.withLock {
loadBatchHome() // A refresh can make a newly imported episode the next playable item for
} else { // an existing series ID. Never launch the pre-refresh negotiated session.
coroutineScope { repository.invalidatePlaybackPrefetch()
launch { loadContinueWatching() } _state.update { it.copy(loading = HomeSection.entries.toSet(), hasRefreshError = false) }
launch { loadFavorites() } if (repository.supportsBatchHome) {
launch { loadLatest() } 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 DETAIL_PREFETCH_DELAY_MS = 450L
private const val ANALYTICS_FLUSH_INTERVAL_MS = 20_000L 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? = private fun initialFocusedItem(state: HomeUiState): BaseItem? =
state.continueWatching.firstOrNull() state.continueWatching.firstOrNull()
?: state.latestMovies.firstOrNull() ?: state.latestMovies.firstOrNull()
?: state.favorites.firstOrNull() ?: state.favorites.firstOrNull()
} }
} }
/** /**
@@ -39,7 +39,8 @@ internal inline fun <T, K> List<T>.distinctForKeys(selector: (T) -> K): List<T>
} }
/** [distinctForKeys] for the one shape most of these lists have. */ /** [distinctForKeys] for the one shape most of these lists have. */
internal fun List<BaseItem>.distinctItems(): List<BaseItem> = distinctForKeys(BaseItem::id) internal fun List<BaseItem>.distinctItems(): List<BaseItem> =
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 * 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<BaseItem>.distinctItems(): List<BaseItem> = distinctForKeys(Ba
* whichever row holds them, taking the launcher down just the same. * whichever row holds them, taking the launcher down just the same.
*/ */
internal fun List<HomeRow>.sanitisedRows(): List<HomeRow> = internal fun List<HomeRow>.sanitisedRows(): List<HomeRow> =
distinctForKeys(HomeRow::id).map { row -> filter { it.id.isNotBlank() }.distinctForKeys(HomeRow::id).map { row ->
val items = row.items.distinctItems() val items = row.items.distinctItems()
if (items.size == row.items.size) row else row.copy(items = items) if (items.size == row.items.size) row else row.copy(items = items)
} }
@@ -122,6 +122,7 @@ import com.ponzischeme89.memby.BuildConfig
import com.ponzischeme89.memby.ServiceLocator import com.ponzischeme89.memby.ServiceLocator
import com.ponzischeme89.memby.data.Settings import com.ponzischeme89.memby.data.Settings
import com.ponzischeme89.memby.data.EmbyProfile 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.BaseItem
import com.ponzischeme89.memby.data.model.GatewayUpdate import com.ponzischeme89.memby.data.model.GatewayUpdate
import com.ponzischeme89.memby.data.model.HomeRow import com.ponzischeme89.memby.data.model.HomeRow
@@ -1898,10 +1899,16 @@ private fun HomeScreen(
var quickMenuRowId by remember { mutableStateOf<String?>(null) } var quickMenuRowId by remember { mutableStateOf<String?>(null) }
var focusedHomeRowId by remember { mutableStateOf<String?>(null) } var focusedHomeRowId by remember { mutableStateOf<String?>(null) }
var myShows by remember(settings.userId) { mutableStateOf<List<MyShow>>(emptyList()) } var myShows by remember(settings.userId) { mutableStateOf<List<MyShow>>(emptyList()) }
var myShowsLoading by remember(settings.userId) { mutableStateOf(true) }
var myShowsError by remember(settings.userId) { mutableStateOf<String?>(null) }
var myShowsMutationBusy by remember(settings.userId) { mutableStateOf(false) }
var selectedMyShow by remember { mutableStateOf<MyShow?>(null) } var selectedMyShow by remember { mutableStateOf<MyShow?>(null) }
var myShowReturnItemId by remember { mutableStateOf<String?>(null) } var myShowReturnItemId by remember { mutableStateOf<String?>(null) }
var removingMyShow by remember { mutableStateOf(false) } var removingMyShow by remember { mutableStateOf(false) }
var notificationState by remember(settings.userId) { mutableStateOf(NotificationsResponse()) } var notificationState by remember(settings.userId) { mutableStateOf(NotificationsResponse()) }
var notificationsLoading by remember(settings.userId) { mutableStateOf(true) }
var notificationsError by remember(settings.userId) { mutableStateOf<String?>(null) }
var notificationsMutationBusy by remember(settings.userId) { mutableStateOf(false) }
var showNotifications by remember { mutableStateOf(false) } var showNotifications by remember { mutableStateOf(false) }
// Two different things: [launchingItem] is the gate that stops a second Play press // 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 // 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. // routes into the player, including the one that hands over without waiting.
launchingItem = null launchingItem = null
scope.launch { scope.launch {
runCatching { repo.getMyShows() }.onSuccess { myShows = it } myShowsLoading = true
runCatching { repo.getNotifications() }.onSuccess { notificationState = it } 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) kotlinx.coroutines.delay(32L)
if (returnRowId != null && returnItemId != null) { if (returnRowId != null && returnItemId != null) {
requestFirstAvailableFocus( requestFirstAvailableFocus(
@@ -1999,8 +2014,16 @@ private fun HomeScreen(
} }
LaunchedEffect(settings.userId) { LaunchedEffect(settings.userId) {
runCatching { repo.getMyShows() }.onSuccess { myShows = it } myShowsLoading = true
runCatching { repo.getNotifications() }.onSuccess { notificationState = it } 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) { LaunchedEffect(liveMaintenance) {
@@ -2093,8 +2116,9 @@ private fun HomeScreen(
playable playable
} }
.onSuccess { playable -> .onSuccess { playable ->
playbackLauncher.launch( val launched = runCatching {
PlayerActivity.intent( playbackLauncher.launch(
PlayerActivity.intent(
context = context, context = context,
itemId = playable.itemId, itemId = playable.itemId,
url = playable.url, url = playable.url,
@@ -2118,8 +2142,13 @@ private fun HomeScreen(
playSessionId = playable.playSessionId, playSessionId = playable.playSessionId,
playMethod = playable.playMethod, playMethod = playable.playMethod,
requestStartedAtMs = playbackRequestedAtMs, requestStartedAtMs = playbackRequestedAtMs,
), ),
) )
}
if (launched.isFailure) {
Toast.makeText(context, "Couldnt start playback", Toast.LENGTH_SHORT).show()
launchingItem = null
}
} }
.onFailure { error -> .onFailure { error ->
// A cancellation is the viewer having pressed Back out of the // 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") { item(key = "my-shows", contentType = "my-shows") {
MyShowsStrip( MyShowsStrip(
shows = myShows, 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, repository = repo,
availableWidth = contentWidth, availableWidth = contentWidth,
density = settings.homeCardDensity, density = settings.homeCardDensity,
@@ -2958,8 +2999,12 @@ private fun HomeScreen(
navigationExpanded = false navigationExpanded = false
showNotifications = true showNotifications = true
scope.launch { scope.launch {
notificationsLoading = true
notificationsError = null
runCatching { repo.getNotifications() } runCatching { repo.getNotifications() }
.onSuccess { notificationState = it } .onSuccess { notificationState = it }
.onFailure { notificationsError = friendlyEmbyError(it) }
notificationsLoading = false
} }
}, },
onDismiss = { onDismiss = {
@@ -3144,6 +3189,8 @@ private fun HomeScreen(
// — a request the viewer has no reason to sit through watching an // — a request the viewer has no reason to sit through watching an
// unchanged button. The row that lands a moment later replaces this // unchanged button. The row that lands a moment later replaces this
// placeholder; a failure puts the button back where it was. // placeholder; a failure puts the button back where it was.
if (myShowsMutationBusy) return@FocusedDetailsOverlay
myShowsMutationBusy = true
val previous = myShows val previous = myShows
myShows = if (saved) { myShows = if (saved) {
myShows.filterNot { it.itemId == item.id } + myShowStub(item) myShows.filterNot { it.itemId == item.id } + myShowStub(item)
@@ -3166,6 +3213,7 @@ private fun HomeScreen(
runCatching { repo.removeMyShow(item.id) } runCatching { repo.removeMyShow(item.id) }
.onFailure { myShows = previous } .onFailure { myShows = previous }
} }
myShowsMutationBusy = false
} }
}, },
onTogglePlayed = { item, played -> onTogglePlayed = { item, played ->
@@ -3235,22 +3283,42 @@ private fun HomeScreen(
MyAlertsPage( MyAlertsPage(
notifications = notificationState.notifications, notifications = notificationState.notifications,
preferences = notificationState.preferences, 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 = { onToggleEnabled = {
if (notificationsMutationBusy) return@MyAlertsPage
notificationsMutationBusy = true
scope.launch { scope.launch {
val updated = notificationState.preferences.copy( val updated = notificationState.preferences.copy(
enabled = !notificationState.preferences.enabled, enabled = !notificationState.preferences.enabled,
) )
runCatching { repo.setNotificationPreferences(updated) } runCatching { repo.setNotificationPreferences(updated) }
.onSuccess { notificationState = it } .onSuccess { notificationState = it }
.onFailure { notificationsError = friendlyEmbyError(it) }
notificationsMutationBusy = false
} }
}, },
onToggleShowReturns = { onToggleShowReturns = {
if (notificationsMutationBusy) return@MyAlertsPage
notificationsMutationBusy = true
scope.launch { scope.launch {
val updated = notificationState.preferences.copy( val updated = notificationState.preferences.copy(
showReturnAlerts = !notificationState.preferences.showReturnAlerts, showReturnAlerts = !notificationState.preferences.showReturnAlerts,
) )
runCatching { repo.setNotificationPreferences(updated) } runCatching { repo.setNotificationPreferences(updated) }
.onSuccess { notificationState = it } .onSuccess { notificationState = it }
.onFailure { notificationsError = friendlyEmbyError(it) }
notificationsMutationBusy = false
} }
}, },
// Marked read locally first. This fires on *focus*, so on a slow // 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 // request once per pass — clearing the flag immediately is what makes the
// row stop asking. // row stop asking.
onRead = { notification -> onRead = { notification ->
val previousReadAt = notification.readAt
notificationState = notificationState.copy( notificationState = notificationState.copy(
notifications = notificationState.notifications.map { notifications = notificationState.notifications.map {
if (it.id == notification.id) it.copy(readAt = "now") else it 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 // Optimistic, for the reason "Dismiss all" beneath it already is: this
// page is judged entirely on emptying itself, and a row that stayed put // 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 // page also re-aims where focus lands afterwards. A failure puts the row
// back where it was rather than quietly losing somebody's alert. // back where it was rather than quietly losing somebody's alert.
onDismiss = { notification -> onDismiss = { notification ->
if (notificationsMutationBusy) return@MyAlertsPage
notificationsMutationBusy = true
val previous = notificationState val previous = notificationState
notificationState = notificationState.copy( notificationState = notificationState.copy(
notifications = notificationState.notifications.filterNot { notifications = notificationState.notifications.filterNot {
@@ -3279,7 +3364,11 @@ private fun HomeScreen(
) )
scope.launch { scope.launch {
runCatching { repo.dismissNotification(notification.id) } 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 // 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 // and a row that lingered while its request was in flight would be pressed
// a second time. // a second time.
onDismissAll = { onDismissAll = {
if (notificationsMutationBusy) return@MyAlertsPage
notificationsMutationBusy = true
val previous = notificationState
val pending = notificationState.notifications.map(UserNotification::id) val pending = notificationState.notifications.map(UserNotification::id)
notificationState = notificationState.copy(notifications = emptyList()) notificationState = notificationState.copy(notifications = emptyList())
scope.launch { scope.launch {
pending.forEach { id -> runCatching { repo.dismissNotification(id) } } val failed = pending.filter { id ->
runCatching { repo.dismissNotification(id) }.isFailure
}
runCatching { repo.getNotifications() } runCatching { repo.getNotifications() }
.onSuccess { notificationState = it } .onSuccess { notificationState = it }
.onFailure { failure ->
notificationState = previous
notificationsError = friendlyEmbyError(failure)
}
if (failed.isNotEmpty()) {
notificationsError = "Some alerts couldnt be dismissed. Try again."
}
notificationsMutationBusy = false
} }
}, },
onClose = closeAlerts, onClose = closeAlerts,
@@ -56,6 +56,9 @@ import java.time.format.DateTimeFormatter
@Composable @Composable
internal fun MyShowsStrip( internal fun MyShowsStrip(
shows: List<MyShow>, shows: List<MyShow>,
loading: Boolean = false,
errorMessage: String? = null,
onRetry: () -> Unit = {},
repository: EmbyRepository, repository: EmbyRepository,
availableWidth: Dp, availableWidth: Dp,
density: String, 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( Text(
"Open any series and choose “Add to My Shows”.", "Open any series and choose “Add to My Shows”.",
color = MembyQuietText, color = MembyQuietText,
@@ -104,6 +123,14 @@ internal fun MyShowsStrip(
modifier = Modifier.padding(horizontal = 36.dp, vertical = 18.dp), modifier = Modifier.padding(horizontal = 36.dp, vertical = 18.dp),
) )
} else { } else {
if (loading || errorMessage != null) {
Text(
if (loading) "Refreshing My Shows…" else "Couldnt refresh My Shows — showing saved results.",
color = MembyQuietText,
fontSize = 12.sp,
modifier = Modifier.padding(horizontal = 36.dp),
)
}
val cardsAcross = when (density) { val cardsAcross = when (density) {
"compact" -> 8 "compact" -> 8
"large" -> 5 "large" -> 5
@@ -121,12 +121,21 @@ fun SeriesDetailsOverlay(
var ratings by remember(item.id) { mutableStateOf<List<MediaRating>>(emptyList()) } var ratings by remember(item.id) { mutableStateOf<List<MediaRating>>(emptyList()) }
LaunchedEffect(item.id) { LaunchedEffect(item.id) {
runCatching { repository.getSeriesEpisodes(item.id) } var retryDelayMs = 2_000L
.onSuccess { episodes = it.sortedWith(seriesEpisodeComparator) } while (true) {
.onFailure { 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 loadFailed = true
episodes = emptyList() 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 // 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. // must not wait on "more like this" to show a show's own episodes.
@@ -85,6 +85,9 @@ import kotlinx.coroutines.delay
fun MyAlertsPage( fun MyAlertsPage(
notifications: List<UserNotification>, notifications: List<UserNotification>,
preferences: NotificationPreferences, preferences: NotificationPreferences,
loading: Boolean = false,
errorMessage: String? = null,
onRetry: () -> Unit = {},
onToggleEnabled: () -> Unit, onToggleEnabled: () -> Unit,
onToggleShowReturns: () -> Unit, onToggleShowReturns: () -> Unit,
onRead: (UserNotification) -> Unit, onRead: (UserNotification) -> Unit,
@@ -165,12 +168,19 @@ fun MyAlertsPage(
onClick = onDismissAll, onClick = onDismissAll,
) )
} }
if (errorMessage != null) {
MembyChoiceChip(label = "Try again", selected = false, onClick = onRetry)
}
Spacer(Modifier.weight(1f)) Spacer(Modifier.weight(1f))
MembyChoiceChip(label = "Close", selected = false, onClick = onClose) MembyChoiceChip(label = "Close", selected = false, onClick = onClose)
} }
Spacer(Modifier.height(18.dp)) Spacer(Modifier.height(18.dp))
Box(Modifier.fillMaxWidth().height(1.dp).background(MembyHairline)) 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) AlertsEmptyState(enabled = preferences.enabled)
} else { } else {
LazyColumn( 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. */ /** The row now occupying the removed row's place, or the preceding row at the end. */
internal fun alertFocusIndexAfterRemoval(removedIndex: Int, remainingCount: Int): Int? = internal fun alertFocusIndexAfterRemoval(removedIndex: Int, remainingCount: Int): Int? =
if (remainingCount <= 0) null else removedIndex.coerceIn(0, remainingCount - 1) if (remainingCount <= 0) null else removedIndex.coerceIn(0, remainingCount - 1)
@@ -170,6 +170,7 @@ class GenreBrowseViewModel(
) )
} }
}.onFailure { error -> }.onFailure { error ->
if (error is kotlinx.coroutines.CancellationException) throw error
_state.update { current -> _state.update { current ->
if (current.selectedCategoryId != category.id) current else current.copy( if (current.selectedCategoryId != category.id) current else current.copy(
isLoading = false, isLoading = false,
@@ -56,7 +56,6 @@ internal fun describePlaybackFailure(errorCode: Int): PlaybackFailure =
requiresTranscode = true, requiresTranscode = true,
) )
PlaybackException.ERROR_CODE_PARSING_CONTAINER_MALFORMED,
PlaybackException.ERROR_CODE_PARSING_CONTAINER_UNSUPPORTED, PlaybackException.ERROR_CODE_PARSING_CONTAINER_UNSUPPORTED,
PlaybackException.ERROR_CODE_PARSING_MANIFEST_UNSUPPORTED, PlaybackException.ERROR_CODE_PARSING_MANIFEST_UNSUPPORTED,
-> PlaybackFailure( -> PlaybackFailure(
@@ -7,6 +7,8 @@ import androidx.work.ExistingWorkPolicy
import androidx.work.OneTimeWorkRequestBuilder import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkManager import androidx.work.WorkManager
import androidx.work.WorkerParameters import androidx.work.WorkerParameters
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import com.ponzischeme89.memby.ServiceLocator import com.ponzischeme89.memby.ServiceLocator
import com.ponzischeme89.memby.data.PlaybackSession import com.ponzischeme89.memby.data.PlaybackSession
@@ -47,6 +49,9 @@ class PlaybackStopWorker(
private const val POSITION_MS = "position_ms" private const val POSITION_MS = "position_ms"
private const val MAX_RETRIES = 5 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) { fun enqueue(context: Context, session: PlaybackSession, positionMs: Long) {
val data = Data.Builder() val data = Data.Builder()
.putString(ITEM_ID, session.itemId) .putString(ITEM_ID, session.itemId)
@@ -58,12 +63,19 @@ class PlaybackStopWorker(
val request = OneTimeWorkRequestBuilder<PlaybackStopWorker>() val request = OneTimeWorkRequestBuilder<PlaybackStopWorker>()
.setInputData(data) .setInputData(data)
.build() .build()
val key = session.playSessionId.ifBlank { session.itemId }
WorkManager.getInstance(context.applicationContext).enqueueUniqueWork( WorkManager.getInstance(context.applicationContext).enqueueUniqueWork(
"emby-playback-stop-$key", workName(session),
ExistingWorkPolicy.REPLACE, ExistingWorkPolicy.REPLACE,
request, 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()
}
} }
} }
@@ -92,6 +92,7 @@ import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.isActive import kotlinx.coroutines.isActive
@@ -113,6 +114,9 @@ class PlayerActivity : ComponentActivity() {
private var player: ExoPlayer? = null private var player: ExoPlayer? = null
private var playerView: PlayerView? = null private var playerView: PlayerView? = null
private var progressJob: Job? = null private var progressJob: Job? = null
private var playbackStartReportJob: Job? = null
private var progressUploadJob: Job? = null
private val progressReports = Channel<PendingProgressReport>(Channel.CONFLATED)
private var playbackStarted = false private var playbackStarted = false
private var initialResumePositionMs = 0L private var initialResumePositionMs = 0L
private var stopReported = false private var stopReported = false
@@ -138,6 +142,7 @@ class PlayerActivity : ComponentActivity() {
private var errorDetailView: TextView? = null private var errorDetailView: TextView? = null
private var streamStatusView: TextView? = null private var streamStatusView: TextView? = null
private var retryJob: Job? = null private var retryJob: Job? = null
private var retryGeneration = 0L
private var stablePlaybackJob: Job? = null private var stablePlaybackJob: Job? = null
private var prolongedRebufferJob: Job? = null private var prolongedRebufferJob: Job? = null
private var prolongedRebufferRecoveryAttempted = false private var prolongedRebufferRecoveryAttempted = false
@@ -154,6 +159,7 @@ class PlayerActivity : ComponentActivity() {
*/ */
private var pendingRequest: PlaybackRequest? = null private var pendingRequest: PlaybackRequest? = null
private var pendingResolveJob: Job? = null private var pendingResolveJob: Job? = null
private var pendingResolveGeneration = 0L
private var serviceAlertsMounted = false private var serviceAlertsMounted = false
// Open trace spans, or -1 for "not open". Held so they can be closed on destroy: a // 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 nextEpisode: NextEpisode? = null
private var returningHomeAfterCompletion = false private var returningHomeAfterCompletion = false
private var nextUpJob: Job? = null private var nextUpJob: Job? = null
private var nextEpisodeLookupJob: Job? = null
private var nextUpBanner: View? = null private var nextUpBanner: View? = null
private var nextUpCountdown: TextView? = null private var nextUpCountdown: TextView? = null
private var nextUpDismissed = false private var nextUpDismissed = false
private var advancing = 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 // 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. // 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 // 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 // form exists so this activity, its layout and its decoder start while the server
// is still being asked — see [PlaybackRequest]. // 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)) val request = decodeRequest(intent.getStringExtra(EXTRA_PLAYBACK_REQUEST))
pendingRequest = request.takeIf { url == null } pendingRequest = request.takeIf { url == null }
if (url == null && request == null) { if (url == null && request == null) {
@@ -398,11 +412,21 @@ class PlayerActivity : ComponentActivity() {
return return
} }
itemId = intent.getStringExtra(EXTRA_ITEM_ID) itemId = savedInstanceState?.getString(STATE_ITEM_ID)
mediaSourceId = intent.getStringExtra(EXTRA_MEDIA_SOURCE_ID).orEmpty() ?: intent.getStringExtra(EXTRA_ITEM_ID)
playSessionId = intent.getStringExtra(EXTRA_PLAY_SESSION_ID).orEmpty() mediaSourceId = savedInstanceState?.getString(STATE_MEDIA_SOURCE_ID)
playMethod = intent.getStringExtra(EXTRA_PLAY_METHOD) ?: "DirectPlay" ?: intent.getStringExtra(EXTRA_MEDIA_SOURCE_ID).orEmpty()
val resumePositionMs = intent.getLongExtra(EXTRA_RESUME_POSITION_MS, 0L) 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) initialResumePositionMs = resumePositionMs.coerceAtLeast(0L)
val prerollEnabled = intent.getBooleanExtra(EXTRA_PREROLL_ENABLED, true) val prerollEnabled = intent.getBooleanExtra(EXTRA_PREROLL_ENABLED, true)
configuredPrerollDurationMs = intent.getLongExtra( configuredPrerollDurationMs = intent.getLongExtra(
@@ -411,26 +435,36 @@ class PlayerActivity : ComponentActivity() {
).coerceIn(MIN_PREROLL_DURATION_MS, MAX_PREROLL_DURATION_MS) ).coerceIn(MIN_PREROLL_DURATION_MS, MAX_PREROLL_DURATION_MS)
prerollDurationMs = configuredPrerollDurationMs prerollDurationMs = configuredPrerollDurationMs
val showPreroll = shouldShowPreroll(resumePositionMs, prerollEnabled) 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 availableSubtitles = subtitles
// Falling back to this TV's copy of the setting rather than to `true` matters for // 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 // 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. // subtitles off must not get them back for the seconds before one is resolved.
subtitlePreference = intent.getBooleanExtra( subtitlePreference = savedInstanceState?.takeIf { it.containsKey(STATE_SUBTITLES_ENABLED) }
EXTRA_SUBTITLES_ENABLED, ?.getBoolean(STATE_SUBTITLES_ENABLED)
ServiceLocator.settings.current?.subtitlesEnabled ?: true, ?: intent.getBooleanExtra(
) EXTRA_SUBTITLES_ENABLED,
serverSubtitleId = intent.getStringExtra(EXTRA_SELECTED_SUBTITLE_ID).orEmpty() 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 // Default false, not true: a missing extra must never conjure a section whose
// only row leads to a request the backend cannot answer. // 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, // 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 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 // the launch these are corrected by adoptPlayable once the server settles; on this
// form the intent is the only thing that will ever say. // form the intent is the only thing that will ever say.
trickplayAvailable = intent.getBooleanExtra(EXTRA_TRICKPLAY, false) trickplayAvailable = savedInstanceState?.getBoolean(STATE_TRICKPLAY)
skipIntroAvailable = intent.getBooleanExtra(EXTRA_SKIP_INTRO, false) ?: intent.getBooleanExtra(EXTRA_TRICKPLAY, false)
endCreditsAvailable = intent.getBooleanExtra(EXTRA_END_CREDITS, 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}") Log.i(PLAYBACK_LOG_TAG, "event=subtitle_configs item=${itemId.orEmpty()} count=${subtitles.size}")
setContentView(R.layout.activity_player) setContentView(R.layout.activity_player)
@@ -475,13 +509,23 @@ class PlayerActivity : ComponentActivity() {
remainingView = view.findViewById(R.id.player_remaining) remainingView = view.findViewById(R.id.player_remaining)
finishTimeView = view.findViewById(R.id.player_finish_time) finishTimeView = view.findViewById(R.id.player_finish_time)
streamStatusView = view.findViewById(R.id.player_stream_status) streamStatusView = view.findViewById(R.id.player_stream_status)
logoUrl = intent.getStringExtra(EXTRA_LOGO_URL) logoUrl = savedInstanceState?.getString(STATE_LOGO_URL)
playbackTitle = intent.getStringExtra(EXTRA_TITLE).orEmpty() ?: intent.getStringExtra(EXTRA_LOGO_URL)
pauseOverview = intent.getStringExtra(EXTRA_OVERVIEW).orEmpty() playbackTitle = savedInstanceState?.getString(STATE_TITLE)
prerollEpisodeCode = intent.getStringExtra(EXTRA_EPISODE_CODE).orEmpty() ?: intent.getStringExtra(EXTRA_TITLE).orEmpty()
prerollRuntimeMs = intent.getLongExtra(EXTRA_RUNTIME_MS, 0L).coerceAtLeast(0L) pauseOverview = savedInstanceState?.getString(STATE_OVERVIEW)
pausePosterUrl = intent.getStringExtra(EXTRA_POSTER_URL) ?: 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() if (showPreroll) startPreroll() else startWithoutPreroll()
setUpPlaybackError()
// --- The critical path ------------------------------------------------------ // --- The critical path ------------------------------------------------------
// Everything above this point is what the player needs in order to open the // 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. // first one cannot arrive until onCreate has returned.
// Surround passthrough is settled before the sink is built, not after: an audio // 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. // sink cannot change its mind about bitstreaming a format once a track is open.
player = PlayerEngine.create(this, ServiceLocator.settings.current.audioPassthroughPreference) val createdPlayer = runCatching {
.also { playback -> 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 = "Couldnt start the video player",
detail = "Memby couldnt initialise this TVs video decoder. Try again or return to Memby.",
canAutoRetry = false,
),
)
return
}
player = createdPlayer.also { playback ->
view.player = playback view.player = playback
trace.mark(PlaybackTrace.PLAYER_BUILT) trace.mark(PlaybackTrace.PLAYER_BUILT)
playback.addListener(object : Player.Listener { playback.addListener(object : Player.Listener {
@@ -530,7 +589,7 @@ class PlayerActivity : ComponentActivity() {
} }
override fun onIsPlayingChanged(isPlaying: Boolean) { override fun onIsPlayingChanged(isPlaying: Boolean) {
if (playbackStarted) { if (playbackStarted && !stopReported) {
reportProgress( reportProgress(
playback.currentPosition, playback.currentPosition,
isPaused = !isPlaying, isPaused = !isPlaying,
@@ -548,7 +607,7 @@ class PlayerActivity : ComponentActivity() {
// only place that hears about it — without it the OSD it raised // only place that hears about it — without it the OSD it raised
// would sit on the picture for the rest of the film. // would sit on the picture for the rest of the film.
if (player.playbackState == Player.STATE_READY) endSeekBuffering() 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") 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 // 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. // full-screen parent and should begin as soon as it is ready.
if (url != null) { val startResolvedPlayback = {
trace.mark(PlaybackTrace.STREAM_RESOLVED) if (url != null) {
startMedia(url, subtitles, resumePositionMs, playWhenReady = !showPreroll) 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 { } else {
resolvePendingStream(requireNotNull(request), playWhenReady = !showPreroll) startResolvedPlayback()
} }
// --- Decoration ------------------------------------------------------------- // --- Decoration -------------------------------------------------------------
@@ -609,7 +689,6 @@ class PlayerActivity : ComponentActivity() {
setUpEndCredits() setUpEndCredits()
setUpTimeRemainingCue() setUpTimeRemainingCue()
setUpSeasonFinaleCue() setUpSeasonFinaleCue()
setUpPlaybackError()
lifecycleScope.launch { lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) { repeatOnLifecycle(Lifecycle.State.STARTED) {
@@ -628,9 +707,24 @@ class PlayerActivity : ComponentActivity() {
playWhenReady: Boolean, playWhenReady: Boolean,
) { ) {
val playback = player ?: return val playback = player ?: return
playback.setMediaItem(mediaItem(url, subtitles), positionMs.coerceAtLeast(0L)) val prepared = runCatching {
playback.playWhenReady = playWhenReady require(url.isNotBlank()) { "Playback URL is blank" }
playback.prepare() 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) trace.mark(PlaybackTrace.PREPARED)
endFirstFrameTrace() endFirstFrameTrace()
firstFrameTraceCookie = PlaybackTraceSections.nextCookie() firstFrameTraceCookie = PlaybackTraceSections.nextCookie()
@@ -663,11 +757,14 @@ class PlayerActivity : ComponentActivity() {
*/ */
private fun resolvePendingStream(request: PlaybackRequest, playWhenReady: Boolean) { private fun resolvePendingStream(request: PlaybackRequest, playWhenReady: Boolean) {
showPlaybackLoading() showPlaybackLoading()
val generation = ++pendingResolveGeneration
pendingResolveJob?.cancel() pendingResolveJob?.cancel()
pendingResolveJob = lifecycleScope.launch { pendingResolveJob = lifecycleScope.launch {
runCatching { ServiceLocator.repository.resolvePlayableForLaunch(request) } runCatching { ServiceLocator.repository.resolvePlayableForLaunch(request) }
.onSuccess { playable -> .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 // 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 // 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 // viewer is told is whatever media3 makes of that rather than that the
@@ -693,12 +790,15 @@ class PlayerActivity : ComponentActivity() {
startMedia( startMedia(
url = playable.url, url = playable.url,
subtitles = playable.subtitles, subtitles = playable.subtitles,
positionMs = playable.resumePositionMs, positionMs = restoredPositionMs ?: playable.resumePositionMs,
playWhenReady = playWhenReady, playWhenReady = playWhenReady,
) )
} }
.onFailure { error -> .onFailure { error ->
if (isFinishing || isDestroyed) return@onFailure if (error is kotlinx.coroutines.CancellationException) throw error
if (generation != pendingResolveGeneration || isFinishing || isDestroyed) {
return@onFailure
}
Log.e( Log.e(
PLAYBACK_LOG_TAG, PLAYBACK_LOG_TAG,
"event=stream_resolve_failed item=${request.itemId}", "event=stream_resolve_failed item=${request.itemId}",
@@ -1197,6 +1297,10 @@ class PlayerActivity : ComponentActivity() {
errorTitleView = overlay.findViewById(R.id.playback_error_title) errorTitleView = overlay.findViewById(R.id.playback_error_title)
errorDetailView = overlay.findViewById(R.id.playback_error_detail) errorDetailView = overlay.findViewById(R.id.playback_error_detail)
overlay.findViewById<View>(R.id.playback_error_retry).setOnClickListener { overlay.findViewById<View>(R.id.playback_error_retry).setOnClickListener {
if (player == null) {
recreate()
return@setOnClickListener
}
automaticRetryAttempt = 0 automaticRetryAttempt = 0
retryPlayback(refreshSource = true) retryPlayback(refreshSource = true)
} }
@@ -1214,6 +1318,7 @@ class PlayerActivity : ComponentActivity() {
prerollView?.visibility = View.GONE prerollView?.visibility = View.GONE
playerView?.useController = true playerView?.useController = true
} }
retryGeneration += 1
retryJob?.cancel() retryJob?.cancel()
prolongedRebufferJob?.cancel() prolongedRebufferJob?.cancel()
prolongedRebufferJob = null prolongedRebufferJob = null
@@ -1260,6 +1365,7 @@ class PlayerActivity : ComponentActivity() {
} }
private fun retryPlayback(refreshSource: Boolean, forceTranscode: Boolean = false) { private fun retryPlayback(refreshSource: Boolean, forceTranscode: Boolean = false) {
val generation = ++retryGeneration
retryJob?.cancel() retryJob?.cancel()
prolongedRebufferJob?.cancel() prolongedRebufferJob?.cancel()
prolongedRebufferJob = null prolongedRebufferJob = null
@@ -1301,7 +1407,9 @@ class PlayerActivity : ComponentActivity() {
forceTranscode = forceTranscode, forceTranscode = forceTranscode,
) )
}.onSuccess { refreshed -> }.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 // 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 // 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 // an empty URI, which would fail as a source error and be retried on the
@@ -1318,23 +1426,32 @@ class PlayerActivity : ComponentActivity() {
) )
return@onSuccess return@onSuccess
} }
itemId = refreshed.itemId adoptReplacementSession(
mediaSourceId = refreshed.mediaSourceId newItemId = refreshed.itemId,
playSessionId = refreshed.playSessionId newMediaSourceId = refreshed.mediaSourceId,
playMethod = refreshed.playMethod newPlaySessionId = refreshed.playSessionId,
newPlayMethod = refreshed.playMethod,
positionMs = positionMs,
)
availableSubtitles = refreshed.subtitles availableSubtitles = refreshed.subtitles
subtitlePreference = refreshed.subtitlesEnabled subtitlePreference = refreshed.subtitlesEnabled
serverSubtitleId = refreshed.selectedSubtitleId serverSubtitleId = refreshed.selectedSubtitleId
subtitleDownloadAvailable = refreshed.subtitleDownloadAvailable
trickplayAvailable = refreshed.trickplayAvailable
skipIntroAvailable = refreshed.skipIntroAvailable
endCreditsAvailable = refreshed.endCreditsAvailable
if (refreshed.title.isNotBlank()) playbackTitle = refreshed.title if (refreshed.title.isNotBlank()) playbackTitle = refreshed.title
subtitleAutoSelectionAttempted = false subtitleAutoSelectionAttempted = false
Log.i( Log.i(
PLAYBACK_LOG_TAG, PLAYBACK_LOG_TAG,
"event=subtitle_configs item=${refreshed.itemId} count=${refreshed.subtitles.size} source=refresh", "event=subtitle_configs item=${refreshed.itemId} count=${refreshed.subtitles.size} source=refresh",
) )
playback.setMediaItem(mediaItem(refreshed.url, refreshed.subtitles), positionMs) startMedia(refreshed.url, refreshed.subtitles, positionMs, playWhenReady = true)
playback.playWhenReady = true
playback.prepare()
}.onFailure { refreshError -> }.onFailure { refreshError ->
if (refreshError is kotlinx.coroutines.CancellationException) throw refreshError
if (generation != retryGeneration || itemId != id || isFinishing || isDestroyed) {
return@onFailure
}
Log.e( Log.e(
PLAYBACK_LOG_TAG, PLAYBACK_LOG_TAG,
"event=stream_refresh_failed item=$id attempt=$automaticRetryAttempt", "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. * here; the banner only ever appears when there is something real to show.
*/ */
private fun prefetchNextEpisode() { private fun prefetchNextEpisode() {
nextEpisodeLookupJob?.cancel()
nextEpisode = null nextEpisode = null
val id = itemId?.takeIf { it.isNotBlank() } ?: return val id = itemId?.takeIf { it.isNotBlank() } ?: return
lifecycleScope.launch { nextEpisodeLookupJob = lifecycleScope.launch {
val enabled = ServiceLocator.repository.settingsFlow.first().autoPlayNextEpisode 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 // 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 // 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 // played, and then — because the countdown runs itself out — swap it in
// automatically and fail, with a viewer who pressed nothing having their // automatically and fail, with a viewer who pressed nothing having their
// programme replaced by an error. // programme replaced by an error.
val resolved = ServiceLocator.repository.nextEpisode(id, seriesId = null) ServiceLocator.repository.nextEpisode(id, seriesId = null)
?.takeIf { it.url.isNotBlank() } ?.takeIf { it.url.isNotBlank() }
} else {
null
}
resolved?.imageUrl?.let { imageUrl -> resolved?.imageUrl?.let { imageUrl ->
imageLoader.enqueue( imageLoader.enqueue(
ImageRequest.Builder(this@PlayerActivity) ImageRequest.Builder(this@PlayerActivity)
@@ -2386,9 +2507,14 @@ class PlayerActivity : ComponentActivity() {
// Playback may have moved on to another episode while this was in flight. // Playback may have moved on to another episode while this was in flight.
if (itemId == id) { if (itemId == id) {
nextEpisode = resolved nextEpisode = resolved
// Extremely short episodes can end before the background lookup returns. // Extremely short episodes and hostile latency can reach Ended before the
if (player?.playbackState == Player.STATE_ENDED && !nextUpDismissed) { // lookup. The ended frame waits for this answer rather than leaving Home.
resolved?.let(::playNext) 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() { 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.PLAY_NEXT -> nextEpisode?.let(::playNext)
PlaybackCompletionAction.RETURN_HOME -> returnHomeAfterCompletion() 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 returns to the exact screen that launched playback. */
* 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.
*/
private fun returnHomeAfterCompletion() { private fun returnHomeAfterCompletion() {
if (returningHomeAfterCompletion || isFinishing || isDestroyed) return if (returningHomeAfterCompletion || isFinishing || isDestroyed) return
returningHomeAfterCompletion = true returningHomeAfterCompletion = true
nextUpJob?.cancel() nextUpJob?.cancel()
progressJob?.cancel() stopProgressUploading()
hideNextUp() hideNextUp()
val playback = player val playback = player
@@ -2809,13 +2939,6 @@ class PlayerActivity : ComponentActivity() {
playback?.currentPosition ?: 0L, 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() finish()
} }
@@ -2828,7 +2951,19 @@ class PlayerActivity : ComponentActivity() {
if (advancing || returningHomeAfterCompletion) return if (advancing || returningHomeAfterCompletion) return
advancing = true advancing = true
nextUpJob?.cancel() 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() hideNextUp()
val playback = player val playback = player
@@ -2849,6 +2984,11 @@ class PlayerActivity : ComponentActivity() {
availableSubtitles = next.subtitles availableSubtitles = next.subtitles
subtitlePreference = next.subtitlesEnabled subtitlePreference = next.subtitlesEnabled
serverSubtitleId = next.selectedSubtitleId serverSubtitleId = next.selectedSubtitleId
subtitleDownloadAvailable = next.subtitleDownloadAvailable
trickplayAvailable = next.trickplayAvailable
skipIntroAvailable = next.skipIntroAvailable
endCreditsAvailable = next.endCreditsAvailable
encodedSubtitleId = null
stopReported = false stopReported = false
playbackStarted = false playbackStarted = false
playbackIdentityShown = 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. // The cast reloads with the rest of the new episode's session, once it is playing.
playbackTitle = nextTitle(next) 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) bindTitleArtwork(title = playbackTitle, logoUrl = logoUrl)
setUpPlaybackIdentity(title = playbackTitle) setUpPlaybackIdentity(title = playbackTitle)
pauseOverlay?.findViewById<TextView>(R.id.player_pause_title)?.text = playbackTitle
pauseOverlay?.findViewById<TextView>(R.id.player_pause_overview)?.text =
pauseOverview.ifBlank { getString(R.string.player_pause_overview_fallback) }
pauseOverlay?.findViewById<ImageView>(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() hidePlaybackError()
showPlaybackLoading() showPlaybackLoading()
if (playback != null) { if (playback != null) {
@@ -3479,6 +3637,7 @@ class PlayerActivity : ComponentActivity() {
private fun searchForSubtitles() { private fun searchForSubtitles() {
if (subtitleRequestInFlight) return if (subtitleRequestInFlight) return
val id = itemId?.takeIf(String::isNotBlank) ?: return val id = itemId?.takeIf(String::isNotBlank) ?: return
val generation = ++subtitleStreamGeneration
subtitleCandidates = emptyList() subtitleCandidates = emptyList()
subtitleDownloadStatus = getString(R.string.player_subtitle_searching) subtitleDownloadStatus = getString(R.string.player_subtitle_searching)
subtitleDownloadExpanded = true subtitleDownloadExpanded = true
@@ -3493,6 +3652,7 @@ class PlayerActivity : ComponentActivity() {
itemId = id, itemId = id,
language = ServiceLocator.settings.current?.subtitleLanguage, language = ServiceLocator.settings.current?.subtitleLanguage,
) )
if (generation != subtitleStreamGeneration || itemId != id) return@launch
subtitleCandidates = found.results subtitleCandidates = found.results
subtitleDownloadStatus = if (found.results.isEmpty()) { subtitleDownloadStatus = if (found.results.isEmpty()) {
found.message.ifBlank { getString(R.string.player_subtitle_search_empty) } found.message.ifBlank { getString(R.string.player_subtitle_search_empty) }
@@ -3503,8 +3663,9 @@ class PlayerActivity : ComponentActivity() {
} finally { } finally {
// In a finally so a cancelled search — Back out of the section, or the // In a finally so a cancelled search — Back out of the section, or the
// activity going away — cannot leave every row permanently unpressable. // 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) redrawSubtitleOverlay(focusDownload = if (subtitleCandidates.isEmpty()) 0 else 1)
} }
} }
@@ -3521,6 +3682,7 @@ class PlayerActivity : ComponentActivity() {
if (subtitleRequestInFlight) return if (subtitleRequestInFlight) return
val playback = player ?: return val playback = player ?: return
val id = itemId?.takeIf(String::isNotBlank) ?: return val id = itemId?.takeIf(String::isNotBlank) ?: return
val generation = ++subtitleStreamGeneration
subtitleDownloadStatus = getString( subtitleDownloadStatus = getString(
R.string.player_subtitle_downloading, R.string.player_subtitle_downloading,
candidate.languageLabel.ifBlank { candidate.language }, candidate.languageLabel.ifBlank { candidate.language },
@@ -3528,20 +3690,29 @@ class PlayerActivity : ComponentActivity() {
subtitleRequestInFlight = true subtitleRequestInFlight = true
subtitleSearchJob = lifecycleScope.launch { subtitleSearchJob = lifecycleScope.launch {
redrawSubtitleOverlay() redrawSubtitleOverlay()
val result = try { val result = ServiceLocator.repository.downloadSubtitle(id, candidate)
ServiceLocator.repository.downloadSubtitle(id, candidate) if (generation != subtitleStreamGeneration || itemId != id) return@launch
} finally { subtitleRequestInFlight = false
subtitleRequestInFlight = false
}
if (result == null) { if (result == null) {
subtitleDownloadStatus = getString(R.string.player_subtitle_download_failed) subtitleDownloadStatus = getString(R.string.player_subtitle_download_failed)
redrawSubtitleOverlay(focusDownload = 0) redrawSubtitleOverlay(focusDownload = 0)
return@launch return@launch
} }
val position = playback.currentPosition.coerceAtLeast(0L) 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 availableSubtitles = result.subtitles
mediaSourceId = result.mediaSourceId.ifBlank { mediaSourceId }
playSessionId = result.playSessionId.ifBlank { playSessionId }
serverSubtitleId = result.selectedSubtitleId serverSubtitleId = result.selectedSubtitleId
subtitlePreference = true subtitlePreference = true
encodedSubtitleId = null encodedSubtitleId = null
@@ -3551,9 +3722,7 @@ class PlayerActivity : ComponentActivity() {
subtitleAutoSelectionAttempted = false subtitleAutoSelectionAttempted = false
rememberSubtitleChoice(enabled = true, language = candidate.language) rememberSubtitleChoice(enabled = true, language = candidate.language)
pendingSubtitleConfirmation = candidate.languageLabel.ifBlank { candidate.language } pendingSubtitleConfirmation = candidate.languageLabel.ifBlank { candidate.language }
playback.setMediaItem(mediaItem(result.url, result.subtitles), position) startMedia(result.url, result.subtitles, position, playWhenReady = resumePlaying)
playback.playWhenReady = true
playback.prepare()
Log.i(PLAYBACK_LOG_TAG, "event=subtitle_downloaded item=$id language=${candidate.language}") Log.i(PLAYBACK_LOG_TAG, "event=subtitle_downloaded item=$id language=${candidate.language}")
subtitleCandidates = emptyList() subtitleCandidates = emptyList()
subtitleDownloadStatus = result.message 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. * back an hour later presses a row that quietly fails. A search is one press away.
*/ */
private fun collapseSubtitleDownloads() { private fun collapseSubtitleDownloads() {
subtitleStreamGeneration += 1
subtitleSearchJob?.cancel() subtitleSearchJob?.cancel()
subtitleRequestInFlight = false subtitleRequestInFlight = false
subtitleDownloadExpanded = false subtitleDownloadExpanded = false
@@ -3655,10 +3825,14 @@ class PlayerActivity : ComponentActivity() {
val id = itemId?.takeIf(String::isNotBlank) ?: return val id = itemId?.takeIf(String::isNotBlank) ?: return
val index = subtitle.id.toIntOrNull() ?: return val index = subtitle.id.toIntOrNull() ?: return
val position = playback.currentPosition.coerceAtLeast(0L) val position = playback.currentPosition.coerceAtLeast(0L)
val resumePlaying = playback.playWhenReady
val generation = ++subtitleStreamGeneration
encodedSubtitleJob?.cancel()
dismissSubtitleOverlayAfterSelection() dismissSubtitleOverlayAfterSelection()
reportProgress(position, !playback.isPlaying, "SubtitleTrackChange") reportProgress(position, !playback.isPlaying, "SubtitleTrackChange")
playback.pause()
showPlaybackLoading(getString(R.string.playback_loading), "Preparing burned-in subtitles…") showPlaybackLoading(getString(R.string.playback_loading), "Preparing burned-in subtitles…")
lifecycleScope.launch { encodedSubtitleJob = lifecycleScope.launch {
runCatching { runCatching {
ServiceLocator.repository.selectEncodedSubtitle( ServiceLocator.repository.selectEncodedSubtitle(
playbackSession(id), playbackSession(id),
@@ -3667,26 +3841,41 @@ class PlayerActivity : ComponentActivity() {
position, position,
) )
}.onSuccess { selected -> }.onSuccess { selected ->
mediaSourceId = selected.mediaSourceId if (generation != subtitleStreamGeneration || itemId != id || isFinishing || isDestroyed) {
playSessionId = selected.playSessionId return@onSuccess
playMethod = selected.playMethod }
if (selected.url.isBlank()) {
hidePlaybackLoading()
if (resumePlaying) playback.play()
Toast.makeText(this@PlayerActivity, "Couldnt 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 availableSubtitles = selected.subtitles
encodedSubtitleId = subtitle.id encodedSubtitleId = subtitle.id
subtitlePreference = true subtitlePreference = true
subtitleDownloadAvailable = selected.subtitleDownloadAvailable
trickplayAvailable = selected.trickplayAvailable
skipIntroAvailable = selected.skipIntroAvailable
endCreditsAvailable = selected.endCreditsAvailable
rememberSubtitleChoice(enabled = true, language = subtitle.language) rememberSubtitleChoice(enabled = true, language = subtitle.language)
subtitleAutoSelectionAttempted = true subtitleAutoSelectionAttempted = true
pendingSubtitleConfirmation = subtitleDisplayLabel(subtitle) pendingSubtitleConfirmation = subtitleDisplayLabel(subtitle)
playback.setMediaItem(mediaItem(selected.url, selected.subtitles), position) startMedia(selected.url, selected.subtitles, position, playWhenReady = resumePlaying)
playback.playWhenReady = true }.onFailure { error ->
playback.prepare() if (error is kotlinx.coroutines.CancellationException) throw error
}.onFailure { if (generation != subtitleStreamGeneration || itemId != id || isFinishing || isDestroyed) {
showPlaybackError( return@onFailure
PlaybackFailure( }
title = "Couldnt enable subtitles", hidePlaybackLoading()
detail = "The Emby server could not prepare this image subtitle track.", if (resumePlaying) playback.play()
canAutoRetry = false, Toast.makeText(this@PlayerActivity, "Couldnt enable subtitles", Toast.LENGTH_SHORT).show()
),
)
} }
} }
} }
@@ -3762,6 +3951,44 @@ class PlayerActivity : ComponentActivity() {
return if (traits.isEmpty()) name else "$name · ${traits.joinToString(" · ")}" 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() { override fun onStart() {
super.onStart() super.onStart()
if (prerollActive) { if (prerollActive) {
@@ -3770,10 +3997,29 @@ class PlayerActivity : ComponentActivity() {
localPrerollPlayer?.play() ?: run { player?.playWhenReady = true } localPrerollPlayer?.play() ?: run { player?.playWhenReady = true }
} }
beginContentWhenReady() 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) { if (stoppedInBackground && playbackStarted) {
stoppedInBackground = false stoppedInBackground = false
stopReported = 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 // 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. // this title resumes from — is the one the viewer had already skipped past.
commitSeek() 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 { player?.let {
if (playbackStarted && !stopReported) { 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 stopReported = true
stoppedInBackground = true stoppedInBackground = true
itemId?.takeIf(String::isNotBlank)?.let { id -> 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. // A launch that never reached a frame still has to close its spans.
endFirstFrameTrace() endFirstFrameTrace()
endLaunchTrace() endLaunchTrace()
progressJob?.cancel() stopProgressUploading()
pendingResolveJob?.cancel() pendingResolveJob?.cancel()
prerollTimerJob?.cancel() prerollTimerJob?.cancel()
prerollScheduleJob?.cancel() prerollScheduleJob?.cancel()
@@ -3816,7 +4075,9 @@ class PlayerActivity : ComponentActivity() {
castJob?.cancel() castJob?.cancel()
castPersonJob?.cancel() castPersonJob?.cancel()
subtitleSearchJob?.cancel() subtitleSearchJob?.cancel()
encodedSubtitleJob?.cancel()
nextUpJob?.cancel() nextUpJob?.cancel()
nextEpisodeLookupJob?.cancel()
creditsSpeedJob?.cancel() creditsSpeedJob?.cancel()
creditsView?.animate()?.cancel() creditsView?.animate()?.cancel()
retryJob?.cancel() retryJob?.cancel()
@@ -3848,7 +4109,8 @@ class PlayerActivity : ComponentActivity() {
private fun reportStarted(positionMs: Long) { private fun reportStarted(positionMs: Long) {
val id = itemId?.takeIf { it.isNotBlank() } ?: return val id = itemId?.takeIf { it.isNotBlank() } ?: return
lifecycleScope.launch { playbackStartReportJob?.cancel()
playbackStartReportJob = lifecycleScope.launch {
runCatching { runCatching {
ServiceLocator.repository.reportPlaybackStarted(playbackSession(id), positionMs) ServiceLocator.repository.reportPlaybackStarted(playbackSession(id), positionMs)
} }
@@ -3857,31 +4119,96 @@ class PlayerActivity : ComponentActivity() {
private fun reportProgress(positionMs: Long, isPaused: Boolean, eventName: String) { private fun reportProgress(positionMs: Long, isPaused: Boolean, eventName: String) {
val id = itemId?.takeIf { it.isNotBlank() } ?: return val id = itemId?.takeIf { it.isNotBlank() } ?: return
lifecycleScope.launch { val durationMs = player?.duration?.takeIf { it != C.TIME_UNSET && it > 0L } ?: 0L
val durationMs = player?.duration?.takeIf { it != C.TIME_UNSET && it > 0L } ?: 0L progressReports.trySend(
val evaluatesAutoFollow = !autoFollowEvaluated && PendingProgressReport(id, positionMs, isPaused, eventName, durationMs),
durationMs > 0L && positionMs >= (durationMs + 1L) / 2L )
runCatching { if (progressUploadJob?.isActive == true) return
ServiceLocator.repository.reportPlaybackProgress( progressUploadJob = lifecycleScope.launch {
playbackSession(id), // A hostilely slow start response must still precede the first progress event.
positionMs, playbackStartReportJob?.join()
isPaused, for (report in progressReports) {
eventName, // One report at a time, with the channel retaining only the newest report
durationMs.takeUnless { autoFollowEvaluated } ?: 0L, // 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.
}.onSuccess { showTitle -> if (itemId != report.itemId) continue
if (evaluatesAutoFollow) autoFollowEvaluated = true val evaluatesAutoFollow = !autoFollowEvaluated &&
showTitle?.let { report.durationMs > 0L &&
Toast.makeText( report.positionMs >= (report.durationMs + 1L) / 2L
this@PlayerActivity, runCatching {
"$it was added to My Shows", ServiceLocator.repository.reportPlaybackProgress(
Toast.LENGTH_LONG, playbackSession(report.itemId),
).show() 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( private fun playbackSession(id: String) = PlaybackSession(
itemId = id, itemId = id,
mediaSourceId = mediaSourceId.ifBlank { 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_SESSION_ID = "extra_play_session_id"
private const val EXTRA_PLAY_METHOD = "extra_play_method" private const val EXTRA_PLAY_METHOD = "extra_play_method"
private const val EXTRA_PLAYBACK_REQUEST = "extra_playback_request" 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 PLAYER_PREFERENCES = "player_preferences"
private const val SUBTITLE_SIZE_KEY = "subtitle_size" private const val SUBTITLE_SIZE_KEY = "subtitle_size"
private const val PICTURE_MODE_KEY = "picture_mode" private const val PICTURE_MODE_KEY = "picture_mode"
@@ -4254,19 +4601,38 @@ internal fun shouldShowPreroll(resumePositionMs: Long, enabled: Boolean = true):
internal enum class PlaybackCompletionAction { internal enum class PlaybackCompletionAction {
PLAY_NEXT, PLAY_NEXT,
WAIT_FOR_NEXT,
RETURN_HOME, RETURN_HOME,
} }
internal fun playbackCompletionAction( internal fun playbackCompletionAction(
hasNextEpisode: Boolean, hasNextEpisode: Boolean,
nextUpDismissed: Boolean, nextUpDismissed: Boolean,
nextLookupInFlight: Boolean = false,
): PlaybackCompletionAction = ): PlaybackCompletionAction =
if (hasNextEpisode && !nextUpDismissed) { if (hasNextEpisode && !nextUpDismissed) {
PlaybackCompletionAction.PLAY_NEXT PlaybackCompletionAction.PLAY_NEXT
} else if (!nextUpDismissed && nextLookupInFlight) {
PlaybackCompletionAction.WAIT_FOR_NEXT
} else { } else {
PlaybackCompletionAction.RETURN_HOME 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( internal fun shouldZoomVideo(
modeKey: String, modeKey: String,
width: Int, width: Int,
@@ -98,10 +98,8 @@ internal object PrerollPreloader {
player.normalised() player.normalised()
cachedPlayer?.release() cachedPlayer?.release()
cachedPlayer = player cachedPlayer = player
// Stopping the player leaves it idle, so the next borrower would pay for the local // Keep it idle while the programme owns the decoder. Home calls [start] when it is
// resource read itself. Re-arm the idle prepare here rather than depending on the // visible again, which prepares this cached instance without competing with video.
// caller to remember: the launcher is not the only thing that returns one now.
scheduleAtMainQueueIdle()
} }
/** Drops a failed or otherwise unusable instance; the next Home/start call replaces it. */ /** Drops a failed or otherwise unusable instance; the next Home/start call replaces it. */
@@ -182,6 +182,7 @@ private fun Slideshow(
var settingsOpen by remember { mutableStateOf(false) } var settingsOpen by remember { mutableStateOf(false) }
var loading by remember { mutableStateOf(true) } var loading by remember { mutableStateOf(true) }
var loadingMore by remember { mutableStateOf(false) } var loadingMore by remember { mutableStateOf(false) }
var playbackLaunching by remember { mutableStateOf(false) }
var loadError by remember { mutableStateOf<String?>(null) } var loadError by remember { mutableStateOf<String?>(null) }
var toast by remember(startupMessage) { mutableStateOf(startupMessage) } var toast by remember(startupMessage) { mutableStateOf(startupMessage) }
var reloadKey by remember { mutableIntStateOf(0) } var reloadKey by remember { mutableIntStateOf(0) }
@@ -247,8 +248,11 @@ private fun Slideshow(
loadingMore = true loadingMore = true
toast = "Finding more from your library…" toast = "Finding more from your library…"
scope.launch { scope.launch {
var prefetchError: Throwable? = null
val more = visibleWithWatchedPreference( val more = visibleWithWatchedPreference(
runCatching { repo.getScreensaverItems() }.getOrDefault(emptyList()), runCatching { repo.getScreensaverItems() }
.onFailure { prefetchError = it }
.getOrDefault(emptyList()),
hideWatchedMovies, hideWatchedMovies,
) )
if (more.isNotEmpty()) { if (more.isNotEmpty()) {
@@ -270,7 +274,7 @@ private fun Slideshow(
"Refreshed the queue with a new order" "Refreshed the queue with a new order"
} }
} else { } else {
toast = "No additional titles found" toast = prefetchError?.let(::friendlyEmbyError) ?: "No additional titles found"
} }
loadingMore = false loadingMore = false
} }
@@ -367,20 +371,45 @@ private fun Slideshow(
} }
fun playTrailer(item: BaseItem?) { fun playTrailer(item: BaseItem?) {
if (playbackLaunching) return
val target = item ?: return val target = item ?: return
playbackLaunching = true
toast = "Finding trailer…" toast = "Finding trailer…"
scope.launch { scope.launch {
runCatching { repo.getLocalTrailer(target.id) } runCatching { repo.getLocalTrailer(target.id) }
.onSuccess { trailer -> .onSuccess { trailer ->
if (trailer == null) { if (trailer == null) {
toast = "No trailer is available for ${target.name}." toast = "No trailer is available for ${target.name}."
playbackLaunching = false
} else { } else {
runCatching { repo.resolvePlayable(trailer) } runCatching { repo.resolvePlayable(trailer) }
.onSuccess { onPlay(it.url, "${target.name} trailer") } .onSuccess { playable ->
.onFailure { toast = friendlyEmbyError(it) } if (playable.url.isBlank()) {
toast = "The trailer is unavailable right now."
playbackLaunching = false
} else if (runCatching {
onPlay(playable.url, "${target.name} trailer")
}.isFailure
) {
toast = "Couldnt 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
}
} }
} }
@@ -99,6 +99,7 @@ import com.ponzischeme89.memby.ServiceLocator
import com.ponzischeme89.memby.data.model.BaseItem import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.data.model.GatewayRequestCandidate import com.ponzischeme89.memby.data.model.GatewayRequestCandidate
import com.ponzischeme89.memby.ui.FocusScaleContainer import com.ponzischeme89.memby.ui.FocusScaleContainer
import com.ponzischeme89.memby.ui.MembyChoiceChip
import com.ponzischeme89.memby.ui.PosterGridCard import com.ponzischeme89.memby.ui.PosterGridCard
import com.ponzischeme89.memby.ui.theme.MembyAccent import com.ponzischeme89.memby.ui.theme.MembyAccent
import com.ponzischeme89.memby.ui.theme.MembyAccentInk import com.ponzischeme89.memby.ui.theme.MembyAccentInk
@@ -786,6 +787,8 @@ private fun ResultsPane(
// listener running for nothing. // listener running for nothing.
paging = state.genre != null && (state.canLoadMore || state.isLoadingMore), paging = state.genre != null && (state.canLoadMore || state.isLoadingMore),
loadingMore = state.isLoadingMore, loadingMore = state.isLoadingMore,
pagingErrorMessage = state.pagingErrorMessage,
onRetryPage = onRetry,
onLoadMore = onLoadMore, onLoadMore = onLoadMore,
) )
} }
@@ -863,6 +866,8 @@ private fun ResultsGrid(
onItemSelected: (BaseItem) -> Unit, onItemSelected: (BaseItem) -> Unit,
paging: Boolean = false, paging: Boolean = false,
loadingMore: Boolean = false, loadingMore: Boolean = false,
pagingErrorMessage: String? = null,
onRetryPage: () -> Unit = {},
onLoadMore: () -> Unit = {}, onLoadMore: () -> Unit = {},
) { ) {
val context = LocalContext.current 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("Couldnt load more titles.", color = Muted, fontSize = 13.sp)
Spacer(Modifier.width(10.dp))
MembyChoiceChip(label = "Try again", selected = false, onClick = onRetryPage)
}
}
}
} }
} }
@@ -64,6 +64,7 @@ data class SearchUiState(
/** True once a query has run to completion, so "no matches" is distinguishable from "not yet". */ /** True once a query has run to completion, so "no matches" is distinguishable from "not yet". */
val hasSearched: Boolean = false, val hasSearched: Boolean = false,
val errorMessage: String? = null, val errorMessage: String? = null,
val pagingErrorMessage: String? = null,
val requestCandidates: List<GatewayRequestCandidate> = emptyList(), val requestCandidates: List<GatewayRequestCandidate> = emptyList(),
val requestLookupLoading: Boolean = false, val requestLookupLoading: Boolean = false,
val requestingCandidateKey: String? = null, val requestingCandidateKey: String? = null,
@@ -156,6 +157,7 @@ class SearchViewModel(private val repository: EmbyRepository) : ViewModel() {
requestLookupLoading = false, requestLookupLoading = false,
requestMessage = null, requestMessage = null,
requestMessageIsError = false, requestMessageIsError = false,
pagingErrorMessage = null,
) )
} }
queryFlow.value = query queryFlow.value = query
@@ -174,6 +176,7 @@ class SearchViewModel(private val repository: EmbyRepository) : ViewModel() {
query = "", results = emptyList(), genreOffset = 0, query = "", results = emptyList(), genreOffset = 0,
isLoading = false, hasSearched = false, isLoading = false, hasSearched = false,
errorMessage = null, requestCandidates = emptyList(), errorMessage = null, requestCandidates = emptyList(),
pagingErrorMessage = null,
requestLookupLoading = false, requestMessage = null, requestLookupLoading = false, requestMessage = null,
requestMessageIsError = false, genre = null, requestMessageIsError = false, genre = null,
isLoadingMore = false, canLoadMore = false, isLoadingMore = false, canLoadMore = false,
@@ -196,6 +199,7 @@ class SearchViewModel(private val repository: EmbyRepository) : ViewModel() {
isLoading = true, isLoading = true,
hasSearched = false, errorMessage = null, isLoadingMore = false, hasSearched = false, errorMessage = null, isLoadingMore = false,
canLoadMore = false, requestCandidates = emptyList(), canLoadMore = false, requestCandidates = emptyList(),
pagingErrorMessage = null,
requestLookupLoading = false, requestMessage = null, requestLookupLoading = false, requestMessage = null,
requestMessageIsError = false, requestMessageIsError = false,
) )
@@ -214,6 +218,7 @@ class SearchViewModel(private val repository: EmbyRepository) : ViewModel() {
genre = null, results = emptyList(), genreOffset = 0, genre = null, results = emptyList(), genreOffset = 0,
isLoading = false, hasSearched = false, isLoading = false, hasSearched = false,
errorMessage = null, isLoadingMore = false, canLoadMore = 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 current = state.value
val genre = current.genre ?: return val genre = current.genre ?: return
if (!current.canLoadMore || current.isLoadingMore || current.isLoading) 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) } genrePageJob = viewModelScope.launch { loadGenrePage(genre, offset = current.genreOffset) }
} }
@@ -236,7 +241,14 @@ class SearchViewModel(private val repository: EmbyRepository) : ViewModel() {
val current = state.value val current = state.value
current.genre?.let { genre -> current.genre?.let { genre ->
genrePageJob?.cancel() 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 { genrePageJob = viewModelScope.launch {
loadGenrePage(genre, offset = current.genreOffset) loadGenrePage(genre, offset = current.genreOffset)
} }
@@ -251,6 +263,8 @@ class SearchViewModel(private val repository: EmbyRepository) : ViewModel() {
fun request(candidate: GatewayRequestCandidate) { fun request(candidate: GatewayRequestCandidate) {
if (candidate.alreadyAdded || state.value.requestingCandidateKey != null) return if (candidate.alreadyAdded || state.value.requestingCandidateKey != null) return
val candidateKey = "${candidate.mediaType}:${candidate.foreignId}" val candidateKey = "${candidate.mediaType}:${candidate.foreignId}"
val queryAtRequest = state.value.query.trim()
val genreAtRequest = state.value.genre
_state.update { _state.update {
it.copy(requestingCandidateKey = candidateKey, requestMessage = null, requestMessageIsError = false) it.copy(requestingCandidateKey = candidateKey, requestMessage = null, requestMessageIsError = false)
} }
@@ -258,24 +272,32 @@ class SearchViewModel(private val repository: EmbyRepository) : ViewModel() {
runCatching { repository.requestMedia(candidate) } runCatching { repository.requestMedia(candidate) }
.onSuccess { title -> .onSuccess { title ->
_state.update { _state.update {
if (it.requestingCandidateKey != candidateKey) return@update it
val stillShowingRequest = it.query.trim() == queryAtRequest &&
it.genre == genreAtRequest
it.copy( it.copy(
requestingCandidateKey = null, requestingCandidateKey = null,
requestCandidates = it.requestCandidates.map { option -> requestCandidates = if (stillShowingRequest) it.requestCandidates.map { option ->
if (option.mediaType == candidate.mediaType && if (option.mediaType == candidate.mediaType &&
option.foreignId == candidate.foreignId option.foreignId == candidate.foreignId
) option.copy(alreadyAdded = true) else option ) option.copy(alreadyAdded = true) else option
}, } else it.requestCandidates,
requestMessage = "${title.ifBlank { candidate.title }} was requested.", requestMessage = if (stillShowingRequest) {
"${title.ifBlank { candidate.title }} was requested."
} else null,
requestMessageIsError = false, requestMessageIsError = false,
) )
} }
} }
.onFailure { error -> .onFailure { error ->
_state.update { _state.update {
if (it.requestingCandidateKey != candidateKey) return@update it
val stillShowingRequest = it.query.trim() == queryAtRequest &&
it.genre == genreAtRequest
it.copy( it.copy(
requestingCandidateKey = null, requestingCandidateKey = null,
requestMessage = friendlyEmbyError(error), requestMessage = if (stillShowingRequest) friendlyEmbyError(error) else null,
requestMessageIsError = true, requestMessageIsError = stillShowingRequest,
) )
} }
} }
@@ -333,6 +355,7 @@ class SearchViewModel(private val repository: EmbyRepository) : ViewModel() {
isLoadingMore = false, isLoadingMore = false,
hasSearched = true, hasSearched = true,
errorMessage = null, errorMessage = null,
pagingErrorMessage = null,
canLoadMore = hasMoreGenreItems( canLoadMore = hasMoreGenreItems(
loaded = read, loaded = read,
total = page.total, 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 // there and simply stops: the viewer has plenty on screen, and
// replacing it with an error would take away what was working. // replacing it with an error would take away what was working.
errorMessage = if (current.results.isEmpty()) friendlyEmbyError(error) else null, errorMessage = if (current.results.isEmpty()) friendlyEmbyError(error) else null,
pagingErrorMessage = if (current.results.isNotEmpty()) friendlyEmbyError(error) else null,
canLoadMore = false, canLoadMore = false,
) )
} }
@@ -28,6 +28,16 @@ import org.junit.Test
* side, this fails before a TV ever sees it. * side, this fails before a TV ever sees it.
*/ */
class GatewayPayloadTest { class GatewayPayloadTest {
@Test
fun `partially populated items decode without inventing an identity`() {
val item = json.decodeFromString<BaseItem>(
"""{"Name":"Incomplete","Type":"Movie"}""",
)
assertEquals("", item.id)
assertEquals("Incomplete", item.name)
}
@Test @Test
fun `decodes server formatted movie rating sources and scales`() { fun `decodes server formatted movie rating sources and scales`() {
val response = json.decodeFromString<GatewayMovieRatings>( val response = json.decodeFromString<GatewayMovieRatings>(
@@ -545,7 +555,11 @@ class GatewayPayloadTest {
}, },
"title": "Westworld The Bicameral Mind", "title": "Westworld The Bicameral Mind",
"url": "https://emby.example/Videos/11/stream?static=true", "url": "https://emby.example/Videos/11/stream?static=true",
"resumePositionMs": 0 "resumePositionMs": 0,
"subtitleDownloadAvailable": true,
"trickplayAvailable": true,
"skipIntroAvailable": true,
"endCreditsAvailable": true
} }
""".trimIndent(), """.trimIndent(),
) )
@@ -556,6 +570,10 @@ class GatewayPayloadTest {
assertEquals("S2 · E5", next.item.episodeCode) assertEquals("S2 · E5", next.item.episodeCode)
assertEquals(0L, next.resumePositionMs) assertEquals(0L, next.resumePositionMs)
assertTrue(next.url.startsWith("https://emby.example/Videos/11/stream")) assertTrue(next.url.startsWith("https://emby.example/Videos/11/stream"))
assertTrue(next.subtitleDownloadAvailable)
assertTrue(next.trickplayAvailable)
assertTrue(next.skipIntroAvailable)
assertTrue(next.endCreditsAvailable)
} }
@Test @Test
@@ -40,6 +40,14 @@ class ListKeysTest {
assertEquals(emptyList<BaseItem>(), emptyList<BaseItem>().distinctForKeys(BaseItem::id)) assertEquals(emptyList<BaseItem>(), emptyList<BaseItem>().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 // 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 // refresh could produce by appending a freshly built row beside the one it was meant to
// replace. // replace.
@@ -48,6 +48,16 @@ class PlaybackRecoveryTest {
assertTrue(failure.canAutoRetry) assertTrue(failure.canAutoRetry)
} }
@Test
fun malformedContainersDoNotLoopThroughTranscodingRetries() {
val failure = describePlaybackFailure(
PlaybackException.ERROR_CODE_PARSING_CONTAINER_MALFORMED,
)
assertFalse(failure.canAutoRetry)
assertFalse(failure.requiresTranscode)
}
@Test @Test
fun automaticRetriesAreBoundedAndBackOff() { fun automaticRetriesAreBoundedAndBackOff() {
assertEquals(1_000L, automaticRetryDelayMs(1)) assertEquals(1_000L, automaticRetryDelayMs(1))
@@ -63,4 +73,17 @@ class PlaybackRecoveryTest {
assertFalse(shouldRecoverProlongedRebuffer(true, false, true, false)) assertFalse(shouldRecoverProlongedRebuffer(true, false, true, false))
assertFalse(shouldRecoverProlongedRebuffer(true, false, false, true)) 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", ""),
)
}
} }
@@ -57,5 +57,13 @@ class PlayerTimingTest {
PlaybackCompletionAction.PLAY_NEXT, PlaybackCompletionAction.PLAY_NEXT,
playbackCompletionAction(hasNextEpisode = true, nextUpDismissed = false), playbackCompletionAction(hasNextEpisode = true, nextUpDismissed = false),
) )
assertEquals(
PlaybackCompletionAction.WAIT_FOR_NEXT,
playbackCompletionAction(
hasNextEpisode = false,
nextUpDismissed = false,
nextLookupInFlight = true,
),
)
} }
} }
+8
View File
@@ -566,6 +566,14 @@ func (s *Server) writeUpstreamError(
case apiErr.StatusCode == http.StatusNotFound: case apiErr.StatusCode == http.StatusNotFound:
writeError(w, http.StatusNotFound, "not found on the emby server") writeError(w, http.StatusNotFound, "not found on the emby server")
return 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) s.loggerFor(ctx).Error(message, "error", err)
+35
View File
@@ -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) { func TestHashTokenIsStable(t *testing.T) {
a, b := hashToken("token"), hashToken("token") a, b := hashToken("token"), hashToken("token")
if string(a) != string(b) { if string(a) != string(b) {
+36 -21
View File
@@ -92,16 +92,20 @@ type playbackReportResponse struct {
// Emby's own item JSON, forwarded verbatim like every other item the gateway returns, so // 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. // the client decodes it into the same BaseItem it uses everywhere else.
type nextEpisodeResponse struct { type nextEpisodeResponse struct {
Item json.RawMessage `json:"item"` Item json.RawMessage `json:"item"`
Title string `json:"title"` Title string `json:"title"`
URL string `json:"url"` URL string `json:"url"`
ResumePositionMs int64 `json:"resumePositionMs"` ResumePositionMs int64 `json:"resumePositionMs"`
Subtitles []playableSubtitle `json:"subtitles"` Subtitles []playableSubtitle `json:"subtitles"`
SubtitlesEnabled bool `json:"subtitlesEnabled"` SubtitlesEnabled bool `json:"subtitlesEnabled"`
SelectedSubtitleID string `json:"selectedSubtitleId,omitempty"` SelectedSubtitleID string `json:"selectedSubtitleId,omitempty"`
MediaSourceID string `json:"mediaSourceId"` MediaSourceID string `json:"mediaSourceId"`
PlaySessionID string `json:"playSessionId"` PlaySessionID string `json:"playSessionId"`
PlayMethod string `json:"playMethod"` 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. // 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{ writeJSON(w, http.StatusOK, nextEpisodeResponse{
Item: raw, Item: raw,
Title: title, Title: title,
URL: streamURL, URL: streamURL,
ResumePositionMs: max64(next.UserData.PlaybackPositionTicks/ticksPerMillisecond, 0), ResumePositionMs: max64(next.UserData.PlaybackPositionTicks/ticksPerMillisecond, 0),
Subtitles: subtitles, Subtitles: subtitles,
SubtitlesEnabled: subtitlesEnabled, SubtitlesEnabled: subtitlesEnabled,
SelectedSubtitleID: selectedSubtitleID, SelectedSubtitleID: selectedSubtitleID,
MediaSourceID: mediaSourceID, MediaSourceID: mediaSourceID,
PlaySessionID: playSessionID, PlaySessionID: playSessionID,
PlayMethod: playMethod, 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, max64(report.PositionMs, 0)*ticksPerMillisecond, report.IsPaused,
) )
if err != nil { 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) 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. // Start and stop are the shape of an evening's viewing and belong in the normal log.
@@ -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)
}
}
+5 -2
View File
@@ -180,7 +180,10 @@ func (s *Server) handleRequest(w http.ResponseWriter, r *http.Request, sess stor
continue continue
} }
if movie.ID > 0 { 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 return
} }
added, err := s.radarr.AddUnmonitored(r.Context(), movie) 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 continue
} }
if show.ID > 0 { 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 return
} }
added, err := s.sonarr.AddUnmonitored(r.Context(), show) added, err := s.sonarr.AddUnmonitored(r.Context(), show)
+5 -4
View File
@@ -126,6 +126,7 @@ type MediaStream struct {
type APIError struct { type APIError struct {
StatusCode int StatusCode int
Body string Body string
RetryAfter string
} }
func (e *APIError) Error() 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 { if resp.StatusCode >= 400 {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048)) body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
resp.Body.Close() 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 return resp, nil
} }
@@ -509,7 +510,7 @@ func (c *Client) TrickplayBytes(
defer resp.Body.Close() defer resp.Body.Close()
if resp.StatusCode >= 400 { if resp.StatusCode >= 400 {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048)) 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 // 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. // 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() defer resp.Body.Close()
if resp.StatusCode >= 400 { if resp.StatusCode >= 400 {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048)) 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)) 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 // Emby error bodies can echo request details; cap what we keep and never log it
// alongside a token. // alongside a token.
body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048)) 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 { if out == nil {
_, _ = io.Copy(io.Discard, resp.Body) _, _ = io.Copy(io.Discard, resp.Body)