@sveltesentio/media 0.1.0 → 0.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/CHANGELOG.md +21 -0
- package/README.md +53 -4
- package/package.json +59 -9
- package/src/Carousel.svelte +128 -0
- package/src/Image.svelte +130 -0
- package/src/Player.svelte +182 -0
- package/src/carousel.ts +83 -0
- package/src/image.ts +160 -0
- package/src/index.ts +68 -2
- package/src/lqip.ts +82 -0
- package/src/player-controls.ts +124 -0
- package/src/player.ts +255 -0
package/src/carousel.ts
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Headless carousel helpers shared by the `<Carousel>` shell and any consumer
|
|
3
|
+
* driving `embla-carousel-svelte` directly: a preset-aware embla options
|
|
4
|
+
* builder that bakes in the two obligations ADR-0012 leaves to the caller —
|
|
5
|
+
* reduced-motion (collapse transition duration to 0) and target-size
|
|
6
|
+
* (WCAG 2.5.8 nav-button sizing per interface preset). Pure and DOM-free.
|
|
7
|
+
*
|
|
8
|
+
* @see ADR-0012 (embla via shadcn) — the three consumer obligations.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** Interface-type preset governing nav-button target size (ADR-0047). */
|
|
12
|
+
export type CarouselPreset = 'desktop' | 'handheld' | 'tv';
|
|
13
|
+
|
|
14
|
+
/** Carousel scroll axis. */
|
|
15
|
+
export type CarouselOrientation = 'horizontal' | 'vertical';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The subset of `embla-carousel`'s options this helper sets. Structural so the
|
|
19
|
+
* package needs no `embla-carousel-svelte` dependency (it is an optional peer);
|
|
20
|
+
* the result spreads onto embla's own options object.
|
|
21
|
+
*/
|
|
22
|
+
export interface EmblaOptionsLike {
|
|
23
|
+
readonly loop: boolean;
|
|
24
|
+
readonly align: 'start' | 'center' | 'end';
|
|
25
|
+
readonly axis: 'x' | 'y';
|
|
26
|
+
readonly duration: number;
|
|
27
|
+
readonly dragFree: boolean;
|
|
28
|
+
/**
|
|
29
|
+
* Per-media-query option overrides. The reduced-motion query collapses the
|
|
30
|
+
* transition duration so SC 2.3.3 / 2.2.2 are honoured without JS.
|
|
31
|
+
*/
|
|
32
|
+
readonly breakpoints: Readonly<Record<string, { readonly duration: number }>>;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface CarouselOptionsInput {
|
|
36
|
+
/** Wrap from last slide to first. Default `false`. */
|
|
37
|
+
readonly loop?: boolean;
|
|
38
|
+
/** Slide alignment within the viewport. Default `'start'`. */
|
|
39
|
+
readonly align?: 'start' | 'center' | 'end';
|
|
40
|
+
/** Scroll axis. Default `'horizontal'`. */
|
|
41
|
+
readonly orientation?: CarouselOrientation;
|
|
42
|
+
/** Embla transition duration (embla time units). Default `25`. */
|
|
43
|
+
readonly duration?: number;
|
|
44
|
+
/** Allow free-scroll momentum dragging. Default `false`. */
|
|
45
|
+
readonly dragFree?: boolean;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const REDUCED_MOTION_QUERY = '(prefers-reduced-motion: reduce)';
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Build embla options with the reduced-motion breakpoint always present, so a
|
|
52
|
+
* consumer who forgets obligation (a) from ADR-0012 still gets a
|
|
53
|
+
* reduced-motion-correct carousel. The breakpoint sets `duration: 0` (instant
|
|
54
|
+
* snap) under `prefers-reduced-motion: reduce`.
|
|
55
|
+
*/
|
|
56
|
+
export function buildCarouselOptions(input: CarouselOptionsInput = {}): EmblaOptionsLike {
|
|
57
|
+
return {
|
|
58
|
+
loop: input.loop ?? false,
|
|
59
|
+
align: input.align ?? 'start',
|
|
60
|
+
axis: (input.orientation ?? 'horizontal') === 'vertical' ? 'y' : 'x',
|
|
61
|
+
duration: input.duration ?? 25,
|
|
62
|
+
dragFree: input.dragFree ?? false,
|
|
63
|
+
breakpoints: {
|
|
64
|
+
[REDUCED_MOTION_QUERY]: { duration: 0 },
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Minimum CSS px target size for the carousel's prev/next buttons per preset.
|
|
71
|
+
* `handheld` / `tv` upgrade to 44 px (WCAG 2.5.8 enhanced / touch + 10-foot
|
|
72
|
+
* reach); `desktop` keeps the shadcn `size="icon"` 32 px default — above the
|
|
73
|
+
* 24 px AA minimum (SC 2.5.8).
|
|
74
|
+
*/
|
|
75
|
+
export function navButtonTargetPx(preset: CarouselPreset): number {
|
|
76
|
+
return preset === 'desktop' ? 32 : 44;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Whether the user has requested reduced motion. SSR-safe (`false` server-side). */
|
|
80
|
+
export function carouselPrefersReducedMotion(): boolean {
|
|
81
|
+
if (typeof globalThis.matchMedia !== 'function') return false;
|
|
82
|
+
return globalThis.matchMedia(REDUCED_MOTION_QUERY).matches;
|
|
83
|
+
}
|
package/src/image.ts
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure responsive-image helpers: `srcset` / `sizes` string builders plus a
|
|
3
|
+
* width-descriptor candidate model. Framework-agnostic — no DOM, no Svelte —
|
|
4
|
+
* so the same logic drives a `<picture>`, an `<img srcset>`, or a server-side
|
|
5
|
+
* `Link: rel=preload` header.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** A `src`-templating function: maps a target pixel width to a URL. */
|
|
9
|
+
export type SrcWidthTemplate = (width: number) => string;
|
|
10
|
+
|
|
11
|
+
/** One `srcset` candidate: a URL paired with its intrinsic pixel width. */
|
|
12
|
+
export interface SrcSetCandidate {
|
|
13
|
+
readonly url: string;
|
|
14
|
+
readonly width: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface SrcSetOptions {
|
|
18
|
+
/**
|
|
19
|
+
* How to derive each candidate URL from a width. Either a template function
|
|
20
|
+
* or a token-substitution string containing `{w}` (replaced with the width).
|
|
21
|
+
* When omitted, `?w=<width>` is appended to `src` (or merged into an
|
|
22
|
+
* existing query string).
|
|
23
|
+
*/
|
|
24
|
+
readonly template?: SrcWidthTemplate | string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const WIDTH_TOKEN = /\{w\}/g;
|
|
28
|
+
|
|
29
|
+
/** De-duplicate, drop non-positive/non-finite entries, and sort ascending. */
|
|
30
|
+
function normaliseWidths(widths: readonly number[]): number[] {
|
|
31
|
+
const seen = new Set<number>();
|
|
32
|
+
for (const w of widths) {
|
|
33
|
+
if (Number.isFinite(w) && w > 0) seen.add(Math.round(w));
|
|
34
|
+
}
|
|
35
|
+
return [...seen].sort((a, b) => a - b);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Append/merge a `w` query param onto a URL without a URL parser dependency. */
|
|
39
|
+
function appendWidthQuery(src: string, width: number): string {
|
|
40
|
+
const [base, hash = ''] = src.split('#', 2);
|
|
41
|
+
const safeBase = base ?? src;
|
|
42
|
+
const sep = safeBase.includes('?') ? '&' : '?';
|
|
43
|
+
const suffix = hash ? `#${hash}` : '';
|
|
44
|
+
return `${safeBase}${sep}w=${width}${suffix}`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function resolveTemplate(
|
|
48
|
+
src: string,
|
|
49
|
+
template: SrcWidthTemplate | string | undefined,
|
|
50
|
+
): SrcWidthTemplate {
|
|
51
|
+
if (typeof template === 'function') return template;
|
|
52
|
+
if (typeof template === 'string') {
|
|
53
|
+
return (width) => template.replace(WIDTH_TOKEN, String(width));
|
|
54
|
+
}
|
|
55
|
+
return (width) => appendWidthQuery(src, width);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Build the structured candidate list backing a `srcset`. Widths are
|
|
60
|
+
* normalised (de-duped, positive, integer, ascending) so callers can pass a
|
|
61
|
+
* raw breakpoint list in any order.
|
|
62
|
+
*/
|
|
63
|
+
export function buildSrcSetCandidates(
|
|
64
|
+
src: string,
|
|
65
|
+
widths: readonly number[],
|
|
66
|
+
options: SrcSetOptions = {},
|
|
67
|
+
): SrcSetCandidate[] {
|
|
68
|
+
const resolved = resolveTemplate(src, options.template);
|
|
69
|
+
return normaliseWidths(widths).map((width) => ({
|
|
70
|
+
url: resolved(width),
|
|
71
|
+
width,
|
|
72
|
+
}));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Build a `srcset` attribute string with `w` width descriptors, e.g.
|
|
77
|
+
* `"/img?w=320 320w, /img?w=640 640w"`. Returns `""` for an empty width list.
|
|
78
|
+
*/
|
|
79
|
+
export function buildSrcSet(
|
|
80
|
+
src: string,
|
|
81
|
+
widths: readonly number[],
|
|
82
|
+
options: SrcSetOptions = {},
|
|
83
|
+
): string {
|
|
84
|
+
return buildSrcSetCandidates(src, widths, options)
|
|
85
|
+
.map((c) => `${c.url} ${c.width}w`)
|
|
86
|
+
.join(', ');
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** One `sizes` media-condition / length pair. */
|
|
90
|
+
export interface SizesRule {
|
|
91
|
+
/** A media condition, e.g. `"(min-width: 768px)"`. */
|
|
92
|
+
readonly condition: string;
|
|
93
|
+
/** A CSS length, e.g. `"50vw"` or `"600px"`. */
|
|
94
|
+
readonly size: string;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export interface BuildSizesOptions {
|
|
98
|
+
/**
|
|
99
|
+
* The trailing default length applied when no condition matches. Defaults to
|
|
100
|
+
* `"100vw"` — the responsive-image-correct fallback for a fluid layout.
|
|
101
|
+
*/
|
|
102
|
+
readonly fallback?: string;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Build a `sizes` attribute string from ordered conditional rules plus a
|
|
107
|
+
* fallback length, e.g. `"(min-width: 768px) 50vw, 100vw"`. Rules are emitted
|
|
108
|
+
* in array order — the browser uses the first matching condition.
|
|
109
|
+
*/
|
|
110
|
+
export function buildSizes(
|
|
111
|
+
rules: readonly SizesRule[],
|
|
112
|
+
options: BuildSizesOptions = {},
|
|
113
|
+
): string {
|
|
114
|
+
const fallback = options.fallback ?? '100vw';
|
|
115
|
+
const conditional = rules.map((r) => `${r.condition} ${r.size}`);
|
|
116
|
+
return [...conditional, fallback].join(', ');
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export interface ResponsiveImageAttrs {
|
|
120
|
+
readonly src: string;
|
|
121
|
+
readonly srcset: string;
|
|
122
|
+
readonly sizes: string;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export interface BuildResponsiveImageOptions extends SrcSetOptions {
|
|
126
|
+
readonly sizes?: readonly SizesRule[];
|
|
127
|
+
readonly sizesFallback?: string;
|
|
128
|
+
/**
|
|
129
|
+
* Which width to use for the legacy `src` fallback attribute. Defaults to
|
|
130
|
+
* the largest provided width so non-`srcset` browsers get full quality.
|
|
131
|
+
*/
|
|
132
|
+
readonly fallbackWidth?: number;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Compose `{ src, srcset, sizes }` ready to spread onto an `<img>`. The `src`
|
|
137
|
+
* fallback targets `fallbackWidth` (default: largest width) so legacy browsers
|
|
138
|
+
* without `srcset` support still receive a sensibly-sized asset.
|
|
139
|
+
*/
|
|
140
|
+
export function buildResponsiveImage(
|
|
141
|
+
src: string,
|
|
142
|
+
widths: readonly number[],
|
|
143
|
+
options: BuildResponsiveImageOptions = {},
|
|
144
|
+
): ResponsiveImageAttrs {
|
|
145
|
+
const candidates = buildSrcSetCandidates(src, widths, options);
|
|
146
|
+
const fallbackWidth =
|
|
147
|
+
options.fallbackWidth ?? candidates.at(-1)?.width;
|
|
148
|
+
const resolved = resolveTemplate(src, options.template);
|
|
149
|
+
const fallbackSrc =
|
|
150
|
+
fallbackWidth === undefined ? src : resolved(fallbackWidth);
|
|
151
|
+
return {
|
|
152
|
+
src: fallbackSrc,
|
|
153
|
+
srcset: candidates.map((c) => `${c.url} ${c.width}w`).join(', '),
|
|
154
|
+
sizes: buildSizes(options.sizes ?? [], {
|
|
155
|
+
...(options.sizesFallback === undefined
|
|
156
|
+
? {}
|
|
157
|
+
: { fallback: options.sizesFallback }),
|
|
158
|
+
}),
|
|
159
|
+
};
|
|
160
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,2 +1,68 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
export type {
|
|
2
|
+
HlsRendition,
|
|
3
|
+
PickRenditionOptions,
|
|
4
|
+
MediaSessionArtwork,
|
|
5
|
+
MediaSessionMetadataInit,
|
|
6
|
+
MediaSessionMetadata,
|
|
7
|
+
PlaybackStatus,
|
|
8
|
+
PlaybackEvent,
|
|
9
|
+
PlaybackState,
|
|
10
|
+
HlsLike,
|
|
11
|
+
HlsConstructorLike,
|
|
12
|
+
HlsAttachmentOptions,
|
|
13
|
+
HlsAttachment,
|
|
14
|
+
} from './player.js';
|
|
15
|
+
export {
|
|
16
|
+
pickRendition,
|
|
17
|
+
buildMediaSessionMetadata,
|
|
18
|
+
initialPlaybackState,
|
|
19
|
+
playbackReducer,
|
|
20
|
+
createHlsAttachment,
|
|
21
|
+
} from './player.js';
|
|
22
|
+
|
|
23
|
+
export type {
|
|
24
|
+
SrcWidthTemplate,
|
|
25
|
+
SrcSetCandidate,
|
|
26
|
+
SrcSetOptions,
|
|
27
|
+
SizesRule,
|
|
28
|
+
BuildSizesOptions,
|
|
29
|
+
ResponsiveImageAttrs,
|
|
30
|
+
BuildResponsiveImageOptions,
|
|
31
|
+
} from './image.js';
|
|
32
|
+
export {
|
|
33
|
+
buildSrcSet,
|
|
34
|
+
buildSrcSetCandidates,
|
|
35
|
+
buildSizes,
|
|
36
|
+
buildResponsiveImage,
|
|
37
|
+
} from './image.js';
|
|
38
|
+
|
|
39
|
+
export type { PlayerAction, MediaTrack } from './player-controls.js';
|
|
40
|
+
export {
|
|
41
|
+
actionForKey,
|
|
42
|
+
assertCaptionsContract,
|
|
43
|
+
formatMediaTime,
|
|
44
|
+
clampVolume,
|
|
45
|
+
} from './player-controls.js';
|
|
46
|
+
|
|
47
|
+
export type {
|
|
48
|
+
CarouselPreset,
|
|
49
|
+
CarouselOrientation,
|
|
50
|
+
EmblaOptionsLike,
|
|
51
|
+
CarouselOptionsInput,
|
|
52
|
+
} from './carousel.js';
|
|
53
|
+
export {
|
|
54
|
+
buildCarouselOptions,
|
|
55
|
+
navButtonTargetPx,
|
|
56
|
+
carouselPrefersReducedMotion,
|
|
57
|
+
} from './carousel.js';
|
|
58
|
+
|
|
59
|
+
export type {
|
|
60
|
+
LqipPlaceholder,
|
|
61
|
+
ImageLoadingPriority,
|
|
62
|
+
ImageLoadingAttrs,
|
|
63
|
+
} from './lqip.js';
|
|
64
|
+
export {
|
|
65
|
+
buildPlaceholderStyle,
|
|
66
|
+
resolveAspectRatio,
|
|
67
|
+
imageLoadingAttrs,
|
|
68
|
+
} from './lqip.js';
|
package/src/lqip.ts
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Headless LQIP (low-quality image placeholder) / blur-up helpers for the
|
|
3
|
+
* `<Image>` wrapper. Builds the inline background style for the blurred
|
|
4
|
+
* placeholder and resolves the layout-shift-free aspect ratio. Pure and
|
|
5
|
+
* DOM-free so the styling logic is unit-tested without rendering.
|
|
6
|
+
*
|
|
7
|
+
* @see docs/compose/image-optimization.md — LCP < 2.5 s / CLS < 0.1 gates.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** A placeholder source: an inline data-URI (or any URL) plus an optional solid colour. */
|
|
11
|
+
export interface LqipPlaceholder {
|
|
12
|
+
/** Tiny blurred image, typically a base64 data-URI. */
|
|
13
|
+
readonly src?: string;
|
|
14
|
+
/** Solid fallback colour shown before the blurred image decodes. */
|
|
15
|
+
readonly color?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Build the `background` CSS for the placeholder layer. A data-URI / URL becomes
|
|
20
|
+
* a covering `background-image` (with an optional solid colour beneath it);
|
|
21
|
+
* a colour-only placeholder becomes a flat fill. Returns `undefined` when no
|
|
22
|
+
* placeholder is supplied, so the caller can omit the layer entirely.
|
|
23
|
+
*/
|
|
24
|
+
export function buildPlaceholderStyle(placeholder: LqipPlaceholder | undefined): string | undefined {
|
|
25
|
+
if (placeholder === undefined) return undefined;
|
|
26
|
+
const { src, color } = placeholder;
|
|
27
|
+
if (src !== undefined && src !== '') {
|
|
28
|
+
const layers = [`url("${cssEscapeUrl(src)}") center / cover no-repeat`];
|
|
29
|
+
if (color !== undefined && color !== '') layers.push(color);
|
|
30
|
+
return `background: ${layers.join(', ')};`;
|
|
31
|
+
}
|
|
32
|
+
if (color !== undefined && color !== '') return `background: ${color};`;
|
|
33
|
+
return undefined;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Escape the characters that would break out of a CSS `url("…")` token. */
|
|
37
|
+
function cssEscapeUrl(url: string): string {
|
|
38
|
+
return url.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Resolve an `aspect-ratio` CSS value from intrinsic width/height. Returns
|
|
43
|
+
* `undefined` when either dimension is missing or non-positive, so the caller
|
|
44
|
+
* only reserves space when it can do so correctly (avoids a wrong-ratio CLS).
|
|
45
|
+
*/
|
|
46
|
+
export function resolveAspectRatio(
|
|
47
|
+
width: number | undefined,
|
|
48
|
+
height: number | undefined,
|
|
49
|
+
): string | undefined {
|
|
50
|
+
if (
|
|
51
|
+
width === undefined ||
|
|
52
|
+
height === undefined ||
|
|
53
|
+
!Number.isFinite(width) ||
|
|
54
|
+
!Number.isFinite(height) ||
|
|
55
|
+
width <= 0 ||
|
|
56
|
+
height <= 0
|
|
57
|
+
) {
|
|
58
|
+
return undefined;
|
|
59
|
+
}
|
|
60
|
+
return `${width} / ${height}`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Loading priority for the underlying `<img>`. */
|
|
64
|
+
export type ImageLoadingPriority = 'auto' | 'high';
|
|
65
|
+
|
|
66
|
+
export interface ImageLoadingAttrs {
|
|
67
|
+
readonly loading: 'lazy' | 'eager';
|
|
68
|
+
readonly fetchpriority: 'high' | 'auto';
|
|
69
|
+
readonly decoding: 'async';
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Derive the `loading` / `fetchpriority` / `decoding` attributes. `high`
|
|
74
|
+
* priority (an above-the-fold LCP hero) eager-loads with `fetchpriority="high"`;
|
|
75
|
+
* everything else lazy-loads. `decoding` is always `async` to keep the main
|
|
76
|
+
* thread free.
|
|
77
|
+
*/
|
|
78
|
+
export function imageLoadingAttrs(priority: ImageLoadingPriority = 'auto'): ImageLoadingAttrs {
|
|
79
|
+
return priority === 'high'
|
|
80
|
+
? { loading: 'eager', fetchpriority: 'high', decoding: 'async' }
|
|
81
|
+
: { loading: 'lazy', fetchpriority: 'auto', decoding: 'async' };
|
|
82
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Headless player-control helpers for the `<Player>` shell: keyboard-event →
|
|
3
|
+
* action mapping (Vidstack-compatible defaults), a captions-invariant guard,
|
|
4
|
+
* and time formatting for the progress label. Pure and DOM-free so the mapping
|
|
5
|
+
* table is unit-tested without rendering a component.
|
|
6
|
+
*
|
|
7
|
+
* @see ADR-0042 (Vidstack `@next` + `hls.js`) — keyboard parity with Vidstack.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { ProblemError } from '@sveltesentio/core';
|
|
11
|
+
|
|
12
|
+
/** A discrete player control intent produced by a key press. */
|
|
13
|
+
export type PlayerAction =
|
|
14
|
+
| 'toggle-play'
|
|
15
|
+
| 'seek-back'
|
|
16
|
+
| 'seek-forward'
|
|
17
|
+
| 'volume-up'
|
|
18
|
+
| 'volume-down'
|
|
19
|
+
| 'toggle-mute'
|
|
20
|
+
| 'toggle-fullscreen'
|
|
21
|
+
| 'toggle-captions';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Map a keyboard event to a player action, mirroring Vidstack's default
|
|
25
|
+
* shortcuts: Space / K toggle play, ← → seek, ↑ ↓ volume, M mute, F fullscreen,
|
|
26
|
+
* C captions. Returns `undefined` for unmapped keys or when a modifier is held
|
|
27
|
+
* (so browser/OS chords are never hijacked). Matching is case-insensitive.
|
|
28
|
+
*/
|
|
29
|
+
export function actionForKey(event: {
|
|
30
|
+
readonly key: string;
|
|
31
|
+
readonly ctrlKey?: boolean;
|
|
32
|
+
readonly metaKey?: boolean;
|
|
33
|
+
readonly altKey?: boolean;
|
|
34
|
+
}): PlayerAction | undefined {
|
|
35
|
+
if (event.ctrlKey || event.metaKey || event.altKey) return undefined;
|
|
36
|
+
switch (event.key.toLowerCase()) {
|
|
37
|
+
case ' ':
|
|
38
|
+
case 'spacebar':
|
|
39
|
+
case 'k':
|
|
40
|
+
return 'toggle-play';
|
|
41
|
+
case 'arrowleft':
|
|
42
|
+
return 'seek-back';
|
|
43
|
+
case 'arrowright':
|
|
44
|
+
return 'seek-forward';
|
|
45
|
+
case 'arrowup':
|
|
46
|
+
return 'volume-up';
|
|
47
|
+
case 'arrowdown':
|
|
48
|
+
return 'volume-down';
|
|
49
|
+
case 'm':
|
|
50
|
+
return 'toggle-mute';
|
|
51
|
+
case 'f':
|
|
52
|
+
return 'toggle-fullscreen';
|
|
53
|
+
case 'c':
|
|
54
|
+
return 'toggle-captions';
|
|
55
|
+
default:
|
|
56
|
+
return undefined;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** One caption / subtitle track for a consumer-supplied video. */
|
|
61
|
+
export interface MediaTrack {
|
|
62
|
+
readonly src: string;
|
|
63
|
+
/** Track kind; `captions` / `subtitles` satisfy WCAG 1.2.2. */
|
|
64
|
+
readonly kind: 'captions' | 'subtitles' | 'descriptions' | 'chapters' | 'metadata';
|
|
65
|
+
/** BCP 47 language tag, e.g. `"en"`. */
|
|
66
|
+
readonly srclang?: string;
|
|
67
|
+
/** Human label shown in the track menu, e.g. `"English"`. */
|
|
68
|
+
readonly label?: string;
|
|
69
|
+
/** Whether the browser shows this track by default. */
|
|
70
|
+
readonly default?: boolean;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Enforce the captions invariant for video (WCAG 2.2 SC 1.2.2): a video
|
|
75
|
+
* `<Player>` must receive a `tracks` prop, even if empty. Passing `tracks`
|
|
76
|
+
* (including `[]`) is the explicit opt-out a consumer makes when the source has
|
|
77
|
+
* no spoken audio. Audio-only players are exempt. Throws an RFC 9457
|
|
78
|
+
* `ProblemError` rather than rendering a caption-less video.
|
|
79
|
+
*/
|
|
80
|
+
export function assertCaptionsContract(
|
|
81
|
+
viewType: 'video' | 'audio',
|
|
82
|
+
tracks: readonly MediaTrack[] | undefined,
|
|
83
|
+
): void {
|
|
84
|
+
if (viewType === 'audio') return;
|
|
85
|
+
if (tracks === undefined) {
|
|
86
|
+
throw new ProblemError({
|
|
87
|
+
status: 500,
|
|
88
|
+
title: 'Captions contract violated',
|
|
89
|
+
detail:
|
|
90
|
+
'A video <Player> requires a `tracks` prop (use `tracks={[]}` to opt out explicitly when the source has no spoken audio). WCAG 2.2 SC 1.2.2.',
|
|
91
|
+
type: 'https://sveltesentio.dev/problems/media/captions-required',
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Pad a non-negative integer to two digits. */
|
|
97
|
+
function pad2(n: number): string {
|
|
98
|
+
return n < 10 ? `0${n}` : String(n);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Format a media time (seconds) as `M:SS` or `H:MM:SS`. Non-finite or negative
|
|
103
|
+
* input clamps to `0:00`. Used for the visible time-code and the progress
|
|
104
|
+
* slider's `aria-valuetext`.
|
|
105
|
+
*/
|
|
106
|
+
export function formatMediaTime(seconds: number): string {
|
|
107
|
+
const total = Number.isFinite(seconds) && seconds > 0 ? Math.floor(seconds) : 0;
|
|
108
|
+
const hrs = Math.floor(total / 3600);
|
|
109
|
+
const mins = Math.floor((total % 3600) / 60);
|
|
110
|
+
const secs = total % 60;
|
|
111
|
+
if (hrs > 0) return `${hrs}:${pad2(mins)}:${pad2(secs)}`;
|
|
112
|
+
return `${mins}:${pad2(secs)}`;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Clamp a volume to the valid `[0, 1]` range, mapping non-finite input to `0`.
|
|
117
|
+
* Volume key steps add/subtract `step` (default `0.1`) before clamping.
|
|
118
|
+
*/
|
|
119
|
+
export function clampVolume(value: number, step = 0): number {
|
|
120
|
+
const v = Number.isFinite(value) ? value + step : 0;
|
|
121
|
+
if (v < 0) return 0;
|
|
122
|
+
if (v > 1) return 1;
|
|
123
|
+
return v;
|
|
124
|
+
}
|