Switch to direct MP3 streaming, add DNB source selection + drag-to-reorder

Player: Replace HLS with direct FileResponse streaming. Token passed as
query param (?token=JWT) so browser <audio> can authenticate. Multi-track
support: seeks and track transitions handled in AudioPlayer with refs.
Removes hls.js dependency from playback path.

Admin: Add DNB to match sources list. Replace toggle buttons with ordered
drag-to-reorder list (HTML5 drag API) + separate add/remove buttons so
source priority is explicit and adjustable.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Audiolib
2026-05-26 17:49:40 +02:00
parent eefdfc9886
commit 6c702cb29f
3 changed files with 226 additions and 134 deletions

View File

@@ -1,5 +1,4 @@
import React, { useEffect, useRef, useState } from 'react'
import Hls from 'hls.js'
import React, { useCallback, useEffect, useRef, useState } from 'react'
import {
Play, Pause, SkipBack, SkipForward, Volume2, VolumeX,
List, BookmarkPlus, Moon, X, ChevronLeft
@@ -14,51 +13,66 @@ export default function AudioPlayer() {
const {
item, session, currentTime, duration, isPlaying, playbackRate, volume, chapters,
sleepTimerActive, sleepTimer,
setPlaying, setCurrentTime, setDuration, setPlaybackRate, setVolume,
setPlaying, setCurrentTime, setPlaybackRate, setVolume,
seek, stop, setExpanded, setSleepTimer, cancelSleepTimer, syncProgress,
} = usePlayerStore()
const audioRef = useRef<HTMLAudioElement>(null)
const hlsRef = useRef<Hls | null>(null)
const tracksRef = useRef<any[]>([])
const currentTrackIdxRef = useRef(0)
const pendingSeekRef = useRef<number | null>(null)
const currentTimeRef = useRef(0)
const isPlayingRef = useRef(false)
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 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
useEffect(() => {
if (!session || !audioRef.current) return
const hlsUrl = session.audioTracks?.[0]?.contentUrl
if (!hlsUrl) return
const tracks = session.audioTracks || []
if (!tracks.length) return
if (hlsRef.current) { hlsRef.current.destroy(); hlsRef.current = null }
tracksRef.current = tracks
const startTime = session.currentTime || 0
const audio = audioRef.current
if (Hls.isSupported()) {
const token = localStorage.getItem('token')
const hls = new Hls({
startPosition: session.currentTime || 0,
xhrSetup: (xhr: XMLHttpRequest) => {
if (token) xhr.setRequestHeader('Authorization', `Bearer ${token}`)
},
})
hls.loadSource(hlsUrl)
hls.attachMedia(audio)
hlsRef.current = hls
} else if (audio.canPlayType('application/vnd.apple.mpegurl')) {
audio.src = hlsUrl
audio.currentTime = session.currentTime || 0
let startIdx = 0
for (let i = tracks.length - 1; i >= 0; i--) {
if (startTime >= (tracks[i].startOffset || 0)) { startIdx = i; break }
}
audio.playbackRate = playbackRate
audio.volume = volume
audio.play().catch(() => {})
audioRef.current.playbackRate = playbackRate
audioRef.current.volume = muted ? 0 : volume
loadTrack(startIdx, startTime - (tracks[startIdx].startOffset || 0), true)
return () => {
if (hlsRef.current) { hlsRef.current.destroy(); hlsRef.current = null }
if (audioRef.current) {
audioRef.current.pause()
audioRef.current.removeAttribute('src')
}
}
}, [session?.id])
@@ -76,36 +90,67 @@ export default function AudioPlayer() {
if (audioRef.current) audioRef.current.volume = muted ? 0 : volume
}, [volume, muted])
const seekToAbsoluteTime = useCallback((t: 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))
let targetIdx = 0
for (let i = tracks.length - 1; i >= 0; i--) {
if (clamped >= (tracks[i].startOffset || 0)) { targetIdx = i; break }
}
const timeInTrack = clamped - (tracks[targetIdx].startOffset || 0)
if (targetIdx !== currentTrackIdxRef.current) {
loadTrack(targetIdx, timeInTrack, isPlayingRef.current)
} else {
audio.currentTime = timeInTrack
}
seek(clamped)
}, [loadTrack, seek])
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.target instanceof HTMLInputElement) return
if (e.code === 'Space') { e.preventDefault(); setPlaying(!isPlaying) }
if (e.code === 'ArrowRight') audioRef.current && (audioRef.current.currentTime += 30)
if (e.code === 'ArrowLeft') audioRef.current && (audioRef.current.currentTime -= 10)
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))
}
window.addEventListener('keydown', handler)
return () => window.removeEventListener('keydown', handler)
}, [isPlaying])
}, [seekToAbsoluteTime])
const handleTimeUpdate = () => {
if (!audioRef.current) return
setCurrentTime(audioRef.current.currentTime)
const track = tracksRef.current[currentTrackIdxRef.current]
setCurrentTime((track?.startOffset || 0) + audioRef.current.currentTime)
}
const handleLoadedMetadata = () => {
if (!audioRef.current) return
setDuration(audioRef.current.duration)
if (pendingSeekRef.current !== null && audioRef.current) {
audioRef.current.currentTime = pendingSeekRef.current
pendingSeekRef.current = null
}
}
const handleEnded = () => {
setPlaying(false)
syncProgress()
const tracks = tracksRef.current
const nextIdx = currentTrackIdxRef.current + 1
if (nextIdx < tracks.length) {
loadTrack(nextIdx, 0, true)
} else {
setPlaying(false)
syncProgress()
}
}
const handleSeekBar = (e: React.ChangeEvent<HTMLInputElement>) => {
const t = parseFloat(e.target.value)
if (audioRef.current) audioRef.current.currentTime = t
seek(t)
seekToAbsoluteTime(parseFloat(e.target.value))
}
const fmtTime = (s: number) => {
@@ -175,22 +220,14 @@ export default function AudioPlayer() {
<div className="mb-4">
<div className="relative bg-muted2 rounded-full mb-2" style={{ height: '4px' }}>
{chapterMarkers.map((m: any, i: number) => (
<div
key={i}
className="absolute top-0 w-px h-full bg-muted"
style={{ left: `${m.pct}%` }}
/>
<div key={i} className="absolute top-0 w-px h-full bg-muted" style={{ left: `${m.pct}%` }} />
))}
<div
className="absolute top-0 left-0 h-full bg-ink rounded-full pointer-events-none"
style={{ width: `${duration > 0 ? (currentTime / duration) * 100 : 0}%` }}
/>
<input
type="range"
min={0}
max={duration || 0}
step={1}
value={currentTime}
type="range" min={0} max={duration || 0} step={1} value={currentTime}
onChange={handleSeekBar}
className="absolute inset-0 w-full opacity-0 cursor-pointer h-full"
/>
@@ -203,29 +240,19 @@ export default function AudioPlayer() {
{/* Controls */}
<div className="flex items-center justify-center gap-6 mb-6">
<button
className="text-muted hover:text-ink transition-colors"
onClick={() => audioRef.current && (audioRef.current.currentTime -= 30)}
>
<button className="text-muted hover:text-ink transition-colors" onClick={() => seekToAbsoluteTime(currentTime - 30)}>
<SkipBack size={28} />
</button>
<button
className="flex items-center justify-center"
style={{
width: 52, height: 52, borderRadius: '50%',
background: '#e4ede5', color: '#000',
transition: 'transform 0.1s',
}}
style={{ width: 52, height: 52, borderRadius: '50%', background: '#e4ede5', color: '#000', transition: 'transform 0.1s' }}
onMouseEnter={(e) => { e.currentTarget.style.transform = 'scale(1.06)'; e.currentTarget.style.background = '#fff' }}
onMouseLeave={(e) => { e.currentTarget.style.transform = 'scale(1)'; e.currentTarget.style.background = '#e4ede5' }}
onClick={() => setPlaying(!isPlaying)}
>
{isPlaying ? <Pause size={22} fill="currentColor" /> : <Play size={22} fill="currentColor" />}
</button>
<button
className="text-muted hover:text-ink transition-colors"
onClick={() => audioRef.current && (audioRef.current.currentTime += 30)}
>
<button className="text-muted hover:text-ink transition-colors" onClick={() => seekToAbsoluteTime(currentTime + 30)}>
<SkipForward size={28} />
</button>
</div>
@@ -235,7 +262,7 @@ export default function AudioPlayer() {
{/* Speed */}
<div className="relative">
<button
className="text-muted hover:text-ink border border-divider px-2 py-1 transition-colors"
className="text-muted hover:text-ink border border-divider transition-colors"
style={{ fontSize: '11px', fontWeight: 600, borderRadius: '4px', padding: '3px 8px' }}
onClick={() => setShowSpeedMenu(!showSpeedMenu)}
>
@@ -306,7 +333,7 @@ export default function AudioPlayer() {
<ChapterList
chapters={chapters}
currentTime={currentTime}
onSeek={(t) => { if (audioRef.current) audioRef.current.currentTime = t; seek(t) }}
onSeek={seekToAbsoluteTime}
/>
)}
</div>

View File

@@ -1,5 +1,5 @@
import React, { useEffect, useRef, useState } from 'react'
import { Users, Library, Settings, Trash2, Plus, RefreshCw, Loader2, Check, X, FolderOpen, Pencil, Sparkles, ScrollText } from 'lucide-react'
import { Users, Library, Settings, Trash2, Plus, RefreshCw, Loader2, Check, X, FolderOpen, Pencil, Sparkles, ScrollText, GripVertical } from 'lucide-react'
import { getUsers, createUser, deleteUser, getSettings, updateSettings } from '../api/me'
import { getLibraries, scanLibrary, matchAllLibrary, createLibrary, updateLibrary, deleteLibrary } from '../api/libraries'
import { getLog, clearLog } from '../api/logs'
@@ -131,6 +131,7 @@ const MATCH_SOURCES = [
{ id: 'musicbrainz', label: 'MusicBrainz' },
{ id: 'open_library', label: 'Open Library' },
{ id: 'google_books', label: 'Google Books' },
{ id: 'dnb', label: 'DNB' },
]
function LibraryForm({
@@ -144,6 +145,7 @@ function LibraryForm({
const [form, setForm] = useState(initial)
const [showBrowser, setShowBrowser] = useState(false)
const [saving, setSaving] = useState(false)
const [dragOverIdx, setDragOverIdx] = useState<number | null>(null)
const submit = async () => {
setSaving(true)
@@ -182,32 +184,64 @@ function LibraryForm({
{form.mediaType === 'book' && (
<div>
<p className="text-muted mb-2" style={{ fontSize: '11px' }}>Matching-Quellen (Reihenfolge = Priorität)</p>
<div className="flex flex-wrap gap-2">
{MATCH_SOURCES.map((s) => {
const checked = form.matchSources.includes(s.id)
<p className="text-muted mb-2" style={{ fontSize: '11px' }}>
Matching-Quellen · Reihenfolge = Priorität · per Drag verschieben
</p>
{/* Selected sources — sortable */}
<div className="space-y-1 mb-2">
{form.matchSources.map((id, i) => {
const src = MATCH_SOURCES.find((s) => s.id === id)
if (!src) return null
return (
<button
key={s.id}
type="button"
onClick={() => setForm({
...form,
matchSources: checked
? form.matchSources.filter((x) => x !== s.id)
: [...form.matchSources, s.id],
})}
className={`px-3 py-1 rounded-full border transition-colors ${
checked
? 'bg-primary-dim border-primary text-primary'
: 'bg-card border-divider text-muted hover:text-ink'
<div
key={id}
draggable
onDragStart={(e) => { e.dataTransfer.setData('text/plain', String(i)); e.dataTransfer.effectAllowed = 'move' }}
onDragOver={(e) => { e.preventDefault(); setDragOverIdx(i) }}
onDrop={(e) => {
e.preventDefault()
const from = parseInt(e.dataTransfer.getData('text/plain'))
if (from === i) { setDragOverIdx(null); return }
const next = [...form.matchSources]
const [moved] = next.splice(from, 1)
next.splice(i, 0, moved)
setForm({ ...form, matchSources: next })
setDragOverIdx(null)
}}
onDragEnd={() => setDragOverIdx(null)}
className={`flex items-center gap-2 px-3 py-1.5 rounded-lg border cursor-grab active:cursor-grabbing transition-colors ${
dragOverIdx === i ? 'border-primary bg-primary-dim' : 'border-primary/40 bg-primary-dim'
}`}
style={{ fontSize: '12px' }}
>
{s.label}
</button>
<GripVertical size={12} className="text-primary opacity-50 flex-shrink-0" />
<span className="text-primary flex-1" style={{ fontSize: '12px' }}>{src.label}</span>
<button
type="button"
onClick={() => setForm({ ...form, matchSources: form.matchSources.filter((x) => x !== id) })}
className="text-primary opacity-50 hover:opacity-100 transition-opacity"
>
<X size={12} />
</button>
</div>
)
})}
</div>
{/* Unselected sources — add button */}
{MATCH_SOURCES.filter((s) => !form.matchSources.includes(s.id)).length > 0 && (
<div className="flex flex-wrap gap-2">
{MATCH_SOURCES.filter((s) => !form.matchSources.includes(s.id)).map((s) => (
<button
key={s.id}
type="button"
onClick={() => setForm({ ...form, matchSources: [...form.matchSources, s.id] })}
className="flex items-center gap-1 px-3 py-1 rounded-full border border-divider bg-card text-muted hover:text-ink transition-colors"
style={{ fontSize: '12px' }}
>
<Plus size={10} /> {s.label}
</button>
))}
</div>
)}
</div>
)}
<div className="flex gap-2">