@cinecrew/cinecrew-player 0.1.2 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cinecrew/cinecrew-player",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "type": "module",
5
5
  "description": "Customizable cross-platform live and on-demand video player for React and React Native.",
6
6
  "license": "MIT",
@@ -117,7 +117,7 @@ function InlinePlayerOverlay({
117
117
  palette,
118
118
  });
119
119
  const muteLabel = muted ? 'Unmute' : 'Mute';
120
- const muteIcon = muted ? 'volume-off' : 'volume-high';
120
+ const muteIcon = muted ? 'mute' : 'unmute';
121
121
  const playbackLabel = paused ? 'Play' : 'Pause';
122
122
  const playbackIcon = paused ? 'play' : 'pause';
123
123
 
@@ -360,7 +360,7 @@ function InlinePreviewTopActions({ controls, muted, videoOnlyMode, handleMuteAct
360
360
  handleMuteAction();
361
361
  }}
362
362
  >
363
- <PlayerIcon name={muted || videoOnlyMode ? 'volume-off' : 'volume-high'} size={19} color="#FFF" />
363
+ <PlayerIcon name={muted || videoOnlyMode ? 'mute' : 'unmute'} size={19} color="#FFF" />
364
364
  </TouchableOpacity>
365
365
  ) : null}
366
366
  </View>
@@ -40,5 +40,6 @@ export function PlayerIcon({ name, pack = 'community', size = 20, color = '#FFFF
40
40
  return React.createElement(Text, { style: [{ fontSize: size, color }, style], accessibilityElementsHidden: true }, custom);
41
41
  }
42
42
  const IconSet = pack === 'material' ? MaterialIcons : MaterialCommunityIcons;
43
- return React.createElement(IconSet, { name: typeof custom === 'string' ? custom : name, size, color, style });
43
+ const defaultIconNames = { mute: 'volume-off', unmute: 'volume-high' };
44
+ return React.createElement(IconSet, { name: typeof custom === 'string' ? custom : (defaultIconNames[name] || name), size, color, style });
44
45
  }
@@ -53,7 +53,7 @@ function AudioOnlyModeControl({ isAudioOnly, onToggle }) {
53
53
  function VideoOnlyControl({ isVideoOnly, onToggle }) {
54
54
  const palette = usePlayerColors();
55
55
  const selectedStyle = isVideoOnly ? { backgroundColor: palette.surfaceColor, borderWidth: 1.5, borderColor: palette.accentColor } : null;
56
- return <TouchableOpacity style={[styles.speedButton, { backgroundColor: palette.controlBackground }, selectedStyle]} onPress={(event) => { event.stopPropagation(); onToggle?.(); }} accessibilityLabel={isVideoOnly ? 'Enable audio' : 'Video only'}><PlayerIcon name="video" size={18} color={isVideoOnly ? palette.accentColor : palette.controlColor} /></TouchableOpacity>;
56
+ return <TouchableOpacity style={[styles.speedButton, { backgroundColor: palette.controlBackground }, selectedStyle]} onPress={(event) => { event.stopPropagation(); onToggle?.(); }} accessibilityLabel={isVideoOnly ? 'Disable video-only mode' : 'Video only'}><PlayerIcon name="video" size={18} color={isVideoOnly ? palette.accentColor : palette.controlColor} /></TouchableOpacity>;
57
57
  }
58
58
 
59
59
  function SpeedControl({ enabled, open, playbackRate, onToggle, onSelect }) {
@@ -81,7 +81,7 @@ function SpeedControl({ enabled, open, playbackRate, onToggle, onSelect }) {
81
81
 
82
82
  function AudioTrackMenu({ open, audioTracks, selectedAudioTrack, onSelect, palette }) {
83
83
  if (!open) return null;
84
- if (audioTracks.length <= 1) return <View style={[styles.speedPickerPopup, { backgroundColor: palette.surfaceColor, borderColor: palette.borderColor }]}><Text style={[styles.speedOptionText, { color: palette.controlColor }]}>No other audio available</Text></View>;
84
+ if (audioTracks.length === 0) return <View style={[styles.speedPickerPopup, { backgroundColor: palette.surfaceColor, borderColor: palette.borderColor }]}><Text style={[styles.speedOptionText, { color: palette.controlColor }]}>No audio tracks available</Text></View>;
85
85
  return (
86
86
  <View style={[styles.speedPickerPopup, { backgroundColor: palette.surfaceColor, borderColor: palette.borderColor }]}>
87
87
  <Text style={[styles.speedPickerTitle, { color: palette.mutedColor }]}>Audio</Text>
@@ -60,7 +60,7 @@ function PlaybackSessionControls({ isLive, controls, muted, onRestart, onMute, o
60
60
  return (
61
61
  <>
62
62
  {!isLive && controls.restart !== false ? <TouchableOpacity style={styles.pill} onPress={(event) => { event.stopPropagation(); onRestart(); }} hitSlop={12}><PlayerIcon name="restart" size={20} color={palette.controlColor} /></TouchableOpacity> : null}
63
- {controls.mute !== false ? <TouchableOpacity style={styles.pill} onPress={(event) => { event.stopPropagation(); onMute(); }} hitSlop={12}><PlayerIcon name={muted ? 'volume-off' : 'volume-high'} size={20} color={muted ? palette.errorColor : palette.controlColor} /></TouchableOpacity> : null}
63
+ {controls.mute !== false ? <TouchableOpacity style={styles.pill} onPress={(event) => { event.stopPropagation(); onMute(); }} hitSlop={12}><PlayerIcon name={muted ? 'mute' : 'unmute'} size={20} color={muted ? palette.errorColor : palette.controlColor} /></TouchableOpacity> : null}
64
64
  {controls.lock !== false ? <TouchableOpacity style={styles.pill} onPress={(event) => { event.stopPropagation(); onLock(); }} hitSlop={12}><PlayerIcon name="lock-open-variant" size={20} color={palette.controlColor} /></TouchableOpacity> : null}
65
65
  </>
66
66
  );
@@ -36,7 +36,17 @@ export function useWebVideoAspectRatio(videoAspectRatio, audioOnly) {
36
36
  width: useCustomRatio ? 'auto' : '100%',
37
37
  height: useCustomRatio ? 'auto' : '100%',
38
38
  ...(useCustomRatio
39
- ? { maxWidth: '100%', maxHeight: '100%', aspectRatio: aspectRatioVal }
39
+ ? {
40
+ position: 'absolute',
41
+ left: '50%',
42
+ top: '50%',
43
+ right: 'auto',
44
+ bottom: 'auto',
45
+ transform: 'translate(-50%, -50%)',
46
+ maxWidth: '100%',
47
+ maxHeight: '100%',
48
+ aspectRatio: aspectRatioVal,
49
+ }
40
50
  : {}),
41
51
  objectFit: useCustomRatio ? 'contain' : aspectFit,
42
52
  backgroundColor: '#000',
package/src/web/index.js CHANGED
@@ -21,7 +21,7 @@ const DEFAULT_THEME = {
21
21
  const DEFAULT_ICONS = {
22
22
  play: 'play', pause: 'pause', restart: 'restart', lock: 'lock', unlock: 'lock-open',
23
23
  mute: 'volume-mute', unmute: 'volume-high', aspectRatio: 'aspect-ratio', videoOnly: 'video',
24
- audio: 'music-note', back: 'arrow-left', recording: 'record-rec', stop: 'stop',
24
+ audio: 'music-note', audioOnly: 'headphones', back: 'arrow-left', recording: 'record-rec', stop: 'stop',
25
25
  liveChat: 'comment-text-multiple-outline', epg: 'television-classic', diagnostics: 'logs',
26
26
  fullscreen: 'fullscreen', close: 'close',
27
27
  };
@@ -34,11 +34,12 @@ const WEB_ICON_PATHS = {
34
34
  restart: 'M12 5V1L7 6l5 5V7a6 6 0 1 1-5.65 8H4.26A9 9 0 1 0 12 5z',
35
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',
36
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',
37
- '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',
38
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',
39
39
  'aspect-ratio': 'M3 5h18v14H3zm2 2v10h14V7zm2 2h4v2H9v4H7zm10 6h-4v-2h2V9h2z',
40
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',
41
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',
42
43
  'arrow-collapse': 'M4 4h6v2H6v4H4zm10 0h6v6h-2V6h-4zM4 14h2v4h4v2H4zm14 0h2v6h-6v-2h4z',
43
44
  logs: 'M3 3h18v18H3zm2 2v14h14V5zm3 3h2v2H8zm4 0h7v2h-7zm-4 4h2v2H8zm4 0h7v2h-7zm-4 4h2v2H8zm4 0h7v2h-7z',
44
45
  'arrow-left': 'M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20z',
@@ -48,6 +49,13 @@ const WEB_ICON_PATHS = {
48
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',
49
50
  fullscreen: 'M4 4h6v2H6v4H4zm10 0h6v6h-2V6h-4zM4 14h2v4h4v2H4zm14 0h2v6h-6v-2h4z',
50
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',
51
59
  };
52
60
 
53
61
  function getSource(source, url) {
@@ -119,44 +127,62 @@ function getPanelActionName(panel) {
119
127
  function WebSeekControl({ currentTime, duration, theme, onSeek }) {
120
128
  const [seekDraft, setSeekDraft] = useState(currentTime);
121
129
  const [isDragging, setIsDragging] = useState(false);
130
+ const seekDraftRef = useRef(currentTime);
122
131
 
123
132
  useEffect(() => {
124
- if (!isDragging) setSeekDraft(currentTime);
133
+ if (!isDragging) {
134
+ seekDraftRef.current = currentTime;
135
+ setSeekDraft(currentTime);
136
+ }
125
137
  }, [currentTime, isDragging]);
126
138
 
127
- const updateSeek = (event) => {
139
+ const updateDraft = (event) => {
128
140
  const nextTime = Number(event.currentTarget.value);
141
+ seekDraftRef.current = nextTime;
129
142
  setSeekDraft(nextTime);
130
- onSeek(nextTime);
131
143
  };
132
144
 
133
145
  const finishSeek = (event) => {
134
146
  if (!isDragging) return;
135
- updateSeek(event);
147
+ const nextTime = event?.currentTarget && event.type !== 'blur'
148
+ ? Number(event.currentTarget.value)
149
+ : seekDraftRef.current;
150
+ seekDraftRef.current = nextTime;
151
+ setSeekDraft(nextTime);
136
152
  setIsDragging(false);
153
+ onSeek(nextTime);
137
154
  };
155
+ const displayTime = Math.min(isDragging ? seekDraft : currentTime, duration);
156
+ const remainingTime = Math.max(0, duration - displayTime);
138
157
 
139
158
  return h('div', { className: 'cinecrew-player__seek' },
140
- h('span', null, formatTime(currentTime)),
159
+ h('span', null, formatTime(displayTime)),
141
160
  h('input', {
142
161
  type: 'range',
143
162
  'aria-label': 'Seek video',
144
163
  min: 0,
145
164
  max: duration,
146
165
  step: 'any',
147
- value: Math.min(isDragging ? seekDraft : currentTime, duration),
148
- onChange: updateSeek,
166
+ value: displayTime,
167
+ onChange: updateDraft,
149
168
  onPointerDown: (event) => {
150
169
  event.stopPropagation();
151
170
  setIsDragging(true);
152
- setSeekDraft(Number(event.currentTarget.value));
171
+ seekDraftRef.current = Number(event.currentTarget.value);
172
+ setSeekDraft(seekDraftRef.current);
153
173
  },
154
174
  onPointerUp: finishSeek,
155
175
  onPointerCancel: finishSeek,
156
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
+ },
157
183
  style: { accentColor: theme.accentColor },
158
184
  }),
159
- h('span', null, formatTime(duration)));
185
+ h('span', null, `−${formatTime(remainingTime)}`));
160
186
  }
161
187
 
162
188
  function WebAspectRatioMenu({ open, theme, icons, onToggle, onSelect }) {
@@ -179,11 +205,13 @@ function WebAudioTrackMenu({ open, tracks, theme, icons, onToggle, onSelect }) {
179
205
  let menu = null;
180
206
  if (open) {
181
207
  menu = h('div', { className: 'cinecrew-player__menu', style: { background: theme.surfaceColor } },
182
- tracks.map((track) => h('button', {
183
- key: track.id,
184
- type: 'button',
185
- onClick: () => onSelect(track.id),
186
- }, 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'));
187
215
  }
188
216
  return h('div', { className: 'cinecrew-player__menu-wrap', key: 'audioTracks' },
189
217
  h(PlayerButton, { name: 'audio', label: 'Audio tracks', icons, theme, onClick: onToggle, active: open }),
@@ -226,7 +254,7 @@ function WebBottomControls(props) {
226
254
  });
227
255
  }
228
256
  let audioTracks = null;
229
- if (availableTracks.length > 1 && isControlEnabled(overrides, 'audioTracks', true)) {
257
+ if (isControlEnabled(overrides, 'audioTracks', true)) {
230
258
  audioTracks = h(WebAudioTrackMenu, {
231
259
  open: showAudioMenu,
232
260
  tracks: availableTracks,
@@ -243,25 +271,25 @@ function WebBottomControls(props) {
243
271
  if (isControlEnabled(overrides, 'playbackRate', true) && !isLive) {
244
272
  playbackRateControl = h(WebPlaybackRateControl, { value: playbackRate, onChange: setPlaybackRateAction });
245
273
  }
246
- const videoOnlyLabel = videoOnly ? 'Enable audio' : 'Video only';
247
- const audioOnlyLabel = audioOnly ? 'Switch to audio card' : 'Audio only';
274
+ const videoOnlyLabel = 'Video only';
275
+ const audioOnlyLabel = 'Audio only';
248
276
  const fullscreenLabel = fullscreen ? 'Exit full screen' : 'Full screen';
249
277
 
250
278
  return h('div', { className: 'cinecrew-player__bottom-controls' },
251
279
  seek,
252
280
  h('div', { className: 'cinecrew-player__bottom-actions' },
253
281
  h('div', { className: 'cinecrew-player__bottom-left-actions' },
282
+ renderControlButton({ name: 'audioOnly', label: audioOnlyLabel, callback: () => setAudioOnlyMode(true), options: { active: audioOnly }, overrides, icons, theme }),
254
283
  renderControlButton({ name: 'videoOnly', label: videoOnlyLabel, callback: () => setVideoOnlyMode(!videoOnly), options: { active: videoOnly, defaultVisible: false }, overrides, icons, theme }),
255
284
  aspect),
256
285
  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
286
  audioTracks,
259
287
  playbackRateControl,
260
288
  renderControlButton({ name: 'fullscreen', label: fullscreenLabel, callback: toggleFullscreen, options: { icon: 'fullscreen' }, overrides, icons, theme }))));
261
289
  }
262
290
 
263
291
  function WebPlayerControls({ locked, buffering, overrides, theme, icons, unlockedControls, toggleLock, paused, togglePlay, bottomProps }) {
264
- let leftControls = unlockedControls.left;
292
+ let leftControls = locked ? null : unlockedControls.left;
265
293
  let rightControls = unlockedControls.right;
266
294
  if (locked) {
267
295
  rightControls = renderControlButton({
@@ -330,10 +358,31 @@ function WebPlayerSurface({
330
358
  onStateChange,
331
359
  onError,
332
360
  onEnded,
333
- style: drawerResize ? { width: '64%', height: '64%', inset: '18% auto auto 0' } : undefined,
361
+ style: drawerResize ? { width: 'var(--cinecrew-media-width, 64%)', height: '100%', inset: '0 auto 0 0' } : undefined,
334
362
  });
335
363
  }
336
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
+ }
337
386
  return h('video', {
338
387
  ref: videoRef,
339
388
  className: 'cinecrew-player__video',
@@ -345,7 +394,7 @@ function WebPlayerSurface({
345
394
  preload: 'auto',
346
395
  style: {
347
396
  ...videoStyle,
348
- ...(drawerResize ? { width: '64%', height: '64%', top: '18%', right: 'auto', bottom: 'auto' } : {}),
397
+ ...resizedVideoStyle,
349
398
  opacity: audioOnly ? 0 : 1,
350
399
  },
351
400
  onClick: inlinePreview ? onPromotePreview : undefined,
@@ -357,12 +406,15 @@ function WebPlayerSurface({
357
406
  });
358
407
  }
359
408
 
360
- function WebAudioOnlyCard({ poster, title, theme, onSwitchToVideo }) {
409
+ function WebAudioOnlyCard({ poster, title, theme, icons, onSwitchToVideo }) {
361
410
  return h('div', {
362
411
  className: 'cinecrew-player__audio-card',
363
412
  style: { background: theme.surfaceColor, color: theme.controlColor, borderColor: theme.accentColor },
364
413
  },
365
- 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 })),
366
418
  h('span', { className: 'cinecrew-player__audio-wave', style: { color: theme.accentColor }, 'aria-hidden': true }, '•••••••'),
367
419
  h('strong', null, title || 'Audio only'),
368
420
  h('button', {
@@ -478,7 +530,7 @@ function WebPlayerLayout(props) {
478
530
  }
479
531
  let audioCard = null;
480
532
  if (props.audioOnly) {
481
- audioCard = h(WebAudioOnlyCard, { poster: props.poster, title: props.title, theme: props.theme, onSwitchToVideo: props.onSwitchToVideo });
533
+ audioCard = h(WebAudioOnlyCard, { poster: props.poster, title: props.title, theme: props.theme, icons: props.icons, onSwitchToVideo: props.onSwitchToVideo });
482
534
  }
483
535
  let panelNode = null;
484
536
  if (props.activePanel && props.webPanel) {
@@ -491,7 +543,7 @@ function WebPlayerLayout(props) {
491
543
  return h('div', {
492
544
  ref: props.playerRef,
493
545
  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 },
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%' },
495
547
  onWheel: props.onWheel,
496
548
  'data-stream-mode': props.streamMode,
497
549
  },
@@ -499,7 +551,7 @@ function WebPlayerLayout(props) {
499
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))' } }),
500
552
  playerTitle,
501
553
  loadingNotice,
502
- 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 }),
503
555
  controlLayer,
504
556
  audioCard,
505
557
  panelNode);
@@ -544,11 +596,11 @@ function getPanelRenderer(activePanel, renderLiveChat, renderEpg, integrations)
544
596
  }
545
597
 
546
598
  function createWebPanelNode({
547
- renderer, integration, activePanel, integrations, source, title, theme, onClose,
599
+ renderer, integration, activePanel, integrations, source, title, theme, icons, onClose,
548
600
  diagnostics, messagePageSize,
549
601
  }) {
550
602
  if (activePanel === 'diagnostics') {
551
- return h(WebDiagnosticsPanel, { title, theme, ...diagnostics });
603
+ return h(WebDiagnosticsPanel, { title, theme, icons, onClose, ...diagnostics });
552
604
  }
553
605
  if (renderer) return h(renderer, { title, source, onClose });
554
606
  if (!integration) return null;
@@ -568,26 +620,73 @@ function createWebPanelNode({
568
620
  });
569
621
  }
570
622
 
571
- function WebDiagnosticsPanel({ title, theme, streamMode, status, currentTime, duration, videoRef }) {
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 }) {
572
647
  const video = videoRef?.current;
573
648
  const resolution = video?.videoWidth && video?.videoHeight
574
649
  ? `${video.videoWidth} × ${video.videoHeight}`
575
650
  : '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)}`],
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' },
582
663
  ];
583
664
  return h('section', {
584
665
  className: 'cinecrew-player__diagnostics',
585
666
  style: { color: theme.controlColor },
586
667
  'aria-label': 'Stream diagnostics',
587
668
  },
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))));
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.'));
591
690
  }
592
691
 
593
692
  function getChatMessageKey(message, index = 0) {
@@ -704,7 +803,12 @@ function WebChatComposer({ sending, canSend, onSend }) {
704
803
  'aria-label': 'Chat message',
705
804
  maxLength: 1000,
706
805
  }),
707
- h('button', { type: 'submit', disabled: sending || !message.trim() || !canSend }, sending ? 'Sending…' : 'Send')),
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' }))),
708
812
  emojiOpen ? h(WebEmojiPicker, { onSelect: insertEmoji }) : null);
709
813
  }
710
814
 
@@ -1252,6 +1356,7 @@ export const CineCrewPlayer = forwardRef(function CineCrewPlayer(props, ref) {
1252
1356
  source: panelSource,
1253
1357
  title,
1254
1358
  theme,
1359
+ icons,
1255
1360
  onClose: () => setActivePanel(null),
1256
1361
  diagnostics: {
1257
1362
  streamMode,
@@ -30,7 +30,7 @@
30
30
 
31
31
  .cinecrew-player__video,
32
32
  .cinecrew-player__youtube {
33
- transition: width 180ms ease, height 180ms ease, top 180ms ease;
33
+ transition: width 180ms ease, height 180ms ease, top 180ms ease, left 180ms ease, transform 180ms ease;
34
34
  }
35
35
 
36
36
  .cinecrew-player__shade {
@@ -131,15 +131,22 @@
131
131
  .cinecrew-player__seek {
132
132
  flex: 0 0 auto;
133
133
  width: 100%;
134
+ align-items: center;
134
135
  font-variant-numeric: tabular-nums;
135
136
  text-shadow: 0 1px 3px #000;
136
137
  }
137
138
 
139
+ .cinecrew-player__seek > span {
140
+ min-width: 3.4em;
141
+ }
142
+
143
+ .cinecrew-player__seek > span:last-child { text-align: right; }
144
+
138
145
  .cinecrew-player__seek input {
139
146
  flex: 1;
140
147
  min-width: 50px;
141
148
  min-height: 24px;
142
- touch-action: auto;
149
+ touch-action: pan-y;
143
150
  cursor: ew-resize;
144
151
  }
145
152
 
@@ -193,8 +200,9 @@
193
200
 
194
201
  .cinecrew-player__audio-card {
195
202
  position: absolute;
196
- inset: 10%;
203
+ inset: 0;
197
204
  z-index: 5;
205
+ box-sizing: border-box;
198
206
  display: flex;
199
207
  flex-direction: column;
200
208
  align-items: center;
@@ -202,7 +210,7 @@
202
210
  gap: 18px;
203
211
  padding: 24px;
204
212
  border: 1px solid;
205
- border-radius: 20px;
213
+ border-radius: inherit;
206
214
  text-align: center;
207
215
  box-shadow: 0 18px 60px rgba(0, 0, 0, .32);
208
216
  }
@@ -214,6 +222,23 @@
214
222
  border-radius: 50%;
215
223
  }
216
224
 
225
+ .cinecrew-player__audio-placeholder {
226
+ display: grid;
227
+ width: min(30vw, 160px);
228
+ aspect-ratio: 1;
229
+ place-items: center;
230
+ border: 2px solid var(--cinecrew-accent, #00d4ff);
231
+ border-radius: 50%;
232
+ background: radial-gradient(circle at 35% 30%, rgba(0, 212, 255, .28), transparent 58%), linear-gradient(145deg, #142b49, #081321 72%);
233
+ box-shadow: 0 0 28px rgba(0, 212, 255, .2), inset 0 0 22px rgba(0, 212, 255, .12);
234
+ }
235
+
236
+ .cinecrew-player__audio-placeholder .cinecrew-player__icon {
237
+ width: 42px;
238
+ height: 42px;
239
+ font-size: 42px;
240
+ }
241
+
217
242
  .cinecrew-player__audio-wave {
218
243
  letter-spacing: .28em;
219
244
  font-size: 1.25rem;
@@ -302,36 +327,120 @@
302
327
  font: inherit;
303
328
  }
304
329
 
330
+ .cinecrew-player__menu-empty {
331
+ padding: 10px 13px;
332
+ color: var(--cinecrew-text, #fff);
333
+ white-space: nowrap;
334
+ }
335
+
305
336
  .cinecrew-player__menu-wrap--aspect .cinecrew-player__menu button {
306
337
  padding-block: 6px;
307
338
  }
308
339
 
309
340
  .cinecrew-player__diagnostics {
310
341
  display: flex;
342
+ box-sizing: border-box;
343
+ height: 100%;
311
344
  flex-direction: column;
312
- gap: 10px;
313
- min-width: 220px;
345
+ gap: 14px;
346
+ min-width: 0;
347
+ overflow: auto;
314
348
  }
315
349
 
316
- .cinecrew-player__diagnostics > strong {
317
- padding-bottom: 8px;
350
+ .cinecrew-player__diagnostics-heading {
351
+ display: flex;
352
+ align-items: flex-start;
353
+ justify-content: space-between;
354
+ gap: 12px;
355
+ padding-bottom: 12px;
318
356
  border-bottom: 1px solid rgba(255, 255, 255, .14);
319
357
  }
320
358
 
321
- .cinecrew-player__diagnostic-row {
359
+ .cinecrew-player__diagnostics-heading > div {
322
360
  display: flex;
323
- justify-content: space-between;
324
- gap: 16px;
361
+ min-width: 0;
362
+ flex-direction: column;
363
+ gap: 3px;
364
+ }
365
+
366
+ .cinecrew-player__diagnostics-heading strong { font-size: 1.05rem; }
367
+ .cinecrew-player__diagnostics-heading > div > span { opacity: .7; overflow-wrap: anywhere; }
368
+
369
+ .cinecrew-player__diagnostics-close {
370
+ display: grid;
371
+ flex: 0 0 36px;
372
+ width: 36px;
373
+ height: 36px;
374
+ place-items: center;
375
+ border: 1px solid rgba(255,255,255,.2);
376
+ border-radius: 50%;
377
+ color: inherit;
378
+ background: rgba(255,255,255,.1);
379
+ cursor: pointer;
380
+ }
381
+
382
+ .cinecrew-player__diagnostics-status {
383
+ display: flex;
384
+ align-items: center;
385
+ gap: 8px;
386
+ align-self: flex-start;
387
+ padding: 6px 10px;
388
+ border: 1px solid rgba(255,255,255,.15);
389
+ border-radius: 999px;
390
+ background: rgba(255,255,255,.07);
391
+ font-size: .86em;
392
+ }
393
+
394
+ .cinecrew-player__status-dot {
395
+ width: 8px;
396
+ height: 8px;
397
+ border-radius: 50%;
398
+ background: #9ba9bb;
399
+ box-shadow: 0 0 10px currentColor;
400
+ }
401
+
402
+ .cinecrew-player__status-dot.is-playing { color: #42d392; background: currentColor; }
403
+ .cinecrew-player__status-dot.is-buffering { color: #ffca58; background: currentColor; }
404
+ .cinecrew-player__status-dot.is-error { color: #ff647c; background: currentColor; }
405
+
406
+ .cinecrew-player__diagnostic-grid {
407
+ display: grid;
408
+ grid-template-columns: repeat(2, minmax(0, 1fr));
409
+ gap: 9px;
410
+ }
411
+
412
+ .cinecrew-player__diagnostic-card {
413
+ display: grid;
414
+ min-width: 0;
415
+ grid-template-columns: 32px minmax(0, 1fr);
416
+ grid-template-rows: auto auto;
417
+ align-items: center;
418
+ column-gap: 9px;
419
+ row-gap: 3px;
420
+ padding: 10px;
421
+ border: 1px solid rgba(255,255,255,.14);
422
+ border-radius: 12px;
423
+ background: rgba(255,255,255,.08);
325
424
  font-variant-numeric: tabular-nums;
326
425
  }
327
426
 
328
- .cinecrew-player__diagnostic-row span:first-child {
329
- opacity: .72;
427
+ .cinecrew-player__diagnostic-icon {
428
+ display: grid;
429
+ width: 30px;
430
+ height: 30px;
431
+ grid-row: span 2;
432
+ place-items: center;
433
+ border-radius: 9px;
434
+ background: rgba(255,255,255,.08);
330
435
  }
331
436
 
332
- .cinecrew-player__diagnostic-row span:last-child {
333
- text-align: right;
334
- overflow-wrap: anywhere;
437
+ .cinecrew-player__diagnostic-icon .cinecrew-player__icon { width: 18px; height: 18px; }
438
+ .cinecrew-player__diagnostic-label { overflow: hidden; opacity: .7; font-size: .76em; text-overflow: ellipsis; white-space: nowrap; }
439
+ .cinecrew-player__diagnostic-card strong { overflow-wrap: anywhere; font-size: .9em; }
440
+ .cinecrew-player__diagnostics-note { opacity: .65; line-height: 1.35; }
441
+
442
+ @media (max-width: 420px) {
443
+ .cinecrew-player__diagnostic-grid { grid-template-columns: minmax(0, 1fr); }
335
444
  }
336
445
 
337
446
  .cinecrew-player__panel-content {
@@ -420,17 +529,18 @@
420
529
  position: relative;
421
530
  display: flex;
422
531
  align-items: center;
423
- gap: 8px;
424
- padding: 6px;
425
- border: 1px solid rgba(255, 255, 255, .2);
426
- border-radius: 999px;
427
- background: rgba(0, 0, 0, .2);
532
+ gap: 7px;
533
+ padding: 5px 6px 5px 10px;
534
+ border: 1px solid rgba(255, 255, 255, .22);
535
+ border-radius: 14px;
536
+ background: rgba(12, 20, 32, .72);
537
+ box-shadow: 0 3px 12px rgba(0,0,0,.12);
428
538
  transition: border-color .16s ease, box-shadow .16s ease, background-color .16s ease;
429
539
  }
430
540
 
431
541
  .cinecrew-player__chat-composer:focus-within {
432
542
  border-color: var(--cinecrew-accent);
433
- background: rgba(0, 0, 0, .28);
543
+ background: rgba(12, 20, 32, .88);
434
544
  box-shadow: 0 0 0 3px color-mix(in srgb, var(--cinecrew-accent) 18%, transparent);
435
545
  }
436
546
 
@@ -439,7 +549,7 @@
439
549
  min-width: 0;
440
550
  border: 0;
441
551
  outline: 0;
442
- padding: 8px 10px;
552
+ padding: 10px 4px;
443
553
  color: inherit;
444
554
  background: transparent;
445
555
  font: inherit;
@@ -468,15 +578,19 @@
468
578
  }
469
579
 
470
580
  .cinecrew-player__chat-composer button[type="submit"] {
471
- min-width: auto;
472
- padding: 0 14px;
473
- border-radius: 999px;
581
+ width: 40px;
582
+ min-width: 40px;
583
+ height: 40px;
584
+ min-height: 40px;
585
+ padding: 0;
586
+ border-radius: 12px;
474
587
  color: #07111e;
475
588
  background: var(--cinecrew-accent);
476
- font-weight: 700;
477
589
  box-shadow: 0 2px 8px color-mix(in srgb, var(--cinecrew-accent) 24%, transparent);
478
590
  }
479
591
 
592
+ .cinecrew-player__chat-composer button[type="submit"] .cinecrew-player__icon { width: 19px; height: 19px; }
593
+
480
594
  .cinecrew-player__chat-composer button[type="submit"]:hover:not(:disabled) {
481
595
  filter: brightness(1.08);
482
596
  }
@@ -649,15 +763,31 @@
649
763
  padding: 16px;
650
764
  border-left: 1px solid rgba(255,255,255,.2);
651
765
  border-radius: 0;
652
- background: rgba(13, 26, 44, .78);
653
- backdrop-filter: blur(18px);
766
+ background: rgba(13, 26, 44, .58);
767
+ backdrop-filter: blur(10px);
654
768
  box-shadow: -12px 0 34px rgba(0,0,0,.34);
655
769
  animation: cinecrew-drawer-in 180ms ease-out both;
656
770
  }
657
771
 
658
- .cinecrew-player--drawer-resize .cinecrew-player__panel { width: min(420px, 36%); }
772
+ .cinecrew-player--drawer-resize .cinecrew-player__panel {
773
+ width: calc(100% - var(--cinecrew-media-width, 64%));
774
+ max-width: none;
775
+ padding: 14px;
776
+ }
777
+
778
+ .cinecrew-player__panel.is-resizing { background: rgba(13, 26, 44, .82); }
659
779
 
660
- .cinecrew-player__panel.is-resizing { background: rgba(13, 26, 44, .88); }
780
+ .cinecrew-player--drawer-resize .cinecrew-player__shade,
781
+ .cinecrew-player--drawer-resize .cinecrew-player__controls,
782
+ .cinecrew-player--drawer-resize .cinecrew-player__status,
783
+ .cinecrew-player--drawer-resize .cinecrew-player__error {
784
+ right: auto;
785
+ width: var(--cinecrew-media-width, 64%);
786
+ }
787
+
788
+ .cinecrew-player--drawer-resize .cinecrew-player__title {
789
+ right: calc(100% - var(--cinecrew-media-width, 64%));
790
+ }
661
791
 
662
792
  .cinecrew-player__panel-close {
663
793
  position: absolute;
@@ -699,4 +829,6 @@
699
829
  .cinecrew-player__bottom-actions { align-items: flex-end; gap: 6px; }
700
830
  .cinecrew-player__bottom-right-actions { max-width: 100%; flex-wrap: wrap; justify-content: flex-end; gap: 4px; }
701
831
  .cinecrew-player__panel { width: min(380px, 92%); }
832
+ .cinecrew-player--drawer-resize { --cinecrew-media-width: 52% !important; }
833
+ .cinecrew-player--drawer-resize .cinecrew-player__panel { width: calc(100% - var(--cinecrew-media-width)); padding: 10px; }
702
834
  }