Build out an audio player card in preperation for audio datasets and samples.

This commit is contained in:
Jaret Burkett
2026-02-03 08:15:55 -07:00
parent 50664c2421
commit 42acb0d4be
11 changed files with 843 additions and 12 deletions

View File

@@ -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);

View File

@@ -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';

View File

@@ -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';

View File

@@ -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 });
}

View File

@@ -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);

View File

@@ -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,

View File

@@ -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<ExclusivePlayDetail>(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<Id3Meta> {
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<HTMLDivElement | null>(null);
const audioRef = useRef<HTMLAudioElement | null>(null);
const barRef = useRef<HTMLDivElement | null>(null);
const waveRef = useRef<HTMLCanvasElement | null>(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<string | null>(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<string | null>(null);
const [metaArtist, setMetaArtist] = useState<string | null>(null);
const [metaAlbum, setMetaAlbum] = useState<string | null>(null);
const [albumArtUrl, setAlbumArtUrl] = useState<string | null>(null);
const albumArtBlobUrlRef = useRef<string | null>(null);
// WebAudio analyser
const audioCtxRef = useRef<AudioContext | null>(null);
const analyserRef = useRef<AnalyserNode | null>(null);
const timeRef = useRef<Uint8Array | null>(null);
const rafRef = useRef<number | null>(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 <audio>
useEffect(() => {
const el = audioRef.current;
if (!el) return;
setErr(null);
setIsReady(false);
setIsBuffering(false);
setIsPlaying(false);
setDuration(0);
setT(0);
setDragging(false);
setDragValue(0);
el.src = src;
el.preload = 'metadata';
el.load();
}, [src]);
// Audio events
useEffect(() => {
const el = audioRef.current;
if (!el) return;
const onLoaded = () => {
setDuration(isFinite(el.duration) ? el.duration : 0);
setIsReady(true);
if (autoPlay) void safePlay();
};
const onTime = () => {
if (!dragging) setT(el.currentTime || 0);
};
const onPlayEvt = () => setIsPlaying(true);
const onPauseEvt = () => setIsPlaying(false);
const onWaiting = () => setIsBuffering(true);
const onPlayingEvt = () => setIsBuffering(false);
const onEnded = () => setIsPlaying(false);
const onError = () => {
setIsPlaying(false);
setIsBuffering(false);
setErr('Failed to load audio.');
};
el.addEventListener('loadedmetadata', onLoaded);
el.addEventListener('timeupdate', onTime);
el.addEventListener('play', onPlayEvt);
el.addEventListener('pause', onPauseEvt);
el.addEventListener('waiting', onWaiting);
el.addEventListener('playing', onPlayingEvt);
el.addEventListener('ended', onEnded);
el.addEventListener('error', onError);
return () => {
el.removeEventListener('loadedmetadata', onLoaded);
el.removeEventListener('timeupdate', onTime);
el.removeEventListener('play', onPlayEvt);
el.removeEventListener('pause', onPauseEvt);
el.removeEventListener('waiting', onWaiting);
el.removeEventListener('playing', onPlayingEvt);
el.removeEventListener('ended', onEnded);
el.removeEventListener('error', onError);
};
}, [dragging, autoPlay]);
// Exclusive playback listener
useEffect(() => {
const handler = (e: Event) => {
const ce = e as CustomEvent<ExclusivePlayDetail>;
const other = ce.detail?.token;
if (!other) return;
if (other !== tokenRef.current) {
if (audioRef.current && !audioRef.current.paused) audioRef.current.pause();
}
};
window.addEventListener(AUDIO_EXCLUSIVE_EVENT, handler as EventListener);
return () => window.removeEventListener(AUDIO_EXCLUSIVE_EVENT, handler as EventListener);
}, []);
// Keep the canvas progress overlay updating smoothly while playing.
// This fixes “only updates on start/stop” by forcing continuous redraw with the current progress.
useEffect(() => {
if (isPlaying) startLoop();
else stopLoop();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isPlaying, duration]);
function ensureAudioGraph() {
if (audioCtxRef.current) return;
const el = audioRef.current;
if (!el) return;
const Ctx = (window.AudioContext || (window as any).webkitAudioContext) as typeof AudioContext | undefined;
if (!Ctx) return;
const ctx = new Ctx();
const srcNode = ctx.createMediaElementSource(el);
const analyser = ctx.createAnalyser();
analyser.fftSize = 2048;
analyser.smoothingTimeConstant = 0.85;
srcNode.connect(analyser);
analyser.connect(ctx.destination);
audioCtxRef.current = ctx;
analyserRef.current = analyser;
timeRef.current = new Uint8Array(analyser.fftSize);
}
function stopLoop() {
if (rafRef.current != null) {
cancelAnimationFrame(rafRef.current);
rafRef.current = null;
}
energyRef.current = 0;
}
function startLoop() {
if (rafRef.current != null) return;
const tick = () => {
rafRef.current = requestAnimationFrame(tick);
const analyser = analyserRef.current;
const time = timeRef.current;
let targetEnergy = 0;
if (analyser && time) {
analyser.getByteTimeDomainData(time as Uint8Array<any>);
let sum = 0;
for (let i = 0; i < time.length; i++) sum += Math.abs(time[i] - 128);
targetEnergy = sum / time.length / 128; // 0..1
}
const prev = energyRef.current;
const next = prev + (targetEnergy - prev) * 0.12;
energyRef.current = next;
// IMPORTANT: use real-time currentTime here so progress updates even if React state lags.
const el = audioRef.current;
const cur = dragging ? dragValue : (el?.currentTime ?? t);
const prog = duration > 0 ? clamp(cur / duration, 0, 1) : 0;
drawWave(next, analyser, time, prog);
};
rafRef.current = requestAnimationFrame(tick);
}
function drawPath(ctx: CanvasRenderingContext2D, time: Uint8Array, w: number, h: number, energy: number) {
const mid = h * 0.5;
const amp = h * (0.26 + energy * 0.3);
ctx.beginPath();
for (let i = 0; i < time.length; i++) {
const x = (i / (time.length - 1)) * w;
const v = (time[i] - 128) / 128;
const y = mid + v * amp;
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
}
}
/**
* Waveform is ALWAYS yellow.
* Progress is shown as a subtle background shade behind it (matching percentage).
*/
function drawWave(energy: number, analyser: AnalyserNode | null, time: Uint8Array | null, prog: number) {
const c = waveRef.current;
if (!c) return;
const ctx = c.getContext('2d');
if (!ctx) return;
const w = c.width;
const h = c.height;
ctx.clearRect(0, 0, w, h);
if (!analyser || !time) return;
analyser.getByteTimeDomainData(time as Uint8Array<any>);
// progress background (behind waveform), semi-transparent
const playedX = Math.floor(w * prog);
ctx.fillStyle = 'rgba(251,191,36,0.10)'; // faint amber wash
ctx.fillRect(0, 0, playedX, h);
// waveform ALWAYS yellow, semi-transparent
drawPath(ctx, time, w, h, energy);
ctx.strokeStyle = 'rgba(251,191,36,0.70)'; // #fbbf24 @ alpha
ctx.lineWidth = Math.max(2, Math.floor(h * 0.02));
ctx.stroke();
}
async function safePlay() {
const el = audioRef.current;
if (!el) return;
setErr(null);
broadcastExclusivePlay(tokenRef.current);
try {
ensureAudioGraph();
if (audioCtxRef.current && audioCtxRef.current.state === 'suspended') {
await audioCtxRef.current.resume();
}
await el.play();
onPlay?.();
startLoop();
} catch {
setErr('Playback was blocked or failed.');
}
}
function pause() {
const el = audioRef.current;
if (!el) return;
el.pause();
onPause?.();
stopLoop();
}
function togglePlay() {
const el = audioRef.current;
if (!el) return;
if (el.paused) void safePlay();
else pause();
}
function seekTo(sec: number) {
const el = audioRef.current;
if (!el || !isFinite(duration) || duration <= 0) return;
el.currentTime = clamp(sec, 0, duration);
setT(el.currentTime);
}
function restart() {
seekTo(0);
}
// Scrubber pointer interactions
function barValueFromClientX(clientX: number) {
const bar = barRef.current;
if (!bar || !duration) return 0;
const r = bar.getBoundingClientRect();
const x = clamp((clientX - r.left) / r.width, 0, 1);
return x * duration;
}
function onBarPointerDown(e: React.PointerEvent) {
if (!duration) return;
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
setDragging(true);
setDragValue(barValueFromClientX(e.clientX));
}
function onBarPointerMove(e: React.PointerEvent) {
if (!dragging || !duration) return;
setDragValue(barValueFromClientX(e.clientX));
}
function onBarPointerUp(e: React.PointerEvent) {
if (!duration) return;
const v = barValueFromClientX(e.clientX);
setDragging(false);
setDragValue(0);
seekTo(v);
}
// Sizing / layout
const pad = clamp(Math.round(size * 0.06), 12, 36);
const titleSize = clamp(Math.round(size * 0.05), 12, 22);
const subSize = clamp(Math.round(size * 0.035), 10, 16);
const timeSize = clamp(Math.round(size * 0.04), 11, 18);
const barH = clamp(Math.round(size * 0.035), 10, 16);
const thumb = clamp(Math.round(size * 0.065), 16, 30);
const playBtn = clamp(Math.round(size * 0.3), 76, 260);
const playIcon = Math.round(playBtn * 0.4);
const restartBtn = clamp(Math.round(playBtn * 0.55), 42, 140);
const restartIcon = Math.round(restartBtn * 0.5);
// bottom UI block fixed so scrub never gets pushed out
const bottomBlock = Math.round(timeSize * 1.3 + barH + pad * 0.9);
const bgUrl = albumArtUrl || (defaultAlbumArtUrl ?? null);
return (
<div ref={wrapRef} className={`relative h-full w-full overflow-hidden bg-gray-900 ${className}`}>
{bgUrl ? (
<>
<div className="absolute inset-0 bg-cover bg-center" style={{ backgroundImage: `url(${bgUrl})` }} />
<div className="absolute inset-0 bg-gray-900/50" />
</>
) : null}
<div className="relative z-10 flex h-full w-full flex-col" style={{ padding: pad }}>
{/* Header */}
<div className="min-h-0">
<div
className="truncate text-gray-200"
style={{ fontSize: titleSize, lineHeight: 1.1, letterSpacing: '0.01em' }}
>
{effectiveTitle}
</div>
{effectiveSubtitle ? (
<div className="mt-1 truncate text-gray-400" style={{ fontSize: subSize, lineHeight: 1.15 }}>
{effectiveSubtitle}
</div>
) : null}
{err ? (
<div className="mt-2 text-gray-300" style={{ fontSize: subSize }}>
{err}
</div>
) : !isReady ? (
<div className="mt-2 text-gray-500" style={{ fontSize: subSize }}>
Loading
</div>
) : null}
</div>
{/* Waveform + controls */}
<div className="mt-3 flex-1 min-h-0">
<div
className="group relative w-full overflow-hidden rounded-lg border border-2 border-yellow-400 bg-gray-900/80"
style={{ height: `calc(100% - ${bottomBlock}px)` }}
>
{/* semi-transparent background so album art shows through */}
<canvas ref={waveRef} className="h-full w-full" />
<div className="absolute left-1/2 top-1/2 flex -translate-x-1/2 -translate-y-1/2 items-center gap-3">
<button
onClick={restart}
className={[
'rounded-full border border-gray-700 bg-gray-950/80 text-gray-200',
'transition group-hover:border-gray-600 group-hover:bg-gray-950/90',
'focus:outline-none focus:ring-2 focus:ring-gray-500/40',
].join(' ')}
style={{ width: restartBtn, height: restartBtn }}
aria-label="Restart"
title="Restart"
>
<svg width={restartIcon} height={restartIcon} viewBox="0 0 24 24" className="mx-auto" aria-hidden>
<path d="M12 5a7 7 0 1 1-6.4 4H3l3.5-3.5L10 9H7.8A5 5 0 1 0 12 7v-2z" fill="currentColor" />
</svg>
</button>
<button
onClick={togglePlay}
className={[
'rounded-full border border-gray-700 bg-gray-950/80 text-gray-200',
'transition group-hover:border-gray-600 group-hover:bg-gray-950/90',
'focus:outline-none focus:ring-2 focus:ring-gray-500/40',
].join(' ')}
style={{ width: playBtn, height: playBtn }}
aria-label={isPlaying ? 'Pause' : 'Play'}
title={isPlaying ? 'Pause' : 'Play'}
>
{!isPlaying ? (
<svg width={playIcon} height={playIcon} viewBox="0 0 24 24" className="mx-auto" aria-hidden>
<path d="M8.5 5.5v13l11-6.5-11-6.5z" fill="currentColor" />
</svg>
) : (
<svg width={playIcon} height={playIcon} viewBox="0 0 24 24" className="mx-auto" aria-hidden>
<path d="M7 6h3v12H7zM14 6h3v12h-3z" fill="currentColor" />
</svg>
)}
{isBuffering ? (
<div className="absolute -bottom-7 left-1/2 -translate-x-1/2 text-xs text-gray-300">Buffering</div>
) : null}
</button>
</div>
</div>
{/* Bottom block: always visible */}
<div className="mt-3">
<div
className="flex items-center justify-between tabular-nums text-gray-300"
style={{ fontSize: timeSize }}
>
<div>{fmtTime(dragging ? dragValue : t)}</div>
<div>{duration ? `-${fmtTime(remaining)}` : '0:00'}</div>
</div>
<div
ref={barRef}
className="mt-2 relative w-full cursor-pointer select-none rounded-full bg-gray-800"
style={{ height: barH }}
onPointerDown={onBarPointerDown}
onPointerMove={onBarPointerMove}
onPointerUp={onBarPointerUp}
onPointerCancel={() => setDragging(false)}
title="Scrub"
>
<div
className="absolute left-0 top-0 h-full rounded-full bg-gray-600"
style={{
width: `${progress * 100}%`,
transition: dragging ? 'none' : 'width 80ms linear',
}}
/>
<div
className="absolute top-1/2 -translate-y-1/2 rounded-full bg-gray-200"
style={{
left: `calc(${progress * 100}% - ${Math.floor(thumb / 2)}px)`,
width: thumb,
height: thumb,
transform: `translateY(-50%) scale(${dragging ? 1.08 : 1})`,
transition: 'transform 120ms ease-out',
}}
/>
</div>
</div>
</div>
</div>
<audio ref={audioRef} preload="metadata" />
</div>
);
}

View File

@@ -3,7 +3,8 @@ import { FaTrashAlt, FaEye, FaEyeSlash } from 'react-icons/fa';
import { openConfirm } from './ConfirmModal';
import classNames from 'classnames';
import { apiClient } from '@/utils/api';
import { isVideo } from '@/utils/basic';
import AudioPlayer from './AudioPlayer';
import { isVideo, isAudio } from '@/utils/basic';
interface DatasetImageCardProps {
imageUrl: string;
@@ -37,9 +38,9 @@ const DatasetImageCard: React.FC<DatasetImageCardProps> = ({
.then(res => res.data)
.then(data => {
console.log('Caption fetched:', data);
if (data){
if (data) {
// fix issue where caption could be non string
data = `${data}`
data = `${data}`;
}
setCaption(data || '');
setSavedCaption(data || '');
@@ -123,6 +124,8 @@ const DatasetImageCard: React.FC<DatasetImageCardProps> = ({
const isCaptionCurrent = caption.trim() === savedCaption;
const isItAVideo = isVideo(imageUrl);
const isItAudio = isAudio(imageUrl);
const isItImage = !isItAVideo && !isItAudio;
return (
<div className={`flex flex-col ${className}`}>
@@ -135,7 +138,7 @@ const DatasetImageCard: React.FC<DatasetImageCardProps> = ({
<div className="absolute inset-0 rounded-t-lg shadow-md">
{inViewport && isVisible && (
<>
{isItAVideo ? (
{isItAVideo && (
<video
src={`/api/img/${encodeURIComponent(imageUrl)}`}
className={`w-full h-full object-contain`}
@@ -144,7 +147,14 @@ const DatasetImageCard: React.FC<DatasetImageCardProps> = ({
muted
controls
/>
) : (
)}
{isItAudio && (
<AudioPlayer
src={`/api/img/${encodeURIComponent(imageUrl)}`}
title={imageUrl.replace(/^.*[\\/]/, '')}
/>
)}
{isItImage && (
<img
src={`/api/img/${encodeURIComponent(imageUrl)}`}
alt={alt}
@@ -162,7 +172,7 @@ const DatasetImageCard: React.FC<DatasetImageCardProps> = ({
</div>
)}
{children && <div className="absolute inset-0 flex items-center justify-center">{children}</div>}
<div className="absolute top-1 right-1 flex space-x-2">
<div className="absolute top-1 right-1 flex space-x-2 z-10">
<button
className="bg-gray-800 rounded-full p-2"
onClick={() => {
@@ -189,7 +199,7 @@ const DatasetImageCard: React.FC<DatasetImageCardProps> = ({
</button>
</div>
</div>
{inViewport && isVisible && (
{inViewport && isVisible && !isItAudio && (
<div className="text-xs text-gray-100 bg-gray-950 mt-1 absolute bottom-0 left-0 p-1 opacity-25 hover:opacity-90 transition-opacity duration-300 w-full">
{imageUrl}
</div>

View File

@@ -116,6 +116,7 @@ export default function FullscreenDropOverlay({
accept || {
'image/*': ['.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp'],
'video/*': ['.mp4', '.avi', '.mov', '.mkv', '.wmv', '.m4v', '.flv'],
'audio/*': ['.mp3', '.wav'],
'text/*': ['.txt'],
},
[accept],

View File

@@ -6,6 +6,8 @@ export const wait = (ms: number) => new Promise(resolve => setTimeout(resolve, m
export const imgExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.svg', '.bmp'];
export const videoExtensions = ['.mp4', '.avi', '.mov', '.mkv', '.wmv', '.m4v', '.flv'];
export const audioExtensions = ['.mp3', '.wav'];
export const isVideo = (filePath: string) => videoExtensions.includes(filePath.toLowerCase().slice(-4));
export const isImage = (filePath: string) => imgExtensions.includes(filePath.toLowerCase().slice(-4));
export const isAudio = (filePath: string) => audioExtensions.includes(filePath.toLowerCase().slice(-4));

View File

@@ -22,6 +22,20 @@ const config: Config = {
200: '#e5e5e5',
100: '#f5f5f5',
},
yellow: {
950: '#1a1203',
900: '#2a1c05',
800: '#3a2607',
700: '#5a3a0b',
600: '#7a4e0f',
500: '#f59e0b', // deeper than base; good for hover on dark
400: '#fbbf24', // your base
300: '#fcd34d',
200: '#fde68a',
100: '#fef3c7',
50: '#fffbeb',
},
},
},
},