feat: 添加视频窗口等比例缩放按钮

This commit is contained in:
mofeng-git
2026-07-29 19:32:46 +08:00
parent d414557d74
commit cb3fd3e646
6 changed files with 362 additions and 129 deletions

View File

@@ -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>

View 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>

View 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,
}
}

View File

@@ -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',

View File

@@ -125,6 +125,10 @@ export default {
settingsTip: '系统设置', settingsTip: '系统设置',
fullscreen: '全屏', fullscreen: '全屏',
fullscreenTip: '切换全屏模式', fullscreenTip: '切换全屏模式',
actualSizeAria: '按视频原始像素显示',
actualSizeTip: '按原始像素显示',
fitSizeAria: '使视频适应窗口',
fitSizeTip: '恢复适应窗口',
videoConfig: '视频配置', videoConfig: '视频配置',
streamSettings: '流设置', streamSettings: '流设置',
deviceSettings: '设备配置', deviceSettings: '设备配置',

View File

@@ -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>