full init
This commit is contained in:
343
project-specifications.md
Normal file
343
project-specifications.md
Normal file
@@ -0,0 +1,343 @@
|
||||
# SignSync Web App - Project Specifications
|
||||
|
||||
## 1. Product Summary
|
||||
|
||||
SignSync is a browser-based practice and comparison tool for sign language interpreters.
|
||||
Users watch a YouTube source video, record their own interpretation via webcam, save that recording, and share a link so a second interpreter can record against the same source. The app then plays the source plus both interpretation videos in sync for side-by-side review.
|
||||
|
||||
## 2. Core Objectives
|
||||
|
||||
1. Let interpreters practice against any YouTube video.
|
||||
2. Capture webcam + microphone recordings directly in-browser.
|
||||
3. Persist recordings with lightweight user metadata.
|
||||
4. Generate a shareable link for collaboration.
|
||||
5. Compare two interpreters in synchronized playback.
|
||||
|
||||
## 3. User Roles
|
||||
|
||||
1. First interpreter:
|
||||
Creates an initial recording and share link.
|
||||
2. Second interpreter:
|
||||
Opens shared link and adds a second recording.
|
||||
3. Viewer/reviewer:
|
||||
Uses playback controls to compare source + interpretations.
|
||||
|
||||
## 4. Primary User Flows
|
||||
|
||||
### Flow A: Create first recording
|
||||
|
||||
1. User opens app.
|
||||
2. User pastes YouTube URL (or raw video ID) and clicks `Load Video`.
|
||||
3. User enters name/email.
|
||||
4. User records interpretation from webcam.
|
||||
5. User stops recording and previews it.
|
||||
6. User clicks `Save Recording`.
|
||||
7. Backend stores metadata + video file and returns unique link ID.
|
||||
8. Frontend shows full share URL (`?share=<id>`) and copy button.
|
||||
9. Saved recording appears in "First Recording" panel.
|
||||
|
||||
### Flow B: Add second recording from shared link
|
||||
|
||||
1. User opens shared link with `?share=<unique_link>`.
|
||||
2. App loads existing record and cues stored YouTube video.
|
||||
3. If second recording does not exist:
|
||||
- First recording video is shown blurred with overlay text: "Recording done, waiting for other person..."
|
||||
- Playback controls (play/pause, timeline seek) are disabled.
|
||||
- Form + recorder are shown for the second user.
|
||||
4. User records and submits second video.
|
||||
5. Backend updates same row with `name_2`, `email_2`, `recorded_video_path_2`.
|
||||
6. Frontend refreshes shared data and shows both videos immediately (no reload needed).
|
||||
7. First video blur is removed and playback controls are enabled.
|
||||
8. Once both videos exist, form + recorder are hidden.
|
||||
|
||||
### Flow C: Synchronized comparison
|
||||
|
||||
1. User presses global play/pause button.
|
||||
2. App controls YouTube + local videos together.
|
||||
3. Timeline slider seeks all videos simultaneously.
|
||||
4. First interpretation is always offset +2 seconds (headstart).
|
||||
5. User can swap left/right interpreter panels.
|
||||
|
||||
## 5. Functional Requirements
|
||||
|
||||
### 5.1 YouTube Loading
|
||||
|
||||
1. Must support:
|
||||
- Full URLs (`youtube.com/watch?v=...`)
|
||||
- Short URLs (`youtu.be/...`)
|
||||
- Embed URLs
|
||||
- Raw 11-char video IDs
|
||||
2. Invalid input must show alert: `Please enter a valid YouTube URL`.
|
||||
3. Default loaded video ID: `wLM5bzt1xks`.
|
||||
4. Use YouTube IFrame API (`https://www.youtube.com/iframe_api`).
|
||||
5. Show title + duration once available.
|
||||
|
||||
### 5.2 Webcam Recording
|
||||
|
||||
1. Request `getUserMedia` with:
|
||||
- Video: 640x480
|
||||
- Audio: true
|
||||
2. Use `MediaRecorder` with preferred mime:
|
||||
- `video/webm;codecs=vp9` if supported
|
||||
- fallback `video/webm`
|
||||
3. Record in 1-second chunks.
|
||||
4. Show recording timer (`mm:ss`) and active indicator.
|
||||
5. After stop, create preview player using `URL.createObjectURL`.
|
||||
6. Provide `Discard` and `Re-record` actions.
|
||||
7. On camera failure, show:
|
||||
`Unable to access webcam. Please ensure camera permissions are granted.`
|
||||
|
||||
### 5.3 Form Validation
|
||||
|
||||
1. Name required (non-empty).
|
||||
2. Email required and regex-validated in UI.
|
||||
3. Submission blocked if no recorded blob.
|
||||
4. Show inline validation errors in form.
|
||||
|
||||
### 5.4 Recording Submission Logic
|
||||
|
||||
1. New recording (no share context):
|
||||
- `POST /api/recordings`
|
||||
- Include `name`, `email`, `video`, `youtubeVideoUrl`
|
||||
2. Second recording (share context, second not yet present):
|
||||
- `POST /api/share/:uniqueLink/second-video`
|
||||
- Include `name`, `email`, `video`
|
||||
3. On success for first recording:
|
||||
- Show success message
|
||||
- Generate and display share URL
|
||||
- Load saved recording into first video panel immediately (no reload needed)
|
||||
4. On success for second recording:
|
||||
- Show success message
|
||||
- Refresh shared recording data and show second video immediately (no reload needed)
|
||||
5. On server/network failure:
|
||||
- Show friendly error message.
|
||||
|
||||
### 5.5 Shared Recording Behavior
|
||||
|
||||
1. If URL has `share` query param, app must load recording via `GET /api/share/:uniqueLink`.
|
||||
2. If record includes YouTube URL, cue corresponding source video.
|
||||
3. If first/second local video exists, load into respective HTML5 video elements.
|
||||
4. If second recording exists, disable additional recording UI.
|
||||
5. If second recording does not yet exist (`isSecondUserPending`):
|
||||
- First video is blurred (`filter: blur(15px)`) with pointer events disabled.
|
||||
- Overlay text "Recording done, waiting for other person..." is shown centered on the blurred video.
|
||||
- Play/pause button and timeline slider are disabled.
|
||||
|
||||
### 5.6 Playback + Sync
|
||||
|
||||
1. Global play button label toggles:
|
||||
- `▶ Play`
|
||||
- `⏸ Pause`
|
||||
2. Pressing play should reset all media to start state:
|
||||
- YouTube at `0`
|
||||
- First local video at `2` seconds
|
||||
- Second local video at `0`
|
||||
3. Timeline reflects YouTube current time (poll every 250ms while playing).
|
||||
4. Seek operation:
|
||||
- YouTube seek to `t`
|
||||
- First local video seek to `min(t + 2, duration1)`
|
||||
- Second local video seek to `min(t, duration2)`
|
||||
5. If user presses play/pause on local videos, YouTube should sync play/pause.
|
||||
|
||||
### 5.7 Video Panels and Swap
|
||||
|
||||
1. Show two local video panels in one row on desktop.
|
||||
2. Left panel is "First Recording" and marked with `This video has 2 seconds headstart`.
|
||||
3. Right panel is "Second Recording".
|
||||
4. If content missing, show placeholders:
|
||||
- `No recording yet`
|
||||
- `Waiting for second recording...`
|
||||
- `Record your interpretation above` (in shared state with missing second video)
|
||||
5. Swap button (`⇄`) exchanges displayed first/second recording content.
|
||||
6. Swap disabled until second recording exists.
|
||||
|
||||
## 6. UI/UX Requirements
|
||||
|
||||
### 6.1 Page Layout (top to bottom)
|
||||
|
||||
1. YouTube URL input + Load button
|
||||
2. YouTube title + duration
|
||||
3. Embedded YouTube player
|
||||
4. User form + webcam recorder (when allowed)
|
||||
5. Timeline slider with current/duration text
|
||||
6. Two local recording panels + swap button
|
||||
7. Global play/pause control
|
||||
|
||||
### 6.2 Responsive Behavior
|
||||
|
||||
1. Mobile breakpoint around `600px`.
|
||||
2. URL input stack vertically on mobile.
|
||||
3. Local video panels stack vertically on mobile.
|
||||
4. Swap button rotates 90 degrees on mobile.
|
||||
|
||||
### 6.3 Visual Style Baseline
|
||||
|
||||
1. Dark UI theme.
|
||||
2. Blue primary actions (`#2563eb`).
|
||||
3. Red recording actions (`#e63946`).
|
||||
4. Rounded cards, slider, and buttons.
|
||||
|
||||
## 7. Technical Architecture
|
||||
|
||||
### 7.1 Frontend
|
||||
|
||||
1. Stack: React + Vite.
|
||||
2. Main components:
|
||||
- `YouTubePlayer` (orchestrator + sync + state)
|
||||
- `WebcamRecorder` (capture and preview)
|
||||
- `UserForm` (metadata input + validation)
|
||||
3. Frontend API base URL: `http://localhost:3001`.
|
||||
4. Share state is URL-driven (`window.location.search`).
|
||||
|
||||
### 7.2 Backend
|
||||
|
||||
1. Stack: Node.js + Express + SQLite.
|
||||
2. Middleware:
|
||||
- `cors()`
|
||||
- `express.json()`
|
||||
- `multer` for multipart uploads
|
||||
3. Upload constraints:
|
||||
- max file size: 100MB
|
||||
- accept only `video/*` mimetypes
|
||||
4. File naming pattern:
|
||||
- `recording_<timestamp>.webm`
|
||||
5. Storage path:
|
||||
- `public/media/` (local filesystem)
|
||||
|
||||
### 7.3 Data Persistence Model
|
||||
|
||||
Single table: `recordings`
|
||||
|
||||
- `id` INTEGER PK 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 NULL
|
||||
- `email_2` TEXT NULL
|
||||
- `recorded_video_path_2` TEXT NULL
|
||||
- `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
|
||||
Unique link generation: 8 random bytes hex string.
|
||||
|
||||
## 8. Backend API Contract
|
||||
|
||||
### `POST /api/recordings`
|
||||
|
||||
Creates first recording.
|
||||
|
||||
Multipart fields:
|
||||
- `name` (required)
|
||||
- `email` (required)
|
||||
- `youtubeVideoUrl` (optional, stored as empty string if missing)
|
||||
- `video` file (required)
|
||||
|
||||
Success `200`:
|
||||
- `success`
|
||||
- `id`
|
||||
- `uniqueLink`
|
||||
- `recordedVideoPath`
|
||||
- `message`
|
||||
|
||||
Validation failure `400`:
|
||||
- `error: Name, email, and video file are required`
|
||||
|
||||
### `GET /api/recordings`
|
||||
|
||||
Returns all recordings ordered by newest first.
|
||||
|
||||
### `GET /api/recordings/:id`
|
||||
|
||||
Returns one recording by numeric ID.
|
||||
|
||||
`404` if not found.
|
||||
|
||||
### `GET /api/share/:uniqueLink`
|
||||
|
||||
Returns one recording by share token.
|
||||
|
||||
`404` if not found.
|
||||
|
||||
### `POST /api/share/:uniqueLink/second-video`
|
||||
|
||||
Adds second interpreter video to existing row.
|
||||
|
||||
Multipart fields:
|
||||
- `name` (required)
|
||||
- `email` (required)
|
||||
- `video` file (required)
|
||||
|
||||
Errors:
|
||||
- `404` recording not found
|
||||
- `400` second video already recorded
|
||||
- `400` missing required fields
|
||||
|
||||
Success `200`:
|
||||
- `success`
|
||||
- `recordedVideoPath2`
|
||||
- `message`
|
||||
|
||||
## 9. Error Handling and Edge Cases
|
||||
|
||||
1. Invalid YouTube URL input blocks load.
|
||||
2. Camera permission denied shows inline error.
|
||||
3. Missing recording on submit blocked client-side.
|
||||
4. Backend returns generic `500` on server exceptions.
|
||||
5. Shared link with invalid token should show not-found behavior (current app logs and remains mostly empty).
|
||||
6. If local video shorter than seek target, clamp to video duration.
|
||||
7. Newly uploaded video files may not be immediately available from Vite's dev server. A `loadWithRetry` mechanism retries `.load()` up to 5 times at 500ms intervals using native `addEventListener('error')` on the video element.
|
||||
8. WebM files from `MediaRecorder` lack seek indices. Setting `currentTime` before metadata loads crashes the demuxer. Only set `currentTime` in `onLoadedMetadata` handlers.
|
||||
9. React's synthetic `onError` prop on `<video>` elements is unreliable for media errors. Use native event listeners instead.
|
||||
|
||||
## 10. Browser/Platform Requirements
|
||||
|
||||
1. Modern Chromium/Safari/Firefox with support for:
|
||||
- `navigator.mediaDevices.getUserMedia`
|
||||
- `MediaRecorder`
|
||||
- `URL.createObjectURL`
|
||||
- Clipboard API (`navigator.clipboard.writeText`)
|
||||
2. Desktop-first, mobile supported via responsive CSS.
|
||||
|
||||
## 11. Security and Privacy Baseline
|
||||
|
||||
1. No authentication.
|
||||
2. No authorization; share link is access mechanism.
|
||||
3. No encryption-at-rest beyond host defaults.
|
||||
4. User email stored in plaintext SQLite.
|
||||
5. CORS enabled broadly (default permissive).
|
||||
6. Uploaded files are stored locally and directly web-accessible via `/media/...`.
|
||||
|
||||
## 12. Local Development and Run Commands
|
||||
|
||||
1. Install dependencies: `npm install`
|
||||
2. Run frontend + backend: `npm run dev:all`
|
||||
3. Frontend only: `npm run dev` (Vite default port `5173`)
|
||||
4. Backend only: `npm run server` (port `3001`)
|
||||
|
||||
## 13. Implementation Notes for Another AI Builder
|
||||
|
||||
1. Keep functionality equivalent to this baseline rather than redesigning feature behavior.
|
||||
2. Preserve the 2-second headstart offset for first recording in all sync/seek/play reset paths.
|
||||
3. Keep shared-link mode logic URL-driven.
|
||||
4. Ensure form+recorder are hidden once both recordings exist.
|
||||
5. Use multipart uploads and SQLite schema exactly unless explicitly changing architecture.
|
||||
6. If deploying production, ensure `/media` static files are served from the same origin expected by frontend paths.
|
||||
7. Video loading after upload uses a belt-and-suspenders approach: callback refs trigger `loadWithRetry` when video elements mount, and backup `useEffect` hooks watching `sharedRecording.recorded_video_path` / `recorded_video_path_2` catch edge cases where the callback ref alone is insufficient.
|
||||
8. Always clean up previous native error listeners before adding new ones on the same video element to prevent duplicate retry loops.
|
||||
9. Never set `currentTime` on a video element before its metadata has loaded — use `onLoadedMetadata` handlers instead.
|
||||
|
||||
## 14. Acceptance Criteria
|
||||
|
||||
1. User can load a YouTube video and see title/duration.
|
||||
2. User can record webcam video with audio and preview it.
|
||||
3. User can submit first recording and receive usable share link.
|
||||
4. Shared link opens same source video and first recording.
|
||||
5. Second user can add second recording exactly once.
|
||||
6. App can play/pause/seek source + local videos in sync.
|
||||
7. First recording consistently plays with +2s offset.
|
||||
8. User can swap left/right recording panels.
|
||||
9. UI works on desktop and mobile widths.
|
||||
10. After saving first recording, video appears immediately in the first video panel without page reload.
|
||||
11. After saving second recording, video appears immediately in the second video panel without page reload.
|
||||
12. Second user sees first video blurred with overlay text before recording, with playback controls disabled.
|
||||
BIN
public/media/andy.webm
Normal file
BIN
public/media/andy.webm
Normal file
Binary file not shown.
BIN
public/media/ben.webm
Normal file
BIN
public/media/ben.webm
Normal file
Binary file not shown.
BIN
public/media/recording_1770500282265.webm
Normal file
BIN
public/media/recording_1770500282265.webm
Normal file
Binary file not shown.
BIN
public/media/recording_1770500312584.webm
Normal file
BIN
public/media/recording_1770500312584.webm
Normal file
Binary file not shown.
BIN
public/media/recording_1770503157387.webm
Normal file
BIN
public/media/recording_1770503157387.webm
Normal file
Binary file not shown.
BIN
public/media/recording_1770503205464.webm
Normal file
BIN
public/media/recording_1770503205464.webm
Normal file
Binary file not shown.
BIN
public/media/recording_1770562213097.webm
Normal file
BIN
public/media/recording_1770562213097.webm
Normal file
Binary file not shown.
BIN
public/media/recording_1770562457423.webm
Normal file
BIN
public/media/recording_1770562457423.webm
Normal file
Binary file not shown.
BIN
public/media/recording_1770563083764.webm
Normal file
BIN
public/media/recording_1770563083764.webm
Normal file
Binary file not shown.
BIN
public/media/recording_1770563127041.webm
Normal file
BIN
public/media/recording_1770563127041.webm
Normal file
Binary file not shown.
BIN
public/media/recording_1770563332170.webm
Normal file
BIN
public/media/recording_1770563332170.webm
Normal file
Binary file not shown.
BIN
public/media/recording_1770563372810.webm
Normal file
BIN
public/media/recording_1770563372810.webm
Normal file
Binary file not shown.
BIN
public/media/recording_1770563518259.webm
Normal file
BIN
public/media/recording_1770563518259.webm
Normal file
Binary file not shown.
BIN
public/media/recording_1770563611867.webm
Normal file
BIN
public/media/recording_1770563611867.webm
Normal file
Binary file not shown.
BIN
public/media/recording_1770563923825.webm
Normal file
BIN
public/media/recording_1770563923825.webm
Normal file
Binary file not shown.
BIN
public/media/recording_1770563952429.webm
Normal file
BIN
public/media/recording_1770563952429.webm
Normal file
Binary file not shown.
BIN
public/media/recording_1770564337959.webm
Normal file
BIN
public/media/recording_1770564337959.webm
Normal file
Binary file not shown.
BIN
public/media/recording_1770564370209.webm
Normal file
BIN
public/media/recording_1770564370209.webm
Normal file
Binary file not shown.
BIN
public/media/recording_1770565141496.webm
Normal file
BIN
public/media/recording_1770565141496.webm
Normal file
Binary file not shown.
BIN
public/media/recording_1770565539156.webm
Normal file
BIN
public/media/recording_1770565539156.webm
Normal file
Binary file not shown.
BIN
public/media/recording_1770565742890.webm
Normal file
BIN
public/media/recording_1770565742890.webm
Normal file
Binary file not shown.
BIN
public/media/recording_1770565767330.webm
Normal file
BIN
public/media/recording_1770565767330.webm
Normal file
Binary file not shown.
BIN
public/media/recording_1770565784115.webm
Normal file
BIN
public/media/recording_1770565784115.webm
Normal file
Binary file not shown.
BIN
public/media/recording_1770565863658.webm
Normal file
BIN
public/media/recording_1770565863658.webm
Normal file
Binary file not shown.
BIN
public/media/recording_1770565885058.webm
Normal file
BIN
public/media/recording_1770565885058.webm
Normal file
Binary file not shown.
BIN
public/media/recording_1770566032476.webm
Normal file
BIN
public/media/recording_1770566032476.webm
Normal file
Binary file not shown.
BIN
public/media/recording_1770566161938.webm
Normal file
BIN
public/media/recording_1770566161938.webm
Normal file
Binary file not shown.
BIN
public/media/recording_1770566378468.webm
Normal file
BIN
public/media/recording_1770566378468.webm
Normal file
Binary file not shown.
BIN
public/media/recording_1770566403707.webm
Normal file
BIN
public/media/recording_1770566403707.webm
Normal file
Binary file not shown.
BIN
public/media/recording_1770566470113.webm
Normal file
BIN
public/media/recording_1770566470113.webm
Normal file
Binary file not shown.
BIN
public/media/recording_1770566492390.webm
Normal file
BIN
public/media/recording_1770566492390.webm
Normal file
Binary file not shown.
BIN
public/media/recording_1770566872602.webm
Normal file
BIN
public/media/recording_1770566872602.webm
Normal file
Binary file not shown.
BIN
public/media/recording_1770566893973.webm
Normal file
BIN
public/media/recording_1770566893973.webm
Normal file
Binary file not shown.
Binary file not shown.
@@ -143,7 +143,6 @@ export default function WebcamRecorder({ onRecordingComplete, onRecordingStart,
|
||||
|
||||
return (
|
||||
<div className="webcam-recorder">
|
||||
<h2 className="webcam-title">Record Your Interpretation</h2>
|
||||
|
||||
{error && <p className="webcam-error">{error}</p>}
|
||||
|
||||
|
||||
@@ -167,6 +167,7 @@
|
||||
}
|
||||
|
||||
.video-wrapper {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-width: 800px;
|
||||
aspect-ratio: 16 / 9;
|
||||
@@ -191,6 +192,25 @@
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.video-wrapper.blurred-unplayable video {
|
||||
filter: blur(15px);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.blur-overlay-text {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
z-index: 10;
|
||||
color: #fff;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
pointer-events: none;
|
||||
text-shadow: 0 2px 8px rgba(0, 0, 0, 0.7);
|
||||
}
|
||||
|
||||
.no-video-placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef, useState, useCallback } from 'react';
|
||||
import { useEffect, useRef, useState, useCallback, useMemo } from 'react';
|
||||
import './YouTubePlayer.css';
|
||||
import UserForm from './UserForm';
|
||||
import WebcamRecorder from './WebcamRecorder';
|
||||
@@ -58,6 +58,12 @@ export default function YouTubePlayer() {
|
||||
// Video swap state
|
||||
const [videosSwapped, setVideosSwapped] = useState(false);
|
||||
|
||||
// Detect if this is a second user viewing a shared link with no second video yet
|
||||
const isSecondUserPending = useMemo(() => {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
return !!urlParams.get('share') && !!sharedRecording?.recorded_video_path && !sharedRecording?.recorded_video_path_2;
|
||||
}, [sharedRecording]);
|
||||
|
||||
// Check for shared recording in URL
|
||||
useEffect(() => {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
@@ -95,15 +101,52 @@ export default function YouTubePlayer() {
|
||||
}
|
||||
}, [isReady, sharedRecording]);
|
||||
|
||||
// Load local videos when sharedRecording changes
|
||||
// Load video with retry — newly uploaded files may not be served immediately.
|
||||
// Uses native error events (not React synthetic) to guarantee they fire for media errors.
|
||||
const loadWithRetry = useCallback((videoEl) => {
|
||||
if (!videoEl) return;
|
||||
// Clean up any previous retry listener on this element
|
||||
if (videoEl._cleanupRetry) videoEl._cleanupRetry();
|
||||
videoEl._retryCount = 0;
|
||||
const onError = () => {
|
||||
if (videoEl._retryCount < 5) {
|
||||
videoEl._retryCount++;
|
||||
setTimeout(() => videoEl.load(), 500);
|
||||
} else {
|
||||
videoEl.removeEventListener('error', onError);
|
||||
}
|
||||
};
|
||||
videoEl.addEventListener('error', onError);
|
||||
videoEl._cleanupRetry = () => videoEl.removeEventListener('error', onError);
|
||||
videoEl.load();
|
||||
}, []);
|
||||
|
||||
// Callback refs: trigger loadWithRetry the moment each video element mounts.
|
||||
const video1RefCallback = useCallback((el) => {
|
||||
if (localVideo1Ref.current?._cleanupRetry) localVideo1Ref.current._cleanupRetry();
|
||||
localVideo1Ref.current = el;
|
||||
if (el) loadWithRetry(el);
|
||||
}, [loadWithRetry]);
|
||||
|
||||
const video2RefCallback = useCallback((el) => {
|
||||
if (localVideo2Ref.current?._cleanupRetry) localVideo2Ref.current._cleanupRetry();
|
||||
localVideo2Ref.current = el;
|
||||
if (el) loadWithRetry(el);
|
||||
}, [loadWithRetry]);
|
||||
|
||||
// Backup: also trigger loadWithRetry when sharedRecording video paths change.
|
||||
// Handles edge cases where callback refs may not fire (e.g. same element, new src).
|
||||
useEffect(() => {
|
||||
if (sharedRecording?.recorded_video_path && localVideo1Ref.current) {
|
||||
localVideo1Ref.current.load();
|
||||
loadWithRetry(localVideo1Ref.current);
|
||||
}
|
||||
}, [sharedRecording?.recorded_video_path, loadWithRetry]);
|
||||
|
||||
useEffect(() => {
|
||||
if (sharedRecording?.recorded_video_path_2 && localVideo2Ref.current) {
|
||||
localVideo2Ref.current.load();
|
||||
loadWithRetry(localVideo2Ref.current);
|
||||
}
|
||||
}, [sharedRecording?.recorded_video_path, sharedRecording?.recorded_video_path_2]);
|
||||
}, [sharedRecording?.recorded_video_path_2, loadWithRetry]);
|
||||
|
||||
const formatTime = (seconds) => {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
@@ -205,6 +248,7 @@ export default function YouTubePlayer() {
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const playAllLocalVideos = () => {
|
||||
if (localVideo1Ref.current) {
|
||||
localVideo1Ref.current.play();
|
||||
@@ -549,7 +593,7 @@ export default function YouTubePlayer() {
|
||||
step="0.1"
|
||||
value={currentTime}
|
||||
onChange={handleSeek}
|
||||
disabled={!isReady}
|
||||
disabled={!isReady || isSecondUserPending}
|
||||
/>
|
||||
<span className="time-display">{formatTime(duration)}</span>
|
||||
</div>
|
||||
@@ -565,12 +609,15 @@ export default function YouTubePlayer() {
|
||||
</p>
|
||||
<p className="video-note">This video has 2 seconds headstart</p>
|
||||
</div>
|
||||
<div className="video-wrapper">
|
||||
<div className={`video-wrapper${isSecondUserPending ? ' blurred-unplayable' : ''}`}>
|
||||
{isSecondUserPending && (
|
||||
<div className="blur-overlay-text">Recording done, waiting for other person...</div>
|
||||
)}
|
||||
{sharedRecording ? (
|
||||
videosSwapped ? (
|
||||
sharedRecording.recorded_video_path_2 ? (
|
||||
<video
|
||||
ref={localVideo1Ref}
|
||||
ref={video1RefCallback}
|
||||
className="local-video"
|
||||
onPlay={handleLocalVideoPlay}
|
||||
onPause={handleLocalVideoPause}
|
||||
@@ -587,11 +634,11 @@ export default function YouTubePlayer() {
|
||||
)
|
||||
) : (
|
||||
<video
|
||||
ref={localVideo1Ref}
|
||||
ref={isSecondUserPending ? undefined : video1RefCallback}
|
||||
className="local-video"
|
||||
onPlay={handleLocalVideoPlay}
|
||||
onPause={handleLocalVideoPause}
|
||||
onLoadedMetadata={handleVideo1LoadedMetadata}
|
||||
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}
|
||||
>
|
||||
@@ -629,7 +676,7 @@ export default function YouTubePlayer() {
|
||||
{sharedRecording?.recorded_video_path_2 ? (
|
||||
videosSwapped ? (
|
||||
<video
|
||||
ref={localVideo2Ref}
|
||||
ref={video2RefCallback}
|
||||
className="local-video"
|
||||
onPlay={handleLocalVideoPlay}
|
||||
onPause={handleLocalVideoPause}
|
||||
@@ -640,7 +687,7 @@ export default function YouTubePlayer() {
|
||||
</video>
|
||||
) : (
|
||||
<video
|
||||
ref={localVideo2Ref}
|
||||
ref={video2RefCallback}
|
||||
className="local-video"
|
||||
onPlay={handleLocalVideoPlay}
|
||||
onPause={handleLocalVideoPause}
|
||||
@@ -663,7 +710,7 @@ export default function YouTubePlayer() {
|
||||
<button
|
||||
className="control-btn toggle-btn"
|
||||
onClick={handleToggle}
|
||||
disabled={!isReady}
|
||||
disabled={!isReady || isSecondUserPending}
|
||||
>
|
||||
{isPlaying ? '⏸ Pause' : '▶ Play'}
|
||||
</button>
|
||||
|
||||
Reference in New Issue
Block a user