0.3.32
This commit is contained in:
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -250,3 +250,57 @@ func TestBuildSonarrRowCoversFiveDaysAndUsesRelativeAirLabels(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestResolveNextAiringOnlyAnswersForContinuingShowsWithAFutureAiring(t *testing.T) {
|
||||||
|
location := time.UTC
|
||||||
|
now := time.Date(2026, 8, 26, 12, 0, 0, 0, location)
|
||||||
|
tomorrow := now.AddDate(0, 0, 1)
|
||||||
|
yesterday := now.AddDate(0, 0, -1)
|
||||||
|
|
||||||
|
catalogue := []sonarr.Series{
|
||||||
|
{Title: "Continuing Show", Status: "continuing", NextAiring: &tomorrow},
|
||||||
|
{Title: "Ended Show", Status: "ended", NextAiring: &tomorrow},
|
||||||
|
{Title: "Cancelled Show", Status: "cancelled", NextAiring: &tomorrow},
|
||||||
|
{Title: "No Schedule Show", Status: "continuing", NextAiring: nil},
|
||||||
|
{Title: "Already Aired Show", Status: "continuing", NextAiring: &yesterday},
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, info, ok := resolveNextAiring(catalogue, "Continuing Show", now, location); !ok {
|
||||||
|
t.Fatal("expected a continuing show with a future airing to resolve")
|
||||||
|
} else if info.DayLabel != "Tomorrow" {
|
||||||
|
t.Fatalf("day label = %q, want Tomorrow", info.DayLabel)
|
||||||
|
}
|
||||||
|
|
||||||
|
cases := []string{
|
||||||
|
"Ended Show", "Cancelled Show", "No Schedule Show", "Already Aired Show", "Unknown Show",
|
||||||
|
}
|
||||||
|
for _, name := range cases {
|
||||||
|
if _, _, ok := resolveNextAiring(catalogue, name, now, location); ok {
|
||||||
|
t.Fatalf("%q should not have produced a next-airing answer", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, _, ok := resolveNextAiring(catalogue, " ", now, location); ok {
|
||||||
|
t.Fatal("a blank series name should never resolve")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveNextAiringWordingMatchesTheScheduleRow(t *testing.T) {
|
||||||
|
location := time.FixedZone("NZST", 12*60*60)
|
||||||
|
now := time.Date(2026, 7, 27, 8, 0, 0, 0, location)
|
||||||
|
air := now.Add(8 * time.Hour)
|
||||||
|
catalogue := []sonarr.Series{
|
||||||
|
{Title: "Northbound", Status: "continuing", NextAiring: &air},
|
||||||
|
}
|
||||||
|
|
||||||
|
_, info, ok := resolveNextAiring(catalogue, "Northbound", now, location)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("expected a resolved answer")
|
||||||
|
}
|
||||||
|
wantLabel := scheduleAirLabel(air, now, location)
|
||||||
|
wantDayLabel := scheduleAirDayLabel(air, now, location)
|
||||||
|
if info.Label != wantLabel || info.DayLabel != wantDayLabel {
|
||||||
|
t.Fatalf("wording drifted from the schedule row: got %+v, want label=%q day=%q",
|
||||||
|
info, wantLabel, wantDayLabel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
0.1.75
|
0.1.76
|
||||||
|
|||||||
@@ -81,6 +81,10 @@ type JourneyEvent struct {
|
|||||||
// the two records having to agree on anything but this.
|
// 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)
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user