@cinecrew/cinecrew-player 0.1.4 → 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.
@@ -3,7 +3,7 @@ import { ActivityIndicator, StyleSheet, Text, View } from 'react-native';
3
3
  import { PlayerCustomizationProvider } from './customization';
4
4
  import { MediaPlayerView } from './MediaPlayerView';
5
5
  import { isElectron, isWeb } from '../utils/runtimePlatform';
6
- import { getYouTubeVideoId, useResolvedPlayerSource } from '../utils/sourceUtils';
6
+ import { useResolvedPlayerSource } from '../utils/sourceUtils';
7
7
 
8
8
  function getRuntimePlatform() {
9
9
  if (isWeb()) return 'web';
@@ -50,8 +50,7 @@ export const CineCrewPlayer = forwardRef(function CineCrewPlayer(props, ref) {
50
50
  const streamUrl = String(media.uri || media.url || '');
51
51
  const resolvedIsLive = isLive ?? media.isLive ?? (media.mediaType === 'live');
52
52
  const resolvedMediaType = mediaType || media.mediaType || (resolvedIsLive ? 'live' : 'movie');
53
- const youtubeVideoId = getYouTubeVideoId(media);
54
- const isVisible = visible ?? Boolean(streamUrl || youtubeVideoId);
53
+ const isVisible = visible ?? Boolean(streamUrl);
55
54
 
56
55
  React.useEffect(() => {
57
56
  if (resolution.error) props.onError?.(resolution.error);
@@ -75,11 +74,11 @@ export const CineCrewPlayer = forwardRef(function CineCrewPlayer(props, ref) {
75
74
  ...metadata,
76
75
  visible: isVisible,
77
76
  streamUrl,
78
- youtubeVideoId,
77
+ onBack,
79
78
  title: title || media.title || '',
80
79
  posterUrl: poster || posterUrl || media.poster || media.posterUrl || '',
81
80
  mediaType: resolvedMediaType,
82
- onClose: onClose || onBack || (() => {}),
81
+ onClose: onClose || (() => {}),
83
82
  initialPaused: paused ?? initialPaused ?? !autoPlay,
84
83
  initialMuted: muted ?? initialMuted ?? false,
85
84
  initialAudioOnly: audioOnly,
@@ -39,6 +39,9 @@ export const WebVideoPlayer = forwardRef(({
39
39
  const playbackErrorRef = useRef(onError);
40
40
  const onEndedRef = useRef(onEnded);
41
41
  const [activeUrl, setActiveUrl] = useState('');
42
+ const [corsMode, setCorsMode] = useState('anonymous');
43
+ const corsModeRef = useRef('anonymous');
44
+ corsModeRef.current = corsMode;
42
45
  pausedRef.current = paused;
43
46
  playbackRateRef.current = playbackRate;
44
47
  onPlaybackRouteRef.current = onPlaybackRoute;
@@ -126,6 +129,8 @@ export const WebVideoPlayer = forwardRef(({
126
129
  return undefined;
127
130
  }
128
131
  setActiveUrl(streamUrl);
132
+ setCorsMode('anonymous');
133
+ corsModeRef.current = 'anonymous';
129
134
  return undefined;
130
135
  }, [streamUrl]);
131
136
 
@@ -243,6 +248,7 @@ export const WebVideoPlayer = forwardRef(({
243
248
  muted={muted}
244
249
  playsInline
245
250
  controls={false}
251
+ crossOrigin={corsMode}
246
252
  style={videoStyle}
247
253
  onPlaying={() => {
248
254
  const video = videoRef.current;
@@ -280,6 +286,13 @@ export const WebVideoPlayer = forwardRef(({
280
286
  if (video && video.readyState >= 2) {
281
287
  return;
282
288
  }
289
+ // If we were trying anonymous CORS and it failed, retry without.
290
+ // Playback will work; recording will fall back to screen capture.
291
+ if (corsModeRef.current === 'anonymous') {
292
+ setCorsMode(undefined);
293
+ corsModeRef.current = undefined;
294
+ return;
295
+ }
283
296
  playbackErrorRef.current?.({
284
297
  message: mediaError?.message || 'Failed to load stream.',
285
298
  err: mediaError || e,
@@ -48,23 +48,6 @@ function SoundWave({ isPlaying, isLandscape, accentColor }) {
48
48
  );
49
49
  }
50
50
 
51
- function AudioOnlyBackdrop({ posterUrl }) {
52
- return (
53
- <>
54
- {posterUrl ? (
55
- <Image
56
- source={{ uri: posterUrl }}
57
- style={[StyleSheet.absoluteFill, styles.audioOnlyBackdropImage]}
58
- resizeMode="cover"
59
- blurRadius={isWeb() ? 40 : 25}
60
- pointerEvents="none"
61
- />
62
- ) : null}
63
- <View style={styles.audioOnlyBackdropDim} pointerEvents="none" />
64
- </>
65
- );
66
- }
67
-
68
51
  function AudioOnlyBadge({ usesAudioProxy, palette, isLandscape }) {
69
52
  const message = usesAudioProxy
70
53
  ? 'Battery Saver Audio Mode • Screen can be locked'
@@ -101,10 +84,9 @@ export const AudioOnlyView = ({
101
84
  );
102
85
 
103
86
  return (
104
- <View style={[styles.audioOnlyContainer, { backgroundColor: palette.backgroundColor }]} pointerEvents="auto">
105
- <AudioOnlyBackdrop posterUrl={posterUrl} />
87
+ <View style={styles.audioOnlyContainer} pointerEvents="auto">
106
88
 
107
- <View style={[styles.audioOnlyCard, isLandscape && styles.audioOnlyCardLandscape, { backgroundColor: palette.surfaceColor, borderColor: palette.accentColor }]} pointerEvents="auto">
89
+ <View style={[styles.audioOnlyCard, isLandscape && styles.audioOnlyCardLandscape, { borderColor: palette.accentColor }]} pointerEvents="auto">
108
90
  <AudioArtwork posterUrl={posterUrl} isLandscape={isLandscape} palette={palette} />
109
91
 
110
92
  <SoundWave isPlaying={isPlaying} isLandscape={isLandscape} accentColor={palette.accentColor} />
@@ -153,25 +135,6 @@ const styles = StyleSheet.create({
153
135
  zIndex: 50,
154
136
  elevation: 50,
155
137
  },
156
- audioOnlyBackdropImage: {
157
- position: 'absolute',
158
- top: 0,
159
- left: 0,
160
- right: 0,
161
- bottom: 0,
162
- width: '100%',
163
- height: '100%',
164
- },
165
- audioOnlyBackdropDim: {
166
- position: 'absolute',
167
- top: 0,
168
- left: 0,
169
- right: 0,
170
- bottom: 0,
171
- width: '100%',
172
- height: '100%',
173
- backgroundColor: 'rgba(7, 9, 14, 0.88)',
174
- },
175
138
  audioOnlyCard: {
176
139
  alignItems: 'center',
177
140
  justifyContent: 'center',
@@ -180,7 +143,7 @@ const styles = StyleSheet.create({
180
143
  maxWidth: 440,
181
144
  maxHeight: '92%',
182
145
  borderRadius: 20,
183
- backgroundColor: 'rgba(15, 23, 42, 0.85)',
146
+ backgroundColor: '#0B1220',
184
147
  borderWidth: 1,
185
148
  borderColor: 'rgba(0, 229, 255, 0.25)',
186
149
  shadowColor: '#000',
@@ -41,6 +41,7 @@ export function useWebHlsPlayback({ activeUrl, isLive, videoRef, pausedRef, onEr
41
41
  onErrorRef.current?.({ message, httpStatus, err: data });
42
42
  });
43
43
  hls.loadSource(activeUrl);
44
+ video.crossOrigin = 'anonymous';
44
45
  hls.attachMedia(video);
45
46
  } else if (video.canPlayType('application/vnd.apple.mpegurl')) {
46
47
  video.src = activeUrl;
@@ -1,4 +1,4 @@
1
- /** Run the player's built-in action before notifying the consuming app. */
1
+ /** Run the core action when present, then notify the consuming app. */
2
2
  export function invokePlayerAction(coreAction, callback, payload, context) {
3
3
  const result = typeof coreAction === 'function' ? coreAction(payload) : undefined;
4
4
  if (typeof callback !== 'function') return result;
@@ -6,31 +6,6 @@ export function normalizePlayerSource(source, url) {
6
6
  return value && typeof value === 'object' ? value : { uri: '' };
7
7
  }
8
8
 
9
- export function getYouTubeVideoId(source) {
10
- const media = typeof source === 'string' ? { uri: source } : source || {};
11
- const explicitId = media.youtubeVideoId || (String(media.type || '').toLowerCase() === 'youtube' ? media.id : null);
12
- const isValidId = (value) => /^[\w-]{11}$/.test(String(value || ''));
13
- if (isValidId(explicitId)) return String(explicitId);
14
-
15
- const raw = String(media.uri || media.url || '').trim();
16
- if (!raw) return null;
17
- try {
18
- const parsed = new URL(raw);
19
- const host = parsed.hostname.toLowerCase().replace(/^www\./, '');
20
- if (host === 'youtu.be') {
21
- const id = parsed.pathname.split('/').find(Boolean);
22
- return isValidId(id) ? id : null;
23
- }
24
- if (host !== 'youtube.com' && host !== 'youtube-nocookie.com' && host !== 'm.youtube.com') return null;
25
- const queryId = parsed.searchParams.get('v');
26
- if (isValidId(queryId)) return queryId;
27
- const match = /^\/(?:embed|shorts|live)\/([\w-]{11})/.exec(parsed.pathname);
28
- return isValidId(match?.[1]) ? match[1] : null;
29
- } catch {
30
- return null;
31
- }
32
- }
33
-
34
9
  export function getWebRuntimePlatform() {
35
10
  if (typeof window === 'undefined') return 'web';
36
11
  const electronProcess = Boolean(window.process?.versions?.electron);
@@ -48,19 +48,28 @@ function drawVideoFrame(context, canvas, video, aspectRatio) {
48
48
  context.drawImage(video, frame.x + (frame.width - drawWidth) / 2, frame.y + (frame.height - drawHeight) / 2, drawWidth, drawHeight);
49
49
  }
50
50
 
51
- /** Capture the visible media frame (not player chrome) and preserve its audio tracks. */
51
+ /** Capture the visible media frame (not player chrome) and preserve its audio tracks.
52
+ * Returns `null` when cross-origin restrictions prevent direct capture so the
53
+ * caller can fall back to screen/tab capture via `createScreenRecordingStream`.
54
+ */
52
55
  export function createVideoRecordingStream(video, playerElement, getAspectRatio, additionalAudioStream) {
53
56
  const capture = video?.captureStream || video?.webkitCaptureStream;
54
- if (!video || typeof capture !== 'function' || typeof MediaStream === 'undefined') {
57
+ if (!video || typeof MediaStream === 'undefined') {
55
58
  throw new Error('This browser does not support recording this media element.');
56
59
  }
57
60
 
61
+ // captureStream may not exist on every browser / element combination.
62
+ if (typeof capture !== 'function') {
63
+ return null;
64
+ }
65
+
58
66
  let sourceStream;
59
67
  try {
60
68
  sourceStream = capture.call(video);
61
69
  } catch (error) {
62
70
  if (error?.name === 'SecurityError' || /cross.?origin|security/i.test(error?.message || '')) {
63
- throw new Error('The browser cannot record this cross-origin video because its source does not grant capture access. Use a local or CORS-enabled source to record it.');
71
+ // Cross-origin media without CORS – let the caller fall back to screen capture.
72
+ return null;
64
73
  }
65
74
  throw error;
66
75
  }
@@ -116,6 +125,44 @@ export function createVideoRecordingStream(video, playerElement, getAspectRatio,
116
125
  };
117
126
  }
118
127
 
128
+ /**
129
+ * Generalised screen / tab capture that works for any source type.
130
+ * Uses `preferCurrentTab` (Chrome 109+) to automatically select the
131
+ * current tab, making the user experience smoother.
132
+ */
133
+ export async function createScreenRecordingStream() {
134
+ const mediaDevices = typeof navigator !== 'undefined' ? navigator.mediaDevices : null;
135
+ if (typeof mediaDevices?.getDisplayMedia !== 'function') {
136
+ throw new TypeError('Recording requires browser tab/screen capture, which is not available in this browser.');
137
+ }
138
+
139
+ let stream;
140
+ try {
141
+ stream = await mediaDevices.getDisplayMedia({
142
+ video: true,
143
+ audio: true,
144
+ preferCurrentTab: true,
145
+ });
146
+ } catch (error) {
147
+ if (error?.name === 'NotAllowedError' || error?.name === 'AbortError') {
148
+ throw new Error('Recording was cancelled. Allow tab/screen capture and choose the player tab to record.');
149
+ }
150
+ throw error;
151
+ }
152
+
153
+ const videoTracks = stream.getVideoTracks?.() || [];
154
+ const audioTracks = stream.getAudioTracks?.() || [];
155
+ if (!videoTracks.length || !audioTracks.length) {
156
+ stream.getTracks?.().forEach((track) => track.stop());
157
+ throw new Error('The selected capture has no video or tab audio. Choose a browser tab and enable Share tab audio.');
158
+ }
159
+
160
+ return {
161
+ stream,
162
+ cleanup: () => stream.getTracks?.().forEach((track) => track.stop()),
163
+ };
164
+ }
165
+
119
166
  export function getRecordingMimeType() {
120
167
  if (typeof MediaRecorder === 'undefined') return '';
121
168
  const candidates = ['video/webm;codecs=vp9,opus', 'video/webm;codecs=vp8,opus', 'video/webm'];