310 lines
8.9 KiB
JavaScript
310 lines
8.9 KiB
JavaScript
/**
|
||
* TTS 队列播放器
|
||
*/
|
||
|
||
export class TtsPlayer {
|
||
constructor() {
|
||
this.queue = [];
|
||
this.currentAudio = null;
|
||
this.currentItem = null;
|
||
this.currentStream = null;
|
||
this.currentCleanup = null;
|
||
this.isPlaying = false;
|
||
this.onStateChange = null; // 回调:(state, item, info) => void
|
||
}
|
||
|
||
/**
|
||
* 入队
|
||
* @param {Object} item - { id, audioBlob, text? }
|
||
* @returns {boolean} 是否成功入队(重复id会跳过)
|
||
*/
|
||
enqueue(item) {
|
||
if (!item?.audioBlob && !item?.streamFactory) return false;
|
||
// 防重复
|
||
if (item.id && this.queue.some(q => q.id === item.id)) {
|
||
return false;
|
||
}
|
||
this.queue.push(item);
|
||
this._notifyState('enqueued', item);
|
||
if (!this.isPlaying) {
|
||
this._playNext();
|
||
}
|
||
return true;
|
||
}
|
||
|
||
/**
|
||
* 清空队列并停止播放
|
||
*/
|
||
clear() {
|
||
this.queue = [];
|
||
this._stopCurrent(true);
|
||
this.currentItem = null;
|
||
this.isPlaying = false;
|
||
this._notifyState('cleared', null);
|
||
}
|
||
|
||
/**
|
||
* 获取队列长度
|
||
*/
|
||
get length() {
|
||
return this.queue.length;
|
||
}
|
||
|
||
/**
|
||
* 立即播放(打断队列)
|
||
* @param {Object} item
|
||
*/
|
||
playNow(item) {
|
||
if (!item?.audioBlob && !item?.streamFactory) return false;
|
||
this.queue = [];
|
||
this._stopCurrent(true);
|
||
this._playItem(item);
|
||
return true;
|
||
}
|
||
|
||
/**
|
||
* 切换播放(同一条则暂停/继续)
|
||
* @param {Object} item
|
||
*/
|
||
toggle(item) {
|
||
if (!item?.audioBlob && !item?.streamFactory) return false;
|
||
if (this.currentItem?.id === item.id && this.currentAudio) {
|
||
if (this.currentAudio.paused) {
|
||
this.currentAudio.play().catch(err => {
|
||
console.warn('[TTS Player] 播放被阻止(需用户手势):', err);
|
||
this._notifyState('blocked', item);
|
||
});
|
||
} else {
|
||
this.currentAudio.pause();
|
||
}
|
||
return true;
|
||
}
|
||
return this.playNow(item);
|
||
}
|
||
|
||
_playNext() {
|
||
if (this.queue.length === 0) {
|
||
this.isPlaying = false;
|
||
this.currentItem = null;
|
||
this._notifyState('idle', null);
|
||
return;
|
||
}
|
||
|
||
const item = this.queue.shift();
|
||
this._playItem(item);
|
||
}
|
||
|
||
_playItem(item) {
|
||
this.isPlaying = true;
|
||
this.currentItem = item;
|
||
this._notifyState('playing', item);
|
||
|
||
if (item.streamFactory) {
|
||
this._playStreamItem(item);
|
||
return;
|
||
}
|
||
|
||
const url = URL.createObjectURL(item.audioBlob);
|
||
const audio = new Audio(url);
|
||
this.currentAudio = audio;
|
||
this.currentCleanup = () => {
|
||
URL.revokeObjectURL(url);
|
||
};
|
||
|
||
audio.onloadedmetadata = () => {
|
||
this._notifyState('metadata', item, { duration: audio.duration || 0 });
|
||
};
|
||
|
||
audio.ontimeupdate = () => {
|
||
this._notifyState('progress', item, { currentTime: audio.currentTime || 0, duration: audio.duration || 0 });
|
||
};
|
||
|
||
audio.onplay = () => {
|
||
this._notifyState('playing', item);
|
||
};
|
||
|
||
audio.onpause = () => {
|
||
if (!audio.ended) this._notifyState('paused', item);
|
||
};
|
||
|
||
audio.onended = () => {
|
||
this.currentCleanup?.();
|
||
this.currentCleanup = null;
|
||
this.currentAudio = null;
|
||
this.currentItem = null;
|
||
this._notifyState('ended', item);
|
||
this._playNext();
|
||
};
|
||
|
||
audio.onerror = (e) => {
|
||
console.error('[TTS Player] 播放失败:', e);
|
||
this.currentCleanup?.();
|
||
this.currentCleanup = null;
|
||
this.currentAudio = null;
|
||
this.currentItem = null;
|
||
this._notifyState('error', item);
|
||
this._playNext();
|
||
};
|
||
|
||
audio.play().catch(err => {
|
||
console.warn('[TTS Player] 播放被阻止(需用户手势):', err);
|
||
this._notifyState('blocked', item);
|
||
this._playNext();
|
||
});
|
||
}
|
||
|
||
_playStreamItem(item) {
|
||
let objectUrl = '';
|
||
let mediaSource = null;
|
||
let sourceBuffer = null;
|
||
let streamEnded = false;
|
||
let hasError = false;
|
||
const queue = [];
|
||
|
||
const stream = item.streamFactory();
|
||
this.currentStream = stream;
|
||
|
||
const audio = new Audio();
|
||
this.currentAudio = audio;
|
||
|
||
const cleanup = () => {
|
||
if (this.currentAudio) {
|
||
this.currentAudio.pause();
|
||
}
|
||
this.currentAudio = null;
|
||
this.currentItem = null;
|
||
this.currentStream = null;
|
||
if (objectUrl) {
|
||
URL.revokeObjectURL(objectUrl);
|
||
objectUrl = '';
|
||
}
|
||
};
|
||
this.currentCleanup = cleanup;
|
||
|
||
const pump = () => {
|
||
if (!sourceBuffer || sourceBuffer.updating || queue.length === 0) {
|
||
if (streamEnded && sourceBuffer && !sourceBuffer.updating && queue.length === 0) {
|
||
try {
|
||
if (mediaSource?.readyState === 'open') mediaSource.endOfStream();
|
||
} catch {}
|
||
}
|
||
return;
|
||
}
|
||
const chunk = queue.shift();
|
||
if (chunk) {
|
||
try {
|
||
sourceBuffer.appendBuffer(chunk);
|
||
} catch (err) {
|
||
handleStreamError(err);
|
||
}
|
||
}
|
||
};
|
||
|
||
const handleStreamError = (err) => {
|
||
if (hasError) return;
|
||
if (this.currentItem !== item) return;
|
||
hasError = true;
|
||
console.error('[TTS Player] 流式播放失败:', err);
|
||
try { stream?.abort?.(); } catch {}
|
||
cleanup();
|
||
this.currentCleanup = null;
|
||
this._notifyState('error', item);
|
||
this._playNext();
|
||
};
|
||
|
||
mediaSource = new MediaSource();
|
||
objectUrl = URL.createObjectURL(mediaSource);
|
||
audio.src = objectUrl;
|
||
|
||
mediaSource.addEventListener('sourceopen', () => {
|
||
if (hasError) return;
|
||
if (this.currentItem !== item) return;
|
||
try {
|
||
const mimeType = stream?.mimeType || 'audio/mpeg';
|
||
if (!MediaSource.isTypeSupported(mimeType)) {
|
||
throw new Error(`不支持的流式音频类型: ${mimeType}`);
|
||
}
|
||
sourceBuffer = mediaSource.addSourceBuffer(mimeType);
|
||
sourceBuffer.mode = 'sequence';
|
||
sourceBuffer.addEventListener('updateend', pump);
|
||
} catch (err) {
|
||
handleStreamError(err);
|
||
return;
|
||
}
|
||
|
||
const append = (chunk) => {
|
||
if (hasError) return;
|
||
queue.push(chunk);
|
||
pump();
|
||
};
|
||
|
||
const end = () => {
|
||
streamEnded = true;
|
||
pump();
|
||
};
|
||
|
||
const fail = (err) => {
|
||
handleStreamError(err);
|
||
};
|
||
|
||
Promise.resolve(stream?.start?.(append, end, fail)).catch(fail);
|
||
});
|
||
|
||
audio.onloadedmetadata = () => {
|
||
this._notifyState('metadata', item, { duration: audio.duration || 0 });
|
||
};
|
||
|
||
audio.ontimeupdate = () => {
|
||
this._notifyState('progress', item, { currentTime: audio.currentTime || 0, duration: audio.duration || 0 });
|
||
};
|
||
|
||
audio.onplay = () => {
|
||
this._notifyState('playing', item);
|
||
};
|
||
|
||
audio.onpause = () => {
|
||
if (!audio.ended) this._notifyState('paused', item);
|
||
};
|
||
|
||
audio.onended = () => {
|
||
if (this.currentItem !== item) return;
|
||
cleanup();
|
||
this.currentCleanup = null;
|
||
this._notifyState('ended', item);
|
||
this._playNext();
|
||
};
|
||
|
||
audio.onerror = (e) => {
|
||
console.error('[TTS Player] 播放失败:', e);
|
||
handleStreamError(e);
|
||
};
|
||
|
||
audio.play().catch(err => {
|
||
console.warn('[TTS Player] 播放被阻止(需用户手势):', err);
|
||
try { stream?.abort?.(); } catch {}
|
||
cleanup();
|
||
this._notifyState('blocked', item);
|
||
this._playNext();
|
||
});
|
||
}
|
||
|
||
_stopCurrent(abortStream = false) {
|
||
if (abortStream) {
|
||
try { this.currentStream?.abort?.(); } catch {}
|
||
}
|
||
if (this.currentAudio) {
|
||
this.currentAudio.pause();
|
||
this.currentAudio = null;
|
||
}
|
||
this.currentCleanup?.();
|
||
this.currentCleanup = null;
|
||
this.currentStream = null;
|
||
}
|
||
|
||
_notifyState(state, item, info = null) {
|
||
if (typeof this.onStateChange === 'function') {
|
||
try { this.onStateChange(state, item, info); } catch (e) {}
|
||
}
|
||
}
|
||
}
|