@tribe-nest/forge 3.34.0 → 3.36.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/package.json +3 -2
  2. package/src/client/_tests/tenantHeaders.spec.ts +77 -0
  3. package/src/client/createForgeClient.ts +37 -0
  4. package/src/data/queries/_tests/paymentFlowReturnUrl.spec.tsx +165 -0
  5. package/src/data/queries/useFilmPlaybackSession.ts +224 -0
  6. package/src/data/queries/useFilms.ts +561 -0
  7. package/src/data/queries/usePaymentFlow.ts +28 -3
  8. package/src/i18n/de.json +130 -28
  9. package/src/i18n/en.json +130 -28
  10. package/src/index.ts +5 -0
  11. package/src/provider/ForgeAppProvider.tsx +10 -0
  12. package/src/provider/ForgeProvider.tsx +18 -3
  13. package/src/server/index.ts +76 -17
  14. package/src/ui/headless/_tests/dialogPaystackStandDown.spec.tsx +185 -0
  15. package/src/ui/headless/dialog.tsx +139 -4
  16. package/src/ui/headless/film/FilmWatermark.tsx +180 -0
  17. package/src/ui/headless/film/_tests/filmRules.spec.ts +531 -0
  18. package/src/ui/headless/film/_tests/useStageFullscreen.spec.ts +167 -0
  19. package/src/ui/headless/film/index.ts +36 -0
  20. package/src/ui/headless/film/useFilmCatalog.ts +66 -0
  21. package/src/ui/headless/film/useFilmPlayback.ts +497 -0
  22. package/src/ui/headless/film/useFilmRentalFlow.ts +277 -0
  23. package/src/ui/headless/film/useStageFullscreen.ts +156 -0
  24. package/src/ui/headless/index.ts +6 -9
  25. package/src/ui/index.ts +21 -16
  26. package/src/ui/media/CallStage.tsx +59 -1
  27. package/src/ui/media/CallWindowNotice.tsx +94 -0
  28. package/src/ui/media/_tests/CallWindowNotice.spec.tsx +83 -0
  29. package/src/ui/media/index.ts +9 -0
  30. package/src/ui/styled/AccountDashboard.tsx +101 -37
  31. package/src/ui/styled/FilmCatalog.tsx +278 -0
  32. package/src/ui/styled/FilmDetail.tsx +661 -0
  33. package/src/ui/styled/FilmLibrary.tsx +291 -0
  34. package/src/ui/styled/FilmWatch.tsx +701 -0
  35. package/src/ui/styled/_tests/AccountDashboardBookingCall.spec.tsx +28 -0
  36. package/src/ui/styled/_tests/AccountDashboardRentals.spec.tsx +200 -0
  37. package/src/ui/styled/forge-utilities.css +6 -0
  38. package/src/utils/_tests/paystackCheckout.spec.ts +82 -1
  39. package/src/utils/paystackCheckout.ts +47 -0
  40. package/src/utils/structuredData.ts +88 -17
@@ -0,0 +1,497 @@
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2
+ import { usePublicAuth } from "../../../contexts/PublicAuthContext";
3
+ import { useForge } from "../../../provider/ForgeProvider";
4
+ import { safeRedirectPath } from "../../../utils/safeRedirect";
5
+ import {
6
+ FILM_SESSION_EXPIRED,
7
+ FILM_SESSION_TAKEN_OVER,
8
+ absolutizeFilmUrl,
9
+ filmErrorCode,
10
+ filmErrorMessage,
11
+ filmErrorStatus,
12
+ useCreateFilmPlaybackSession,
13
+ useFilmPlaybackHeartbeat,
14
+ useRecordFilmProgress,
15
+ withFilmSessionToken,
16
+ type FilmPlaybackSession,
17
+ type FilmSubtitleTrack,
18
+ type FilmWatermarkPayload,
19
+ } from "../../../data/queries/useFilmPlaybackSession";
20
+
21
+ /** A `<track>` ready to render: the session's track with a fetchable `src`. */
22
+ export interface FilmPlaybackSubtitle extends FilmSubtitleTrack {
23
+ /** Absolute and token-bearing, built exactly the way `src` is. */
24
+ src: string;
25
+ }
26
+
27
+ /**
28
+ * Headless episode playback: open the session, keep it alive, hand a player one
29
+ * URL, count the window down, and report where the fan got to.
30
+ *
31
+ * ## What this hook is not
32
+ *
33
+ * It is not the protection. Every layer that actually holds is on the server and
34
+ * is re-derived per request: the key endpoint re-checks the rental, the session
35
+ * holder and the token's episode binding on every fetch; the manifest is
36
+ * session-bound and single-episode-bound; the segment URLs are short-lived
37
+ * presigns; the platform watermark is burned into the pixels. A custom player
38
+ * built on this hook loses exactly ONE layer, the dynamic viewer overlay, and
39
+ * that layer deters rather than prevents. Drop `<FilmWatermark>` and a leaked
40
+ * recording is still traceable to the film but no longer to the renter. That is
41
+ * a real loss and it is stated here rather than discovered later.
42
+ *
43
+ * ## Why the src is deliberately stable
44
+ *
45
+ * The heartbeat mints a FRESH token every minute, but `src` is not rebuilt from
46
+ * it. Changing a `<video>` source mid-film restarts the film, which is a worse
47
+ * failure than the one it would prevent. The stored playlist is fetched once for
48
+ * VOD, so the original token is all a normal watch needs. The exception is an
49
+ * ABR switch after the token's ~10 minutes are up, which 401s inside the engine;
50
+ * that is what `recoverPlayback()` is for, and why a player should wire its own
51
+ * fatal-error handler to it rather than reloading the page.
52
+ */
53
+
54
+ /** What the watch surface should draw INSTEAD of the player. */
55
+ export type FilmPlaybackGateKind =
56
+ /** Nobody is signed in, and this episode is not a free preview. Needs a way IN. */
57
+ | "sign_in_required"
58
+ /**
59
+ * Signed in as somebody else. Needs a way OUT of the current session, never
60
+ * the word "sign in", which they have already done.
61
+ *
62
+ * The API does not currently emit 403 here - rentals are account-bound and a
63
+ * miss is a 404 so the endpoint is not an id oracle - but the arm is kept
64
+ * because it is the one state whose copy is actively harmful if merged into
65
+ * `sign_in_required`, and because the money path can start emitting it without
66
+ * a Forge change.
67
+ */
68
+ | "wrong_account"
69
+ /** No live rental covers this episode. Also what an unreleased or archived episode looks like. */
70
+ | "no_rental"
71
+ /** There WAS a rental and its window has run out. Offer a re-rent, not a sign-in. */
72
+ | "expired"
73
+ /** Something else went wrong: offline, 500, a slug that resolves to nothing. */
74
+ | "unavailable";
75
+
76
+ export interface FilmPlaybackGate {
77
+ kind: FilmPlaybackGateKind;
78
+ /** The API's sentence when it sent one; the caller supplies its own copy otherwise. */
79
+ message: string | null;
80
+ status: number | null;
81
+ }
82
+
83
+ export interface UseFilmPlaybackOptions {
84
+ filmId?: string;
85
+ episodeId?: string;
86
+ /** Sign-in path (default `/login`; the site-starter passes `/i/login`). */
87
+ loginPath?: string;
88
+ /**
89
+ * Is this episode a free preview? Optional, and only an optimisation: it lets
90
+ * the hook attempt a session for an anonymous caller instead of gating first.
91
+ * Getting it wrong costs one refused request, never access.
92
+ */
93
+ isFreePreview?: boolean;
94
+ /** Don't open a session yet (the caller is still resolving the film). */
95
+ enabled?: boolean;
96
+ /** Seconds between position writes. The write is an upsert, so this can be lazy. */
97
+ progressIntervalSec?: number;
98
+ }
99
+
100
+ const DEFAULT_LOGIN_PATH = "/login";
101
+ const DEFAULT_HEARTBEAT_SEC = 60;
102
+ const DEFAULT_PROGRESS_INTERVAL_SEC = 10;
103
+ /** Past this fraction of the runtime the episode counts as watched, which drives "next episode". */
104
+ const COMPLETION_THRESHOLD = 0.95;
105
+
106
+ const currentPath = (): string =>
107
+ typeof window === "undefined" ? "" : `${window.location.pathname}${window.location.search}`;
108
+
109
+ /** `loginPath?redirect=<here>` - a sign-in that lands back on the player, never on a generic home. */
110
+ export const filmSignInHref = (loginPath: string, path: string): string => {
111
+ const target = safeRedirectPath(path, "");
112
+ return target ? `${loginPath}?redirect=${encodeURIComponent(target)}` : loginPath;
113
+ };
114
+
115
+ /**
116
+ * Map a failed session/heartbeat call to what the screen should say.
117
+ *
118
+ * Pure and total so both 4xx arms can be exercised without a DOM, a client or a
119
+ * session. `isAuthenticated` is what separates the two 401 readings: signed out
120
+ * means "sign in", and signed in with a 401 means the SESSION lapsed rather than
121
+ * the person being unknown, which is recoverable rather than a gate.
122
+ */
123
+ export function buildFilmPlaybackGate(input: {
124
+ error: unknown;
125
+ isAuthenticated: boolean;
126
+ /** The viewer's own rental, when one is known. Turns a bare 404 into "expired". */
127
+ hadRental?: boolean;
128
+ }): FilmPlaybackGate | null {
129
+ const { error } = input;
130
+ if (error == null) return null;
131
+ const status = filmErrorStatus(error);
132
+ const message = filmErrorMessage(error);
133
+
134
+ if (status === 401) {
135
+ return { kind: input.isAuthenticated ? "unavailable" : "sign_in_required", message, status };
136
+ }
137
+ if (status === 403) return { kind: "wrong_account", message, status };
138
+ if (status === 410) return { kind: "expired", message, status };
139
+ if (status === 404) return { kind: input.hadRental ? "expired" : "no_rental", message, status };
140
+ return { kind: "unavailable", message, status };
141
+ }
142
+
143
+ /**
144
+ * Milliseconds left on a window, or null when there is no window (a free
145
+ * preview) or the timestamp is unreadable. Never negative: zero IS the answer
146
+ * once it has run out, and a negative number renders as a countdown running
147
+ * backwards.
148
+ */
149
+ export function msUntil(expiresAt: string | null | undefined, now: Date = new Date()): number | null {
150
+ if (!expiresAt) return null;
151
+ const at = new Date(expiresAt).getTime();
152
+ if (!Number.isFinite(at)) return null;
153
+ return Math.max(0, at - now.getTime());
154
+ }
155
+
156
+ export function useFilmPlayback(opts: UseFilmPlaybackOptions) {
157
+ const { filmId, episodeId, isFreePreview, enabled = true } = opts;
158
+ const { apiUrl } = useForge();
159
+ const { isAuthenticated, isInitialized, user, logout } = usePublicAuth();
160
+
161
+ const createSession = useCreateFilmPlaybackSession(filmId, episodeId);
162
+ const heartbeat = useFilmPlaybackHeartbeat(filmId, episodeId);
163
+ const recordProgress = useRecordFilmProgress(filmId, episodeId);
164
+
165
+ const [session, setSession] = useState<FilmPlaybackSession | null>(null);
166
+ const [gate, setGate] = useState<FilmPlaybackGate | null>(null);
167
+ const [takenOver, setTakenOver] = useState(false);
168
+ const [now, setNow] = useState(() => Date.now());
169
+ /**
170
+ * Bumped only by `recoverPlayback()`. It is what makes `src` change, and it is
171
+ * deliberately NOT bumped by the heartbeat - see the hook docblock.
172
+ */
173
+ const [srcNonce, setSrcNonce] = useState(0);
174
+
175
+ /** The freshest token. Read through a ref so the heartbeat timer never re-arms on it. */
176
+ const tokenRef = useRef<string | null>(null);
177
+ /** One session per (film, episode) mount. Without this, StrictMode opens two and one takes the other over. */
178
+ const openedForRef = useRef<string | null>(null);
179
+ const lastProgressSentRef = useRef(0);
180
+ const completedRef = useRef(false);
181
+ /**
182
+ * Read by `reportProgress`, which is memoized and would otherwise close over a
183
+ * stale `takenOver`. A displaced tab whose player keeps firing time updates
184
+ * would post positions the server 409s, and the swallowed failures would
185
+ * quietly discard this rental's resume point for the rest of the film.
186
+ */
187
+ const stoppedRef = useRef(false);
188
+
189
+ const canAttempt =
190
+ enabled &&
191
+ !!filmId &&
192
+ !!episodeId &&
193
+ isInitialized &&
194
+ // An anonymous caller can only ever start a preview. Attempting anything
195
+ // else just to read the 401 back is a wasted round trip on every page view.
196
+ (isAuthenticated || isFreePreview !== false);
197
+
198
+ const open = useCallback(async () => {
199
+ if (!filmId || !episodeId) return;
200
+ setGate(null);
201
+ setTakenOver(false);
202
+ try {
203
+ const result = await createSession.mutateAsync();
204
+ tokenRef.current = result.sessionToken;
205
+ completedRef.current = false;
206
+ setSession(result);
207
+ } catch (error) {
208
+ tokenRef.current = null;
209
+ setSession(null);
210
+ setGate(buildFilmPlaybackGate({ error, isAuthenticated }));
211
+ }
212
+ // `createSession` is a stable react-query mutation object.
213
+ // eslint-disable-next-line react-hooks/exhaustive-deps
214
+ }, [filmId, episodeId, isAuthenticated]);
215
+
216
+ // Open exactly once per (film, episode). The key includes the account so a
217
+ // sign-in on the gate re-attempts rather than sitting on the anonymous refusal.
218
+ useEffect(() => {
219
+ if (!canAttempt) return;
220
+ const key = `${filmId}:${episodeId}:${user?.id ?? "anon"}`;
221
+ if (openedForRef.current === key) return;
222
+ openedForRef.current = key;
223
+ void open();
224
+ }, [canAttempt, filmId, episodeId, user?.id, open]);
225
+
226
+ /**
227
+ * The one refusal we make without asking the server.
228
+ *
229
+ * An anonymous caller on an episode we KNOW is not a free preview would get a
230
+ * 401 and nothing else, so the round trip buys nothing. What it would cost, if
231
+ * this arm were missing, is worse than a wasted request: with no gate and no
232
+ * session the surface has nothing to draw, and the person who simply needs to
233
+ * sign in gets "this cannot be played" instead of a way in.
234
+ */
235
+ useEffect(() => {
236
+ if (!enabled || !filmId || !episodeId || !isInitialized) return;
237
+ if (isAuthenticated || isFreePreview !== false) return;
238
+ setGate({ kind: "sign_in_required", message: null, status: 401 });
239
+ }, [enabled, filmId, episodeId, isInitialized, isAuthenticated, isFreePreview]);
240
+
241
+ // ── Heartbeat ──────────────────────────────────────────────────────────────
242
+ //
243
+ // Armed on the session id, not on the token: the token is replaced every beat
244
+ // and depending on it would tear the timer down and rebuild it each minute.
245
+ const sessionId = session?.sessionId ?? null;
246
+ const heartbeatSec = session?.heartbeatIntervalSec || DEFAULT_HEARTBEAT_SEC;
247
+
248
+ useEffect(() => {
249
+ if (!sessionId || takenOver || gate) return;
250
+ let cancelled = false;
251
+
252
+ const beat = async () => {
253
+ const token = tokenRef.current;
254
+ if (!token || cancelled) return;
255
+ try {
256
+ const result = await heartbeat.mutateAsync({ sessionToken: token });
257
+ if (cancelled) return;
258
+ tokenRef.current = result.sessionToken;
259
+ setSession((prev) => (prev ? { ...prev, expiresAt: result.expiresAt } : prev));
260
+ } catch (error) {
261
+ if (cancelled) return;
262
+ const code = filmErrorCode(error);
263
+ if (code === FILM_SESSION_TAKEN_OVER || filmErrorStatus(error) === 409) {
264
+ // Somebody else is watching on this rental. Stop, and say so plainly.
265
+ setTakenOver(true);
266
+ return;
267
+ }
268
+ if (code === FILM_SESSION_EXPIRED) {
269
+ // Nobody took over; this tab merely stopped beating. Re-open rather
270
+ // than gating - telling this person they are watching elsewhere would
271
+ // be a lie they would act on.
272
+ openedForRef.current = null;
273
+ void open();
274
+ return;
275
+ }
276
+ setGate(buildFilmPlaybackGate({ error, isAuthenticated, hadRental: !!session?.rental }));
277
+ }
278
+ };
279
+
280
+ const timer = setInterval(() => void beat(), heartbeatSec * 1000);
281
+ return () => {
282
+ cancelled = true;
283
+ clearInterval(timer);
284
+ };
285
+ // `heartbeat` is a stable react-query mutation object.
286
+ // eslint-disable-next-line react-hooks/exhaustive-deps
287
+ }, [sessionId, heartbeatSec, takenOver, gate, isAuthenticated, open]);
288
+
289
+ // ── The countdown ──────────────────────────────────────────────────────────
290
+ //
291
+ // Ticks only while there is something to count. A free preview has no window,
292
+ // so it never arms a timer.
293
+ const expiresAt = session?.expiresAt ?? null;
294
+ useEffect(() => {
295
+ if (!expiresAt) return;
296
+ setNow(Date.now());
297
+ const timer = setInterval(() => setNow(Date.now()), 1000);
298
+ return () => clearInterval(timer);
299
+ }, [expiresAt]);
300
+
301
+ const msRemaining = useMemo(() => msUntil(expiresAt, new Date(now)), [expiresAt, now]);
302
+
303
+ const stopped = !!gate || takenOver;
304
+ stoppedRef.current = stopped;
305
+
306
+ // The window running out mid-film is the one expiry a fan actually watches
307
+ // happen. Gate on it locally rather than waiting for the next request to fail,
308
+ // so the player stops with an explanation instead of stalling.
309
+ useEffect(() => {
310
+ if (msRemaining === 0 && !gate) setGate({ kind: "expired", message: null, status: null });
311
+ }, [msRemaining, gate]);
312
+
313
+ const src = useMemo(() => {
314
+ if (!session) return undefined;
315
+ void srcNonce;
316
+ const token = tokenRef.current ?? session.sessionToken;
317
+ return withFilmSessionToken(absolutizeFilmUrl(session.masterUrl, apiUrl), token);
318
+ // `srcNonce` is the whole point of this memo - see the hook docblock.
319
+ // eslint-disable-next-line react-hooks/exhaustive-deps
320
+ }, [session?.sessionId, session?.masterUrl, apiUrl, srcNonce]);
321
+
322
+ const keyUrl = useMemo(() => {
323
+ if (!session) return undefined;
324
+ const token = tokenRef.current ?? session.sessionToken;
325
+ return withFilmSessionToken(absolutizeFilmUrl(session.keyUrl, apiUrl), token);
326
+ // eslint-disable-next-line react-hooks/exhaustive-deps
327
+ }, [session?.sessionId, session?.keyUrl, apiUrl, srcNonce]);
328
+
329
+ /**
330
+ * The `<track>` list, each with a fetchable `src`.
331
+ *
332
+ * Built the same way and on the same schedule as `src`: absolutized against
333
+ * the API, given the freshest token, and re-derived only on a recovery. A
334
+ * subtitle URL is credentialed exactly like the manifest, because it IS the
335
+ * script of a paid episode - the delivery route runs the same rental, session
336
+ * and episode-binding checks the key does.
337
+ *
338
+ * Stable across heartbeats for the same reason `src` is: swapping a track's
339
+ * `src` mid-film makes the browser drop and re-fetch the cues, which blanks
340
+ * the subtitles the viewer is reading.
341
+ *
342
+ * ⚠️ The token in these URLs lives ten minutes, and a `<track>` is fetched
343
+ * LAZILY - when the viewer picks that language. A player that renders them and
344
+ * leaves it there gives a fan who turns subtitles on half an hour in a 401 and
345
+ * an empty track, silently. A renderer must pull the cues down while the token
346
+ * is fresh by setting each track's `mode` to `"hidden"` once mounted; the
347
+ * starter's `FilmHlsPlayer` shows the four lines.
348
+ */
349
+ const subtitles = useMemo<FilmPlaybackSubtitle[]>(() => {
350
+ if (!session) return [];
351
+ const token = tokenRef.current ?? session.sessionToken;
352
+ return (session.subtitles ?? []).map((track) => ({
353
+ ...track,
354
+ src: withFilmSessionToken(absolutizeFilmUrl(track.url, apiUrl), token),
355
+ }));
356
+ // eslint-disable-next-line react-hooks/exhaustive-deps
357
+ }, [session?.sessionId, session?.subtitles, apiUrl, srcNonce]);
358
+
359
+ /**
360
+ * Debounced position write.
361
+ *
362
+ * Call it from the player's time-update event as often as that fires; it
363
+ * writes at most once per `progressIntervalSec`, plus one immediate write the
364
+ * first time the episode crosses the completion threshold, because that write
365
+ * is what turns the next-episode prompt on and a fan who stops watching right
366
+ * there would otherwise never send it.
367
+ */
368
+ const reportProgress = useCallback(
369
+ (positionSec: number, durationSec?: number) => {
370
+ const token = tokenRef.current;
371
+ if (stoppedRef.current) return;
372
+ if (!token || !Number.isFinite(positionSec) || positionSec < 0) return;
373
+
374
+ const runtime = durationSec ?? session?.episode.durationSec ?? 0;
375
+ const completed = runtime > 0 && positionSec / runtime >= COMPLETION_THRESHOLD;
376
+ const justCompleted = completed && !completedRef.current;
377
+
378
+ const interval = (opts.progressIntervalSec ?? DEFAULT_PROGRESS_INTERVAL_SEC) * 1000;
379
+ const nowMs = Date.now();
380
+ if (!justCompleted && nowMs - lastProgressSentRef.current < interval) return;
381
+ lastProgressSentRef.current = nowMs;
382
+ if (justCompleted) completedRef.current = true;
383
+
384
+ // Fire and forget: this is resume state, never entitlement, so a failed
385
+ // write must not interrupt the film or surface an error to the viewer.
386
+ recordProgress
387
+ .mutateAsync({ sessionToken: token, positionSec: Math.floor(positionSec), completed })
388
+ .catch(() => undefined);
389
+ },
390
+ // `recordProgress` is a stable react-query mutation object.
391
+ // eslint-disable-next-line react-hooks/exhaustive-deps
392
+ [session?.episode.durationSec, opts.progressIntervalSec],
393
+ );
394
+
395
+ /**
396
+ * Re-derive the playback URL from the freshest token.
397
+ *
398
+ * The recovery path for the one thing a stable `src` cannot survive: an ABR
399
+ * switch after the original token's ten minutes are up. A player should call
400
+ * this from its own fatal-network-error handler and restore its position,
401
+ * rather than reloading the page and losing it.
402
+ */
403
+ const recoverPlayback = useCallback(async () => {
404
+ const token = tokenRef.current;
405
+ if (token) {
406
+ try {
407
+ const result = await heartbeat.mutateAsync({ sessionToken: token });
408
+ tokenRef.current = result.sessionToken;
409
+ setSrcNonce((n) => n + 1);
410
+ return;
411
+ } catch {
412
+ // Fall through to a full re-open: whatever the heartbeat refused, a new
413
+ // session is the only other move, and it is the one that also re-checks
414
+ // the rental.
415
+ }
416
+ }
417
+ openedForRef.current = null;
418
+ await open();
419
+ setSrcNonce((n) => n + 1);
420
+ // `heartbeat` is a stable react-query mutation object.
421
+ // eslint-disable-next-line react-hooks/exhaustive-deps
422
+ }, [open]);
423
+
424
+ /** Take the rental BACK from whoever displaced this tab. Opens a fresh session. */
425
+ const resumeHere = useCallback(async () => {
426
+ openedForRef.current = null;
427
+ await open();
428
+ }, [open]);
429
+
430
+ /** The `wrong_account` exit: clear the session and re-ask. */
431
+ const signOutAndRetry = useCallback(async () => {
432
+ await logout();
433
+ openedForRef.current = null;
434
+ await open();
435
+ }, [logout, open]);
436
+
437
+ return {
438
+ /** Held open until auth restores, so a bound rental never flashes "sign in" at its owner. */
439
+ isLoading: !isInitialized || (canAttempt && !session && !gate),
440
+ /** Non-null when the player must NOT be drawn. */
441
+ gate,
442
+ /** Another tab or device took this rental. Not a gate: `resumeHere()` takes it back. */
443
+ takenOver,
444
+ /**
445
+ * ⚠️ The player must not be on screen. UNMOUNT it, do not merely cover it.
446
+ *
447
+ * True while a gate is showing and true once this tab has been displaced.
448
+ * The second case is the one that is easy to get wrong: decision 7 caps a
449
+ * rental at one stream, and by the time the 409 arrives the engine has
450
+ * already fetched the AES key once and holds up to six hours of presigned
451
+ * segment URLs. Nothing server-side can pull those back, and after the 409
452
+ * this tab makes no further requests to refuse - so an overlay drawn over a
453
+ * still-playing video means both devices watch the film to the end, which is
454
+ * exactly the leak the cap exists to stop.
455
+ *
456
+ * A custom player built on this hook is on the honour system for the viewer
457
+ * overlay (decision 16) but NOT for this: honouring `stopped` is what keeps
458
+ * the one-stream cap true on a custom surface.
459
+ */
460
+ stopped,
461
+ session,
462
+ /** The master playlist for this session, absolute and token-bearing. */
463
+ src,
464
+ /** The AES-128 key endpoint for this session. Players fetch it via the playlist; exposed for diagnostics. */
465
+ keyUrl,
466
+ /**
467
+ * Sidecar WebVTT tracks for this episode, ready to render as `<track>`.
468
+ *
469
+ * ⚠️ A cross-origin `<track>` is only loaded when the `<video>` carries
470
+ * `crossOrigin="anonymous"`. These URLs are on the API's origin, not the
471
+ * site's, so a player that renders them without that attribute gets silence
472
+ * and no error.
473
+ */
474
+ subtitles,
475
+ watermark: (session?.watermark ?? null) as FilmWatermarkPayload | null,
476
+ episode: session?.episode ?? null,
477
+ rental: session?.rental ?? null,
478
+ /** True only for the visit that started the window. Say it out loud: it is the whole-title clock. */
479
+ clockStartedNow: !!session?.clockStartedNow,
480
+ resumeAtSec: session?.resumeAtSec ?? 0,
481
+ expiresAt,
482
+ /** Null for a free preview and until the clock starts; 0 once the window has run out. */
483
+ msRemaining,
484
+ isFreePreview: !!session?.episode.isFreePreview,
485
+ signedInEmail: user?.email ?? null,
486
+ isAuthenticated,
487
+ signInHref: filmSignInHref(opts.loginPath ?? DEFAULT_LOGIN_PATH, currentPath()),
488
+ reportProgress,
489
+ recoverPlayback,
490
+ resumeHere,
491
+ signOutAndRetry,
492
+ /** Try the whole thing again, from the session create. */
493
+ retry: resumeHere,
494
+ };
495
+ }
496
+
497
+ export type FilmPlaybackState = ReturnType<typeof useFilmPlayback>;