From 3d42c98947a6c00e7cc336320981c79ead171b05 Mon Sep 17 00:00:00 2001 From: ponzischeme89 Date: Wed, 26 Aug 2026 21:31:05 +1200 Subject: [PATCH] 0.3.32 --- admin-ui/src/pages/JourneyViewer.tsx | 73 +++- admin-ui/src/styles.css | 8 + app/build.gradle.kts | 2 +- .../memby/data/EmbyRepository.kt | 38 +++ .../memby/data/analytics/JourneyAnalytics.kt | 2 + .../memby/data/analytics/JourneySink.kt | 1 + .../memby/data/analytics/JourneyTracker.kt | 2 + .../memby/data/analytics/PlaybackJourney.kt | 43 +++ .../memby/data/model/GatewayModels.kt | 17 + .../memby/ui/DetailPageComponents.kt | 42 +++ .../ponzischeme89/memby/ui/HomeOverlays.kt | 8 + .../com/ponzischeme89/memby/ui/HomeScreen.kt | 45 ++- .../memby/ui/MediaDetailsOverlay.kt | 10 + .../ponzischeme89/memby/ui/MembyButtons.kt | 22 +- .../memby/ui/SeriesDetailsOverlay.kt | 10 + .../memby/ui/genre/GenreBrowseScreen.kt | 15 +- .../memby/ui/player/NextUpPipeline.kt | 19 ++ .../memby/ui/player/PlayerActivity.kt | 314 ++++++++++++++++-- app/src/main/res/layout/activity_player.xml | 5 + .../res/layout/player_next_airing_notice.xml | 55 +++ .../main/res/layout/player_next_up_banner.xml | 43 ++- app/src/main/res/values/strings.xml | 8 + .../data/analytics/PlaybackJourneyTest.kt | 37 +++ .../memby/ui/player/NextUpPipelineTest.kt | 64 ++++ server/internal/api/analytics.go | 23 +- server/internal/api/analytics_test.go | 42 +++ server/internal/api/features.go | 8 + server/internal/api/playback.go | 63 +++- server/internal/api/sonarr.go | 147 ++++++++ server/internal/api/sonarr_test.go | 54 +++ server/internal/buildinfo/VERSION | 2 +- server/internal/store/analytics.go | 15 +- server/internal/store/schema.sql | 4 + 33 files changed, 1172 insertions(+), 69 deletions(-) create mode 100644 app/src/main/res/layout/player_next_airing_notice.xml diff --git a/admin-ui/src/pages/JourneyViewer.tsx b/admin-ui/src/pages/JourneyViewer.tsx index 777358f..2776de3 100644 --- a/admin-ui/src/pages/JourneyViewer.tsx +++ b/admin-ui/src/pages/JourneyViewer.tsx @@ -21,6 +21,7 @@ interface JourneyEvent { itemType?: string; playSessionId?: string; outcome?: string; + positionMs?: number; } interface JourneyResponse { @@ -43,6 +44,36 @@ const label = (value: string | undefined | null) => { const place = (event: JourneyEvent | undefined) => label(event?.target || event?.screen || event?.source || event?.feature); +/* "15m 22s" / "1h 04m" — where playback was, for a pause or resume step. Seconds are + * dropped past an hour; a viewing session that long has no use for that precision. */ +const formatPosition = (ms: number | undefined) => { + const totalSeconds = Math.max(0, Math.round((ms ?? 0) / 1000)); + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = totalSeconds % 60; + if (hours > 0) return `${hours}h ${String(minutes).padStart(2, '0')}m`; + if (minutes > 0) return `${minutes}m ${String(seconds).padStart(2, '0')}s`; + return `${seconds}s`; +}; + +/* "4m" / "48s" — how long a pause lasted, from the gap between its timestamp and the + * matching resume's. Coarser than formatPosition: nobody needs their pause timed to the + * second, only roughly how long they were away. */ +const formatDuration = (ms: number) => { + const totalSeconds = Math.max(0, Math.round(ms / 1000)); + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + if (hours > 0) return `${hours}h ${minutes}m`; + if (minutes > 0) return `${minutes}m`; + return `${totalSeconds}s`; +}; + +/* A Hero is a featured card, not a poster in a shelf — the same "content"/"open" step a + * poster press writes, wearing `feature: "hero"` instead of the shelf's own kind. Nothing + * else in the event says that: `source` is still the hero's row id, which reads exactly + * like any other row id next to it. This is the one place that distinction is read back. */ +const isHeroOrigin = (event: JourneyEvent | undefined) => event?.feature === 'hero'; + /* Where a viewing journey began. * * The journey is cut at the playback request, so the first event's `target` is "player" for @@ -54,9 +85,14 @@ const entryPoint = (events: JourneyEvent[]) => { const request = events.find((event) => event.category === 'playback' && event.action === 'request'); return request?.source ? label(request.source) : place(events[0]); }; -const detail = (event: JourneyEvent) => event.itemName - ? `${label(event.itemType)} · ${event.itemName}` - : event.source && event.target ? `${label(event.source)} → ${label(event.target)}` : place(event); +const detail = (event: JourneyEvent) => { + const base = event.itemName + ? `${label(event.itemType)} · ${event.itemName}` + : event.source && event.target ? `${label(event.source)} → ${label(event.target)}` : place(event); + // The page/section the Hero was shown on rides `screen` already, on every event — this + // is the one place it is worth restating, since it is what says *which* Hero this was. + return isHeroOrigin(event) ? `${base} · from the Hero${event.screen ? ` on ${label(event.screen)}` : ''}` : base; +}; const verb = (event: JourneyEvent) => ({ journey_start: 'Opened Memby', home_open: 'Opened Memby', journey_end: 'Finished session', screen_view: 'Viewed', select: 'Selected', open: 'Opened', close: 'Closed', @@ -68,8 +104,34 @@ const verb = (event: JourneyEvent) => ({ complete: event.category === 'playback' ? (event.outcome === 'completed' ? 'Finished watching' : 'Stopped watching') : 'Completed', + pause: 'Paused', + resume: 'Resumed', }[event.action] ?? label(event.action)); +/* Pause and resume replace the ordinary itemName/place detail with where playback was and, + * for a resume, how long the pause it closes lasted — computed from the two steps' + * timestamps rather than sent as a field of its own, so it can never disagree with them. + * Matched by playSessionId and position within the sequence: a pause is "open" until the + * next resume carrying the same stream closes it, which is what keeps a second pause/resume + * pair in the same viewing journey from pairing across each other. */ +function playbackDetail(events: JourneyEvent[]) { + const detailByStep = new Map(); + const openPauseAt = new Map(); + for (const event of events) { + if (event.category !== 'playback') continue; + if (event.action === 'pause') { + openPauseAt.set(event.playSessionId ?? '', event.occurredAt); + detailByStep.set(event, `at ${formatPosition(event.positionMs)}`); + } else if (event.action === 'resume') { + const pausedAt = openPauseAt.get(event.playSessionId ?? ''); + openPauseAt.delete(event.playSessionId ?? ''); + const gapMs = pausedAt ? new Date(event.occurredAt).getTime() - new Date(pausedAt).getTime() : NaN; + detailByStep.set(event, Number.isFinite(gapMs) && gapMs >= 0 ? `after ${formatDuration(gapMs)}` : `at ${formatPosition(event.positionMs)}`); + } + } + return detailByStep; +} + function outcome(events: JourneyEvent[]) { /* A playback step is the verdict on a viewing journey, so it outranks whatever incidental * outcome a favourite toggle or a settings change left behind on the way in. */ @@ -131,14 +193,15 @@ export function JourneyViewerPage() { const entry = events[0]; const selection = [...events].reverse().find((event) => event.itemName || event.action === 'select' || (event.category === 'playback' && event.action === 'request')); const result = outcome(events); + const pauseResumeDetail = playbackDetail(events); return
{when(entry?.occurredAt)}Journey {index + 1} · {events.length} recorded steps
{result.label}
Entered from{entryPoint(events)}
-
Selected{selection ? detail(selection) : 'Nothing selected'}
+
Selected{selection ? detail(selection) : 'Nothing selected'}
Outcome{result.label}
-
    {events.map((event) =>
  1. {verb(event)}{detail(event)}
  2. )}
+
    {events.map((event) =>
  1. {verb(event)}{pauseResumeDetail.get(event) ?? detail(event)}
  2. )}
; })} diff --git a/admin-ui/src/styles.css b/admin-ui/src/styles.css index 5309257..9c6ef74 100644 --- a/admin-ui/src/styles.css +++ b/admin-ui/src/styles.css @@ -2911,6 +2911,14 @@ pre.code { .timeline-dot[data-action="select"], .timeline-dot[data-action="open"] { background: var(--note-ink); } .timeline-dot[data-action="close"] { background: var(--quiet); } +/* Paused is amber (look at this — playback stopped); resumed keeps the default accent, the + same "still going" colour a step that moves the journey forward already wears. */ +.timeline-dot[data-action="pause"] { background: var(--warn-ink); } +/* A step that came from the Hero rather than a poster in a shelf — same dot position, + a ring around it so it reads at a glance without having to read the step's text. */ +.timeline-dot[data-hero] { + box-shadow: 0 0 0 2px var(--note-wash), 0 0 0 3px var(--note-ink); +} .journey-timeline li > div { min-width: 0; } .journey-timeline li b { display: inline; diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 275890c..fc46275 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -38,7 +38,7 @@ val membyGatewayUrl: String = (project.findProperty("memby.gatewayUrl") as Strin val membyDiagnosticLogLevel: String = (project.findProperty("memby.diagnosticLogLevel") as String?) ?.trim()?.uppercase()?.takeIf { it in setOf("INFO", "DEBUG", "TRACE") } ?: "INFO" -val defaultVersionName = "0.3.31" +val defaultVersionName = "0.3.32" val membyVersionName: String = (project.findProperty("memby.versionName") as String?) ?.trim() diff --git a/app/src/main/java/com/ponzischeme89/memby/data/EmbyRepository.kt b/app/src/main/java/com/ponzischeme89/memby/data/EmbyRepository.kt index 8353c85..643ff76 100644 --- a/app/src/main/java/com/ponzischeme89/memby/data/EmbyRepository.kt +++ b/app/src/main/java/com/ponzischeme89/memby/data/EmbyRepository.kt @@ -97,6 +97,10 @@ data class NextEpisode( val trickplayAvailable: Boolean = false, val skipIntroAvailable: Boolean = false, val endCreditsAvailable: Boolean = false, + val nextAiringAvailable: Boolean = false, + val nextAiringLabel: String? = null, + val nextAiringDayLabel: String? = null, + val nextAiringEpisodeCode: String? = null, ) /** @@ -173,6 +177,16 @@ data class Playable( * to lose the credits pane with it. */ val endCreditsAvailable: Boolean = false, + /** + * Whether Sonarr can name when this continuing show's next episode airs. Only the + * gateway can answer — the direct path has nobody to ask, so this stays false there — + * and it stays false when the operator has switched the notice off or Sonarr has + * nothing scheduled, so the player never shows a notice with nothing to say. + */ + val nextAiringAvailable: Boolean = false, + val nextAiringLabel: String? = null, + val nextAiringDayLabel: String? = null, + val nextAiringEpisodeCode: String? = null, /** * Which streaming service, if any, this title is licensed through, by Emby's own * Studios field — see [BaseItem.streamingService]. Drives the service mark on the @@ -2430,6 +2444,10 @@ class EmbyRepository internal constructor( trickplayAvailable = playback.trickplayAvailable, skipIntroAvailable = playback.skipIntroAvailable, endCreditsAvailable = playback.endCreditsAvailable, + nextAiringAvailable = playback.nextAiringAvailable, + nextAiringLabel = playback.nextAiringLabel.ifBlank { null }, + nextAiringDayLabel = playback.nextAiringDayLabel.ifBlank { null }, + nextAiringEpisodeCode = playback.nextAiringEpisodeCode.ifBlank { null }, ) } @@ -2466,6 +2484,10 @@ class EmbyRepository internal constructor( trickplayAvailable = playback.trickplayAvailable, skipIntroAvailable = playback.skipIntroAvailable, endCreditsAvailable = playback.endCreditsAvailable, + nextAiringAvailable = playback.nextAiringAvailable, + nextAiringLabel = playback.nextAiringLabel.ifBlank { null }, + nextAiringDayLabel = playback.nextAiringDayLabel.ifBlank { null }, + nextAiringEpisodeCode = playback.nextAiringEpisodeCode.ifBlank { null }, ) } val discovery = directPlayback( @@ -2556,6 +2578,10 @@ class EmbyRepository internal constructor( trickplayAvailable = playback.trickplayAvailable, skipIntroAvailable = playback.skipIntroAvailable, endCreditsAvailable = playback.endCreditsAvailable, + nextAiringAvailable = playback.nextAiringAvailable, + nextAiringLabel = playback.nextAiringLabel.ifBlank { null }, + nextAiringDayLabel = playback.nextAiringDayLabel.ifBlank { null }, + nextAiringEpisodeCode = playback.nextAiringEpisodeCode.ifBlank { null }, streamingService = item.streamingService, ) } @@ -3146,6 +3172,10 @@ class EmbyRepository internal constructor( response.mediaSourceId, response.playSessionId, response.playMethod, response.subtitleDownloadAvailable, response.trickplayAvailable, response.skipIntroAvailable, response.endCreditsAvailable, + response.nextAiringAvailable, + response.nextAiringLabel.ifBlank { null }, + response.nextAiringDayLabel.ifBlank { null }, + response.nextAiringEpisodeCode.ifBlank { null }, ) } @@ -3212,6 +3242,10 @@ class EmbyRepository internal constructor( trickplayAvailable: Boolean, skipIntroAvailable: Boolean, endCreditsAvailable: Boolean, + nextAiringAvailable: Boolean = false, + nextAiringLabel: String? = null, + nextAiringDayLabel: String? = null, + nextAiringEpisodeCode: String? = null, ) = NextEpisode( itemId = item.id, title = item.name, @@ -3233,6 +3267,10 @@ class EmbyRepository internal constructor( trickplayAvailable = trickplayAvailable, skipIntroAvailable = skipIntroAvailable, endCreditsAvailable = endCreditsAvailable, + nextAiringAvailable = nextAiringAvailable, + nextAiringLabel = nextAiringLabel, + nextAiringDayLabel = nextAiringDayLabel, + nextAiringEpisodeCode = nextAiringEpisodeCode, ) /** diff --git a/app/src/main/java/com/ponzischeme89/memby/data/analytics/JourneyAnalytics.kt b/app/src/main/java/com/ponzischeme89/memby/data/analytics/JourneyAnalytics.kt index aa5b7df..c0cd556 100644 --- a/app/src/main/java/com/ponzischeme89/memby/data/analytics/JourneyAnalytics.kt +++ b/app/src/main/java/com/ponzischeme89/memby/data/analytics/JourneyAnalytics.kt @@ -39,6 +39,7 @@ class JourneyAnalytics( itemType: String, playSessionId: String, outcome: String, + positionMs: Long, ) = synchronized(lock) { if (ended) return@synchronized buffer += GatewayJourneyEvent( @@ -59,6 +60,7 @@ class JourneyAnalytics( // fields it cannot read. A session id is worth less than the step it describes. playSessionId = clean(playSessionId), outcome = clean(outcome), + positionMs = positionMs.coerceAtLeast(0), occurredAt = timestamp(), ) if (buffer.size > MAX_BUFFERED_EVENTS) buffer.removeAt(0) diff --git a/app/src/main/java/com/ponzischeme89/memby/data/analytics/JourneySink.kt b/app/src/main/java/com/ponzischeme89/memby/data/analytics/JourneySink.kt index 9ccde35..6a2aea8 100644 --- a/app/src/main/java/com/ponzischeme89/memby/data/analytics/JourneySink.kt +++ b/app/src/main/java/com/ponzischeme89/memby/data/analytics/JourneySink.kt @@ -23,5 +23,6 @@ interface JourneySink { itemType: String = "", playSessionId: String = "", outcome: String = "", + positionMs: Long = 0, ) } diff --git a/app/src/main/java/com/ponzischeme89/memby/data/analytics/JourneyTracker.kt b/app/src/main/java/com/ponzischeme89/memby/data/analytics/JourneyTracker.kt index 25d7dd2..5ab0beb 100644 --- a/app/src/main/java/com/ponzischeme89/memby/data/analytics/JourneyTracker.kt +++ b/app/src/main/java/com/ponzischeme89/memby/data/analytics/JourneyTracker.kt @@ -44,6 +44,7 @@ object JourneyTracker : JourneySink { itemType: String, playSessionId: String, outcome: String, + positionMs: Long, ) { // Silently nothing when no journey is open — the screensaver can start playback in a // process where nobody has reached the launcher, and telemetry must never be a reason @@ -52,6 +53,7 @@ object JourneyTracker : JourneySink { category = category, action = action, screen = screen, feature = feature, source = source, target = target, itemId = itemId, itemName = itemName, itemType = itemType, playSessionId = playSessionId, outcome = outcome, + positionMs = positionMs, ) } diff --git a/app/src/main/java/com/ponzischeme89/memby/data/analytics/PlaybackJourney.kt b/app/src/main/java/com/ponzischeme89/memby/data/analytics/PlaybackJourney.kt index 8382194..5482862 100644 --- a/app/src/main/java/com/ponzischeme89/memby/data/analytics/PlaybackJourney.kt +++ b/app/src/main/java/com/ponzischeme89/memby/data/analytics/PlaybackJourney.kt @@ -86,6 +86,49 @@ object PlaybackJourney { playSessionId = playSessionId, outcome = "failure", ) + /** + * Playback paused, with where it stopped. + * + * [positionMs] is what lets the console show "Paused at 15m 22s"; a pause's *length* is + * never sent on its own account, because the matching [resumed] step's timestamp and this + * one's already say it — a duration field here would be a second copy of that gap, free to + * disagree with it the moment either clock is a little off. + */ + fun paused( + sink: JourneySink, + entryPoint: PlaybackEntryPoint, + itemId: String, + itemName: String, + itemType: String, + playSessionId: String, + positionMs: Long, + ) = sink.track( + category = CATEGORY, action = "pause", screen = PLAYER, feature = FEATURE, + source = entryPoint.id, target = PLAYER, + itemId = itemId, itemName = itemName, itemType = itemType, + playSessionId = playSessionId, positionMs = positionMs, + ) + + /** + * Playback resumed from a pause, at the position it was left at. Recorded only when a + * matching [paused] step was actually recorded first — a resume with no pause behind it + * would be a buffering recovery mislabelled as somebody pressing a button. + */ + fun resumed( + sink: JourneySink, + entryPoint: PlaybackEntryPoint, + itemId: String, + itemName: String, + itemType: String, + playSessionId: String, + positionMs: Long, + ) = sink.track( + category = CATEGORY, action = "resume", screen = PLAYER, feature = FEATURE, + source = entryPoint.id, target = PLAYER, + itemId = itemId, itemName = itemName, itemType = itemType, + playSessionId = playSessionId, positionMs = positionMs, + ) + /** * This title's playback ended because the player moved on to another one. * diff --git a/app/src/main/java/com/ponzischeme89/memby/data/model/GatewayModels.kt b/app/src/main/java/com/ponzischeme89/memby/data/model/GatewayModels.kt index 276f352..a69b4bb 100644 --- a/app/src/main/java/com/ponzischeme89/memby/data/model/GatewayModels.kt +++ b/app/src/main/java/com/ponzischeme89/memby/data/model/GatewayModels.kt @@ -887,6 +887,13 @@ data class GatewayPlayback( // [skipIntroAvailable]: they are separate features with separate switches, and a house // that turned the skip button off has not asked to lose the credits pane with it. val endCreditsAvailable: Boolean = false, + // Whether Sonarr can name when this continuing show's next episode airs. Absent on an + // older gateway or a show that isn't Sonarr-tracked, still continuing, or has nothing + // scheduled — the default false means the notice simply never appears. + val nextAiringAvailable: Boolean = false, + val nextAiringLabel: String = "", + val nextAiringDayLabel: String = "", + val nextAiringEpisodeCode: String = "", ) /** @@ -1014,6 +1021,10 @@ data class GatewayNextEpisode( val trickplayAvailable: Boolean = false, val skipIntroAvailable: Boolean = false, val endCreditsAvailable: Boolean = false, + val nextAiringAvailable: Boolean = false, + val nextAiringLabel: String = "", + val nextAiringDayLabel: String = "", + val nextAiringEpisodeCode: String = "", ) @Serializable @@ -1133,6 +1144,12 @@ data class GatewayJourneyEvent( */ val playSessionId: String = "", val outcome: String = "", + /** + * Where playback was, in the title, on a pause or resume step. Zero on every other + * kind of step. The console derives a pause's length from the gap between a pause + * row's [occurredAt] and its matching resume's, so no duration is sent here. + */ + val positionMs: Long = 0, val occurredAt: String = "", ) diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/DetailPageComponents.kt b/app/src/main/java/com/ponzischeme89/memby/ui/DetailPageComponents.kt index ea22aa1..d337c02 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/DetailPageComponents.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/DetailPageComponents.kt @@ -233,6 +233,17 @@ internal data class DetailHeroAction( val onClick: () -> Unit, ) +/** + * A contextual deep-link back action, shown immediately before Play rather than among the + * circular secondary actions — it names a destination ("Back to Search Results") rather than + * a plain choice, and it is offered only when the page was reached from that destination. Not + * a permanent detail-page control: most pages never construct one. + */ +internal data class DetailBackNavigation( + val label: String, + val onClick: () -> Unit, +) + /** * Full-bleed artwork with a protected reading area on the left and at the fold. * @@ -364,6 +375,11 @@ internal fun DetailPageScaffold( ratings: List = emptyList(), showRatingsStrip: Boolean = true, heroActions: List = emptyList(), + /** + * Set only when this page was opened from Search — see [DetailBackNavigation]. Rendered + * before Play; every other entry point leaves this null and gets no button. + */ + backNavigation: DetailBackNavigation? = null, confirmation: String? = null, onZoneFocused: (DetailZone) -> Unit = {}, footer: (@Composable () -> Unit)? = null, @@ -375,6 +391,7 @@ internal fun DetailPageScaffold( List(6) { FocusRequester() } } val actionRequesters = allActionRequesters.take(heroActions.size) + val backNavigationFocusRequester = remember(item.id) { FocusRequester() } var lastHeroIndex by remember(item.id) { mutableIntStateOf(-1) } var focusedZone by remember(item.id) { mutableStateOf(DetailZone.PLAY) } // Reported on the way *in* to a band, never on every focus move inside one. The pane's @@ -456,6 +473,8 @@ internal fun DetailPageScaffold( playLabel = playLabel, onPlay = onPlay, playFocusRequester = playFocusRequester, + backNavigation = backNavigation, + backNavigationFocusRequester = backNavigationFocusRequester, onNavigateDown = enterStripFromHero, progress = progress, progressLabel = progressLabel, @@ -595,6 +614,8 @@ private fun DetailHero( playLabel: String, onPlay: () -> Unit, playFocusRequester: FocusRequester, + backNavigation: DetailBackNavigation?, + backNavigationFocusRequester: FocusRequester, onNavigateDown: () -> Boolean, progress: Float, progressLabel: String?, @@ -842,6 +863,8 @@ private fun DetailHero( playLabel = playLabel, onPlay = onPlay, playFocusRequester = playFocusRequester, + backNavigation = backNavigation, + backNavigationFocusRequester = backNavigationFocusRequester, actions = actions, actionRequesters = actionRequesters, caption = actionCaption, @@ -849,6 +872,10 @@ private fun DetailHero( actionCaption = null onPlayFocused() }, + onBackNavigationFocused = { + actionCaption = null + onPlayFocused() + }, onActionFocused = { index -> actionCaption = actions.getOrNull(index)?.description onActionFocused(index) @@ -871,10 +898,13 @@ private fun DetailHeroActions( playLabel: String, onPlay: () -> Unit, playFocusRequester: FocusRequester, + backNavigation: DetailBackNavigation?, + backNavigationFocusRequester: FocusRequester, actions: List, actionRequesters: List, caption: String?, onPlayFocused: () -> Unit, + onBackNavigationFocused: () -> Unit, onActionFocused: (Int) -> Unit, ) { Column { @@ -883,6 +913,18 @@ private fun DetailHeroActions( verticalAlignment = Alignment.CenterVertically, modifier = Modifier.focusGroup(), ) { + if (backNavigation != null) { + MembySecondaryButton( + label = backNavigation.label, + onClick = backNavigation.onClick, + onFocused = onBackNavigationFocused, + compact = true, + icon = MembyIcon.ArrowBack, + modifier = Modifier + .testTag("detail-back-to-search") + .focusRequester(backNavigationFocusRequester), + ) + } MembyPlayButton( label = playLabel, onClick = onPlay, diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/HomeOverlays.kt b/app/src/main/java/com/ponzischeme89/memby/ui/HomeOverlays.kt index 92e2732..ed85261 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/HomeOverlays.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/HomeOverlays.kt @@ -346,6 +346,12 @@ internal fun FocusedDetailsOverlay( */ onOpenEmbyItem: (BaseItem) -> Unit = {}, airingNotice: AiringNotice? = null, + /** + * Set only when [selected] is the page Search opened directly — never carried across a + * "More like this" trail step, which is [onOpenItem]'s own destination. See + * [DetailBackNavigation]. + */ + onBackToSearch: (() -> Unit)? = null, ) { val focusedItem by homeViewModel.focusedItem.collectAsStateWithLifecycle() // Detail metadata belongs to this overlay and this item id. Launcher/Search focus is @@ -440,6 +446,7 @@ internal fun FocusedDetailsOverlay( onOpenItem = onOpenItem, restorePosition = restorePosition, airingNotice = airingNotice, + onBackToSearch = onBackToSearch, ) } } else if (item.isEpisode) { @@ -470,6 +477,7 @@ internal fun FocusedDetailsOverlay( onClose = trackedOnClose, onOpenItem = onOpenItem, restorePosition = restorePosition, + onBackToSearch = onBackToSearch, ) } } diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/HomeScreen.kt b/app/src/main/java/com/ponzischeme89/memby/ui/HomeScreen.kt index 7d9d0f3..320eecc 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/HomeScreen.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/HomeScreen.kt @@ -323,6 +323,12 @@ internal fun HomeScreen( // "Shows airing" row, and dropped the moment the viewer moves anywhere else, so the // same series reached from Favourites or a search never claims a schedule. var detailsAiringNotice by remember { mutableStateOf(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(null) } // Whether the long-press menu may offer a trailer for the Radarr card it is open on. // Asked once, when the menu opens, and false until answered: a row entry that appears @@ -1122,6 +1128,7 @@ internal fun HomeScreen( itemName = item.name, itemType = item.type, ) detailsAiringNotice = null + detailsFromSearch = true detailsItem = item } }, @@ -1156,6 +1163,7 @@ internal fun HomeScreen( // show, carrying the air time across because that is why it was // pressed. val seriesStub = scheduleSeriesStub(item) + detailsFromSearch = false if (seriesStub != null) { detailsAiringNotice = airingNoticeFor(item) homeViewModel.focusItem(seriesStub) @@ -1205,6 +1213,7 @@ internal fun HomeScreen( itemName = item.name, itemType = item.type, ) detailsAiringNotice = null + detailsFromSearch = false detailsItem = item }, onContentFocused = { navigationExpanded = false }, @@ -1413,9 +1422,10 @@ internal fun HomeScreen( homeViewModel.trackJourney( category = "content", action = "open", screen = selectedDestination.name.lowercase(), feature = "hero", source = HOME_HERO_ROW_ID, target = "details", - itemName = item.name, itemType = item.type, + itemId = item.id, itemName = item.name, itemType = item.type, ) detailsAiringNotice = null + detailsFromSearch = false detailsItem = item }, modifier = Modifier.height(metadataHeight), @@ -1706,6 +1716,7 @@ internal fun HomeScreen( // is still only Radarr's opens its own. Neither is // inert, which is what the card used to be. val movieStub = scheduleMovieStub(item) + detailsFromSearch = false if (seriesStub != null) { detailsAiringNotice = airingNoticeFor(item) homeViewModel.focusItem(seriesStub) @@ -2211,6 +2222,21 @@ internal fun HomeScreen( // Debug-only: on a release build both calls return before allocating. remember(selected.id) { StartupTrace.beginSpan(StartupTrace.DETAIL) } LaunchedEffect(selected.id) { StartupTrace.endSpan(StartupTrace.DETAIL) } + // Closing the page with nothing left in the trail — the hardware Back key and the + // "Back to Search Results" action both mean exactly this, so both call it rather + // than keeping two copies of what "leave the page" does. + val closeDetails: () -> Unit = { + restoreDetailPosition = false + detailsItem = null + detailsAiringNotice = null + detailsFromSearch = false + requestFirstAvailableFocus( + cardReturnFocusRequester, + contentFocusRequester, + navigationFocusRequester, + ) + Unit + } BackHandler { val previous = detailsTrail.lastOrNull() if (previous != null) { @@ -2218,14 +2244,7 @@ internal fun HomeScreen( restoreDetailPosition = true detailsItem = previous } else { - restoreDetailPosition = false - detailsItem = null - detailsAiringNotice = null - requestFirstAvailableFocus( - cardReturnFocusRequester, - contentFocusRequester, - navigationFocusRequester, - ) + closeDetails() } } FocusedDetailsOverlay( @@ -2235,6 +2254,11 @@ internal fun HomeScreen( detailExperience = detailExperience, restorePosition = restoreDetailPosition, airingNotice = detailsAiringNotice, + // Contextual to the exact page Search opened: still true after walking back + // out of a "More like this" trail to it, gone the moment that trail is not + // empty — a deep-link shortcut back to a specific entry point rather than a + // permanent control every detail page carries. + onBackToSearch = closeDetails.takeIf { detailsFromSearch && detailsTrail.isEmpty() }, onOpenItem = { related -> homeViewModel.trackJourney( category = "recommendations", action = "open", screen = "details", @@ -2335,6 +2359,7 @@ internal fun HomeScreen( detailsTrail = emptyList() restoreDetailPosition = false detailsAiringNotice = null + detailsFromSearch = false requestFirstAvailableFocus( cardReturnFocusRequester, contentFocusRequester, @@ -2497,6 +2522,7 @@ internal fun HomeScreen( .onSuccess { item -> closeRequests() detailsAiringNotice = null + detailsFromSearch = false detailsTrail = emptyList() detailsItem = item } @@ -2664,6 +2690,7 @@ internal fun HomeScreen( onOpenDetails = { quickMenuItem = null detailsAiringNotice = null + detailsFromSearch = false detailsItem = it }, onSetFavorite = homeViewModel::setFavorite, diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/MediaDetailsOverlay.kt b/app/src/main/java/com/ponzischeme89/memby/ui/MediaDetailsOverlay.kt index 5d4c785..187c8d2 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/MediaDetailsOverlay.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/MediaDetailsOverlay.kt @@ -53,6 +53,11 @@ fun MediaDetailsOverlay( onClose: () -> Unit, onOpenItem: (BaseItem) -> Unit = {}, restorePosition: Boolean = false, + /** + * Set only when this page was opened directly from a Search result — see + * [DetailBackNavigation]. Every other entry point leaves this null. + */ + onBackToSearch: (() -> Unit)? = null, modifier: Modifier = Modifier, ) { val settings by ServiceLocator.repository.settingsFlow @@ -91,6 +96,7 @@ fun MediaDetailsOverlay( showRatingsStrip = settings.showRatingsStrip, hideWatchedMovies = settings.hideWatchedMovies, restorePosition = restorePosition, + onBackToSearch = onBackToSearch, modifier = modifier, ) } @@ -115,6 +121,7 @@ internal fun MediaDetailContent( hideWatchedMovies: Boolean = false, onOpenItem: (BaseItem) -> Unit = {}, restorePosition: Boolean = false, + onBackToSearch: (() -> Unit)? = null, ) { val specs = remember(item.id, item.mediaStreams) { technicalSpecs(item) } val credits = remember(item.id, item.people, item.genres) { creditRows(item) } @@ -243,6 +250,9 @@ internal fun MediaDetailContent( ?: listOfNotNull(item.membyRecommendationReason?.takeIf(String::isNotBlank)), ratings = ratings, showRatingsStrip = showRatingsStrip, + backNavigation = onBackToSearch?.let { + DetailBackNavigation(label = "Back to Search Results", onClick = it) + }, confirmation = confirmation, onZoneFocused = { zone -> focusedZone = zone diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/MembyButtons.kt b/app/src/main/java/com/ponzischeme89/memby/ui/MembyButtons.kt index 8f295fd..7d8343a 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/MembyButtons.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/MembyButtons.kt @@ -177,24 +177,40 @@ internal fun MembySecondaryButton( onClick: () -> Unit, modifier: Modifier = Modifier, compact: Boolean = false, + /** + * A leading mark, for a secondary action that names a destination rather than a plain + * choice — "Back to Search Results" reads as a place to go, and the arrow is what says + * so at a glance rather than making the word "Back" carry it alone. + */ + icon: MembyIcon? = null, + onFocused: () -> Unit = {}, ) { var focused by remember { mutableStateOf(false) } val scale by animateFloatAsState(if (focused) 1.055f else 1f, tween(100), label = "secondary-focus") val shape = RoundedCornerShape(MembyCardCorner) - Box( + Row( modifier = modifier .graphicsLayer { scaleX = scale; scaleY = scale; translationY = if (focused) -3f else 0f } .clip(shape) .background(if (focused) MembyOutline else Color.Transparent) .border(if (focused) 2.dp else 1.dp, if (focused) Color.White else MembyOutline, shape) - .onFocusChanged { focused = it.isFocused } + .onFocusChanged { focused = it.isFocused; if (it.isFocused) onFocused() } .clickable(onClick = onClick) .padding( horizontal = if (compact) 14.dp else 23.dp, vertical = if (compact) 8.dp else 13.dp, ), - contentAlignment = Alignment.Center, + verticalAlignment = Alignment.CenterVertically, ) { + if (icon != null) { + Icon( + icon.mark, + contentDescription = null, + tint = if (focused) Color.White else MembyMutedText, + modifier = Modifier.size(if (compact) 16.dp else 20.dp), + ) + Spacer(Modifier.width(if (compact) 5.dp else 7.dp)) + } Text( label, color = if (focused) Color.White else MembyMutedText, diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/SeriesDetailsOverlay.kt b/app/src/main/java/com/ponzischeme89/memby/ui/SeriesDetailsOverlay.kt index a72599d..e154aac 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/SeriesDetailsOverlay.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/SeriesDetailsOverlay.kt @@ -119,6 +119,11 @@ fun SeriesDetailsOverlay( onOpenItem: (BaseItem) -> Unit = {}, restorePosition: Boolean = false, airingNotice: AiringNotice? = null, + /** + * Set only when this page was opened directly from a Search result — see + * [DetailBackNavigation]. Every other entry point leaves this null. + */ + onBackToSearch: (() -> Unit)? = null, modifier: Modifier = Modifier, ) { val repository = ServiceLocator.repository @@ -214,6 +219,7 @@ fun SeriesDetailsOverlay( onOpenItem = onOpenItem, restorePosition = restorePosition, airingNotice = airingNotice, + onBackToSearch = onBackToSearch, modifier = modifier, ) } @@ -241,6 +247,7 @@ internal fun SeriesDetailContent( onOpenItem: (BaseItem) -> Unit = {}, restorePosition: Boolean = false, airingNotice: AiringNotice? = null, + onBackToSearch: (() -> Unit)? = null, ) { val remembered = remember(item.id) { detailPositions.get(item.id) } val seasons = remember(episodes) { availableSeasons(episodes.orEmpty()) } @@ -420,6 +427,9 @@ internal fun SeriesDetailContent( confirmation = confirmation, ratings = ratings, showRatingsStrip = showRatingsStrip, + backNavigation = onBackToSearch?.let { + DetailBackNavigation(label = "Back to Search Results", onClick = it) + }, onZoneFocused = { zone -> focusedZone = zone detailPositions.update(item.id) { it.copy(zone = zone) } diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/genre/GenreBrowseScreen.kt b/app/src/main/java/com/ponzischeme89/memby/ui/genre/GenreBrowseScreen.kt index 3f968fd..ec08dfa 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/genre/GenreBrowseScreen.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/genre/GenreBrowseScreen.kt @@ -815,8 +815,19 @@ internal fun ServicesRail( .fillMaxWidth() .focusGroup() .onKeyEvent { event -> - if (event.type != KeyEventType.KeyDown) return@onKeyEvent false - if (event.key == Key.DirectionRight) onEnterContent() else false + if (event.type != KeyEventType.KeyDown || event.key != Key.DirectionRight) { + return@onKeyEvent false + } + // Unlike the genre rail's single column, Right means two different + // things here: walk to the next icon, or — only once there is no + // next icon — enter the grid. Consuming it unconditionally is what + // made Right on Netflix or Apple TV jump straight into the grid + // instead of reaching the icon beside it, so every other icon has + // to let the press fall through to the item's own focusProperties + // and the built-in directional search that reads it. Only the last + // icon, whose own `right` is Cancel, hands the press to the grid. + if (activeServiceId != services.lastOrNull()?.id) return@onKeyEvent false + onEnterContent() }, contentPadding = PaddingValues(start = 16.dp, end = 14.dp), horizontalArrangement = Arrangement.spacedBy(14.dp), diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/player/NextUpPipeline.kt b/app/src/main/java/com/ponzischeme89/memby/ui/player/NextUpPipeline.kt index 7cf106c..1114542 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/player/NextUpPipeline.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/player/NextUpPipeline.kt @@ -184,6 +184,25 @@ internal fun shouldAutoAdvance( dismissed: Boolean, ): Boolean = autoPlayEnabled && hasNextEpisode && !dismissed +/** + * Whether the next-airing notice belongs on screen right now. + * + * Independent of whether there is a next episode to hand off to — a continuing show can + * have new material coming without Emby having imported the next one yet, which is exactly + * the case this notice exists for. Its own window is strictly ahead of [leadMs] (the + * next-up banner's own), and it retires the instant remaining time reaches that mark + * whether or not a next episode exists, which is what keeps the two from ever being visible + * together without either needing to know the other exists. + */ +internal fun nextAiringNoticeVisible( + available: Boolean, + remainingMs: Long, + windowMs: Long, + leadMs: Long, + dismissed: Boolean, + canShow: Boolean, +): Boolean = available && remainingMs in (leadMs + 1)..windowMs && !dismissed && canShow + /** * Whether the manual Next Episode control belongs on the transport row. * diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/player/PlayerActivity.kt b/app/src/main/java/com/ponzischeme89/memby/ui/player/PlayerActivity.kt index c7a7b75..3a8ec54 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/player/PlayerActivity.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/player/PlayerActivity.kt @@ -183,6 +183,15 @@ class PlayerActivity : ComponentActivity() { * one. Cleared wherever [playbackStarted] is, because that is where the title changes. */ private var journeyFailureRecorded = false + + /** + * Whether a pause step is currently open, waiting on its resume. Set only when + * [recordPlaybackPausedJourney] actually wrote one, so a resume is only ever recorded + * against a pause the journey genuinely has — see that function for why a plain + * `isPlaying` flip is not enough on its own. Cleared wherever [journeyFailureRecorded] is, + * because that is where the title changes and an old pause could never be this one's. + */ + private var journeyPauseRecorded = false private var availableSubtitles: List = emptyList() private var encodedSubtitleId: String? = null private var remainingView: TextView? = null @@ -411,6 +420,7 @@ class PlayerActivity : ComponentActivity() { private var nextUpRing: CountdownRingView? = null private var nextUpLogo: ImageView? = null private var nextUpSeries: TextView? = null + private var nextUpActionButton: View? = null /** * The logo the bar is currently wearing, so the identity is bound once per episode * rather than on every 250ms tick of the countdown. Coil would answer the repeats from @@ -493,6 +503,20 @@ class PlayerActivity : ComponentActivity() { */ private var creditsDismissed = false private var creditsSpeedJob: Job? = null + + // The next-airing notice: a continuing show's own schedule, independent of whether Emby + // has a next episode to hand off to. Whichever episode is actually playing carries its + // own answer — set from the initial resolution in [adoptPlayable] and refreshed from + // [nextEpisode]'s own fields on advance, since an advance does not re-resolve playback. + private var nextAiringAvailable = false + private var nextAiringLabel: String? = null + private var nextAiringDayLabel: String? = null + private var nextAiringEpisodeCode: String? = null + private var nextAiringView: View? = null + private var nextAiringEyebrow: TextView? = null + private var nextAiringLabelView: TextView? = null + /** This episode's notice has been dealt with — dismissed by Back — and does not return. */ + private var nextAiringDismissed = false /** * The fastest this stream has been allowed to run. It only ever falls — see * [creditsCeilingAfterStall]. Reset per episode, because the next file may be a @@ -562,6 +586,13 @@ class PlayerActivity : ComponentActivity() { private var seekBuffering = false private var seekLoadingFallbackJob: Job? = null private var playbackStartCueShown = false + /** + * Ticking down to the Time Left card while the screen is clear of every other Memby + * overlay. See [scheduleTimeRemainingCue] — it is not a plain [delay], because losing + * eligibility partway through (the transport opening, a pause) must abandon the attempt + * rather than show the card the instant the delay elapses regardless. + */ + private var timeRemainingSettleJob: Job? = null private var seasonFinaleCue: View? = null private var seasonFinaleValue: TextView? = null private var seasonFinaleInfo: GatewaySeasonFinale? = null @@ -663,6 +694,14 @@ class PlayerActivity : ComponentActivity() { ?: intent.getBooleanExtra(EXTRA_SKIP_INTRO, false) endCreditsAvailable = savedInstanceState?.getBoolean(STATE_END_CREDITS) ?: intent.getBooleanExtra(EXTRA_END_CREDITS, false) + nextAiringAvailable = savedInstanceState?.getBoolean(STATE_NEXT_AIRING_AVAILABLE) + ?: intent.getBooleanExtra(EXTRA_NEXT_AIRING_AVAILABLE, false) + nextAiringLabel = savedInstanceState?.getString(STATE_NEXT_AIRING_LABEL) + ?: intent.getStringExtra(EXTRA_NEXT_AIRING_LABEL) + nextAiringDayLabel = savedInstanceState?.getString(STATE_NEXT_AIRING_DAY_LABEL) + ?: intent.getStringExtra(EXTRA_NEXT_AIRING_DAY_LABEL) + nextAiringEpisodeCode = savedInstanceState?.getString(STATE_NEXT_AIRING_EPISODE_CODE) + ?: intent.getStringExtra(EXTRA_NEXT_AIRING_EPISODE_CODE) // Same rule and default: a missing extra must never draw a badge for a title Emby // never told this build was licensed through a service. On the request form this // is corrected by adoptPlayable once the server settles. @@ -868,10 +907,24 @@ class PlayerActivity : ComponentActivity() { eventName = if (isPlaying) "Unpause" else "Pause", ) } + // A resume is only ever the *far* end of a pause the journey actually + // recorded — see recordPlaybackPausedJourney for why `isPlaying` alone, + // which also flips false during an ordinary rebuffer, is not that signal. + if (isPlaying) recordPlaybackResumedJourney(playback.currentPosition) if (isPlaying) scheduleRetryBudgetReset() else stablePlaybackJob?.cancel() updatePauseOverlay(playback) } + // The journey's pause/resume timeline reads from playWhenReady, not + // isPlaying: playWhenReady only moves when somebody actually asked for + // playback to stop or continue, where isPlaying also flips false for an + // ordinary rebuffer with playWhenReady never touched. Recording pauses off + // isPlaying would fill the timeline with a "Paused"/"Resumed" pair for every + // network stall. + override fun onPlayWhenReadyChanged(playWhenReady: Boolean, reason: Int) { + if (!playWhenReady) recordPlaybackPausedJourney(playback.currentPosition) + } + override fun onEvents(player: Player, events: Player.Events) { updatePlaybackTiming(player) // A skip that landed inside what was already buffered never @@ -1007,6 +1060,7 @@ class PlayerActivity : ComponentActivity() { ) setUpSubtitleOverlay() setUpNextUpBanner() + setUpNextAiringNotice() setUpSkipIntro() setUpEndCredits() setUpTimeRemainingCue() @@ -1347,6 +1401,10 @@ class PlayerActivity : ComponentActivity() { trickplayAvailable = playable.trickplayAvailable skipIntroAvailable = playable.skipIntroAvailable endCreditsAvailable = playable.endCreditsAvailable + nextAiringAvailable = playable.nextAiringAvailable + nextAiringLabel = playable.nextAiringLabel + nextAiringDayLabel = playable.nextAiringDayLabel + nextAiringEpisodeCode = playable.nextAiringEpisodeCode streamingService = playable.streamingService subtitleAutoSelectionAttempted = false initialResumePositionMs = playable.resumePositionMs.coerceAtLeast(0L) @@ -2648,30 +2706,56 @@ class PlayerActivity : ComponentActivity() { } private fun updatePlaybackStartCue(playback: Player) { - if (playbackStartCueShown || !timingCueCanShow()) return + if (playbackStartCueShown || timeRemainingSettleJob?.isActive == true) return + if (!timingCueCanShow()) return val duration = playback.duration if (duration == C.TIME_UNSET || duration <= 0L || playback.isCurrentMediaItemLive) return val remainingMs = (duration - playback.currentPosition).coerceAtLeast(0L) if (remainingMs == 0L) return val speed = playback.playbackParameters.speed.coerceAtLeast(0.1f) val wallClockRemainingMs = (remainingMs / speed).toLong() - playbackStartCueShown = true - seasonFinaleInfo?.let(::showSeasonFinaleCue) - if (initialResumePositionMs > 0L) { - showTimingCue( - label = getString(R.string.player_resume_time_left_label), - value = formatCueDuration(wallClockRemainingMs), - ) - } else { - val finishAt = System.currentTimeMillis() + wallClockRemainingMs - showTimingCue( - label = getString(R.string.player_finishes_in_label), - value = getString( - R.string.player_finishes_in_value, - formatCueDuration(wallClockRemainingMs), - DateFormat.getTimeFormat(this).format(Date(finishAt)), - ), - ) + scheduleTimeRemainingCue(wallClockRemainingMs) + } + + /** + * Waits for the screen to stay clear of every other Memby overlay for + * [TIME_REMAINING_SETTLE_MS] before showing the Time Left card, so it never appears + * underneath the transport, the pause hero or the station ident, or in the instant they + * clear. Polled at the same cadence as [startPlaybackStartCueWatch] rather than driven by + * each overlay's own callback, which is what lets one function answer for all of them. + * + * Losing eligibility mid-wait abandons this attempt outright rather than pausing a clock: + * [startPlaybackStartCueWatch] is still ticking and calls [updatePlaybackStartCue] again on + * its next tick, which starts a fresh wait once the screen is clear again. + */ + private fun scheduleTimeRemainingCue(wallClockRemainingMs: Long) { + timeRemainingSettleJob?.cancel() + timeRemainingSettleJob = lifecycleScope.launch { + var settledMs = 0L + while (isActive && settledMs < TIME_REMAINING_SETTLE_MS) { + delay(PLAYBACK_START_CUE_TICK_MS) + if (!timingCueCanShow()) return@launch + settledMs += PLAYBACK_START_CUE_TICK_MS + } + if (playbackStartCueShown || !timingCueCanShow()) return@launch + playbackStartCueShown = true + seasonFinaleInfo?.let(::showSeasonFinaleCue) + if (initialResumePositionMs > 0L) { + showTimingCue( + label = getString(R.string.player_resume_time_left_label), + value = formatCueDuration(wallClockRemainingMs), + ) + } else { + val finishAt = System.currentTimeMillis() + wallClockRemainingMs + showTimingCue( + label = getString(R.string.player_finishes_in_label), + value = getString( + R.string.player_finishes_in_value, + formatCueDuration(wallClockRemainingMs), + DateFormat.getTimeFormat(this@PlayerActivity).format(Date(finishAt)), + ), + ) + } } } @@ -2700,7 +2784,10 @@ class PlayerActivity : ComponentActivity() { !prerollActive && prerollView?.isVisible != true && loadingView?.isVisible != true && - errorView?.isVisible != true + errorView?.isVisible != true && + !transportVisible && + !pauseHeroVisible.value && + playbackIdentityPhase != PlaybackIdentityPhase.SHOWING private fun showTimingCue(label: String, value: String) { val cue = timeRemainingCue ?: return @@ -2737,6 +2824,8 @@ class PlayerActivity : ComponentActivity() { private fun resetTimeRemainingCue() { playbackStartCueJob?.cancel() playbackStartCueJob = null + timeRemainingSettleJob?.cancel() + timeRemainingSettleJob = null timeRemainingHideJob?.cancel() timeRemainingHideJob = null playbackStartCueShown = false @@ -3270,6 +3359,66 @@ class PlayerActivity : ComponentActivity() { } } + // --- Next episode airing -------------------------------------------------------- + + private fun setUpNextAiringNotice() { + val view = findViewById(R.id.player_next_airing) + nextAiringView = view + nextAiringEyebrow = view.findViewById(R.id.player_next_airing_eyebrow) + nextAiringLabelView = view.findViewById(R.id.player_next_airing_label) + } + + /** + * Driven off the playhead exactly like the next-up countdown: pausing holds it where it + * is, seeking re-arms or retires it, nothing here runs off a timer of its own. + * + * The notice's own window is strictly ahead of [NEXT_UP_LEAD_MS] — it retires the moment + * remaining time reaches that mark whether or not [nextEpisode] is null, which is what + * keeps it from ever coinciding with the next-up banner or the credits pane without + * either overlay needing to know the other exists. + */ + private fun updateNextAiringNotice(remainingMs: Long) { + // Seeking back out past the notice's own window re-arms it, the skip-intro + // countdown's rule: only a viewer's own Back press should keep it away for the + // rest of the episode, not merely the playhead having moved past it once. + if (remainingMs > NEXT_EPISODE_AIRING_LEAD_MS) nextAiringDismissed = false + val visible = nextAiringNoticeVisible( + available = nextAiringAvailable, + remainingMs = remainingMs, + windowMs = NEXT_EPISODE_AIRING_LEAD_MS, + leadMs = NEXT_UP_LEAD_MS, + dismissed = nextAiringDismissed, + canShow = nextUpCanShow(), + ) + if (visible) showNextAiringNotice() else hideNextAiringNotice() + } + + private fun showNextAiringNotice() { + val view = nextAiringView ?: return + val eyebrow = nextAiringEpisodeCode?.takeIf(String::isNotBlank)?.let { + "$it · ${getString(R.string.next_airing_next_episode)}" + } ?: getString(R.string.next_airing_next_episode) + nextAiringEyebrow?.text = eyebrow + nextAiringLabelView?.text = nextAiringLabel ?: nextAiringDayLabel.orEmpty() + if (view.visibility != View.VISIBLE) view.visibility = View.VISIBLE + } + + private fun hideNextAiringNotice() { + nextAiringView?.takeIf { it.visibility != View.GONE }?.visibility = View.GONE + } + + /** Back dismisses it for the rest of this episode, the same one-press-per-level contract + * every overlay here has. */ + private fun dismissNextAiringNotice() { + nextAiringDismissed = true + hideNextAiringNotice() + } + + private fun resetNextAiringNotice() { + nextAiringDismissed = false + hideNextAiringNotice() + } + // --- Next up ------------------------------------------------------------------ private fun setUpNextUpBanner() { @@ -3278,6 +3427,9 @@ class PlayerActivity : ComponentActivity() { nextUpCountdown = banner.findViewById(R.id.player_next_up_countdown) nextUpLogo = banner.findViewById(R.id.player_next_up_logo) nextUpSeries = banner.findViewById(R.id.player_next_up_series) + nextUpActionButton = banner.findViewById(R.id.player_next_up_action)?.also { button -> + button.setOnClickListener { startNextEpisode() } + } nextUpRing = banner.findViewById(R.id.player_next_up_ring)?.apply { // Fixed rather than taken from a drawable state, because nothing here is // focusable: the ring sits on somebody's programme and has to read against @@ -3399,6 +3551,7 @@ class PlayerActivity : ComponentActivity() { private fun recordPlaybackStartedJourney() { if (!journeyRecordsPlayback) return journeyFailureRecorded = false + journeyPauseRecorded = false PlaybackJourney.started( sink = JourneyTracker, entryPoint = journeyEntryPoint, @@ -3423,6 +3576,42 @@ class PlayerActivity : ComponentActivity() { ) } + /** + * Playback stopped advancing with [Player.playWhenReady] false — the viewer (or the app, + * pausing behind an overlay) actually asked for this, as opposed to a stall the decoder is + * recovering from on its own, where `playWhenReady` never moves. Guarded on + * [journeyPauseRecorded] so a held pause key or a second overlay opening while paused + * cannot write the step twice. + */ + private fun recordPlaybackPausedJourney(positionMs: Long) { + if (!journeyRecordsPlayback || !playbackStarted || journeyPauseRecorded) return + journeyPauseRecorded = true + PlaybackJourney.paused( + sink = JourneyTracker, + entryPoint = journeyEntryPoint, + itemId = itemId.orEmpty(), + itemName = playbackTitle, + itemType = journeyItemType, + playSessionId = playSessionId, + positionMs = positionMs, + ) + } + + /** The other half of [recordPlaybackPausedJourney]; see it for why this is guarded. */ + private fun recordPlaybackResumedJourney(positionMs: Long) { + if (!journeyPauseRecorded) return + journeyPauseRecorded = false + PlaybackJourney.resumed( + sink = JourneyTracker, + entryPoint = journeyEntryPoint, + itemId = itemId.orEmpty(), + itemName = playbackTitle, + itemType = journeyItemType, + playSessionId = playSessionId, + positionMs = positionMs, + ) + } + /** * The title on screen is being replaced by another one inside this same player — a Magic * pick or an episode advance. Without it the films before the last one in a chain would @@ -3592,11 +3781,16 @@ class PlayerActivity : ComponentActivity() { private fun updateNextUpFromPlayhead() { if (advancing || playingNextEpisodePreview) return val playback = player ?: return - val next = nextEpisode ?: return val duration = playback.duration if (duration == C.TIME_UNSET || duration <= 0L) return - val remainingMs = (duration - playback.currentPosition).coerceAtLeast(0L) + + // Independent of whether there is a next episode to hand off to — a show can be + // airing new episodes without Emby having imported the next one yet, which is + // exactly the case this notice exists for. Evaluated before the early return below. + updateNextAiringNotice(remainingMs) + + val next = nextEpisode ?: return if (remainingMs > NEXT_EPISODE_PREVIEW_LEAD_MS) previewWindowArmed = true if (shouldStartNextEpisodePreview( autoPlayEnabled = autoPlayNextEpisodeEnabled, @@ -3696,6 +3890,7 @@ class PlayerActivity : ComponentActivity() { stopProgressUploading() hideNextUp() + hideNextAiringNotice() if (creditsActive) leaveEndCredits(restoreSpeed = true) playbackTitle = "Next: ${nextTitle(next)}" playbackSeriesName = next.seriesName @@ -3813,9 +4008,10 @@ class PlayerActivity : ComponentActivity() { * * Every other overlay in this player *owns* the screen while it is up — the drop-up, * the cast panel, the credits pane, the error and loading surfaces — and this one owns - * nothing at all, so wherever one of those is up this simply stands down rather than - * being drawn underneath it. The transport is in the list for the plainest reason: - * it is a full-width strip along the same bottom edge, and the two would overlap. + * only its own action pill, so wherever one of those is up this simply stands down + * rather than being drawn underneath it. The transport is in the list for the plainest + * reason: it is a full-width strip along the same bottom edge, and the two would + * overlap. */ private fun nextUpCanShow(): Boolean = !prerollActive && @@ -3878,11 +4074,16 @@ class PlayerActivity : ComponentActivity() { .setDuration(NEXT_UP_ANIMATION_MS) .setInterpolator(DecelerateInterpolator()) .start() + // Focus is the only way a remote can say "press this". It is taken as the pill + // appears and handed back to the video the moment the bar goes, the + // `player_skip_intro` pattern. + nextUpActionButton?.requestFocus() } private fun hideNextUp() { val banner = nextUpBanner ?: return if (!banner.isVisible) return + val hadFocus = nextUpActionButton?.isFocused == true banner.animate() .alpha(0f) .setDuration(NEXT_UP_ANIMATION_MS) @@ -3891,6 +4092,9 @@ class PlayerActivity : ComponentActivity() { banner.alpha = 1f } .start() + // Only if this was holding it. Taking focus back off whatever the viewer has since + // opened would be worse than leaving it where they put it. + if (hadFocus) playerView?.requestFocus() } /** @@ -3978,6 +4182,10 @@ class PlayerActivity : ComponentActivity() { if (creditsActive) return creditsActive = true creditsEnteredAtMs = SystemClock.elapsedRealtime() + // The pane replaces the bar, not joins it — a short credit roll can start inside the + // bar's own last-minute window, and the bar's action pill would otherwise be left + // holding focus underneath a pane that has just taken it for its own Play button. + hideNextUp() view.findViewById(R.id.player_end_credits_title).text = next.title.ifBlank { next.seriesName } @@ -4397,6 +4605,10 @@ class PlayerActivity : ComponentActivity() { trickplayAvailable = next.trickplayAvailable skipIntroAvailable = next.skipIntroAvailable endCreditsAvailable = next.endCreditsAvailable + nextAiringAvailable = next.nextAiringAvailable + nextAiringLabel = next.nextAiringLabel + nextAiringDayLabel = next.nextAiringDayLabel + nextAiringEpisodeCode = next.nextAiringEpisodeCode encodedSubtitleId = null stopReported = false playbackStarted = false @@ -4405,6 +4617,7 @@ class PlayerActivity : ComponentActivity() { // to the launcher, which cannot see an advance at all. journeyEntryPoint = PlaybackEntryPoint.NEXT_EPISODE journeyFailureRecorded = false + journeyPauseRecorded = false PlaybackJourney.requested( sink = JourneyTracker, entryPoint = PlaybackEntryPoint.NEXT_EPISODE, @@ -4432,6 +4645,7 @@ class PlayerActivity : ComponentActivity() { // a different point, and a speed left behind would run the next episode's opening // scene at double speed. resetEndCredits() + resetNextAiringNotice() nextUpDismissed = false requestStartedAtMs = SystemClock.elapsedRealtime() trace = PlaybackTrace(requestStartedAtMs, SystemClock::elapsedRealtime) @@ -4586,6 +4800,9 @@ class PlayerActivity : ComponentActivity() { collapseSubtitleDownloads() subtitleOverlay?.isVisible == true -> hideSubtitleOverlay() nextUpBanner?.isVisible == true -> dismissNextUp() + // Same contract, one level earlier: Back dismisses the next-airing + // notice for the rest of this episode rather than leaving the film. + nextAiringView?.isVisible == true -> dismissNextAiringNotice() // Back asks for the credits back rather than leaving the film: one press // per level, the same contract every other overlay here has. creditsView?.isVisible == true -> dismissEndCredits() @@ -4645,10 +4862,10 @@ class PlayerActivity : ComponentActivity() { playerView?.isControllerFullyVisible != true && !castPanelVisible.value && subtitleOverlay?.isVisible != true && - // The next-up bar is deliberately absent from this list. It takes no focus and - // holds no button, so the centre key still means pause while it is up — the - // whole point of the compact bar is that the remote goes on meaning what it - // meant a moment before it appeared. + // The bar's action pill holds focus while it is up, the same reason the credits + // pane's Play button and the skip-intro button are excluded here: the centre key + // is how a remote presses whatever it is focused on. + nextUpBanner?.isVisible != true && // The pane's Play button holds focus while it is up, and the centre key is how a // remote presses what it is focused on. creditsView?.isVisible != true && @@ -5614,6 +5831,10 @@ class PlayerActivity : ComponentActivity() { outState.putBoolean(STATE_TRICKPLAY, trickplayAvailable) outState.putBoolean(STATE_SKIP_INTRO, skipIntroAvailable) outState.putBoolean(STATE_END_CREDITS, endCreditsAvailable) + outState.putBoolean(STATE_NEXT_AIRING_AVAILABLE, nextAiringAvailable) + nextAiringLabel?.let { outState.putString(STATE_NEXT_AIRING_LABEL, it) } + nextAiringDayLabel?.let { outState.putString(STATE_NEXT_AIRING_DAY_LABEL, it) } + nextAiringEpisodeCode?.let { outState.putString(STATE_NEXT_AIRING_EPISODE_CODE, it) } streamingService?.let { outState.putString(STATE_STREAMING_SERVICE, it.name) } outState.putString(STATE_TITLE, if (savingPreview) previewResumeTitle else playbackTitle) outState.putString(STATE_LOGO_URL, if (savingPreview) previewResumeLogoUrl else logoUrl) @@ -5730,6 +5951,7 @@ class PlayerActivity : ComponentActivity() { prerollScheduleJob?.cancel() disposeLocalPreroll(reuse = true) playbackStartCueJob?.cancel() + timeRemainingSettleJob?.cancel() timeRemainingHideJob?.cancel() seekCommitJob?.cancel() seekHideJob?.cancel() @@ -5965,6 +6187,10 @@ class PlayerActivity : ComponentActivity() { private const val EXTRA_TRICKPLAY = "extra_trickplay_available" private const val EXTRA_SKIP_INTRO = "extra_skip_intro_available" private const val EXTRA_END_CREDITS = "extra_end_credits_available" + private const val EXTRA_NEXT_AIRING_AVAILABLE = "extra_next_airing_available" + private const val EXTRA_NEXT_AIRING_LABEL = "extra_next_airing_label" + private const val EXTRA_NEXT_AIRING_DAY_LABEL = "extra_next_airing_day_label" + private const val EXTRA_NEXT_AIRING_EPISODE_CODE = "extra_next_airing_episode_code" private const val EXTRA_STREAMING_SERVICE = "extra_streaming_service" private const val EXTRA_MEDIA_SOURCE_ID = "extra_media_source_id" private const val EXTRA_PLAY_SESSION_ID = "extra_play_session_id" @@ -5993,6 +6219,10 @@ class PlayerActivity : ComponentActivity() { private const val STATE_TRICKPLAY = "state_trickplay" private const val STATE_SKIP_INTRO = "state_skip_intro" private const val STATE_END_CREDITS = "state_end_credits" + private const val STATE_NEXT_AIRING_AVAILABLE = "state_next_airing_available" + private const val STATE_NEXT_AIRING_LABEL = "state_next_airing_label" + private const val STATE_NEXT_AIRING_DAY_LABEL = "state_next_airing_day_label" + private const val STATE_NEXT_AIRING_EPISODE_CODE = "state_next_airing_episode_code" private const val STATE_STREAMING_SERVICE = "state_streaming_service" private const val STATE_TITLE = "state_title" private const val STATE_LOGO_URL = "state_logo_url" @@ -6104,6 +6334,10 @@ class PlayerActivity : ComponentActivity() { trickplayAvailable = playable.trickplayAvailable, skipIntroAvailable = playable.skipIntroAvailable, endCreditsAvailable = playable.endCreditsAvailable, + nextAiringAvailable = playable.nextAiringAvailable, + nextAiringLabel = playable.nextAiringLabel, + nextAiringDayLabel = playable.nextAiringDayLabel, + nextAiringEpisodeCode = playable.nextAiringEpisodeCode, streamingService = playable.streamingService, mediaSourceId = playable.mediaSourceId, playSessionId = playable.playSessionId, @@ -6134,6 +6368,10 @@ class PlayerActivity : ComponentActivity() { trickplayAvailable: Boolean = false, skipIntroAvailable: Boolean = false, endCreditsAvailable: Boolean = false, + nextAiringAvailable: Boolean = false, + nextAiringLabel: String? = null, + nextAiringDayLabel: String? = null, + nextAiringEpisodeCode: String? = null, streamingService: StreamingService? = null, mediaSourceId: String = "", playSessionId: String = "", @@ -6163,6 +6401,12 @@ class PlayerActivity : ComponentActivity() { putExtra(EXTRA_TRICKPLAY, trickplayAvailable) putExtra(EXTRA_SKIP_INTRO, skipIntroAvailable) putExtra(EXTRA_END_CREDITS, endCreditsAvailable) + putExtra(EXTRA_NEXT_AIRING_AVAILABLE, nextAiringAvailable) + nextAiringLabel?.takeIf { it.isNotBlank() }?.let { putExtra(EXTRA_NEXT_AIRING_LABEL, it) } + nextAiringDayLabel?.takeIf { it.isNotBlank() } + ?.let { putExtra(EXTRA_NEXT_AIRING_DAY_LABEL, it) } + nextAiringEpisodeCode?.takeIf { it.isNotBlank() } + ?.let { putExtra(EXTRA_NEXT_AIRING_EPISODE_CODE, it) } streamingService?.let { putExtra(EXTRA_STREAMING_SERVICE, it.name) } putExtra(EXTRA_MEDIA_SOURCE_ID, mediaSourceId) putExtra(EXTRA_PLAY_SESSION_ID, playSessionId) @@ -6214,6 +6458,12 @@ class PlayerActivity : ComponentActivity() { private const val RESUME_TIME_LEFT_CUE_DELAY_MS = 2_000L private const val PLAYBACK_START_CUE_TICK_MS = 250L private const val TIME_REMAINING_THRESHOLD_MS = 10L * 60_000L + /** + * How long the screen must stay clear of the transport, the pause hero and the + * station ident before the Time Left card appears — long enough that it reads as its + * own moment rather than the tail end of whichever of those just closed. + */ + private const val TIME_REMAINING_SETTLE_MS = 3_000L private const val TIME_REMAINING_VISIBLE_MS = 6_000L private const val TIME_REMAINING_ANIMATION_MS = 240L private const val TIME_REMAINING_TRAVEL_DP = 12 @@ -6286,6 +6536,14 @@ class PlayerActivity : ComponentActivity() { private const val MAGIC_MEMORY = 8 private const val NEXT_UP_TICK_MS = 250L private const val NEXT_EPISODE_PREVIEW_LEAD_MS = 120_000L + + /** + * Where the next-airing notice's own window begins, strictly ahead of + * [NEXT_UP_LEAD_MS] — the notice retires the moment remaining time reaches that + * mark, whether or not there is a next episode to hand off to, so the two can never + * be visible together. + */ + private const val NEXT_EPISODE_AIRING_LEAD_MS = 5 * 60_000L private const val NEXT_EPISODE_PREVIEW_STARTUP_TIMEOUT_MS = 8_000L private const val NEXT_UP_ANIMATION_MS = 260L diff --git a/app/src/main/res/layout/activity_player.xml b/app/src/main/res/layout/activity_player.xml index 37dd94f..114af9a 100644 --- a/app/src/main/res/layout/activity_player.xml +++ b/app/src/main/res/layout/activity_player.xml @@ -44,6 +44,11 @@ above it. Both remain non-focusable lower thirds. --> + + + diff --git a/app/src/main/res/layout/player_next_airing_notice.xml b/app/src/main/res/layout/player_next_airing_notice.xml new file mode 100644 index 0000000..8e496be --- /dev/null +++ b/app/src/main/res/layout/player_next_airing_notice.xml @@ -0,0 +1,55 @@ + + + + + + + + + + + diff --git a/app/src/main/res/layout/player_next_up_banner.xml b/app/src/main/res/layout/player_next_up_banner.xml index a9ffc26..97ce450 100644 --- a/app/src/main/res/layout/player_next_up_banner.xml +++ b/app/src/main/res/layout/player_next_up_banner.xml @@ -4,10 +4,13 @@ It replaced a 420dp card that shrank the picture to 58% to make room for itself, which is the whole complaint: the credits are the last thing an episode has to say and the - overlay was covering them. Nothing here is focusable — the bar takes no part in the - remote's world, so the transport, the seek keys and the centre button all keep meaning - what they meant a moment before it appeared. Play now is the transport's own Next - Episode button, which is offered whenever this bar is, and Back dismisses. + overlay was covering them. Only the action pill at the end is focusable — the rest of + the bar takes no part in the remote's world, so the transport and the seek keys keep + meaning what they meant a moment before it appeared while nothing has been pressed. The + transport's own Next Episode button is still offered whenever this bar is, for a viewer + who already has the controls up; the pill is the same action reachable without them. + PlayerActivity gives it focus as the bar appears and hands focus back to the video the + moment it goes, the `player_skip_intro` pattern. Back dismisses. Everything is in dp and nothing is measured against the screen, so it is the same size on a 720p set and a 4K one; the safe-area margins keep it clear of overscan. --> @@ -104,5 +107,37 @@ android:layout_width="38dp" android:layout_height="38dp" android:layout_marginStart="20dp" /> + + + + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 866334f..56cbf5a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -96,6 +96,14 @@ a clock value over a minute, a bare count of seconds under one. Two figures on one bar that disagree - "60s" beside "1:00" - read as a countdown that has gone wrong. --> Starting in %1$s + + Start Next Episode + + NEXT EPISODE diff --git a/app/src/test/java/com/ponzischeme89/memby/data/analytics/PlaybackJourneyTest.kt b/app/src/test/java/com/ponzischeme89/memby/data/analytics/PlaybackJourneyTest.kt index 9233f58..db058f2 100644 --- a/app/src/test/java/com/ponzischeme89/memby/data/analytics/PlaybackJourneyTest.kt +++ b/app/src/test/java/com/ponzischeme89/memby/data/analytics/PlaybackJourneyTest.kt @@ -129,6 +129,43 @@ class PlaybackJourneyTest { assertEquals("success", events.step("playback", "start").outcome) } + @Test + fun `a pause and its resume carry where playback was, in one journey`() { + val journey = collector() + val entryPoint = playbackEntryPointFor(rowId = "continue", rowKind = "CONTINUE") + + PlaybackJourney.started( + sink = journey, entryPoint = entryPoint, + itemId = "ep-9", itemName = "The Pitt – 7:00 A.M.", itemType = "Episode", + playSessionId = "sess-9", + ) + PlaybackJourney.paused( + sink = journey, entryPoint = entryPoint, + itemId = "ep-9", itemName = "The Pitt – 7:00 A.M.", itemType = "Episode", + playSessionId = "sess-9", positionMs = 922_000, + ) + PlaybackJourney.resumed( + sink = journey, entryPoint = entryPoint, + itemId = "ep-9", itemName = "The Pitt – 7:00 A.M.", itemType = "Episode", + playSessionId = "sess-9", positionMs = 922_000, + ) + val events = journey.drain() + + val paused = events.step("playback", "pause") + assertEquals(922_000L, paused.positionMs) + assertEquals("sess-9", paused.playSessionId) + assertEquals("continue_watching", paused.source) + // No outcome: a pause is neither a success nor a failure, it is where somebody left off. + assertEquals("", paused.outcome) + + val resumed = events.step("playback", "resume") + assertEquals(922_000L, resumed.positionMs) + assertEquals("sess-9", resumed.playSessionId) + + // Both steps belong to the one journey and viewing session the start opened. + assertEquals(setOf("journey-1"), events.map { it.journeyId }.toSet()) + } + @Test fun `a failure is the same step wearing the other outcome`() { val journey = collector() diff --git a/app/src/test/java/com/ponzischeme89/memby/ui/player/NextUpPipelineTest.kt b/app/src/test/java/com/ponzischeme89/memby/ui/player/NextUpPipelineTest.kt index 9e23b2a..0912521 100644 --- a/app/src/test/java/com/ponzischeme89/memby/ui/player/NextUpPipelineTest.kt +++ b/app/src/test/java/com/ponzischeme89/memby/ui/player/NextUpPipelineTest.kt @@ -255,4 +255,68 @@ class NextUpPipelineTest { assertNull(resolver.current) assertFalse(resolver.resolving) } + + // --- The next-airing notice's own window --------------------------------------------- + + private val windowMs = 5 * 60_000L + private val leadMs = 60_000L + + @Test + fun `the notice is independent of whether a next episode exists`() { + // The whole point: a continuing show can have nothing to advance to and the notice + // must still show, since nextAiringNoticeVisible never takes hasNextEpisode at all. + assertTrue( + nextAiringNoticeVisible( + available = true, remainingMs = 4 * 60_000L, windowMs = windowMs, leadMs = leadMs, + dismissed = false, canShow = true, + ), + ) + } + + @Test + fun `the notice retires at the next-up banner's own lead, never inside it`() { + assertFalse( + "exactly at the banner's lead the notice must already be gone", + nextAiringNoticeVisible( + available = true, remainingMs = leadMs, windowMs = windowMs, leadMs = leadMs, + dismissed = false, canShow = true, + ), + ) + assertTrue( + "one tick before the banner's lead it is still the notice's window", + nextAiringNoticeVisible( + available = true, remainingMs = leadMs + 1, windowMs = windowMs, leadMs = leadMs, + dismissed = false, canShow = true, + ), + ) + } + + @Test + fun `a dismissal or an unavailable answer or something else owning the screen refuses it`() { + assertTrue( + nextAiringNoticeVisible(true, windowMs - 1L, windowMs, leadMs, dismissed = false, canShow = true), + ) + assertFalse( + "no scheduled airing to report", + nextAiringNoticeVisible(false, windowMs - 1L, windowMs, leadMs, dismissed = false, canShow = true), + ) + assertFalse( + "dismissed by Back for this episode", + nextAiringNoticeVisible(true, windowMs - 1L, windowMs, leadMs, dismissed = true, canShow = true), + ) + assertFalse( + "something else owns the screen", + nextAiringNoticeVisible(true, windowMs - 1L, windowMs, leadMs, dismissed = false, canShow = false), + ) + } + + @Test + fun `beyond the notice's own window it does not show`() { + assertFalse( + nextAiringNoticeVisible( + available = true, remainingMs = windowMs + 1, windowMs = windowMs, leadMs = leadMs, + dismissed = false, canShow = true, + ), + ) + } } diff --git a/server/internal/api/analytics.go b/server/internal/api/analytics.go index de738ca..9f7ca6d 100644 --- a/server/internal/api/analytics.go +++ b/server/internal/api/analytics.go @@ -20,6 +20,11 @@ const maxAnalyticsBatch = 200 // nothing about what anyone was looking at. const maxDwellMs = 30 * 60 * 1000 +// maxPositionMs clamps a pause/resume step's reported position. A title that long does +// not exist, so a wilder value says the field was misread rather than that playback was +// genuinely there. +const maxPositionMs = 24 * 60 * 60 * 1000 + type rowEventPayload struct { RowID string `json:"rowId"` RowKind string `json:"rowKind"` @@ -52,6 +57,9 @@ type journeyEventPayload struct { PlaySessionID string `json:"playSessionId"` Outcome string `json:"outcome"` OccurredAt string `json:"occurredAt"` + // PositionMs is where playback was, in the title, on a pause or resume step. Absent + // (zero) on every other kind of step. + PositionMs int64 `json:"positionMs"` } type journeyAnalyticsRequest struct { @@ -63,7 +71,7 @@ var journeyActions = allowedAnalyticsValues( "journey_start", "home_open", "journey_end", "screen_view", "open", "close", "select", "submit", "request", "start", "stop", "complete", "abandon", "change", "toggle", "follow", "unfollow", "favourite", "unfavourite", "mark_played", - "mark_unplayed", "retry", "dismiss", "switch", + "mark_unplayed", "retry", "dismiss", "switch", "pause", "resume", ) var journeyOutcomes = allowedAnalyticsValues("", "success", "failure", "cancelled", "completed", "abandoned") @@ -122,7 +130,18 @@ func toJourneyEvent(payload journeyEventPayload, userID string, now time.Time) ( Screen: payload.Screen, Feature: payload.Feature, Source: payload.Source, Target: payload.Target, ItemID: payload.ItemID, ItemName: strings.TrimSpace(payload.ItemName), ItemType: payload.ItemType, - PlaySessionID: payload.PlaySessionID, Outcome: payload.Outcome}, true + PlaySessionID: payload.PlaySessionID, Outcome: payload.Outcome, + PositionMs: clampPositionMs(payload.PositionMs)}, true +} + +func clampPositionMs(value int64) int64 { + if value < 0 { + return 0 + } + if value > maxPositionMs { + return maxPositionMs + } + return value } func safeAnalyticsValue(value string, max int) bool { diff --git a/server/internal/api/analytics_test.go b/server/internal/api/analytics_test.go index 1680107..d0c736d 100644 --- a/server/internal/api/analytics_test.go +++ b/server/internal/api/analytics_test.go @@ -90,6 +90,48 @@ func TestJourneyEventRejectsAnUnreadablePlaySession(t *testing.T) { } } +// Pause and resume are playback steps like start and complete, and carry where playback +// was rather than an outcome — the console derives the pause's length from the gap +// between a pause row's timestamp and its matching resume's. +func TestJourneyEventAcceptsPauseAndResume(t *testing.T) { + now := time.Now().UTC() + for _, action := range []string{"pause", "resume"} { + payload := journeyEventPayload{UserID: "u1", JourneyID: "j1", Category: "playback", + Action: action, Screen: "player", Feature: "playback", Source: "continue_watching", + Target: "player", ItemID: "42", ItemName: "Whale Rider", ItemType: "Movie", + PlaySessionID: "b3d1f0a4-9c22-4f61-8a10-77d0e2c9aa51", PositionMs: 925000} + event, ok := toJourneyEvent(payload, "u1", now) + if !ok { + t.Fatalf("action %q was rejected", action) + } + if event.PositionMs != 925000 { + t.Fatalf("action %q: position was not retained: %+v", action, event) + } + } +} + +// A wild position is clamped rather than dropped: the step it describes is worth more +// than one field on it, the same trade dwell time makes on the row events. +func TestJourneyEventClampsAWildPosition(t *testing.T) { + now := time.Now().UTC() + base := journeyEventPayload{UserID: "u1", JourneyID: "j1", Category: "playback", + Action: "pause", Screen: "player", Feature: "playback", ItemID: "42"} + + negative := base + negative.PositionMs = -500 + event, ok := toJourneyEvent(negative, "u1", now) + if !ok || event.PositionMs != 0 { + t.Fatalf("negative position was not clamped to zero: %+v", event) + } + + wild := base + wild.PositionMs = maxPositionMs + 1 + event, ok = toJourneyEvent(wild, "u1", now) + if !ok || event.PositionMs != maxPositionMs { + t.Fatalf("oversized position was not clamped: %+v", event) + } +} + func TestJourneyEventRetainsTheItemName(t *testing.T) { now := time.Now().UTC() payload := journeyEventPayload{UserID: "u1", JourneyID: "j1", Category: "content", diff --git a/server/internal/api/features.go b/server/internal/api/features.go index bb6c6e2..89c64ac 100644 --- a/server/internal/api/features.go +++ b/server/internal/api/features.go @@ -23,6 +23,7 @@ const ( featureTrickplay = "trickplay" featureSkipIntro = "skip_intro" featureEndCredits = "end_credits" + featureNextEpisodeAiring = "next_episode_airing" featureSeasonalThemes = "seasonal_themes" featureSeasonalDecorations = "seasonal_decorations" featureGenreBrowser = "genre_browser" @@ -251,6 +252,13 @@ var featureCatalogue = []featureDefinition{ DefaultEnabled: true, MinimumProtocol: 1, Capability: "end_credits_v1", Recovery: "Takes effect the next time playback starts; the credits simply play out full size.", }, + { + Key: featureNextEpisodeAiring, Name: "Next episode airing notice", Area: "Playback", + Description: "Show a quiet notice ahead of the Next Up banner naming when a " + + "continuing show's next episode airs, from Sonarr's schedule.", + DefaultEnabled: true, MinimumProtocol: 1, Capability: "next_episode_airing_v1", + Recovery: "Takes effect the next time playback starts; the notice simply stops appearing.", + }, { // The only switch there is for seasonal themes, and it is deliberately the // operator's rather than the viewer's: a per-person opt-out is a thing somebody diff --git a/server/internal/api/playback.go b/server/internal/api/playback.go index 95a94ad..7969d1e 100644 --- a/server/internal/api/playback.go +++ b/server/internal/api/playback.go @@ -62,6 +62,15 @@ type playbackResponse struct { // separate features with separate switches, and a house that has turned the skip button // off has not asked to lose the credits pane with it. EndCreditsAvailable bool `json:"endCreditsAvailable"` + // Whether Sonarr can name when this continuing show's next episode airs. Rides here for + // the same reason the other availability flags do: the player asks once at the start of + // playback and the notice itself takes no further request. Absent (all fields blank) + // means either the show isn't Sonarr-tracked, isn't continuing, or has no scheduled + // airing to report — the client shows nothing in every one of those cases. + NextAiringAvailable bool `json:"nextAiringAvailable"` + NextAiringLabel string `json:"nextAiringLabel,omitempty"` + NextAiringDayLabel string `json:"nextAiringDayLabel,omitempty"` + NextAiringEpisodeCode string `json:"nextAiringEpisodeCode,omitempty"` } type playableSubtitle struct { @@ -114,6 +123,10 @@ type nextEpisodeResponse struct { TrickplayAvailable bool `json:"trickplayAvailable"` SkipIntroAvailable bool `json:"skipIntroAvailable"` EndCreditsAvailable bool `json:"endCreditsAvailable"` + NextAiringAvailable bool `json:"nextAiringAvailable"` + NextAiringLabel string `json:"nextAiringLabel,omitempty"` + NextAiringDayLabel string `json:"nextAiringDayLabel,omitempty"` + NextAiringEpisodeCode string `json:"nextAiringEpisodeCode,omitempty"` } // handlePlayback resolves what to actually play. @@ -235,6 +248,8 @@ func (s *Server) handlePlayback(w http.ResponseWriter, r *http.Request, sess sto "negotiation_duration", time.Since(negotiationStarted).Round(time.Millisecond), ) + nextAiringAvailable, nextAiringLabel, nextAiringDayLabel, nextAiringCode := + s.nextAiringFieldsFor(ctx, target) writeJSON(w, http.StatusOK, playbackResponse{ ItemID: target.ID, Title: title, @@ -257,6 +272,10 @@ func (s *Server) handlePlayback(w http.ResponseWriter, r *http.Request, sess sto TrickplayAvailable: s.trickplayEnabled(ctx), SkipIntroAvailable: s.skipIntroEnabled(ctx), EndCreditsAvailable: s.endCreditsEnabled(ctx), + NextAiringAvailable: nextAiringAvailable, + NextAiringLabel: nextAiringLabel, + NextAiringDayLabel: nextAiringDayLabel, + NextAiringEpisodeCode: nextAiringCode, }) } @@ -336,6 +355,22 @@ func (s *Server) firstPlayableEpisode( return &summary, nil } +// nextAiringFieldsFor answers the current episode's own show's next airing, never the +// episode being played itself. It only ever asks Sonarr about a series that is actually +// known — a hinted playback response that skipped the Emby lookup and so carries no +// SeriesName simply gets nothing, the same silent degradation every other availability +// flag here takes. +func (s *Server) nextAiringFieldsFor(ctx context.Context, item emby.Summary) (available bool, label, dayLabel, code string) { + if !strings.EqualFold(item.Type, "Episode") { + return false, "", "", "" + } + info, ok := s.nextAiringInfoFor(ctx, item.SeriesName) + if !ok { + return false, "", "", "" + } + return true, info.Label, info.DayLabel, info.EpisodeCode +} + func episodeCode(item emby.Summary) string { if !strings.EqualFold(item.Type, "Episode") || item.ParentIndexNumber < 0 || item.IndexNumber <= 0 { return "" @@ -435,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, // and a client that said it does not want one yet must not be given one anyway. + nextAiringAvailable, nextAiringLabel, nextAiringDayLabel, nextAiringCode := + s.nextAiringFieldsFor(ctx, next) if !nextEpisodeWantsStream(r) { s.playbackTitles.remember(next.ID, title) s.loggerFor(ctx).Debug("next episode identified", "title", title, "item", next.ID, "after_item", itemID) writeJSON(w, http.StatusOK, nextEpisodeResponse{ - Item: raw, - Title: title, - StreamReady: false, - ResumePositionMs: max64(next.UserData.PlaybackPositionTicks/ticksPerMillisecond, 0), - Subtitles: []playableSubtitle{}, - SubtitlesEnabled: true, - TrickplayAvailable: s.trickplayEnabled(ctx), - SkipIntroAvailable: s.skipIntroEnabled(ctx), - EndCreditsAvailable: s.endCreditsEnabled(ctx), + Item: raw, + Title: title, + StreamReady: false, + ResumePositionMs: max64(next.UserData.PlaybackPositionTicks/ticksPerMillisecond, 0), + Subtitles: []playableSubtitle{}, + SubtitlesEnabled: true, + TrickplayAvailable: s.trickplayEnabled(ctx), + SkipIntroAvailable: s.skipIntroEnabled(ctx), + EndCreditsAvailable: s.endCreditsEnabled(ctx), + NextAiringAvailable: nextAiringAvailable, + NextAiringLabel: nextAiringLabel, + NextAiringDayLabel: nextAiringDayLabel, + NextAiringEpisodeCode: nextAiringCode, }) return } @@ -491,6 +532,10 @@ func (s *Server) handleNextEpisode(w http.ResponseWriter, r *http.Request, sess TrickplayAvailable: s.trickplayEnabled(ctx), SkipIntroAvailable: s.skipIntroEnabled(ctx), EndCreditsAvailable: s.endCreditsEnabled(ctx), + NextAiringAvailable: nextAiringAvailable, + NextAiringLabel: nextAiringLabel, + NextAiringDayLabel: nextAiringDayLabel, + NextAiringEpisodeCode: nextAiringCode, }) } diff --git a/server/internal/api/sonarr.go b/server/internal/api/sonarr.go index 42c6c3f..4248f49 100644 --- a/server/internal/api/sonarr.go +++ b/server/internal/api/sonarr.go @@ -582,6 +582,153 @@ func scheduleAirLabel(airTime, now time.Time, location *time.Location) string { } } +// nextAiringInfo is what the in-player notice needs about a continuing show's next +// episode: the household's own wording for when it airs, and the episode code when it can +// be resolved cheaply. +type nextAiringInfo struct { + Label string + DayLabel string + EpisodeCode string +} + +// nextEpisodeAiringEnabled gates the whole feature: Sonarr must be configured and the +// operator must not have switched it off. +func (s *Server) nextEpisodeAiringEnabled(ctx context.Context) bool { + return s.sonarrEnabled(ctx) && s.featureEnabled(ctx, featureNextEpisodeAiring) +} + +// nextAiringInfoFor resolves a currently-playing episode's series against Sonarr's own +// catalogue (already cached by sonarrSeriesCatalogue) and answers only when the show is +// still being made and Sonarr has a scheduled airing in the future. Matched by title alone +// — a playing episode's summary carries no series year, and title collisions are rare +// enough that My Shows accepts the same trade when a follow was saved with no year either. +func (s *Server) nextAiringInfoFor(ctx context.Context, seriesName string) (nextAiringInfo, bool) { + if !s.nextEpisodeAiringEnabled(ctx) { + return nextAiringInfo{}, false + } + catalogue, err := s.sonarrSeriesCatalogue(ctx) + if err != nil { + s.loggerFor(ctx).Debug("next airing lookup unavailable", "error", err) + return nextAiringInfo{}, false + } + location := s.cfg.SonarrLocation + if location == nil { + location = time.Local + } + now := time.Now().In(location) + matched, info, ok := resolveNextAiring(catalogue, seriesName, now, location) + if !ok { + return nextAiringInfo{}, false + } + info.EpisodeCode = s.nextAiringEpisodeCode(ctx, matched.ID, *matched.NextAiring) + return info, true +} + +// resolveNextAiring is the pure half of nextAiringInfoFor: given Sonarr's catalogue and the +// series being played, decide whether there is anything to say and, when there is, the +// matched series so the caller can look up its episode code. Kept apart from the Server +// method so the matching, lifecycle and future-airing rules can be pinned without a live +// cache or Sonarr client. +func resolveNextAiring( + catalogue []sonarr.Series, seriesName string, now time.Time, location *time.Location, +) (*sonarr.Series, nextAiringInfo, bool) { + seriesName = strings.TrimSpace(seriesName) + if seriesName == "" { + return nil, nextAiringInfo{}, false + } + key := normalizedShowTitle(seriesName) + var matched *sonarr.Series + for i := range catalogue { + if normalizedShowTitle(catalogue[i].Title) != key { + continue + } + matched = &catalogue[i] + break + } + if matched == nil { + return nil, nextAiringInfo{}, false + } + if seriesLifecycleTag(matched.Status).Status != "continuing" { + return nil, nextAiringInfo{}, false + } + if matched.NextAiring == nil || !matched.NextAiring.After(now) { + return nil, nextAiringInfo{}, false + } + airTime := matched.NextAiring.In(location) + return matched, nextAiringInfo{ + Label: scheduleAirLabel(airTime, now, location), + DayLabel: scheduleAirDayLabel(airTime, now, location), + }, true +} + +// nextAiringEpisodeCode is a best-effort lookup against the same 5-day calendar window the +// schedule row already fetches and caches. A next airing beyond that window, or one Sonarr +// has not yet attached to a specific episode, omits the code rather than guessing at it. +func (s *Server) nextAiringEpisodeCode(ctx context.Context, seriesID int, nextAiring time.Time) string { + episodes, err := s.sonarrUpcomingEpisodes(ctx) + if err != nil { + return "" + } + for _, episode := range episodes { + candidateSeriesID := episode.SeriesID + if episode.Series.ID > 0 { + candidateSeriesID = episode.Series.ID + } + if candidateSeriesID != seriesID || episode.AirDateUTC == nil { + continue + } + if episode.AirDateUTC.Equal(nextAiring) { + return fmt.Sprintf("S%02dE%02d", episode.SeasonNumber, episode.EpisodeNumber) + } + } + return "" +} + +// sonarrUpcomingEpisodes shares the schedule row's own cached calendar window, so this +// feature costs no extra request to Sonarr beyond what the launcher's schedule row already +// pays for once a day. +func (s *Server) sonarrUpcomingEpisodes(ctx context.Context) ([]sonarr.Episode, error) { + location := s.cfg.SonarrLocation + if location == nil { + location = time.Local + } + now := time.Now().In(location) + dayStart := localDayStart(now, location) + key := sonarrCalendarCachePrefix + "episodes:" + dayStart.Format("2006-01-02") + if episodes := s.cachedSonarrEpisodes(ctx, key); episodes != nil { + return episodes, nil + } + + s.sonarrMu.Lock() + defer s.sonarrMu.Unlock() + if episodes := s.cachedSonarrEpisodes(ctx, key); episodes != nil { + return episodes, nil + } + + episodes, err := s.sonarr.Calendar(ctx, dayStart, dayStart.AddDate(0, 0, sonarrScheduleDays)) + if err != nil { + return nil, err + } + if body, marshalErr := json.Marshal(episodes); marshalErr == nil { + if cacheErr := s.cache.Set(ctx, key, body, s.cfg.SonarrTTL); cacheErr != nil { + s.loggerFor(ctx).Warn("sonarr upcoming episodes cache write failed", "error", cacheErr) + } + } + return episodes, nil +} + +func (s *Server) cachedSonarrEpisodes(ctx context.Context, key string) []sonarr.Episode { + raw, err := s.cache.Get(ctx, key) + if err != nil { + return nil + } + var episodes []sonarr.Episode + if json.Unmarshal(raw, &episodes) != nil { + return nil + } + return episodes +} + func localDayStart(value time.Time, location *time.Location) time.Time { value = value.In(location) return time.Date(value.Year(), value.Month(), value.Day(), 0, 0, 0, 0, location) diff --git a/server/internal/api/sonarr_test.go b/server/internal/api/sonarr_test.go index c8ab58c..ae15d33 100644 --- a/server/internal/api/sonarr_test.go +++ b/server/internal/api/sonarr_test.go @@ -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) + } +} diff --git a/server/internal/buildinfo/VERSION b/server/internal/buildinfo/VERSION index d9ba747..ad069db 100644 --- a/server/internal/buildinfo/VERSION +++ b/server/internal/buildinfo/VERSION @@ -1 +1 @@ -0.1.75 +0.1.76 diff --git a/server/internal/store/analytics.go b/server/internal/store/analytics.go index 7de834f..92c6dc2 100644 --- a/server/internal/store/analytics.go +++ b/server/internal/store/analytics.go @@ -81,6 +81,10 @@ type JourneyEvent struct { // the two records having to agree on anything but this. PlaySessionID string `json:"playSessionId,omitempty"` Outcome string `json:"outcome,omitempty"` + // PositionMs is where playback was, in the title, at a pause or resume step. Zero on + // every other kind of step. A pause's duration is derived by the reader from the gap + // between a pause event's OccurredAt and its matching resume's, never stored directly. + PositionMs int64 `json:"positionMs,omitempty"` } type AnalyticsUser struct { @@ -336,13 +340,14 @@ func (s *Store) InsertJourneyEvents(ctx context.Context, events []JourneyEvent) batch.Queue(` INSERT INTO journey_events (occurred_at, emby_user_id, journey_id, sequence, category, action, screen, - feature, source, target, item_id, item_name, item_type, play_session_id, outcome) - VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15) + feature, source, target, item_id, item_name, item_type, play_session_id, outcome, + position_ms) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16) ON CONFLICT (emby_user_id, journey_id, sequence) DO NOTHING`, event.OccurredAt, event.UserID, event.JourneyID, event.Sequence, event.Category, event.Action, event.Screen, event.Feature, event.Source, event.Target, event.ItemID, event.ItemName, event.ItemType, - event.PlaySessionID, event.Outcome) + event.PlaySessionID, event.Outcome, event.PositionMs) } results := s.pool.SendBatch(ctx, batch) defer results.Close() @@ -564,7 +569,7 @@ func (s *Store) UserJourneyEvents(ctx context.Context, userID string, since time rows, err := s.pool.Query(ctx, ` SELECT id, occurred_at, emby_user_id, journey_id, sequence, category, action, screen, feature, source, target, item_id, item_name, item_type, - play_session_id, outcome + play_session_id, outcome, position_ms FROM journey_events WHERE emby_user_id=$1 AND occurred_at >= $2 ORDER BY occurred_at DESC, sequence DESC LIMIT $3`, userID, since, limit) if err != nil { @@ -574,7 +579,7 @@ func (s *Store) UserJourneyEvents(ctx context.Context, userID string, since time out := []JourneyEvent{} for rows.Next() { var v JourneyEvent - if err := rows.Scan(&v.ID, &v.OccurredAt, &v.UserID, &v.JourneyID, &v.Sequence, &v.Category, &v.Action, &v.Screen, &v.Feature, &v.Source, &v.Target, &v.ItemID, &v.ItemName, &v.ItemType, &v.PlaySessionID, &v.Outcome); err != nil { + if err := rows.Scan(&v.ID, &v.OccurredAt, &v.UserID, &v.JourneyID, &v.Sequence, &v.Category, &v.Action, &v.Screen, &v.Feature, &v.Source, &v.Target, &v.ItemID, &v.ItemName, &v.ItemType, &v.PlaySessionID, &v.Outcome, &v.PositionMs); err != nil { return nil, err } out = append(out, v) diff --git a/server/internal/store/schema.sql b/server/internal/store/schema.sql index afbf18a..ce3bcb2 100644 --- a/server/internal/store/schema.sql +++ b/server/internal/store/schema.sql @@ -222,6 +222,10 @@ ALTER TABLE journey_events ADD COLUMN IF NOT EXISTS item_name TEXT NOT NULL DEFA -- Emby's id for the stream a playback step describes. Empty on every other kind of step, -- and on every row written before playback steps carried one. ALTER TABLE journey_events ADD COLUMN IF NOT EXISTS play_session_id TEXT NOT NULL DEFAULT ''; +-- Where playback was in the title at the moment of a pause or resume step, in +-- milliseconds. Zero on every other kind of step. A pause's length is never stored of its +-- own accord — it is the gap between a pause row's occurred_at and its matching resume's. +ALTER TABLE journey_events ADD COLUMN IF NOT EXISTS position_ms BIGINT NOT NULL DEFAULT 0; CREATE UNIQUE INDEX IF NOT EXISTS journey_events_journey_sequence_idx ON journey_events (emby_user_id, journey_id, sequence);