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" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>youtube-player</title>
|
<title>SignSync</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<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",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
"server": "node server/index.js",
|
||||||
|
"dev:all": "concurrently \"npm run dev\" \"npm run server\"",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"express": "^4.21.0",
|
||||||
|
"multer": "^1.4.5-lts.1",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0"
|
"react-dom": "^19.2.0",
|
||||||
|
"sqlite": "^5.1.1",
|
||||||
|
"sqlite3": "^5.1.7"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.39.1",
|
"@eslint/js": "^9.39.1",
|
||||||
"@types/react": "^19.2.5",
|
"@types/react": "^19.2.5",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@vitejs/plugin-react": "^5.1.1",
|
"@vitejs/plugin-react": "^5.1.1",
|
||||||
|
"concurrently": "^9.0.1",
|
||||||
"eslint": "^9.39.1",
|
"eslint": "^9.39.1",
|
||||||
"eslint-plugin-react-hooks": "^7.0.1",
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
"eslint-plugin-react-refresh": "^0.4.24",
|
"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;
|
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 {
|
.video-title {
|
||||||
font-size: 1.5rem;
|
font-size: 1.5rem;
|
||||||
color: #333;
|
color: #333;
|
||||||
@@ -61,6 +191,17 @@
|
|||||||
object-fit: contain;
|
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 {
|
.timeline-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -126,17 +267,47 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
gap: 20px;
|
gap: 20px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 800px;
|
max-width: 900px;
|
||||||
justify-content: center;
|
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 {
|
.local-video-section {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
.video-header {
|
.video-header {
|
||||||
min-height: 50px;
|
min-height: 70px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -216,6 +387,33 @@
|
|||||||
|
|
||||||
.local-videos-row {
|
.local-videos-row {
|
||||||
flex-direction: column;
|
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 { useEffect, useRef, useState, useCallback } from 'react';
|
||||||
import './YouTubePlayer.css';
|
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 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() {
|
export default function YouTubePlayer() {
|
||||||
const youtubePlayerRef = useRef(null);
|
const youtubePlayerRef = useRef(null);
|
||||||
@@ -15,12 +41,93 @@ export default function YouTubePlayer() {
|
|||||||
const [videoTitle, setVideoTitle] = useState('');
|
const [videoTitle, setVideoTitle] = useState('');
|
||||||
const timeUpdateIntervalRef = useRef(null);
|
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 formatTime = (seconds) => {
|
||||||
const mins = Math.floor(seconds / 60);
|
const mins = Math.floor(seconds / 60);
|
||||||
const secs = Math.floor(seconds % 60);
|
const secs = Math.floor(seconds % 60);
|
||||||
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
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(() => {
|
const startTimeTracking = useCallback(() => {
|
||||||
if (timeUpdateIntervalRef.current) {
|
if (timeUpdateIntervalRef.current) {
|
||||||
clearInterval(timeUpdateIntervalRef.current);
|
clearInterval(timeUpdateIntervalRef.current);
|
||||||
@@ -49,7 +156,7 @@ export default function YouTubePlayer() {
|
|||||||
// Set up the global callback for when API is ready
|
// Set up the global callback for when API is ready
|
||||||
window.onYouTubeIframeAPIReady = () => {
|
window.onYouTubeIframeAPIReady = () => {
|
||||||
youtubePlayerRef.current = new window.YT.Player('youtube-player', {
|
youtubePlayerRef.current = new window.YT.Player('youtube-player', {
|
||||||
videoId: VIDEO_ID,
|
videoId: currentVideoId,
|
||||||
playerVars: {
|
playerVars: {
|
||||||
playsinline: 1,
|
playsinline: 1,
|
||||||
rel: 0,
|
rel: 0,
|
||||||
@@ -120,18 +227,37 @@ export default function YouTubePlayer() {
|
|||||||
// YouTube Player States:
|
// YouTube Player States:
|
||||||
// -1 = unstarted, 0 = ended, 1 = playing, 2 = paused, 3 = buffering, 5 = video cued
|
// -1 = unstarted, 0 = ended, 1 = playing, 2 = paused, 3 = buffering, 5 = video cued
|
||||||
|
|
||||||
// Try to get video title if not already set
|
// Update video title and duration when video is cued or changes
|
||||||
if (!videoTitle && youtubePlayerRef.current && youtubePlayerRef.current.getVideoData) {
|
if (event.data === 5 || event.data === -1) { // 5 = cued, -1 = unstarted
|
||||||
const videoData = youtubePlayerRef.current.getVideoData();
|
// Update duration
|
||||||
if (videoData && videoData.title) {
|
if (youtubePlayerRef.current && youtubePlayerRef.current.getDuration) {
|
||||||
setVideoTitle(videoData.title);
|
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) {
|
if (event.data === window.YT.PlayerState.PLAYING) {
|
||||||
setIsPlaying(true);
|
setIsPlaying(true);
|
||||||
playAllLocalVideos();
|
playAllLocalVideos();
|
||||||
startTimeTracking();
|
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 (
|
} else if (
|
||||||
event.data === window.YT.PlayerState.PAUSED ||
|
event.data === window.YT.PlayerState.PAUSED ||
|
||||||
event.data === window.YT.PlayerState.ENDED
|
event.data === window.YT.PlayerState.ENDED
|
||||||
@@ -161,6 +287,18 @@ export default function YouTubePlayer() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handlePlay = () => {
|
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
|
// Play all videos
|
||||||
if (youtubePlayerRef.current && isReady) {
|
if (youtubePlayerRef.current && isReady) {
|
||||||
youtubePlayerRef.current.playVideo();
|
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 handleSeek = (event) => {
|
||||||
const newTime = parseFloat(event.target.value);
|
const newTime = parseFloat(event.target.value);
|
||||||
setCurrentTime(newTime);
|
setCurrentTime(newTime);
|
||||||
@@ -209,14 +475,70 @@ export default function YouTubePlayer() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="player-container">
|
<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">
|
<div className="video-section">
|
||||||
<h2 className="video-label">YouTube Video</h2>
|
|
||||||
<div className="video-wrapper">
|
<div className="video-wrapper">
|
||||||
<div id="youtube-player"></div>
|
<div id="youtube-player"></div>
|
||||||
</div>
|
</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">
|
<div className="timeline-container">
|
||||||
<span className="time-display">{formatTime(currentTime)}</span>
|
<span className="time-display">{formatTime(currentTime)}</span>
|
||||||
<input
|
<input
|
||||||
@@ -235,37 +557,104 @@ export default function YouTubePlayer() {
|
|||||||
<div className="local-videos-row">
|
<div className="local-videos-row">
|
||||||
<div className="video-section local-video-section">
|
<div className="video-section local-video-section">
|
||||||
<div className="video-header">
|
<div className="video-header">
|
||||||
<h2 className="video-label">terp3.mp4</h2>
|
<h2 className="video-label">
|
||||||
<p className="video-note">Starting with 2 second headstart</p>
|
{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>
|
||||||
<div className="video-wrapper">
|
<div className="video-wrapper">
|
||||||
<video
|
{sharedRecording ? (
|
||||||
ref={localVideo1Ref}
|
videosSwapped ? (
|
||||||
className="local-video"
|
sharedRecording.recorded_video_path_2 ? (
|
||||||
onPlay={handleLocalVideoPlay}
|
<video
|
||||||
onPause={handleLocalVideoPause}
|
ref={localVideo1Ref}
|
||||||
onLoadedMetadata={handleVideo1LoadedMetadata}
|
className="local-video"
|
||||||
>
|
onPlay={handleLocalVideoPlay}
|
||||||
<source src="/media/terp3.mp4" type="video/mp4" />
|
onPause={handleLocalVideoPause}
|
||||||
Your browser does not support the video tag.
|
onLoadedMetadata={handleVideo1LoadedMetadata}
|
||||||
</video>
|
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>
|
||||||
</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-section local-video-section">
|
||||||
<div className="video-header">
|
<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>
|
||||||
<div className="video-wrapper">
|
<div className="video-wrapper">
|
||||||
<video
|
{sharedRecording?.recorded_video_path_2 ? (
|
||||||
ref={localVideo2Ref}
|
videosSwapped ? (
|
||||||
className="local-video"
|
<video
|
||||||
onPlay={handleLocalVideoPlay}
|
ref={localVideo2Ref}
|
||||||
onPause={handleLocalVideoPause}
|
className="local-video"
|
||||||
>
|
onPlay={handleLocalVideoPlay}
|
||||||
<source src="/media/terp4.mp4" type="video/mp4" />
|
onPause={handleLocalVideoPause}
|
||||||
Your browser does not support the video tag.
|
key={`shared-1-swapped-${sharedRecording.id}-${sharedRecording.recorded_video_path}`}
|
||||||
</video>
|
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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user