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;