stream-chat-react 13.11.0 → 13.13.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/dist/components/Attachment/Audio.js +38 -15
- package/dist/components/Attachment/Card.js +33 -11
- package/dist/components/Attachment/Geolocation.js +1 -1
- package/dist/components/Attachment/VoiceRecording.js +45 -20
- package/dist/components/Attachment/components/PlayButton.js +1 -1
- package/dist/components/Attachment/components/PlaybackRateButton.js +1 -1
- package/dist/components/Attachment/hooks/useAudioController.d.ts +1 -0
- package/dist/components/Attachment/hooks/useAudioController.js +1 -0
- package/dist/components/Attachment/index.d.ts +1 -0
- package/dist/components/Attachment/index.js +1 -0
- package/dist/components/AudioPlayback/AudioPlayer.d.ts +116 -0
- package/dist/components/AudioPlayback/AudioPlayer.js +456 -0
- package/dist/components/AudioPlayback/AudioPlayerPool.d.ts +49 -0
- package/dist/components/AudioPlayback/AudioPlayerPool.js +156 -0
- package/dist/components/AudioPlayback/WithAudioPlayback.d.ts +24 -0
- package/dist/components/AudioPlayback/WithAudioPlayback.js +57 -0
- package/dist/components/AudioPlayback/index.d.ts +3 -0
- package/dist/components/AudioPlayback/index.js +3 -0
- package/dist/components/AudioPlayback/plugins/AudioPlayerNotificationsPlugin.d.ts +7 -0
- package/dist/components/AudioPlayback/plugins/AudioPlayerNotificationsPlugin.js +25 -0
- package/dist/components/AudioPlayback/plugins/AudioPlayerPlugin.d.ts +10 -0
- package/dist/components/AudioPlayback/plugins/AudioPlayerPlugin.js +1 -0
- package/dist/components/AudioPlayback/plugins/index.d.ts +1 -0
- package/dist/components/AudioPlayback/plugins/index.js +1 -0
- package/dist/components/Channel/Channel.d.ts +2 -0
- package/dist/components/Channel/Channel.js +4 -2
- package/dist/components/Chat/hooks/useChat.js +1 -1
- package/dist/components/MediaRecorder/AudioRecorder/AudioRecordingPreview.d.ts +3 -2
- package/dist/components/MediaRecorder/AudioRecorder/AudioRecordingPreview.js +23 -8
- package/dist/components/MessageInput/AttachmentPreviewList/FileAttachmentPreview.js +1 -1
- package/dist/components/MessageInput/AttachmentPreviewList/GeolocationPreview.js +1 -1
- package/dist/components/MessageInput/AttachmentPreviewList/ImageAttachmentPreview.js +1 -1
- package/dist/components/MessageInput/AttachmentPreviewList/UnsupportedAttachmentPreview.js +1 -1
- package/dist/components/MessageInput/AttachmentPreviewList/VoiceRecordingPreview.d.ts +1 -1
- package/dist/components/MessageInput/AttachmentPreviewList/VoiceRecordingPreview.js +20 -7
- package/dist/components/MessageList/MessageList.js +8 -5
- package/dist/components/MessageList/renderMessages.js +6 -6
- package/dist/components/index.d.ts +2 -1
- package/dist/components/index.js +2 -1
- package/dist/context/ComponentContext.d.ts +4 -0
- package/dist/context/MessageListContext.d.ts +3 -0
- package/dist/experimental/index.browser.cjs.map +2 -2
- package/dist/experimental/index.node.cjs.map +2 -2
- package/dist/i18n/TranslationBuilder/notifications/NotificationTranslationTopic.js +2 -0
- package/dist/i18n/TranslationBuilder/notifications/browserAudioPlaybackError.d.ts +3 -0
- package/dist/i18n/TranslationBuilder/notifications/browserAudioPlaybackError.js +1 -0
- package/dist/index.browser.cjs +2833 -2089
- package/dist/index.browser.cjs.map +4 -4
- package/dist/index.node.cjs +2842 -2089
- package/dist/index.node.cjs.map +4 -4
- package/package.json +1 -1
|
@@ -0,0 +1,456 @@
|
|
|
1
|
+
import { StateStore } from 'stream-chat';
|
|
2
|
+
import throttle from 'lodash.throttle';
|
|
3
|
+
const DEFAULT_PLAYBACK_RATES = [1.0, 1.5, 2.0];
|
|
4
|
+
const isSeekable = (audioElement) => !(audioElement.duration === Infinity || isNaN(audioElement.duration));
|
|
5
|
+
export const defaultRegisterAudioPlayerError = ({ error, } = {}) => {
|
|
6
|
+
if (!error)
|
|
7
|
+
return;
|
|
8
|
+
console.error('[AUDIO PLAYER]', error);
|
|
9
|
+
};
|
|
10
|
+
export const elementIsPlaying = (audioElement) => audioElement && !(audioElement.paused || audioElement.ended);
|
|
11
|
+
export class AudioPlayer {
|
|
12
|
+
constructor({ durationSeconds, fileSize, id, mimeType, playbackRates: customPlaybackRates, plugins, pool, src, title, waveformData, }) {
|
|
13
|
+
this._plugins = new Map();
|
|
14
|
+
this.playTimeout = undefined;
|
|
15
|
+
this.unsubscribeEventListeners = null;
|
|
16
|
+
this._disposed = false;
|
|
17
|
+
this._restoringPosition = false;
|
|
18
|
+
this._removalTimeout = undefined;
|
|
19
|
+
this.setPlaybackStartSafetyTimeout = () => {
|
|
20
|
+
clearTimeout(this.playTimeout);
|
|
21
|
+
this.playTimeout = setTimeout(() => {
|
|
22
|
+
if (!this.elementRef)
|
|
23
|
+
return;
|
|
24
|
+
try {
|
|
25
|
+
this.elementRef.pause();
|
|
26
|
+
this.state.partialNext({ isPlaying: false });
|
|
27
|
+
}
|
|
28
|
+
catch (e) {
|
|
29
|
+
this.registerError({ errCode: 'failed-to-start' });
|
|
30
|
+
}
|
|
31
|
+
}, 2000);
|
|
32
|
+
};
|
|
33
|
+
this.clearPlaybackStartSafetyTimeout = () => {
|
|
34
|
+
if (!this.elementRef)
|
|
35
|
+
return;
|
|
36
|
+
clearTimeout(this.playTimeout);
|
|
37
|
+
this.playTimeout = undefined;
|
|
38
|
+
};
|
|
39
|
+
this.clearPendingLoadedMeta = () => {
|
|
40
|
+
const pending = this._pendingLoadedMeta;
|
|
41
|
+
if (pending?.element && pending.onLoaded) {
|
|
42
|
+
pending.element.removeEventListener('loadedmetadata', pending.onLoaded);
|
|
43
|
+
}
|
|
44
|
+
this._pendingLoadedMeta = undefined;
|
|
45
|
+
};
|
|
46
|
+
this.restoreSavedPosition = (elementRef) => {
|
|
47
|
+
const saved = this.secondsElapsed;
|
|
48
|
+
if (!saved || saved <= 0)
|
|
49
|
+
return;
|
|
50
|
+
const apply = () => {
|
|
51
|
+
const duration = elementRef.duration;
|
|
52
|
+
const clamped = typeof duration === 'number' && !isNaN(duration) && isFinite(duration)
|
|
53
|
+
? Math.min(saved, duration)
|
|
54
|
+
: saved;
|
|
55
|
+
try {
|
|
56
|
+
if (elementRef.currentTime === clamped)
|
|
57
|
+
return;
|
|
58
|
+
elementRef.currentTime = clamped;
|
|
59
|
+
// Preempt UI with restored position to avoid flicker
|
|
60
|
+
this.setSecondsElapsed(clamped);
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
// ignore
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
// No information is available about the media resource.
|
|
67
|
+
if (elementRef.readyState < 1) {
|
|
68
|
+
this.clearPendingLoadedMeta();
|
|
69
|
+
this._restoringPosition = true;
|
|
70
|
+
const onLoaded = () => {
|
|
71
|
+
// Ensure this callback still belongs to the same pending registration and same element
|
|
72
|
+
if (this._pendingLoadedMeta?.onLoaded !== onLoaded)
|
|
73
|
+
return;
|
|
74
|
+
this._pendingLoadedMeta = undefined;
|
|
75
|
+
if (this.elementRef !== elementRef) {
|
|
76
|
+
this._restoringPosition = false;
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
apply();
|
|
80
|
+
this._restoringPosition = false;
|
|
81
|
+
};
|
|
82
|
+
elementRef.addEventListener('loadedmetadata', onLoaded, { once: true });
|
|
83
|
+
this._pendingLoadedMeta = { element: elementRef, onLoaded };
|
|
84
|
+
}
|
|
85
|
+
else {
|
|
86
|
+
this._restoringPosition = true;
|
|
87
|
+
apply();
|
|
88
|
+
this._restoringPosition = false;
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
this.elementIsReady = () => {
|
|
92
|
+
if (this._elementIsReadyPromise)
|
|
93
|
+
return this._elementIsReadyPromise;
|
|
94
|
+
this._elementIsReadyPromise = new Promise((resolve) => {
|
|
95
|
+
if (!this.elementRef)
|
|
96
|
+
return resolve(false);
|
|
97
|
+
const element = this.elementRef;
|
|
98
|
+
const handleLoaded = () => {
|
|
99
|
+
element.removeEventListener('loadedmetadata', handleLoaded);
|
|
100
|
+
resolve(element.readyState > 0);
|
|
101
|
+
};
|
|
102
|
+
element.addEventListener('loadedmetadata', handleLoaded);
|
|
103
|
+
});
|
|
104
|
+
return this._elementIsReadyPromise;
|
|
105
|
+
};
|
|
106
|
+
this.setRef = (elementRef) => {
|
|
107
|
+
if (elementIsPlaying(this.elementRef)) {
|
|
108
|
+
// preserve state during swap
|
|
109
|
+
this.releaseElement({ resetState: false });
|
|
110
|
+
}
|
|
111
|
+
this.clearPendingLoadedMeta();
|
|
112
|
+
this._restoringPosition = false;
|
|
113
|
+
this._elementIsReadyPromise = undefined;
|
|
114
|
+
this.state.partialNext({ elementRef });
|
|
115
|
+
// When a new element is attached, make sure listeners are wired to it
|
|
116
|
+
if (elementRef) {
|
|
117
|
+
this.registerSubscriptions();
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
this.setSecondsElapsed = (secondsElapsed) => {
|
|
121
|
+
this.state.partialNext({
|
|
122
|
+
progressPercent: this.elementRef && secondsElapsed
|
|
123
|
+
? (secondsElapsed / this.elementRef.duration) * 100
|
|
124
|
+
: 0,
|
|
125
|
+
secondsElapsed,
|
|
126
|
+
});
|
|
127
|
+
};
|
|
128
|
+
this.canPlayMimeType = (mimeType) => {
|
|
129
|
+
if (!mimeType)
|
|
130
|
+
return false;
|
|
131
|
+
if (this.elementRef)
|
|
132
|
+
return !!this.elementRef.canPlayType(mimeType);
|
|
133
|
+
return !!new Audio().canPlayType(mimeType);
|
|
134
|
+
};
|
|
135
|
+
this.play = async (params) => {
|
|
136
|
+
if (this._disposed)
|
|
137
|
+
return;
|
|
138
|
+
const elementRef = this.ensureElementRef();
|
|
139
|
+
if (elementIsPlaying(this.elementRef)) {
|
|
140
|
+
if (this.isPlaying)
|
|
141
|
+
return;
|
|
142
|
+
this.state.partialNext({ isPlaying: true });
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
const { currentPlaybackRate, playbackRates } = {
|
|
146
|
+
currentPlaybackRate: this.currentPlaybackRate,
|
|
147
|
+
playbackRates: this.playbackRates,
|
|
148
|
+
...params,
|
|
149
|
+
};
|
|
150
|
+
if (!this.canPlayRecord) {
|
|
151
|
+
this.registerError({ errCode: 'not-playable' });
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
// Restore last known position for this player before attempting to play
|
|
155
|
+
this.restoreSavedPosition(elementRef);
|
|
156
|
+
elementRef.playbackRate = currentPlaybackRate ?? this.currentPlaybackRate;
|
|
157
|
+
this.setPlaybackStartSafetyTimeout();
|
|
158
|
+
try {
|
|
159
|
+
await elementRef.play();
|
|
160
|
+
this.state.partialNext({
|
|
161
|
+
currentPlaybackRate,
|
|
162
|
+
isPlaying: true,
|
|
163
|
+
playbackRates,
|
|
164
|
+
});
|
|
165
|
+
this._pool.setActiveAudioPlayer(this);
|
|
166
|
+
}
|
|
167
|
+
catch (e) {
|
|
168
|
+
this.registerError({ error: e });
|
|
169
|
+
this.state.partialNext({ isPlaying: false });
|
|
170
|
+
}
|
|
171
|
+
finally {
|
|
172
|
+
this.clearPlaybackStartSafetyTimeout();
|
|
173
|
+
}
|
|
174
|
+
};
|
|
175
|
+
this.pause = () => {
|
|
176
|
+
if (!elementIsPlaying(this.elementRef))
|
|
177
|
+
return;
|
|
178
|
+
this.clearPlaybackStartSafetyTimeout();
|
|
179
|
+
// existence of the element already checked by elementIsPlaying
|
|
180
|
+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
181
|
+
this.elementRef.pause();
|
|
182
|
+
this.state.partialNext({ isPlaying: false });
|
|
183
|
+
};
|
|
184
|
+
this.stop = () => {
|
|
185
|
+
this.pause();
|
|
186
|
+
this.setSecondsElapsed(0);
|
|
187
|
+
if (this.elementRef)
|
|
188
|
+
this.elementRef.currentTime = 0;
|
|
189
|
+
};
|
|
190
|
+
this.togglePlay = async () => (this.isPlaying ? this.pause() : await this.play());
|
|
191
|
+
this.increasePlaybackRate = () => {
|
|
192
|
+
if (!this.elementRef)
|
|
193
|
+
return;
|
|
194
|
+
let currentPlaybackRateIndex = this.state
|
|
195
|
+
.getLatestValue()
|
|
196
|
+
.playbackRates.findIndex((rate) => rate === this.currentPlaybackRate);
|
|
197
|
+
if (currentPlaybackRateIndex === -1) {
|
|
198
|
+
currentPlaybackRateIndex = 0;
|
|
199
|
+
}
|
|
200
|
+
const nextIndex = currentPlaybackRateIndex === this.playbackRates.length - 1
|
|
201
|
+
? 0
|
|
202
|
+
: currentPlaybackRateIndex + 1;
|
|
203
|
+
const currentPlaybackRate = this.playbackRates[nextIndex];
|
|
204
|
+
this.state.partialNext({ currentPlaybackRate });
|
|
205
|
+
this.elementRef.playbackRate = currentPlaybackRate;
|
|
206
|
+
};
|
|
207
|
+
this.seek = throttle(async ({ clientX, currentTarget }) => {
|
|
208
|
+
let element = this.elementRef;
|
|
209
|
+
if (!this.elementRef) {
|
|
210
|
+
element = this.ensureElementRef();
|
|
211
|
+
const isReady = await this.elementIsReady();
|
|
212
|
+
if (!isReady)
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
if (!currentTarget || !element)
|
|
216
|
+
return;
|
|
217
|
+
if (!isSeekable(element)) {
|
|
218
|
+
this.registerError({ errCode: 'seek-not-supported' });
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
const { width, x } = currentTarget.getBoundingClientRect();
|
|
222
|
+
const ratio = (clientX - x) / width;
|
|
223
|
+
if (ratio > 1 || ratio < 0)
|
|
224
|
+
return;
|
|
225
|
+
const currentTime = ratio * element.duration;
|
|
226
|
+
this.setSecondsElapsed(currentTime);
|
|
227
|
+
element.currentTime = currentTime;
|
|
228
|
+
}, 16);
|
|
229
|
+
this.registerError = (params) => {
|
|
230
|
+
defaultRegisterAudioPlayerError(params);
|
|
231
|
+
this.plugins.forEach(({ onError }) => onError?.({ player: this, ...params }));
|
|
232
|
+
};
|
|
233
|
+
/**
|
|
234
|
+
* Removes the audio element reference, event listeners and audio player from the player pool.
|
|
235
|
+
* Helpful when only a single AudioPlayer instance is to be removed from the AudioPlayerPool.
|
|
236
|
+
*/
|
|
237
|
+
this.requestRemoval = () => {
|
|
238
|
+
this._disposed = true;
|
|
239
|
+
this.cancelScheduledRemoval();
|
|
240
|
+
this.clearPendingLoadedMeta();
|
|
241
|
+
this._restoringPosition = false;
|
|
242
|
+
this.releaseElement({ resetState: true });
|
|
243
|
+
this.unsubscribeEventListeners?.();
|
|
244
|
+
this.unsubscribeEventListeners = null;
|
|
245
|
+
this.plugins.forEach(({ onRemove }) => onRemove?.({ player: this }));
|
|
246
|
+
this._pool.deregister(this.id);
|
|
247
|
+
};
|
|
248
|
+
this.cancelScheduledRemoval = () => {
|
|
249
|
+
clearTimeout(this._removalTimeout);
|
|
250
|
+
this._removalTimeout = undefined;
|
|
251
|
+
};
|
|
252
|
+
this.scheduleRemoval = (ms = 0) => {
|
|
253
|
+
this.cancelScheduledRemoval();
|
|
254
|
+
this._removalTimeout = setTimeout(() => {
|
|
255
|
+
if (this.disposed)
|
|
256
|
+
return;
|
|
257
|
+
this.requestRemoval();
|
|
258
|
+
}, ms);
|
|
259
|
+
};
|
|
260
|
+
/**
|
|
261
|
+
* Releases only the underlying element back to the pool without disposing the player instance.
|
|
262
|
+
* Used by the pool to hand off the shared element in single-playback mode.
|
|
263
|
+
*/
|
|
264
|
+
this.releaseElementForHandoff = () => {
|
|
265
|
+
if (!this.elementRef)
|
|
266
|
+
return;
|
|
267
|
+
this.releaseElement({ resetState: false });
|
|
268
|
+
this.unsubscribeEventListeners?.();
|
|
269
|
+
this.unsubscribeEventListeners = null;
|
|
270
|
+
};
|
|
271
|
+
this.registerSubscriptions = () => {
|
|
272
|
+
this.unsubscribeEventListeners?.();
|
|
273
|
+
const audioElement = this.elementRef;
|
|
274
|
+
if (!audioElement)
|
|
275
|
+
return;
|
|
276
|
+
const handleEnded = () => {
|
|
277
|
+
this.state.partialNext({
|
|
278
|
+
isPlaying: false,
|
|
279
|
+
secondsElapsed: audioElement?.duration ?? this.durationSeconds ?? 0,
|
|
280
|
+
});
|
|
281
|
+
};
|
|
282
|
+
const handleError = (e) => {
|
|
283
|
+
// if fired probably is one of these (e.srcElement.error.code)
|
|
284
|
+
// 1 = MEDIA_ERR_ABORTED (fetch aborted by user/JS)
|
|
285
|
+
// 2 = MEDIA_ERR_NETWORK (network failed while fetching)
|
|
286
|
+
// 3 = MEDIA_ERR_DECODE (data fetched but couldn’t decode)
|
|
287
|
+
// 4 = MEDIA_ERR_SRC_NOT_SUPPORTED (no resource supported / bad type)
|
|
288
|
+
// reported during the mount so only logging to the console
|
|
289
|
+
const audio = e.currentTarget;
|
|
290
|
+
const state = { isPlaying: false };
|
|
291
|
+
if (!audio?.error?.code) {
|
|
292
|
+
this.state.partialNext(state);
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
if (audio.error.code === 4) {
|
|
296
|
+
state.canPlayRecord = false;
|
|
297
|
+
this.state.partialNext(state);
|
|
298
|
+
}
|
|
299
|
+
const errorMsg = [
|
|
300
|
+
undefined,
|
|
301
|
+
'MEDIA_ERR_ABORTED: fetch aborted by user',
|
|
302
|
+
'MEDIA_ERR_NETWORK: network failed while fetching',
|
|
303
|
+
'MEDIA_ERR_DECODE: audio fetched but couldn’t decode',
|
|
304
|
+
'MEDIA_ERR_SRC_NOT_SUPPORTED: source not supported',
|
|
305
|
+
][audio?.error?.code];
|
|
306
|
+
if (!errorMsg)
|
|
307
|
+
return;
|
|
308
|
+
defaultRegisterAudioPlayerError({ error: new Error(errorMsg + ` (${audio.src})`) });
|
|
309
|
+
};
|
|
310
|
+
const handleTimeupdate = () => {
|
|
311
|
+
const t = audioElement?.currentTime ?? 0;
|
|
312
|
+
// Ignore spurious zero during restore/handoff to avoid UI flicker
|
|
313
|
+
if (this._restoringPosition && t === 0)
|
|
314
|
+
return;
|
|
315
|
+
// Also avoid regressing UI to zero if we already have non-zero progress and we're not playing
|
|
316
|
+
if (!this.isPlaying && t === 0 && this.secondsElapsed > 0)
|
|
317
|
+
return;
|
|
318
|
+
this.setSecondsElapsed(t);
|
|
319
|
+
};
|
|
320
|
+
audioElement.addEventListener('ended', handleEnded);
|
|
321
|
+
audioElement.addEventListener('error', handleError);
|
|
322
|
+
audioElement.addEventListener('timeupdate', handleTimeupdate);
|
|
323
|
+
this.unsubscribeEventListeners = () => {
|
|
324
|
+
audioElement.pause();
|
|
325
|
+
audioElement.removeEventListener('ended', handleEnded);
|
|
326
|
+
audioElement.removeEventListener('error', handleError);
|
|
327
|
+
audioElement.removeEventListener('timeupdate', handleTimeupdate);
|
|
328
|
+
};
|
|
329
|
+
};
|
|
330
|
+
this._data = {
|
|
331
|
+
durationSeconds,
|
|
332
|
+
fileSize,
|
|
333
|
+
id,
|
|
334
|
+
mimeType,
|
|
335
|
+
src,
|
|
336
|
+
title,
|
|
337
|
+
waveformData,
|
|
338
|
+
};
|
|
339
|
+
this._pool = pool;
|
|
340
|
+
this.setPlugins(() => plugins ?? []);
|
|
341
|
+
const playbackRates = customPlaybackRates?.length
|
|
342
|
+
? customPlaybackRates
|
|
343
|
+
: DEFAULT_PLAYBACK_RATES;
|
|
344
|
+
// do not create element here; only evaluate canPlayRecord cheaply
|
|
345
|
+
const canPlayRecord = mimeType ? !!new Audio().canPlayType(mimeType) : true;
|
|
346
|
+
this.state = new StateStore({
|
|
347
|
+
canPlayRecord,
|
|
348
|
+
currentPlaybackRate: playbackRates[0],
|
|
349
|
+
elementRef: null,
|
|
350
|
+
isPlaying: false,
|
|
351
|
+
playbackError: null,
|
|
352
|
+
playbackRates,
|
|
353
|
+
progressPercent: 0,
|
|
354
|
+
secondsElapsed: 0,
|
|
355
|
+
});
|
|
356
|
+
this.plugins.forEach((p) => p.onInit?.({ player: this }));
|
|
357
|
+
}
|
|
358
|
+
get plugins() {
|
|
359
|
+
return Array.from(this._plugins.values());
|
|
360
|
+
}
|
|
361
|
+
get canPlayRecord() {
|
|
362
|
+
return this.state.getLatestValue().canPlayRecord;
|
|
363
|
+
}
|
|
364
|
+
get elementRef() {
|
|
365
|
+
return this.state.getLatestValue().elementRef;
|
|
366
|
+
}
|
|
367
|
+
get isPlaying() {
|
|
368
|
+
return this.state.getLatestValue().isPlaying;
|
|
369
|
+
}
|
|
370
|
+
get currentPlaybackRate() {
|
|
371
|
+
return this.state.getLatestValue().currentPlaybackRate;
|
|
372
|
+
}
|
|
373
|
+
get playbackRates() {
|
|
374
|
+
return this.state.getLatestValue().playbackRates;
|
|
375
|
+
}
|
|
376
|
+
get durationSeconds() {
|
|
377
|
+
return this._data.durationSeconds;
|
|
378
|
+
}
|
|
379
|
+
get fileSize() {
|
|
380
|
+
return this._data.fileSize;
|
|
381
|
+
}
|
|
382
|
+
get id() {
|
|
383
|
+
return this._data.id;
|
|
384
|
+
}
|
|
385
|
+
get src() {
|
|
386
|
+
return this._data.src;
|
|
387
|
+
}
|
|
388
|
+
get mimeType() {
|
|
389
|
+
return this._data.mimeType;
|
|
390
|
+
}
|
|
391
|
+
get title() {
|
|
392
|
+
return this._data.title;
|
|
393
|
+
}
|
|
394
|
+
get waveformData() {
|
|
395
|
+
return this._data.waveformData;
|
|
396
|
+
}
|
|
397
|
+
get secondsElapsed() {
|
|
398
|
+
return this.state.getLatestValue().secondsElapsed;
|
|
399
|
+
}
|
|
400
|
+
get progressPercent() {
|
|
401
|
+
return this.state.getLatestValue().progressPercent;
|
|
402
|
+
}
|
|
403
|
+
get disposed() {
|
|
404
|
+
return this._disposed;
|
|
405
|
+
}
|
|
406
|
+
ensureElementRef() {
|
|
407
|
+
if (this._disposed) {
|
|
408
|
+
throw new Error('AudioPlayer is disposed');
|
|
409
|
+
}
|
|
410
|
+
if (!this.elementRef) {
|
|
411
|
+
const el = this._pool.acquireElement({
|
|
412
|
+
ownerId: this.id,
|
|
413
|
+
src: this.src,
|
|
414
|
+
});
|
|
415
|
+
this.setRef(el);
|
|
416
|
+
}
|
|
417
|
+
return this.elementRef;
|
|
418
|
+
}
|
|
419
|
+
setDescriptor(descriptor) {
|
|
420
|
+
this._data = { ...this._data, ...descriptor };
|
|
421
|
+
if (descriptor.src !== this.src && this.elementRef) {
|
|
422
|
+
this.elementRef.src = descriptor.src;
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
releaseElement({ resetState }) {
|
|
426
|
+
this.clearPendingLoadedMeta();
|
|
427
|
+
this._restoringPosition = false;
|
|
428
|
+
if (resetState) {
|
|
429
|
+
this.stop();
|
|
430
|
+
}
|
|
431
|
+
else {
|
|
432
|
+
// Ensure isPlaying reflects reality, but keep progress/seconds
|
|
433
|
+
this.state.partialNext({ isPlaying: false });
|
|
434
|
+
if (this.elementRef) {
|
|
435
|
+
try {
|
|
436
|
+
this.elementRef.pause();
|
|
437
|
+
}
|
|
438
|
+
catch {
|
|
439
|
+
// ignore
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
if (this.elementRef) {
|
|
444
|
+
this._pool.releaseElement(this.id);
|
|
445
|
+
this.setRef(null);
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
setPlugins(setter) {
|
|
449
|
+
this._plugins = setter(this.plugins).reduce((acc, plugin) => {
|
|
450
|
+
if (plugin.id) {
|
|
451
|
+
acc.set(plugin.id, plugin);
|
|
452
|
+
}
|
|
453
|
+
return acc;
|
|
454
|
+
}, new Map());
|
|
455
|
+
}
|
|
456
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { AudioPlayer, type AudioPlayerOptions } from './AudioPlayer';
|
|
2
|
+
import { StateStore } from 'stream-chat';
|
|
3
|
+
export type AudioPlayerPoolState = {
|
|
4
|
+
activeAudioPlayer: AudioPlayer | null;
|
|
5
|
+
};
|
|
6
|
+
export declare class AudioPlayerPool {
|
|
7
|
+
state: StateStore<AudioPlayerPoolState>;
|
|
8
|
+
private pool;
|
|
9
|
+
private audios;
|
|
10
|
+
private sharedAudio;
|
|
11
|
+
private sharedOwnerId;
|
|
12
|
+
private readonly allowConcurrentPlayback;
|
|
13
|
+
constructor(config?: {
|
|
14
|
+
allowConcurrentPlayback?: boolean;
|
|
15
|
+
});
|
|
16
|
+
get players(): AudioPlayer[];
|
|
17
|
+
get activeAudioPlayer(): AudioPlayer | null;
|
|
18
|
+
getOrAdd: (params: Omit<AudioPlayerOptions, 'pool'>) => AudioPlayer;
|
|
19
|
+
/**
|
|
20
|
+
* In case of allowConcurrentPlayback enabled, a new Audio is created and assigned to the given audioPlayer owner.
|
|
21
|
+
* In case of disabled concurrency, the shared audio ownership is transferred to the new owner loading the owner's
|
|
22
|
+
* source.
|
|
23
|
+
*
|
|
24
|
+
* @param ownerId
|
|
25
|
+
* @param src
|
|
26
|
+
*/
|
|
27
|
+
acquireElement: ({ ownerId, src }: {
|
|
28
|
+
ownerId: string;
|
|
29
|
+
src: string;
|
|
30
|
+
}) => HTMLAudioElement;
|
|
31
|
+
/**
|
|
32
|
+
* Removes the given audio players ownership of the shared audio element (in case of concurrent playback is disabled)
|
|
33
|
+
* and pauses the reproduction of the audio.
|
|
34
|
+
* In case of concurrent playback mode (allowConcurrentPlayback enabled), the audio is paused,
|
|
35
|
+
* its source cleared and removed from the audios pool readied for garbage collection.
|
|
36
|
+
*
|
|
37
|
+
* @param ownerId
|
|
38
|
+
*/
|
|
39
|
+
releaseElement: (ownerId: string) => void;
|
|
40
|
+
/** Sets active audio player when allowConcurrentPlayback is disabled */
|
|
41
|
+
setActiveAudioPlayer: (activeAudioPlayer: AudioPlayer | null) => void;
|
|
42
|
+
/** Removes the AudioPlayer instance from the pool of players */
|
|
43
|
+
deregister(id: string): void;
|
|
44
|
+
/** Performs all the necessary cleanup actions and removes the player from the pool */
|
|
45
|
+
remove: (id: string) => void;
|
|
46
|
+
/** Removes and cleans up all the players from the pool */
|
|
47
|
+
clear: () => void;
|
|
48
|
+
registerSubscriptions: () => void;
|
|
49
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { AudioPlayer } from './AudioPlayer';
|
|
2
|
+
import { StateStore } from 'stream-chat';
|
|
3
|
+
export class AudioPlayerPool {
|
|
4
|
+
constructor(config) {
|
|
5
|
+
this.state = new StateStore({
|
|
6
|
+
activeAudioPlayer: null,
|
|
7
|
+
});
|
|
8
|
+
this.pool = new Map();
|
|
9
|
+
this.audios = new Map();
|
|
10
|
+
this.sharedAudio = null;
|
|
11
|
+
this.sharedOwnerId = null;
|
|
12
|
+
this.getOrAdd = (params) => {
|
|
13
|
+
const { playbackRates, plugins, ...descriptor } = params;
|
|
14
|
+
let player = this.pool.get(params.id);
|
|
15
|
+
if (player) {
|
|
16
|
+
if (!player.disposed) {
|
|
17
|
+
player.setDescriptor(descriptor);
|
|
18
|
+
return player;
|
|
19
|
+
}
|
|
20
|
+
this.deregister(params.id);
|
|
21
|
+
}
|
|
22
|
+
player = new AudioPlayer({
|
|
23
|
+
playbackRates,
|
|
24
|
+
plugins,
|
|
25
|
+
...descriptor,
|
|
26
|
+
pool: this,
|
|
27
|
+
});
|
|
28
|
+
this.pool.set(params.id, player);
|
|
29
|
+
return player;
|
|
30
|
+
};
|
|
31
|
+
/**
|
|
32
|
+
* In case of allowConcurrentPlayback enabled, a new Audio is created and assigned to the given audioPlayer owner.
|
|
33
|
+
* In case of disabled concurrency, the shared audio ownership is transferred to the new owner loading the owner's
|
|
34
|
+
* source.
|
|
35
|
+
*
|
|
36
|
+
* @param ownerId
|
|
37
|
+
* @param src
|
|
38
|
+
*/
|
|
39
|
+
this.acquireElement = ({ ownerId, src }) => {
|
|
40
|
+
if (!this.allowConcurrentPlayback) {
|
|
41
|
+
// Single shared element mode
|
|
42
|
+
if (!this.sharedAudio) {
|
|
43
|
+
this.sharedAudio = new Audio();
|
|
44
|
+
}
|
|
45
|
+
// Handoff from previous owner if different
|
|
46
|
+
if (this.sharedOwnerId && this.sharedOwnerId !== ownerId) {
|
|
47
|
+
const previous = this.pool.get(this.sharedOwnerId);
|
|
48
|
+
// Ask previous to pause and drop ref, but keep player in pool
|
|
49
|
+
previous?.pause();
|
|
50
|
+
previous?.releaseElementForHandoff();
|
|
51
|
+
}
|
|
52
|
+
this.sharedOwnerId = ownerId;
|
|
53
|
+
if (this.sharedAudio.src !== src) {
|
|
54
|
+
// setting src starts loading; avoid explicit load() to prevent currentTime reset flicker
|
|
55
|
+
this.sharedAudio.src = src;
|
|
56
|
+
}
|
|
57
|
+
return this.sharedAudio;
|
|
58
|
+
}
|
|
59
|
+
// Concurrent-per-owner mode
|
|
60
|
+
let audio = this.audios.get(ownerId);
|
|
61
|
+
if (!audio) {
|
|
62
|
+
audio = new Audio();
|
|
63
|
+
this.audios.set(ownerId, audio);
|
|
64
|
+
}
|
|
65
|
+
if (audio.src !== src) {
|
|
66
|
+
// setting src starts loading; avoid explicit load() here as well
|
|
67
|
+
audio.src = src;
|
|
68
|
+
}
|
|
69
|
+
return audio;
|
|
70
|
+
};
|
|
71
|
+
/**
|
|
72
|
+
* Removes the given audio players ownership of the shared audio element (in case of concurrent playback is disabled)
|
|
73
|
+
* and pauses the reproduction of the audio.
|
|
74
|
+
* In case of concurrent playback mode (allowConcurrentPlayback enabled), the audio is paused,
|
|
75
|
+
* its source cleared and removed from the audios pool readied for garbage collection.
|
|
76
|
+
*
|
|
77
|
+
* @param ownerId
|
|
78
|
+
*/
|
|
79
|
+
this.releaseElement = (ownerId) => {
|
|
80
|
+
if (!this.allowConcurrentPlayback) {
|
|
81
|
+
if (this.sharedOwnerId !== ownerId)
|
|
82
|
+
return;
|
|
83
|
+
const el = this.sharedAudio;
|
|
84
|
+
if (el) {
|
|
85
|
+
try {
|
|
86
|
+
el.pause();
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
// ignore
|
|
90
|
+
}
|
|
91
|
+
el.removeAttribute('src');
|
|
92
|
+
el.load();
|
|
93
|
+
}
|
|
94
|
+
// Keep shared element instance for reuse
|
|
95
|
+
this.sharedOwnerId = null;
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
const el = this.audios.get(ownerId);
|
|
99
|
+
if (!el)
|
|
100
|
+
return;
|
|
101
|
+
try {
|
|
102
|
+
el.pause();
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
// ignore
|
|
106
|
+
}
|
|
107
|
+
el.removeAttribute('src');
|
|
108
|
+
el.load();
|
|
109
|
+
this.audios.delete(ownerId);
|
|
110
|
+
};
|
|
111
|
+
/** Sets active audio player when allowConcurrentPlayback is disabled */
|
|
112
|
+
this.setActiveAudioPlayer = (activeAudioPlayer) => {
|
|
113
|
+
if (this.allowConcurrentPlayback)
|
|
114
|
+
return;
|
|
115
|
+
this.state.partialNext({ activeAudioPlayer });
|
|
116
|
+
};
|
|
117
|
+
/** Performs all the necessary cleanup actions and removes the player from the pool */
|
|
118
|
+
this.remove = (id) => {
|
|
119
|
+
const player = this.pool.get(id);
|
|
120
|
+
if (!player)
|
|
121
|
+
return;
|
|
122
|
+
player.requestRemoval();
|
|
123
|
+
};
|
|
124
|
+
/** Removes and cleans up all the players from the pool */
|
|
125
|
+
this.clear = () => {
|
|
126
|
+
this.players.forEach((player) => {
|
|
127
|
+
this.remove(player.id);
|
|
128
|
+
});
|
|
129
|
+
};
|
|
130
|
+
this.registerSubscriptions = () => {
|
|
131
|
+
// Only register subscriptions for players that have an attached element.
|
|
132
|
+
// Avoid creating elements or cross-wiring listeners on the shared element in single-playback mode.
|
|
133
|
+
this.players.forEach((p) => {
|
|
134
|
+
if (p.elementRef) {
|
|
135
|
+
p.registerSubscriptions();
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
};
|
|
139
|
+
this.allowConcurrentPlayback = !!config?.allowConcurrentPlayback;
|
|
140
|
+
}
|
|
141
|
+
get players() {
|
|
142
|
+
return Array.from(this.pool.values());
|
|
143
|
+
}
|
|
144
|
+
get activeAudioPlayer() {
|
|
145
|
+
return this.state.getLatestValue().activeAudioPlayer;
|
|
146
|
+
}
|
|
147
|
+
/** Removes the AudioPlayer instance from the pool of players */
|
|
148
|
+
deregister(id) {
|
|
149
|
+
if (this.pool.has(id)) {
|
|
150
|
+
this.pool.delete(id);
|
|
151
|
+
}
|
|
152
|
+
if (this.activeAudioPlayer?.id === id) {
|
|
153
|
+
this.setActiveAudioPlayer(null);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import type { AudioPlayerOptions } from './AudioPlayer';
|
|
3
|
+
export type WithAudioPlaybackProps = {
|
|
4
|
+
children?: React.ReactNode;
|
|
5
|
+
allowConcurrentPlayback?: boolean;
|
|
6
|
+
};
|
|
7
|
+
export declare const WithAudioPlayback: ({ allowConcurrentPlayback, children, }: WithAudioPlaybackProps) => React.JSX.Element;
|
|
8
|
+
export type UseAudioPlayerProps = {
|
|
9
|
+
/**
|
|
10
|
+
* Identifier of the entity that requested the audio playback, e.g. message ID.
|
|
11
|
+
* Asset to specific audio player is a many-to-many relationship
|
|
12
|
+
* - one URL can be associated with multiple UI elements,
|
|
13
|
+
* - one UI element can display multiple audio sources.
|
|
14
|
+
* Therefore, the AudioPlayer ID is a combination of request:src.
|
|
15
|
+
*
|
|
16
|
+
* The requester string can take into consideration whether there are multiple instances of
|
|
17
|
+
* the same URL requested by the same requester (message has multiple attachments with the same asset URL).
|
|
18
|
+
* In reality the fact that one message has multiple attachments with the same asset URL
|
|
19
|
+
* could be considered a bad practice or a bug.
|
|
20
|
+
*/
|
|
21
|
+
requester?: string;
|
|
22
|
+
} & Partial<Omit<AudioPlayerOptions, 'id' | 'pool'>>;
|
|
23
|
+
export declare const useAudioPlayer: ({ durationSeconds, fileSize, mimeType, playbackRates, plugins, requester, src, title, waveformData, }: UseAudioPlayerProps) => import("./AudioPlayer").AudioPlayer | undefined;
|
|
24
|
+
export declare const useActiveAudioPlayer: () => import("./AudioPlayer").AudioPlayer | null | undefined;
|