Extract AudioEngine — fix root cause of broken playback

ROOT CAUSE: AudioPlayer was the only component holding the <audio>
element, and it was only mounted when expanded=true. The MiniPlayer
(default state after Play) had no audio element at all. So clicking
Play set the store state but no audio was ever loaded or played.

Fix: New AudioEngine component holds the single <audio> element and
all playback logic. Mounted globally in App.tsx whenever an item is
loaded — independent of MiniPlayer/AudioPlayer UI state.

Store: New seekRequest (counter-based) lets external UI request seeks
without direct audio element access. New playerError surfaces errors
across MiniPlayer (red progress bar) and AudioPlayer (banner).

AudioPlayer + MiniPlayer reduced to pure UI components that interact
through the store. They can mount/unmount freely without affecting
playback.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Audiolib
2026-05-26 18:22:44 +02:00
parent d93f972079
commit 38f7c9726e
5 changed files with 278 additions and 201 deletions

View File

@@ -4,6 +4,7 @@ import { useAuthStore } from './store/authStore'
import { usePlayerStore } from './store/playerStore' import { usePlayerStore } from './store/playerStore'
import Layout from './components/common/Layout' import Layout from './components/common/Layout'
import AudioPlayer from './components/player/AudioPlayer' import AudioPlayer from './components/player/AudioPlayer'
import AudioEngine from './components/player/AudioEngine'
import Login from './pages/Login' import Login from './pages/Login'
import Setup from './pages/Setup' import Setup from './pages/Setup'
import Library from './pages/Library' import Library from './pages/Library'
@@ -21,6 +22,7 @@ function AppRoutes() {
const { user, loadAuth } = useAuthStore() const { user, loadAuth } = useAuthStore()
const { libraries } = useAuthStore() const { libraries } = useAuthStore()
const { expanded, setExpanded } = usePlayerStore() const { expanded, setExpanded } = usePlayerStore()
const item = usePlayerStore((s) => s.item)
const [setupNeeded, setSetupNeeded] = useState<boolean | null>(null) const [setupNeeded, setSetupNeeded] = useState<boolean | null>(null)
useEffect(() => { useEffect(() => {
@@ -64,6 +66,8 @@ function AppRoutes() {
/> />
</Routes> </Routes>
{item && <AudioEngine />}
{expanded && ( {expanded && (
<div className="fixed inset-0 bg-background z-50 overflow-y-auto"> <div className="fixed inset-0 bg-background z-50 overflow-y-auto">
<AudioPlayer /> <AudioPlayer />

View File

@@ -0,0 +1,229 @@
import { useEffect, useRef } from 'react'
import { usePlayerStore } from '../../store/playerStore'
interface Track {
index: number
startOffset: number
duration: number
contentUrl: string
mimeType: string
}
function findTrackForTime(tracks: Track[], time: number): number {
for (let i = tracks.length - 1; i >= 0; i--) {
if (time >= (tracks[i].startOffset || 0)) return i
}
return 0
}
/**
* Globale Audio-Engine. Wird immer gemountet, sobald ein item geladen ist.
* Hält das einzige <audio>-Element der App, lauscht auf Store-Änderungen
* (seekRequest, isPlaying, playbackRate, volume) und schreibt currentTime
* zurück in den Store.
*/
export default function AudioEngine() {
const session = usePlayerStore((s) => s.session)
const isPlaying = usePlayerStore((s) => s.isPlaying)
const playbackRate = usePlayerStore((s) => s.playbackRate)
const volume = usePlayerStore((s) => s.volume)
const seekRequest = usePlayerStore((s) => s.seekRequest)
const setCurrentTime = usePlayerStore((s) => s.setCurrentTime)
const setPlaying = usePlayerStore((s) => s.setPlaying)
const setPlayerError = usePlayerStore((s) => s.setPlayerError)
const syncProgress = usePlayerStore((s) => s.syncProgress)
const audioRef = useRef<HTMLAudioElement | null>(null)
const currentTrackIdx = useRef(0)
const pendingSeek = useRef<number | null>(null)
const lastSeekCounter = useRef(0)
const isPlayingRef = useRef(false)
useEffect(() => { isPlayingRef.current = isPlaying }, [isPlaying])
const tracks: Track[] = session?.audioTracks || []
// Audio-Element nur einmal anlegen
useEffect(() => {
const audio = new Audio()
audio.preload = 'auto'
audioRef.current = audio
const onTimeUpdate = () => {
const a = audioRef.current
if (!a) return
const tracksNow = usePlayerStore.getState().session?.audioTracks || []
const offset = tracksNow[currentTrackIdx.current]?.startOffset || 0
setCurrentTime(offset + a.currentTime)
}
const onLoadedMetadata = () => {
const a = audioRef.current
if (!a) return
console.log('[Engine] loadedmetadata', {
trackIdx: currentTrackIdx.current,
duration: a.duration,
pendingSeek: pendingSeek.current,
})
if (pendingSeek.current !== null) {
try { a.currentTime = pendingSeek.current } catch (e) { console.warn('seek failed', e) }
pendingSeek.current = null
}
}
const onCanPlay = () => {
if (pendingSeek.current !== null && audioRef.current) {
try { audioRef.current.currentTime = pendingSeek.current } catch {}
pendingSeek.current = null
}
}
const onEnded = () => {
const tracksNow = usePlayerStore.getState().session?.audioTracks || []
const nextIdx = currentTrackIdx.current + 1
console.log('[Engine] ended', { nextIdx, total: tracksNow.length })
if (nextIdx < tracksNow.length) {
currentTrackIdx.current = nextIdx
pendingSeek.current = null
const a = audioRef.current!
a.src = tracksNow[nextIdx].contentUrl
a.load()
a.play().catch((e) => {
console.warn('[Engine] play() failed:', e)
setPlayerError(`Wiedergabe blockiert: ${e?.message || e}`)
})
} else {
setPlaying(false)
syncProgress()
}
}
const onError = () => {
const a = audioRef.current
const err = a?.error
const codeMap: Record<number, string> = {
1: 'MEDIA_ERR_ABORTED — Abruf abgebrochen',
2: 'MEDIA_ERR_NETWORK — Netzwerkfehler beim Laden',
3: 'MEDIA_ERR_DECODE — Format nicht dekodierbar',
4: 'MEDIA_ERR_SRC_NOT_SUPPORTED — Quelle nicht unterstützt (404/Format/MIME)',
}
const msg = err
? `${codeMap[err.code] || `Code ${err.code}`}${err.message ? ': ' + err.message : ''}`
: 'Unbekannter Audio-Fehler'
console.error('[Engine] audio error', {
code: err?.code,
message: err?.message,
src: a?.src,
networkState: a?.networkState,
readyState: a?.readyState,
})
setPlayerError(msg)
}
audio.addEventListener('timeupdate', onTimeUpdate)
audio.addEventListener('loadedmetadata', onLoadedMetadata)
audio.addEventListener('canplay', onCanPlay)
audio.addEventListener('ended', onEnded)
audio.addEventListener('error', onError)
return () => {
audio.removeEventListener('timeupdate', onTimeUpdate)
audio.removeEventListener('loadedmetadata', onLoadedMetadata)
audio.removeEventListener('canplay', onCanPlay)
audio.removeEventListener('ended', onEnded)
audio.removeEventListener('error', onError)
audio.pause()
audio.removeAttribute('src')
audio.load()
audioRef.current = null
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
// Bei neuer Session: Track laden
useEffect(() => {
const audio = audioRef.current
if (!audio || !session || !tracks.length) return
const startTime = session.currentTime || 0
const startIdx = findTrackForTime(tracks, startTime)
const timeInTrack = startTime - (tracks[startIdx]?.startOffset || 0)
console.log('[Engine] init session', {
sessionId: session.id,
tracks: tracks.length,
duration: session.duration,
startTime,
startIdx,
timeInTrack,
url: tracks[startIdx]?.contentUrl,
})
currentTrackIdx.current = startIdx
pendingSeek.current = timeInTrack > 0 ? timeInTrack : null
setPlayerError(null)
audio.playbackRate = playbackRate
audio.volume = volume
audio.src = tracks[startIdx].contentUrl
audio.load()
audio.play().catch((e) => {
console.warn('[Engine] play() failed:', e)
setPlayerError(`Wiedergabe blockiert: ${e?.message || e}`)
})
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [session?.id])
// play/pause
useEffect(() => {
const audio = audioRef.current
if (!audio || !audio.src) return
if (isPlaying) {
audio.play().catch((e) => {
console.warn('[Engine] play() failed:', e)
setPlayerError(`Wiedergabe blockiert: ${e?.message || e}`)
})
} else {
audio.pause()
}
}, [isPlaying, setPlayerError])
// playbackRate
useEffect(() => {
if (audioRef.current) audioRef.current.playbackRate = playbackRate
}, [playbackRate])
// volume
useEffect(() => {
if (audioRef.current) audioRef.current.volume = volume
}, [volume])
// seekRequest: extern angefragter Sprung
useEffect(() => {
if (!seekRequest || seekRequest.counter === lastSeekCounter.current) return
lastSeekCounter.current = seekRequest.counter
const audio = audioRef.current
const tracksNow = usePlayerStore.getState().session?.audioTracks || []
if (!audio || !tracksNow.length) return
const t = seekRequest.time
const targetIdx = findTrackForTime(tracksNow, t)
const timeInTrack = t - (tracksNow[targetIdx]?.startOffset || 0)
console.log('[Engine] seek', { t, targetIdx, timeInTrack, currentIdx: currentTrackIdx.current })
if (targetIdx === currentTrackIdx.current) {
audio.currentTime = timeInTrack
} else {
currentTrackIdx.current = targetIdx
pendingSeek.current = timeInTrack
audio.src = tracksNow[targetIdx].contentUrl
audio.load()
if (isPlayingRef.current) {
audio.play().catch((e) => {
console.warn('[Engine] play() failed:', e)
setPlayerError(`Wiedergabe blockiert: ${e?.message || e}`)
})
}
}
}, [seekRequest, setPlayerError])
return null
}

View File

@@ -1,4 +1,4 @@
import React, { useCallback, useEffect, useRef, useState } from 'react' import React, { useEffect, useState } from 'react'
import { import {
Play, Pause, SkipBack, SkipForward, Volume2, VolumeX, Play, Pause, SkipBack, SkipForward, Volume2, VolumeX,
List, BookmarkPlus, Moon, X, ChevronLeft List, BookmarkPlus, Moon, X, ChevronLeft
@@ -9,25 +9,6 @@ import CoverImage from '../common/CoverImage'
import { coverUrl } from '../../api/items' import { coverUrl } from '../../api/items'
import ChapterList from './ChapterList' import ChapterList from './ChapterList'
interface Track {
index: number
startOffset: number
duration: number
contentUrl: string
mimeType: string
}
function findTrackForTime(tracks: Track[], time: number): number {
for (let i = tracks.length - 1; i >= 0; i--) {
if (time >= (tracks[i].startOffset || 0)) return i
}
return 0
}
function trackUrl(track: Track): string {
return track.contentUrl
}
export default function AudioPlayer() { export default function AudioPlayer() {
const session = usePlayerStore((s) => s.session) const session = usePlayerStore((s) => s.session)
const item = usePlayerStore((s) => s.item) const item = usePlayerStore((s) => s.item)
@@ -39,184 +20,38 @@ export default function AudioPlayer() {
const chapters = usePlayerStore((s) => s.chapters) const chapters = usePlayerStore((s) => s.chapters)
const sleepTimer = usePlayerStore((s) => s.sleepTimer) const sleepTimer = usePlayerStore((s) => s.sleepTimer)
const sleepTimerActive = usePlayerStore((s) => s.sleepTimerActive) const sleepTimerActive = usePlayerStore((s) => s.sleepTimerActive)
const playerError = usePlayerStore((s) => s.playerError)
const setPlaying = usePlayerStore((s) => s.setPlaying) const setPlaying = usePlayerStore((s) => s.setPlaying)
const setCurrentTime = usePlayerStore((s) => s.setCurrentTime)
const setPlaybackRate = usePlayerStore((s) => s.setPlaybackRate) const setPlaybackRate = usePlayerStore((s) => s.setPlaybackRate)
const setVolume = usePlayerStore((s) => s.setVolume)
const seek = usePlayerStore((s) => s.seek) const seek = usePlayerStore((s) => s.seek)
const stop = usePlayerStore((s) => s.stop) const stop = usePlayerStore((s) => s.stop)
const setExpanded = usePlayerStore((s) => s.setExpanded) const setExpanded = usePlayerStore((s) => s.setExpanded)
const setSleepTimer = usePlayerStore((s) => s.setSleepTimer) const setSleepTimer = usePlayerStore((s) => s.setSleepTimer)
const cancelSleepTimer = usePlayerStore((s) => s.cancelSleepTimer) const cancelSleepTimer = usePlayerStore((s) => s.cancelSleepTimer)
const syncProgress = usePlayerStore((s) => s.syncProgress)
const audioRef = useRef<HTMLAudioElement>(null)
const currentTrackIdx = useRef(0)
const pendingSeek = useRef<number | null>(null)
const [showChapters, setShowChapters] = useState(false) const [showChapters, setShowChapters] = useState(false)
const [showSpeedMenu, setShowSpeedMenu] = useState(false) const [showSpeedMenu, setShowSpeedMenu] = useState(false)
const [showSleepMenu, setShowSleepMenu] = useState(false) const [showSleepMenu, setShowSleepMenu] = useState(false)
const [muted, setMuted] = useState(false) const [muted, setMuted] = useState(false)
const [playerError, setPlayerError] = useState<string | null>(null) const [lastVolume, setLastVolume] = useState(1)
const tracks: Track[] = session?.audioTracks || []
// Initialize on session change
useEffect(() => { useEffect(() => {
if (!session || !audioRef.current || !tracks.length) return if (muted) setVolume(0)
const audio = audioRef.current else setVolume(lastVolume)
const startTime = session.currentTime || 0
const startIdx = findTrackForTime(tracks, startTime)
const timeInTrack = startTime - (tracks[startIdx]?.startOffset || 0)
console.log('[Player] init session', {
sessionId: session.id,
tracks: tracks.length,
duration: session.duration,
startTime,
startIdx,
timeInTrack,
})
currentTrackIdx.current = startIdx
pendingSeek.current = timeInTrack > 0 ? timeInTrack : null
setPlayerError(null)
audio.preload = 'auto'
audio.playbackRate = playbackRate
audio.volume = muted ? 0 : volume
audio.src = trackUrl(tracks[startIdx])
audio.load()
audio.play().catch((e) => {
console.warn('[Player] play() failed:', e)
setPlayerError(`Wiedergabe blockiert: ${e?.message || e}`)
})
return () => {
audio.pause()
audio.removeAttribute('src')
audio.load()
}
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [session?.id]) }, [muted])
// Sync isPlaying with audio element
useEffect(() => {
const audio = audioRef.current
if (!audio || !audio.src) return
if (isPlaying) audio.play().catch((e) => console.warn('[Player] play() failed:', e))
else audio.pause()
}, [isPlaying])
useEffect(() => {
if (audioRef.current) audioRef.current.playbackRate = playbackRate
}, [playbackRate])
useEffect(() => {
if (audioRef.current) audioRef.current.volume = muted ? 0 : volume
}, [volume, muted])
const seekTo = useCallback((absoluteTime: number) => {
const audio = audioRef.current
if (!audio || !tracks.length) return
const lastTrack = tracks[tracks.length - 1]
const totalDur = (lastTrack?.startOffset || 0) + (lastTrack?.duration || 0)
const clamped = Math.max(0, Math.min(absoluteTime, totalDur || absoluteTime))
const targetIdx = findTrackForTime(tracks, clamped)
const timeInTrack = clamped - (tracks[targetIdx]?.startOffset || 0)
console.log('[Player] seek', { absoluteTime, clamped, targetIdx, timeInTrack, currentIdx: currentTrackIdx.current })
if (targetIdx === currentTrackIdx.current) {
audio.currentTime = timeInTrack
} else {
currentTrackIdx.current = targetIdx
pendingSeek.current = timeInTrack
audio.src = trackUrl(tracks[targetIdx])
audio.load()
if (isPlaying) audio.play().catch((e) => console.warn('[Player] play() failed:', e))
}
seek(clamped)
}, [tracks, isPlaying, seek])
// Keyboard shortcuts — re-register when seekTo changes
useEffect(() => { useEffect(() => {
const handler = (e: KeyboardEvent) => { const handler = (e: KeyboardEvent) => {
if (e.target instanceof HTMLInputElement) return if (e.target instanceof HTMLInputElement) return
if (e.code === 'Space') { e.preventDefault(); setPlaying(!isPlaying) } if (e.code === 'Space') { e.preventDefault(); setPlaying(!isPlaying) }
else if (e.code === 'ArrowRight') seekTo(currentTime + 30) else if (e.code === 'ArrowRight') seek(currentTime + 30)
else if (e.code === 'ArrowLeft') seekTo(Math.max(0, currentTime - 10)) else if (e.code === 'ArrowLeft') seek(Math.max(0, currentTime - 10))
} }
window.addEventListener('keydown', handler) window.addEventListener('keydown', handler)
return () => window.removeEventListener('keydown', handler) return () => window.removeEventListener('keydown', handler)
}, [seekTo, currentTime, isPlaying, setPlaying]) }, [currentTime, isPlaying, seek, setPlaying])
const onTimeUpdate = () => {
const audio = audioRef.current
if (!audio) return
const offset = tracks[currentTrackIdx.current]?.startOffset || 0
setCurrentTime(offset + audio.currentTime)
}
const onLoadedMetadata = () => {
const audio = audioRef.current
if (!audio) return
console.log('[Player] loadedmetadata', {
trackIdx: currentTrackIdx.current,
duration: audio.duration,
pendingSeek: pendingSeek.current,
})
if (pendingSeek.current !== null) {
try { audio.currentTime = pendingSeek.current } catch (e) { console.warn('seek failed', e) }
pendingSeek.current = null
}
}
const onCanPlay = () => {
if (pendingSeek.current !== null && audioRef.current) {
try { audioRef.current.currentTime = pendingSeek.current } catch {}
pendingSeek.current = null
}
}
const onEnded = () => {
const nextIdx = currentTrackIdx.current + 1
console.log('[Player] track ended', { nextIdx, total: tracks.length })
if (nextIdx < tracks.length) {
currentTrackIdx.current = nextIdx
pendingSeek.current = null
const audio = audioRef.current!
audio.src = trackUrl(tracks[nextIdx])
audio.load()
audio.play().catch((e) => console.warn('[Player] play() failed:', e))
} else {
setPlaying(false)
syncProgress()
}
}
const onError = () => {
const audio = audioRef.current
const err = audio?.error
const codeMap: Record<number, string> = {
1: 'MEDIA_ERR_ABORTED — Abruf abgebrochen',
2: 'MEDIA_ERR_NETWORK — Netzwerkfehler beim Laden',
3: 'MEDIA_ERR_DECODE — Format nicht dekodierbar',
4: 'MEDIA_ERR_SRC_NOT_SUPPORTED — Quelle nicht unterstützt (404/Format/MIME)',
}
const msg = err ? `${codeMap[err.code] || `Code ${err.code}`}${err.message ? ': ' + err.message : ''}` : 'Unbekannter Fehler'
console.error('[Player] audio error', {
code: err?.code,
message: err?.message,
src: audio?.src,
networkState: audio?.networkState,
readyState: audio?.readyState,
})
setPlayerError(msg)
}
const fmtTime = (s: number) => { const fmtTime = (s: number) => {
if (!isFinite(s)) return '0:00' if (!isFinite(s)) return '0:00'
@@ -231,7 +66,6 @@ export default function AudioPlayer() {
const meta = item?.media?.metadata || {} const meta = item?.media?.metadata || {}
const title = meta.title || item?.relPath || '' const title = meta.title || item?.relPath || ''
const author = meta.authors?.[0]?.name || '' const author = meta.authors?.[0]?.name || ''
const currentChapter = [...chapters].reverse().find((c: any) => currentTime >= c.start) || chapters[0] const currentChapter = [...chapters].reverse().find((c: any) => currentTime >= c.start) || chapters[0]
const addBookmark = async () => { const addBookmark = async () => {
@@ -247,17 +81,13 @@ export default function AudioPlayer() {
pct: duration > 0 ? (c.start / duration) * 100 : 0, pct: duration > 0 ? (c.start / duration) * 100 : 0,
})) }))
const streamUrl = session?.audioTracks?.[0]?.contentUrl
const trackInfo = session?.audioTracks
? `${session.audioTracks.length} Track(s), Gesamt-Dauer ${fmtTime(duration)}`
: 'Keine Tracks'
return ( return (
<div className="flex flex-col h-full bg-background p-6 overflow-y-auto"> <div className="flex flex-col h-full bg-background p-6 overflow-y-auto">
<audio
ref={audioRef}
onTimeUpdate={onTimeUpdate}
onLoadedMetadata={onLoadedMetadata}
onCanPlay={onCanPlay}
onEnded={onEnded}
onError={onError}
/>
<div className="flex items-center justify-between mb-6"> <div className="flex items-center justify-between mb-6">
<button onClick={() => setExpanded(false)} className="text-muted hover:text-ink transition-colors"> <button onClick={() => setExpanded(false)} className="text-muted hover:text-ink transition-colors">
<ChevronLeft size={24} /> <ChevronLeft size={24} />
@@ -288,9 +118,12 @@ export default function AudioPlayer() {
<div className="mb-4 px-4 py-3 rounded-lg border border-red-500/40 bg-red-500/10 text-red-300" style={{ fontSize: '12px' }}> <div className="mb-4 px-4 py-3 rounded-lg border border-red-500/40 bg-red-500/10 text-red-300" style={{ fontSize: '12px' }}>
<p className="font-medium mb-1">Audio-Fehler</p> <p className="font-medium mb-1">Audio-Fehler</p>
<p className="opacity-80">{playerError}</p> <p className="opacity-80">{playerError}</p>
<p className="opacity-60 mt-2" style={{ fontSize: '10px' }}> {streamUrl && (
Stream-URL: <span className="font-mono break-all">{tracks[currentTrackIdx.current]?.contentUrl}</span> <p className="opacity-60 mt-2" style={{ fontSize: '10px' }}>
</p> Stream-URL: <span className="font-mono break-all">{streamUrl}</span>
</p>
)}
<p className="opacity-60 mt-1" style={{ fontSize: '10px' }}>{trackInfo}</p>
</div> </div>
)} )}
@@ -305,7 +138,7 @@ export default function AudioPlayer() {
/> />
<input <input
type="range" min={0} max={duration || 0} step={1} value={currentTime} type="range" min={0} max={duration || 0} step={1} value={currentTime}
onChange={(e) => seekTo(parseFloat(e.target.value))} onChange={(e) => seek(parseFloat(e.target.value))}
className="absolute inset-0 w-full opacity-0 cursor-pointer h-full" className="absolute inset-0 w-full opacity-0 cursor-pointer h-full"
/> />
</div> </div>
@@ -316,7 +149,7 @@ export default function AudioPlayer() {
</div> </div>
<div className="flex items-center justify-center gap-6 mb-6"> <div className="flex items-center justify-center gap-6 mb-6">
<button className="text-muted hover:text-ink transition-colors" onClick={() => seekTo(currentTime - 30)}> <button className="text-muted hover:text-ink transition-colors" onClick={() => seek(Math.max(0, currentTime - 30))}>
<SkipBack size={28} /> <SkipBack size={28} />
</button> </button>
<button <button
@@ -328,7 +161,7 @@ export default function AudioPlayer() {
> >
{isPlaying ? <Pause size={22} fill="currentColor" /> : <Play size={22} fill="currentColor" />} {isPlaying ? <Pause size={22} fill="currentColor" /> : <Play size={22} fill="currentColor" />}
</button> </button>
<button className="text-muted hover:text-ink transition-colors" onClick={() => seekTo(currentTime + 30)}> <button className="text-muted hover:text-ink transition-colors" onClick={() => seek(currentTime + 30)}>
<SkipForward size={28} /> <SkipForward size={28} />
</button> </button>
</div> </div>
@@ -393,7 +226,7 @@ export default function AudioPlayer() {
<List size={20} /> <List size={20} />
</button> </button>
<button className="text-muted hover:text-ink transition-colors" onClick={() => setMuted(!muted)}> <button className="text-muted hover:text-ink transition-colors" onClick={() => { setLastVolume(volume || 1); setMuted(!muted) }}>
{muted ? <VolumeX size={20} /> : <Volume2 size={20} />} {muted ? <VolumeX size={20} /> : <Volume2 size={20} />}
</button> </button>
</div> </div>
@@ -402,7 +235,7 @@ export default function AudioPlayer() {
<ChapterList <ChapterList
chapters={chapters} chapters={chapters}
currentTime={currentTime} currentTime={currentTime}
onSeek={seekTo} onSeek={seek}
/> />
)} )}
</div> </div>

View File

@@ -5,7 +5,7 @@ import CoverImage from '../common/CoverImage'
import { coverUrl } from '../../api/items' import { coverUrl } from '../../api/items'
export default function MiniPlayer() { export default function MiniPlayer() {
const { item, currentTime, duration, isPlaying, setPlaying, stop, setExpanded } = usePlayerStore() const { item, currentTime, duration, isPlaying, setPlaying, stop, setExpanded, playerError } = usePlayerStore()
if (!item) return null if (!item) return null
const meta = item.media?.metadata || {} const meta = item.media?.metadata || {}
@@ -15,11 +15,11 @@ export default function MiniPlayer() {
return ( return (
<div className="h-full flex flex-col bg-surface border-t border-divider"> <div className="h-full flex flex-col bg-surface border-t border-divider">
{/* Progress track */} {/* Progress track (oder Fehler-Strich rot) */}
<div className="bg-muted2 relative" style={{ height: '4px', borderRadius: '2px' }}> <div className="bg-muted2 relative" style={{ height: '4px', borderRadius: '2px' }}>
<div <div
className="h-full bg-ink" className={`h-full ${playerError ? 'bg-red-500' : 'bg-ink'}`}
style={{ width: `${pct}%`, borderRadius: '2px', transition: 'width 0.5s linear' }} style={{ width: playerError ? '100%' : `${pct}%`, borderRadius: '2px', transition: 'width 0.5s linear' }}
/> />
</div> </div>

View File

@@ -12,10 +12,12 @@ interface PlayerState {
isPlaying: boolean isPlaying: boolean
playbackRate: number playbackRate: number
volume: number volume: number
sleepTimer: number | null // Sekunden verbleibend sleepTimer: number | null
sleepTimerActive: boolean sleepTimerActive: boolean
chapters: Chapter[] chapters: Chapter[]
expanded: boolean expanded: boolean
seekRequest: { time: number; counter: number } | null
playerError: string | null
play: (item: any) => Promise<void> play: (item: any) => Promise<void>
stop: () => Promise<void> stop: () => Promise<void>
@@ -28,6 +30,7 @@ interface PlayerState {
setSleepTimer: (seconds: number) => void setSleepTimer: (seconds: number) => void
cancelSleepTimer: () => void cancelSleepTimer: () => void
setExpanded: (v: boolean) => void setExpanded: (v: boolean) => void
setPlayerError: (msg: string | null) => void
syncProgress: () => Promise<void> syncProgress: () => Promise<void>
} }
@@ -46,6 +49,8 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
sleepTimerActive: false, sleepTimerActive: false,
chapters: [], chapters: [],
expanded: false, expanded: false,
seekRequest: null,
playerError: null,
play: async (item: any) => { play: async (item: any) => {
const { session: oldSession, stop } = get() const { session: oldSession, stop } = get()
@@ -60,9 +65,10 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
chapters: session.chapters || [], chapters: session.chapters || [],
isPlaying: true, isPlaying: true,
expanded: false, expanded: false,
seekRequest: null,
playerError: null,
}) })
// Sync alle 15 Sekunden
if (syncInterval) clearInterval(syncInterval) if (syncInterval) clearInterval(syncInterval)
syncInterval = setInterval(() => get().syncProgress(), 15000) syncInterval = setInterval(() => get().syncProgress(), 15000)
}, },
@@ -74,10 +80,13 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
if (session) { if (session) {
try { await closeSession(session.id) } catch { } try { await closeSession(session.id) } catch { }
} }
set({ item: null, session: null, isPlaying: false, currentTime: 0, sleepTimerActive: false }) set({ item: null, session: null, isPlaying: false, currentTime: 0, sleepTimerActive: false, seekRequest: null, playerError: null })
}, },
seek: (time) => set({ currentTime: time }), seek: (time) => set((s) => ({
currentTime: time,
seekRequest: { time, counter: (s.seekRequest?.counter || 0) + 1 },
})),
setPlaying: (v) => set({ isPlaying: v }), setPlaying: (v) => set({ isPlaying: v }),
@@ -110,6 +119,8 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
setExpanded: (v) => set({ expanded: v }), setExpanded: (v) => set({ expanded: v }),
setPlayerError: (msg) => set({ playerError: msg }),
syncProgress: async () => { syncProgress: async () => {
const { session, currentTime, duration, item } = get() const { session, currentTime, duration, item } = get()
if (!session || !item) return if (!session || !item) return