diff --git a/apps/showcase_ui/src/app/components/floating-video-player/floating-video-player.component.html b/apps/showcase_ui/src/app/components/floating-video-player/floating-video-player.component.html index 8cd6ca3..8e08b2a 100644 --- a/apps/showcase_ui/src/app/components/floating-video-player/floating-video-player.component.html +++ b/apps/showcase_ui/src/app/components/floating-video-player/floating-video-player.component.html @@ -338,6 +338,8 @@ limitations under the License. playsinline (loadedmetadata)="onLoadedMetadata()" (timeupdate)="onTimeUpdate()" + (seeked)="onSeeked()" + (emptied)="onPause()" (play)="onPlay()" (pause)="onPause()" (ended)="onEnded()" diff --git a/apps/showcase_ui/src/app/components/floating-video-player/floating-video-player.component.spec.ts b/apps/showcase_ui/src/app/components/floating-video-player/floating-video-player.component.spec.ts index f970b48..008d9b9 100644 --- a/apps/showcase_ui/src/app/components/floating-video-player/floating-video-player.component.spec.ts +++ b/apps/showcase_ui/src/app/components/floating-video-player/floating-video-player.component.spec.ts @@ -1,17 +1,26 @@ -import { TestBed } from '@angular/core/testing'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; import { signal } from '@angular/core'; import { AgentService } from '../../services/agent.service'; import { StepReplayFrame } from '../../core/models/stream.model'; import { FloatingVideoPlayerComponent } from './floating-video-player.component'; describe('FloatingVideoPlayerComponent video markers', () => { - it('shows a recorded step on the video timeline and seeks when clicked', () => { + let fixture: ComponentFixture; + let component: FloatingVideoPlayerComponent; + let video: HTMLVideoElement; + let paused: boolean; + let seeking: boolean; + let time: number; + let play: jasmine.Spy; + let agentService: ReturnType; + + function createAgentService() { const start = 1_800_000_000; const frame: StepReplayFrame = { index: 0, stepNumber: 1, title: '步骤 1: 点击设置', imageUrl: 'step.png', actionText: '点击设置', timestamp: start + 5 }; - const agentService = { + return { isVideoWindowOpen: signal(true), isVideoMinimized: signal(false), recordingPlaybackStatus: signal('ready'), @@ -25,15 +34,57 @@ describe('FloatingVideoPlayerComponent video markers', () => { currentSession: signal({ session_id: 'test', start_time: start }), currentSessionId: signal('test'), videoSeekRequest: signal(null), - stepSeekRequest: signal(null) + stepSeekRequest: signal(null), + consumeVideoAutoplay: jasmine.createSpy('consumeVideoAutoplay').and.returnValue(false) }; + } + + beforeEach(() => { + agentService = createAgentService(); TestBed.configureTestingModule({ imports: [FloatingVideoPlayerComponent], providers: [{ provide: AgentService, useValue: agentService }] }); - const fixture = TestBed.createComponent(FloatingVideoPlayerComponent); + fixture = TestBed.createComponent(FloatingVideoPlayerComponent); fixture.detectChanges(); + component = fixture.componentInstance; + video = component.videoRef!.nativeElement; + paused = true; + seeking = false; + time = 0; + Object.defineProperties(video, { + paused: { configurable: true, get: () => paused }, + seeking: { configurable: true, get: () => seeking }, + ended: { configurable: true, get: () => false }, + duration: { configurable: true, get: () => 20 }, + currentTime: { + configurable: true, get: () => time, + set: (value: number) => { time = value; seeking = true; } + } + }); + spyOn(video, 'pause').and.callFake(() => { + paused = true; + video.dispatchEvent(new Event('pause')); + }); + play = spyOn(video, 'play').and.callFake(() => { + paused = false; + video.dispatchEvent(new Event('play')); + return Promise.resolve(); + }); + }); + + function finishSeek(): void { + seeking = false; + video.dispatchEvent(new Event('seeked')); + fixture.detectChanges(); + } + + function playButton(): HTMLButtonElement { + return fixture.nativeElement.querySelector('.play-btn'); + } + + it('shows a recorded step on the video timeline and seeks when clicked', () => { const marker = fixture.nativeElement.querySelector('.video-marker') as HTMLButtonElement; expect(marker).not.toBeNull(); expect(marker.getAttribute('aria-label')).toContain('步骤 1'); @@ -43,4 +94,150 @@ describe('FloatingVideoPlayerComponent video markers', () => { marker.click(); expect(seek).toHaveBeenCalledOnceWith(5); }); + + it('resumes a playing video after a marker seek finishes and synchronizes the button', async () => { + component.togglePlay(); + await Promise.resolve(); + play.calls.reset(); + fixture.nativeElement.querySelector('.video-marker').click(); + expect(time).toBe(5); + expect(play).not.toHaveBeenCalled(); + finishSeek(); + await Promise.resolve(); + fixture.detectChanges(); + expect(paused).toBeFalse(); + expect(play).toHaveBeenCalledTimes(1); + expect(playButton().title).toBe('暂停'); + }); + + it('keeps a paused video paused after a marker seek and resumes with one click', async () => { + fixture.nativeElement.querySelector('.video-marker').click(); + finishSeek(); + expect(paused).toBeTrue(); + expect(play).not.toHaveBeenCalled(); + expect(playButton().title).toBe('播放'); + playButton().click(); + await Promise.resolve(); + fixture.detectChanges(); + expect(paused).toBeFalse(); + expect(playButton().title).toBe('暂停'); + }); + + it('preserves playing intent across rapid consecutive marker seeks', () => { + component.togglePlay(); + component.seek(5); + component.seek(8); + play.calls.reset(); + finishSeek(); + expect(time).toBe(8); + expect(play).toHaveBeenCalledTimes(1); + expect(paused).toBeFalse(); + }); + + it('honors play and pause clicks while the destination is still seeking', () => { + component.togglePlay(); + component.seek(5); + play.calls.reset(); + fixture.detectChanges(); + expect(playButton().title).toBe('播放'); + playButton().click(); + fixture.detectChanges(); + expect(playButton().title).toBe('暂停'); + expect(play).not.toHaveBeenCalled(); + playButton().click(); + finishSeek(); + expect(paused).toBeTrue(); + expect(play).not.toHaveBeenCalled(); + expect(playButton().title).toBe('播放'); + }); + + it('waits for the destination segment to seek before resuming playback', () => { + agentService.activeVideoSegments.set([ + { url: '/test.mp4', start: 0, duration: 20, width: 400, height: 800 }, + { url: '/second.mp4', start: 20, duration: 20, width: 400, height: 800 } + ]); + fixture.detectChanges(); + component.togglePlay(); + component.seek(25); + play.calls.reset(); + fixture.detectChanges(); + video.dispatchEvent(new Event('loadedmetadata')); + expect(component.activeSegmentIndex()).toBe(1); + expect(time).toBe(5); + expect(play).not.toHaveBeenCalled(); + finishSeek(); + expect(play).toHaveBeenCalledTimes(1); + expect(paused).toBeFalse(); + expect(component.currentTime()).toBe(25); + }); + + it('keeps a paused video paused when jumping to another segment', () => { + agentService.activeVideoSegments.set([ + { url: '/test.mp4', start: 0, duration: 20, width: 400, height: 800 }, + { url: '/second.mp4', start: 20, duration: 20, width: 400, height: 800 } + ]); + fixture.detectChanges(); + component.seek(25); + fixture.detectChanges(); + video.dispatchEvent(new Event('loadedmetadata')); + finishSeek(); + expect(time).toBe(5); + expect(paused).toBeTrue(); + expect(play).not.toHaveBeenCalled(); + expect(playButton().title).toBe('播放'); + }); + + it('resumes after seeking a recording without a segment manifest', () => { + agentService.activeVideoSegments.set([]); + fixture.detectChanges(); + video.dispatchEvent(new Event('loadedmetadata')); + component.togglePlay(); + component.seek(5); + play.calls.reset(); + finishSeek(); + expect(time).toBe(5); + expect(component.currentTime()).toBe(5); + expect(paused).toBeFalse(); + expect(play).toHaveBeenCalledTimes(1); + }); + + it('resets a rejected play request so a single subsequent click can resume', async () => { + play.and.callFake(() => { + paused = false; + video.dispatchEvent(new Event('play')); + return Promise.reject(new DOMException('Interrupted', 'AbortError')); + }); + component.seek(5, true); + finishSeek(); + await Promise.resolve(); + await Promise.resolve(); + fixture.detectChanges(); + expect(paused).toBeTrue(); + expect(playButton().title).toBe('播放'); + play.and.callFake(() => { + paused = false; + video.dispatchEvent(new Event('play')); + return Promise.resolve(); + }); + playButton().click(); + await Promise.resolve(); + fixture.detectChanges(); + expect(paused).toBeFalse(); + expect(playButton().title).toBe('暂停'); + }); + + it('ignores an older aborted play request after a newer seek resumes', async () => { + let rejectOldPlay!: (reason: unknown) => void; + play.and.returnValue(new Promise((_, reject) => { rejectOldPlay = reject; })); + component.togglePlay(); + paused = false; + component.seek(5); + play.and.callFake(() => { paused = false; return Promise.resolve(); }); + finishSeek(); + rejectOldPlay(new DOMException('Interrupted', 'AbortError')); + await Promise.resolve(); + await Promise.resolve(); + expect(paused).toBeFalse(); + expect(component.isPlaying()).toBeTrue(); + }); }); diff --git a/apps/showcase_ui/src/app/components/floating-video-player/floating-video-player.component.ts b/apps/showcase_ui/src/app/components/floating-video-player/floating-video-player.component.ts index 557df63..ec5d08d 100644 --- a/apps/showcase_ui/src/app/components/floating-video-player/floating-video-player.component.ts +++ b/apps/showcase_ui/src/app/components/floating-video-player/floating-video-player.component.ts @@ -71,6 +71,8 @@ export class FloatingVideoPlayerComponent implements OnDestroy { private pendingLocalTime: number | null = null; private pendingAutoplay = false; private pendingAbsoluteSeek: number | null = null; + private seekPlaybackIntent: boolean | null = null; + private playRequestId = 0; // Step Replay state signals public activeStepIndex = signal(0); @@ -138,6 +140,10 @@ export class FloatingVideoPlayerComponent implements OnDestroy { this.currentTime.set(0); this.duration.set(segments.reduce((sum, segment) => sum + segment.duration, 0)); this.isPlaying.set(false); + this.pendingLocalTime = null; + this.pendingAutoplay = false; + this.seekPlaybackIntent = null; + this.playRequestId++; }, { allowSignalWrites: true } ); @@ -315,20 +321,19 @@ export class FloatingVideoPlayerComponent implements OnDestroy { // that segment's metadata event apply pendingLocalTime. if (this.pendingLocalTime !== null) return; } - if (this.pendingLocalTime !== null) { - v.currentTime = Math.max(0, Math.min(v.duration || this.pendingLocalTime, this.pendingLocalTime)); - this.pendingLocalTime = null; - } - if (this.pendingAutoplay) { - this.pendingAutoplay = false; - v.play().catch(() => {}); - } - if (this.agentService.consumeVideoAutoplay()) { + const autoplay = this.agentService.consumeVideoAutoplay(); + if (autoplay) { this.isMuted.set(true); v.muted = true; - v.play().catch(() => { - this.isPlaying.set(false); - }); + } + const shouldPlay = this.pendingAutoplay || autoplay; + this.pendingAutoplay = false; + if (this.pendingLocalTime !== null) { + const localTime = Math.max(0, Math.min(v.duration || this.pendingLocalTime, this.pendingLocalTime)); + this.pendingLocalTime = null; + this.seekWithinVideo(localTime, shouldPlay); + } else if (shouldPlay) { + this.playVideo(); } } } @@ -346,14 +351,28 @@ export class FloatingVideoPlayerComponent implements OnDestroy { } public onPlay(): void { - this.isPlaying.set(true); + this.isPlaying.set(!this.videoRef?.nativeElement.paused); } public onPause(): void { this.isPlaying.set(false); } + public onSeeked(): void { + const v = this.videoRef?.nativeElement; + if (!v || v.seeking || this.pendingLocalTime !== null) return; + const shouldPlay = this.seekPlaybackIntent; + this.seekPlaybackIntent = null; + this.onTimeUpdate(); + if (shouldPlay) { + this.playVideo(); + } else { + this.isPlaying.set(!v.paused && !v.ended); + } + } + public onEnded(): void { + this.isPlaying.set(false); const segments = this.agentService.activeVideoSegments(); if (segments.length && this.activeSegmentIndex() < segments.length - 1) { this.pendingLocalTime = 0; @@ -369,6 +388,10 @@ export class FloatingVideoPlayerComponent implements OnDestroy { } public onError(): void { + this.playRequestId++; + this.pendingLocalTime = null; + this.pendingAutoplay = false; + this.seekPlaybackIntent = null; this.videoLoadError.set(true); this.isPlaying.set(false); } @@ -379,11 +402,52 @@ export class FloatingVideoPlayerComponent implements OnDestroy { public togglePlay(): void { const v = this.videoRef?.nativeElement; if (!v) return; - if (v.paused) { - v.play().catch(() => {}); - } else { - v.pause(); + if (this.pendingLocalTime !== null) { + this.pendingAutoplay = !this.isPlaying(); + this.isPlaying.set(this.pendingAutoplay); + return; } + if (this.seekPlaybackIntent !== null) { + this.seekPlaybackIntent = !this.isPlaying(); + this.isPlaying.set(this.seekPlaybackIntent); + return; + } + if (v.paused) { + this.playVideo(); + } else { + this.playRequestId++; + v.pause(); + this.isPlaying.set(false); + } + } + + private playVideo(): void { + const v = this.videoRef?.nativeElement; + if (!v) return; + const requestId = ++this.playRequestId; + v.play().then(() => { + if (requestId === this.playRequestId) this.isPlaying.set(!v.paused && !v.ended); + }).catch(() => { + // A rejected play request can leave paused=false without advancing frames. + // Reset the native state too, so the next click really starts playback. + if (requestId === this.playRequestId) { + v.pause(); + this.isPlaying.set(false); + } + }); + } + + private seekWithinVideo(seconds: number, shouldPlay: boolean): void { + const v = this.videoRef?.nativeElement; + if (!v) return; + this.playRequestId++; + this.seekPlaybackIntent = shouldPlay; + // Cancel any unfinished play request before seeking; resume only once the + // browser has decoded the destination, including after a source switch. + v.pause(); + this.isPlaying.set(false); + v.currentTime = seconds; + if (!v.seeking) this.onSeeked(); } public restart(): void { @@ -421,19 +485,19 @@ export class FloatingVideoPlayerComponent implements OnDestroy { const target = Math.max(0, Math.min(this.duration(), seconds)); const segments = this.agentService.activeVideoSegments(); const location = locateTimelineTime(segments, target); + const shouldPlay = autoplay || this.pendingAutoplay || (this.seekPlaybackIntent ?? !v.paused); if (!location) { - v.currentTime = target; - if (autoplay) v.play().catch(() => {}); - return; - } - const { index, localTime } = location; - if (index === this.activeSegmentIndex()) { - v.currentTime = localTime; - if (autoplay) v.play().catch(() => {}); + this.seekWithinVideo(target, shouldPlay); + } else if (location.index === this.activeSegmentIndex() && this.pendingLocalTime === null) { + this.seekWithinVideo(location.localTime, shouldPlay); } else { - this.pendingLocalTime = localTime; - this.pendingAutoplay = autoplay || !v.paused; - this.activeSegmentIndex.set(index); + this.playRequestId++; + this.seekPlaybackIntent = null; + v.pause(); + this.isPlaying.set(false); + this.pendingLocalTime = location.localTime; + this.pendingAutoplay = shouldPlay; + this.activeSegmentIndex.set(location.index); } this.currentTime.set(target); } @@ -641,6 +705,7 @@ export class FloatingVideoPlayerComponent implements OnDestroy { } public ngOnDestroy(): void { + this.playRequestId++; if (this.stepTimer) { clearTimeout(this.stepTimer); this.stepTimer = null;