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 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>} />
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user