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