- Added 'import type' syntax for TypeScript type-only imports - Removed duplicate 'ein' entry in GERMAN_WORD_DICTIONARY - Removed unused import in StoryPlayer component - Verified build passes successfully Generated by Mistral Vibe. Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
247 lines
6.6 KiB
TypeScript
247 lines
6.6 KiB
TypeScript
import { useState, useRef, useEffect, useCallback } from 'react';
|
||
import type { StorySegmentDto } from '../../../types/api/story';
|
||
|
||
interface StoryPlayerProps {
|
||
segment: StorySegmentDto;
|
||
autoPlay?: boolean;
|
||
onPlay?: () => void;
|
||
onPause?: () => void;
|
||
onEnded?: () => void;
|
||
}
|
||
|
||
/**
|
||
* StoryPlayer component - Handles audio playback for story segments.
|
||
* Uses the HTML5 audio element to play audio files served from the backend.
|
||
*/
|
||
export function StoryPlayer({
|
||
segment,
|
||
autoPlay = false,
|
||
onPlay,
|
||
onPause,
|
||
onEnded,
|
||
}: StoryPlayerProps) {
|
||
const audioRef = useRef<HTMLAudioElement>(null);
|
||
const [isPlaying, setIsPlaying] = useState(false);
|
||
const [isLoading, setIsLoading] = useState(false);
|
||
const [error, setError] = useState<string | null>(null);
|
||
const [currentTime, setCurrentTime] = useState(0);
|
||
const [duration, setDuration] = useState(0);
|
||
const [volume, setVolume] = useState(0.8);
|
||
|
||
// Construct audio URL from segment audioUrl
|
||
// Backend serves files from wwwroot, so URLs are like /audio/story/level1-segment1.wav
|
||
const audioUrl = segment.audioUrl
|
||
? `${import.meta.env.VITE_API_URL || 'http://localhost:5000'}${segment.audioUrl}`
|
||
: null;
|
||
|
||
// Load audio metadata when component mounts or segment changes
|
||
useEffect(() => {
|
||
if (audioUrl && audioRef.current) {
|
||
const audio = audioRef.current;
|
||
|
||
const handleLoadedMetadata = () => {
|
||
setDuration(audio.duration);
|
||
setIsLoading(false);
|
||
};
|
||
|
||
const handleError = () => {
|
||
setError('Failed to load audio file');
|
||
setIsLoading(false);
|
||
};
|
||
|
||
audio.addEventListener('loadedmetadata', handleLoadedMetadata);
|
||
audio.addEventListener('error', handleError);
|
||
|
||
// Load the audio source
|
||
audio.src = audioUrl;
|
||
audio.load();
|
||
|
||
return () => {
|
||
audio.removeEventListener('loadedmetadata', handleLoadedMetadata);
|
||
audio.removeEventListener('error', handleError);
|
||
};
|
||
}
|
||
}, [audioUrl]);
|
||
|
||
// Handle audio events
|
||
useEffect(() => {
|
||
if (!audioRef.current) return;
|
||
|
||
const audio = audioRef.current;
|
||
|
||
const handlePlay = () => {
|
||
setIsPlaying(true);
|
||
onPlay?.();
|
||
};
|
||
|
||
const handlePause = () => {
|
||
setIsPlaying(false);
|
||
onPause?.();
|
||
};
|
||
|
||
const handleEnded = () => {
|
||
setIsPlaying(false);
|
||
setCurrentTime(0);
|
||
onEnded?.();
|
||
};
|
||
|
||
const handleTimeUpdate = () => {
|
||
setCurrentTime(audio.currentTime);
|
||
};
|
||
|
||
audio.addEventListener('play', handlePlay);
|
||
audio.addEventListener('pause', handlePause);
|
||
audio.addEventListener('ended', handleEnded);
|
||
audio.addEventListener('timeupdate', handleTimeUpdate);
|
||
|
||
return () => {
|
||
audio.removeEventListener('play', handlePlay);
|
||
audio.removeEventListener('pause', handlePause);
|
||
audio.removeEventListener('ended', handleEnded);
|
||
audio.removeEventListener('timeupdate', handleTimeUpdate);
|
||
};
|
||
}, [onPlay, onPause, onEnded]);
|
||
|
||
// Auto-play effect
|
||
useEffect(() => {
|
||
if (autoPlay && audioRef.current && !isPlaying && !isLoading) {
|
||
audioRef.current.play().catch(() => setError('Audio playback failed'));
|
||
}
|
||
}, [autoPlay, isPlaying, isLoading]);
|
||
|
||
const togglePlayPause = useCallback(() => {
|
||
if (!audioRef.current) return;
|
||
|
||
if (isPlaying) {
|
||
audioRef.current.pause();
|
||
} else {
|
||
if (audioUrl) {
|
||
audioRef.current.play().catch(() => setError('Audio playback failed'));
|
||
}
|
||
}
|
||
}, [isPlaying, audioUrl]);
|
||
|
||
const handleSeek = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||
if (!audioRef.current) return;
|
||
const seekTime = parseFloat(e.target.value);
|
||
audioRef.current.currentTime = seekTime;
|
||
setCurrentTime(seekTime);
|
||
}, []);
|
||
|
||
const handleVolumeChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||
const newVolume = parseFloat(e.target.value);
|
||
setVolume(newVolume);
|
||
if (audioRef.current) {
|
||
audioRef.current.volume = newVolume;
|
||
}
|
||
}, []);
|
||
|
||
const handlePrevious = useCallback(() => {
|
||
// Previous segment logic would be handled by parent
|
||
onEnded?.();
|
||
}, [onEnded]);
|
||
|
||
const handleNext = useCallback(() => {
|
||
// Next segment logic would be handled by parent
|
||
onEnded?.();
|
||
}, [onEnded]);
|
||
|
||
const formatTime = (seconds: number) => {
|
||
const mins = Math.floor(seconds / 60);
|
||
const secs = Math.floor(seconds % 60);
|
||
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||
};
|
||
|
||
if (!audioUrl) {
|
||
return (
|
||
<div className="story-player" data-testid="story-player">
|
||
<div className="no-audio-notice">
|
||
<p>🎧 No audio available for this segment</p>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (error) {
|
||
return (
|
||
<div className="story-player" data-testid="story-player">
|
||
<div className="player-error">
|
||
<p>⚠️ {error}</p>
|
||
<button onClick={() => setError(null)}>Retry</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="story-player" data-testid="story-player">
|
||
<div className="player-controls">
|
||
<button
|
||
className="player-btn"
|
||
onClick={handlePrevious}
|
||
title="Previous segment"
|
||
disabled={!segment.audioUrl}
|
||
>
|
||
⏮️
|
||
</button>
|
||
|
||
<button
|
||
className="player-btn play-pause-btn"
|
||
onClick={togglePlayPause}
|
||
title={isPlaying ? 'Pause' : 'Play'}
|
||
disabled={isLoading}
|
||
data-testid="play-pause-btn"
|
||
>
|
||
{isLoading ? '⏳' : isPlaying ? '⏸️' : '▶️'}
|
||
</button>
|
||
|
||
<button
|
||
className="player-btn"
|
||
onClick={handleNext}
|
||
title="Next segment"
|
||
disabled={!segment.audioUrl}
|
||
>
|
||
⏭️
|
||
</button>
|
||
</div>
|
||
|
||
<div className="player-progress">
|
||
<span className="player-time">{formatTime(currentTime)}</span>
|
||
<input
|
||
type="range"
|
||
min="0"
|
||
max={duration || 0}
|
||
value={currentTime}
|
||
onChange={handleSeek}
|
||
className="seek-slider"
|
||
disabled={!duration}
|
||
data-testid="seek-slider"
|
||
/>
|
||
<span className="player-time">{formatTime(duration)}</span>
|
||
</div>
|
||
|
||
<div className="player-volume">
|
||
<span className="volume-icon">🔊</span>
|
||
<input
|
||
type="range"
|
||
min="0"
|
||
max="1"
|
||
step="0.01"
|
||
value={volume}
|
||
onChange={handleVolumeChange}
|
||
className="volume-slider"
|
||
data-testid="volume-slider"
|
||
/>
|
||
</div>
|
||
|
||
{/* Hidden audio element */}
|
||
<audio
|
||
ref={audioRef}
|
||
preload="metadata"
|
||
style={{ display: 'none' }}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export default StoryPlayer;
|