@classic-homes/theme-svelte 1.2.2 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +43 -0
- package/dist/__tests__/a11y/Media.a11y.test.js +145 -0
- package/dist/__tests__/media-components.test.js +206 -0
- package/dist/__tests__/media-hls.test.js +273 -0
- package/dist/__tests__/media-matterport.test.js +296 -0
- package/dist/__tests__/media-players.test.js +244 -0
- package/dist/__tests__/media-scrubber.test.js +44 -0
- package/dist/__tests__/media-utils.test.js +427 -0
- package/dist/__tests__/setup.js +50 -0
- package/dist/lib/components/DataPanel.svelte +8 -0
- package/dist/lib/components/DataPanel.svelte.d.ts +6 -0
- package/dist/lib/components/DataPanelTab.svelte +38 -8
- package/dist/lib/components/DataPanelTab.svelte.d.ts +7 -0
- package/dist/lib/components/FileUpload.svelte +221 -4
- package/dist/lib/components/FileUpload.svelte.d.ts +13 -1
- package/dist/lib/components/media/AspectRatio.svelte +47 -0
- package/dist/lib/components/media/AspectRatio.svelte.d.ts +20 -0
- package/dist/lib/components/media/Audio.svelte +247 -0
- package/dist/lib/components/media/Audio.svelte.d.ts +34 -0
- package/dist/lib/components/media/Image.svelte +209 -0
- package/dist/lib/components/media/Image.svelte.d.ts +52 -0
- package/dist/lib/components/media/Lightbox.svelte +449 -0
- package/dist/lib/components/media/Lightbox.svelte.d.ts +33 -0
- package/dist/lib/components/media/MediaCarousel.svelte +359 -0
- package/dist/lib/components/media/MediaCarousel.svelte.d.ts +30 -0
- package/dist/lib/components/media/MediaEmbed.svelte +314 -0
- package/dist/lib/components/media/MediaEmbed.svelte.d.ts +99 -0
- package/dist/lib/components/media/MediaGrid.svelte +327 -0
- package/dist/lib/components/media/MediaGrid.svelte.d.ts +57 -0
- package/dist/lib/components/media/Video.svelte +455 -0
- package/dist/lib/components/media/Video.svelte.d.ts +81 -0
- package/dist/lib/components/media/index.d.ts +14 -0
- package/dist/lib/components/media/index.js +14 -0
- package/dist/lib/components/media/internal/MediaControls.svelte +283 -0
- package/dist/lib/components/media/internal/MediaControls.svelte.d.ts +32 -0
- package/dist/lib/components/media/internal/MediaScrubber.svelte +211 -0
- package/dist/lib/components/media/internal/MediaScrubber.svelte.d.ts +27 -0
- package/dist/lib/components/media/internal/MediaTile.svelte +129 -0
- package/dist/lib/components/media/internal/MediaTile.svelte.d.ts +22 -0
- package/dist/lib/composables/index.d.ts +3 -0
- package/dist/lib/composables/index.js +4 -0
- package/dist/lib/composables/useHlsSource.svelte.d.ts +70 -0
- package/dist/lib/composables/useHlsSource.svelte.js +119 -0
- package/dist/lib/composables/useMediaPlayer.svelte.d.ts +114 -0
- package/dist/lib/composables/useMediaPlayer.svelte.js +422 -0
- package/dist/lib/composables/useMediaZoom.svelte.d.ts +59 -0
- package/dist/lib/composables/useMediaZoom.svelte.js +199 -0
- package/dist/lib/index.d.ts +8 -2
- package/dist/lib/index.js +14 -1
- package/dist/lib/types/components.d.ts +172 -0
- package/dist/lib/utils/hls.d.ts +76 -0
- package/dist/lib/utils/hls.js +83 -0
- package/dist/lib/utils/media.d.ts +379 -0
- package/dist/lib/utils/media.js +715 -0
- package/package.json +6 -2
|
@@ -0,0 +1,427 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { buildEmbedSrc, clamp, cloudflareStream, cloudflareStreamHost, cloudflareStreamThumbnail, cloudflareStreamUrls, formatDuration, formatDurationSpoken, getEmbedThumbnail, getFileExtension, getMediaLabel, getMediaThumbnail, inferMediaKind, isHlsSource, isTimeBasedMedia, parseEmbedUrl, resolveAspectRatio, timeRangesToArray, toMediaSources, } from '../lib/utils/media.js';
|
|
3
|
+
describe('formatDuration', () => {
|
|
4
|
+
it('formats under a minute with a padded seconds field', () => {
|
|
5
|
+
expect(formatDuration(0)).toBe('0:00');
|
|
6
|
+
expect(formatDuration(9)).toBe('0:09');
|
|
7
|
+
expect(formatDuration(59)).toBe('0:59');
|
|
8
|
+
});
|
|
9
|
+
it('formats minutes and seconds', () => {
|
|
10
|
+
expect(formatDuration(60)).toBe('1:00');
|
|
11
|
+
expect(formatDuration(83)).toBe('1:23');
|
|
12
|
+
expect(formatDuration(599)).toBe('9:59');
|
|
13
|
+
});
|
|
14
|
+
it('adds an hours field past an hour and pads the minutes', () => {
|
|
15
|
+
expect(formatDuration(3600)).toBe('1:00:00');
|
|
16
|
+
expect(formatDuration(3723)).toBe('1:02:03');
|
|
17
|
+
});
|
|
18
|
+
it('collapses non-finite and negative input to zero', () => {
|
|
19
|
+
// `video.duration` is NaN until metadata loads, and a player must not
|
|
20
|
+
// render "NaN:NaN" in the meantime.
|
|
21
|
+
expect(formatDuration(NaN)).toBe('0:00');
|
|
22
|
+
expect(formatDuration(Infinity)).toBe('0:00');
|
|
23
|
+
expect(formatDuration(-5)).toBe('0:00');
|
|
24
|
+
});
|
|
25
|
+
it('truncates fractional seconds rather than rounding up', () => {
|
|
26
|
+
expect(formatDuration(59.9)).toBe('0:59');
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
describe('formatDurationSpoken', () => {
|
|
30
|
+
it('spells out each unit', () => {
|
|
31
|
+
expect(formatDurationSpoken(83)).toBe('1 minute 23 seconds');
|
|
32
|
+
expect(formatDurationSpoken(3723)).toBe('1 hour 2 minutes 3 seconds');
|
|
33
|
+
});
|
|
34
|
+
it('singularises correctly', () => {
|
|
35
|
+
expect(formatDurationSpoken(1)).toBe('1 second');
|
|
36
|
+
expect(formatDurationSpoken(60)).toBe('1 minute');
|
|
37
|
+
});
|
|
38
|
+
it('always says something for zero', () => {
|
|
39
|
+
expect(formatDurationSpoken(0)).toBe('0 seconds');
|
|
40
|
+
expect(formatDurationSpoken(NaN)).toBe('0 seconds');
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
describe('getFileExtension', () => {
|
|
44
|
+
it('ignores query strings and fragments', () => {
|
|
45
|
+
expect(getFileExtension('/clip.mp4?token=abc')).toBe('mp4');
|
|
46
|
+
expect(getFileExtension('/clip.MP4#t=10')).toBe('mp4');
|
|
47
|
+
});
|
|
48
|
+
it('returns an empty string when there is no extension', () => {
|
|
49
|
+
expect(getFileExtension('/asset')).toBe('');
|
|
50
|
+
expect(getFileExtension('/asset.')).toBe('');
|
|
51
|
+
expect(getFileExtension('https://example.com/path.with.dots/file')).toBe('');
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
describe('inferMediaKind', () => {
|
|
55
|
+
it('prefers the MIME type over the URL', () => {
|
|
56
|
+
expect(inferMediaKind('/asset', 'image/png')).toBe('image');
|
|
57
|
+
expect(inferMediaKind('/asset.mp4', 'audio/mpeg')).toBe('audio');
|
|
58
|
+
});
|
|
59
|
+
it('falls back to the file extension', () => {
|
|
60
|
+
expect(inferMediaKind('/photo.webp')).toBe('image');
|
|
61
|
+
expect(inferMediaKind('/clip.webm')).toBe('video');
|
|
62
|
+
expect(inferMediaKind('/track.flac')).toBe('audio');
|
|
63
|
+
});
|
|
64
|
+
it('recognises provider URLs as embeds', () => {
|
|
65
|
+
expect(inferMediaKind('https://youtu.be/dQw4w9WgXcQ')).toBe('embed');
|
|
66
|
+
expect(inferMediaKind('https://vimeo.com/123456789')).toBe('embed');
|
|
67
|
+
});
|
|
68
|
+
it('returns null when nothing can be inferred', () => {
|
|
69
|
+
expect(inferMediaKind('/unknown.xyz')).toBeNull();
|
|
70
|
+
expect(inferMediaKind('')).toBeNull();
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
describe('parseEmbedUrl', () => {
|
|
74
|
+
it('parses every common YouTube URL shape', () => {
|
|
75
|
+
const expected = { provider: 'youtube', videoId: 'dQw4w9WgXcQ' };
|
|
76
|
+
expect(parseEmbedUrl('https://www.youtube.com/watch?v=dQw4w9WgXcQ')).toEqual(expected);
|
|
77
|
+
expect(parseEmbedUrl('https://youtu.be/dQw4w9WgXcQ')).toEqual(expected);
|
|
78
|
+
expect(parseEmbedUrl('https://www.youtube.com/embed/dQw4w9WgXcQ')).toEqual(expected);
|
|
79
|
+
expect(parseEmbedUrl('https://www.youtube.com/shorts/dQw4w9WgXcQ')).toEqual(expected);
|
|
80
|
+
expect(parseEmbedUrl('https://www.youtube.com/live/dQw4w9WgXcQ')).toEqual(expected);
|
|
81
|
+
expect(parseEmbedUrl('https://m.youtube.com/watch?v=dQw4w9WgXcQ')).toEqual(expected);
|
|
82
|
+
});
|
|
83
|
+
it('parses Vimeo URLs, including the player and channel forms', () => {
|
|
84
|
+
expect(parseEmbedUrl('https://vimeo.com/123456789')).toEqual({
|
|
85
|
+
provider: 'vimeo',
|
|
86
|
+
videoId: '123456789',
|
|
87
|
+
});
|
|
88
|
+
expect(parseEmbedUrl('https://player.vimeo.com/video/123456789')).toEqual({
|
|
89
|
+
provider: 'vimeo',
|
|
90
|
+
videoId: '123456789',
|
|
91
|
+
});
|
|
92
|
+
expect(parseEmbedUrl('https://vimeo.com/channels/staffpicks/123456789')).toEqual({
|
|
93
|
+
provider: 'vimeo',
|
|
94
|
+
videoId: '123456789',
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
it('reads the start offset in both numeric and hms form', () => {
|
|
98
|
+
expect(parseEmbedUrl('https://youtu.be/dQw4w9WgXcQ?t=30')?.start).toBe(30);
|
|
99
|
+
expect(parseEmbedUrl('https://youtu.be/dQw4w9WgXcQ?t=1m30s')?.start).toBe(90);
|
|
100
|
+
expect(parseEmbedUrl('https://youtu.be/dQw4w9WgXcQ?t=1h2m3s')?.start).toBe(3723);
|
|
101
|
+
expect(parseEmbedUrl('https://www.youtube.com/watch?v=dQw4w9WgXcQ&start=45')?.start).toBe(45);
|
|
102
|
+
});
|
|
103
|
+
it('rejects malformed ids rather than building a broken embed', () => {
|
|
104
|
+
expect(parseEmbedUrl('https://www.youtube.com/watch?v=short')).toBeNull();
|
|
105
|
+
expect(parseEmbedUrl('https://youtu.be/way-too-long-an-id')).toBeNull();
|
|
106
|
+
expect(parseEmbedUrl('https://vimeo.com/not-a-number')).toBeNull();
|
|
107
|
+
});
|
|
108
|
+
it('returns null for unsupported hosts and empty input', () => {
|
|
109
|
+
expect(parseEmbedUrl('https://example.com/video.mp4')).toBeNull();
|
|
110
|
+
expect(parseEmbedUrl('https://dailymotion.com/video/x123')).toBeNull();
|
|
111
|
+
expect(parseEmbedUrl('')).toBeNull();
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
describe('buildEmbedSrc', () => {
|
|
115
|
+
it('routes YouTube through the no-cookie domain by default', () => {
|
|
116
|
+
expect(buildEmbedSrc('youtube', 'abc')).toContain('youtube-nocookie.com');
|
|
117
|
+
});
|
|
118
|
+
it('uses the standard domain when privacy is turned off', () => {
|
|
119
|
+
const src = buildEmbedSrc('youtube', 'abc', { privacy: false });
|
|
120
|
+
expect(src).toContain('www.youtube.com');
|
|
121
|
+
expect(src).not.toContain('nocookie');
|
|
122
|
+
});
|
|
123
|
+
it('adds autoplay and start parameters only when requested', () => {
|
|
124
|
+
expect(buildEmbedSrc('youtube', 'abc')).not.toContain('autoplay');
|
|
125
|
+
const src = buildEmbedSrc('youtube', 'abc', { autoplay: true, start: 30.7 });
|
|
126
|
+
expect(src).toContain('autoplay=1');
|
|
127
|
+
// Providers reject fractional offsets.
|
|
128
|
+
expect(src).toContain('start=30');
|
|
129
|
+
});
|
|
130
|
+
it('suppresses related videos by default', () => {
|
|
131
|
+
expect(buildEmbedSrc('youtube', 'abc')).toContain('rel=0');
|
|
132
|
+
expect(buildEmbedSrc('youtube', 'abc', { showRelated: true })).not.toContain('rel=0');
|
|
133
|
+
});
|
|
134
|
+
it('builds a Vimeo player URL with do-not-track set', () => {
|
|
135
|
+
const src = buildEmbedSrc('vimeo', '123');
|
|
136
|
+
expect(src).toContain('player.vimeo.com/video/123');
|
|
137
|
+
expect(src).toContain('dnt=1');
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
describe('getEmbedThumbnail', () => {
|
|
141
|
+
it('derives a YouTube thumbnail URL', () => {
|
|
142
|
+
expect(getEmbedThumbnail('youtube', 'abc')).toBe('https://i.ytimg.com/vi/abc/hqdefault.jpg');
|
|
143
|
+
});
|
|
144
|
+
it('returns null for Vimeo, which needs an oEmbed lookup', () => {
|
|
145
|
+
expect(getEmbedThumbnail('vimeo', '123')).toBeNull();
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
describe('getMediaThumbnail', () => {
|
|
149
|
+
const base = { id: '1', thumbnail: undefined };
|
|
150
|
+
it('prefers an explicit thumbnail over anything derived', () => {
|
|
151
|
+
const item = { ...base, kind: 'image', src: '/a.jpg', alt: '', thumbnail: '/t.jpg' };
|
|
152
|
+
expect(getMediaThumbnail(item)).toBe('/t.jpg');
|
|
153
|
+
});
|
|
154
|
+
it('falls back per kind', () => {
|
|
155
|
+
expect(getMediaThumbnail({ ...base, kind: 'image', src: '/a.jpg', alt: '' })).toBe('/a.jpg');
|
|
156
|
+
expect(getMediaThumbnail({ ...base, kind: 'video', src: '/a.mp4', poster: '/p.jpg' })).toBe('/p.jpg');
|
|
157
|
+
expect(getMediaThumbnail({ ...base, kind: 'audio', src: '/a.mp3', artwork: '/w.jpg' })).toBe('/w.jpg');
|
|
158
|
+
expect(getMediaThumbnail({ ...base, kind: 'embed', provider: 'youtube', videoId: 'abc' })).toContain('i.ytimg.com');
|
|
159
|
+
});
|
|
160
|
+
it('returns null when there is nothing to show', () => {
|
|
161
|
+
expect(getMediaThumbnail({ ...base, kind: 'video', src: '/a.mp4' })).toBeNull();
|
|
162
|
+
expect(getMediaThumbnail({ ...base, kind: 'audio', src: '/a.mp3' })).toBeNull();
|
|
163
|
+
expect(getMediaThumbnail({ ...base, kind: 'embed', provider: 'vimeo', videoId: '1' })).toBeNull();
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
describe('getMediaLabel', () => {
|
|
167
|
+
it('prefers the title, then image alt text', () => {
|
|
168
|
+
expect(getMediaLabel({ id: '1', kind: 'image', src: '/a.jpg', alt: 'A cat', title: 'Cat' })).toBe('Cat');
|
|
169
|
+
expect(getMediaLabel({ id: '1', kind: 'image', src: '/a.jpg', alt: 'A cat' })).toBe('A cat');
|
|
170
|
+
});
|
|
171
|
+
it('falls back to a positional name so a tile is never unlabelled', () => {
|
|
172
|
+
expect(getMediaLabel({ id: '1', kind: 'video', src: '/a.mp4' }, 2)).toBe('Video 3');
|
|
173
|
+
expect(getMediaLabel({ id: '1', kind: 'video', src: '/a.mp4' })).toBe('Untitled video');
|
|
174
|
+
});
|
|
175
|
+
it('describes embeds as videos rather than "embed"', () => {
|
|
176
|
+
expect(getMediaLabel({ id: '1', kind: 'embed', provider: 'youtube', videoId: 'a' }, 0)).toBe('Video 1');
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
describe('isTimeBasedMedia', () => {
|
|
180
|
+
it('is true for anything with a timeline', () => {
|
|
181
|
+
expect(isTimeBasedMedia({ id: '1', kind: 'video', src: '/a.mp4' })).toBe(true);
|
|
182
|
+
expect(isTimeBasedMedia({ id: '1', kind: 'audio', src: '/a.mp3' })).toBe(true);
|
|
183
|
+
expect(isTimeBasedMedia({ id: '1', kind: 'embed', provider: 'vimeo', videoId: '1' })).toBe(true);
|
|
184
|
+
expect(isTimeBasedMedia({ id: '1', kind: 'image', src: '/a.jpg', alt: '' })).toBe(false);
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
describe('toMediaSources', () => {
|
|
188
|
+
it('passes an explicit source list through untouched', () => {
|
|
189
|
+
const sources = [{ src: '/a.webm', type: 'video/webm' }];
|
|
190
|
+
expect(toMediaSources(sources)).toBe(sources);
|
|
191
|
+
});
|
|
192
|
+
it('wraps a single URL and guesses its MIME type', () => {
|
|
193
|
+
expect(toMediaSources('/a.mp4')).toEqual([{ src: '/a.mp4', type: 'video/mp4' }]);
|
|
194
|
+
expect(toMediaSources('/a.mp3')).toEqual([{ src: '/a.mp3', type: 'audio/mpeg' }]);
|
|
195
|
+
});
|
|
196
|
+
it('omits the type when the extension is unknown', () => {
|
|
197
|
+
expect(toMediaSources('/stream')).toEqual([{ src: '/stream', type: undefined }]);
|
|
198
|
+
});
|
|
199
|
+
});
|
|
200
|
+
describe('resolveAspectRatio', () => {
|
|
201
|
+
it('maps named ratios to numbers and passes numbers through', () => {
|
|
202
|
+
expect(resolveAspectRatio('square')).toBe(1);
|
|
203
|
+
expect(resolveAspectRatio('video')).toBeCloseTo(16 / 9);
|
|
204
|
+
expect(resolveAspectRatio(2.35)).toBe(2.35);
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
describe('timeRangesToArray', () => {
|
|
208
|
+
it('flattens the opaque TimeRanges object into plain pairs', () => {
|
|
209
|
+
const ranges = {
|
|
210
|
+
length: 2,
|
|
211
|
+
start: (i) => [0, 30][i],
|
|
212
|
+
end: (i) => [10, 45][i],
|
|
213
|
+
};
|
|
214
|
+
expect(timeRangesToArray(ranges)).toEqual([
|
|
215
|
+
{ start: 0, end: 10 },
|
|
216
|
+
{ start: 30, end: 45 },
|
|
217
|
+
]);
|
|
218
|
+
});
|
|
219
|
+
it('handles a null ranges object', () => {
|
|
220
|
+
expect(timeRangesToArray(null)).toEqual([]);
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
describe('isHlsSource', () => {
|
|
224
|
+
it('detects HLS by declared MIME type', () => {
|
|
225
|
+
expect(isHlsSource({ src: '/stream', type: 'application/vnd.apple.mpegurl' })).toBe(true);
|
|
226
|
+
expect(isHlsSource({ src: '/stream', type: 'application/x-mpegURL' })).toBe(true);
|
|
227
|
+
});
|
|
228
|
+
it('detects HLS by extension when no type is declared', () => {
|
|
229
|
+
expect(isHlsSource('/manifest/video.m3u8')).toBe(true);
|
|
230
|
+
expect(isHlsSource('/manifest/video.m3u8?protocol=llhls')).toBe(true);
|
|
231
|
+
});
|
|
232
|
+
it('trusts an explicit type over the extension', () => {
|
|
233
|
+
// A progressive MP4 must not be routed through hls.js just because the
|
|
234
|
+
// URL happens to mention a playlist.
|
|
235
|
+
expect(isHlsSource({ src: '/video.m3u8', type: 'video/mp4' })).toBe(false);
|
|
236
|
+
});
|
|
237
|
+
it('is false for ordinary files', () => {
|
|
238
|
+
expect(isHlsSource('/clip.mp4')).toBe(false);
|
|
239
|
+
expect(isHlsSource('/manifest/video.mpd')).toBe(false);
|
|
240
|
+
});
|
|
241
|
+
});
|
|
242
|
+
describe('cloudflareStreamHost', () => {
|
|
243
|
+
it('builds the customer subdomain', () => {
|
|
244
|
+
expect(cloudflareStreamHost({ customerCode: 'abc123' })).toBe('customer-abc123.cloudflarestream.com');
|
|
245
|
+
});
|
|
246
|
+
it('prefers a custom domain and strips scheme and trailing slash', () => {
|
|
247
|
+
expect(cloudflareStreamHost({ domain: 'https://video.example.com/', customerCode: 'abc' })).toBe('video.example.com');
|
|
248
|
+
});
|
|
249
|
+
it('returns null when the account cannot be resolved', () => {
|
|
250
|
+
expect(cloudflareStreamHost({})).toBeNull();
|
|
251
|
+
});
|
|
252
|
+
});
|
|
253
|
+
describe('cloudflareStreamUrls', () => {
|
|
254
|
+
const account = { customerCode: 'abc123' };
|
|
255
|
+
it('builds every playback URL from the video id', () => {
|
|
256
|
+
const urls = cloudflareStreamUrls('a1b2c3', account);
|
|
257
|
+
const base = 'https://customer-abc123.cloudflarestream.com/a1b2c3';
|
|
258
|
+
expect(urls.hls).toBe(`${base}/manifest/video.m3u8`);
|
|
259
|
+
expect(urls.dash).toBe(`${base}/manifest/video.mpd`);
|
|
260
|
+
expect(urls.iframe).toBe(`${base}/iframe`);
|
|
261
|
+
expect(urls.thumbnail).toBe(`${base}/thumbnails/thumbnail.jpg`);
|
|
262
|
+
expect(urls.download).toBe(`${base}/downloads/default.mp4`);
|
|
263
|
+
});
|
|
264
|
+
it('requests the low-latency variant on demand', () => {
|
|
265
|
+
expect(cloudflareStreamUrls('a1b2c3', { ...account, lowLatency: true }).hls).toContain('protocol=llhls');
|
|
266
|
+
expect(cloudflareStreamUrls('a1b2c3', account).hls).not.toContain('protocol');
|
|
267
|
+
});
|
|
268
|
+
it('passes a bandwidth hint through', () => {
|
|
269
|
+
expect(cloudflareStreamUrls('a1b2c3', { ...account, clientBandwidthHint: 3.5 }).hls).toContain('clientBandwidthHint=3.5');
|
|
270
|
+
});
|
|
271
|
+
it('accepts a signed token in place of the video id', () => {
|
|
272
|
+
// Stream substitutes the whole JWT wherever the UID would go, so no
|
|
273
|
+
// separate code path should be needed for private videos.
|
|
274
|
+
const token = 'eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJhMWIyYzMifQ.signature';
|
|
275
|
+
expect(cloudflareStreamUrls(token, account).hls).toBe(`https://customer-abc123.cloudflarestream.com/${token}/manifest/video.m3u8`);
|
|
276
|
+
});
|
|
277
|
+
it('returns null without an account or a video id', () => {
|
|
278
|
+
expect(cloudflareStreamUrls('a1b2c3', {})).toBeNull();
|
|
279
|
+
expect(cloudflareStreamUrls('', account)).toBeNull();
|
|
280
|
+
});
|
|
281
|
+
});
|
|
282
|
+
describe('cloudflareStreamThumbnail', () => {
|
|
283
|
+
const account = { customerCode: 'abc123' };
|
|
284
|
+
it('builds a still thumbnail with its options', () => {
|
|
285
|
+
expect(cloudflareStreamThumbnail('a1b2c3', { ...account, time: '1s', height: 600 })).toBe('https://customer-abc123.cloudflarestream.com/a1b2c3/thumbnails/thumbnail.jpg?time=1s&height=600');
|
|
286
|
+
});
|
|
287
|
+
it('supports width and fit', () => {
|
|
288
|
+
const url = cloudflareStreamThumbnail('a1b2c3', { ...account, width: 320, fit: 'scale' });
|
|
289
|
+
expect(url).toContain('width=320');
|
|
290
|
+
expect(url).toContain('fit=scale');
|
|
291
|
+
});
|
|
292
|
+
it('builds an animated thumbnail with its extra options', () => {
|
|
293
|
+
const url = cloudflareStreamThumbnail('a1b2c3', {
|
|
294
|
+
...account,
|
|
295
|
+
animated: true,
|
|
296
|
+
duration: '4s',
|
|
297
|
+
fps: 8,
|
|
298
|
+
});
|
|
299
|
+
expect(url).toContain('/thumbnails/thumbnail.gif');
|
|
300
|
+
expect(url).toContain('duration=4s');
|
|
301
|
+
expect(url).toContain('fps=8');
|
|
302
|
+
});
|
|
303
|
+
it('omits GIF-only options from a still thumbnail', () => {
|
|
304
|
+
const url = cloudflareStreamThumbnail('a1b2c3', { ...account, duration: '4s', fps: 8 });
|
|
305
|
+
expect(url).toContain('thumbnail.jpg');
|
|
306
|
+
expect(url).not.toContain('duration');
|
|
307
|
+
expect(url).not.toContain('fps');
|
|
308
|
+
});
|
|
309
|
+
it('returns null without an account', () => {
|
|
310
|
+
expect(cloudflareStreamThumbnail('a1b2c3', {})).toBeNull();
|
|
311
|
+
});
|
|
312
|
+
});
|
|
313
|
+
describe('cloudflareStream', () => {
|
|
314
|
+
const account = { customerCode: 'abc123' };
|
|
315
|
+
it('builds a playable video item with a derived poster', () => {
|
|
316
|
+
const item = cloudflareStream('a1b2c3', { ...account, title: 'Product tour' });
|
|
317
|
+
expect(item.kind).toBe('video');
|
|
318
|
+
expect(item.id).toBe('a1b2c3');
|
|
319
|
+
expect(item.title).toBe('Product tour');
|
|
320
|
+
// Typed as HLS so the player routes it through hls.js where needed.
|
|
321
|
+
expect(item.src).toEqual([
|
|
322
|
+
{
|
|
323
|
+
src: 'https://customer-abc123.cloudflarestream.com/a1b2c3/manifest/video.m3u8',
|
|
324
|
+
type: 'application/vnd.apple.mpegurl',
|
|
325
|
+
},
|
|
326
|
+
]);
|
|
327
|
+
expect(item.poster).toContain('/thumbnails/thumbnail.jpg?time=1s&height=720');
|
|
328
|
+
// The thumbnail is reused for grids and lightbox strips.
|
|
329
|
+
expect(item.thumbnail).toBe(item.poster);
|
|
330
|
+
});
|
|
331
|
+
it('honours an explicit poster and poster timing', () => {
|
|
332
|
+
expect(cloudflareStream('a1b2c3', { ...account, poster: '/own.jpg' }).poster).toBe('/own.jpg');
|
|
333
|
+
expect(cloudflareStream('a1b2c3', { ...account, posterTime: '30s', posterHeight: 400 }).poster).toContain('time=30s&height=400');
|
|
334
|
+
});
|
|
335
|
+
it('only exposes a download URL when downloads are requested', () => {
|
|
336
|
+
expect(cloudflareStream('a1b2c3', account).downloadUrl).toBeUndefined();
|
|
337
|
+
expect(cloudflareStream('a1b2c3', { ...account, downloadable: true }).downloadUrl).toContain('/downloads/default.mp4');
|
|
338
|
+
});
|
|
339
|
+
it('throws a directed error when the account is missing', () => {
|
|
340
|
+
expect(() => cloudflareStream('a1b2c3', {})).toThrow(/customerCode/);
|
|
341
|
+
});
|
|
342
|
+
it('produces an item the media helpers understand', () => {
|
|
343
|
+
const item = cloudflareStream('a1b2c3', { ...account, title: 'Tour' });
|
|
344
|
+
expect(isTimeBasedMedia(item)).toBe(true);
|
|
345
|
+
expect(getMediaLabel(item, 0)).toBe('Tour');
|
|
346
|
+
expect(getMediaThumbnail(item)).toBe(item.poster);
|
|
347
|
+
expect(isHlsSource(toMediaSources(item.src)[0])).toBe(true);
|
|
348
|
+
});
|
|
349
|
+
});
|
|
350
|
+
describe('parseEmbedUrl - Cloudflare Stream', () => {
|
|
351
|
+
it('parses every Stream asset URL shape', () => {
|
|
352
|
+
const expected = { provider: 'cloudflare', videoId: 'a1b2c3', customerCode: 'abc' };
|
|
353
|
+
expect(parseEmbedUrl('https://customer-abc.cloudflarestream.com/a1b2c3/iframe')).toEqual(expected);
|
|
354
|
+
expect(parseEmbedUrl('https://customer-abc.cloudflarestream.com/a1b2c3/watch')).toEqual(expected);
|
|
355
|
+
expect(parseEmbedUrl('https://customer-abc.cloudflarestream.com/a1b2c3/manifest/video.m3u8')).toEqual(expected);
|
|
356
|
+
});
|
|
357
|
+
it('reads the Stream player start offset', () => {
|
|
358
|
+
expect(parseEmbedUrl('https://customer-abc.cloudflarestream.com/a1b2c3/iframe?startTime=1m30s')
|
|
359
|
+
?.start).toBe(90);
|
|
360
|
+
});
|
|
361
|
+
it('recognises the legacy videodelivery.net hosts without a customer code', () => {
|
|
362
|
+
expect(parseEmbedUrl('https://iframe.videodelivery.net/a1b2c3')).toEqual({
|
|
363
|
+
provider: 'cloudflare',
|
|
364
|
+
videoId: 'a1b2c3',
|
|
365
|
+
});
|
|
366
|
+
});
|
|
367
|
+
it('accepts a signed token as the id, since it has no fixed shape', () => {
|
|
368
|
+
const token = 'eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJhMWIyYzMifQ.sig';
|
|
369
|
+
expect(parseEmbedUrl(`https://customer-abc.cloudflarestream.com/${token}/iframe`)).toEqual({
|
|
370
|
+
provider: 'cloudflare',
|
|
371
|
+
videoId: token,
|
|
372
|
+
customerCode: 'abc',
|
|
373
|
+
});
|
|
374
|
+
});
|
|
375
|
+
it('rejects a Stream host with no video id', () => {
|
|
376
|
+
expect(parseEmbedUrl('https://customer-abc.cloudflarestream.com/')).toBeNull();
|
|
377
|
+
});
|
|
378
|
+
});
|
|
379
|
+
describe('buildEmbedSrc - Cloudflare Stream', () => {
|
|
380
|
+
const account = { customerCode: 'abc' };
|
|
381
|
+
it('builds the player iframe URL', () => {
|
|
382
|
+
expect(buildEmbedSrc('cloudflare', 'a1b2c3', account)).toBe('https://customer-abc.cloudflarestream.com/a1b2c3/iframe');
|
|
383
|
+
});
|
|
384
|
+
it('mutes automatically when autoplaying, as browsers require', () => {
|
|
385
|
+
const src = buildEmbedSrc('cloudflare', 'a1b2c3', { ...account, autoplay: true });
|
|
386
|
+
expect(src).toContain('autoplay=true');
|
|
387
|
+
expect(src).toContain('muted=true');
|
|
388
|
+
});
|
|
389
|
+
it('maps the remaining player options', () => {
|
|
390
|
+
const src = buildEmbedSrc('cloudflare', 'a1b2c3', {
|
|
391
|
+
...account,
|
|
392
|
+
start: 90,
|
|
393
|
+
loop: true,
|
|
394
|
+
controls: false,
|
|
395
|
+
preload: 'metadata',
|
|
396
|
+
primaryColor: '#c00',
|
|
397
|
+
defaultTextTrack: 'en',
|
|
398
|
+
});
|
|
399
|
+
expect(src).toContain('startTime=90s');
|
|
400
|
+
expect(src).toContain('loop=true');
|
|
401
|
+
expect(src).toContain('controls=false');
|
|
402
|
+
expect(src).toContain('preload=metadata');
|
|
403
|
+
expect(src).toContain(`primaryColor=${encodeURIComponent('#c00')}`);
|
|
404
|
+
expect(src).toContain('defaultTextTrack=en');
|
|
405
|
+
});
|
|
406
|
+
it('throws a directed error when the account is missing', () => {
|
|
407
|
+
expect(() => buildEmbedSrc('cloudflare', 'a1b2c3')).toThrow(/customerCode/);
|
|
408
|
+
});
|
|
409
|
+
});
|
|
410
|
+
describe('getEmbedThumbnail - Cloudflare Stream', () => {
|
|
411
|
+
it('derives a Stream thumbnail, unlike Vimeo', () => {
|
|
412
|
+
expect(getEmbedThumbnail('cloudflare', 'a1b2c3', { customerCode: 'abc' })).toContain('https://customer-abc.cloudflarestream.com/a1b2c3/thumbnails/thumbnail.jpg');
|
|
413
|
+
});
|
|
414
|
+
it('returns null without an account rather than a malformed URL', () => {
|
|
415
|
+
expect(getEmbedThumbnail('cloudflare', 'a1b2c3')).toBeNull();
|
|
416
|
+
});
|
|
417
|
+
});
|
|
418
|
+
describe('clamp', () => {
|
|
419
|
+
it('bounds the value inclusively', () => {
|
|
420
|
+
expect(clamp(5, 0, 10)).toBe(5);
|
|
421
|
+
expect(clamp(-1, 0, 10)).toBe(0);
|
|
422
|
+
expect(clamp(11, 0, 10)).toBe(10);
|
|
423
|
+
});
|
|
424
|
+
it('resolves NaN to the minimum, so a seek never writes NaN to the element', () => {
|
|
425
|
+
expect(clamp(NaN, 0, 10)).toBe(0);
|
|
426
|
+
});
|
|
427
|
+
});
|
package/dist/__tests__/setup.js
CHANGED
|
@@ -4,3 +4,53 @@ import * as matchers from 'vitest-axe/matchers';
|
|
|
4
4
|
expect.extend(matchers);
|
|
5
5
|
// Mock scrollIntoView since jsdom doesn't support it
|
|
6
6
|
Element.prototype.scrollIntoView = vi.fn();
|
|
7
|
+
// jsdom has no layout engine, so scrollTo is a no-op stub
|
|
8
|
+
Element.prototype.scrollTo = vi.fn();
|
|
9
|
+
// jsdom implements no media pipeline: play/pause/load all throw "not
|
|
10
|
+
// implemented" without these stubs, which breaks any Video or Audio test.
|
|
11
|
+
HTMLMediaElement.prototype.play = vi.fn().mockResolvedValue(undefined);
|
|
12
|
+
HTMLMediaElement.prototype.pause = vi.fn();
|
|
13
|
+
HTMLMediaElement.prototype.load = vi.fn();
|
|
14
|
+
// Fullscreen and picture-in-picture are not implemented in jsdom either
|
|
15
|
+
Element.prototype.requestFullscreen = vi.fn().mockResolvedValue(undefined);
|
|
16
|
+
document.exitFullscreen = vi.fn().mockResolvedValue(undefined);
|
|
17
|
+
// Observers used for lazy loading and layout measurement
|
|
18
|
+
if (!('IntersectionObserver' in globalThis)) {
|
|
19
|
+
globalThis.IntersectionObserver = class {
|
|
20
|
+
constructor() {
|
|
21
|
+
this.observe = vi.fn();
|
|
22
|
+
this.unobserve = vi.fn();
|
|
23
|
+
this.disconnect = vi.fn();
|
|
24
|
+
this.takeRecords = vi.fn(() => []);
|
|
25
|
+
this.root = null;
|
|
26
|
+
this.rootMargin = '';
|
|
27
|
+
this.thresholds = [];
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
if (!('ResizeObserver' in globalThis)) {
|
|
32
|
+
globalThis.ResizeObserver = class {
|
|
33
|
+
constructor() {
|
|
34
|
+
this.observe = vi.fn();
|
|
35
|
+
this.unobserve = vi.fn();
|
|
36
|
+
this.disconnect = vi.fn();
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
// jsdom has no canvas backend and logs a "not implemented" notice on every
|
|
41
|
+
// getContext call. Returning null matches what a real browser does when the
|
|
42
|
+
// context type is unsupported, and the callers already handle it.
|
|
43
|
+
HTMLCanvasElement.prototype.getContext = vi.fn(() => null);
|
|
44
|
+
// matchMedia backs useReducedMotion; jsdom does not provide it
|
|
45
|
+
if (!window.matchMedia) {
|
|
46
|
+
window.matchMedia = vi.fn().mockImplementation((query) => ({
|
|
47
|
+
matches: false,
|
|
48
|
+
media: query,
|
|
49
|
+
onchange: null,
|
|
50
|
+
addEventListener: vi.fn(),
|
|
51
|
+
removeEventListener: vi.fn(),
|
|
52
|
+
addListener: vi.fn(),
|
|
53
|
+
removeListener: vi.fn(),
|
|
54
|
+
dispatchEvent: vi.fn(),
|
|
55
|
+
}));
|
|
56
|
+
}
|
|
@@ -96,6 +96,12 @@
|
|
|
96
96
|
* cover the tab. Defaults to 0.
|
|
97
97
|
*/
|
|
98
98
|
tabInset?: number;
|
|
99
|
+
/**
|
|
100
|
+
* Thickness (px) of the collapsed tab — how far it protrudes into the page
|
|
101
|
+
* from its edge. The 56px default fits a title + subtitle; a single-line
|
|
102
|
+
* tab reads better around 36-40.
|
|
103
|
+
*/
|
|
104
|
+
tabThickness?: number;
|
|
99
105
|
/** Disable close button */
|
|
100
106
|
disableClose?: boolean;
|
|
101
107
|
/** Disable resize functionality */
|
|
@@ -141,6 +147,7 @@
|
|
|
141
147
|
cardConfig = {},
|
|
142
148
|
tabWidth = DEFAULT_TAB_WIDTH,
|
|
143
149
|
tabInset = 0,
|
|
150
|
+
tabThickness = undefined,
|
|
144
151
|
disableClose = false,
|
|
145
152
|
disableResize = false,
|
|
146
153
|
disableDrag = false,
|
|
@@ -1169,6 +1176,7 @@
|
|
|
1169
1176
|
{disableClose}
|
|
1170
1177
|
{tabWidth}
|
|
1171
1178
|
{tabInset}
|
|
1179
|
+
{tabThickness}
|
|
1172
1180
|
onclick={handleTabClick}
|
|
1173
1181
|
onClose={handleClose}
|
|
1174
1182
|
offset={internalTabOffset}
|
|
@@ -44,6 +44,12 @@ interface Props {
|
|
|
44
44
|
* cover the tab. Defaults to 0.
|
|
45
45
|
*/
|
|
46
46
|
tabInset?: number;
|
|
47
|
+
/**
|
|
48
|
+
* Thickness (px) of the collapsed tab — how far it protrudes into the page
|
|
49
|
+
* from its edge. The 56px default fits a title + subtitle; a single-line
|
|
50
|
+
* tab reads better around 36-40.
|
|
51
|
+
*/
|
|
52
|
+
tabThickness?: number;
|
|
47
53
|
/** Disable close button */
|
|
48
54
|
disableClose?: boolean;
|
|
49
55
|
/** Disable resize functionality */
|
|
@@ -37,6 +37,13 @@
|
|
|
37
37
|
* to 0 (flush with the edge).
|
|
38
38
|
*/
|
|
39
39
|
tabInset?: number;
|
|
40
|
+
/**
|
|
41
|
+
* Thickness (px) of the tab — how far it protrudes into the page from its
|
|
42
|
+
* edge. The 56px default fits a title + subtitle; a single-line tab reads
|
|
43
|
+
* better around 36-40. Only applies to side tabs; top/bottom tabs stay
|
|
44
|
+
* content-height.
|
|
45
|
+
*/
|
|
46
|
+
tabThickness?: number;
|
|
40
47
|
/** Additional class */
|
|
41
48
|
class?: string;
|
|
42
49
|
}
|
|
@@ -52,6 +59,7 @@
|
|
|
52
59
|
onOffsetChange,
|
|
53
60
|
tabWidth = DEFAULT_TAB_WIDTH,
|
|
54
61
|
tabInset = 0,
|
|
62
|
+
tabThickness = TAB_THICKNESS,
|
|
55
63
|
class: className,
|
|
56
64
|
}: Props = $props();
|
|
57
65
|
|
|
@@ -83,8 +91,10 @@
|
|
|
83
91
|
// Geometry is entirely pixel-based (see getTabTransform): the tab carries an
|
|
84
92
|
// explicit width so nothing has to resolve a percentage against a
|
|
85
93
|
// content-sized box while a rotation is composed onto it.
|
|
86
|
-
const tabTransform = $derived<string>(
|
|
87
|
-
|
|
94
|
+
const tabTransform = $derived<string>(
|
|
95
|
+
getTabTransform(edge, tabWidth, effectiveOffset, tabThickness)
|
|
96
|
+
);
|
|
97
|
+
const tabBounds = $derived(getTabBounds(edge, tabWidth, tabThickness));
|
|
88
98
|
|
|
89
99
|
// Non-reactive drag bookkeeping.
|
|
90
100
|
let drag = { startX: 0, startY: 0, startOffset: 0, moved: false, pointerId: -1 };
|
|
@@ -210,7 +220,7 @@
|
|
|
210
220
|
style={`${insetStyle} width: ${tabWidth}px; ${
|
|
211
221
|
/* Side tabs rotate, so they need a fixed thickness for a deterministic edge
|
|
212
222
|
hug; top/bottom stay content-height. */
|
|
213
|
-
isVertical ? `height: ${
|
|
223
|
+
isVertical ? `height: ${tabThickness}px; ` : ''
|
|
214
224
|
}transform-origin: 0 0; transform: ${tabTransform};`}
|
|
215
225
|
onpointerdown={handlePointerDown}
|
|
216
226
|
onpointermove={draggable ? handlePointerMove : undefined}
|
|
@@ -220,14 +230,34 @@
|
|
|
220
230
|
<button
|
|
221
231
|
type="button"
|
|
222
232
|
aria-label={`Expand ${title} panel`}
|
|
223
|
-
class="flex min-w-0 flex-1
|
|
233
|
+
class="flex min-w-0 flex-1 items-center gap-1.5 text-left leading-tight hover:opacity-80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
224
234
|
onclick={draggable ? undefined : onclick}
|
|
225
235
|
onkeydown={handleExpandKeyDown}
|
|
226
236
|
>
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
237
|
+
<!-- Expand chevron, pointing into the page (the direction the panel opens).
|
|
238
|
+
The chip rotates with its edge, so the pre-rotation "up" chevron lands
|
|
239
|
+
correctly on side edges; only a top tab needs flipping. -->
|
|
240
|
+
<svg
|
|
241
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
242
|
+
width="14"
|
|
243
|
+
height="14"
|
|
244
|
+
viewBox="0 0 24 24"
|
|
245
|
+
fill="none"
|
|
246
|
+
stroke="currentColor"
|
|
247
|
+
stroke-width="2"
|
|
248
|
+
stroke-linecap="round"
|
|
249
|
+
stroke-linejoin="round"
|
|
250
|
+
aria-hidden="true"
|
|
251
|
+
class={cn('shrink-0 text-muted-foreground', edge === 'top' && 'rotate-180')}
|
|
252
|
+
>
|
|
253
|
+
<path d="m18 15-6-6-6 6" />
|
|
254
|
+
</svg>
|
|
255
|
+
<span class="flex min-w-0 flex-col">
|
|
256
|
+
<span class="truncate text-sm font-semibold">{title}</span>
|
|
257
|
+
{#if subtitle}
|
|
258
|
+
<span class="truncate text-xs text-muted-foreground">{subtitle}</span>
|
|
259
|
+
{/if}
|
|
260
|
+
</span>
|
|
231
261
|
</button>
|
|
232
262
|
|
|
233
263
|
{#if !disableClose && onClose}
|
|
@@ -27,6 +27,13 @@ interface Props {
|
|
|
27
27
|
* to 0 (flush with the edge).
|
|
28
28
|
*/
|
|
29
29
|
tabInset?: number;
|
|
30
|
+
/**
|
|
31
|
+
* Thickness (px) of the tab — how far it protrudes into the page from its
|
|
32
|
+
* edge. The 56px default fits a title + subtitle; a single-line tab reads
|
|
33
|
+
* better around 36-40. Only applies to side tabs; top/bottom tabs stay
|
|
34
|
+
* content-height.
|
|
35
|
+
*/
|
|
36
|
+
tabThickness?: number;
|
|
30
37
|
/** Additional class */
|
|
31
38
|
class?: string;
|
|
32
39
|
}
|