- 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>
305 lines
10 KiB
TypeScript
305 lines
10 KiB
TypeScript
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>
|
||
)
|
||
}
|