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