mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 07:40:43 +00:00
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:
@@ -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 |
@@ -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 |
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user