fix: preserve playback state when seeking video steps
This commit is contained in:
1 parent
1fdf7c0d1d
commit
079d7b1e28
3 files changed
+297
-33
No files matched your search
+2
@@ -338,6 +338,8 @@ limitations under the License.
|
||||
playsinline
|
||||
(loadedmetadata)="onLoadedMetadata()"
|
||||
(timeupdate)="onTimeUpdate()"
|
||||
(seeked)="onSeeked()"
|
||||
(emptied)="onPause()"
|
||||
(play)="onPlay()"
|
||||
(pause)="onPause()"
|
||||
(ended)="onEnded()"
|
||||
|
||||
+202
-5
@@ -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();
|
||||
});
|
||||
});
|
||||
+93
-28
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user