From 53674cb7f29d20e3f0482ef7125f54f40cd66c4e Mon Sep 17 00:00:00 2001 From: Compot Developer Date: Mon, 12 Oct 2026 12:38:30 +0000 Subject: [PATCH] fix: fit the video inside the screen and refit on size changes The view width was derived from the screen height only, so on screens narrower than the stream (16:10 and 4:3 tablets) the left and right edges were cut off (fixes #17). - fitInside() picks the largest size with the stream's aspect ratio that fits in the container (letterbox or pillarbox). - A layout-change listener on the root refits the video when the container size changes: rotation, multi-window, and Android 16 ignoring the fixed landscape orientation on large screens (fixes #31). - Zoom is reset on refit, since it depends on the view size. --- .../java/com/openipc/decoder/Decoder.java | 46 +++++++++++++++--- .../com/openipc/decoder/VideoFitTest.java | 47 +++++++++++++++++++ 2 files changed, 87 insertions(+), 6 deletions(-) create mode 100644 android/app/src/test/java/com/openipc/decoder/VideoFitTest.java diff --git a/android/app/src/main/java/com/openipc/decoder/Decoder.java b/android/app/src/main/java/com/openipc/decoder/Decoder.java index 10859687..e1532ee8 100755 --- a/android/app/src/main/java/com/openipc/decoder/Decoder.java +++ b/android/app/src/main/java/com/openipc/decoder/Decoder.java @@ -118,6 +118,10 @@ public class Decoder extends Activity // Screenshot private boolean isTakingScreenshot; + // Stream size, for fitting the video view into the screen + private int mVideoWidth; + private int mVideoHeight; + // Codec/bitrate state for status overlay (pulled from RtspClient) private volatile boolean codecH265; private volatile String audioCodec = AudioConfig.NONE; @@ -201,6 +205,10 @@ public void onCodecChanged(boolean h265, AudioConfig audio) { } }); final View menuAnchor = findViewById(R.id.decoder); + // Rotation or a resized window: fit the video again (#31) + menuAnchor.addOnLayoutChangeListener((v, l, t, r, b, ol, ot, or, ob) -> { + if (r - l != or - ol || b - t != ob - ot) fitVideo(); + }); mGestureDetector = new GestureDetector(this, new GestureDetector.SimpleOnGestureListener() { @Override public boolean onScroll(MotionEvent e1, MotionEvent e2, float dx, float dy) { if (mZoomScale > ZOOM_MIN) { @@ -765,15 +773,41 @@ private void updateResolution(int width, int height) { if (width < 64 || height < 64) return; Log.d(TAG, "Resolution update: " + width + "x" + height); runOnUiThread(() -> { - int surfaceH = mSurface.getHeight(); - if (surfaceH == 0) surfaceH = getResources().getDisplayMetrics().heightPixels; - int surfaceW = (int) ((float) surfaceH / height * width); - FrameLayout.LayoutParams params = new FrameLayout.LayoutParams(surfaceW, surfaceH); - params.gravity = Gravity.CENTER; - mSurface.setLayoutParams(params); + mVideoWidth = width; + mVideoHeight = height; + fitVideo(); }); } + /** + * Size the video view to fit inside the screen with the stream's aspect ratio + * (letterbox or pillarbox). Re-run when the container size changes: rotation, + * multi-window, and Android 16 ignoring the fixed orientation on large screens. + */ + private void fitVideo() { + if (mVideoWidth <= 0 || mVideoHeight <= 0) return; + View box = (View) mSurface.getParent(); + int boxW = box.getWidth(); + int boxH = box.getHeight(); + if (boxW == 0 || boxH == 0) { + boxW = getResources().getDisplayMetrics().widthPixels; + boxH = getResources().getDisplayMetrics().heightPixels; + } + int[] size = fitInside(mVideoWidth, mVideoHeight, boxW, boxH); + FrameLayout.LayoutParams params = new FrameLayout.LayoutParams(size[0], size[1]); + params.gravity = Gravity.CENTER; + mSurface.setLayoutParams(params); + resetZoom(); + } + + /** Largest size with the video's aspect ratio that fits inside the box (#17). */ + static int[] fitInside(int videoW, int videoH, int boxW, int boxH) { + double scale = Math.min((double) boxW / videoW, (double) boxH / videoH); + int w = (int) Math.round(videoW * scale); + int h = (int) Math.round(videoH * scale); + return new int[] {Math.min(w, boxW), Math.min(h, boxH)}; + } + private void applyZoomTransform() { Matrix matrix = new Matrix(); float cx = mSurface.getWidth() / 2f; diff --git a/android/app/src/test/java/com/openipc/decoder/VideoFitTest.java b/android/app/src/test/java/com/openipc/decoder/VideoFitTest.java new file mode 100644 index 00000000..325d8720 --- /dev/null +++ b/android/app/src/test/java/com/openipc/decoder/VideoFitTest.java @@ -0,0 +1,47 @@ +/* + * Copyright (c) OpenIPC https://openipc.org Prosperity Public License 3.0.0 + * + * VideoFitTest.java — fitting the video view inside the screen + * + */ + +package com.openipc.decoder; + +import static org.junit.Assert.assertArrayEquals; + +import org.junit.Test; + +public class VideoFitTest { + @Test + public void sameAspectFillsTheScreen() { + assertArrayEquals(new int[] {1920, 1080}, Decoder.fitInside(1280, 720, 1920, 1080)); + } + + @Test + public void narrowerScreenGetsBarsTopAndBottom() { + // #17: 16:9 video on a 16:10 tablet is no longer cropped left and right + assertArrayEquals(new int[] {1920, 1080}, Decoder.fitInside(1920, 1080, 1920, 1200)); + // 4:3 tablet + assertArrayEquals(new int[] {1024, 576}, Decoder.fitInside(1920, 1080, 1024, 768)); + } + + @Test + public void widerScreenGetsBarsLeftAndRight() { + // 4:3 camera on a 16:9 TV + assertArrayEquals(new int[] {1440, 1080}, Decoder.fitInside(640, 480, 1920, 1080)); + // 21:9 phone + assertArrayEquals(new int[] {1920, 1080}, Decoder.fitInside(1920, 1080, 2520, 1080)); + } + + @Test + public void portraitScreen() { + // #31: Android 16 may show the app in portrait on large screens + assertArrayEquals(new int[] {1200, 675}, Decoder.fitInside(1920, 1080, 1200, 1920)); + } + + @Test + public void neverLargerThanTheBox() { + int[] s = Decoder.fitInside(1921, 1081, 1000, 563); + assertArrayEquals(new int[] {1000, 563}, s); + } +}