This commit is contained in:
ponzischeme89
2026-08-26 21:31:05 +12:00
parent a5f9a91832
commit 3d42c98947
33 changed files with 1172 additions and 69 deletions
+1 -1
View File
@@ -38,7 +38,7 @@ val membyGatewayUrl: String = (project.findProperty("memby.gatewayUrl") as Strin
val membyDiagnosticLogLevel: String = (project.findProperty("memby.diagnosticLogLevel") as String?)
?.trim()?.uppercase()?.takeIf { it in setOf("INFO", "DEBUG", "TRACE") } ?: "INFO"
val defaultVersionName = "0.3.31"
val defaultVersionName = "0.3.32"
val membyVersionName: String =
(project.findProperty("memby.versionName") as String?)
?.trim()
@@ -97,6 +97,10 @@ data class NextEpisode(
val trickplayAvailable: Boolean = false,
val skipIntroAvailable: Boolean = false,
val endCreditsAvailable: Boolean = false,
val nextAiringAvailable: Boolean = false,
val nextAiringLabel: String? = null,
val nextAiringDayLabel: String? = null,
val nextAiringEpisodeCode: String? = null,
)
/**
@@ -173,6 +177,16 @@ data class Playable(
* to lose the credits pane with it.
*/
val endCreditsAvailable: Boolean = false,
/**
* Whether Sonarr can name when this continuing show's next episode airs. Only the
* gateway can answer — the direct path has nobody to ask, so this stays false there —
* and it stays false when the operator has switched the notice off or Sonarr has
* nothing scheduled, so the player never shows a notice with nothing to say.
*/
val nextAiringAvailable: Boolean = false,
val nextAiringLabel: String? = null,
val nextAiringDayLabel: String? = null,
val nextAiringEpisodeCode: String? = null,
/**
* Which streaming service, if any, this title is licensed through, by Emby's own
* Studios field — see [BaseItem.streamingService]. Drives the service mark on the
@@ -2430,6 +2444,10 @@ class EmbyRepository internal constructor(
trickplayAvailable = playback.trickplayAvailable,
skipIntroAvailable = playback.skipIntroAvailable,
endCreditsAvailable = playback.endCreditsAvailable,
nextAiringAvailable = playback.nextAiringAvailable,
nextAiringLabel = playback.nextAiringLabel.ifBlank { null },
nextAiringDayLabel = playback.nextAiringDayLabel.ifBlank { null },
nextAiringEpisodeCode = playback.nextAiringEpisodeCode.ifBlank { null },
)
}
@@ -2466,6 +2484,10 @@ class EmbyRepository internal constructor(
trickplayAvailable = playback.trickplayAvailable,
skipIntroAvailable = playback.skipIntroAvailable,
endCreditsAvailable = playback.endCreditsAvailable,
nextAiringAvailable = playback.nextAiringAvailable,
nextAiringLabel = playback.nextAiringLabel.ifBlank { null },
nextAiringDayLabel = playback.nextAiringDayLabel.ifBlank { null },
nextAiringEpisodeCode = playback.nextAiringEpisodeCode.ifBlank { null },
)
}
val discovery = directPlayback(
@@ -2556,6 +2578,10 @@ class EmbyRepository internal constructor(
trickplayAvailable = playback.trickplayAvailable,
skipIntroAvailable = playback.skipIntroAvailable,
endCreditsAvailable = playback.endCreditsAvailable,
nextAiringAvailable = playback.nextAiringAvailable,
nextAiringLabel = playback.nextAiringLabel.ifBlank { null },
nextAiringDayLabel = playback.nextAiringDayLabel.ifBlank { null },
nextAiringEpisodeCode = playback.nextAiringEpisodeCode.ifBlank { null },
streamingService = item.streamingService,
)
}
@@ -3146,6 +3172,10 @@ class EmbyRepository internal constructor(
response.mediaSourceId, response.playSessionId, response.playMethod,
response.subtitleDownloadAvailable, response.trickplayAvailable,
response.skipIntroAvailable, response.endCreditsAvailable,
response.nextAiringAvailable,
response.nextAiringLabel.ifBlank { null },
response.nextAiringDayLabel.ifBlank { null },
response.nextAiringEpisodeCode.ifBlank { null },
)
}
@@ -3212,6 +3242,10 @@ class EmbyRepository internal constructor(
trickplayAvailable: Boolean,
skipIntroAvailable: Boolean,
endCreditsAvailable: Boolean,
nextAiringAvailable: Boolean = false,
nextAiringLabel: String? = null,
nextAiringDayLabel: String? = null,
nextAiringEpisodeCode: String? = null,
) = NextEpisode(
itemId = item.id,
title = item.name,
@@ -3233,6 +3267,10 @@ class EmbyRepository internal constructor(
trickplayAvailable = trickplayAvailable,
skipIntroAvailable = skipIntroAvailable,
endCreditsAvailable = endCreditsAvailable,
nextAiringAvailable = nextAiringAvailable,
nextAiringLabel = nextAiringLabel,
nextAiringDayLabel = nextAiringDayLabel,
nextAiringEpisodeCode = nextAiringEpisodeCode,
)
/**
@@ -39,6 +39,7 @@ class JourneyAnalytics(
itemType: String,
playSessionId: String,
outcome: String,
positionMs: Long,
) = synchronized(lock) {
if (ended) return@synchronized
buffer += GatewayJourneyEvent(
@@ -59,6 +60,7 @@ class JourneyAnalytics(
// fields it cannot read. A session id is worth less than the step it describes.
playSessionId = clean(playSessionId),
outcome = clean(outcome),
positionMs = positionMs.coerceAtLeast(0),
occurredAt = timestamp(),
)
if (buffer.size > MAX_BUFFERED_EVENTS) buffer.removeAt(0)
@@ -23,5 +23,6 @@ interface JourneySink {
itemType: String = "",
playSessionId: String = "",
outcome: String = "",
positionMs: Long = 0,
)
}
@@ -44,6 +44,7 @@ object JourneyTracker : JourneySink {
itemType: String,
playSessionId: String,
outcome: String,
positionMs: Long,
) {
// Silently nothing when no journey is open — the screensaver can start playback in a
// process where nobody has reached the launcher, and telemetry must never be a reason
@@ -52,6 +53,7 @@ object JourneyTracker : JourneySink {
category = category, action = action, screen = screen, feature = feature,
source = source, target = target, itemId = itemId, itemName = itemName,
itemType = itemType, playSessionId = playSessionId, outcome = outcome,
positionMs = positionMs,
)
}
@@ -86,6 +86,49 @@ object PlaybackJourney {
playSessionId = playSessionId, outcome = "failure",
)
/**
* Playback paused, with where it stopped.
*
* [positionMs] is what lets the console show "Paused at 15m 22s"; a pause's *length* is
* never sent on its own account, because the matching [resumed] step's timestamp and this
* one's already say it — a duration field here would be a second copy of that gap, free to
* disagree with it the moment either clock is a little off.
*/
fun paused(
sink: JourneySink,
entryPoint: PlaybackEntryPoint,
itemId: String,
itemName: String,
itemType: String,
playSessionId: String,
positionMs: Long,
) = sink.track(
category = CATEGORY, action = "pause", screen = PLAYER, feature = FEATURE,
source = entryPoint.id, target = PLAYER,
itemId = itemId, itemName = itemName, itemType = itemType,
playSessionId = playSessionId, positionMs = positionMs,
)
/**
* Playback resumed from a pause, at the position it was left at. Recorded only when a
* matching [paused] step was actually recorded first — a resume with no pause behind it
* would be a buffering recovery mislabelled as somebody pressing a button.
*/
fun resumed(
sink: JourneySink,
entryPoint: PlaybackEntryPoint,
itemId: String,
itemName: String,
itemType: String,
playSessionId: String,
positionMs: Long,
) = sink.track(
category = CATEGORY, action = "resume", screen = PLAYER, feature = FEATURE,
source = entryPoint.id, target = PLAYER,
itemId = itemId, itemName = itemName, itemType = itemType,
playSessionId = playSessionId, positionMs = positionMs,
)
/**
* This title's playback ended because the player moved on to another one.
*
@@ -887,6 +887,13 @@ data class GatewayPlayback(
// [skipIntroAvailable]: they are separate features with separate switches, and a house
// that turned the skip button off has not asked to lose the credits pane with it.
val endCreditsAvailable: Boolean = false,
// Whether Sonarr can name when this continuing show's next episode airs. Absent on an
// older gateway or a show that isn't Sonarr-tracked, still continuing, or has nothing
// scheduled — the default false means the notice simply never appears.
val nextAiringAvailable: Boolean = false,
val nextAiringLabel: String = "",
val nextAiringDayLabel: String = "",
val nextAiringEpisodeCode: String = "",
)
/**
@@ -1014,6 +1021,10 @@ data class GatewayNextEpisode(
val trickplayAvailable: Boolean = false,
val skipIntroAvailable: Boolean = false,
val endCreditsAvailable: Boolean = false,
val nextAiringAvailable: Boolean = false,
val nextAiringLabel: String = "",
val nextAiringDayLabel: String = "",
val nextAiringEpisodeCode: String = "",
)
@Serializable
@@ -1133,6 +1144,12 @@ data class GatewayJourneyEvent(
*/
val playSessionId: String = "",
val outcome: String = "",
/**
* Where playback was, in the title, on a pause or resume step. Zero on every other
* kind of step. The console derives a pause's length from the gap between a pause
* row's [occurredAt] and its matching resume's, so no duration is sent here.
*/
val positionMs: Long = 0,
val occurredAt: String = "",
)
@@ -233,6 +233,17 @@ internal data class DetailHeroAction(
val onClick: () -> Unit,
)
/**
* A contextual deep-link back action, shown immediately before Play rather than among the
* circular secondary actions it names a destination ("Back to Search Results") rather than
* a plain choice, and it is offered only when the page was reached from that destination. Not
* a permanent detail-page control: most pages never construct one.
*/
internal data class DetailBackNavigation(
val label: String,
val onClick: () -> Unit,
)
/**
* Full-bleed artwork with a protected reading area on the left and at the fold.
*
@@ -364,6 +375,11 @@ internal fun DetailPageScaffold(
ratings: List<MediaRating> = emptyList(),
showRatingsStrip: Boolean = true,
heroActions: List<DetailHeroAction> = emptyList(),
/**
* Set only when this page was opened from Search see [DetailBackNavigation]. Rendered
* before Play; every other entry point leaves this null and gets no button.
*/
backNavigation: DetailBackNavigation? = null,
confirmation: String? = null,
onZoneFocused: (DetailZone) -> Unit = {},
footer: (@Composable () -> Unit)? = null,
@@ -375,6 +391,7 @@ internal fun DetailPageScaffold(
List(6) { FocusRequester() }
}
val actionRequesters = allActionRequesters.take(heroActions.size)
val backNavigationFocusRequester = remember(item.id) { FocusRequester() }
var lastHeroIndex by remember(item.id) { mutableIntStateOf(-1) }
var focusedZone by remember(item.id) { mutableStateOf(DetailZone.PLAY) }
// Reported on the way *in* to a band, never on every focus move inside one. The pane's
@@ -456,6 +473,8 @@ internal fun DetailPageScaffold(
playLabel = playLabel,
onPlay = onPlay,
playFocusRequester = playFocusRequester,
backNavigation = backNavigation,
backNavigationFocusRequester = backNavigationFocusRequester,
onNavigateDown = enterStripFromHero,
progress = progress,
progressLabel = progressLabel,
@@ -595,6 +614,8 @@ private fun DetailHero(
playLabel: String,
onPlay: () -> Unit,
playFocusRequester: FocusRequester,
backNavigation: DetailBackNavigation?,
backNavigationFocusRequester: FocusRequester,
onNavigateDown: () -> Boolean,
progress: Float,
progressLabel: String?,
@@ -842,6 +863,8 @@ private fun DetailHero(
playLabel = playLabel,
onPlay = onPlay,
playFocusRequester = playFocusRequester,
backNavigation = backNavigation,
backNavigationFocusRequester = backNavigationFocusRequester,
actions = actions,
actionRequesters = actionRequesters,
caption = actionCaption,
@@ -849,6 +872,10 @@ private fun DetailHero(
actionCaption = null
onPlayFocused()
},
onBackNavigationFocused = {
actionCaption = null
onPlayFocused()
},
onActionFocused = { index ->
actionCaption = actions.getOrNull(index)?.description
onActionFocused(index)
@@ -871,10 +898,13 @@ private fun DetailHeroActions(
playLabel: String,
onPlay: () -> Unit,
playFocusRequester: FocusRequester,
backNavigation: DetailBackNavigation?,
backNavigationFocusRequester: FocusRequester,
actions: List<DetailHeroAction>,
actionRequesters: List<FocusRequester>,
caption: String?,
onPlayFocused: () -> Unit,
onBackNavigationFocused: () -> Unit,
onActionFocused: (Int) -> Unit,
) {
Column {
@@ -883,6 +913,18 @@ private fun DetailHeroActions(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.focusGroup(),
) {
if (backNavigation != null) {
MembySecondaryButton(
label = backNavigation.label,
onClick = backNavigation.onClick,
onFocused = onBackNavigationFocused,
compact = true,
icon = MembyIcon.ArrowBack,
modifier = Modifier
.testTag("detail-back-to-search")
.focusRequester(backNavigationFocusRequester),
)
}
MembyPlayButton(
label = playLabel,
onClick = onPlay,
@@ -346,6 +346,12 @@ internal fun FocusedDetailsOverlay(
*/
onOpenEmbyItem: (BaseItem) -> Unit = {},
airingNotice: AiringNotice? = null,
/**
* Set only when [selected] is the page Search opened directly never carried across a
* "More like this" trail step, which is [onOpenItem]'s own destination. See
* [DetailBackNavigation].
*/
onBackToSearch: (() -> Unit)? = null,
) {
val focusedItem by homeViewModel.focusedItem.collectAsStateWithLifecycle()
// Detail metadata belongs to this overlay and this item id. Launcher/Search focus is
@@ -440,6 +446,7 @@ internal fun FocusedDetailsOverlay(
onOpenItem = onOpenItem,
restorePosition = restorePosition,
airingNotice = airingNotice,
onBackToSearch = onBackToSearch,
)
}
} else if (item.isEpisode) {
@@ -470,6 +477,7 @@ internal fun FocusedDetailsOverlay(
onClose = trackedOnClose,
onOpenItem = onOpenItem,
restorePosition = restorePosition,
onBackToSearch = onBackToSearch,
)
}
}
@@ -323,6 +323,12 @@ internal fun HomeScreen(
// "Shows airing" row, and dropped the moment the viewer moves anywhere else, so the
// same series reached from Favourites or a search never claims a schedule.
var detailsAiringNotice by remember { mutableStateOf<AiringNotice?>(null) }
// Belongs to the *route*, the [detailsAiringNotice] arrangement: true only for the page
// Search opened directly, false the moment the trail moves anywhere else (a "More like
// this" step, a fresh press from Home, Genres, Calendar or a row). It is what gates the
// contextual "Back to Search Results" action — a deep link back to a specific entry
// point, not a permanent detail-page control.
var detailsFromSearch by remember { mutableStateOf(false) }
var quickMenuItem by remember { mutableStateOf<BaseItem?>(null) }
// Whether the long-press menu may offer a trailer for the Radarr card it is open on.
// Asked once, when the menu opens, and false until answered: a row entry that appears
@@ -1122,6 +1128,7 @@ internal fun HomeScreen(
itemName = item.name, itemType = item.type,
)
detailsAiringNotice = null
detailsFromSearch = true
detailsItem = item
}
},
@@ -1156,6 +1163,7 @@ internal fun HomeScreen(
// show, carrying the air time across because that is why it was
// pressed.
val seriesStub = scheduleSeriesStub(item)
detailsFromSearch = false
if (seriesStub != null) {
detailsAiringNotice = airingNoticeFor(item)
homeViewModel.focusItem(seriesStub)
@@ -1205,6 +1213,7 @@ internal fun HomeScreen(
itemName = item.name, itemType = item.type,
)
detailsAiringNotice = null
detailsFromSearch = false
detailsItem = item
},
onContentFocused = { navigationExpanded = false },
@@ -1413,9 +1422,10 @@ internal fun HomeScreen(
homeViewModel.trackJourney(
category = "content", action = "open", screen = selectedDestination.name.lowercase(),
feature = "hero", source = HOME_HERO_ROW_ID, target = "details",
itemName = item.name, itemType = item.type,
itemId = item.id, itemName = item.name, itemType = item.type,
)
detailsAiringNotice = null
detailsFromSearch = false
detailsItem = item
},
modifier = Modifier.height(metadataHeight),
@@ -1706,6 +1716,7 @@ internal fun HomeScreen(
// is still only Radarr's opens its own. Neither is
// inert, which is what the card used to be.
val movieStub = scheduleMovieStub(item)
detailsFromSearch = false
if (seriesStub != null) {
detailsAiringNotice = airingNoticeFor(item)
homeViewModel.focusItem(seriesStub)
@@ -2211,6 +2222,21 @@ internal fun HomeScreen(
// Debug-only: on a release build both calls return before allocating.
remember(selected.id) { StartupTrace.beginSpan(StartupTrace.DETAIL) }
LaunchedEffect(selected.id) { StartupTrace.endSpan(StartupTrace.DETAIL) }
// Closing the page with nothing left in the trail — the hardware Back key and the
// "Back to Search Results" action both mean exactly this, so both call it rather
// than keeping two copies of what "leave the page" does.
val closeDetails: () -> Unit = {
restoreDetailPosition = false
detailsItem = null
detailsAiringNotice = null
detailsFromSearch = false
requestFirstAvailableFocus(
cardReturnFocusRequester,
contentFocusRequester,
navigationFocusRequester,
)
Unit
}
BackHandler {
val previous = detailsTrail.lastOrNull()
if (previous != null) {
@@ -2218,14 +2244,7 @@ internal fun HomeScreen(
restoreDetailPosition = true
detailsItem = previous
} else {
restoreDetailPosition = false
detailsItem = null
detailsAiringNotice = null
requestFirstAvailableFocus(
cardReturnFocusRequester,
contentFocusRequester,
navigationFocusRequester,
)
closeDetails()
}
}
FocusedDetailsOverlay(
@@ -2235,6 +2254,11 @@ internal fun HomeScreen(
detailExperience = detailExperience,
restorePosition = restoreDetailPosition,
airingNotice = detailsAiringNotice,
// Contextual to the exact page Search opened: still true after walking back
// out of a "More like this" trail to it, gone the moment that trail is not
// empty — a deep-link shortcut back to a specific entry point rather than a
// permanent control every detail page carries.
onBackToSearch = closeDetails.takeIf { detailsFromSearch && detailsTrail.isEmpty() },
onOpenItem = { related ->
homeViewModel.trackJourney(
category = "recommendations", action = "open", screen = "details",
@@ -2335,6 +2359,7 @@ internal fun HomeScreen(
detailsTrail = emptyList()
restoreDetailPosition = false
detailsAiringNotice = null
detailsFromSearch = false
requestFirstAvailableFocus(
cardReturnFocusRequester,
contentFocusRequester,
@@ -2497,6 +2522,7 @@ internal fun HomeScreen(
.onSuccess { item ->
closeRequests()
detailsAiringNotice = null
detailsFromSearch = false
detailsTrail = emptyList()
detailsItem = item
}
@@ -2664,6 +2690,7 @@ internal fun HomeScreen(
onOpenDetails = {
quickMenuItem = null
detailsAiringNotice = null
detailsFromSearch = false
detailsItem = it
},
onSetFavorite = homeViewModel::setFavorite,
@@ -53,6 +53,11 @@ fun MediaDetailsOverlay(
onClose: () -> Unit,
onOpenItem: (BaseItem) -> Unit = {},
restorePosition: Boolean = false,
/**
* Set only when this page was opened directly from a Search result see
* [DetailBackNavigation]. Every other entry point leaves this null.
*/
onBackToSearch: (() -> Unit)? = null,
modifier: Modifier = Modifier,
) {
val settings by ServiceLocator.repository.settingsFlow
@@ -91,6 +96,7 @@ fun MediaDetailsOverlay(
showRatingsStrip = settings.showRatingsStrip,
hideWatchedMovies = settings.hideWatchedMovies,
restorePosition = restorePosition,
onBackToSearch = onBackToSearch,
modifier = modifier,
)
}
@@ -115,6 +121,7 @@ internal fun MediaDetailContent(
hideWatchedMovies: Boolean = false,
onOpenItem: (BaseItem) -> Unit = {},
restorePosition: Boolean = false,
onBackToSearch: (() -> Unit)? = null,
) {
val specs = remember(item.id, item.mediaStreams) { technicalSpecs(item) }
val credits = remember(item.id, item.people, item.genres) { creditRows(item) }
@@ -243,6 +250,9 @@ internal fun MediaDetailContent(
?: listOfNotNull(item.membyRecommendationReason?.takeIf(String::isNotBlank)),
ratings = ratings,
showRatingsStrip = showRatingsStrip,
backNavigation = onBackToSearch?.let {
DetailBackNavigation(label = "Back to Search Results", onClick = it)
},
confirmation = confirmation,
onZoneFocused = { zone ->
focusedZone = zone
@@ -177,24 +177,40 @@ internal fun MembySecondaryButton(
onClick: () -> Unit,
modifier: Modifier = Modifier,
compact: Boolean = false,
/**
* A leading mark, for a secondary action that names a destination rather than a plain
* choice "Back to Search Results" reads as a place to go, and the arrow is what says
* so at a glance rather than making the word "Back" carry it alone.
*/
icon: MembyIcon? = null,
onFocused: () -> Unit = {},
) {
var focused by remember { mutableStateOf(false) }
val scale by animateFloatAsState(if (focused) 1.055f else 1f, tween(100), label = "secondary-focus")
val shape = RoundedCornerShape(MembyCardCorner)
Box(
Row(
modifier = modifier
.graphicsLayer { scaleX = scale; scaleY = scale; translationY = if (focused) -3f else 0f }
.clip(shape)
.background(if (focused) MembyOutline else Color.Transparent)
.border(if (focused) 2.dp else 1.dp, if (focused) Color.White else MembyOutline, shape)
.onFocusChanged { focused = it.isFocused }
.onFocusChanged { focused = it.isFocused; if (it.isFocused) onFocused() }
.clickable(onClick = onClick)
.padding(
horizontal = if (compact) 14.dp else 23.dp,
vertical = if (compact) 8.dp else 13.dp,
),
contentAlignment = Alignment.Center,
verticalAlignment = Alignment.CenterVertically,
) {
if (icon != null) {
Icon(
icon.mark,
contentDescription = null,
tint = if (focused) Color.White else MembyMutedText,
modifier = Modifier.size(if (compact) 16.dp else 20.dp),
)
Spacer(Modifier.width(if (compact) 5.dp else 7.dp))
}
Text(
label,
color = if (focused) Color.White else MembyMutedText,
@@ -119,6 +119,11 @@ fun SeriesDetailsOverlay(
onOpenItem: (BaseItem) -> Unit = {},
restorePosition: Boolean = false,
airingNotice: AiringNotice? = null,
/**
* Set only when this page was opened directly from a Search result see
* [DetailBackNavigation]. Every other entry point leaves this null.
*/
onBackToSearch: (() -> Unit)? = null,
modifier: Modifier = Modifier,
) {
val repository = ServiceLocator.repository
@@ -214,6 +219,7 @@ fun SeriesDetailsOverlay(
onOpenItem = onOpenItem,
restorePosition = restorePosition,
airingNotice = airingNotice,
onBackToSearch = onBackToSearch,
modifier = modifier,
)
}
@@ -241,6 +247,7 @@ internal fun SeriesDetailContent(
onOpenItem: (BaseItem) -> Unit = {},
restorePosition: Boolean = false,
airingNotice: AiringNotice? = null,
onBackToSearch: (() -> Unit)? = null,
) {
val remembered = remember(item.id) { detailPositions.get(item.id) }
val seasons = remember(episodes) { availableSeasons(episodes.orEmpty()) }
@@ -420,6 +427,9 @@ internal fun SeriesDetailContent(
confirmation = confirmation,
ratings = ratings,
showRatingsStrip = showRatingsStrip,
backNavigation = onBackToSearch?.let {
DetailBackNavigation(label = "Back to Search Results", onClick = it)
},
onZoneFocused = { zone ->
focusedZone = zone
detailPositions.update(item.id) { it.copy(zone = zone) }
@@ -815,8 +815,19 @@ internal fun ServicesRail(
.fillMaxWidth()
.focusGroup()
.onKeyEvent { event ->
if (event.type != KeyEventType.KeyDown) return@onKeyEvent false
if (event.key == Key.DirectionRight) onEnterContent() else false
if (event.type != KeyEventType.KeyDown || event.key != Key.DirectionRight) {
return@onKeyEvent false
}
// Unlike the genre rail's single column, Right means two different
// things here: walk to the next icon, or — only once there is no
// next icon — enter the grid. Consuming it unconditionally is what
// made Right on Netflix or Apple TV jump straight into the grid
// instead of reaching the icon beside it, so every other icon has
// to let the press fall through to the item's own focusProperties
// and the built-in directional search that reads it. Only the last
// icon, whose own `right` is Cancel, hands the press to the grid.
if (activeServiceId != services.lastOrNull()?.id) return@onKeyEvent false
onEnterContent()
},
contentPadding = PaddingValues(start = 16.dp, end = 14.dp),
horizontalArrangement = Arrangement.spacedBy(14.dp),
@@ -184,6 +184,25 @@ internal fun shouldAutoAdvance(
dismissed: Boolean,
): Boolean = autoPlayEnabled && hasNextEpisode && !dismissed
/**
* Whether the next-airing notice belongs on screen right now.
*
* Independent of whether there is a next episode to hand off to a continuing show can
* have new material coming without Emby having imported the next one yet, which is exactly
* the case this notice exists for. Its own window is strictly ahead of [leadMs] (the
* next-up banner's own), and it retires the instant remaining time reaches that mark
* whether or not a next episode exists, which is what keeps the two from ever being visible
* together without either needing to know the other exists.
*/
internal fun nextAiringNoticeVisible(
available: Boolean,
remainingMs: Long,
windowMs: Long,
leadMs: Long,
dismissed: Boolean,
canShow: Boolean,
): Boolean = available && remainingMs in (leadMs + 1)..windowMs && !dismissed && canShow
/**
* Whether the manual Next Episode control belongs on the transport row.
*
@@ -183,6 +183,15 @@ class PlayerActivity : ComponentActivity() {
* one. Cleared wherever [playbackStarted] is, because that is where the title changes.
*/
private var journeyFailureRecorded = false
/**
* Whether a pause step is currently open, waiting on its resume. Set only when
* [recordPlaybackPausedJourney] actually wrote one, so a resume is only ever recorded
* against a pause the journey genuinely has see that function for why a plain
* `isPlaying` flip is not enough on its own. Cleared wherever [journeyFailureRecorded] is,
* because that is where the title changes and an old pause could never be this one's.
*/
private var journeyPauseRecorded = false
private var availableSubtitles: List<PlayableSubtitle> = emptyList()
private var encodedSubtitleId: String? = null
private var remainingView: TextView? = null
@@ -411,6 +420,7 @@ class PlayerActivity : ComponentActivity() {
private var nextUpRing: CountdownRingView? = null
private var nextUpLogo: ImageView? = null
private var nextUpSeries: TextView? = null
private var nextUpActionButton: View? = null
/**
* The logo the bar is currently wearing, so the identity is bound once per episode
* rather than on every 250ms tick of the countdown. Coil would answer the repeats from
@@ -493,6 +503,20 @@ class PlayerActivity : ComponentActivity() {
*/
private var creditsDismissed = false
private var creditsSpeedJob: Job? = null
// The next-airing notice: a continuing show's own schedule, independent of whether Emby
// has a next episode to hand off to. Whichever episode is actually playing carries its
// own answer — set from the initial resolution in [adoptPlayable] and refreshed from
// [nextEpisode]'s own fields on advance, since an advance does not re-resolve playback.
private var nextAiringAvailable = false
private var nextAiringLabel: String? = null
private var nextAiringDayLabel: String? = null
private var nextAiringEpisodeCode: String? = null
private var nextAiringView: View? = null
private var nextAiringEyebrow: TextView? = null
private var nextAiringLabelView: TextView? = null
/** This episode's notice has been dealt with — dismissed by Back — and does not return. */
private var nextAiringDismissed = false
/**
* The fastest this stream has been allowed to run. It only ever falls see
* [creditsCeilingAfterStall]. Reset per episode, because the next file may be a
@@ -562,6 +586,13 @@ class PlayerActivity : ComponentActivity() {
private var seekBuffering = false
private var seekLoadingFallbackJob: Job? = null
private var playbackStartCueShown = false
/**
* Ticking down to the Time Left card while the screen is clear of every other Memby
* overlay. See [scheduleTimeRemainingCue] it is not a plain [delay], because losing
* eligibility partway through (the transport opening, a pause) must abandon the attempt
* rather than show the card the instant the delay elapses regardless.
*/
private var timeRemainingSettleJob: Job? = null
private var seasonFinaleCue: View? = null
private var seasonFinaleValue: TextView? = null
private var seasonFinaleInfo: GatewaySeasonFinale? = null
@@ -663,6 +694,14 @@ class PlayerActivity : ComponentActivity() {
?: intent.getBooleanExtra(EXTRA_SKIP_INTRO, false)
endCreditsAvailable = savedInstanceState?.getBoolean(STATE_END_CREDITS)
?: intent.getBooleanExtra(EXTRA_END_CREDITS, false)
nextAiringAvailable = savedInstanceState?.getBoolean(STATE_NEXT_AIRING_AVAILABLE)
?: intent.getBooleanExtra(EXTRA_NEXT_AIRING_AVAILABLE, false)
nextAiringLabel = savedInstanceState?.getString(STATE_NEXT_AIRING_LABEL)
?: intent.getStringExtra(EXTRA_NEXT_AIRING_LABEL)
nextAiringDayLabel = savedInstanceState?.getString(STATE_NEXT_AIRING_DAY_LABEL)
?: intent.getStringExtra(EXTRA_NEXT_AIRING_DAY_LABEL)
nextAiringEpisodeCode = savedInstanceState?.getString(STATE_NEXT_AIRING_EPISODE_CODE)
?: intent.getStringExtra(EXTRA_NEXT_AIRING_EPISODE_CODE)
// Same rule and default: a missing extra must never draw a badge for a title Emby
// never told this build was licensed through a service. On the request form this
// is corrected by adoptPlayable once the server settles.
@@ -868,10 +907,24 @@ class PlayerActivity : ComponentActivity() {
eventName = if (isPlaying) "Unpause" else "Pause",
)
}
// A resume is only ever the *far* end of a pause the journey actually
// recorded — see recordPlaybackPausedJourney for why `isPlaying` alone,
// which also flips false during an ordinary rebuffer, is not that signal.
if (isPlaying) recordPlaybackResumedJourney(playback.currentPosition)
if (isPlaying) scheduleRetryBudgetReset() else stablePlaybackJob?.cancel()
updatePauseOverlay(playback)
}
// The journey's pause/resume timeline reads from playWhenReady, not
// isPlaying: playWhenReady only moves when somebody actually asked for
// playback to stop or continue, where isPlaying also flips false for an
// ordinary rebuffer with playWhenReady never touched. Recording pauses off
// isPlaying would fill the timeline with a "Paused"/"Resumed" pair for every
// network stall.
override fun onPlayWhenReadyChanged(playWhenReady: Boolean, reason: Int) {
if (!playWhenReady) recordPlaybackPausedJourney(playback.currentPosition)
}
override fun onEvents(player: Player, events: Player.Events) {
updatePlaybackTiming(player)
// A skip that landed inside what was already buffered never
@@ -1007,6 +1060,7 @@ class PlayerActivity : ComponentActivity() {
)
setUpSubtitleOverlay()
setUpNextUpBanner()
setUpNextAiringNotice()
setUpSkipIntro()
setUpEndCredits()
setUpTimeRemainingCue()
@@ -1347,6 +1401,10 @@ class PlayerActivity : ComponentActivity() {
trickplayAvailable = playable.trickplayAvailable
skipIntroAvailable = playable.skipIntroAvailable
endCreditsAvailable = playable.endCreditsAvailable
nextAiringAvailable = playable.nextAiringAvailable
nextAiringLabel = playable.nextAiringLabel
nextAiringDayLabel = playable.nextAiringDayLabel
nextAiringEpisodeCode = playable.nextAiringEpisodeCode
streamingService = playable.streamingService
subtitleAutoSelectionAttempted = false
initialResumePositionMs = playable.resumePositionMs.coerceAtLeast(0L)
@@ -2648,30 +2706,56 @@ class PlayerActivity : ComponentActivity() {
}
private fun updatePlaybackStartCue(playback: Player) {
if (playbackStartCueShown || !timingCueCanShow()) return
if (playbackStartCueShown || timeRemainingSettleJob?.isActive == true) return
if (!timingCueCanShow()) return
val duration = playback.duration
if (duration == C.TIME_UNSET || duration <= 0L || playback.isCurrentMediaItemLive) return
val remainingMs = (duration - playback.currentPosition).coerceAtLeast(0L)
if (remainingMs == 0L) return
val speed = playback.playbackParameters.speed.coerceAtLeast(0.1f)
val wallClockRemainingMs = (remainingMs / speed).toLong()
playbackStartCueShown = true
seasonFinaleInfo?.let(::showSeasonFinaleCue)
if (initialResumePositionMs > 0L) {
showTimingCue(
label = getString(R.string.player_resume_time_left_label),
value = formatCueDuration(wallClockRemainingMs),
)
} else {
val finishAt = System.currentTimeMillis() + wallClockRemainingMs
showTimingCue(
label = getString(R.string.player_finishes_in_label),
value = getString(
R.string.player_finishes_in_value,
formatCueDuration(wallClockRemainingMs),
DateFormat.getTimeFormat(this).format(Date(finishAt)),
),
)
scheduleTimeRemainingCue(wallClockRemainingMs)
}
/**
* Waits for the screen to stay clear of every other Memby overlay for
* [TIME_REMAINING_SETTLE_MS] before showing the Time Left card, so it never appears
* underneath the transport, the pause hero or the station ident, or in the instant they
* clear. Polled at the same cadence as [startPlaybackStartCueWatch] rather than driven by
* each overlay's own callback, which is what lets one function answer for all of them.
*
* Losing eligibility mid-wait abandons this attempt outright rather than pausing a clock:
* [startPlaybackStartCueWatch] is still ticking and calls [updatePlaybackStartCue] again on
* its next tick, which starts a fresh wait once the screen is clear again.
*/
private fun scheduleTimeRemainingCue(wallClockRemainingMs: Long) {
timeRemainingSettleJob?.cancel()
timeRemainingSettleJob = lifecycleScope.launch {
var settledMs = 0L
while (isActive && settledMs < TIME_REMAINING_SETTLE_MS) {
delay(PLAYBACK_START_CUE_TICK_MS)
if (!timingCueCanShow()) return@launch
settledMs += PLAYBACK_START_CUE_TICK_MS
}
if (playbackStartCueShown || !timingCueCanShow()) return@launch
playbackStartCueShown = true
seasonFinaleInfo?.let(::showSeasonFinaleCue)
if (initialResumePositionMs > 0L) {
showTimingCue(
label = getString(R.string.player_resume_time_left_label),
value = formatCueDuration(wallClockRemainingMs),
)
} else {
val finishAt = System.currentTimeMillis() + wallClockRemainingMs
showTimingCue(
label = getString(R.string.player_finishes_in_label),
value = getString(
R.string.player_finishes_in_value,
formatCueDuration(wallClockRemainingMs),
DateFormat.getTimeFormat(this@PlayerActivity).format(Date(finishAt)),
),
)
}
}
}
@@ -2700,7 +2784,10 @@ class PlayerActivity : ComponentActivity() {
!prerollActive &&
prerollView?.isVisible != true &&
loadingView?.isVisible != true &&
errorView?.isVisible != true
errorView?.isVisible != true &&
!transportVisible &&
!pauseHeroVisible.value &&
playbackIdentityPhase != PlaybackIdentityPhase.SHOWING
private fun showTimingCue(label: String, value: String) {
val cue = timeRemainingCue ?: return
@@ -2737,6 +2824,8 @@ class PlayerActivity : ComponentActivity() {
private fun resetTimeRemainingCue() {
playbackStartCueJob?.cancel()
playbackStartCueJob = null
timeRemainingSettleJob?.cancel()
timeRemainingSettleJob = null
timeRemainingHideJob?.cancel()
timeRemainingHideJob = null
playbackStartCueShown = false
@@ -3270,6 +3359,66 @@ class PlayerActivity : ComponentActivity() {
}
}
// --- Next episode airing --------------------------------------------------------
private fun setUpNextAiringNotice() {
val view = findViewById<View>(R.id.player_next_airing)
nextAiringView = view
nextAiringEyebrow = view.findViewById(R.id.player_next_airing_eyebrow)
nextAiringLabelView = view.findViewById(R.id.player_next_airing_label)
}
/**
* Driven off the playhead exactly like the next-up countdown: pausing holds it where it
* is, seeking re-arms or retires it, nothing here runs off a timer of its own.
*
* The notice's own window is strictly ahead of [NEXT_UP_LEAD_MS] it retires the moment
* remaining time reaches that mark whether or not [nextEpisode] is null, which is what
* keeps it from ever coinciding with the next-up banner or the credits pane without
* either overlay needing to know the other exists.
*/
private fun updateNextAiringNotice(remainingMs: Long) {
// Seeking back out past the notice's own window re-arms it, the skip-intro
// countdown's rule: only a viewer's own Back press should keep it away for the
// rest of the episode, not merely the playhead having moved past it once.
if (remainingMs > NEXT_EPISODE_AIRING_LEAD_MS) nextAiringDismissed = false
val visible = nextAiringNoticeVisible(
available = nextAiringAvailable,
remainingMs = remainingMs,
windowMs = NEXT_EPISODE_AIRING_LEAD_MS,
leadMs = NEXT_UP_LEAD_MS,
dismissed = nextAiringDismissed,
canShow = nextUpCanShow(),
)
if (visible) showNextAiringNotice() else hideNextAiringNotice()
}
private fun showNextAiringNotice() {
val view = nextAiringView ?: return
val eyebrow = nextAiringEpisodeCode?.takeIf(String::isNotBlank)?.let {
"$it · ${getString(R.string.next_airing_next_episode)}"
} ?: getString(R.string.next_airing_next_episode)
nextAiringEyebrow?.text = eyebrow
nextAiringLabelView?.text = nextAiringLabel ?: nextAiringDayLabel.orEmpty()
if (view.visibility != View.VISIBLE) view.visibility = View.VISIBLE
}
private fun hideNextAiringNotice() {
nextAiringView?.takeIf { it.visibility != View.GONE }?.visibility = View.GONE
}
/** Back dismisses it for the rest of this episode, the same one-press-per-level contract
* every overlay here has. */
private fun dismissNextAiringNotice() {
nextAiringDismissed = true
hideNextAiringNotice()
}
private fun resetNextAiringNotice() {
nextAiringDismissed = false
hideNextAiringNotice()
}
// --- Next up ------------------------------------------------------------------
private fun setUpNextUpBanner() {
@@ -3278,6 +3427,9 @@ class PlayerActivity : ComponentActivity() {
nextUpCountdown = banner.findViewById(R.id.player_next_up_countdown)
nextUpLogo = banner.findViewById(R.id.player_next_up_logo)
nextUpSeries = banner.findViewById(R.id.player_next_up_series)
nextUpActionButton = banner.findViewById<View>(R.id.player_next_up_action)?.also { button ->
button.setOnClickListener { startNextEpisode() }
}
nextUpRing = banner.findViewById<CountdownRingView>(R.id.player_next_up_ring)?.apply {
// Fixed rather than taken from a drawable state, because nothing here is
// focusable: the ring sits on somebody's programme and has to read against
@@ -3399,6 +3551,7 @@ class PlayerActivity : ComponentActivity() {
private fun recordPlaybackStartedJourney() {
if (!journeyRecordsPlayback) return
journeyFailureRecorded = false
journeyPauseRecorded = false
PlaybackJourney.started(
sink = JourneyTracker,
entryPoint = journeyEntryPoint,
@@ -3423,6 +3576,42 @@ class PlayerActivity : ComponentActivity() {
)
}
/**
* Playback stopped advancing with [Player.playWhenReady] false the viewer (or the app,
* pausing behind an overlay) actually asked for this, as opposed to a stall the decoder is
* recovering from on its own, where `playWhenReady` never moves. Guarded on
* [journeyPauseRecorded] so a held pause key or a second overlay opening while paused
* cannot write the step twice.
*/
private fun recordPlaybackPausedJourney(positionMs: Long) {
if (!journeyRecordsPlayback || !playbackStarted || journeyPauseRecorded) return
journeyPauseRecorded = true
PlaybackJourney.paused(
sink = JourneyTracker,
entryPoint = journeyEntryPoint,
itemId = itemId.orEmpty(),
itemName = playbackTitle,
itemType = journeyItemType,
playSessionId = playSessionId,
positionMs = positionMs,
)
}
/** The other half of [recordPlaybackPausedJourney]; see it for why this is guarded. */
private fun recordPlaybackResumedJourney(positionMs: Long) {
if (!journeyPauseRecorded) return
journeyPauseRecorded = false
PlaybackJourney.resumed(
sink = JourneyTracker,
entryPoint = journeyEntryPoint,
itemId = itemId.orEmpty(),
itemName = playbackTitle,
itemType = journeyItemType,
playSessionId = playSessionId,
positionMs = positionMs,
)
}
/**
* The title on screen is being replaced by another one inside this same player a Magic
* pick or an episode advance. Without it the films before the last one in a chain would
@@ -3592,11 +3781,16 @@ class PlayerActivity : ComponentActivity() {
private fun updateNextUpFromPlayhead() {
if (advancing || playingNextEpisodePreview) return
val playback = player ?: return
val next = nextEpisode ?: return
val duration = playback.duration
if (duration == C.TIME_UNSET || duration <= 0L) return
val remainingMs = (duration - playback.currentPosition).coerceAtLeast(0L)
// Independent of whether there is a next episode to hand off to — a show can be
// airing new episodes without Emby having imported the next one yet, which is
// exactly the case this notice exists for. Evaluated before the early return below.
updateNextAiringNotice(remainingMs)
val next = nextEpisode ?: return
if (remainingMs > NEXT_EPISODE_PREVIEW_LEAD_MS) previewWindowArmed = true
if (shouldStartNextEpisodePreview(
autoPlayEnabled = autoPlayNextEpisodeEnabled,
@@ -3696,6 +3890,7 @@ class PlayerActivity : ComponentActivity() {
stopProgressUploading()
hideNextUp()
hideNextAiringNotice()
if (creditsActive) leaveEndCredits(restoreSpeed = true)
playbackTitle = "Next: ${nextTitle(next)}"
playbackSeriesName = next.seriesName
@@ -3813,9 +4008,10 @@ class PlayerActivity : ComponentActivity() {
*
* Every other overlay in this player *owns* the screen while it is up the drop-up,
* the cast panel, the credits pane, the error and loading surfaces and this one owns
* nothing at all, so wherever one of those is up this simply stands down rather than
* being drawn underneath it. The transport is in the list for the plainest reason:
* it is a full-width strip along the same bottom edge, and the two would overlap.
* only its own action pill, so wherever one of those is up this simply stands down
* rather than being drawn underneath it. The transport is in the list for the plainest
* reason: it is a full-width strip along the same bottom edge, and the two would
* overlap.
*/
private fun nextUpCanShow(): Boolean =
!prerollActive &&
@@ -3878,11 +4074,16 @@ class PlayerActivity : ComponentActivity() {
.setDuration(NEXT_UP_ANIMATION_MS)
.setInterpolator(DecelerateInterpolator())
.start()
// Focus is the only way a remote can say "press this". It is taken as the pill
// appears and handed back to the video the moment the bar goes, the
// `player_skip_intro` pattern.
nextUpActionButton?.requestFocus()
}
private fun hideNextUp() {
val banner = nextUpBanner ?: return
if (!banner.isVisible) return
val hadFocus = nextUpActionButton?.isFocused == true
banner.animate()
.alpha(0f)
.setDuration(NEXT_UP_ANIMATION_MS)
@@ -3891,6 +4092,9 @@ class PlayerActivity : ComponentActivity() {
banner.alpha = 1f
}
.start()
// Only if this was holding it. Taking focus back off whatever the viewer has since
// opened would be worse than leaving it where they put it.
if (hadFocus) playerView?.requestFocus()
}
/**
@@ -3978,6 +4182,10 @@ class PlayerActivity : ComponentActivity() {
if (creditsActive) return
creditsActive = true
creditsEnteredAtMs = SystemClock.elapsedRealtime()
// The pane replaces the bar, not joins it — a short credit roll can start inside the
// bar's own last-minute window, and the bar's action pill would otherwise be left
// holding focus underneath a pane that has just taken it for its own Play button.
hideNextUp()
view.findViewById<TextView>(R.id.player_end_credits_title).text =
next.title.ifBlank { next.seriesName }
@@ -4397,6 +4605,10 @@ class PlayerActivity : ComponentActivity() {
trickplayAvailable = next.trickplayAvailable
skipIntroAvailable = next.skipIntroAvailable
endCreditsAvailable = next.endCreditsAvailable
nextAiringAvailable = next.nextAiringAvailable
nextAiringLabel = next.nextAiringLabel
nextAiringDayLabel = next.nextAiringDayLabel
nextAiringEpisodeCode = next.nextAiringEpisodeCode
encodedSubtitleId = null
stopReported = false
playbackStarted = false
@@ -4405,6 +4617,7 @@ class PlayerActivity : ComponentActivity() {
// to the launcher, which cannot see an advance at all.
journeyEntryPoint = PlaybackEntryPoint.NEXT_EPISODE
journeyFailureRecorded = false
journeyPauseRecorded = false
PlaybackJourney.requested(
sink = JourneyTracker,
entryPoint = PlaybackEntryPoint.NEXT_EPISODE,
@@ -4432,6 +4645,7 @@ class PlayerActivity : ComponentActivity() {
// a different point, and a speed left behind would run the next episode's opening
// scene at double speed.
resetEndCredits()
resetNextAiringNotice()
nextUpDismissed = false
requestStartedAtMs = SystemClock.elapsedRealtime()
trace = PlaybackTrace(requestStartedAtMs, SystemClock::elapsedRealtime)
@@ -4586,6 +4800,9 @@ class PlayerActivity : ComponentActivity() {
collapseSubtitleDownloads()
subtitleOverlay?.isVisible == true -> hideSubtitleOverlay()
nextUpBanner?.isVisible == true -> dismissNextUp()
// Same contract, one level earlier: Back dismisses the next-airing
// notice for the rest of this episode rather than leaving the film.
nextAiringView?.isVisible == true -> dismissNextAiringNotice()
// Back asks for the credits back rather than leaving the film: one press
// per level, the same contract every other overlay here has.
creditsView?.isVisible == true -> dismissEndCredits()
@@ -4645,10 +4862,10 @@ class PlayerActivity : ComponentActivity() {
playerView?.isControllerFullyVisible != true &&
!castPanelVisible.value &&
subtitleOverlay?.isVisible != true &&
// The next-up bar is deliberately absent from this list. It takes no focus and
// holds no button, so the centre key still means pause while it is up — the
// whole point of the compact bar is that the remote goes on meaning what it
// meant a moment before it appeared.
// The bar's action pill holds focus while it is up, the same reason the credits
// pane's Play button and the skip-intro button are excluded here: the centre key
// is how a remote presses whatever it is focused on.
nextUpBanner?.isVisible != true &&
// The pane's Play button holds focus while it is up, and the centre key is how a
// remote presses what it is focused on.
creditsView?.isVisible != true &&
@@ -5614,6 +5831,10 @@ class PlayerActivity : ComponentActivity() {
outState.putBoolean(STATE_TRICKPLAY, trickplayAvailable)
outState.putBoolean(STATE_SKIP_INTRO, skipIntroAvailable)
outState.putBoolean(STATE_END_CREDITS, endCreditsAvailable)
outState.putBoolean(STATE_NEXT_AIRING_AVAILABLE, nextAiringAvailable)
nextAiringLabel?.let { outState.putString(STATE_NEXT_AIRING_LABEL, it) }
nextAiringDayLabel?.let { outState.putString(STATE_NEXT_AIRING_DAY_LABEL, it) }
nextAiringEpisodeCode?.let { outState.putString(STATE_NEXT_AIRING_EPISODE_CODE, it) }
streamingService?.let { outState.putString(STATE_STREAMING_SERVICE, it.name) }
outState.putString(STATE_TITLE, if (savingPreview) previewResumeTitle else playbackTitle)
outState.putString(STATE_LOGO_URL, if (savingPreview) previewResumeLogoUrl else logoUrl)
@@ -5730,6 +5951,7 @@ class PlayerActivity : ComponentActivity() {
prerollScheduleJob?.cancel()
disposeLocalPreroll(reuse = true)
playbackStartCueJob?.cancel()
timeRemainingSettleJob?.cancel()
timeRemainingHideJob?.cancel()
seekCommitJob?.cancel()
seekHideJob?.cancel()
@@ -5965,6 +6187,10 @@ class PlayerActivity : ComponentActivity() {
private const val EXTRA_TRICKPLAY = "extra_trickplay_available"
private const val EXTRA_SKIP_INTRO = "extra_skip_intro_available"
private const val EXTRA_END_CREDITS = "extra_end_credits_available"
private const val EXTRA_NEXT_AIRING_AVAILABLE = "extra_next_airing_available"
private const val EXTRA_NEXT_AIRING_LABEL = "extra_next_airing_label"
private const val EXTRA_NEXT_AIRING_DAY_LABEL = "extra_next_airing_day_label"
private const val EXTRA_NEXT_AIRING_EPISODE_CODE = "extra_next_airing_episode_code"
private const val EXTRA_STREAMING_SERVICE = "extra_streaming_service"
private const val EXTRA_MEDIA_SOURCE_ID = "extra_media_source_id"
private const val EXTRA_PLAY_SESSION_ID = "extra_play_session_id"
@@ -5993,6 +6219,10 @@ class PlayerActivity : ComponentActivity() {
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_NEXT_AIRING_AVAILABLE = "state_next_airing_available"
private const val STATE_NEXT_AIRING_LABEL = "state_next_airing_label"
private const val STATE_NEXT_AIRING_DAY_LABEL = "state_next_airing_day_label"
private const val STATE_NEXT_AIRING_EPISODE_CODE = "state_next_airing_episode_code"
private const val STATE_STREAMING_SERVICE = "state_streaming_service"
private const val STATE_TITLE = "state_title"
private const val STATE_LOGO_URL = "state_logo_url"
@@ -6104,6 +6334,10 @@ class PlayerActivity : ComponentActivity() {
trickplayAvailable = playable.trickplayAvailable,
skipIntroAvailable = playable.skipIntroAvailable,
endCreditsAvailable = playable.endCreditsAvailable,
nextAiringAvailable = playable.nextAiringAvailable,
nextAiringLabel = playable.nextAiringLabel,
nextAiringDayLabel = playable.nextAiringDayLabel,
nextAiringEpisodeCode = playable.nextAiringEpisodeCode,
streamingService = playable.streamingService,
mediaSourceId = playable.mediaSourceId,
playSessionId = playable.playSessionId,
@@ -6134,6 +6368,10 @@ class PlayerActivity : ComponentActivity() {
trickplayAvailable: Boolean = false,
skipIntroAvailable: Boolean = false,
endCreditsAvailable: Boolean = false,
nextAiringAvailable: Boolean = false,
nextAiringLabel: String? = null,
nextAiringDayLabel: String? = null,
nextAiringEpisodeCode: String? = null,
streamingService: StreamingService? = null,
mediaSourceId: String = "",
playSessionId: String = "",
@@ -6163,6 +6401,12 @@ class PlayerActivity : ComponentActivity() {
putExtra(EXTRA_TRICKPLAY, trickplayAvailable)
putExtra(EXTRA_SKIP_INTRO, skipIntroAvailable)
putExtra(EXTRA_END_CREDITS, endCreditsAvailable)
putExtra(EXTRA_NEXT_AIRING_AVAILABLE, nextAiringAvailable)
nextAiringLabel?.takeIf { it.isNotBlank() }?.let { putExtra(EXTRA_NEXT_AIRING_LABEL, it) }
nextAiringDayLabel?.takeIf { it.isNotBlank() }
?.let { putExtra(EXTRA_NEXT_AIRING_DAY_LABEL, it) }
nextAiringEpisodeCode?.takeIf { it.isNotBlank() }
?.let { putExtra(EXTRA_NEXT_AIRING_EPISODE_CODE, it) }
streamingService?.let { putExtra(EXTRA_STREAMING_SERVICE, it.name) }
putExtra(EXTRA_MEDIA_SOURCE_ID, mediaSourceId)
putExtra(EXTRA_PLAY_SESSION_ID, playSessionId)
@@ -6214,6 +6458,12 @@ class PlayerActivity : ComponentActivity() {
private const val RESUME_TIME_LEFT_CUE_DELAY_MS = 2_000L
private const val PLAYBACK_START_CUE_TICK_MS = 250L
private const val TIME_REMAINING_THRESHOLD_MS = 10L * 60_000L
/**
* How long the screen must stay clear of the transport, the pause hero and the
* station ident before the Time Left card appears long enough that it reads as its
* own moment rather than the tail end of whichever of those just closed.
*/
private const val TIME_REMAINING_SETTLE_MS = 3_000L
private const val TIME_REMAINING_VISIBLE_MS = 6_000L
private const val TIME_REMAINING_ANIMATION_MS = 240L
private const val TIME_REMAINING_TRAVEL_DP = 12
@@ -6286,6 +6536,14 @@ class PlayerActivity : ComponentActivity() {
private const val MAGIC_MEMORY = 8
private const val NEXT_UP_TICK_MS = 250L
private const val NEXT_EPISODE_PREVIEW_LEAD_MS = 120_000L
/**
* Where the next-airing notice's own window begins, strictly ahead of
* [NEXT_UP_LEAD_MS] the notice retires the moment remaining time reaches that
* mark, whether or not there is a next episode to hand off to, so the two can never
* be visible together.
*/
private const val NEXT_EPISODE_AIRING_LEAD_MS = 5 * 60_000L
private const val NEXT_EPISODE_PREVIEW_STARTUP_TIMEOUT_MS = 8_000L
private const val NEXT_UP_ANIMATION_MS = 260L
@@ -44,6 +44,11 @@
above it. Both remain non-focusable lower thirds. -->
<include layout="@layout/player_season_finale" />
<!-- A quieter, earlier notice than the one below: when a continuing show's next episode
airs, independent of whether Emby already has a next episode to advance to. Gated so
it is never visible at the same time as the next-up banner or the credits pane. -->
<include layout="@layout/player_next_airing_notice" />
<!-- Above the video, below the loading overlay: a slide that is still starting has
nothing to say about what comes next. -->
<include layout="@layout/player_next_up_banner" />
@@ -0,0 +1,55 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- A quiet notice for a continuing show: when its next episode airs, said ahead of the Next
Up banner rather than instead of it — this answers "when does the show itself have more",
Next Up answers "what plays next in this app", and the two questions are independent. It
never repeats the show's logo: that is already on screen via the playback identity ident
in the opposite corner for this same episode, so a second one here would be the exact
duplication that ident's "one owner" rule already forbids.
Bottom-end, the mirror of the next-up banner's bottom-start, so the two are visually
distinct even though the playhead-driven gating in PlayerActivity never lets both be
visible together. Non-focusable throughout, and takes no part in the remote's world. -->
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/player_next_airing"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:focusable="false"
android:visibility="gone">
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="bottom|end"
android:layout_marginEnd="48dp"
android:layout_marginBottom="48dp"
android:background="@drawable/next_up_banner_background"
android:focusable="false"
android:orientation="vertical"
android:paddingStart="22dp"
android:paddingTop="13dp"
android:paddingEnd="22dp"
android:paddingBottom="13dp">
<TextView
android:id="@+id/player_next_airing_eyebrow"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:letterSpacing="0.14"
android:text="@string/next_airing_next_episode"
android:textColor="#FF69CD61"
android:textSize="11sp"
android:textStyle="bold"
tools:text="S03E06 · NEXT EPISODE" />
<TextView
android:id="@+id/player_next_airing_label"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="3dp"
android:maxLines="1"
android:textColor="#D9FFFFFF"
android:textSize="13sp"
tools:text="Tomorrow · 9:00 PM" />
</LinearLayout>
</FrameLayout>
@@ -4,10 +4,13 @@
It replaced a 420dp card that shrank the picture to 58% to make room for itself, which
is the whole complaint: the credits are the last thing an episode has to say and the
overlay was covering them. Nothing here is focusable — the bar takes no part in the
remote's world, so the transport, the seek keys and the centre button all keep meaning
what they meant a moment before it appeared. Play now is the transport's own Next
Episode button, which is offered whenever this bar is, and Back dismisses.
overlay was covering them. Only the action pill at the end is focusable — the rest of
the bar takes no part in the remote's world, so the transport and the seek keys keep
meaning what they meant a moment before it appeared while nothing has been pressed. The
transport's own Next Episode button is still offered whenever this bar is, for a viewer
who already has the controls up; the pill is the same action reachable without them.
PlayerActivity gives it focus as the bar appears and hands focus back to the video the
moment it goes, the `player_skip_intro` pattern. Back dismisses.
Everything is in dp and nothing is measured against the screen, so it is the same size
on a 720p set and a 4K one; the safe-area margins keep it clear of overscan. -->
@@ -104,5 +107,37 @@
android:layout_width="38dp"
android:layout_height="38dp"
android:layout_marginStart="20dp" />
<View
android:layout_width="1dp"
android:layout_height="26dp"
android:layout_marginStart="18dp"
android:layout_marginEnd="18dp"
android:background="@drawable/next_up_divider" />
<!-- The one focusable thing on the bar. A remote has no other way to say "press
this", so the pill is what takes focus — never the bar around it, which would
leave a click landing without anywhere on screen to show it was aimed. -->
<LinearLayout
android:id="@+id/player_next_up_action"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="@drawable/next_up_primary_button"
android:focusable="true"
android:gravity="center_vertical"
android:paddingStart="18dp"
android:paddingTop="9dp"
android:paddingEnd="18dp"
android:paddingBottom="9dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:duplicateParentState="true"
android:text="@string/next_up_start_next_episode"
android:textColor="@color/next_up_button_text"
android:textSize="14sp"
android:textStyle="bold" />
</LinearLayout>
</LinearLayout>
</FrameLayout>
+8
View File
@@ -96,6 +96,14 @@
a clock value over a minute, a bare count of seconds under one. Two figures on one
bar that disagree - "60s" beside "1:00" - read as a countdown that has gone wrong. -->
<string name="next_up_starting_in">Starting in %1$s</string>
<!-- The bar's own action pill. Named for what pressing it does, not for the offer it
answers, so it reads correctly whether automatic advance is on (where it jumps the
countdown) or off (where it is the only way the episode changes). -->
<string name="next_up_start_next_episode">Start Next Episode</string>
<!-- The next-airing notice's eyebrow: the episode code, when Sonarr named one, then
this fixed word. "S03E06 · NEXT EPISODE" reads correctly whichever half is present,
because the code is prepended in code rather than baked into this string. -->
<string name="next_airing_next_episode">NEXT EPISODE</string>
<!-- The way back to the credits at normal size and normal speed. Worded as wanting the
credits rather than as dismissing a panel: it is the only thing this button does,
and somebody pressing it is asking to watch them. -->
@@ -129,6 +129,43 @@ class PlaybackJourneyTest {
assertEquals("success", events.step("playback", "start").outcome)
}
@Test
fun `a pause and its resume carry where playback was, in one journey`() {
val journey = collector()
val entryPoint = playbackEntryPointFor(rowId = "continue", rowKind = "CONTINUE")
PlaybackJourney.started(
sink = journey, entryPoint = entryPoint,
itemId = "ep-9", itemName = "The Pitt 7:00 A.M.", itemType = "Episode",
playSessionId = "sess-9",
)
PlaybackJourney.paused(
sink = journey, entryPoint = entryPoint,
itemId = "ep-9", itemName = "The Pitt 7:00 A.M.", itemType = "Episode",
playSessionId = "sess-9", positionMs = 922_000,
)
PlaybackJourney.resumed(
sink = journey, entryPoint = entryPoint,
itemId = "ep-9", itemName = "The Pitt 7:00 A.M.", itemType = "Episode",
playSessionId = "sess-9", positionMs = 922_000,
)
val events = journey.drain()
val paused = events.step("playback", "pause")
assertEquals(922_000L, paused.positionMs)
assertEquals("sess-9", paused.playSessionId)
assertEquals("continue_watching", paused.source)
// No outcome: a pause is neither a success nor a failure, it is where somebody left off.
assertEquals("", paused.outcome)
val resumed = events.step("playback", "resume")
assertEquals(922_000L, resumed.positionMs)
assertEquals("sess-9", resumed.playSessionId)
// Both steps belong to the one journey and viewing session the start opened.
assertEquals(setOf("journey-1"), events.map { it.journeyId }.toSet())
}
@Test
fun `a failure is the same step wearing the other outcome`() {
val journey = collector()
@@ -255,4 +255,68 @@ class NextUpPipelineTest {
assertNull(resolver.current)
assertFalse(resolver.resolving)
}
// --- The next-airing notice's own window ---------------------------------------------
private val windowMs = 5 * 60_000L
private val leadMs = 60_000L
@Test
fun `the notice is independent of whether a next episode exists`() {
// The whole point: a continuing show can have nothing to advance to and the notice
// must still show, since nextAiringNoticeVisible never takes hasNextEpisode at all.
assertTrue(
nextAiringNoticeVisible(
available = true, remainingMs = 4 * 60_000L, windowMs = windowMs, leadMs = leadMs,
dismissed = false, canShow = true,
),
)
}
@Test
fun `the notice retires at the next-up banner's own lead, never inside it`() {
assertFalse(
"exactly at the banner's lead the notice must already be gone",
nextAiringNoticeVisible(
available = true, remainingMs = leadMs, windowMs = windowMs, leadMs = leadMs,
dismissed = false, canShow = true,
),
)
assertTrue(
"one tick before the banner's lead it is still the notice's window",
nextAiringNoticeVisible(
available = true, remainingMs = leadMs + 1, windowMs = windowMs, leadMs = leadMs,
dismissed = false, canShow = true,
),
)
}
@Test
fun `a dismissal or an unavailable answer or something else owning the screen refuses it`() {
assertTrue(
nextAiringNoticeVisible(true, windowMs - 1L, windowMs, leadMs, dismissed = false, canShow = true),
)
assertFalse(
"no scheduled airing to report",
nextAiringNoticeVisible(false, windowMs - 1L, windowMs, leadMs, dismissed = false, canShow = true),
)
assertFalse(
"dismissed by Back for this episode",
nextAiringNoticeVisible(true, windowMs - 1L, windowMs, leadMs, dismissed = true, canShow = true),
)
assertFalse(
"something else owns the screen",
nextAiringNoticeVisible(true, windowMs - 1L, windowMs, leadMs, dismissed = false, canShow = false),
)
}
@Test
fun `beyond the notice's own window it does not show`() {
assertFalse(
nextAiringNoticeVisible(
available = true, remainingMs = windowMs + 1, windowMs = windowMs, leadMs = leadMs,
dismissed = false, canShow = true,
),
)
}
}