/** * 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; @ViewChild('stepImgRef') stepImgRef?: ElementRef; @ViewChild('stepOverlayRef') stepOverlayRef?: ElementRef; // Playback state signals public isPlaying = signal(false); public playbackRate = signal(1.0); public isLooping = signal(false); public currentTime = signal(0); public duration = signal(0); public isMuted = signal(false); public isTheaterMode = signal(false); public videoLoadError = signal(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('/api/stream/device-live'); public liveStreamError = signal(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(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(0); public isStepPlaying = signal(false); public viewMode = signal<'pre' | 'post'>('post'); // default to the observed result public isCardHovered = signal(false); public isCardPinned = signal(false); public isCardVisible = computed(() => this.isCardHovered() || this.isCardPinned()); public isStepImageLoading = signal(false); public stepImageError = signal(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(() => { const frames = this.stepFrames(); const idx = this.activeStepIndex(); return frames[idx] || null; }); public currentStepImageUrl = computed(() => { 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( typeof window !== 'undefined' ? Math.max(20, window.innerWidth - 420) : 100 ); public posY = signal( 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); } }