0.2.50
This commit is contained in:
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
var retryDelayMs = 2_000L
|
||||||
|
while (true) {
|
||||||
runCatching { repository.getSeriesEpisodes(seriesId) }
|
runCatching { repository.getSeriesEpisodes(seriesId) }
|
||||||
.onSuccess { episodes = it.sortedWith(seriesEpisodeComparator) }
|
.onSuccess {
|
||||||
.onFailure {
|
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()
|
||||||
}
|
}
|
||||||
|
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,7 +255,12 @@ 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
|
||||||
|
// slow therefore do not queue a burst that hits the server after it recovers.
|
||||||
|
if (refreshJob?.isActive == true) return
|
||||||
|
refreshJob = viewModelScope.launch(Dispatchers.IO) {
|
||||||
|
var retryDelayMs = HOME_RETRY_INITIAL_MS
|
||||||
|
do {
|
||||||
refreshMutex.withLock {
|
refreshMutex.withLock {
|
||||||
// A refresh can make a newly imported episode the next playable item for
|
// A refresh can make a newly imported episode the next playable item for
|
||||||
// an existing series ID. Never launch the pre-refresh negotiated session.
|
// an existing series ID. Never launch the pre-refresh negotiated session.
|
||||||
@@ -264,6 +277,10 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
|
|||||||
}
|
}
|
||||||
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,6 +2116,7 @@ private fun HomeScreen(
|
|||||||
playable
|
playable
|
||||||
}
|
}
|
||||||
.onSuccess { playable ->
|
.onSuccess { playable ->
|
||||||
|
val launched = runCatching {
|
||||||
playbackLauncher.launch(
|
playbackLauncher.launch(
|
||||||
PlayerActivity.intent(
|
PlayerActivity.intent(
|
||||||
context = context,
|
context = context,
|
||||||
@@ -2121,6 +2145,11 @@ private fun HomeScreen(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
if (launched.isFailure) {
|
||||||
|
Toast.makeText(context, "Couldn’t 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
|
||||||
// wait, which has already reopened the gate and said so on screen.
|
// wait, which has already reopened the gate and said so on screen.
|
||||||
@@ -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 couldn’t 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 "Couldn’t 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) }
|
||||||
|
?.getBoolean(STATE_SUBTITLES_ENABLED)
|
||||||
|
?: intent.getBooleanExtra(
|
||||||
EXTRA_SUBTITLES_ENABLED,
|
EXTRA_SUBTITLES_ENABLED,
|
||||||
ServiceLocator.settings.current?.subtitlesEnabled ?: true,
|
ServiceLocator.settings.current?.subtitlesEnabled ?: true,
|
||||||
)
|
)
|
||||||
serverSubtitleId = intent.getStringExtra(EXTRA_SELECTED_SUBTITLE_ID).orEmpty()
|
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 = "Couldn’t start the video player",
|
||||||
|
detail = "Memby couldn’t initialise this TV’s video decoder. Try again or return to Memby.",
|
||||||
|
canAutoRetry = false,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
player = createdPlayer.also { playback ->
|
||||||
view.player = playback
|
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.
|
||||||
|
val startResolvedPlayback = {
|
||||||
if (url != null) {
|
if (url != null) {
|
||||||
trace.mark(PlaybackTrace.STREAM_RESOLVED)
|
trace.mark(PlaybackTrace.STREAM_RESOLVED)
|
||||||
startMedia(url, subtitles, resumePositionMs, playWhenReady = !showPreroll)
|
startMedia(
|
||||||
|
url,
|
||||||
|
subtitles,
|
||||||
|
resumePositionMs,
|
||||||
|
playWhenReady = restoredPlayWhenReady ?: !showPreroll,
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
resolvePendingStream(requireNotNull(request), playWhenReady = !showPreroll)
|
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 {
|
||||||
|
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
|
||||||
|
val prepared = runCatching {
|
||||||
|
require(url.isNotBlank()) { "Playback URL is blank" }
|
||||||
playback.setMediaItem(mediaItem(url, subtitles), positionMs.coerceAtLeast(0L))
|
playback.setMediaItem(mediaItem(url, subtitles), positionMs.coerceAtLeast(0L))
|
||||||
playback.playWhenReady = playWhenReady
|
playback.playWhenReady = playWhenReady
|
||||||
playback.prepare()
|
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, "Couldn’t enable subtitles", Toast.LENGTH_SHORT).show()
|
||||||
|
return@onSuccess
|
||||||
|
}
|
||||||
|
adoptReplacementSession(
|
||||||
|
newItemId = selected.itemId,
|
||||||
|
newMediaSourceId = selected.mediaSourceId,
|
||||||
|
newPlaySessionId = selected.playSessionId,
|
||||||
|
newPlayMethod = selected.playMethod,
|
||||||
|
positionMs = position,
|
||||||
|
)
|
||||||
availableSubtitles = selected.subtitles
|
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 = "Couldn’t enable subtitles",
|
hidePlaybackLoading()
|
||||||
detail = "The Emby server could not prepare this image subtitle track.",
|
if (resumePlaying) playback.play()
|
||||||
canAutoRetry = false,
|
Toast.makeText(this@PlayerActivity, "Couldn’t enable subtitles", Toast.LENGTH_SHORT).show()
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3762,6 +3951,44 @@ class PlayerActivity : ComponentActivity() {
|
|||||||
return if (traits.isEmpty()) name else "$name · ${traits.joinToString(" · ")}"
|
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,17 +4119,29 @@ 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(
|
||||||
|
PendingProgressReport(id, positionMs, isPaused, eventName, durationMs),
|
||||||
|
)
|
||||||
|
if (progressUploadJob?.isActive == true) return
|
||||||
|
progressUploadJob = lifecycleScope.launch {
|
||||||
|
// A hostilely slow start response must still precede the first progress event.
|
||||||
|
playbackStartReportJob?.join()
|
||||||
|
for (report in progressReports) {
|
||||||
|
// One report at a time, with the channel retaining only the newest report
|
||||||
|
// that arrived during the wait. A slow earlier position can therefore
|
||||||
|
// never land at Emby after a newer one and move the saved playhead back.
|
||||||
|
if (itemId != report.itemId) continue
|
||||||
val evaluatesAutoFollow = !autoFollowEvaluated &&
|
val evaluatesAutoFollow = !autoFollowEvaluated &&
|
||||||
durationMs > 0L && positionMs >= (durationMs + 1L) / 2L
|
report.durationMs > 0L &&
|
||||||
|
report.positionMs >= (report.durationMs + 1L) / 2L
|
||||||
runCatching {
|
runCatching {
|
||||||
ServiceLocator.repository.reportPlaybackProgress(
|
ServiceLocator.repository.reportPlaybackProgress(
|
||||||
playbackSession(id),
|
playbackSession(report.itemId),
|
||||||
positionMs,
|
report.positionMs,
|
||||||
isPaused,
|
report.isPaused,
|
||||||
eventName,
|
report.eventName,
|
||||||
durationMs.takeUnless { autoFollowEvaluated } ?: 0L,
|
report.durationMs.takeUnless { autoFollowEvaluated } ?: 0L,
|
||||||
)
|
)
|
||||||
}.onSuccess { showTitle ->
|
}.onSuccess { showTitle ->
|
||||||
if (evaluatesAutoFollow) autoFollowEvaluated = true
|
if (evaluatesAutoFollow) autoFollowEvaluated = true
|
||||||
@@ -3881,6 +4155,59 @@ class PlayerActivity : ComponentActivity() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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,
|
||||||
@@ -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 = "Couldn’t start the trailer."
|
||||||
|
playbackLaunching = false
|
||||||
|
} else {
|
||||||
|
// Keep rapid Select/media-key repeats gated while the
|
||||||
|
// activity hand-off occurs, then re-arm on return.
|
||||||
|
delay(1_000L)
|
||||||
|
playbackLaunching = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onFailure { toast = friendlyEmbyError(it) }
|
.onFailure {
|
||||||
|
toast = friendlyEmbyError(it)
|
||||||
|
playbackLaunching = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.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("Couldn’t 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,
|
||||||
|
),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -102,6 +102,10 @@ type nextEpisodeResponse struct {
|
|||||||
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.
|
||||||
@@ -376,6 +380,10 @@ func (s *Server) handleNextEpisode(w http.ResponseWriter, r *http.Request, sess
|
|||||||
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user