feat(web): 完善控制台视频与电源控件

This commit is contained in:
mofeng-git
2026-09-06 11:20:11 +08:00
parent 5de5ee42c2
commit 1db572e020
7 changed files with 177 additions and 47 deletions

View File

@@ -3,7 +3,7 @@ import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import { useSystemStore } from '@/stores/system'
import type { VideoScaleMode } from '@/composables/useVideoScaling'
import type { VideoRotation, VideoScaleMode } from '@/composables/useVideoScaling'
import { Button } from '@/components/ui/button'
import { ButtonGroup } from '@/components/ui/button-group'
import {
@@ -68,8 +68,10 @@ const props = defineProps<{
layout?: ConsoleLayout
mouseMode?: 'absolute' | 'relative'
videoMode?: VideoMode
videoRotation?: VideoRotation
ttydRunning?: boolean
showPower?: boolean
atxEnabled?: boolean
showTerminal?: boolean
showComputerUse?: boolean
showPasteText?: boolean
@@ -90,6 +92,7 @@ async function setFloatingCollapsed(collapsed: boolean) {
target?.$el?.focus()
}
const showAtx = computed(() => props.showPower !== false)
const atxEnabled = computed(() => props.atxEnabled === true)
const showStats = computed(() => (props.videoMode ?? 'mjpeg') !== 'mjpeg')
const showPasteText = computed(() => props.showPasteText !== false)
const showMic = computed(() => props.showMic === true)
@@ -102,6 +105,7 @@ const emit = defineEmits<{
(e: 'toggleVirtualKeyboard'): void
(e: 'toggleMouseMode'): void
(e: 'update:videoMode', mode: VideoMode): void
(e: 'update:videoRotation', rotation: VideoRotation): void
(e: 'powerShort'): void
(e: 'powerLong'): void
(e: 'reset'): void
@@ -381,8 +385,10 @@ const hasRightOverflow = computed(() => {
<VideoConfigPopover
v-model:open="videoPopoverOpen"
:video-mode="props.videoMode || 'mjpeg'"
:video-rotation="props.videoRotation ?? 0"
:side="isSidebarLayout ? 'right' : 'bottom'"
@update:video-mode="emit('update:videoMode', $event)"
@update:video-rotation="emit('update:videoRotation', $event)"
/>
</div>
@@ -450,6 +456,7 @@ const hasRightOverflow = computed(() => {
:side="isSidebarLayout ? 'right' : 'bottom'"
>
<AtxPopover
:atx-enabled="atxEnabled"
@close="atxOpen = false"
@power-short="emit('powerShort')"
@power-long="emit('powerLong')"
@@ -686,6 +693,7 @@ const hasRightOverflow = computed(() => {
<SheetTitle>{{ t('actionbar.power') }}</SheetTitle>
</SheetHeader>
<AtxPopover
:atx-enabled="atxEnabled"
@close="mobileAtxOpen = false"
@power-short="emit('powerShort')"
@power-long="emit('powerLong')"

View File

@@ -27,9 +27,17 @@ const emit = defineEmits<{
(e: 'wol', macAddress: string): void
}>()
const props = withDefaults(defineProps<{
/** Whether a hardware ATX controller is configured and available. */
atxEnabled?: boolean
}>(), {
atxEnabled: false,
})
const { t } = useI18n()
const activeTab = ref('atx')
const activeTab = ref(props.atxEnabled ? 'atx' : 'wol')
const showAtxControls = computed(() => props.atxEnabled)
const tabTriggerClass = 'h-8 rounded-md border-0 bg-transparent text-center text-xs text-muted-foreground shadow-none hover:text-foreground data-[state=active]:border-0 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm'
const powerState = ref<'on' | 'off' | 'unknown'>('unknown')
@@ -194,12 +202,17 @@ watch(
},
{ immediate: true },
)
watch(showAtxControls, (enabled) => {
// A disabled ATX controller must never leave the hidden ATX tab selected.
if (!enabled) activeTab.value = 'wol'
})
</script>
<template>
<div class="p-2.5 space-y-2.5">
<Tabs v-model="activeTab">
<TabsList class="grid h-auto w-full grid-cols-2 gap-1 rounded-md border border-border bg-muted p-0.5">
<TabsList v-if="showAtxControls" class="grid h-auto w-full grid-cols-2 gap-1 rounded-md border border-border bg-muted p-0.5">
<TabsTrigger
value="atx"
:class="tabTriggerClass"
@@ -217,7 +230,7 @@ watch(
</TabsList>
<!-- ATX Tab -->
<TabsContent value="atx" class="mt-2.5 space-y-2.5">
<TabsContent v-if="showAtxControls" value="atx" class="mt-2.5 space-y-2.5">
<!-- Status -->
<div class="grid grid-cols-2 gap-2">
<div class="flex min-w-0 items-center gap-2 rounded-md border bg-muted/40 px-2 py-1.5">

View File

@@ -33,6 +33,7 @@ import { toConfigFps } from '@/lib/fps'
import { formatVideoDeviceLabel } from '@/lib/video-device-label'
import { useConfigStore } from '@/stores/config'
import { useVideoDeviceConfiguration } from '@/composables/useVideoDeviceConfiguration'
import type { VideoRotation } from '@/composables/useVideoScaling'
import VideoInputFields from '@/components/VideoInputFields.vue'
export type VideoMode = 'mjpeg' | 'h264' | 'h265' | 'vp8' | 'vp9'
@@ -40,12 +41,14 @@ export type VideoMode = 'mjpeg' | 'h264' | 'h265' | 'vp8' | 'vp9'
const props = defineProps<{
open: boolean
videoMode: VideoMode
videoRotation: VideoRotation
side?: 'top' | 'right' | 'bottom' | 'left'
}>()
const emit = defineEmits<{
(e: 'update:open', value: boolean): void
(e: 'update:videoMode', value: VideoMode): void
(e: 'update:videoRotation', value: VideoRotation): void
}>()
const { t } = useI18n()
@@ -209,6 +212,7 @@ const currentConfig = computed(() => ({
}))
const buttonText = computed(() => t('actionbar.videoConfig'))
const videoRotationOptions: VideoRotation[] = [0, 90, 180, 270]
// Available codecs for selection (filtered by backend support and enriched with backend info)
const availableCodecs = computed(() => {
@@ -627,6 +631,27 @@ watch(
</p>
</div>
<!-- Display Rotation -->
<div class="space-y-2">
<Label class="text-xs text-muted-foreground">{{ t('actionbar.videoRotation') }}</Label>
<div class="grid grid-cols-4 gap-1.5">
<Button
v-for="rotation in videoRotationOptions"
:key="rotation"
variant="outline"
size="sm"
:class="[
'h-8 px-1 text-xs tabular-nums',
props.videoRotation === rotation && 'border-primary bg-primary/10',
]"
:aria-pressed="props.videoRotation === rotation"
@click="emit('update:videoRotation', rotation)"
>
{{ rotation }}°
</Button>
</div>
</div>
<!-- Bitrate Preset - Only shown for WebRTC modes -->
<div v-if="props.videoMode !== 'mjpeg'" class="space-y-2">
<div class="flex items-center gap-1">

View File

@@ -1,18 +1,20 @@
import { computed, nextTick, ref, watch } from 'vue'
import type { CSSProperties } from 'vue'
import type { CSSProperties, Ref } from 'vue'
import { useElementSize } from '@vueuse/core'
export type VideoScaleMode = 'fit' | 'actual'
export type VideoRotation = 0 | 90 | 180 | 270
export interface VideoSize {
width: number
height: number
}
export function useVideoScaling() {
export function useVideoScaling(options: { rotation?: Readonly<Ref<VideoRotation>> } = {}) {
const workspaceRef = ref<HTMLDivElement | null>(null)
const scaleMode = ref<VideoScaleMode>('fit')
const sourceSize = ref<VideoSize | null>(null)
const rotation = options.rotation ?? ref<VideoRotation>(0)
const { width: workspaceWidth, height: workspaceHeight } = useElementSize(workspaceRef)
const sourceSizeAvailable = computed(() => sourceSize.value !== null)
@@ -20,8 +22,18 @@ export function useVideoScaling() {
scaleMode.value === 'actual' && sourceSizeAvailable.value ? 'actual' : 'fit'
))
const fittedSize = computed<VideoSize | null>(() => {
const hasQuarterTurn = computed(() => rotation.value === 90 || rotation.value === 270)
const rotatedSourceSize = computed<VideoSize | null>(() => {
const source = sourceSize.value
if (!source) return null
return hasQuarterTurn.value
? { width: source.height, height: source.width }
: source
})
const fittedSize = computed<VideoSize | null>(() => {
const source = rotatedSourceSize.value
if (!source || workspaceWidth.value <= 0 || workspaceHeight.value <= 0) return null
const scale = Math.min(
@@ -40,7 +52,7 @@ export function useVideoScaling() {
)
const containerStyle = computed<CSSProperties>(() => {
const size = effectiveScaleMode.value === 'actual' ? sourceSize.value : fittedSize.value
const size = effectiveScaleMode.value === 'actual' ? rotatedSourceSize.value : fittedSize.value
if (size) {
return {
width: `${size.width}px`,
@@ -55,6 +67,19 @@ export function useVideoScaling() {
}
})
// A quarter turn swaps the displayed dimensions. Keep the video itself at
// its unrotated dimensions, then rotate it inside the correctly sized frame.
const contentStyle = computed<CSSProperties>(() => {
const size = effectiveScaleMode.value === 'actual' ? rotatedSourceSize.value : fittedSize.value
const quarterTurn = hasQuarterTurn.value
return {
width: size ? `${quarterTurn ? size.height : size.width}px` : '100%',
height: size ? `${quarterTurn ? size.width : size.height}px` : '100%',
transform: `rotate(${rotation.value}deg)`,
}
})
function updateSourceSize(width: number, height: number) {
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) return
@@ -88,6 +113,7 @@ export function useVideoScaling() {
sourceSizeAvailable,
stageClass,
containerStyle,
contentStyle,
updateSourceSize,
clearSourceSize,
setScaleMode,

View File

@@ -233,6 +233,7 @@ export default {
streamSettings: 'Stream Settings',
deviceSettings: 'Device Settings',
videoMode: 'Mode',
videoRotation: 'Rotation',
selectMode: 'Select mode...',
videoModeHint: 'HTTP uses more bandwidth but offers broad compatibility; WebRTC has stricter network requirements but uses less bandwidth',
videoDevice: 'Device',

View File

@@ -233,6 +233,7 @@ export default {
streamSettings: '流设置',
deviceSettings: '设备配置',
videoMode: '视频模式',
videoRotation: '视频旋转',
selectMode: '选择模式...',
videoModeHint: 'HTTP 对带宽占用较大但模式兼容性好WebRTC 对网络要求较高但带宽占用低',
videoDevice: '视频设备',

View File

@@ -10,7 +10,7 @@ import { useConsoleEvents } from '@/composables/useConsoleEvents'
import { useHidWebSocket } from '@/composables/useHidWebSocket'
import { useWebRTC } from '@/composables/useWebRTC'
import { useVideoSession } from '@/composables/useVideoSession'
import { useVideoScaling } from '@/composables/useVideoScaling'
import { useVideoScaling, type VideoRotation } from '@/composables/useVideoScaling'
import { useComputerUseSocket, type ComputerUseServerMessage } from '@/composables/useComputerUseSocket'
import { useFeatureVisibility } from '@/composables/useFeatureVisibility'
import { useTheme } from '@/composables/useTheme'
@@ -105,6 +105,19 @@ const consoleEvents = useConsoleEvents({
})
const videoMode = ref<VideoMode>('mjpeg')
const VIDEO_ROTATIONS: VideoRotation[] = [0, 90, 180, 270]
const storedVideoRotation = Number(localStorage.getItem('videoRotation'))
const videoRotation = ref<VideoRotation>(
VIDEO_ROTATIONS.includes(storedVideoRotation as VideoRotation)
? storedVideoRotation as VideoRotation
: 0,
)
function setVideoRotation(rotation: VideoRotation) {
if (!VIDEO_ROTATIONS.includes(rotation)) return
videoRotation.value = rotation
localStorage.setItem('videoRotation', String(rotation))
}
const computerUseOpen = ref(false)
const computerUseSession = ref<ComputerUseSession | null>(null)
const computerUseTimeline = ref<ComputerUseTimelineItem[]>([])
@@ -135,10 +148,11 @@ const {
sourceSizeAvailable,
stageClass: videoStageClass,
containerStyle: videoContainerStyle,
contentStyle: videoContentStyle,
updateSourceSize: updateVideoSourceSize,
clearSourceSize: clearVideoSourceSize,
setScaleMode: setVideoScaleMode,
} = useVideoScaling()
} = useVideoScaling({ rotation: videoRotation })
const backendFps = ref(0)
@@ -2320,6 +2334,13 @@ function getRenderedVideoRect() {
const rect = videoElement.getBoundingClientRect()
if (rect.width <= 0 || rect.height <= 0) return null
// For a quarter turn, the transformed element already describes the exact
// visible portrait frame. Its original landscape aspect ratio must not be
// used to add artificial letterboxing here.
if (videoRotation.value === 90 || videoRotation.value === 270) {
return rect
}
const contentAspectRatio = getActiveVideoAspectRatio()
if (!contentAspectRatio) {
return rect
@@ -2349,6 +2370,32 @@ function getRenderedVideoRect() {
}
}
function rotateAbsolutePosition(x: number, y: number) {
switch (videoRotation.value) {
case 90:
return { x: y, y: 1 - x }
case 180:
return { x: 1 - x, y: 1 - y }
case 270:
return { x: 1 - y, y: x }
default:
return { x, y }
}
}
function rotateRelativeDelta(dx: number, dy: number) {
switch (videoRotation.value) {
case 90:
return { dx: dy, dy: -dx }
case 180:
return { dx: -dx, dy: -dy }
case 270:
return { dx: -dy, dy: dx }
default:
return { dx, dy }
}
}
function getAbsoluteMousePosition(e: MouseEvent) {
const rect = getRenderedVideoRect()
if (!rect) return null
@@ -2356,9 +2403,10 @@ function getAbsoluteMousePosition(e: MouseEvent) {
const normalizedX = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
const normalizedY = Math.max(0, Math.min(1, (e.clientY - rect.top) / rect.height))
const sourcePosition = rotateAbsolutePosition(normalizedX, normalizedY)
return {
x: Math.round(normalizedX * 32767),
y: Math.round(normalizedY * 32767),
x: Math.round(sourcePosition.x * 32767),
y: Math.round(sourcePosition.y * 32767),
}
}
@@ -2624,11 +2672,12 @@ function handleTouchPointerMove(e: PointerEvent) {
activePointer.lastX += dx
activePointer.lastY += dy
accumulatedDelta.x += dx
accumulatedDelta.y += dy
const rotatedDelta = rotateRelativeDelta(dx, dy)
accumulatedDelta.x += rotatedDelta.dx
accumulatedDelta.y += rotatedDelta.dy
mousePosition.value = {
x: mousePosition.value.x + dx,
y: mousePosition.value.y + dy,
x: mousePosition.value.x + rotatedDelta.dx,
y: mousePosition.value.y + rotatedDelta.dy,
}
updateLocalCrosshairByDelta(dx, dy)
requestMouseMoveFlush()
@@ -2699,14 +2748,16 @@ function handleMouseMove(e: MouseEvent) {
const dy = e.movementY
if (dx !== 0 || dy !== 0) {
accumulatedDelta.x += dx
accumulatedDelta.y += dy
const rotatedDelta = rotateRelativeDelta(dx, dy)
accumulatedDelta.x += rotatedDelta.dx
accumulatedDelta.y += rotatedDelta.dy
requestMouseMoveFlush()
}
const rotatedDelta = rotateRelativeDelta(dx, dy)
mousePosition.value = {
x: mousePosition.value.x + dx,
y: mousePosition.value.y + dy,
x: mousePosition.value.x + rotatedDelta.dx,
y: mousePosition.value.y + rotatedDelta.dy,
}
}
}
@@ -3304,13 +3355,15 @@ onUnmounted(() => {
</div>
</div>
</header>
<Teleport defer :to="consoleLayout === 'floating' ? '#console-header-toolbar' : 'body'" :disabled="consoleLayout !== 'floating'">
<Teleport :key="consoleLayout" defer :to="consoleLayout === 'floating' ? '#console-header-toolbar' : 'body'" :disabled="consoleLayout !== 'floating'">
<ActionBar
:layout="consoleLayout"
:mouse-mode="mouseMode"
:video-mode="videoMode"
:video-rotation="videoRotation"
:ttyd-running="ttydStatus?.running"
:show-power="showPower"
:atx-enabled="systemStore.atx?.available === true"
:show-terminal="showTerminal"
:show-computer-use="showComputerUse"
:show-paste-text="showPasteText"
@@ -3323,6 +3376,7 @@ onUnmounted(() => {
@toggle-virtual-keyboard="handleToggleVirtualKeyboard"
@toggle-mouse-mode="handleToggleMouseMode"
@update:video-mode="handleVideoModeChange"
@update:video-rotation="setVideoRotation"
@power-short="handlePowerShort"
@power-long="handlePowerLong"
@reset="handleReset"
@@ -3365,32 +3419,34 @@ onUnmounted(() => {
@wheel.prevent="handleWheel"
@contextmenu="handleContextMenu"
>
<img
v-show="videoMode === 'mjpeg'"
ref="videoRef"
:src="mjpegUrl"
class="size-full object-contain pointer-events-none select-none"
:alt="t('console.videoAlt')"
draggable="false"
@load="handleVideoLoad"
@error="handleVideoError"
/>
<video
v-show="videoMode !== 'mjpeg'"
ref="webrtcVideoRef"
class="size-full object-contain pointer-events-none"
autoplay
playsinline
@loadedmetadata="handleWebRTCVideoResize"
@loadeddata="handleWebRTCVideoResize"
@resize="handleWebRTCVideoResize"
/>
<img
v-if="frameOverlayUrl"
:src="frameOverlayUrl"
class="absolute inset-0 size-full object-contain pointer-events-none"
alt=""
/>
<div class="relative shrink-0" :style="videoContentStyle">
<img
v-show="videoMode === 'mjpeg'"
ref="videoRef"
:src="mjpegUrl"
class="size-full object-contain pointer-events-none select-none"
:alt="t('console.videoAlt')"
draggable="false"
@load="handleVideoLoad"
@error="handleVideoError"
/>
<video
v-show="videoMode !== 'mjpeg'"
ref="webrtcVideoRef"
class="size-full object-contain pointer-events-none"
autoplay
playsinline
@loadedmetadata="handleWebRTCVideoResize"
@loadeddata="handleWebRTCVideoResize"
@resize="handleWebRTCVideoResize"
/>
<img
v-if="frameOverlayUrl"
:src="frameOverlayUrl"
class="absolute inset-0 size-full object-contain pointer-events-none"
alt=""
/>
</div>
<div
v-if="cursorVisible && localCrosshairPos"
class="pointer-events-none absolute z-[15] -translate-x-1/2 -translate-y-1/2"