This commit is contained in:
ponzischeme89
2026-08-11 15:18:26 +12:00
parent 594159c0ae
commit 5e5849f985
33 changed files with 1143 additions and 241 deletions
+1 -1
View File
@@ -42,7 +42,7 @@ val projectNoticeText =
// A release workflow can derive the app version from its Git tag without editing the
// 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, "Couldnt 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 couldnt 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 "Couldnt 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 = "Couldnt start the video player",
detail = "Memby couldnt initialise this TVs video decoder. Try again or return to Memby.",
canAutoRetry = false,
),
)
return
}
player = createdPlayer.also { playback ->
view.player = playback
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, "Couldnt enable subtitles", Toast.LENGTH_SHORT).show()
return@onSuccess
}
adoptReplacementSession(
newItemId = selected.itemId,
newMediaSourceId = selected.mediaSourceId,
newPlaySessionId = selected.playSessionId,
newPlayMethod = selected.playMethod,
positionMs = position,
)
availableSubtitles = selected.subtitles
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 = "Couldnt 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, "Couldnt 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 = "Couldnt start the trailer."
playbackLaunching = false
} else {
// Keep rapid Select/media-key repeats gated while the
// activity hand-off occurs, then re-arm on return.
delay(1_000L)
playbackLaunching = false
}
}
.onFailure {
toast = friendlyEmbyError(it)
playbackLaunching = false
}
}
}
.onFailure { toast = friendlyEmbyError(it) }
.onFailure {
toast = friendlyEmbyError(it)
playbackLaunching = false
}
}
}
@@ -99,6 +99,7 @@ import com.ponzischeme89.memby.ServiceLocator
import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.data.model.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("Couldnt load more titles.", color = Muted, fontSize = 13.sp)
Spacer(Modifier.width(10.dp))
MembyChoiceChip(label = "Try again", selected = false, onClick = onRetryPage)
}
}
}
}
}
@@ -64,6 +64,7 @@ data class SearchUiState(
/** True once a query has run to completion, so "no matches" is distinguishable from "not yet". */
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,
),
)
}
}