init 新范式

This commit is contained in:
2025-05-24 09:11:30 +08:00
parent 3057ca121c
commit 7124382c5c
64 changed files with 4074 additions and 16 deletions

194
src/lib/player-stream.ts Normal file
View File

@@ -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<void>[] = []; // 跟踪解码进程
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();
}
}
}

75
src/lib/player.ts Normal file
View File

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

21
src/lib/random.ts Normal file
View File

@@ -0,0 +1,21 @@
import { customAlphabet } from 'nanoid';
export const letter = 'abcdefghijklmnopqrstuvwxyz';
export const number = '0123456789';
const alphanumeric = `${letter}${number}`;
export const alphanumericWithDash = `${alphanumeric}-`;
export const uuid = customAlphabet(letter);
export const nanoid = customAlphabet(alphanumeric, 10);
export const nanoidWithDash = customAlphabet(alphanumericWithDash, 10);
/**
* 创建一个随机的 id以字母开头的字符串
* @param number
* @returns
*/
export const randomId = (number: number) => {
const _letter = uuid(1);
return `${_letter}${nanoid(number)}`;
};