mirror of
https://github.com/mofeng-git/One-KVM.git
synced 2026-09-12 18:44:25 +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 { useRouter } from 'vue-router'
|
||||
import { useSystemStore } from '@/stores/system'
|
||||
import type { VideoScaleMode } from '@/composables/useVideoScaling'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { ButtonGroup } from '@/components/ui/button-group'
|
||||
import {
|
||||
@@ -32,9 +33,7 @@ import {
|
||||
import {
|
||||
ClipboardPaste,
|
||||
HardDrive,
|
||||
Keyboard,
|
||||
Settings,
|
||||
Maximize,
|
||||
Power,
|
||||
BarChart3,
|
||||
Terminal,
|
||||
@@ -47,6 +46,7 @@ import VideoConfigPopover, { type VideoMode } from '@/components/VideoConfigPopo
|
||||
import HidConfigPopover from '@/components/HidConfigPopover.vue'
|
||||
import AudioConfigPopover from '@/components/AudioConfigPopover.vue'
|
||||
import MsdDialog from '@/components/MsdDialog.vue'
|
||||
import VideoDisplayControls from '@/components/VideoDisplayControls.vue'
|
||||
|
||||
const { t, locale } = useI18n()
|
||||
const router = useRouter()
|
||||
@@ -69,6 +69,8 @@ const props = defineProps<{
|
||||
showComputerUse?: boolean
|
||||
showPasteText?: boolean
|
||||
showMic?: boolean
|
||||
scaleMode?: VideoScaleMode
|
||||
sourceSizeAvailable?: boolean
|
||||
}>()
|
||||
const showStats = computed(() => (props.videoMode ?? 'mjpeg') !== 'mjpeg')
|
||||
const showPasteText = computed(() => props.showPasteText !== false)
|
||||
@@ -77,6 +79,7 @@ const showMic = computed(() => props.showMic === true)
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'toggleFullscreen'): void
|
||||
(e: 'update:scaleMode', mode: VideoScaleMode): void
|
||||
(e: 'toggleStats'): void
|
||||
(e: 'toggleVirtualKeyboard'): void
|
||||
(e: 'toggleMouseMode'): void
|
||||
@@ -130,7 +133,8 @@ const openMobilePaste = () => openFromOverflow(() => {
|
||||
const barRef = ref<HTMLElement | null>(null)
|
||||
const measureRef = ref<HTMLElement | null>(null)
|
||||
const barWidth = ref(0)
|
||||
let resizeObserver: ResizeObserver | null = null
|
||||
const alwaysRightWidth = ref(152)
|
||||
let layoutResizeObserver: ResizeObserver | null = null
|
||||
|
||||
type CollapsibleItem =
|
||||
| 'video' | 'audio' | 'hid'
|
||||
@@ -157,16 +161,18 @@ const ITEM_SPECS: ItemSpec[] = [
|
||||
const measuredWidths = ref<Map<CollapsibleItem, { icon: number; label: number }>>(new Map())
|
||||
const measurementReady = ref(false)
|
||||
|
||||
const measureButtonWidths = async () => {
|
||||
const measureLayout = async () => {
|
||||
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 }>()
|
||||
|
||||
for (const spec of ITEM_SPECS) {
|
||||
const iconEl = measureRef.value.querySelector(`[data-measure="${spec.id}-icon"]`) as HTMLElement
|
||||
const labelEl = measureRef.value.querySelector(`[data-measure="${spec.id}-label"]`) as HTMLElement
|
||||
|
||||
const iconEl = measureContainer.querySelector(`[data-measure="${spec.id}-icon"]`) as HTMLElement
|
||||
const labelEl = measureContainer.querySelector(`[data-measure="${spec.id}-label"]`) as HTMLElement
|
||||
if (iconEl && labelEl) {
|
||||
newWidths.set(spec.id, {
|
||||
icon: Math.ceil(iconEl.offsetWidth) + 8,
|
||||
@@ -174,31 +180,48 @@ const measureButtonWidths = async () => {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
const observeLayout = async () => {
|
||||
await measureLayout()
|
||||
layoutResizeObserver?.disconnect()
|
||||
layoutResizeObserver = new ResizeObserver(() => {
|
||||
void measureLayout()
|
||||
})
|
||||
if (barRef.value) layoutResizeObserver.observe(barRef.value)
|
||||
barRef.value?.querySelectorAll('[data-fixed-action]').forEach((element) => {
|
||||
layoutResizeObserver?.observe(element)
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (barRef.value) {
|
||||
resizeObserver = new ResizeObserver((entries) => {
|
||||
const entry = entries[0]
|
||||
if (entry) barWidth.value = entry.contentRect.width
|
||||
})
|
||||
resizeObserver.observe(barRef.value)
|
||||
barWidth.value = barRef.value.clientWidth
|
||||
}
|
||||
|
||||
measureButtonWidths()
|
||||
void observeLayout()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
resizeObserver?.disconnect()
|
||||
onUnmounted(() => {
|
||||
layoutResizeObserver?.disconnect()
|
||||
})
|
||||
|
||||
watch(locale, () => {
|
||||
measurementReady.value = false
|
||||
measureButtonWidths()
|
||||
void measureLayout()
|
||||
})
|
||||
|
||||
watch(() => props.showComputerUse, () => {
|
||||
void observeLayout()
|
||||
})
|
||||
|
||||
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 items = ITEM_SPECS.slice(3).filter(item => {
|
||||
@@ -234,7 +257,7 @@ const visibleSet = computed(() => {
|
||||
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
|
||||
if (barRef.value) {
|
||||
@@ -277,6 +300,7 @@ const hasLeftOverflow = computed(() => {
|
||||
const hasRightOverflow = computed(() => {
|
||||
return collapsibleItems.value.some(i => i.side === 'right' && !visibleSet.value.has(i.id))
|
||||
})
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -361,6 +385,14 @@ const hasRightOverflow = computed(() => {
|
||||
|
||||
<!-- Right side buttons -->
|
||||
<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 -->
|
||||
<div v-if="isVisible('stats')">
|
||||
<TooltipProvider>
|
||||
@@ -406,6 +438,7 @@ const hasRightOverflow = computed(() => {
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<Button
|
||||
data-fixed-action
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
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>
|
||||
</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 -->
|
||||
<DropdownMenu v-if="hasOverflow" v-model:open="overflowMenuOpen">
|
||||
<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',
|
||||
fullscreen: 'Fullscreen',
|
||||
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',
|
||||
streamSettings: 'Stream Settings',
|
||||
deviceSettings: 'Device Settings',
|
||||
|
||||
@@ -125,6 +125,10 @@ export default {
|
||||
settingsTip: '系统设置',
|
||||
fullscreen: '全屏',
|
||||
fullscreenTip: '切换全屏模式',
|
||||
actualSizeAria: '按视频原始像素显示',
|
||||
actualSizeTip: '按原始像素显示',
|
||||
fitSizeAria: '使视频适应窗口',
|
||||
fitSizeTip: '恢复适应窗口',
|
||||
videoConfig: '视频配置',
|
||||
streamSettings: '流设置',
|
||||
deviceSettings: '设备配置',
|
||||
|
||||
@@ -10,6 +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 { useComputerUseSocket, type ComputerUseServerMessage } from '@/composables/useComputerUseSocket'
|
||||
import { useFeatureVisibility } from '@/composables/useFeatureVisibility'
|
||||
import { useTheme } from '@/composables/useTheme'
|
||||
@@ -121,7 +122,17 @@ const streamSignalState = ref<StreamSignalState>('ok')
|
||||
const streamSignalReason = ref<string | 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)
|
||||
|
||||
@@ -691,22 +702,15 @@ const connectProgress = computed<{ current: number; total: number } | null>(() =
|
||||
}
|
||||
})
|
||||
|
||||
const videoContainerStyle = computed(() => {
|
||||
if (!videoAspectRatio.value) {
|
||||
return {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
maxWidth: '100%',
|
||||
maxHeight: '100%',
|
||||
minHeight: '120px',
|
||||
}
|
||||
}
|
||||
return {
|
||||
aspectRatio: videoAspectRatio.value,
|
||||
maxWidth: '100%',
|
||||
maxHeight: '100%',
|
||||
minHeight: '120px',
|
||||
}
|
||||
function handleWebRTCVideoResize() {
|
||||
if (videoMode.value === 'mjpeg') return
|
||||
const video = webrtcVideoRef.value
|
||||
if (!video) return
|
||||
updateVideoSourceSize(video.videoWidth, video.videoHeight)
|
||||
}
|
||||
|
||||
watch(videoLoading, (loading) => {
|
||||
if (loading) clearVideoSourceSize()
|
||||
})
|
||||
|
||||
const computerUsePanelVisible = computed(() => computerUseOpen.value && !isFullscreen.value)
|
||||
@@ -1015,7 +1019,7 @@ function handleVideoLoad() {
|
||||
systemStore.setStreamOnline(true)
|
||||
const img = videoRef.value
|
||||
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 {
|
||||
}
|
||||
await waitForVideoFirstFrame(webrtcVideoRef.value, 2000)
|
||||
handleWebRTCVideoResize()
|
||||
clearFrameOverlay()
|
||||
}
|
||||
}
|
||||
@@ -1968,9 +1973,6 @@ watch(webrtc.stats, (stats) => {
|
||||
if (videoMode.value !== 'mjpeg' && stats.framesPerSecond > 0) {
|
||||
backendFps.value = Math.round(stats.framesPerSecond)
|
||||
systemStore.setStreamOnline(true)
|
||||
if (stats.frameWidth && stats.frameHeight) {
|
||||
videoAspectRatio.value = `${stats.frameWidth}/${stats.frameHeight}`
|
||||
}
|
||||
}
|
||||
}, { deep: true })
|
||||
|
||||
@@ -2052,9 +2054,9 @@ watch(() => webrtc.state.value, (newState, oldState) => {
|
||||
})
|
||||
|
||||
async function toggleFullscreen() {
|
||||
if (!videoContainerRef.value) return
|
||||
if (!videoWorkspaceRef.value) return
|
||||
if (!document.fullscreenElement) {
|
||||
await videoContainerRef.value.requestFullscreen()
|
||||
await videoWorkspaceRef.value.requestFullscreen()
|
||||
isFullscreen.value = true
|
||||
} else {
|
||||
await document.exitFullscreen()
|
||||
@@ -2253,10 +2255,8 @@ function getActiveVideoAspectRatio(): number | null {
|
||||
}
|
||||
}
|
||||
|
||||
if (!videoAspectRatio.value) return null
|
||||
const [width, height] = videoAspectRatio.value.split('/').map(Number)
|
||||
if (!width || !height) return null
|
||||
return width / height
|
||||
const source = videoSourceSize.value
|
||||
return source ? source.width / source.height : null
|
||||
}
|
||||
|
||||
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) {
|
||||
if (!cursorVisible.value) {
|
||||
localCrosshairPos.value = null
|
||||
@@ -2317,7 +2331,8 @@ function updateLocalCrosshairFromEvent(e: MouseEvent) {
|
||||
if (!container) return
|
||||
|
||||
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) {
|
||||
updateLocalCrosshairByDelta(e.movementX, e.movementY)
|
||||
@@ -2325,8 +2340,8 @@ function updateLocalCrosshairFromEvent(e: MouseEvent) {
|
||||
}
|
||||
|
||||
localCrosshairPos.value = {
|
||||
x: Math.max(0, Math.min(rect.width, e.clientX - rect.left)),
|
||||
y: Math.max(0, Math.min(rect.height, e.clientY - rect.top)),
|
||||
x: Math.max(bounds.left, Math.min(bounds.right, e.clientX - rect.left)),
|
||||
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
|
||||
if (!container) return
|
||||
|
||||
const rect = container.getBoundingClientRect()
|
||||
if (rect.width <= 0 || rect.height <= 0) return
|
||||
const bounds = getLocalRenderedVideoBounds()
|
||||
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 = {
|
||||
x: Math.max(0, Math.min(rect.width, current.x + dx)),
|
||||
y: Math.max(0, Math.min(rect.height, current.y + dy)),
|
||||
x: Math.max(bounds.left, Math.min(bounds.right, current.x + dx)),
|
||||
y: Math.max(bounds.top, Math.min(bounds.bottom, current.y + dy)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2821,9 +2839,12 @@ function handlePointerLockChange() {
|
||||
if (isPointerLocked.value) {
|
||||
mousePosition.value = { x: 0, y: 0 }
|
||||
if (cursorVisible.value && container) {
|
||||
const r = container.getBoundingClientRect()
|
||||
if (r.width > 0 && r.height > 0) {
|
||||
localCrosshairPos.value = { x: r.width / 2, y: r.height / 2 }
|
||||
const bounds = getLocalRenderedVideoBounds()
|
||||
if (bounds) {
|
||||
localCrosshairPos.value = {
|
||||
x: (bounds.left + bounds.right) / 2,
|
||||
y: (bounds.top + bounds.bottom) / 2,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3217,7 +3238,10 @@ onUnmounted(() => {
|
||||
:show-computer-use="showComputerUse"
|
||||
:show-paste-text="showPasteText"
|
||||
:show-mic="microphoneTransferEnabled"
|
||||
:scale-mode="videoScaleMode"
|
||||
:source-size-available="sourceSizeAvailable"
|
||||
@toggle-fullscreen="toggleFullscreen"
|
||||
@update:scale-mode="setVideoScaleMode"
|
||||
@toggle-stats="openStatsSheet"
|
||||
@toggle-virtual-keyboard="handleToggleVirtualKeyboard"
|
||||
@toggle-mouse-mode="handleToggleMouseMode"
|
||||
@@ -3237,29 +3261,34 @@ onUnmounted(() => {
|
||||
:class="{ 'md:pr-1': computerUsePanelVisible }"
|
||||
>
|
||||
<div
|
||||
ref="videoContainerRef"
|
||||
class="relative bg-black overflow-hidden flex items-center justify-center touch-none"
|
||||
:style="videoContainerStyle"
|
||||
:class="{
|
||||
'cursor-none': true,
|
||||
}"
|
||||
tabindex="0"
|
||||
@mouseleave="handleMouseLeaveVideo"
|
||||
@pointerdown="handleTouchPointerDown"
|
||||
@pointermove="handleTouchPointerMove"
|
||||
@pointerup="handleTouchPointerUp"
|
||||
@pointercancel="handleTouchPointerCancel"
|
||||
@mousemove="handleMouseMove"
|
||||
@mousedown="handleMouseDown"
|
||||
@mouseup="handleMouseUp"
|
||||
@wheel.prevent="handleWheel"
|
||||
@contextmenu="handleContextMenu"
|
||||
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
|
||||
ref="videoContainerRef"
|
||||
class="relative flex shrink-0 cursor-none items-center justify-center overflow-hidden bg-black touch-none"
|
||||
:style="videoContainerStyle"
|
||||
tabindex="0"
|
||||
@mouseleave="handleMouseLeaveVideo"
|
||||
@pointerdown="handleTouchPointerDown"
|
||||
@pointermove="handleTouchPointerMove"
|
||||
@pointerup="handleTouchPointerUp"
|
||||
@pointercancel="handleTouchPointerCancel"
|
||||
@mousemove="handleMouseMove"
|
||||
@mousedown="handleMouseDown"
|
||||
@mouseup="handleMouseUp"
|
||||
@wheel.prevent="handleWheel"
|
||||
@contextmenu="handleContextMenu"
|
||||
>
|
||||
<img
|
||||
v-show="videoMode === 'mjpeg'"
|
||||
ref="videoRef"
|
||||
: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')"
|
||||
draggable="false"
|
||||
@load="handleVideoLoad"
|
||||
@@ -3268,14 +3297,17 @@ onUnmounted(() => {
|
||||
<video
|
||||
v-show="videoMode !== 'mjpeg'"
|
||||
ref="webrtcVideoRef"
|
||||
class="w-full h-full object-contain"
|
||||
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 w-full h-full object-contain pointer-events-none"
|
||||
class="absolute inset-0 size-full object-contain pointer-events-none"
|
||||
alt=""
|
||||
/>
|
||||
<div
|
||||
@@ -3430,6 +3462,8 @@ onUnmounted(() => {
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ComputerUseSheet
|
||||
@@ -3535,4 +3569,9 @@ onUnmounted(() => {
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.video-workspace:fullscreen {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user