Author SHA1 Message Date
MichaelScottsman 7775b8d683 README: title names the music player too 2026-10-10 14:38:06 -04:00
MichaelScottsman 8b422d1cd6 AssPod in + > Launch Program: the installer adds it once VLC is there, and it opens the assPod with the theme on or off
lgs pod: with the theme on, the bar's asspod.open; with it off, only the assPod's runtime modules
in Steam (rt.podOnly, no liveness watch on the CSS core) and lgs-shell in pod mode (no theme, the
assPod's page script only), which exits once the assPod is away with nothing playing. lgs on
replaces a pod unit with a full one.
2026-10-10 14:37:53 -04:00
MichaelScottsman d19fd83a27 Merge pull request #2 from MichaelScottsman/morepods
assPod: Nano Gen 2 body
2026-10-10 14:10:03 -04:00
MichaelScottsman 80d4710f3f assPod: Nano Gen 2 body, chosen on the main menu and kept across launches
- assPod Model on the main menu switches between Video Gen 1 and Nano Gen 2; Color lists the
  current body's finishes (the nano: silver, green, blue, pink, black, (PRODUCT)RED). The choice is
  kept in ~/.config/glass-shell/asspod.json beside the glass tune.
- The nano: a procedural 2nd-generation model (tools/nano2-model) exported as render models, drawn
  by podd with anodized aluminum (tinted, bead-blasted, the laser-etched back paler), white end
  caps and a clickable wheel. Same menus on a 240 x 180 logical screen, so its text is larger, as on
  the real one; it plays no video. Shown 52.5 mm wide.
- podd: one box layout that holds either body, so a switch needs no restart; 64 px between atlas
  tiles and a 4 px clear band around each eye's image, which removes the hairline rectangle around
  the body (filtering at the stereo split took the other eye's edge); texture dump and debug modes.
- The left stick acts like the right: Steam's d-pad directions are no longer relayed (tilting the
  left stick down or left pressed the centre button or Menu).
2026-10-10 14:08:31 -04:00
111 changed files with 117229 additions and 342 deletions

No files matched your search

+11 -5
View File
@@ -1,6 +1,6 @@
<p align="center"><img src="assets/icon.png" alt="LiquidAss icon" width="160"></p>
# LiquidAss: Liquid Glass for the Steam Frame's VR interface
# LiquidAss: Liquid Glass Theme and Virtual Music Player for Steam Frame
LiquidAss re-skins the Steam Frame's VR UI in Apple's glass design language. AI assisted.
@@ -50,6 +50,10 @@ Press the paintbrush button at the right end of the dashboard bar. Its panel set
Press the music note button on the dashboard bar, just left of the paintbrush. The assPod appears in the hand you pressed it with.
You can also open it from **+** › **Launch Program** › **AssPod**, and this works whether the LiquidAss theme is on or off. The installer adds that entry once VLC is installed. With the theme off, Steam's UI stays stock. Only the assPod and its player run, and they stop by themselves about 30 seconds after you put the assPod away with nothing playing.
**assPod Model** on its main menu switches the body: **Video Gen 1** (the original) or **Nano Gen 2**, in anodized aluminum with white end caps. The nano has the same menus, laid out for its smaller screen with larger text (six rows, like the real one), and it plays no video. **Color** lists the current body's finishes: Black and White for the Video; Silver, Green, Blue, Pink, Black and (PRODUCT)RED for the nano. The model and colours you pick are kept across launches.
## Nothing persists
**The theme is never written to disk.** `lgs` injects the stylesheet into the *running* Steam client through its local devtools socket (`127.0.0.1:8080`; Steam on the Frame runs with `-cef-enable-debugging`). Any of these brings back the stock UI:
@@ -60,21 +64,23 @@ Press the music note button on the dashboard bar, just left of the paintbrush. T
Nothing starts at boot. There are no systemd units, autostart entries or Steam file patches.
Only two things stay installed, so the toggle is there after a reboot, plus one file of your own:
Only the launcher entries and the files they run stay installed, so they are there after a reboot, plus two files of your own:
| Path | What it is |
|---|---|
| `~/.local/share/applications/glass-shell.desktop` | The "LiquidAss" entry in + › Launch Program |
| `~/.local/share/applications/asspod.desktop` | The "AssPod" entry in + › Launch Program (only when VLC is installed) |
| `~/.local/share/glass-shell/` | The toggle script and the theme files it reads |
| `~/.config/glass-shell/tune.json` | Your glass settings from the paintbrush button on the bar (colour, intensity, refraction, frost, highlights). Written only when you change them; **Reset to default** in that panel deletes it. The one exception to "nothing persists", by request |
| `~/.config/glass-shell/tune.json` | Your glass settings from the paintbrush button on the bar (colour, intensity, refraction, frost, highlights). Written only when you change them; **Reset to default** in that panel deletes it. An exception to "nothing persists", by request |
| `~/.config/glass-shell/asspod.json` | The assPod model and colours you picked. Written only when you change them. The other exception, by request |
The [uninstall](#uninstall) command (or `python glass.py uninstall`) removes all three. (The install paths keep the project's original `glass-shell` name, so existing installs carry over.)
The [uninstall](#uninstall) command (or `python glass.py uninstall`) removes all of them. (The install paths keep the project's original `glass-shell` name, so existing installs carry over.)
## From the PC
```bash
python glass.py install # copy this checkout to the headset, set up glassd and add the launcher (for development)
python glass.py on | off | toggle | status
python glass.py on | off | toggle | status | pod
python glass.py dial 0.7 # glass intensity: 0 = clearest, 1 = most opaque (saved with your glass settings)
python glass.py uninstall
```
+11
View File
@@ -0,0 +1,11 @@
[Desktop Entry]
Type=Application
Name=AssPod
GenericName=assPod music player
Comment=Take out the assPod, with or without the LiquidAss theme. It plays your music and videos through VLC.
Exec=/home/steamos/.local/share/glass-shell/device/lgs pod
Icon=/home/steamos/.local/share/glass-shell/device/asspod/icon.png
Terminal=false
Categories=AudioVideo;Audio;Player;
Keywords=asspod;music;player;vlc;liquidass;
StartupNotify=false
Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

@@ -0,0 +1,8 @@
newmtl nano_back_black
Kd 1 1 1
Ns 64
d 1
illum 2
Ka 0 0 0
Ks 0.3 0.3 0.3
map_Kd nano_back_black_color.png
@@ -0,0 +1,63 @@
# nano_back_black: the assPod nano body (tools/nano2-model/export.html)
mtllib nano_back_black.mtl
usemtl nano_back_black
v -0.016750 -0.045000 -0.003250
v -0.016750 -0.015000 -0.003250
v 0.016750 -0.045000 -0.003250
v 0.016750 -0.045000 -0.003250
v -0.016750 -0.015000 -0.003250
v 0.016750 -0.015000 -0.003250
v -0.016750 -0.015000 -0.003250
v -0.016750 0.015000 -0.003250
v 0.016750 -0.015000 -0.003250
v 0.016750 -0.015000 -0.003250
v -0.016750 0.015000 -0.003250
v 0.016750 0.015000 -0.003250
v -0.016750 0.015000 -0.003250
v -0.016750 0.045000 -0.003250
v 0.016750 0.015000 -0.003250
v 0.016750 0.015000 -0.003250
v -0.016750 0.045000 -0.003250
v 0.016750 0.045000 -0.003250
vt 0.918750 -0.000000
vt 0.918750 0.333333
vt 0.081250 -0.000000
vt 0.081250 -0.000000
vt 0.918750 0.333333
vt 0.081250 0.333333
vt 0.918750 0.333333
vt 0.918750 0.666667
vt 0.081250 0.333333
vt 0.081250 0.333333
vt 0.918750 0.666667
vt 0.081250 0.666667
vt 0.918750 0.666667
vt 0.918750 1.000000
vt 0.081250 0.666667
vt 0.081250 0.666667
vt 0.918750 1.000000
vt 0.081250 1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
f 1/1/1 2/2/2 3/3/3
f 4/4/4 5/5/5 6/6/6
f 7/7/7 8/8/8 9/9/9
f 10/10/10 11/11/11 12/12/12
f 13/13/13 14/14/14 15/15/15
f 16/16/16 17/17/17 18/18/18
Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

@@ -0,0 +1,8 @@
newmtl nano_back_blue
Kd 1 1 1
Ns 64
d 1
illum 2
Ka 0 0 0
Ks 0.3 0.3 0.3
map_Kd nano_back_blue_color.png
@@ -0,0 +1,63 @@
# nano_back_blue: the assPod nano body (tools/nano2-model/export.html)
mtllib nano_back_blue.mtl
usemtl nano_back_blue
v -0.016750 -0.045000 -0.003250
v -0.016750 -0.015000 -0.003250
v 0.016750 -0.045000 -0.003250
v 0.016750 -0.045000 -0.003250
v -0.016750 -0.015000 -0.003250
v 0.016750 -0.015000 -0.003250
v -0.016750 -0.015000 -0.003250
v -0.016750 0.015000 -0.003250
v 0.016750 -0.015000 -0.003250
v 0.016750 -0.015000 -0.003250
v -0.016750 0.015000 -0.003250
v 0.016750 0.015000 -0.003250
v -0.016750 0.015000 -0.003250
v -0.016750 0.045000 -0.003250
v 0.016750 0.015000 -0.003250
v 0.016750 0.015000 -0.003250
v -0.016750 0.045000 -0.003250
v 0.016750 0.045000 -0.003250
vt 0.918750 -0.000000
vt 0.918750 0.333333
vt 0.081250 -0.000000
vt 0.081250 -0.000000
vt 0.918750 0.333333
vt 0.081250 0.333333
vt 0.918750 0.333333
vt 0.918750 0.666667
vt 0.081250 0.333333
vt 0.081250 0.333333
vt 0.918750 0.666667
vt 0.081250 0.666667
vt 0.918750 0.666667
vt 0.918750 1.000000
vt 0.081250 0.666667
vt 0.081250 0.666667
vt 0.918750 1.000000
vt 0.081250 1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
f 1/1/1 2/2/2 3/3/3
f 4/4/4 5/5/5 6/6/6
f 7/7/7 8/8/8 9/9/9
f 10/10/10 11/11/11 12/12/12
f 13/13/13 14/14/14 15/15/15
f 16/16/16 17/17/17 18/18/18
Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

@@ -0,0 +1,8 @@
newmtl nano_back_green
Kd 1 1 1
Ns 64
d 1
illum 2
Ka 0 0 0
Ks 0.3 0.3 0.3
map_Kd nano_back_green_color.png
@@ -0,0 +1,63 @@
# nano_back_green: the assPod nano body (tools/nano2-model/export.html)
mtllib nano_back_green.mtl
usemtl nano_back_green
v -0.016750 -0.045000 -0.003250
v -0.016750 -0.015000 -0.003250
v 0.016750 -0.045000 -0.003250
v 0.016750 -0.045000 -0.003250
v -0.016750 -0.015000 -0.003250
v 0.016750 -0.015000 -0.003250
v -0.016750 -0.015000 -0.003250
v -0.016750 0.015000 -0.003250
v 0.016750 -0.015000 -0.003250
v 0.016750 -0.015000 -0.003250
v -0.016750 0.015000 -0.003250
v 0.016750 0.015000 -0.003250
v -0.016750 0.015000 -0.003250
v -0.016750 0.045000 -0.003250
v 0.016750 0.015000 -0.003250
v 0.016750 0.015000 -0.003250
v -0.016750 0.045000 -0.003250
v 0.016750 0.045000 -0.003250
vt 0.918750 -0.000000
vt 0.918750 0.333333
vt 0.081250 -0.000000
vt 0.081250 -0.000000
vt 0.918750 0.333333
vt 0.081250 0.333333
vt 0.918750 0.333333
vt 0.918750 0.666667
vt 0.081250 0.333333
vt 0.081250 0.333333
vt 0.918750 0.666667
vt 0.081250 0.666667
vt 0.918750 0.666667
vt 0.918750 1.000000
vt 0.081250 0.666667
vt 0.081250 0.666667
vt 0.918750 1.000000
vt 0.081250 1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
f 1/1/1 2/2/2 3/3/3
f 4/4/4 5/5/5 6/6/6
f 7/7/7 8/8/8 9/9/9
f 10/10/10 11/11/11 12/12/12
f 13/13/13 14/14/14 15/15/15
f 16/16/16 17/17/17 18/18/18
Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

@@ -0,0 +1,8 @@
newmtl nano_back_pink
Kd 1 1 1
Ns 64
d 1
illum 2
Ka 0 0 0
Ks 0.3 0.3 0.3
map_Kd nano_back_pink_color.png
@@ -0,0 +1,63 @@
# nano_back_pink: the assPod nano body (tools/nano2-model/export.html)
mtllib nano_back_pink.mtl
usemtl nano_back_pink
v -0.016750 -0.045000 -0.003250
v -0.016750 -0.015000 -0.003250
v 0.016750 -0.045000 -0.003250
v 0.016750 -0.045000 -0.003250
v -0.016750 -0.015000 -0.003250
v 0.016750 -0.015000 -0.003250
v -0.016750 -0.015000 -0.003250
v -0.016750 0.015000 -0.003250
v 0.016750 -0.015000 -0.003250
v 0.016750 -0.015000 -0.003250
v -0.016750 0.015000 -0.003250
v 0.016750 0.015000 -0.003250
v -0.016750 0.015000 -0.003250
v -0.016750 0.045000 -0.003250
v 0.016750 0.015000 -0.003250
v 0.016750 0.015000 -0.003250
v -0.016750 0.045000 -0.003250
v 0.016750 0.045000 -0.003250
vt 0.918750 -0.000000
vt 0.918750 0.333333
vt 0.081250 -0.000000
vt 0.081250 -0.000000
vt 0.918750 0.333333
vt 0.081250 0.333333
vt 0.918750 0.333333
vt 0.918750 0.666667
vt 0.081250 0.333333
vt 0.081250 0.333333
vt 0.918750 0.666667
vt 0.081250 0.666667
vt 0.918750 0.666667
vt 0.918750 1.000000
vt 0.081250 0.666667
vt 0.081250 0.666667
vt 0.918750 1.000000
vt 0.081250 1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
f 1/1/1 2/2/2 3/3/3
f 4/4/4 5/5/5 6/6/6
f 7/7/7 8/8/8 9/9/9
f 10/10/10 11/11/11 12/12/12
f 13/13/13 14/14/14 15/15/15
f 16/16/16 17/17/17 18/18/18
Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

@@ -0,0 +1,8 @@
newmtl nano_back_red
Kd 1 1 1
Ns 64
d 1
illum 2
Ka 0 0 0
Ks 0.3 0.3 0.3
map_Kd nano_back_red_color.png
@@ -0,0 +1,63 @@
# nano_back_red: the assPod nano body (tools/nano2-model/export.html)
mtllib nano_back_red.mtl
usemtl nano_back_red
v -0.016750 -0.045000 -0.003250
v -0.016750 -0.015000 -0.003250
v 0.016750 -0.045000 -0.003250
v 0.016750 -0.045000 -0.003250
v -0.016750 -0.015000 -0.003250
v 0.016750 -0.015000 -0.003250
v -0.016750 -0.015000 -0.003250
v -0.016750 0.015000 -0.003250
v 0.016750 -0.015000 -0.003250
v 0.016750 -0.015000 -0.003250
v -0.016750 0.015000 -0.003250
v 0.016750 0.015000 -0.003250
v -0.016750 0.015000 -0.003250
v -0.016750 0.045000 -0.003250
v 0.016750 0.015000 -0.003250
v 0.016750 0.015000 -0.003250
v -0.016750 0.045000 -0.003250
v 0.016750 0.045000 -0.003250
vt 0.918750 -0.000000
vt 0.918750 0.333333
vt 0.081250 -0.000000
vt 0.081250 -0.000000
vt 0.918750 0.333333
vt 0.081250 0.333333
vt 0.918750 0.333333
vt 0.918750 0.666667
vt 0.081250 0.333333
vt 0.081250 0.333333
vt 0.918750 0.666667
vt 0.081250 0.666667
vt 0.918750 0.666667
vt 0.918750 1.000000
vt 0.081250 0.666667
vt 0.081250 0.666667
vt 0.918750 1.000000
vt 0.081250 1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
f 1/1/1 2/2/2 3/3/3
f 4/4/4 5/5/5 6/6/6
f 7/7/7 8/8/8 9/9/9
f 10/10/10 11/11/11 12/12/12
f 13/13/13 14/14/14 15/15/15
f 16/16/16 17/17/17 18/18/18
Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

@@ -0,0 +1,8 @@
newmtl nano_back_silver
Kd 1 1 1
Ns 64
d 1
illum 2
Ka 0 0 0
Ks 0.3 0.3 0.3
map_Kd nano_back_silver_color.png
@@ -0,0 +1,63 @@
# nano_back_silver: the assPod nano body (tools/nano2-model/export.html)
mtllib nano_back_silver.mtl
usemtl nano_back_silver
v -0.016750 -0.045000 -0.003250
v -0.016750 -0.015000 -0.003250
v 0.016750 -0.045000 -0.003250
v 0.016750 -0.045000 -0.003250
v -0.016750 -0.015000 -0.003250
v 0.016750 -0.015000 -0.003250
v -0.016750 -0.015000 -0.003250
v -0.016750 0.015000 -0.003250
v 0.016750 -0.015000 -0.003250
v 0.016750 -0.015000 -0.003250
v -0.016750 0.015000 -0.003250
v 0.016750 0.015000 -0.003250
v -0.016750 0.015000 -0.003250
v -0.016750 0.045000 -0.003250
v 0.016750 0.015000 -0.003250
v 0.016750 0.015000 -0.003250
v -0.016750 0.045000 -0.003250
v 0.016750 0.045000 -0.003250
vt 0.918750 -0.000000
vt 0.918750 0.333333
vt 0.081250 -0.000000
vt 0.081250 -0.000000
vt 0.918750 0.333333
vt 0.081250 0.333333
vt 0.918750 0.333333
vt 0.918750 0.666667
vt 0.081250 0.333333
vt 0.081250 0.333333
vt 0.918750 0.666667
vt 0.081250 0.666667
vt 0.918750 0.666667
vt 0.918750 1.000000
vt 0.081250 0.666667
vt 0.081250 0.666667
vt 0.918750 1.000000
vt 0.081250 1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
f 1/1/1 2/2/2 3/3/3
f 4/4/4 5/5/5 6/6/6
f 7/7/7 8/8/8 9/9/9
f 10/10/10 11/11/11 12/12/12
f 13/13/13 14/14/14 15/15/15
f 16/16/16 17/17/17 18/18/18
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

@@ -0,0 +1,8 @@
newmtl nano_cap_bottom
Kd 1 1 1
Ns 64
d 1
illum 2
Ka 0 0 0
Ks 0.1 0.1 0.1
map_Kd nano_cap_bottom_color.png
@@ -0,0 +1,503 @@
# nano_cap_bottom: the assPod nano body (tools/nano2-model/export.html)
mtllib nano_cap_bottom.mtl
usemtl nano_cap_bottom
v 0.000000 -0.044750 0.000000
v 0.016750 -0.044750 -0.002850
v 0.017122 -0.044750 -0.002826
v 0.000000 -0.044750 0.000000
v 0.017122 -0.044750 -0.002826
v 0.017488 -0.044750 -0.002753
v 0.000000 -0.044750 0.000000
v 0.017488 -0.044750 -0.002753
v 0.017841 -0.044750 -0.002633
v 0.000000 -0.044750 0.000000
v 0.017841 -0.044750 -0.002633
v 0.018175 -0.044750 -0.002468
v 0.000000 -0.044750 0.000000
v 0.018175 -0.044750 -0.002468
v 0.018485 -0.044750 -0.002261
v 0.000000 -0.044750 0.000000
v 0.018485 -0.044750 -0.002261
v 0.018765 -0.044750 -0.002015
v 0.000000 -0.044750 0.000000
v 0.018765 -0.044750 -0.002015
v 0.019011 -0.044750 -0.001735
v 0.000000 -0.044750 0.000000
v 0.019011 -0.044750 -0.001735
v 0.019218 -0.044750 -0.001425
v 0.000000 -0.044750 0.000000
v 0.019218 -0.044750 -0.001425
v 0.019383 -0.044750 -0.001091
v 0.000000 -0.044750 0.000000
v 0.019383 -0.044750 -0.001091
v 0.019503 -0.044750 -0.000738
v 0.000000 -0.044750 0.000000
v 0.019503 -0.044750 -0.000738
v 0.019576 -0.044750 -0.000372
v 0.000000 -0.044750 0.000000
v 0.019576 -0.044750 -0.000372
v 0.019600 -0.044750 0.000000
v 0.000000 -0.044750 0.000000
v 0.019600 -0.044750 0.000000
v 0.019576 -0.044750 0.000372
v 0.000000 -0.044750 0.000000
v 0.019576 -0.044750 0.000372
v 0.019503 -0.044750 0.000738
v 0.000000 -0.044750 0.000000
v 0.019503 -0.044750 0.000738
v 0.019383 -0.044750 0.001091
v 0.000000 -0.044750 0.000000
v 0.019383 -0.044750 0.001091
v 0.019218 -0.044750 0.001425
v 0.000000 -0.044750 0.000000
v 0.019218 -0.044750 0.001425
v 0.019011 -0.044750 0.001735
v 0.000000 -0.044750 0.000000
v 0.019011 -0.044750 0.001735
v 0.018765 -0.044750 0.002015
v 0.000000 -0.044750 0.000000
v 0.018765 -0.044750 0.002015
v 0.018485 -0.044750 0.002261
v 0.000000 -0.044750 0.000000
v 0.018485 -0.044750 0.002261
v 0.018175 -0.044750 0.002468
v 0.000000 -0.044750 0.000000
v 0.018175 -0.044750 0.002468
v 0.017841 -0.044750 0.002633
v 0.000000 -0.044750 0.000000
v 0.017841 -0.044750 0.002633
v 0.017488 -0.044750 0.002753
v 0.000000 -0.044750 0.000000
v 0.017488 -0.044750 0.002753
v 0.017122 -0.044750 0.002826
v 0.000000 -0.044750 0.000000
v 0.017122 -0.044750 0.002826
v 0.016750 -0.044750 0.002850
v 0.000000 -0.044750 0.000000
v 0.016750 -0.044750 0.002850
v -0.016750 -0.044750 0.002850
v 0.000000 -0.044750 0.000000
v -0.016750 -0.044750 0.002850
v -0.017122 -0.044750 0.002826
v 0.000000 -0.044750 0.000000
v -0.017122 -0.044750 0.002826
v -0.017488 -0.044750 0.002753
v 0.000000 -0.044750 0.000000
v -0.017488 -0.044750 0.002753
v -0.017841 -0.044750 0.002633
v 0.000000 -0.044750 0.000000
v -0.017841 -0.044750 0.002633
v -0.018175 -0.044750 0.002468
v 0.000000 -0.044750 0.000000
v -0.018175 -0.044750 0.002468
v -0.018485 -0.044750 0.002261
v 0.000000 -0.044750 0.000000
v -0.018485 -0.044750 0.002261
v -0.018765 -0.044750 0.002015
v 0.000000 -0.044750 0.000000
v -0.018765 -0.044750 0.002015
v -0.019011 -0.044750 0.001735
v 0.000000 -0.044750 0.000000
v -0.019011 -0.044750 0.001735
v -0.019218 -0.044750 0.001425
v 0.000000 -0.044750 0.000000
v -0.019218 -0.044750 0.001425
v -0.019383 -0.044750 0.001091
v 0.000000 -0.044750 0.000000
v -0.019383 -0.044750 0.001091
v -0.019503 -0.044750 0.000738
v 0.000000 -0.044750 0.000000
v -0.019503 -0.044750 0.000738
v -0.019576 -0.044750 0.000372
v 0.000000 -0.044750 0.000000
v -0.019576 -0.044750 0.000372
v -0.019600 -0.044750 0.000000
v 0.000000 -0.044750 0.000000
v -0.019600 -0.044750 0.000000
v -0.019576 -0.044750 -0.000372
v 0.000000 -0.044750 0.000000
v -0.019576 -0.044750 -0.000372
v -0.019503 -0.044750 -0.000738
v 0.000000 -0.044750 0.000000
v -0.019503 -0.044750 -0.000738
v -0.019383 -0.044750 -0.001091
v 0.000000 -0.044750 0.000000
v -0.019383 -0.044750 -0.001091
v -0.019218 -0.044750 -0.001425
v 0.000000 -0.044750 0.000000
v -0.019218 -0.044750 -0.001425
v -0.019011 -0.044750 -0.001735
v 0.000000 -0.044750 0.000000
v -0.019011 -0.044750 -0.001735
v -0.018765 -0.044750 -0.002015
v 0.000000 -0.044750 0.000000
v -0.018765 -0.044750 -0.002015
v -0.018485 -0.044750 -0.002261
v 0.000000 -0.044750 0.000000
v -0.018485 -0.044750 -0.002261
v -0.018175 -0.044750 -0.002468
v 0.000000 -0.044750 0.000000
v -0.018175 -0.044750 -0.002468
v -0.017841 -0.044750 -0.002633
v 0.000000 -0.044750 0.000000
v -0.017841 -0.044750 -0.002633
v -0.017488 -0.044750 -0.002753
v 0.000000 -0.044750 0.000000
v -0.017488 -0.044750 -0.002753
v -0.017122 -0.044750 -0.002826
v 0.000000 -0.044750 0.000000
v -0.017122 -0.044750 -0.002826
v -0.016750 -0.044750 -0.002850
v 0.000000 -0.044750 0.000000
v -0.016750 -0.044750 -0.002850
v 0.016750 -0.044750 -0.002850
vt 0.500000 0.500000
vt 0.918750 0.938462
vt 0.928050 0.934710
vt 0.500000 0.500000
vt 0.928050 0.934710
vt 0.937191 0.923521
vt 0.500000 0.500000
vt 0.937191 0.923521
vt 0.946016 0.905086
vt 0.500000 0.500000
vt 0.946016 0.905086
vt 0.954375 0.879719
vt 0.500000 0.500000
vt 0.954375 0.879719
vt 0.962124 0.847855
vt 0.500000 0.500000
vt 0.962124 0.847855
vt 0.969131 0.810039
vt 0.500000 0.500000
vt 0.969131 0.810039
vt 0.975276 0.766918
vt 0.500000 0.500000
vt 0.975276 0.766918
vt 0.980454 0.719231
vt 0.500000 0.500000
vt 0.980454 0.719231
vt 0.984576 0.667792
vt 0.500000 0.500000
vt 0.984576 0.667792
vt 0.987572 0.613482
vt 0.500000 0.500000
vt 0.987572 0.613482
vt 0.989390 0.557231
vt 0.500000 0.500000
vt 0.989390 0.557231
vt 0.990000 0.500000
vt 0.500000 0.500000
vt 0.990000 0.500000
vt 0.989390 0.442769
vt 0.500000 0.500000
vt 0.989390 0.442769
vt 0.987572 0.386518
vt 0.500000 0.500000
vt 0.987572 0.386518
vt 0.984576 0.332208
vt 0.500000 0.500000
vt 0.984576 0.332208
vt 0.980454 0.280769
vt 0.500000 0.500000
vt 0.980454 0.280769
vt 0.975276 0.233082
vt 0.500000 0.500000
vt 0.975276 0.233082
vt 0.969131 0.189961
vt 0.500000 0.500000
vt 0.969131 0.189961
vt 0.962124 0.152145
vt 0.500000 0.500000
vt 0.962124 0.152145
vt 0.954375 0.120281
vt 0.500000 0.500000
vt 0.954375 0.120281
vt 0.946016 0.094914
vt 0.500000 0.500000
vt 0.946016 0.094914
vt 0.937191 0.076479
vt 0.500000 0.500000
vt 0.937191 0.076479
vt 0.928050 0.065290
vt 0.500000 0.500000
vt 0.928050 0.065290
vt 0.918750 0.061538
vt 0.500000 0.500000
vt 0.918750 0.061538
vt 0.081250 0.061538
vt 0.500000 0.500000
vt 0.081250 0.061538
vt 0.071950 0.065290
vt 0.500000 0.500000
vt 0.071950 0.065290
vt 0.062809 0.076479
vt 0.500000 0.500000
vt 0.062809 0.076479
vt 0.053984 0.094914
vt 0.500000 0.500000
vt 0.053984 0.094914
vt 0.045625 0.120281
vt 0.500000 0.500000
vt 0.045625 0.120281
vt 0.037876 0.152145
vt 0.500000 0.500000
vt 0.037876 0.152145
vt 0.030869 0.189961
vt 0.500000 0.500000
vt 0.030869 0.189961
vt 0.024724 0.233082
vt 0.500000 0.500000
vt 0.024724 0.233082
vt 0.019546 0.280769
vt 0.500000 0.500000
vt 0.019546 0.280769
vt 0.015424 0.332208
vt 0.500000 0.500000
vt 0.015424 0.332208
vt 0.012428 0.386518
vt 0.500000 0.500000
vt 0.012428 0.386518
vt 0.010610 0.442769
vt 0.500000 0.500000
vt 0.010610 0.442769
vt 0.010000 0.500000
vt 0.500000 0.500000
vt 0.010000 0.500000
vt 0.010610 0.557231
vt 0.500000 0.500000
vt 0.010610 0.557231
vt 0.012428 0.613482
vt 0.500000 0.500000
vt 0.012428 0.613482
vt 0.015424 0.667792
vt 0.500000 0.500000
vt 0.015424 0.667792
vt 0.019546 0.719231
vt 0.500000 0.500000
vt 0.019546 0.719231
vt 0.024724 0.766918
vt 0.500000 0.500000
vt 0.024724 0.766918
vt 0.030869 0.810039
vt 0.500000 0.500000
vt 0.030869 0.810039
vt 0.037876 0.847855
vt 0.500000 0.500000
vt 0.037876 0.847855
vt 0.045625 0.879719
vt 0.500000 0.500000
vt 0.045625 0.879719
vt 0.053984 0.905086
vt 0.500000 0.500000
vt 0.053984 0.905086
vt 0.062809 0.923521
vt 0.500000 0.500000
vt 0.062809 0.923521
vt 0.071950 0.934710
vt 0.500000 0.500000
vt 0.071950 0.934710
vt 0.081250 0.938462
vt 0.500000 0.500000
vt 0.081250 0.938462
vt 0.918750 0.938462
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
f 1/1/1 2/2/2 3/3/3
f 4/4/4 5/5/5 6/6/6
f 7/7/7 8/8/8 9/9/9
f 10/10/10 11/11/11 12/12/12
f 13/13/13 14/14/14 15/15/15
f 16/16/16 17/17/17 18/18/18
f 19/19/19 20/20/20 21/21/21
f 22/22/22 23/23/23 24/24/24
f 25/25/25 26/26/26 27/27/27
f 28/28/28 29/29/29 30/30/30
f 31/31/31 32/32/32 33/33/33
f 34/34/34 35/35/35 36/36/36
f 37/37/37 38/38/38 39/39/39
f 40/40/40 41/41/41 42/42/42
f 43/43/43 44/44/44 45/45/45
f 46/46/46 47/47/47 48/48/48
f 49/49/49 50/50/50 51/51/51
f 52/52/52 53/53/53 54/54/54
f 55/55/55 56/56/56 57/57/57
f 58/58/58 59/59/59 60/60/60
f 61/61/61 62/62/62 63/63/63
f 64/64/64 65/65/65 66/66/66
f 67/67/67 68/68/68 69/69/69
f 70/70/70 71/71/71 72/72/72
f 73/73/73 74/74/74 75/75/75
f 76/76/76 77/77/77 78/78/78
f 79/79/79 80/80/80 81/81/81
f 82/82/82 83/83/83 84/84/84
f 85/85/85 86/86/86 87/87/87
f 88/88/88 89/89/89 90/90/90
f 91/91/91 92/92/92 93/93/93
f 94/94/94 95/95/95 96/96/96
f 97/97/97 98/98/98 99/99/99
f 100/100/100 101/101/101 102/102/102
f 103/103/103 104/104/104 105/105/105
f 106/106/106 107/107/107 108/108/108
f 109/109/109 110/110/110 111/111/111
f 112/112/112 113/113/113 114/114/114
f 115/115/115 116/116/116 117/117/117
f 118/118/118 119/119/119 120/120/120
f 121/121/121 122/122/122 123/123/123
f 124/124/124 125/125/125 126/126/126
f 127/127/127 128/128/128 129/129/129
f 130/130/130 131/131/131 132/132/132
f 133/133/133 134/134/134 135/135/135
f 136/136/136 137/137/137 138/138/138
f 139/139/139 140/140/140 141/141/141
f 142/142/142 143/143/143 144/144/144
f 145/145/145 146/146/146 147/147/147
f 148/148/148 149/149/149 150/150/150
Binary file not shown.

After

Width:  |  Height:  |  Size: 109 B

@@ -0,0 +1,8 @@
newmtl nano_cap_top
Kd 1 1 1
Ns 64
d 1
illum 2
Ka 0 0 0
Ks 0.1 0.1 0.1
map_Kd nano_cap_top_color.png
File diff suppressed because it is too large. Load diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

@@ -0,0 +1,8 @@
newmtl nano_center_black
Kd 1 1 1
Ns 64
d 1
illum 2
Ka 0 0 0
Ks 0.3 0.3 0.3
map_Kd nano_center_black_color.png
File diff suppressed because it is too large. Load diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 110 B

@@ -0,0 +1,8 @@
newmtl nano_center_blue
Kd 1 1 1
Ns 64
d 1
illum 2
Ka 0 0 0
Ks 0.3 0.3 0.3
map_Kd nano_center_blue_color.png
File diff suppressed because it is too large. Load diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 110 B

@@ -0,0 +1,8 @@
newmtl nano_center_green
Kd 1 1 1
Ns 64
d 1
illum 2
Ka 0 0 0
Ks 0.3 0.3 0.3
map_Kd nano_center_green_color.png
File diff suppressed because it is too large. Load diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 110 B

@@ -0,0 +1,8 @@
newmtl nano_center_pink
Kd 1 1 1
Ns 64
d 1
illum 2
Ka 0 0 0
Ks 0.3 0.3 0.3
map_Kd nano_center_pink_color.png
File diff suppressed because it is too large. Load diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 110 B

@@ -0,0 +1,8 @@
newmtl nano_center_red
Kd 1 1 1
Ns 64
d 1
illum 2
Ka 0 0 0
Ks 0.3 0.3 0.3
map_Kd nano_center_red_color.png
File diff suppressed because it is too large. Load diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 111 B

@@ -0,0 +1,8 @@
newmtl nano_center_silver
Kd 1 1 1
Ns 64
d 1
illum 2
Ka 0 0 0
Ks 0.3 0.3 0.3
map_Kd nano_center_silver_color.png
File diff suppressed because it is too large. Load diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 111 B

@@ -0,0 +1,8 @@
newmtl nano_ports
Kd 1 1 1
Ns 256
d 1
illum 2
Ka 0 0 0
Ks 0.2 0.2 0.2
map_Kd nano_ports_color.png
File diff suppressed because it is too large. Load diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 127 B

Binary file not shown.
@@ -0,0 +1,8 @@
newmtl nano_shell_black
Kd 1 1 1
Ns 64
d 1
illum 2
Ka 0 0 0
Ks 0.3 0.3 0.3
map_Kd nano_shell_black_color.png
File diff suppressed because it is too large. Load diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 110 B

@@ -0,0 +1,8 @@
newmtl nano_shell_blue
Kd 1 1 1
Ns 64
d 1
illum 2
Ka 0 0 0
Ks 0.3 0.3 0.3
map_Kd nano_shell_blue_color.png
File diff suppressed because it is too large. Load diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 110 B

@@ -0,0 +1,8 @@
newmtl nano_shell_green
Kd 1 1 1
Ns 64
d 1
illum 2
Ka 0 0 0
Ks 0.3 0.3 0.3
map_Kd nano_shell_green_color.png
File diff suppressed because it is too large. Load diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 110 B

@@ -0,0 +1,8 @@
newmtl nano_shell_pink
Kd 1 1 1
Ns 64
d 1
illum 2
Ka 0 0 0
Ks 0.3 0.3 0.3
map_Kd nano_shell_pink_color.png
File diff suppressed because it is too large. Load diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 110 B

@@ -0,0 +1,8 @@
newmtl nano_shell_red
Kd 1 1 1
Ns 64
d 1
illum 2
Ka 0 0 0
Ks 0.3 0.3 0.3
map_Kd nano_shell_red_color.png
File diff suppressed because it is too large. Load diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 111 B

@@ -0,0 +1,8 @@
newmtl nano_shell_silver
Kd 1 1 1
Ns 64
d 1
illum 2
Ka 0 0 0
Ks 0.3 0.3 0.3
map_Kd nano_shell_silver_color.png
File diff suppressed because it is too large. Load diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 111 B

@@ -0,0 +1,8 @@
newmtl nano_well
Kd 1 1 1
Ns 256
d 1
illum 2
Ka 0 0 0
Ks 0 0 0
map_Kd nano_well_color.png
File diff suppressed because it is too large. Load diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 107 B

@@ -0,0 +1,8 @@
newmtl nano_wheel_black
Kd 1 1 1
Ns 64
d 1
illum 2
Ka 0 0 0
Ks 0.08 0.08 0.08
map_Kd nano_wheel_black_color.png
File diff suppressed because it is too large. Load diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

@@ -0,0 +1,8 @@
newmtl nano_wheel_white
Kd 1 1 1
Ns 64
d 1
illum 2
Ka 0 0 0
Ks 0.08 0.08 0.08
map_Kd nano_wheel_white_color.png
File diff suppressed because it is too large. Load diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

+43 -2
View File
@@ -4,6 +4,11 @@
//
// The static body is drawn once per finish into a layer; a frame blits it, then the screen and the
// wheel state. draw() returns false when nothing changed, so the page repaints only on change.
//
// The nano (models.js) has only its screen here, in its window, filling most of the canvas: podd or
// its render models draw the rest, and the page shows just the window's rect (windowRect('nano')).
import { MODELS } from './models.js';
export const BODY = { w: 61.8, h: 103.5, corner: 6.5 };
const LCD = { cy: 24.2, w: 50.8, h: 38.1 };
@@ -52,6 +57,41 @@ export function createFace(canvas, screen, opts = {}) {
const lcd = { x: X(-LCD.w / 2), y: Y(LCD.cy + LCD.h / 2), w: LCD.w * k, h: LCD.h * k };
const wc = { x: X(0), y: Y(WHEEL.cy) };
// the nano's window: 90% of the canvas's width, centred
const NANO = MODELS.nano;
const nk = W * 0.9 / NANO.win.w; // px per mm
const nanoWin = { w: NANO.win.w * nk, h: NANO.win.h * nk };
nanoWin.x = (W - nanoWin.w) / 2;
nanoWin.y = (H - nanoWin.h) / 2;
const nanoLcd = { w: NANO.lcd.w * nk, h: NANO.lcd.h * nk };
nanoLcd.x = (W - nanoLcd.w) / 2;
nanoLcd.y = (H - nanoLcd.h) / 2;
/** The model's screen window in this canvas (px): what the page shows over the window. */
function windowRect(model) {
if (model === 'nano') return { ...nanoWin };
return { x: X(-WINDOW.w / 2), y: Y(LCD.cy + WINDOW.h / 2), w: WINDOW.w * k, h: WINDOW.h * k };
}
// The nano: its LCD in a black window (rounded like the nano's), the backlight and the glass.
function drawNano(s) {
roundRect(g, nanoWin.x, nanoWin.y, nanoWin.w, nanoWin.h, 0.6 * nk);
g.fillStyle = '#000';
g.fill();
g.imageSmoothingEnabled = true;
g.imageSmoothingQuality = 'high';
g.drawImage(screen, nanoLcd.x, nanoLcd.y, nanoLcd.w, nanoLcd.h);
const veil = 1 - Math.max(0, Math.min(1, s.backlight));
if (veil > 0.001) {
g.fillStyle = `rgba(0,0,0,${veil})`;
g.fillRect(nanoLcd.x, nanoLcd.y, nanoLcd.w, nanoLcd.h);
}
const refl = g.createLinearGradient(nanoLcd.x, nanoLcd.y, nanoLcd.x + nanoLcd.w, nanoLcd.y + nanoLcd.h);
refl.addColorStop(0, 'rgba(255,255,255,0.07)');
refl.addColorStop(0.5, 'rgba(255,255,255,0)');
g.fillStyle = refl;
g.fillRect(nanoLcd.x, nanoLcd.y, nanoLcd.w, nanoLcd.h);
}
function bodyLayer(finish) {
let c = layers.get(finish);
@@ -204,12 +244,13 @@ export function createFace(canvas, screen, opts = {}) {
if (marker !== target) {
marker = target > marker ? Math.min(1, marker + dt / MARKER_IN) : Math.max(0, marker - dt / MARKER_OUT);
}
const key = [s.finish, Math.round(s.backlight * 100), s.pressed || '', marker > 0 ? Math.round(s.angle * 200) : -1,
const key = [s.model || 'video', s.finish, Math.round(s.backlight * 100), s.pressed || '', marker > 0 ? Math.round(s.angle * 200) : -1,
Math.round(marker * 20), s.reflVersion || 0, s.screenOnly ? 1 : 0, s.videoUnder ? 1 : 0].join('|');
if (!s.screenDirty && key === last) return false;
last = key;
const f = FINISHES[s.finish] || FINISHES.black;
g.clearRect(0, 0, W, H);
if (s.model === 'nano') { drawNano(s); return true; }
if (overlay) {
roundRect(g, X(-WINDOW.w / 2), Y(LCD.cy + WINDOW.h / 2), WINDOW.w * k, WINDOW.h * k, WINDOW.r * k);
g.fillStyle = '#000';
@@ -252,7 +293,7 @@ export function createFace(canvas, screen, opts = {}) {
return true;
}
return { draw, get animating() { return marker > 0 && marker < 1; } };
return { draw, windowRect, get animating() { return marker > 0 && marker < 1; } };
}
/** The mirror-polished steel back with the peach engraved in it (static: drawn once). Polished
+43 -14
View File
@@ -1,5 +1,6 @@
// assPod: a 5th-generation-style music player in your hand in the SteamVR menu, playing the VLC
// media library. This is its half inside SteamVR's systemui page (installed by the daemon as the
// media library. The main menu's assPod Model swaps the body for a 2nd-generation nano (models.js):
// the same OS, scaled onto its smaller screen, and no video. This is its half inside SteamVR's systemui page (installed by the daemon as the
// page script device/vr/systemui.asspod.js, built from these sources by tools/build_asspod.py);
// the daemon's plugin device/shell_ext/asspod.py runs VLC and reads the library.
//
@@ -12,8 +13,9 @@
// Controls: both sticks turn the click wheel; a stick click presses the wheel button under it
// (centred: the centre button). A is the centre button, B is the wheel's Menu, and the hamburger
// (menu) button puts the assPod away; so does closing the SteamVR menu. The right controller's
// buttons are read here from its model; the left one's (A and B in the same places) come from Steam
// through the plugin (btn()). Music keeps playing: the player keeps running here at a few Hz, and
// buttons are read here from its model, Steam's A / B / hamburger come through the plugin (btn())
// otherwise. Both sticks act alike: only their models are read (Steam's d-pad directions, which the
// left stick's tilts also send, are not relayed). Music keeps playing: the player keeps running here at a few Hz, and
// the plugin keeps VLC up while anything plays and stops it when the assPod is away and nothing plays.
//
// The plugin talks to this page only through the returned api: poll() drains the commands for VLC
@@ -33,6 +35,7 @@ import { createFace, createBack, BODY } from './face.js';
import { drawPeachSilhouette } from './brand/peach.js';
import { createReflector, pose, qmul, qrot, qconj } from './reflect.js';
import { createSceneGraph } from './sg.js';
import { MODELS, currentModel, currentFinish, bodyMetres } from './models.js';
const FACE_W = 476; // px; the body is 61.8 x 103.5 mm
const FACE_H = Math.round(FACE_W * BODY.h / BODY.w);
@@ -40,6 +43,10 @@ const FACE_WIDTH_M = 0.085; // shown about 1.4x the real body, fo
const BODY_M = { // the 3D body in metres, at that scale
w: FACE_WIDTH_M, h: FACE_WIDTH_M * BODY.h / BODY.w, d: FACE_WIDTH_M * 11 / BODY.w, r: FACE_WIDTH_M * BODY.corner / BODY.w,
};
const NANO_M = bodyMetres(MODELS.nano); // the nano, at its own scale
// a model's geometry for the scene graph (model metres)
const geoOf = (m) => ({ w: m.body.w / 1000, d: m.body.d / 1000, lcdCy: m.lcd.cy / 1000, lcdW: m.lcd.w / 1000,
winW: m.win.w / 1000, winH: m.win.h / 1000, wheelCy: m.wheel.cy / 1000 });
const BACK_W = 238; // px: the steel back's live reflection (half the face's resolution)
const BACK_H = Math.round(BACK_W * BODY.h / BODY.w);
const REFLECT_EVERY = 2; // frames between reflection updates (about 30 a second)
@@ -151,9 +158,15 @@ export default function install(ctx) {
library.onLoad(() => { art.clear(); os.ctx.popToRoot(); os.invalidate(); });
// the EQ follows the setting (VLC's preset by name)
settings.on('change', ({ key }) => { if (key === 'eq' || key === '*') send({ op: 'eq', name: settings.get('eq') }); });
// the body's colour: podd follows the finish in the poll; the render-model body is rebuilt
// The body: podd follows the model and its finish in the poll (one layout holds both); the scene
// graph is rebuilt for the other body's screen panel and place in the hand, or for the render
// models' colour. (Picking the nano stops a video: os/screens/main-menu.js.)
settings.on('change', ({ key }) => {
if ((key === 'finish' || key === '*') && st.open && !st.podd) st.holdPose = sg.show(showOpts(st.hand, st.world)) || st.holdPose;
if (!st.open) return;
if (key === 'model' || key === '*' || (!st.podd && (key === 'finish' || key === 'nanoFinish'))) {
if (key === 'model' && st.world) unpin(false);
st.holdPose = sg.show(showOpts(st.hand, st.world)) || st.holdPose;
}
});
// ---------------------------------------------------------------- state
@@ -246,11 +259,13 @@ export default function install(ctx) {
videoMode();
const screenDirty = os.render(t);
// the painted reflections only without podd (podd shades the glass and the steel itself)
if (!st.podd && st.frames % REFLECT_EVERY === 0 && reflector.update(st.hand, st.holdPose)) back.draw(reflector.back, reflector.version);
const model = currentModel(settings).id;
const painted = !st.podd && model === 'video'; // the nano's render models carry their own back
if (painted && st.frames % REFLECT_EVERY === 0 && reflector.update(st.hand, st.holdPose)) back.draw(reflector.back, reflector.version);
const ws = wheel.state;
face.draw({
reflection: !st.podd && reflector.version ? reflector.front : null, reflVersion: reflector.version, screenOnly: !!st.podd, videoUnder: !!st.videoMode,
finish: settings.get('finish'), backlight: os.backlight, screenDirty,
reflection: painted && reflector.version ? reflector.front : null, reflVersion: reflector.version, screenOnly: !!st.podd, videoUnder: !!st.videoMode,
model, finish: currentFinish(settings), backlight: os.backlight, screenDirty,
touching: ws.touching, angle: ws.angle, pressed: ws.pressedPart || (st.keys.select ? 'select' : st.keys.menu ? 'menu' : null), now: t,
});
} catch (err) { note(err); }
@@ -268,9 +283,15 @@ export default function install(ctx) {
if (player.state === 'playing') st.bgTimer = setTimeout(background, BACKGROUND_MS);
}
// Reposition's places are kept per body: the Video's under the hand's name, the nano's as nano-<hand>
const holdKey = (hand) => (currentModel(settings).id === 'video' ? hand : `${currentModel(settings).id}-${hand}`);
function showOpts(hand, world) {
return { hand, body: BODY_M, rects, modelDir: st.modelDir, finish: settings.get('finish'), podd: st.podd,
hold: st.holds[hand] || null, world: world || null, video: !!st.videoMode };
const m = currentModel(settings);
const wr = face.windowRect(m.id);
rects.win = { x: rects.face.x + wr.x, y: rects.face.y + wr.y, w: wr.w, h: wr.h };
return { hand, body: m.id === 'video' ? BODY_M : NANO_M, rects, modelDir: st.modelDir, model: m.id, geo: geoOf(m),
finish: currentFinish(settings), podd: st.podd, hold: st.holds[holdKey(hand)] || null, world: world || null, video: !!st.videoMode };
}
// A video on the screen with podd: podd shows its picture (the daemon's frames, full rate) in a panel
@@ -302,7 +323,7 @@ export default function install(ctx) {
const hp = pose('/user/hand/' + st.hand);
if (hp) {
const inv = qconj(hp.q);
st.holds[st.hand] = {
st.holds[holdKey(st.hand)] = {
rot: qmul(inv, st.world.rot),
centre: qrot(inv, st.world.centre.map((v, i) => v - hp.t[i])),
};
@@ -317,6 +338,7 @@ export default function install(ctx) {
return {
id: 'reposition',
title: 'Reposition',
adaptive: true,
keepAwake: true,
enter() { if (!pin()) note('reposition: no hand pose'); },
exit() { if (st.world) unpin(false); },
@@ -325,7 +347,7 @@ export default function install(ctx) {
if (e.type !== 'press') return e.type === 'release' ? false : undefined;
if (e.part === 'select') return done(() => unpin(true));
if (e.part === 'menu') return done(() => unpin(false));
if (e.part === 'play') return done(() => { st.holds[st.hand] = null; unpin(false); });
if (e.part === 'play') return done(() => { st.holds[holdKey(st.hand)] = null; unpin(false); });
return false;
},
draw(g, r) {
@@ -379,6 +401,10 @@ export default function install(ctx) {
for (const [p, t] of Object.entries(opts.resume)) if (Number.isFinite(t)) resume.set(p, t);
}
if (opts.holds && typeof opts.holds === 'object') st.holds = Object.assign({ left: null, right: null }, opts.holds);
// the body and colours picked last time (the plugin keeps them: device/shell_ext/asspod.py PREFS)
if (opts.prefs && typeof opts.prefs === 'object') {
for (const key of ['model', 'finish', 'nanoFinish']) if (typeof opts.prefs[key] === 'string') settings.set(key, opts.prefs[key]);
}
st.world = null;
st.videoMode = false;
art.videoExternal = false;
@@ -436,7 +462,8 @@ export default function install(ctx) {
function status() {
return {
ok: true, open: st.open, hand: st.hand, closedBy: st.closedBy, loading: st.loading,
hold: st.holdPose || null, bodyScale: BODY_M.w / 0.0618, finish: settings.get('finish'), podd: !!st.podd,
hold: st.holdPose || null, bodyScale: BODY_M.w / 0.0618, nanoScale: MODELS.nano.scale, model: currentModel(settings).id,
finish: currentFinish(settings), podd: !!st.podd,
player: { state: player.state, current: player.current ? { id: player.current.id, title: player.current.title } : null,
position: Math.round((player.position || 0) * 10) / 10, queue: player.queue.length, volume: player.volume },
library: { tracks: library.tracks.length, albums: library.albums.length, videos: library.videos.length },
@@ -452,6 +479,7 @@ export default function install(ctx) {
os.ctx.push({
id: 'notice',
title: String(title || 'assPod'),
adaptive: true,
handle(e) {
if (e.type === 'press' && (e.part === 'select' || e.part === 'menu')) { os.ctx.pop(); return true; }
return e.type === 'scroll' ? false : undefined;
@@ -493,7 +521,8 @@ export default function install(ctx) {
playing: player.state === 'playing', state: player.state,
current: player.current ? player.current.path : null,
volume: player.volume, eq: settings.get('eq'),
hold: st.holdPose || null, world: st.world, holds: st.holds, finish: settings.get('finish'),
hold: st.holdPose || null, world: st.world, holds: st.holds, model: currentModel(settings).id, finish: currentFinish(settings),
prefs: { model: currentModel(settings).id, finish: settings.get('finish'), nanoFinish: settings.get('nanoFinish') },
position: Math.round((player.position || 0) * 1000) / 1000,
video: player.current && player.current.kind === 'video' && os.ctx.top()?.id === 'videoplayer' && st.open,
};
+60
View File
@@ -0,0 +1,60 @@
// The assPod's bodies: the Video (5th generation, the original) and the nano (2nd generation). The
// main menu's assPod Model picks one; Color picks among that body's own finishes. Both run the same
// OS on a 4:3 screen; the nano's is 240 x 180 logical px against the Video's 320 x 240, so its text is
// larger on its screen, as on the real one. The nano plays no video: its menus leave the Videos out.
//
// Geometry in millimetres, model frame (tools/asspod-model, tools/nano2-model GEOMETRY): origin at
// the body's centre, y toward the top. `scale` is how much larger than life it is shown in the hand;
// podd (native/podd/podd.cpp VIDEO / NANO) and its render models use the same numbers. `screen` is
// the OS's logical screen on that body.
export const MODELS = Object.freeze({
video: Object.freeze({
id: 'video',
label: 'Video Gen 1',
about: 'assPod Video',
body: { w: 61.8, h: 103.5, d: 11.0 },
lcd: { cy: 24.2, w: 50.8, h: 38.1 },
win: { w: 53.2, h: 40.5 },
wheel: { cy: -24.0 },
screen: { w: 320, h: 240 }, // logical px (os/theme.js setScreenSize)
scale: 0.085 / 0.0618, // shown 85 mm wide
finishKey: 'finish',
finishes: [['black', 'Black'], ['white', 'White']],
videos: true,
}),
nano: Object.freeze({
id: 'nano',
label: 'Nano Gen 2',
about: 'assPod nano',
body: { w: 40.0, h: 90.0, d: 6.5 },
lcd: { cy: 28.88, w: 29.6, h: 22.2 },
win: { w: 31.62, h: 24.04 },
wheel: { cy: -13.3 },
screen: { w: 240, h: 180 }, // the same text on less screen: six rows, as on the real nano
scale: 1.3125, // shown 52.5 mm wide
finishKey: 'nanoFinish',
finishes: [['silver', 'Silver'], ['green', 'Green'], ['blue', 'Blue'], ['pink', 'Pink'], ['black', 'Black'], ['red', '(PRODUCT)RED']],
videos: false,
}),
});
export const MODEL_ORDER = ['video', 'nano'];
/** The current body's definition. */
export function currentModel(settings) {
return MODELS[settings.get('model')] || MODELS.video;
}
/** The current body's finish (one of its own; its first otherwise). */
export function currentFinish(settings) {
const m = currentModel(settings);
const f = settings.get(m.finishKey);
return m.finishes.some(([k]) => k === f) ? f : m.finishes[0][0];
}
/** The body in metres as shown in the hand: { w, h, d } at the model's scale. */
export function bodyMetres(m) {
const k = m.scale / 1000;
return { w: m.body.w * k, h: m.body.h * k, d: m.body.d * k };
}
+4
View File
@@ -66,7 +66,11 @@ export function createMenu(ctx, def) {
}
function refresh() {
// the highlight stays on its item when the list changes around it (the main menu without Videos)
const was = items[index];
items = resolve(def.items) ?? [];
const j = was ? items.indexOf(was) : -1;
if (j >= 0) index = j;
clampWindow();
}
+29 -9
View File
@@ -1,8 +1,10 @@
// assPod OS core: screen stack, 220 ms slide transitions, status bar, backlight timer and dimming,
// sleep/wake, the boot logo, the default button behavior, and render throttling for the screen
// texture. Draws the 320x240 logical LCD onto the 640x480 canvas that main.js uploads as a texture.
// texture. Draws the logical LCD (320x240 on the Video, 240x180 on the nano) onto the 640x480 canvas
// that main.js uploads as a texture.
import { SCREEN_W } from '../config.js';
import { currentModel } from '../models.js';
import { settings as defaultSettings } from '../settings.js';
import { drawPeachSilhouette } from '../brand/peach.js';
import * as theme from './theme.js';
@@ -11,7 +13,7 @@ import { createMenu } from './menu.js';
import { createMainMenu } from './screens/main-menu.js';
import { createNowPlaying } from './screens/now-playing.js';
const { M, C, ANIM } = theme;
const { M, C, ANIM, setScreenSize } = theme;
/**
* @typedef {import('../input/clickwheel.js').WheelEvent} WheelEvent
@@ -44,8 +46,9 @@ const { M, C, ANIM } = theme;
// ---------------------------------------------------------------- constants
const CONTENT = Object.freeze({ x: 0, y: M.statusH, w: M.W, h: M.H - M.statusH });
const FULL = Object.freeze({ x: 0, y: 0, w: M.W, h: M.H });
// the content area under the status bar, and the whole screen (the logical size can change: models.js)
const content = () => ({ x: 0, y: M.statusH, w: M.W, h: M.H - M.statusH });
const full = () => ({ x: 0, y: 0, w: M.W, h: M.H });
const FRAME_MS = 1000 / ANIM.maxFps;
// rAF timestamps jitter by a fraction of a ms; without slack a 25 ms cap would skip a frame at 80 Hz.
const FRAME_SLACK_MS = 1;
@@ -74,7 +77,12 @@ function normKey(s) {
*/
export function createOS({ canvas, player, library, art, apps, settings = defaultSettings, boot: bootEnabled = false }) {
const g = canvas.getContext('2d');
const scale = canvas.width / SCREEN_W;
// The body's logical screen (models.js): the canvas stays the same size and the scale follows.
const applyModel = () => { const m = currentModel(settings); setScreenSize(m.screen.w, m.screen.h); };
applyModel();
if (typeof settings.on === 'function') {
settings.on('change', ({ key }) => { if (key === 'model' || key === '*') { applyModel(); dirty = DIRTY_NOW; } });
}
/** @type {Screen[]} */
const stack = [];
@@ -360,8 +368,18 @@ export function createOS({ canvas, player, library, art, apps, settings = defaul
}
const reported = new WeakSet();
// Menus and the screens marked `adaptive` lay themselves out on the current logical screen; the
// rest (the games, the clock, Cover Flow...) are drawn for the Video's 320 x 240 and scaled to fit.
function drawScreen(s, r, now) {
try {
if (!s.isMenu && !s.adaptive && M.W !== SCREEN_W) {
const k = M.W / SCREEN_W;
g.save();
g.scale(k, k);
s.draw(g, { x: r.x / k, y: r.y / k, w: r.w / k, h: r.h / k }, now);
g.restore();
return;
}
s.draw(g, r, now);
} catch (err) {
// One broken screen must not take the whole OS down every frame; report it once.
@@ -373,12 +391,13 @@ export function createOS({ canvas, player, library, art, apps, settings = defaul
if (dx <= -M.W || dx >= M.W) return;
g.save();
g.beginPath();
g.rect(CONTENT.x, CONTENT.y, CONTENT.w, CONTENT.h);
const r = content();
g.rect(r.x, r.y, r.w, r.h);
g.clip();
g.translate(dx, 0);
g.fillStyle = C.bg;
g.fillRect(CONTENT.x, CONTENT.y, CONTENT.w, CONTENT.h);
drawScreen(s, CONTENT, now);
g.fillRect(r.x, r.y, r.w, r.h);
drawScreen(s, r, now);
g.restore();
}
@@ -397,7 +416,7 @@ export function createOS({ canvas, player, library, art, apps, settings = defaul
g.fillStyle = '#000000';
g.fillRect(0, 0, M.W, M.H);
}
drawScreen(s, FULL, now);
drawScreen(s, full(), now);
g.restore();
return;
}
@@ -409,6 +428,7 @@ export function createOS({ canvas, player, library, art, apps, settings = defaul
}
function paint(now) {
const scale = canvas.width / M.W;
g.setTransform(scale, 0, 0, scale, 0, 0);
g.globalAlpha = 1;
g.globalCompositeOperation = 'source-over';
+70 -14
View File
@@ -4,6 +4,7 @@
import { createRng, hashString } from '../../util/rng.js';
import { createSettings } from './settings.js';
import { createSearch } from './search.js';
import { MODELS, MODEL_ORDER, currentModel, currentFinish } from '../../models.js';
// ---------------------------------------------------------------- sorting
@@ -32,7 +33,9 @@ function albumOrder(library) {
* A list of songs (or videos, in playlists). Selecting row i plays the whole list from i,
* the way the assPod queues whatever list you picked from, then opens Now Playing.
*/
export function songList(ctx, id, title, list) {
export function songList(ctx, id, title, all) {
// the nano plays no video: a playlist's videos are left out on it
const list = currentModel(ctx.settings).videos ? all : all.filter((t) => t.kind !== 'video');
const items = list.map((t, i) => ({
label: t.title,
speaker: () => ctx.player.current?.id === t.id && ctx.player.state !== 'stopped',
@@ -170,30 +173,83 @@ function shuffleSongs(ctx) {
return true;
}
// ---------------------------------------------------------------- the body: model and colour
/** assPod Model: the Video or the nano, checkmarked; picking one switches the body and goes back.
* The nano plays no video: a video playing stops when it is picked. */
function modelMenu(ctx) {
return ctx.menu({
id: 'model',
title: 'assPod Model',
initialIndex: () => Math.max(0, MODEL_ORDER.indexOf(currentModel(ctx.settings).id)),
items: MODEL_ORDER.map((id) => ({
label: MODELS[id].label,
check: () => currentModel(ctx.settings).id === id,
action: (c) => {
if (!MODELS[id].videos && c.player.current?.kind === 'video') c.player.stop();
c.settings.set('model', id);
c.pop();
return true;
},
})),
});
}
/** Color: the current body's own finishes, checkmarked. */
function colorMenu(ctx) {
const m = currentModel(ctx.settings);
return ctx.menu({
id: 'color',
title: 'Color',
initialIndex: () => Math.max(0, m.finishes.findIndex(([k]) => k === currentFinish(ctx.settings))),
items: m.finishes.map(([key, label]) => ({
label,
check: () => currentFinish(ctx.settings) === key,
action: (c) => {
c.settings.set(m.finishKey, key);
c.pop();
return true;
},
})),
});
}
// ---------------------------------------------------------------- main menu
export function createMainMenu(ctx) {
const base = [
const finishLabel = () => {
const m = currentModel(ctx.settings);
const f = currentFinish(ctx.settings);
return (m.finishes.find(([k]) => k === f) || m.finishes[0])[1];
};
const all = [
{ id: 'music', label: 'Music', open: createMusicMenu },
{ id: 'videos', label: 'Videos', open: (c) => c.apps.videos(c) },
{ id: 'extras', label: 'Extras', open: (c) => c.apps.extras(c) },
{ id: 'settings', label: 'Settings', open: createSettings },
{ id: 'reposition', label: 'Reposition', open: (c) => c.apps.reposition(c) },
{
id: 'color',
label: 'Color',
detail: () => (ctx.settings.get('finish') === 'white' ? 'White' : 'Black'),
action: (c) => { c.settings.set('finish', c.settings.get('finish') === 'white' ? 'black' : 'white'); return true; },
},
{ id: 'model', label: 'assPod Model', detail: () => currentModel(ctx.settings).label, open: modelMenu },
{ id: 'color', label: 'Color', detail: finishLabel, open: colorMenu },
{ id: 'shuffle', label: 'Shuffle Songs', action: shuffleSongs },
{ id: 'backlight', label: 'Backlight', action: (c) => { c.os.toggleBacklight(); return false; } },
];
const withNowPlaying = [
...base,
{ id: 'nowplaying', label: 'Now Playing', chevron: true, action: (c) => { c.nowPlaying(); return true; } },
];
const nowPlaying = { id: 'nowplaying', label: 'Now Playing', chevron: true, action: (c) => { c.nowPlaying(); return true; } };
// the same item objects every time, so the menu keeps its place across a model switch
const lists = new Map();
return ctx.menu({
id: 'main',
title: 'assPod',
// Like the 5G, Now Playing is listed only while something is queued (not after the queue ends).
items: () => (ctx.player.current && ctx.player.state !== 'stopped' ? withNowPlaying : base),
// Like the 5G, Now Playing is listed only while something is queued (not after the queue ends);
// the nano has no Videos.
items: () => {
const videos = currentModel(ctx.settings).videos;
const playing = !!(ctx.player.current && ctx.player.state !== 'stopped');
const key = `${videos}|${playing}`;
if (!lists.has(key)) {
const base = videos ? all : all.filter((it) => it.id !== 'videos');
lists.set(key, playing ? [...base, nowPlaying] : base);
}
return lists.get(key);
},
});
}
+33 -11
View File
@@ -2,23 +2,40 @@
// artist / album on the right, and a progress bar with elapsed and remaining time.
// Center click cycles progress -> scrubber -> rating; the wheel sets volume, seeks or rates.
import { SCREEN_SCALE } from '../../config.js';
import { SCREEN_W, SCREEN_SCALE } from '../../config.js';
import { M } from '../theme.js';
// ---------------------------------------------------------------- layout
const POS = { x: 10, y: 40 };
const ART = { x: 10, y: 48, s: 112 };
const TEXT = { x: 132, w: 180, title: 82, artist: 102, album: 122 };
const BAR = { x: 10, y: 182, w: 300, h: 10 };
const TIME_Y = 208;
const STARS = { cx: 160, cy: 187 };
const VOLUME = { x: 10, y: 180, w: 300 };
// The Video's 320 x 240 layout, and the nano's on its 240 x 180 screen (the same text, less room:
// smaller art and a shorter bar). relayout() picks the one for the current logical screen.
const LAYOUTS = {
320: {
POS: { x: 10, y: 40 }, ART: { x: 10, y: 48, s: 112 }, TEXT: { x: 132, w: 180, title: 82, artist: 102, album: 122 },
BAR: { x: 10, y: 182, w: 300, h: 10 }, TIME_Y: 208, STARS: { cx: 160, cy: 187 }, VOLUME: { x: 10, y: 180, w: 300 },
GLYPH: { right: 310, w: 12, h: 9, gap: 5 },
},
240: {
POS: { x: 8, y: 38 }, ART: { x: 8, y: 46, s: 80 }, TEXT: { x: 96, w: 136, title: 66, artist: 84, album: 102 },
BAR: { x: 8, y: 136, w: 224, h: 9 }, TIME_Y: 160, STARS: { cx: 120, cy: 141 }, VOLUME: { x: 8, y: 134, w: 224 },
GLYPH: { right: 232, w: 12, h: 9, gap: 5 },
},
};
let POS, ART, TEXT, BAR, TIME_Y, STARS, VOLUME, GLYPH, ART_PX;
let laidOut = 0;
function relayout() {
if (laidOut === M.W) return;
laidOut = M.W;
({ POS, ART, TEXT, BAR, TIME_Y, STARS, VOLUME, GLYPH } = LAYOUTS[M.W] || LAYOUTS[320]);
// the cover is resampled to the art box's backing-store size (the canvas is 640 px wide)
ART_PX = Math.round(ART.s * SCREEN_W * SCREEN_SCALE / M.W);
}
relayout();
const VOLUME_STEPS = 16;
const SCRUB_STEP_S = 2;
const SCRUB_FAST_MS = 120; // steps closer than this count as "scrolling fast"
const MODES = ['progress', 'scrub', 'rating'];
const GLYPH = { right: 310, w: 12, h: 9, gap: 5 }; // shuffle / repeat marks, right of "n of N"
/** Grey square with a beamed note, for items without cover art. */
function drawPlaceholderArt(g, x, y, s) {
@@ -94,14 +111,14 @@ function drawRepeatGlyph(g, x, y, color, one, oneFont) {
// The 256px cover is resampled once per album to the art box's backing-store size (224px), so
// each 4 Hz (or 40 fps marquee) redraw is a 1:1 blit instead of a high-quality downscale.
const ART_PX = ART.s * SCREEN_SCALE;
let artKey = null;
let artPx = 0;
let artSrc = null;
let artCanvas = null;
function scaledCover(src, key) {
// The source canvas counts too: art.clear() redraws covers (late web fonts) as new canvases.
if (key === artKey && src === artSrc && artCanvas) return artCanvas;
if (key === artKey && src === artSrc && artCanvas && artPx === ART_PX) return artCanvas;
if (typeof document === 'undefined') return src;
artCanvas ??= document.createElement('canvas');
artCanvas.width = artCanvas.height = ART_PX;
@@ -111,6 +128,7 @@ function scaledCover(src, key) {
g.drawImage(src, 0, 0, ART_PX, ART_PX);
artKey = key;
artSrc = src;
artPx = ART_PX;
return artCanvas;
}
@@ -157,6 +175,7 @@ export function createNowPlaying(ctx) {
const screen = {
id: 'nowplaying',
adaptive: true,
title: 'Now Playing',
// Only a moving playhead needs the 4 Hz refresh; paused, the screen is static (no uploads).
get refreshMs() { return player.state === 'playing' || player.seeking ? 250 : 0; },
@@ -176,6 +195,7 @@ export function createNowPlaying(ctx) {
},
handle(e) {
relayout();
const now = ctx.now();
const cur = player.current;
if (e.type === 'press' && e.part === 'select') {
@@ -216,6 +236,7 @@ export function createNowPlaying(ctx) {
},
draw(g, r, now) {
relayout();
g.fillStyle = C.bg;
g.fillRect(r.x, r.y, r.w, r.h);
const cur = player.current;
@@ -310,6 +331,7 @@ export function createNowPlaying(ctx) {
},
animating(now) {
relayout();
// One redraw when the volume overlay or a scrubber/rating mode times out, even when paused.
if (volumeUntil && drawnAt < volumeUntil && now >= volumeUntil) return true;
const modeEnd = modeAt + ANIM.npModeTimeoutMs;
+9 -6
View File
@@ -11,7 +11,6 @@ import { songList, albumSongs } from './main-menu.js';
const KEYS = [...'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789', ' ', 'DEL', 'DONE'];
const STRIP_H = 44; // letter band at the bottom of the content area
const RESULT_ROWS = 7; // 7 x 24 px rows above the band
const KEY_W = 22;
function keyLabel(k) {
@@ -26,6 +25,8 @@ export function createSearch(ctx) {
const keyFont = font(15, 600);
const smallKeyFont = font(10, 700);
// result rows above the letter band: 7 on the Video, 4 on the nano
const resultRows = () => Math.floor((M.H - M.statusH - STRIP_H) / M.rowH);
let query = '';
let keyIndex = 0;
let inResults = false;
@@ -92,6 +93,7 @@ export function createSearch(ctx) {
return {
id: 'search',
title: 'Search',
adaptive: true, // lays itself out on the current logical screen (os.js)
enter() { runSearch(); },
handle(e) {
if (e.type === 'scroll') {
@@ -101,7 +103,7 @@ export function createSearch(ctx) {
if (next >= results.length) return false;
index = next;
if (index < first) first = index;
if (index >= first + RESULT_ROWS) first = index - RESULT_ROWS + 1;
if (index >= first + resultRows()) first = index - resultRows() + 1;
return true;
}
const next = keyIndex + e.delta;
@@ -127,10 +129,11 @@ export function createSearch(ctx) {
draw(g, r, now) {
g.fillStyle = C.bg;
g.fillRect(r.x, r.y, r.w, r.h);
const listH = RESULT_ROWS * M.rowH;
const scroll = results.length > RESULT_ROWS;
const rows = resultRows();
const listH = rows * M.rowH;
const scroll = results.length > rows;
const w = scroll ? M.W - M.scrollbarW : M.W;
for (let i = first; i < Math.min(results.length, first + RESULT_ROWS); i++) {
for (let i = first; i < Math.min(results.length, first + rows); i++) {
const res = results[i];
row.label = res.label;
row.detail = res.kind;
@@ -145,7 +148,7 @@ export function createSearch(ctx) {
g.fillText('No results', M.W / 2, r.y + 60);
g.restore();
}
if (scroll) ui.drawScrollbar(g, M.W - M.scrollbarW, r.y, listH, results.length, RESULT_ROWS, first);
if (scroll) ui.drawScrollbar(g, M.W - M.scrollbarW, r.y, listH, results.length, rows, first);
drawStrip(g, r.y + r.h - STRIP_H);
},
highlight() {
+7 -4
View File
@@ -1,7 +1,9 @@
// Settings, following the 5G: values cycle in place (Shuffle, Repeat, Clicker),
// option lists show a checkmark and return to Settings when one is picked (Backlight Timer, EQ),
// Brightness has its own slider screen, plus About, Date & Time, Legal and Reset Settings.
// EQ is VLC's own equalizer presets. (The body's colour is on the main menu: Color.)
// EQ is VLC's own equalizer presets. (The body and its colour are on the main menu: assPod Model, Color.)
import { currentModel } from '../../models.js';
// ---------------------------------------------------------------- option tables
@@ -132,22 +134,23 @@ function aboutScreen(ctx) {
const videos = lib.videos?.length ?? 0;
const albums = lib.albums?.length ?? 0;
const secs = (list) => (list ?? []).reduce((s, t) => s + (t.duration || 0), 0);
const hours = (secs(lib.tracks) + secs(lib.videos)) / 3600;
const hours = (secs(lib.tracks) + (currentModel(ctx.settings).videos ? secs(lib.videos) : 0)) / 3600;
const info = lib.info || {};
const lines = [
['Songs', songs.toLocaleString('en-US')],
['Albums', albums.toLocaleString('en-US')],
['Videos', videos.toLocaleString('en-US')],
...(currentModel(ctx.settings).videos ? [['Videos', videos.toLocaleString('en-US')]] : []),
['Playing Time', `${hours.toFixed(1)} h`],
['Sources', String((info.sources || []).length)],
['Player', 'VLC'],
['Model', 'assPod Video'],
['Model', currentModel(ctx.settings).about],
];
const total = lines.length + 1; // + the name line
let first = 0;
return {
id: 'about',
title: 'About',
adaptive: true,
handle(e) {
if (e.type === 'scroll') {
const next = Math.max(0, Math.min(total - M.rows, first + e.delta));
+12 -1
View File
@@ -19,7 +19,7 @@ export const FONTS = {
};
export const M = {
W: SCREEN_W, H: SCREEN_H,
W: SCREEN_W, H: SCREEN_H, // the logical screen: setScreenSize() switches it (the nano's is smaller)
statusH: 22, // includes the 1px bottom border; content starts at y=22
rowH: 24, rows: 9, // list area y=22..238 (2px white margin at the bottom)
padX: 8, // label left inset
@@ -28,6 +28,17 @@ export const M = {
scrollbarW: 8, // at x=312..320 when a list overflows; rows then end at 312
};
/**
* The logical screen, in the theme's own px: the Video's 320 x 240, or the nano's 240 x 180
* (models.js). Text and rows keep their size, so on the nano they fill more of the screen (six rows,
* as on the real one); the list's row count follows.
*/
export function setScreenSize(w, h) {
M.W = w;
M.H = h;
M.rows = Math.floor((h - M.statusH - 2) / M.rowH);
}
export const C = {
bg: '#ffffff', text: '#000000', textHi: '#ffffff', textDim: '#6b6b6b',
status: [[0, '#fdfdfd'], [0.5, '#e4e4e4'], [1, '#c3c3c3']],
+3 -3
View File
@@ -171,7 +171,7 @@ function drawBattery(g, x, y) {
g.lineWidth = 1;
roundRectPath(g, 0.5, 0.5, 23, 10, 1.5);
g.stroke();
// Terminal nub, 2x5 at x = 311.
// Terminal nub, 2x5 at the body's right.
g.fillStyle = C.batteryBorder;
g.fillRect(24, 3, 2, 5);
g.restore();
@@ -189,7 +189,7 @@ export function drawStatusBar(g, s) {
g.fillStyle = C.statusLine;
g.fillRect(0, M.statusH - 1, M.W, 1);
const title = ellipsize(g, s.title ?? '', FONTS.title, 220);
const title = ellipsize(g, s.title ?? '', FONTS.title, M.W - 100);
g.font = FONTS.title;
g.textAlign = 'center';
g.textBaseline = 'alphabetic';
@@ -200,7 +200,7 @@ export function drawStatusBar(g, s) {
if (s.play === 'playing' || s.play === 'paused') drawPlayIcon(g, 8, ICON_Y, s.play);
if (s.locked) drawLock(g, 22, 5);
drawBattery(g, 287, ICON_Y);
drawBattery(g, M.W - 33, ICON_Y);
g.restore();
}
+6 -2
View File
@@ -1,5 +1,7 @@
// The assPod's settings, for this session only: kept in memory in SteamVR's page (never written to
// disk, like the rest of LiquidAss), so a SteamVR restart starts from the defaults.
// disk, like the rest of LiquidAss), so a SteamVR restart starts from the defaults. The exception, by
// request: the body and its colours (model, finish, nanoFinish), which the plugin keeps across
// launches (device/shell_ext/asspod.py PREFS) and hands back at each open.
import { Emitter } from './util/emitter.js';
export const DEFAULTS = Object.freeze({
@@ -11,7 +13,9 @@ export const DEFAULTS = Object.freeze({
clicker: true,
volume: 0.6, // 0..1
timeFormat: '12', // '12' | '24'
finish: 'black', // 'black' | 'white': the body
model: 'video', // 'video' | 'nano': the body (models.js)
finish: 'black', // the Video's colour: 'black' | 'white'
nanoFinish: 'silver', // the nano's: 'silver' | 'green' | 'blue' | 'pink' | 'black' | 'red'
ratings: {}, // trackId -> 0..5 (always replace the object, never mutate it)
plays: {}, // trackId -> [playCount, lastPlayed]
});
+50 -26
View File
@@ -1,5 +1,6 @@
// The assPod's nodes in SteamVR's scene graph (systemui page): the assPod in one hand (the original
// 3D model as SteamVR render models, device/asspod/model, with the live screen on a panel), and
// The assPod's nodes in SteamVR's scene graph (systemui page): the assPod in one hand (its 3D model,
// the Video or the nano, as SteamVR render models from device/asspod/model, or drawn by podd, with
// the live screen on a panel), and
// while it is out an invisible laser blocker where the menu was, with "(menu glyph) to close".
//
// vsg-transform parent-path=/user/hand/<side> the controller's pose
@@ -26,12 +27,12 @@ const MAIN = 'system.standalone::valve.steam.gamepadui.main';
// The sheet, in the window's frame units (the window is 2.67 x 1.5 above its bottom centre; the bar
// hangs below it): from under the bar to just over the top, a little wider than the window.
const SHEET = { w: 3.2, h: 2.25, y: 0.55, z: 0.06 }; // the blocker
const MODEL_W = 0.0618; // m: the model's own width (tools/asspod-model, GEOMETRY.BODY)
// The Video's geometry (model metres, tools/asspod-model GEOMETRY), the default when show() gets none.
const VIDEO_GEO = { w: 0.0618, d: 0.0110, lcdCy: 0.0242, lcdW: 0.0508, winW: 0.0532, winH: 0.0405, wheelCy: -0.0240 };
const VIDEO_SCALE = 0.085 / 0.0618; // as shown (main.js FACE_WIDTH_M): DEFAULT_HOLD is for this body
const OVERLAY_DZ = 0.00025; // m (model units) in front of the plate: the screen panel (render-model body)
const SCREEN_RECESS = 0.0004; // m (model units) behind the plate's surface: the screen under podd's glass
const VIDEO_BELOW = 0.0001; // m (model units) behind the screen panel: podd's video picture
const LCD_M = { cy: 0.0242, w: 0.0508 }; // the LCD on the model (face.js LCD, podd.cpp LCD_CY)
const WIN_M = { w: 0.0532, h: 0.0405 }; // its window (face.js WINDOW, podd.cpp WIN)
const CAPTION = { w: 1.0, y: 0.75, z: 0.075 };
// The body in the hand (controller frame: x right, y up, -z forward): its rotation (w x y z) and
// centre for the right hand, as placed with Reposition; the left hand mirrors it (defaultHold).
@@ -196,24 +197,22 @@ export function createSceneGraph() {
// stereoscopy: the left half of its rect to the left eye). podd renders each eye's view of the
// model through each face; the faces write no depth, so the screen inside shows through podd's
// glass. The faces match podd's (native/podd/podd.cpp makeLayout): centre, right, up, normal.
function poddBody(parent, b, rects, pd, video) {
const k = b.w / MODEL_W;
// The screen panel is the window only, centred on it like the video panel: SteamVR orders
// see-through panels by their centres, so two panels on one axis keep their order from any angle
// (offset centres swapped it head on, and the screen hid the video).
const ppm = rects.face.w / b.w;
const ww = WIN_M.w * k * ppm, wh = WIN_M.h * k * ppm;
const win = { x: rects.face.x + rects.face.w / 2 - ww / 2, y: rects.face.y + rects.face.h / 2 - LCD_M.cy * k * ppm - wh / 2, w: ww, h: wh };
function poddBody(parent, b, rects, pd, video, geo) {
const k = b.w / geo.w;
// The screen panel is the window only (rects.win: where face.js draws it), centred on it like the
// video panel: SteamVR orders see-through panels by their centres, so two panels on one axis keep
// their order from any angle (offset centres swapped it head on, and the screen hid the video).
const win = rects.win;
if (video && Array.isArray(pd.video)) {
// the video's picture (podd's video tile, the frame twice: one per eye) just under the screen,
// which is clear over it but for the overlays; first, so the screen blends over it
const [vx, vy, vw, vh] = pd.video;
const pic = vxf([0, LCD_M.cy * k, b.d / 2 - (SCREEN_RECESS + VIDEO_BELOW) * k]);
const pic = vxf([0, geo.lcdCy * k, b.d / 2 - (SCREEN_RECESS + VIDEO_BELOW) * k]);
pic.appendChild(vnode('panel', {
key: pd.key,
uv_min: [r6(vx / pd.texW), r6(vy / pd.texH)],
uv_max: [r6((vx + 2 * vw) / pd.texW), r6((vy + vh) / pd.texH)],
'meters-per-pixel': r6(LCD_M.w * k / vw),
'meters-per-pixel': r6(geo.lcdW * k / vw),
origin: [0, 0],
interactive: false,
visibility: 0,
@@ -224,8 +223,8 @@ export function createSceneGraph() {
parent.appendChild(pic);
}
// recessed in the plate's opening, under podd's glass (which sits on the plate's surface)
const front = vxf([0, LCD_M.cy * k, b.d / 2 - SCREEN_RECESS * k]);
front.appendChild(pagePanel(win, b.w / rects.face.w, 'lgs-asspod-screen'));
const front = vxf([0, geo.lcdCy * k, b.d / 2 - SCREEN_RECESS * k]);
front.appendChild(pagePanel(win, geo.winW * k / win.w, 'lgs-asspod-screen'));
parent.appendChild(front);
const [hx, hy, hz] = pd.box;
const faces = {
@@ -262,8 +261,23 @@ export function createSceneGraph() {
// The body without podd: the model's parts as SteamVR render models (fixed lighting), the screen
// panel just in front of the plate.
function body(parent, b, rects, modelDir, finish) {
const k = b.w / MODEL_W;
function body(parent, b, rects, modelDir, finish, model, geo) {
const k = b.w / geo.w;
if (model === 'nano') {
// the nano's parts: its colour's aluminum, the shared caps, ports and well; the screen panel
// over its window
const scaled = vxf([0, 0, 0]);
scaled.setAttribute('scale', [k, k, k].map(r6).join(' '));
const wheel = finish === 'black' ? 'black' : 'white';
const parts = [`nano_shell_${finish}`, `nano_back_${finish}`, `nano_center_${finish}`, `nano_wheel_${wheel}`,
'nano_cap_top', 'nano_cap_bottom', 'nano_ports', 'nano_well'];
for (const name of parts) scaled.appendChild(vnode('rendermodel', { source: `${modelDir}/${name}/${name}.obj` }));
parent.appendChild(scaled);
const front = vxf([0, geo.lcdCy * k, b.d / 2 + OVERLAY_DZ * k]);
front.appendChild(pagePanel(rects.win, geo.winW * k / rects.win.w, 'lgs-asspod-screen'));
parent.appendChild(front);
return;
}
const fin = finish === 'white' ? 'white' : 'black';
const scaled = vxf([0, 0, 0]);
scaled.setAttribute('scale', [k, k, k].map(r6).join(' '));
@@ -281,15 +295,24 @@ export function createSceneGraph() {
// The default place in the hand: where you put it with Reposition (2026-10-09, right hand), and
// its mirror image across the controller's centre line for the left hand.
function defaultHold(side) {
// Another body keeps its wheel where the Video's is (over the stick), front faces level.
function defaultHold(side, b, geo) {
const r = DEFAULT_HOLD;
if (side === 'right') return { rot: r.rot.slice(), centre: r.centre.slice() };
return { rot: [r.rot[0], r.rot[1], -r.rot[2], -r.rot[3]], centre: [-r.centre[0], r.centre[1], r.centre[2]] };
const h = side === 'right'
? { rot: r.rot.slice(), centre: r.centre.slice() }
: { rot: [r.rot[0], r.rot[1], -r.rot[2], -r.rot[3]], centre: [-r.centre[0], r.centre[1], r.centre[2]] };
const k = b.w / geo.w;
const wheelV = [0, VIDEO_GEO.wheelCy * VIDEO_SCALE, VIDEO_GEO.d * VIDEO_SCALE / 2];
const wheelM = [0, geo.wheelCy * k, b.d / 2];
const d = qrot(h.rot, wheelV.map((v, i) => v - wheelM[i]));
h.centre = h.centre.map((v, i) => v + d[i]);
return h;
}
/**
* Shows the assPod in `hand` ('left' | 'right') with the sheet over the menu.
* @param {{ hand: string, body: {w,h,d,r}, rects: {face, back, dim, text}, modelDir: string, finish: string,
* @param {{ hand: string, body: {w,h,d,r}, rects: {face, back, win, dim, text}, modelDir: string, finish: string,
* model?: 'video'|'nano', geo?: {w, d, lcdCy, lcdW, winW, winH, wheelCy} (model metres),
* podd?: { key, texW, texH, box, tiles }, hold?: { rot, centre }, world?: { rot, centre } }} o
* podd: its layout (podd-out.json) when podd draws the body; hold: a place in the hand other than
* the default; world: pinned in the room (standing space) instead of on the hand
@@ -300,14 +323,15 @@ export function createSceneGraph() {
if (!attach()) return null;
clearNodes();
const side = o.hand === 'left' ? 'left' : 'right';
const { rot, centre } = o.hold && o.hold.rot && o.hold.centre ? o.hold : defaultHold(side);
const geo = o.geo || VIDEO_GEO;
const { rot, centre } = o.hold && o.hold.rot && o.hold.centre ? o.hold : defaultHold(side, o.body, geo);
// pinned in the room (Reposition: the controller moves, the assPod stays), else on the hand
const hand = o.world
? vxf(o.world.centre, o.world.rot)
: vxf([0, 0, 0], null, { 'parent-path': '/user/hand/' + side });
const hold = o.world ? vxf([0, 0, 0]) : vxf(centre, rot);
if (o.podd && o.podd.tiles && Array.isArray(o.podd.box)) poddBody(hold, o.body, o.rects, o.podd, o.video);
else body(hold, o.body, o.rects, o.modelDir, o.finish);
if (o.podd && o.podd.tiles && Array.isArray(o.podd.box)) poddBody(hold, o.body, o.rects, o.podd, o.video, geo);
else body(hold, o.body, o.rects, o.modelDir, o.finish, o.model || 'video', geo);
hand.appendChild(hold);
st.root.appendChild(hand);
// the sheet and the caption, on Steam's window
+77 -7
View File
@@ -6,7 +6,7 @@ dashboard, bar, popups, keyboard, toasts) on or off. The theme is injected
into the running Steam client over its local devtools socket and is never
written anywhere persistent: restarting Steam or rebooting restores stock.
usage: lgs [toggle|on|off|reload|status|dial 0..1|toast TEXT|flags ...|check]
usage: lgs [toggle|on|off|reload|status|pod|dial 0..1|toast TEXT|flags ...|check]
[--quiet] [--native|--css] [--flags a,b=v] [--no-rt]
toggle on if off, off if on (what the "+ > Launch Program" entry runs)
@@ -23,6 +23,12 @@ usage: lgs [toggle|on|off|reload|status|dial 0..1|toast TEXT|flags ...|check]
reload re-read theme/*.css and the runtime and swap them in without a toast
status print what is applied, module states, and any theme tokens that
did not resolve
pod take out the assPod (what "+ > Launch Program > AssPod" runs). With
the theme on, the bar's music note does the same. With it off, only
the assPod's runtime modules go into Steam (rt.podOnly) and lgs-shell
starts in pod mode (lgs_shell.py --pod: no theme, the assPod's page
script only), which ends itself once the assPod is away and nothing
plays. lgs on replaces both with the full theme
dial V transparency dial, 0 = clearest .. 1 = most opaque (default 0.5);
remembered for the next "on", applied now if the theme is on
flags print the effective runtime flags and where each comes from
@@ -630,20 +636,26 @@ def _carried():
_RT_EXTRA = [] # [(label, source)] extra module sources: selftest fixtures only (RT-2)
POD_RT_FILES = ("rt/02-react.js", "rt/34-asspod.js") # `lgs pod` with the theme off: the assPod and its deps
async def rt_load(s, cli_flags=None, version=None):
async def rt_load(s, cli_flags=None, version=None, pod=False):
"""Boot 00-rt.js, load every module file (one evaluation each, so a syntax
error fails only that file), then start(). Returns a short summary."""
error fails only that file), then start(). Returns a short summary.
pod=True: the core, device/shared and POD_RT_FILES only, booted podOnly
(no liveness watch on the CSS core; `lgs pod` with the theme off)."""
files = rt_sources()
if not files or files[0][0] != "rt/00-rt.js":
return {"runtime": "off", "reason": "device/rt/00-rt.js is missing"}
if pod:
files = [f for f in files if f[0] == "rt/00-rt.js" or f[0].startswith("shared/") or f[0] in POD_RT_FILES]
layers = flag_layers(cli_flags)
data, data_errors = rt_data()
cfg = {"builtin": layers["builtin"], "defaults": layers["defaults"], "session": layers["session"],
"cli": layers["cli"], "version": version, "files": [f for f, _ in files], "data": data,
"test": _carried()}
_TEST_CARRY.update(t=0, state=None)
"test": None if pod else _carried(), "podOnly": bool(pod)}
if not pod:
_TEST_CARRY.update(t=0, state=None)
t0 = time.time()
boot = (f"(() => {{\nconst __LGS_RT_CONFIG = {json.dumps(cfg)};\n{_read(files[0][1])}\n}})()\n"
"//# sourceURL=lgs/rt/00-rt.js")
@@ -652,7 +664,7 @@ async def rt_load(s, cli_flags=None, version=None):
return {"runtime": "off", "reason": "00-rt.js did not define __LGS_RT"}
for label, msg in data_errors:
await s.eval(f"window.__LGS_RT._fail({json.dumps(label)}, {json.dumps(msg)})", 10)
todo = [(label, path, None) for label, path in files[1:]] + [(label, None, src) for label, src in _RT_EXTRA]
todo = [(label, path, None) for label, path in files[1:]] + ([] if pod else [(label, None, src) for label, src in _RT_EXTRA])
for label, path, src in todo:
if src is None:
try:
@@ -2274,6 +2286,62 @@ def is_on():
return bool(st and st.get("enabled"))
POD_WAIT_S = 30 # `lgs pod`: how long the pod daemon may take to offer asspod.open
POD_RT_JS = ("(() => { const r = window.__LGS_RT; if (!r) return 'none';"
" return (r.podOnly ? 'pod' : 'full') + (r.has && r.has('asspod') ? '+asspod' : ''); })()")
POD_READY_JS = ("(() => { const r = window.__LGS_RT; if (!r || !r.bridge) return false;"
" const d = r.bridge.get('daemon'); return !!(d && typeof d.at === 'number'"
" && Date.now() - d.at <= (d.ttlMs || 6000) && Array.isArray(d.actions)"
" && d.actions.includes('asspod.open') && typeof window.lgsAction === 'function'); })()")
POD_OPEN_JS = ("(async () => { const r = window.__LGS_RT;"
" if (!r || !r.has('asspod')) return JSON.stringify({ok: false, error: 'no-asspod'});"
" return JSON.stringify(await r.action('asspod.open', {}, {src: 'bar', timeoutMs: 45000})); })()")
def pod():
"""lgs pod: the assPod out in the hand that launched it, with or without the theme."""
import lgs_shell
out = {"theme": is_on()}
async def go(s):
o = {}
cur = await s.eval(POD_RT_JS, 10)
if not out["theme"] and cur != "pod+asspod":
# theme off: the assPod's own runtime (a full one left over is replaced; a pod one that lost
# its module is reloaded)
if cur != "none":
o["rtOff"] = await rt_teardown(s, "lgs pod")
o["runtime"] = await rt_load(s, pod=True)
cur = await s.eval(POD_RT_JS, 10)
o["rt"] = cur
return o
out.update(asyncio.run(_session(go)))
if not str(out.get("rt", "")).endswith("+asspod"):
out.update(ok=False, error="the assPod module is not running in Steam (flag assPod off, or it failed)")
return out
if not out["theme"]:
out["shell"] = lgs_shell.start(native="off", pod=True)
async def wait_open(s):
t0 = time.time()
while not await s.eval(POD_READY_JS, 10):
if time.time() - t0 > POD_WAIT_S:
return {"ok": False, "error": f"no daemon offering asspod.open after {POD_WAIT_S} s"}
await asyncio.sleep(0.5)
while True:
r = _jsonish(await s.eval(POD_OPEN_JS, 50))
# a new pod daemon offers asspod.open a moment before its page script is in SteamVR's systemui
if not (isinstance(r, dict) and r.get("ok") is False and "not installed" in str(r.get("detail"))) \
or time.time() - t0 > POD_WAIT_S:
return r
await asyncio.sleep(1.0)
out["open"] = asyncio.run(_session(wait_open))
out["ok"] = bool(isinstance(out["open"], dict) and out["open"].get("ok"))
return out
LAB_LOCK = "/tmp/lgs/lab.lock"
CLI_LOCK_WAIT_S = 90
@@ -2362,7 +2430,7 @@ def main(argv):
return 0 if res["ok"] else 1
if cmd == "selftest":
return selftest_main(args[1:], opts)
held = _cli_lab_lock() if cmd in ("toggle", "on", "off", "reload", "dial") else None
held = _cli_lab_lock() if cmd in ("toggle", "on", "off", "reload", "dial", "pod") else None
try:
if cmd == "toggle":
cmd = "off" if is_on() else "on"
@@ -2389,6 +2457,8 @@ def main(argv):
res["flagsFile"] = "cleared"
elif cmd == "status":
res = op("status", vr=True)
elif cmd == "pod":
res = pod()
else:
print(__doc__)
return 2
+97 -16
View File
@@ -35,6 +35,14 @@ In both modes it also (docs/phase2/contracts/daemon.md):
- injects SteamVR page scripts device/vr/<page>.<name>.js and removes them
again.
Pod mode (--pod, what `lgs pod` starts while the theme is off; + > Launch
Program > AssPod): no theme anywhere. SteamVR's pages are not themed, only
the assPod's page script goes in, Steam runs only the assPod's runtime
modules (rt.podOnly, loaded by lgs pod), and a theme that is off is not a
reason to go dormant. It exits once the assPod has been away with nothing
playing for POD_IDLE_S, at once after a Steam restart, and on lgs on (which
starts a full unit in its place) or lgs off.
Nothing persists: the unit is transient (systemd-run --collect) and writes no
bytecode (python3 -B); lgs off stops it at once. A theme off without lgs off
(a lab --stock step) makes it dormant: native layer, glassd, reporter and scene
@@ -45,7 +53,7 @@ class, binding global and file it made. If it dies without cleaning up,
lgs-native clears itself within 3 s and the scene-graph nodes within 12 s
(heartbeat watchdogs).
lgs_shell.py start [--native|--css|--auto] [--glassd PATH|none]
lgs_shell.py start [--native|--css|--auto|--pod] [--glassd PATH|none]
[--glassd-args "ARGS"] [--feed] [--stay]
[--test-report PATH] [--assume-caps CAPS]
start the unit; lgs on does this.
@@ -152,6 +160,11 @@ SLAB_SIZE_TOL_PX = 2.5 # glassd slab vs element x backdropScale
THEME_OFF_POLLS = 3 # Steam theme seen off this many polls (1 s) -> dormant (or exit)
THEME_OFF_GRACE_S = 600 # ... dormant this long before exiting (flag shellThemeGraceS; 0 = exit)
STAY_MAX_S = 2400 # --stay: dormant at most this long, and native at most this long after start
POD_SCRIPTS = ("asspod",) # --pod: the only SteamVR page scripts it installs
POD_IDLE_S = 30.0 # --pod: exit once the assPod is away and nothing plays this long
POD_FIRST_OPEN_S = 90.0 # --pod: ... or when it was never taken out this long after start
POD_RT_TEARDOWN_JS = ("(async () => { const r = window.__LGS_RT; if (!r || !r.podOnly || typeof r.teardown !== 'function')"
" return 'absent'; await r.teardown('pod daemon exit'); return 'removed'; })()")
# (glass.py's native-session client gives up after 2400 s; a unit older than
# that has lost its client, e.g. a usage-limit cut-off)
STEAM_GONE_S = 120 # Steam's devtools unreachable this long -> exit (also with --stay)
@@ -916,7 +929,10 @@ def clean_quad(q):
class Shell:
def __init__(self, glassd_path, stay=False, glassd_args=None, native=False, native_req=None, native_src=None,
test_report=None, assume_caps=None):
test_report=None, assume_caps=None, pod=False):
self.pod = bool(pod) # pod mode: the assPod alone, no theme (lgs pod)
if self.pod:
native = False
self.glassd_path = glassd_path
self.glassd_args = list(glassd_args or [])
self.stay = stay # lab aid: go dormant instead of exiting on theme off
@@ -1101,6 +1117,8 @@ class Shell:
# ---------------------------------------------------------------- state
def mode(self):
if self.pod:
return "pod"
if self.dormant_since is not None: # theme off without lgs off: waiting for it (contract §1)
return "dormant"
if not self.native_enabled:
@@ -1364,7 +1382,7 @@ class Shell:
rep = self.current_report() or {}
return {
"pid": os.getpid(), "since": time.strftime("%H:%M:%S", time.localtime(self.since)),
"updated": time.time(), "mode": self.mode(),
"updated": time.time(), "mode": self.mode(), "pod": self.pod,
"native": {"requested": self.native_req, "resolved": "on" if self.native_requested else "off",
"source": self.native_src, "enabled": self.native_enabled, "reason": self.css_reason},
"flags": self.rt_flags, "profile": self.profile, "reduceMotion": self.reduce_motion,
@@ -2214,7 +2232,7 @@ class Shell:
self.native_force, self.bridge_sent = True, {}
self.changed.set()
self.check_stay_limit()
if self.theme_off_polls >= THEME_OFF_POLLS:
if self.theme_off_polls >= THEME_OFF_POLLS and not self.pod: # pod: no theme, by design
# A theme off without `lgs off` (which stops us first) is a
# test toggling it (lab --stock, P1's selftests) or a Steam
# restart: stay dormant for the grace, exit on a restart.
@@ -3116,6 +3134,22 @@ class Shell:
self.vr_targets = {}
await self.vr_conns_close()
continue
if self.pod: # no page theming: the assPod's page script only
self.vr_targets = {(t.get("title") or t.get("url")): t for t in ts}
scripts = {n: v for n, v in self.vr_scripts_cached().items() if n in POD_SCRIPTS}
await self.vr_conns_close(keep={t.get("id") or t["webSocketDebuggerUrl"] for t in ts})
pages = {}
for t in ts:
name = t.get("title") or t.get("url")
try:
await self.vr_scripts_for(t, scripts)
pages[name] = "pod"
except Exception: # noqa: BLE001 - page reloading or closing
pages[name] = "error"
self.vr_pages = pages
for gone in [n for n in self.vrx_status if n not in pages]:
del self.vrx_status[gone]
continue
s = lgs_vr.signature()
if s != sig:
sig = s
@@ -3149,6 +3183,37 @@ class Shell:
except Exception as e: # noqa: BLE001 - best effort, like lgs-vr
self.note(f"vr: {e!r}")
# ---------------------------------------------------------------- pod mode
async def pod_loop(self):
"""--pod: end once the assPod is away and nothing plays (POD_IDLE_S), when it was never taken out
(POD_FIRST_OPEN_S), or at once after a Steam restart (its pod runtime went with the old page)."""
opened, idle_since = False, None
while not self.stopping:
await asyncio.sleep(1.0)
if self.steam_restarted:
log("pod: Steam restarted; exiting")
self.request_stop("pod: steam restarted", strip_vr=True)
return
p = self.registry.plugins.get("asspod") or {}
st = p["ctx"].state if p.get("ctx") is not None else {}
busy = bool(st.get("open") or st.get("playing") or st.get("starting"))
opened = opened or bool(st.get("open"))
now = time.time()
if busy:
idle_since = None
continue
if not opened:
if now - self.since >= POD_FIRST_OPEN_S:
log(f"pod: the assPod was not taken out within {POD_FIRST_OPEN_S:.0f} s; exiting")
self.request_stop("pod: never opened", strip_vr=True)
return
continue
idle_since = idle_since or now
if now - idle_since >= POD_IDLE_S:
log(f"pod: the assPod has been away with nothing playing for {POD_IDLE_S:.0f} s; exiting")
self.request_stop("pod: idle", strip_vr=True)
return
# ---------------------------------------------------------------- status
async def status_loop(self):
while not self.stopping:
@@ -3244,6 +3309,9 @@ class Shell:
# the inert binding functions (both: removeBinding leaves them):
# gone, so callers see no daemon and nothing of ours stays (G-REMOVE)
rb = await s.eval(f"({DELETE_BINDINGS_JS})({json.dumps([BINDING, ACTION_BINDING])})", 5)
if self.pod: # the assPod's own runtime (lgs pod) goes with us; a full one stays
rp = await s.eval(POD_RT_TEARDOWN_JS, 30)
log(f"teardown: pod runtime {rp}")
finally:
await s.close()
log(f"teardown: lgs-native {r}, reporter {rr}, binding globals deleted [{rb}]")
@@ -3271,14 +3339,16 @@ class Shell:
for sig in (signal.SIGTERM, signal.SIGINT, signal.SIGHUP):
loop.add_signal_handler(sig, lambda s=sig: self.request_stop(signal.Signals(s).name))
os.makedirs(SHM, exist_ok=True)
log(f"lgs-shell starting (pid {os.getpid()}, "
+ (f"native, glassd {self.glassd_path} {' '.join(self.glassd_args)}".rstrip() if self.native_enabled
else f"css only: {self.css_reason}") + ")")
log(f"lgs-shell starting (pid {os.getpid()}, " + ("pod mode: the assPod alone, no theme" if self.pod else
(f"native, glassd {self.glassd_path} {' '.join(self.glassd_args)}".rstrip() if self.native_enabled
else f"css only: {self.css_reason}")) + ")")
self.refresh_flags()
self.tasks = [loop.create_task(c) for c in (
self.stall_loop(), self.steam_loop(), self.native_loop(), self.sysui_loop(), self.glassd_loop(),
self.glassd_out_loop(),
self.vr_theme_loop(), self.status_loop(), self.geom_loop(), self.actions_loop())]
if self.pod:
self.tasks.append(loop.create_task(self.pod_loop()))
pending = set(self.tasks)
while self.stop_task is None and pending:
done, pending = await asyncio.wait(pending, return_when=asyncio.FIRST_COMPLETED)
@@ -3412,7 +3482,7 @@ def ensure_glassd(path=GLASSD_BIN):
return False
def start(glassd=GLASSD_BIN, stay=False, glassd_args=None, native=None, test_report=None, assume_caps=None):
def start(glassd=GLASSD_BIN, stay=False, glassd_args=None, native=None, test_report=None, assume_caps=None, pod=False):
"""Start the unit. Also resumes SteamVR page theming that a lab "--theme
off" on a vr: page paused.
@@ -3423,7 +3493,9 @@ def start(glassd=GLASSD_BIN, stay=False, glassd_args=None, native=None, test_rep
then defaults.json, else CSS only). A unit that is stopping is waited for
and then started fresh. stay=True (lab testing only) keeps it running,
dormant, while the Steam theme is off. test_report (lab) replaces the
reporter with a report JSON file.
reporter with a report JSON file. pod=True (lgs pod, theme off) runs pod
mode: a running pod unit is kept, any other unit is replaced by one; a
running pod unit is replaced by a full one for any other request.
Serialized by START_LOCK: two `lgs on` at once (one lock-free) made the
loser's systemd-run fail with "already loaded" although a unit ran (review
@@ -3436,13 +3508,15 @@ def start(glassd=GLASSD_BIN, stay=False, glassd_args=None, native=None, test_rep
except (OSError, TimeoutError):
lk = None
try:
return _start(glassd, stay, glassd_args, native, test_report, assume_caps)
return _start(glassd, stay, glassd_args, native, test_report, assume_caps, pod)
finally:
if lk is not None:
lk.__exit__()
def _start(glassd, stay, glassd_args, native, test_report, assume_caps):
def _start(glassd, stay, glassd_args, native, test_report, assume_caps, pod=False):
if pod:
native = "off"
req = "auto" if native is None else native_word(native)
try:
os.remove(VR_PAUSE)
@@ -3455,18 +3529,22 @@ def _start(glassd, stay, glassd_args, native, test_report, assume_caps):
return ["--glassd", (glassd if nat and glassd else "none")] + (["--native"] if nat else []) + \
(["--stay"] if stay else []) + (["--glassd-args", " ".join(glassd_args)] if nat and glassd_args else []) + \
(["--test-report", os.path.abspath(test_report)] if test_report else []) + \
(["--assume-caps", ",".join(assume_caps)] if assume_caps else [])
(["--assume-caps", ",".join(assume_caps)] if assume_caps else []) + \
(["--pod"] if pod else [])
state, pid = settle()
if state in ("active", "activating", "reloading"):
argv = daemon_argv(pid)
cur = "--native" in argv
if pod and "--pod" in argv:
return "running (pod)"
was_pod = "--pod" in argv
running = core_args(argv[argv.index("daemon") + 1:]) if "daemon" in argv else None
# an explicit request restarts a unit that runs differently
st = read_json(STATUS) or {}
stay_over = req == "on" and st.get("pid") == pid and \
str((st.get("native") or {}).get("reason") or "").startswith("--stay limit")
if (req == "auto" or running == daemon_args(req == "on")) and not stay_over:
if (req == "auto" or running == daemon_args(req == "on")) and not stay_over and not was_pod and not pod:
if cur and st.get("pid") == pid and not (st.get("native") or {}).get("enabled", True):
return f"running (native requested; {st.get('mode')})"
return "running (native)" if cur else "running (css only)"
@@ -3488,6 +3566,8 @@ def _start(glassd, stay, glassd_args, native, test_report, assume_caps):
cur = "--native" in daemon_argv(pid)
return "running (native, started concurrently)" if cur else "running (css only, started concurrently)"
return f"failed: {r.stderr.strip()}"
if pod:
return "started (pod)"
if nat and not (glassd and os.access(glassd, os.X_OK)):
return "started (css only: native requested but no glassd binary)"
return "started (native)" if nat else "started (css only)"
@@ -4438,8 +4518,8 @@ def main(argv):
native=bool(native_arg(argv)), native_req=opt_arg(argv, "--req"),
native_src=opt_arg(argv, "--src"),
test_report=opt_arg(argv, "--test-report"),
assume_caps=[c for c in (opt_arg(argv, "--assume-caps") or "").split(",") if c]
).run())
assume_caps=[c for c in (opt_arg(argv, "--assume-caps") or "").split(",") if c],
pod="--pod" in argv).run())
except KeyboardInterrupt:
return 0
if cmd == "start":
@@ -4452,7 +4532,8 @@ def main(argv):
gargs = ["--no-feed"]
print(start(glassd, stay_arg(argv), gargs, None if nat is None or "--auto" in argv else nat,
test_report=opt_arg(argv, "--test-report"),
assume_caps=[c for c in (opt_arg(argv, "--assume-caps") or "").split(",") if c]))
assume_caps=[c for c in (opt_arg(argv, "--assume-caps") or "").split(",") if c],
pod="--pod" in argv))
elif cmd == "stop":
print(stop())
elif cmd == "status":
+6 -1
View File
@@ -30,6 +30,9 @@
const now = () => (W.performance ? W.performance.now() : Date.now());
CONFIG = CONFIG || {};
// `lgs pod` with the theme off: only the assPod's modules, kept by the pod daemon (lgs_shell.py --pod),
// not by the CSS core, so the liveness watch below never removes it for a missing html.lgs-on.
const POD_ONLY = !!CONFIG.podOnly;
// A previous instance (manual re-evaluation): tear it down first. lgs.py
// already awaits teardown before booting, so this is only a safety net.
@@ -755,7 +758,7 @@
}
// true when it started the teardown
function checkLiveness() {
if (!started || stopping) return false;
if (!started || stopping || POD_ONLY) return false;
let on = true, what = 'html.lgs-on gone from main';
try {
if (moHtml) on = moHtml.classList.contains('lgs-on');
@@ -1011,6 +1014,7 @@
runtime: stopped ? 'removed' : (stopping ? 'stopping' : (started ? 'running' : 'loaded')),
version: VERSION,
build: CONFIG.version || null,
podOnly: POD_ONLY,
since: new Date(T0).toISOString(),
modules: list,
failedLoads: failedLoads.slice(),
@@ -1110,6 +1114,7 @@
// ------------------------------------------------------------------ public object
const rt = {
version: VERSION,
podOnly: POD_ONLY,
W,
define,
use(name) {
+47 -24
View File
@@ -7,13 +7,17 @@
device/vr/systemui.asspod.js), with the menu dimmed behind it.
- While it is out (the daemon's bridge value 'asspod' {open, at, ttlMs}, renewed while it stays
out): every gamepad input Steam gets is swallowed (FocusNavController's catch-all), so the
sticks and buttons drive only the assPod. A (OK, or the left controller's button in that place,
DIR_DOWN), B (CANCEL, or DIR_LEFT) and the hamburger (START) go to the daemon as asspod.btn: A is
the centre button, B the wheel's Menu, the hamburger puts the assPod away. The Steam /
sticks and buttons drive only the assPod. A (OK), B (CANCEL) and the hamburger (START) go to the
daemon as asspod.btn: A is the centre button, B the wheel's Menu, the hamburger puts the assPod
away. The d-pad directions are not relayed: Steam sends them for the left stick's tilts too, and
both sticks reach the wheel from their controller models (device/asspod/src/input). The Steam /
quick-access buttons pass. Steam's windows also take no laser clicks meanwhile
(html.lgs-asspod-out, theme/34-asspod.css). A bridge value older
than its ttl ends all of this on its own, so a dead daemon never leaves Steam's input taken.
remove(): the patch layer, the catch-all and the class go. */
- Pod only (`lgs pod` with the theme off, rt.podOnly; + > Launch Program > AssPod): no button on
Steam's own bar, and no theme CSS, so the fade and the click block of html.lgs-asspod-out come
from a small style of this module's own in each window. The rest is the same.
remove(): the patch layer, the catch-all, the class and the style go. */
let ASSPOD = null;
__LGS_RT.define({
name: 'asspod',
@@ -21,20 +25,22 @@ __LGS_RT.define({
flag: 'assPod',
install(rt) {
const R = rt.react;
R.ready();
const { React, jsx } = R;
const c = R.c || {};
const Focusable = c.Focusable || R.Focusable;
if (!Focusable) throw new Error('asspod: no Focusable');
if (!R.patch.targets.dashboardBar) throw new Error('asspod: no dashboardBar target');
const podOnly = !!rt.podOnly;
if (!podOnly) {
R.ready();
if (!(R.c && R.c.Focusable) && !R.Focusable) throw new Error('asspod: no Focusable');
if (!R.patch.targets.dashboardBar) throw new Error('asspod: no dashboardBar target');
}
// Steam's gamepad buttons (EGamepadButton)
const BTN = { OK: 1, CANCEL: 2, DIR_DOWN: 10, DIR_LEFT: 11, START: 14, STEAM_GUIDE: 27, STEAM_QUICK_MENU: 28 };
// button -> [what it does, which controller]
const MAP = { [BTN.OK]: ['ok', 'right'], [BTN.CANCEL]: ['menu', 'right'], [BTN.DIR_DOWN]: ['ok', 'left'],
[BTN.DIR_LEFT]: ['menu', 'left'], [BTN.START]: ['close', 'right'] };
const BTN = { OK: 1, CANCEL: 2, START: 14, STEAM_GUIDE: 27, STEAM_QUICK_MENU: 28 };
// button -> [what it does, which controller]. No d-pad directions: tilting the left stick sends
// them too, and pressed the centre button (down) or Menu (left).
const MAP = { [BTN.OK]: ['ok', 'right'], [BTN.CANCEL]: ['menu', 'right'], [BTN.START]: ['close', 'right'] };
const OUT_CLASS = 'lgs-asspod-out';
const ICON = jsx('svg', { viewBox: '0 0 24 24', fill: 'currentColor', 'aria-hidden': 'true', children: [
const POD_STYLE = `body { transition: opacity 300ms ease; }
html.${OUT_CLASS} body { opacity: 0 !important; pointer-events: none !important; }`;
const icon = (jsx) => jsx('svg', { viewBox: '0 0 24 24', fill: 'currentColor', 'aria-hidden': 'true', children: [
jsx('path', { d: 'M9 3.6 20 1.4v14.1a3.4 3.4 0 1 1-2-3.1V6.3L11 7.7v10.1A3.4 3.4 0 1 1 9 14.7z' }, 'n'),
] });
@@ -85,8 +91,20 @@ __LGS_RT.define({
};
S.off.push(rt.bridge.on('asspod', () => { if (S.timer) { S.timer(); S.timer = 0; } check(); }));
S.off.push(rt.windows.track((e) => {
try { e.doc.documentElement.classList.toggle(OUT_CLASS, S.out); } catch (_) { /* window gone */ }
return () => { try { e.doc.documentElement.classList.remove(OUT_CLASS); } catch (_) { /* gone */ } };
let style = null;
try {
e.doc.documentElement.classList.toggle(OUT_CLASS, S.out);
if (podOnly) {
style = e.doc.createElement('style');
style.className = 'lgs-asspod-style';
style.textContent = POD_STYLE;
(e.doc.head || e.doc.documentElement).appendChild(style);
}
} catch (_) { /* window gone */ }
return () => {
try { e.doc.documentElement.classList.remove(OUT_CLASS); } catch (_) { /* gone */ }
try { if (style) style.remove(); } catch (_) { /* gone */ }
};
}));
const daemon = () => {
@@ -106,7 +124,18 @@ __LGS_RT.define({
});
};
const api = {
status: () => ({ out: S.out, opening: S.opening, daemon: daemon(), sent: S.sent, swallowed: S.swallowed, lastErr: S.lastErr,
podOnly, bridge: rt.bridge.get('asspod') || null, patch: S.handle ? { count: S.handle.count, live: S.handle.live } : null,
shown: (() => { try { return rt.windows.byKind('bar').some((e) => !!e.doc.querySelector('.lgs-asspod')); } catch (_) { return null; } })() }),
open,
};
if (podOnly) { check(); return api; }
// ---- the button: Steam's small bar disc in a glass circle of its own
const { React, jsx } = R;
const Focusable = (R.c && R.c.Focusable) || R.Focusable;
const ICON = icon(jsx);
const barClasses = () => (R.M && R.M.BarClasses) || {};
function AssPodButton() {
const [, bump] = React.useState(0);
@@ -155,13 +184,7 @@ __LGS_RT.define({
}, { optional: true });
try { R.patch.rerender(S.handle); } catch (_) { /* shows at the bar's next render */ }
check();
return {
status: () => ({ out: S.out, opening: S.opening, daemon: daemon(), sent: S.sent, swallowed: S.swallowed, lastErr: S.lastErr,
bridge: rt.bridge.get('asspod') || null, patch: S.handle ? { count: S.handle.count, live: S.handle.live } : null,
shown: (() => { try { return rt.windows.byKind('bar').some((e) => !!e.doc.querySelector('.lgs-asspod')); } catch (_) { return null; } })() }),
open,
};
return api;
},
remove() {
const S = ASSPOD;
+66 -8
View File
@@ -57,6 +57,14 @@ PODD = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.real
PODD_PREBUILT = os.path.join(os.path.dirname(PODD), "prebuilt", "podd")
PODD_SPEC = "/dev/shm/lgs/podd.json"
PODD_OUT = "/dev/shm/lgs/podd-out.json"
# the bodies podd draws and their colours (device/asspod/src/models.js, native/podd/podd.cpp)
PODD_FINISHES = {"video": ("black", "white"), "nano": ("silver", "green", "blue", "pink", "black", "red")}
NANO_SCALE = 1.3125 # the nano as shown (models.js MODELS.nano.scale)
# The body you picked (assPod Model) and each body's colour, kept across launches beside the glass
# tune (lgs.TUNE): {"model": "video"|"nano", "finish": <the Video's>, "nanoFinish": <the nano's>}.
# Written only when you change them; uninstall removes it with the folder.
PREFS = os.path.join(os.environ.get("XDG_CONFIG_HOME") or os.path.expanduser("~/.config"), "glass-shell", "asspod.json")
PREFS_DEFAULT = {"model": "video", "finish": "black", "nanoFinish": "silver"}
API = "window.__LGS_VRX && window.__LGS_VRX.asspod && window.__LGS_VRX.asspod.api"
HOME = os.path.expanduser("~")
FLATPAK_VLC = "org.videolan.VLC"
@@ -597,14 +605,16 @@ async def _poll_loop(ctx):
s["open"], s["playing"] = bool(r.get("open")), bool(r.get("playing"))
if isinstance(r.get("holds"), dict):
s["holds"] = r["holds"] # Reposition's places, kept for the next open
_prefs_note(ctx, r.get("prefs")) # the body and colours you picked, kept across launches
# podd follows the body: on the hand (hold) or pinned in the room while repositioning (world)
if s["open"] and s.get("podd") is not None and s["podd"].returncode is None:
h, w = r.get("hold") or {}, r.get("world")
fin = r.get("finish") if r.get("finish") in ("black", "white") else "black"
want = (json.dumps(h, sort_keys=True), json.dumps(w, sort_keys=True), fin)
model = r.get("model") if r.get("model") in PODD_FINISHES else "video"
fin = r.get("finish") if r.get("finish") in PODD_FINISHES[model] else PODD_FINISHES[model][0]
want = (json.dumps(h, sort_keys=True), json.dumps(w, sort_keys=True), model, fin)
if want != s.get("podd_pose") and h.get("rot") and h.get("centre"):
s["podd_pose"] = want
_podd_spec(s, rot=h["rot"], centre=h["centre"], world=w, finish=fin)
_podd_spec(s, rot=h["rot"], centre=h["centre"], world=w, model=model, finish=fin)
if s["open"]:
s["hand"] = r.get("hand")
if was_open and not s["open"]:
@@ -1081,7 +1091,8 @@ def _model_dir():
# ------------------------------------------------------------------ podd (native/podd: the body with real shaders)
def _podd_spec(s, **kw):
spec = dict(s.get("podd_spec") or {"open": False, "hand": "right", "scale": 0.085 / 0.0618, "finish": "black"})
spec = dict(s.get("podd_spec") or {"open": False, "hand": "right", "scale": 0.085 / 0.0618, "nanoScale": NANO_SCALE,
"model": "video", "finish": "black"})
spec.update(kw)
s["podd_spec"] = spec
os.makedirs(os.path.dirname(PODD_SPEC), exist_ok=True)
@@ -1113,7 +1124,7 @@ def _podd_kill_strays():
time.sleep(0.05)
async def _podd_start(ctx, model_dir, scale, finish):
async def _podd_start(ctx, model_dir, scale, finish, nano_scale=NANO_SCALE, model="video"):
"""podd up with its layout (podd-out.json), or None (then SteamVR's render models draw the body)."""
s = _st(ctx)
if not os.access(PODD, os.X_OK) and os.path.isfile(PODD_PREBUILT):
@@ -1125,7 +1136,7 @@ async def _podd_start(ctx, model_dir, scale, finish):
_err(ctx, f"podd: {e!r}")
if not os.access(PODD, os.X_OK):
return None
_podd_spec(s, open=False, scale=scale, finish=finish)
_podd_spec(s, open=False, scale=scale, nanoScale=nano_scale, model=model, finish=finish)
p = s.get("podd")
# a podd older than its binary (a rebuild) is replaced
if p is not None and p.returncode is None and os.path.getmtime(PODD) > s.get("podd_t0", 0):
@@ -1177,6 +1188,49 @@ async def _podd_stop(ctx):
pass
# ------------------------------------------------------------------ the body you picked, kept
def _prefs_clean(p):
"""The model and colours from `p`, each checked; the defaults for anything missing or wrong."""
p = p if isinstance(p, dict) else {}
out = dict(PREFS_DEFAULT)
if p.get("model") in PODD_FINISHES:
out["model"] = p["model"]
if p.get("finish") in PODD_FINISHES["video"]:
out["finish"] = p["finish"]
if p.get("nanoFinish") in PODD_FINISHES["nano"]:
out["nanoFinish"] = p["nanoFinish"]
return out
def _prefs_load(s):
if s.get("prefs") is None:
try:
with open(PREFS, encoding="utf-8") as f:
s["prefs"] = _prefs_clean(json.load(f))
except (OSError, ValueError):
s["prefs"] = dict(PREFS_DEFAULT)
return s["prefs"]
def _prefs_note(ctx, p):
"""The page's current model and colours (each poll): saved when they changed."""
s = _st(ctx)
if not isinstance(p, dict):
return
want = _prefs_clean(p)
if want == _prefs_load(s):
return
s["prefs"] = want
try:
os.makedirs(os.path.dirname(PREFS), exist_ok=True)
with open(PREFS + ".tmp", "w", encoding="utf-8") as f:
json.dump(want, f)
os.replace(PREFS + ".tmp", PREFS)
except OSError as e:
_err(ctx, f"prefs: {e!r}")
# ------------------------------------------------------------------ actions
async def _open(ctx):
@@ -1186,8 +1240,12 @@ async def _open(ctx):
raise RuntimeError("the assPod page script is not installed in SteamVR (systemui)")
if not st.get("open"):
model_dir = _model_dir()
podd = await _podd_start(ctx, model_dir, st.get("bodyScale") or 0.085 / 0.0618, st.get("finish") or "black")
r = await _page(ctx, f"A.open({json.dumps({'modelDir': model_dir, 'podd': podd, 'holds': s.get('holds'), 'resume': _resume(s)})})")
prefs = _prefs_load(s)
model = prefs["model"]
podd = await _podd_start(ctx, model_dir, st.get("bodyScale") or 0.085 / 0.0618,
prefs["nanoFinish"] if model == "nano" else prefs["finish"],
st.get("nanoScale") or NANO_SCALE, model)
r = await _page(ctx, f"A.open({json.dumps({'modelDir': model_dir, 'podd': podd, 'holds': s.get('holds'), 'resume': _resume(s), 'prefs': prefs})})")
if not r or not r.get("open"):
await _podd_stop(ctx)
raise RuntimeError(f"open failed: {(r or {}).get('error')}")
+379 -92
View File
@@ -2319,6 +2319,11 @@ __e.drawEdge = drawEdge;
//
// The static body is drawn once per finish into a layer; a frame blits it, then the screen and the
// wheel state. draw() returns false when nothing changed, so the page repaints only on change.
//
// The nano (models.js) has only its screen here, in its window, filling most of the canvas: podd or
// its render models draw the rest, and the page shows just the window's rect (windowRect('nano')).
const { MODELS } = __req('models.js');
const BODY = { w: 61.8, h: 103.5, corner: 6.5 };
const LCD = { cy: 24.2, w: 50.8, h: 38.1 };
@@ -2367,6 +2372,41 @@ function createFace(canvas, screen, opts = {}) {
const lcd = { x: X(-LCD.w / 2), y: Y(LCD.cy + LCD.h / 2), w: LCD.w * k, h: LCD.h * k };
const wc = { x: X(0), y: Y(WHEEL.cy) };
// the nano's window: 90% of the canvas's width, centred
const NANO = MODELS.nano;
const nk = W * 0.9 / NANO.win.w; // px per mm
const nanoWin = { w: NANO.win.w * nk, h: NANO.win.h * nk };
nanoWin.x = (W - nanoWin.w) / 2;
nanoWin.y = (H - nanoWin.h) / 2;
const nanoLcd = { w: NANO.lcd.w * nk, h: NANO.lcd.h * nk };
nanoLcd.x = (W - nanoLcd.w) / 2;
nanoLcd.y = (H - nanoLcd.h) / 2;
/** The model's screen window in this canvas (px): what the page shows over the window. */
function windowRect(model) {
if (model === 'nano') return { ...nanoWin };
return { x: X(-WINDOW.w / 2), y: Y(LCD.cy + WINDOW.h / 2), w: WINDOW.w * k, h: WINDOW.h * k };
}
// The nano: its LCD in a black window (rounded like the nano's), the backlight and the glass.
function drawNano(s) {
roundRect(g, nanoWin.x, nanoWin.y, nanoWin.w, nanoWin.h, 0.6 * nk);
g.fillStyle = '#000';
g.fill();
g.imageSmoothingEnabled = true;
g.imageSmoothingQuality = 'high';
g.drawImage(screen, nanoLcd.x, nanoLcd.y, nanoLcd.w, nanoLcd.h);
const veil = 1 - Math.max(0, Math.min(1, s.backlight));
if (veil > 0.001) {
g.fillStyle = `rgba(0,0,0,${veil})`;
g.fillRect(nanoLcd.x, nanoLcd.y, nanoLcd.w, nanoLcd.h);
}
const refl = g.createLinearGradient(nanoLcd.x, nanoLcd.y, nanoLcd.x + nanoLcd.w, nanoLcd.y + nanoLcd.h);
refl.addColorStop(0, 'rgba(255,255,255,0.07)');
refl.addColorStop(0.5, 'rgba(255,255,255,0)');
g.fillStyle = refl;
g.fillRect(nanoLcd.x, nanoLcd.y, nanoLcd.w, nanoLcd.h);
}
function bodyLayer(finish) {
let c = layers.get(finish);
@@ -2519,12 +2559,13 @@ function createFace(canvas, screen, opts = {}) {
if (marker !== target) {
marker = target > marker ? Math.min(1, marker + dt / MARKER_IN) : Math.max(0, marker - dt / MARKER_OUT);
}
const key = [s.finish, Math.round(s.backlight * 100), s.pressed || '', marker > 0 ? Math.round(s.angle * 200) : -1,
const key = [s.model || 'video', s.finish, Math.round(s.backlight * 100), s.pressed || '', marker > 0 ? Math.round(s.angle * 200) : -1,
Math.round(marker * 20), s.reflVersion || 0, s.screenOnly ? 1 : 0, s.videoUnder ? 1 : 0].join('|');
if (!s.screenDirty && key === last) return false;
last = key;
const f = FINISHES[s.finish] || FINISHES.black;
g.clearRect(0, 0, W, H);
if (s.model === 'nano') { drawNano(s); return true; }
if (overlay) {
roundRect(g, X(-WINDOW.w / 2), Y(LCD.cy + WINDOW.h / 2), WINDOW.w * k, WINDOW.h * k, WINDOW.r * k);
g.fillStyle = '#000';
@@ -2567,7 +2608,7 @@ function createFace(canvas, screen, opts = {}) {
return true;
}
return { draw, get animating() { return marker > 0 && marker < 1; } };
return { draw, windowRect, get animating() { return marker > 0 && marker < 1; } };
}
/** The mirror-polished steel back with the peach engraved in it (static: drawn once). Polished
@@ -3048,7 +3089,8 @@ function createControllerReader() {
__defs['main.js'] = function (__e) {
__e.default = install;
// assPod: a 5th-generation-style music player in your hand in the SteamVR menu, playing the VLC
// media library. This is its half inside SteamVR's systemui page (installed by the daemon as the
// media library. The main menu's assPod Model swaps the body for a 2nd-generation nano (models.js):
// the same OS, scaled onto its smaller screen, and no video. This is its half inside SteamVR's systemui page (installed by the daemon as the
// page script device/vr/systemui.asspod.js, built from these sources by tools/build_asspod.py);
// the daemon's plugin device/shell_ext/asspod.py runs VLC and reads the library.
//
@@ -3061,8 +3103,9 @@ __e.default = install;
// Controls: both sticks turn the click wheel; a stick click presses the wheel button under it
// (centred: the centre button). A is the centre button, B is the wheel's Menu, and the hamburger
// (menu) button puts the assPod away; so does closing the SteamVR menu. The right controller's
// buttons are read here from its model; the left one's (A and B in the same places) come from Steam
// through the plugin (btn()). Music keeps playing: the player keeps running here at a few Hz, and
// buttons are read here from its model, Steam's A / B / hamburger come through the plugin (btn())
// otherwise. Both sticks act alike: only their models are read (Steam's d-pad directions, which the
// left stick's tilts also send, are not relayed). Music keeps playing: the player keeps running here at a few Hz, and
// the plugin keeps VLC up while anything plays and stops it when the assPod is away and nothing plays.
//
// The plugin talks to this page only through the returned api: poll() drains the commands for VLC
@@ -3082,6 +3125,7 @@ const { createFace, createBack, BODY } = __req('face.js');
const { drawPeachSilhouette } = __req('brand/peach.js');
const { createReflector, pose, qmul, qrot, qconj } = __req('reflect.js');
const { createSceneGraph } = __req('sg.js');
const { MODELS, currentModel, currentFinish, bodyMetres } = __req('models.js');
const FACE_W = 476; // px; the body is 61.8 x 103.5 mm
const FACE_H = Math.round(FACE_W * BODY.h / BODY.w);
@@ -3089,6 +3133,10 @@ const FACE_WIDTH_M = 0.085; // shown about 1.4x the real body, fo
const BODY_M = { // the 3D body in metres, at that scale
w: FACE_WIDTH_M, h: FACE_WIDTH_M * BODY.h / BODY.w, d: FACE_WIDTH_M * 11 / BODY.w, r: FACE_WIDTH_M * BODY.corner / BODY.w,
};
const NANO_M = bodyMetres(MODELS.nano); // the nano, at its own scale
// a model's geometry for the scene graph (model metres)
const geoOf = (m) => ({ w: m.body.w / 1000, d: m.body.d / 1000, lcdCy: m.lcd.cy / 1000, lcdW: m.lcd.w / 1000,
winW: m.win.w / 1000, winH: m.win.h / 1000, wheelCy: m.wheel.cy / 1000 });
const BACK_W = 238; // px: the steel back's live reflection (half the face's resolution)
const BACK_H = Math.round(BACK_W * BODY.h / BODY.w);
const REFLECT_EVERY = 2; // frames between reflection updates (about 30 a second)
@@ -3200,9 +3248,15 @@ function install(ctx) {
library.onLoad(() => { art.clear(); os.ctx.popToRoot(); os.invalidate(); });
// the EQ follows the setting (VLC's preset by name)
settings.on('change', ({ key }) => { if (key === 'eq' || key === '*') send({ op: 'eq', name: settings.get('eq') }); });
// the body's colour: podd follows the finish in the poll; the render-model body is rebuilt
// The body: podd follows the model and its finish in the poll (one layout holds both); the scene
// graph is rebuilt for the other body's screen panel and place in the hand, or for the render
// models' colour. (Picking the nano stops a video: os/screens/main-menu.js.)
settings.on('change', ({ key }) => {
if ((key === 'finish' || key === '*') && st.open && !st.podd) st.holdPose = sg.show(showOpts(st.hand, st.world)) || st.holdPose;
if (!st.open) return;
if (key === 'model' || key === '*' || (!st.podd && (key === 'finish' || key === 'nanoFinish'))) {
if (key === 'model' && st.world) unpin(false);
st.holdPose = sg.show(showOpts(st.hand, st.world)) || st.holdPose;
}
});
// ---------------------------------------------------------------- state
@@ -3295,11 +3349,13 @@ function install(ctx) {
videoMode();
const screenDirty = os.render(t);
// the painted reflections only without podd (podd shades the glass and the steel itself)
if (!st.podd && st.frames % REFLECT_EVERY === 0 && reflector.update(st.hand, st.holdPose)) back.draw(reflector.back, reflector.version);
const model = currentModel(settings).id;
const painted = !st.podd && model === 'video'; // the nano's render models carry their own back
if (painted && st.frames % REFLECT_EVERY === 0 && reflector.update(st.hand, st.holdPose)) back.draw(reflector.back, reflector.version);
const ws = wheel.state;
face.draw({
reflection: !st.podd && reflector.version ? reflector.front : null, reflVersion: reflector.version, screenOnly: !!st.podd, videoUnder: !!st.videoMode,
finish: settings.get('finish'), backlight: os.backlight, screenDirty,
reflection: painted && reflector.version ? reflector.front : null, reflVersion: reflector.version, screenOnly: !!st.podd, videoUnder: !!st.videoMode,
model, finish: currentFinish(settings), backlight: os.backlight, screenDirty,
touching: ws.touching, angle: ws.angle, pressed: ws.pressedPart || (st.keys.select ? 'select' : st.keys.menu ? 'menu' : null), now: t,
});
} catch (err) { note(err); }
@@ -3317,9 +3373,15 @@ function install(ctx) {
if (player.state === 'playing') st.bgTimer = setTimeout(background, BACKGROUND_MS);
}
// Reposition's places are kept per body: the Video's under the hand's name, the nano's as nano-<hand>
const holdKey = (hand) => (currentModel(settings).id === 'video' ? hand : `${currentModel(settings).id}-${hand}`);
function showOpts(hand, world) {
return { hand, body: BODY_M, rects, modelDir: st.modelDir, finish: settings.get('finish'), podd: st.podd,
hold: st.holds[hand] || null, world: world || null, video: !!st.videoMode };
const m = currentModel(settings);
const wr = face.windowRect(m.id);
rects.win = { x: rects.face.x + wr.x, y: rects.face.y + wr.y, w: wr.w, h: wr.h };
return { hand, body: m.id === 'video' ? BODY_M : NANO_M, rects, modelDir: st.modelDir, model: m.id, geo: geoOf(m),
finish: currentFinish(settings), podd: st.podd, hold: st.holds[holdKey(hand)] || null, world: world || null, video: !!st.videoMode };
}
// A video on the screen with podd: podd shows its picture (the daemon's frames, full rate) in a panel
@@ -3351,7 +3413,7 @@ function install(ctx) {
const hp = pose('/user/hand/' + st.hand);
if (hp) {
const inv = qconj(hp.q);
st.holds[st.hand] = {
st.holds[holdKey(st.hand)] = {
rot: qmul(inv, st.world.rot),
centre: qrot(inv, st.world.centre.map((v, i) => v - hp.t[i])),
};
@@ -3366,6 +3428,7 @@ function install(ctx) {
return {
id: 'reposition',
title: 'Reposition',
adaptive: true,
keepAwake: true,
enter() { if (!pin()) note('reposition: no hand pose'); },
exit() { if (st.world) unpin(false); },
@@ -3374,7 +3437,7 @@ function install(ctx) {
if (e.type !== 'press') return e.type === 'release' ? false : undefined;
if (e.part === 'select') return done(() => unpin(true));
if (e.part === 'menu') return done(() => unpin(false));
if (e.part === 'play') return done(() => { st.holds[st.hand] = null; unpin(false); });
if (e.part === 'play') return done(() => { st.holds[holdKey(st.hand)] = null; unpin(false); });
return false;
},
draw(g, r) {
@@ -3428,6 +3491,10 @@ function install(ctx) {
for (const [p, t] of Object.entries(opts.resume)) if (Number.isFinite(t)) resume.set(p, t);
}
if (opts.holds && typeof opts.holds === 'object') st.holds = Object.assign({ left: null, right: null }, opts.holds);
// the body and colours picked last time (the plugin keeps them: device/shell_ext/asspod.py PREFS)
if (opts.prefs && typeof opts.prefs === 'object') {
for (const key of ['model', 'finish', 'nanoFinish']) if (typeof opts.prefs[key] === 'string') settings.set(key, opts.prefs[key]);
}
st.world = null;
st.videoMode = false;
art.videoExternal = false;
@@ -3485,7 +3552,8 @@ function install(ctx) {
function status() {
return {
ok: true, open: st.open, hand: st.hand, closedBy: st.closedBy, loading: st.loading,
hold: st.holdPose || null, bodyScale: BODY_M.w / 0.0618, finish: settings.get('finish'), podd: !!st.podd,
hold: st.holdPose || null, bodyScale: BODY_M.w / 0.0618, nanoScale: MODELS.nano.scale, model: currentModel(settings).id,
finish: currentFinish(settings), podd: !!st.podd,
player: { state: player.state, current: player.current ? { id: player.current.id, title: player.current.title } : null,
position: Math.round((player.position || 0) * 10) / 10, queue: player.queue.length, volume: player.volume },
library: { tracks: library.tracks.length, albums: library.albums.length, videos: library.videos.length },
@@ -3501,6 +3569,7 @@ function install(ctx) {
os.ctx.push({
id: 'notice',
title: String(title || 'assPod'),
adaptive: true,
handle(e) {
if (e.type === 'press' && (e.part === 'select' || e.part === 'menu')) { os.ctx.pop(); return true; }
return e.type === 'scroll' ? false : undefined;
@@ -3542,7 +3611,8 @@ function install(ctx) {
playing: player.state === 'playing', state: player.state,
current: player.current ? player.current.path : null,
volume: player.volume, eq: settings.get('eq'),
hold: st.holdPose || null, world: st.world, holds: st.holds, finish: settings.get('finish'),
hold: st.holdPose || null, world: st.world, holds: st.holds, model: currentModel(settings).id, finish: currentFinish(settings),
prefs: { model: currentModel(settings).id, finish: settings.get('finish'), nanoFinish: settings.get('nanoFinish') },
position: Math.round((player.position || 0) * 1000) / 1000,
video: player.current && player.current.kind === 'video' && os.ctx.top()?.id === 'videoplayer' && st.open,
};
@@ -4398,6 +4468,75 @@ class Player extends Emitter {
this.#settings.set('plays', { ...plays, [item.id]: [item.playCount ?? 0, item.lastPlayed] });
}
}
};
// ---------------------------------------------------------------- models.js
__defs['models.js'] = function (__e) {
__e.currentModel = currentModel;
__e.currentFinish = currentFinish;
__e.bodyMetres = bodyMetres;
// The assPod's bodies: the Video (5th generation, the original) and the nano (2nd generation). The
// main menu's assPod Model picks one; Color picks among that body's own finishes. Both run the same
// OS on a 4:3 screen; the nano's is 240 x 180 logical px against the Video's 320 x 240, so its text is
// larger on its screen, as on the real one. The nano plays no video: its menus leave the Videos out.
//
// Geometry in millimetres, model frame (tools/asspod-model, tools/nano2-model GEOMETRY): origin at
// the body's centre, y toward the top. `scale` is how much larger than life it is shown in the hand;
// podd (native/podd/podd.cpp VIDEO / NANO) and its render models use the same numbers. `screen` is
// the OS's logical screen on that body.
const MODELS = Object.freeze({
video: Object.freeze({
id: 'video',
label: 'Video Gen 1',
about: 'assPod Video',
body: { w: 61.8, h: 103.5, d: 11.0 },
lcd: { cy: 24.2, w: 50.8, h: 38.1 },
win: { w: 53.2, h: 40.5 },
wheel: { cy: -24.0 },
screen: { w: 320, h: 240 }, // logical px (os/theme.js setScreenSize)
scale: 0.085 / 0.0618, // shown 85 mm wide
finishKey: 'finish',
finishes: [['black', 'Black'], ['white', 'White']],
videos: true,
}),
nano: Object.freeze({
id: 'nano',
label: 'Nano Gen 2',
about: 'assPod nano',
body: { w: 40.0, h: 90.0, d: 6.5 },
lcd: { cy: 28.88, w: 29.6, h: 22.2 },
win: { w: 31.62, h: 24.04 },
wheel: { cy: -13.3 },
screen: { w: 240, h: 180 }, // the same text on less screen: six rows, as on the real nano
scale: 1.3125, // shown 52.5 mm wide
finishKey: 'nanoFinish',
finishes: [['silver', 'Silver'], ['green', 'Green'], ['blue', 'Blue'], ['pink', 'Pink'], ['black', 'Black'], ['red', '(PRODUCT)RED']],
videos: false,
}),
});
const MODEL_ORDER = ['video', 'nano'];
/** The current body's definition. */
function currentModel(settings) {
return MODELS[settings.get('model')] || MODELS.video;
}
/** The current body's finish (one of its own; its first otherwise). */
function currentFinish(settings) {
const m = currentModel(settings);
const f = settings.get(m.finishKey);
return m.finishes.some(([k]) => k === f) ? f : m.finishes[0][0];
}
/** The body in metres as shown in the hand: { w, h, d } at the model's scale. */
function bodyMetres(m) {
const k = m.scale / 1000;
return { w: m.body.w * k, h: m.body.h * k, d: m.body.d * k };
}
__e.MODELS = MODELS;
__e.MODEL_ORDER = MODEL_ORDER;
};
// ---------------------------------------------------------------- os/menu.js
__defs['os/menu.js'] = function (__e) {
@@ -4470,7 +4609,11 @@ function createMenu(ctx, def) {
}
function refresh() {
// the highlight stays on its item when the list changes around it (the main menu without Videos)
const was = items[index];
items = resolve(def.items) ?? [];
const j = was ? items.indexOf(was) : -1;
if (j >= 0) index = j;
clampWindow();
}
@@ -4584,9 +4727,11 @@ function createMenu(ctx, def) {
__e.createOS = createOS;
// assPod OS core: screen stack, 220 ms slide transitions, status bar, backlight timer and dimming,
// sleep/wake, the boot logo, the default button behavior, and render throttling for the screen
// texture. Draws the 320x240 logical LCD onto the 640x480 canvas that main.js uploads as a texture.
// texture. Draws the logical LCD (320x240 on the Video, 240x180 on the nano) onto the 640x480 canvas
// that main.js uploads as a texture.
const { SCREEN_W } = __req('config.js');
const { currentModel } = __req('models.js');
const { settings: defaultSettings } = __req('settings.js');
const { drawPeachSilhouette } = __req('brand/peach.js');
const theme = __req('os/theme.js');
@@ -4595,7 +4740,7 @@ const { createMenu } = __req('os/menu.js');
const { createMainMenu } = __req('os/screens/main-menu.js');
const { createNowPlaying } = __req('os/screens/now-playing.js');
const { M, C, ANIM } = theme;
const { M, C, ANIM, setScreenSize } = theme;
/**
* @typedef {import('../input/clickwheel.js').WheelEvent} WheelEvent
@@ -4628,8 +4773,9 @@ const { M, C, ANIM } = theme;
// ---------------------------------------------------------------- constants
const CONTENT = Object.freeze({ x: 0, y: M.statusH, w: M.W, h: M.H - M.statusH });
const FULL = Object.freeze({ x: 0, y: 0, w: M.W, h: M.H });
// the content area under the status bar, and the whole screen (the logical size can change: models.js)
const content = () => ({ x: 0, y: M.statusH, w: M.W, h: M.H - M.statusH });
const full = () => ({ x: 0, y: 0, w: M.W, h: M.H });
const FRAME_MS = 1000 / ANIM.maxFps;
// rAF timestamps jitter by a fraction of a ms; without slack a 25 ms cap would skip a frame at 80 Hz.
const FRAME_SLACK_MS = 1;
@@ -4658,7 +4804,12 @@ function normKey(s) {
*/
function createOS({ canvas, player, library, art, apps, settings = defaultSettings, boot: bootEnabled = false }) {
const g = canvas.getContext('2d');
const scale = canvas.width / SCREEN_W;
// The body's logical screen (models.js): the canvas stays the same size and the scale follows.
const applyModel = () => { const m = currentModel(settings); setScreenSize(m.screen.w, m.screen.h); };
applyModel();
if (typeof settings.on === 'function') {
settings.on('change', ({ key }) => { if (key === 'model' || key === '*') { applyModel(); dirty = DIRTY_NOW; } });
}
/** @type {Screen[]} */
const stack = [];
@@ -4944,8 +5095,18 @@ function createOS({ canvas, player, library, art, apps, settings = defaultSettin
}
const reported = new WeakSet();
// Menus and the screens marked `adaptive` lay themselves out on the current logical screen; the
// rest (the games, the clock, Cover Flow...) are drawn for the Video's 320 x 240 and scaled to fit.
function drawScreen(s, r, now) {
try {
if (!s.isMenu && !s.adaptive && M.W !== SCREEN_W) {
const k = M.W / SCREEN_W;
g.save();
g.scale(k, k);
s.draw(g, { x: r.x / k, y: r.y / k, w: r.w / k, h: r.h / k }, now);
g.restore();
return;
}
s.draw(g, r, now);
} catch (err) {
// One broken screen must not take the whole OS down every frame; report it once.
@@ -4957,12 +5118,13 @@ function createOS({ canvas, player, library, art, apps, settings = defaultSettin
if (dx <= -M.W || dx >= M.W) return;
g.save();
g.beginPath();
g.rect(CONTENT.x, CONTENT.y, CONTENT.w, CONTENT.h);
const r = content();
g.rect(r.x, r.y, r.w, r.h);
g.clip();
g.translate(dx, 0);
g.fillStyle = C.bg;
g.fillRect(CONTENT.x, CONTENT.y, CONTENT.w, CONTENT.h);
drawScreen(s, CONTENT, now);
g.fillRect(r.x, r.y, r.w, r.h);
drawScreen(s, r, now);
g.restore();
}
@@ -4981,7 +5143,7 @@ function createOS({ canvas, player, library, art, apps, settings = defaultSettin
g.fillStyle = '#000000';
g.fillRect(0, 0, M.W, M.H);
}
drawScreen(s, FULL, now);
drawScreen(s, full(), now);
g.restore();
return;
}
@@ -4993,6 +5155,7 @@ function createOS({ canvas, player, library, art, apps, settings = defaultSettin
}
function paint(now) {
const scale = canvas.width / M.W;
g.setTransform(scale, 0, 0, scale, 0, 0);
g.globalAlpha = 1;
g.globalCompositeOperation = 'source-over';
@@ -5226,6 +5389,7 @@ __e.createMainMenu = createMainMenu;
const { createRng, hashString } = __req('util/rng.js');
const { createSettings } = __req('os/screens/settings.js');
const { createSearch } = __req('os/screens/search.js');
const { MODELS, MODEL_ORDER, currentModel, currentFinish } = __req('models.js');
// ---------------------------------------------------------------- sorting
@@ -5254,7 +5418,9 @@ function albumOrder(library) {
* A list of songs (or videos, in playlists). Selecting row i plays the whole list from i,
* the way the assPod queues whatever list you picked from, then opens Now Playing.
*/
function songList(ctx, id, title, list) {
function songList(ctx, id, title, all) {
// the nano plays no video: a playlist's videos are left out on it
const list = currentModel(ctx.settings).videos ? all : all.filter((t) => t.kind !== 'video');
const items = list.map((t, i) => ({
label: t.title,
speaker: () => ctx.player.current?.id === t.id && ctx.player.state !== 'stopped',
@@ -5392,31 +5558,84 @@ function shuffleSongs(ctx) {
return true;
}
// ---------------------------------------------------------------- the body: model and colour
/** assPod Model: the Video or the nano, checkmarked; picking one switches the body and goes back.
* The nano plays no video: a video playing stops when it is picked. */
function modelMenu(ctx) {
return ctx.menu({
id: 'model',
title: 'assPod Model',
initialIndex: () => Math.max(0, MODEL_ORDER.indexOf(currentModel(ctx.settings).id)),
items: MODEL_ORDER.map((id) => ({
label: MODELS[id].label,
check: () => currentModel(ctx.settings).id === id,
action: (c) => {
if (!MODELS[id].videos && c.player.current?.kind === 'video') c.player.stop();
c.settings.set('model', id);
c.pop();
return true;
},
})),
});
}
/** Color: the current body's own finishes, checkmarked. */
function colorMenu(ctx) {
const m = currentModel(ctx.settings);
return ctx.menu({
id: 'color',
title: 'Color',
initialIndex: () => Math.max(0, m.finishes.findIndex(([k]) => k === currentFinish(ctx.settings))),
items: m.finishes.map(([key, label]) => ({
label,
check: () => currentFinish(ctx.settings) === key,
action: (c) => {
c.settings.set(m.finishKey, key);
c.pop();
return true;
},
})),
});
}
// ---------------------------------------------------------------- main menu
function createMainMenu(ctx) {
const base = [
const finishLabel = () => {
const m = currentModel(ctx.settings);
const f = currentFinish(ctx.settings);
return (m.finishes.find(([k]) => k === f) || m.finishes[0])[1];
};
const all = [
{ id: 'music', label: 'Music', open: createMusicMenu },
{ id: 'videos', label: 'Videos', open: (c) => c.apps.videos(c) },
{ id: 'extras', label: 'Extras', open: (c) => c.apps.extras(c) },
{ id: 'settings', label: 'Settings', open: createSettings },
{ id: 'reposition', label: 'Reposition', open: (c) => c.apps.reposition(c) },
{
id: 'color',
label: 'Color',
detail: () => (ctx.settings.get('finish') === 'white' ? 'White' : 'Black'),
action: (c) => { c.settings.set('finish', c.settings.get('finish') === 'white' ? 'black' : 'white'); return true; },
},
{ id: 'model', label: 'assPod Model', detail: () => currentModel(ctx.settings).label, open: modelMenu },
{ id: 'color', label: 'Color', detail: finishLabel, open: colorMenu },
{ id: 'shuffle', label: 'Shuffle Songs', action: shuffleSongs },
{ id: 'backlight', label: 'Backlight', action: (c) => { c.os.toggleBacklight(); return false; } },
];
const withNowPlaying = [
...base,
{ id: 'nowplaying', label: 'Now Playing', chevron: true, action: (c) => { c.nowPlaying(); return true; } },
];
const nowPlaying = { id: 'nowplaying', label: 'Now Playing', chevron: true, action: (c) => { c.nowPlaying(); return true; } };
// the same item objects every time, so the menu keeps its place across a model switch
const lists = new Map();
return ctx.menu({
id: 'main',
title: 'assPod',
// Like the 5G, Now Playing is listed only while something is queued (not after the queue ends).
items: () => (ctx.player.current && ctx.player.state !== 'stopped' ? withNowPlaying : base),
// Like the 5G, Now Playing is listed only while something is queued (not after the queue ends);
// the nano has no Videos.
items: () => {
const videos = currentModel(ctx.settings).videos;
const playing = !!(ctx.player.current && ctx.player.state !== 'stopped');
const key = `${videos}|${playing}`;
if (!lists.has(key)) {
const base = videos ? all : all.filter((it) => it.id !== 'videos');
lists.set(key, playing ? [...base, nowPlaying] : base);
}
return lists.get(key);
},
});
}
};
@@ -5427,23 +5646,40 @@ __e.createNowPlaying = createNowPlaying;
// artist / album on the right, and a progress bar with elapsed and remaining time.
// Center click cycles progress -> scrubber -> rating; the wheel sets volume, seeks or rates.
const { SCREEN_SCALE } = __req('config.js');
const { SCREEN_W, SCREEN_SCALE } = __req('config.js');
const { M } = __req('os/theme.js');
// ---------------------------------------------------------------- layout
const POS = { x: 10, y: 40 };
const ART = { x: 10, y: 48, s: 112 };
const TEXT = { x: 132, w: 180, title: 82, artist: 102, album: 122 };
const BAR = { x: 10, y: 182, w: 300, h: 10 };
const TIME_Y = 208;
const STARS = { cx: 160, cy: 187 };
const VOLUME = { x: 10, y: 180, w: 300 };
// The Video's 320 x 240 layout, and the nano's on its 240 x 180 screen (the same text, less room:
// smaller art and a shorter bar). relayout() picks the one for the current logical screen.
const LAYOUTS = {
320: {
POS: { x: 10, y: 40 }, ART: { x: 10, y: 48, s: 112 }, TEXT: { x: 132, w: 180, title: 82, artist: 102, album: 122 },
BAR: { x: 10, y: 182, w: 300, h: 10 }, TIME_Y: 208, STARS: { cx: 160, cy: 187 }, VOLUME: { x: 10, y: 180, w: 300 },
GLYPH: { right: 310, w: 12, h: 9, gap: 5 },
},
240: {
POS: { x: 8, y: 38 }, ART: { x: 8, y: 46, s: 80 }, TEXT: { x: 96, w: 136, title: 66, artist: 84, album: 102 },
BAR: { x: 8, y: 136, w: 224, h: 9 }, TIME_Y: 160, STARS: { cx: 120, cy: 141 }, VOLUME: { x: 8, y: 134, w: 224 },
GLYPH: { right: 232, w: 12, h: 9, gap: 5 },
},
};
let POS, ART, TEXT, BAR, TIME_Y, STARS, VOLUME, GLYPH, ART_PX;
let laidOut = 0;
function relayout() {
if (laidOut === M.W) return;
laidOut = M.W;
({ POS, ART, TEXT, BAR, TIME_Y, STARS, VOLUME, GLYPH } = LAYOUTS[M.W] || LAYOUTS[320]);
// the cover is resampled to the art box's backing-store size (the canvas is 640 px wide)
ART_PX = Math.round(ART.s * SCREEN_W * SCREEN_SCALE / M.W);
}
relayout();
const VOLUME_STEPS = 16;
const SCRUB_STEP_S = 2;
const SCRUB_FAST_MS = 120; // steps closer than this count as "scrolling fast"
const MODES = ['progress', 'scrub', 'rating'];
const GLYPH = { right: 310, w: 12, h: 9, gap: 5 }; // shuffle / repeat marks, right of "n of N"
/** Grey square with a beamed note, for items without cover art. */
function drawPlaceholderArt(g, x, y, s) {
@@ -5519,14 +5755,14 @@ function drawRepeatGlyph(g, x, y, color, one, oneFont) {
// The 256px cover is resampled once per album to the art box's backing-store size (224px), so
// each 4 Hz (or 40 fps marquee) redraw is a 1:1 blit instead of a high-quality downscale.
const ART_PX = ART.s * SCREEN_SCALE;
let artKey = null;
let artPx = 0;
let artSrc = null;
let artCanvas = null;
function scaledCover(src, key) {
// The source canvas counts too: art.clear() redraws covers (late web fonts) as new canvases.
if (key === artKey && src === artSrc && artCanvas) return artCanvas;
if (key === artKey && src === artSrc && artCanvas && artPx === ART_PX) return artCanvas;
if (typeof document === 'undefined') return src;
artCanvas ??= document.createElement('canvas');
artCanvas.width = artCanvas.height = ART_PX;
@@ -5536,6 +5772,7 @@ function scaledCover(src, key) {
g.drawImage(src, 0, 0, ART_PX, ART_PX);
artKey = key;
artSrc = src;
artPx = ART_PX;
return artCanvas;
}
@@ -5582,6 +5819,7 @@ function createNowPlaying(ctx) {
const screen = {
id: 'nowplaying',
adaptive: true,
title: 'Now Playing',
// Only a moving playhead needs the 4 Hz refresh; paused, the screen is static (no uploads).
get refreshMs() { return player.state === 'playing' || player.seeking ? 250 : 0; },
@@ -5601,6 +5839,7 @@ function createNowPlaying(ctx) {
},
handle(e) {
relayout();
const now = ctx.now();
const cur = player.current;
if (e.type === 'press' && e.part === 'select') {
@@ -5641,6 +5880,7 @@ function createNowPlaying(ctx) {
},
draw(g, r, now) {
relayout();
g.fillStyle = C.bg;
g.fillRect(r.x, r.y, r.w, r.h);
const cur = player.current;
@@ -5735,6 +5975,7 @@ function createNowPlaying(ctx) {
},
animating(now) {
relayout();
// One redraw when the volume overlay or a scrubber/rating mode times out, even when paused.
if (volumeUntil && drawnAt < volumeUntil && now >= volumeUntil) return true;
const modeEnd = modeAt + ANIM.npModeTimeoutMs;
@@ -5768,7 +6009,6 @@ const { songList, albumSongs } = __req('os/screens/main-menu.js');
const KEYS = [...'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789', ' ', 'DEL', 'DONE'];
const STRIP_H = 44; // letter band at the bottom of the content area
const RESULT_ROWS = 7; // 7 x 24 px rows above the band
const KEY_W = 22;
function keyLabel(k) {
@@ -5783,6 +6023,8 @@ function createSearch(ctx) {
const keyFont = font(15, 600);
const smallKeyFont = font(10, 700);
// result rows above the letter band: 7 on the Video, 4 on the nano
const resultRows = () => Math.floor((M.H - M.statusH - STRIP_H) / M.rowH);
let query = '';
let keyIndex = 0;
let inResults = false;
@@ -5849,6 +6091,7 @@ function createSearch(ctx) {
return {
id: 'search',
title: 'Search',
adaptive: true, // lays itself out on the current logical screen (os.js)
enter() { runSearch(); },
handle(e) {
if (e.type === 'scroll') {
@@ -5858,7 +6101,7 @@ function createSearch(ctx) {
if (next >= results.length) return false;
index = next;
if (index < first) first = index;
if (index >= first + RESULT_ROWS) first = index - RESULT_ROWS + 1;
if (index >= first + resultRows()) first = index - resultRows() + 1;
return true;
}
const next = keyIndex + e.delta;
@@ -5884,10 +6127,11 @@ function createSearch(ctx) {
draw(g, r, now) {
g.fillStyle = C.bg;
g.fillRect(r.x, r.y, r.w, r.h);
const listH = RESULT_ROWS * M.rowH;
const scroll = results.length > RESULT_ROWS;
const rows = resultRows();
const listH = rows * M.rowH;
const scroll = results.length > rows;
const w = scroll ? M.W - M.scrollbarW : M.W;
for (let i = first; i < Math.min(results.length, first + RESULT_ROWS); i++) {
for (let i = first; i < Math.min(results.length, first + rows); i++) {
const res = results[i];
row.label = res.label;
row.detail = res.kind;
@@ -5902,7 +6146,7 @@ function createSearch(ctx) {
g.fillText('No results', M.W / 2, r.y + 60);
g.restore();
}
if (scroll) ui.drawScrollbar(g, M.W - M.scrollbarW, r.y, listH, results.length, RESULT_ROWS, first);
if (scroll) ui.drawScrollbar(g, M.W - M.scrollbarW, r.y, listH, results.length, rows, first);
drawStrip(g, r.y + r.h - STRIP_H);
},
highlight() {
@@ -5919,7 +6163,9 @@ __e.createSettings = createSettings;
// Settings, following the 5G: values cycle in place (Shuffle, Repeat, Clicker),
// option lists show a checkmark and return to Settings when one is picked (Backlight Timer, EQ),
// Brightness has its own slider screen, plus About, Date & Time, Legal and Reset Settings.
// EQ is VLC's own equalizer presets. (The body's colour is on the main menu: Color.)
// EQ is VLC's own equalizer presets. (The body and its colour are on the main menu: assPod Model, Color.)
const { currentModel } = __req('models.js');
// ---------------------------------------------------------------- option tables
@@ -6050,22 +6296,23 @@ function aboutScreen(ctx) {
const videos = lib.videos?.length ?? 0;
const albums = lib.albums?.length ?? 0;
const secs = (list) => (list ?? []).reduce((s, t) => s + (t.duration || 0), 0);
const hours = (secs(lib.tracks) + secs(lib.videos)) / 3600;
const hours = (secs(lib.tracks) + (currentModel(ctx.settings).videos ? secs(lib.videos) : 0)) / 3600;
const info = lib.info || {};
const lines = [
['Songs', songs.toLocaleString('en-US')],
['Albums', albums.toLocaleString('en-US')],
['Videos', videos.toLocaleString('en-US')],
...(currentModel(ctx.settings).videos ? [['Videos', videos.toLocaleString('en-US')]] : []),
['Playing Time', `${hours.toFixed(1)} h`],
['Sources', String((info.sources || []).length)],
['Player', 'VLC'],
['Model', 'assPod Video'],
['Model', currentModel(ctx.settings).about],
];
const total = lines.length + 1; // + the name line
let first = 0;
return {
id: 'about',
title: 'About',
adaptive: true,
handle(e) {
if (e.type === 'scroll') {
const next = Math.max(0, Math.min(total - M.rows, first + e.delta));
@@ -6252,6 +6499,7 @@ __e.EQ_NAMES = EQ_NAMES;
};
// ---------------------------------------------------------------- os/theme.js
__defs['os/theme.js'] = function (__e) {
__e.setScreenSize = setScreenSize;
// 5G visual spec, logical px on the 320x240 screen
const { SCREEN_W, SCREEN_H } = __req('config.js');
@@ -6273,7 +6521,7 @@ const FONTS = {
};
const M = {
W: SCREEN_W, H: SCREEN_H,
W: SCREEN_W, H: SCREEN_H, // the logical screen: setScreenSize() switches it (the nano's is smaller)
statusH: 22, // includes the 1px bottom border; content starts at y=22
rowH: 24, rows: 9, // list area y=22..238 (2px white margin at the bottom)
padX: 8, // label left inset
@@ -6282,6 +6530,17 @@ const M = {
scrollbarW: 8, // at x=312..320 when a list overflows; rows then end at 312
};
/**
* The logical screen, in the theme's own px: the Video's 320 x 240, or the nano's 240 x 180
* (models.js). Text and rows keep their size, so on the nano they fill more of the screen (six rows,
* as on the real one); the list's row count follows.
*/
function setScreenSize(w, h) {
M.W = w;
M.H = h;
M.rows = Math.floor((h - M.statusH - 2) / M.rowH);
}
const C = {
bg: '#ffffff', text: '#000000', textHi: '#ffffff', textDim: '#6b6b6b',
status: [[0, '#fdfdfd'], [0.5, '#e4e4e4'], [1, '#c3c3c3']],
@@ -6508,7 +6767,7 @@ function drawBattery(g, x, y) {
g.lineWidth = 1;
roundRectPath(g, 0.5, 0.5, 23, 10, 1.5);
g.stroke();
// Terminal nub, 2x5 at x = 311.
// Terminal nub, 2x5 at the body's right.
g.fillStyle = C.batteryBorder;
g.fillRect(24, 3, 2, 5);
g.restore();
@@ -6526,7 +6785,7 @@ function drawStatusBar(g, s) {
g.fillStyle = C.statusLine;
g.fillRect(0, M.statusH - 1, M.W, 1);
const title = ellipsize(g, s.title ?? '', FONTS.title, 220);
const title = ellipsize(g, s.title ?? '', FONTS.title, M.W - 100);
g.font = FONTS.title;
g.textAlign = 'center';
g.textBaseline = 'alphabetic';
@@ -6537,7 +6796,7 @@ function drawStatusBar(g, s) {
if (s.play === 'playing' || s.play === 'paused') drawPlayIcon(g, 8, ICON_Y, s.play);
if (s.locked) drawLock(g, 22, 5);
drawBattery(g, 287, ICON_Y);
drawBattery(g, M.W - 33, ICON_Y);
g.restore();
}
@@ -6970,7 +7229,9 @@ __e.qconj = qconj;
// ---------------------------------------------------------------- settings.js
__defs['settings.js'] = function (__e) {
// The assPod's settings, for this session only: kept in memory in SteamVR's page (never written to
// disk, like the rest of LiquidAss), so a SteamVR restart starts from the defaults.
// disk, like the rest of LiquidAss), so a SteamVR restart starts from the defaults. The exception, by
// request: the body and its colours (model, finish, nanoFinish), which the plugin keeps across
// launches (device/shell_ext/asspod.py PREFS) and hands back at each open.
const { Emitter } = __req('util/emitter.js');
const DEFAULTS = Object.freeze({
@@ -6982,7 +7243,9 @@ const DEFAULTS = Object.freeze({
clicker: true,
volume: 0.6, // 0..1
timeFormat: '12', // '12' | '24'
finish: 'black', // 'black' | 'white': the body
model: 'video', // 'video' | 'nano': the body (models.js)
finish: 'black', // the Video's colour: 'black' | 'white'
nanoFinish: 'silver', // the nano's: 'silver' | 'green' | 'blue' | 'pink' | 'black' | 'red'
ratings: {}, // trackId -> 0..5 (always replace the object, never mutate it)
plays: {}, // trackId -> [playCount, lastPlayed]
});
@@ -7011,8 +7274,9 @@ __e.settings = settings;
// ---------------------------------------------------------------- sg.js
__defs['sg.js'] = function (__e) {
__e.createSceneGraph = createSceneGraph;
// The assPod's nodes in SteamVR's scene graph (systemui page): the assPod in one hand (the original
// 3D model as SteamVR render models, device/asspod/model, with the live screen on a panel), and
// The assPod's nodes in SteamVR's scene graph (systemui page): the assPod in one hand (its 3D model,
// the Video or the nano, as SteamVR render models from device/asspod/model, or drawn by podd, with
// the live screen on a panel), and
// while it is out an invisible laser blocker where the menu was, with "(menu glyph) to close".
//
// vsg-transform parent-path=/user/hand/<side> the controller's pose
@@ -7039,12 +7303,12 @@ const MAIN = 'system.standalone::valve.steam.gamepadui.main';
// The sheet, in the window's frame units (the window is 2.67 x 1.5 above its bottom centre; the bar
// hangs below it): from under the bar to just over the top, a little wider than the window.
const SHEET = { w: 3.2, h: 2.25, y: 0.55, z: 0.06 }; // the blocker
const MODEL_W = 0.0618; // m: the model's own width (tools/asspod-model, GEOMETRY.BODY)
// The Video's geometry (model metres, tools/asspod-model GEOMETRY), the default when show() gets none.
const VIDEO_GEO = { w: 0.0618, d: 0.0110, lcdCy: 0.0242, lcdW: 0.0508, winW: 0.0532, winH: 0.0405, wheelCy: -0.0240 };
const VIDEO_SCALE = 0.085 / 0.0618; // as shown (main.js FACE_WIDTH_M): DEFAULT_HOLD is for this body
const OVERLAY_DZ = 0.00025; // m (model units) in front of the plate: the screen panel (render-model body)
const SCREEN_RECESS = 0.0004; // m (model units) behind the plate's surface: the screen under podd's glass
const VIDEO_BELOW = 0.0001; // m (model units) behind the screen panel: podd's video picture
const LCD_M = { cy: 0.0242, w: 0.0508 }; // the LCD on the model (face.js LCD, podd.cpp LCD_CY)
const WIN_M = { w: 0.0532, h: 0.0405 }; // its window (face.js WINDOW, podd.cpp WIN)
const CAPTION = { w: 1.0, y: 0.75, z: 0.075 };
// The body in the hand (controller frame: x right, y up, -z forward): its rotation (w x y z) and
// centre for the right hand, as placed with Reposition; the left hand mirrors it (defaultHold).
@@ -7209,24 +7473,22 @@ function createSceneGraph() {
// stereoscopy: the left half of its rect to the left eye). podd renders each eye's view of the
// model through each face; the faces write no depth, so the screen inside shows through podd's
// glass. The faces match podd's (native/podd/podd.cpp makeLayout): centre, right, up, normal.
function poddBody(parent, b, rects, pd, video) {
const k = b.w / MODEL_W;
// The screen panel is the window only, centred on it like the video panel: SteamVR orders
// see-through panels by their centres, so two panels on one axis keep their order from any angle
// (offset centres swapped it head on, and the screen hid the video).
const ppm = rects.face.w / b.w;
const ww = WIN_M.w * k * ppm, wh = WIN_M.h * k * ppm;
const win = { x: rects.face.x + rects.face.w / 2 - ww / 2, y: rects.face.y + rects.face.h / 2 - LCD_M.cy * k * ppm - wh / 2, w: ww, h: wh };
function poddBody(parent, b, rects, pd, video, geo) {
const k = b.w / geo.w;
// The screen panel is the window only (rects.win: where face.js draws it), centred on it like the
// video panel: SteamVR orders see-through panels by their centres, so two panels on one axis keep
// their order from any angle (offset centres swapped it head on, and the screen hid the video).
const win = rects.win;
if (video && Array.isArray(pd.video)) {
// the video's picture (podd's video tile, the frame twice: one per eye) just under the screen,
// which is clear over it but for the overlays; first, so the screen blends over it
const [vx, vy, vw, vh] = pd.video;
const pic = vxf([0, LCD_M.cy * k, b.d / 2 - (SCREEN_RECESS + VIDEO_BELOW) * k]);
const pic = vxf([0, geo.lcdCy * k, b.d / 2 - (SCREEN_RECESS + VIDEO_BELOW) * k]);
pic.appendChild(vnode('panel', {
key: pd.key,
uv_min: [r6(vx / pd.texW), r6(vy / pd.texH)],
uv_max: [r6((vx + 2 * vw) / pd.texW), r6((vy + vh) / pd.texH)],
'meters-per-pixel': r6(LCD_M.w * k / vw),
'meters-per-pixel': r6(geo.lcdW * k / vw),
origin: [0, 0],
interactive: false,
visibility: 0,
@@ -7237,8 +7499,8 @@ function createSceneGraph() {
parent.appendChild(pic);
}
// recessed in the plate's opening, under podd's glass (which sits on the plate's surface)
const front = vxf([0, LCD_M.cy * k, b.d / 2 - SCREEN_RECESS * k]);
front.appendChild(pagePanel(win, b.w / rects.face.w, 'lgs-asspod-screen'));
const front = vxf([0, geo.lcdCy * k, b.d / 2 - SCREEN_RECESS * k]);
front.appendChild(pagePanel(win, geo.winW * k / win.w, 'lgs-asspod-screen'));
parent.appendChild(front);
const [hx, hy, hz] = pd.box;
const faces = {
@@ -7275,8 +7537,23 @@ function createSceneGraph() {
// The body without podd: the model's parts as SteamVR render models (fixed lighting), the screen
// panel just in front of the plate.
function body(parent, b, rects, modelDir, finish) {
const k = b.w / MODEL_W;
function body(parent, b, rects, modelDir, finish, model, geo) {
const k = b.w / geo.w;
if (model === 'nano') {
// the nano's parts: its colour's aluminum, the shared caps, ports and well; the screen panel
// over its window
const scaled = vxf([0, 0, 0]);
scaled.setAttribute('scale', [k, k, k].map(r6).join(' '));
const wheel = finish === 'black' ? 'black' : 'white';
const parts = [`nano_shell_${finish}`, `nano_back_${finish}`, `nano_center_${finish}`, `nano_wheel_${wheel}`,
'nano_cap_top', 'nano_cap_bottom', 'nano_ports', 'nano_well'];
for (const name of parts) scaled.appendChild(vnode('rendermodel', { source: `${modelDir}/${name}/${name}.obj` }));
parent.appendChild(scaled);
const front = vxf([0, geo.lcdCy * k, b.d / 2 + OVERLAY_DZ * k]);
front.appendChild(pagePanel(rects.win, geo.winW * k / rects.win.w, 'lgs-asspod-screen'));
parent.appendChild(front);
return;
}
const fin = finish === 'white' ? 'white' : 'black';
const scaled = vxf([0, 0, 0]);
scaled.setAttribute('scale', [k, k, k].map(r6).join(' '));
@@ -7294,15 +7571,24 @@ function createSceneGraph() {
// The default place in the hand: where you put it with Reposition (2026-10-09, right hand), and
// its mirror image across the controller's centre line for the left hand.
function defaultHold(side) {
// Another body keeps its wheel where the Video's is (over the stick), front faces level.
function defaultHold(side, b, geo) {
const r = DEFAULT_HOLD;
if (side === 'right') return { rot: r.rot.slice(), centre: r.centre.slice() };
return { rot: [r.rot[0], r.rot[1], -r.rot[2], -r.rot[3]], centre: [-r.centre[0], r.centre[1], r.centre[2]] };
const h = side === 'right'
? { rot: r.rot.slice(), centre: r.centre.slice() }
: { rot: [r.rot[0], r.rot[1], -r.rot[2], -r.rot[3]], centre: [-r.centre[0], r.centre[1], r.centre[2]] };
const k = b.w / geo.w;
const wheelV = [0, VIDEO_GEO.wheelCy * VIDEO_SCALE, VIDEO_GEO.d * VIDEO_SCALE / 2];
const wheelM = [0, geo.wheelCy * k, b.d / 2];
const d = qrot(h.rot, wheelV.map((v, i) => v - wheelM[i]));
h.centre = h.centre.map((v, i) => v + d[i]);
return h;
}
/**
* Shows the assPod in `hand` ('left' | 'right') with the sheet over the menu.
* @param {{ hand: string, body: {w,h,d,r}, rects: {face, back, dim, text}, modelDir: string, finish: string,
* @param {{ hand: string, body: {w,h,d,r}, rects: {face, back, win, dim, text}, modelDir: string, finish: string,
* model?: 'video'|'nano', geo?: {w, d, lcdCy, lcdW, winW, winH, wheelCy} (model metres),
* podd?: { key, texW, texH, box, tiles }, hold?: { rot, centre }, world?: { rot, centre } }} o
* podd: its layout (podd-out.json) when podd draws the body; hold: a place in the hand other than
* the default; world: pinned in the room (standing space) instead of on the hand
@@ -7313,14 +7599,15 @@ function createSceneGraph() {
if (!attach()) return null;
clearNodes();
const side = o.hand === 'left' ? 'left' : 'right';
const { rot, centre } = o.hold && o.hold.rot && o.hold.centre ? o.hold : defaultHold(side);
const geo = o.geo || VIDEO_GEO;
const { rot, centre } = o.hold && o.hold.rot && o.hold.centre ? o.hold : defaultHold(side, o.body, geo);
// pinned in the room (Reposition: the controller moves, the assPod stays), else on the hand
const hand = o.world
? vxf(o.world.centre, o.world.rot)
: vxf([0, 0, 0], null, { 'parent-path': '/user/hand/' + side });
const hold = o.world ? vxf([0, 0, 0]) : vxf(centre, rot);
if (o.podd && o.podd.tiles && Array.isArray(o.podd.box)) poddBody(hold, o.body, o.rects, o.podd, o.video);
else body(hold, o.body, o.rects, o.modelDir, o.finish);
if (o.podd && o.podd.tiles && Array.isArray(o.podd.box)) poddBody(hold, o.body, o.rects, o.podd, o.video, geo);
else body(hold, o.body, o.rects, o.modelDir, o.finish, o.model || 'video', geo);
hand.appendChild(hold);
st.root.appendChild(hand);
// the sheet and the caption, on Steam's window
Loaded 100 of 111 files, more files were not shown because too many files have changed in this diff. Show more