Initial commit
This commit is contained in:
309
modules/tts/tts-player.js
Normal file
309
modules/tts/tts-player.js
Normal file
@@ -0,0 +1,309 @@
|
||||
/**
|
||||
* 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) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user