@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.
- package/package.json +3 -2
- package/src/client/_tests/tenantHeaders.spec.ts +77 -0
- package/src/client/createForgeClient.ts +37 -0
- package/src/data/queries/_tests/paymentFlowReturnUrl.spec.tsx +165 -0
- package/src/data/queries/useFilmPlaybackSession.ts +224 -0
- package/src/data/queries/useFilms.ts +561 -0
- package/src/data/queries/usePaymentFlow.ts +28 -3
- package/src/i18n/de.json +130 -28
- package/src/i18n/en.json +130 -28
- package/src/index.ts +5 -0
- package/src/provider/ForgeAppProvider.tsx +10 -0
- package/src/provider/ForgeProvider.tsx +18 -3
- package/src/server/index.ts +76 -17
- package/src/ui/headless/_tests/dialogPaystackStandDown.spec.tsx +185 -0
- package/src/ui/headless/dialog.tsx +139 -4
- package/src/ui/headless/film/FilmWatermark.tsx +180 -0
- package/src/ui/headless/film/_tests/filmRules.spec.ts +531 -0
- package/src/ui/headless/film/_tests/useStageFullscreen.spec.ts +167 -0
- package/src/ui/headless/film/index.ts +36 -0
- package/src/ui/headless/film/useFilmCatalog.ts +66 -0
- package/src/ui/headless/film/useFilmPlayback.ts +497 -0
- package/src/ui/headless/film/useFilmRentalFlow.ts +277 -0
- package/src/ui/headless/film/useStageFullscreen.ts +156 -0
- package/src/ui/headless/index.ts +6 -9
- package/src/ui/index.ts +21 -16
- package/src/ui/media/CallStage.tsx +59 -1
- package/src/ui/media/CallWindowNotice.tsx +94 -0
- package/src/ui/media/_tests/CallWindowNotice.spec.tsx +83 -0
- package/src/ui/media/index.ts +9 -0
- package/src/ui/styled/AccountDashboard.tsx +101 -37
- package/src/ui/styled/FilmCatalog.tsx +278 -0
- package/src/ui/styled/FilmDetail.tsx +661 -0
- package/src/ui/styled/FilmLibrary.tsx +291 -0
- package/src/ui/styled/FilmWatch.tsx +701 -0
- package/src/ui/styled/_tests/AccountDashboardBookingCall.spec.tsx +28 -0
- package/src/ui/styled/_tests/AccountDashboardRentals.spec.tsx +200 -0
- package/src/ui/styled/forge-utilities.css +6 -0
- package/src/utils/_tests/paystackCheckout.spec.ts +82 -1
- package/src/utils/paystackCheckout.ts +47 -0
- package/src/utils/structuredData.ts +88 -17
|
@@ -0,0 +1,701 @@
|
|
|
1
|
+
import { useEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from "react";
|
|
2
|
+
import { useThemeTokens } from "../theme/ForgeThemeProvider";
|
|
3
|
+
import { usePublicAuth } from "../../contexts/PublicAuthContext";
|
|
4
|
+
import { Loading } from "./Loading";
|
|
5
|
+
import { FilmWatermark } from "../headless/film/FilmWatermark";
|
|
6
|
+
import { useFilmPlayback, type FilmPlaybackGate, type FilmPlaybackSubtitle } from "../headless/film/useFilmPlayback";
|
|
7
|
+
import { useStageFullscreen } from "../headless/film/useStageFullscreen";
|
|
8
|
+
import {
|
|
9
|
+
groupFilmEpisodesBySeason,
|
|
10
|
+
nextFilmEpisode,
|
|
11
|
+
splitRemainingMs,
|
|
12
|
+
useFilm,
|
|
13
|
+
useMyFilmRentals,
|
|
14
|
+
type PublicFilmDetail,
|
|
15
|
+
type PublicFilmEpisode,
|
|
16
|
+
} from "../../data/queries/useFilms";
|
|
17
|
+
import { useForgeT, type ForgeT } from "../../i18n";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* What a player renderer is handed. Everything it needs, nothing it could leak.
|
|
21
|
+
*
|
|
22
|
+
* There is no "stop" prop, because there does not need to be one: `<FilmWatch>`
|
|
23
|
+
* UNMOUNTS the renderer the moment playback must stop (a gate, or this tab being
|
|
24
|
+
* displaced under decision 7's one-stream cap), so the renderer's own cleanup is
|
|
25
|
+
* what tears the engine down. A renderer must therefore destroy its player in an
|
|
26
|
+
* effect cleanup rather than assume it will be told. A player built directly on
|
|
27
|
+
* `useFilmPlayback` instead of on this block reads `playback.stopped` for the
|
|
28
|
+
* same signal.
|
|
29
|
+
*/
|
|
30
|
+
export interface FilmPlayerRenderProps {
|
|
31
|
+
/** The session-scoped, single-episode-bound master playlist. Changes only on recovery. */
|
|
32
|
+
src: string;
|
|
33
|
+
/** Poster art while nothing has loaded. */
|
|
34
|
+
poster?: string;
|
|
35
|
+
/**
|
|
36
|
+
* Sidecar WebVTT tracks, already absolute and already carrying the session
|
|
37
|
+
* token. Render one `<track kind="subtitles">` each, `default` on the one
|
|
38
|
+
* whose `isDefault` is true. Empty is the normal answer.
|
|
39
|
+
*
|
|
40
|
+
* Two things a renderer must do, both of which fail SILENTLY when skipped:
|
|
41
|
+
*
|
|
42
|
+
* - ⚠️ These live on the API's origin, not the site's, and a browser refuses
|
|
43
|
+
* to load a cross-origin `<track>` unless the `<video>` carries
|
|
44
|
+
* `crossOrigin="anonymous"`.
|
|
45
|
+
* - ⚠️ The token in each URL lives ten minutes and a `<track>` is fetched
|
|
46
|
+
* lazily, so force the cues down at mount (`textTrack.mode = "hidden"`) or
|
|
47
|
+
* a fan who enables subtitles late gets a 401 and an empty track.
|
|
48
|
+
*
|
|
49
|
+
* The starter's `FilmHlsPlayer` is the reference for both.
|
|
50
|
+
*/
|
|
51
|
+
subtitles: FilmPlaybackSubtitle[];
|
|
52
|
+
/** Where to pick up. Seek to this once metadata is ready, then ignore it. */
|
|
53
|
+
startAtSec: number;
|
|
54
|
+
/** Fire as often as the player likes; the hook debounces the write. */
|
|
55
|
+
onTimeUpdate: (positionSec: number, durationSec?: number) => void;
|
|
56
|
+
onEnded: () => void;
|
|
57
|
+
/**
|
|
58
|
+
* A fatal playback error. Call it INSTEAD of reloading the page: the block
|
|
59
|
+
* re-derives the URL from a fresh token and hands back a new `src`, and the
|
|
60
|
+
* renderer restores its own position from `startAtSec`.
|
|
61
|
+
*/
|
|
62
|
+
onFatalError: () => void;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface FilmWatchProps {
|
|
66
|
+
/** Route params. */
|
|
67
|
+
slug: string;
|
|
68
|
+
episodeId: string;
|
|
69
|
+
/**
|
|
70
|
+
* How to play encrypted HLS.
|
|
71
|
+
*
|
|
72
|
+
* Forge ships no HLS engine by design: a bare `<video src="…m3u8">` plays on
|
|
73
|
+
* Safari and nowhere else, and `EXT-X-KEY` fetching off Safari needs a real
|
|
74
|
+
* engine. The site-starter passes a player built on a client-only dynamic
|
|
75
|
+
* import of hls.js. The bare `<video>` is the fallback rather than the
|
|
76
|
+
* default-and-only, so a missing renderer degrades to "plays on Safari"
|
|
77
|
+
* instead of to a blank rectangle everywhere.
|
|
78
|
+
*
|
|
79
|
+
* Swapping this cannot defeat anything: the manifest handed to it is already
|
|
80
|
+
* session-scoped and episode-bound, and the key endpoint re-checks the rental
|
|
81
|
+
* on every fetch.
|
|
82
|
+
*/
|
|
83
|
+
renderPlayer?: (props: FilmPlayerRenderProps) => ReactNode;
|
|
84
|
+
/** Where films live. Default `/i/films`. */
|
|
85
|
+
basePath?: string;
|
|
86
|
+
/** Sign-in path. Default `/i/login`. */
|
|
87
|
+
loginPath?: string;
|
|
88
|
+
/** SPA navigation adapter. Defaults to a full-page visit. */
|
|
89
|
+
navigate?: (href: string) => void;
|
|
90
|
+
/**
|
|
91
|
+
* Draw the dynamic viewer overlay. Default true, and leaving it true is the
|
|
92
|
+
* point: switching it off removes the only layer that ties a leaked recording
|
|
93
|
+
* to the RENTER rather than only to the film. Everything else that protects
|
|
94
|
+
* the stream is server-side and unaffected either way.
|
|
95
|
+
*/
|
|
96
|
+
showWatermark?: boolean;
|
|
97
|
+
className?: string;
|
|
98
|
+
style?: CSSProperties;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* The player surface: the gate, the stream, the overlay, the clock and the way
|
|
103
|
+
* to the next episode.
|
|
104
|
+
*
|
|
105
|
+
* ## The five states, and why none of them may be merged
|
|
106
|
+
*
|
|
107
|
+
* - **sign in** - nobody is signed in, and this is not a free preview. Needs a
|
|
108
|
+
* way IN that comes back here.
|
|
109
|
+
* - **wrong account** - signed in as somebody else. Needs a way OUT of the
|
|
110
|
+
* session they are already in. Never the words "sign in", which they have
|
|
111
|
+
* done.
|
|
112
|
+
* - **no rental** - nothing covers this episode. Needs the title page, where
|
|
113
|
+
* the offers are.
|
|
114
|
+
* - **expired** - there WAS a rental and the window closed. Needs a re-rent,
|
|
115
|
+
* not a sign-in, and needs to be told that plainly rather than shown an
|
|
116
|
+
* error.
|
|
117
|
+
* - **taken over** - somebody is watching this rental somewhere else. The
|
|
118
|
+
* player is torn down (see the overlay below: covering a playing video caps
|
|
119
|
+
* nothing), but this is NOT a dead end: one button takes it back, because
|
|
120
|
+
* takeover is the design and a crashed tab must never lock the buyer out.
|
|
121
|
+
*
|
|
122
|
+
* ## What is drawn over the video, and what that costs
|
|
123
|
+
*
|
|
124
|
+
* `<FilmWatermark>` sits INSIDE the player surface, not beside it, so it shares
|
|
125
|
+
* the pixels a screen recording captures. It deters the record-and-share leak by
|
|
126
|
+
* naming the leaker; it prevents nothing. A creator building a custom player
|
|
127
|
+
* from `useFilmPlayback` can leave it out, and if they do, a leaked recording is
|
|
128
|
+
* still traceable to the film (that watermark is burned into the pixels by the
|
|
129
|
+
* transcoder) but no longer to the renter.
|
|
130
|
+
*/
|
|
131
|
+
export function FilmWatch({
|
|
132
|
+
slug,
|
|
133
|
+
episodeId,
|
|
134
|
+
renderPlayer,
|
|
135
|
+
basePath = "/i/films",
|
|
136
|
+
loginPath = "/i/login",
|
|
137
|
+
navigate,
|
|
138
|
+
showWatermark = true,
|
|
139
|
+
className,
|
|
140
|
+
style,
|
|
141
|
+
}: FilmWatchProps) {
|
|
142
|
+
const t = useForgeT();
|
|
143
|
+
const tokens = useThemeTokens();
|
|
144
|
+
const { user, isInitialized } = usePublicAuth();
|
|
145
|
+
const go = navigate ?? ((href: string) => window.location.assign(href));
|
|
146
|
+
const [ended, setEnded] = useState(false);
|
|
147
|
+
const fullscreen = useStageFullscreen();
|
|
148
|
+
|
|
149
|
+
const detail = useFilm(slug, { accountId: user?.id });
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* The fallback that stops an archived title from stranding a paying fan.
|
|
153
|
+
*
|
|
154
|
+
* `/public/films/:slug` 404s for an archived title and while the feature
|
|
155
|
+
* switch is off, but an active rental keeps playing in both cases - turning a
|
|
156
|
+
* switch off must never eat something a fan paid for. The fan's own library
|
|
157
|
+
* always carries the rental with its film's slug, so it is the one place the
|
|
158
|
+
* id can still be resolved. Only asked for when the detail actually failed.
|
|
159
|
+
*/
|
|
160
|
+
const fallback = useMyFilmRentals(detail.isError ? user?.id : undefined, 1, 50);
|
|
161
|
+
const fallbackFilm = useMemo(() => {
|
|
162
|
+
if (!detail.isError) return null;
|
|
163
|
+
const match = (fallback.data?.data ?? []).find((rental) => rental.film.slug === slug);
|
|
164
|
+
return match?.film ?? null;
|
|
165
|
+
}, [detail.isError, fallback.data, slug]);
|
|
166
|
+
|
|
167
|
+
const film: PublicFilmDetail | null = detail.data ?? null;
|
|
168
|
+
const filmId = film?.id ?? fallbackFilm?.id;
|
|
169
|
+
const episode = film?.episodes.find((candidate) => candidate.id === episodeId) ?? null;
|
|
170
|
+
|
|
171
|
+
const playback = useFilmPlayback({
|
|
172
|
+
filmId,
|
|
173
|
+
episodeId,
|
|
174
|
+
loginPath,
|
|
175
|
+
isFreePreview: episode?.isFreePreview,
|
|
176
|
+
enabled: !!filmId,
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
// ⚠️ EVERY HOOK MUST SIT ABOVE THE EARLY RETURNS BELOW. This block lived under
|
|
180
|
+
// them and produced "Rendered more hooks than during the previous render": the
|
|
181
|
+
// loading render bailed at the guard having called fewer hooks than the render
|
|
182
|
+
// that got past it. The effect guards on its own ref instead, which is null
|
|
183
|
+
// until the stage mounts.
|
|
184
|
+
/**
|
|
185
|
+
* The fallback `<video>`, and the one thing it has to do about subtitles.
|
|
186
|
+
*
|
|
187
|
+
* ⚠️ This block tells every renderer to force the cues down at mount, and the
|
|
188
|
+
* fallback below is a renderer. A `<track>` is fetched LAZILY, when the viewer
|
|
189
|
+
* picks a language out of the captions menu, and the session token baked into
|
|
190
|
+
* its URL lives ten minutes. Eleven minutes in, that fetch carries an expired
|
|
191
|
+
* token, the subtitle endpoint answers 401, and the viewer gets a menu entry
|
|
192
|
+
* that is selected and empty: a `<track>` load failure fires an `error` event
|
|
193
|
+
* on the element and nothing else, so there is nowhere for it to surface.
|
|
194
|
+
*
|
|
195
|
+
* `mode = "hidden"` fetches and parses the cues without displaying them, so
|
|
196
|
+
* every language is already in memory before anyone asks for one. Only
|
|
197
|
+
* `disabled` tracks are touched, leaving the `default` one in the `showing`
|
|
198
|
+
* mode it loaded with, and the menu still reads as off until the viewer picks.
|
|
199
|
+
*
|
|
200
|
+
* The starter's `FilmHlsPlayer` carries the same effect for the same reason;
|
|
201
|
+
* this is not a duplicate of it, it is the fallback path honouring the rule
|
|
202
|
+
* the props above set.
|
|
203
|
+
*/
|
|
204
|
+
const fallbackVideoRef = useRef<HTMLVideoElement | null>(null);
|
|
205
|
+
useEffect(() => {
|
|
206
|
+
const video = fallbackVideoRef.current;
|
|
207
|
+
if (!video || playback.subtitles.length === 0) return;
|
|
208
|
+
for (const textTrack of Array.from(video.textTracks)) {
|
|
209
|
+
if (textTrack.mode === "disabled") textTrack.mode = "hidden";
|
|
210
|
+
}
|
|
211
|
+
}, [playback.subtitles, playback.src, playback.stopped, renderPlayer]);
|
|
212
|
+
|
|
213
|
+
const resolvingFilm = detail.isLoading || (detail.isError && fallback.isLoading);
|
|
214
|
+
if (!isInitialized || resolvingFilm || playback.isLoading) return <Loading fullPage />;
|
|
215
|
+
|
|
216
|
+
const titleHref = `${basePath}/${slug}`;
|
|
217
|
+
|
|
218
|
+
if (playback.gate) {
|
|
219
|
+
return (
|
|
220
|
+
<GatePanel
|
|
221
|
+
gate={playback.gate}
|
|
222
|
+
t={t}
|
|
223
|
+
onSignIn={() => go(playback.signInHref)}
|
|
224
|
+
onSignOut={() => void playback.signOutAndRetry()}
|
|
225
|
+
onTitle={() => go(titleHref)}
|
|
226
|
+
signedInEmail={playback.signedInEmail}
|
|
227
|
+
className={className}
|
|
228
|
+
style={style}
|
|
229
|
+
/>
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
if (!filmId || !playback.src) {
|
|
234
|
+
return (
|
|
235
|
+
<Panel className={className} style={style}>
|
|
236
|
+
<p style={{ fontSize: 14, color: tokens.muted }}>{t("forge.film_watch.unavailable")}</p>
|
|
237
|
+
</Panel>
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const remaining = splitRemainingMs(playback.msRemaining);
|
|
242
|
+
const next = film ? nextFilmEpisode(film.episodes, episodeId) : null;
|
|
243
|
+
const seasons = film ? groupFilmEpisodesBySeason(film.episodes) : [];
|
|
244
|
+
const episodeLabel = playback.episode
|
|
245
|
+
? t("forge.film_watch.episode_label", {
|
|
246
|
+
season: playback.episode.seasonNumber,
|
|
247
|
+
number: playback.episode.episodeNumber,
|
|
248
|
+
})
|
|
249
|
+
: null;
|
|
250
|
+
|
|
251
|
+
const playerProps: FilmPlayerRenderProps = {
|
|
252
|
+
src: playback.src,
|
|
253
|
+
poster: film?.cover?.url,
|
|
254
|
+
subtitles: playback.subtitles,
|
|
255
|
+
startAtSec: playback.resumeAtSec,
|
|
256
|
+
onTimeUpdate: playback.reportProgress,
|
|
257
|
+
onEnded: () => setEnded(true),
|
|
258
|
+
onFatalError: () => void playback.recoverPlayback(),
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
return (
|
|
262
|
+
<div
|
|
263
|
+
className={className}
|
|
264
|
+
style={{
|
|
265
|
+
display: "flex",
|
|
266
|
+
flexDirection: "column",
|
|
267
|
+
gap: 16,
|
|
268
|
+
maxWidth: 1100,
|
|
269
|
+
margin: "0 auto",
|
|
270
|
+
color: tokens.text,
|
|
271
|
+
fontFamily: tokens.fontFamily,
|
|
272
|
+
...style,
|
|
273
|
+
}}
|
|
274
|
+
>
|
|
275
|
+
{/* THE STAGE IS THE FULLSCREEN ELEMENT, never the <video>. The browser
|
|
276
|
+
promotes one element and hides the rest of the document, so a native
|
|
277
|
+
fullscreen on the video would leave the watermark behind - see
|
|
278
|
+
`useStageFullscreen`. Everything that must survive fullscreen has to be
|
|
279
|
+
a DESCENDANT of this node. */}
|
|
280
|
+
<div
|
|
281
|
+
ref={fullscreen.ref}
|
|
282
|
+
data-testid="film-stage"
|
|
283
|
+
style={{
|
|
284
|
+
position: "relative",
|
|
285
|
+
width: "100%",
|
|
286
|
+
// In fullscreen the stage IS the viewport, so a 16/9 box would letterbox
|
|
287
|
+
// inside an already letterboxed screen.
|
|
288
|
+
...(fullscreen.isFullscreen
|
|
289
|
+
? { height: "100%", aspectRatio: undefined, borderRadius: 0 }
|
|
290
|
+
: { aspectRatio: "16/9", borderRadius: tokens.cornerRadius }),
|
|
291
|
+
// The one accepted literal in this file: the letterbox behind a video,
|
|
292
|
+
// which has to be black whatever the theme is.
|
|
293
|
+
background: "#000",
|
|
294
|
+
overflow: "hidden",
|
|
295
|
+
}}
|
|
296
|
+
>
|
|
297
|
+
{/* UNMOUNTED once this tab has been displaced, never merely covered.
|
|
298
|
+
Decision 7 caps a rental at one stream, and an overlay painted over a
|
|
299
|
+
still-playing <video> caps nothing: the engine has already fetched
|
|
300
|
+
the key and holds hours of presigned segment URLs the server cannot
|
|
301
|
+
revoke, so both devices would watch the whole film, audio included on
|
|
302
|
+
this one. Unmounting runs the renderer's cleanup - hls.js is
|
|
303
|
+
destroyed, the element is torn down - which is the only thing here
|
|
304
|
+
that actually stops the bytes. The overlay below still offers the way
|
|
305
|
+
back, so this is a stop, not a dead end. */}
|
|
306
|
+
{!playback.stopped &&
|
|
307
|
+
(renderPlayer ? (
|
|
308
|
+
renderPlayer(playerProps)
|
|
309
|
+
) : (
|
|
310
|
+
<video
|
|
311
|
+
ref={fallbackVideoRef}
|
|
312
|
+
src={playback.src}
|
|
313
|
+
// CORS mode is what lets a cross-origin <track> load at all, and
|
|
314
|
+
// it is asked for only when there is a track to load: it also puts
|
|
315
|
+
// the POSTER fetch into CORS mode, and the media bucket does not
|
|
316
|
+
// allow creator custom domains, so a poster requested that way
|
|
317
|
+
// silently fails to paint. A title with no subtitles keeps its
|
|
318
|
+
// poster; a title with them trades it for the captions.
|
|
319
|
+
{...(playback.subtitles.length > 0
|
|
320
|
+
? { crossOrigin: "anonymous" as const }
|
|
321
|
+
: { poster: film?.cover?.url })}
|
|
322
|
+
controls
|
|
323
|
+
playsInline
|
|
324
|
+
autoPlay
|
|
325
|
+
onLoadedMetadata={(e) => {
|
|
326
|
+
if (playback.resumeAtSec > 0) e.currentTarget.currentTime = playback.resumeAtSec;
|
|
327
|
+
}}
|
|
328
|
+
onTimeUpdate={(e) => playback.reportProgress(e.currentTarget.currentTime, e.currentTarget.duration)}
|
|
329
|
+
onEnded={() => setEnded(true)}
|
|
330
|
+
style={{ width: "100%", height: "100%", display: "block" }}
|
|
331
|
+
>
|
|
332
|
+
{playback.subtitles.map((track) => (
|
|
333
|
+
<track
|
|
334
|
+
key={track.id}
|
|
335
|
+
kind="subtitles"
|
|
336
|
+
src={track.src}
|
|
337
|
+
srcLang={track.language}
|
|
338
|
+
label={track.label}
|
|
339
|
+
default={track.isDefault}
|
|
340
|
+
/>
|
|
341
|
+
))}
|
|
342
|
+
</video>
|
|
343
|
+
))}
|
|
344
|
+
|
|
345
|
+
{showWatermark && !playback.stopped && playback.watermark && (
|
|
346
|
+
<FilmWatermark
|
|
347
|
+
name={playback.watermark.name}
|
|
348
|
+
email={playback.watermark.email}
|
|
349
|
+
rentalRef={playback.watermark.rentalRef}
|
|
350
|
+
/>
|
|
351
|
+
)}
|
|
352
|
+
|
|
353
|
+
{/* The stage's own fullscreen control. On Chromium the player hides the
|
|
354
|
+
native one (`controlsList="nofullscreen"`), so this is the only route
|
|
355
|
+
and the watermark always travels. Elsewhere the native button still
|
|
356
|
+
works and `useStageFullscreen` corrects it. */}
|
|
357
|
+
{fullscreen.isSupported && !playback.stopped && (
|
|
358
|
+
<button
|
|
359
|
+
type="button"
|
|
360
|
+
onClick={fullscreen.toggle}
|
|
361
|
+
aria-label={fullscreen.isFullscreen ? t("forge.film_watch.exit_fullscreen") : t("forge.film_watch.enter_fullscreen")}
|
|
362
|
+
style={{
|
|
363
|
+
position: "absolute",
|
|
364
|
+
right: 12,
|
|
365
|
+
top: 12,
|
|
366
|
+
zIndex: 3,
|
|
367
|
+
cursor: "pointer",
|
|
368
|
+
border: "none",
|
|
369
|
+
borderRadius: tokens.cornerRadius,
|
|
370
|
+
padding: "6px 10px",
|
|
371
|
+
fontSize: 12,
|
|
372
|
+
fontFamily: tokens.fontFamily,
|
|
373
|
+
// Sits on the black stage, so theme surface tokens would vanish
|
|
374
|
+
// against it.
|
|
375
|
+
background: "rgba(0,0,0,0.55)",
|
|
376
|
+
color: "#ffffff",
|
|
377
|
+
}}
|
|
378
|
+
>
|
|
379
|
+
{fullscreen.isFullscreen ? t("forge.film_watch.exit_fullscreen") : t("forge.film_watch.enter_fullscreen")}
|
|
380
|
+
</button>
|
|
381
|
+
)}
|
|
382
|
+
|
|
383
|
+
{/* Takeover is drawn OVER the stage rather than replacing the page: the
|
|
384
|
+
fan is one click from taking their rental back, and losing the page
|
|
385
|
+
they were on would make that feel like a punishment. */}
|
|
386
|
+
{playback.takenOver && (
|
|
387
|
+
<div
|
|
388
|
+
role="alert"
|
|
389
|
+
style={{
|
|
390
|
+
position: "absolute",
|
|
391
|
+
inset: 0,
|
|
392
|
+
zIndex: 30,
|
|
393
|
+
display: "flex",
|
|
394
|
+
flexDirection: "column",
|
|
395
|
+
alignItems: "center",
|
|
396
|
+
justifyContent: "center",
|
|
397
|
+
gap: 14,
|
|
398
|
+
padding: 24,
|
|
399
|
+
textAlign: "center",
|
|
400
|
+
background: "rgba(0,0,0,0.82)",
|
|
401
|
+
color: "#ffffff",
|
|
402
|
+
}}
|
|
403
|
+
>
|
|
404
|
+
<p style={{ fontSize: 17, fontWeight: 700 }}>{t("forge.film_watch.taken_over_title")}</p>
|
|
405
|
+
<p style={{ fontSize: 14, maxWidth: 420, lineHeight: 1.6 }}>{t("forge.film_watch.taken_over_body")}</p>
|
|
406
|
+
<button
|
|
407
|
+
type="button"
|
|
408
|
+
onClick={() => void playback.resumeHere()}
|
|
409
|
+
style={{
|
|
410
|
+
padding: "10px 20px",
|
|
411
|
+
borderRadius: tokens.cornerRadius,
|
|
412
|
+
border: "none",
|
|
413
|
+
background: tokens.primary,
|
|
414
|
+
color: tokens.textPrimary,
|
|
415
|
+
fontSize: 14,
|
|
416
|
+
fontWeight: 700,
|
|
417
|
+
cursor: "pointer",
|
|
418
|
+
}}
|
|
419
|
+
>
|
|
420
|
+
{t("forge.film_watch.taken_over_action")}
|
|
421
|
+
</button>
|
|
422
|
+
</div>
|
|
423
|
+
)}
|
|
424
|
+
</div>
|
|
425
|
+
|
|
426
|
+
<div style={{ display: "flex", justifyContent: "space-between", gap: 16, flexWrap: "wrap" }}>
|
|
427
|
+
<div>
|
|
428
|
+
<h1
|
|
429
|
+
style={{
|
|
430
|
+
fontSize: 22,
|
|
431
|
+
fontWeight: 800,
|
|
432
|
+
fontFamily: tokens.headingFontFamily || tokens.fontFamily,
|
|
433
|
+
}}
|
|
434
|
+
>
|
|
435
|
+
{film?.title ?? fallbackFilm?.title ?? ""}
|
|
436
|
+
</h1>
|
|
437
|
+
{film?.kind === "series" && episodeLabel && (
|
|
438
|
+
<p style={{ fontSize: 14, color: tokens.muted }}>
|
|
439
|
+
{episodeLabel}
|
|
440
|
+
{playback.episode?.title ? ` · ${playback.episode.title}` : ""}
|
|
441
|
+
</p>
|
|
442
|
+
)}
|
|
443
|
+
</div>
|
|
444
|
+
|
|
445
|
+
{/* Time remaining is content, never help: it is the thing the fan is
|
|
446
|
+
spending. Free previews have no window, so they get no clock. */}
|
|
447
|
+
{!playback.isFreePreview && (
|
|
448
|
+
<p style={{ fontSize: 14, fontWeight: 600, alignSelf: "center" }}>
|
|
449
|
+
{playback.msRemaining == null
|
|
450
|
+
? t("forge.film_watch.window_not_started")
|
|
451
|
+
: remaining && remaining.days > 0
|
|
452
|
+
? t("forge.film_watch.time_left_days", { days: remaining.days, hours: remaining.hours })
|
|
453
|
+
: remaining && remaining.hours > 0
|
|
454
|
+
? t("forge.film_watch.time_left_hours", { hours: remaining.hours, minutes: remaining.minutes })
|
|
455
|
+
: t("forge.film_watch.time_left_minutes", { minutes: remaining?.minutes ?? 0 })}
|
|
456
|
+
</p>
|
|
457
|
+
)}
|
|
458
|
+
</div>
|
|
459
|
+
|
|
460
|
+
{/* Decision 12, said out loud at the only moment it can still be acted on.
|
|
461
|
+
Every rental is a whole-title rental, so there is no case where this
|
|
462
|
+
sentence would be untrue: a free preview has no rental and no clock. */}
|
|
463
|
+
{playback.clockStartedNow && playback.rental && (
|
|
464
|
+
<p
|
|
465
|
+
style={{
|
|
466
|
+
fontSize: 13,
|
|
467
|
+
lineHeight: 1.6,
|
|
468
|
+
padding: "10px 14px",
|
|
469
|
+
borderRadius: tokens.cornerRadius,
|
|
470
|
+
border: `1px solid ${tokens.border}`,
|
|
471
|
+
background: `${tokens.primary}12`,
|
|
472
|
+
}}
|
|
473
|
+
>
|
|
474
|
+
{t("forge.film_watch.clock_started", { hours: playback.rental.durationHours })}
|
|
475
|
+
</p>
|
|
476
|
+
)}
|
|
477
|
+
|
|
478
|
+
{ended && next && (
|
|
479
|
+
<div
|
|
480
|
+
style={{
|
|
481
|
+
display: "flex",
|
|
482
|
+
alignItems: "center",
|
|
483
|
+
gap: 12,
|
|
484
|
+
flexWrap: "wrap",
|
|
485
|
+
padding: "12px 16px",
|
|
486
|
+
borderRadius: tokens.cornerRadius,
|
|
487
|
+
border: `1px solid ${tokens.border}`,
|
|
488
|
+
background: tokens.surface,
|
|
489
|
+
}}
|
|
490
|
+
>
|
|
491
|
+
<span style={{ flex: "1 1 220px", fontSize: 14 }}>
|
|
492
|
+
{t("forge.film_watch.up_next", {
|
|
493
|
+
title:
|
|
494
|
+
next.title ||
|
|
495
|
+
t("forge.film_watch.episode_label", { season: next.seasonNumber, number: next.episodeNumber }),
|
|
496
|
+
})}
|
|
497
|
+
</span>
|
|
498
|
+
<button
|
|
499
|
+
type="button"
|
|
500
|
+
onClick={() => go(`${basePath}/${slug}/watch/${next.id}`)}
|
|
501
|
+
style={{
|
|
502
|
+
padding: "9px 16px",
|
|
503
|
+
borderRadius: tokens.cornerRadius,
|
|
504
|
+
border: "none",
|
|
505
|
+
background: tokens.primary,
|
|
506
|
+
color: tokens.textPrimary,
|
|
507
|
+
fontSize: 13,
|
|
508
|
+
fontWeight: 700,
|
|
509
|
+
cursor: "pointer",
|
|
510
|
+
}}
|
|
511
|
+
>
|
|
512
|
+
{t("forge.film_watch.play_next")}
|
|
513
|
+
</button>
|
|
514
|
+
</div>
|
|
515
|
+
)}
|
|
516
|
+
|
|
517
|
+
{seasons.length > 0 && film?.kind === "series" && (
|
|
518
|
+
<section style={{ display: "flex", flexDirection: "column", gap: 12 }}>
|
|
519
|
+
<h2 style={{ fontSize: 15, fontWeight: 700 }}>{t("forge.film_watch.episodes")}</h2>
|
|
520
|
+
{seasons.map((season) => (
|
|
521
|
+
<div key={season.seasonNumber} style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
|
522
|
+
{seasons.length > 1 && (
|
|
523
|
+
<h3 style={{ fontSize: 13, fontWeight: 700, color: tokens.muted }}>
|
|
524
|
+
{t("forge.film_watch.season", { number: season.seasonNumber })}
|
|
525
|
+
</h3>
|
|
526
|
+
)}
|
|
527
|
+
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
|
|
528
|
+
{season.episodes.map((candidate) => (
|
|
529
|
+
<EpisodeChip
|
|
530
|
+
key={candidate.id}
|
|
531
|
+
episode={candidate}
|
|
532
|
+
t={t}
|
|
533
|
+
isCurrent={candidate.id === episodeId}
|
|
534
|
+
onSelect={() => go(`${basePath}/${slug}/watch/${candidate.id}`)}
|
|
535
|
+
/>
|
|
536
|
+
))}
|
|
537
|
+
</div>
|
|
538
|
+
</div>
|
|
539
|
+
))}
|
|
540
|
+
</section>
|
|
541
|
+
)}
|
|
542
|
+
</div>
|
|
543
|
+
);
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
function EpisodeChip({
|
|
547
|
+
episode,
|
|
548
|
+
t,
|
|
549
|
+
isCurrent,
|
|
550
|
+
onSelect,
|
|
551
|
+
}: {
|
|
552
|
+
episode: PublicFilmEpisode;
|
|
553
|
+
t: ForgeT;
|
|
554
|
+
isCurrent: boolean;
|
|
555
|
+
onSelect: () => void;
|
|
556
|
+
}) {
|
|
557
|
+
const tokens = useThemeTokens();
|
|
558
|
+
return (
|
|
559
|
+
<button
|
|
560
|
+
type="button"
|
|
561
|
+
onClick={onSelect}
|
|
562
|
+
aria-current={isCurrent ? "true" : undefined}
|
|
563
|
+
title={episode.title ?? undefined}
|
|
564
|
+
style={{
|
|
565
|
+
padding: "7px 12px",
|
|
566
|
+
borderRadius: tokens.cornerRadius,
|
|
567
|
+
border: `1px solid ${isCurrent ? tokens.primary : tokens.border}`,
|
|
568
|
+
background: isCurrent ? `${tokens.primary}20` : "transparent",
|
|
569
|
+
color: isCurrent ? tokens.primary : tokens.text,
|
|
570
|
+
fontSize: 13,
|
|
571
|
+
fontWeight: isCurrent ? 700 : 500,
|
|
572
|
+
cursor: "pointer",
|
|
573
|
+
fontFamily: tokens.fontFamily,
|
|
574
|
+
}}
|
|
575
|
+
>
|
|
576
|
+
{t("forge.film_watch.episode_short", { number: episode.episodeNumber })}
|
|
577
|
+
{episode.isFreePreview ? ` · ${t("forge.film_watch.free")}` : ""}
|
|
578
|
+
</button>
|
|
579
|
+
);
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
function Panel({ children, className, style }: { children: ReactNode; className?: string; style?: CSSProperties }) {
|
|
583
|
+
const tokens = useThemeTokens();
|
|
584
|
+
return (
|
|
585
|
+
<div
|
|
586
|
+
className={className}
|
|
587
|
+
style={{
|
|
588
|
+
maxWidth: 520,
|
|
589
|
+
margin: "0 auto",
|
|
590
|
+
padding: 28,
|
|
591
|
+
borderRadius: tokens.cornerRadius,
|
|
592
|
+
border: `1px solid ${tokens.border}`,
|
|
593
|
+
background: tokens.surface,
|
|
594
|
+
color: tokens.text,
|
|
595
|
+
textAlign: "center",
|
|
596
|
+
display: "flex",
|
|
597
|
+
flexDirection: "column",
|
|
598
|
+
gap: 12,
|
|
599
|
+
fontFamily: tokens.fontFamily,
|
|
600
|
+
...style,
|
|
601
|
+
}}
|
|
602
|
+
>
|
|
603
|
+
{children}
|
|
604
|
+
</div>
|
|
605
|
+
);
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
function GatePanel({
|
|
609
|
+
gate,
|
|
610
|
+
t,
|
|
611
|
+
onSignIn,
|
|
612
|
+
onSignOut,
|
|
613
|
+
onTitle,
|
|
614
|
+
signedInEmail,
|
|
615
|
+
className,
|
|
616
|
+
style,
|
|
617
|
+
}: {
|
|
618
|
+
gate: FilmPlaybackGate;
|
|
619
|
+
t: ForgeT;
|
|
620
|
+
onSignIn: () => void;
|
|
621
|
+
onSignOut: () => void;
|
|
622
|
+
onTitle: () => void;
|
|
623
|
+
signedInEmail: string | null;
|
|
624
|
+
className?: string;
|
|
625
|
+
style?: CSSProperties;
|
|
626
|
+
}) {
|
|
627
|
+
const tokens = useThemeTokens();
|
|
628
|
+
|
|
629
|
+
const copy: Record<FilmPlaybackGate["kind"], { title: string; body: string; action: string | null }> = {
|
|
630
|
+
sign_in_required: {
|
|
631
|
+
title: t("forge.film_watch.gate_sign_in_title"),
|
|
632
|
+
body: t("forge.film_watch.gate_sign_in_body"),
|
|
633
|
+
action: t("forge.film_watch.gate_sign_in_action"),
|
|
634
|
+
},
|
|
635
|
+
wrong_account: {
|
|
636
|
+
title: t("forge.film_watch.gate_wrong_account_title"),
|
|
637
|
+
body: t("forge.film_watch.gate_wrong_account_body"),
|
|
638
|
+
action: t("forge.film_watch.gate_wrong_account_action"),
|
|
639
|
+
},
|
|
640
|
+
no_rental: {
|
|
641
|
+
title: t("forge.film_watch.gate_no_rental_title"),
|
|
642
|
+
body: t("forge.film_watch.gate_no_rental_body"),
|
|
643
|
+
action: t("forge.film_watch.gate_no_rental_action"),
|
|
644
|
+
},
|
|
645
|
+
expired: {
|
|
646
|
+
title: t("forge.film_watch.gate_expired_title"),
|
|
647
|
+
body: t("forge.film_watch.gate_expired_body"),
|
|
648
|
+
action: t("forge.film_watch.gate_expired_action"),
|
|
649
|
+
},
|
|
650
|
+
unavailable: {
|
|
651
|
+
title: t("forge.film_watch.gate_unavailable_title"),
|
|
652
|
+
body: t("forge.film_watch.gate_unavailable_body"),
|
|
653
|
+
action: t("forge.film_watch.gate_unavailable_action"),
|
|
654
|
+
},
|
|
655
|
+
};
|
|
656
|
+
|
|
657
|
+
const chosen = copy[gate.kind];
|
|
658
|
+
const onAction = gate.kind === "sign_in_required" ? onSignIn : gate.kind === "wrong_account" ? onSignOut : onTitle;
|
|
659
|
+
|
|
660
|
+
return (
|
|
661
|
+
<Panel className={className} style={style}>
|
|
662
|
+
<h1 style={{ fontSize: 20, fontWeight: 800, fontFamily: tokens.headingFontFamily || tokens.fontFamily }}>
|
|
663
|
+
{chosen.title}
|
|
664
|
+
</h1>
|
|
665
|
+
{/* The API's own sentence wins when it sent one - it is already translated
|
|
666
|
+
and it knows more about the refusal than this component does. */}
|
|
667
|
+
<p style={{ fontSize: 14, color: tokens.muted, lineHeight: 1.6 }}>{gate.message ?? chosen.body}</p>
|
|
668
|
+
|
|
669
|
+
{/* Naming the session they are in is what makes "wrong account"
|
|
670
|
+
actionable: otherwise the reader has no idea which of their addresses
|
|
671
|
+
they are currently signed in with. */}
|
|
672
|
+
{gate.kind === "wrong_account" && signedInEmail && (
|
|
673
|
+
<p style={{ fontSize: 13, color: tokens.muted }}>
|
|
674
|
+
{t("forge.film_watch.signed_in_as")} <strong style={{ color: tokens.text }}>{signedInEmail}</strong>
|
|
675
|
+
</p>
|
|
676
|
+
)}
|
|
677
|
+
|
|
678
|
+
{chosen.action && (
|
|
679
|
+
<button
|
|
680
|
+
type="button"
|
|
681
|
+
onClick={onAction}
|
|
682
|
+
style={{
|
|
683
|
+
alignSelf: "center",
|
|
684
|
+
padding: "10px 20px",
|
|
685
|
+
borderRadius: tokens.cornerRadius,
|
|
686
|
+
border: "none",
|
|
687
|
+
background: tokens.primary,
|
|
688
|
+
color: tokens.textPrimary,
|
|
689
|
+
fontSize: 14,
|
|
690
|
+
fontWeight: 700,
|
|
691
|
+
cursor: "pointer",
|
|
692
|
+
}}
|
|
693
|
+
>
|
|
694
|
+
{chosen.action}
|
|
695
|
+
</button>
|
|
696
|
+
)}
|
|
697
|
+
</Panel>
|
|
698
|
+
);
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
export default FilmWatch;
|