fixed choppy video on iPhone - mp4 encoding
This commit is contained in:
BIN
public/media/andy.mp4
Normal file
BIN
public/media/andy.mp4
Normal file
Binary file not shown.
Binary file not shown.
BIN
public/media/ben.mp4
Normal file
BIN
public/media/ben.mp4
Normal file
Binary file not shown.
Binary file not shown.
BIN
public/media/recording_1770569104899.mp4
Normal file
BIN
public/media/recording_1770569104899.mp4
Normal file
Binary file not shown.
BIN
public/media/recording_1770569130681.mp4
Normal file
BIN
public/media/recording_1770569130681.mp4
Normal file
Binary file not shown.
BIN
public/media/recording_1770571670297.mp4
Normal file
BIN
public/media/recording_1770571670297.mp4
Normal file
Binary file not shown.
BIN
public/media/recording_1770571697502.mp4
Normal file
BIN
public/media/recording_1770571697502.mp4
Normal file
Binary file not shown.
BIN
public/media/recording_1770572219607.mp4
Normal file
BIN
public/media/recording_1770572219607.mp4
Normal file
Binary file not shown.
BIN
public/media/recording_1770572287059.mp4
Normal file
BIN
public/media/recording_1770572287059.mp4
Normal file
Binary file not shown.
BIN
public/media/recording_1770572628450.mp4
Normal file
BIN
public/media/recording_1770572628450.mp4
Normal file
Binary file not shown.
BIN
public/media/recording_1770572652336.mp4
Normal file
BIN
public/media/recording_1770572652336.mp4
Normal file
Binary file not shown.
BIN
public/media/recording_1770572757674.mp4
Normal file
BIN
public/media/recording_1770572757674.mp4
Normal file
Binary file not shown.
BIN
public/media/recording_1770572849703.mp4
Normal file
BIN
public/media/recording_1770572849703.mp4
Normal file
Binary file not shown.
121
public/signsync/test/index.html
Normal file
121
public/signsync/test/index.html
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>SignSync Test Videos</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
color-scheme: light;
|
||||||
|
font-family: "Avenir Next", "Segoe UI", sans-serif;
|
||||||
|
background: #f4f2ec;
|
||||||
|
color: #1f1b16;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 24px 16px 48px;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
font-size: 20px;
|
||||||
|
margin: 0 0 16px;
|
||||||
|
}
|
||||||
|
.videos {
|
||||||
|
display: grid;
|
||||||
|
gap: 16px;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
.panel {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #e5e1d8;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 12px;
|
||||||
|
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.06);
|
||||||
|
}
|
||||||
|
.controls {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.note {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #5b5145;
|
||||||
|
margin: 0 0 16px;
|
||||||
|
}
|
||||||
|
.btn {
|
||||||
|
border: 1px solid #c8bfae;
|
||||||
|
background: #efe9dd;
|
||||||
|
color: #2f271f;
|
||||||
|
padding: 8px 14px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.btn:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
.label {
|
||||||
|
font-size: 14px;
|
||||||
|
margin: 0 0 8px;
|
||||||
|
color: #5b5145;
|
||||||
|
}
|
||||||
|
video {
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #101010;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>SignSync Test Videos</h1>
|
||||||
|
<div class="controls">
|
||||||
|
<button class="btn" id="toggleBtn" type="button">Play Both</button>
|
||||||
|
</div>
|
||||||
|
<p class="note">On iPhone, both videos must be muted to play simultaneously. Use individual controls for audio.</p>
|
||||||
|
<div class="videos">
|
||||||
|
<div class="panel">
|
||||||
|
<p class="label">/signsync/media/ben.mp4</p>
|
||||||
|
<video id="videoA" src="/signsync/media/ben.mp4" controls playsinline muted></video>
|
||||||
|
</div>
|
||||||
|
<div class="panel">
|
||||||
|
<p class="label">/signsync/media/andy.mp4</p>
|
||||||
|
<video id="videoB" src="/signsync/media/andy.mp4" controls playsinline muted></video>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
const videoA = document.getElementById('videoA');
|
||||||
|
const videoB = document.getElementById('videoB');
|
||||||
|
const toggleBtn = document.getElementById('toggleBtn');
|
||||||
|
|
||||||
|
const updateButtonLabel = () => {
|
||||||
|
const isPlaying = !videoA.paused || !videoB.paused;
|
||||||
|
toggleBtn.textContent = isPlaying ? 'Pause Both' : 'Play Both';
|
||||||
|
};
|
||||||
|
|
||||||
|
toggleBtn.addEventListener('click', async () => {
|
||||||
|
const shouldPlay = videoA.paused && videoB.paused;
|
||||||
|
if (shouldPlay) {
|
||||||
|
try {
|
||||||
|
videoA.muted = true;
|
||||||
|
videoB.muted = true;
|
||||||
|
await Promise.all([videoA.play(), videoB.play()]);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to play both videos:', error);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
videoA.pause();
|
||||||
|
videoB.pause();
|
||||||
|
}
|
||||||
|
updateButtonLabel();
|
||||||
|
});
|
||||||
|
|
||||||
|
['play', 'pause', 'ended'].forEach((eventName) => {
|
||||||
|
videoA.addEventListener(eventName, updateButtonLabel);
|
||||||
|
videoB.addEventListener(eventName, updateButtonLabel);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
104
server/convert-webm.js
Normal file
104
server/convert-webm.js
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
import path from 'path';
|
||||||
|
import fs from 'fs';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
import sqlite3 from 'sqlite3';
|
||||||
|
import { open } from 'sqlite';
|
||||||
|
import { spawn } from 'child_process';
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = path.dirname(__filename);
|
||||||
|
const mediaDir = path.join(__dirname, '..', 'public', 'media');
|
||||||
|
|
||||||
|
const transcodeToMp4 = (inputPath, outputPath) => new Promise((resolve, reject) => {
|
||||||
|
const args = [
|
||||||
|
'-y',
|
||||||
|
'-i', inputPath,
|
||||||
|
'-vf', 'scale=trunc(iw/2)*2:trunc(ih/2)*2',
|
||||||
|
'-c:v', 'libx264',
|
||||||
|
'-profile:v', 'baseline',
|
||||||
|
'-level', '3.0',
|
||||||
|
'-pix_fmt', 'yuv420p',
|
||||||
|
'-c:a', 'aac',
|
||||||
|
'-b:a', '128k',
|
||||||
|
'-movflags', '+faststart',
|
||||||
|
outputPath
|
||||||
|
];
|
||||||
|
|
||||||
|
const ffmpeg = spawn('ffmpeg', args, { stdio: 'ignore' });
|
||||||
|
ffmpeg.on('error', reject);
|
||||||
|
ffmpeg.on('close', (code) => {
|
||||||
|
if (code === 0) {
|
||||||
|
resolve();
|
||||||
|
} else {
|
||||||
|
reject(new Error(`ffmpeg exited with code ${code}`));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const toMediaPath = (filename) => `/media/${filename}`;
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
const db = await open({
|
||||||
|
filename: path.join(__dirname, 'signsync.db'),
|
||||||
|
driver: sqlite3.Database
|
||||||
|
});
|
||||||
|
|
||||||
|
const addColumnIfMissing = async (columnDef) => {
|
||||||
|
try {
|
||||||
|
await db.exec(`ALTER TABLE recordings ADD COLUMN ${columnDef}`);
|
||||||
|
} catch (error) {
|
||||||
|
if (!String(error.message).toLowerCase().includes('duplicate column')) {
|
||||||
|
console.error('Error adding column:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
await addColumnIfMissing('recorded_video_processing INTEGER DEFAULT 0');
|
||||||
|
await addColumnIfMissing('recorded_video_processing_2 INTEGER DEFAULT 0');
|
||||||
|
|
||||||
|
const rows = await db.all('SELECT id, recorded_video_path, recorded_video_path_2 FROM recordings');
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
const updates = {};
|
||||||
|
|
||||||
|
for (const key of ['recorded_video_path', 'recorded_video_path_2']) {
|
||||||
|
const currentPath = row[key];
|
||||||
|
if (!currentPath || !currentPath.endsWith('.webm')) continue;
|
||||||
|
|
||||||
|
const inputPath = path.join(mediaDir, path.basename(currentPath));
|
||||||
|
if (!fs.existsSync(inputPath)) {
|
||||||
|
console.warn(`Missing file for ${key} on row ${row.id}: ${inputPath}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const outputPath = inputPath.replace(/\.webm$/i, '.mp4');
|
||||||
|
if (!fs.existsSync(outputPath)) {
|
||||||
|
console.log(`Transcoding ${inputPath} -> ${outputPath}`);
|
||||||
|
await transcodeToMp4(inputPath, outputPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
updates[key] = toMediaPath(path.basename(outputPath));
|
||||||
|
fs.unlinkSync(inputPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(updates).length > 0) {
|
||||||
|
await db.run(
|
||||||
|
`UPDATE recordings
|
||||||
|
SET recorded_video_path = COALESCE(?, recorded_video_path),
|
||||||
|
recorded_video_path_2 = COALESCE(?, recorded_video_path_2),
|
||||||
|
recorded_video_processing = 0,
|
||||||
|
recorded_video_processing_2 = 0
|
||||||
|
WHERE id = ?`,
|
||||||
|
[updates.recorded_video_path || null, updates.recorded_video_path_2 || null, row.id]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.close();
|
||||||
|
console.log('Conversion complete.');
|
||||||
|
}
|
||||||
|
|
||||||
|
run().catch((err) => {
|
||||||
|
console.error(err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
147
server/index.js
147
server/index.js
@@ -7,6 +7,7 @@ import multer from 'multer';
|
|||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { fileURLToPath } from 'url';
|
import { fileURLToPath } from 'url';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
|
import { spawn } from 'child_process';
|
||||||
|
|
||||||
// Generate a unique link ID
|
// Generate a unique link ID
|
||||||
const generateUniqueId = () => {
|
const generateUniqueId = () => {
|
||||||
@@ -17,7 +18,7 @@ const __filename = fileURLToPath(import.meta.url);
|
|||||||
const __dirname = path.dirname(__filename);
|
const __dirname = path.dirname(__filename);
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const PORT = 3001;
|
const PORT = process.env.PORT ? Number(process.env.PORT) : 3005;
|
||||||
|
|
||||||
// Middleware
|
// Middleware
|
||||||
app.use(cors());
|
app.use(cors());
|
||||||
@@ -30,13 +31,22 @@ if (!fs.existsSync(mediaDir)) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Configure multer for video uploads
|
// Configure multer for video uploads
|
||||||
|
const getExtensionForMime = (mimetype) => {
|
||||||
|
if (!mimetype) return '.webm';
|
||||||
|
if (mimetype.includes('mp4')) return '.mp4';
|
||||||
|
if (mimetype.includes('webm')) return '.webm';
|
||||||
|
if (mimetype.includes('quicktime')) return '.mov';
|
||||||
|
return '.webm';
|
||||||
|
};
|
||||||
|
|
||||||
const storage = multer.diskStorage({
|
const storage = multer.diskStorage({
|
||||||
destination: (req, file, cb) => {
|
destination: (req, file, cb) => {
|
||||||
cb(null, mediaDir);
|
cb(null, mediaDir);
|
||||||
},
|
},
|
||||||
filename: (req, file, cb) => {
|
filename: (req, file, cb) => {
|
||||||
const timestamp = Date.now();
|
const timestamp = Date.now();
|
||||||
const safeName = `recording_${timestamp}.webm`;
|
const ext = getExtensionForMime(file.mimetype);
|
||||||
|
const safeName = `recording_${timestamp}${ext}`;
|
||||||
cb(null, safeName);
|
cb(null, safeName);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -56,6 +66,61 @@ const upload = multer({
|
|||||||
// Initialize SQLite database
|
// Initialize SQLite database
|
||||||
let db;
|
let db;
|
||||||
|
|
||||||
|
const transcodeToMp4 = (inputPath, outputPath) => new Promise((resolve, reject) => {
|
||||||
|
const args = [
|
||||||
|
'-y',
|
||||||
|
'-i', inputPath,
|
||||||
|
'-vf', 'scale=trunc(iw/2)*2:trunc(ih/2)*2',
|
||||||
|
'-c:v', 'libx264',
|
||||||
|
'-profile:v', 'baseline',
|
||||||
|
'-level', '3.0',
|
||||||
|
'-pix_fmt', 'yuv420p',
|
||||||
|
'-c:a', 'aac',
|
||||||
|
'-b:a', '128k',
|
||||||
|
'-movflags', '+faststart',
|
||||||
|
outputPath
|
||||||
|
];
|
||||||
|
|
||||||
|
const ffmpeg = spawn('ffmpeg', args, { stdio: 'ignore' });
|
||||||
|
ffmpeg.on('error', reject);
|
||||||
|
ffmpeg.on('close', (code) => {
|
||||||
|
if (code === 0) {
|
||||||
|
resolve();
|
||||||
|
} else {
|
||||||
|
reject(new Error(`ffmpeg exited with code ${code}`));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const isMp4Upload = (file) => {
|
||||||
|
if (!file) return false;
|
||||||
|
if (file.mimetype && file.mimetype.includes('mp4')) return true;
|
||||||
|
return path.extname(file.filename).toLowerCase() === '.mp4';
|
||||||
|
};
|
||||||
|
|
||||||
|
const transcodeQueue = [];
|
||||||
|
let isTranscoding = false;
|
||||||
|
|
||||||
|
const processTranscodeQueue = async () => {
|
||||||
|
if (isTranscoding) return;
|
||||||
|
const job = transcodeQueue.shift();
|
||||||
|
if (!job) return;
|
||||||
|
isTranscoding = true;
|
||||||
|
try {
|
||||||
|
await job();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Transcode job failed:', error);
|
||||||
|
} finally {
|
||||||
|
isTranscoding = false;
|
||||||
|
processTranscodeQueue();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const enqueueTranscode = (job) => {
|
||||||
|
transcodeQueue.push(job);
|
||||||
|
processTranscodeQueue();
|
||||||
|
};
|
||||||
|
|
||||||
async function initializeDatabase() {
|
async function initializeDatabase() {
|
||||||
db = await open({
|
db = await open({
|
||||||
filename: path.join(__dirname, 'signsync.db'),
|
filename: path.join(__dirname, 'signsync.db'),
|
||||||
@@ -69,14 +134,32 @@ async function initializeDatabase() {
|
|||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
email TEXT NOT NULL,
|
email TEXT NOT NULL,
|
||||||
recorded_video_path TEXT NOT NULL,
|
recorded_video_path TEXT NOT NULL,
|
||||||
|
recorded_video_processing INTEGER DEFAULT 0,
|
||||||
youtube_video_url TEXT NOT NULL,
|
youtube_video_url TEXT NOT NULL,
|
||||||
name_2 TEXT,
|
name_2 TEXT,
|
||||||
email_2 TEXT,
|
email_2 TEXT,
|
||||||
recorded_video_path_2 TEXT,
|
recorded_video_path_2 TEXT,
|
||||||
|
recorded_video_processing_2 INTEGER DEFAULT 0,
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
)
|
)
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
const addColumnIfMissing = async (columnDef) => {
|
||||||
|
try {
|
||||||
|
await db.exec(`ALTER TABLE recordings ADD COLUMN ${columnDef}`);
|
||||||
|
} catch (error) {
|
||||||
|
if (!String(error.message).toLowerCase().includes('duplicate column')) {
|
||||||
|
console.error('Error adding column:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
await addColumnIfMissing('recorded_video_processing INTEGER DEFAULT 0');
|
||||||
|
await addColumnIfMissing('recorded_video_processing_2 INTEGER DEFAULT 0');
|
||||||
|
|
||||||
|
await db.run('UPDATE recordings SET recorded_video_processing = 0 WHERE recorded_video_processing IS NULL');
|
||||||
|
await db.run('UPDATE recordings SET recorded_video_processing_2 = 0 WHERE recorded_video_processing_2 IS NULL');
|
||||||
|
|
||||||
console.log('Database initialized');
|
console.log('Database initialized');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,11 +178,13 @@ app.post('/api/recordings', upload.single('video'), async (req, res) => {
|
|||||||
|
|
||||||
const recordedVideoPath = `/media/${req.file.filename}`;
|
const recordedVideoPath = `/media/${req.file.filename}`;
|
||||||
const uniqueLink = generateUniqueId();
|
const uniqueLink = generateUniqueId();
|
||||||
|
const needsTranscode = !isMp4Upload(req.file);
|
||||||
|
const recordedVideoProcessing = needsTranscode ? 1 : 0;
|
||||||
|
|
||||||
const result = await db.run(
|
const result = await db.run(
|
||||||
`INSERT INTO recordings (unique_link, name, email, recorded_video_path, youtube_video_url)
|
`INSERT INTO recordings (unique_link, name, email, recorded_video_path, recorded_video_processing, youtube_video_url)
|
||||||
VALUES (?, ?, ?, ?, ?)`,
|
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||||
[uniqueLink, name, email, recordedVideoPath, youtubeVideoUrl || '']
|
[uniqueLink, name, email, recordedVideoPath, recordedVideoProcessing, youtubeVideoUrl || '']
|
||||||
);
|
);
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
@@ -107,8 +192,30 @@ app.post('/api/recordings', upload.single('video'), async (req, res) => {
|
|||||||
id: result.lastID,
|
id: result.lastID,
|
||||||
uniqueLink,
|
uniqueLink,
|
||||||
recordedVideoPath,
|
recordedVideoPath,
|
||||||
|
recordedVideoProcessing,
|
||||||
message: 'Recording saved successfully'
|
message: 'Recording saved successfully'
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (needsTranscode) {
|
||||||
|
const inputPath = req.file.path;
|
||||||
|
const outputPath = inputPath.replace(/\.[^.]+$/, '.mp4');
|
||||||
|
enqueueTranscode(async () => {
|
||||||
|
try {
|
||||||
|
await transcodeToMp4(inputPath, outputPath);
|
||||||
|
await db.run(
|
||||||
|
`UPDATE recordings SET recorded_video_path = ?, recorded_video_processing = 0 WHERE unique_link = ?`,
|
||||||
|
[`/media/${path.basename(outputPath)}`, uniqueLink]
|
||||||
|
);
|
||||||
|
fs.unlink(inputPath, () => {});
|
||||||
|
} catch (transcodeError) {
|
||||||
|
console.error('Transcode failed, keeping original file:', transcodeError);
|
||||||
|
await db.run(
|
||||||
|
`UPDATE recordings SET recorded_video_processing = 0 WHERE unique_link = ?`,
|
||||||
|
[uniqueLink]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error saving recording:', error);
|
console.error('Error saving recording:', error);
|
||||||
res.status(500).json({ error: 'Failed to save recording' });
|
res.status(500).json({ error: 'Failed to save recording' });
|
||||||
@@ -193,17 +300,43 @@ app.post('/api/share/:uniqueLink/second-video', upload.single('video'), async (r
|
|||||||
}
|
}
|
||||||
|
|
||||||
const recordedVideoPath2 = `/media/${req.file.filename}`;
|
const recordedVideoPath2 = `/media/${req.file.filename}`;
|
||||||
|
const needsTranscode = !isMp4Upload(req.file);
|
||||||
|
const recordedVideoProcessing2 = needsTranscode ? 1 : 0;
|
||||||
|
|
||||||
await db.run(
|
await db.run(
|
||||||
`UPDATE recordings SET name_2 = ?, email_2 = ?, recorded_video_path_2 = ? WHERE unique_link = ?`,
|
`UPDATE recordings
|
||||||
[name, email, recordedVideoPath2, uniqueLink]
|
SET name_2 = ?, email_2 = ?, recorded_video_path_2 = ?, recorded_video_processing_2 = ?
|
||||||
|
WHERE unique_link = ?`,
|
||||||
|
[name, email, recordedVideoPath2, recordedVideoProcessing2, uniqueLink]
|
||||||
);
|
);
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
recordedVideoPath2,
|
recordedVideoPath2,
|
||||||
|
recordedVideoProcessing2,
|
||||||
message: 'Second recording saved successfully'
|
message: 'Second recording saved successfully'
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (needsTranscode) {
|
||||||
|
const inputPath = req.file.path;
|
||||||
|
const outputPath = inputPath.replace(/\.[^.]+$/, '.mp4');
|
||||||
|
enqueueTranscode(async () => {
|
||||||
|
try {
|
||||||
|
await transcodeToMp4(inputPath, outputPath);
|
||||||
|
await db.run(
|
||||||
|
`UPDATE recordings SET recorded_video_path_2 = ?, recorded_video_processing_2 = 0 WHERE unique_link = ?`,
|
||||||
|
[`/media/${path.basename(outputPath)}`, uniqueLink]
|
||||||
|
);
|
||||||
|
fs.unlink(inputPath, () => {});
|
||||||
|
} catch (transcodeError) {
|
||||||
|
console.error('Transcode failed, keeping original file:', transcodeError);
|
||||||
|
await db.run(
|
||||||
|
`UPDATE recordings SET recorded_video_processing_2 = 0 WHERE unique_link = ?`,
|
||||||
|
[uniqueLink]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error saving second recording:', error);
|
console.error('Error saving second recording:', error);
|
||||||
res.status(500).json({ error: 'Failed to save second recording' });
|
res.status(500).json({ error: 'Failed to save second recording' });
|
||||||
|
|||||||
Binary file not shown.
@@ -58,13 +58,19 @@ export default function WebcamRecorder({ onRecordingComplete, onRecordingStart,
|
|||||||
setRecordedBlob(null);
|
setRecordedBlob(null);
|
||||||
setRecordingTime(0);
|
setRecordingTime(0);
|
||||||
|
|
||||||
// Use webm format for recording (better browser support)
|
// Prefer MP4 on iOS/WebKit, fallback to WebM where supported
|
||||||
const mimeType = MediaRecorder.isTypeSupported('video/webm;codecs=vp9')
|
const preferredTypes = [
|
||||||
? 'video/webm;codecs=vp9'
|
'video/mp4;codecs="avc1.42E01E,mp4a.40.2"',
|
||||||
: 'video/webm';
|
'video/mp4',
|
||||||
|
'video/webm;codecs=vp9',
|
||||||
|
'video/webm',
|
||||||
|
];
|
||||||
|
const mimeType = preferredTypes.find((type) => MediaRecorder.isTypeSupported(type));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const mediaRecorder = new MediaRecorder(stream, { mimeType });
|
const mediaRecorder = mimeType
|
||||||
|
? new MediaRecorder(stream, { mimeType })
|
||||||
|
: new MediaRecorder(stream);
|
||||||
|
|
||||||
mediaRecorder.ondataavailable = (event) => {
|
mediaRecorder.ondataavailable = (event) => {
|
||||||
if (event.data.size > 0) {
|
if (event.data.size > 0) {
|
||||||
|
|||||||
@@ -5,7 +5,14 @@ import WebcamRecorder from './WebcamRecorder';
|
|||||||
|
|
||||||
const DEFAULT_VIDEO_ID = 'wLM5bzt1xks';
|
const DEFAULT_VIDEO_ID = 'wLM5bzt1xks';
|
||||||
const TERP1_HEADSTART = 2; // seconds ahead
|
const TERP1_HEADSTART = 2; // seconds ahead
|
||||||
const API_BASE_URL = 'http://localhost:3001';
|
const BASE_URL = import.meta.env.BASE_URL || '/';
|
||||||
|
const API_BASE_URL = BASE_URL.replace(/\/$/, '');
|
||||||
|
|
||||||
|
const withBase = (assetPath) => {
|
||||||
|
if (!assetPath || assetPath.startsWith('http')) return assetPath;
|
||||||
|
if (BASE_URL === '/' || assetPath.startsWith(BASE_URL)) return assetPath;
|
||||||
|
return `${BASE_URL.replace(/\/$/, '')}${assetPath}`;
|
||||||
|
};
|
||||||
|
|
||||||
// Extract video ID from various YouTube URL formats
|
// Extract video ID from various YouTube URL formats
|
||||||
const extractVideoId = (url) => {
|
const extractVideoId = (url) => {
|
||||||
@@ -58,18 +65,46 @@ export default function YouTubePlayer() {
|
|||||||
// Video swap state
|
// Video swap state
|
||||||
const [videosSwapped, setVideosSwapped] = useState(false);
|
const [videosSwapped, setVideosSwapped] = useState(false);
|
||||||
|
|
||||||
|
const [shareId] = useState(() => {
|
||||||
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
|
return urlParams.get('share');
|
||||||
|
});
|
||||||
|
const isShareView = Boolean(shareId);
|
||||||
|
const [isShareLoading, setIsShareLoading] = useState(isShareView);
|
||||||
|
const [shareLoadError, setShareLoadError] = useState(null);
|
||||||
|
|
||||||
// Detect if this is a second user viewing a shared link with no second video yet
|
// Detect if this is a second user viewing a shared link with no second video yet
|
||||||
const isSecondUserPending = useMemo(() => {
|
const isSecondUserPending = useMemo(() => {
|
||||||
const urlParams = new URLSearchParams(window.location.search);
|
return isShareView && !!sharedRecording?.recorded_video_path && !sharedRecording?.recorded_video_path_2;
|
||||||
return !!urlParams.get('share') && !!sharedRecording?.recorded_video_path && !sharedRecording?.recorded_video_path_2;
|
}, [isShareView, sharedRecording]);
|
||||||
}, [sharedRecording]);
|
|
||||||
|
const canPlayWebm = useMemo(() => {
|
||||||
|
try {
|
||||||
|
const testVideo = document.createElement('video');
|
||||||
|
return Boolean(
|
||||||
|
testVideo.canPlayType('video/webm; codecs="vp9,opus"') ||
|
||||||
|
testVideo.canPlayType('video/webm')
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const getVideoStatus = useCallback((path, isProcessing) => {
|
||||||
|
if (!path) return { src: null, status: 'missing' };
|
||||||
|
const cleanPath = path.split('?')[0].toLowerCase();
|
||||||
|
const isWebm = cleanPath.endsWith('.webm');
|
||||||
|
if (isWebm && !canPlayWebm) {
|
||||||
|
return { src: null, status: isProcessing ? 'processing' : 'unsupported' };
|
||||||
|
}
|
||||||
|
return { src: path, status: 'ready' };
|
||||||
|
}, [canPlayWebm]);
|
||||||
|
|
||||||
// Check for shared recording in URL
|
// Check for shared recording in URL
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const urlParams = new URLSearchParams(window.location.search);
|
|
||||||
const shareId = urlParams.get('share');
|
|
||||||
|
|
||||||
if (shareId) {
|
if (shareId) {
|
||||||
|
setIsShareLoading(true);
|
||||||
|
setShareLoadError(null);
|
||||||
// Fetch the shared recording
|
// Fetch the shared recording
|
||||||
fetch(`${API_BASE_URL}/api/share/${shareId}`)
|
fetch(`${API_BASE_URL}/api/share/${shareId}`)
|
||||||
.then(res => res.json())
|
.then(res => res.json())
|
||||||
@@ -85,9 +120,19 @@ export default function YouTubePlayer() {
|
|||||||
youtubePlayerRef.current.cueVideoById(videoId);
|
youtubePlayerRef.current.cueVideoById(videoId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
setSharedRecording(null);
|
||||||
|
setShareLoadError(data?.error || 'Recording not found');
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(err => console.error('Error fetching shared recording:', err));
|
.catch(err => {
|
||||||
|
console.error('Error fetching shared recording:', err);
|
||||||
|
setSharedRecording(null);
|
||||||
|
setShareLoadError('Failed to load shared recording');
|
||||||
|
})
|
||||||
|
.finally(() => setIsShareLoading(false));
|
||||||
|
} else {
|
||||||
|
setIsShareLoading(false);
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -101,6 +146,38 @@ export default function YouTubePlayer() {
|
|||||||
}
|
}
|
||||||
}, [isReady, sharedRecording]);
|
}, [isReady, sharedRecording]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!sharedRecording?.unique_link) return;
|
||||||
|
if (!sharedRecording.recorded_video_processing && !sharedRecording.recorded_video_processing_2) return;
|
||||||
|
|
||||||
|
let timeoutId;
|
||||||
|
let isCancelled = false;
|
||||||
|
|
||||||
|
const poll = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/api/share/${sharedRecording.unique_link}`);
|
||||||
|
const data = await response.json();
|
||||||
|
if (!isCancelled && data && !data.error) {
|
||||||
|
setSharedRecording(data);
|
||||||
|
if (data.recorded_video_processing || data.recorded_video_processing_2) {
|
||||||
|
timeoutId = setTimeout(poll, 5000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (!isCancelled) {
|
||||||
|
timeoutId = setTimeout(poll, 5000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
timeoutId = setTimeout(poll, 5000);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isCancelled = true;
|
||||||
|
if (timeoutId) clearTimeout(timeoutId);
|
||||||
|
};
|
||||||
|
}, [sharedRecording?.unique_link, sharedRecording?.recorded_video_processing, sharedRecording?.recorded_video_processing_2]);
|
||||||
|
|
||||||
// Load video with retry — newly uploaded files may not be served immediately.
|
// Load video with retry — newly uploaded files may not be served immediately.
|
||||||
// Uses native error events (not React synthetic) to guarantee they fire for media errors.
|
// Uses native error events (not React synthetic) to guarantee they fire for media errors.
|
||||||
const loadWithRetry = useCallback((videoEl) => {
|
const loadWithRetry = useCallback((videoEl) => {
|
||||||
@@ -251,9 +328,11 @@ export default function YouTubePlayer() {
|
|||||||
|
|
||||||
const playAllLocalVideos = () => {
|
const playAllLocalVideos = () => {
|
||||||
if (localVideo1Ref.current) {
|
if (localVideo1Ref.current) {
|
||||||
|
localVideo1Ref.current.muted = true;
|
||||||
localVideo1Ref.current.play();
|
localVideo1Ref.current.play();
|
||||||
}
|
}
|
||||||
if (localVideo2Ref.current) {
|
if (localVideo2Ref.current) {
|
||||||
|
localVideo2Ref.current.muted = true;
|
||||||
localVideo2Ref.current.play();
|
localVideo2Ref.current.play();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -430,9 +509,12 @@ export default function YouTubePlayer() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
|
const blobType = recordedBlob?.type || '';
|
||||||
|
const extension = blobType.includes('mp4') ? 'mp4' : blobType.includes('webm') ? 'webm' : 'webm';
|
||||||
|
|
||||||
formData.append('name', name);
|
formData.append('name', name);
|
||||||
formData.append('email', email);
|
formData.append('email', email);
|
||||||
formData.append('video', recordedBlob, 'recording.webm');
|
formData.append('video', recordedBlob, `recording.${extension}`);
|
||||||
|
|
||||||
// Check if we're adding a second video to a shared recording
|
// Check if we're adding a second video to a shared recording
|
||||||
// Must have a share parameter in URL and sharedRecording loaded without a second video
|
// Must have a share parameter in URL and sharedRecording loaded without a second video
|
||||||
@@ -517,6 +599,25 @@ export default function YouTubePlayer() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const recordedVideoProcessing = Number(sharedRecording?.recorded_video_processing) === 1;
|
||||||
|
const recordedVideoProcessing2 = Number(sharedRecording?.recorded_video_processing_2) === 1;
|
||||||
|
const baseVideo1 = getVideoStatus(sharedRecording?.recorded_video_path, recordedVideoProcessing);
|
||||||
|
const baseVideo2 = getVideoStatus(sharedRecording?.recorded_video_path_2, recordedVideoProcessing2);
|
||||||
|
const panel1Video = videosSwapped ? baseVideo2 : baseVideo1;
|
||||||
|
const panel2Video = videosSwapped ? baseVideo1 : baseVideo2;
|
||||||
|
const isProcessingPlayback = baseVideo1.status === 'processing' || baseVideo2.status === 'processing';
|
||||||
|
const isUnsupportedPlayback = Boolean(sharedRecording && (
|
||||||
|
(sharedRecording.recorded_video_path && baseVideo1.status === 'unsupported') ||
|
||||||
|
(sharedRecording.recorded_video_path_2 && baseVideo2.status === 'unsupported')
|
||||||
|
));
|
||||||
|
const shouldShowRecorder = !isShareView || (!isShareLoading && sharedRecording && !sharedRecording.recorded_video_path_2);
|
||||||
|
const isPlaybackBlocked = isSecondUserPending || (
|
||||||
|
sharedRecording && (
|
||||||
|
(sharedRecording.recorded_video_path && baseVideo1.status !== 'ready') ||
|
||||||
|
(sharedRecording.recorded_video_path_2 && baseVideo2.status !== 'ready')
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="player-container">
|
<div className="player-container">
|
||||||
{/* YouTube URL Input */}
|
{/* YouTube URL Input */}
|
||||||
@@ -535,6 +636,12 @@ export default function YouTubePlayer() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{videoTitle && <h1 className="video-title">{videoTitle} ({formatTime(duration)})</h1>}
|
{videoTitle && <h1 className="video-title">{videoTitle} ({formatTime(duration)})</h1>}
|
||||||
|
{isShareView && isShareLoading && (
|
||||||
|
<p className="loading-text">Loading shared recording...</p>
|
||||||
|
)}
|
||||||
|
{isShareView && !isShareLoading && shareLoadError && (
|
||||||
|
<p className="loading-text">{shareLoadError}</p>
|
||||||
|
)}
|
||||||
<div className="video-section">
|
<div className="video-section">
|
||||||
<div className="video-wrapper">
|
<div className="video-wrapper">
|
||||||
<div id="youtube-player"></div>
|
<div id="youtube-player"></div>
|
||||||
@@ -542,7 +649,7 @@ export default function YouTubePlayer() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* User Form and Webcam Recorder Section - hide when both recordings exist */}
|
{/* User Form and Webcam Recorder Section - hide when both recordings exist */}
|
||||||
{!(sharedRecording?.recorded_video_path && sharedRecording?.recorded_video_path_2) && (
|
{shouldShowRecorder && (
|
||||||
<div className="user-recording-section">
|
<div className="user-recording-section">
|
||||||
<div className="user-form-container">
|
<div className="user-form-container">
|
||||||
<UserForm onSubmit={handleFormSubmit} isSubmitting={isSubmitting} />
|
<UserForm onSubmit={handleFormSubmit} isSubmitting={isSubmitting} />
|
||||||
@@ -593,7 +700,7 @@ export default function YouTubePlayer() {
|
|||||||
step="0.1"
|
step="0.1"
|
||||||
value={currentTime}
|
value={currentTime}
|
||||||
onChange={handleSeek}
|
onChange={handleSeek}
|
||||||
disabled={!isReady || isSecondUserPending}
|
disabled={!isReady || isPlaybackBlocked}
|
||||||
/>
|
/>
|
||||||
<span className="time-display">{formatTime(duration)}</span>
|
<span className="time-display">{formatTime(duration)}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -614,36 +721,30 @@ export default function YouTubePlayer() {
|
|||||||
<div className="blur-overlay-text">Recording done, waiting for other person...</div>
|
<div className="blur-overlay-text">Recording done, waiting for other person...</div>
|
||||||
)}
|
)}
|
||||||
{sharedRecording ? (
|
{sharedRecording ? (
|
||||||
videosSwapped ? (
|
(videosSwapped ? sharedRecording.recorded_video_path_2 : sharedRecording.recorded_video_path) ? (
|
||||||
sharedRecording.recorded_video_path_2 ? (
|
panel1Video.status === 'ready' ? (
|
||||||
<video
|
<video
|
||||||
ref={video1RefCallback}
|
ref={isSecondUserPending ? undefined : video1RefCallback}
|
||||||
className="local-video"
|
className="local-video"
|
||||||
onPlay={handleLocalVideoPlay}
|
playsInline
|
||||||
onPause={handleLocalVideoPause}
|
muted
|
||||||
onLoadedMetadata={handleVideo1LoadedMetadata}
|
onPlay={isSecondUserPending ? undefined : handleLocalVideoPlay}
|
||||||
key={`shared-2-swapped-${sharedRecording.id}-${sharedRecording.recorded_video_path_2}`}
|
onPause={isSecondUserPending ? undefined : handleLocalVideoPause}
|
||||||
src={sharedRecording.recorded_video_path_2}
|
onLoadedMetadata={isSecondUserPending ? undefined : handleVideo1LoadedMetadata}
|
||||||
|
key={`shared-1-${videosSwapped ? 'swapped' : 'primary'}-${sharedRecording.id}-${panel1Video.src}`}
|
||||||
|
src={withBase(panel1Video.src)}
|
||||||
>
|
>
|
||||||
Your browser does not support the video tag.
|
Your browser does not support the video tag.
|
||||||
</video>
|
</video>
|
||||||
) : (
|
) : (
|
||||||
<div className="no-video-placeholder">
|
<div className="no-video-placeholder">
|
||||||
<p>No recording yet</p>
|
<p>{panel1Video.status === 'processing' ? 'Processing video for mobile playback...' : 'This recording is not playable in this browser yet.'}</p>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
) : (
|
) : (
|
||||||
<video
|
<div className="no-video-placeholder">
|
||||||
ref={isSecondUserPending ? undefined : video1RefCallback}
|
<p>No recording yet</p>
|
||||||
className="local-video"
|
</div>
|
||||||
onPlay={isSecondUserPending ? undefined : handleLocalVideoPlay}
|
|
||||||
onPause={isSecondUserPending ? undefined : handleLocalVideoPause}
|
|
||||||
onLoadedMetadata={isSecondUserPending ? undefined : handleVideo1LoadedMetadata}
|
|
||||||
key={`shared-${sharedRecording.id}-${sharedRecording.recorded_video_path}`}
|
|
||||||
src={sharedRecording.recorded_video_path}
|
|
||||||
>
|
|
||||||
Your browser does not support the video tag.
|
|
||||||
</video>
|
|
||||||
)
|
)
|
||||||
) : (
|
) : (
|
||||||
<div className="no-video-placeholder">
|
<div className="no-video-placeholder">
|
||||||
@@ -673,29 +774,24 @@ export default function YouTubePlayer() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="video-wrapper">
|
<div className="video-wrapper">
|
||||||
{sharedRecording?.recorded_video_path_2 ? (
|
{(videosSwapped ? sharedRecording?.recorded_video_path : sharedRecording?.recorded_video_path_2) ? (
|
||||||
videosSwapped ? (
|
panel2Video.status === 'ready' ? (
|
||||||
<video
|
<video
|
||||||
ref={video2RefCallback}
|
ref={video2RefCallback}
|
||||||
className="local-video"
|
className="local-video"
|
||||||
|
playsInline
|
||||||
|
muted
|
||||||
onPlay={handleLocalVideoPlay}
|
onPlay={handleLocalVideoPlay}
|
||||||
onPause={handleLocalVideoPause}
|
onPause={handleLocalVideoPause}
|
||||||
key={`shared-1-swapped-${sharedRecording.id}-${sharedRecording.recorded_video_path}`}
|
key={`shared-2-${videosSwapped ? 'swapped' : 'primary'}-${sharedRecording.id}-${panel2Video.src}`}
|
||||||
src={sharedRecording.recorded_video_path}
|
src={withBase(panel2Video.src)}
|
||||||
>
|
>
|
||||||
Your browser does not support the video tag.
|
Your browser does not support the video tag.
|
||||||
</video>
|
</video>
|
||||||
) : (
|
) : (
|
||||||
<video
|
<div className="no-video-placeholder">
|
||||||
ref={video2RefCallback}
|
<p>{panel2Video.status === 'processing' ? 'Processing video for mobile playback...' : 'This recording is not playable in this browser yet.'}</p>
|
||||||
className="local-video"
|
</div>
|
||||||
onPlay={handleLocalVideoPlay}
|
|
||||||
onPause={handleLocalVideoPause}
|
|
||||||
key={`shared-2-${sharedRecording.id}-${sharedRecording.recorded_video_path_2}`}
|
|
||||||
src={sharedRecording.recorded_video_path_2}
|
|
||||||
>
|
|
||||||
Your browser does not support the video tag.
|
|
||||||
</video>
|
|
||||||
)
|
)
|
||||||
) : (
|
) : (
|
||||||
<div className="no-video-placeholder">
|
<div className="no-video-placeholder">
|
||||||
@@ -710,11 +806,17 @@ export default function YouTubePlayer() {
|
|||||||
<button
|
<button
|
||||||
className="control-btn toggle-btn"
|
className="control-btn toggle-btn"
|
||||||
onClick={handleToggle}
|
onClick={handleToggle}
|
||||||
disabled={!isReady || isSecondUserPending}
|
disabled={!isReady || isPlaybackBlocked}
|
||||||
>
|
>
|
||||||
{isPlaying ? '⏸ Pause' : '▶ Play'}
|
{isPlaying ? '⏸ Pause' : '▶ Play'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
{isProcessingPlayback && (
|
||||||
|
<p className="loading-text">Processing video for mobile playback. This may take a few minutes for long recordings.</p>
|
||||||
|
)}
|
||||||
|
{!isProcessingPlayback && isUnsupportedPlayback && (
|
||||||
|
<p className="loading-text">This browser can't play one of the recordings yet. Please wait for conversion or use a different browser.</p>
|
||||||
|
)}
|
||||||
{!isReady && <p className="loading-text">Loading player...</p>}
|
{!isReady && <p className="loading-text">Loading player...</p>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
4
urls.txt
Normal file
4
urls.txt
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
Both showing
|
||||||
|
https://zappy.jaredlog.com/signsync/?share=c9322176fcb96a9b
|
||||||
|
Only Ben showing
|
||||||
|
https://zappy.jaredlog.com/signsync/?share=fbdaf2e777047855
|
||||||
@@ -2,6 +2,15 @@ import { defineConfig } from 'vite'
|
|||||||
import react from '@vitejs/plugin-react'
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
// https://vite.dev/config/
|
// https://vite.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig(({ mode }) => ({
|
||||||
|
base: mode === 'production' ? '/signsync/' : '/',
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
})
|
server: {
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://localhost:3005',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|||||||
Reference in New Issue
Block a user