Files
controller-v2/client/src/components/FirPanel.tsx
T

121 lines
7.9 KiB
TypeScript
Raw Normal View History

import FirRoomDetails from "./FirRoomDetails";
import { ChevronDown } from "lucide-react";
import { toast } from "sonner";
import { firTranslate, firErrorNotice, firNoticeText, type FirNotice } from '@/lib/firLocale';
import { useEffect, useRef, useState } from 'react';
import { useDevice } from '@/contexts/DeviceContext';
import { decodeFirWav, FirError, firRoomName, applyFir, readFirBinary, readFirJson, validateFirBinary, type FirRoom } from '@/lib/fir';
const LIBRARY = (import.meta.env.VITE_FIR_API_BASE || 'https://cs-fir.luxsin.com.cn/api').replace(/\/$/, '');
export default function FirPanel() {
const { ip } = useDevice();
return <FirControls key={ip} />;
}
function FirControls() {
const { api, isConnected, isDemoMode, deviceState } = useDevice();
const [enabled, setEnabled] = useState<boolean | null>(deviceState?.fir_enable === undefined ? null : deviceState.fir_enable === 1);
const [rooms, setRooms] = useState<FirRoom[]>([]);
const [selected, setSelected] = useState('');
const [detailsOpen, setDetailsOpen] = useState(false);
const [source, setSource] = useState<'library' | 'manual'>('library');
const [left, setLeft] = useState<File | null>(null);
const [right, setRight] = useState<File | null>(null);
const [busy, setBusy] = useState(false);
const [loading, setLoading] = useState(false);
const [status, setStatus] = useState<FirNotice | null>(null);
const [error, setError] = useState<FirNotice | null>(null);
const t = (code: string, params?: Record<string, string | number>) => firTranslate(deviceState?.language, code, params);
const lock = useRef(false);
const mounted = useRef(true);
useEffect(() => { mounted.current = true; return () => { mounted.current = false; }; }, []);
useEffect(() => {
if (deviceState?.fir_enable !== undefined && !lock.current) setEnabled(deviceState.fir_enable === 1);
}, [deviceState?.fir_enable]);
const connected = !!api && isConnected && !isDemoMode;
const room = rooms.find(item => item.room_id === selected);
async function loadRooms() {
if (lock.current) return;
setLoading(true); setError(null);
try {
const items = await readFirJson<FirRoom[]>(`${LIBRARY}/rooms/defaults`);
if (!Array.isArray(items)) throw new FirError('invalidRooms');
if (mounted.current) {
setRooms(items);
setSelected(value => items.some(r => r.room_id === value) ? value : items[0]?.room_id || '');
}
} catch (e) { if (mounted.current) setError(firErrorNotice(e)); }
finally { if (mounted.current) setLoading(false); }
}
useEffect(() => { void loadRooms(); }, []);
async function toggle(value: boolean) {
if (!connected || !api || lock.current) return;
lock.current = true; setBusy(true); setError(null); setStatus(null);
try { await api.setFirEnable(value); if (mounted.current) { setEnabled(value); setStatus(null); } }
catch (e) { if (mounted.current) { setEnabled(null); setError(firErrorNotice(e)); } }
finally { lock.current = false; if (mounted.current) setBusy(false); }
}
async function upload() {
if (!connected || !api || lock.current) return;
lock.current = true; setBusy(true); setError(null); setStatus({ code: 'validating' });
try {
let l: ArrayBuffer, r: ArrayBuffer;
if (source === 'library') {
if (!room) throw new FirError('selectError');
if (room.sample_rate == null) throw new FirError('rateError');
const base = `${LIBRARY}/rooms/${encodeURIComponent(room.room_id)}/device`;
[l, r] = await Promise.all([readFirBinary(`${base}/left`, room.sample_rate), readFirBinary(`${base}/right`, room.sample_rate)]);
} else {
if (!left || !right) throw new FirError('filesError');
// Check sizes before loading potentially large files.
if (left.size > 1024 * 1024 || right.size > 1024 * 1024) throw new FirError('binarySize');
[l, r] = await Promise.all([left.arrayBuffer().then(decodeFirWav), right.arrayBuffer().then(decodeFirWav)]);
}
if (validateFirBinary(l) !== validateFirBinary(r)) throw new FirError('mismatch');
if (!mounted.current) return;
await applyFir(api, l, r, message => { if (mounted.current) { setStatus({ code: message }); } });
if (mounted.current) { setStatus(null); toast.success(t('success', { count: l.byteLength / 4 }), { duration: 10000 }); }
} catch (e) {
if (mounted.current) { setStatus(null); setError(firErrorNotice(e)); }
} finally { lock.current = false; if (mounted.current) setBusy(false); }
}
const control = 'w-full rounded-lg border border-white/20 bg-black p-2 text-sm text-white';
return <section className="ios-list-group p-4 space-y-3" aria-label={t('title')}>
<div className="flex items-center justify-between">
<h2 className="text-[16px] font-medium text-white">{t('title')}</h2>
<label className="ios-toggle">
<input aria-label={t('enable')} type="checkbox" checked={enabled === true} disabled={!connected || busy} onChange={e => void toggle(e.target.checked)} />
<span className="ios-toggle-track"><span className="ios-toggle-thumb" /></span>
</label>
</div>
{(enabled === true || busy) && <fieldset disabled={busy} className="space-y-3 disabled:opacity-60">
<label className="block text-sm text-white/80">{t('source')}
<span className="relative mt-1 block"><select className={`${control} appearance-none pr-10`} value={source} onChange={e => { setSource(e.target.value as 'library' | 'manual'); setStatus(null); }}>
<option value="library">{t('library')}</option><option value="manual">{t('manual')}</option>
</select><ChevronDown aria-hidden="true" size={16} className="pointer-events-none absolute right-4 top-1/2 -translate-y-1/2 text-white/60" /></span>
</label>
{source === 'library' ? <>
<div>
<div className="flex items-center justify-between">
<label htmlFor="fir-room-select" className="text-sm text-white/80">{t('room')}</label>
<button type="button" disabled={!room || loading} onClick={() => setDetailsOpen(true)} className="text-xs text-cyan-300 hover:underline disabled:opacity-40">{t('details')}</button>
</div>
<span className="relative mt-1 block"><select className={`${control} appearance-none pr-10`} id="fir-room-select" value={selected} disabled={loading} onChange={e => { setSelected(e.target.value); setStatus(null); }}>
<option value="">{loading ? t('loading') : t('select')}</option>
{rooms.map(item => <option key={item.room_id} value={item.room_id}>{firRoomName(item) || t('unnamed')}</option>)}
</select><ChevronDown aria-hidden="true" size={16} className="pointer-events-none absolute right-4 top-1/2 -translate-y-1/2 text-white/60" /></span>
</div>
</> : <>
{(['left', 'right'] as const).map(side => <label key={side} className="block text-sm text-white/80">{t(side)} .wav
<input className={control} type="file" accept=".wav,audio/wav,audio/x-wav" onChange={e => { (side === 'left' ? setLeft : setRight)(e.target.files?.[0] || null); setStatus(null); }} />
<span className="text-xs text-white/60">{t('bytes', { count: (side === 'left' ? left : right)?.size ?? 0 })}</span>
</label>)}
</>}
<button className="w-full rounded-lg bg-cyan-300 p-2 text-black font-medium disabled:opacity-40" disabled={!connected || loading || (source === 'library' ? !room : !left || !right)} onClick={() => void upload()}>{t('upload')}</button>
</fieldset>}
{room && <FirRoomDetails room={room} language={deviceState?.language} open={detailsOpen && enabled === true && source === 'library'} onOpenChange={setDetailsOpen} />}
{status && (enabled === true || busy) && <p role="status" className="text-sm text-cyan-300">{t(status.code, status.params)}</p>}
{error && (enabled === true || error.code !== 'libraryError') && <p role="alert" className="text-sm text-red-300">{firNoticeText(deviceState?.language, error)}</p>}
</section>;
}