assPod: a music and video player in the hand, on VLC
A music note button on the dashboard bar, left of the paintbrush, opens the assPod in the hand whose laser pressed it. VLC plays its media library's music and videos in the background; both sticks turn the click wheel, A and stick clicks press the centre, B is Menu and the hamburger button puts it away. The rest of the UI fades out while it is open. - device/asspod: the pod's OS, screens, VLC engine and scene graph (bundled by tools/build_asspod.py into device/vr/systemui.asspod.js), with the model exported from tools/asspod-model - native/podd: renders the 3D body with real shaders into a box of stereo portal panels, and the playing video's picture under the screen - device/shell_ext/asspod.py: VLC, the library scan, art, podd, and video frames from ffmpeg paced to VLC's clock; videos keep their place across restarts - Reposition and Black/White finish on the main menu - install.sh offers to install VLC when it is missing
No files matched your search
@@ -46,6 +46,10 @@ Press the paintbrush button at the right end of the dashboard bar. Its panel set
|
||||
|
||||
<p align="center"><img src="assets/screenshots/Configure.jpg" alt="The paintbrush glass panel: colour, tint strength, intensity, refraction, frost, highlights" width="80%"></p>
|
||||
|
||||
## Open the assPod
|
||||
|
||||
Press the music note button on the dashboard bar, just left of the paintbrush. The assPod appears in the hand you pressed it with.
|
||||
|
||||
## 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:
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
newmtl asspod_center_black
|
||||
Kd 1 1 1
|
||||
Ns 512
|
||||
d 1
|
||||
illum 2
|
||||
Ka 0 0 0
|
||||
Ks 0.6 0.6 0.6
|
||||
map_Kd asspod_center_black_color.png
|
||||
|
After Width: | Height: | Size: 108 B |
@@ -0,0 +1,8 @@
|
||||
newmtl asspod_center_white
|
||||
Kd 1 1 1
|
||||
Ns 512
|
||||
d 1
|
||||
illum 2
|
||||
Ka 0 0 0
|
||||
Ks 0.6 0.6 0.6
|
||||
map_Kd asspod_center_white_color.png
|
||||
|
After Width: | Height: | Size: 108 B |
@@ -0,0 +1,8 @@
|
||||
newmtl asspod_plate_black
|
||||
Kd 1 1 1
|
||||
Ns 512
|
||||
d 1
|
||||
illum 2
|
||||
Ka 0 0 0
|
||||
Ks 0.6 0.6 0.6
|
||||
map_Kd asspod_plate_black_color.png
|
||||
|
After Width: | Height: | Size: 108 B |
@@ -0,0 +1,8 @@
|
||||
newmtl asspod_plate_white
|
||||
Kd 1 1 1
|
||||
Ns 512
|
||||
d 1
|
||||
illum 2
|
||||
Ka 0 0 0
|
||||
Ks 0.6 0.6 0.6
|
||||
map_Kd asspod_plate_white_color.png
|
||||
|
After Width: | Height: | Size: 108 B |
@@ -0,0 +1,8 @@
|
||||
newmtl asspod_ports
|
||||
Kd 1 1 1
|
||||
Ns 256
|
||||
d 1
|
||||
illum 2
|
||||
Ka 0 0 0
|
||||
Ks 0.2 0.2 0.2
|
||||
map_Kd asspod_ports_color.png
|
||||
|
After Width: | Height: | Size: 110 B |
@@ -0,0 +1,8 @@
|
||||
newmtl asspod_steel_back
|
||||
Kd 1 1 1
|
||||
Ns 512
|
||||
d 1
|
||||
illum 2
|
||||
Ka 0 0 0
|
||||
Ks 0.5 0.5 0.5
|
||||
map_Kd asspod_steel_back_color.png
|
||||
|
After Width: | Height: | Size: 534 KiB |
@@ -0,0 +1,8 @@
|
||||
newmtl asspod_steel_edge
|
||||
Kd 1 1 1
|
||||
Ns 512
|
||||
d 1
|
||||
illum 2
|
||||
Ka 0 0 0
|
||||
Ks 0.6 0.6 0.6
|
||||
map_Kd asspod_steel_edge_color.png
|
||||
|
After Width: | Height: | Size: 38 KiB |
@@ -0,0 +1,8 @@
|
||||
newmtl asspod_well_black
|
||||
Kd 1 1 1
|
||||
Ns 256
|
||||
d 1
|
||||
illum 2
|
||||
Ka 0 0 0
|
||||
Ks 0 0 0
|
||||
map_Kd asspod_well_black_color.png
|
||||
|
After Width: | Height: | Size: 107 B |
@@ -0,0 +1,8 @@
|
||||
newmtl asspod_well_white
|
||||
Kd 1 1 1
|
||||
Ns 256
|
||||
d 1
|
||||
illum 2
|
||||
Ka 0 0 0
|
||||
Ks 0 0 0
|
||||
map_Kd asspod_well_white_color.png
|
||||
|
After Width: | Height: | Size: 108 B |
@@ -0,0 +1,8 @@
|
||||
newmtl asspod_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 asspod_wheel_black_color.png
|
||||
|
After Width: | Height: | Size: 30 KiB |
@@ -0,0 +1,8 @@
|
||||
newmtl asspod_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 asspod_wheel_white_color.png
|
||||
|
After Width: | Height: | Size: 38 KiB |
@@ -0,0 +1,344 @@
|
||||
// Brick: the assPod's Breakout. The wheel slides the paddle (10 px per detent), the center button
|
||||
// launches the ball, Play/Pause pauses and MENU leaves. Score, level and lives sit in a status-bar
|
||||
// style strip at the top; glossy bricks and a blue paddle keep it in the 5G's look.
|
||||
import { C, font } from '../os/theme.js';
|
||||
import { SCREEN_W, SCREEN_H } from '../config.js';
|
||||
|
||||
// ---------------------------------------------------------------- layout (spec 6.5)
|
||||
const W = SCREEN_W;
|
||||
const H = SCREEN_H;
|
||||
const TOP = 22; // the score strip occupies 0..22
|
||||
const PADDLE_W = 40;
|
||||
const PADDLE_H = 5;
|
||||
const PADDLE_Y = 228;
|
||||
const PADDLE_STEP = 10;
|
||||
const BALL_R = 3;
|
||||
const COLS = 8;
|
||||
const ROWS = 5;
|
||||
const BRICK_W = 36;
|
||||
const BRICK_H = 10;
|
||||
const GAP_X = 4;
|
||||
const GAP_Y = 3;
|
||||
const BRICKS_X = (W - (COLS * BRICK_W + (COLS - 1) * GAP_X)) / 2;
|
||||
const BRICKS_Y = 40;
|
||||
const ROW_COLORS = [['#ff6b6b', '#d62828'], ['#ffb05a', '#e8730c'], ['#ffe066', '#e0b400'], ['#7ddc6a', '#2f9e44'], ['#6fb6ff', '#1f62c9']];
|
||||
const ROW_STOPS = ROW_COLORS.map(([light, dark]) => [[0, light], [1, dark]]);
|
||||
const FIELD_STOPS = [[0, '#ffffff'], [1, '#dde3ea']];
|
||||
const ROW_POINTS = [7, 5, 3, 2, 1];
|
||||
const LIVES = 3;
|
||||
const SPEED0 = 140; // px/s at level 1
|
||||
const SUBSTEP = 1 / 240; // fixed physics step, so a fast ball can't tunnel through a brick
|
||||
|
||||
const FONT_HUD = font(12, 600);
|
||||
const FONT_LEVEL = font(14, 700);
|
||||
const FONT_BIG = font(20, 700);
|
||||
const FONT_HINT = font(12, 400);
|
||||
|
||||
// Brick layouts per level: 1 = brick, 2 = two-hit brick, 0 = empty.
|
||||
const PATTERNS = [
|
||||
(r, c) => 1,
|
||||
(r, c) => (Math.abs(c - 3.5) <= r + 0.5 ? 1 : 0),
|
||||
(r, c) => ((r + c) % 2 ? 1 : r === 0 ? 2 : 0),
|
||||
(r, c) => (r === 0 || c === 0 || c === COLS - 1 ? 2 : 1),
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------- game state (pure, testable)
|
||||
export function createBrickGame() {
|
||||
const bricks = new Uint8Array(COLS * ROWS);
|
||||
const game = {
|
||||
state: 'ready', // ready (ball on paddle) | playing | paused | dead (between lives) | over | cleared
|
||||
score: 0,
|
||||
level: 1,
|
||||
lives: LIVES,
|
||||
paddleX: (W - PADDLE_W) / 2,
|
||||
ballX: 0, ballY: 0, vx: 0, vy: 0,
|
||||
speed: SPEED0,
|
||||
bricks,
|
||||
broken: 0,
|
||||
hits: 0,
|
||||
effects: [], // { x, y, color, t } brief shards where a brick broke
|
||||
timer: 0,
|
||||
};
|
||||
for (let i = 0; i < 8; i++) game.effects.push({ x: 0, y: 0, color: '', t: 1 });
|
||||
|
||||
function layLevel() {
|
||||
const pat = PATTERNS[(game.level - 1) % PATTERNS.length];
|
||||
for (let r = 0; r < ROWS; r++) for (let c = 0; c < COLS; c++) bricks[r * COLS + c] = pat(r, c);
|
||||
game.speed = SPEED0 * (1 + 0.12 * (game.level - 1));
|
||||
}
|
||||
function remaining() {
|
||||
let n = 0;
|
||||
for (let i = 0; i < bricks.length; i++) if (bricks[i]) n++;
|
||||
return n;
|
||||
}
|
||||
function resetBall() {
|
||||
game.state = 'ready';
|
||||
game.ballX = game.paddleX + PADDLE_W / 2;
|
||||
game.ballY = PADDLE_Y - BALL_R - 0.5;
|
||||
game.vx = 0;
|
||||
game.vy = 0;
|
||||
}
|
||||
game.newGame = () => {
|
||||
game.score = 0;
|
||||
game.level = 1;
|
||||
game.lives = LIVES;
|
||||
game.broken = 0;
|
||||
layLevel();
|
||||
resetBall();
|
||||
};
|
||||
game.launch = () => {
|
||||
if (game.state !== 'ready') return false;
|
||||
// Launch slightly off vertical, toward the side the paddle has more room on.
|
||||
const a = -Math.PI / 2 + (game.paddleX + PADDLE_W / 2 < W / 2 ? 0.35 : -0.35);
|
||||
game.vx = Math.cos(a) * game.speed;
|
||||
game.vy = Math.sin(a) * game.speed;
|
||||
game.state = 'playing';
|
||||
return true;
|
||||
};
|
||||
game.movePaddle = (steps) => {
|
||||
const x = Math.max(0, Math.min(W - PADDLE_W, game.paddleX + steps * PADDLE_STEP));
|
||||
if (x === game.paddleX) return false;
|
||||
game.paddleX = x;
|
||||
if (game.state === 'ready') game.ballX = x + PADDLE_W / 2;
|
||||
return true;
|
||||
};
|
||||
function shard(x, y, color) {
|
||||
let e = game.effects[0];
|
||||
for (const f of game.effects) if (f.t > e.t) e = f;
|
||||
e.x = x; e.y = y; e.color = color; e.t = 0;
|
||||
}
|
||||
function hitBricks() {
|
||||
const c0 = Math.floor((game.ballX - BALL_R - BRICKS_X) / (BRICK_W + GAP_X));
|
||||
const r0 = Math.floor((game.ballY - BALL_R - BRICKS_Y) / (BRICK_H + GAP_Y));
|
||||
for (let r = r0; r <= r0 + 1; r++) {
|
||||
if (r < 0 || r >= ROWS) continue;
|
||||
for (let c = c0; c <= c0 + 1; c++) {
|
||||
if (c < 0 || c >= COLS) continue;
|
||||
const i = r * COLS + c;
|
||||
if (!bricks[i]) continue;
|
||||
const bx = BRICKS_X + c * (BRICK_W + GAP_X);
|
||||
const by = BRICKS_Y + r * (BRICK_H + GAP_Y);
|
||||
// Circle against box: nearest point on the box.
|
||||
const nx = Math.max(bx, Math.min(game.ballX, bx + BRICK_W));
|
||||
const ny = Math.max(by, Math.min(game.ballY, by + BRICK_H));
|
||||
const dx = game.ballX - nx;
|
||||
const dy = game.ballY - ny;
|
||||
if (dx * dx + dy * dy > BALL_R * BALL_R) continue;
|
||||
// Bounce off the face it came through: the axis of least penetration.
|
||||
const penX = Math.min(Math.abs(game.ballX + BALL_R - bx), Math.abs(bx + BRICK_W - (game.ballX - BALL_R)));
|
||||
const penY = Math.min(Math.abs(game.ballY + BALL_R - by), Math.abs(by + BRICK_H - (game.ballY - BALL_R)));
|
||||
if (penX < penY) game.vx = -game.vx; else game.vy = -game.vy;
|
||||
bricks[i]--;
|
||||
game.hits++;
|
||||
if (!bricks[i]) {
|
||||
game.score += ROW_POINTS[r] * game.level;
|
||||
game.broken++;
|
||||
shard(bx + BRICK_W / 2, by + BRICK_H / 2, ROW_COLORS[r][1]);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
function physics(dt) {
|
||||
game.ballX += game.vx * dt;
|
||||
game.ballY += game.vy * dt;
|
||||
if (game.ballX < BALL_R) { game.ballX = BALL_R; game.vx = Math.abs(game.vx); }
|
||||
if (game.ballX > W - BALL_R) { game.ballX = W - BALL_R; game.vx = -Math.abs(game.vx); }
|
||||
if (game.ballY < TOP + BALL_R) { game.ballY = TOP + BALL_R; game.vy = Math.abs(game.vy); }
|
||||
if (game.vy > 0 && game.ballY + BALL_R >= PADDLE_Y && game.ballY - BALL_R <= PADDLE_Y + PADDLE_H
|
||||
&& game.ballX >= game.paddleX - BALL_R && game.ballX <= game.paddleX + PADDLE_W + BALL_R) {
|
||||
// Where it lands on the paddle sets the angle: the edges send it out at 60° from vertical.
|
||||
const off = Math.max(-1, Math.min(1, (game.ballX - (game.paddleX + PADDLE_W / 2)) / (PADDLE_W / 2)));
|
||||
const a = -Math.PI / 2 + off * (Math.PI / 3);
|
||||
game.speed *= 1.01;
|
||||
game.vx = Math.cos(a) * game.speed;
|
||||
game.vy = Math.sin(a) * game.speed;
|
||||
game.ballY = PADDLE_Y - BALL_R;
|
||||
}
|
||||
if (game.vy < 0 || game.ballY < BRICKS_Y + ROWS * (BRICK_H + GAP_Y) + BALL_R) hitBricks();
|
||||
if (game.ballY - BALL_R > H) {
|
||||
game.lives--;
|
||||
game.state = game.lives > 0 ? 'dead' : 'over';
|
||||
game.timer = 0;
|
||||
} else if (!remaining()) {
|
||||
game.state = 'cleared';
|
||||
game.timer = 0;
|
||||
}
|
||||
}
|
||||
game.step = (dt) => {
|
||||
for (const e of game.effects) if (e.t < 1) e.t = Math.min(1, e.t + dt / 0.35);
|
||||
if (game.state === 'playing') {
|
||||
let left = dt;
|
||||
while (left > 0 && game.state === 'playing') {
|
||||
const h = Math.min(SUBSTEP, left);
|
||||
physics(h);
|
||||
left -= h;
|
||||
}
|
||||
} else if (game.state === 'dead' || game.state === 'cleared') {
|
||||
game.timer += dt;
|
||||
if (game.timer > 0.9) {
|
||||
if (game.state === 'cleared') { game.level++; layLevel(); }
|
||||
resetBall();
|
||||
}
|
||||
}
|
||||
};
|
||||
game.busy = () => game.state === 'playing' || game.state === 'dead' || game.state === 'cleared'
|
||||
|| game.effects.some((e) => e.t < 1);
|
||||
game.newGame();
|
||||
return game;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- screen
|
||||
export function brick(ctx) {
|
||||
const { ui } = ctx;
|
||||
const game = createBrickGame();
|
||||
let last = 0;
|
||||
// HUD strings are rebuilt only when the numbers change, not every frame.
|
||||
let hudScore = -1;
|
||||
let hudLevel = -1;
|
||||
let scoreText = '';
|
||||
let levelText = '';
|
||||
|
||||
function drawHud(g) {
|
||||
g.fillStyle = ui.gradient(g, 0, 0, 0, TOP - 1, C.status);
|
||||
g.fillRect(0, 0, W, TOP - 1);
|
||||
g.fillStyle = C.statusLine;
|
||||
g.fillRect(0, TOP - 1, W, 1);
|
||||
if (hudScore !== game.score) { hudScore = game.score; scoreText = `Score: ${hudScore}`; }
|
||||
if (hudLevel !== game.level) { hudLevel = game.level; levelText = `Level ${hudLevel}`; }
|
||||
g.textBaseline = 'alphabetic';
|
||||
g.font = FONT_HUD;
|
||||
g.textAlign = 'left';
|
||||
g.fillStyle = C.text;
|
||||
g.fillText(scoreText, 8, 16);
|
||||
g.font = FONT_LEVEL;
|
||||
g.textAlign = 'center';
|
||||
g.fillStyle = C.statusShadow;
|
||||
g.fillText(levelText, W / 2, 17);
|
||||
g.fillStyle = C.text;
|
||||
g.fillText(levelText, W / 2, 16);
|
||||
// Lives as little paddles, right-aligned where the battery usually is.
|
||||
for (let i = 0; i < game.lives; i++) {
|
||||
const x = W - 8 - (i + 1) * 16 + 2;
|
||||
g.fillStyle = ui.gradient(g, 0, 8, 0, 14, C.hi);
|
||||
g.fillRect(x, 8, 13, 5);
|
||||
g.fillStyle = C.hiBottom;
|
||||
g.fillRect(x, 12, 13, 1);
|
||||
}
|
||||
}
|
||||
|
||||
function drawBrick(g, x, y, r, tough) {
|
||||
g.fillStyle = ui.gradient(g, 0, y, 0, y + BRICK_H, ROW_STOPS[r]);
|
||||
g.fillRect(x, y, BRICK_W, BRICK_H);
|
||||
g.fillStyle = 'rgba(255,255,255,0.45)';
|
||||
g.fillRect(x + 1, y + 1, BRICK_W - 2, 3);
|
||||
g.strokeStyle = tough ? '#333333' : 'rgba(0,0,0,0.25)';
|
||||
g.lineWidth = tough ? 1.5 : 1;
|
||||
g.strokeRect(x + 0.5, y + 0.5, BRICK_W - 1, BRICK_H - 1);
|
||||
}
|
||||
|
||||
function drawBanner(g, line1, line2) {
|
||||
g.fillStyle = 'rgba(255,255,255,0.88)';
|
||||
g.fillRect(40, 132, 240, 50);
|
||||
g.strokeStyle = C.sbBorder;
|
||||
g.lineWidth = 1;
|
||||
g.strokeRect(40.5, 132.5, 239, 49);
|
||||
g.textAlign = 'center';
|
||||
g.fillStyle = C.text;
|
||||
g.font = FONT_BIG;
|
||||
g.fillText(line1, W / 2, 155);
|
||||
if (line2) {
|
||||
g.font = FONT_HINT;
|
||||
g.fillStyle = C.textDim;
|
||||
g.fillText(line2, W / 2, 172);
|
||||
}
|
||||
}
|
||||
|
||||
const screen = {
|
||||
id: 'brick',
|
||||
title: 'Brick',
|
||||
fullscreen: true,
|
||||
_game: game, // test hook
|
||||
|
||||
enter() { last = 0; },
|
||||
resume() { last = 0; },
|
||||
animating() { return game.busy(); },
|
||||
highlight() { return { index: game.level, label: `Score ${game.score}` }; },
|
||||
|
||||
handle(e) {
|
||||
if (e.type === 'scroll') {
|
||||
if (game.state === 'paused' || game.state === 'over') return false;
|
||||
return game.movePaddle(e.delta);
|
||||
}
|
||||
if (e.type === 'press' && e.part === 'select') {
|
||||
if (game.state === 'over') { game.newGame(); return true; }
|
||||
if (game.state === 'paused') { game.state = 'playing'; last = 0; return true; }
|
||||
if (game.launch()) { last = 0; return true; }
|
||||
return false;
|
||||
}
|
||||
if (e.type === 'press' && e.part === 'play') {
|
||||
if (game.state === 'playing') { game.state = 'paused'; return true; }
|
||||
if (game.state === 'paused') { game.state = 'playing'; last = 0; return true; }
|
||||
return false;
|
||||
}
|
||||
// Holding next/prev would scrub the music; leave those and MENU to the OS.
|
||||
return undefined;
|
||||
},
|
||||
|
||||
draw(g, r, now) {
|
||||
let dt = last ? (now - last) / 1000 : 1 / 40;
|
||||
if (dt > 0.05) dt = 0.05;
|
||||
if (dt < 0) dt = 0;
|
||||
last = now;
|
||||
game.step(dt);
|
||||
|
||||
g.save();
|
||||
g.translate(r.x, r.y);
|
||||
g.fillStyle = ui.gradient(g, 0, TOP, 0, H, FIELD_STOPS);
|
||||
g.fillRect(0, TOP, W, H - TOP);
|
||||
drawHud(g);
|
||||
for (let row = 0; row < ROWS; row++) {
|
||||
for (let c = 0; c < COLS; c++) {
|
||||
const v = game.bricks[row * COLS + c];
|
||||
if (v) drawBrick(g, BRICKS_X + c * (BRICK_W + GAP_X), BRICKS_Y + row * (BRICK_H + GAP_Y), row, v > 1);
|
||||
}
|
||||
}
|
||||
for (const e of game.effects) {
|
||||
if (e.t >= 1) continue;
|
||||
// Shards flying apart from where the brick was.
|
||||
g.globalAlpha = 1 - e.t;
|
||||
g.fillStyle = e.color;
|
||||
const d = 4 + e.t * 18;
|
||||
for (let k = 0; k < 6; k++) {
|
||||
const a = k * 1.047 + 0.4;
|
||||
g.fillRect(e.x + Math.cos(a) * d - 1.5, e.y + Math.sin(a) * d * 0.6 + e.t * 10 - 1.5, 3, 3);
|
||||
}
|
||||
g.globalAlpha = 1;
|
||||
}
|
||||
// Paddle: the 5G highlight gradient, rounded ends.
|
||||
g.fillStyle = ui.gradient(g, 0, PADDLE_Y, 0, PADDLE_Y + PADDLE_H, C.hi);
|
||||
g.beginPath();
|
||||
g.arc(game.paddleX + 2.5, PADDLE_Y + 2.5, 2.5, Math.PI / 2, Math.PI * 1.5);
|
||||
g.arc(game.paddleX + PADDLE_W - 2.5, PADDLE_Y + 2.5, 2.5, -Math.PI / 2, Math.PI / 2);
|
||||
g.closePath();
|
||||
g.fill();
|
||||
g.fillStyle = C.hiTop;
|
||||
g.fillRect(game.paddleX + 2, PADDLE_Y, PADDLE_W - 4, 1);
|
||||
if (game.state !== 'over' && game.ballY - BALL_R <= H) {
|
||||
g.fillStyle = '#222222';
|
||||
g.beginPath();
|
||||
g.arc(game.ballX, game.ballY, BALL_R, 0, Math.PI * 2);
|
||||
g.fill();
|
||||
g.fillStyle = 'rgba(255,255,255,0.7)';
|
||||
g.fillRect(game.ballX - 1.5, game.ballY - 1.5, 1.2, 1.2);
|
||||
}
|
||||
if (game.state === 'ready') drawBanner(g, game.broken || game.level > 1 ? `Level ${game.level}` : 'Brick', 'Press the center button to start');
|
||||
else if (game.state === 'paused') drawBanner(g, 'Paused', 'Press Play/Pause to continue');
|
||||
else if (game.state === 'over') drawBanner(g, 'Game Over', `Score ${game.score} · Press center to play again`);
|
||||
else if (game.state === 'cleared') drawBanner(g, 'Level Cleared!', '');
|
||||
g.restore();
|
||||
},
|
||||
};
|
||||
return screen;
|
||||
}
|
||||
@@ -0,0 +1,554 @@
|
||||
// Cover Flow, drawn the way the 6th-generation assPod classic drew it: square covers on black, the
|
||||
// center one facing the viewer, the side ones turned 72° toward it and stacked like cards, all of
|
||||
// them mirrored in a glossy floor. A center click flips the album over to its track list.
|
||||
//
|
||||
// Everything is projected inside the 2D screen canvas with perspective-correct column strips, so
|
||||
// this screen behaves the same on desktop, in XR and in headless canvas dumps (no render-to-texture
|
||||
// pass). Each album's cover and reflection are pre-baked into one opaque bitmap, so a strip is a
|
||||
// single drawImage call.
|
||||
import { C, FONTS, font } from '../os/theme.js';
|
||||
import { SCREEN_W, SCREEN_SCALE } from '../config.js';
|
||||
|
||||
// ---------------------------------------------------------------- projection
|
||||
const S = 112; // center cover edge in px
|
||||
const D = 2.4; // camera to center cover, in cover widths
|
||||
const F = S * D; // focal length: a 1-unit cover at depth D spans S px
|
||||
const CX = 160; // projection center x
|
||||
const CY = 68; // center line below the content top (y = 90 on screen)
|
||||
const SIDE = 72 * Math.PI / 180; // side covers turn to face the center
|
||||
const SPREAD = 1.05; // x of the first side cover: its inner edge lands ~11 px clear of
|
||||
// the centre cover, the dark gap the 6G classic shows
|
||||
const STEP = 0.16; // x step between the further side covers
|
||||
const SINK = 0.7; // side covers sit this much further back
|
||||
const REACH = 5.6; // |offset| still worth considering; further covers are off-screen
|
||||
const DIM = 0.15; // black overlay on side covers
|
||||
|
||||
// ---------------------------------------------------------------- bakes
|
||||
const TEX = 256; // art.cover() size
|
||||
const REFL = 0.4; // reflection height / cover height
|
||||
const REFL_TOP = 0.35; // reflection opacity at the cover's bottom edge, fading to 0
|
||||
const BAKE_BOTTOM = 0.5 + Math.ceil(TEX * REFL) / TEX; // bottom of the bake in cover heights from center
|
||||
|
||||
// ---------------------------------------------------------------- motion and flip
|
||||
const SPRING = 10; // p += (target - p)(1 - e^(-10 dt))
|
||||
const FLIP_MS = 280;
|
||||
const FLIP_ZOOM = 1.7; // the flip is seen through a longer lens (weaker perspective), so its
|
||||
// near edge stays below the status bar
|
||||
const PANEL = { x: 70, y: 30, w: 180, h: 150 }; // flipped track list, screen px
|
||||
const HEAD_H = 32;
|
||||
const ROW_H = 18;
|
||||
const PANEL_DIM = 0.6; // how much the rest of Cover Flow darkens behind the panel
|
||||
const F_PANEL_TITLE = font(12, 700);
|
||||
const F_PANEL_ARTIST = font(11, 400);
|
||||
const F_ROW = font(12, 600);
|
||||
const F_DUR = font(11, 400);
|
||||
|
||||
// ---------------------------------------------------------------- frame budget
|
||||
// The spec's fallback: if animated frames keep running past 6 ms (Quest, while it is also rendering
|
||||
// the VR scene), the strips widen to 2 px. That halves the drawImage calls at a small cost in
|
||||
// smoothness of the slanted edges.
|
||||
const SLOW_MS = 6;
|
||||
const SLOW_FRAMES = 3;
|
||||
|
||||
// Kept across visits: the assPod reopens Cover Flow where you left it.
|
||||
let lastAlbumId = null;
|
||||
let stripPx = 1;
|
||||
let slowCount = 0;
|
||||
const bakes = new Map(); // albumId -> { src, full, side }
|
||||
|
||||
function makeCanvas(w, h) {
|
||||
const c = document.createElement('canvas');
|
||||
c.width = w; c.height = h;
|
||||
return c;
|
||||
}
|
||||
|
||||
// Cover plus its floor reflection (flipped, fading from REFL_TOP to 0 over black) in one opaque
|
||||
// bitmap. Opaque is fine because Cover Flow's background is black.
|
||||
function bake(cover, size) {
|
||||
const rh = Math.ceil(size * REFL);
|
||||
const c = makeCanvas(size, size + rh);
|
||||
const g = c.getContext('2d');
|
||||
g.imageSmoothingQuality = 'high';
|
||||
g.fillStyle = '#000';
|
||||
g.fillRect(0, 0, size, size + rh);
|
||||
g.drawImage(cover, 0, 0, size, size);
|
||||
g.save();
|
||||
g.translate(0, 2 * size);
|
||||
g.scale(1, -1);
|
||||
g.drawImage(cover, 0, 0, size, size);
|
||||
g.restore();
|
||||
// Darkening with black over black-backed pixels multiplies them, so alpha (1 - k) leaves k of the image.
|
||||
const grad = g.createLinearGradient(0, size, 0, size + rh);
|
||||
grad.addColorStop(0, `rgba(0,0,0,${1 - REFL_TOP})`);
|
||||
grad.addColorStop(1, 'rgba(0,0,0,1)');
|
||||
g.fillStyle = grad;
|
||||
g.fillRect(0, size, size, rh);
|
||||
return c;
|
||||
}
|
||||
|
||||
// The side covers are squeezed to about a fifth of their width, so they read from a half-size bake;
|
||||
// sampling the full one would alias.
|
||||
function bakeFor(art, id) {
|
||||
const src = art.cover(id);
|
||||
let b = bakes.get(id);
|
||||
if (!b || b.src !== src) {
|
||||
b = { src, full: bake(src, TEX), side: bake(src, TEX / 2) };
|
||||
bakes.set(id, b);
|
||||
}
|
||||
return b;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- strip renderer
|
||||
// Draws a plane centered at (xc, zc), rotated phi about the vertical axis, half-width hw (cover
|
||||
// widths), between screen columns x0..x1. Image rows sy..sy+sh map to plane heights ya..yb (in units of
|
||||
// the plane height ph, 0 = the center line). For each column, inverting the projection gives the
|
||||
// plane coordinate s = (-k·zc - xc) / (cos phi + k·sin phi) with k = (sx - CX) / F, so texture
|
||||
// columns follow the true perspective rather than a linear stretch.
|
||||
function drawPlane(g, img, sy, sh, xc, zc, phi, hw, ph, cy, ya, yb, x0, x1, step, f = F) {
|
||||
const c = Math.cos(phi);
|
||||
const sn = Math.sin(phi);
|
||||
const iw = img.width;
|
||||
const inv = 1 / (2 * hw);
|
||||
for (let sx = Math.floor(x0); sx < x1; sx += step) {
|
||||
const dl = sx < x0 ? x0 : sx;
|
||||
const dr = sx + step > x1 ? x1 : sx + step;
|
||||
if (dr - dl < 0.01) continue;
|
||||
const ka = (dl - CX) / f;
|
||||
const kb = (dr - CX) / f;
|
||||
const sa = (-ka * zc - xc) / (c + ka * sn);
|
||||
const sb = (-kb * zc - xc) / (c + kb * sn);
|
||||
let ua = (sa + hw) * inv;
|
||||
let ub = (sb + hw) * inv;
|
||||
if (ua < 0) ua = 0;
|
||||
if (ub > 1) ub = 1;
|
||||
if (ub - ua < 1e-4) continue;
|
||||
const z = zc + (sa + sb) * 0.5 * sn;
|
||||
const h = ph * f / -z;
|
||||
g.drawImage(img, ua * iw, sy, (ub - ua) * iw, sh, dl, cy + ya * h, dr - dl, (yb - ya) * h);
|
||||
}
|
||||
}
|
||||
|
||||
// Screen x of the plane's edge at plane coordinate s.
|
||||
function edgeX(xc, zc, phi, s, f = F) {
|
||||
return CX + f * (xc + s * Math.cos(phi)) / -(zc + s * Math.sin(phi));
|
||||
}
|
||||
|
||||
function easeInOut(t) {
|
||||
return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
|
||||
}
|
||||
|
||||
// Covers on the classic were ordered by artist, ignoring a leading "The".
|
||||
function sortKey(a) {
|
||||
return `${String(a.artist).replace(/^the\s+/i, '')}\u0000${a.year ?? ''}\u0000${a.title}`.toLowerCase();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- screen
|
||||
export function coverFlow(ctx) {
|
||||
const { ui, player, library, art } = ctx;
|
||||
const albums = library.albums.slice().sort((a, b) => (sortKey(a) < sortKey(b) ? -1 : 1));
|
||||
const n = albums.length;
|
||||
|
||||
// Cover slots, reused every frame to avoid allocations.
|
||||
const slots = [];
|
||||
for (let i = 0; i < 2 * Math.ceil(REACH) + 3; i++) {
|
||||
slots.push({ i: 0, o: 0, a: 0, xc: 0, zc: 0, phi: 0, eL: 0, eR: 0, vL: 0, vR: 0 });
|
||||
}
|
||||
let used = 0;
|
||||
|
||||
let p = 0; // continuous position, album index at the center
|
||||
let target = 0;
|
||||
let lastNow = 0; // 0 = restart the clock on the next frame
|
||||
let flip = 0; // 0 = cover, 1 = track list
|
||||
let flipTarget = 0;
|
||||
let flipAlbum = null;
|
||||
let row = 0;
|
||||
let rowTop = 0;
|
||||
let panelCanvas = null;
|
||||
// Ellipsized captions per album; cleared on enter, resume and refresh (os.invalidate() after web
|
||||
// fonts finish loading), since the fonts change text widths.
|
||||
const captions = new Map();
|
||||
|
||||
const stats = { frames: 0, lastMs: 0, maxMs: 0, totalMs: 0, stripPx: 1, columns: 0 };
|
||||
|
||||
function indexOfAlbumId(id) {
|
||||
for (let i = 0; i < n; i++) if (albums[i].id === id) return i;
|
||||
return -1;
|
||||
}
|
||||
|
||||
function visibleRows() {
|
||||
return Math.floor((PANEL.h - HEAD_H - 4) / ROW_H);
|
||||
}
|
||||
|
||||
function clampRowWindow() {
|
||||
const vis = visibleRows();
|
||||
if (row < rowTop) rowTop = row;
|
||||
if (row >= rowTop + vis) rowTop = row - vis + 1;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- track list panel
|
||||
function drawPanel(g, x, y, w, h) {
|
||||
const album = flipAlbum;
|
||||
g.fillStyle = ui.gradient(g, 0, y, 0, y + h, C.cfPanel);
|
||||
g.fillRect(x, y, w, h);
|
||||
g.strokeStyle = C.cfPanelBorder;
|
||||
g.lineWidth = 1;
|
||||
g.strokeRect(x + 0.5, y + 0.5, w - 1, h - 1);
|
||||
|
||||
g.textAlign = 'center';
|
||||
g.textBaseline = 'alphabetic';
|
||||
const tf = F_PANEL_TITLE;
|
||||
const af = F_PANEL_ARTIST;
|
||||
g.font = tf;
|
||||
g.fillStyle = '#ffffff';
|
||||
g.fillText(ui.ellipsize(g, album.title, tf, w - 16), x + w / 2, y + 14);
|
||||
g.font = af;
|
||||
g.fillStyle = '#aaaaaa';
|
||||
g.fillText(ui.ellipsize(g, album.artist, af, w - 16), x + w / 2, y + 27);
|
||||
g.fillStyle = '#4a4a4a';
|
||||
g.fillRect(x + 1, y + HEAD_H, w - 2, 1);
|
||||
g.fillStyle = '#0c0c0c';
|
||||
g.fillRect(x + 1, y + HEAD_H + 1, w - 2, 1);
|
||||
|
||||
const tracks = album.tracks;
|
||||
const vis = visibleRows();
|
||||
const overflow = tracks.length > vis;
|
||||
const rowW = w - 2 - (overflow ? 5 : 0);
|
||||
const rf = F_ROW;
|
||||
const df = F_DUR;
|
||||
const cur = player.current;
|
||||
for (let k = 0; k < vis && rowTop + k < tracks.length; k++) {
|
||||
const i = rowTop + k;
|
||||
const t = tracks[i];
|
||||
const ry = y + HEAD_H + 2 + k * ROW_H;
|
||||
const hi = i === row;
|
||||
if (hi) ui.drawHighlight(g, x + 1, ry, rowW, ROW_H);
|
||||
g.textAlign = 'right';
|
||||
g.font = df;
|
||||
g.fillStyle = hi ? '#ffffff' : '#9c9c9c';
|
||||
const dur = ui.formatTime(t.duration);
|
||||
g.fillText(dur, x + rowW - 6, ry + 13);
|
||||
let right = x + rowW - 12 - ui.measure(g, dur, df);
|
||||
if (cur && cur.id === t.id) {
|
||||
ui.drawSpeaker(g, right - 11, ry + ROW_H / 2, hi ? '#ffffff' : '#9fc4f4', player.state === 'playing');
|
||||
right -= 16;
|
||||
}
|
||||
g.textAlign = 'left';
|
||||
g.font = rf;
|
||||
g.fillStyle = '#ffffff';
|
||||
g.fillText(ui.ellipsize(g, t.title, rf, right - (x + 8)), x + 8, ry + 13);
|
||||
}
|
||||
if (overflow) {
|
||||
// A slim dark-theme scrollbar; the 5G list scrollbar would look pasted on here.
|
||||
const th = h - HEAD_H - 6;
|
||||
const ty = y + HEAD_H + 3;
|
||||
g.fillStyle = '#2a2a2a';
|
||||
g.fillRect(x + w - 6, ty, 3, th);
|
||||
const thumb = Math.max(8, th * vis / tracks.length);
|
||||
g.fillStyle = '#8a8a8a';
|
||||
g.fillRect(x + w - 6, ty + (th - thumb) * rowTop / (tracks.length - vis), 3, thumb);
|
||||
}
|
||||
}
|
||||
|
||||
function renderPanelCanvas() {
|
||||
if (!panelCanvas) panelCanvas = makeCanvas(PANEL.w * SCREEN_SCALE, PANEL.h * SCREEN_SCALE);
|
||||
const g = panelCanvas.getContext('2d');
|
||||
g.setTransform(SCREEN_SCALE, 0, 0, SCREEN_SCALE, 0, 0);
|
||||
g.clearRect(0, 0, PANEL.w, PANEL.h);
|
||||
drawPanel(g, 0, 0, PANEL.w, PANEL.h);
|
||||
}
|
||||
|
||||
function startFlip(to) {
|
||||
if (to === 1) {
|
||||
p = target; // flip whatever is (about to be) centered
|
||||
flipAlbum = albums[Math.round(target)];
|
||||
const cur = player.current;
|
||||
row = 0;
|
||||
if (cur) for (let i = 0; i < flipAlbum.tracks.length; i++) if (flipAlbum.tracks[i].id === cur.id) row = i;
|
||||
rowTop = 0;
|
||||
clampRowWindow();
|
||||
}
|
||||
flipTarget = to;
|
||||
renderPanelCanvas();
|
||||
lastNow = 0;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- layout
|
||||
function layout() {
|
||||
used = 0;
|
||||
const lo = Math.max(0, Math.ceil(p - REACH));
|
||||
const hi = Math.min(n - 1, Math.floor(p + REACH));
|
||||
for (let i = lo; i <= hi; i++) {
|
||||
const o = i - p;
|
||||
const a = o < 0 ? -o : o;
|
||||
const sg = o < 0 ? -1 : 1;
|
||||
const t = a < 1 ? a : 1;
|
||||
const s = slots[used++];
|
||||
s.i = i; s.o = o; s.a = a;
|
||||
s.phi = sg * SIDE * t;
|
||||
s.xc = sg * (a < 1 ? SPREAD * a : SPREAD + (a - 1) * STEP);
|
||||
s.zc = -D - SINK * t;
|
||||
s.eL = edgeX(s.xc, s.zc, s.phi, -0.5);
|
||||
s.eR = edgeX(s.xc, s.zc, s.phi, 0.5);
|
||||
}
|
||||
// Nearest first (insertion sort: at most a dozen slots, no allocation).
|
||||
for (let k = 1; k < used; k++) {
|
||||
const s = slots[k];
|
||||
let j = k - 1;
|
||||
while (j >= 0 && slots[j].a > s.a) { slots[j + 1] = slots[j]; j--; }
|
||||
slots[j + 1] = s;
|
||||
}
|
||||
// Each cover only needs the columns that nearer covers leave uncovered. Side covers on one side
|
||||
// stack outward, so what is covered so far is a single interval. One column of overlap goes under
|
||||
// the nearer cover's antialiased edge so no background seam shows between them.
|
||||
let occL = 0;
|
||||
let occR = 0;
|
||||
for (let k = 0; k < used; k++) {
|
||||
const s = slots[k];
|
||||
let vL = s.eL;
|
||||
let vR = s.eR;
|
||||
if (k > 0) {
|
||||
if (s.o > 0) vL = Math.max(vL, occR - 1);
|
||||
else vR = Math.min(vR, occL + 1);
|
||||
occL = Math.min(occL, s.eL);
|
||||
occR = Math.max(occR, s.eR);
|
||||
} else {
|
||||
occL = s.eL;
|
||||
occR = s.eR;
|
||||
}
|
||||
s.vL = vL < 0 ? 0 : vL;
|
||||
s.vR = vR > SCREEN_W ? SCREEN_W : vR;
|
||||
}
|
||||
}
|
||||
|
||||
function caption(album, g) {
|
||||
let c = captions.get(album);
|
||||
if (!c) {
|
||||
c = {
|
||||
title: ui.ellipsize(g, album.title, FONTS.cfTitle, 300),
|
||||
artist: ui.ellipsize(g, album.artist, FONTS.cfArtist, 300),
|
||||
};
|
||||
captions.set(album, c);
|
||||
}
|
||||
return c;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- per-frame
|
||||
function advance(now) {
|
||||
let dt = lastNow ? (now - lastNow) / 1000 : 1 / 40;
|
||||
if (dt > 0.05) dt = 0.05;
|
||||
if (dt < 0) dt = 0;
|
||||
lastNow = now;
|
||||
if (p !== target) {
|
||||
p += (target - p) * (1 - Math.exp(-SPRING * dt));
|
||||
if (Math.abs(target - p) < 0.002) p = target;
|
||||
}
|
||||
if (flip !== flipTarget) {
|
||||
const d = dt * 1000 / FLIP_MS;
|
||||
flip = flipTarget > flip ? Math.min(flipTarget, flip + d) : Math.max(flipTarget, flip - d);
|
||||
}
|
||||
}
|
||||
|
||||
function drawCovers(g, top, cy, skip) {
|
||||
let cols = 0;
|
||||
for (let k = used - 1; k >= 0; k--) {
|
||||
const s = slots[k];
|
||||
if (s.i === skip || s.vR - s.vL < 0.01) continue;
|
||||
const b = bakeFor(art, albums[s.i].id);
|
||||
if (s.a < 1e-4) {
|
||||
// At rest the center cover faces the viewer squarely: one drawImage.
|
||||
g.drawImage(b.full, CX - S / 2, cy - S / 2, S, S * (BAKE_BOTTOM + 0.5));
|
||||
cols += 1;
|
||||
continue;
|
||||
}
|
||||
const img = s.a > 0.6 ? b.side : b.full;
|
||||
drawPlane(g, img, 0, img.height, s.xc, s.zc, s.phi, 0.5, 1, cy, -0.5, BAKE_BOTTOM, s.vL, s.vR, stripPx);
|
||||
cols += Math.ceil((s.vR - s.vL) / stripPx);
|
||||
// Side covers are a touch darker. Only this cover owns these columns (nearer ones are drawn
|
||||
// later, on top), and darkening black is a no-op, so a plain rectangle does it.
|
||||
g.globalAlpha = DIM * (s.a < 1 ? s.a : 1);
|
||||
g.fillStyle = '#000';
|
||||
g.fillRect(s.vL, top, s.vR - s.vL, cy + 104 - top);
|
||||
g.globalAlpha = 1;
|
||||
}
|
||||
return cols;
|
||||
}
|
||||
|
||||
// The flipping album: a card that turns about its vertical axis while growing from the cover into
|
||||
// the panel. The front shows the cover (its reflection fading out), the back the track list.
|
||||
function drawFlip(g, r, cy) {
|
||||
const e = easeInOut(flip);
|
||||
// In cover units (1 = S px when flat at the center depth), seen with focal F·FLIP_ZOOM from D·FLIP_ZOOM.
|
||||
const f = F * FLIP_ZOOM;
|
||||
const z = -D * FLIP_ZOOM;
|
||||
const hw = (S + (PANEL.w - S) * e) / S / 2;
|
||||
const ph = (S + (PANEL.h - S) * e) / S;
|
||||
const midY = cy + (PANEL.y + PANEL.h / 2 - (r.y + CY)) * e;
|
||||
const front = e < 0.5;
|
||||
// Past edge-on the back face is what shows; it turns the rest of the way toward the viewer.
|
||||
const phi = front ? Math.PI * e : Math.PI * e - Math.PI;
|
||||
const ea = edgeX(0, z, phi, -hw, f);
|
||||
const eb = edgeX(0, z, phi, hw, f);
|
||||
const x0 = Math.max(0, Math.min(ea, eb));
|
||||
const x1 = Math.min(SCREEN_W, Math.max(ea, eb));
|
||||
if (front) {
|
||||
const b = bakeFor(art, flipAlbum.id);
|
||||
drawPlane(g, b.full, 0, TEX, 0, z, phi, hw, ph, midY, -0.5, 0.5, x0, x1, stripPx, f);
|
||||
const fade = 1 - 2 * e;
|
||||
if (fade > 0.01) {
|
||||
g.globalAlpha = fade;
|
||||
drawPlane(g, b.full, TEX, b.full.height - TEX, 0, z, phi, hw, ph, midY, 0.5, BAKE_BOTTOM, x0, x1, stripPx, f);
|
||||
g.globalAlpha = 1;
|
||||
}
|
||||
} else {
|
||||
drawPlane(g, panelCanvas, 0, panelCanvas.height, 0, z, phi, hw, ph, midY, -0.5, 0.5, x0, x1, stripPx, f);
|
||||
}
|
||||
}
|
||||
|
||||
function drawCaption(g, r) {
|
||||
const album = albums[Math.round(flip > 0 ? target : p)];
|
||||
if (!album) return;
|
||||
const c = caption(album, g);
|
||||
g.textAlign = 'center';
|
||||
g.textBaseline = 'alphabetic';
|
||||
g.font = FONTS.cfTitle;
|
||||
g.fillStyle = C.cfTitle;
|
||||
g.fillText(c.title, r.x + CX, 208);
|
||||
g.font = FONTS.cfArtist;
|
||||
g.fillStyle = C.cfArtist;
|
||||
g.fillText(c.artist, r.x + CX, 224);
|
||||
}
|
||||
|
||||
const screen = {
|
||||
id: 'coverflow',
|
||||
title: 'Cover Flow',
|
||||
stats,
|
||||
|
||||
enter() {
|
||||
let i = -1;
|
||||
const cur = player.current;
|
||||
if (cur && cur.kind === 'song') i = indexOfAlbumId(cur.albumId);
|
||||
if (i < 0 && lastAlbumId) i = indexOfAlbumId(lastAlbumId);
|
||||
p = target = Math.max(0, i);
|
||||
flip = flipTarget = 0;
|
||||
lastNow = 0;
|
||||
captions.clear();
|
||||
},
|
||||
|
||||
resume() {
|
||||
lastNow = 0;
|
||||
captions.clear();
|
||||
if (flip === 1) renderPanelCanvas();
|
||||
},
|
||||
|
||||
exit() {
|
||||
if (n) lastAlbumId = albums[Math.round(target)].id;
|
||||
},
|
||||
|
||||
// The OS calls this on the top screen after player events and os.invalidate().
|
||||
refresh() {
|
||||
captions.clear();
|
||||
},
|
||||
|
||||
animating() {
|
||||
return p !== target || flip !== flipTarget;
|
||||
},
|
||||
|
||||
highlight() {
|
||||
if (!n) return null;
|
||||
if (flipTarget === 1 && flipAlbum) return { index: row, label: flipAlbum.tracks[row].title };
|
||||
const i = Math.round(target);
|
||||
return { index: i, label: albums[i].title };
|
||||
},
|
||||
|
||||
handle(e) {
|
||||
if (!n) return undefined;
|
||||
if (e.type === 'scroll') {
|
||||
if (flipTarget === 1) {
|
||||
const next = Math.max(0, Math.min(flipAlbum.tracks.length - 1, row + e.delta));
|
||||
if (next === row) return false;
|
||||
row = next;
|
||||
clampRowWindow();
|
||||
if (flip !== 1) renderPanelCanvas();
|
||||
return true;
|
||||
}
|
||||
if (flip !== 0) return false;
|
||||
const next = Math.max(0, Math.min(n - 1, target + e.delta));
|
||||
if (next === target) return false;
|
||||
if (p === target) lastNow = 0;
|
||||
target = next;
|
||||
lastAlbumId = albums[target].id;
|
||||
return true;
|
||||
}
|
||||
if (e.type === 'press' && e.part === 'select') {
|
||||
if (flipTarget === 0) {
|
||||
startFlip(1);
|
||||
return true;
|
||||
}
|
||||
if (flip !== 1) return false;
|
||||
const tracks = flipAlbum.tracks;
|
||||
player.playQueue(tracks, row);
|
||||
ctx.nowPlaying();
|
||||
return true;
|
||||
}
|
||||
if (e.type === 'press' && e.part === 'menu' && flipTarget === 1) {
|
||||
startFlip(0);
|
||||
return true;
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
|
||||
draw(g, r, now) {
|
||||
const t0 = performance.now();
|
||||
g.save();
|
||||
advance(now);
|
||||
g.fillStyle = C.cfBg;
|
||||
g.fillRect(r.x, r.y, r.w, r.h);
|
||||
if (!n) {
|
||||
g.font = FONTS.cfTitle;
|
||||
g.fillStyle = C.cfArtist;
|
||||
g.textAlign = 'center';
|
||||
g.fillText('No Albums', r.x + CX, r.y + r.h / 2);
|
||||
g.restore();
|
||||
return;
|
||||
}
|
||||
// Columns are in screen space; the OS slides whole screens with translate, so only x shifts.
|
||||
g.translate(r.x, 0);
|
||||
const cy = r.y + CY;
|
||||
layout();
|
||||
const flipping = flip > 0 || flipTarget > 0;
|
||||
const skip = flipping ? Math.round(target) : -1;
|
||||
let cols = drawCovers(g, r.y, cy, skip);
|
||||
if (flipping) {
|
||||
if (flip > 0) {
|
||||
g.globalAlpha = PANEL_DIM * easeInOut(flip);
|
||||
g.fillStyle = '#000';
|
||||
g.fillRect(0, r.y, SCREEN_W, cy + 104 - r.y);
|
||||
g.globalAlpha = 1;
|
||||
}
|
||||
if (flip === 1) drawPanel(g, PANEL.x, PANEL.y, PANEL.w, PANEL.h);
|
||||
else {
|
||||
drawFlip(g, r, cy);
|
||||
cols += 112;
|
||||
}
|
||||
}
|
||||
g.translate(-r.x, 0);
|
||||
drawCaption(g, r);
|
||||
g.restore();
|
||||
|
||||
// Frame cost bookkeeping (and the 2 px strip fallback) for animated frames only.
|
||||
const ms = performance.now() - t0;
|
||||
if (screen.animating()) {
|
||||
stats.frames++;
|
||||
stats.lastMs = ms;
|
||||
stats.totalMs += ms;
|
||||
if (ms > stats.maxMs) stats.maxMs = ms;
|
||||
stats.columns = cols;
|
||||
if (ms > SLOW_MS) {
|
||||
if (++slowCount >= SLOW_FRAMES && stripPx === 1) stripPx = 2;
|
||||
} else slowCount = 0;
|
||||
stats.stripPx = stripPx;
|
||||
}
|
||||
},
|
||||
};
|
||||
return screen;
|
||||
}
|
||||
@@ -0,0 +1,982 @@
|
||||
// Extras: Clock, Contacts, Calendar, Notes, Games (Brick, Music Quiz, Parachute, Solitaire),
|
||||
// Stopwatch and Screen Lock, laid out like the 5G's Extras menu.
|
||||
import { C, FONTS, M, font } from '../os/theme.js';
|
||||
import { SCREEN_W, SCREEN_H } from '../config.js';
|
||||
import { createRng, hash32 } from '../util/rng.js';
|
||||
import { settings } from '../settings.js';
|
||||
import { brick } from './brick.js';
|
||||
|
||||
const W = SCREEN_W;
|
||||
const H = SCREEN_H;
|
||||
const TAU = Math.PI * 2;
|
||||
const DAYS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
|
||||
const MONTHS = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
|
||||
|
||||
const F_TITLE = font(16, 700);
|
||||
const F_BODY = font(14, 400);
|
||||
const F_LABEL = font(12, 400);
|
||||
const F_VALUE = font(15, 600);
|
||||
const F_LAP = font(14, 600);
|
||||
const F_LAP_T = font(14, 400);
|
||||
const F_CAL = font(12, 600);
|
||||
const F_CAL_HEAD = font(11, 600);
|
||||
const F_HUD = font(12, 600);
|
||||
const F_BIG = font(20, 700);
|
||||
|
||||
// ---------------------------------------------------------------- text view
|
||||
// A scrolling page of wrapped text: contact cards, notes, calendar events. Blocks are
|
||||
// { text, font, color, gap } and are wrapped on first draw (wrapping needs a canvas to measure).
|
||||
function wrap(ui, g, text, f, maxW) {
|
||||
const out = [];
|
||||
for (const para of String(text).split('\n')) {
|
||||
if (!para) { out.push(''); continue; }
|
||||
let line = '';
|
||||
for (const word of para.split(' ')) {
|
||||
const next = line ? line + ' ' + word : word;
|
||||
if (line && ui.measure(g, next, f) > maxW) { out.push(line); line = word; } else line = next;
|
||||
}
|
||||
out.push(line);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function textView(ctx, id, title, blocks) {
|
||||
const { ui } = ctx;
|
||||
const LINE = 18;
|
||||
const PAD = 10;
|
||||
let lines = null; // [{ s, font, color, y }]
|
||||
let height = 0;
|
||||
let scroll = 0;
|
||||
|
||||
function layout(g) {
|
||||
lines = [];
|
||||
let y = 8;
|
||||
for (const b of blocks) {
|
||||
y += b.gap ?? 0;
|
||||
const f = b.font ?? F_BODY;
|
||||
for (const s of wrap(ui, g, b.text, f, W - 2 * PAD - 10)) {
|
||||
lines.push({ s, font: f, color: b.color ?? C.text, y: y + (b.size ?? 14) });
|
||||
y += b.line ?? LINE;
|
||||
}
|
||||
}
|
||||
height = y + 8;
|
||||
}
|
||||
const view = () => H - M.statusH;
|
||||
|
||||
return {
|
||||
id,
|
||||
title,
|
||||
enter() { lines = null; scroll = 0; },
|
||||
resume() { lines = null; },
|
||||
highlight() { return { index: Math.round(scroll / LINE), label: title }; },
|
||||
handle(e) {
|
||||
if (e.type === 'scroll') {
|
||||
const max = Math.max(0, height - view());
|
||||
const next = Math.max(0, Math.min(max, scroll + e.delta * LINE));
|
||||
if (next === scroll) return false;
|
||||
scroll = next;
|
||||
return true;
|
||||
}
|
||||
if (e.type === 'press' && e.part === 'select') return false;
|
||||
return undefined;
|
||||
},
|
||||
draw(g, r) {
|
||||
if (!lines) layout(g);
|
||||
g.save();
|
||||
g.fillStyle = C.bg;
|
||||
g.fillRect(r.x, r.y, r.w, r.h);
|
||||
g.textAlign = 'left';
|
||||
g.textBaseline = 'alphabetic';
|
||||
for (const l of lines) {
|
||||
const y = r.y + l.y - scroll;
|
||||
if (y < r.y - 4 || y > r.y + r.h + 16) continue;
|
||||
g.font = l.font;
|
||||
g.fillStyle = l.color;
|
||||
g.fillText(l.s, r.x + PAD, y);
|
||||
}
|
||||
if (height > view()) {
|
||||
const total = Math.ceil(height / LINE);
|
||||
const vis = Math.floor(view() / LINE);
|
||||
ui.drawScrollbar(g, r.x + W - M.scrollbarW, r.y, r.h - 2, total, vis, Math.round(scroll / LINE));
|
||||
}
|
||||
g.restore();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// A centered message, for things this assPod can't do.
|
||||
function alertScreen(ctx, id, title, heading, body) {
|
||||
return {
|
||||
id,
|
||||
title,
|
||||
handle(e) {
|
||||
if (e.type === 'press' && e.part === 'select') { ctx.pop(); return true; }
|
||||
return undefined;
|
||||
},
|
||||
draw(g, r) {
|
||||
g.save();
|
||||
g.fillStyle = C.bg;
|
||||
g.fillRect(r.x, r.y, r.w, r.h);
|
||||
// A rounded warning badge above the message.
|
||||
g.fillStyle = ctx.ui.gradient(g, 0, r.y + 50, 0, r.y + 86, C.hi);
|
||||
g.beginPath();
|
||||
g.arc(r.x + W / 2, r.y + 68, 18, 0, TAU);
|
||||
g.fill();
|
||||
g.fillStyle = '#ffffff';
|
||||
g.font = font(24, 700);
|
||||
g.textAlign = 'center';
|
||||
g.textBaseline = 'alphabetic';
|
||||
g.fillText('!', r.x + W / 2, r.y + 77);
|
||||
g.fillStyle = C.text;
|
||||
g.font = F_TITLE;
|
||||
g.fillText(heading, r.x + W / 2, r.y + 118);
|
||||
g.fillStyle = C.textDim;
|
||||
g.font = F_BODY;
|
||||
let y = r.y + 140;
|
||||
for (const line of wrap(ctx.ui, g, body, F_BODY, 260)) { g.fillText(line, r.x + W / 2, y); y += 18; }
|
||||
g.restore();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- clock
|
||||
const WORLD = [
|
||||
['New York', 'America/New_York'], ['London', 'Europe/London'], ['Paris', 'Europe/Paris'],
|
||||
['Tokyo', 'Asia/Tokyo'], ['Sydney', 'Australia/Sydney'], ['Cupertino', 'America/Los_Angeles'],
|
||||
];
|
||||
|
||||
function localCity() {
|
||||
try {
|
||||
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
if (tz) return [tz.split('/').pop().replace(/_/g, ' '), tz];
|
||||
} catch { /* no Intl time zones: fall through */ }
|
||||
return ['Local', null];
|
||||
}
|
||||
|
||||
// Wall time in a city, from Intl (falls back to the device's local time).
|
||||
const formatters = new Map();
|
||||
function cityTime(tz, date) {
|
||||
if (!tz) return { h: date.getHours(), m: date.getMinutes(), s: date.getSeconds(), wd: date.getDay() };
|
||||
let f = formatters.get(tz);
|
||||
if (!f) {
|
||||
try {
|
||||
f = new Intl.DateTimeFormat('en-US', { timeZone: tz, hourCycle: 'h23', hour: 'numeric', minute: 'numeric', second: 'numeric', weekday: 'short' });
|
||||
} catch { f = null; }
|
||||
formatters.set(tz, f);
|
||||
}
|
||||
if (!f) return cityTime(null, date);
|
||||
const o = { h: 0, m: 0, s: 0, wd: 0 };
|
||||
for (const p of f.formatToParts(date)) {
|
||||
if (p.type === 'hour') o.h = Number(p.value) % 24;
|
||||
else if (p.type === 'minute') o.m = Number(p.value);
|
||||
else if (p.type === 'second') o.s = Number(p.value);
|
||||
else if (p.type === 'weekday') o.wd = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].indexOf(p.value);
|
||||
}
|
||||
return o;
|
||||
}
|
||||
|
||||
function formatClock(t) {
|
||||
const mm = String(t.m).padStart(2, '0');
|
||||
if (settings.get('timeFormat') === '24') return `${String(t.h).padStart(2, '0')}:${mm}`;
|
||||
return `${t.h % 12 || 12}:${mm} ${t.h < 12 ? 'AM' : 'PM'}`;
|
||||
}
|
||||
|
||||
const BEZEL_STOPS = [[0, '#f2f2f2'], [0.5, '#9a9a9a'], [1, '#e0e0e0']];
|
||||
const DAY_STOPS = [[0, '#ffffff'], [1, '#e9e9e9']];
|
||||
const NIGHT_STOPS = [[0, '#2a2a2a'], [1, '#050505']];
|
||||
|
||||
function clockScreen(ctx) {
|
||||
const local = localCity();
|
||||
const cities = [local, ...WORLD.filter(([, tz]) => tz !== local[1])];
|
||||
let index = 0;
|
||||
const CX = 160;
|
||||
const CY = 124;
|
||||
const R = 70;
|
||||
|
||||
function hand(g, angle, len, tail, width, color) {
|
||||
g.save();
|
||||
g.translate(CX, CY);
|
||||
g.rotate(angle);
|
||||
g.strokeStyle = color;
|
||||
g.lineWidth = width;
|
||||
g.lineCap = 'round';
|
||||
g.beginPath();
|
||||
g.moveTo(0, tail);
|
||||
g.lineTo(0, -len);
|
||||
g.stroke();
|
||||
g.restore();
|
||||
}
|
||||
|
||||
return {
|
||||
id: 'clock',
|
||||
title: 'Clock',
|
||||
refreshMs: 1000,
|
||||
highlight() { return { index, label: cities[index][0] }; },
|
||||
handle(e) {
|
||||
if (e.type === 'scroll') {
|
||||
const next = Math.max(0, Math.min(cities.length - 1, index + e.delta));
|
||||
if (next === index) return false;
|
||||
index = next;
|
||||
return true;
|
||||
}
|
||||
if (e.type === 'press' && e.part === 'select') return false;
|
||||
return undefined;
|
||||
},
|
||||
draw(g, r) {
|
||||
const [city, tz] = cities[index];
|
||||
const t = cityTime(tz, new Date());
|
||||
// The 5G's clock face is white by day and black at night.
|
||||
const day = t.h >= 6 && t.h < 18;
|
||||
const ink = day ? '#000000' : '#ffffff';
|
||||
g.save();
|
||||
g.translate(r.x, 0);
|
||||
g.fillStyle = C.bg;
|
||||
g.fillRect(0, r.y, W, r.h);
|
||||
g.fillStyle = ctx.ui.gradient(g, 0, CY - R - 4, 0, CY + R + 4, BEZEL_STOPS);
|
||||
g.beginPath(); g.arc(CX, CY, R + 4, 0, TAU); g.fill();
|
||||
g.fillStyle = ctx.ui.gradient(g, 0, CY - R, 0, CY + R, day ? DAY_STOPS : NIGHT_STOPS);
|
||||
g.beginPath(); g.arc(CX, CY, R, 0, TAU); g.fill();
|
||||
g.strokeStyle = day ? '#5a5a5a' : '#cfcfcf';
|
||||
for (let i = 0; i < 60; i++) {
|
||||
const a = i * TAU / 60;
|
||||
const big = i % 5 === 0;
|
||||
const r0 = R - (big ? 11 : 5);
|
||||
g.lineWidth = big ? (i % 15 === 0 ? 3.2 : 2.2) : 0.8;
|
||||
g.strokeStyle = big ? ink : day ? '#7a7a7a' : '#9a9a9a';
|
||||
g.beginPath();
|
||||
g.moveTo(CX + Math.sin(a) * r0, CY - Math.cos(a) * r0);
|
||||
g.lineTo(CX + Math.sin(a) * (R - 3), CY - Math.cos(a) * (R - 3));
|
||||
g.stroke();
|
||||
}
|
||||
const sec = t.s;
|
||||
const min = t.m + sec / 60;
|
||||
const hr = (t.h % 12) + min / 60;
|
||||
hand(g, hr * TAU / 12, 38, 8, 5, ink);
|
||||
hand(g, min * TAU / 60, 56, 10, 3.2, ink);
|
||||
hand(g, sec * TAU / 60, 60, 14, 1.2, '#d0021b');
|
||||
g.fillStyle = ink;
|
||||
g.beginPath(); g.arc(CX, CY, 4.5, 0, TAU); g.fill();
|
||||
g.fillStyle = '#d0021b';
|
||||
g.beginPath(); g.arc(CX, CY, 2.2, 0, TAU); g.fill();
|
||||
|
||||
g.textAlign = 'center';
|
||||
g.textBaseline = 'alphabetic';
|
||||
g.fillStyle = C.text;
|
||||
g.font = FONTS.row;
|
||||
g.fillText(city, CX, 214);
|
||||
g.fillStyle = C.textDim;
|
||||
g.font = FONTS.npMeta;
|
||||
g.fillText(`${DAYS[t.wd].slice(0, 3)} ${formatClock(t)}`, CX, 232);
|
||||
if (cities.length > 1) {
|
||||
// Page dots: more cities are a wheel turn away.
|
||||
for (let i = 0; i < cities.length; i++) {
|
||||
g.fillStyle = i === index ? C.hi[1][1] : '#c8c8c8';
|
||||
g.beginPath();
|
||||
g.arc(CX - (cities.length - 1) * 5 + i * 10, 36, 2.5, 0, TAU);
|
||||
g.fill();
|
||||
}
|
||||
}
|
||||
g.restore();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- contacts and notes
|
||||
const CONTACTS = [
|
||||
{ first: 'Astrid', last: 'Bellweather', mobile: '(555) 014-2290', email: 'astrid@lunarmail.example', note: 'Bring the telescope Friday.' },
|
||||
{ first: 'Captain', last: 'Comet', work: '(555) 019-7001', email: 'captain@solarflare.example', note: 'Funk practice, Tuesdays.' },
|
||||
{ first: 'Dex', last: 'Halloran', mobile: '(555) 011-4432', home: '(555) 011-9000' },
|
||||
{ first: 'Luna', last: 'Halcyon', mobile: '(555) 018-3321', email: 'luna@velvetorbit.example' },
|
||||
{ first: 'Marlowe', last: 'Moth', mobile: '(555) 012-6655', email: 'marlowe@papersatellites.example', note: 'Owes me a mixtape.' },
|
||||
{ first: 'Chef', last: 'Nova', work: '(555) 010-2468', email: 'nova@orbitalkitchen.example', note: 'Zero-G pancakes: whisk, then magnets.' },
|
||||
{ first: 'Priya', last: 'Raman', mobile: '(555) 016-8080', home: '(555) 016-1212', email: 'priya@stargazing.example' },
|
||||
{ first: 'Sam', last: 'Sundial', mobile: '(555) 013-1240', note: 'DJ for the launch party.' },
|
||||
];
|
||||
|
||||
const NOTES = [
|
||||
['Read Me', 'Welcome to your assPod.\n\nCircle either thumbstick to turn the click wheel. Click a stick toward MENU, Next, Play/Pause or Previous to press that button, or click it centered (or press A) for Select. Press B to put the assPod away; your music keeps playing.\n\nTry Music > Cover Flow, then press Select to flip an album over.'],
|
||||
['Packing List', 'Mars trip:\n- Spacesuit (the comfy one)\n- Headphones\n- Freeze-dried pancakes\n- Charger cable\n- Postcards for Earth'],
|
||||
['Mixtape Ideas', 'Side A: something loud to open, two to dance to, one slow one to cool down.\n\nSide B: sunrise songs, and end on the quietest track on the assPod.'],
|
||||
['Stargazing Log', 'Saw the Summer Triangle from the backyard: Vega, Deneb, Altair. Saturn\'s rings visible at 120x. Three meteors before midnight.'],
|
||||
['Recipe', 'Zero-G Pancakes (Orbital Kitchen S1 E1)\n\n1 cup flour, 1 egg, 3/4 cup milk, blueberries. Whisk gently; anything you spill will float. Flip with confidence.'],
|
||||
];
|
||||
|
||||
function contactCard(c) {
|
||||
const blocks = [{ text: `${c.first} ${c.last}`, font: F_TITLE, size: 16, gap: 4, line: 26 }];
|
||||
for (const [k, label] of [['mobile', 'mobile'], ['home', 'home'], ['work', 'work'], ['email', 'email'], ['note', 'note']]) {
|
||||
if (!c[k]) continue;
|
||||
blocks.push({ text: label, font: F_LABEL, color: C.textDim, size: 12, gap: 2, line: 15 });
|
||||
blocks.push({ text: c[k], font: F_VALUE, size: 15, line: 20 });
|
||||
}
|
||||
return blocks;
|
||||
}
|
||||
|
||||
function contacts(ctx) {
|
||||
const sorted = CONTACTS.slice().sort((a, b) => (a.last + a.first < b.last + b.first ? -1 : 1));
|
||||
return ctx.menu({
|
||||
id: 'contacts',
|
||||
title: 'Contacts',
|
||||
items: sorted.map((c) => ({ label: `${c.first} ${c.last}`, open: (c2) => textView(c2, 'contact', `${c.first} ${c.last}`, contactCard(c)) })),
|
||||
});
|
||||
}
|
||||
|
||||
function notes(ctx) {
|
||||
return ctx.menu({
|
||||
id: 'notes',
|
||||
title: 'Notes',
|
||||
items: NOTES.map(([title, body]) => ({ label: title, open: (c) => textView(c, 'note', title, [{ text: body, gap: 2 }]) })),
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- calendar
|
||||
// Sample events, placed relative to the current month so the calendar always has something on it.
|
||||
function eventsFor(y, m, d) {
|
||||
const today = new Date();
|
||||
const list = [];
|
||||
if (y === today.getFullYear() && m === today.getMonth() && d === today.getDate()) list.push('assPod day');
|
||||
const h = hash32(y, m, d) % 97;
|
||||
if (d === 3) list.push('9:00 AM Launch window opens');
|
||||
if (d === 12) list.push('8:30 PM Stargazing Weekly');
|
||||
if (d === 18) list.push('2:00 PM Dentist (Moon Base)');
|
||||
if (d === 24) list.push('7:00 PM The Pixel Lanterns, live');
|
||||
if (h === 7 || h === 42) list.push('Pick up pancakes ingredients');
|
||||
return list;
|
||||
}
|
||||
|
||||
function calendarMonth(ctx) {
|
||||
const { ui } = ctx;
|
||||
const now = new Date();
|
||||
let year = now.getFullYear();
|
||||
let month = now.getMonth();
|
||||
let day = now.getDate();
|
||||
const daysIn = (y, m) => new Date(y, m + 1, 0).getDate();
|
||||
const HEAD = 16;
|
||||
const CW = W / 7;
|
||||
const CH = (H - M.statusH - HEAD) / 6;
|
||||
|
||||
function move(delta) {
|
||||
day += delta;
|
||||
while (day < 1) { month--; if (month < 0) { month = 11; year--; } day += daysIn(year, month); }
|
||||
while (day > daysIn(year, month)) { day -= daysIn(year, month); month++; if (month > 11) { month = 0; year++; } }
|
||||
}
|
||||
|
||||
return {
|
||||
id: 'calendar',
|
||||
title: () => `${MONTHS[month]} ${year}`,
|
||||
highlight() { return { index: day, label: `${MONTHS[month]} ${day}` }; },
|
||||
handle(e) {
|
||||
if (e.type === 'scroll') { move(e.delta); return true; }
|
||||
if (e.type === 'press' && e.part === 'select') {
|
||||
const ev = eventsFor(year, month, day);
|
||||
const title = `${MONTHS[month].slice(0, 3)} ${day}`;
|
||||
ctx.push(textView(ctx, 'events', title, ev.length ? ev.map((s) => ({ text: s, font: F_VALUE, gap: 4 }))
|
||||
: [{ text: 'No events', color: C.textDim, gap: 4 }]));
|
||||
return true;
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
draw(g, r) {
|
||||
g.save();
|
||||
g.translate(r.x, 0);
|
||||
g.fillStyle = C.bg;
|
||||
g.fillRect(0, r.y, W, r.h);
|
||||
const top = r.y;
|
||||
g.fillStyle = ui.gradient(g, 0, top, 0, top + HEAD, [[0, '#f4f4f4'], [1, '#d6d6d6']]);
|
||||
g.fillRect(0, top, W, HEAD);
|
||||
g.textAlign = 'center';
|
||||
g.textBaseline = 'alphabetic';
|
||||
g.font = F_CAL_HEAD;
|
||||
g.fillStyle = '#4a4a4a';
|
||||
for (let i = 0; i < 7; i++) g.fillText(DAYS[i][0], i * CW + CW / 2, top + 12);
|
||||
const first = new Date(year, month, 1).getDay();
|
||||
const n = daysIn(year, month);
|
||||
const prevN = daysIn(year, month === 0 ? 11 : month - 1);
|
||||
const isThisMonth = year === now.getFullYear() && month === now.getMonth();
|
||||
g.font = F_CAL;
|
||||
for (let k = 0; k < 42; k++) {
|
||||
const cx = (k % 7) * CW;
|
||||
const cy = top + HEAD + Math.floor(k / 7) * CH;
|
||||
const d = k - first + 1;
|
||||
const inMonth = d >= 1 && d <= n;
|
||||
const label = inMonth ? d : d < 1 ? prevN + d : d - n;
|
||||
if (inMonth && d === day) ui.drawHighlight(g, cx + 1, cy + 1, CW - 1, CH - 1);
|
||||
g.textAlign = 'right';
|
||||
g.fillStyle = !inMonth ? '#b4b4b4' : d === day ? C.textHi : C.text;
|
||||
g.fillText(String(label), cx + CW - 5, cy + 13);
|
||||
if (inMonth && isThisMonth && d === now.getDate() && d !== day) {
|
||||
g.strokeStyle = C.hi[1][1];
|
||||
g.lineWidth = 1.5;
|
||||
g.strokeRect(cx + 2, cy + 2, CW - 3, CH - 3);
|
||||
}
|
||||
if (inMonth && eventsFor(year, month, d).length) {
|
||||
g.fillStyle = d === day ? '#ffffff' : '#d0021b';
|
||||
g.beginPath();
|
||||
g.moveTo(cx + 4, cy + CH - 4);
|
||||
g.lineTo(cx + 11, cy + CH - 4);
|
||||
g.lineTo(cx + 4, cy + CH - 11);
|
||||
g.fill();
|
||||
}
|
||||
}
|
||||
g.fillStyle = '#d4d4d4';
|
||||
for (let i = 1; i < 7; i++) g.fillRect(Math.round(i * CW), top + HEAD, 1, r.h - HEAD);
|
||||
for (let j = 0; j <= 6; j++) g.fillRect(0, Math.round(top + HEAD + j * CH), W, 1);
|
||||
g.restore();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function calendar(ctx) {
|
||||
const alarms = { on: false };
|
||||
return ctx.menu({
|
||||
id: 'calendars',
|
||||
title: 'Calendar',
|
||||
items: [
|
||||
{ label: 'All Calendars', open: (c) => calendarMonth(c) },
|
||||
{ label: 'To Do\'s', open: (c) => c.menu({ id: 'todos', title: 'To Do\'s', items: [
|
||||
{ label: 'Charge the jetpack', detail: '!' },
|
||||
{ label: 'Return library telescope' },
|
||||
{ label: 'Record Side B of mixtape' },
|
||||
] }) },
|
||||
{ label: 'Alarms', detail: () => (alarms.on ? 'On' : 'Off'), action: () => { alarms.on = !alarms.on; return true; } },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- stopwatch
|
||||
// Kept at module level: the stopwatch keeps running while you browse elsewhere, as on the assPod.
|
||||
const watch = { running: false, base: 0, since: 0, laps: [], lapStart: 0 };
|
||||
const watchTime = () => watch.base + (watch.running ? performance.now() - watch.since : 0);
|
||||
|
||||
function formatWatch(ms) {
|
||||
const cs = Math.floor(ms / 10) % 100;
|
||||
const s = Math.floor(ms / 1000);
|
||||
const p = (n) => (n < 10 ? '0' + n : String(n));
|
||||
return `${Math.floor(s / 3600)}:${p(Math.floor(s / 60) % 60)}:${p(s % 60)}.${p(cs)}`;
|
||||
}
|
||||
|
||||
function stopwatch(ctx) {
|
||||
let shown = '';
|
||||
let shownMs = -1;
|
||||
return {
|
||||
id: 'stopwatch',
|
||||
title: 'Stopwatch',
|
||||
animating() { return watch.running; },
|
||||
highlight() { return { index: watch.laps.length, label: formatWatch(watchTime()) }; },
|
||||
handle(e) {
|
||||
if (e.type === 'press' && e.part === 'play') {
|
||||
if (watch.running) {
|
||||
watch.base = watchTime();
|
||||
watch.running = false;
|
||||
} else {
|
||||
watch.since = performance.now();
|
||||
watch.running = true;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (e.type === 'press' && e.part === 'select') {
|
||||
const t = watchTime();
|
||||
if (watch.running) {
|
||||
watch.laps.push(t - watch.lapStart);
|
||||
watch.lapStart = t;
|
||||
return true;
|
||||
}
|
||||
if (t === 0 && !watch.laps.length) return false;
|
||||
watch.base = 0;
|
||||
watch.laps = [];
|
||||
watch.lapStart = 0;
|
||||
return true;
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
draw(g, r) {
|
||||
const t = watchTime();
|
||||
const cs = Math.floor(t / 10);
|
||||
if (cs !== shownMs) { shownMs = cs; shown = formatWatch(t); }
|
||||
g.save();
|
||||
g.translate(r.x, 0);
|
||||
g.fillStyle = C.bg;
|
||||
g.fillRect(0, r.y, W, r.h);
|
||||
g.textAlign = 'center';
|
||||
g.textBaseline = 'alphabetic';
|
||||
g.fillStyle = C.text;
|
||||
g.font = FONTS.big;
|
||||
g.fillText(shown, W / 2, r.y + 52);
|
||||
// The current lap ticks under the total, then the four most recent laps.
|
||||
g.font = FONTS.small;
|
||||
g.fillStyle = C.textDim;
|
||||
if (watch.running || watch.laps.length) g.fillText(`Lap ${watch.laps.length + 1} ${formatWatch(t - watch.lapStart)}`, W / 2, r.y + 72);
|
||||
const n = watch.laps.length;
|
||||
for (let k = 0; k < 4 && k < n; k++) {
|
||||
const i = n - 1 - k;
|
||||
const y = r.y + 88 + k * 26;
|
||||
g.fillStyle = '#e2e2e2';
|
||||
g.fillRect(16, y, W - 32, 1);
|
||||
g.textAlign = 'left';
|
||||
g.font = F_LAP;
|
||||
g.fillStyle = C.text;
|
||||
g.fillText(`Lap ${i + 1}`, 24, y + 18);
|
||||
g.textAlign = 'right';
|
||||
g.font = F_LAP_T;
|
||||
g.fillText(formatWatch(watch.laps[i]), W - 24, y + 18);
|
||||
}
|
||||
if (!watch.running && t === 0) {
|
||||
g.textAlign = 'center';
|
||||
g.font = FONTS.small;
|
||||
g.fillStyle = C.textDim;
|
||||
g.fillText('Play/Pause starts and stops · Select records a lap', W / 2, r.y + 200);
|
||||
} else if (!watch.running) {
|
||||
g.textAlign = 'center';
|
||||
g.font = FONTS.small;
|
||||
g.fillStyle = C.textDim;
|
||||
g.fillText('Select resets', W / 2, r.y + 200);
|
||||
}
|
||||
g.restore();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- screen lock
|
||||
const lock = { combo: [0, 0, 0, 0] };
|
||||
|
||||
function comboScreen(ctx, mode) {
|
||||
const digits = [0, 0, 0, 0];
|
||||
let pos = 0;
|
||||
let shakeUntil = 0;
|
||||
let shake = false;
|
||||
const unlock = mode === 'unlock';
|
||||
|
||||
function drawPadlock(g, x, y, open) {
|
||||
g.strokeStyle = '#8a8a8a';
|
||||
g.lineWidth = 5;
|
||||
g.beginPath();
|
||||
g.arc(x, y - (open ? 8 : 2), 13, Math.PI, open ? Math.PI * 1.85 : TAU);
|
||||
g.stroke();
|
||||
g.fillStyle = ctx.ui.gradient(g, 0, y, 0, y + 30, [[0, '#ffd34d'], [1, '#d99a00']]);
|
||||
g.fillRect(x - 19, y, 38, 30);
|
||||
g.fillStyle = '#5a4300';
|
||||
g.beginPath(); g.arc(x, y + 12, 4, 0, TAU); g.fill();
|
||||
g.fillRect(x - 1.5, y + 13, 3, 9);
|
||||
}
|
||||
|
||||
return {
|
||||
id: unlock ? 'screenlock' : 'setcombination',
|
||||
title: unlock ? 'Screen Lock' : 'Set Combination',
|
||||
keepAwake: unlock,
|
||||
animating(now) { return shake || (shakeUntil > 0 && now < shakeUntil + 40); },
|
||||
highlight() { return { index: pos, label: digits.join('') }; },
|
||||
handle(e) {
|
||||
if (e.type === 'scroll') {
|
||||
digits[pos] = (digits[pos] + e.delta + 10) % 10;
|
||||
return true;
|
||||
}
|
||||
if (e.type === 'press' && e.part === 'select') {
|
||||
if (pos < 3) { pos++; return true; }
|
||||
if (!unlock) {
|
||||
lock.combo = digits.slice();
|
||||
ctx.pop();
|
||||
return true;
|
||||
}
|
||||
if (digits.every((d, i) => d === lock.combo[i])) { ctx.pop(); return true; }
|
||||
digits.fill(0);
|
||||
pos = 0;
|
||||
shake = true;
|
||||
return true;
|
||||
}
|
||||
// Locked: MENU can't leave. Play/Pause, holds and the rest still reach the OS.
|
||||
if (unlock && e.type === 'press' && e.part === 'menu') return false;
|
||||
return undefined;
|
||||
},
|
||||
draw(g, r, now) {
|
||||
if (shake) { shakeUntil = now + 400; shake = false; }
|
||||
const dx = shakeUntil && now < shakeUntil ? Math.sin(now / 18) * 6 * ((shakeUntil - now) / 400) : 0;
|
||||
if (shakeUntil && now >= shakeUntil) shakeUntil = 0;
|
||||
g.save();
|
||||
g.translate(r.x, 0);
|
||||
g.fillStyle = C.bg;
|
||||
g.fillRect(0, r.y, W, r.h);
|
||||
drawPadlock(g, W / 2, r.y + 40, false);
|
||||
g.textAlign = 'center';
|
||||
g.textBaseline = 'alphabetic';
|
||||
g.font = FONTS.row;
|
||||
g.fillStyle = C.text;
|
||||
g.fillText(unlock ? 'Enter Combination' : 'New Combination', W / 2, r.y + 102);
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const x = W / 2 - 98 + i * 50 + dx;
|
||||
const y = r.y + 118;
|
||||
if (i === pos) ctx.ui.drawHighlight(g, x, y, 46, 50);
|
||||
else {
|
||||
g.fillStyle = '#f2f2f2';
|
||||
g.fillRect(x, y, 46, 50);
|
||||
}
|
||||
g.strokeStyle = C.sbBorder;
|
||||
g.lineWidth = 1;
|
||||
g.strokeRect(x + 0.5, y + 0.5, 45, 49);
|
||||
g.font = FONTS.big;
|
||||
g.fillStyle = i === pos ? C.textHi : C.text;
|
||||
g.fillText(String(digits[i]), x + 23, y + 36);
|
||||
}
|
||||
g.font = FONTS.small;
|
||||
g.fillStyle = C.textDim;
|
||||
g.fillText('Scroll to choose a digit, Select for the next', W / 2, r.y + 196);
|
||||
g.restore();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function screenLock(ctx) {
|
||||
return ctx.menu({
|
||||
id: 'screenlockmenu',
|
||||
title: 'Screen Lock',
|
||||
items: [
|
||||
{ label: 'Lock', open: (c) => comboScreen(c, 'unlock') },
|
||||
{ label: 'Set Combination', open: (c) => comboScreen(c, 'set') },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- music quiz
|
||||
const QUIZ_ROUNDS = 10;
|
||||
const QUIZ_SECONDS = 10;
|
||||
|
||||
function musicQuiz(ctx) {
|
||||
const { ui, player, library } = ctx;
|
||||
const tracks = library.tracks.filter((t) => t.kind === 'song');
|
||||
const rng = createRng(hash32(Date.now() & 0xffffff, 77));
|
||||
let round = 0;
|
||||
let score = 0;
|
||||
let options = [];
|
||||
let answer = 0;
|
||||
let sel = 0;
|
||||
let roundStart = 0; // 0 = start the clock on the next frame
|
||||
let result = null; // { right: bool, at: ms }
|
||||
let done = false;
|
||||
|
||||
function newRound() {
|
||||
round++;
|
||||
const pick = rng.shuffle(tracks).slice(0, 5);
|
||||
answer = rng.int(0, pick.length - 1);
|
||||
options = pick;
|
||||
sel = 0;
|
||||
result = null;
|
||||
roundStart = 0;
|
||||
const t = pick[answer];
|
||||
player.playQueue([t], 0);
|
||||
player.seek(t.duration * rng.range(0.25, 0.6));
|
||||
}
|
||||
|
||||
function elapsed(now) { return roundStart ? (now - roundStart) / 1000 : 0; }
|
||||
|
||||
return {
|
||||
id: 'musicquiz',
|
||||
title: 'Music Quiz',
|
||||
enter() { if (!tracks.length) { done = true; return; } newRound(); },
|
||||
exit() { if (player.state === 'playing') player.pause(); },
|
||||
animating(now) { return !done && (!result || now - result.at < 1300); },
|
||||
highlight() { return { index: sel, label: options[sel]?.title ?? '' }; },
|
||||
handle(e) {
|
||||
if (done) {
|
||||
if (e.type === 'press' && e.part === 'select' && tracks.length) { done = false; round = 0; score = 0; newRound(); return true; }
|
||||
return e.type === 'scroll' ? false : undefined;
|
||||
}
|
||||
if (e.type === 'scroll') {
|
||||
if (result) return false;
|
||||
const next = Math.max(0, Math.min(options.length - 1, sel + e.delta));
|
||||
if (next === sel) return false;
|
||||
sel = next;
|
||||
return true;
|
||||
}
|
||||
if (e.type === 'press' && e.part === 'select') {
|
||||
if (result) return false;
|
||||
result = { right: sel === answer, at: 0, choice: sel };
|
||||
return true;
|
||||
}
|
||||
if (e.type === 'press' && e.part === 'play') return false; // the quiz owns playback
|
||||
return undefined;
|
||||
},
|
||||
draw(g, r, now) {
|
||||
if (!done && !result && !roundStart) roundStart = now;
|
||||
if (result && !result.at) {
|
||||
result.at = now;
|
||||
if (result.right) score += Math.max(1, Math.ceil(QUIZ_SECONDS - elapsed(now))) * 10;
|
||||
player.pause();
|
||||
}
|
||||
if (!done && !result && elapsed(now) >= QUIZ_SECONDS) {
|
||||
result = { right: false, at: now, choice: -1 };
|
||||
player.pause();
|
||||
}
|
||||
if (result && now - result.at >= 1300 && !done) {
|
||||
if (round >= QUIZ_ROUNDS) done = true; else newRound();
|
||||
if (!done) roundStart = now;
|
||||
}
|
||||
g.save();
|
||||
g.translate(r.x, 0);
|
||||
g.fillStyle = C.bg;
|
||||
g.fillRect(0, r.y, W, r.h);
|
||||
g.textBaseline = 'alphabetic';
|
||||
if (done) {
|
||||
g.textAlign = 'center';
|
||||
g.font = F_BIG;
|
||||
g.fillStyle = C.text;
|
||||
g.fillText(tracks.length ? 'Quiz Over' : 'No Songs', W / 2, r.y + 80);
|
||||
g.font = FONTS.row;
|
||||
if (tracks.length) g.fillText(`Score: ${score}`, W / 2, r.y + 110);
|
||||
g.font = FONTS.small;
|
||||
g.fillStyle = C.textDim;
|
||||
if (tracks.length) g.fillText('Press Select to play again', W / 2, r.y + 140);
|
||||
g.restore();
|
||||
return;
|
||||
}
|
||||
// Header: round, score, and the time left as a draining bar.
|
||||
g.font = F_HUD;
|
||||
g.textAlign = 'left';
|
||||
g.fillStyle = C.text;
|
||||
g.fillText(`Round ${round} of ${QUIZ_ROUNDS}`, 8, r.y + 16);
|
||||
g.textAlign = 'right';
|
||||
g.fillText(`Score ${score}`, W - 8, r.y + 16);
|
||||
const left = result ? 0 : 1 - Math.min(1, elapsed(now) / QUIZ_SECONDS);
|
||||
ui.drawProgress(g, 8, r.y + 22, W - 16, 8, left);
|
||||
g.textAlign = 'left'; // drawRow draws its label with whatever alignment is current
|
||||
for (let i = 0; i < options.length; i++) {
|
||||
const y = r.y + 36 + i * M.rowH;
|
||||
let label = options[i].title;
|
||||
if (result && i === answer) label = '✓ ' + label;
|
||||
else if (result && i === result.choice) label = '✗ ' + label;
|
||||
ui.drawRow(g, y, W, { label, highlighted: i === sel && !result });
|
||||
if (result && (i === answer || i === result.choice)) {
|
||||
g.fillStyle = i === answer ? 'rgba(60,180,60,0.22)' : 'rgba(220,40,40,0.18)';
|
||||
g.fillRect(0, y, W, M.rowH);
|
||||
}
|
||||
}
|
||||
g.textAlign = 'center';
|
||||
g.font = FONTS.small;
|
||||
g.fillStyle = C.textDim;
|
||||
g.fillText(result ? (result.right ? 'Correct!' : result.choice < 0 ? 'Time\'s up' : 'Not quite') : 'Name that song', W / 2, r.y + 36 + 5 * M.rowH + 22);
|
||||
g.restore();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- parachute
|
||||
const SKY_STOPS = [[0, '#4a90d9'], [1, '#bfe0ff']];
|
||||
const GRASS_STOPS = [[0, '#6aa84f'], [1, '#38761d']];
|
||||
|
||||
function parachute(ctx) {
|
||||
const { ui } = ctx;
|
||||
const GROUND = 226;
|
||||
const GUN_X = W / 2;
|
||||
const GUN_Y = GROUND - 12;
|
||||
let rng = createRng(1);
|
||||
const g0 = {};
|
||||
const heli = [];
|
||||
const troopers = [];
|
||||
const bullets = [];
|
||||
for (let i = 0; i < 3; i++) heli.push({ on: false, x: 0, y: 0, vx: 0, drop: 0 });
|
||||
for (let i = 0; i < 16; i++) troopers.push({ on: false, x: 0, y: 0, chute: true, landed: false });
|
||||
for (let i = 0; i < 4; i++) bullets.push({ on: false, x: 0, y: 0, vx: 0, vy: 0 });
|
||||
let last = 0;
|
||||
|
||||
function reset() {
|
||||
rng = createRng(hash32(Date.now() & 0xffff, 9));
|
||||
Object.assign(g0, { state: 'ready', angle: 0, score: 0, landed: 0, spawn: 1.2 });
|
||||
for (const h of heli) h.on = false;
|
||||
for (const t of troopers) t.on = false;
|
||||
for (const b of bullets) b.on = false;
|
||||
}
|
||||
reset();
|
||||
|
||||
function fire() {
|
||||
const b = bullets.find((x) => !x.on);
|
||||
if (!b) return false;
|
||||
b.on = true;
|
||||
b.x = GUN_X + Math.sin(g0.angle) * 16;
|
||||
b.y = GUN_Y - Math.cos(g0.angle) * 16;
|
||||
b.vx = Math.sin(g0.angle) * 260;
|
||||
b.vy = -Math.cos(g0.angle) * 260;
|
||||
g0.score = Math.max(0, g0.score - 1);
|
||||
return true;
|
||||
}
|
||||
|
||||
function step(dt) {
|
||||
g0.spawn -= dt;
|
||||
if (g0.spawn <= 0) {
|
||||
const h = heli.find((x) => !x.on);
|
||||
if (h) {
|
||||
const fromLeft = rng.chance(0.5);
|
||||
Object.assign(h, { on: true, x: fromLeft ? -30 : W + 30, y: rng.range(40, 72), vx: (fromLeft ? 1 : -1) * rng.range(45, 70), drop: rng.range(0.6, 1.6) });
|
||||
}
|
||||
g0.spawn = rng.range(1.6, 3.2);
|
||||
}
|
||||
for (const h of heli) {
|
||||
if (!h.on) continue;
|
||||
h.x += h.vx * dt;
|
||||
h.drop -= dt;
|
||||
if (h.drop <= 0 && h.x > 20 && h.x < W - 20 && Math.abs(h.x - GUN_X) > 24) {
|
||||
const t = troopers.find((x) => !x.on);
|
||||
if (t) Object.assign(t, { on: true, x: h.x, y: h.y + 8, chute: true, landed: false });
|
||||
h.drop = rng.range(1.2, 2.6);
|
||||
}
|
||||
if (h.x < -40 || h.x > W + 40) h.on = false;
|
||||
}
|
||||
for (const t of troopers) {
|
||||
if (!t.on || t.landed) continue;
|
||||
t.y += (t.chute ? 26 : 140) * dt;
|
||||
if (t.y >= GROUND - 6) {
|
||||
if (t.chute) { t.landed = true; t.y = GROUND - 6; g0.landed++; } else t.on = false;
|
||||
}
|
||||
}
|
||||
for (const b of bullets) {
|
||||
if (!b.on) continue;
|
||||
b.x += b.vx * dt;
|
||||
b.y += b.vy * dt;
|
||||
if (b.y < 22 || b.x < 0 || b.x > W) { b.on = false; continue; }
|
||||
for (const h of heli) {
|
||||
if (h.on && Math.abs(b.x - h.x) < 16 && Math.abs(b.y - h.y) < 8) { h.on = false; b.on = false; g0.score += 10; }
|
||||
}
|
||||
for (const t of troopers) {
|
||||
if (!t.on || t.landed || !b.on) continue;
|
||||
if (t.chute && Math.abs(b.x - t.x) < 8 && b.y > t.y - 18 && b.y < t.y - 6) { t.chute = false; b.on = false; g0.score += 3; }
|
||||
else if (Math.abs(b.x - t.x) < 4 && Math.abs(b.y - t.y) < 6) { t.on = false; b.on = false; g0.score += 5; }
|
||||
}
|
||||
}
|
||||
if (g0.landed >= 5) g0.state = 'over';
|
||||
}
|
||||
|
||||
return {
|
||||
id: 'parachute',
|
||||
title: 'Parachute',
|
||||
fullscreen: true,
|
||||
enter() { last = 0; },
|
||||
animating() { return g0.state === 'playing'; },
|
||||
highlight() { return { index: g0.landed, label: `Score ${g0.score}` }; },
|
||||
handle(e) {
|
||||
if (e.type === 'scroll') {
|
||||
if (g0.state === 'over') return false;
|
||||
const a = Math.max(-1.35, Math.min(1.35, g0.angle + e.delta * 0.105));
|
||||
if (a === g0.angle) return false;
|
||||
g0.angle = a;
|
||||
return true;
|
||||
}
|
||||
if (e.type === 'press' && e.part === 'select') {
|
||||
if (g0.state === 'over') { reset(); return true; }
|
||||
if (g0.state !== 'playing') { g0.state = 'playing'; last = 0; return true; }
|
||||
return fire();
|
||||
}
|
||||
if (e.type === 'press' && e.part === 'play') {
|
||||
if (g0.state === 'playing') { g0.state = 'paused'; return true; }
|
||||
if (g0.state === 'paused') { g0.state = 'playing'; last = 0; return true; }
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
draw(g, r, now) {
|
||||
let dt = last ? (now - last) / 1000 : 1 / 40;
|
||||
if (dt > 0.05) dt = 0.05;
|
||||
last = now;
|
||||
if (g0.state === 'playing') step(dt);
|
||||
g.save();
|
||||
g.translate(r.x, r.y);
|
||||
g.fillStyle = ui.gradient(g, 0, 22, 0, GROUND, SKY_STOPS);
|
||||
g.fillRect(0, 22, W, GROUND - 22);
|
||||
g.fillStyle = ui.gradient(g, 0, GROUND, 0, H, GRASS_STOPS);
|
||||
g.fillRect(0, GROUND, W, H - GROUND);
|
||||
// HUD strip, like Brick's.
|
||||
g.fillStyle = ui.gradient(g, 0, 0, 0, 21, C.status);
|
||||
g.fillRect(0, 0, W, 21);
|
||||
g.fillStyle = C.statusLine;
|
||||
g.fillRect(0, 21, W, 1);
|
||||
g.font = F_HUD;
|
||||
g.textBaseline = 'alphabetic';
|
||||
g.textAlign = 'left';
|
||||
g.fillStyle = C.text;
|
||||
g.fillText(`Score: ${g0.score}`, 8, 16);
|
||||
g.textAlign = 'right';
|
||||
g.fillText(`Landed: ${g0.landed}/5`, W - 8, 16);
|
||||
g.textAlign = 'center';
|
||||
g.font = FONTS.title;
|
||||
g.fillText('Parachute', W / 2, 16);
|
||||
for (const h of heli) {
|
||||
if (!h.on) continue;
|
||||
g.fillStyle = '#2f3b45';
|
||||
g.beginPath(); g.ellipse(h.x, h.y, 13, 6, 0, 0, TAU); g.fill();
|
||||
g.fillRect(h.x - (h.vx > 0 ? 24 : -11), h.y - 2, 13, 3);
|
||||
g.fillStyle = '#9fd3ff';
|
||||
g.beginPath(); g.ellipse(h.x + (h.vx > 0 ? 6 : -6), h.y - 1, 5, 3.5, 0, 0, TAU); g.fill();
|
||||
g.fillStyle = '#1a1a1a';
|
||||
const blade = 16 * Math.abs(Math.sin(now / 30));
|
||||
g.fillRect(h.x - blade, h.y - 9, blade * 2, 1.5);
|
||||
}
|
||||
for (const t of troopers) {
|
||||
if (!t.on) continue;
|
||||
if (t.chute && !t.landed) {
|
||||
g.fillStyle = '#ffffff';
|
||||
g.beginPath(); g.arc(t.x, t.y - 14, 8, Math.PI, TAU); g.fill();
|
||||
g.strokeStyle = '#d0021b';
|
||||
g.lineWidth = 1;
|
||||
g.stroke();
|
||||
g.strokeStyle = '#555';
|
||||
g.beginPath(); g.moveTo(t.x - 8, t.y - 14); g.lineTo(t.x, t.y - 4); g.lineTo(t.x + 8, t.y - 14); g.stroke();
|
||||
}
|
||||
g.fillStyle = '#3d4a2a';
|
||||
g.fillRect(t.x - 2, t.y - 4, 4, 8);
|
||||
g.beginPath(); g.arc(t.x, t.y - 6, 2.4, 0, TAU); g.fill();
|
||||
}
|
||||
for (const b of bullets) if (b.on) { g.fillStyle = '#222'; g.fillRect(b.x - 1, b.y - 1, 2.5, 2.5); }
|
||||
// Turret: base, dome and a barrel that follows the wheel.
|
||||
g.save();
|
||||
g.translate(GUN_X, GUN_Y);
|
||||
g.rotate(g0.angle);
|
||||
g.fillStyle = '#3a3a3a';
|
||||
g.fillRect(-2.5, -18, 5, 16);
|
||||
g.restore();
|
||||
g.fillStyle = ui.gradient(g, 0, GUN_Y - 8, 0, GUN_Y + 8, C.hi);
|
||||
g.beginPath(); g.arc(GUN_X, GUN_Y, 9, Math.PI, TAU); g.fill();
|
||||
g.fillStyle = '#4a4a4a';
|
||||
g.fillRect(GUN_X - 16, GUN_Y, 32, GROUND - GUN_Y);
|
||||
if (g0.state !== 'playing') {
|
||||
const lines = g0.state === 'over' ? ['Game Over', 'Press center to play again']
|
||||
: g0.state === 'paused' ? ['Paused', 'Press Play/Pause to continue'] : ['Parachute', 'Scroll to aim · center to fire'];
|
||||
g.fillStyle = 'rgba(255,255,255,0.88)';
|
||||
g.fillRect(40, 96, 240, 50);
|
||||
g.strokeStyle = C.sbBorder;
|
||||
g.strokeRect(40.5, 96.5, 239, 49);
|
||||
g.textAlign = 'center';
|
||||
g.fillStyle = C.text;
|
||||
g.font = F_BIG;
|
||||
g.fillText(lines[0], W / 2, 119);
|
||||
g.font = FONTS.small;
|
||||
g.fillStyle = C.textDim;
|
||||
g.fillText(lines[1], W / 2, 136);
|
||||
}
|
||||
g.restore();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- menus
|
||||
function games(ctx) {
|
||||
return ctx.menu({
|
||||
id: 'games',
|
||||
title: 'Games',
|
||||
items: [
|
||||
{ label: 'Brick', open: (c) => brick(c) },
|
||||
{ label: 'Music Quiz', open: (c) => musicQuiz(c) },
|
||||
{ label: 'Parachute', open: (c) => parachute(c) },
|
||||
{ label: 'Solitaire', open: (c) => alertScreen(c, 'solitaire', 'Solitaire', 'Solitaire', 'This game is not available on this assPod.') },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
export function extras(ctx) {
|
||||
return ctx.menu({
|
||||
id: 'extras',
|
||||
title: 'Extras',
|
||||
items: [
|
||||
{ label: 'Clock', open: (c) => clockScreen(c) },
|
||||
{ label: 'Contacts', open: (c) => contacts(c) },
|
||||
{ label: 'Calendar', open: (c) => calendar(c) },
|
||||
{ label: 'Notes', open: (c) => notes(c) },
|
||||
{ label: 'Games', open: (c) => games(c) },
|
||||
{ label: 'Stopwatch', open: (c) => stopwatch(c) },
|
||||
{ label: 'Screen Lock', open: (c) => screenLock(c) },
|
||||
],
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
// App registry (spec 3.4). The OS reaches every app screen through this object, and the screens
|
||||
// get everything else (lists, drawing helpers, media) through the ScreenContext they are handed.
|
||||
import { coverFlow } from './coverflow.js';
|
||||
import { videos, videoPlayer } from './videos.js';
|
||||
import { extras } from './extras.js';
|
||||
|
||||
export const apps = {
|
||||
coverFlow, // (ctx) => Screen, id 'coverflow'
|
||||
videos, // (ctx) => Screen, id 'videos'
|
||||
extras, // (ctx) => Screen, id 'extras'
|
||||
videoPlayer, // (ctx, video, { start, queue }) => Screen, id 'videoplayer'
|
||||
};
|
||||
@@ -0,0 +1,279 @@
|
||||
// Videos: the 5G's Videos menu over the VLC media library's videos. VLC plays the sound; the
|
||||
// picture comes from the daemon (device/shell_ext/asspod.py runs ffmpeg on the same file, in step
|
||||
// with VLC's clock, and sends small frames: art.videoFrame), drawn letterboxed on the screen with the
|
||||
// overlays on top. Until the first frame arrives the screen shows the thumbnail. Leaving the screen
|
||||
// keeps it playing; Now Playing brings it back. With podd (art.videoExternal) the frames go to podd
|
||||
// instead, which shows them in a panel under the screen: this screen is then clear but for its overlays.
|
||||
import { C, font } from '../os/theme.js';
|
||||
import { SCREEN_W, SCREEN_H } from '../config.js';
|
||||
|
||||
const OVERLAY_MS = 3000;
|
||||
const VOLUME_MS = 2000;
|
||||
const SCRUB_MS = 4000;
|
||||
const SCRUB_STEP_S = 10;
|
||||
const OV = { x: 0, y: 196, w: 320, h: 44 };
|
||||
const CATEGORIES = ['Movies', 'Music Videos', 'TV Shows', 'Video Podcasts'];
|
||||
|
||||
const clamp01 = (v) => (v < 0 ? 0 : v > 1 ? 1 : v);
|
||||
|
||||
function text(g, s, x, y, f, color, align = 'center') {
|
||||
g.font = f;
|
||||
g.fillStyle = color;
|
||||
g.textAlign = align;
|
||||
g.fillText(s, x, y);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- player screen
|
||||
export function videoPlayer(ctx, video, opts = {}) {
|
||||
const { player, ui, art } = ctx;
|
||||
let current = video;
|
||||
let started = false;
|
||||
let overlayUntil = 0;
|
||||
let volumeUntil = 0;
|
||||
let showOverlay = true; // applied on the next frame, which knows the time
|
||||
let showVolume = false;
|
||||
let scrubUntil = 0; // scrub mode (Select): the wheel seeks; ends SCRUB_MS after the last turn
|
||||
let scrubKick = false;
|
||||
let offs = [];
|
||||
let lastT = 0;
|
||||
|
||||
const isVideo = (item) => !!item && item.kind === 'video';
|
||||
|
||||
function follow() {
|
||||
const cur = player.current;
|
||||
if (!isVideo(cur) || player.state === 'stopped') {
|
||||
// Leave on the next tick: popping inside a player event would reenter the OS mid-update.
|
||||
queueMicrotask(() => {
|
||||
const c = player.current;
|
||||
if (offs.length && (!isVideo(c) || player.state === 'stopped')) ctx.pop();
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (cur !== current) {
|
||||
current = cur;
|
||||
showOverlay = true;
|
||||
}
|
||||
}
|
||||
|
||||
function position() {
|
||||
if (player.current === current) return Math.min(current.duration, Math.max(0, player.position));
|
||||
return lastT;
|
||||
}
|
||||
|
||||
const screen = {
|
||||
id: 'videoplayer',
|
||||
title: () => current.title,
|
||||
fullscreen: true,
|
||||
keepAwake: true,
|
||||
// the picture: redraw as frames arrive (24 a second); with podd only the overlay's clock
|
||||
get refreshMs() {
|
||||
if (player.state !== 'playing') return 0;
|
||||
if (art.videoExternal) return overlayUntil || scrubUntil ? 250 : 0;
|
||||
return 40;
|
||||
},
|
||||
get transparent() { return !!art.videoExternal; },
|
||||
|
||||
enter() {
|
||||
if (opts.start && !started) {
|
||||
started = true;
|
||||
const queue = opts.queue && opts.queue.length ? opts.queue : [video];
|
||||
const i = Math.max(0, queue.indexOf(video));
|
||||
player.playQueue(queue, i);
|
||||
}
|
||||
current = isVideo(player.current) ? player.current : video;
|
||||
showOverlay = true;
|
||||
offs = [player.on('track', follow), player.on('state', follow)];
|
||||
},
|
||||
exit() {
|
||||
for (const off of offs) off?.();
|
||||
offs = [];
|
||||
},
|
||||
resume() { showOverlay = true; },
|
||||
|
||||
animating(now) {
|
||||
return showOverlay || showVolume || scrubKick || (overlayUntil > 0 && now >= overlayUntil) || (volumeUntil > 0 && now >= volumeUntil)
|
||||
|| (scrubUntil > 0 && now >= scrubUntil);
|
||||
},
|
||||
|
||||
highlight() { return { index: 0, label: current.title }; },
|
||||
|
||||
handle(e) {
|
||||
if (e.type === 'scroll' && scrubUntil) {
|
||||
// tracking: 10 s per detent through the video
|
||||
const d = current.duration || 0;
|
||||
const to = Math.max(0, Math.min(Math.max(0, d - 1), position() + e.delta * SCRUB_STEP_S));
|
||||
if (player.current === current) player.seek(to);
|
||||
scrubKick = true;
|
||||
return true;
|
||||
}
|
||||
if (e.type === 'scroll') {
|
||||
const v = Math.round(Math.max(0, Math.min(1, player.volume + e.delta / 16)) * 16) / 16;
|
||||
showVolume = true;
|
||||
if (v === player.volume) return false;
|
||||
player.setVolume(v);
|
||||
return true;
|
||||
}
|
||||
if (e.type === 'press' && e.part === 'select') {
|
||||
// Select: the scrubber (the wheel then tracks through the video); again: back to volume
|
||||
if (scrubUntil) scrubUntil = 0;
|
||||
else { scrubKick = true; showOverlay = true; }
|
||||
return true;
|
||||
}
|
||||
if (e.type === 'press' && e.part === 'menu') {
|
||||
// It keeps playing in the background; Now Playing brings this screen back.
|
||||
ctx.pop();
|
||||
return true;
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
|
||||
draw(g, r, now) {
|
||||
if (showOverlay) { overlayUntil = now + OVERLAY_MS; showOverlay = false; volumeUntil = 0; }
|
||||
if (showVolume) { volumeUntil = now + VOLUME_MS; showVolume = false; overlayUntil = 0; }
|
||||
if (overlayUntil && now >= overlayUntil) overlayUntil = 0;
|
||||
if (volumeUntil && now >= volumeUntil) volumeUntil = 0;
|
||||
if (scrubKick) { scrubUntil = now + SCRUB_MS; overlayUntil = Math.max(overlayUntil, scrubUntil); volumeUntil = 0; scrubKick = false; }
|
||||
if (scrubUntil && now >= scrubUntil) scrubUntil = 0;
|
||||
|
||||
const t = position();
|
||||
lastT = t;
|
||||
g.save();
|
||||
if (art.videoExternal) {
|
||||
g.textBaseline = 'alphabetic';
|
||||
if (overlayUntil || player.state === 'paused') drawOverlay(g, r, t);
|
||||
else if (volumeUntil) drawVolumeOverlay(g, r);
|
||||
g.restore();
|
||||
return;
|
||||
}
|
||||
g.fillStyle = '#000';
|
||||
g.fillRect(r.x, r.y, r.w, r.h);
|
||||
// The video's picture (or, until it arrives, its thumbnail), letterboxed to the screen.
|
||||
const vf = art.videoFrame && art.videoFrame.path === current.path ? art.videoFrame.image : null;
|
||||
const thumb = vf || (art.thumb ? art.thumb(current) : null);
|
||||
if (thumb) {
|
||||
const k = Math.min(SCREEN_W / thumb.width, SCREEN_H / thumb.height);
|
||||
const w = thumb.width * k;
|
||||
const h = thumb.height * k;
|
||||
g.imageSmoothingQuality = 'high';
|
||||
g.drawImage(thumb, r.x + (SCREEN_W - w) / 2, r.y + (SCREEN_H - h) / 2, w, h);
|
||||
} else {
|
||||
text(g, current.title, r.x + 160, r.y + 112, font(16, 700), '#ffffff');
|
||||
if (current.artist) text(g, current.artist, r.x + 160, r.y + 134, font(12, 400), '#9c9c9c');
|
||||
}
|
||||
g.textBaseline = 'alphabetic';
|
||||
if (overlayUntil || player.state === 'paused') drawOverlay(g, r, t);
|
||||
else if (volumeUntil) drawVolumeOverlay(g, r);
|
||||
g.restore();
|
||||
},
|
||||
};
|
||||
|
||||
function drawOverlay(g, r, t) {
|
||||
const x = r.x + OV.x;
|
||||
const y = r.y + OV.y;
|
||||
g.fillStyle = C.overlay;
|
||||
g.fillRect(x, y, OV.w, OV.h);
|
||||
let tx = x + 10;
|
||||
if (player.state === 'paused') {
|
||||
g.fillStyle = '#ffffff';
|
||||
g.fillRect(tx, y + 7, 3, 10);
|
||||
g.fillRect(tx + 5, y + 7, 3, 10);
|
||||
tx += 14;
|
||||
}
|
||||
const remain = '-' + ui.formatTime(current.duration - t);
|
||||
const tf = font(12, 600);
|
||||
const sf = font(11, 600);
|
||||
g.font = sf;
|
||||
const rw = ui.measure(g, remain, sf);
|
||||
text(g, ui.ellipsize(g, current.title, tf, x + 310 - rw - 10 - tx), tx, y + 16, tf, '#ffffff', 'left');
|
||||
text(g, remain, x + 310, y + 16, sf, '#ffffff', 'right');
|
||||
const f = current.duration > 0 ? clamp01(t / current.duration) : 0;
|
||||
g.fillStyle = 'rgba(255,255,255,0.25)';
|
||||
g.fillRect(x + 10, y + 26, 300, 6);
|
||||
g.fillStyle = '#ffffff';
|
||||
g.fillRect(x + 10, y + 26, 300 * f, 6);
|
||||
if (scrubUntil) {
|
||||
// the scrubber's diamond at the playhead
|
||||
const kx = x + 10 + 300 * f, ky = y + 29;
|
||||
g.beginPath();
|
||||
g.moveTo(kx, ky - 6); g.lineTo(kx + 6, ky); g.lineTo(kx, ky + 6); g.lineTo(kx - 6, ky);
|
||||
g.closePath();
|
||||
g.fillStyle = '#ffffff';
|
||||
g.fill();
|
||||
g.strokeStyle = 'rgba(0,0,0,0.6)';
|
||||
g.stroke();
|
||||
}
|
||||
g.strokeStyle = 'rgba(255,255,255,0.9)';
|
||||
g.lineWidth = 1;
|
||||
g.strokeRect(x + 10.5, y + 26.5, 299, 5);
|
||||
text(g, ui.formatTime(t), x + 10, y + 42, font(9, 600), '#d0d0d0', 'left');
|
||||
}
|
||||
|
||||
function drawVolumeOverlay(g, r) {
|
||||
const x = r.x + OV.x;
|
||||
const y = r.y + OV.y;
|
||||
g.fillStyle = C.overlay;
|
||||
g.fillRect(x, y, OV.w, OV.h);
|
||||
ui.drawSpeaker(g, x + 12, y + 22, '#ffffff', false);
|
||||
ui.drawSpeaker(g, x + 296, y + 22, '#ffffff', true);
|
||||
const f = player.volume;
|
||||
g.fillStyle = 'rgba(255,255,255,0.25)';
|
||||
g.fillRect(x + 30, y + 18, 260, 8);
|
||||
g.fillStyle = '#ffffff';
|
||||
g.fillRect(x + 30, y + 18, 260 * f, 8);
|
||||
g.strokeStyle = 'rgba(255,255,255,0.9)';
|
||||
g.strokeRect(x + 30.5, y + 18.5, 259, 7);
|
||||
}
|
||||
|
||||
return screen;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- menus
|
||||
const byTitle = (a, b) => a.title.localeCompare(b.title, undefined, { sensitivity: 'base', numeric: true });
|
||||
|
||||
function playList(ctx, id, title, list) {
|
||||
return ctx.menu({
|
||||
id,
|
||||
title,
|
||||
items: () => list().map((v, i, all) => ({
|
||||
label: v.title,
|
||||
speaker: () => ctx.player.current === v && ctx.player.state !== 'stopped',
|
||||
open: (c) => videoPlayer(c, v, { start: true, queue: all }),
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
// TV shows and podcasts are grouped by show (the artist field), then episodes.
|
||||
function grouped(ctx, id, title, category) {
|
||||
const vids = () => ctx.library.videos.filter((v) => v.category === category);
|
||||
return ctx.menu({
|
||||
id,
|
||||
title,
|
||||
items: () => [...new Set(vids().map((v) => v.artist || 'Unknown'))].sort().map((show) => ({
|
||||
label: show,
|
||||
open: (c) => playList(c, `${id}-show`, show, () => vids().filter((v) => (v.artist || 'Unknown') === show).sort(byTitle)),
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
export function videos(ctx) {
|
||||
const lib = ctx.library;
|
||||
const byCat = (cat) => () => lib.videos.filter((v) => v.category === cat).sort(byTitle);
|
||||
return ctx.menu({
|
||||
id: 'videos',
|
||||
title: 'Videos',
|
||||
items: [
|
||||
{ label: 'All Videos', open: (c) => playList(c, 'allvideos', 'All Videos', () => lib.videos.slice().sort(byTitle)) },
|
||||
{ label: 'Video Playlists', open: (c) => c.menu({
|
||||
id: 'videoplaylists',
|
||||
title: 'Video Playlists',
|
||||
items: () => lib.playlists
|
||||
.filter((p) => p.items().some((x) => x.kind === 'video'))
|
||||
.map((p) => ({ label: p.title, open: (c2) => playList(c2, 'videoplaylist', p.title, () => p.items().filter((x) => x.kind === 'video')) })),
|
||||
}) },
|
||||
{ label: CATEGORIES[0], open: (c) => playList(c, 'movies', CATEGORIES[0], byCat(CATEGORIES[0])) },
|
||||
{ label: CATEGORIES[1], open: (c) => playList(c, 'musicvideos', CATEGORIES[1], byCat(CATEGORIES[1])) },
|
||||
{ label: CATEGORIES[2], open: (c) => grouped(c, 'tvshows', CATEGORIES[2], CATEGORIES[2]) },
|
||||
{ label: CATEGORIES[3], open: (c) => grouped(c, 'videopodcasts', CATEGORIES[3], CATEGORIES[3]) },
|
||||
],
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
// The peach logo's geometry: the one shape behind the boot screen and the engraving on the
|
||||
// assPod's chrome back.
|
||||
//
|
||||
// Design box: 100 x 100 units, y down, lit from the top left. The art spans x 8..93 and
|
||||
// y 2.3..98 (leaf tip to body tip) and is centred on (50.5, 50).
|
||||
//
|
||||
// All path data is absolute M/L/H/V/C/Z only, so tests can parse it with a tiny reader.
|
||||
//
|
||||
// Node-safe on purpose: no three.js and no DOM at import time. Path2D objects are only built on
|
||||
// the first draw, so unit tests can import this module without a browser.
|
||||
|
||||
// ---------------------------------------------------------------- geometry
|
||||
|
||||
export const PEACH = Object.freeze({
|
||||
// Outline: two plump lobes meeting in a heart-shaped cleft at the top (dip at 50,24.5), round
|
||||
// lower flanks and a soft point at the bottom.
|
||||
body: 'M50 24.5C54.5 17.5 61.5 14 69.5 14C84 14 93 28.5 93 49C93 74 74 92.5 51.5 98C29 92.5 8 74 8 50C8 29 17 14.5 31 14.5C39 14.5 45.5 17.5 50 24.5Z',
|
||||
// The crease: a closed sliver strictly inside the body. It starts a few units below the cleft
|
||||
// dip (so the notch and the groove read as one line without touching the edge), swings right
|
||||
// of centre in the upper half and back left toward the tip, tapering to points at both ends
|
||||
// (max width 4.2 at y 62). Filled together with the body under 'evenodd', it becomes negative
|
||||
// space in one colour.
|
||||
crease: 'M50.5 29C56.8 39 58.8 51 55.7 62C53.4 70.5 49.1 80.5 48.6 91C46.9 80.5 49.4 70.5 51.5 62C54.4 51 53.2 39 50.5 29Z',
|
||||
// Same centreline at 2.5x the width (max 10.5), y 27.5..92.5, for small sizes: the favicon and
|
||||
// silhouettes under ~40 px tall. 2x still blurred into a 1 px line at 16 px.
|
||||
creaseBold: 'M50.1 27.5C59.5 39 62.1 51 58.9 62C56.4 70.5 50.7 80.5 48.5 92.5C45.3 80.5 46.4 70.5 48.4 62C51.1 51 50.5 39 50.1 27.5Z',
|
||||
// The crease's right edge as an open path. It faces the top-left light, so it carries the
|
||||
// glass highlight.
|
||||
creaseEdge: 'M50.5 29C56.8 39 58.8 51 55.7 62C53.4 70.5 49.1 80.5 48.6 91',
|
||||
creaseBoldEdge: 'M50.1 27.5C59.5 39 62.1 51 58.9 62C56.4 70.5 50.7 80.5 48.5 92.5',
|
||||
// Everything left of the crease centreline: a clip that isolates the left lobe.
|
||||
leftHalf: 'M0 0H50.5V29C55 39 56.6 51 53.6 62C51.4 70.5 48 80.5 48.6 91V100H0Z',
|
||||
// The leaf keeps >= 2.5 units clear of the right lobe, or it merges with the body in a
|
||||
// one-colour silhouette. Its base overlaps the stem tip so the two read as one sprig; only the
|
||||
// stem touches the body, inside the notch.
|
||||
leaf: 'M53.2 13.4C57 5.5 67.5 1.2 80.5 2.6C75.5 9.2 65.5 12.6 53.2 13.4Z',
|
||||
leafRib: 'M55.6 11.7C62 8.6 70 5.7 78 3.7',
|
||||
stem: 'M48.8 26C48.6 21 49.6 16 52.4 12.5L54.6 14C52.4 17 51.6 21 51.6 26Z',
|
||||
});
|
||||
|
||||
// Where the art is centred in the design box; drawPeachSilhouette puts this point at (cx, cy).
|
||||
const CENTER_X = 50.5;
|
||||
const CENTER_Y = 50;
|
||||
|
||||
// ---------------------------------------------------------------- silhouette
|
||||
|
||||
// Built on first use so importing this module never touches Path2D (absent in Node).
|
||||
let paths = null;
|
||||
|
||||
function getPaths() {
|
||||
if (!paths) {
|
||||
paths = {
|
||||
body: new Path2D(PEACH.body + ' ' + PEACH.crease),
|
||||
bodyBold: new Path2D(PEACH.body + ' ' + PEACH.creaseBold),
|
||||
stem: new Path2D(PEACH.stem),
|
||||
leaf: new Path2D(PEACH.leaf),
|
||||
};
|
||||
}
|
||||
return paths;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fills a one-colour peach: the body with the crease cut out as negative space, then the stem
|
||||
* and the leaf. The context state is restored afterwards.
|
||||
* @param {CanvasRenderingContext2D} g
|
||||
* @param {number} cx centre x in canvas px
|
||||
* @param {number} cy centre y in canvas px
|
||||
* @param {number} height height of the 100-unit design box in canvas px (the art is ~96% of it)
|
||||
* @param {string} color any canvas fillStyle
|
||||
* @param {{ bold?: boolean }} [opts] bold uses the double-width crease, for small sizes
|
||||
*/
|
||||
export function drawPeachSilhouette(g, cx, cy, height, color, { bold = false } = {}) {
|
||||
const p = getPaths();
|
||||
const s = height / 100;
|
||||
g.save();
|
||||
g.translate(cx, cy);
|
||||
g.scale(s, s);
|
||||
g.translate(-CENTER_X, -CENTER_Y);
|
||||
g.fillStyle = color;
|
||||
// evenodd: the crease sliver lies strictly inside the body, so it punches a hole.
|
||||
g.fill(bold ? p.bodyBold : p.body, 'evenodd');
|
||||
g.fill(p.stem);
|
||||
g.fill(p.leaf);
|
||||
g.restore();
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export const SCREEN_W = 320; // logical px, the 5G LCD
|
||||
export const SCREEN_H = 240;
|
||||
export const SCREEN_SCALE = 2; // backing canvas 640x480: about 2-3x oversampled at arm's length on Quest, so it is mipmapped
|
||||
@@ -0,0 +1,386 @@
|
||||
// The assPod's front, drawn flat into one canvas that SteamVR shows in the hand: the body, the
|
||||
// screen (the OS canvas, dimmed by the backlight), and the click wheel with its labels, the
|
||||
// pressed part and the touch marker. Proportions are the 5th-generation body's, in millimetres.
|
||||
//
|
||||
// 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.
|
||||
|
||||
export const BODY = { w: 61.8, h: 103.5, corner: 6.5 };
|
||||
const LCD = { cy: 24.2, w: 50.8, h: 38.1 };
|
||||
const WINDOW = { w: 53.2, h: 40.5, r: 0.9 };
|
||||
const WHEEL = { cy: -24.0, rOuter: 19.7, rCenter: 7.3, rMarker: 13.5, markerSize: 9.5 };
|
||||
const SECTOR = { menu: 0, next: Math.PI / 2, play: Math.PI, prev: Math.PI * 1.5 };
|
||||
|
||||
const FINISHES = {
|
||||
black: { front: '#0b0b0c', frontHi: '#2a2a2d', wheel: '#1a1a1b', wheelHi: '#2b2b2d', label: '#9a9a9a',
|
||||
wheelEdge: '#060606', well: '#030303', center: '#141415', pressed: 'rgba(255,255,255,0.10)' },
|
||||
white: { front: '#f4f4f2', frontHi: '#ffffff', wheel: '#cdcecf', wheelHi: '#d9dadb', label: '#f2f2f2',
|
||||
wheelEdge: '#b4b5b6', well: '#8d8d89', center: '#f6f6f4', pressed: 'rgba(0,0,0,0.10)' },
|
||||
};
|
||||
|
||||
const MARKER_IN = 0.06; // s, the marker fading in on touch
|
||||
const MARKER_OUT = 0.15; // s, and out
|
||||
|
||||
function roundRect(g, x, y, w, h, r) {
|
||||
g.beginPath();
|
||||
g.moveTo(x + r, y);
|
||||
g.arcTo(x + w, y, x + w, y + h, r);
|
||||
g.arcTo(x + w, y + h, x, y + h, r);
|
||||
g.arcTo(x, y + h, x, y, r);
|
||||
g.arcTo(x, y, x + w, y, r);
|
||||
g.closePath();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {HTMLCanvasElement} canvas the face, BODY.w : BODY.h
|
||||
* @param {HTMLCanvasElement} screen the OS canvas (4:3)
|
||||
*/
|
||||
export function createFace(canvas, screen, opts = {}) {
|
||||
// overlay: only what changes (the screen in its black window, the pressed part, the touch marker)
|
||||
// and the gloss, over the 3D model's front; otherwise the whole flat face
|
||||
const overlay = !!opts.overlay;
|
||||
const g = canvas.getContext('2d');
|
||||
const W = canvas.width;
|
||||
const H = canvas.height;
|
||||
const k = W / BODY.w; // px per mm
|
||||
const X = (mm) => W / 2 + mm * k;
|
||||
const Y = (mm) => H / 2 - mm * k;
|
||||
const layers = new Map(); // finish -> static body
|
||||
let last = '';
|
||||
let marker = 0; // 0..1 marker opacity
|
||||
let lastT = 0;
|
||||
|
||||
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) };
|
||||
|
||||
function bodyLayer(finish) {
|
||||
let c = layers.get(finish);
|
||||
if (c) return c;
|
||||
const f = FINISHES[finish] || FINISHES.black;
|
||||
c = document.createElement('canvas');
|
||||
c.width = W;
|
||||
c.height = H;
|
||||
const b = c.getContext('2d');
|
||||
// The front plate: it covers the whole face (the steel shows only on the edges and the back).
|
||||
roundRect(b, 0, 0, W, H, BODY.corner * k);
|
||||
b.fillStyle = f.front;
|
||||
b.fill();
|
||||
// The screen window.
|
||||
roundRect(b, X(-WINDOW.w / 2), Y(LCD.cy + WINDOW.h / 2), WINDOW.w * k, WINDOW.h * k, WINDOW.r * k);
|
||||
b.fillStyle = '#000';
|
||||
b.fill();
|
||||
// The wheel: its well, the ring, the labels and the centre button.
|
||||
b.beginPath();
|
||||
b.arc(wc.x, wc.y, (WHEEL.rOuter + 0.3) * k, 0, Math.PI * 2);
|
||||
b.fillStyle = f.well;
|
||||
b.fill();
|
||||
b.beginPath();
|
||||
b.arc(wc.x, wc.y, WHEEL.rOuter * k, 0, Math.PI * 2);
|
||||
const ring = b.createRadialGradient(wc.x, wc.y - WHEEL.rOuter * k * 0.6, WHEEL.rOuter * k * 0.1, wc.x, wc.y, WHEEL.rOuter * k);
|
||||
ring.addColorStop(0, f.wheelHi);
|
||||
ring.addColorStop(1, f.wheel);
|
||||
b.fillStyle = ring;
|
||||
b.fill();
|
||||
drawLabels(b, f.label);
|
||||
b.beginPath();
|
||||
b.arc(wc.x, wc.y, (WHEEL.rCenter + 0.25) * k, 0, Math.PI * 2);
|
||||
b.fillStyle = f.wheelEdge;
|
||||
b.fill();
|
||||
b.beginPath();
|
||||
b.arc(wc.x, wc.y, WHEEL.rCenter * k, 0, Math.PI * 2);
|
||||
b.fillStyle = f.center;
|
||||
b.fill();
|
||||
layers.set(finish, c);
|
||||
return c;
|
||||
}
|
||||
|
||||
// The glossy finish: a clear-coat sheen over the whole face (a soft reflection from above and a
|
||||
// diagonal highlight band), drawn once per finish.
|
||||
const glosses = new Map();
|
||||
function glossLayer(finish) {
|
||||
let c = glosses.get(finish);
|
||||
if (c) return c;
|
||||
c = document.createElement('canvas');
|
||||
c.width = W;
|
||||
c.height = H;
|
||||
const q = c.getContext('2d');
|
||||
roundRect(q, 0, 0, W, H, BODY.corner * k);
|
||||
q.clip();
|
||||
const white = finish === 'white';
|
||||
const top = q.createLinearGradient(0, 0, 0, H * 0.55);
|
||||
top.addColorStop(0, `rgba(255,255,255,${white ? 0.55 : 0.22})`);
|
||||
top.addColorStop(0.35, `rgba(255,255,255,${white ? 0.18 : 0.07})`);
|
||||
top.addColorStop(1, 'rgba(255,255,255,0)');
|
||||
q.fillStyle = top;
|
||||
q.fillRect(0, 0, W, H * 0.55);
|
||||
// the diagonal highlight band, as a soft box reflected in the coat
|
||||
q.save();
|
||||
q.translate(W * 0.15, H * 0.42);
|
||||
q.rotate(-0.52);
|
||||
const band = q.createLinearGradient(0, -H * 0.09, 0, H * 0.09);
|
||||
band.addColorStop(0, 'rgba(255,255,255,0)');
|
||||
band.addColorStop(0.5, `rgba(255,255,255,${white ? 0.30 : 0.13})`);
|
||||
band.addColorStop(1, 'rgba(255,255,255,0)');
|
||||
q.fillStyle = band;
|
||||
q.fillRect(-W, -H * 0.09, W * 3, H * 0.18);
|
||||
q.restore();
|
||||
glosses.set(finish, c);
|
||||
return c;
|
||||
}
|
||||
|
||||
function drawLabels(b, color) {
|
||||
const r = (WHEEL.rOuter + WHEEL.rCenter) / 2 * k + 0.6 * k;
|
||||
const s = 1.35 * k; // glyph half-size
|
||||
b.fillStyle = color;
|
||||
b.textAlign = 'center';
|
||||
b.textBaseline = 'middle';
|
||||
b.font = `700 ${Math.round(2.9 * k)}px "LGS Inter", "Helvetica Neue", Helvetica, Arial, sans-serif`;
|
||||
b.fillText('MENU', wc.x, wc.y - r);
|
||||
const tri = (x, y, dir) => { // dir 1 = pointing right
|
||||
b.beginPath();
|
||||
b.moveTo(x - dir * s * 0.8, y - s);
|
||||
b.lineTo(x + dir * s * 0.8, y);
|
||||
b.lineTo(x - dir * s * 0.8, y + s);
|
||||
b.closePath();
|
||||
b.fill();
|
||||
};
|
||||
// Next: two triangles and a bar.
|
||||
tri(wc.x + r - s * 1.1, wc.y, 1);
|
||||
tri(wc.x + r + s * 0.5, wc.y, 1);
|
||||
b.fillRect(wc.x + r + s * 1.3, wc.y - s, s * 0.35, s * 2);
|
||||
// Previous: mirrored.
|
||||
tri(wc.x - r + s * 1.1, wc.y, -1);
|
||||
tri(wc.x - r - s * 0.5, wc.y, -1);
|
||||
b.fillRect(wc.x - r - s * 1.65, wc.y - s, s * 0.35, s * 2);
|
||||
// Play/Pause: a triangle and two bars.
|
||||
const py = wc.y + r;
|
||||
tri(wc.x - s * 0.9, py, 1);
|
||||
b.fillRect(wc.x + s * 0.5, py - s, s * 0.38, s * 2);
|
||||
b.fillRect(wc.x + s * 1.15, py - s, s * 0.38, s * 2);
|
||||
}
|
||||
|
||||
function drawPressed(part, f) {
|
||||
g.save();
|
||||
g.fillStyle = f.pressed;
|
||||
if (part === 'select') {
|
||||
g.beginPath();
|
||||
g.arc(wc.x, wc.y, WHEEL.rCenter * k, 0, Math.PI * 2);
|
||||
g.fill();
|
||||
} else if (part in SECTOR) {
|
||||
const a = SECTOR[part] - Math.PI / 2; // canvas angle (0 = right, clockwise)
|
||||
g.beginPath();
|
||||
g.arc(wc.x, wc.y, WHEEL.rOuter * k, a - Math.PI / 4, a + Math.PI / 4);
|
||||
g.arc(wc.x, wc.y, (WHEEL.rCenter + 0.3) * k, a + Math.PI / 4, a - Math.PI / 4, true);
|
||||
g.closePath();
|
||||
g.fill();
|
||||
}
|
||||
g.restore();
|
||||
}
|
||||
|
||||
function drawMarker(angle, alpha) {
|
||||
const a = angle - Math.PI / 2;
|
||||
const x = wc.x + Math.cos(a) * WHEEL.rMarker * k;
|
||||
const y = wc.y + Math.sin(a) * WHEEL.rMarker * k;
|
||||
const r = WHEEL.markerSize / 2 * k;
|
||||
const glow = g.createRadialGradient(x, y, 0, x, y, r);
|
||||
glow.addColorStop(0, `rgba(160,200,255,${0.55 * alpha})`);
|
||||
glow.addColorStop(0.6, `rgba(120,170,255,${0.22 * alpha})`);
|
||||
glow.addColorStop(1, 'rgba(120,170,255,0)');
|
||||
g.fillStyle = glow;
|
||||
g.beginPath();
|
||||
g.arc(x, y, r, 0, Math.PI * 2);
|
||||
g.fill();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ finish: string, backlight: number, screenDirty: boolean, touching: boolean, angle: number,
|
||||
* pressed: string|null, now: number }} s
|
||||
* @returns {boolean} whether it drew
|
||||
*/
|
||||
function draw(s) {
|
||||
const dt = lastT ? Math.max(0, (s.now - lastT) / 1000) : 0;
|
||||
lastT = s.now;
|
||||
const target = s.touching ? 1 : 0;
|
||||
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,
|
||||
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 (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';
|
||||
g.fill();
|
||||
// podd's video panel lies under the LCD: the LCD is left clear for it
|
||||
if (s.videoUnder) g.clearRect(lcd.x, lcd.y, lcd.w, lcd.h);
|
||||
} else {
|
||||
g.drawImage(bodyLayer(s.finish), 0, 0);
|
||||
}
|
||||
// The LCD, then the backlight as a black veil (off still shows a dim screen).
|
||||
g.imageSmoothingEnabled = true;
|
||||
g.imageSmoothingQuality = 'high';
|
||||
g.drawImage(screen, lcd.x, lcd.y, lcd.w, lcd.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(lcd.x, lcd.y, lcd.w, lcd.h);
|
||||
}
|
||||
// A faint reflection across the glass.
|
||||
const refl = g.createLinearGradient(lcd.x, lcd.y, lcd.x + lcd.w, lcd.y + lcd.h);
|
||||
refl.addColorStop(0, 'rgba(255,255,255,0.07)');
|
||||
refl.addColorStop(0.5, 'rgba(255,255,255,0)');
|
||||
g.fillStyle = refl;
|
||||
g.fillRect(lcd.x, lcd.y, lcd.w, lcd.h);
|
||||
if (s.screenOnly) return true; // podd draws the wheel, the marker and the glass
|
||||
if (s.pressed) drawPressed(s.pressed, f);
|
||||
if (marker > 0) drawMarker(s.angle, marker);
|
||||
if (s.reflection) {
|
||||
// the live reflection (reflect.js), scaled up smoothly over the face
|
||||
g.save();
|
||||
roundRect(g, 0, 0, W, H, BODY.corner * k);
|
||||
g.clip();
|
||||
g.imageSmoothingEnabled = true;
|
||||
g.imageSmoothingQuality = 'high';
|
||||
g.drawImage(s.reflection, 0, 0, W, H);
|
||||
g.restore();
|
||||
} else {
|
||||
g.drawImage(glossLayer(s.finish), 0, 0);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
return { draw, get animating() { return marker > 0 && marker < 1; } };
|
||||
}
|
||||
|
||||
/** The mirror-polished steel back with the peach engraved in it (static: drawn once). Polished
|
||||
* steel shows its surroundings: a bright sky above a dark horizon band, a pale floor below,
|
||||
* soft streaks of light, and one strong highlight. */
|
||||
export function drawBack(canvas, drawPeach) {
|
||||
const g = canvas.getContext('2d');
|
||||
const W = canvas.width;
|
||||
const H = canvas.height;
|
||||
const k = W / BODY.w;
|
||||
g.clearRect(0, 0, W, H);
|
||||
roundRect(g, 0, 0, W, H, BODY.corner * k);
|
||||
g.save();
|
||||
g.clip();
|
||||
const env = g.createLinearGradient(0, 0, W * 0.25, H);
|
||||
env.addColorStop(0, '#fbfcfe');
|
||||
env.addColorStop(0.18, '#dfe5ec');
|
||||
env.addColorStop(0.36, '#a9b1bb');
|
||||
env.addColorStop(0.44, '#3a3e45');
|
||||
env.addColorStop(0.5, '#575c64');
|
||||
env.addColorStop(0.58, '#c7ccd2');
|
||||
env.addColorStop(0.8, '#eef0f2');
|
||||
env.addColorStop(1, '#9aa0a8');
|
||||
g.fillStyle = env;
|
||||
g.fillRect(0, 0, W, H);
|
||||
// soft vertical streaks: the curve of the steel catching light
|
||||
for (const [x, a, w] of [[0.12, 0.28, 0.06], [0.3, 0.12, 0.1], [0.78, 0.22, 0.05], [0.9, 0.35, 0.04]]) {
|
||||
const st = g.createLinearGradient(W * (x - w), 0, W * (x + w), 0);
|
||||
st.addColorStop(0, 'rgba(255,255,255,0)');
|
||||
st.addColorStop(0.5, `rgba(255,255,255,${a})`);
|
||||
st.addColorStop(1, 'rgba(255,255,255,0)');
|
||||
g.fillStyle = st;
|
||||
g.fillRect(0, 0, W, H);
|
||||
}
|
||||
// the highlight
|
||||
const hi = g.createRadialGradient(W * 0.3, H * 0.16, 0, W * 0.3, H * 0.16, W * 0.55);
|
||||
hi.addColorStop(0, 'rgba(255,255,255,0.85)');
|
||||
hi.addColorStop(1, 'rgba(255,255,255,0)');
|
||||
g.fillStyle = hi;
|
||||
g.fillRect(0, 0, W, H);
|
||||
// the rounded edge darkens as it turns away
|
||||
const rim = g.createRadialGradient(W / 2, H / 2, Math.min(W, H) * 0.42, W / 2, H / 2, Math.max(W, H) * 0.62);
|
||||
rim.addColorStop(0, 'rgba(0,0,0,0)');
|
||||
rim.addColorStop(1, 'rgba(20,24,30,0.35)');
|
||||
g.fillStyle = rim;
|
||||
g.fillRect(0, 0, W, H);
|
||||
g.restore();
|
||||
// the engraving, 30 mm below the top edge: a frosted mark in the mirror
|
||||
if (drawPeach) drawPeach(g, W / 2, 30 * k, 18.5 * k, 'rgba(60,64,72,0.5)');
|
||||
g.fillStyle = 'rgba(60,64,72,0.5)';
|
||||
g.textAlign = 'center';
|
||||
g.textBaseline = 'middle';
|
||||
g.font = `600 ${Math.round(5 * k)}px "LGS Inter", "Helvetica Neue", Helvetica, Arial, sans-serif`;
|
||||
g.fillText('assPod', W / 2, 52 * k);
|
||||
}
|
||||
|
||||
/** The live steel back: the reflection (reflect.js) scaled over the back, the engraving etched in
|
||||
* it as satin (it takes the reflection, darker and softer), the edge turning away darker. */
|
||||
export function createBack(canvas, drawPeach) {
|
||||
const g = canvas.getContext('2d');
|
||||
const W = canvas.width;
|
||||
const H = canvas.height;
|
||||
const k = W / BODY.w;
|
||||
let last = -1;
|
||||
// the engraving as a mask, drawn once
|
||||
const mask = document.createElement('canvas');
|
||||
mask.width = W;
|
||||
mask.height = H;
|
||||
if (drawPeach) drawPeach(mask.getContext('2d'), W / 2, 30 * k, 18.5 * k, '#000');
|
||||
const mg = mask.getContext('2d');
|
||||
mg.fillStyle = '#000';
|
||||
mg.textAlign = 'center';
|
||||
mg.textBaseline = 'middle';
|
||||
mg.font = `600 ${Math.round(5 * k)}px "LGS Inter", "Helvetica Neue", Helvetica, Arial, sans-serif`;
|
||||
mg.fillText('assPod', W / 2, 52 * k);
|
||||
const etch = document.createElement('canvas');
|
||||
etch.width = W;
|
||||
etch.height = H;
|
||||
const eg = etch.getContext('2d');
|
||||
return {
|
||||
draw(reflection, version) {
|
||||
if (!reflection || version === last) return false;
|
||||
last = version;
|
||||
g.clearRect(0, 0, W, H);
|
||||
g.save();
|
||||
roundRect(g, 0, 0, W, H, BODY.corner * k);
|
||||
g.clip();
|
||||
g.imageSmoothingEnabled = true;
|
||||
g.imageSmoothingQuality = 'high';
|
||||
g.drawImage(reflection, 0, 0, W, H);
|
||||
// the etch: the reflection, blurred and dimmed, through the logo mask
|
||||
eg.clearRect(0, 0, W, H);
|
||||
eg.globalCompositeOperation = 'source-over';
|
||||
eg.filter = 'blur(3px) brightness(0.72)';
|
||||
eg.drawImage(canvas, 0, 0);
|
||||
eg.filter = 'none';
|
||||
eg.globalCompositeOperation = 'destination-in';
|
||||
eg.drawImage(mask, 0, 0);
|
||||
eg.globalCompositeOperation = 'source-over';
|
||||
g.drawImage(etch, 0, 0);
|
||||
const rim = g.createRadialGradient(W / 2, H / 2, Math.min(W, H) * 0.42, W / 2, H / 2, Math.max(W, H) * 0.62);
|
||||
rim.addColorStop(0, 'rgba(0,0,0,0)');
|
||||
rim.addColorStop(1, 'rgba(20,24,30,0.3)');
|
||||
g.fillStyle = rim;
|
||||
g.fillRect(0, 0, W, H);
|
||||
g.restore();
|
||||
return true;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** The edge: along the strip, the same all the way; across it (the depth), the white front
|
||||
* plate's rim at the top, then the polished steel of the back. */
|
||||
export function drawEdge(canvas, finish) {
|
||||
const g = canvas.getContext('2d');
|
||||
const W = canvas.width;
|
||||
const H = canvas.height;
|
||||
// across the depth: the front plate's thin lip, then the steel rounding back (mirror bands)
|
||||
const gr = g.createLinearGradient(0, 0, 0, H);
|
||||
const plate = finish === 'white' ? '#ecece9' : '#141416';
|
||||
gr.addColorStop(0, plate);
|
||||
gr.addColorStop(0.12, plate);
|
||||
gr.addColorStop(0.15, '#ffffff');
|
||||
gr.addColorStop(0.3, '#c9d0d8');
|
||||
gr.addColorStop(0.48, '#4a4f57');
|
||||
gr.addColorStop(0.6, '#e9edf1');
|
||||
gr.addColorStop(0.82, '#9aa1aa');
|
||||
gr.addColorStop(1, '#5f656e');
|
||||
g.fillStyle = gr;
|
||||
g.fillRect(0, 0, W, H);
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
// Click wheel state machine: turns a 2D "finger" sample per frame into assPod wheel events.
|
||||
// Pure module (no DOM, no three.js) so Node can unit-test it and any input source can drive it:
|
||||
// the XR thumbstick, a desktop mouse drag on the 3D wheel, or a test script.
|
||||
//
|
||||
// Conventions (see the design spec, section 3.1):
|
||||
// input { x, y, pressed }, x right, y UP, magnitude clamped to 1 (XR callers pass y = -axes[3])
|
||||
// angle radians, 0 = top (MENU), increasing clockwise: pi/2 Next, pi Play/Pause, 3pi/2 Previous
|
||||
// scroll +1 = clockwise = next item / volume up
|
||||
|
||||
// ---------------------------------------------------------------- constants
|
||||
|
||||
/** Wheel buttons by 90 degree sector: top, right, bottom, left. */
|
||||
export const SECTOR_PARTS = Object.freeze(['menu', 'next', 'play', 'prev']);
|
||||
|
||||
export const DEFAULTS = Object.freeze({
|
||||
step: Math.PI / 12, // 15 degrees per detent: 24 scroll steps per revolution
|
||||
touchOn: 0.45, // a touch starts at this radius ...
|
||||
touchOff: 0.30, // ... and only ends below this one, so a stick resting near the edge doesn't flicker
|
||||
scrollMinR: 0.6, // angle is only trusted for scrolling out on the ring; near the center atan2 is noise
|
||||
centerR: 0.45, // a press inside this radius is the center (Select) button
|
||||
holdS: 0.5,
|
||||
longHoldS: 2.0,
|
||||
});
|
||||
|
||||
const TAU = Math.PI * 2;
|
||||
// Lets an exact full turn (24 x 15 degrees summed from float atan2 deltas) land on exactly 24 steps.
|
||||
const STEP_EPS = 1e-6;
|
||||
// Returned when a frame produces no events, so idle frames allocate nothing. Treat results as read-only.
|
||||
const NO_EVENTS = Object.freeze([]);
|
||||
|
||||
/**
|
||||
* @typedef {'menu'|'next'|'play'|'prev'|'select'} Part
|
||||
* @typedef {{ x: number, y: number, pressed: boolean }} WheelInput
|
||||
* @typedef {{ type: 'touch', active: true, angle: number }
|
||||
* | { type: 'touch', active: false }
|
||||
* | { type: 'scroll', delta: 1 | -1 }
|
||||
* | { type: 'press', part: Part }
|
||||
* | { type: 'hold', part: Part }
|
||||
* | { type: 'longhold', part: Part }
|
||||
* | { type: 'release', part: Part, held: boolean, cancelled?: true }} WheelEvent
|
||||
* @typedef {{ touching: boolean, angle: number, radius: number, pressedPart: Part | null }} WheelState
|
||||
* @typedef {{ update(input: WheelInput | null, dt: number): readonly WheelEvent[],
|
||||
* readonly state: WheelState, reset(): WheelEvent[] }} ClickWheel
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------- helpers
|
||||
|
||||
/** Normalizes any angle into [0, 2pi). */
|
||||
function norm(a) {
|
||||
a %= TAU;
|
||||
return a < 0 ? a + TAU : a;
|
||||
}
|
||||
|
||||
/** Wraps an angle difference into (-pi, pi], so crossing 0/2pi counts as a small step. */
|
||||
function wrapDelta(d) {
|
||||
while (d > Math.PI) d -= TAU;
|
||||
while (d <= -Math.PI) d += TAU;
|
||||
return d;
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps a wheel angle to the button under it: 90 degree sectors centered on each label,
|
||||
* so MENU covers [-45, 45), Next [45, 135), Play [135, 225), Previous [225, 315).
|
||||
* @param {number} angle radians, 0 = top, clockwise
|
||||
* @returns {'menu'|'next'|'play'|'prev'}
|
||||
*/
|
||||
export function angleToPart(angle) {
|
||||
const i = Math.floor((norm(angle) + Math.PI / 4) / (Math.PI / 2)) % 4;
|
||||
return SECTOR_PARTS[i];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- state machine
|
||||
|
||||
/**
|
||||
* @param {Partial<typeof DEFAULTS>} [opts]
|
||||
* @returns {ClickWheel}
|
||||
*/
|
||||
export function createClickWheel(opts = {}) {
|
||||
const o = { ...DEFAULTS, ...opts };
|
||||
|
||||
/** @type {WheelState} live object, mutated in place (read it, don't keep copies expecting them to change) */
|
||||
const state = { touching: false, angle: 0, radius: 0, pressedPart: null };
|
||||
|
||||
let wasPressed = false;
|
||||
let acc = 0; // accumulated, not yet emitted rotation (radians)
|
||||
let prevAngle = 0;
|
||||
let prevOnRing = false; // previous sample was touching with r >= scrollMinR
|
||||
let pressT = 0;
|
||||
let holdFired = false;
|
||||
let longFired = false;
|
||||
/** @type {WheelEvent[] | null} */
|
||||
let out = null;
|
||||
|
||||
function emit(e) {
|
||||
(out ??= []).push(e);
|
||||
}
|
||||
|
||||
/**
|
||||
* Feeds one frame of input. Returns the events it produced, in order: touch start, scrolls,
|
||||
* press / hold / longhold / release, touch end.
|
||||
* @param {WheelInput | null} input null = untouched and unpressed
|
||||
* @param {number} dt seconds since the previous call
|
||||
*/
|
||||
function update(input, dt) {
|
||||
out = null;
|
||||
const x = input ? input.x : 0;
|
||||
const y = input ? input.y : 0;
|
||||
const pressed = !!(input && input.pressed);
|
||||
const r = Math.min(1, Math.hypot(x, y));
|
||||
state.radius = r;
|
||||
// Keep the last meaningful angle when the finger drifts to the center, so the
|
||||
// touch marker fades out where it was instead of snapping to the top.
|
||||
if (r >= o.touchOff) state.angle = norm(Math.atan2(x, y));
|
||||
const angle = state.angle;
|
||||
const onRing = r >= o.scrollMinR;
|
||||
|
||||
// Touch start. No scroll on this frame: the first sample only seeds the accumulator.
|
||||
if (!state.touching && r >= o.touchOn) {
|
||||
state.touching = true;
|
||||
acc = 0;
|
||||
prevAngle = angle;
|
||||
prevOnRing = onRing;
|
||||
emit({ type: 'touch', active: true, angle });
|
||||
} else if (state.touching && r >= o.touchOff) {
|
||||
// Scroll. Keep tracking the angle while pressed so releasing doesn't dump the
|
||||
// rotation made during the press into a burst of scrolls.
|
||||
if (!wasPressed && !pressed && prevOnRing && onRing) {
|
||||
acc += wrapDelta(angle - prevAngle);
|
||||
while (acc >= o.step - STEP_EPS) { acc -= o.step; emit({ type: 'scroll', delta: 1 }); }
|
||||
while (acc <= -o.step + STEP_EPS) { acc += o.step; emit({ type: 'scroll', delta: -1 }); }
|
||||
}
|
||||
prevAngle = angle;
|
||||
prevOnRing = onRing;
|
||||
}
|
||||
|
||||
// Buttons. The part is decided on the rising edge and stays latched until release,
|
||||
// even if the stick slides into another sector while held down.
|
||||
if (pressed && !wasPressed) {
|
||||
const part = r < o.centerR ? 'select' : angleToPart(angle);
|
||||
state.pressedPart = part;
|
||||
pressT = 0;
|
||||
holdFired = false;
|
||||
longFired = false;
|
||||
emit({ type: 'press', part });
|
||||
} else if (pressed && wasPressed) {
|
||||
pressT += dt || 0;
|
||||
if (!holdFired && pressT >= o.holdS - 1e-9) {
|
||||
holdFired = true;
|
||||
emit({ type: 'hold', part: state.pressedPart });
|
||||
}
|
||||
if (!longFired && pressT >= o.longHoldS - 1e-9) {
|
||||
longFired = true;
|
||||
emit({ type: 'longhold', part: state.pressedPart });
|
||||
}
|
||||
} else if (!pressed && wasPressed) {
|
||||
emit({ type: 'release', part: state.pressedPart, held: holdFired });
|
||||
state.pressedPart = null;
|
||||
acc = 0;
|
||||
}
|
||||
wasPressed = pressed;
|
||||
|
||||
// Touch end (hysteresis: only below touchOff).
|
||||
if (state.touching && r < o.touchOff) {
|
||||
state.touching = false;
|
||||
prevOnRing = false;
|
||||
acc = 0;
|
||||
emit({ type: 'touch', active: false });
|
||||
}
|
||||
|
||||
return out ?? NO_EVENTS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ends any press or touch (e.g. when the assPod is dropped) and returns the closing events. The
|
||||
* release is marked cancelled: the click was never finished, so it must not act like one.
|
||||
*/
|
||||
function reset() {
|
||||
/** @type {WheelEvent[]} */
|
||||
const events = [];
|
||||
if (wasPressed) events.push({ type: 'release', part: state.pressedPart, held: holdFired, cancelled: true });
|
||||
if (state.touching) events.push({ type: 'touch', active: false });
|
||||
wasPressed = false;
|
||||
state.touching = false;
|
||||
state.pressedPart = null;
|
||||
state.radius = 0;
|
||||
acc = 0;
|
||||
prevOnRing = false;
|
||||
pressT = 0;
|
||||
holdFired = false;
|
||||
longFired = false;
|
||||
return events;
|
||||
}
|
||||
|
||||
return {
|
||||
update,
|
||||
reset,
|
||||
get state() { return state; },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
// Reads both thumbsticks (tilt and click) and the right controller's A, B and menu (hamburger)
|
||||
// buttons from SteamVR's own
|
||||
// controller models, inside SteamVR's systemui page. SteamVR animates each model's parts from the
|
||||
// live input whatever has input focus, and VRHTML.VRRenderModels.GetComponentStateForDevicePath
|
||||
// returns a part's current pose: Steam's UI only gets digital presses while the dashboard is up,
|
||||
// and nothing at all for the sticks.
|
||||
//
|
||||
// Measured on the Frame (2026-10-09, both sticks circled and clicked): the thumbstick's pose is a
|
||||
// rotation about a pivot P below the stick, in the model's frame (x right, y up, -z forward):
|
||||
// xf = R x + (I - R) P to 0.05 mm. Its rotation vectors lie in the plane normal to the stick's axis,
|
||||
// up to |v| = 0.17 (full tilt). A click pushes the stick about 0.5 mm into the controller, along
|
||||
// the axis. A and B move about 1.4 mm. (The left controller's model has no parts for its face
|
||||
// buttons: those come from Steam, through the daemon.)
|
||||
|
||||
const DEV = { left: '/user/hand/left', right: '/user/hand/right' };
|
||||
const RENDER_MODEL_NAME = 1003; // ETrackedDeviceProperty Prop_RenderModelName_String
|
||||
// Per hand: the stick's pivot P (m) and its axis out of the controller, from the fits above.
|
||||
const STICK = {
|
||||
right: { P: [-0.0218, -0.0049, 0.0564], axis: [-0.173, 0.807, -0.565] },
|
||||
left: { P: [0.0218, -0.0049, 0.0565], axis: [0.168, 0.797, -0.581] },
|
||||
};
|
||||
const FULL_TILT = 0.165; // |vector part of the rotation| at full deflection
|
||||
const CLICK_M = 0.00025; // pushed in this far = clicked
|
||||
const CLICK_OFF_M = 0.00015; // ... and released below this (hysteresis)
|
||||
const BUTTON_M = 0.0006; // a face button moved this far = down
|
||||
const BUTTON_OFF_M = 0.0003;
|
||||
|
||||
const dot = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
|
||||
const cross = (a, b) => [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]];
|
||||
const norm = (a) => { const l = Math.hypot(a[0], a[1], a[2]) || 1; return [a[0] / l, a[1] / l, a[2] / l]; };
|
||||
|
||||
// The stick's right and forward directions, in the plane normal to its axis.
|
||||
function basis(axis) {
|
||||
const s = norm(axis);
|
||||
const proj = (v) => { const k = dot(v, s); return norm([v[0] - k * s[0], v[1] - k * s[1], v[2] - k * s[2]]); };
|
||||
return { s, right: proj([1, 0, 0]), fwd: proj([0, 0, -1]) };
|
||||
}
|
||||
|
||||
function rotate(q, v) {
|
||||
// v' = v + 2w (u x v) + 2 u x (u x v), q = (w, u)
|
||||
const u = [q.x, q.y, q.z];
|
||||
const t = cross(u, v).map((c) => 2 * c);
|
||||
const ut = cross(u, t);
|
||||
return [v[0] + q.w * t[0] + ut[0], v[1] + q.w * t[1] + ut[1], v[2] + q.w * t[2] + ut[2]];
|
||||
}
|
||||
|
||||
export function createControllerReader() {
|
||||
const VR = globalThis.VRHTML;
|
||||
const hands = {};
|
||||
for (const side of ['left', 'right']) {
|
||||
hands[side] = { side, dev: DEV[side], rm: null, stick: null, b: null, ...basis(STICK[side].axis),
|
||||
P: STICK[side].P, clicked: false, btn: {}, down: {}, x: 0, y: 0, ok: false, err: null };
|
||||
}
|
||||
|
||||
function component(h, source) {
|
||||
try { return VR.VRRenderModelsInternal.FindComponentForInputSource(h.rm, source) || null; } catch (_) { return null; }
|
||||
}
|
||||
function resolve(h) {
|
||||
if (h.rm) return true;
|
||||
try { h.rm = VR.VRProperties.GetStringProperty(h.dev, RENDER_MODEL_NAME) || null; } catch (_) { h.rm = null; }
|
||||
if (!h.rm) return false;
|
||||
h.stick = component(h, '/input/thumbstick');
|
||||
h.btn = {};
|
||||
if (h.side === 'right') for (const [k, src] of [['a', '/input/a'], ['b', '/input/b'], ['menu', '/input/menu']]) {
|
||||
const c = component(h, src);
|
||||
if (c) h.btn[k] = c;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
function pose(h, comp) {
|
||||
try {
|
||||
const st = VR.VRRenderModels.GetComponentStateForDevicePath(h.rm, comp, h.dev);
|
||||
return st && st.xfTrackingToComponentRenderModel;
|
||||
} catch (_) { return null; }
|
||||
}
|
||||
|
||||
function readHand(h) {
|
||||
h.ok = false;
|
||||
if (!VR || !resolve(h) || !h.stick) { h.x = h.y = 0; return; }
|
||||
const xf = pose(h, h.stick);
|
||||
if (!xf) { h.x = h.y = 0; return; }
|
||||
let q = xf.rotation;
|
||||
if (q.w < 0) q = { w: -q.w, x: -q.x, y: -q.y, z: -q.z };
|
||||
const v = [q.x, q.y, q.z];
|
||||
// The tip moves along v x axis; its right and forward parts are the stick's x and y.
|
||||
const d = cross(v, h.s);
|
||||
let x = dot(d, h.right) / FULL_TILT;
|
||||
let y = dot(d, h.fwd) / FULL_TILT;
|
||||
const r = Math.hypot(x, y);
|
||||
if (r > 1) { x /= r; y /= r; }
|
||||
h.x = x;
|
||||
h.y = y;
|
||||
// What the rotation about the pivot does not explain is the click, along the axis (inward).
|
||||
const t = [xf.translation.x, xf.translation.y, xf.translation.z];
|
||||
const rp = rotate(q, h.P);
|
||||
const resid = [t[0] - (h.P[0] - rp[0]), t[1] - (h.P[1] - rp[1]), t[2] - (h.P[2] - rp[2])];
|
||||
const depth = -dot(resid, h.s);
|
||||
h.clicked = h.clicked ? depth > CLICK_OFF_M : depth > CLICK_M;
|
||||
for (const [k, comp] of Object.entries(h.btn)) {
|
||||
const bx = pose(h, comp);
|
||||
const m = bx ? Math.hypot(bx.translation.x, bx.translation.y, bx.translation.z) : 0;
|
||||
h.down[k] = h.down[k] ? m > BUTTON_OFF_M : m > BUTTON_M;
|
||||
}
|
||||
h.ok = true;
|
||||
}
|
||||
|
||||
return {
|
||||
/** Reads both hands now. */
|
||||
update() { readHand(hands.left); readHand(hands.right); },
|
||||
/** One wheel thumb from both sticks: the one tilted further drives it; either click presses. */
|
||||
wheel() {
|
||||
const l = hands.left;
|
||||
const r = hands.right;
|
||||
const src = Math.hypot(r.x, r.y) >= Math.hypot(l.x, l.y) ? r : l;
|
||||
return { x: src.x, y: src.y, pressed: l.clicked || r.clicked };
|
||||
},
|
||||
get hands() { return hands; },
|
||||
/** The right controller's buttons now: { a, b, menu } (true while held). */
|
||||
get buttons() { return hands.right.down; },
|
||||
/** Whether the right controller's buttons can be read here (else Steam's relay is used). */
|
||||
get rightButtons() { return hands.right.ok && Object.keys(hands.right.btn).length > 0; },
|
||||
debug() {
|
||||
const o = {};
|
||||
for (const h of Object.values(hands)) {
|
||||
o[h.side] = { ok: h.ok, rm: h.rm, x: Math.round(h.x * 100) / 100, y: Math.round(h.y * 100) / 100, clicked: h.clicked,
|
||||
buttons: Object.assign({}, h.down) };
|
||||
}
|
||||
return o;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,534 @@
|
||||
// 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
|
||||
// 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.
|
||||
//
|
||||
// Flow: the bar's music-note button (device/rt/34-asspod.js) -> action asspod.open -> the plugin
|
||||
// starts VLC (the boot logo shows until it answers) and calls open(): the assPod appears in the hand
|
||||
// whose laser pressed the button (SteamVR's primary dashboard device): the original 3D model, face
|
||||
// up in the palm with its wheel over the stick. The whole menu fades out (Steam's windows and the
|
||||
// native glass); an invisible blocker takes the laser where it was, under "(menu glyph) to close".
|
||||
// Closing fades the menu back in.
|
||||
// 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
|
||||
// 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
|
||||
// and reports the state; setStatus(), setLibrary(), receiveArt(), setLoading() and btn() feed it.
|
||||
import { SCREEN_W, SCREEN_H, SCREEN_SCALE } from './config.js';
|
||||
import { settings } from './settings.js';
|
||||
import { createOS } from './os/os.js';
|
||||
import { apps } from './apps/index.js';
|
||||
import { createLibrary } from './media/library.js';
|
||||
import { createArt } from './media/art.js';
|
||||
import { createVlcEngine } from './media/engine.js';
|
||||
import { createPlayer } from './media/player.js';
|
||||
import { createClickWheel } from './input/clickwheel.js';
|
||||
import { createControllerReader } from './input/controllers.js';
|
||||
import { playClick, unlockClicker, clickerState } from './media/clicker.js';
|
||||
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';
|
||||
|
||||
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);
|
||||
const FACE_WIDTH_M = 0.085; // shown about 1.4x the real body, for reading at arm's length
|
||||
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 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)
|
||||
const MARGIN = 4; // px between our page rects
|
||||
const CAPTION = { w: 820, h: 120 };
|
||||
const SWATCH = { w: 48, h: 36 }; // the blocker's swatch (invisible; only its aspect matters)
|
||||
// Steam's own glyph for the controller's menu (hamburger) button (/steaminputglyphs/sd_button_menu.svg)
|
||||
const MENU_GLYPH = '<svg width="96" height="96" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">'
|
||||
+ '<path fill-rule="evenodd" clip-rule="evenodd" d="M7.33333 8.66663C3.28324 8.66663 0 11.9499 0 16C0 20.05 3.28325 23.3333 7.33333 '
|
||||
+ '23.3333H24.6667C28.7168 23.3333 32 20.05 32 16C32 11.9499 28.7168 8.66663 24.6667 8.66663H7.33333ZM20.6667 11.7222H11.3333V13.4333H20.6667V11.7222Z'
|
||||
+ 'M11.3333 18.5667H20.6667V20.2778H11.3333V18.5667ZM20.6667 15.1444H11.3333V16.8555H20.6667V15.1444Z" fill="white"/></svg>';
|
||||
const FRAME_MS = 16;
|
||||
const BACKGROUND_MS = 250; // the player's clock while the assPod is away
|
||||
const OUTBOX_MAX = 200;
|
||||
const RESUME_MIN_S = 10; // a video's place is kept from here (asspod.py RESUME_MIN_S) ...
|
||||
const RESUME_END_S = 30; // ... until this close to its end: then it starts over next time
|
||||
|
||||
export default function install(ctx) {
|
||||
const W = window;
|
||||
const doc = document;
|
||||
const t0 = performance.now();
|
||||
const now = () => performance.now() - t0;
|
||||
|
||||
// ---------------------------------------------------------------- page elements
|
||||
// Bottom of systemui's page: SteamVR lays its own panels out from the top (its tallest, Now
|
||||
// Playing and Settings, end near y 1210 of 2048), so this band is free.
|
||||
const host = doc.createElement('div');
|
||||
host.id = 'lgs-asspod';
|
||||
host.setAttribute('aria-hidden', 'true');
|
||||
host.style.cssText = 'position:fixed;left:0;top:0;width:0;height:0;overflow:visible;pointer-events:none;z-index:2147483000;display:none';
|
||||
const canvas = (w, h) => { const c = doc.createElement('canvas'); c.width = w; c.height = h; return c; };
|
||||
const faceCanvas = canvas(FACE_W, FACE_H);
|
||||
const backCanvas = canvas(BACK_W, BACK_H);
|
||||
const swatch = doc.createElement('div');
|
||||
const caption = doc.createElement('div');
|
||||
caption.innerHTML = MENU_GLYPH + '<span>to close</span>';
|
||||
host.append(faceCanvas, backCanvas, swatch, caption);
|
||||
(doc.body || doc.documentElement).appendChild(host);
|
||||
// SteamVR's own window chrome on this page (frame controls, grab bar, resize corner) fades with the
|
||||
// menu while the assPod is out: opacity only, so the panels keep their sizes
|
||||
const CHROME = '[class*="FrameControlsContainer"], [class*="GrabHandleButton"], [class*="ResizeHandleButton"]';
|
||||
const chromeStyle = doc.createElement('style');
|
||||
chromeStyle.id = 'lgs-asspod-chrome';
|
||||
chromeStyle.textContent = `html.lgs-asspod-out :is(${CHROME}) { opacity: 0 !important; transition: opacity 300ms ease !important; }
|
||||
`
|
||||
+ `html.lgs-asspod-fadein :is(${CHROME}) { transition: opacity 300ms ease !important; }`;
|
||||
(doc.head || doc.documentElement).appendChild(chromeStyle);
|
||||
let fadeInTimer = 0;
|
||||
const rects = {};
|
||||
function layout() {
|
||||
const iw = W.innerWidth;
|
||||
const ih = W.innerHeight;
|
||||
rects.face = { x: iw - FACE_W - MARGIN, y: ih - FACE_H - MARGIN, w: FACE_W, h: FACE_H };
|
||||
rects.back = { x: rects.face.x - BACK_W - 2 * MARGIN, y: ih - BACK_H - MARGIN, w: BACK_W, h: BACK_H };
|
||||
rects.text = { x: MARGIN, y: ih - CAPTION.h - MARGIN, w: CAPTION.w, h: CAPTION.h };
|
||||
rects.swatchBox = { x: MARGIN, y: rects.text.y - SWATCH.h - 2 * MARGIN, w: SWATCH.w, h: SWATCH.h };
|
||||
rects.dim = { x: rects.swatchBox.x + 8, y: rects.swatchBox.y + 6, w: SWATCH.w - 16, h: SWATCH.h - 12 };
|
||||
const at = (el, r) => { el.style.cssText = `position:fixed;left:${r.x}px;top:${r.y}px;width:${r.w}px;height:${r.h}px;margin:0;padding:0;border:0`; };
|
||||
at(faceCanvas, rects.face);
|
||||
at(backCanvas, rects.back);
|
||||
at(swatch, rects.swatchBox);
|
||||
swatch.style.background = 'rgba(0,0,0,0.01)';
|
||||
at(caption, rects.text);
|
||||
caption.style.cssText += ';display:flex;align-items:center;justify-content:center;gap:22px;box-sizing:border-box;'
|
||||
+ 'font:600 56px "LGS Inter","Helvetica Neue",Helvetica,Arial,sans-serif;color:#fff;letter-spacing:-0.01em;'
|
||||
+ 'text-shadow:0 2px 12px rgba(0,0,0,.55);background:rgba(28,28,30,.6);border-radius:60px;white-space:nowrap';
|
||||
}
|
||||
// our page rects must never overlap (one showed in another's panel)
|
||||
function overlaps() {
|
||||
const list = ['face', 'back', 'swatchBox', 'text'].map((k) => rects[k]);
|
||||
for (let i = 0; i < list.length; i++) for (let j = i + 1; j < list.length; j++) {
|
||||
const a = list[i], b = list[j];
|
||||
if (a.x < b.x + b.w && b.x < a.x + a.w && a.y < b.y + b.h && b.y < a.y + a.h) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
layout();
|
||||
|
||||
// ---------------------------------------------------------------- media and OS
|
||||
const outbox = [];
|
||||
const send = (cmd) => { outbox.push(cmd); if (outbox.length > OUTBOX_MAX) outbox.splice(0, outbox.length - OUTBOX_MAX); };
|
||||
const library = createLibrary(settings);
|
||||
const engine = createVlcEngine({ send });
|
||||
const player = createPlayer({ engine, settings });
|
||||
// Where each video was left (path -> s): the daemon's saved places at each open, kept up to date
|
||||
// here while one plays (device/shell_ext/asspod.py keeps the same, from VLC, and saves them).
|
||||
const resume = new Map();
|
||||
player.startAt = (it) => (it && it.kind === 'video' ? resume.get(it.path) || 0 : 0);
|
||||
function noteResume() {
|
||||
const it = player.current;
|
||||
if (!it || it.kind !== 'video' || player.state !== 'playing') return;
|
||||
const t = player.position;
|
||||
if (!Number.isFinite(t)) return;
|
||||
if (it.duration > 0 && t >= it.duration - RESUME_END_S) resume.delete(it.path);
|
||||
else if (t >= RESUME_MIN_S) resume.set(it.path, t);
|
||||
}
|
||||
let os = null;
|
||||
const art = createArt({ library, request: (key) => send({ op: 'art', key }), onChange: () => os && os.invalidate() });
|
||||
const screenCanvas = doc.createElement('canvas');
|
||||
screenCanvas.width = SCREEN_W * SCREEN_SCALE;
|
||||
screenCanvas.height = SCREEN_H * SCREEN_SCALE;
|
||||
os = createOS({ canvas: screenCanvas, player, library, art, apps, settings, boot: false });
|
||||
const wheel = createClickWheel();
|
||||
const reader = createControllerReader();
|
||||
const face = createFace(faceCanvas, screenCanvas, { overlay: true });
|
||||
const back = createBack(backCanvas, drawPeachSilhouette);
|
||||
const reflector = createReflector(BODY_M);
|
||||
const sg = createSceneGraph();
|
||||
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
|
||||
settings.on('change', ({ key }) => {
|
||||
if ((key === 'finish' || key === '*') && st.open && !st.podd) st.holdPose = sg.show(showOpts(st.hand, st.world)) || st.holdPose;
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- state
|
||||
const st = {
|
||||
open: false, hand: null, openedAt: 0, closedBy: null, frames: 0, lastFrame: 0, timer: 0, bgTimer: 0,
|
||||
loading: false, errors: [], lastStatusAt: 0, polls: 0,
|
||||
keys: {}, // face buttons held: part ('select' | 'menu') -> { t, held, src }
|
||||
rightWas: {}, // the right controller's buttons last frame
|
||||
menuArmed: false, // the hamburger must be released once after opening before it closes
|
||||
holds: { left: null, right: null }, // where the assPod sits in each hand when not the default (Reposition)
|
||||
world: null, // Reposition: pinned in the room ({rot, centre}, standing space) while you move the hand
|
||||
};
|
||||
const HOLD_S = 0.5;
|
||||
const note = (e) => { st.errors.push(String(e && e.message || e).slice(0, 200)); if (st.errors.length > 8) st.errors.shift(); };
|
||||
|
||||
function dashboardVisible() {
|
||||
try { return !!VRHTML.VRCompositor.IsDashboardVisibleInternal(); } catch (_) { return true; }
|
||||
}
|
||||
// The hand whose laser drives the menu: SteamVR's primary dashboard device and its role.
|
||||
function laserHand() {
|
||||
try {
|
||||
const idx = VRHTML.VROverlay.GetPrimaryDashboardDevice();
|
||||
if (idx >= 0) {
|
||||
const role = VRHTML.VRSystem.GetControllerRoleForTrackedDeviceIndex(idx);
|
||||
if (role === 1) return 'left';
|
||||
if (role === 2) return 'right';
|
||||
}
|
||||
} catch (_) { /* fall through */ }
|
||||
return 'right';
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- input -> OS
|
||||
function handle(e) {
|
||||
let changed = false;
|
||||
try { changed = os.handleWheelEvent(e); } catch (err) { note(err); }
|
||||
if (e.type === 'press') playClick('press');
|
||||
else if (e.type === 'scroll' && changed) playClick('tick');
|
||||
}
|
||||
|
||||
// A and B as the wheel's centre and Menu buttons: press, hold (after HOLD_S) and release, like
|
||||
// the wheel's own buttons. src says who pressed it, so one source's release ends only its press.
|
||||
function keyDown(part, src) {
|
||||
if (st.keys[part]) return;
|
||||
st.keys[part] = { t: now(), held: false, src };
|
||||
handle({ type: 'press', part });
|
||||
}
|
||||
function keyUp(part, src, cancelled) {
|
||||
const k = st.keys[part];
|
||||
if (!k || (src && k.src !== src)) return;
|
||||
delete st.keys[part];
|
||||
handle(Object.assign({ type: 'release', part, held: k.held }, cancelled ? { cancelled: true } : {}));
|
||||
}
|
||||
function keysTick() {
|
||||
const t = now();
|
||||
for (const [part, k] of Object.entries(st.keys)) {
|
||||
if (!k.held && t - k.t >= HOLD_S * 1000) { k.held = true; handle({ type: 'hold', part }); }
|
||||
}
|
||||
}
|
||||
// The right controller's A, B and hamburger, from its model.
|
||||
function rightButtons() {
|
||||
if (!reader.rightButtons) return false;
|
||||
const b = reader.buttons;
|
||||
const was = st.rightWas;
|
||||
const edge = (k, down, up) => { if (b[k] && !was[k]) down(); else if (!b[k] && was[k]) up(); };
|
||||
edge('a', () => keyDown('select', 'right'), () => keyUp('select', 'right'));
|
||||
edge('b', () => keyDown('menu', 'right'), () => keyUp('menu', 'right'));
|
||||
if (!b.menu) st.menuArmed = true;
|
||||
const close_ = b.menu && !was.menu && st.menuArmed;
|
||||
st.rightWas = Object.assign({}, b);
|
||||
if (close_) { close('menu'); return true; }
|
||||
return false;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- frame loop (while open)
|
||||
function frame() {
|
||||
st.timer = 0;
|
||||
if (!st.open) return;
|
||||
const t = now();
|
||||
const dt = st.lastFrame ? Math.min(0.1, (t - st.lastFrame) / 1000) : 0;
|
||||
st.lastFrame = t;
|
||||
st.frames++;
|
||||
try {
|
||||
if (!dashboardVisible()) { close('dashboard'); return; }
|
||||
reader.update();
|
||||
if (rightButtons()) return;
|
||||
keysTick();
|
||||
const evs = wheel.update(reader.wheel(), dt);
|
||||
for (let i = 0; i < evs.length; i++) handle(evs[i]);
|
||||
player.update(dt);
|
||||
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 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,
|
||||
touching: ws.touching, angle: ws.angle, pressed: ws.pressedPart || (st.keys.select ? 'select' : st.keys.menu ? 'menu' : null), now: t,
|
||||
});
|
||||
} catch (err) { note(err); }
|
||||
st.timer = setTimeout(frame, FRAME_MS);
|
||||
}
|
||||
|
||||
// The player's clock while the assPod is away: next track, end of the queue.
|
||||
function background() {
|
||||
st.bgTimer = 0;
|
||||
if (st.open) return;
|
||||
const t = now();
|
||||
const dt = st.lastFrame ? Math.min(1, (t - st.lastFrame) / 1000) : 0;
|
||||
st.lastFrame = t;
|
||||
try { player.update(dt); } catch (err) { note(err); }
|
||||
if (player.state === 'playing') st.bgTimer = setTimeout(background, BACKGROUND_MS);
|
||||
}
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
// A video on the screen with podd: podd shows its picture (the daemon's frames, full rate) in a panel
|
||||
// under the screen, and the video player draws only its overlays over a clear screen.
|
||||
function videoMode() {
|
||||
const on = !!(st.podd && st.podd.video && player.current && player.current.kind === 'video'
|
||||
&& os.ctx.top()?.id === 'videoplayer');
|
||||
if (on === !!st.videoMode) return;
|
||||
st.videoMode = on;
|
||||
art.videoExternal = on;
|
||||
os.invalidate();
|
||||
st.holdPose = sg.show(showOpts(st.hand, st.world)) || st.holdPose;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Reposition
|
||||
// The assPod stays where it is in the room while you move the controller; Select keeps the new
|
||||
// place in that hand, Menu leaves it as it was, Play/Pause goes back to the default.
|
||||
function pin() {
|
||||
const hp = pose('/user/hand/' + st.hand);
|
||||
const h = st.holdPose;
|
||||
if (!hp || !h) return false;
|
||||
st.world = { rot: qmul(hp.q, h.rot), centre: hp.t.map((v, i) => v + qrot(hp.q, h.centre)[i]) };
|
||||
st.holdPose = sg.show(showOpts(st.hand, st.world)) || st.holdPose;
|
||||
return true;
|
||||
}
|
||||
function unpin(keep) {
|
||||
if (!st.world) return;
|
||||
if (keep) {
|
||||
const hp = pose('/user/hand/' + st.hand);
|
||||
if (hp) {
|
||||
const inv = qconj(hp.q);
|
||||
st.holds[st.hand] = {
|
||||
rot: qmul(inv, st.world.rot),
|
||||
centre: qrot(inv, st.world.centre.map((v, i) => v - hp.t[i])),
|
||||
};
|
||||
}
|
||||
}
|
||||
st.world = null;
|
||||
if (st.open) st.holdPose = sg.show(showOpts(st.hand)) || st.holdPose;
|
||||
}
|
||||
apps.reposition = (ctx) => {
|
||||
let lines = null;
|
||||
const done = (fn) => { fn(); ctx.pop(); return true; };
|
||||
return {
|
||||
id: 'reposition',
|
||||
title: 'Reposition',
|
||||
keepAwake: true,
|
||||
enter() { if (!pin()) note('reposition: no hand pose'); },
|
||||
exit() { if (st.world) unpin(false); },
|
||||
handle(e) {
|
||||
if (e.type === 'scroll') return false;
|
||||
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); });
|
||||
return false;
|
||||
},
|
||||
draw(g, r) {
|
||||
const { C, M, FONTS } = ctx.theme;
|
||||
g.fillStyle = C.bg;
|
||||
g.fillRect(r.x, r.y, r.w, r.h);
|
||||
g.font = FONTS.row;
|
||||
g.fillStyle = C.text;
|
||||
g.textAlign = 'left';
|
||||
g.textBaseline = 'alphabetic';
|
||||
if (!lines) {
|
||||
lines = [];
|
||||
for (const para of ['The assPod stays put. Move your controller to where you want it.',
|
||||
'Select: keep it there', 'Menu: cancel', 'Play/Pause: default place']) {
|
||||
let line = '';
|
||||
for (const w of para.split(' ')) {
|
||||
const t = line ? line + ' ' + w : w;
|
||||
if (line && g.measureText(t).width > M.W - 2 * M.padX) { lines.push(line); line = w; } else line = t;
|
||||
}
|
||||
lines.push(line, '');
|
||||
}
|
||||
}
|
||||
lines.forEach((l, i) => g.fillText(l, M.padX, r.y + 24 + i * 19));
|
||||
},
|
||||
highlight: () => null,
|
||||
};
|
||||
};
|
||||
|
||||
// The menu's native glass (lgs_sg.js, in this page) fades with Steam's windows (34-asspod.css).
|
||||
function fadeMenu(v) {
|
||||
try { if (W.__LGS_SG && typeof W.__LGS_SG.fade === 'function') W.__LGS_SG.fade(v); } catch (e) { note(e); }
|
||||
const root = doc.documentElement;
|
||||
clearTimeout(fadeInTimer);
|
||||
root.classList.toggle('lgs-asspod-out', v < 1);
|
||||
root.classList.toggle('lgs-asspod-fadein', v >= 1);
|
||||
if (v >= 1) fadeInTimer = setTimeout(() => root.classList.remove('lgs-asspod-fadein'), 400);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- open / close
|
||||
function open(o) {
|
||||
const opts = o || {};
|
||||
if (st.open) return status();
|
||||
const hand = opts.hand === 'left' || opts.hand === 'right' ? opts.hand : laserHand();
|
||||
layout();
|
||||
host.style.display = 'block';
|
||||
if (overlaps()) note('page rects overlap');
|
||||
st.podd = opts.podd || null;
|
||||
if (opts.modelDir) st.modelDir = opts.modelDir;
|
||||
if (opts.resume && typeof opts.resume === 'object') {
|
||||
resume.clear();
|
||||
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);
|
||||
st.world = null;
|
||||
st.videoMode = false;
|
||||
art.videoExternal = false;
|
||||
const ok = sg.show(showOpts(hand));
|
||||
st.holdPose = ok;
|
||||
if (!ok) { host.style.display = 'none'; return Object.assign(status(), { ok: false, error: sg.status().error || 'scene graph' }); }
|
||||
fadeMenu(0);
|
||||
st.open = true;
|
||||
st.hand = hand;
|
||||
st.openedAt = Date.now();
|
||||
st.closedBy = null;
|
||||
st.keys = {};
|
||||
reader.update();
|
||||
st.rightWas = Object.assign({}, reader.buttons); // a button still held from before must be released first
|
||||
st.menuArmed = !st.rightWas.menu;
|
||||
if (st.bgTimer) { clearTimeout(st.bgTimer); st.bgTimer = 0; }
|
||||
unlockClicker();
|
||||
wheel.reset();
|
||||
os.invalidate();
|
||||
st.lastFrame = 0;
|
||||
frame();
|
||||
return status();
|
||||
}
|
||||
|
||||
function close(why) {
|
||||
if (!st.open) return status();
|
||||
st.open = false;
|
||||
st.closedBy = why || 'api';
|
||||
if (st.timer) { clearTimeout(st.timer); st.timer = 0; }
|
||||
for (const e of wheel.reset()) handle(e);
|
||||
for (const part of Object.keys(st.keys)) keyUp(part, null, true);
|
||||
sg.hide();
|
||||
st.videoMode = false;
|
||||
art.videoExternal = false;
|
||||
fadeMenu(1);
|
||||
host.style.display = 'none';
|
||||
st.lastFrame = now();
|
||||
if (player.state === 'playing') st.bgTimer = setTimeout(background, BACKGROUND_MS);
|
||||
return status();
|
||||
}
|
||||
|
||||
// Steam's digital buttons, relayed by the plugin: 'ok' (A) is the centre button, 'menu' (B) the
|
||||
// wheel's Menu, 'close' (the hamburger) puts the assPod away. side: which controller. The right
|
||||
// controller's are read from its model when that works, so Steam's copy of them is ignored then.
|
||||
function btn(name, down, side) {
|
||||
if (!st.open) return false;
|
||||
if (side === 'right' && reader.rightButtons) return true;
|
||||
if (name === 'close') { if (down) close('menu'); return true; }
|
||||
const part = name === 'ok' ? 'select' : name === 'menu' ? 'menu' : null;
|
||||
if (!part) return false;
|
||||
if (down) keyDown(part, 'steam'); else keyUp(part, 'steam');
|
||||
return true;
|
||||
}
|
||||
|
||||
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,
|
||||
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 },
|
||||
frames: st.frames, sg: sg.status(), art: art.stats, clicker: clickerState(), os: os.getState(),
|
||||
input: reader.debug(), errors: st.errors.slice(), outbox: outbox.length, videoFrames: st.videoFrames || 0, resume: resume.size,
|
||||
};
|
||||
}
|
||||
|
||||
// A message on the screen (the plugin's notices: VLC missing), until Select or Menu.
|
||||
function notice(title, text) {
|
||||
const { C, M, FONTS } = os.ctx.theme;
|
||||
let lines = null;
|
||||
os.ctx.push({
|
||||
id: 'notice',
|
||||
title: String(title || 'assPod'),
|
||||
handle(e) {
|
||||
if (e.type === 'press' && (e.part === 'select' || e.part === 'menu')) { os.ctx.pop(); return true; }
|
||||
return e.type === 'scroll' ? false : undefined;
|
||||
},
|
||||
draw(g, r) {
|
||||
g.fillStyle = C.bg;
|
||||
g.fillRect(r.x, r.y, r.w, r.h);
|
||||
g.font = FONTS.row;
|
||||
if (!lines) {
|
||||
lines = [];
|
||||
let line = '';
|
||||
for (const w of String(text || '').split(/\s+/)) {
|
||||
const t = line ? line + ' ' + w : w;
|
||||
if (line && g.measureText(t).width > M.W - 2 * M.padX) { lines.push(line); line = w; } else line = t;
|
||||
}
|
||||
if (line) lines.push(line);
|
||||
}
|
||||
g.fillStyle = C.text;
|
||||
g.textAlign = 'left';
|
||||
g.textBaseline = 'alphabetic';
|
||||
lines.forEach((l, i) => g.fillText(l, M.padX, r.y + 28 + i * 21));
|
||||
},
|
||||
highlight: () => null,
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- the plugin's api
|
||||
const api = {
|
||||
open,
|
||||
close: () => close('api'),
|
||||
btn,
|
||||
/** The commands for VLC since the last poll, and the state the plugin acts on. */
|
||||
poll() {
|
||||
noteResume();
|
||||
st.polls++;
|
||||
return {
|
||||
cmds: outbox.splice(0),
|
||||
open: st.open, hand: st.hand, closedBy: st.closedBy,
|
||||
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'),
|
||||
position: Math.round((player.position || 0) * 1000) / 1000,
|
||||
video: player.current && player.current.kind === 'video' && os.ctx.top()?.id === 'videoplayer' && st.open,
|
||||
};
|
||||
},
|
||||
setStatus(s) { st.lastStatusAt = Date.now(); engine.status(s); },
|
||||
setLibrary(data) { library.load(data); return { tracks: library.tracks.length, videos: library.videos.length }; },
|
||||
receiveArt(key, b64) { art.receive(key, b64); },
|
||||
/** A frame of the playing video (JPEG, base64) from the daemon, for the video player screen. */
|
||||
async videoFrame(path, b64) {
|
||||
if (!b64 || player.current?.path !== path) return false;
|
||||
try {
|
||||
const bin = atob(b64);
|
||||
const bytes = new Uint8Array(bin.length);
|
||||
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
|
||||
const bmp = await createImageBitmap(new Blob([bytes], { type: 'image/jpeg' }));
|
||||
const old = art.videoFrame;
|
||||
art.videoFrame = { path, image: bmp };
|
||||
if (old && old.image && old.image.close) old.image.close();
|
||||
st.videoFrames = (st.videoFrames || 0) + 1;
|
||||
return true;
|
||||
} catch (_) { return false; }
|
||||
},
|
||||
setLoading(on) { st.loading = !!on; os.setLoading(!!on); },
|
||||
notice,
|
||||
status,
|
||||
goto(path) { os.debug.goto(path); return os.getState(); }, // lab
|
||||
seekBy(d) { player.seek(player.position + (Number(d) || 0)); return player.position; }, // lab
|
||||
remove() {
|
||||
close('remove');
|
||||
if (st.bgTimer) { clearTimeout(st.bgTimer); st.bgTimer = 0; }
|
||||
sg.destroy();
|
||||
host.remove();
|
||||
chromeStyle.remove();
|
||||
doc.documentElement.classList.remove('lgs-asspod-out', 'lgs-asspod-fadein');
|
||||
},
|
||||
};
|
||||
return api;
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
// Cover art and video thumbnails from the VLC media library. The daemon extracts them with ffmpeg
|
||||
// (embedded art, or a cover.jpg / folder.jpg beside the files; a frame for videos) and hands them
|
||||
// over as JPEG bytes. SteamVR's pages forbid data: URLs (CSP), so the bytes are decoded with
|
||||
// createImageBitmap, which fetches nothing.
|
||||
//
|
||||
// cover(albumId) always returns a 256 px canvas: a drawn tile (the album's initials on a colour
|
||||
// from its name) until the real cover arrives, then a new canvas. Screens notice the new object
|
||||
// (Cover Flow re-bakes, Now Playing re-scales), and onChange() redraws them.
|
||||
import { hashString } from '../util/rng.js';
|
||||
|
||||
const SIZE = 256;
|
||||
const SANS = '"LGS Inter", "Helvetica Neue", Helvetica, Arial, sans-serif';
|
||||
|
||||
function makeCanvas(w = SIZE, h = SIZE) {
|
||||
const c = document.createElement('canvas');
|
||||
c.width = w;
|
||||
c.height = h;
|
||||
return c;
|
||||
}
|
||||
|
||||
/** A cover for an album without art: a two-tone gradient from its name, and its initials. */
|
||||
function drawTile(g, title, artist) {
|
||||
const h = hashString(`${title}\u0000${artist}`);
|
||||
const hue = h % 360;
|
||||
const gr = g.createLinearGradient(0, 0, SIZE, SIZE);
|
||||
gr.addColorStop(0, `hsl(${hue} 55% 46%)`);
|
||||
gr.addColorStop(1, `hsl(${(hue + 40) % 360} 60% 22%)`);
|
||||
g.fillStyle = gr;
|
||||
g.fillRect(0, 0, SIZE, SIZE);
|
||||
const words = String(title || '?').replace(/^(the|a|an)\s+/i, '').split(/\s+/).filter(Boolean);
|
||||
const initials = (words.length > 1 ? words[0][0] + words[1][0] : (words[0] || '?').slice(0, 2)).toUpperCase();
|
||||
g.fillStyle = 'rgba(255,255,255,0.92)';
|
||||
g.textAlign = 'center';
|
||||
g.textBaseline = 'middle';
|
||||
g.font = `700 96px ${SANS}`;
|
||||
g.fillText(initials, SIZE / 2, SIZE / 2 - 10);
|
||||
g.font = `600 18px ${SANS}`;
|
||||
g.fillStyle = 'rgba(255,255,255,0.7)';
|
||||
const a = String(artist || '');
|
||||
g.fillText(a.length > 24 ? a.slice(0, 23) + '…' : a, SIZE / 2, SIZE - 34);
|
||||
}
|
||||
|
||||
function b64ToBytes(b64) {
|
||||
const bin = atob(b64);
|
||||
const out = new Uint8Array(bin.length);
|
||||
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ library: any, request(key: string): void, onChange(): void }} opts
|
||||
* request(key) asks the daemon for the art behind `key` (a library art key); it answers through
|
||||
* receive(key, base64 | null).
|
||||
*/
|
||||
export function createArt({ library, request, onChange }) {
|
||||
const tiles = new Map(); // albumId -> drawn tile
|
||||
const images = new Map(); // art key -> canvas (null: the daemon has none)
|
||||
const asked = new Set();
|
||||
|
||||
function want(key) {
|
||||
if (!key || images.has(key) || asked.has(key)) return;
|
||||
asked.add(key);
|
||||
try { request(key); } catch (_) { asked.delete(key); }
|
||||
}
|
||||
|
||||
function tile(album) {
|
||||
let c = tiles.get(album.id);
|
||||
if (!c) {
|
||||
c = makeCanvas();
|
||||
drawTile(c.getContext('2d'), album.title, album.artist);
|
||||
tiles.set(album.id, c);
|
||||
}
|
||||
return c;
|
||||
}
|
||||
|
||||
return {
|
||||
cover(albumId) {
|
||||
const album = library.getAlbum(albumId);
|
||||
if (!album) return this.placeholder();
|
||||
const img = album.art ? images.get(album.art) : undefined;
|
||||
if (img) return img;
|
||||
want(album.art);
|
||||
return tile(album);
|
||||
},
|
||||
/** A video's thumbnail canvas, or null until it arrives (or when there is none). */
|
||||
thumb(video) {
|
||||
if (!video || !video.art) return null;
|
||||
const img = images.get(video.art);
|
||||
if (img === undefined) want(video.art);
|
||||
return img || null;
|
||||
},
|
||||
placeholder() { return null; },
|
||||
/** The daemon's answer to request(key). */
|
||||
async receive(key, b64) {
|
||||
asked.delete(key);
|
||||
if (!b64) { images.set(key, null); return; }
|
||||
try {
|
||||
const bmp = await createImageBitmap(new Blob([b64ToBytes(b64)], { type: 'image/jpeg' }));
|
||||
const w = bmp.width >= bmp.height ? SIZE : Math.round(SIZE * bmp.width / bmp.height);
|
||||
const h = bmp.width >= bmp.height ? Math.round(SIZE * bmp.height / bmp.width) : SIZE;
|
||||
// Covers are square; a thumbnail keeps its aspect.
|
||||
const square = !String(key).startsWith('v:');
|
||||
const c = makeCanvas(square ? SIZE : w, square ? SIZE : h);
|
||||
const g = c.getContext('2d');
|
||||
g.imageSmoothingQuality = 'high';
|
||||
g.drawImage(bmp, 0, 0, c.width, c.height);
|
||||
bmp.close?.();
|
||||
images.set(key, c);
|
||||
} catch (_) {
|
||||
images.set(key, null);
|
||||
}
|
||||
onChange();
|
||||
},
|
||||
/** Forgets everything (a new library). */
|
||||
clear() {
|
||||
tiles.clear();
|
||||
images.clear();
|
||||
asked.clear();
|
||||
},
|
||||
get stats() { return { tiles: tiles.size, images: images.size, pending: asked.size }; },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
// The click wheel's clicker: a short tick per detent and a firmer thunk per press, pre-rendered
|
||||
// once into buffers. Web Audio in SteamVR's own page; silent where there is none.
|
||||
import { settings } from '../settings.js';
|
||||
|
||||
let ctx = null;
|
||||
let clicks = null;
|
||||
let gain = null;
|
||||
|
||||
function context() {
|
||||
if (ctx) return ctx;
|
||||
const AC = globalThis.AudioContext ?? globalThis.webkitAudioContext;
|
||||
try { ctx = AC ? new AC({ latencyHint: 'interactive' }) : null; } catch { ctx = null; }
|
||||
if (ctx) {
|
||||
gain = ctx.createGain();
|
||||
gain.gain.value = 0.35;
|
||||
gain.connect(ctx.destination);
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
|
||||
/** Starts the audio context (pages may need a resume before they make sound). */
|
||||
export function unlockClicker() {
|
||||
const c = context();
|
||||
if (!c) return Promise.resolve();
|
||||
try { return Promise.resolve(c.resume()).then(() => {}, () => {}); } catch { return Promise.resolve(); }
|
||||
}
|
||||
|
||||
function renderClicks(c) {
|
||||
const sr = c.sampleRate;
|
||||
const make = (len, fn) => {
|
||||
const n = Math.floor(len * sr);
|
||||
const buf = c.createBuffer(1, n, sr);
|
||||
const d = buf.getChannelData(0);
|
||||
for (let i = 0; i < n; i++) d[i] = fn(i / sr);
|
||||
return buf;
|
||||
};
|
||||
let s = 0x1234567;
|
||||
const rnd = () => { s ^= s << 13; s ^= s >>> 17; s ^= s << 5; return ((s >>> 0) / 4294967296) * 2 - 1; };
|
||||
return {
|
||||
// 2.8 kHz resonance, ~8 ms to silence.
|
||||
tick: make(0.012, (t) => 0.9 * Math.exp(-t / 0.0016) * Math.sin(2 * Math.PI * 2800 * t) + 0.25 * Math.exp(-t / 0.0004) * rnd()),
|
||||
press: make(0.03, (t) => 1.0 * Math.exp(-t / 0.004) * Math.sin(2 * Math.PI * 1400 * t)
|
||||
+ 0.45 * Math.exp(-t / 0.0025) * Math.sin(2 * Math.PI * 3100 * t) + 0.3 * Math.exp(-t / 0.0008) * rnd()),
|
||||
};
|
||||
}
|
||||
|
||||
/** @param {'tick'|'press'} kind */
|
||||
export function playClick(kind) {
|
||||
if (!settings.get('clicker') || !ctx || ctx.state !== 'running') return;
|
||||
clicks ??= renderClicks(ctx);
|
||||
const src = ctx.createBufferSource();
|
||||
src.buffer = clicks[kind] ?? clicks.tick;
|
||||
src.connect(gain);
|
||||
src.onended = () => src.disconnect();
|
||||
src.start();
|
||||
}
|
||||
|
||||
export function clickerState() { return ctx ? ctx.state : 'none'; }
|
||||
@@ -0,0 +1,67 @@
|
||||
// The player's sound engine: VLC, run by the daemon (device/shell_ext/asspod.py) with its HTTP
|
||||
// interface. Commands go out through `send` (the daemon collects them a few times a second);
|
||||
// VLC's state comes back through status(). Between status reports the position runs on the local
|
||||
// clock, so the playhead moves smoothly and the player can tell when an item ends.
|
||||
//
|
||||
// Engine contract (media/player.js): play(item, offsetSec), stop(), position, setVolume(v), tick().
|
||||
|
||||
const RESYNC_S = 0.35; // a reported position this far from ours wins
|
||||
const END_GRACE_MS = 2500; // VLC reporting "stopped" this soon after a play is the play still starting
|
||||
|
||||
/** @param {{ send(cmd: object): void, now?: () => number }} opts */
|
||||
export function createVlcEngine({ send, now = () => Date.now() }) {
|
||||
let item = null; // what we asked VLC to play
|
||||
let playing = false;
|
||||
let base = 0; // position (s) at t0
|
||||
let t0 = 0;
|
||||
let startedAt = 0;
|
||||
let ended = false;
|
||||
let last = null; // the last status from the daemon
|
||||
|
||||
const pos = () => (playing ? base + (now() - t0) / 1000 : base);
|
||||
|
||||
return {
|
||||
play(it, offsetSec) {
|
||||
item = it;
|
||||
playing = true;
|
||||
ended = false;
|
||||
base = Math.max(0, Number(offsetSec) || 0);
|
||||
t0 = now();
|
||||
startedAt = t0;
|
||||
send({ op: 'play', id: it.id, path: it.path, at: Math.round(base * 1000) / 1000 });
|
||||
},
|
||||
stop() {
|
||||
if (!item) return;
|
||||
base = pos();
|
||||
playing = false;
|
||||
send({ op: 'pause' });
|
||||
},
|
||||
get position() { return ended ? Infinity : pos(); },
|
||||
setVolume(v) {
|
||||
send({ op: 'volume', v: Math.round(Math.max(0, Math.min(1, Number(v) || 0)) * 1000) / 1000 });
|
||||
},
|
||||
tick() {},
|
||||
|
||||
/**
|
||||
* VLC's state from the daemon: { state: 'playing'|'paused'|'stopped', path, time (s), length (s),
|
||||
* at (ms, when it was read) }.
|
||||
*/
|
||||
status(s) {
|
||||
if (!s || typeof s !== 'object') return;
|
||||
last = s;
|
||||
if (!item || !playing) return;
|
||||
const mine = s.path === item.path;
|
||||
if (mine && s.state === 'playing') {
|
||||
const t = Number(s.time) + Math.max(0, (now() - Number(s.at || now())) / 1000);
|
||||
if (Number.isFinite(t) && Math.abs(t - pos()) > RESYNC_S) { base = t; t0 = now(); }
|
||||
if (Number(s.length) > 1 && Math.abs(Number(s.length) - item.duration) > 1) item.duration = Number(s.length);
|
||||
} else if ((s.state === 'stopped' || (mine && s.state === 'ended')) && now() - startedAt > END_GRACE_MS) {
|
||||
// VLC played it to the end (it stops after each item): the player moves on.
|
||||
ended = true;
|
||||
}
|
||||
},
|
||||
get item() { return item; },
|
||||
get playing() { return playing; },
|
||||
get last() { return last; },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
// The music and video catalog, built from what the daemon read out of VLC's media library (its
|
||||
// saved Media Library list and its My Music / My Videos folders) with ffprobe tags. Same shape as
|
||||
// the screens expect: albums, tracks, videos, playlists, artists, genres, composers and the
|
||||
// query helpers. load() swaps the whole catalog in place, so the screens' `ctx.library` stays valid.
|
||||
//
|
||||
// Ratings and play counts live in settings (`ratings`, `plays`, memory only) and are mirrored onto
|
||||
// the track objects.
|
||||
import { hashString } from '../util/rng.js';
|
||||
|
||||
/**
|
||||
* @typedef {{ id: string, kind: 'song', path: string, title: string, artist: string, album: string,
|
||||
* albumId: string, genre: string, composer: string, trackNo: number, discNo: number, duration: number,
|
||||
* year: number, added: number, art: string|null, rating: number, playCount: number, lastPlayed: number }} Track
|
||||
* @typedef {{ id: string, kind: 'video', path: string, title: string, artist: string,
|
||||
* category: 'Movies'|'Music Videos'|'TV Shows'|'Video Podcasts', duration: number, added: number,
|
||||
* art: string|null }} Video
|
||||
* @typedef {{ id: string, title: string, artist: string, genre: string, year: number, composer: string,
|
||||
* art: string|null, tracks: Track[] }} Album
|
||||
* @typedef {{ id: string, title: string, items: () => (Track|Video)[] }} Playlist
|
||||
*/
|
||||
|
||||
const UNKNOWN_ARTIST = 'Unknown Artist';
|
||||
const UNKNOWN_ALBUM = 'Unknown Album';
|
||||
const CATEGORIES = new Set(['Movies', 'Music Videos', 'TV Shows', 'Video Podcasts']);
|
||||
|
||||
const sortKey = (s) => String(s ?? '').replace(/^(the|a|an)\s+/i, '').toLowerCase();
|
||||
const byKey = (a, b) => sortKey(a).localeCompare(sortKey(b), undefined, { numeric: true });
|
||||
const str = (v, dflt = '') => (typeof v === 'string' && v.trim() ? v.trim() : dflt);
|
||||
const num = (v, dflt = 0) => (Number.isFinite(Number(v)) ? Number(v) : dflt);
|
||||
|
||||
/** @param {{ get(k: string): any, set(k: string, v: any): void, on(t: string, f: Function): void }} settings */
|
||||
export function createLibrary(settings) {
|
||||
/** @type {Album[]} */ const albums = [];
|
||||
/** @type {Track[]} */ const tracks = [];
|
||||
/** @type {Video[]} */ const videos = [];
|
||||
/** @type {Playlist[]} */ const playlists = [];
|
||||
const artists = [];
|
||||
const genres = [];
|
||||
const composers = [];
|
||||
const onTheGo = [];
|
||||
const byId = new Map();
|
||||
const albumById = new Map();
|
||||
const listeners = new Set();
|
||||
let info = { sources: [], scanned: 0, at: 0 };
|
||||
|
||||
function applyStored() {
|
||||
const ratings = settings.get('ratings') ?? {};
|
||||
const plays = settings.get('plays') ?? {};
|
||||
for (const t of tracks) {
|
||||
const r = ratings[t.id];
|
||||
t.rating = Number.isFinite(r) ? r : 0;
|
||||
const p = plays[t.id];
|
||||
t.playCount = Array.isArray(p) ? p[0] : 0;
|
||||
t.lastPlayed = Array.isArray(p) ? p[1] : 0;
|
||||
}
|
||||
}
|
||||
settings.on('change', ({ key }) => {
|
||||
if (key === 'ratings' || key === 'plays' || key === '*') applyStored();
|
||||
});
|
||||
|
||||
const replace = (arr, items) => { arr.length = 0; arr.push(...items); };
|
||||
|
||||
/**
|
||||
* @param {{ tracks?: object[], videos?: object[], playlists?: { id: string, title: string, items: string[] }[],
|
||||
* sources?: string[], scanned?: number }} data
|
||||
*/
|
||||
function load(data) {
|
||||
const d = data && typeof data === 'object' ? data : {};
|
||||
byId.clear();
|
||||
albumById.clear();
|
||||
const newTracks = [];
|
||||
const albumMap = new Map();
|
||||
for (const r of Array.isArray(d.tracks) ? d.tracks : []) {
|
||||
if (!r || typeof r.id !== 'string' || typeof r.path !== 'string') continue;
|
||||
const artist = str(r.artist, UNKNOWN_ARTIST);
|
||||
const albumArtist = str(r.albumArtist, artist);
|
||||
const albumTitle = str(r.album, UNKNOWN_ALBUM);
|
||||
const albumId = 'al' + hashString(`${sortKey(albumArtist)}\u0000${albumTitle.toLowerCase()}`).toString(36);
|
||||
/** @type {Track} */
|
||||
const t = {
|
||||
id: r.id, kind: 'song', path: r.path,
|
||||
title: str(r.title, r.path.split('/').pop()),
|
||||
artist, album: albumTitle, albumId,
|
||||
genre: str(r.genre, 'Unknown'), composer: str(r.composer, artist),
|
||||
trackNo: num(r.trackNo), discNo: num(r.discNo, 1), duration: Math.max(1, num(r.duration, 0)),
|
||||
year: num(r.year), added: num(r.added), art: typeof r.art === 'string' ? r.art : null,
|
||||
rating: 0, playCount: 0, lastPlayed: 0,
|
||||
};
|
||||
let al = albumMap.get(albumId);
|
||||
if (!al) {
|
||||
al = { id: albumId, title: albumTitle, artist: albumArtist, genre: t.genre, year: t.year, composer: t.composer,
|
||||
art: t.art, tracks: [] };
|
||||
albumMap.set(albumId, al);
|
||||
}
|
||||
if (!al.art && t.art) al.art = t.art;
|
||||
if (!al.year && t.year) al.year = t.year;
|
||||
al.tracks.push(t);
|
||||
newTracks.push(t);
|
||||
}
|
||||
for (const al of albumMap.values()) {
|
||||
al.tracks.sort((a, b) => a.discNo - b.discNo || a.trackNo - b.trackNo || byKey(a.title, b.title));
|
||||
// A compilation (several artists, no album artist): the album is listed under its most common artist.
|
||||
if (al.artist === UNKNOWN_ARTIST) al.artist = al.tracks[0].artist;
|
||||
}
|
||||
const newAlbums = [...albumMap.values()].sort((a, b) => byKey(a.title, b.title));
|
||||
replace(albums, newAlbums);
|
||||
replace(tracks, newAlbums.flatMap((a) => a.tracks));
|
||||
|
||||
const newVideos = [];
|
||||
for (const r of Array.isArray(d.videos) ? d.videos : []) {
|
||||
if (!r || typeof r.id !== 'string' || typeof r.path !== 'string') continue;
|
||||
newVideos.push({
|
||||
id: r.id, kind: 'video', path: r.path, title: str(r.title, r.path.split('/').pop()), artist: str(r.artist),
|
||||
category: CATEGORIES.has(r.category) ? r.category : 'Movies',
|
||||
duration: Math.max(1, num(r.duration, 0)), added: num(r.added), art: typeof r.art === 'string' ? r.art : null,
|
||||
});
|
||||
}
|
||||
replace(videos, newVideos);
|
||||
for (const t of tracks) byId.set(t.id, t);
|
||||
for (const v of videos) byId.set(v.id, v);
|
||||
for (const a of albums) albumById.set(a.id, a);
|
||||
|
||||
const uniq = (list) => [...new Set(list)].sort(byKey);
|
||||
replace(artists, uniq(albums.map((a) => a.artist).concat(tracks.map((t) => t.artist))));
|
||||
replace(genres, uniq(tracks.map((t) => t.genre)));
|
||||
replace(composers, uniq(tracks.map((t) => t.composer)));
|
||||
onTheGo.length = 0;
|
||||
|
||||
// Playlists: the VLC Media Library list and playlist files first, then the smart ones.
|
||||
const lists = [];
|
||||
for (const p of Array.isArray(d.playlists) ? d.playlists : []) {
|
||||
if (!p || typeof p.title !== 'string' || !Array.isArray(p.items)) continue;
|
||||
const ids = p.items.filter((x) => typeof x === 'string');
|
||||
lists.push({ id: String(p.id || p.title), title: p.title, items: () => ids.map((id) => byId.get(id)).filter(Boolean) });
|
||||
}
|
||||
lists.push(
|
||||
{
|
||||
id: 'top-rated', title: 'My Top Rated',
|
||||
items: () => tracks.filter((t) => t.rating >= 4).sort((a, b) => b.rating - a.rating || b.playCount - a.playCount),
|
||||
},
|
||||
{
|
||||
id: 'recently-added', title: 'Recently Added',
|
||||
items: () => tracks.slice().sort((a, b) => b.added - a.added || a.trackNo - b.trackNo).slice(0, 25),
|
||||
},
|
||||
{
|
||||
id: 'recently-played', title: 'Recently Played',
|
||||
items: () => tracks.filter((t) => t.lastPlayed > 0).sort((a, b) => b.lastPlayed - a.lastPlayed).slice(0, 25),
|
||||
},
|
||||
{
|
||||
id: 'top-25', title: 'Top 25 Most Played',
|
||||
items: () => tracks.filter((t) => t.playCount > 0).sort((a, b) => b.playCount - a.playCount || b.lastPlayed - a.lastPlayed).slice(0, 25),
|
||||
},
|
||||
{ id: 'on-the-go', title: 'On-The-Go', items: () => onTheGo.slice() },
|
||||
);
|
||||
replace(playlists, lists);
|
||||
info = { sources: Array.isArray(d.sources) ? d.sources.slice(0, 20) : [], scanned: num(d.scanned), at: Date.now() };
|
||||
applyStored();
|
||||
for (const f of listeners) { try { f(); } catch (_) { /* a listener's bug */ } }
|
||||
}
|
||||
|
||||
function getAlbum(id) { return albumById.get(id) ?? null; }
|
||||
function getTrack(id) { return byId.get(id) ?? null; }
|
||||
function albumsBy(artist) { return albums.filter((a) => a.artist === artist || a.tracks.some((t) => t.artist === artist)); }
|
||||
function tracksBy(filter = {}) {
|
||||
return tracks.filter((t) => (filter.artist === undefined || t.artist === filter.artist)
|
||||
&& (filter.albumId === undefined || t.albumId === filter.albumId)
|
||||
&& (filter.genre === undefined || t.genre === filter.genre)
|
||||
&& (filter.composer === undefined || t.composer === filter.composer));
|
||||
}
|
||||
function search(q) {
|
||||
const s = String(q ?? '').trim().toLowerCase();
|
||||
if (!s) return { artists: [], albums: [], tracks: [] };
|
||||
return {
|
||||
artists: artists.filter((a) => a.toLowerCase().includes(s)),
|
||||
albums: albums.filter((a) => a.title.toLowerCase().includes(s)),
|
||||
tracks: tracks.filter((t) => t.title.toLowerCase().includes(s)),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
albums, tracks, videos, playlists, podcasts: [], audiobooks: [], artists, genres, composers, onTheGo,
|
||||
getAlbum, getTrack, albumsBy, tracksBy, search, load,
|
||||
get info() { return info; },
|
||||
onLoad(f) { listeners.add(f); return () => listeners.delete(f); },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,362 @@
|
||||
// Pure transport: queue, shuffle, repeat, seek, volume and ratings. Sound comes from an
|
||||
// injected Engine (synth.js in the app, a fake in tests), so this module never touches audio.
|
||||
import { Emitter } from '../util/emitter.js';
|
||||
import { settings as defaultSettings } from '../settings.js';
|
||||
|
||||
/**
|
||||
* @typedef {import('./library.js').Track} Track
|
||||
* @typedef {import('./library.js').Video} Video
|
||||
* @typedef {{ play(item: Track|Video, offsetSec: number): void, stop(): void, readonly position: number,
|
||||
* setVolume(v: number): void, tick?(): void }} Engine
|
||||
* @typedef {'stopped'|'playing'|'paused'} PlayState
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------- constants
|
||||
const RESTART_AFTER_SEC = 3; // Previous restarts the item past this point, as on the 5G
|
||||
const FF_RATE = 4; // FF/REW speed for the first FF_RAMP_SEC, then FF_RATE_FAST
|
||||
const FF_RATE_FAST = 16;
|
||||
const FF_RAMP_SEC = 2;
|
||||
const COUNT_MIN_SEC = 30; // a skip after this much listening (or half the item) still counts as a play
|
||||
// Scrubbing seeks once per wheel detent, and every engine restart builds a whole synth session, so
|
||||
// a seek while playing restarts the sound only after the seeks have settled for this long.
|
||||
const SEEK_SETTLE_SEC = 0.25;
|
||||
|
||||
const clamp = (v, lo, hi) => (v < lo ? lo : v > hi ? hi : v);
|
||||
|
||||
// ---------------------------------------------------------------- player
|
||||
/**
|
||||
* @param {{ engine: Engine, settings?: typeof defaultSettings, random?: () => number, now?: () => number }} opts
|
||||
*/
|
||||
export function createPlayer({ engine, settings = defaultSettings, random = Math.random, now = () => Date.now() }) {
|
||||
return new Player(engine, settings, random, now);
|
||||
}
|
||||
|
||||
class Player extends Emitter {
|
||||
#engine; #settings; #random; #now;
|
||||
/** @type {PlayState} */
|
||||
#state = 'stopped';
|
||||
/** @type {(Track|Video)[]} */
|
||||
#queue = [];
|
||||
#index = -1;
|
||||
#pausePos = 0;
|
||||
#seeking = 0;
|
||||
#seekPos = 0;
|
||||
#seekHeld = 0;
|
||||
#volume = 0.6;
|
||||
#listened = 0;
|
||||
#counted = false;
|
||||
#resumeIn = 0; // > 0: a seek is settling; the engine restarts at #pausePos when it runs out
|
||||
|
||||
constructor(engine, settings, random, now) {
|
||||
super();
|
||||
this.#engine = engine;
|
||||
this.#settings = settings;
|
||||
this.#random = random;
|
||||
this.#now = now;
|
||||
this.#volume = clamp(Number(settings.get('volume') ?? 0.6) || 0, 0, 1);
|
||||
engine.setVolume(this.#volume);
|
||||
settings.on('change', ({ key }) => {
|
||||
if (key !== 'volume' && key !== '*') return;
|
||||
const v = clamp(Number(settings.get('volume') ?? 0.6) || 0, 0, 1);
|
||||
if (v === this.#volume) return;
|
||||
this.#volume = v;
|
||||
this.#engine.setVolume(v);
|
||||
this.emit('volume', { volume: v });
|
||||
});
|
||||
}
|
||||
|
||||
/** (item) -> seconds to start it at (a video's saved place), or null: from the start. */
|
||||
startAt = null;
|
||||
|
||||
// ---------------------------------------------------------------- read-only state
|
||||
get state() { return this.#state; }
|
||||
get current() { return this.#queue[this.#index] ?? null; }
|
||||
get queue() { return this.#queue; }
|
||||
get index() { return this.#index; }
|
||||
get duration() { return this.current?.duration ?? 0; }
|
||||
get volume() { return this.#volume; }
|
||||
get seeking() { return this.#seeking; }
|
||||
get position() {
|
||||
if (!this.current) return 0;
|
||||
if (this.#seeking) return this.#seekPos;
|
||||
if (this.#resumeIn > 0) return this.#pausePos;
|
||||
if (this.#state === 'playing') return clamp(this.#engine.position, 0, this.duration);
|
||||
return this.#pausePos;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- queue
|
||||
playQueue(items, start = 0) {
|
||||
if (!items?.length) return;
|
||||
start = clamp(Math.floor(start) || 0, 0, items.length - 1);
|
||||
this.#leaveItem();
|
||||
this.#seeking = 0;
|
||||
const mode = this.#settings.get('shuffle');
|
||||
if (mode === 'songs') {
|
||||
const rest = items.filter((_, i) => i !== start);
|
||||
this.#queue = [items[start], ...this.#shuffle(rest)];
|
||||
this.#index = 0;
|
||||
} else if (mode === 'albums') {
|
||||
this.#queue = this.#shuffleAlbums(items, start);
|
||||
this.#index = 0;
|
||||
} else {
|
||||
this.#queue = items.slice();
|
||||
this.#index = start;
|
||||
}
|
||||
this.emit('queue', { queue: this.#queue });
|
||||
this.#load(this.#index, true);
|
||||
}
|
||||
|
||||
#shuffle(list) {
|
||||
const a = list.slice();
|
||||
for (let i = a.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(this.#random() * (i + 1));
|
||||
[a[i], a[j]] = [a[j], a[i]];
|
||||
}
|
||||
return a;
|
||||
}
|
||||
|
||||
/** Whole albums in random order; the chosen album comes first, starting at the chosen item. */
|
||||
#shuffleAlbums(items, start) {
|
||||
const groups = new Map();
|
||||
for (const it of items) {
|
||||
const k = it.albumId ?? it.id;
|
||||
if (!groups.has(k)) groups.set(k, []);
|
||||
groups.get(k).push(it);
|
||||
}
|
||||
const chosen = items[start];
|
||||
const first = groups.get(chosen.albumId ?? chosen.id);
|
||||
groups.delete(chosen.albumId ?? chosen.id);
|
||||
const at = first.indexOf(chosen);
|
||||
const head = first.slice(at).concat(first.slice(0, at));
|
||||
return head.concat(...this.#shuffle([...groups.values()]));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- transport
|
||||
togglePlay() {
|
||||
if (this.#state === 'playing') this.pause();
|
||||
else this.play();
|
||||
}
|
||||
|
||||
play() {
|
||||
if (this.#seeking) this.stopSeek();
|
||||
if (this.#state === 'playing') return;
|
||||
if (!this.current) {
|
||||
if (this.#queue.length) this.#load(0, true);
|
||||
return;
|
||||
}
|
||||
this.#engine.play(this.current, this.#pausePos);
|
||||
this.#setState('playing');
|
||||
}
|
||||
|
||||
pause() {
|
||||
if (this.#state !== 'playing') return;
|
||||
if (this.#seeking) this.stopSeek();
|
||||
this.#pausePos = this.position;
|
||||
this.#resumeIn = 0;
|
||||
this.#engine.stop();
|
||||
this.#setState('paused');
|
||||
}
|
||||
|
||||
stop() {
|
||||
if (!this.current && this.#state === 'stopped') return;
|
||||
this.#leaveItem();
|
||||
this.#toStopped();
|
||||
}
|
||||
|
||||
next() {
|
||||
if (!this.current) return;
|
||||
const autoplay = this.#state === 'playing';
|
||||
this.#seeking = 0;
|
||||
this.#leaveItem();
|
||||
if (this.#index + 1 < this.#queue.length) this.#load(this.#index + 1, autoplay);
|
||||
else if (this.#settings.get('repeat') === 'all') this.#load(0, autoplay);
|
||||
else this.#toStopped();
|
||||
}
|
||||
|
||||
prev() {
|
||||
if (!this.current) return;
|
||||
if (this.position > RESTART_AFTER_SEC) {
|
||||
this.seek(0);
|
||||
if (this.#resumeIn > 0) this.#resume(); // a deliberate restart, not a scrub: no settling gap
|
||||
return;
|
||||
}
|
||||
const autoplay = this.#state === 'playing';
|
||||
this.#seeking = 0;
|
||||
this.#leaveItem();
|
||||
if (this.#index > 0) this.#load(this.#index - 1, autoplay);
|
||||
else if (this.#settings.get('repeat') === 'all' && this.#queue.length > 1) this.#load(this.#queue.length - 1, autoplay);
|
||||
else this.#load(this.#index, autoplay);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- seeking
|
||||
seek(sec) {
|
||||
if (!this.current) return;
|
||||
const pos = clamp(Number(sec) || 0, 0, this.duration);
|
||||
if (this.#seeking) this.#seekPos = pos;
|
||||
else if (this.#state === 'playing') {
|
||||
// Silent until the seeks settle (update() restarts once); position reads the target meanwhile.
|
||||
if (this.#resumeIn <= 0) this.#engine.stop();
|
||||
this.#pausePos = pos;
|
||||
this.#resumeIn = SEEK_SETTLE_SEC;
|
||||
} else this.#pausePos = pos;
|
||||
this.emit('seek', { position: pos });
|
||||
}
|
||||
|
||||
/** Restarts the sound at the settled seek target. */
|
||||
#resume() {
|
||||
this.#resumeIn = 0;
|
||||
if (this.#state === 'playing' && this.current) this.#engine.play(this.current, this.#pausePos);
|
||||
}
|
||||
|
||||
/** FF/REW is silent: the engine stops and the position moves at 4x, then 16x after 2 s. */
|
||||
startSeek(dir) {
|
||||
if (!this.current || (dir !== 1 && dir !== -1)) return;
|
||||
if (this.#seeking === dir) return;
|
||||
if (!this.#seeking) {
|
||||
this.#seekPos = this.position;
|
||||
this.#resumeIn = 0; // FF/REW takes over a settling seek; stopSeek() restarts the sound
|
||||
if (this.#state === 'playing') this.#engine.stop();
|
||||
}
|
||||
this.#seeking = dir;
|
||||
this.#seekHeld = 0;
|
||||
this.emit('seek', { position: this.#seekPos });
|
||||
}
|
||||
|
||||
stopSeek() {
|
||||
if (!this.#seeking) return;
|
||||
this.#seeking = 0;
|
||||
if (!this.current) return;
|
||||
if (this.#state === 'playing') this.#engine.play(this.current, this.#seekPos);
|
||||
else this.#pausePos = this.#seekPos;
|
||||
this.emit('seek', { position: this.#seekPos });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- volume and ratings
|
||||
setVolume(v) {
|
||||
const vol = clamp(Number(v), 0, 1);
|
||||
if (!Number.isFinite(vol) || vol === this.#volume) return;
|
||||
this.#volume = vol;
|
||||
this.#engine.setVolume(vol);
|
||||
this.#settings.set('volume', vol);
|
||||
this.emit('volume', { volume: vol });
|
||||
}
|
||||
|
||||
rate(item, stars) {
|
||||
if (!item) return;
|
||||
const s = clamp(Math.round(Number(stars) || 0), 0, 5);
|
||||
item.rating = s;
|
||||
// Settings values are replaced, never mutated, so change listeners see a new object.
|
||||
this.#settings.set('ratings', { ...(this.#settings.get('ratings') ?? {}), [item.id]: s });
|
||||
this.emit('rating', { item });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- frame update
|
||||
update(dt) {
|
||||
this.#engine.tick?.();
|
||||
const item = this.current;
|
||||
if (!item) return;
|
||||
if (this.#seeking) {
|
||||
this.#seekHeld += dt;
|
||||
const rate = this.#seekHeld < FF_RAMP_SEC ? FF_RATE : FF_RATE_FAST;
|
||||
let p = this.#seekPos + this.#seeking * rate * dt;
|
||||
if (p >= item.duration) {
|
||||
const hasNext = this.#index + 1 < this.#queue.length || this.#settings.get('repeat') === 'all';
|
||||
if (hasNext) {
|
||||
// Fast-forwarding off the end rolls into the next item and keeps going.
|
||||
const dir = this.#seeking;
|
||||
this.#leaveItem();
|
||||
this.#index = this.#index + 1 < this.#queue.length ? this.#index + 1 : 0;
|
||||
this.#listened = 0;
|
||||
this.#counted = false;
|
||||
this.#seeking = dir;
|
||||
this.#pausePos = 0;
|
||||
p = 0;
|
||||
this.#markPlayed(this.current);
|
||||
this.emit('track', { item: this.current, index: this.#index });
|
||||
} else p = item.duration;
|
||||
}
|
||||
this.#seekPos = clamp(p, 0, this.current.duration);
|
||||
this.emit('seek', { position: this.#seekPos });
|
||||
return;
|
||||
}
|
||||
if (this.#state !== 'playing') return;
|
||||
if (this.#resumeIn > 0) {
|
||||
// A seek is settling: silent until the seeks stop, then one restart at the target.
|
||||
this.#resumeIn -= dt;
|
||||
if (this.#resumeIn <= 0) this.#resume();
|
||||
return;
|
||||
}
|
||||
this.#listened += dt;
|
||||
if (this.#engine.position >= item.duration) this.#ended();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- internals
|
||||
#ended() {
|
||||
this.#countPlay(true);
|
||||
const repeat = this.#settings.get('repeat');
|
||||
if (repeat === 'one') { this.#load(this.#index, true); return; }
|
||||
if (this.#index + 1 < this.#queue.length) this.#load(this.#index + 1, true);
|
||||
else if (repeat === 'all') this.#load(0, true);
|
||||
else { this.#engine.stop(); this.#toStopped(); }
|
||||
}
|
||||
|
||||
#load(index, autoplay) {
|
||||
this.#engine.stop();
|
||||
this.#index = index;
|
||||
this.#resumeIn = 0;
|
||||
this.#listened = 0;
|
||||
this.#counted = false;
|
||||
const item = this.current;
|
||||
let at = this.startAt ? Math.max(0, Number(this.startAt(item)) || 0) : 0;
|
||||
if (item.duration > 1) at = Math.min(at, item.duration - 1);
|
||||
this.#pausePos = at;
|
||||
if (autoplay) {
|
||||
this.#engine.play(item, at);
|
||||
this.#markPlayed(item);
|
||||
}
|
||||
this.emit('track', { item, index });
|
||||
this.#setState(autoplay ? 'playing' : 'paused');
|
||||
}
|
||||
|
||||
#toStopped() {
|
||||
this.#engine.stop();
|
||||
this.#seeking = 0;
|
||||
this.#index = -1;
|
||||
this.#pausePos = 0;
|
||||
this.#resumeIn = 0;
|
||||
this.emit('track', { item: null, index: -1 });
|
||||
this.#setState('stopped');
|
||||
}
|
||||
|
||||
#setState(s) {
|
||||
if (this.#state === s) return;
|
||||
this.#state = s;
|
||||
this.emit('state', { state: s });
|
||||
}
|
||||
|
||||
/** Counts the outgoing item as played if it was listened to long enough, then stops the engine. */
|
||||
#leaveItem() {
|
||||
if (this.current) this.#countPlay(false);
|
||||
this.#engine.stop();
|
||||
}
|
||||
|
||||
#countPlay(completed) {
|
||||
const item = this.current;
|
||||
if (!item || this.#counted) return;
|
||||
if (!completed && this.#listened < Math.min(COUNT_MIN_SEC, item.duration * 0.5)) return;
|
||||
this.#counted = true;
|
||||
item.playCount = (item.playCount ?? 0) + 1;
|
||||
item.lastPlayed = this.#now();
|
||||
this.#persistPlays(item);
|
||||
}
|
||||
|
||||
#markPlayed(item) {
|
||||
item.lastPlayed = this.#now();
|
||||
this.#persistPlays(item);
|
||||
}
|
||||
|
||||
#persistPlays(item) {
|
||||
const plays = this.#settings.get('plays') ?? {};
|
||||
this.#settings.set('plays', { ...plays, [item.id]: [item.playCount ?? 0, item.lastPlayed] });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
// Generic 5G list screen (ctx.menu): full-width rows, blue highlight, no wrap-around,
|
||||
// a window that only scrolls when the highlight passes its edge, a scrollbar above 9 rows,
|
||||
// and a marquee on the highlighted label when it doesn't fit.
|
||||
|
||||
import { M, C, FONTS } from './theme.js';
|
||||
|
||||
/**
|
||||
* @typedef {import('./os.js').ScreenContext} ScreenContext
|
||||
* @typedef {import('./os.js').Screen} Screen
|
||||
* @typedef {{
|
||||
* id?: string, // extension: stable id for os.debug.goto
|
||||
* label: string | (() => string),
|
||||
* detail?: string | (() => string),
|
||||
* chevron?: boolean,
|
||||
* check?: () => boolean,
|
||||
* speaker?: () => boolean,
|
||||
* open?: (ctx: ScreenContext) => Screen,
|
||||
* action?: (ctx: ScreenContext) => boolean | void,
|
||||
* }} MenuItem
|
||||
* @typedef {{
|
||||
* id: string, title: string | (() => string),
|
||||
* items: MenuItem[] | (() => MenuItem[]),
|
||||
* initialIndex?: () => number, // extension: where the highlight starts on enter
|
||||
* }} MenuDef
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------- constants
|
||||
|
||||
const ACCEL_MIN_ITEMS = 40; // only long lists accelerate
|
||||
const ACCEL_WINDOW_MS = 400;
|
||||
|
||||
function resolve(v) {
|
||||
return typeof v === 'function' ? v() : v;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- list screen
|
||||
|
||||
/**
|
||||
* @param {ScreenContext} ctx
|
||||
* @param {MenuDef} def
|
||||
* @returns {Screen & { menuItems(): MenuItem[], setIndex(i: number): void, refresh(): void }}
|
||||
*/
|
||||
export function createMenu(ctx, def) {
|
||||
const ui = ctx.ui;
|
||||
/** @type {MenuItem[]} */
|
||||
let items = [];
|
||||
let index = 0;
|
||||
let first = 0;
|
||||
let hiSince = 0; // when the highlight landed: the marquee clock
|
||||
let hiOverflow = false; // set at draw time, read by animating()
|
||||
let hiTextW = 0;
|
||||
let hiBoxW = 0;
|
||||
let lastMarqueeX = 0;
|
||||
const stepTimes = new Float64Array(10); // ring of recent scroll times, for acceleration
|
||||
let stepHead = 0;
|
||||
// One scratch row object, reused for every row on every draw.
|
||||
const row = { label: '', detail: '', chevron: false, speaker: false, check: false, highlighted: false, marqueeX: 0 };
|
||||
|
||||
function clampWindow() {
|
||||
const n = items.length;
|
||||
if (index >= n) index = Math.max(0, n - 1);
|
||||
if (index < 0) index = 0;
|
||||
if (index < first) first = index;
|
||||
if (index >= first + M.rows) first = index - M.rows + 1;
|
||||
first = Math.max(0, Math.min(first, n - M.rows));
|
||||
}
|
||||
|
||||
function refresh() {
|
||||
items = resolve(def.items) ?? [];
|
||||
clampWindow();
|
||||
}
|
||||
|
||||
function setIndex(i) {
|
||||
index = i;
|
||||
clampWindow();
|
||||
hiSince = ctx.now();
|
||||
}
|
||||
|
||||
/** Rows to move for this scroll step: 1, or 2/4 when spinning fast through a long list. */
|
||||
function accel() {
|
||||
if (items.length <= ACCEL_MIN_ITEMS) return 1;
|
||||
const now = ctx.now();
|
||||
stepTimes[stepHead] = now;
|
||||
stepHead = (stepHead + 1) % stepTimes.length;
|
||||
let recent = 0;
|
||||
for (let i = 0; i < stepTimes.length; i++) if (stepTimes[i] > 0 && now - stepTimes[i] <= ACCEL_WINDOW_MS) recent++;
|
||||
return recent >= 10 ? 4 : recent >= 6 ? 2 : 1;
|
||||
}
|
||||
|
||||
/** @type {Screen & { menuItems(): MenuItem[], setIndex(i: number): void, refresh(): void }} */
|
||||
const screen = {
|
||||
id: def.id,
|
||||
title: def.title,
|
||||
isMenu: true,
|
||||
|
||||
enter() {
|
||||
refresh();
|
||||
if (def.initialIndex) setIndex(def.initialIndex());
|
||||
hiSince = ctx.now();
|
||||
},
|
||||
resume() {
|
||||
refresh();
|
||||
hiSince = ctx.now();
|
||||
},
|
||||
refresh,
|
||||
setIndex,
|
||||
menuItems: () => items,
|
||||
|
||||
handle(e) {
|
||||
if (e.type === 'scroll') {
|
||||
if (!items.length) return false;
|
||||
const next = Math.max(0, Math.min(items.length - 1, index + e.delta * accel()));
|
||||
if (next === index) return false;
|
||||
setIndex(next);
|
||||
return true;
|
||||
}
|
||||
if (e.type === 'press' && e.part === 'select') {
|
||||
const it = items[index];
|
||||
if (!it) return false;
|
||||
if (it.open) {
|
||||
const s = it.open(ctx);
|
||||
if (s) ctx.push(s);
|
||||
return true;
|
||||
}
|
||||
if (it.action) {
|
||||
const changed = it.action(ctx) === true;
|
||||
refresh();
|
||||
return changed;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
|
||||
draw(g, r, now) {
|
||||
g.fillStyle = C.bg;
|
||||
g.fillRect(r.x, r.y, r.w, r.h);
|
||||
const n = items.length;
|
||||
const scroll = n > M.rows;
|
||||
const w = scroll ? M.W - M.scrollbarW : M.W;
|
||||
const end = Math.min(n, first + M.rows);
|
||||
hiOverflow = false;
|
||||
for (let i = first; i < end; i++) {
|
||||
const it = items[i];
|
||||
row.label = String(resolve(it.label) ?? '');
|
||||
row.detail = it.detail != null ? String(resolve(it.detail)) : '';
|
||||
row.chevron = it.chevron ?? !!it.open;
|
||||
row.check = it.check ? !!it.check() : false;
|
||||
row.speaker = it.speaker ? !!it.speaker() : false;
|
||||
row.highlighted = i === index;
|
||||
row.marqueeX = 0;
|
||||
if (row.highlighted) {
|
||||
hiTextW = ui.measure(g, row.label, FONTS.row);
|
||||
hiBoxW = ui.rowLabelWidth(g, w, row);
|
||||
hiOverflow = hiTextW > hiBoxW;
|
||||
if (hiOverflow) row.marqueeX = ui.marquee(now, hiSince, hiTextW, hiBoxW);
|
||||
lastMarqueeX = row.marqueeX;
|
||||
}
|
||||
ui.drawRow(g, r.y + (i - first) * M.rowH, w, row);
|
||||
}
|
||||
if (scroll) ui.drawScrollbar(g, M.W - M.scrollbarW, r.y, M.rows * M.rowH, n, M.rows, first);
|
||||
},
|
||||
|
||||
// Redraw only while the marquee is moving, plus one frame to settle it back at the start.
|
||||
animating(now) {
|
||||
if (!hiOverflow) return false;
|
||||
return ui.marquee(now, hiSince, hiTextW, hiBoxW) !== 0 || lastMarqueeX !== 0;
|
||||
},
|
||||
|
||||
highlight() {
|
||||
const it = items[index];
|
||||
return it ? { index, label: String(resolve(it.label) ?? '') } : null;
|
||||
},
|
||||
};
|
||||
return screen;
|
||||
}
|
||||
@@ -0,0 +1,631 @@
|
||||
// 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.
|
||||
|
||||
import { SCREEN_W } from '../config.js';
|
||||
import { settings as defaultSettings } from '../settings.js';
|
||||
import { drawPeachSilhouette } from '../brand/peach.js';
|
||||
import * as theme from './theme.js';
|
||||
import * as ui from './ui.js';
|
||||
import { createMenu } from './menu.js';
|
||||
import { createMainMenu } from './screens/main-menu.js';
|
||||
import { createNowPlaying } from './screens/now-playing.js';
|
||||
|
||||
const { M, C, ANIM } = theme;
|
||||
|
||||
/**
|
||||
* @typedef {import('../input/clickwheel.js').WheelEvent} WheelEvent
|
||||
* @typedef {{ x: number, y: number, w: number, h: number }} Rect
|
||||
* @typedef {{
|
||||
* id: string, title: string | (() => string), fullscreen?: boolean, keepAwake?: boolean,
|
||||
* handle?(e: WheelEvent): boolean | undefined, draw(g: CanvasRenderingContext2D, r: Rect, now: number): void,
|
||||
* animating?(now: number): boolean, refreshMs?: number,
|
||||
* enter?(): void, exit?(): void, resume?(): void, refresh?(): void,
|
||||
* highlight?(): { index: number, label: string } | null,
|
||||
* }} Screen
|
||||
* @typedef {{
|
||||
* os: AssPodOS, player: any, library: any, art: any, apps: any, settings: any,
|
||||
* theme: typeof theme, ui: typeof ui,
|
||||
* push(s: Screen): void, pop(): void, popToRoot(): void, replace(s: Screen): void,
|
||||
* menu(def: import('./menu.js').MenuDef): Screen, nowPlaying(): void, invalidate(): void,
|
||||
* now(): number, top(): Screen,
|
||||
* }} ScreenContext
|
||||
* @typedef {{
|
||||
* handleWheelEvent(e: WheelEvent): boolean, render(now: number): boolean,
|
||||
* readonly backlight: number, readonly asleep: boolean, invalidate(): void,
|
||||
* getState(): { stack: string[], title: string, highlight: { index: number, label: string } | null,
|
||||
* asleep: boolean, backlight: number, transitioning: boolean, booting: boolean },
|
||||
* debug: { goto(path: string[]): void },
|
||||
* toggleBacklight(): void, sleep(): void, wake(): void, setHold(on: boolean): void, readonly hold: boolean,
|
||||
* boot(ms?: number): void, setLoading(on: boolean): void,
|
||||
* readonly ctx: ScreenContext,
|
||||
* }} AssPodOS
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------- 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 });
|
||||
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;
|
||||
const DIRTY_NONE = 0;
|
||||
const DIRTY_SOFT = 1; // player/settings change: redraw at the next frame the 40 fps cap allows
|
||||
const DIRTY_NOW = 2; // input: redraw on this very frame
|
||||
const BOOT_LOGO_H = 112; // design-box height of the boot logo: the art is ~45% of the screen, as on the 5G
|
||||
|
||||
const easeOutCubic = (t) => 1 - (1 - t) ** 3;
|
||||
|
||||
function titleOf(s) {
|
||||
return typeof s.title === 'function' ? s.title() : s.title;
|
||||
}
|
||||
|
||||
function normKey(s) {
|
||||
return String(s).toLowerCase().replace(/[^a-z0-9]/g, '');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- OS
|
||||
|
||||
/**
|
||||
* `boot` shows the boot logo at startup and on every wake from sleep. It is opt-in, so harnesses
|
||||
* that drive the OS directly start on the menu.
|
||||
* @param {{ canvas: HTMLCanvasElement, player: any, library: any, art: any, apps: any, settings?: any, boot?: boolean }} opts
|
||||
* @returns {AssPodOS}
|
||||
*/
|
||||
export function createOS({ canvas, player, library, art, apps, settings = defaultSettings, boot: bootEnabled = false }) {
|
||||
const g = canvas.getContext('2d');
|
||||
const scale = canvas.width / SCREEN_W;
|
||||
|
||||
/** @type {Screen[]} */
|
||||
const stack = [];
|
||||
/** @type {{ from: Screen, to: Screen, dir: 1 | -1, start: number } | null} */
|
||||
let transition = null;
|
||||
let dirty = DIRTY_NOW;
|
||||
let lastDraw = -Infinity;
|
||||
let lastNow = 0;
|
||||
let haveNow = false;
|
||||
|
||||
// Backlight: `lit` is the logical state, `level` the faded multiplier main.js applies to the screen.
|
||||
let lit = settings.get('backlightTimer') !== 0;
|
||||
let level = 1;
|
||||
let lastActivity = 0;
|
||||
let litAtPress = true; // backlight state before the latest press, so MENU-hold toggles what the user saw
|
||||
let asleep = false;
|
||||
let swallowPart = null; // the rest of a press gesture that woke the assPod
|
||||
let swallowTouch = false;
|
||||
let hold = false; // hold switch (P2): wheel input ignored, lock shown in the status bar
|
||||
/** @type {{ ms: number, end: number, painted: boolean } | null} */
|
||||
let boot = null; // boot logo showing; `end` is NaN until the first render times it
|
||||
|
||||
const status = { title: '', play: 'stopped', locked: false };
|
||||
|
||||
// ---------------------------------------------------------------- stack
|
||||
|
||||
function top() {
|
||||
return stack[stack.length - 1];
|
||||
}
|
||||
|
||||
// Input arrives between frames, so a slide starts at the previous frame's time and the next
|
||||
// render already shows it moving. Before the first render there is no clock yet: NaN makes
|
||||
// render() start it then.
|
||||
function slideStart() {
|
||||
return haveNow ? lastNow : NaN;
|
||||
}
|
||||
|
||||
function push(s, instant = false) {
|
||||
const from = top();
|
||||
stack.push(s);
|
||||
s.enter?.();
|
||||
transition = from && !instant ? { from, to: s, dir: 1, start: slideStart() } : null;
|
||||
dirty = DIRTY_NOW;
|
||||
}
|
||||
|
||||
function pop(instant = false) {
|
||||
if (stack.length <= 1) return;
|
||||
const from = stack.pop();
|
||||
from.exit?.();
|
||||
const to = top();
|
||||
to.resume?.();
|
||||
transition = instant ? null : { from, to, dir: -1, start: slideStart() };
|
||||
dirty = DIRTY_NOW;
|
||||
}
|
||||
|
||||
function popToRoot(instant = false) {
|
||||
if (stack.length <= 1) return;
|
||||
const from = top();
|
||||
while (stack.length > 1) stack.pop().exit?.();
|
||||
const to = top();
|
||||
to.resume?.();
|
||||
transition = instant ? null : { from, to, dir: -1, start: slideStart() };
|
||||
dirty = DIRTY_NOW;
|
||||
}
|
||||
|
||||
/** Swaps the top screen in place, with no slide (e.g. a photo going full screen into a slideshow). */
|
||||
function replace(s) {
|
||||
const old = stack.pop();
|
||||
old?.exit?.();
|
||||
stack.push(s);
|
||||
s.enter?.();
|
||||
if (transition && transition.to === old) transition.to = s;
|
||||
dirty = DIRTY_NOW;
|
||||
}
|
||||
|
||||
function nowPlaying() {
|
||||
const cur = player.current;
|
||||
if (!cur) return;
|
||||
const t = top();
|
||||
if (t && (t.id === 'nowplaying' || t.id === 'videoplayer')) return;
|
||||
if (cur.kind === 'video') push(apps.videoPlayer(ctx, cur, { start: false }));
|
||||
else push(createNowPlaying(ctx));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- backlight and sleep
|
||||
|
||||
function timerSetting() {
|
||||
return settings.get('backlightTimer');
|
||||
}
|
||||
|
||||
/** Full-on level: the Brightness setting maps 0..1 onto 0.6..1 so even the minimum stays readable. */
|
||||
function litLevel() {
|
||||
const b = Number(settings.get('brightness'));
|
||||
return 0.6 + 0.4 * Math.min(1, Math.max(0, Number.isFinite(b) ? b : 0.75));
|
||||
}
|
||||
|
||||
function targetLevel() {
|
||||
if (boot) return litLevel(); // the logo always shows lit, whatever the timer says
|
||||
if (asleep) return 0;
|
||||
return lit ? litLevel() : ANIM.backlightDim;
|
||||
}
|
||||
|
||||
/** User activity: lights the backlight (unless the timer is Off) and restarts the timer. */
|
||||
function activity() {
|
||||
lastActivity = lastNow;
|
||||
if (timerSetting() !== 0) lit = true;
|
||||
}
|
||||
|
||||
function setLit(on) {
|
||||
lit = on;
|
||||
litAtPress = on;
|
||||
lastActivity = lastNow;
|
||||
}
|
||||
|
||||
/**
|
||||
* Flips the backlight relative to what the user saw before the press that asked for it
|
||||
* (that press has already lit the screen as activity), so a dark screen turns on, not off.
|
||||
*/
|
||||
function toggleBacklight() {
|
||||
setLit(!litAtPress);
|
||||
dirty = DIRTY_NOW;
|
||||
}
|
||||
|
||||
function sleep() {
|
||||
if (asleep) return;
|
||||
if (player.state === 'playing') player.pause();
|
||||
if (player.seeking) player.stopSeek();
|
||||
asleep = true;
|
||||
boot = null;
|
||||
lit = false;
|
||||
transition = null;
|
||||
dirty = DIRTY_NOW;
|
||||
}
|
||||
|
||||
function wake() {
|
||||
if (!asleep) return;
|
||||
asleep = false;
|
||||
lit = timerSetting() !== 0;
|
||||
lastActivity = lastNow;
|
||||
dirty = DIRTY_NOW;
|
||||
// Holding Play is the only "power off" here, so waking is a power-on: show the logo, briefly.
|
||||
// (The real 5G resumed instantly from light sleep.) The screen stack is kept underneath.
|
||||
if (bootEnabled) startBoot(ANIM.wakeBootMs);
|
||||
}
|
||||
|
||||
function updateBacklight(now, dtMs) {
|
||||
const timer = timerSetting();
|
||||
if (!asleep && !boot && lit && timer > 0 && !top()?.keepAwake && now - lastActivity > timer * 1000) lit = false;
|
||||
const target = targetLevel();
|
||||
// The first render snaps to the target, except during a boot, which fades up from black.
|
||||
if (dtMs <= 0) { if (!boot) level = target; return; }
|
||||
const step = dtMs / ANIM.backlightFadeMs;
|
||||
level = level < target ? Math.min(target, level + step) : Math.max(target, level - step);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- boot
|
||||
|
||||
/**
|
||||
* Shows the boot logo for `ms`, timed from the next render. The backlight restarts from 0, so the
|
||||
* usual 300 ms fade is the power-on fade (a material change, not a texture upload).
|
||||
*/
|
||||
function startBoot(ms) {
|
||||
// Releases are swallowed while booting, so nothing could end a running seek later.
|
||||
if (player.seeking) player.stopSeek();
|
||||
boot = { ms, end: NaN, painted: false };
|
||||
asleep = false;
|
||||
transition = null;
|
||||
level = 0;
|
||||
dirty = DIRTY_NOW;
|
||||
}
|
||||
|
||||
/** Idempotent: hands over to the screen stack with the backlight timer restarted. */
|
||||
function endBoot() {
|
||||
if (!boot) return;
|
||||
boot = null;
|
||||
lit = timerSetting() !== 0;
|
||||
lastActivity = lastNow;
|
||||
dirty = DIRTY_NOW;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- input
|
||||
|
||||
/** OS default behavior for events a screen leaves to the OS (its handle returned undefined). */
|
||||
function defaultHandle(e) {
|
||||
const hasItem = !!player.current;
|
||||
switch (e.type) {
|
||||
case 'press':
|
||||
if (e.part === 'menu') {
|
||||
if (stack.length > 1) { pop(); return true; }
|
||||
return false;
|
||||
}
|
||||
if (e.part === 'play') {
|
||||
if (!hasItem) return false;
|
||||
player.togglePlay();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
case 'hold':
|
||||
if (e.part === 'menu') { toggleBacklight(); return true; }
|
||||
if ((e.part === 'next' || e.part === 'prev') && hasItem) {
|
||||
player.startSeek(e.part === 'next' ? 1 : -1);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
case 'longhold':
|
||||
if (e.part === 'play') { sleep(); return true; }
|
||||
return false;
|
||||
case 'release':
|
||||
if ((e.part === 'next' || e.part === 'prev') && hasItem) {
|
||||
if (e.held) player.stopSeek();
|
||||
else if (e.part === 'next') player.next();
|
||||
else player.prev();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** @param {WheelEvent} e @returns {boolean} true when the UI visibly reacted */
|
||||
function handleWheelEvent(e) {
|
||||
if (boot) {
|
||||
// Booting: the wheel does nothing. A press or touch that starts now is swallowed to its end,
|
||||
// even after the boot, so it can't act on the menu that appears under the thumb.
|
||||
if (e.type === 'press') swallowPart = e.part;
|
||||
else if (e.type === 'release') { if (e.part === swallowPart) swallowPart = null; }
|
||||
else if (e.type === 'touch') swallowTouch = e.active;
|
||||
return false;
|
||||
}
|
||||
if (hold) {
|
||||
// Like the 5G with Hold on: a press only lights the screen so the lock icon can be seen.
|
||||
if (e.type === 'press' && !asleep) activity();
|
||||
return false;
|
||||
}
|
||||
if (asleep) {
|
||||
// Asleep: the first press, touch or scroll only wakes the assPod, and the rest of that
|
||||
// gesture (hold, release, scrolls of the same touch) is swallowed too.
|
||||
if (e.type === 'release') { if (e.part === swallowPart) swallowPart = null; return false; }
|
||||
if (e.type === 'touch' && !e.active) { swallowTouch = false; return false; }
|
||||
if (e.type === 'hold' || e.type === 'longhold') return false;
|
||||
wake();
|
||||
if (e.type === 'press') swallowPart = e.part;
|
||||
if (e.type === 'touch') swallowTouch = true;
|
||||
return true;
|
||||
}
|
||||
if (swallowPart && e.part === swallowPart && (e.type === 'hold' || e.type === 'longhold' || e.type === 'release')) {
|
||||
if (e.type === 'release') swallowPart = null;
|
||||
return false;
|
||||
}
|
||||
if (swallowTouch) {
|
||||
if (e.type === 'scroll') return false;
|
||||
if (e.type === 'touch' && !e.active) { swallowTouch = false; return false; }
|
||||
}
|
||||
|
||||
// A cancelled release (the assPod was let go mid-press) only ends what the press started, a
|
||||
// running seek; it never fires the click (Next, Previous) the user didn't finish.
|
||||
if (e.type === 'release' && e.cancelled) {
|
||||
if (e.held && player.seeking) { player.stopSeek(); dirty = DIRTY_NOW; return true; }
|
||||
return false;
|
||||
}
|
||||
|
||||
if (e.type === 'press') litAtPress = lit;
|
||||
// Releases and touch-ends don't count as activity: releasing MENU after a hold that turned
|
||||
// the light off must not turn it straight back on.
|
||||
if (e.type !== 'release' && !(e.type === 'touch' && !e.active)) activity();
|
||||
|
||||
const s = top();
|
||||
let r = s.handle ? s.handle(e) : undefined;
|
||||
if (r === undefined) r = defaultHandle(e);
|
||||
if (r) dirty = DIRTY_NOW;
|
||||
return !!r;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- drawing
|
||||
|
||||
function drawStatus(s) {
|
||||
status.title = titleOf(s) ?? '';
|
||||
const st = player.state;
|
||||
status.play = st === 'playing' || st === 'paused' ? st : 'stopped';
|
||||
status.locked = hold;
|
||||
ui.drawStatusBar(g, status);
|
||||
}
|
||||
|
||||
const reported = new WeakSet();
|
||||
function drawScreen(s, r, now) {
|
||||
try {
|
||||
s.draw(g, r, now);
|
||||
} catch (err) {
|
||||
// One broken screen must not take the whole OS down every frame; report it once.
|
||||
if (!reported.has(s)) { reported.add(s); console.error('[asspod-os] draw failed in screen', s.id, err); }
|
||||
}
|
||||
}
|
||||
|
||||
function drawContent(s, dx, now) {
|
||||
if (dx <= -M.W || dx >= M.W) return;
|
||||
g.save();
|
||||
g.beginPath();
|
||||
g.rect(CONTENT.x, CONTENT.y, CONTENT.w, CONTENT.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.restore();
|
||||
}
|
||||
|
||||
/** A whole screen (status bar included) offset by dx; used when a fullscreen screen slides. */
|
||||
function drawWhole(s, dx, now) {
|
||||
if (dx <= -M.W || dx >= M.W) return;
|
||||
if (s.fullscreen) {
|
||||
g.save();
|
||||
g.beginPath();
|
||||
g.rect(0, 0, M.W, M.H);
|
||||
g.clip();
|
||||
g.translate(dx, 0);
|
||||
// a screen whose picture lies under the page (podd's video panel) is clear, unless it slides
|
||||
if (s.transparent && dx === 0 && !transition) g.clearRect(0, 0, M.W, M.H);
|
||||
else {
|
||||
g.fillStyle = '#000000';
|
||||
g.fillRect(0, 0, M.W, M.H);
|
||||
}
|
||||
drawScreen(s, FULL, now);
|
||||
g.restore();
|
||||
return;
|
||||
}
|
||||
g.save();
|
||||
g.translate(dx, 0);
|
||||
drawStatus(s);
|
||||
g.restore();
|
||||
drawContent(s, dx, now);
|
||||
}
|
||||
|
||||
function paint(now) {
|
||||
g.setTransform(scale, 0, 0, scale, 0, 0);
|
||||
g.globalAlpha = 1;
|
||||
g.globalCompositeOperation = 'source-over';
|
||||
if (asleep) {
|
||||
g.fillStyle = '#000000';
|
||||
g.fillRect(0, 0, M.W, M.H);
|
||||
return;
|
||||
}
|
||||
if (boot) {
|
||||
// Pure vector shapes, no text: the logo never waits for the web font.
|
||||
g.fillStyle = C.bootBg;
|
||||
g.fillRect(0, 0, M.W, M.H);
|
||||
drawPeachSilhouette(g, M.W / 2, M.H / 2, BOOT_LOGO_H, C.bootLogo);
|
||||
return;
|
||||
}
|
||||
if (transition) {
|
||||
const tr = transition;
|
||||
const t = Math.min(1, Math.max(0, (now - tr.start) / ANIM.slideMs));
|
||||
const k = 1 - easeOutCubic(t);
|
||||
// Push: old slides out left, new comes in from the right. Pop mirrors it.
|
||||
const dxTo = tr.dir > 0 ? M.W * k : -M.W * k;
|
||||
const dxFrom = tr.dir > 0 ? dxTo - M.W : dxTo + M.W;
|
||||
if (tr.from.fullscreen || tr.to.fullscreen) {
|
||||
g.fillStyle = '#000000';
|
||||
g.fillRect(0, 0, M.W, M.H);
|
||||
drawWhole(tr.from, dxFrom, now);
|
||||
drawWhole(tr.to, dxTo, now);
|
||||
} else {
|
||||
drawStatus(tr.to); // the title swaps immediately; only the content slides
|
||||
drawContent(tr.from, dxFrom, now);
|
||||
drawContent(tr.to, dxTo, now);
|
||||
}
|
||||
if (t >= 1) transition = null;
|
||||
} else {
|
||||
drawWhole(top(), 0, now);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Redraws when needed and returns whether it drew (main.js then re-uploads the texture).
|
||||
* Input redraws immediately; animation, marquees, periodic refresh and player/settings
|
||||
* changes are capped at ANIM.maxFps.
|
||||
*/
|
||||
function render(now) {
|
||||
const dtMs = haveNow ? now - lastNow : 0;
|
||||
lastNow = now;
|
||||
haveNow = true;
|
||||
// The power-on fade runs from the logo's first frame: a boot started between frames (wake, the
|
||||
// XR session start) must not count the gap before it, or the fade is skipped.
|
||||
updateBacklight(now, boot && !(boot.end >= 0) ? 0 : dtMs);
|
||||
if (boot) {
|
||||
// Timed from the first render after it started (NaN until then), like a slide, so a boot
|
||||
// started between frames lasts its full length and a virtual clock works.
|
||||
if (!(boot.end >= 0)) boot.end = now + boot.ms;
|
||||
else if (now >= boot.end) endBoot();
|
||||
}
|
||||
if (transition && !(transition.start <= now)) transition.start = now;
|
||||
|
||||
let draw = dirty === DIRTY_NOW;
|
||||
if (boot) {
|
||||
// The logo is static: draw it once and hold it. Soft dirties, animation, refresh and even
|
||||
// invalidate() wait for the menu, so a whole boot costs two uploads (the logo, then the menu).
|
||||
draw = !boot.painted;
|
||||
} else if (!draw) {
|
||||
if (now - lastDraw < FRAME_MS - FRAME_SLACK_MS) return false;
|
||||
if (asleep) {
|
||||
draw = dirty !== DIRTY_NONE;
|
||||
} else {
|
||||
const s = top();
|
||||
draw = dirty !== DIRTY_NONE || !!transition
|
||||
|| (s.animating ? !!s.animating(now) : false)
|
||||
|| (s.refreshMs > 0 && now - lastDraw >= s.refreshMs - FRAME_SLACK_MS);
|
||||
}
|
||||
}
|
||||
if (!draw) return false;
|
||||
paint(now);
|
||||
if (boot) boot.painted = true;
|
||||
dirty = DIRTY_NONE;
|
||||
lastDraw = now;
|
||||
return true;
|
||||
}
|
||||
|
||||
function invalidate() {
|
||||
ui.clearCache();
|
||||
// Screens that keep their own measured text (Cover Flow captions) re-measure on refresh; the
|
||||
// ones below re-measure on resume anyway.
|
||||
top()?.refresh?.();
|
||||
dirty = DIRTY_NOW;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- media and settings events
|
||||
|
||||
const soft = () => { if (dirty < DIRTY_SOFT) dirty = DIRTY_SOFT; };
|
||||
// Lists built from player state (the main menu's Now Playing row, speaker icons) refresh on changes.
|
||||
const playerChanged = () => { top()?.refresh?.(); soft(); };
|
||||
if (typeof player.on === 'function') {
|
||||
for (const type of ['state', 'track', 'queue']) player.on(type, playerChanged);
|
||||
for (const type of ['volume', 'rating']) player.on(type, soft);
|
||||
// FF/REW emits a seek every frame. Only the screens that draw the playhead redraw for it; a
|
||||
// static menu would otherwise re-upload an unchanged texture 40 times a second.
|
||||
player.on('seek', () => {
|
||||
const id = top()?.id;
|
||||
if (id === 'nowplaying' || id === 'videoplayer') soft();
|
||||
});
|
||||
}
|
||||
if (typeof settings.on === 'function') {
|
||||
settings.on('change', ({ key }) => {
|
||||
if (key === 'backlightTimer' || key === '*') {
|
||||
const t = timerSetting();
|
||||
lit = t !== 0;
|
||||
lastActivity = lastNow;
|
||||
}
|
||||
top()?.refresh?.();
|
||||
soft();
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- debug
|
||||
|
||||
function getState() {
|
||||
const s = top();
|
||||
return {
|
||||
stack: stack.map((x) => x.id),
|
||||
title: titleOf(s) ?? '',
|
||||
highlight: s.highlight ? s.highlight() : null,
|
||||
asleep,
|
||||
backlight: level,
|
||||
transitioning: !!transition,
|
||||
booting: !!boot,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Jumps straight to a screen, e.g. ['music', 'albums'] or ['settings', 'eq'], with no slides.
|
||||
* Each segment matches a menu item's id, its label (case and punctuation ignored), or the id
|
||||
* of the screen the item opens; 'nowplaying' also works from anywhere when something is current.
|
||||
*/
|
||||
function goto(path) {
|
||||
if (asleep) wake();
|
||||
endBoot(); // tests and tools want the screen they asked for, not the logo
|
||||
popToRoot(true);
|
||||
for (const seg of path) {
|
||||
const key = normKey(seg);
|
||||
const s = top();
|
||||
if (key === 'nowplaying' && player.current && !(s.menuItems && s.menuItems().some((it) => it.id === 'nowplaying'))) {
|
||||
nowPlaying();
|
||||
transition = null;
|
||||
continue;
|
||||
}
|
||||
if (!s.menuItems) throw new Error(`goto: screen "${s.id}" is not a menu (at "${seg}")`);
|
||||
const items = s.menuItems();
|
||||
let idx = items.findIndex((it) => (it.id != null && normKey(it.id) === key)
|
||||
|| normKey(typeof it.label === 'function' ? it.label() : it.label) === key);
|
||||
let opened = null;
|
||||
if (idx < 0) {
|
||||
for (let i = 0; i < items.length && !opened; i++) {
|
||||
if (!items[i].open) continue;
|
||||
const candidate = items[i].open(ctx);
|
||||
if (candidate && normKey(candidate.id) === key) { idx = i; opened = candidate; }
|
||||
}
|
||||
}
|
||||
if (idx < 0) throw new Error(`goto: no item "${seg}" in "${s.id}"`);
|
||||
s.setIndex?.(idx);
|
||||
const it = items[idx];
|
||||
if (opened) push(opened, true);
|
||||
else if (it.open) { const next = it.open(ctx); if (next) push(next, true); }
|
||||
else if (it.action) it.action(ctx);
|
||||
transition = null;
|
||||
}
|
||||
dirty = DIRTY_NOW;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- assembly
|
||||
|
||||
/** @type {AssPodOS} */
|
||||
const os = {
|
||||
handleWheelEvent,
|
||||
render,
|
||||
get backlight() { return level; },
|
||||
get asleep() { return asleep; },
|
||||
invalidate,
|
||||
getState,
|
||||
debug: { goto },
|
||||
toggleBacklight,
|
||||
sleep,
|
||||
wake,
|
||||
/** Hold switch (P2): while on, wheel events are ignored and the status bar shows a lock. */
|
||||
setHold(on) { hold = !!on; dirty = DIRTY_NOW; },
|
||||
get hold() { return hold; },
|
||||
/** Shows the boot logo for `ms`; it also wakes a sleeping assPod. */
|
||||
boot(ms = ANIM.bootMs) { startBoot(Number.isFinite(ms) ? Math.max(0, ms) : ANIM.bootMs); }, // a NaN end would never time out
|
||||
/**
|
||||
* The boot logo while the player daemon (VLC) is starting: shown until setLoading(false), and
|
||||
* only then. The wheel is ignored meanwhile, as during any boot.
|
||||
*/
|
||||
setLoading(on) {
|
||||
if (on) { if (!boot) startBoot(Infinity); else boot.ms = Infinity; }
|
||||
else if (boot) endBoot();
|
||||
},
|
||||
get ctx() { return ctx; },
|
||||
};
|
||||
|
||||
/** @type {ScreenContext} */
|
||||
const ctx = {
|
||||
os, player, library, art, apps, settings, theme, ui,
|
||||
push: (s) => push(s),
|
||||
pop: () => pop(),
|
||||
popToRoot: () => popToRoot(),
|
||||
replace,
|
||||
menu: (def) => createMenu(ctx, def),
|
||||
nowPlaying,
|
||||
invalidate() { dirty = DIRTY_NOW; },
|
||||
now: () => lastNow,
|
||||
top,
|
||||
};
|
||||
|
||||
push(createMainMenu(ctx), true);
|
||||
if (bootEnabled) startBoot(ANIM.bootMs);
|
||||
return os;
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
// Main menu and the Music subtree, following the 5G menu tree (design spec 6.6), with Cover Flow
|
||||
// added as the first Music item the way the 6G classic had it.
|
||||
|
||||
import { createRng, hashString } from '../../util/rng.js';
|
||||
import { createSettings } from './settings.js';
|
||||
import { createSearch } from './search.js';
|
||||
|
||||
// ---------------------------------------------------------------- sorting
|
||||
|
||||
// The assPod files "The Beatles" under B, so leading articles are ignored when sorting.
|
||||
function sortKey(s) {
|
||||
return String(s ?? '').replace(/^(the|a|an)\s+/i, '').toLowerCase();
|
||||
}
|
||||
|
||||
function byKey(get) {
|
||||
return (a, b) => {
|
||||
const ka = sortKey(get(a));
|
||||
const kb = sortKey(get(b));
|
||||
return ka < kb ? -1 : ka > kb ? 1 : 0;
|
||||
};
|
||||
}
|
||||
|
||||
/** Album order, then track number: how "All" lists songs for an artist or genre. */
|
||||
function albumOrder(library) {
|
||||
const pos = new Map(library.albums.map((a, i) => [a.id, i]));
|
||||
return (a, b) => (pos.get(a.albumId) ?? 0) - (pos.get(b.albumId) ?? 0) || (a.trackNo ?? 0) - (b.trackNo ?? 0);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- song lists
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
const items = list.map((t, i) => ({
|
||||
label: t.title,
|
||||
speaker: () => ctx.player.current?.id === t.id && ctx.player.state !== 'stopped',
|
||||
action: (c) => {
|
||||
c.player.playQueue(list, i);
|
||||
c.nowPlaying();
|
||||
return true;
|
||||
},
|
||||
}));
|
||||
return ctx.menu({ id, title, items });
|
||||
}
|
||||
|
||||
export function albumSongs(ctx, album) {
|
||||
const tracks = album.tracks.slice().sort((a, b) => (a.trackNo ?? 0) - (b.trackNo ?? 0));
|
||||
return songList(ctx, 'album', album.title, tracks);
|
||||
}
|
||||
|
||||
/** Artist screen: "All" plus the artist's albums (optionally only those in one genre). */
|
||||
function artistMenu(ctx, artist, genre) {
|
||||
const lib = ctx.library;
|
||||
let albums = lib.albumsBy(artist);
|
||||
if (genre) albums = albums.filter((a) => a.genre === genre);
|
||||
albums = albums.slice().sort(byKey((a) => a.title));
|
||||
const filter = genre ? { artist, genre } : { artist };
|
||||
return ctx.menu({
|
||||
id: 'artist',
|
||||
title: artist,
|
||||
items: [
|
||||
{ id: 'all', label: 'All', open: (c) => songList(c, 'songs', artist, lib.tracksBy(filter).slice().sort(albumOrder(lib))) },
|
||||
...albums.map((a) => ({ label: a.title, open: (c) => albumSongs(c, a) })),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
function artistsMenu(ctx) {
|
||||
return ctx.menu({
|
||||
id: 'artists',
|
||||
title: 'Artists',
|
||||
items: ctx.library.artists.map((name) => ({ label: name, open: (c) => artistMenu(c, name) })),
|
||||
});
|
||||
}
|
||||
|
||||
function albumsMenu(ctx) {
|
||||
const albums = ctx.library.albums.slice().sort(byKey((a) => a.title));
|
||||
return ctx.menu({
|
||||
id: 'albums',
|
||||
title: 'Albums',
|
||||
items: albums.map((a) => ({ label: a.title, open: (c) => albumSongs(c, a) })),
|
||||
});
|
||||
}
|
||||
|
||||
function songsMenu(ctx) {
|
||||
return songList(ctx, 'songs', 'Songs', ctx.library.tracks.slice().sort(byKey((t) => t.title)));
|
||||
}
|
||||
|
||||
function playlistsMenu(ctx) {
|
||||
return ctx.menu({
|
||||
id: 'playlists',
|
||||
title: 'Playlists',
|
||||
// items() is evaluated on open, so Recently Played and Top Rated are current.
|
||||
items: ctx.library.playlists.map((p) => ({ label: p.title, open: (c) => songList(c, 'playlist', p.title, p.items()) })),
|
||||
});
|
||||
}
|
||||
|
||||
/** Genres -> artists in that genre ("All" first) -> that artist's albums in the genre -> songs. */
|
||||
function genresMenu(ctx) {
|
||||
const lib = ctx.library;
|
||||
return ctx.menu({
|
||||
id: 'genres',
|
||||
title: 'Genres',
|
||||
items: lib.genres.map((genre) => ({
|
||||
label: genre,
|
||||
open: (c) => {
|
||||
const tracks = lib.tracksBy({ genre });
|
||||
const artists = [...new Set(tracks.map((t) => t.artist))].sort(byKey((a) => a));
|
||||
return c.menu({
|
||||
id: 'genre',
|
||||
title: genre,
|
||||
items: [
|
||||
{ id: 'all', label: 'All', open: (cc) => songList(cc, 'songs', genre, tracks.slice().sort(albumOrder(lib))) },
|
||||
...artists.map((a) => ({ label: a, open: (cc) => artistMenu(cc, a, genre) })),
|
||||
],
|
||||
});
|
||||
},
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
function composersMenu(ctx) {
|
||||
const lib = ctx.library;
|
||||
return ctx.menu({
|
||||
id: 'composers',
|
||||
title: 'Composers',
|
||||
items: lib.composers.map((name) => ({
|
||||
label: name,
|
||||
open: (c) => songList(c, 'songs', name, lib.tracksBy({ composer: name }).slice().sort(byKey((t) => t.title))),
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Music
|
||||
|
||||
export function createMusicMenu(ctx) {
|
||||
const lib = ctx.library;
|
||||
return ctx.menu({
|
||||
id: 'music',
|
||||
title: 'Music',
|
||||
items: [
|
||||
{ id: 'coverflow', label: 'Cover Flow', open: (c) => c.apps.coverFlow(c) },
|
||||
{ id: 'playlists', label: 'Playlists', open: playlistsMenu },
|
||||
{ id: 'artists', label: 'Artists', open: artistsMenu },
|
||||
{ id: 'albums', label: 'Albums', open: albumsMenu },
|
||||
{ id: 'songs', label: 'Songs', open: songsMenu },
|
||||
// The 5G shows Podcasts and Audiobooks even when there are none: an empty white list.
|
||||
{ id: 'podcasts', label: 'Podcasts', open: (c) => songList(c, 'podcasts', 'Podcasts', lib.podcasts ?? []) },
|
||||
{ id: 'genres', label: 'Genres', open: genresMenu },
|
||||
{ id: 'composers', label: 'Composers', open: composersMenu },
|
||||
{ id: 'audiobooks', label: 'Audiobooks', open: (c) => songList(c, 'audiobooks', 'Audiobooks', lib.audiobooks ?? []) },
|
||||
{ id: 'search', label: 'Search', open: (c) => createSearch(c) },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- main menu
|
||||
|
||||
let shuffleCount = 0;
|
||||
|
||||
function shuffleSongs(ctx) {
|
||||
const tracks = ctx.library.tracks;
|
||||
if (!tracks.length) return false;
|
||||
// A fresh order every time; seeded through rng.js so the shuffle is uniform and cheap.
|
||||
const seed = hashString(`${Date.now()}:${ctx.now()}:${++shuffleCount}`);
|
||||
ctx.player.playQueue(createRng(seed).shuffle(tracks), 0);
|
||||
ctx.nowPlaying();
|
||||
return true;
|
||||
}
|
||||
|
||||
export function createMainMenu(ctx) {
|
||||
const base = [
|
||||
{ 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: '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; } },
|
||||
];
|
||||
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),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
// Now Playing, laid out like the 5G (design spec 6.3): "n of N", album art on the left, title /
|
||||
// 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';
|
||||
|
||||
// ---------------------------------------------------------------- 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 };
|
||||
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) {
|
||||
g.save();
|
||||
const gr = g.createLinearGradient(0, y, 0, y + s);
|
||||
gr.addColorStop(0, '#e9e9e9');
|
||||
gr.addColorStop(1, '#bdbdbd');
|
||||
g.fillStyle = gr;
|
||||
g.fillRect(x, y, s, s);
|
||||
g.fillStyle = '#ffffff';
|
||||
g.strokeStyle = '#ffffff';
|
||||
const cx = x + s / 2;
|
||||
const cy = y + s / 2;
|
||||
g.beginPath();
|
||||
g.ellipse(cx - 14, cy + 18, 9, 6.5, -0.4, 0, Math.PI * 2);
|
||||
g.ellipse(cx + 18, cy + 12, 9, 6.5, -0.4, 0, Math.PI * 2);
|
||||
g.fill();
|
||||
g.lineWidth = 4;
|
||||
g.beginPath();
|
||||
g.moveTo(cx - 6, cy + 17);
|
||||
g.lineTo(cx - 6, cy - 22);
|
||||
g.lineTo(cx + 26, cy - 30);
|
||||
g.lineTo(cx + 26, cy + 11);
|
||||
g.stroke();
|
||||
g.lineWidth = 7;
|
||||
g.beginPath();
|
||||
g.moveTo(cx - 6, cy - 19);
|
||||
g.lineTo(cx + 26, cy - 27);
|
||||
g.stroke();
|
||||
g.restore();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- shuffle and repeat glyphs
|
||||
|
||||
/** Two crossing arrows, in a w x h box with its top-left at (x, y). */
|
||||
function drawShuffleGlyph(g, x, y, color) {
|
||||
g.strokeStyle = color;
|
||||
g.fillStyle = color;
|
||||
g.lineWidth = 1.4;
|
||||
g.lineJoin = 'round';
|
||||
g.beginPath();
|
||||
g.moveTo(x, y + 2); g.lineTo(x + 3, y + 2); g.lineTo(x + 7, y + 7); g.lineTo(x + 9.5, y + 7);
|
||||
g.moveTo(x, y + 7); g.lineTo(x + 3, y + 7); g.lineTo(x + 7, y + 2); g.lineTo(x + 9.5, y + 2);
|
||||
g.stroke();
|
||||
g.beginPath();
|
||||
g.moveTo(x + 9, y); g.lineTo(x + 12, y + 2); g.lineTo(x + 9, y + 4); g.closePath();
|
||||
g.moveTo(x + 9, y + 5); g.lineTo(x + 12, y + 7); g.lineTo(x + 9, y + 9); g.closePath();
|
||||
g.fill();
|
||||
}
|
||||
|
||||
/** A looping arrow; for repeat-one the loop narrows and a small "1" sits at its right. */
|
||||
function drawRepeatGlyph(g, x, y, color, one, oneFont) {
|
||||
const w = one ? 8 : 10.6; // loop width
|
||||
g.strokeStyle = color;
|
||||
g.fillStyle = color;
|
||||
g.lineWidth = 1.4;
|
||||
g.beginPath();
|
||||
g.roundRect(x + 0.7, y + 2, w, 5.5, 2.2);
|
||||
g.stroke();
|
||||
const top = x + 0.7 + w * 0.55; // arrowheads ride the loop: right on top, left underneath
|
||||
const bottom = x + 0.7 + w * 0.45;
|
||||
g.beginPath();
|
||||
g.moveTo(top - 1.5, y); g.lineTo(top + 1.5, y + 2); g.lineTo(top - 1.5, y + 4); g.closePath();
|
||||
g.moveTo(bottom + 1.5, y + 5.5); g.lineTo(bottom - 1.5, y + 7.5); g.lineTo(bottom + 1.5, y + 9.5); g.closePath();
|
||||
g.fill();
|
||||
if (one) {
|
||||
g.font = oneFont;
|
||||
g.fillText('1', x + 10.2, y + 8.5);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- art cache
|
||||
|
||||
// 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 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 (typeof document === 'undefined') return src;
|
||||
artCanvas ??= document.createElement('canvas');
|
||||
artCanvas.width = artCanvas.height = ART_PX;
|
||||
const g = artCanvas.getContext('2d');
|
||||
g.imageSmoothingEnabled = true;
|
||||
g.imageSmoothingQuality = 'high';
|
||||
g.drawImage(src, 0, 0, ART_PX, ART_PX);
|
||||
artKey = key;
|
||||
artSrc = src;
|
||||
return artCanvas;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- screen
|
||||
|
||||
export function createNowPlaying(ctx) {
|
||||
const { player, art, ui, theme, settings } = ctx;
|
||||
const { C, FONTS, ANIM } = theme;
|
||||
const oneFont = theme.font(8, 700); // the "1" in the repeat-one glyph
|
||||
|
||||
let mode = 'progress';
|
||||
let modeAt = 0; // last interaction in scrub/rating mode (reverts after npModeTimeoutMs)
|
||||
let volumeUntil = 0; // volume overlay visible until this time
|
||||
let lastScrubAt = -1e9;
|
||||
let scrubRun = 0; // consecutive fast scrub steps
|
||||
let titleSince = 0; // marquee clock for the title
|
||||
let titleId = null;
|
||||
let titleOverflow = false;
|
||||
let titleW = 0;
|
||||
let lastTitleX = 0;
|
||||
let offState = null;
|
||||
// "n of N" is rebuilt only when it changes.
|
||||
let posKey = -1;
|
||||
let posText = '';
|
||||
|
||||
function rating(item) {
|
||||
if (!item) return 0;
|
||||
const saved = settings.get('ratings')?.[item.id];
|
||||
return Number.isFinite(item.rating) ? item.rating : Number.isFinite(saved) ? saved : 0;
|
||||
}
|
||||
|
||||
function onState({ state } = {}) {
|
||||
// The 5G drops out of Now Playing when the queue runs out.
|
||||
if ((state ?? player.state) === 'stopped' && ctx.top() === screen) ctx.pop();
|
||||
}
|
||||
|
||||
function setMode(m, now) {
|
||||
mode = m;
|
||||
modeAt = now;
|
||||
volumeUntil = 0;
|
||||
}
|
||||
|
||||
let drawnAt = 0;
|
||||
|
||||
const screen = {
|
||||
id: 'nowplaying',
|
||||
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; },
|
||||
|
||||
enter() {
|
||||
offState = player.on?.('state', onState) ?? null;
|
||||
titleSince = ctx.now();
|
||||
titleId = player.current?.id ?? null;
|
||||
},
|
||||
exit() {
|
||||
offState?.();
|
||||
offState = null;
|
||||
},
|
||||
resume() {
|
||||
titleSince = ctx.now();
|
||||
if (player.state === 'stopped') onState({ state: 'stopped' });
|
||||
},
|
||||
|
||||
handle(e) {
|
||||
const now = ctx.now();
|
||||
const cur = player.current;
|
||||
if (e.type === 'press' && e.part === 'select') {
|
||||
if (!cur) return false;
|
||||
setMode(MODES[(MODES.indexOf(mode) + 1) % MODES.length], now);
|
||||
return true;
|
||||
}
|
||||
if (e.type !== 'scroll') return undefined;
|
||||
if (!cur) return false;
|
||||
|
||||
if (mode === 'scrub') {
|
||||
// Fast spinning seeks further per detent: 2 s, then 4 s, then 8 s.
|
||||
scrubRun = now - lastScrubAt < SCRUB_FAST_MS ? scrubRun + 1 : 0;
|
||||
lastScrubAt = now;
|
||||
const mult = scrubRun >= 8 ? 4 : scrubRun >= 3 ? 2 : 1;
|
||||
const dur = player.duration || cur.duration || 0;
|
||||
const pos = Math.max(0, Math.min(dur, player.position + e.delta * SCRUB_STEP_S * mult));
|
||||
modeAt = now;
|
||||
if (pos === player.position) return false;
|
||||
player.seek(pos);
|
||||
return true;
|
||||
}
|
||||
if (mode === 'rating') {
|
||||
modeAt = now;
|
||||
const r = rating(cur);
|
||||
const next = Math.max(0, Math.min(5, r + e.delta));
|
||||
if (next === r) return false;
|
||||
player.rate(cur, next);
|
||||
return true;
|
||||
}
|
||||
// Progress mode: the wheel is the volume knob, shown on an overlay.
|
||||
const v = player.volume;
|
||||
const next = Math.max(0, Math.min(1, Math.round(v * VOLUME_STEPS + e.delta) / VOLUME_STEPS));
|
||||
const wasShown = now < volumeUntil;
|
||||
volumeUntil = now + ANIM.volumeOverlayMs;
|
||||
if (next !== v) player.setVolume(next);
|
||||
return next !== v || !wasShown;
|
||||
},
|
||||
|
||||
draw(g, r, now) {
|
||||
g.fillStyle = C.bg;
|
||||
g.fillRect(r.x, r.y, r.w, r.h);
|
||||
const cur = player.current;
|
||||
if (!cur) return;
|
||||
drawnAt = now;
|
||||
if (mode !== 'progress' && now - modeAt > ANIM.npModeTimeoutMs) mode = 'progress';
|
||||
if (cur.id !== titleId) { titleId = cur.id; titleSince = now; }
|
||||
|
||||
g.save();
|
||||
g.textBaseline = 'alphabetic';
|
||||
|
||||
// "3 of 12"
|
||||
const key = player.index * 100000 + player.queue.length;
|
||||
if (key !== posKey) { posKey = key; posText = `${player.index + 1} of ${player.queue.length}`; }
|
||||
g.font = FONTS.small;
|
||||
g.fillStyle = C.text;
|
||||
g.fillText(posText, POS.x, POS.y);
|
||||
|
||||
// Shuffle and repeat marks at the right end of that line, as on the 5G.
|
||||
const repeat = settings.get('repeat');
|
||||
let gx = GLYPH.right - GLYPH.w;
|
||||
if (repeat === 'one' || repeat === 'all') {
|
||||
drawRepeatGlyph(g, gx, POS.y - GLYPH.h, C.text, repeat === 'one', oneFont);
|
||||
gx -= GLYPH.w + GLYPH.gap;
|
||||
}
|
||||
if (settings.get('shuffle') !== 'off') drawShuffleGlyph(g, gx, POS.y - GLYPH.h, C.text);
|
||||
|
||||
// Album art with a light grey hairline.
|
||||
const cover = cur.albumId ? art.cover(cur.albumId) : art.placeholder?.();
|
||||
if (cover) {
|
||||
g.drawImage(scaledCover(cover, cur.albumId ?? 'placeholder'), ART.x, ART.y, ART.s, ART.s);
|
||||
} else {
|
||||
drawPlaceholderArt(g, ART.x, ART.y, ART.s);
|
||||
}
|
||||
g.strokeStyle = C.artBorder;
|
||||
g.lineWidth = 1;
|
||||
g.strokeRect(ART.x + 0.5, ART.y + 0.5, ART.s - 1, ART.s - 1);
|
||||
|
||||
// Title (marquee when long), artist, album.
|
||||
const title = String(cur.title ?? '');
|
||||
titleW = ui.measure(g, title, FONTS.npTitle);
|
||||
titleOverflow = titleW > TEXT.w;
|
||||
g.font = FONTS.npTitle;
|
||||
if (titleOverflow) {
|
||||
const x = ui.marquee(now, titleSince, titleW, TEXT.w);
|
||||
lastTitleX = x;
|
||||
if (x === 0) {
|
||||
g.fillText(ui.ellipsize(g, title, FONTS.npTitle, TEXT.w), TEXT.x, TEXT.title);
|
||||
} else {
|
||||
g.save();
|
||||
g.beginPath();
|
||||
g.rect(TEXT.x, TEXT.title - 16, TEXT.w, 22);
|
||||
g.clip();
|
||||
g.fillText(title, TEXT.x + x, TEXT.title);
|
||||
g.fillText(title, TEXT.x + x + titleW + ANIM.marqueeGap, TEXT.title);
|
||||
g.restore();
|
||||
}
|
||||
} else {
|
||||
lastTitleX = 0;
|
||||
g.fillText(title, TEXT.x, TEXT.title);
|
||||
}
|
||||
g.font = FONTS.npMeta;
|
||||
g.fillText(ui.ellipsize(g, String(cur.artist ?? ''), FONTS.npMeta, TEXT.w), TEXT.x, TEXT.artist);
|
||||
g.fillText(ui.ellipsize(g, String(cur.album ?? ''), FONTS.npMeta, TEXT.w), TEXT.x, TEXT.album);
|
||||
|
||||
// Bottom band: volume overlay, or the mode's bar.
|
||||
const dur = player.duration || cur.duration || 0;
|
||||
const pos = Math.max(0, Math.min(dur, player.position || 0));
|
||||
const frac = dur > 0 ? pos / dur : 0;
|
||||
let showTimes = true;
|
||||
if (now < volumeUntil) {
|
||||
ui.drawVolume(g, VOLUME.x, VOLUME.y, VOLUME.w, player.volume);
|
||||
showTimes = false;
|
||||
} else if (mode === 'scrub') {
|
||||
ui.drawScrubber(g, BAR.x, BAR.y, BAR.w, BAR.h, frac);
|
||||
} else if (mode === 'rating') {
|
||||
ui.drawStars(g, STARS.cx, STARS.cy, rating(cur));
|
||||
showTimes = false;
|
||||
} else {
|
||||
ui.drawProgress(g, BAR.x, BAR.y, BAR.w, BAR.h, frac);
|
||||
}
|
||||
if (showTimes) {
|
||||
g.font = FONTS.time;
|
||||
g.fillStyle = C.text;
|
||||
// Whole seconds that always add up to the track length, as the assPod shows them.
|
||||
const elapsed = Math.floor(pos);
|
||||
g.fillText(ui.formatTime(elapsed), BAR.x, TIME_Y);
|
||||
g.textAlign = 'right';
|
||||
g.fillText('-' + ui.formatTime(Math.max(0, Math.round(dur) - elapsed)), BAR.x + BAR.w, TIME_Y);
|
||||
}
|
||||
g.restore();
|
||||
},
|
||||
|
||||
animating(now) {
|
||||
// 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;
|
||||
if (mode !== 'progress' && drawnAt <= modeEnd && now > modeEnd) return true;
|
||||
if (!titleOverflow) return false;
|
||||
return ui.marquee(now, titleSince, titleW, TEXT.w) !== 0 || lastTitleX !== 0;
|
||||
},
|
||||
|
||||
highlight: () => null,
|
||||
|
||||
/** Debug/test hook: the current display mode and whether the volume overlay is up. */
|
||||
get mode() { return mode; },
|
||||
get volumeVisible() { return ctx.now() < volumeUntil; },
|
||||
};
|
||||
return screen;
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
// Music > Search, after the 5G's firmware 1.2 search: results fill the screen while a strip of
|
||||
// letters along the bottom spells the query. The wheel picks a letter and select types it; "DONE"
|
||||
// (or scrolling past the last letter) moves the highlight into the results, and MENU goes back to
|
||||
// the letters. Results are artists, albums and songs from library.search().
|
||||
|
||||
// Song lists are the Music menu's own. (main-menu.js imports this file too; the cycle is harmless
|
||||
// because both sides only use each other's functions when a screen opens.)
|
||||
import { songList, albumSongs } from './main-menu.js';
|
||||
|
||||
// ---------------------------------------------------------------- keys
|
||||
|
||||
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) {
|
||||
return k === ' ' ? 'SPC' : k;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- screen
|
||||
|
||||
export function createSearch(ctx) {
|
||||
const { library, ui, theme } = ctx;
|
||||
const { C, M, FONTS, font } = theme;
|
||||
const keyFont = font(15, 600);
|
||||
const smallKeyFont = font(10, 700);
|
||||
|
||||
let query = '';
|
||||
let keyIndex = 0;
|
||||
let inResults = false;
|
||||
let results = [];
|
||||
let index = 0;
|
||||
let first = 0;
|
||||
const row = { label: '', detail: '', chevron: false, speaker: false, check: false, highlighted: false, marqueeX: 0 };
|
||||
|
||||
function runSearch() {
|
||||
results = [];
|
||||
const q = query.trim();
|
||||
if (!q || typeof library.search !== 'function') return;
|
||||
const r = library.search(q);
|
||||
for (const a of r.artists ?? []) results.push({ label: a, kind: 'Artist', open: () => songList(ctx, 'songs', a, library.tracksBy({ artist: a })) });
|
||||
for (const al of r.albums ?? []) results.push({ label: al.title, kind: 'Album', open: () => albumSongs(ctx, al) });
|
||||
const tracks = r.tracks ?? [];
|
||||
tracks.forEach((t, i) => results.push({ label: t.title, kind: 'Song', play: () => { ctx.player.playQueue(tracks, i); ctx.nowPlaying(); } }));
|
||||
index = 0;
|
||||
first = 0;
|
||||
}
|
||||
|
||||
function type(k) {
|
||||
if (k === 'DEL') { if (!query) return false; query = query.slice(0, -1); }
|
||||
else if (k === 'DONE') { if (!results.length) return false; inResults = true; return true; }
|
||||
else { if (query.length >= 24) return false; query += k; }
|
||||
runSearch();
|
||||
return true;
|
||||
}
|
||||
|
||||
function drawStrip(g, y) {
|
||||
// Band: the query on top, letters below with the picked one in the highlight.
|
||||
g.fillStyle = gradientBand(g, y);
|
||||
g.fillRect(0, y, M.W, STRIP_H);
|
||||
g.fillStyle = C.statusLine;
|
||||
g.fillRect(0, y, M.W, 1);
|
||||
g.save();
|
||||
g.font = FONTS.row;
|
||||
g.fillStyle = C.text;
|
||||
g.textBaseline = 'alphabetic';
|
||||
const shown = ui.ellipsize(g, query + (inResults ? '' : '_'), FONTS.row, M.W - 16);
|
||||
g.fillText(shown, M.padX, y + 17);
|
||||
const ky = y + 22;
|
||||
const cx = M.W / 2 - KEY_W / 2;
|
||||
g.textAlign = 'center';
|
||||
for (let i = -7; i <= 7; i++) {
|
||||
const k = KEYS[keyIndex + i];
|
||||
if (k === undefined) continue;
|
||||
const x = cx + i * KEY_W;
|
||||
const hi = i === 0 && !inResults;
|
||||
if (hi) ui.drawHighlight(g, x, ky, KEY_W, 20);
|
||||
const label = keyLabel(k);
|
||||
g.font = label.length > 1 ? smallKeyFont : keyFont;
|
||||
g.fillStyle = hi ? C.textHi : C.text;
|
||||
g.fillText(label, x + KEY_W / 2, ky + (label.length > 1 ? 14 : 15));
|
||||
}
|
||||
g.restore();
|
||||
}
|
||||
|
||||
const bandStops = [[0, '#f4f4f4'], [1, '#d6d6d6']];
|
||||
function gradientBand(g, y) {
|
||||
return ui.gradient(g, 0, y, 0, y + STRIP_H, bandStops);
|
||||
}
|
||||
|
||||
return {
|
||||
id: 'search',
|
||||
title: 'Search',
|
||||
enter() { runSearch(); },
|
||||
handle(e) {
|
||||
if (e.type === 'scroll') {
|
||||
if (inResults) {
|
||||
const next = index + e.delta;
|
||||
if (next < 0) { inResults = false; return true; }
|
||||
if (next >= results.length) return false;
|
||||
index = next;
|
||||
if (index < first) first = index;
|
||||
if (index >= first + RESULT_ROWS) first = index - RESULT_ROWS + 1;
|
||||
return true;
|
||||
}
|
||||
const next = keyIndex + e.delta;
|
||||
if (next >= KEYS.length) { if (!results.length) return false; inResults = true; return true; }
|
||||
if (next < 0) return false;
|
||||
keyIndex = next;
|
||||
return true;
|
||||
}
|
||||
if (e.type === 'press' && e.part === 'select') {
|
||||
if (!inResults) return type(KEYS[keyIndex]);
|
||||
const r = results[index];
|
||||
if (!r) return false;
|
||||
if (r.open) ctx.push(r.open());
|
||||
else r.play();
|
||||
return true;
|
||||
}
|
||||
if (e.type === 'press' && e.part === 'menu' && inResults) {
|
||||
inResults = false;
|
||||
return true;
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
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 w = scroll ? M.W - M.scrollbarW : M.W;
|
||||
for (let i = first; i < Math.min(results.length, first + RESULT_ROWS); i++) {
|
||||
const res = results[i];
|
||||
row.label = res.label;
|
||||
row.detail = res.kind;
|
||||
row.highlighted = inResults && i === index;
|
||||
ui.drawRow(g, r.y + (i - first) * M.rowH, w, row);
|
||||
}
|
||||
if (!results.length && query.trim()) {
|
||||
g.save();
|
||||
g.font = FONTS.small;
|
||||
g.fillStyle = C.textDim;
|
||||
g.textAlign = 'center';
|
||||
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);
|
||||
drawStrip(g, r.y + r.h - STRIP_H);
|
||||
},
|
||||
highlight() {
|
||||
if (inResults) return results[index] ? { index, label: results[index].label } : null;
|
||||
return { index: keyIndex, label: keyLabel(KEYS[keyIndex]) };
|
||||
},
|
||||
get query() { return query; },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,331 @@
|
||||
// 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.)
|
||||
|
||||
// ---------------------------------------------------------------- option tables
|
||||
|
||||
const SHUFFLE = [['off', 'Off'], ['songs', 'Songs'], ['albums', 'Albums']];
|
||||
const REPEAT = [['off', 'Off'], ['one', 'One'], ['all', 'All']];
|
||||
const ON_OFF = [[false, 'Off'], [true, 'On']];
|
||||
const TIME_FORMAT = [['12', '12-hour'], ['24', '24-hour']];
|
||||
|
||||
const BACKLIGHT_TIMER = [
|
||||
[0, 'Off'], [2, '2 Seconds'], [5, '5 Seconds'], [10, '10 Seconds'],
|
||||
[15, '15 Seconds'], [20, '20 Seconds'], [30, '30 Seconds'], [-1, 'Always On'],
|
||||
];
|
||||
|
||||
// VLC's equalizer presets, in VLC's own order after 'Off' (the daemon maps a name to VLC's
|
||||
// preset number; device/shell_ext/asspod.py EQ_PRESETS lists the same names).
|
||||
export const EQ_NAMES = [
|
||||
'Off', 'Flat', 'Classical', 'Club', 'Dance', 'Full Bass', 'Full Bass & Treble', 'Full Treble',
|
||||
'Headphones', 'Large Hall', 'Live', 'Party', 'Pop', 'Reggae', 'Rock', 'Ska', 'Soft', 'Soft Rock',
|
||||
'Techno',
|
||||
];
|
||||
|
||||
const BRIGHTNESS_STEPS = 16;
|
||||
|
||||
// ---------------------------------------------------------------- helpers
|
||||
|
||||
function labelFor(table, value) {
|
||||
const row = table.find(([v]) => v === value);
|
||||
return row ? row[1] : table[0][1];
|
||||
}
|
||||
|
||||
/** A row whose value is shown on the right and advances to the next option on select. */
|
||||
function cycleItem(ctx, label, key, table) {
|
||||
return {
|
||||
id: key,
|
||||
label,
|
||||
detail: () => labelFor(table, ctx.settings.get(key)),
|
||||
action: (c) => {
|
||||
const i = table.findIndex(([v]) => v === c.settings.get(key));
|
||||
c.settings.set(key, table[(i + 1) % table.length][0]);
|
||||
return true;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Option submenu: a checkmark on the current value; picking one sets it and goes back. */
|
||||
function optionList(ctx, id, title, key, table) {
|
||||
return ctx.menu({
|
||||
id,
|
||||
title,
|
||||
initialIndex: () => Math.max(0, table.findIndex(([v]) => v === ctx.settings.get(key))),
|
||||
items: table.map(([value, label]) => ({
|
||||
label,
|
||||
check: () => ctx.settings.get(key) === value,
|
||||
action: (c) => {
|
||||
c.settings.set(key, value);
|
||||
c.pop();
|
||||
return true;
|
||||
},
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Brightness
|
||||
|
||||
function drawSun(g, cx, cy, r, rays, color) {
|
||||
g.save();
|
||||
g.fillStyle = color;
|
||||
g.strokeStyle = color;
|
||||
g.lineCap = 'round';
|
||||
g.lineWidth = 1.3;
|
||||
g.beginPath();
|
||||
g.arc(cx, cy, r, 0, Math.PI * 2);
|
||||
g.fill();
|
||||
g.beginPath();
|
||||
for (let i = 0; i < 8; i++) {
|
||||
const a = (i * Math.PI) / 4;
|
||||
g.moveTo(cx + Math.cos(a) * (r + 1.8), cy + Math.sin(a) * (r + 1.8));
|
||||
g.lineTo(cx + Math.cos(a) * (r + 1.8 + rays), cy + Math.sin(a) * (r + 1.8 + rays));
|
||||
}
|
||||
g.stroke();
|
||||
g.restore();
|
||||
}
|
||||
|
||||
/** Slider screen: dim sun, bar, bright sun, laid out like the volume overlay. */
|
||||
function brightnessScreen(ctx) {
|
||||
const { settings, ui, theme } = ctx;
|
||||
const { C } = theme;
|
||||
const value = () => {
|
||||
const b = Number(settings.get('brightness'));
|
||||
return Number.isFinite(b) ? Math.min(1, Math.max(0, b)) : 0.75;
|
||||
};
|
||||
return {
|
||||
id: 'brightness',
|
||||
title: 'Brightness',
|
||||
handle(e) {
|
||||
if (e.type === 'scroll') {
|
||||
const b = value();
|
||||
const next = Math.min(1, Math.max(0, Math.round(b * BRIGHTNESS_STEPS + e.delta) / BRIGHTNESS_STEPS));
|
||||
if (next === b) return false;
|
||||
settings.set('brightness', next);
|
||||
return true;
|
||||
}
|
||||
if (e.type === 'press' && e.part === 'select') { ctx.pop(); return true; }
|
||||
return undefined;
|
||||
},
|
||||
draw(g, r) {
|
||||
g.fillStyle = C.bg;
|
||||
g.fillRect(r.x, r.y, r.w, r.h);
|
||||
const y = 124;
|
||||
drawSun(g, 15, y + 7, 2.2, 1.6, C.text);
|
||||
ui.drawProgress(g, 28, y + 2, 264, 10, value());
|
||||
drawSun(g, 305, y + 7, 3.4, 2.6, C.text);
|
||||
},
|
||||
highlight: () => null,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- About
|
||||
|
||||
/**
|
||||
* The 5G About page: the assPod's name centered in bold, then label/value lines. It is a page,
|
||||
* not a menu, so there is no highlight; the wheel scrolls it when it overflows.
|
||||
*/
|
||||
function aboutScreen(ctx) {
|
||||
const { library: lib, ui, theme } = ctx;
|
||||
const { C, M, FONTS } = theme;
|
||||
const songs = lib.tracks?.length ?? 0;
|
||||
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 info = lib.info || {};
|
||||
const lines = [
|
||||
['Songs', songs.toLocaleString('en-US')],
|
||||
['Albums', albums.toLocaleString('en-US')],
|
||||
['Videos', videos.toLocaleString('en-US')],
|
||||
['Playing Time', `${hours.toFixed(1)} h`],
|
||||
['Sources', String((info.sources || []).length)],
|
||||
['Player', 'VLC'],
|
||||
['Model', 'assPod Video'],
|
||||
];
|
||||
const total = lines.length + 1; // + the name line
|
||||
let first = 0;
|
||||
return {
|
||||
id: 'about',
|
||||
title: 'About',
|
||||
handle(e) {
|
||||
if (e.type === 'scroll') {
|
||||
const next = Math.max(0, Math.min(total - M.rows, first + e.delta));
|
||||
if (next === first) return false;
|
||||
first = next;
|
||||
return true;
|
||||
}
|
||||
if (e.type === 'press' && e.part === 'select') return false;
|
||||
return undefined;
|
||||
},
|
||||
draw(g, r) {
|
||||
g.fillStyle = C.bg;
|
||||
g.fillRect(r.x, r.y, r.w, r.h);
|
||||
const scroll = total > M.rows;
|
||||
const w = scroll ? M.W - M.scrollbarW : M.W;
|
||||
g.save();
|
||||
g.textBaseline = 'alphabetic';
|
||||
g.fillStyle = C.text;
|
||||
for (let i = first; i < Math.min(total, first + M.rows); i++) {
|
||||
const y = r.y + (i - first) * M.rowH + M.rowBaseline;
|
||||
if (i === 0) {
|
||||
g.font = FONTS.row;
|
||||
g.textAlign = 'center';
|
||||
g.fillText('assPod', w / 2, y);
|
||||
continue;
|
||||
}
|
||||
const [label, value] = lines[i - 1];
|
||||
g.font = FONTS.row;
|
||||
g.textAlign = 'left';
|
||||
g.fillText(label, M.padX, y);
|
||||
g.font = FONTS.detail;
|
||||
g.textAlign = 'right';
|
||||
g.fillText(value, w - M.padX, y);
|
||||
}
|
||||
g.restore();
|
||||
if (scroll) ui.drawScrollbar(g, M.W - M.scrollbarW, r.y, M.rows * M.rowH, total, M.rows, first);
|
||||
},
|
||||
highlight: () => null,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Date & Time
|
||||
|
||||
function dateTimeMenu(ctx) {
|
||||
const zone = (() => {
|
||||
try {
|
||||
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
|
||||
return tz.split('/').pop().replace(/_/g, ' ');
|
||||
} catch { return 'UTC'; }
|
||||
})();
|
||||
const date = () => {
|
||||
const d = new Date();
|
||||
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
|
||||
};
|
||||
return ctx.menu({
|
||||
id: 'datetime',
|
||||
title: 'Date & Time',
|
||||
items: [
|
||||
{ label: 'Set Time Zone', detail: zone },
|
||||
{ label: 'Set Date & Time', detail: date },
|
||||
cycleItem(ctx, 'Time', 'timeFormat', TIME_FORMAT),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Legal
|
||||
|
||||
const LEGAL = [
|
||||
'assPod is part of LiquidAss. Your music and videos come from the VLC media library on this '
|
||||
+ 'headset, and VLC plays them.',
|
||||
'VLC is copyright VideoLAN and licensed under the GNU GPL 2.0 or later. FFmpeg reads the tags '
|
||||
+ 'and artwork.',
|
||||
'Inter is copyright The Inter Project Authors and licensed under the SIL Open Font License 1.1.',
|
||||
'Portions of this software are provided "as is", without warranty of any kind, express or '
|
||||
+ 'implied, including but not limited to the warranties of merchantability, fitness for a '
|
||||
+ 'particular purpose and noninfringement.',
|
||||
];
|
||||
|
||||
/** Scrolling text page: the wheel moves one line per step, with the list scrollbar on the right. */
|
||||
function legalScreen(ctx) {
|
||||
const { ui, theme } = ctx;
|
||||
const { C, M, FONTS } = theme;
|
||||
const LINE_H = 16;
|
||||
const TEXT_W = 292;
|
||||
const VISIBLE = 13;
|
||||
let first = 0;
|
||||
let lines = [];
|
||||
let wrappedWith = -1;
|
||||
|
||||
function wrap(g) {
|
||||
// Re-wrap only when glyph metrics change (fonts finishing loading clears the measure cache).
|
||||
const probe = ui.measure(g, 'The quick brown fox', FONTS.small);
|
||||
if (probe === wrappedWith) return;
|
||||
wrappedWith = probe;
|
||||
lines = [];
|
||||
for (const para of LEGAL) {
|
||||
let line = '';
|
||||
for (const word of para.split(' ')) {
|
||||
const tryLine = line ? line + ' ' + word : word;
|
||||
if (line && ui.measure(g, tryLine, FONTS.small) > TEXT_W) { lines.push(line); line = word; } else line = tryLine;
|
||||
}
|
||||
lines.push(line, '');
|
||||
}
|
||||
lines.pop();
|
||||
}
|
||||
|
||||
return {
|
||||
id: 'legal',
|
||||
title: 'Legal',
|
||||
handle(e) {
|
||||
if (e.type === 'scroll') {
|
||||
const max = Math.max(0, lines.length - VISIBLE);
|
||||
const next = Math.max(0, Math.min(max, first + e.delta));
|
||||
if (next === first) return false;
|
||||
first = next;
|
||||
return true;
|
||||
}
|
||||
if (e.type === 'press' && e.part === 'select') return false;
|
||||
return undefined;
|
||||
},
|
||||
draw(g, r) {
|
||||
wrap(g);
|
||||
g.fillStyle = C.bg;
|
||||
g.fillRect(r.x, r.y, r.w, r.h);
|
||||
g.save();
|
||||
g.font = FONTS.small;
|
||||
g.fillStyle = C.text;
|
||||
g.textBaseline = 'alphabetic';
|
||||
for (let i = 0; i < VISIBLE && first + i < lines.length; i++) {
|
||||
g.fillText(lines[first + i], M.padX, r.y + 16 + i * LINE_H);
|
||||
}
|
||||
g.restore();
|
||||
if (lines.length > VISIBLE) {
|
||||
ui.drawScrollbar(g, M.W - M.scrollbarW, r.y, M.rows * M.rowH, lines.length, VISIBLE, first);
|
||||
}
|
||||
},
|
||||
highlight: () => null,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Reset
|
||||
|
||||
function resetMenu(ctx) {
|
||||
return ctx.menu({
|
||||
id: 'reset',
|
||||
title: 'Reset All',
|
||||
items: [
|
||||
{ id: 'cancel', label: 'Cancel', action: (c) => { c.pop(); return true; } },
|
||||
{
|
||||
id: 'reset',
|
||||
label: 'Reset',
|
||||
action: (c) => {
|
||||
c.settings.reset();
|
||||
c.pop();
|
||||
return true;
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Settings
|
||||
|
||||
export function createSettings(ctx) {
|
||||
return ctx.menu({
|
||||
id: 'settings',
|
||||
title: 'Settings',
|
||||
items: [
|
||||
{ id: 'about', label: 'About', open: aboutScreen },
|
||||
cycleItem(ctx, 'Shuffle', 'shuffle', SHUFFLE),
|
||||
cycleItem(ctx, 'Repeat', 'repeat', REPEAT),
|
||||
{ id: 'backlighttimer', label: 'Backlight Timer', open: (c) => optionList(c, 'backlighttimer', 'Backlight Timer', 'backlightTimer', BACKLIGHT_TIMER) },
|
||||
{ id: 'brightness', label: 'Brightness', open: brightnessScreen },
|
||||
{ id: 'eq', label: 'EQ', open: (c) => optionList(c, 'eq', 'EQ', 'eq', EQ_NAMES.map((n) => [n, n])) },
|
||||
cycleItem(ctx, 'Clicker', 'clicker', ON_OFF),
|
||||
{ id: 'datetime', label: 'Date & Time', open: dateTimeMenu },
|
||||
{ id: 'legal', label: 'Legal', open: legalScreen },
|
||||
{ id: 'resetsettings', label: 'Reset Settings', open: resetMenu },
|
||||
],
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
// 5G visual spec, logical px on the 320x240 screen
|
||||
import { SCREEN_W, SCREEN_H } from '../config.js';
|
||||
|
||||
// "LGS Inter" is the theme's own UI font, which LiquidAss adds to every SteamVR page (theme/01-font.nowrap.css).
|
||||
export const FONT_FAMILY = '"LGS Inter", "Helvetica Neue", Helvetica, Arial, sans-serif';
|
||||
export const font = (px, weight = 400) => `${weight} ${px}px ${FONT_FAMILY}`;
|
||||
|
||||
export const FONTS = {
|
||||
title: font(14, 700), // status bar title
|
||||
row: font(15, 600), // list labels
|
||||
detail: font(14, 400), // right-aligned values in Settings rows
|
||||
npTitle: font(14, 700),
|
||||
npMeta: font(13, 400),
|
||||
small: font(12, 400), // "1 of 12", captions
|
||||
time: font(11, 600), // elapsed / remaining
|
||||
cfTitle: font(13, 700),
|
||||
cfArtist: font(12, 400),
|
||||
big: font(30, 600), // stopwatch / clock digits
|
||||
};
|
||||
|
||||
export const M = {
|
||||
W: SCREEN_W, H: SCREEN_H,
|
||||
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
|
||||
rowBaseline: 17, // text baseline offset inside a 24px row
|
||||
chevronW: 6, chevronH: 10, chevronInset: 9, // chevron right edge = rowRight - 9
|
||||
scrollbarW: 8, // at x=312..320 when a list overflows; rows then end at 312
|
||||
};
|
||||
|
||||
export const C = {
|
||||
bg: '#ffffff', text: '#000000', textHi: '#ffffff', textDim: '#6b6b6b',
|
||||
status: [[0, '#fdfdfd'], [0.5, '#e4e4e4'], [1, '#c3c3c3']],
|
||||
statusLine: '#7f7f7f', statusShadow: 'rgba(255,255,255,0.85)',
|
||||
hi: [[0, '#78b3f4'], [1, '#2d6cd2']], hiTop: '#a2ccf9', hiBottom: '#2457b4',
|
||||
sbBorder: '#9b9b9b', sbTrack: '#ffffff', sbThumb: [[0, '#9a9a9a'], [1, '#585858']],
|
||||
prBorder: '#6f6f6f', prTrack: [[0, '#ffffff'], [1, '#dcdcdc']], prGloss: 'rgba(255,255,255,0.35)',
|
||||
battery: [[0, '#a8ec7a'], [1, '#3a9e22']], batteryBorder: '#4a4a4a',
|
||||
play: [[0, '#7fbaf6'], [1, '#1f62c9']], playBorder: '#173f80',
|
||||
artBorder: '#c8c8c8',
|
||||
cfBg: '#000000', cfTitle: '#ffffff', cfArtist: '#9c9c9c',
|
||||
cfPanel: [[0, '#3a3a3a'], [1, '#161616']], cfPanelBorder: '#555555',
|
||||
overlay: 'rgba(0,0,0,0.55)',
|
||||
bootBg: '#ffffff', bootLogo: '#2c2c2e', // the 5G boot: a dark logo alone on a white screen
|
||||
};
|
||||
|
||||
export const ANIM = {
|
||||
slideMs: 220, // push/pop slide, ease-out cubic
|
||||
maxFps: 40, // cap for continuous screen animation (texture uploads)
|
||||
marqueeDelayMs: 1000, marqueeSpeed: 28, marqueeGap: 32,
|
||||
volumeOverlayMs: 2000, npModeTimeoutMs: 4000,
|
||||
backlightFadeMs: 300, backlightDim: 0.2, // "backlight off" still shows a dim screen, as on the 5G
|
||||
bootMs: 2000, // boot logo (os.boot()); the assPod shows it only while VLC starts (os.setLoading)
|
||||
wakeBootMs: 1200, // boot on wake from sleep (unused: the OS is created with boot off)
|
||||
};
|
||||
@@ -0,0 +1,494 @@
|
||||
// assPod OS drawing kit: the 5G status bar, list rows, highlight, scrollbar, bars, icons, text cache.
|
||||
// All coordinates are logical 320x240 px; the OS has already applied scale(2,2) and a clip.
|
||||
// Every helper restores the canvas state it changes, so screens can call them freely.
|
||||
// Passed to screens and apps as ctx.ui; apps never import this module directly.
|
||||
|
||||
import { FONTS, M, C, ANIM } from './theme.js';
|
||||
|
||||
// ---------------------------------------------------------------- text cache
|
||||
|
||||
/** font -> (text -> width). Cleared by clearCache() once web fonts finish loading. */
|
||||
const widths = new Map();
|
||||
/** font -> (maxW -> (text -> ellipsized)) */
|
||||
const ellipses = new Map();
|
||||
const CACHE_LIMIT = 4000; // per font; lists are small, so hitting this means churn (e.g. a clock) and a reset is fine
|
||||
|
||||
/**
|
||||
* Width of `text` in `font`, cached. Measures on `g` but restores its font, so a cache
|
||||
* miss between a caller's `g.font = …` and its `fillText` can't change what gets drawn.
|
||||
*/
|
||||
export function measure(g, text, font) {
|
||||
let m = widths.get(font);
|
||||
if (!m) { m = new Map(); widths.set(font, m); }
|
||||
let w = m.get(text);
|
||||
if (w === undefined) {
|
||||
const prev = g.font;
|
||||
g.font = font;
|
||||
w = g.measureText(text).width;
|
||||
g.font = prev;
|
||||
if (m.size >= CACHE_LIMIT) m.clear();
|
||||
m.set(text, w);
|
||||
}
|
||||
return w;
|
||||
}
|
||||
|
||||
/** Longest prefix of `text` that fits `maxW` with a trailing ellipsis (the assPod's "…" truncation). */
|
||||
export function ellipsize(g, text, font, maxW) {
|
||||
if (measure(g, text, font) <= maxW) return text;
|
||||
let byW = ellipses.get(font);
|
||||
if (!byW) { byW = new Map(); ellipses.set(font, byW); }
|
||||
let m = byW.get(maxW);
|
||||
if (!m) { m = new Map(); byW.set(maxW, m); }
|
||||
let s = m.get(text);
|
||||
if (s === undefined) {
|
||||
// Binary search on the prefix length; trailing spaces are trimmed so we never get "word …".
|
||||
let lo = 0;
|
||||
let hi = text.length;
|
||||
while (lo < hi) {
|
||||
const mid = (lo + hi + 1) >> 1;
|
||||
if (measure(g, text.slice(0, mid).trimEnd() + '…', font) <= maxW) lo = mid; else hi = mid - 1;
|
||||
}
|
||||
s = lo > 0 ? text.slice(0, lo).trimEnd() + '…' : '…';
|
||||
if (m.size >= CACHE_LIMIT) m.clear();
|
||||
m.set(text, s);
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
/** Drops every cached measurement (call after fonts load, since widths were taken with a fallback). */
|
||||
export function clearCache() {
|
||||
widths.clear();
|
||||
ellipses.clear();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- gradients
|
||||
|
||||
/** ctx -> (key -> CanvasGradient). Gradients live in user space, so callers translate rather than re-create. */
|
||||
const gradients = new WeakMap();
|
||||
const stopKeys = new WeakMap();
|
||||
|
||||
function stopsKey(stops) {
|
||||
let k = stopKeys.get(stops);
|
||||
if (k === undefined) {
|
||||
k = stops.map((s) => s[0] + ':' + s[1]).join('|');
|
||||
stopKeys.set(stops, k);
|
||||
}
|
||||
return k;
|
||||
}
|
||||
|
||||
/**
|
||||
* Linear gradient, cached per context by coordinates and stops.
|
||||
* @param {CanvasRenderingContext2D} g
|
||||
* @param {[number, string][]} stops
|
||||
*/
|
||||
export function gradient(g, x0, y0, x1, y1, stops) {
|
||||
let m = gradients.get(g);
|
||||
if (!m) { m = new Map(); gradients.set(g, m); }
|
||||
const key = x0 + ',' + y0 + ',' + x1 + ',' + y1 + '|' + stopsKey(stops);
|
||||
let gr = m.get(key);
|
||||
if (!gr) {
|
||||
gr = g.createLinearGradient(x0, y0, x1, y1);
|
||||
for (const [o, c] of stops) gr.addColorStop(o, c);
|
||||
m.set(key, gr);
|
||||
}
|
||||
return gr;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- shapes
|
||||
|
||||
function roundRectPath(g, x, y, w, h, r) {
|
||||
g.beginPath();
|
||||
g.moveTo(x + r, y);
|
||||
g.lineTo(x + w - r, y);
|
||||
g.arcTo(x + w, y, x + w, y + r, r);
|
||||
g.lineTo(x + w, y + h - r);
|
||||
g.arcTo(x + w, y + h, x + w - r, y + h, r);
|
||||
g.lineTo(x + r, y + h);
|
||||
g.arcTo(x, y + h, x, y + h - r, r);
|
||||
g.lineTo(x, y + r);
|
||||
g.arcTo(x, y, x + r, y, r);
|
||||
g.closePath();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- status bar
|
||||
|
||||
const PLAY_GRAD = C.play;
|
||||
const ICON_Y = 6;
|
||||
|
||||
function drawPlayIcon(g, x, y, state) {
|
||||
g.save();
|
||||
g.translate(x, y);
|
||||
g.fillStyle = gradient(g, 0, 0, 0, 10, PLAY_GRAD);
|
||||
g.strokeStyle = C.playBorder;
|
||||
g.lineWidth = 1;
|
||||
g.lineJoin = 'round';
|
||||
g.beginPath();
|
||||
if (state === 'playing') {
|
||||
// 9x10 triangle; the path is inset half a pixel so the 1px outline stays inside the box.
|
||||
g.moveTo(0.5, 0.5);
|
||||
g.lineTo(8.5, 5);
|
||||
g.lineTo(0.5, 9.5);
|
||||
g.closePath();
|
||||
} else {
|
||||
// Two 3x10 bars, 2px apart.
|
||||
g.rect(0.5, 0.5, 3, 9);
|
||||
g.rect(5.5, 0.5, 3, 9);
|
||||
}
|
||||
g.fill();
|
||||
g.stroke();
|
||||
g.restore();
|
||||
}
|
||||
|
||||
function drawLock(g, x, y) {
|
||||
// Hold-switch padlock: a 9x7 body under a 5px wide shackle.
|
||||
g.save();
|
||||
g.translate(x, y);
|
||||
g.strokeStyle = '#3c3c3c';
|
||||
g.lineWidth = 1.4;
|
||||
g.beginPath();
|
||||
g.moveTo(2.5, 5);
|
||||
g.lineTo(2.5, 3);
|
||||
g.arc(4.5, 3, 2, Math.PI, 0);
|
||||
g.lineTo(6.5, 5);
|
||||
g.stroke();
|
||||
g.fillStyle = gradient(g, 0, 4, 0, 11, [[0, '#8a8a8a'], [1, '#3a3a3a']]);
|
||||
roundRectPath(g, 0, 4.5, 9, 6.5, 1);
|
||||
g.fill();
|
||||
g.restore();
|
||||
}
|
||||
|
||||
function drawBattery(g, x, y) {
|
||||
g.save();
|
||||
g.translate(x, y);
|
||||
// Full green fill, inset 1.5px from the 24x11 body.
|
||||
g.fillStyle = gradient(g, 0, 1.5, 0, 9.5, C.battery);
|
||||
roundRectPath(g, 1.5, 1.5, 21, 8, 0.75);
|
||||
g.fill();
|
||||
// A soft gloss on the upper half, as on the 5G icon.
|
||||
g.fillStyle = 'rgba(255,255,255,0.35)';
|
||||
g.fillRect(2, 2, 20, 3);
|
||||
g.strokeStyle = C.batteryBorder;
|
||||
g.lineWidth = 1;
|
||||
roundRectPath(g, 0.5, 0.5, 23, 10, 1.5);
|
||||
g.stroke();
|
||||
// Terminal nub, 2x5 at x = 311.
|
||||
g.fillStyle = C.batteryBorder;
|
||||
g.fillRect(24, 3, 2, 5);
|
||||
g.restore();
|
||||
}
|
||||
|
||||
/**
|
||||
* The 22px silver title bar.
|
||||
* @param {CanvasRenderingContext2D} g
|
||||
* @param {{ title: string, play: 'playing'|'paused'|'stopped', locked?: boolean }} s
|
||||
*/
|
||||
export function drawStatusBar(g, s) {
|
||||
g.save();
|
||||
g.fillStyle = gradient(g, 0, 0, 0, M.statusH - 1, C.status);
|
||||
g.fillRect(0, 0, M.W, M.statusH - 1);
|
||||
g.fillStyle = C.statusLine;
|
||||
g.fillRect(0, M.statusH - 1, M.W, 1);
|
||||
|
||||
const title = ellipsize(g, s.title ?? '', FONTS.title, 220);
|
||||
g.font = FONTS.title;
|
||||
g.textAlign = 'center';
|
||||
g.textBaseline = 'alphabetic';
|
||||
g.fillStyle = C.statusShadow;
|
||||
g.fillText(title, M.W / 2, 17);
|
||||
g.fillStyle = C.text;
|
||||
g.fillText(title, M.W / 2, 16);
|
||||
|
||||
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);
|
||||
g.restore();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- list rows
|
||||
|
||||
/** Blue selection bar: C.hi gradient with a light top line and a dark bottom line. */
|
||||
export function drawHighlight(g, x, y, w, h) {
|
||||
g.save();
|
||||
g.translate(x, y);
|
||||
g.fillStyle = gradient(g, 0, 0, 0, h, C.hi);
|
||||
g.fillRect(0, 0, w, h);
|
||||
g.fillStyle = C.hiTop;
|
||||
g.fillRect(0, 0, w, 1);
|
||||
g.fillStyle = C.hiBottom;
|
||||
g.fillRect(0, h - 1, w, 1);
|
||||
g.restore();
|
||||
}
|
||||
|
||||
/** The ">" disclosure mark: a 6x10 polyline with a 2px stroke whose right edge is at xRight. */
|
||||
export function drawChevron(g, xRight, cy, color) {
|
||||
g.save();
|
||||
g.strokeStyle = color;
|
||||
g.lineWidth = 2;
|
||||
g.lineCap = 'butt';
|
||||
g.lineJoin = 'miter';
|
||||
g.beginPath();
|
||||
// The miter pokes about 1.5px past the tip, so the tip sits inside xRight.
|
||||
g.moveTo(xRight - 6, cy - 5);
|
||||
g.lineTo(xRight - 1.5, cy);
|
||||
g.lineTo(xRight - 6, cy + 5);
|
||||
g.stroke();
|
||||
g.restore();
|
||||
}
|
||||
|
||||
/** Small speaker (about 7x10, 13 wide with waves), left edge at x, centered on cy. */
|
||||
export function drawSpeaker(g, x, cy, color, waves = false) {
|
||||
g.save();
|
||||
g.fillStyle = color;
|
||||
g.beginPath();
|
||||
g.moveTo(x, cy - 2);
|
||||
g.lineTo(x + 3, cy - 2);
|
||||
g.lineTo(x + 7, cy - 5);
|
||||
g.lineTo(x + 7, cy + 5);
|
||||
g.lineTo(x + 3, cy + 2);
|
||||
g.lineTo(x, cy + 2);
|
||||
g.closePath();
|
||||
g.fill();
|
||||
if (waves) {
|
||||
g.strokeStyle = color;
|
||||
g.lineWidth = 1.2;
|
||||
g.lineCap = 'round';
|
||||
g.beginPath();
|
||||
g.arc(x + 7, cy, 3, -Math.PI / 4, Math.PI / 4);
|
||||
g.stroke();
|
||||
g.beginPath();
|
||||
g.arc(x + 7, cy, 6, -Math.PI / 4, Math.PI / 4);
|
||||
g.stroke();
|
||||
}
|
||||
g.restore();
|
||||
}
|
||||
|
||||
/** Option-list checkmark, about 10x9, left edge at x. */
|
||||
export function drawCheck(g, x, cy, color) {
|
||||
g.save();
|
||||
g.strokeStyle = color;
|
||||
g.lineWidth = 2;
|
||||
g.lineCap = 'round';
|
||||
g.lineJoin = 'round';
|
||||
g.beginPath();
|
||||
g.moveTo(x + 1, cy);
|
||||
g.lineTo(x + 4, cy + 4);
|
||||
g.lineTo(x + 10, cy - 5);
|
||||
g.stroke();
|
||||
g.restore();
|
||||
}
|
||||
|
||||
function resolve(v) {
|
||||
return typeof v === 'function' ? v() : v;
|
||||
}
|
||||
|
||||
/**
|
||||
* Width available to a row's label, after the right-hand decorations (chevron, detail, icons).
|
||||
* Exported so lists can tell whether the highlighted label needs a marquee.
|
||||
*/
|
||||
export function rowLabelWidth(g, w, item) {
|
||||
let right = w - M.padX;
|
||||
if (item.detail != null && item.detail !== '') {
|
||||
right = w - M.padX - measure(g, String(resolve(item.detail)), FONTS.detail) - 10;
|
||||
} else if (item.chevron) {
|
||||
right = w - M.chevronInset - M.chevronW - 6;
|
||||
}
|
||||
if (item.check) right = Math.min(right, w - 20 - 6);
|
||||
if (item.speaker) right = Math.min(right, w - 24 - 6);
|
||||
return Math.max(0, right - M.padX);
|
||||
}
|
||||
|
||||
/**
|
||||
* One 24px list row starting at y, spanning x = 0..w.
|
||||
* Non-highlighted rows draw no background (the list fills white once). A highlighted row whose
|
||||
* label overflows scrolls by `marqueeX` (<= 0); at 0 it shows the "…" form like other rows.
|
||||
* @param {{ label: string, detail?: string, chevron?: boolean, speaker?: boolean, check?: boolean,
|
||||
* highlighted?: boolean, marqueeX?: number }} item
|
||||
*/
|
||||
export function drawRow(g, y, w, item) {
|
||||
const hi = !!item.highlighted;
|
||||
if (hi) drawHighlight(g, 0, y, w, M.rowH);
|
||||
const color = hi ? C.textHi : C.text;
|
||||
const label = String(resolve(item.label) ?? '');
|
||||
const base = y + M.rowBaseline;
|
||||
const cy = y + M.rowH / 2;
|
||||
const boxW = rowLabelWidth(g, w, item);
|
||||
|
||||
g.save();
|
||||
g.textBaseline = 'alphabetic';
|
||||
g.textAlign = 'left'; // callers may leave the context right-aligned
|
||||
g.fillStyle = color;
|
||||
g.font = FONTS.row;
|
||||
const textW = measure(g, label, FONTS.row);
|
||||
if (hi && item.marqueeX && textW > boxW) {
|
||||
g.save();
|
||||
g.beginPath();
|
||||
g.rect(M.padX, y, boxW, M.rowH);
|
||||
g.clip();
|
||||
const x0 = M.padX + item.marqueeX;
|
||||
g.fillText(label, x0, base);
|
||||
g.fillText(label, x0 + textW + ANIM.marqueeGap, base);
|
||||
g.restore();
|
||||
} else {
|
||||
g.fillText(textW > boxW ? ellipsize(g, label, FONTS.row, boxW) : label, M.padX, base);
|
||||
}
|
||||
|
||||
const detail = item.detail != null ? String(resolve(item.detail)) : '';
|
||||
if (detail !== '') {
|
||||
g.font = FONTS.detail;
|
||||
g.fillStyle = color;
|
||||
g.textAlign = 'right';
|
||||
g.fillText(detail, w - M.padX, base);
|
||||
} else if (item.chevron) {
|
||||
drawChevron(g, w - M.chevronInset, cy, color);
|
||||
}
|
||||
if (item.check) drawCheck(g, w - 20, cy, color);
|
||||
if (item.speaker) drawSpeaker(g, w - 24, cy, color, true);
|
||||
g.restore();
|
||||
}
|
||||
|
||||
/**
|
||||
* Vertical scrollbar: 1px border, white track, 6px thumb with a horizontal grey gradient.
|
||||
* @param {number} total rows in the list @param {number} visible rows on screen @param {number} first top row index
|
||||
*/
|
||||
export function drawScrollbar(g, x, y, h, total, visible, first) {
|
||||
const w = M.scrollbarW;
|
||||
g.save();
|
||||
g.fillStyle = C.sbTrack;
|
||||
g.fillRect(x, y, w, h);
|
||||
g.strokeStyle = C.sbBorder;
|
||||
g.lineWidth = 1;
|
||||
g.strokeRect(x + 0.5, y + 0.5, w - 1, h - 1);
|
||||
const inner = h - 2;
|
||||
const thumbH = total > visible ? Math.max(10, Math.round(inner * visible / total)) : inner;
|
||||
const span = Math.max(0, total - visible);
|
||||
const top = y + 1 + (span > 0 ? Math.round((inner - thumbH) * Math.min(1, Math.max(0, first / span))) : 0);
|
||||
g.translate(x + 1, 0);
|
||||
g.fillStyle = gradient(g, 0, 0, w - 2, 0, C.sbThumb);
|
||||
g.fillRect(0, top, w - 2, thumbH);
|
||||
g.restore();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- bars
|
||||
|
||||
function drawBarTrack(g, x, y, w, h) {
|
||||
g.translate(x, y);
|
||||
g.fillStyle = gradient(g, 0, 1, 0, h - 1, C.prTrack);
|
||||
g.fillRect(1, 1, w - 2, h - 2);
|
||||
}
|
||||
|
||||
function drawBarBorder(g, w, h) {
|
||||
g.strokeStyle = C.prBorder;
|
||||
g.lineWidth = 1;
|
||||
g.strokeRect(0.5, 0.5, w - 1, h - 1);
|
||||
}
|
||||
|
||||
function drawBarFill(g, w, h, fraction) {
|
||||
const f = Math.min(1, Math.max(0, fraction || 0));
|
||||
const fw = Math.round((w - 2) * f * 2) / 2;
|
||||
if (fw <= 0) return;
|
||||
g.fillStyle = gradient(g, 0, 1, 0, h - 1, C.hi);
|
||||
g.fillRect(1, 1, fw, h - 2);
|
||||
g.fillStyle = C.prGloss;
|
||||
g.fillRect(1, 1, fw, Math.floor((h - 2) / 2));
|
||||
}
|
||||
|
||||
/** Now Playing progress bar: grey track, blue fill with a glossy top half. */
|
||||
export function drawProgress(g, x, y, w, h, fraction) {
|
||||
g.save();
|
||||
drawBarTrack(g, x, y, w, h);
|
||||
drawBarFill(g, w, h, fraction);
|
||||
drawBarBorder(g, w, h);
|
||||
g.restore();
|
||||
}
|
||||
|
||||
/** Scrubber: the empty track with a 10x14 white diamond at the play position. */
|
||||
export function drawScrubber(g, x, y, w, h, fraction) {
|
||||
g.save();
|
||||
drawBarTrack(g, x, y, w, h);
|
||||
drawBarBorder(g, w, h);
|
||||
const f = Math.min(1, Math.max(0, fraction || 0));
|
||||
const cx = 1 + (w - 2) * f;
|
||||
const cy = h / 2;
|
||||
g.beginPath();
|
||||
g.moveTo(cx, cy - 7);
|
||||
g.lineTo(cx + 5, cy);
|
||||
g.lineTo(cx, cy + 7);
|
||||
g.lineTo(cx - 5, cy);
|
||||
g.closePath();
|
||||
g.fillStyle = '#ffffff';
|
||||
g.fill();
|
||||
g.strokeStyle = '#000000';
|
||||
g.lineWidth = 1;
|
||||
g.lineJoin = 'miter';
|
||||
g.stroke();
|
||||
g.restore();
|
||||
}
|
||||
|
||||
/**
|
||||
* Volume row spanning (x, y, w, 14): a small speaker, a 10px bar from x+18 to x+w-18 filled
|
||||
* to `fraction`, and a speaker with waves at the right. Now Playing calls it as (10, 180, 300, f).
|
||||
*/
|
||||
export function drawVolume(g, x, y, w, fraction) {
|
||||
drawSpeaker(g, x, y + 7, C.text, false);
|
||||
drawProgress(g, x + 18, y + 2, w - 36, 10, fraction);
|
||||
drawSpeaker(g, x + w - 12, y + 7, C.text, true);
|
||||
}
|
||||
|
||||
function starPath(g, cx, cy, ro, ri) {
|
||||
g.beginPath();
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const r = i % 2 ? ri : ro;
|
||||
const a = -Math.PI / 2 + i * Math.PI / 5;
|
||||
const px = cx + r * Math.cos(a);
|
||||
const py = cy + r * Math.sin(a);
|
||||
if (i) g.lineTo(px, py); else g.moveTo(px, py);
|
||||
}
|
||||
g.closePath();
|
||||
}
|
||||
|
||||
/** Five rating marks centered on (cx, cy), 20px apart: a star when rated, a dot when not. */
|
||||
export function drawStars(g, cx, cy, rating, color = C.text) {
|
||||
g.save();
|
||||
g.fillStyle = color;
|
||||
const n = Math.round(rating || 0);
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const x = cx + (i - 2) * 20;
|
||||
if (i < n) {
|
||||
starPath(g, x, cy, 6.5, 2.7);
|
||||
g.fill();
|
||||
} else {
|
||||
g.beginPath();
|
||||
g.arc(x, cy, 1.6, 0, Math.PI * 2);
|
||||
g.fill();
|
||||
}
|
||||
}
|
||||
g.restore();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- text helpers
|
||||
|
||||
/** 'm:ss', or 'h:mm:ss' at an hour or more. Negative and NaN read as 0:00. */
|
||||
export function formatTime(sec) {
|
||||
let s = Math.floor(Number.isFinite(sec) && sec > 0 ? sec : 0);
|
||||
const h = Math.floor(s / 3600);
|
||||
s -= h * 3600;
|
||||
const m = Math.floor(s / 60);
|
||||
s -= m * 60;
|
||||
const ss = s < 10 ? '0' + s : String(s);
|
||||
if (h > 0) return h + ':' + (m < 10 ? '0' + m : m) + ':' + ss;
|
||||
return m + ':' + ss;
|
||||
}
|
||||
|
||||
/**
|
||||
* Marquee x offset (<= 0) for an overflowing label: rests ANIM.marqueeDelayMs at the start,
|
||||
* scrolls left at ANIM.marqueeSpeed px/s until the second copy (ANIM.marqueeGap behind) reaches
|
||||
* the start, then rests again. 0 while resting or when the text fits.
|
||||
*/
|
||||
export function marquee(now, startMs, textW, boxW) {
|
||||
if (textW <= boxW) return 0;
|
||||
const period = textW + ANIM.marqueeGap;
|
||||
const cycle = ANIM.marqueeDelayMs + (period / ANIM.marqueeSpeed) * 1000;
|
||||
const t = (now - startMs) % cycle;
|
||||
if (!(t > ANIM.marqueeDelayMs)) return 0;
|
||||
return -((t - ANIM.marqueeDelayMs) / 1000) * ANIM.marqueeSpeed;
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
// View-dependent reflections for the assPod's front glass and its polished steel back: what a
|
||||
// glass or metal shader would do, painted into the two live panels SteamVR shows over the 3D model
|
||||
// (SteamVR's render models take no shaders of ours).
|
||||
//
|
||||
// Each update samples a coarse grid over each face: the eye's ray to that point, mirrored about
|
||||
// the face's normal, looks up a studio environment (a bright sky, a dark horizon band, a pale floor
|
||||
// and one key light, fixed in the room). The front stores it as a white sheen whose strength
|
||||
// follows Fresnel (faint face-on, strong at grazing angles); the back as the steel's colour. The
|
||||
// grids are tiny canvases the faces scale up smoothly, so a frame costs a few hundred samples.
|
||||
|
||||
const COLS = 14;
|
||||
const ROWS = 24;
|
||||
const KEY = norm([-0.45, 0.8, -0.4]); // the key light's direction (room frame, y up)
|
||||
|
||||
function norm(v) { const l = Math.hypot(v[0], v[1], v[2]) || 1; return [v[0] / l, v[1] / l, v[2] / l]; }
|
||||
const dot = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
|
||||
export function qrot(q, v) { // q = [w, x, y, z]
|
||||
const u = [q[1], q[2], q[3]];
|
||||
const cr = (p, r) => [p[1] * r[2] - p[2] * r[1], p[2] * r[0] - p[0] * r[2], p[0] * r[1] - p[1] * r[0]];
|
||||
const t = cr(u, v).map((x) => 2 * x);
|
||||
const ut = cr(u, t);
|
||||
return [v[0] + q[0] * t[0] + ut[0], v[1] + q[0] * t[1] + ut[1], v[2] + q[0] * t[2] + ut[2]];
|
||||
}
|
||||
export const qmul = (a, b) => [
|
||||
a[0] * b[0] - a[1] * b[1] - a[2] * b[2] - a[3] * b[3],
|
||||
a[0] * b[1] + a[1] * b[0] + a[2] * b[3] - a[3] * b[2],
|
||||
a[0] * b[2] - a[1] * b[3] + a[2] * b[0] + a[3] * b[1],
|
||||
a[0] * b[3] + a[1] * b[2] - a[2] * b[1] + a[3] * b[0],
|
||||
];
|
||||
|
||||
// The studio: brightness 0..1.2 for a reflected direction r (unit, y up).
|
||||
function env(r) {
|
||||
const y = r[1];
|
||||
let v;
|
||||
if (y > 0.3) v = 0.92 + 0.08 * Math.min(1, (y - 0.3) / 0.5);
|
||||
else if (y > 0.04) v = 0.42 + 0.5 * ((y - 0.04) / 0.26);
|
||||
else if (y > -0.1) v = 0.16 + 0.26 * ((y + 0.1) / 0.14);
|
||||
else v = 0.55 + 0.2 * Math.min(1, (-y - 0.1) / 0.6);
|
||||
const k = dot(r, KEY);
|
||||
v += 0.9 * Math.exp(-(1 - k) / 0.012);
|
||||
return v;
|
||||
}
|
||||
|
||||
export const qconj = (q) => [q[0], -q[1], -q[2], -q[3]];
|
||||
|
||||
export function pose(path) {
|
||||
try {
|
||||
const [p] = VRHTML.GetPose(path, 1); // TrackingUniverseStanding
|
||||
if (!p || !p.bPoseIsValid || !p.xfDeviceToAbsoluteTracking) return null;
|
||||
const x = p.xfDeviceToAbsoluteTracking;
|
||||
return { t: [x.translation.x, x.translation.y, x.translation.z], q: [x.rotation.w, x.rotation.x, x.rotation.y, x.rotation.z] };
|
||||
} catch (_) { return null; }
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ w: number, h: number, d: number }} body metres
|
||||
*/
|
||||
export function createReflector(body) {
|
||||
const front = document.createElement('canvas');
|
||||
front.width = COLS;
|
||||
front.height = ROWS;
|
||||
const back = document.createElement('canvas');
|
||||
back.width = COLS;
|
||||
back.height = ROWS;
|
||||
const fg = front.getContext('2d');
|
||||
const bg = back.getContext('2d');
|
||||
const fimg = fg.createImageData(COLS, ROWS);
|
||||
const bimg = bg.createImageData(COLS, ROWS);
|
||||
let version = 0;
|
||||
let lastKey = '';
|
||||
|
||||
/**
|
||||
* @param {string} hand 'left' | 'right'
|
||||
* @param {{ rot: number[], centre: number[] }} hold the body in the controller's frame (sg.js)
|
||||
* @returns {boolean} whether the reflections changed
|
||||
*/
|
||||
function update(hand, hold) {
|
||||
const hp = pose('/user/hand/' + hand);
|
||||
const head = pose('/user/head');
|
||||
if (!hp || !head || !hold) return false;
|
||||
const rot = qmul(hp.q, hold.rot);
|
||||
const c = hp.t.map((v, i) => v + qrot(hp.q, hold.centre)[i]);
|
||||
const ax = qrot(rot, [1, 0, 0]);
|
||||
const ay = qrot(rot, [0, 1, 0]);
|
||||
const n = qrot(rot, [0, 0, 1]);
|
||||
const eye = head.t;
|
||||
// skip when nothing moved enough to matter (0.5 mm, ~0.3 degrees)
|
||||
const key = [...c, ...n, ...ay, ...eye].map((v) => Math.round(v * 2000)).join(',');
|
||||
if (key === lastKey) return false;
|
||||
lastKey = key;
|
||||
for (const [img, side] of [[fimg, 1], [bimg, -1]]) {
|
||||
const nn = n.map((v) => v * side);
|
||||
for (let j = 0; j < ROWS; j++) {
|
||||
for (let i = 0; i < COLS; i++) {
|
||||
// the back is seen from behind: its image's x runs the other way
|
||||
const u = (i + 0.5) / COLS - 0.5;
|
||||
const lx = (side > 0 ? u : -u) * body.w;
|
||||
const ly = (0.5 - (j + 0.5) / ROWS) * body.h;
|
||||
const p = [0, 1, 2].map((k) => c[k] + ax[k] * lx + ay[k] * ly + nn[k] * body.d / 2);
|
||||
const v = norm(p.map((x, k) => x - eye[k]));
|
||||
const vn = dot(v, nn);
|
||||
const r = norm(v.map((x, k) => x - 2 * vn * nn[k]));
|
||||
const e = env(r);
|
||||
const o = (j * COLS + i) * 4;
|
||||
if (side > 0) {
|
||||
// glass over a black plate: a white sheen, Fresnel-weighted
|
||||
const cos = Math.max(0, -vn);
|
||||
const fr = 0.05 + 0.55 * Math.pow(1 - cos, 4);
|
||||
const a = Math.min(1, fr * e * 1.4);
|
||||
img.data[o] = 255; img.data[o + 1] = 255; img.data[o + 2] = 255;
|
||||
img.data[o + 3] = Math.round(a * 255);
|
||||
} else {
|
||||
// polished steel: the environment itself, slightly cool, never quite black
|
||||
const l = Math.min(1, 0.12 + 0.86 * e);
|
||||
img.data[o] = Math.round(232 * l); img.data[o + 1] = Math.round(236 * l); img.data[o + 2] = Math.round(244 * l);
|
||||
img.data[o + 3] = 255;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
fg.putImageData(fimg, 0, 0);
|
||||
bg.putImageData(bimg, 0, 0);
|
||||
version++;
|
||||
return true;
|
||||
}
|
||||
|
||||
return { update, front, back, get version() { return version; } };
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
// 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.
|
||||
import { Emitter } from './util/emitter.js';
|
||||
|
||||
export const DEFAULTS = Object.freeze({
|
||||
shuffle: 'off', // 'off' | 'songs' | 'albums'
|
||||
repeat: 'off', // 'off' | 'one' | 'all'
|
||||
backlightTimer: 30, // seconds; 0 = Off (never lit), -1 = Always On
|
||||
brightness: 0.75, // 0..1
|
||||
eq: 'Off', // VLC equalizer preset name (os/screens/settings.js EQ_NAMES)
|
||||
clicker: true,
|
||||
volume: 0.6, // 0..1
|
||||
timeFormat: '12', // '12' | '24'
|
||||
finish: 'black', // 'black' | 'white': the body
|
||||
ratings: {}, // trackId -> 0..5 (always replace the object, never mutate it)
|
||||
plays: {}, // trackId -> [playCount, lastPlayed]
|
||||
});
|
||||
|
||||
class Settings extends Emitter {
|
||||
#values = { ...DEFAULTS, ratings: {}, plays: {} };
|
||||
get(key) { return this.#values[key]; }
|
||||
set(key, value) {
|
||||
if (Object.is(this.#values[key], value)) return;
|
||||
this.#values[key] = value;
|
||||
this.emit('change', { key, value });
|
||||
}
|
||||
/** A value for this session only; the same as set() here (nothing is stored). */
|
||||
override(key, value) { this.set(key, value); }
|
||||
reset() {
|
||||
this.#values = { ...DEFAULTS, ratings: {}, plays: {} };
|
||||
this.emit('change', { key: '*', value: null });
|
||||
}
|
||||
all() { return { ...this.#values }; }
|
||||
}
|
||||
export const settings = new Settings();
|
||||
@@ -0,0 +1,349 @@
|
||||
// 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
|
||||
// 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
|
||||
// vsg-transform (HOLD) the body's centre in the hand (wheel over the stick)
|
||||
// vsg-transform scale > rendermodel x7 the body, the model's metres scaled up
|
||||
// vsg-transform (front + 0.25 mm) > panel the screen, the touch marker, the glass's reflection
|
||||
// vsg-transform (back - 0.25 mm, turned) > panel the steel back's live reflection (reflect.js)
|
||||
// vsg-transform parent-id=<Steam main window>_BottomCenter follows the window (frame units)
|
||||
// vsg-transform (BLOCK) > panel (invisible but intersectable: the laser stops here, so the
|
||||
// faded-out menu behind it is not used)
|
||||
// vsg-transform (CAPTION) > panel ("≡ to close")
|
||||
//
|
||||
// The menu itself fades out (Steam's windows in CSS, the native glass through lgs_sg.js fade()).
|
||||
//
|
||||
// The panels show parts of systemui's own page (the canvases this script adds there), the way
|
||||
// SteamVR's own panels do. Changes reach the compositor when the page's scene-graph scheduler runs
|
||||
// (the export of the webpack module containing "update_scene_graph", as in lgs_sg.js); every node
|
||||
// removed is handed to the module's retire export first: a removed node that is not retired stays
|
||||
// in the compositor, and enough of those stop new panels from rendering (docs/phase2/capabilities/
|
||||
// spatial.md §9).
|
||||
|
||||
const SYSTEMUI_KEY = 'system.systemui';
|
||||
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)
|
||||
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).
|
||||
const DEFAULT_HOLD = {
|
||||
rot: [0.4629683756855247, -0.8817248681122181, -0.05644956223246437, 0.07095886474063698],
|
||||
centre: [-0.022855362986507255, -0.014096345082941181, 0.021905882792809507],
|
||||
};
|
||||
|
||||
const r6 = (v) => Math.round(v * 1e6) / 1e6;
|
||||
|
||||
// Rotation quaternion (w x y z) of the frame whose columns are x, y, z.
|
||||
function quatFromBasis(x, y, z) {
|
||||
const m00 = x[0], m11 = y[1], m22 = z[2];
|
||||
const tr = m00 + m11 + m22;
|
||||
let w, qx, qy, qz;
|
||||
if (tr > 0) {
|
||||
const s = Math.sqrt(tr + 1) * 2;
|
||||
w = s / 4; qx = (y[2] - z[1]) / s; qy = (z[0] - x[2]) / s; qz = (x[1] - y[0]) / s;
|
||||
} else if (m00 > m11 && m00 > m22) {
|
||||
const s = Math.sqrt(1 + m00 - m11 - m22) * 2;
|
||||
w = (y[2] - z[1]) / s; qx = s / 4; qy = (y[0] + x[1]) / s; qz = (z[0] + x[2]) / s;
|
||||
} else if (m11 > m22) {
|
||||
const s = Math.sqrt(1 + m11 - m00 - m22) * 2;
|
||||
w = (z[0] - x[2]) / s; qx = (y[0] + x[1]) / s; qy = s / 4; qz = (z[1] + y[2]) / s;
|
||||
} else {
|
||||
const s = Math.sqrt(1 + m22 - m00 - m11) * 2;
|
||||
w = (x[1] - y[0]) / s; qx = (z[0] + x[2]) / s; qy = (z[1] + y[2]) / s; qz = s / 4;
|
||||
}
|
||||
return [w, qx, qy, qz];
|
||||
}
|
||||
|
||||
const qmul = (a, b) => [
|
||||
a[0] * b[0] - a[1] * b[1] - a[2] * b[2] - a[3] * b[3],
|
||||
a[0] * b[1] + a[1] * b[0] + a[2] * b[3] - a[3] * b[2],
|
||||
a[0] * b[2] - a[1] * b[3] + a[2] * b[0] + a[3] * b[1],
|
||||
a[0] * b[3] + a[1] * b[2] - a[2] * b[1] + a[3] * b[0],
|
||||
];
|
||||
function qrot(q, v) {
|
||||
const u = [q[1], q[2], q[3]];
|
||||
const cr = (p, r) => [p[1] * r[2] - p[2] * r[1], p[2] * r[0] - p[0] * r[2], p[0] * r[1] - p[1] * r[0]];
|
||||
const t = cr(u, v).map((x) => 2 * x);
|
||||
const ut = cr(u, t);
|
||||
return [v[0] + q[0] * t[0] + ut[0], v[1] + q[0] * t[1] + ut[1], v[2] + q[0] * t[2] + ut[2]];
|
||||
}
|
||||
|
||||
export function createSceneGraph() {
|
||||
const W = window;
|
||||
const sched = { push: null, retire: null, error: null };
|
||||
(function locate() {
|
||||
let req = null;
|
||||
try {
|
||||
const chunks = W.webpackChunkvrwebui;
|
||||
const entry = [[Symbol('lgs-asspod')], {}, (r) => { req = r; }];
|
||||
chunks.push(entry);
|
||||
for (let i = chunks.length - 1; i >= 0; i--) if (chunks[i] === entry) chunks.splice(i, 1);
|
||||
} catch (e) { sched.error = 'no webpackChunkvrwebui'; return; }
|
||||
if (!req) { sched.error = 'webpack runtime did not answer'; return; }
|
||||
const RETIRE = /^function\s*\w*\((\w+)\)\{\w+\.push\(\1\),\w+\(\)\}$/;
|
||||
const tryModule = (id) => {
|
||||
let src;
|
||||
try { src = String(req.m[id]); } catch (_) { return false; }
|
||||
if (src.indexOf('"update_scene_graph"') < 0) return false;
|
||||
let ex;
|
||||
try { ex = req(id); } catch (_) { return false; }
|
||||
for (const k of Object.keys(ex)) {
|
||||
let f;
|
||||
try { f = ex[k]; } catch (_) { continue; }
|
||||
if (typeof f !== 'function') continue;
|
||||
const s = String(f);
|
||||
if (s.indexOf('update_scene_graph') >= 0) sched.push = f;
|
||||
else if (RETIRE.test(s)) sched.retire = f;
|
||||
}
|
||||
return !!sched.push;
|
||||
};
|
||||
if (!tryModule('5723')) for (const id of Object.keys(req.m)) if (tryModule(id)) break;
|
||||
if (!sched.push) sched.error = 'scene-graph scheduler not found';
|
||||
else if (!sched.retire) sched.error = 'retire export not found'; // fail closed: no nodes
|
||||
})();
|
||||
|
||||
const st = { root: null, nodes: [], retireQ: [], pushes: 0, hand: null, shown: false, errors: [] };
|
||||
|
||||
function sgid() {
|
||||
let id = 0;
|
||||
try { id = W.VRHTML && VRHTML.NextSGID ? VRHTML.NextSGID() : 0; } catch (_) { /* fall back */ }
|
||||
return id || 910000000 + Math.floor(Math.random() * 1e8);
|
||||
}
|
||||
function vnode(type, props) {
|
||||
const el = document.createElement('vsg-node');
|
||||
el.setAttribute('vsg-type', type);
|
||||
const id = sgid();
|
||||
el.setAttribute('sgid', String(id));
|
||||
el.__lgsSgid = id;
|
||||
el.__lgsProps = props;
|
||||
el.buildNode = (ctx) => [Object.assign({}, ctx), { type, properties: Object.assign({ sgid: id }, el.__lgsProps) }];
|
||||
st.nodes.push(el);
|
||||
return el;
|
||||
}
|
||||
function vxf(t, rot, attrs) {
|
||||
const el = document.createElement('vsg-transform');
|
||||
el.setAttribute('translation', t.map(r6).join(' '));
|
||||
el.setAttribute('rotation', (rot || [1, 0, 0, 0]).map(r6).join(' '));
|
||||
el.setAttribute('scale', '1 1 1');
|
||||
for (const [k, v] of Object.entries(attrs || {})) el.setAttribute(k, v);
|
||||
el.setAttribute('vsg-type', 'base');
|
||||
const id = sgid();
|
||||
el.setAttribute('sgid', String(id));
|
||||
el.__lgsSgid = id;
|
||||
st.nodes.push(el);
|
||||
return el;
|
||||
}
|
||||
// A panel showing the page rect r (CSS px) of systemui's own texture at `mpp` units per pixel.
|
||||
function pagePanel(r, mpp, name, extra) {
|
||||
const iw = W.innerWidth || 1;
|
||||
const ih = W.innerHeight || 1;
|
||||
return vnode('panel', Object.assign({
|
||||
key: SYSTEMUI_KEY,
|
||||
uv_min: [r6(r.x / iw), r6(r.y / ih)],
|
||||
uv_max: [r6((r.x + r.w) / iw), r6((r.y + r.h) / ih)],
|
||||
'meters-per-pixel': mpp,
|
||||
origin: [0, 0],
|
||||
interactive: false,
|
||||
visibility: 0,
|
||||
reflect: 0,
|
||||
debug_name: name,
|
||||
}, extra || {}));
|
||||
}
|
||||
|
||||
function attach() {
|
||||
const app = document.querySelector('vsg-app');
|
||||
if (!app) return false;
|
||||
if (!st.root) {
|
||||
st.root = document.createElement('div');
|
||||
st.root.id = 'lgs-asspod-sg';
|
||||
st.root.setAttribute('aria-hidden', 'true');
|
||||
st.root.style.cssText = 'display:none';
|
||||
}
|
||||
if (st.root.parentNode !== app) app.appendChild(st.root);
|
||||
return true;
|
||||
}
|
||||
|
||||
function push() {
|
||||
if (!sched.push) return;
|
||||
try {
|
||||
const q = st.retireQ.splice(0);
|
||||
if (sched.retire) for (const id of q) { try { sched.retire(id); } catch (_) { /* best effort */ } }
|
||||
sched.push();
|
||||
st.pushes++;
|
||||
} catch (e) {
|
||||
st.errors.push(String(e && e.message || e));
|
||||
if (st.errors.length > 5) st.errors.shift();
|
||||
}
|
||||
}
|
||||
|
||||
function clearNodes() {
|
||||
for (const el of st.nodes) if (el.__lgsSgid) st.retireQ.push(el.__lgsSgid);
|
||||
st.nodes = [];
|
||||
if (st.root) st.root.textContent = '';
|
||||
}
|
||||
|
||||
// The body drawn by podd (native/podd, real shaders): the screen panel first, then a box of six
|
||||
// stereo panels around the body showing podd's texture, each face one eye pair (SteamVR's Parallel
|
||||
// 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 };
|
||||
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]);
|
||||
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),
|
||||
origin: [0, 0],
|
||||
interactive: false,
|
||||
visibility: 0,
|
||||
reflect: 0,
|
||||
stereoscopy: 1,
|
||||
debug_name: 'lgs-asspod-video',
|
||||
}));
|
||||
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'));
|
||||
parent.appendChild(front);
|
||||
const [hx, hy, hz] = pd.box;
|
||||
const faces = {
|
||||
front: [[0, 0, hz], [1, 0, 0], [0, 1, 0], [0, 0, 1], 2 * hx],
|
||||
back: [[0, 0, -hz], [-1, 0, 0], [0, 1, 0], [0, 0, -1], 2 * hx],
|
||||
right: [[hx, 0, 0], [0, 0, -1], [0, 1, 0], [1, 0, 0], 2 * hz],
|
||||
left: [[-hx, 0, 0], [0, 0, 1], [0, 1, 0], [-1, 0, 0], 2 * hz],
|
||||
top: [[0, hy, 0], [1, 0, 0], [0, 0, -1], [0, 1, 0], 2 * hx],
|
||||
bottom: [[0, -hy, 0], [1, 0, 0], [0, 0, 1], [0, -1, 0], 2 * hx],
|
||||
};
|
||||
for (const [name, [c, R, U, N, w]] of Object.entries(faces)) {
|
||||
const t = pd.tiles[name];
|
||||
if (!t) continue;
|
||||
const [tx, ty, tw, th] = t;
|
||||
const p = pd.pad || 0;
|
||||
const xf = vxf(c, quatFromBasis(R, U, N));
|
||||
// both eyes with their overscan: the panel is the face grown by `pad` px on every side
|
||||
xf.appendChild(vnode('panel', {
|
||||
key: pd.key,
|
||||
uv_min: [r6((tx - p) / pd.texW), r6((ty - p) / pd.texH)],
|
||||
uv_max: [r6((tx + 2 * tw + 3 * p) / pd.texW), r6((ty + th + p) / pd.texH)],
|
||||
'meters-per-pixel': r6(w / tw),
|
||||
origin: [0, 0],
|
||||
interactive: false,
|
||||
visibility: 0,
|
||||
reflect: 0,
|
||||
stereoscopy: 1,
|
||||
'no-depth-write': true,
|
||||
debug_name: 'lgs-asspod-' + name,
|
||||
}));
|
||||
parent.appendChild(xf);
|
||||
}
|
||||
}
|
||||
|
||||
// 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;
|
||||
const fin = finish === 'white' ? 'white' : 'black';
|
||||
const scaled = vxf([0, 0, 0]);
|
||||
scaled.setAttribute('scale', [k, k, k].map(r6).join(' '));
|
||||
const parts = ['asspod_steel_back', 'asspod_steel_edge', 'asspod_ports', `asspod_plate_${fin}`, `asspod_well_${fin}`,
|
||||
`asspod_wheel_${fin}`, `asspod_center_${fin}`];
|
||||
for (const name of parts) scaled.appendChild(vnode('rendermodel', { source: `${modelDir}/${name}/${name}.obj` }));
|
||||
parent.appendChild(scaled);
|
||||
const front = vxf([0, 0, b.d / 2 + OVERLAY_DZ * k]);
|
||||
front.appendChild(pagePanel(rects.face, b.w / rects.face.w, 'lgs-asspod-screen'));
|
||||
parent.appendChild(front);
|
||||
const back = vxf([0, 0, -(b.d / 2 + OVERLAY_DZ * k)], [0, 0, 1, 0]);
|
||||
back.appendChild(pagePanel(rects.back, b.w / rects.back.w, 'lgs-asspod-back'));
|
||||
parent.appendChild(back);
|
||||
}
|
||||
|
||||
// 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) {
|
||||
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]] };
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
* 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
|
||||
* @returns {{ rot: number[], centre: number[] } | null} the body in the controller's frame (for reflect.js)
|
||||
*/
|
||||
function show(o) {
|
||||
if (sched.error) return null;
|
||||
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);
|
||||
// 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);
|
||||
hand.appendChild(hold);
|
||||
st.root.appendChild(hand);
|
||||
// the sheet and the caption, on Steam's window
|
||||
const curve = { 'curvature-origin-id': MAIN + '_CurvatureOrigin' };
|
||||
const win = vxf([0, 0, 0], null, { 'parent-id': MAIN + '_BottomCenter' });
|
||||
const sheet = vxf([0, SHEET.y, SHEET.z]);
|
||||
sheet.appendChild(pagePanel(o.rects.dim, SHEET.w / o.rects.dim.w, 'lgs-asspod-block',
|
||||
Object.assign({ interactive: true, visibility: 3 }, curve)));
|
||||
const cap = vxf([0, CAPTION.y, CAPTION.z]);
|
||||
cap.appendChild(pagePanel(o.rects.text, CAPTION.w / o.rects.text.w, 'lgs-asspod-caption', curve));
|
||||
win.appendChild(sheet);
|
||||
win.appendChild(cap);
|
||||
st.root.appendChild(win);
|
||||
st.hand = side;
|
||||
st.shown = true;
|
||||
push();
|
||||
return { rot, centre };
|
||||
}
|
||||
|
||||
function hide() {
|
||||
if (!st.shown && !st.nodes.length) return;
|
||||
clearNodes();
|
||||
st.shown = false;
|
||||
st.hand = null;
|
||||
push();
|
||||
}
|
||||
|
||||
function destroy() {
|
||||
hide();
|
||||
if (st.root) { st.root.remove(); st.root = null; }
|
||||
}
|
||||
|
||||
return {
|
||||
show, hide, destroy,
|
||||
get shown() { return st.shown; },
|
||||
status: () => ({ error: sched.error, shown: st.shown, hand: st.hand, nodes: st.nodes.length, pushes: st.pushes,
|
||||
attached: !!(st.root && st.root.isConnected), errors: st.errors.slice() }),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
export class Emitter {
|
||||
#listeners = new Map();
|
||||
on(type, fn) {
|
||||
if (!this.#listeners.has(type)) this.#listeners.set(type, new Set());
|
||||
this.#listeners.get(type).add(fn);
|
||||
return () => this.off(type, fn);
|
||||
}
|
||||
off(type, fn) { this.#listeners.get(type)?.delete(fn); }
|
||||
emit(type, payload) { for (const fn of [...(this.#listeners.get(type) ?? [])]) fn(payload); }
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
export function hashString(s) {
|
||||
let h = 0x811c9dc5;
|
||||
for (let i = 0; i < s.length; i++) h = Math.imul(h ^ s.charCodeAt(i), 0x01000193);
|
||||
return h >>> 0;
|
||||
}
|
||||
export function hash32(...ints) {
|
||||
let h = 0x811c9dc5;
|
||||
for (const n of ints) {
|
||||
h = Math.imul(h ^ (n >>> 0), 0x01000193);
|
||||
h ^= h >>> 15; h = Math.imul(h, 0x2c1b3c6d); h ^= h >>> 12;
|
||||
}
|
||||
return h >>> 0;
|
||||
}
|
||||
export function mulberry32(seed) {
|
||||
let a = seed >>> 0;
|
||||
return () => {
|
||||
a = (a + 0x6d2b79f5) >>> 0;
|
||||
let t = a;
|
||||
t = Math.imul(t ^ (t >>> 15), t | 1);
|
||||
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
}
|
||||
export function createRng(seed) {
|
||||
const next = mulberry32(seed);
|
||||
return {
|
||||
next,
|
||||
range: (lo, hi) => lo + (hi - lo) * next(),
|
||||
int: (lo, hi) => lo + Math.floor(next() * (hi - lo + 1)), // inclusive
|
||||
pick: (arr) => arr[Math.floor(next() * arr.length)],
|
||||
chance: (p) => next() < p,
|
||||
shuffle: (arr) => {
|
||||
const a = arr.slice();
|
||||
for (let i = a.length - 1; i > 0; i--) { const j = Math.floor(next() * (i + 1)); [a[i], a[j]] = [a[j], a[i]]; }
|
||||
return a;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,369 @@
|
||||
// Unit tests for the click wheel state machine. Run: node --test device/asspod/tests/
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { angleToPart, createClickWheel, DEFAULTS, SECTOR_PARTS } from '../src/input/clickwheel.js';
|
||||
|
||||
// ---------------------------------------------------------------- helpers
|
||||
|
||||
const DEG = Math.PI / 180;
|
||||
const DT = 1 / 72;
|
||||
|
||||
/** Stick sample at a wheel angle (degrees, 0 = top, clockwise) and radius. */
|
||||
function at(deg, r = 1, pressed = false) {
|
||||
return { x: r * Math.sin(deg * DEG), y: r * Math.cos(deg * DEG), pressed };
|
||||
}
|
||||
|
||||
function scrolls(events) {
|
||||
return events.filter((e) => e.type === 'scroll').reduce((s, e) => s + e.delta, 0);
|
||||
}
|
||||
|
||||
function ofType(events, type) {
|
||||
return events.filter((e) => e.type === type);
|
||||
}
|
||||
|
||||
/** Feeds a sequence of samples and returns every event produced, in order. */
|
||||
function feed(wheel, samples, dt = DT) {
|
||||
const all = [];
|
||||
for (const s of samples) all.push(...wheel.update(s, dt));
|
||||
return all;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- sectors
|
||||
|
||||
test('exports match the contract', () => {
|
||||
assert.deepEqual([...SECTOR_PARTS], ['menu', 'next', 'play', 'prev']);
|
||||
assert.equal(DEFAULTS.step, Math.PI / 12);
|
||||
assert.equal(DEFAULTS.touchOn, 0.45);
|
||||
assert.equal(DEFAULTS.touchOff, 0.30);
|
||||
assert.equal(DEFAULTS.scrollMinR, 0.6);
|
||||
assert.equal(DEFAULTS.centerR, 0.45);
|
||||
assert.equal(DEFAULTS.holdS, 0.5);
|
||||
assert.equal(DEFAULTS.longHoldS, 2.0);
|
||||
});
|
||||
|
||||
test('angleToPart: the four sector centers', () => {
|
||||
assert.equal(angleToPart(0), 'menu');
|
||||
assert.equal(angleToPart(90 * DEG), 'next');
|
||||
assert.equal(angleToPart(180 * DEG), 'play');
|
||||
assert.equal(angleToPart(270 * DEG), 'prev');
|
||||
});
|
||||
|
||||
test('angleToPart: boundaries at 44 and 46 degrees around each sector', () => {
|
||||
assert.equal(angleToPart(44 * DEG), 'menu');
|
||||
assert.equal(angleToPart(46 * DEG), 'next');
|
||||
assert.equal(angleToPart(134 * DEG), 'next');
|
||||
assert.equal(angleToPart(136 * DEG), 'play');
|
||||
assert.equal(angleToPart(224 * DEG), 'play');
|
||||
assert.equal(angleToPart(226 * DEG), 'prev');
|
||||
assert.equal(angleToPart(314 * DEG), 'prev');
|
||||
assert.equal(angleToPart(316 * DEG), 'menu');
|
||||
assert.equal(angleToPart(-44 * DEG), 'menu');
|
||||
assert.equal(angleToPart(-46 * DEG), 'prev');
|
||||
assert.equal(angleToPart((360 + 90) * DEG), 'next');
|
||||
});
|
||||
|
||||
test('pressing the stick toward each sector presses that part', () => {
|
||||
for (const [deg, part] of [[0, 'menu'], [90, 'next'], [180, 'play'], [270, 'prev'], [44, 'menu'], [46, 'next']]) {
|
||||
const w = createClickWheel();
|
||||
w.update(at(deg), DT);
|
||||
const ev = w.update(at(deg, 1, true), DT);
|
||||
assert.deepEqual(ofType(ev, 'press'), [{ type: 'press', part }], `at ${deg} degrees`);
|
||||
assert.equal(w.state.pressedPart, part);
|
||||
}
|
||||
});
|
||||
|
||||
test('XR convention: stick pushed forward (axes[3] = -1, y = +1) is MENU at angle 0', () => {
|
||||
const w = createClickWheel();
|
||||
const ev = w.update({ x: 0, y: -(-1), pressed: false }, DT);
|
||||
assert.equal(ev[0].type, 'touch');
|
||||
assert.ok(Math.abs(ev[0].angle) < 1e-9);
|
||||
assert.deepEqual(ofType(w.update({ x: 0, y: 1, pressed: true }, DT), 'press'), [{ type: 'press', part: 'menu' }]);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- center press
|
||||
|
||||
test('a centered press is select, with no touch events', () => {
|
||||
const w = createClickWheel();
|
||||
assert.equal(w.update({ x: 0, y: 0, pressed: false }, DT).length, 0);
|
||||
const ev = w.update({ x: 0.1, y: -0.2, pressed: true }, DT);
|
||||
assert.deepEqual(ev, [{ type: 'press', part: 'select' }]);
|
||||
assert.deepEqual(w.update({ x: 0, y: 0, pressed: false }, DT), [{ type: 'release', part: 'select', held: false }]);
|
||||
});
|
||||
|
||||
test('a press inside the hysteresis band (touching, r < centerR) is select', () => {
|
||||
const w = createClickWheel();
|
||||
w.update(at(90, 1), DT);
|
||||
w.update(at(90, 0.4), DT); // still touching (above touchOff) but inside the center button
|
||||
assert.equal(w.state.touching, true);
|
||||
assert.deepEqual(ofType(w.update(at(90, 0.4, true), DT), 'press'), [{ type: 'press', part: 'select' }]);
|
||||
});
|
||||
|
||||
test('null input means untouched and unpressed', () => {
|
||||
const w = createClickWheel();
|
||||
assert.deepEqual(w.update(null, DT), []);
|
||||
w.update(at(180, 1, true), DT);
|
||||
const ev = w.update(null, DT);
|
||||
assert.deepEqual(ev, [{ type: 'release', part: 'play', held: false }, { type: 'touch', active: false }]);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- scrolling
|
||||
|
||||
test('one full clockwise turn in 2 degree steps scrolls exactly +24', () => {
|
||||
const w = createClickWheel();
|
||||
const samples = [];
|
||||
for (let d = 0; d <= 360; d += 2) samples.push(at(d));
|
||||
const ev = feed(w, samples);
|
||||
assert.equal(scrolls(ev), 24);
|
||||
assert.equal(ofType(ev, 'scroll').length, 24);
|
||||
assert.ok(ofType(ev, 'scroll').every((e) => e.delta === 1));
|
||||
});
|
||||
|
||||
test('one full counter-clockwise turn in 2 degree steps scrolls exactly -24', () => {
|
||||
const w = createClickWheel();
|
||||
const samples = [];
|
||||
for (let d = 0; d >= -360; d -= 2) samples.push(at(d));
|
||||
const ev = feed(w, samples);
|
||||
assert.equal(scrolls(ev), -24);
|
||||
assert.ok(ofType(ev, 'scroll').every((e) => e.delta === -1));
|
||||
});
|
||||
|
||||
test('starting the turn at another angle and crossing 0 still gives 24', () => {
|
||||
const w = createClickWheel();
|
||||
const samples = [];
|
||||
for (let d = 200; d <= 560; d += 2) samples.push(at(d));
|
||||
assert.equal(scrolls(feed(w, samples)), 24);
|
||||
});
|
||||
|
||||
test('two turns at partial radius (0.8) scroll 48', () => {
|
||||
const w = createClickWheel();
|
||||
const samples = [];
|
||||
for (let d = 0; d <= 720; d += 3) samples.push(at(d, 0.8));
|
||||
assert.equal(scrolls(feed(w, samples)), 48);
|
||||
});
|
||||
|
||||
test('+-5 degree jitter never scrolls', () => {
|
||||
for (const base of [0, 37, 90, 180, 359]) {
|
||||
const w = createClickWheel();
|
||||
const samples = [at(base)];
|
||||
for (let i = 0; i < 200; i++) samples.push(at(base + (i % 2 ? 5 : -5)));
|
||||
for (let i = 0; i < 200; i++) samples.push(at(base + 5 * Math.sin(i * 0.7)));
|
||||
assert.equal(ofType(feed(w, samples), 'scroll').length, 0, `base ${base}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('one detent needs a full 15 degrees', () => {
|
||||
const w = createClickWheel();
|
||||
w.update(at(0), DT);
|
||||
assert.equal(scrolls(w.update(at(14), DT)), 0);
|
||||
assert.equal(scrolls(w.update(at(15.5), DT)), 1);
|
||||
// Coming back 15 degrees from the post-scroll remainder does not immediately scroll back.
|
||||
assert.equal(scrolls(w.update(at(5), DT)), 0);
|
||||
assert.equal(scrolls(w.update(at(-0.5), DT)), -1);
|
||||
});
|
||||
|
||||
test('a fast flick emits several scrolls in one frame', () => {
|
||||
const w = createClickWheel();
|
||||
w.update(at(0), DT);
|
||||
const ev = w.update(at(50), DT);
|
||||
assert.equal(scrolls(ev), 3);
|
||||
});
|
||||
|
||||
test('no scroll below scrollMinR even while touching', () => {
|
||||
const w = createClickWheel();
|
||||
const samples = [];
|
||||
for (let d = 0; d <= 360; d += 2) samples.push(at(d, 0.55));
|
||||
const ev = feed(w, samples);
|
||||
assert.equal(w.state.touching, true);
|
||||
assert.equal(ofType(ev, 'scroll').length, 0);
|
||||
});
|
||||
|
||||
test('dipping inside scrollMinR does not count the rotation made there', () => {
|
||||
const w = createClickWheel();
|
||||
w.update(at(0), DT);
|
||||
w.update(at(10, 0.5), DT);
|
||||
w.update(at(170, 0.5), DT); // swing around near the center: ignored
|
||||
assert.equal(scrolls(w.update(at(175, 1), DT)), 0); // back on the ring: re-seeds, no burst
|
||||
assert.equal(scrolls(w.update(at(191, 1), DT)), 1);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- touch hysteresis
|
||||
|
||||
test('touch starts at 0.45 and ends below 0.30', () => {
|
||||
const w = createClickWheel();
|
||||
assert.deepEqual(w.update(at(90, 0.44), DT), []);
|
||||
assert.equal(w.state.touching, false);
|
||||
const start = w.update(at(90, 0.45), DT);
|
||||
assert.equal(start.length, 1);
|
||||
assert.equal(start[0].type, 'touch');
|
||||
assert.equal(start[0].active, true);
|
||||
assert.ok(Math.abs(start[0].angle - Math.PI / 2) < 1e-9);
|
||||
assert.equal(w.state.touching, true);
|
||||
assert.deepEqual(w.update(at(90, 0.35), DT), []);
|
||||
assert.deepEqual(w.update(at(90, 0.30), DT), []);
|
||||
assert.equal(w.state.touching, true);
|
||||
assert.deepEqual(w.update(at(90, 0.29), DT), [{ type: 'touch', active: false }]);
|
||||
assert.equal(w.state.touching, false);
|
||||
assert.deepEqual(w.update(at(90, 0.40), DT), []);
|
||||
assert.equal(w.state.touching, false);
|
||||
});
|
||||
|
||||
test('no scroll on the frame a touch starts, even after a big jump', () => {
|
||||
const w = createClickWheel();
|
||||
w.update(at(0, 0.2), DT); // resting near the center at the top
|
||||
const ev = w.update(at(120, 1), DT); // jumps straight to the ring far away
|
||||
assert.deepEqual(ev.map((e) => e.type), ['touch']);
|
||||
assert.equal(scrolls(w.update(at(122, 1), DT)), 0);
|
||||
assert.equal(scrolls(w.update(at(136, 1), DT)), 1);
|
||||
});
|
||||
|
||||
test('the accumulator resets when a new touch starts', () => {
|
||||
const w = createClickWheel();
|
||||
w.update(at(0), DT);
|
||||
w.update(at(14), DT); // 14 degrees pending
|
||||
w.update(null, DT); // touch ends
|
||||
w.update(at(14), DT); // new touch
|
||||
assert.equal(scrolls(w.update(at(16), DT)), 0); // would have scrolled if 14 degrees had carried over
|
||||
});
|
||||
|
||||
test('state reports radius and angle', () => {
|
||||
const w = createClickWheel();
|
||||
w.update(at(270, 0.8), DT);
|
||||
assert.ok(Math.abs(w.state.radius - 0.8) < 1e-9);
|
||||
assert.ok(Math.abs(w.state.angle - 1.5 * Math.PI) < 1e-9);
|
||||
w.update({ x: 3, y: 0, pressed: false }, DT);
|
||||
assert.equal(w.state.radius, 1); // clamped
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- hold and longhold
|
||||
|
||||
test('hold fires once at 0.5 s and longhold once at 2.0 s, then release reports held', () => {
|
||||
const w = createClickWheel();
|
||||
w.update(at(90), DT);
|
||||
const ev = [...w.update(at(90, 1, true), DT)];
|
||||
let t = 0;
|
||||
let holdAt = -1;
|
||||
let longAt = -1;
|
||||
const step = 1 / 60;
|
||||
while (t < 3) {
|
||||
t += step;
|
||||
for (const e of w.update(at(90, 1, true), step)) {
|
||||
ev.push(e);
|
||||
if (e.type === 'hold') holdAt = t;
|
||||
if (e.type === 'longhold') longAt = t;
|
||||
}
|
||||
}
|
||||
assert.equal(ofType(ev, 'hold').length, 1);
|
||||
assert.equal(ofType(ev, 'longhold').length, 1);
|
||||
assert.ok(holdAt >= 0.5 - 1e-9 && holdAt < 0.5 + step + 1e-9, `hold at ${holdAt}`);
|
||||
assert.ok(longAt >= 2.0 - 1e-9 && longAt < 2.0 + step + 1e-9, `longhold at ${longAt}`);
|
||||
assert.deepEqual(ofType(ev, 'hold')[0], { type: 'hold', part: 'next' });
|
||||
assert.deepEqual(ofType(ev, 'longhold')[0], { type: 'longhold', part: 'next' });
|
||||
assert.deepEqual(w.update(at(90), DT), [{ type: 'release', part: 'next', held: true }]);
|
||||
});
|
||||
|
||||
test('hold timing with exact 0.1 s frames', () => {
|
||||
const w = createClickWheel();
|
||||
w.update(at(180, 1, true), 0.1);
|
||||
const seen = [];
|
||||
for (let i = 1; i <= 25; i++) for (const e of w.update(at(180, 1, true), 0.1)) seen.push([i, e.type]);
|
||||
assert.deepEqual(seen, [[5, 'hold'], [20, 'longhold']]);
|
||||
});
|
||||
|
||||
test('a short press releases with held: false and no hold events', () => {
|
||||
const w = createClickWheel();
|
||||
const ev = feed(w, [at(270), at(270, 1, true), at(270, 1, true), at(270, 1, true), at(270)], 0.1);
|
||||
assert.deepEqual(ev.filter((e) => e.type !== 'touch'), [
|
||||
{ type: 'press', part: 'prev' },
|
||||
{ type: 'release', part: 'prev', held: false },
|
||||
]);
|
||||
});
|
||||
|
||||
test('a centered hold also fires hold and longhold for select', () => {
|
||||
const w = createClickWheel();
|
||||
const ev = feed(w, Array.from({ length: 30 }, () => ({ x: 0, y: 0, pressed: true })), 0.1);
|
||||
assert.deepEqual(ev.map((e) => e.type + ':' + e.part), ['press:select', 'hold:select', 'longhold:select']);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- press suppresses scroll, part latching
|
||||
|
||||
test('pressing suspends scrolling, and the part stays latched while the stick moves', () => {
|
||||
const w = createClickWheel();
|
||||
w.update(at(0), DT);
|
||||
const press = w.update(at(0, 1, true), DT);
|
||||
assert.deepEqual(ofType(press, 'press'), [{ type: 'press', part: 'menu' }]);
|
||||
const during = [];
|
||||
for (let d = 2; d <= 180; d += 2) during.push(...w.update(at(d, 1, true), 0.001)); // quick: no hold
|
||||
assert.equal(ofType(during, 'scroll').length, 0);
|
||||
assert.equal(w.state.pressedPart, 'menu');
|
||||
// Slide into the center while still pressed: touch ends, part stays latched.
|
||||
const center = w.update({ x: 0, y: 0, pressed: true }, 0.001);
|
||||
assert.deepEqual(center, [{ type: 'touch', active: false }]);
|
||||
assert.equal(w.state.pressedPart, 'menu');
|
||||
assert.deepEqual(w.update({ x: 0, y: 0, pressed: false }, DT), [{ type: 'release', part: 'menu', held: false }]);
|
||||
assert.equal(w.state.pressedPart, null);
|
||||
});
|
||||
|
||||
test('releasing after rotating while pressed does not dump the rotation into scrolls', () => {
|
||||
const w = createClickWheel();
|
||||
w.update(at(0), DT);
|
||||
w.update(at(0, 1, true), DT);
|
||||
for (let d = 2; d <= 90; d += 2) w.update(at(d, 1, true), 0.001);
|
||||
const rel = w.update(at(90), DT);
|
||||
assert.deepEqual(rel, [{ type: 'release', part: 'menu', held: false }]);
|
||||
assert.equal(scrolls(w.update(at(92), DT)), 0);
|
||||
// Scrolling works again after the release, starting from a clean accumulator.
|
||||
const after = feed(w, [at(100), at(107), at(108)]);
|
||||
assert.equal(scrolls(after), 1);
|
||||
});
|
||||
|
||||
test('the release frame accumulator is reset', () => {
|
||||
const w = createClickWheel();
|
||||
w.update(at(0), DT);
|
||||
w.update(at(14), DT); // 14 degrees pending
|
||||
w.update(at(14, 1, true), DT);
|
||||
w.update(at(14), DT); // release resets it
|
||||
assert.equal(scrolls(w.update(at(16), DT)), 0);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- reset and allocation
|
||||
|
||||
test('reset() closes an open press and touch', () => {
|
||||
const w = createClickWheel();
|
||||
w.update(at(90, 1, true), DT);
|
||||
for (let i = 0; i < 40; i++) w.update(at(90, 1, true), 1 / 60);
|
||||
const ev = w.reset();
|
||||
assert.deepEqual(ev, [{ type: 'release', part: 'next', held: true, cancelled: true }, { type: 'touch', active: false }]);
|
||||
assert.equal(w.state.touching, false);
|
||||
assert.equal(w.state.pressedPart, null);
|
||||
assert.deepEqual(w.reset(), []);
|
||||
// After a reset, still holding the button down is a fresh press.
|
||||
assert.deepEqual(ofType(w.update(at(90, 1, true), DT), 'press'), [{ type: 'press', part: 'next' }]);
|
||||
});
|
||||
|
||||
test('reset() during a short click returns a cancelled release; a normal release is never cancelled', () => {
|
||||
const w = createClickWheel();
|
||||
w.update(at(90, 1, true), DT);
|
||||
w.update(at(90, 1, true), DT);
|
||||
assert.deepEqual(w.reset(), [{ type: 'release', part: 'next', held: false, cancelled: true }, { type: 'touch', active: false }]);
|
||||
w.update(null, DT);
|
||||
w.update(at(0, 0, true), DT);
|
||||
const rel = ofType(w.update(at(0, 0, false), DT), 'release');
|
||||
assert.deepEqual(rel, [{ type: 'release', part: 'select', held: false }]);
|
||||
assert.equal('cancelled' in rel[0], false);
|
||||
});
|
||||
|
||||
test('idle frames return the same empty array (no per-frame allocation)', () => {
|
||||
const w = createClickWheel();
|
||||
const a = w.update(null, DT);
|
||||
const b = w.update(at(90, 0.1), DT);
|
||||
assert.equal(a.length, 0);
|
||||
assert.equal(a, b);
|
||||
assert.ok(Object.isFrozen(a));
|
||||
});
|
||||
|
||||
test('custom options are honored', () => {
|
||||
const w = createClickWheel({ step: Math.PI / 6 });
|
||||
const samples = [];
|
||||
for (let d = 0; d <= 360; d += 2) samples.push(at(d));
|
||||
assert.equal(scrolls(feed(w, samples)), 12);
|
||||
});
|
||||
@@ -0,0 +1,351 @@
|
||||
// node --test device/asspod/tests/
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createPlayer } from '../src/media/player.js';
|
||||
import { DEFAULTS } from '../src/settings.js';
|
||||
import { Emitter } from '../src/util/emitter.js';
|
||||
import { mulberry32 } from '../src/util/rng.js';
|
||||
|
||||
// ---------------------------------------------------------------- fakes
|
||||
class FakeEngine {
|
||||
pos = 0; playing = false; item = null; volume = null; calls = [];
|
||||
play(item, offset) { this.item = item; this.pos = offset; this.playing = true; this.calls.push(['play', item.id, offset]); }
|
||||
stop() { this.playing = false; this.calls.push(['stop']); }
|
||||
get position() { return this.pos; }
|
||||
setVolume(v) { this.volume = v; }
|
||||
advance(sec) { if (this.playing) this.pos += sec; }
|
||||
}
|
||||
|
||||
/** Same API as the settings singleton, but fresh per test. */
|
||||
class FakeSettings extends Emitter {
|
||||
values = { ...DEFAULTS, ratings: {} };
|
||||
get(k) { return this.values[k]; }
|
||||
set(k, v) { if (Object.is(this.values[k], v)) return; this.values[k] = v; this.emit('change', { key: k, value: v }); }
|
||||
reset() { this.values = { ...DEFAULTS, ratings: {} }; this.emit('change', { key: '*', value: null }); }
|
||||
}
|
||||
|
||||
const song = (id, duration = 100, albumId = 'a') => ({ id, kind: 'song', title: id, albumId, duration, rating: 0, playCount: 0, lastPlayed: 0 });
|
||||
const items = (n, duration = 100) => Array.from({ length: n }, (_, i) => song('t' + i, duration, 'a' + Math.floor(i / 3)));
|
||||
|
||||
function setup(over = {}) {
|
||||
const engine = new FakeEngine();
|
||||
const settings = new FakeSettings();
|
||||
Object.assign(settings.values, over);
|
||||
let clock = 1000;
|
||||
const player = createPlayer({ engine, settings, random: mulberry32(42), now: () => clock });
|
||||
const events = [];
|
||||
for (const type of ['state', 'track', 'queue', 'volume', 'seek', 'rating']) player.on(type, (p) => events.push([type, p]));
|
||||
const run = (sec, dt = 0.1) => {
|
||||
for (let t = 0; t < sec - 1e-9; t += dt) { engine.advance(dt); clock += dt * 1000; player.update(dt); }
|
||||
};
|
||||
return { engine, settings, player, events, run, tick: (ms) => { clock += ms; } };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- transport
|
||||
test('playQueue starts the chosen item and emits queue, track and state', () => {
|
||||
const { player, engine, events } = setup();
|
||||
const q = items(5);
|
||||
player.playQueue(q, 2);
|
||||
assert.equal(player.state, 'playing');
|
||||
assert.equal(player.current, q[2]);
|
||||
assert.equal(player.index, 2);
|
||||
assert.deepEqual(engine.calls.at(-1), ['play', 't2', 0]);
|
||||
assert.deepEqual(events.map((e) => e[0]), ['queue', 'track', 'state']);
|
||||
assert.equal(events[1][1].item, q[2]);
|
||||
assert.equal(events[2][1].state, 'playing');
|
||||
});
|
||||
|
||||
test('position follows the engine; pause and play resume where they left off', () => {
|
||||
const { player, engine, run } = setup();
|
||||
player.playQueue(items(3));
|
||||
run(5);
|
||||
assert.ok(Math.abs(player.position - 5) < 1e-6);
|
||||
player.pause();
|
||||
assert.equal(player.state, 'paused');
|
||||
assert.equal(engine.playing, false);
|
||||
run(3);
|
||||
assert.ok(Math.abs(player.position - 5) < 1e-6, 'paused position holds');
|
||||
player.togglePlay();
|
||||
assert.equal(player.state, 'playing');
|
||||
assert.ok(Math.abs(engine.calls.at(-1)[2] - 5) < 1e-6, 'engine resumes at the paused offset');
|
||||
});
|
||||
|
||||
test('prev restarts after 3 s and goes back before that', () => {
|
||||
const { player, run } = setup();
|
||||
const q = items(3);
|
||||
player.playQueue(q, 1);
|
||||
run(4);
|
||||
player.prev();
|
||||
assert.equal(player.current, q[1], 'restarted, same item');
|
||||
assert.ok(player.position < 0.01);
|
||||
run(1);
|
||||
player.prev();
|
||||
assert.equal(player.current, q[0], 'went to the previous item');
|
||||
player.prev();
|
||||
assert.equal(player.current, q[0], 'first item just restarts');
|
||||
assert.equal(player.state, 'playing');
|
||||
});
|
||||
|
||||
test('next advances, and next on the last item stops (repeat off)', () => {
|
||||
const { player, events } = setup();
|
||||
const q = items(2);
|
||||
player.playQueue(q);
|
||||
player.next();
|
||||
assert.equal(player.current, q[1]);
|
||||
player.next();
|
||||
assert.equal(player.state, 'stopped');
|
||||
assert.equal(player.current, null);
|
||||
assert.deepEqual(events.at(-1), ['state', { state: 'stopped' }]);
|
||||
});
|
||||
|
||||
test('next while paused stays paused', () => {
|
||||
const { player, engine } = setup();
|
||||
const q = items(3);
|
||||
player.playQueue(q);
|
||||
player.pause();
|
||||
player.next();
|
||||
assert.equal(player.current, q[1]);
|
||||
assert.equal(player.state, 'paused');
|
||||
assert.equal(engine.playing, false);
|
||||
});
|
||||
|
||||
test('end of an item advances; end of the queue stops', () => {
|
||||
const { player, run } = setup();
|
||||
const q = items(2, 10);
|
||||
player.playQueue(q);
|
||||
run(10.5);
|
||||
assert.equal(player.current, q[1]);
|
||||
assert.equal(player.state, 'playing');
|
||||
run(10.5);
|
||||
assert.equal(player.state, 'stopped');
|
||||
assert.equal(player.current, null);
|
||||
});
|
||||
|
||||
test('repeat one replays the item; repeat all wraps to the start', () => {
|
||||
{
|
||||
const { player, run } = setup({ repeat: 'one' });
|
||||
const q = items(2, 10);
|
||||
player.playQueue(q);
|
||||
run(10.5);
|
||||
assert.equal(player.current, q[0]);
|
||||
assert.ok(player.position < 1);
|
||||
player.next();
|
||||
assert.equal(player.current, q[1], 'manual next still moves on');
|
||||
}
|
||||
{
|
||||
const { player, run } = setup({ repeat: 'all' });
|
||||
const q = items(2, 10);
|
||||
player.playQueue(q);
|
||||
run(21);
|
||||
assert.equal(player.current, q[0]);
|
||||
assert.equal(player.state, 'playing');
|
||||
player.prev();
|
||||
assert.equal(player.current, q[1], 'prev from the first item wraps with repeat all');
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- shuffle
|
||||
test("shuffle 'songs' keeps the chosen item first and shuffles the rest", () => {
|
||||
const { player } = setup({ shuffle: 'songs' });
|
||||
const q = items(12);
|
||||
player.playQueue(q, 5);
|
||||
assert.equal(player.queue[0], q[5]);
|
||||
assert.equal(player.index, 0);
|
||||
assert.equal(player.current, q[5]);
|
||||
assert.equal(player.queue.length, q.length);
|
||||
assert.deepEqual(new Set(player.queue), new Set(q));
|
||||
assert.notDeepEqual(player.queue.slice(1).map((t) => t.id), q.filter((_, i) => i !== 5).map((t) => t.id), 'rest is shuffled');
|
||||
assert.equal(q[0].id, 't0', 'the caller array is not mutated');
|
||||
});
|
||||
|
||||
test("shuffle 'albums' keeps albums together and starts with the chosen album", () => {
|
||||
const { player } = setup({ shuffle: 'albums' });
|
||||
const q = items(12); // albums a0..a3 with 3 tracks each
|
||||
player.playQueue(q, 7); // t7 is the 2nd track of a2
|
||||
const ids = player.queue.map((t) => t.id);
|
||||
assert.deepEqual(ids.slice(0, 3), ['t7', 't8', 't6']);
|
||||
for (let g = 0; g < 4; g++) {
|
||||
const albums = new Set(player.queue.slice(g * 3, g * 3 + 3).map((t) => t.albumId));
|
||||
assert.equal(albums.size, 1, 'albums stay contiguous');
|
||||
}
|
||||
assert.equal(new Set(ids).size, 12);
|
||||
const rest = player.queue.slice(3).map((t) => t.albumId);
|
||||
assert.ok(rest.every((a) => a !== 'a2'));
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- seeking
|
||||
test('seek is clamped to [0, duration] and emits seek', () => {
|
||||
const { player, engine, events, run } = setup();
|
||||
player.playQueue(items(2, 100));
|
||||
player.seek(-5);
|
||||
assert.equal(player.position, 0);
|
||||
player.seek(42);
|
||||
assert.equal(player.position, 42);
|
||||
assert.deepEqual(events.at(-1), ['seek', { position: 42 }]);
|
||||
run(0.3);
|
||||
assert.deepEqual(engine.calls.at(-1), ['play', 't0', 42]);
|
||||
player.seek(1e6);
|
||||
assert.equal(player.position, 100);
|
||||
player.pause();
|
||||
player.seek(30);
|
||||
assert.equal(player.position, 30);
|
||||
assert.equal(engine.playing, false, 'seeking while paused stays silent');
|
||||
});
|
||||
|
||||
test('rapid seeks while playing restart the engine once, at the last target, after they settle', () => {
|
||||
const { player, engine, run } = setup();
|
||||
player.playQueue(items(1, 200));
|
||||
run(10);
|
||||
engine.calls.length = 0;
|
||||
for (let i = 1; i <= 10; i++) {
|
||||
player.seek(10 + 2 * i);
|
||||
assert.equal(player.position, 10 + 2 * i, 'position reads the scrub target at once');
|
||||
run(0.1); // one detent every 100 ms: still settling
|
||||
}
|
||||
assert.equal(engine.playing, false, 'silent while the seeks settle');
|
||||
assert.deepEqual(engine.calls, [['stop']], 'one stop for the whole gesture, no restarts yet');
|
||||
run(0.3);
|
||||
assert.deepEqual(engine.calls, [['stop'], ['play', 't0', 30]]);
|
||||
assert.equal(player.state, 'playing');
|
||||
run(1);
|
||||
assert.ok(Math.abs(player.position - 31) < 0.15, `plays on from the target: ${player.position}`);
|
||||
});
|
||||
|
||||
test('pausing while a seek settles keeps the target and stays silent', () => {
|
||||
const { player, engine, run } = setup();
|
||||
player.playQueue(items(1, 200));
|
||||
player.seek(50);
|
||||
player.pause();
|
||||
run(1);
|
||||
assert.equal(player.position, 50);
|
||||
assert.equal(engine.playing, false);
|
||||
player.play();
|
||||
assert.deepEqual(engine.calls.at(-1), ['play', 't0', 50]);
|
||||
});
|
||||
|
||||
test('prev past 3 s restarts the item at once, without the settling gap', () => {
|
||||
const { player, engine, run } = setup();
|
||||
player.playQueue(items(2, 100));
|
||||
run(10);
|
||||
player.prev();
|
||||
assert.deepEqual(engine.calls.at(-1), ['play', 't0', 0]);
|
||||
assert.equal(engine.playing, true);
|
||||
});
|
||||
|
||||
test('FF/REW is silent at 4x, then 16x after 2 s, and resumes on release', () => {
|
||||
const { player, engine, run } = setup();
|
||||
player.playQueue(items(2, 200));
|
||||
run(10);
|
||||
player.startSeek(1);
|
||||
assert.equal(player.seeking, 1);
|
||||
assert.equal(engine.playing, false, 'silent while seeking');
|
||||
run(1);
|
||||
assert.ok(Math.abs(player.position - 14) < 0.05, `4x: ${player.position}`);
|
||||
run(2);
|
||||
// 1 more second at 4x, then 1 s at 16x (one 0.1 s frame of slack at the 2 s switch).
|
||||
assert.ok(Math.abs(player.position - 34) < 1.3, `16x: ${player.position}`);
|
||||
const at = player.position;
|
||||
player.stopSeek();
|
||||
assert.equal(player.seeking, 0);
|
||||
assert.equal(engine.playing, true);
|
||||
assert.ok(Math.abs(engine.calls.at(-1)[2] - player.position) < 1e-6);
|
||||
player.startSeek(-1);
|
||||
run(1);
|
||||
assert.ok(Math.abs(player.position - (at - 4)) < 0.05, `rewind: ${player.position}`);
|
||||
run(5);
|
||||
assert.equal(player.position, 0, 'rewind clamps at 0');
|
||||
player.stopSeek();
|
||||
});
|
||||
|
||||
test('fast-forward off the end rolls into the next item', () => {
|
||||
const { player, run } = setup();
|
||||
const q = items(2, 10);
|
||||
player.playQueue(q);
|
||||
player.startSeek(1);
|
||||
run(3);
|
||||
assert.equal(player.current, q[1]);
|
||||
player.stopSeek();
|
||||
assert.equal(player.state, 'playing');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- volume, ratings, history
|
||||
test('volume is clamped, applied to the engine and persisted to settings.volume', () => {
|
||||
const { player, engine, settings, events } = setup();
|
||||
assert.equal(player.volume, DEFAULTS.volume);
|
||||
assert.equal(engine.volume, DEFAULTS.volume, 'initial volume reaches the engine');
|
||||
player.setVolume(1.7);
|
||||
assert.equal(player.volume, 1);
|
||||
assert.equal(settings.get('volume'), 1);
|
||||
assert.equal(engine.volume, 1);
|
||||
player.setVolume(-1);
|
||||
assert.equal(player.volume, 0);
|
||||
assert.deepEqual(events.filter((e) => e[0] === 'volume').map((e) => e[1].volume), [1, 0]);
|
||||
settings.set('volume', 0.25);
|
||||
assert.equal(player.volume, 0.25, 'external settings changes are followed');
|
||||
assert.equal(engine.volume, 0.25);
|
||||
settings.reset();
|
||||
assert.equal(player.volume, DEFAULTS.volume);
|
||||
});
|
||||
|
||||
test('ratings persist to settings.ratings by replacing the object', () => {
|
||||
const { player, settings, events } = setup();
|
||||
const q = items(2);
|
||||
const before = settings.get('ratings');
|
||||
player.rate(q[0], 4);
|
||||
assert.equal(q[0].rating, 4);
|
||||
assert.deepEqual(settings.get('ratings'), { t0: 4 });
|
||||
assert.notEqual(settings.get('ratings'), before, 'a new object');
|
||||
assert.deepEqual(before, {}, 'the old object was not mutated');
|
||||
player.rate(q[1], 9);
|
||||
assert.equal(q[1].rating, 5);
|
||||
assert.deepEqual(settings.get('ratings'), { t0: 4, t1: 5 });
|
||||
assert.deepEqual(events.at(-1), ['rating', { item: q[1] }]);
|
||||
});
|
||||
|
||||
test('playCount and lastPlayed update', () => {
|
||||
const { player, settings, run } = setup();
|
||||
const q = items(3, 10);
|
||||
player.playQueue(q);
|
||||
assert.equal(q[0].lastPlayed, 1000, 'lastPlayed is stamped when an item starts');
|
||||
run(10.5);
|
||||
assert.equal(q[0].playCount, 1, 'a finished item counts');
|
||||
assert.equal(player.current, q[1]);
|
||||
player.next();
|
||||
assert.equal(q[1].playCount, 0, 'an early skip does not count');
|
||||
assert.ok(q[1].lastPlayed > 0);
|
||||
run(6);
|
||||
player.next();
|
||||
assert.equal(q[2].playCount, 1, 'a skip after half the item counts');
|
||||
assert.deepEqual(settings.get('plays').t0, [1, q[0].lastPlayed]);
|
||||
});
|
||||
|
||||
test('stop() clears the current item; play() with no current item restarts the queue', () => {
|
||||
const { player, engine } = setup();
|
||||
const q = items(3);
|
||||
player.playQueue(q, 1);
|
||||
player.stop();
|
||||
assert.equal(player.state, 'stopped');
|
||||
assert.equal(player.current, null);
|
||||
assert.equal(player.position, 0);
|
||||
assert.equal(engine.playing, false);
|
||||
player.play();
|
||||
assert.equal(player.current, q[0]);
|
||||
assert.equal(player.state, 'playing');
|
||||
});
|
||||
|
||||
test('calls with no current item are harmless', () => {
|
||||
const { player } = setup();
|
||||
player.togglePlay(); player.next(); player.prev(); player.seek(5); player.startSeek(1); player.stopSeek(); player.update(0.1);
|
||||
player.playQueue([]);
|
||||
assert.equal(player.state, 'stopped');
|
||||
assert.equal(player.duration, 0);
|
||||
});
|
||||
|
||||
test('update() drives an engine tick hook when the engine has one', () => {
|
||||
const { player, engine } = setup();
|
||||
let ticks = 0;
|
||||
engine.tick = () => { ticks++; };
|
||||
player.update(0.016);
|
||||
player.update(0.016);
|
||||
assert.equal(ticks, 2);
|
||||
});
|
||||
@@ -12,5 +12,6 @@
|
||||
"wp.c6a": true, "wp.c6b": true,
|
||||
"wp.c7": true,
|
||||
"glassTune": true,
|
||||
"assPod": true,
|
||||
"pointerProxy": "off"
|
||||
}
|
||||
@@ -96,6 +96,8 @@ BUILTIN_FLAGS = {
|
||||
"sgDepthAnim": True, # P8/P7: depth motion kill switch (daemon.md §2)
|
||||
"shellThemeGraceS": 600, # P8: dormant seconds while the theme is off without lgs off (daemon.md §2)
|
||||
"glassTune": False, # the bar's paintbrush (glass tuner, rt 33-tune.js); defaults.json turns it on
|
||||
"assPod": False, # the bar's music note: the assPod (rt 34-asspod.js, shell_ext/asspod.py, vr script);
|
||||
# defaults.json turns it on
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -11,6 +11,8 @@
|
||||
// last spec and re-applies the last overrides
|
||||
// overrides(set) transform overrides of SteamVR's own chrome ({rules})
|
||||
// windowState(w) Steam's real window panel: {dim, recede, motion}
|
||||
// fade(v) every node's opacity to v (0..1) on the fade token: the assPod hides the menu's
|
||||
// glass with it (device/asspod), 1 brings it back
|
||||
// clear() remove every node, restore every override, push once
|
||||
// destroy() clear() and uninstall
|
||||
// status() dump() geom() timeline() spec() inspection
|
||||
@@ -227,6 +229,7 @@
|
||||
timeline: [],
|
||||
sg: { created: 0, retired: 0, live: new Set(), noRetire: 0 },
|
||||
profile: 'default',
|
||||
fade: { c: chan(1), wrap: false }, // fade(): an opacity node over every item while not at 1
|
||||
reduce: false,
|
||||
depthToken: 'depth',
|
||||
unitM: 0,
|
||||
@@ -367,6 +370,7 @@
|
||||
for (const p of st.pops.values()) if (p.c.moving && !chanSettled(p.c, now)) return true;
|
||||
for (const d of st.dims.values()) if (d.c.moving && !chanSettled(d.c, now)) return true;
|
||||
for (const c of animatedOverrideChans()) if (c.moving && !chanSettled(c, now)) return true;
|
||||
if (st.fade.c.moving && !chanSettled(st.fade.c, now)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -398,6 +402,7 @@
|
||||
for (const p of st.pops.values()) if (p.c.moving) { p.c.moving = false; p.c.x0 = p.c.target; p.c.v0 = 0; }
|
||||
for (const d of st.dims.values()) if (d.c.moving) { d.c.moving = false; d.c.x0 = d.c.target; d.c.v0 = 0; }
|
||||
for (const c of animatedOverrideChans()) if (c.moving) { c.moving = false; c.x0 = c.target; c.v0 = 0; }
|
||||
if (st.fade.c.moving) { st.fade.c.moving = false; st.fade.c.x0 = st.fade.c.target; st.fade.c.v0 = 0; }
|
||||
}
|
||||
|
||||
// Sunk pops and faded-out dims leave; removed override rules restore.
|
||||
@@ -406,6 +411,7 @@
|
||||
for (const [k, p] of st.pops) if (p.sinking && !p.c.moving) { st.pops.delete(k); structural = true; }
|
||||
for (const [k, d] of st.dims) if (d.want === null && !d.c.moving) { st.dims.delete(k); structural = true; }
|
||||
if (ovCleanup()) structural = true;
|
||||
if (st.fade.wrap && !st.fade.c.moving && st.fade.c.target >= 1) { st.fade.wrap = false; structural = true; }
|
||||
if (structural && st.lastSpec && !st.expired) { build(st.lastSpec); }
|
||||
else if (structural) schedulePush();
|
||||
}
|
||||
@@ -437,6 +443,13 @@
|
||||
it.zNow = z;
|
||||
}
|
||||
}
|
||||
if (it.op) it.op.__lgsProps = { opacity: r6(clamp(chanAt(st.fade.c, now)[0], 0, 1)) };
|
||||
}
|
||||
for (const c of VRC.values()) {
|
||||
if (c.op) c.op.__lgsProps = { opacity: r6(clamp(chanAt(st.fade.c, now)[0], 0, 1)) };
|
||||
}
|
||||
for (const it of st.items.values()) {
|
||||
const d = it.d;
|
||||
if (it.wrap && d.dimKey) {
|
||||
const dm = st.dims.get(d.dimKey);
|
||||
const v = dm ? clamp(chanAt(dm.c, now)[0], 0, 1) : 1;
|
||||
@@ -539,6 +552,12 @@
|
||||
} else {
|
||||
xf.appendChild(panel);
|
||||
}
|
||||
let op = null;
|
||||
if (st.fade.wrap) {
|
||||
op = vnode('opacity', { opacity: r6(clamp(chanAt(st.fade.c, Date.now())[0], 0, 1)) });
|
||||
op.appendChild(xf.firstElementChild);
|
||||
xf.appendChild(op);
|
||||
}
|
||||
const anchor = vnode('panel-anchor', { 'anchor-u': r6(d.u), 'anchor-v': r6(d.v) });
|
||||
anchor.appendChild(xf);
|
||||
let it;
|
||||
@@ -546,12 +565,12 @@
|
||||
const g = groupFor(d.parentKey);
|
||||
g.top.appendChild(anchor);
|
||||
g.n++;
|
||||
it = { d, top: null, anchor, xf, wrap, panel, sig: sigOf(d), group: d.parentKey, pushedAt: 0 };
|
||||
it = { d, top: null, anchor, xf, op, wrap, panel, sig: sigOf(d), group: d.parentKey, pushedAt: 0 };
|
||||
} else {
|
||||
const top = vnode('reparent-to-panel', { 'parent-overlay-key': d.parentKey });
|
||||
top.appendChild(anchor);
|
||||
rootEl().appendChild(top);
|
||||
it = { d, top, anchor, xf, wrap, panel, sig: sigOf(d), pushedAt: 0 };
|
||||
it = { d, top, anchor, xf, op, wrap, panel, sig: sigOf(d), pushedAt: 0 };
|
||||
}
|
||||
st.unpushed.add(it);
|
||||
return it;
|
||||
@@ -566,6 +585,7 @@
|
||||
|
||||
// Update in place; true if anything changed, 'rebuild' if the wrapper changes.
|
||||
function updateItem(it, d) {
|
||||
if (st.fade.wrap !== !!it.op) return 'rebuild'; // fade(): the opacity node comes or goes
|
||||
const sig = sigOf(d);
|
||||
if (sig === it.sig) return false;
|
||||
// a wrapper appears or goes (dim on / off): new nodes, swapped in one push
|
||||
@@ -598,11 +618,11 @@
|
||||
st.unpushed.delete(it);
|
||||
if (it.top) {
|
||||
it.top.remove();
|
||||
retire([it.top, it.anchor, it.xf, it.wrap, it.panel]);
|
||||
retire([it.top, it.anchor, it.xf, it.op, it.wrap, it.panel]);
|
||||
return;
|
||||
}
|
||||
it.anchor.remove();
|
||||
retire([it.anchor, it.xf, it.wrap, it.panel]);
|
||||
retire([it.anchor, it.xf, it.op, it.wrap, it.panel]);
|
||||
const g = groups.get(it.group);
|
||||
if (g && --g.n <= 0) {
|
||||
g.top.remove();
|
||||
@@ -1088,7 +1108,7 @@
|
||||
const c = VRC.get(name);
|
||||
if (c && c.xf) {
|
||||
try { c.xf.remove(); } catch (_) { /* gone */ }
|
||||
retire([c.xf, c.panel]);
|
||||
retire([c.xf, c.op, c.panel]);
|
||||
}
|
||||
VRC.delete(name);
|
||||
setVrClass(name, false);
|
||||
@@ -1124,13 +1144,15 @@
|
||||
debug_name: 'lgs:vr:' + d.name });
|
||||
if (g.stereo === true) props.stereoscopy = STEREO_PARALLEL;
|
||||
let c = VRC.get(d.name);
|
||||
if (!c || c.el !== p || !c.xf || !c.xf.isConnected) {
|
||||
if (!c || c.el !== p || !c.xf || !c.xf.isConnected || st.fade.wrap !== !!c.op) {
|
||||
if (c) vrCoverDrop(d.name);
|
||||
const xf = vtransform(-0.0004);
|
||||
const panel = vnode('panel', props);
|
||||
xf.appendChild(panel);
|
||||
// fade(): an opacity node over it too
|
||||
const op = st.fade.wrap ? vnode('opacity', { opacity: r6(clamp(chanAt(st.fade.c, Date.now())[0], 0, 1)) }) : null;
|
||||
if (op) { op.appendChild(panel); xf.appendChild(op); } else xf.appendChild(panel);
|
||||
p.parentElement.insertBefore(xf, p);
|
||||
VRC.set(d.name, c = { el: p, xf, panel });
|
||||
VRC.set(d.name, c = { el: p, xf, op, panel });
|
||||
changed = true;
|
||||
} else if (JSON.stringify(c.panel.__lgsProps) !== JSON.stringify(props)) {
|
||||
c.panel.__lgsProps = props;
|
||||
@@ -1730,6 +1752,20 @@
|
||||
const now = Date.now();
|
||||
return { dim: r6(chanAt(win.dimC, now)[0]), dimTarget: win.dimC.target, recede: r6(chanAt(win.recC, now)[0]), recedeTarget: win.recC.target, error };
|
||||
}
|
||||
// Every node's opacity to v (0..1) on the fade token; the opacity nodes go again once back at 1.
|
||||
function fade(v) {
|
||||
if (st.destroyed) return { error: 'destroyed' };
|
||||
const target = clamp(num(v, 1), 0, 1);
|
||||
const now = Date.now();
|
||||
if (target < 1 && !st.fade.wrap) {
|
||||
st.fade.wrap = true;
|
||||
if (st.lastSpec && !st.expired) build(st.lastSpec);
|
||||
}
|
||||
chanSet(st.fade.c, target, st.reduce ? null : 'fade', now);
|
||||
if (anyMoving(now)) startAnim(); else { schedulePush(); afterSettle(); }
|
||||
return { fade: r6(chanAt(st.fade.c, now)[0]), target, wrap: st.fade.wrap };
|
||||
}
|
||||
|
||||
function windowState(w) {
|
||||
if (st.destroyed) return { error: 'destroyed' };
|
||||
contact();
|
||||
@@ -1981,7 +2017,7 @@
|
||||
}
|
||||
|
||||
const api = {
|
||||
version: VERSION, caps: CAPS, update, ping, overrides, windowState, clear, status, dump, destroy,
|
||||
version: VERSION, caps: CAPS, update, ping, overrides, windowState, fade, clear, status, dump, destroy,
|
||||
geom: () => Object.assign({}, geom()), timeline, spec: () => st.lastSpec, guillotine, mosaic, test,
|
||||
_spring: spring, _token: tokenDB, _retargets: () => retargets.slice(),
|
||||
};
|
||||
|
||||
@@ -1077,6 +1077,7 @@ class Shell:
|
||||
self.geom_dump_at = 0.0 # last DumpLaserOverlays read (status geomDumpAt; DM-3 waits for one)
|
||||
self.geom_ok = False
|
||||
self.vr_conns = {} # SteamVR page target id -> CDP (kept open; m4)
|
||||
self.vr_targets = {} # SteamVR page title -> devtools target (the page loop's last list)
|
||||
self.vr_http = None # aiohttp session for 8090's /json/list (keep-alive)
|
||||
# glassd.json v3 and the materialize policy
|
||||
self.items_first = {} # (surface, kind, id) -> time first written
|
||||
@@ -1251,11 +1252,24 @@ class Shell:
|
||||
return await eval_once(t["webSocketDebuggerUrl"], expr, timeout, "steam-ext")
|
||||
|
||||
async def ext_vr_eval(self, page, expr, timeout=10):
|
||||
loop = asyncio.get_running_loop()
|
||||
for t in await loop.run_in_executor(None, lgs_vr.targets):
|
||||
if (t.get("title") or "") == page:
|
||||
return await eval_once(t["webSocketDebuggerUrl"], expr, timeout, "vr-ext")
|
||||
raise ConnectionError(f"no SteamVR page {page!r}")
|
||||
"""Over the page's kept socket (the page loop's target list, else a fresh one): plugins that
|
||||
poll a page several times a second (asspod) cost one message per call, not a new socket."""
|
||||
t = self.vr_targets.get(page)
|
||||
if t is None:
|
||||
loop = asyncio.get_running_loop()
|
||||
t = next((x for x in await loop.run_in_executor(None, lgs_vr.targets) if (x.get("title") or "") == page), None)
|
||||
if t is None:
|
||||
raise ConnectionError(f"no SteamVR page {page!r}")
|
||||
try:
|
||||
return await self.vr_page_eval(t, expr, timeout)
|
||||
except RuntimeError as e:
|
||||
if str(e).startswith("JS: "):
|
||||
raise
|
||||
self.vr_targets.pop(page, None)
|
||||
raise
|
||||
except Exception:
|
||||
self.vr_targets.pop(page, None)
|
||||
raise
|
||||
|
||||
def ext_vr_pages(self):
|
||||
return sorted(self.vr_pages)
|
||||
@@ -3093,12 +3107,13 @@ class Shell:
|
||||
while not self.stopping:
|
||||
await asyncio.sleep(lgs_vr.POLL)
|
||||
if self.vr_paused(): # lab "--theme off" on a vr: page (page scripts stripped with it)
|
||||
self.vrx_status, self.vr_pages = {}, {}
|
||||
self.vrx_status, self.vr_pages, self.vr_targets = {}, {}, {}
|
||||
await self.vr_conns_close()
|
||||
continue
|
||||
try:
|
||||
ts = await self.vr_list()
|
||||
if ts is None:
|
||||
self.vr_targets = {}
|
||||
await self.vr_conns_close()
|
||||
continue
|
||||
s = lgs_vr.signature()
|
||||
@@ -3110,6 +3125,7 @@ class Shell:
|
||||
" ? (window.__LGS_VR.apply() ? 'ok' : 'retry') : 'need'")
|
||||
self.vr_version = p["version"]
|
||||
log(f"vr: theme {p['version']} ({len(p['css'])} bytes)")
|
||||
self.vr_targets = {(t.get("title") or t.get("url")): t for t in ts}
|
||||
pages = {}
|
||||
scripts = self.vr_scripts_cached()
|
||||
await self.vr_conns_close(keep={t.get("id") or t["webSocketDebuggerUrl"] for t in ts})
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/* Glass Shell: the paintbrush on the bar (the glass tuner; flag glassTune).
|
||||
- Where: a glass circle of its own after the system capsule, the third child of the bar's
|
||||
- Where: a glass circle of its own after the system capsule (and after the assPod's music note,
|
||||
34-asspod.js, when that is on), a child of the bar's
|
||||
%{PopupBody>DashboardBar} (mirroring the Home circle, Steam's Bookend, before the apps capsule).
|
||||
Added by a layer on P2's `dashboardBar` patch (rt.react.patch.targets.dashboardBar); its glass
|
||||
is theme/33-tune.css, its native cover theme/layers/30-bar.json (.lgs-tune).
|
||||
@@ -221,7 +222,9 @@ __LGS_RT.define({
|
||||
const insert = (el, add, depth) => {
|
||||
if (!el || typeof el !== 'object' || depth > 10) return null;
|
||||
if (Array.isArray(el)) {
|
||||
const at = el.findIndex((x) => x && typeof x === 'object' && x.props && isMainSurface(x.props));
|
||||
let at = el.findIndex((x) => x && typeof x === 'object' && x.props && isMainSurface(x.props));
|
||||
// the assPod's music note (34-asspod.js) stays between the capsule and the paintbrush
|
||||
if (at >= 0 && el[at + 1] && el[at + 1].key === 'lgs-asspod') at++;
|
||||
if (at >= 0) { const n = el.slice(); n.splice(at + 1, 0, add); return n; }
|
||||
for (let i = 0; i < el.length; i++) { const n = insert(el[i], add, depth + 1); if (n) { const cpy = el.slice(); cpy[i] = n; return cpy; } }
|
||||
return null;
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
/* Glass Shell: the music note on the bar, which takes out the assPod (flag assPod).
|
||||
- Where: a glass circle of its own between the system capsule and the paintbrush (33-tune.js puts
|
||||
itself after this one). Added by a layer on P2's `dashboardBar` patch; its glass is
|
||||
theme/34-asspod.css, its native cover theme/layers/30-bar.json (.lgs-asspod).
|
||||
- Pressing it calls the daemon's asspod.open (device/shell_ext/asspod.py): VLC starts, the library
|
||||
loads and the assPod appears in the hand whose laser pressed it (SteamVR's systemui page,
|
||||
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 /
|
||||
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. */
|
||||
let ASSPOD = null;
|
||||
__LGS_RT.define({
|
||||
name: 'asspod',
|
||||
deps: ['react'],
|
||||
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');
|
||||
|
||||
// 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 OUT_CLASS = 'lgs-asspod-out';
|
||||
const ICON = 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'),
|
||||
] });
|
||||
|
||||
const S = ASSPOD = {
|
||||
rt, R, handle: null, lastErr: null, opening: false, out: false, catchOff: null, timer: 0, off: [],
|
||||
sent: 0, swallowed: 0, subs: new Set(),
|
||||
};
|
||||
const changed = () => S.subs.forEach((f) => { try { f(); } catch (_) { /* unmounted */ } });
|
||||
|
||||
// ---- out or not: the daemon's bridge value, fresh
|
||||
const fresh = (v) => !!(v && v.open && typeof v.at === 'number' && Date.now() - v.at <= (v.ttlMs || 5000));
|
||||
const setClass = (on) => {
|
||||
for (const e of rt.windows.list()) {
|
||||
try { e.doc.documentElement.classList.toggle(OUT_CLASS, on); } catch (_) { /* window gone */ }
|
||||
}
|
||||
};
|
||||
const onPad = (button, down, repeat) => {
|
||||
if (!S.out) return false;
|
||||
if (button === BTN.STEAM_GUIDE || button === BTN.STEAM_QUICK_MENU) return false;
|
||||
S.swallowed++;
|
||||
if (repeat) return true;
|
||||
const m = MAP[button];
|
||||
if (m) {
|
||||
S.sent++;
|
||||
rt.action('asspod.btn', { b: m[0], side: m[1], down: !!down }, { src: 'main', timeoutMs: 4000 })
|
||||
.then((r) => { if (r && r.ok === false) S.lastErr = r.error + (r.detail ? ' ' + r.detail : ''); });
|
||||
}
|
||||
return true;
|
||||
};
|
||||
function setOut(on) {
|
||||
if (on === S.out) return;
|
||||
S.out = on;
|
||||
if (on && !S.catchOff) {
|
||||
try { S.catchOff = W.FocusNavController.SetCatchAllGamepadInput(onPad).Unregister; } catch (e) { S.lastErr = 'catch: ' + String(e && e.message || e); }
|
||||
} else if (!on && S.catchOff) {
|
||||
try { S.catchOff(); } catch (_) { /* gone */ }
|
||||
S.catchOff = null;
|
||||
}
|
||||
setClass(on);
|
||||
changed();
|
||||
}
|
||||
const W = rt.W;
|
||||
const check = () => {
|
||||
S.timer = 0;
|
||||
const v = rt.bridge.get('asspod');
|
||||
setOut(fresh(v));
|
||||
if (S.out) S.timer = rt.setTimeout(check, 500);
|
||||
};
|
||||
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 */ } };
|
||||
}));
|
||||
|
||||
const daemon = () => {
|
||||
const d = rt.bridge.get('daemon');
|
||||
const live = !!(d && Date.now() - d.at < (d.ttlMs || 6000) && typeof window.lgsAction === 'function');
|
||||
return live && Array.isArray(d.actions) && d.actions.includes('asspod.open');
|
||||
};
|
||||
const open = () => {
|
||||
if (S.opening) return;
|
||||
if (!daemon()) { S.lastErr = 'no daemon'; return; }
|
||||
S.opening = true;
|
||||
changed();
|
||||
rt.action('asspod.open', {}, { src: 'bar', timeoutMs: 45000 }).then((r) => {
|
||||
S.opening = false;
|
||||
if (r && r.ok === false) S.lastErr = r.error + (r.detail ? ' ' + r.detail : '');
|
||||
changed();
|
||||
});
|
||||
};
|
||||
|
||||
// ---- the button: Steam's small bar disc in a glass circle of its own
|
||||
const barClasses = () => (R.M && R.M.BarClasses) || {};
|
||||
function AssPodButton() {
|
||||
const [, bump] = React.useState(0);
|
||||
React.useEffect(() => { const f = () => bump((n) => n + 1); S.subs.add(f); return () => S.subs.delete(f); }, []);
|
||||
const bc = barClasses();
|
||||
const on = S.out || S.opening;
|
||||
return jsx('div', { className: 'lgs-asspod', children: jsx(Focusable, {
|
||||
className: ['VRDashboardBarSmallButton', bc.Item, bc.Clickable, on && bc.Active, on && 'VRDashboardBarSmallButtonActive', 'lgs-asspod-btn']
|
||||
.filter(Boolean).join(' '),
|
||||
style: { '--bar-item-padding-pct': '0' },
|
||||
'aria-label': 'assPod',
|
||||
onActivate: open,
|
||||
children: jsx('div', { className: bc.Highlight || '', children: jsx('div', { className: 'lgs-asspod-icon', children: ICON }) }),
|
||||
}) });
|
||||
}
|
||||
|
||||
// right after the main %{BarSurface} (the apps and system capsules), so it sits left of the
|
||||
// paintbrush whichever patch layer runs first (fail closed: unchanged when not found)
|
||||
const surfaceCls = () => barClasses().BarSurface || '';
|
||||
const isMainSurface = (p) => {
|
||||
const sc = surfaceCls();
|
||||
return !!sc && p && typeof p.className === 'string' && p.className.split(/\s+/).includes(sc) && !p.className.includes(barClasses().Bookend || '\u0000');
|
||||
};
|
||||
const insert = (el, add, depth) => {
|
||||
if (!el || typeof el !== 'object' || depth > 10) return null;
|
||||
if (Array.isArray(el)) {
|
||||
const at = el.findIndex((x) => x && typeof x === 'object' && x.props && isMainSurface(x.props));
|
||||
if (at >= 0) { const n = el.slice(); n.splice(at + 1, 0, add); return n; }
|
||||
for (let i = 0; i < el.length; i++) { const n = insert(el[i], add, depth + 1); if (n) { const cpy = el.slice(); cpy[i] = n; return cpy; } }
|
||||
return null;
|
||||
}
|
||||
if (!el.props) return null;
|
||||
const ch = el.props.children;
|
||||
if (ch == null) return null;
|
||||
if (!Array.isArray(ch) && typeof ch === 'object' && ch.props && isMainSurface(ch.props)) {
|
||||
return React.cloneElement(el, null, ch, add);
|
||||
}
|
||||
const n = insert(ch, add, depth + 1);
|
||||
return n ? React.cloneElement(el, null, ...(Array.isArray(n) ? n : [n])) : null;
|
||||
};
|
||||
|
||||
S.handle = R.patch.byProps('asspod.bar', R.patch.targets.dashboardBar, (orig) => function (props, ref) {
|
||||
const out = orig.call(this, props, ref);
|
||||
if (ASSPOD !== S) return out;
|
||||
try { return insert(out, jsx(AssPodButton, {}, 'lgs-asspod'), 0) || out; } catch (e) { S.lastErr = String(e && e.message || e); return out; }
|
||||
}, { 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,
|
||||
};
|
||||
},
|
||||
remove() {
|
||||
const S = ASSPOD;
|
||||
ASSPOD = null;
|
||||
if (!S) return;
|
||||
try { if (S.timer) S.timer(); } catch (_) { /* scoped timer */ }
|
||||
if (S.catchOff) { try { S.catchOff(); } catch (_) { /* gone */ } S.catchOff = null; }
|
||||
for (const off of S.off) { try { if (typeof off === 'function') off(); else if (off && off.remove) off.remove(); } catch (_) { /* gone */ } }
|
||||
S.subs.clear();
|
||||
try { if (S.handle) { S.handle.remove(); try { S.R.patch.rerender(S.handle); } catch (_) { /* */ } } } catch (_) { /* */ }
|
||||
},
|
||||
});
|
||||
@@ -9,6 +9,8 @@
|
||||
#
|
||||
# install fetch the latest release, put it in ~/.local/share/glass-shell and
|
||||
# add "LiquidAss" to + > Launch Program. Running it again updates.
|
||||
# The assPod (the music note on the bar) plays through VLC: when VLC
|
||||
# is missing, it offers to install it (Flathub, for this user only).
|
||||
# uninstall turn the theme off, remove the launcher entry, the files and
|
||||
# your saved glass settings.
|
||||
#
|
||||
@@ -16,6 +18,7 @@
|
||||
# LIQUIDASS_VERSION=v0.1 install that release instead of the latest
|
||||
# LIQUIDASS_ARCHIVE=FILE install from a local LiquidAss-Frame.tar.gz (no download)
|
||||
# GITHUB_TOKEN=... needed only while the repository is private
|
||||
# LIQUIDASS_VLC=yes|no answer the VLC question without asking
|
||||
set -eu
|
||||
|
||||
REPO=MichaelScottsman/LiquidAss-Frame
|
||||
@@ -109,6 +112,47 @@ install_app() {
|
||||
fi
|
||||
}
|
||||
|
||||
# VLC for the assPod: found (a system vlc or the Flathub app), or offered.
|
||||
has_vlc() {
|
||||
command -v vlc >/dev/null 2>&1 && return 0
|
||||
command -v flatpak >/dev/null 2>&1 && flatpak info org.videolan.VLC >/dev/null 2>&1
|
||||
}
|
||||
|
||||
check_vlc() {
|
||||
if has_vlc; then
|
||||
say "VLC is installed: the assPod is ready (press the music note on the dashboard bar)"
|
||||
return 0
|
||||
fi
|
||||
say "the assPod (the music note on the dashboard bar) plays your music and videos with VLC, which is not installed"
|
||||
answer=${LIQUIDASS_VLC:-}
|
||||
if [ -z "$answer" ]; then
|
||||
if [ -r /dev/tty ] && [ -w /dev/tty ]; then
|
||||
printf 'LiquidAss: install VLC now? [y/N] ' > /dev/tty
|
||||
read -r answer < /dev/tty || answer=
|
||||
else
|
||||
answer=no
|
||||
fi
|
||||
fi
|
||||
case "$answer" in
|
||||
y|Y|yes|YES|Yes)
|
||||
if ! command -v flatpak >/dev/null 2>&1; then
|
||||
say "flatpak is missing, so VLC cannot be installed from here; install VLC from Discover in Desktop Mode"
|
||||
return 0
|
||||
fi
|
||||
say "installing VLC from Flathub (for this user)"
|
||||
flatpak remote-add --user --if-not-exists flathub https://dl.flathub.org/repo/flathub.flatpakrepo >/dev/null 2>&1 || true
|
||||
if flatpak install --user -y --noninteractive flathub org.videolan.VLC; then
|
||||
say "VLC installed: the assPod is ready"
|
||||
else
|
||||
say "VLC could not be installed; the assPod needs it (Discover in Desktop Mode, or: flatpak install flathub org.videolan.VLC)"
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
say "skipped: install VLC to use the assPod (Discover in Desktop Mode, or: flatpak install flathub org.videolan.VLC)"
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
uninstall_app() {
|
||||
if [ -f "$DEST/device/lgs.py" ] && command -v python3 >/dev/null 2>&1; then
|
||||
py "$DEST/device/lgs.py" off --quiet >/dev/null 2>&1 || true
|
||||
@@ -120,7 +164,7 @@ uninstall_app() {
|
||||
}
|
||||
|
||||
case "${1:-install}" in
|
||||
install) install_app ;;
|
||||
install) install_app; check_vlc ;;
|
||||
uninstall) uninstall_app ;;
|
||||
-h|--help|help) sed -n '2,20p' "$0" 2>/dev/null | sed 's/^# \{0,1\}//' || true ;;
|
||||
*) die "unknown command '$1' (use install or uninstall)" ;;
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
#!/bin/sh
|
||||
# Builds podd on the Frame (aarch64, GCC, GBM/EGL/GLES 3.2, SteamVR's own libopenvr_api), with
|
||||
# glassd's GPU and math helpers (../glassd/src). Output: ./podd (where device/shell_ext/asspod.py
|
||||
# looks: native/podd/podd).
|
||||
#
|
||||
# sh build.sh # release build
|
||||
# DEBUG=1 sh build.sh # -O0 -g
|
||||
set -e
|
||||
cd "$(dirname "$0")"
|
||||
INC=${OPENVR_INCLUDE:-$HOME/frametop/screens/build/include}
|
||||
if [ ! -f "$INC/openvr.h" ]; then
|
||||
INC=../glassd/build/include
|
||||
if [ ! -f "$INC/openvr.h" ]; then
|
||||
mkdir -p "$INC"
|
||||
curl -fsSL "https://raw.githubusercontent.com/ValveSoftware/openvr/v2.15.6/headers/openvr.h" -o "$INC/openvr.h"
|
||||
fi
|
||||
fi
|
||||
mkdir -p build
|
||||
if [ -n "$DEBUG" ]; then OPT="-O0 -g"; else OPT="-O2"; fi
|
||||
VRLIB="-L/opt/steamvr/bin/linuxarm64 -lopenvr_api -Wl,-rpath,/opt/steamvr/bin/linuxarm64"
|
||||
# shellcheck disable=SC2046
|
||||
g++ -std=c++17 $OPT -Wall -Wno-missing-field-initializers -Wno-unused-function \
|
||||
-I"$INC" -I../glassd/src $(pkg-config --cflags egl glesv2 gbm) \
|
||||
podd.cpp -o build/podd \
|
||||
$(pkg-config --libs egl glesv2 gbm) $VRLIB -lpthread
|
||||
install -m755 build/podd podd
|
||||
echo "built $(pwd)/podd"
|
||||
@@ -0,0 +1,876 @@
|
||||
// podd: the assPod's body with real shaders, for SteamVR's scene graph.
|
||||
//
|
||||
// SteamVR draws render models with fixed lighting only, so this renders the original model
|
||||
// (device/asspod/model, exported from tools/asspod-model) itself on the GPU: clearcoat plastic on
|
||||
// the front, mirror-polished steel on the back and edges, the engraved peach as satin, glass over the
|
||||
// screen, all lit by a studio environment and one key light.
|
||||
//
|
||||
// How it reaches the headset: the body sits in a box of six panels in SteamVR's scene graph
|
||||
// (device/asspod/src/sg.js), rigid with the hand. For each face and each eye, podd renders the model
|
||||
// as seen from that eye through the face (an off-axis projection onto the face's plane), from
|
||||
// predicted head and hand poses. A face's panel shows its eye pair with SteamVR's "Parallel"
|
||||
// stereoscopy, so the body looks solid from any side, and the panels themselves move with the hand
|
||||
// at the compositor's rate. The screen is not drawn here: the window shows the live screen panel
|
||||
// inside the box (glass reflections over it, transparent otherwise).
|
||||
//
|
||||
// Input: --spec /dev/shm/lgs/podd.json {open, hand, rot [w,x,y,z], centre [x,y,z], scale, finish}
|
||||
// (the body in the controller's frame, written by device/shell_ext/asspod.py)
|
||||
// Output: --out /dev/shm/lgs/podd-out.json {pid, ok, key, texW, texH, box [hx,hy,hz], tiles {face: [x,y,w,h]}}
|
||||
// tiles are the left eye's rect in texture pixels (v = 0 at the top), with `pad` px of
|
||||
// overscan around each eye; the right eye's starts tw + 2 pad to its right. A face's panel
|
||||
// shows [tx - pad, tx + 2 tw + 3 pad] x [ty - pad, ty + th + pad]. The overlay's mouse scale
|
||||
// is half the texture's width (one eye).
|
||||
//
|
||||
// Build on the Frame: sh native/podd/build.sh (GBM, EGL, GLES 3.2, SteamVR's libopenvr_api).
|
||||
#include <openvr.h>
|
||||
#include <signal.h>
|
||||
#include <sys/file.h>
|
||||
#include <sys/prctl.h>
|
||||
#include <sys/stat.h>
|
||||
#include <time.h>
|
||||
|
||||
#include <array>
|
||||
#include <cmath>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <fstream>
|
||||
#include <sstream>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "gfx.h"
|
||||
#include "json.h"
|
||||
#include "vmath.h"
|
||||
|
||||
// ------------------------------------------------------------------ model constants (tools/asspod-model)
|
||||
static const float BODY_W = 0.0618f, BODY_H = 0.1035f, BODY_D = 0.0110f;
|
||||
static const float FRONT_Z = BODY_D / 2;
|
||||
static const float LCD_CY = 0.0242f, LCD_W = 0.0508f, LCD_H = 0.0381f;
|
||||
static const float WIN_W = 0.0532f, WIN_H = 0.0405f;
|
||||
static const float WHEEL_CY = -0.0240f, WHEEL_R = 0.0197f, WHEEL_RC = 0.0073f, WHEEL_RM = 0.0135f;
|
||||
static const float MARGIN = 0.004f; // m around the body: the box of panels
|
||||
static const float PX_PER_M = 4200.f; // panel pixels per metre (about 1 px per 0.24 mm)
|
||||
static const int PAD = 3; // px rendered past each face's edge: neighbouring faces overlap,
|
||||
// so no panel edge (where texels blend with the gap) shows
|
||||
|
||||
static const float kPi = 3.14159265f;
|
||||
static volatile sig_atomic_t g_stop = 0;
|
||||
static void onSignal(int) { g_stop = 1; }
|
||||
|
||||
static uint64_t nowNs() {
|
||||
timespec ts;
|
||||
clock_gettime(CLOCK_MONOTONIC, &ts);
|
||||
return uint64_t(ts.tv_sec) * 1000000000ull + uint64_t(ts.tv_nsec);
|
||||
}
|
||||
|
||||
static bool readFile(const std::string &p, std::string &out) {
|
||||
std::ifstream f(p, std::ios::binary);
|
||||
if (!f) return false;
|
||||
std::stringstream ss;
|
||||
ss << f.rdbuf();
|
||||
out = ss.str();
|
||||
return true;
|
||||
}
|
||||
|
||||
static void writeAtomic(const std::string &path, const std::string &text) {
|
||||
std::string tmp = path + ".tmp";
|
||||
FILE *f = std::fopen(tmp.c_str(), "w");
|
||||
if (!f) return;
|
||||
std::fwrite(text.data(), 1, text.size(), f);
|
||||
std::fclose(f);
|
||||
std::rename(tmp.c_str(), path.c_str());
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ meshes
|
||||
struct Mesh {
|
||||
GLuint vao = 0, vbo = 0;
|
||||
GLsizei count = 0;
|
||||
};
|
||||
|
||||
// Our exporter's OBJ: v / vt / vn, then non-indexed triangles "f i/i/i i/i/i i/i/i".
|
||||
static bool loadObj(const std::string &path, Mesh &m) {
|
||||
std::string text;
|
||||
if (!readFile(path, text)) return false;
|
||||
std::vector<float> pos, uv, nrm, out;
|
||||
std::istringstream in(text);
|
||||
std::string line;
|
||||
while (std::getline(in, line)) {
|
||||
if (line.size() < 2) continue;
|
||||
if (line[0] == 'v' && line[1] == ' ') {
|
||||
float x, y, z;
|
||||
if (std::sscanf(line.c_str() + 2, "%f %f %f", &x, &y, &z) == 3) pos.insert(pos.end(), {x, y, z});
|
||||
} else if (line[0] == 'v' && line[1] == 't') {
|
||||
float u, v;
|
||||
if (std::sscanf(line.c_str() + 3, "%f %f", &u, &v) == 2) uv.insert(uv.end(), {u, v});
|
||||
} else if (line[0] == 'v' && line[1] == 'n') {
|
||||
float x, y, z;
|
||||
if (std::sscanf(line.c_str() + 3, "%f %f %f", &x, &y, &z) == 3) nrm.insert(nrm.end(), {x, y, z});
|
||||
} else if (line[0] == 'f' && line[1] == ' ') {
|
||||
int a[3][3];
|
||||
if (std::sscanf(line.c_str() + 2, "%d/%d/%d %d/%d/%d %d/%d/%d", &a[0][0], &a[0][1], &a[0][2], &a[1][0], &a[1][1],
|
||||
&a[1][2], &a[2][0], &a[2][1], &a[2][2]) != 9)
|
||||
continue;
|
||||
for (auto &c : a) {
|
||||
int p = c[0] - 1, t = c[1] - 1, n = c[2] - 1;
|
||||
if (p < 0 || size_t(p) * 3 + 2 >= pos.size() || t < 0 || size_t(t) * 2 + 1 >= uv.size() || n < 0 ||
|
||||
size_t(n) * 3 + 2 >= nrm.size())
|
||||
return false;
|
||||
out.insert(out.end(), {pos[p * 3], pos[p * 3 + 1], pos[p * 3 + 2], nrm[n * 3], nrm[n * 3 + 1], nrm[n * 3 + 2],
|
||||
uv[t * 2], uv[t * 2 + 1]});
|
||||
}
|
||||
}
|
||||
}
|
||||
if (out.empty()) return false;
|
||||
glGenVertexArrays(1, &m.vao);
|
||||
glGenBuffers(1, &m.vbo);
|
||||
glBindVertexArray(m.vao);
|
||||
glBindBuffer(GL_ARRAY_BUFFER, m.vbo);
|
||||
glBufferData(GL_ARRAY_BUFFER, GLsizeiptr(out.size() * sizeof(float)), out.data(), GL_STATIC_DRAW);
|
||||
for (int i = 0; i < 3; i++) glEnableVertexAttribArray(GLuint(i));
|
||||
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 32, reinterpret_cast<void *>(0));
|
||||
glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, 32, reinterpret_cast<void *>(12));
|
||||
glVertexAttribPointer(2, 2, GL_FLOAT, GL_FALSE, 32, reinterpret_cast<void *>(24));
|
||||
glBindVertexArray(0);
|
||||
m.count = GLsizei(out.size() / 8);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Raw RGBA from the exporter: "RGBA", u32 width, u32 height, rows top first.
|
||||
static GLuint loadRgba(const std::string &path) {
|
||||
std::string d;
|
||||
if (!readFile(path, d) || d.size() < 12 || d.compare(0, 4, "RGBA") != 0) return 0;
|
||||
uint32_t w, h;
|
||||
std::memcpy(&w, d.data() + 4, 4);
|
||||
std::memcpy(&h, d.data() + 8, 4);
|
||||
if (d.size() < 12 + size_t(w) * h * 4) return 0;
|
||||
GLuint t;
|
||||
glGenTextures(1, &t);
|
||||
glBindTexture(GL_TEXTURE_2D, t);
|
||||
int levels = 1;
|
||||
for (uint32_t m = std::max(w, h); m > 1; m >>= 1) levels++;
|
||||
glTexStorage2D(GL_TEXTURE_2D, levels, GL_RGBA8, GLsizei(w), GLsizei(h));
|
||||
glPixelStorei(GL_UNPACK_ALIGNMENT, 1);
|
||||
glTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, GLsizei(w), GLsizei(h), GL_RGBA, GL_UNSIGNED_BYTE, d.data() + 12);
|
||||
glGenerateMipmap(GL_TEXTURE_2D);
|
||||
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_LINEAR);
|
||||
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
|
||||
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
|
||||
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
|
||||
return t;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ shaders
|
||||
static const char *VS = R"GLSL(#version 320 es
|
||||
layout(location = 0) in vec3 aPos;
|
||||
layout(location = 1) in vec3 aNrm;
|
||||
layout(location = 2) in vec2 aUv;
|
||||
uniform mat4 uProj, uView, uModel; // model: the part's own motion (wheel rock, button sink), in body units
|
||||
uniform float uScale; // model metres -> body metres
|
||||
out vec3 vPos; // body space (metres)
|
||||
out vec3 vNrm;
|
||||
out vec2 vUv;
|
||||
out vec3 vModel; // the model's own coordinates (metres, unscaled)
|
||||
void main() {
|
||||
vec4 p = uModel * vec4(aPos, 1.0);
|
||||
vModel = p.xyz;
|
||||
vPos = p.xyz * uScale;
|
||||
vNrm = mat3(uModel) * aNrm;
|
||||
vUv = aUv;
|
||||
gl_Position = uProj * uView * vec4(vPos, 1.0);
|
||||
}
|
||||
)GLSL";
|
||||
|
||||
static const char *FS = R"GLSL(#version 320 es
|
||||
precision highp float;
|
||||
in vec3 vPos;
|
||||
in vec3 vNrm;
|
||||
in vec2 vUv;
|
||||
in vec3 vModel;
|
||||
uniform vec3 uEye; // body space
|
||||
uniform mat3 uToWorld; // body -> world rotation (the environment is fixed in the room)
|
||||
uniform int uMat; // 0 plate, 1 steel, 2 steel back (engraving), 3 wheel, 4 well, 5 ports, 6 window glass
|
||||
uniform vec3 uBase; // base colour (linear)
|
||||
uniform sampler2D uTex; // wheel labels / engraving mask
|
||||
uniform vec4 uMarker; // x angle (rad, 0 = top, clockwise), y opacity
|
||||
out vec4 frag;
|
||||
|
||||
const float PI = 3.14159265;
|
||||
const vec3 KEY = normalize(vec3(-0.45, 0.80, 0.40));
|
||||
const vec3 FILL = normalize(vec3(0.70, 0.35, 0.60));
|
||||
|
||||
// The studio, as seen along world direction r, blurred for roughness: dark surroundings (so glossy
|
||||
// black stays black and polished steel has contrast), a soft grey ceiling, and bright soft boxes.
|
||||
vec3 envColor(vec3 r, float rough) {
|
||||
float y = r.y;
|
||||
float w = 0.03 + rough * 0.6;
|
||||
float up = smoothstep(0.05 - w, 0.55 + w, y);
|
||||
vec3 c = mix(vec3(0.016, 0.017, 0.019), vec3(0.10, 0.105, 0.115), up);
|
||||
c = mix(c, vec3(0.05, 0.048, 0.045), smoothstep(-0.05 + w, -0.35 - w, y)); // the floor
|
||||
// the key soft box above-left, a fill on the right, a long strip light behind
|
||||
float lk = 0.004 + rough * rough * 0.9;
|
||||
float key = exp(-(1.0 - dot(r, KEY)) / lk) * (0.004 / lk) * 6.0;
|
||||
float lf = 0.012 + rough * rough * 0.9;
|
||||
float fill = exp(-(1.0 - dot(r, FILL)) / lf) * (0.012 / lf) * 1.5;
|
||||
float sw = 0.0015 + rough * 0.25;
|
||||
float strip = exp(-pow(r.x + 0.55, 2.0) / sw) * smoothstep(-0.2, 0.3, y) * smoothstep(0.95, 0.5, y) * 1.1 * (0.0015 / sw + 0.15 * rough);
|
||||
return c + vec3(key + fill) + vec3(1.0, 0.98, 0.95) * strip;
|
||||
}
|
||||
vec3 irradiance(vec3 n) {
|
||||
vec3 c = mix(vec3(0.05, 0.05, 0.05), vec3(0.22, 0.23, 0.25), n.y * 0.5 + 0.5);
|
||||
return c + vec3(1.0, 0.98, 0.95) * max(dot(n, KEY), 0.0) * 0.32 + vec3(0.14) * max(dot(n, FILL), 0.0);
|
||||
}
|
||||
float fresnel(float f0, float cosT) { return f0 + (1.0 - f0) * pow(1.0 - cosT, 5.0); }
|
||||
vec3 fresnel3(vec3 f0, float cosT) { return f0 + (1.0 - f0) * pow(1.0 - cosT, 5.0); }
|
||||
|
||||
void main() {
|
||||
vec3 n = normalize(vNrm);
|
||||
vec3 v = normalize(uEye - vPos);
|
||||
if (dot(n, v) < 0.0) n = -n; // two-sided (thin parts)
|
||||
vec3 nw = normalize(uToWorld * n);
|
||||
vec3 r = normalize(uToWorld * reflect(-v, n));
|
||||
float cosT = clamp(dot(n, v), 0.0, 1.0);
|
||||
vec3 col;
|
||||
float alpha = 1.0;
|
||||
if (uMat == 1 || uMat == 2) {
|
||||
// polished stainless steel; the engraving is a satin etch
|
||||
float etch = uMat == 2 ? texture(uTex, vec2(vUv.x, 1.0 - vUv.y)).r : 0.0;
|
||||
float rough = mix(0.06, 0.38, etch);
|
||||
vec3 f0 = mix(vec3(0.90, 0.91, 0.93), vec3(0.74, 0.75, 0.78), etch);
|
||||
col = fresnel3(f0, cosT) * envColor(r, rough);
|
||||
} else if (uMat == 3) {
|
||||
// the click wheel: printed labels on satin plastic
|
||||
vec3 base = texture(uTex, vec2(vUv.x, 1.0 - vUv.y)).rgb;
|
||||
base = pow(base, vec3(2.2));
|
||||
col = base * irradiance(nw) + fresnel(0.04, cosT) * envColor(r, 0.45);
|
||||
// the touch marker: a soft glow where the thumb is
|
||||
if (uMarker.y > 0.001) {
|
||||
vec2 p = vModel.xy - vec2(0.0, -0.0240);
|
||||
vec2 m = vec2(sin(uMarker.x), cos(uMarker.x)) * 0.0135;
|
||||
float d = length(p - m);
|
||||
col += vec3(0.55, 0.72, 1.0) * uMarker.y * exp(-d * d / (2.0 * 0.0028 * 0.0028)) * 0.9;
|
||||
}
|
||||
} else if (uMat == 4) {
|
||||
col = uBase * irradiance(nw);
|
||||
} else if (uMat == 5) {
|
||||
col = uBase * irradiance(nw) + fresnel(0.3, cosT) * envColor(r, 0.35) * 0.5;
|
||||
} else if (uMat == 6) {
|
||||
// the window: black glossy border; the LCD itself transparent, with the glass's reflection
|
||||
vec2 p = vModel.xy - vec2(0.0, 0.0242);
|
||||
bool lcd = abs(p.x) < 0.0254 && abs(p.y) < 0.01905;
|
||||
vec3 refl = fresnel(0.04, cosT) * envColor(r, 0.02);
|
||||
if (lcd) {
|
||||
float a = clamp(max(refl.r, max(refl.g, refl.b)), 0.0, 1.0);
|
||||
frag = vec4(refl, a); // premultiplied: the reflection over whatever is behind
|
||||
frag.rgb = pow(frag.rgb / (1.0 + frag.rgb), vec3(1.0 / 2.2)) * a;
|
||||
return;
|
||||
}
|
||||
col = vec3(0.004) + refl;
|
||||
} else {
|
||||
// the front plate and centre button: plastic under a clear coat
|
||||
vec3 diffuse = uBase * irradiance(nw);
|
||||
vec3 spec = fresnel(0.04, cosT) * envColor(r, uBase.r > 0.5 ? 0.18 : 0.12);
|
||||
vec3 coat = fresnel(0.04, cosT) * envColor(r, 0.03);
|
||||
col = diffuse * (1.0 - fresnel(0.04, cosT)) + spec * 0.5 + coat;
|
||||
}
|
||||
// filmic tone map and gamma (the overlay is submitted as gamma colour)
|
||||
col = col / (1.0 + col * 0.55) * 1.1;
|
||||
frag = vec4(pow(max(col, 0.0), vec3(1.0 / 2.2)), alpha);
|
||||
}
|
||||
)GLSL";
|
||||
|
||||
// ------------------------------------------------------------------ the box and its atlas
|
||||
struct Face {
|
||||
const char *name;
|
||||
v3 c, R, U, N; // body space: centre, right, up, outward normal
|
||||
float w, h; // metres
|
||||
int tx, ty, tw, th; // the left eye's tile (texture px, top-left origin) inside PAD px of overscan;
|
||||
// the right eye's at tx + tw + 2 PAD
|
||||
};
|
||||
|
||||
struct Layout {
|
||||
std::array<Face, 6> f;
|
||||
int texW = 0, texH = 0;
|
||||
float hx = 0, hy = 0, hz = 0;
|
||||
int vx = 0, vy = 0; // the video tile: the playing video's picture twice (left eye, right eye), like a face block
|
||||
};
|
||||
|
||||
// The video's picture: device/shell_ext/asspod.py writes the latest frame to VIDEO_FILE as
|
||||
// "PVID", u32 seq, u32 width, u32 height, then RGBA rows top first (letterboxed to 4:3).
|
||||
static const int VIDEO_W = 320, VIDEO_H = 240;
|
||||
static const char *VIDEO_FILE = "/dev/shm/lgs/podd-video";
|
||||
|
||||
static Layout makeLayout(float scale) {
|
||||
Layout L;
|
||||
L.hx = BODY_W * scale / 2 + MARGIN;
|
||||
L.hy = BODY_H * scale / 2 + MARGIN;
|
||||
L.hz = BODY_D * scale / 2 + MARGIN;
|
||||
const float hx = L.hx, hy = L.hy, hz = L.hz;
|
||||
L.f = {{
|
||||
{"front", {0, 0, hz}, {1, 0, 0}, {0, 1, 0}, {0, 0, 1}, 2 * hx, 2 * hy},
|
||||
{"back", {0, 0, -hz}, {-1, 0, 0}, {0, 1, 0}, {0, 0, -1}, 2 * hx, 2 * hy},
|
||||
{"right", {hx, 0, 0}, {0, 0, -1}, {0, 1, 0}, {1, 0, 0}, 2 * hz, 2 * hy},
|
||||
{"left", {-hx, 0, 0}, {0, 0, 1}, {0, 1, 0}, {-1, 0, 0}, 2 * hz, 2 * hy},
|
||||
{"top", {0, hy, 0}, {1, 0, 0}, {0, 0, -1}, {0, 1, 0}, 2 * hx, 2 * hz},
|
||||
{"bottom", {0, -hy, 0}, {1, 0, 0}, {0, 0, 1}, {0, -1, 0}, 2 * hx, 2 * hz},
|
||||
}};
|
||||
// two rows: front, back, right, left (all as tall as the body); then top and bottom. A face's block
|
||||
// is both eyes with PAD px around each: [PAD | left eye | 2 PAD | right eye | PAD]
|
||||
const int gap = 4;
|
||||
int x = 0, row0 = 0;
|
||||
for (int i = 0; i < 4; i++) {
|
||||
Face &F = L.f[size_t(i)];
|
||||
F.tw = int(std::lround(F.w * PX_PER_M));
|
||||
F.th = int(std::lround(F.h * PX_PER_M));
|
||||
F.tx = x + PAD;
|
||||
F.ty = PAD;
|
||||
x += 2 * F.tw + 4 * PAD + gap;
|
||||
row0 = std::max(row0, F.th + 2 * PAD);
|
||||
}
|
||||
int w0 = x;
|
||||
x = 0;
|
||||
int row1 = 0;
|
||||
for (int i = 4; i < 6; i++) {
|
||||
Face &F = L.f[size_t(i)];
|
||||
F.tw = int(std::lround(F.w * PX_PER_M));
|
||||
F.th = int(std::lround(F.h * PX_PER_M));
|
||||
F.tx = x + PAD;
|
||||
F.ty = row0 + gap + PAD;
|
||||
x += 2 * F.tw + 4 * PAD + gap;
|
||||
row1 = std::max(row1, F.th + 2 * PAD);
|
||||
}
|
||||
// the video tile under the second row
|
||||
L.vx = x;
|
||||
L.vy = row0 + gap;
|
||||
x += 2 * VIDEO_W + gap;
|
||||
L.texW = (std::max(w0, x) + 63) / 64 * 64;
|
||||
L.texH = (row0 + gap + std::max(row1, VIDEO_H) + 7) / 8 * 8;
|
||||
return L;
|
||||
}
|
||||
|
||||
// Off-axis projection through a face (Kooima's generalized perspective), eye in body space.
|
||||
static bool faceMatrices(const Face &F, v3 e, float proj[16], float view[16]) {
|
||||
v3 pa = F.c - F.R * (F.w / 2) - F.U * (F.h / 2);
|
||||
v3 pb = F.c + F.R * (F.w / 2) - F.U * (F.h / 2);
|
||||
v3 pc = F.c - F.R * (F.w / 2) + F.U * (F.h / 2);
|
||||
v3 va = pa - e, vb = pb - e, vc = pc - e;
|
||||
float d = -dot(va, F.N);
|
||||
if (d < 0.002f) return false; // the eye is behind (or in) this face's plane
|
||||
const float n = 0.002f, f = 4.0f;
|
||||
float l = dot(F.R, va) * n / d, r = dot(F.R, vb) * n / d;
|
||||
float b = dot(F.U, va) * n / d, t = dot(F.U, vc) * n / d;
|
||||
// memory row 0 is the texture's top and GL's y = 0: map the face's top (+U) to y = -1
|
||||
std::swap(b, t);
|
||||
std::fill(proj, proj + 16, 0.f);
|
||||
proj[0] = 2 * n / (r - l);
|
||||
proj[5] = 2 * n / (t - b);
|
||||
proj[8] = (r + l) / (r - l);
|
||||
proj[9] = (t + b) / (t - b);
|
||||
proj[10] = -(f + n) / (f - n);
|
||||
proj[11] = -1;
|
||||
proj[14] = -2 * f * n / (f - n);
|
||||
// view: rotate into the face's frame (rows R, U, N), then move the eye to the origin
|
||||
const v3 R = F.R, U = F.U, N = F.N;
|
||||
view[0] = R.x; view[4] = R.y; view[8] = R.z;
|
||||
view[1] = U.x; view[5] = U.y; view[9] = U.z;
|
||||
view[2] = N.x; view[6] = N.y; view[10] = N.z;
|
||||
view[3] = view[7] = view[11] = 0;
|
||||
view[12] = -dot(R, e);
|
||||
view[13] = -dot(U, e);
|
||||
view[14] = -dot(N, e);
|
||||
view[15] = 1;
|
||||
return true;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ the thumbstick (as device/asspod/src/input/controllers.js)
|
||||
struct Stick {
|
||||
v3 P, axis;
|
||||
};
|
||||
static const Stick STICK_R = {{-0.0218f, -0.0049f, 0.0564f}, {-0.173f, 0.807f, -0.565f}};
|
||||
static const Stick STICK_L = {{0.0218f, -0.0049f, 0.0565f}, {0.168f, 0.797f, -0.581f}};
|
||||
|
||||
struct HandInput {
|
||||
std::string rm;
|
||||
vr::VRInputValueHandle_t path = 0;
|
||||
float x = 0, y = 0;
|
||||
bool clicked = false;
|
||||
};
|
||||
|
||||
static void readStick(HandInput &h, const Stick &S) {
|
||||
h.x = h.y = 0;
|
||||
h.clicked = false;
|
||||
if (h.rm.empty() || !h.path || !vr::VRRenderModels()) return;
|
||||
vr::RenderModel_ComponentState_t cs{};
|
||||
vr::RenderModel_ControllerMode_State_t mode{};
|
||||
if (!vr::VRRenderModels()->GetComponentStateForDevicePath(h.rm.c_str(), "thumbstick", h.path, &mode, &cs)) return;
|
||||
Pose xf = fromVR(cs.mTrackingToComponentRenderModel);
|
||||
quat q = quatFrom(xf);
|
||||
if (q.w < 0) q = {-q.w, -q.x, -q.y, -q.z};
|
||||
v3 s = normalize(S.axis);
|
||||
auto proj = [&](v3 v) { return normalize(v - s * dot(v, s)); };
|
||||
v3 right = proj({1, 0, 0}), fwd = proj({0, 0, -1});
|
||||
v3 d = cross({q.x, q.y, q.z}, s);
|
||||
float x = dot(d, right) / 0.165f, y = dot(d, fwd) / 0.165f;
|
||||
float r = std::sqrt(x * x + y * y);
|
||||
if (r > 1) { x /= r; y /= r; }
|
||||
h.x = x;
|
||||
h.y = y;
|
||||
v3 rp = poseFrom(q, {}).rotate(S.P);
|
||||
v3 resid = xf.t - (S.P - rp);
|
||||
h.clicked = -dot(resid, s) > 0.00025f;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ main
|
||||
struct Spec {
|
||||
bool open = false;
|
||||
std::string hand = "right";
|
||||
quat rot;
|
||||
v3 centre;
|
||||
float scale = 1.375f;
|
||||
std::string finish = "black";
|
||||
bool world = false; // pinned in the room (Reposition): wrot / wcentre in standing space
|
||||
quat wrot;
|
||||
v3 wcentre;
|
||||
};
|
||||
|
||||
static bool readSpec(const std::string &path, Spec &s) {
|
||||
std::string t, err;
|
||||
if (!readFile(path, t)) return false;
|
||||
JVal j;
|
||||
JParser p(t);
|
||||
if (!p.parse(j, err)) return false;
|
||||
s.open = j.boolean("open", false);
|
||||
s.hand = j.str("hand", "right");
|
||||
s.scale = float(j.num("scale", 1.375));
|
||||
s.finish = j.str("finish", "black");
|
||||
const JVal *r = j.get("rot"), *c = j.get("centre");
|
||||
if (r && r->type == JVal::Arr && r->a.size() == 4) s.rot = qnorm({float(r->a[0].n), float(r->a[1].n), float(r->a[2].n), float(r->a[3].n)});
|
||||
if (c && c->type == JVal::Arr && c->a.size() == 3) s.centre = {float(c->a[0].n), float(c->a[1].n), float(c->a[2].n)};
|
||||
s.world = false;
|
||||
const JVal *w = j.get("world");
|
||||
if (w && w->type == JVal::Obj) {
|
||||
const JVal *wr = w->get("rot"), *wc = w->get("centre");
|
||||
if (wr && wr->type == JVal::Arr && wr->a.size() == 4 && wc && wc->type == JVal::Arr && wc->a.size() == 3) {
|
||||
s.world = true;
|
||||
s.wrot = qnorm({float(wr->a[0].n), float(wr->a[1].n), float(wr->a[2].n), float(wr->a[3].n)});
|
||||
s.wcentre = {float(wc->a[0].n), float(wc->a[1].n), float(wc->a[2].n)};
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
int main(int argc, char **argv) {
|
||||
std::string specPath = "/dev/shm/lgs/podd.json", outPath = "/dev/shm/lgs/podd-out.json", modelDir, key = "podd.pod";
|
||||
float fps = 72;
|
||||
bool orphanOk = false;
|
||||
for (int i = 1; i < argc; i++) {
|
||||
std::string a = argv[i];
|
||||
auto next = [&]() -> std::string { return i + 1 < argc ? argv[++i] : ""; };
|
||||
if (a == "--spec") specPath = next();
|
||||
else if (a == "--out") outPath = next();
|
||||
else if (a == "--model") modelDir = next();
|
||||
else if (a == "--key") key = next();
|
||||
else if (a == "--fps") fps = std::stof(next());
|
||||
else if (a == "--orphan-ok") orphanOk = true;
|
||||
else { std::fprintf(stderr, "unknown option %s\n", a.c_str()); return 2; }
|
||||
}
|
||||
if (modelDir.empty()) { std::fprintf(stderr, "--model DIR is required\n"); return 2; }
|
||||
if (!orphanOk) prctl(PR_SET_PDEATHSIG, SIGTERM);
|
||||
signal(SIGTERM, onSignal);
|
||||
signal(SIGINT, onSignal);
|
||||
signal(SIGHUP, onSignal);
|
||||
setvbuf(stdout, nullptr, _IOLBF, 0);
|
||||
|
||||
int lock = open((outPath + ".lock").c_str(), O_CREAT | O_RDWR | O_CLOEXEC, 0644);
|
||||
if (lock < 0 || flock(lock, LOCK_EX | LOCK_NB) != 0) { std::printf("another podd is running\n"); return 75; }
|
||||
|
||||
vr::EVRInitError err = vr::VRInitError_None;
|
||||
vr::VR_Init(&err, vr::VRApplication_Overlay);
|
||||
if (err != vr::VRInitError_None) { std::printf("VR_Init: %s\n", vr::VR_GetVRInitErrorAsEnglishDescription(err)); return 75; }
|
||||
if (!vr::VROverlay() || !vr::VRSystem() || !vr::VRIPCResourceManager()) { std::printf("OpenVR interfaces missing\n"); return 1; }
|
||||
|
||||
Gfx gfx;
|
||||
if (!gfx.init()) return 1;
|
||||
std::printf("podd: %s\n", gfx.renderer.c_str());
|
||||
|
||||
Program prog;
|
||||
if (!linkProgram(prog, VS, FS, "pod")) return 1;
|
||||
|
||||
// the parts (both finishes' plates, wheels and buttons are loaded; the spec picks)
|
||||
struct Part { std::string name; int mat; v3 base; Mesh mesh; GLuint tex = 0; bool wheel = false, center = false; };
|
||||
std::vector<Part> parts = {
|
||||
{"asspod_steel_back", 2, {0.9f, 0.9f, 0.9f}},
|
||||
{"asspod_steel_edge", 1, {0.9f, 0.9f, 0.9f}},
|
||||
{"asspod_ports", 5, {0.03f, 0.03f, 0.035f}},
|
||||
{"asspod_plate_black", 0, {0.004f, 0.004f, 0.0045f}},
|
||||
{"asspod_plate_white", 0, {0.86f, 0.86f, 0.84f}},
|
||||
{"asspod_well_black", 4, {0.002f, 0.002f, 0.002f}},
|
||||
{"asspod_well_white", 4, {0.25f, 0.25f, 0.24f}},
|
||||
{"asspod_wheel_black", 3, {1, 1, 1}},
|
||||
{"asspod_wheel_white", 3, {1, 1, 1}},
|
||||
{"asspod_center_black", 0, {0.004f, 0.004f, 0.0045f}},
|
||||
{"asspod_center_white", 0, {0.86f, 0.86f, 0.84f}},
|
||||
};
|
||||
for (auto &p : parts) {
|
||||
std::string dir = modelDir + "/" + p.name + "/";
|
||||
if (!loadObj(dir + p.name + ".obj", p.mesh)) { std::printf("cannot load %s\n", p.name.c_str()); return 1; }
|
||||
if (p.mat == 3) p.tex = loadRgba(dir + p.name + "_color.rgba");
|
||||
if (p.mat == 2) p.tex = loadRgba(dir + p.name + "_mask.rgba");
|
||||
p.wheel = p.name.find("wheel") != std::string::npos;
|
||||
p.center = p.name.find("center") != std::string::npos;
|
||||
}
|
||||
// the window: a quad over the plate's opening (border + transparent LCD), in model metres
|
||||
Mesh window;
|
||||
{
|
||||
const float z = FRONT_Z + 0.00008f, hw = WIN_W / 2, hh = WIN_H / 2, cy = LCD_CY;
|
||||
const float q[] = {-hw, cy - hh, z, 0, 0, 1, 0, 0, hw, cy - hh, z, 0, 0, 1, 1, 0, hw, cy + hh, z, 0, 0, 1, 1, 1,
|
||||
-hw, cy - hh, z, 0, 0, 1, 0, 0, hw, cy + hh, z, 0, 0, 1, 1, 1, -hw, cy + hh, z, 0, 0, 1, 0, 1};
|
||||
glGenVertexArrays(1, &window.vao);
|
||||
glGenBuffers(1, &window.vbo);
|
||||
glBindVertexArray(window.vao);
|
||||
glBindBuffer(GL_ARRAY_BUFFER, window.vbo);
|
||||
glBufferData(GL_ARRAY_BUFFER, sizeof q, q, GL_STATIC_DRAW);
|
||||
for (int i = 0; i < 3; i++) glEnableVertexAttribArray(GLuint(i));
|
||||
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 32, reinterpret_cast<void *>(0));
|
||||
glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, 32, reinterpret_cast<void *>(12));
|
||||
glVertexAttribPointer(2, 2, GL_FLOAT, GL_FALSE, 32, reinterpret_cast<void *>(24));
|
||||
glBindVertexArray(0);
|
||||
window.count = 6;
|
||||
}
|
||||
(void)LCD_W; (void)LCD_H; (void)WHEEL_R; (void)WHEEL_RC; (void)WHEEL_RM;
|
||||
|
||||
Spec spec;
|
||||
struct stat lastSt{};
|
||||
Layout L = makeLayout(spec.scale);
|
||||
float layoutScale = -1;
|
||||
|
||||
vr::VROverlayHandle_t ov = vr::k_ulOverlayHandleInvalid;
|
||||
if (vr::VROverlay()->CreateOverlay(key.c_str(), "assPod", &ov) != vr::VROverlayError_None) {
|
||||
std::printf("CreateOverlay(%s) failed\n", key.c_str());
|
||||
return 75;
|
||||
}
|
||||
vr::VROverlay()->SetOverlayFlag(ov, vr::VROverlayFlags_IsPremultiplied, true);
|
||||
vr::VROverlay()->SetOverlayWidthInMeters(ov, 1.0f);
|
||||
|
||||
std::array<DmaTarget, 3> bufs;
|
||||
GLuint depthRb = 0, msColor = 0, msFbo = 0; // 4x MSAA, resolved into the dmabuf SteamVR reads
|
||||
GLint samples = 4;
|
||||
int cur = 0;
|
||||
bool mouseSet = false;
|
||||
auto release = [&]() {
|
||||
for (auto &b : bufs) {
|
||||
if (b.vrHandle) vr::VRIPCResourceManager()->UnrefResource(vr::SharedTextureHandle_t(b.vrHandle));
|
||||
if (b.bo) b.destroy(gfx);
|
||||
}
|
||||
if (depthRb) { glDeleteRenderbuffers(1, &depthRb); depthRb = 0; }
|
||||
if (msColor) { glDeleteRenderbuffers(1, &msColor); msColor = 0; }
|
||||
if (msFbo) { glDeleteFramebuffers(1, &msFbo); msFbo = 0; }
|
||||
};
|
||||
auto allocate = [&]() -> bool {
|
||||
release();
|
||||
for (auto &b : bufs) {
|
||||
if (!b.create(gfx, L.texW, L.texH)) return false;
|
||||
vr::DmabufAttributes_t a{};
|
||||
a.unWidth = uint32_t(L.texW);
|
||||
a.unHeight = uint32_t(L.texH);
|
||||
a.unDepth = a.unMipLevels = a.unArrayLayers = a.unSampleCount = 1;
|
||||
a.unFormat = GBM_FORMAT_ABGR8888;
|
||||
a.ulModifier = DRM_FORMAT_MOD_LINEAR;
|
||||
a.unPlaneCount = 1;
|
||||
a.plane[0].unOffset = b.offset;
|
||||
a.plane[0].unStride = b.stride;
|
||||
a.plane[0].nFd = b.fd;
|
||||
vr::SharedTextureHandle_t h = 0;
|
||||
if (!vr::VRIPCResourceManager()->ImportDmabuf(vr::VRApplication_Overlay, &a, &h) || !h) {
|
||||
std::printf("ImportDmabuf failed\n");
|
||||
return false;
|
||||
}
|
||||
b.vrHandle = h;
|
||||
}
|
||||
GLint maxS = 0;
|
||||
glGetIntegerv(GL_MAX_SAMPLES, &maxS);
|
||||
samples = std::min<GLint>(4, std::max<GLint>(1, maxS));
|
||||
glGenRenderbuffers(1, &msColor);
|
||||
glBindRenderbuffer(GL_RENDERBUFFER, msColor);
|
||||
glRenderbufferStorageMultisample(GL_RENDERBUFFER, samples, GL_RGBA8, L.texW, L.texH);
|
||||
glGenRenderbuffers(1, &depthRb);
|
||||
glBindRenderbuffer(GL_RENDERBUFFER, depthRb);
|
||||
glRenderbufferStorageMultisample(GL_RENDERBUFFER, samples, GL_DEPTH_COMPONENT24, L.texW, L.texH);
|
||||
glGenFramebuffers(1, &msFbo);
|
||||
glBindFramebuffer(GL_FRAMEBUFFER, msFbo);
|
||||
glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_RENDERBUFFER, msColor);
|
||||
glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT, GL_RENDERBUFFER, depthRb);
|
||||
if (glCheckFramebufferStatus(GL_FRAMEBUFFER) != GL_FRAMEBUFFER_COMPLETE) { std::printf("MSAA framebuffer incomplete\n"); return false; }
|
||||
glBindFramebuffer(GL_FRAMEBUFFER, 0);
|
||||
std::printf("MSAA x%d\n", samples);
|
||||
mouseSet = false;
|
||||
return true;
|
||||
};
|
||||
|
||||
auto writeOut = [&](bool ok, long frames, float fpsNow, float ms) {
|
||||
std::string o = "{\"pid\": " + std::to_string(getpid()) + ", \"ok\": " + (ok ? "true" : "false") + ", \"key\": \"" + key +
|
||||
"\", \"texW\": " + std::to_string(L.texW) + ", \"texH\": " + std::to_string(L.texH) + ", \"pad\": " + std::to_string(PAD) + ", \"box\": [" +
|
||||
std::to_string(L.hx) + ", " + std::to_string(L.hy) + ", " + std::to_string(L.hz) + "], \"tiles\": {";
|
||||
for (size_t i = 0; i < L.f.size(); i++) {
|
||||
const Face &F = L.f[i];
|
||||
o += std::string(i ? ", " : "") + "\"" + F.name + "\": [" + std::to_string(F.tx) + ", " + std::to_string(F.ty) + ", " +
|
||||
std::to_string(F.tw) + ", " + std::to_string(F.th) + "]";
|
||||
}
|
||||
o += "}, \"video\": [" + std::to_string(L.vx) + ", " + std::to_string(L.vy) + ", " + std::to_string(VIDEO_W) + ", " +
|
||||
std::to_string(VIDEO_H) + "]";
|
||||
o += ", \"frames\": " + std::to_string(frames) + ", \"fps\": " + std::to_string(fpsNow) + ", \"cpu_ms\": " + std::to_string(ms) +
|
||||
", \"updated\": " + std::to_string(double(time(nullptr))) + "}\n";
|
||||
writeAtomic(outPath, o);
|
||||
};
|
||||
|
||||
// the video's picture: a texture the frames go into, and a framebuffer to copy it into its tile
|
||||
GLuint videoTex = 0, videoFbo = 0;
|
||||
long videoFrames = 0;
|
||||
struct stat videoSt{};
|
||||
{
|
||||
glGenTextures(1, &videoTex);
|
||||
glBindTexture(GL_TEXTURE_2D, videoTex);
|
||||
glTexStorage2D(GL_TEXTURE_2D, 1, GL_RGBA8, VIDEO_W, VIDEO_H);
|
||||
std::vector<uint8_t> black(size_t(VIDEO_W) * VIDEO_H * 4, 0);
|
||||
for (size_t i = 3; i < black.size(); i += 4) black[i] = 255;
|
||||
glTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, VIDEO_W, VIDEO_H, GL_RGBA, GL_UNSIGNED_BYTE, black.data());
|
||||
glGenFramebuffers(1, &videoFbo);
|
||||
glBindFramebuffer(GL_FRAMEBUFFER, videoFbo);
|
||||
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, videoTex, 0);
|
||||
glBindFramebuffer(GL_FRAMEBUFFER, 0);
|
||||
}
|
||||
(void)videoFrames;
|
||||
|
||||
HandInput handIn[2];
|
||||
vr::VRInputValueHandle_t pathR = 0, pathL = 0;
|
||||
if (vr::VRInput()) {
|
||||
vr::VRInput()->GetInputSourceHandle("/user/hand/right", &pathR);
|
||||
vr::VRInput()->GetInputSourceHandle("/user/hand/left", &pathL);
|
||||
}
|
||||
handIn[0].path = pathL;
|
||||
handIn[1].path = pathR;
|
||||
float marker = 0, markerAngle = 0, sink = 0, rock = 0, rockAngle = 0;
|
||||
|
||||
long frames = 0;
|
||||
uint64_t lastOut = 0, fpsT0 = nowNs();
|
||||
long fpsFrames = 0;
|
||||
float fpsNow = 0, cpuMs = 0;
|
||||
const uint64_t frameNs = uint64_t(1e9 / std::max(10.f, fps));
|
||||
uint64_t lastFrameNs = nowNs();
|
||||
std::printf("podd: ready (%s)\n", key.c_str());
|
||||
|
||||
while (!g_stop) {
|
||||
vr::VREvent_t ev;
|
||||
while (vr::VRSystem()->PollNextEvent(&ev, sizeof ev)) {
|
||||
if (ev.eventType == vr::VREvent_Quit) { g_stop = 1; break; }
|
||||
}
|
||||
if (!orphanOk && getppid() == 1) break;
|
||||
// the spec, when it changed
|
||||
struct stat st{};
|
||||
if (stat(specPath.c_str(), &st) == 0 && (st.st_mtim.tv_sec != lastSt.st_mtim.tv_sec || st.st_mtim.tv_nsec != lastSt.st_mtim.tv_nsec)) {
|
||||
lastSt = st;
|
||||
readSpec(specPath, spec);
|
||||
}
|
||||
if (std::fabs(spec.scale - layoutScale) > 1e-5f) {
|
||||
L = makeLayout(spec.scale);
|
||||
layoutScale = spec.scale;
|
||||
if (!allocate()) { std::printf("cannot allocate %dx%d\n", L.texW, L.texH); return 1; }
|
||||
writeOut(true, frames, 0, 0);
|
||||
std::printf("layout %dx%d (scale %.3f)\n", L.texW, L.texH, double(spec.scale));
|
||||
}
|
||||
const bool visible = spec.open && vr::VROverlay()->IsDashboardVisible();
|
||||
const uint64_t t0 = nowNs();
|
||||
if (!visible) {
|
||||
if (t0 - lastOut > 2000000000ull) { writeOut(true, frames, 0, cpuMs); lastOut = t0; }
|
||||
struct timespec ts = {0, 50000000};
|
||||
nanosleep(&ts, nullptr);
|
||||
continue;
|
||||
}
|
||||
// poses at the time these pixels reach the eyes
|
||||
float sinceVsync = 0;
|
||||
uint64_t frameCounter = 0;
|
||||
vr::VRSystem()->GetTimeSinceLastVsync(&sinceVsync, &frameCounter);
|
||||
float hz = vr::VRSystem()->GetFloatTrackedDeviceProperty(vr::k_unTrackedDeviceIndex_Hmd, vr::Prop_DisplayFrequency_Float);
|
||||
float toPhotons = vr::VRSystem()->GetFloatTrackedDeviceProperty(vr::k_unTrackedDeviceIndex_Hmd, vr::Prop_SecondsFromVsyncToPhotons_Float);
|
||||
float frameS = hz > 1 ? 1.f / hz : 1.f / 90.f;
|
||||
float predict = frameS * 2 - sinceVsync + toPhotons;
|
||||
vr::TrackedDevicePose_t poses[vr::k_unMaxTrackedDeviceCount];
|
||||
vr::VRSystem()->GetDeviceToAbsoluteTrackingPose(vr::TrackingUniverseStanding, predict, poses, vr::k_unMaxTrackedDeviceCount);
|
||||
const vr::ETrackedControllerRole role = spec.hand == "left" ? vr::TrackedControllerRole_LeftHand : vr::TrackedControllerRole_RightHand;
|
||||
const vr::TrackedDeviceIndex_t hi = vr::VRSystem()->GetTrackedDeviceIndexForControllerRole(role);
|
||||
if (hi == vr::k_unTrackedDeviceIndexInvalid || !poses[hi].bPoseIsValid || !poses[0].bPoseIsValid) {
|
||||
struct timespec ts = {0, 10000000};
|
||||
nanosleep(&ts, nullptr);
|
||||
continue;
|
||||
}
|
||||
const Pose hmd = fromVR(poses[0].mDeviceToAbsoluteTracking);
|
||||
const Pose hand = fromVR(poses[hi].mDeviceToAbsoluteTracking);
|
||||
const Pose body = spec.world ? poseFrom(spec.wrot, spec.wcentre) : hand * poseFrom(spec.rot, spec.centre);
|
||||
const Pose toBody = body.inverse();
|
||||
|
||||
// the wheel: marker and press motion from both sticks (the one tilted further)
|
||||
for (int k = 0; k < 2; k++) {
|
||||
HandInput &h = handIn[k];
|
||||
if (h.rm.empty()) {
|
||||
vr::TrackedDeviceIndex_t di = vr::VRSystem()->GetTrackedDeviceIndexForControllerRole(
|
||||
k ? vr::TrackedControllerRole_RightHand : vr::TrackedControllerRole_LeftHand);
|
||||
if (di != vr::k_unTrackedDeviceIndexInvalid) {
|
||||
char buf[256] = {};
|
||||
vr::VRSystem()->GetStringTrackedDeviceProperty(di, vr::Prop_RenderModelName_String, buf, sizeof buf);
|
||||
h.rm = buf;
|
||||
}
|
||||
}
|
||||
readStick(h, k ? STICK_R : STICK_L);
|
||||
}
|
||||
const HandInput &src = std::hypot(handIn[1].x, handIn[1].y) >= std::hypot(handIn[0].x, handIn[0].y) ? handIn[1] : handIn[0];
|
||||
const float rad = std::hypot(src.x, src.y);
|
||||
const bool touching = rad >= 0.45f;
|
||||
if (rad >= 0.3f) markerAngle = std::atan2(src.x, src.y);
|
||||
const float dt = float(double(t0 - lastFrameNs) / 1e9);
|
||||
lastFrameNs = t0;
|
||||
marker += ((touching ? 1.f : 0.f) - marker) * std::min(1.f, dt / (touching ? 0.06f : 0.15f));
|
||||
const bool clicked = handIn[0].clicked || handIn[1].clicked;
|
||||
const bool centred = rad < 0.45f;
|
||||
sink += (((clicked && centred) ? 1.f : 0.f) - sink) * std::min(1.f, dt / 0.05f);
|
||||
rock += (((clicked && !centred) ? 1.f : 0.f) - rock) * std::min(1.f, dt / 0.05f);
|
||||
if (clicked && !centred) rockAngle = std::round(markerAngle / (kPi / 2)) * (kPi / 2);
|
||||
|
||||
// render: every face, both eyes
|
||||
DmaTarget &B = bufs[size_t(cur)];
|
||||
glBindFramebuffer(GL_FRAMEBUFFER, msFbo);
|
||||
glViewport(0, 0, L.texW, L.texH);
|
||||
glDisable(GL_SCISSOR_TEST);
|
||||
glClearColor(0, 0, 0, 0);
|
||||
glClearDepthf(1);
|
||||
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
|
||||
glEnable(GL_DEPTH_TEST);
|
||||
glDisable(GL_CULL_FACE);
|
||||
glDisable(GL_BLEND);
|
||||
prog.use();
|
||||
prog.set("uScale", spec.scale);
|
||||
prog.set("uTex", 0);
|
||||
float toWorld[9];
|
||||
for (int i = 0; i < 3; i++)
|
||||
for (int j = 0; j < 3; j++) toWorld[j * 3 + i] = body.r[i][j]; // column-major
|
||||
glUniformMatrix3fv(prog.loc("uToWorld"), 1, GL_FALSE, toWorld);
|
||||
prog.set("uMarker", markerAngle, marker, 0.f, 0.f);
|
||||
const bool white = spec.finish == "white";
|
||||
for (int eye = 0; eye < 2; eye++) {
|
||||
const Pose e2h = fromVR(vr::VRSystem()->GetEyeToHeadTransform(eye ? vr::Eye_Right : vr::Eye_Left));
|
||||
const v3 eyeW = (hmd * e2h).t;
|
||||
const v3 eyeB = toBody.apply(eyeW);
|
||||
prog.set("uEye", eyeB.x, eyeB.y, eyeB.z);
|
||||
for (const Face &F : L.f) {
|
||||
float P[16], V[16];
|
||||
// the face grown by PAD px on every side (the panel shows it all)
|
||||
Face G = F;
|
||||
G.w = F.w * float(F.tw + 2 * PAD) / float(F.tw);
|
||||
G.h = F.h * float(F.th + 2 * PAD) / float(F.th);
|
||||
if (!faceMatrices(G, eyeB, P, V)) continue;
|
||||
const int x = F.tx - PAD + eye * (F.tw + 2 * PAD);
|
||||
const int y = F.ty - PAD, vw = F.tw + 2 * PAD, vh = F.th + 2 * PAD;
|
||||
// GL rows count from the buffer's first row (the texture's top)
|
||||
glViewport(x, y, vw, vh);
|
||||
glEnable(GL_SCISSOR_TEST);
|
||||
glScissor(x, y, vw, vh);
|
||||
prog.mat4("uProj", P);
|
||||
prog.mat4("uView", V);
|
||||
for (auto &p : parts) {
|
||||
const bool isWhite = p.name.find("_white") != std::string::npos, isBlack = p.name.find("_black") != std::string::npos;
|
||||
if ((isWhite && !white) || (isBlack && white)) continue;
|
||||
Pose m; // the part's own motion, about the wheel's centre (model metres)
|
||||
if (p.center) m.t = {0, 0, -0.0005f * sink};
|
||||
if (p.wheel && rock > 0.001f) {
|
||||
const float ang = -2.5f * kPi / 180 * rock;
|
||||
const v3 axis = normalize(v3{std::cos(rockAngle), -std::sin(rockAngle), 0});
|
||||
Pose R = poseFrom(qnorm({std::cos(ang / 2), axis.x * std::sin(ang / 2), axis.y * std::sin(ang / 2), 0}), {});
|
||||
const v3 pivot = {0, WHEEL_CY, FRONT_Z};
|
||||
R.t = pivot - R.rotate(pivot);
|
||||
m = R;
|
||||
}
|
||||
float M[16];
|
||||
m.toGL(M);
|
||||
prog.mat4("uModel", M);
|
||||
prog.set("uMat", p.mat);
|
||||
prog.set("uBase", p.base.x, p.base.y, p.base.z);
|
||||
glActiveTexture(GL_TEXTURE0);
|
||||
glBindTexture(GL_TEXTURE_2D, p.tex);
|
||||
glBindVertexArray(p.mesh.vao);
|
||||
glDrawArrays(GL_TRIANGLES, 0, p.mesh.count);
|
||||
}
|
||||
// the window last: it replaces what lies behind it (the screen panel shows through the LCD)
|
||||
Pose id;
|
||||
float M[16];
|
||||
id.toGL(M);
|
||||
prog.mat4("uModel", M);
|
||||
prog.set("uMat", 6);
|
||||
glBindVertexArray(window.vao);
|
||||
glDrawArrays(GL_TRIANGLES, 0, window.count);
|
||||
}
|
||||
}
|
||||
glDisable(GL_SCISSOR_TEST);
|
||||
glBindVertexArray(0);
|
||||
// resolve the samples into the buffer SteamVR reads
|
||||
glBindFramebuffer(GL_READ_FRAMEBUFFER, msFbo);
|
||||
glBindFramebuffer(GL_DRAW_FRAMEBUFFER, B.fbo);
|
||||
glBlitFramebuffer(0, 0, L.texW, L.texH, 0, 0, L.texW, L.texH, GL_COLOR_BUFFER_BIT, GL_NEAREST);
|
||||
// the video's latest frame, when there is a new one, into its tile (rows top first both ways)
|
||||
{
|
||||
struct stat vs{};
|
||||
if (stat(VIDEO_FILE, &vs) == 0 && (vs.st_mtim.tv_sec != videoSt.st_mtim.tv_sec || vs.st_mtim.tv_nsec != videoSt.st_mtim.tv_nsec)) {
|
||||
videoSt = vs;
|
||||
std::string d;
|
||||
uint32_t vw = 0, vh = 0;
|
||||
if (readFile(VIDEO_FILE, d) && d.size() >= 16 && d.compare(0, 4, "PVID") == 0) {
|
||||
std::memcpy(&vw, d.data() + 8, 4);
|
||||
std::memcpy(&vh, d.data() + 12, 4);
|
||||
if (int(vw) == VIDEO_W && int(vh) == VIDEO_H && d.size() >= 16 + size_t(vw) * vh * 4) {
|
||||
glBindTexture(GL_TEXTURE_2D, videoTex);
|
||||
glPixelStorei(GL_UNPACK_ALIGNMENT, 1);
|
||||
glTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, VIDEO_W, VIDEO_H, GL_RGBA, GL_UNSIGNED_BYTE, d.data() + 16);
|
||||
videoFrames++;
|
||||
}
|
||||
}
|
||||
}
|
||||
glBindFramebuffer(GL_READ_FRAMEBUFFER, videoFbo);
|
||||
glBindFramebuffer(GL_DRAW_FRAMEBUFFER, B.fbo);
|
||||
for (int eye = 0; eye < 2; eye++) {
|
||||
const int x0 = L.vx + eye * VIDEO_W;
|
||||
glBlitFramebuffer(0, 0, VIDEO_W, VIDEO_H, x0, L.vy, x0 + VIDEO_W, L.vy + VIDEO_H, GL_COLOR_BUFFER_BIT, GL_NEAREST);
|
||||
}
|
||||
}
|
||||
glBindFramebuffer(GL_FRAMEBUFFER, 0);
|
||||
glFinish(); // SteamVR samples the dmabuf from another process
|
||||
vr::SharedTextureHandle_t h = vr::SharedTextureHandle_t(B.vrHandle);
|
||||
vr::Texture_t tex = {&h, vr::TextureType_SharedTextureHandle, vr::ColorSpace_Gamma};
|
||||
auto e = vr::VROverlay()->SetOverlayTexture(ov, &tex);
|
||||
if (e != vr::VROverlayError_None) std::printf("SetOverlayTexture: %s\n", vr::VROverlay()->GetOverlayErrorNameFromEnum(e));
|
||||
if (!mouseSet) {
|
||||
vr::HmdVector2_t mouse = {float(L.texW / 2), float(L.texH)}; // one eye's width: Parallel stereo
|
||||
vr::VROverlay()->SetOverlayMouseScale(ov, &mouse);
|
||||
mouseSet = true;
|
||||
}
|
||||
cur = (cur + 1) % int(bufs.size());
|
||||
frames++;
|
||||
fpsFrames++;
|
||||
const uint64_t t1 = nowNs();
|
||||
cpuMs = cpuMs * 0.9f + float(double(t1 - t0) / 1e6) * 0.1f;
|
||||
if (t1 - fpsT0 > 1000000000ull) {
|
||||
fpsNow = float(fpsFrames * 1e9 / double(t1 - fpsT0));
|
||||
fpsFrames = 0;
|
||||
fpsT0 = t1;
|
||||
}
|
||||
if (t1 - lastOut > 1000000000ull) { writeOut(true, frames, fpsNow, cpuMs); lastOut = t1; }
|
||||
if (t1 - t0 < frameNs) {
|
||||
uint64_t left = frameNs - (t1 - t0);
|
||||
struct timespec ts = {time_t(left / 1000000000ull), long(left % 1000000000ull)};
|
||||
nanosleep(&ts, nullptr);
|
||||
}
|
||||
}
|
||||
std::printf("podd: stopping\n");
|
||||
if (ov != vr::k_ulOverlayHandleInvalid) vr::VROverlay()->DestroyOverlay(ov);
|
||||
release();
|
||||
writeOut(false, frames, 0, 0);
|
||||
vr::VR_Shutdown();
|
||||
gfx.shutdown();
|
||||
return 0;
|
||||
}
|
||||
@@ -53,6 +53,7 @@
|
||||
:where(%{VRVirtualKeyboardContents})::before,
|
||||
:where(%{VirtualKeyboardStandaloneContainer>VirtualKeyboardContainer})::before,
|
||||
:where(%{VolumeSliderLabel>VolumePopin}%{VolumeSliderLabel>VR})::before,
|
||||
:where(.lgs-asspod)::before,
|
||||
:where(.lgs-btn)::before,
|
||||
:where(.lgs-cc-circle-face)::before,
|
||||
:where(.lgs-cc-tile)::before,
|
||||
@@ -161,6 +162,7 @@
|
||||
:where(%{RecentlyCompletedItem})::before,
|
||||
:where(%{TabRowTabs})::before,
|
||||
:where(%{TextBoxCarouselContents})::before,
|
||||
:where(.lgs-asspod)::before,
|
||||
:where(.lgs-cc-circle-face)::before,
|
||||
:where(.lgs-home-card)::before,
|
||||
:where(.lgs-home-disc)::before,
|
||||
@@ -229,6 +231,7 @@
|
||||
:where(%{VRVirtualKeyboardContents})::before,
|
||||
:where(%{VirtualKeyboardStandaloneContainer>VirtualKeyboardContainer})::before,
|
||||
:where(%{VolumeSliderLabel>VolumePopin}%{VolumeSliderLabel>VR})::before,
|
||||
:where(.lgs-asspod)::before,
|
||||
:where(.lgs-btn)::before,
|
||||
:where(.lgs-cc-circle-face)::before,
|
||||
:where(.lgs-cc-tile)::before,
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
/* Glass Shell: the music note on the bar (the assPod, device/rt/34-asspod.js, flag assPod).
|
||||
Wrapped as html.lgs-on { ... }.
|
||||
|
||||
- .lgs-asspod: a glass circle of its own between the system capsule and the paintbrush, the
|
||||
paintbrush's glass and size (33-tune.css §1). Its native cover is in theme/layers/30-bar.json.
|
||||
The button inside is Steam's small bar button (VRDashboardBarSmallButton > Highlight), so
|
||||
30-bar.css §1.2 lights it like the other discs (laser hover, gamepad focus).
|
||||
- While the assPod is out (html.lgs-asspod-out, set by the module while the daemon's bridge value
|
||||
says so), Steam's windows fade out and take no laser clicks: the assPod has the controls. */
|
||||
|
||||
|
||||
/* 1. The circle */
|
||||
.lgs-asspod {
|
||||
position: relative;
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
background: var(--lgs-mat-liquid-room-bg);
|
||||
box-shadow: var(--lgs-mat-liquid-shade);
|
||||
border-radius: var(--lgs-r-capsule);
|
||||
--lgs-edge: liquid;
|
||||
}
|
||||
/* 56 pp disc at a 64 pp pitch, as in the system capsule */
|
||||
.lgs-asspod .VRDashboardBarSmallButton {
|
||||
font-size: 28px;
|
||||
padding: 0 4px;
|
||||
}
|
||||
.lgs-asspod-icon {
|
||||
display: flex;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
.lgs-asspod-icon svg {
|
||||
display: block;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
|
||||
/* 2. The assPod is out: the whole UI fades out (every Steam window: the window, sidebars, the bar,
|
||||
popups) and takes no clicks; it fades back in when the assPod is put away. The native glass fades
|
||||
with it (lgs_sg.js fade(), called by the assPod in SteamVR's page). */
|
||||
body {
|
||||
transition: opacity 300ms ease;
|
||||
}
|
||||
&.lgs-asspod-out body {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
@@ -8,13 +8,14 @@
|
||||
"the shapes follow the T1 CSS, which is always on. No pops on the bar (PLAN 1.7: bar popups are their own quads at +25 mm,",
|
||||
"theme/popups/30-bar.json; tooltips are CSS shadow only). notifications, volumelevel and tooltip covers are in 00-base.json",
|
||||
"(flag wp.c3a).",
|
||||
"A fourth piece when the glass tuner is on (flag glassTune, rt 33-tune.js): the paintbrush circle .lgs-tune after the system capsule."
|
||||
"A fourth piece when the glass tuner is on (flag glassTune, rt 33-tune.js): the paintbrush circle .lgs-tune after the system capsule.",
|
||||
"And one before it when the assPod is on (flag assPod, rt 34-asspod.js): the music-note circle .lgs-asspod."
|
||||
],
|
||||
"owns": ["bar"],
|
||||
"surfaces": {
|
||||
"bar": {
|
||||
"cover": {
|
||||
"sel": "%{PopupBody>DashboardBar} %{BarSurface}%{Bookend}, %{PopupBody>DashboardBar} %{BarSurface}:not(%{Bookend}) > %{BarSurfaceSection}%{LowBatteryGauge>Tabs}, %{PopupBody>DashboardBar} %{BarSurfaceSection}%{Tray}, %{PopupBody>DashboardBar} .lgs-tune",
|
||||
"sel": "%{PopupBody>DashboardBar} %{BarSurface}%{Bookend}, %{PopupBody>DashboardBar} %{BarSurface}:not(%{Bookend}) > %{BarSurfaceSection}%{LowBatteryGauge>Tabs}, %{PopupBody>DashboardBar} %{BarSurfaceSection}%{Tray}, %{PopupBody>DashboardBar} .lgs-tune, %{PopupBody>DashboardBar} .lgs-asspod",
|
||||
"all": true,
|
||||
"r": "capsule"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,675 @@
|
||||
// Procedural assPod Video (5th generation): glossy front plate, mirror-chrome back that wraps the
|
||||
// sides with the peach logo etched into it, LCD behind glass, click wheel with vector labels, touch
|
||||
// marker and press animations. Everything is built from code and canvases; there are no asset files.
|
||||
//
|
||||
// Local frame (design spec 3.1): meters, origin at the body center, +X right, +Y toward the
|
||||
// headphone jack, +Z out of the screen. Wheel angle: 0 = top (MENU), clockwise seen from the front.
|
||||
|
||||
import * as THREE from 'three';
|
||||
import { mergeGeometries } from 'three/addons/utils/BufferGeometryUtils.js';
|
||||
import { drawPeachSilhouette } from '../../device/asspod/src/brand/peach.js';
|
||||
|
||||
// ---------------------------------------------------------------- geometry
|
||||
|
||||
export const GEOMETRY = Object.freeze({
|
||||
BODY: Object.freeze({ w: 0.0618, h: 0.1035, d: 0.0110, corner: 0.0065 }),
|
||||
LCD: Object.freeze({ cx: 0, cy: 0.0242, w: 0.0508, h: 0.0381 }),
|
||||
WINDOW: Object.freeze({ w: 0.0532, h: 0.0405 }),
|
||||
WHEEL: Object.freeze({ cy: -0.0240, rOuter: 0.0197, rCenter: 0.0073, rMarker: 0.0135, markerSize: 0.0095 }),
|
||||
});
|
||||
|
||||
const { BODY, LCD, WINDOW, WHEEL } = GEOMETRY;
|
||||
const MM = 0.001;
|
||||
const FRONT_Z = BODY.d / 2; // front surface of the plate
|
||||
const BACK_Z = -BODY.d / 2;
|
||||
const SEAM_Z = FRONT_Z - 2 * MM; // where the 2 mm front plate meets the steel
|
||||
const WELL_R = WHEEL.rOuter + 0.02 * MM; // hole in the plate around the wheel: flush (a visible gap ring aliased into a dotted seam in VR)
|
||||
const WELL_FLOOR_Z = FRONT_Z - 1.5 * MM;
|
||||
const WHEEL_T = 1.0 * MM; // wheel and center button thickness, so their sides show when they rock or sink
|
||||
const ROCK_ANGLE = -2.5 * Math.PI / 180;
|
||||
const SINK = 0.5 * MM;
|
||||
const MARKER_PEAK = 0.9;
|
||||
const MARKER_IN_S = 0.06;
|
||||
const MARKER_OUT_S = 0.15;
|
||||
const MARKER_PRESS_SCALE = 0.85;
|
||||
const SECTOR_ANGLE = { menu: 0, next: Math.PI / 2, play: Math.PI, prev: Math.PI * 1.5 };
|
||||
// Back engraving: the 100-unit design box at 18.5 mm (art ~18 x 16 mm), centred 30 mm below the
|
||||
// top edge like the logo on the real back, well inside the flat area (which ends 2.4 mm in).
|
||||
const ENGRAVE_SIZE = 18.5 * MM;
|
||||
const ENGRAVE_Y = BODY.h / 2 - 30 * MM;
|
||||
const ENGRAVE_PX = 512;
|
||||
|
||||
const FINISHES = {
|
||||
black: { front: '#0b0b0c', frontRough: 0.12, wheel: '#1a1a1b', label: '#9a9a9a', wheelEdge: '#060606', well: '#030303', shadow: false },
|
||||
// The gaps around the white wheel read as a soft grey line, not a black outline.
|
||||
white: { front: '#f4f4f2', frontRough: 0.18, wheel: '#cdcecf', label: '#f2f2f2', wheelEdge: '#b4b5b6', well: '#8d8d89', shadow: true },
|
||||
};
|
||||
|
||||
// Rounded-rectangle outline sampled corner by corner. Each point keeps its corner center and the
|
||||
// outward direction, so a profile can be swept along it with exact offsets and smooth normals.
|
||||
function roundedOutline(hw, hh, radius, segs) {
|
||||
const pts = [];
|
||||
const corners = [[hw - radius, hh - radius, 0], [-(hw - radius), hh - radius, 0.5], [-(hw - radius), -(hh - radius), 1], [hw - radius, -(hh - radius), 1.5]];
|
||||
for (const [cx, cy, start] of corners) {
|
||||
for (let i = 0; i <= segs; i++) {
|
||||
const a = (start + 0.5 * i / segs) * Math.PI;
|
||||
pts.push({ cx, cy, nx: Math.cos(a), ny: Math.sin(a) });
|
||||
}
|
||||
}
|
||||
return pts;
|
||||
}
|
||||
|
||||
// Sweeps a profile [{ o: outward offset from the outline, z, n: [outward, z] normal }] around the
|
||||
// outline (counter-clockwise seen from +Z) into an indexed strip mesh.
|
||||
function sweepGeometry(outline, radius, profile) {
|
||||
const pos = [];
|
||||
const nor = [];
|
||||
const idx = [];
|
||||
const ring = outline.length;
|
||||
for (const p of profile) {
|
||||
const len = Math.hypot(p.n[0], p.n[1]);
|
||||
for (const q of outline) {
|
||||
const r = radius + p.o;
|
||||
pos.push(q.cx + q.nx * r, q.cy + q.ny * r, p.z);
|
||||
nor.push(q.nx * p.n[0] / len, q.ny * p.n[0] / len, p.n[1] / len);
|
||||
}
|
||||
}
|
||||
for (let j = 0; j < profile.length - 1; j++) {
|
||||
for (let i = 0; i < ring; i++) {
|
||||
const a = j * ring + i;
|
||||
const b = j * ring + (i + 1) % ring;
|
||||
const c = (j + 1) * ring + i;
|
||||
const d = (j + 1) * ring + (i + 1) % ring;
|
||||
// Profiles run from the front toward the back, so this winding faces outward.
|
||||
idx.push(a, c, b, b, c, d);
|
||||
}
|
||||
}
|
||||
const g = new THREE.BufferGeometry();
|
||||
g.setAttribute('position', new THREE.Float32BufferAttribute(pos, 3));
|
||||
g.setAttribute('normal', new THREE.Float32BufferAttribute(nor, 3));
|
||||
g.setIndex(idx);
|
||||
return g;
|
||||
}
|
||||
|
||||
// Flat cap closing an outline at a given inset. A fan is enough because the outline is convex.
|
||||
function capGeometry(outline, radius, inset, z, facing) {
|
||||
const pos = [0, 0, z];
|
||||
const nor = [0, 0, facing];
|
||||
const idx = [];
|
||||
for (const q of outline) {
|
||||
const r = radius - inset;
|
||||
pos.push(q.cx + q.nx * r, q.cy + q.ny * r, z);
|
||||
nor.push(0, 0, facing);
|
||||
}
|
||||
const n = outline.length;
|
||||
for (let i = 0; i < n; i++) {
|
||||
const a = 1 + i;
|
||||
const b = 1 + (i + 1) % n;
|
||||
if (facing > 0) idx.push(0, a, b); else idx.push(0, b, a);
|
||||
}
|
||||
const g = new THREE.BufferGeometry();
|
||||
g.setAttribute('position', new THREE.Float32BufferAttribute(pos, 3));
|
||||
g.setAttribute('normal', new THREE.Float32BufferAttribute(nor, 3));
|
||||
g.setIndex(idx);
|
||||
return g;
|
||||
}
|
||||
|
||||
// True circular corners, so flat faces meet the swept bevels without slivers at the corners.
|
||||
function roundedRectShape(w, h, r, cx = 0, cy = 0) {
|
||||
const s = new THREE.Shape();
|
||||
const x0 = cx - w / 2;
|
||||
const y0 = cy - h / 2;
|
||||
const x1 = cx + w / 2;
|
||||
const y1 = cy + h / 2;
|
||||
const q = Math.PI / 2;
|
||||
s.moveTo(x0 + r, y0);
|
||||
s.lineTo(x1 - r, y0);
|
||||
s.absarc(x1 - r, y0 + r, r, -q, 0, false);
|
||||
s.lineTo(x1, y1 - r);
|
||||
s.absarc(x1 - r, y1 - r, r, 0, q, false);
|
||||
s.lineTo(x0 + r, y1);
|
||||
s.absarc(x0 + r, y1 - r, r, q, 2 * q, false);
|
||||
s.lineTo(x0, y0 + r);
|
||||
s.absarc(x0 + r, y0 + r, r, 2 * q, 3 * q, false);
|
||||
return s;
|
||||
}
|
||||
|
||||
// Keeps merged parts compatible: mergeGeometries needs identical attribute sets.
|
||||
function onlyPositionNormal(g) {
|
||||
const out = g.index ? g.toNonIndexed() : g;
|
||||
for (const k of Object.keys(out.attributes)) if (k !== 'position' && k !== 'normal') out.deleteAttribute(k);
|
||||
return out;
|
||||
}
|
||||
|
||||
function paint(g, hex) {
|
||||
const c = new THREE.Color(hex);
|
||||
const n = g.attributes.position.count;
|
||||
const col = new Float32Array(n * 3);
|
||||
for (let i = 0; i < n; i++) { col[i * 3] = c.r; col[i * 3 + 1] = c.g; col[i * 3 + 2] = c.b; }
|
||||
g.setAttribute('color', new THREE.BufferAttribute(col, 3));
|
||||
return g;
|
||||
}
|
||||
|
||||
function buildBody() {
|
||||
const hw = BODY.w / 2;
|
||||
const hh = BODY.h / 2;
|
||||
const rc = BODY.corner;
|
||||
const outline = roundedOutline(hw, hh, rc, 16);
|
||||
|
||||
// Chrome shell: straight side wall from the seam, then an elliptical roll onto the flat back.
|
||||
// A tiny chamfer at the seam makes the plate/steel joint catch a dark line, as on the real one.
|
||||
const rollXY = 2.4 * MM;
|
||||
const rollZ = 3.4 * MM;
|
||||
const chromeProfile = [
|
||||
{ o: -0.2 * MM, z: SEAM_Z, n: [0.4, 1] },
|
||||
{ o: 0, z: SEAM_Z - 0.25 * MM, n: [1, 0.15] },
|
||||
];
|
||||
const SEG = 10;
|
||||
for (let i = 0; i <= SEG; i++) {
|
||||
const phi = (i / SEG) * Math.PI / 2;
|
||||
chromeProfile.push({
|
||||
o: -rollXY * (1 - Math.cos(phi)),
|
||||
z: BACK_Z + rollZ * (1 - Math.sin(phi)),
|
||||
n: [Math.cos(phi) / rollXY, -Math.sin(phi) / rollZ],
|
||||
});
|
||||
}
|
||||
const chrome = mergeGeometries([
|
||||
sweepGeometry(outline, rc, chromeProfile),
|
||||
capGeometry(outline, rc, rollXY, BACK_Z, -1),
|
||||
].map(onlyPositionNormal));
|
||||
|
||||
// Front plate: 2 mm of glossy plastic with a 0.6 mm rounded front edge. The front face is a
|
||||
// shape with a hole where the click wheel sits, so the wheel can rock into the well.
|
||||
const bev = 0.6 * MM;
|
||||
const plateProfile = [];
|
||||
for (let i = 0; i <= 6; i++) {
|
||||
const phi = (i / 6) * Math.PI / 2;
|
||||
plateProfile.push({ o: -bev * (1 - Math.sin(phi)), z: FRONT_Z - bev * (1 - Math.cos(phi)), n: [Math.sin(phi), Math.cos(phi)] });
|
||||
}
|
||||
plateProfile.push({ o: 0, z: SEAM_Z + 0.2 * MM, n: [1, 0] });
|
||||
plateProfile.push({ o: -0.2 * MM, z: SEAM_Z, n: [0.4, -1] });
|
||||
const faceShape = roundedRectShape(BODY.w - 2 * bev, BODY.h - 2 * bev, rc - bev);
|
||||
const hole = new THREE.Path();
|
||||
hole.absarc(0, WHEEL.cy, WELL_R, 0, Math.PI * 2, true);
|
||||
faceShape.holes.push(hole);
|
||||
// and an opening for the screen window: in SteamVR the live screen is a panel in it, and a plate
|
||||
// surface just under that panel fought it for depth (the LCD mesh here covers it either way)
|
||||
const win = new THREE.Path();
|
||||
{
|
||||
const hw = WINDOW.w / 2, hh = WINDOW.h / 2, r = 0.9 * MM, cy = LCD.cy;
|
||||
win.moveTo(-hw + r, cy - hh);
|
||||
win.absarc(-hw + r, cy - hh + r, r, -Math.PI / 2, -Math.PI, true);
|
||||
win.lineTo(-hw, cy + hh - r);
|
||||
win.absarc(-hw + r, cy + hh - r, r, Math.PI, Math.PI / 2, true);
|
||||
win.lineTo(hw - r, cy + hh);
|
||||
win.absarc(hw - r, cy + hh - r, r, Math.PI / 2, 0, true);
|
||||
win.lineTo(hw, cy - hh + r);
|
||||
win.absarc(hw - r, cy - hh + r, r, 0, -Math.PI / 2, true);
|
||||
}
|
||||
faceShape.holes.push(win);
|
||||
const face = new THREE.ShapeGeometry(faceShape, 48); // arcs get 2x this many segments; the wheel hole needs them
|
||||
face.translate(0, 0, FRONT_Z);
|
||||
const plate = mergeGeometries([sweepGeometry(outline, rc, plateProfile), face].map(onlyPositionNormal));
|
||||
|
||||
// The well behind the wheel: a dark wall and floor, so a rocking wheel never shows the hollow body.
|
||||
const wall = new THREE.CylinderGeometry(WELL_R, WELL_R, FRONT_Z - WELL_FLOOR_Z, 64, 1, true);
|
||||
wall.rotateX(Math.PI / 2);
|
||||
wall.translate(0, WHEEL.cy, (FRONT_Z + WELL_FLOOR_Z) / 2);
|
||||
const floor = new THREE.CircleGeometry(WELL_R, 64);
|
||||
floor.translate(0, WHEEL.cy, WELL_FLOOR_Z);
|
||||
const well = mergeGeometries([wall, floor].map(onlyPositionNormal));
|
||||
|
||||
return { chrome, plate, well };
|
||||
}
|
||||
|
||||
// Hold switch, headphone jack and dock connector, merged into one vertex-colored mesh.
|
||||
function buildPorts() {
|
||||
const parts = [];
|
||||
const top = BODY.h / 2;
|
||||
const wallZ = (SEAM_Z + BACK_Z + 3.4 * MM) / 2; // middle of the straight part of the side wall
|
||||
const decal = (w, h, r, color, x, y, z, rotX) => {
|
||||
const g = new THREE.ShapeGeometry(roundedRectShape(w, h, r), 6);
|
||||
g.rotateX(rotX);
|
||||
g.translate(x, y, z);
|
||||
return paint(onlyPositionNormal(g), color);
|
||||
};
|
||||
// Hold switch (top, left): slot plus a slider parked toward the jack side = unlocked.
|
||||
parts.push(decal(8.6 * MM, 2.7 * MM, 1.2 * MM, '#050505', -0.0185, top + 0.03 * MM, wallZ, -Math.PI / 2));
|
||||
const knob = new THREE.BoxGeometry(4.2 * MM, 0.5 * MM, 1.9 * MM);
|
||||
knob.translate(-0.0165, top + 0.15 * MM, wallZ);
|
||||
parts.push(paint(onlyPositionNormal(knob), '#1d1d1f'));
|
||||
// Headphone jack (top, right): metal ring with a black hole.
|
||||
const ring = new THREE.CircleGeometry(2.7 * MM, 32);
|
||||
ring.rotateX(-Math.PI / 2);
|
||||
ring.translate(0.0195, top + 0.03 * MM, wallZ);
|
||||
parts.push(paint(onlyPositionNormal(ring), '#3a3b3e'));
|
||||
const holeG = new THREE.CircleGeometry(1.75 * MM, 32);
|
||||
holeG.rotateX(-Math.PI / 2);
|
||||
holeG.translate(0.0195, top + 0.06 * MM, wallZ);
|
||||
parts.push(paint(onlyPositionNormal(holeG), '#000000'));
|
||||
// Dock connector (bottom, centered).
|
||||
parts.push(decal(21 * MM, 2.6 * MM, 1.0 * MM, '#050505', 0, -top - 0.03 * MM, wallZ, Math.PI / 2));
|
||||
parts.push(decal(18.5 * MM, 1.0 * MM, 0.4 * MM, '#26272a', 0, -top - 0.06 * MM, wallZ, Math.PI / 2));
|
||||
return mergeGeometries(parts);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- canvases
|
||||
|
||||
const LABEL_PX = 512;
|
||||
|
||||
// The wheel face: base color, gap rings and the four labels drawn as vector shapes, so they stay
|
||||
// crisp at any mip level and never depend on a font having the transport glyphs.
|
||||
function drawWheelLabels(canvas, finish) {
|
||||
const f = FINISHES[finish];
|
||||
const g = canvas.getContext('2d');
|
||||
const S = LABEL_PX;
|
||||
const c = S / 2;
|
||||
const pxPerM = S / (2 * WHEEL.rOuter);
|
||||
const mm = pxPerM * MM;
|
||||
g.clearRect(0, 0, S, S);
|
||||
g.fillStyle = f.wheelEdge;
|
||||
g.fillRect(0, 0, S, S);
|
||||
g.beginPath();
|
||||
g.arc(c, c, c, 0, Math.PI * 2); // the wheel colour to its very edge: no dark rim to alias
|
||||
g.fillStyle = f.wheel;
|
||||
g.fill();
|
||||
// A whisper of shading toward the rim, as the real wheel catches less light at its beveled edge.
|
||||
const shade = g.createRadialGradient(c, c, c * 0.6, c, c, c);
|
||||
shade.addColorStop(0, 'rgba(0,0,0,0)');
|
||||
shade.addColorStop(1, finish === 'white' ? 'rgba(0,0,0,0.03)' : 'rgba(0,0,0,0.08)'); // light: a dark rim read as a seam
|
||||
g.fillStyle = shade;
|
||||
g.fill();
|
||||
// Inner gap ring around the center button.
|
||||
g.beginPath();
|
||||
g.arc(c, c, (WHEEL.rCenter + 0.25 * MM) * pxPerM, 0, Math.PI * 2);
|
||||
g.lineWidth = 0.35 * mm;
|
||||
g.strokeStyle = f.wheelEdge;
|
||||
g.stroke();
|
||||
|
||||
g.fillStyle = f.label;
|
||||
const labelR = 14.6 * mm;
|
||||
// MENU: bold, widely tracked caps.
|
||||
g.save();
|
||||
g.font = `700 ${Math.round(3.2 * mm)}px "Source Sans 3", "Myriad Pro", "Helvetica Neue", Arial, sans-serif`;
|
||||
g.textBaseline = 'middle';
|
||||
g.textAlign = 'left';
|
||||
const letters = 'MENU';
|
||||
const track = 0.32 * mm;
|
||||
let total = -track;
|
||||
for (const ch of letters) total += g.measureText(ch).width + track;
|
||||
let x = c - total / 2;
|
||||
for (const ch of letters) { g.fillText(ch, x, c - labelR); x += g.measureText(ch).width + track; }
|
||||
g.restore();
|
||||
|
||||
const triH = 2.5 * mm;
|
||||
const triW = 2.05 * mm;
|
||||
const barW = 0.55 * mm;
|
||||
const tri = (x0, cy, dir) => { // dir 1 = pointing right
|
||||
g.beginPath();
|
||||
g.moveTo(x0, cy - triH / 2);
|
||||
g.lineTo(x0 + dir * triW, cy);
|
||||
g.lineTo(x0, cy + triH / 2);
|
||||
g.closePath();
|
||||
g.fill();
|
||||
};
|
||||
// Next: two triangles then a bar, centered on the right label position.
|
||||
{
|
||||
const w = triW * 2 + barW;
|
||||
const x0 = c + labelR - w / 2;
|
||||
tri(x0, c, 1);
|
||||
tri(x0 + triW, c, 1);
|
||||
g.fillRect(x0 + triW * 2 - 0.05 * mm, c - triH / 2, barW, triH);
|
||||
}
|
||||
// Previous: mirror image on the left.
|
||||
{
|
||||
const w = triW * 2 + barW;
|
||||
const x1 = c - labelR + w / 2;
|
||||
tri(x1, c, -1);
|
||||
tri(x1 - triW, c, -1);
|
||||
g.fillRect(x1 - triW * 2 - barW + 0.05 * mm, c - triH / 2, barW, triH);
|
||||
}
|
||||
// Play/Pause at the bottom: a triangle followed by two bars.
|
||||
{
|
||||
const gap = 0.55 * mm;
|
||||
const pw = 0.5 * mm;
|
||||
const w = triW + gap + pw * 2 + 0.45 * mm;
|
||||
const x0 = c - w / 2;
|
||||
const cy = c + labelR;
|
||||
tri(x0, cy, 1);
|
||||
g.fillRect(x0 + triW + gap, cy - triH / 2, pw, triH);
|
||||
g.fillRect(x0 + triW + gap + pw + 0.45 * mm, cy - triH / 2, pw, triH);
|
||||
}
|
||||
}
|
||||
|
||||
// Soft glow for the touch marker: a bright core that rolls off into a halo. It is drawn additively,
|
||||
// so only the shape of the alpha matters.
|
||||
function markerCanvas() {
|
||||
const S = 128;
|
||||
const cv = document.createElement('canvas');
|
||||
cv.width = cv.height = S;
|
||||
const g = cv.getContext('2d');
|
||||
const grad = g.createRadialGradient(S / 2, S / 2, 0, S / 2, S / 2, S / 2);
|
||||
grad.addColorStop(0, 'rgba(255,255,255,0.95)');
|
||||
grad.addColorStop(0.28, 'rgba(255,255,255,0.80)');
|
||||
grad.addColorStop(0.42, 'rgba(255,255,255,0.42)');
|
||||
grad.addColorStop(0.62, 'rgba(255,255,255,0.14)');
|
||||
grad.addColorStop(1, 'rgba(255,255,255,0)');
|
||||
g.fillStyle = grad;
|
||||
g.fillRect(0, 0, S, S);
|
||||
return cv;
|
||||
}
|
||||
|
||||
// Alpha mask for the back engraving: white peach on black (alphaMap reads the green channel).
|
||||
function engravingCanvas() {
|
||||
const S = ENGRAVE_PX;
|
||||
const cv = document.createElement('canvas');
|
||||
cv.width = cv.height = S;
|
||||
const g = cv.getContext('2d');
|
||||
g.fillStyle = '#000000';
|
||||
g.fillRect(0, 0, S, S);
|
||||
drawPeachSilhouette(g, S / 2, S / 2, S, '#ffffff');
|
||||
return cv;
|
||||
}
|
||||
|
||||
function shadowCanvas() {
|
||||
const S = 64;
|
||||
const cv = document.createElement('canvas');
|
||||
cv.width = cv.height = S;
|
||||
const g = cv.getContext('2d');
|
||||
const grad = g.createRadialGradient(S / 2, S / 2, 0, S / 2, S / 2, S / 2);
|
||||
grad.addColorStop(0, 'rgba(0,0,0,1)');
|
||||
grad.addColorStop(0.55, 'rgba(0,0,0,0.75)');
|
||||
grad.addColorStop(1, 'rgba(0,0,0,0)');
|
||||
g.fillStyle = grad;
|
||||
g.fillRect(0, 0, S, S);
|
||||
return cv;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- springs
|
||||
|
||||
// Exact critically damped step, stable at any dt (frame hitches must not make the wheel explode).
|
||||
function springStep(s, target, w, dt) {
|
||||
const d = s.x - target;
|
||||
const c = s.v + w * d;
|
||||
const e = Math.exp(-w * dt);
|
||||
s.x = target + (d + c * dt) * e;
|
||||
s.v = (s.v - c * w * dt) * e;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- model
|
||||
|
||||
/**
|
||||
* @param {{ screenCanvas: HTMLCanvasElement, envMap: THREE.Texture, finish?: 'black'|'white', anisotropy?: number }} o
|
||||
*/
|
||||
export function createAssPodModel({ screenCanvas, envMap, finish = 'black', anisotropy = 1 }) {
|
||||
const root = new THREE.Group();
|
||||
root.name = 'asspod';
|
||||
|
||||
const body = buildBody();
|
||||
|
||||
const chromeMat = new THREE.MeshStandardMaterial({
|
||||
color: 0xe9eaec, metalness: 1, roughness: 0.08, envMap, envMapIntensity: 1.2,
|
||||
});
|
||||
const frontMat = new THREE.MeshPhysicalMaterial({
|
||||
color: 0x0b0b0c, metalness: 0, roughness: 0.12, clearcoat: 1, clearcoatRoughness: 0.04, envMap, envMapIntensity: 0.7,
|
||||
});
|
||||
const darkMat = new THREE.MeshStandardMaterial({ color: 0x030303, roughness: 0.7, side: THREE.DoubleSide });
|
||||
|
||||
const chrome = new THREE.Mesh(body.chrome, chromeMat);
|
||||
const plate = new THREE.Mesh(body.plate, frontMat);
|
||||
const well = new THREE.Mesh(body.well, darkMat);
|
||||
const ports = new THREE.Mesh(buildPorts(), new THREE.MeshStandardMaterial({ vertexColors: true, roughness: 0.45, metalness: 0.3, envMap }));
|
||||
root.add(chrome, plate, well, ports);
|
||||
|
||||
// ---- back engraving: a satin etch in the mirror chrome
|
||||
// The same steel at a higher roughness. With the PMREM env map that blurs the nebula and studio
|
||||
// panels, so the logo reads lighter over dark reflections and darker over bright ones, like the
|
||||
// real etched back. 0.35 (tuned on screenshots against 0.4 and 0.45) keeps a soft sheen across
|
||||
// the lobes, so it reads as satin metal rather than grey print. Identical for both finishes
|
||||
// (the back is always chrome).
|
||||
const engraveTex = new THREE.CanvasTexture(engravingCanvas());
|
||||
engraveTex.anisotropy = anisotropy; // linear data (no colour space); mipmaps keep it from shimmering
|
||||
const engravingMat = new THREE.MeshStandardMaterial({
|
||||
color: 0xd2d5d9, metalness: 1, roughness: 0.35, envMap, envMapIntensity: 1.2,
|
||||
alphaMap: engraveTex, transparent: true, depthWrite: false,
|
||||
});
|
||||
const engravingGeo = new THREE.PlaneGeometry(ENGRAVE_SIZE, ENGRAVE_SIZE);
|
||||
// Facing -Z, u = 1 lands at -X: the viewer's right seen from behind, so the logo reads unmirrored.
|
||||
engravingGeo.rotateY(Math.PI);
|
||||
const engraving = new THREE.Mesh(engravingGeo, engravingMat);
|
||||
engraving.position.set(0, ENGRAVE_Y, BACK_Z - 0.05 * MM); // like the decals: clear of depth precision
|
||||
engraving.renderOrder = 1;
|
||||
engraving.name = 'engraving';
|
||||
root.add(engraving);
|
||||
|
||||
// ---- screen: window border, LCD, glass
|
||||
const screenTex = new THREE.CanvasTexture(screenCanvas);
|
||||
screenTex.colorSpace = THREE.SRGBColorSpace;
|
||||
screenTex.anisotropy = anisotropy;
|
||||
// Mipmapped trilinear (the defaults) keeps 640x480 from shimmering at arm's length.
|
||||
const screenMat = new THREE.MeshBasicMaterial({ map: screenTex, toneMapped: false, vertexColors: true });
|
||||
// The black window border shares the LCD's mesh and draw call: its vertices are coloured black,
|
||||
// which zeroes the screen texture there (still unlit pure black). That saved call pays for the
|
||||
// back engraving, keeping XR within the 60-call budget with both controllers drawn.
|
||||
const lcdGeo = paint(new THREE.PlaneGeometry(LCD.w, LCD.h), '#ffffff');
|
||||
const windowGeo = paint(new THREE.ShapeGeometry(roundedRectShape(WINDOW.w, WINDOW.h, 0.9 * MM), 4), '#000000');
|
||||
windowGeo.translate(0, 0, -0.04 * MM); // 0.04 mm under the LCD, as when it was its own mesh
|
||||
{ const uv = windowGeo.attributes.uv; for (let i = 0; i < uv.count; i++) uv.setXY(i, 0.5, 0.5); }
|
||||
// LCD first, so early depth rejects the border hidden under it.
|
||||
const lcd = new THREE.Mesh(mergeGeometries([lcdGeo, windowGeo]), screenMat);
|
||||
lcd.position.set(LCD.cx, LCD.cy, FRONT_Z + 0.08 * MM);
|
||||
lcd.name = 'lcd';
|
||||
|
||||
// Glass: black dielectric drawn additively, so it contributes only its reflection and never
|
||||
// dims the LCD under it.
|
||||
const glassMat = new THREE.MeshStandardMaterial({
|
||||
color: 0x000000, metalness: 0, roughness: 0.05, envMap, envMapIntensity: 1.0,
|
||||
transparent: true, blending: THREE.AdditiveBlending, depthWrite: false,
|
||||
});
|
||||
const glass = new THREE.Mesh(new THREE.ShapeGeometry(roundedRectShape(WINDOW.w + 1 * MM, WINDOW.h + 1 * MM, 1.2 * MM), 4), glassMat);
|
||||
glass.position.set(LCD.cx, LCD.cy, FRONT_Z + 0.14 * MM);
|
||||
glass.renderOrder = 2;
|
||||
root.add(lcd, glass);
|
||||
|
||||
// ---- click wheel (rocks as one group; the marker rides on it)
|
||||
const wheelGroup = new THREE.Group();
|
||||
wheelGroup.position.set(0, WHEEL.cy, FRONT_Z);
|
||||
root.add(wheelGroup);
|
||||
|
||||
const labelCanvas = document.createElement('canvas');
|
||||
labelCanvas.width = labelCanvas.height = LABEL_PX;
|
||||
const labelTex = new THREE.CanvasTexture(labelCanvas);
|
||||
labelTex.colorSpace = THREE.SRGBColorSpace;
|
||||
labelTex.anisotropy = anisotropy;
|
||||
const wheelMat = new THREE.MeshStandardMaterial({ map: labelTex, roughness: 0.55, metalness: 0, envMap, envMapIntensity: 0.6 });
|
||||
const innerR = WHEEL.rCenter + 0.3 * MM;
|
||||
const wheelTop = new THREE.RingGeometry(innerR, WHEEL.rOuter, 128, 1);
|
||||
const wheelSide = new THREE.CylinderGeometry(WHEEL.rOuter, WHEEL.rOuter, WHEEL_T, 128, 1, true);
|
||||
wheelSide.rotateX(Math.PI / 2);
|
||||
wheelSide.translate(0, 0, -WHEEL_T / 2);
|
||||
// The side walls sample the dark rim of the label texture.
|
||||
{ const uv = wheelSide.attributes.uv; for (let i = 0; i < uv.count; i++) uv.setXY(i, 0.5, 0.0008); }
|
||||
const wheelInner = new THREE.CylinderGeometry(innerR, innerR, WHEEL_T, 64, 1, true);
|
||||
wheelInner.rotateX(Math.PI / 2);
|
||||
wheelInner.translate(0, 0, -WHEEL_T / 2);
|
||||
{ const uv = wheelInner.attributes.uv; for (let i = 0; i < uv.count; i++) uv.setXY(i, 0.5, 0.0008); }
|
||||
const wheelGeo = mergeGeometries([wheelTop, wheelSide, wheelInner]);
|
||||
const wheel = new THREE.Mesh(wheelGeo, wheelMat);
|
||||
wheel.name = 'wheel';
|
||||
wheelGroup.add(wheel);
|
||||
|
||||
const buttonGeo = new THREE.CylinderGeometry(WHEEL.rCenter, WHEEL.rCenter, WHEEL_T, 96, 1, false);
|
||||
buttonGeo.rotateX(Math.PI / 2);
|
||||
buttonGeo.translate(0, 0, -WHEEL_T / 2);
|
||||
const button = new THREE.Mesh(buttonGeo, frontMat);
|
||||
button.name = 'center';
|
||||
wheelGroup.add(button);
|
||||
|
||||
const shadowMat = new THREE.MeshBasicMaterial({
|
||||
map: new THREE.CanvasTexture(shadowCanvas()), transparent: true, opacity: 0, depthWrite: false, toneMapped: false,
|
||||
});
|
||||
const shadow = new THREE.Mesh(new THREE.PlaneGeometry(1, 1), shadowMat);
|
||||
shadow.scale.setScalar(WHEEL.markerSize * 1.25);
|
||||
shadow.renderOrder = 3;
|
||||
const markerTex = new THREE.CanvasTexture(markerCanvas());
|
||||
markerTex.colorSpace = THREE.SRGBColorSpace;
|
||||
const markerMat = new THREE.MeshBasicMaterial({
|
||||
map: markerTex, color: 0xffffff, transparent: true, opacity: 0,
|
||||
blending: THREE.AdditiveBlending, depthWrite: false, toneMapped: false,
|
||||
});
|
||||
const marker = new THREE.Mesh(new THREE.PlaneGeometry(1, 1), markerMat);
|
||||
marker.renderOrder = 4;
|
||||
marker.name = 'marker';
|
||||
marker.visible = false;
|
||||
shadow.visible = false;
|
||||
wheelGroup.add(shadow, marker);
|
||||
|
||||
// Ray-grab target; invisible materials are skipped by the renderer but still raycast.
|
||||
const collider = new THREE.Mesh(
|
||||
new THREE.BoxGeometry(BODY.w * 1.6, BODY.h * 1.6, BODY.d * 1.6),
|
||||
new THREE.MeshBasicMaterial({ visible: false }),
|
||||
);
|
||||
collider.name = 'asspod-collider';
|
||||
root.add(collider);
|
||||
|
||||
// ---- state
|
||||
let currentFinish = null;
|
||||
let screenDirty = false;
|
||||
const stats = { uploads: 1 }; // CanvasTexture uploads once on creation
|
||||
const touch = { active: false, angle: 0, opacity: 0, scale: 1 };
|
||||
let pressCount = 0;
|
||||
const rock = { x: 0, v: 0 };
|
||||
let rockTarget = 0;
|
||||
const rockAxis = new THREE.Vector3(1, 0, 0);
|
||||
const sink = { x: 0, v: 0 };
|
||||
let sinkTarget = 0;
|
||||
const pressed = new Set();
|
||||
|
||||
function setFinish(f) {
|
||||
const key = FINISHES[f] ? f : 'black';
|
||||
if (key === currentFinish) return;
|
||||
currentFinish = key;
|
||||
const fin = FINISHES[key];
|
||||
frontMat.color.set(fin.front);
|
||||
frontMat.roughness = fin.frontRough;
|
||||
darkMat.color.set(fin.well);
|
||||
drawWheelLabels(labelCanvas, key);
|
||||
labelTex.needsUpdate = true;
|
||||
// White absorbs an additive glow, so the white finish gets a dark disc under it.
|
||||
shadow.userData.enabled = fin.shadow;
|
||||
}
|
||||
setFinish(finish);
|
||||
|
||||
function setTouch(active, angle) {
|
||||
touch.active = !!active;
|
||||
if (active && Number.isFinite(angle)) touch.angle = angle;
|
||||
}
|
||||
|
||||
function pressDown(part) {
|
||||
if (pressed.has(part)) return;
|
||||
pressed.add(part);
|
||||
pressCount++;
|
||||
if (part === 'select') {
|
||||
sinkTarget = 1;
|
||||
} else if (part in SECTOR_ANGLE) {
|
||||
const a = SECTOR_ANGLE[part];
|
||||
rockAxis.set(Math.cos(a), -Math.sin(a), 0);
|
||||
rockTarget = 1;
|
||||
}
|
||||
}
|
||||
|
||||
function pressUp(part) {
|
||||
if (!pressed.delete(part)) return;
|
||||
pressCount = Math.max(0, pressCount - 1);
|
||||
if (part === 'select') sinkTarget = 0;
|
||||
else if (part in SECTOR_ANGLE) rockTarget = 0;
|
||||
}
|
||||
|
||||
function setBacklight(level) {
|
||||
screenMat.color.setScalar(Math.max(0, Math.min(1, level)));
|
||||
}
|
||||
|
||||
function markScreenDirty() { screenDirty = true; }
|
||||
|
||||
function update(dt) {
|
||||
if (screenDirty) {
|
||||
screenTex.needsUpdate = true;
|
||||
stats.uploads++;
|
||||
screenDirty = false;
|
||||
}
|
||||
|
||||
// Marker: linear fades with different in/out times, so a touch reads instantly and lingers briefly.
|
||||
const target = touch.active ? MARKER_PEAK : 0;
|
||||
if (touch.opacity < target) touch.opacity = Math.min(target, touch.opacity + dt * MARKER_PEAK / MARKER_IN_S);
|
||||
else if (touch.opacity > target) touch.opacity = Math.max(target, touch.opacity - dt * MARKER_PEAK / MARKER_OUT_S);
|
||||
const sTarget = pressCount > 0 ? MARKER_PRESS_SCALE : 1;
|
||||
touch.scale += (sTarget - touch.scale) * (1 - Math.exp(-dt / 0.04));
|
||||
const on = touch.opacity > 0.001;
|
||||
marker.visible = on;
|
||||
if (on) {
|
||||
const s = Math.sin(touch.angle);
|
||||
const c = Math.cos(touch.angle);
|
||||
marker.position.set(WHEEL.rMarker * s, WHEEL.rMarker * c, 0.15 * MM);
|
||||
marker.scale.setScalar(WHEEL.markerSize * touch.scale);
|
||||
markerMat.opacity = touch.opacity;
|
||||
}
|
||||
shadow.visible = on && shadow.userData.enabled;
|
||||
if (shadow.visible) {
|
||||
shadow.position.set(marker.position.x, marker.position.y, 0.08 * MM);
|
||||
shadowMat.opacity = 0.18 * touch.opacity / MARKER_PEAK;
|
||||
}
|
||||
|
||||
// Press feel: fast in, critically damped back out.
|
||||
springStep(rock, rockTarget, rockTarget ? 70 : 38, dt);
|
||||
springStep(sink, sinkTarget, sinkTarget ? 70 : 38, dt);
|
||||
wheelGroup.quaternion.setFromAxisAngle(rockAxis, ROCK_ANGLE * rock.x);
|
||||
button.position.z = -SINK * sink.x;
|
||||
}
|
||||
|
||||
// ---- wheel hit testing
|
||||
const _inv = new THREE.Matrix4();
|
||||
const _ray = new THREE.Ray();
|
||||
|
||||
/** Projects a ray onto the wheel plane. Returns wheel coords in units of rOuter (y up), or null. */
|
||||
function projectToWheel(raycaster, out = { x: 0, y: 0 }) {
|
||||
root.updateWorldMatrix(true, false);
|
||||
_inv.copy(root.matrixWorld).invert();
|
||||
_ray.copy(raycaster.ray).applyMatrix4(_inv);
|
||||
const dz = _ray.direction.z;
|
||||
if (dz > -1e-9) return null; // parallel, or looking at the back
|
||||
const t = (FRONT_Z - _ray.origin.z) / dz;
|
||||
if (t < 0) return null;
|
||||
out.x = (_ray.origin.x + _ray.direction.x * t) / WHEEL.rOuter;
|
||||
out.y = (_ray.origin.y + _ray.direction.y * t - WHEEL.cy) / WHEEL.rOuter;
|
||||
return out;
|
||||
}
|
||||
|
||||
const _p = { x: 0, y: 0 };
|
||||
function hitTestWheel(raycaster) {
|
||||
if (!projectToWheel(raycaster, _p)) return null;
|
||||
const r = Math.hypot(_p.x, _p.y);
|
||||
if (r > 1) return null;
|
||||
let angle = Math.atan2(_p.x, _p.y);
|
||||
if (angle < 0) angle += Math.PI * 2;
|
||||
return { angle, r, onCenter: r * WHEEL.rOuter < WHEEL.rCenter };
|
||||
}
|
||||
|
||||
return {
|
||||
root,
|
||||
collider,
|
||||
setTouch,
|
||||
pressDown,
|
||||
pressUp,
|
||||
setBacklight,
|
||||
markScreenDirty,
|
||||
setFinish,
|
||||
hitTestWheel,
|
||||
projectToWheel,
|
||||
update,
|
||||
stats,
|
||||
get finish() { return currentFinish; },
|
||||
// Exposed for tests and the debug handle.
|
||||
parts: { chrome, plate, wheel, button, marker, shadow, lcd, glass, wheelGroup, engraving },
|
||||
touch,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
<!doctype html>
|
||||
<!-- Exports the assPod's 3D body (asspod-model.js, the original procedural model) as SteamVR render
|
||||
models: one folder per part under device/asspod/model/, each <name>.obj + <name>.mtl + texture.
|
||||
SteamVR lights them and draws them in the hand (device/asspod/src/sg.js); the live screen, the
|
||||
touch marker and the gloss are a panel on the front.
|
||||
|
||||
Run: python tools/build_asspod_model.py (serves the repo, opens this page in a browser and
|
||||
writes the files the page sends back).
|
||||
|
||||
Parts: steel_back (the flat mirror back, planar-mapped onto the polished-steel texture with the
|
||||
engraved peach), steel_edge (the steel that wraps the sides, mapped by its normal onto a baked
|
||||
chrome reflection), plate_<finish> (the front plate), wheel_<finish> (ring with its labels),
|
||||
center_<finish> (the centre button), well (the gap around the wheel), ports (top and bottom). -->
|
||||
<html><head><meta charset="utf-8"><title>assPod model export</title>
|
||||
<script type="importmap">
|
||||
{ "imports": {
|
||||
"three": "https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js",
|
||||
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.160.0/examples/jsm/"
|
||||
} }
|
||||
</script>
|
||||
</head><body>
|
||||
<pre id="log">exporting…</pre>
|
||||
<script type="module">
|
||||
import * as THREE from 'three';
|
||||
import { createAssPodModel, GEOMETRY } from './asspod-model.js';
|
||||
import { drawBack } from '../../device/asspod/src/face.js';
|
||||
import { drawPeachSilhouette } from '../../device/asspod/src/brand/peach.js';
|
||||
|
||||
const { BODY } = GEOMETRY;
|
||||
const FILES = {};
|
||||
const log = (s) => { document.getElementById('log').textContent += '\n' + s; };
|
||||
|
||||
// ---- textures
|
||||
function canvas(w, h) { const c = document.createElement('canvas'); c.width = w; c.height = h; return c; }
|
||||
const b64 = (c) => c.toDataURL('image/png').split(',')[1];
|
||||
// raw RGBA for the native renderer (native/podd): "RGBA", width, height (u32 little-endian), rows top first
|
||||
function rgba(c) {
|
||||
const g = c.getContext('2d');
|
||||
const d = g.getImageData(0, 0, c.width, c.height).data;
|
||||
const out = new Uint8Array(12 + d.length);
|
||||
out.set([82, 71, 66, 65]);
|
||||
new DataView(out.buffer).setUint32(4, c.width, true);
|
||||
new DataView(out.buffer).setUint32(8, c.height, true);
|
||||
out.set(d, 12);
|
||||
let bin = '';
|
||||
for (let i = 0; i < out.length; i += 0x8000) bin += String.fromCharCode.apply(null, out.subarray(i, i + 0x8000));
|
||||
return btoa(bin);
|
||||
}
|
||||
function solid(hex) {
|
||||
const c = canvas(8, 8);
|
||||
const g = c.getContext('2d');
|
||||
g.fillStyle = hex;
|
||||
g.fillRect(0, 0, 8, 8);
|
||||
return c;
|
||||
}
|
||||
// A chrome reflection by normal (a "matcap"): polished steel shows a bright studio sky, a dark
|
||||
// horizon band, a pale floor and one strong highlight.
|
||||
function chromeMap(size) {
|
||||
const c = canvas(size, size);
|
||||
const g = c.getContext('2d');
|
||||
const img = g.createImageData(size, size);
|
||||
const env = (ry, rx) => {
|
||||
let v;
|
||||
if (ry > 0.25) v = 0.93 + 0.07 * Math.min(1, (ry - 0.25) / 0.5);
|
||||
else if (ry > 0.02) v = 0.45 + 0.48 * ((ry - 0.02) / 0.23);
|
||||
else if (ry > -0.12) v = 0.2 + 0.25 * ((ry + 0.12) / 0.14);
|
||||
else v = 0.62 + 0.18 * Math.min(1, (-ry - 0.12) / 0.6);
|
||||
const hx = rx + 0.45, hy = ry - 0.55;
|
||||
v += 0.6 * Math.exp(-(hx * hx + hy * hy) / 0.03);
|
||||
return Math.min(1, v);
|
||||
};
|
||||
for (let y = 0; y < size; y++) {
|
||||
for (let x = 0; x < size; x++) {
|
||||
const nx = (x + 0.5) / size * 2 - 1;
|
||||
const ny = 1 - (y + 0.5) / size * 2;
|
||||
const r2 = nx * nx + ny * ny;
|
||||
const i = (y * size + x) * 4;
|
||||
const nz = Math.sqrt(Math.max(0, 1 - Math.min(1, r2)));
|
||||
// reflect the view (0,0,-1) about n: r = v - 2(v.n)n, v = (0,0,-1)
|
||||
const rx = 2 * nz * nx, ry = 2 * nz * ny;
|
||||
const l = env(ry, rx);
|
||||
img.data[i] = Math.round(255 * l * 0.97);
|
||||
img.data[i + 1] = Math.round(255 * l * 0.98);
|
||||
img.data[i + 2] = Math.round(255 * l);
|
||||
img.data[i + 3] = 255;
|
||||
}
|
||||
}
|
||||
g.putImageData(img, 0, 0);
|
||||
return c;
|
||||
}
|
||||
|
||||
// ---- OBJ writer: a geometry (positions, normals, uvs; indexed or not) in the model's frame
|
||||
function objText(name, geo) {
|
||||
const g = geo.index ? geo.toNonIndexed() : geo;
|
||||
const p = g.attributes.position, n = g.attributes.normal, t = g.attributes.uv;
|
||||
const out = [`# ${name}: the assPod body (tools/asspod-model/export.html)`, `mtllib ${name}.mtl`, `usemtl ${name}`];
|
||||
const f = (v) => v.toFixed(6);
|
||||
for (let i = 0; i < p.count; i++) out.push(`v ${f(p.getX(i))} ${f(p.getY(i))} ${f(p.getZ(i))}`);
|
||||
for (let i = 0; i < p.count; i++) out.push(`vt ${f(t ? t.getX(i) : 0.5)} ${f(t ? t.getY(i) : 0.5)}`);
|
||||
for (let i = 0; i < p.count; i++) out.push(`vn ${f(n.getX(i))} ${f(n.getY(i))} ${f(n.getZ(i))}`);
|
||||
for (let i = 0; i < p.count; i += 3) out.push(`f ${i + 1}/${i + 1}/${i + 1} ${i + 2}/${i + 2}/${i + 2} ${i + 3}/${i + 3}/${i + 3}`);
|
||||
return out.join('\n') + '\n';
|
||||
}
|
||||
function mtlText(name, { ks = 0.04, ns = 256 } = {}) {
|
||||
return `newmtl ${name}\nKd 1 1 1\nNs ${ns}\nd 1\nillum 2\nKa 0 0 0\nKs ${ks} ${ks} ${ks}\nmap_Kd ${name}_color.png\n`;
|
||||
}
|
||||
function part(name, geo, tex, mtl) {
|
||||
const dir = `device/asspod/model/${name}/`;
|
||||
FILES[dir + name + '.obj'] = { text: objText(name, geo) };
|
||||
FILES[dir + name + '.mtl'] = { text: mtlText(name, mtl) };
|
||||
FILES[dir + name + '_color.png'] = { b64: b64(tex) };
|
||||
FILES[dir + name + '_color.rgba'] = { b64: rgba(tex) };
|
||||
log(`${name}: ${(geo.index ? geo.index.count : geo.attributes.position.count) / 3} triangles`);
|
||||
}
|
||||
|
||||
// The mesh's geometry in the model's frame (its own and its parents' transforms applied).
|
||||
function baked(mesh, root) {
|
||||
root.updateMatrixWorld(true);
|
||||
const g = mesh.geometry.clone();
|
||||
if (!g.attributes.normal) g.computeVertexNormals();
|
||||
g.applyMatrix4(mesh.matrixWorld);
|
||||
return g;
|
||||
}
|
||||
// Splits a non-indexed geometry by a triangle predicate.
|
||||
function split(geo, test) {
|
||||
const g = geo.index ? geo.toNonIndexed() : geo;
|
||||
const a = new THREE.BufferGeometry(), b = new THREE.BufferGeometry();
|
||||
const keep = [[], []];
|
||||
const n = g.attributes.normal;
|
||||
for (let i = 0; i < g.attributes.position.count; i += 3) {
|
||||
const avg = new THREE.Vector3();
|
||||
for (let k = 0; k < 3; k++) avg.add(new THREE.Vector3(n.getX(i + k), n.getY(i + k), n.getZ(i + k)));
|
||||
keep[test(avg.normalize(), g, i) ? 0 : 1].push(i);
|
||||
}
|
||||
for (const [dst, list] of [[a, keep[0]], [b, keep[1]]]) {
|
||||
for (const key of ['position', 'normal', 'uv']) {
|
||||
const src = g.attributes[key];
|
||||
if (!src) continue;
|
||||
const arr = new Float32Array(list.length * 3 * src.itemSize);
|
||||
list.forEach((i, j) => { for (let k = 0; k < 3; k++) for (let c = 0; c < src.itemSize; c++) arr[(j * 3 + k) * src.itemSize + c] = src.array[(i + k) * src.itemSize + c]; });
|
||||
dst.setAttribute(key, new THREE.BufferAttribute(arr, src.itemSize));
|
||||
}
|
||||
}
|
||||
return [a, b];
|
||||
}
|
||||
function setUV(geo, fn) {
|
||||
const p = geo.attributes.position, n = geo.attributes.normal;
|
||||
const uv = new Float32Array(p.count * 2);
|
||||
for (let i = 0; i < p.count; i++) {
|
||||
const [u, v] = fn(p.getX(i), p.getY(i), p.getZ(i), n.getX(i), n.getY(i), n.getZ(i));
|
||||
uv[i * 2] = u; uv[i * 2 + 1] = v;
|
||||
}
|
||||
geo.setAttribute('uv', new THREE.BufferAttribute(uv, 2));
|
||||
return geo;
|
||||
}
|
||||
|
||||
const FINISH = {
|
||||
black: { front: '#0b0b0c', well: '#030303' },
|
||||
white: { front: '#f4f4f2', well: '#8d8d89' },
|
||||
};
|
||||
|
||||
for (const finish of ['black', 'white']) {
|
||||
const model = createAssPodModel({ screenCanvas: canvas(64, 48), envMap: null, finish });
|
||||
const meshes = [];
|
||||
model.root.traverse((o) => { if (o.isMesh) meshes.push(o); });
|
||||
const byName = (n) => meshes.find((m) => m.name === n);
|
||||
const chrome = meshes.find((m) => m.material.metalness === 1 && m.material.roughness < 0.1);
|
||||
const plate = meshes.find((m) => m.material.isMeshPhysicalMaterial && m.name !== 'center');
|
||||
const well = meshes.find((m) => m.material.side === THREE.DoubleSide && m.material.roughness === 0.7);
|
||||
const ports = meshes.find((m) => m.material.vertexColors && m.name !== 'lcd');
|
||||
const wheel = byName('wheel');
|
||||
const center = byName('center');
|
||||
if (finish === 'black') {
|
||||
// the steel, once: the flat back (planar onto the polished steel), the rest by its normal
|
||||
const [back, edge] = split(baked(chrome, model.root), (nrm) => nrm.z < -0.97);
|
||||
const backTex = canvas(512, Math.round(512 * BODY.h / BODY.w));
|
||||
drawBack(backTex, drawPeachSilhouette);
|
||||
// seen from behind: +x of the model is the viewer's left, so u runs from +x to -x
|
||||
setUV(back, (x, y) => [0.5 - x / BODY.w, 0.5 + y / BODY.h]);
|
||||
setUV(edge, (x, y, z, nx, ny, nz) => [0.5 + nx * 0.48, 0.5 + ny * 0.48]);
|
||||
part('asspod_steel_back', back, backTex, { ks: 0.5, ns: 512 });
|
||||
// the engraving as a mask (white = etched) for the native renderer's metal: satin where etched
|
||||
const mask = canvas(512, Math.round(512 * BODY.h / BODY.w));
|
||||
{
|
||||
const g = mask.getContext('2d');
|
||||
g.fillStyle = '#000';
|
||||
g.fillRect(0, 0, mask.width, mask.height);
|
||||
const k = mask.width / (BODY.w * 1000);
|
||||
drawPeachSilhouette(g, mask.width / 2, 30 * k, 18.5 * k, '#fff');
|
||||
g.fillStyle = '#fff';
|
||||
g.textAlign = 'center';
|
||||
g.textBaseline = 'middle';
|
||||
g.font = `600 ${Math.round(5 * k)}px "Helvetica Neue", Helvetica, Arial, sans-serif`;
|
||||
g.fillText('assPod', mask.width / 2, 52 * k);
|
||||
}
|
||||
FILES['device/asspod/model/asspod_steel_back/asspod_steel_back_mask.rgba'] = { b64: rgba(mask) };
|
||||
part('asspod_steel_edge', edge, chromeMap(256), { ks: 0.6, ns: 512 });
|
||||
part('asspod_ports', baked(ports, model.root), solid('#2a2b2e'), { ks: 0.2 });
|
||||
}
|
||||
const fin = FINISH[finish];
|
||||
part(`asspod_plate_${finish}`, baked(plate, model.root), solid(fin.front), { ks: 0.6, ns: 512 });
|
||||
part(`asspod_well_${finish}`, baked(well, model.root), solid(fin.well), { ks: 0 });
|
||||
part(`asspod_wheel_${finish}`, baked(wheel, model.root), wheel.material.map.image, { ks: 0.08, ns: 64 });
|
||||
part(`asspod_center_${finish}`, baked(center, model.root), solid(fin.front), { ks: 0.6, ns: 512 });
|
||||
}
|
||||
window.FILES = FILES;
|
||||
log('exported ' + Object.keys(FILES).length + ' files');
|
||||
// tools/build_asspod_model.py writes them into the repo
|
||||
fetch('/asspod-model', { method: 'POST', body: JSON.stringify(FILES) })
|
||||
.then((r) => r.text()).then((t) => { log('saved: ' + t); window.DONE = true; })
|
||||
.catch((e) => { log('not saved (open this page through tools/build_asspod_model.py): ' + e); window.DONE = true; });
|
||||
</script>
|
||||
</body></html>
|
||||
@@ -0,0 +1,143 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build device/vr/systemui.asspod.js from the assPod's ES modules in device/asspod/src.
|
||||
|
||||
The daemon installs SteamVR page scripts as one function expression, (function (ctx) {...}), so
|
||||
the modules are wrapped into a tiny module table: each import becomes a lookup and each export an
|
||||
assignment. Exported functions are assigned at the top of their module (they are hoisted), so the
|
||||
one import cycle (main-menu <-> search) resolves; other exports are assigned at the end.
|
||||
|
||||
Only the forms the sources use are accepted, one per line:
|
||||
import { a, b as c } from './x.js'; import * as ns from './x.js';
|
||||
export function f(...) / export async function f(...) / export default function f(...)
|
||||
export const|let|class X
|
||||
Anything else at the start of a line that says import or export is an error.
|
||||
|
||||
python tools/build_asspod.py write the bundle
|
||||
python tools/build_asspod.py --check exit 1 if the committed bundle is out of date
|
||||
"""
|
||||
import os
|
||||
import posixpath
|
||||
import re
|
||||
import sys
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
SRC = os.path.join(ROOT, "device", "asspod", "src")
|
||||
OUT = os.path.join(ROOT, "device", "vr", "systemui.asspod.js")
|
||||
ENTRY = "main.js"
|
||||
FLAG = "assPod"
|
||||
|
||||
IMPORT_NAMED = re.compile(r"^import\s*\{([^}]*)\}\s*from\s*'([^']+)';\s*$")
|
||||
IMPORT_NS = re.compile(r"^import\s+\*\s+as\s+(\w+)\s+from\s*'([^']+)';\s*$")
|
||||
EXPORT_FN = re.compile(r"^export\s+(default\s+)?(async\s+)?function\s+(\w+)")
|
||||
EXPORT_DECL = re.compile(r"^export\s+(const|let|class)\s+(\w+)")
|
||||
|
||||
|
||||
def modules():
|
||||
out = {}
|
||||
for d, _, files in os.walk(SRC):
|
||||
for f in sorted(files):
|
||||
if f.endswith(".js"):
|
||||
p = os.path.join(d, f)
|
||||
out[os.path.relpath(p, SRC).replace(os.sep, "/")] = p
|
||||
return dict(sorted(out.items()))
|
||||
|
||||
|
||||
def resolve(mod, spec):
|
||||
if not spec.startswith("."):
|
||||
raise SystemExit(f"{mod}: bare import {spec!r} (no packages in a page script)")
|
||||
return posixpath.normpath(posixpath.join(posixpath.dirname(mod), spec))
|
||||
|
||||
|
||||
def transform(mod, src, known):
|
||||
early, late, body = [], [], []
|
||||
for n, line in enumerate(src.split("\n"), 1):
|
||||
m = IMPORT_NAMED.match(line)
|
||||
if m:
|
||||
target = resolve(mod, m.group(2))
|
||||
if target not in known:
|
||||
raise SystemExit(f"{mod}:{n}: no module {target}")
|
||||
names = []
|
||||
for part in [p.strip() for p in m.group(1).split(",") if p.strip()]:
|
||||
a = re.match(r"^(\w+)(?:\s+as\s+(\w+))?$", part)
|
||||
if not a:
|
||||
raise SystemExit(f"{mod}:{n}: cannot read import {part!r}")
|
||||
names.append(a.group(1) if not a.group(2) else f"{a.group(1)}: {a.group(2)}")
|
||||
body.append(f"const {{ {', '.join(names)} }} = __req({target!r});")
|
||||
continue
|
||||
m = IMPORT_NS.match(line)
|
||||
if m:
|
||||
target = resolve(mod, m.group(2))
|
||||
if target not in known:
|
||||
raise SystemExit(f"{mod}:{n}: no module {target}")
|
||||
body.append(f"const {m.group(1)} = __req({target!r});")
|
||||
continue
|
||||
m = EXPORT_FN.match(line)
|
||||
if m:
|
||||
name = m.group(3)
|
||||
early.append(f"__e.{'default' if m.group(1) else name} = {name};")
|
||||
body.append(line[len("export "):].replace("default ", "", 1) if m.group(1) else line[len("export "):])
|
||||
continue
|
||||
m = EXPORT_DECL.match(line)
|
||||
if m:
|
||||
late.append(f"__e.{m.group(2)} = {m.group(2)};")
|
||||
body.append(line[len("export "):])
|
||||
continue
|
||||
if re.match(r"^\s*(import|export)\b", line):
|
||||
raise SystemExit(f"{mod}:{n}: unsupported module syntax: {line.strip()[:80]}")
|
||||
body.append(line)
|
||||
return "\n".join(early + body + late)
|
||||
|
||||
|
||||
def build():
|
||||
mods = modules()
|
||||
if ENTRY not in mods:
|
||||
raise SystemExit(f"no {ENTRY} in {SRC}")
|
||||
parts = [
|
||||
f"// @lgs-flag {FLAG}",
|
||||
"// assPod for SteamVR's systemui page. GENERATED by tools/build_asspod.py from device/asspod/src;",
|
||||
"// edit the sources and run the tool, never this file. The daemon installs it as a page script",
|
||||
"// (device/lgs_shell.py vr_scripts) at window.__LGS_VRX.asspod; device/shell_ext/asspod.py drives it.",
|
||||
"(function (ctx) {",
|
||||
" 'use strict';",
|
||||
" const __defs = Object.create(null);",
|
||||
" const __mods = Object.create(null);",
|
||||
" const __req = (id) => {",
|
||||
" if (__mods[id]) return __mods[id];",
|
||||
" const e = (__mods[id] = {});",
|
||||
" __defs[id](e);",
|
||||
" return e;",
|
||||
" };",
|
||||
]
|
||||
for mod, path in mods.items():
|
||||
with open(path, encoding="utf-8") as f:
|
||||
src = f.read()
|
||||
parts.append(f" // ---------------------------------------------------------------- {mod}")
|
||||
parts.append(f" __defs[{mod!r}] = function (__e) {{")
|
||||
parts.append(transform(mod, src, mods).rstrip())
|
||||
parts.append(" };")
|
||||
parts.append(f" return __req({ENTRY!r}).default(ctx);")
|
||||
parts.append("})")
|
||||
return "\n".join(parts) + "\n"
|
||||
|
||||
|
||||
def main():
|
||||
out = build()
|
||||
if "--check" in sys.argv[1:]:
|
||||
try:
|
||||
with open(OUT, encoding="utf-8") as f:
|
||||
cur = f.read()
|
||||
except OSError:
|
||||
cur = None
|
||||
if cur != out:
|
||||
print(f"{os.path.relpath(OUT, ROOT)} is out of date: run python tools/build_asspod.py")
|
||||
return 1
|
||||
print("assPod bundle up to date")
|
||||
return 0
|
||||
with open(OUT, "w", encoding="utf-8", newline="\n") as f:
|
||||
f.write(out)
|
||||
print(f"wrote {os.path.relpath(OUT, ROOT)} ({len(out)} bytes, {len(modules())} modules)")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,67 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Export the assPod's 3D body into device/asspod/model/ (SteamVR render models).
|
||||
|
||||
The body is the original procedural model (tools/asspod-model/asspod-model.js, three.js). Its
|
||||
exporter runs in a browser: this serves the repo on http://127.0.0.1:8766/, opens
|
||||
tools/asspod-model/export.html, and writes the files the page sends back (only under
|
||||
device/asspod/model/). Ctrl+C when it says "done".
|
||||
|
||||
python tools/build_asspod_model.py [--no-open]
|
||||
"""
|
||||
import base64
|
||||
import http.server
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import webbrowser
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
OUT = os.path.join(ROOT, "device", "asspod", "model")
|
||||
PORT = 8766
|
||||
|
||||
|
||||
class Handler(http.server.SimpleHTTPRequestHandler):
|
||||
def __init__(self, *a, **k):
|
||||
super().__init__(*a, directory=ROOT, **k)
|
||||
|
||||
def log_message(self, fmt, *args):
|
||||
pass
|
||||
|
||||
def do_POST(self):
|
||||
if self.path != "/asspod-model":
|
||||
self.send_error(404)
|
||||
return
|
||||
n = int(self.headers.get("Content-Length") or 0)
|
||||
files = json.loads(self.rfile.read(n).decode("utf-8"))
|
||||
written = 0
|
||||
for rel, v in files.items():
|
||||
dst = os.path.realpath(os.path.join(ROOT, rel))
|
||||
if not dst.startswith(os.path.realpath(OUT) + os.sep):
|
||||
print(f"refused {rel}")
|
||||
continue
|
||||
os.makedirs(os.path.dirname(dst), exist_ok=True)
|
||||
data = base64.b64decode(v["b64"]) if "b64" in v else v["text"].encode("utf-8")
|
||||
with open(dst, "wb") as f:
|
||||
f.write(data)
|
||||
written += 1
|
||||
print(f"done: {written} files in {os.path.relpath(OUT, ROOT)}")
|
||||
self.send_response(200)
|
||||
self.end_headers()
|
||||
self.wfile.write(b"ok")
|
||||
|
||||
|
||||
def main():
|
||||
srv = http.server.ThreadingHTTPServer(("127.0.0.1", PORT), Handler)
|
||||
url = f"http://127.0.0.1:{PORT}/tools/asspod-model/export.html"
|
||||
print(f"serving {ROOT}; open {url}")
|
||||
if "--no-open" not in sys.argv[1:]:
|
||||
webbrowser.open(url)
|
||||
try:
|
||||
srv.serve_forever()
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -12,6 +12,7 @@ ver=${1:?usage: make_release.sh VERSION (e.g. v0.1)}
|
||||
mkdir -p dist
|
||||
stage=$(mktemp -d)
|
||||
trap 'rm -rf "$stage"' EXIT
|
||||
python tools/build_asspod.py --check # the assPod page script must match its sources
|
||||
git archive --format=tar HEAD device theme lab native | tar -xf - -C "$stage"
|
||||
printf '%s\n' "$ver" > "$stage/VERSION"
|
||||
tar -czf dist/LiquidAss-Frame.tar.gz -C "$stage" VERSION device theme lab native
|
||||
|
||||