874 lines
30 KiB
TypeScript
874 lines
30 KiB
TypeScript
/**
|
||
* 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);
|
||
}
|
||
}
|