diff --git a/public/media/andy.mp4 b/public/media/andy.mp4 new file mode 100644 index 0000000..679ffaf Binary files /dev/null and b/public/media/andy.mp4 differ diff --git a/public/media/andy.webm b/public/media/andy.webm deleted file mode 100644 index 804ab1b..0000000 Binary files a/public/media/andy.webm and /dev/null differ diff --git a/public/media/ben.mp4 b/public/media/ben.mp4 new file mode 100644 index 0000000..0b9b60d Binary files /dev/null and b/public/media/ben.mp4 differ diff --git a/public/media/ben.webm b/public/media/ben.webm deleted file mode 100644 index ce86cf2..0000000 Binary files a/public/media/ben.webm and /dev/null differ diff --git a/public/media/recording_1770569104899.mp4 b/public/media/recording_1770569104899.mp4 new file mode 100644 index 0000000..757a2ab Binary files /dev/null and b/public/media/recording_1770569104899.mp4 differ diff --git a/public/media/recording_1770569130681.mp4 b/public/media/recording_1770569130681.mp4 new file mode 100644 index 0000000..7125f4e Binary files /dev/null and b/public/media/recording_1770569130681.mp4 differ diff --git a/public/media/recording_1770571670297.mp4 b/public/media/recording_1770571670297.mp4 new file mode 100644 index 0000000..d3fe887 Binary files /dev/null and b/public/media/recording_1770571670297.mp4 differ diff --git a/public/media/recording_1770571697502.mp4 b/public/media/recording_1770571697502.mp4 new file mode 100644 index 0000000..45c8005 Binary files /dev/null and b/public/media/recording_1770571697502.mp4 differ diff --git a/public/media/recording_1770572219607.mp4 b/public/media/recording_1770572219607.mp4 new file mode 100644 index 0000000..3f33a6e Binary files /dev/null and b/public/media/recording_1770572219607.mp4 differ diff --git a/public/media/recording_1770572287059.mp4 b/public/media/recording_1770572287059.mp4 new file mode 100644 index 0000000..38ebf28 Binary files /dev/null and b/public/media/recording_1770572287059.mp4 differ diff --git a/public/media/recording_1770572628450.mp4 b/public/media/recording_1770572628450.mp4 new file mode 100644 index 0000000..ff20154 Binary files /dev/null and b/public/media/recording_1770572628450.mp4 differ diff --git a/public/media/recording_1770572652336.mp4 b/public/media/recording_1770572652336.mp4 new file mode 100644 index 0000000..f0e3130 Binary files /dev/null and b/public/media/recording_1770572652336.mp4 differ diff --git a/public/media/recording_1770572757674.mp4 b/public/media/recording_1770572757674.mp4 new file mode 100644 index 0000000..d41e67f Binary files /dev/null and b/public/media/recording_1770572757674.mp4 differ diff --git a/public/media/recording_1770572849703.mp4 b/public/media/recording_1770572849703.mp4 new file mode 100644 index 0000000..21eb11d Binary files /dev/null and b/public/media/recording_1770572849703.mp4 differ diff --git a/public/signsync/test/index.html b/public/signsync/test/index.html new file mode 100644 index 0000000..8ef0a73 --- /dev/null +++ b/public/signsync/test/index.html @@ -0,0 +1,121 @@ + + + + + + SignSync Test Videos + + + +

SignSync Test Videos

+
+ +
+

On iPhone, both videos must be muted to play simultaneously. Use individual controls for audio.

+
+
+

/signsync/media/ben.mp4

+ +
+
+

/signsync/media/andy.mp4

+ +
+
+ + + diff --git a/server/convert-webm.js b/server/convert-webm.js new file mode 100644 index 0000000..33cc2df --- /dev/null +++ b/server/convert-webm.js @@ -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); +}); diff --git a/server/index.js b/server/index.js index 9a0183e..5c9c5ee 100644 --- a/server/index.js +++ b/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' }); diff --git a/server/signsync.db b/server/signsync.db index 69bf105..f8bcf43 100644 Binary files a/server/signsync.db and b/server/signsync.db differ diff --git a/src/components/WebcamRecorder.jsx b/src/components/WebcamRecorder.jsx index 0042bd4..caa65de 100644 --- a/src/components/WebcamRecorder.jsx +++ b/src/components/WebcamRecorder.jsx @@ -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) { diff --git a/src/components/YouTubePlayer.jsx b/src/components/YouTubePlayer.jsx index 70a4f57..3ca2be5 100644 --- a/src/components/YouTubePlayer.jsx +++ b/src/components/YouTubePlayer.jsx @@ -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 (
{/* YouTube URL Input */} @@ -535,6 +636,12 @@ export default function YouTubePlayer() {
{videoTitle &&

{videoTitle} ({formatTime(duration)})

} + {isShareView && isShareLoading && ( +

Loading shared recording...

+ )} + {isShareView && !isShareLoading && shareLoadError && ( +

{shareLoadError}

+ )}
@@ -542,7 +649,7 @@ export default function YouTubePlayer() {
{/* User Form and Webcam Recorder Section - hide when both recordings exist */} - {!(sharedRecording?.recorded_video_path && sharedRecording?.recorded_video_path_2) && ( + {shouldShowRecorder && (
@@ -593,7 +700,7 @@ export default function YouTubePlayer() { step="0.1" value={currentTime} onChange={handleSeek} - disabled={!isReady || isSecondUserPending} + disabled={!isReady || isPlaybackBlocked} /> {formatTime(duration)}
@@ -614,36 +721,30 @@ export default function YouTubePlayer() {
Recording done, waiting for other person...
)} {sharedRecording ? ( - videosSwapped ? ( - sharedRecording.recorded_video_path_2 ? ( + (videosSwapped ? sharedRecording.recorded_video_path_2 : sharedRecording.recorded_video_path) ? ( + panel1Video.status === 'ready' ? ( ) : (
-

No recording yet

+

{panel1Video.status === 'processing' ? 'Processing video for mobile playback...' : 'This recording is not playable in this browser yet.'}

) ) : ( - +
+

No recording yet

+
) ) : (
@@ -673,29 +774,24 @@ export default function YouTubePlayer() {

- {sharedRecording?.recorded_video_path_2 ? ( - videosSwapped ? ( + {(videosSwapped ? sharedRecording?.recorded_video_path : sharedRecording?.recorded_video_path_2) ? ( + panel2Video.status === 'ready' ? ( ) : ( - +
+

{panel2Video.status === 'processing' ? 'Processing video for mobile playback...' : 'This recording is not playable in this browser yet.'}

+
) ) : (
@@ -710,11 +806,17 @@ export default function YouTubePlayer() {
+ {isProcessingPlayback && ( +

Processing video for mobile playback. This may take a few minutes for long recordings.

+ )} + {!isProcessingPlayback && isUnsupportedPlayback && ( +

This browser can't play one of the recordings yet. Please wait for conversion or use a different browser.

+ )} {!isReady &&

Loading player...

}
); diff --git a/urls.txt b/urls.txt new file mode 100644 index 0000000..d6f027e --- /dev/null +++ b/urls.txt @@ -0,0 +1,4 @@ +Both showing +https://zappy.jaredlog.com/signsync/?share=c9322176fcb96a9b +Only Ben showing +https://zappy.jaredlog.com/signsync/?share=fbdaf2e777047855 diff --git a/vite.config.js b/vite.config.js index 8b0f57b..5f26a99 100644 --- a/vite.config.js +++ b/vite.config.js @@ -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, + }, + }, + }, +}))