@cinecrew/cinecrew-player 0.1.3 → 0.1.4
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 +31 -20
- package/package.json +1 -1
- package/src/native/InlineLivePlayer.js +7 -6
- package/src/native/MediaPlayerView.js +58 -25
- package/src/native/media/LiveChatDrawer.js +16 -25
- package/src/native/media/YouTubeVideoPlayer.web.js +2 -0
- 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/useWebVideoAspectRatio.js +7 -2
- package/src/utils/invokePlayerAction.js +24 -0
- package/src/utils/progressBarTime.js +46 -0
- package/src/utils/webRecording.js +147 -0
- package/src/web/index.js +481 -99
- package/src/web/styles.css +129 -21
- package/types/index.d.ts +14 -1
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
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
|
+
export function createVideoRecordingStream(video, playerElement, getAspectRatio, additionalAudioStream) {
|
|
53
|
+
const capture = video?.captureStream || video?.webkitCaptureStream;
|
|
54
|
+
if (!video || typeof capture !== 'function' || typeof MediaStream === 'undefined') {
|
|
55
|
+
throw new Error('This browser does not support recording this media element.');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
let sourceStream;
|
|
59
|
+
try {
|
|
60
|
+
sourceStream = capture.call(video);
|
|
61
|
+
} catch (error) {
|
|
62
|
+
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.');
|
|
64
|
+
}
|
|
65
|
+
throw error;
|
|
66
|
+
}
|
|
67
|
+
const extraAudioTracks = additionalAudioStream?.getAudioTracks?.() || [];
|
|
68
|
+
const audioTracks = extraAudioTracks.length ? extraAudioTracks : sourceStream.getAudioTracks();
|
|
69
|
+
const bounds = playerElement?.getBoundingClientRect?.();
|
|
70
|
+
const outputWidth = Math.max(2, video.videoWidth || Math.round(bounds?.width || 640));
|
|
71
|
+
const playerRatio = bounds?.width && bounds?.height ? bounds.width / bounds.height : 16 / 9;
|
|
72
|
+
const outputHeight = Math.max(2, Math.round(outputWidth / playerRatio));
|
|
73
|
+
const canvas = document.createElement('canvas');
|
|
74
|
+
canvas.width = outputWidth;
|
|
75
|
+
canvas.height = outputHeight;
|
|
76
|
+
const context = canvas.getContext('2d');
|
|
77
|
+
if (!context || typeof canvas.captureStream !== 'function') {
|
|
78
|
+
sourceStream.getTracks().forEach((track) => track.stop());
|
|
79
|
+
throw new Error('Canvas recording is not supported in this browser.');
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
let originClean = false;
|
|
83
|
+
try {
|
|
84
|
+
drawVideoFrame(context, canvas, video, getAspectRatio());
|
|
85
|
+
context.getImageData(0, 0, 1, 1);
|
|
86
|
+
originClean = true;
|
|
87
|
+
} catch {
|
|
88
|
+
// Cross-origin media without CORS can still be played and captured from the
|
|
89
|
+
// media element, but cannot be re-rendered through a canvas.
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (!originClean) {
|
|
93
|
+
return {
|
|
94
|
+
stream: sourceStream,
|
|
95
|
+
aspectRatioApplied: false,
|
|
96
|
+
cleanup: () => sourceStream.getTracks().forEach((track) => track.stop()),
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const canvasStream = canvas.captureStream(30);
|
|
101
|
+
const stream = new MediaStream([...canvasStream.getVideoTracks(), ...audioTracks]);
|
|
102
|
+
let frameId;
|
|
103
|
+
const draw = () => {
|
|
104
|
+
drawVideoFrame(context, canvas, video, getAspectRatio());
|
|
105
|
+
frameId = requestAnimationFrame(draw);
|
|
106
|
+
};
|
|
107
|
+
frameId = requestAnimationFrame(draw);
|
|
108
|
+
return {
|
|
109
|
+
stream,
|
|
110
|
+
aspectRatioApplied: true,
|
|
111
|
+
cleanup: () => {
|
|
112
|
+
cancelAnimationFrame(frameId);
|
|
113
|
+
canvasStream.getTracks().forEach((track) => track.stop());
|
|
114
|
+
sourceStream.getTracks().forEach((track) => track.stop());
|
|
115
|
+
},
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function getRecordingMimeType() {
|
|
120
|
+
if (typeof MediaRecorder === 'undefined') return '';
|
|
121
|
+
const candidates = ['video/webm;codecs=vp9,opus', 'video/webm;codecs=vp8,opus', 'video/webm'];
|
|
122
|
+
return candidates.find((type) => MediaRecorder.isTypeSupported?.(type)) || '';
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function createRecordingDownloadLink(blob, title = 'cinecrew-recording') {
|
|
126
|
+
if (!blob?.size) return null;
|
|
127
|
+
const safeTitle = String(title || 'cinecrew-recording').replace(/[^a-z0-9-_]+/gi, '-').replace(/^-|-$/g, '') || 'cinecrew-recording';
|
|
128
|
+
const url = URL.createObjectURL(blob);
|
|
129
|
+
return {
|
|
130
|
+
url,
|
|
131
|
+
filename: `${safeTitle}-${new Date().toISOString().replace(/[:.]/g, '-')}.webm`,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function downloadRecording(blob, title = 'cinecrew-recording') {
|
|
136
|
+
const artifact = createRecordingDownloadLink(blob, title);
|
|
137
|
+
if (!artifact) return false;
|
|
138
|
+
const link = document.createElement('a');
|
|
139
|
+
link.href = artifact.url;
|
|
140
|
+
link.download = artifact.filename;
|
|
141
|
+
link.style.display = 'none';
|
|
142
|
+
document.body.appendChild(link);
|
|
143
|
+
link.click();
|
|
144
|
+
link.remove();
|
|
145
|
+
window.setTimeout(() => URL.revokeObjectURL(artifact.url), 60_000);
|
|
146
|
+
return true;
|
|
147
|
+
}
|