unlit3d_examples/fullscreen.rs
1//! Fullscreen: the button that takes the window fullscreen, and the landscape
2//! lock that only a browser permits.
3//!
4//! The example's content has a fixed aspect, so it reads best on a display of
5//! roughly that shape. A phone held upright shows it as a band across the
6//! middle of the screen, which is the case this module exists for: the button
7//! takes the window fullscreen, and a browser then locks a device being held
8//! upright to landscape so the picture fills the display.
9//!
10//! Every platform draws the button, because every platform has a window that
11//! can be made fullscreen. On Android winit has no fullscreen to offer, so the
12//! button is drawn but a press changes nothing: the activity already takes the
13//! screen over for itself.
14
15#[cfg(target_arch = "wasm32")]
16use std::cell::RefCell;
17
18#[cfg(target_arch = "wasm32")]
19use wasm_bindgen::JsCast;
20#[cfg(target_arch = "wasm32")]
21use wasm_bindgen::closure::Closure;
22#[cfg(target_arch = "wasm32")]
23use wasm_bindgen::prelude::JsValue;
24use winit::window::{Fullscreen, Window};
25
26/// Whether `window` is fullscreen at this moment.
27///
28/// The button reads this every frame and labels itself for the action it would
29/// take. That matters most on a phone, where leaving fullscreen is otherwise
30/// only possible through the browser's own chrome, which a page cannot reach.
31#[must_use]
32pub fn active(window: &Window) -> bool {
33 window.fullscreen().is_some()
34}
35
36/// Put `window` into or out of fullscreen, whichever it is not.
37///
38/// A browser grants this only to a page with a *transient activation*, and the
39/// click on the button is what provides one. The request leaves that click's
40/// own DOM handler — the panel records it and the frame loop reads it a frame
41/// or two later — but an activation outlives that by seconds, so the call is
42/// still made from inside the window the gesture opened.
43///
44/// A refusal is silent: winit neither reports nor logs one, so a browser that
45/// turns the request down simply leaves the app windowed. Nothing is asked for
46/// while already fullscreen except to leave it, so the button cannot trap a
47/// user in a display they did not choose.
48///
49/// Android's winit backend logs and ignores the call, which is what leaves the
50/// button there a press that changes nothing.
51pub fn toggle(window: &Window) {
52 // Armed here rather than at start-up because the event it waits for is the
53 // one the request below is about to cause. Registering is idempotent, so
54 // every press after the first only flips the state.
55 #[cfg(target_arch = "wasm32")]
56 watch_fullscreen_change();
57 let fullscreen = match window.fullscreen() {
58 Some(_) => None,
59 None => Some(Fullscreen::Borderless(None)),
60 };
61 window.set_fullscreen(fullscreen);
62}
63
64/// Lock the screen to landscape while the document is fullscreen and the device
65/// is being held upright.
66///
67/// The call is *not* made next to the fullscreen request, for two reasons that
68/// both come from the spec: a lock is only permitted while the document is
69/// already fullscreen, and entering fullscreen is asynchronous. So the lock is
70/// driven by `fullscreenchange`, which fires once the browser has entered it.
71///
72/// That event also stands in for a matching `unlock`: leaving fullscreen
73/// releases the orientation by itself, so the lock lasts exactly as long as the
74/// fullscreen that permitted it and a phone with rotation enabled cannot turn
75/// back mid-session.
76///
77/// A device already held sideways is left alone, so the lock never fights a
78/// user holding it the way they want. A refusal — a desktop browser has no
79/// orientation to lock — is expected and logged at debug level.
80#[cfg(target_arch = "wasm32")]
81fn lock_landscape_when_upright() {
82 let Ok(orientation) = screen_orientation() else {
83 return;
84 };
85 let upright = matches!(
86 orientation.kind,
87 web_sys::OrientationType::PortraitPrimary | web_sys::OrientationType::PortraitSecondary
88 );
89 if !upright {
90 return;
91 }
92 if let Err(error) = orientation
93 .value
94 .lock(web_sys::OrientationLockType::Landscape)
95 {
96 log::debug!("the screen orientation cannot be locked: {error:?}");
97 }
98}
99
100/// The screen's orientation object and the orientation it currently reports.
101#[cfg(target_arch = "wasm32")]
102struct ScreenOrientation {
103 kind: web_sys::OrientationType,
104 value: web_sys::ScreenOrientation,
105}
106
107/// Read the screen's orientation, or explain why it could not be read.
108///
109/// Every step can be missing — a browser may expose no `screen`, no
110/// `orientation` on it, or no `type` on that — and the API reports each by
111/// returning a value rather than by throwing.
112#[cfg(target_arch = "wasm32")]
113fn screen_orientation() -> Result<ScreenOrientation, JsValue> {
114 let screen = web_sys::window()
115 .ok_or_else(|| JsValue::from_str("the page has no window"))?
116 .screen()?;
117 let value = screen.orientation();
118 let kind = value.type_()?;
119 Ok(ScreenOrientation { kind, value })
120}
121
122#[cfg(target_arch = "wasm32")]
123thread_local! {
124 /// The `fullscreenchange` listener, kept alive for the page's lifetime.
125 ///
126 /// Registered once, because fullscreen can be entered and left repeatedly,
127 /// and never dropped: the document holds a reference to it either way, and
128 /// the app lives as long as the page does.
129 static FULLSCREEN_CHANGE: RefCell<Option<Closure<dyn FnMut()>>> = const { RefCell::new(None) };
130}
131
132/// Watch for the document entering fullscreen, so the orientation can be locked
133/// once it is permitted.
134///
135/// Called before the first fullscreen request, which is the event it waits for.
136/// A browser that refuses the listener simply never locks, which is what a
137/// platform with no orientation does anyway.
138#[cfg(target_arch = "wasm32")]
139fn watch_fullscreen_change() {
140 let Some(document) = web_sys::window().and_then(|window| window.document()) else {
141 return;
142 };
143 FULLSCREEN_CHANGE.with(|slot| {
144 let mut slot = slot.borrow_mut();
145 if slot.is_some() {
146 return;
147 }
148 let listener = Closure::<dyn FnMut()>::new(lock_landscape_when_upright);
149 if let Err(error) = document
150 .add_event_listener_with_callback("fullscreenchange", listener.as_ref().unchecked_ref())
151 {
152 log::debug!("the page cannot watch for fullscreen changes: {error:?}");
153 return;
154 }
155 *slot = Some(listener);
156 });
157}