Skip to content

Add fit-width-pan-Y viewer mode for portrait streams on landscape monitors#1867

Draft
REMvisual wants to merge 2 commits intomoonlight-stream:masterfrom
REMvisual:feature/fit-width-pan-y
Draft

Add fit-width-pan-Y viewer mode for portrait streams on landscape monitors#1867
REMvisual wants to merge 2 commits intomoonlight-stream:masterfrom
REMvisual:feature/fit-width-pan-y

Conversation

@REMvisual
Copy link
Copy Markdown

Motivation

When streaming a host with a portrait (vertical) display resolution (e.g. 1440×2560) onto a landscape client monitor (e.g. 1920×1080), the default aspect-preserving scaler letterboxes the stream into a narrow centered column with two large unused bars on the left and right. Most of the local screen is wasted.

This PR adds an opt-in viewer-side mode that:

  • Scales the stream to fill the window width instead of the height.
  • Pans vertically based on the local cursor's Y position, so the user can move the cursor up/down to scroll the visible region of the remote desktop.

The mode is purely client-side: the host's resolution, capture pipeline, and protocol input semantics are completely unchanged. Coordinates flow through the same scaleSourceToDestinationSurfaceLiSendMousePositionEvent path as today; the new dst rect (with negative y and h > window_h) inverts correctly through the existing transform.

Design

The implementation hinges on the fact that every renderer backend and the input/touch code already routes through one helper, StreamUtils::scaleSourceToDestinationSurface. By making that helper read a new pref and produce a different dst rect when active, all 11 renderer backends and the input-coordinate transform pick up the new behavior with zero per-call-site changes.

When the pref is on and the default math would have letterboxed left/right (i.e. stream is taller than window aspect), the helper:

  • Leaves dst.x = 0, dst.w = window_w (full window width)
  • Sets dst.h = ceil(window_w * src.h / src.w) (extends past window vertically — the zoomed height)
  • Sets dst.y -= panOffset (negative, off-screen top)

The pan offset is stored in a static QAtomicInt and updated by SdlInputHandler::handleMouseMotionEvent from the cursor's window-relative Y. Writing it from the input handler before computing the inverse transform guarantees the renderer's next frame and this event's LiSendMousePositionEvent use the same offset, keeping click coordinates consistent with what the user sees.

Renderer compatibility

Backend Mechanism Status
SDL (sdlvid) SDL_RenderSetViewport with extended rect — SDL clips to renderer output
D3D11VA Vertices in NDC → GPU rasterizer clips out-of-bounds
DXVA2 Uses dst rect for IDirect3DDevice9::StretchRect after scale ✅ (clips natively)
Vulkan / libplacebo pl_render_image with crop extending past target — clips per libplacebo blit semantics
EGL / VAAPI / VDPAU / MMAL / CUDA Viewport / shader-based scaling, GPU clips
DRM (KMS) Hardware plane bounds via configurePlane are strict ⚠️ Unaddressed in this change — a per-renderer clamp would be needed

Cursor confinement (updatePointerRegionLock) clamps dst to the window via SDL_IntersectRect, so SDL_SetWindowMouseRect always receives an in-window rect.

Scope / non-goals

  • Default behavior unchanged. Pref defaults to off; existing letterbox path is untouched.
  • Only kicks in when useful. When stream + window aspect would have letterboxed top/bottom (landscape stream into landscape window), the pref is bypassed naturally.
  • Absolute mouse mode only for v1. Relative-mouse paths (LiSendMouseMoveEvent for games) don't scale deltas in this PR — the feature is a desktop-streaming tool. Easy follow-up to scale xrel/yrel by 1/zoom in fit-width mode.
  • No pan smoothing yet. The pan offset tracks the cursor 1:1 each frame. Adding an exponential lerp inside the scaler is a small follow-up.
  • DRM backend would need a clamp + source-rect adjustment to feed the hardware plane a valid in-bounds rect.

Files

  • app/settings/streamingpreferences.{h,cpp} — new fitWidthPanY bool, QSettings key fitwidthpany
  • app/streaming/streamutils.{h,cpp} — atomic pan-offset getter/setter, scaler reads pref
  • app/streaming/input/mouse.cpp — pan offset write before inverse transform; SDL_IntersectRect on confinement
  • app/gui/SettingsView.qml — checkbox in Input Settings group

Testing

Draft because I have not yet built and run this against a real portrait host. Will mark ready-for-review after a local smoke test on Windows (D3D11VA + SDL fallback). Happy to take review feedback in parallel.

REMvisual and others added 2 commits April 28, 2026 18:56
…itors

When streaming a portrait remote desktop (e.g. 1440x2560) onto a landscape
monitor, the default aspect-preserving scaler letterboxes the stream into a
narrow centered column with large unused bars. This adds an opt-in viewer-side
mode that scales the stream to fill the window width and pans vertically based
on the local cursor's Y position - so the user sees a zoomed-in view of the
remote and can move their cursor to scroll it.

The mode is purely client-side: the host's resolution, capture, and protocol
input semantics are unchanged. Coordinates sent to the host go through the
existing scaleSourceToDestinationSurface->LiSendMousePositionEvent path - the
new dst rect (with negative y and h > window_h) inverts correctly through the
existing transform.

Implementation:
- New StreamingPreferences::fitWidthPanY (default off) with QSettings persistence
- StreamUtils::scaleSourceToDestinationSurface now reads the pref. When the
  default path would letterbox left/right (stream taller than window), it
  instead extends dst vertically and offsets dst.y by the most recently
  computed pan amount. All 11 renderer call sites and the input/touch call
  sites pick this up with no per-call-site changes.
- Pan offset is stored in a static QAtomicInt and updated by the absolute-mouse
  motion handler from the cursor's window-relative Y. This guarantees the
  inverse transform in mouse.cpp uses the same offset that the next render
  frame will display.
- updatePointerRegionLock clamps dst to the window via SDL_IntersectRect so
  SDL_SetWindowMouseRect receives a valid in-window rect when fit-width is
  active and the unclamped dst extends offscreen.
- Settings UI checkbox in the Input Settings group.

Renderer compatibility: SDL (viewport-clipped), D3D11VA (NDC rasterizer-clipped),
DXVA2, EGL, VAAPI, Vulkan/libplacebo, MMAL, CUDA, VDPAU all handle the
extended dst correctly via natural clipping. DRM (kernel mode setting via
configurePlane) is the one platform where strict hardware plane bounds may
need a per-renderer clamp; not addressed in this change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant