tr]:last:border-b-0",
+ className
+ )}
+ {...props}
+ />
+ )
+}
+
+function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
+ return (
+
+ )
+}
+
+function TableHead({ className, ...props }: React.ComponentProps<"th">) {
+ return (
+ [role=checkbox]]:translate-y-[2px]",
+ className
+ )}
+ {...props}
+ />
+ )
+}
+
+function TableCell({ className, ...props }: React.ComponentProps<"td">) {
+ return (
+ | [role=checkbox]]:translate-y-[2px]",
+ className
+ )}
+ {...props}
+ />
+ )
+}
+
+function TableCaption({
+ className,
+ ...props
+}: React.ComponentProps<"caption">) {
+ return (
+
+ )
+}
+
+export {
+ Table,
+ TableHeader,
+ TableBody,
+ TableFooter,
+ TableHead,
+ TableRow,
+ TableCell,
+ TableCaption,
+}
diff --git a/libs/registry/registry/components/ui/textarea.tsx b/libs/registry/registry/components/ui/textarea.tsx
new file mode 100644
index 0000000..7f21b5e
--- /dev/null
+++ b/libs/registry/registry/components/ui/textarea.tsx
@@ -0,0 +1,18 @@
+import * as React from "react"
+
+import { cn } from "@/lib/utils"
+
+function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
+ return (
+
+ )
+}
+
+export { Textarea }
diff --git a/libs/registry/registry/components/ui/tooltip.tsx b/libs/registry/registry/components/ui/tooltip.tsx
new file mode 100644
index 0000000..71ee0fe
--- /dev/null
+++ b/libs/registry/registry/components/ui/tooltip.tsx
@@ -0,0 +1,59 @@
+import * as React from "react"
+import * as TooltipPrimitive from "@radix-ui/react-tooltip"
+
+import { cn } from "@/lib/utils"
+
+function TooltipProvider({
+ delayDuration = 0,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function Tooltip({
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+ )
+}
+
+function TooltipTrigger({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function TooltipContent({
+ className,
+ sideOffset = 0,
+ children,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+ {children}
+
+
+
+ )
+}
+
+export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
diff --git a/libs/registry/registry/lib/player-stream.ts b/libs/registry/registry/lib/player-stream.ts
new file mode 100644
index 0000000..dee27d1
--- /dev/null
+++ b/libs/registry/registry/lib/player-stream.ts
@@ -0,0 +1,194 @@
+import { EventEmitter } from 'eventemitter3';
+type VideoStreamPlayerOptions = {
+ emitter?: EventEmitter;
+};
+export class VideoStreamPlayer {
+ emitter: EventEmitter;
+ audioContext: AudioContext;
+ audioBuffer: AudioBuffer | null = null;
+ audioQueue: Uint8Array[] = [];
+ decodedBuffers: AudioBuffer[] = []; // 存储已解码的音频缓冲区
+ currentSource: AudioBufferSourceNode | null = null;
+ audioElement: HTMLAudioElement;
+ processing: boolean = false;
+ canPlaying: boolean = false;
+ isPlaying: boolean = false;
+ bufferingThreshold: number = 3; // 预缓冲的音频块数量
+ decodePromises: Promise[] = []; // 跟踪解码进程
+ nextPlayTime: number = 0; // 下一个音频片段的开始时间
+ playStatus: 'paused' | 'playing' | 'buffering' | 'ended' = 'paused';
+
+ constructor(opts?: VideoStreamPlayerOptions) {
+ this.emitter = opts?.emitter || new EventEmitter();
+ this.audioContext = new AudioContext();
+ this.audioElement = new Audio();
+ this.audioElement.autoplay = false;
+ // 确保在页面交互后恢复音频上下文(解决自动播放限制问题)
+ document.addEventListener(
+ 'click',
+ () => {
+ if (this.audioContext.state === 'suspended') {
+ this.audioContext.resume();
+ }
+ },
+ { once: true },
+ );
+ }
+
+ // 处理收到的音频数据
+ async appendAudioChunk(chunk: ArrayBuffer | Uint8Array | string) {
+ let audioData: Uint8Array;
+
+ // 处理不同类型的输入数据
+ if (typeof chunk === 'string') {
+ // 如果是base64编码的数据
+ const binary = atob(chunk);
+ audioData = new Uint8Array(binary.length);
+ for (let i = 0; i < binary.length; i++) {
+ audioData[i] = binary.charCodeAt(i);
+ }
+ } else if (chunk instanceof ArrayBuffer) {
+ audioData = new Uint8Array(chunk);
+ } else {
+ audioData = chunk;
+ }
+
+ // 将音频数据加入队列
+ this.audioQueue.push(audioData);
+
+ // 开始解码音频,不等待前面的解码完成
+ this.decodeAudio();
+
+ // 如果当前没有在播放且可以播放,并且已有足够缓冲则开始播放
+ if (!this.isPlaying && this.canPlaying && this.decodedBuffers.length >= this.bufferingThreshold) {
+ this.startPlaying();
+ }
+ }
+
+ // 异步解码音频,不阻塞主线程
+ async decodeAudio() {
+ if (this.processing || this.audioQueue.length === 0) return;
+
+ this.processing = true;
+ const chunk = this.audioQueue.shift()!;
+
+ try {
+ // 解码音频数据
+ const decodePromise = this.audioContext
+ .decodeAudioData(chunk.buffer.slice(0))
+ .then((audioBuffer) => {
+ this.decodedBuffers.push(audioBuffer);
+
+ // 如果已经可以开始播放但尚未播放,开始播放
+ if (this.canPlaying && !this.isPlaying && this.decodedBuffers.length >= this.bufferingThreshold) {
+ this.startPlaying();
+ }
+
+ const index = this.decodePromises.indexOf(decodePromise as any);
+ if (index > -1) {
+ this.decodePromises.splice(index, 1);
+ }
+ })
+ .catch((error) => {
+ console.error('音频解码错误:', error);
+ const index = this.decodePromises.indexOf(decodePromise as any);
+ if (index > -1) {
+ this.decodePromises.splice(index, 1);
+ }
+ });
+
+ this.decodePromises.push(decodePromise as any);
+ } finally {
+ this.processing = false;
+
+ // 继续处理队列中的下一个
+ if (this.audioQueue.length > 0) {
+ this.decodeAudio();
+ }
+ }
+ }
+
+ // 开始播放
+ startPlaying() {
+ if (this.decodedBuffers.length === 0 || this.isPlaying) return;
+
+ this.isPlaying = true;
+ this.nextPlayTime = this.audioContext.currentTime;
+ this.scheduleNextBuffer();
+ this.emitter.emit('play-start');
+ }
+
+ // 安排播放下一个音频缓冲区
+ scheduleNextBuffer() {
+ if (this.decodedBuffers.length === 0) {
+ // 没有更多缓冲区时,如果队列中也没有待解码的数据,就标记为未播放状态
+ if (this.audioQueue.length === 0 && this.decodePromises.length === 0) {
+ this.isPlaying = false;
+ this.emitter.emit('play-end', true);
+ }
+ return;
+ }
+
+ const audioBuffer = this.decodedBuffers.shift()!;
+ const source = this.audioContext.createBufferSource();
+ this.currentSource = source;
+ source.buffer = audioBuffer;
+ source.connect(this.audioContext.destination);
+
+ // 在确切的时间安排播放,确保无缝连接
+ source.start(this.nextPlayTime);
+ this.nextPlayTime = parseFloat((this.nextPlayTime + audioBuffer.duration).toFixed(4));
+ // console.log('audioBuffer.duration start', audioBuffer.duration, this.nextPlayTime);
+ // 在音频播放结束前安排下一个缓冲区(提前一点安排可以减少间隙)
+ const safetyOffset = Math.min(0.05, audioBuffer.duration / 2); // 至少提前50ms或一半时长
+ setTimeout(() => {
+ this.scheduleNextBuffer();
+ }, (audioBuffer.duration - safetyOffset) * 1000);
+
+ // 发出事件通知
+ this.emitter.emit('playing', { duration: audioBuffer.duration });
+
+ // 如果缓冲区不足,继续解码
+ if (this.decodedBuffers.length < this.bufferingThreshold && this.audioQueue.length > 0 && !this.processing) {
+ this.decodeAudio();
+ }
+ }
+
+ // 处理WebSocket接收到的音频数据
+ handleWebSocketAudio(data: any) {
+ if (data && data.audio) {
+ this.appendAudioChunk(data.audio);
+ }
+ }
+
+ // 停止播放
+ stop() {
+ if (this.currentSource) {
+ try {
+ this.currentSource.stop();
+ } catch (e) {
+ // 可能已经停止,忽略错误
+ }
+ this.currentSource.disconnect();
+ this.currentSource = null;
+ }
+
+ this.audioQueue = [];
+ this.decodedBuffers = [];
+ this.decodePromises = [];
+ this.processing = false;
+ this.isPlaying = false;
+ this.canPlaying = false;
+ this.nextPlayTime = 0;
+ this.emitter.emit('stopped');
+ }
+
+ setCanPlaying(canPlaying = true) {
+ this.canPlaying = canPlaying;
+
+ // 如果设置为可播放,且有足够的解码缓冲区,则开始播放
+ if (canPlaying && !this.isPlaying && this.decodedBuffers.length >= this.bufferingThreshold) {
+ this.startPlaying();
+ }
+ }
+}
diff --git a/libs/registry/registry/lib/player.ts b/libs/registry/registry/lib/player.ts
new file mode 100644
index 0000000..60a76bc
--- /dev/null
+++ b/libs/registry/registry/lib/player.ts
@@ -0,0 +1,75 @@
+import { EventEmitter } from 'eventemitter3';
+type VideoPlayerOptions = {
+ url?: string;
+ emitter?: EventEmitter;
+};
+export class VideoPlayer {
+ url?: string;
+ isPlaying = false;
+ audio?: HTMLAudioElement;
+ emitter?: EventEmitter;
+ private endedHandler?: () => void;
+ constructor(opts?: VideoPlayerOptions) {
+ this.url = opts?.url;
+ this.emitter = opts?.emitter || new EventEmitter();
+ }
+ init() {
+ if (!this.emitter) {
+ this.emitter = new EventEmitter();
+ }
+ return this.emitter;
+ }
+ play(url?: string) {
+ if (this.isPlaying) {
+ return { code: 400 };
+ }
+ const playUrl = url || this.url;
+ if (!playUrl) {
+ return { code: 404 };
+ }
+ if (playUrl !== this.url) {
+ this.url = playUrl;
+ }
+ // 创建新的Audio对象前,确保清理之前的资源
+ if (this.audio && this.endedHandler) {
+ this.audio.removeEventListener('ended', this.endedHandler);
+ }
+
+ this.audio = new Audio(playUrl);
+ this.audio.play();
+ this.isPlaying = true;
+ this.emitter?.emit('start', { url: playUrl, status: 'start' });
+
+ // 保存引用以便于后续移除
+ this.endedHandler = () => {
+ this.audio = undefined;
+ this.isPlaying = false;
+ this.emitter?.emit('stop', this.url);
+ };
+
+ this.audio.addEventListener('ended', this.endedHandler);
+ return { code: 200 };
+ }
+ stop() {
+ if (this.isPlaying) {
+ // 移除事件监听器
+ if (this.audio && this.endedHandler) {
+ this.audio.removeEventListener('ended', this.endedHandler);
+ }
+ this.audio?.pause();
+ this.audio = undefined;
+ this.isPlaying = false;
+ }
+ this.emitter?.emit('stop', this.url);
+ }
+ onStop(callback: (url: string) => void) {
+ this.emitter?.on('stop', callback);
+ return () => {
+ this.emitter?.off('stop', callback);
+ };
+ }
+ close() {
+ this.emitter?.removeAllListeners();
+ this.emitter = undefined;
+ }
+}
diff --git a/libs/registry/registry/lib/random.ts b/libs/registry/registry/lib/random.ts
new file mode 100644
index 0000000..741ec3f
--- /dev/null
+++ b/libs/registry/registry/lib/random.ts
@@ -0,0 +1,4 @@
+import { customAlphabet } from 'nanoid';
+
+export const letter = 'abcdefghijklmnopqrstuvwxyz';
+export const uuid = customAlphabet(letter);
diff --git a/libs/registry/registry/lib/utils.ts b/libs/registry/registry/lib/utils.ts
new file mode 100644
index 0000000..bd0c391
--- /dev/null
+++ b/libs/registry/registry/lib/utils.ts
@@ -0,0 +1,6 @@
+import { clsx, type ClassValue } from "clsx"
+import { twMerge } from "tailwind-merge"
+
+export function cn(...inputs: ClassValue[]) {
+ return twMerge(clsx(inputs))
+}
|