diff --git a/crates/bevy_window/src/event.rs b/crates/bevy_window/src/event.rs index ffc0939cbef94..d92516d731e42 100644 --- a/crates/bevy_window/src/event.rs +++ b/crates/bevy_window/src/event.rs @@ -330,3 +330,22 @@ pub struct WindowThemeChanged { /// The new system theme. pub theme: WindowTheme, } + +/// Application lifetime events +#[derive(Event, Debug, Clone, Copy, PartialEq, Eq, Reflect)] +#[reflect(Debug, PartialEq)] +#[cfg_attr( + feature = "serialize", + derive(serde::Serialize, serde::Deserialize), + reflect(Serialize, Deserialize) +)] +pub enum ApplicationLifetime { + /// The application just started. + Started, + /// The application was suspended. + /// + /// On Android, applications have one frame to react to this event before being paused in the background. + Suspended, + /// The application was resumed. + Resumed, +} diff --git a/crates/bevy_window/src/lib.rs b/crates/bevy_window/src/lib.rs index bb367918db420..f1e8a304c52a5 100644 --- a/crates/bevy_window/src/lib.rs +++ b/crates/bevy_window/src/lib.rs @@ -98,7 +98,8 @@ impl Plugin for WindowPlugin { .add_event::() .add_event::() .add_event::() - .add_event::(); + .add_event::() + .add_event::(); if let Some(primary_window) = &self.primary_window { let initial_focus = app @@ -141,7 +142,8 @@ impl Plugin for WindowPlugin { .register_type::() .register_type::() .register_type::() - .register_type::(); + .register_type::() + .register_type::(); // Register window descriptor and related types app.register_type::() diff --git a/crates/bevy_winit/src/lib.rs b/crates/bevy_winit/src/lib.rs index a7cb24a7d1848..6d8e898e4f570 100644 --- a/crates/bevy_winit/src/lib.rs +++ b/crates/bevy_winit/src/lib.rs @@ -38,10 +38,10 @@ use bevy_utils::{ Duration, Instant, }; use bevy_window::{ - exit_on_all_closed, CursorEntered, CursorLeft, CursorMoved, FileDragAndDrop, Ime, - ReceivedCharacter, RequestRedraw, Window, WindowBackendScaleFactorChanged, - WindowCloseRequested, WindowCreated, WindowDestroyed, WindowFocused, WindowMoved, - WindowResized, WindowScaleFactorChanged, WindowThemeChanged, + exit_on_all_closed, ApplicationLifetime, CursorEntered, CursorLeft, CursorMoved, + FileDragAndDrop, Ime, ReceivedCharacter, RequestRedraw, Window, + WindowBackendScaleFactorChanged, WindowCloseRequested, WindowCreated, WindowDestroyed, + WindowFocused, WindowMoved, WindowResized, WindowScaleFactorChanged, WindowThemeChanged, }; #[cfg(target_os = "android")] use bevy_window::{PrimaryWindow, RawHandleWrapper}; @@ -279,6 +279,7 @@ struct WindowAndInputEventWriters<'w> { window_moved: EventWriter<'w, WindowMoved>, window_theme_changed: EventWriter<'w, WindowThemeChanged>, window_destroyed: EventWriter<'w, WindowDestroyed>, + lifetime: EventWriter<'w, ApplicationLifetime>, keyboard_input: EventWriter<'w, KeyboardInput>, character_input: EventWriter<'w, ReceivedCharacter>, mouse_button_input: EventWriter<'w, MouseButtonInput>, @@ -298,8 +299,8 @@ struct WindowAndInputEventWriters<'w> { /// Persistent state that is used to run the [`App`] according to the current /// [`UpdateMode`]. struct WinitAppRunnerState { - /// Is `true` if the app is running and not suspended. - is_active: bool, + /// Current active state of the app. + active: ActiveState, /// Is `true` if a new [`WindowEvent`] has been received since the last update. window_event_received: bool, /// Is `true` if the app has requested a redraw since the last update. @@ -312,10 +313,28 @@ struct WinitAppRunnerState { scheduled_update: Option, } +#[derive(PartialEq, Eq)] +enum ActiveState { + NotYetStarted, + Active, + Suspended, + WillSuspend, +} + +impl ActiveState { + #[inline] + fn should_run(&self) -> bool { + match self { + ActiveState::NotYetStarted | ActiveState::Suspended => false, + ActiveState::Active | ActiveState::WillSuspend => true, + } + } +} + impl Default for WinitAppRunnerState { fn default() -> Self { Self { - is_active: false, + active: ActiveState::NotYetStarted, window_event_received: false, redraw_requested: false, wait_elapsed: false, @@ -703,19 +722,23 @@ pub fn winit_runner(mut app: App) { }); } event::Event::Suspended => { - runner_state.is_active = false; - #[cfg(target_os = "android")] - { - // Remove the `RawHandleWrapper` from the primary window. - // This will trigger the surface destruction. - let mut query = app.world.query_filtered::>(); - let entity = query.single(&app.world); - app.world.entity_mut(entity).remove::(); - *control_flow = ControlFlow::Wait; - } + let (mut event_writers, _, _) = event_writer_system_state.get_mut(&mut app.world); + event_writers.lifetime.send(ApplicationLifetime::Suspended); + // Mark the state as `WillSuspend`. This will let the schedule run one last time + // before actually suspending to let the application react + runner_state.active = ActiveState::WillSuspend; } event::Event::Resumed => { - runner_state.is_active = true; + let (mut event_writers, _, _) = event_writer_system_state.get_mut(&mut app.world); + match runner_state.active { + ActiveState::NotYetStarted => { + event_writers.lifetime.send(ApplicationLifetime::Started); + } + _ => { + event_writers.lifetime.send(ApplicationLifetime::Resumed); + } + } + runner_state.active = ActiveState::Active; #[cfg(target_os = "android")] { // Get windows that are cached but without raw handles. Those window were already created, but got their @@ -757,7 +780,20 @@ pub fn winit_runner(mut app: App) { } } event::Event::MainEventsCleared => { - if runner_state.is_active { + if runner_state.active.should_run() { + if runner_state.active == ActiveState::WillSuspend { + runner_state.active = ActiveState::Suspended; + #[cfg(target_os = "android")] + { + // Remove the `RawHandleWrapper` from the primary window. + // This will trigger the surface destruction. + let mut query = + app.world.query_filtered::>(); + let entity = query.single(&app.world); + app.world.entity_mut(entity).remove::(); + *control_flow = ControlFlow::Wait; + } + } let (config, windows) = focused_windows_state.get(&app.world); let focused = windows.iter().any(|window| window.focused); let should_update = match config.update_mode(focused) { diff --git a/examples/mobile/src/lib.rs b/examples/mobile/src/lib.rs index 5dcac8aceb677..a4715c43689b0 100644 --- a/examples/mobile/src/lib.rs +++ b/examples/mobile/src/lib.rs @@ -2,7 +2,11 @@ // type aliases tends to obfuscate code while offering no improvement in code cleanliness. #![allow(clippy::type_complexity)] -use bevy::{input::touch::TouchPhase, prelude::*, window::WindowMode}; +use bevy::{ + input::touch::TouchPhase, + prelude::*, + window::{ApplicationLifetime, WindowMode}, +}; // the `bevy_main` proc_macro generates the required boilerplate for iOS and Android #[bevy_main] @@ -17,7 +21,7 @@ fn main() { ..default() })) .add_systems(Startup, (setup_scene, setup_music)) - .add_systems(Update, (touch_camera, button_handler)); + .add_systems(Update, (touch_camera, button_handler, handle_lifetime)); // MSAA makes some Android devices panic, this is under investigation // https://github.com/bevyengine/bevy/issues/8229 @@ -161,3 +165,18 @@ fn setup_music(asset_server: Res, mut commands: Commands) { settings: PlaybackSettings::LOOP, }); } + +// Pause audio when app goes into background and resume when it returns. +// This is handled by the OS on iOS, but not on Android. +fn handle_lifetime( + mut lifetime_events: EventReader, + music_controller: Query<&AudioSink>, +) { + for event in lifetime_events.read() { + match event { + ApplicationLifetime::Suspended => music_controller.single().pause(), + ApplicationLifetime::Resumed => music_controller.single().play(), + ApplicationLifetime::Started => (), + } + } +}