diff --git a/app/src/main/java/org/schabi/newpipe/player/Player.java b/app/src/main/java/org/schabi/newpipe/player/Player.java
index 23e803fee..05dfe84cb 100644
--- a/app/src/main/java/org/schabi/newpipe/player/Player.java
+++ b/app/src/main/java/org/schabi/newpipe/player/Player.java
@@ -45,6 +45,7 @@
import android.os.Build;
import android.os.Handler;
import android.provider.Settings;
+import android.text.InputType;
import android.util.DisplayMetrics;
import android.util.Log;
import android.util.TypedValue;
@@ -71,6 +72,7 @@
import androidx.core.content.ContextCompat;
import androidx.core.graphics.Insets;
import androidx.core.view.GestureDetectorCompat;
+import androidx.core.view.MenuCompat;
import androidx.core.view.ViewCompat;
import androidx.core.view.WindowInsetsCompat;
import androidx.fragment.app.FragmentManager;
@@ -310,12 +312,19 @@ public final class Player implements
private static final int POPUP_MENU_ID_PLAYBACK_SPEED = 79;
private static final int POPUP_MENU_ID_CAPTION = 89;
private static final int POPUP_MENU_ID_AUDIO_TRACK = 99;
+ private static final int POPUP_MENU_ID_DISPLAY_MODE = 109;
+ private static final int POPUP_MENU_ID_ASPECT_RATIO = 119;
private boolean isSomePopupMenuVisible = false;
private PopupMenu qualityPopupMenu;
private PopupMenu playbackSpeedPopupMenu;
private PopupMenu captionPopupMenu;
private PopupMenu audioTrackPopupMenu;
+ private PopupMenu displayModePopupMenu;
+
+ // Aspect ratio forced by the user, 0 means "auto" (use the video's own aspect ratio)
+ private float forcedAspectRatio;
+ private float videoNaturalAspectRatio;
/*//////////////////////////////////////////////////////////////////////////
// Popup player
@@ -494,8 +503,7 @@ private void initViews(@NonNull final PlayerBinding playerBinding) {
binding = playerBinding;
setupSubtitleView();
- binding.resizeTextView
- .setText(PlayerHelper.resizeTypeOf(context, binding.surfaceView.getResizeMode()));
+ updateDisplayModeButtonText();
binding.playbackSeekBar.getThumb()
.setColorFilter(new PorterDuffColorFilter(Color.RED, PorterDuff.Mode.SRC_IN));
@@ -509,6 +517,7 @@ private void initViews(@NonNull final PlayerBinding playerBinding) {
playbackSpeedPopupMenu = new PopupMenu(context, binding.playbackSpeed);
captionPopupMenu = new PopupMenu(themeWrapper, binding.captionTextView);
audioTrackPopupMenu = new PopupMenu(themeWrapper, binding.audioTrackTextView);
+ displayModePopupMenu = new PopupMenu(themeWrapper, binding.resizeTextView);
binding.progressBarLoadingPanel.getIndeterminateDrawable()
.setColorFilter(new PorterDuffColorFilter(Color.WHITE, PorterDuff.Mode.MULTIPLY));
@@ -3529,6 +3538,13 @@ private void onMetadataChanged(@NonNull final StreamInfo info) {
Log.d(TAG, "Playback - onMetadataChanged() called, playing: " + info.getName());
}
+ // a forced aspect ratio is a per-video correction, don't carry it over to the next one;
+ // it temporarily forced the resize mode to Fit, so restore the persisted resize mode
+ if (forcedAspectRatio > 0) {
+ forcedAspectRatio = 0.0f;
+ setResizeMode(PlayerHelper.retrieveResizeModeFromPrefs(this));
+ }
+
initThumbnail(info.getThumbnailUrl());
registerStreamViewed();
updateStreamRelatedViews();
@@ -4178,6 +4194,9 @@ private void closeAllPopupMenus() {
if (captionPopupMenu != null) {
captionPopupMenu.dismiss();
}
+ if (displayModePopupMenu != null) {
+ displayModePopupMenu.dismiss();
+ }
isSomePopupMenuVisible = false;
}
//endregion
@@ -4362,7 +4381,7 @@ public void onClick(final View v) {
Log.d(TAG, "onClick() called with: v = [" + v + "]");
}
if (v.getId() == binding.resizeTextView.getId()) {
- onResizeClicked();
+ onDisplayModeClicked();
} else if (v.getId() == binding.captionTextView.getId()) {
onCaptionClicked();
} else if (v.getId() == binding.audioTrackTextView.getId()) {
@@ -4594,13 +4613,149 @@ private void setupScreenRotationButton() {
private void setResizeMode(@AspectRatioFrameLayout.ResizeMode final int resizeMode) {
binding.surfaceView.setResizeMode(resizeMode);
- binding.resizeTextView.setText(PlayerHelper.resizeTypeOf(context, resizeMode));
+ updateDisplayModeButtonText();
}
- void onResizeClicked() {
- if (binding != null) {
- setResizeMode(nextResizeModeAndSaveToPrefs(this, binding.surfaceView.getResizeMode()));
+ /**
+ * Updates the display-mode button label: the forced aspect ratio takes precedence over the
+ * resize mode, since selecting an aspect ratio is what the user sees applied.
+ */
+ private void updateDisplayModeButtonText() {
+ binding.resizeTextView.setText(forcedAspectRatio > 0
+ ? PlayerHelper.aspectRatioNameOf(forcedAspectRatio)
+ : PlayerHelper.resizeTypeOf(context, binding.surfaceView.getResizeMode()));
+ }
+
+ void onDisplayModeClicked() {
+ if (DEBUG) {
+ Log.d(TAG, "onDisplayModeClicked() called");
+ }
+ if (displayModePopupMenu == null) {
+ return;
+ }
+ // rebuild on every open so the checkmark reflects the current resize mode / forced ratio
+ buildDisplayModeMenu();
+ displayModePopupMenu.show();
+ isSomePopupMenuVisible = true;
+ }
+
+ /**
+ * Builds the single display-mode menu that combines the resize modes (Fit / Fill / Zoom) with
+ * the forced aspect ratios (1:1 / 4:3 / ... / Custom). Picking a resize mode clears any forced
+ * aspect ratio; picking an aspect ratio applies it with the resize mode set to Fit.
+ */
+ private void buildDisplayModeMenu() {
+ if (displayModePopupMenu == null) {
+ return;
+ }
+ final Menu menu = displayModePopupMenu.getMenu();
+ menu.removeGroup(POPUP_MENU_ID_DISPLAY_MODE);
+ menu.removeGroup(POPUP_MENU_ID_ASPECT_RATIO);
+ // draw a divider between the resize-mode group and the aspect-ratio group
+ MenuCompat.setGroupDividerEnabled(menu, true);
+ displayModePopupMenu.setOnDismissListener(this);
+
+ // a forced aspect ratio takes precedence: when active, no resize mode is the "current" one
+ final boolean ratioActive = forcedAspectRatio > 0;
+ final int currentResizeMode = binding.surfaceView.getResizeMode();
+ MenuItem activeItem = null;
+
+ int order = 0;
+ for (final int resizeMode : new int[]{
+ AspectRatioFrameLayout.RESIZE_MODE_FIT,
+ AspectRatioFrameLayout.RESIZE_MODE_FILL,
+ AspectRatioFrameLayout.RESIZE_MODE_ZOOM}) {
+ final MenuItem resizeItem = menu.add(POPUP_MENU_ID_DISPLAY_MODE, order, order,
+ PlayerHelper.resizeTypeOf(context, resizeMode));
+ resizeItem.setOnMenuItemClickListener(menuItem -> {
+ onResizeModeSelected(resizeMode);
+ return true;
+ });
+ if (!ratioActive && resizeMode == currentResizeMode) {
+ activeItem = resizeItem;
+ }
+ order++;
+ }
+
+ for (int i = 0; i < PlayerHelper.ASPECT_RATIO_VALUES.length; i++) {
+ final float ratio = PlayerHelper.ASPECT_RATIO_VALUES[i];
+ final MenuItem ratioItem = menu.add(POPUP_MENU_ID_ASPECT_RATIO, order, order,
+ PlayerHelper.ASPECT_RATIO_LABELS[i]);
+ ratioItem.setOnMenuItemClickListener(menuItem -> {
+ setForcedAspectRatio(ratio);
+ return true;
+ });
+ if (ratioActive && Math.abs(forcedAspectRatio - ratio) < 0.001f) {
+ activeItem = ratioItem;
+ }
+ order++;
+ }
+
+ final MenuItem customItem = menu.add(POPUP_MENU_ID_ASPECT_RATIO, order, order,
+ R.string.aspect_ratio_custom);
+ customItem.setOnMenuItemClickListener(menuItem -> {
+ openCustomAspectRatioDialog();
+ return true;
+ });
+ // a forced ratio that matches none of the presets is a custom value
+ if (ratioActive && activeItem == null) {
+ activeItem = customItem;
}
+
+ menu.setGroupCheckable(POPUP_MENU_ID_DISPLAY_MODE, true, true);
+ menu.setGroupCheckable(POPUP_MENU_ID_ASPECT_RATIO, true, true);
+ if (activeItem != null) {
+ activeItem.setChecked(true);
+ }
+ }
+
+ private void onResizeModeSelected(@AspectRatioFrameLayout.ResizeMode final int resizeMode) {
+ // a resize mode supersedes any forced aspect ratio, which would otherwise have no effect
+ forcedAspectRatio = 0.0f;
+ if (videoNaturalAspectRatio > 0) {
+ binding.surfaceView.setAspectRatio(videoNaturalAspectRatio);
+ }
+ setResizeMode(resizeMode);
+ PlayerHelper.saveResizeMode(this, resizeMode);
+ }
+
+ private void setForcedAspectRatio(final float aspectRatio) {
+ forcedAspectRatio = aspectRatio;
+ // a forced aspect ratio is only meaningful with Fit; this resize mode change is per-video
+ // and is intentionally not persisted, so the saved resize mode is restored on the next video
+ setResizeMode(AspectRatioFrameLayout.RESIZE_MODE_FIT);
+
+ final float effectiveRatio = aspectRatio > 0 ? aspectRatio : videoNaturalAspectRatio;
+ if (effectiveRatio > 0) {
+ binding.surfaceView.setAspectRatio(effectiveRatio);
+ }
+ }
+
+ private void openCustomAspectRatioDialog() {
+ final AppCompatActivity activity = getParentActivity();
+ if (activity == null) {
+ return;
+ }
+ final EditText input = new EditText(activity);
+ input.setHint(R.string.aspect_ratio_custom_hint);
+ input.setInputType(InputType.TYPE_CLASS_TEXT);
+ if (forcedAspectRatio > 0) {
+ input.setText(PlayerHelper.aspectRatioNameOf(forcedAspectRatio));
+ }
+ new AlertDialog.Builder(activity)
+ .setTitle(R.string.aspect_ratio_custom_title)
+ .setView(input)
+ .setPositiveButton(R.string.ok, (dialog, which) -> {
+ final float ratio = PlayerHelper.parseAspectRatio(input.getText().toString());
+ if (ratio > 0) {
+ setForcedAspectRatio(ratio);
+ } else {
+ Toast.makeText(context, R.string.aspect_ratio_invalid, Toast.LENGTH_SHORT)
+ .show();
+ }
+ })
+ .setNegativeButton(R.string.cancel, null)
+ .show();
}
@Override // exoplayer listener
@@ -4613,7 +4768,9 @@ public void onVideoSizeChanged(@NonNull final VideoSize videoSize) {
+ "pixelWidthHeightRatio = [" + videoSize.pixelWidthHeightRatio + "]");
}
- binding.surfaceView.setAspectRatio(((float) videoSize.width) / videoSize.height);
+ videoNaturalAspectRatio = ((float) videoSize.width) / videoSize.height;
+ binding.surfaceView.setAspectRatio(forcedAspectRatio > 0
+ ? forcedAspectRatio : videoNaturalAspectRatio);
isVerticalVideo = videoSize.width < videoSize.height;
if (globalScreenOrientationLocked(context)
diff --git a/app/src/main/java/org/schabi/newpipe/player/helper/PlayerHelper.java b/app/src/main/java/org/schabi/newpipe/player/helper/PlayerHelper.java
index 91258be87..93895ad79 100644
--- a/app/src/main/java/org/schabi/newpipe/player/helper/PlayerHelper.java
+++ b/app/src/main/java/org/schabi/newpipe/player/helper/PlayerHelper.java
@@ -552,34 +552,65 @@ public static int nextRepeatMode(@RepeatMode final int repeatMode) {
}
}
+ /**
+ * Aspect ratios selectable in the player. Indices in both arrays match.
+ */
+ public static final String[] ASPECT_RATIO_LABELS = {"1:1", "4:3", "16:9", "18:9", "21:9"};
+ public static final float[] ASPECT_RATIO_VALUES = {
+ 1.0f, 4.0f / 3.0f, 16.0f / 9.0f, 18.0f / 9.0f, 21.0f / 9.0f};
+
+ public static String aspectRatioNameOf(final float aspectRatio) {
+ for (int i = 0; i < ASPECT_RATIO_VALUES.length; i++) {
+ if (Math.abs(ASPECT_RATIO_VALUES[i] - aspectRatio) < 0.001f) {
+ return ASPECT_RATIO_LABELS[i];
+ }
+ }
+ return String.format(Locale.US, "%.2f", aspectRatio);
+ }
+
+ /**
+ * Parses a user-entered aspect ratio like {@code "16:9"}, {@code "16/9"} or {@code "1.78"}.
+ *
+ * @return the ratio as a float, or {@code 0} if the input could not be parsed
+ */
+ public static float parseAspectRatio(@Nullable final String input) {
+ if (input == null) {
+ return 0.0f;
+ }
+ final String trimmed = input.trim();
+ try {
+ final String[] parts = trimmed.split("[:/]");
+ if (parts.length == 2) {
+ final float width = Float.parseFloat(parts[0].trim());
+ final float height = Float.parseFloat(parts[1].trim());
+ if (width > 0 && height > 0) {
+ return width / height;
+ }
+ } else if (parts.length == 1) {
+ final float ratio = Float.parseFloat(trimmed.replace(',', '.'));
+ if (ratio > 0) {
+ return ratio;
+ }
+ }
+ } catch (final NumberFormatException ignored) {
+ // fall through to invalid
+ }
+ return 0.0f;
+ }
+
@ResizeMode
public static int retrieveResizeModeFromPrefs(final Player player) {
return player.getPrefs().getInt(player.getContext().getString(R.string.last_resize_mode),
AspectRatioFrameLayout.RESIZE_MODE_FIT);
}
- @SuppressLint("SwitchIntDef") // only fit, fill and zoom are supported by NewPipe
- @ResizeMode
- public static int nextResizeModeAndSaveToPrefs(final Player player,
- @ResizeMode final int resizeMode) {
- final int newResizeMode;
- switch (resizeMode) {
- case AspectRatioFrameLayout.RESIZE_MODE_FIT:
- newResizeMode = AspectRatioFrameLayout.RESIZE_MODE_FILL;
- break;
- case AspectRatioFrameLayout.RESIZE_MODE_FILL:
- newResizeMode = AspectRatioFrameLayout.RESIZE_MODE_ZOOM;
- break;
- case AspectRatioFrameLayout.RESIZE_MODE_ZOOM:
- default:
- newResizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT;
- break;
- }
-
- // save the new resize mode so it can be restored in a future session
+ /**
+ * Persists the given resize mode so it can be restored in a future session.
+ */
+ public static void saveResizeMode(final Player player,
+ @ResizeMode final int resizeMode) {
player.getPrefs().edit().putInt(
- player.getContext().getString(R.string.last_resize_mode), newResizeMode).apply();
- return newResizeMode;
+ player.getContext().getString(R.string.last_resize_mode), resizeMode).apply();
}
public static PlaybackParameters retrievePlaybackParametersFromPrefs(final Player player) {
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index e1901939c..be21b5345 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -425,6 +425,10 @@
Fit
Fill
Zoom
+Custom…
+Custom aspect ratio
+e.g. 16:9 or 1.78
+Invalid aspect ratio
Auto-generated
Captions