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

@@ -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;