@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.
- package/README.md +30 -31
- package/package.json +1 -2
- package/src/native/InlineLivePlayer.js +88 -27
- package/src/native/MediaPlayerView.js +27 -58
- package/src/native/index.js +4 -5
- package/src/native/media/WebVideoPlayer.js +13 -0
- package/src/native/media/player/AudioOnlyView.js +3 -40
- package/src/native/media/web/useWebHlsPlayback.web.js +1 -0
- package/src/utils/invokePlayerAction.js +1 -1
- package/src/utils/sourceUtils.js +0 -25
- package/src/utils/webRecording.js +50 -3
- package/src/web/index.js +61 -76
- package/src/web/styles.css +13 -2
- package/types/index.d.ts +8 -2
- package/src/native/media/YouTubeVideoPlayer.native.js +0 -120
- package/src/native/media/YouTubeVideoPlayer.web.js +0 -225
- package/src/utils/youtubeHtml.js +0 -34
package/src/native/index.js
CHANGED
|
@@ -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 {
|
|
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
|
|
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
|
-
|
|
77
|
+
onBack,
|
|
79
78
|
title: title || media.title || '',
|
|
80
79
|
posterUrl: poster || posterUrl || media.poster || media.posterUrl || '',
|
|
81
80
|
mediaType: resolvedMediaType,
|
|
82
|
-
onClose: onClose ||
|
|
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={
|
|
105
|
-
<AudioOnlyBackdrop posterUrl={posterUrl} />
|
|
87
|
+
<View style={styles.audioOnlyContainer} pointerEvents="auto">
|
|
106
88
|
|
|
107
|
-
<View style={[styles.audioOnlyCard, isLandscape && styles.audioOnlyCardLandscape, {
|
|
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: '
|
|
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
|
|
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;
|
package/src/utils/sourceUtils.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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'];
|