@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 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
- > vidstack + HLS.js + embla-carousel wrappers for media server UIs
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
- 🚧 Phase 1 stub implementation begins in Phase 2+.
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
- ## Installation
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
- See the [monorepo README](../../README.md) and [`docs/`](../../docs/) for design principles and usage.
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.1.0",
4
- "description": "Vidstack + HLS.js + embla-carousel wrappers, artwork grid, 10-foot UI preset",
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
- "vidstack": ">=0.6.0",
51
+ "embla-carousel-svelte": ">=8.6.0",
16
52
  "hls.js": ">=1.6.0",
17
- "embla-carousel-svelte": ">=8.0.0",
18
- "svelte": ">=5.0.0"
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
- "embla-carousel-svelte": {
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
- "vitest": "^4.1.4"
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 --passWithNoTests"
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>
@@ -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>