@enigmax/primitives 0.24.0 → 0.26.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 (59) hide show
  1. package/dist/chunk-4SZ5N7BA.js +121 -0
  2. package/dist/chunk-EYZ366LP.js +189 -0
  3. package/dist/chunk-ISXB5RUH.js +380 -0
  4. package/dist/chunk-JJVZYW5C.js +946 -0
  5. package/dist/chunk-KVLSOQTI.js +66 -0
  6. package/dist/chunk-QIIV2QSV.js +108 -0
  7. package/dist/{chunk-QJJ34E4G.js → chunk-RMNXVWQ4.js} +32 -15
  8. package/dist/chunk-RUEQ3TGP.js +155 -0
  9. package/dist/clipboard-menu-BvXJCSFN.d.ts +93 -0
  10. package/dist/{color-OPV3BJV6.js → color-T63FLJNH.js} +183 -120
  11. package/dist/context-BfjYGPnH.d.ts +54 -0
  12. package/dist/{clipboard-menu-B_ouitfS.d.ts → context-menu-D3FtTn7v.d.ts} +1 -91
  13. package/dist/{index-ZPvlf9vs.d.ts → index-qXOkYQCU.d.ts} +4 -0
  14. package/dist/index.d.ts +266 -2
  15. package/dist/index.js +4 -2
  16. package/dist/menu-MSQSE6U5.js +54 -0
  17. package/dist/menu-PNQRTRNV.js +33 -0
  18. package/dist/next/index.d.ts +7 -3
  19. package/dist/next/index.js +11 -7
  20. package/dist/react/context-menu.d.ts +8 -54
  21. package/dist/react/image.d.ts +109 -0
  22. package/dist/react/image.js +3 -0
  23. package/dist/react/index.d.ts +8 -4
  24. package/dist/react/index.js +11 -7
  25. package/dist/react/input.d.ts +1 -1
  26. package/dist/react/input.js +2 -2
  27. package/dist/react/video.d.ts +120 -0
  28. package/dist/react/video.js +3 -0
  29. package/dist/react-router/index.d.ts +7 -3
  30. package/dist/react-router/index.js +11 -7
  31. package/dist/viewer-73LSE6RR.js +3 -0
  32. package/package.json +15 -2
  33. package/recipes/color/styles.css +20 -0
  34. package/recipes/image/styles.css +182 -0
  35. package/recipes/video/styles.css +154 -0
  36. package/registry.json +477 -3
  37. package/src/core/image-viewer.ts +222 -0
  38. package/src/core/player.ts +303 -0
  39. package/src/index.ts +52 -0
  40. package/src/react/image/icons.tsx +55 -0
  41. package/src/react/image/index.tsx +185 -0
  42. package/src/react/image/menu.tsx +76 -0
  43. package/src/react/image/styles.ts +213 -0
  44. package/src/react/image/types.ts +113 -0
  45. package/src/react/image/viewer.tsx +571 -0
  46. package/src/react/index.ts +3 -0
  47. package/src/react/input/color-styles.ts +20 -0
  48. package/src/react/input/color-swatch.tsx +96 -0
  49. package/src/react/input/color.tsx +84 -23
  50. package/src/react/input/index.tsx +27 -4
  51. package/src/react/input/types.ts +4 -0
  52. package/src/react/video/icons.tsx +135 -0
  53. package/src/react/video/index.tsx +724 -0
  54. package/src/react/video/menu.tsx +66 -0
  55. package/src/react/video/rail.tsx +99 -0
  56. package/src/react/video/styles.ts +161 -0
  57. package/src/react/video/types.ts +135 -0
  58. package/dist/chunk-S7EE57YB.js +0 -9
  59. /package/dist/{chunk-MSOCCQGH.js → chunk-T7I6WJSN.js} +0 -0
@@ -0,0 +1,222 @@
1
+ /**
2
+ * The arithmetic behind the image viewer: zoom around a point, panning that cannot lose the
3
+ * image, moving through a set, and getting a file onto the disk.
4
+ *
5
+ * Not a React module, and deliberately: none of this is rendering. A page drawing its own
6
+ * viewer, or a test, gets the same maths - and the parts that are easy to get wrong (zoom
7
+ * that drifts away from the cursor, a pan that strands the image off screen, a download that
8
+ * silently opens a tab instead) are then testable without a browser.
9
+ */
10
+
11
+ /** How far the image is scaled, and where its centre sits relative to the frame's, in px. */
12
+ export interface Transform {
13
+ scale: number;
14
+ x: number;
15
+ y: number;
16
+ }
17
+
18
+ export interface ZoomLimits {
19
+ min: number;
20
+ max: number;
21
+ }
22
+
23
+ /** A box in viewport coordinates - the frame the image is being viewed in. */
24
+ export interface Box {
25
+ left: number;
26
+ top: number;
27
+ width: number;
28
+ height: number;
29
+ }
30
+
31
+ export const IDENTITY: Transform = { scale: 1, x: 0, y: 0 };
32
+
33
+ export const ZOOM_LIMITS: ZoomLimits = { min: 1, max: 8 };
34
+
35
+ /** What a keyboard `+`/`-` step multiplies by, and the ratio a double press toggles to. */
36
+ export const ZOOM_STEP = 1.35;
37
+ export const ZOOM_DOUBLE = 2.5;
38
+
39
+ export function clampScale(scale: number, limits: ZoomLimits = ZOOM_LIMITS): number {
40
+ return Math.min(Math.max(scale, limits.min), limits.max);
41
+ }
42
+
43
+ /**
44
+ * A wheel notch to a zoom factor.
45
+ *
46
+ * `deltaMode` matters: a mouse reports pixels, but Firefox reports LINES (mode 1) and a page
47
+ * scroll reports pages (mode 2), so treating every delta as pixels makes the same gesture
48
+ * zoom a hundred times harder in one browser than another. The exponential keeps a notch
49
+ * proportional - zooming out from 8x and in from 1x take the same number of turns.
50
+ */
51
+ export function wheelFactor(deltaY: number, deltaMode = 0): number {
52
+ const pixels = deltaMode === 1 ? deltaY * 16 : deltaMode === 2 ? deltaY * 400 : deltaY;
53
+ return Math.exp(-Math.min(Math.max(pixels, -240), 240) / 320);
54
+ }
55
+
56
+ /**
57
+ * Zoom, keeping the point under the pointer under the pointer.
58
+ *
59
+ * The defect this exists for: scaling around the frame's centre slides whatever the visitor
60
+ * was pointing at out from under them, so zooming into a face means zooming and then hunting
61
+ * for it again. The offset is corrected by how much that point moved when the scale changed.
62
+ */
63
+ export function zoomAt(current: Transform, factor: number, point: { x: number; y: number; }, box: Box, limits: ZoomLimits = ZOOM_LIMITS): Transform {
64
+ const scale = clampScale(current.scale * factor, limits);
65
+ if (scale === current.scale) return current;
66
+
67
+ // Where the pointer is relative to the frame's centre, which is what the offsets are
68
+ // measured from.
69
+ const dx = point.x - (box.left + box.width / 2);
70
+ const dy = point.y - (box.top + box.height / 2);
71
+ const ratio = scale / current.scale;
72
+
73
+ return { scale, x: dx - (dx - current.x) * ratio, y: dy - (dy - current.y) * ratio };
74
+ }
75
+
76
+ /**
77
+ * Keep the image over the frame.
78
+ *
79
+ * At 1x it is centred and there is nothing to pan. Zoomed, the offset is bounded by the half
80
+ * of the image that hangs outside the frame - without it a drag can throw the picture off the
81
+ * screen entirely, and the only way back is to close the viewer.
82
+ */
83
+ export function clampPan(transform: Transform, box: Box, natural: { width: number; height: number; }): Transform {
84
+ const width = natural.width * transform.scale;
85
+ const height = natural.height * transform.scale;
86
+ const x = Math.max(0, (width - box.width) / 2);
87
+ const y = Math.max(0, (height - box.height) / 2);
88
+
89
+ return {
90
+ scale: transform.scale,
91
+ x: Math.min(Math.max(transform.x, -x), x),
92
+ y: Math.min(Math.max(transform.y, -y), y)
93
+ };
94
+ }
95
+
96
+ /** The size an image is DRAWN at inside a frame it is fitted to, which is what pan is bounded by. */
97
+ export function fittedSize(natural: { width: number; height: number; }, box: Box): { width: number; height: number; } {
98
+ if (!natural.width || !natural.height) return { width: box.width, height: box.height };
99
+ const ratio = Math.min(box.width / natural.width, box.height / natural.height, 1);
100
+ return { width: natural.width * ratio, height: natural.height * ratio };
101
+ }
102
+
103
+ /** The distance between two pointers, for a pinch. */
104
+ export function pinchDistance(a: { x: number; y: number; }, b: { x: number; y: number; }): number {
105
+ return Math.hypot(a.x - b.x, a.y - b.y);
106
+ }
107
+
108
+ /**
109
+ * The next index in a set, skipping what has been discarded.
110
+ *
111
+ * Returns -1 when there is nothing left to show, which is the viewer's cue to close rather
112
+ * than to sit on an empty frame.
113
+ */
114
+ export function nextIndex(index: number, length: number, step: number, options: { loop?: boolean; skip?: ReadonlySet<number>; } = {}): number {
115
+ const { loop = true, skip } = options;
116
+ if (length <= 0) return -1;
117
+
118
+ let next = index;
119
+ // At most one full pass: with everything else discarded the walk would never stop.
120
+ for (let moved = 0; moved < length; moved += 1) {
121
+ next += step;
122
+ if (next < 0 || next >= length) {
123
+ if (!loop) return -1;
124
+ next = (next % length + length) % length;
125
+ }
126
+ if (!skip?.has(next)) return next;
127
+ }
128
+ return -1;
129
+ }
130
+
131
+ /** The file name to save as: whatever the caller asked for, else the one in the URL. */
132
+ export function filenameFrom(url: string, fallback = "image"): string {
133
+ try {
134
+ const { pathname } = new URL(url, typeof location === "undefined" ? "https://localhost" : location.href);
135
+ const name = pathname.split("/").filter(Boolean).pop();
136
+ return name && name.includes(".") ? decodeURIComponent(name) : fallback;
137
+ } catch {
138
+ return fallback;
139
+ }
140
+ }
141
+
142
+ /**
143
+ * Save the file, rather than navigate to it.
144
+ *
145
+ * `<a download>` is honoured only for a same-origin URL; on a CDN the browser ignores the
146
+ * attribute and opens the image in a tab instead, which is not what the row said it would do.
147
+ * So the bytes are fetched and handed over as a blob, and the plain anchor is the fallback for
148
+ * when that is refused (no CORS header) - a tab is still better than nothing happening.
149
+ */
150
+ export async function downloadFile(url: string, filename?: string): Promise<void> {
151
+ const name = filename ?? filenameFrom(url);
152
+ try {
153
+ const response = await fetch(url, { mode: "cors", credentials: "omit" });
154
+ if (!response.ok) throw new Error(`the file answered ${response.status}`);
155
+ const blob = await response.blob();
156
+ const href = URL.createObjectURL(blob);
157
+ saveAs(href, name);
158
+ // Revoked on the next task, not immediately: Safari has not started reading yet.
159
+ setTimeout(() => URL.revokeObjectURL(href), 10_000);
160
+ } catch {
161
+ saveAs(url, name, true);
162
+ }
163
+ }
164
+
165
+ function saveAs(href: string, name: string, external = false): void {
166
+ if (typeof document === "undefined") return;
167
+ const anchor = document.createElement("a");
168
+ anchor.href = href;
169
+ anchor.download = name;
170
+ anchor.rel = "noopener";
171
+ if (external) anchor.target = "_blank";
172
+ document.body.append(anchor);
173
+ anchor.click();
174
+ anchor.remove();
175
+ }
176
+
177
+ /* -------- the flight from the thumbnail into the viewer -------- */
178
+
179
+ /** How long the picture takes to fly, when the stylesheet does not say otherwise. */
180
+ export const FLIGHT_MS = 300;
181
+
182
+ /**
183
+ * How long the flight ACTUALLY lasts, read off the element.
184
+ *
185
+ * The duration lives in the stylesheet (`--enigma-image-flight`), so a project that slows it
186
+ * down would otherwise have the viewer give up waiting halfway through and close mid-flight.
187
+ * The constant above is only the answer for an element with no transition on it at all.
188
+ */
189
+ export function flightMs(element: Element | null): number {
190
+ if (!element || typeof getComputedStyle === "undefined") return FLIGHT_MS;
191
+ const seconds = Number.parseFloat(getComputedStyle(element).transitionDuration);
192
+ return Number.isFinite(seconds) && seconds > 0 ? seconds * 1000 : FLIGHT_MS;
193
+ }
194
+
195
+ /**
196
+ * The transform that puts a picture exactly over another box.
197
+ *
198
+ * This is the FLIP the lightbox opens with: the full-size image is laid out where it belongs,
199
+ * measured, and then drawn back ON the thumbnail with this transform - so releasing it to the
200
+ * identity transform is the picture growing out of the one in the page rather than a dialog
201
+ * appearing over it. Closing is the same transform applied in the other direction.
202
+ *
203
+ * Both boxes are the same picture, so one scale is enough; the width is used because a
204
+ * thumbnail is nearly always constrained by it. Null when either box has not been laid out
205
+ * yet, which is the caller's cue to skip the animation rather than to divide by zero.
206
+ */
207
+ export function flightFrom(from: Box, to: Box): Transform | null {
208
+ if (!from.width || !from.height || !to.width || !to.height) return null;
209
+ return {
210
+ scale: from.width / to.width,
211
+ // Centre to centre: `translate(x, y) scale(s)` scales about the element's own centre
212
+ // first, so the offset that lands it on the thumbnail is the distance between them.
213
+ x: (from.left + from.width / 2) - (to.left + to.width / 2),
214
+ y: (from.top + from.height / 2) - (to.top + to.height / 2)
215
+ };
216
+ }
217
+
218
+ /** Whether the reader has asked for less movement. Every animation here is skipped when they have. */
219
+ export function prefersReducedMotion(): boolean {
220
+ if (typeof window === "undefined" || !window.matchMedia) return false;
221
+ return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
222
+ }
@@ -0,0 +1,303 @@
1
+ /**
2
+ * The arithmetic behind the video player: times, seeking, buffering, volume and the keys.
3
+ *
4
+ * None of it is rendering, so it is testable without a browser and reusable by a page that
5
+ * draws its own controls - the same split the colour maths and the image viewer's are in.
6
+ */
7
+
8
+ /** The playback rates the settings menu offers, in the order it lists them. */
9
+ export const SPEEDS = [0.5, 0.75, 1, 1.25, 1.5, 1.75, 2] as const;
10
+
11
+ /** How far the arrows seek, in seconds, and what a bigger jump moves by. */
12
+ export const SEEK_STEP = 5;
13
+ export const SEEK_JUMP = 10;
14
+ export const VOLUME_STEP = 0.05;
15
+
16
+ /**
17
+ * Seconds as a clock, sized by the LONGEST time it will have to show.
18
+ *
19
+ * `duration` is what stops the label changing width mid-playback: a 1h04m video passing 10:00
20
+ * would otherwise go from `9:59` to `10:00` to `1:00:00`, and every control after it moves.
21
+ */
22
+ export function formatTime(seconds: number, duration = seconds): string {
23
+ if (!Number.isFinite(seconds) || seconds < 0) seconds = 0;
24
+ const total = Math.floor(seconds);
25
+ const hours = Math.floor(total / 3600);
26
+ const minutes = Math.floor((total % 3600) / 60);
27
+ const rest = total % 60;
28
+ const withHours = Number.isFinite(duration) && duration >= 3600;
29
+
30
+ const pad = (value: number): string => String(value).padStart(2, "0");
31
+ if (withHours || hours > 0) return `${hours}:${pad(minutes)}:${pad(rest)}`;
32
+ return `${minutes}:${pad(rest)}`;
33
+ }
34
+
35
+ /** A time as a fraction of the duration, safe before the metadata says what that is. */
36
+ export function progress(time: number, duration: number): number {
37
+ if (!Number.isFinite(duration) || duration <= 0) return 0;
38
+ return Math.min(Math.max(time / duration, 0), 1);
39
+ }
40
+
41
+ /** Where a press on a horizontal rail lands, as a fraction of it. */
42
+ export function fractionAt(box: { left: number; width: number; }, clientX: number): number {
43
+ if (!box.width) return 0;
44
+ return Math.min(Math.max((clientX - box.left) / box.width, 0), 1);
45
+ }
46
+
47
+ /**
48
+ * How much is buffered AHEAD of where playback is.
49
+ *
50
+ * The range under the playhead, not the largest one: a viewer who seeks past the buffer is
51
+ * looking at a bar that says nothing is loaded, which is true of the part they are watching.
52
+ */
53
+ export function bufferedAhead(ranges: TimeRanges | null, time: number, duration: number): number {
54
+ if (!ranges || !Number.isFinite(duration) || duration <= 0) return 0;
55
+ for (let at = 0; at < ranges.length; at += 1) {
56
+ if (ranges.start(at) <= time && ranges.end(at) >= time) return Math.min(ranges.end(at) / duration, 1);
57
+ }
58
+ return 0;
59
+ }
60
+
61
+ /** The next rate in the list, wrapping - what a repeated press on the speed row does. */
62
+ export function nextSpeed(current: number, step = 1): number {
63
+ const at = SPEEDS.indexOf(current as typeof SPEEDS[number]);
64
+ const from = at === -1 ? SPEEDS.indexOf(1) : at;
65
+ return SPEEDS[(from + step + SPEEDS.length) % SPEEDS.length] as number;
66
+ }
67
+
68
+ /** What a key press means, or null when the player should leave it to the page. */
69
+ export type PlayerCommand =
70
+ | { type: "toggle"; }
71
+ | { type: "seek"; by: number; }
72
+ | { type: "seekTo"; fraction: number; }
73
+ | { type: "volume"; by: number; }
74
+ | { type: "mute"; }
75
+ | { type: "fullscreen"; }
76
+ | { type: "captions"; }
77
+ | { type: "pip"; };
78
+
79
+ /**
80
+ * The shortcuts, as the platform's video players define them.
81
+ *
82
+ * Space and K play, J and L jump ten, the arrows nudge five and the volume, M mutes, F is
83
+ * fullscreen, C captions, and a digit seeks to that tenth of the video.
84
+ */
85
+ export function commandFor(key: string): PlayerCommand | null {
86
+ if (key === " " || key === "k" || key === "K") return { type: "toggle" };
87
+ if (key === "ArrowRight") return { type: "seek", by: SEEK_STEP };
88
+ if (key === "ArrowLeft") return { type: "seek", by: -SEEK_STEP };
89
+ if (key === "l" || key === "L") return { type: "seek", by: SEEK_JUMP };
90
+ if (key === "j" || key === "J") return { type: "seek", by: -SEEK_JUMP };
91
+ if (key === "ArrowUp") return { type: "volume", by: VOLUME_STEP };
92
+ if (key === "ArrowDown") return { type: "volume", by: -VOLUME_STEP };
93
+ if (key === "m" || key === "M") return { type: "mute" };
94
+ if (key === "f" || key === "F") return { type: "fullscreen" };
95
+ if (key === "c" || key === "C") return { type: "captions" };
96
+ if (key === "p" || key === "P") return { type: "pip" };
97
+ if (key >= "0" && key <= "9") return { type: "seekTo", fraction: Number(key) / 10 };
98
+ return null;
99
+ }
100
+
101
+ /**
102
+ * Whether a key press belongs to the page rather than to the player.
103
+ *
104
+ * A shortcut that fires while somebody is typing turns their space bar into a pause, so the
105
+ * player stands down inside anything that takes text.
106
+ */
107
+ export function isTypingTarget(target: EventTarget | null): boolean {
108
+ const element = target as HTMLElement | null;
109
+ if (!element || typeof element.tagName !== "string") return false;
110
+ if (element.isContentEditable) return true;
111
+ return ["INPUT", "TEXTAREA", "SELECT"].includes(element.tagName);
112
+ }
113
+
114
+ /** Whether the document is showing something fullscreen, across the two spellings of it. */
115
+ export function fullscreenElement(): Element | null {
116
+ if (typeof document === "undefined") return null;
117
+ const legacy = document as Document & { webkitFullscreenElement?: Element | null; };
118
+ return document.fullscreenElement ?? legacy.webkitFullscreenElement ?? null;
119
+ }
120
+
121
+ /**
122
+ * Fullscreen, including the one platform that will not give it to a container.
123
+ *
124
+ * iOS Safari has no Fullscreen API on an arbitrary element: the only thing that can fill the
125
+ * screen is the video itself, through `webkitEnterFullscreen`. Without that branch the button
126
+ * does nothing at all on an iPhone, which is where most video is watched.
127
+ */
128
+ export async function toggleFullscreen(container: HTMLElement, video: HTMLVideoElement | null): Promise<void> {
129
+ const legacyDocument = document as Document & { webkitExitFullscreen?: () => Promise<void>; };
130
+ if (fullscreenElement()) {
131
+ await (document.exitFullscreen?.() ?? legacyDocument.webkitExitFullscreen?.());
132
+ return;
133
+ }
134
+
135
+ const legacyElement = container as HTMLElement & { webkitRequestFullscreen?: () => Promise<void>; };
136
+ if (container.requestFullscreen) return void await container.requestFullscreen();
137
+ if (legacyElement.webkitRequestFullscreen) return void await legacyElement.webkitRequestFullscreen();
138
+
139
+ const iosVideo = video as (HTMLVideoElement & { webkitEnterFullscreen?: () => void; }) | null;
140
+ iosVideo?.webkitEnterFullscreen?.();
141
+ }
142
+
143
+ /** Picture in picture, where the browser has it. Firefox has its own button and no API. */
144
+ export function supportsPip(): boolean {
145
+ return typeof document !== "undefined" && Boolean(document.pictureInPictureEnabled);
146
+ }
147
+
148
+ export async function togglePip(video: HTMLVideoElement): Promise<void> {
149
+ if (document.pictureInPictureElement) return void await document.exitPictureInPicture();
150
+ await video.requestPictureInPicture();
151
+ }
152
+
153
+ /* -------- captions -------- */
154
+
155
+ /** One subtitle track, as the menu lists it. */
156
+ export interface CaptionTrack {
157
+ /** Where it sits in the element's own `textTracks`, which is what turns it on. */
158
+ index: number;
159
+ label: string;
160
+ language: string;
161
+ }
162
+
163
+ /** Which of the element's text tracks are subtitles a viewer would choose between. */
164
+ export function captionTracks(list: TextTrackList | null | undefined): CaptionTrack[] {
165
+ if (!list) return [];
166
+ const found: CaptionTrack[] = [];
167
+ for (let at = 0; at < list.length; at += 1) {
168
+ const track = list[at];
169
+ // Chapters and metadata are for the page, not for the viewer: listing them offers a
170
+ // language picker entry that draws nothing over the picture.
171
+ if (!track || (track.kind !== "subtitles" && track.kind !== "captions")) continue;
172
+ found.push({ index: at, label: track.label || track.language || `Track ${found.length + 1}`, language: track.language });
173
+ }
174
+ return found;
175
+ }
176
+
177
+ /**
178
+ * The track the element is SHOWING, or -1.
179
+ *
180
+ * Read rather than remembered: a `default` track is showing before any button was pressed, and
181
+ * a player that assumed "off" would need two presses to turn something off.
182
+ */
183
+ export function activeCaption(list: TextTrackList | null | undefined): number {
184
+ if (!list) return -1;
185
+ for (let at = 0; at < list.length; at += 1) if (list[at]?.mode === "showing") return at;
186
+ return -1;
187
+ }
188
+
189
+ /**
190
+ * Show one track and disable the rest. -1 turns them all off.
191
+ *
192
+ * "disabled" rather than "hidden": a hidden track still fires its cues, so a page listening to
193
+ * `cuechange` for its own transcript would keep receiving a language nobody asked for.
194
+ */
195
+ export function showCaption(list: TextTrackList | null | undefined, index: number): void {
196
+ if (!list) return;
197
+ for (let at = 0; at < list.length; at += 1) {
198
+ const track = list[at];
199
+ if (track) track.mode = at === index ? "showing" : "disabled";
200
+ }
201
+ }
202
+
203
+ /* -------- casting: the remote screen this is played on -------- */
204
+
205
+ /** What the cast control knows about the world. */
206
+ export interface RemoteState {
207
+ /** There is somewhere to cast TO. Before the browser has looked, this is optimistic. */
208
+ available: boolean;
209
+ /** Playing on that screen right now. */
210
+ connected: boolean;
211
+ }
212
+
213
+ interface RemotePlaybackLike {
214
+ state: "connected" | "connecting" | "disconnected";
215
+ watchAvailability: (callback: (available: boolean) => void) => Promise<number>;
216
+ cancelWatchAvailability: (id: number) => Promise<void>;
217
+ prompt: () => Promise<void>;
218
+ addEventListener: (type: string, listener: () => void) => void;
219
+ removeEventListener: (type: string, listener: () => void) => void;
220
+ }
221
+
222
+ /** The element, with the two spellings of "play this somewhere else" on it. */
223
+ type CastableVideo = HTMLVideoElement & {
224
+ remote?: RemotePlaybackLike;
225
+ /** Safari's AirPlay picker, which predates the standard and is still the only one it has on iOS. */
226
+ webkitShowPlaybackTargetPicker?: () => void;
227
+ webkitCurrentPlaybackTargetIsWireless?: boolean;
228
+ };
229
+
230
+ /**
231
+ * Whether this element can be cast at all.
232
+ *
233
+ * `disableRemotePlayback` is honoured: a page that asked for the button to be gone does not
234
+ * get one drawn over its video by a component.
235
+ */
236
+ export function supportsRemote(video: HTMLVideoElement | null): boolean {
237
+ const element = video as CastableVideo | null;
238
+ if (!element || element.disableRemotePlayback) return false;
239
+ return Boolean(element.remote?.prompt ?? element.webkitShowPlaybackTargetPicker);
240
+ }
241
+
242
+ /**
243
+ * Watch for a screen to cast to, and for the connection to it.
244
+ *
245
+ * WHY AVAILABILITY IS OPTIMISTIC WHEN THE WATCH FAILS. `watchAvailability` rejects with
246
+ * NotSupportedError on the platforms that can still `prompt()` - Safari, and Chromium for some
247
+ * sources - so treating a rejection as "no devices" hides a control that works. A button that
248
+ * opens an empty picker is a smaller loss than a missing one.
249
+ */
250
+ export function watchRemote(video: HTMLVideoElement, onChange: (state: RemoteState) => void): () => void {
251
+ const element = video as CastableVideo;
252
+ const state: RemoteState = { available: false, connected: false };
253
+ const publish = (next: Partial<RemoteState>): void => {
254
+ Object.assign(state, next);
255
+ onChange({ ...state });
256
+ };
257
+
258
+ if (element.remote) {
259
+ const remote = element.remote;
260
+ const onState = (): void => publish({ connected: remote.state === "connected" });
261
+ let watch: number | null = null;
262
+ let cancelled = false;
263
+
264
+ remote.watchAvailability((available) => { if (!cancelled) publish({ available }); })
265
+ .then((id) => {
266
+ if (cancelled) void remote.cancelWatchAvailability(id).catch(() => { /* already gone */ });
267
+ else watch = id;
268
+ })
269
+ .catch(() => { if (!cancelled) publish({ available: true }); });
270
+
271
+ for (const name of ["connect", "connecting", "disconnect"]) remote.addEventListener(name, onState);
272
+ onState();
273
+
274
+ return () => {
275
+ cancelled = true;
276
+ for (const name of ["connect", "connecting", "disconnect"]) remote.removeEventListener(name, onState);
277
+ if (watch !== null) void remote.cancelWatchAvailability(watch).catch(() => { /* already gone */ });
278
+ };
279
+ }
280
+
281
+ const onAvailability = (event: Event): void => publish({ available: (event as Event & { availability?: string; }).availability === "available" });
282
+ const onWireless = (): void => publish({ connected: Boolean(element.webkitCurrentPlaybackTargetIsWireless) });
283
+ element.addEventListener("webkitplaybacktargetavailabilitychanged", onAvailability);
284
+ element.addEventListener("webkitcurrentplaybacktargetiswirelesschanged", onWireless);
285
+ onWireless();
286
+
287
+ return () => {
288
+ element.removeEventListener("webkitplaybacktargetavailabilitychanged", onAvailability);
289
+ element.removeEventListener("webkitcurrentplaybacktargetiswirelesschanged", onWireless);
290
+ };
291
+ }
292
+
293
+ /**
294
+ * Open the browser's own device picker.
295
+ *
296
+ * There is no list to draw ourselves: both APIs hand the choice to the platform, which is the
297
+ * only thing that can enumerate the screens on the network.
298
+ */
299
+ export async function promptRemote(video: HTMLVideoElement): Promise<void> {
300
+ const element = video as CastableVideo;
301
+ if (element.remote?.prompt) return void await element.remote.prompt();
302
+ element.webkitShowPlaybackTargetPicker?.();
303
+ }
package/src/index.ts CHANGED
@@ -152,3 +152,55 @@ export {
152
152
  type TypeaheadState,
153
153
  type TypeaheadStep
154
154
  } from "@/core/keys";
155
+
156
+ // The image viewer's arithmetic: zoom that stays under the cursor, a pan that cannot lose the
157
+ // picture, and moving through a set. The lightbox is React; none of this is.
158
+ export {
159
+ zoomAt,
160
+ clampPan,
161
+ clampScale,
162
+ wheelFactor,
163
+ fittedSize,
164
+ pinchDistance,
165
+ nextIndex,
166
+ filenameFrom,
167
+ downloadFile,
168
+ flightFrom,
169
+ flightMs,
170
+ prefersReducedMotion,
171
+ FLIGHT_MS,
172
+ IDENTITY,
173
+ ZOOM_LIMITS,
174
+ ZOOM_STEP,
175
+ ZOOM_DOUBLE,
176
+ type Transform,
177
+ type ZoomLimits,
178
+ type Box
179
+ } from "@/core/image-viewer";
180
+ // The video player's arithmetic: times, seeking, buffering and the shortcut map.
181
+ export {
182
+ formatTime,
183
+ progress,
184
+ fractionAt,
185
+ bufferedAhead,
186
+ nextSpeed,
187
+ commandFor,
188
+ isTypingTarget,
189
+ fullscreenElement,
190
+ toggleFullscreen,
191
+ supportsPip,
192
+ togglePip,
193
+ captionTracks,
194
+ activeCaption,
195
+ showCaption,
196
+ supportsRemote,
197
+ watchRemote,
198
+ promptRemote,
199
+ SPEEDS,
200
+ SEEK_STEP,
201
+ SEEK_JUMP,
202
+ VOLUME_STEP,
203
+ type PlayerCommand,
204
+ type CaptionTrack,
205
+ type RemoteState
206
+ } from "@/core/player";
@@ -0,0 +1,55 @@
1
+ import type { ReactNode } from "react";
2
+
3
+ /**
4
+ * The viewer's icons, drawn rather than loaded.
5
+ *
6
+ * A sprite or an SVG file would be a request, and an icon package would be a dependency for
7
+ * eight paths. They sit in one module so the viewer and its menu share them without either
8
+ * pulling the other's chunk.
9
+ */
10
+
11
+ function Glyph({ children }: { children: ReactNode; }): ReactNode {
12
+ return (
13
+ <svg viewBox="0 0 24 24" width="1.125em" height="1.125em" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
14
+ {children}
15
+ </svg>
16
+ );
17
+ }
18
+
19
+ export function Close(): ReactNode {
20
+ return <Glyph><path d="M6 6 18 18M18 6 6 18" /></Glyph>;
21
+ }
22
+
23
+ export function Plus(): ReactNode {
24
+ return <Glyph><path d="M12 5v14M5 12h14" /></Glyph>;
25
+ }
26
+
27
+ export function Minus(): ReactNode {
28
+ return <Glyph><path d="M5 12h14" /></Glyph>;
29
+ }
30
+
31
+ export function ChevronLeft(): ReactNode {
32
+ return <Glyph><path d="m15 5-7 7 7 7" /></Glyph>;
33
+ }
34
+
35
+ export function ChevronRight(): ReactNode {
36
+ return <Glyph><path d="m9 5 7 7-7 7" /></Glyph>;
37
+ }
38
+
39
+ export function Trash(): ReactNode {
40
+ return <Glyph><path d="M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13" /></Glyph>;
41
+ }
42
+
43
+ export function Dots(): ReactNode {
44
+ return (
45
+ <svg viewBox="0 0 24 24" width="1.125em" height="1.125em" fill="currentColor" aria-hidden="true">
46
+ <circle cx="12" cy="5" r="1.75" />
47
+ <circle cx="12" cy="12" r="1.75" />
48
+ <circle cx="12" cy="19" r="1.75" />
49
+ </svg>
50
+ );
51
+ }
52
+
53
+ export function Download(): ReactNode {
54
+ return <Glyph><path d="M12 3v12M7 11l5 5 5-5M4 20h16" /></Glyph>;
55
+ }