@classic-homes/theme-svelte 1.2.2 → 1.3.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/README.md +43 -0
- package/dist/__tests__/a11y/Media.a11y.test.js +145 -0
- package/dist/__tests__/media-components.test.js +206 -0
- package/dist/__tests__/media-hls.test.js +273 -0
- package/dist/__tests__/media-matterport.test.js +296 -0
- package/dist/__tests__/media-players.test.js +244 -0
- package/dist/__tests__/media-scrubber.test.js +44 -0
- package/dist/__tests__/media-utils.test.js +427 -0
- package/dist/__tests__/setup.js +50 -0
- package/dist/lib/components/DataPanel.svelte +8 -0
- package/dist/lib/components/DataPanel.svelte.d.ts +6 -0
- package/dist/lib/components/DataPanelTab.svelte +38 -8
- package/dist/lib/components/DataPanelTab.svelte.d.ts +7 -0
- package/dist/lib/components/FileUpload.svelte +221 -4
- package/dist/lib/components/FileUpload.svelte.d.ts +13 -1
- package/dist/lib/components/media/AspectRatio.svelte +47 -0
- package/dist/lib/components/media/AspectRatio.svelte.d.ts +20 -0
- package/dist/lib/components/media/Audio.svelte +247 -0
- package/dist/lib/components/media/Audio.svelte.d.ts +34 -0
- package/dist/lib/components/media/Image.svelte +209 -0
- package/dist/lib/components/media/Image.svelte.d.ts +52 -0
- package/dist/lib/components/media/Lightbox.svelte +449 -0
- package/dist/lib/components/media/Lightbox.svelte.d.ts +33 -0
- package/dist/lib/components/media/MediaCarousel.svelte +359 -0
- package/dist/lib/components/media/MediaCarousel.svelte.d.ts +30 -0
- package/dist/lib/components/media/MediaEmbed.svelte +314 -0
- package/dist/lib/components/media/MediaEmbed.svelte.d.ts +99 -0
- package/dist/lib/components/media/MediaGrid.svelte +327 -0
- package/dist/lib/components/media/MediaGrid.svelte.d.ts +57 -0
- package/dist/lib/components/media/Video.svelte +455 -0
- package/dist/lib/components/media/Video.svelte.d.ts +81 -0
- package/dist/lib/components/media/index.d.ts +14 -0
- package/dist/lib/components/media/index.js +14 -0
- package/dist/lib/components/media/internal/MediaControls.svelte +283 -0
- package/dist/lib/components/media/internal/MediaControls.svelte.d.ts +32 -0
- package/dist/lib/components/media/internal/MediaScrubber.svelte +211 -0
- package/dist/lib/components/media/internal/MediaScrubber.svelte.d.ts +27 -0
- package/dist/lib/components/media/internal/MediaTile.svelte +129 -0
- package/dist/lib/components/media/internal/MediaTile.svelte.d.ts +22 -0
- package/dist/lib/composables/index.d.ts +3 -0
- package/dist/lib/composables/index.js +4 -0
- package/dist/lib/composables/useHlsSource.svelte.d.ts +70 -0
- package/dist/lib/composables/useHlsSource.svelte.js +119 -0
- package/dist/lib/composables/useMediaPlayer.svelte.d.ts +114 -0
- package/dist/lib/composables/useMediaPlayer.svelte.js +422 -0
- package/dist/lib/composables/useMediaZoom.svelte.d.ts +59 -0
- package/dist/lib/composables/useMediaZoom.svelte.js +199 -0
- package/dist/lib/index.d.ts +8 -2
- package/dist/lib/index.js +14 -1
- package/dist/lib/types/components.d.ts +172 -0
- package/dist/lib/utils/hls.d.ts +76 -0
- package/dist/lib/utils/hls.js +83 -0
- package/dist/lib/utils/media.d.ts +379 -0
- package/dist/lib/utils/media.js +715 -0
- package/package.json +6 -2
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { MediaAspectRatio, MediaItem } from '../../../types/components.js';
|
|
2
|
+
interface Props {
|
|
3
|
+
/** Item to represent */
|
|
4
|
+
item: MediaItem;
|
|
5
|
+
/** Zero-based position, used for the fallback accessible name */
|
|
6
|
+
index: number;
|
|
7
|
+
/** Aspect ratio of the tile. Omit to let the thumbnail set its own height. */
|
|
8
|
+
ratio?: MediaAspectRatio | number;
|
|
9
|
+
/** Marks the tile as the current item (carousel dots, thumbnail strips) */
|
|
10
|
+
selected?: boolean;
|
|
11
|
+
/** Participates in a roving tabindex container */
|
|
12
|
+
roving?: boolean;
|
|
13
|
+
/** Click and keyboard activation handler */
|
|
14
|
+
onselect?: (index: number) => void;
|
|
15
|
+
/** Additional classes */
|
|
16
|
+
class?: string;
|
|
17
|
+
/** Any other attribute is forwarded to the button (tabindex, data-*, …) */
|
|
18
|
+
[key: string]: unknown;
|
|
19
|
+
}
|
|
20
|
+
declare const MediaTile: import("svelte").Component<Props, {}, "">;
|
|
21
|
+
type MediaTile = ReturnType<typeof MediaTile>;
|
|
22
|
+
export default MediaTile;
|
|
@@ -32,3 +32,6 @@ export { useRovingTabindex, type UseRovingTabindexOptions } from './useRovingTab
|
|
|
32
32
|
export { useAnnounce, type UseAnnounceOptions, type UseAnnounceReturn, } from './useAnnounce.svelte.js';
|
|
33
33
|
export { useEscapeKey, type UseEscapeKeyOptions } from './useEscapeKey.svelte.js';
|
|
34
34
|
export { useClickOutside, clickOutsideAction, type UseClickOutsideOptions, } from './useClickOutside.svelte.js';
|
|
35
|
+
export { useMediaPlayer, type UseMediaPlayerOptions, type MediaPlayerApi, type MediaTimeRange, type MediaTrackInfo, } from './useMediaPlayer.svelte.js';
|
|
36
|
+
export { useMediaZoom, type UseMediaZoomOptions, type MediaZoomApi, } from './useMediaZoom.svelte.js';
|
|
37
|
+
export { useHlsSource, type UseHlsSourceOptions, type HlsSourceApi, type HlsStatus, } from './useHlsSource.svelte.js';
|
|
@@ -35,3 +35,7 @@ export { useRovingTabindex } from './useRovingTabindex.svelte.js';
|
|
|
35
35
|
export { useAnnounce, } from './useAnnounce.svelte.js';
|
|
36
36
|
export { useEscapeKey } from './useEscapeKey.svelte.js';
|
|
37
37
|
export { useClickOutside, clickOutsideAction, } from './useClickOutside.svelte.js';
|
|
38
|
+
// Media composables
|
|
39
|
+
export { useMediaPlayer, } from './useMediaPlayer.svelte.js';
|
|
40
|
+
export { useMediaZoom, } from './useMediaZoom.svelte.js';
|
|
41
|
+
export { useHlsSource, } from './useHlsSource.svelte.js';
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Composable that plays an HLS stream through a media element.
|
|
3
|
+
*
|
|
4
|
+
* Safari and iOS play HLS natively, in which case the playlist is simply set
|
|
5
|
+
* as the element's source. Everywhere else this attaches `hls.js`, which is an
|
|
6
|
+
* optional peer dependency — see `utils/hls.ts` for how to provide it.
|
|
7
|
+
*
|
|
8
|
+
* Used by `Video` for Cloudflare Stream and any other HLS source. Exported so
|
|
9
|
+
* applications can build their own players on the same behaviour.
|
|
10
|
+
*
|
|
11
|
+
* @example
|
|
12
|
+
* ```svelte
|
|
13
|
+
* <script lang="ts">
|
|
14
|
+
* import { useHlsSource } from '@classic-homes/theme-svelte';
|
|
15
|
+
*
|
|
16
|
+
* let el = $state<HTMLVideoElement | null>(null);
|
|
17
|
+
* const hls = useHlsSource(() => el, { src: () => streamUrl });
|
|
18
|
+
* </script>
|
|
19
|
+
*
|
|
20
|
+
* <video bind:this={el}></video>
|
|
21
|
+
* {#if hls.status === 'unsupported'}
|
|
22
|
+
* <p>This browser needs hls.js to play adaptive streams.</p>
|
|
23
|
+
* {/if}
|
|
24
|
+
* ```
|
|
25
|
+
*/
|
|
26
|
+
import { type HlsLoader } from '../utils/hls.js';
|
|
27
|
+
/** How the stream is currently being played. */
|
|
28
|
+
export type HlsStatus = 'idle' | 'loading' | 'native' | 'attached'
|
|
29
|
+
/** hls.js is needed but was neither registered nor supported */
|
|
30
|
+
| 'unsupported' | 'error';
|
|
31
|
+
/** Options accepted by {@link useHlsSource}. */
|
|
32
|
+
export interface UseHlsSourceOptions {
|
|
33
|
+
/**
|
|
34
|
+
* The HLS playlist to play, or `null` when the current source is not HLS.
|
|
35
|
+
* A getter, so the stream can change without re-creating the composable.
|
|
36
|
+
*/
|
|
37
|
+
src: () => string | null;
|
|
38
|
+
/**
|
|
39
|
+
* Per-instance `hls.js` loader, overriding any globally registered one.
|
|
40
|
+
* A getter, matching `src`, so a prop-supplied loader stays reactive.
|
|
41
|
+
*/
|
|
42
|
+
loader?: () => HlsLoader | undefined;
|
|
43
|
+
/** Extra hls.js configuration */
|
|
44
|
+
config?: () => Record<string, unknown> | undefined;
|
|
45
|
+
/**
|
|
46
|
+
* Whether to start downloading as soon as the stream is attached
|
|
47
|
+
* (default `true`).
|
|
48
|
+
*
|
|
49
|
+
* hls.js fetches through its own loader, so it ignores the media element's
|
|
50
|
+
* `preload` attribute. Pass `false` to mirror `preload="none"` and hold off
|
|
51
|
+
* until {@link HlsSourceApi.startLoad} is called.
|
|
52
|
+
*/
|
|
53
|
+
autoStartLoad?: () => boolean;
|
|
54
|
+
/** Called when the stream cannot be played */
|
|
55
|
+
onError?: (error: Error) => void;
|
|
56
|
+
}
|
|
57
|
+
/** Reactive state returned by {@link useHlsSource}. */
|
|
58
|
+
export interface HlsSourceApi {
|
|
59
|
+
readonly status: HlsStatus;
|
|
60
|
+
/** True when the browser is playing HLS without hls.js */
|
|
61
|
+
readonly native: boolean;
|
|
62
|
+
/** Last fatal error, if any */
|
|
63
|
+
readonly error: Error | null;
|
|
64
|
+
/**
|
|
65
|
+
* Begin downloading when `autoStartLoad` was disabled. Safe to call more
|
|
66
|
+
* than once, and a no-op on the native path.
|
|
67
|
+
*/
|
|
68
|
+
startLoad(): void;
|
|
69
|
+
}
|
|
70
|
+
export declare function useHlsSource(getElement: () => HTMLMediaElement | null | undefined, options: UseHlsSourceOptions): HlsSourceApi;
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Composable that plays an HLS stream through a media element.
|
|
3
|
+
*
|
|
4
|
+
* Safari and iOS play HLS natively, in which case the playlist is simply set
|
|
5
|
+
* as the element's source. Everywhere else this attaches `hls.js`, which is an
|
|
6
|
+
* optional peer dependency — see `utils/hls.ts` for how to provide it.
|
|
7
|
+
*
|
|
8
|
+
* Used by `Video` for Cloudflare Stream and any other HLS source. Exported so
|
|
9
|
+
* applications can build their own players on the same behaviour.
|
|
10
|
+
*
|
|
11
|
+
* @example
|
|
12
|
+
* ```svelte
|
|
13
|
+
* <script lang="ts">
|
|
14
|
+
* import { useHlsSource } from '@classic-homes/theme-svelte';
|
|
15
|
+
*
|
|
16
|
+
* let el = $state<HTMLVideoElement | null>(null);
|
|
17
|
+
* const hls = useHlsSource(() => el, { src: () => streamUrl });
|
|
18
|
+
* </script>
|
|
19
|
+
*
|
|
20
|
+
* <video bind:this={el}></video>
|
|
21
|
+
* {#if hls.status === 'unsupported'}
|
|
22
|
+
* <p>This browser needs hls.js to play adaptive streams.</p>
|
|
23
|
+
* {/if}
|
|
24
|
+
* ```
|
|
25
|
+
*/
|
|
26
|
+
import { canPlayHlsNatively, loadHls, } from '../utils/hls.js';
|
|
27
|
+
export function useHlsSource(getElement, options) {
|
|
28
|
+
let status = $state('idle');
|
|
29
|
+
let error = $state(null);
|
|
30
|
+
// Held outside the effect so startLoad() can reach the live instance, and so
|
|
31
|
+
// a call that arrives while hls.js is still loading is not dropped.
|
|
32
|
+
let current = null;
|
|
33
|
+
let loadRequested = false;
|
|
34
|
+
$effect(() => {
|
|
35
|
+
const media = getElement();
|
|
36
|
+
const src = options.src();
|
|
37
|
+
if (!media || !src) {
|
|
38
|
+
status = 'idle';
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
error = null;
|
|
42
|
+
// Native playback needs no library; hand the playlist straight over.
|
|
43
|
+
if (canPlayHlsNatively(media)) {
|
|
44
|
+
media.src = src;
|
|
45
|
+
status = 'native';
|
|
46
|
+
return () => {
|
|
47
|
+
media.removeAttribute('src');
|
|
48
|
+
media.load();
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
status = 'loading';
|
|
52
|
+
// The effect cannot await, so track cancellation explicitly: the source
|
|
53
|
+
// may change or the component unmount while hls.js is still loading.
|
|
54
|
+
let cancelled = false;
|
|
55
|
+
let instance = null;
|
|
56
|
+
const autoStartLoad = options.autoStartLoad?.() ?? true;
|
|
57
|
+
const config = { autoStartLoad, ...options.config?.() };
|
|
58
|
+
loadRequested = autoStartLoad;
|
|
59
|
+
loadHls(options.loader?.()).then((Hls) => {
|
|
60
|
+
if (cancelled)
|
|
61
|
+
return;
|
|
62
|
+
if (!Hls || !Hls.isSupported()) {
|
|
63
|
+
status = 'unsupported';
|
|
64
|
+
const failure = new Error('Playing an HLS stream in this browser requires hls.js. Install it and register a loader: setHlsLoader(() => import("hls.js").then((m) => m.default)).');
|
|
65
|
+
error = failure;
|
|
66
|
+
options.onError?.(failure);
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
instance = new Hls(config);
|
|
70
|
+
instance.on(Hls.Events.ERROR, (_event, data) => {
|
|
71
|
+
if (!data?.fatal)
|
|
72
|
+
return;
|
|
73
|
+
// hls.js can recover from network and media errors; only a fatal
|
|
74
|
+
// error of another type is truly terminal.
|
|
75
|
+
if (data.type === Hls.ErrorTypes.NETWORK_ERROR) {
|
|
76
|
+
instance?.startLoad();
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
if (data.type === Hls.ErrorTypes.MEDIA_ERROR) {
|
|
80
|
+
instance?.recoverMediaError();
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
status = 'error';
|
|
84
|
+
const failure = new Error(`HLS playback failed: ${data.details ?? 'unknown error'}`);
|
|
85
|
+
error = failure;
|
|
86
|
+
options.onError?.(failure);
|
|
87
|
+
});
|
|
88
|
+
instance.loadSource(src);
|
|
89
|
+
instance.attachMedia(media);
|
|
90
|
+
current = instance;
|
|
91
|
+
status = 'attached';
|
|
92
|
+
// A play request that arrived while hls.js was still loading.
|
|
93
|
+
if (loadRequested && !autoStartLoad)
|
|
94
|
+
instance.startLoad();
|
|
95
|
+
});
|
|
96
|
+
return () => {
|
|
97
|
+
cancelled = true;
|
|
98
|
+
instance?.destroy();
|
|
99
|
+
instance = null;
|
|
100
|
+
current = null;
|
|
101
|
+
};
|
|
102
|
+
});
|
|
103
|
+
function startLoad() {
|
|
104
|
+
loadRequested = true;
|
|
105
|
+
current?.startLoad();
|
|
106
|
+
}
|
|
107
|
+
return {
|
|
108
|
+
get status() {
|
|
109
|
+
return status;
|
|
110
|
+
},
|
|
111
|
+
get native() {
|
|
112
|
+
return status === 'native';
|
|
113
|
+
},
|
|
114
|
+
get error() {
|
|
115
|
+
return error;
|
|
116
|
+
},
|
|
117
|
+
startLoad,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Composable that mirrors an `HTMLMediaElement`'s state into runes and exposes
|
|
3
|
+
* imperative playback commands.
|
|
4
|
+
*
|
|
5
|
+
* Drives both `Video` and `Audio`, and is exported so applications can build
|
|
6
|
+
* their own player chrome on top of the same behaviour.
|
|
7
|
+
*
|
|
8
|
+
* The element is passed as a getter rather than a value, because the element
|
|
9
|
+
* does not exist on the first call — `bind:this` populates it after mount.
|
|
10
|
+
* Reading it inside the effect keeps the subscription reactive.
|
|
11
|
+
*
|
|
12
|
+
* @example
|
|
13
|
+
* ```svelte
|
|
14
|
+
* <script lang="ts">
|
|
15
|
+
* import { useMediaPlayer } from '@classic-homes/theme-svelte';
|
|
16
|
+
*
|
|
17
|
+
* let el = $state<HTMLVideoElement | null>(null);
|
|
18
|
+
* const player = useMediaPlayer(() => el);
|
|
19
|
+
* </script>
|
|
20
|
+
*
|
|
21
|
+
* <video bind:this={el} src="/clip.mp4"></video>
|
|
22
|
+
* <button onclick={() => player.toggle()}>
|
|
23
|
+
* {player.playing ? 'Pause' : 'Play'}
|
|
24
|
+
* </button>
|
|
25
|
+
* ```
|
|
26
|
+
*/
|
|
27
|
+
/** A buffered or seekable range, in seconds. */
|
|
28
|
+
export interface MediaTimeRange {
|
|
29
|
+
start: number;
|
|
30
|
+
end: number;
|
|
31
|
+
}
|
|
32
|
+
/** A text track exposed to the captions menu. */
|
|
33
|
+
export interface MediaTrackInfo {
|
|
34
|
+
index: number;
|
|
35
|
+
label: string;
|
|
36
|
+
language: string;
|
|
37
|
+
kind: string;
|
|
38
|
+
active: boolean;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Options accepted by {@link useMediaPlayer}.
|
|
42
|
+
*
|
|
43
|
+
* `volume` and `muted` are getters so they stay reactive — a plain value would
|
|
44
|
+
* be captured once when the composable is called and never follow the prop.
|
|
45
|
+
*/
|
|
46
|
+
export interface UseMediaPlayerOptions {
|
|
47
|
+
/** Volume, 0–1 (default `1`) */
|
|
48
|
+
volume?: () => number;
|
|
49
|
+
/** Whether the media is muted (default `false`) */
|
|
50
|
+
muted?: () => boolean;
|
|
51
|
+
/** Called when playback reaches the end */
|
|
52
|
+
onEnded?: () => void;
|
|
53
|
+
/** Called when the element reports an error */
|
|
54
|
+
onError?: (error: MediaError | null) => void;
|
|
55
|
+
/** Called on every `timeupdate` with the current time in seconds */
|
|
56
|
+
onTimeUpdate?: (currentTime: number) => void;
|
|
57
|
+
}
|
|
58
|
+
/** Reactive state and commands returned by {@link useMediaPlayer}. */
|
|
59
|
+
export interface MediaPlayerApi {
|
|
60
|
+
readonly element: HTMLMediaElement | null;
|
|
61
|
+
readonly playing: boolean;
|
|
62
|
+
readonly paused: boolean;
|
|
63
|
+
readonly ended: boolean;
|
|
64
|
+
readonly currentTime: number;
|
|
65
|
+
readonly duration: number;
|
|
66
|
+
/** Playback progress as a fraction, 0–1 */
|
|
67
|
+
readonly progress: number;
|
|
68
|
+
readonly buffered: MediaTimeRange[];
|
|
69
|
+
/** How much of the media is buffered ahead of the playhead, as a fraction 0–1 */
|
|
70
|
+
readonly bufferedProgress: number;
|
|
71
|
+
readonly volume: number;
|
|
72
|
+
readonly muted: boolean;
|
|
73
|
+
readonly playbackRate: number;
|
|
74
|
+
/**
|
|
75
|
+
* True while the element has reported that it is stalled.
|
|
76
|
+
*
|
|
77
|
+
* Prefer {@link MediaPlayerApi.buffering} for showing a spinner: browsers
|
|
78
|
+
* emit `waiting` on paused elements too, where it means nothing to the
|
|
79
|
+
* viewer.
|
|
80
|
+
*/
|
|
81
|
+
readonly waiting: boolean;
|
|
82
|
+
/**
|
|
83
|
+
* True only while playback is actually held up waiting for data.
|
|
84
|
+
*
|
|
85
|
+
* A paused player is never buffering, however many `waiting` events the
|
|
86
|
+
* browser emits.
|
|
87
|
+
*/
|
|
88
|
+
readonly buffering: boolean;
|
|
89
|
+
/** True until metadata (and therefore duration) is known */
|
|
90
|
+
readonly loading: boolean;
|
|
91
|
+
readonly error: MediaError | null;
|
|
92
|
+
readonly fullscreen: boolean;
|
|
93
|
+
readonly pip: boolean;
|
|
94
|
+
readonly textTracks: MediaTrackInfo[];
|
|
95
|
+
/** Index of the showing text track, or `null` when captions are off */
|
|
96
|
+
readonly activeTextTrack: number | null;
|
|
97
|
+
play(): Promise<void>;
|
|
98
|
+
pause(): void;
|
|
99
|
+
toggle(): Promise<void>;
|
|
100
|
+
seek(seconds: number): void;
|
|
101
|
+
seekBy(delta: number): void;
|
|
102
|
+
/** Seek to a fraction of the duration, 0–1 */
|
|
103
|
+
seekToFraction(fraction: number): void;
|
|
104
|
+
setVolume(volume: number): void;
|
|
105
|
+
adjustVolume(delta: number): void;
|
|
106
|
+
toggleMute(): void;
|
|
107
|
+
setPlaybackRate(rate: number): void;
|
|
108
|
+
toggleFullscreen(container?: HTMLElement | null): Promise<void>;
|
|
109
|
+
togglePictureInPicture(): Promise<void>;
|
|
110
|
+
/** Show one text track, or pass `null` to turn captions off */
|
|
111
|
+
setTextTrack(index: number | null): void;
|
|
112
|
+
toggleCaptions(): void;
|
|
113
|
+
}
|
|
114
|
+
export declare function useMediaPlayer(getElement: () => HTMLMediaElement | null | undefined, options?: UseMediaPlayerOptions): MediaPlayerApi;
|