@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
|
+
});
|
|
@@ -1,9 +1,51 @@
|
|
|
1
1
|
import { getCover } from './get-cover.js';
|
|
2
|
+
import { isValidTrackIndex } from './is-valid-track-index.js';
|
|
2
3
|
|
|
3
|
-
|
|
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
|
-
|
|
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
|
-
|
|
60
|
+
mediaSession.setActionHandler(
|
|
19
61
|
'nexttrack',
|
|
20
62
|
index < playlist.length - 1 ? handleAudio.next : null
|
|
21
63
|
);
|
|
22
|
-
|
|
64
|
+
mediaSession.setActionHandler(
|
|
23
65
|
'previoustrack',
|
|
24
66
|
index > 0 ? handleAudio.previous : null
|
|
25
67
|
);
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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.
|
|
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
|
-
"
|
|
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.
|
|
39
|
-
"@outbook/webcomponents-player-timeline": ">=1.0.
|
|
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.
|
|
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 {
|
|
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 (
|
|
97
|
-
|
|
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
|
-
|
|
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
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
|
152
|
-
|
|
153
|
-
|
|
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
|
-
|
|
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
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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 (
|
|
248
|
-
media
|
|
404
|
+
if (
|
|
405
|
+
!media ||
|
|
406
|
+
!Number.isFinite(totalTime) ||
|
|
407
|
+
totalTime <= 0 ||
|
|
408
|
+
!Number.isFinite(newTime)
|
|
409
|
+
) {
|
|
410
|
+
return;
|
|
249
411
|
}
|
|
250
|
-
|
|
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}
|