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,7 +1,7 @@
import os import os
import uuid import uuid
from datetime import datetime from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException, Query, Header
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select from sqlalchemy import select
@@ -10,8 +10,7 @@ from ..models.user import User
from ..models.media_item import LibraryItem, BookFile, Chapter from ..models.media_item import LibraryItem, BookFile, Chapter
from ..models.session import PlaybackSession from ..models.session import PlaybackSession
from ..models.progress import MediaProgress from ..models.progress import MediaProgress
from ..services.hls import start_hls_session, wait_for_playlist, cleanup_hls_session from ..services.auth import decode_token
from ..config import get_settings
router = APIRouter(tags=["stream"]) router = APIRouter(tags=["stream"])
@@ -53,14 +52,6 @@ async def start_playback(
current_time = float(body["startTime"]) current_time = float(body["startTime"])
session_id = str(uuid.uuid4()) session_id = str(uuid.uuid4())
audio_paths = [f.path for f in files]
hls_dir = start_hls_session(session_id, audio_paths, start_time=0.0)
# Warten bis erste Segmente da sind (max. 60s)
ready = await wait_for_playlist(session_id, timeout=60.0)
if not ready:
cleanup_hls_session(session_id)
raise HTTPException(status_code=500, detail="HLS-Konvertierung fehlgeschlagen")
session = PlaybackSession( session = PlaybackSession(
id=session_id, id=session_id,
@@ -72,18 +63,38 @@ async def start_playback(
device_id=body.get("deviceId", ""), device_id=body.get("deviceId", ""),
device_info=body.get("deviceInfo", {}), device_info=body.get("deviceInfo", {}),
media_player=body.get("mediaPlayer", ""), media_player=body.get("mediaPlayer", ""),
hls_session_path=hls_dir, hls_session_path=None,
is_active=True, is_active=True,
) )
db.add(session) db.add(session)
await db.commit() await db.commit()
hls_url = f"/hls/{session_id}/output.m3u8"
chapters_out = [ chapters_out = [
{"id": c.chapter_index, "start": c.start_seconds, "end": c.end_seconds, "title": c.title} {"id": c.chapter_index, "start": c.start_seconds, "end": c.end_seconds, "title": c.title}
for c in chapters for c in chapters
] ]
_MIME = {
".mp3": "audio/mpeg", ".m4a": "audio/mp4", ".m4b": "audio/mp4",
".aac": "audio/aac", ".ogg": "audio/ogg", ".flac": "audio/flac", ".wav": "audio/wav",
}
audio_tracks = []
offset = 0.0
for i, f in enumerate(files):
dur = f.duration_seconds or 0.0
ext = os.path.splitext(f.filename or "")[1].lower()
audio_tracks.append({
"index": i,
"startOffset": offset,
"duration": dur,
"title": f.filename or f"Part {i + 1}",
"contentUrl": f"/api/stream/{session_id}?track={i}",
"mimeType": _MIME.get(ext, "audio/mpeg"),
})
offset += dur
total_duration = item.duration_seconds or offset
return { return {
"id": session_id, "id": session_id,
"userId": current_user.id, "userId": current_user.id,
@@ -95,8 +106,8 @@ async def start_playback(
"displayTitle": item.title, "displayTitle": item.title,
"displayAuthor": item.author, "displayAuthor": item.author,
"coverPath": f"/api/items/{item_id}/cover" if item.cover_path else None, "coverPath": f"/api/items/{item_id}/cover" if item.cover_path else None,
"duration": item.duration_seconds or 0.0, "duration": total_duration,
"playMethod": 0, "playMethod": 2,
"mediaPlayer": body.get("mediaPlayer", ""), "mediaPlayer": body.get("mediaPlayer", ""),
"deviceInfo": body.get("deviceInfo", {}), "deviceInfo": body.get("deviceInfo", {}),
"serverVersion": "2.4.0", "serverVersion": "2.4.0",
@@ -107,19 +118,57 @@ async def start_playback(
"currentTime": current_time, "currentTime": current_time,
"startedAt": int(datetime.utcnow().timestamp() * 1000), "startedAt": int(datetime.utcnow().timestamp() * 1000),
"updatedAt": int(datetime.utcnow().timestamp() * 1000), "updatedAt": int(datetime.utcnow().timestamp() * 1000),
"audioTracks": [{ "audioTracks": audio_tracks,
"index": 0,
"startOffset": 0.0,
"duration": item.duration_seconds or 0.0,
"title": "Part 1",
"contentUrl": hls_url,
"mimeType": "application/x-mpegURL",
"metadata": {"filename": "output.m3u8", "ext": ".m3u8", "path": hls_url, "relPath": "output.m3u8", "size": 0},
}],
"videoTrack": None, "videoTrack": None,
} }
@router.get("/api/stream/{session_id}")
async def stream_file(
session_id: str,
track: int = Query(0),
token: str | None = Query(None),
authorization: str | None = Header(None),
db: AsyncSession = Depends(get_db),
):
"""Direktes Audio-Streaming mit Range-Request-Unterstützung."""
raw = token
if not raw and authorization:
parts = authorization.split(" ", 1)
if len(parts) == 2 and parts[0].lower() == "bearer":
raw = parts[1]
if not raw or not decode_token(raw):
raise HTTPException(status_code=401, detail="Nicht autorisiert")
result = await db.execute(
select(PlaybackSession).where(PlaybackSession.id == session_id)
)
session = result.scalar_one_or_none()
if not session:
raise HTTPException(status_code=404, detail="Session nicht gefunden")
files_result = await db.execute(
select(BookFile)
.where(BookFile.library_item_id == session.library_item_id)
.order_by(BookFile.track_index)
)
files = files_result.scalars().all()
if track >= len(files):
raise HTTPException(status_code=404, detail="Track nicht gefunden")
path = files[track].path
if not os.path.exists(path):
raise HTTPException(status_code=404, detail="Audio-Datei nicht gefunden")
_MIME = {
".mp3": "audio/mpeg", ".m4a": "audio/mp4", ".m4b": "audio/mp4",
".aac": "audio/aac", ".ogg": "audio/ogg", ".flac": "audio/flac", ".wav": "audio/wav",
}
ext = os.path.splitext(path)[1].lower()
return FileResponse(path, media_type=_MIME.get(ext, "audio/mpeg"))
@router.post("/api/playback-session/{session_id}/sync") @router.post("/api/playback-session/{session_id}/sync")
async def sync_session( async def sync_session(
session_id: str, session_id: str,
@@ -129,7 +178,10 @@ async def sync_session(
): ):
body = body or {} body = body or {}
result = await db.execute( result = await db.execute(
select(PlaybackSession).where(PlaybackSession.id == session_id, PlaybackSession.user_id == current_user.id) select(PlaybackSession).where(
PlaybackSession.id == session_id,
PlaybackSession.user_id == current_user.id,
)
) )
session = result.scalar_one_or_none() session = result.scalar_one_or_none()
if not session: if not session:
@@ -180,35 +232,14 @@ async def close_session(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
result = await db.execute( result = await db.execute(
select(PlaybackSession).where(PlaybackSession.id == session_id, PlaybackSession.user_id == current_user.id) select(PlaybackSession).where(
PlaybackSession.id == session_id,
PlaybackSession.user_id == current_user.id,
)
) )
session = result.scalar_one_or_none() session = result.scalar_one_or_none()
if not session: if not session:
raise HTTPException(status_code=404, detail="Session not found") raise HTTPException(status_code=404, detail="Session not found")
session.is_active = False session.is_active = False
await db.commit() await db.commit()
cleanup_hls_session(session_id)
return {"success": True} return {"success": True}
@router.get("/hls/{session_id}/{filename:path}")
async def serve_hls(
session_id: str,
filename: str,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(PlaybackSession).where(PlaybackSession.id == session_id, PlaybackSession.user_id == current_user.id)
)
if not result.scalar_one_or_none():
raise HTTPException(status_code=404, detail="Session not found")
settings = get_settings()
file_path = os.path.join(settings.hls_cache_dir, session_id, filename)
if not os.path.exists(file_path):
raise HTTPException(status_code=404, detail="Segment not found")
if filename.endswith(".m3u8"):
return FileResponse(file_path, media_type="application/x-mpegURL")
return FileResponse(file_path, media_type="video/MP2T")

View File

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

View File

@@ -1,5 +1,5 @@
import React, { useEffect, useRef, useState } from 'react' 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 { getUsers, createUser, deleteUser, getSettings, updateSettings } from '../api/me'
import { getLibraries, scanLibrary, matchAllLibrary, createLibrary, updateLibrary, deleteLibrary } from '../api/libraries' import { getLibraries, scanLibrary, matchAllLibrary, createLibrary, updateLibrary, deleteLibrary } from '../api/libraries'
import { getLog, clearLog } from '../api/logs' import { getLog, clearLog } from '../api/logs'
@@ -131,6 +131,7 @@ const MATCH_SOURCES = [
{ id: 'musicbrainz', label: 'MusicBrainz' }, { id: 'musicbrainz', label: 'MusicBrainz' },
{ id: 'open_library', label: 'Open Library' }, { id: 'open_library', label: 'Open Library' },
{ id: 'google_books', label: 'Google Books' }, { id: 'google_books', label: 'Google Books' },
{ id: 'dnb', label: 'DNB' },
] ]
function LibraryForm({ function LibraryForm({
@@ -144,6 +145,7 @@ function LibraryForm({
const [form, setForm] = useState(initial) const [form, setForm] = useState(initial)
const [showBrowser, setShowBrowser] = useState(false) const [showBrowser, setShowBrowser] = useState(false)
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [dragOverIdx, setDragOverIdx] = useState<number | null>(null)
const submit = async () => { const submit = async () => {
setSaving(true) setSaving(true)
@@ -182,32 +184,64 @@ function LibraryForm({
{form.mediaType === 'book' && ( {form.mediaType === 'book' && (
<div> <div>
<p className="text-muted mb-2" style={{ fontSize: '11px' }}>Matching-Quellen (Reihenfolge = Priorität)</p> <p className="text-muted mb-2" style={{ fontSize: '11px' }}>
<div className="flex flex-wrap gap-2"> Matching-Quellen · Reihenfolge = Priorität · per Drag verschieben
{MATCH_SOURCES.map((s) => { </p>
const checked = form.matchSources.includes(s.id) {/* 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 ( return (
<button <div
key={s.id} key={id}
type="button" draggable
onClick={() => setForm({ onDragStart={(e) => { e.dataTransfer.setData('text/plain', String(i)); e.dataTransfer.effectAllowed = 'move' }}
...form, onDragOver={(e) => { e.preventDefault(); setDragOverIdx(i) }}
matchSources: checked onDrop={(e) => {
? form.matchSources.filter((x) => x !== s.id) e.preventDefault()
: [...form.matchSources, s.id], const from = parseInt(e.dataTransfer.getData('text/plain'))
})} if (from === i) { setDragOverIdx(null); return }
className={`px-3 py-1 rounded-full border transition-colors ${ const next = [...form.matchSources]
checked const [moved] = next.splice(from, 1)
? 'bg-primary-dim border-primary text-primary' next.splice(i, 0, moved)
: 'bg-card border-divider text-muted hover:text-ink' 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} <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> </button>
</div>
) )
})} })}
</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>
)} )}
<div className="flex gap-2"> <div className="flex gap-2">