Skip to main content

repame_shell/
gesture.rs

1//! First-gesture hook for host policies that gate audio behind one.
2//!
3//! Browsers refuse to start an `AudioContext` outside a user gesture, so an
4//! engine opened at boot comes up suspended. Games call
5//! [`on_first_user_gesture`] with [`repame_audio::Audio::unlock`] and get a
6//! resumed device; there is no DOM code in the game.
7
8/// Set once a real gesture has been seen, so the hook fires exactly once even
9/// though the listeners stay attached.
10static SEEN: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
11
12/// Guards listener installation, so polling and callback registration can
13/// both happen in either order.
14#[cfg(target_arch = "wasm32")]
15static INSTALLED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
16
17/// Late-bound callback, so a game that registered [`on_first_user_gesture`]
18/// after something already triggered [`install`] still gets its call.
19#[cfg(target_arch = "wasm32")]
20static CALLBACK: std::sync::Mutex<Option<Box<dyn Fn() + Send + Sync>>> =
21    std::sync::Mutex::new(None);
22
23/// Run `f` on the first user gesture on this page.
24///
25/// The listener is attached to `window` for `pointerdown`, `touchstart` and
26/// `keydown`, so mouse, touch and keyboard all arm the device. It stays
27/// attached for the page's life; [`SEEN`] makes every later call a no-op, which
28/// keeps the closure alive cheaply instead of tearing listeners down mid-gesture.
29///
30/// Off wasm this does nothing, so callers need no cfg.
31pub fn on_first_user_gesture(f: impl Fn() + Send + Sync + 'static) {
32    #[cfg(target_arch = "wasm32")]
33    {
34        let mut slot = CALLBACK.lock().unwrap_or_else(|e| e.into_inner());
35        if slot.is_none() {
36            *slot = Some(Box::new(f));
37        }
38        install();
39    }
40    #[cfg(not(target_arch = "wasm32"))]
41    let _ = f;
42}
43
44#[cfg(not(target_arch = "wasm32"))]
45fn install() {}
46
47/// Whether a user gesture has been seen.
48///
49/// Installs the listeners on first call, so a game that prefers polling its
50/// frame loop over registering a callback needs no separate setup step.
51pub fn user_gesture_seen() -> bool {
52    install();
53    SEEN.load(std::sync::atomic::Ordering::SeqCst)
54}
55
56#[cfg(target_arch = "wasm32")]
57fn install() {
58    use std::sync::atomic::Ordering;
59
60    use wasm_bindgen::JsCast;
61    use wasm_bindgen::closure::Closure;
62
63    if INSTALLED.swap(true, Ordering::SeqCst) {
64        return;
65    }
66    let global = js_sys::global();
67    let Ok(add) = js_sys::Reflect::get(
68        &global,
69        &wasm_bindgen::JsValue::from_str("addEventListener"),
70    ) else {
71        return;
72    };
73    let Some(add) = add.dyn_ref::<js_sys::Function>() else {
74        return;
75    };
76    // Leaked into the page on purpose: the closure must outlive this call for
77    // the listener to stay callable, and one 24-byte handler for the life of
78    // the page is cheaper than the bookkeeping to remove it.
79    let handler = Closure::wrap(Box::new(|| {
80        if !SEEN.swap(true, Ordering::SeqCst)
81            && let Some(f) = CALLBACK.lock().unwrap_or_else(|e| e.into_inner()).take()
82        {
83            f();
84        }
85    }) as Box<dyn FnMut()>)
86    .into_js_value();
87    for event in ["pointerdown", "touchstart", "keydown"] {
88        let _ = add.call2(&global, &wasm_bindgen::JsValue::from_str(event), &handler);
89    }
90}