everything
This commit is contained in:
9
.claude/settings.local.json
Normal file
9
.claude/settings.local.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(tree:*)",
|
||||
"Bash(npm install:*)",
|
||||
"Bash(npm run build:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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
2638
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
10
package.json
10
package.json
@@ -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",
|
||||
|
||||
BIN
public/media/recording_1770255256704.webm
Normal file
BIN
public/media/recording_1770255256704.webm
Normal file
Binary file not shown.
BIN
public/media/recording_1770255357427.webm
Normal file
BIN
public/media/recording_1770255357427.webm
Normal file
Binary file not shown.
BIN
public/media/recording_1770255439304.webm
Normal file
BIN
public/media/recording_1770255439304.webm
Normal file
Binary file not shown.
BIN
public/media/recording_1770255482497.webm
Normal file
BIN
public/media/recording_1770255482497.webm
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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
218
server/index.js
Normal 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
BIN
server/signsync.db
Normal file
Binary file not shown.
101
src/components/UserForm.css
Normal file
101
src/components/UserForm.css
Normal 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;
|
||||
}
|
||||
}
|
||||
79
src/components/UserForm.jsx
Normal file
79
src/components/UserForm.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
171
src/components/WebcamRecorder.css
Normal file
171
src/components/WebcamRecorder.css
Normal 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;
|
||||
}
|
||||
}
|
||||
226
src/components/WebcamRecorder.jsx
Normal file
226
src/components/WebcamRecorder.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
const videoData = youtubePlayerRef.current.getVideoData();
|
||||
if (videoData && videoData.title) {
|
||||
setVideoTitle(videoData.title);
|
||||
// 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">
|
||||
<video
|
||||
ref={localVideo1Ref}
|
||||
className="local-video"
|
||||
onPlay={handleLocalVideoPlay}
|
||||
onPause={handleLocalVideoPause}
|
||||
onLoadedMetadata={handleVideo1LoadedMetadata}
|
||||
>
|
||||
<source src="/media/terp3.mp4" type="video/mp4" />
|
||||
Your browser does not support the video tag.
|
||||
</video>
|
||||
{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}
|
||||
>
|
||||
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">
|
||||
<video
|
||||
ref={localVideo2Ref}
|
||||
className="local-video"
|
||||
onPlay={handleLocalVideoPlay}
|
||||
onPause={handleLocalVideoPause}
|
||||
>
|
||||
<source src="/media/terp4.mp4" type="video/mp4" />
|
||||
Your browser does not support the video tag.
|
||||
</video>
|
||||
{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}
|
||||
>
|
||||
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>
|
||||
|
||||
Reference in New Issue
Block a user