Add video sentence feature with playlist functionality
This commit is contained in:
@@ -6,6 +6,7 @@ import { Admin } from './pages/Admin';
|
|||||||
import { AdminTerms } from './pages/AdminTerms';
|
import { AdminTerms } from './pages/AdminTerms';
|
||||||
import Dictionary from './pages/Dictionary';
|
import Dictionary from './pages/Dictionary';
|
||||||
import Znakopis from './pages/Znakopis';
|
import Znakopis from './pages/Znakopis';
|
||||||
|
import VideoSentence from './pages/VideoSentence';
|
||||||
import { ProtectedRoute } from './components/ProtectedRoute';
|
import { ProtectedRoute } from './components/ProtectedRoute';
|
||||||
import { useAuthStore } from './stores/authStore';
|
import { useAuthStore } from './stores/authStore';
|
||||||
import { Toaster } from 'sonner';
|
import { Toaster } from 'sonner';
|
||||||
@@ -51,8 +52,9 @@ function App() {
|
|||||||
<Route path="/dictionary" element={<ProtectedRoute><Dictionary /></ProtectedRoute>} />
|
<Route path="/dictionary" element={<ProtectedRoute><Dictionary /></ProtectedRoute>} />
|
||||||
{/* Znakopis */}
|
{/* Znakopis */}
|
||||||
<Route path="/znakopis" element={<ProtectedRoute><Znakopis /></ProtectedRoute>} />
|
<Route path="/znakopis" element={<ProtectedRoute><Znakopis /></ProtectedRoute>} />
|
||||||
|
{/* Video Sentence */}
|
||||||
|
<Route path="/video-sentence" element={<ProtectedRoute><VideoSentence /></ProtectedRoute>} />
|
||||||
{/* Placeholder routes for other pages */}
|
{/* 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="/cloud" element={<ProtectedRoute><div>Cloud (Coming Soon)</div></ProtectedRoute>} />
|
||||||
<Route path="/help" element={<ProtectedRoute><div>Help (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>} />
|
<Route path="/community" element={<ProtectedRoute><div>Community (Coming Soon)</div></ProtectedRoute>} />
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
118
packages/frontend/src/components/video-sentence/VideoPlayer.tsx
Normal file
118
packages/frontend/src/components/video-sentence/VideoPlayer.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
45
packages/frontend/src/lib/videoPlaylist.ts
Normal file
45
packages/frontend/src/lib/videoPlaylist.ts
Normal 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}`;
|
||||||
|
}
|
||||||
|
|
||||||
147
packages/frontend/src/pages/VideoSentence.tsx
Normal file
147
packages/frontend/src/pages/VideoSentence.tsx
Normal 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
608
videosentence.md
Normal 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.
|
||||||
|
|
||||||
Reference in New Issue
Block a user