fixed choppy video on iPhone - mp4 encoding

This commit is contained in:
2026-02-08 18:04:34 +00:00
parent 8896aced3e
commit ac95ee9060
22 changed files with 539 additions and 60 deletions

BIN
public/media/andy.mp4 Normal file

Binary file not shown.

Binary file not shown.

BIN
public/media/ben.mp4 Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View 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
View 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);
});

View File

@@ -7,6 +7,7 @@ import multer from 'multer';
import path from 'path'; import path from 'path';
import { fileURLToPath } from 'url'; import { fileURLToPath } from 'url';
import fs from 'fs'; import fs from 'fs';
import { spawn } from 'child_process';
// Generate a unique link ID // Generate a unique link ID
const generateUniqueId = () => { const generateUniqueId = () => {
@@ -17,7 +18,7 @@ const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename); const __dirname = path.dirname(__filename);
const app = express(); const app = express();
const PORT = 3001; const PORT = process.env.PORT ? Number(process.env.PORT) : 3005;
// Middleware // Middleware
app.use(cors()); app.use(cors());
@@ -30,13 +31,22 @@ if (!fs.existsSync(mediaDir)) {
} }
// Configure multer for video uploads // 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({ const storage = multer.diskStorage({
destination: (req, file, cb) => { destination: (req, file, cb) => {
cb(null, mediaDir); cb(null, mediaDir);
}, },
filename: (req, file, cb) => { filename: (req, file, cb) => {
const timestamp = Date.now(); const timestamp = Date.now();
const safeName = `recording_${timestamp}.webm`; const ext = getExtensionForMime(file.mimetype);
const safeName = `recording_${timestamp}${ext}`;
cb(null, safeName); cb(null, safeName);
} }
}); });
@@ -56,6 +66,61 @@ const upload = multer({
// Initialize SQLite database // Initialize SQLite database
let db; 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() { async function initializeDatabase() {
db = await open({ db = await open({
filename: path.join(__dirname, 'signsync.db'), filename: path.join(__dirname, 'signsync.db'),
@@ -69,14 +134,32 @@ async function initializeDatabase() {
name TEXT NOT NULL, name TEXT NOT NULL,
email TEXT NOT NULL, email TEXT NOT NULL,
recorded_video_path TEXT NOT NULL, recorded_video_path TEXT NOT NULL,
recorded_video_processing INTEGER DEFAULT 0,
youtube_video_url TEXT NOT NULL, youtube_video_url TEXT NOT NULL,
name_2 TEXT, name_2 TEXT,
email_2 TEXT, email_2 TEXT,
recorded_video_path_2 TEXT, recorded_video_path_2 TEXT,
recorded_video_processing_2 INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP 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'); 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 recordedVideoPath = `/media/${req.file.filename}`;
const uniqueLink = generateUniqueId(); const uniqueLink = generateUniqueId();
const needsTranscode = !isMp4Upload(req.file);
const recordedVideoProcessing = needsTranscode ? 1 : 0;
const result = await db.run( const result = await db.run(
`INSERT INTO recordings (unique_link, name, email, recorded_video_path, youtube_video_url) `INSERT INTO recordings (unique_link, name, email, recorded_video_path, recorded_video_processing, youtube_video_url)
VALUES (?, ?, ?, ?, ?)`, VALUES (?, ?, ?, ?, ?, ?)`,
[uniqueLink, name, email, recordedVideoPath, youtubeVideoUrl || ''] [uniqueLink, name, email, recordedVideoPath, recordedVideoProcessing, youtubeVideoUrl || '']
); );
res.json({ res.json({
@@ -107,8 +192,30 @@ app.post('/api/recordings', upload.single('video'), async (req, res) => {
id: result.lastID, id: result.lastID,
uniqueLink, uniqueLink,
recordedVideoPath, recordedVideoPath,
recordedVideoProcessing,
message: 'Recording saved successfully' 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) { } catch (error) {
console.error('Error saving recording:', error); console.error('Error saving recording:', error);
res.status(500).json({ error: 'Failed to save recording' }); 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 recordedVideoPath2 = `/media/${req.file.filename}`;
const needsTranscode = !isMp4Upload(req.file);
const recordedVideoProcessing2 = needsTranscode ? 1 : 0;
await db.run( await db.run(
`UPDATE recordings SET name_2 = ?, email_2 = ?, recorded_video_path_2 = ? WHERE unique_link = ?`, `UPDATE recordings
[name, email, recordedVideoPath2, uniqueLink] SET name_2 = ?, email_2 = ?, recorded_video_path_2 = ?, recorded_video_processing_2 = ?
WHERE unique_link = ?`,
[name, email, recordedVideoPath2, recordedVideoProcessing2, uniqueLink]
); );
res.json({ res.json({
success: true, success: true,
recordedVideoPath2, recordedVideoPath2,
recordedVideoProcessing2,
message: 'Second recording saved successfully' 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) { } catch (error) {
console.error('Error saving second recording:', error); console.error('Error saving second recording:', error);
res.status(500).json({ error: 'Failed to save second recording' }); res.status(500).json({ error: 'Failed to save second recording' });

Binary file not shown.

View File

@@ -58,13 +58,19 @@ export default function WebcamRecorder({ onRecordingComplete, onRecordingStart,
setRecordedBlob(null); setRecordedBlob(null);
setRecordingTime(0); setRecordingTime(0);
// Use webm format for recording (better browser support) // Prefer MP4 on iOS/WebKit, fallback to WebM where supported
const mimeType = MediaRecorder.isTypeSupported('video/webm;codecs=vp9') const preferredTypes = [
? 'video/webm;codecs=vp9' 'video/mp4;codecs="avc1.42E01E,mp4a.40.2"',
: 'video/webm'; 'video/mp4',
'video/webm;codecs=vp9',
'video/webm',
];
const mimeType = preferredTypes.find((type) => MediaRecorder.isTypeSupported(type));
try { try {
const mediaRecorder = new MediaRecorder(stream, { mimeType }); const mediaRecorder = mimeType
? new MediaRecorder(stream, { mimeType })
: new MediaRecorder(stream);
mediaRecorder.ondataavailable = (event) => { mediaRecorder.ondataavailable = (event) => {
if (event.data.size > 0) { if (event.data.size > 0) {

View File

@@ -5,7 +5,14 @@ import WebcamRecorder from './WebcamRecorder';
const DEFAULT_VIDEO_ID = 'wLM5bzt1xks'; const DEFAULT_VIDEO_ID = 'wLM5bzt1xks';
const TERP1_HEADSTART = 2; // seconds ahead 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 // Extract video ID from various YouTube URL formats
const extractVideoId = (url) => { const extractVideoId = (url) => {
@@ -58,18 +65,46 @@ export default function YouTubePlayer() {
// Video swap state // Video swap state
const [videosSwapped, setVideosSwapped] = useState(false); 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 // Detect if this is a second user viewing a shared link with no second video yet
const isSecondUserPending = useMemo(() => { const isSecondUserPending = useMemo(() => {
const urlParams = new URLSearchParams(window.location.search); return isShareView && !!sharedRecording?.recorded_video_path && !sharedRecording?.recorded_video_path_2;
return !!urlParams.get('share') && !!sharedRecording?.recorded_video_path && !sharedRecording?.recorded_video_path_2; }, [isShareView, sharedRecording]);
}, [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 // Check for shared recording in URL
useEffect(() => { useEffect(() => {
const urlParams = new URLSearchParams(window.location.search);
const shareId = urlParams.get('share');
if (shareId) { if (shareId) {
setIsShareLoading(true);
setShareLoadError(null);
// Fetch the shared recording // Fetch the shared recording
fetch(`${API_BASE_URL}/api/share/${shareId}`) fetch(`${API_BASE_URL}/api/share/${shareId}`)
.then(res => res.json()) .then(res => res.json())
@@ -85,9 +120,19 @@ export default function YouTubePlayer() {
youtubePlayerRef.current.cueVideoById(videoId); 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]); }, [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. // 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. // Uses native error events (not React synthetic) to guarantee they fire for media errors.
const loadWithRetry = useCallback((videoEl) => { const loadWithRetry = useCallback((videoEl) => {
@@ -251,9 +328,11 @@ export default function YouTubePlayer() {
const playAllLocalVideos = () => { const playAllLocalVideos = () => {
if (localVideo1Ref.current) { if (localVideo1Ref.current) {
localVideo1Ref.current.muted = true;
localVideo1Ref.current.play(); localVideo1Ref.current.play();
} }
if (localVideo2Ref.current) { if (localVideo2Ref.current) {
localVideo2Ref.current.muted = true;
localVideo2Ref.current.play(); localVideo2Ref.current.play();
} }
}; };
@@ -430,9 +509,12 @@ export default function YouTubePlayer() {
try { try {
const formData = new FormData(); 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('name', name);
formData.append('email', email); 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 // 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 // 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 ( return (
<div className="player-container"> <div className="player-container">
{/* YouTube URL Input */} {/* YouTube URL Input */}
@@ -535,6 +636,12 @@ export default function YouTubePlayer() {
</div> </div>
{videoTitle && <h1 className="video-title">{videoTitle} ({formatTime(duration)})</h1>} {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-section">
<div className="video-wrapper"> <div className="video-wrapper">
<div id="youtube-player"></div> <div id="youtube-player"></div>
@@ -542,7 +649,7 @@ export default function YouTubePlayer() {
</div> </div>
{/* User Form and Webcam Recorder Section - hide when both recordings exist */} {/* 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-recording-section">
<div className="user-form-container"> <div className="user-form-container">
<UserForm onSubmit={handleFormSubmit} isSubmitting={isSubmitting} /> <UserForm onSubmit={handleFormSubmit} isSubmitting={isSubmitting} />
@@ -593,7 +700,7 @@ export default function YouTubePlayer() {
step="0.1" step="0.1"
value={currentTime} value={currentTime}
onChange={handleSeek} onChange={handleSeek}
disabled={!isReady || isSecondUserPending} disabled={!isReady || isPlaybackBlocked}
/> />
<span className="time-display">{formatTime(duration)}</span> <span className="time-display">{formatTime(duration)}</span>
</div> </div>
@@ -614,36 +721,30 @@ export default function YouTubePlayer() {
<div className="blur-overlay-text">Recording done, waiting for other person...</div> <div className="blur-overlay-text">Recording done, waiting for other person...</div>
)} )}
{sharedRecording ? ( {sharedRecording ? (
videosSwapped ? ( (videosSwapped ? sharedRecording.recorded_video_path_2 : sharedRecording.recorded_video_path) ? (
sharedRecording.recorded_video_path_2 ? ( panel1Video.status === 'ready' ? (
<video <video
ref={video1RefCallback} ref={isSecondUserPending ? undefined : video1RefCallback}
className="local-video" className="local-video"
onPlay={handleLocalVideoPlay} playsInline
onPause={handleLocalVideoPause} muted
onLoadedMetadata={handleVideo1LoadedMetadata} onPlay={isSecondUserPending ? undefined : handleLocalVideoPlay}
key={`shared-2-swapped-${sharedRecording.id}-${sharedRecording.recorded_video_path_2}`} onPause={isSecondUserPending ? undefined : handleLocalVideoPause}
src={sharedRecording.recorded_video_path_2} 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. Your browser does not support the video tag.
</video> </video>
) : ( ) : (
<div className="no-video-placeholder"> <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> </div>
) )
) : ( ) : (
<video <div className="no-video-placeholder">
ref={isSecondUserPending ? undefined : video1RefCallback} <p>No recording yet</p>
className="local-video" </div>
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"> <div className="no-video-placeholder">
@@ -673,29 +774,24 @@ export default function YouTubePlayer() {
</p> </p>
</div> </div>
<div className="video-wrapper"> <div className="video-wrapper">
{sharedRecording?.recorded_video_path_2 ? ( {(videosSwapped ? sharedRecording?.recorded_video_path : sharedRecording?.recorded_video_path_2) ? (
videosSwapped ? ( panel2Video.status === 'ready' ? (
<video <video
ref={video2RefCallback} ref={video2RefCallback}
className="local-video" className="local-video"
playsInline
muted
onPlay={handleLocalVideoPlay} onPlay={handleLocalVideoPlay}
onPause={handleLocalVideoPause} onPause={handleLocalVideoPause}
key={`shared-1-swapped-${sharedRecording.id}-${sharedRecording.recorded_video_path}`} key={`shared-2-${videosSwapped ? 'swapped' : 'primary'}-${sharedRecording.id}-${panel2Video.src}`}
src={sharedRecording.recorded_video_path} src={withBase(panel2Video.src)}
> >
Your browser does not support the video tag. Your browser does not support the video tag.
</video> </video>
) : ( ) : (
<video <div className="no-video-placeholder">
ref={video2RefCallback} <p>{panel2Video.status === 'processing' ? 'Processing video for mobile playback...' : 'This recording is not playable in this browser yet.'}</p>
className="local-video" </div>
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"> <div className="no-video-placeholder">
@@ -710,11 +806,17 @@ export default function YouTubePlayer() {
<button <button
className="control-btn toggle-btn" className="control-btn toggle-btn"
onClick={handleToggle} onClick={handleToggle}
disabled={!isReady || isSecondUserPending} disabled={!isReady || isPlaybackBlocked}
> >
{isPlaying ? '⏸ Pause' : '▶ Play'} {isPlaying ? '⏸ Pause' : '▶ Play'}
</button> </button>
</div> </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>} {!isReady && <p className="loading-text">Loading player...</p>}
</div> </div>
); );

4
urls.txt Normal file
View File

@@ -0,0 +1,4 @@
Both showing
https://zappy.jaredlog.com/signsync/?share=c9322176fcb96a9b
Only Ben showing
https://zappy.jaredlog.com/signsync/?share=fbdaf2e777047855

View File

@@ -2,6 +2,15 @@ import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react' import react from '@vitejs/plugin-react'
// https://vite.dev/config/ // https://vite.dev/config/
export default defineConfig({ export default defineConfig(({ mode }) => ({
base: mode === 'production' ? '/signsync/' : '/',
plugins: [react()], plugins: [react()],
}) server: {
proxy: {
'/api': {
target: 'http://localhost:3005',
changeOrigin: true,
},
},
},
}))