503 lines
27 KiB
HTML
503 lines
27 KiB
HTML
<!--
|
||
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.
|
||
-->
|
||
|
||
<!-- Backdrop overlay only when in theater / modal mode -->
|
||
@if (agentService.isVideoWindowOpen() && isTheaterMode()) {
|
||
<div
|
||
class="theater-backdrop"
|
||
(click)="isTheaterMode.set(false)"
|
||
></div>
|
||
}
|
||
|
||
@if (agentService.isVideoWindowOpen()) {
|
||
<div
|
||
class="floating-video-wrapper"
|
||
[class.minimized]="agentService.isVideoMinimized()"
|
||
[class.theater]="isTheaterMode()"
|
||
[style.--extension-width]="extensionWidth() + 'px'"
|
||
[style.left.px]="!isTheaterMode() ? windowLeft() : null"
|
||
[style.width.px]="isTheaterMode() || agentService.isVideoMinimized() ? null : 400 + extensionWidth()"
|
||
[style.top.px]="!isTheaterMode() ? posY() : null"
|
||
[style.--player-top]="posY() + 'px'"
|
||
>
|
||
<!-- Window Header (Draggable Handle) -->
|
||
<header class="window-header" (mousedown)="startDrag($event)">
|
||
<div class="header-left">
|
||
<span class="material-symbols-outlined header-icon">
|
||
{{ agentService.recordingPlaybackStatus() === 'live' ? 'sensors' : (agentService.playerMode() === 'steps' ? 'slideshow' : (agentService.recordingPlaybackStatus() === 'processing' ? 'progress_activity' : 'smart_display')) }}
|
||
</span>
|
||
<span class="header-title" [title]="agentService.activeVideoTitle()">
|
||
{{ agentService.recordingPlaybackStatus() === 'live' ? '设备实时画面' : (agentService.playerMode() === 'steps' ? ('步骤回放(' + (totalStepFrames() > 0 ? (activeStepIndex() + 1) + '/' + totalStepFrames() : '0') + ')') : (agentService.recordingPlaybackStatus() === 'processing' ? '正在准备录像' : (agentService.activeVideoTitle() || '屏幕录像'))) }}
|
||
</span>
|
||
</div>
|
||
<div class="header-actions" (mousedown)="$event.stopPropagation()">
|
||
@if (extensionPanels(); as panels) {
|
||
<ng-container [ngTemplateOutlet]="panels.headerControls() || null" />
|
||
}
|
||
<!-- Mode Toggle Group: Video vs. Step Replay -->
|
||
@if (agentService.recordingPlaybackStatus() === 'ready' && agentService.hasCurrentSessionStepFrames() && !agentService.isVideoMinimized()) {
|
||
<div class="mode-toggle-group">
|
||
<button
|
||
class="mode-btn"
|
||
[class.active]="agentService.playerMode() === 'video'"
|
||
(click)="agentService.setPlayerMode('video')"
|
||
title="视频录制"
|
||
>
|
||
<span class="material-symbols-outlined mode-icon">videocam</span>
|
||
<span>视频</span>
|
||
</button>
|
||
<button
|
||
class="mode-btn"
|
||
[class.active]="agentService.playerMode() === 'steps'"
|
||
(click)="agentService.setPlayerMode('steps')"
|
||
title="步骤截图回放"
|
||
>
|
||
<span class="material-symbols-outlined mode-icon">slideshow</span>
|
||
<span>步骤</span>
|
||
</button>
|
||
</div>
|
||
}
|
||
<!-- Minimize / Restore Toggle -->
|
||
<button
|
||
class="header-btn"
|
||
(click)="toggleMinimize()"
|
||
[title]="agentService.isVideoMinimized() ? 'Restore' : '最小化'"
|
||
aria-label="最小化"
|
||
>
|
||
<span class="material-symbols-outlined">{{ agentService.isVideoMinimized() ? 'expand_less' : 'remove' }}</span>
|
||
</button>
|
||
<!-- Theater / Fullscreen Toggle -->
|
||
@if (!agentService.isVideoMinimized()) {
|
||
<button
|
||
class="header-btn"
|
||
(click)="toggleTheater()"
|
||
[title]="isTheaterMode() ? 'Exit Theater' : 'Theater Mode'"
|
||
aria-label="影院模式"
|
||
>
|
||
<span class="material-symbols-outlined">{{ isTheaterMode() ? 'close_fullscreen' : 'open_in_full' }}</span>
|
||
</button>
|
||
}
|
||
<!-- Close Window -->
|
||
<button
|
||
class="header-btn close-btn"
|
||
(click)="agentService.closeVideoPlayer()"
|
||
title="关闭"
|
||
aria-label="关闭"
|
||
>
|
||
<span class="material-symbols-outlined">close</span>
|
||
</button>
|
||
</div>
|
||
</header>
|
||
@if (sources().length && !agentService.isVideoMinimized()) {
|
||
<div class="recording-sources">
|
||
<label for="recording-source">录像来源</label>
|
||
<select id="recording-source" [ngModel]="agentService.selectedRecordingSource()" (ngModelChange)="selectSource($event)">
|
||
@for (source of sources(); track source.id) {
|
||
<option [value]="source.id">{{ source.label }}{{ source.status === 'failed' ? '(录制异常)' : '' }}</option>
|
||
}
|
||
</select>
|
||
</div>
|
||
@if (agentService.recordingPlaybackMessage()) {
|
||
<div class="recording-notice" role="status">{{ agentService.recordingPlaybackMessage() }}</div>
|
||
}
|
||
@if (!agentService.activeVideoUrl()) {
|
||
<div class="recording-notice">此来源暂无可播放录像,请查看步骤截图或日志。</div>
|
||
}
|
||
}
|
||
@if (!agentService.isVideoMinimized() && agentService.playerMode() === 'video' && missingRecordingLabels().length) {
|
||
<div class="recording-notice" role="status">{{ missingRecordingLabels().join('、') }}步骤没有对应录像,可切换「步骤」查看已有截图。</div>
|
||
}
|
||
<!-- Video Main Content Area -->
|
||
@if (!agentService.isVideoMinimized()) {
|
||
<app-task-extension-panels [sessionId]="agentService.currentSessionId()" [enabled]="true"
|
||
[sessionIds]="reviewSessionIds()" (navigate)="agentService.selectSession($event, true)"
|
||
[taskStatus]="agentService.currentSession()?.status || ''" (expandedWidth)="extensionWidth.set($event)">
|
||
<div class="video-body">
|
||
<!-- 1. LIVE SCREEN STREAM (AUTOMATICALLY ACTIVE DURING RUNNING TEST) -->
|
||
@if (agentService.recordingPlaybackStatus() === 'live') {
|
||
@if (!liveStreamError() && pageVisible()) {
|
||
<div class="live-stream-container">
|
||
<img
|
||
class="live-screen-img"
|
||
[src]="liveStreamUrl()"
|
||
(error)="onLiveStreamError()"
|
||
alt="设备实时画面"
|
||
/>
|
||
<div class="live-status-overlay">
|
||
<div class="live-badge">
|
||
<span class="pulse-dot"></span>
|
||
<span>实时</span>
|
||
</div>
|
||
<button class="live-refresh-btn" (click)="retryLiveStream()" title="刷新实时画面">
|
||
<span class="material-symbols-outlined">refresh</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
}
|
||
@if (liveStreamError()) {
|
||
<div class="empty-video-fallback">
|
||
<div class="fallback-icon-wrap">
|
||
<span class="material-symbols-outlined fallback-icon">phonelink_off</span>
|
||
</div>
|
||
<h4 class="fallback-title">正在连接设备画面</h4>
|
||
<p class="fallback-desc">正在等待 ADB 设备画面…</p>
|
||
<button class="retry-btn" (click)="retryLiveStream()">
|
||
<span class="material-symbols-outlined">refresh</span> 重新连接
|
||
</button>
|
||
</div>
|
||
}
|
||
}
|
||
<!-- 2. RECORDING PLAYBACK / STEP REPLAY (AUTOMATICALLY ACTIVE WHEN TEST FINISHED) -->
|
||
@if (agentService.recordingPlaybackStatus() !== 'live') {
|
||
<!-- Loading State (when in video mode and processing) -->
|
||
@if (agentService.playerMode() === 'video' && (agentService.recordingPlaybackStatus() === 'processing' || agentService.isVideoLoading())) {
|
||
<div class="video-loading-state">
|
||
<span class="material-symbols-outlined spinning-icon">progress_activity</span>
|
||
<span>{{ agentService.recordingPlaybackMessage() || '正在准备屏幕录像…' }}</span>
|
||
</div>
|
||
}
|
||
<!-- Option A: STEP REPLAY VIEW (ACTIVE IN STEP MODE) -->
|
||
@if (agentService.playerMode() === 'steps' && totalStepFrames() > 0) {
|
||
<div class="step-replay-container">
|
||
<!-- Fallback info banner if recording had failed -->
|
||
@if (agentService.recordingPlaybackStatus() === 'failed' || videoLoadError()) {
|
||
<div class="step-replay-notice">
|
||
<span class="material-symbols-outlined notice-icon">info</span>
|
||
<span>录像不可用,正在逐步回放截图。</span>
|
||
</div>
|
||
}
|
||
<!-- Step Image Viewport with Action Coordinates Overlay -->
|
||
<div
|
||
class="step-image-viewport"
|
||
(mouseenter)="onViewportMouseEnter()"
|
||
(mouseleave)="onViewportMouseLeave()"
|
||
>
|
||
<!-- Loading Skeleton (Clean Light Spinner) -->
|
||
@if (isStepImageLoading()) {
|
||
<div class="step-image-loading-state">
|
||
<span class="material-symbols-outlined spinning-icon">progress_activity</span>
|
||
<span>正在加载截图…</span>
|
||
</div>
|
||
}
|
||
<!-- Error / Missing Image Fallback -->
|
||
@if (stepImageError() && !isStepImageLoading()) {
|
||
<div class="step-image-error-state">
|
||
<span class="material-symbols-outlined broken-icon">broken_image</span>
|
||
<span>此步骤暂无可用截图</span>
|
||
</div>
|
||
}
|
||
<!-- Device Screen Frame & Action Coordinates Overlay -->
|
||
@if (!stepImageError()) {
|
||
<div class="step-image-frame-wrap">
|
||
<img
|
||
#stepImgRef
|
||
class="step-frame-img"
|
||
[src]="currentStepImageUrl()"
|
||
[alt]="$safeNavigationMigration(currentStepFrame()?.title)"
|
||
(load)="onStepImageLoad()"
|
||
(error)="onStepImageError()"
|
||
(click)="openImageInNewTab()"
|
||
title="点击在新标签页查看图片"
|
||
/>
|
||
<!-- Action Visual Coordinates Overlay (Touch point, swipe arrow, sequence path) -->
|
||
<div
|
||
#stepOverlayRef
|
||
class="overlay-layer"
|
||
[style.display]="viewMode() === 'post' ? 'none' : 'block'"
|
||
></div>
|
||
</div>
|
||
}
|
||
<!-- Hideable Floating Detail Card (Appears on hover or when pinned) -->
|
||
@if (currentStepFrame(); as frame) {
|
||
<div
|
||
class="step-frame-badge"
|
||
[class.visible]="isCardVisible()"
|
||
(click)="$event.stopPropagation()"
|
||
>
|
||
<div class="badge-content-left">
|
||
<span class="badge-step-no">步骤 {{ frame.stepNumber }}</span>
|
||
<span class="badge-action-desc" [title]="frame.actionText || frame.title">
|
||
@if (frame.action) {
|
||
<span class="material-symbols-outlined action-type-icon">
|
||
{{ getStepActionIcon(frame.action) }}
|
||
</span>
|
||
}
|
||
{{ frame.actionText || frame.title }}
|
||
</span>
|
||
</div>
|
||
<div class="badge-actions-right">
|
||
<!-- Pre/Post Screenshot Switcher (Only if post-screenshot is available) -->
|
||
@if (frame.postImageUrl) {
|
||
<div class="pre-post-toggle">
|
||
<button
|
||
class="toggle-btn"
|
||
[class.active]="viewMode() === 'pre'"
|
||
(click)="setStepViewMode('pre', $event)"
|
||
title="查看操作前画面(含点击坐标)"
|
||
>
|
||
|
||
操作前
|
||
</button>
|
||
<button
|
||
class="toggle-btn"
|
||
[class.active]="viewMode() === 'post'"
|
||
(click)="setStepViewMode('post', $event)"
|
||
title="查看操作后的屏幕"
|
||
>
|
||
|
||
操作后
|
||
</button>
|
||
</div>
|
||
}
|
||
<!-- Pin Card Button -->
|
||
<button
|
||
class="pin-card-btn"
|
||
[class.pinned]="isCardPinned()"
|
||
(click)="toggleCardPin($event)"
|
||
[title]="isCardPinned() ? 'Unpin details card (auto-hide on mouse leave)' : 'Pin details card (keep always visible)'"
|
||
>
|
||
<span class="material-symbols-outlined">{{ isCardPinned() ? 'push_pin' : 'keep' }}</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
}
|
||
</div>
|
||
<!-- Step Replay Controls Footer -->
|
||
<div class="video-controls-footer">
|
||
<!-- Timeline Scrubber Bar with Step Ticks -->
|
||
<div class="scrubber-bar-container">
|
||
<input
|
||
type="range"
|
||
class="scrubber-range"
|
||
[min]="0"
|
||
[max]="totalStepFrames() - 1"
|
||
[step]="1"
|
||
[value]="activeStepIndex()"
|
||
(input)="onStepScrub($event)"
|
||
/>
|
||
<div class="scrubber-progress-fill" [style.width.%]="stepProgressPercent()"></div>
|
||
<!-- Step Tick Markers -->
|
||
@if (totalStepFrames() > 1) {
|
||
<div class="step-ticks-wrapper">
|
||
@for (f of stepFrames(); track trackStepFrame(i, f); let i = $index) {
|
||
<span
|
||
class="step-tick"
|
||
[class.active]="i === activeStepIndex()"
|
||
[class.passed]="i < activeStepIndex()"
|
||
[style.left.%]="(i / (totalStepFrames() - 1)) * 100"
|
||
(click)="seekStepFrame(i); $event.stopPropagation()"
|
||
[title]="'步骤 ' + (i + 1) + ': ' + (f.actionText || f.title)"
|
||
></span>
|
||
}
|
||
</div>
|
||
}
|
||
</div>
|
||
<!-- Bottom Controls Row -->
|
||
<div class="controls-row">
|
||
<div class="controls-left">
|
||
<!-- Play / Pause Button -->
|
||
<button class="ctrl-btn play-btn" (click)="toggleStepPlay()" [title]="isStepPlaying() ? '暂停' : '播放截图'">
|
||
<span class="material-symbols-outlined">{{ isStepPlaying() ? 'pause' : 'play_arrow' }}</span>
|
||
</button>
|
||
<!-- Previous Frame Button -->
|
||
<button class="ctrl-btn" (click)="prevStepFrame()" [disabled]="activeStepIndex() <= 0" title="上一步">
|
||
<span class="material-symbols-outlined">skip_previous</span>
|
||
</button>
|
||
<!-- Next Frame Button -->
|
||
<button class="ctrl-btn" (click)="nextStepFrame()" [disabled]="activeStepIndex() >= totalStepFrames() - 1" title="下一步">
|
||
<span class="material-symbols-outlined">skip_next</span>
|
||
</button>
|
||
<!-- Step Replay / Reset -->
|
||
<button class="ctrl-btn" (click)="restartStep()" title="从头回放">
|
||
<span class="material-symbols-outlined">replay</span>
|
||
</button>
|
||
<!-- Step Counter Display -->
|
||
<span class="time-display">
|
||
步骤 {{ activeStepIndex() + 1 }} / {{ totalStepFrames() }}
|
||
</span>
|
||
</div>
|
||
<div class="controls-right">
|
||
<!-- Speed Button (Cycle 0.5x, 1x, 1.5x, 2x, 4x) -->
|
||
<button
|
||
class="ctrl-btn speed-pill-btn"
|
||
(click)="cycleSpeed()"
|
||
[title]="'Speed: ' + playbackRate() + 'x (Click to cycle)'"
|
||
>
|
||
<span class="speed-label">{{ playbackRate() }}x</span>
|
||
</button>
|
||
<!-- Loop Toggle -->
|
||
<button
|
||
class="ctrl-btn"
|
||
[class.active]="isLooping()"
|
||
(click)="toggleLoop()"
|
||
[title]="isLooping() ? 'Loop enabled' : 'Enable loop'"
|
||
>
|
||
<span class="material-symbols-outlined">repeat</span>
|
||
</button>
|
||
<!-- Open in new tab -->
|
||
<button class="ctrl-btn" (click)="openImageInNewTab()" title="在新标签页打开图片">
|
||
<span class="material-symbols-outlined">open_in_new</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
}
|
||
<!-- Option B: ACTIVE VIDEO PLAYBACK VIEW -->
|
||
@if (agentService.playerMode() === 'video' && agentService.recordingPlaybackStatus() === 'ready' && !agentService.isVideoLoading() && agentService.activeVideoUrl(); as videoUrl) {
|
||
@if (!videoLoadError()) {
|
||
<div class="video-container">
|
||
@if (gapMessage()) { <div class="recording-gap" role="status">{{ gapMessage() }}</div> }
|
||
<video
|
||
#videoRef
|
||
[style.visibility]="gapMessage() ? 'hidden' : 'visible'"
|
||
[src]="currentVideoUrl()"
|
||
playsinline
|
||
(loadedmetadata)="onLoadedMetadata()"
|
||
(timeupdate)="onTimeUpdate()"
|
||
(seeked)="onSeeked()"
|
||
(emptied)="onPause()"
|
||
(play)="onPlay()"
|
||
(pause)="onPause()"
|
||
(ended)="onEnded()"
|
||
(error)="onError()"
|
||
(click)="togglePlay()"
|
||
></video>
|
||
</div>
|
||
}
|
||
<!-- Dedicated Bottom Controls Bar for Video -->
|
||
@if (!videoLoadError()) {
|
||
<div class="video-controls-footer">
|
||
@if (hasRecordingGaps()) {
|
||
<div class="recording-coverage-summary">
|
||
<span>已录制 {{ formatTime(recordedDuration()) }}</span>
|
||
<span>空白区间自动跳过</span>
|
||
</div>
|
||
}
|
||
<!-- Timeline Scrubber Bar -->
|
||
<div class="scrubber-bar-container" [class.has-recording-gaps]="hasRecordingGaps()">
|
||
<input
|
||
type="range"
|
||
class="scrubber-range"
|
||
aria-label="录像进度"
|
||
[min]="0"
|
||
[max]="duration() || 100"
|
||
[step]="0.1"
|
||
[value]="currentTime()"
|
||
(input)="onScrub($event)"
|
||
/>
|
||
<div class="scrubber-progress-fill" [style.width.%]="progressPercent()"></div>
|
||
@if (hasRecordingGaps()) {
|
||
<div class="recording-coverage" aria-hidden="true">
|
||
@for (range of recordedCoverage(); track $index) {
|
||
<span [style.left.%]="range.left" [style.width.%]="range.width"></span>
|
||
}
|
||
<i [style.left.%]="progressPercent()"></i>
|
||
</div>
|
||
}
|
||
@if (videoStepMarkers().length > 0) {
|
||
<div class="video-markers" aria-label="录像关键步骤">
|
||
@for (marker of videoStepMarkers(); track marker.time) {
|
||
<button
|
||
type="button"
|
||
class="video-marker"
|
||
[class.passed]="currentTime() >= marker.time"
|
||
[class.failed]="marker.failed"
|
||
[style.left.%]="marker.percent"
|
||
[title]="videoMarkerLabel(marker)"
|
||
[attr.aria-label]="'跳转到' + videoMarkerLabel(marker)"
|
||
(click)="seekToVideoMarker(marker)"
|
||
></button>
|
||
}
|
||
</div>
|
||
}
|
||
</div>
|
||
<!-- Bottom Controls Row -->
|
||
<div class="controls-row">
|
||
<div class="controls-left">
|
||
<!-- Play / Pause Button -->
|
||
<button class="ctrl-btn play-btn" (click)="togglePlay()" [title]="isPlaying() ? '暂停' : '播放'">
|
||
<span class="material-symbols-outlined">{{ isPlaying() ? 'pause' : 'play_arrow' }}</span>
|
||
</button>
|
||
<!-- Replay / Reset -->
|
||
<button class="ctrl-btn" (click)="restart()" title="从头回放">
|
||
<span class="material-symbols-outlined">replay</span>
|
||
</button>
|
||
<!-- Time Display -->
|
||
<span class="time-display">
|
||
<span [title]="agentService.usesSessionTimeline() ? '任务时间;蓝色区间有录像,空白区间未录制' : '录像时间'">{{ formatTime(currentTime()) }} / {{ formatTime(duration()) }}</span>
|
||
</span>
|
||
</div>
|
||
<div class="controls-right">
|
||
<!-- Speed Button (Cycle 0.5x, 1x, 1.5x, 2x, 4x) -->
|
||
<button
|
||
class="ctrl-btn speed-pill-btn"
|
||
(click)="cycleSpeed()"
|
||
[title]="'Speed: ' + playbackRate() + 'x (Click to cycle)'"
|
||
>
|
||
<span class="speed-label">{{ playbackRate() }}x</span>
|
||
</button>
|
||
<!-- Loop Toggle -->
|
||
<button
|
||
class="ctrl-btn"
|
||
[class.active]="isLooping()"
|
||
(click)="toggleLoop()"
|
||
[title]="isLooping() ? 'Loop enabled' : 'Enable loop'"
|
||
>
|
||
<span class="material-symbols-outlined">repeat</span>
|
||
</button>
|
||
<!-- Mute / Unmute Toggle -->
|
||
<button class="ctrl-btn" (click)="toggleMute()" [title]="isMuted() ? 'Unmute' : 'Mute'">
|
||
<span class="material-symbols-outlined">{{ isMuted() ? 'volume_off' : 'volume_up' }}</span>
|
||
</button>
|
||
<!-- Open in new tab -->
|
||
<button class="ctrl-btn" (click)="openInNewTab()" title="在新标签页打开视频">
|
||
<span class="material-symbols-outlined">open_in_new</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
@if (skippedInterval()) {
|
||
<div class="recording-skip-notice" role="status">{{ skippedInterval() }}</div>
|
||
}
|
||
</div>
|
||
}
|
||
}
|
||
<!-- Option C: EMPTY / FALLBACK STATE (Only when no video AND no step frames) -->
|
||
@if (!agentService.isVideoLoading() && (agentService.playerMode() === 'video' || totalStepFrames() === 0) && (agentService.recordingPlaybackStatus() === 'failed' || agentService.recordingPlaybackStatus() === 'unavailable' || videoLoadError()) && totalStepFrames() === 0) {
|
||
<div class="empty-video-fallback">
|
||
<div class="fallback-icon-wrap">
|
||
<span class="material-symbols-outlined fallback-icon">videocam_off</span>
|
||
</div>
|
||
<h4 class="fallback-title">
|
||
{{ agentService.recordingPlaybackStatus() === 'failed' || videoLoadError() ? 'Recording Playback Failed' : 'No Video Recording Available' }}
|
||
</h4>
|
||
<p class="fallback-desc">
|
||
{{ videoLoadError() ? 'The finalized recording could not be played.' : (agentService.recordingPlaybackMessage() || 'No screen recording was found for this task.') }}
|
||
</p>
|
||
@if (agentService.recordingPlaybackStatus() === 'failed' || videoLoadError()) {
|
||
<button class="retry-btn" (click)="videoLoadError.set(false); agentService.retryVideoRecording()">
|
||
<span class="material-symbols-outlined">refresh</span> 重试
|
||
</button>
|
||
}
|
||
</div>
|
||
}
|
||
}
|
||
</div>
|
||
</app-task-extension-panels>
|
||
}
|
||
</div>
|
||
}
|