everything

This commit is contained in:
2026-02-04 20:40:22 -05:00
parent 5fd087a4d5
commit 0626e53bfe
21 changed files with 4066 additions and 47 deletions

View File

@@ -0,0 +1,9 @@
{
"permissions": {
"allow": [
"Bash(tree:*)",
"Bash(npm install:*)",
"Bash(npm run build:*)"
]
}
}

View File

@@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>youtube-player</title>
<title>SignSync</title>
</head>
<body>
<div id="root"></div>

2638
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -5,19 +5,27 @@
"type": "module",
"scripts": {
"dev": "vite",
"server": "node server/index.js",
"dev:all": "concurrently \"npm run dev\" \"npm run server\"",
"build": "vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"cors": "^2.8.5",
"express": "^4.21.0",
"multer": "^1.4.5-lts.1",
"react": "^19.2.0",
"react-dom": "^19.2.0"
"react-dom": "^19.2.0",
"sqlite": "^5.1.1",
"sqlite3": "^5.1.7"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@types/react": "^19.2.5",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"concurrently": "^9.0.1",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",

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

@@ -1 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<path fill="#9333ea" d="M16 28.72l-1.45-1.32C6.4 20.18 2 16.22 2 11.5 2 7.42 5.42 4 9.5 4c2.24 0 4.38 1.04 5.75 2.65L16 7.54l.75-.89C18.12 5.04 20.26 4 22.5 4 26.58 4 30 7.42 30 11.5c0 4.72-4.4 8.68-12.55 15.9L16 28.72z"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 293 B

218
server/index.js Normal file
View File

@@ -0,0 +1,218 @@
import express from 'express';
import cors from 'cors';
import crypto from 'crypto';
import sqlite3 from 'sqlite3';
import { open } from 'sqlite';
import multer from 'multer';
import path from 'path';
import { fileURLToPath } from 'url';
import fs from 'fs';
// Generate a unique link ID
const generateUniqueId = () => {
return crypto.randomBytes(8).toString('hex');
};
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const app = express();
const PORT = 3001;
// Middleware
app.use(cors());
app.use(express.json());
// Ensure media directory exists
const mediaDir = path.join(__dirname, '..', 'public', 'media');
if (!fs.existsSync(mediaDir)) {
fs.mkdirSync(mediaDir, { recursive: true });
}
// Configure multer for video uploads
const storage = multer.diskStorage({
destination: (req, file, cb) => {
cb(null, mediaDir);
},
filename: (req, file, cb) => {
const timestamp = Date.now();
const safeName = `recording_${timestamp}.webm`;
cb(null, safeName);
}
});
const upload = multer({
storage,
limits: { fileSize: 100 * 1024 * 1024 }, // 100MB limit
fileFilter: (req, file, cb) => {
if (file.mimetype.startsWith('video/')) {
cb(null, true);
} else {
cb(new Error('Only video files are allowed'));
}
}
});
// Initialize SQLite database
let db;
async function initializeDatabase() {
db = await open({
filename: path.join(__dirname, 'signsync.db'),
driver: sqlite3.Database
});
await db.exec(`
CREATE TABLE IF NOT EXISTS recordings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
unique_link TEXT UNIQUE NOT NULL,
name TEXT NOT NULL,
email TEXT NOT NULL,
recorded_video_path TEXT NOT NULL,
youtube_video_url TEXT NOT NULL,
name_2 TEXT,
email_2 TEXT,
recorded_video_path_2 TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`);
console.log('Database initialized');
}
// API Routes
// Save recording with user info
app.post('/api/recordings', upload.single('video'), async (req, res) => {
try {
const { name, email, youtubeVideoUrl } = req.body;
if (!name || !email || !req.file) {
return res.status(400).json({
error: 'Name, email, and video file are required'
});
}
const recordedVideoPath = `/media/${req.file.filename}`;
const uniqueLink = generateUniqueId();
const result = await db.run(
`INSERT INTO recordings (unique_link, name, email, recorded_video_path, youtube_video_url)
VALUES (?, ?, ?, ?, ?)`,
[uniqueLink, name, email, recordedVideoPath, youtubeVideoUrl || '']
);
res.json({
success: true,
id: result.lastID,
uniqueLink,
recordedVideoPath,
message: 'Recording saved successfully'
});
} catch (error) {
console.error('Error saving recording:', error);
res.status(500).json({ error: 'Failed to save recording' });
}
});
// Get all recordings
app.get('/api/recordings', async (req, res) => {
try {
const recordings = await db.all(
'SELECT * FROM recordings ORDER BY created_at DESC'
);
res.json(recordings);
} catch (error) {
console.error('Error fetching recordings:', error);
res.status(500).json({ error: 'Failed to fetch recordings' });
}
});
// Get recording by ID
app.get('/api/recordings/:id', async (req, res) => {
try {
const recording = await db.get(
'SELECT * FROM recordings WHERE id = ?',
[req.params.id]
);
if (!recording) {
return res.status(404).json({ error: 'Recording not found' });
}
res.json(recording);
} catch (error) {
console.error('Error fetching recording:', error);
res.status(500).json({ error: 'Failed to fetch recording' });
}
});
// Get recording by unique link
app.get('/api/share/:uniqueLink', async (req, res) => {
try {
const recording = await db.get(
'SELECT * FROM recordings WHERE unique_link = ?',
[req.params.uniqueLink]
);
if (!recording) {
return res.status(404).json({ error: 'Recording not found' });
}
res.json(recording);
} catch (error) {
console.error('Error fetching recording:', error);
res.status(500).json({ error: 'Failed to fetch recording' });
}
});
// Add second recording to an existing shared recording
app.post('/api/share/:uniqueLink/second-video', upload.single('video'), async (req, res) => {
try {
const { name, email } = req.body;
const { uniqueLink } = req.params;
if (!name || !email || !req.file) {
return res.status(400).json({
error: 'Name, email, and video file are required'
});
}
// Check if recording exists
const recording = await db.get(
'SELECT * FROM recordings WHERE unique_link = ?',
[uniqueLink]
);
if (!recording) {
return res.status(404).json({ error: 'Recording not found' });
}
if (recording.recorded_video_path_2) {
return res.status(400).json({ error: 'Second video already recorded' });
}
const recordedVideoPath2 = `/media/${req.file.filename}`;
await db.run(
`UPDATE recordings SET name_2 = ?, email_2 = ?, recorded_video_path_2 = ? WHERE unique_link = ?`,
[name, email, recordedVideoPath2, uniqueLink]
);
res.json({
success: true,
recordedVideoPath2,
message: 'Second recording saved successfully'
});
} catch (error) {
console.error('Error saving second recording:', error);
res.status(500).json({ error: 'Failed to save second recording' });
}
});
// Start server
initializeDatabase().then(() => {
app.listen(PORT, () => {
console.log(`Server running on http://localhost:${PORT}`);
});
});

BIN
server/signsync.db Normal file

Binary file not shown.

101
src/components/UserForm.css Normal file
View File

@@ -0,0 +1,101 @@
.user-form {
display: flex;
flex-direction: column;
gap: 15px;
padding: 20px;
background-color: #1a1a1a;
border-radius: 12px;
width: 100%;
max-width: 500px;
}
.form-title {
font-size: 1.2rem;
color: #fff;
margin: 0;
font-weight: 500;
text-align: center;
}
.form-group {
display: flex;
flex-direction: column;
gap: 6px;
}
.form-label {
font-size: 14px;
color: #ccc;
font-weight: 500;
}
.form-input {
padding: 12px 16px;
font-size: 16px;
border: 2px solid #3b3b3b;
border-radius: 8px;
background-color: #2a2a2a;
color: #fff;
outline: none;
transition: border-color 0.2s ease;
}
.form-input:focus {
border-color: #2563eb;
}
.form-input::placeholder {
color: #666;
}
.form-input.input-error {
border-color: #e63946;
}
.form-input:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.error-message {
font-size: 12px;
color: #e63946;
}
.submit-btn {
padding: 14px 24px;
font-size: 16px;
font-weight: 600;
border: none;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s ease;
background-color: #2563eb;
color: #fff;
margin-top: 10px;
}
.submit-btn:hover:not(:disabled) {
background-color: #3b82f6;
}
.submit-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
@media (max-width: 600px) {
.user-form {
max-width: 100%;
}
.form-input {
padding: 10px 14px;
font-size: 14px;
}
.submit-btn {
padding: 12px 20px;
font-size: 14px;
}
}

View File

@@ -0,0 +1,79 @@
import { useState } from 'react';
import './UserForm.css';
export default function UserForm({ onSubmit, isSubmitting }) {
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [errors, setErrors] = useState({});
const validateEmail = (email) => {
const re = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return re.test(email);
};
const handleSubmit = (e) => {
e.preventDefault();
const newErrors = {};
if (!name.trim()) {
newErrors.name = 'Name is required';
}
if (!email.trim()) {
newErrors.email = 'Email is required';
} else if (!validateEmail(email)) {
newErrors.email = 'Please enter a valid email address';
}
if (Object.keys(newErrors).length > 0) {
setErrors(newErrors);
return;
}
setErrors({});
onSubmit({ name: name.trim(), email: email.trim() });
};
return (
<form className="user-form" onSubmit={handleSubmit}>
<h2 className="form-title">Your Information</h2>
<div className="form-group">
<label htmlFor="name" className="form-label">Name</label>
<input
type="text"
id="name"
className={`form-input ${errors.name ? 'input-error' : ''}`}
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Enter your name"
disabled={isSubmitting}
/>
{errors.name && <span className="error-message">{errors.name}</span>}
</div>
<div className="form-group">
<label htmlFor="email" className="form-label">Email</label>
<input
type="email"
id="email"
className={`form-input ${errors.email ? 'input-error' : ''}`}
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="Enter your email"
disabled={isSubmitting}
/>
{errors.email && <span className="error-message">{errors.email}</span>}
</div>
<button
type="submit"
className="submit-btn"
disabled={isSubmitting}
>
{isSubmitting ? 'Saving...' : 'Save Recording'}
</button>
</form>
);
}

View File

@@ -0,0 +1,171 @@
.webcam-recorder {
display: flex;
flex-direction: column;
align-items: center;
gap: 15px;
padding: 20px;
background-color: #1a1a1a;
border-radius: 12px;
width: 100%;
max-width: 500px;
}
.webcam-title {
font-size: 1.2rem;
color: #fff;
margin: 0;
font-weight: 500;
}
.webcam-error {
color: #e63946;
font-size: 0.9rem;
margin: 0;
text-align: center;
}
.webcam-preview {
position: relative;
width: 100%;
aspect-ratio: 4 / 3;
background-color: #000;
border-radius: 8px;
overflow: hidden;
}
.webcam-video {
width: 100%;
height: 100%;
object-fit: cover;
transform: scaleX(-1); /* Mirror the live video */
}
.webcam-video.preview-video {
transform: scaleX(1); /* Don't mirror the preview playback */
}
.recording-indicator {
position: absolute;
top: 10px;
left: 10px;
display: flex;
align-items: center;
gap: 8px;
background-color: rgba(0, 0, 0, 0.7);
padding: 6px 12px;
border-radius: 20px;
}
.recording-dot {
width: 12px;
height: 12px;
background-color: #e63946;
border-radius: 50%;
animation: pulse 1s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
.recording-time {
color: #fff;
font-size: 14px;
font-family: monospace;
}
.webcam-controls {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
}
.webcam-btn {
padding: 12px 24px;
font-size: 16px;
font-weight: 600;
border: none;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s ease;
}
.webcam-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.record-btn {
background-color: #e63946;
color: #fff;
}
.record-btn:hover:not(:disabled) {
background-color: #f25c69;
}
.stop-btn {
background-color: #3b3b3b;
color: #fff;
}
.stop-btn:hover:not(:disabled) {
background-color: #4a4a4a;
}
.recording-actions {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
}
.recording-buttons {
display: flex;
gap: 10px;
}
.recording-ready {
color: #4ade80;
font-size: 14px;
margin: 0;
}
.recording-actions .webcam-btn {
min-width: 140px;
}
.discard-btn {
background-color: #6b7280;
color: #fff;
}
.discard-btn:hover:not(:disabled) {
background-color: #7c8591;
}
.rerecord-btn {
background-color: #2563eb;
color: #fff;
}
.rerecord-btn:hover:not(:disabled) {
background-color: #3b82f6;
}
@media (max-width: 600px) {
.webcam-recorder {
max-width: 100%;
}
.webcam-btn {
padding: 10px 18px;
font-size: 14px;
}
}

View File

@@ -0,0 +1,226 @@
import { useEffect, useRef, useState } from 'react';
import './WebcamRecorder.css';
export default function WebcamRecorder({ onRecordingComplete, onRecordingStart, onRecordingStop, onReRecord, onDiscard }) {
const videoRef = useRef(null);
const previewRef = useRef(null);
const mediaRecorderRef = useRef(null);
const chunksRef = useRef([]);
const [stream, setStream] = useState(null);
const [isRecording, setIsRecording] = useState(false);
const [recordedBlob, setRecordedBlob] = useState(null);
const [previewUrl, setPreviewUrl] = useState(null);
const [error, setError] = useState(null);
const [recordingTime, setRecordingTime] = useState(0);
const timerRef = useRef(null);
useEffect(() => {
startWebcam();
return () => {
stopWebcam();
if (timerRef.current) {
clearInterval(timerRef.current);
}
};
}, []);
const startWebcam = async () => {
try {
const mediaStream = await navigator.mediaDevices.getUserMedia({
video: { width: 640, height: 480 },
audio: true
});
setStream(mediaStream);
if (videoRef.current) {
videoRef.current.srcObject = mediaStream;
}
setError(null);
} catch (err) {
console.error('Error accessing webcam:', err);
setError('Unable to access webcam. Please ensure camera permissions are granted.');
}
};
const stopWebcam = () => {
if (stream) {
stream.getTracks().forEach(track => track.stop());
setStream(null);
}
};
const startRecording = () => {
if (!stream) {
setError('No webcam stream available');
return;
}
chunksRef.current = [];
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';
try {
const mediaRecorder = new MediaRecorder(stream, { mimeType });
mediaRecorder.ondataavailable = (event) => {
if (event.data.size > 0) {
chunksRef.current.push(event.data);
}
};
mediaRecorder.onstop = () => {
const blob = new Blob(chunksRef.current, { type: 'video/webm' });
setRecordedBlob(blob);
// Create preview URL
const url = URL.createObjectURL(blob);
setPreviewUrl(url);
if (onRecordingComplete) {
onRecordingComplete(blob);
}
};
mediaRecorder.start(1000); // Collect data every second
mediaRecorderRef.current = mediaRecorder;
setIsRecording(true);
// Notify parent that recording started
if (onRecordingStart) {
onRecordingStart();
}
// Start timer
timerRef.current = setInterval(() => {
setRecordingTime(prev => prev + 1);
}, 1000);
} catch (err) {
console.error('Error starting recording:', err);
setError('Failed to start recording');
}
};
const stopRecording = () => {
if (mediaRecorderRef.current && isRecording) {
mediaRecorderRef.current.stop();
setIsRecording(false);
// Notify parent that recording stopped
if (onRecordingStop) {
onRecordingStop();
}
if (timerRef.current) {
clearInterval(timerRef.current);
timerRef.current = null;
}
}
};
const formatTime = (seconds) => {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
};
const discardRecording = (skipCallback = false) => {
if (previewUrl) {
URL.revokeObjectURL(previewUrl);
setPreviewUrl(null);
}
setRecordedBlob(null);
setRecordingTime(0);
if (onRecordingComplete) {
onRecordingComplete(null);
}
if (!skipCallback && onDiscard) {
onDiscard();
}
};
return (
<div className="webcam-recorder">
<h2 className="webcam-title">Record Your Interpretation</h2>
{error && <p className="webcam-error">{error}</p>}
<div className="webcam-preview">
{/* Live webcam feed - hidden when showing preview */}
<video
ref={videoRef}
autoPlay
muted
playsInline
className="webcam-video"
style={{ display: previewUrl && !isRecording ? 'none' : 'block' }}
/>
{/* Preview of recorded video */}
{previewUrl && !isRecording && (
<video
ref={previewRef}
src={previewUrl}
controls
playsInline
className="webcam-video preview-video"
/>
)}
{isRecording && (
<div className="recording-indicator">
<span className="recording-dot"></span>
<span className="recording-time">{formatTime(recordingTime)}</span>
</div>
)}
</div>
<div className="webcam-controls">
{!isRecording && !recordedBlob && (
<button
className="webcam-btn record-btn"
onClick={startRecording}
disabled={!stream}
>
Start Recording
</button>
)}
{isRecording && (
<button
className="webcam-btn stop-btn"
onClick={stopRecording}
>
Stop Recording
</button>
)}
{recordedBlob && !isRecording && (
<div className="recording-actions">
<p className="recording-ready">Review your recording ({formatTime(recordingTime)})</p>
<div className="recording-buttons">
<button
className="webcam-btn discard-btn"
onClick={() => discardRecording(false)}
>
🗑 Discard
</button>
<button
className="webcam-btn rerecord-btn"
onClick={() => {
discardRecording(true); // Skip discard callback, use rerecord instead
if (onReRecord) {
onReRecord();
}
startRecording();
}}
>
🔄 Re-record
</button>
</div>
</div>
)}
</div>
</div>
);
}

View File

@@ -5,6 +5,136 @@
gap: 30px;
}
.youtube-url-input {
display: flex;
gap: 10px;
width: 100%;
max-width: 800px;
padding: 10px 0;
}
.url-input {
flex: 1;
padding: 12px 16px;
font-size: 16px;
border: 2px solid #3b3b3b;
border-radius: 8px;
background-color: #2a2a2a;
color: #fff;
outline: none;
transition: border-color 0.2s ease;
}
.url-input:focus {
border-color: #2563eb;
}
.url-input::placeholder {
color: #666;
}
.load-btn {
padding: 12px 24px;
font-size: 16px;
font-weight: 600;
border: none;
border-radius: 8px;
cursor: pointer;
background-color: #2563eb;
color: #fff;
transition: background-color 0.2s ease;
}
.load-btn:hover {
background-color: #3b82f6;
}
.user-recording-section {
display: flex;
gap: 30px;
width: 100%;
max-width: 1100px;
justify-content: center;
flex-wrap: wrap;
padding: 20px;
background-color: #0d0d0d;
border-radius: 16px;
margin-bottom: 20px;
}
.user-form-container {
display: flex;
flex-direction: column;
gap: 10px;
}
.submit-message {
text-align: center;
padding: 10px;
border-radius: 8px;
font-size: 14px;
margin: 0;
}
.submit-message.success {
background-color: rgba(74, 222, 128, 0.1);
color: #4ade80;
}
.submit-message.error {
background-color: rgba(230, 57, 70, 0.1);
color: #e63946;
}
.share-link-container {
display: flex;
flex-direction: column;
gap: 8px;
padding: 15px;
background-color: rgba(37, 99, 235, 0.1);
border-radius: 8px;
margin-top: 10px;
}
.share-link-label {
color: #fff;
font-size: 14px;
font-weight: 500;
margin: 0;
}
.share-link-input {
padding: 10px 12px;
font-size: 14px;
border: 2px solid #3b3b3b;
border-radius: 6px;
background-color: #2a2a2a;
color: #fff;
outline: none;
width: 100%;
box-sizing: border-box;
}
.share-link-input:focus {
border-color: #2563eb;
}
.copy-link-btn {
padding: 10px 16px;
font-size: 14px;
font-weight: 600;
border: none;
border-radius: 6px;
cursor: pointer;
background-color: #2563eb;
color: #fff;
transition: background-color 0.2s ease;
}
.copy-link-btn:hover {
background-color: #3b82f6;
}
.video-title {
font-size: 1.5rem;
color: #333;
@@ -61,6 +191,17 @@
object-fit: contain;
}
.no-video-placeholder {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background-color: #1a1a1a;
color: #666;
font-style: italic;
}
.timeline-container {
display: flex;
align-items: center;
@@ -126,17 +267,47 @@
display: flex;
gap: 20px;
width: 100%;
max-width: 800px;
max-width: 900px;
justify-content: center;
align-items: center;
}
.swap-btn {
display: flex;
align-items: center;
justify-content: center;
width: 50px;
height: 50px;
font-size: 24px;
border: none;
border-radius: 50%;
background-color: #2563eb;
color: #fff;
cursor: pointer;
transition: all 0.2s ease;
flex-shrink: 0;
}
.swap-btn:hover:not(:disabled) {
background-color: #3b82f6;
transform: scale(1.1);
}
.swap-btn:disabled {
background-color: #3b3b3b;
color: #666;
cursor: not-allowed;
}
.local-video-section {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
}
.video-header {
min-height: 50px;
min-height: 70px;
display: flex;
flex-direction: column;
align-items: center;
@@ -216,6 +387,33 @@
.local-videos-row {
flex-direction: column;
gap: 30px;
gap: 20px;
}
.swap-btn {
transform: rotate(90deg);
}
.swap-btn:hover:not(:disabled) {
transform: rotate(90deg) scale(1.1);
}
.user-recording-section {
flex-direction: column;
align-items: center;
}
.youtube-url-input {
flex-direction: column;
}
.url-input {
padding: 10px 14px;
font-size: 14px;
}
.load-btn {
padding: 10px 18px;
font-size: 14px;
}
}

View File

@@ -1,8 +1,34 @@
import { useEffect, useRef, useState, useCallback } from 'react';
import './YouTubePlayer.css';
import UserForm from './UserForm';
import WebcamRecorder from './WebcamRecorder';
const VIDEO_ID = 'wLM5bzt1xks';
const DEFAULT_VIDEO_ID = 'wLM5bzt1xks';
const TERP1_HEADSTART = 2; // seconds ahead
const API_BASE_URL = 'http://localhost:3001';
// Extract video ID from various YouTube URL formats
const extractVideoId = (url) => {
if (!url) return null;
// Handle direct video ID (11 characters)
if (/^[a-zA-Z0-9_-]{11}$/.test(url)) {
return url;
}
// Handle various YouTube URL formats
const patterns = [
/(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/|youtube\.com\/v\/)([a-zA-Z0-9_-]{11})/,
/youtube\.com\/watch\?.*v=([a-zA-Z0-9_-]{11})/,
];
for (const pattern of patterns) {
const match = url.match(pattern);
if (match) return match[1];
}
return null;
};
export default function YouTubePlayer() {
const youtubePlayerRef = useRef(null);
@@ -15,12 +41,93 @@ export default function YouTubePlayer() {
const [videoTitle, setVideoTitle] = useState('');
const timeUpdateIntervalRef = useRef(null);
// YouTube URL state
const [youtubeUrl, setYoutubeUrl] = useState('');
const [currentVideoId, setCurrentVideoId] = useState(DEFAULT_VIDEO_ID);
// Recording state
const [recordedBlob, setRecordedBlob] = useState(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const [submitMessage, setSubmitMessage] = useState(null);
// Recent recordings state
// Shared recording state (when viewing via unique link)
const [sharedRecording, setSharedRecording] = useState(null);
const [shareLink, setShareLink] = useState(null);
// Video swap state
const [videosSwapped, setVideosSwapped] = useState(false);
// Check for shared recording in URL
useEffect(() => {
const urlParams = new URLSearchParams(window.location.search);
const shareId = urlParams.get('share');
if (shareId) {
// Fetch the shared recording
fetch(`${API_BASE_URL}/api/share/${shareId}`)
.then(res => res.json())
.then(data => {
if (data && !data.error) {
setSharedRecording(data);
// Extract and set the YouTube video ID from the shared recording
const videoId = extractVideoId(data.youtube_video_url);
if (videoId) {
setCurrentVideoId(videoId);
// Load the video into the player if it's ready
if (youtubePlayerRef.current && youtubePlayerRef.current.cueVideoById) {
youtubePlayerRef.current.cueVideoById(videoId);
}
}
}
})
.catch(err => console.error('Error fetching shared recording:', err));
}
}, []);
// Load shared recording's YouTube video when player becomes ready
useEffect(() => {
if (isReady && sharedRecording?.youtube_video_url) {
const videoId = extractVideoId(sharedRecording.youtube_video_url);
if (videoId && videoId !== DEFAULT_VIDEO_ID && youtubePlayerRef.current?.cueVideoById) {
youtubePlayerRef.current.cueVideoById(videoId);
}
}
}, [isReady, sharedRecording]);
// Load local videos when sharedRecording changes
useEffect(() => {
if (sharedRecording?.recorded_video_path && localVideo1Ref.current) {
localVideo1Ref.current.load();
}
if (sharedRecording?.recorded_video_path_2 && localVideo2Ref.current) {
localVideo2Ref.current.load();
}
}, [sharedRecording?.recorded_video_path, sharedRecording?.recorded_video_path_2]);
const formatTime = (seconds) => {
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins}:${secs.toString().padStart(2, '0')}`;
};
const handleLoadVideo = () => {
const videoId = extractVideoId(youtubeUrl);
if (!videoId) {
alert('Please enter a valid YouTube URL');
return;
}
setCurrentVideoId(videoId);
setCurrentTime(0);
setIsPlaying(false);
// Load the new video into the player (cue instead of load to not auto-play)
if (youtubePlayerRef.current && youtubePlayerRef.current.cueVideoById) {
youtubePlayerRef.current.cueVideoById(videoId);
}
};
const startTimeTracking = useCallback(() => {
if (timeUpdateIntervalRef.current) {
clearInterval(timeUpdateIntervalRef.current);
@@ -49,7 +156,7 @@ export default function YouTubePlayer() {
// Set up the global callback for when API is ready
window.onYouTubeIframeAPIReady = () => {
youtubePlayerRef.current = new window.YT.Player('youtube-player', {
videoId: VIDEO_ID,
videoId: currentVideoId,
playerVars: {
playsinline: 1,
rel: 0,
@@ -120,18 +227,37 @@ export default function YouTubePlayer() {
// YouTube Player States:
// -1 = unstarted, 0 = ended, 1 = playing, 2 = paused, 3 = buffering, 5 = video cued
// Try to get video title if not already set
if (!videoTitle && youtubePlayerRef.current && youtubePlayerRef.current.getVideoData) {
// Update video title and duration when video is cued or changes
if (event.data === 5 || event.data === -1) { // 5 = cued, -1 = unstarted
// Update duration
if (youtubePlayerRef.current && youtubePlayerRef.current.getDuration) {
const newDuration = youtubePlayerRef.current.getDuration();
if (newDuration > 0) {
setDuration(newDuration);
}
}
// Update title with small delay to ensure data is available
setTimeout(() => {
if (youtubePlayerRef.current && youtubePlayerRef.current.getVideoData) {
const videoData = youtubePlayerRef.current.getVideoData();
if (videoData && videoData.title) {
setVideoTitle(videoData.title);
}
}
}, 300);
}
if (event.data === window.YT.PlayerState.PLAYING) {
setIsPlaying(true);
playAllLocalVideos();
startTimeTracking();
// Also update duration when playing starts (in case it wasn't available before)
if (youtubePlayerRef.current && youtubePlayerRef.current.getDuration) {
const newDuration = youtubePlayerRef.current.getDuration();
if (newDuration > 0) {
setDuration(newDuration);
}
}
} else if (
event.data === window.YT.PlayerState.PAUSED ||
event.data === window.YT.PlayerState.ENDED
@@ -161,6 +287,18 @@ export default function YouTubePlayer() {
};
const handlePlay = () => {
// Seek to beginning first
if (youtubePlayerRef.current && youtubePlayerRef.current.seekTo) {
youtubePlayerRef.current.seekTo(0, true);
}
if (localVideo1Ref.current) {
localVideo1Ref.current.currentTime = TERP1_HEADSTART;
}
if (localVideo2Ref.current) {
localVideo2Ref.current.currentTime = 0;
}
setCurrentTime(0);
// Play all videos
if (youtubePlayerRef.current && isReady) {
youtubePlayerRef.current.playVideo();
@@ -184,6 +322,134 @@ export default function YouTubePlayer() {
}
};
const handleRecordingComplete = (blob) => {
setRecordedBlob(blob);
setSubmitMessage(null);
};
const handleReRecord = () => {
// Seek YouTube video to the beginning
if (youtubePlayerRef.current && youtubePlayerRef.current.seekTo) {
youtubePlayerRef.current.seekTo(0, true);
}
// Seek local videos to the beginning
if (localVideo1Ref.current) {
localVideo1Ref.current.currentTime = TERP1_HEADSTART;
}
if (localVideo2Ref.current) {
localVideo2Ref.current.currentTime = 0;
}
setCurrentTime(0);
};
const handleDiscard = () => {
// Pause and seek YouTube video to the beginning
if (youtubePlayerRef.current) {
if (youtubePlayerRef.current.pauseVideo) {
youtubePlayerRef.current.pauseVideo();
}
if (youtubePlayerRef.current.seekTo) {
youtubePlayerRef.current.seekTo(0, true);
}
}
// Pause and seek local videos to the beginning
if (localVideo1Ref.current) {
localVideo1Ref.current.pause();
localVideo1Ref.current.currentTime = TERP1_HEADSTART;
}
if (localVideo2Ref.current) {
localVideo2Ref.current.pause();
localVideo2Ref.current.currentTime = 0;
}
setCurrentTime(0);
setIsPlaying(false);
};
const handleFormSubmit = async ({ name, email }) => {
if (!name || !name.trim()) {
setSubmitMessage({ type: 'error', text: 'Please enter your name' });
return;
}
if (!email || !email.trim()) {
setSubmitMessage({ type: 'error', text: 'Please enter your email address' });
return;
}
if (!recordedBlob) {
setSubmitMessage({ type: 'error', text: 'Please record a video first' });
return;
}
setIsSubmitting(true);
setSubmitMessage(null);
try {
const formData = new FormData();
formData.append('name', name);
formData.append('email', email);
formData.append('video', recordedBlob, 'recording.webm');
// 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
const urlParams = new URLSearchParams(window.location.search);
const shareId = urlParams.get('share');
const isAddingSecondVideo = shareId && sharedRecording?.unique_link && !sharedRecording.recorded_video_path_2;
let response;
if (isAddingSecondVideo) {
// Save as second video
response = await fetch(`${API_BASE_URL}/api/share/${sharedRecording.unique_link}/second-video`, {
method: 'POST',
body: formData,
});
} else {
// Save as new recording (first video)
formData.append('youtubeVideoUrl', `https://www.youtube.com/watch?v=${currentVideoId}`);
response = await fetch(`${API_BASE_URL}/api/recordings`, {
method: 'POST',
body: formData,
});
}
const data = await response.json();
if (response.ok) {
if (isAddingSecondVideo) {
setSubmitMessage({
type: 'success',
text: 'Second recording saved successfully!'
});
// Refresh the shared recording to show the new video
const updatedRecording = await fetch(`${API_BASE_URL}/api/share/${sharedRecording.unique_link}`);
const updatedData = await updatedRecording.json();
setSharedRecording(updatedData);
} else {
const generatedShareLink = `${window.location.origin}${window.location.pathname}?share=${data.uniqueLink}`;
setShareLink(generatedShareLink);
setSubmitMessage({
type: 'success',
text: 'Recording saved successfully!'
});
// Fetch and display the saved recording in the first video player
const savedRecording = await fetch(`${API_BASE_URL}/api/share/${data.uniqueLink}`);
const savedData = await savedRecording.json();
if (savedData && !savedData.error) {
setSharedRecording(savedData);
}
}
setRecordedBlob(null);
} else {
setSubmitMessage({ type: 'error', text: data.error || 'Failed to save recording' });
}
} catch (error) {
console.error('Error submitting recording:', error);
setSubmitMessage({ type: 'error', text: 'Failed to connect to server. Make sure the server is running.' });
} finally {
setIsSubmitting(false);
}
};
const handleSeek = (event) => {
const newTime = parseFloat(event.target.value);
setCurrentTime(newTime);
@@ -209,14 +475,70 @@ export default function YouTubePlayer() {
return (
<div className="player-container">
{videoTitle && <h1 className="video-title">{videoTitle}</h1>}
{/* YouTube URL Input */}
<div className="youtube-url-input">
<input
type="text"
placeholder="Paste YouTube URL here..."
value={youtubeUrl}
onChange={(e) => setYoutubeUrl(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleLoadVideo()}
className="url-input"
/>
<button onClick={handleLoadVideo} className="load-btn">
Load Video
</button>
</div>
{videoTitle && <h1 className="video-title">{videoTitle} ({formatTime(duration)})</h1>}
<div className="video-section">
<h2 className="video-label">YouTube Video</h2>
<div className="video-wrapper">
<div id="youtube-player"></div>
</div>
</div>
{/* User Form and Webcam Recorder Section - hide when both recordings exist */}
{!(sharedRecording?.recorded_video_path && sharedRecording?.recorded_video_path_2) && (
<div className="user-recording-section">
<div className="user-form-container">
<UserForm onSubmit={handleFormSubmit} isSubmitting={isSubmitting} />
{submitMessage && (
<p className={`submit-message ${submitMessage.type}`}>
{submitMessage.text}
</p>
)}
{shareLink && (
<div className="share-link-container">
<p className="share-link-label">Share this recording:</p>
<input
type="text"
className="share-link-input"
value={shareLink}
readOnly
onClick={(e) => e.target.select()}
/>
<button
className="copy-link-btn"
onClick={() => {
navigator.clipboard.writeText(shareLink);
alert('Link copied to clipboard!');
}}
>
Copy Link
</button>
</div>
)}
</div>
<WebcamRecorder
onRecordingComplete={handleRecordingComplete}
onRecordingStart={handlePlay}
onRecordingStop={handlePause}
onReRecord={handleReRecord}
onDiscard={handleDiscard}
/>
</div>
)}
<div className="timeline-container">
<span className="time-display">{formatTime(currentTime)}</span>
<input
@@ -235,37 +557,104 @@ export default function YouTubePlayer() {
<div className="local-videos-row">
<div className="video-section local-video-section">
<div className="video-header">
<h2 className="video-label">terp3.mp4</h2>
<p className="video-note">Starting with 2 second headstart</p>
<h2 className="video-label">
{sharedRecording ? (videosSwapped ? sharedRecording.name_2 : sharedRecording.name) : 'First Recording'}
</h2>
<p className="video-note">
{sharedRecording ? (videosSwapped ? sharedRecording.email_2 : sharedRecording.email) : 'No recording yet'}
</p>
<p className="video-note">This video has 2 seconds headstart</p>
</div>
<div className="video-wrapper">
{sharedRecording ? (
videosSwapped ? (
sharedRecording.recorded_video_path_2 ? (
<video
ref={localVideo1Ref}
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}
>
<source src="/media/terp3.mp4" type="video/mp4" />
Your browser does not support the video tag.
</video>
) : (
<div className="no-video-placeholder">
<p>No recording yet</p>
</div>
)
) : (
<video
ref={localVideo1Ref}
className="local-video"
onPlay={handleLocalVideoPlay}
onPause={handleLocalVideoPause}
onLoadedMetadata={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>
</div>
{/* Swap button */}
<button
className="swap-btn"
onClick={() => setVideosSwapped(!videosSwapped)}
disabled={!sharedRecording?.recorded_video_path_2}
title="Swap videos"
>
</button>
<div className="video-section local-video-section">
<div className="video-header">
<h2 className="video-label">terp4.mp4</h2>
<h2 className="video-label">
{sharedRecording?.recorded_video_path_2 ? (videosSwapped ? sharedRecording.name : sharedRecording.name_2) : 'Second Recording'}
</h2>
<p className="video-note">
{sharedRecording?.recorded_video_path_2 ? (videosSwapped ? sharedRecording.email : sharedRecording.email_2) : (sharedRecording ? 'Waiting for second recording...' : 'No recording yet')}
</p>
</div>
<div className="video-wrapper">
{sharedRecording?.recorded_video_path_2 ? (
videosSwapped ? (
<video
ref={localVideo2Ref}
className="local-video"
onPlay={handleLocalVideoPlay}
onPause={handleLocalVideoPause}
key={`shared-1-swapped-${sharedRecording.id}-${sharedRecording.recorded_video_path}`}
src={sharedRecording.recorded_video_path}
>
<source src="/media/terp4.mp4" type="video/mp4" />
Your browser does not support the video tag.
</video>
) : (
<video
ref={localVideo2Ref}
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>{sharedRecording ? 'Record your interpretation above' : 'No recording yet'}</p>
</div>
)}
</div>
</div>
</div>