@outbook/webcomponents-player 1.4.7 → 1.5.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.
@@ -0,0 +1,18 @@
1
+ export function isValidTrackIndex({ index, playlist }) {
2
+ if (
3
+ !Number.isInteger(index) ||
4
+ !Array.isArray(playlist) ||
5
+ index < 0 ||
6
+ index >= playlist.length
7
+ ) {
8
+ return false;
9
+ }
10
+
11
+ const track = playlist[index];
12
+ return (
13
+ track &&
14
+ typeof track === 'object' &&
15
+ typeof track.file === 'string' &&
16
+ track.file.trim().length > 0
17
+ );
18
+ }
@@ -0,0 +1,21 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { isValidTrackIndex } from './is-valid-track-index.js';
3
+
4
+ const playlist = [{ file: '/audio/valid.mp3' }, { file: '/audio/other.mp3' }];
5
+
6
+ describe('isValidTrackIndex', () => {
7
+ it('accepts an in-range integer for a track with an audio file', () => {
8
+ expect(isValidTrackIndex({ index: 1, playlist })).toBe(true);
9
+ });
10
+
11
+ it.each([-1, 2, 0.5, '0', NaN])('rejects an invalid index: %s', index => {
12
+ expect(isValidTrackIndex({ index, playlist })).toBe(false);
13
+ });
14
+
15
+ it.each([undefined, null, '', ' ', 42])(
16
+ 'rejects a track without a valid audio file: %s',
17
+ file => {
18
+ expect(isValidTrackIndex({ index: 0, playlist: [{ file }] })).toBe(false);
19
+ }
20
+ );
21
+ });
@@ -1,9 +1,51 @@
1
1
  import { getCover } from './get-cover.js';
2
+ import { isValidTrackIndex } from './is-valid-track-index.js';
2
3
 
3
- export function setupMediaSession({ index, playlist, handleAudio }) {
4
+ const MEDIA_SESSION_ACTIONS = [
5
+ 'play',
6
+ 'pause',
7
+ 'stop',
8
+ 'nexttrack',
9
+ 'previoustrack'
10
+ ];
11
+ let mediaSessionOwner = null;
12
+
13
+ function getMediaSession() {
14
+ return navigator.mediaSession || null;
15
+ }
16
+
17
+ export function isMediaSessionOwner({ owner }) {
18
+ return owner !== null && owner !== undefined && mediaSessionOwner === owner;
19
+ }
20
+
21
+ export function clearMediaSession({ owner }) {
22
+ const mediaSession = getMediaSession();
23
+ if (!mediaSession || !isMediaSessionOwner({ owner })) {
24
+ return false;
25
+ }
26
+
27
+ mediaSession.metadata = null;
28
+ MEDIA_SESSION_ACTIONS.forEach(action => {
29
+ mediaSession.setActionHandler(action, null);
30
+ });
31
+ mediaSessionOwner = null;
32
+ return true;
33
+ }
34
+
35
+ export function setupMediaSession({ owner, index, playlist, handleAudio }) {
36
+ const mediaSession = getMediaSession();
37
+ if (!mediaSession || owner === null || owner === undefined) {
38
+ return false;
39
+ }
40
+
41
+ if (!isValidTrackIndex({ index, playlist })) {
42
+ return false;
43
+ }
44
+
45
+ mediaSessionOwner = owner;
4
46
  const metadata = playlist[index];
5
47
  const type = 'image/jpeg';
6
- navigator.mediaSession.metadata = new MediaMetadata({
48
+ mediaSession.metadata = new MediaMetadata({
7
49
  title: metadata.title,
8
50
  artist: metadata.artist,
9
51
  album: metadata.album,
@@ -15,15 +57,32 @@ export function setupMediaSession({ index, playlist, handleAudio }) {
15
57
  };
16
58
  })
17
59
  });
18
- navigator.mediaSession.setActionHandler(
60
+ mediaSession.setActionHandler(
19
61
  'nexttrack',
20
62
  index < playlist.length - 1 ? handleAudio.next : null
21
63
  );
22
- navigator.mediaSession.setActionHandler(
64
+ mediaSession.setActionHandler(
23
65
  'previoustrack',
24
66
  index > 0 ? handleAudio.previous : null
25
67
  );
26
- navigator.mediaSession.setActionHandler('play', handleAudio.play);
27
- navigator.mediaSession.setActionHandler('pause', handleAudio.pause);
28
- navigator.mediaSession.setActionHandler('stop', handleAudio.stop);
68
+ mediaSession.setActionHandler('play', handleAudio.play);
69
+ mediaSession.setActionHandler('pause', handleAudio.pause);
70
+ mediaSession.setActionHandler('stop', handleAudio.stop);
71
+ return true;
72
+ }
73
+
74
+ export function syncMediaSession({ owner, index, playlist, handleAudio }) {
75
+ if (!getMediaSession()) {
76
+ return () => {};
77
+ }
78
+
79
+ if (index !== null) {
80
+ setupMediaSession({ owner, index, playlist, handleAudio });
81
+ } else {
82
+ clearMediaSession({ owner });
83
+ }
84
+
85
+ return () => {
86
+ clearMediaSession({ owner });
87
+ };
29
88
  }
@@ -0,0 +1,176 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest';
2
+ import {
3
+ clearMediaSession,
4
+ setupMediaSession,
5
+ syncMediaSession
6
+ } from './setup-media-session.js';
7
+
8
+ const originalMediaSession = navigator.mediaSession;
9
+ const originalMediaMetadata = globalThis.MediaMetadata;
10
+ const MEDIA_SESSION_ACTIONS = [
11
+ 'play',
12
+ 'pause',
13
+ 'stop',
14
+ 'nexttrack',
15
+ 'previoustrack'
16
+ ];
17
+
18
+ function createMediaSessionSpy() {
19
+ const actionHandlers = new Map();
20
+ return {
21
+ actionHandlers,
22
+ mediaSession: {
23
+ setActionHandler: vi.fn((action, handler) => {
24
+ actionHandlers.set(action, handler);
25
+ })
26
+ }
27
+ };
28
+ }
29
+
30
+ afterEach(() => {
31
+ Object.defineProperty(navigator, 'mediaSession', {
32
+ configurable: true,
33
+ value: originalMediaSession
34
+ });
35
+ globalThis.MediaMetadata = originalMediaMetadata;
36
+ });
37
+
38
+ describe('setupMediaSession', () => {
39
+ it('does not change Media Session for an invalid track selection', () => {
40
+ const { mediaSession } = createMediaSessionSpy();
41
+ Object.defineProperty(navigator, 'mediaSession', {
42
+ configurable: true,
43
+ value: mediaSession
44
+ });
45
+
46
+ expect(
47
+ setupMediaSession({
48
+ owner: Symbol('player-owner'),
49
+ index: 0,
50
+ playlist: [{ file: '' }],
51
+ handleAudio: {}
52
+ })
53
+ ).toBe(false);
54
+ expect(mediaSession.setActionHandler).not.toHaveBeenCalled();
55
+ expect(mediaSession.metadata).toBeUndefined();
56
+ });
57
+
58
+ it('configures Media Session for a valid track selection', () => {
59
+ const { mediaSession } = createMediaSessionSpy();
60
+ Object.defineProperty(navigator, 'mediaSession', {
61
+ configurable: true,
62
+ value: mediaSession
63
+ });
64
+ globalThis.MediaMetadata = class MediaMetadata {
65
+ constructor(metadata) {
66
+ Object.assign(this, metadata);
67
+ }
68
+ };
69
+
70
+ expect(
71
+ setupMediaSession({
72
+ owner: Symbol('player-owner'),
73
+ index: 0,
74
+ playlist: [{ file: '/audio/valid.mp3', title: 'Valid track' }],
75
+ handleAudio: {
76
+ next: vi.fn(),
77
+ previous: vi.fn(),
78
+ play: vi.fn(),
79
+ pause: vi.fn(),
80
+ stop: vi.fn()
81
+ }
82
+ })
83
+ ).toBe(true);
84
+ expect(mediaSession.metadata.title).toBe('Valid track');
85
+ expect(mediaSession.setActionHandler).toHaveBeenCalledTimes(5);
86
+ });
87
+
88
+ it('only lets the owning player clear Media Session handlers', () => {
89
+ const { actionHandlers, mediaSession } = createMediaSessionSpy();
90
+ const firstOwner = Symbol('first-player');
91
+ const secondOwner = Symbol('second-player');
92
+ const firstHandleAudio = {
93
+ next: vi.fn(),
94
+ previous: vi.fn(),
95
+ play: vi.fn(),
96
+ pause: vi.fn(),
97
+ stop: vi.fn()
98
+ };
99
+ const secondHandleAudio = {
100
+ next: vi.fn(),
101
+ previous: vi.fn(),
102
+ play: vi.fn(),
103
+ pause: vi.fn(),
104
+ stop: vi.fn()
105
+ };
106
+ Object.defineProperty(navigator, 'mediaSession', {
107
+ configurable: true,
108
+ value: mediaSession
109
+ });
110
+ globalThis.MediaMetadata = class MediaMetadata {
111
+ constructor(metadata) {
112
+ Object.assign(this, metadata);
113
+ }
114
+ };
115
+
116
+ setupMediaSession({
117
+ owner: firstOwner,
118
+ index: 0,
119
+ playlist: [{ file: '/audio/first.mp3', title: 'First track' }],
120
+ handleAudio: firstHandleAudio
121
+ });
122
+ setupMediaSession({
123
+ owner: secondOwner,
124
+ index: 0,
125
+ playlist: [{ file: '/audio/second.mp3', title: 'Second track' }],
126
+ handleAudio: secondHandleAudio
127
+ });
128
+
129
+ expect(clearMediaSession({ owner: firstOwner })).toBe(false);
130
+ expect(mediaSession.metadata.title).toBe('Second track');
131
+ expect(actionHandlers.get('play')).toBe(secondHandleAudio.play);
132
+
133
+ expect(clearMediaSession({ owner: secondOwner })).toBe(true);
134
+ expect(mediaSession.metadata).toBeNull();
135
+ MEDIA_SESSION_ACTIONS.forEach(action => {
136
+ expect(actionHandlers.get(action)).toBeNull();
137
+ });
138
+ });
139
+
140
+ it('returns unmount cleanup that clears the owning player session', () => {
141
+ const { actionHandlers, mediaSession } = createMediaSessionSpy();
142
+ const owner = Symbol('player-owner');
143
+ Object.defineProperty(navigator, 'mediaSession', {
144
+ configurable: true,
145
+ value: mediaSession
146
+ });
147
+ globalThis.MediaMetadata = class MediaMetadata {
148
+ constructor(metadata) {
149
+ Object.assign(this, metadata);
150
+ }
151
+ };
152
+
153
+ const cleanup = syncMediaSession({
154
+ owner,
155
+ index: 0,
156
+ playlist: [{ file: '/audio/track.mp3', title: 'Track to clean up' }],
157
+ handleAudio: {
158
+ next: vi.fn(),
159
+ previous: vi.fn(),
160
+ play: vi.fn(),
161
+ pause: vi.fn(),
162
+ stop: vi.fn()
163
+ }
164
+ });
165
+
166
+ expect(mediaSession.metadata.title).toBe('Track to clean up');
167
+
168
+ cleanup();
169
+
170
+ expect(clearMediaSession({ owner })).toBe(false);
171
+ expect(mediaSession.metadata).toBeNull();
172
+ MEDIA_SESSION_ACTIONS.forEach(action => {
173
+ expect(actionHandlers.get(action)).toBeNull();
174
+ });
175
+ });
176
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@outbook/webcomponents-player",
3
- "version": "1.4.7",
3
+ "version": "1.5.0",
4
4
  "main": "player.js",
5
5
  "type": "module",
6
6
  "private": false,
@@ -31,12 +31,12 @@
31
31
  "@outbook/a11y": ">=1.2.1",
32
32
  "color.js": "1.2.0",
33
33
  "custom-event-conjurer": "1.0.0",
34
- "@outbook/format-time": "1.0.4",
34
+ "@outbook/format-time": "1.0.5",
35
35
  "@outbook/webcomponents-scrollable": ">=1.0.2",
36
36
  "@outbook/webcomponents-type-icon": ">=1.1.4",
37
37
  "@outbook/webcomponents-badge-file-extension": ">=1.2.2",
38
38
  "@outbook/webcomponents-player-artwork": ">=1.1.3",
39
- "@outbook/webcomponents-player-timeline": ">=1.0.2",
39
+ "@outbook/webcomponents-player-timeline": ">=1.0.6",
40
40
  "@outbook/icons": ">=1.3.1",
41
41
  "@outbook/webcomponents-player-audio-decoy": ">=1.0.0"
42
42
  },
package/player.js CHANGED
@@ -1,9 +1,14 @@
1
1
  import { adoptStyles, html, nothing } from 'lit';
2
2
  import { ifDefined } from 'lit/directives/if-defined.js';
3
- import { component, useEffect, useState } from 'haunted';
3
+ import { component, useEffect, useRef, useState } from 'haunted';
4
4
  import { customEventConjurer } from 'custom-event-conjurer';
5
5
  import styleComponent from './_style/player.style.js';
6
- import { setupMediaSession } from './_lib/setup-media-session.js';
6
+ import {
7
+ clearMediaSession,
8
+ isMediaSessionOwner,
9
+ syncMediaSession
10
+ } from './_lib/setup-media-session.js';
11
+ import { isValidTrackIndex } from './_lib/is-valid-track-index.js';
7
12
  import { emptyBackground, getBackgroundColor } from './_lib/background.js';
8
13
  import { getCover } from './_lib/get-cover.js';
9
14
  import { classMap } from 'lit/directives/class-map.js';
@@ -58,6 +63,57 @@ function ComponentPlayer(element) {
58
63
  const [prominentColors, setProminentColors] = useState('');
59
64
  const [hasProminentColors, setHasProminentColors] = useState(false);
60
65
  const [coverUrl, setCoverUrl] = useState(null);
66
+ const [playbackRequestId, setPlaybackRequestId] = useState(0);
67
+ const playbackRequestRef = useRef({
68
+ id: 0,
69
+ started: true,
70
+ removeListeners: null
71
+ });
72
+ const selectedTrackRef = useRef(null);
73
+ const mediaSessionOwner = useRef(Symbol('outbook-player-media-session'));
74
+ const playlistMediaSessionSignature = playlist
75
+ .map(track =>
76
+ [
77
+ track?.file,
78
+ track?.title,
79
+ track?.artist,
80
+ track?.album,
81
+ track?.cover?.path,
82
+ track?.cover?.name,
83
+ track?.cover?.extension
84
+ ].join('\u0000')
85
+ )
86
+ .join('\u0001');
87
+
88
+ function setMediaSessionPlaybackState(playbackState) {
89
+ if (
90
+ navigator.mediaSession &&
91
+ isMediaSessionOwner({ owner: mediaSessionOwner.current })
92
+ ) {
93
+ navigator.mediaSession.playbackState = playbackState;
94
+ }
95
+ }
96
+
97
+ function cancelPendingInitialPlayback() {
98
+ const request = playbackRequestRef.current;
99
+ request.removeListeners?.();
100
+ playbackRequestRef.current = {
101
+ id: request.id + 1,
102
+ started: true,
103
+ removeListeners: null
104
+ };
105
+ return playbackRequestRef.current.id;
106
+ }
107
+
108
+ function requestInitialPlayback() {
109
+ const requestId = cancelPendingInitialPlayback();
110
+ playbackRequestRef.current = {
111
+ id: requestId,
112
+ started: false,
113
+ removeListeners: null
114
+ };
115
+ setPlaybackRequestId(requestId);
116
+ }
61
117
 
62
118
  useEffect(() => {
63
119
  let isActive = true;
@@ -94,30 +150,101 @@ function ComponentPlayer(element) {
94
150
  }, []);
95
151
 
96
152
  useEffect(() => {
97
- if (navigator.mediaSession && indexSelected !== null) {
98
- setupMediaSession({
99
- index: indexSelected,
100
- playlist,
101
- handleAudio: {
102
- stop: handleAudio.stop,
103
- play: handleAudio.play,
104
- pause: handleSessionPause,
105
- next: handleSessionNext,
106
- previous: handleSessionPrevious
107
- }
108
- });
109
- navigator.mediaSession.setActionHandler('nexttrack', () => {
110
- handleSessionNext();
111
- });
112
- navigator.mediaSession.setActionHandler('previoustrack', () => {
113
- handleSessionPrevious();
114
- });
115
- } else if (navigator.mediaSession) {
116
- navigator.mediaSession.metadata = null;
117
- navigator.mediaSession.setActionHandler('nexttrack', null);
118
- navigator.mediaSession.setActionHandler('previoustrack', null);
153
+ if (!Number.isInteger(indexSelected)) {
154
+ return undefined;
155
+ }
156
+
157
+ const request = playbackRequestRef.current;
158
+ if (request.id !== playbackRequestId || request.started) {
159
+ return undefined;
160
+ }
161
+
162
+ const mediaElement = getMediaElement();
163
+ if (!mediaElement) {
164
+ return undefined;
165
+ }
166
+
167
+ const playInitial = () => {
168
+ if (playbackRequestRef.current !== request || request.started) {
169
+ return;
170
+ }
171
+
172
+ request.started = true;
173
+ request.removeListeners?.();
174
+ request.removeListeners = null;
175
+ mediaElement.currentTime = 0;
176
+ setCurrentTime(0);
177
+ playAudio(mediaElement);
178
+ };
179
+
180
+ if (mediaElement.readyState > 0) {
181
+ playInitial();
182
+ return undefined;
183
+ }
184
+
185
+ const removeListeners = () => {
186
+ mediaElement.removeEventListener('loadedmetadata', playInitial);
187
+ mediaElement.removeEventListener('canplay', playInitial);
188
+ };
189
+ request.removeListeners = removeListeners;
190
+ mediaElement.addEventListener('loadedmetadata', playInitial, {
191
+ once: true
192
+ });
193
+ mediaElement.addEventListener('canplay', playInitial, { once: true });
194
+
195
+ return () => {
196
+ if (playbackRequestRef.current === request) {
197
+ removeListeners();
198
+ request.removeListeners = null;
199
+ }
200
+ };
201
+ }, [indexSelected, playbackRequestId]);
202
+
203
+ useEffect(() => () => cancelPendingInitialPlayback(), []);
204
+
205
+ useEffect(() => {
206
+ if (!Number.isInteger(indexSelected)) {
207
+ selectedTrackRef.current = null;
208
+ return;
209
+ }
210
+
211
+ const selectedTrack = playlist[indexSelected];
212
+ if (
213
+ !isValidTrackIndex({ index: indexSelected, playlist }) ||
214
+ selectedTrackRef.current?.file !== selectedTrack.file
215
+ ) {
216
+ handleSessionStop();
217
+ return;
218
+ }
219
+
220
+ selectedTrackRef.current = selectedTrack;
221
+ setCoverUrl(
222
+ selectedTrack.cover?.name ? getCover(selectedTrack.cover) : null
223
+ );
224
+ }, [indexSelected, playlist, playlistMediaSessionSignature]);
225
+
226
+ useEffect(() => {
227
+ if (
228
+ indexSelected !== null &&
229
+ (!isValidTrackIndex({ index: indexSelected, playlist }) ||
230
+ selectedTrackRef.current?.file !== playlist[indexSelected].file)
231
+ ) {
232
+ return undefined;
119
233
  }
120
- }, [indexSelected]);
234
+
235
+ return syncMediaSession({
236
+ owner: mediaSessionOwner.current,
237
+ index: indexSelected,
238
+ playlist,
239
+ handleAudio: {
240
+ stop: handleAudio.stop,
241
+ play: handleAudio.play,
242
+ pause: handleSessionPause,
243
+ next: handleSessionNext,
244
+ previous: handleSessionPrevious
245
+ }
246
+ });
247
+ }, [indexSelected, playlist, playlistMediaSessionSignature]);
121
248
 
122
249
  useEffect(() => {
123
250
  let isMounted = true;
@@ -142,25 +269,54 @@ function ComponentPlayer(element) {
142
269
  }
143
270
 
144
271
  function handleSessionPause() {
145
- getMediaElement().pause();
146
- if (navigator.mediaSession) {
147
- navigator.mediaSession.playbackState = 'paused';
272
+ cancelPendingInitialPlayback();
273
+ const media = getMediaElement();
274
+ if (!media) {
275
+ return;
148
276
  }
277
+ media.pause();
149
278
  setIsPlaying(false);
279
+ setMediaSessionPlaybackState('paused');
150
280
  }
151
281
 
152
- function handleSessionPlay() {
153
- getMediaElement().play();
154
- setIsPlaying(true);
155
- if (navigator.mediaSession) {
156
- navigator.mediaSession.playbackState = 'playing';
282
+ async function playAudio(mediaElement) {
283
+ try {
284
+ await mediaElement.play();
285
+ } catch (err) {
286
+ console.error('Playback failed:', err);
287
+ setIsPlaying(false);
288
+ setMediaSessionPlaybackState('paused');
157
289
  }
158
290
  }
159
291
 
160
- function handleSessionStop() {
161
- if (navigator.mediaSession) {
162
- navigator.mediaSession.playbackState = 'none';
292
+ async function handleSessionPlay() {
293
+ const media = getMediaElement();
294
+ if (!media) {
295
+ return;
163
296
  }
297
+ await playAudio(media);
298
+ }
299
+
300
+ function handleAudioNativePlay() {
301
+ setIsPlaying(true);
302
+ setMediaSessionPlaybackState('playing');
303
+ }
304
+
305
+ function handleAudioNativePause() {
306
+ setIsPlaying(false);
307
+ setMediaSessionPlaybackState('paused');
308
+ }
309
+
310
+ function handleAudioNativeError(ev) {
311
+ cancelPendingInitialPlayback();
312
+ console.error('Audio error:', ev.currentTarget?.error);
313
+ setIsPlaying(false);
314
+ setMediaSessionPlaybackState('paused');
315
+ }
316
+
317
+ function handleSessionStop() {
318
+ cancelPendingInitialPlayback();
319
+ setMediaSessionPlaybackState('none');
164
320
 
165
321
  const media = getMediaElement();
166
322
 
@@ -173,12 +329,10 @@ function ComponentPlayer(element) {
173
329
  setCurrentTime(0); // Set zero on stop
174
330
  setHasProminentColors(false);
175
331
  setIsPlaying(false);
176
-
177
- if (navigator.mediaSession) {
178
- navigator.mediaSession.metadata = null;
179
- }
332
+ clearMediaSession({ owner: mediaSessionOwner.current });
180
333
 
181
334
  setIndexSelected(null);
335
+ selectedTrackRef.current = null;
182
336
  setCoverUrl(null);
183
337
  }
184
338
 
@@ -198,43 +352,31 @@ function ComponentPlayer(element) {
198
352
  next: handleSessionNext,
199
353
  previous: handleSessionPrevious,
200
354
  setTrack({ index, fromControls = false }) {
355
+ if (!isValidTrackIndex({ index, playlist })) {
356
+ return false;
357
+ }
358
+
359
+ const media = getMediaElement();
360
+ if (media) {
361
+ media.pause();
362
+ media.currentTime = 0;
363
+ }
364
+ setTotalTime(0);
365
+ setCurrentTime(0);
201
366
  setIndexSelected(index);
367
+ selectedTrackRef.current = playlist[index];
202
368
  if (fromControls) {
203
369
  handleScroll({ index });
204
370
  }
205
371
 
206
- if (playlist[index]?.cover?.name) {
207
- setCoverUrl(getCover(playlist[index]?.cover));
208
- } else {
209
- setCoverUrl(null);
210
- }
211
- setTimeout(() => {
212
- this.playInitial();
213
- }, 100);
372
+ setCoverUrl(
373
+ playlist[index].cover?.name ? getCover(playlist[index].cover) : null
374
+ );
375
+ requestInitialPlayback();
376
+ return true;
214
377
  },
215
378
  stop: handleSessionStop,
216
379
  play: handleSessionPlay,
217
- playInitial() {
218
- const mediaElement = getMediaElement();
219
- if (!mediaElement) {
220
- console.warn('Audio element not ready yet, retrying...');
221
- setTimeout(() => this.playInitial(), 50);
222
- return;
223
- }
224
- mediaElement.currentTime = 0;
225
- setCurrentTime(0); // Set zero initial time
226
-
227
- mediaElement
228
- .play()
229
- .then(function () {
230
- setTotalTime(mediaElement.duration);
231
- if (navigator.mediaSession) {
232
- navigator.mediaSession.playbackState = 'playing';
233
- }
234
- setIsPlaying(true);
235
- })
236
- .catch(err => console.error('Autoplay blocked:', err));
237
- },
238
380
  pause: handleSessionPause
239
381
  };
240
382
 
@@ -251,13 +393,25 @@ function ComponentPlayer(element) {
251
393
  setCurrentTime(media.currentTime);
252
394
  }
253
395
 
396
+ function handleDurationChange(ev) {
397
+ const duration = ev.currentTarget.duration;
398
+ setTotalTime(Number.isFinite(duration) && duration > 0 ? duration : 0);
399
+ }
400
+
254
401
  function handleReposition(ev) {
255
402
  const newTime = ev.detail.currentTime;
256
403
  const media = getMediaElement();
257
- if (media) {
258
- media.currentTime = newTime;
404
+ if (
405
+ !media ||
406
+ !Number.isFinite(totalTime) ||
407
+ totalTime <= 0 ||
408
+ !Number.isFinite(newTime)
409
+ ) {
410
+ return;
259
411
  }
260
- setCurrentTime(newTime);
412
+ const safeTime = Math.max(0, Math.min(newTime, totalTime));
413
+ media.currentTime = safeTime;
414
+ setCurrentTime(safeTime);
261
415
  }
262
416
 
263
417
  const mainClasses = classMap({
@@ -319,6 +473,11 @@ function ComponentPlayer(element) {
319
473
  id="${mediaId}"
320
474
  @ended="${handleAudioEnd}"
321
475
  @timeupdate="${handleTimeUpdate}"
476
+ @loadedmetadata="${handleDurationChange}"
477
+ @durationchange="${handleDurationChange}"
478
+ @play="${handleAudioNativePlay}"
479
+ @pause="${handleAudioNativePause}"
480
+ @error="${handleAudioNativeError}"
322
481
  ></audio>
323
482
  `
324
483
  : nothing}