@loidolt/theme-svelte 0.4.0 → 0.6.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 (127) hide show
  1. package/README.md +11 -0
  2. package/dist/announcer.svelte.d.ts +36 -0
  3. package/dist/announcer.svelte.js +73 -0
  4. package/dist/attachments.d.ts +83 -0
  5. package/dist/attachments.js +211 -0
  6. package/dist/components/ActiveFilterChips.svelte +80 -0
  7. package/dist/components/ActiveFilterChips.svelte.d.ts +41 -0
  8. package/dist/components/AlertDialog.svelte +4 -4
  9. package/dist/components/AppShell.svelte +9 -2
  10. package/dist/components/AppShell.svelte.d.ts +3 -1
  11. package/dist/components/AspectRatio.svelte +32 -0
  12. package/dist/components/AspectRatio.svelte.d.ts +13 -0
  13. package/dist/components/AudioPlayer.svelte +94 -0
  14. package/dist/components/AudioPlayer.svelte.d.ts +24 -0
  15. package/dist/components/Avatar.svelte +85 -0
  16. package/dist/components/Avatar.svelte.d.ts +31 -0
  17. package/dist/components/Badge.svelte +28 -2
  18. package/dist/components/Badge.svelte.d.ts +13 -1
  19. package/dist/components/Combobox.svelte +180 -0
  20. package/dist/components/Combobox.svelte.d.ts +77 -0
  21. package/dist/components/DataTable.svelte +228 -0
  22. package/dist/components/DataTable.svelte.d.ts +62 -0
  23. package/dist/components/DataTableColumnVisibility.svelte +46 -0
  24. package/dist/components/DataTableColumnVisibility.svelte.d.ts +31 -0
  25. package/dist/components/DataTableFacetedFilter.svelte +87 -0
  26. package/dist/components/DataTableFacetedFilter.svelte.d.ts +40 -0
  27. package/dist/components/DataTablePagination.svelte +68 -0
  28. package/dist/components/DataTablePagination.svelte.d.ts +42 -0
  29. package/dist/components/DataTableSearch.svelte +67 -0
  30. package/dist/components/DataTableSearch.svelte.d.ts +37 -0
  31. package/dist/components/Dialog.svelte +35 -14
  32. package/dist/components/Dialog.svelte.d.ts +7 -0
  33. package/dist/components/Drawer.svelte +3 -3
  34. package/dist/components/DropdownMenu.svelte +95 -28
  35. package/dist/components/DropdownMenu.svelte.d.ts +12 -3
  36. package/dist/components/Field.svelte +2 -3
  37. package/dist/components/Fieldset.svelte +2 -3
  38. package/dist/components/Filmstrip.svelte +10 -3
  39. package/dist/components/FilterPanel.svelte +62 -0
  40. package/dist/components/FilterPanel.svelte.d.ts +22 -0
  41. package/dist/components/Lightbox.svelte +239 -0
  42. package/dist/components/Lightbox.svelte.d.ts +40 -0
  43. package/dist/components/LiveRegion.svelte +48 -0
  44. package/dist/components/LiveRegion.svelte.d.ts +20 -0
  45. package/dist/components/MediaCarousel.svelte +177 -0
  46. package/dist/components/MediaCarousel.svelte.d.ts +32 -0
  47. package/dist/components/MediaEmbed.svelte +116 -0
  48. package/dist/components/MediaEmbed.svelte.d.ts +34 -0
  49. package/dist/components/MediaGrid.svelte +91 -0
  50. package/dist/components/MediaGrid.svelte.d.ts +24 -0
  51. package/dist/components/MultiSelect.svelte +232 -0
  52. package/dist/components/MultiSelect.svelte.d.ts +67 -0
  53. package/dist/components/NumberField.svelte +53 -25
  54. package/dist/components/NumberField.svelte.d.ts +47 -18
  55. package/dist/components/OTPInput.svelte +94 -0
  56. package/dist/components/OTPInput.svelte.d.ts +26 -0
  57. package/dist/components/PasswordInput.svelte +93 -0
  58. package/dist/components/PasswordInput.svelte.d.ts +24 -0
  59. package/dist/components/Progress.svelte +57 -14
  60. package/dist/components/Progress.svelte.d.ts +13 -0
  61. package/dist/components/RadioGroup.svelte +19 -5
  62. package/dist/components/RadioGroup.svelte.d.ts +3 -2
  63. package/dist/components/Select.svelte +15 -5
  64. package/dist/components/Select.svelte.d.ts +3 -2
  65. package/dist/components/SignaturePad.svelte +317 -0
  66. package/dist/components/SignaturePad.svelte.d.ts +48 -0
  67. package/dist/components/Skeleton.svelte +40 -9
  68. package/dist/components/Skeleton.svelte.d.ts +8 -0
  69. package/dist/components/SkipLink.svelte +48 -0
  70. package/dist/components/SkipLink.svelte.d.ts +14 -0
  71. package/dist/components/Slider.svelte +148 -0
  72. package/dist/components/Slider.svelte.d.ts +55 -0
  73. package/dist/components/Spinner.svelte +11 -2
  74. package/dist/components/Spinner.svelte.d.ts +3 -0
  75. package/dist/components/Tabs.svelte.d.ts +1 -1
  76. package/dist/components/Thumbnail.svelte +52 -6
  77. package/dist/components/Thumbnail.svelte.d.ts +14 -2
  78. package/dist/components/Toast.svelte +24 -0
  79. package/dist/components/Toast.svelte.d.ts +10 -0
  80. package/dist/components/ToastViewport.svelte +10 -1
  81. package/dist/components/ToastViewport.svelte.d.ts +2 -0
  82. package/dist/components/Toolbar.svelte +49 -0
  83. package/dist/components/Toolbar.svelte.d.ts +23 -0
  84. package/dist/components/Tooltip.svelte +9 -1
  85. package/dist/components/Tooltip.svelte.d.ts +2 -0
  86. package/dist/components/VideoPlayer.svelte +136 -0
  87. package/dist/components/VideoPlayer.svelte.d.ts +38 -0
  88. package/dist/data-table-utils.d.ts +62 -0
  89. package/dist/data-table-utils.js +91 -0
  90. package/dist/data-table.svelte.d.ts +132 -0
  91. package/dist/data-table.svelte.js +292 -0
  92. package/dist/hls.d.ts +35 -0
  93. package/dist/hls.js +37 -0
  94. package/dist/hls.svelte.d.ts +22 -0
  95. package/dist/hls.svelte.js +66 -0
  96. package/dist/index.d.ts +47 -4
  97. package/dist/index.js +35 -2
  98. package/dist/internal/EditableCell.svelte +58 -0
  99. package/dist/internal/EditableCell.svelte.d.ts +8 -0
  100. package/dist/internal/ListboxEntries.svelte +49 -0
  101. package/dist/internal/ListboxEntries.svelte.d.ts +35 -0
  102. package/dist/internal/media/MediaControls.svelte +163 -0
  103. package/dist/internal/media/MediaControls.svelte.d.ts +12 -0
  104. package/dist/internal/media/MediaStage.svelte +49 -0
  105. package/dist/internal/media/MediaStage.svelte.d.ts +11 -0
  106. package/dist/internal/media/MediaTile.svelte +36 -0
  107. package/dist/internal/media/MediaTile.svelte.d.ts +11 -0
  108. package/dist/internal/media/keys.d.ts +7 -0
  109. package/dist/internal/media/keys.js +51 -0
  110. package/dist/internal/media/labels.d.ts +19 -0
  111. package/dist/internal/media/labels.js +15 -0
  112. package/dist/internal/options.d.ts +9 -0
  113. package/dist/internal/options.js +20 -0
  114. package/dist/media-player.svelte.d.ts +61 -0
  115. package/dist/media-player.svelte.js +246 -0
  116. package/dist/media-utils.d.ts +112 -0
  117. package/dist/media-utils.js +205 -0
  118. package/dist/media-zoom.svelte.d.ts +33 -0
  119. package/dist/media-zoom.svelte.js +126 -0
  120. package/dist/signature.d.ts +55 -0
  121. package/dist/signature.js +52 -0
  122. package/dist/toaster.svelte.d.ts +31 -2
  123. package/dist/toaster.svelte.js +30 -2
  124. package/dist/types.d.ts +62 -3
  125. package/dist/utils.d.ts +9 -0
  126. package/dist/utils.js +11 -0
  127. package/package.json +10 -4
@@ -0,0 +1,11 @@
1
+ import type { MediaZoom } from '../../media-zoom.svelte.js';
2
+ import type { MediaItem } from '../../media-utils.js';
3
+ interface Props {
4
+ item: MediaItem;
5
+ zoom?: MediaZoom;
6
+ /** Images fill the frame (`cover`) in a carousel, and fit whole (`contain`) in a lightbox. */
7
+ fit?: 'contain' | 'cover';
8
+ }
9
+ declare const MediaStage: import("svelte").Component<Props, {}, "">;
10
+ type MediaStage = ReturnType<typeof MediaStage>;
11
+ export default MediaStage;
@@ -0,0 +1,36 @@
1
+ <script lang="ts">
2
+ import Thumbnail from '../../components/Thumbnail.svelte';
3
+ import { mediaLabel, mediaThumbnail, type MediaItem } from '../../media-utils.js';
4
+
5
+ /* One pressable tile in a MediaGrid: the preview, plus a kind marker for time-based media. */
6
+ interface Props {
7
+ item: MediaItem;
8
+ index: number;
9
+ ratio?: string | number;
10
+ kindLabels: Record<MediaItem['kind'], string>;
11
+ onSelect: () => void;
12
+ }
13
+
14
+ let { item, index, ratio, kindLabels, onSelect }: Props = $props();
15
+
16
+ const label = $derived(mediaLabel(item, index));
17
+ const preview = $derived(mediaThumbnail(item));
18
+ </script>
19
+
20
+ <button
21
+ type="button"
22
+ class="ldt-media-tile"
23
+ data-roving-item
24
+ aria-label={item.kind === 'image' ? label : `${label}, ${kindLabels[item.kind]}`}
25
+ onclick={onSelect}
26
+ >
27
+ <Thumbnail src={preview} alt="" {ratio} fit="cover">
28
+ {#snippet overlay()}
29
+ {#if item.kind !== 'image'}<span class="ldt-media-tile__kind" aria-hidden="true"
30
+ >{kindLabels[item.kind]}</span
31
+ >{/if}
32
+ {/snippet}
33
+ </Thumbnail>
34
+ {#if item.caption}<span class="ldt-media-tile__caption" aria-hidden="true">{item.caption}</span
35
+ >{/if}
36
+ </button>
@@ -0,0 +1,11 @@
1
+ import { type MediaItem } from '../../media-utils.js';
2
+ interface Props {
3
+ item: MediaItem;
4
+ index: number;
5
+ ratio?: string | number;
6
+ kindLabels: Record<MediaItem['kind'], string>;
7
+ onSelect: () => void;
8
+ }
9
+ declare const MediaTile: import("svelte").Component<Props, {}, "">;
10
+ type MediaTile = ReturnType<typeof MediaTile>;
11
+ export default MediaTile;
@@ -0,0 +1,7 @@
1
+ import type { MediaPlayer } from '../../media-player.svelte.js';
2
+ /**
3
+ * The shortcuts every web player shares: Space or K plays, arrows seek and change volume, M
4
+ * mutes, F goes full screen, C toggles captions. Keys meant for a focused control — typing in a
5
+ * field, stepping a range, pressing a button — are left alone.
6
+ */
7
+ export declare function mediaKeydown(player: MediaPlayer, container: () => HTMLElement | null): (event: KeyboardEvent) => void;
@@ -0,0 +1,51 @@
1
+ /**
2
+ * The shortcuts every web player shares: Space or K plays, arrows seek and change volume, M
3
+ * mutes, F goes full screen, C toggles captions. Keys meant for a focused control — typing in a
4
+ * field, stepping a range, pressing a button — are left alone.
5
+ */
6
+ export function mediaKeydown(player, container) {
7
+ return (event) => {
8
+ const target = event.target;
9
+ if (event.altKey || event.ctrlKey || event.metaKey)
10
+ return;
11
+ if (target.matches('input, select, textarea'))
12
+ return;
13
+ if (target.matches('button') && (event.key === ' ' || event.key === 'Enter'))
14
+ return;
15
+ const handled = (() => {
16
+ switch (event.key) {
17
+ case ' ':
18
+ case 'k':
19
+ void player.toggle();
20
+ return true;
21
+ case 'ArrowLeft':
22
+ player.seekBy(-5);
23
+ return true;
24
+ case 'ArrowRight':
25
+ player.seekBy(5);
26
+ return true;
27
+ case 'ArrowUp':
28
+ player.setVolume(player.volume + 0.1);
29
+ return true;
30
+ case 'ArrowDown':
31
+ player.setVolume(player.volume - 0.1);
32
+ return true;
33
+ case 'm':
34
+ player.toggleMute();
35
+ return true;
36
+ case 'f':
37
+ void player.toggleFullscreen(container());
38
+ return true;
39
+ case 'c':
40
+ if (!player.tracks.length)
41
+ return false;
42
+ player.selectTrack(player.activeTrack === null ? 0 : null);
43
+ return true;
44
+ default:
45
+ return false;
46
+ }
47
+ })();
48
+ if (handled)
49
+ event.preventDefault();
50
+ };
51
+ }
@@ -0,0 +1,19 @@
1
+ /** Every string the media players show or announce. */
2
+ export interface MediaPlayerLabels {
3
+ play: string;
4
+ pause: string;
5
+ mute: string;
6
+ unmute: string;
7
+ seek: string;
8
+ volume: string;
9
+ speed: string;
10
+ captions: string;
11
+ pictureInPicture: string;
12
+ fullscreen: string;
13
+ exitFullscreen: string;
14
+ /** Shown when the media cannot be played. */
15
+ error: string;
16
+ /** Spoken position, e.g. "1 minute 5 seconds of 4 minutes". */
17
+ position: (current: string, total: string) => string;
18
+ }
19
+ export declare const defaultMediaLabels: MediaPlayerLabels;
@@ -0,0 +1,15 @@
1
+ export const defaultMediaLabels = {
2
+ play: 'Play',
3
+ pause: 'Pause',
4
+ mute: 'Mute',
5
+ unmute: 'Unmute',
6
+ seek: 'Seek',
7
+ volume: 'Volume',
8
+ speed: 'Playback speed',
9
+ captions: 'Captions',
10
+ pictureInPicture: 'Picture in picture',
11
+ fullscreen: 'Full screen',
12
+ exitFullscreen: 'Exit full screen',
13
+ error: 'This media could not be played.',
14
+ position: (current, total) => `${current} of ${total}`,
15
+ };
@@ -0,0 +1,9 @@
1
+ import type { Option, OptionGroup } from '../types.js';
2
+ export type OptionEntry<T extends string = string> = Option<T> | OptionGroup<T>;
3
+ export declare const isOptionGroup: <T extends string>(entry: OptionEntry<T>) => entry is OptionGroup<T>;
4
+ /** Every option, groups flattened, with a group's `disabled` pushed down onto its members. */
5
+ export declare const flattenOptions: <T extends string>(entries: readonly OptionEntry<T>[]) => Option<T>[];
6
+ /** Keeps the entries whose options match, dropping groups left empty. */
7
+ export declare const filterEntries: <T extends string>(entries: readonly OptionEntry<T>[], keep: (option: Option<T>) => boolean) => OptionEntry<T>[];
8
+ /** Case- and accent-insensitive substring match on the label. */
9
+ export declare const matchesQuery: (option: Option, query: string) => boolean;
@@ -0,0 +1,20 @@
1
+ export const isOptionGroup = (entry) => 'options' in entry;
2
+ /** Every option, groups flattened, with a group's `disabled` pushed down onto its members. */
3
+ export const flattenOptions = (entries) => entries.flatMap((entry) => isOptionGroup(entry)
4
+ ? entry.options.map((option) => (entry.disabled ? { ...option, disabled: true } : option))
5
+ : [entry]);
6
+ /** Keeps the entries whose options match, dropping groups left empty. */
7
+ export const filterEntries = (entries, keep) => entries.flatMap((entry) => {
8
+ if (!isOptionGroup(entry))
9
+ return keep(entry) ? [entry] : [];
10
+ const options = entry.options.filter(keep);
11
+ return options.length ? [{ ...entry, options }] : [];
12
+ });
13
+ /** Case- and accent-insensitive substring match on the label. */
14
+ export const matchesQuery = (option, query) => {
15
+ const fold = (value) => value
16
+ .normalize('NFD')
17
+ .replace(/\p{Diacritic}/gu, '')
18
+ .toLocaleLowerCase();
19
+ return fold(option.label).includes(fold(query.trim()));
20
+ };
@@ -0,0 +1,61 @@
1
+ import type { Attachment } from 'svelte/attachments';
2
+ export interface MediaPlayerOptions {
3
+ onEnded?: () => void;
4
+ onError?: (error: MediaError | null) => void;
5
+ onTimeUpdate?: (seconds: number) => void;
6
+ }
7
+ export interface MediaTrackInfo {
8
+ label: string;
9
+ language: string;
10
+ kind: string;
11
+ }
12
+ export interface MediaPlayer {
13
+ /** Put on the `<video>` or `<audio>` element: `{@attach player.attach}`. */
14
+ readonly attach: Attachment<HTMLMediaElement>;
15
+ readonly playing: boolean;
16
+ readonly ended: boolean;
17
+ /** Stalled waiting for data. */
18
+ readonly waiting: boolean;
19
+ readonly currentTime: number;
20
+ /** `NaN` until metadata loads; `Infinity` for a live stream. */
21
+ readonly duration: number;
22
+ /** Buffered time ranges, in seconds. */
23
+ readonly buffered: Array<[number, number]>;
24
+ readonly volume: number;
25
+ readonly muted: boolean;
26
+ readonly rate: number;
27
+ readonly error: MediaError | null;
28
+ readonly fullscreen: boolean;
29
+ readonly pictureInPicture: boolean;
30
+ /** Captions and subtitles, in document order. */
31
+ readonly tracks: MediaTrackInfo[];
32
+ /** Index into `tracks` of the one showing, or `null`. */
33
+ readonly activeTrack: number | null;
34
+ /** Whether this browser offers picture-in-picture for the attached element. */
35
+ readonly canPictureInPicture: boolean;
36
+ play(): Promise<void>;
37
+ pause(): void;
38
+ toggle(): Promise<void>;
39
+ seek(seconds: number): void;
40
+ seekBy(delta: number): void;
41
+ setVolume(volume: number): void;
42
+ toggleMute(): void;
43
+ setRate(rate: number): void;
44
+ /** Show one text track (by index into `tracks`) or none. */
45
+ selectTrack(index: number | null): void;
46
+ /** Fullscreen the given container (so custom controls come along), else the media itself. */
47
+ toggleFullscreen(container?: HTMLElement | null): Promise<void>;
48
+ togglePictureInPicture(): Promise<void>;
49
+ destroy(): void;
50
+ }
51
+ /**
52
+ * Reactive state and commands over a native `<video>` or `<audio>` element. `VideoPlayer` and
53
+ * `AudioPlayer` are built on it; use it directly to put your own controls on a media element.
54
+ *
55
+ * ```svelte
56
+ * const player = createMediaPlayer();
57
+ * <video src={url} {@attach player.attach}></video>
58
+ * <button onclick={player.toggle}>{player.playing ? 'Pause' : 'Play'}</button>
59
+ * ```
60
+ */
61
+ export declare function createMediaPlayer(options?: MediaPlayerOptions): MediaPlayer;
@@ -0,0 +1,246 @@
1
+ import { onDestroy } from 'svelte';
2
+ import { clamp, timeRangesToArray } from './media-utils.js';
3
+ /**
4
+ * Reactive state and commands over a native `<video>` or `<audio>` element. `VideoPlayer` and
5
+ * `AudioPlayer` are built on it; use it directly to put your own controls on a media element.
6
+ *
7
+ * ```svelte
8
+ * const player = createMediaPlayer();
9
+ * <video src={url} {@attach player.attach}></video>
10
+ * <button onclick={player.toggle}>{player.playing ? 'Pause' : 'Play'}</button>
11
+ * ```
12
+ */
13
+ export function createMediaPlayer(options = {}) {
14
+ // Not reactive on purpose: the attachment reads it, and an attachment that reads state it
15
+ // also writes re-runs itself forever.
16
+ let media = null;
17
+ let pipSupported = $state(false);
18
+ let playing = $state(false);
19
+ let ended = $state(false);
20
+ let waiting = $state(false);
21
+ let currentTime = $state(0);
22
+ let duration = $state(Number.NaN);
23
+ let buffered = $state([]);
24
+ let volume = $state(1);
25
+ let muted = $state(false);
26
+ let rate = $state(1);
27
+ let error = $state(null);
28
+ let fullscreen = $state(false);
29
+ let pictureInPicture = $state(false);
30
+ let tracks = $state([]);
31
+ let activeTrack = $state(null);
32
+ const textTracks = () => [...(media?.textTracks ?? [])].filter((track) => track.kind === 'captions' || track.kind === 'subtitles');
33
+ let detach;
34
+ const attach = (node) => {
35
+ media = node;
36
+ pipSupported =
37
+ node instanceof HTMLVideoElement &&
38
+ node.ownerDocument.pictureInPictureEnabled === true &&
39
+ typeof node.requestPictureInPicture === 'function';
40
+ const doc = node.ownerDocument;
41
+ const syncTracks = () => {
42
+ const list = textTracks();
43
+ tracks = list.map((track) => ({
44
+ label: track.label,
45
+ language: track.language,
46
+ kind: track.kind,
47
+ }));
48
+ const showing = list.findIndex((track) => track.mode === 'showing');
49
+ activeTrack = showing === -1 ? null : showing;
50
+ };
51
+ const sync = () => {
52
+ playing = !node.paused;
53
+ ended = node.ended;
54
+ currentTime = node.currentTime;
55
+ duration = node.duration;
56
+ buffered = timeRangesToArray(node.buffered);
57
+ volume = node.volume;
58
+ muted = node.muted;
59
+ rate = node.playbackRate;
60
+ };
61
+ const handlers = {
62
+ play: () => {
63
+ playing = true;
64
+ ended = false;
65
+ },
66
+ pause: () => (playing = false),
67
+ playing: () => (waiting = false),
68
+ waiting: () => (waiting = true),
69
+ ended: () => {
70
+ playing = false;
71
+ ended = true;
72
+ options.onEnded?.();
73
+ },
74
+ timeupdate: () => {
75
+ currentTime = node.currentTime;
76
+ options.onTimeUpdate?.(node.currentTime);
77
+ },
78
+ durationchange: () => (duration = node.duration),
79
+ loadedmetadata: () => {
80
+ sync();
81
+ syncTracks();
82
+ },
83
+ progress: () => (buffered = timeRangesToArray(node.buffered)),
84
+ volumechange: () => {
85
+ volume = node.volume;
86
+ muted = node.muted;
87
+ },
88
+ ratechange: () => (rate = node.playbackRate),
89
+ seeked: () => (currentTime = node.currentTime),
90
+ error: () => {
91
+ error = node.error;
92
+ options.onError?.(node.error);
93
+ },
94
+ enterpictureinpicture: () => (pictureInPicture = true),
95
+ leavepictureinpicture: () => (pictureInPicture = false),
96
+ };
97
+ const onFullscreen = () => {
98
+ const element = doc.fullscreenElement;
99
+ fullscreen = Boolean(element && (element === node || element.contains(node)));
100
+ };
101
+ sync();
102
+ syncTracks();
103
+ for (const [type, handler] of Object.entries(handlers))
104
+ node.addEventListener(type, handler);
105
+ node.textTracks?.addEventListener?.('change', syncTracks);
106
+ node.textTracks?.addEventListener?.('addtrack', syncTracks);
107
+ doc.addEventListener('fullscreenchange', onFullscreen);
108
+ detach = () => {
109
+ for (const [type, handler] of Object.entries(handlers)) {
110
+ node.removeEventListener(type, handler);
111
+ }
112
+ node.textTracks?.removeEventListener?.('change', syncTracks);
113
+ node.textTracks?.removeEventListener?.('addtrack', syncTracks);
114
+ doc.removeEventListener('fullscreenchange', onFullscreen);
115
+ if (media === node)
116
+ media = null;
117
+ detach = undefined;
118
+ };
119
+ return () => detach?.();
120
+ };
121
+ async function play() {
122
+ if (!media)
123
+ return;
124
+ try {
125
+ await media.play();
126
+ }
127
+ catch {
128
+ // Autoplay policy or an unplayable source; `error` and `playing` already say what happened.
129
+ }
130
+ }
131
+ function seek(seconds) {
132
+ if (!media)
133
+ return;
134
+ const end = Number.isFinite(media.duration) ? media.duration : Number.MAX_SAFE_INTEGER;
135
+ media.currentTime = clamp(seconds, 0, end);
136
+ currentTime = media.currentTime;
137
+ }
138
+ const destroy = () => {
139
+ detach?.();
140
+ };
141
+ try {
142
+ onDestroy(destroy);
143
+ }
144
+ catch {
145
+ /* module scope: the caller owns `destroy()` */
146
+ }
147
+ return {
148
+ attach,
149
+ get playing() {
150
+ return playing;
151
+ },
152
+ get ended() {
153
+ return ended;
154
+ },
155
+ get waiting() {
156
+ return waiting;
157
+ },
158
+ get currentTime() {
159
+ return currentTime;
160
+ },
161
+ get duration() {
162
+ return duration;
163
+ },
164
+ get buffered() {
165
+ return buffered;
166
+ },
167
+ get volume() {
168
+ return volume;
169
+ },
170
+ get muted() {
171
+ return muted;
172
+ },
173
+ get rate() {
174
+ return rate;
175
+ },
176
+ get error() {
177
+ return error;
178
+ },
179
+ get fullscreen() {
180
+ return fullscreen;
181
+ },
182
+ get pictureInPicture() {
183
+ return pictureInPicture;
184
+ },
185
+ get tracks() {
186
+ return tracks;
187
+ },
188
+ get activeTrack() {
189
+ return activeTrack;
190
+ },
191
+ get canPictureInPicture() {
192
+ return pipSupported;
193
+ },
194
+ play,
195
+ pause: () => media?.pause(),
196
+ toggle: () => (media && !media.paused ? Promise.resolve(media.pause()) : play()),
197
+ seek,
198
+ seekBy: (delta) => seek((media?.currentTime ?? 0) + delta),
199
+ setVolume(next) {
200
+ if (!media)
201
+ return;
202
+ media.volume = clamp(next, 0, 1);
203
+ if (media.volume > 0 && media.muted)
204
+ media.muted = false;
205
+ },
206
+ toggleMute() {
207
+ if (media)
208
+ media.muted = !media.muted;
209
+ },
210
+ setRate(next) {
211
+ if (media)
212
+ media.playbackRate = clamp(next, 0.25, 4);
213
+ },
214
+ selectTrack(index) {
215
+ textTracks().forEach((track, position) => {
216
+ track.mode = position === index ? 'showing' : 'disabled';
217
+ });
218
+ activeTrack = index;
219
+ },
220
+ async toggleFullscreen(container) {
221
+ if (!media)
222
+ return;
223
+ const doc = media.ownerDocument;
224
+ if (doc.fullscreenElement) {
225
+ await doc.exitFullscreen?.();
226
+ return;
227
+ }
228
+ const target = container ?? media;
229
+ if (typeof target.requestFullscreen === 'function')
230
+ await target.requestFullscreen();
231
+ // iOS Safari only fullscreens the video element itself.
232
+ else
233
+ media.webkitEnterFullscreen?.();
234
+ },
235
+ async togglePictureInPicture() {
236
+ if (!(media instanceof HTMLVideoElement))
237
+ return;
238
+ const doc = media.ownerDocument;
239
+ if (doc.pictureInPictureElement)
240
+ await doc.exitPictureInPicture();
241
+ else
242
+ await media.requestPictureInPicture?.();
243
+ },
244
+ destroy,
245
+ };
246
+ }
@@ -0,0 +1,112 @@
1
+ import { aspectRatio } from '@loidolt/theme-tokens';
2
+ /** One source for a `<video>` or `<audio>`: the browser plays the first it supports. */
3
+ export interface MediaSourceEntry {
4
+ src: string;
5
+ /** MIME type. Guessed from the extension when missing. */
6
+ type?: string;
7
+ }
8
+ /** Captions, subtitles or chapters for a video. */
9
+ export interface MediaTextTrack {
10
+ src: string;
11
+ kind?: 'captions' | 'subtitles' | 'descriptions' | 'chapters';
12
+ /** BCP 47 language tag, e.g. `en`. */
13
+ srclang: string;
14
+ label: string;
15
+ default?: boolean;
16
+ }
17
+ export type MediaKind = 'image' | 'video' | 'audio' | 'embed';
18
+ export type AspectRatioName = keyof typeof aspectRatio;
19
+ export type EmbedProvider = 'youtube' | 'vimeo';
20
+ interface MediaItemBase {
21
+ /** Stable key. Defaults to the source. */
22
+ id?: string;
23
+ /** Shown under the item in a lightbox or carousel. */
24
+ caption?: string;
25
+ /** A small preview for grids and filmstrips. Images fall back to their own `src`. */
26
+ thumbnail?: string;
27
+ }
28
+ export interface ImageMediaItem extends MediaItemBase {
29
+ kind: 'image';
30
+ src: string;
31
+ /** Required; `''` for a purely decorative image. */
32
+ alt: string;
33
+ srcset?: string;
34
+ /** Link to the full-size file, for a lightbox's download button. */
35
+ download?: string;
36
+ }
37
+ export interface VideoMediaItem extends MediaItemBase {
38
+ kind: 'video';
39
+ src: string | MediaSourceEntry[];
40
+ /** Names the video for assistive tech and in a grid. */
41
+ title: string;
42
+ poster?: string;
43
+ tracks?: MediaTextTrack[];
44
+ }
45
+ export interface AudioMediaItem extends MediaItemBase {
46
+ kind: 'audio';
47
+ src: string | MediaSourceEntry[];
48
+ title: string;
49
+ artist?: string;
50
+ artwork?: string;
51
+ }
52
+ export interface EmbedMediaItem extends MediaItemBase {
53
+ kind: 'embed';
54
+ /** A YouTube or Vimeo page or share URL. */
55
+ url: string;
56
+ title: string;
57
+ poster?: string;
58
+ }
59
+ export type MediaItem = ImageMediaItem | VideoMediaItem | AudioMediaItem | EmbedMediaItem;
60
+ export declare const clamp: (value: number, min: number, max: number) => number;
61
+ /** A named frame (`'video'`) or any number or `'4 / 3'` string, as a width-over-height number. */
62
+ export declare function resolveAspectRatio(ratio: AspectRatioName | number | string): number;
63
+ /** `75` → `1:15`; `3725` → `1:02:05`. */
64
+ export declare function formatDuration(seconds: number): string;
65
+ /** `75` → `1 minute 15 seconds` — for `aria-valuetext`, where `1:15` is read as a ratio. */
66
+ export declare function formatDurationSpoken(seconds: number): string;
67
+ /** The lower-cased extension of a URL's last path segment, ignoring query and hash. */
68
+ export declare function fileExtension(url: string): string;
69
+ /** A MIME type for a file extension, when it is a media type this library knows. */
70
+ export declare function guessMimeType(extension: string): string | undefined;
71
+ /** What a URL most likely points at, from its MIME type, embed host or extension. */
72
+ export declare function inferMediaKind(url: string, mimeType?: string): MediaKind | null;
73
+ /** Whether a source is an HLS playlist (`.m3u8` or an HLS MIME type). */
74
+ export declare function isHlsSource(source: string | MediaSourceEntry): boolean;
75
+ /** Normalises a `src` prop to a list of typed sources. */
76
+ export declare function toMediaSources(src: string | MediaSourceEntry[]): MediaSourceEntry[];
77
+ /** A `TimeRanges` object (e.g. `media.buffered`) as plain `[start, end]` pairs. */
78
+ export declare function timeRangesToArray(ranges: TimeRanges | null | undefined): Array<[number, number]>;
79
+ export interface ParsedEmbed {
80
+ provider: EmbedProvider;
81
+ videoId: string;
82
+ /** Start offset in seconds, from a `t=` / `start=` parameter or a `#t=` fragment. */
83
+ start?: number;
84
+ }
85
+ /**
86
+ * Reads a YouTube or Vimeo page, share or embed URL. Returns `null` for anything else, so an
87
+ * arbitrary string can be tested safely.
88
+ */
89
+ export declare function parseEmbedUrl(url: string): ParsedEmbed | null;
90
+ export interface EmbedSrcOptions {
91
+ /**
92
+ * Use the provider's no-tracking mode — `youtube-nocookie.com`, Vimeo's `dnt=1`. Defaults to
93
+ * `true`.
94
+ */
95
+ privacy?: boolean;
96
+ autoplay?: boolean;
97
+ start?: number;
98
+ }
99
+ /** The iframe `src` for a provider's player. */
100
+ export declare function buildEmbedSrc(provider: EmbedProvider, videoId: string, { privacy, autoplay, start }?: EmbedSrcOptions): string;
101
+ /**
102
+ * YouTube's own still for a video. Loading it contacts Google before the user presses play, so
103
+ * `MediaEmbed` never fetches it by itself — pass it as `poster` only if that is acceptable.
104
+ */
105
+ export declare function embedThumbnail(provider: EmbedProvider, videoId: string): string | null;
106
+ /** The preview image for a media item, if it has one. */
107
+ export declare function mediaThumbnail(item: MediaItem): string | undefined;
108
+ /** A human name for a media item: its title, alt text or caption. */
109
+ export declare function mediaLabel(item: MediaItem, index?: number): string;
110
+ /** A stable key for a media item. */
111
+ export declare function mediaKey(item: MediaItem, index: number): string;
112
+ export {};