mirror of
https://github.com/ostris/ai-toolkit.git
synced 2026-04-30 11:11:37 +00:00
Build out an audio player card in preperation for audio datasets and samples.
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
797
ui/src/components/AudioPlayer.tsx
Normal file
797
ui/src/components/AudioPlayer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user