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:
@@ -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 />
|
||||||
|
|||||||
229
frontend/src/components/player/AudioEngine.tsx
Normal file
229
frontend/src/components/player/AudioEngine.tsx
Normal 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
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user