fixed choppy video on iPhone - mp4 encoding
This commit is contained in:
BIN
public/media/andy.mp4
Normal file
BIN
public/media/andy.mp4
Normal file
Binary file not shown.
Binary file not shown.
BIN
public/media/ben.mp4
Normal file
BIN
public/media/ben.mp4
Normal file
Binary file not shown.
Binary file not shown.
BIN
public/media/recording_1770569104899.mp4
Normal file
BIN
public/media/recording_1770569104899.mp4
Normal file
Binary file not shown.
BIN
public/media/recording_1770569130681.mp4
Normal file
BIN
public/media/recording_1770569130681.mp4
Normal file
Binary file not shown.
BIN
public/media/recording_1770571670297.mp4
Normal file
BIN
public/media/recording_1770571670297.mp4
Normal file
Binary file not shown.
BIN
public/media/recording_1770571697502.mp4
Normal file
BIN
public/media/recording_1770571697502.mp4
Normal file
Binary file not shown.
BIN
public/media/recording_1770572219607.mp4
Normal file
BIN
public/media/recording_1770572219607.mp4
Normal file
Binary file not shown.
BIN
public/media/recording_1770572287059.mp4
Normal file
BIN
public/media/recording_1770572287059.mp4
Normal file
Binary file not shown.
BIN
public/media/recording_1770572628450.mp4
Normal file
BIN
public/media/recording_1770572628450.mp4
Normal file
Binary file not shown.
BIN
public/media/recording_1770572652336.mp4
Normal file
BIN
public/media/recording_1770572652336.mp4
Normal file
Binary file not shown.
BIN
public/media/recording_1770572757674.mp4
Normal file
BIN
public/media/recording_1770572757674.mp4
Normal file
Binary file not shown.
BIN
public/media/recording_1770572849703.mp4
Normal file
BIN
public/media/recording_1770572849703.mp4
Normal file
Binary file not shown.
121
public/signsync/test/index.html
Normal file
121
public/signsync/test/index.html
Normal file
@@ -0,0 +1,121 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>SignSync Test Videos</title>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: light;
|
||||
font-family: "Avenir Next", "Segoe UI", sans-serif;
|
||||
background: #f4f2ec;
|
||||
color: #1f1b16;
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 24px 16px 48px;
|
||||
}
|
||||
h1 {
|
||||
font-size: 20px;
|
||||
margin: 0 0 16px;
|
||||
}
|
||||
.videos {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||
align-items: start;
|
||||
}
|
||||
.panel {
|
||||
background: #fff;
|
||||
border: 1px solid #e5e1d8;
|
||||
border-radius: 12px;
|
||||
padding: 12px;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
.controls {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.note {
|
||||
font-size: 12px;
|
||||
color: #5b5145;
|
||||
margin: 0 0 16px;
|
||||
}
|
||||
.btn {
|
||||
border: 1px solid #c8bfae;
|
||||
background: #efe9dd;
|
||||
color: #2f271f;
|
||||
padding: 8px 14px;
|
||||
border-radius: 999px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.label {
|
||||
font-size: 14px;
|
||||
margin: 0 0 8px;
|
||||
color: #5b5145;
|
||||
}
|
||||
video {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
border-radius: 8px;
|
||||
background: #101010;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>SignSync Test Videos</h1>
|
||||
<div class="controls">
|
||||
<button class="btn" id="toggleBtn" type="button">Play Both</button>
|
||||
</div>
|
||||
<p class="note">On iPhone, both videos must be muted to play simultaneously. Use individual controls for audio.</p>
|
||||
<div class="videos">
|
||||
<div class="panel">
|
||||
<p class="label">/signsync/media/ben.mp4</p>
|
||||
<video id="videoA" src="/signsync/media/ben.mp4" controls playsinline muted></video>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<p class="label">/signsync/media/andy.mp4</p>
|
||||
<video id="videoB" src="/signsync/media/andy.mp4" controls playsinline muted></video>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
const videoA = document.getElementById('videoA');
|
||||
const videoB = document.getElementById('videoB');
|
||||
const toggleBtn = document.getElementById('toggleBtn');
|
||||
|
||||
const updateButtonLabel = () => {
|
||||
const isPlaying = !videoA.paused || !videoB.paused;
|
||||
toggleBtn.textContent = isPlaying ? 'Pause Both' : 'Play Both';
|
||||
};
|
||||
|
||||
toggleBtn.addEventListener('click', async () => {
|
||||
const shouldPlay = videoA.paused && videoB.paused;
|
||||
if (shouldPlay) {
|
||||
try {
|
||||
videoA.muted = true;
|
||||
videoB.muted = true;
|
||||
await Promise.all([videoA.play(), videoB.play()]);
|
||||
} catch (error) {
|
||||
console.error('Failed to play both videos:', error);
|
||||
}
|
||||
} else {
|
||||
videoA.pause();
|
||||
videoB.pause();
|
||||
}
|
||||
updateButtonLabel();
|
||||
});
|
||||
|
||||
['play', 'pause', 'ended'].forEach((eventName) => {
|
||||
videoA.addEventListener(eventName, updateButtonLabel);
|
||||
videoB.addEventListener(eventName, updateButtonLabel);
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
104
server/convert-webm.js
Normal file
104
server/convert-webm.js
Normal file
@@ -0,0 +1,104 @@
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import { fileURLToPath } from 'url';
|
||||
import sqlite3 from 'sqlite3';
|
||||
import { open } from 'sqlite';
|
||||
import { spawn } from 'child_process';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const mediaDir = path.join(__dirname, '..', 'public', 'media');
|
||||
|
||||
const transcodeToMp4 = (inputPath, outputPath) => new Promise((resolve, reject) => {
|
||||
const args = [
|
||||
'-y',
|
||||
'-i', inputPath,
|
||||
'-vf', 'scale=trunc(iw/2)*2:trunc(ih/2)*2',
|
||||
'-c:v', 'libx264',
|
||||
'-profile:v', 'baseline',
|
||||
'-level', '3.0',
|
||||
'-pix_fmt', 'yuv420p',
|
||||
'-c:a', 'aac',
|
||||
'-b:a', '128k',
|
||||
'-movflags', '+faststart',
|
||||
outputPath
|
||||
];
|
||||
|
||||
const ffmpeg = spawn('ffmpeg', args, { stdio: 'ignore' });
|
||||
ffmpeg.on('error', reject);
|
||||
ffmpeg.on('close', (code) => {
|
||||
if (code === 0) {
|
||||
resolve();
|
||||
} else {
|
||||
reject(new Error(`ffmpeg exited with code ${code}`));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const toMediaPath = (filename) => `/media/${filename}`;
|
||||
|
||||
async function run() {
|
||||
const db = await open({
|
||||
filename: path.join(__dirname, 'signsync.db'),
|
||||
driver: sqlite3.Database
|
||||
});
|
||||
|
||||
const addColumnIfMissing = async (columnDef) => {
|
||||
try {
|
||||
await db.exec(`ALTER TABLE recordings ADD COLUMN ${columnDef}`);
|
||||
} catch (error) {
|
||||
if (!String(error.message).toLowerCase().includes('duplicate column')) {
|
||||
console.error('Error adding column:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
await addColumnIfMissing('recorded_video_processing INTEGER DEFAULT 0');
|
||||
await addColumnIfMissing('recorded_video_processing_2 INTEGER DEFAULT 0');
|
||||
|
||||
const rows = await db.all('SELECT id, recorded_video_path, recorded_video_path_2 FROM recordings');
|
||||
|
||||
for (const row of rows) {
|
||||
const updates = {};
|
||||
|
||||
for (const key of ['recorded_video_path', 'recorded_video_path_2']) {
|
||||
const currentPath = row[key];
|
||||
if (!currentPath || !currentPath.endsWith('.webm')) continue;
|
||||
|
||||
const inputPath = path.join(mediaDir, path.basename(currentPath));
|
||||
if (!fs.existsSync(inputPath)) {
|
||||
console.warn(`Missing file for ${key} on row ${row.id}: ${inputPath}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const outputPath = inputPath.replace(/\.webm$/i, '.mp4');
|
||||
if (!fs.existsSync(outputPath)) {
|
||||
console.log(`Transcoding ${inputPath} -> ${outputPath}`);
|
||||
await transcodeToMp4(inputPath, outputPath);
|
||||
}
|
||||
|
||||
updates[key] = toMediaPath(path.basename(outputPath));
|
||||
fs.unlinkSync(inputPath);
|
||||
}
|
||||
|
||||
if (Object.keys(updates).length > 0) {
|
||||
await db.run(
|
||||
`UPDATE recordings
|
||||
SET recorded_video_path = COALESCE(?, recorded_video_path),
|
||||
recorded_video_path_2 = COALESCE(?, recorded_video_path_2),
|
||||
recorded_video_processing = 0,
|
||||
recorded_video_processing_2 = 0
|
||||
WHERE id = ?`,
|
||||
[updates.recorded_video_path || null, updates.recorded_video_path_2 || null, row.id]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await db.close();
|
||||
console.log('Conversion complete.');
|
||||
}
|
||||
|
||||
run().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
147
server/index.js
147
server/index.js
@@ -7,6 +7,7 @@ import multer from 'multer';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import fs from 'fs';
|
||||
import { spawn } from 'child_process';
|
||||
|
||||
// Generate a unique link ID
|
||||
const generateUniqueId = () => {
|
||||
@@ -17,7 +18,7 @@ const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
const app = express();
|
||||
const PORT = 3001;
|
||||
const PORT = process.env.PORT ? Number(process.env.PORT) : 3005;
|
||||
|
||||
// Middleware
|
||||
app.use(cors());
|
||||
@@ -30,13 +31,22 @@ if (!fs.existsSync(mediaDir)) {
|
||||
}
|
||||
|
||||
// Configure multer for video uploads
|
||||
const getExtensionForMime = (mimetype) => {
|
||||
if (!mimetype) return '.webm';
|
||||
if (mimetype.includes('mp4')) return '.mp4';
|
||||
if (mimetype.includes('webm')) return '.webm';
|
||||
if (mimetype.includes('quicktime')) return '.mov';
|
||||
return '.webm';
|
||||
};
|
||||
|
||||
const storage = multer.diskStorage({
|
||||
destination: (req, file, cb) => {
|
||||
cb(null, mediaDir);
|
||||
},
|
||||
filename: (req, file, cb) => {
|
||||
const timestamp = Date.now();
|
||||
const safeName = `recording_${timestamp}.webm`;
|
||||
const ext = getExtensionForMime(file.mimetype);
|
||||
const safeName = `recording_${timestamp}${ext}`;
|
||||
cb(null, safeName);
|
||||
}
|
||||
});
|
||||
@@ -56,6 +66,61 @@ const upload = multer({
|
||||
// Initialize SQLite database
|
||||
let db;
|
||||
|
||||
const transcodeToMp4 = (inputPath, outputPath) => new Promise((resolve, reject) => {
|
||||
const args = [
|
||||
'-y',
|
||||
'-i', inputPath,
|
||||
'-vf', 'scale=trunc(iw/2)*2:trunc(ih/2)*2',
|
||||
'-c:v', 'libx264',
|
||||
'-profile:v', 'baseline',
|
||||
'-level', '3.0',
|
||||
'-pix_fmt', 'yuv420p',
|
||||
'-c:a', 'aac',
|
||||
'-b:a', '128k',
|
||||
'-movflags', '+faststart',
|
||||
outputPath
|
||||
];
|
||||
|
||||
const ffmpeg = spawn('ffmpeg', args, { stdio: 'ignore' });
|
||||
ffmpeg.on('error', reject);
|
||||
ffmpeg.on('close', (code) => {
|
||||
if (code === 0) {
|
||||
resolve();
|
||||
} else {
|
||||
reject(new Error(`ffmpeg exited with code ${code}`));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const isMp4Upload = (file) => {
|
||||
if (!file) return false;
|
||||
if (file.mimetype && file.mimetype.includes('mp4')) return true;
|
||||
return path.extname(file.filename).toLowerCase() === '.mp4';
|
||||
};
|
||||
|
||||
const transcodeQueue = [];
|
||||
let isTranscoding = false;
|
||||
|
||||
const processTranscodeQueue = async () => {
|
||||
if (isTranscoding) return;
|
||||
const job = transcodeQueue.shift();
|
||||
if (!job) return;
|
||||
isTranscoding = true;
|
||||
try {
|
||||
await job();
|
||||
} catch (error) {
|
||||
console.error('Transcode job failed:', error);
|
||||
} finally {
|
||||
isTranscoding = false;
|
||||
processTranscodeQueue();
|
||||
}
|
||||
};
|
||||
|
||||
const enqueueTranscode = (job) => {
|
||||
transcodeQueue.push(job);
|
||||
processTranscodeQueue();
|
||||
};
|
||||
|
||||
async function initializeDatabase() {
|
||||
db = await open({
|
||||
filename: path.join(__dirname, 'signsync.db'),
|
||||
@@ -69,14 +134,32 @@ async function initializeDatabase() {
|
||||
name TEXT NOT NULL,
|
||||
email TEXT NOT NULL,
|
||||
recorded_video_path TEXT NOT NULL,
|
||||
recorded_video_processing INTEGER DEFAULT 0,
|
||||
youtube_video_url TEXT NOT NULL,
|
||||
name_2 TEXT,
|
||||
email_2 TEXT,
|
||||
recorded_video_path_2 TEXT,
|
||||
recorded_video_processing_2 INTEGER DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`);
|
||||
|
||||
const addColumnIfMissing = async (columnDef) => {
|
||||
try {
|
||||
await db.exec(`ALTER TABLE recordings ADD COLUMN ${columnDef}`);
|
||||
} catch (error) {
|
||||
if (!String(error.message).toLowerCase().includes('duplicate column')) {
|
||||
console.error('Error adding column:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
await addColumnIfMissing('recorded_video_processing INTEGER DEFAULT 0');
|
||||
await addColumnIfMissing('recorded_video_processing_2 INTEGER DEFAULT 0');
|
||||
|
||||
await db.run('UPDATE recordings SET recorded_video_processing = 0 WHERE recorded_video_processing IS NULL');
|
||||
await db.run('UPDATE recordings SET recorded_video_processing_2 = 0 WHERE recorded_video_processing_2 IS NULL');
|
||||
|
||||
console.log('Database initialized');
|
||||
}
|
||||
|
||||
@@ -95,11 +178,13 @@ app.post('/api/recordings', upload.single('video'), async (req, res) => {
|
||||
|
||||
const recordedVideoPath = `/media/${req.file.filename}`;
|
||||
const uniqueLink = generateUniqueId();
|
||||
const needsTranscode = !isMp4Upload(req.file);
|
||||
const recordedVideoProcessing = needsTranscode ? 1 : 0;
|
||||
|
||||
const result = await db.run(
|
||||
`INSERT INTO recordings (unique_link, name, email, recorded_video_path, youtube_video_url)
|
||||
VALUES (?, ?, ?, ?, ?)`,
|
||||
[uniqueLink, name, email, recordedVideoPath, youtubeVideoUrl || '']
|
||||
`INSERT INTO recordings (unique_link, name, email, recorded_video_path, recorded_video_processing, youtube_video_url)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
[uniqueLink, name, email, recordedVideoPath, recordedVideoProcessing, youtubeVideoUrl || '']
|
||||
);
|
||||
|
||||
res.json({
|
||||
@@ -107,8 +192,30 @@ app.post('/api/recordings', upload.single('video'), async (req, res) => {
|
||||
id: result.lastID,
|
||||
uniqueLink,
|
||||
recordedVideoPath,
|
||||
recordedVideoProcessing,
|
||||
message: 'Recording saved successfully'
|
||||
});
|
||||
|
||||
if (needsTranscode) {
|
||||
const inputPath = req.file.path;
|
||||
const outputPath = inputPath.replace(/\.[^.]+$/, '.mp4');
|
||||
enqueueTranscode(async () => {
|
||||
try {
|
||||
await transcodeToMp4(inputPath, outputPath);
|
||||
await db.run(
|
||||
`UPDATE recordings SET recorded_video_path = ?, recorded_video_processing = 0 WHERE unique_link = ?`,
|
||||
[`/media/${path.basename(outputPath)}`, uniqueLink]
|
||||
);
|
||||
fs.unlink(inputPath, () => {});
|
||||
} catch (transcodeError) {
|
||||
console.error('Transcode failed, keeping original file:', transcodeError);
|
||||
await db.run(
|
||||
`UPDATE recordings SET recorded_video_processing = 0 WHERE unique_link = ?`,
|
||||
[uniqueLink]
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error saving recording:', error);
|
||||
res.status(500).json({ error: 'Failed to save recording' });
|
||||
@@ -193,17 +300,43 @@ app.post('/api/share/:uniqueLink/second-video', upload.single('video'), async (r
|
||||
}
|
||||
|
||||
const recordedVideoPath2 = `/media/${req.file.filename}`;
|
||||
const needsTranscode = !isMp4Upload(req.file);
|
||||
const recordedVideoProcessing2 = needsTranscode ? 1 : 0;
|
||||
|
||||
await db.run(
|
||||
`UPDATE recordings SET name_2 = ?, email_2 = ?, recorded_video_path_2 = ? WHERE unique_link = ?`,
|
||||
[name, email, recordedVideoPath2, uniqueLink]
|
||||
`UPDATE recordings
|
||||
SET name_2 = ?, email_2 = ?, recorded_video_path_2 = ?, recorded_video_processing_2 = ?
|
||||
WHERE unique_link = ?`,
|
||||
[name, email, recordedVideoPath2, recordedVideoProcessing2, uniqueLink]
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
recordedVideoPath2,
|
||||
recordedVideoProcessing2,
|
||||
message: 'Second recording saved successfully'
|
||||
});
|
||||
|
||||
if (needsTranscode) {
|
||||
const inputPath = req.file.path;
|
||||
const outputPath = inputPath.replace(/\.[^.]+$/, '.mp4');
|
||||
enqueueTranscode(async () => {
|
||||
try {
|
||||
await transcodeToMp4(inputPath, outputPath);
|
||||
await db.run(
|
||||
`UPDATE recordings SET recorded_video_path_2 = ?, recorded_video_processing_2 = 0 WHERE unique_link = ?`,
|
||||
[`/media/${path.basename(outputPath)}`, uniqueLink]
|
||||
);
|
||||
fs.unlink(inputPath, () => {});
|
||||
} catch (transcodeError) {
|
||||
console.error('Transcode failed, keeping original file:', transcodeError);
|
||||
await db.run(
|
||||
`UPDATE recordings SET recorded_video_processing_2 = 0 WHERE unique_link = ?`,
|
||||
[uniqueLink]
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error saving second recording:', error);
|
||||
res.status(500).json({ error: 'Failed to save second recording' });
|
||||
|
||||
Binary file not shown.
@@ -58,13 +58,19 @@ export default function WebcamRecorder({ onRecordingComplete, onRecordingStart,
|
||||
setRecordedBlob(null);
|
||||
setRecordingTime(0);
|
||||
|
||||
// Use webm format for recording (better browser support)
|
||||
const mimeType = MediaRecorder.isTypeSupported('video/webm;codecs=vp9')
|
||||
? 'video/webm;codecs=vp9'
|
||||
: 'video/webm';
|
||||
// Prefer MP4 on iOS/WebKit, fallback to WebM where supported
|
||||
const preferredTypes = [
|
||||
'video/mp4;codecs="avc1.42E01E,mp4a.40.2"',
|
||||
'video/mp4',
|
||||
'video/webm;codecs=vp9',
|
||||
'video/webm',
|
||||
];
|
||||
const mimeType = preferredTypes.find((type) => MediaRecorder.isTypeSupported(type));
|
||||
|
||||
try {
|
||||
const mediaRecorder = new MediaRecorder(stream, { mimeType });
|
||||
const mediaRecorder = mimeType
|
||||
? new MediaRecorder(stream, { mimeType })
|
||||
: new MediaRecorder(stream);
|
||||
|
||||
mediaRecorder.ondataavailable = (event) => {
|
||||
if (event.data.size > 0) {
|
||||
|
||||
@@ -5,7 +5,14 @@ import WebcamRecorder from './WebcamRecorder';
|
||||
|
||||
const DEFAULT_VIDEO_ID = 'wLM5bzt1xks';
|
||||
const TERP1_HEADSTART = 2; // seconds ahead
|
||||
const API_BASE_URL = 'http://localhost:3001';
|
||||
const BASE_URL = import.meta.env.BASE_URL || '/';
|
||||
const API_BASE_URL = BASE_URL.replace(/\/$/, '');
|
||||
|
||||
const withBase = (assetPath) => {
|
||||
if (!assetPath || assetPath.startsWith('http')) return assetPath;
|
||||
if (BASE_URL === '/' || assetPath.startsWith(BASE_URL)) return assetPath;
|
||||
return `${BASE_URL.replace(/\/$/, '')}${assetPath}`;
|
||||
};
|
||||
|
||||
// Extract video ID from various YouTube URL formats
|
||||
const extractVideoId = (url) => {
|
||||
@@ -58,18 +65,46 @@ export default function YouTubePlayer() {
|
||||
// Video swap state
|
||||
const [videosSwapped, setVideosSwapped] = useState(false);
|
||||
|
||||
const [shareId] = useState(() => {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
return urlParams.get('share');
|
||||
});
|
||||
const isShareView = Boolean(shareId);
|
||||
const [isShareLoading, setIsShareLoading] = useState(isShareView);
|
||||
const [shareLoadError, setShareLoadError] = useState(null);
|
||||
|
||||
// Detect if this is a second user viewing a shared link with no second video yet
|
||||
const isSecondUserPending = useMemo(() => {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
return !!urlParams.get('share') && !!sharedRecording?.recorded_video_path && !sharedRecording?.recorded_video_path_2;
|
||||
}, [sharedRecording]);
|
||||
return isShareView && !!sharedRecording?.recorded_video_path && !sharedRecording?.recorded_video_path_2;
|
||||
}, [isShareView, sharedRecording]);
|
||||
|
||||
const canPlayWebm = useMemo(() => {
|
||||
try {
|
||||
const testVideo = document.createElement('video');
|
||||
return Boolean(
|
||||
testVideo.canPlayType('video/webm; codecs="vp9,opus"') ||
|
||||
testVideo.canPlayType('video/webm')
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const getVideoStatus = useCallback((path, isProcessing) => {
|
||||
if (!path) return { src: null, status: 'missing' };
|
||||
const cleanPath = path.split('?')[0].toLowerCase();
|
||||
const isWebm = cleanPath.endsWith('.webm');
|
||||
if (isWebm && !canPlayWebm) {
|
||||
return { src: null, status: isProcessing ? 'processing' : 'unsupported' };
|
||||
}
|
||||
return { src: path, status: 'ready' };
|
||||
}, [canPlayWebm]);
|
||||
|
||||
// Check for shared recording in URL
|
||||
useEffect(() => {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const shareId = urlParams.get('share');
|
||||
|
||||
if (shareId) {
|
||||
setIsShareLoading(true);
|
||||
setShareLoadError(null);
|
||||
// Fetch the shared recording
|
||||
fetch(`${API_BASE_URL}/api/share/${shareId}`)
|
||||
.then(res => res.json())
|
||||
@@ -85,9 +120,19 @@ export default function YouTubePlayer() {
|
||||
youtubePlayerRef.current.cueVideoById(videoId);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
setSharedRecording(null);
|
||||
setShareLoadError(data?.error || 'Recording not found');
|
||||
}
|
||||
})
|
||||
.catch(err => console.error('Error fetching shared recording:', err));
|
||||
.catch(err => {
|
||||
console.error('Error fetching shared recording:', err);
|
||||
setSharedRecording(null);
|
||||
setShareLoadError('Failed to load shared recording');
|
||||
})
|
||||
.finally(() => setIsShareLoading(false));
|
||||
} else {
|
||||
setIsShareLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
@@ -101,6 +146,38 @@ export default function YouTubePlayer() {
|
||||
}
|
||||
}, [isReady, sharedRecording]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!sharedRecording?.unique_link) return;
|
||||
if (!sharedRecording.recorded_video_processing && !sharedRecording.recorded_video_processing_2) return;
|
||||
|
||||
let timeoutId;
|
||||
let isCancelled = false;
|
||||
|
||||
const poll = async () => {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/api/share/${sharedRecording.unique_link}`);
|
||||
const data = await response.json();
|
||||
if (!isCancelled && data && !data.error) {
|
||||
setSharedRecording(data);
|
||||
if (data.recorded_video_processing || data.recorded_video_processing_2) {
|
||||
timeoutId = setTimeout(poll, 5000);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (!isCancelled) {
|
||||
timeoutId = setTimeout(poll, 5000);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
timeoutId = setTimeout(poll, 5000);
|
||||
|
||||
return () => {
|
||||
isCancelled = true;
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
};
|
||||
}, [sharedRecording?.unique_link, sharedRecording?.recorded_video_processing, sharedRecording?.recorded_video_processing_2]);
|
||||
|
||||
// Load video with retry — newly uploaded files may not be served immediately.
|
||||
// Uses native error events (not React synthetic) to guarantee they fire for media errors.
|
||||
const loadWithRetry = useCallback((videoEl) => {
|
||||
@@ -251,9 +328,11 @@ export default function YouTubePlayer() {
|
||||
|
||||
const playAllLocalVideos = () => {
|
||||
if (localVideo1Ref.current) {
|
||||
localVideo1Ref.current.muted = true;
|
||||
localVideo1Ref.current.play();
|
||||
}
|
||||
if (localVideo2Ref.current) {
|
||||
localVideo2Ref.current.muted = true;
|
||||
localVideo2Ref.current.play();
|
||||
}
|
||||
};
|
||||
@@ -430,9 +509,12 @@ export default function YouTubePlayer() {
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
const blobType = recordedBlob?.type || '';
|
||||
const extension = blobType.includes('mp4') ? 'mp4' : blobType.includes('webm') ? 'webm' : 'webm';
|
||||
|
||||
formData.append('name', name);
|
||||
formData.append('email', email);
|
||||
formData.append('video', recordedBlob, 'recording.webm');
|
||||
formData.append('video', recordedBlob, `recording.${extension}`);
|
||||
|
||||
// Check if we're adding a second video to a shared recording
|
||||
// Must have a share parameter in URL and sharedRecording loaded without a second video
|
||||
@@ -517,6 +599,25 @@ export default function YouTubePlayer() {
|
||||
}
|
||||
};
|
||||
|
||||
const recordedVideoProcessing = Number(sharedRecording?.recorded_video_processing) === 1;
|
||||
const recordedVideoProcessing2 = Number(sharedRecording?.recorded_video_processing_2) === 1;
|
||||
const baseVideo1 = getVideoStatus(sharedRecording?.recorded_video_path, recordedVideoProcessing);
|
||||
const baseVideo2 = getVideoStatus(sharedRecording?.recorded_video_path_2, recordedVideoProcessing2);
|
||||
const panel1Video = videosSwapped ? baseVideo2 : baseVideo1;
|
||||
const panel2Video = videosSwapped ? baseVideo1 : baseVideo2;
|
||||
const isProcessingPlayback = baseVideo1.status === 'processing' || baseVideo2.status === 'processing';
|
||||
const isUnsupportedPlayback = Boolean(sharedRecording && (
|
||||
(sharedRecording.recorded_video_path && baseVideo1.status === 'unsupported') ||
|
||||
(sharedRecording.recorded_video_path_2 && baseVideo2.status === 'unsupported')
|
||||
));
|
||||
const shouldShowRecorder = !isShareView || (!isShareLoading && sharedRecording && !sharedRecording.recorded_video_path_2);
|
||||
const isPlaybackBlocked = isSecondUserPending || (
|
||||
sharedRecording && (
|
||||
(sharedRecording.recorded_video_path && baseVideo1.status !== 'ready') ||
|
||||
(sharedRecording.recorded_video_path_2 && baseVideo2.status !== 'ready')
|
||||
)
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="player-container">
|
||||
{/* YouTube URL Input */}
|
||||
@@ -535,6 +636,12 @@ export default function YouTubePlayer() {
|
||||
</div>
|
||||
|
||||
{videoTitle && <h1 className="video-title">{videoTitle} ({formatTime(duration)})</h1>}
|
||||
{isShareView && isShareLoading && (
|
||||
<p className="loading-text">Loading shared recording...</p>
|
||||
)}
|
||||
{isShareView && !isShareLoading && shareLoadError && (
|
||||
<p className="loading-text">{shareLoadError}</p>
|
||||
)}
|
||||
<div className="video-section">
|
||||
<div className="video-wrapper">
|
||||
<div id="youtube-player"></div>
|
||||
@@ -542,7 +649,7 @@ export default function YouTubePlayer() {
|
||||
</div>
|
||||
|
||||
{/* User Form and Webcam Recorder Section - hide when both recordings exist */}
|
||||
{!(sharedRecording?.recorded_video_path && sharedRecording?.recorded_video_path_2) && (
|
||||
{shouldShowRecorder && (
|
||||
<div className="user-recording-section">
|
||||
<div className="user-form-container">
|
||||
<UserForm onSubmit={handleFormSubmit} isSubmitting={isSubmitting} />
|
||||
@@ -593,7 +700,7 @@ export default function YouTubePlayer() {
|
||||
step="0.1"
|
||||
value={currentTime}
|
||||
onChange={handleSeek}
|
||||
disabled={!isReady || isSecondUserPending}
|
||||
disabled={!isReady || isPlaybackBlocked}
|
||||
/>
|
||||
<span className="time-display">{formatTime(duration)}</span>
|
||||
</div>
|
||||
@@ -614,36 +721,30 @@ export default function YouTubePlayer() {
|
||||
<div className="blur-overlay-text">Recording done, waiting for other person...</div>
|
||||
)}
|
||||
{sharedRecording ? (
|
||||
videosSwapped ? (
|
||||
sharedRecording.recorded_video_path_2 ? (
|
||||
(videosSwapped ? sharedRecording.recorded_video_path_2 : sharedRecording.recorded_video_path) ? (
|
||||
panel1Video.status === 'ready' ? (
|
||||
<video
|
||||
ref={video1RefCallback}
|
||||
ref={isSecondUserPending ? undefined : video1RefCallback}
|
||||
className="local-video"
|
||||
onPlay={handleLocalVideoPlay}
|
||||
onPause={handleLocalVideoPause}
|
||||
onLoadedMetadata={handleVideo1LoadedMetadata}
|
||||
key={`shared-2-swapped-${sharedRecording.id}-${sharedRecording.recorded_video_path_2}`}
|
||||
src={sharedRecording.recorded_video_path_2}
|
||||
playsInline
|
||||
muted
|
||||
onPlay={isSecondUserPending ? undefined : handleLocalVideoPlay}
|
||||
onPause={isSecondUserPending ? undefined : handleLocalVideoPause}
|
||||
onLoadedMetadata={isSecondUserPending ? undefined : handleVideo1LoadedMetadata}
|
||||
key={`shared-1-${videosSwapped ? 'swapped' : 'primary'}-${sharedRecording.id}-${panel1Video.src}`}
|
||||
src={withBase(panel1Video.src)}
|
||||
>
|
||||
Your browser does not support the video tag.
|
||||
</video>
|
||||
) : (
|
||||
<div className="no-video-placeholder">
|
||||
<p>No recording yet</p>
|
||||
<p>{panel1Video.status === 'processing' ? 'Processing video for mobile playback...' : 'This recording is not playable in this browser yet.'}</p>
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<video
|
||||
ref={isSecondUserPending ? undefined : video1RefCallback}
|
||||
className="local-video"
|
||||
onPlay={isSecondUserPending ? undefined : handleLocalVideoPlay}
|
||||
onPause={isSecondUserPending ? undefined : handleLocalVideoPause}
|
||||
onLoadedMetadata={isSecondUserPending ? undefined : handleVideo1LoadedMetadata}
|
||||
key={`shared-${sharedRecording.id}-${sharedRecording.recorded_video_path}`}
|
||||
src={sharedRecording.recorded_video_path}
|
||||
>
|
||||
Your browser does not support the video tag.
|
||||
</video>
|
||||
<div className="no-video-placeholder">
|
||||
<p>No recording yet</p>
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<div className="no-video-placeholder">
|
||||
@@ -673,29 +774,24 @@ export default function YouTubePlayer() {
|
||||
</p>
|
||||
</div>
|
||||
<div className="video-wrapper">
|
||||
{sharedRecording?.recorded_video_path_2 ? (
|
||||
videosSwapped ? (
|
||||
{(videosSwapped ? sharedRecording?.recorded_video_path : sharedRecording?.recorded_video_path_2) ? (
|
||||
panel2Video.status === 'ready' ? (
|
||||
<video
|
||||
ref={video2RefCallback}
|
||||
className="local-video"
|
||||
playsInline
|
||||
muted
|
||||
onPlay={handleLocalVideoPlay}
|
||||
onPause={handleLocalVideoPause}
|
||||
key={`shared-1-swapped-${sharedRecording.id}-${sharedRecording.recorded_video_path}`}
|
||||
src={sharedRecording.recorded_video_path}
|
||||
key={`shared-2-${videosSwapped ? 'swapped' : 'primary'}-${sharedRecording.id}-${panel2Video.src}`}
|
||||
src={withBase(panel2Video.src)}
|
||||
>
|
||||
Your browser does not support the video tag.
|
||||
</video>
|
||||
) : (
|
||||
<video
|
||||
ref={video2RefCallback}
|
||||
className="local-video"
|
||||
onPlay={handleLocalVideoPlay}
|
||||
onPause={handleLocalVideoPause}
|
||||
key={`shared-2-${sharedRecording.id}-${sharedRecording.recorded_video_path_2}`}
|
||||
src={sharedRecording.recorded_video_path_2}
|
||||
>
|
||||
Your browser does not support the video tag.
|
||||
</video>
|
||||
<div className="no-video-placeholder">
|
||||
<p>{panel2Video.status === 'processing' ? 'Processing video for mobile playback...' : 'This recording is not playable in this browser yet.'}</p>
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<div className="no-video-placeholder">
|
||||
@@ -710,11 +806,17 @@ export default function YouTubePlayer() {
|
||||
<button
|
||||
className="control-btn toggle-btn"
|
||||
onClick={handleToggle}
|
||||
disabled={!isReady || isSecondUserPending}
|
||||
disabled={!isReady || isPlaybackBlocked}
|
||||
>
|
||||
{isPlaying ? '⏸ Pause' : '▶ Play'}
|
||||
</button>
|
||||
</div>
|
||||
{isProcessingPlayback && (
|
||||
<p className="loading-text">Processing video for mobile playback. This may take a few minutes for long recordings.</p>
|
||||
)}
|
||||
{!isProcessingPlayback && isUnsupportedPlayback && (
|
||||
<p className="loading-text">This browser can't play one of the recordings yet. Please wait for conversion or use a different browser.</p>
|
||||
)}
|
||||
{!isReady && <p className="loading-text">Loading player...</p>}
|
||||
</div>
|
||||
);
|
||||
|
||||
4
urls.txt
Normal file
4
urls.txt
Normal file
@@ -0,0 +1,4 @@
|
||||
Both showing
|
||||
https://zappy.jaredlog.com/signsync/?share=c9322176fcb96a9b
|
||||
Only Ben showing
|
||||
https://zappy.jaredlog.com/signsync/?share=fbdaf2e777047855
|
||||
@@ -2,6 +2,15 @@ import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
export default defineConfig(({ mode }) => ({
|
||||
base: mode === 'production' ? '/signsync/' : '/',
|
||||
plugins: [react()],
|
||||
})
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:3005',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
Reference in New Issue
Block a user