From 97450354faf71de7c8e2b88621722965e28705f0 Mon Sep 17 00:00:00 2001 From: unrasyonel Date: Sat, 15 Aug 2026 17:32:16 +0300 Subject: [PATCH] feat(player): add pinch-to-zoom mode --- .../org/schabi/newpipe/player/Player.java | 96 ++++++++++++++++++- .../player/event/BasePlayerGestureListener.kt | 56 ++++++++++- .../newpipe/player/helper/PlayerHelper.java | 11 +++ .../newpipe/views/ExpandableSurfaceView.java | 61 +++++++++++- app/src/main/res/layout/player.xml | 16 ++++ app/src/main/res/values-tr/strings.xml | 2 + app/src/main/res/values/settings_keys.xml | 1 + app/src/main/res/values/strings.xml | 4 +- 8 files changed, 237 insertions(+), 10 deletions(-) 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 484185b7c..87547780d 100644 --- a/app/src/main/java/org/schabi/newpipe/player/Player.java +++ b/app/src/main/java/org/schabi/newpipe/player/Player.java @@ -1132,8 +1132,11 @@ public void smoothStopPlayer() { //region Player type specific setup private void initVideoPlayer() { - // restore last resize mode - setResizeMode(PlayerHelper.retrieveResizeModeFromPrefs(this)); + // Pinch zoom owns video scaling while enabled; otherwise restore the regular display mode. + setResizeMode(PlayerHelper.isPinchToZoomEnabled(context) + ? AspectRatioFrameLayout.RESIZE_MODE_FIT + : PlayerHelper.retrieveResizeModeFromPrefs(this)); + binding.surfaceView.resetPinchScale(); binding.getRoot().setLayoutParams(new FrameLayout.LayoutParams( FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT)); } @@ -3749,6 +3752,13 @@ private void onMetadataChanged(@NonNull final StreamInfo info) { Log.d(TAG, "Playback - onMetadataChanged() called, playing: " + info.getName()); } + // Zoom belongs to the current video, matching the transient behavior of the official app. + resetPinchZoom(); + if (PlayerHelper.isPinchToZoomEnabled(context)) { + forcedAspectRatio = 0.0f; + setResizeMode(AspectRatioFrameLayout.RESIZE_MODE_FIT); + } + // 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) { @@ -4858,7 +4868,9 @@ private void setResizeMode(@AspectRatioFrameLayout.ResizeMode final int resizeMo * resize mode, since selecting an aspect ratio is what the user sees applied. */ private void updateDisplayModeButtonText() { - binding.resizeTextView.setText(forcedAspectRatio > 0 + binding.resizeTextView.setText(PlayerHelper.isPinchToZoomEnabled(context) + ? getContext().getString(R.string.resize_pinch) + : forcedAspectRatio > 0 ? PlayerHelper.aspectRatioNameOf(forcedAspectRatio) : PlayerHelper.resizeTypeOf(context, binding.surfaceView.getResizeMode())); } @@ -4893,7 +4905,8 @@ private void buildDisplayModeMenu() { displayModePopupMenu.setOnDismissListener(this); // a forced aspect ratio takes precedence: when active, no resize mode is the "current" one - final boolean ratioActive = forcedAspectRatio > 0; + final boolean pinchActive = PlayerHelper.isPinchToZoomEnabled(context); + final boolean ratioActive = forcedAspectRatio > 0 && !pinchActive; final int currentResizeMode = binding.surfaceView.getResizeMode(); MenuItem activeItem = null; @@ -4908,12 +4921,23 @@ private void buildDisplayModeMenu() { onResizeModeSelected(resizeMode); return true; }); - if (!ratioActive && resizeMode == currentResizeMode) { + if (!ratioActive && !pinchActive && resizeMode == currentResizeMode) { activeItem = resizeItem; } order++; } + final MenuItem pinchItem = menu.add(POPUP_MENU_ID_DISPLAY_MODE, order, order, + R.string.resize_pinch); + pinchItem.setOnMenuItemClickListener(menuItem -> { + onPinchModeSelected(); + return true; + }); + if (pinchActive) { + activeItem = pinchItem; + } + 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, @@ -4947,6 +4971,8 @@ private void buildDisplayModeMenu() { } private void onResizeModeSelected(@AspectRatioFrameLayout.ResizeMode final int resizeMode) { + PlayerHelper.setPinchToZoomEnabled(context, false); + resetPinchZoom(); // a resize mode supersedes any forced aspect ratio, which would otherwise have no effect forcedAspectRatio = 0.0f; if (videoNaturalAspectRatio > 0) { @@ -4957,6 +4983,8 @@ private void onResizeModeSelected(@AspectRatioFrameLayout.ResizeMode final int r } private void setForcedAspectRatio(final float aspectRatio) { + PlayerHelper.setPinchToZoomEnabled(context, false); + resetPinchZoom(); 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 @@ -4968,6 +4996,62 @@ private void setForcedAspectRatio(final float aspectRatio) { } } + public boolean isPinchToZoomEnabled() { + return isFullscreen && PlayerHelper.isPinchToZoomEnabled(context); + } + + private void onPinchModeSelected() { + forcedAspectRatio = 0.0f; + PlayerHelper.setPinchToZoomEnabled(context, true); + if (videoNaturalAspectRatio > 0.0f) { + binding.surfaceView.setAspectRatio(videoNaturalAspectRatio); + } + setResizeMode(AspectRatioFrameLayout.RESIZE_MODE_FIT); + resetPinchZoom(); + updateDisplayModeButtonText(); + Toast.makeText(context, R.string.pinch_to_zoom_selected, Toast.LENGTH_SHORT).show(); + } + + private void resetPinchZoom() { + binding.surfaceView.resetPinchScale(); + binding.pinchZoomIndicator.animate().cancel(); + binding.pinchZoomIndicator.setVisibility(View.GONE); + } + + public void onPinchZoomStart(final float focusX, final float focusY) { + forcedAspectRatio = 0.0f; + if (videoNaturalAspectRatio > 0.0f) { + binding.surfaceView.setAspectRatio(videoNaturalAspectRatio); + } + setResizeMode(AspectRatioFrameLayout.RESIZE_MODE_FIT); + binding.surfaceView.beginPinchGesture( + focusX - binding.surfaceView.getLeft(), + focusY - binding.surfaceView.getTop()); + binding.pinchZoomIndicator.animate().cancel(); + binding.pinchZoomIndicator.setAlpha(1.0f); + binding.pinchZoomIndicator.setText(String.format(Locale.US, "%.1f×", + binding.surfaceView.getPinchScale())); + binding.pinchZoomIndicator.setVisibility(View.VISIBLE); + } + + public void onPinchZoom(final float scaleFactor, final float focusX, final float focusY) { + if (!Float.isFinite(scaleFactor)) { + return; + } + binding.surfaceView.setPinchScale( + binding.surfaceView.getPinchScale() * scaleFactor, + focusX - binding.surfaceView.getLeft(), + focusY - binding.surfaceView.getTop()); + binding.pinchZoomIndicator.setText(String.format(Locale.US, "%.1f×", + binding.surfaceView.getPinchScale())); + } + + public void onPinchZoomEnd() { + binding.pinchZoomIndicator.animate().cancel(); + binding.pinchZoomIndicator.animate().alpha(0.0f).setStartDelay(250L).setDuration(180L) + .withEndAction(() -> binding.pinchZoomIndicator.setVisibility(View.GONE)).start(); + } + private void openCustomAspectRatioDialog() { final AppCompatActivity activity = getParentActivity(); if (activity == null) { @@ -5032,6 +5116,8 @@ public void toggleFullscreen() { } isFullscreen = !isFullscreen; + // Pinch zoom is fullscreen-only and never survives either direction of the transition. + resetPinchZoom(); if (!isFullscreen) { // Apply window insets because Android will not do it when orientation changes // from landscape to portrait (open vertical video to reproduce) diff --git a/app/src/main/java/org/schabi/newpipe/player/event/BasePlayerGestureListener.kt b/app/src/main/java/org/schabi/newpipe/player/event/BasePlayerGestureListener.kt index 081d640dd..d24237fbc 100644 --- a/app/src/main/java/org/schabi/newpipe/player/event/BasePlayerGestureListener.kt +++ b/app/src/main/java/org/schabi/newpipe/player/event/BasePlayerGestureListener.kt @@ -28,6 +28,29 @@ abstract class BasePlayerGestureListener( protected val service: Service ) : GestureDetector.SimpleOnGestureListener(), View.OnTouchListener { + private val scaleGestureDetector = ScaleGestureDetector( + service, + object : ScaleGestureDetector.SimpleOnScaleGestureListener() { + override fun onScaleBegin(detector: ScaleGestureDetector): Boolean { + if (!player.isPinchToZoomEnabled || player.popupPlayerSelected()) return false + isPinchingInMain = true + suppressMainGestureUntilUp = true + player.onPinchZoomStart(detector.focusX, detector.focusY) + return true + } + + override fun onScale(detector: ScaleGestureDetector): Boolean { + player.onPinchZoom(detector.scaleFactor, detector.focusX, detector.focusY) + return true + } + + override fun onScaleEnd(detector: ScaleGestureDetector) { + player.onPinchZoomEnd() + isPinchingInMain = false + } + } + ) + // /////////////////////////////////////////////////////////////////// // Abstract methods for VIDEO and POPUP // /////////////////////////////////////////////////////////////////// @@ -59,6 +82,8 @@ abstract class BasePlayerGestureListener( private var initialPopupY: Int = -1 private var isMovingInMain = false + private var isPinchingInMain = false + private var suppressMainGestureUntilUp = false private var isMovingInPopup = false private var isResizing = false @@ -86,7 +111,26 @@ abstract class BasePlayerGestureListener( private var velocityTracker: VelocityTracker? = null private fun onTouchInMain(v: View, event: MotionEvent): Boolean { - player.gestureDetector.onTouchEvent(event) + if (player.isPinchToZoomEnabled && + event.actionMasked == MotionEvent.ACTION_POINTER_DOWN + ) { + // GestureDetector does not receive multi-pointer events below, so explicitly cancel + // its pending long-press callback before it can enable speed-up during a pinch. + val cancelEvent = MotionEvent.obtain(event) + cancelEvent.action = MotionEvent.ACTION_CANCEL + player.gestureDetector.onTouchEvent(cancelEvent) + cancelEvent.recycle() + if (player.longPressSpeedingEnabled) { + player.playbackSpeed /= player.longPressSpeedingFactor + player.longPressSpeedingEnabled = false + } + } + if (player.isPinchToZoomEnabled) { + scaleGestureDetector.onTouchEvent(event) + } + if (!isPinchingInMain && !suppressMainGestureUntilUp && event.pointerCount == 1) { + player.gestureDetector.onTouchEvent(event) + } when (event.action) { MotionEvent.ACTION_DOWN -> { @@ -95,6 +139,7 @@ abstract class BasePlayerGestureListener( velocityTracker?.addMovement(event) } MotionEvent.ACTION_MOVE -> { + if (isPinchingInMain) return true velocityTracker?.addMovement(event) velocityTracker?.computeCurrentVelocity(1000) val yVelocity = velocityTracker?.yVelocity ?: 0f @@ -111,6 +156,15 @@ abstract class BasePlayerGestureListener( velocityTracker?.recycle() velocityTracker = null + if (isPinchingInMain) { + player.onPinchZoomEnd() + isPinchingInMain = false + suppressMainGestureUntilUp = false + return true + } + + suppressMainGestureUntilUp = false + if (isMovingInMain) { isMovingInMain = false onScrollEnd(PlayerService.PlayerType.VIDEO, event) 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 e70d3f327..cf45140f7 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 @@ -329,6 +329,17 @@ public static boolean isFullscreenGestureEnabled(@NonNull final Context context) .getBoolean(context.getString(R.string.fullscreen_gesture_control_key), true); } + public static boolean isPinchToZoomEnabled(@NonNull final Context context) { + return getPreferences(context) + .getBoolean(context.getString(R.string.pinch_to_zoom_key), false); + } + + public static void setPinchToZoomEnabled(@NonNull final Context context, + final boolean enabled) { + getPreferences(context).edit() + .putBoolean(context.getString(R.string.pinch_to_zoom_key), enabled).apply(); + } + public static boolean isSwipeSeekGestureEnabled(@NonNull final Context context) { return getPreferences(context) .getBoolean(context.getString(R.string.swipe_seek_gesture_control_key), true); diff --git a/app/src/main/java/org/schabi/newpipe/views/ExpandableSurfaceView.java b/app/src/main/java/org/schabi/newpipe/views/ExpandableSurfaceView.java index 7c7651045..14a21117d 100644 --- a/app/src/main/java/org/schabi/newpipe/views/ExpandableSurfaceView.java +++ b/app/src/main/java/org/schabi/newpipe/views/ExpandableSurfaceView.java @@ -17,6 +17,11 @@ public class ExpandableSurfaceView extends SurfaceView { private float videoAspectRatio = 0.0f; private float scaleX = 1.0f; private float scaleY = 1.0f; + private float pinchScale = 1.0f; + private float pinchTranslationX = 0.0f; + private float pinchTranslationY = 0.0f; + private float lastPinchFocusX = Float.NaN; + private float lastPinchFocusY = Float.NaN; public ExpandableSurfaceView(final Context context, final AttributeSet attrs) { super(context, attrs); @@ -70,9 +75,18 @@ protected void onMeasure(final int widthMeasureSpec, final int heightMeasureSpec @Override protected void onLayout(final boolean changed, final int left, final int top, final int right, final int bottom) { - // Defensive: never push a non-finite scale into the view (it throws and takes down layout). - setScaleX(Float.isFinite(scaleX) ? scaleX : 1.0f); - setScaleY(Float.isFinite(scaleY) ? scaleY : 1.0f); + applyScaleAndTranslation(); + } + + private void applyScaleAndTranslation() { + final float safePinchScale = Float.isFinite(pinchScale) ? pinchScale : 1.0f; + final boolean pinchActive = safePinchScale > 1.0f; + setPivotX(pinchActive ? 0.0f : getWidth() / 2.0f); + setPivotY(pinchActive ? 0.0f : getHeight() / 2.0f); + setScaleX((Float.isFinite(scaleX) ? scaleX : 1.0f) * safePinchScale); + setScaleY((Float.isFinite(scaleY) ? scaleY : 1.0f) * safePinchScale); + setTranslationX(pinchActive ? pinchTranslationX : 0.0f); + setTranslationY(pinchActive ? pinchTranslationY : 0.0f); } /** @@ -102,6 +116,47 @@ public int getResizeMode() { return resizeMode; } + public void beginPinchGesture(final float focusX, final float focusY) { + lastPinchFocusX = focusX; + lastPinchFocusY = focusY; + } + + public void setPinchScale(final float newScale, final float focusX, final float focusY) { + final float oldScale = pinchScale; + pinchScale = Math.max(1.0f, Math.min(newScale, 8.0f)); + final float scaleChange = pinchScale / oldScale; + + if (Float.isFinite(lastPinchFocusX) && Float.isFinite(lastPinchFocusY)) { + // Keep the content that was under the fingers anchored while also following their + // midpoint. This avoids the inverted, jumpy motion caused by changing View pivots. + pinchTranslationX = focusX + - (lastPinchFocusX - pinchTranslationX) * scaleChange; + pinchTranslationY = focusY + - (lastPinchFocusY - pinchTranslationY) * scaleChange; + } + lastPinchFocusX = focusX; + lastPinchFocusY = focusY; + + pinchTranslationX = Math.max(getWidth() * (1.0f - pinchScale), + Math.min(0.0f, pinchTranslationX)); + pinchTranslationY = Math.max(getHeight() * (1.0f - pinchScale), + Math.min(0.0f, pinchTranslationY)); + applyScaleAndTranslation(); + } + + public float getPinchScale() { + return pinchScale; + } + + public void resetPinchScale() { + pinchScale = 1.0f; + pinchTranslationX = 0.0f; + pinchTranslationY = 0.0f; + lastPinchFocusX = Float.NaN; + lastPinchFocusY = Float.NaN; + applyScaleAndTranslation(); + } + public void setAspectRatio(final float aspectRatio) { // A 0x0 / not-yet-known video gives NaN (or Infinity) here; keep it as "no ratio" (0) so the // measure path skips scaling instead of pushing NaN into setScaleX, which throws diff --git a/app/src/main/res/layout/player.xml b/app/src/main/res/layout/player.xml index 772a8fbd6..92863070e 100644 --- a/app/src/main/res/layout/player.xml +++ b/app/src/main/res/layout/player.xml @@ -21,6 +21,22 @@ android:layout_alignBottom="@+id/surfaceView" android:background="@android:color/black" /> + + Kod çözücü başlatma başarısız oldu Cihazınız gerekli donanım kod çözücülerinden yoksun, bu yüzden bu videoyu PipePipe\'ta oynatamazsınız.\nLütfen indirin ve VLC\'de oynatın. Jest Ayarları +Sıkıştır +Yakınlaştırmak için sıkıştırın. Kayan Yorum Ayarları Yedekleme Gelişmiş diff --git a/app/src/main/res/values/settings_keys.xml b/app/src/main/res/values/settings_keys.xml index 67359f930..760de6d83 100644 --- a/app/src/main/res/values/settings_keys.xml +++ b/app/src/main/res/values/settings_keys.xml @@ -23,6 +23,7 @@ volume_gesture_control brightness_gesture_control fullscreen_gesture_control + pinch_to_zoom swipe_seek_gesture_control playback_speed_gesture_control resume_on_audio_focus_gain diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e3ee94dc9..99b21d4e6 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -940,6 +940,8 @@ Decoder initialization failed Your device lacks the required hardware decoders, so you cannot play this video in PipePipe.\nPlease download and play it in VLC. Gestures +Pinch +Pinch to zoom. Bullet Comment Settings Backup Advanced @@ -1083,4 +1085,4 @@ Notifications when YouTube requires a playback request to wait Waiting for YouTube YouTube forces us to wait %1$d seconds before continuing playback - \ No newline at end of file +