From d3c90ea4473e082e500c3d814e8fe19e3d49f3e9 Mon Sep 17 00:00:00 2001 From: johnny2211 Date: Sun, 18 Jan 2026 14:56:28 +0100 Subject: [PATCH] Add video sentence feature with playlist functionality --- packages/frontend/src/App.tsx | 4 +- .../video-sentence/PlaybackControls.tsx | 131 ++++ .../video-sentence/SentencePanel.tsx | 72 +++ .../components/video-sentence/VideoPlayer.tsx | 118 ++++ packages/frontend/src/lib/videoPlaylist.ts | 45 ++ packages/frontend/src/pages/VideoSentence.tsx | 147 +++++ videosentence.md | 608 ++++++++++++++++++ 7 files changed, 1124 insertions(+), 1 deletion(-) create mode 100644 packages/frontend/src/components/video-sentence/PlaybackControls.tsx create mode 100644 packages/frontend/src/components/video-sentence/SentencePanel.tsx create mode 100644 packages/frontend/src/components/video-sentence/VideoPlayer.tsx create mode 100644 packages/frontend/src/lib/videoPlaylist.ts create mode 100644 packages/frontend/src/pages/VideoSentence.tsx create mode 100644 videosentence.md diff --git a/packages/frontend/src/App.tsx b/packages/frontend/src/App.tsx index af74d4e..de8b1c1 100644 --- a/packages/frontend/src/App.tsx +++ b/packages/frontend/src/App.tsx @@ -6,6 +6,7 @@ import { Admin } from './pages/Admin'; import { AdminTerms } from './pages/AdminTerms'; import Dictionary from './pages/Dictionary'; import Znakopis from './pages/Znakopis'; +import VideoSentence from './pages/VideoSentence'; import { ProtectedRoute } from './components/ProtectedRoute'; import { useAuthStore } from './stores/authStore'; import { Toaster } from 'sonner'; @@ -51,8 +52,9 @@ function App() { } /> {/* Znakopis */} } /> + {/* Video Sentence */} + } /> {/* Placeholder routes for other pages */} -
Video Sentence (Coming Soon)
} />
Cloud (Coming Soon)
} />
Help (Coming Soon)
} />
Community (Coming Soon)
} /> diff --git a/packages/frontend/src/components/video-sentence/PlaybackControls.tsx b/packages/frontend/src/components/video-sentence/PlaybackControls.tsx new file mode 100644 index 0000000..0fc043f --- /dev/null +++ b/packages/frontend/src/components/video-sentence/PlaybackControls.tsx @@ -0,0 +1,131 @@ +import { Play, Pause, SkipForward, SkipBack, Repeat, Repeat1 } from 'lucide-react'; +import { Button } from '../ui/button'; + +interface PlaybackControlsProps { + isPlaying: boolean; + playbackSpeed: number; + loopMode: 'none' | 'sentence' | 'word'; + onPlayPause: () => void; + onNext: () => void; + onPrevious: () => void; + onSpeedChange: (speed: number) => void; + onLoopModeChange: (mode: 'none' | 'sentence' | 'word') => void; + canGoNext: boolean; + canGoPrevious: boolean; +} + +const SPEED_OPTIONS = [0.5, 0.75, 1.0, 1.25, 1.5, 2.0]; + +export function PlaybackControls({ + isPlaying, + playbackSpeed, + loopMode, + onPlayPause, + onNext, + onPrevious, + onSpeedChange, + onLoopModeChange, + canGoNext, + canGoPrevious, +}: PlaybackControlsProps) { + const handleLoopToggle = () => { + const modes: Array<'none' | 'sentence' | 'word'> = ['none', 'sentence', 'word']; + const currentIndex = modes.indexOf(loopMode); + const nextIndex = (currentIndex + 1) % modes.length; + onLoopModeChange(modes[nextIndex]); + }; + + const getLoopIcon = () => { + if (loopMode === 'word') { + return ; + } + return ; + }; + + const getLoopLabel = () => { + switch (loopMode) { + case 'word': + return 'Ponavljanje riječi'; + case 'sentence': + return 'Ponavljanje rečenice'; + default: + return 'Bez ponavljanja'; + } + }; + + return ( +
+
+ {/* Main playback controls */} +
+ + + + + +
+ + {/* Speed and loop controls */} +
+ {/* Speed selector */} +
+ + +
+ + {/* Loop mode toggle */} +
+ +
+
+
+
+ ); +} + diff --git a/packages/frontend/src/components/video-sentence/SentencePanel.tsx b/packages/frontend/src/components/video-sentence/SentencePanel.tsx new file mode 100644 index 0000000..c93d7d9 --- /dev/null +++ b/packages/frontend/src/components/video-sentence/SentencePanel.tsx @@ -0,0 +1,72 @@ +import { SentenceToken } from '../../stores/sentenceStore'; +import { FileText } from 'lucide-react'; + +interface SentencePanelProps { + tokens: SentenceToken[]; + currentTokenIndex: number; + onTokenClick: (index: number) => void; +} + +export function SentencePanel({ + tokens, + currentTokenIndex, + onTokenClick, +}: SentencePanelProps) { + // Empty state + if (tokens.length === 0) { + return ( +
+
+ +

Nema učitane rečenice

+
+

Kako započeti:

+
    +
  1. 1. Dodajte riječi u Rječniku
  2. +
  3. 2. Uredite rečenicu u Znakopisu
  4. +
  5. 3. Vratite se ovdje za reprodukciju
  6. +
+
+
+
+ ); + } + + return ( +
+

Rečenica

+ + {/* Token list */} +
+ {tokens.map((token, index) => { + const isActive = index === currentTokenIndex; + + return ( + + ); + })} +
+ + {/* Info */} +
+
+ Ukupno riječi: {tokens.length} + Trenutna pozicija: {currentTokenIndex + 1} / {tokens.length} +
+
+
+ ); +} + diff --git a/packages/frontend/src/components/video-sentence/VideoPlayer.tsx b/packages/frontend/src/components/video-sentence/VideoPlayer.tsx new file mode 100644 index 0000000..1b61e44 --- /dev/null +++ b/packages/frontend/src/components/video-sentence/VideoPlayer.tsx @@ -0,0 +1,118 @@ +import { useRef, useEffect, useState } from 'react'; +import { AlertCircle } from 'lucide-react'; + +interface VideoPlayerProps { + videoUrl: string | null; + isPlaying: boolean; + playbackSpeed: number; + currentWord: string; + onVideoEnd: () => void; + onPlayPause: () => void; +} + +export function VideoPlayer({ + videoUrl, + isPlaying, + playbackSpeed, + currentWord, + onVideoEnd, + onPlayPause, +}: VideoPlayerProps) { + const videoRef = useRef(null); + const [hasError, setHasError] = useState(false); + const [isLoading, setIsLoading] = useState(true); + + // Handle play/pause state changes + useEffect(() => { + if (!videoRef.current || !videoUrl) return; + + if (isPlaying) { + videoRef.current.play().catch(err => { + console.error('Error playing video:', err); + setHasError(true); + }); + } else { + videoRef.current.pause(); + } + }, [isPlaying, videoUrl]); + + // Handle playback speed changes + useEffect(() => { + if (videoRef.current) { + videoRef.current.playbackRate = playbackSpeed; + } + }, [playbackSpeed]); + + // Reset error state when video URL changes + useEffect(() => { + setHasError(false); + setIsLoading(true); + }, [videoUrl]); + + const handleVideoError = () => { + console.error('Video load error for:', videoUrl); + setHasError(true); + setIsLoading(false); + // Auto-advance to next video after a short delay + setTimeout(() => { + onVideoEnd(); + }, 1500); + }; + + const handleLoadedData = () => { + setIsLoading(false); + }; + + // No video available for this word + if (!videoUrl) { + return ( +
+ +

{currentWord}

+

Video nije dostupan za ovu riječ

+

Automatski prelazak na sljedeću riječ...

+
+ ); + } + + // Video error state + if (hasError) { + return ( +
+ +

{currentWord}

+

Greška pri učitavanju videa

+

Automatski prelazak na sljedeću riječ...

+
+ ); + } + + return ( +
+
+ {isLoading && ( +
+
+
+ )} +
+ + {/* Current word display */} +
+

Trenutno:

+

{currentWord}

+
+
+ ); +} + diff --git a/packages/frontend/src/lib/videoPlaylist.ts b/packages/frontend/src/lib/videoPlaylist.ts new file mode 100644 index 0000000..2fe013a --- /dev/null +++ b/packages/frontend/src/lib/videoPlaylist.ts @@ -0,0 +1,45 @@ +import { SentenceToken } from '../stores/sentenceStore'; +import { MediaKind } from '../types/term'; + +export interface PlaylistItem { + tokenId: string; + termId: string; + displayText: string; + videoUrl: string | null; + durationMs: number | null; +} + +/** + * Build a playlist from sentence tokens + * Extracts video media from each token's term + */ +export function buildPlaylist(tokens: SentenceToken[]): PlaylistItem[] { + return tokens.map(token => { + // Get first video media for the term + const videoMedia = token.term.media?.find(m => m.kind === MediaKind.VIDEO); + + return { + tokenId: token.id, + termId: token.termId, + displayText: token.displayText, + videoUrl: videoMedia?.url || null, + durationMs: videoMedia?.durationMs || null, + }; + }); +} + +/** + * Convert relative URL to absolute URL + * Handles both relative and absolute URLs + */ +export function getVideoUrl(relativeUrl: string | null): string | null { + if (!relativeUrl) return null; + + // If already absolute URL, return as-is + if (relativeUrl.startsWith('http')) return relativeUrl; + + // Construct full URL using backend base URL + const baseUrl = import.meta.env.VITE_API_URL || 'http://localhost:3000'; + return `${baseUrl}${relativeUrl}`; +} + diff --git a/packages/frontend/src/pages/VideoSentence.tsx b/packages/frontend/src/pages/VideoSentence.tsx new file mode 100644 index 0000000..d2b763d --- /dev/null +++ b/packages/frontend/src/pages/VideoSentence.tsx @@ -0,0 +1,147 @@ +import { useState, useEffect } from 'react'; +import { Layout } from '../components/layout/Layout'; +import { VideoPlayer } from '../components/video-sentence/VideoPlayer'; +import { SentencePanel } from '../components/video-sentence/SentencePanel'; +import { PlaybackControls } from '../components/video-sentence/PlaybackControls'; +import { useSentenceStore } from '../stores/sentenceStore'; +import { buildPlaylist, getVideoUrl } from '../lib/videoPlaylist'; +import { toast } from 'sonner'; + +function VideoSentence() { + const { currentTokens } = useSentenceStore(); + const [currentTokenIndex, setCurrentTokenIndex] = useState(0); + const [isPlaying, setIsPlaying] = useState(false); + const [playbackSpeed, setPlaybackSpeed] = useState(1.0); + const [loopMode, setLoopMode] = useState<'none' | 'sentence' | 'word'>('none'); + + const playlist = buildPlaylist(currentTokens); + const currentItem = playlist[currentTokenIndex]; + const videoUrl = currentItem ? getVideoUrl(currentItem.videoUrl) : null; + + // Preload next video for smooth transitions + const nextItem = currentTokenIndex < playlist.length - 1 ? playlist[currentTokenIndex + 1] : null; + const nextVideoUrl = nextItem ? getVideoUrl(nextItem.videoUrl) : null; + + // Reset to first token when tokens change + useEffect(() => { + if (currentTokens.length > 0) { + setCurrentTokenIndex(0); + setIsPlaying(false); + } + }, [currentTokens]); + + // Auto-advance to next video when no video is available + useEffect(() => { + if (currentItem && !currentItem.videoUrl && currentTokens.length > 0) { + const timer = setTimeout(() => { + handleVideoEnd(); + }, 2000); + return () => clearTimeout(timer); + } + }, [currentTokenIndex, currentItem]); + + const handleVideoEnd = () => { + if (loopMode === 'word') { + // Replay current video - the video player will handle this + return; + } + + if (currentTokenIndex < playlist.length - 1) { + // Advance to next token + setCurrentTokenIndex(prev => prev + 1); + } else { + // End of sentence + if (loopMode === 'sentence') { + // Restart from beginning + setCurrentTokenIndex(0); + } else { + // Stop playback + setIsPlaying(false); + toast.success('Reprodukcija završena'); + } + } + }; + + const handleNext = () => { + if (currentTokenIndex < playlist.length - 1) { + setCurrentTokenIndex(prev => prev + 1); + } + }; + + const handlePrevious = () => { + if (currentTokenIndex > 0) { + setCurrentTokenIndex(prev => prev - 1); + } + }; + + const handleTokenClick = (index: number) => { + setCurrentTokenIndex(index); + setIsPlaying(true); + }; + + const handlePlayPause = () => { + setIsPlaying(!isPlaying); + }; + + return ( + +
+
+

Video Rečenica

+

+ Pogledajte sekvencijalnu reprodukciju znakovnog jezika za cijelu rečenicu +

+
+ +
+ {/* Video Player - 60% (3/5 columns) */} +
+ + + 0} + /> +
+ + {/* Sentence Panel - 40% (2/5 columns) */} +
+ +
+
+ + {/* Hidden video element for preloading next video */} + {nextVideoUrl && ( +
+
+ ); +} + +export default VideoSentence; + diff --git a/videosentence.md b/videosentence.md new file mode 100644 index 0000000..223a4c2 --- /dev/null +++ b/videosentence.md @@ -0,0 +1,608 @@ +# Video Rečenica (Video Sentence Player) - Implementation Guide + +## Overview +The Video Rečenica feature allows users to watch sequential playback of sign language videos for complete sentences. Each word's video plays in order with synchronized highlighting of the current token. + +## Architecture + +### Data Flow +``` +User navigates to Video Rečenica + ↓ +Loads sentence from: + - sentenceStore (current working sentence from Znakopis) + - OR selected document/page from Oblak + ↓ +For each token in sentence: + - Get term data (already includes media from database) + - Extract video URL from term.media array + - Build playlist of videos + ↓ +Sequential video playback: + - Play video for token[0] + - Highlight token[0] in sentence list + - On video end → advance to token[1] + - Repeat until all tokens played + ↓ +User controls: play/pause, next/prev, speed, loop, autoplay +``` + +## Implementation Tasks + +### 1. Backend API (Optional - Can use existing data) +**Note:** The existing document API already returns all necessary data (tokens with terms and media). No new backend endpoint is strictly required, but you may optionally create a playlist helper endpoint. + +**Optional Endpoint:** `GET /api/playlists/generate` +- Query params: `sentenceId` or `documentId` + `pageIndex` + `sentenceIndex` +- Returns: Array of playlist items +```typescript +{ + playlist: [ + { + tokenId: string, + termId: string, + displayText: string, + videoUrl: string, + durationMs: number + } + ] +} +``` + +**Alternative:** Use existing document API and transform data on frontend. + +### 2. Frontend Components + +#### A. Create `VideoSentence.tsx` Page +**Location:** `packages/frontend/src/pages/VideoSentence.tsx` + +**Layout:** +- Two-column grid: 60% video player (left) + 40% sentence panel (right) +- Responsive: stack vertically on mobile + +**State Management:** +```typescript +- currentTokens: SentenceToken[] (from sentenceStore or loaded document) +- currentTokenIndex: number (which token is playing) +- isPlaying: boolean +- playbackSpeed: number (0.5, 0.75, 1.0, 1.25, 1.5, 2.0) +- loopMode: 'none' | 'sentence' | 'word' +- autoplay: boolean +``` + +**Key Features:** +1. Load sentence from sentenceStore on mount +2. Option to load from saved documents (document selector) +3. Build video playlist from tokens +4. Handle video playback state +5. Sync highlighting with current video + +#### B. Create `VideoPlayer.tsx` Component +**Location:** `packages/frontend/src/components/video-sentence/VideoPlayer.tsx` + +**Props:** +```typescript +interface VideoPlayerProps { + videoUrl: string | null; + isPlaying: boolean; + playbackSpeed: number; + onVideoEnd: () => void; + onPlayPause: () => void; + onNext: () => void; + onPrevious: () => void; +} +``` + +**Features:** +- Large video element with controls +- Custom control bar (play/pause, next/prev, speed selector) +- Preload next video for smooth transitions +- Handle video errors gracefully (show placeholder if no video) +- Display current word being signed +- Fullscreen support (optional) + +**Implementation Notes:** +- Use HTML5 `