Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions client/src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -370,6 +370,7 @@ describe("App — chrome button tooltips", () => {
// independent of that detail.
{ name: /now playing/i, tipId: "now playing" },
{ name: "layout editor", tipId: "layout editor" },
{ name: "fullscreen", tipId: "fullscreen" },
{ name: "settings", tipId: "settings" },
];
for (const c of cases) {
Expand All @@ -393,6 +394,7 @@ describe("App — chrome button tooltips", () => {
screen.getByRole("button", { name: "manual control" }),
screen.getByRole("button", { name: /now playing/i }),
screen.getByRole("button", { name: "layout editor" }),
screen.getByRole("button", { name: "fullscreen" }),
screen.getByRole("button", { name: "settings" }),
];
for (const b of iconOnly) {
Expand Down Expand Up @@ -1110,3 +1112,63 @@ describe("App — session lock takeover", () => {
expect(screen.getByRole("button", { name: /running programs/i }).getAttribute("disabled")).toBe("");
});
});

/* ---------------------------------------------------------------------
Fullscreen chrome toggle (phone-friendly full-surface mode).
jsdom implements no Fullscreen API, so we stub the three members the
hook touches and drive state through the DOM's "fullscreenchange"
event, exactly as a real browser would.
--------------------------------------------------------------------- */
describe("App — fullscreen toggle", () => {
afterEach(cleanup);
beforeEach(() => {
send.mockReset();
window.history.replaceState(null, "", "/?demo=default");
});
afterEach(() => {
const doc = document as unknown as Record<string, unknown>;
delete doc.fullscreenEnabled;
delete doc.fullscreenElement;
delete (document.documentElement as unknown as Record<string, unknown>).requestFullscreen;
delete doc.exitFullscreen;
});

it("requests fullscreen on tap, then exits on the second tap", async () => {
render(<App />);
const button = screen.getByRole("button", { name: "fullscreen" });
expect(button.getAttribute("aria-pressed")).toBe("false");

const doc = document as unknown as Record<string, unknown>;
doc.fullscreenEnabled = true;

const requestFullscreen = vi.fn(() => Promise.resolve());
(document.documentElement as unknown as Record<string, unknown>).requestFullscreen =
requestFullscreen;

fireEvent.pointerDown(button);
await waitFor(() => expect(requestFullscreen).toHaveBeenCalledTimes(1));

// A real browser flips fullscreenElement then fires the event.
doc.fullscreenElement = document.documentElement;
fireEvent(document, new Event("fullscreenchange"));
const pressed = screen.getByRole("button", { name: "exit fullscreen" });
expect(pressed.getAttribute("aria-pressed")).toBe("true");

const exitFullscreen = vi.fn(() => Promise.resolve());
(document as unknown as Record<string, unknown>).exitFullscreen = exitFullscreen;

fireEvent.pointerDown(pressed);
await waitFor(() => expect(exitFullscreen).toHaveBeenCalledTimes(1));

doc.fullscreenElement = null;
fireEvent(document, new Event("fullscreenchange"));
expect(screen.getByRole("button", { name: "fullscreen" }).getAttribute("aria-pressed")).toBe("false");
});

it("is a no-op when the Fullscreen API is unavailable", () => {
render(<App />);
// No stubs installed: fullscreenEnabled is undefined.
fireEvent.pointerDown(screen.getByRole("button", { name: "fullscreen" }));
expect(screen.getByRole("button", { name: "fullscreen" }).getAttribute("aria-pressed")).toBe("false");
});
});
20 changes: 19 additions & 1 deletion client/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Lock as LockIcon, Moon as MoonIcon, Pencil as PencilIcon, Settings as SettingsIcon, Globe as GlobeIcon } from "lucide-react";
import { Lock as LockIcon, Moon as MoonIcon, Pencil as PencilIcon, Settings as SettingsIcon, Globe as GlobeIcon, Maximize as MaximizeIcon, Minimize as MinimizeIcon } from "lucide-react";
import { LayoutGrid as LayoutGridIcon, Music as MusicIcon, PointerIcon } from "lucide-react";
import { useDeckdSocket } from "./socket";
import { ButtonGrid } from "./ButtonGrid";
Expand Down Expand Up @@ -30,6 +30,7 @@ import {
} from "./settings-store";
import type { CSSProperties } from "react";
import { useWakeLock } from "./wake-lock";
import { useFullscreen } from "./fullscreen";
import { getDemoLayout, getDemoView, MEDIA_DEMO_STATES, MPRIS_DEMO_STATES, EDITOR_DEMO_LAYOUTS } from "./demo";
import { usePlaygroundDaemon } from "./playground/usePlaygroundDaemon";
import { Icon } from "./Icon";
Expand Down Expand Up @@ -322,6 +323,11 @@ export function App() {
// screen on. Visibility is handled inside the hook.
useWakeLock(wakeLock.enabled && status === "open");

// Fullscreen toggle (hides browser chrome on phone/desktop). The
// ``fullscreenchange`` sync inside the hook keeps the icon honest when
// the browser exits fullscreen on its own (back gesture, Esc).
const [isFullscreen, toggleFullscreen] = useFullscreen();

const press = (id: string) => send({ type: "press", id });
const jog = (id: string, delta: number) => send({ type: "jog", id, delta });
const jogEnd = (id: string, velocity: number) => send({ type: "jog_end", id, velocity });
Expand Down Expand Up @@ -405,6 +411,7 @@ export function App() {
// Refs to the chrome buttons themselves, so focus restoration knows
// where to land when a view closes.
const trackpadBtnRef = useRef<HTMLButtonElement | null>(null);
const fullscreenBtnRef = useRef<HTMLButtonElement | null>(null);
const settingsBtnRef = useRef<HTMLButtonElement | null>(null);
const mediaBtnRef = useRef<HTMLButtonElement | null>(null);
const editorBtnRef = useRef<HTMLButtonElement | null>(null);
Expand Down Expand Up @@ -968,6 +975,17 @@ export function App() {
<LayoutGridIcon size={18} />
</button>
</Tooltip>
<Tooltip ref={fullscreenBtnRef} label={isFullscreen ? "exit fullscreen" : "fullscreen"}>
<button
className="chrome-btn"
aria-label={isFullscreen ? "exit fullscreen" : "fullscreen"}
aria-pressed={isFullscreen}
onPointerDown={toggleFullscreen}
onKeyDown={onActivate(toggleFullscreen)}
>
{isFullscreen ? <MinimizeIcon size={18} /> : <MaximizeIcon size={18} />}
</button>
</Tooltip>
<Tooltip ref={settingsBtnRef} label="settings">
<button
className={`chrome-btn${view === "settings" || view === "help" ? " chrome-btn-active" : ""}`}
Expand Down
33 changes: 33 additions & 0 deletions client/src/fullscreen.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { useCallback, useEffect, useState } from "react";

/** Track document fullscreen state and expose a toggle. The "enter" side
* must be called from a user gesture (tap / keydown) — the browser enforces
* that, so the hook only forwards the request. The ``fullscreenchange``
* sync means external exits (system back gesture, Esc) update the state
* without extra wiring. All calls are guarded: jsdom and older browsers
* lack the API, in which case the state is permanently false and the
* toggle is a no-op. */
export function useFullscreen(): [boolean, () => void] {
const [isFullscreen, setFullscreen] = useState(
() => typeof document !== "undefined" && document.fullscreenElement != null,
);

useEffect(() => {
const onChange = () => setFullscreen(document.fullscreenElement != null);
document.addEventListener("fullscreenchange", onChange);
return () => document.removeEventListener("fullscreenchange", onChange);
}, []);

const toggle = useCallback(() => {
if (!document.fullscreenEnabled) return;
if (document.fullscreenElement != null) {
void document.exitFullscreen().catch(() => {});
} else {
void document.documentElement
.requestFullscreen({ navigationUI: "hide" })
.catch(() => {});
}
}, []);

return [isFullscreen, toggle];
}
Loading