Skip to content

Commit 683ebfe

Browse files
kixelatedclaude
andauthored
moq-boy: dark mode, fix paths, subscription improvements (#1226)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 040b75b commit 683ebfe

13 files changed

Lines changed: 129 additions & 63 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

cdn/README.md

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,16 +13,19 @@ However, we do use GCP for GeoDNS because most providers don't support it or too
1313
mkdir -p secrets
1414

1515
# generate the root key private key
16-
cargo run --bin moq-token-cli -- --key secrets/root.jwk generate > secrets/root.jwk
16+
cargo run --bin moq-token-cli -- generate --key secrets/root.jwk > secrets/root.jwk
1717

1818
# to allow relay servers to connect to each other
19-
cargo run --bin moq-token-cli -- --key secrets/root.jwk sign --publish "" --subscribe "" --cluster > secrets/cluster.jwt
19+
cargo run --bin moq-token-cli -- sign --key secrets/root.jwk --publish "" --subscribe "" --cluster > secrets/cluster.jwt
2020

2121
# to allow publishing to `demo/`
22-
cargo run --bin moq-token-cli -- --key secrets/root.jwk sign --root "demo" --publish "" > secrets/demo-pub.jwt
22+
cargo run --bin moq-token-cli -- sign --key secrets/root.jwk --root "demo" --publish "" > secrets/demo-pub.jwt
2323

2424
# to allow subscribing to `demo/` (used by health checks and the website)
25-
cargo run --bin moq-token-cli -- --key secrets/root.jwk sign --root "demo" --subscribe "" > secrets/demo-sub.jwt
25+
cargo run --bin moq-token-cli -- sign --key secrets/root.jwk --root "demo" --subscribe "" > secrets/demo-sub.jwt
26+
27+
# to allow moq-boy to publish to `demo/boy` and subscribe to `anon/boy`
28+
cargo run --bin moq-token-cli -- sign --key secrets/root.jwk --root "" --publish "demo/boy" --subscribe "anon/boy" > secrets/boy.jwt
2629
```
2730
3. Run `tofu init`.
2831
4. Run `tofu apply`.

cdn/boy/boy.service.tftpl

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,11 @@ WorkingDirectory=/var/lib/moq
1111
Environment="RUST_LOG=info"
1212

1313
ExecStart=/var/lib/moq/pkg/bin/moq-boy \
14-
--url "https://${domain}/anon" \
14+
--url "https://${domain}?jwt=$(cat /var/lib/moq/boy.jwt)" \
1515
--rom "/var/lib/moq/${rom}" \
16-
--name "${name}"
16+
--name "${name}" \
17+
--prefix-game "demo/boy" \
18+
--prefix-viewer "anon/boy"
1719

1820
# Restart with exponential backoff
1921
Restart=always

demo/boy/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"name": "@moq/demo-boy",
33
"private": true,
44
"type": "module",
5-
"version": "0.1.0",
5+
"version": "0.2.0",
66
"description": "MoQ Boy Demo - Crowd-controlled Game Boy streaming",
77
"license": "(MIT OR Apache-2.0)",
88
"scripts": {

demo/boy/src/index.html

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,16 @@
88
* { margin: 0; padding: 0; box-sizing: border-box; }
99
body {
1010
font-family: system-ui, sans-serif;
11-
color-scheme: light dark;
11+
color-scheme: dark;
12+
background: #0a0a0a;
13+
color: #e0e0e0;
1214
min-height: 100vh;
1315
display: flex;
1416
flex-direction: column;
1517
}
1618

1719
header {
18-
border-bottom: 1px solid light-dark(#ddd, #333);
20+
border-bottom: 1px solid #333;
1921
padding: 0.75rem 1.5rem;
2022
display: flex;
2123
align-items: center;
@@ -28,7 +30,7 @@
2830
margin: 1.5rem auto;
2931
padding: 0 1.5rem;
3032
font-size: 0.75rem;
31-
color: light-dark(#777, #555);
33+
color: #555;
3234
line-height: 1.7;
3335
}
3436
.about p { margin-bottom: 0.5rem; }
@@ -39,6 +41,18 @@
3941
.about li::before { content: "\2022"; color: #8bac0f; position: absolute; left: 0; }
4042

4143
moq-boy-ui { flex: 1; display: flex; flex-direction: column; }
44+
45+
footer {
46+
padding: 0.75rem 1.5rem;
47+
border-top: 1px solid #333;
48+
font-family: monospace;
49+
font-size: 0.7rem;
50+
color: #555;
51+
text-align: center;
52+
}
53+
footer code {
54+
color: #8bac0f;
55+
}
4256
</style>
4357
</head>
4458
<body>
@@ -50,7 +64,7 @@ <h1>MoQ Boy</h1>
5064
<moq-boy></moq-boy>
5165
</moq-boy-ui>
5266

53-
<div class="about">
67+
<div class="about" id="about">
5468
<p>Click a game to play. Multiple players can control it. (anarchy!)</p>
5569
<p>
5670
A generic <a href="https://moq.dev">MoQ</a> relay is used for everything:
@@ -62,6 +76,8 @@ <h1>MoQ Boy</h1>
6276
</ul>
6377
</div>
6478

79+
<footer>try: <code>just boy opossum</code></footer>
80+
6581
<script type="module" src="./index.ts"></script>
6682
</body>
6783
</html>

demo/boy/src/index.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,16 @@
11
import "@moq/boy/element";
22
import "@moq/boy/ui";
3+
import { Effect } from "@moq/signals";
34

45
const url = import.meta.env.VITE_RELAY_URL || "http://localhost:4443/anon";
56

67
const boy = document.querySelector("moq-boy");
78
if (boy) boy.url = url;
9+
10+
const about = document.getElementById("about");
11+
if (boy && about) {
12+
const effect = new Effect();
13+
effect.run(() => {
14+
about.hidden = effect.get(boy.expanded) !== undefined;
15+
});
16+
}

js/moq-boy/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "@moq/boy",
33
"type": "module",
4-
"version": "0.1.0",
4+
"version": "0.2.0",
55
"description": "MoQ Boy - Crowd-controlled Game Boy streaming via MoQ",
66
"license": "(MIT OR Apache-2.0)",
77
"repository": {

js/moq-boy/src/element.ts

Lines changed: 35 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,10 @@ import * as Moq from "@moq/lite";
22
import type { GameConfig } from "./index.ts";
33
import { Game } from "./index.ts";
44

5-
const OBSERVED = ["url", "game-prefix", "viewer-prefix"] as const;
5+
const OBSERVED = ["url", "prefix", "prefix-game", "prefix-viewer"] as const;
66
type Observed = (typeof OBSERVED)[number];
77

8-
const DEFAULT_GAME_PREFIX = "anon/boy/game";
9-
const DEFAULT_VIEWER_PREFIX = "anon/boy/viewer";
8+
const DEFAULT_PREFIX = "boy";
109

1110
const cleanup = new FinalizationRegistry<Moq.Signals.Effect>((signals) => signals.close());
1211

@@ -19,8 +18,10 @@ const cleanup = new FinalizationRegistry<Moq.Signals.Effect>((signals) => signal
1918
*
2019
* Attributes:
2120
* - `url` — MoQ relay URL
22-
* - `game-prefix` — Path prefix for game broadcasts (default: "anon/boy/game")
23-
* - `viewer-prefix` — Path prefix for viewer broadcasts (default: "anon/boy/viewer")
21+
* - `prefix` — Base path prefix (default: "boy"). Derives prefix-game and prefix-viewer.
22+
* **Breaking change**: previously the derived attributes were named game-prefix/viewer-prefix.
23+
* - `prefix-game` — Path prefix for game broadcasts (default: "{prefix}/game")
24+
* - `prefix-viewer` — Path prefix for viewer broadcasts (default: "{prefix}/viewer")
2425
*/
2526
export default class MoqBoy extends HTMLElement {
2627
static observedAttributes = OBSERVED;
@@ -33,8 +34,9 @@ export default class MoqBoy extends HTMLElement {
3334

3435
readonly #signals = new Moq.Signals.Effect();
3536
readonly #enabled = new Moq.Signals.Signal(false);
36-
readonly #gamePrefix = new Moq.Signals.Signal(DEFAULT_GAME_PREFIX);
37-
readonly #viewerPrefix = new Moq.Signals.Signal(DEFAULT_VIEWER_PREFIX);
37+
readonly #prefix = new Moq.Signals.Signal(DEFAULT_PREFIX);
38+
readonly #gamePrefixOverride = new Moq.Signals.Signal<string | undefined>(undefined);
39+
readonly #viewerPrefixOverride = new Moq.Signals.Signal<string | undefined>(undefined);
3840
readonly #sessions = new Map<string, Game>();
3941

4042
constructor() {
@@ -61,11 +63,14 @@ export default class MoqBoy extends HTMLElement {
6163
case "url":
6264
this.connection.url.set(newValue ? new URL(newValue) : undefined);
6365
break;
64-
case "game-prefix":
65-
this.#gamePrefix.set(newValue ?? DEFAULT_GAME_PREFIX);
66+
case "prefix":
67+
this.#prefix.set(newValue ?? DEFAULT_PREFIX);
6668
break;
67-
case "viewer-prefix":
68-
this.#viewerPrefix.set(newValue ?? DEFAULT_VIEWER_PREFIX);
69+
case "prefix-game":
70+
this.#gamePrefixOverride.set(newValue ?? undefined);
71+
break;
72+
case "prefix-viewer":
73+
this.#viewerPrefixOverride.set(newValue ?? undefined);
6974
break;
7075
}
7176
}
@@ -78,28 +83,37 @@ export default class MoqBoy extends HTMLElement {
7883
this.connection.url.set(value ? new URL(value) : undefined);
7984
}
8085

81-
get gamePrefix(): string {
82-
return this.#gamePrefix.peek();
86+
get prefixPath(): string {
87+
return this.#prefix.peek();
88+
}
89+
90+
set prefixPath(value: string) {
91+
this.#prefix.set(value);
92+
}
93+
94+
get prefixGame(): string {
95+
return this.#gamePrefixOverride.peek() ?? `${this.#prefix.peek()}/game`;
8396
}
8497

85-
set gamePrefix(value: string) {
86-
this.#gamePrefix.set(value);
98+
set prefixGame(value: string) {
99+
this.#gamePrefixOverride.set(value);
87100
}
88101

89-
get viewerPrefix(): string {
90-
return this.#viewerPrefix.peek();
102+
get prefixViewer(): string {
103+
return this.#viewerPrefixOverride.peek() ?? `${this.#prefix.peek()}/viewer`;
91104
}
92105

93-
set viewerPrefix(value: string) {
94-
this.#viewerPrefix.set(value);
106+
set prefixViewer(value: string) {
107+
this.#viewerPrefixOverride.set(value);
95108
}
96109

97110
#runDiscovery(effect: Moq.Signals.Effect) {
98111
const conn = effect.get(this.connection.established);
99112
if (!conn) return;
100113

101-
const gamePrefix = effect.get(this.#gamePrefix);
102-
const viewerPrefix = effect.get(this.#viewerPrefix);
114+
const base = effect.get(this.#prefix);
115+
const gamePrefix = effect.get(this.#gamePrefixOverride) ?? `${base}/game`;
116+
const viewerPrefix = effect.get(this.#viewerPrefixOverride) ?? `${base}/viewer`;
103117
const prefix = Moq.Path.from(gamePrefix);
104118

105119
const announced = conn.announced(prefix);

js/moq-boy/src/index.ts

Lines changed: 18 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -115,26 +115,28 @@ export class Game {
115115

116116
this.#signals.run(this.#runPixelBudget.bind(this));
117117

118-
// Pass active signal directly — no effect wrapper needed.
119-
this.videoDecoder = new Watch.Video.Decoder(this.videoSource, { enabled: this.active });
118+
// Video is enabled on the grid or when this game is expanded.
119+
const videoEnabled = new Moq.Signals.Signal(true);
120+
this.#signals.run(this.#runVideoEnabled.bind(this, videoEnabled));
121+
122+
this.videoDecoder = new Watch.Video.Decoder(this.videoSource, { enabled: videoEnabled });
120123
this.#signals.cleanup(() => this.videoDecoder.close());
121124

122125
// Renderer needs a canvas — created by the UI layer, set via setCanvas().
123126
this.videoRenderer = new Watch.Video.Renderer(this.videoDecoder);
124127
this.#signals.cleanup(() => this.videoRenderer.close());
125128

126-
// Audio pipeline — pass signals directly.
129+
// Audio pipeline — only download audio when active AND unmuted.
127130
this.audioSource = new Watch.Audio.Source(this.sync, { broadcast: this.broadcast });
128131
this.#signals.cleanup(() => this.audioSource.close());
129132

130-
this.audioDecoder = new Watch.Audio.Decoder(this.audioSource, { enabled: this.active });
131-
this.#signals.cleanup(() => this.audioDecoder.close());
133+
const audioEnabled = new Moq.Signals.Signal(false);
134+
this.#signals.run(this.#runAudioEnabled.bind(this, audioEnabled));
132135

133-
// Derive a muted signal: muted when user mutes OR when not active.
134-
const muted = new Moq.Signals.Signal(true);
135-
this.#signals.run(this.#runAudioMuted.bind(this, muted));
136+
this.audioDecoder = new Watch.Audio.Decoder(this.audioSource, { enabled: audioEnabled });
137+
this.#signals.cleanup(() => this.audioDecoder.close());
136138

137-
this.audioEmitter = new Watch.Audio.Emitter(this.audioDecoder, { volume: 0.5, muted });
139+
this.audioEmitter = new Watch.Audio.Emitter(this.audioDecoder, { volume: 0.5 });
138140
this.#signals.cleanup(() => this.audioEmitter.close());
139141

140142
// Resume AudioContext on first user interaction (browser autoplay policy).
@@ -198,10 +200,15 @@ export class Game {
198200
this.videoSource.target.set({ pixels });
199201
}
200202

201-
#runAudioMuted(muted: Moq.Signals.Signal<boolean>, effect: Moq.Signals.Effect) {
203+
#runVideoEnabled(videoEnabled: Moq.Signals.Signal<boolean>, effect: Moq.Signals.Effect) {
204+
const exp = effect.get(this.expanded);
205+
videoEnabled.set(exp === undefined || exp === this.sessionId);
206+
}
207+
208+
#runAudioEnabled(audioEnabled: Moq.Signals.Signal<boolean>, effect: Moq.Signals.Effect) {
202209
const active = effect.get(this.active);
203210
const userMuted = effect.get(this.userMuted);
204-
muted.set(userMuted || !active);
211+
audioEnabled.set(active && !userMuted);
205212
}
206213

207214
#runStatus(effect: Moq.Signals.Effect) {

js/moq-boy/src/ui/components/StatsPanel.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,9 @@ export default function StatsPanel() {
5050
</div>
5151
</Show>
5252

53-
<div class="boy__stats-note">Emulation and encoding are paused when there are no viewers.</div>
53+
<div class="boy__stats-note">
54+
Emulation and encoding are paused when there are no viewers. Try muting or tabbing away!
55+
</div>
5456

5557
<Show when={latencyEntries().length > 0}>
5658
<div class="boy__latency-list">

0 commit comments

Comments
 (0)