diff --git a/ui/src/app/api/datasets/listImages/route.ts b/ui/src/app/api/datasets/listImages/route.ts index 06dca84a..1887e425 100644 --- a/ui/src/app/api/datasets/listImages/route.ts +++ b/ui/src/app/api/datasets/listImages/route.ts @@ -36,7 +36,7 @@ export async function POST(request: Request) { * @returns Array of absolute paths to image files */ function findImagesRecursively(dir: string): string[] { - const imageExtensions = ['.png', '.jpg', '.jpeg', '.webp', '.mp4', '.avi', '.mov', '.mkv', '.wmv', '.m4v', '.flv']; + const imageExtensions = ['.png', '.jpg', '.jpeg', '.webp', '.mp4', '.avi', '.mov', '.mkv', '.wmv', '.m4v', '.flv', '.mp3', '.wav']; let results: string[] = []; const items = fs.readdirSync(dir); diff --git a/ui/src/app/api/files/[...filePath]/route.ts b/ui/src/app/api/files/[...filePath]/route.ts index 46eb5c4a..689bebbe 100644 --- a/ui/src/app/api/files/[...filePath]/route.ts +++ b/ui/src/app/api/files/[...filePath]/route.ts @@ -58,7 +58,10 @@ export async function GET(request: NextRequest, { params }: { params: { filePath '.mkv': 'video/x-matroska', '.wmv': 'video/x-ms-wmv', '.m4v': 'video/x-m4v', - '.flv': 'video/x-flv' + '.flv': 'video/x-flv', + // Audio + '.mp3': 'audio/mpeg', + '.wav': 'audio/wav', }; const contentType = contentTypeMap[ext] || 'application/octet-stream'; diff --git a/ui/src/app/api/img/[...imagePath]/route.ts b/ui/src/app/api/img/[...imagePath]/route.ts index 80fc7272..6f2ae390 100644 --- a/ui/src/app/api/img/[...imagePath]/route.ts +++ b/ui/src/app/api/img/[...imagePath]/route.ts @@ -55,7 +55,10 @@ export async function GET(request: NextRequest, { params }: { params: { imagePat '.mkv': 'video/x-matroska', '.wmv': 'video/x-ms-wmv', '.m4v': 'video/x-m4v', - '.flv': 'video/x-flv' + '.flv': 'video/x-flv', + // Audio + '.mp3': 'audio/mpeg', + '.wav': 'audio/wav', }; const contentType = contentTypeMap[ext] || 'application/octet-stream'; diff --git a/ui/src/app/api/img/delete/route.ts b/ui/src/app/api/img/delete/route.ts index 3db7019a..2b5b16a3 100644 --- a/ui/src/app/api/img/delete/route.ts +++ b/ui/src/app/api/img/delete/route.ts @@ -15,7 +15,7 @@ export async function POST(request: Request) { } // make sure it is an image - if (!/\.(jpg|jpeg|png|bmp|gif|tiff|webp|mp4)$/i.test(imgPath.toLowerCase())) { + if (!/\.(jpg|jpeg|png|bmp|gif|tiff|webp|mp4|mp3|wav)$/i.test(imgPath.toLowerCase())) { return NextResponse.json({ error: 'Not an image' }, { status: 400 }); } diff --git a/ui/src/app/api/jobs/[jobID]/samples/route.ts b/ui/src/app/api/jobs/[jobID]/samples/route.ts index c0f2ab33..fb93e7f4 100644 --- a/ui/src/app/api/jobs/[jobID]/samples/route.ts +++ b/ui/src/app/api/jobs/[jobID]/samples/route.ts @@ -29,7 +29,7 @@ export async function GET(request: NextRequest, { params }: { params: { jobID: s const samples = fs .readdirSync(samplesFolder) .filter(file => { - return file.endsWith('.png') || file.endsWith('.jpg') || file.endsWith('.jpeg') || file.endsWith('.webp') || file.endsWith('.mp4'); + return file.endsWith('.png') || file.endsWith('.jpg') || file.endsWith('.jpeg') || file.endsWith('.webp') || file.endsWith('.mp4') || file.endsWith('mp3') || file.endsWith('wav'); }) .map(file => { return path.join(samplesFolder, file); diff --git a/ui/src/components/AddImagesModal.tsx b/ui/src/components/AddImagesModal.tsx index ff91a883..82ad6c72 100644 --- a/ui/src/components/AddImagesModal.tsx +++ b/ui/src/components/AddImagesModal.tsx @@ -77,6 +77,7 @@ export default function AddImagesModal() { accept: { 'image/*': ['.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp'], 'video/*': ['.mp4', '.avi', '.mov', '.mkv', '.wmv', '.m4v', '.flv'], + 'audio/*': ['.mp3', '.wav'], 'text/*': ['.txt'], }, multiple: true, diff --git a/ui/src/components/AudioPlayer.tsx b/ui/src/components/AudioPlayer.tsx new file mode 100644 index 00000000..541716d2 --- /dev/null +++ b/ui/src/components/AudioPlayer.tsx @@ -0,0 +1,797 @@ +'use client'; + +import React, { useEffect, useMemo, useRef, useState } from 'react'; + +type AudioPlayerProps = { + src: string; + + /** Fallbacks (used only if meta missing) */ + title?: string; + subtitle?: string; + + /** Optional: default background image if no embedded album art is found */ + defaultAlbumArtUrl?: string; + + className?: string; + autoPlay?: boolean; + onPlay?: () => void; + onPause?: () => void; +}; + +function clamp(n: number, a: number, b: number) { + return Math.min(b, Math.max(a, n)); +} +function fmtTime(sec: number) { + if (!isFinite(sec) || sec < 0) return '0:00'; + const s = Math.floor(sec % 60); + const m = Math.floor(sec / 60); + return `${m}:${String(s).padStart(2, '0')}`; +} + +/** + * Global “only one plays at a time” channel. + */ +const AUDIO_EXCLUSIVE_EVENT = 'app:exclusive-audio-play'; +type ExclusivePlayDetail = { token: string }; +function broadcastExclusivePlay(token: string) { + window.dispatchEvent(new CustomEvent(AUDIO_EXCLUSIVE_EVENT, { detail: { token } })); +} + +/** + * ID3 helpers (v2.2/v2.3/v2.4): + * - robust album art extraction: APIC (v2.3/2.4) + PIC (v2.2) + * - basic text frames: title/artist/album + * - handles tag-level unsynchronisation + * + * Requires fetch() byte access; if CORS blocks, it will fall back gracefully. + */ +type Id3Meta = { + title?: string; + artist?: string; + album?: string; + pictureUrl?: string; // object URL +}; + +function synchsafeToInt(b0: number, b1: number, b2: number, b3: number) { + return ((b0 & 0x7f) << 21) | ((b1 & 0x7f) << 14) | ((b2 & 0x7f) << 7) | (b3 & 0x7f); +} + +function deUnsync(bytes: Uint8Array) { + const out: number[] = []; + for (let i = 0; i < bytes.length; i++) { + const cur = bytes[i]; + out.push(cur); + if (cur === 0xff && i + 1 < bytes.length && bytes[i + 1] === 0x00) i += 1; + } + return new Uint8Array(out); +} + +function decodeText(encoding: number, bytes: Uint8Array) { + let end = bytes.length; + while (end > 0 && bytes[end - 1] === 0) end--; + const b = bytes.slice(0, end); + + try { + if (encoding === 0) return new TextDecoder('latin1').decode(b); + if (encoding === 1) return new TextDecoder('utf-16').decode(b); + if (encoding === 2) return new TextDecoder('utf-16be').decode(b); + if (encoding === 3) return new TextDecoder('utf-8').decode(b); + } catch { + // ignore + } + return new TextDecoder('latin1').decode(b); +} + +function readNullTerminated(bytes: Uint8Array, start: number, encoding: number) { + if (encoding === 1 || encoding === 2) { + let i = start; + while (i + 1 < bytes.length && !(bytes[i] === 0 && bytes[i + 1] === 0)) i += 2; + const textBytes = bytes.slice(start, i); + return { text: decodeText(encoding, textBytes), next: i + 2 }; + } else { + let i = start; + while (i < bytes.length && bytes[i] !== 0) i++; + const textBytes = bytes.slice(start, i); + return { text: decodeText(encoding, textBytes), next: i + 1 }; + } +} + +async function fetchBytes(src: string, start: number, endInclusive: number) { + const wantLen = endInclusive - start + 1; + + try { + const r = await fetch(src, { headers: { Range: `bytes=${start}-${endInclusive}` } }); + if (!r.ok) throw new Error('range not ok'); + const buf = await r.arrayBuffer(); + return new Uint8Array(buf); + } catch { + const r = await fetch(src); + if (!r.ok) throw new Error('fetch not ok'); + const buf = await r.arrayBuffer(); + const u8 = new Uint8Array(buf); + if (start === 0 && u8.length >= wantLen) return u8.slice(0, wantLen); + return u8.slice(start, Math.min(u8.length, endInclusive + 1)); + } +} + +async function extractId3MetaAndArt(src: string, maxTagBytes = 4_000_000): Promise { + const head = await fetchBytes(src, 0, 64 * 1024 - 1).catch(() => null); + if (!head || head.length < 10) return {}; + if (head[0] !== 0x49 || head[1] !== 0x44 || head[2] !== 0x33) return {}; + + const verMajor = head[3]; // 2,3,4 + const flags = head[5]; + const tagSize = synchsafeToInt(head[6], head[7], head[8], head[9]); + const tagEnd = 10 + tagSize; + + const need = Math.min(tagEnd, maxTagBytes); + let tagBytes = head; + if (head.length < need) { + const more = await fetchBytes(src, 0, need - 1).catch(() => null); + if (!more) return {}; + tagBytes = more; + } else { + tagBytes = head.slice(0, need); + } + + const tagUnsync = (flags & 0x80) !== 0; + const tagDataRaw = tagBytes.slice(10, Math.min(tagBytes.length, tagEnd)); + const tagData = tagUnsync ? deUnsync(tagDataRaw) : tagDataRaw; + + let offset = 0; + + // Extended header (v2.3/v2.4) + if (verMajor === 3 || verMajor === 4) { + const hasExt = (flags & 0x40) !== 0; + if (hasExt && tagData.length >= 4) { + let extSize = 0; + if (verMajor === 4) extSize = synchsafeToInt(tagData[0], tagData[1], tagData[2], tagData[3]); + else extSize = (tagData[0] << 24) | (tagData[1] << 16) | (tagData[2] << 8) | tagData[3]; + offset += 4 + Math.max(0, extSize); + } + } + + const meta: Id3Meta = {}; + const setIfEmpty = (k: keyof Id3Meta, v?: string) => { + if (!v) return; + if (!meta[k]) meta[k] = v; + }; + + while (offset < tagData.length) { + if (tagData[offset] === 0x00) break; + + if (verMajor === 2) { + if (offset + 6 > tagData.length) break; + const id = new TextDecoder('latin1').decode(tagData.slice(offset, offset + 3)); + const size = (tagData[offset + 3] << 16) | (tagData[offset + 4] << 8) | tagData[offset + 5]; + offset += 6; + if (!id.trim() || size <= 0 || offset + size > tagData.length) break; + + const frame = tagData.slice(offset, offset + size); + + if (id === 'TT2' || id === 'TP1' || id === 'TAL') { + const enc = frame[0]; + const txt = decodeText(enc, frame.slice(1)); + if (id === 'TT2') setIfEmpty('title', txt); + if (id === 'TP1') setIfEmpty('artist', txt); + if (id === 'TAL') setIfEmpty('album', txt); + } + + if (id === 'PIC' && frame.length > 6) { + const enc = frame[0]; + const fmt = new TextDecoder('latin1').decode(frame.slice(1, 4)).toLowerCase(); + const imgType = fmt === 'png' ? 'image/png' : 'image/jpeg'; + let p = 5; + const desc = readNullTerminated(frame, p, enc); + p = desc.next; + if (p < frame.length) { + const img = frame.slice(p); + if (img.length > 64) { + const blob = new Blob([img], { type: imgType }); + meta.pictureUrl = URL.createObjectURL(blob); + } + } + } + + offset += size; + } else { + if (offset + 10 > tagData.length) break; + + const id = new TextDecoder('latin1').decode(tagData.slice(offset, offset + 4)); + let size = 0; + if (verMajor === 4) + size = synchsafeToInt(tagData[offset + 4], tagData[offset + 5], tagData[offset + 6], tagData[offset + 7]); + else + size = + (tagData[offset + 4] << 24) | (tagData[offset + 5] << 16) | (tagData[offset + 6] << 8) | tagData[offset + 7]; + + const flag2 = tagData[offset + 9]; + offset += 10; + + if (!id.trim() || size <= 0 || offset + size > tagData.length) break; + + let frame = tagData.slice(offset, offset + size); + + const frameUnsync = verMajor === 4 && (flag2 & 0x02) !== 0; + if (frameUnsync) frame = deUnsync(frame); + + if (id === 'TIT2' || id === 'TPE1' || id === 'TALB') { + const enc = frame[0]; + const txt = decodeText(enc, frame.slice(1)); + if (id === 'TIT2') setIfEmpty('title', txt); + if (id === 'TPE1') setIfEmpty('artist', txt); + if (id === 'TALB') setIfEmpty('album', txt); + } + + if (id === 'APIC' && frame.length > 10) { + const enc = frame[0]; + const mimeZ = readNullTerminated(frame, 1, 0); + const mime = mimeZ.text || 'image/jpeg'; + let p = mimeZ.next; + if (p < frame.length) p += 1; // pictureType + const desc = readNullTerminated(frame, p, enc); + p = desc.next; + + if (p < frame.length) { + const img = frame.slice(p); + if (img.length > 64) { + const blob = new Blob([img], { type: mime }); + if (!meta.pictureUrl) meta.pictureUrl = URL.createObjectURL(blob); + } + } + } + + offset += size; + } + } + + return meta; +} + +export default function AudioPlayer({ + src, + title = 'Audio', + subtitle, + defaultAlbumArtUrl, + className = '', + autoPlay = false, + onPlay, + onPause, +}: AudioPlayerProps) { + const tokenRef = useRef(`aud_${Math.random().toString(16).slice(2)}_${Date.now()}`); + const wrapRef = useRef(null); + + const audioRef = useRef(null); + const barRef = useRef(null); + const waveRef = useRef(null); + + const [size, setSize] = useState(256); + + const [isReady, setIsReady] = useState(false); + const [isPlaying, setIsPlaying] = useState(false); + const [isBuffering, setIsBuffering] = useState(false); + const [err, setErr] = useState(null); + + const [duration, setDuration] = useState(0); + const [t, setT] = useState(0); + + const [dragging, setDragging] = useState(false); + const [dragValue, setDragValue] = useState(0); + + // Meta + artwork + const [metaTitle, setMetaTitle] = useState(null); + const [metaArtist, setMetaArtist] = useState(null); + const [metaAlbum, setMetaAlbum] = useState(null); + const [albumArtUrl, setAlbumArtUrl] = useState(null); + const albumArtBlobUrlRef = useRef(null); + + // WebAudio analyser + const audioCtxRef = useRef(null); + const analyserRef = useRef(null); + const timeRef = useRef(null); + const rafRef = useRef(null); + + // Smoothed energy + const energyRef = useRef(0); + + // Resize observer (square) + useEffect(() => { + const el = wrapRef.current; + if (!el) return; + const ro = new ResizeObserver(entries => { + const r = entries[0]?.contentRect; + if (!r) return; + setSize(Math.max(1, Math.floor(Math.min(r.width, r.height)))); + }); + ro.observe(el); + return () => ro.disconnect(); + }, []); + + // DPR-aware canvas sizing + useEffect(() => { + const c = waveRef.current; + if (!c) return; + const dpr = Math.max(1, window.devicePixelRatio || 1); + const cssW = c.clientWidth || 1; + const cssH = c.clientHeight || 1; + c.width = Math.max(1, Math.floor(cssW * dpr)); + c.height = Math.max(1, Math.floor(cssH * dpr)); + }, [size]); + + // Extract meta + art + useEffect(() => { + let cancelled = false; + + (async () => { + if (albumArtBlobUrlRef.current) { + URL.revokeObjectURL(albumArtBlobUrlRef.current); + albumArtBlobUrlRef.current = null; + } + setAlbumArtUrl(null); + setMetaTitle(null); + setMetaArtist(null); + setMetaAlbum(null); + + try { + const meta = await extractId3MetaAndArt(src); + if (cancelled) return; + + if (meta.title) setMetaTitle(meta.title); + if (meta.artist) setMetaArtist(meta.artist); + if (meta.album) setMetaAlbum(meta.album); + + if (meta.pictureUrl) { + albumArtBlobUrlRef.current = meta.pictureUrl; + setAlbumArtUrl(meta.pictureUrl); + } else if (defaultAlbumArtUrl) { + setAlbumArtUrl(defaultAlbumArtUrl); + } + } catch { + if (!cancelled && defaultAlbumArtUrl) setAlbumArtUrl(defaultAlbumArtUrl); + } + })(); + + return () => { + cancelled = true; + }; + }, [src, defaultAlbumArtUrl]); + + // Cleanup artwork blob URL on unmount + useEffect(() => { + return () => { + if (albumArtBlobUrlRef.current) { + URL.revokeObjectURL(albumArtBlobUrlRef.current); + albumArtBlobUrlRef.current = null; + } + }; + }, []); + + const effectiveTitle = metaTitle || title; + const effectiveSubtitle = metaArtist || subtitle || metaAlbum || ''; + + const progress = useMemo(() => { + const cur = dragging ? dragValue : t; + if (!duration) return 0; + return clamp(cur / duration, 0, 1); + }, [t, duration, dragging, dragValue]); + + const elapsed = dragging ? dragValue : t; + const remaining = Math.max(0, (duration || 0) - elapsed); + + // Load src into