0.3.32
This commit is contained in:
@@ -21,6 +21,7 @@ interface JourneyEvent {
|
||||
itemType?: string;
|
||||
playSessionId?: string;
|
||||
outcome?: string;
|
||||
positionMs?: number;
|
||||
}
|
||||
|
||||
interface JourneyResponse {
|
||||
@@ -43,6 +44,36 @@ const label = (value: string | undefined | null) => {
|
||||
|
||||
const place = (event: JourneyEvent | undefined) => label(event?.target || event?.screen || event?.source || event?.feature);
|
||||
|
||||
/* "15m 22s" / "1h 04m" — where playback was, for a pause or resume step. Seconds are
|
||||
* dropped past an hour; a viewing session that long has no use for that precision. */
|
||||
const formatPosition = (ms: number | undefined) => {
|
||||
const totalSeconds = Math.max(0, Math.round((ms ?? 0) / 1000));
|
||||
const hours = Math.floor(totalSeconds / 3600);
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
if (hours > 0) return `${hours}h ${String(minutes).padStart(2, '0')}m`;
|
||||
if (minutes > 0) return `${minutes}m ${String(seconds).padStart(2, '0')}s`;
|
||||
return `${seconds}s`;
|
||||
};
|
||||
|
||||
/* "4m" / "48s" — how long a pause lasted, from the gap between its timestamp and the
|
||||
* matching resume's. Coarser than formatPosition: nobody needs their pause timed to the
|
||||
* second, only roughly how long they were away. */
|
||||
const formatDuration = (ms: number) => {
|
||||
const totalSeconds = Math.max(0, Math.round(ms / 1000));
|
||||
const hours = Math.floor(totalSeconds / 3600);
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||
if (hours > 0) return `${hours}h ${minutes}m`;
|
||||
if (minutes > 0) return `${minutes}m`;
|
||||
return `${totalSeconds}s`;
|
||||
};
|
||||
|
||||
/* A Hero is a featured card, not a poster in a shelf — the same "content"/"open" step a
|
||||
* poster press writes, wearing `feature: "hero"` instead of the shelf's own kind. Nothing
|
||||
* else in the event says that: `source` is still the hero's row id, which reads exactly
|
||||
* like any other row id next to it. This is the one place that distinction is read back. */
|
||||
const isHeroOrigin = (event: JourneyEvent | undefined) => event?.feature === 'hero';
|
||||
|
||||
/* Where a viewing journey began.
|
||||
*
|
||||
* The journey is cut at the playback request, so the first event's `target` is "player" for
|
||||
@@ -54,9 +85,14 @@ const entryPoint = (events: JourneyEvent[]) => {
|
||||
const request = events.find((event) => event.category === 'playback' && event.action === 'request');
|
||||
return request?.source ? label(request.source) : place(events[0]);
|
||||
};
|
||||
const detail = (event: JourneyEvent) => event.itemName
|
||||
const detail = (event: JourneyEvent) => {
|
||||
const base = event.itemName
|
||||
? `${label(event.itemType)} · ${event.itemName}`
|
||||
: event.source && event.target ? `${label(event.source)} → ${label(event.target)}` : place(event);
|
||||
// The page/section the Hero was shown on rides `screen` already, on every event — this
|
||||
// is the one place it is worth restating, since it is what says *which* Hero this was.
|
||||
return isHeroOrigin(event) ? `${base} · from the Hero${event.screen ? ` on ${label(event.screen)}` : ''}` : base;
|
||||
};
|
||||
const verb = (event: JourneyEvent) => ({
|
||||
journey_start: 'Opened Memby', home_open: 'Opened Memby', journey_end: 'Finished session',
|
||||
screen_view: 'Viewed', select: 'Selected', open: 'Opened', close: 'Closed',
|
||||
@@ -68,8 +104,34 @@ const verb = (event: JourneyEvent) => ({
|
||||
complete: event.category === 'playback'
|
||||
? (event.outcome === 'completed' ? 'Finished watching' : 'Stopped watching')
|
||||
: 'Completed',
|
||||
pause: 'Paused',
|
||||
resume: 'Resumed',
|
||||
}[event.action] ?? label(event.action));
|
||||
|
||||
/* Pause and resume replace the ordinary itemName/place detail with where playback was and,
|
||||
* for a resume, how long the pause it closes lasted — computed from the two steps'
|
||||
* timestamps rather than sent as a field of its own, so it can never disagree with them.
|
||||
* Matched by playSessionId and position within the sequence: a pause is "open" until the
|
||||
* next resume carrying the same stream closes it, which is what keeps a second pause/resume
|
||||
* pair in the same viewing journey from pairing across each other. */
|
||||
function playbackDetail(events: JourneyEvent[]) {
|
||||
const detailByStep = new Map<JourneyEvent, string>();
|
||||
const openPauseAt = new Map<string, string>();
|
||||
for (const event of events) {
|
||||
if (event.category !== 'playback') continue;
|
||||
if (event.action === 'pause') {
|
||||
openPauseAt.set(event.playSessionId ?? '', event.occurredAt);
|
||||
detailByStep.set(event, `at ${formatPosition(event.positionMs)}`);
|
||||
} else if (event.action === 'resume') {
|
||||
const pausedAt = openPauseAt.get(event.playSessionId ?? '');
|
||||
openPauseAt.delete(event.playSessionId ?? '');
|
||||
const gapMs = pausedAt ? new Date(event.occurredAt).getTime() - new Date(pausedAt).getTime() : NaN;
|
||||
detailByStep.set(event, Number.isFinite(gapMs) && gapMs >= 0 ? `after ${formatDuration(gapMs)}` : `at ${formatPosition(event.positionMs)}`);
|
||||
}
|
||||
}
|
||||
return detailByStep;
|
||||
}
|
||||
|
||||
function outcome(events: JourneyEvent[]) {
|
||||
/* A playback step is the verdict on a viewing journey, so it outranks whatever incidental
|
||||
* outcome a favourite toggle or a settings change left behind on the way in. */
|
||||
@@ -131,14 +193,15 @@ export function JourneyViewerPage() {
|
||||
const entry = events[0];
|
||||
const selection = [...events].reverse().find((event) => event.itemName || event.action === 'select' || (event.category === 'playback' && event.action === 'request'));
|
||||
const result = outcome(events);
|
||||
const pauseResumeDetail = playbackDetail(events);
|
||||
return <article className="visit" key={journey.key}>
|
||||
<header><div><b>{when(entry?.occurredAt)}</b><span>Journey {index + 1} · {events.length} recorded steps</span></div><Tag tone={result.tone}>{result.label}</Tag></header>
|
||||
<div className="journey-answers">
|
||||
<div className="journey-answer" data-kind="entry"><Icon name="journey" /><span>Entered from</span><b>{entryPoint(events)}</b></div>
|
||||
<div className="journey-answer" data-kind="selection"><Icon name="play" /><span>Selected</span><b>{selection ? detail(selection) : 'Nothing selected'}</b></div>
|
||||
<div className="journey-answer" data-kind="selection"><Icon name={isHeroOrigin(selection) ? 'star' : 'play'} /><span>Selected</span><b>{selection ? detail(selection) : 'Nothing selected'}</b></div>
|
||||
<div className="journey-answer" data-kind="outcome"><Icon name={result.tone === 'ok' ? 'check' : 'clock'} /><span>Outcome</span><b>{result.label}</b></div>
|
||||
</div>
|
||||
<ol className="journey-timeline">{events.map((event) => <li key={`${event.journeyId}:${event.sequence}`}><span className="timeline-dot" data-action={event.action} /><div><b>{verb(event)}</b><span>{detail(event)}</span></div><time>{when(event.occurredAt)}</time></li>)}</ol>
|
||||
<ol className="journey-timeline">{events.map((event) => <li key={`${event.journeyId}:${event.sequence}`}><span className="timeline-dot" data-action={event.action} data-hero={isHeroOrigin(event) ? '' : undefined} /><div><b>{verb(event)}</b><span>{pauseResumeDetail.get(event) ?? detail(event)}</span></div><time>{when(event.occurredAt)}</time></li>)}</ol>
|
||||
</article>;
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -2911,6 +2911,14 @@ pre.code {
|
||||
.timeline-dot[data-action="select"],
|
||||
.timeline-dot[data-action="open"] { background: var(--note-ink); }
|
||||
.timeline-dot[data-action="close"] { background: var(--quiet); }
|
||||
/* Paused is amber (look at this — playback stopped); resumed keeps the default accent, the
|
||||
same "still going" colour a step that moves the journey forward already wears. */
|
||||
.timeline-dot[data-action="pause"] { background: var(--warn-ink); }
|
||||
/* A step that came from the Hero rather than a poster in a shelf — same dot position,
|
||||
a ring around it so it reads at a glance without having to read the step's text. */
|
||||
.timeline-dot[data-hero] {
|
||||
box-shadow: 0 0 0 2px var(--note-wash), 0 0 0 3px var(--note-ink);
|
||||
}
|
||||
.journey-timeline li > div { min-width: 0; }
|
||||
.journey-timeline li b {
|
||||
display: inline;
|
||||
|
||||
@@ -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,13 +2706,38 @@ 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()
|
||||
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) {
|
||||
@@ -2669,11 +2752,12 @@ class PlayerActivity : ComponentActivity() {
|
||||
value = getString(
|
||||
R.string.player_finishes_in_value,
|
||||
formatCueDuration(wallClockRemainingMs),
|
||||
DateFormat.getTimeFormat(this).format(Date(finishAt)),
|
||||
DateFormat.getTimeFormat(this@PlayerActivity).format(Date(finishAt)),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Announces the ten-minute boundary once per item. Media time is intentional: pause
|
||||
@@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,11 @@ const maxAnalyticsBatch = 200
|
||||
// nothing about what anyone was looking at.
|
||||
const maxDwellMs = 30 * 60 * 1000
|
||||
|
||||
// maxPositionMs clamps a pause/resume step's reported position. A title that long does
|
||||
// not exist, so a wilder value says the field was misread rather than that playback was
|
||||
// genuinely there.
|
||||
const maxPositionMs = 24 * 60 * 60 * 1000
|
||||
|
||||
type rowEventPayload struct {
|
||||
RowID string `json:"rowId"`
|
||||
RowKind string `json:"rowKind"`
|
||||
@@ -52,6 +57,9 @@ type journeyEventPayload struct {
|
||||
PlaySessionID string `json:"playSessionId"`
|
||||
Outcome string `json:"outcome"`
|
||||
OccurredAt string `json:"occurredAt"`
|
||||
// PositionMs is where playback was, in the title, on a pause or resume step. Absent
|
||||
// (zero) on every other kind of step.
|
||||
PositionMs int64 `json:"positionMs"`
|
||||
}
|
||||
|
||||
type journeyAnalyticsRequest struct {
|
||||
@@ -63,7 +71,7 @@ var journeyActions = allowedAnalyticsValues(
|
||||
"journey_start", "home_open", "journey_end", "screen_view", "open", "close", "select",
|
||||
"submit", "request", "start", "stop", "complete", "abandon", "change",
|
||||
"toggle", "follow", "unfollow", "favourite", "unfavourite", "mark_played",
|
||||
"mark_unplayed", "retry", "dismiss", "switch",
|
||||
"mark_unplayed", "retry", "dismiss", "switch", "pause", "resume",
|
||||
)
|
||||
var journeyOutcomes = allowedAnalyticsValues("", "success", "failure", "cancelled", "completed", "abandoned")
|
||||
|
||||
@@ -122,7 +130,18 @@ func toJourneyEvent(payload journeyEventPayload, userID string, now time.Time) (
|
||||
Screen: payload.Screen, Feature: payload.Feature, Source: payload.Source,
|
||||
Target: payload.Target, ItemID: payload.ItemID,
|
||||
ItemName: strings.TrimSpace(payload.ItemName), ItemType: payload.ItemType,
|
||||
PlaySessionID: payload.PlaySessionID, Outcome: payload.Outcome}, true
|
||||
PlaySessionID: payload.PlaySessionID, Outcome: payload.Outcome,
|
||||
PositionMs: clampPositionMs(payload.PositionMs)}, true
|
||||
}
|
||||
|
||||
func clampPositionMs(value int64) int64 {
|
||||
if value < 0 {
|
||||
return 0
|
||||
}
|
||||
if value > maxPositionMs {
|
||||
return maxPositionMs
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func safeAnalyticsValue(value string, max int) bool {
|
||||
|
||||
@@ -90,6 +90,48 @@ func TestJourneyEventRejectsAnUnreadablePlaySession(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Pause and resume are playback steps like start and complete, and carry where playback
|
||||
// was rather than an outcome — the console derives the pause's length from the gap
|
||||
// between a pause row's timestamp and its matching resume's.
|
||||
func TestJourneyEventAcceptsPauseAndResume(t *testing.T) {
|
||||
now := time.Now().UTC()
|
||||
for _, action := range []string{"pause", "resume"} {
|
||||
payload := journeyEventPayload{UserID: "u1", JourneyID: "j1", Category: "playback",
|
||||
Action: action, Screen: "player", Feature: "playback", Source: "continue_watching",
|
||||
Target: "player", ItemID: "42", ItemName: "Whale Rider", ItemType: "Movie",
|
||||
PlaySessionID: "b3d1f0a4-9c22-4f61-8a10-77d0e2c9aa51", PositionMs: 925000}
|
||||
event, ok := toJourneyEvent(payload, "u1", now)
|
||||
if !ok {
|
||||
t.Fatalf("action %q was rejected", action)
|
||||
}
|
||||
if event.PositionMs != 925000 {
|
||||
t.Fatalf("action %q: position was not retained: %+v", action, event)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A wild position is clamped rather than dropped: the step it describes is worth more
|
||||
// than one field on it, the same trade dwell time makes on the row events.
|
||||
func TestJourneyEventClampsAWildPosition(t *testing.T) {
|
||||
now := time.Now().UTC()
|
||||
base := journeyEventPayload{UserID: "u1", JourneyID: "j1", Category: "playback",
|
||||
Action: "pause", Screen: "player", Feature: "playback", ItemID: "42"}
|
||||
|
||||
negative := base
|
||||
negative.PositionMs = -500
|
||||
event, ok := toJourneyEvent(negative, "u1", now)
|
||||
if !ok || event.PositionMs != 0 {
|
||||
t.Fatalf("negative position was not clamped to zero: %+v", event)
|
||||
}
|
||||
|
||||
wild := base
|
||||
wild.PositionMs = maxPositionMs + 1
|
||||
event, ok = toJourneyEvent(wild, "u1", now)
|
||||
if !ok || event.PositionMs != maxPositionMs {
|
||||
t.Fatalf("oversized position was not clamped: %+v", event)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJourneyEventRetainsTheItemName(t *testing.T) {
|
||||
now := time.Now().UTC()
|
||||
payload := journeyEventPayload{UserID: "u1", JourneyID: "j1", Category: "content",
|
||||
|
||||
@@ -23,6 +23,7 @@ const (
|
||||
featureTrickplay = "trickplay"
|
||||
featureSkipIntro = "skip_intro"
|
||||
featureEndCredits = "end_credits"
|
||||
featureNextEpisodeAiring = "next_episode_airing"
|
||||
featureSeasonalThemes = "seasonal_themes"
|
||||
featureSeasonalDecorations = "seasonal_decorations"
|
||||
featureGenreBrowser = "genre_browser"
|
||||
@@ -251,6 +252,13 @@ var featureCatalogue = []featureDefinition{
|
||||
DefaultEnabled: true, MinimumProtocol: 1, Capability: "end_credits_v1",
|
||||
Recovery: "Takes effect the next time playback starts; the credits simply play out full size.",
|
||||
},
|
||||
{
|
||||
Key: featureNextEpisodeAiring, Name: "Next episode airing notice", Area: "Playback",
|
||||
Description: "Show a quiet notice ahead of the Next Up banner naming when a " +
|
||||
"continuing show's next episode airs, from Sonarr's schedule.",
|
||||
DefaultEnabled: true, MinimumProtocol: 1, Capability: "next_episode_airing_v1",
|
||||
Recovery: "Takes effect the next time playback starts; the notice simply stops appearing.",
|
||||
},
|
||||
{
|
||||
// The only switch there is for seasonal themes, and it is deliberately the
|
||||
// operator's rather than the viewer's: a per-person opt-out is a thing somebody
|
||||
|
||||
@@ -62,6 +62,15 @@ type playbackResponse struct {
|
||||
// separate features with separate switches, and a house that has turned the skip button
|
||||
// off has not asked to lose the credits pane with it.
|
||||
EndCreditsAvailable bool `json:"endCreditsAvailable"`
|
||||
// Whether Sonarr can name when this continuing show's next episode airs. Rides here for
|
||||
// the same reason the other availability flags do: the player asks once at the start of
|
||||
// playback and the notice itself takes no further request. Absent (all fields blank)
|
||||
// means either the show isn't Sonarr-tracked, isn't continuing, or has no scheduled
|
||||
// airing to report — the client shows nothing in every one of those cases.
|
||||
NextAiringAvailable bool `json:"nextAiringAvailable"`
|
||||
NextAiringLabel string `json:"nextAiringLabel,omitempty"`
|
||||
NextAiringDayLabel string `json:"nextAiringDayLabel,omitempty"`
|
||||
NextAiringEpisodeCode string `json:"nextAiringEpisodeCode,omitempty"`
|
||||
}
|
||||
|
||||
type playableSubtitle struct {
|
||||
@@ -114,6 +123,10 @@ type nextEpisodeResponse struct {
|
||||
TrickplayAvailable bool `json:"trickplayAvailable"`
|
||||
SkipIntroAvailable bool `json:"skipIntroAvailable"`
|
||||
EndCreditsAvailable bool `json:"endCreditsAvailable"`
|
||||
NextAiringAvailable bool `json:"nextAiringAvailable"`
|
||||
NextAiringLabel string `json:"nextAiringLabel,omitempty"`
|
||||
NextAiringDayLabel string `json:"nextAiringDayLabel,omitempty"`
|
||||
NextAiringEpisodeCode string `json:"nextAiringEpisodeCode,omitempty"`
|
||||
}
|
||||
|
||||
// handlePlayback resolves what to actually play.
|
||||
@@ -235,6 +248,8 @@ func (s *Server) handlePlayback(w http.ResponseWriter, r *http.Request, sess sto
|
||||
"negotiation_duration", time.Since(negotiationStarted).Round(time.Millisecond),
|
||||
)
|
||||
|
||||
nextAiringAvailable, nextAiringLabel, nextAiringDayLabel, nextAiringCode :=
|
||||
s.nextAiringFieldsFor(ctx, target)
|
||||
writeJSON(w, http.StatusOK, playbackResponse{
|
||||
ItemID: target.ID,
|
||||
Title: title,
|
||||
@@ -257,6 +272,10 @@ func (s *Server) handlePlayback(w http.ResponseWriter, r *http.Request, sess sto
|
||||
TrickplayAvailable: s.trickplayEnabled(ctx),
|
||||
SkipIntroAvailable: s.skipIntroEnabled(ctx),
|
||||
EndCreditsAvailable: s.endCreditsEnabled(ctx),
|
||||
NextAiringAvailable: nextAiringAvailable,
|
||||
NextAiringLabel: nextAiringLabel,
|
||||
NextAiringDayLabel: nextAiringDayLabel,
|
||||
NextAiringEpisodeCode: nextAiringCode,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -336,6 +355,22 @@ func (s *Server) firstPlayableEpisode(
|
||||
return &summary, nil
|
||||
}
|
||||
|
||||
// nextAiringFieldsFor answers the current episode's own show's next airing, never the
|
||||
// episode being played itself. It only ever asks Sonarr about a series that is actually
|
||||
// known — a hinted playback response that skipped the Emby lookup and so carries no
|
||||
// SeriesName simply gets nothing, the same silent degradation every other availability
|
||||
// flag here takes.
|
||||
func (s *Server) nextAiringFieldsFor(ctx context.Context, item emby.Summary) (available bool, label, dayLabel, code string) {
|
||||
if !strings.EqualFold(item.Type, "Episode") {
|
||||
return false, "", "", ""
|
||||
}
|
||||
info, ok := s.nextAiringInfoFor(ctx, item.SeriesName)
|
||||
if !ok {
|
||||
return false, "", "", ""
|
||||
}
|
||||
return true, info.Label, info.DayLabel, info.EpisodeCode
|
||||
}
|
||||
|
||||
func episodeCode(item emby.Summary) string {
|
||||
if !strings.EqualFold(item.Type, "Episode") || item.ParentIndexNumber < 0 || item.IndexNumber <= 0 {
|
||||
return ""
|
||||
@@ -435,6 +470,8 @@ func (s *Server) handleNextEpisode(w http.ResponseWriter, r *http.Request, sess
|
||||
|
||||
// The metadata-only shape. Everything below this point is a PlaybackInfo negotiation,
|
||||
// and a client that said it does not want one yet must not be given one anyway.
|
||||
nextAiringAvailable, nextAiringLabel, nextAiringDayLabel, nextAiringCode :=
|
||||
s.nextAiringFieldsFor(ctx, next)
|
||||
if !nextEpisodeWantsStream(r) {
|
||||
s.playbackTitles.remember(next.ID, title)
|
||||
s.loggerFor(ctx).Debug("next episode identified",
|
||||
@@ -449,6 +486,10 @@ func (s *Server) handleNextEpisode(w http.ResponseWriter, r *http.Request, sess
|
||||
TrickplayAvailable: s.trickplayEnabled(ctx),
|
||||
SkipIntroAvailable: s.skipIntroEnabled(ctx),
|
||||
EndCreditsAvailable: s.endCreditsEnabled(ctx),
|
||||
NextAiringAvailable: nextAiringAvailable,
|
||||
NextAiringLabel: nextAiringLabel,
|
||||
NextAiringDayLabel: nextAiringDayLabel,
|
||||
NextAiringEpisodeCode: nextAiringCode,
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -491,6 +532,10 @@ func (s *Server) handleNextEpisode(w http.ResponseWriter, r *http.Request, sess
|
||||
TrickplayAvailable: s.trickplayEnabled(ctx),
|
||||
SkipIntroAvailable: s.skipIntroEnabled(ctx),
|
||||
EndCreditsAvailable: s.endCreditsEnabled(ctx),
|
||||
NextAiringAvailable: nextAiringAvailable,
|
||||
NextAiringLabel: nextAiringLabel,
|
||||
NextAiringDayLabel: nextAiringDayLabel,
|
||||
NextAiringEpisodeCode: nextAiringCode,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -582,6 +582,153 @@ func scheduleAirLabel(airTime, now time.Time, location *time.Location) string {
|
||||
}
|
||||
}
|
||||
|
||||
// nextAiringInfo is what the in-player notice needs about a continuing show's next
|
||||
// episode: the household's own wording for when it airs, and the episode code when it can
|
||||
// be resolved cheaply.
|
||||
type nextAiringInfo struct {
|
||||
Label string
|
||||
DayLabel string
|
||||
EpisodeCode string
|
||||
}
|
||||
|
||||
// nextEpisodeAiringEnabled gates the whole feature: Sonarr must be configured and the
|
||||
// operator must not have switched it off.
|
||||
func (s *Server) nextEpisodeAiringEnabled(ctx context.Context) bool {
|
||||
return s.sonarrEnabled(ctx) && s.featureEnabled(ctx, featureNextEpisodeAiring)
|
||||
}
|
||||
|
||||
// nextAiringInfoFor resolves a currently-playing episode's series against Sonarr's own
|
||||
// catalogue (already cached by sonarrSeriesCatalogue) and answers only when the show is
|
||||
// still being made and Sonarr has a scheduled airing in the future. Matched by title alone
|
||||
// — a playing episode's summary carries no series year, and title collisions are rare
|
||||
// enough that My Shows accepts the same trade when a follow was saved with no year either.
|
||||
func (s *Server) nextAiringInfoFor(ctx context.Context, seriesName string) (nextAiringInfo, bool) {
|
||||
if !s.nextEpisodeAiringEnabled(ctx) {
|
||||
return nextAiringInfo{}, false
|
||||
}
|
||||
catalogue, err := s.sonarrSeriesCatalogue(ctx)
|
||||
if err != nil {
|
||||
s.loggerFor(ctx).Debug("next airing lookup unavailable", "error", err)
|
||||
return nextAiringInfo{}, false
|
||||
}
|
||||
location := s.cfg.SonarrLocation
|
||||
if location == nil {
|
||||
location = time.Local
|
||||
}
|
||||
now := time.Now().In(location)
|
||||
matched, info, ok := resolveNextAiring(catalogue, seriesName, now, location)
|
||||
if !ok {
|
||||
return nextAiringInfo{}, false
|
||||
}
|
||||
info.EpisodeCode = s.nextAiringEpisodeCode(ctx, matched.ID, *matched.NextAiring)
|
||||
return info, true
|
||||
}
|
||||
|
||||
// resolveNextAiring is the pure half of nextAiringInfoFor: given Sonarr's catalogue and the
|
||||
// series being played, decide whether there is anything to say and, when there is, the
|
||||
// matched series so the caller can look up its episode code. Kept apart from the Server
|
||||
// method so the matching, lifecycle and future-airing rules can be pinned without a live
|
||||
// cache or Sonarr client.
|
||||
func resolveNextAiring(
|
||||
catalogue []sonarr.Series, seriesName string, now time.Time, location *time.Location,
|
||||
) (*sonarr.Series, nextAiringInfo, bool) {
|
||||
seriesName = strings.TrimSpace(seriesName)
|
||||
if seriesName == "" {
|
||||
return nil, nextAiringInfo{}, false
|
||||
}
|
||||
key := normalizedShowTitle(seriesName)
|
||||
var matched *sonarr.Series
|
||||
for i := range catalogue {
|
||||
if normalizedShowTitle(catalogue[i].Title) != key {
|
||||
continue
|
||||
}
|
||||
matched = &catalogue[i]
|
||||
break
|
||||
}
|
||||
if matched == nil {
|
||||
return nil, nextAiringInfo{}, false
|
||||
}
|
||||
if seriesLifecycleTag(matched.Status).Status != "continuing" {
|
||||
return nil, nextAiringInfo{}, false
|
||||
}
|
||||
if matched.NextAiring == nil || !matched.NextAiring.After(now) {
|
||||
return nil, nextAiringInfo{}, false
|
||||
}
|
||||
airTime := matched.NextAiring.In(location)
|
||||
return matched, nextAiringInfo{
|
||||
Label: scheduleAirLabel(airTime, now, location),
|
||||
DayLabel: scheduleAirDayLabel(airTime, now, location),
|
||||
}, true
|
||||
}
|
||||
|
||||
// nextAiringEpisodeCode is a best-effort lookup against the same 5-day calendar window the
|
||||
// schedule row already fetches and caches. A next airing beyond that window, or one Sonarr
|
||||
// has not yet attached to a specific episode, omits the code rather than guessing at it.
|
||||
func (s *Server) nextAiringEpisodeCode(ctx context.Context, seriesID int, nextAiring time.Time) string {
|
||||
episodes, err := s.sonarrUpcomingEpisodes(ctx)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
for _, episode := range episodes {
|
||||
candidateSeriesID := episode.SeriesID
|
||||
if episode.Series.ID > 0 {
|
||||
candidateSeriesID = episode.Series.ID
|
||||
}
|
||||
if candidateSeriesID != seriesID || episode.AirDateUTC == nil {
|
||||
continue
|
||||
}
|
||||
if episode.AirDateUTC.Equal(nextAiring) {
|
||||
return fmt.Sprintf("S%02dE%02d", episode.SeasonNumber, episode.EpisodeNumber)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// sonarrUpcomingEpisodes shares the schedule row's own cached calendar window, so this
|
||||
// feature costs no extra request to Sonarr beyond what the launcher's schedule row already
|
||||
// pays for once a day.
|
||||
func (s *Server) sonarrUpcomingEpisodes(ctx context.Context) ([]sonarr.Episode, error) {
|
||||
location := s.cfg.SonarrLocation
|
||||
if location == nil {
|
||||
location = time.Local
|
||||
}
|
||||
now := time.Now().In(location)
|
||||
dayStart := localDayStart(now, location)
|
||||
key := sonarrCalendarCachePrefix + "episodes:" + dayStart.Format("2006-01-02")
|
||||
if episodes := s.cachedSonarrEpisodes(ctx, key); episodes != nil {
|
||||
return episodes, nil
|
||||
}
|
||||
|
||||
s.sonarrMu.Lock()
|
||||
defer s.sonarrMu.Unlock()
|
||||
if episodes := s.cachedSonarrEpisodes(ctx, key); episodes != nil {
|
||||
return episodes, nil
|
||||
}
|
||||
|
||||
episodes, err := s.sonarr.Calendar(ctx, dayStart, dayStart.AddDate(0, 0, sonarrScheduleDays))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if body, marshalErr := json.Marshal(episodes); marshalErr == nil {
|
||||
if cacheErr := s.cache.Set(ctx, key, body, s.cfg.SonarrTTL); cacheErr != nil {
|
||||
s.loggerFor(ctx).Warn("sonarr upcoming episodes cache write failed", "error", cacheErr)
|
||||
}
|
||||
}
|
||||
return episodes, nil
|
||||
}
|
||||
|
||||
func (s *Server) cachedSonarrEpisodes(ctx context.Context, key string) []sonarr.Episode {
|
||||
raw, err := s.cache.Get(ctx, key)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var episodes []sonarr.Episode
|
||||
if json.Unmarshal(raw, &episodes) != nil {
|
||||
return nil
|
||||
}
|
||||
return episodes
|
||||
}
|
||||
|
||||
func localDayStart(value time.Time, location *time.Location) time.Time {
|
||||
value = value.In(location)
|
||||
return time.Date(value.Year(), value.Month(), value.Day(), 0, 0, 0, 0, location)
|
||||
|
||||
@@ -250,3 +250,57 @@ func TestBuildSonarrRowCoversFiveDaysAndUsesRelativeAirLabels(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveNextAiringOnlyAnswersForContinuingShowsWithAFutureAiring(t *testing.T) {
|
||||
location := time.UTC
|
||||
now := time.Date(2026, 8, 26, 12, 0, 0, 0, location)
|
||||
tomorrow := now.AddDate(0, 0, 1)
|
||||
yesterday := now.AddDate(0, 0, -1)
|
||||
|
||||
catalogue := []sonarr.Series{
|
||||
{Title: "Continuing Show", Status: "continuing", NextAiring: &tomorrow},
|
||||
{Title: "Ended Show", Status: "ended", NextAiring: &tomorrow},
|
||||
{Title: "Cancelled Show", Status: "cancelled", NextAiring: &tomorrow},
|
||||
{Title: "No Schedule Show", Status: "continuing", NextAiring: nil},
|
||||
{Title: "Already Aired Show", Status: "continuing", NextAiring: &yesterday},
|
||||
}
|
||||
|
||||
if _, info, ok := resolveNextAiring(catalogue, "Continuing Show", now, location); !ok {
|
||||
t.Fatal("expected a continuing show with a future airing to resolve")
|
||||
} else if info.DayLabel != "Tomorrow" {
|
||||
t.Fatalf("day label = %q, want Tomorrow", info.DayLabel)
|
||||
}
|
||||
|
||||
cases := []string{
|
||||
"Ended Show", "Cancelled Show", "No Schedule Show", "Already Aired Show", "Unknown Show",
|
||||
}
|
||||
for _, name := range cases {
|
||||
if _, _, ok := resolveNextAiring(catalogue, name, now, location); ok {
|
||||
t.Fatalf("%q should not have produced a next-airing answer", name)
|
||||
}
|
||||
}
|
||||
|
||||
if _, _, ok := resolveNextAiring(catalogue, " ", now, location); ok {
|
||||
t.Fatal("a blank series name should never resolve")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveNextAiringWordingMatchesTheScheduleRow(t *testing.T) {
|
||||
location := time.FixedZone("NZST", 12*60*60)
|
||||
now := time.Date(2026, 7, 27, 8, 0, 0, 0, location)
|
||||
air := now.Add(8 * time.Hour)
|
||||
catalogue := []sonarr.Series{
|
||||
{Title: "Northbound", Status: "continuing", NextAiring: &air},
|
||||
}
|
||||
|
||||
_, info, ok := resolveNextAiring(catalogue, "Northbound", now, location)
|
||||
if !ok {
|
||||
t.Fatal("expected a resolved answer")
|
||||
}
|
||||
wantLabel := scheduleAirLabel(air, now, location)
|
||||
wantDayLabel := scheduleAirDayLabel(air, now, location)
|
||||
if info.Label != wantLabel || info.DayLabel != wantDayLabel {
|
||||
t.Fatalf("wording drifted from the schedule row: got %+v, want label=%q day=%q",
|
||||
info, wantLabel, wantDayLabel)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
0.1.75
|
||||
0.1.76
|
||||
|
||||
@@ -81,6 +81,10 @@ type JourneyEvent struct {
|
||||
// the two records having to agree on anything but this.
|
||||
PlaySessionID string `json:"playSessionId,omitempty"`
|
||||
Outcome string `json:"outcome,omitempty"`
|
||||
// PositionMs is where playback was, in the title, at a pause or resume step. Zero on
|
||||
// every other kind of step. A pause's duration is derived by the reader from the gap
|
||||
// between a pause event's OccurredAt and its matching resume's, never stored directly.
|
||||
PositionMs int64 `json:"positionMs,omitempty"`
|
||||
}
|
||||
|
||||
type AnalyticsUser struct {
|
||||
@@ -336,13 +340,14 @@ func (s *Store) InsertJourneyEvents(ctx context.Context, events []JourneyEvent)
|
||||
batch.Queue(`
|
||||
INSERT INTO journey_events
|
||||
(occurred_at, emby_user_id, journey_id, sequence, category, action, screen,
|
||||
feature, source, target, item_id, item_name, item_type, play_session_id, outcome)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15)
|
||||
feature, source, target, item_id, item_name, item_type, play_session_id, outcome,
|
||||
position_ms)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16)
|
||||
ON CONFLICT (emby_user_id, journey_id, sequence) DO NOTHING`,
|
||||
event.OccurredAt, event.UserID, event.JourneyID, event.Sequence,
|
||||
event.Category, event.Action, event.Screen, event.Feature, event.Source,
|
||||
event.Target, event.ItemID, event.ItemName, event.ItemType,
|
||||
event.PlaySessionID, event.Outcome)
|
||||
event.PlaySessionID, event.Outcome, event.PositionMs)
|
||||
}
|
||||
results := s.pool.SendBatch(ctx, batch)
|
||||
defer results.Close()
|
||||
@@ -564,7 +569,7 @@ func (s *Store) UserJourneyEvents(ctx context.Context, userID string, since time
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT id, occurred_at, emby_user_id, journey_id, sequence, category, action,
|
||||
screen, feature, source, target, item_id, item_name, item_type,
|
||||
play_session_id, outcome
|
||||
play_session_id, outcome, position_ms
|
||||
FROM journey_events WHERE emby_user_id=$1 AND occurred_at >= $2
|
||||
ORDER BY occurred_at DESC, sequence DESC LIMIT $3`, userID, since, limit)
|
||||
if err != nil {
|
||||
@@ -574,7 +579,7 @@ func (s *Store) UserJourneyEvents(ctx context.Context, userID string, since time
|
||||
out := []JourneyEvent{}
|
||||
for rows.Next() {
|
||||
var v JourneyEvent
|
||||
if err := rows.Scan(&v.ID, &v.OccurredAt, &v.UserID, &v.JourneyID, &v.Sequence, &v.Category, &v.Action, &v.Screen, &v.Feature, &v.Source, &v.Target, &v.ItemID, &v.ItemName, &v.ItemType, &v.PlaySessionID, &v.Outcome); err != nil {
|
||||
if err := rows.Scan(&v.ID, &v.OccurredAt, &v.UserID, &v.JourneyID, &v.Sequence, &v.Category, &v.Action, &v.Screen, &v.Feature, &v.Source, &v.Target, &v.ItemID, &v.ItemName, &v.ItemType, &v.PlaySessionID, &v.Outcome, &v.PositionMs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, v)
|
||||
|
||||
@@ -222,6 +222,10 @@ ALTER TABLE journey_events ADD COLUMN IF NOT EXISTS item_name TEXT NOT NULL DEFA
|
||||
-- Emby's id for the stream a playback step describes. Empty on every other kind of step,
|
||||
-- and on every row written before playback steps carried one.
|
||||
ALTER TABLE journey_events ADD COLUMN IF NOT EXISTS play_session_id TEXT NOT NULL DEFAULT '';
|
||||
-- Where playback was in the title at the moment of a pause or resume step, in
|
||||
-- milliseconds. Zero on every other kind of step. A pause's length is never stored of its
|
||||
-- own accord — it is the gap between a pause row's occurred_at and its matching resume's.
|
||||
ALTER TABLE journey_events ADD COLUMN IF NOT EXISTS position_ms BIGINT NOT NULL DEFAULT 0;
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS journey_events_journey_sequence_idx
|
||||
ON journey_events (emby_user_id, journey_id, sequence);
|
||||
|
||||
Reference in New Issue
Block a user