GUI: plainer words, readable dim text, quieter empty tabs, one way to connect, portal switches

- Plain words: patch titles lose bracketed internals unless Show technical details is on (C.patch_title); a passed launch test just says so, how far it got shows only when it fails (C.test_line); Frame page readiness says Quest games / PC VR games and storage drops the file system (reason in a tooltip); the failure pop-up says where it stopped, that Resume continues and that saves and signing keys are kept. Engine and VR API stay on the game page; the OpenXR runtime and game launch capacity checks stay; the first start still names its tools.
- Readable dim text: TEXT_3 is #868A96 (Portal, Portal OLED) and #868B9C (Original), at least 4.5:1 on the window, sidebar, cards and raised panels (was 3.6-4.4:1); a test keeps the built-in themes there.
- Quieter empty tabs: the not-connected Files, Screenshots, Live view, Type on Frame and Monitor tabs show one sentence and Connect above a faint sketch of the tab (C.ghost_preview) instead of three feature cards; the Live view shows one Start button and no feature cards.
- The connect page has one way in: "Set up your Frame" up front (no password, nothing changes until you allow it), "Already set up?" below, the address and password behind "Connect by address…"; no 1/2/3 numbers.
- Consistency: every switch is C.switch, with the portal gradient painted under a transparent track (the real Switch keeps keyboard, focus and semantics), a dark thumb with a check when on and a grey one with a cross when off (the same size); where a patch came from is blue information, not orange; the platform filter says "Quest/Android"; the welcome screen says "Set up your Frame".
- Showcase: the live-view shot waits for "Ready when you are"; the install video clicks "Set up your Frame".
This commit is contained in:
spoopyghosty0 committed 2026-10-12 15:46:14 -04:00
1 parent 2c1b901728
commit c477070772
17 files changed
+232 -144

No files matched your search

+1 -1
View File
@@ -93,7 +93,7 @@ shots:
docs: [docs/SHOWCASE.md]
steps:
- go: live
- wait_for: "What the player sees"
- wait_for: "Ready when you are"
- name: install-progress
docs: [docs/SHOWCASE.md]
+1 -1
View File
@@ -62,7 +62,7 @@ scenes:
caption: ["Connect your Steam Frame", "Once: one line on the Frame"]
hold: 1.5
steps:
- click: "Set up the Frame"
- click: "Set up your Frame"
- wait: 1.6
- click: "Start setup"
- wait_for: "Waiting for your Frame to ask"
+18 -39
View File
@@ -124,7 +124,6 @@
"All at their defaults": "",
"All games": "",
"All of FramePort's data on this PC is gone": "",
"All of Steam's headset screenshots, newest first.": "",
"All releases": "",
"All {n} {name} processes ended.": "",
"Allow": "",
@@ -136,7 +135,7 @@
"Allows very large pictures (8K video, theaters) instead of quitting.": "",
"Already patched: don't change the game's files": "",
"Already patched: install unchanged": "",
"Already set up": "",
"Already set up?": "",
"Also delete its saves and everything it stored on the Frame (mods, downloaded content)": "",
"Also delete this game's files on this PC ({size})": "",
"Also in your library: {value} version": "",
@@ -188,7 +187,6 @@
"BlazeRush 1.0.349 only (Klownicle's guide, GitHub #57). Its menus wait for Meta's avatar, which never comes on the Frame, before they create the game's own controllers: the second menu ignored all input. Two instructions in libtargemapp.so skip that wait and the avatar mesh drawing; the game's shader cache gets a new folder name (cache/br_shader_v1), so programs compiled before the GL shim's shader fixes aren't reused. Only this exact build is changed (code fingerprint and every instruction checked); anything else is left as it is.": "",
"BlazeRush: controllers without Meta avatars, own shader cache": "",
"Blit quads flagged XrCompositionLayerImageLayoutFB VERTICAL_FLIP upside down (Vulkan). Fixes UI panels and text that show upside down (for example Assassin's Creed Nexus).": "",
"Browse each installed game's files and download your saves.": "",
"Browse this game's files.": "",
"Build page": "",
"Built for PCs (x86_64): runs through a translator, slower": "",
@@ -196,6 +194,7 @@
"Cable connected.": "",
"Camera": "",
"Can't add {name} — drop an APK, a Linux app, a Windows program (.exe) or a folder": "",
"can't hold games": "",
"can't hold games: {reason}": "",
"Can't reach the Frame. Wake it or turn it on; uploads resume by themselves.": "",
"Can't reach your Frame. Make sure it's on, awake and on the same network, then try again.": "",
@@ -253,6 +252,7 @@
"Command-line arguments for the game program. Games that support several VR runtimes pick one with an argument, for example Unreal's -hmd=OpenXR or -hmd=SteamVR, Unity's -vrmode OpenVR, or a game's own switch (-Runtime=OpenXRHMD). FramePort fills them in from the game files (the repack's VD.bat with the Oculus module swapped for OpenXR, else the engine's usual switch).": "",
"Computes some of the game's effects more precisely, so they don't draw streaks or flashes (for example BlazeRush's particles). Comes from a game's recipe.": "",
"Connect": "",
"Connect by address…": "",
"Connect Frames in Developer Mode automatically": "",
"Connect the Frame's USB-C port to this PC with a data cable (not a charge-only one).": "",
"Connect to your Steam Frame to browse its videos, downloads and each game's storage.": "",
@@ -318,7 +318,6 @@
"CPU": "",
"CPU cores": "",
"CPU {cpu} · GPU {gpu} · memory {mem} · {n} processes": "",
"CPU, GPU, memory, temperatures, power and battery.": "",
"Create": "",
"Customize": "",
"Cylinder layers (curved menus and movie screens, for example 4XVR), which the Frame's runtime lacks, are shown as a few flat strips along the curve. 0 = drop them. 360° (equirect) layers can't be shown on the Frame.": "",
@@ -355,7 +354,6 @@
"Developer Mode lets FramePort find and connect to the Frame; the setup turns it on for you. With it on, you can also open Settings → Developer → Pair new host on the Frame and approve FramePort there.": "",
"Diagnostics for frame.gl_multiview_fbo: checks for GL errors after every single-view draw of a multiview shader and logs counters every 5 seconds (tag GLMV in launch.log).": "",
"Diagnostics: the Vulkan shim (frame.vk_sanitize) adds Khronos' validation layer to the game's instance; its findings go to launch.log. The layer library (libVkLayer_khronos_validation.so) must be in the APK.": "",
"Didn't finish ({value}): {error}": "",
"Disable application space warp if used": "",
"Disable controller tracking offset": "",
"Disable Meta XR Audio telemetry": "",
@@ -371,7 +369,6 @@
"Download all ({n})…": "",
"Download all…": "",
"Download and install": "",
"Download one, a selection or all of them.": "",
"Download selected…": "",
"Download to this PC…": "",
"Download to which folder on this PC?": "",
@@ -406,7 +403,6 @@
"Drop to upload": "",
"Drops Quest-only shared library requirements.": "",
"Each game start used to use up one kernel key; after about 200 starts no game started. FramePort stops that, but used keys only come back after a restart of the Frame.": "",
"Each shot is labeled with the game you were playing.": "",
"Enabled by you.": "",
"End all": "",
"End all {n} {name}?": "",
@@ -418,7 +414,6 @@
"End {title}?": "",
"Ending this stops Steam, SteamVR or the desktop.": "",
"End…": "",
"Enter the address": "",
"Enter the Frame's address (for example steamos@frame.local or its IP)": "",
"Environment variables exported by the launcher (for example VK_INSTANCE_LAYERS=\"\" to test without Valve's layers).": "",
"Environment variables for the Proton launcher on the Frame (for example DXVK_HUD=fps), one KEY=value per line.": "",
@@ -444,14 +439,12 @@
"Eye-tracked foveation (Valve)": "",
"failed": "",
"Failed": "",
"Failed: {error}": "",
"Fake XR_FB_scene/spatial entities: a guardian-sized room with floor, ceiling and four walls, for mixed-reality games that build their level from the room (for example Demeter).": "",
"Fan": "",
"file": "",
"Files": "",
"Files and settings on the Frame": "",
"Files and settings placed next to the game on the Frame.": "",
"Files and whole folders": "",
"Files on the Frame — {title}": "",
"Filled tags are your own. Outlined tags come from the engine, VR API and store genres. Filter the library by either.": "",
"Filter files (for example .pak, Binaries)": "",
@@ -460,7 +453,6 @@
"Find artwork…": "",
"Find automatically": "",
"firewalld is active. Allow the setup port until the next restart with: sudo firewall-cmd --add-port={port}/tcp": "",
"First-time setup": "",
"Fix crash when the game prepares the microphone": "",
"Fix depth flicker (Unreal Vulkan)": "",
"Fix freezes when controllers vibrate": "",
@@ -525,7 +517,6 @@
"Frame battery: not charging": "",
"Frame not connected": "",
"Frame OpenXR compatibility layer": "",
"Frame rate": "",
"FrameBridge OpenXR adapter": "",
"FramePort": "",
"FramePort can't update itself in {value} (no permission) — opening the release page to download it": "",
@@ -562,8 +553,8 @@
"FramePort's original violet": "",
"FramePort's own name for it": "",
"FramePort's tools, library, artwork and your games' signing keys. Back it up: an update needs the same key, or the game must be reinstalled and loses its saves.": "",
"Frames in Developer Mode show up here.": "",
"frames per second": "",
"Frames that are set up show up here by themselves.": "",
"Free up space": "",
"Free up space on the Frame": "",
"Free up space?": "",
@@ -589,7 +580,6 @@
"Game settings…": "",
"game started": "",
"Game storage": "",
"Game storage and saves": "",
"game window opened": "",
"Game's choice": "",
"Game: the running game's processes plus the busiest others. Steam and SteamVR: Steam, SteamVR and the desktop. All: every program. A lock marks programs that would close Steam or SteamVR.": "",
@@ -662,7 +652,6 @@
"Icon": "",
"If the pointer doesn't hit what you aim at, tilt it up or down.": "",
"If Windows asks whether FramePort may use the network, allow it.": "",
"If you know the Frame's address.": "",
"Ignore focus dips up to": "",
"ILMxLAB's Unreal games (for example Vader Immortal) bind their controls through URPOCKeyMapManagerComponent, which for an Oculus headset picks the Quest (\"SantaCruz\") or the Gear VR key set by IsRunningOnSantaCruz. On the Frame it picks Gear VR, which is empty in a Quest build: grip, trigger and touch reach Unreal but never the game's hands (grabbing does nothing). Removes the branch to the Gear VR set in the Oculus case of the key selector both action and axis mappings use. Found by Klownicle (GitHub #49).": "",
"ILMxLAB's Unreal games (for example Vader Immortal) wait on their loading card until UVRUtils::GetQuestShaderPrecompilePercent reaches 100 %. The precompile only starts on a Quest (IsRunningOnSantaCruz); on any other device the function returns 0.0, so the loading card (a portrait with a progress bar) never ends. Makes that non-Quest branch return 1.0. The Quest branch and the game's normal shader compiling stay as they are. Found by Klownicle (GitHub #49).": "",
@@ -726,6 +715,7 @@
"It has issues: share…": "",
"It needs Android {version}; the Frame has Android 11. Such apps often crash at start, but not always: a launch test tells.": "",
"it stopped after: {value}": "",
"It stopped at “{value}”. {error} Resume picks up where it stopped.": "",
"it stopped right at the start": "",
"It was removed from the library.": "",
"It will be closed without saving. Continue?": "",
@@ -760,7 +750,8 @@
"Language packs from the game's files": "",
"Last build": "",
"Last launch test's log": "",
"Last launch test: {get} · furthest step: {value}": "",
"Last launch test: passed": "",
"Last launch test: {get} · got as far as: {value}": "",
"Last session · ended {ago}": "",
"late": "",
"Launch log": "",
@@ -807,7 +798,6 @@
"Lets you type into this app's text fields on the Frame (they'd close at once).": "",
"Letters, Enter, Backspace, arrows, Esc and shortcuts reach the headset.": "",
"Library": "",
"Light on the game": "",
"Link": "",
"Linux": "",
"Linux app": "",
@@ -816,7 +806,6 @@
"Linux VR app · OpenXR": "",
"Live": "",
"Live view": "",
"Load and temperatures": "",
"Loading…": "",
"Loads FramePort's OpenXR layer under Proton on the Frame. The Frame's SteamVR runtime only accepts OpenXR 1.0 apps, but Proton's VR helper asks for 1.1, so without the layer VR never starts (the game shows as a flat window, or Revive fails with 'Unable to load LibOVRRT DLL'); the layer retries as 1.0. It also emulates xrConvertTimespecTimeToTimeKHR if a runtime refuses it. Ignored on this PC.": "",
"Loads FramePort's shader-fix Vulkan layer into an OpenGL ES game. On the Frame, OpenGL ES runs on Zink (Mesa's GL on Vulkan); the layer sits between Zink and the driver and inserts the words of the adapter setting zink_shader_fix into the SPIR-V modules whose size and SHA-256 match (every other module is untouched), for example stores that set loop counters and accumulators a shader reads before writing, which hang the GPU (Vader Immortal's lightspeed jump; found by Klownicle, GitHub #49). Does nothing without zink_shader_fix (or zink_shader_dump) in the recipe. The layer adds itself to Android's Vulkan debug layer list for the game's process only.": "",
@@ -857,7 +846,6 @@
"Mark application to allow backup": "",
"Marked \"Can't run\": ": "",
"Marks the game as a VR app so headsets show it as one.": "",
"Matched to your games": "",
"Maximum": "",
"Maximum size in pixels of each face of the cube the 360° image is converted to (256–2730). Higher is sharper but uses more GPU memory.": "",
"Memory": "",
@@ -899,7 +887,6 @@
"never": "",
"New folder": "",
"New folder…": "",
"New Frame? Run the setup on it and click Allow here. Already set up? Your Frame shows under step 2.": "",
"New games go to {label}": "",
"New name": "",
"New recipe applied. Click Update on Frame to install it.": "",
@@ -913,7 +900,7 @@
"No folders to rescan yet — add games with Scan a folder…": "",
"No Frame connected: the game goes to your library now and installs when the Frame connects.": "",
"No FramePort game is running. Start one, or switch the filter to see everything.": "",
"No Frames found. New Frame? Use step 1. Otherwise turn on Developer Mode and keep the Frame on this network.": "",
"No Frames found. New Frame? Use Start setup above. Otherwise turn on Developer Mode and keep the Frame on this network.": "",
"No games found in that folder": "",
"No keyboard? Open {page} in Chromium on the Frame, copy the line and paste it into Konsole. This PC shows there as \"{words}\".": "",
"No limit": "",
@@ -1007,7 +994,7 @@
"OpenXR runtime": "",
"or": "",
"Or paste text to type it in one go": "",
"Or turn on Developer Mode on the Frame (Settings → System) and connect it under step 2.": "",
"Or turn on Developer Mode on the Frame (Settings → System) and connect it under \"Already set up?\".": "",
"Or use:": "",
"Orange for actions, blue for the PC side: the logo's two portals": "",
"Other": "",
@@ -1116,6 +1103,7 @@
"Quest ✓": "",
"Quest ✓: Lepton is installed, so Quest games can run. PC VR ✓: Proton is installed, so PC VR games can run on the Frame.": "",
"Quest ✗": "",
"Quest/Android": "",
"Queued": "",
"Queued {len} installs — they run one at a time": "",
"Quiet Meta's VrApi loader (OpenXR games)": "",
@@ -1231,6 +1219,7 @@
"Rotate panels 180°": "",
"Routes OVRPlugin through FramePort's extension shim, which turns Meta's amplitude-envelope vibrations into plain ones: OVRPort's loader reads their nanosecond duration as seconds and allocates gigabytes, freezing the Frame when the game vibrates a controller (for example Lucky's Tale's save slots, GitHub #9). Needs a rebuild.": "",
"Run a launch test after installing on the Frame": "",
"Run one line on the Frame, then click Allow here. You do this once. No password, and nothing changes on the Frame until you allow it.": "",
"Running": "",
"Running a Windows program under Proton": "",
"running for {t}": "",
@@ -1244,7 +1233,6 @@
"Save": "",
"Save as known-good recipe": "",
"Save the screenshots to which folder on this PC?": "",
"Save them to this PC": "",
"Saved as a known-good recipe": "",
"Saved as known-good. Check the GitHub issue that opened and submit it.": "",
"Saved {name} (in {parent})": "",
@@ -1280,8 +1268,8 @@
"Select games to install": "",
"Set by you.": "",
"Set up for FramePort": "",
"Set up the Frame": "",
"Set up with a USB cable": "",
"Set up your Frame": "",
"Sets Unity QualitySettings antiAliasing to 0. Unity's multisampled render-to-texture path can hang the Frame's GL driver ('zink: DEVICE LOST', for example Sniper Elite VR). Try it for GLES Unity games that freeze or crash the GPU.": "",
"Settings": "",
"Settings files for the game": "",
@@ -1349,7 +1337,6 @@
"Something not working? Attach a diagnostics zip to a GitHub issue; personal data is removed. For one game, use its menu instead.": "",
"Something went wrong": "",
"Sort": "",
"Sorted by day": "",
"Sound off": "",
"Sound on": "",
"Source": "",
@@ -1482,21 +1469,18 @@
"The Frame's battery is at {pct}% and not charging. Plug it into a strong charger — installs pause at {pause}%.": "",
"The Frame's battery is at {pct}%, so installs are paused. Plug it in and they continue.": "",
"The Frame's desktop password, needed only once so FramePort can add its key.": "",
"The Frame's own video encoder does the work where it can.": "",
"The Frame's runtime has no cube-map layers and refuses their swapchains; OVRPlugin then crashes when it submits the next frame (for example Budget Cuts Ultimate). FrameBridge serves such a swapchain itself (a GL cube map, GLES games) and drops its layers, so the game runs on without that layer.": "",
"The Frame's Steam couldn't start {title} (error {code}). Please report it.": "",
"The Frame's Steam didn't accept {title}, so it was added as \"Devkit Game: …\". Starting it now — put the headset on.": "",
"The Frame's Steam library still shows the old artwork.": "",
"The Frame's Steam library still shows the old name.": "",
"the game failed its checks (see the list above)": "",
"The game's audio streams along with the picture.": "",
"The headset is asleep: put it on, then take the screenshot.": "",
"The headset's view and sound open in an mpv window. Open it in your browser any time.": "",
"The headset's view and sound open in your web browser. Stop the stream when you're done.": "",
"The headset's view and sound play right here. Open it in your browser any time.": "",
"The headset's view is open in an mpv window. You can also watch it in your browser.": "",
"The headset's view is open in your browser. Closed the tab? Open it again.": "",
"The headset's view on this PC, for watching along or recording.": "",
"the installed game (APK and its data)": "",
"The issue's text is also on your clipboard. If the form's fields are empty, open a plain issue instead: it comes filled in.": "",
"The launch settings changed since the install — update the Steam shortcut.": "",
@@ -1510,7 +1494,6 @@
"the previous version, kept as a backup": "",
"The processor types the game has code for. The Frame runs only 64-bit ARM (arm64-v8a); 32-bit-only games can't run on it.": "",
"The recipe or your game files changed since the install. Update to install the new build; saves are kept.": "",
"The running game's frames per second, as it plays.": "",
"The setup turns on Developer Mode, lets FramePort in and installs Lepton if needed. The Frame's desktop closes during it, then FramePort connects by itself. No password needed.": "",
"The store's name": "",
"The time the Frame spends collecting these numbers.": "",
@@ -1531,7 +1514,7 @@
"This deletes the {what} on the Frame. It can't be undone.": "",
"This folder is empty. Upload files with the buttons above.": "",
"This Frame isn't set up for FramePort yet. Go to Steam Frame → Start setup.": "",
"This Frame isn't set up for FramePort yet. Run the setup once (step 1).": "",
"This Frame isn't set up for FramePort yet. Run the setup once.": "",
"This game can't run on the Steam Frame.": "",
"This game is marked \"Can't run\" on the Steam Frame:": "",
"This is a website in an app: it opens in Meta's browser, which the Frame doesn't have. If nothing opens, visit the website in a browser.": "",
@@ -1645,8 +1628,6 @@
"Upload": "",
"Upload files…": "",
"Upload folder…": "",
"Upload from this PC, download, rename and tidy up.": "",
"Upload movies straight into a player's own folder.": "",
"Upload to {label}": "",
"Upload videos into this player's storage on the Frame.": "",
"Upload, download, rename and delete files on the Frame. Videos, Downloads and Documents are shared by every Quest game; each game also has its own storage.": "",
@@ -1679,7 +1660,6 @@
"Video panels that an Android media player draws into (XR_KHR_android_surface_swapchain, for example I Am Monkey's intro video), which the Frame's runtime refuses, get an Android Surface from FrameBridge: each video frame is copied into an ordinary swapchain the panel shows. Without it these panels are missing and a game waiting for its video stays black. 0 = leave them to the runtime.": "",
"Video players' virtual theaters and 360° videos would otherwise stay black.": "",
"Videos": "",
"Videos for your players": "",
"Videos, documents, mods and saves on your Steam Frame": "",
"View": "",
"View screenshot": "",
@@ -1720,13 +1700,11 @@
"What changed in this version and the ones before it.": "",
"What FramePort will do": "",
"What happens? (optional)": "",
"What the player sees": "",
"What the whole Frame draws right now, with the processor's and graphics chip's share. More power means more heat and shorter battery life.": "",
"What your Frame is doing right now": "",
"What your Frame is ready for and what's installed": "",
"What's new in FramePort {version}": "",
"What's new…": "",
"What's running, and a way to end what's stuck.": "",
"Where it's installed": "",
"Where new games go on the Frame, such as a microSD card. Installed games stay put; right-click → Move to… moves one. Cards formatted as FAT, exFAT or NTFS can't hold games.": "",
"Where the power goes": "",
@@ -1745,7 +1723,6 @@
"Windows' firewall blocks the Frame from reaching FramePort. Start the setup again and allow the change when Windows asks.": "",
"Windows' firewall blocks the Frame on public networks. Set this network to Private in Windows' network settings.": "",
"Windows' firewall for WSL blocks the Frame. Show the setup command again and allow the change when Windows asks.": "",
"With sound": "",
"Without a Meta account, OVRPort's platform library makes up a new user id at every start (ovr_GetLoggedInUserID). Games that file saves under the user id (for example Walkabout Mini Golf) then start with an empty profile each time and seem to lose all progress. Gives the game a fixed id instead, different for each game and each FramePort user.": "",
"Working": "",
"Working…": "",
@@ -1780,6 +1757,7 @@
"Your Frame's files, from this PC": "",
"Your own artwork for {title}": "",
"Your own game files aren't touched.": "",
"Your saves on the Frame and each game's signing key are kept.": "",
"{app} opens these links on this PC.": "",
"{count} files · {human}": "",
"{count} on": "",
@@ -1833,7 +1811,7 @@
"",
""
],
"{n} game didn't work out": [
"{n} game ran into a problem": [
"",
""
],
@@ -1895,17 +1873,18 @@
"{title}: checks failed": "",
"{title}: details from {value}": "",
"{title}: done": "",
"{title}: launch test {verdict} · furthest step: {value}": "",
"{title}: name updated in Steam": "",
"{title}: ready": "",
"{title}: Steam artwork updated on the Frame": "",
"{title}: {line}": "",
"{used} of {total}": "",
"{used}/{max} kernel keys used": "",
"{user}@{host} · {get} {get2} (build {get3})": "",
"{value:.0f} MiB": "",
"{value:.1f} GiB": "",
"{value} didn't work out": "",
"{value} didn't finish installing": "",
"{value} failed ({returncode})": "",
"{value} installed, but didn't start": "",
"{value} took too long — update by hand: {join}": "",
"• On the Frame (if selected below): {n} game ({size}), its Steam entry and FramePort's files": [
"",
+13 -12
View File
@@ -1387,9 +1387,10 @@ class FramePortApp:
s = job.summary or {}
fatal = [f for f in s.get("findings", []) if f.get("severity") == "fatal"] or s.get("findings", [])
if job.state == "failed" and pkg in recs:
why = tr("Didn't finish ({value}): {error}").format(value=job.stage or 'install', error=job.error)
why = tr("It stopped at \u201c{value}\u201d. {error} Resume picks up where it stopped.").format(
value=(job.stage or tr("Install")).rstrip("…. "), error=job.error)
elif job.state == "failed":
why = tr("Failed: {error}").format(error=job.error)
why = job.error
else:
why = tr("Installed, but it didn't start: ") + (
fatal[0]["diagnosis"] if fatal
@@ -1416,10 +1417,14 @@ class FramePortApp:
ft.Row(buttons, spacing=T.S2, wrap=True),
], spacing=T.S2)))
n = len(jobs)
failed = [j for j in jobs if j.state == "failed"]
if failed: # what a failed install never touches, said where the user worries about it
rows.append(C.meta(tr("Your saves on the Frame and each game's signing key are kept.")))
one = self._title(jobs[0].package) if jobs[0].package else jobs[0].title
self.page.show_dialog(C.dialog(
tr_n("{n} game didn't work out", "{n} games didn't work out", n) if n > 1 else
tr("{value} didn't work out").format(
value=self._title(jobs[0].package) if jobs[0].package else jobs[0].title),
tr_n("{n} game ran into a problem", "{n} games ran into problems", n) if n > 1 else
tr("{value} didn't finish installing").format(value=one) if failed else
tr("{value} installed, but didn't start").format(value=one),
ft.Column(rows, spacing=T.S3, scroll=ft.ScrollMode.AUTO, tight=True),
height=T.px(min(160 * n + 20, 520)),
actions=[C.ghost(tr("Details"), on_click=lambda e: (self.page.pop_dialog(), self.show_activity(True))),
@@ -1588,9 +1593,8 @@ class FramePortApp:
res, _ = target.launch_test(pkg, job.reporter)
summary = {"verdict": res.verdict, "milestone": res.milestone, "suggestions": []}
job.summary, job.to, job.log_path = summary, to, summary.get("log_path")
verdict, step = C.test_result(summary["verdict"], summary.get("milestone"))
return tr("{title}: launch test {verdict} · furthest step: {value}").format(
title=title, verdict=verdict, value=step)
line = C.test_line(summary["verdict"], summary.get("milestone"))
return tr("{title}: {line}").format(title=title, line=line)
return self.submit(tr("Launch test: {title}").format(title=title), run, pkg, "test", to=to)
def update_steam_art(self, pkg: str) -> Job:
@@ -1936,10 +1940,7 @@ class FramePortApp:
ft.Column([
C.body(tr("Opens a GitHub issue with this recipe so others get it too. No game files or personal "
"data are sent."), T.TEXT_2),
*([C.body(tr("Last launch test: {get} · furthest step: {value}").format(
get=C.test_result(last.get("verdict"), last.get("milestone"))[0],
value=C.test_result(last.get("verdict"), last.get("milestone"))[1]),
T.TEXT_3)] if last else []),
*([C.body(C.test_line(last.get("verdict"), last.get("milestone")), T.TEXT_3)] if last else []),
ft.Row([status, C.help_icon("share_config")]), notes, played_row,
C.body(tr("Launch tests can't check the picture or controls — only you can."),
T.TEXT_3),
+133 -14
View File
@@ -1,6 +1,7 @@
"""Reusable GUI building blocks (all styling comes from ui.theme)."""
from __future__ import annotations
import re
from collections.abc import Callable
from types import SimpleNamespace
from typing import NamedTuple
@@ -77,6 +78,32 @@ def test_result(verdict: str | None, milestone: str | None) -> tuple[str, str]:
return result, steps.get(milestone or "", milestone or tr("nothing yet"))
def test_line(verdict: str | None, milestone: str | None) -> str:
"""The last launch test in one line: how far it got only when it didn't pass (a passed test reached the end)."""
result, step = test_result(verdict, milestone)
if verdict == "pass":
return tr("Last launch test: passed")
return tr("Last launch test: {get} · got as far as: {value}").format(get=result, value=step)
def technical() -> bool:
"""The "Show technical details" switch (game page → Customize; remembered for every screen): engine and VR API
names, runtime names, kernel keys, file systems and the internals in patch titles show only with it on."""
from ..core import library
return bool(library.setting("ui.patch_details", False))
# a bracket that holds internals: an arrow, an underscore, camelCase or an all-caps code ("(INFO → LAUNCHER)",
# "(ovrMessageType_ToString)", "(OVRManager)"); plain ones stay ("(Frame)", "(skip the 2D launcher)")
_TECH_BRACKET = re.compile(r"\s*\((?=[^)]*(?:→|_|[a-z][A-Z]|[A-Z]{4,}))[^)]*\)")
def patch_title(title: str, details: bool | None = None) -> str:
"""A patch's title as shown: without bracketed internals unless technical details are on."""
title = tr(title)
return title if (technical() if details is None else details) else _TECH_BRACKET.sub("", title).strip()
def settings_diff(game: dict, frame_info: dict | None) -> tuple[list[str], list[str]] | None:
"""(patches turned on, patches turned off) in the app since the Frame's copy was installed; None if the same (or
unknown: installs from before FramePort recorded their recipe)."""
@@ -230,27 +257,58 @@ def checkbox(label: str, **kw) -> tuple[ft.Checkbox, ft.Control]:
spacing=T.S1, vertical_alignment=ft.CrossAxisAlignment.CENTER)
SWITCH_TRACK = (52, 32) # Material 3's switch track in logical px (the Switch itself doesn't follow the UI scale)
def paint_switch_track(track: ft.Container, on: bool, disabled: bool = False) -> None:
"""On: the portal (blue into orange, as in the logo) under a dark thumb; off: a quiet well with an edge."""
if on:
track.gradient = portal_gradient(opacity=0.95) if T.DUAL else None
track.bgcolor = None if T.DUAL else T.ACCENT
track.border = None
else:
track.gradient, track.bgcolor = None, T.SURFACE_3
track.border = ft.Border.all(2, T.BORDER_STRONG)
track.opacity = 0.45 if disabled else 1.0
def switch(label: str = "", wrap: bool = True, **kw) -> ft.Control:
"""A labelled switch in the theme's text colour (Material's default label is dark text). The label wraps (a
Switch's own label is cut off when it's longer than the window); clicking it toggles the switch too.
wrap=False keeps the built-in label, for a short label in a Row next to other controls."""
kw.setdefault("active_color", T.ACCENT)
if T.DUAL: # orange thumb on a blue track
kw.setdefault("active_track_color", T.soft(T.SECONDARY, 0.5))
if not wrap or not label:
return ft.Switch(label=label or None, label_text_style=ft.TextStyle(color=T.TEXT, size=T.px(14)), **kw)
sw = ft.Switch(**kw)
wrap=False keeps the label on one line, for a short label in a Row next to other controls.
The track carries the portal gradient when on: the real ft.Switch (keyboard, focus, semantics) sits with a
transparent track on a painted one (paint_switch_track), repainted on every change."""
user_change = kw.pop("on_change", None)
clear = {ft.ControlState.DEFAULT: ft.Colors.TRANSPARENT}
kw.setdefault("track_color", clear)
kw.setdefault("track_outline_color", clear)
# the thumb is the portal's dark middle on the gradient (a light check on it), light grey with a dark cross when
# off; an icon in both states keeps the thumb the same size on and off (Material 3 shrinks an empty off thumb)
kw.setdefault("thumb_color", {ft.ControlState.SELECTED: T.BG, ft.ControlState.DEFAULT: T.TEXT_2})
kw.setdefault("thumb_icon", {ft.ControlState.SELECTED: ft.Icons.CHECK_ROUNDED,
ft.ControlState.DEFAULT: ft.Icons.CLOSE_ROUNDED})
track = ft.Container(width=SWITCH_TRACK[0], height=SWITCH_TRACK[1], border_radius=SWITCH_TRACK[1] / 2)
def changed(e):
paint_switch_track(track, bool(sw.value), bool(sw.disabled))
update(track)
if user_change:
user_change(e)
sw = ft.Switch(on_change=changed, **kw)
paint_switch_track(track, bool(sw.value), bool(sw.disabled))
knob = ft.Stack([track, sw], alignment=ft.Alignment.CENTER)
if not label:
return knob
def click_label(e):
if sw.disabled:
return
sw.value = not sw.value
update(sw)
if sw.on_change:
sw.on_change(SimpleNamespace(control=sw))
return ft.Row([sw, ft.Container(ft.Text(label, color=T.TEXT, size=T.px(14)), expand=True, on_click=click_label,
ink=True, border_radius=T.RADIUS_XS)],
spacing=T.S2, vertical_alignment=ft.CrossAxisAlignment.CENTER, data="switch")
changed(SimpleNamespace(control=sw))
text = ft.Text(label, color=T.TEXT, size=T.px(14), no_wrap=not wrap)
return ft.Row([knob, ft.Container(text, expand=wrap, on_click=click_label, ink=True, border_radius=T.RADIUS_XS)],
spacing=T.S2, vertical_alignment=ft.CrossAxisAlignment.CENTER, data="switch", tight=not wrap)
def body(text: str, color: str | None = None, size: int | None = None, **kw) -> ft.Text:
@@ -673,11 +731,72 @@ def portal_glow() -> ft.RadialGradient:
return ft.RadialGradient(colors=colors, stops=stops, center=ft.Alignment(0, -0.25), radius=0.85)
def _ghost_bar(width: float, height: float = 10, color: str | None = None) -> ft.Container:
return ft.Container(width=T.px(width), height=T.px(height), border_radius=T.px(4),
bgcolor=color or T.BORDER_STRONG)
def ghost_preview(kind: str) -> ft.Control:
"""A faint, wordless sketch of what a Frame tab shows once a Frame is connected (its not-connected empty state):
shapes only, so it reads as "this is where it goes", never as content. kinds: files, screenshots, live,
keyboard, monitor."""
if kind == "files":
rows = [ft.Row([as_icon(ft.Icons.FOLDER_OUTLINED if i < 2 else ft.Icons.MOVIE_OUTLINED, T.px(18),
T.BORDER_STRONG),
_ghost_bar(w), ft.Container(expand=True), _ghost_bar(56), _ghost_bar(18, 18)],
spacing=T.S3)
for i, w in enumerate((140, 96, 210, 172, 120))]
return card(ft.Column(rows, spacing=T.S4), bgcolor=T.SURFACE)
if kind == "screenshots":
return ft.Row([ft.Container(width=T.px(240), height=T.px(135), border_radius=T.RADIUS_SM, bgcolor=T.SURFACE_2,
border=ft.Border.all(1, T.BORDER)) for _ in range(6)],
wrap=True, spacing=T.S3, run_spacing=T.S3, alignment=ft.MainAxisAlignment.CENTER)
if kind == "live":
return ft.Container(as_icon(G.LIVE, T.px(40), T.BORDER_STRONG), width=T.px(640), height=T.px(360),
alignment=ft.Alignment.CENTER, border_radius=T.RADIUS, bgcolor=T.BG,
border=ft.Border.all(1, T.BORDER_STRONG))
if kind == "keyboard":
keys = ft.Row([ft.Container(width=T.px(w), height=T.px(34), border_radius=T.px(6), bgcolor=T.SURFACE_2,
border=ft.Border.all(1, T.BORDER)) for w in (34, 34, 34, 34, 34, 34, 34, 120)],
spacing=T.S2, alignment=ft.MainAxisAlignment.CENTER)
return ft.Column([card(ft.Column([_ghost_bar(220), _ghost_bar(160)], spacing=T.S3), bgcolor=T.SURFACE,
height=T.px(130), width=T.px(640)), keys],
spacing=T.S4, horizontal_alignment=ft.CrossAxisAlignment.CENTER)
# monitor: tiles with a little graph, then a few process rows
spark = [5, 9, 7, 12, 10, 14, 11, 16, 13, 15, 12, 17]
tile = card(ft.Column([_ghost_bar(70), _ghost_bar(44, 18),
ft.Row([ft.Container(width=T.px(5), height=T.px(h), border_radius=T.px(2),
bgcolor=T.BORDER_STRONG) for h in spark],
spacing=T.px(3), vertical_alignment=ft.CrossAxisAlignment.END)],
spacing=T.S2), bgcolor=T.SURFACE, width=T.px(180))
rows = [ft.Row([_ghost_bar(w), ft.Container(expand=True), _ghost_bar(40), _ghost_bar(40)], spacing=T.S4)
for w in (180, 130, 220, 150)]
return ft.Column([ft.Row([tile] * 4, spacing=T.S3, wrap=True, alignment=ft.MainAxisAlignment.CENTER),
card(ft.Column(rows, spacing=T.S4), bgcolor=T.SURFACE)], spacing=T.S3)
def empty_state(icon: str, heading: str, text: str, *actions: ft.Control,
features: list[tuple[str, str, str]] | None = None) -> ft.Container:
features: list[tuple[str, str, str]] | None = None, preview: ft.Control | None = None) -> ft.Container:
"""A screen with nothing to show yet: the portal with what the screen is about coming out of it, a heading, one
sentence and the action that fills it. `features` (icon, heading, sentence) say what the screen will show, so a
first look isn't an empty page; with them the portal is bigger and a soft portal light fills the background."""
first look isn't an empty page; with them the portal is bigger and a soft portal light fills the background.
`preview` (C.ghost_preview) is the quieter form for tabs waiting on the Frame: a faint sketch of the tab under
the action, fading out, instead of the feature cards."""
if preview is not None:
sketch = ft.Stack([ft.Container(preview, opacity=0.8),
ft.Container(left=0, right=0, bottom=0, height=T.px(140),
gradient=ft.LinearGradient(begin=ft.Alignment.TOP_CENTER,
end=ft.Alignment.BOTTOM_CENTER,
colors=[T.soft(T.BG, 0.0), T.BG]))])
return ft.Container(ft.Column([
portal_scene(icon),
title(heading, T.T_DISPLAY),
ft.Container(body(text, text_align=ft.TextAlign.CENTER), width=T.px(500)),
ft.Row(list(actions), alignment=ft.MainAxisAlignment.CENTER, spacing=T.S3),
ft.Container(height=T.S4),
ft.Container(sketch, width=T.px(780)),
], horizontal_alignment=ft.CrossAxisAlignment.CENTER, spacing=T.S3, scroll=ft.ScrollMode.AUTO, expand=True),
alignment=ft.Alignment.TOP_CENTER, expand=True, padding=ft.Padding(0, T.S6, 0, 0))
rich = bool(features)
return ft.Container(ft.Column([
portal_scene(icon, T.px(104) if rich else None),
+3 -3
View File
@@ -16,7 +16,7 @@ import flet as ft
# Colour tokens of a theme:
# surfaces: BG window, SIDEBAR, SURFACE cards, SURFACE_2 raised/hover, SURFACE_3 inputs/chips, BORDER(_STRONG)
# text: TEXT, TEXT_2 secondary, TEXT_3 meta/disabled
# text: TEXT, TEXT_2 secondary, TEXT_3 meta/disabled (built-ins keep all three at >= 4.5:1 on BG..SURFACE_2)
# ACCENT (+ ACCENT_SOFT tint, ON_ACCENT text on it): the main action and the current selection
# SECONDARY (+ SECONDARY_SOFT): secondary actions, progress, switch tracks (with "dual" also the two-colour touches)
# states OK/WARN/ERROR/INFO, PC = PC VR / "on this PC"
@@ -29,7 +29,7 @@ TOKENS = ("BG", "SIDEBAR", "SURFACE", "SURFACE_2", "SURFACE_3", "BORDER", "BORDE
_PORTAL = {
"BG": "#0D0E12", "SIDEBAR": "#111217", "SURFACE": "#16181E", "SURFACE_2": "#1D1F27", "SURFACE_3": "#252832",
"BORDER": "#2A2D37", "BORDER_STRONG": "#393D49",
"TEXT": "#EDEEF2", "TEXT_2": "#A7ABB7", "TEXT_3": "#717583",
"TEXT": "#EDEEF2", "TEXT_2": "#A7ABB7", "TEXT_3": "#868A96",
"ACCENT": "#FF8A1F", "ACCENT_SOFT": "#3A2512", "ON_ACCENT": "#160C03",
"SECONDARY": "#3AA8FF", "SECONDARY_SOFT": "#0F2A40",
# INFO is a cyan of its own: next to PC (the portal blue) it tells data series apart (Monitor: Steam vs
@@ -45,7 +45,7 @@ BUILTIN: dict[str, dict] = {
"original": {"name": "Original", "dual": False, "colors": { # FramePort's colours before the Portal theme
"BG": "#0E0F13", "SIDEBAR": "#121319", "SURFACE": "#171920", "SURFACE_2": "#1E2029", "SURFACE_3": "#262935",
"BORDER": "#2C2F3B", "BORDER_STRONG": "#3A3E4D",
"TEXT": "#ECEDF3", "TEXT_2": "#A9ADBD", "TEXT_3": "#737889",
"TEXT": "#ECEDF3", "TEXT_2": "#A9ADBD", "TEXT_3": "#868B9C",
"ACCENT": "#8B7CFF", "ACCENT_SOFT": "#2A2550", "ON_ACCENT": "#0E0F13",
"SECONDARY": "#38BDF8", "SECONDARY_SOFT": "#12303F",
"OK": "#4ADE80", "WARN": "#FBBF24", "ERROR": "#F87171", "INFO": "#60A5FA", "PC": "#38BDF8"}},
+1 -2
View File
@@ -112,8 +112,7 @@ def _open(app: FramePortApp, package: str, g: dict | None, values: dict) -> None
kind, *spec = UI[key]["control"]
v = values[key]
if kind == "switch":
return ft.Switch(value=bool(v), active_color=T.ACCENT,
on_change=lambda e: set_value(key, 1 if e.control.value else 0))
return C.switch(value=bool(v), on_change=lambda e: set_value(key, 1 if e.control.value else 0))
if kind == "slider":
lo, hi, step, fmt = spec
readouts[key] = C.body(fmt.format(v), T.TEXT, weight=ft.FontWeight.W_600)
+1 -8
View File
@@ -131,14 +131,7 @@ class FilesView:
tr("Connect to your Steam Frame to browse its videos, downloads and each game's "
"storage."),
C.primary(tr("Connect"), ft.Icons.LINK_ROUNDED, lambda e: app.go("frame")),
features=[
(ft.Icons.VIDEO_LIBRARY_OUTLINED, tr("Videos for your players"),
tr("Upload movies straight into a player's own folder.")),
(ft.Icons.SAVE_ROUNDED, tr("Game storage and saves"),
tr("Browse each installed game's files and download your saves.")),
(ft.Icons.DRIVE_FOLDER_UPLOAD_OUTLINED, tr("Files and whole folders"),
tr("Upload from this PC, download, rename and tidy up.")),
])], expand=True)
preview=C.ghost_preview("files"))], expand=True)
if self.root is None:
self.root = ft.Column([
app.top_bar(tr("Files"), tr("Videos, documents, mods and saves on your Steam Frame")),
+36 -24
View File
@@ -89,7 +89,8 @@ class FrameView:
app = self.app
items = []
lepton = info.get("lepton")
items.append(C.Check(bool(lepton), tr("Quest games (Lepton)"),
details = C.technical()
items.append(C.Check(bool(lepton), tr("Quest games (Lepton)") if details else tr("Quest games"),
tr("Ready") if lepton
else tr("Not installed yet (needs Developer Mode)"),
"lepton", (tr("Install"), ft.Icons.DOWNLOAD_ROUNDED, app.install_lepton)))
@@ -97,7 +98,7 @@ class FrameView:
ready, sug = pr.get("ready"), pr.get("suggested")
if pr:
items.append(C.Check(
bool(ready), tr("PC VR games (Proton)"),
bool(ready), tr("PC VR games (Proton)") if details else tr("PC VR games"),
tr("{display_name} installed").format(display_name=ready['display_name']) if ready else
(tr("{display_name} can be installed (about 1 GiB; Steam restarts once)")
.format(display_name=sug['display_name']) if sug else
@@ -153,13 +154,16 @@ class FrameView:
if d.get("usable"):
opts.append(ft.DropdownOption(key=drives.INTERNAL if d["internal"] else d["install_dir"],
text=" · ".join(filter(None, [name(d), free]))))
details = C.technical()
reason = d.get("reason") or ""
state = tr_n("{n} game", "{n} games", d.get("games", 0)) if d.get("usable") else \
tr("can't hold games: {reason}").format(reason=d.get("reason") or "")
tr("can't hold games: {reason}").format(reason=reason) if details else tr("can't hold games")
lines.append(ft.Row([
ft.Icon(ft.Icons.SD_CARD_ROUNDED if d.get("removable") else ft.Icons.STORAGE_OUTLINED,
size=T.px(18), color=T.TEXT_2 if d.get("usable") else T.TEXT_3),
C.body(name(d), T.TEXT if d.get("usable") else T.TEXT_3, expand=True),
C.meta(" · ".join(filter(None, [d.get("fstype"), free, state]))),
ft.Container(C.meta(" · ".join(filter(None, [d.get("fstype") if details else None, free, state]))),
tooltip=C.tip(reason) if reason and not details else None),
], spacing=T.S2))
if current and not any(o.key == current for o in opts): # the chosen card isn't inserted now
label = current.rstrip("/").rsplit("/", 2)[-2] if current.rstrip("/").endswith("/FramePort") \
@@ -269,8 +273,8 @@ class FrameView:
devkit=f.source == "devkit")),
], spacing=T.S3), padding=ft.Padding(T.S3, T.px(8), T.S2, T.px(8)), bgcolor=T.SURFACE_2,
border_radius=T.RADIUS_SM))
found.controls = items or [ft.Row([C.body(tr("No Frames found. New Frame? Use step 1. Otherwise turn on "
"Developer Mode and keep the Frame on this network."),
found.controls = items or [ft.Row([C.body(tr("No Frames found. New Frame? Use Start setup above. Otherwise "
"turn on Developer Mode and keep the Frame on this network."),
expand=True),
C.help_icon("developer_mode")], spacing=T.px(4))]
found.controls.append(C.ghost(tr("Search again"), ft.Icons.REFRESH_ROUNDED, lambda e: app.run_bg(discover)))
@@ -445,7 +449,7 @@ class FrameView:
weight=ft.FontWeight.W_600),
C.body(hint, T.TEXT),
ft.Row([C.meta(tr("Or turn on Developer Mode on the Frame (Settings → System) and connect it under "
"step 2."),
"\"Already set up?\"."),
expand=True), C.help_icon("developer_mode")], spacing=T.px(4)),
], spacing=T.S2), "warn"))
if update:
@@ -457,11 +461,19 @@ class FrameView:
addr = C.field(hint_text=tr("steamos@frame.local or an IP address"), width=T.px(320))
pw = C.field(hint_text=tr("Password (first time only)"), password=True, can_reveal_password=True,
width=T.px(240), tooltip=C.tip(HELP["password"]))
# by address: for people who know their network; out of the way until asked for (the setup needs no password)
by_address = ft.Row([addr, pw, C.primary(tr("Connect"),
on_click=lambda e: app.connect_manual(addr.value, pw.value))],
wrap=True, spacing=T.S3, visible=False)
def show_address(e):
by_address.visible = not by_address.visible
C.update(by_address)
saved = saved_targets()
offline = None
if getattr(app, "_not_paired", False): # it answered, but doesn't know FramePort yet
offline = C.callout(ft.Row([
C.body(tr("This Frame isn't set up for FramePort yet. Run the setup once (step 1)."), T.TEXT,
C.body(tr("This Frame isn't set up for FramePort yet. Run the setup once."), T.TEXT,
expand=True),
C.primary(tr("Start setup"), ft.Icons.TERMINAL_ROUNDED, pair)]), "warn")
elif saved and app.frame_state == "offline":
@@ -501,16 +513,18 @@ class FrameView:
"info"))
return out
step = lambda n, head, text, *content, help=None: C.card(ft.Row([ # noqa: E731
ft.Container(ft.Text(str(n), weight=ft.FontWeight.W_700, color=T.ACCENT), width=T.px(30), height=T.px(30),
border_radius=T.px(15), bgcolor=T.ACCENT_SOFT, alignment=ft.Alignment.CENTER),
ft.Column([C.with_help(C.h2(head), help), C.body(text), *content], spacing=T.S3, expand=True),
], spacing=T.S4, vertical_alignment=ft.CrossAxisAlignment.START), padding=T.S5)
def block(head, text, *content, help=None, main=False):
"""One way to connect. The setup is the main one (a bigger heading, the portal's light); the others are
quieter cards. No numbers: they are alternatives, not steps."""
heading = C.title(head, T.T_DISPLAY) if main else C.h2(head)
return C.card(ft.Column([C.with_help(heading, help), C.body(text), *content], spacing=T.S3),
padding=T.S6 if main else T.S5, bgcolor=T.SURFACE_2 if main else None)
return ft.Column([
app.top_bar(tr("Connect your Steam Frame"), tr("FramePort installs games on the Frame over your network")),
*([offline] if offline else []),
step(1, tr("First-time setup"), tr("New Frame? Run the setup on it and click Allow here. Already set up? "
"Your Frame shows under step 2."),
block(tr("Set up your Frame"),
tr("Run one line on the Frame, then click Allow here. You do this once. No password, and nothing "
"changes on the Frame until you allow it."),
ft.Row([C.primary(tr("Start setup"), ft.Icons.TERMINAL_ROUNDED, pair),
C.meta(tr("or"), weight=ft.FontWeight.W_600),
C.secondary(tr("Set up with a USB cable"), ft.Icons.USB_ROUNDED, usb_explain,
@@ -521,15 +535,13 @@ class FrameView:
"that block the search."))],
wrap=True, spacing=T.S3, vertical_alignment=ft.CrossAxisAlignment.CENTER),
pair_box,
help="first_time_setup"),
step(2, tr("Already set up"), tr("Frames in Developer Mode show up here."), found,
*auto_pair_controls(),
help="developer_mode"),
step(3, tr("Enter the address"), tr("If you know the Frame's address."),
ft.Row([addr, pw, C.primary(tr("Connect"),
on_click=lambda e: app.connect_manual(addr.value, pw.value))],
wrap=True, spacing=T.S3)),
], spacing=T.S4, scroll=ft.ScrollMode.AUTO, expand=True)
help="first_time_setup", main=True),
block(tr("Already set up?"), tr("Frames that are set up show up here by themselves."), found,
*auto_pair_controls(),
help="developer_mode"),
ft.Column([ft.Row([C.ghost(tr("Connect by address…"), ft.Icons.LAN_ROUNDED, show_address)]), by_address],
spacing=T.S2),
], spacing=T.S4, scroll=ft.ScrollMode.AUTO, expand=True, horizontal_alignment=ft.CrossAxisAlignment.STRETCH)
def build(self) -> ft.Control:
app = self.app
+8 -10
View File
@@ -244,9 +244,7 @@ class GameView:
tr("Needs the Meta Horizon app for its license check, so it crashes on the Frame. Play it on "
"this PC.") if rift_platform else
tr("Uses its own Revive — experimental on the Frame") if rift_repack and not last else
tr("Last launch test: {get} · furthest step: {value}")
.format(get=C.test_result(last.get("verdict"), last.get("milestone"))[0],
value=C.test_result(last.get("verdict"), last.get("milestone"))[1]) if last
C.test_line(last.get("verdict"), last.get("milestone")) if last
else tr("Runs directly — no Revive needed") if self.rift else
tr("Runs directly on SteamOS, from the Frame's Steam library") if self.linux else "")
cards.append(self.target_card(
@@ -635,7 +633,7 @@ class GameView:
else:
lead, icon, color = tr("Suggested by FramePort from the game's engine"), \
ft.Icons.AUTO_AWESOME_ROUNDED, T.ACCENT
chips = [ft.Container(C.body(tr(p.title), T.TEXT, size=T.T_META),
chips = [ft.Container(C.body(C.patch_title(p.title), T.TEXT, size=T.T_META),
tooltip=C.tip(tr(recipe.reasons.get(p.id) or p.description)),
bgcolor=T.SURFACE_3, border_radius=T.RADIUS_XS,
padding=ft.Padding(T.px(10), T.px(5), T.px(10), T.px(5)))
@@ -780,7 +778,7 @@ class GameView:
if reason:
shown = tr(reason).replace("overport", "OVRPort") if technical else \
plain_reason(reason, p.default_on)
sub.insert(0, C.meta(shown, T.ACCENT))
sub.insert(0, C.meta(shown, T.SECONDARY)) # where a patch came from: information, not an action
if on and p.id in superseded:
sub.append(C.meta(upstream_note, T.TEXT_3))
extra = None
@@ -791,7 +789,7 @@ class GameView:
if current not in keys: # a tool name typed under technical details
choices = (*choices, (current, current))
rows.append(ft.Container(ft.Row([
ft.Column([C.body(tr(p.title), T.TEXT, weight=ft.FontWeight.W_500), *sub],
ft.Column([C.body(C.patch_title(p.title, technical), T.TEXT, weight=ft.FontWeight.W_500), *sub],
spacing=T.px(3), expand=True),
C.dropdown(value=current, width=T.px(280),
options=[ft.DropdownOption(key=k, text=tr(label)) for k, label in choices],
@@ -811,13 +809,13 @@ class GameView:
width=T.px(260), multiline=multi, min_lines=1, max_lines=4 if multi else 1,
on_blur=set_param(p.id, q.key))
rows.append(ft.Container(ft.Row([
ft.Column([ft.Row([C.body(tr(p.title), T.TEXT, weight=ft.FontWeight.W_500)]
ft.Column([ft.Row([C.body(C.patch_title(p.title, technical), T.TEXT, weight=ft.FontWeight.W_500)]
+ ([C.pill(tr("experimental"), T.WARN, tooltip=C.tip(HELP["experimental"]))]
if p.experimental else [])
+ ([C.meta(p.id)] if technical else []), spacing=T.S2, wrap=True), *sub],
spacing=T.px(3), expand=True),
*([extra] if extra else []),
ft.Switch(value=on, on_change=toggle(p.id), active_color=T.ACCENT),
C.switch(value=on, on_change=toggle(p.id)),
], spacing=T.S3), padding=ft.Padding(T.S4, T.px(10), T.S4, T.px(10)), border=ft.Border(
top=ft.BorderSide(1, T.BORDER))))
if not rows:
@@ -842,13 +840,13 @@ class GameView:
alt = ", ".join(recipe.alt_patches if technical else
[tr(base.get(p).title) if p in base.REGISTRY else p for p in recipe.alt_patches])
top.append(C.with_help(C.switch(label=tr("Install the second build (") + alt
+ ")", value=recipe.use_alt, on_change=set_alt, active_color=T.ACCENT),
+ ")", value=recipe.use_alt, on_change=set_alt),
"alt_build"))
if hidden:
top.append(C.with_help(C.switch(
label=tr("Show all patches ({len} don't apply to this game)").format(len=len(hidden)),
value=self.show_all,
active_color=T.ACCENT, on_change=lambda e: app.open_game(package, advanced=True,
on_change=lambda e: app.open_game(package, advanced=True,
show_all=e.control.value)), "show_all"))
warn.value = "\n".join(engine.warnings(recipe))
return C.section(tr("Patches"), *top, warn, *sections,
+1 -1
View File
@@ -67,7 +67,7 @@ class KeyboardView:
C.empty_state(G.KEYS, tr("Type in VR with your PC's keyboard"),
tr("Connect to your Steam Frame to type into the headset from here."),
C.primary(tr("Connect"), ft.Icons.LINK_ROUNDED, lambda e: app.go("frame")),
features=keyboard_tips())], expand=True)
preview=C.ghost_preview("keyboard"))], expand=True)
if self.root is None:
self.root = ft.Column([
app.top_bar(heading, sub),
+1 -1
View File
@@ -102,7 +102,7 @@ def platform_filter_options(games: list[dict]) -> list[tuple[str, str]]:
kinds = {g.get("kind") or "quest" for g in games}
if not kinds & {"rift", "linux"}:
return []
out = [("all", tr("All")), ("quest", tr("Android"))]
out = [("all", tr("All")), ("quest", tr("Quest/Android"))]
if "rift" in kinds:
out.append(("pcvr", tr("PC VR")))
if "linux" in kinds:
+3 -11
View File
@@ -58,15 +58,6 @@ def health_text(st: dict) -> str:
"quality.", st.get("dropped") or 0)
def live_features() -> list[tuple[str, str, str]]:
"""What the live view does (its empty states)."""
return [(G.LIVE, tr("What the player sees"),
tr("The headset's view on this PC, for watching along or recording.")),
(ft.Icons.VOLUME_UP_ROUNDED, tr("With sound"), tr("The game's audio streams along with the picture.")),
(ft.Icons.MEMORY_ROUNDED, tr("Light on the game"),
tr("The Frame's own video encoder does the work where it can."))]
VIDEO_START_S = 12 # the in-window player must show a moving picture within this, else the next player takes over
VIDEO_REOPENS = 3 # times a minute the in-window player reconnects after an unexpected end of the stream
@@ -134,7 +125,7 @@ class LiveView:
C.empty_state(G.LIVE, tr("See what the headset sees"),
tr("Connect to your Steam Frame to stream its view and sound to this PC."),
C.primary(tr("Connect"), ft.Icons.LINK_ROUNDED, lambda e: app.go("frame")),
features=live_features())], expand=True)
preview=C.ghost_preview("live"))], expand=True)
if self.embedded_ok is None:
self.embedded_ok = LP.embedded_available(web=bool(getattr(app.page, "web", False)))
self.mpv_exe = LP.find_mpv()
@@ -169,7 +160,7 @@ class LiveView:
], horizontal_alignment=ft.CrossAxisAlignment.CENTER, spacing=T.S2, tight=True),
alignment=ft.Alignment.CENTER, expand=True, padding=T.S6, gradient=C.portal_glow(),
bgcolor=T.soft("#000000", 0.25), border=ft.Border.all(1, T.BORDER), border_radius=T.RADIUS)
self.idle = ft.Column([stage, C.feature_row(live_features())], spacing=T.S4, expand=True,
self.idle = ft.Column([stage], spacing=T.S4, expand=True,
horizontal_alignment=ft.CrossAxisAlignment.STRETCH)
self.root = ft.Column([
app.top_bar(heading, sub),
@@ -233,6 +224,7 @@ class LiveView:
elsewhere = running and self.mode in ("browser", "mpv")
self.idle.visible = live is None or elsewhere
self.idle_start.visible = live is None
self.start_btn.visible = not running and not self.idle_start.visible # one Start on screen, not two
self.idle_open.visible = elsewhere
self.idle_title.value = (tr("Streaming to an mpv window") if elsewhere and self.mode == "mpv" else
tr("Streaming to your browser") if elsewhere else tr("Ready when you are"))
+1 -8
View File
@@ -289,14 +289,7 @@ class MonitorView:
tr("Connect to your Steam Frame to watch its games, load, temperatures and battery "
"live."),
C.primary(tr("Connect"), ft.Icons.LINK_ROUNDED, lambda e: app.go("frame")),
features=[
(ft.Icons.SPEED_ROUNDED, tr("Frame rate"),
tr("The running game's frames per second, as it plays.")),
(ft.Icons.THERMOSTAT_ROUNDED, tr("Load and temperatures"),
tr("CPU, GPU, memory, temperatures, power and battery.")),
(ft.Icons.LIST_ALT_ROUNDED, tr("Processes"),
tr("What's running, and a way to end what's stuck.")),
])], expand=True)
preview=C.ghost_preview("monitor"))], expand=True)
if self.root is None:
self.root = ft.Column([
app.top_bar(heading, sub, [self.overhead, self.live, self.reconnect, self.interval, self.pause_btn]),
+1 -8
View File
@@ -121,14 +121,7 @@ class ScreenshotsView:
C.empty_state(G.SHOT, tr("Every shot you take in the headset"),
tr("Connect to your Steam Frame to see its screenshots here."),
C.primary(tr("Connect"), ft.Icons.LINK_ROUNDED, lambda e: app.go("frame")),
features=[
(G.SHOT, tr("Sorted by day"),
tr("All of Steam's headset screenshots, newest first.")),
(ft.Icons.VIDEOGAME_ASSET_OUTLINED, tr("Matched to your games"),
tr("Each shot is labeled with the game you were playing.")),
(ft.Icons.DOWNLOAD_ROUNDED, tr("Save them to this PC"),
tr("Download one, a selection or all of them.")),
])], expand=True)
preview=C.ghost_preview("screenshots"))], expand=True)
if self.root is None:
# the selection bar below the grid (as in the Library): above it, it pushed the cards down mid-drag
self.root = ft.Column([app.top_bar(heading, sub), self.toolbar, self.menu, self.sel_bar, self.status],
+1 -1
View File
@@ -73,7 +73,7 @@ class WelcomeView:
if t_state == "error" else [])),
self.step(2, tr("Connect your Steam Frame"), f_text, f_state,
*([] if f_state == "done"
else [C.secondary(tr("Set up the Frame"), ft.Icons.ARROW_FORWARD_ROUNDED,
else [C.secondary(tr("Set up your Frame"), ft.Icons.ARROW_FORWARD_ROUNDED,
lambda e: app.go("frame"))])),
self.step(3, tr("Add your games"),
tr("A folder with Quest, Android or PC VR games."),
+9
View File
@@ -168,3 +168,12 @@ def test_as_icon_turns_glyphs_into_tinted_images():
assert isinstance(img, ft.Image) and img.color == T.ACCENT and img.src.endswith("frame.svg")
assert isinstance(C.as_icon(ft.Icons.TUNE_ROUNDED, 20), ft.Icon)
assert C._btn_icon(ft.Icons.TUNE_ROUNDED, T.TEXT) == ft.Icons.TUNE_ROUNDED # buttons colour Material icons
def test_builtin_text_is_readable_on_every_card_surface():
# TEXT_3 carries the 11-12 px meta text (C.meta): it was #717583 (3.6:1 on SURFACE_2), now >= 4.5:1 (WCAG AA)
for name, theme in T.BUILTIN.items():
c = theme["colors"]
for token in ("TEXT", "TEXT_2", "TEXT_3"):
for surface in ("BG", "SIDEBAR", "SURFACE", "SURFACE_2"):
assert T.contrast(c[token], c[surface]) >= 4.5, (name, token, surface)