Files
Artemis-Eapil/apps/showcase_ui/src/app/components/floating-video-player/floating-video-player.component.ts
T

874 lines
30 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Copyright 2026 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
Component,
ChangeDetectionStrategy,
NgZone,
inject,
signal,
computed,
ElementRef,
ViewChild,
HostListener,
effect,
untracked,
OnDestroy,
viewChild
} from '@angular/core';
import { TaskExtensionPanelsComponent } from '../task-extension-panels/task-extension-panels.component';
import { FormsModule } from '@angular/forms';
import { NgTemplateOutlet } from '@angular/common';
import { AgentService } from '../../services/agent.service';
import { StepReplayFrame } from '../../core/models/stream.model';
import { drawActionCoordinatesOnOverlay } from '../../utils/image-overlay.util';
import { getActionIcon } from '../../utils/action-formatter.util';
import { locateCoveredSessionTime, locateTimelineTime, sessionTimeToTimelineTime } from '../../utils/recording-timeline.util';
import { buildVideoStepMarkers, VideoStepMarker } from '../../utils/video-step-markers.util';
@Component({
selector: 'app-floating-video-player',
standalone: true,
imports: [FormsModule, NgTemplateOutlet, TaskExtensionPanelsComponent],
templateUrl: './floating-video-player.component.html',
styleUrl: './floating-video-player.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class FloatingVideoPlayerComponent implements OnDestroy {
public agentService = inject(AgentService);
public extensionWidth = signal(0);
public extensionPanels = viewChild(TaskExtensionPanelsComponent);
public windowLeft(): number {
const width = this.agentService.isVideoMinimized() ? 280 : 400 + this.extensionWidth();
return Math.max(16, Math.min(this.posX(), window.innerWidth - Math.min(width, window.innerWidth - 32) - 16));
}
private zone = inject(NgZone);
@ViewChild('videoRef') videoRef?: ElementRef<HTMLVideoElement>;
@ViewChild('stepImgRef') stepImgRef?: ElementRef<HTMLImageElement>;
@ViewChild('stepOverlayRef') stepOverlayRef?: ElementRef<HTMLElement>;
// Playback state signals
public isPlaying = signal<boolean>(false);
public playbackRate = signal<number>(1.0);
public isLooping = signal<boolean>(false);
public currentTime = signal<number>(0);
public duration = signal<number>(0);
public isMuted = signal<boolean>(false);
public isTheaterMode = signal<boolean>(false);
public videoLoadError = signal<boolean>(false);
public gapMessage = signal('');
public skippedInterval = signal('');
private sourceSwitch: { time: number; play: boolean } | null = null;
private lastHandledVideoSeekRequestId: number | null = null;
private gapCanAdvance = true;
public sources = computed(() => this.agentService.recordingSources?.() || []);
public liveStreamUrl = signal<string>('/api/stream/device-live');
public liveStreamError = signal<boolean>(false);
public pageVisible = signal(!document.hidden);
@HostListener('document:visibilitychange')
@HostListener('window:pageshow')
onPageVisibilityChange(): void {
this.pageVisible.set(!document.hidden);
if (!document.hidden && this.agentService.recordingPlaybackStatus() === 'live') {
this.retryLiveStream();
}
}
@HostListener('window:pagehide')
onPageHide(): void {
this.pageVisible.set(false);
}
public activeSegmentIndex = signal<number>(0);
public currentVideoUrl = computed(() => {
const segments = this.agentService.activeVideoSegments();
return segments[this.activeSegmentIndex()]?.url || this.agentService.activeVideoUrl();
});
private pendingLocalTime: number | null = null;
private pendingAutoplay = false;
private pendingAbsoluteSeek: number | null = null;
private waitingForMetadata = true;
private seekPlaybackIntent: boolean | null = null;
private playRequestId = 0;
// Step Replay state signals
public activeStepIndex = signal<number>(0);
public isStepPlaying = signal<boolean>(false);
public viewMode = signal<'pre' | 'post'>('post'); // default to the observed result
public isCardHovered = signal<boolean>(false);
public isCardPinned = signal<boolean>(false);
public isCardVisible = computed(() => this.isCardHovered() || this.isCardPinned());
public isStepImageLoading = signal<boolean>(false);
public stepImageError = signal<boolean>(false);
private stepTimer: any = null;
public stepFrames = computed(() => this.agentService.currentSessionStepFrames());
public missingRecordingLabels = computed(() => {
const sources = this.sources();
return [...new Set(this.stepFrames()
.filter(frame => frame.sourceId && frame.sourceId !== 'android' && !sources.some(source =>
(source.id === frame.sourceId || source.target_ids.includes(frame.sourceId!)) &&
(source.segments.length > 0 || source.url)))
.map(frame => frame.sourceLabel || '插件'))];
});
public recordedDuration = computed(() => this.agentService.activeVideoSegments()
.reduce((sum, segment) => sum + segment.duration, 0));
public hasRecordingGaps = computed(() => !!this.agentService.usesSessionTimeline?.() &&
this.agentService.activeVideoSegments().length > 0 && this.duration() - this.recordedDuration() > 0.1);
public recordedCoverage = computed(() => this.agentService.activeVideoSegments().map(segment => ({
start: segment.start,
left: this.duration() > 0 ? segment.start / this.duration() * 100 : 0,
width: this.duration() > 0 ? segment.duration / this.duration() * 100 : 0
})));
public videoStepMarkers = computed(() => buildVideoStepMarkers(
this.stepFrames().filter(frame => {
const source = this.sources().find(s => s.id === this.agentService.selectedRecordingSource?.());
return !source || source.target_ids.includes(frame.sourceId || 'android');
}),
this.agentService.currentSession()?.start_time,
this.agentService.activeVideoSegments(),
this.duration(),
this.agentService.usesSessionTimeline?.() || false
));
public totalStepFrames = computed(() => this.stepFrames().length);
public currentStepFrame = computed<StepReplayFrame | null>(() => {
const frames = this.stepFrames();
const idx = this.activeStepIndex();
return frames[idx] || null;
});
public currentStepImageUrl = computed<string>(() => {
const frame = this.currentStepFrame();
if (!frame) return '';
if (this.viewMode() === 'post' && frame.postImageUrl) {
return frame.postImageUrl;
}
return frame.preImageUrl || frame.imageUrl || '';
});
public stepProgressPercent = computed(() => {
const total = this.totalStepFrames();
if (total <= 1) return 0;
return (this.activeStepIndex() / (total - 1)) * 100;
});
// Available speed options
public speedOptions = [0.5, 1.0, 1.5, 2.0, 4.0];
// Dragging and window positioning state
public posX = signal<number>(
typeof window !== 'undefined' ? Math.max(20, window.innerWidth - 420) : 100
);
public posY = signal<number>(
typeof window !== 'undefined' ? Math.max(60, window.innerHeight - 700) : 100
);
private isDragging = false;
private dragStartX = 0;
private dragStartY = 0;
private initialPosX = 0;
private initialPosY = 0;
constructor() {
// Reset video load error whenever active video URL changes
effect(
() => {
const url = this.agentService.activeVideoUrl();
const segments = this.agentService.activeVideoSegments();
this.videoLoadError.set(false);
this.waitingForMetadata = true;
this.activeSegmentIndex.set(0);
const switching = this.sourceSwitch;
this.sourceSwitch = null;
this.currentTime.set(switching?.time || 0);
this.gapMessage.set('');
this.skippedInterval.set('');
this.gapCanAdvance = true;
this.duration.set(this.agentService.usesSessionTimeline?.()
? Math.max(this.agentService.recordingSessionDuration?.() || 0, ...segments.map(s => s.start + s.duration))
: segments.reduce((sum, segment) => sum + segment.duration, 0));
this.isPlaying.set(false);
this.pendingLocalTime = null;
this.pendingAutoplay = false;
if (switching) {
this.pendingAbsoluteSeek = switching.time;
this.pendingAutoplay = switching.play;
}
this.seekPlaybackIntent = null;
this.playRequestId++;
},
{ allowSignalWrites: true }
);
// Reset step playback state only when session actually changes
let lastTrackedSessionId: string | null = null;
effect(
() => {
const curSessionId = this.agentService.currentSessionId();
const total = this.totalStepFrames();
if (curSessionId !== lastTrackedSessionId) {
lastTrackedSessionId = curSessionId;
this.activeStepIndex.set(0);
this.viewMode.set('post');
this.pauseStepPlay();
} else if (total > 0 && this.activeStepIndex() >= total) {
this.activeStepIndex.set(total - 1);
}
},
{ allowSignalWrites: true }
);
// Track step screenshot URL changes and reset loading/error state
effect(() => {
const url = this.currentStepImageUrl();
if (url) {
this.isStepImageLoading.set(true);
this.stepImageError.set(false);
} else {
this.isStepImageLoading.set(false);
}
}, { allowSignalWrites: true });
// Re-draw overlay whenever activeStepIndex or viewMode changes
effect(() => {
this.activeStepIndex();
this.viewMode();
setTimeout(() => this.drawStepOverlay(), 40);
});
effect(() => {
const request = this.agentService.videoSeekRequest();
const status = this.agentService.recordingPlaybackStatus();
const sources = this.sources();
if (!request || status !== 'ready' || request.requestId === this.lastHandledVideoSeekRequestId) return;
// A seek is a command, not a binding to playback/source state. Reading
// those signals in this effect would replay the command after play/pause
// or a manual source selection.
untracked(() => {
this.lastHandledVideoSeekRequestId = request.requestId;
this.pendingAbsoluteSeek = request.seconds;
if (request.sourceId && sources.length) {
const source = sources.find(s => s.id === request.sourceId || s.target_ids.includes(request.sourceId!));
if (!source) {
this.pendingAbsoluteSeek = null;
this.pendingLocalTime = null;
this.pendingAutoplay = false;
this.seekPlaybackIntent = null;
this.playRequestId++;
this.gapCanAdvance = false;
this.currentTime.set(request.seconds);
this.gapMessage.set('此步骤的来源没有录像,请查看步骤截图或日志。');
this.videoRef?.nativeElement.pause();
this.isPlaying.set(false);
return;
}
if (source.id !== this.agentService.selectedRecordingSource?.()) {
this.sourceSwitch = { time: request.seconds, play: this.isPlaying() };
this.agentService.selectRecordingSource(source.id);
return;
}
}
const video = this.videoRef?.nativeElement;
if (video && video.readyState >= 1 && !this.waitingForMetadata) {
this.pendingAbsoluteSeek = null;
this.seekToSessionTime(request.seconds, this.isPlaying());
}
});
});
effect(() => {
const request = this.agentService.stepSeekRequest();
if (!request) return;
this.seekStepFrame(request.index);
});
}
public onLiveStreamError(): void {
this.liveStreamError.set(true);
}
public retryLiveStream(): void {
this.liveStreamError.set(false);
this.liveStreamUrl.set(`/api/stream/device-live?t=${Date.now()}`);
}
/**
* Format seconds into mm:ss format
*/
public formatTime(seconds: number): string {
if (isNaN(seconds) || seconds < 0) return '00:00';
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
const mStr = mins < 10 ? '0' + mins : String(mins);
const sStr = secs < 10 ? '0' + secs : String(secs);
return `${mStr}:${sStr}`;
}
/**
* Progress percentage for timeline scrubber (0 - 100)
*/
public progressPercent = computed(() => {
const dur = this.duration();
if (dur <= 0) return 0;
return Math.min(100, Math.max(0, (this.currentTime() / dur) * 100));
});
/**
* Start dragging the window from header. The move/up listeners are attached
* only for the duration of the drag and live outside the Angular zone, so
* ordinary mouse movement over the page never touches change detection.
*/
public startDrag(event: MouseEvent): void {
if (this.isTheaterMode()) return;
this.isDragging = true;
this.dragStartX = event.clientX;
this.dragStartY = event.clientY;
this.initialPosX = this.windowLeft();
this.initialPosY = this.posY();
event.preventDefault();
this.zone.runOutsideAngular(() => {
document.addEventListener('mousemove', this.onDrag);
document.addEventListener('mouseup', this.stopDrag);
});
}
private onDrag = (event: MouseEvent): void => {
if (!this.isDragging) return;
const deltaX = event.clientX - this.dragStartX;
const deltaY = event.clientY - this.dragStartY;
const minX = 10;
const minY = 10;
const maxX = Math.max(10, window.innerWidth - 240);
const maxY = Math.max(10, window.innerHeight - 80);
const targetX = Math.max(minX, Math.min(maxX, this.initialPosX + deltaX));
const targetY = Math.max(minY, Math.min(maxY, this.initialPosY + deltaY));
this.posX.set(targetX);
this.posY.set(targetY);
};
private stopDrag = (): void => {
this.isDragging = false;
document.removeEventListener('mousemove', this.onDrag);
document.removeEventListener('mouseup', this.stopDrag);
};
@HostListener('window:resize')
public onResize(): void {
// Keep window within viewport bounds if window resized
const maxX = Math.max(10, window.innerWidth - 240);
const maxY = Math.max(10, window.innerHeight - 80);
if (this.posX() > maxX) this.posX.set(maxX);
if (this.posY() > maxY) this.posY.set(maxY);
}
@HostListener('window:keydown', ['$event'])
public onKeyDown(event: KeyboardEvent): void {
if (!this.agentService.isVideoWindowOpen()) return;
if (event.key === 'Escape') {
if (this.isTheaterMode()) {
this.isTheaterMode.set(false);
} else {
this.agentService.closeVideoPlayer();
}
}
}
/**
* Video element event handlers
*/
public onLoadedMetadata(): void {
const v = this.videoRef?.nativeElement;
if (v) {
this.waitingForMetadata = false;
const segments = this.agentService.activeVideoSegments();
if (!segments.length) {
this.duration.set(v.duration || 0);
}
this.videoLoadError.set(false);
v.playbackRate = this.playbackRate();
v.loop = this.isLooping() && !segments.length;
v.muted = this.isMuted();
const autoplay = this.agentService.consumeVideoAutoplay();
if (autoplay) {
this.isMuted.set(true);
v.muted = true;
}
const shouldPlay = this.pendingAutoplay || autoplay;
this.pendingAutoplay = false;
if (this.pendingAbsoluteSeek !== null) {
const target = this.pendingAbsoluteSeek;
this.pendingAbsoluteSeek = null;
this.seekToSessionTime(target, shouldPlay);
// Same-source seeks resume on seeked; cross-segment seeks resume after
// the destination metadata and seeked. Never play the old frame here.
return;
}
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();
}
if (this.pendingLocalTime === null) this.onTimeUpdate();
}
}
public onTimeUpdate(): void {
if (this.gapMessage() || this.pendingLocalTime !== null) return;
const v = this.videoRef?.nativeElement;
if (v) {
const segments = this.agentService.activeVideoSegments();
const segment = segments[this.activeSegmentIndex()];
this.currentTime.set((segment?.start || 0) + v.currentTime);
if (!segments.length && v.duration && v.duration !== this.duration()) {
this.duration.set(v.duration);
}
}
}
public onPlay(): void {
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) {
const current = segments[this.activeSegmentIndex()];
const next = segments[this.activeSegmentIndex() + 1];
this.seek(next.start, true);
if (this.agentService.usesSessionTimeline?.() && next.start - current.start - current.duration > 0.05) {
this.showSkippedInterval(current.start + current.duration, next.start);
}
return;
}
if (segments.length && this.isLooping()) {
this.restart();
return;
}
const last = segments[segments.length - 1];
if (last && this.agentService.usesSessionTimeline?.()) {
this.currentTime.set(last.start + last.duration);
if (this.duration() - this.currentTime() > 0.05) {
this.skippedInterval.set('录像已结束,之后的任务时间未录制。');
}
}
this.isPlaying.set(false);
}
public onError(): void {
this.playRequestId++;
this.pendingLocalTime = null;
this.pendingAutoplay = false;
this.seekPlaybackIntent = null;
this.videoLoadError.set(true);
this.isPlaying.set(false);
}
/**
* Playback actions
*/
public togglePlay(): void {
if (this.gapMessage()) {
if (!this.gapCanAdvance) return;
const next = this.agentService.activeVideoSegments().find(s => s.start > this.currentTime());
if (next) {
const from = this.currentTime();
this.seek(next.start, true);
this.showSkippedInterval(from, next.start);
}
return;
}
const v = this.videoRef?.nativeElement;
if (!v) return;
if (v.ended && this.agentService.activeVideoSegments().length) {
this.restart();
return;
}
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 {
const first = this.agentService.activeVideoSegments()[0];
this.seek(this.agentService.usesSessionTimeline?.() ? first?.start || 0 : 0, true);
}
/**
* Seek to a session-relative time (seconds since the task session started,
* the axis step timestamps and analyzer ranges use). Manifest v2 segments
* carry real session offsets, so the time lands in the right segment even
* across scrcpy restart gaps; legacy playlists fall back to the timeline axis.
*/
public seekToSessionTime(sessionSeconds: number, autoplay = false): void {
const segments = this.agentService.activeVideoSegments();
this.seek(this.agentService.usesSessionTimeline?.() ? sessionSeconds : sessionTimeToTimelineTime(segments, sessionSeconds), autoplay);
}
public selectSource(id: string): void {
if (id === this.agentService.selectedRecordingSource?.()) return;
this.sourceSwitch = { time: this.currentTime(), play: this.isPlaying() };
if (!this.agentService.selectRecordingSource(id)) this.sourceSwitch = null;
}
public seekToVideoMarker(marker: VideoStepMarker): void {
this.seek(marker.time);
}
public videoMarkerLabel(marker: VideoStepMarker): string {
const first = marker.steps[0];
const count = marker.steps.length;
const action = first.actionText || first.title;
return count > 1
? `步骤 ${first.stepNumber} 等 ${count} 步 · ${this.formatTime(marker.time)} · ${action}`
: `步骤 ${first.stepNumber} · ${this.formatTime(marker.time)} · ${action}`;
}
/** Seek exactly: a step/source request must never display unrelated evidence. */
public seek(seconds: number, autoplay = false): void {
const v = this.videoRef?.nativeElement;
if (!v) return;
this.skippedInterval.set('');
const target = Math.max(0, Math.min(this.duration(), seconds));
const segments = this.agentService.activeVideoSegments();
// URL-only legacy recordings have no coverage manifest. Preserve their
// native video seeking instead of treating every timestamp as a gap.
const sessionTimeline = this.agentService.usesSessionTimeline?.() && segments.length > 0;
const location = sessionTimeline ? locateCoveredSessionTime(segments, target) : locateTimelineTime(segments, target);
if (sessionTimeline && !location) {
this.playRequestId++;
this.pendingLocalTime = null;
this.pendingAutoplay = false;
this.seekPlaybackIntent = null;
v.pause();
this.isPlaying.set(false);
this.currentTime.set(target);
this.gapCanAdvance = true;
this.gapMessage.set('此时间段未录制(尚未开始、暂停或录制中断),可查看步骤截图与日志。');
return;
}
this.gapMessage.set('');
const shouldPlay = autoplay || this.pendingAutoplay || (this.seekPlaybackIntent ?? !v.paused);
if (!location) {
this.seekWithinVideo(target, shouldPlay);
} else if (location.index === this.activeSegmentIndex() && this.pendingLocalTime === null) {
this.seekWithinVideo(location.localTime, shouldPlay);
} else {
this.playRequestId++;
this.seekPlaybackIntent = null;
v.pause();
this.isPlaying.set(false);
this.pendingLocalTime = location.localTime;
this.pendingAutoplay = shouldPlay;
this.waitingForMetadata = true;
this.activeSegmentIndex.set(location.index);
}
this.currentTime.set(target);
}
public onScrub(event: Event): void {
const input = event.target as HTMLInputElement;
const targetTime = parseFloat(input.value);
const segments = this.agentService.activeVideoSegments();
if (this.agentService.usesSessionTimeline?.() && segments.length && !locateCoveredSessionTime(segments, targetTime)) {
const next = segments.find(segment => segment.start > targetTime);
if (next) {
this.seek(next.start);
this.showSkippedInterval(targetTime, next.start);
return;
}
}
this.seek(targetTime);
}
private showSkippedInterval(from: number, to: number): void {
this.skippedInterval.set(`已跳过未录制区间 ${this.formatTime(from)}–${this.formatTime(to)}`);
}
public setSpeed(rate: number): void {
this.playbackRate.set(rate);
const v = this.videoRef?.nativeElement;
if (v) {
v.playbackRate = rate;
}
if (this.isStepPlaying()) {
this.scheduleNextStepFrame();
}
}
public toggleLoop(): void {
const next = !this.isLooping();
this.isLooping.set(next);
const v = this.videoRef?.nativeElement;
if (v) {
v.loop = next && !this.agentService.activeVideoSegments().length;
}
}
public toggleMute(): void {
const next = !this.isMuted();
this.isMuted.set(next);
const v = this.videoRef?.nativeElement;
if (v) {
v.muted = next;
}
}
public toggleTheater(): void {
this.isTheaterMode.set(!this.isTheaterMode());
}
public toggleMinimize(): void {
this.agentService.isVideoMinimized.set(!this.agentService.isVideoMinimized());
}
public openInNewTab(): void {
const url = this.agentService.activeVideoUrl();
if (url) {
window.open(url, '_blank');
}
}
/**
* Step Replay Controls
*/
public toggleStepPlay(): void {
if (this.isStepPlaying()) {
this.pauseStepPlay();
} else {
this.startStepPlay();
}
}
public startStepPlay(): void {
if (this.totalStepFrames() <= 0) return;
if (this.activeStepIndex() >= this.totalStepFrames() - 1) {
this.activeStepIndex.set(0);
}
this.isStepPlaying.set(true);
this.scheduleNextStepFrame();
}
public pauseStepPlay(): void {
this.isStepPlaying.set(false);
if (this.stepTimer) {
clearTimeout(this.stepTimer);
this.stepTimer = null;
}
}
private scheduleNextStepFrame(): void {
if (this.stepTimer) clearTimeout(this.stepTimer);
const delay = Math.max(300, 1500 / this.playbackRate());
this.stepTimer = setTimeout(() => {
if (!this.isStepPlaying()) return;
if (this.activeStepIndex() < this.totalStepFrames() - 1) {
this.activeStepIndex.update((i) => i + 1);
this.scheduleNextStepFrame();
} else {
if (this.isLooping()) {
this.activeStepIndex.set(0);
this.scheduleNextStepFrame();
} else {
this.pauseStepPlay();
}
}
}, delay);
}
public prevStepFrame(): void {
this.pauseStepPlay();
this.viewMode.set('pre');
this.activeStepIndex.update((i) => Math.max(0, i - 1));
}
public nextStepFrame(): void {
this.pauseStepPlay();
this.viewMode.set('pre');
this.activeStepIndex.update((i) => Math.min(this.totalStepFrames() - 1, i + 1));
}
public seekStepFrame(index: number): void {
this.viewMode.set('pre');
this.activeStepIndex.set(Math.max(0, Math.min(this.totalStepFrames() - 1, index)));
}
public onStepScrub(event: Event): void {
const input = event.target as HTMLInputElement;
this.seekStepFrame(parseInt(input.value, 10));
}
public restartStep(): void {
this.seekStepFrame(0);
this.startStepPlay();
}
public setStepViewMode(mode: 'pre' | 'post', event?: Event): void {
if (event) event.stopPropagation();
this.viewMode.set(mode);
setTimeout(() => this.drawStepOverlay(), 40);
}
public toggleStepViewMode(event?: Event): void {
if (event) event.stopPropagation();
this.setStepViewMode(this.viewMode() === 'pre' ? 'post' : 'pre');
}
public onViewportMouseEnter(): void {
this.isCardHovered.set(true);
}
public onViewportMouseLeave(): void {
this.isCardHovered.set(false);
}
public toggleCardPin(event?: Event): void {
if (event) event.stopPropagation();
this.isCardPinned.update((v) => !v);
}
public cycleSpeed(): void {
const current = this.playbackRate();
const idx = this.speedOptions.indexOf(current);
const nextIdx = (idx + 1) % this.speedOptions.length;
this.setSpeed(this.speedOptions[nextIdx]);
}
public onStepImageLoad(): void {
this.isStepImageLoading.set(false);
this.stepImageError.set(false);
this.drawStepOverlay();
}
public onStepImageError(): void {
this.isStepImageLoading.set(false);
this.stepImageError.set(true);
}
public drawStepOverlay(): void {
const img = this.stepImgRef?.nativeElement;
const overlay = this.stepOverlayRef?.nativeElement;
if (!img || !overlay) return;
overlay.innerHTML = '';
// Only draw coordinate overlays when viewing the pre-action screenshot
if (this.viewMode() === 'post') {
return;
}
const frame = this.currentStepFrame();
if (!frame || !frame.action) return;
try {
drawActionCoordinatesOnOverlay(img, overlay, frame.action);
} catch (err) {
console.warn('Failed to draw step coordinates overlay:', err);
}
}
public getStepActionIcon(action: any): string {
return getActionIcon(action);
}
public openImageInNewTab(): void {
const url = this.currentStepImageUrl() || this.currentStepFrame()?.imageUrl;
if (url) {
window.open(url, '_blank');
}
}
public trackStepFrame(index: number, frame: StepReplayFrame): string {
return frame.stepId || String(index);
}
public ngOnDestroy(): void {
this.playRequestId++;
if (this.stepTimer) {
clearTimeout(this.stepTimer);
this.stepTimer = null;
}
document.removeEventListener('mousemove', this.onDrag);
document.removeEventListener('mouseup', this.stopDrag);
}
}