浏览代码

fix(mobile): reveal history word meanings after audio ends

彭峰 3 天之前
父节点
当前提交
11627fe2ed

+ 8 - 1
projects/xiaoshu-mobile/src/app/core/home-learning-modules.integration.spec.ts

@@ -601,7 +601,7 @@ describe('home learning modules', () => {
     api.historyWordAudio = 'apple.mp3';
     const post = spyOn(api, 'post').and.callThrough();
     const platform = TestBed.inject(PlatformService);
-    const play = spyOn(platform, 'play');
+    const play = spyOn(platform, 'play').and.resolveTo('played');
     const stopAudio = spyOn(platform, 'stopAudio');
     const harness = await RouterTestingHarness.create();
     await harness.navigateByUrl('/pages/his/his');
@@ -614,7 +614,14 @@ describe('home learning modules', () => {
     const button = harness.routeNativeElement?.querySelector('.history-word-audio') as HTMLButtonElement;
     expect(button.disabled).toBeFalse();
     expect(button.getAttribute('aria-label')).toBe('播放单词读音:apple');
+    expect(harness.routeNativeElement?.querySelector('.readonly-word-list')?.textContent).not.toContain('苹果');
     button.querySelector('strong')!.click();
+    await harness.fixture.whenStable();
+    harness.fixture.detectChanges();
+    expect(harness.routeNativeElement?.querySelector('.readonly-word-list')?.textContent).not.toContain('苹果');
+    play.calls.mostRecent().args[2]!.onEnded();
+    harness.fixture.detectChanges();
+    expect(harness.routeNativeElement?.querySelector('.readonly-word-list')?.textContent).toContain('苹果');
     (button.querySelector('lucide-icon') as HTMLElement).click();
     expect(play).toHaveBeenCalledTimes(2);
     expect(play.calls.mostRecent().args[0]).toContain('apple.mp3');

+ 40 - 0
projects/xiaoshu-mobile/src/app/core/platform.service.spec.ts

@@ -13,3 +13,43 @@ describe('学习提示音', () => {
     service.stopAudio(); expect(audios[1].pause).toHaveBeenCalled(); expect(audios[2].pause).toHaveBeenCalled();
   });
 });
+
+describe('单词播放完成通知', () => {
+  let audios: HTMLAudioElement[], service: PlatformService;
+  beforeEach(() => {
+    audios = [];
+    spyOn(window, 'Audio').and.callFake((function() {
+      const audio = document.createElement('audio');
+      spyOn(audio, 'play').and.resolveTo();
+      spyOn(audio, 'pause');
+      audios.push(audio);
+      return audio;
+    }) as any);
+    service = new PlatformService();
+  });
+  it('play 成功不代表结束,仅 ended 事件触发完成通知', async () => {
+    const callbacks = { onEnded: jasmine.createSpy(), onError: jasmine.createSpy() };
+    expect(await service.play('/word.mp3', true, callbacks)).toBe('played');
+    expect(callbacks.onEnded).not.toHaveBeenCalled();
+    audios[0].dispatchEvent(new Event('ended'));
+    audios[0].dispatchEvent(new Event('ended'));
+    expect(callbacks.onEnded).toHaveBeenCalledTimes(1);
+  });
+  it('切换或停止播放后不再通知旧词完成', async () => {
+    const callbacks = { onEnded: jasmine.createSpy(), onError: jasmine.createSpy() };
+    await service.play('/first.mp3', true, callbacks);
+    await service.play('/second.mp3', true, callbacks);
+    audios[0].dispatchEvent(new Event('ended'));
+    service.stopAudio();
+    audios[1].dispatchEvent(new Event('ended'));
+    expect(callbacks.onEnded).not.toHaveBeenCalled();
+  });
+  it('开始后发生错误通知失败,之后的 ended 不触发完成', async () => {
+    const callbacks = { onEnded: jasmine.createSpy(), onError: jasmine.createSpy() };
+    await service.play('/word.mp3', true, callbacks);
+    audios[0].dispatchEvent(new Event('error'));
+    audios[0].dispatchEvent(new Event('ended'));
+    expect(callbacks.onError).toHaveBeenCalledTimes(1);
+    expect(callbacks.onEnded).not.toHaveBeenCalled();
+  });
+});

+ 19 - 1
projects/xiaoshu-mobile/src/app/core/platform.service.ts

@@ -5,6 +5,11 @@ export interface ToastState {
   tone: 'info' | 'success' | 'error';
 }
 
+export interface AudioPlaybackCallbacks {
+  onEnded: () => void;
+  onError: () => void;
+}
+
 @Injectable({ providedIn: 'root' })
 export class PlatformService {
   readonly loading = signal(false);
@@ -36,13 +41,25 @@ export class PlatformService {
     await this.copy(data.url || data.text || data.title || '');
   }
 
-  async play(url: string, silentFailure = false): Promise<'played' | 'blocked' | 'failed' | 'cancelled'> {
+  async play(url: string, silentFailure = false, callbacks?: AudioPlaybackCallbacks): Promise<'played' | 'blocked' | 'failed' | 'cancelled'> {
+    if (this.audio) this.audio.onended = this.audio.onerror = null;
     this.audio?.pause();
     const audio = this.audio = new Audio(url);
+    if (callbacks) {
+      audio.onended = () => {
+        audio.onended = audio.onerror = null;
+        if (this.audio === audio && !audio.error) callbacks.onEnded();
+      };
+    }
     try {
       await audio.play();
+      if (callbacks && this.audio === audio) audio.onerror = () => {
+        audio.onended = audio.onerror = null;
+        if (this.audio === audio) callbacks.onError();
+      };
       return this.audio === audio ? 'played' : 'cancelled';
     } catch (error) {
+      audio.onended = audio.onerror = null;
       if (this.audio !== audio || (error as DOMException)?.name === 'AbortError') return 'cancelled';
       if ((error as DOMException)?.name === 'NotAllowedError') {
         this.toast('请再点一次喇叭播放读音', 'info');
@@ -64,6 +81,7 @@ export class PlatformService {
   stopAudio(): void {
     this.effectAudio?.pause();
     this.effectAudio = null;
+    if (this.audio) this.audio.onended = this.audio.onerror = null;
     this.audio?.pause();
     this.audio = null;
   }

+ 57 - 1
projects/xiaoshu-mobile/src/app/core/word-audio.service.spec.ts

@@ -85,6 +85,62 @@ describe('学习页面自动读音', () => {
     audio.ensure.and.rejectWith(new Error('额度不足')); await component.play();
     expect(component.current()?.audio).toBe(''); expect(component.audioPreparing()).toBeFalse();
     expect(platform.toast).toHaveBeenCalledWith('额度不足', 'error');
-    audio.ensure.and.resolveTo('/retry.mp3'); await component.play(); expect(platform.play).toHaveBeenCalledWith('/retry.mp3');
+    audio.ensure.and.resolveTo('/retry.mp3'); await component.play(); expect(platform.play).toHaveBeenCalledWith('/retry.mp3', false, jasmine.any(Object));
+  });
+  it('历史单词在真实播放结束后显示中文,开始播放和生成成功均不提前显示', async () => {
+    expect(component.historyWordRevealed().size).toBe(0);
+    await component.play();
+    expect(component.historyWordRevealed().size).toBe(0);
+    expect(component.historyAudioWordId()).toBe('101');
+    platform.play.calls.mostRecent().args[2].onEnded();
+    expect(component.historyWordRevealed().has('101')).toBeTrue();
+    expect(component.historyAudioWordId()).toBe('');
+    await component.play();
+    expect(component.historyWordRevealed().has('101')).toBeTrue();
+  });
+  it('切换单词、翻页和离开页面后,旧播放完成回调不能显示中文', async () => {
+    const first = word('101', '/first.mp3'), second = word('102', '/second.mp3');
+    component.words.set([first, second]);
+    await component.play(first);
+    const firstEnded = platform.play.calls.mostRecent().args[2].onEnded;
+    await component.play(second);
+    firstEnded();
+    expect(component.historyWordRevealed().size).toBe(0);
+    expect(component.historyAudioWordId()).toBe('102');
+    platform.play.calls.mostRecent().args[2].onEnded();
+    expect([...component.historyWordRevealed()]).toEqual(['102']);
+
+    await component.play(first);
+    const beforePage = platform.play.calls.mostRecent().args[2].onEnded;
+    component.wordPreviewTotal.set(31);
+    (component as any).historyPreviewWords = Array.from({ length: 31 }, (_, i) => word(String(101 + i), '/word.mp3'));
+    component.openWordPreviewPage(2);
+    beforePage();
+    expect(component.historyWordRevealed().has('101')).toBeFalse();
+    expect(component.historyAudioWordId()).toBe('');
+    await component.play();
+    const beforeLeave = platform.play.calls.mostRecent().args[2].onEnded;
+    component.ngOnDestroy();
+    beforeLeave();
+    expect([...component.historyWordRevealed()]).toEqual(['102']);
+  });
+  it('播放受限、失败或中途中断不显示中文,重试读完后才显示', async () => {
+    const item = word('101', '/word.mp3');
+    platform.play.and.resolveTo('blocked');
+    await component.play(item);
+    expect(component.historyWordRevealed().size).toBe(0);
+    expect(component.historyAudioWordId()).toBe('');
+    platform.play.and.resolveTo('failed');
+    await component.play(item);
+    expect(component.historyWordRevealed().size).toBe(0);
+    expect(component.historyAudioWordId()).toBe('');
+    platform.play.and.resolveTo('played');
+    await component.play(item);
+    platform.play.calls.mostRecent().args[2].onError();
+    expect(component.historyWordRevealed().size).toBe(0);
+    expect(component.historyAudioWordId()).toBe('');
+    await component.play(item);
+    platform.play.calls.mostRecent().args[2].onEnded();
+    expect(component.historyWordRevealed().has('101')).toBeTrue();
   });
 });

+ 3 - 3
projects/xiaoshu-mobile/src/app/features/learning/learning-page.component.html

@@ -73,7 +73,7 @@
           <article>
             <div>
               @if (isHistoryWordView) {
-                <button type="button" class="history-word-audio" [attr.aria-label]="'播放单词读音:' + word.word" (click)="play(word)">
+                <button type="button" class="history-word-audio" [attr.aria-label]="'播放单词读音:' + word.word" [attr.aria-busy]="historyAudioWordId() === word.id" (click)="play(word)">
                   <span><strong>{{ word.word }}</strong>@if (word.phonetic) { <small>{{ word.phonetic }}</small> }</span>
                   <lucide-icon [img]="icons.Volume2" [size]="20" aria-hidden="true" />
                 </button>
@@ -82,7 +82,7 @@
                 <strong>{{ word.word }}</strong>@if (word.phonetic) { <small>{{ word.phonetic }}</small> }
               }
             </div>
-            <p>{{ word.meaning || '暂无释义' }}</p>
+            <p>@if (!isHistoryWordView || historyWordRevealed().has(word.id)) { {{ word.meaning || '暂无释义' }} } @else { <small>{{ historyAudioWordId() === word.id ? (audioPreparing() ? '正在准备读音…' : '播放中,听完显示中文') : '点击单词,听完显示中文' }}</small> }</p>
           </article>
         } @empty {
           <div class="empty-state"><p>{{ isHistoryWordView ? '该次学习记录暂无可显示的单词' : '该单元暂无单词' }}</p></div>
@@ -100,7 +100,7 @@
           <span>{{ wordPreviewPage() }} / {{ wordPreviewPageCount() }} 页</span>
         </nav>
       }
-      <p class="readonly-word-note">{{ isHistoryWordView ? '点击单词或喇叭可听读音,复习不会改动原学习结果' : '学生可查看老师分配的单词,点读、跟读和学习记录由陪练老师在训练时操作' }}</p>
+      <p class="readonly-word-note">{{ isHistoryWordView ? '中文默认隐藏,点击单词或喇叭听完读音后显示,复习不会改动原学习结果' : '学生可查看老师分配的单词,点读、跟读和学习记录由陪练老师在训练时操作' }}</p>
     </section>
   } @else if (isSelectionPage) {
     <header class="selection-header" [class.refine-selection-header]="isRefineSelectionPage">

+ 38 - 3
projects/xiaoshu-mobile/src/app/features/learning/learning-page.component.ts

@@ -122,6 +122,8 @@ export class LearningPageComponent implements OnInit, OnDestroy {
   readonly wordPreviewPage = signal(1);
   readonly wordPreviewTotal = signal(0);
   readonly wordPreviewLoading = signal(false);
+  readonly historyWordRevealed = signal<Set<string>>(new Set());
+  readonly historyAudioWordId = signal('');
   private historyPreviewWords: Word[] = [];
   readonly selected = signal<Set<string>>(new Set());
   readonly selectionRevealed = signal<Set<string>>(new Set());
@@ -557,6 +559,10 @@ export class LearningPageComponent implements OnInit, OnDestroy {
     const previous = this.wordPreviewPage();
     this.wordPreviewPage.set(target);
     if (this.isHistoryWordView) {
+      this.audioRequest++;
+      this.platform.stopAudio();
+      this.audioPreparing.set(false);
+      this.historyAudioWordId.set('');
       const start = (target - 1) * this.wordPreviewPageSize;
       this.words.set(this.historyPreviewWords.slice(start, start + this.wordPreviewPageSize));
       setTimeout(() => document.getElementById('readonly-word-title')?.scrollIntoView({ behavior: 'smooth', block: 'start' }));
@@ -785,23 +791,47 @@ export class LearningPageComponent implements OnInit, OnDestroy {
     const request = ++this.audioRequest, focusedWord = this.current(), step = this.studyStep();
     const active = () => request === this.audioRequest && this.current() === focusedWord && this.studyStep() === step;
     this.audioPreparing.set(false);
+    if (this.isHistoryWordView) this.historyAudioWordId.set(word.id);
+    const playAudio = async (url: string, silentFailure = false) => {
+      const status = await (this.isHistoryWordView
+        ? this.platform.play(url, silentFailure, {
+          onEnded: () => {
+            if (!active()) return;
+            this.historyWordRevealed.update(current => new Set(current).add(word.id));
+            this.historyAudioWordId.set('');
+          },
+          onError: () => {
+            if (!active()) return;
+            this.historyAudioWordId.set('');
+            this.platform.toast('音频播放中断,请再点一次单词重试', 'error');
+          },
+        })
+        : this.platform.play(url, silentFailure));
+      if (status !== 'played' && active()) this.historyAudioWordId.set('');
+      return status;
+    };
     const original = word.audio?.trim();
     if (original) {
-      const status = await this.platform.play(this.wordAudioUrl(original), true);
+      const status = await playAudio(this.wordAudioUrl(original), true);
       if (status !== 'failed' || !active()) return;
     } else this.platform.stopAudio();
     this.audioPreparing.set(true);
+    if (this.isHistoryWordView) this.historyAudioWordId.set(word.id);
     try {
       const url = await this.wordAudio.ensure(word.wordId, !!original);
       if (!active()) return;
       word.audio = url;
       if (original && this.wordAudioUrl(original) === this.wordAudioUrl(url)) {
+        this.historyAudioWordId.set('');
         this.platform.toast('音频暂时无法播放,请检查网络后重试', 'error');
         return;
       }
-      await this.platform.play(this.wordAudioUrl(url));
+      await playAudio(this.wordAudioUrl(url));
     } catch (error) {
-      if (active()) this.platform.toast(error instanceof Error ? error.message : '读音暂时不可用,请再点喇叭重试', 'error');
+      if (active()) {
+        this.historyAudioWordId.set('');
+        this.platform.toast(error instanceof Error ? error.message : '读音暂时不可用,请再点喇叭重试', 'error');
+      }
     } finally {
       if (request === this.audioRequest) this.audioPreparing.set(false);
     }
@@ -2180,6 +2210,9 @@ export class LearningPageComponent implements OnInit, OnDestroy {
   private clearLearningStorage(): void { this.draft().clear(); }
 
   private resetPageState(): void {
+    this.audioRequest++;
+    this.platform.stopAudio();
+    this.audioPreparing.set(false);
     this.outsideChoicePool = null;
     this.choicesLoading.set(false);
     this.choiceOptionsVersion++;
@@ -2189,6 +2222,8 @@ export class LearningPageComponent implements OnInit, OnDestroy {
     this.selectedNodes.set(new Set());
     this.words.set([]);
     this.historyPreviewWords = [];
+    this.historyWordRevealed.set(new Set());
+    this.historyAudioWordId.set('');
     this.historySources.set([]);
     this.wordPreviewPage.set(1);
     this.wordPreviewTotal.set(0);