|
@@ -1,4 +1,4 @@
|
|
|
-import { Injectable, signal } from '@angular/core';
|
|
|
|
|
|
|
+import { Injectable, NgZone, Optional, signal } from '@angular/core';
|
|
|
|
|
|
|
|
export interface ToastState {
|
|
export interface ToastState {
|
|
|
message: string;
|
|
message: string;
|
|
@@ -17,6 +17,93 @@ export class PlatformService {
|
|
|
private toastTimer: ReturnType<typeof setTimeout> | null = null;
|
|
private toastTimer: ReturnType<typeof setTimeout> | null = null;
|
|
|
private audio: HTMLAudioElement | null = null;
|
|
private audio: HTMLAudioElement | null = null;
|
|
|
private effectAudio: HTMLAudioElement | null = null;
|
|
private effectAudio: HTMLAudioElement | null = null;
|
|
|
|
|
+ private playbackVersion = 0;
|
|
|
|
|
+ private readonly audioCache = new Map<string, HTMLAudioElement>();
|
|
|
|
|
+ private readonly audioPreloads = new Map<string, () => void>();
|
|
|
|
|
+ private preloadQueue: string[] = [];
|
|
|
|
|
+
|
|
|
|
|
+ constructor(@Optional() private readonly zone: NgZone | null = null) {}
|
|
|
|
|
+
|
|
|
|
|
+ /** Only prepare the nearby words; never synthesize audio or download a whole course. */
|
|
|
|
|
+ preloadAudio(urls: string[]): void {
|
|
|
|
|
+ const connection = (navigator as Navigator & { connection?: { saveData?: boolean; effectiveType?: string } }).connection;
|
|
|
|
|
+ if (connection?.saveData || ['slow-2g', '2g'].includes(connection?.effectiveType || '')) {
|
|
|
|
|
+ this.clearAudioPreloads();
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ const nearby = [...new Set(urls.filter(Boolean))].slice(0, 3);
|
|
|
|
|
+ this.preloadQueue = nearby.filter(url => !this.audioCache.has(url));
|
|
|
|
|
+ for (const [url, cancel] of this.audioPreloads) {
|
|
|
|
|
+ if (!nearby.includes(url)) {
|
|
|
|
|
+ cancel();
|
|
|
|
|
+ this.releaseCachedAudio(url);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ this.prepareNextAudio();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ clearAudioPreloads(): void {
|
|
|
|
|
+ this.preloadQueue = [];
|
|
|
|
|
+ for (const [url, cancel] of this.audioPreloads) {
|
|
|
|
|
+ cancel();
|
|
|
|
|
+ this.releaseCachedAudio(url);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private prepareNextAudio(): void {
|
|
|
|
|
+ // Background media readiness must not keep Angular/navigation waiting on a timer.
|
|
|
|
|
+ if (this.zone && NgZone.isInAngularZone()) {
|
|
|
|
|
+ this.zone.runOutsideAngular(() => this.prepareNextAudio());
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ while (this.audioPreloads.size < 2 && this.preloadQueue.length) {
|
|
|
|
|
+ const url = this.preloadQueue.shift()!;
|
|
|
|
|
+ if (this.audioCache.has(url)) continue;
|
|
|
|
|
+ const audio = this.cachedAudio(url);
|
|
|
|
|
+ const finish = (failed = false) => {
|
|
|
|
|
+ cancel();
|
|
|
|
|
+ if (failed) this.releaseCachedAudio(url);
|
|
|
|
|
+ this.prepareNextAudio();
|
|
|
|
|
+ };
|
|
|
|
|
+ const ready = () => finish();
|
|
|
|
|
+ const failed = () => finish(true);
|
|
|
|
|
+ const timer = setTimeout(failed, 8000);
|
|
|
|
|
+ const cancel = () => {
|
|
|
|
|
+ clearTimeout(timer);
|
|
|
|
|
+ audio.removeEventListener('canplaythrough', ready);
|
|
|
|
|
+ audio.removeEventListener('error', failed);
|
|
|
|
|
+ this.audioPreloads.delete(url);
|
|
|
|
|
+ };
|
|
|
|
|
+ this.audioPreloads.set(url, cancel);
|
|
|
|
|
+ audio.addEventListener('canplaythrough', ready, { once: true });
|
|
|
|
|
+ audio.addEventListener('error', failed, { once: true });
|
|
|
|
|
+ audio.preload = 'auto';
|
|
|
|
|
+ audio.load();
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private cachedAudio(url: string): HTMLAudioElement {
|
|
|
|
|
+ const existing = this.audioCache.get(url);
|
|
|
|
|
+ const audio = existing || new Audio(url);
|
|
|
|
|
+ this.audioCache.delete(url);
|
|
|
|
|
+ this.audioCache.set(url, audio);
|
|
|
|
|
+ for (const [key, candidate] of this.audioCache) {
|
|
|
|
|
+ if (this.audioCache.size <= 12) break;
|
|
|
|
|
+ if (candidate !== this.audio && key !== url) this.releaseCachedAudio(key);
|
|
|
|
|
+ }
|
|
|
|
|
+ return audio;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private releaseCachedAudio(url: string): void {
|
|
|
|
|
+ this.audioPreloads.get(url)?.();
|
|
|
|
|
+ const audio = this.audioCache.get(url);
|
|
|
|
|
+ this.audioCache.delete(url);
|
|
|
|
|
+ if (audio && audio !== this.audio) {
|
|
|
|
|
+ audio.pause();
|
|
|
|
|
+ audio.removeAttribute('src');
|
|
|
|
|
+ audio.load();
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
|
|
|
toast(message: string, tone: ToastState['tone'] = 'info'): void {
|
|
toast(message: string, tone: ToastState['tone'] = 'info'): void {
|
|
|
if (this.toastTimer) clearTimeout(this.toastTimer);
|
|
if (this.toastTimer) clearTimeout(this.toastTimer);
|
|
@@ -42,29 +129,41 @@ export class PlatformService {
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
async play(url: string, silentFailure = false, callbacks?: AudioPlaybackCallbacks): Promise<'played' | 'blocked' | 'failed' | 'cancelled'> {
|
|
async play(url: string, silentFailure = false, callbacks?: AudioPlaybackCallbacks): Promise<'played' | 'blocked' | 'failed' | 'cancelled'> {
|
|
|
|
|
+ const version = ++this.playbackVersion;
|
|
|
if (this.audio) this.audio.onended = this.audio.onerror = null;
|
|
if (this.audio) this.audio.onended = this.audio.onerror = null;
|
|
|
this.audio?.pause();
|
|
this.audio?.pause();
|
|
|
- const audio = this.audio = new Audio(url);
|
|
|
|
|
|
|
+ this.audioPreloads.get(url)?.();
|
|
|
|
|
+ const audio = this.audio = this.cachedAudio(url);
|
|
|
|
|
+ const active = () => this.audio === audio && this.playbackVersion === version;
|
|
|
|
|
+ // Reusing an element retains its buffered media; rewind for each deliberate click.
|
|
|
|
|
+ try { audio.currentTime = 0; } catch { /* Metadata may not be available yet. */ }
|
|
|
if (callbacks) {
|
|
if (callbacks) {
|
|
|
audio.onended = () => {
|
|
audio.onended = () => {
|
|
|
|
|
+ if (!active()) return;
|
|
|
audio.onended = audio.onerror = null;
|
|
audio.onended = audio.onerror = null;
|
|
|
- if (this.audio === audio && !audio.error) callbacks.onEnded();
|
|
|
|
|
|
|
+ if (!audio.error) callbacks.onEnded();
|
|
|
};
|
|
};
|
|
|
}
|
|
}
|
|
|
|
|
+ this.prepareNextAudio();
|
|
|
try {
|
|
try {
|
|
|
await audio.play();
|
|
await audio.play();
|
|
|
- if (callbacks && this.audio === audio) audio.onerror = () => {
|
|
|
|
|
|
|
+ if (!active()) return 'cancelled';
|
|
|
|
|
+ audio.onerror = () => {
|
|
|
|
|
+ if (!active()) return;
|
|
|
audio.onended = audio.onerror = null;
|
|
audio.onended = audio.onerror = null;
|
|
|
- if (this.audio === audio) callbacks.onError();
|
|
|
|
|
|
|
+ this.releaseCachedAudio(url);
|
|
|
|
|
+ callbacks?.onError();
|
|
|
};
|
|
};
|
|
|
- return this.audio === audio ? 'played' : 'cancelled';
|
|
|
|
|
|
|
+ return 'played';
|
|
|
} catch (error) {
|
|
} catch (error) {
|
|
|
|
|
+ if (!active()) return 'cancelled';
|
|
|
audio.onended = audio.onerror = null;
|
|
audio.onended = audio.onerror = null;
|
|
|
- if (this.audio !== audio || (error as DOMException)?.name === 'AbortError') return 'cancelled';
|
|
|
|
|
|
|
+ if ((error as DOMException)?.name === 'AbortError') return 'cancelled';
|
|
|
if ((error as DOMException)?.name === 'NotAllowedError') {
|
|
if ((error as DOMException)?.name === 'NotAllowedError') {
|
|
|
this.toast('请再点一次喇叭播放读音', 'info');
|
|
this.toast('请再点一次喇叭播放读音', 'info');
|
|
|
return 'blocked';
|
|
return 'blocked';
|
|
|
}
|
|
}
|
|
|
|
|
+ this.releaseCachedAudio(url);
|
|
|
if (!silentFailure) this.toast('音频播放失败', 'error');
|
|
if (!silentFailure) this.toast('音频播放失败', 'error');
|
|
|
return 'failed';
|
|
return 'failed';
|
|
|
}
|
|
}
|
|
@@ -79,6 +178,8 @@ export class PlatformService {
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
stopAudio(): void {
|
|
stopAudio(): void {
|
|
|
|
|
+ this.playbackVersion++;
|
|
|
|
|
+ this.clearAudioPreloads();
|
|
|
this.effectAudio?.pause();
|
|
this.effectAudio?.pause();
|
|
|
this.effectAudio = null;
|
|
this.effectAudio = null;
|
|
|
if (this.audio) this.audio.onended = this.audio.onerror = null;
|
|
if (this.audio) this.audio.onended = this.audio.onerror = null;
|