This commit is contained in:
ponzischeme89
2026-08-26 21:31:05 +12:00
parent a5f9a91832
commit 3d42c98947
33 changed files with 1172 additions and 69 deletions
+68 -5
View File
@@ -21,6 +21,7 @@ interface JourneyEvent {
itemType?: string; itemType?: string;
playSessionId?: string; playSessionId?: string;
outcome?: string; outcome?: string;
positionMs?: number;
} }
interface JourneyResponse { 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); 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. /* Where a viewing journey began.
* *
* The journey is cut at the playback request, so the first event's `target` is "player" for * 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'); const request = events.find((event) => event.category === 'playback' && event.action === 'request');
return request?.source ? label(request.source) : place(events[0]); return request?.source ? label(request.source) : place(events[0]);
}; };
const detail = (event: JourneyEvent) => event.itemName const detail = (event: JourneyEvent) => {
? `${label(event.itemType)} · ${event.itemName}` const base = event.itemName
: event.source && event.target ? `${label(event.source)} ${label(event.target)}` : place(event); ? `${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) => ({ const verb = (event: JourneyEvent) => ({
journey_start: 'Opened Memby', home_open: 'Opened Memby', journey_end: 'Finished session', journey_start: 'Opened Memby', home_open: 'Opened Memby', journey_end: 'Finished session',
screen_view: 'Viewed', select: 'Selected', open: 'Opened', close: 'Closed', screen_view: 'Viewed', select: 'Selected', open: 'Opened', close: 'Closed',
@@ -68,8 +104,34 @@ const verb = (event: JourneyEvent) => ({
complete: event.category === 'playback' complete: event.category === 'playback'
? (event.outcome === 'completed' ? 'Finished watching' : 'Stopped watching') ? (event.outcome === 'completed' ? 'Finished watching' : 'Stopped watching')
: 'Completed', : 'Completed',
pause: 'Paused',
resume: 'Resumed',
}[event.action] ?? label(event.action)); }[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[]) { function outcome(events: JourneyEvent[]) {
/* A playback step is the verdict on a viewing journey, so it outranks whatever incidental /* 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. */ * 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 entry = events[0];
const selection = [...events].reverse().find((event) => event.itemName || event.action === 'select' || (event.category === 'playback' && event.action === 'request')); const selection = [...events].reverse().find((event) => event.itemName || event.action === 'select' || (event.category === 'playback' && event.action === 'request'));
const result = outcome(events); const result = outcome(events);
const pauseResumeDetail = playbackDetail(events);
return <article className="visit" key={journey.key}> 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> <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-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="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 className="journey-answer" data-kind="outcome"><Icon name={result.tone === 'ok' ? 'check' : 'clock'} /><span>Outcome</span><b>{result.label}</b></div>
</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>; </article>;
})} })}
</div> </div>
+8
View File
@@ -2911,6 +2911,14 @@ pre.code {
.timeline-dot[data-action="select"], .timeline-dot[data-action="select"],
.timeline-dot[data-action="open"] { background: var(--note-ink); } .timeline-dot[data-action="open"] { background: var(--note-ink); }
.timeline-dot[data-action="close"] { background: var(--quiet); } .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 > div { min-width: 0; }
.journey-timeline li b { .journey-timeline li b {
display: inline; display: inline;
+1 -1
View File
@@ -38,7 +38,7 @@ val membyGatewayUrl: String = (project.findProperty("memby.gatewayUrl") as Strin
val membyDiagnosticLogLevel: String = (project.findProperty("memby.diagnosticLogLevel") as String?) val membyDiagnosticLogLevel: String = (project.findProperty("memby.diagnosticLogLevel") as String?)
?.trim()?.uppercase()?.takeIf { it in setOf("INFO", "DEBUG", "TRACE") } ?: "INFO" ?.trim()?.uppercase()?.takeIf { it in setOf("INFO", "DEBUG", "TRACE") } ?: "INFO"
val defaultVersionName = "0.3.31" val defaultVersionName = "0.3.32"
val membyVersionName: String = val membyVersionName: String =
(project.findProperty("memby.versionName") as String?) (project.findProperty("memby.versionName") as String?)
?.trim() ?.trim()
@@ -97,6 +97,10 @@ data class NextEpisode(
val trickplayAvailable: Boolean = false, val trickplayAvailable: Boolean = false,
val skipIntroAvailable: Boolean = false, val skipIntroAvailable: Boolean = false,
val endCreditsAvailable: 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. * to lose the credits pane with it.
*/ */
val endCreditsAvailable: Boolean = false, 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 * 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 * Studios field — see [BaseItem.streamingService]. Drives the service mark on the
@@ -2430,6 +2444,10 @@ class EmbyRepository internal constructor(
trickplayAvailable = playback.trickplayAvailable, trickplayAvailable = playback.trickplayAvailable,
skipIntroAvailable = playback.skipIntroAvailable, skipIntroAvailable = playback.skipIntroAvailable,
endCreditsAvailable = playback.endCreditsAvailable, 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, trickplayAvailable = playback.trickplayAvailable,
skipIntroAvailable = playback.skipIntroAvailable, skipIntroAvailable = playback.skipIntroAvailable,
endCreditsAvailable = playback.endCreditsAvailable, endCreditsAvailable = playback.endCreditsAvailable,
nextAiringAvailable = playback.nextAiringAvailable,
nextAiringLabel = playback.nextAiringLabel.ifBlank { null },
nextAiringDayLabel = playback.nextAiringDayLabel.ifBlank { null },
nextAiringEpisodeCode = playback.nextAiringEpisodeCode.ifBlank { null },
) )
} }
val discovery = directPlayback( val discovery = directPlayback(
@@ -2556,6 +2578,10 @@ class EmbyRepository internal constructor(
trickplayAvailable = playback.trickplayAvailable, trickplayAvailable = playback.trickplayAvailable,
skipIntroAvailable = playback.skipIntroAvailable, skipIntroAvailable = playback.skipIntroAvailable,
endCreditsAvailable = playback.endCreditsAvailable, endCreditsAvailable = playback.endCreditsAvailable,
nextAiringAvailable = playback.nextAiringAvailable,
nextAiringLabel = playback.nextAiringLabel.ifBlank { null },
nextAiringDayLabel = playback.nextAiringDayLabel.ifBlank { null },
nextAiringEpisodeCode = playback.nextAiringEpisodeCode.ifBlank { null },
streamingService = item.streamingService, streamingService = item.streamingService,
) )
} }
@@ -3146,6 +3172,10 @@ class EmbyRepository internal constructor(
response.mediaSourceId, response.playSessionId, response.playMethod, response.mediaSourceId, response.playSessionId, response.playMethod,
response.subtitleDownloadAvailable, response.trickplayAvailable, response.subtitleDownloadAvailable, response.trickplayAvailable,
response.skipIntroAvailable, response.endCreditsAvailable, 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, trickplayAvailable: Boolean,
skipIntroAvailable: Boolean, skipIntroAvailable: Boolean,
endCreditsAvailable: Boolean, endCreditsAvailable: Boolean,
nextAiringAvailable: Boolean = false,
nextAiringLabel: String? = null,
nextAiringDayLabel: String? = null,
nextAiringEpisodeCode: String? = null,
) = NextEpisode( ) = NextEpisode(
itemId = item.id, itemId = item.id,
title = item.name, title = item.name,
@@ -3233,6 +3267,10 @@ class EmbyRepository internal constructor(
trickplayAvailable = trickplayAvailable, trickplayAvailable = trickplayAvailable,
skipIntroAvailable = skipIntroAvailable, skipIntroAvailable = skipIntroAvailable,
endCreditsAvailable = endCreditsAvailable, endCreditsAvailable = endCreditsAvailable,
nextAiringAvailable = nextAiringAvailable,
nextAiringLabel = nextAiringLabel,
nextAiringDayLabel = nextAiringDayLabel,
nextAiringEpisodeCode = nextAiringEpisodeCode,
) )
/** /**
@@ -39,6 +39,7 @@ class JourneyAnalytics(
itemType: String, itemType: String,
playSessionId: String, playSessionId: String,
outcome: String, outcome: String,
positionMs: Long,
) = synchronized(lock) { ) = synchronized(lock) {
if (ended) return@synchronized if (ended) return@synchronized
buffer += GatewayJourneyEvent( buffer += GatewayJourneyEvent(
@@ -59,6 +60,7 @@ class JourneyAnalytics(
// fields it cannot read. A session id is worth less than the step it describes. // fields it cannot read. A session id is worth less than the step it describes.
playSessionId = clean(playSessionId), playSessionId = clean(playSessionId),
outcome = clean(outcome), outcome = clean(outcome),
positionMs = positionMs.coerceAtLeast(0),
occurredAt = timestamp(), occurredAt = timestamp(),
) )
if (buffer.size > MAX_BUFFERED_EVENTS) buffer.removeAt(0) if (buffer.size > MAX_BUFFERED_EVENTS) buffer.removeAt(0)
@@ -23,5 +23,6 @@ interface JourneySink {
itemType: String = "", itemType: String = "",
playSessionId: String = "", playSessionId: String = "",
outcome: String = "", outcome: String = "",
positionMs: Long = 0,
) )
} }
@@ -44,6 +44,7 @@ object JourneyTracker : JourneySink {
itemType: String, itemType: String,
playSessionId: String, playSessionId: String,
outcome: String, outcome: String,
positionMs: Long,
) { ) {
// Silently nothing when no journey is open — the screensaver can start playback in a // 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 // 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, category = category, action = action, screen = screen, feature = feature,
source = source, target = target, itemId = itemId, itemName = itemName, source = source, target = target, itemId = itemId, itemName = itemName,
itemType = itemType, playSessionId = playSessionId, outcome = outcome, itemType = itemType, playSessionId = playSessionId, outcome = outcome,
positionMs = positionMs,
) )
} }
@@ -86,6 +86,49 @@ object PlaybackJourney {
playSessionId = playSessionId, outcome = "failure", 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. * 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 // [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. // that turned the skip button off has not asked to lose the credits pane with it.
val endCreditsAvailable: Boolean = false, 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 trickplayAvailable: Boolean = false,
val skipIntroAvailable: Boolean = false, val skipIntroAvailable: Boolean = false,
val endCreditsAvailable: Boolean = false, val endCreditsAvailable: Boolean = false,
val nextAiringAvailable: Boolean = false,
val nextAiringLabel: String = "",
val nextAiringDayLabel: String = "",
val nextAiringEpisodeCode: String = "",
) )
@Serializable @Serializable
@@ -1133,6 +1144,12 @@ data class GatewayJourneyEvent(
*/ */
val playSessionId: String = "", val playSessionId: String = "",
val outcome: 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 = "", val occurredAt: String = "",
) )
@@ -233,6 +233,17 @@ internal data class DetailHeroAction(
val onClick: () -> Unit, 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. * 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(), ratings: List<MediaRating> = emptyList(),
showRatingsStrip: Boolean = true, showRatingsStrip: Boolean = true,
heroActions: List<DetailHeroAction> = emptyList(), 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, confirmation: String? = null,
onZoneFocused: (DetailZone) -> Unit = {}, onZoneFocused: (DetailZone) -> Unit = {},
footer: (@Composable () -> Unit)? = null, footer: (@Composable () -> Unit)? = null,
@@ -375,6 +391,7 @@ internal fun DetailPageScaffold(
List(6) { FocusRequester() } List(6) { FocusRequester() }
} }
val actionRequesters = allActionRequesters.take(heroActions.size) val actionRequesters = allActionRequesters.take(heroActions.size)
val backNavigationFocusRequester = remember(item.id) { FocusRequester() }
var lastHeroIndex by remember(item.id) { mutableIntStateOf(-1) } var lastHeroIndex by remember(item.id) { mutableIntStateOf(-1) }
var focusedZone by remember(item.id) { mutableStateOf(DetailZone.PLAY) } 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 // 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, playLabel = playLabel,
onPlay = onPlay, onPlay = onPlay,
playFocusRequester = playFocusRequester, playFocusRequester = playFocusRequester,
backNavigation = backNavigation,
backNavigationFocusRequester = backNavigationFocusRequester,
onNavigateDown = enterStripFromHero, onNavigateDown = enterStripFromHero,
progress = progress, progress = progress,
progressLabel = progressLabel, progressLabel = progressLabel,
@@ -595,6 +614,8 @@ private fun DetailHero(
playLabel: String, playLabel: String,
onPlay: () -> Unit, onPlay: () -> Unit,
playFocusRequester: FocusRequester, playFocusRequester: FocusRequester,
backNavigation: DetailBackNavigation?,
backNavigationFocusRequester: FocusRequester,
onNavigateDown: () -> Boolean, onNavigateDown: () -> Boolean,
progress: Float, progress: Float,
progressLabel: String?, progressLabel: String?,
@@ -842,6 +863,8 @@ private fun DetailHero(
playLabel = playLabel, playLabel = playLabel,
onPlay = onPlay, onPlay = onPlay,
playFocusRequester = playFocusRequester, playFocusRequester = playFocusRequester,
backNavigation = backNavigation,
backNavigationFocusRequester = backNavigationFocusRequester,
actions = actions, actions = actions,
actionRequesters = actionRequesters, actionRequesters = actionRequesters,
caption = actionCaption, caption = actionCaption,
@@ -849,6 +872,10 @@ private fun DetailHero(
actionCaption = null actionCaption = null
onPlayFocused() onPlayFocused()
}, },
onBackNavigationFocused = {
actionCaption = null
onPlayFocused()
},
onActionFocused = { index -> onActionFocused = { index ->
actionCaption = actions.getOrNull(index)?.description actionCaption = actions.getOrNull(index)?.description
onActionFocused(index) onActionFocused(index)
@@ -871,10 +898,13 @@ private fun DetailHeroActions(
playLabel: String, playLabel: String,
onPlay: () -> Unit, onPlay: () -> Unit,
playFocusRequester: FocusRequester, playFocusRequester: FocusRequester,
backNavigation: DetailBackNavigation?,
backNavigationFocusRequester: FocusRequester,
actions: List<DetailHeroAction>, actions: List<DetailHeroAction>,
actionRequesters: List<FocusRequester>, actionRequesters: List<FocusRequester>,
caption: String?, caption: String?,
onPlayFocused: () -> Unit, onPlayFocused: () -> Unit,
onBackNavigationFocused: () -> Unit,
onActionFocused: (Int) -> Unit, onActionFocused: (Int) -> Unit,
) { ) {
Column { Column {
@@ -883,6 +913,18 @@ private fun DetailHeroActions(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.focusGroup(), 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( MembyPlayButton(
label = playLabel, label = playLabel,
onClick = onPlay, onClick = onPlay,
@@ -346,6 +346,12 @@ internal fun FocusedDetailsOverlay(
*/ */
onOpenEmbyItem: (BaseItem) -> Unit = {}, onOpenEmbyItem: (BaseItem) -> Unit = {},
airingNotice: AiringNotice? = null, 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() val focusedItem by homeViewModel.focusedItem.collectAsStateWithLifecycle()
// Detail metadata belongs to this overlay and this item id. Launcher/Search focus is // Detail metadata belongs to this overlay and this item id. Launcher/Search focus is
@@ -440,6 +446,7 @@ internal fun FocusedDetailsOverlay(
onOpenItem = onOpenItem, onOpenItem = onOpenItem,
restorePosition = restorePosition, restorePosition = restorePosition,
airingNotice = airingNotice, airingNotice = airingNotice,
onBackToSearch = onBackToSearch,
) )
} }
} else if (item.isEpisode) { } else if (item.isEpisode) {
@@ -470,6 +477,7 @@ internal fun FocusedDetailsOverlay(
onClose = trackedOnClose, onClose = trackedOnClose,
onOpenItem = onOpenItem, onOpenItem = onOpenItem,
restorePosition = restorePosition, 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 // "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. // same series reached from Favourites or a search never claims a schedule.
var detailsAiringNotice by remember { mutableStateOf<AiringNotice?>(null) } 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) } var quickMenuItem by remember { mutableStateOf<BaseItem?>(null) }
// Whether the long-press menu may offer a trailer for the Radarr card it is open on. // 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 // 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, itemName = item.name, itemType = item.type,
) )
detailsAiringNotice = null detailsAiringNotice = null
detailsFromSearch = true
detailsItem = item detailsItem = item
} }
}, },
@@ -1156,6 +1163,7 @@ internal fun HomeScreen(
// show, carrying the air time across because that is why it was // show, carrying the air time across because that is why it was
// pressed. // pressed.
val seriesStub = scheduleSeriesStub(item) val seriesStub = scheduleSeriesStub(item)
detailsFromSearch = false
if (seriesStub != null) { if (seriesStub != null) {
detailsAiringNotice = airingNoticeFor(item) detailsAiringNotice = airingNoticeFor(item)
homeViewModel.focusItem(seriesStub) homeViewModel.focusItem(seriesStub)
@@ -1205,6 +1213,7 @@ internal fun HomeScreen(
itemName = item.name, itemType = item.type, itemName = item.name, itemType = item.type,
) )
detailsAiringNotice = null detailsAiringNotice = null
detailsFromSearch = false
detailsItem = item detailsItem = item
}, },
onContentFocused = { navigationExpanded = false }, onContentFocused = { navigationExpanded = false },
@@ -1413,9 +1422,10 @@ internal fun HomeScreen(
homeViewModel.trackJourney( homeViewModel.trackJourney(
category = "content", action = "open", screen = selectedDestination.name.lowercase(), category = "content", action = "open", screen = selectedDestination.name.lowercase(),
feature = "hero", source = HOME_HERO_ROW_ID, target = "details", 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 detailsAiringNotice = null
detailsFromSearch = false
detailsItem = item detailsItem = item
}, },
modifier = Modifier.height(metadataHeight), modifier = Modifier.height(metadataHeight),
@@ -1706,6 +1716,7 @@ internal fun HomeScreen(
// is still only Radarr's opens its own. Neither is // is still only Radarr's opens its own. Neither is
// inert, which is what the card used to be. // inert, which is what the card used to be.
val movieStub = scheduleMovieStub(item) val movieStub = scheduleMovieStub(item)
detailsFromSearch = false
if (seriesStub != null) { if (seriesStub != null) {
detailsAiringNotice = airingNoticeFor(item) detailsAiringNotice = airingNoticeFor(item)
homeViewModel.focusItem(seriesStub) homeViewModel.focusItem(seriesStub)
@@ -2211,6 +2222,21 @@ internal fun HomeScreen(
// Debug-only: on a release build both calls return before allocating. // Debug-only: on a release build both calls return before allocating.
remember(selected.id) { StartupTrace.beginSpan(StartupTrace.DETAIL) } remember(selected.id) { StartupTrace.beginSpan(StartupTrace.DETAIL) }
LaunchedEffect(selected.id) { StartupTrace.endSpan(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 { BackHandler {
val previous = detailsTrail.lastOrNull() val previous = detailsTrail.lastOrNull()
if (previous != null) { if (previous != null) {
@@ -2218,14 +2244,7 @@ internal fun HomeScreen(
restoreDetailPosition = true restoreDetailPosition = true
detailsItem = previous detailsItem = previous
} else { } else {
restoreDetailPosition = false closeDetails()
detailsItem = null
detailsAiringNotice = null
requestFirstAvailableFocus(
cardReturnFocusRequester,
contentFocusRequester,
navigationFocusRequester,
)
} }
} }
FocusedDetailsOverlay( FocusedDetailsOverlay(
@@ -2235,6 +2254,11 @@ internal fun HomeScreen(
detailExperience = detailExperience, detailExperience = detailExperience,
restorePosition = restoreDetailPosition, restorePosition = restoreDetailPosition,
airingNotice = detailsAiringNotice, 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 -> onOpenItem = { related ->
homeViewModel.trackJourney( homeViewModel.trackJourney(
category = "recommendations", action = "open", screen = "details", category = "recommendations", action = "open", screen = "details",
@@ -2335,6 +2359,7 @@ internal fun HomeScreen(
detailsTrail = emptyList() detailsTrail = emptyList()
restoreDetailPosition = false restoreDetailPosition = false
detailsAiringNotice = null detailsAiringNotice = null
detailsFromSearch = false
requestFirstAvailableFocus( requestFirstAvailableFocus(
cardReturnFocusRequester, cardReturnFocusRequester,
contentFocusRequester, contentFocusRequester,
@@ -2497,6 +2522,7 @@ internal fun HomeScreen(
.onSuccess { item -> .onSuccess { item ->
closeRequests() closeRequests()
detailsAiringNotice = null detailsAiringNotice = null
detailsFromSearch = false
detailsTrail = emptyList() detailsTrail = emptyList()
detailsItem = item detailsItem = item
} }
@@ -2664,6 +2690,7 @@ internal fun HomeScreen(
onOpenDetails = { onOpenDetails = {
quickMenuItem = null quickMenuItem = null
detailsAiringNotice = null detailsAiringNotice = null
detailsFromSearch = false
detailsItem = it detailsItem = it
}, },
onSetFavorite = homeViewModel::setFavorite, onSetFavorite = homeViewModel::setFavorite,
@@ -53,6 +53,11 @@ fun MediaDetailsOverlay(
onClose: () -> Unit, onClose: () -> Unit,
onOpenItem: (BaseItem) -> Unit = {}, onOpenItem: (BaseItem) -> Unit = {},
restorePosition: Boolean = false, 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, modifier: Modifier = Modifier,
) { ) {
val settings by ServiceLocator.repository.settingsFlow val settings by ServiceLocator.repository.settingsFlow
@@ -91,6 +96,7 @@ fun MediaDetailsOverlay(
showRatingsStrip = settings.showRatingsStrip, showRatingsStrip = settings.showRatingsStrip,
hideWatchedMovies = settings.hideWatchedMovies, hideWatchedMovies = settings.hideWatchedMovies,
restorePosition = restorePosition, restorePosition = restorePosition,
onBackToSearch = onBackToSearch,
modifier = modifier, modifier = modifier,
) )
} }
@@ -115,6 +121,7 @@ internal fun MediaDetailContent(
hideWatchedMovies: Boolean = false, hideWatchedMovies: Boolean = false,
onOpenItem: (BaseItem) -> Unit = {}, onOpenItem: (BaseItem) -> Unit = {},
restorePosition: Boolean = false, restorePosition: Boolean = false,
onBackToSearch: (() -> Unit)? = null,
) { ) {
val specs = remember(item.id, item.mediaStreams) { technicalSpecs(item) } val specs = remember(item.id, item.mediaStreams) { technicalSpecs(item) }
val credits = remember(item.id, item.people, item.genres) { creditRows(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)), ?: listOfNotNull(item.membyRecommendationReason?.takeIf(String::isNotBlank)),
ratings = ratings, ratings = ratings,
showRatingsStrip = showRatingsStrip, showRatingsStrip = showRatingsStrip,
backNavigation = onBackToSearch?.let {
DetailBackNavigation(label = "Back to Search Results", onClick = it)
},
confirmation = confirmation, confirmation = confirmation,
onZoneFocused = { zone -> onZoneFocused = { zone ->
focusedZone = zone focusedZone = zone
@@ -177,24 +177,40 @@ internal fun MembySecondaryButton(
onClick: () -> Unit, onClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
compact: Boolean = false, 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) } var focused by remember { mutableStateOf(false) }
val scale by animateFloatAsState(if (focused) 1.055f else 1f, tween(100), label = "secondary-focus") val scale by animateFloatAsState(if (focused) 1.055f else 1f, tween(100), label = "secondary-focus")
val shape = RoundedCornerShape(MembyCardCorner) val shape = RoundedCornerShape(MembyCardCorner)
Box( Row(
modifier = modifier modifier = modifier
.graphicsLayer { scaleX = scale; scaleY = scale; translationY = if (focused) -3f else 0f } .graphicsLayer { scaleX = scale; scaleY = scale; translationY = if (focused) -3f else 0f }
.clip(shape) .clip(shape)
.background(if (focused) MembyOutline else Color.Transparent) .background(if (focused) MembyOutline else Color.Transparent)
.border(if (focused) 2.dp else 1.dp, if (focused) Color.White else MembyOutline, shape) .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) .clickable(onClick = onClick)
.padding( .padding(
horizontal = if (compact) 14.dp else 23.dp, horizontal = if (compact) 14.dp else 23.dp,
vertical = if (compact) 8.dp else 13.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( Text(
label, label,
color = if (focused) Color.White else MembyMutedText, color = if (focused) Color.White else MembyMutedText,
@@ -119,6 +119,11 @@ fun SeriesDetailsOverlay(
onOpenItem: (BaseItem) -> Unit = {}, onOpenItem: (BaseItem) -> Unit = {},
restorePosition: Boolean = false, restorePosition: Boolean = false,
airingNotice: AiringNotice? = null, 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, modifier: Modifier = Modifier,
) { ) {
val repository = ServiceLocator.repository val repository = ServiceLocator.repository
@@ -214,6 +219,7 @@ fun SeriesDetailsOverlay(
onOpenItem = onOpenItem, onOpenItem = onOpenItem,
restorePosition = restorePosition, restorePosition = restorePosition,
airingNotice = airingNotice, airingNotice = airingNotice,
onBackToSearch = onBackToSearch,
modifier = modifier, modifier = modifier,
) )
} }
@@ -241,6 +247,7 @@ internal fun SeriesDetailContent(
onOpenItem: (BaseItem) -> Unit = {}, onOpenItem: (BaseItem) -> Unit = {},
restorePosition: Boolean = false, restorePosition: Boolean = false,
airingNotice: AiringNotice? = null, airingNotice: AiringNotice? = null,
onBackToSearch: (() -> Unit)? = null,
) { ) {
val remembered = remember(item.id) { detailPositions.get(item.id) } val remembered = remember(item.id) { detailPositions.get(item.id) }
val seasons = remember(episodes) { availableSeasons(episodes.orEmpty()) } val seasons = remember(episodes) { availableSeasons(episodes.orEmpty()) }
@@ -420,6 +427,9 @@ internal fun SeriesDetailContent(
confirmation = confirmation, confirmation = confirmation,
ratings = ratings, ratings = ratings,
showRatingsStrip = showRatingsStrip, showRatingsStrip = showRatingsStrip,
backNavigation = onBackToSearch?.let {
DetailBackNavigation(label = "Back to Search Results", onClick = it)
},
onZoneFocused = { zone -> onZoneFocused = { zone ->
focusedZone = zone focusedZone = zone
detailPositions.update(item.id) { it.copy(zone = zone) } detailPositions.update(item.id) { it.copy(zone = zone) }
@@ -815,8 +815,19 @@ internal fun ServicesRail(
.fillMaxWidth() .fillMaxWidth()
.focusGroup() .focusGroup()
.onKeyEvent { event -> .onKeyEvent { event ->
if (event.type != KeyEventType.KeyDown) return@onKeyEvent false if (event.type != KeyEventType.KeyDown || event.key != Key.DirectionRight) {
if (event.key == Key.DirectionRight) onEnterContent() else false 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), contentPadding = PaddingValues(start = 16.dp, end = 14.dp),
horizontalArrangement = Arrangement.spacedBy(14.dp), horizontalArrangement = Arrangement.spacedBy(14.dp),
@@ -184,6 +184,25 @@ internal fun shouldAutoAdvance(
dismissed: Boolean, dismissed: Boolean,
): Boolean = autoPlayEnabled && hasNextEpisode && !dismissed ): 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. * 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. * one. Cleared wherever [playbackStarted] is, because that is where the title changes.
*/ */
private var journeyFailureRecorded = false 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 availableSubtitles: List<PlayableSubtitle> = emptyList()
private var encodedSubtitleId: String? = null private var encodedSubtitleId: String? = null
private var remainingView: TextView? = null private var remainingView: TextView? = null
@@ -411,6 +420,7 @@ class PlayerActivity : ComponentActivity() {
private var nextUpRing: CountdownRingView? = null private var nextUpRing: CountdownRingView? = null
private var nextUpLogo: ImageView? = null private var nextUpLogo: ImageView? = null
private var nextUpSeries: TextView? = 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 * 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 * 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 creditsDismissed = false
private var creditsSpeedJob: Job? = null 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 * 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 * [creditsCeilingAfterStall]. Reset per episode, because the next file may be a
@@ -562,6 +586,13 @@ class PlayerActivity : ComponentActivity() {
private var seekBuffering = false private var seekBuffering = false
private var seekLoadingFallbackJob: Job? = null private var seekLoadingFallbackJob: Job? = null
private var playbackStartCueShown = false 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 seasonFinaleCue: View? = null
private var seasonFinaleValue: TextView? = null private var seasonFinaleValue: TextView? = null
private var seasonFinaleInfo: GatewaySeasonFinale? = null private var seasonFinaleInfo: GatewaySeasonFinale? = null
@@ -663,6 +694,14 @@ class PlayerActivity : ComponentActivity() {
?: intent.getBooleanExtra(EXTRA_SKIP_INTRO, false) ?: intent.getBooleanExtra(EXTRA_SKIP_INTRO, false)
endCreditsAvailable = savedInstanceState?.getBoolean(STATE_END_CREDITS) endCreditsAvailable = savedInstanceState?.getBoolean(STATE_END_CREDITS)
?: intent.getBooleanExtra(EXTRA_END_CREDITS, false) ?: 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 // 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 // never told this build was licensed through a service. On the request form this
// is corrected by adoptPlayable once the server settles. // is corrected by adoptPlayable once the server settles.
@@ -868,10 +907,24 @@ class PlayerActivity : ComponentActivity() {
eventName = if (isPlaying) "Unpause" else "Pause", 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() if (isPlaying) scheduleRetryBudgetReset() else stablePlaybackJob?.cancel()
updatePauseOverlay(playback) 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) { override fun onEvents(player: Player, events: Player.Events) {
updatePlaybackTiming(player) updatePlaybackTiming(player)
// A skip that landed inside what was already buffered never // A skip that landed inside what was already buffered never
@@ -1007,6 +1060,7 @@ class PlayerActivity : ComponentActivity() {
) )
setUpSubtitleOverlay() setUpSubtitleOverlay()
setUpNextUpBanner() setUpNextUpBanner()
setUpNextAiringNotice()
setUpSkipIntro() setUpSkipIntro()
setUpEndCredits() setUpEndCredits()
setUpTimeRemainingCue() setUpTimeRemainingCue()
@@ -1347,6 +1401,10 @@ class PlayerActivity : ComponentActivity() {
trickplayAvailable = playable.trickplayAvailable trickplayAvailable = playable.trickplayAvailable
skipIntroAvailable = playable.skipIntroAvailable skipIntroAvailable = playable.skipIntroAvailable
endCreditsAvailable = playable.endCreditsAvailable endCreditsAvailable = playable.endCreditsAvailable
nextAiringAvailable = playable.nextAiringAvailable
nextAiringLabel = playable.nextAiringLabel
nextAiringDayLabel = playable.nextAiringDayLabel
nextAiringEpisodeCode = playable.nextAiringEpisodeCode
streamingService = playable.streamingService streamingService = playable.streamingService
subtitleAutoSelectionAttempted = false subtitleAutoSelectionAttempted = false
initialResumePositionMs = playable.resumePositionMs.coerceAtLeast(0L) initialResumePositionMs = playable.resumePositionMs.coerceAtLeast(0L)
@@ -2648,30 +2706,56 @@ class PlayerActivity : ComponentActivity() {
} }
private fun updatePlaybackStartCue(playback: Player) { private fun updatePlaybackStartCue(playback: Player) {
if (playbackStartCueShown || !timingCueCanShow()) return if (playbackStartCueShown || timeRemainingSettleJob?.isActive == true) return
if (!timingCueCanShow()) return
val duration = playback.duration val duration = playback.duration
if (duration == C.TIME_UNSET || duration <= 0L || playback.isCurrentMediaItemLive) return if (duration == C.TIME_UNSET || duration <= 0L || playback.isCurrentMediaItemLive) return
val remainingMs = (duration - playback.currentPosition).coerceAtLeast(0L) val remainingMs = (duration - playback.currentPosition).coerceAtLeast(0L)
if (remainingMs == 0L) return if (remainingMs == 0L) return
val speed = playback.playbackParameters.speed.coerceAtLeast(0.1f) val speed = playback.playbackParameters.speed.coerceAtLeast(0.1f)
val wallClockRemainingMs = (remainingMs / speed).toLong() val wallClockRemainingMs = (remainingMs / speed).toLong()
playbackStartCueShown = true scheduleTimeRemainingCue(wallClockRemainingMs)
seasonFinaleInfo?.let(::showSeasonFinaleCue) }
if (initialResumePositionMs > 0L) {
showTimingCue( /**
label = getString(R.string.player_resume_time_left_label), * Waits for the screen to stay clear of every other Memby overlay for
value = formatCueDuration(wallClockRemainingMs), * [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
} else { * clear. Polled at the same cadence as [startPlaybackStartCueWatch] rather than driven by
val finishAt = System.currentTimeMillis() + wallClockRemainingMs * each overlay's own callback, which is what lets one function answer for all of them.
showTimingCue( *
label = getString(R.string.player_finishes_in_label), * Losing eligibility mid-wait abandons this attempt outright rather than pausing a clock:
value = getString( * [startPlaybackStartCueWatch] is still ticking and calls [updatePlaybackStartCue] again on
R.string.player_finishes_in_value, * its next tick, which starts a fresh wait once the screen is clear again.
formatCueDuration(wallClockRemainingMs), */
DateFormat.getTimeFormat(this).format(Date(finishAt)), private fun scheduleTimeRemainingCue(wallClockRemainingMs: Long) {
), timeRemainingSettleJob?.cancel()
) timeRemainingSettleJob = lifecycleScope.launch {
var settledMs = 0L
while (isActive && settledMs < TIME_REMAINING_SETTLE_MS) {
delay(PLAYBACK_START_CUE_TICK_MS)
if (!timingCueCanShow()) return@launch
settledMs += PLAYBACK_START_CUE_TICK_MS
}
if (playbackStartCueShown || !timingCueCanShow()) return@launch
playbackStartCueShown = true
seasonFinaleInfo?.let(::showSeasonFinaleCue)
if (initialResumePositionMs > 0L) {
showTimingCue(
label = getString(R.string.player_resume_time_left_label),
value = formatCueDuration(wallClockRemainingMs),
)
} else {
val finishAt = System.currentTimeMillis() + wallClockRemainingMs
showTimingCue(
label = getString(R.string.player_finishes_in_label),
value = getString(
R.string.player_finishes_in_value,
formatCueDuration(wallClockRemainingMs),
DateFormat.getTimeFormat(this@PlayerActivity).format(Date(finishAt)),
),
)
}
} }
} }
@@ -2700,7 +2784,10 @@ class PlayerActivity : ComponentActivity() {
!prerollActive && !prerollActive &&
prerollView?.isVisible != true && prerollView?.isVisible != true &&
loadingView?.isVisible != true && loadingView?.isVisible != true &&
errorView?.isVisible != true errorView?.isVisible != true &&
!transportVisible &&
!pauseHeroVisible.value &&
playbackIdentityPhase != PlaybackIdentityPhase.SHOWING
private fun showTimingCue(label: String, value: String) { private fun showTimingCue(label: String, value: String) {
val cue = timeRemainingCue ?: return val cue = timeRemainingCue ?: return
@@ -2737,6 +2824,8 @@ class PlayerActivity : ComponentActivity() {
private fun resetTimeRemainingCue() { private fun resetTimeRemainingCue() {
playbackStartCueJob?.cancel() playbackStartCueJob?.cancel()
playbackStartCueJob = null playbackStartCueJob = null
timeRemainingSettleJob?.cancel()
timeRemainingSettleJob = null
timeRemainingHideJob?.cancel() timeRemainingHideJob?.cancel()
timeRemainingHideJob = null timeRemainingHideJob = null
playbackStartCueShown = false 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 ------------------------------------------------------------------ // --- Next up ------------------------------------------------------------------
private fun setUpNextUpBanner() { private fun setUpNextUpBanner() {
@@ -3278,6 +3427,9 @@ class PlayerActivity : ComponentActivity() {
nextUpCountdown = banner.findViewById(R.id.player_next_up_countdown) nextUpCountdown = banner.findViewById(R.id.player_next_up_countdown)
nextUpLogo = banner.findViewById(R.id.player_next_up_logo) nextUpLogo = banner.findViewById(R.id.player_next_up_logo)
nextUpSeries = banner.findViewById(R.id.player_next_up_series) 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 { nextUpRing = banner.findViewById<CountdownRingView>(R.id.player_next_up_ring)?.apply {
// Fixed rather than taken from a drawable state, because nothing here is // 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 // focusable: the ring sits on somebody's programme and has to read against
@@ -3399,6 +3551,7 @@ class PlayerActivity : ComponentActivity() {
private fun recordPlaybackStartedJourney() { private fun recordPlaybackStartedJourney() {
if (!journeyRecordsPlayback) return if (!journeyRecordsPlayback) return
journeyFailureRecorded = false journeyFailureRecorded = false
journeyPauseRecorded = false
PlaybackJourney.started( PlaybackJourney.started(
sink = JourneyTracker, sink = JourneyTracker,
entryPoint = journeyEntryPoint, 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 * 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 * 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() { private fun updateNextUpFromPlayhead() {
if (advancing || playingNextEpisodePreview) return if (advancing || playingNextEpisodePreview) return
val playback = player ?: return val playback = player ?: return
val next = nextEpisode ?: return
val duration = playback.duration val duration = playback.duration
if (duration == C.TIME_UNSET || duration <= 0L) return if (duration == C.TIME_UNSET || duration <= 0L) return
val remainingMs = (duration - playback.currentPosition).coerceAtLeast(0L) 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 (remainingMs > NEXT_EPISODE_PREVIEW_LEAD_MS) previewWindowArmed = true
if (shouldStartNextEpisodePreview( if (shouldStartNextEpisodePreview(
autoPlayEnabled = autoPlayNextEpisodeEnabled, autoPlayEnabled = autoPlayNextEpisodeEnabled,
@@ -3696,6 +3890,7 @@ class PlayerActivity : ComponentActivity() {
stopProgressUploading() stopProgressUploading()
hideNextUp() hideNextUp()
hideNextAiringNotice()
if (creditsActive) leaveEndCredits(restoreSpeed = true) if (creditsActive) leaveEndCredits(restoreSpeed = true)
playbackTitle = "Next: ${nextTitle(next)}" playbackTitle = "Next: ${nextTitle(next)}"
playbackSeriesName = next.seriesName 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, * 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 * 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 * only its own action pill, so wherever one of those is up this simply stands down
* being drawn underneath it. The transport is in the list for the plainest reason: * rather than being drawn underneath it. The transport is in the list for the plainest
* it is a full-width strip along the same bottom edge, and the two would overlap. * reason: it is a full-width strip along the same bottom edge, and the two would
* overlap.
*/ */
private fun nextUpCanShow(): Boolean = private fun nextUpCanShow(): Boolean =
!prerollActive && !prerollActive &&
@@ -3878,11 +4074,16 @@ class PlayerActivity : ComponentActivity() {
.setDuration(NEXT_UP_ANIMATION_MS) .setDuration(NEXT_UP_ANIMATION_MS)
.setInterpolator(DecelerateInterpolator()) .setInterpolator(DecelerateInterpolator())
.start() .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() { private fun hideNextUp() {
val banner = nextUpBanner ?: return val banner = nextUpBanner ?: return
if (!banner.isVisible) return if (!banner.isVisible) return
val hadFocus = nextUpActionButton?.isFocused == true
banner.animate() banner.animate()
.alpha(0f) .alpha(0f)
.setDuration(NEXT_UP_ANIMATION_MS) .setDuration(NEXT_UP_ANIMATION_MS)
@@ -3891,6 +4092,9 @@ class PlayerActivity : ComponentActivity() {
banner.alpha = 1f banner.alpha = 1f
} }
.start() .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 if (creditsActive) return
creditsActive = true creditsActive = true
creditsEnteredAtMs = SystemClock.elapsedRealtime() 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 = view.findViewById<TextView>(R.id.player_end_credits_title).text =
next.title.ifBlank { next.seriesName } next.title.ifBlank { next.seriesName }
@@ -4397,6 +4605,10 @@ class PlayerActivity : ComponentActivity() {
trickplayAvailable = next.trickplayAvailable trickplayAvailable = next.trickplayAvailable
skipIntroAvailable = next.skipIntroAvailable skipIntroAvailable = next.skipIntroAvailable
endCreditsAvailable = next.endCreditsAvailable endCreditsAvailable = next.endCreditsAvailable
nextAiringAvailable = next.nextAiringAvailable
nextAiringLabel = next.nextAiringLabel
nextAiringDayLabel = next.nextAiringDayLabel
nextAiringEpisodeCode = next.nextAiringEpisodeCode
encodedSubtitleId = null encodedSubtitleId = null
stopReported = false stopReported = false
playbackStarted = false playbackStarted = false
@@ -4405,6 +4617,7 @@ class PlayerActivity : ComponentActivity() {
// to the launcher, which cannot see an advance at all. // to the launcher, which cannot see an advance at all.
journeyEntryPoint = PlaybackEntryPoint.NEXT_EPISODE journeyEntryPoint = PlaybackEntryPoint.NEXT_EPISODE
journeyFailureRecorded = false journeyFailureRecorded = false
journeyPauseRecorded = false
PlaybackJourney.requested( PlaybackJourney.requested(
sink = JourneyTracker, sink = JourneyTracker,
entryPoint = PlaybackEntryPoint.NEXT_EPISODE, 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 // a different point, and a speed left behind would run the next episode's opening
// scene at double speed. // scene at double speed.
resetEndCredits() resetEndCredits()
resetNextAiringNotice()
nextUpDismissed = false nextUpDismissed = false
requestStartedAtMs = SystemClock.elapsedRealtime() requestStartedAtMs = SystemClock.elapsedRealtime()
trace = PlaybackTrace(requestStartedAtMs, SystemClock::elapsedRealtime) trace = PlaybackTrace(requestStartedAtMs, SystemClock::elapsedRealtime)
@@ -4586,6 +4800,9 @@ class PlayerActivity : ComponentActivity() {
collapseSubtitleDownloads() collapseSubtitleDownloads()
subtitleOverlay?.isVisible == true -> hideSubtitleOverlay() subtitleOverlay?.isVisible == true -> hideSubtitleOverlay()
nextUpBanner?.isVisible == true -> dismissNextUp() 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 // Back asks for the credits back rather than leaving the film: one press
// per level, the same contract every other overlay here has. // per level, the same contract every other overlay here has.
creditsView?.isVisible == true -> dismissEndCredits() creditsView?.isVisible == true -> dismissEndCredits()
@@ -4645,10 +4862,10 @@ class PlayerActivity : ComponentActivity() {
playerView?.isControllerFullyVisible != true && playerView?.isControllerFullyVisible != true &&
!castPanelVisible.value && !castPanelVisible.value &&
subtitleOverlay?.isVisible != true && subtitleOverlay?.isVisible != true &&
// The next-up bar is deliberately absent from this list. It takes no focus and // The bar's action pill holds focus while it is up, the same reason the credits
// holds no button, so the centre key still means pause while it is up — the // pane's Play button and the skip-intro button are excluded here: the centre key
// whole point of the compact bar is that the remote goes on meaning what it // is how a remote presses whatever it is focused on.
// meant a moment before it appeared. nextUpBanner?.isVisible != true &&
// The pane's Play button holds focus while it is up, and the centre key is how a // 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. // remote presses what it is focused on.
creditsView?.isVisible != true && creditsView?.isVisible != true &&
@@ -5614,6 +5831,10 @@ class PlayerActivity : ComponentActivity() {
outState.putBoolean(STATE_TRICKPLAY, trickplayAvailable) outState.putBoolean(STATE_TRICKPLAY, trickplayAvailable)
outState.putBoolean(STATE_SKIP_INTRO, skipIntroAvailable) outState.putBoolean(STATE_SKIP_INTRO, skipIntroAvailable)
outState.putBoolean(STATE_END_CREDITS, endCreditsAvailable) 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) } streamingService?.let { outState.putString(STATE_STREAMING_SERVICE, it.name) }
outState.putString(STATE_TITLE, if (savingPreview) previewResumeTitle else playbackTitle) outState.putString(STATE_TITLE, if (savingPreview) previewResumeTitle else playbackTitle)
outState.putString(STATE_LOGO_URL, if (savingPreview) previewResumeLogoUrl else logoUrl) outState.putString(STATE_LOGO_URL, if (savingPreview) previewResumeLogoUrl else logoUrl)
@@ -5730,6 +5951,7 @@ class PlayerActivity : ComponentActivity() {
prerollScheduleJob?.cancel() prerollScheduleJob?.cancel()
disposeLocalPreroll(reuse = true) disposeLocalPreroll(reuse = true)
playbackStartCueJob?.cancel() playbackStartCueJob?.cancel()
timeRemainingSettleJob?.cancel()
timeRemainingHideJob?.cancel() timeRemainingHideJob?.cancel()
seekCommitJob?.cancel() seekCommitJob?.cancel()
seekHideJob?.cancel() seekHideJob?.cancel()
@@ -5965,6 +6187,10 @@ class PlayerActivity : ComponentActivity() {
private const val EXTRA_TRICKPLAY = "extra_trickplay_available" private const val EXTRA_TRICKPLAY = "extra_trickplay_available"
private const val EXTRA_SKIP_INTRO = "extra_skip_intro_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_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_STREAMING_SERVICE = "extra_streaming_service"
private const val EXTRA_MEDIA_SOURCE_ID = "extra_media_source_id" private const val EXTRA_MEDIA_SOURCE_ID = "extra_media_source_id"
private const val EXTRA_PLAY_SESSION_ID = "extra_play_session_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_TRICKPLAY = "state_trickplay"
private const val STATE_SKIP_INTRO = "state_skip_intro" private const val STATE_SKIP_INTRO = "state_skip_intro"
private const val STATE_END_CREDITS = "state_end_credits" 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_STREAMING_SERVICE = "state_streaming_service"
private const val STATE_TITLE = "state_title" private const val STATE_TITLE = "state_title"
private const val STATE_LOGO_URL = "state_logo_url" private const val STATE_LOGO_URL = "state_logo_url"
@@ -6104,6 +6334,10 @@ class PlayerActivity : ComponentActivity() {
trickplayAvailable = playable.trickplayAvailable, trickplayAvailable = playable.trickplayAvailable,
skipIntroAvailable = playable.skipIntroAvailable, skipIntroAvailable = playable.skipIntroAvailable,
endCreditsAvailable = playable.endCreditsAvailable, endCreditsAvailable = playable.endCreditsAvailable,
nextAiringAvailable = playable.nextAiringAvailable,
nextAiringLabel = playable.nextAiringLabel,
nextAiringDayLabel = playable.nextAiringDayLabel,
nextAiringEpisodeCode = playable.nextAiringEpisodeCode,
streamingService = playable.streamingService, streamingService = playable.streamingService,
mediaSourceId = playable.mediaSourceId, mediaSourceId = playable.mediaSourceId,
playSessionId = playable.playSessionId, playSessionId = playable.playSessionId,
@@ -6134,6 +6368,10 @@ class PlayerActivity : ComponentActivity() {
trickplayAvailable: Boolean = false, trickplayAvailable: Boolean = false,
skipIntroAvailable: Boolean = false, skipIntroAvailable: Boolean = false,
endCreditsAvailable: Boolean = false, endCreditsAvailable: Boolean = false,
nextAiringAvailable: Boolean = false,
nextAiringLabel: String? = null,
nextAiringDayLabel: String? = null,
nextAiringEpisodeCode: String? = null,
streamingService: StreamingService? = null, streamingService: StreamingService? = null,
mediaSourceId: String = "", mediaSourceId: String = "",
playSessionId: String = "", playSessionId: String = "",
@@ -6163,6 +6401,12 @@ class PlayerActivity : ComponentActivity() {
putExtra(EXTRA_TRICKPLAY, trickplayAvailable) putExtra(EXTRA_TRICKPLAY, trickplayAvailable)
putExtra(EXTRA_SKIP_INTRO, skipIntroAvailable) putExtra(EXTRA_SKIP_INTRO, skipIntroAvailable)
putExtra(EXTRA_END_CREDITS, endCreditsAvailable) 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) } streamingService?.let { putExtra(EXTRA_STREAMING_SERVICE, it.name) }
putExtra(EXTRA_MEDIA_SOURCE_ID, mediaSourceId) putExtra(EXTRA_MEDIA_SOURCE_ID, mediaSourceId)
putExtra(EXTRA_PLAY_SESSION_ID, playSessionId) 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 RESUME_TIME_LEFT_CUE_DELAY_MS = 2_000L
private const val PLAYBACK_START_CUE_TICK_MS = 250L private const val PLAYBACK_START_CUE_TICK_MS = 250L
private const val TIME_REMAINING_THRESHOLD_MS = 10L * 60_000L 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_VISIBLE_MS = 6_000L
private const val TIME_REMAINING_ANIMATION_MS = 240L private const val TIME_REMAINING_ANIMATION_MS = 240L
private const val TIME_REMAINING_TRAVEL_DP = 12 private const val TIME_REMAINING_TRAVEL_DP = 12
@@ -6286,6 +6536,14 @@ class PlayerActivity : ComponentActivity() {
private const val MAGIC_MEMORY = 8 private const val MAGIC_MEMORY = 8
private const val NEXT_UP_TICK_MS = 250L private const val NEXT_UP_TICK_MS = 250L
private const val NEXT_EPISODE_PREVIEW_LEAD_MS = 120_000L 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_EPISODE_PREVIEW_STARTUP_TIMEOUT_MS = 8_000L
private const val NEXT_UP_ANIMATION_MS = 260L private const val NEXT_UP_ANIMATION_MS = 260L
@@ -44,6 +44,11 @@
above it. Both remain non-focusable lower thirds. --> above it. Both remain non-focusable lower thirds. -->
<include layout="@layout/player_season_finale" /> <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 <!-- Above the video, below the loading overlay: a slide that is still starting has
nothing to say about what comes next. --> nothing to say about what comes next. -->
<include layout="@layout/player_next_up_banner" /> <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 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 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 overlay was covering them. Only the action pill at the end is focusable — the rest of
remote's world, so the transport, the seek keys and the centre button all keep meaning the bar takes no part in the remote's world, so the transport and the seek keys keep
what they meant a moment before it appeared. Play now is the transport's own Next meaning what they meant a moment before it appeared while nothing has been pressed. The
Episode button, which is offered whenever this bar is, and Back dismisses. 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 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. --> 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_width="38dp"
android:layout_height="38dp" android:layout_height="38dp"
android:layout_marginStart="20dp" /> 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> </LinearLayout>
</FrameLayout> </FrameLayout>
+8
View File
@@ -96,6 +96,14 @@
a clock value over a minute, a bare count of seconds under one. Two figures on one 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. --> 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> <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 <!-- 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, credits rather than as dismissing a panel: it is the only thing this button does,
and somebody pressing it is asking to watch them. --> and somebody pressing it is asking to watch them. -->
@@ -129,6 +129,43 @@ class PlaybackJourneyTest {
assertEquals("success", events.step("playback", "start").outcome) 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 @Test
fun `a failure is the same step wearing the other outcome`() { fun `a failure is the same step wearing the other outcome`() {
val journey = collector() val journey = collector()
@@ -255,4 +255,68 @@ class NextUpPipelineTest {
assertNull(resolver.current) assertNull(resolver.current)
assertFalse(resolver.resolving) 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,
),
)
}
} }
+21 -2
View File
@@ -20,6 +20,11 @@ const maxAnalyticsBatch = 200
// nothing about what anyone was looking at. // nothing about what anyone was looking at.
const maxDwellMs = 30 * 60 * 1000 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 { type rowEventPayload struct {
RowID string `json:"rowId"` RowID string `json:"rowId"`
RowKind string `json:"rowKind"` RowKind string `json:"rowKind"`
@@ -52,6 +57,9 @@ type journeyEventPayload struct {
PlaySessionID string `json:"playSessionId"` PlaySessionID string `json:"playSessionId"`
Outcome string `json:"outcome"` Outcome string `json:"outcome"`
OccurredAt string `json:"occurredAt"` 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 { type journeyAnalyticsRequest struct {
@@ -63,7 +71,7 @@ var journeyActions = allowedAnalyticsValues(
"journey_start", "home_open", "journey_end", "screen_view", "open", "close", "select", "journey_start", "home_open", "journey_end", "screen_view", "open", "close", "select",
"submit", "request", "start", "stop", "complete", "abandon", "change", "submit", "request", "start", "stop", "complete", "abandon", "change",
"toggle", "follow", "unfollow", "favourite", "unfavourite", "mark_played", "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") 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, Screen: payload.Screen, Feature: payload.Feature, Source: payload.Source,
Target: payload.Target, ItemID: payload.ItemID, Target: payload.Target, ItemID: payload.ItemID,
ItemName: strings.TrimSpace(payload.ItemName), ItemType: payload.ItemType, 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 { func safeAnalyticsValue(value string, max int) bool {
+42
View File
@@ -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) { func TestJourneyEventRetainsTheItemName(t *testing.T) {
now := time.Now().UTC() now := time.Now().UTC()
payload := journeyEventPayload{UserID: "u1", JourneyID: "j1", Category: "content", payload := journeyEventPayload{UserID: "u1", JourneyID: "j1", Category: "content",
+8
View File
@@ -23,6 +23,7 @@ const (
featureTrickplay = "trickplay" featureTrickplay = "trickplay"
featureSkipIntro = "skip_intro" featureSkipIntro = "skip_intro"
featureEndCredits = "end_credits" featureEndCredits = "end_credits"
featureNextEpisodeAiring = "next_episode_airing"
featureSeasonalThemes = "seasonal_themes" featureSeasonalThemes = "seasonal_themes"
featureSeasonalDecorations = "seasonal_decorations" featureSeasonalDecorations = "seasonal_decorations"
featureGenreBrowser = "genre_browser" featureGenreBrowser = "genre_browser"
@@ -251,6 +252,13 @@ var featureCatalogue = []featureDefinition{
DefaultEnabled: true, MinimumProtocol: 1, Capability: "end_credits_v1", DefaultEnabled: true, MinimumProtocol: 1, Capability: "end_credits_v1",
Recovery: "Takes effect the next time playback starts; the credits simply play out full size.", 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 // 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 // operator's rather than the viewer's: a per-person opt-out is a thing somebody
+54 -9
View File
@@ -62,6 +62,15 @@ type playbackResponse struct {
// separate features with separate switches, and a house that has turned the skip button // 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. // off has not asked to lose the credits pane with it.
EndCreditsAvailable bool `json:"endCreditsAvailable"` 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 { type playableSubtitle struct {
@@ -114,6 +123,10 @@ type nextEpisodeResponse struct {
TrickplayAvailable bool `json:"trickplayAvailable"` TrickplayAvailable bool `json:"trickplayAvailable"`
SkipIntroAvailable bool `json:"skipIntroAvailable"` SkipIntroAvailable bool `json:"skipIntroAvailable"`
EndCreditsAvailable bool `json:"endCreditsAvailable"` 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. // 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), "negotiation_duration", time.Since(negotiationStarted).Round(time.Millisecond),
) )
nextAiringAvailable, nextAiringLabel, nextAiringDayLabel, nextAiringCode :=
s.nextAiringFieldsFor(ctx, target)
writeJSON(w, http.StatusOK, playbackResponse{ writeJSON(w, http.StatusOK, playbackResponse{
ItemID: target.ID, ItemID: target.ID,
Title: title, Title: title,
@@ -257,6 +272,10 @@ func (s *Server) handlePlayback(w http.ResponseWriter, r *http.Request, sess sto
TrickplayAvailable: s.trickplayEnabled(ctx), TrickplayAvailable: s.trickplayEnabled(ctx),
SkipIntroAvailable: s.skipIntroEnabled(ctx), SkipIntroAvailable: s.skipIntroEnabled(ctx),
EndCreditsAvailable: s.endCreditsEnabled(ctx), EndCreditsAvailable: s.endCreditsEnabled(ctx),
NextAiringAvailable: nextAiringAvailable,
NextAiringLabel: nextAiringLabel,
NextAiringDayLabel: nextAiringDayLabel,
NextAiringEpisodeCode: nextAiringCode,
}) })
} }
@@ -336,6 +355,22 @@ func (s *Server) firstPlayableEpisode(
return &summary, nil 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 { func episodeCode(item emby.Summary) string {
if !strings.EqualFold(item.Type, "Episode") || item.ParentIndexNumber < 0 || item.IndexNumber <= 0 { if !strings.EqualFold(item.Type, "Episode") || item.ParentIndexNumber < 0 || item.IndexNumber <= 0 {
return "" return ""
@@ -435,20 +470,26 @@ func (s *Server) handleNextEpisode(w http.ResponseWriter, r *http.Request, sess
// The metadata-only shape. Everything below this point is a PlaybackInfo negotiation, // 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. // 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) { if !nextEpisodeWantsStream(r) {
s.playbackTitles.remember(next.ID, title) s.playbackTitles.remember(next.ID, title)
s.loggerFor(ctx).Debug("next episode identified", s.loggerFor(ctx).Debug("next episode identified",
"title", title, "item", next.ID, "after_item", itemID) "title", title, "item", next.ID, "after_item", itemID)
writeJSON(w, http.StatusOK, nextEpisodeResponse{ writeJSON(w, http.StatusOK, nextEpisodeResponse{
Item: raw, Item: raw,
Title: title, Title: title,
StreamReady: false, StreamReady: false,
ResumePositionMs: max64(next.UserData.PlaybackPositionTicks/ticksPerMillisecond, 0), ResumePositionMs: max64(next.UserData.PlaybackPositionTicks/ticksPerMillisecond, 0),
Subtitles: []playableSubtitle{}, Subtitles: []playableSubtitle{},
SubtitlesEnabled: true, SubtitlesEnabled: true,
TrickplayAvailable: s.trickplayEnabled(ctx), TrickplayAvailable: s.trickplayEnabled(ctx),
SkipIntroAvailable: s.skipIntroEnabled(ctx), SkipIntroAvailable: s.skipIntroEnabled(ctx),
EndCreditsAvailable: s.endCreditsEnabled(ctx), EndCreditsAvailable: s.endCreditsEnabled(ctx),
NextAiringAvailable: nextAiringAvailable,
NextAiringLabel: nextAiringLabel,
NextAiringDayLabel: nextAiringDayLabel,
NextAiringEpisodeCode: nextAiringCode,
}) })
return return
} }
@@ -491,6 +532,10 @@ func (s *Server) handleNextEpisode(w http.ResponseWriter, r *http.Request, sess
TrickplayAvailable: s.trickplayEnabled(ctx), TrickplayAvailable: s.trickplayEnabled(ctx),
SkipIntroAvailable: s.skipIntroEnabled(ctx), SkipIntroAvailable: s.skipIntroEnabled(ctx),
EndCreditsAvailable: s.endCreditsEnabled(ctx), EndCreditsAvailable: s.endCreditsEnabled(ctx),
NextAiringAvailable: nextAiringAvailable,
NextAiringLabel: nextAiringLabel,
NextAiringDayLabel: nextAiringDayLabel,
NextAiringEpisodeCode: nextAiringCode,
}) })
} }
+147
View File
@@ -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 { func localDayStart(value time.Time, location *time.Location) time.Time {
value = value.In(location) value = value.In(location)
return time.Date(value.Year(), value.Month(), value.Day(), 0, 0, 0, 0, location) return time.Date(value.Year(), value.Month(), value.Day(), 0, 0, 0, 0, location)
+54
View File
@@ -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
View File
@@ -1 +1 @@
0.1.75 0.1.76
+10 -5
View File
@@ -81,6 +81,10 @@ type JourneyEvent struct {
// the two records having to agree on anything but this. // the two records having to agree on anything but this.
PlaySessionID string `json:"playSessionId,omitempty"` PlaySessionID string `json:"playSessionId,omitempty"`
Outcome string `json:"outcome,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 { type AnalyticsUser struct {
@@ -336,13 +340,14 @@ func (s *Store) InsertJourneyEvents(ctx context.Context, events []JourneyEvent)
batch.Queue(` batch.Queue(`
INSERT INTO journey_events INSERT INTO journey_events
(occurred_at, emby_user_id, journey_id, sequence, category, action, screen, (occurred_at, emby_user_id, journey_id, sequence, category, action, screen,
feature, source, target, item_id, item_name, item_type, play_session_id, outcome) 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) 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`, ON CONFLICT (emby_user_id, journey_id, sequence) DO NOTHING`,
event.OccurredAt, event.UserID, event.JourneyID, event.Sequence, event.OccurredAt, event.UserID, event.JourneyID, event.Sequence,
event.Category, event.Action, event.Screen, event.Feature, event.Source, event.Category, event.Action, event.Screen, event.Feature, event.Source,
event.Target, event.ItemID, event.ItemName, event.ItemType, event.Target, event.ItemID, event.ItemName, event.ItemType,
event.PlaySessionID, event.Outcome) event.PlaySessionID, event.Outcome, event.PositionMs)
} }
results := s.pool.SendBatch(ctx, batch) results := s.pool.SendBatch(ctx, batch)
defer results.Close() defer results.Close()
@@ -564,7 +569,7 @@ func (s *Store) UserJourneyEvents(ctx context.Context, userID string, since time
rows, err := s.pool.Query(ctx, ` rows, err := s.pool.Query(ctx, `
SELECT id, occurred_at, emby_user_id, journey_id, sequence, category, action, SELECT id, occurred_at, emby_user_id, journey_id, sequence, category, action,
screen, feature, source, target, item_id, item_name, item_type, 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 FROM journey_events WHERE emby_user_id=$1 AND occurred_at >= $2
ORDER BY occurred_at DESC, sequence DESC LIMIT $3`, userID, since, limit) ORDER BY occurred_at DESC, sequence DESC LIMIT $3`, userID, since, limit)
if err != nil { if err != nil {
@@ -574,7 +579,7 @@ func (s *Store) UserJourneyEvents(ctx context.Context, userID string, since time
out := []JourneyEvent{} out := []JourneyEvent{}
for rows.Next() { for rows.Next() {
var v JourneyEvent 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 return nil, err
} }
out = append(out, v) out = append(out, v)
+4
View File
@@ -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, -- 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. -- 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 ''; 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 CREATE UNIQUE INDEX IF NOT EXISTS journey_events_journey_sequence_idx
ON journey_events (emby_user_id, journey_id, sequence); ON journey_events (emby_user_id, journey_id, sequence);