mirror of
https://github.com/mofeng-git/One-KVM.git
synced 2026-09-12 10:34:24 +08:00
feat: 添加视频窗口等比例缩放按钮
This commit is contained in:
@@ -3,6 +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 { 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 {
|
||||||
@@ -32,9 +33,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
ClipboardPaste,
|
ClipboardPaste,
|
||||||
HardDrive,
|
HardDrive,
|
||||||
Keyboard,
|
|
||||||
Settings,
|
Settings,
|
||||||
Maximize,
|
|
||||||
Power,
|
Power,
|
||||||
BarChart3,
|
BarChart3,
|
||||||
Terminal,
|
Terminal,
|
||||||
@@ -47,6 +46,7 @@ import VideoConfigPopover, { type VideoMode } from '@/components/VideoConfigPopo
|
|||||||
import HidConfigPopover from '@/components/HidConfigPopover.vue'
|
import HidConfigPopover from '@/components/HidConfigPopover.vue'
|
||||||
import AudioConfigPopover from '@/components/AudioConfigPopover.vue'
|
import AudioConfigPopover from '@/components/AudioConfigPopover.vue'
|
||||||
import MsdDialog from '@/components/MsdDialog.vue'
|
import MsdDialog from '@/components/MsdDialog.vue'
|
||||||
|
import VideoDisplayControls from '@/components/VideoDisplayControls.vue'
|
||||||
|
|
||||||
const { t, locale } = useI18n()
|
const { t, locale } = useI18n()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -69,6 +69,8 @@ const props = defineProps<{
|
|||||||
showComputerUse?: boolean
|
showComputerUse?: boolean
|
||||||
showPasteText?: boolean
|
showPasteText?: boolean
|
||||||
showMic?: boolean
|
showMic?: boolean
|
||||||
|
scaleMode?: VideoScaleMode
|
||||||
|
sourceSizeAvailable?: boolean
|
||||||
}>()
|
}>()
|
||||||
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)
|
||||||
@@ -77,6 +79,7 @@ const showMic = computed(() => props.showMic === true)
|
|||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(e: 'toggleFullscreen'): void
|
(e: 'toggleFullscreen'): void
|
||||||
|
(e: 'update:scaleMode', mode: VideoScaleMode): void
|
||||||
(e: 'toggleStats'): void
|
(e: 'toggleStats'): void
|
||||||
(e: 'toggleVirtualKeyboard'): void
|
(e: 'toggleVirtualKeyboard'): void
|
||||||
(e: 'toggleMouseMode'): void
|
(e: 'toggleMouseMode'): void
|
||||||
@@ -130,7 +133,8 @@ const openMobilePaste = () => openFromOverflow(() => {
|
|||||||
const barRef = ref<HTMLElement | null>(null)
|
const barRef = ref<HTMLElement | null>(null)
|
||||||
const measureRef = ref<HTMLElement | null>(null)
|
const measureRef = ref<HTMLElement | null>(null)
|
||||||
const barWidth = ref(0)
|
const barWidth = ref(0)
|
||||||
let resizeObserver: ResizeObserver | null = null
|
const alwaysRightWidth = ref(152)
|
||||||
|
let layoutResizeObserver: ResizeObserver | null = null
|
||||||
|
|
||||||
type CollapsibleItem =
|
type CollapsibleItem =
|
||||||
| 'video' | 'audio' | 'hid'
|
| 'video' | 'audio' | 'hid'
|
||||||
@@ -157,16 +161,18 @@ const ITEM_SPECS: ItemSpec[] = [
|
|||||||
const measuredWidths = ref<Map<CollapsibleItem, { icon: number; label: number }>>(new Map())
|
const measuredWidths = ref<Map<CollapsibleItem, { icon: number; label: number }>>(new Map())
|
||||||
const measurementReady = ref(false)
|
const measurementReady = ref(false)
|
||||||
|
|
||||||
const measureButtonWidths = async () => {
|
const measureLayout = async () => {
|
||||||
await nextTick()
|
await nextTick()
|
||||||
if (!measureRef.value) return
|
const bar = barRef.value
|
||||||
|
const measureContainer = measureRef.value
|
||||||
|
if (!bar || !measureContainer) return
|
||||||
|
|
||||||
|
barWidth.value = bar.clientWidth
|
||||||
|
|
||||||
const newWidths = new Map<CollapsibleItem, { icon: number; label: number }>()
|
const newWidths = new Map<CollapsibleItem, { icon: number; label: number }>()
|
||||||
|
|
||||||
for (const spec of ITEM_SPECS) {
|
for (const spec of ITEM_SPECS) {
|
||||||
const iconEl = measureRef.value.querySelector(`[data-measure="${spec.id}-icon"]`) as HTMLElement
|
const iconEl = measureContainer.querySelector(`[data-measure="${spec.id}-icon"]`) as HTMLElement
|
||||||
const labelEl = measureRef.value.querySelector(`[data-measure="${spec.id}-label"]`) as HTMLElement
|
const labelEl = measureContainer.querySelector(`[data-measure="${spec.id}-label"]`) as HTMLElement
|
||||||
|
|
||||||
if (iconEl && labelEl) {
|
if (iconEl && labelEl) {
|
||||||
newWidths.set(spec.id, {
|
newWidths.set(spec.id, {
|
||||||
icon: Math.ceil(iconEl.offsetWidth) + 8,
|
icon: Math.ceil(iconEl.offsetWidth) + 8,
|
||||||
@@ -174,31 +180,48 @@ const measureButtonWidths = async () => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
measuredWidths.value = newWidths
|
measuredWidths.value = newWidths
|
||||||
|
|
||||||
|
const elements = Array.from(bar.querySelectorAll('[data-fixed-action]')) as HTMLElement[]
|
||||||
|
const width = elements.reduce((sum, element) => {
|
||||||
|
const style = window.getComputedStyle(element)
|
||||||
|
return sum
|
||||||
|
+ element.getBoundingClientRect().width
|
||||||
|
+ Number.parseFloat(style.marginLeft || '0')
|
||||||
|
+ Number.parseFloat(style.marginRight || '0')
|
||||||
|
}, 0)
|
||||||
|
if (width > 0) alwaysRightWidth.value = Math.ceil(width)
|
||||||
|
|
||||||
measurementReady.value = true
|
measurementReady.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
const observeLayout = async () => {
|
||||||
if (barRef.value) {
|
await measureLayout()
|
||||||
resizeObserver = new ResizeObserver((entries) => {
|
layoutResizeObserver?.disconnect()
|
||||||
const entry = entries[0]
|
layoutResizeObserver = new ResizeObserver(() => {
|
||||||
if (entry) barWidth.value = entry.contentRect.width
|
void measureLayout()
|
||||||
})
|
})
|
||||||
resizeObserver.observe(barRef.value)
|
if (barRef.value) layoutResizeObserver.observe(barRef.value)
|
||||||
barWidth.value = barRef.value.clientWidth
|
barRef.value?.querySelectorAll('[data-fixed-action]').forEach((element) => {
|
||||||
}
|
layoutResizeObserver?.observe(element)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
measureButtonWidths()
|
onMounted(() => {
|
||||||
|
void observeLayout()
|
||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
resizeObserver?.disconnect()
|
layoutResizeObserver?.disconnect()
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(locale, () => {
|
watch(locale, () => {
|
||||||
measurementReady.value = false
|
measurementReady.value = false
|
||||||
measureButtonWidths()
|
void measureLayout()
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(() => props.showComputerUse, () => {
|
||||||
|
void observeLayout()
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(showAtx, (visible) => {
|
watch(showAtx, (visible) => {
|
||||||
@@ -215,7 +238,7 @@ watch(showPasteText, (visible) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const RIGHT_FIXED_PX = 120
|
const OVERFLOW_BUTTON_BUDGET_PX = 36
|
||||||
|
|
||||||
const collapsibleItems = computed(() => {
|
const collapsibleItems = computed(() => {
|
||||||
const items = ITEM_SPECS.slice(3).filter(item => {
|
const items = ITEM_SPECS.slice(3).filter(item => {
|
||||||
@@ -234,7 +257,7 @@ const visibleSet = computed(() => {
|
|||||||
return new Map<CollapsibleItem, 'icon' | 'label'>()
|
return new Map<CollapsibleItem, 'icon' | 'label'>()
|
||||||
}
|
}
|
||||||
|
|
||||||
const available = barWidth.value - RIGHT_FIXED_PX
|
const available = barWidth.value - alwaysRightWidth.value - OVERFLOW_BUTTON_BUDGET_PX
|
||||||
|
|
||||||
let used = 0
|
let used = 0
|
||||||
if (barRef.value) {
|
if (barRef.value) {
|
||||||
@@ -277,6 +300,7 @@ const hasLeftOverflow = computed(() => {
|
|||||||
const hasRightOverflow = computed(() => {
|
const hasRightOverflow = computed(() => {
|
||||||
return collapsibleItems.value.some(i => i.side === 'right' && !visibleSet.value.has(i.id))
|
return collapsibleItems.value.some(i => i.side === 'right' && !visibleSet.value.has(i.id))
|
||||||
})
|
})
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -361,6 +385,14 @@ const hasRightOverflow = computed(() => {
|
|||||||
|
|
||||||
<!-- Right side buttons -->
|
<!-- Right side buttons -->
|
||||||
<ButtonGroup class="shrink-0 ml-1 sm:ml-2">
|
<ButtonGroup class="shrink-0 ml-1 sm:ml-2">
|
||||||
|
<VideoDisplayControls
|
||||||
|
:scale-mode="props.scaleMode"
|
||||||
|
:source-size-available="props.sourceSizeAvailable"
|
||||||
|
@toggle-fullscreen="emit('toggleFullscreen')"
|
||||||
|
@update:scale-mode="emit('update:scaleMode', $event)"
|
||||||
|
@toggle-virtual-keyboard="emit('toggleVirtualKeyboard')"
|
||||||
|
/>
|
||||||
|
|
||||||
<!-- Connection Stats - Adaptive -->
|
<!-- Connection Stats - Adaptive -->
|
||||||
<div v-if="isVisible('stats')">
|
<div v-if="isVisible('stats')">
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
@@ -406,6 +438,7 @@ const hasRightOverflow = computed(() => {
|
|||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger as-child>
|
<TooltipTrigger as-child>
|
||||||
<Button
|
<Button
|
||||||
|
data-fixed-action
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
class="size-8 sm:w-auto p-0 sm:px-2 sm:gap-1.5 text-xs"
|
class="size-8 sm:w-auto p-0 sm:px-2 sm:gap-1.5 text-xs"
|
||||||
@@ -438,52 +471,6 @@ const hasRightOverflow = computed(() => {
|
|||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
|
||||||
v-if="isVisible('settings')"
|
|
||||||
aria-hidden="true"
|
|
||||||
class="mr-4 h-5 w-px shrink-0 -translate-x-px self-center bg-border"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- Virtual Keyboard - Always visible -->
|
|
||||||
<TooltipProvider>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger as-child>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
class="size-8 sm:w-auto p-0 sm:px-2 sm:gap-1.5 text-xs"
|
|
||||||
@click="emit('toggleVirtualKeyboard')"
|
|
||||||
>
|
|
||||||
<Keyboard class="size-3.5 sm:size-4" />
|
|
||||||
<span class="hidden xl:inline">{{ t('actionbar.keyboard') }}</span>
|
|
||||||
</Button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>
|
|
||||||
<p>{{ t('actionbar.keyboardTip') }}</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</TooltipProvider>
|
|
||||||
|
|
||||||
<!-- Fullscreen - Always visible -->
|
|
||||||
<TooltipProvider>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger as-child>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
class="size-8 sm:w-auto p-0 sm:px-2 sm:gap-1.5 text-xs"
|
|
||||||
@click="emit('toggleFullscreen')"
|
|
||||||
>
|
|
||||||
<Maximize class="size-3.5 sm:size-4" />
|
|
||||||
<span class="hidden xl:inline">{{ t('actionbar.fullscreen') }}</span>
|
|
||||||
</Button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>
|
|
||||||
<p>{{ t('actionbar.fullscreenTip') }}</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</TooltipProvider>
|
|
||||||
|
|
||||||
<!-- Overflow Menu - Only show if there are overflowed items -->
|
<!-- Overflow Menu - Only show if there are overflowed items -->
|
||||||
<DropdownMenu v-if="hasOverflow" v-model:open="overflowMenuOpen">
|
<DropdownMenu v-if="hasOverflow" v-model:open="overflowMenuOpen">
|
||||||
<DropdownMenuTrigger as-child>
|
<DropdownMenuTrigger as-child>
|
||||||
|
|||||||
104
web/src/components/VideoDisplayControls.vue
Normal file
104
web/src/components/VideoDisplayControls.vue
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { Keyboard, Maximize, Scaling } from 'lucide-vue-next'
|
||||||
|
import type { VideoScaleMode } from '@/composables/useVideoScaling'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from '@/components/ui/tooltip'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
scaleMode?: VideoScaleMode
|
||||||
|
sourceSizeAvailable?: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'toggleFullscreen'): void
|
||||||
|
(e: 'toggleVirtualKeyboard'): void
|
||||||
|
(e: 'update:scaleMode', mode: VideoScaleMode): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
function toggleScaleMode() {
|
||||||
|
if (!props.sourceSizeAvailable) return
|
||||||
|
emit('update:scaleMode', props.scaleMode === 'actual' ? 'fit' : 'actual')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger as-child>
|
||||||
|
<Button
|
||||||
|
data-fixed-action
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
class="size-8 p-0 text-xs sm:w-auto sm:gap-1.5 sm:px-2"
|
||||||
|
:aria-label="t('actionbar.fullscreen')"
|
||||||
|
@click="emit('toggleFullscreen')"
|
||||||
|
>
|
||||||
|
<Maximize class="size-3.5 sm:size-4" />
|
||||||
|
<span class="hidden xl:inline">{{ t('actionbar.fullscreen') }}</span>
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>{{ t('actionbar.fullscreenTip') }}</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger as-child>
|
||||||
|
<span data-fixed-action class="inline-flex">
|
||||||
|
<Button
|
||||||
|
:variant="props.scaleMode === 'actual' ? 'secondary' : 'ghost'"
|
||||||
|
size="sm"
|
||||||
|
class="min-w-10 rounded-none px-2 text-xs tabular-nums"
|
||||||
|
:disabled="!props.sourceSizeAvailable"
|
||||||
|
:aria-label="t(props.scaleMode === 'actual' ? 'actionbar.fitSizeAria' : 'actionbar.actualSizeAria')"
|
||||||
|
:aria-pressed="props.scaleMode === 'actual'"
|
||||||
|
@click="toggleScaleMode"
|
||||||
|
>
|
||||||
|
<Scaling class="size-3.5" />
|
||||||
|
1:1
|
||||||
|
</Button>
|
||||||
|
</span>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>{{ t(props.scaleMode === 'actual' ? 'actionbar.fitSizeTip' : 'actionbar.actualSizeTip') }}</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger as-child>
|
||||||
|
<Button
|
||||||
|
data-fixed-action
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
class="size-8 p-0 text-xs sm:w-auto sm:gap-1.5 sm:px-2"
|
||||||
|
:aria-label="t('actionbar.keyboard')"
|
||||||
|
@click="emit('toggleVirtualKeyboard')"
|
||||||
|
>
|
||||||
|
<Keyboard class="size-3.5 sm:size-4" />
|
||||||
|
<span class="hidden xl:inline">{{ t('actionbar.keyboard') }}</span>
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>{{ t('actionbar.keyboardTip') }}</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
|
||||||
|
<div
|
||||||
|
data-fixed-action
|
||||||
|
aria-hidden="true"
|
||||||
|
class="mx-2 h-5 w-px shrink-0 self-center bg-border"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
95
web/src/composables/useVideoScaling.ts
Normal file
95
web/src/composables/useVideoScaling.ts
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
import { computed, nextTick, ref, watch } from 'vue'
|
||||||
|
import type { CSSProperties } from 'vue'
|
||||||
|
import { useElementSize } from '@vueuse/core'
|
||||||
|
|
||||||
|
export type VideoScaleMode = 'fit' | 'actual'
|
||||||
|
|
||||||
|
export interface VideoSize {
|
||||||
|
width: number
|
||||||
|
height: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useVideoScaling() {
|
||||||
|
const workspaceRef = ref<HTMLDivElement | null>(null)
|
||||||
|
const scaleMode = ref<VideoScaleMode>('fit')
|
||||||
|
const sourceSize = ref<VideoSize | null>(null)
|
||||||
|
const { width: workspaceWidth, height: workspaceHeight } = useElementSize(workspaceRef)
|
||||||
|
|
||||||
|
const sourceSizeAvailable = computed(() => sourceSize.value !== null)
|
||||||
|
const effectiveScaleMode = computed<VideoScaleMode>(() => (
|
||||||
|
scaleMode.value === 'actual' && sourceSizeAvailable.value ? 'actual' : 'fit'
|
||||||
|
))
|
||||||
|
|
||||||
|
const fittedSize = computed<VideoSize | null>(() => {
|
||||||
|
const source = sourceSize.value
|
||||||
|
if (!source || workspaceWidth.value <= 0 || workspaceHeight.value <= 0) return null
|
||||||
|
|
||||||
|
const scale = Math.min(
|
||||||
|
workspaceWidth.value / source.width,
|
||||||
|
workspaceHeight.value / source.height,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
width: Math.max(1, Math.floor(source.width * scale)),
|
||||||
|
height: Math.max(1, Math.floor(source.height * scale)),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const stageClass = computed(() => effectiveScaleMode.value === 'actual'
|
||||||
|
? 'h-max w-max min-h-full min-w-full'
|
||||||
|
: 'h-full w-full'
|
||||||
|
)
|
||||||
|
|
||||||
|
const containerStyle = computed<CSSProperties>(() => {
|
||||||
|
const size = effectiveScaleMode.value === 'actual' ? sourceSize.value : fittedSize.value
|
||||||
|
if (size) {
|
||||||
|
return {
|
||||||
|
width: `${size.width}px`,
|
||||||
|
height: `${size.height}px`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
minHeight: '120px',
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function updateSourceSize(width: number, height: number) {
|
||||||
|
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) return
|
||||||
|
|
||||||
|
const nextSize = { width: Math.round(width), height: Math.round(height) }
|
||||||
|
if (sourceSize.value?.width === nextSize.width && sourceSize.value.height === nextSize.height) return
|
||||||
|
sourceSize.value = nextSize
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearSourceSize() {
|
||||||
|
sourceSize.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
function setScaleMode(mode: VideoScaleMode) {
|
||||||
|
if (mode === 'actual' && !sourceSizeAvailable.value) return
|
||||||
|
scaleMode.value = mode
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
[effectiveScaleMode, () => sourceSize.value?.width, () => sourceSize.value?.height],
|
||||||
|
async ([mode]) => {
|
||||||
|
if (mode !== 'actual') return
|
||||||
|
await nextTick()
|
||||||
|
workspaceRef.value?.scrollTo({ left: 0, top: 0 })
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
workspaceRef,
|
||||||
|
scaleMode,
|
||||||
|
sourceSize,
|
||||||
|
sourceSizeAvailable,
|
||||||
|
stageClass,
|
||||||
|
containerStyle,
|
||||||
|
updateSourceSize,
|
||||||
|
clearSourceSize,
|
||||||
|
setScaleMode,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -125,6 +125,10 @@ export default {
|
|||||||
settingsTip: 'System settings',
|
settingsTip: 'System settings',
|
||||||
fullscreen: 'Fullscreen',
|
fullscreen: 'Fullscreen',
|
||||||
fullscreenTip: 'Toggle fullscreen mode',
|
fullscreenTip: 'Toggle fullscreen mode',
|
||||||
|
actualSizeAria: 'Display video at original pixel size',
|
||||||
|
actualSizeTip: 'Display at original pixels',
|
||||||
|
fitSizeAria: 'Fit video to window',
|
||||||
|
fitSizeTip: 'Restore fit to window',
|
||||||
videoConfig: 'Video',
|
videoConfig: 'Video',
|
||||||
streamSettings: 'Stream Settings',
|
streamSettings: 'Stream Settings',
|
||||||
deviceSettings: 'Device Settings',
|
deviceSettings: 'Device Settings',
|
||||||
|
|||||||
@@ -125,6 +125,10 @@ export default {
|
|||||||
settingsTip: '系统设置',
|
settingsTip: '系统设置',
|
||||||
fullscreen: '全屏',
|
fullscreen: '全屏',
|
||||||
fullscreenTip: '切换全屏模式',
|
fullscreenTip: '切换全屏模式',
|
||||||
|
actualSizeAria: '按视频原始像素显示',
|
||||||
|
actualSizeTip: '按原始像素显示',
|
||||||
|
fitSizeAria: '使视频适应窗口',
|
||||||
|
fitSizeTip: '恢复适应窗口',
|
||||||
videoConfig: '视频配置',
|
videoConfig: '视频配置',
|
||||||
streamSettings: '流设置',
|
streamSettings: '流设置',
|
||||||
deviceSettings: '设备配置',
|
deviceSettings: '设备配置',
|
||||||
|
|||||||
@@ -10,6 +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 { 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'
|
||||||
@@ -121,7 +122,17 @@ const streamSignalState = ref<StreamSignalState>('ok')
|
|||||||
const streamSignalReason = ref<string | null>(null)
|
const streamSignalReason = ref<string | null>(null)
|
||||||
const streamNextRetryMs = ref<number | null>(null)
|
const streamNextRetryMs = ref<number | null>(null)
|
||||||
|
|
||||||
const videoAspectRatio = ref<string | null>(null)
|
const {
|
||||||
|
workspaceRef: videoWorkspaceRef,
|
||||||
|
scaleMode: videoScaleMode,
|
||||||
|
sourceSize: videoSourceSize,
|
||||||
|
sourceSizeAvailable,
|
||||||
|
stageClass: videoStageClass,
|
||||||
|
containerStyle: videoContainerStyle,
|
||||||
|
updateSourceSize: updateVideoSourceSize,
|
||||||
|
clearSourceSize: clearVideoSourceSize,
|
||||||
|
setScaleMode: setVideoScaleMode,
|
||||||
|
} = useVideoScaling()
|
||||||
|
|
||||||
const backendFps = ref(0)
|
const backendFps = ref(0)
|
||||||
|
|
||||||
@@ -691,22 +702,15 @@ const connectProgress = computed<{ current: number; total: number } | null>(() =
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const videoContainerStyle = computed(() => {
|
function handleWebRTCVideoResize() {
|
||||||
if (!videoAspectRatio.value) {
|
if (videoMode.value === 'mjpeg') return
|
||||||
return {
|
const video = webrtcVideoRef.value
|
||||||
width: '100%',
|
if (!video) return
|
||||||
height: '100%',
|
updateVideoSourceSize(video.videoWidth, video.videoHeight)
|
||||||
maxWidth: '100%',
|
}
|
||||||
maxHeight: '100%',
|
|
||||||
minHeight: '120px',
|
watch(videoLoading, (loading) => {
|
||||||
}
|
if (loading) clearVideoSourceSize()
|
||||||
}
|
|
||||||
return {
|
|
||||||
aspectRatio: videoAspectRatio.value,
|
|
||||||
maxWidth: '100%',
|
|
||||||
maxHeight: '100%',
|
|
||||||
minHeight: '120px',
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const computerUsePanelVisible = computed(() => computerUseOpen.value && !isFullscreen.value)
|
const computerUsePanelVisible = computed(() => computerUseOpen.value && !isFullscreen.value)
|
||||||
@@ -1015,7 +1019,7 @@ function handleVideoLoad() {
|
|||||||
systemStore.setStreamOnline(true)
|
systemStore.setStreamOnline(true)
|
||||||
const img = videoRef.value
|
const img = videoRef.value
|
||||||
if (img && img.naturalWidth && img.naturalHeight) {
|
if (img && img.naturalWidth && img.naturalHeight) {
|
||||||
videoAspectRatio.value = `${img.naturalWidth}/${img.naturalHeight}`
|
updateVideoSourceSize(img.naturalWidth, img.naturalHeight)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1707,6 +1711,7 @@ async function rebindWebRTCVideo() {
|
|||||||
} catch {
|
} catch {
|
||||||
}
|
}
|
||||||
await waitForVideoFirstFrame(webrtcVideoRef.value, 2000)
|
await waitForVideoFirstFrame(webrtcVideoRef.value, 2000)
|
||||||
|
handleWebRTCVideoResize()
|
||||||
clearFrameOverlay()
|
clearFrameOverlay()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1968,9 +1973,6 @@ watch(webrtc.stats, (stats) => {
|
|||||||
if (videoMode.value !== 'mjpeg' && stats.framesPerSecond > 0) {
|
if (videoMode.value !== 'mjpeg' && stats.framesPerSecond > 0) {
|
||||||
backendFps.value = Math.round(stats.framesPerSecond)
|
backendFps.value = Math.round(stats.framesPerSecond)
|
||||||
systemStore.setStreamOnline(true)
|
systemStore.setStreamOnline(true)
|
||||||
if (stats.frameWidth && stats.frameHeight) {
|
|
||||||
videoAspectRatio.value = `${stats.frameWidth}/${stats.frameHeight}`
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}, { deep: true })
|
}, { deep: true })
|
||||||
|
|
||||||
@@ -2052,9 +2054,9 @@ watch(() => webrtc.state.value, (newState, oldState) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
async function toggleFullscreen() {
|
async function toggleFullscreen() {
|
||||||
if (!videoContainerRef.value) return
|
if (!videoWorkspaceRef.value) return
|
||||||
if (!document.fullscreenElement) {
|
if (!document.fullscreenElement) {
|
||||||
await videoContainerRef.value.requestFullscreen()
|
await videoWorkspaceRef.value.requestFullscreen()
|
||||||
isFullscreen.value = true
|
isFullscreen.value = true
|
||||||
} else {
|
} else {
|
||||||
await document.exitFullscreen()
|
await document.exitFullscreen()
|
||||||
@@ -2253,10 +2255,8 @@ function getActiveVideoAspectRatio(): number | null {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!videoAspectRatio.value) return null
|
const source = videoSourceSize.value
|
||||||
const [width, height] = videoAspectRatio.value.split('/').map(Number)
|
return source ? source.width / source.height : null
|
||||||
if (!width || !height) return null
|
|
||||||
return width / height
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getRenderedVideoRect() {
|
function getRenderedVideoRect() {
|
||||||
@@ -2308,6 +2308,20 @@ function getAbsoluteMousePosition(e: MouseEvent) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getLocalRenderedVideoBounds() {
|
||||||
|
const container = videoContainerRef.value
|
||||||
|
const renderedRect = getRenderedVideoRect()
|
||||||
|
if (!container || !renderedRect) return null
|
||||||
|
|
||||||
|
const containerRect = container.getBoundingClientRect()
|
||||||
|
return {
|
||||||
|
left: renderedRect.left - containerRect.left,
|
||||||
|
top: renderedRect.top - containerRect.top,
|
||||||
|
right: renderedRect.left - containerRect.left + renderedRect.width,
|
||||||
|
bottom: renderedRect.top - containerRect.top + renderedRect.height,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function updateLocalCrosshairFromEvent(e: MouseEvent) {
|
function updateLocalCrosshairFromEvent(e: MouseEvent) {
|
||||||
if (!cursorVisible.value) {
|
if (!cursorVisible.value) {
|
||||||
localCrosshairPos.value = null
|
localCrosshairPos.value = null
|
||||||
@@ -2317,7 +2331,8 @@ function updateLocalCrosshairFromEvent(e: MouseEvent) {
|
|||||||
if (!container) return
|
if (!container) return
|
||||||
|
|
||||||
const rect = container.getBoundingClientRect()
|
const rect = container.getBoundingClientRect()
|
||||||
if (rect.width <= 0 || rect.height <= 0) return
|
const bounds = getLocalRenderedVideoBounds()
|
||||||
|
if (rect.width <= 0 || rect.height <= 0 || !bounds) return
|
||||||
|
|
||||||
if (mouseMode.value === 'relative' && isPointerLocked.value) {
|
if (mouseMode.value === 'relative' && isPointerLocked.value) {
|
||||||
updateLocalCrosshairByDelta(e.movementX, e.movementY)
|
updateLocalCrosshairByDelta(e.movementX, e.movementY)
|
||||||
@@ -2325,8 +2340,8 @@ function updateLocalCrosshairFromEvent(e: MouseEvent) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
localCrosshairPos.value = {
|
localCrosshairPos.value = {
|
||||||
x: Math.max(0, Math.min(rect.width, e.clientX - rect.left)),
|
x: Math.max(bounds.left, Math.min(bounds.right, e.clientX - rect.left)),
|
||||||
y: Math.max(0, Math.min(rect.height, e.clientY - rect.top)),
|
y: Math.max(bounds.top, Math.min(bounds.bottom, e.clientY - rect.top)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2339,13 +2354,16 @@ function updateLocalCrosshairByDelta(dx: number, dy: number) {
|
|||||||
const container = videoContainerRef.value
|
const container = videoContainerRef.value
|
||||||
if (!container) return
|
if (!container) return
|
||||||
|
|
||||||
const rect = container.getBoundingClientRect()
|
const bounds = getLocalRenderedVideoBounds()
|
||||||
if (rect.width <= 0 || rect.height <= 0) return
|
if (!bounds) return
|
||||||
|
|
||||||
const current = localCrosshairPos.value ?? { x: rect.width / 2, y: rect.height / 2 }
|
const current = localCrosshairPos.value ?? {
|
||||||
|
x: (bounds.left + bounds.right) / 2,
|
||||||
|
y: (bounds.top + bounds.bottom) / 2,
|
||||||
|
}
|
||||||
localCrosshairPos.value = {
|
localCrosshairPos.value = {
|
||||||
x: Math.max(0, Math.min(rect.width, current.x + dx)),
|
x: Math.max(bounds.left, Math.min(bounds.right, current.x + dx)),
|
||||||
y: Math.max(0, Math.min(rect.height, current.y + dy)),
|
y: Math.max(bounds.top, Math.min(bounds.bottom, current.y + dy)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2821,9 +2839,12 @@ function handlePointerLockChange() {
|
|||||||
if (isPointerLocked.value) {
|
if (isPointerLocked.value) {
|
||||||
mousePosition.value = { x: 0, y: 0 }
|
mousePosition.value = { x: 0, y: 0 }
|
||||||
if (cursorVisible.value && container) {
|
if (cursorVisible.value && container) {
|
||||||
const r = container.getBoundingClientRect()
|
const bounds = getLocalRenderedVideoBounds()
|
||||||
if (r.width > 0 && r.height > 0) {
|
if (bounds) {
|
||||||
localCrosshairPos.value = { x: r.width / 2, y: r.height / 2 }
|
localCrosshairPos.value = {
|
||||||
|
x: (bounds.left + bounds.right) / 2,
|
||||||
|
y: (bounds.top + bounds.bottom) / 2,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3217,7 +3238,10 @@ onUnmounted(() => {
|
|||||||
:show-computer-use="showComputerUse"
|
:show-computer-use="showComputerUse"
|
||||||
:show-paste-text="showPasteText"
|
:show-paste-text="showPasteText"
|
||||||
:show-mic="microphoneTransferEnabled"
|
:show-mic="microphoneTransferEnabled"
|
||||||
|
:scale-mode="videoScaleMode"
|
||||||
|
:source-size-available="sourceSizeAvailable"
|
||||||
@toggle-fullscreen="toggleFullscreen"
|
@toggle-fullscreen="toggleFullscreen"
|
||||||
|
@update:scale-mode="setVideoScaleMode"
|
||||||
@toggle-stats="openStatsSheet"
|
@toggle-stats="openStatsSheet"
|
||||||
@toggle-virtual-keyboard="handleToggleVirtualKeyboard"
|
@toggle-virtual-keyboard="handleToggleVirtualKeyboard"
|
||||||
@toggle-mouse-mode="handleToggleMouseMode"
|
@toggle-mouse-mode="handleToggleMouseMode"
|
||||||
@@ -3235,14 +3259,19 @@ onUnmounted(() => {
|
|||||||
<div
|
<div
|
||||||
class="flex min-w-0 flex-1 items-center justify-center transition-all duration-300"
|
class="flex min-w-0 flex-1 items-center justify-center transition-all duration-300"
|
||||||
:class="{ 'md:pr-1': computerUsePanelVisible }"
|
:class="{ 'md:pr-1': computerUsePanelVisible }"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
ref="videoWorkspaceRef"
|
||||||
|
class="video-workspace relative h-full w-full min-h-[120px] overflow-auto fullscreen:bg-black"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="relative grid place-items-center"
|
||||||
|
:class="videoStageClass"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
ref="videoContainerRef"
|
ref="videoContainerRef"
|
||||||
class="relative bg-black overflow-hidden flex items-center justify-center touch-none"
|
class="relative flex shrink-0 cursor-none items-center justify-center overflow-hidden bg-black touch-none"
|
||||||
:style="videoContainerStyle"
|
:style="videoContainerStyle"
|
||||||
:class="{
|
|
||||||
'cursor-none': true,
|
|
||||||
}"
|
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
@mouseleave="handleMouseLeaveVideo"
|
@mouseleave="handleMouseLeaveVideo"
|
||||||
@pointerdown="handleTouchPointerDown"
|
@pointerdown="handleTouchPointerDown"
|
||||||
@@ -3259,7 +3288,7 @@ onUnmounted(() => {
|
|||||||
v-show="videoMode === 'mjpeg'"
|
v-show="videoMode === 'mjpeg'"
|
||||||
ref="videoRef"
|
ref="videoRef"
|
||||||
:src="mjpegUrl"
|
:src="mjpegUrl"
|
||||||
class="w-full h-full object-contain pointer-events-none select-none"
|
class="size-full object-contain pointer-events-none select-none"
|
||||||
:alt="t('console.videoAlt')"
|
:alt="t('console.videoAlt')"
|
||||||
draggable="false"
|
draggable="false"
|
||||||
@load="handleVideoLoad"
|
@load="handleVideoLoad"
|
||||||
@@ -3268,14 +3297,17 @@ onUnmounted(() => {
|
|||||||
<video
|
<video
|
||||||
v-show="videoMode !== 'mjpeg'"
|
v-show="videoMode !== 'mjpeg'"
|
||||||
ref="webrtcVideoRef"
|
ref="webrtcVideoRef"
|
||||||
class="w-full h-full object-contain"
|
class="size-full object-contain pointer-events-none"
|
||||||
autoplay
|
autoplay
|
||||||
playsinline
|
playsinline
|
||||||
|
@loadedmetadata="handleWebRTCVideoResize"
|
||||||
|
@loadeddata="handleWebRTCVideoResize"
|
||||||
|
@resize="handleWebRTCVideoResize"
|
||||||
/>
|
/>
|
||||||
<img
|
<img
|
||||||
v-if="frameOverlayUrl"
|
v-if="frameOverlayUrl"
|
||||||
:src="frameOverlayUrl"
|
:src="frameOverlayUrl"
|
||||||
class="absolute inset-0 w-full h-full object-contain pointer-events-none"
|
class="absolute inset-0 size-full object-contain pointer-events-none"
|
||||||
alt=""
|
alt=""
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
@@ -3432,6 +3464,8 @@ onUnmounted(() => {
|
|||||||
</Transition>
|
</Transition>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<ComputerUseSheet
|
<ComputerUseSheet
|
||||||
v-if="showComputerUse"
|
v-if="showComputerUse"
|
||||||
v-model:open="computerUseOpen"
|
v-model:open="computerUseOpen"
|
||||||
@@ -3535,4 +3569,9 @@ onUnmounted(() => {
|
|||||||
.fade-leave-to {
|
.fade-leave-to {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.video-workspace:fullscreen {
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user