- Showcase UI Reorganization: * Move web UI dashboard from root directory into apps/showcase_ui/ for cleaner project structure * Add 'artemis ui' CLI command (artemis/interfaces/cli/commands/ui.py) and launcher scripts for the interactive showcase console - Standalone MCP Server & Notifiers (mcp_server/): * Implement dedicated MCP server package with mobile automation tools (device_state, inspect_trace, task_manager, task_runner) * Add multi-channel notifiers (AgentAPI, Desktop notifications, Webhooks, File output, and Composite routing) * Introduce background task runner and trace store for autonomous mobile execution monitoring - Cross-Platform Runtime & Toolchain Support: * Add artemis/platform/ with OS-specific implementations for Linux, macOS (Darwin), and Windows * Add artemis/runtime/supervisor.py for resilient process and agent runtime supervision * Add artemis/toolchain/ with dependency resolution and SDK descriptors * Add cross-platform launcher scripts (start.sh, start.bat) and installer scripts (scripts/install_deps.*) - Core Enhancements & Diagnostics: * Enhance coordinate normalization, OCR API fallback, and perception pipeline * Add credential validation (credentials_validator.py) and system diagnostics * Update bilingual documentation (README.md, README_CN.md) and architecture diagrams - Test Suite Expansion: * Add integration tests for cloud gateway, remote ADB, and Artemis login * Add unit tests covering core diagnostics, platform detection, toolchain, credentials validator, optional OCR, and MCP tools
287 lines
7.5 KiB
TypeScript
287 lines
7.5 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,
|
|
inject,
|
|
signal,
|
|
computed,
|
|
ElementRef,
|
|
ViewChild,
|
|
HostListener,
|
|
effect
|
|
} from '@angular/core';
|
|
import { CommonModule } from '@angular/common';
|
|
import { FormsModule } from '@angular/forms';
|
|
import { AgentService } from '../../services/agent.service';
|
|
|
|
@Component({
|
|
selector: 'app-floating-video-player',
|
|
standalone: true,
|
|
imports: [CommonModule, FormsModule],
|
|
templateUrl: './floating-video-player.component.html',
|
|
styleUrl: './floating-video-player.component.scss'
|
|
})
|
|
export class FloatingVideoPlayerComponent {
|
|
public agentService = inject(AgentService);
|
|
|
|
@ViewChild('videoRef') videoRef?: ElementRef<HTMLVideoElement>;
|
|
|
|
// 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 liveStreamUrl = signal<string>('/api/stream/device-live');
|
|
public liveStreamError = signal<boolean>(false);
|
|
|
|
// 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 - 380) : 100
|
|
);
|
|
public posY = signal<number>(
|
|
typeof window !== 'undefined' ? Math.max(60, window.innerHeight - 660) : 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();
|
|
this.videoLoadError.set(false);
|
|
this.currentTime.set(0);
|
|
this.duration.set(0);
|
|
this.isPlaying.set(false);
|
|
},
|
|
{ allowSignalWrites: true }
|
|
);
|
|
}
|
|
|
|
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
|
|
*/
|
|
public startDrag(event: MouseEvent): void {
|
|
if (this.isTheaterMode()) return;
|
|
this.isDragging = true;
|
|
this.dragStartX = event.clientX;
|
|
this.dragStartY = event.clientY;
|
|
this.initialPosX = this.posX();
|
|
this.initialPosY = this.posY();
|
|
event.preventDefault();
|
|
}
|
|
|
|
@HostListener('window:mousemove', ['$event'])
|
|
public 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);
|
|
}
|
|
|
|
@HostListener('window:mouseup')
|
|
public stopDrag(): void {
|
|
this.isDragging = false;
|
|
}
|
|
|
|
@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.duration.set(v.duration || 0);
|
|
this.videoLoadError.set(false);
|
|
v.playbackRate = this.playbackRate();
|
|
v.loop = this.isLooping();
|
|
}
|
|
}
|
|
|
|
public onTimeUpdate(): void {
|
|
const v = this.videoRef?.nativeElement;
|
|
if (v) {
|
|
this.currentTime.set(v.currentTime);
|
|
if (v.duration && v.duration !== this.duration()) {
|
|
this.duration.set(v.duration);
|
|
}
|
|
}
|
|
}
|
|
|
|
public onPlay(): void {
|
|
this.isPlaying.set(true);
|
|
}
|
|
|
|
public onPause(): void {
|
|
this.isPlaying.set(false);
|
|
}
|
|
|
|
public onEnded(): void {
|
|
this.isPlaying.set(false);
|
|
}
|
|
|
|
public onError(): void {
|
|
this.videoLoadError.set(true);
|
|
this.isPlaying.set(false);
|
|
}
|
|
|
|
/**
|
|
* Playback actions
|
|
*/
|
|
public togglePlay(): void {
|
|
const v = this.videoRef?.nativeElement;
|
|
if (!v) return;
|
|
if (v.paused) {
|
|
v.play().catch(() => {});
|
|
} else {
|
|
v.pause();
|
|
}
|
|
}
|
|
|
|
public restart(): void {
|
|
const v = this.videoRef?.nativeElement;
|
|
if (!v) return;
|
|
v.currentTime = 0;
|
|
v.play().catch(() => {});
|
|
}
|
|
|
|
public seek(seconds: number): void {
|
|
const v = this.videoRef?.nativeElement;
|
|
if (!v) return;
|
|
v.currentTime = Math.max(0, Math.min(this.duration(), seconds));
|
|
}
|
|
|
|
public onScrub(event: Event): void {
|
|
const input = event.target as HTMLInputElement;
|
|
const targetTime = parseFloat(input.value);
|
|
this.seek(targetTime);
|
|
}
|
|
|
|
public setSpeed(rate: number): void {
|
|
this.playbackRate.set(rate);
|
|
const v = this.videoRef?.nativeElement;
|
|
if (v) {
|
|
v.playbackRate = rate;
|
|
}
|
|
}
|
|
|
|
public toggleLoop(): void {
|
|
const next = !this.isLooping();
|
|
this.isLooping.set(next);
|
|
const v = this.videoRef?.nativeElement;
|
|
if (v) {
|
|
v.loop = next;
|
|
}
|
|
}
|
|
|
|
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');
|
|
}
|
|
}
|
|
}
|