diff --git a/.github/workflows/build-video-downloader.yml b/.github/workflows/build-video-downloader.yml index b904700d..989983ef 100644 --- a/.github/workflows/build-video-downloader.yml +++ b/.github/workflows/build-video-downloader.yml @@ -1,14 +1,17 @@ -name: Build Nova Video Downloader (feature branch) +name: Build Nova PiP Video Test (feature branch) +# PiP UI/fullscreen fix test build 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' + pull_request: + branches: [ main ] push: - branches: [ feature/video-downloader ] + branches: [ feature/video-downloader, feature/pip-video-test ] paths-ignore: - 'README.md' - 'screenshots/**' @@ -23,7 +26,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: @@ -45,9 +48,9 @@ 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 video-downloader test APK, version $VERSION_NAME" + echo "Building Nova PiP test APK, version $VERSION_NAME" - name: Build forkRelease variant timeout-minutes: 90 @@ -79,14 +82,14 @@ jobs: --ks-key-alias "$ALIAS" \ --ks-pass env:KEY_STORE_PASSWORD \ --key-pass env:KEY_PASSWORD \ - --out nova-video-downloader.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 + --out nova-pip-video-test.apk "$APK_PATH" + $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 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 + } } } 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..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,7 +7,9 @@ package mozilla.components.feature.session import android.app.Activity import android.app.PictureInPictureParams import android.content.pm.PackageManager +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 +31,7 @@ class PictureInPictureFeature( private val activity: Activity, private val crashReporting: CrashReporting? = null, private val tabId: String? = null, + private val isEnabled: () -> Boolean = { true }, ) { internal val logger = Logger("PictureInPictureFeature") @@ -36,19 +39,36 @@ 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 (!isEnabled() || !hasSystemFeature || Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { return false } 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 canEnter = + mediaSession?.playbackState in listOf( + MediaSession.PlaybackState.PLAYING, + MediaSession.PlaybackState.PAUSED, + ) && + mediaSession?.elementMetadata?.videoTrackCount?.let { it > 0 } == true + + if (!canEnter) { + return false + } + + return try { + 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 @@ -58,13 +78,89 @@ class PictureInPictureFeature( /** * Enter Picture-in-Picture mode. */ - fun enterPipModeCompat() = when { + fun enterPipModeCompat(session: SessionState? = null) = when { !hasSystemFeature -> false - else -> enterPipModeForO() + else -> enterPipModeForO(session) + } + + fun updatePipParams(session: SessionState?) { + if (!hasSystemFeature) { + return + } + + val mediaSession = session?.mediaSessionState + val isVideoPlaying = + mediaSession?.playbackState in listOf( + MediaSession.PlaybackState.PLAYING, + MediaSession.PlaybackState.PAUSED, + ) && + 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(), + ), + ) + } + } + + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + builder.setAutoEnterEnabled(false) + builder.setSeamlessResizeEnabled(true) + } + } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + builder.setAutoEnterEnabled(false) + } + + activity.setPictureInPictureParams(builder.build()) } - private fun enterPipModeForO() = - activity.enterPictureInPictureMode(PictureInPictureParams.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..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,218 +45,103 @@ 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, controller = controller, + elementMetadata = MediaSession.ElementMetadata( + width = 1920, + height = 1080, + 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, controller = controller, + elementMetadata = MediaSession.ElementMetadata( + width = 1920, + height = 1080, + 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, - ), - ).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( @@ -271,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 } 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..f3a68243 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,9 @@ 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 android.widget.FrameLayout import androidx.activity.result.ActivityResultLauncher import androidx.annotation.CallSuper import androidx.annotation.VisibleForTesting @@ -71,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 @@ -340,6 +346,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,7 +927,9 @@ abstract class BaseBrowserFragment : activity = requireActivity(), crashReporting = context.components.analytics.crashReporter, tabId = customTabSessionId, + isEnabled = { org.mozilla.fenix.components.NovaPictureInPicture.isEnabled(requireContext()) }, ) + setupPipButton() biometricPromptFeature.set( feature = BiometricPromptFeature( @@ -1323,8 +1338,34 @@ 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?.fullscreen, + media?.playbackState, + media?.elementMetadata?.width, + media?.elementMetadata?.height, + media?.elementMetadata?.videoTrackCount, + ) + } + .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) { + hidePipButton() + } else { + updatePipButton(tab) + } + } } binding.swipeRefresh.isEnabled = shouldPullToRefreshBeEnabled(false) @@ -2007,6 +2048,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() || @@ -2183,6 +2231,78 @@ 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.engineView.addView( + button, + FrameLayout.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 = org.mozilla.fenix.components.NovaPictureInPicture.isEnabled(requireContext()) && + 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 */ @@ -2194,7 +2314,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) } @@ -2339,6 +2471,10 @@ abstract class BaseBrowserFragment : message = "onDestroyView()", ) + pipButtonHandler.removeCallbacks(hidePipButtonRunnable) + pipButton?.let { binding.engineView.removeView(it) } + pipButton = null + binding.engineView.setActivityContext(null) requireContext().accessibilityManager.removeAccessibilityStateChangeListener(this) 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/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, 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( 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 @@ + + + + + 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.