@outbook/webcomponents-player 1.4.6 → 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
+ });
@@ -2,7 +2,7 @@ import { html } from 'lit';
2
2
  import {
3
3
  readable as formatTime,
4
4
  iso as formatTimeDuration
5
- } from 'mystic-format-time';
5
+ } from '@outbook/format-time';
6
6
 
7
7
  export function ItemDuration({ duration }) {
8
8
  return html`
@@ -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.6",
3
+ "version": "1.5.0",
4
4
  "main": "player.js",
5
5
  "type": "module",
6
6
  "private": false,
@@ -31,19 +31,18 @@
31
31
  "@outbook/a11y": ">=1.2.1",
32
32
  "color.js": "1.2.0",
33
33
  "custom-event-conjurer": "1.0.0",
34
- "mystic-format-time": "1.0.2",
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
- "@outbook/webcomponents-player-artwork": ">=1.1.2",
39
- "@outbook/webcomponents-player-timeline": ">=1.0.2",
40
- "@outbook/icons": ">=1.3.1"
38
+ "@outbook/webcomponents-player-artwork": ">=1.1.3",
39
+ "@outbook/webcomponents-player-timeline": ">=1.0.6",
40
+ "@outbook/icons": ">=1.3.1",
41
+ "@outbook/webcomponents-player-audio-decoy": ">=1.0.0"
41
42
  },
42
43
  "devDependencies": {
43
44
  "@outbook/colorful": ">=1.1.4",
44
- "@outbook/design-decisions": ">=1.1.7",
45
- "@outbook/webcomponents-player-artwork": ">=1.0.0",
46
- "@outbook/webcomponents-player-timeline": ">=1.0.4"
45
+ "@outbook/design-decisions": ">=1.1.9"
47
46
  },
48
47
  "peerDependencies": {
49
48
  "lit": "^3.0.0",
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';
@@ -16,6 +21,7 @@ import { loadLiterals } from './_i18n/i18n.js';
16
21
  import _literals from './_i18n/en.json';
17
22
  import { useLangObserver } from '@outbook/hooks';
18
23
  import { toBoolean } from './_lib/to-boolean.js';
24
+ import { PlayerAudioDecoy } from '@outbook/webcomponents-player-audio-decoy';
19
25
 
20
26
  function ComponentPlayer(element) {
21
27
  const {
@@ -57,6 +63,57 @@ function ComponentPlayer(element) {
57
63
  const [prominentColors, setProminentColors] = useState('');
58
64
  const [hasProminentColors, setHasProminentColors] = useState(false);
59
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
+ }
60
117
 
61
118
  useEffect(() => {
62
119
  let isActive = true;
@@ -93,22 +150,101 @@ function ComponentPlayer(element) {
93
150
  }, []);
94
151
 
95
152
  useEffect(() => {
96
- if (navigator.mediaSession && indexSelected !== null) {
97
- setupMediaSession({
98
- index: indexSelected,
99
- playlist,
100
- handleAudio: {
101
- stop: handleAudio.stop,
102
- play: handleAudio.play,
103
- pause: handleSessionPause,
104
- next: handleSessionNext,
105
- previous: handleSessionPrevious
106
- }
107
- });
108
- } else if (navigator.mediaSession) {
109
- navigator.mediaSession.metadata = null;
153
+ if (!Number.isInteger(indexSelected)) {
154
+ return undefined;
110
155
  }
111
- }, [indexSelected]);
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;
233
+ }
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]);
112
248
 
113
249
  useEffect(() => {
114
250
  let isMounted = true;
@@ -133,25 +269,54 @@ function ComponentPlayer(element) {
133
269
  }
134
270
 
135
271
  function handleSessionPause() {
136
- getMediaElement().pause();
137
- if (navigator.mediaSession) {
138
- navigator.mediaSession.playbackState = 'paused';
272
+ cancelPendingInitialPlayback();
273
+ const media = getMediaElement();
274
+ if (!media) {
275
+ return;
139
276
  }
277
+ media.pause();
140
278
  setIsPlaying(false);
279
+ setMediaSessionPlaybackState('paused');
141
280
  }
142
281
 
143
- function handleSessionPlay() {
144
- getMediaElement().play();
145
- setIsPlaying(true);
146
- if (navigator.mediaSession) {
147
- 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');
148
289
  }
149
290
  }
150
291
 
151
- function handleSessionStop() {
152
- if (navigator.mediaSession) {
153
- navigator.mediaSession.playbackState = 'none';
292
+ async function handleSessionPlay() {
293
+ const media = getMediaElement();
294
+ if (!media) {
295
+ return;
154
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');
155
320
 
156
321
  const media = getMediaElement();
157
322
 
@@ -163,12 +328,11 @@ function ComponentPlayer(element) {
163
328
  setTotalTime(0);
164
329
  setCurrentTime(0); // Set zero on stop
165
330
  setHasProminentColors(false);
166
-
167
- if (navigator.mediaSession) {
168
- navigator.mediaSession.metadata = null;
169
- }
331
+ setIsPlaying(false);
332
+ clearMediaSession({ owner: mediaSessionOwner.current });
170
333
 
171
334
  setIndexSelected(null);
335
+ selectedTrackRef.current = null;
172
336
  setCoverUrl(null);
173
337
  }
174
338
 
@@ -188,43 +352,31 @@ function ComponentPlayer(element) {
188
352
  next: handleSessionNext,
189
353
  previous: handleSessionPrevious,
190
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);
191
366
  setIndexSelected(index);
367
+ selectedTrackRef.current = playlist[index];
192
368
  if (fromControls) {
193
369
  handleScroll({ index });
194
370
  }
195
371
 
196
- if (playlist[index]?.cover?.name) {
197
- setCoverUrl(getCover(playlist[index]?.cover));
198
- } else {
199
- setCoverUrl(null);
200
- }
201
- setTimeout(() => {
202
- this.playInitial();
203
- }, 100);
372
+ setCoverUrl(
373
+ playlist[index].cover?.name ? getCover(playlist[index].cover) : null
374
+ );
375
+ requestInitialPlayback();
376
+ return true;
204
377
  },
205
378
  stop: handleSessionStop,
206
379
  play: handleSessionPlay,
207
- playInitial() {
208
- const mediaElement = getMediaElement();
209
- if (!mediaElement) {
210
- console.warn('Audio element not ready yet, retrying...');
211
- setTimeout(() => this.playInitial(), 50);
212
- return;
213
- }
214
- mediaElement.currentTime = 0;
215
- setCurrentTime(0); // Set zero initial time
216
-
217
- mediaElement
218
- .play()
219
- .then(function () {
220
- setTotalTime(mediaElement.duration);
221
- if (navigator.mediaSession) {
222
- navigator.mediaSession.playbackState = 'playing';
223
- }
224
- setIsPlaying(true);
225
- })
226
- .catch(err => console.error('Autoplay blocked:', err));
227
- },
228
380
  pause: handleSessionPause
229
381
  };
230
382
 
@@ -241,13 +393,25 @@ function ComponentPlayer(element) {
241
393
  setCurrentTime(media.currentTime);
242
394
  }
243
395
 
396
+ function handleDurationChange(ev) {
397
+ const duration = ev.currentTarget.duration;
398
+ setTotalTime(Number.isFinite(duration) && duration > 0 ? duration : 0);
399
+ }
400
+
244
401
  function handleReposition(ev) {
245
402
  const newTime = ev.detail.currentTime;
246
403
  const media = getMediaElement();
247
- if (media) {
248
- media.currentTime = newTime;
404
+ if (
405
+ !media ||
406
+ !Number.isFinite(totalTime) ||
407
+ totalTime <= 0 ||
408
+ !Number.isFinite(newTime)
409
+ ) {
410
+ return;
249
411
  }
250
- setCurrentTime(newTime);
412
+ const safeTime = Math.max(0, Math.min(newTime, totalTime));
413
+ media.currentTime = safeTime;
414
+ setCurrentTime(safeTime);
251
415
  }
252
416
 
253
417
  const mainClasses = classMap({
@@ -301,6 +465,7 @@ function ComponentPlayer(element) {
301
465
  </div>
302
466
  </div>
303
467
  </div>
468
+ ${isPlaying ? html`${PlayerAudioDecoy({ mode: 'auto' })}` : nothing}
304
469
  ${Number.isInteger(indexSelected)
305
470
  ? html`
306
471
  <audio
@@ -308,6 +473,11 @@ function ComponentPlayer(element) {
308
473
  id="${mediaId}"
309
474
  @ended="${handleAudioEnd}"
310
475
  @timeupdate="${handleTimeUpdate}"
476
+ @loadedmetadata="${handleDurationChange}"
477
+ @durationchange="${handleDurationChange}"
478
+ @play="${handleAudioNativePlay}"
479
+ @pause="${handleAudioNativePause}"
480
+ @error="${handleAudioNativeError}"
311
481
  ></audio>
312
482
  `
313
483
  : nothing}