Add resizable virtual display feature

Introduce `--flex-display` to continuously resize the virtual display
to match the window.
This commit is contained in:
Romain Vimont 2026-04-17 08:30:25 +02:00
parent 00c941ab03
commit 3ca88616d2
18 changed files with 219 additions and 19 deletions

View file

@ -66,6 +66,7 @@ public class Options {
private NewDisplay newDisplay;
private boolean vdDestroyContent = true;
private boolean vdSystemDecorations = true;
private boolean flexDisplay;
private Orientation.Lock captureOrientationLock = Orientation.Lock.Unlocked;
private Orientation captureOrientation = Orientation.Orient0;
@ -258,6 +259,10 @@ public class Options {
return vdSystemDecorations;
}
public boolean getFlexDisplay() {
return flexDisplay;
}
public boolean getList() {
return listEncoders || listDisplays || listCameras || listCameraSizes || listApps;
}
@ -506,6 +511,9 @@ public class Options {
case "vd_system_decorations":
options.vdSystemDecorations = Boolean.parseBoolean(value);
break;
case "flex_display":
options.flexDisplay = Boolean.parseBoolean(value);
break;
case "capture_orientation":
Pair<Orientation.Lock, Orientation> pair = parseCaptureOrientation(value);
options.captureOrientationLock = pair.first;

View file

@ -14,6 +14,7 @@ import com.genymobile.scrcpy.util.Ln;
import com.genymobile.scrcpy.util.LogUtils;
import com.genymobile.scrcpy.video.CameraCapture;
import com.genymobile.scrcpy.video.CaptureControl;
import com.genymobile.scrcpy.video.NewDisplayCapture;
import com.genymobile.scrcpy.video.SurfaceCapture;
import com.genymobile.scrcpy.video.VideoSource;
import com.genymobile.scrcpy.video.VirtualDisplayListener;
@ -370,6 +371,9 @@ public class Controller implements AsyncProcessor, VirtualDisplayListener {
case ControlMessage.TYPE_START_APP:
startAppAsync(msg.getText());
return true;
case ControlMessage.TYPE_RESIZE_DISPLAY:
resizeDisplay(msg.getWidth(), msg.getHeight());
return true;
default:
// fall through
}
@ -819,4 +823,9 @@ public class Controller implements AsyncProcessor, VirtualDisplayListener {
surfaceCapture.getCaptureControl().reset(CaptureControl.RESET_REASON_CLIENT_RESET);
}
}
private void resizeDisplay(int width, int height) {
NewDisplayCapture newDisplayCapture = (NewDisplayCapture) surfaceCapture;
newDisplayCapture.resizeDisplay(width, height);
}
}

View file

@ -46,4 +46,9 @@ public enum Orientation {
public int getRotation() {
return ordinal() & 3;
}
public boolean isSwap() {
// width and height are swapped on 90-degree rotations
return (ordinal() & 1) != 0;
}
}

View file

@ -93,6 +93,10 @@ public final class Size {
return new Size(w, h);
}
public boolean isAligned(int alignment) {
return width / alignment * alignment == width && height / alignment * alignment == height;
}
private static int round(int value, int alignment) {
return (value + (alignment / 2)) / alignment * alignment;
}

View file

@ -7,6 +7,7 @@ public class CaptureControl {
public static final int RESET_REASON_TERMINATE = 1;
public static final int RESET_REASON_SIZE_CHANGED = 1 << 1;
public static final int RESET_REASON_CLIENT_RESET = 1 << 2;
public static final int RESET_REASON_CLIENT_RESIZED = 1 << 2;
private int reset = 0;

View file

@ -58,12 +58,16 @@ public class NewDisplayCapture extends SurfaceCapture {
private final float angle;
private final boolean vdDestroyContent;
private final boolean vdSystemDecorations;
private final boolean flexDisplay;
private VirtualDisplay virtualDisplay;
private Size videoSize;
private Size displaySize; // the logical size of the display (including rotation)
private Size physicalSize; // the physical size of the display (without rotation)
private Size pendingClientResized;
private Size latestSize;
private int dpi;
public NewDisplayCapture(VirtualDisplayListener vdListener, Options options) {
@ -79,13 +83,30 @@ public class NewDisplayCapture extends SurfaceCapture {
this.angle = options.getAngle();
this.vdDestroyContent = options.getVDDestroyContent();
this.vdSystemDecorations = options.getVDSystemDecorations();
this.flexDisplay = options.getFlexDisplay();
}
@Override
protected void init() {
if (flexDisplay && crop != null) {
throw new IllegalArgumentException("Flex display does not support cropping");
}
if (getVideoConstraints().getMaxSize() != 0) {
// A maxSize request constrains the resulting size while preserving the aspect ratio, which is meaningless for a flex display
throw new IllegalArgumentException("Flex display does not support explicit maxSize constraint");
}
displaySize = newDisplay.getSize();
dpi = newDisplay.getDpi();
if (displaySize == null || dpi == 0) {
if (flexDisplay) {
// Hardcode default values if not defined
if (displaySize == null) {
displaySize = new Size(1280, 960);
}
if (dpi == 0) {
dpi = 160;
}
} else if (displaySize == null || dpi == 0) {
DisplayInfo displayInfo = ServiceManager.getDisplayManager().getDisplayInfo(0);
if (displayInfo != null) {
mainDisplaySize = displayInfo.getSize();
@ -103,16 +124,20 @@ public class NewDisplayCapture extends SurfaceCapture {
@Override
public void prepare() {
VideoConstraints constraints = getVideoConstraints();
int displayRotation;
if (virtualDisplay == null) {
if (!newDisplay.hasExplicitSize()) {
if (displaySize == null) {
assert !flexDisplay;
displaySize = mainDisplaySize;
}
// Align the physical display size to avoid unnecessary mismatches with the output size
displaySize = displaySize.align(getVideoConstraints().getAlignment());
if (!newDisplay.hasExplicitDpi()) {
if (dpi == 0) {
assert !flexDisplay;
dpi = scaleDpi(mainDisplaySize, mainDisplayDpi, displaySize);
}
@ -124,8 +149,27 @@ public class NewDisplayCapture extends SurfaceCapture {
dpi = displayInfo.getDpi();
displayRotation = displayInfo.getRotation();
// Align the physical display size to avoid unnecessary mismatches with the output size
displaySize = displayInfo.getSize().align(getVideoConstraints().getAlignment());
Size pendingClientResized = consumeClientResized();
if (pendingClientResized != null) {
assert pendingClientResized.isAligned(getVideoConstraints().getAlignment()) : "pendingClientResized ust be aligned";
displaySize = pendingClientResized;
if (captureOrientation.isSwap()) {
displaySize = displaySize.rotate();
}
Size vdSize = displaySize;
if ((displayRotation % 2) != 0) {
vdSize = vdSize.rotate();
}
displayMonitor.expectChange(new DisplayProperties(vdSize, displayRotation));
if (Ln.isEnabled(Ln.Level.VERBOSE)) {
Ln.v(getClass().getSimpleName() + ": virtualDisplay.resize(" + vdSize.getWidth() + ", " + vdSize.getHeight() + ")");
}
virtualDisplay.resize(vdSize.getWidth(), vdSize.getHeight(), dpi);
} else {
// Align the physical display size to avoid unnecessary mismatches with the output size
displaySize = displayInfo.getSize().align(getVideoConstraints().getAlignment());
}
}
VideoFilter filter = new VideoFilter(displaySize);
@ -139,7 +183,7 @@ public class NewDisplayCapture extends SurfaceCapture {
filter.addAngle(angle);
Size outputSize = filter.getOutputSize();
Size filteredSize = outputSize.constrain(getVideoConstraints());
Size filteredSize = outputSize.constrain(constraints);
if (!filteredSize.equals(outputSize)) {
filter.addResize(filteredSize);
}
@ -148,6 +192,7 @@ public class NewDisplayCapture extends SurfaceCapture {
// DisplayInfo gives the oriented size (so videoSize includes the display rotation)
videoSize = filter.getOutputSize();
setLatestSize(videoSize);
// However, the virtual display video always remains in its original orientation, so it must be rotated manually.
// This additional display rotation must not be included in the input events transform (the expected coordinates are already in the
@ -257,4 +302,51 @@ public class NewDisplayCapture extends SurfaceCapture {
int num = size.getMax();
return initialDpi * num / den;
}
public synchronized void resizeDisplay(int width, int height) {
if (!flexDisplay) {
throw new IllegalStateException("Cannot resize a non-flex display");
}
Size newSize = new Size(width, height).constrain(getVideoConstraints());
if (Ln.isEnabled(Ln.Level.VERBOSE)) {
Ln.v(getClass().getSimpleName() + ": resizeDisplay(" + width + ", " + height + ")");
Ln.v(getClass().getSimpleName() + ": constrained size=" + newSize);
}
if (newSize.equals(pendingClientResized)) {
// Already requested
if (Ln.isEnabled(Ln.Level.VERBOSE)) {
Ln.v(getClass().getSimpleName() + ": new size already requested (" + newSize + ")");
}
return;
}
Size latestSize = getLatestSize(); // with synchro
if (newSize.equals(latestSize)) {
if (Ln.isEnabled(Ln.Level.VERBOSE)) {
Ln.v(getClass().getSimpleName() + ": requested new size (" + newSize + ") is already the latest one");
}
return;
}
pendingClientResized = newSize;
if (Ln.isEnabled(Ln.Level.VERBOSE)) {
Ln.v(getClass().getSimpleName() + ": reset (" + newSize + ")");
}
getCaptureControl().reset(CaptureControl.RESET_REASON_CLIENT_RESIZED);
}
private synchronized Size consumeClientResized() {
Size size = pendingClientResized;
pendingClientResized = null;
return size;
}
private synchronized Size getLatestSize() {
return latestSize;
}
private synchronized void setLatestSize(Size latestSize) {
this.latestSize = latestSize;
}
}

View file

@ -128,7 +128,11 @@ public class SurfaceEncoder implements AsyncProcessor {
} else {
if (!captureControl.isResetRequested()) {
// If a reset is requested during encode(), it will interrupt the encoding by an EOS
streamer.writeSessionMeta(size.getWidth(), size.getHeight(), false);
// The reset is due to a resize initiated by the client
boolean clientResize = (resetReasons & CaptureControl.RESET_REASON_CLIENT_RESIZED) != 0
&& (resetReasons & CaptureControl.RESET_REASON_SIZE_CHANGED) == 0;
streamer.writeSessionMeta(size.getWidth(), size.getHeight(), clientResize);
encode(mediaCodec, streamer);
}