From ef0163ba9ff3ccd8490608afe5bfb344fdc7397b Mon Sep 17 00:00:00 2001 From: Fredia Huya-Kouadio Date: Wed, 31 Dec 2025 02:14:23 -0800 Subject: [PATCH] Add support for PiP mode --- core/os/main_loop.cpp | 2 + core/os/main_loop.h | 2 + doc/classes/DisplayServer.xml | 38 +++++++ doc/classes/MainLoop.xml | 6 + doc/classes/Node.xml | 6 + platform/android/display_server_android.cpp | 34 ++++++ platform/android/display_server_android.h | 5 + .../java/app/src/main/AndroidManifest.xml | 1 + .../main/java/com/godot/game/GodotApp.java | 5 + .../java/org/godotengine/editor/GodotGame.kt | 47 +------- .../editor/embed/EmbeddedGodotGame.kt | 2 +- .../editor/embed/GameMenuFragment.kt | 4 +- .../main/java/org/godotengine/godot/Godot.kt | 57 ++++++++++ .../org/godotengine/godot/GodotActivity.kt | 103 +++++++++++++++++- .../java/org/godotengine/godot/GodotLib.java | 2 + .../godot/feature/PictureInPictureProvider.kt | 41 +++++++ platform/android/java_godot_lib_jni.cpp | 14 +++ platform/android/java_godot_lib_jni.h | 1 + platform/android/java_godot_wrapper.cpp | 51 +++++++++ platform/android/java_godot_wrapper.h | 11 ++ platform/android/os_android.cpp | 4 +- scene/main/node.cpp | 2 + scene/main/node.h | 2 + scene/main/scene_tree.cpp | 4 +- servers/display/display_server.cpp | 6 + servers/display/display_server.h | 6 + servers/display/display_server_enums.h | 1 + 27 files changed, 405 insertions(+), 52 deletions(-) create mode 100644 platform/android/java/lib/src/main/java/org/godotengine/godot/feature/PictureInPictureProvider.kt diff --git a/core/os/main_loop.cpp b/core/os/main_loop.cpp index f348d58a50..c4a3a848b1 100644 --- a/core/os/main_loop.cpp +++ b/core/os/main_loop.cpp @@ -43,6 +43,8 @@ void MainLoop::_bind_methods() { BIND_CONSTANT(NOTIFICATION_APPLICATION_FOCUS_IN); BIND_CONSTANT(NOTIFICATION_APPLICATION_FOCUS_OUT); BIND_CONSTANT(NOTIFICATION_TEXT_SERVER_CHANGED); + BIND_CONSTANT(NOTIFICATION_APPLICATION_PIP_MODE_ENTERED); + BIND_CONSTANT(NOTIFICATION_APPLICATION_PIP_MODE_EXITED); ADD_SIGNAL(MethodInfo("on_request_permissions_result", PropertyInfo(Variant::STRING, "permission"), PropertyInfo(Variant::BOOL, "granted"))); diff --git a/core/os/main_loop.h b/core/os/main_loop.h index d7e1d60f88..fdd0c2cfe7 100644 --- a/core/os/main_loop.h +++ b/core/os/main_loop.h @@ -57,6 +57,8 @@ public: NOTIFICATION_APPLICATION_FOCUS_IN = 2016, NOTIFICATION_APPLICATION_FOCUS_OUT = 2017, NOTIFICATION_TEXT_SERVER_CHANGED = 2018, + NOTIFICATION_APPLICATION_PIP_MODE_ENTERED = 2019, + NOTIFICATION_APPLICATION_PIP_MODE_EXITED = 2020, }; virtual void initialize(); diff --git a/doc/classes/DisplayServer.xml b/doc/classes/DisplayServer.xml index 6fb91f82b6..1a5dc36a61 100644 --- a/doc/classes/DisplayServer.xml +++ b/doc/classes/DisplayServer.xml @@ -1604,6 +1604,14 @@ [b]Note:[/b] This method is implemented on Android, iOS, macOS, Windows, and Linux (X11/Wayland). + + + + + Returns [code]true[/code] if the application is in picture-in-picture mode. + [b]Note:[/b] This method is implemented on Android. + + @@ -1695,6 +1703,33 @@ Sets the current mouse mode. See also [method mouse_get_mode]. + + + + + Enters picture-in-picture mode. + [b]Note:[/b] This method is implemented on Android. + + + + + + + + + Specifies the aspect ratio for picture-in-picture mode. + [b]Note:[/b] This method is implemented on Android. + + + + + + + + Specifies whether picture-in-picture mode should be entered automatically when the application goes in the background. + [b]Note:[/b] This method is implemented on Android. + + @@ -2753,6 +2788,9 @@ Display server supports HDR output. [b]macOS, iOS, visionOS, Windows[/b] + + Display server supports putting the application in picture-in-picture mode. [b]Android[/b] + Unknown or custom role. diff --git a/doc/classes/MainLoop.xml b/doc/classes/MainLoop.xml index 227e1ad422..0bf53560b7 100644 --- a/doc/classes/MainLoop.xml +++ b/doc/classes/MainLoop.xml @@ -143,5 +143,11 @@ Notification received when text server is changed. + + Notification received when the application enters picture-in-picture mode. + + + Notification received when the application exits picture-in-picture mode. + diff --git a/doc/classes/Node.xml b/doc/classes/Node.xml index 62fa2663c3..532859a28f 100644 --- a/doc/classes/Node.xml +++ b/doc/classes/Node.xml @@ -1323,6 +1323,12 @@ Notification received when the [TextServer] is changed. + + Notification received when the application enters picture-in-picture mode. + + + Notification received when the application exits picture-in-picture mode. + Notification received when an accessibility information update is required. diff --git a/platform/android/display_server_android.cpp b/platform/android/display_server_android.cpp index 265c3beea0..00d1bd9b05 100644 --- a/platform/android/display_server_android.cpp +++ b/platform/android/display_server_android.cpp @@ -72,6 +72,12 @@ bool DisplayServerAndroid::has_feature(DisplayServerEnums::Feature p_feature) co return (native_menu && native_menu->has_feature(NativeMenu::FEATURE_GLOBAL_MENU)); } break; #endif + case DisplayServerEnums::FEATURE_PIP_MODE: { + GodotJavaWrapper *godot_java = OS_Android::get_singleton()->get_godot_java(); + ERR_FAIL_NULL_V(godot_java, false); + return godot_java->is_pip_mode_supported(); + } break; + case DisplayServerEnums::FEATURE_CURSOR_SHAPE: //case DisplayServerEnums::FEATURE_CUSTOM_CURSOR_SHAPE: //case DisplayServerEnums::FEATURE_HIDPI: @@ -1003,3 +1009,31 @@ void DisplayServerAndroid::set_icon(const Ref &p_icon) { bool DisplayServerAndroid::is_window_transparency_available() const { return GLOBAL_GET_CACHED(bool, "display/window/per_pixel_transparency/allowed"); } + +bool DisplayServerAndroid::is_in_pip_mode(DisplayServerEnums::WindowID p_window) { + ERR_FAIL_COND_V(p_window != DisplayServerEnums::MAIN_WINDOW_ID, false); + GodotJavaWrapper *godot_java = OS_Android::get_singleton()->get_godot_java(); + ERR_FAIL_NULL_V(godot_java, false); + return godot_java->is_in_pip_mode(); +} + +void DisplayServerAndroid::pip_mode_enter(DisplayServerEnums::WindowID p_window) { + ERR_FAIL_COND(p_window != DisplayServerEnums::MAIN_WINDOW_ID); + GodotJavaWrapper *godot_java = OS_Android::get_singleton()->get_godot_java(); + ERR_FAIL_NULL(godot_java); + godot_java->enter_pip_mode(); +} + +void DisplayServerAndroid::pip_mode_set_aspect_ratio(int p_numerator, int p_denominator, DisplayServerEnums::WindowID p_window) { + ERR_FAIL_COND(p_window != DisplayServerEnums::MAIN_WINDOW_ID); + GodotJavaWrapper *godot_java = OS_Android::get_singleton()->get_godot_java(); + ERR_FAIL_NULL(godot_java); + godot_java->set_pip_mode_aspect_ratio(p_numerator, p_denominator); +} + +void DisplayServerAndroid::pip_mode_set_auto_enter_on_background(bool p_auto_enter_on_background, DisplayServerEnums::WindowID p_window) { + ERR_FAIL_COND(p_window != DisplayServerEnums::MAIN_WINDOW_ID); + GodotJavaWrapper *godot_java = OS_Android::get_singleton()->get_godot_java(); + ERR_FAIL_NULL(godot_java); + godot_java->set_auto_enter_pip_mode_on_background(p_auto_enter_on_background); +} diff --git a/platform/android/display_server_android.h b/platform/android/display_server_android.h index c4b3f82a58..1d3757a793 100644 --- a/platform/android/display_server_android.h +++ b/platform/android/display_server_android.h @@ -269,6 +269,11 @@ public: virtual bool is_window_transparency_available() const override; + virtual bool is_in_pip_mode(DisplayServerEnums::WindowID p_window = DisplayServerEnums::MAIN_WINDOW_ID) override; + virtual void pip_mode_enter(DisplayServerEnums::WindowID p_window = DisplayServerEnums::MAIN_WINDOW_ID) override; + virtual void pip_mode_set_aspect_ratio(int p_numerator, int p_denominator, DisplayServerEnums::WindowID p_window = DisplayServerEnums::MAIN_WINDOW_ID) override; + virtual void pip_mode_set_auto_enter_on_background(bool p_auto_enter_on_background, DisplayServerEnums::WindowID p_window = DisplayServerEnums::MAIN_WINDOW_ID) override; + DisplayServerAndroid(const String &p_rendering_driver, DisplayServerEnums::WindowMode p_mode, DisplayServerEnums::VSyncMode p_vsync_mode, uint32_t p_flags, const Vector2i *p_position, const Vector2i &p_resolution, int p_screen, DisplayServerEnums::Context p_context, int64_t p_parent_window, Error &r_error); ~DisplayServerAndroid(); }; diff --git a/platform/android/java/app/src/main/AndroidManifest.xml b/platform/android/java/app/src/main/AndroidManifest.xml index 41774516a4..f523b31767 100644 --- a/platform/android/java/app/src/main/AndroidManifest.xml +++ b/platform/android/java/app/src/main/AndroidManifest.xml @@ -35,6 +35,7 @@ android:launchMode="singleInstancePerTask" android:excludeFromRecents="false" android:exported="false" + android:supportsPictureInPicture="true" android:screenOrientation="landscape" android:windowSoftInputMode="adjustResize" android:configChanges="layoutDirection|locale|orientation|keyboardHidden|screenSize|smallestScreenSize|density|keyboard|navigation|screenLayout|uiMode" diff --git a/platform/android/java/app/src/main/java/com/godot/game/GodotApp.java b/platform/android/java/app/src/main/java/com/godot/game/GodotApp.java index 4bd7359b8a..8573f71326 100644 --- a/platform/android/java/app/src/main/java/com/godot/game/GodotApp.java +++ b/platform/android/java/app/src/main/java/com/godot/game/GodotApp.java @@ -92,4 +92,9 @@ public class GodotApp extends GodotActivity { super.onGodotForceQuit(instance); } } + + @Override + protected boolean isPiPEnabled() { + return true; + } } diff --git a/platform/android/java/editor/src/main/java/org/godotengine/editor/GodotGame.kt b/platform/android/java/editor/src/main/java/org/godotengine/editor/GodotGame.kt index 45c8f5050d..286d31f442 100644 --- a/platform/android/java/editor/src/main/java/org/godotengine/editor/GodotGame.kt +++ b/platform/android/java/editor/src/main/java/org/godotengine/editor/GodotGame.kt @@ -30,12 +30,7 @@ package org.godotengine.editor -import android.app.PictureInPictureParams -import android.content.pm.PackageManager -import android.graphics.Rect -import android.os.Build import android.os.Bundle -import android.util.Log import android.view.View import androidx.annotation.CallSuper import androidx.core.view.isVisible @@ -55,7 +50,6 @@ open class GodotGame : BaseGodotGame() { private val TAG = GodotGame::class.java.simpleName } - private val gameViewSourceRectHint = Rect() private val expandGameMenuButton: View? by lazy { findViewById(R.id.game_menu_expand_button) } override fun onCreate(savedInstanceState: Bundle?) { @@ -75,13 +69,6 @@ open class GodotGame : BaseGodotGame() { gameMenuFragment?.expandGameMenu() } } - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - val gameView = findViewById(R.id.godot_fragment_container) - gameView?.addOnLayoutChangeListener { v, left, top, right, bottom, oldLeft, oldTop, oldRight, oldBottom -> - gameView.getGlobalVisibleRect(gameViewSourceRectHint) - } - } } override fun getCommandLine(): MutableList { @@ -96,27 +83,7 @@ open class GodotGame : BaseGodotGame() { return updatedArgs } - override fun enterPiPMode() { - if (hasPiPSystemFeature()) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - val builder = PictureInPictureParams.Builder().setSourceRectHint(gameViewSourceRectHint) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - builder.setSeamlessResizeEnabled(false) - } - setPictureInPictureParams(builder.build()) - } - - Log.v(TAG, "Entering PiP mode") - enterPictureInPictureMode() - } - } - - /** - * Returns true the if the device supports picture-in-picture (PiP). - */ - protected fun hasPiPSystemFeature(): Boolean { - return packageManager.hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE) - } + override fun isPiPEnabled() = true override fun shouldShowGameMenuBar(): Boolean { return intent.getBooleanExtra( @@ -127,21 +94,11 @@ open class GodotGame : BaseGodotGame() { override fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean) { super.onPictureInPictureModeChanged(isInPictureInPictureMode) - Log.v(TAG, "onPictureInPictureModeChanged: $isInPictureInPictureMode") // Hide the game menu fragment when in PiP. gameMenuContainer?.isVisible = !isInPictureInPictureMode } - override fun onStop() { - super.onStop() - - if (isInPictureInPictureMode && !isFinishing) { - // We get in this state when PiP is closed, so we terminate the activity. - finish() - } - } - override fun getGodotAppLayout() = R.layout.godot_game_layout override fun getEditorWindowInfo() = RUN_GAME_INFO @@ -258,7 +215,7 @@ open class GodotGame : BaseGodotGame() { override fun isCloseButtonEnabled() = !isHorizonOSDevice(applicationContext) - override fun isPiPButtonEnabled() = hasPiPSystemFeature() + override fun isPiPButtonEnabled() = isPiPModeSupported() override fun isMenuBarCollapsable() = true diff --git a/platform/android/java/editor/src/main/java/org/godotengine/editor/embed/EmbeddedGodotGame.kt b/platform/android/java/editor/src/main/java/org/godotengine/editor/embed/EmbeddedGodotGame.kt index d03c1439cc..5456cb231e 100644 --- a/platform/android/java/editor/src/main/java/org/godotengine/editor/embed/EmbeddedGodotGame.kt +++ b/platform/android/java/editor/src/main/java/org/godotengine/editor/embed/EmbeddedGodotGame.kt @@ -140,7 +140,7 @@ class EmbeddedGodotGame : GodotGame() { override fun isMenuBarCollapsable() = false - override fun isAlwaysOnTopSupported() = hasPiPSystemFeature() + override fun isAlwaysOnTopSupported() = isPiPModeSupported() override fun onFullScreenUpdated(enabled: Boolean) { godot?.enableImmersiveMode(enabled) diff --git a/platform/android/java/editor/src/main/java/org/godotengine/editor/embed/GameMenuFragment.kt b/platform/android/java/editor/src/main/java/org/godotengine/editor/embed/GameMenuFragment.kt index 715f050877..5c40c83065 100644 --- a/platform/android/java/editor/src/main/java/org/godotengine/editor/embed/GameMenuFragment.kt +++ b/platform/android/java/editor/src/main/java/org/godotengine/editor/embed/GameMenuFragment.kt @@ -47,6 +47,7 @@ import androidx.fragment.app.Fragment import org.godotengine.editor.BaseGodotEditor import org.godotengine.editor.BaseGodotEditor.Companion.SNACKBAR_SHOW_DURATION_MS import org.godotengine.editor.R +import org.godotengine.godot.feature.PictureInPictureProvider import org.godotengine.godot.utils.DialogUtils /** @@ -65,7 +66,7 @@ class GameMenuFragment : Fragment(), PopupMenu.OnMenuItemClickListener { /** * Used to be notified of events fired when interacting with the game menu. */ - interface GameMenuListener { + interface GameMenuListener : PictureInPictureProvider { /** * Kotlin representation of the RuntimeNodeSelect::SelectMode enum in 'scene/debugger/scene_debugger.h'. @@ -109,7 +110,6 @@ class GameMenuFragment : Fragment(), PopupMenu.OnMenuItemClickListener { fun isGameEmbeddingSupported(): Boolean fun embedGameOnPlay(embedded: Boolean) - fun enterPiPMode() {} fun minimizeGameWindow() {} fun closeGameWindow() {} diff --git a/platform/android/java/lib/src/main/java/org/godotengine/godot/Godot.kt b/platform/android/java/lib/src/main/java/org/godotengine/godot/Godot.kt index 921e083e8f..7c5adb2173 100644 --- a/platform/android/java/lib/src/main/java/org/godotengine/godot/Godot.kt +++ b/platform/android/java/lib/src/main/java/org/godotengine/godot/Godot.kt @@ -43,6 +43,7 @@ import android.hardware.Sensor import android.hardware.SensorManager import android.os.* import android.util.Log +import android.util.Rational import android.util.TypedValue import android.view.* import android.widget.FrameLayout @@ -57,6 +58,7 @@ import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsControllerCompat import com.google.android.vending.expansion.downloader.* import org.godotengine.godot.error.Error +import org.godotengine.godot.feature.PictureInPictureProvider import org.godotengine.godot.input.GodotEditText import org.godotengine.godot.input.GodotInputHandler import org.godotengine.godot.io.FilePicker @@ -716,6 +718,13 @@ class Godot private constructor(val context: Context) { } } + internal fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean) { + Log.v(TAG, "onPictureInPictureModeChanged: $isInPictureInPictureMode") + runOnRenderThread { + GodotLib.onPictureInPictureModeChanged(isInPictureInPictureMode) + } + } + fun onPause(host: GodotHost) { Log.v(TAG, "OnPause: $host") resumed = false @@ -1372,4 +1381,52 @@ class Godot private constructor(val context: Context) { } } + @Keep + private fun nativeIsPiPModeSupported(): Boolean { + val hostActivity = getActivity() + if (hostActivity is PictureInPictureProvider) { + return hostActivity.isPiPModeSupported() + } + return false + } + + @Keep + private fun nativeIsInPiPMode(): Boolean { + val hostActivity = getActivity() + if (hostActivity is GodotActivity) { + return hostActivity.isInPictureInPictureMode + } + return false + } + + @Keep + private fun nativeEnterPiPMode() { + val hostActivity = getActivity() + if (hostActivity is PictureInPictureProvider) { + runOnHostThread { + hostActivity.enterPiPMode() + } + } + } + + @Keep + private fun nativeSetPiPModeAspectRatio(numerator: Int, denominator: Int) { + val hostActivity = getActivity() + if (hostActivity is GodotActivity) { + runOnHostThread { + hostActivity.updatePiPParams(aspectRatio = Rational(numerator, denominator)) + } + } + } + + @Keep + private fun nativeSetAutoEnterPiPModeOnBackground(autoEnterPiPOnBackground: Boolean) { + val hostActivity = getActivity() + if (hostActivity is GodotActivity) { + runOnHostThread { + hostActivity.updatePiPParams(enableAutoEnter = autoEnterPiPOnBackground) + } + } + } + } diff --git a/platform/android/java/lib/src/main/java/org/godotengine/godot/GodotActivity.kt b/platform/android/java/lib/src/main/java/org/godotengine/godot/GodotActivity.kt index 56fedc4443..938bdb00b6 100644 --- a/platform/android/java/lib/src/main/java/org/godotengine/godot/GodotActivity.kt +++ b/platform/android/java/lib/src/main/java/org/godotengine/godot/GodotActivity.kt @@ -31,17 +31,25 @@ package org.godotengine.godot import android.app.Activity +import android.app.PictureInPictureParams import android.content.ComponentName import android.content.Intent import android.content.pm.PackageManager +import android.graphics.Rect +import android.os.Build import android.os.Bundle import android.util.Log +import android.util.Rational +import android.view.View import androidx.annotation.CallSuper import androidx.annotation.LayoutRes import androidx.fragment.app.FragmentActivity +import org.godotengine.godot.feature.PictureInPictureProvider import org.godotengine.godot.utils.CommandLineFileParser import org.godotengine.godot.utils.PermissionsUtil import org.godotengine.godot.utils.ProcessPhoenix +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicReference /** * Base abstract activity for Android apps intending to use Godot as the primary screen. @@ -49,7 +57,7 @@ import org.godotengine.godot.utils.ProcessPhoenix * Also a reference implementation for how to setup and use the [GodotFragment] fragment * within an Android app. */ -abstract class GodotActivity : FragmentActivity(), GodotHost { +abstract class GodotActivity : FragmentActivity(), GodotHost, PictureInPictureProvider { companion object { private val TAG = GodotActivity::class.java.simpleName @@ -65,6 +73,12 @@ abstract class GodotActivity : FragmentActivity(), GodotHost { private final val DEFAULT_WINDOW_ID = 664; } + /** + * Set to true if the activity should automatically enter picture-in-picture when put in the background. + */ + private val pipAspectRatio = AtomicReference() + private val autoEnterPiP = AtomicBoolean(false) + private val gameViewSourceRectHint = Rect() private val commandLineParams = ArrayList() /** * Interaction with the [Godot] object is delegated to the [GodotFragment] class. @@ -139,6 +153,13 @@ abstract class GodotActivity : FragmentActivity(), GodotHost { .setPrimaryNavigationFragment(godotFragment) .commitNowAllowingStateLoss() } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val gameView = findViewById(R.id.godot_fragment_container) + gameView?.addOnLayoutChangeListener { v, left, top, right, bottom, oldLeft, oldTop, oldRight, oldBottom -> + gameView.getGlobalVisibleRect(gameViewSourceRectHint) + } + } } override fun onNewGodotInstanceRequested(args: Array): Int { @@ -149,7 +170,7 @@ abstract class GodotActivity : FragmentActivity(), GodotHost { .putExtra(EXTRA_COMMAND_LINE_PARAMS, args) triggerRebirth(null, intent) // fake 'process' id returned by create_instance() etc - return DEFAULT_WINDOW_ID; + return DEFAULT_WINDOW_ID } protected fun triggerRebirth(bundle: Bundle?, intent: Intent) { @@ -167,6 +188,15 @@ abstract class GodotActivity : FragmentActivity(), GodotHost { super.onDestroy() } + override fun onStop() { + super.onStop() + + if (isInPictureInPictureMode && !isFinishing) { + // We get in this state when PiP is closed, so we terminate the activity. + finish() + } + } + override fun onGodotForceQuit(instance: Godot) { runOnUiThread { terminateGodotInstance(instance) } } @@ -196,6 +226,23 @@ abstract class GodotActivity : FragmentActivity(), GodotHost { } } + override fun onGodotSetupCompleted() { + super.onGodotSetupCompleted() + + if (isPiPEnabled()) { + try { + // Update the aspect ratio for picture-in-picture mode. + val viewportWidth = Integer.parseInt(GodotLib.getGlobal("display/window/size/viewport_width")) + val viewportHeight = Integer.parseInt(GodotLib.getGlobal("display/window/size/viewport_height")) + pipAspectRatio.set(Rational(viewportWidth, viewportHeight)) + } catch (e: NumberFormatException) { + Log.w(TAG, "Unable to parse viewport dimensions.", e) + } + + runOnHostThread { updatePiPParams() } + } + } + override fun onNewIntent(newIntent: Intent) { intent = sanitizeLaunchIntent(newIntent) super.onNewIntent(intent) @@ -257,4 +304,56 @@ abstract class GodotActivity : FragmentActivity(), GodotHost { @CallSuper override fun getCommandLine(): MutableList = commandLineParams + + override fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean) { + super.onPictureInPictureModeChanged(isInPictureInPictureMode) + godot?.onPictureInPictureModeChanged(isInPictureInPictureMode) + } + + /** + * Returns true if picture-in-picture (PiP) mode is supported. + */ + override fun isPiPModeSupported() = isPiPEnabled() && packageManager.hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE) + + /** + * Returns true if the current activity has enabled picture-in-picture in its manifest declaration using + * 'android:supportsPictureInPicture="true"' + */ + protected open fun isPiPEnabled() = false + + internal fun updatePiPParams(enableAutoEnter: Boolean = autoEnterPiP.get(), aspectRatio: Rational? = pipAspectRatio.get()) { + if (isPiPModeSupported()) { + autoEnterPiP.set(enableAutoEnter) + pipAspectRatio.set(aspectRatio) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val builder = PictureInPictureParams.Builder() + .setSourceRectHint(gameViewSourceRectHint) + .setAspectRatio(aspectRatio) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + builder.setSeamlessResizeEnabled(false) + .setAutoEnterEnabled(enableAutoEnter) + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + builder.setExpandedAspectRatio(aspectRatio) + } + setPictureInPictureParams(builder.build()) + } + } + } + + override fun enterPiPMode() { + if (isPiPModeSupported()) { + updatePiPParams() + + Log.v(TAG, "Entering PiP mode") + enterPictureInPictureMode() + } + } + + override fun onUserLeaveHint() { + if (autoEnterPiP.get()) { + enterPiPMode() + } + } } diff --git a/platform/android/java/lib/src/main/java/org/godotengine/godot/GodotLib.java b/platform/android/java/lib/src/main/java/org/godotengine/godot/GodotLib.java index 9984bca351..c6d780ceeb 100644 --- a/platform/android/java/lib/src/main/java/org/godotengine/godot/GodotLib.java +++ b/platform/android/java/lib/src/main/java/org/godotengine/godot/GodotLib.java @@ -320,4 +320,6 @@ public class GodotLib { static native boolean isProjectManagerHint(); static native boolean hasFeature(String feature); + + static native void onPictureInPictureModeChanged(boolean isInPictureInPictureMode); } diff --git a/platform/android/java/lib/src/main/java/org/godotengine/godot/feature/PictureInPictureProvider.kt b/platform/android/java/lib/src/main/java/org/godotengine/godot/feature/PictureInPictureProvider.kt new file mode 100644 index 0000000000..c4979ce096 --- /dev/null +++ b/platform/android/java/lib/src/main/java/org/godotengine/godot/feature/PictureInPictureProvider.kt @@ -0,0 +1,41 @@ +/**************************************************************************/ +/* PictureInPictureProvider.kt */ +/**************************************************************************/ +/* This file is part of: */ +/* GODOT ENGINE */ +/* https://godotengine.org */ +/**************************************************************************/ +/* Copyright (c) 2014-present Godot Engine contributors (see AUTHORS.md). */ +/* Copyright (c) 2007-2014 Juan Linietsky, Ariel Manzur. */ +/* */ +/* Permission is hereby granted, free of charge, to any person obtaining */ +/* a copy of this software and associated documentation files (the */ +/* "Software"), to deal in the Software without restriction, including */ +/* without limitation the rights to use, copy, modify, merge, publish, */ +/* distribute, sublicense, and/or sell copies of the Software, and to */ +/* permit persons to whom the Software is furnished to do so, subject to */ +/* the following conditions: */ +/* */ +/* The above copyright notice and this permission notice shall be */ +/* included in all copies or substantial portions of the Software. */ +/* */ +/* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, */ +/* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF */ +/* MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. */ +/* IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY */ +/* CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, */ +/* TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE */ +/* SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ +/**************************************************************************/ + +package org.godotengine.godot.feature + +/** + * Provides APIs to enable picture-in-picture. + */ +interface PictureInPictureProvider { + + fun enterPiPMode() + + fun isPiPModeSupported(): Boolean +} diff --git a/platform/android/java_godot_lib_jni.cpp b/platform/android/java_godot_lib_jni.cpp index 65997dc7f6..e15f89c203 100644 --- a/platform/android/java_godot_lib_jni.cpp +++ b/platform/android/java_godot_lib_jni.cpp @@ -731,4 +731,18 @@ JNIEXPORT jboolean JNICALL Java_org_godotengine_godot_GodotLib_hasFeature(JNIEnv } return false; } + +JNIEXPORT void JNICALL Java_org_godotengine_godot_GodotLib_onPictureInPictureModeChanged(JNIEnv *env, jclass clazz, jboolean p_is_in_picture_in_picture_mode) { + if (step.get() <= STEP_SETUP) { + return; + } + + if (os_android->get_main_loop()) { + if (p_is_in_picture_in_picture_mode) { + os_android->get_main_loop()->notification(MainLoop::NOTIFICATION_APPLICATION_PIP_MODE_ENTERED); + } else { + os_android->get_main_loop()->notification(MainLoop::NOTIFICATION_APPLICATION_PIP_MODE_EXITED); + } + } +} } diff --git a/platform/android/java_godot_lib_jni.h b/platform/android/java_godot_lib_jni.h index 6c1faa0d5f..6579c5ca5d 100644 --- a/platform/android/java_godot_lib_jni.h +++ b/platform/android/java_godot_lib_jni.h @@ -78,4 +78,5 @@ JNIEXPORT jstring JNICALL Java_org_godotengine_godot_GodotLib_getProjectResource JNIEXPORT jboolean JNICALL Java_org_godotengine_godot_GodotLib_isEditorHint(JNIEnv *env, jclass clazz); JNIEXPORT jboolean JNICALL Java_org_godotengine_godot_GodotLib_isProjectManagerHint(JNIEnv *env, jclass clazz); JNIEXPORT jboolean JNICALL Java_org_godotengine_godot_GodotLib_hasFeature(JNIEnv *env, jclass clazz, jstring p_feature); +JNIEXPORT void JNICALL Java_org_godotengine_godot_GodotLib_onPictureInPictureModeChanged(JNIEnv *env, jclass clazz, jboolean p_is_in_picture_in_picture_mode); } diff --git a/platform/android/java_godot_wrapper.cpp b/platform/android/java_godot_wrapper.cpp index fb1f467c43..ba880dc8b7 100644 --- a/platform/android/java_godot_wrapper.cpp +++ b/platform/android/java_godot_wrapper.cpp @@ -96,6 +96,13 @@ GodotJavaWrapper::GodotJavaWrapper(JNIEnv *p_env, jobject p_godot_instance) { _build_env_execute = p_env->GetMethodID(godot_class, "nativeBuildEnvExecute", "(Ljava/lang/String;[Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lorg/godotengine/godot/variant/Callable;Lorg/godotengine/godot/variant/Callable;)I"); _build_env_cancel = p_env->GetMethodID(godot_class, "nativeBuildEnvCancel", "(I)V"); _build_env_clean_project = p_env->GetMethodID(godot_class, "nativeBuildEnvCleanProject", "(Ljava/lang/String;Ljava/lang/String;Lorg/godotengine/godot/variant/Callable;)V"); + + // PiP mode method ids. + _is_pip_mode_supported = p_env->GetMethodID(godot_class, "nativeIsPiPModeSupported", "()Z"); + _is_in_pip_mode = p_env->GetMethodID(godot_class, "nativeIsInPiPMode", "()Z"); + _enter_pip_mode = p_env->GetMethodID(godot_class, "nativeEnterPiPMode", "()V"); + _set_pip_mode_aspect_ratio = p_env->GetMethodID(godot_class, "nativeSetPiPModeAspectRatio", "(II)V"); + _set_auto_enter_pip_mode_on_background = p_env->GetMethodID(godot_class, "nativeSetAutoEnterPiPModeOnBackground", "(Z)V"); } GodotJavaWrapper::~GodotJavaWrapper() { @@ -705,3 +712,47 @@ void GodotJavaWrapper::build_env_clean_project(const String &p_project_path, con env->DeleteLocalRef(j_callback); } } + +bool GodotJavaWrapper::is_pip_mode_supported() { + if (_is_pip_mode_supported) { + JNIEnv *env = get_jni_env(); + ERR_FAIL_NULL_V(env, false); + return env->CallBooleanMethod(godot_instance, _is_pip_mode_supported); + } else { + return false; + } +} + +bool GodotJavaWrapper::is_in_pip_mode() { + if (_is_in_pip_mode) { + JNIEnv *env = get_jni_env(); + ERR_FAIL_NULL_V(env, false); + return env->CallBooleanMethod(godot_instance, _is_in_pip_mode); + } else { + return false; + } +} + +void GodotJavaWrapper::enter_pip_mode() { + if (_enter_pip_mode) { + JNIEnv *env = get_jni_env(); + ERR_FAIL_NULL(env); + env->CallVoidMethod(godot_instance, _enter_pip_mode); + } +} + +void GodotJavaWrapper::set_pip_mode_aspect_ratio(int p_numerator, int p_denominator) { + if (_set_pip_mode_aspect_ratio) { + JNIEnv *env = get_jni_env(); + ERR_FAIL_NULL(env); + env->CallVoidMethod(godot_instance, _set_pip_mode_aspect_ratio, p_numerator, p_denominator); + } +} + +void GodotJavaWrapper::set_auto_enter_pip_mode_on_background(bool p_auto_enter_on_background) { + if (_set_auto_enter_pip_mode_on_background) { + JNIEnv *env = get_jni_env(); + ERR_FAIL_NULL(env); + env->CallVoidMethod(godot_instance, _set_auto_enter_pip_mode_on_background, p_auto_enter_on_background); + } +} diff --git a/platform/android/java_godot_wrapper.h b/platform/android/java_godot_wrapper.h index dfa98e0cc2..6e433eb4df 100644 --- a/platform/android/java_godot_wrapper.h +++ b/platform/android/java_godot_wrapper.h @@ -90,6 +90,11 @@ private: jmethodID _build_env_execute = nullptr; jmethodID _build_env_cancel = nullptr; jmethodID _build_env_clean_project = nullptr; + jmethodID _is_pip_mode_supported = nullptr; + jmethodID _is_in_pip_mode = nullptr; + jmethodID _enter_pip_mode = nullptr; + jmethodID _set_pip_mode_aspect_ratio = nullptr; + jmethodID _set_auto_enter_pip_mode_on_background = nullptr; public: GodotJavaWrapper(JNIEnv *p_env, jobject p_godot_instance); @@ -154,4 +159,10 @@ public: int build_env_execute(const String &p_build_tool, const List &p_arguments, const String &p_project_path, const String &p_gradle_build_directory, const Callable &p_output_callback, const Callable &p_result_callback); void build_env_cancel(int p_job_id); void build_env_clean_project(const String &p_project_path, const String &p_gradle_build_directory, const Callable &p_callback); + + bool is_pip_mode_supported(); + bool is_in_pip_mode(); + void enter_pip_mode(); + void set_pip_mode_aspect_ratio(int p_numerator, int p_denominator); + void set_auto_enter_pip_mode_on_background(bool p_auto_enter_on_background); }; diff --git a/platform/android/os_android.cpp b/platform/android/os_android.cpp index 4174c9e4e4..e62e0688e0 100644 --- a/platform/android/os_android.cpp +++ b/platform/android/os_android.cpp @@ -435,7 +435,9 @@ void OS_Android::main_loop_focusout() { if (OS::get_singleton()->get_main_loop()) { OS::get_singleton()->get_main_loop()->notification(MainLoop::NOTIFICATION_APPLICATION_FOCUS_OUT); } - audio_driver_android.set_pause(true); + + // Only pause when we are not in PiP mode. + audio_driver_android.set_pause(!DisplayServerAndroid::get_singleton()->is_in_pip_mode()); } void OS_Android::main_loop_focusin() { diff --git a/scene/main/node.cpp b/scene/main/node.cpp index 6a2df1ae75..7bb91676cd 100644 --- a/scene/main/node.cpp +++ b/scene/main/node.cpp @@ -3964,6 +3964,8 @@ void Node::_bind_methods() { BIND_CONSTANT(NOTIFICATION_APPLICATION_FOCUS_IN); BIND_CONSTANT(NOTIFICATION_APPLICATION_FOCUS_OUT); BIND_CONSTANT(NOTIFICATION_TEXT_SERVER_CHANGED); + BIND_CONSTANT(NOTIFICATION_APPLICATION_PIP_MODE_ENTERED); + BIND_CONSTANT(NOTIFICATION_APPLICATION_PIP_MODE_EXITED); BIND_CONSTANT(NOTIFICATION_ACCESSIBILITY_UPDATE); BIND_CONSTANT(NOTIFICATION_ACCESSIBILITY_INVALIDATE); diff --git a/scene/main/node.h b/scene/main/node.h index a06bd7dedb..8341daac47 100644 --- a/scene/main/node.h +++ b/scene/main/node.h @@ -498,6 +498,8 @@ public: NOTIFICATION_APPLICATION_FOCUS_IN = 2016, NOTIFICATION_APPLICATION_FOCUS_OUT = 2017, NOTIFICATION_TEXT_SERVER_CHANGED = 2018, + NOTIFICATION_APPLICATION_PIP_MODE_ENTERED = 2019, + NOTIFICATION_APPLICATION_PIP_MODE_EXITED = 2020, // Editor specific node notifications NOTIFICATION_EDITOR_PRE_SAVE = 9001, diff --git a/scene/main/scene_tree.cpp b/scene/main/scene_tree.cpp index 994a474bfe..d1c0ef42bd 100644 --- a/scene/main/scene_tree.cpp +++ b/scene/main/scene_tree.cpp @@ -920,7 +920,9 @@ void SceneTree::_notification(int p_notification) { case NOTIFICATION_WM_ABOUT: case NOTIFICATION_CRASH: case NOTIFICATION_APPLICATION_RESUMED: - case NOTIFICATION_APPLICATION_PAUSED: { + case NOTIFICATION_APPLICATION_PAUSED: + case NOTIFICATION_APPLICATION_PIP_MODE_ENTERED: + case NOTIFICATION_APPLICATION_PIP_MODE_EXITED: { // Pass these to nodes, since they are mirrored. get_root()->propagate_notification(p_notification); } break; diff --git a/servers/display/display_server.cpp b/servers/display/display_server.cpp index 5804b596b5..cac5c9d2b3 100644 --- a/servers/display/display_server.cpp +++ b/servers/display/display_server.cpp @@ -1722,6 +1722,11 @@ void DisplayServer::_bind_methods() { ClassDB::bind_method(D_METHOD("unregister_additional_output", "object"), &DisplayServer::unregister_additional_output); ClassDB::bind_method(D_METHOD("has_additional_outputs"), &DisplayServer::has_additional_outputs); + ClassDB::bind_method(D_METHOD("is_in_pip_mode", "window_id"), &DisplayServer::is_in_pip_mode, DEFVAL(DisplayServerEnums::MAIN_WINDOW_ID)); + ClassDB::bind_method(D_METHOD("pip_mode_enter", "window_id"), &DisplayServer::pip_mode_enter, DEFVAL(DisplayServerEnums::MAIN_WINDOW_ID)); + ClassDB::bind_method(D_METHOD("pip_mode_set_aspect_ratio", "numerator", "denominator", "window_id"), &DisplayServer::pip_mode_set_aspect_ratio, DEFVAL(DisplayServerEnums::MAIN_WINDOW_ID)); + ClassDB::bind_method(D_METHOD("pip_mode_set_auto_enter_on_background", "auto_enter_on_background", "window_id"), &DisplayServer::pip_mode_set_auto_enter_on_background, DEFVAL(DisplayServerEnums::MAIN_WINDOW_ID)); + #ifndef DISABLE_DEPRECATED BIND_ENUM_CONSTANT(DisplayServerEnums::FEATURE_GLOBAL_MENU); #endif // DISABLE_DEPRECATED @@ -1759,6 +1764,7 @@ void DisplayServer::_bind_methods() { BIND_ENUM_CONSTANT(DisplayServerEnums::FEATURE_SELF_FITTING_WINDOWS); BIND_ENUM_CONSTANT(DisplayServerEnums::FEATURE_ACCESSIBILITY_SCREEN_READER); BIND_ENUM_CONSTANT(DisplayServerEnums::FEATURE_HDR_OUTPUT); + BIND_ENUM_CONSTANT(DisplayServerEnums::FEATURE_PIP_MODE); #ifndef DISABLE_DEPRECATED BIND_ENUM_CONSTANT(DisplayServerEnums::ROLE_UNKNOWN); diff --git a/servers/display/display_server.h b/servers/display/display_server.h index 00688f7c3b..f4ba35db89 100644 --- a/servers/display/display_server.h +++ b/servers/display/display_server.h @@ -531,6 +531,12 @@ public: void unregister_additional_output(Object *p_output); bool has_additional_outputs() const { return additional_outputs.size() > 0; } + /* PICTURE_IN_PICTURE */ + virtual bool is_in_pip_mode(DisplayServerEnums::WindowID p_window = DisplayServerEnums::MAIN_WINDOW_ID) { return false; } + virtual void pip_mode_enter(DisplayServerEnums::WindowID p_window = DisplayServerEnums::MAIN_WINDOW_ID) {} + virtual void pip_mode_set_aspect_ratio(int p_numerator, int p_denominator, DisplayServerEnums::WindowID p_window = DisplayServerEnums::MAIN_WINDOW_ID) {} + virtual void pip_mode_set_auto_enter_on_background(bool p_auto_enter_on_background, DisplayServerEnums::WindowID p_window = DisplayServerEnums::MAIN_WINDOW_ID) {} + /* ACCESSIBILITY */ #ifndef DISABLE_DEPRECATED diff --git a/servers/display/display_server_enums.h b/servers/display/display_server_enums.h index a1604b9c3a..57e68ee759 100644 --- a/servers/display/display_server_enums.h +++ b/servers/display/display_server_enums.h @@ -79,6 +79,7 @@ enum Feature { FEATURE_SELF_FITTING_WINDOWS, FEATURE_ACCESSIBILITY_SCREEN_READER, FEATURE_HDR_OUTPUT, + FEATURE_PIP_MODE, }; /* RENDERING DEVICE */