From bf8e8fc0bf5e43f7072f7b71617618f7fa63131c Mon Sep 17 00:00:00 2001 From: codegeasse Date: Thu, 24 Sep 2026 15:11:49 +0530 Subject: [PATCH 01/24] Add video PiP test build --- .github/workflows/build-video-downloader.yml | 14 +-- .../session/PictureInPictureFeature.kt | 110 ++++++++++++++++-- .../session/PictureInPictureFeatureTest.kt | 15 +++ .../fenix/browser/BaseBrowserFragment.kt | 18 ++- 4 files changed, 141 insertions(+), 16 deletions(-) diff --git a/.github/workflows/build-video-downloader.yml b/.github/workflows/build-video-downloader.yml index b904700d..1efed245 100644 --- a/.github/workflows/build-video-downloader.yml +++ b/.github/workflows/build-video-downloader.yml @@ -1,14 +1,14 @@ -name: Build Nova Video Downloader (feature branch) +name: Build Nova PiP Video Test (feature branch) on: workflow_dispatch: inputs: version_name: - description: 'Version name (e.g. 3.1.0-video)' + description: 'Version name for the PiP test build' required: true - default: '3.1.0-video' + default: '1.4.6-pip-test' push: - branches: [ feature/video-downloader ] + branches: [ feature/video-downloader, feature/pip-video-test ] paths-ignore: - 'README.md' - 'screenshots/**' @@ -23,7 +23,7 @@ concurrency: jobs: build: - name: Build Nova Video Downloader APK + name: Build Nova PiP Video Test APK runs-on: ubuntu-latest timeout-minutes: 360 steps: @@ -47,7 +47,7 @@ jobs: run: | VERSION_NAME="${{ github.event.inputs.version_name || '3.1.0-video' }}" echo "VERSION_NAME=$VERSION_NAME" >> $GITHUB_ENV - echo "Building video-downloader test APK, version $VERSION_NAME" + echo "Building Nova PiP test APK, version $VERSION_NAME" - name: Build forkRelease variant timeout-minutes: 90 @@ -79,7 +79,7 @@ jobs: --ks-key-alias "$ALIAS" \ --ks-pass env:KEY_STORE_PASSWORD \ --key-pass env:KEY_PASSWORD \ - --out nova-video-downloader.apk "$APK_PATH" + --out nova-pip-video-test.apk "$APK_PATH" $HOME/android-sdk/android-sdk-linux/build-tools/$BUILD_TOOL_VERSION/apksigner verify --print-certs nova-video-downloader.apk ls -alh nova-video-downloader.apk $HOME/android-sdk/android-sdk-linux/build-tools/$BUILD_TOOL_VERSION/aapt dump badging nova-video-downloader.apk | head -3 diff --git a/android-components/components/feature/session/src/main/java/mozilla/components/feature/session/PictureInPictureFeature.kt b/android-components/components/feature/session/src/main/java/mozilla/components/feature/session/PictureInPictureFeature.kt index 77284808..95aab17d 100644 --- a/android-components/components/feature/session/src/main/java/mozilla/components/feature/session/PictureInPictureFeature.kt +++ b/android-components/components/feature/session/src/main/java/mozilla/components/feature/session/PictureInPictureFeature.kt @@ -6,8 +6,12 @@ package mozilla.components.feature.session import android.app.Activity import android.app.PictureInPictureParams +import android.view.View import android.content.pm.PackageManager +import android.graphics.Rect +import android.os.Build import mozilla.components.browser.state.action.ContentAction +import mozilla.components.browser.state.state.SessionState import mozilla.components.browser.state.selector.findTabOrCustomTabOrSelectedTab import mozilla.components.browser.state.selector.selectedTab import mozilla.components.browser.state.store.BrowserStore @@ -29,6 +33,7 @@ class PictureInPictureFeature( private val activity: Activity, private val crashReporting: CrashReporting? = null, private val tabId: String? = null, + private val playerView: View? = null, ) { internal val logger = Logger("PictureInPictureFeature") @@ -42,9 +47,21 @@ class PictureInPictureFeature( val session = store.state.findTabOrCustomTabOrSelectedTab(tabId) val fullScreenMode = session?.content?.fullScreen == true - val contentIsPlaying = session?.mediaSessionState?.playbackState == MediaSession.PlaybackState.PLAYING - return fullScreenMode && contentIsPlaying && try { - enterPipModeCompat() + val mediaSession = session?.mediaSessionState + val contentIsPlaying = mediaSession?.playbackState == MediaSession.PlaybackState.PLAYING + val hasVideoTrack = mediaSession?.elementMetadata?.videoTrackCount?.let { it > 0 } == true + + if (!fullScreenMode || !contentIsPlaying || !hasVideoTrack) { + return false + } + + return try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + updatePipParams(session) + true + } else { + enterPipModeCompat(session) + } } catch (e: IllegalStateException) { // On certain Samsung devices, if accessibility mode is enabled, this will throw an // IllegalStateException even if we check for the system feature beforehand. So let's @@ -58,13 +75,92 @@ class PictureInPictureFeature( /** * Enter Picture-in-Picture mode. */ - fun enterPipModeCompat() = when { + fun enterPipModeCompat(session: SessionState? = null) = when { !hasSystemFeature -> false - else -> enterPipModeForO() + else -> enterPipModeForO(session) } - private fun enterPipModeForO() = - activity.enterPictureInPictureMode(PictureInPictureParams.Builder().build()) + fun updatePipParams(session: SessionState?) { + if (!hasSystemFeature) { + return + } + + val mediaSession = session?.mediaSessionState + val isVideoPlaying = session?.content?.fullScreen == true && + mediaSession?.playbackState == MediaSession.PlaybackState.PLAYING && + mediaSession.elementMetadata?.videoTrackCount?.let { it > 0 } == true + + val builder = PictureInPictureParams.Builder() + + if (isVideoPlaying) { + val metadata = mediaSession.elementMetadata + val width = metadata?.width ?: 0L + val height = metadata?.height ?: 0L + + if (width > 0L && height > 0L) { + val ratio = width.toDouble() / height.toDouble() + if (ratio in (1.0 / 2.39)..2.39) { + builder.setAspectRatio( + android.util.Rational( + width.coerceAtMost(Int.MAX_VALUE.toLong()).toInt(), + height.coerceAtMost(Int.MAX_VALUE.toLong()).toInt(), + ), + ) + } + } + + playerView?.let { view -> + val sourceRect = Rect() + if (view.getGlobalVisibleRect(sourceRect)) { + builder.setSourceRectHint(sourceRect) + } + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + builder.setAutoEnterEnabled(true) + builder.setSeamlessResizeEnabled(true) + } + } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + builder.setAutoEnterEnabled(false) + } + + activity.setPictureInPictureParams(builder.build()) + } + + private fun enterPipModeForO(session: SessionState?) = + activity.enterPictureInPictureMode(buildPipParams(session)) + + private fun buildPipParams(session: SessionState?): PictureInPictureParams { + val builder = PictureInPictureParams.Builder() + val metadata = session?.mediaSessionState?.elementMetadata + val width = metadata?.width ?: 0L + val height = metadata?.height ?: 0L + + if (width > 0L && height > 0L) { + val ratio = width.toDouble() / height.toDouble() + if (ratio in (1.0 / 2.39)..2.39) { + builder.setAspectRatio( + android.util.Rational( + width.coerceAtMost(Int.MAX_VALUE.toLong()).toInt(), + height.coerceAtMost(Int.MAX_VALUE.toLong()).toInt(), + ), + ) + } + } + + playerView?.let { view -> + val sourceRect = Rect() + if (view.getGlobalVisibleRect(sourceRect)) { + builder.setSourceRectHint(sourceRect) + } + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + builder.setSeamlessResizeEnabled(true) + } + + return builder.build() + } /** * Should be called when the system informs you of changes to and from picture-in-picture mode. diff --git a/android-components/components/feature/session/src/test/java/mozilla/components/feature/session/PictureInPictureFeatureTest.kt b/android-components/components/feature/session/src/test/java/mozilla/components/feature/session/PictureInPictureFeatureTest.kt index df68ab3a..2a7c6c42 100644 --- a/android-components/components/feature/session/src/test/java/mozilla/components/feature/session/PictureInPictureFeatureTest.kt +++ b/android-components/components/feature/session/src/test/java/mozilla/components/feature/session/PictureInPictureFeatureTest.kt @@ -135,6 +135,11 @@ class PictureInPictureFeatureTest { mediaSessionState = MediaSessionState( playbackState = MediaSession.PlaybackState.PLAYING, controller = controller, + elementMetadata = MediaSession.ElementMetadata( + width = 1920, + height = 1080, + videoTrackCount = 1, + ), ), ).copyWithFullScreen(true) val store = BrowserStore( @@ -187,6 +192,11 @@ class PictureInPictureFeatureTest { mediaSessionState = MediaSessionState( playbackState = MediaSession.PlaybackState.PLAYING, controller = controller, + elementMetadata = MediaSession.ElementMetadata( + width = 1920, + height = 1080, + videoTrackCount = 1, + ), ), ).copyWithFullScreen(true) val store = BrowserStore( @@ -228,6 +238,11 @@ class PictureInPictureFeatureTest { mediaSessionState = MediaSessionState( playbackState = MediaSession.PlaybackState.PLAYING, controller = controller, + elementMetadata = MediaSession.ElementMetadata( + width = 1920, + height = 1080, + videoTrackCount = 1, + ), ), ).copyWithFullScreen(true) val store = BrowserStore( diff --git a/app/src/main/java/org/mozilla/fenix/browser/BaseBrowserFragment.kt b/app/src/main/java/org/mozilla/fenix/browser/BaseBrowserFragment.kt index ac47a358..10b9176f 100644 --- a/app/src/main/java/org/mozilla/fenix/browser/BaseBrowserFragment.kt +++ b/app/src/main/java/org/mozilla/fenix/browser/BaseBrowserFragment.kt @@ -914,6 +914,7 @@ abstract class BaseBrowserFragment : activity = requireActivity(), crashReporting = context.components.analytics.crashReporter, tabId = customTabSessionId, + playerView = binding.engineView, ) biometricPromptFeature.set( @@ -1323,8 +1324,21 @@ abstract class BaseBrowserFragment : store.flowScoped(viewLifecycleOwner, Dispatchers.Main) { flow -> flow.mapNotNull { state -> state.findTabOrCustomTabOrSelectedTab(customTabSessionId) } - .distinctUntilChangedBy { tab -> tab.content.pictureInPictureEnabled } - .collect { tab -> pipModeChanged(tab) } + .distinctUntilChangedBy { tab -> + val media = tab.mediaSessionState + listOf( + tab.content.pictureInPictureEnabled, + tab.content.fullScreen, + media?.playbackState, + media?.elementMetadata?.width, + media?.elementMetadata?.height, + media?.elementMetadata?.videoTrackCount, + ) + } + .collect { tab -> + pipModeChanged(tab) + pipFeature?.updatePipParams(tab) + } } binding.swipeRefresh.isEnabled = shouldPullToRefreshBeEnabled(false) From ddcb069ec4e30cead2dd6a2990d67d6f63d9dcb5 Mon Sep 17 00:00:00 2001 From: codegeasse Date: Thu, 24 Sep 2026 15:26:17 +0530 Subject: [PATCH 02/24] Fix PiP player view type for EngineView --- .../components/feature/session/PictureInPictureFeature.kt | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/android-components/components/feature/session/src/main/java/mozilla/components/feature/session/PictureInPictureFeature.kt b/android-components/components/feature/session/src/main/java/mozilla/components/feature/session/PictureInPictureFeature.kt index 95aab17d..55baa24f 100644 --- a/android-components/components/feature/session/src/main/java/mozilla/components/feature/session/PictureInPictureFeature.kt +++ b/android-components/components/feature/session/src/main/java/mozilla/components/feature/session/PictureInPictureFeature.kt @@ -6,10 +6,10 @@ package mozilla.components.feature.session import android.app.Activity import android.app.PictureInPictureParams -import android.view.View import android.content.pm.PackageManager import android.graphics.Rect import android.os.Build +import android.view.View import mozilla.components.browser.state.action.ContentAction import mozilla.components.browser.state.state.SessionState import mozilla.components.browser.state.selector.findTabOrCustomTabOrSelectedTab @@ -33,7 +33,7 @@ class PictureInPictureFeature( private val activity: Activity, private val crashReporting: CrashReporting? = null, private val tabId: String? = null, - private val playerView: View? = null, + private val playerView: Any? = null, ) { internal val logger = Logger("PictureInPictureFeature") @@ -109,7 +109,7 @@ class PictureInPictureFeature( } } - playerView?.let { view -> + (playerView as? View)?.let { view -> val sourceRect = Rect() if (view.getGlobalVisibleRect(sourceRect)) { builder.setSourceRectHint(sourceRect) @@ -148,7 +148,7 @@ class PictureInPictureFeature( } } - playerView?.let { view -> + (playerView as? View)?.let { view -> val sourceRect = Rect() if (view.getGlobalVisibleRect(sourceRect)) { builder.setSourceRectHint(sourceRect) From 41010ba9300442d9fb868a22c2dc0a98d2f9616e Mon Sep 17 00:00:00 2001 From: codegeasse Date: Thu, 24 Sep 2026 16:49:10 +0530 Subject: [PATCH 03/24] Fix PiP build and switch to manual video control --- .../session/PictureInPictureFeature.kt | 36 ++++++++++--------- 1 file changed, 19 insertions(+), 17 deletions(-) diff --git a/android-components/components/feature/session/src/main/java/mozilla/components/feature/session/PictureInPictureFeature.kt b/android-components/components/feature/session/src/main/java/mozilla/components/feature/session/PictureInPictureFeature.kt index 55baa24f..fba83bde 100644 --- a/android-components/components/feature/session/src/main/java/mozilla/components/feature/session/PictureInPictureFeature.kt +++ b/android-components/components/feature/session/src/main/java/mozilla/components/feature/session/PictureInPictureFeature.kt @@ -33,7 +33,7 @@ class PictureInPictureFeature( private val activity: Activity, private val crashReporting: CrashReporting? = null, private val tabId: String? = null, - private val playerView: Any? = null, + private val playerView: View? = null, ) { internal val logger = Logger("PictureInPictureFeature") @@ -41,31 +41,33 @@ class PictureInPictureFeature( activity.packageManager.hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE) fun onHomePressed(): Boolean { - if (!hasSystemFeature) { + // PiP is deliberately user-initiated from the in-video button. Do not + // intercept Home and unexpectedly move a video into PiP. + return false + } + + /** + * Enters PiP from the dedicated in-video control. + */ + fun enterPipMode(): Boolean { + if (!hasSystemFeature || Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { return false } val session = store.state.findTabOrCustomTabOrSelectedTab(tabId) - val fullScreenMode = session?.content?.fullScreen == true val mediaSession = session?.mediaSessionState - val contentIsPlaying = mediaSession?.playbackState == MediaSession.PlaybackState.PLAYING - val hasVideoTrack = mediaSession?.elementMetadata?.videoTrackCount?.let { it > 0 } == true + val canEnter = session?.content?.fullScreen == true && + mediaSession?.playbackState == MediaSession.PlaybackState.PLAYING && + mediaSession.elementMetadata?.videoTrackCount?.let { it > 0 } == true - if (!fullScreenMode || !contentIsPlaying || !hasVideoTrack) { + if (!canEnter) { return false } return try { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - updatePipParams(session) - true - } else { - enterPipModeCompat(session) - } + updatePipParams(session) + activity.enterPictureInPictureMode(buildPipParams(session)) } catch (e: IllegalStateException) { - // On certain Samsung devices, if accessibility mode is enabled, this will throw an - // IllegalStateException even if we check for the system feature beforehand. So let's - // catch it, log it, and not enter PiP. See https://stackoverflow.com/q/55288858 logger.warn("Entering PipMode failed", e) crashReporting?.submitCaughtException(e) false @@ -109,7 +111,7 @@ class PictureInPictureFeature( } } - (playerView as? View)?.let { view -> + playerView?.let { view -> val sourceRect = Rect() if (view.getGlobalVisibleRect(sourceRect)) { builder.setSourceRectHint(sourceRect) @@ -117,7 +119,7 @@ class PictureInPictureFeature( } if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - builder.setAutoEnterEnabled(true) + builder.setAutoEnterEnabled(false) builder.setSeamlessResizeEnabled(true) } } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { From 1f7c6ead62a093dfb57bfaf42f5f3caff58e0431 Mon Sep 17 00:00:00 2001 From: codegeasse Date: Thu, 24 Sep 2026 16:50:03 +0530 Subject: [PATCH 04/24] Add manual in-video PiP control --- .../fenix/browser/BaseBrowserFragment.kt | 97 ++++++++++++++++++- 1 file changed, 96 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/org/mozilla/fenix/browser/BaseBrowserFragment.kt b/app/src/main/java/org/mozilla/fenix/browser/BaseBrowserFragment.kt index 10b9176f..584bcc53 100644 --- a/app/src/main/java/org/mozilla/fenix/browser/BaseBrowserFragment.kt +++ b/app/src/main/java/org/mozilla/fenix/browser/BaseBrowserFragment.kt @@ -11,6 +11,8 @@ import android.content.Intent import android.content.res.Configuration import android.os.Build import android.os.Bundle +import android.os.Handler +import android.os.Looper import android.os.storage.StorageManager import android.provider.Settings import android.util.Log @@ -19,6 +21,8 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.view.accessibility.AccessibilityManager +import android.graphics.drawable.GradientDrawable +import android.widget.ImageButton import androidx.activity.result.ActivityResultLauncher import androidx.annotation.CallSuper import androidx.annotation.VisibleForTesting @@ -340,6 +344,13 @@ abstract class BaseBrowserFragment : } private var pipFeature: PictureInPictureFeature? = null + private var pipButton: ImageButton? = null + private val pipButtonHandler = Handler(Looper.getMainLooper()) + private val hidePipButtonRunnable = Runnable { + pipButton?.animate()?.alpha(0f)?.setDuration(180)?.withEndAction { + pipButton?.visibility = View.GONE + }?.start() + } var customTabSessionId: String? = null private set @@ -914,8 +925,10 @@ abstract class BaseBrowserFragment : activity = requireActivity(), crashReporting = context.components.analytics.crashReporter, tabId = customTabSessionId, - playerView = binding.engineView, + playerView = binding.browserLayout, + isEnabled = { NovaPictureInPicture.isEnabled(requireContext()) }, ) + setupPipButton() biometricPromptFeature.set( feature = BiometricPromptFeature( @@ -1338,6 +1351,11 @@ abstract class BaseBrowserFragment : .collect { tab -> pipModeChanged(tab) pipFeature?.updatePipParams(tab) + if (tab.content.pictureInPictureEnabled) { + hidePipButton() + } else { + updatePipButton(tab) + } } } @@ -2197,6 +2215,79 @@ abstract class BaseBrowserFragment : override fun onHomePressed() = pipFeature?.onHomePressed() ?: false + private fun setupPipButton() { + if (pipButton != null) return + + val density = resources.displayMetrics.density + val size = (48 * density).toInt() + val margin = (16 * density).toInt() + val button = ImageButton(requireContext()).apply { + setImageResource(R.drawable.nova_ic_picture_in_picture) + setColorFilter(android.graphics.Color.WHITE) + background = GradientDrawable().apply { + shape = GradientDrawable.OVAL + setColor(0xCC000000.toInt()) + } + contentDescription = getString(R.string.nova_pip_button_content_description) + setPadding( + (12 * density).toInt(), + (12 * density).toInt(), + (12 * density).toInt(), + (12 * density).toInt(), + ) + elevation = 8 * density + alpha = 0f + visibility = View.GONE + setOnClickListener { + hidePipButton() + pipFeature?.enterPipMode() + } + } + + binding.browserLayout.addView( + button, + CoordinatorLayout.LayoutParams(size, size).apply { + gravity = Gravity.TOP or Gravity.END + topMargin = margin + marginEnd = margin + }, + ) + pipButton = button + } + + private fun updatePipButton(session: SessionState) { + val button = pipButton ?: return + val mediaSession = session.mediaSessionState + val hasVideo = mediaSession?.elementMetadata?.videoTrackCount?.let { it > 0 } == true + val playbackState = mediaSession?.playbackState + val shouldShow = NovaPictureInPicture.isEnabled(requireContext()) && + session.content.fullScreen && + hasVideo && + playbackState in listOf( + mozilla.components.concept.engine.mediasession.MediaSession.PlaybackState.PLAYING, + mozilla.components.concept.engine.mediasession.MediaSession.PlaybackState.PAUSED, + ) + + if (!shouldShow) { + hidePipButton() + return + } + + pipButtonHandler.removeCallbacks(hidePipButtonRunnable) + button.animate().cancel() + button.visibility = View.VISIBLE + button.alpha = 0f + button.animate().alpha(1f).setDuration(160).start() + pipButtonHandler.postDelayed(hidePipButtonRunnable, 2000) + } + + private fun hidePipButton() { + pipButtonHandler.removeCallbacks(hidePipButtonRunnable) + pipButton?.animate()?.cancel() + pipButton?.visibility = View.GONE + pipButton?.alpha = 0f + } + /** * Exit fullscreen mode when exiting PIP mode */ @@ -2353,6 +2444,10 @@ abstract class BaseBrowserFragment : message = "onDestroyView()", ) + pipButtonHandler.removeCallbacks(hidePipButtonRunnable) + pipButton?.let { binding.browserLayout.removeView(it) } + pipButton = null + binding.engineView.setActivityContext(null) requireContext().accessibilityManager.removeAccessibilityStateChangeListener(this) From d0934b33c66e7d862c032be3876b985e59c09826 Mon Sep 17 00:00:00 2001 From: codegeasse Date: Thu, 24 Sep 2026 16:50:14 +0530 Subject: [PATCH 05/24] Fix Nova PiP preference reference --- .../java/org/mozilla/fenix/browser/BaseBrowserFragment.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/org/mozilla/fenix/browser/BaseBrowserFragment.kt b/app/src/main/java/org/mozilla/fenix/browser/BaseBrowserFragment.kt index 584bcc53..abc0ef67 100644 --- a/app/src/main/java/org/mozilla/fenix/browser/BaseBrowserFragment.kt +++ b/app/src/main/java/org/mozilla/fenix/browser/BaseBrowserFragment.kt @@ -926,7 +926,7 @@ abstract class BaseBrowserFragment : crashReporting = context.components.analytics.crashReporter, tabId = customTabSessionId, playerView = binding.browserLayout, - isEnabled = { NovaPictureInPicture.isEnabled(requireContext()) }, + isEnabled = { org.mozilla.fenix.components.NovaPictureInPicture.isEnabled(requireContext()) }, ) setupPipButton() @@ -2260,7 +2260,7 @@ abstract class BaseBrowserFragment : val mediaSession = session.mediaSessionState val hasVideo = mediaSession?.elementMetadata?.videoTrackCount?.let { it > 0 } == true val playbackState = mediaSession?.playbackState - val shouldShow = NovaPictureInPicture.isEnabled(requireContext()) && + val shouldShow = org.mozilla.fenix.components.NovaPictureInPicture.isEnabled(requireContext()) && session.content.fullScreen && hasVideo && playbackState in listOf( From 0c2d4fc27047bdad536d1cc3f565cc6648450f9c Mon Sep 17 00:00:00 2001 From: codegeasse Date: Thu, 24 Sep 2026 16:50:33 +0530 Subject: [PATCH 06/24] Add PiP toggle to browser menu --- .../fenix/components/menu/compose/MainMenu.kt | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/app/src/main/java/org/mozilla/fenix/components/menu/compose/MainMenu.kt b/app/src/main/java/org/mozilla/fenix/components/menu/compose/MainMenu.kt index 67ad29a5..7e31ed98 100644 --- a/app/src/main/java/org/mozilla/fenix/components/menu/compose/MainMenu.kt +++ b/app/src/main/java/org/mozilla/fenix/components/menu/compose/MainMenu.kt @@ -150,6 +150,8 @@ fun MainMenu( onNovaViewSource: () -> Unit = {}, novaVideoDownloaderEnabled: Boolean = true, onNovaVideoDownloaderToggle: () -> Unit = {}, + novaPictureInPictureEnabled: Boolean = false, + onNovaPictureInPictureToggle: () -> Unit = {}, canGoBack: Boolean, canGoForward: Boolean, scrollState: ScrollState, @@ -344,6 +346,25 @@ fun MainMenu( if (accessPoint == MenuAccessPoint.Browser) { MenuGroup { + MenuItem( + label = stringResource(id = R.string.browser_menu_picture_in_picture), + description = stringResource( + id = if (novaPictureInPictureEnabled) { + R.string.browser_menu_picture_in_picture_on + } else { + R.string.browser_menu_picture_in_picture_off + }, + ), + beforeIconPainter = painterResource(id = R.drawable.nova_ic_picture_in_picture), + onClick = onNovaPictureInPictureToggle, + afterContent = { + androidx.compose.material3.Switch( + checked = novaPictureInPictureEnabled, + onCheckedChange = { onNovaPictureInPictureToggle() }, + ) + }, + ) + MenuItem( label = stringResource(id = R.string.browser_menu_video_downloader), description = stringResource( From 9af45bb147512744cc10bec4da85a755bb008610 Mon Sep 17 00:00:00 2001 From: codegeasse Date: Thu, 24 Sep 2026 16:50:41 +0530 Subject: [PATCH 07/24] Wire Nova PiP menu toggle --- .../fenix/components/menu/MenuDialogFragment.kt | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/app/src/main/java/org/mozilla/fenix/components/menu/MenuDialogFragment.kt b/app/src/main/java/org/mozilla/fenix/components/menu/MenuDialogFragment.kt index eb43d398..c377778a 100644 --- a/app/src/main/java/org/mozilla/fenix/components/menu/MenuDialogFragment.kt +++ b/app/src/main/java/org/mozilla/fenix/components/menu/MenuDialogFragment.kt @@ -690,6 +690,21 @@ class MenuDialogFragment : BottomSheetDialogFragment() { Unit } + var novaPictureInPictureEnabled by remember { + mutableStateOf( + org.mozilla.fenix.components.NovaPictureInPicture.isEnabled(requireContext()), + ) + } + val onNovaPictureInPictureToggle = { + val nextEnabled = !novaPictureInPictureEnabled + org.mozilla.fenix.components.NovaPictureInPicture.setEnabled( + requireContext(), + nextEnabled, + ) + novaPictureInPictureEnabled = nextEnabled + Unit + } + @@ -747,6 +762,8 @@ class MenuDialogFragment : BottomSheetDialogFragment() { onNovaViewSource = onNovaViewSource, novaVideoDownloaderEnabled = novaVideoDownloaderEnabled, onNovaVideoDownloaderToggle = onNovaVideoDownloaderToggle, + novaPictureInPictureEnabled = novaPictureInPictureEnabled, + onNovaPictureInPictureToggle = onNovaPictureInPictureToggle, isAllWebExtensionsDisabled = isAllWebExtensionsDisabled, showIPProtection = components.ipProtection.store.state.isEligible, ipProtectionMenuState = ipProtectionMenuState, From 72db210da4cd14e3b50075673789cb18e30a388a Mon Sep 17 00:00:00 2001 From: codegeasse Date: Thu, 24 Sep 2026 16:50:48 +0530 Subject: [PATCH 08/24] Add Nova PiP menu strings --- app/src/main/res/values/strings.xml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 782450f3..cec1376a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -4318,6 +4318,10 @@ Keeps this site working while you use other apps or lock the screen. View page source Show the raw HTML source of this page in a new tab. + Picture-in-picture + On: show the PiP button when a video is playing. + Off: the in-video PiP button stays hidden. + Open video in picture-in-picture Video Downloader On: a download button appears on pages with a video. Off: nothing is shown while you browse. From 010f1eab23f9f8f8dcdc81c5e91a604729ba12a5 Mon Sep 17 00:00:00 2001 From: codegeasse Date: Thu, 24 Sep 2026 16:51:03 +0530 Subject: [PATCH 09/24] Gate manual PiP behind Nova setting --- .../components/feature/session/PictureInPictureFeature.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/android-components/components/feature/session/src/main/java/mozilla/components/feature/session/PictureInPictureFeature.kt b/android-components/components/feature/session/src/main/java/mozilla/components/feature/session/PictureInPictureFeature.kt index fba83bde..5d8042c1 100644 --- a/android-components/components/feature/session/src/main/java/mozilla/components/feature/session/PictureInPictureFeature.kt +++ b/android-components/components/feature/session/src/main/java/mozilla/components/feature/session/PictureInPictureFeature.kt @@ -34,6 +34,7 @@ class PictureInPictureFeature( private val crashReporting: CrashReporting? = null, private val tabId: String? = null, private val playerView: View? = null, + private val isEnabled: () -> Boolean = { true }, ) { internal val logger = Logger("PictureInPictureFeature") @@ -50,7 +51,7 @@ class PictureInPictureFeature( * Enters PiP from the dedicated in-video control. */ fun enterPipMode(): Boolean { - if (!hasSystemFeature || Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { + if (!isEnabled() || !hasSystemFeature || Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { return false } From 5dfbb91656ebc34fb8ace292aa6f3cec0bf099b6 Mon Sep 17 00:00:00 2001 From: codegeasse Date: Thu, 24 Sep 2026 16:51:13 +0530 Subject: [PATCH 10/24] Update PiP tests for manual controls --- .../session/PictureInPictureFeatureTest.kt | 246 ++++-------------- 1 file changed, 47 insertions(+), 199 deletions(-) diff --git a/android-components/components/feature/session/src/test/java/mozilla/components/feature/session/PictureInPictureFeatureTest.kt b/android-components/components/feature/session/src/test/java/mozilla/components/feature/session/PictureInPictureFeatureTest.kt index 2a7c6c42..89b85706 100644 --- a/android-components/components/feature/session/src/test/java/mozilla/components/feature/session/PictureInPictureFeatureTest.kt +++ b/android-components/components/feature/session/src/test/java/mozilla/components/feature/session/PictureInPictureFeatureTest.kt @@ -14,7 +14,6 @@ import mozilla.components.browser.state.action.ContentAction import mozilla.components.browser.state.engine.EngineMiddleware import mozilla.components.browser.state.state.BrowserState import mozilla.components.browser.state.state.MediaSessionState -import mozilla.components.browser.state.state.SessionState import mozilla.components.browser.state.state.createTab import mozilla.components.browser.state.store.BrowserStore import mozilla.components.concept.base.crash.CrashReporting @@ -23,19 +22,14 @@ import mozilla.components.support.test.any import mozilla.components.support.test.middleware.CaptureActionsMiddleware import mozilla.components.support.test.mock import mozilla.components.support.test.whenever -import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test import org.junit.runner.RunWith import org.mockito.Mockito -import org.mockito.Mockito.doReturn import org.mockito.Mockito.never -import org.mockito.Mockito.spy -import org.mockito.Mockito.times import org.mockito.Mockito.verify -import org.mockito.verification.VerificationMode import org.robolectric.annotation.Config @RunWith(AndroidJUnit4::class) @@ -51,86 +45,16 @@ class PictureInPictureFeatureTest { } @Test - @Config(sdk = [Build.VERSION_CODES.O]) - fun `on home pressed without system feature`() { - val captureActionsMiddleware = CaptureActionsMiddleware() - - val store = BrowserStore(middleware = listOf(captureActionsMiddleware)) - // clear InitAction - captureActionsMiddleware.reset() - - whenever(activity.packageManager.hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE)) - .thenReturn(false) - - val pictureInPictureFeature = - spy(PictureInPictureFeature(store, activity, crashReporting)) - - assertFalse(pictureInPictureFeature.onHomePressed()) - - captureActionsMiddleware.assertNoActionDispatched() - - verify(activity.packageManager).hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE) - verify(pictureInPictureFeature, never()).enterPipModeCompat() - } - - @Test - fun `on home pressed without a selected session`() { - val store = BrowserStore() - val pictureInPictureFeature = - spy(PictureInPictureFeature(store, activity, crashReporting)) - - assertFalse(pictureInPictureFeature.onHomePressed()) - verify(activity.packageManager).hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE) - verify(pictureInPictureFeature, never()).enterPipModeCompat() - } - - @Test - fun `on home pressed with a selected session without a fullscreen mode`() { - val selectedSession = createTab("https://mozilla.org").copyWithFullScreen(false) - val store = BrowserStore( - BrowserState( - tabs = listOf(selectedSession), - selectedTabId = selectedSession.id, - ), - ) - val pictureInPictureFeature = - spy(PictureInPictureFeature(store, activity, crashReporting)) - - assertFalse(selectedSession.content.fullScreen) - assertFalse(pictureInPictureFeature.onHomePressed()) - verify(pictureInPictureFeature, never()).enterPipModeCompat() - } - - @Test - fun `on home pressed with a selected session in fullscreen without media playing and without pip mode`() { - val controller = mock() - val selectedSession = createTab( - url = "https://mozilla.org", - mediaSessionState = MediaSessionState( - playbackState = MediaSession.PlaybackState.UNKNOWN, - controller = controller, - ), - ).copyWithFullScreen(true) - val store = BrowserStore( - BrowserState( - tabs = listOf(selectedSession), - selectedTabId = selectedSession.id, - ), - ) - val pictureInPictureFeature = - spy(PictureInPictureFeature(store, activity, crashReporting)) - - doReturn(false).`when`(pictureInPictureFeature).enterPipModeCompat() - - assertFalse(selectedSession.mediaSessionState?.playbackState == MediaSession.PlaybackState.PLAYING) - assertFalse(pictureInPictureFeature.onHomePressed()) - verify(pictureInPictureFeature, never()).enterPipModeCompat() + fun `home never enters PiP automatically`() { + val feature = PictureInPictureFeature(BrowserStore(), activity, crashReporting) + assertFalse(feature.onHomePressed()) + verify(activity, never()).enterPictureInPictureMode(any()) } @Test - fun `on home pressed with a selected session in fullscreen with media playing and without pip mode`() { + fun `manual PiP enters for fullscreen playing video`() { val controller = mock() - val selectedSession = createTab( + val session = createTab( url = "https://mozilla.org", mediaSessionState = MediaSessionState( playbackState = MediaSession.PlaybackState.PLAYING, @@ -141,53 +65,44 @@ class PictureInPictureFeatureTest { videoTrackCount = 1, ), ), - ).copyWithFullScreen(true) + ).copy(content = createTab("https://mozilla.org").content.copy(fullScreen = true)) + val store = BrowserStore( BrowserState( - tabs = listOf(selectedSession), - selectedTabId = selectedSession.id, + tabs = listOf(session), + selectedTabId = session.id, ), ) - val pictureInPictureFeature = - spy(PictureInPictureFeature(store, activity, crashReporting)) + whenever(activity.enterPictureInPictureMode(any())).thenReturn(true) - doReturn(false).`when`(pictureInPictureFeature).enterPipModeCompat() + val feature = PictureInPictureFeature( + store, + activity, + crashReporting, + isEnabled = { true }, + ) - assertTrue(selectedSession.mediaSessionState?.playbackState == MediaSession.PlaybackState.PLAYING) - assertFalse(pictureInPictureFeature.onHomePressed()) - verify(pictureInPictureFeature).enterPipModeCompat() + assertTrue(feature.enterPipMode()) + verify(activity).enterPictureInPictureMode(any()) } @Test - fun `on home pressed with a selected session in fullscreen without media playing and with pip mode`() { - val controller = mock() - val selectedSession = createTab( - url = "https://mozilla.org", - mediaSessionState = MediaSessionState( - playbackState = MediaSession.PlaybackState.UNKNOWN, - controller = controller, - ), - ).copyWithFullScreen(true) - val store = BrowserStore( - BrowserState( - tabs = listOf(selectedSession), - selectedTabId = selectedSession.id, - ), + fun `manual PiP stays disabled when Nova setting is off`() { + val feature = PictureInPictureFeature( + BrowserStore(), + activity, + crashReporting, + isEnabled = { false }, ) - val pictureInPictureFeature = - spy(PictureInPictureFeature(store, activity, crashReporting)) - doReturn(true).`when`(pictureInPictureFeature).enterPipModeCompat() - - assertFalse(selectedSession.mediaSessionState?.playbackState == MediaSession.PlaybackState.PLAYING) - assertFalse(pictureInPictureFeature.onHomePressed()) - verify(pictureInPictureFeature, never()).enterPipModeCompat() + assertFalse(feature.enterPipMode()) + verify(activity, never()).enterPictureInPictureMode(any()) } @Test - fun `on home pressed with a selected session in fullscreen with media playing and with pip mode`() { + fun `manual PiP requires a video track`() { val controller = mock() - val selectedSession = createTab( + val session = createTab( url = "https://mozilla.org", mediaSessionState = MediaSessionState( playbackState = MediaSession.PlaybackState.PLAYING, @@ -195,89 +110,38 @@ class PictureInPictureFeatureTest { elementMetadata = MediaSession.ElementMetadata( width = 1920, height = 1080, - videoTrackCount = 1, + videoTrackCount = 0, ), ), - ).copyWithFullScreen(true) + ).copy(content = createTab("https://mozilla.org").content.copy(fullScreen = true)) + val store = BrowserStore( BrowserState( - tabs = listOf(selectedSession), - selectedTabId = selectedSession.id, + tabs = listOf(session), + selectedTabId = session.id, ), ) - val pictureInPictureFeature = - spy(PictureInPictureFeature(store, activity, crashReporting)) - - doReturn(true).`when`(pictureInPictureFeature).enterPipModeCompat() + val feature = PictureInPictureFeature(store, activity, crashReporting) - assertTrue(selectedSession.mediaSessionState?.playbackState == MediaSession.PlaybackState.PLAYING) - assertTrue(pictureInPictureFeature.onHomePressed()) - verify(pictureInPictureFeature).enterPipModeCompat() + assertFalse(feature.enterPipMode()) + verify(activity, never()).enterPictureInPictureMode(any()) } @Test @Config(sdk = [Build.VERSION_CODES.O]) - fun `enter pip mode compat without system feature on android o`() { + fun `manual PiP returns false without system support`() { whenever(activity.packageManager.hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE)) .thenReturn(false) - val pictureInPictureFeature = - PictureInPictureFeature(BrowserStore(), activity, crashReporting) + val feature = PictureInPictureFeature(BrowserStore(), activity, crashReporting) - assertFalse(pictureInPictureFeature.enterPipModeCompat()) + assertFalse(feature.enterPipMode()) verify(activity, never()).enterPictureInPictureMode(any()) - verifyDeprecatedPictureInPictureMode(activity, never()) } @Test - @Config(sdk = [Build.VERSION_CODES.O]) - fun `enter pip mode compat with system feature on android o but entering throws exception`() { - val controller = mock() - val selectedSession = createTab( - url = "https://mozilla.org", - mediaSessionState = MediaSessionState( - playbackState = MediaSession.PlaybackState.PLAYING, - controller = controller, - elementMetadata = MediaSession.ElementMetadata( - width = 1920, - height = 1080, - videoTrackCount = 1, - ), - ), - ).copyWithFullScreen(true) - val store = BrowserStore( - BrowserState( - tabs = listOf(selectedSession), - selectedTabId = selectedSession.id, - ), - ) - - whenever(activity.packageManager.hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE)) - .thenReturn(true) - whenever(activity.enterPictureInPictureMode(any())).thenThrow(IllegalStateException()) - - val pictureInPictureFeature = - PictureInPictureFeature(store, activity, crashReporting) - assertFalse(pictureInPictureFeature.onHomePressed()) - verify(crashReporting).submitCaughtException(any()) - } - - @Test - @Config(sdk = [Build.VERSION_CODES.O]) - fun `enter pip mode compat on android o and above`() { - val pictureInPictureFeature = - PictureInPictureFeature(BrowserStore(), activity, crashReporting) - - whenever(activity.enterPictureInPictureMode(any())).thenReturn(true) - - assertTrue(pictureInPictureFeature.enterPipModeCompat()) - verify(activity).enterPictureInPictureMode(any()) - } - - @Test - fun `on pip mode changed`() { + fun `PiP mode changes are dispatched to the selected session`() { val captureActionsMiddleware = CaptureActionsMiddleware() - val store = BrowserStore( initialState = BrowserState(), middleware = listOf(captureActionsMiddleware) + EngineMiddleware.create( @@ -286,37 +150,21 @@ class PictureInPictureFeatureTest { ), ) - val pipFeature = PictureInPictureFeature( - store, - activity, - crashReporting, + val feature = PictureInPictureFeature( + store = store, + activity = activity, + crashReporting = crashReporting, tabId = "tab-id", ) - pipFeature.onPictureInPictureModeChanged(isInPipMode = true) + feature.onPictureInPictureModeChanged(true) captureActionsMiddleware.assertFirstAction(ContentAction.PictureInPictureChangedAction::class) { action -> - assertEquals("tab-id", action.sessionId) assertTrue(action.pipEnabled) } - pipFeature.onPictureInPictureModeChanged(isInPipMode = false) + feature.onPictureInPictureModeChanged(false) captureActionsMiddleware.assertLastAction(ContentAction.PictureInPictureChangedAction::class) { action -> - assertEquals("tab-id", action.sessionId) assertFalse(action.pipEnabled) } - verify(activity, never()).enterPictureInPictureMode(any()) - verifyDeprecatedPictureInPictureMode(activity, never()) } - - @Suppress("Deprecation") - private fun verifyDeprecatedPictureInPictureMode( - activity: Activity, - mode: VerificationMode = times(1), - ) { - verify(activity, mode).enterPictureInPictureMode() - } - - @Suppress("Unchecked_Cast") - private fun T.copyWithFullScreen(fullScreen: Boolean): T = - createCopy(content = content.copy(fullScreen = fullScreen)) as T } From 20ff906265be5c5e2f98b526052cd79bc2b746e0 Mon Sep 17 00:00:00 2001 From: codegeasse Date: Thu, 24 Sep 2026 16:51:29 +0530 Subject: [PATCH 11/24] Allow PiP from paused video too --- .../feature/session/PictureInPictureFeature.kt | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/android-components/components/feature/session/src/main/java/mozilla/components/feature/session/PictureInPictureFeature.kt b/android-components/components/feature/session/src/main/java/mozilla/components/feature/session/PictureInPictureFeature.kt index 5d8042c1..255f0937 100644 --- a/android-components/components/feature/session/src/main/java/mozilla/components/feature/session/PictureInPictureFeature.kt +++ b/android-components/components/feature/session/src/main/java/mozilla/components/feature/session/PictureInPictureFeature.kt @@ -58,7 +58,10 @@ class PictureInPictureFeature( val session = store.state.findTabOrCustomTabOrSelectedTab(tabId) val mediaSession = session?.mediaSessionState val canEnter = session?.content?.fullScreen == true && - mediaSession?.playbackState == MediaSession.PlaybackState.PLAYING && + mediaSession?.playbackState in listOf( + MediaSession.PlaybackState.PLAYING, + MediaSession.PlaybackState.PAUSED, + ) && mediaSession.elementMetadata?.videoTrackCount?.let { it > 0 } == true if (!canEnter) { @@ -90,7 +93,10 @@ class PictureInPictureFeature( val mediaSession = session?.mediaSessionState val isVideoPlaying = session?.content?.fullScreen == true && - mediaSession?.playbackState == MediaSession.PlaybackState.PLAYING && + mediaSession?.playbackState in listOf( + MediaSession.PlaybackState.PLAYING, + MediaSession.PlaybackState.PAUSED, + ) && mediaSession.elementMetadata?.videoTrackCount?.let { it > 0 } == true val builder = PictureInPictureParams.Builder() From 386a2bfe3967628f9b1475d1130e92e12e1f2ab0 Mon Sep 17 00:00:00 2001 From: codegeasse Date: Thu, 24 Sep 2026 16:51:57 +0530 Subject: [PATCH 12/24] Tidy PiP test session setup From ac17cf0c3fdd35bd19598fe7921293b7631ef0e0 Mon Sep 17 00:00:00 2001 From: codegeasse Date: Thu, 24 Sep 2026 16:52:16 +0530 Subject: [PATCH 13/24] Fix PiP test artifact signing --- .github/workflows/build-video-downloader.yml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/build-video-downloader.yml b/.github/workflows/build-video-downloader.yml index 1efed245..15fee567 100644 --- a/.github/workflows/build-video-downloader.yml +++ b/.github/workflows/build-video-downloader.yml @@ -45,7 +45,7 @@ jobs: - name: Create version name run: | - VERSION_NAME="${{ github.event.inputs.version_name || '3.1.0-video' }}" + VERSION_NAME="${{ github.event.inputs.version_name || '1.4.6-pip-test' }}" echo "VERSION_NAME=$VERSION_NAME" >> $GITHUB_ENV echo "Building Nova PiP test APK, version $VERSION_NAME" @@ -80,13 +80,13 @@ jobs: --ks-pass env:KEY_STORE_PASSWORD \ --key-pass env:KEY_PASSWORD \ --out nova-pip-video-test.apk "$APK_PATH" - $HOME/android-sdk/android-sdk-linux/build-tools/$BUILD_TOOL_VERSION/apksigner verify --print-certs nova-video-downloader.apk - ls -alh nova-video-downloader.apk - $HOME/android-sdk/android-sdk-linux/build-tools/$BUILD_TOOL_VERSION/aapt dump badging nova-video-downloader.apk | head -3 + $HOME/android-sdk/android-sdk-linux/build-tools/$BUILD_TOOL_VERSION/apksigner verify --print-certs nova-pip-video-test.apk + ls -alh nova-pip-video-test.apk + $HOME/android-sdk/android-sdk-linux/build-tools/$BUILD_TOOL_VERSION/aapt dump badging nova-pip-video-test.apk | head -3 - name: Upload test APK as artifact (no release) uses: actions/upload-artifact@v4 with: - name: nova-video-downloader.apk - path: nova-video-downloader.apk + name: nova-pip-video-test.apk + path: nova-pip-video-test.apk retention-days: 30 From 654c68234946c6ba7cc92bca2607dae695503b85 Mon Sep 17 00:00:00 2001 From: codegeasse Date: Thu, 24 Sep 2026 17:02:12 +0530 Subject: [PATCH 14/24] Fix nullable media session PiP build --- .../components/feature/session/PictureInPictureFeature.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/android-components/components/feature/session/src/main/java/mozilla/components/feature/session/PictureInPictureFeature.kt b/android-components/components/feature/session/src/main/java/mozilla/components/feature/session/PictureInPictureFeature.kt index 255f0937..3744e353 100644 --- a/android-components/components/feature/session/src/main/java/mozilla/components/feature/session/PictureInPictureFeature.kt +++ b/android-components/components/feature/session/src/main/java/mozilla/components/feature/session/PictureInPictureFeature.kt @@ -62,7 +62,7 @@ class PictureInPictureFeature( MediaSession.PlaybackState.PLAYING, MediaSession.PlaybackState.PAUSED, ) && - mediaSession.elementMetadata?.videoTrackCount?.let { it > 0 } == true + mediaSession?.elementMetadata?.videoTrackCount?.let { it > 0 } == true if (!canEnter) { return false @@ -102,7 +102,7 @@ class PictureInPictureFeature( val builder = PictureInPictureParams.Builder() if (isVideoPlaying) { - val metadata = mediaSession.elementMetadata + val metadata = mediaSession?.elementMetadata val width = metadata?.width ?: 0L val height = metadata?.height ?: 0L From 3ce2b23e7ccb733af52f6e000192e4efb14d4fab Mon Sep 17 00:00:00 2001 From: codegeasse Date: Thu, 24 Sep 2026 17:10:46 +0530 Subject: [PATCH 15/24] Fix remaining nullable PiP media access --- .../components/feature/session/PictureInPictureFeature.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/android-components/components/feature/session/src/main/java/mozilla/components/feature/session/PictureInPictureFeature.kt b/android-components/components/feature/session/src/main/java/mozilla/components/feature/session/PictureInPictureFeature.kt index 3744e353..068ccdb9 100644 --- a/android-components/components/feature/session/src/main/java/mozilla/components/feature/session/PictureInPictureFeature.kt +++ b/android-components/components/feature/session/src/main/java/mozilla/components/feature/session/PictureInPictureFeature.kt @@ -97,7 +97,7 @@ class PictureInPictureFeature( MediaSession.PlaybackState.PLAYING, MediaSession.PlaybackState.PAUSED, ) && - mediaSession.elementMetadata?.videoTrackCount?.let { it > 0 } == true + mediaSession?.elementMetadata?.videoTrackCount?.let { it > 0 } == true val builder = PictureInPictureParams.Builder() From dc497507f10a2ffc68d7d781a268fbd14fdbceaa Mon Sep 17 00:00:00 2001 From: codegeasse Date: Thu, 24 Sep 2026 17:19:19 +0530 Subject: [PATCH 16/24] Fix PiP compiler warnings treated as errors --- .../components/feature/session/PictureInPictureFeature.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/android-components/components/feature/session/src/main/java/mozilla/components/feature/session/PictureInPictureFeature.kt b/android-components/components/feature/session/src/main/java/mozilla/components/feature/session/PictureInPictureFeature.kt index 068ccdb9..de2c67a4 100644 --- a/android-components/components/feature/session/src/main/java/mozilla/components/feature/session/PictureInPictureFeature.kt +++ b/android-components/components/feature/session/src/main/java/mozilla/components/feature/session/PictureInPictureFeature.kt @@ -102,7 +102,7 @@ class PictureInPictureFeature( val builder = PictureInPictureParams.Builder() if (isVideoPlaying) { - val metadata = mediaSession?.elementMetadata + val metadata = mediaSession.elementMetadata val width = metadata?.width ?: 0L val height = metadata?.height ?: 0L @@ -157,7 +157,7 @@ class PictureInPictureFeature( } } - (playerView as? View)?.let { view -> + playerView?.let { view -> val sourceRect = Rect() if (view.getGlobalVisibleRect(sourceRect)) { builder.setSourceRectHint(sourceRect) From b8ca4dca6cfc9e50cd497b4d0352b256608e7499 Mon Sep 17 00:00:00 2001 From: codegeasse Date: Thu, 24 Sep 2026 17:31:01 +0530 Subject: [PATCH 17/24] Add missing Nova PiP resources to test branch --- .../fenix/components/NovaPictureInPicture.kt | 27 +++++++++++++++++++ .../drawable/nova_ic_picture_in_picture.xml | 15 +++++++++++ 2 files changed, 42 insertions(+) create mode 100644 app/src/main/java/org/mozilla/fenix/components/NovaPictureInPicture.kt create mode 100644 app/src/main/res/drawable/nova_ic_picture_in_picture.xml diff --git a/app/src/main/java/org/mozilla/fenix/components/NovaPictureInPicture.kt b/app/src/main/java/org/mozilla/fenix/components/NovaPictureInPicture.kt new file mode 100644 index 00000000..be9bd527 --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/components/NovaPictureInPicture.kt @@ -0,0 +1,27 @@ +package org.mozilla.fenix.components + +import android.content.Context + +/** + * Stores the user's preference for Nova's manual video Picture-in-Picture control. + * + * PiP is opt-in from the three-dot menu. The preference is kept separate from + * Android's activity-level PiP support so the in-video control can be hidden + * immediately when the user turns the feature off. + */ +object NovaPictureInPicture { + + private const val PREFERENCES_NAME = "NovaPictureInPicture" + private const val KEY_ENABLED = "novaPictureInPictureEnabled" + + private fun prefs(context: Context) = + context.getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE) + + /** Whether the in-video PiP control is enabled. Defaults to off. */ + fun isEnabled(context: Context): Boolean = + prefs(context).getBoolean(KEY_ENABLED, false) + + fun setEnabled(context: Context, enabled: Boolean) { + prefs(context).edit().putBoolean(KEY_ENABLED, enabled).apply() + } +} diff --git a/app/src/main/res/drawable/nova_ic_picture_in_picture.xml b/app/src/main/res/drawable/nova_ic_picture_in_picture.xml new file mode 100644 index 00000000..89cc5bcd --- /dev/null +++ b/app/src/main/res/drawable/nova_ic_picture_in_picture.xml @@ -0,0 +1,15 @@ + + + + + From 24c54855fb433969f827fa8c008e3f5a24a947d1 Mon Sep 17 00:00:00 2001 From: codegeasse Date: Thu, 24 Sep 2026 18:27:53 +0530 Subject: [PATCH 18/24] Fix PiP button visibility and media fullscreen UI --- .../fenix/browser/BaseBrowserFragment.kt | 38 ++++++++++++++++--- 1 file changed, 33 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/org/mozilla/fenix/browser/BaseBrowserFragment.kt b/app/src/main/java/org/mozilla/fenix/browser/BaseBrowserFragment.kt index abc0ef67..d49edc2c 100644 --- a/app/src/main/java/org/mozilla/fenix/browser/BaseBrowserFragment.kt +++ b/app/src/main/java/org/mozilla/fenix/browser/BaseBrowserFragment.kt @@ -23,6 +23,7 @@ import android.view.ViewGroup import android.view.accessibility.AccessibilityManager import android.graphics.drawable.GradientDrawable import android.widget.ImageButton +import android.widget.FrameLayout import androidx.activity.result.ActivityResultLauncher import androidx.annotation.CallSuper import androidx.annotation.VisibleForTesting @@ -75,6 +76,7 @@ import mozilla.components.compose.browser.toolbar.store.BrowserToolbarStore import mozilla.components.concept.base.crash.Breadcrumb import mozilla.components.concept.engine.permission.SitePermissions import mozilla.components.concept.engine.prompt.ShareData +import mozilla.components.concept.engine.mediasession.MediaSession import mozilla.components.concept.storage.Address import mozilla.components.concept.storage.CreditCardEntry import mozilla.components.concept.storage.Login @@ -1342,6 +1344,7 @@ abstract class BaseBrowserFragment : listOf( tab.content.pictureInPictureEnabled, tab.content.fullScreen, + media?.fullscreen, media?.playbackState, media?.elementMetadata?.width, media?.elementMetadata?.height, @@ -1349,6 +1352,13 @@ abstract class BaseBrowserFragment : ) } .collect { tab -> + val mediaFullscreen = tab.mediaSessionState?.fullscreen == true + if (!tab.content.pictureInPictureEnabled && + mediaFullscreen != tab.content.fullScreen + ) { + fullScreenChanged(mediaFullscreen) + } + pipModeChanged(tab) pipFeature?.updatePipParams(tab) if (tab.content.pictureInPictureEnabled) { @@ -2039,6 +2049,13 @@ abstract class BaseBrowserFragment : @CallSuper override fun onBackPressed(): Boolean { + val activeSession = store.state.findTabOrCustomTabOrSelectedTab(customTabSessionId) + if (activeSession?.mediaSessionState?.fullscreen == true) { + requireComponents.useCases.sessionUseCases.exitFullscreen(activeSession.id) + fullScreenChanged(false) + return true + } + return findInPageIntegration.onBackPressed() || fullScreenFeature.onBackPressed() || promptsFeature.onBackPressed() || @@ -2244,9 +2261,9 @@ abstract class BaseBrowserFragment : } } - binding.browserLayout.addView( + binding.engineView.addView( button, - CoordinatorLayout.LayoutParams(size, size).apply { + FrameLayout.LayoutParams(size, size).apply { gravity = Gravity.TOP or Gravity.END topMargin = margin marginEnd = margin @@ -2261,7 +2278,6 @@ abstract class BaseBrowserFragment : val hasVideo = mediaSession?.elementMetadata?.videoTrackCount?.let { it > 0 } == true val playbackState = mediaSession?.playbackState val shouldShow = org.mozilla.fenix.components.NovaPictureInPicture.isEnabled(requireContext()) && - session.content.fullScreen && hasVideo && playbackState in listOf( mozilla.components.concept.engine.mediasession.MediaSession.PlaybackState.PLAYING, @@ -2299,7 +2315,19 @@ abstract class BaseBrowserFragment : } final override fun onPictureInPictureModeChanged(isInPipMode: Boolean) { - if (isInPipMode) MediaState.pictureInPicture.record(NoExtras()) + if (isInPipMode) { + MediaState.pictureInPicture.record(NoExtras()) + hidePipButton() + expandBrowserView() + } else { + val session = store.state.findTabOrCustomTabOrSelectedTab(customTabSessionId) + if (session?.content?.fullScreen != true) { + activity?.exitImmersiveMode( + unregisterOnApplyWindowInsetsListener = binding.engineView::removeWindowInsetsListener, + ) + collapseBrowserView() + } + } pipFeature?.onPictureInPictureModeChanged(isInPipMode) } @@ -2445,7 +2473,7 @@ abstract class BaseBrowserFragment : ) pipButtonHandler.removeCallbacks(hidePipButtonRunnable) - pipButton?.let { binding.browserLayout.removeView(it) } + pipButton?.let { binding.engineView.removeView(it) } pipButton = null binding.engineView.setActivityContext(null) From 10b85e0a6ca0db5a0e523cdf1cbd3e2ef258d3a3 Mon Sep 17 00:00:00 2001 From: codegeasse Date: Thu, 24 Sep 2026 18:28:18 +0530 Subject: [PATCH 19/24] Allow PiP from any active video --- .../components/feature/session/PictureInPictureFeature.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/android-components/components/feature/session/src/main/java/mozilla/components/feature/session/PictureInPictureFeature.kt b/android-components/components/feature/session/src/main/java/mozilla/components/feature/session/PictureInPictureFeature.kt index de2c67a4..2e2e1518 100644 --- a/android-components/components/feature/session/src/main/java/mozilla/components/feature/session/PictureInPictureFeature.kt +++ b/android-components/components/feature/session/src/main/java/mozilla/components/feature/session/PictureInPictureFeature.kt @@ -57,7 +57,7 @@ class PictureInPictureFeature( val session = store.state.findTabOrCustomTabOrSelectedTab(tabId) val mediaSession = session?.mediaSessionState - val canEnter = session?.content?.fullScreen == true && + val canEnter = mediaSession?.playbackState in listOf( MediaSession.PlaybackState.PLAYING, MediaSession.PlaybackState.PAUSED, @@ -92,7 +92,7 @@ class PictureInPictureFeature( } val mediaSession = session?.mediaSessionState - val isVideoPlaying = session?.content?.fullScreen == true && + val isVideoPlaying = mediaSession?.playbackState in listOf( MediaSession.PlaybackState.PLAYING, MediaSession.PlaybackState.PAUSED, @@ -102,7 +102,7 @@ class PictureInPictureFeature( val builder = PictureInPictureParams.Builder() if (isVideoPlaying) { - val metadata = mediaSession.elementMetadata + val metadata = mediaSession?.elementMetadata val width = metadata?.width ?: 0L val height = metadata?.height ?: 0L From 8e3994c27aa01966097df55fe3363b81d6151e1f Mon Sep 17 00:00:00 2001 From: codegeasse Date: Thu, 24 Sep 2026 18:28:33 +0530 Subject: [PATCH 20/24] Stabilize video fullscreen orientation --- .../MediaSessionFullscreenFeature.kt | 35 +++++++++++++------ 1 file changed, 25 insertions(+), 10 deletions(-) diff --git a/android-components/components/feature/media/src/main/java/mozilla/components/feature/media/fullscreen/MediaSessionFullscreenFeature.kt b/android-components/components/feature/media/src/main/java/mozilla/components/feature/media/fullscreen/MediaSessionFullscreenFeature.kt index b1a6bdcd..0004b40a 100644 --- a/android-components/components/feature/media/src/main/java/mozilla/components/feature/media/fullscreen/MediaSessionFullscreenFeature.kt +++ b/android-components/components/feature/media/src/main/java/mozilla/components/feature/media/fullscreen/MediaSessionFullscreenFeature.kt @@ -58,20 +58,35 @@ class MediaSessionFullscreenFeature( @Suppress("SourceLockedOrientationActivity") // We deliberately want to lock the orientation here. private fun setOrientationForTabState(activeTabState: SessionState) { - when (activeTabState.mediaSessionState?.elementMetadata?.portrait) { - true -> - activity.requestedOrientation = - ActivityInfo.SCREEN_ORIENTATION_USER_PORTRAIT + val metadata = activeTabState.mediaSessionState?.elementMetadata - false -> - if (activity.isInPictureInPictureMode) { - activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED + if (activity.isInPictureInPictureMode) { + activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED + return + } + + when { + // Prefer the actual media dimensions. This avoids a temporary + // portrait lock while a landscape player is still reporting its + // metadata during fullscreen entry. + metadata != null && metadata.width > 0L && metadata.height > 0L -> { + activity.requestedOrientation = if (metadata.height > metadata.width) { + ActivityInfo.SCREEN_ORIENTATION_USER_PORTRAIT } else { - activity.requestedOrientation = - ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE + ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE } + } - null -> activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_USER + // A video fullscreen session without dimensions should still + // enter landscape instead of briefly switching to portrait. + metadata?.videoTrackCount?.let { it > 0 } == true -> { + activity.requestedOrientation = + ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE + } + + else -> { + activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_USER + } } } From 57bbb7ac1a5d47a91b3c75d9d674e1ddc81503e7 Mon Sep 17 00:00:00 2001 From: codegeasse Date: Thu, 24 Sep 2026 18:29:15 +0530 Subject: [PATCH 21/24] Trigger PiP UI fix test build --- .github/workflows/build-video-downloader.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/build-video-downloader.yml b/.github/workflows/build-video-downloader.yml index 15fee567..bb4c67fc 100644 --- a/.github/workflows/build-video-downloader.yml +++ b/.github/workflows/build-video-downloader.yml @@ -1,4 +1,5 @@ name: Build Nova PiP Video Test (feature branch) +# PiP UI/fullscreen fix test build on: workflow_dispatch: From 76665db59d86bc117da181c57d4f422ad19f2e91 Mon Sep 17 00:00:00 2001 From: codegeasse Date: Thu, 24 Sep 2026 18:30:18 +0530 Subject: [PATCH 22/24] Run PiP fix build on test PR --- .github/workflows/build-video-downloader.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/build-video-downloader.yml b/.github/workflows/build-video-downloader.yml index bb4c67fc..989983ef 100644 --- a/.github/workflows/build-video-downloader.yml +++ b/.github/workflows/build-video-downloader.yml @@ -8,6 +8,8 @@ on: description: 'Version name for the PiP test build' required: true default: '1.4.6-pip-test' + pull_request: + branches: [ main ] push: branches: [ feature/video-downloader, feature/pip-video-test ] paths-ignore: From dc20577c223e311182c55c07fe72a5b445c55c99 Mon Sep 17 00:00:00 2001 From: codegeasse Date: Thu, 24 Sep 2026 18:52:17 +0530 Subject: [PATCH 23/24] Fix PiP build compatibility --- .../feature/session/PictureInPictureFeature.kt | 9 --------- 1 file changed, 9 deletions(-) diff --git a/android-components/components/feature/session/src/main/java/mozilla/components/feature/session/PictureInPictureFeature.kt b/android-components/components/feature/session/src/main/java/mozilla/components/feature/session/PictureInPictureFeature.kt index 2e2e1518..afc6dc01 100644 --- a/android-components/components/feature/session/src/main/java/mozilla/components/feature/session/PictureInPictureFeature.kt +++ b/android-components/components/feature/session/src/main/java/mozilla/components/feature/session/PictureInPictureFeature.kt @@ -7,9 +7,7 @@ package mozilla.components.feature.session import android.app.Activity import android.app.PictureInPictureParams import android.content.pm.PackageManager -import android.graphics.Rect import android.os.Build -import android.view.View import mozilla.components.browser.state.action.ContentAction import mozilla.components.browser.state.state.SessionState import mozilla.components.browser.state.selector.findTabOrCustomTabOrSelectedTab @@ -33,7 +31,6 @@ class PictureInPictureFeature( private val activity: Activity, private val crashReporting: CrashReporting? = null, private val tabId: String? = null, - private val playerView: View? = null, private val isEnabled: () -> Boolean = { true }, ) { internal val logger = Logger("PictureInPictureFeature") @@ -118,12 +115,6 @@ class PictureInPictureFeature( } } - playerView?.let { view -> - val sourceRect = Rect() - if (view.getGlobalVisibleRect(sourceRect)) { - builder.setSourceRectHint(sourceRect) - } - } if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { builder.setAutoEnterEnabled(false) From deda7a2d0af08c81f79152889e2e757736149516 Mon Sep 17 00:00:00 2001 From: codegeasse Date: Thu, 24 Sep 2026 18:52:39 +0530 Subject: [PATCH 24/24] Fix PiP build compatibility --- .../main/java/org/mozilla/fenix/browser/BaseBrowserFragment.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/app/src/main/java/org/mozilla/fenix/browser/BaseBrowserFragment.kt b/app/src/main/java/org/mozilla/fenix/browser/BaseBrowserFragment.kt index d49edc2c..f3a68243 100644 --- a/app/src/main/java/org/mozilla/fenix/browser/BaseBrowserFragment.kt +++ b/app/src/main/java/org/mozilla/fenix/browser/BaseBrowserFragment.kt @@ -927,7 +927,6 @@ abstract class BaseBrowserFragment : activity = requireActivity(), crashReporting = context.components.analytics.crashReporter, tabId = customTabSessionId, - playerView = binding.browserLayout, isEnabled = { org.mozilla.fenix.components.NovaPictureInPicture.isEnabled(requireContext()) }, ) setupPipButton()