@cinecrew/cinecrew-player 0.1.2 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +31 -20
- package/package.json +1 -1
- package/src/native/InlineLivePlayer.js +8 -7
- package/src/native/MediaPlayerView.js +59 -26
- package/src/native/customization.js +2 -1
- package/src/native/media/LiveChatDrawer.js +16 -25
- package/src/native/media/YouTubeVideoPlayer.web.js +2 -0
- package/src/native/media/player/PlayerBottomBar.js +5 -13
- package/src/native/media/player/PlayerTopBar.js +1 -1
- package/src/native/media/player/playerConstants.js +3 -3
- package/src/native/media/web/useWebAc3AudioPlayback.web.js +9 -3
- package/src/native/media/web/useWebVideoAspectRatio.js +18 -3
- package/src/utils/invokePlayerAction.js +24 -0
- package/src/utils/progressBarTime.js +46 -0
- package/src/utils/webRecording.js +147 -0
- package/src/web/index.js +615 -128
- package/src/web/styles.css +291 -51
- package/types/index.d.ts +14 -1
package/src/web/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import React, { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react';
|
|
1
|
+
import React, { forwardRef, useCallback, useEffect, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
|
2
2
|
import { useWebVideoAspectRatio } from '../native/media/web/useWebVideoAspectRatio';
|
|
3
3
|
import { useWebMpegTsPlayback } from '../native/media/web/useWebMpegTsPlayback.web';
|
|
4
4
|
import { useWebHlsPlayback } from '../native/media/web/useWebHlsPlayback.web';
|
|
@@ -6,6 +6,9 @@ import { useWebAc3AudioPlayback } from '../native/media/web/useWebAc3AudioPlayba
|
|
|
6
6
|
import { YouTubeVideoPlayer } from '../native/media/YouTubeVideoPlayer.web.js';
|
|
7
7
|
import { getYouTubeVideoId, getWebRuntimePlatform, useResolvedPlayerSource } from '../utils/sourceUtils';
|
|
8
8
|
import { EMOJI_GROUPS, searchEmojis } from '../data/emoji';
|
|
9
|
+
import { createRecordingDownloadLink, createVideoRecordingStream, downloadRecording, getRecordingMimeType } from '../utils/webRecording';
|
|
10
|
+
import { invokePlayerAction } from '../utils/invokePlayerAction.js';
|
|
11
|
+
import { emitProgressBarTime } from '../utils/progressBarTime.js';
|
|
9
12
|
import './styles.css';
|
|
10
13
|
|
|
11
14
|
const h = React.createElement;
|
|
@@ -21,10 +24,23 @@ const DEFAULT_THEME = {
|
|
|
21
24
|
const DEFAULT_ICONS = {
|
|
22
25
|
play: 'play', pause: 'pause', restart: 'restart', lock: 'lock', unlock: 'lock-open',
|
|
23
26
|
mute: 'volume-mute', unmute: 'volume-high', aspectRatio: 'aspect-ratio', videoOnly: 'video',
|
|
24
|
-
audio: 'music-note', back: 'arrow-left', recording: 'record-rec', stop: 'stop',
|
|
27
|
+
audio: 'music-note', audioOnly: 'headphones', back: 'arrow-left', recording: 'record-rec', stop: 'stop',
|
|
25
28
|
liveChat: 'comment-text-multiple-outline', epg: 'television-classic', diagnostics: 'logs',
|
|
26
29
|
fullscreen: 'fullscreen', close: 'close',
|
|
27
30
|
};
|
|
31
|
+
const DEFAULT_ASPECT_RATIOS = ['FIT', 'FILL', 'STRETCH', '16:9', '4:3', '21:9', '1:1'];
|
|
32
|
+
|
|
33
|
+
function normalizeAspectRatios(aspectRatios) {
|
|
34
|
+
const values = Array.isArray(aspectRatios) && aspectRatios.length ? aspectRatios : DEFAULT_ASPECT_RATIOS;
|
|
35
|
+
const unique = new Map();
|
|
36
|
+
values.forEach((item) => {
|
|
37
|
+
const value = typeof item === 'string' ? item : item?.value;
|
|
38
|
+
if (typeof value !== 'string' || !value.trim()) return;
|
|
39
|
+
const label = typeof item === 'string' ? item : item.label || value;
|
|
40
|
+
unique.set(value, { value, label });
|
|
41
|
+
});
|
|
42
|
+
return unique.size ? [...unique.values()] : DEFAULT_ASPECT_RATIOS.map((value) => ({ value, label: value }));
|
|
43
|
+
}
|
|
28
44
|
|
|
29
45
|
// Inline SVG keeps the React DOM/Electron entry independent of React Native,
|
|
30
46
|
// Expo, and native icon-font packages. The native entry uses Expo vector icons.
|
|
@@ -34,11 +50,12 @@ const WEB_ICON_PATHS = {
|
|
|
34
50
|
restart: 'M12 5V1L7 6l5 5V7a6 6 0 1 1-5.65 8H4.26A9 9 0 1 0 12 5z',
|
|
35
51
|
lock: 'M18 8h-1V6a5 5 0 0 0-10 0v2H6a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V10a2 2 0 0 0-2-2zM9 6a3 3 0 0 1 6 0v2H9zm3 11a2 2 0 1 1 0-4 2 2 0 0 1 0 4z',
|
|
36
52
|
'lock-open': 'M18 8h-1V6a5 5 0 0 0-9.8-1H9a3 3 0 0 1 6 .8V8H6a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V10a2 2 0 0 0-2-2zm-6 9a2 2 0 1 1 0-4 2 2 0 0 1 0 4z',
|
|
37
|
-
'volume-mute': 'M3 9v6h4l5 5V4L7 9H3zm13.
|
|
53
|
+
'volume-mute': 'M3 9v6h4l5 5V4L7 9H3zm13.59 3 2.12-2.12 1.41 1.41L19 13.41l2.12 2.12-1.41 1.41-2.12-2.12-2.12 2.12-1.41-1.41 2.12-2.12-2.12-2.12 1.41-1.41z',
|
|
38
54
|
'volume-high': 'M3 9v6h4l5 5V4L7 9H3zm13.5 3a4.5 4.5 0 0 0-2.5-4.03v8.05A4.5 4.5 0 0 0 16.5 12zM14 3.23v2.06a7 7 0 0 1 0 13.42v2.06a9 9 0 0 0 0-17.54z',
|
|
39
55
|
'aspect-ratio': 'M3 5h18v14H3zm2 2v10h14V7zm2 2h4v2H9v4H7zm10 6h-4v-2h2V9h2z',
|
|
40
56
|
video: 'M18 7V5a2 2 0 0 0-2-2H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-2l4 4V5zm-2 12H4V5h12z',
|
|
41
57
|
'music-note': 'M12 3v12.26A4 4 0 1 0 14 19V7h6V3z',
|
|
58
|
+
headphones: 'M12 3a9 9 0 0 0-9 9v6a3 3 0 0 0 3 3h3v-9H5v-1a7 7 0 0 1 14 0v1h-4v9h3a3 3 0 0 0 3-3v-6a9 9 0 0 0-9-9z',
|
|
42
59
|
'arrow-collapse': 'M4 4h6v2H6v4H4zm10 0h6v6h-2V6h-4zM4 14h2v4h4v2H4zm14 0h2v6h-6v-2h4z',
|
|
43
60
|
logs: 'M3 3h18v18H3zm2 2v14h14V5zm3 3h2v2H8zm4 0h7v2h-7zm-4 4h2v2H8zm4 0h7v2h-7zm-4 4h2v2H8zm4 0h7v2h-7z',
|
|
44
61
|
'arrow-left': 'M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20z',
|
|
@@ -48,6 +65,13 @@ const WEB_ICON_PATHS = {
|
|
|
48
65
|
'television-classic': 'M21 3H3a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2h7v2h4v-2h7a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2zm0 15H3V5h18zM5 7h14v9H5z',
|
|
49
66
|
fullscreen: 'M4 4h6v2H6v4H4zm10 0h6v6h-2V6h-4zM4 14h2v4h4v2H4zm14 0h2v6h-6v-2h4z',
|
|
50
67
|
close: 'M19 6.41 17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z',
|
|
68
|
+
send: 'M2 21 23 12 2 3v7l15 2-15 2z',
|
|
69
|
+
network: 'M12 2a3 3 0 1 0 0 6 3 3 0 0 0 0-6zM4 16a3 3 0 1 0 0 6 3 3 0 0 0 0-6zm16 0a3 3 0 1 0 0 6 3 3 0 0 0 0-6zM10.7 7.4 5.3 16.7l1.7 1 5.4-9.3zm2.6 0 5.4 9.3-1.7 1-5.4-9.3z',
|
|
70
|
+
speedometer: 'M12 3a9 9 0 1 0 9 9h-2a7 7 0 1 1-2.05-4.95l1.42-1.42A8.96 8.96 0 0 0 12 3zm7.7 1.3-6.4 6.4a2.5 2.5 0 1 0 1.4 1.4l6.4-6.4z',
|
|
71
|
+
buffer: 'M4 5h16v3H4zm0 5.5h16v3H4zm0 5.5h16v3H4z',
|
|
72
|
+
clock: 'M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20zm0 18a8 8 0 1 1 0-16 8 8 0 0 1 0 16zm1-13h-2v6l5 3 1-1.7-4-2.3z',
|
|
73
|
+
display: 'M3 4h18v13H3zm2 2v9h14V6zm5 13h4v2h-4z',
|
|
74
|
+
frames: 'M4 4h16v12H4zm2 2v8h12V6zm-4 13h16v2H2zm4-3h16v2H6z',
|
|
51
75
|
};
|
|
52
76
|
|
|
53
77
|
function getSource(source, url) {
|
|
@@ -119,71 +143,94 @@ function getPanelActionName(panel) {
|
|
|
119
143
|
function WebSeekControl({ currentTime, duration, theme, onSeek }) {
|
|
120
144
|
const [seekDraft, setSeekDraft] = useState(currentTime);
|
|
121
145
|
const [isDragging, setIsDragging] = useState(false);
|
|
146
|
+
const seekDraftRef = useRef(currentTime);
|
|
122
147
|
|
|
123
148
|
useEffect(() => {
|
|
124
|
-
if (!isDragging)
|
|
149
|
+
if (!isDragging) {
|
|
150
|
+
seekDraftRef.current = currentTime;
|
|
151
|
+
setSeekDraft(currentTime);
|
|
152
|
+
}
|
|
125
153
|
}, [currentTime, isDragging]);
|
|
126
154
|
|
|
127
|
-
const
|
|
155
|
+
const updateDraft = (event) => {
|
|
128
156
|
const nextTime = Number(event.currentTarget.value);
|
|
157
|
+
seekDraftRef.current = nextTime;
|
|
129
158
|
setSeekDraft(nextTime);
|
|
130
|
-
onSeek(nextTime);
|
|
131
159
|
};
|
|
132
160
|
|
|
133
161
|
const finishSeek = (event) => {
|
|
134
162
|
if (!isDragging) return;
|
|
135
|
-
|
|
163
|
+
const nextTime = event?.currentTarget && event.type !== 'blur'
|
|
164
|
+
? Number(event.currentTarget.value)
|
|
165
|
+
: seekDraftRef.current;
|
|
166
|
+
seekDraftRef.current = nextTime;
|
|
167
|
+
setSeekDraft(nextTime);
|
|
136
168
|
setIsDragging(false);
|
|
169
|
+
onSeek(nextTime);
|
|
137
170
|
};
|
|
171
|
+
const displayTime = Math.min(isDragging ? seekDraft : currentTime, duration);
|
|
172
|
+
const remainingTime = Math.max(0, duration - displayTime);
|
|
138
173
|
|
|
139
174
|
return h('div', { className: 'cinecrew-player__seek' },
|
|
140
|
-
h('span', null, formatTime(
|
|
175
|
+
h('span', null, formatTime(displayTime)),
|
|
141
176
|
h('input', {
|
|
142
177
|
type: 'range',
|
|
143
178
|
'aria-label': 'Seek video',
|
|
144
179
|
min: 0,
|
|
145
180
|
max: duration,
|
|
146
181
|
step: 'any',
|
|
147
|
-
value:
|
|
148
|
-
onChange:
|
|
182
|
+
value: displayTime,
|
|
183
|
+
onChange: updateDraft,
|
|
149
184
|
onPointerDown: (event) => {
|
|
150
185
|
event.stopPropagation();
|
|
151
186
|
setIsDragging(true);
|
|
152
|
-
|
|
187
|
+
seekDraftRef.current = Number(event.currentTarget.value);
|
|
188
|
+
setSeekDraft(seekDraftRef.current);
|
|
153
189
|
},
|
|
154
190
|
onPointerUp: finishSeek,
|
|
155
191
|
onPointerCancel: finishSeek,
|
|
156
192
|
onBlur: finishSeek,
|
|
193
|
+
onKeyDown: (event) => {
|
|
194
|
+
if (['ArrowLeft', 'ArrowRight', 'Home', 'End', 'PageUp', 'PageDown'].includes(event.key)) setIsDragging(true);
|
|
195
|
+
},
|
|
196
|
+
onKeyUp: (event) => {
|
|
197
|
+
if (['ArrowLeft', 'ArrowRight', 'Home', 'End', 'PageUp', 'PageDown'].includes(event.key)) finishSeek(event);
|
|
198
|
+
},
|
|
157
199
|
style: { accentColor: theme.accentColor },
|
|
158
200
|
}),
|
|
159
|
-
h('span', null, formatTime(
|
|
201
|
+
h('span', null, `−${formatTime(remainingTime)}`));
|
|
160
202
|
}
|
|
161
203
|
|
|
162
|
-
function WebAspectRatioMenu({ open, theme, icons, onToggle, onSelect }) {
|
|
163
|
-
const ratios = ['FIT', 'FILL', 'STRETCH', '16:9', '4:3', '1:1'];
|
|
204
|
+
function WebAspectRatioMenu({ open, theme, icons, onToggle, onSelect, ratios, selectedRatio }) {
|
|
164
205
|
let menu = null;
|
|
165
206
|
if (open) {
|
|
166
207
|
menu = h('div', { className: 'cinecrew-player__menu', style: { background: theme.surfaceColor } },
|
|
167
208
|
ratios.map((ratio) => h('button', {
|
|
168
|
-
key: ratio,
|
|
209
|
+
key: ratio.value,
|
|
169
210
|
type: 'button',
|
|
211
|
+
className: `cinecrew-player__menu-option${selectedRatio === ratio.value ? ' is-selected' : ''}`,
|
|
212
|
+
'aria-pressed': selectedRatio === ratio.value,
|
|
170
213
|
onClick: () => onSelect(ratio),
|
|
171
|
-
}, ratio)));
|
|
214
|
+
}, ratio.label)));
|
|
172
215
|
}
|
|
173
216
|
return h('div', { className: 'cinecrew-player__menu-wrap cinecrew-player__menu-wrap--aspect', key: 'aspectRatio' },
|
|
174
217
|
h(PlayerButton, { name: 'aspectRatio', label: 'Aspect ratio', icons, theme, onClick: onToggle, active: open }),
|
|
175
218
|
menu);
|
|
176
219
|
}
|
|
177
220
|
|
|
178
|
-
function WebAudioTrackMenu({ open, tracks, theme, icons, onToggle, onSelect }) {
|
|
221
|
+
function WebAudioTrackMenu({ open, tracks, selectedTrackId, theme, icons, onToggle, onSelect }) {
|
|
179
222
|
let menu = null;
|
|
180
223
|
if (open) {
|
|
181
224
|
menu = h('div', { className: 'cinecrew-player__menu', style: { background: theme.surfaceColor } },
|
|
182
|
-
tracks.
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
225
|
+
tracks.length
|
|
226
|
+
? tracks.map((track, index) => h('button', {
|
|
227
|
+
key: track.id ?? index,
|
|
228
|
+
type: 'button',
|
|
229
|
+
className: String(track.id) === String(selectedTrackId) ? 'cinecrew-player__menu-option is-selected' : 'cinecrew-player__menu-option',
|
|
230
|
+
'aria-pressed': String(track.id) === String(selectedTrackId),
|
|
231
|
+
onClick: () => onSelect(track.id),
|
|
232
|
+
}, track.name || track.language || `Track ${index + 1}`))
|
|
233
|
+
: h('div', { className: 'cinecrew-player__menu-empty', role: 'status' }, 'No audio tracks available'));
|
|
187
234
|
}
|
|
188
235
|
return h('div', { className: 'cinecrew-player__menu-wrap', key: 'audioTracks' },
|
|
189
236
|
h(PlayerButton, { name: 'audio', label: 'Audio tracks', icons, theme, onClick: onToggle, active: open }),
|
|
@@ -203,8 +250,8 @@ function WebPlaybackRateControl({ value, onChange }) {
|
|
|
203
250
|
function WebBottomControls(props) {
|
|
204
251
|
const {
|
|
205
252
|
isLive, overrides, theme, icons, currentTime, duration, seekTo,
|
|
206
|
-
showAspectMenu, setShowAspectMenu, selectAspect,
|
|
207
|
-
|
|
253
|
+
showAspectMenu, setShowAspectMenu, selectAspect, aspectRatios, selectedAspectRatio,
|
|
254
|
+
audioOnly, setAudioOnlyMode, availableTracks, selectedAudioTrack,
|
|
208
255
|
showAudioMenu, setShowAudioMenu, selectAudio, playbackRate,
|
|
209
256
|
setPlaybackRateAction, fullscreen, toggleFullscreen,
|
|
210
257
|
} = props;
|
|
@@ -220,16 +267,19 @@ function WebBottomControls(props) {
|
|
|
220
267
|
icons,
|
|
221
268
|
onToggle: () => setShowAspectMenu((value) => !value),
|
|
222
269
|
onSelect: (ratio) => {
|
|
223
|
-
selectAspect(ratio);
|
|
270
|
+
selectAspect(ratio.value);
|
|
224
271
|
setShowAspectMenu(false);
|
|
225
272
|
},
|
|
273
|
+
ratios: aspectRatios,
|
|
274
|
+
selectedRatio: selectedAspectRatio,
|
|
226
275
|
});
|
|
227
276
|
}
|
|
228
277
|
let audioTracks = null;
|
|
229
|
-
if (
|
|
278
|
+
if (isControlEnabled(overrides, 'audioTracks', true)) {
|
|
230
279
|
audioTracks = h(WebAudioTrackMenu, {
|
|
231
280
|
open: showAudioMenu,
|
|
232
281
|
tracks: availableTracks,
|
|
282
|
+
selectedTrackId: selectedAudioTrack,
|
|
233
283
|
theme,
|
|
234
284
|
icons,
|
|
235
285
|
onToggle: () => setShowAudioMenu((value) => !value),
|
|
@@ -243,25 +293,66 @@ function WebBottomControls(props) {
|
|
|
243
293
|
if (isControlEnabled(overrides, 'playbackRate', true) && !isLive) {
|
|
244
294
|
playbackRateControl = h(WebPlaybackRateControl, { value: playbackRate, onChange: setPlaybackRateAction });
|
|
245
295
|
}
|
|
246
|
-
const
|
|
247
|
-
const audioOnlyLabel = audioOnly ? 'Switch to audio card' : 'Audio only';
|
|
296
|
+
const audioOnlyLabel = 'Audio only';
|
|
248
297
|
const fullscreenLabel = fullscreen ? 'Exit full screen' : 'Full screen';
|
|
249
298
|
|
|
250
299
|
return h('div', { className: 'cinecrew-player__bottom-controls' },
|
|
251
300
|
seek,
|
|
252
301
|
h('div', { className: 'cinecrew-player__bottom-actions' },
|
|
253
302
|
h('div', { className: 'cinecrew-player__bottom-left-actions' },
|
|
254
|
-
renderControlButton({ name: '
|
|
303
|
+
renderControlButton({ name: 'audioOnly', label: audioOnlyLabel, callback: () => setAudioOnlyMode(true), options: { active: audioOnly }, overrides, icons, theme }),
|
|
255
304
|
aspect),
|
|
256
305
|
h('div', { className: 'cinecrew-player__bottom-right-actions' },
|
|
257
|
-
renderControlButton({ name: 'audioOnly', label: audioOnlyLabel, callback: () => setAudioOnlyMode(true), options: { icon: 'audio', active: audioOnly }, overrides, icons, theme }),
|
|
258
306
|
audioTracks,
|
|
259
307
|
playbackRateControl,
|
|
260
308
|
renderControlButton({ name: 'fullscreen', label: fullscreenLabel, callback: toggleFullscreen, options: { icon: 'fullscreen' }, overrides, icons, theme }))));
|
|
261
309
|
}
|
|
262
310
|
|
|
263
|
-
function
|
|
264
|
-
|
|
311
|
+
function getRecordingOverlayContent({ status, elapsed, error, downloadLink, onPause, onResume, onStop, onDownload, onDismiss }) {
|
|
312
|
+
if (status === 'complete') {
|
|
313
|
+
const children = [];
|
|
314
|
+
if (error) children.push(h('span', null, error));
|
|
315
|
+
children.push(h('strong', { className: 'cinecrew-player__recording-ready' }, 'Recording ready'));
|
|
316
|
+
if (downloadLink) {
|
|
317
|
+
children.push(h('a', {
|
|
318
|
+
className: 'cinecrew-player__recording-download',
|
|
319
|
+
href: downloadLink.url,
|
|
320
|
+
download: downloadLink.filename,
|
|
321
|
+
onClick: onDownload,
|
|
322
|
+
}, 'Download recording'));
|
|
323
|
+
}
|
|
324
|
+
children.push(h('button', { type: 'button', onClick: onDismiss, 'aria-label': 'Dismiss recording controls' }, 'Done'));
|
|
325
|
+
return h(React.Fragment, null, ...children);
|
|
326
|
+
}
|
|
327
|
+
if (status === 'finalizing') {
|
|
328
|
+
return h('strong', { className: 'cinecrew-player__recording-ready' }, 'Preparing recording…');
|
|
329
|
+
}
|
|
330
|
+
if (error) return h('span', null, error);
|
|
331
|
+
|
|
332
|
+
const isPaused = status === 'paused';
|
|
333
|
+
return h(React.Fragment, null,
|
|
334
|
+
h('span', { className: 'cinecrew-player__recording-indicator' }, 'REC'),
|
|
335
|
+
h('strong', { className: 'cinecrew-player__recording-timer' }, formatTime(elapsed / 1000)),
|
|
336
|
+
h('div', { className: 'cinecrew-player__recording-actions' },
|
|
337
|
+
h('button', { type: 'button', onClick: isPaused ? onResume : onPause }, isPaused ? 'Resume' : 'Pause'),
|
|
338
|
+
h('button', { type: 'button', onClick: onStop }, 'Stop & download')));
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function WebRecordingOverlay(props) {
|
|
342
|
+
const { status, error, theme, onDismiss } = props;
|
|
343
|
+
if (status === 'idle' && !error) return null;
|
|
344
|
+
const dismissButton = error
|
|
345
|
+
? h('button', { type: 'button', onClick: onDismiss, 'aria-label': 'Dismiss recording message' }, '×')
|
|
346
|
+
: null;
|
|
347
|
+
return h('div', {
|
|
348
|
+
className: `cinecrew-player__recording-overlay${error ? ' is-error' : ''}`,
|
|
349
|
+
style: { color: theme.controlColor },
|
|
350
|
+
role: error ? 'alert' : 'status',
|
|
351
|
+
}, getRecordingOverlayContent(props), dismissButton);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function WebPlayerControls({ locked, buffering, overrides, theme, icons, unlockedControls, toggleLock, paused, title, togglePlay, bottomProps }) {
|
|
355
|
+
let leftControls = locked ? null : unlockedControls.left;
|
|
265
356
|
let rightControls = unlockedControls.right;
|
|
266
357
|
if (locked) {
|
|
267
358
|
rightControls = renderControlButton({
|
|
@@ -272,7 +363,7 @@ function WebPlayerControls({ locked, buffering, overrides, theme, icons, unlocke
|
|
|
272
363
|
let centerControls = null;
|
|
273
364
|
let bottomControls = null;
|
|
274
365
|
if (!locked) {
|
|
275
|
-
if (!buffering) {
|
|
366
|
+
if (!buffering && bottomProps.recordingStatus === 'idle') {
|
|
276
367
|
const playLabel = paused ? 'Play' : 'Pause';
|
|
277
368
|
const playIcon = paused ? 'play' : 'pause';
|
|
278
369
|
centerControls = h('div', { className: 'cinecrew-player__center-controls' },
|
|
@@ -280,11 +371,25 @@ function WebPlayerControls({ locked, buffering, overrides, theme, icons, unlocke
|
|
|
280
371
|
}
|
|
281
372
|
bottomControls = h(WebBottomControls, bottomProps);
|
|
282
373
|
}
|
|
283
|
-
|
|
374
|
+
const recordingUiVisible = bottomProps.recordingStatus !== 'idle' || Boolean(bottomProps.recordingError);
|
|
375
|
+
return h('div', { className: `cinecrew-player__controls${recordingUiVisible ? ' is-recording' : ''}`, style: { color: theme.controlColor } },
|
|
284
376
|
h('div', { className: 'cinecrew-player__top-controls' },
|
|
285
377
|
h('div', { className: 'cinecrew-player__top-left-actions' }, leftControls),
|
|
378
|
+
!locked && paused && title ? h('div', { className: 'cinecrew-player__title', style: { color: theme.controlColor }, title }, title) : null,
|
|
286
379
|
h('div', { className: 'cinecrew-player__top-right-actions' }, rightControls)),
|
|
287
380
|
centerControls,
|
|
381
|
+
h(WebRecordingOverlay, {
|
|
382
|
+
status: bottomProps.recordingStatus,
|
|
383
|
+
elapsed: bottomProps.recordingElapsed,
|
|
384
|
+
error: bottomProps.recordingError,
|
|
385
|
+
theme,
|
|
386
|
+
downloadLink: bottomProps.recordingDownloadLink,
|
|
387
|
+
onPause: bottomProps.pauseRecording,
|
|
388
|
+
onResume: bottomProps.resumeRecording,
|
|
389
|
+
onStop: bottomProps.stopRecording,
|
|
390
|
+
onDownload: bottomProps.downloadRecording,
|
|
391
|
+
onDismiss: bottomProps.clearRecordingError,
|
|
392
|
+
}),
|
|
288
393
|
bottomControls);
|
|
289
394
|
}
|
|
290
395
|
|
|
@@ -330,10 +435,31 @@ function WebPlayerSurface({
|
|
|
330
435
|
onStateChange,
|
|
331
436
|
onError,
|
|
332
437
|
onEnded,
|
|
333
|
-
style: drawerResize ? { width: '64%', height: '
|
|
438
|
+
style: drawerResize ? { width: 'var(--cinecrew-media-width, 64%)', height: '100%', inset: '0 auto 0 0' } : undefined,
|
|
334
439
|
});
|
|
335
440
|
}
|
|
336
441
|
if (!streamUrl) return h('div', { className: 'cinecrew-player__empty' });
|
|
442
|
+
let resizedVideoStyle = {};
|
|
443
|
+
if (drawerResize && videoStyle.width === 'auto') {
|
|
444
|
+
resizedVideoStyle = {
|
|
445
|
+
left: 'calc(var(--cinecrew-media-width, 64%) / 2)',
|
|
446
|
+
top: '50%',
|
|
447
|
+
right: 'auto',
|
|
448
|
+
bottom: 'auto',
|
|
449
|
+
maxWidth: 'var(--cinecrew-media-width, 64%)',
|
|
450
|
+
transform: 'translate(-50%, -50%)',
|
|
451
|
+
};
|
|
452
|
+
} else if (drawerResize) {
|
|
453
|
+
resizedVideoStyle = {
|
|
454
|
+
width: 'var(--cinecrew-media-width, 64%)',
|
|
455
|
+
height: '100%',
|
|
456
|
+
left: 0,
|
|
457
|
+
top: 0,
|
|
458
|
+
right: 'auto',
|
|
459
|
+
bottom: 0,
|
|
460
|
+
transform: 'none',
|
|
461
|
+
};
|
|
462
|
+
}
|
|
337
463
|
return h('video', {
|
|
338
464
|
ref: videoRef,
|
|
339
465
|
className: 'cinecrew-player__video',
|
|
@@ -345,7 +471,7 @@ function WebPlayerSurface({
|
|
|
345
471
|
preload: 'auto',
|
|
346
472
|
style: {
|
|
347
473
|
...videoStyle,
|
|
348
|
-
...
|
|
474
|
+
...resizedVideoStyle,
|
|
349
475
|
opacity: audioOnly ? 0 : 1,
|
|
350
476
|
},
|
|
351
477
|
onClick: inlinePreview ? onPromotePreview : undefined,
|
|
@@ -357,12 +483,15 @@ function WebPlayerSurface({
|
|
|
357
483
|
});
|
|
358
484
|
}
|
|
359
485
|
|
|
360
|
-
function WebAudioOnlyCard({ poster, title, theme, onSwitchToVideo }) {
|
|
486
|
+
function WebAudioOnlyCard({ poster, title, theme, icons, onSwitchToVideo }) {
|
|
361
487
|
return h('div', {
|
|
362
488
|
className: 'cinecrew-player__audio-card',
|
|
363
489
|
style: { background: theme.surfaceColor, color: theme.controlColor, borderColor: theme.accentColor },
|
|
364
490
|
},
|
|
365
|
-
poster
|
|
491
|
+
poster
|
|
492
|
+
? h('img', { className: 'cinecrew-player__audio-poster', src: poster, alt: '' })
|
|
493
|
+
: h('div', { className: 'cinecrew-player__audio-placeholder', role: 'img', 'aria-label': 'Audio artwork placeholder' },
|
|
494
|
+
h(Icon, { name: 'audioOnly', icons, color: theme.accentColor })),
|
|
366
495
|
h('span', { className: 'cinecrew-player__audio-wave', style: { color: theme.accentColor }, 'aria-hidden': true }, '•••••••'),
|
|
367
496
|
h('strong', null, title || 'Audio only'),
|
|
368
497
|
h('button', {
|
|
@@ -404,23 +533,59 @@ function getInlinePlayerStyle(inlinePreview, rect) {
|
|
|
404
533
|
return { position: 'absolute', left: rect.x, top: rect.y, width: rect.width, height: rect.height };
|
|
405
534
|
}
|
|
406
535
|
|
|
536
|
+
function stopBrowserRecorder(recorderRef, completionRef, finalizerRef) {
|
|
537
|
+
return new Promise((resolve, reject) => {
|
|
538
|
+
const recorder = recorderRef.current;
|
|
539
|
+
const completion = completionRef.current;
|
|
540
|
+
if (!recorder || recorder.state === 'inactive') {
|
|
541
|
+
finalizerRef.current?.();
|
|
542
|
+
Promise.resolve(completion).then(resolve, reject);
|
|
543
|
+
return;
|
|
544
|
+
}
|
|
545
|
+
recorder.addEventListener('error', (event) => reject(event.error || new Error('Recording failed.')), { once: true });
|
|
546
|
+
recorder.stop();
|
|
547
|
+
if (completion) completion.then(resolve, reject);
|
|
548
|
+
else resolve();
|
|
549
|
+
});
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
function waitForRecordingFinalization(completion) {
|
|
553
|
+
let timeoutId;
|
|
554
|
+
return Promise.race([
|
|
555
|
+
completion,
|
|
556
|
+
new Promise((_, reject) => {
|
|
557
|
+
timeoutId = window.setTimeout(() => reject(new Error('The browser did not finish finalizing the recording. Try again with a local or CORS-enabled source.')), 10000);
|
|
558
|
+
}),
|
|
559
|
+
]).finally(() => window.clearTimeout(timeoutId));
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
async function performRecordingStop({ stop, action, recordingElapsed, streamUrl, title, recorderRef, completionRef, finalizerRef, setRecordingStatus }) {
|
|
563
|
+
if (!stop) setRecordingStatus('finalizing');
|
|
564
|
+
let stopCore = () => stopBrowserRecorder(recorderRef, completionRef, finalizerRef);
|
|
565
|
+
if (stop) stopCore = () => stop();
|
|
566
|
+
await action('onRecordingStop', stopCore, { elapsedMs: recordingElapsed, source: streamUrl, title });
|
|
567
|
+
if (!stop) await waitForRecordingFinalization(completionRef.current);
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
function updateRecordingStopState({ clock, recordingBlobRef, stop, setRecordingElapsed, setRecordingStatus, setRecordingError }) {
|
|
571
|
+
if (clock.startedAt) clock.accumulatedMs += Date.now() - clock.startedAt;
|
|
572
|
+
setRecordingElapsed(clock.accumulatedMs);
|
|
573
|
+
const hasRecording = Boolean(recordingBlobRef.current);
|
|
574
|
+
setRecordingStatus(hasRecording ? 'complete' : 'idle');
|
|
575
|
+
if (!hasRecording && !stop) {
|
|
576
|
+
setRecordingError((current) => current || 'The browser returned no recorded media data for this source. Try a local or CORS-enabled video while it is playing.');
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
|
|
407
580
|
function buildUnlockedControls({
|
|
408
581
|
control, onBack, isLive, restart, toggleLock, toggleMute, muted, hasRecording,
|
|
409
|
-
|
|
410
|
-
hasChat, hasEpg, hasDiagnostics, handleOpenPanel,
|
|
582
|
+
recordingStatus, startRecording, resumeRecording, stopRecording, activePanel,
|
|
583
|
+
hasChat, hasEpg, hasDiagnostics, handleOpenPanel, backVisible,
|
|
411
584
|
}) {
|
|
412
585
|
const leftControls = [control('back', 'Back', onBack, { defaultVisible: backVisible })];
|
|
413
586
|
let recordingControl = null;
|
|
414
587
|
if (hasRecording) {
|
|
415
|
-
|
|
416
|
-
const recordingAction = recording ? 'onRecordingStop' : 'onRecordingStart';
|
|
417
|
-
const handleRecordingToggle = async () => {
|
|
418
|
-
const handler = recording ? integrations.recording?.stop : integrations.recording?.start;
|
|
419
|
-
const fallback = handler ? () => handler({ getVideoElement: () => videoRef.current, streamUrl, title }) : undefined;
|
|
420
|
-
await action(recordingAction, fallback, { source: streamUrl, title });
|
|
421
|
-
setRecording((value) => !value);
|
|
422
|
-
};
|
|
423
|
-
recordingControl = control('recording', recordingLabel, handleRecordingToggle, { active: recording });
|
|
588
|
+
recordingControl = buildRecordingControl(control, recordingStatus, startRecording, resumeRecording, stopRecording);
|
|
424
589
|
}
|
|
425
590
|
const chatLabel = activePanel === 'chat' ? 'Close live chat' : 'Live chat';
|
|
426
591
|
const epgLabel = activePanel === 'epg' ? 'Close programme guide' : 'Programme guide';
|
|
@@ -439,6 +604,23 @@ function buildUnlockedControls({
|
|
|
439
604
|
return { left: leftControls, right: rightControls };
|
|
440
605
|
}
|
|
441
606
|
|
|
607
|
+
function buildRecordingControl(control, status, startRecording, resumeRecording, stopRecording) {
|
|
608
|
+
let label = 'Start recording';
|
|
609
|
+
let callback = startRecording;
|
|
610
|
+
if (status === 'recording') {
|
|
611
|
+
label = 'Stop recording';
|
|
612
|
+
callback = stopRecording;
|
|
613
|
+
} else if (status === 'paused') {
|
|
614
|
+
label = 'Resume recording';
|
|
615
|
+
callback = resumeRecording;
|
|
616
|
+
} else if (status === 'finalizing') {
|
|
617
|
+
label = 'Preparing recording';
|
|
618
|
+
callback = () => {};
|
|
619
|
+
}
|
|
620
|
+
const isFinalizing = status === 'finalizing';
|
|
621
|
+
return control('recording', label, callback, { active: status !== 'idle', disabled: isFinalizing });
|
|
622
|
+
}
|
|
623
|
+
|
|
442
624
|
function getDrawerLabel(activePanel) {
|
|
443
625
|
if (activePanel === 'chat') return 'Live chat drawer';
|
|
444
626
|
if (activePanel === 'epg') return 'Programme guide drawer';
|
|
@@ -463,14 +645,11 @@ function WebPlayerLayout(props) {
|
|
|
463
645
|
unlockedControls: props.unlockedControls,
|
|
464
646
|
toggleLock: props.toggleLock,
|
|
465
647
|
paused: props.isPaused,
|
|
648
|
+
title: props.title,
|
|
466
649
|
togglePlay: props.togglePlay,
|
|
467
650
|
bottomProps: props.bottomControlProps,
|
|
468
651
|
});
|
|
469
652
|
}
|
|
470
|
-
let playerTitle = null;
|
|
471
|
-
if (props.title && !props.audioOnly) {
|
|
472
|
-
playerTitle = h('div', { className: 'cinecrew-player__title', style: { color: props.theme.controlColor } }, props.title);
|
|
473
|
-
}
|
|
474
653
|
let loadingNotice = null;
|
|
475
654
|
if (props.buffering && !props.error) {
|
|
476
655
|
loadingNotice = h('div', { className: 'cinecrew-player__status', style: { color: props.theme.controlColor } },
|
|
@@ -478,7 +657,7 @@ function WebPlayerLayout(props) {
|
|
|
478
657
|
}
|
|
479
658
|
let audioCard = null;
|
|
480
659
|
if (props.audioOnly) {
|
|
481
|
-
audioCard = h(WebAudioOnlyCard, { poster: props.poster, title: props.title, theme: props.theme, onSwitchToVideo: props.onSwitchToVideo });
|
|
660
|
+
audioCard = h(WebAudioOnlyCard, { poster: props.poster, title: props.title, theme: props.theme, icons: props.icons, onSwitchToVideo: props.onSwitchToVideo });
|
|
482
661
|
}
|
|
483
662
|
let panelNode = null;
|
|
484
663
|
if (props.activePanel && props.webPanel) {
|
|
@@ -491,15 +670,14 @@ function WebPlayerLayout(props) {
|
|
|
491
670
|
return h('div', {
|
|
492
671
|
ref: props.playerRef,
|
|
493
672
|
className: `cinecrew-player${props.inlinePreview ? ' cinecrew-player--inline-preview' : ''}${props.drawerMode === 'resize' && props.activePanel ? ' cinecrew-player--drawer-resize' : ''} ${props.className}`.trim(),
|
|
494
|
-
style: { ...props.rootStyle, ...props.style, background: props.theme.backgroundColor, borderRadius: props.theme.borderRadius, '--cinecrew-accent': props.theme.accentColor, '--cinecrew-text': props.theme.controlColor, '--cinecrew-surface': props.theme.surfaceColor },
|
|
673
|
+
style: { ...props.rootStyle, ...props.style, background: props.theme.backgroundColor, borderRadius: props.theme.borderRadius, '--cinecrew-accent': props.theme.accentColor, '--cinecrew-text': props.theme.controlColor, '--cinecrew-surface': props.theme.surfaceColor, '--cinecrew-media-width': '64%' },
|
|
495
674
|
onWheel: props.onWheel,
|
|
496
675
|
'data-stream-mode': props.streamMode,
|
|
497
676
|
},
|
|
498
677
|
props.mediaSurface,
|
|
499
678
|
h('div', { className: 'cinecrew-player__shade', style: { background: 'linear-gradient(180deg, rgba(0,0,0,.48), transparent 28%, transparent 68%, rgba(0,0,0,.64))' } }),
|
|
500
|
-
playerTitle,
|
|
501
679
|
loadingNotice,
|
|
502
|
-
h(WebPlayerError, { error: props.error, theme: props.theme, renderBackButton: props.renderBackButton }),
|
|
680
|
+
h(WebPlayerError, { error: props.error, theme: props.theme, renderBackButton: props.locked ? () => null : props.renderBackButton }),
|
|
503
681
|
controlLayer,
|
|
504
682
|
audioCard,
|
|
505
683
|
panelNode);
|
|
@@ -544,11 +722,11 @@ function getPanelRenderer(activePanel, renderLiveChat, renderEpg, integrations)
|
|
|
544
722
|
}
|
|
545
723
|
|
|
546
724
|
function createWebPanelNode({
|
|
547
|
-
renderer, integration, activePanel, integrations, source, title, theme, onClose,
|
|
725
|
+
renderer, integration, activePanel, integrations, source, title, theme, icons, onClose,
|
|
548
726
|
diagnostics, messagePageSize,
|
|
549
727
|
}) {
|
|
550
728
|
if (activePanel === 'diagnostics') {
|
|
551
|
-
return h(WebDiagnosticsPanel, { title, theme, ...diagnostics });
|
|
729
|
+
return h(WebDiagnosticsPanel, { title, theme, icons, onClose, ...diagnostics });
|
|
552
730
|
}
|
|
553
731
|
if (renderer) return h(renderer, { title, source, onClose });
|
|
554
732
|
if (!integration) return null;
|
|
@@ -568,26 +746,73 @@ function createWebPanelNode({
|
|
|
568
746
|
});
|
|
569
747
|
}
|
|
570
748
|
|
|
571
|
-
function
|
|
749
|
+
function getBufferedAhead(video, currentTime) {
|
|
750
|
+
if (!video?.buffered) return null;
|
|
751
|
+
for (let index = 0; index < video.buffered.length; index += 1) {
|
|
752
|
+
const start = video.buffered.start(index);
|
|
753
|
+
const end = video.buffered.end(index);
|
|
754
|
+
if (currentTime >= start && currentTime <= end) return Math.max(0, end - currentTime);
|
|
755
|
+
}
|
|
756
|
+
return 0;
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
function getFrameQuality(video) {
|
|
760
|
+
try {
|
|
761
|
+
const quality = video?.getVideoPlaybackQuality?.();
|
|
762
|
+
if (quality) return { dropped: quality.droppedVideoFrames, total: quality.totalVideoFrames };
|
|
763
|
+
} catch {
|
|
764
|
+
// Playback quality is optional and may not be exposed by a media engine.
|
|
765
|
+
}
|
|
766
|
+
if (Number.isFinite(video?.webkitDroppedFrameCount) || Number.isFinite(video?.webkitDecodedFrameCount)) {
|
|
767
|
+
return { dropped: video.webkitDroppedFrameCount, total: video.webkitDecodedFrameCount };
|
|
768
|
+
}
|
|
769
|
+
return null;
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
function WebDiagnosticsPanel({ title, theme, icons, onClose, streamMode, status, currentTime, duration, videoRef }) {
|
|
572
773
|
const video = videoRef?.current;
|
|
573
774
|
const resolution = video?.videoWidth && video?.videoHeight
|
|
574
775
|
? `${video.videoWidth} × ${video.videoHeight}`
|
|
575
776
|
: 'Not available';
|
|
576
|
-
const
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
777
|
+
const connection = globalThis.navigator?.connection;
|
|
778
|
+
const buffered = getBufferedAhead(video, currentTime);
|
|
779
|
+
const frames = getFrameQuality(video);
|
|
780
|
+
const cards = [
|
|
781
|
+
{ icon: 'display', color: '#48c9ff', label: 'Video resolution', value: resolution },
|
|
782
|
+
{ icon: 'network', color: '#43d6a0', label: 'Connection rate', value: Number.isFinite(connection?.downlink) ? `${connection.downlink} Mbps` : 'Not reported' },
|
|
783
|
+
{ icon: 'clock', color: '#ffbd59', label: 'Network RTT estimate', value: Number.isFinite(connection?.rtt) ? `${connection.rtt} ms` : 'Not reported' },
|
|
784
|
+
{ icon: 'buffer', color: '#b18cff', label: 'Buffered ahead', value: buffered === null ? 'Not reported' : `${buffered.toFixed(1)} sec` },
|
|
785
|
+
{ icon: 'frames', color: '#ff6f91', label: 'Dropped / total frames', value: frames && Number.isFinite(frames.dropped) ? `${frames.dropped} / ${frames.total ?? '—'}` : 'Not reported' },
|
|
786
|
+
{ icon: 'speedometer', color: '#47d0cf', label: 'Playback position', value: `${formatTime(currentTime)} / ${formatTime(duration)}` },
|
|
787
|
+
{ icon: 'network', color: '#ff9666', label: 'Connection type', value: connection?.effectiveType || 'Not reported' },
|
|
788
|
+
{ icon: 'video', color: '#93aaff', label: 'Stream format', value: streamMode || 'Unknown' },
|
|
582
789
|
];
|
|
583
790
|
return h('section', {
|
|
584
791
|
className: 'cinecrew-player__diagnostics',
|
|
585
792
|
style: { color: theme.controlColor },
|
|
586
793
|
'aria-label': 'Stream diagnostics',
|
|
587
794
|
},
|
|
588
|
-
h('
|
|
589
|
-
|
|
590
|
-
|
|
795
|
+
h('header', { className: 'cinecrew-player__diagnostics-heading' },
|
|
796
|
+
h('div', null,
|
|
797
|
+
h('strong', null, 'Stream diagnostics'),
|
|
798
|
+
h('span', null, title || 'Current playback')),
|
|
799
|
+
h('button', {
|
|
800
|
+
type: 'button',
|
|
801
|
+
className: 'cinecrew-player__diagnostics-close',
|
|
802
|
+
onClick: onClose,
|
|
803
|
+
'aria-label': 'Close stream diagnostics',
|
|
804
|
+
title: 'Close diagnostics',
|
|
805
|
+
}, h(Icon, { name: 'close', icons, color: theme.controlColor }))),
|
|
806
|
+
h('div', { className: 'cinecrew-player__diagnostics-status' },
|
|
807
|
+
h('span', { className: `cinecrew-player__status-dot is-${String(status || 'unknown').toLowerCase()}` }),
|
|
808
|
+
h('span', null, status || 'Unknown'),
|
|
809
|
+
h('span', null, streamMode || 'Unknown format')),
|
|
810
|
+
h('div', { className: 'cinecrew-player__diagnostic-grid' },
|
|
811
|
+
cards.map(({ icon, color, label, value }) => h('article', { className: 'cinecrew-player__diagnostic-card', key: label, style: { '--diagnostic-accent': color } },
|
|
812
|
+
h('span', { className: 'cinecrew-player__diagnostic-icon' }, h(Icon, { name: icon, icons, color })),
|
|
813
|
+
h('span', { className: 'cinecrew-player__diagnostic-label' }, label),
|
|
814
|
+
h('strong', null, value)))),
|
|
815
|
+
h('small', { className: 'cinecrew-player__diagnostics-note' }, 'Network rate and RTT are browser-reported estimates when supported; no extra ping or stream requests are sent.'));
|
|
591
816
|
}
|
|
592
817
|
|
|
593
818
|
function getChatMessageKey(message, index = 0) {
|
|
@@ -680,6 +905,24 @@ function WebEmojiPicker({ onSelect }) {
|
|
|
680
905
|
function WebChatComposer({ sending, canSend, onSend }) {
|
|
681
906
|
const [message, setMessage] = useState('');
|
|
682
907
|
const [emojiOpen, setEmojiOpen] = useState(false);
|
|
908
|
+
const emojiAnchorRef = useRef(null);
|
|
909
|
+
useEffect(() => {
|
|
910
|
+
if (!emojiOpen) return undefined;
|
|
911
|
+
const closeOnOutsidePointer = (event) => {
|
|
912
|
+
if (!emojiAnchorRef.current?.contains(event.target)) setEmojiOpen(false);
|
|
913
|
+
};
|
|
914
|
+
const closeOnEscape = (event) => {
|
|
915
|
+
if (event.key === 'Escape') setEmojiOpen(false);
|
|
916
|
+
};
|
|
917
|
+
document.addEventListener('pointerdown', closeOnOutsidePointer, true);
|
|
918
|
+
document.addEventListener('click', closeOnOutsidePointer, true);
|
|
919
|
+
document.addEventListener('keydown', closeOnEscape);
|
|
920
|
+
return () => {
|
|
921
|
+
document.removeEventListener('pointerdown', closeOnOutsidePointer, true);
|
|
922
|
+
document.removeEventListener('click', closeOnOutsidePointer, true);
|
|
923
|
+
document.removeEventListener('keydown', closeOnEscape);
|
|
924
|
+
};
|
|
925
|
+
}, [emojiOpen]);
|
|
683
926
|
const handleSubmit = async (event) => {
|
|
684
927
|
event.preventDefault();
|
|
685
928
|
const comment = message.trim();
|
|
@@ -691,12 +934,14 @@ function WebChatComposer({ sending, canSend, onSend }) {
|
|
|
691
934
|
|
|
692
935
|
return h('form', { className: 'cinecrew-player__chat-form', onSubmit: handleSubmit },
|
|
693
936
|
h('div', { className: 'cinecrew-player__chat-composer' },
|
|
694
|
-
h('
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
937
|
+
h('span', { className: 'cinecrew-player__emoji-anchor', ref: emojiAnchorRef },
|
|
938
|
+
h('button', {
|
|
939
|
+
type: 'button',
|
|
940
|
+
className: 'cinecrew-player__emoji-toggle',
|
|
941
|
+
onClick: () => setEmojiOpen((open) => !open),
|
|
942
|
+
'aria-label': emojiOpen ? 'Close emoji picker' : 'Open emoji picker',
|
|
943
|
+
}, '☺'),
|
|
944
|
+
emojiOpen ? h(WebEmojiPicker, { onSelect: insertEmoji }) : null),
|
|
700
945
|
h('input', {
|
|
701
946
|
value: message,
|
|
702
947
|
onChange: (event) => setMessage(event.target.value),
|
|
@@ -704,8 +949,12 @@ function WebChatComposer({ sending, canSend, onSend }) {
|
|
|
704
949
|
'aria-label': 'Chat message',
|
|
705
950
|
maxLength: 1000,
|
|
706
951
|
}),
|
|
707
|
-
h('button', {
|
|
708
|
-
|
|
952
|
+
h('button', {
|
|
953
|
+
type: 'submit',
|
|
954
|
+
disabled: sending || !message.trim() || !canSend,
|
|
955
|
+
'aria-label': sending ? 'Sending message' : 'Send message',
|
|
956
|
+
title: sending ? 'Sending…' : 'Send message',
|
|
957
|
+
}, h(Icon, { name: 'send', color: '#07111e' }))));
|
|
709
958
|
}
|
|
710
959
|
|
|
711
960
|
function WebPanelRow({ row, index, isChat }) {
|
|
@@ -725,15 +974,6 @@ function WebPanelRow({ row, index, isChat }) {
|
|
|
725
974
|
row.description ? h('span', null, row.description) : null);
|
|
726
975
|
}
|
|
727
976
|
|
|
728
|
-
function WebLoadMoreMessages({ loading, onClick }) {
|
|
729
|
-
return h('button', {
|
|
730
|
-
type: 'button',
|
|
731
|
-
className: 'cinecrew-player__load-more',
|
|
732
|
-
onClick,
|
|
733
|
-
disabled: loading,
|
|
734
|
-
}, loading ? 'Loading more…' : 'See more messages');
|
|
735
|
-
}
|
|
736
|
-
|
|
737
977
|
function WebIntegrationPanel({ kind, integration, integrations, source, title, theme, onClose, messagePageSize = 50 }) {
|
|
738
978
|
const [rows, setRows] = useState([]);
|
|
739
979
|
const [user, setUser] = useState(integrations.user || null);
|
|
@@ -744,6 +984,9 @@ function WebIntegrationPanel({ kind, integration, integrations, source, title, t
|
|
|
744
984
|
const [sending, setSending] = useState(false);
|
|
745
985
|
const [error, setError] = useState('');
|
|
746
986
|
const rowsRef = useRef([]);
|
|
987
|
+
const panelListRef = useRef(null);
|
|
988
|
+
const olderScrollRef = useRef(null);
|
|
989
|
+
const shouldScrollBottomRef = useRef(false);
|
|
747
990
|
const channelId = String(source.streamId || source.mediaId || source.id || '');
|
|
748
991
|
const isChat = kind === 'chat';
|
|
749
992
|
const pageSize = Math.max(1, Math.floor(Number(messagePageSize) || 50));
|
|
@@ -771,6 +1014,7 @@ function WebIntegrationPanel({ kind, integration, integrations, source, title, t
|
|
|
771
1014
|
const mergedRows = mergeChatMessages(rowsRef.current, nextRows);
|
|
772
1015
|
updateRows(mergedRows);
|
|
773
1016
|
const addedCount = Math.max(0, mergedRows.length - previousCount);
|
|
1017
|
+
if (addedCount > 0) shouldScrollBottomRef.current = true;
|
|
774
1018
|
setMessageOffset((offset) => previousCount === 0
|
|
775
1019
|
? nextRows.length
|
|
776
1020
|
: Math.max(offset, nextRows.length) + addedCount);
|
|
@@ -812,6 +1056,8 @@ function WebIntegrationPanel({ kind, integration, integrations, source, title, t
|
|
|
812
1056
|
|
|
813
1057
|
const loadOlderMessages = async () => {
|
|
814
1058
|
if (!isChat || !hasMoreMessages || loadingOlder) return;
|
|
1059
|
+
const panelList = panelListRef.current;
|
|
1060
|
+
if (panelList) olderScrollRef.current = { height: panelList.scrollHeight, top: panelList.scrollTop };
|
|
815
1061
|
setLoadingOlder(true);
|
|
816
1062
|
try {
|
|
817
1063
|
setError('');
|
|
@@ -828,6 +1074,25 @@ function WebIntegrationPanel({ kind, integration, integrations, source, title, t
|
|
|
828
1074
|
}
|
|
829
1075
|
};
|
|
830
1076
|
|
|
1077
|
+
useLayoutEffect(() => {
|
|
1078
|
+
const panelList = panelListRef.current;
|
|
1079
|
+
if (!panelList) return;
|
|
1080
|
+
if (olderScrollRef.current) {
|
|
1081
|
+
const previous = olderScrollRef.current;
|
|
1082
|
+
panelList.scrollTop = previous.top + panelList.scrollHeight - previous.height;
|
|
1083
|
+
olderScrollRef.current = null;
|
|
1084
|
+
return;
|
|
1085
|
+
}
|
|
1086
|
+
if (shouldScrollBottomRef.current) {
|
|
1087
|
+
panelList.scrollTop = panelList.scrollHeight;
|
|
1088
|
+
shouldScrollBottomRef.current = false;
|
|
1089
|
+
}
|
|
1090
|
+
}, [rows]);
|
|
1091
|
+
|
|
1092
|
+
useEffect(() => {
|
|
1093
|
+
if (isChat && hasMoreMessages && !loadingOlder && panelListRef.current?.scrollTop <= 24) loadOlderMessages();
|
|
1094
|
+
}, [rows.length, hasMoreMessages, loadingOlder, isChat]);
|
|
1095
|
+
|
|
831
1096
|
const send = async (comment) => {
|
|
832
1097
|
if (!comment || sending || !integration.sendMessage) return false;
|
|
833
1098
|
setSending(true);
|
|
@@ -859,8 +1124,15 @@ function WebIntegrationPanel({ kind, integration, integrations, source, title, t
|
|
|
859
1124
|
loading ? h('div', { className: 'cinecrew-player__panel-state' }, 'Loading…') : null,
|
|
860
1125
|
error ? h('div', { className: 'cinecrew-player__panel-state is-error', role: 'status' }, error) : null,
|
|
861
1126
|
!loading && !error && rows.length === 0 ? h('div', { className: 'cinecrew-player__panel-state' }, emptyStateMessage) : null,
|
|
862
|
-
h('div', {
|
|
863
|
-
|
|
1127
|
+
h('div', {
|
|
1128
|
+
className: 'cinecrew-player__panel-list',
|
|
1129
|
+
ref: panelListRef,
|
|
1130
|
+
onScroll: (event) => {
|
|
1131
|
+
if (isChat && event.currentTarget.scrollTop <= 24) loadOlderMessages();
|
|
1132
|
+
},
|
|
1133
|
+
'aria-live': isChat ? 'polite' : undefined,
|
|
1134
|
+
},
|
|
1135
|
+
isChat && loadingOlder ? h('div', { className: 'cinecrew-player__older-loading', role: 'status' }, 'Loading older messages…') : null,
|
|
864
1136
|
rows.map((row, index) => h(WebPanelRow, { key: getChatMessageKey(row, index), row, index, isChat }))),
|
|
865
1137
|
isChat ? h(WebChatComposer, {
|
|
866
1138
|
sending,
|
|
@@ -915,6 +1187,7 @@ export const CineCrewPlayer = forwardRef(function CineCrewPlayer(props, ref) {
|
|
|
915
1187
|
onFullscreen,
|
|
916
1188
|
onReady,
|
|
917
1189
|
onProgress,
|
|
1190
|
+
onProgressBarChange,
|
|
918
1191
|
onPlaying,
|
|
919
1192
|
onBuffering,
|
|
920
1193
|
onError,
|
|
@@ -938,7 +1211,7 @@ export const CineCrewPlayer = forwardRef(function CineCrewPlayer(props, ref) {
|
|
|
938
1211
|
const [volume, setVolume] = useState(Math.max(0, Math.min(1, Number(volumeProp) || 0)));
|
|
939
1212
|
const [videoOnly, setVideoOnly] = useState(!!videoOnlyProp);
|
|
940
1213
|
const [audioOnly, setAudioOnly] = useState(!!audioOnlyProp);
|
|
941
|
-
const [aspectRatio, setAspectRatio] = useState('FIT');
|
|
1214
|
+
const [aspectRatio, setAspectRatio] = useState(props.defaultAspectRatio || 'FIT');
|
|
942
1215
|
const [locked, setLocked] = useState(false);
|
|
943
1216
|
const [buffering, setBuffering] = useState(Boolean(streamUrl) || resolution.loading);
|
|
944
1217
|
const [error, setError] = useState('');
|
|
@@ -947,11 +1220,26 @@ export const CineCrewPlayer = forwardRef(function CineCrewPlayer(props, ref) {
|
|
|
947
1220
|
const [showAspectMenu, setShowAspectMenu] = useState(false);
|
|
948
1221
|
const [showAudioMenu, setShowAudioMenu] = useState(false);
|
|
949
1222
|
const [activePanel, setActivePanel] = useState(null);
|
|
950
|
-
const [
|
|
1223
|
+
const [recordingStatus, setRecordingStatus] = useState('idle');
|
|
1224
|
+
const [recordingElapsed, setRecordingElapsed] = useState(0);
|
|
1225
|
+
const [recordingError, setRecordingError] = useState('');
|
|
1226
|
+
const [recordingDownloadLink, setRecordingDownloadLink] = useState(null);
|
|
951
1227
|
const [availableTracks, setAvailableTracks] = useState([]);
|
|
952
1228
|
const [fullscreen, setFullscreen] = useState(false);
|
|
953
1229
|
const [playbackRate, setPlaybackRate] = useState(Number(playbackRateProp) || 1);
|
|
1230
|
+
const aspectRatiosNormalized = useMemo(() => normalizeAspectRatios(props.aspectRatios), [props.aspectRatios]);
|
|
954
1231
|
const { videoStyle } = useWebVideoAspectRatio(aspectRatio, false);
|
|
1232
|
+
const aspectRatioRef = useRef(aspectRatio);
|
|
1233
|
+
const recorderRef = useRef(null);
|
|
1234
|
+
const recordingCaptureRef = useRef(null);
|
|
1235
|
+
const recordingChunksRef = useRef([]);
|
|
1236
|
+
const recordingBlobRef = useRef(null);
|
|
1237
|
+
const recordingDownloadLinkRef = useRef(null);
|
|
1238
|
+
const recordingFinalizerRef = useRef(null);
|
|
1239
|
+
const recordingCompletionRef = useRef(null);
|
|
1240
|
+
const lastProgressBarSecondRef = useRef(null);
|
|
1241
|
+
const recordingClockRef = useRef({ startedAt: 0, accumulatedMs: 0 });
|
|
1242
|
+
aspectRatioRef.current = aspectRatio;
|
|
955
1243
|
const pausedStateRef = pausedRef;
|
|
956
1244
|
pausedRef.current = isPaused;
|
|
957
1245
|
errorRef.current = onError;
|
|
@@ -978,6 +1266,7 @@ export const CineCrewPlayer = forwardRef(function CineCrewPlayer(props, ref) {
|
|
|
978
1266
|
setBuffering(Boolean(streamUrl) || resolution.loading);
|
|
979
1267
|
setCurrentTime(0);
|
|
980
1268
|
setDuration(0);
|
|
1269
|
+
lastProgressBarSecondRef.current = null;
|
|
981
1270
|
setAvailableTracks([]);
|
|
982
1271
|
if (resolution.error) {
|
|
983
1272
|
handleError(resolution.error);
|
|
@@ -993,6 +1282,9 @@ export const CineCrewPlayer = forwardRef(function CineCrewPlayer(props, ref) {
|
|
|
993
1282
|
}, [pausedProp]);
|
|
994
1283
|
|
|
995
1284
|
useEffect(() => setAudioOnly(!!audioOnlyProp), [audioOnlyProp]);
|
|
1285
|
+
useEffect(() => {
|
|
1286
|
+
setAspectRatio(props.defaultAspectRatio || 'FIT');
|
|
1287
|
+
}, [streamUrl, props.defaultAspectRatio]);
|
|
996
1288
|
|
|
997
1289
|
useEffect(() => {
|
|
998
1290
|
setMuted(!!mutedProp);
|
|
@@ -1034,7 +1326,7 @@ export const CineCrewPlayer = forwardRef(function CineCrewPlayer(props, ref) {
|
|
|
1034
1326
|
onErrorRef,
|
|
1035
1327
|
});
|
|
1036
1328
|
const streamMode = getStreamMode(youtubeVideoId, mpegTs.useMpegTs, useHls);
|
|
1037
|
-
useWebAc3AudioPlayback({
|
|
1329
|
+
const ac3AudioPlayback = useWebAc3AudioPlayback({
|
|
1038
1330
|
active: mpegTs.useAc3Fallback,
|
|
1039
1331
|
streamUrl,
|
|
1040
1332
|
enabled: isAudioPlaybackEnabled(muted, videoOnly),
|
|
@@ -1075,18 +1367,200 @@ export const CineCrewPlayer = forwardRef(function CineCrewPlayer(props, ref) {
|
|
|
1075
1367
|
}, []);
|
|
1076
1368
|
|
|
1077
1369
|
const action = useCallback((name, fallback, payload) => {
|
|
1078
|
-
|
|
1079
|
-
if (
|
|
1080
|
-
|
|
1081
|
-
|
|
1370
|
+
let callback = actions?.[name];
|
|
1371
|
+
if (name === 'onAspectRatioChange') callback = callback || props.onAspectRatioChange;
|
|
1372
|
+
else if (name === 'onFullscreen') callback = callback || onFullscreen;
|
|
1373
|
+
return invokePlayerAction(
|
|
1374
|
+
fallback,
|
|
1375
|
+
callback,
|
|
1376
|
+
payload,
|
|
1377
|
+
{ video: videoRef.current, player: publicPlayerRef.current },
|
|
1378
|
+
);
|
|
1379
|
+
}, [actions, props.onAspectRatioChange, onFullscreen]);
|
|
1380
|
+
|
|
1381
|
+
const startBuiltinRecording = useCallback(async () => {
|
|
1382
|
+
const video = videoRef.current;
|
|
1383
|
+
if (!video || youtubeVideoId) throw new Error('This source cannot be recorded from the browser player.');
|
|
1384
|
+
const mimeType = getRecordingMimeType();
|
|
1385
|
+
if (typeof MediaRecorder === 'undefined' || !mimeType) throw new Error('This browser does not support WebM audio/video recording.');
|
|
1386
|
+
const capture = createVideoRecordingStream(video, playerRef.current, () => aspectRatioRef.current, ac3AudioPlayback.getRecordingAudioStream());
|
|
1387
|
+
const recorder = new MediaRecorder(capture.stream, { mimeType });
|
|
1388
|
+
recordingCaptureRef.current = capture;
|
|
1389
|
+
recordingChunksRef.current = [];
|
|
1390
|
+
recorderRef.current = recorder;
|
|
1391
|
+
let finalized = false;
|
|
1392
|
+
let resolveCompletion;
|
|
1393
|
+
recordingCompletionRef.current = new Promise((resolve) => { resolveCompletion = resolve; });
|
|
1394
|
+
const finalizeRecording = () => {
|
|
1395
|
+
if (finalized) return;
|
|
1396
|
+
finalized = true;
|
|
1397
|
+
let completedBlob = null;
|
|
1398
|
+
try {
|
|
1399
|
+
const blob = new Blob(recordingChunksRef.current, { type: recorder.mimeType || mimeType });
|
|
1400
|
+
if (!blob.size) throw new Error('The recording is empty. Play the video briefly, then stop and download again.');
|
|
1401
|
+
recordingBlobRef.current = blob;
|
|
1402
|
+
completedBlob = blob;
|
|
1403
|
+
if (recordingDownloadLinkRef.current?.url) URL.revokeObjectURL(recordingDownloadLinkRef.current.url);
|
|
1404
|
+
const downloadLink = createRecordingDownloadLink(blob, title);
|
|
1405
|
+
if (!downloadLink) throw new Error('The recording is empty. Play the video briefly, then stop and download again.');
|
|
1406
|
+
recordingDownloadLinkRef.current = downloadLink;
|
|
1407
|
+
setRecordingDownloadLink(downloadLink);
|
|
1408
|
+
setRecordingStatus('complete');
|
|
1409
|
+
try {
|
|
1410
|
+
if (!downloadRecording(blob, title)) throw new Error('The browser did not start the recording download. Use Download recording to retry.');
|
|
1411
|
+
} catch (downloadError) {
|
|
1412
|
+
setRecordingError(downloadError?.message || 'The recording could not be downloaded. Use Download recording to retry.');
|
|
1413
|
+
}
|
|
1414
|
+
} catch (error_) {
|
|
1415
|
+
setRecordingError(error_?.message || 'Could not finish the recording.');
|
|
1416
|
+
setRecordingStatus('idle');
|
|
1417
|
+
} finally {
|
|
1418
|
+
recordingCaptureRef.current?.cleanup?.();
|
|
1419
|
+
recordingCaptureRef.current = null;
|
|
1420
|
+
recorderRef.current = null;
|
|
1421
|
+
recordingChunksRef.current = [];
|
|
1422
|
+
recordingFinalizerRef.current = null;
|
|
1423
|
+
resolveCompletion?.(completedBlob);
|
|
1424
|
+
}
|
|
1425
|
+
};
|
|
1426
|
+
recordingFinalizerRef.current = finalizeRecording;
|
|
1427
|
+
recorder.addEventListener('dataavailable', (event) => {
|
|
1428
|
+
if (event.data?.size) recordingChunksRef.current.push(event.data);
|
|
1429
|
+
});
|
|
1430
|
+
recorder.addEventListener('stop', finalizeRecording, { once: true });
|
|
1431
|
+
recorder.addEventListener('error', (event) => {
|
|
1432
|
+
setRecordingError(event.error?.message || 'The browser could not record this stream.');
|
|
1433
|
+
}, { once: true });
|
|
1434
|
+
recorder.start(1000);
|
|
1435
|
+
}, [ac3AudioPlayback, title, youtubeVideoId]);
|
|
1436
|
+
|
|
1437
|
+
const startRecording = useCallback(async () => {
|
|
1438
|
+
setRecordingError('');
|
|
1439
|
+
recordingBlobRef.current = null;
|
|
1440
|
+
if (recordingDownloadLinkRef.current?.url) URL.revokeObjectURL(recordingDownloadLinkRef.current.url);
|
|
1441
|
+
recordingDownloadLinkRef.current = null;
|
|
1442
|
+
setRecordingDownloadLink(null);
|
|
1443
|
+
try {
|
|
1444
|
+
const adapterStart = integrations.recording?.start;
|
|
1445
|
+
await action('onRecordingStart', adapterStart
|
|
1446
|
+
? () => adapterStart({ getVideoElement: () => videoRef.current, streamUrl, title })
|
|
1447
|
+
: startBuiltinRecording, { source: streamUrl, title });
|
|
1448
|
+
recordingClockRef.current = { startedAt: Date.now(), accumulatedMs: 0 };
|
|
1449
|
+
setRecordingElapsed(0);
|
|
1450
|
+
setRecordingStatus('recording');
|
|
1451
|
+
} catch (startError) {
|
|
1452
|
+
setRecordingStatus('idle');
|
|
1453
|
+
setRecordingError(startError?.message || 'Could not start recording.');
|
|
1454
|
+
}
|
|
1455
|
+
}, [action, integrations.recording, startBuiltinRecording, streamUrl, title]);
|
|
1082
1456
|
|
|
1083
|
-
const
|
|
1457
|
+
const pauseRecording = useCallback(async () => {
|
|
1458
|
+
try {
|
|
1459
|
+
const pause = integrations.recording?.pause;
|
|
1460
|
+
await action('onRecordingPause', pause
|
|
1461
|
+
? () => pause()
|
|
1462
|
+
: () => {
|
|
1463
|
+
if (recorderRef.current?.state !== 'recording') throw new Error('Recording cannot be paused right now.');
|
|
1464
|
+
recorderRef.current.pause();
|
|
1465
|
+
}, { elapsedMs: recordingElapsed });
|
|
1466
|
+
const clock = recordingClockRef.current;
|
|
1467
|
+
if (clock.startedAt) clock.accumulatedMs += Date.now() - clock.startedAt;
|
|
1468
|
+
clock.startedAt = 0;
|
|
1469
|
+
setRecordingElapsed(clock.accumulatedMs);
|
|
1470
|
+
setRecordingStatus('paused');
|
|
1471
|
+
} catch (pauseError) {
|
|
1472
|
+
setRecordingError(pauseError?.message || 'Could not pause recording.');
|
|
1473
|
+
}
|
|
1474
|
+
}, [action, integrations.recording, recordingElapsed]);
|
|
1475
|
+
|
|
1476
|
+
const resumeRecording = useCallback(async () => {
|
|
1477
|
+
try {
|
|
1478
|
+
const resume = integrations.recording?.resume;
|
|
1479
|
+
await action('onRecordingResume', resume
|
|
1480
|
+
? () => resume()
|
|
1481
|
+
: () => {
|
|
1482
|
+
if (recorderRef.current?.state !== 'paused') throw new Error('Recording cannot resume right now.');
|
|
1483
|
+
recorderRef.current.resume();
|
|
1484
|
+
}, { elapsedMs: recordingElapsed });
|
|
1485
|
+
recordingClockRef.current.startedAt = Date.now();
|
|
1486
|
+
setRecordingStatus('recording');
|
|
1487
|
+
} catch (resumeError) {
|
|
1488
|
+
setRecordingError(resumeError?.message || 'Could not resume recording.');
|
|
1489
|
+
}
|
|
1490
|
+
}, [action, integrations.recording, recordingElapsed]);
|
|
1491
|
+
|
|
1492
|
+
const stopRecording = useCallback(async () => {
|
|
1493
|
+
try {
|
|
1494
|
+
const stop = integrations.recording?.stop;
|
|
1495
|
+
await performRecordingStop({
|
|
1496
|
+
stop,
|
|
1497
|
+
action,
|
|
1498
|
+
recordingElapsed,
|
|
1499
|
+
streamUrl,
|
|
1500
|
+
title,
|
|
1501
|
+
recorderRef,
|
|
1502
|
+
completionRef: recordingCompletionRef,
|
|
1503
|
+
finalizerRef: recordingFinalizerRef,
|
|
1504
|
+
setRecordingStatus,
|
|
1505
|
+
});
|
|
1506
|
+
updateRecordingStopState({
|
|
1507
|
+
clock: recordingClockRef.current,
|
|
1508
|
+
recordingBlobRef,
|
|
1509
|
+
stop,
|
|
1510
|
+
setRecordingElapsed,
|
|
1511
|
+
setRecordingStatus,
|
|
1512
|
+
setRecordingError,
|
|
1513
|
+
});
|
|
1514
|
+
} catch (stopError) {
|
|
1515
|
+
setRecordingError(stopError?.message || 'Could not finish recording.');
|
|
1516
|
+
}
|
|
1517
|
+
}, [action, integrations.recording, recordingElapsed, streamUrl, title]);
|
|
1518
|
+
|
|
1519
|
+
const downloadCompletedRecording = useCallback(() => {
|
|
1520
|
+
try {
|
|
1521
|
+
if (!recordingDownloadLinkRef.current?.url) throw new Error('No recording is ready to download.');
|
|
1522
|
+
setRecordingError('');
|
|
1523
|
+
} catch (downloadError) {
|
|
1524
|
+
setRecordingError(downloadError?.message || 'The recording could not be downloaded.');
|
|
1525
|
+
}
|
|
1526
|
+
}, []);
|
|
1527
|
+
|
|
1528
|
+
const dismissRecording = useCallback(() => {
|
|
1529
|
+
setRecordingStatus('idle');
|
|
1530
|
+
setRecordingError('');
|
|
1531
|
+
recordingBlobRef.current = null;
|
|
1532
|
+
if (recordingDownloadLinkRef.current?.url) URL.revokeObjectURL(recordingDownloadLinkRef.current.url);
|
|
1533
|
+
recordingDownloadLinkRef.current = null;
|
|
1534
|
+
setRecordingDownloadLink(null);
|
|
1535
|
+
recordingCompletionRef.current = null;
|
|
1536
|
+
}, []);
|
|
1537
|
+
|
|
1538
|
+
useEffect(() => {
|
|
1539
|
+
if (recordingStatus !== 'recording') return undefined;
|
|
1540
|
+
const timer = setInterval(() => {
|
|
1541
|
+
const clock = recordingClockRef.current;
|
|
1542
|
+
setRecordingElapsed(clock.accumulatedMs + (clock.startedAt ? Date.now() - clock.startedAt : 0));
|
|
1543
|
+
}, 250);
|
|
1544
|
+
return () => clearInterval(timer);
|
|
1545
|
+
}, [recordingStatus]);
|
|
1546
|
+
|
|
1547
|
+
useEffect(() => () => {
|
|
1548
|
+
const recorder = recorderRef.current;
|
|
1549
|
+
if (recorder && recorder.state !== 'inactive') recorder.stop();
|
|
1550
|
+
recordingCaptureRef.current?.cleanup?.();
|
|
1551
|
+
}, [streamUrl]);
|
|
1552
|
+
useEffect(() => () => {
|
|
1553
|
+
if (recordingDownloadLinkRef.current?.url) URL.revokeObjectURL(recordingDownloadLinkRef.current.url);
|
|
1554
|
+
}, []);
|
|
1555
|
+
|
|
1556
|
+
const togglePlay = useCallback(() => action('onPlayPause', () => setPaused(), { isPlaying: pausedRef.current }), [action, setPaused]);
|
|
1084
1557
|
const restart = useCallback(() => action('onRestart', () => {
|
|
1085
1558
|
const video = videoRef.current;
|
|
1086
1559
|
if (video) video.currentTime = 0;
|
|
1087
1560
|
else youtubeRef.current?.seekTo?.(0);
|
|
1088
1561
|
setPaused(false);
|
|
1089
|
-
|
|
1562
|
+
emitProgressBarTime(0, onProgressBarChange, lastProgressBarSecondRef, { force: true });
|
|
1563
|
+
}, { currentTime: Number(videoRef.current?.currentTime) || 0 }), [action, onProgressBarChange, setPaused]);
|
|
1090
1564
|
const toggleMute = useCallback(() => action('onMute', () => setMuted((value) => !value), { muted: !muted }), [action, muted]);
|
|
1091
1565
|
const toggleLock = useCallback(() => action('onLock', () => setLocked((value) => !value), { locked: !locked }), [action, locked]);
|
|
1092
1566
|
const selectAspect = useCallback((next) => action('onAspectRatioChange', () => setAspectRatio(next), { aspectRatio: next }), [action]);
|
|
@@ -1100,9 +1574,11 @@ export const CineCrewPlayer = forwardRef(function CineCrewPlayer(props, ref) {
|
|
|
1100
1574
|
const setAudioOnlyMode = useCallback((next) => action('onAudioOnlyChange', () => setAudioOnly(next), { enabled: next }), [action]);
|
|
1101
1575
|
const setPlaybackRateAction = useCallback((next) => action('onPlaybackRateChange', () => setPlaybackRate(next), { playbackRate: next }), [action]);
|
|
1102
1576
|
const seekTo = useCallback((seconds) => action('onSeek', () => {
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1577
|
+
const targetSeconds = Math.max(0, Number(seconds) || 0);
|
|
1578
|
+
if (videoRef.current) videoRef.current.currentTime = targetSeconds;
|
|
1579
|
+
else youtubeRef.current?.seekTo?.(targetSeconds);
|
|
1580
|
+
emitProgressBarTime(targetSeconds, onProgressBarChange, lastProgressBarSecondRef, { force: true });
|
|
1581
|
+
}, { seconds: Number(seconds) || 0 }), [action, onProgressBarChange]);
|
|
1106
1582
|
const handleBack = useCallback(() => action('onBack', onBack || props.onClose, { title, source: media }), [action, onBack, props.onClose, title, media]);
|
|
1107
1583
|
useImperativeHandle(ref, () => {
|
|
1108
1584
|
const api = {
|
|
@@ -1118,12 +1594,14 @@ export const CineCrewPlayer = forwardRef(function CineCrewPlayer(props, ref) {
|
|
|
1118
1594
|
setAudioTrack: (id) => {
|
|
1119
1595
|
for (const item of availableTracks) if (item.nativeTrack) item.nativeTrack.enabled = String(item.id) === String(id);
|
|
1120
1596
|
},
|
|
1121
|
-
setAudioOnly
|
|
1597
|
+
setAudioOnly,
|
|
1122
1598
|
setVideoOnly: setVideoOnlyMode,
|
|
1123
|
-
setPlaybackRate
|
|
1599
|
+
setPlaybackRate,
|
|
1124
1600
|
seekTo,
|
|
1125
1601
|
seekBy: (delta) => seekTo((Number(videoRef.current?.currentTime) || currentTime) + (Number(delta) || 0)),
|
|
1126
1602
|
back: handleBack,
|
|
1603
|
+
setPanel: (panel) => setActivePanel(panel || null),
|
|
1604
|
+
closePanel: () => setActivePanel(null),
|
|
1127
1605
|
getVideoElement: () => videoRef.current,
|
|
1128
1606
|
enterFullscreen: () => playerRef.current?.requestFullscreen?.(),
|
|
1129
1607
|
exitFullscreen: () => typeof document !== 'undefined' ? document.exitFullscreen?.() : undefined,
|
|
@@ -1131,11 +1609,12 @@ export const CineCrewPlayer = forwardRef(function CineCrewPlayer(props, ref) {
|
|
|
1131
1609
|
};
|
|
1132
1610
|
publicPlayerRef.current = api;
|
|
1133
1611
|
return api;
|
|
1134
|
-
}, [setPaused, togglePlay, restart, toggleMute, setMuted, setAspectRatio,
|
|
1612
|
+
}, [setPaused, togglePlay, restart, toggleMute, setMuted, setAspectRatio, setAudioOnly, setVideoOnlyMode, setPlaybackRate, seekTo, handleBack, availableTracks, tracksProp, currentTime]);
|
|
1135
1613
|
|
|
1136
1614
|
const hasChat = typeof integrations.liveChat?.loadMessages === 'function' || typeof renderLiveChat === 'function' || typeof integrations.liveChat?.render === 'function' || typeof actions.onLiveChatOpen === 'function';
|
|
1137
1615
|
const hasEpg = typeof integrations.epg?.loadListings === 'function' || typeof renderEpg === 'function' || typeof integrations.epg?.render === 'function' || typeof actions.onEpgOpen === 'function';
|
|
1138
|
-
const hasRecording =
|
|
1616
|
+
const hasRecording = controlOverrides.recording !== false
|
|
1617
|
+
&& (!!integrations.recording || typeof actions.onRecordingStart === 'function' || (!youtubeVideoId && typeof MediaRecorder !== 'undefined'));
|
|
1139
1618
|
const hasDiagnostics = Boolean(features.diagnostics) || typeof actions.onDiagnosticsOpen === 'function';
|
|
1140
1619
|
const sourceType = String(media.type || media.mimeType || '').toLowerCase();
|
|
1141
1620
|
const directVideoSource = getDirectVideoSource({
|
|
@@ -1157,6 +1636,7 @@ export const CineCrewPlayer = forwardRef(function CineCrewPlayer(props, ref) {
|
|
|
1157
1636
|
const updateTime = () => {
|
|
1158
1637
|
setCurrentTime(Number(video.currentTime) || 0);
|
|
1159
1638
|
if (Number.isFinite(video.duration)) setDuration(video.duration);
|
|
1639
|
+
emitProgressBarTime(Number(video.currentTime) || 0, onProgressBarChange, lastProgressBarSecondRef);
|
|
1160
1640
|
onProgress?.({ currentTime: (Number(video.currentTime) || 0) * 1000, duration: (Number(video.duration) || 0) * 1000, target: video.currentTime });
|
|
1161
1641
|
};
|
|
1162
1642
|
const onReadyEvent = () => {
|
|
@@ -1184,7 +1664,7 @@ export const CineCrewPlayer = forwardRef(function CineCrewPlayer(props, ref) {
|
|
|
1184
1664
|
video.removeEventListener('timeupdate', updateTime);
|
|
1185
1665
|
video.removeEventListener('ended', endedHandler);
|
|
1186
1666
|
};
|
|
1187
|
-
}, [onReady, onPlaying, onProgress, onEnded, tracksProp]);
|
|
1667
|
+
}, [onReady, onPlaying, onProgress, onProgressBarChange, onEnded, tracksProp]);
|
|
1188
1668
|
|
|
1189
1669
|
useEffect(() => {
|
|
1190
1670
|
if (selectedAudioTrack === undefined || selectedAudioTrack === null) return;
|
|
@@ -1200,18 +1680,18 @@ export const CineCrewPlayer = forwardRef(function CineCrewPlayer(props, ref) {
|
|
|
1200
1680
|
}, []);
|
|
1201
1681
|
|
|
1202
1682
|
const toggleFullscreen = () => action('onFullscreen', () => {
|
|
1203
|
-
if (typeof
|
|
1683
|
+
if (inlinePreview && typeof onPromotePreview === 'function') return onPromotePreview({ title, source: media });
|
|
1204
1684
|
if (document.fullscreenElement) document.exitFullscreen?.();
|
|
1205
1685
|
else playerRef.current?.requestFullscreen?.();
|
|
1206
1686
|
}, { isFullscreen: !fullscreen });
|
|
1207
|
-
const openPanel = (panel) =>
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1687
|
+
const openPanel = (panel) => {
|
|
1688
|
+
const isOpen = activePanel !== panel;
|
|
1689
|
+
return action(
|
|
1690
|
+
getPanelActionName(panel),
|
|
1691
|
+
() => setActivePanel((current) => current === panel ? null : panel),
|
|
1692
|
+
{ tab: panel, isOpen, close: () => setActivePanel(null) },
|
|
1693
|
+
);
|
|
1694
|
+
};
|
|
1215
1695
|
const control = (name, label, callback, options = {}) => renderControlButton({
|
|
1216
1696
|
name, label, callback, options, overrides: controlOverrides, icons, theme,
|
|
1217
1697
|
});
|
|
@@ -1231,18 +1711,15 @@ export const CineCrewPlayer = forwardRef(function CineCrewPlayer(props, ref) {
|
|
|
1231
1711
|
toggleMute,
|
|
1232
1712
|
muted,
|
|
1233
1713
|
hasRecording,
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
streamUrl,
|
|
1239
|
-
title,
|
|
1714
|
+
recordingStatus,
|
|
1715
|
+
startRecording,
|
|
1716
|
+
resumeRecording,
|
|
1717
|
+
stopRecording,
|
|
1240
1718
|
activePanel,
|
|
1241
1719
|
hasChat,
|
|
1242
1720
|
hasEpg,
|
|
1243
1721
|
hasDiagnostics,
|
|
1244
1722
|
handleOpenPanel: openPanel,
|
|
1245
|
-
setRecording,
|
|
1246
1723
|
});
|
|
1247
1724
|
const webPanel = createWebPanelNode({
|
|
1248
1725
|
renderer: panelRenderer,
|
|
@@ -1252,6 +1729,7 @@ export const CineCrewPlayer = forwardRef(function CineCrewPlayer(props, ref) {
|
|
|
1252
1729
|
source: panelSource,
|
|
1253
1730
|
title,
|
|
1254
1731
|
theme,
|
|
1732
|
+
icons,
|
|
1255
1733
|
onClose: () => setActivePanel(null),
|
|
1256
1734
|
diagnostics: {
|
|
1257
1735
|
streamMode,
|
|
@@ -1264,8 +1742,10 @@ export const CineCrewPlayer = forwardRef(function CineCrewPlayer(props, ref) {
|
|
|
1264
1742
|
});
|
|
1265
1743
|
|
|
1266
1744
|
const handleYouTubeProgress = (event) => {
|
|
1267
|
-
|
|
1745
|
+
const seconds = Number(event.currentTime) / 1000 || 0;
|
|
1746
|
+
setCurrentTime(seconds);
|
|
1268
1747
|
setDuration(Number(event.duration) / 1000 || 0);
|
|
1748
|
+
emitProgressBarTime(seconds, onProgressBarChange, lastProgressBarSecondRef);
|
|
1269
1749
|
onProgress?.(event);
|
|
1270
1750
|
};
|
|
1271
1751
|
const mediaSurface = h(WebPlayerSurface, {
|
|
@@ -1308,11 +1788,12 @@ export const CineCrewPlayer = forwardRef(function CineCrewPlayer(props, ref) {
|
|
|
1308
1788
|
showAspectMenu,
|
|
1309
1789
|
setShowAspectMenu,
|
|
1310
1790
|
selectAspect,
|
|
1311
|
-
|
|
1312
|
-
|
|
1791
|
+
aspectRatios: aspectRatiosNormalized,
|
|
1792
|
+
selectedAspectRatio: aspectRatio,
|
|
1313
1793
|
audioOnly,
|
|
1314
1794
|
setAudioOnlyMode,
|
|
1315
1795
|
availableTracks,
|
|
1796
|
+
selectedAudioTrack,
|
|
1316
1797
|
showAudioMenu,
|
|
1317
1798
|
setShowAudioMenu,
|
|
1318
1799
|
selectAudio,
|
|
@@ -1320,6 +1801,15 @@ export const CineCrewPlayer = forwardRef(function CineCrewPlayer(props, ref) {
|
|
|
1320
1801
|
setPlaybackRateAction,
|
|
1321
1802
|
fullscreen,
|
|
1322
1803
|
toggleFullscreen,
|
|
1804
|
+
recordingStatus,
|
|
1805
|
+
recordingElapsed,
|
|
1806
|
+
recordingError,
|
|
1807
|
+
recordingDownloadLink,
|
|
1808
|
+
pauseRecording,
|
|
1809
|
+
resumeRecording,
|
|
1810
|
+
stopRecording,
|
|
1811
|
+
downloadRecording: downloadCompletedRecording,
|
|
1812
|
+
clearRecordingError: dismissRecording,
|
|
1323
1813
|
};
|
|
1324
1814
|
return h(WebPlayerLayout, {
|
|
1325
1815
|
playerRef,
|
|
@@ -1401,10 +1891,7 @@ export const InlineLivePlayer = React.memo(function InlineLivePlayer({
|
|
|
1401
1891
|
playbackRate: false, playPause: true, mute: true, fullscreen: true,
|
|
1402
1892
|
...controls,
|
|
1403
1893
|
},
|
|
1404
|
-
actions
|
|
1405
|
-
...actions,
|
|
1406
|
-
...(onFullscreen && !actions.onFullscreen ? { onFullscreen: () => onFullscreen() } : {}),
|
|
1407
|
-
},
|
|
1894
|
+
actions,
|
|
1408
1895
|
theme,
|
|
1409
1896
|
icons,
|
|
1410
1897
|
onError,
|