0.3.32
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user