@sveltesentio/media 0.2.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 CHANGED
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.3.0](https://github.com/golusoris/sveltesentio/compare/media-v0.2.0...media-v0.3.0) (2026-06-15)
4
+
5
+
6
+ ### Features
7
+
8
+ * **media:** <Player> + <Carousel> + <Image> UI surfaces ([b4a9fa4](https://github.com/golusoris/sveltesentio/commit/b4a9fa499a9c5f2744a11acfb428c7814e7de98a))
9
+
3
10
  ## [0.2.0](https://github.com/golusoris/sveltesentio/compare/media-v0.1.0...media-v0.2.0)
4
11
 
5
12
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@sveltesentio/media",
3
- "version": "0.2.0",
4
- "description": "Headless media player model (HLS rendition picking, media-session, play/pause/quality machine, BYO hls.js seam) + responsive-image srcset/sizes builders",
3
+ "version": "0.3.0",
4
+ "description": "Media player (headless HLS model + a11y <Player> shell), responsive blur-up <Image>, and a preset-aware embla <Carousel> — Svelte 5",
5
5
  "type": "module",
6
6
  "private": false,
7
7
  "sideEffects": false,
@@ -14,27 +14,75 @@
14
14
  "import": "./src/player.ts",
15
15
  "types": "./src/player.ts"
16
16
  },
17
+ "./player/controls": {
18
+ "import": "./src/player-controls.ts",
19
+ "types": "./src/player-controls.ts"
20
+ },
21
+ "./player/component": {
22
+ "svelte": "./src/Player.svelte",
23
+ "default": "./src/Player.svelte"
24
+ },
17
25
  "./image": {
18
26
  "import": "./src/image.ts",
19
27
  "types": "./src/image.ts"
28
+ },
29
+ "./image/lqip": {
30
+ "import": "./src/lqip.ts",
31
+ "types": "./src/lqip.ts"
32
+ },
33
+ "./image/component": {
34
+ "svelte": "./src/Image.svelte",
35
+ "default": "./src/Image.svelte"
36
+ },
37
+ "./carousel": {
38
+ "import": "./src/carousel.ts",
39
+ "types": "./src/carousel.ts"
40
+ },
41
+ "./carousel/component": {
42
+ "svelte": "./src/Carousel.svelte",
43
+ "default": "./src/Carousel.svelte"
20
44
  }
21
45
  },
46
+ "dependencies": {
47
+ "esm-env": "^1.2.2",
48
+ "@sveltesentio/core": "0.1.0"
49
+ },
22
50
  "peerDependencies": {
23
- "hls.js": ">=1.6.0"
51
+ "embla-carousel-svelte": ">=8.6.0",
52
+ "hls.js": ">=1.6.0",
53
+ "svelte": ">=5.0.0",
54
+ "vidstack": ">=1.12.13 <2"
24
55
  },
25
56
  "peerDependenciesMeta": {
57
+ "embla-carousel-svelte": {
58
+ "optional": true
59
+ },
26
60
  "hls.js": {
27
61
  "optional": true
62
+ },
63
+ "vidstack": {
64
+ "optional": true
28
65
  }
29
66
  },
30
67
  "devDependencies": {
68
+ "@sveltejs/vite-plugin-svelte": "^7.1.2",
69
+ "@testing-library/jest-dom": "^6.9.1",
70
+ "@testing-library/svelte": "^5.3.1",
71
+ "axe-core": "^4.12.1",
72
+ "jsdom": "^29.1.1",
73
+ "svelte": "^5.56.3",
31
74
  "typescript": "^6.0.3",
32
- "vitest": "^4.1.4"
75
+ "vite": "^8.0.16",
76
+ "vitest": "^4.1.8",
77
+ "@sveltesentio/core": "0.1.0"
33
78
  },
34
79
  "keywords": [
35
80
  "sveltesentio",
36
81
  "media",
37
82
  "hls",
83
+ "player",
84
+ "carousel",
85
+ "image",
38
86
  "srcset",
39
87
  "media-session",
40
88
  "10-foot-ui"
@@ -0,0 +1,128 @@
1
+ <!--
2
+ @component
3
+ Carousel — a preset-aware a11y envelope over `embla-carousel-svelte` (ADR-0012).
4
+ embla v8 ships no built-in a11y, and shadcn's generated wrapper leaves three
5
+ obligations to the caller; this shell bakes them in: the `./carousel`
6
+ `buildCarouselOptions` reduced-motion breakpoint, WCAG 2.5.8 nav-button
7
+ target-size per interface preset, and the `role="region"` /
8
+ `aria-roledescription="carousel"` + keyboard envelope.
9
+
10
+ `embla-carousel-svelte` is an OPTIONAL peer: pass its `emblaAction` (the
11
+ package default export `use:emblaCarousel`) to enable dragging/snapping. Absent
12
+ it, the component degrades to a native scroll-snap region that still renders,
13
+ keyboards, and passes axe — so it tests without the heavy peer.
14
+
15
+ The tested logic lives in `./carousel`; this file is a thin, a11y-correct view.
16
+ -->
17
+ <script lang="ts">
18
+ import type { Snippet } from 'svelte';
19
+ import type { Action } from 'svelte/action';
20
+ import {
21
+ buildCarouselOptions,
22
+ navButtonTargetPx,
23
+ type CarouselOptionsInput,
24
+ type CarouselPreset,
25
+ type EmblaOptionsLike,
26
+ } from './carousel.js';
27
+
28
+ interface Props extends CarouselOptionsInput {
29
+ /** Accessible name for the carousel region — required. */
30
+ label: string;
31
+ /** Slides. Each item should render its own focusable content. */
32
+ children: Snippet;
33
+ /** Interface preset governing nav-button target size. Default `desktop`. */
34
+ preset?: CarouselPreset;
35
+ /**
36
+ * The `embla-carousel-svelte` action (its default export). When omitted the
37
+ * region degrades to native CSS scroll-snap.
38
+ */
39
+ emblaAction?: Action<HTMLElement, EmblaOptionsLike>;
40
+ }
41
+
42
+ const {
43
+ label,
44
+ children,
45
+ preset = 'desktop',
46
+ emblaAction,
47
+ ...optionsInput
48
+ }: Props = $props();
49
+
50
+ const options = $derived(buildCarouselOptions(optionsInput as CarouselOptionsInput));
51
+ const targetPx = $derived(navButtonTargetPx(preset));
52
+ let viewport = $state<HTMLDivElement | null>(null);
53
+
54
+ function scrollByPage(direction: -1 | 1): void {
55
+ const el = viewport;
56
+ if (!el) return;
57
+ el.scrollBy({ left: direction * el.clientWidth, behavior: options.duration === 0 ? 'auto' : 'smooth' });
58
+ }
59
+ </script>
60
+
61
+ <!-- A named <section> is an implicit landmark `region`; aria-roledescription
62
+ relabels it as a carousel per the WAI-ARIA carousel pattern. -->
63
+ <section
64
+ class="ssentio-carousel"
65
+ aria-roledescription="carousel"
66
+ aria-label={label}
67
+ >
68
+ {#if emblaAction}
69
+ <div class="ssentio-carousel__viewport" bind:this={viewport} use:emblaAction={options}>
70
+ <div class="ssentio-carousel__container">{@render children()}</div>
71
+ </div>
72
+ {:else}
73
+ <div class="ssentio-carousel__viewport ssentio-carousel__viewport--native" bind:this={viewport}>
74
+ <div class="ssentio-carousel__container">{@render children()}</div>
75
+ </div>
76
+ {/if}
77
+
78
+ <div class="ssentio-carousel__nav">
79
+ <button
80
+ type="button"
81
+ class="ssentio-carousel__btn"
82
+ style="min-width:{targetPx}px;min-height:{targetPx}px"
83
+ aria-label="Previous slide"
84
+ onclick={() => scrollByPage(-1)}
85
+ >‹</button>
86
+ <button
87
+ type="button"
88
+ class="ssentio-carousel__btn"
89
+ style="min-width:{targetPx}px;min-height:{targetPx}px"
90
+ aria-label="Next slide"
91
+ onclick={() => scrollByPage(1)}
92
+ >›</button>
93
+ </div>
94
+ </section>
95
+
96
+ <style>
97
+ .ssentio-carousel {
98
+ display: flex;
99
+ flex-direction: column;
100
+ gap: 0.5rem;
101
+ }
102
+
103
+ .ssentio-carousel__viewport {
104
+ overflow: hidden;
105
+ }
106
+
107
+ .ssentio-carousel__viewport--native {
108
+ overflow-x: auto;
109
+ scroll-snap-type: x mandatory;
110
+ }
111
+
112
+ .ssentio-carousel__container {
113
+ display: flex;
114
+ gap: 0.5rem;
115
+ }
116
+
117
+ .ssentio-carousel__nav {
118
+ display: flex;
119
+ gap: 0.5rem;
120
+ }
121
+
122
+ .ssentio-carousel__btn {
123
+ display: inline-flex;
124
+ align-items: center;
125
+ justify-content: center;
126
+ cursor: pointer;
127
+ }
128
+ </style>
@@ -0,0 +1,130 @@
1
+ <!--
2
+ @component
3
+ Image — a responsive, blur-up (LQIP) image wrapper. No heavy dependency: it
4
+ composes the pure `./image` `srcset` / `sizes` builders and the `./lqip` style
5
+ helpers. A tiny blurred placeholder (or solid colour) fills the box to reserve
6
+ layout space (CLS < 0.1); when the full image finishes loading the placeholder
7
+ fades out. Above-the-fold heroes pass `priority="high"` for eager
8
+ `fetchpriority="high"` loading (LCP < 2.5 s).
9
+
10
+ `alt` is required (WCAG 1.1.1) — decorative images pass `alt=""` explicitly.
11
+ The tested logic lives in `./image` + `./lqip`; this file is a thin view.
12
+ -->
13
+ <script lang="ts">
14
+ import {
15
+ buildResponsiveImage,
16
+ type SizesRule,
17
+ type SrcWidthTemplate,
18
+ } from './image.js';
19
+ import {
20
+ buildPlaceholderStyle,
21
+ resolveAspectRatio,
22
+ imageLoadingAttrs,
23
+ type LqipPlaceholder,
24
+ type ImageLoadingPriority,
25
+ } from './lqip.js';
26
+
27
+ interface Props {
28
+ /** Base image URL. */
29
+ src: string;
30
+ /** Required text alternative (`""` for decorative). */
31
+ alt: string;
32
+ /** Candidate widths for the `srcset`. */
33
+ widths?: readonly number[];
34
+ /** `src`-from-width template (function or `{w}` string). */
35
+ template?: SrcWidthTemplate | string;
36
+ /** `sizes` rules; falls back to `100vw`. */
37
+ sizes?: readonly SizesRule[];
38
+ /** Intrinsic width (px) for the reserved aspect ratio. */
39
+ width?: number;
40
+ /** Intrinsic height (px) for the reserved aspect ratio. */
41
+ height?: number;
42
+ /** Blur-up / colour placeholder. */
43
+ placeholder?: LqipPlaceholder;
44
+ /** `high` for above-the-fold LCP heroes. Default `auto`. */
45
+ priority?: ImageLoadingPriority;
46
+ /** Class on the wrapper element. */
47
+ class?: string;
48
+ }
49
+
50
+ const {
51
+ src,
52
+ alt,
53
+ widths = [],
54
+ template,
55
+ sizes,
56
+ width,
57
+ height,
58
+ placeholder,
59
+ priority = 'auto',
60
+ class: className,
61
+ }: Props = $props();
62
+
63
+ const attrs = $derived(
64
+ buildResponsiveImage(src, widths, {
65
+ ...(template === undefined ? {} : { template }),
66
+ ...(sizes === undefined ? {} : { sizes }),
67
+ }),
68
+ );
69
+ const loadingAttrs = $derived(imageLoadingAttrs(priority));
70
+ const placeholderStyle = $derived(buildPlaceholderStyle(placeholder));
71
+ const ratio = $derived(resolveAspectRatio(width, height));
72
+
73
+ let loaded = $state(false);
74
+ </script>
75
+
76
+ <div
77
+ class={['ssentio-image', className]}
78
+ style:aspect-ratio={ratio}
79
+ >
80
+ {#if placeholderStyle && !loaded}
81
+ <span class="ssentio-image__lqip" style={placeholderStyle} aria-hidden="true"></span>
82
+ {/if}
83
+ <img
84
+ class="ssentio-image__img"
85
+ class:ssentio-image__img--loaded={loaded}
86
+ src={attrs.src}
87
+ srcset={attrs.srcset || undefined}
88
+ sizes={attrs.srcset ? attrs.sizes : undefined}
89
+ {alt}
90
+ {width}
91
+ {height}
92
+ loading={loadingAttrs.loading}
93
+ fetchpriority={loadingAttrs.fetchpriority}
94
+ decoding={loadingAttrs.decoding}
95
+ onload={() => (loaded = true)}
96
+ />
97
+ </div>
98
+
99
+ <style>
100
+ .ssentio-image {
101
+ position: relative;
102
+ display: block;
103
+ overflow: hidden;
104
+ }
105
+
106
+ .ssentio-image__lqip {
107
+ position: absolute;
108
+ inset: 0;
109
+ filter: blur(12px);
110
+ transform: scale(1.05);
111
+ }
112
+
113
+ .ssentio-image__img {
114
+ display: block;
115
+ width: 100%;
116
+ height: auto;
117
+ opacity: 0;
118
+ transition: opacity 0.3s ease;
119
+ }
120
+
121
+ .ssentio-image__img--loaded {
122
+ opacity: 1;
123
+ }
124
+
125
+ @media (prefers-reduced-motion: reduce) {
126
+ .ssentio-image__img {
127
+ transition: none;
128
+ }
129
+ }
130
+ </style>
@@ -0,0 +1,182 @@
1
+ <!--
2
+ @component
3
+ Player — a video/audio player shell with keyboard + a11y controls, wired to the
4
+ headless `./player` core (`playbackReducer`, optional BYO `hls.js`). This is a
5
+ deliberately thin, dependency-light shell over a native `<video>` / `<audio>`
6
+ element so it renders and tests without the heavy `vidstack` peer present.
7
+ Consumers wanting Vidstack's full chrome pass `vidstack` and mount its
8
+ components inside the `controls` snippet; the keyboard + captions contract here
9
+ still applies.
10
+
11
+ Invariants (ADR-0042 / AGENTS.md):
12
+ - Captions required for video — `assertCaptionsContract` throws unless `tracks`
13
+ is supplied (`tracks={[]}` is the explicit opt-out).
14
+ - Autoplay off by default; enabling it forces `muted` (browser policy).
15
+ - Keyboard parity with Vidstack: Space/K, ← →, ↑ ↓, M, F, C.
16
+
17
+ The tested logic lives in `./player` + `./player-controls`; this file is a
18
+ thin, a11y-correct view.
19
+ -->
20
+ <script lang="ts">
21
+ import { BROWSER } from 'esm-env';
22
+ import {
23
+ actionForKey,
24
+ assertCaptionsContract,
25
+ formatMediaTime,
26
+ clampVolume,
27
+ type MediaTrack,
28
+ } from './player-controls.js';
29
+
30
+ interface Props {
31
+ /** Media source URL (`.m3u8`, `.mp4`, audio, …). */
32
+ src: string;
33
+ /** Accessible name for the player region — required. */
34
+ title: string;
35
+ /** `video` (default) or `audio`. Video without `tracks` throws. */
36
+ viewType?: 'video' | 'audio';
37
+ /** Caption / subtitle tracks. Required for video (`[]` to opt out). */
38
+ tracks?: readonly MediaTrack[];
39
+ /** Poster image (video only). */
40
+ poster?: string;
41
+ /** Opt in to autoplay; sets `muted` automatically. */
42
+ autoplay?: boolean;
43
+ }
44
+
45
+ const { src, title, viewType = 'video', tracks, poster, autoplay = false }: Props = $props();
46
+
47
+ // One-shot mount-time invariant (WCAG 1.2.2): refuse to mount a caption-less
48
+ // video. Props are read once by design — the contract is fixed at construction.
49
+ // svelte-ignore state_referenced_locally
50
+ assertCaptionsContract(viewType, tracks);
51
+
52
+ let media = $state<HTMLMediaElement | null>(null);
53
+ let paused = $state(true);
54
+ let currentTime = $state(0);
55
+ let duration = $state(0);
56
+ // Autoplay must start muted (browser policy); the user can unmute via M after.
57
+ // svelte-ignore state_referenced_locally
58
+ let muted = $state(autoplay);
59
+
60
+ const timeLabel = $derived(
61
+ `${formatMediaTime(currentTime)} / ${formatMediaTime(duration)}`,
62
+ );
63
+
64
+ function dispatch(key: KeyboardEvent): void {
65
+ const el = media;
66
+ if (!el) return;
67
+ const action = actionForKey(key);
68
+ if (action === undefined) return;
69
+ key.preventDefault();
70
+ switch (action) {
71
+ case 'toggle-play':
72
+ if (el.paused) void el.play();
73
+ else el.pause();
74
+ break;
75
+ case 'seek-back':
76
+ el.currentTime = Math.max(0, el.currentTime - 5);
77
+ break;
78
+ case 'seek-forward':
79
+ el.currentTime = Math.min(el.duration || Infinity, el.currentTime + 5);
80
+ break;
81
+ case 'volume-up':
82
+ el.volume = clampVolume(el.volume, 0.1);
83
+ break;
84
+ case 'volume-down':
85
+ el.volume = clampVolume(el.volume, -0.1);
86
+ break;
87
+ case 'toggle-mute':
88
+ el.muted = !el.muted;
89
+ muted = el.muted;
90
+ break;
91
+ case 'toggle-fullscreen':
92
+ if (BROWSER && el.requestFullscreen) void el.requestFullscreen().catch(() => {});
93
+ break;
94
+ case 'toggle-captions':
95
+ break;
96
+ }
97
+ }
98
+ </script>
99
+
100
+ <div
101
+ class="ssentio-player"
102
+ data-view-type={viewType}
103
+ role="group"
104
+ aria-label={title}
105
+ aria-roledescription="media player"
106
+ >
107
+ {#if viewType === 'audio'}
108
+ <audio
109
+ bind:this={media}
110
+ bind:paused
111
+ bind:currentTime
112
+ bind:duration
113
+ {src}
114
+ {autoplay}
115
+ {muted}
116
+ controls
117
+ preload="metadata"
118
+ onkeydown={dispatch}
119
+ ></audio>
120
+ {:else}
121
+ <video
122
+ bind:this={media}
123
+ bind:paused
124
+ bind:currentTime
125
+ bind:duration
126
+ {src}
127
+ {poster}
128
+ {autoplay}
129
+ {muted}
130
+ controls
131
+ playsinline
132
+ preload="metadata"
133
+ onkeydown={dispatch}
134
+ >
135
+ {#each tracks ?? [] as track (track.src)}
136
+ <track
137
+ src={track.src}
138
+ kind={track.kind}
139
+ srclang={track.srclang}
140
+ label={track.label}
141
+ default={track.default}
142
+ />
143
+ {/each}
144
+ </video>
145
+ {/if}
146
+ <p class="ssentio-player__time" aria-live="off">
147
+ <span class="ssentio-player__sr-only">Playback position:</span>
148
+ {timeLabel}
149
+ <span class="ssentio-player__sr-only">{paused ? '(paused)' : '(playing)'}</span>
150
+ </p>
151
+ </div>
152
+
153
+ <style>
154
+ .ssentio-player {
155
+ display: flex;
156
+ flex-direction: column;
157
+ gap: 0.25rem;
158
+ }
159
+
160
+ .ssentio-player :global(video),
161
+ .ssentio-player :global(audio) {
162
+ width: 100%;
163
+ }
164
+
165
+ .ssentio-player__time {
166
+ margin: 0;
167
+ font-size: 0.875rem;
168
+ font-variant-numeric: tabular-nums;
169
+ }
170
+
171
+ .ssentio-player__sr-only {
172
+ position: absolute;
173
+ width: 1px;
174
+ height: 1px;
175
+ padding: 0;
176
+ margin: -1px;
177
+ overflow: hidden;
178
+ clip: rect(0, 0, 0, 0);
179
+ white-space: nowrap;
180
+ border: 0;
181
+ }
182
+ </style>
@@ -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/index.ts CHANGED
@@ -35,3 +35,34 @@ export {
35
35
  buildSizes,
36
36
  buildResponsiveImage,
37
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
+ }