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 Layout from './components/common/Layout'
|
||||
import AudioPlayer from './components/player/AudioPlayer'
|
||||
import AudioEngine from './components/player/AudioEngine'
|
||||
import Login from './pages/Login'
|
||||
import Setup from './pages/Setup'
|
||||
import Library from './pages/Library'
|
||||
@@ -21,6 +22,7 @@ function AppRoutes() {
|
||||
const { user, loadAuth } = useAuthStore()
|
||||
const { libraries } = useAuthStore()
|
||||
const { expanded, setExpanded } = usePlayerStore()
|
||||
const item = usePlayerStore((s) => s.item)
|
||||
const [setupNeeded, setSetupNeeded] = useState<boolean | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -64,6 +66,8 @@ function AppRoutes() {
|
||||
/>
|
||||
</Routes>
|
||||
|
||||
{item && <AudioEngine />}
|
||||
|
||||
{expanded && (
|
||||
<div className="fixed inset-0 bg-background z-50 overflow-y-auto">
|
||||
<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 {
|
||||
Play, Pause, SkipBack, SkipForward, Volume2, VolumeX,
|
||||
List, BookmarkPlus, Moon, X, ChevronLeft
|
||||
@@ -9,25 +9,6 @@ import CoverImage from '../common/CoverImage'
|
||||
import { coverUrl } from '../../api/items'
|
||||
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() {
|
||||
const session = usePlayerStore((s) => s.session)
|
||||
const item = usePlayerStore((s) => s.item)
|
||||
@@ -39,184 +20,38 @@ export default function AudioPlayer() {
|
||||
const chapters = usePlayerStore((s) => s.chapters)
|
||||
const sleepTimer = usePlayerStore((s) => s.sleepTimer)
|
||||
const sleepTimerActive = usePlayerStore((s) => s.sleepTimerActive)
|
||||
const playerError = usePlayerStore((s) => s.playerError)
|
||||
const setPlaying = usePlayerStore((s) => s.setPlaying)
|
||||
const setCurrentTime = usePlayerStore((s) => s.setCurrentTime)
|
||||
const setPlaybackRate = usePlayerStore((s) => s.setPlaybackRate)
|
||||
const setVolume = usePlayerStore((s) => s.setVolume)
|
||||
const seek = usePlayerStore((s) => s.seek)
|
||||
const stop = usePlayerStore((s) => s.stop)
|
||||
const setExpanded = usePlayerStore((s) => s.setExpanded)
|
||||
const setSleepTimer = usePlayerStore((s) => s.setSleepTimer)
|
||||
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 [showSpeedMenu, setShowSpeedMenu] = useState(false)
|
||||
const [showSleepMenu, setShowSleepMenu] = 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(() => {
|
||||
if (!session || !audioRef.current || !tracks.length) return
|
||||
const audio = audioRef.current
|
||||
|
||||
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()
|
||||
}
|
||||
if (muted) setVolume(0)
|
||||
else setVolume(lastVolume)
|
||||
// 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(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.target instanceof HTMLInputElement) return
|
||||
if (e.code === 'Space') { e.preventDefault(); setPlaying(!isPlaying) }
|
||||
else if (e.code === 'ArrowRight') seekTo(currentTime + 30)
|
||||
else if (e.code === 'ArrowLeft') seekTo(Math.max(0, currentTime - 10))
|
||||
else if (e.code === 'ArrowRight') seek(currentTime + 30)
|
||||
else if (e.code === 'ArrowLeft') seek(Math.max(0, currentTime - 10))
|
||||
}
|
||||
window.addEventListener('keydown', handler)
|
||||
return () => window.removeEventListener('keydown', handler)
|
||||
}, [seekTo, currentTime, isPlaying, 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)
|
||||
}
|
||||
}, [currentTime, isPlaying, seek, setPlaying])
|
||||
|
||||
const fmtTime = (s: number) => {
|
||||
if (!isFinite(s)) return '0:00'
|
||||
@@ -231,7 +66,6 @@ export default function AudioPlayer() {
|
||||
const meta = item?.media?.metadata || {}
|
||||
const title = meta.title || item?.relPath || ''
|
||||
const author = meta.authors?.[0]?.name || ''
|
||||
|
||||
const currentChapter = [...chapters].reverse().find((c: any) => currentTime >= c.start) || chapters[0]
|
||||
|
||||
const addBookmark = async () => {
|
||||
@@ -247,17 +81,13 @@ export default function AudioPlayer() {
|
||||
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 (
|
||||
<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">
|
||||
<button onClick={() => setExpanded(false)} className="text-muted hover:text-ink transition-colors">
|
||||
<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' }}>
|
||||
<p className="font-medium mb-1">Audio-Fehler</p>
|
||||
<p className="opacity-80">{playerError}</p>
|
||||
{streamUrl && (
|
||||
<p className="opacity-60 mt-2" style={{ fontSize: '10px' }}>
|
||||
Stream-URL: <span className="font-mono break-all">{tracks[currentTrackIdx.current]?.contentUrl}</span>
|
||||
Stream-URL: <span className="font-mono break-all">{streamUrl}</span>
|
||||
</p>
|
||||
)}
|
||||
<p className="opacity-60 mt-1" style={{ fontSize: '10px' }}>{trackInfo}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -305,7 +138,7 @@ export default function AudioPlayer() {
|
||||
/>
|
||||
<input
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
@@ -316,7 +149,7 @@ export default function AudioPlayer() {
|
||||
</div>
|
||||
|
||||
<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} />
|
||||
</button>
|
||||
<button
|
||||
@@ -328,7 +161,7 @@ export default function AudioPlayer() {
|
||||
>
|
||||
{isPlaying ? <Pause size={22} fill="currentColor" /> : <Play size={22} fill="currentColor" />}
|
||||
</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} />
|
||||
</button>
|
||||
</div>
|
||||
@@ -393,7 +226,7 @@ export default function AudioPlayer() {
|
||||
<List size={20} />
|
||||
</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} />}
|
||||
</button>
|
||||
</div>
|
||||
@@ -402,7 +235,7 @@ export default function AudioPlayer() {
|
||||
<ChapterList
|
||||
chapters={chapters}
|
||||
currentTime={currentTime}
|
||||
onSeek={seekTo}
|
||||
onSeek={seek}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -5,7 +5,7 @@ import CoverImage from '../common/CoverImage'
|
||||
import { coverUrl } from '../../api/items'
|
||||
|
||||
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
|
||||
|
||||
const meta = item.media?.metadata || {}
|
||||
@@ -15,11 +15,11 @@ export default function MiniPlayer() {
|
||||
|
||||
return (
|
||||
<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="h-full bg-ink"
|
||||
style={{ width: `${pct}%`, borderRadius: '2px', transition: 'width 0.5s linear' }}
|
||||
className={`h-full ${playerError ? 'bg-red-500' : 'bg-ink'}`}
|
||||
style={{ width: playerError ? '100%' : `${pct}%`, borderRadius: '2px', transition: 'width 0.5s linear' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -12,10 +12,12 @@ interface PlayerState {
|
||||
isPlaying: boolean
|
||||
playbackRate: number
|
||||
volume: number
|
||||
sleepTimer: number | null // Sekunden verbleibend
|
||||
sleepTimer: number | null
|
||||
sleepTimerActive: boolean
|
||||
chapters: Chapter[]
|
||||
expanded: boolean
|
||||
seekRequest: { time: number; counter: number } | null
|
||||
playerError: string | null
|
||||
|
||||
play: (item: any) => Promise<void>
|
||||
stop: () => Promise<void>
|
||||
@@ -28,6 +30,7 @@ interface PlayerState {
|
||||
setSleepTimer: (seconds: number) => void
|
||||
cancelSleepTimer: () => void
|
||||
setExpanded: (v: boolean) => void
|
||||
setPlayerError: (msg: string | null) => void
|
||||
syncProgress: () => Promise<void>
|
||||
}
|
||||
|
||||
@@ -46,6 +49,8 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
|
||||
sleepTimerActive: false,
|
||||
chapters: [],
|
||||
expanded: false,
|
||||
seekRequest: null,
|
||||
playerError: null,
|
||||
|
||||
play: async (item: any) => {
|
||||
const { session: oldSession, stop } = get()
|
||||
@@ -60,9 +65,10 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
|
||||
chapters: session.chapters || [],
|
||||
isPlaying: true,
|
||||
expanded: false,
|
||||
seekRequest: null,
|
||||
playerError: null,
|
||||
})
|
||||
|
||||
// Sync alle 15 Sekunden
|
||||
if (syncInterval) clearInterval(syncInterval)
|
||||
syncInterval = setInterval(() => get().syncProgress(), 15000)
|
||||
},
|
||||
@@ -74,10 +80,13 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
|
||||
if (session) {
|
||||
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 }),
|
||||
|
||||
@@ -110,6 +119,8 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
|
||||
|
||||
setExpanded: (v) => set({ expanded: v }),
|
||||
|
||||
setPlayerError: (msg) => set({ playerError: msg }),
|
||||
|
||||
syncProgress: async () => {
|
||||
const { session, currentTime, duration, item } = get()
|
||||
if (!session || !item) return
|
||||
|
||||
Reference in New Issue
Block a user