Rewrite player + fix matching metadata loss
Streaming: Custom range-aware HTTP endpoint. Returns 206 Partial Content for Range requests (with Content-Range, Content-Length, Accept-Ranges). This was the root cause of broken seeking — Starlette's default FileResponse behavior wasn't reliable across all clients. Now seeking works natively via standard HTML5 audio. Player: Full rewrite. Cleaner separation between absolute book time and per-track time. Track switching uses pendingSeek + canplay/loadedmetadata handlers. Console logs for debugging. Removed crossOrigin to avoid CORS issues. Removed hls.js entirely. Matcher: Critical bug fix — get_work_details (OpenLibrary) was returning a sparse MatchResult that REPLACED the rich search result, losing cover, author, year. New _enrich_match merges details into best without overwriting existing values (except description/chapters which are preferred from details fetch). Scoring: Lenient min/max-weighted similarity (better for German episodic titles like "Die drei ??? - Folge 215"). Thresholds lowered: UNCERTAIN 0.50→0.40, AUTO_ACCEPT 0.75→0.65. Search: search_for_item now returns ALL fields (narrator, publisher, series, genres, description, language) so manual apply has full data. Apply: apply_match now always constructs from body first, then enriches with details. Previously OL applies would lose cover/author. Added detailed logging across matcher and apply paths. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -9,77 +9,101 @@ 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 {
|
||||
const token = localStorage.getItem('token') || ''
|
||||
const sep = track.contentUrl.includes('?') ? '&' : '?'
|
||||
return `${track.contentUrl}${sep}token=${encodeURIComponent(token)}`
|
||||
}
|
||||
|
||||
export default function AudioPlayer() {
|
||||
const {
|
||||
item, session, currentTime, duration, isPlaying, playbackRate, volume, chapters,
|
||||
sleepTimerActive, sleepTimer,
|
||||
setPlaying, setCurrentTime, setPlaybackRate, setVolume,
|
||||
seek, stop, setExpanded, setSleepTimer, cancelSleepTimer, syncProgress,
|
||||
} = usePlayerStore()
|
||||
const session = usePlayerStore((s) => s.session)
|
||||
const item = usePlayerStore((s) => s.item)
|
||||
const currentTime = usePlayerStore((s) => s.currentTime)
|
||||
const duration = usePlayerStore((s) => s.duration)
|
||||
const isPlaying = usePlayerStore((s) => s.isPlaying)
|
||||
const playbackRate = usePlayerStore((s) => s.playbackRate)
|
||||
const volume = usePlayerStore((s) => s.volume)
|
||||
const chapters = usePlayerStore((s) => s.chapters)
|
||||
const sleepTimer = usePlayerStore((s) => s.sleepTimer)
|
||||
const sleepTimerActive = usePlayerStore((s) => s.sleepTimerActive)
|
||||
const setPlaying = usePlayerStore((s) => s.setPlaying)
|
||||
const setCurrentTime = usePlayerStore((s) => s.setCurrentTime)
|
||||
const setPlaybackRate = usePlayerStore((s) => s.setPlaybackRate)
|
||||
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 tracksRef = useRef<any[]>([])
|
||||
const currentTrackIdxRef = useRef(0)
|
||||
const pendingSeekRef = useRef<number | null>(null)
|
||||
const currentTimeRef = useRef(0)
|
||||
const isPlayingRef = useRef(false)
|
||||
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)
|
||||
|
||||
useEffect(() => { currentTimeRef.current = currentTime }, [currentTime])
|
||||
useEffect(() => { isPlayingRef.current = isPlaying }, [isPlaying])
|
||||
const tracks: Track[] = session?.audioTracks || []
|
||||
|
||||
const meta = item?.media?.metadata || {}
|
||||
const title = meta.title || item?.relPath || ''
|
||||
const author = meta.authors?.[0]?.name || ''
|
||||
|
||||
const loadTrack = useCallback((idx: number, startAt: number, autoPlay: boolean) => {
|
||||
const audio = audioRef.current
|
||||
const tracks = tracksRef.current
|
||||
if (!audio || !tracks[idx]) return
|
||||
const token = localStorage.getItem('token') || ''
|
||||
currentTrackIdxRef.current = idx
|
||||
audio.src = `${tracks[idx].contentUrl}&token=${encodeURIComponent(token)}`
|
||||
if (startAt > 0) {
|
||||
pendingSeekRef.current = startAt
|
||||
try { audio.currentTime = startAt } catch {}
|
||||
}
|
||||
if (autoPlay) audio.play().catch(() => {})
|
||||
}, [])
|
||||
|
||||
// Load audio when session starts
|
||||
// Initialize on session change
|
||||
useEffect(() => {
|
||||
if (!session || !audioRef.current) return
|
||||
const tracks = session.audioTracks || []
|
||||
if (!tracks.length) return
|
||||
if (!session || !audioRef.current || !tracks.length) return
|
||||
const audio = audioRef.current
|
||||
|
||||
tracksRef.current = tracks
|
||||
const startTime = session.currentTime || 0
|
||||
const startIdx = findTrackForTime(tracks, startTime)
|
||||
const timeInTrack = startTime - (tracks[startIdx]?.startOffset || 0)
|
||||
|
||||
let startIdx = 0
|
||||
for (let i = tracks.length - 1; i >= 0; i--) {
|
||||
if (startTime >= (tracks[i].startOffset || 0)) { startIdx = i; break }
|
||||
}
|
||||
console.log('[Player] init session', {
|
||||
sessionId: session.id,
|
||||
tracks: tracks.length,
|
||||
duration: session.duration,
|
||||
startTime,
|
||||
startIdx,
|
||||
timeInTrack,
|
||||
})
|
||||
|
||||
audioRef.current.playbackRate = playbackRate
|
||||
audioRef.current.volume = muted ? 0 : volume
|
||||
loadTrack(startIdx, startTime - (tracks[startIdx].startOffset || 0), true)
|
||||
currentTrackIdx.current = startIdx
|
||||
pendingSeek.current = timeInTrack > 0 ? timeInTrack : 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))
|
||||
|
||||
return () => {
|
||||
if (audioRef.current) {
|
||||
audioRef.current.pause()
|
||||
audioRef.current.removeAttribute('src')
|
||||
}
|
||||
audio.pause()
|
||||
audio.removeAttribute('src')
|
||||
audio.load()
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [session?.id])
|
||||
|
||||
// Sync isPlaying with audio element
|
||||
useEffect(() => {
|
||||
if (!audioRef.current) return
|
||||
if (isPlaying) audioRef.current.play().catch(() => {})
|
||||
else audioRef.current.pause()
|
||||
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(() => {
|
||||
@@ -90,67 +114,97 @@ export default function AudioPlayer() {
|
||||
if (audioRef.current) audioRef.current.volume = muted ? 0 : volume
|
||||
}, [volume, muted])
|
||||
|
||||
const seekToAbsoluteTime = useCallback((t: number) => {
|
||||
const seekTo = useCallback((absoluteTime: number) => {
|
||||
const audio = audioRef.current
|
||||
const tracks = tracksRef.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(t, totalDur))
|
||||
const clamped = Math.max(0, Math.min(absoluteTime, totalDur || absoluteTime))
|
||||
|
||||
let targetIdx = 0
|
||||
for (let i = tracks.length - 1; i >= 0; i--) {
|
||||
if (clamped >= (tracks[i].startOffset || 0)) { targetIdx = i; break }
|
||||
}
|
||||
const targetIdx = findTrackForTime(tracks, clamped)
|
||||
const timeInTrack = clamped - (tracks[targetIdx]?.startOffset || 0)
|
||||
|
||||
const timeInTrack = clamped - (tracks[targetIdx].startOffset || 0)
|
||||
console.log('[Player] seek', { absoluteTime, clamped, targetIdx, timeInTrack, currentIdx: currentTrackIdx.current })
|
||||
|
||||
if (targetIdx !== currentTrackIdxRef.current) {
|
||||
loadTrack(targetIdx, timeInTrack, isPlayingRef.current)
|
||||
} else {
|
||||
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)
|
||||
}, [loadTrack, seek])
|
||||
}, [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(!isPlayingRef.current) }
|
||||
if (e.code === 'ArrowRight') seekToAbsoluteTime(currentTimeRef.current + 30)
|
||||
if (e.code === 'ArrowLeft') seekToAbsoluteTime(Math.max(0, currentTimeRef.current - 10))
|
||||
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))
|
||||
}
|
||||
window.addEventListener('keydown', handler)
|
||||
return () => window.removeEventListener('keydown', handler)
|
||||
}, [seekToAbsoluteTime])
|
||||
}, [seekTo, currentTime, isPlaying, setPlaying])
|
||||
|
||||
const handleTimeUpdate = () => {
|
||||
if (!audioRef.current) return
|
||||
const track = tracksRef.current[currentTrackIdxRef.current]
|
||||
setCurrentTime((track?.startOffset || 0) + audioRef.current.currentTime)
|
||||
const onTimeUpdate = () => {
|
||||
const audio = audioRef.current
|
||||
if (!audio) return
|
||||
const offset = tracks[currentTrackIdx.current]?.startOffset || 0
|
||||
setCurrentTime(offset + audio.currentTime)
|
||||
}
|
||||
|
||||
const handleLoadedMetadata = () => {
|
||||
if (pendingSeekRef.current !== null && audioRef.current) {
|
||||
audioRef.current.currentTime = pendingSeekRef.current
|
||||
pendingSeekRef.current = null
|
||||
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 handleEnded = () => {
|
||||
const tracks = tracksRef.current
|
||||
const nextIdx = currentTrackIdxRef.current + 1
|
||||
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) {
|
||||
loadTrack(nextIdx, 0, true)
|
||||
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 handleSeekBar = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
seekToAbsoluteTime(parseFloat(e.target.value))
|
||||
const onError = () => {
|
||||
const audio = audioRef.current
|
||||
const err = audio?.error
|
||||
console.error('[Player] audio error', {
|
||||
code: err?.code,
|
||||
message: err?.message,
|
||||
src: audio?.src,
|
||||
networkState: audio?.networkState,
|
||||
readyState: audio?.readyState,
|
||||
})
|
||||
}
|
||||
|
||||
const fmtTime = (s: number) => {
|
||||
@@ -163,6 +217,10 @@ export default function AudioPlayer() {
|
||||
: `${m}:${sec.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
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 () => {
|
||||
@@ -182,12 +240,13 @@ export default function AudioPlayer() {
|
||||
<div className="flex flex-col h-full bg-background p-6 overflow-y-auto">
|
||||
<audio
|
||||
ref={audioRef}
|
||||
onTimeUpdate={handleTimeUpdate}
|
||||
onLoadedMetadata={handleLoadedMetadata}
|
||||
onEnded={handleEnded}
|
||||
onTimeUpdate={onTimeUpdate}
|
||||
onLoadedMetadata={onLoadedMetadata}
|
||||
onCanPlay={onCanPlay}
|
||||
onEnded={onEnded}
|
||||
onError={onError}
|
||||
/>
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<button onClick={() => setExpanded(false)} className="text-muted hover:text-ink transition-colors">
|
||||
<ChevronLeft size={24} />
|
||||
@@ -198,7 +257,6 @@ export default function AudioPlayer() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Cover */}
|
||||
<div className="flex justify-center mb-6">
|
||||
<CoverImage
|
||||
src={item ? coverUrl(item.id) : null}
|
||||
@@ -207,7 +265,6 @@ export default function AudioPlayer() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<div className="text-center mb-6">
|
||||
{currentChapter && (
|
||||
<p className="text-primary mb-1 truncate" style={{ fontSize: '12px' }}>{currentChapter.title}</p>
|
||||
@@ -216,7 +273,6 @@ export default function AudioPlayer() {
|
||||
{author && <p className="text-muted mt-1 truncate" style={{ fontSize: '13px' }}>{author}</p>}
|
||||
</div>
|
||||
|
||||
{/* Progress bar */}
|
||||
<div className="mb-4">
|
||||
<div className="relative bg-muted2 rounded-full mb-2" style={{ height: '4px' }}>
|
||||
{chapterMarkers.map((m: any, i: number) => (
|
||||
@@ -228,19 +284,18 @@ export default function AudioPlayer() {
|
||||
/>
|
||||
<input
|
||||
type="range" min={0} max={duration || 0} step={1} value={currentTime}
|
||||
onChange={handleSeekBar}
|
||||
onChange={(e) => seekTo(parseFloat(e.target.value))}
|
||||
className="absolute inset-0 w-full opacity-0 cursor-pointer h-full"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-between text-muted" style={{ fontSize: '11px' }}>
|
||||
<span>{fmtTime(currentTime)}</span>
|
||||
<span>-{fmtTime(duration - currentTime)}</span>
|
||||
<span>-{fmtTime(Math.max(0, duration - currentTime))}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Controls */}
|
||||
<div className="flex items-center justify-center gap-6 mb-6">
|
||||
<button className="text-muted hover:text-ink transition-colors" onClick={() => seekToAbsoluteTime(currentTime - 30)}>
|
||||
<button className="text-muted hover:text-ink transition-colors" onClick={() => seekTo(currentTime - 30)}>
|
||||
<SkipBack size={28} />
|
||||
</button>
|
||||
<button
|
||||
@@ -252,14 +307,12 @@ 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={() => seekToAbsoluteTime(currentTime + 30)}>
|
||||
<button className="text-muted hover:text-ink transition-colors" onClick={() => seekTo(currentTime + 30)}>
|
||||
<SkipForward size={28} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Secondary controls */}
|
||||
<div className="flex items-center justify-between">
|
||||
{/* Speed */}
|
||||
<div className="relative">
|
||||
<button
|
||||
className="text-muted hover:text-ink border border-divider transition-colors"
|
||||
@@ -283,12 +336,10 @@ export default function AudioPlayer() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Bookmark */}
|
||||
<button className="text-muted hover:text-ink transition-colors" onClick={addBookmark}>
|
||||
<BookmarkPlus size={20} />
|
||||
</button>
|
||||
|
||||
{/* Sleep Timer */}
|
||||
<div className="relative">
|
||||
<button
|
||||
className={`hover:text-ink transition-colors ${sleepTimerActive ? 'text-primary' : 'text-muted'}`}
|
||||
@@ -314,7 +365,6 @@ export default function AudioPlayer() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Chapter List */}
|
||||
<button
|
||||
className={`hover:text-ink transition-colors ${showChapters ? 'text-primary' : 'text-muted'}`}
|
||||
onClick={() => setShowChapters(!showChapters)}
|
||||
@@ -322,18 +372,16 @@ export default function AudioPlayer() {
|
||||
<List size={20} />
|
||||
</button>
|
||||
|
||||
{/* Volume */}
|
||||
<button className="text-muted hover:text-ink transition-colors" 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={seekToAbsoluteTime}
|
||||
onSeek={seekTo}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user