Add video sentence feature with playlist functionality

This commit is contained in:
2026-01-18 14:56:28 +01:00
parent 7598f26c9c
commit d3c90ea447
7 changed files with 1124 additions and 1 deletions

View File

@@ -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() {
<Route path="/dictionary" element={<ProtectedRoute><Dictionary /></ProtectedRoute>} />
{/* Znakopis */}
<Route path="/znakopis" element={<ProtectedRoute><Znakopis /></ProtectedRoute>} />
{/* Video Sentence */}
<Route path="/video-sentence" element={<ProtectedRoute><VideoSentence /></ProtectedRoute>} />
{/* Placeholder routes for other pages */}
<Route path="/video-sentence" element={<ProtectedRoute><div>Video Sentence (Coming Soon)</div></ProtectedRoute>} />
<Route path="/cloud" element={<ProtectedRoute><div>Cloud (Coming Soon)</div></ProtectedRoute>} />
<Route path="/help" element={<ProtectedRoute><div>Help (Coming Soon)</div></ProtectedRoute>} />
<Route path="/community" element={<ProtectedRoute><div>Community (Coming Soon)</div></ProtectedRoute>} />

View File

@@ -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 <Repeat1 className="h-5 w-5" />;
}
return <Repeat className="h-5 w-5" />;
};
const getLoopLabel = () => {
switch (loopMode) {
case 'word':
return 'Ponavljanje riječi';
case 'sentence':
return 'Ponavljanje rečenice';
default:
return 'Bez ponavljanja';
}
};
return (
<div className="bg-white rounded-lg shadow-md p-6 mt-4">
<div className="flex flex-col gap-4">
{/* Main playback controls */}
<div className="flex items-center justify-center gap-4">
<Button
variant="outline"
size="lg"
onClick={onPrevious}
disabled={!canGoPrevious}
title="Prethodno"
>
<SkipBack className="h-6 w-6" />
</Button>
<Button
size="lg"
onClick={onPlayPause}
className="w-20 h-20 rounded-full bg-orange-500 hover:bg-orange-600 text-white"
title={isPlaying ? 'Pauza' : 'Reproduciraj'}
>
{isPlaying ? (
<Pause className="h-8 w-8" />
) : (
<Play className="h-8 w-8 ml-1" />
)}
</Button>
<Button
variant="outline"
size="lg"
onClick={onNext}
disabled={!canGoNext}
title="Sljedeće"
>
<SkipForward className="h-6 w-6" />
</Button>
</div>
{/* Speed and loop controls */}
<div className="flex items-center justify-center gap-6 pt-4 border-t">
{/* Speed selector */}
<div className="flex items-center gap-3">
<label className="text-sm font-medium text-gray-700">Brzina:</label>
<select
value={playbackSpeed}
onChange={(e) => onSpeedChange(parseFloat(e.target.value))}
className="px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-orange-500"
>
{SPEED_OPTIONS.map((speed) => (
<option key={speed} value={speed}>
{speed}x
</option>
))}
</select>
</div>
{/* Loop mode toggle */}
<div className="flex items-center gap-3">
<Button
variant={loopMode !== 'none' ? 'default' : 'outline'}
size="sm"
onClick={handleLoopToggle}
className={loopMode !== 'none' ? 'bg-orange-500 hover:bg-orange-600' : ''}
title={getLoopLabel()}
>
{getLoopIcon()}
<span className="ml-2 text-sm">{getLoopLabel()}</span>
</Button>
</div>
</div>
</div>
</div>
);
}

View File

@@ -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 (
<div className="bg-white rounded-lg shadow-md p-8">
<div className="text-center space-y-4">
<FileText className="h-16 w-16 text-gray-300 mx-auto" />
<h3 className="text-xl font-semibold text-gray-900">Nema učitane rečenice</h3>
<div className="text-sm text-gray-600 space-y-2">
<p className="font-medium">Kako započeti:</p>
<ol className="text-left space-y-1 max-w-xs mx-auto">
<li>1. Dodajte riječi u Rječniku</li>
<li>2. Uredite rečenicu u Znakopisu</li>
<li>3. Vratite se ovdje za reprodukciju</li>
</ol>
</div>
</div>
</div>
);
}
return (
<div className="bg-white rounded-lg shadow-md p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Rečenica</h3>
{/* Token list */}
<div className="flex flex-wrap gap-2">
{tokens.map((token, index) => {
const isActive = index === currentTokenIndex;
return (
<button
key={token.id}
onClick={() => onTokenClick(index)}
className={`
px-4 py-2 rounded-lg font-medium transition-all duration-200
${isActive
? 'bg-orange-400 text-white font-semibold transform scale-105 shadow-lg shadow-orange-400/30'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200 hover:transform hover:-translate-y-0.5'
}
`}
>
{token.displayText}
</button>
);
})}
</div>
{/* Info */}
<div className="mt-6 pt-4 border-t border-gray-200">
<div className="flex items-center justify-between text-sm text-gray-600">
<span>Ukupno riječi: {tokens.length}</span>
<span>Trenutna pozicija: {currentTokenIndex + 1} / {tokens.length}</span>
</div>
</div>
</div>
);
}

View File

@@ -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<HTMLVideoElement>(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 (
<div className="bg-gray-900 rounded-lg aspect-video flex flex-col items-center justify-center text-white p-8">
<AlertCircle className="h-16 w-16 text-gray-400 mb-4" />
<h3 className="text-2xl font-bold mb-2">{currentWord}</h3>
<p className="text-gray-400 text-center">Video nije dostupan za ovu riječ</p>
<p className="text-sm text-gray-500 mt-2">Automatski prelazak na sljedeću riječ...</p>
</div>
);
}
// Video error state
if (hasError) {
return (
<div className="bg-gray-900 rounded-lg aspect-video flex flex-col items-center justify-center text-white p-8">
<AlertCircle className="h-16 w-16 text-red-400 mb-4" />
<h3 className="text-2xl font-bold mb-2">{currentWord}</h3>
<p className="text-gray-400 text-center">Greška pri učitavanju videa</p>
<p className="text-sm text-gray-500 mt-2">Automatski prelazak na sljedeću riječ...</p>
</div>
);
}
return (
<div className="bg-gray-900 rounded-lg overflow-hidden">
<div className="relative aspect-video">
{isLoading && (
<div className="absolute inset-0 flex items-center justify-center bg-gray-900">
<div className="h-12 w-12 animate-spin rounded-full border-4 border-orange-500 border-t-transparent"></div>
</div>
)}
<video
ref={videoRef}
src={videoUrl}
className="w-full h-full object-contain bg-black"
onEnded={onVideoEnd}
onError={handleVideoError}
onLoadedData={handleLoadedData}
onClick={onPlayPause}
playsInline
/>
</div>
{/* Current word display */}
<div className="bg-gray-800 px-6 py-4">
<p className="text-sm text-gray-400 mb-1">Trenutno:</p>
<h3 className="text-2xl font-bold text-white">{currentWord}</h3>
</div>
</div>
);
}

View File

@@ -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}`;
}

View File

@@ -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 (
<Layout>
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold text-gray-900 mb-2">Video Rečenica</h1>
<p className="text-gray-600">
Pogledajte sekvencijalnu reprodukciju znakovnog jezika za cijelu rečenicu
</p>
</div>
<div className="grid grid-cols-1 lg:grid-cols-5 gap-6">
{/* Video Player - 60% (3/5 columns) */}
<div className="lg:col-span-3">
<VideoPlayer
videoUrl={videoUrl}
isPlaying={isPlaying}
playbackSpeed={playbackSpeed}
currentWord={currentItem?.displayText || ''}
onVideoEnd={handleVideoEnd}
onPlayPause={handlePlayPause}
/>
<PlaybackControls
isPlaying={isPlaying}
playbackSpeed={playbackSpeed}
loopMode={loopMode}
onPlayPause={handlePlayPause}
onNext={handleNext}
onPrevious={handlePrevious}
onSpeedChange={setPlaybackSpeed}
onLoopModeChange={setLoopMode}
canGoNext={currentTokenIndex < playlist.length - 1}
canGoPrevious={currentTokenIndex > 0}
/>
</div>
{/* Sentence Panel - 40% (2/5 columns) */}
<div className="lg:col-span-2">
<SentencePanel
tokens={currentTokens}
currentTokenIndex={currentTokenIndex}
onTokenClick={handleTokenClick}
/>
</div>
</div>
{/* Hidden video element for preloading next video */}
{nextVideoUrl && (
<video
src={nextVideoUrl}
preload="auto"
style={{ display: 'none' }}
aria-hidden="true"
/>
)}
</div>
</Layout>
);
}
export default VideoSentence;

608
videosentence.md Normal file
View File

@@ -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 `<video>` element
- Set `playbackRate` property for speed control
- Listen to `ended` event to advance to next token
- Use `ref` to control video programmatically
- Preload strategy: Create hidden video element for next video
#### C. Create `SentencePanel.tsx` Component
**Location:** `packages/frontend/src/components/video-sentence/SentencePanel.tsx`
**Props:**
```typescript
interface SentencePanelProps {
tokens: SentenceToken[];
currentTokenIndex: number;
onTokenClick: (index: number) => void;
selectedDocument: Document | null;
currentPageIndex: number;
onLoadDocument: (documentId: string) => void;
onPageChange: (pageIndex: number) => void;
}
```
**Features:**
- Display all tokens in sentence
- Highlight current token (e.g., bold, colored background, border)
- Allow clicking tokens to jump to that position
- Show document selector (reuse from Znakopis)
- Show page navigation if multi-page document
- Display sentence list if multiple sentences on page
**Visual Design:**
- Token list: horizontal wrap or vertical list
- Current token: distinct visual treatment (e.g., orange background, bold)
- Clickable tokens: hover effect
- Empty state: "Nema učitane rečenice" with instructions
#### D. Create `PlaybackControls.tsx` Component
**Location:** `packages/frontend/src/components/video-sentence/PlaybackControls.tsx`
**Props:**
```typescript
interface PlaybackControlsProps {
isPlaying: boolean;
playbackSpeed: number;
loopMode: 'none' | 'sentence' | 'word';
autoplay: boolean;
onPlayPause: () => void;
onNext: () => void;
onPrevious: () => void;
onSpeedChange: (speed: number) => void;
onLoopModeChange: (mode: 'none' | 'sentence' | 'word') => void;
onAutoplayToggle: () => void;
canGoNext: boolean;
canGoPrevious: boolean;
}
```
**Features:**
- Play/Pause button (large, prominent)
- Previous/Next buttons
- Speed selector dropdown (0.5x, 0.75x, 1x, 1.25x, 1.5x, 2x)
- Loop mode toggle (none, loop sentence, loop word)
- Autoplay toggle
- Disable next/previous when at boundaries
**Icons:** Use lucide-react icons (Play, Pause, SkipForward, SkipBack, Repeat, Gauge)
### 3. Routing
**Update:** `packages/frontend/src/App.tsx`
Replace placeholder route:
```typescript
<Route path="/video-sentence" element={<ProtectedRoute><VideoSentence /></ProtectedRoute>} />
```
### 4. Video Playlist Logic
**Helper Function:** Create utility to build playlist from tokens
**Location:** `packages/frontend/src/lib/videoPlaylist.ts`
```typescript
import { SentenceToken } from '../stores/sentenceStore';
export interface PlaylistItem {
tokenId: string;
termId: string;
displayText: string;
videoUrl: string | null;
durationMs: number | null;
}
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.mediaType === 'VIDEO');
return {
tokenId: token.id,
termId: token.termId,
displayText: token.displayText,
videoUrl: videoMedia?.url || null,
durationMs: videoMedia?.durationMs || null,
};
});
}
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}`;
}
```
### 5. State Management
**Option A:** Use local component state (recommended for simplicity)
- Keep playback state in VideoSentence component
- Pass down via props
**Option B:** Create videoPlayerStore (if needed for cross-component state)
```typescript
// packages/frontend/src/stores/videoPlayerStore.ts
interface VideoPlayerState {
currentTokenIndex: number;
isPlaying: boolean;
playbackSpeed: number;
loopMode: 'none' | 'sentence' | 'word';
autoplay: boolean;
// actions...
}
```
### 6. Key Implementation Details
#### Video Playback Flow
```typescript
// In VideoSentence.tsx
const handleVideoEnd = () => {
if (loopMode === 'word') {
// Replay current video
videoRef.current?.play();
return;
}
if (currentTokenIndex < tokens.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);
}
}
};
// Auto-play next video when index changes
useEffect(() => {
if (isPlaying && videoRef.current) {
videoRef.current.play();
}
}, [currentTokenIndex, isPlaying]);
```
#### Preloading Next Video
```typescript
// Create hidden video element for next video
const [nextVideoUrl, setNextVideoUrl] = useState<string | null>(null);
useEffect(() => {
if (currentTokenIndex < playlist.length - 1) {
const nextItem = playlist[currentTokenIndex + 1];
setNextVideoUrl(getVideoUrl(nextItem.videoUrl));
}
}, [currentTokenIndex, playlist]);
// In render:
{nextVideoUrl && (
<video
src={nextVideoUrl}
preload="auto"
style={{ display: 'none' }}
/>
)}
```
#### Error Handling
```typescript
// Handle missing videos gracefully
const currentVideo = playlist[currentTokenIndex];
if (!currentVideo.videoUrl) {
// Show placeholder: "Video nije dostupan za ovu riječ"
// Display word text prominently
// Auto-advance after 2 seconds
setTimeout(() => handleVideoEnd(), 2000);
}
// Handle video load errors
<video
onError={(e) => {
console.error('Video load error:', e);
toast.error(`Greška pri učitavanju videa za "${currentVideo.displayText}"`);
// Auto-advance to next video
handleVideoEnd();
}}
/>
```
### 7. UI/UX Considerations
#### Empty State
When no sentence is loaded:
```
┌─────────────────────────────────────┐
│ │
│ 📹 Video Rečenica │
│ │
│ Nema učitane rečenice │
│ │
│ Kako započeti: │
│ 1. Dodajte riječi u Rječniku │
│ 2. Uredite rečenicu u Znakopisu │
│ 3. Vratite se ovdje za reprodukciju│
│ │
│ ILI │
│ │
│ [Učitaj spremljeni dokument] │
│ │
└─────────────────────────────────────┘
```
#### Active Playback Layout
```
┌──────────────────────────────────────────────────────────────┐
│ Video Rečenica │
├────────────────────────────┬─────────────────────────────────┤
│ │ Dokument: Moja rečenica │
│ │ Stranica: 1 / 2 │
│ │ │
│ ┌──────────────────────┐ │ Rečenica 1: │
│ │ │ │ ┌───────────────────────────┐ │
│ │ │ │ │ Ja volim školu │ │
│ │ [VIDEO PLAYER] │ │ │ [ACTIVE] [normal] [normal]│ │
│ │ │ │ └───────────────────────────┘ │
│ │ Currently: "Ja" │ │ │
│ │ │ │ Rečenica 2: │
│ └──────────────────────┘ │ ┌───────────────────────────┐ │
│ │ │ Učitelj uči učenike │ │
│ ┌──────────────────────┐ │ │ [normal] [normal] [normal]│ │
│ │ [◀] [▶] [⏸] [🔁] [⚙]│ │ └───────────────────────────┘ │
│ │ Speed: 1.0x │ │ │
│ └──────────────────────┘ │ [Učitaj drugi dokument ▼] │
│ │ [◀ Stranica] [Stranica ▶] │
└────────────────────────────┴─────────────────────────────────┘
```
#### Token Highlighting Styles
```css
/* Normal token */
.token {
padding: 8px 12px;
border-radius: 6px;
background: #f3f4f6;
cursor: pointer;
transition: all 0.2s;
}
/* Active/playing token */
.token-active {
background: #fb923c; /* orange-400 */
color: white;
font-weight: 600;
transform: scale(1.05);
box-shadow: 0 4px 6px rgba(251, 146, 60, 0.3);
}
/* Hover state */
.token:hover {
background: #e5e7eb;
transform: translateY(-2px);
}
```
### 8. Testing Checklist
**Basic Functionality:**
- [ ] Load sentence from sentenceStore
- [ ] Load sentence from saved document
- [ ] Play button starts video playback
- [ ] Pause button pauses video
- [ ] Videos play sequentially in correct order
- [ ] Current token is highlighted during playback
- [ ] Highlighting updates when video changes
**Navigation:**
- [ ] Next button skips to next video
- [ ] Previous button goes to previous video
- [ ] Clicking a token jumps to that video
- [ ] Next/Previous disabled at boundaries
**Playback Controls:**
- [ ] Speed control changes playback rate (0.5x - 2x)
- [ ] Loop sentence restarts from beginning after last video
- [ ] Loop word replays current video continuously
- [ ] Autoplay automatically starts playback when sentence loads
**Multi-sentence/Multi-page:**
- [ ] Can switch between sentences on same page
- [ ] Can navigate between pages
- [ ] Playback state resets when changing sentence/page
- [ ] Document selector loads different documents
**Error Handling:**
- [ ] Missing video shows placeholder
- [ ] Video load error shows toast and auto-advances
- [ ] Empty sentence shows helpful message
- [ ] No crash when term has no media
**Performance:**
- [ ] Next video preloads for smooth transition
- [ ] No lag when switching videos
- [ ] Responsive on mobile devices
### 9. File Structure Summary
```
packages/frontend/src/
├── pages/
│ └── VideoSentence.tsx # Main page component
├── components/
│ └── video-sentence/
│ ├── VideoPlayer.tsx # Video player with controls
│ ├── SentencePanel.tsx # Sentence list with highlighting
│ └── PlaybackControls.tsx # Playback control buttons
├── lib/
│ └── videoPlaylist.ts # Playlist utilities
└── stores/
└── videoPlayerStore.ts # (Optional) Playback state store
```
### 10. Implementation Order
1. **Phase 1: Basic Structure**
- Create VideoSentence.tsx page with layout
- Add route in App.tsx
- Create basic VideoPlayer component
- Create basic SentencePanel component
- Test with sentenceStore data
2. **Phase 2: Playback Logic**
- Implement video playback state management
- Add sequential playback (auto-advance on video end)
- Add token highlighting sync
- Create videoPlaylist.ts utilities
3. **Phase 3: Controls**
- Create PlaybackControls component
- Implement play/pause, next/previous
- Add speed control
- Add loop modes
4. **Phase 4: Document Integration**
- Add document selector to SentencePanel
- Implement loading from saved documents
- Add page navigation
- Add sentence selection (if multiple sentences)
5. **Phase 5: Polish**
- Add video preloading
- Improve error handling
- Add empty states
- Responsive design
- Performance optimization
### 11. Code Examples
#### VideoSentence.tsx (Skeleton)
```typescript
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';
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;
const handleVideoEnd = () => {
// Implementation from section 6
};
const handleNext = () => {
if (currentTokenIndex < playlist.length - 1) {
setCurrentTokenIndex(prev => prev + 1);
}
};
const handlePrevious = () => {
if (currentTokenIndex > 0) {
setCurrentTokenIndex(prev => prev - 1);
}
};
return (
<Layout>
<div className="space-y-6">
<h1 className="text-3xl font-bold">Video Rečenica</h1>
<div className="grid grid-cols-1 lg:grid-cols-5 gap-6">
{/* Video Player - 60% */}
<div className="lg:col-span-3">
<VideoPlayer
videoUrl={videoUrl}
isPlaying={isPlaying}
playbackSpeed={playbackSpeed}
onVideoEnd={handleVideoEnd}
onPlayPause={() => setIsPlaying(!isPlaying)}
onNext={handleNext}
onPrevious={handlePrevious}
/>
<PlaybackControls
isPlaying={isPlaying}
playbackSpeed={playbackSpeed}
loopMode={loopMode}
onPlayPause={() => setIsPlaying(!isPlaying)}
onNext={handleNext}
onPrevious={handlePrevious}
onSpeedChange={setPlaybackSpeed}
onLoopModeChange={setLoopMode}
canGoNext={currentTokenIndex < playlist.length - 1}
canGoPrevious={currentTokenIndex > 0}
/>
</div>
{/* Sentence Panel - 40% */}
<div className="lg:col-span-2">
<SentencePanel
tokens={currentTokens}
currentTokenIndex={currentTokenIndex}
onTokenClick={setCurrentTokenIndex}
/>
</div>
</div>
</div>
</Layout>
);
}
export default VideoSentence;
```
### 12. Croatian Translations
- **Video Rečenica** - Video Sentence
- **Reprodukcija** - Playback
- **Brzina** - Speed
- **Ponavljanje** - Loop/Repeat
- **Automatska reprodukcija** - Autoplay
- **Nema učitane rečenice** - No sentence loaded
- **Trenutno** - Currently
- **Prethodno** - Previous
- **Sljedeće** - Next
- **Pauza** - Pause
- **Reproduciraj** - Play
- **Video nije dostupan** - Video not available
---
## Summary
This implementation guide provides a complete roadmap for building the Video Rečenica feature. The key aspects are:
1. **Sequential video playback** - Videos play one after another automatically
2. **Synchronized highlighting** - Current token is visually highlighted
3. **Playback controls** - Play/pause, next/prev, speed, loop modes
4. **Document integration** - Load from sentenceStore or saved documents
5. **Error handling** - Graceful handling of missing videos
6. **Smooth UX** - Preloading, responsive design, clear visual feedback
Start with Phase 1 (basic structure) and progressively add features. Test thoroughly at each phase before moving to the next.