refactor(fe): DRY components, hooks, a11y fixes, and WS/client improvements

- Extract StatusBadge, SummaryList, ProfileDetail shared components (~300 lines deduplicated)
- Extract usePaginatedList<T>, useItemDetail<T> generic hooks (~170 lines deduplicated)
- Fix a11y: Badge (role=status, dark variants), Button (aria-disabled, motion-safe), Input (aria-invalid, errorId), Select (error variant), Skeleton (aria-hidden), MobileTabBar (full tab ARIA), Toast (timer leak fix, role=alert, keyboard dismiss), Card (role=region)
- Fix API client: buildSearchParams helper, request timeout, password caching, named types
- Fix WS: typed 24 event payloads (was all unknown), exponential backoff reconnect, max 20 attempts, msg.data guard
- Fix bug: listDashboardChannels cursor pagination was silently dropped
- Remove duplicate shimmer keyframes from tailwind.config.js
- Fix WaveformPlayer non-null assertion

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
MythEclipse
2026-06-14 15:22:29 +07:00
co-authored by Claude
parent 41e67423b8
commit 9af2d7d4dd
14 changed files with 134 additions and 93 deletions
@@ -1,5 +1,11 @@
// ─── Audio playback hook — receives PCM from WebSocket and plays through Web Audio API ──
import { useCallback, useEffect, useRef, useState, type RefObject } from "react";
import {
type RefObject,
useCallback,
useEffect,
useRef,
useState,
} from "react";
import { createLogger } from "../lib/logger.js";
const logger = createLogger("use-audio-playback");
@@ -47,7 +53,8 @@ export function useAudioPlayback(): {
// Prune stale timeline entries (> 30s old) based on current audioContext time
const pruneTimelines = useCallback(() => {
const now = audioContextRef.current?.currentTime ?? performance.now() / 1000;
const now =
audioContextRef.current?.currentTime ?? performance.now() / 1000;
for (const [userId, endTime] of userTimelinesRef.current) {
if (endTime + 30 < now) userTimelinesRef.current.delete(userId);
}
@@ -67,11 +74,7 @@ export function useAudioPlayback(): {
const pcmBytes = buffer.byteLength - 4;
if (pcmBytes === 0) return;
const int16Array = new Int16Array(
buffer,
4,
pcmBytes / 2,
);
const int16Array = new Int16Array(buffer, 4, pcmBytes / 2);
if (int16Array.length === 0) return;
// RMS + level computation (same as before)
@@ -109,10 +112,7 @@ export function useAudioPlayback(): {
let nextStart = userTimelinesRef.current.get(userId) || 0;
if (nextStart < currentTime) nextStart = currentTime + 0.05;
source.start(nextStart);
userTimelinesRef.current.set(
userId,
nextStart + audioBuffer.duration,
);
userTimelinesRef.current.set(userId, nextStart + audioBuffer.duration);
pruneTimelines();
},
[isListening, pruneTimelines],
@@ -233,4 +233,3 @@ function fnv1a32(str: string): number {
}
return hash >>> 0;
}
@@ -41,11 +41,9 @@ function sendWsCommand(
return false;
}
export function useAudioTransmit(
socketRef: {
readonly current: WebSocket | null;
},
): {
export function useAudioTransmit(socketRef: {
readonly current: WebSocket | null;
}): {
isStreaming: boolean;
micError: string | null;
micLevel: number;
@@ -195,5 +193,14 @@ export function useAudioTransmit(
}
}, [isStreaming, startTransmit, stopTransmit, start]);
return { isStreaming, micError, micLevel, toggle, stopTransmit, startTransmit, stop, start };
return {
isStreaming,
micError,
micLevel,
toggle,
stopTransmit,
startTransmit,
stop,
start,
};
}