DeutschLernen/german-app-frontend/src/components/features/story/StoryPlayer.tsx
Lasse Rune Hansen 17ce0d1eb8 fix(frontend/story-integration): Fix TypeScript type imports and duplicate dictionary key
- 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>
2026-06-13 16:25:17 +02:00

247 lines
6.6 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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;