@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/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,26 @@
|
|
|
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
|
+
|
|
10
|
+
## [0.2.0](https://github.com/golusoris/sveltesentio/compare/media-v0.1.0...media-v0.2.0)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
### Features
|
|
14
|
+
|
|
15
|
+
* **player:** headless HLS source model — `pickRendition` (separate-rendition quality switching with `maxHeight` / `preferCodec`), `buildMediaSessionMetadata`, a pure `playbackReducer` play/pause/quality state machine, and a bring-your-own-`hls.js` `createHlsAttachment` seam ([#67](https://github.com/golusoris/sveltesentio/issues/67), [#68](https://github.com/golusoris/sveltesentio/issues/68))
|
|
16
|
+
* **image:** pure responsive-image `buildSrcSet` / `buildSizes` / `buildResponsiveImage` builders with template + query-merge URL strategies ([#67](https://github.com/golusoris/sveltesentio/issues/67))
|
|
17
|
+
* sub-exports `./player` and `./image`; `hls.js` declared as an optional peer (no runtime media deps)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
### Notes
|
|
21
|
+
|
|
22
|
+
* The `vidstack@next` `<Player>` UI shell and the `./carousel` re-export remain follow-throughs; README and AGENTS status reconciled per [#67](https://github.com/golusoris/sveltesentio/issues/67).
|
|
23
|
+
|
|
3
24
|
## [0.1.0](https://github.com/golusoris/sveltesentio/compare/media-v0.0.1...media-v0.1.0) (2026-06-14)
|
|
4
25
|
|
|
5
26
|
|
package/README.md
CHANGED
|
@@ -1,20 +1,69 @@
|
|
|
1
1
|
# @sveltesentio/media
|
|
2
2
|
|
|
3
|
-
>
|
|
3
|
+
> Headless media-player logic + responsive-image builders for media-server UIs
|
|
4
4
|
|
|
5
5
|
Part of the [sveltesentio](https://github.com/golusoris/sveltesentio) composable SvelteKit framework.
|
|
6
6
|
|
|
7
7
|
## Status
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
v0.2.0 — headless core landed. The pure logic for HLS rendition selection,
|
|
10
|
+
OS media-session metadata, a play/pause/quality state machine, a
|
|
11
|
+
bring-your-own-`hls.js` attachment seam, and responsive-image `srcset` / `sizes`
|
|
12
|
+
builders all ship and are unit-tested. The full `<Player>` UI shell
|
|
13
|
+
(`vidstack`) and the `./carousel` re-export are tracked as follow-throughs (see
|
|
14
|
+
[AGENTS.md](AGENTS.md)) and are **not** in this release.
|
|
10
15
|
|
|
11
|
-
##
|
|
16
|
+
## Install
|
|
12
17
|
|
|
13
18
|
```bash
|
|
14
19
|
pnpm add @sveltesentio/media
|
|
20
|
+
# optional, only if you drive adaptive HLS yourself:
|
|
21
|
+
pnpm add hls.js
|
|
15
22
|
```
|
|
16
23
|
|
|
17
|
-
|
|
24
|
+
`hls.js` is an **optional** peer — nothing in this package imports it. You
|
|
25
|
+
inject its constructor at the `createHlsAttachment` seam.
|
|
26
|
+
|
|
27
|
+
## `@sveltesentio/media/player`
|
|
28
|
+
|
|
29
|
+
```ts
|
|
30
|
+
import {
|
|
31
|
+
pickRendition,
|
|
32
|
+
buildMediaSessionMetadata,
|
|
33
|
+
playbackReducer,
|
|
34
|
+
initialPlaybackState,
|
|
35
|
+
createHlsAttachment,
|
|
36
|
+
} from '@sveltesentio/media/player';
|
|
37
|
+
|
|
38
|
+
// Separate-rendition (un-muxed) quality switching: prefer HEVC, cap at 1080p.
|
|
39
|
+
const best = pickRendition(renditions, { maxHeight: 1080, preferCodec: 'hvc1' });
|
|
40
|
+
|
|
41
|
+
// OS lock-screen / media-keys metadata (pass to `new MediaMetadata(...)`).
|
|
42
|
+
const meta = buildMediaSessionMetadata({ title, artist, artwork });
|
|
43
|
+
|
|
44
|
+
// Pure play/pause/quality machine — invalid transitions are no-ops.
|
|
45
|
+
let state = playbackReducer(initialPlaybackState, { type: 'load' });
|
|
46
|
+
|
|
47
|
+
// Bring-your-own hls.js — no dynamic import, no bundled engine.
|
|
48
|
+
import Hls from 'hls.js';
|
|
49
|
+
const handle = createHlsAttachment(Hls).attach(videoEl, manifestUrl);
|
|
50
|
+
// handle.destroy();
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## `@sveltesentio/media/image`
|
|
54
|
+
|
|
55
|
+
```ts
|
|
56
|
+
import { buildResponsiveImage } from '@sveltesentio/media/image';
|
|
57
|
+
|
|
58
|
+
const attrs = buildResponsiveImage('/images/poster/{path}', [320, 640, 1280], {
|
|
59
|
+
template: '/images/poster/w{w}/abc.avif',
|
|
60
|
+
sizes: [{ condition: '(min-width: 768px)', size: '33vw' }],
|
|
61
|
+
});
|
|
62
|
+
// → { src, srcset, sizes } — spread onto <img>.
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
See the [monorepo README](../../README.md) and [`docs/`](../../docs/) for design
|
|
66
|
+
principles.
|
|
18
67
|
|
|
19
68
|
## License
|
|
20
69
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sveltesentio/media",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
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,
|
|
@@ -9,32 +9,82 @@
|
|
|
9
9
|
".": {
|
|
10
10
|
"import": "./src/index.ts",
|
|
11
11
|
"types": "./src/index.ts"
|
|
12
|
+
},
|
|
13
|
+
"./player": {
|
|
14
|
+
"import": "./src/player.ts",
|
|
15
|
+
"types": "./src/player.ts"
|
|
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
|
+
},
|
|
25
|
+
"./image": {
|
|
26
|
+
"import": "./src/image.ts",
|
|
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"
|
|
12
44
|
}
|
|
13
45
|
},
|
|
46
|
+
"dependencies": {
|
|
47
|
+
"esm-env": "^1.2.2",
|
|
48
|
+
"@sveltesentio/core": "0.1.0"
|
|
49
|
+
},
|
|
14
50
|
"peerDependencies": {
|
|
15
|
-
"
|
|
51
|
+
"embla-carousel-svelte": ">=8.6.0",
|
|
16
52
|
"hls.js": ">=1.6.0",
|
|
17
|
-
"
|
|
18
|
-
"
|
|
53
|
+
"svelte": ">=5.0.0",
|
|
54
|
+
"vidstack": ">=1.12.13 <2"
|
|
19
55
|
},
|
|
20
56
|
"peerDependenciesMeta": {
|
|
57
|
+
"embla-carousel-svelte": {
|
|
58
|
+
"optional": true
|
|
59
|
+
},
|
|
21
60
|
"hls.js": {
|
|
22
61
|
"optional": true
|
|
23
62
|
},
|
|
24
|
-
"
|
|
63
|
+
"vidstack": {
|
|
25
64
|
"optional": true
|
|
26
65
|
}
|
|
27
66
|
},
|
|
28
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",
|
|
29
74
|
"typescript": "^6.0.3",
|
|
30
|
-
"
|
|
75
|
+
"vite": "^8.0.16",
|
|
76
|
+
"vitest": "^4.1.8",
|
|
77
|
+
"@sveltesentio/core": "0.1.0"
|
|
31
78
|
},
|
|
32
79
|
"keywords": [
|
|
33
80
|
"sveltesentio",
|
|
34
81
|
"media",
|
|
35
|
-
"vidstack",
|
|
36
82
|
"hls",
|
|
83
|
+
"player",
|
|
37
84
|
"carousel",
|
|
85
|
+
"image",
|
|
86
|
+
"srcset",
|
|
87
|
+
"media-session",
|
|
38
88
|
"10-foot-ui"
|
|
39
89
|
],
|
|
40
90
|
"publishConfig": {
|
|
@@ -48,6 +98,6 @@
|
|
|
48
98
|
"build": "tsc",
|
|
49
99
|
"lint": "eslint src/",
|
|
50
100
|
"typecheck": "tsc --noEmit",
|
|
51
|
-
"test": "vitest run
|
|
101
|
+
"test": "vitest run"
|
|
52
102
|
}
|
|
53
103
|
}
|
|
@@ -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>
|
package/src/Image.svelte
ADDED
|
@@ -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>
|