@cinecrew/cinecrew-player 0.1.1 → 0.1.3

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/src/web/index.js CHANGED
@@ -1,10 +1,11 @@
1
- import React, { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState } from 'react';
1
+ import React, { forwardRef, useCallback, useEffect, useImperativeHandle, 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';
5
5
  import { useWebAc3AudioPlayback } from '../native/media/web/useWebAc3AudioPlayback.web';
6
6
  import { YouTubeVideoPlayer } from '../native/media/YouTubeVideoPlayer.web.js';
7
7
  import { getYouTubeVideoId, getWebRuntimePlatform, useResolvedPlayerSource } from '../utils/sourceUtils';
8
+ import { EMOJI_GROUPS, searchEmojis } from '../data/emoji';
8
9
  import './styles.css';
9
10
 
10
11
  const h = React.createElement;
@@ -20,8 +21,9 @@ const DEFAULT_THEME = {
20
21
  const DEFAULT_ICONS = {
21
22
  play: 'play', pause: 'pause', restart: 'restart', lock: 'lock', unlock: 'lock-open',
22
23
  mute: 'volume-mute', unmute: 'volume-high', aspectRatio: 'aspect-ratio', videoOnly: 'video',
23
- audio: 'music-note', minimize: 'arrow-collapse', back: 'arrow-left', recording: 'record-rec', stop: 'stop',
24
- liveChat: 'comment-text-multiple-outline', epg: 'television-classic', fullscreen: 'fullscreen', close: 'close',
24
+ audio: 'music-note', audioOnly: 'headphones', back: 'arrow-left', recording: 'record-rec', stop: 'stop',
25
+ liveChat: 'comment-text-multiple-outline', epg: 'television-classic', diagnostics: 'logs',
26
+ fullscreen: 'fullscreen', close: 'close',
25
27
  };
26
28
 
27
29
  // Inline SVG keeps the React DOM/Electron entry independent of React Native,
@@ -32,12 +34,14 @@ const WEB_ICON_PATHS = {
32
34
  restart: 'M12 5V1L7 6l5 5V7a6 6 0 1 1-5.65 8H4.26A9 9 0 1 0 12 5z',
33
35
  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',
34
36
  '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',
35
- 'volume-mute': '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',
37
+ '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',
36
38
  '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',
37
39
  'aspect-ratio': 'M3 5h18v14H3zm2 2v10h14V7zm2 2h4v2H9v4H7zm10 6h-4v-2h2V9h2z',
38
40
  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',
39
41
  'music-note': 'M12 3v12.26A4 4 0 1 0 14 19V7h6V3z',
42
+ 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',
40
43
  'arrow-collapse': 'M4 4h6v2H6v4H4zm10 0h6v6h-2V6h-4zM4 14h2v4h4v2H4zm14 0h2v6h-6v-2h4z',
44
+ logs: 'M3 3h18v18H3zm2 2v14h14V5zm3 3h2v2H8zm4 0h7v2h-7zm-4 4h2v2H8zm4 0h7v2h-7zm-4 4h2v2H8zm4 0h7v2h-7z',
41
45
  'arrow-left': 'M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20z',
42
46
  'record-rec': 'M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20zm0 15a5 5 0 1 1 0-10 5 5 0 0 1 0 10z',
43
47
  stop: 'M6 6h12v12H6z',
@@ -45,6 +49,13 @@ const WEB_ICON_PATHS = {
45
49
  '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',
46
50
  fullscreen: 'M4 4h6v2H6v4H4zm10 0h6v6h-2V6h-4zM4 14h2v4h4v2H4zm14 0h2v6h-6v-2h4z',
47
51
  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',
52
+ send: 'M2 21 23 12 2 3v7l15 2-15 2z',
53
+ 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',
54
+ 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',
55
+ buffer: 'M4 5h16v3H4zm0 5.5h16v3H4zm0 5.5h16v3H4z',
56
+ 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',
57
+ display: 'M3 4h18v13H3zm2 2v9h14V6zm5 13h4v2h-4z',
58
+ frames: 'M4 4h16v12H4zm2 2v8h12V6zm-4 13h16v2H2zm4-3h16v2H6z',
48
59
  };
49
60
 
50
61
  function getSource(source, url) {
@@ -114,17 +125,64 @@ function getPanelActionName(panel) {
114
125
  }
115
126
 
116
127
  function WebSeekControl({ currentTime, duration, theme, onSeek }) {
128
+ const [seekDraft, setSeekDraft] = useState(currentTime);
129
+ const [isDragging, setIsDragging] = useState(false);
130
+ const seekDraftRef = useRef(currentTime);
131
+
132
+ useEffect(() => {
133
+ if (!isDragging) {
134
+ seekDraftRef.current = currentTime;
135
+ setSeekDraft(currentTime);
136
+ }
137
+ }, [currentTime, isDragging]);
138
+
139
+ const updateDraft = (event) => {
140
+ const nextTime = Number(event.currentTarget.value);
141
+ seekDraftRef.current = nextTime;
142
+ setSeekDraft(nextTime);
143
+ };
144
+
145
+ const finishSeek = (event) => {
146
+ if (!isDragging) return;
147
+ const nextTime = event?.currentTarget && event.type !== 'blur'
148
+ ? Number(event.currentTarget.value)
149
+ : seekDraftRef.current;
150
+ seekDraftRef.current = nextTime;
151
+ setSeekDraft(nextTime);
152
+ setIsDragging(false);
153
+ onSeek(nextTime);
154
+ };
155
+ const displayTime = Math.min(isDragging ? seekDraft : currentTime, duration);
156
+ const remainingTime = Math.max(0, duration - displayTime);
157
+
117
158
  return h('div', { className: 'cinecrew-player__seek' },
118
- h('span', null, formatTime(currentTime)),
159
+ h('span', null, formatTime(displayTime)),
119
160
  h('input', {
120
161
  type: 'range',
162
+ 'aria-label': 'Seek video',
121
163
  min: 0,
122
164
  max: duration,
123
- value: Math.min(currentTime, duration),
124
- onChange: (event) => onSeek(Number(event.target.value)),
165
+ step: 'any',
166
+ value: displayTime,
167
+ onChange: updateDraft,
168
+ onPointerDown: (event) => {
169
+ event.stopPropagation();
170
+ setIsDragging(true);
171
+ seekDraftRef.current = Number(event.currentTarget.value);
172
+ setSeekDraft(seekDraftRef.current);
173
+ },
174
+ onPointerUp: finishSeek,
175
+ onPointerCancel: finishSeek,
176
+ onBlur: finishSeek,
177
+ onKeyDown: (event) => {
178
+ if (['ArrowLeft', 'ArrowRight', 'Home', 'End', 'PageUp', 'PageDown'].includes(event.key)) setIsDragging(true);
179
+ },
180
+ onKeyUp: (event) => {
181
+ if (['ArrowLeft', 'ArrowRight', 'Home', 'End', 'PageUp', 'PageDown'].includes(event.key)) finishSeek(event);
182
+ },
125
183
  style: { accentColor: theme.accentColor },
126
184
  }),
127
- h('span', null, formatTime(duration)));
185
+ h('span', null, `−${formatTime(remainingTime)}`));
128
186
  }
129
187
 
130
188
  function WebAspectRatioMenu({ open, theme, icons, onToggle, onSelect }) {
@@ -138,7 +196,7 @@ function WebAspectRatioMenu({ open, theme, icons, onToggle, onSelect }) {
138
196
  onClick: () => onSelect(ratio),
139
197
  }, ratio)));
140
198
  }
141
- return h('div', { className: 'cinecrew-player__menu-wrap', key: 'aspectRatio' },
199
+ return h('div', { className: 'cinecrew-player__menu-wrap cinecrew-player__menu-wrap--aspect', key: 'aspectRatio' },
142
200
  h(PlayerButton, { name: 'aspectRatio', label: 'Aspect ratio', icons, theme, onClick: onToggle, active: open }),
143
201
  menu);
144
202
  }
@@ -147,11 +205,13 @@ function WebAudioTrackMenu({ open, tracks, theme, icons, onToggle, onSelect }) {
147
205
  let menu = null;
148
206
  if (open) {
149
207
  menu = h('div', { className: 'cinecrew-player__menu', style: { background: theme.surfaceColor } },
150
- tracks.map((track) => h('button', {
151
- key: track.id,
152
- type: 'button',
153
- onClick: () => onSelect(track.id),
154
- }, track.name)));
208
+ tracks.length
209
+ ? tracks.map((track, index) => h('button', {
210
+ key: track.id ?? index,
211
+ type: 'button',
212
+ onClick: () => onSelect(track.id),
213
+ }, track.name || track.language || `Track ${index + 1}`))
214
+ : h('div', { className: 'cinecrew-player__menu-empty', role: 'status' }, 'No audio tracks available'));
155
215
  }
156
216
  return h('div', { className: 'cinecrew-player__menu-wrap', key: 'audioTracks' },
157
217
  h(PlayerButton, { name: 'audio', label: 'Audio tracks', icons, theme, onClick: onToggle, active: open }),
@@ -170,8 +230,8 @@ function WebPlaybackRateControl({ value, onChange }) {
170
230
 
171
231
  function WebBottomControls(props) {
172
232
  const {
173
- isLive, overrides, theme, icons, currentTime, duration, seekTo, muted,
174
- toggleMute, showAspectMenu, setShowAspectMenu, selectAspect, videoOnly,
233
+ isLive, overrides, theme, icons, currentTime, duration, seekTo,
234
+ showAspectMenu, setShowAspectMenu, selectAspect, videoOnly,
175
235
  setVideoOnlyMode, audioOnly, setAudioOnlyMode, availableTracks,
176
236
  showAudioMenu, setShowAudioMenu, selectAudio, playbackRate,
177
237
  setPlaybackRateAction, fullscreen, toggleFullscreen,
@@ -194,7 +254,7 @@ function WebBottomControls(props) {
194
254
  });
195
255
  }
196
256
  let audioTracks = null;
197
- if (availableTracks.length > 1 && isControlEnabled(overrides, 'audioTracks', true)) {
257
+ if (isControlEnabled(overrides, 'audioTracks', true)) {
198
258
  audioTracks = h(WebAudioTrackMenu, {
199
259
  open: showAudioMenu,
200
260
  tracks: availableTracks,
@@ -211,27 +271,28 @@ function WebBottomControls(props) {
211
271
  if (isControlEnabled(overrides, 'playbackRate', true) && !isLive) {
212
272
  playbackRateControl = h(WebPlaybackRateControl, { value: playbackRate, onChange: setPlaybackRateAction });
213
273
  }
214
- const muteLabel = muted ? 'Unmute' : 'Mute';
215
- const muteIcon = muted ? 'mute' : 'unmute';
216
- const videoOnlyLabel = videoOnly ? 'Enable audio' : 'Video only';
217
- const audioOnlyLabel = audioOnly ? 'Switch to audio card' : 'Audio only';
274
+ const videoOnlyLabel = 'Video only';
275
+ const audioOnlyLabel = 'Audio only';
218
276
  const fullscreenLabel = fullscreen ? 'Exit full screen' : 'Full screen';
219
277
 
220
278
  return h('div', { className: 'cinecrew-player__bottom-controls' },
221
279
  seek,
222
- renderControlButton({ name: 'mute', label: muteLabel, callback: toggleMute, options: { icon: muteIcon }, overrides, icons, theme }),
223
- aspect,
224
- renderControlButton({ name: 'videoOnly', label: videoOnlyLabel, callback: () => setVideoOnlyMode(!videoOnly), options: { active: videoOnly, defaultVisible: false }, overrides, icons, theme }),
225
- renderControlButton({ name: 'audioOnly', label: audioOnlyLabel, callback: () => setAudioOnlyMode(true), options: { icon: 'audio', active: audioOnly }, overrides, icons, theme }),
226
- audioTracks,
227
- playbackRateControl,
228
- renderControlButton({ name: 'fullscreen', label: fullscreenLabel, callback: toggleFullscreen, options: { icon: 'fullscreen' }, overrides, icons, theme }));
280
+ h('div', { className: 'cinecrew-player__bottom-actions' },
281
+ h('div', { className: 'cinecrew-player__bottom-left-actions' },
282
+ renderControlButton({ name: 'audioOnly', label: audioOnlyLabel, callback: () => setAudioOnlyMode(true), options: { active: audioOnly }, overrides, icons, theme }),
283
+ renderControlButton({ name: 'videoOnly', label: videoOnlyLabel, callback: () => setVideoOnlyMode(!videoOnly), options: { active: videoOnly, defaultVisible: false }, overrides, icons, theme }),
284
+ aspect),
285
+ h('div', { className: 'cinecrew-player__bottom-right-actions' },
286
+ audioTracks,
287
+ playbackRateControl,
288
+ renderControlButton({ name: 'fullscreen', label: fullscreenLabel, callback: toggleFullscreen, options: { icon: 'fullscreen' }, overrides, icons, theme }))));
229
289
  }
230
290
 
231
- function WebPlayerControls({ locked, overrides, theme, icons, unlockedControls, toggleLock, paused, togglePlay, bottomProps }) {
232
- let topControls = unlockedControls;
291
+ function WebPlayerControls({ locked, buffering, overrides, theme, icons, unlockedControls, toggleLock, paused, togglePlay, bottomProps }) {
292
+ let leftControls = locked ? null : unlockedControls.left;
293
+ let rightControls = unlockedControls.right;
233
294
  if (locked) {
234
- topControls = renderControlButton({
295
+ rightControls = renderControlButton({
235
296
  name: 'lock', label: 'Unlock controls', callback: toggleLock,
236
297
  options: { active: true, defaultVisible: true }, overrides, icons, theme,
237
298
  });
@@ -239,14 +300,18 @@ function WebPlayerControls({ locked, overrides, theme, icons, unlockedControls,
239
300
  let centerControls = null;
240
301
  let bottomControls = null;
241
302
  if (!locked) {
242
- const playLabel = paused ? 'Play' : 'Pause';
243
- const playIcon = paused ? 'play' : 'pause';
244
- centerControls = h('div', { className: 'cinecrew-player__center-controls' },
245
- renderControlButton({ name: 'playPause', label: playLabel, callback: togglePlay, options: { icon: playIcon }, overrides, icons, theme }));
303
+ if (!buffering) {
304
+ const playLabel = paused ? 'Play' : 'Pause';
305
+ const playIcon = paused ? 'play' : 'pause';
306
+ centerControls = h('div', { className: 'cinecrew-player__center-controls' },
307
+ renderControlButton({ name: 'playPause', label: playLabel, callback: togglePlay, options: { icon: playIcon }, overrides, icons, theme }));
308
+ }
246
309
  bottomControls = h(WebBottomControls, bottomProps);
247
310
  }
248
311
  return h('div', { className: 'cinecrew-player__controls', style: { color: theme.controlColor } },
249
- h('div', { className: 'cinecrew-player__top-controls' }, topControls),
312
+ h('div', { className: 'cinecrew-player__top-controls' },
313
+ h('div', { className: 'cinecrew-player__top-left-actions' }, leftControls),
314
+ h('div', { className: 'cinecrew-player__top-right-actions' }, rightControls)),
250
315
  centerControls,
251
316
  bottomControls);
252
317
  }
@@ -265,6 +330,7 @@ function WebPlayerSurface({
265
330
  playbackRate,
266
331
  videoOnly,
267
332
  videoStyle,
333
+ drawerResize,
268
334
  audioOnly,
269
335
  inlinePreview,
270
336
  onPromotePreview,
@@ -292,9 +358,31 @@ function WebPlayerSurface({
292
358
  onStateChange,
293
359
  onError,
294
360
  onEnded,
361
+ style: drawerResize ? { width: 'var(--cinecrew-media-width, 64%)', height: '100%', inset: '0 auto 0 0' } : undefined,
295
362
  });
296
363
  }
297
364
  if (!streamUrl) return h('div', { className: 'cinecrew-player__empty' });
365
+ let resizedVideoStyle = {};
366
+ if (drawerResize && videoStyle.width === 'auto') {
367
+ resizedVideoStyle = {
368
+ left: 'calc(var(--cinecrew-media-width, 64%) / 2)',
369
+ top: '50%',
370
+ right: 'auto',
371
+ bottom: 'auto',
372
+ maxWidth: 'var(--cinecrew-media-width, 64%)',
373
+ transform: 'translate(-50%, -50%)',
374
+ };
375
+ } else if (drawerResize) {
376
+ resizedVideoStyle = {
377
+ width: 'var(--cinecrew-media-width, 64%)',
378
+ height: '100%',
379
+ left: 0,
380
+ top: 0,
381
+ right: 'auto',
382
+ bottom: 0,
383
+ transform: 'none',
384
+ };
385
+ }
298
386
  return h('video', {
299
387
  ref: videoRef,
300
388
  className: 'cinecrew-player__video',
@@ -304,21 +392,29 @@ function WebPlayerSurface({
304
392
  muted: muted || videoOnly,
305
393
  playsInline: true,
306
394
  preload: 'auto',
307
- style: { ...videoStyle, opacity: audioOnly ? 0 : 1 },
395
+ style: {
396
+ ...videoStyle,
397
+ ...resizedVideoStyle,
398
+ opacity: audioOnly ? 0 : 1,
399
+ },
308
400
  onClick: inlinePreview ? onPromotePreview : undefined,
309
401
  onError: (event) => {
402
+ if (!directVideoSource) return;
310
403
  const mediaError = event.currentTarget?.error;
311
404
  handleError({ message: mediaError?.message || 'The browser could not load this stream. Check URL, codec and CORS support.', code: mediaError?.code, cause: mediaError });
312
405
  },
313
406
  });
314
407
  }
315
408
 
316
- function WebAudioOnlyCard({ poster, title, theme, onSwitchToVideo }) {
409
+ function WebAudioOnlyCard({ poster, title, theme, icons, onSwitchToVideo }) {
317
410
  return h('div', {
318
411
  className: 'cinecrew-player__audio-card',
319
412
  style: { background: theme.surfaceColor, color: theme.controlColor, borderColor: theme.accentColor },
320
413
  },
321
- poster ? h('img', { className: 'cinecrew-player__audio-poster', src: poster, alt: '' }) : null,
414
+ poster
415
+ ? h('img', { className: 'cinecrew-player__audio-poster', src: poster, alt: '' })
416
+ : h('div', { className: 'cinecrew-player__audio-placeholder', role: 'img', 'aria-label': 'Audio artwork placeholder' },
417
+ h(Icon, { name: 'audioOnly', icons, color: theme.accentColor })),
322
418
  h('span', { className: 'cinecrew-player__audio-wave', style: { color: theme.accentColor }, 'aria-hidden': true }, '•••••••'),
323
419
  h('strong', null, title || 'Audio only'),
324
420
  h('button', {
@@ -346,7 +442,9 @@ function isAudioPlaybackEnabled(muted, videoOnly) {
346
442
  }
347
443
 
348
444
  function getPanelIntegration(activePanel, integrations) {
349
- return activePanel === 'chat' ? integrations.liveChat : integrations.epg;
445
+ if (activePanel === 'chat') return integrations.liveChat;
446
+ if (activePanel === 'epg') return integrations.epg;
447
+ return null;
350
448
  }
351
449
 
352
450
  function getActiveMediaUrl(youtubeVideoId, streamUrl) {
@@ -359,15 +457,11 @@ function getInlinePlayerStyle(inlinePreview, rect) {
359
457
  }
360
458
 
361
459
  function buildUnlockedControls({
362
- control, actions, onBack, isLive, restart, toggleLock, hasRecording,
460
+ control, onBack, isLive, restart, toggleLock, toggleMute, muted, hasRecording,
363
461
  recording, integrations, action, videoRef, streamUrl, title, activePanel,
364
- hasChat, hasEpg, handleOpenPanel, handleMinimize, setRecording, backVisible, minimizeVisible,
462
+ hasChat, hasEpg, hasDiagnostics, handleOpenPanel, setRecording, backVisible,
365
463
  }) {
366
- const controls = [
367
- control('back', 'Back', onBack, { defaultVisible: backVisible }),
368
- ...(!isLive ? [control('restart', 'Restart', restart)] : []),
369
- control('lock', 'Lock controls', toggleLock, { active: false, defaultVisible: true }),
370
- ];
464
+ const leftControls = [control('back', 'Back', onBack, { defaultVisible: backVisible })];
371
465
  let recordingControl = null;
372
466
  if (hasRecording) {
373
467
  const recordingLabel = recording ? 'Stop recording' : 'Start recording';
@@ -382,19 +476,39 @@ function buildUnlockedControls({
382
476
  }
383
477
  const chatLabel = activePanel === 'chat' ? 'Close live chat' : 'Live chat';
384
478
  const epgLabel = activePanel === 'epg' ? 'Close programme guide' : 'Programme guide';
385
- return [
386
- ...controls,
479
+ const rightControls = [
387
480
  recordingControl,
388
481
  control('liveChat', chatLabel, () => handleOpenPanel('chat'), { active: activePanel === 'chat', defaultVisible: hasChat }),
389
482
  control('epg', epgLabel, () => handleOpenPanel('epg'), { active: activePanel === 'epg', defaultVisible: hasEpg }),
390
- control('minimize', 'Minimize player', handleMinimize, { defaultVisible: minimizeVisible }),
483
+ ...(!isLive ? [control('restart', 'Restart', restart)] : []),
391
484
  ];
485
+ const muteLabel = muted ? 'Unmute' : 'Mute';
486
+ rightControls.push(
487
+ control('mute', muteLabel, toggleMute, { icon: muted ? 'mute' : 'unmute' }),
488
+ control('diagnostics', 'Stream diagnostics', () => handleOpenPanel('diagnostics'), { active: activePanel === 'diagnostics', defaultVisible: hasDiagnostics }),
489
+ control('lock', 'Lock controls', toggleLock, { active: false, defaultVisible: true }),
490
+ );
491
+ return { left: leftControls, right: rightControls };
492
+ }
493
+
494
+ function getDrawerLabel(activePanel) {
495
+ if (activePanel === 'chat') return 'Live chat drawer';
496
+ if (activePanel === 'epg') return 'Programme guide drawer';
497
+ return 'Stream diagnostics drawer';
498
+ }
499
+
500
+ function getPlaybackStatus(error, buffering, paused) {
501
+ if (error) return 'Error';
502
+ if (buffering) return 'Buffering';
503
+ return paused ? 'Paused' : 'Playing';
392
504
  }
393
505
 
394
506
  function WebPlayerLayout(props) {
395
- const controlLayer = !props.error && !props.audioOnly
396
- ? h(WebPlayerControls, {
507
+ let controlLayer = null;
508
+ if (!props.error && !props.audioOnly) {
509
+ controlLayer = h(WebPlayerControls, {
397
510
  locked: props.locked,
511
+ buffering: props.buffering,
398
512
  overrides: props.controlOverrides,
399
513
  theme: props.theme,
400
514
  icons: props.icons,
@@ -403,25 +517,33 @@ function WebPlayerLayout(props) {
403
517
  paused: props.isPaused,
404
518
  togglePlay: props.togglePlay,
405
519
  bottomProps: props.bottomControlProps,
406
- })
407
- : null;
408
- const playerTitle = props.title && !props.audioOnly
409
- ? h('div', { className: 'cinecrew-player__title', style: { color: props.theme.controlColor } }, props.title)
410
- : null;
411
- const loadingNotice = props.buffering && !props.error
412
- ? h('div', { className: 'cinecrew-player__status', style: { color: props.theme.controlColor } },
520
+ });
521
+ }
522
+ let playerTitle = null;
523
+ if (props.title && !props.audioOnly) {
524
+ playerTitle = h('div', { className: 'cinecrew-player__title', style: { color: props.theme.controlColor } }, props.title);
525
+ }
526
+ let loadingNotice = null;
527
+ if (props.buffering && !props.error) {
528
+ loadingNotice = h('div', { className: 'cinecrew-player__status', style: { color: props.theme.controlColor } },
413
529
  h('span', { className: 'cinecrew-player__spinner', style: { borderTopColor: props.theme.accentColor } }), 'Loading stream…')
414
- : null;
415
- const audioCard = props.audioOnly
416
- ? h(WebAudioOnlyCard, { poster: props.poster, title: props.title, theme: props.theme, onSwitchToVideo: props.onSwitchToVideo })
417
- : null;
418
- const panelNode = props.activePanel && props.webPanel
419
- ? h('div', { className: 'cinecrew-player__panel', style: { background: props.theme.surfaceColor, color: props.theme.controlColor } }, props.webPanel)
420
- : null;
530
+ }
531
+ let audioCard = null;
532
+ if (props.audioOnly) {
533
+ audioCard = h(WebAudioOnlyCard, { poster: props.poster, title: props.title, theme: props.theme, icons: props.icons, onSwitchToVideo: props.onSwitchToVideo });
534
+ }
535
+ let panelNode = null;
536
+ if (props.activePanel && props.webPanel) {
537
+ panelNode = h('aside', {
538
+ className: `cinecrew-player__panel${props.drawerMode === 'resize' ? ' is-resizing' : ''}`,
539
+ style: { color: props.theme.controlColor, ...props.drawerStyle },
540
+ 'aria-label': getDrawerLabel(props.activePanel),
541
+ }, props.webPanel);
542
+ }
421
543
  return h('div', {
422
544
  ref: props.playerRef,
423
- className: `cinecrew-player${props.inlinePreview ? ' cinecrew-player--inline-preview' : ''} ${props.className}`.trim(),
424
- 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 },
545
+ className: `cinecrew-player${props.inlinePreview ? ' cinecrew-player--inline-preview' : ''}${props.drawerMode === 'resize' && props.activePanel ? ' cinecrew-player--drawer-resize' : ''} ${props.className}`.trim(),
546
+ 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%' },
425
547
  onWheel: props.onWheel,
426
548
  'data-stream-mode': props.streamMode,
427
549
  },
@@ -429,7 +551,7 @@ function WebPlayerLayout(props) {
429
551
  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))' } }),
430
552
  playerTitle,
431
553
  loadingNotice,
432
- h(WebPlayerError, { error: props.error, theme: props.theme, renderBackButton: props.renderBackButton }),
554
+ h(WebPlayerError, { error: props.error, theme: props.theme, renderBackButton: props.locked ? () => null : props.renderBackButton }),
433
555
  controlLayer,
434
556
  audioCard,
435
557
  panelNode);
@@ -473,7 +595,13 @@ function getPanelRenderer(activePanel, renderLiveChat, renderEpg, integrations)
473
595
  return null;
474
596
  }
475
597
 
476
- function createWebPanelNode({ renderer, integration, activePanel, integrations, source, title, theme, onClose }) {
598
+ function createWebPanelNode({
599
+ renderer, integration, activePanel, integrations, source, title, theme, icons, onClose,
600
+ diagnostics, messagePageSize,
601
+ }) {
602
+ if (activePanel === 'diagnostics') {
603
+ return h(WebDiagnosticsPanel, { title, theme, icons, onClose, ...diagnostics });
604
+ }
477
605
  if (renderer) return h(renderer, { title, source, onClose });
478
606
  if (!integration) return null;
479
607
  const hasLoader = activePanel === 'chat'
@@ -488,20 +616,247 @@ function createWebPanelNode({ renderer, integration, activePanel, integrations,
488
616
  title,
489
617
  theme,
490
618
  onClose,
619
+ messagePageSize,
491
620
  });
492
621
  }
493
622
 
494
- function WebIntegrationPanel({ kind, integration, integrations, source, title, theme, onClose }) {
623
+ function getBufferedAhead(video, currentTime) {
624
+ if (!video?.buffered) return null;
625
+ for (let index = 0; index < video.buffered.length; index += 1) {
626
+ const start = video.buffered.start(index);
627
+ const end = video.buffered.end(index);
628
+ if (currentTime >= start && currentTime <= end) return Math.max(0, end - currentTime);
629
+ }
630
+ return 0;
631
+ }
632
+
633
+ function getFrameQuality(video) {
634
+ try {
635
+ const quality = video?.getVideoPlaybackQuality?.();
636
+ if (quality) return { dropped: quality.droppedVideoFrames, total: quality.totalVideoFrames };
637
+ } catch {
638
+ // Playback quality is optional and may not be exposed by a media engine.
639
+ }
640
+ if (Number.isFinite(video?.webkitDroppedFrameCount) || Number.isFinite(video?.webkitDecodedFrameCount)) {
641
+ return { dropped: video.webkitDroppedFrameCount, total: video.webkitDecodedFrameCount };
642
+ }
643
+ return null;
644
+ }
645
+
646
+ function WebDiagnosticsPanel({ title, theme, icons, onClose, streamMode, status, currentTime, duration, videoRef }) {
647
+ const video = videoRef?.current;
648
+ const resolution = video?.videoWidth && video?.videoHeight
649
+ ? `${video.videoWidth} × ${video.videoHeight}`
650
+ : 'Not available';
651
+ const connection = globalThis.navigator?.connection;
652
+ const buffered = getBufferedAhead(video, currentTime);
653
+ const frames = getFrameQuality(video);
654
+ const cards = [
655
+ { icon: 'display', label: 'Video resolution', value: resolution },
656
+ { icon: 'network', label: 'Connection rate', value: Number.isFinite(connection?.downlink) ? `${connection.downlink} Mbps` : 'Not reported' },
657
+ { icon: 'clock', label: 'Network RTT estimate', value: Number.isFinite(connection?.rtt) ? `${connection.rtt} ms` : 'Not reported' },
658
+ { icon: 'buffer', label: 'Buffered ahead', value: buffered === null ? 'Not reported' : `${buffered.toFixed(1)} sec` },
659
+ { icon: 'frames', label: 'Dropped / total frames', value: frames && Number.isFinite(frames.dropped) ? `${frames.dropped} / ${frames.total ?? '—'}` : 'Not reported' },
660
+ { icon: 'speedometer', label: 'Playback position', value: `${formatTime(currentTime)} / ${formatTime(duration)}` },
661
+ { icon: 'network', label: 'Connection type', value: connection?.effectiveType || 'Not reported' },
662
+ { icon: 'video', label: 'Stream format', value: streamMode || 'Unknown' },
663
+ ];
664
+ return h('section', {
665
+ className: 'cinecrew-player__diagnostics',
666
+ style: { color: theme.controlColor },
667
+ 'aria-label': 'Stream diagnostics',
668
+ },
669
+ h('header', { className: 'cinecrew-player__diagnostics-heading' },
670
+ h('div', null,
671
+ h('strong', null, 'Stream diagnostics'),
672
+ h('span', null, title || 'Current playback')),
673
+ h('button', {
674
+ type: 'button',
675
+ className: 'cinecrew-player__diagnostics-close',
676
+ onClick: onClose,
677
+ 'aria-label': 'Close stream diagnostics',
678
+ title: 'Close diagnostics',
679
+ }, h(Icon, { name: 'close', icons, color: theme.controlColor }))),
680
+ h('div', { className: 'cinecrew-player__diagnostics-status' },
681
+ h('span', { className: `cinecrew-player__status-dot is-${String(status || 'unknown').toLowerCase()}` }),
682
+ h('span', null, status || 'Unknown'),
683
+ h('span', null, streamMode || 'Unknown format')),
684
+ h('div', { className: 'cinecrew-player__diagnostic-grid' },
685
+ cards.map(({ icon, label, value }) => h('article', { className: 'cinecrew-player__diagnostic-card', key: label },
686
+ h('span', { className: 'cinecrew-player__diagnostic-icon' }, h(Icon, { name: icon, icons, color: theme.accentColor })),
687
+ h('span', { className: 'cinecrew-player__diagnostic-label' }, label),
688
+ h('strong', null, value)))),
689
+ 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.'));
690
+ }
691
+
692
+ function getChatMessageKey(message, index = 0) {
693
+ return String(message?.id ?? message?.messageId ?? message?.createdAt ?? message?.timestamp
694
+ ?? `${message?.username || message?.userName || ''}:${message?.comment || message?.message || message?.text || index}`);
695
+ }
696
+
697
+ function mergeChatMessages(existing, incoming, prepend = false) {
698
+ const messages = prepend ? [...incoming, ...existing] : [...existing, ...incoming];
699
+ const unique = new Map();
700
+ messages.forEach((message, index) => unique.set(getChatMessageKey(message, index), message));
701
+ return [...unique.values()];
702
+ }
703
+
704
+ function formatChatTimestamp(value) {
705
+ if (value === null || value === undefined || value === '') return '';
706
+ const date = new Date(value);
707
+ return Number.isNaN(date.getTime())
708
+ ? String(value)
709
+ : date.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
710
+ }
711
+
712
+ const EMOJI_BATCH_SIZE = 96;
713
+
714
+ function WebEmojiPicker({ onSelect }) {
715
+ const [emojiGroup, setEmojiGroup] = useState(EMOJI_GROUPS[0].name);
716
+ const [emojiQuery, setEmojiQuery] = useState('');
717
+ const [visibleCount, setVisibleCount] = useState(EMOJI_BATCH_SIZE);
718
+ const listRef = useRef(null);
719
+ const activeGroup = EMOJI_GROUPS.find((group) => group.name === emojiGroup) || EMOJI_GROUPS[0];
720
+ const filteredEmojis = useMemo(
721
+ () => emojiQuery.trim() ? searchEmojis(emojiQuery) : activeGroup.items,
722
+ [activeGroup, emojiQuery],
723
+ );
724
+ const visibleEmojis = filteredEmojis.slice(0, visibleCount);
725
+
726
+ useEffect(() => {
727
+ setVisibleCount(EMOJI_BATCH_SIZE);
728
+ if (listRef.current) listRef.current.scrollTop = 0;
729
+ }, [emojiGroup, emojiQuery]);
730
+
731
+ const loadMore = () => setVisibleCount((count) => Math.min(count + EMOJI_BATCH_SIZE, filteredEmojis.length));
732
+ const handleScroll = (event) => {
733
+ const element = event.currentTarget;
734
+ if (element.scrollTop + element.clientHeight >= element.scrollHeight - 32) loadMore();
735
+ };
736
+
737
+ return h('div', { className: 'cinecrew-player__emoji-picker', role: 'dialog', 'aria-label': 'Choose an emoji' },
738
+ h('input', {
739
+ className: 'cinecrew-player__emoji-search',
740
+ type: 'search',
741
+ value: emojiQuery,
742
+ onChange: (event) => setEmojiQuery(event.target.value),
743
+ placeholder: 'Search all emojis',
744
+ 'aria-label': 'Search all emojis',
745
+ }),
746
+ h('nav', { className: 'cinecrew-player__emoji-categories', 'aria-label': 'Emoji categories' },
747
+ EMOJI_GROUPS.map((group) => h('button', {
748
+ key: group.name,
749
+ type: 'button',
750
+ className: !emojiQuery.trim() && group.name === emojiGroup ? 'is-active' : '',
751
+ onClick: () => {
752
+ setEmojiGroup(group.name);
753
+ setEmojiQuery('');
754
+ },
755
+ 'aria-label': group.name,
756
+ title: group.name,
757
+ 'aria-pressed': !emojiQuery.trim() && group.name === emojiGroup,
758
+ }, group.icon))),
759
+ h('div', {
760
+ className: 'cinecrew-player__emoji-grid',
761
+ role: 'grid',
762
+ ref: listRef,
763
+ onScroll: handleScroll,
764
+ },
765
+ visibleEmojis.map((item) => h('button', {
766
+ key: item.codepoints,
767
+ type: 'button',
768
+ onClick: () => onSelect(item.emoji),
769
+ 'aria-label': `Insert ${item.short_name}`,
770
+ title: item.short_name,
771
+ }, item.emoji)),
772
+ visibleCount < filteredEmojis.length ? h('button', {
773
+ type: 'button',
774
+ className: 'cinecrew-player__emoji-load-more',
775
+ onClick: loadMore,
776
+ }, 'Load more emojis') : null));
777
+ }
778
+
779
+ function WebChatComposer({ sending, canSend, onSend }) {
780
+ const [message, setMessage] = useState('');
781
+ const [emojiOpen, setEmojiOpen] = useState(false);
782
+ const handleSubmit = async (event) => {
783
+ event.preventDefault();
784
+ const comment = message.trim();
785
+ if (!comment || sending || !canSend) return;
786
+ const sent = await onSend(comment);
787
+ if (sent) setMessage('');
788
+ };
789
+ const insertEmoji = (emoji) => setMessage((current) => `${current}${emoji}`);
790
+
791
+ return h('form', { className: 'cinecrew-player__chat-form', onSubmit: handleSubmit },
792
+ h('div', { className: 'cinecrew-player__chat-composer' },
793
+ h('button', {
794
+ type: 'button',
795
+ className: 'cinecrew-player__emoji-toggle',
796
+ onClick: () => setEmojiOpen((open) => !open),
797
+ 'aria-label': emojiOpen ? 'Close emoji picker' : 'Open emoji picker',
798
+ }, '☺'),
799
+ h('input', {
800
+ value: message,
801
+ onChange: (event) => setMessage(event.target.value),
802
+ placeholder: 'Add a message…',
803
+ 'aria-label': 'Chat message',
804
+ maxLength: 1000,
805
+ }),
806
+ h('button', {
807
+ type: 'submit',
808
+ disabled: sending || !message.trim() || !canSend,
809
+ 'aria-label': sending ? 'Sending message' : 'Send message',
810
+ title: sending ? 'Sending…' : 'Send message',
811
+ }, h(Icon, { name: 'send', color: '#07111e' }))),
812
+ emojiOpen ? h(WebEmojiPicker, { onSelect: insertEmoji }) : null);
813
+ }
814
+
815
+ function WebPanelRow({ row, index, isChat }) {
816
+ if (isChat) {
817
+ const timestamp = row.createdAt ?? row.timestamp ?? row.sentAt ?? row.time;
818
+ return h('article', { key: row.id ?? row.messageId ?? index, className: 'cinecrew-player__chat-message' },
819
+ h('header', null,
820
+ h('strong', null, row.username || row.userName || row.name || 'Viewer'),
821
+ timestamp ? h('time', { dateTime: String(timestamp) }, formatChatTimestamp(timestamp)) : null),
822
+ h('span', null, row.comment || row.message || row.text || ''));
823
+ }
824
+ const start = row.startMs ?? row.start ?? row.startTime;
825
+ const end = row.endMs ?? row.end ?? row.endTime;
826
+ return h('article', { key: row.id ?? row.startMs ?? index, className: 'cinecrew-player__epg-item' },
827
+ h('small', null, [formatListingTime(start), formatListingTime(end)].filter(Boolean).join(' – ')),
828
+ h('strong', null, row.title || row.name || 'Programme'),
829
+ row.description ? h('span', null, row.description) : null);
830
+ }
831
+
832
+ function WebLoadMoreMessages({ loading, onClick }) {
833
+ return h('button', {
834
+ type: 'button',
835
+ className: 'cinecrew-player__load-more',
836
+ onClick,
837
+ disabled: loading,
838
+ }, loading ? 'Loading more…' : 'See more messages');
839
+ }
840
+
841
+ function WebIntegrationPanel({ kind, integration, integrations, source, title, theme, onClose, messagePageSize = 50 }) {
495
842
  const [rows, setRows] = useState([]);
496
843
  const [user, setUser] = useState(integrations.user || null);
497
- const [message, setMessage] = useState('');
498
844
  const [loading, setLoading] = useState(true);
845
+ const [loadingOlder, setLoadingOlder] = useState(false);
846
+ const [hasMoreMessages, setHasMoreMessages] = useState(false);
847
+ const [messageOffset, setMessageOffset] = useState(0);
499
848
  const [sending, setSending] = useState(false);
500
849
  const [error, setError] = useState('');
850
+ const rowsRef = useRef([]);
501
851
  const channelId = String(source.streamId || source.mediaId || source.id || '');
502
852
  const isChat = kind === 'chat';
853
+ const pageSize = Math.max(1, Math.floor(Number(messagePageSize) || 50));
503
854
  const emptyStateMessage = isChat ? 'No messages yet.' : 'No programme information available.';
504
- const sendButtonLabel = sending ? 'Sending…' : 'Send';
855
+
856
+ const updateRows = (nextRows) => {
857
+ rowsRef.current = nextRows;
858
+ setRows(nextRows);
859
+ };
505
860
 
506
861
  const load = useCallback(async () => {
507
862
  if (!channelId) {
@@ -512,15 +867,30 @@ function WebIntegrationPanel({ kind, integration, integrations, source, title, t
512
867
  try {
513
868
  setError('');
514
869
  const value = isChat
515
- ? await integration.loadMessages({ channelId, limit: 50 })
870
+ ? await integration.loadMessages({ channelId, limit: pageSize, offset: 0 })
516
871
  : await integration.loadListings({ channelId, limit: integration.limit || 48 });
517
- setRows(responseRows(value, isChat ? ['messages', 'items', 'comments'] : ['listings', 'programmes', 'epg', 'items']));
872
+ const nextRows = responseRows(value, isChat ? ['messages', 'items', 'comments'] : ['listings', 'programmes', 'epg', 'items']);
873
+ if (isChat) {
874
+ const previousCount = rowsRef.current.length;
875
+ const mergedRows = mergeChatMessages(rowsRef.current, nextRows);
876
+ updateRows(mergedRows);
877
+ const addedCount = Math.max(0, mergedRows.length - previousCount);
878
+ setMessageOffset((offset) => previousCount === 0
879
+ ? nextRows.length
880
+ : Math.max(offset, nextRows.length) + addedCount);
881
+ if (previousCount === 0) {
882
+ const explicitHasMore = value?.hasMore ?? value?.pagination?.hasMore;
883
+ setHasMoreMessages(explicitHasMore === undefined ? nextRows.length >= pageSize : Boolean(explicitHasMore));
884
+ }
885
+ } else {
886
+ setRows(nextRows);
887
+ }
518
888
  } catch (loadError) {
519
889
  setError(loadError?.message || `Could not load ${isChat ? 'live chat' : 'the programme guide'}.`);
520
890
  } finally {
521
891
  setLoading(false);
522
892
  }
523
- }, [channelId, integration, isChat]);
893
+ }, [channelId, integration, isChat, pageSize]);
524
894
 
525
895
  useEffect(() => {
526
896
  let cancelled = false;
@@ -534,16 +904,36 @@ function WebIntegrationPanel({ kind, integration, integrations, source, title, t
534
904
 
535
905
  useEffect(() => {
536
906
  setLoading(true);
907
+ rowsRef.current = [];
908
+ setRows([]);
909
+ setMessageOffset(0);
910
+ setHasMoreMessages(false);
537
911
  load();
538
912
  if (!isChat) return undefined;
539
913
  const timer = setInterval(load, Math.max(1000, Number(integration.pollIntervalMs) || 5000));
540
914
  return () => clearInterval(timer);
541
915
  }, [load, isChat, integration.pollIntervalMs]);
542
916
 
543
- const send = async (event) => {
544
- event.preventDefault();
545
- const comment = message.trim();
546
- if (!comment || sending || !integration.sendMessage) return;
917
+ const loadOlderMessages = async () => {
918
+ if (!isChat || !hasMoreMessages || loadingOlder) return;
919
+ setLoadingOlder(true);
920
+ try {
921
+ setError('');
922
+ const value = await integration.loadMessages({ channelId, limit: pageSize, offset: messageOffset });
923
+ const olderRows = responseRows(value, ['messages', 'items', 'comments']);
924
+ updateRows(mergeChatMessages(rowsRef.current, olderRows, true));
925
+ setMessageOffset((offset) => offset + olderRows.length);
926
+ const explicitHasMore = value?.hasMore ?? value?.pagination?.hasMore;
927
+ setHasMoreMessages(explicitHasMore === undefined ? olderRows.length >= pageSize : Boolean(explicitHasMore));
928
+ } catch (loadError) {
929
+ setError(loadError?.message || 'Could not load more messages.');
930
+ } finally {
931
+ setLoadingOlder(false);
932
+ }
933
+ };
934
+
935
+ const send = async (comment) => {
936
+ if (!comment || sending || !integration.sendMessage) return false;
547
937
  setSending(true);
548
938
  try {
549
939
  await integration.sendMessage({
@@ -552,10 +942,11 @@ function WebIntegrationPanel({ kind, integration, integrations, source, title, t
552
942
  username: user?.username || 'Viewer',
553
943
  comment,
554
944
  });
555
- setMessage('');
556
945
  await load();
946
+ return true;
557
947
  } catch (sendError) {
558
948
  setError(sendError?.message || 'Could not send your message.');
949
+ return false;
559
950
  } finally {
560
951
  setSending(false);
561
952
  }
@@ -572,29 +963,14 @@ function WebIntegrationPanel({ kind, integration, integrations, source, title, t
572
963
  loading ? h('div', { className: 'cinecrew-player__panel-state' }, 'Loading…') : null,
573
964
  error ? h('div', { className: 'cinecrew-player__panel-state is-error', role: 'status' }, error) : null,
574
965
  !loading && !error && rows.length === 0 ? h('div', { className: 'cinecrew-player__panel-state' }, emptyStateMessage) : null,
575
- h('div', { className: 'cinecrew-player__panel-list' }, rows.map((row, index) => {
576
- const key = row.id ?? row.messageId ?? row.startMs ?? index;
577
- if (isChat) {
578
- return h('article', { key, className: 'cinecrew-player__chat-message' },
579
- h('strong', null, row.username || row.userName || row.name || 'Viewer'),
580
- h('span', null, row.comment || row.message || row.text || ''));
581
- }
582
- const start = row.startMs ?? row.start ?? row.startTime;
583
- const end = row.endMs ?? row.end ?? row.endTime;
584
- return h('article', { key, className: 'cinecrew-player__epg-item' },
585
- h('small', null, [formatListingTime(start), formatListingTime(end)].filter(Boolean).join(' – ')),
586
- h('strong', null, row.title || row.name || 'Programme'),
587
- row.description ? h('span', null, row.description) : null);
588
- })),
589
- isChat && typeof integration.sendMessage === 'function' ? h('form', { className: 'cinecrew-player__chat-form', onSubmit: send },
590
- h('input', {
591
- value: message,
592
- onChange: (event) => setMessage(event.target.value),
593
- placeholder: 'Add a message…',
594
- 'aria-label': 'Chat message',
595
- maxLength: 1000,
596
- }),
597
- h('button', { type: 'submit', disabled: sending || !message.trim() }, sendButtonLabel)) : null);
966
+ h('div', { className: 'cinecrew-player__panel-list' },
967
+ isChat && hasMoreMessages ? h(WebLoadMoreMessages, { loading: loadingOlder, onClick: loadOlderMessages }) : null,
968
+ rows.map((row, index) => h(WebPanelRow, { key: getChatMessageKey(row, index), row, index, isChat }))),
969
+ isChat ? h(WebChatComposer, {
970
+ sending,
971
+ canSend: typeof integration.sendMessage === 'function',
972
+ onSend: send,
973
+ }) : null);
598
974
  }
599
975
 
600
976
  /**
@@ -614,6 +990,10 @@ export const CineCrewPlayer = forwardRef(function CineCrewPlayer(props, ref) {
614
990
  playbackRate: playbackRateProp = 1,
615
991
  paused: pausedProp,
616
992
  controls: controlOverrides = {},
993
+ features = {},
994
+ drawerMode = 'overlay',
995
+ drawerStyle,
996
+ messagePageSize = 50,
617
997
  actions = {},
618
998
  theme: themeProp = {},
619
999
  icons = {},
@@ -634,7 +1014,6 @@ export const CineCrewPlayer = forwardRef(function CineCrewPlayer(props, ref) {
634
1014
  onPromotePreview,
635
1015
  initialShowLiveChat = false,
636
1016
  liveChatNonce = 0,
637
- onMinimize,
638
1017
  onBack,
639
1018
  mediaId,
640
1019
  onFullscreen,
@@ -758,6 +1137,7 @@ export const CineCrewPlayer = forwardRef(function CineCrewPlayer(props, ref) {
758
1137
  pausedRef: pausedStateRef,
759
1138
  onErrorRef,
760
1139
  });
1140
+ const streamMode = getStreamMode(youtubeVideoId, mpegTs.useMpegTs, useHls);
761
1141
  useWebAc3AudioPlayback({
762
1142
  active: mpegTs.useAc3Fallback,
763
1143
  streamUrl,
@@ -772,7 +1152,9 @@ export const CineCrewPlayer = forwardRef(function CineCrewPlayer(props, ref) {
772
1152
  if (!video) return;
773
1153
  if (isPaused) video.pause();
774
1154
  else video.play().catch((playError) => {
775
- if (playError?.name !== 'NotAllowedError') handleError(playError);
1155
+ if (playError?.name !== 'NotAllowedError' && playError?.name !== 'AbortError') {
1156
+ handleError(playError);
1157
+ }
776
1158
  });
777
1159
  }, [isPaused, streamUrl, youtubeVideoId, handleError]);
778
1160
 
@@ -826,7 +1208,6 @@ export const CineCrewPlayer = forwardRef(function CineCrewPlayer(props, ref) {
826
1208
  else youtubeRef.current?.seekTo?.(Math.max(0, Number(seconds) || 0));
827
1209
  }, { seconds: Number(seconds) || 0 }), [action]);
828
1210
  const handleBack = useCallback(() => action('onBack', onBack || props.onClose, { title, source: media }), [action, onBack, props.onClose, title, media]);
829
- const handleMinimize = useCallback(() => action('onMinimize', onMinimize, { title, source: media }), [action, onMinimize, title, media]);
830
1211
  useImperativeHandle(ref, () => {
831
1212
  const api = {
832
1213
  play: () => setPaused(false),
@@ -847,7 +1228,6 @@ export const CineCrewPlayer = forwardRef(function CineCrewPlayer(props, ref) {
847
1228
  seekTo,
848
1229
  seekBy: (delta) => seekTo((Number(videoRef.current?.currentTime) || currentTime) + (Number(delta) || 0)),
849
1230
  back: handleBack,
850
- minimize: handleMinimize,
851
1231
  getVideoElement: () => videoRef.current,
852
1232
  enterFullscreen: () => playerRef.current?.requestFullscreen?.(),
853
1233
  exitFullscreen: () => typeof document !== 'undefined' ? document.exitFullscreen?.() : undefined,
@@ -855,11 +1235,12 @@ export const CineCrewPlayer = forwardRef(function CineCrewPlayer(props, ref) {
855
1235
  };
856
1236
  publicPlayerRef.current = api;
857
1237
  return api;
858
- }, [setPaused, togglePlay, restart, toggleMute, setMuted, setAspectRatio, setAudioOnlyMode, setVideoOnlyMode, setPlaybackRateAction, seekTo, handleBack, handleMinimize, availableTracks, tracksProp, currentTime]);
1238
+ }, [setPaused, togglePlay, restart, toggleMute, setMuted, setAspectRatio, setAudioOnlyMode, setVideoOnlyMode, setPlaybackRateAction, seekTo, handleBack, availableTracks, tracksProp, currentTime]);
859
1239
 
860
1240
  const hasChat = typeof integrations.liveChat?.loadMessages === 'function' || typeof renderLiveChat === 'function' || typeof integrations.liveChat?.render === 'function' || typeof actions.onLiveChatOpen === 'function';
861
1241
  const hasEpg = typeof integrations.epg?.loadListings === 'function' || typeof renderEpg === 'function' || typeof integrations.epg?.render === 'function' || typeof actions.onEpgOpen === 'function';
862
1242
  const hasRecording = !!integrations.recording || typeof actions.onRecordingStart === 'function';
1243
+ const hasDiagnostics = Boolean(features.diagnostics) || typeof actions.onDiagnosticsOpen === 'function';
863
1244
  const sourceType = String(media.type || media.mimeType || '').toLowerCase();
864
1245
  const directVideoSource = getDirectVideoSource({
865
1246
  mpegTs: mpegTs.useMpegTs,
@@ -948,10 +1329,11 @@ export const CineCrewPlayer = forwardRef(function CineCrewPlayer(props, ref) {
948
1329
  actions,
949
1330
  onBack: handleBack,
950
1331
  backVisible: typeof actions.onBack === 'function' || typeof onBack === 'function' || typeof props.onClose === 'function',
951
- minimizeVisible: typeof actions.onMinimize === 'function' || typeof onMinimize === 'function',
952
1332
  isLive,
953
1333
  restart,
954
1334
  toggleLock,
1335
+ toggleMute,
1336
+ muted,
955
1337
  hasRecording,
956
1338
  recording,
957
1339
  integrations,
@@ -962,8 +1344,8 @@ export const CineCrewPlayer = forwardRef(function CineCrewPlayer(props, ref) {
962
1344
  activePanel,
963
1345
  hasChat,
964
1346
  hasEpg,
1347
+ hasDiagnostics,
965
1348
  handleOpenPanel: openPanel,
966
- handleMinimize,
967
1349
  setRecording,
968
1350
  });
969
1351
  const webPanel = createWebPanelNode({
@@ -974,7 +1356,16 @@ export const CineCrewPlayer = forwardRef(function CineCrewPlayer(props, ref) {
974
1356
  source: panelSource,
975
1357
  title,
976
1358
  theme,
1359
+ icons,
977
1360
  onClose: () => setActivePanel(null),
1361
+ diagnostics: {
1362
+ streamMode,
1363
+ status: getPlaybackStatus(error, buffering, isPaused),
1364
+ currentTime,
1365
+ duration,
1366
+ videoRef,
1367
+ },
1368
+ messagePageSize,
978
1369
  });
979
1370
 
980
1371
  const handleYouTubeProgress = (event) => {
@@ -996,6 +1387,7 @@ export const CineCrewPlayer = forwardRef(function CineCrewPlayer(props, ref) {
996
1387
  playbackRate,
997
1388
  videoOnly,
998
1389
  videoStyle,
1390
+ drawerResize: Boolean(activePanel) && drawerMode === 'resize',
999
1391
  audioOnly,
1000
1392
  inlinePreview,
1001
1393
  onPromotePreview,
@@ -1008,8 +1400,6 @@ export const CineCrewPlayer = forwardRef(function CineCrewPlayer(props, ref) {
1008
1400
  onError: handleError,
1009
1401
  handleError,
1010
1402
  });
1011
- const streamMode = getStreamMode(youtubeVideoId, mpegTs.useMpegTs, useHls);
1012
-
1013
1403
  const bottomControlProps = {
1014
1404
  isLive,
1015
1405
  overrides: controlOverrides,
@@ -1063,6 +1453,8 @@ export const CineCrewPlayer = forwardRef(function CineCrewPlayer(props, ref) {
1063
1453
  togglePlay,
1064
1454
  bottomControlProps,
1065
1455
  onWheel: (event) => onInlinePreviewWheel?.(event.deltaY),
1456
+ drawerMode,
1457
+ drawerStyle,
1066
1458
  });
1067
1459
  });
1068
1460
 
@@ -1111,7 +1503,7 @@ export const InlineLivePlayer = React.memo(function InlineLivePlayer({
1111
1503
  controls: {
1112
1504
  back: false, restart: false, lock: false, recording: false, liveChat: false, epg: false,
1113
1505
  seek: false, aspectRatio: false, videoOnly: false, audioOnly: false, audioTracks: false,
1114
- playbackRate: false, minimize: false, playPause: true, mute: true, fullscreen: true,
1506
+ playbackRate: false, playPause: true, mute: true, fullscreen: true,
1115
1507
  ...controls,
1116
1508
  },
1117
1509
  actions: {