diff --git a/web/src/components/VideoConfigPopover.vue b/web/src/components/VideoConfigPopover.vue
index 6e9a876a..7d8795eb 100644
--- a/web/src/components/VideoConfigPopover.vue
+++ b/web/src/components/VideoConfigPopover.vue
@@ -33,6 +33,7 @@ import { toConfigFps } from '@/lib/fps'
import { formatVideoDeviceLabel } from '@/lib/video-device-label'
import { useConfigStore } from '@/stores/config'
import { useVideoDeviceConfiguration } from '@/composables/useVideoDeviceConfiguration'
+import type { VideoRotation } from '@/composables/useVideoScaling'
import VideoInputFields from '@/components/VideoInputFields.vue'
export type VideoMode = 'mjpeg' | 'h264' | 'h265' | 'vp8' | 'vp9'
@@ -40,12 +41,14 @@ export type VideoMode = 'mjpeg' | 'h264' | 'h265' | 'vp8' | 'vp9'
const props = defineProps<{
open: boolean
videoMode: VideoMode
+ videoRotation: VideoRotation
side?: 'top' | 'right' | 'bottom' | 'left'
}>()
const emit = defineEmits<{
(e: 'update:open', value: boolean): void
(e: 'update:videoMode', value: VideoMode): void
+ (e: 'update:videoRotation', value: VideoRotation): void
}>()
const { t } = useI18n()
@@ -209,6 +212,7 @@ const currentConfig = computed(() => ({
}))
const buttonText = computed(() => t('actionbar.videoConfig'))
+const videoRotationOptions: VideoRotation[] = [0, 90, 180, 270]
// Available codecs for selection (filtered by backend support and enriched with backend info)
const availableCodecs = computed(() => {
@@ -627,6 +631,27 @@ watch(
diff --git a/web/src/composables/useVideoScaling.ts b/web/src/composables/useVideoScaling.ts
index 06a18b97..43ab7977 100644
--- a/web/src/composables/useVideoScaling.ts
+++ b/web/src/composables/useVideoScaling.ts
@@ -1,18 +1,20 @@
import { computed, nextTick, ref, watch } from 'vue'
-import type { CSSProperties } from 'vue'
+import type { CSSProperties, Ref } from 'vue'
import { useElementSize } from '@vueuse/core'
export type VideoScaleMode = 'fit' | 'actual'
+export type VideoRotation = 0 | 90 | 180 | 270
export interface VideoSize {
width: number
height: number
}
-export function useVideoScaling() {
+export function useVideoScaling(options: { rotation?: Readonly[> } = {}) {
const workspaceRef = ref(null)
const scaleMode = ref('fit')
const sourceSize = ref(null)
+ const rotation = options.rotation ?? ref(0)
const { width: workspaceWidth, height: workspaceHeight } = useElementSize(workspaceRef)
const sourceSizeAvailable = computed(() => sourceSize.value !== null)
@@ -20,8 +22,18 @@ export function useVideoScaling() {
scaleMode.value === 'actual' && sourceSizeAvailable.value ? 'actual' : 'fit'
))
- const fittedSize = computed(() => {
+ const hasQuarterTurn = computed(() => rotation.value === 90 || rotation.value === 270)
+ const rotatedSourceSize = computed(() => {
const source = sourceSize.value
+ if (!source) return null
+
+ return hasQuarterTurn.value
+ ? { width: source.height, height: source.width }
+ : source
+ })
+
+ const fittedSize = computed(() => {
+ const source = rotatedSourceSize.value
if (!source || workspaceWidth.value <= 0 || workspaceHeight.value <= 0) return null
const scale = Math.min(
@@ -40,7 +52,7 @@ export function useVideoScaling() {
)
const containerStyle = computed(() => {
- const size = effectiveScaleMode.value === 'actual' ? sourceSize.value : fittedSize.value
+ const size = effectiveScaleMode.value === 'actual' ? rotatedSourceSize.value : fittedSize.value
if (size) {
return {
width: `${size.width}px`,
@@ -55,6 +67,19 @@ export function useVideoScaling() {
}
})
+ // A quarter turn swaps the displayed dimensions. Keep the video itself at
+ // its unrotated dimensions, then rotate it inside the correctly sized frame.
+ const contentStyle = computed(() => {
+ const size = effectiveScaleMode.value === 'actual' ? rotatedSourceSize.value : fittedSize.value
+ const quarterTurn = hasQuarterTurn.value
+
+ return {
+ width: size ? `${quarterTurn ? size.height : size.width}px` : '100%',
+ height: size ? `${quarterTurn ? size.width : size.height}px` : '100%',
+ transform: `rotate(${rotation.value}deg)`,
+ }
+ })
+
function updateSourceSize(width: number, height: number) {
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) return
@@ -88,6 +113,7 @@ export function useVideoScaling() {
sourceSizeAvailable,
stageClass,
containerStyle,
+ contentStyle,
updateSourceSize,
clearSourceSize,
setScaleMode,
diff --git a/web/src/i18n/en-US.ts b/web/src/i18n/en-US.ts
index 62a68a92..99e30dea 100644
--- a/web/src/i18n/en-US.ts
+++ b/web/src/i18n/en-US.ts
@@ -233,6 +233,7 @@ export default {
streamSettings: 'Stream Settings',
deviceSettings: 'Device Settings',
videoMode: 'Mode',
+ videoRotation: 'Rotation',
selectMode: 'Select mode...',
videoModeHint: 'HTTP uses more bandwidth but offers broad compatibility; WebRTC has stricter network requirements but uses less bandwidth',
videoDevice: 'Device',
diff --git a/web/src/i18n/zh-CN.ts b/web/src/i18n/zh-CN.ts
index 1fd43e7b..c0b0f643 100644
--- a/web/src/i18n/zh-CN.ts
+++ b/web/src/i18n/zh-CN.ts
@@ -233,6 +233,7 @@ export default {
streamSettings: '流设置',
deviceSettings: '设备配置',
videoMode: '视频模式',
+ videoRotation: '视频旋转',
selectMode: '选择模式...',
videoModeHint: 'HTTP 对带宽占用较大但模式兼容性好;WebRTC 对网络要求较高但带宽占用低',
videoDevice: '视频设备',
diff --git a/web/src/views/ConsoleView.vue b/web/src/views/ConsoleView.vue
index 93444222..99afd5b6 100644
--- a/web/src/views/ConsoleView.vue
+++ b/web/src/views/ConsoleView.vue
@@ -10,7 +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 { useVideoScaling, type VideoRotation } from '@/composables/useVideoScaling'
import { useComputerUseSocket, type ComputerUseServerMessage } from '@/composables/useComputerUseSocket'
import { useFeatureVisibility } from '@/composables/useFeatureVisibility'
import { useTheme } from '@/composables/useTheme'
@@ -105,6 +105,19 @@ const consoleEvents = useConsoleEvents({
})
const videoMode = ref('mjpeg')
+const VIDEO_ROTATIONS: VideoRotation[] = [0, 90, 180, 270]
+const storedVideoRotation = Number(localStorage.getItem('videoRotation'))
+const videoRotation = ref(
+ VIDEO_ROTATIONS.includes(storedVideoRotation as VideoRotation)
+ ? storedVideoRotation as VideoRotation
+ : 0,
+)
+
+function setVideoRotation(rotation: VideoRotation) {
+ if (!VIDEO_ROTATIONS.includes(rotation)) return
+ videoRotation.value = rotation
+ localStorage.setItem('videoRotation', String(rotation))
+}
const computerUseOpen = ref(false)
const computerUseSession = ref(null)
const computerUseTimeline = ref([])
@@ -135,10 +148,11 @@ const {
sourceSizeAvailable,
stageClass: videoStageClass,
containerStyle: videoContainerStyle,
+ contentStyle: videoContentStyle,
updateSourceSize: updateVideoSourceSize,
clearSourceSize: clearVideoSourceSize,
setScaleMode: setVideoScaleMode,
-} = useVideoScaling()
+} = useVideoScaling({ rotation: videoRotation })
const backendFps = ref(0)
@@ -2320,6 +2334,13 @@ function getRenderedVideoRect() {
const rect = videoElement.getBoundingClientRect()
if (rect.width <= 0 || rect.height <= 0) return null
+ // For a quarter turn, the transformed element already describes the exact
+ // visible portrait frame. Its original landscape aspect ratio must not be
+ // used to add artificial letterboxing here.
+ if (videoRotation.value === 90 || videoRotation.value === 270) {
+ return rect
+ }
+
const contentAspectRatio = getActiveVideoAspectRatio()
if (!contentAspectRatio) {
return rect
@@ -2349,6 +2370,32 @@ function getRenderedVideoRect() {
}
}
+function rotateAbsolutePosition(x: number, y: number) {
+ switch (videoRotation.value) {
+ case 90:
+ return { x: y, y: 1 - x }
+ case 180:
+ return { x: 1 - x, y: 1 - y }
+ case 270:
+ return { x: 1 - y, y: x }
+ default:
+ return { x, y }
+ }
+}
+
+function rotateRelativeDelta(dx: number, dy: number) {
+ switch (videoRotation.value) {
+ case 90:
+ return { dx: dy, dy: -dx }
+ case 180:
+ return { dx: -dx, dy: -dy }
+ case 270:
+ return { dx: -dy, dy: dx }
+ default:
+ return { dx, dy }
+ }
+}
+
function getAbsoluteMousePosition(e: MouseEvent) {
const rect = getRenderedVideoRect()
if (!rect) return null
@@ -2356,9 +2403,10 @@ function getAbsoluteMousePosition(e: MouseEvent) {
const normalizedX = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
const normalizedY = Math.max(0, Math.min(1, (e.clientY - rect.top) / rect.height))
+ const sourcePosition = rotateAbsolutePosition(normalizedX, normalizedY)
return {
- x: Math.round(normalizedX * 32767),
- y: Math.round(normalizedY * 32767),
+ x: Math.round(sourcePosition.x * 32767),
+ y: Math.round(sourcePosition.y * 32767),
}
}
@@ -2624,11 +2672,12 @@ function handleTouchPointerMove(e: PointerEvent) {
activePointer.lastX += dx
activePointer.lastY += dy
- accumulatedDelta.x += dx
- accumulatedDelta.y += dy
+ const rotatedDelta = rotateRelativeDelta(dx, dy)
+ accumulatedDelta.x += rotatedDelta.dx
+ accumulatedDelta.y += rotatedDelta.dy
mousePosition.value = {
- x: mousePosition.value.x + dx,
- y: mousePosition.value.y + dy,
+ x: mousePosition.value.x + rotatedDelta.dx,
+ y: mousePosition.value.y + rotatedDelta.dy,
}
updateLocalCrosshairByDelta(dx, dy)
requestMouseMoveFlush()
@@ -2699,14 +2748,16 @@ function handleMouseMove(e: MouseEvent) {
const dy = e.movementY
if (dx !== 0 || dy !== 0) {
- accumulatedDelta.x += dx
- accumulatedDelta.y += dy
+ const rotatedDelta = rotateRelativeDelta(dx, dy)
+ accumulatedDelta.x += rotatedDelta.dx
+ accumulatedDelta.y += rotatedDelta.dy
requestMouseMoveFlush()
}
+ const rotatedDelta = rotateRelativeDelta(dx, dy)
mousePosition.value = {
- x: mousePosition.value.x + dx,
- y: mousePosition.value.y + dy,
+ x: mousePosition.value.x + rotatedDelta.dx,
+ y: mousePosition.value.y + rotatedDelta.dy,
}
}
}
@@ -3304,13 +3355,15 @@ onUnmounted(() => {
]