This commit is contained in:
ponzischeme89
2026-08-17 07:34:23 +12:00
parent 93fb0fd728
commit 36cb1324fd
56 changed files with 1177 additions and 168 deletions
@@ -97,6 +97,7 @@ data class Playable(
val itemId: String,
val title: String,
val url: String,
val seriesName: String? = null,
val resumePositionMs: Long = 0L,
val logoUrl: String? = null,
val subtitles: List<PlayableSubtitle> = emptyList(),
@@ -203,6 +204,7 @@ data class PlaybackRequest(
val itemId: String,
val itemType: String = "",
val title: String = "",
val seriesName: String? = null,
val isSeries: Boolean = false,
val resumePositionMs: Long = 0L,
val logoUrl: String? = null,
@@ -259,6 +261,8 @@ class EmbyRepository(private val settings: SettingsStore) {
val showTitleLogo: Boolean get() = snapshot.showTitleLogo
private val _playbackStops = MutableSharedFlow<String>(extraBufferCapacity = 1)
val playbackStops = _playbackStops.asSharedFlow()
/** Emby session reports must arrive in order; an older progress request cannot follow Stop. */
private val playbackReportMutex = Mutex()
private val playableMutex = Mutex()
private val playableCache = LinkedHashMap<String, CachedPlayable>(16, 0.75f, true)
private val playableInFlight = mutableMapOf<String, Deferred<Playable>>()
@@ -1858,6 +1862,11 @@ class EmbyRepository(private val settings: SettingsStore) {
itemId = item.id,
itemType = item.type,
title = item.name,
seriesName = when {
item.isEpisode -> item.seriesName
item.isSeries -> item.name
else -> null
},
isSeries = item.isSeries,
resumePositionMs = item.resumePositionMs,
logoUrl = logoUrl(item),
@@ -2121,6 +2130,7 @@ class EmbyRepository(private val settings: SettingsStore) {
return Playable(
itemId = playback.itemId,
title = playback.title.ifBlank { item.title },
seriesName = playback.seriesName.ifBlank { item.seriesName },
url = playback.url,
resumePositionMs = playback.resumePositionMs,
logoUrl = item.logoUrl,
@@ -2159,6 +2169,7 @@ class EmbyRepository(private val settings: SettingsStore) {
return Playable(
itemId = episode.id,
title = title,
seriesName = item.title,
url = discovery.url ?: buildStreamUrl(episode.id),
resumePositionMs = episode.resumePositionMs,
logoUrl = item.logoUrl,
@@ -2184,6 +2195,7 @@ class EmbyRepository(private val settings: SettingsStore) {
return Playable(
itemId = item.itemId,
title = item.title,
seriesName = item.seriesName,
url = discovery.url ?: buildStreamUrl(item.itemId),
resumePositionMs = item.resumePositionMs,
logoUrl = item.logoUrl,
@@ -2248,11 +2260,13 @@ class EmbyRepository(private val settings: SettingsStore) {
}
suspend fun reportPlaybackStarted(session: PlaybackSession, positionMs: Long) {
if (ServerConfig.isGateway) {
requireGateway().report("started", session.gatewayReport(positionMs, false, null))
return
playbackReportMutex.withLock {
if (ServerConfig.isGateway) {
requireGateway().report("started", session.gatewayReport(positionMs, false, null))
} else {
requireApi().reportPlaybackStarted(playbackReport(session, positionMs, false, null))
}
}
requireApi().reportPlaybackStarted(playbackReport(session, positionMs, false, null))
}
suspend fun reportPlaybackProgress(
@@ -2261,25 +2275,30 @@ class EmbyRepository(private val settings: SettingsStore) {
isPaused: Boolean,
eventName: String,
durationMs: Long = 0L,
): String? {
): String? = playbackReportMutex.withLock {
if (ServerConfig.isGateway) {
return requireGateway().report(
"progress",
session.gatewayReport(positionMs, isPaused, eventName, durationMs),
).autoFollowedShowTitle.takeIf(String::isNotBlank)
requireGateway().report(
"progress",
session.gatewayReport(positionMs, isPaused, eventName, durationMs),
)
.autoFollowedShowTitle
.takeIf(String::isNotBlank)
} else {
requireApi().reportPlaybackProgress(playbackReport(session, positionMs, isPaused, eventName))
null
}
requireApi().reportPlaybackProgress(playbackReport(session, positionMs, isPaused, eventName))
return null
}
suspend fun reportPlaybackStopped(session: PlaybackSession, positionMs: Long) {
try {
if (ServerConfig.isGateway) {
// Stopping is also what drops the gateway's cached rows for this user,
// so Continue Watching reflects the new position on the next home load.
requireGateway().report("stopped", session.gatewayReport(positionMs, true, null))
} else {
requireApi().reportPlaybackStopped(playbackReport(session, positionMs, true, null))
playbackReportMutex.withLock {
if (ServerConfig.isGateway) {
// Stopping is also what drops the gateway's cached rows for this user,
// so Continue Watching reflects the new position on the next home load.
requireGateway().report("stopped", session.gatewayReport(positionMs, true, null))
} else {
requireApi().reportPlaybackStopped(playbackReport(session, positionMs, true, null))
}
}
} finally {
clearPlayableCache()
@@ -2289,9 +2308,14 @@ class EmbyRepository(private val settings: SettingsStore) {
}
}
fun enqueuePlaybackStopped(session: PlaybackSession, positionMs: Long) {
fun enqueuePlaybackStopped(
session: PlaybackSession,
positionMs: Long,
onSuccess: () -> Unit = {},
) {
scope.launch {
runCatching { reportPlaybackStopped(session, positionMs) }
.onSuccess { onSuccess() }
}
}
@@ -894,7 +894,7 @@ data class GatewayCalendar(
val next: String = "",
/** The household's today, only when it falls inside this month. */
val today: String = "",
/** Sunday is 0, matching the weekday header the grid draws. */
/** Sunday is 0 on the wire; the TV rotates it into its Monday-first NZ week. */
val firstWeekday: Int = 0,
val dayCount: Int = 0,
val days: List<GatewayCalendarDay> = emptyList(),
@@ -181,6 +181,17 @@ enum class BrowseDestination(val label: String, val icon: ImageVector) {
SETTINGS("Settings", Icons.Default.Settings),
}
/**
* The user switcher is a launcher action, not a browsing destination. Keep it pinned above
* Home while the destinations below it may change with server capabilities.
*/
internal fun navigationRailItems(calendarEnabled: Boolean): List<BrowseDestination> =
listOf(BrowseDestination.PROFILES) + BrowseDestination.entries.filter {
it != BrowseDestination.PROFILES &&
it != BrowseDestination.SETTINGS &&
(it != BrowseDestination.CALENDAR || calendarEnabled)
}
// No NEXT_UP: those episodes are part of CONTINUE, which is one row.
enum class MediaRowKind { CONTINUE, MOVIES, SHOWS, FAVORITES }
@@ -238,6 +249,7 @@ private fun homeRowVisual(row: HomeBrowseRow): HomeRowVisual = when {
)
}
@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun TvNavigationRail(
config: NavigationRemoteConfig = BundledRemoteConfig.value.navigation,
@@ -358,12 +370,22 @@ fun TvNavigationRail(
// A destination with nothing behind it is worse than one fewer: the calendar
// needs the gateway and a Sonarr, and a household with neither would otherwise
// carry a rail item that only ever opens an apology.
val destinations = remember(calendarEnabled) {
BrowseDestination.entries.filter {
it != BrowseDestination.CALENDAR || calendarEnabled
val destinations = remember(calendarEnabled) { navigationRailItems(calendarEnabled) }
// Up and Down are explicit because the profile entry is an action while every
// item beneath it changes the current destination. Leaving this to spatial
// search allowed content behind the expanded rail to win occasionally.
val itemFocusRequesters = remember(
destinations,
focusDestination,
navigationFocusRequester,
) {
destinations.associateWith { destination ->
if (destination == focusDestination) navigationFocusRequester
else FocusRequester()
}
}
destinations.forEach { destination ->
destinations.forEachIndexed { index, destination ->
val itemFocusRequester = itemFocusRequesters.getValue(destination)
ExpandableNavigationItem(
destination = destination,
label = if (destination == BrowseDestination.PROFILES) {
@@ -373,11 +395,20 @@ fun TvNavigationRail(
},
selected = destination == selected,
expanded = expanded,
modifier = if (destination == focusDestination) {
Modifier.focusRequester(navigationFocusRequester)
} else {
Modifier
},
modifier = Modifier
.focusRequester(itemFocusRequester)
.focusProperties {
up = if (index == 0) {
FocusRequester.Cancel
} else {
itemFocusRequesters.getValue(destinations[index - 1])
}
down = if (index == destinations.lastIndex) {
FocusRequester.Cancel
} else {
itemFocusRequesters.getValue(destinations[index + 1])
}
},
onFocused = {},
onClick = { onDestinationSelected(destination) },
// Notifications are one level in, behind the user picker. Without a mark out
@@ -433,6 +464,7 @@ fun UserSwitcherOverlay(
modifier: Modifier = Modifier,
alertCount: Int = 0,
onOpenAlerts: () -> Unit = {},
onOpenSettings: () -> Unit = {},
/**
* Whether this viewer may ask the household for titles. False hides the entry entirely
* rather than dimming it: an operator's allowlist is not something a viewer can act on,
@@ -540,7 +572,10 @@ fun UserSwitcherOverlay(
state = profileListState,
modifier = Modifier
.fillMaxWidth()
.heightIn(max = 248.dp),
// Settings adds one fixed action below this list. Give that row its
// space while keeping the panel's established 400dp maximum; larger
// households still reach every profile through the lazy list.
.heightIn(max = 208.dp),
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
itemsIndexed(
@@ -580,8 +615,7 @@ fun UserSwitcherOverlay(
},
onClick = onOpenAlerts,
)
// Requests sits between the two because it is news-shaped like Notifications
// rather than administrative like Manage users, which stays last.
// Requests sits beside Notifications because both are personal activity.
if (showRequests) {
UserSwitcherAction(
label = "Requests",
@@ -594,10 +628,21 @@ fun UserSwitcherOverlay(
onClick = onOpenRequests,
)
}
val settingsIndex = profiles.size + if (showRequests) 2 else 1
UserSwitcherAction(
label = "Settings",
icon = Icons.Default.Settings,
modifier = Modifier
.focusRequester(focusRequesters[settingsIndex])
.onFocusChanged {
if (it.isFocused) focusedIndex = settingsIndex
},
onClick = onOpenSettings,
)
val manageIndex = profiles.size + actionCount - 1
UserSwitcherAction(
label = "Manage users",
icon = Icons.Default.Settings,
icon = Icons.Default.Person,
modifier = Modifier
.focusRequester(focusRequesters[manageIndex])
.onFocusChanged {
@@ -610,13 +655,13 @@ fun UserSwitcherOverlay(
}
/**
* Notifications, then Requests when this viewer may make them, then Manage users.
* Notifications, then Requests when this viewer may make them, Settings, then Manage users.
*
* Pure and derived in one place because three things read it — the requester list's length,
* the D-pad's lower bound and Manage users' own index — and a count that disagreed with the
* rows actually drawn is how the last item in a menu becomes unreachable.
*/
internal fun userSwitcherActionCount(showRequests: Boolean): Int = if (showRequests) 3 else 2
internal fun userSwitcherActionCount(showRequests: Boolean): Int = if (showRequests) 4 else 3
@Composable
private fun UserSwitcherProfileItem(
@@ -2191,6 +2191,8 @@ private fun HomeScreen(
context = context,
request = request,
posterUrl = repo.primaryUrl(item, maxWidth = 500),
backdropUrl = repo.backdropUrl(item, maxWidth = 1920)
?: repo.primaryUrl(item, maxWidth = 1920),
requestStartedAtMs = playbackRequestedAtMs,
),
)
@@ -2239,6 +2241,9 @@ private fun HomeScreen(
title = playable.title,
resumePositionMs = playable.resumePositionMs,
logoUrl = playable.logoUrl,
backdropUrl = repo.backdropUrl(item, maxWidth = 1920)
?: repo.primaryUrl(item, maxWidth = 1920),
seriesName = playable.seriesName,
overview = playable.overview ?: item.overview,
episodeCode = playable.episodeCode,
runtimeMs = playable.runtimeMs,
@@ -3175,6 +3180,18 @@ private fun HomeScreen(
showProfiles = true
},
alertCount = displayedNotifications.size,
onOpenSettings = {
homeViewModel.trackJourney(
category = "navigation", action = "select",
screen = journeyScreen, feature = "settings",
source = journeyScreen, target = "settings",
)
userSwitcherVisible = false
navigationExpanded = false
railFocusDestination = BrowseDestination.PROFILES
restoreRailAfterSettings = true
showSettings = true
},
onOpenAlerts = {
homeViewModel.trackJourney(
category = "notifications", action = "open", screen = journeyScreen,
@@ -3,9 +3,9 @@ package com.ponzischeme89.memby.ui
internal enum class UserSwitcherDirection { UP, DOWN }
/**
* Profiles occupy [0, profileCount); the pinned actions Notifications, then Manage users
* follow them in order. Keeping this arithmetic outside Compose makes remote navigation
* deterministic.
* Profiles occupy [0, profileCount); the pinned actions Notifications, optional Requests,
* Settings, then Manage users follow them in order. Keeping this arithmetic outside
* Compose makes remote navigation deterministic.
*/
internal fun userSwitcherInitialIndex(
profileIds: List<String>,
@@ -31,8 +31,8 @@ data class CalendarCell(
const val CALENDAR_COLUMNS = 7
/** Sunday first, matching the `firstWeekday` the gateway sends. */
val CALENDAR_WEEKDAYS = listOf("Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat")
/** New Zealand week order. The gateway's `firstWeekday` remains Sunday-based on the wire. */
val CALENDAR_WEEKDAYS = listOf("Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun")
data class CalendarAgendaWeek(
val cells: List<CalendarCell>,
@@ -73,7 +73,8 @@ fun calendarAgendaWeekDate(week: CalendarAgendaWeek): String =
fun calendarWeeks(calendar: GatewayCalendar): List<List<CalendarCell>> {
val dayCount = calendar.dayCount.coerceIn(0, 31)
if (dayCount == 0) return emptyList()
val leading = calendar.firstWeekday.coerceIn(0, CALENDAR_COLUMNS - 1)
// The wire uses Sunday = 0. Rotate that offset into the Monday-first grid shown on TV.
val leading = (calendar.firstWeekday.coerceIn(0, CALENDAR_COLUMNS - 1) + 6) % CALENDAR_COLUMNS
val byDay = calendar.days.associateBy { it.day }
val cells = ArrayList<CalendarCell>(leading + dayCount)
@@ -24,6 +24,13 @@ class PlaybackStopWorker(
ServiceLocator.init(applicationContext)
val itemId = inputData.getString(ITEM_ID).orEmpty()
if (itemId.isBlank()) return Result.failure()
if (!playbackStopIsFresh(
enqueuedAtMs = inputData.getLong(ENQUEUED_AT_MS, 0L),
nowMs = System.currentTimeMillis(),
)
) {
return Result.success()
}
val session = PlaybackSession(
itemId = itemId,
mediaSourceId = inputData.getString(MEDIA_SOURCE_ID).orEmpty().ifBlank { itemId },
@@ -47,6 +54,7 @@ class PlaybackStopWorker(
private const val PLAY_SESSION_ID = "play_session_id"
private const val PLAY_METHOD = "play_method"
private const val POSITION_MS = "position_ms"
private const val ENQUEUED_AT_MS = "enqueued_at_ms"
private const val MAX_RETRIES = 5
private fun workName(session: PlaybackSession): String =
@@ -59,15 +67,23 @@ class PlaybackStopWorker(
.putString(PLAY_SESSION_ID, session.playSessionId)
.putString(PLAY_METHOD, session.playMethod)
.putLong(POSITION_MS, positionMs.coerceAtLeast(0L))
.putLong(ENQUEUED_AT_MS, System.currentTimeMillis())
.build()
val request = OneTimeWorkRequestBuilder<PlaybackStopWorker>()
.setInputData(data)
.build()
WorkManager.getInstance(context.applicationContext).enqueueUniqueWork(
val workManager = WorkManager.getInstance(context.applicationContext)
workManager.enqueueUniqueWork(
workName(session),
ExistingWorkPolicy.REPLACE,
request,
)
// WorkManager is the process-death fallback, not the ordinary delivery path.
// Send now from the repository's process scope, which survives Activity
// destruction, then cancel this exact fallback request once Emby accepts it.
ServiceLocator.repository.enqueuePlaybackStopped(session, positionMs) {
workManager.cancelWorkById(request.id)
}
}
/** A resumed session must not be stopped later by work queued while backgrounded. */
@@ -79,3 +95,10 @@ class PlaybackStopWorker(
}
}
}
/** A late retry must not overwrite progress made after the viewer moved to another client. */
internal fun playbackStopIsFresh(
enqueuedAtMs: Long,
nowMs: Long,
maxAgeMs: Long = 60_000L,
): Boolean = enqueuedAtMs > 0L && nowMs >= enqueuedAtMs && nowMs - enqueuedAtMs <= maxAgeMs
@@ -124,6 +124,21 @@ internal fun passthroughOsdSummary(preference: AudioPassthroughPreference): Stri
else -> "${preference.codecs.size} formats"
}
/** Episode wording for the station ident, separate from the logo/fallback presentation. */
internal fun playbackIdentityEpisodeLabel(
title: String,
seriesName: String?,
episodeCode: String?,
): String? {
val code = episodeCode?.trim().orEmpty()
if (code.isEmpty()) return null
val episodeTitle = title.trim()
.removePrefix(seriesName?.trim().orEmpty() + " ")
.trim()
.takeUnless { it.isEmpty() || it == seriesName?.trim() }
return listOfNotNull(code, episodeTitle).joinToString(" · ")
}
/**
* Fullscreen Media3 player with native stream-track selection. Press Menu while
* playing to choose an audio or subtitle track; the subtitle controller button
@@ -151,6 +166,7 @@ class PlayerActivity : ComponentActivity() {
private var remainingView: TextView? = null
private var finishTimeView: TextView? = null
private var loadingView: View? = null
private var loadingBackdropView: ImageView? = null
private var loadingTitleView: TextView? = null
private var loadingHintView: TextView? = null
private val playbackLoadingQuote: String by lazy {
@@ -197,6 +213,8 @@ class PlayerActivity : ComponentActivity() {
private var launchTraceCookie = NO_TRACE
private var firstFrameTraceCookie = NO_TRACE
private var logoUrl: String? = null
private var loadingBackdropUrl: String? = null
private var playbackSeriesName: String? = null
private var playbackTitle = ""
private var pauseOverview = ""
private var prerollEpisodeCode = ""
@@ -306,6 +324,8 @@ class PlayerActivity : ComponentActivity() {
private var previewResumeDurationMs = 0L
private var previewResumeTitle = ""
private var previewResumeLogoUrl: String? = null
private var previewResumeBackdropUrl: String? = null
private var previewResumeSeriesName: String? = null
private var previewResumePosterUrl: String? = null
private var previewResumeOverview = ""
private var previewResumePlaybackStarted = false
@@ -548,6 +568,7 @@ class PlayerActivity : ComponentActivity() {
bindScrubPreview(view)
applyPictureMode()
loadingView = findViewById(R.id.playback_loading)
loadingBackdropView = findViewById(R.id.playback_loading_backdrop)
loadingTitleView = findViewById(R.id.playback_loading_title)
loadingHintView = findViewById<TextView>(R.id.playback_loading_hint).also {
// Set this as soon as the layout is mounted so the XML fallback never flashes
@@ -572,6 +593,10 @@ class PlayerActivity : ComponentActivity() {
streamStatusView = view.findViewById(R.id.player_stream_status)
logoUrl = savedInstanceState?.getString(STATE_LOGO_URL)
?: intent.getStringExtra(EXTRA_LOGO_URL)
loadingBackdropUrl = savedInstanceState?.getString(STATE_BACKDROP_URL)
?: intent.getStringExtra(EXTRA_BACKDROP_URL)
playbackSeriesName = savedInstanceState?.getString(STATE_SERIES_NAME)
?: intent.getStringExtra(EXTRA_SERIES_NAME)
playbackTitle = savedInstanceState?.getString(STATE_TITLE)
?: intent.getStringExtra(EXTRA_TITLE).orEmpty()
pauseOverview = savedInstanceState?.getString(STATE_OVERVIEW)
@@ -585,6 +610,7 @@ class PlayerActivity : ComponentActivity() {
}
pausePosterUrl = savedInstanceState?.getString(STATE_POSTER_URL)
?: intent.getStringExtra(EXTRA_POSTER_URL)
bindLoadingBackdrop(loadingBackdropUrl)
if (showPreroll) startPreroll() else startWithoutPreroll()
setUpPlaybackError()
@@ -789,7 +815,12 @@ class PlayerActivity : ComponentActivity() {
// --- Decoration -------------------------------------------------------------
bindTitleArtwork(title = playbackTitle, logoUrl = logoUrl)
bindPauseOverlay(view)
setUpPlaybackIdentity(title = playbackTitle)
setUpPlaybackIdentity(
title = playbackTitle,
seriesName = playbackSeriesName,
episodeCode = prerollEpisodeCode,
logoUrl = logoUrl,
)
setUpSubtitleOverlay()
setUpCastOverlay()
setUpNextUpBanner()
@@ -882,7 +913,7 @@ class PlayerActivity : ComponentActivity() {
playMethod = playable.playMethod
playbackTitle = playable.title.ifBlank { request.title + " trailer" }
bindTitleArtwork(playbackTitle, logoUrl)
setUpPlaybackIdentity(playbackTitle)
setUpPlaybackIdentity(playbackTitle, null, null, logoUrl)
startMedia(playable.url, emptyList(), 0L, playWhenReady = true)
}
.onFailure { error ->
@@ -1126,15 +1157,21 @@ class PlayerActivity : ComponentActivity() {
playable.overview?.takeIf(String::isNotBlank)?.let { pauseOverview = it }
playable.episodeCode?.takeIf(String::isNotBlank)?.let { prerollEpisodeCode = it }
playable.logoUrl?.takeIf(String::isNotBlank)?.let { logoUrl = it }
playable.seriesName?.takeIf(String::isNotBlank)?.let { playbackSeriesName = it }
Log.i(
PLAYBACK_LOG_TAG,
"event=subtitle_configs item=${playable.itemId} " +
"count=${playable.subtitles.size} source=launch",
)
if (playable.title.isBlank() || playable.title == playbackTitle) return
playbackTitle = playable.title
if (playable.title.isNotBlank()) playbackTitle = playable.title
bindTitleArtwork(title = playbackTitle, logoUrl = logoUrl)
setUpPlaybackIdentity(title = playbackTitle)
if (prerollActive) bindPrerollIdentity()
setUpPlaybackIdentity(
title = playbackTitle,
seriesName = playbackSeriesName,
episodeCode = prerollEpisodeCode,
logoUrl = logoUrl,
)
pauseOverlay?.findViewById<TextView>(R.id.player_pause_title)?.text = playbackTitle
}
@@ -1151,6 +1188,7 @@ class PlayerActivity : ComponentActivity() {
hideController()
}
if (!attachLocalPreroll()) enterPrerollVideoFrame()
bindPrerollIdentity()
bindPrerollNow()
bindPrerollSchedule(GatewayPrerollSchedule(), loading = true)
prerollScheduleJob = lifecycleScope.launch {
@@ -1519,6 +1557,52 @@ class PlayerActivity : ComponentActivity() {
pauseOverview.ifBlank { "Memby is preparing this episode for playback." }
}
private fun bindPrerollIdentity() {
val logo = findViewById<ImageView>(R.id.player_preroll_identity_logo)
val fallback = findViewById<TextView>(R.id.player_preroll_identity_title)
val episode = findViewById<TextView>(R.id.player_preroll_identity_episode)
val episodeLabel = playbackIdentityEpisodeLabel(
playbackTitle,
playbackSeriesName,
prerollEpisodeCode,
)
episode.text = episodeLabel.orEmpty()
episode.visibility = if (episodeLabel == null) View.GONE else View.VISIBLE
// Films retain the Memby station mark. An episode is introduced by the programme
// itself, with its series name as the honest fallback when Emby has no logo.
if (episodeLabel == null) {
logo.clearColorFilter()
logo.setImageResource(R.drawable.emby_logo)
logo.visibility = View.VISIBLE
fallback.visibility = View.GONE
return
}
fallback.text = playbackSeriesName?.takeIf(String::isNotBlank)
?: playbackTitle.substringBefore(" ").ifBlank { "Now playing" }
if (logoUrl.isNullOrBlank()) {
logo.clearColorFilter()
logo.setImageDrawable(null)
logo.visibility = View.GONE
fallback.visibility = View.VISIBLE
return
}
logo.load(logoUrl) {
crossfade(false)
listener(
onSuccess = { _, result ->
makeLogoVisibleOnDarkBackground(logo, result.drawable)
logo.visibility = View.VISIBLE
fallback.visibility = View.GONE
},
onError = { _, _ ->
logo.visibility = View.GONE
fallback.visibility = View.VISIBLE
},
)
}
}
private fun prerollEntry(entry: GatewayPrerollEntry, label: String): View =
LayoutInflater.from(this).inflate(R.layout.player_preroll_schedule_card, null).apply {
findViewById<TextView>(R.id.player_preroll_card_label).text = label
@@ -2015,11 +2099,52 @@ class PlayerActivity : ComponentActivity() {
}
}
private fun setUpPlaybackIdentity(title: String) {
private fun bindLoadingBackdrop(url: String?) {
val backdrop = loadingBackdropView ?: return
if (url.isNullOrBlank()) {
backdrop.setImageDrawable(null)
return
}
backdrop.load(url) {
crossfade(false)
}
}
private fun setUpPlaybackIdentity(
title: String,
seriesName: String?,
episodeCode: String?,
logoUrl: String?,
) {
playbackIdentityView = findViewById(R.id.player_playback_identity)
findViewById<TextView>(R.id.player_playback_identity_title).apply {
text = title.ifBlank { "Now playing" }
visibility = View.VISIBLE
val logo = findViewById<ImageView>(R.id.player_playback_identity_logo)
val fallback = findViewById<TextView>(R.id.player_playback_identity_title).apply {
text = seriesName?.takeIf(String::isNotBlank) ?: title.ifBlank { "Now playing" }
}
findViewById<TextView>(R.id.player_playback_identity_episode).apply {
text = playbackIdentityEpisodeLabel(title, seriesName, episodeCode).orEmpty()
visibility = if (text.isNullOrBlank()) View.GONE else View.VISIBLE
}
if (logoUrl.isNullOrBlank()) {
logo.clearColorFilter()
logo.setImageDrawable(null)
logo.visibility = View.GONE
fallback.visibility = View.VISIBLE
return
}
logo.load(logoUrl) {
crossfade(false)
listener(
onSuccess = { _, result ->
makeLogoVisibleOnDarkBackground(logo, result.drawable)
logo.visibility = View.VISIBLE
fallback.visibility = View.GONE
},
onError = { _, _ ->
logo.visibility = View.GONE
fallback.visibility = View.VISIBLE
},
)
}
}
@@ -2930,6 +3055,8 @@ class PlayerActivity : ComponentActivity() {
previewResumeDurationMs = playback.duration.coerceAtLeast(previewResumePositionMs)
previewResumeTitle = playbackTitle
previewResumeLogoUrl = logoUrl
previewResumeBackdropUrl = loadingBackdropUrl
previewResumeSeriesName = playbackSeriesName
previewResumePosterUrl = pausePosterUrl
previewResumeOverview = pauseOverview
previewResumePlaybackStarted = playbackStarted
@@ -2943,11 +3070,14 @@ class PlayerActivity : ComponentActivity() {
hideNextUp()
if (creditsActive) leaveEndCredits(restoreSpeed = true)
playbackTitle = "Next: ${nextTitle(next)}"
playbackSeriesName = next.seriesName
logoUrl = next.logoUrl
loadingBackdropUrl = next.imageUrl
bindLoadingBackdrop(loadingBackdropUrl)
pausePosterUrl = next.imageUrl
pauseOverview = next.overview
bindTitleArtwork(playbackTitle, logoUrl)
setUpPlaybackIdentity(playbackTitle)
setUpPlaybackIdentity(playbackTitle, playbackSeriesName, next.episodeCode, logoUrl)
renderedFirstFrame = false
showPlaybackLoading(title = "Finding the next episode…", hint = "Starting recap or preview")
startMedia(preview.url, emptyList(), 0L, playWhenReady = true)
@@ -2979,12 +3109,20 @@ class PlayerActivity : ComponentActivity() {
}
playbackTitle = previewResumeTitle
logoUrl = previewResumeLogoUrl
loadingBackdropUrl = previewResumeBackdropUrl
playbackSeriesName = previewResumeSeriesName
bindLoadingBackdrop(loadingBackdropUrl)
pausePosterUrl = previewResumePosterUrl
pauseOverview = previewResumeOverview
playbackStarted = previewResumePlaybackStarted
stopReported = previewResumeStopReported
bindTitleArtwork(playbackTitle, logoUrl)
setUpPlaybackIdentity(playbackTitle)
setUpPlaybackIdentity(
playbackTitle,
playbackSeriesName,
prerollEpisodeCode,
logoUrl,
)
renderedFirstFrame = false
if (previewResumeUrl.isBlank()) {
// The original stream should always be known, but losing the optional preview
@@ -3555,13 +3693,21 @@ class PlayerActivity : ComponentActivity() {
// The cast reloads with the rest of the new episode's session, once it is playing.
playbackTitle = nextTitle(next)
playbackSeriesName = next.seriesName
logoUrl = next.logoUrl
loadingBackdropUrl = next.imageUrl
bindLoadingBackdrop(loadingBackdropUrl)
pausePosterUrl = next.imageUrl
pauseOverview = next.overview
prerollEpisodeCode = next.episodeCode.orEmpty()
prerollRuntimeMs = next.runtimeMs.coerceAtLeast(0L)
bindTitleArtwork(title = playbackTitle, logoUrl = logoUrl)
setUpPlaybackIdentity(title = playbackTitle)
setUpPlaybackIdentity(
title = playbackTitle,
seriesName = playbackSeriesName,
episodeCode = prerollEpisodeCode,
logoUrl = logoUrl,
)
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) }
@@ -4588,6 +4734,14 @@ class PlayerActivity : ComponentActivity() {
outState.putBoolean(STATE_END_CREDITS, endCreditsAvailable)
outState.putString(STATE_TITLE, if (savingPreview) previewResumeTitle else playbackTitle)
outState.putString(STATE_LOGO_URL, if (savingPreview) previewResumeLogoUrl else logoUrl)
outState.putString(
STATE_BACKDROP_URL,
if (savingPreview) previewResumeBackdropUrl else loadingBackdropUrl,
)
outState.putString(
STATE_SERIES_NAME,
if (savingPreview) previewResumeSeriesName else playbackSeriesName,
)
outState.putString(STATE_OVERVIEW, if (savingPreview) previewResumeOverview else pauseOverview)
outState.putString(STATE_POSTER_URL, if (savingPreview) previewResumePosterUrl else pausePosterUrl)
outState.putString(STATE_EPISODE_CODE, prerollEpisodeCode)
@@ -4878,6 +5032,8 @@ class PlayerActivity : ComponentActivity() {
private const val EXTRA_TITLE = "extra_title"
private const val EXTRA_RESUME_POSITION_MS = "extra_resume_position_ms"
private const val EXTRA_LOGO_URL = "extra_logo_url"
private const val EXTRA_BACKDROP_URL = "extra_backdrop_url"
private const val EXTRA_SERIES_NAME = "extra_series_name"
private const val EXTRA_OVERVIEW = "extra_overview"
private const val EXTRA_EPISODE_CODE = "extra_episode_code"
private const val EXTRA_RUNTIME_MS = "extra_runtime_ms"
@@ -4922,6 +5078,8 @@ class PlayerActivity : ComponentActivity() {
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_BACKDROP_URL = "state_backdrop_url"
private const val STATE_SERIES_NAME = "state_series_name"
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"
@@ -4951,6 +5109,7 @@ class PlayerActivity : ComponentActivity() {
context: Context,
request: PlaybackRequest,
posterUrl: String? = null,
backdropUrl: String? = null,
requestStartedAtMs: Long = SystemClock.elapsedRealtime(),
): Intent = Intent(context, PlayerActivity::class.java).apply {
putExtra(EXTRA_PLAYBACK_REQUEST, playerJson.encodeToString(request))
@@ -4958,10 +5117,12 @@ class PlayerActivity : ComponentActivity() {
putExtra(EXTRA_TITLE, request.title)
putExtra(EXTRA_RESUME_POSITION_MS, request.resumePositionMs)
request.logoUrl?.let { putExtra(EXTRA_LOGO_URL, it) }
request.seriesName?.takeIf(String::isNotBlank)?.let { putExtra(EXTRA_SERIES_NAME, it) }
request.overview?.takeIf { it.isNotBlank() }?.let { putExtra(EXTRA_OVERVIEW, it) }
request.episodeCode?.takeIf { it.isNotBlank() }?.let { putExtra(EXTRA_EPISODE_CODE, it) }
putExtra(EXTRA_RUNTIME_MS, request.runtimeMs.coerceAtLeast(0L))
posterUrl?.takeIf { it.isNotBlank() }?.let { putExtra(EXTRA_POSTER_URL, it) }
backdropUrl?.takeIf { it.isNotBlank() }?.let { putExtra(EXTRA_BACKDROP_URL, it) }
putExtra(EXTRA_REQUEST_STARTED_AT_MS, requestStartedAtMs)
}
@@ -4994,6 +5155,8 @@ class PlayerActivity : ComponentActivity() {
title: String?,
resumePositionMs: Long = 0L,
logoUrl: String? = null,
backdropUrl: String? = null,
seriesName: String? = null,
overview: String? = null,
episodeCode: String? = null,
runtimeMs: Long = 0L,
@@ -5018,6 +5181,8 @@ class PlayerActivity : ComponentActivity() {
putExtra(EXTRA_TITLE, title)
putExtra(EXTRA_RESUME_POSITION_MS, resumePositionMs)
logoUrl?.let { putExtra(EXTRA_LOGO_URL, it) }
backdropUrl?.takeIf(String::isNotBlank)?.let { putExtra(EXTRA_BACKDROP_URL, it) }
seriesName?.takeIf(String::isNotBlank)?.let { putExtra(EXTRA_SERIES_NAME, it) }
overview?.takeIf { it.isNotBlank() }?.let { putExtra(EXTRA_OVERVIEW, it) }
episodeCode?.takeIf { it.isNotBlank() }?.let { putExtra(EXTRA_EPISODE_CODE, it) }
putExtra(EXTRA_RUNTIME_MS, runtimeMs.coerceAtLeast(0L))
@@ -377,7 +377,7 @@ private fun MyRequestsPane(
RequestsNotice(
icon = Icons.Default.Inbox,
heading = "You have not asked for anything yet",
body = "Find a film or series and it will show up here while the household gets hold of it.",
body = "Find a film or series and it will show up here while it is added to your library.",
action = "Request something",
onAction = onBrowse,
actionFocusRequester = paneFocusRequester,
@@ -686,7 +686,7 @@ private fun CandidatesPane(
RequestsSearchPhase.NO_MATCHES ->
"Nothing matched “${state.searchedTerm ?: state.query.trim()}”. " +
"Check the spelling, or try the original title."
else -> "Type a name and press Search to find something the household does not have yet."
else -> "Type a name and press Search to find something that is not in your library yet."
},
)
return@Column
@@ -743,7 +743,7 @@ private fun CandidatesPane(
private fun candidateDetail(candidate: GatewayRequestCandidate): String = when (candidate.status) {
RequestStatus.AVAILABLE -> "Already in your library"
RequestStatus.REQUESTED -> "You have already asked for this"
RequestStatus.PROCESSING -> "The household is already getting this"
RequestStatus.PROCESSING -> "Already being added to your library"
RequestStatus.PENDING -> "Already tracked, not out yet"
RequestStatus.REQUESTABLE -> "Press to request"
else -> candidate.statusLabel.ifBlank { "Press to request" }