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
(loadedmetadata)="onLoadedMetadata()"
(timeupdate)="onTimeUpdate()"
(seeked)="onSeeked()"
(emptied)="onPause()"
(play)="onPlay()"
(pause)="onPause()"
(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 { 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<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 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<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 pendingAutoplay = false;
private pendingAbsoluteSeek: number | null = null;
private seekPlaybackIntent: boolean | null = null;
private playRequestId = 0;
// Step Replay state signals
public activeStepIndex = signal<number>(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;