fix: preserve playback state when seeking video steps

This commit is contained in:
chenmingxuan committed 2026-10-08 16:49:07 +08:00
1 parent 1fdf7c0d1d
commit 079d7b1e28
3 files changed
+297 -33

No files matched your search

@@ -338,6 +338,8 @@ limitations under the License.
playsinline playsinline
(loadedmetadata)="onLoadedMetadata()" (loadedmetadata)="onLoadedMetadata()"
(timeupdate)="onTimeUpdate()" (timeupdate)="onTimeUpdate()"
(seeked)="onSeeked()"
(emptied)="onPause()"
(play)="onPlay()" (play)="onPlay()"
(pause)="onPause()" (pause)="onPause()"
(ended)="onEnded()" (ended)="onEnded()"
@@ -1,17 +1,26 @@
import { TestBed } from '@angular/core/testing'; import { ComponentFixture, TestBed } from '@angular/core/testing';
import { signal } from '@angular/core'; import { signal } from '@angular/core';
import { AgentService } from '../../services/agent.service'; import { AgentService } from '../../services/agent.service';
import { StepReplayFrame } from '../../core/models/stream.model'; import { StepReplayFrame } from '../../core/models/stream.model';
import { FloatingVideoPlayerComponent } from './floating-video-player.component'; import { FloatingVideoPlayerComponent } from './floating-video-player.component';
describe('FloatingVideoPlayerComponent video markers', () => { describe('FloatingVideoPlayerComponent video markers', () => {
it('shows a recorded step on the video timeline and seeks when clicked', () => { let fixture: ComponentFixture<FloatingVideoPlayerComponent>;
let component: FloatingVideoPlayerComponent;
let video: HTMLVideoElement;
let paused: boolean;
let seeking: boolean;
let time: number;
let play: jasmine.Spy;
let agentService: ReturnType<typeof createAgentService>;
function createAgentService() {
const start = 1_800_000_000; const start = 1_800_000_000;
const frame: StepReplayFrame = { const frame: StepReplayFrame = {
index: 0, stepNumber: 1, title: '步骤 1: 点击设置', imageUrl: 'step.png', index: 0, stepNumber: 1, title: '步骤 1: 点击设置', imageUrl: 'step.png',
actionText: '点击设置', timestamp: start + 5 actionText: '点击设置', timestamp: start + 5
}; };
const agentService = { return {
isVideoWindowOpen: signal(true), isVideoWindowOpen: signal(true),
isVideoMinimized: signal(false), isVideoMinimized: signal(false),
recordingPlaybackStatus: signal('ready'), recordingPlaybackStatus: signal('ready'),
@@ -25,15 +34,57 @@ describe('FloatingVideoPlayerComponent video markers', () => {
currentSession: signal({ session_id: 'test', start_time: start }), currentSession: signal({ session_id: 'test', start_time: start }),
currentSessionId: signal('test'), currentSessionId: signal('test'),
videoSeekRequest: signal(null), videoSeekRequest: signal(null),
stepSeekRequest: signal(null) stepSeekRequest: signal(null),
consumeVideoAutoplay: jasmine.createSpy('consumeVideoAutoplay').and.returnValue(false)
}; };
}
beforeEach(() => {
agentService = createAgentService();
TestBed.configureTestingModule({ TestBed.configureTestingModule({
imports: [FloatingVideoPlayerComponent], imports: [FloatingVideoPlayerComponent],
providers: [{ provide: AgentService, useValue: agentService }] providers: [{ provide: AgentService, useValue: agentService }]
}); });
const fixture = TestBed.createComponent(FloatingVideoPlayerComponent); fixture = TestBed.createComponent(FloatingVideoPlayerComponent);
fixture.detectChanges(); 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; const marker = fixture.nativeElement.querySelector('.video-marker') as HTMLButtonElement;
expect(marker).not.toBeNull(); expect(marker).not.toBeNull();
expect(marker.getAttribute('aria-label')).toContain('步骤 1'); expect(marker.getAttribute('aria-label')).toContain('步骤 1');
@@ -43,4 +94,150 @@ describe('FloatingVideoPlayerComponent video markers', () => {
marker.click(); marker.click();
expect(seek).toHaveBeenCalledOnceWith(5); 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<void>((_, 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();
});
}); });
@@ -71,6 +71,8 @@ export class FloatingVideoPlayerComponent implements OnDestroy {
private pendingLocalTime: number | null = null; private pendingLocalTime: number | null = null;
private pendingAutoplay = false; private pendingAutoplay = false;
private pendingAbsoluteSeek: number | null = null; private pendingAbsoluteSeek: number | null = null;
private seekPlaybackIntent: boolean | null = null;
private playRequestId = 0;
// Step Replay state signals // Step Replay state signals
public activeStepIndex = signal<number>(0); public activeStepIndex = signal<number>(0);
@@ -138,6 +140,10 @@ export class FloatingVideoPlayerComponent implements OnDestroy {
this.currentTime.set(0); this.currentTime.set(0);
this.duration.set(segments.reduce((sum, segment) => sum + segment.duration, 0)); this.duration.set(segments.reduce((sum, segment) => sum + segment.duration, 0));
this.isPlaying.set(false); this.isPlaying.set(false);
this.pendingLocalTime = null;
this.pendingAutoplay = false;
this.seekPlaybackIntent = null;
this.playRequestId++;
}, },
{ allowSignalWrites: true } { allowSignalWrites: true }
); );
@@ -315,20 +321,19 @@ export class FloatingVideoPlayerComponent implements OnDestroy {
// that segment's metadata event apply pendingLocalTime. // that segment's metadata event apply pendingLocalTime.
if (this.pendingLocalTime !== null) return; if (this.pendingLocalTime !== null) return;
} }
if (this.pendingLocalTime !== null) { const autoplay = this.agentService.consumeVideoAutoplay();
v.currentTime = Math.max(0, Math.min(v.duration || this.pendingLocalTime, this.pendingLocalTime)); if (autoplay) {
this.pendingLocalTime = null;
}
if (this.pendingAutoplay) {
this.pendingAutoplay = false;
v.play().catch(() => {});
}
if (this.agentService.consumeVideoAutoplay()) {
this.isMuted.set(true); this.isMuted.set(true);
v.muted = 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 { public onPlay(): void {
this.isPlaying.set(true); this.isPlaying.set(!this.videoRef?.nativeElement.paused);
} }
public onPause(): void { public onPause(): void {
this.isPlaying.set(false); 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 { public onEnded(): void {
this.isPlaying.set(false);
const segments = this.agentService.activeVideoSegments(); const segments = this.agentService.activeVideoSegments();
if (segments.length && this.activeSegmentIndex() < segments.length - 1) { if (segments.length && this.activeSegmentIndex() < segments.length - 1) {
this.pendingLocalTime = 0; this.pendingLocalTime = 0;
@@ -369,6 +388,10 @@ export class FloatingVideoPlayerComponent implements OnDestroy {
} }
public onError(): void { public onError(): void {
this.playRequestId++;
this.pendingLocalTime = null;
this.pendingAutoplay = false;
this.seekPlaybackIntent = null;
this.videoLoadError.set(true); this.videoLoadError.set(true);
this.isPlaying.set(false); this.isPlaying.set(false);
} }
@@ -379,11 +402,52 @@ export class FloatingVideoPlayerComponent implements OnDestroy {
public togglePlay(): void { public togglePlay(): void {
const v = this.videoRef?.nativeElement; const v = this.videoRef?.nativeElement;
if (!v) return; if (!v) return;
if (v.paused) { if (this.pendingLocalTime !== null) {
v.play().catch(() => {}); this.pendingAutoplay = !this.isPlaying();
} else { this.isPlaying.set(this.pendingAutoplay);
v.pause(); 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 { public restart(): void {
@@ -421,19 +485,19 @@ export class FloatingVideoPlayerComponent implements OnDestroy {
const target = Math.max(0, Math.min(this.duration(), seconds)); const target = Math.max(0, Math.min(this.duration(), seconds));
const segments = this.agentService.activeVideoSegments(); const segments = this.agentService.activeVideoSegments();
const location = locateTimelineTime(segments, target); const location = locateTimelineTime(segments, target);
const shouldPlay = autoplay || this.pendingAutoplay || (this.seekPlaybackIntent ?? !v.paused);
if (!location) { if (!location) {
v.currentTime = target; this.seekWithinVideo(target, shouldPlay);
if (autoplay) v.play().catch(() => {}); } else if (location.index === this.activeSegmentIndex() && this.pendingLocalTime === null) {
return; this.seekWithinVideo(location.localTime, shouldPlay);
}
const { index, localTime } = location;
if (index === this.activeSegmentIndex()) {
v.currentTime = localTime;
if (autoplay) v.play().catch(() => {});
} else { } else {
this.pendingLocalTime = localTime; this.playRequestId++;
this.pendingAutoplay = autoplay || !v.paused; this.seekPlaybackIntent = null;
this.activeSegmentIndex.set(index); v.pause();
this.isPlaying.set(false);
this.pendingLocalTime = location.localTime;
this.pendingAutoplay = shouldPlay;
this.activeSegmentIndex.set(location.index);
} }
this.currentTime.set(target); this.currentTime.set(target);
} }
@@ -641,6 +705,7 @@ export class FloatingVideoPlayerComponent implements OnDestroy {
} }
public ngOnDestroy(): void { public ngOnDestroy(): void {
this.playRequestId++;
if (this.stepTimer) { if (this.stepTimer) {
clearTimeout(this.stepTimer); clearTimeout(this.stepTimer);
this.stepTimer = null; this.stepTimer = null;