@cinecrew/cinecrew-player 0.1.3 → 0.1.5

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.
@@ -28,8 +28,7 @@
28
28
  background: #000;
29
29
  }
30
30
 
31
- .cinecrew-player__video,
32
- .cinecrew-player__youtube {
31
+ .cinecrew-player__video {
33
32
  transition: width 180ms ease, height 180ms ease, top 180ms ease, left 180ms ease, transform 180ms ease;
34
33
  }
35
34
 
@@ -40,10 +39,9 @@
40
39
  }
41
40
 
42
41
  .cinecrew-player__title {
43
- position: absolute;
44
- top: 62px;
45
- left: 20px;
46
- right: 20px;
42
+ flex: 1 1 auto;
43
+ min-width: 0;
44
+ text-align: center;
47
45
  z-index: 2;
48
46
  overflow: hidden;
49
47
  font-weight: 700;
@@ -52,6 +50,18 @@
52
50
  text-shadow: 0 1px 4px #000;
53
51
  }
54
52
 
53
+ .cinecrew-player__inline-title {
54
+ flex: 0 1 auto;
55
+ min-width: 0;
56
+ max-width: min(52vw, 420px);
57
+ overflow: hidden;
58
+ color: var(--cinecrew-text, #fff);
59
+ font-weight: 700;
60
+ text-overflow: ellipsis;
61
+ white-space: nowrap;
62
+ text-shadow: 0 1px 4px #000;
63
+ }
64
+
55
65
  .cinecrew-player__controls {
56
66
  position: absolute;
57
67
  inset: 0;
@@ -70,6 +80,14 @@
70
80
  opacity: 1;
71
81
  }
72
82
 
83
+ .cinecrew-player__controls.is-recording { opacity: 1; }
84
+ .cinecrew-player__controls.is-recording > .cinecrew-player__top-controls,
85
+ .cinecrew-player__controls.is-recording > .cinecrew-player__center-controls,
86
+ .cinecrew-player__controls.is-recording > .cinecrew-player__bottom-controls {
87
+ visibility: hidden;
88
+ pointer-events: none;
89
+ }
90
+
73
91
  .cinecrew-player__top-controls,
74
92
  .cinecrew-player__bottom-controls,
75
93
  .cinecrew-player__center-controls,
@@ -103,8 +121,11 @@
103
121
 
104
122
  .cinecrew-player__top-right-actions {
105
123
  justify-content: flex-end;
124
+ flex: 0 0 auto;
106
125
  }
107
126
 
127
+ .cinecrew-player__top-left-actions { flex: 0 0 auto; }
128
+
108
129
  .cinecrew-player__bottom-controls {
109
130
  width: 100%;
110
131
  flex-direction: column;
@@ -128,6 +149,57 @@
128
149
  justify-content: center;
129
150
  }
130
151
 
152
+ .cinecrew-player__recording-overlay {
153
+ position: absolute;
154
+ top: 10px;
155
+ left: 50%;
156
+ z-index: 5;
157
+ display: flex;
158
+ min-width: 0;
159
+ max-width: calc(100% - 24px);
160
+ box-sizing: border-box;
161
+ align-items: center;
162
+ justify-content: center;
163
+ gap: 8px;
164
+ flex-wrap: wrap;
165
+ padding: 7px 10px;
166
+ border: 1px solid rgba(255,255,255,.22);
167
+ border-radius: 12px;
168
+ background: rgba(0, 0, 0, .68);
169
+ box-shadow: 0 6px 18px rgba(0,0,0,.28);
170
+ transform: translateX(-50%);
171
+ backdrop-filter: blur(8px);
172
+ font-size: .88em;
173
+ }
174
+
175
+ .cinecrew-player__recording-overlay.is-error { border-color: var(--cinecrew-accent); }
176
+ .cinecrew-player__recording-indicator { color: #ff4b63; font-size: .78em; font-weight: 800; letter-spacing: .08em; }
177
+ .cinecrew-player__recording-timer { font-variant-numeric: tabular-nums; font-size: 1.05em; }
178
+ .cinecrew-player__recording-actions { display: flex; gap: 6px; }
179
+ .cinecrew-player__recording-actions button,
180
+ .cinecrew-player__recording-overlay > button {
181
+ border: 1px solid rgba(255,255,255,.2);
182
+ border-radius: 999px;
183
+ padding: 6px 9px;
184
+ color: inherit;
185
+ background: rgba(255,255,255,.1);
186
+ cursor: pointer;
187
+ font: inherit;
188
+ }
189
+ .cinecrew-player__recording-download {
190
+ display: inline-flex;
191
+ align-items: center;
192
+ border: 1px solid rgba(255,255,255,.2);
193
+ border-radius: 999px;
194
+ padding: 6px 9px;
195
+ color: inherit;
196
+ background: rgba(255,255,255,.1);
197
+ text-decoration: none;
198
+ font: inherit;
199
+ }
200
+ .cinecrew-player__recording-actions button:last-child { border-color: #ff4b63; background: rgba(255,75,99,.2); }
201
+ .cinecrew-player__recording-ready { color: #70e3b1; }
202
+
131
203
  .cinecrew-player__seek {
132
204
  flex: 0 0 auto;
133
205
  width: 100%;
@@ -431,7 +503,8 @@
431
503
  grid-row: span 2;
432
504
  place-items: center;
433
505
  border-radius: 9px;
434
- background: rgba(255,255,255,.08);
506
+ border: 1px solid color-mix(in srgb, var(--diagnostic-accent, #fff) 32%, transparent);
507
+ background: color-mix(in srgb, var(--diagnostic-accent, #fff) 14%, transparent);
435
508
  }
436
509
 
437
510
  .cinecrew-player__diagnostic-icon .cinecrew-player__icon { width: 18px; height: 18px; }
@@ -479,6 +552,16 @@
479
552
  gap: 8px;
480
553
  min-height: 0;
481
554
  overflow: auto;
555
+ overscroll-behavior: contain;
556
+ scrollbar-width: thin;
557
+ }
558
+
559
+ .cinecrew-player__older-loading {
560
+ flex: 0 0 auto;
561
+ align-self: center;
562
+ padding: 6px 10px;
563
+ color: var(--cinecrew-accent);
564
+ font-size: .86em;
482
565
  }
483
566
 
484
567
  .cinecrew-player__chat-message,
@@ -527,6 +610,7 @@
527
610
 
528
611
  .cinecrew-player__chat-composer {
529
612
  position: relative;
613
+ container-type: inline-size;
530
614
  display: flex;
531
615
  align-items: center;
532
616
  gap: 7px;
@@ -602,8 +686,9 @@
602
686
 
603
687
  .cinecrew-player__emoji-picker {
604
688
  position: absolute;
605
- right: 0;
606
- bottom: calc(100% + 12px);
689
+ left: 0;
690
+ right: auto;
691
+ bottom: 100%;
607
692
  z-index: 2;
608
693
  display: flex;
609
694
  flex-direction: column;
@@ -617,7 +702,31 @@
617
702
  box-shadow: 0 10px 30px rgba(0, 0, 0, .42);
618
703
  }
619
704
 
620
- .cinecrew-player__chat-composer .cinecrew-player__emoji-search {
705
+ @supports (width: 1cqw) {
706
+ .cinecrew-player__emoji-picker { width: min(340px, 100cqw); }
707
+ }
708
+
709
+ .cinecrew-player__emoji-anchor {
710
+ position: relative;
711
+ display: inline-flex;
712
+ flex: 0 0 34px;
713
+ width: 34px;
714
+ height: 34px;
715
+ align-self: center;
716
+ align-items: center;
717
+ }
718
+
719
+ .cinecrew-player__emoji-anchor > .cinecrew-player__emoji-toggle {
720
+ box-sizing: border-box;
721
+ width: 34px;
722
+ min-width: 34px;
723
+ height: 34px;
724
+ min-height: 34px;
725
+ padding: 0;
726
+ line-height: 1;
727
+ }
728
+
729
+ .cinecrew-player__emoji-picker .cinecrew-player__emoji-search {
621
730
  flex: 0 0 auto;
622
731
  width: 100%;
623
732
  box-sizing: border-box;
@@ -631,7 +740,7 @@
631
740
  font-size: 13px;
632
741
  }
633
742
 
634
- .cinecrew-player__chat-composer .cinecrew-player__emoji-search:focus {
743
+ .cinecrew-player__emoji-picker .cinecrew-player__emoji-search:focus {
635
744
  border-color: var(--cinecrew-accent);
636
745
  }
637
746
 
@@ -652,7 +761,7 @@
652
761
  font: inherit;
653
762
  }
654
763
 
655
- .cinecrew-player__chat-composer .cinecrew-player__emoji-categories button {
764
+ .cinecrew-player__emoji-picker .cinecrew-player__emoji-categories button {
656
765
  display: grid;
657
766
  min-width: 0;
658
767
  min-height: 34px;
@@ -664,8 +773,8 @@
664
773
  font-size: 19px;
665
774
  }
666
775
 
667
- .cinecrew-player__chat-composer .cinecrew-player__emoji-categories button:hover,
668
- .cinecrew-player__chat-composer .cinecrew-player__emoji-categories button.is-active {
776
+ .cinecrew-player__emoji-picker .cinecrew-player__emoji-categories button:hover,
777
+ .cinecrew-player__emoji-picker .cinecrew-player__emoji-categories button.is-active {
669
778
  border-color: var(--cinecrew-accent);
670
779
  background: color-mix(in srgb, var(--cinecrew-accent) 14%, transparent);
671
780
  }
@@ -682,24 +791,27 @@
682
791
  scrollbar-width: thin;
683
792
  }
684
793
 
685
- .cinecrew-player__chat-composer .cinecrew-player__emoji-grid button {
794
+ .cinecrew-player__emoji-picker .cinecrew-player__emoji-grid button {
686
795
  display: grid;
687
796
  width: 100%;
688
797
  min-width: 0;
689
- min-height: 34px;
798
+ min-height: 38px;
690
799
  place-items: center;
691
800
  border: 0;
692
- border-radius: 8px;
801
+ border-radius: 50%;
693
802
  padding: 0;
694
803
  background: transparent;
695
- font-size: 19px;
804
+ font-size: 23px;
805
+ line-height: 1;
806
+ transition: background-color .12s ease, transform .12s ease;
696
807
  }
697
808
 
698
- .cinecrew-player__chat-composer .cinecrew-player__emoji-grid button:hover {
809
+ .cinecrew-player__emoji-picker .cinecrew-player__emoji-grid button:hover {
699
810
  background: rgba(255, 255, 255, .12);
811
+ transform: scale(1.12);
700
812
  }
701
813
 
702
- .cinecrew-player__chat-composer .cinecrew-player__emoji-grid .cinecrew-player__emoji-load-more {
814
+ .cinecrew-player__emoji-picker .cinecrew-player__emoji-grid .cinecrew-player__emoji-load-more {
703
815
  grid-column: 1 / -1;
704
816
  min-height: 34px;
705
817
  border: 1px solid rgba(255, 255, 255, .14);
@@ -752,6 +864,12 @@
752
864
  background: rgba(255,255,255,.1);
753
865
  }
754
866
 
867
+ .cinecrew-player__menu .cinecrew-player__menu-option.is-selected {
868
+ color: var(--cinecrew-accent);
869
+ background: color-mix(in srgb, var(--cinecrew-accent) 18%, transparent);
870
+ font-weight: 700;
871
+ }
872
+
755
873
  .cinecrew-player__panel {
756
874
  position: absolute;
757
875
  inset: 0 0 0 auto;
@@ -811,9 +929,11 @@
811
929
 
812
930
  @media (max-width: 640px) {
813
931
  .cinecrew-player__controls { padding: 10px; }
814
- .cinecrew-player__top-controls { align-items: flex-start; gap: 6px; }
815
- .cinecrew-player__top-left-actions { gap: 4px; }
932
+ .cinecrew-player__top-controls { align-items: center; gap: 6px; flex-wrap: wrap; }
933
+ .cinecrew-player__title { order: 3; flex: 0 0 100%; box-sizing: border-box; padding: 0 4px; text-align: center; }
934
+ .cinecrew-player__top-left-actions { order: 1; gap: 4px; }
816
935
  .cinecrew-player__top-right-actions {
936
+ order: 2;
817
937
  max-width: calc(100% - 128px);
818
938
  flex-wrap: wrap;
819
939
  gap: 4px;
@@ -825,7 +945,6 @@
825
945
  min-height: 34px;
826
946
  padding: 6px 8px;
827
947
  }
828
- .cinecrew-player__title { top: 96px; left: 12px; right: 12px; }
829
948
  .cinecrew-player__bottom-actions { align-items: flex-end; gap: 6px; }
830
949
  .cinecrew-player__bottom-right-actions { max-width: 100%; flex-wrap: wrap; justify-content: flex-end; gap: 4px; }
831
950
  .cinecrew-player__panel { width: min(380px, 92%); }
package/types/index.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import type * as React from 'react';
2
2
 
3
3
  export type AspectRatio = 'FIT' | 'FILL' | 'STRETCH' | 'FILL_SCREEN' | string;
4
+ export interface AspectRatioOption { value: AspectRatio; label?: string }
4
5
  export type PlayerMediaType = 'live' | 'channel' | 'movie' | 'series' | string;
5
6
 
6
7
  export interface PlayerSource {
@@ -57,6 +58,8 @@ export interface PlayerApi {
57
58
  seekBy(seconds: number): void;
58
59
  back(): void;
59
60
  setPlaybackRate(rate: number): void;
61
+ setPanel(panel: 'chat' | 'epg' | 'diagnostics' | null): void;
62
+ closePanel(): void;
60
63
  getVideoElement(): unknown | null;
61
64
  getAudioTracks(): AudioTrack[];
62
65
  enterFullscreen?(): void | Promise<void>;
@@ -77,9 +80,11 @@ export interface PlayerActionContext {
77
80
  video?: unknown | null;
78
81
  }
79
82
 
83
+ /** App callback for a player action. Back is app-owned and has no built-in behavior. */
80
84
  export type PlayerAction = (payload?: Record<string, unknown>, context?: PlayerActionContext) => unknown;
81
85
 
82
86
  export interface PlayerActions {
87
+ /** User-owned navigation event. The player does not close or navigate on its own. */
83
88
  onBack?: PlayerAction;
84
89
  onPlayPause?: PlayerAction;
85
90
  onSeek?: PlayerAction;
@@ -173,6 +178,7 @@ export interface PlayerIntegrations {
173
178
  render?: (context: { title: string; source: PlayerSource; onClose: () => void }) => React.ReactNode;
174
179
  };
175
180
  recording?: {
181
+ /** Starts host-managed recording for the active direct media source. */
176
182
  start?: (args: { getVideoElement: () => unknown | null; streamUrl: string; title?: string }) => Promise<unknown>;
177
183
  pause?: () => Promise<unknown>;
178
184
  resume?: () => Promise<unknown>;
@@ -205,13 +211,20 @@ export interface CineCrewPlayerProps {
205
211
  volume?: number;
206
212
  playbackRate?: number;
207
213
  controls?: PlayerControls;
214
+ /** Show the playback progress/seek bar. When false, onProgressBarChange is not called. Defaults to true. */
215
+ showProgressBar?: boolean;
208
216
  /** Web/Electron chat and EPG drawer behavior. Overlay keeps the video full-size; resize shrinks it to make room. */
209
217
  drawerMode?: PlayerDrawerMode;
210
218
  /** Web CSS or React Native view-style overrides for the chat, EPG, and diagnostics drawer. */
211
219
  drawerStyle?: React.CSSProperties | import('react-native').ViewStyle;
212
- /** Number of chat messages fetched per page; older pages load from the drawer's See more button. Defaults to 50. */
220
+ /** Number of chat messages fetched per page. Older pages load automatically when scrolling to the top. Defaults to 50. */
213
221
  messagePageSize?: number;
222
+ /** Available aspect-ratio choices. Items may be values or labeled { value, label } options. */
223
+ aspectRatios?: Array<AspectRatio | AspectRatioOption>;
224
+ /** Initial aspect-ratio choice. Defaults to FIT. */
225
+ defaultAspectRatio?: AspectRatio;
214
226
  features?: { diagnostics?: boolean; [key: string]: boolean | undefined };
227
+ /** App callbacks invoked after each corresponding built-in action. */
215
228
  actions?: PlayerActions;
216
229
  integrations?: PlayerIntegrations;
217
230
  theme?: PlayerTheme;
@@ -234,8 +247,11 @@ export interface CineCrewPlayerProps {
234
247
  playlist?: Array<Record<string, unknown>>;
235
248
  shuffle?: boolean;
236
249
  onClose?: () => void;
237
- onBack?: () => void;
250
+ /** User-owned back event. The player does not close or navigate on its own. */
251
+ onBack?: PlayerAction;
238
252
  onFullscreen?: (state: { isFullscreen: boolean }) => void;
253
+ /** Invoked after the player applies an aspect ratio selection; actions.onAspectRatioChange takes precedence when both are supplied. */
254
+ onAspectRatioChange?: PlayerAction;
239
255
  onPlayerHostRef?: (node: unknown | null) => void;
240
256
  onInlinePreviewWheel?: (deltaY: number) => void;
241
257
  inlinePreview?: boolean;
@@ -245,6 +261,8 @@ export interface CineCrewPlayerProps {
245
261
  liveChatNonce?: number;
246
262
  onReady?: (playerOrEvent: unknown) => void;
247
263
  onProgress?: (event: unknown) => void;
264
+ /** Called once for each elapsed playback second and after a completed seek/restart with a zero-padded HH:MM:SS position. */
265
+ onProgressBarChange?: (time: string) => void;
248
266
  onPlaying?: (event: unknown) => void;
249
267
  onBuffering?: (buffering: boolean) => void;
250
268
  onError?: (error: Error | Record<string, unknown>) => void;
@@ -266,6 +284,7 @@ export interface InlineLivePlayerProps {
266
284
  paused?: boolean;
267
285
  isActive?: boolean;
268
286
  onActivate?: () => void;
287
+ /** @deprecated Inline fullscreen is self-contained. Use actions.onFullscreen to observe its state. */
269
288
  onFullscreen?: () => void;
270
289
  controls?: Pick<PlayerControls, 'playPause' | 'mute' | 'fullscreen'>;
271
290
  actions?: Pick<PlayerActions, 'onPlayPause' | 'onMute' | 'onFullscreen'>;
@@ -1,120 +0,0 @@
1
- import React, { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef } from 'react';
2
- import { StyleSheet, View } from 'react-native';
3
- import { WebView } from 'react-native-webview';
4
- import { buildYouTubePlayerHtml } from '../../utils/youtubeHtml';
5
-
6
- const stateName = (value) => ({ 0: 'ended', 1: 'playing', 2: 'paused', 3: 'buffering', 5: 'cued' })[value] || 'unstarted';
7
-
8
- export const YouTubeVideoPlayer = forwardRef(function YouTubeVideoPlayer({
9
- videoId,
10
- paused = false,
11
- muted = false,
12
- volume = 1,
13
- playbackRate = 1,
14
- onReady,
15
- onProgress,
16
- onPlaying,
17
- onBuffering,
18
- onStateChange,
19
- onError,
20
- onEnded,
21
- }, ref) {
22
- const webViewRef = useRef(null);
23
- const handlersRef = useRef({});
24
- const latestPropsRef = useRef({});
25
- const readyRef = useRef(false);
26
- handlersRef.current = { onReady, onProgress, onPlaying, onBuffering, onStateChange, onError, onEnded };
27
- latestPropsRef.current = { paused, muted, volume, playbackRate };
28
-
29
- const sendCommand = useCallback((name, value) => {
30
- const serialized = JSON.stringify(value ?? null);
31
- webViewRef.current?.injectJavaScript(
32
- `window.cinecrewPlayerCommand && window.cinecrewPlayerCommand(${JSON.stringify(name)}, ${serialized}); true;`
33
- );
34
- }, []);
35
-
36
- const handleMessage = useCallback((event) => {
37
- let message;
38
- try { message = JSON.parse(event.nativeEvent.data); } catch { return; }
39
- const handlers = handlersRef.current;
40
- if (message.type === 'ready') {
41
- readyRef.current = true;
42
- handlers.onBuffering?.(false);
43
- handlers.onReady?.({ youtube: true });
44
- const latest = latestPropsRef.current;
45
- sendCommand(latest.paused ? 'pause' : 'play');
46
- sendCommand(latest.muted ? 'mute' : 'unmute');
47
- sendCommand('volume', Math.round(Math.max(0, Math.min(1, latest.volume)) * 100));
48
- sendCommand('rate', latest.playbackRate);
49
- return;
50
- }
51
- if (message.type === 'state') {
52
- const state = stateName(Number(message.data?.state));
53
- handlers.onStateChange?.(state);
54
- handlers.onBuffering?.(state === 'buffering');
55
- if (state === 'playing') handlers.onPlaying?.({ state, youtube: true });
56
- if (state === 'ended') handlers.onEnded?.();
57
- return;
58
- }
59
- if (message.type === 'progress') {
60
- const currentTime = Number(message.data?.currentTime) || 0;
61
- const duration = Number(message.data?.duration) || 0;
62
- handlers.onProgress?.({ currentTime: currentTime * 1000, duration: duration * 1000, target: currentTime });
63
- return;
64
- }
65
- if (message.type === 'error') {
66
- handlers.onError?.({ code: message.data?.code, message: `YouTube playback failed (${message.data?.code}).` });
67
- }
68
- }, [sendCommand]);
69
-
70
- useEffect(() => { if (readyRef.current) sendCommand(paused ? 'pause' : 'play'); }, [paused, sendCommand]);
71
- useEffect(() => {
72
- if (!readyRef.current) return;
73
- sendCommand(muted ? 'mute' : 'unmute');
74
- sendCommand('volume', Math.round(Math.max(0, Math.min(1, volume)) * 100));
75
- }, [muted, volume, sendCommand]);
76
- useEffect(() => { if (readyRef.current) sendCommand('rate', playbackRate); }, [playbackRate, sendCommand]);
77
-
78
- const validVideoId = /^[\w-]{11}$/.test(String(videoId || ''));
79
- const html = useMemo(
80
- () => validVideoId ? buildYouTubePlayerHtml(videoId, !paused) : '<!doctype html><html><body style="background:#000"></body></html>',
81
- [validVideoId, videoId],
82
- );
83
- const webViewSource = useMemo(() => ({ html, baseUrl: 'https://www.youtube.com' }), [html]);
84
-
85
- useEffect(() => {
86
- if (!validVideoId) handlersRef.current.onError?.(new Error('A valid YouTube video ID is required.'));
87
- }, [validVideoId]);
88
-
89
- useImperativeHandle(ref, () => ({
90
- play: () => sendCommand('play'),
91
- resume: () => sendCommand('play'),
92
- pause: () => sendCommand('pause'),
93
- seek: (ratio) => sendCommand('seekRatio', Math.max(0, Math.min(1, Number(ratio) || 0))),
94
- seekTo: (seconds) => sendCommand('seek', Math.max(0, Number(seconds) || 0)),
95
- getVideoElement: () => null,
96
- }), [sendCommand]);
97
-
98
- return React.createElement(View, { style: styles.container }, React.createElement(WebView, {
99
- ref: webViewRef,
100
- source: webViewSource,
101
- originWhitelist: ['*'],
102
- javaScriptEnabled: true,
103
- domStorageEnabled: true,
104
- allowsInlineMediaPlayback: true,
105
- mediaPlaybackRequiresUserAction: false,
106
- allowsFullscreenVideo: false,
107
- scrollEnabled: false,
108
- bounces: false,
109
- onMessage: handleMessage,
110
- onError: (event) => handlersRef.current.onError?.(event.nativeEvent || event),
111
- style: styles.webView,
112
- }));
113
- });
114
-
115
- const styles = StyleSheet.create({
116
- container: { ...StyleSheet.absoluteFillObject, backgroundColor: '#000' },
117
- webView: { flex: 1, backgroundColor: '#000' },
118
- });
119
-
120
- export default YouTubeVideoPlayer;