Add fullscreen support: HUD button (desktop/Android) + iOS Add to Home Screen banner (#3688)

Resolves #3685

## Description:

Adds fullscreen support for both desktop and mobile:

**Desktop / Android** — a fullscreen toggle button in the in-game HUD
(right sidebar), next to the settings button. Icon switches between
expand/compress depending on current state, synced with
`fullscreenchange` event (works with F11 too). Hidden on browsers that
don't support `document.fullscreenEnabled`.

**iOS** — since Safari doesn't support the Fullscreen API, a dismissible
banner is shown on the main screen (above the lobby cards) explaining
how to add the game to the Home Screen for a fullscreen experience. The
banner includes:
- **How** button — opens a step-by-step guide modal with iOS version
detection (iOS 26+ shows updated steps for the new ··· menu location,
including the extra Share step inside the menu)
- **Later** — hides until next visit
- **Never** — permanently dismisses via localStorage
- **Click here** button styled as a speech bubble with a tail pointing
toward the Share button location (center for iOS ≤18, right for iOS 26+)

All user-facing strings are wired through `translateText()` with keys
added to `en.json`.

## Please complete the following:

- [x] I have added screenshots for all UI updates
- [x] I process any text displayed to the user through translateText()
and I've added it to the en.json file
- [x] I have added relevant tests to the test directory
- [x] I confirm I have thoroughly tested these changes and take full
responsibility for any bugs introduced

## UI changes: 
### For [Fullscreen API supported
browsers](https://caniuse.com/?search=fullscreen+api):


https://github.com/user-attachments/assets/026e6a67-d070-4a7e-897b-52396a43191e

### For safari on ios: (add to homescreen modal)

<img width="375" height="667" alt="IMG_2242"
src="https://github.com/user-attachments/assets/9d0a6454-8512-44cf-b6ed-989de3ff02bc"
/>
<img width="648" height="1292" alt="CleanShot 2026-04-22 at 11 29 27@2x"
src="https://github.com/user-attachments/assets/dba1c218-2b73-4bc0-ac7d-14962eb79327"
/>



## Please put your Discord username so you can be contacted if a bug or
regression is found:

fghjk_60845

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
This commit is contained in:
Ivan Batsulin
2026-04-23 21:38:07 +03:00
committed by GitHub
parent 9df0569c5e
commit 4fd162415a
7 changed files with 277 additions and 0 deletions
+1
View File
@@ -10,6 +10,7 @@ resources/.DS_Store
.DS_Store
.clinic/
CLAUDE.md
NOTES.md
.claude/
.idea/
# this is autogenerated by script
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="4 14 10 14 10 20"/>
<polyline points="20 10 14 10 14 4"/>
<line x1="10" y1="14" x2="3" y2="21"/>
<line x1="21" y1="3" x2="14" y2="10"/>
</svg>

After

Width:  |  Height:  |  Size: 321 B

+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="15 3 21 3 21 9"/>
<polyline points="9 21 3 21 3 15"/>
<line x1="21" y1="3" x2="14" y2="10"/>
<line x1="3" y1="21" x2="10" y2="14"/>
</svg>

After

Width:  |  Height:  |  Size: 317 B

+17
View File
@@ -1087,5 +1087,22 @@
"dismiss": "Dismiss",
"go_to_item": "Go to item {num}",
"firefox_warning": "OpenFront.io doesn't perform well with [Firefox-based browsers](https://simple.wikipedia.org/wiki/Web_browsers_based_on_Firefox). We recommend you to use a [Chromium-based browser](https://en.wikipedia.org/wiki/Chromium_(web_browser)#Browsers_based_on_Chromium) for best performance."
},
"ios_banner": {
"text": "For fullscreen, add OpenFront to your Home Screen",
"how": "How",
"later": "Later",
"never": "Never",
"modal_title": "Fullscreen on iOS",
"modal_desc": "Your browser doesn't support fullscreen, but you can add OpenFront to your Home Screen for an immersive experience:",
"step_share": "Tap the Share button in your browser",
"step_scroll_and_tap": "Scroll down and tap",
"step_add_to_home_label": "Add to Home Screen",
"step_open": "Open from your Home Screen — launches fullscreen without the address bar",
"got_it": "Got it"
},
"fullscreen": {
"enter": "Enter fullscreen",
"exit": "Exit fullscreen"
}
}
+4
View File
@@ -10,6 +10,7 @@ import {
Trios,
} from "../core/game/Game";
import { PublicGameInfo, PublicGames } from "../core/Schemas";
import "./components/IOSAddToHomeScreenBanner";
import { crazyGamesSDK } from "./CrazyGamesSDK";
import { HostLobbyModal } from "./HostLobbyModal";
import { JoinLobbyModal } from "./JoinLobbyModal";
@@ -142,6 +143,9 @@ export class GameModeSelector extends LitElement {
"bg-slate-600 hover:bg-slate-500 active:bg-slate-700",
)}
</div>
<!-- iOS Add to Home Screen banner -->
<ios-add-to-home-screen-banner></ios-add-to-home-screen-banner>
<!-- Game cards grid -->
<div
class="grid grid-cols-1 sm:grid-cols-[2fr_1fr] gap-4 sm:h-[min(24rem,40vh)]"
@@ -0,0 +1,195 @@
import { LitElement, html, nothing } from "lit";
import { customElement, state } from "lit/decorators.js";
import { Platform } from "../Platform";
import { translateText } from "../Utils";
const DISMISSED_KEY = "ios_a2hs_banner_dismissed";
const LATER_KEY = "ios_a2hs_banner_later";
@customElement("ios-add-to-home-screen-banner")
export class IOSAddToHomeScreenBanner extends LitElement {
@state() private dismissed = false;
@state() private later = false;
@state() private showGuide = false;
createRenderRoot() {
return this;
}
connectedCallback() {
super.connectedCallback();
try {
this.dismissed = localStorage.getItem(DISMISSED_KEY) === "true";
} catch {
this.dismissed = false;
}
try {
this.later = sessionStorage.getItem(LATER_KEY) === "true";
} catch {
this.later = false;
}
}
private never() {
try {
localStorage.setItem(DISMISSED_KEY, "true");
} catch {
// localStorage unavailable — dismiss for session only
}
this.dismissed = true;
}
private later_() {
try {
sessionStorage.setItem(LATER_KEY, "true");
} catch {
// ignore — this.later still set in memory
}
this.later = true;
}
private openGuide() {
this.showGuide = true;
}
private closeGuide() {
this.showGuide = false;
}
private renderGuideModal() {
if (!this.showGuide) return nothing;
return html`
<div
class="fixed inset-0 bg-black/70 backdrop-blur-sm z-[9999] flex items-end sm:items-center justify-center p-4"
@click=${(e: Event) => {
if (e.target === e.currentTarget) this.closeGuide();
}}
>
<div class="relative w-full max-w-sm">
<div
class="bg-slate-800 border border-slate-600 rounded-2xl w-full p-5 pb-6 flex flex-col gap-4"
role="dialog"
aria-modal="true"
aria-labelledby="ios-banner-modal-title"
>
<div class="flex items-center justify-between">
<h2
id="ios-banner-modal-title"
class="text-white font-bold text-lg"
>
${translateText("ios_banner.modal_title")}
</h2>
<button
class="text-slate-400 hover:text-white text-2xl leading-none"
@click=${this.closeGuide}
aria-label=${translateText("common.close")}
>
×
</button>
</div>
<p class="text-slate-300 text-sm">
${translateText("ios_banner.modal_desc")}
</p>
<ol class="flex flex-col gap-3 text-sm text-slate-200">
<li class="flex items-start gap-3">
<span
class="shrink-0 w-6 h-6 rounded-full bg-[#0073b7] flex items-center justify-center text-white font-bold text-xs"
>1</span
>
<span>${translateText("ios_banner.step_share")}</span>
</li>
<li class="flex items-start gap-3">
<span
class="shrink-0 w-6 h-6 rounded-full bg-[#0073b7] flex items-center justify-center text-white font-bold text-xs"
>2</span
>
<span
>${translateText("ios_banner.step_scroll_and_tap")}
<strong class="text-white"
>${translateText(
"ios_banner.step_add_to_home_label",
)}</strong
></span
>
</li>
<li class="flex items-start gap-3">
<span
class="shrink-0 w-6 h-6 rounded-full bg-[#0073b7] flex items-center justify-center text-white font-bold text-xs"
>3</span
>
<span>${translateText("ios_banner.step_open")}</span>
</li>
</ol>
<button
class="w-full py-2.5 rounded-lg bg-[#0073b7] hover:bg-sky-500 active:bg-sky-700 text-white font-semibold transition-colors"
@click=${this.closeGuide}
>
${translateText("ios_banner.got_it")}
</button>
</div>
</div>
</div>
`;
}
render() {
if (!Platform.isIOS) return nothing;
if (this.dismissed || this.later) return nothing;
if (
(navigator as any).standalone === true ||
window.matchMedia("(display-mode: standalone)").matches
) {
return nothing;
}
return html`
${this.renderGuideModal()}
<div
class="flex flex-col gap-3 w-full px-3 py-3 rounded-xl bg-slate-800/90 border border-slate-600 text-sm text-slate-200"
>
<div class="flex gap-3 items-center">
<svg
xmlns="http://www.w3.org/2000/svg"
class="shrink-0 w-8 h-8 text-[#0073b7]"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<rect x="5" y="2" width="14" height="20" rx="2" ry="2" />
<line x1="12" y1="18" x2="12.01" y2="18" />
</svg>
<span>${translateText("ios_banner.text")}</span>
</div>
<div class="flex flex-col gap-1.5">
<button
class="w-full py-1.5 rounded-lg bg-[#0073b7] hover:bg-sky-500 active:bg-sky-700 text-white font-semibold text-sm transition-colors"
@click=${this.openGuide}
>
${translateText("ios_banner.how")}
</button>
<button
class="w-full py-1.5 rounded-lg bg-slate-700 hover:bg-slate-600 active:bg-slate-800 text-slate-300 text-sm transition-colors"
@click=${this.later_}
>
${translateText("ios_banner.later")}
</button>
<button
class="w-full py-1.5 rounded-lg text-slate-500 hover:text-slate-400 text-xs transition-colors"
@click=${this.never}
>
${translateText("ios_banner.never")}
</button>
</div>
</div>
`;
}
}
@@ -18,6 +18,8 @@ const FastForwardIconSolid = assetUrl("images/FastForwardIconSolidWhite.svg");
const pauseIcon = assetUrl("images/PauseIconWhite.svg");
const playIcon = assetUrl("images/PlayIconWhite.svg");
const settingsIcon = assetUrl("images/SettingIconWhite.svg");
const fullscreenIcon = assetUrl("images/FullscreenIconWhite.svg");
const exitFullscreenIcon = assetUrl("images/ExitFullscreenIconWhite.svg");
@customElement("game-right-sidebar")
export class GameRightSidebar extends LitElement implements Layer {
@@ -36,6 +38,9 @@ export class GameRightSidebar extends LitElement implements Layer {
@state()
private isPaused: boolean = false;
@state()
private isFullscreen: boolean = false;
@state()
private timer: number = 0;
@@ -80,6 +85,21 @@ export class GameRightSidebar extends LitElement implements Layer {
this.requestUpdate();
}
private onFullscreenChange = () => {
this.isFullscreen = !!document.fullscreenElement;
};
connectedCallback() {
super.connectedCallback();
document.addEventListener("fullscreenchange", this.onFullscreenChange);
this.onFullscreenChange();
}
disconnectedCallback() {
super.disconnectedCallback();
document.removeEventListener("fullscreenchange", this.onFullscreenChange);
}
getTickIntervalMs() {
return 250;
}
@@ -177,6 +197,18 @@ export class GameRightSidebar extends LitElement implements Layer {
);
}
private onFullscreenButtonClick() {
if (!document.fullscreenElement) {
document.documentElement.requestFullscreen().catch((err) => {
console.warn("Failed to enter fullscreen:", err);
});
} else {
document.exitFullscreen().catch((err) => {
console.warn("Failed to exit fullscreen:", err);
});
}
}
render() {
if (this.game === undefined) return html``;
@@ -204,6 +236,22 @@ export class GameRightSidebar extends LitElement implements Layer {
<img src=${settingsIcon} alt="settings" width="20" height="20" />
</div>
${document.fullscreenEnabled
? html`<div
class="cursor-pointer"
@click=${this.onFullscreenButtonClick}
>
<img
src=${this.isFullscreen ? exitFullscreenIcon : fullscreenIcon}
alt=${this.isFullscreen
? translateText("fullscreen.exit")
: translateText("fullscreen.enter")}
width="20"
height="20"
/>
</div>`
: ""}
<div class="cursor-pointer" @click=${this.onExitButtonClick}>
<img src=${exitIcon} alt="exit" width="20" height="20" />
</div>