generated from template/astro-template
init 新范式
This commit is contained in:
194
src/lib/player-stream.ts
Normal file
194
src/lib/player-stream.ts
Normal 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
75
src/lib/player.ts
Normal 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
21
src/lib/random.ts
Normal 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)}`;
|
||||
};
|
||||
Reference in New Issue
Block a user