Files
Audiolib/frontend/src/components/player/AudioPlayer.tsx
Audiolib c65b8ba5cf fix: TypeScript-Fehler im Frontend beheben
- AudioPlayer: findLast → reverse().find() (ES2022 Kompatibilität)
- ChapterList: findLastIndex → manuelles for-loop + implizit any behoben
- Library: searchDebounce Variable 't' undefined → korrekte Initialisierung

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 13:16:16 +02:00

305 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useEffect, useRef, useState } from 'react'
import Hls from 'hls.js'
import {
Play, Pause, SkipBack, SkipForward, Volume2, VolumeX,
List, BookmarkPlus, Moon, X, ChevronLeft
} from 'lucide-react'
import { usePlayerStore } from '../../store/playerStore'
import { createBookmark } from '../../api/me'
import CoverImage from '../common/CoverImage'
import { coverUrl } from '../../api/items'
import ChapterList from './ChapterList'
export default function AudioPlayer() {
const {
item, session, currentTime, duration, isPlaying, playbackRate, volume, chapters,
sleepTimerActive, sleepTimer,
setPlaying, setCurrentTime, setDuration, setPlaybackRate, setVolume,
seek, stop, setExpanded, setSleepTimer, cancelSleepTimer, syncProgress,
} = usePlayerStore()
const audioRef = useRef<HTMLAudioElement>(null)
const hlsRef = useRef<Hls | null>(null)
const [showChapters, setShowChapters] = useState(false)
const [showSpeedMenu, setShowSpeedMenu] = useState(false)
const [showSleepMenu, setShowSleepMenu] = useState(false)
const [muted, setMuted] = useState(false)
const meta = item?.media?.metadata || {}
const title = meta.title || item?.relPath || ''
const author = meta.authors?.[0]?.name || ''
// HLS laden sobald sich die Session ändert
useEffect(() => {
if (!session || !audioRef.current) return
const hlsUrl = session.audioTracks?.[0]?.contentUrl
if (!hlsUrl) return
if (hlsRef.current) { hlsRef.current.destroy(); hlsRef.current = null }
const audio = audioRef.current
if (Hls.isSupported()) {
const hls = new Hls({ startPosition: session.currentTime || 0 })
hls.loadSource(hlsUrl)
hls.attachMedia(audio)
hlsRef.current = hls
} else if (audio.canPlayType('application/vnd.apple.mpegurl')) {
audio.src = hlsUrl
audio.currentTime = session.currentTime || 0
}
audio.playbackRate = playbackRate
audio.volume = volume
audio.play().catch(() => {})
return () => {
if (hlsRef.current) { hlsRef.current.destroy(); hlsRef.current = null }
}
}, [session?.id])
// isPlaying <-> audio
useEffect(() => {
if (!audioRef.current) return
if (isPlaying) audioRef.current.play().catch(() => {})
else audioRef.current.pause()
}, [isPlaying])
useEffect(() => {
if (audioRef.current) audioRef.current.playbackRate = playbackRate
}, [playbackRate])
useEffect(() => {
if (audioRef.current) audioRef.current.volume = muted ? 0 : volume
}, [volume, muted])
// Keyboard shortcuts
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.target instanceof HTMLInputElement) return
if (e.code === 'Space') { e.preventDefault(); setPlaying(!isPlaying) }
if (e.code === 'ArrowRight') audioRef.current && (audioRef.current.currentTime += 30)
if (e.code === 'ArrowLeft') audioRef.current && (audioRef.current.currentTime -= 10)
}
window.addEventListener('keydown', handler)
return () => window.removeEventListener('keydown', handler)
}, [isPlaying])
const handleTimeUpdate = () => {
if (!audioRef.current) return
setCurrentTime(audioRef.current.currentTime)
}
const handleLoadedMetadata = () => {
if (!audioRef.current) return
setDuration(audioRef.current.duration)
}
const handleEnded = () => {
setPlaying(false)
syncProgress()
}
const handleSeekBar = (e: React.ChangeEvent<HTMLInputElement>) => {
const t = parseFloat(e.target.value)
if (audioRef.current) audioRef.current.currentTime = t
seek(t)
}
const fmtTime = (s: number) => {
if (!isFinite(s)) return '0:00'
const h = Math.floor(s / 3600)
const m = Math.floor((s % 3600) / 60)
const sec = Math.floor(s % 60)
return h > 0
? `${h}:${m.toString().padStart(2, '0')}:${sec.toString().padStart(2, '0')}`
: `${m}:${sec.toString().padStart(2, '0')}`
}
const currentChapter = [...chapters].reverse().find((c: any) => currentTime >= c.start) || chapters[0]
const addBookmark = async () => {
if (!item) return
const label = currentChapter?.title || fmtTime(currentTime)
await createBookmark(item.id, currentTime, label)
}
const SPEEDS = [0.75, 1, 1.25, 1.5, 1.75, 2]
const SLEEP_OPTIONS = [15 * 60, 30 * 60, 45 * 60, 60 * 60]
// Chapter progress markers
const chapterMarkers = chapters.map((c: any) => ({
pct: duration > 0 ? (c.start / duration) * 100 : 0,
}))
return (
<div className="flex flex-col h-full bg-background p-6 overflow-y-auto">
<audio
ref={audioRef}
onTimeUpdate={handleTimeUpdate}
onLoadedMetadata={handleLoadedMetadata}
onEnded={handleEnded}
/>
{/* Header */}
<div className="flex items-center justify-between mb-6">
<button onClick={() => setExpanded(false)} className="text-gray-400 hover:text-white">
<ChevronLeft size={24} />
</button>
<span className="text-sm text-gray-400">Jetzt läuft</span>
<button onClick={stop} className="text-gray-400 hover:text-white">
<X size={20} />
</button>
</div>
{/* Cover */}
<div className="flex justify-center mb-6">
<CoverImage
src={item ? coverUrl(item.id) : null}
alt={title}
className="w-64 h-64 rounded-xl shadow-2xl"
/>
</div>
{/* Title */}
<div className="text-center mb-6">
{currentChapter && (
<p className="text-xs text-primary mb-1 truncate">{currentChapter.title}</p>
)}
<h2 className="text-xl font-bold text-white truncate">{title}</h2>
{author && <p className="text-gray-400 mt-1 truncate">{author}</p>}
</div>
{/* Progress bar with chapter markers */}
<div className="mb-4 relative">
<div className="relative h-1.5 bg-white/10 rounded-full mb-1">
{chapterMarkers.map((m: any, i: number) => (
<div
key={i}
className="absolute top-0 w-0.5 h-full bg-white/20"
style={{ left: `${m.pct}%` }}
/>
))}
<div
className="absolute top-0 left-0 h-full bg-primary rounded-full pointer-events-none"
style={{ width: `${duration > 0 ? (currentTime / duration) * 100 : 0}%` }}
/>
<input
type="range"
min={0}
max={duration || 0}
step={1}
value={currentTime}
onChange={handleSeekBar}
className="absolute inset-0 w-full opacity-0 cursor-pointer h-full"
/>
</div>
<div className="flex justify-between text-xs text-gray-500">
<span>{fmtTime(currentTime)}</span>
<span>-{fmtTime(duration - currentTime)}</span>
</div>
</div>
{/* Controls */}
<div className="flex items-center justify-center gap-6 mb-6">
<button
className="text-gray-400 hover:text-white"
onClick={() => audioRef.current && (audioRef.current.currentTime -= 30)}
>
<SkipBack size={28} />
</button>
<button
className="w-14 h-14 rounded-full bg-primary text-black flex items-center justify-center hover:bg-primary/80 transition-colors"
onClick={() => setPlaying(!isPlaying)}
>
{isPlaying ? <Pause size={24} fill="currentColor" /> : <Play size={24} fill="currentColor" />}
</button>
<button
className="text-gray-400 hover:text-white"
onClick={() => audioRef.current && (audioRef.current.currentTime += 30)}
>
<SkipForward size={28} />
</button>
</div>
{/* Secondary controls */}
<div className="flex items-center justify-between">
{/* Speed */}
<div className="relative">
<button
className="text-sm text-gray-400 hover:text-white px-2 py-1 rounded"
onClick={() => setShowSpeedMenu(!showSpeedMenu)}
>
{playbackRate}×
</button>
{showSpeedMenu && (
<div className="absolute bottom-8 left-0 bg-surface border border-white/10 rounded-lg shadow-xl overflow-hidden z-10">
{SPEEDS.map((s) => (
<button
key={s}
className={`block w-full px-4 py-2 text-sm text-left hover:bg-white/5 ${playbackRate === s ? 'text-primary' : 'text-gray-300'}`}
onClick={() => { setPlaybackRate(s); setShowSpeedMenu(false) }}
>
{s}×
</button>
))}
</div>
)}
</div>
{/* Bookmark */}
<button className="text-gray-400 hover:text-white" onClick={addBookmark}>
<BookmarkPlus size={20} />
</button>
{/* Sleep Timer */}
<div className="relative">
<button
className={`hover:text-white ${sleepTimerActive ? 'text-primary' : 'text-gray-400'}`}
onClick={() => sleepTimerActive ? cancelSleepTimer() : setShowSleepMenu(!showSleepMenu)}
>
<Moon size={20} />
</button>
{sleepTimerActive && sleepTimer !== null && (
<span className="text-xs text-primary ml-1">{Math.floor(sleepTimer / 60)}m</span>
)}
{showSleepMenu && (
<div className="absolute bottom-8 right-0 bg-surface border border-white/10 rounded-lg shadow-xl overflow-hidden z-10">
{SLEEP_OPTIONS.map((s) => (
<button
key={s}
className="block w-full px-4 py-2 text-sm text-left text-gray-300 hover:bg-white/5"
onClick={() => { setSleepTimer(s); setShowSleepMenu(false) }}
>
{s / 60} Min
</button>
))}
</div>
)}
</div>
{/* Chapter List */}
<button
className={`hover:text-white ${showChapters ? 'text-primary' : 'text-gray-400'}`}
onClick={() => setShowChapters(!showChapters)}
>
<List size={20} />
</button>
{/* Volume */}
<button className="text-gray-400 hover:text-white" onClick={() => setMuted(!muted)}>
{muted ? <VolumeX size={20} /> : <Volume2 size={20} />}
</button>
</div>
{/* Chapter List Panel */}
{showChapters && chapters.length > 0 && (
<ChapterList
chapters={chapters}
currentTime={currentTime}
onSeek={(t) => { if (audioRef.current) audioRef.current.currentTime = t; seek(t) }}
/>
)}
</div>
)
}