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