@cinecrew/cinecrew-player 0.1.1 → 0.1.2

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', 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,
@@ -38,6 +40,7 @@ const WEB_ICON_PATHS = {
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',
40
42
  'arrow-collapse': 'M4 4h6v2H6v4H4zm10 0h6v6h-2V6h-4zM4 14h2v4h4v2H4zm14 0h2v6h-6v-2h4z',
43
+ logs: 'M3 3h18v18H3zm2 2v14h14V5zm3 3h2v2H8zm4 0h7v2h-7zm-4 4h2v2H8zm4 0h7v2h-7zm-4 4h2v2H8zm4 0h7v2h-7z',
41
44
  'arrow-left': 'M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20z',
42
45
  '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
46
  stop: 'M6 6h12v12H6z',
@@ -114,14 +117,43 @@ function getPanelActionName(panel) {
114
117
  }
115
118
 
116
119
  function WebSeekControl({ currentTime, duration, theme, onSeek }) {
120
+ const [seekDraft, setSeekDraft] = useState(currentTime);
121
+ const [isDragging, setIsDragging] = useState(false);
122
+
123
+ useEffect(() => {
124
+ if (!isDragging) setSeekDraft(currentTime);
125
+ }, [currentTime, isDragging]);
126
+
127
+ const updateSeek = (event) => {
128
+ const nextTime = Number(event.currentTarget.value);
129
+ setSeekDraft(nextTime);
130
+ onSeek(nextTime);
131
+ };
132
+
133
+ const finishSeek = (event) => {
134
+ if (!isDragging) return;
135
+ updateSeek(event);
136
+ setIsDragging(false);
137
+ };
138
+
117
139
  return h('div', { className: 'cinecrew-player__seek' },
118
140
  h('span', null, formatTime(currentTime)),
119
141
  h('input', {
120
142
  type: 'range',
143
+ 'aria-label': 'Seek video',
121
144
  min: 0,
122
145
  max: duration,
123
- value: Math.min(currentTime, duration),
124
- onChange: (event) => onSeek(Number(event.target.value)),
146
+ step: 'any',
147
+ value: Math.min(isDragging ? seekDraft : currentTime, duration),
148
+ onChange: updateSeek,
149
+ onPointerDown: (event) => {
150
+ event.stopPropagation();
151
+ setIsDragging(true);
152
+ setSeekDraft(Number(event.currentTarget.value));
153
+ },
154
+ onPointerUp: finishSeek,
155
+ onPointerCancel: finishSeek,
156
+ onBlur: finishSeek,
125
157
  style: { accentColor: theme.accentColor },
126
158
  }),
127
159
  h('span', null, formatTime(duration)));
@@ -138,7 +170,7 @@ function WebAspectRatioMenu({ open, theme, icons, onToggle, onSelect }) {
138
170
  onClick: () => onSelect(ratio),
139
171
  }, ratio)));
140
172
  }
141
- return h('div', { className: 'cinecrew-player__menu-wrap', key: 'aspectRatio' },
173
+ return h('div', { className: 'cinecrew-player__menu-wrap cinecrew-player__menu-wrap--aspect', key: 'aspectRatio' },
142
174
  h(PlayerButton, { name: 'aspectRatio', label: 'Aspect ratio', icons, theme, onClick: onToggle, active: open }),
143
175
  menu);
144
176
  }
@@ -170,8 +202,8 @@ function WebPlaybackRateControl({ value, onChange }) {
170
202
 
171
203
  function WebBottomControls(props) {
172
204
  const {
173
- isLive, overrides, theme, icons, currentTime, duration, seekTo, muted,
174
- toggleMute, showAspectMenu, setShowAspectMenu, selectAspect, videoOnly,
205
+ isLive, overrides, theme, icons, currentTime, duration, seekTo,
206
+ showAspectMenu, setShowAspectMenu, selectAspect, videoOnly,
175
207
  setVideoOnlyMode, audioOnly, setAudioOnlyMode, availableTracks,
176
208
  showAudioMenu, setShowAudioMenu, selectAudio, playbackRate,
177
209
  setPlaybackRateAction, fullscreen, toggleFullscreen,
@@ -211,27 +243,28 @@ function WebBottomControls(props) {
211
243
  if (isControlEnabled(overrides, 'playbackRate', true) && !isLive) {
212
244
  playbackRateControl = h(WebPlaybackRateControl, { value: playbackRate, onChange: setPlaybackRateAction });
213
245
  }
214
- const muteLabel = muted ? 'Unmute' : 'Mute';
215
- const muteIcon = muted ? 'mute' : 'unmute';
216
246
  const videoOnlyLabel = videoOnly ? 'Enable audio' : 'Video only';
217
247
  const audioOnlyLabel = audioOnly ? 'Switch to audio card' : 'Audio only';
218
248
  const fullscreenLabel = fullscreen ? 'Exit full screen' : 'Full screen';
219
249
 
220
250
  return h('div', { className: 'cinecrew-player__bottom-controls' },
221
251
  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 }));
252
+ h('div', { className: 'cinecrew-player__bottom-actions' },
253
+ h('div', { className: 'cinecrew-player__bottom-left-actions' },
254
+ renderControlButton({ name: 'videoOnly', label: videoOnlyLabel, callback: () => setVideoOnlyMode(!videoOnly), options: { active: videoOnly, defaultVisible: false }, overrides, icons, theme }),
255
+ aspect),
256
+ 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
+ audioTracks,
259
+ playbackRateControl,
260
+ renderControlButton({ name: 'fullscreen', label: fullscreenLabel, callback: toggleFullscreen, options: { icon: 'fullscreen' }, overrides, icons, theme }))));
229
261
  }
230
262
 
231
- function WebPlayerControls({ locked, overrides, theme, icons, unlockedControls, toggleLock, paused, togglePlay, bottomProps }) {
232
- let topControls = unlockedControls;
263
+ function WebPlayerControls({ locked, buffering, overrides, theme, icons, unlockedControls, toggleLock, paused, togglePlay, bottomProps }) {
264
+ let leftControls = unlockedControls.left;
265
+ let rightControls = unlockedControls.right;
233
266
  if (locked) {
234
- topControls = renderControlButton({
267
+ rightControls = renderControlButton({
235
268
  name: 'lock', label: 'Unlock controls', callback: toggleLock,
236
269
  options: { active: true, defaultVisible: true }, overrides, icons, theme,
237
270
  });
@@ -239,14 +272,18 @@ function WebPlayerControls({ locked, overrides, theme, icons, unlockedControls,
239
272
  let centerControls = null;
240
273
  let bottomControls = null;
241
274
  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 }));
275
+ if (!buffering) {
276
+ const playLabel = paused ? 'Play' : 'Pause';
277
+ const playIcon = paused ? 'play' : 'pause';
278
+ centerControls = h('div', { className: 'cinecrew-player__center-controls' },
279
+ renderControlButton({ name: 'playPause', label: playLabel, callback: togglePlay, options: { icon: playIcon }, overrides, icons, theme }));
280
+ }
246
281
  bottomControls = h(WebBottomControls, bottomProps);
247
282
  }
248
283
  return h('div', { className: 'cinecrew-player__controls', style: { color: theme.controlColor } },
249
- h('div', { className: 'cinecrew-player__top-controls' }, topControls),
284
+ h('div', { className: 'cinecrew-player__top-controls' },
285
+ h('div', { className: 'cinecrew-player__top-left-actions' }, leftControls),
286
+ h('div', { className: 'cinecrew-player__top-right-actions' }, rightControls)),
250
287
  centerControls,
251
288
  bottomControls);
252
289
  }
@@ -265,6 +302,7 @@ function WebPlayerSurface({
265
302
  playbackRate,
266
303
  videoOnly,
267
304
  videoStyle,
305
+ drawerResize,
268
306
  audioOnly,
269
307
  inlinePreview,
270
308
  onPromotePreview,
@@ -292,6 +330,7 @@ function WebPlayerSurface({
292
330
  onStateChange,
293
331
  onError,
294
332
  onEnded,
333
+ style: drawerResize ? { width: '64%', height: '64%', inset: '18% auto auto 0' } : undefined,
295
334
  });
296
335
  }
297
336
  if (!streamUrl) return h('div', { className: 'cinecrew-player__empty' });
@@ -304,9 +343,14 @@ function WebPlayerSurface({
304
343
  muted: muted || videoOnly,
305
344
  playsInline: true,
306
345
  preload: 'auto',
307
- style: { ...videoStyle, opacity: audioOnly ? 0 : 1 },
346
+ style: {
347
+ ...videoStyle,
348
+ ...(drawerResize ? { width: '64%', height: '64%', top: '18%', right: 'auto', bottom: 'auto' } : {}),
349
+ opacity: audioOnly ? 0 : 1,
350
+ },
308
351
  onClick: inlinePreview ? onPromotePreview : undefined,
309
352
  onError: (event) => {
353
+ if (!directVideoSource) return;
310
354
  const mediaError = event.currentTarget?.error;
311
355
  handleError({ message: mediaError?.message || 'The browser could not load this stream. Check URL, codec and CORS support.', code: mediaError?.code, cause: mediaError });
312
356
  },
@@ -346,7 +390,9 @@ function isAudioPlaybackEnabled(muted, videoOnly) {
346
390
  }
347
391
 
348
392
  function getPanelIntegration(activePanel, integrations) {
349
- return activePanel === 'chat' ? integrations.liveChat : integrations.epg;
393
+ if (activePanel === 'chat') return integrations.liveChat;
394
+ if (activePanel === 'epg') return integrations.epg;
395
+ return null;
350
396
  }
351
397
 
352
398
  function getActiveMediaUrl(youtubeVideoId, streamUrl) {
@@ -359,15 +405,11 @@ function getInlinePlayerStyle(inlinePreview, rect) {
359
405
  }
360
406
 
361
407
  function buildUnlockedControls({
362
- control, actions, onBack, isLive, restart, toggleLock, hasRecording,
408
+ control, onBack, isLive, restart, toggleLock, toggleMute, muted, hasRecording,
363
409
  recording, integrations, action, videoRef, streamUrl, title, activePanel,
364
- hasChat, hasEpg, handleOpenPanel, handleMinimize, setRecording, backVisible, minimizeVisible,
410
+ hasChat, hasEpg, hasDiagnostics, handleOpenPanel, setRecording, backVisible,
365
411
  }) {
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
- ];
412
+ const leftControls = [control('back', 'Back', onBack, { defaultVisible: backVisible })];
371
413
  let recordingControl = null;
372
414
  if (hasRecording) {
373
415
  const recordingLabel = recording ? 'Stop recording' : 'Start recording';
@@ -382,19 +424,39 @@ function buildUnlockedControls({
382
424
  }
383
425
  const chatLabel = activePanel === 'chat' ? 'Close live chat' : 'Live chat';
384
426
  const epgLabel = activePanel === 'epg' ? 'Close programme guide' : 'Programme guide';
385
- return [
386
- ...controls,
427
+ const rightControls = [
387
428
  recordingControl,
388
429
  control('liveChat', chatLabel, () => handleOpenPanel('chat'), { active: activePanel === 'chat', defaultVisible: hasChat }),
389
430
  control('epg', epgLabel, () => handleOpenPanel('epg'), { active: activePanel === 'epg', defaultVisible: hasEpg }),
390
- control('minimize', 'Minimize player', handleMinimize, { defaultVisible: minimizeVisible }),
431
+ ...(!isLive ? [control('restart', 'Restart', restart)] : []),
391
432
  ];
433
+ const muteLabel = muted ? 'Unmute' : 'Mute';
434
+ rightControls.push(
435
+ control('mute', muteLabel, toggleMute, { icon: muted ? 'mute' : 'unmute' }),
436
+ control('diagnostics', 'Stream diagnostics', () => handleOpenPanel('diagnostics'), { active: activePanel === 'diagnostics', defaultVisible: hasDiagnostics }),
437
+ control('lock', 'Lock controls', toggleLock, { active: false, defaultVisible: true }),
438
+ );
439
+ return { left: leftControls, right: rightControls };
440
+ }
441
+
442
+ function getDrawerLabel(activePanel) {
443
+ if (activePanel === 'chat') return 'Live chat drawer';
444
+ if (activePanel === 'epg') return 'Programme guide drawer';
445
+ return 'Stream diagnostics drawer';
446
+ }
447
+
448
+ function getPlaybackStatus(error, buffering, paused) {
449
+ if (error) return 'Error';
450
+ if (buffering) return 'Buffering';
451
+ return paused ? 'Paused' : 'Playing';
392
452
  }
393
453
 
394
454
  function WebPlayerLayout(props) {
395
- const controlLayer = !props.error && !props.audioOnly
396
- ? h(WebPlayerControls, {
455
+ let controlLayer = null;
456
+ if (!props.error && !props.audioOnly) {
457
+ controlLayer = h(WebPlayerControls, {
397
458
  locked: props.locked,
459
+ buffering: props.buffering,
398
460
  overrides: props.controlOverrides,
399
461
  theme: props.theme,
400
462
  icons: props.icons,
@@ -403,24 +465,32 @@ function WebPlayerLayout(props) {
403
465
  paused: props.isPaused,
404
466
  togglePlay: props.togglePlay,
405
467
  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 } },
468
+ });
469
+ }
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
+ let loadingNotice = null;
475
+ if (props.buffering && !props.error) {
476
+ loadingNotice = h('div', { className: 'cinecrew-player__status', style: { color: props.theme.controlColor } },
413
477
  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;
478
+ }
479
+ let audioCard = null;
480
+ if (props.audioOnly) {
481
+ audioCard = h(WebAudioOnlyCard, { poster: props.poster, title: props.title, theme: props.theme, onSwitchToVideo: props.onSwitchToVideo });
482
+ }
483
+ let panelNode = null;
484
+ if (props.activePanel && props.webPanel) {
485
+ panelNode = h('aside', {
486
+ className: `cinecrew-player__panel${props.drawerMode === 'resize' ? ' is-resizing' : ''}`,
487
+ style: { color: props.theme.controlColor, ...props.drawerStyle },
488
+ 'aria-label': getDrawerLabel(props.activePanel),
489
+ }, props.webPanel);
490
+ }
421
491
  return h('div', {
422
492
  ref: props.playerRef,
423
- className: `cinecrew-player${props.inlinePreview ? ' cinecrew-player--inline-preview' : ''} ${props.className}`.trim(),
493
+ className: `cinecrew-player${props.inlinePreview ? ' cinecrew-player--inline-preview' : ''}${props.drawerMode === 'resize' && props.activePanel ? ' cinecrew-player--drawer-resize' : ''} ${props.className}`.trim(),
424
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 },
425
495
  onWheel: props.onWheel,
426
496
  'data-stream-mode': props.streamMode,
@@ -473,7 +543,13 @@ function getPanelRenderer(activePanel, renderLiveChat, renderEpg, integrations)
473
543
  return null;
474
544
  }
475
545
 
476
- function createWebPanelNode({ renderer, integration, activePanel, integrations, source, title, theme, onClose }) {
546
+ function createWebPanelNode({
547
+ renderer, integration, activePanel, integrations, source, title, theme, onClose,
548
+ diagnostics, messagePageSize,
549
+ }) {
550
+ if (activePanel === 'diagnostics') {
551
+ return h(WebDiagnosticsPanel, { title, theme, ...diagnostics });
552
+ }
477
553
  if (renderer) return h(renderer, { title, source, onClose });
478
554
  if (!integration) return null;
479
555
  const hasLoader = activePanel === 'chat'
@@ -488,20 +564,195 @@ function createWebPanelNode({ renderer, integration, activePanel, integrations,
488
564
  title,
489
565
  theme,
490
566
  onClose,
567
+ messagePageSize,
491
568
  });
492
569
  }
493
570
 
494
- function WebIntegrationPanel({ kind, integration, integrations, source, title, theme, onClose }) {
571
+ function WebDiagnosticsPanel({ title, theme, streamMode, status, currentTime, duration, videoRef }) {
572
+ const video = videoRef?.current;
573
+ const resolution = video?.videoWidth && video?.videoHeight
574
+ ? `${video.videoWidth} × ${video.videoHeight}`
575
+ : 'Not available';
576
+ const rows = [
577
+ ['Title', title || 'Untitled'],
578
+ ['Stream type', streamMode || 'Unknown'],
579
+ ['Playback', status || 'Unknown'],
580
+ ['Resolution', resolution],
581
+ ['Position', `${formatTime(currentTime)} / ${formatTime(duration)}`],
582
+ ];
583
+ return h('section', {
584
+ className: 'cinecrew-player__diagnostics',
585
+ style: { color: theme.controlColor },
586
+ 'aria-label': 'Stream diagnostics',
587
+ },
588
+ h('strong', null, 'Stream diagnostics'),
589
+ rows.map(([label, value]) => h('div', { className: 'cinecrew-player__diagnostic-row', key: label },
590
+ h('span', null, label), h('span', null, value))));
591
+ }
592
+
593
+ function getChatMessageKey(message, index = 0) {
594
+ return String(message?.id ?? message?.messageId ?? message?.createdAt ?? message?.timestamp
595
+ ?? `${message?.username || message?.userName || ''}:${message?.comment || message?.message || message?.text || index}`);
596
+ }
597
+
598
+ function mergeChatMessages(existing, incoming, prepend = false) {
599
+ const messages = prepend ? [...incoming, ...existing] : [...existing, ...incoming];
600
+ const unique = new Map();
601
+ messages.forEach((message, index) => unique.set(getChatMessageKey(message, index), message));
602
+ return [...unique.values()];
603
+ }
604
+
605
+ function formatChatTimestamp(value) {
606
+ if (value === null || value === undefined || value === '') return '';
607
+ const date = new Date(value);
608
+ return Number.isNaN(date.getTime())
609
+ ? String(value)
610
+ : date.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
611
+ }
612
+
613
+ const EMOJI_BATCH_SIZE = 96;
614
+
615
+ function WebEmojiPicker({ onSelect }) {
616
+ const [emojiGroup, setEmojiGroup] = useState(EMOJI_GROUPS[0].name);
617
+ const [emojiQuery, setEmojiQuery] = useState('');
618
+ const [visibleCount, setVisibleCount] = useState(EMOJI_BATCH_SIZE);
619
+ const listRef = useRef(null);
620
+ const activeGroup = EMOJI_GROUPS.find((group) => group.name === emojiGroup) || EMOJI_GROUPS[0];
621
+ const filteredEmojis = useMemo(
622
+ () => emojiQuery.trim() ? searchEmojis(emojiQuery) : activeGroup.items,
623
+ [activeGroup, emojiQuery],
624
+ );
625
+ const visibleEmojis = filteredEmojis.slice(0, visibleCount);
626
+
627
+ useEffect(() => {
628
+ setVisibleCount(EMOJI_BATCH_SIZE);
629
+ if (listRef.current) listRef.current.scrollTop = 0;
630
+ }, [emojiGroup, emojiQuery]);
631
+
632
+ const loadMore = () => setVisibleCount((count) => Math.min(count + EMOJI_BATCH_SIZE, filteredEmojis.length));
633
+ const handleScroll = (event) => {
634
+ const element = event.currentTarget;
635
+ if (element.scrollTop + element.clientHeight >= element.scrollHeight - 32) loadMore();
636
+ };
637
+
638
+ return h('div', { className: 'cinecrew-player__emoji-picker', role: 'dialog', 'aria-label': 'Choose an emoji' },
639
+ h('input', {
640
+ className: 'cinecrew-player__emoji-search',
641
+ type: 'search',
642
+ value: emojiQuery,
643
+ onChange: (event) => setEmojiQuery(event.target.value),
644
+ placeholder: 'Search all emojis',
645
+ 'aria-label': 'Search all emojis',
646
+ }),
647
+ h('nav', { className: 'cinecrew-player__emoji-categories', 'aria-label': 'Emoji categories' },
648
+ EMOJI_GROUPS.map((group) => h('button', {
649
+ key: group.name,
650
+ type: 'button',
651
+ className: !emojiQuery.trim() && group.name === emojiGroup ? 'is-active' : '',
652
+ onClick: () => {
653
+ setEmojiGroup(group.name);
654
+ setEmojiQuery('');
655
+ },
656
+ 'aria-label': group.name,
657
+ title: group.name,
658
+ 'aria-pressed': !emojiQuery.trim() && group.name === emojiGroup,
659
+ }, group.icon))),
660
+ h('div', {
661
+ className: 'cinecrew-player__emoji-grid',
662
+ role: 'grid',
663
+ ref: listRef,
664
+ onScroll: handleScroll,
665
+ },
666
+ visibleEmojis.map((item) => h('button', {
667
+ key: item.codepoints,
668
+ type: 'button',
669
+ onClick: () => onSelect(item.emoji),
670
+ 'aria-label': `Insert ${item.short_name}`,
671
+ title: item.short_name,
672
+ }, item.emoji)),
673
+ visibleCount < filteredEmojis.length ? h('button', {
674
+ type: 'button',
675
+ className: 'cinecrew-player__emoji-load-more',
676
+ onClick: loadMore,
677
+ }, 'Load more emojis') : null));
678
+ }
679
+
680
+ function WebChatComposer({ sending, canSend, onSend }) {
681
+ const [message, setMessage] = useState('');
682
+ const [emojiOpen, setEmojiOpen] = useState(false);
683
+ const handleSubmit = async (event) => {
684
+ event.preventDefault();
685
+ const comment = message.trim();
686
+ if (!comment || sending || !canSend) return;
687
+ const sent = await onSend(comment);
688
+ if (sent) setMessage('');
689
+ };
690
+ const insertEmoji = (emoji) => setMessage((current) => `${current}${emoji}`);
691
+
692
+ return h('form', { className: 'cinecrew-player__chat-form', onSubmit: handleSubmit },
693
+ h('div', { className: 'cinecrew-player__chat-composer' },
694
+ h('button', {
695
+ type: 'button',
696
+ className: 'cinecrew-player__emoji-toggle',
697
+ onClick: () => setEmojiOpen((open) => !open),
698
+ 'aria-label': emojiOpen ? 'Close emoji picker' : 'Open emoji picker',
699
+ }, '☺'),
700
+ h('input', {
701
+ value: message,
702
+ onChange: (event) => setMessage(event.target.value),
703
+ placeholder: 'Add a message…',
704
+ 'aria-label': 'Chat message',
705
+ maxLength: 1000,
706
+ }),
707
+ h('button', { type: 'submit', disabled: sending || !message.trim() || !canSend }, sending ? 'Sending…' : 'Send')),
708
+ emojiOpen ? h(WebEmojiPicker, { onSelect: insertEmoji }) : null);
709
+ }
710
+
711
+ function WebPanelRow({ row, index, isChat }) {
712
+ if (isChat) {
713
+ const timestamp = row.createdAt ?? row.timestamp ?? row.sentAt ?? row.time;
714
+ return h('article', { key: row.id ?? row.messageId ?? index, className: 'cinecrew-player__chat-message' },
715
+ h('header', null,
716
+ h('strong', null, row.username || row.userName || row.name || 'Viewer'),
717
+ timestamp ? h('time', { dateTime: String(timestamp) }, formatChatTimestamp(timestamp)) : null),
718
+ h('span', null, row.comment || row.message || row.text || ''));
719
+ }
720
+ const start = row.startMs ?? row.start ?? row.startTime;
721
+ const end = row.endMs ?? row.end ?? row.endTime;
722
+ return h('article', { key: row.id ?? row.startMs ?? index, className: 'cinecrew-player__epg-item' },
723
+ h('small', null, [formatListingTime(start), formatListingTime(end)].filter(Boolean).join(' – ')),
724
+ h('strong', null, row.title || row.name || 'Programme'),
725
+ row.description ? h('span', null, row.description) : null);
726
+ }
727
+
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
+ function WebIntegrationPanel({ kind, integration, integrations, source, title, theme, onClose, messagePageSize = 50 }) {
495
738
  const [rows, setRows] = useState([]);
496
739
  const [user, setUser] = useState(integrations.user || null);
497
- const [message, setMessage] = useState('');
498
740
  const [loading, setLoading] = useState(true);
741
+ const [loadingOlder, setLoadingOlder] = useState(false);
742
+ const [hasMoreMessages, setHasMoreMessages] = useState(false);
743
+ const [messageOffset, setMessageOffset] = useState(0);
499
744
  const [sending, setSending] = useState(false);
500
745
  const [error, setError] = useState('');
746
+ const rowsRef = useRef([]);
501
747
  const channelId = String(source.streamId || source.mediaId || source.id || '');
502
748
  const isChat = kind === 'chat';
749
+ const pageSize = Math.max(1, Math.floor(Number(messagePageSize) || 50));
503
750
  const emptyStateMessage = isChat ? 'No messages yet.' : 'No programme information available.';
504
- const sendButtonLabel = sending ? 'Sending…' : 'Send';
751
+
752
+ const updateRows = (nextRows) => {
753
+ rowsRef.current = nextRows;
754
+ setRows(nextRows);
755
+ };
505
756
 
506
757
  const load = useCallback(async () => {
507
758
  if (!channelId) {
@@ -512,15 +763,30 @@ function WebIntegrationPanel({ kind, integration, integrations, source, title, t
512
763
  try {
513
764
  setError('');
514
765
  const value = isChat
515
- ? await integration.loadMessages({ channelId, limit: 50 })
766
+ ? await integration.loadMessages({ channelId, limit: pageSize, offset: 0 })
516
767
  : await integration.loadListings({ channelId, limit: integration.limit || 48 });
517
- setRows(responseRows(value, isChat ? ['messages', 'items', 'comments'] : ['listings', 'programmes', 'epg', 'items']));
768
+ const nextRows = responseRows(value, isChat ? ['messages', 'items', 'comments'] : ['listings', 'programmes', 'epg', 'items']);
769
+ if (isChat) {
770
+ const previousCount = rowsRef.current.length;
771
+ const mergedRows = mergeChatMessages(rowsRef.current, nextRows);
772
+ updateRows(mergedRows);
773
+ const addedCount = Math.max(0, mergedRows.length - previousCount);
774
+ setMessageOffset((offset) => previousCount === 0
775
+ ? nextRows.length
776
+ : Math.max(offset, nextRows.length) + addedCount);
777
+ if (previousCount === 0) {
778
+ const explicitHasMore = value?.hasMore ?? value?.pagination?.hasMore;
779
+ setHasMoreMessages(explicitHasMore === undefined ? nextRows.length >= pageSize : Boolean(explicitHasMore));
780
+ }
781
+ } else {
782
+ setRows(nextRows);
783
+ }
518
784
  } catch (loadError) {
519
785
  setError(loadError?.message || `Could not load ${isChat ? 'live chat' : 'the programme guide'}.`);
520
786
  } finally {
521
787
  setLoading(false);
522
788
  }
523
- }, [channelId, integration, isChat]);
789
+ }, [channelId, integration, isChat, pageSize]);
524
790
 
525
791
  useEffect(() => {
526
792
  let cancelled = false;
@@ -534,16 +800,36 @@ function WebIntegrationPanel({ kind, integration, integrations, source, title, t
534
800
 
535
801
  useEffect(() => {
536
802
  setLoading(true);
803
+ rowsRef.current = [];
804
+ setRows([]);
805
+ setMessageOffset(0);
806
+ setHasMoreMessages(false);
537
807
  load();
538
808
  if (!isChat) return undefined;
539
809
  const timer = setInterval(load, Math.max(1000, Number(integration.pollIntervalMs) || 5000));
540
810
  return () => clearInterval(timer);
541
811
  }, [load, isChat, integration.pollIntervalMs]);
542
812
 
543
- const send = async (event) => {
544
- event.preventDefault();
545
- const comment = message.trim();
546
- if (!comment || sending || !integration.sendMessage) return;
813
+ const loadOlderMessages = async () => {
814
+ if (!isChat || !hasMoreMessages || loadingOlder) return;
815
+ setLoadingOlder(true);
816
+ try {
817
+ setError('');
818
+ const value = await integration.loadMessages({ channelId, limit: pageSize, offset: messageOffset });
819
+ const olderRows = responseRows(value, ['messages', 'items', 'comments']);
820
+ updateRows(mergeChatMessages(rowsRef.current, olderRows, true));
821
+ setMessageOffset((offset) => offset + olderRows.length);
822
+ const explicitHasMore = value?.hasMore ?? value?.pagination?.hasMore;
823
+ setHasMoreMessages(explicitHasMore === undefined ? olderRows.length >= pageSize : Boolean(explicitHasMore));
824
+ } catch (loadError) {
825
+ setError(loadError?.message || 'Could not load more messages.');
826
+ } finally {
827
+ setLoadingOlder(false);
828
+ }
829
+ };
830
+
831
+ const send = async (comment) => {
832
+ if (!comment || sending || !integration.sendMessage) return false;
547
833
  setSending(true);
548
834
  try {
549
835
  await integration.sendMessage({
@@ -552,10 +838,11 @@ function WebIntegrationPanel({ kind, integration, integrations, source, title, t
552
838
  username: user?.username || 'Viewer',
553
839
  comment,
554
840
  });
555
- setMessage('');
556
841
  await load();
842
+ return true;
557
843
  } catch (sendError) {
558
844
  setError(sendError?.message || 'Could not send your message.');
845
+ return false;
559
846
  } finally {
560
847
  setSending(false);
561
848
  }
@@ -572,29 +859,14 @@ function WebIntegrationPanel({ kind, integration, integrations, source, title, t
572
859
  loading ? h('div', { className: 'cinecrew-player__panel-state' }, 'Loading…') : null,
573
860
  error ? h('div', { className: 'cinecrew-player__panel-state is-error', role: 'status' }, error) : null,
574
861
  !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);
862
+ h('div', { className: 'cinecrew-player__panel-list' },
863
+ isChat && hasMoreMessages ? h(WebLoadMoreMessages, { loading: loadingOlder, onClick: loadOlderMessages }) : null,
864
+ rows.map((row, index) => h(WebPanelRow, { key: getChatMessageKey(row, index), row, index, isChat }))),
865
+ isChat ? h(WebChatComposer, {
866
+ sending,
867
+ canSend: typeof integration.sendMessage === 'function',
868
+ onSend: send,
869
+ }) : null);
598
870
  }
599
871
 
600
872
  /**
@@ -614,6 +886,10 @@ export const CineCrewPlayer = forwardRef(function CineCrewPlayer(props, ref) {
614
886
  playbackRate: playbackRateProp = 1,
615
887
  paused: pausedProp,
616
888
  controls: controlOverrides = {},
889
+ features = {},
890
+ drawerMode = 'overlay',
891
+ drawerStyle,
892
+ messagePageSize = 50,
617
893
  actions = {},
618
894
  theme: themeProp = {},
619
895
  icons = {},
@@ -634,7 +910,6 @@ export const CineCrewPlayer = forwardRef(function CineCrewPlayer(props, ref) {
634
910
  onPromotePreview,
635
911
  initialShowLiveChat = false,
636
912
  liveChatNonce = 0,
637
- onMinimize,
638
913
  onBack,
639
914
  mediaId,
640
915
  onFullscreen,
@@ -758,6 +1033,7 @@ export const CineCrewPlayer = forwardRef(function CineCrewPlayer(props, ref) {
758
1033
  pausedRef: pausedStateRef,
759
1034
  onErrorRef,
760
1035
  });
1036
+ const streamMode = getStreamMode(youtubeVideoId, mpegTs.useMpegTs, useHls);
761
1037
  useWebAc3AudioPlayback({
762
1038
  active: mpegTs.useAc3Fallback,
763
1039
  streamUrl,
@@ -772,7 +1048,9 @@ export const CineCrewPlayer = forwardRef(function CineCrewPlayer(props, ref) {
772
1048
  if (!video) return;
773
1049
  if (isPaused) video.pause();
774
1050
  else video.play().catch((playError) => {
775
- if (playError?.name !== 'NotAllowedError') handleError(playError);
1051
+ if (playError?.name !== 'NotAllowedError' && playError?.name !== 'AbortError') {
1052
+ handleError(playError);
1053
+ }
776
1054
  });
777
1055
  }, [isPaused, streamUrl, youtubeVideoId, handleError]);
778
1056
 
@@ -826,7 +1104,6 @@ export const CineCrewPlayer = forwardRef(function CineCrewPlayer(props, ref) {
826
1104
  else youtubeRef.current?.seekTo?.(Math.max(0, Number(seconds) || 0));
827
1105
  }, { seconds: Number(seconds) || 0 }), [action]);
828
1106
  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
1107
  useImperativeHandle(ref, () => {
831
1108
  const api = {
832
1109
  play: () => setPaused(false),
@@ -847,7 +1124,6 @@ export const CineCrewPlayer = forwardRef(function CineCrewPlayer(props, ref) {
847
1124
  seekTo,
848
1125
  seekBy: (delta) => seekTo((Number(videoRef.current?.currentTime) || currentTime) + (Number(delta) || 0)),
849
1126
  back: handleBack,
850
- minimize: handleMinimize,
851
1127
  getVideoElement: () => videoRef.current,
852
1128
  enterFullscreen: () => playerRef.current?.requestFullscreen?.(),
853
1129
  exitFullscreen: () => typeof document !== 'undefined' ? document.exitFullscreen?.() : undefined,
@@ -855,11 +1131,12 @@ export const CineCrewPlayer = forwardRef(function CineCrewPlayer(props, ref) {
855
1131
  };
856
1132
  publicPlayerRef.current = api;
857
1133
  return api;
858
- }, [setPaused, togglePlay, restart, toggleMute, setMuted, setAspectRatio, setAudioOnlyMode, setVideoOnlyMode, setPlaybackRateAction, seekTo, handleBack, handleMinimize, availableTracks, tracksProp, currentTime]);
1134
+ }, [setPaused, togglePlay, restart, toggleMute, setMuted, setAspectRatio, setAudioOnlyMode, setVideoOnlyMode, setPlaybackRateAction, seekTo, handleBack, availableTracks, tracksProp, currentTime]);
859
1135
 
860
1136
  const hasChat = typeof integrations.liveChat?.loadMessages === 'function' || typeof renderLiveChat === 'function' || typeof integrations.liveChat?.render === 'function' || typeof actions.onLiveChatOpen === 'function';
861
1137
  const hasEpg = typeof integrations.epg?.loadListings === 'function' || typeof renderEpg === 'function' || typeof integrations.epg?.render === 'function' || typeof actions.onEpgOpen === 'function';
862
1138
  const hasRecording = !!integrations.recording || typeof actions.onRecordingStart === 'function';
1139
+ const hasDiagnostics = Boolean(features.diagnostics) || typeof actions.onDiagnosticsOpen === 'function';
863
1140
  const sourceType = String(media.type || media.mimeType || '').toLowerCase();
864
1141
  const directVideoSource = getDirectVideoSource({
865
1142
  mpegTs: mpegTs.useMpegTs,
@@ -948,10 +1225,11 @@ export const CineCrewPlayer = forwardRef(function CineCrewPlayer(props, ref) {
948
1225
  actions,
949
1226
  onBack: handleBack,
950
1227
  backVisible: typeof actions.onBack === 'function' || typeof onBack === 'function' || typeof props.onClose === 'function',
951
- minimizeVisible: typeof actions.onMinimize === 'function' || typeof onMinimize === 'function',
952
1228
  isLive,
953
1229
  restart,
954
1230
  toggleLock,
1231
+ toggleMute,
1232
+ muted,
955
1233
  hasRecording,
956
1234
  recording,
957
1235
  integrations,
@@ -962,8 +1240,8 @@ export const CineCrewPlayer = forwardRef(function CineCrewPlayer(props, ref) {
962
1240
  activePanel,
963
1241
  hasChat,
964
1242
  hasEpg,
1243
+ hasDiagnostics,
965
1244
  handleOpenPanel: openPanel,
966
- handleMinimize,
967
1245
  setRecording,
968
1246
  });
969
1247
  const webPanel = createWebPanelNode({
@@ -975,6 +1253,14 @@ export const CineCrewPlayer = forwardRef(function CineCrewPlayer(props, ref) {
975
1253
  title,
976
1254
  theme,
977
1255
  onClose: () => setActivePanel(null),
1256
+ diagnostics: {
1257
+ streamMode,
1258
+ status: getPlaybackStatus(error, buffering, isPaused),
1259
+ currentTime,
1260
+ duration,
1261
+ videoRef,
1262
+ },
1263
+ messagePageSize,
978
1264
  });
979
1265
 
980
1266
  const handleYouTubeProgress = (event) => {
@@ -996,6 +1282,7 @@ export const CineCrewPlayer = forwardRef(function CineCrewPlayer(props, ref) {
996
1282
  playbackRate,
997
1283
  videoOnly,
998
1284
  videoStyle,
1285
+ drawerResize: Boolean(activePanel) && drawerMode === 'resize',
999
1286
  audioOnly,
1000
1287
  inlinePreview,
1001
1288
  onPromotePreview,
@@ -1008,8 +1295,6 @@ export const CineCrewPlayer = forwardRef(function CineCrewPlayer(props, ref) {
1008
1295
  onError: handleError,
1009
1296
  handleError,
1010
1297
  });
1011
- const streamMode = getStreamMode(youtubeVideoId, mpegTs.useMpegTs, useHls);
1012
-
1013
1298
  const bottomControlProps = {
1014
1299
  isLive,
1015
1300
  overrides: controlOverrides,
@@ -1063,6 +1348,8 @@ export const CineCrewPlayer = forwardRef(function CineCrewPlayer(props, ref) {
1063
1348
  togglePlay,
1064
1349
  bottomControlProps,
1065
1350
  onWheel: (event) => onInlinePreviewWheel?.(event.deltaY),
1351
+ drawerMode,
1352
+ drawerStyle,
1066
1353
  });
1067
1354
  });
1068
1355
 
@@ -1111,7 +1398,7 @@ export const InlineLivePlayer = React.memo(function InlineLivePlayer({
1111
1398
  controls: {
1112
1399
  back: false, restart: false, lock: false, recording: false, liveChat: false, epg: false,
1113
1400
  seek: false, aspectRatio: false, videoOnly: false, audioOnly: false, audioTracks: false,
1114
- playbackRate: false, minimize: false, playPause: true, mute: true, fullscreen: true,
1401
+ playbackRate: false, playPause: true, mute: true, fullscreen: true,
1115
1402
  ...controls,
1116
1403
  },
1117
1404
  actions: {