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 { useI18n } from 'vue-i18n'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { useSystemStore } from '@/stores/system'
|
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 { Button } from '@/components/ui/button'
|
||||||
import { ButtonGroup } from '@/components/ui/button-group'
|
import { ButtonGroup } from '@/components/ui/button-group'
|
||||||
import {
|
import {
|
||||||
@@ -68,8 +68,10 @@ const props = defineProps<{
|
|||||||
layout?: ConsoleLayout
|
layout?: ConsoleLayout
|
||||||
mouseMode?: 'absolute' | 'relative'
|
mouseMode?: 'absolute' | 'relative'
|
||||||
videoMode?: VideoMode
|
videoMode?: VideoMode
|
||||||
|
videoRotation?: VideoRotation
|
||||||
ttydRunning?: boolean
|
ttydRunning?: boolean
|
||||||
showPower?: boolean
|
showPower?: boolean
|
||||||
|
atxEnabled?: boolean
|
||||||
showTerminal?: boolean
|
showTerminal?: boolean
|
||||||
showComputerUse?: boolean
|
showComputerUse?: boolean
|
||||||
showPasteText?: boolean
|
showPasteText?: boolean
|
||||||
@@ -90,6 +92,7 @@ async function setFloatingCollapsed(collapsed: boolean) {
|
|||||||
target?.$el?.focus()
|
target?.$el?.focus()
|
||||||
}
|
}
|
||||||
const showAtx = computed(() => props.showPower !== false)
|
const showAtx = computed(() => props.showPower !== false)
|
||||||
|
const atxEnabled = computed(() => props.atxEnabled === true)
|
||||||
const showStats = computed(() => (props.videoMode ?? 'mjpeg') !== 'mjpeg')
|
const showStats = computed(() => (props.videoMode ?? 'mjpeg') !== 'mjpeg')
|
||||||
const showPasteText = computed(() => props.showPasteText !== false)
|
const showPasteText = computed(() => props.showPasteText !== false)
|
||||||
const showMic = computed(() => props.showMic === true)
|
const showMic = computed(() => props.showMic === true)
|
||||||
@@ -102,6 +105,7 @@ const emit = defineEmits<{
|
|||||||
(e: 'toggleVirtualKeyboard'): void
|
(e: 'toggleVirtualKeyboard'): void
|
||||||
(e: 'toggleMouseMode'): void
|
(e: 'toggleMouseMode'): void
|
||||||
(e: 'update:videoMode', mode: VideoMode): void
|
(e: 'update:videoMode', mode: VideoMode): void
|
||||||
|
(e: 'update:videoRotation', rotation: VideoRotation): void
|
||||||
(e: 'powerShort'): void
|
(e: 'powerShort'): void
|
||||||
(e: 'powerLong'): void
|
(e: 'powerLong'): void
|
||||||
(e: 'reset'): void
|
(e: 'reset'): void
|
||||||
@@ -381,8 +385,10 @@ const hasRightOverflow = computed(() => {
|
|||||||
<VideoConfigPopover
|
<VideoConfigPopover
|
||||||
v-model:open="videoPopoverOpen"
|
v-model:open="videoPopoverOpen"
|
||||||
:video-mode="props.videoMode || 'mjpeg'"
|
:video-mode="props.videoMode || 'mjpeg'"
|
||||||
|
:video-rotation="props.videoRotation ?? 0"
|
||||||
:side="isSidebarLayout ? 'right' : 'bottom'"
|
:side="isSidebarLayout ? 'right' : 'bottom'"
|
||||||
@update:video-mode="emit('update:videoMode', $event)"
|
@update:video-mode="emit('update:videoMode', $event)"
|
||||||
|
@update:video-rotation="emit('update:videoRotation', $event)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -450,6 +456,7 @@ const hasRightOverflow = computed(() => {
|
|||||||
:side="isSidebarLayout ? 'right' : 'bottom'"
|
:side="isSidebarLayout ? 'right' : 'bottom'"
|
||||||
>
|
>
|
||||||
<AtxPopover
|
<AtxPopover
|
||||||
|
:atx-enabled="atxEnabled"
|
||||||
@close="atxOpen = false"
|
@close="atxOpen = false"
|
||||||
@power-short="emit('powerShort')"
|
@power-short="emit('powerShort')"
|
||||||
@power-long="emit('powerLong')"
|
@power-long="emit('powerLong')"
|
||||||
@@ -686,6 +693,7 @@ const hasRightOverflow = computed(() => {
|
|||||||
<SheetTitle>{{ t('actionbar.power') }}</SheetTitle>
|
<SheetTitle>{{ t('actionbar.power') }}</SheetTitle>
|
||||||
</SheetHeader>
|
</SheetHeader>
|
||||||
<AtxPopover
|
<AtxPopover
|
||||||
|
:atx-enabled="atxEnabled"
|
||||||
@close="mobileAtxOpen = false"
|
@close="mobileAtxOpen = false"
|
||||||
@power-short="emit('powerShort')"
|
@power-short="emit('powerShort')"
|
||||||
@power-long="emit('powerLong')"
|
@power-long="emit('powerLong')"
|
||||||
|
|||||||
@@ -27,9 +27,17 @@ const emit = defineEmits<{
|
|||||||
(e: 'wol', macAddress: string): void
|
(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 { 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 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')
|
const powerState = ref<'on' | 'off' | 'unknown'>('unknown')
|
||||||
@@ -194,12 +202,17 @@ watch(
|
|||||||
},
|
},
|
||||||
{ immediate: true },
|
{ immediate: true },
|
||||||
)
|
)
|
||||||
|
|
||||||
|
watch(showAtxControls, (enabled) => {
|
||||||
|
// A disabled ATX controller must never leave the hidden ATX tab selected.
|
||||||
|
if (!enabled) activeTab.value = 'wol'
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="p-2.5 space-y-2.5">
|
<div class="p-2.5 space-y-2.5">
|
||||||
<Tabs v-model="activeTab">
|
<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
|
<TabsTrigger
|
||||||
value="atx"
|
value="atx"
|
||||||
:class="tabTriggerClass"
|
:class="tabTriggerClass"
|
||||||
@@ -217,7 +230,7 @@ watch(
|
|||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<!-- ATX Tab -->
|
<!-- 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 -->
|
<!-- Status -->
|
||||||
<div class="grid grid-cols-2 gap-2">
|
<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">
|
<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 { formatVideoDeviceLabel } from '@/lib/video-device-label'
|
||||||
import { useConfigStore } from '@/stores/config'
|
import { useConfigStore } from '@/stores/config'
|
||||||
import { useVideoDeviceConfiguration } from '@/composables/useVideoDeviceConfiguration'
|
import { useVideoDeviceConfiguration } from '@/composables/useVideoDeviceConfiguration'
|
||||||
|
import type { VideoRotation } from '@/composables/useVideoScaling'
|
||||||
import VideoInputFields from '@/components/VideoInputFields.vue'
|
import VideoInputFields from '@/components/VideoInputFields.vue'
|
||||||
|
|
||||||
export type VideoMode = 'mjpeg' | 'h264' | 'h265' | 'vp8' | 'vp9'
|
export type VideoMode = 'mjpeg' | 'h264' | 'h265' | 'vp8' | 'vp9'
|
||||||
@@ -40,12 +41,14 @@ export type VideoMode = 'mjpeg' | 'h264' | 'h265' | 'vp8' | 'vp9'
|
|||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
open: boolean
|
open: boolean
|
||||||
videoMode: VideoMode
|
videoMode: VideoMode
|
||||||
|
videoRotation: VideoRotation
|
||||||
side?: 'top' | 'right' | 'bottom' | 'left'
|
side?: 'top' | 'right' | 'bottom' | 'left'
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(e: 'update:open', value: boolean): void
|
(e: 'update:open', value: boolean): void
|
||||||
(e: 'update:videoMode', value: VideoMode): void
|
(e: 'update:videoMode', value: VideoMode): void
|
||||||
|
(e: 'update:videoRotation', value: VideoRotation): void
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
@@ -209,6 +212,7 @@ const currentConfig = computed(() => ({
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
const buttonText = computed(() => t('actionbar.videoConfig'))
|
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)
|
// Available codecs for selection (filtered by backend support and enriched with backend info)
|
||||||
const availableCodecs = computed(() => {
|
const availableCodecs = computed(() => {
|
||||||
@@ -627,6 +631,27 @@ watch(
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</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 -->
|
<!-- Bitrate Preset - Only shown for WebRTC modes -->
|
||||||
<div v-if="props.videoMode !== 'mjpeg'" class="space-y-2">
|
<div v-if="props.videoMode !== 'mjpeg'" class="space-y-2">
|
||||||
<div class="flex items-center gap-1">
|
<div class="flex items-center gap-1">
|
||||||
|
|||||||
@@ -1,18 +1,20 @@
|
|||||||
import { computed, nextTick, ref, watch } from 'vue'
|
import { computed, nextTick, ref, watch } from 'vue'
|
||||||
import type { CSSProperties } from 'vue'
|
import type { CSSProperties, Ref } from 'vue'
|
||||||
import { useElementSize } from '@vueuse/core'
|
import { useElementSize } from '@vueuse/core'
|
||||||
|
|
||||||
export type VideoScaleMode = 'fit' | 'actual'
|
export type VideoScaleMode = 'fit' | 'actual'
|
||||||
|
export type VideoRotation = 0 | 90 | 180 | 270
|
||||||
|
|
||||||
export interface VideoSize {
|
export interface VideoSize {
|
||||||
width: number
|
width: number
|
||||||
height: number
|
height: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useVideoScaling() {
|
export function useVideoScaling(options: { rotation?: Readonly<Ref<VideoRotation>> } = {}) {
|
||||||
const workspaceRef = ref<HTMLDivElement | null>(null)
|
const workspaceRef = ref<HTMLDivElement | null>(null)
|
||||||
const scaleMode = ref<VideoScaleMode>('fit')
|
const scaleMode = ref<VideoScaleMode>('fit')
|
||||||
const sourceSize = ref<VideoSize | null>(null)
|
const sourceSize = ref<VideoSize | null>(null)
|
||||||
|
const rotation = options.rotation ?? ref<VideoRotation>(0)
|
||||||
const { width: workspaceWidth, height: workspaceHeight } = useElementSize(workspaceRef)
|
const { width: workspaceWidth, height: workspaceHeight } = useElementSize(workspaceRef)
|
||||||
|
|
||||||
const sourceSizeAvailable = computed(() => sourceSize.value !== null)
|
const sourceSizeAvailable = computed(() => sourceSize.value !== null)
|
||||||
@@ -20,8 +22,18 @@ export function useVideoScaling() {
|
|||||||
scaleMode.value === 'actual' && sourceSizeAvailable.value ? 'actual' : 'fit'
|
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
|
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
|
if (!source || workspaceWidth.value <= 0 || workspaceHeight.value <= 0) return null
|
||||||
|
|
||||||
const scale = Math.min(
|
const scale = Math.min(
|
||||||
@@ -40,7 +52,7 @@ export function useVideoScaling() {
|
|||||||
)
|
)
|
||||||
|
|
||||||
const containerStyle = computed<CSSProperties>(() => {
|
const containerStyle = computed<CSSProperties>(() => {
|
||||||
const size = effectiveScaleMode.value === 'actual' ? sourceSize.value : fittedSize.value
|
const size = effectiveScaleMode.value === 'actual' ? rotatedSourceSize.value : fittedSize.value
|
||||||
if (size) {
|
if (size) {
|
||||||
return {
|
return {
|
||||||
width: `${size.width}px`,
|
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) {
|
function updateSourceSize(width: number, height: number) {
|
||||||
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) return
|
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) return
|
||||||
|
|
||||||
@@ -88,6 +113,7 @@ export function useVideoScaling() {
|
|||||||
sourceSizeAvailable,
|
sourceSizeAvailable,
|
||||||
stageClass,
|
stageClass,
|
||||||
containerStyle,
|
containerStyle,
|
||||||
|
contentStyle,
|
||||||
updateSourceSize,
|
updateSourceSize,
|
||||||
clearSourceSize,
|
clearSourceSize,
|
||||||
setScaleMode,
|
setScaleMode,
|
||||||
|
|||||||
@@ -233,6 +233,7 @@ export default {
|
|||||||
streamSettings: 'Stream Settings',
|
streamSettings: 'Stream Settings',
|
||||||
deviceSettings: 'Device Settings',
|
deviceSettings: 'Device Settings',
|
||||||
videoMode: 'Mode',
|
videoMode: 'Mode',
|
||||||
|
videoRotation: 'Rotation',
|
||||||
selectMode: 'Select mode...',
|
selectMode: 'Select mode...',
|
||||||
videoModeHint: 'HTTP uses more bandwidth but offers broad compatibility; WebRTC has stricter network requirements but uses less bandwidth',
|
videoModeHint: 'HTTP uses more bandwidth but offers broad compatibility; WebRTC has stricter network requirements but uses less bandwidth',
|
||||||
videoDevice: 'Device',
|
videoDevice: 'Device',
|
||||||
|
|||||||
@@ -233,6 +233,7 @@ export default {
|
|||||||
streamSettings: '流设置',
|
streamSettings: '流设置',
|
||||||
deviceSettings: '设备配置',
|
deviceSettings: '设备配置',
|
||||||
videoMode: '视频模式',
|
videoMode: '视频模式',
|
||||||
|
videoRotation: '视频旋转',
|
||||||
selectMode: '选择模式...',
|
selectMode: '选择模式...',
|
||||||
videoModeHint: 'HTTP 对带宽占用较大但模式兼容性好;WebRTC 对网络要求较高但带宽占用低',
|
videoModeHint: 'HTTP 对带宽占用较大但模式兼容性好;WebRTC 对网络要求较高但带宽占用低',
|
||||||
videoDevice: '视频设备',
|
videoDevice: '视频设备',
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { useConsoleEvents } from '@/composables/useConsoleEvents'
|
|||||||
import { useHidWebSocket } from '@/composables/useHidWebSocket'
|
import { useHidWebSocket } from '@/composables/useHidWebSocket'
|
||||||
import { useWebRTC } from '@/composables/useWebRTC'
|
import { useWebRTC } from '@/composables/useWebRTC'
|
||||||
import { useVideoSession } from '@/composables/useVideoSession'
|
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 { useComputerUseSocket, type ComputerUseServerMessage } from '@/composables/useComputerUseSocket'
|
||||||
import { useFeatureVisibility } from '@/composables/useFeatureVisibility'
|
import { useFeatureVisibility } from '@/composables/useFeatureVisibility'
|
||||||
import { useTheme } from '@/composables/useTheme'
|
import { useTheme } from '@/composables/useTheme'
|
||||||
@@ -105,6 +105,19 @@ const consoleEvents = useConsoleEvents({
|
|||||||
})
|
})
|
||||||
|
|
||||||
const videoMode = ref<VideoMode>('mjpeg')
|
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 computerUseOpen = ref(false)
|
||||||
const computerUseSession = ref<ComputerUseSession | null>(null)
|
const computerUseSession = ref<ComputerUseSession | null>(null)
|
||||||
const computerUseTimeline = ref<ComputerUseTimelineItem[]>([])
|
const computerUseTimeline = ref<ComputerUseTimelineItem[]>([])
|
||||||
@@ -135,10 +148,11 @@ const {
|
|||||||
sourceSizeAvailable,
|
sourceSizeAvailable,
|
||||||
stageClass: videoStageClass,
|
stageClass: videoStageClass,
|
||||||
containerStyle: videoContainerStyle,
|
containerStyle: videoContainerStyle,
|
||||||
|
contentStyle: videoContentStyle,
|
||||||
updateSourceSize: updateVideoSourceSize,
|
updateSourceSize: updateVideoSourceSize,
|
||||||
clearSourceSize: clearVideoSourceSize,
|
clearSourceSize: clearVideoSourceSize,
|
||||||
setScaleMode: setVideoScaleMode,
|
setScaleMode: setVideoScaleMode,
|
||||||
} = useVideoScaling()
|
} = useVideoScaling({ rotation: videoRotation })
|
||||||
|
|
||||||
const backendFps = ref(0)
|
const backendFps = ref(0)
|
||||||
|
|
||||||
@@ -2320,6 +2334,13 @@ function getRenderedVideoRect() {
|
|||||||
const rect = videoElement.getBoundingClientRect()
|
const rect = videoElement.getBoundingClientRect()
|
||||||
if (rect.width <= 0 || rect.height <= 0) return null
|
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()
|
const contentAspectRatio = getActiveVideoAspectRatio()
|
||||||
if (!contentAspectRatio) {
|
if (!contentAspectRatio) {
|
||||||
return rect
|
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) {
|
function getAbsoluteMousePosition(e: MouseEvent) {
|
||||||
const rect = getRenderedVideoRect()
|
const rect = getRenderedVideoRect()
|
||||||
if (!rect) return null
|
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 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 normalizedY = Math.max(0, Math.min(1, (e.clientY - rect.top) / rect.height))
|
||||||
|
|
||||||
|
const sourcePosition = rotateAbsolutePosition(normalizedX, normalizedY)
|
||||||
return {
|
return {
|
||||||
x: Math.round(normalizedX * 32767),
|
x: Math.round(sourcePosition.x * 32767),
|
||||||
y: Math.round(normalizedY * 32767),
|
y: Math.round(sourcePosition.y * 32767),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2624,11 +2672,12 @@ function handleTouchPointerMove(e: PointerEvent) {
|
|||||||
|
|
||||||
activePointer.lastX += dx
|
activePointer.lastX += dx
|
||||||
activePointer.lastY += dy
|
activePointer.lastY += dy
|
||||||
accumulatedDelta.x += dx
|
const rotatedDelta = rotateRelativeDelta(dx, dy)
|
||||||
accumulatedDelta.y += dy
|
accumulatedDelta.x += rotatedDelta.dx
|
||||||
|
accumulatedDelta.y += rotatedDelta.dy
|
||||||
mousePosition.value = {
|
mousePosition.value = {
|
||||||
x: mousePosition.value.x + dx,
|
x: mousePosition.value.x + rotatedDelta.dx,
|
||||||
y: mousePosition.value.y + dy,
|
y: mousePosition.value.y + rotatedDelta.dy,
|
||||||
}
|
}
|
||||||
updateLocalCrosshairByDelta(dx, dy)
|
updateLocalCrosshairByDelta(dx, dy)
|
||||||
requestMouseMoveFlush()
|
requestMouseMoveFlush()
|
||||||
@@ -2699,14 +2748,16 @@ function handleMouseMove(e: MouseEvent) {
|
|||||||
const dy = e.movementY
|
const dy = e.movementY
|
||||||
|
|
||||||
if (dx !== 0 || dy !== 0) {
|
if (dx !== 0 || dy !== 0) {
|
||||||
accumulatedDelta.x += dx
|
const rotatedDelta = rotateRelativeDelta(dx, dy)
|
||||||
accumulatedDelta.y += dy
|
accumulatedDelta.x += rotatedDelta.dx
|
||||||
|
accumulatedDelta.y += rotatedDelta.dy
|
||||||
requestMouseMoveFlush()
|
requestMouseMoveFlush()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const rotatedDelta = rotateRelativeDelta(dx, dy)
|
||||||
mousePosition.value = {
|
mousePosition.value = {
|
||||||
x: mousePosition.value.x + dx,
|
x: mousePosition.value.x + rotatedDelta.dx,
|
||||||
y: mousePosition.value.y + dy,
|
y: mousePosition.value.y + rotatedDelta.dy,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3304,13 +3355,15 @@ onUnmounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</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
|
<ActionBar
|
||||||
:layout="consoleLayout"
|
:layout="consoleLayout"
|
||||||
:mouse-mode="mouseMode"
|
:mouse-mode="mouseMode"
|
||||||
:video-mode="videoMode"
|
:video-mode="videoMode"
|
||||||
|
:video-rotation="videoRotation"
|
||||||
:ttyd-running="ttydStatus?.running"
|
:ttyd-running="ttydStatus?.running"
|
||||||
:show-power="showPower"
|
:show-power="showPower"
|
||||||
|
:atx-enabled="systemStore.atx?.available === true"
|
||||||
:show-terminal="showTerminal"
|
:show-terminal="showTerminal"
|
||||||
:show-computer-use="showComputerUse"
|
:show-computer-use="showComputerUse"
|
||||||
:show-paste-text="showPasteText"
|
:show-paste-text="showPasteText"
|
||||||
@@ -3323,6 +3376,7 @@ onUnmounted(() => {
|
|||||||
@toggle-virtual-keyboard="handleToggleVirtualKeyboard"
|
@toggle-virtual-keyboard="handleToggleVirtualKeyboard"
|
||||||
@toggle-mouse-mode="handleToggleMouseMode"
|
@toggle-mouse-mode="handleToggleMouseMode"
|
||||||
@update:video-mode="handleVideoModeChange"
|
@update:video-mode="handleVideoModeChange"
|
||||||
|
@update:video-rotation="setVideoRotation"
|
||||||
@power-short="handlePowerShort"
|
@power-short="handlePowerShort"
|
||||||
@power-long="handlePowerLong"
|
@power-long="handlePowerLong"
|
||||||
@reset="handleReset"
|
@reset="handleReset"
|
||||||
@@ -3365,32 +3419,34 @@ onUnmounted(() => {
|
|||||||
@wheel.prevent="handleWheel"
|
@wheel.prevent="handleWheel"
|
||||||
@contextmenu="handleContextMenu"
|
@contextmenu="handleContextMenu"
|
||||||
>
|
>
|
||||||
<img
|
<div class="relative shrink-0" :style="videoContentStyle">
|
||||||
v-show="videoMode === 'mjpeg'"
|
<img
|
||||||
ref="videoRef"
|
v-show="videoMode === 'mjpeg'"
|
||||||
:src="mjpegUrl"
|
ref="videoRef"
|
||||||
class="size-full object-contain pointer-events-none select-none"
|
:src="mjpegUrl"
|
||||||
:alt="t('console.videoAlt')"
|
class="size-full object-contain pointer-events-none select-none"
|
||||||
draggable="false"
|
:alt="t('console.videoAlt')"
|
||||||
@load="handleVideoLoad"
|
draggable="false"
|
||||||
@error="handleVideoError"
|
@load="handleVideoLoad"
|
||||||
/>
|
@error="handleVideoError"
|
||||||
<video
|
/>
|
||||||
v-show="videoMode !== 'mjpeg'"
|
<video
|
||||||
ref="webrtcVideoRef"
|
v-show="videoMode !== 'mjpeg'"
|
||||||
class="size-full object-contain pointer-events-none"
|
ref="webrtcVideoRef"
|
||||||
autoplay
|
class="size-full object-contain pointer-events-none"
|
||||||
playsinline
|
autoplay
|
||||||
@loadedmetadata="handleWebRTCVideoResize"
|
playsinline
|
||||||
@loadeddata="handleWebRTCVideoResize"
|
@loadedmetadata="handleWebRTCVideoResize"
|
||||||
@resize="handleWebRTCVideoResize"
|
@loadeddata="handleWebRTCVideoResize"
|
||||||
/>
|
@resize="handleWebRTCVideoResize"
|
||||||
<img
|
/>
|
||||||
v-if="frameOverlayUrl"
|
<img
|
||||||
:src="frameOverlayUrl"
|
v-if="frameOverlayUrl"
|
||||||
class="absolute inset-0 size-full object-contain pointer-events-none"
|
:src="frameOverlayUrl"
|
||||||
alt=""
|
class="absolute inset-0 size-full object-contain pointer-events-none"
|
||||||
/>
|
alt=""
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<div
|
<div
|
||||||
v-if="cursorVisible && localCrosshairPos"
|
v-if="cursorVisible && localCrosshairPos"
|
||||||
class="pointer-events-none absolute z-[15] -translate-x-1/2 -translate-y-1/2"
|
class="pointer-events-none absolute z-[15] -translate-x-1/2 -translate-y-1/2"
|
||||||
|
|||||||
Reference in New Issue
Block a user