diff --git a/app/scrcpy.1 b/app/scrcpy.1 index 95d5133d..ce7e6a4a 100644 --- a/app/scrcpy.1 +++ b/app/scrcpy.1 @@ -323,7 +323,8 @@ Create a new display with the specified resolution and density. If not provided, Examples: \-\-new\-display=1920x1080 - \-\-new\-display=1920x1080/420 + \-\-new\-display=1920x1080/420 # force 420 dpi + \-\-new\-display=1920x1080@24 # 24 fps (Android >= 14) \-\-new\-display # main display size and density \-\-new\-display=/240 # main display size and 240 dpi diff --git a/app/src/cli.c b/app/src/cli.c index 3f2d23cb..82c9a773 100644 --- a/app/src/cli.c +++ b/app/src/cli.c @@ -586,14 +586,17 @@ static const struct sc_option options[] = { { .longopt_id = OPT_NEW_DISPLAY, .longopt = "new-display", - .argdesc = "[x][/]", + .argdesc = "[x][/][@]", .optional_arg = true, .text = "Create a new display with the specified resolution and " "density. If not provided, they default to the main display " "dimensions and DPI.\n" + "From Android 14, it is also possible to request a frame rate. " + "If not provided, it defaults to 60 fps.\n" "Examples:\n" " --new-display=1920x1080\n" " --new-display=1920x1080/420 # force 420 dpi\n" + " --new-display=1920x1080@24 # 24 fps (Android >= 14)\n" " --new-display # main display size and density\n" " --new-display=/240 # main display size and 240 dpi", }, diff --git a/doc/virtual_display.md b/doc/virtual_display.md index 7523c118..78c06997 100644 --- a/doc/virtual_display.md +++ b/doc/virtual_display.md @@ -7,6 +7,7 @@ To mirror a new virtual display instead of the device screen: ```bash scrcpy --new-display=1920x1080 scrcpy --new-display=1920x1080/420 # force 420 dpi +scrcpy --new-display=1920x1080@24 # 24 fps (Android >= 14) scrcpy --new-display # use the main display size and density scrcpy --new-display=/240 # use the main display size and 240 dpi ``` diff --git a/server/src/main/java/com/genymobile/scrcpy/Options.java b/server/src/main/java/com/genymobile/scrcpy/Options.java index 43cc790d..86b1ddad 100644 --- a/server/src/main/java/com/genymobile/scrcpy/Options.java +++ b/server/src/main/java/com/genymobile/scrcpy/Options.java @@ -566,36 +566,68 @@ public class Options { } } - private static NewDisplay parseNewDisplay(String newDisplay) { - // Possible inputs: - // - "" (empty string) - // - "x/" - // - "x" - // - "/" + static NewDisplay parseNewDisplay(String newDisplay) { + // Input in the form "[x][/][@]" (each [] block is optional) + // For convenience, the order of dpi and fps does not matter. if (newDisplay.isEmpty()) { return new NewDisplay(); } - String[] tokens = newDisplay.split("/"); + String sizeString = null; + String dpiString = null; + String fpsString = null; - Size size; - if (!tokens[0].isEmpty()) { - size = parseSize(tokens[0]); - } else { - size = null; - } - - int dpi; - if (tokens.length >= 2) { - dpi = Integer.parseInt(tokens[1]); - if (dpi <= 0) { - throw new IllegalArgumentException("Invalid non-positive dpi: " + tokens[1]); + String s = newDisplay; + while (true) { + int slashIndex = s.indexOf('/'); + int atIndex = s.indexOf('@'); + int lastSepIndex = Math.max(slashIndex, atIndex); + if (lastSepIndex == -1) { + if (!s.isEmpty()) { + sizeString = s; + } + break; + } else { + char lastSep = newDisplay.charAt(lastSepIndex); + if (lastSep == '@') { + if (fpsString != null) { + throw new IllegalArgumentException("Invalid new display format: '@' may not appear twice"); + } + fpsString = s.substring(lastSepIndex + 1); + } else { + assert lastSep == '/'; + if (dpiString != null) { + throw new IllegalArgumentException("Invalid new display format: '/' may not appear twice"); + } + dpiString = s.substring(lastSepIndex + 1); + } + s = s.substring(0, lastSepIndex); } - } else { - dpi = 0; } - return new NewDisplay(size, dpi); + Size size = null; + int dpi = 0; + float fps = 0; + + if (sizeString != null) { + size = parseSize(sizeString); + } + + if (dpiString != null) { + dpi = Integer.parseInt(dpiString); + if (dpi <= 0) { + throw new IllegalArgumentException("Invalid non-positive dpi: " + dpiString); + } + } + + if (fpsString != null) { + fps = Float.parseFloat(fpsString); + if (fps < 0) { + throw new IllegalArgumentException("Invalid negative fps: " + fpsString); + } + } + + return new NewDisplay(size, dpi, fps); } private static Pair parseCaptureOrientation(String value) { diff --git a/server/src/main/java/com/genymobile/scrcpy/device/NewDisplay.java b/server/src/main/java/com/genymobile/scrcpy/device/NewDisplay.java index 3aa2996a..207787d0 100644 --- a/server/src/main/java/com/genymobile/scrcpy/device/NewDisplay.java +++ b/server/src/main/java/com/genymobile/scrcpy/device/NewDisplay.java @@ -3,14 +3,16 @@ package com.genymobile.scrcpy.device; public final class NewDisplay { private Size size; private int dpi; + private float fps; public NewDisplay() { - // Auto size and dpi + // Auto size, dpi and fps } - public NewDisplay(Size size, int dpi) { + public NewDisplay(Size size, int dpi, float fps) { this.size = size; this.dpi = dpi; + this.fps = fps; } public Size getSize() { @@ -21,6 +23,10 @@ public final class NewDisplay { return dpi; } + public float getFps() { + return fps; + } + public boolean hasExplicitSize() { return size != null; } @@ -28,4 +34,8 @@ public final class NewDisplay { public boolean hasExplicitDpi() { return dpi != 0; } + + public boolean hasExplicitFps() { + return fps != 0; + } } diff --git a/server/src/main/java/com/genymobile/scrcpy/video/NewDisplayCapture.java b/server/src/main/java/com/genymobile/scrcpy/video/NewDisplayCapture.java index d92141af..abe90933 100644 --- a/server/src/main/java/com/genymobile/scrcpy/video/NewDisplayCapture.java +++ b/server/src/main/java/com/genymobile/scrcpy/video/NewDisplayCapture.java @@ -14,8 +14,10 @@ import com.genymobile.scrcpy.util.AffineMatrix; import com.genymobile.scrcpy.util.Ln; import com.genymobile.scrcpy.wrappers.ServiceManager; +import android.annotation.SuppressLint; import android.graphics.Rect; import android.hardware.display.VirtualDisplay; +import android.hardware.display.VirtualDisplayConfig; import android.os.Build; import android.view.Surface; @@ -161,6 +163,7 @@ public class NewDisplayCapture extends SurfaceCapture { displayTransform = AffineMatrix.multiplyAll(displayRotationMatrix, eventTransform); } + @SuppressLint("WrongConstant") public void startNew(Surface surface) { int virtualDisplayId; try { @@ -182,10 +185,30 @@ public class NewDisplayCapture extends SurfaceCapture { | VIRTUAL_DISPLAY_FLAG_DEVICE_DISPLAY_GROUP; } } - virtualDisplay = ServiceManager.getDisplayManager() - .createNewVirtualDisplay("scrcpy", displaySize.getWidth(), displaySize.getHeight(), dpi, surface, flags); + + // Since Android 14, it is possible to request a display frame rate: + // + // It defaults to 60 fps: + // + float fps = newDisplay.getFps(); + if (fps > 0) { + if (Build.VERSION.SDK_INT >= AndroidVersions.API_34_ANDROID_14) { + VirtualDisplayConfig.Builder builder = new VirtualDisplayConfig.Builder( + "scrcpy", displaySize.getWidth(), displaySize.getHeight(), dpi); + builder.setFlags(flags); + builder.setSurface(surface); + builder.setRequestedRefreshRate(fps); + virtualDisplay = ServiceManager.getDisplayManager().createNewVirtualDisplay(builder.build()); + } else { + throw new UnsupportedOperationException("Setting the virtual display frame rate (@" + fps + ") requires Android >= 14"); + } + } else { + virtualDisplay = ServiceManager.getDisplayManager() + .createNewVirtualDisplay("scrcpy", displaySize.getWidth(), displaySize.getHeight(), dpi, surface, flags); + } virtualDisplayId = virtualDisplay.getDisplay().getDisplayId(); - Ln.i("New display: " + displaySize.getWidth() + "x" + displaySize.getHeight() + "/" + dpi + " (id=" + virtualDisplayId + ")"); + String fpsString = fps > 0 ? "@" + fps : ""; + Ln.i("New display: " + displaySize.getWidth() + "x" + displaySize.getHeight() + "/" + dpi + fpsString + " (id=" + virtualDisplayId + ")"); displaySizeMonitor.start(virtualDisplayId, this::invalidate); } catch (Exception e) { diff --git a/server/src/main/java/com/genymobile/scrcpy/wrappers/DisplayManager.java b/server/src/main/java/com/genymobile/scrcpy/wrappers/DisplayManager.java index c1519729..5c137541 100644 --- a/server/src/main/java/com/genymobile/scrcpy/wrappers/DisplayManager.java +++ b/server/src/main/java/com/genymobile/scrcpy/wrappers/DisplayManager.java @@ -11,6 +11,7 @@ import android.annotation.SuppressLint; import android.annotation.TargetApi; import android.content.Context; import android.hardware.display.VirtualDisplay; +import android.hardware.display.VirtualDisplayConfig; import android.os.Handler; import android.view.Display; import android.view.Surface; @@ -174,6 +175,11 @@ public final class DisplayManager { return getAndroidDisplayManager().createVirtualDisplay(name, width, height, dpi, surface, flags); } + @TargetApi(AndroidVersions.API_34_ANDROID_14) + public VirtualDisplay createNewVirtualDisplay(VirtualDisplayConfig config) throws ReflectiveOperationException { + return getAndroidDisplayManager().createVirtualDisplay(config); + } + private Method getRequestDisplayPowerMethod() throws NoSuchMethodException { if (requestDisplayPowerMethod == null) { requestDisplayPowerMethod = manager.getClass().getMethod("requestDisplayPower", int.class, boolean.class); diff --git a/server/src/test/java/com/genymobile/scrcpy/OptionsTest.java b/server/src/test/java/com/genymobile/scrcpy/OptionsTest.java new file mode 100644 index 00000000..c33caf6f --- /dev/null +++ b/server/src/test/java/com/genymobile/scrcpy/OptionsTest.java @@ -0,0 +1,120 @@ +package com.genymobile.scrcpy; + +import com.genymobile.scrcpy.device.NewDisplay; +import com.genymobile.scrcpy.device.Size; + +import org.junit.Assert; +import org.junit.Test; + +public class OptionsTest { + + @Test + public void testParseNewDisplayEmpty() { + NewDisplay newDisplay = Options.parseNewDisplay(""); + Assert.assertFalse(newDisplay.hasExplicitSize()); + Assert.assertFalse(newDisplay.hasExplicitDpi()); + Assert.assertFalse(newDisplay.hasExplicitFps()); + Assert.assertNull(newDisplay.getSize()); + Assert.assertEquals(0, newDisplay.getDpi()); + Assert.assertEquals(0, newDisplay.getFps(), 0); + } + + @Test + public void testParseNewDisplaySizeOnly() { + NewDisplay newDisplay = Options.parseNewDisplay("1920x1080"); + Assert.assertTrue(newDisplay.hasExplicitSize()); + Assert.assertFalse(newDisplay.hasExplicitDpi()); + Assert.assertFalse(newDisplay.hasExplicitFps()); + Assert.assertEquals(new Size(1920, 1080), newDisplay.getSize()); + Assert.assertEquals(0, newDisplay.getDpi()); + Assert.assertEquals(0, newDisplay.getFps(), 0); + } + + @Test + public void testParseNewDisplayDpiOnly() { + NewDisplay newDisplay = Options.parseNewDisplay("/240"); + Assert.assertFalse(newDisplay.hasExplicitSize()); + Assert.assertTrue(newDisplay.hasExplicitDpi()); + Assert.assertFalse(newDisplay.hasExplicitFps()); + Assert.assertNull(newDisplay.getSize()); + Assert.assertEquals(240, newDisplay.getDpi()); + Assert.assertEquals(0, newDisplay.getFps(), 0); + } + + @Test + public void testParseNewDisplayFpsOnly() { + NewDisplay newDisplay = Options.parseNewDisplay("@30"); + Assert.assertFalse(newDisplay.hasExplicitSize()); + Assert.assertFalse(newDisplay.hasExplicitDpi()); + Assert.assertTrue(newDisplay.hasExplicitFps()); + Assert.assertNull(newDisplay.getSize()); + Assert.assertEquals(0, newDisplay.getDpi()); + Assert.assertEquals(30, newDisplay.getFps(), 0); + } + + @Test + public void testParseNewDisplaySizeAndDpi() { + NewDisplay newDisplay = Options.parseNewDisplay("1920x1080/240"); + Assert.assertTrue(newDisplay.hasExplicitSize()); + Assert.assertTrue(newDisplay.hasExplicitDpi()); + Assert.assertFalse(newDisplay.hasExplicitFps()); + Assert.assertEquals(new Size(1920, 1080), newDisplay.getSize()); + Assert.assertEquals(240, newDisplay.getDpi()); + Assert.assertEquals(0, newDisplay.getFps(), 0); + } + + @Test + public void testParseNewDisplaySizeAndFps() { + NewDisplay newDisplay = Options.parseNewDisplay("1920x1080@30"); + Assert.assertTrue(newDisplay.hasExplicitSize()); + Assert.assertFalse(newDisplay.hasExplicitDpi()); + Assert.assertTrue(newDisplay.hasExplicitFps()); + Assert.assertEquals(new Size(1920, 1080), newDisplay.getSize()); + Assert.assertEquals(0, newDisplay.getDpi()); + Assert.assertEquals(30, newDisplay.getFps(), 0); + } + + @Test + public void testParseNewDisplaySizeAndDpiAndFps() { + NewDisplay newDisplay = Options.parseNewDisplay("1920x1080/240@30"); + Assert.assertTrue(newDisplay.hasExplicitSize()); + Assert.assertTrue(newDisplay.hasExplicitDpi()); + Assert.assertTrue(newDisplay.hasExplicitFps()); + Assert.assertEquals(new Size(1920, 1080), newDisplay.getSize()); + Assert.assertEquals(240, newDisplay.getDpi()); + Assert.assertEquals(30, newDisplay.getFps(), 0); + } + + @Test + public void testParseNewDisplaySizeAndFpsAndDpi() { + NewDisplay newDisplay = Options.parseNewDisplay("1920x1080@30/240"); + Assert.assertTrue(newDisplay.hasExplicitSize()); + Assert.assertTrue(newDisplay.hasExplicitDpi()); + Assert.assertTrue(newDisplay.hasExplicitFps()); + Assert.assertEquals(new Size(1920, 1080), newDisplay.getSize()); + Assert.assertEquals(240, newDisplay.getDpi()); + Assert.assertEquals(30, newDisplay.getFps(), 0); + } + + @Test + public void testParseNewDisplayDpiAndFps() { + NewDisplay newDisplay = Options.parseNewDisplay("/240@30"); + Assert.assertFalse(newDisplay.hasExplicitSize()); + Assert.assertTrue(newDisplay.hasExplicitDpi()); + Assert.assertTrue(newDisplay.hasExplicitFps()); + Assert.assertNull(newDisplay.getSize()); + Assert.assertEquals(240, newDisplay.getDpi()); + Assert.assertEquals(30, newDisplay.getFps(), 0); + } + + @Test + public void testParseNewDisplayFpsAndDpi() { + NewDisplay newDisplay = Options.parseNewDisplay("@30/240"); + Assert.assertFalse(newDisplay.hasExplicitSize()); + Assert.assertTrue(newDisplay.hasExplicitDpi()); + Assert.assertTrue(newDisplay.hasExplicitFps()); + Assert.assertNull(newDisplay.getSize()); + Assert.assertEquals(240, newDisplay.getDpi()); + Assert.assertEquals(30, newDisplay.getFps(), 0); + } +}