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

503 lines
27 KiB
HTML
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.
-->
<!-- 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>
}