From ed4a0ad5cd2c2f3a706848425d140bb0a1157f2f Mon Sep 17 00:00:00 2001 From: saphid <4596216+saphid@users.noreply.github.com> Date: Tue, 29 Sep 2026 15:48:57 +1000 Subject: [PATCH] Lead Home with what's happening, and give settings a page Home answers "what's my headset doing, and what do I want to do now". A Right now card says whether SteamVR is running and holds the volume and the next things people do: cast to this screen, play a video or photo, show a Mac window, send files, type on the Frame. With nothing captured, the headset view is a short strip rather than a big black box; the keyboard and trackpad fold to one line until turned on; VR performance folds away. Screenshots and Family and comfort follow. Tools is grouped: in the headset (Mac in the headset, the media player, the panel switcher), then sending files, Linux apps, remote and power. Privacy and updates, library artwork and the assistant move to a Settings page, opened by a gear in the header (key 6), with About and licences. Family and comfort no longer fills the form with a finished session's settings, so it shows 30 / 20 / 30 rather than the last test's 1 / 0 / 0, and its recent events carry their times. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/agents.md | 2 +- docs/family-comfort.md | 2 +- docs/frame-control.md | 17 ++- docs/iphone.md | 4 +- docs/panels.md | 2 +- docs/privacy.md | 2 +- docs/vr-apks.md | 2 +- docs/vr-utilities.md | 2 +- docs/vr-video.md | 2 +- tests/test_comfort_ui.py | 30 ++++ ui/index.html | 292 ++++++++++++++++++++++++--------------- 11 files changed, 231 insertions(+), 126 deletions(-) diff --git a/docs/agents.md b/docs/agents.md index 76ee9c1..20cc82f 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -97,7 +97,7 @@ replies, screenshots and approval payloads are not sent to analytics. ## Assistant panel -Open **Tools → Open assistant**, or `http://127.0.0.1:47810/assistant`. +Open **Settings** (the gear in the header) **→ Open assistant settings**, or `http://127.0.0.1:47810/assistant`. To put the same page in the headset, with the HTTP server still running: ```sh diff --git a/docs/family-comfort.md b/docs/family-comfort.md index e2f1afe..98b94b5 100644 --- a/docs/family-comfort.md +++ b/docs/family-comfort.md @@ -58,7 +58,7 @@ not been verified and is not guaranteed. ## Casting -**Cast headset view** starts the existing headset Live view and requests full +**Cast to this screen** (Home → **Right now**) starts the existing headset Live view and requests full screen where supported. Show that screen to people in the room, or use the computer/phone's own screen mirroring. It creates no new stream transport, public URL or LAN server. iPhone uses the inline viewer if full screen is not diff --git a/docs/frame-control.md b/docs/frame-control.md index eedc055..4c10fe9 100644 --- a/docs/frame-control.md +++ b/docs/frame-control.md @@ -17,11 +17,16 @@ python3 ui/server.py # anywhere: then open http://127.0.0.1:47810 ## Features -The window has five tabs: **Home** (headset view, status, screenshots), -**Games** (installed games, sideloaded titles, getting games), **Android** (apps, -the catalogue, display settings, reports), **Tools** (sending files and text, -Flatpaks, remote and power) and **Devices** (your headsets and their addresses). -Keys 1–5 switch between them. Files can be dropped anywhere in the window. A +The window has five tabs: **Home** (what the headset's doing and what you'd do +next, the headset view, screenshots, family and comfort, the keyboard and +trackpad, and VR performance, folded away), **Games** (installed games, +sideloaded titles, getting games), **Android** (apps, the catalogue, display +settings, reports), **Tools** (in the headset: your computer's windows, the media +player and the panel switcher; then sending files and text, Flatpaks, remote and +power) and **Devices** (your headsets and their addresses). Keys 1–5 switch +between them. The gear in the header (key 6) opens **Settings**: privacy and +updates, library artwork and the assistant. The battery chip opens the headset's +details: storage, memory, temperature, Wi-Fi, uptime and SteamOS build. Files can be dropped anywhere in the window. A connection pill in the header always shows which headset and whether it's connected, and how (Tailscale, or the network's name). Click it to add, edit or reorder the headset's addresses, or to see each step of connecting. When the Frame can't be @@ -178,5 +183,5 @@ Control implementations. MCP wraps this HTTP API without API keys. Changes require a separate user approval; power also retains its password prompt. The assistant uses a user-chosen endpoint and sends nothing until the user opts in for a message. Screenshot context is separately opt-in. Model replies cannot -operate the headset. Tools → Open assistant opens the page; the linked guide +operate the headset. Settings → Open assistant settings opens the page; the linked guide covers putting it in a Chromium panel on the Frame. diff --git a/docs/iphone.md b/docs/iphone.md index debc954..6381799 100644 --- a/docs/iphone.md +++ b/docs/iphone.md @@ -114,8 +114,8 @@ don't. ## Family and comfort -The shared Home card sets session limits, breaks and check-ins, and offers -**Cast headset view**. **Enable / test notifications** requests iOS notification +The shared Home card sets session limits, breaks and check-ins; **Cast to this +screen** is in Home's **Right now** card. **Enable / test notifications** requests iOS notification permission and sends a local test. These are local notifications, not APNs push; iOS background suspension can interrupt phone alerts. The headset timer still runs. See [the behavior and verification limits](family-comfort.md). diff --git a/docs/panels.md b/docs/panels.md index 5b17828..6c8478b 100644 --- a/docs/panels.md +++ b/docs/panels.md @@ -125,7 +125,7 @@ a limit on the number of floating panels. separate from Frame Control. Public reports describe some Proton support; Windows-only does not by itself prove a Frame app cannot run. Local status and sources are in [VR utilities](vr-utilities.md). -- **Our performance HUD:** Home → VR comfort and performance → Open HUD in +- **Our performance HUD:** Home → VR performance (unfold it) → Open HUD in headset creates its own gamescope panel using built-in tools. It needs no third-party overlay app. [Metrics and verification](vr-utilities.md). diff --git a/docs/privacy.md b/docs/privacy.md index 51b7d9a..141f76d 100644 --- a/docs/privacy.md +++ b/docs/privacy.md @@ -98,7 +98,7 @@ The same error is sent at most once every 10 minutes. ## Report a problem **Report a problem** is the speech-bubble button in the header, also in the -Privacy panel and under **Help → Report a Problem…**. It sends the report +Settings page (**Privacy & updates**) and under **Help → Report a Problem…**. It sends the report privately to Frame Control's PostHog project as a `problem_report` event, the same way as the analytics above, so only the maintainer can read it and nothing is published. It works whatever the analytics settings are, because diff --git a/docs/vr-apks.md b/docs/vr-apks.md index bce6817..38ba7ff 100644 --- a/docs/vr-apks.md +++ b/docs/vr-apks.md @@ -94,7 +94,7 @@ A new shortcut is rolled back if artwork fails; failure is never reported as an installed app with a blank tile. Artwork preference is **SteamGridDB → source images → generated fallback**. -Set the optional free key in Frame Control's **Library artwork settings**, or +Set the optional free key in Frame Control's **Settings → Library artwork**, or `STEAMGRIDDB_API_KEY` (`FRAME_STEAMGRIDDB_API_KEY` also works). Environment settings override the saved key. Without a key there are no provider calls or warnings. Saved keys stay in host app data, mode 0600 on POSIX, and are never diff --git a/docs/vr-utilities.md b/docs/vr-utilities.md index 1c289b4..6e2db25 100644 --- a/docs/vr-utilities.md +++ b/docs/vr-utilities.md @@ -11,7 +11,7 @@ checks below. The PR stays draft. ## Our performance HUD -On **Home → VR comfort and performance**, the app shows a timestamped sample +On **Home → VR performance** (folded until you open it), the app shows a timestamped sample with each status refresh (30 seconds, or Refresh). **Open HUD in headset** starts our text HUD as a gamescope panel, refreshed every two seconds. In the SteamVR dashboard, select **Frame Control HUD**, then Float in World or dock diff --git a/docs/vr-video.md b/docs/vr-video.md index 2232915..eab6080 100644 --- a/docs/vr-video.md +++ b/docs/vr-video.md @@ -6,7 +6,7 @@ is required.** Chromium and immersive WebXR are not in this playback path. ## Use it -In **Tools → Media in the headset**, send a file, choose its layout and press +In **Tools → Media player** (or **Play a video or photo** on Home), send a file, choose its layout and press **Play**. **Theatre** gives it a larger screen and an 85% black surround. **Stop** removes both. Refresh reads the library and the player's state. The screen follows your head; it isn't a saved world-space panel. diff --git a/tests/test_comfort_ui.py b/tests/test_comfort_ui.py index 2d75b36..c990bc2 100644 --- a/tests/test_comfort_ui.py +++ b/tests/test_comfort_ui.py @@ -55,6 +55,36 @@ const log = ()=>{}, toast = ()=>{}; assert.equal($('sessionMinutes').disabled,false); assert.equal($('sessionCancel').disabled,true); })().catch(e=>{console.error(e);process.exitCode=1;}); +''' + result = subprocess.run(['node', '-e', setup + code + checks], capture_output=True, text=True) + self.assertEqual(result.returncode, 0, result.stderr) + + def test_a_finished_session_leaves_the_defaults(self): + """A one-minute test session that has ended mustn't fill the form with 1 / 0 / 0.""" + page = (ROOT / 'ui/index.html').read_text(encoding='utf-8') + code = page[page.index('let comfortBusy ='):page.index('async function pollComfort()')] + setup = r''' +const assert = require('node:assert/strict'); +const elements = new Map(); +const $ = id => { + if (!elements.has(id)) elements.set(id, {textContent:'', hidden:true, disabled:false, value:'30', checked:true, + type: /Alert$/.test(id) ? 'checkbox' : 'number'}); + return elements.get(id); +}; +const window = {frameApp:{notify:async()=>{}}}; +const log = ()=>{}, toast = ()=>{}; +''' + checks = r''' +renderComfort({id:'old', active:false, time:100, options:{minutes:1,breakMinutes:0,stillMinutes:0,batteryAlert:false,heatAlert:false}, + events:[{id:'old:1',kind:'finished',time:90,message:'Session ended'}]}); +assert.equal($('sessionMinutes').value, '30'); +assert.equal($('breakMinutes').value, '30'); +assert.equal($('batteryAlert').checked, true); +assert.match($('comfortEvents').textContent, /Session ended/); +assert.notEqual($('comfortEvents').textContent, 'Session ended'); // it carries a time +renderComfort({id:'new', active:true, time:200, remaining:600, options:{minutes:45,breakMinutes:15,stillMinutes:20,batteryAlert:true,heatAlert:false}, events:[]}); +assert.equal($('sessionMinutes').value, 45); // a running session shows its own settings +assert.equal($('heatAlert').checked, false); ''' result = subprocess.run(['node', '-e', setup + code + checks], capture_output=True, text=True) self.assertEqual(result.returncode, 0, result.stderr) diff --git a/ui/index.html b/ui/index.html index 1e15f1b..59a80c6 100644 --- a/ui/index.html +++ b/ui/index.html @@ -72,7 +72,7 @@ @media (max-width: 1150px) { .grid-top, .grid-3 { grid-template-columns: 1fr; } header { gap: 14px; padding: env(safe-area-inset-top) calc(18px + env(safe-area-inset-right)) 0 calc(18px + env(safe-area-inset-left)); } nav a { padding: 0 10px; } } @media (max-width: 960px) { header { gap: 10px; } nav { gap: 0; } nav a { padding: 0 8px; font-size: 13px; letter-spacing: .4px; } .brand, #refreshAll { display: none; } - .pill .pt > b, .pill .via { display: none; } .pill .pt > span { font-size: 13px; color: var(--bright); } } + .pill .pt > b, .pill .via { display: none; } header .pill .pt > span { font-size: 13px; color: var(--bright); } } nav svg { display: none; } html { -webkit-text-size-adjust: 100%; } body { -webkit-tap-highlight-color: transparent; } @@ -233,6 +233,10 @@ .zoombar button:hover { background: rgba(255,255,255,.12); } .zoombar .lvl { min-width: 46px; font-variant-numeric: tabular-nums; color: var(--muted); font-size: 12px; } .viewer .empty { text-align: center; color: var(--muted); padding: 24px; max-width: 460px; } + /* Nothing captured yet: a short strip rather than a big black box. */ + .viewer:has(> #viewerEmpty:not([hidden])) { aspect-ratio: auto !important; height: 190px; + background: radial-gradient(500px 160px at 50% 0%, rgba(42,71,94,.55), transparent 70%), #0b1016; } + .viewer:has(> #viewerEmpty:not([hidden])) .empty svg { width: 44px; height: 44px; margin-bottom: 4px; } .viewer .empty svg { width: 64px; height: 64px; opacity: .35; margin-bottom: 10px; } .viewer .badge { position: absolute; left: 12px; top: 12px; font-size: 11px; letter-spacing: 1.2px; text-transform: uppercase; background: rgba(0,0,0,.6); color: var(--text); padding: 4px 9px; border-radius: 2px; } @@ -260,6 +264,26 @@ @keyframes spin { to { transform: rotate(360deg); } } .toolbar { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; margin-bottom: 12px; } + /* ---- Home: what the headset's doing, and what you'd do next ---- */ + .now-state { margin: 0 0 4px; color: var(--bright); font-size: 15px; } + .now-actions { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; margin-top: 16px; } + .now-actions button { justify-content: center; height: 38px; white-space: normal; line-height: 1.2; text-align: center; } + .now-actions button:first-child { grid-column: 1 / -1; } + @media (max-width: 1150px) { .now { order: -1; } } /* stacked: what's happening comes first */ + #padIntro { margin: 0; } + .group-head { margin: 4px 0 -8px; } + a.button-link { display: inline-flex; align-items: center; height: 34px; padding: 0 14px; border-radius: 3px; background: var(--btn); + color: var(--text); text-decoration: none; } + a.button-link:hover { background: var(--btn-hi); color: var(--bright); } + #settingsBtn.on { color: var(--blue); } + /* A panel that folds: its heading row is the summary. */ + .fold > summary { list-style: none; cursor: pointer; margin: 0; color: inherit; font-size: inherit; } + .fold > summary::-webkit-details-marker { display: none; } + .fold > summary h2::before { content: "▸"; display: inline-block; width: 1.1em; color: var(--muted); } + .fold[open] > summary h2::before { content: "▾"; } + .fold:not([open]) > summary { margin-bottom: 0; } + .fold > summary:focus-visible { outline: 2px solid var(--blue); outline-offset: 4px; } + /* ---- the headset's details: a menu under the battery chip ---- */ .pop { position: fixed; inset: auto; margin: 0; width: min(390px, calc(100vw - 24px)); max-height: calc(100vh - 90px); overflow: auto; padding: 16px; border: 1px solid rgba(255,255,255,.1); border-radius: 6px; background: #1e2329; color: var(--text); @@ -481,8 +505,8 @@ header { height: calc(52px + env(safe-area-inset-top)); padding: env(safe-area-inset-top) calc(14px + env(safe-area-inset-right)) 0 calc(14px + env(safe-area-inset-left)); gap: 10px; backdrop-filter: none; background: rgb(23, 29, 37); } .brand svg { width: 26px; height: 26px; } - .brand { display: flex; } #refreshAll { display: inline-flex; } /* the tabs are at the bottom here */ - .pill .pt > b { display: block; } .pill .via { display: inline; } .pill .pt > span { font-size: 11.5px; color: var(--muted); } + .brand { display: flex; } /* the tabs are at the bottom here; Refresh is automatic */ + .pill .pt > b { display: block; } .pill .via { display: inline; } header .pill .pt > span { font-size: 11.5px; color: var(--muted); } header .chip { height: 30px; padding: 0 10px; } nav { position: fixed; left: 0; right: 0; bottom: 0; z-index: 25; gap: 0; height: calc(58px + env(safe-area-inset-bottom)); padding: 0 env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left); @@ -697,6 +721,9 @@ + @@ -805,8 +832,9 @@
-
-

Volume

+
+

Right now

+

Waiting for the Frame…

+
+ + + + + +
+

Casting shows everything the wearer sees, including anything private.

-
-

VR comfort and performance

Waiting for the Frame
-
-
Compositor FPS measures SteamVR output, not game FPS. Application FPS is unavailable when no app supplies timing. GPU time is render time, not GPU utilisation. Battery and temperature are in the battery menu at the top.
-
- - +
+

Screenshots

+ + +
-
The HUD is ours and uses the Frame’s built-in tools. Place its panel with SteamVR’s Float in World or controller docking controls. It updates every two seconds until closed.
-

Playspace and seated comfort

-

Tracking-origin controls are not available yet: reversible changes still need an idle-headset test. Use SteamVR’s recenter and room setup for now. A seated origin only affects apps that use seated tracking; it cannot force a game into seated mode.

-
For motion comfort, choose snap-turn, teleport movement and a movement vignette in each game’s settings. SteamVR has no verified universal snap-turn or locomotion-vignette switch.
-
Optional VR software -

None of Frame Control’s features needs these apps. Compatibility reports and account ownership are separate. Paid apps can only be installed here when already owned.

- -
Load this optional list to check the Frame account.
-
+
Loading…
+
Screenshots you take in the headset with Steam's screenshot shortcut. Click one to open it in the viewer; Save copies it to ~/Pictures/SteamFrame.
-
-

Family and comfort

- -
-

Share this screen with people in the room. Casting shows everything the wearer sees, including private content.

+
+

Family and comfort

Session timer and reminders
@@ -870,6 +894,9 @@

Keyboard and trackpad

+

Use this computer's keyboard and mouse in apps on the Frame, such as Chromium or the + desktop's Linux apps.

+ +
-
-

Screenshots

- - - +
+

VR performance

Frame timing, the HUD, playspace and optional software + Waiting for the Frame
+
+
Compositor FPS measures SteamVR output, not game FPS. Application FPS is unavailable when no app supplies timing. GPU time is render time, not GPU utilisation. Battery and temperature are in the battery menu at the top.
+
+ +
-
Loading…
-
Screenshots you take in the headset with Steam's screenshot shortcut. Click one to open it in the viewer; Save copies it to ~/Pictures/SteamFrame.
-
+
The HUD is ours and uses the Frame’s built-in tools. Place its panel with SteamVR’s Float in World or controller docking controls. It updates every two seconds until closed.
+

Playspace and seated comfort

+

Tracking-origin controls are not available yet: reversible changes still need an idle-headset test. Use SteamVR’s recenter and room setup for now. A seated origin only affects apps that use seated tracking; it cannot force a game into seated mode.

+
For motion comfort, choose snap-turn, teleport movement and a movement vignette in each game’s settings. SteamVR has no verified universal snap-turn or locomotion-vignette switch.
+
Optional VR software +

None of Frame Control’s features needs these apps. Compatibility reports and account ownership are separate. Paid apps can only be installed here when already owned.

+ +
Load this optional list to check the Frame account.
+
+ +
@@ -1024,7 +1063,64 @@
-

Assistant and AI agents

Use your own model endpoint, or review a proposed MCP action. Nothing is sent to a model until you opt in.

Open assistant
+

In the headset

Show your computer, films and photos, and panels
+
+ + +
+

Media player

Preview
+

Watch your own films and stereo photos on a big screen in the headset.

+
+
+ +
+
+
+
+
+
+
+

Choose a file to begin.

+
H.264/H.265 video; SBS/OU PNG or JPEG photos. Screens follow your head. + Small .splat files show a stationary stereo preview (up to 20,000 Gaussians). + Native HEIC/MPO photos, large splat scenes, seeking and subtitles aren't supported yet. + Stop works from here; playback also ends after four hours.
+
+ +
+

Panel switcher

+ +
+
Refresh to see open panels.
+
Choose a panel to show it in SteamVR. The headset switcher stays available from Steam's dashboard. + Saved spatial layouts are not available yet.
+
+
+

Send and manage

Files, clipboard, Linux apps, remote access and power

Send to Frame

@@ -1051,31 +1147,6 @@
Clipboard needs the desktop panel open in the headset.
-
-

Media in the headset

Preview
-

Play your movies and stereo photos with Frame Control's own player.

-
-
- -
-
-
-
-
-
-
-

Choose a file to begin.

-
H.264/H.265 video; SBS/OU PNG or JPEG photos. Screens follow your head. - Small .splat files show a stationary stereo preview (up to 20,000 Gaussians). - Native HEIC/MPO photos, large splat scenes, seeking and subtitles aren't supported yet. - Stop works from here; playback also ends after four hours.
-
-

Linux apps

Flatpak
Loading…
@@ -1086,20 +1157,6 @@
- -
-

Library artwork settings

-

SteamGridDB supplies community artwork for sideloaded games. Without a key, Frame Control uses source images and creates the remaining art.

- - -
- - - Get a free key -
-
- -

Remote & power

@@ -1114,44 +1171,17 @@
Sleep, Restart and Shut down open a terminal window for the Developer Mode password.
Sleep, Restart and Shut down ask for the Developer Mode password. SSH, SFTP, Steam Link and remote desktop open in the app that handles them.
-
- -
-

Panel switcher

- -
-
Refresh to see open panels.
-
Choose a panel to show it in SteamVR. The headset switcher stays available from Steam's dashboard. - Saved spatial layouts are not available yet.
-
- - - +
+
+
+

Settings

+

Privacy & updates

+ +
+

Library artwork

+

SteamGridDB supplies community artwork for sideloaded games. Without a key, Frame Control uses source images and creates the remaining art.

+ + +
+ + + Get a free key +
+
+ +
+ +
+

Assistant and AI agents

+

Use your own model endpoint, and review what an AI agent asks to do over MCP before it happens. Nothing is + sent to a model until you turn it on.

+ +
@@ -1430,6 +1481,7 @@ function applyHostWording(h) { $("shotsSaveNew").textContent = h.mobile ? `Save all to ${h.computer}` : `Save new to ${h.computer}`; $("clipMac").textContent = `Send ${h.computer} clipboard`; if (h.mobile) $("offSetup").textContent = "Change headset…"; + $("nowMac").hidden = !!h.mobile || h.computer !== "Mac"; } // Images to the phone (the app offers Save Image to Photos, Files and so on). function blobBase64(blob) { @@ -1513,6 +1565,7 @@ function setOnline(ok, why) { $("offMsg").textContent = why || "Can't reach the Frame"; setConn(false, "Offline"); $("battChip").hidden = true; closeDevPop(); + $("nowState").textContent = "Waiting for the Frame…"; if (was !== false) { log(why || "Can't reach the Frame", "e"); schedule(); } if (window.connBanner) window.connBanner(); } else if (was === false) { @@ -1615,7 +1668,8 @@ function renderComfort(s) { if (s.id !== comfortSession) { comfortSession = s.id; comfortSeen.clear(); - if (s.options) for (const [key, value] of Object.entries(s.options)) { + // A finished session's settings (a one-minute test, say) mustn't replace the defaults. + if (s.active && s.options) for (const [key, value] of Object.entries(s.options)) { const el = $(key === "minutes" ? "sessionMinutes" : key); if (!el) continue; if (el.type === "checkbox") el.checked = value; else el.value = value; @@ -1641,7 +1695,8 @@ function renderComfort(s) { // Keep only the server's bounded history; a new page seeds it without replay. comfortSeen = new Set((s.events || []).map(e => e.id)); if (latest !== null) $("comfortAnnouncement").textContent = latest; - const history = (s.events || []).slice(-3).map(e => e.message).join(" · "); + const at = t => new Date(t * 1000).toLocaleTimeString([], { hour: "numeric", minute: "2-digit" }); + const history = (s.events || []).slice(-3).map(e => `${at(e.time)} ${e.message}`).join(" · "); if ($("comfortEvents").textContent !== history) $("comfortEvents").textContent = history; } async function pollComfort() { @@ -1672,6 +1727,13 @@ $("testNotification").onclick = () => act("Test notification", async () => { await localNotification("Comfort notifications are enabled on this device.", true); return {message: "Test notification sent. Check your device's notification settings if it didn't appear."}; }); +// Home's shortcuts to the things people come to do next. +document.querySelectorAll("[data-go]").forEach(b => b.onclick = () => { + const to = b.dataset.go; + if (location.hash === "#" + to) showPage(); else location.hash = to; + if (to === "pad") { if (pad.state === "off" || pad.state === "error") padStart(); $("padType").focus({ preventScroll: true }); } +}); +$("nowSend").onclick = () => $("fileInput").click(); $("castBtn").onclick = () => { setView("headset"); if (!live) toggleLive(true); @@ -1752,6 +1814,8 @@ function render(s) { const svc = [["SteamVR", s.services.steamvr], ["Desktop", s.services.desktop], ["Lepton", s.services.lepton], ["RDP", s.services.rdp]]; + $("nowState").textContent = s.services.steamvr ? "In VR: SteamVR is running." : + "SteamVR isn't running, so there's no headset view until it starts."; $("services").innerHTML = svc.map(([n, on]) => `${esc(n)}`).join(""); if (s.volume && !volTimer) setVolume(Math.round(s.volume.level * 100), s.volume.muted); @@ -3739,8 +3803,10 @@ $("macview").onclick = async e => { // ---- pages: #home, #games, #android, #tools (older section links still work) ---- const PAGES = ["home", "games", "android", "tools"]; -const SECTION_PAGE = { view: "home", device: "home", shots: "home", library: "games", sideloaded: "games", getgames: "games", - display: "android", transfer: "tools", apps: "tools", power: "tools", macview: "tools", privacy: "tools", panels: "tools" }; +const SECTION_PAGE = { view: "home", device: "home", shots: "home", comfort: "home", pad: "home", vrTools: "home", + library: "games", sideloaded: "games", getgames: "games", display: "android", + transfer: "tools", apps: "tools", power: "tools", macview: "tools", media: "tools", panels: "tools", + privacy: "settings", artworkSettings: "settings", assistant: "settings" }; let page = "home"; function showPage() { const id = location.hash.slice(1); @@ -3749,6 +3815,7 @@ function showPage() { page = next; document.querySelectorAll(".page").forEach(p => p.classList.toggle("on", p.dataset.page === page)); document.querySelectorAll("nav a").forEach(a => a.classList.toggle("on", a.getAttribute("href") === "#" + page)); + $("settingsBtn").classList.toggle("on", page === "settings"); document.title = page === "home" ? "Frame Control" : `${page[0].toUpperCase() + page.slice(1)} · Frame Control`; const section = !PAGES.includes(id) && id && $(id); if (section) section.scrollIntoView(); else scrollToY(0); @@ -3781,6 +3848,8 @@ function padShow(status) { $("padOn").hidden = ready || busy; $("padOn").textContent = pad.state === "error" ? "Try again" : "Turn on"; $("padArea").classList.toggle("off", !ready); + $("padBody").hidden = pad.state === "off"; + $("padIntro").hidden = pad.state !== "off"; $("padHint").textContent = pad.state === "error" ? status.message || "Couldn't reach KDE Connect on the Frame." : !ready ? (busy ? PAD_STATES[pad.state] : "Turn on to use this as a trackpad for the Frame.") : TOUCH ? "Drag to move the pointer · tap to click · two fingers to scroll · two-finger tap to right-click" @@ -4565,7 +4634,8 @@ function setUpActive() { $("connSetup").onclick = setUpActive; // ---- Devices tab ---- -PAGES.push("devices"); +PAGES.push("devices", "settings"); // Settings has no tab: the gear in the header, or key 6 +$("settingsBtn").onclick = () => { location.hash = "settings"; }; let devicesSeq = 0; // an older answer arriving late mustn't bring back the old selection async function loadDevices() { if (!link.live) return;