mirror of
https://github.com/mofeng-git/One-KVM.git
synced 2026-09-13 02:54:26 +08:00
feat(web): 完善控制台视频与电源控件
This commit is contained in:
@@ -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')"
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -233,6 +233,7 @@ export default {
|
||||
streamSettings: '流设置',
|
||||
deviceSettings: '设备配置',
|
||||
videoMode: '视频模式',
|
||||
videoRotation: '视频旋转',
|
||||
selectMode: '选择模式...',
|
||||
videoModeHint: 'HTTP 对带宽占用较大但模式兼容性好;WebRTC 对网络要求较高但带宽占用低',
|
||||
videoDevice: '视频设备',
|
||||
|
||||
@@ -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,6 +3419,7 @@ onUnmounted(() => {
|
||||
@wheel.prevent="handleWheel"
|
||||
@contextmenu="handleContextMenu"
|
||||
>
|
||||
<div class="relative shrink-0" :style="videoContentStyle">
|
||||
<img
|
||||
v-show="videoMode === 'mjpeg'"
|
||||
ref="videoRef"
|
||||
@@ -3391,6 +3446,7 @@ onUnmounted(() => {
|
||||
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"
|
||||
|
||||
Reference in New Issue
Block a user