Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7775b8d683 | ||
|
|
8b422d1cd6 | ||
|
|
d19fd83a27 | ||
|
|
80d4710f3f | ||
|
|
3eac2fbff1 | ||
|
|
37502c7ac7 | ||
|
|
fe798703ea |
No files matched your search
@@ -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,28 +64,30 @@ 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
|
||||
```
|
||||
|
||||
On the headset itself, `~/.local/share/glass-shell/device/lgs on|off|toggle|status|dial V` does the same.
|
||||
|
||||
Native glass works out of the box. Releases ship glassd built for the Frame (`native/glassd/prebuilt/glassd`), and the installer puts it in place. `python glass.py install` does the same, then rebuilds glassd from the checkout's sources over it (if that build fails, the shipped binary stays). The daemon also puts the shipped binary in place whenever glassd is missing, so the LiquidAss toggle never starts CSS-only. Native mode is on by default (`device/defaults.json`).
|
||||
Native glass works out of the box. Releases ship glassd built for the Frame (`native/glassd/prebuilt/glassd`), and the installer puts it in place. `python glass.py install` does the same, then rebuilds glassd from the checkout's sources over it (if that build fails, the shipped binary stays). The daemon also puts the shipped binary in place whenever glassd is missing, so the LiquidAss toggle never starts CSS-only. Native mode is on by default (`device/defaults.json`). The assPod's 3D renderer, podd, ships the same way (`native/podd/prebuilt/podd`).
|
||||
|
||||
## How it works
|
||||
|
||||
|
||||
@@ -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
|
||||
|
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
|
||||
|
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
|
||||
|
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
|
||||
|
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
|
||||
|
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
|
||||
|
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
|
||||
|
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
|
||||
|
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
|
||||
|
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
|
||||
|
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
|
||||
|
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
|
||||
|
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
|
||||
|
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
|
||||
|
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
|
||||
|
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
|
||||
|
After Width: | Height: | Size: 127 B |
@@ -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
|
||||
|
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
|
||||
|
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
|
||||
|
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
|
||||
|
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
|
||||
|
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
|
||||
|
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
|
||||
|
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
|
||||
|
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
|
||||
|
After Width: | Height: | Size: 21 KiB |
@@ -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
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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']],
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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]
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -54,8 +54,17 @@ ACTIONS = {
|
||||
PAGE = "systemui"
|
||||
MODEL_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.realpath(__file__))), "asspod", "model") # the 3D body
|
||||
PODD = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.realpath(__file__)))), "native", "podd", "podd")
|
||||
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"
|
||||
@@ -596,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"]:
|
||||
@@ -1080,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)
|
||||
@@ -1112,12 +1124,19 @@ 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):
|
||||
# the release's shipped podd (built for the Frame), when none was built here
|
||||
try:
|
||||
shutil.copyfile(PODD_PREBUILT, PODD)
|
||||
os.chmod(PODD, 0o755)
|
||||
except OSError as e:
|
||||
_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):
|
||||
@@ -1169,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):
|
||||
@@ -1178,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')}")
|
||||
|
||||
@@ -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
|
||||
|
||||