diff --git a/src/browser/offline.ts b/src/browser/offline.ts index 803eedc..7a0cbc7 100644 --- a/src/browser/offline.ts +++ b/src/browser/offline.ts @@ -1,6 +1,10 @@ import { advanceCursor, + eligibleSlots, + firstEligibilityBoundary, nextTimingBoundary, + TIMING_TRANSITION_LIMIT, + type TimingCursor, type TimingSlot, type TimingTimeline, } from "../shared/playback-timing.js"; @@ -111,17 +115,110 @@ function isUsable(selection: SceneSelection, atMs: number, unusable: ReadonlySet return selection.availableUntilMs === null || atMs < selection.availableUntilMs; } -/** The most recent cached answer for one slot. History is newest-first, so the first match is the - * one that most recently played there. */ -function cachedForSlot( +/** + * The answers one slot has already had, as the rotation they came in: one per item, oldest-played + * first, so the item on screen most recently is last. + * + * A pinned slot has one item and so a rotation of one. A provider-source slot is answered with the + * provider's *next* item on every play, so its history is the provider's own order — and replaying + * only the newest answer would replay the image already on screen at every boundary. Each item's + * newest answer is the one that counts: an item whose newest answer was withdrawn or failed here is + * left out, never replaced by an older answer for it. + */ +function rotationForSlot( snapshot: OfflineSnapshot, slotId: string, atMs: number, unusable: ReadonlySet, +): readonly SceneSelection[] { + const seen = new Set(); + const newestFirst: SceneSelection[] = []; + // History is newest-first, so the first answer seen for an item is its most recent one. + for (const selection of snapshot.selections) { + if (selection.slotId !== slotId) { + continue; + } + const item = `${selection.providerId}\u0000${selection.itemId}`; + if (seen.has(item)) { + continue; + } + seen.add(item); + if (isUsable(selection, atMs, unusable)) { + newestFirst.push(selection); + } + } + return newestFirst.reverse(); +} + +/** The cached answer for the `play`-th play of a slot since the anchor (1 is the next one). */ +function cachedForSlot( + snapshot: OfflineSnapshot, + slotId: string, + play: number, + atMs: number, + unusable: ReadonlySet, ): SceneSelection | undefined { - return snapshot.selections.find( - (selection) => selection.slotId === slotId && isUsable(selection, atMs, unusable), - ); + const rotation = rotationForSlot(snapshot, slotId, atMs, unusable); + if (rotation.length === 0) { + return undefined; + } + return rotation[(Math.max(1, play) - 1) % rotation.length]; +} + +/** + * How many times `slotId` has started since the anchor, up to and including a play starting at + * `throughMs`. + * + * Arithmetic, like the cursor itself: wherever the eligible set cannot change, whole passes of the + * pattern are counted by division — every eligible slot plays exactly once per pass — so a week + * offline is one evaluation. It only chooses *which* cached answer a play shows; where a play + * starts is still decided by `advanceCursor` alone. + */ +function playsSinceAnchor( + timeline: TimingTimeline, + anchor: TimingCursor, + slotId: string, + throughMs: number, +): number { + let cursor = anchor; + let plays = 0; + for (let step = 0; step < TIMING_TRANSITION_LIMIT; step += 1) { + const fromMs = cursor.deadlineAtMs; + if (fromMs === null || fromMs > throughMs) { + break; + } + const stable = eligibleSlots(timeline, fromMs); + const passDuration = stable.reduce((total, slot) => total + slot.estimatedDurationMs, 0); + const changeAt = firstEligibilityBoundary(timeline, fromMs, throughMs); + const stableThrough = changeAt === null ? throughMs : changeAt - 1; + if (passDuration > 0 && stable.some((slot) => slot.id === cursor.slotId)) { + const passes = Math.floor((stableThrough - fromMs) / passDuration); + if (passes > 0) { + if (stable.some((slot) => slot.id === slotId)) { + plays += passes; + } + cursor = { + deadlineAtMs: fromMs + passes * passDuration, + slotId: cursor.slotId, + startedAtMs: (cursor.startedAtMs ?? fromMs) + passes * passDuration, + }; + continue; + } + } + const next = advanceCursor(timeline, cursor, fromMs); + if (next.kind !== "advance") { + break; + } + if (next.slotId === slotId) { + plays += 1; + } + cursor = { + deadlineAtMs: next.deadlineAtMs, + slotId: next.slotId, + startedAtMs: next.startedAtMs, + }; + } + return plays; } export interface OfflineEvaluation { @@ -179,6 +276,11 @@ export function resolveOffline(input: { } const timing = timingTimelineOf(timeline); + const anchor: TimingCursor = { + deadlineAtMs: snapshot.playback.deadlineAtMs, + slotId: snapshot.playback.currentSlotId, + startedAtMs: snapshot.playback.startedAtMs, + }; let cursor = { deadlineAtMs: snapshot.playback.deadlineAtMs, slotId: snapshot.playback.currentSlotId, @@ -224,7 +326,13 @@ export function resolveOffline(input: { }; } - const cached = cachedForSlot(snapshot, advance.slotId, atMs, unusable); + const cached = cachedForSlot( + snapshot, + advance.slotId, + playsSinceAnchor(timing, anchor, advance.slotId, advance.startedAtMs), + atMs, + unusable, + ); if (cached) { const nextCursor = { deadlineAtMs: advance.deadlineAtMs, diff --git a/tests/browser/offline.test.ts b/tests/browser/offline.test.ts index 42ded36..2c7468f 100644 --- a/tests/browser/offline.test.ts +++ b/tests/browser/offline.test.ts @@ -295,6 +295,98 @@ describe("offline continuation", () => { expect(JSON.stringify(persisted)).not.toContain("sig=secret"); expect(JSON.stringify(persisted)).not.toContain("https://"); }); + + /** + * Stage 0's real timeline: **one** provider-source slot, which the server answers with the next + * image of the provider's rotation on every play. Offline, replaying only that slot's newest answer + * meant replaying the image already on screen — every boundary "advanced" to the same picture. + */ + function rotatingSnapshot(newestFirstItems: readonly string[]): OfflineSnapshot { + const selections = newestFirstItems.map((item, index) => + selection({ id: `sel-${index}`, itemId: `item-${item}`, slotId: "slot-source" }), + ); + return snapshot({ + playback: { + blackout: false, + currentSelectionId: "sel-0", + currentSlotId: "slot-source", + deadlineAtMs: 5000, + pendingTimelineId: null, + remainingMs: 5000, + revision: 3, + startedAtMs: 0, + transport: "playing", + }, + selections, + timeline: { + eligibleFromMs: null, + eligibleUntilMs: null, + id: "timeline-1", + revision: 2, + slots: [slot("slot-source", 0)], + }, + }); + } + + function itemAt(persisted: OfflineSnapshot, atMs: number): string | undefined { + const { resolution } = resolveOffline({ atMs, snapshot: persisted }); + return resolution.kind === "selection" ? resolution.selection.itemId : undefined; + } + + it("continues a source slot's rotation through the answers it already has", () => { + // On screen: B. Before it, A, then B, then A — the provider's two-image rotation. + const persisted = rotatingSnapshot(["b", "a", "b", "a"]); + + expect(itemAt(persisted, 2500)).toBe("item-b"); + expect([7500, 12_500, 17_500, 22_500, 27_500].map((at) => itemAt(persisted, at))).toEqual([ + "item-a", + "item-b", + "item-a", + "item-b", + "item-a", + ]); + }); + + it("keeps the provider's own order across a longer rotation", () => { + // Played C, A, B, C, A, B — B is on screen, so C comes next, then A, then B. + const persisted = rotatingSnapshot(["b", "a", "c", "b", "a", "c"]); + + expect([7500, 12_500, 17_500, 22_500].map((at) => itemAt(persisted, at))).toEqual([ + "item-c", + "item-a", + "item-b", + "item-c", + ]); + }); + + it("catches a rotation up across a week in one evaluation", () => { + const persisted = rotatingSnapshot(["b", "a", "c", "b", "a", "c"]); + // Mid-way through the slot's 120,961st play since the anchor. + const week = 7 * 24 * 60 * 60 * 1000 + 7500; + const started = Date.now(); + + // A three-image rotation that resumes at C shows C on every play numbered 1 mod 3. + expect(itemAt(persisted, week)).toBe("item-c"); + expect(itemAt(persisted, week + 5000)).toBe("item-a"); + expect(Date.now() - started).toBeLessThan(200); + }); + + it("skips an image it cannot render and keeps rotating through the rest", () => { + const persisted = rotatingSnapshot(["b", "a", "c", "b", "a", "c"]); + const unusable = new Set(["sel-1"]); + + const items = [7500, 12_500, 17_500].map((atMs) => { + const { resolution } = resolveOffline({ + atMs, + snapshot: persisted, + unusableSelectionIds: unusable, + }); + return resolution.kind === "selection" ? resolution.selection.itemId : undefined; + }); + + // A's newest answer is unusable, so A is not shown — and no older answer of A is dug up instead. + expect(items).toEqual(["item-c", "item-b", "item-c"]); + }); }); describe("a display that loses its network", () => { @@ -660,18 +752,25 @@ describe("a running display that loses its network for several slides", () => { return selectionId?.split("-")[1]; } + /** The two timeline shapes a pair of alternating images can take. Two pinned slots, one image + * each; or Stage 0's real one — a single provider-source slot the server answers with the next + * image of the rotation on every play. */ + type TimelineShape = "two pinned slots" | "one provider-source slot"; + /** The server's answer at `atMs`: two images alternating, each play its own selection, and a * freshly signed delivery URL on every answer — exactly as Signage's own server sends them. */ - function serverState(atMs: number): DisplayChannelStateView { + function serverState(atMs: number, shape: TimelineShape): DisplayChannelStateView { const cycle = Math.floor(atMs / SLOT_MS); const slide = (c: number) => (c % 2 === 0 ? "a" : "b"); + const slotFor = (c: number) => + shape === "two pinned slots" ? `slot-${slide(c)}` : "slot-source"; const history: DisplaySelection[] = [cycle, cycle - 1, cycle - 2] .filter((c) => c >= 0) .map((c) => ({ ...selection({ id: `sel-${slide(c)}-${c}`, itemId: `item-${slide(c)}`, - slotId: `slot-${slide(c)}`, + slotId: slotFor(c), }), media: { display: { @@ -716,16 +815,29 @@ describe("a running display that loses its network for several slides", () => { eligibleUntilMs: null, id: "timeline-1", revision: 1, - slots: [ - slot("slot-a", 0, { estimatedDurationMs: SLOT_MS }), - slot("slot-b", 1, { estimatedDurationMs: SLOT_MS }), - ], + slots: + shape === "two pinned slots" + ? [ + slot("slot-a", 0, { estimatedDurationMs: SLOT_MS }), + slot("slot-b", 1, { estimatedDurationMs: SLOT_MS }), + ] + : [slot("slot-source", 0, { estimatedDurationMs: SLOT_MS })], }, }; } - it("keeps advancing through its cached images on an origin with no Cache Storage", async () => { - const network = { reachable: true }; + it.each([ + // Wi-Fi off on a plain-http LAN origin: no API, no storage, and no Cache Storage. + { outage: "network", shape: "two pinned slots" }, + { outage: "network", shape: "one provider-source slot" }, + // The API process stopped while the storage server stays up. + { outage: "api", shape: "one provider-source slot" }, + ] as const)("keeps cycling through its cached images with the $outage down: $shape", async ({ + outage, + shape, + }) => { + const api = { reachable: true }; + const storage = { reachable: true }; // Mid-slide, so every sample below lands halfway through one. const clock = createClock(12_500); const unreachable = () => new TypeError("Failed to fetch"); @@ -749,17 +861,17 @@ describe("a running display that loses its network for several slides", () => { method: "GET", path: /^viewers\/[^/]+\/display-state$/u, respond: () => { - if (!network.reachable) { + if (!api.reachable) { throw unreachable(); } - return jsonResponse(serverState(clock.now())); + return jsonResponse(serverState(clock.now(), shape)); }, }, { method: "POST", path: /^viewers\/.*\/heartbeat$/u, respond: () => { - if (!network.reachable) { + if (!api.reachable) { throw unreachable(); } return jsonResponse({}); @@ -767,10 +879,10 @@ describe("a running display that loses its network for several slides", () => { }, ], }); - // A plain-http LAN origin: no `caches`, but `fetch` — which needs the network like anything else. + // A plain-http LAN origin: no `caches`, but `fetch` — which needs the storage server. const media = createBrowserMediaCache({ fetch: async () => { - if (!network.reachable) { + if (!storage.reachable) { throw unreachable(); } return new Response(new Uint8Array([1, 2, 3]), { @@ -783,9 +895,9 @@ describe("a running display that loses its network for several slides", () => { const controller = createDisplayController({ clearTimer: clock.clearTimer, context: host.context, - // What a browser does: bytes this page holds always decode, and a remote URL needs a network. + // What a browser does: bytes this page holds always decode, and a remote URL needs storage. decodeImage: (image) => - (image.getAttribute("src") ?? "").startsWith("blob:") || network.reachable + (image.getAttribute("src") ?? "").startsWith("blob:") || storage.reachable ? Promise.resolve() : Promise.reject(new Error("net::ERR_INTERNET_DISCONNECTED")), media, @@ -807,8 +919,9 @@ describe("a running display that loses its network for several slides", () => { expect(online).toEqual(["a", "b", "a"]); expect(controller.diagnostics().offline).toBe(false); - // The network goes away for six slides. - network.reachable = false; + // The outage lasts six slides. + api.reachable = false; + storage.reachable = outage !== "network"; const offline: (string | undefined)[] = []; for (let interval = 0; interval < 6; interval += 1) { await clock.advance(SLOT_MS); @@ -821,8 +934,9 @@ describe("a running display that loses its network for several slides", () => { expect(controller.diagnostics().unusableSelectionIds).toEqual([]); expect(root.dataset.slate).toBe("false"); - // The network returns. The server is authoritative again with no reload. - network.reachable = true; + // Everything returns. The server is authoritative again with no reload. + api.reachable = true; + storage.reachable = true; await clock.advance(70_000); const diagnostics = controller.diagnostics(); expect(diagnostics.offline).toBe(false);