@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.
- package/README.md +56 -46
- package/package.json +1 -2
- package/src/native/InlineLivePlayer.js +95 -33
- package/src/native/MediaPlayerView.js +75 -73
- package/src/native/index.js +4 -5
- package/src/native/media/LiveChatDrawer.js +16 -25
- package/src/native/media/WebVideoPlayer.js +13 -0
- package/src/native/media/player/AudioOnlyView.js +3 -40
- package/src/native/media/player/PlayerBottomBar.js +4 -12
- package/src/native/media/player/playerConstants.js +3 -3
- package/src/native/media/web/useWebAc3AudioPlayback.web.js +9 -3
- package/src/native/media/web/useWebHlsPlayback.web.js +1 -0
- package/src/native/media/web/useWebVideoAspectRatio.js +7 -2
- package/src/utils/invokePlayerAction.js +24 -0
- package/src/utils/progressBarTime.js +46 -0
- package/src/utils/sourceUtils.js +0 -25
- package/src/utils/webRecording.js +194 -0
- package/src/web/index.js +523 -156
- package/src/web/styles.css +142 -23
- package/types/index.d.ts +21 -2
- package/src/native/media/YouTubeVideoPlayer.native.js +0 -120
- package/src/native/media/YouTubeVideoPlayer.web.js +0 -223
- package/src/utils/youtubeHtml.js +0 -34
|
@@ -121,11 +121,13 @@ export function useWebAc3AudioPlayback({
|
|
|
121
121
|
|
|
122
122
|
const context = new AudioContextConstructor({ latencyHint: 'interactive' });
|
|
123
123
|
const gain = context.createGain();
|
|
124
|
+
const recordingDestination = context.createMediaStreamDestination?.();
|
|
124
125
|
gain.gain.value = enabledRef.current
|
|
125
126
|
? Math.max(0, Math.min(1, Number(volumeRef.current) / 100))
|
|
126
127
|
: 0;
|
|
127
128
|
gain.connect(context.destination);
|
|
128
|
-
|
|
129
|
+
if (recordingDestination) gain.connect(recordingDestination);
|
|
130
|
+
audioRef.current = { context, gain, recordingDestination };
|
|
129
131
|
return audioRef.current;
|
|
130
132
|
}, []);
|
|
131
133
|
|
|
@@ -212,8 +214,12 @@ export function useWebAc3AudioPlayback({
|
|
|
212
214
|
useEffect(() => () => {
|
|
213
215
|
const { context } = audioRef.current;
|
|
214
216
|
if (context && context.state !== 'closed') context.close().catch(() => {});
|
|
215
|
-
audioRef.current = { context: null, gain: null };
|
|
217
|
+
audioRef.current = { context: null, gain: null, recordingDestination: null };
|
|
216
218
|
}, []);
|
|
217
219
|
|
|
218
|
-
return {
|
|
220
|
+
return {
|
|
221
|
+
activateAudio,
|
|
222
|
+
deactivateAudio,
|
|
223
|
+
getRecordingAudioStream: () => audioRef.current.recordingDestination?.stream || null,
|
|
224
|
+
};
|
|
219
225
|
}
|
|
@@ -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;
|
|
@@ -17,8 +17,13 @@ export function useWebVideoAspectRatio(videoAspectRatio, audioOnly) {
|
|
|
17
17
|
} else if (aspect === 'CENTER') {
|
|
18
18
|
setAspectFit('none');
|
|
19
19
|
setAspectRatioVal(undefined);
|
|
20
|
-
} else if (typeof aspect === 'string' &&
|
|
21
|
-
const parts = aspect.split(':');
|
|
20
|
+
} else if (typeof aspect === 'string' && /^\d+(?:\.\d+)?:\d+(?:\.\d+)?$/.test(aspect)) {
|
|
21
|
+
const parts = aspect.split(':').map(Number);
|
|
22
|
+
if (!parts[0] || !parts[1]) {
|
|
23
|
+
setAspectFit('contain');
|
|
24
|
+
setAspectRatioVal(undefined);
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
22
27
|
setAspectFit('contain');
|
|
23
28
|
setAspectRatioVal(`${parts[0]}/${parts[1]}`);
|
|
24
29
|
} else {
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/** Run the core action when present, then notify the consuming app. */
|
|
2
|
+
export function invokePlayerAction(coreAction, callback, payload, context) {
|
|
3
|
+
const result = typeof coreAction === 'function' ? coreAction(payload) : undefined;
|
|
4
|
+
if (typeof callback !== 'function') return result;
|
|
5
|
+
|
|
6
|
+
const notify = () => {
|
|
7
|
+
try {
|
|
8
|
+
callback(payload, context);
|
|
9
|
+
} catch (error) {
|
|
10
|
+
// A consumer notification must never roll back or block the player action.
|
|
11
|
+
console.error('CineCrewPlayer action callback failed:', error);
|
|
12
|
+
}
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
if (result && typeof result.then === 'function') {
|
|
16
|
+
return result.then((value) => {
|
|
17
|
+
notify();
|
|
18
|
+
return value;
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
notify();
|
|
23
|
+
return result;
|
|
24
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/** Format a player position as an unambiguous, zero-padded HH:MM:SS value. */
|
|
2
|
+
export function formatProgressBarTime(seconds) {
|
|
3
|
+
const totalSeconds = Number.isFinite(Number(seconds)) ? Math.max(0, Math.floor(Number(seconds))) : 0;
|
|
4
|
+
const hours = Math.floor(totalSeconds / 3600);
|
|
5
|
+
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
|
6
|
+
const remainder = totalSeconds % 60;
|
|
7
|
+
return [hours, minutes, remainder].map((value) => String(value).padStart(2, '0')).join(':');
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Emit one progress-bar callback per whole playback second, while allowing
|
|
12
|
+
* completed seeks/restarts to report immediately even within the same second.
|
|
13
|
+
*/
|
|
14
|
+
export function emitProgressBarTime(seconds, callback, lastSecondRef, { force = false } = {}) {
|
|
15
|
+
if (typeof callback !== 'function') return false;
|
|
16
|
+
const normalizedSeconds = Number.isFinite(Number(seconds)) ? Math.max(0, Number(seconds)) : 0;
|
|
17
|
+
const wholeSecond = Math.floor(normalizedSeconds);
|
|
18
|
+
const report = (value) => {
|
|
19
|
+
try {
|
|
20
|
+
callback(formatProgressBarTime(value));
|
|
21
|
+
} catch (error) {
|
|
22
|
+
// Progress observers must not interrupt media playback or native updates.
|
|
23
|
+
console.error('CineCrewPlayer progress-bar callback failed:', error);
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
if (force) {
|
|
28
|
+
lastSecondRef.current = wholeSecond;
|
|
29
|
+
report(normalizedSeconds);
|
|
30
|
+
return true;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (lastSecondRef.current === null || lastSecondRef.current === undefined) {
|
|
34
|
+
lastSecondRef.current = wholeSecond;
|
|
35
|
+
if (wholeSecond === 0) return false;
|
|
36
|
+
report(normalizedSeconds);
|
|
37
|
+
return true;
|
|
38
|
+
}
|
|
39
|
+
if (wholeSecond <= lastSecondRef.current) return false;
|
|
40
|
+
|
|
41
|
+
for (let elapsedSecond = lastSecondRef.current + 1; elapsedSecond <= wholeSecond; elapsedSecond += 1) {
|
|
42
|
+
report(elapsedSecond);
|
|
43
|
+
}
|
|
44
|
+
lastSecondRef.current = wholeSecond;
|
|
45
|
+
return true;
|
|
46
|
+
}
|
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);
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
function parseRatio(value) {
|
|
2
|
+
const [width, height] = String(value || '').split(':').map(Number);
|
|
3
|
+
return width > 0 && height > 0 ? width / height : null;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
function fitRect(width, height, ratio) {
|
|
7
|
+
if (!ratio || !width || !height) return { x: 0, y: 0, width, height };
|
|
8
|
+
const boundsRatio = width / height;
|
|
9
|
+
const frameWidth = boundsRatio > ratio ? height * ratio : width;
|
|
10
|
+
const frameHeight = frameWidth / ratio;
|
|
11
|
+
return { x: (width - frameWidth) / 2, y: (height - frameHeight) / 2, width: frameWidth, height: frameHeight };
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function drawVideoFrame(context, canvas, video, aspectRatio) {
|
|
15
|
+
const width = canvas.width;
|
|
16
|
+
const height = canvas.height;
|
|
17
|
+
context.fillStyle = '#000';
|
|
18
|
+
context.fillRect(0, 0, width, height);
|
|
19
|
+
|
|
20
|
+
const ratio = parseRatio(aspectRatio);
|
|
21
|
+
const frame = fitRect(width, height, ratio);
|
|
22
|
+
const sourceWidth = video.videoWidth;
|
|
23
|
+
const sourceHeight = video.videoHeight;
|
|
24
|
+
if (!sourceWidth || !sourceHeight) return;
|
|
25
|
+
|
|
26
|
+
const mode = String(aspectRatio || 'FIT').toUpperCase();
|
|
27
|
+
if (mode === 'STRETCH') {
|
|
28
|
+
context.drawImage(video, frame.x, frame.y, frame.width, frame.height);
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const sourceRatio = sourceWidth / sourceHeight;
|
|
33
|
+
const frameRatio = frame.width / frame.height;
|
|
34
|
+
if (mode === 'FILL' || mode === 'FILL_SCREEN') {
|
|
35
|
+
if (sourceRatio > frameRatio) {
|
|
36
|
+
const croppedWidth = sourceHeight * frameRatio;
|
|
37
|
+
context.drawImage(video, (sourceWidth - croppedWidth) / 2, 0, croppedWidth, sourceHeight, frame.x, frame.y, frame.width, frame.height);
|
|
38
|
+
} else {
|
|
39
|
+
const croppedHeight = sourceWidth / frameRatio;
|
|
40
|
+
context.drawImage(video, 0, (sourceHeight - croppedHeight) / 2, sourceWidth, croppedHeight, frame.x, frame.y, frame.width, frame.height);
|
|
41
|
+
}
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const scale = Math.min(frame.width / sourceWidth, frame.height / sourceHeight);
|
|
46
|
+
const drawWidth = sourceWidth * scale;
|
|
47
|
+
const drawHeight = sourceHeight * scale;
|
|
48
|
+
context.drawImage(video, frame.x + (frame.width - drawWidth) / 2, frame.y + (frame.height - drawHeight) / 2, drawWidth, drawHeight);
|
|
49
|
+
}
|
|
50
|
+
|
|
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
|
+
*/
|
|
55
|
+
export function createVideoRecordingStream(video, playerElement, getAspectRatio, additionalAudioStream) {
|
|
56
|
+
const capture = video?.captureStream || video?.webkitCaptureStream;
|
|
57
|
+
if (!video || typeof MediaStream === 'undefined') {
|
|
58
|
+
throw new Error('This browser does not support recording this media element.');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// captureStream may not exist on every browser / element combination.
|
|
62
|
+
if (typeof capture !== 'function') {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
let sourceStream;
|
|
67
|
+
try {
|
|
68
|
+
sourceStream = capture.call(video);
|
|
69
|
+
} catch (error) {
|
|
70
|
+
if (error?.name === 'SecurityError' || /cross.?origin|security/i.test(error?.message || '')) {
|
|
71
|
+
// Cross-origin media without CORS – let the caller fall back to screen capture.
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
throw error;
|
|
75
|
+
}
|
|
76
|
+
const extraAudioTracks = additionalAudioStream?.getAudioTracks?.() || [];
|
|
77
|
+
const audioTracks = extraAudioTracks.length ? extraAudioTracks : sourceStream.getAudioTracks();
|
|
78
|
+
const bounds = playerElement?.getBoundingClientRect?.();
|
|
79
|
+
const outputWidth = Math.max(2, video.videoWidth || Math.round(bounds?.width || 640));
|
|
80
|
+
const playerRatio = bounds?.width && bounds?.height ? bounds.width / bounds.height : 16 / 9;
|
|
81
|
+
const outputHeight = Math.max(2, Math.round(outputWidth / playerRatio));
|
|
82
|
+
const canvas = document.createElement('canvas');
|
|
83
|
+
canvas.width = outputWidth;
|
|
84
|
+
canvas.height = outputHeight;
|
|
85
|
+
const context = canvas.getContext('2d');
|
|
86
|
+
if (!context || typeof canvas.captureStream !== 'function') {
|
|
87
|
+
sourceStream.getTracks().forEach((track) => track.stop());
|
|
88
|
+
throw new Error('Canvas recording is not supported in this browser.');
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
let originClean = false;
|
|
92
|
+
try {
|
|
93
|
+
drawVideoFrame(context, canvas, video, getAspectRatio());
|
|
94
|
+
context.getImageData(0, 0, 1, 1);
|
|
95
|
+
originClean = true;
|
|
96
|
+
} catch {
|
|
97
|
+
// Cross-origin media without CORS can still be played and captured from the
|
|
98
|
+
// media element, but cannot be re-rendered through a canvas.
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (!originClean) {
|
|
102
|
+
return {
|
|
103
|
+
stream: sourceStream,
|
|
104
|
+
aspectRatioApplied: false,
|
|
105
|
+
cleanup: () => sourceStream.getTracks().forEach((track) => track.stop()),
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const canvasStream = canvas.captureStream(30);
|
|
110
|
+
const stream = new MediaStream([...canvasStream.getVideoTracks(), ...audioTracks]);
|
|
111
|
+
let frameId;
|
|
112
|
+
const draw = () => {
|
|
113
|
+
drawVideoFrame(context, canvas, video, getAspectRatio());
|
|
114
|
+
frameId = requestAnimationFrame(draw);
|
|
115
|
+
};
|
|
116
|
+
frameId = requestAnimationFrame(draw);
|
|
117
|
+
return {
|
|
118
|
+
stream,
|
|
119
|
+
aspectRatioApplied: true,
|
|
120
|
+
cleanup: () => {
|
|
121
|
+
cancelAnimationFrame(frameId);
|
|
122
|
+
canvasStream.getTracks().forEach((track) => track.stop());
|
|
123
|
+
sourceStream.getTracks().forEach((track) => track.stop());
|
|
124
|
+
},
|
|
125
|
+
};
|
|
126
|
+
}
|
|
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
|
+
|
|
166
|
+
export function getRecordingMimeType() {
|
|
167
|
+
if (typeof MediaRecorder === 'undefined') return '';
|
|
168
|
+
const candidates = ['video/webm;codecs=vp9,opus', 'video/webm;codecs=vp8,opus', 'video/webm'];
|
|
169
|
+
return candidates.find((type) => MediaRecorder.isTypeSupported?.(type)) || '';
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export function createRecordingDownloadLink(blob, title = 'cinecrew-recording') {
|
|
173
|
+
if (!blob?.size) return null;
|
|
174
|
+
const safeTitle = String(title || 'cinecrew-recording').replace(/[^a-z0-9-_]+/gi, '-').replace(/^-|-$/g, '') || 'cinecrew-recording';
|
|
175
|
+
const url = URL.createObjectURL(blob);
|
|
176
|
+
return {
|
|
177
|
+
url,
|
|
178
|
+
filename: `${safeTitle}-${new Date().toISOString().replace(/[:.]/g, '-')}.webm`,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export function downloadRecording(blob, title = 'cinecrew-recording') {
|
|
183
|
+
const artifact = createRecordingDownloadLink(blob, title);
|
|
184
|
+
if (!artifact) return false;
|
|
185
|
+
const link = document.createElement('a');
|
|
186
|
+
link.href = artifact.url;
|
|
187
|
+
link.download = artifact.filename;
|
|
188
|
+
link.style.display = 'none';
|
|
189
|
+
document.body.appendChild(link);
|
|
190
|
+
link.click();
|
|
191
|
+
link.remove();
|
|
192
|
+
window.setTimeout(() => URL.revokeObjectURL(artifact.url), 60_000);
|
|
193
|
+
return true;
|
|
194
|
+
}
|