otherplane 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/CLAUDE.md +130 -0
  2. package/LICENSE +21 -0
  3. package/README.md +146 -0
  4. package/bin/otherplane.mjs +489 -0
  5. package/engine/eslint.config.mjs +25 -0
  6. package/engine/next.config.ts +43 -0
  7. package/engine/package-lock.json +6848 -0
  8. package/engine/package.json +36 -0
  9. package/engine/postcss.config.mjs +5 -0
  10. package/engine/src/app/LandingRedirect.tsx +15 -0
  11. package/engine/src/app/[room]/RoomViewer.tsx +413 -0
  12. package/engine/src/app/[room]/page.tsx +30 -0
  13. package/engine/src/app/favicon.ico +0 -0
  14. package/engine/src/app/layout.tsx +45 -0
  15. package/engine/src/app/page.tsx +11 -0
  16. package/engine/src/app/providers.tsx +22 -0
  17. package/engine/src/components/controls/MobileHud.tsx +25 -0
  18. package/engine/src/components/controls/PlayerController.tsx +170 -0
  19. package/engine/src/components/controls/TouchLookController.tsx +93 -0
  20. package/engine/src/components/controls/VirtualStick.tsx +153 -0
  21. package/engine/src/components/edit/EditCapture.tsx +182 -0
  22. package/engine/src/components/edit/EditorPanel.tsx +265 -0
  23. package/engine/src/components/edit/Markers.tsx +91 -0
  24. package/engine/src/components/hud/Button.tsx +228 -0
  25. package/engine/src/components/hud/ClickToPlay.tsx +13 -0
  26. package/engine/src/components/hud/ContentOverlay.tsx +44 -0
  27. package/engine/src/components/hud/NavHeader.module.css +24 -0
  28. package/engine/src/components/scene/Artifacts.tsx +85 -0
  29. package/engine/src/components/scene/Exits.tsx +92 -0
  30. package/engine/src/components/scene/PointerLockBridge.tsx +28 -0
  31. package/engine/src/components/scene/WorldScene.tsx +164 -0
  32. package/engine/src/components/spark/SparkLayer.tsx +112 -0
  33. package/engine/src/components/spark/SplatWorld.tsx +156 -0
  34. package/engine/src/config/audio.ts +11 -0
  35. package/engine/src/data/editApi.ts +73 -0
  36. package/engine/src/data/presets.ts +34 -0
  37. package/engine/src/data/room.ts +100 -0
  38. package/engine/src/data/site.ts +50 -0
  39. package/engine/src/data/universeconfig.ts +19 -0
  40. package/engine/src/icons/ArrowLeft.tsx +20 -0
  41. package/engine/src/icons/ChevronDown.tsx +23 -0
  42. package/engine/src/icons/ChevronLeft.tsx +22 -0
  43. package/engine/src/icons/Home.tsx +22 -0
  44. package/engine/src/icons/Spinner.module.css +13 -0
  45. package/engine/src/icons/Spinner.tsx +28 -0
  46. package/engine/src/icons/VolumeMax.tsx +21 -0
  47. package/engine/src/icons/VolumeX.tsx +22 -0
  48. package/engine/src/icons/icons.interface.ts +7 -0
  49. package/engine/src/icons/index.ts +27 -0
  50. package/engine/src/physics/RapierProvider.tsx +302 -0
  51. package/engine/src/physics/index.ts +2 -0
  52. package/engine/src/physics/types.ts +9 -0
  53. package/engine/src/providers/audio.tsx +215 -0
  54. package/engine/src/providers/edit.tsx +357 -0
  55. package/engine/src/providers/pointerLock.tsx +88 -0
  56. package/engine/src/styles/globals.css +88 -0
  57. package/engine/tailwind.config.js +184 -0
  58. package/engine/tsconfig.json +27 -0
  59. package/otherplane.config.example.json +6 -0
  60. package/package.json +56 -0
  61. package/schema/room.schema.json +77 -0
  62. package/scripts/gen_world.py +147 -0
  63. package/skill.md +94 -0
@@ -0,0 +1,302 @@
1
+ 'use client';
2
+
3
+ import React, { createContext, useContext, useEffect, useMemo, useRef, useState } from 'react';
4
+ import * as RAPIER from '@dimforge/rapier3d-compat';
5
+ import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
6
+ import * as THREE from 'three';
7
+ import { CONFIG as UNIVERSE_CONFIG } from '@/data/universeconfig'
8
+ import type { WorldDef } from '@/data/presets';
9
+
10
+ type RapierCtx = {
11
+ rapier: typeof RAPIER | null;
12
+ world: RAPIER.World | null;
13
+ playerBody: RAPIER.RigidBody | null;
14
+ };
15
+ const Ctx = createContext<RapierCtx>({ rapier: null, world: null, playerBody: null });
16
+
17
+ const FIXED_DT = 1 / 60;
18
+ const MAX_STEPS = 5;
19
+
20
+ // Find a clear, stand-able floor spot near the room's center, so we don't drop
21
+ // the player inside a wall or a bookshelf. For a grid of candidate (x,z) points
22
+ // (center first), cast a ray straight down from mid-height (inside the room,
23
+ // below any ceiling) to find the floor, then cast up to require player-height
24
+ // headroom. Returns the valid spot nearest the center, or null if none.
25
+ function findValidSpawn(
26
+ world: RAPIER.World,
27
+ rapier: typeof RAPIER,
28
+ aabb: THREE.Box3,
29
+ RADIUS: number,
30
+ HALF_HEIGHT: number,
31
+ excludeBody: RAPIER.RigidBody | null,
32
+ ): { x: number; y: number; z: number } | null {
33
+ const cx = (aabb.min.x + aabb.max.x) / 2;
34
+ const cz = (aabb.min.z + aabb.max.z) / 2;
35
+ const midY = (aabb.min.y + aabb.max.y) / 2;
36
+ const stand = HALF_HEIGHT + RADIUS; // body-center height above feet
37
+ const playerH = 2 * HALF_HEIGHT + 2 * RADIUS; // approx full capsule height
38
+ const downMax = midY - aabb.min.y + 1;
39
+ const exclude = excludeBody ?? undefined;
40
+
41
+ const cand: Array<[number, number]> = [[cx, cz]];
42
+ const N = 5;
43
+ for (let i = 0; i < N; i++) {
44
+ for (let j = 0; j < N; j++) {
45
+ const fx = (i + 0.5) / N;
46
+ const fz = (j + 0.5) / N;
47
+ cand.push([
48
+ aabb.min.x + fx * (aabb.max.x - aabb.min.x),
49
+ aabb.min.z + fz * (aabb.max.z - aabb.min.z),
50
+ ]);
51
+ }
52
+ }
53
+
54
+ let best: { x: number; y: number; z: number } | null = null;
55
+ let bestD = Infinity;
56
+ for (const [x, z] of cand) {
57
+ const down = new rapier.Ray({ x, y: midY, z }, { x: 0, y: -1, z: 0 });
58
+ const dh = world.castRay(down, downMax, true, undefined, undefined, undefined, exclude);
59
+ if (!dh || dh.toi <= 0.001) continue; // no floor below, or we're in a solid
60
+ const floorY = midY - dh.toi;
61
+ const up = new rapier.Ray({ x, y: floorY + 0.1, z }, { x: 0, y: 1, z: 0 });
62
+ const uh = world.castRay(up, playerH, true, undefined, undefined, undefined, exclude);
63
+ if (uh && uh.toi < playerH) continue; // shelf / low ceiling above — not stand-able
64
+ const d = (x - cx) ** 2 + (z - cz) ** 2;
65
+ if (d < bestD) {
66
+ bestD = d;
67
+ best = { x, y: floorY + stand + 0.05, z };
68
+ }
69
+ }
70
+ return best;
71
+ }
72
+
73
+ export function RapierProvider({
74
+ gravity = UNIVERSE_CONFIG.GRAVITY,
75
+ world: worldDef,
76
+ spawn,
77
+ children,
78
+ }: {
79
+ gravity?: { x: number; y: number; z: number };
80
+ world?: WorldDef;
81
+ /** Entryway spawn (body-center). When set it overrides the floor search. */
82
+ spawn?: { x: number; y: number; z: number } | null;
83
+ children: React.ReactNode;
84
+ }) {
85
+ const [rapierReady, setRapierReady] = useState(false);
86
+ const worldRef = useRef<RAPIER.World | null>(null);
87
+ const rapierRef = useRef<typeof RAPIER | null>(null);
88
+ const accRef = useRef(0);
89
+ const prevRef = useRef<number>(performance.now());
90
+ const envBodyRef = useRef<RAPIER.RigidBody | null>(null);
91
+ const envCollidersRef = useRef<RAPIER.Collider[]>([]);
92
+ const playerBodyRef = useRef<RAPIER.RigidBody | null>(null);
93
+ const [playerBodyState, setPlayerBodyState] = useState<RAPIER.RigidBody | null>(null);
94
+ // spawn the player actually teleports to: the entryway override when provided,
95
+ // else a best-effort floor search from the room collider.
96
+ const [resolvedSpawn, setResolvedSpawn] = useState<{ x: number; y: number; z: number } | null>(null);
97
+ // latest entryway override, read inside the async collider-load callback
98
+ const spawnPropRef = useRef(spawn);
99
+ spawnPropRef.current = spawn;
100
+
101
+ // init once
102
+ useEffect(() => {
103
+ let cancelled = false;
104
+ (async () => {
105
+ await RAPIER.init();
106
+ if (cancelled) return;
107
+ rapierRef.current = RAPIER;
108
+ worldRef.current = new RAPIER.World(gravity);
109
+ setRapierReady(true);
110
+ })();
111
+ return () => {
112
+ cancelled = true;
113
+ // dispose world on unmount
114
+ if (worldRef.current) {
115
+ worldRef.current.free();
116
+ worldRef.current = null;
117
+ }
118
+ };
119
+ }, [gravity.x, gravity.y, gravity.z]);
120
+
121
+ // fixed-step stepping loop (piggybacks on r3f’s render loop if present)
122
+ useEffect(() => {
123
+ if (!rapierReady) return;
124
+ let mounted = true;
125
+ const loop = (t: number) => {
126
+ if (!mounted) return;
127
+ const world = worldRef.current;
128
+ if (world) {
129
+ const dt = Math.min((t - prevRef.current) / 1000, 0.1);
130
+ prevRef.current = t;
131
+ accRef.current += dt;
132
+ let steps = 0;
133
+ while (accRef.current >= FIXED_DT && steps < MAX_STEPS) {
134
+ world.step();
135
+ accRef.current -= FIXED_DT;
136
+ steps++;
137
+ }
138
+ }
139
+ requestAnimationFrame(loop);
140
+ };
141
+ prevRef.current = performance.now();
142
+ requestAnimationFrame(loop);
143
+ return () => { mounted = false; };
144
+ }, [rapierReady]);
145
+
146
+ // Build the environment collider for the CURRENT room from its Marble collider
147
+ // GLB, baking the same position/quaternion/scale the splat uses (see SplatWorld)
148
+ // into the trimesh so walls/floor line up with what you see.
149
+ const colliderUrl = worldDef?.colliderUrl;
150
+ const isRoomCollider = !!colliderUrl;
151
+ const posKey = (worldDef?.position ?? [0, 0, 0]).join(',');
152
+ const quatKey = (worldDef?.quaternion ?? [0, 0, 0, 1]).join(',');
153
+ const scaleKey = worldDef?.scale ?? 1;
154
+
155
+ useEffect(() => {
156
+ if (!rapierReady || !colliderUrl) return;
157
+ const world = worldRef.current;
158
+ const rapier = rapierRef.current;
159
+ if (!world || !rapier) return;
160
+ let disposed = false;
161
+
162
+ // transform matching the splat's
163
+ const M = new THREE.Matrix4();
164
+ const p = worldDef!.position ?? [0, 0, 0];
165
+ const q = worldDef!.quaternion ?? [0, 0, 0, 1];
166
+ const s = worldDef!.scale ?? 1;
167
+ M.compose(
168
+ new THREE.Vector3(p[0], p[1], p[2]),
169
+ new THREE.Quaternion(q[0], q[1], q[2], q[3]),
170
+ new THREE.Vector3(s, s, s),
171
+ );
172
+
173
+ const loader = new GLTFLoader();
174
+ loader.load(colliderUrl, (gltf) => {
175
+ if (disposed) return;
176
+ const body = world.createRigidBody(rapier.RigidBodyDesc.fixed());
177
+ envBodyRef.current = body;
178
+ const created: RAPIER.Collider[] = [];
179
+ const aabb = new THREE.Box3();
180
+ gltf.scene.updateMatrixWorld(true);
181
+ gltf.scene.traverse((child: THREE.Object3D) => {
182
+ const mesh = child as THREE.Mesh;
183
+ if (!mesh.isMesh || !mesh.geometry) return;
184
+ mesh.updateWorldMatrix(true, false);
185
+ const geom = mesh.geometry.clone();
186
+ geom.applyMatrix4(new THREE.Matrix4().multiplyMatrices(M, mesh.matrixWorld));
187
+ const posAttr = geom.getAttribute('position') as THREE.BufferAttribute | null;
188
+ if (!posAttr) return;
189
+ geom.computeBoundingBox();
190
+ if (geom.boundingBox) aabb.union(geom.boundingBox);
191
+ const vertices = new Float32Array(posAttr.array);
192
+ let indices: Uint32Array;
193
+ if (geom.index) indices = new Uint32Array(geom.index.array as ArrayLike<number>);
194
+ else {
195
+ indices = new Uint32Array(posAttr.count);
196
+ for (let i = 0; i < posAttr.count; i++) indices[i] = i;
197
+ }
198
+ const colDesc = rapier
199
+ .ColliderDesc.trimesh(vertices, indices)
200
+ .setRestitution(UNIVERSE_CONFIG.ENVIRONMENT_RESTITUTION);
201
+ created.push(world.createCollider(colDesc, body));
202
+ });
203
+ envCollidersRef.current = created;
204
+
205
+ const override = spawnPropRef.current;
206
+ if (override) {
207
+ // The room declares an entryway — trust it (this is the marked, known-good
208
+ // spot, and what fixes feet-below-floor spawns).
209
+ setResolvedSpawn(override);
210
+ } else if (isRoomCollider && !aabb.isEmpty()) {
211
+ // No entryway yet (e.g. a freshly generated, unmarked room): best-effort.
212
+ const { RADIUS, HALF_HEIGHT } = UNIVERSE_CONFIG.PLAYER;
213
+ const found = findValidSpawn(world, rapier, aabb, RADIUS, HALF_HEIGHT, playerBodyRef.current);
214
+ setResolvedSpawn(
215
+ found ?? {
216
+ x: (aabb.min.x + aabb.max.x) / 2,
217
+ y: aabb.min.y + HALF_HEIGHT + RADIUS + 0.4,
218
+ z: (aabb.min.z + aabb.max.z) / 2,
219
+ },
220
+ );
221
+ } else {
222
+ setResolvedSpawn(null);
223
+ }
224
+ console.log(`✓ Environment collider loaded (${created.length} mesh parts) from ${colliderUrl}`);
225
+ });
226
+
227
+ return () => {
228
+ disposed = true;
229
+ const world = worldRef.current;
230
+ if (!world) return;
231
+ for (const c of envCollidersRef.current) world.removeCollider(c, true);
232
+ envCollidersRef.current = [];
233
+ if (envBodyRef.current) {
234
+ world.removeRigidBody(envBodyRef.current);
235
+ envBodyRef.current = null;
236
+ }
237
+ };
238
+ }, [rapierReady, colliderUrl, isRoomCollider, posKey, quatKey, scaleKey, worldDef]);
239
+
240
+ // Create player rigid body and capsule collider
241
+ useEffect(() => {
242
+ if (!rapierReady) return;
243
+ const world = worldRef.current;
244
+ const rapier = rapierRef.current;
245
+ if (!world || !rapier) return;
246
+ // Remove existing if any
247
+ if (playerBodyRef.current) {
248
+ world.removeRigidBody(playerBodyRef.current);
249
+ playerBodyRef.current = null;
250
+ setPlayerBodyState(null);
251
+ }
252
+ const { RADIUS, HALF_HEIGHT, START, FRICTION, RESTI, LINEAR_DAMPING } = UNIVERSE_CONFIG.PLAYER;
253
+ const bodyDesc = rapier
254
+ .RigidBodyDesc.dynamic()
255
+ .setTranslation(START[0], START[1], START[2])
256
+ .lockRotations()
257
+ .setLinearDamping(LINEAR_DAMPING)
258
+ .setCcdEnabled(true);
259
+ const body = world.createRigidBody(bodyDesc);
260
+ const colDesc = rapier
261
+ .ColliderDesc.capsule(HALF_HEIGHT, RADIUS)
262
+ .setFriction(FRICTION)
263
+ .setRestitution(RESTI);
264
+ world.createCollider(colDesc, body);
265
+ playerBodyRef.current = body;
266
+ setPlayerBodyState(body);
267
+ return () => {
268
+ const w = worldRef.current;
269
+ if (w && playerBodyRef.current) {
270
+ w.removeRigidBody(playerBodyRef.current);
271
+ playerBodyRef.current = null;
272
+ setPlayerBodyState(null);
273
+ }
274
+ };
275
+ }, [rapierReady]);
276
+
277
+ // Keep the resolved spawn in sync if the entryway override changes after the
278
+ // collider has loaded (e.g. arriving through a different entryway).
279
+ useEffect(() => {
280
+ if (spawn) setResolvedSpawn(spawn);
281
+ }, [spawn?.x, spawn?.y, spawn?.z]);
282
+
283
+ // Teleport the player to the resolved spawn once both exist.
284
+ useEffect(() => {
285
+ if (!playerBodyState || !resolvedSpawn) return;
286
+ playerBodyState.setTranslation(resolvedSpawn, true);
287
+ playerBodyState.setLinvel({ x: 0, y: 0, z: 0 }, true);
288
+ }, [playerBodyState, resolvedSpawn]);
289
+
290
+ const value = useMemo<RapierCtx>(
291
+ () => ({ rapier: rapierRef.current, world: worldRef.current, playerBody: playerBodyState }),
292
+ [rapierReady, playerBodyState]
293
+ );
294
+
295
+ return <Ctx.Provider value={value}>{children}</Ctx.Provider>;
296
+ }
297
+
298
+ export function useRapierWorld() {
299
+ const ctx = useContext(Ctx);
300
+ if (!ctx.world || !ctx.rapier) throw new Error('Rapier not ready yet.');
301
+ return ctx as Required<RapierCtx>;
302
+ }
@@ -0,0 +1,2 @@
1
+ export * from './types';
2
+ export * from './RapierProvider';
@@ -0,0 +1,9 @@
1
+ import * as RAPIER from '@dimforge/rapier3d-compat';
2
+
3
+ export type RapierRigidBody = RAPIER.RigidBody;
4
+ export type RapierCollider = RAPIER.Collider;
5
+ export type RapierWorld = RAPIER.World;
6
+ export type RapierRigidBodyDesc = RAPIER.RigidBodyDesc;
7
+ export type RapierColliderDesc = RAPIER.ColliderDesc;
8
+ export type RapierShape = RAPIER.Shape;
9
+ export type RapierShapeType = RAPIER.ShapeType;
@@ -0,0 +1,215 @@
1
+ // providers/audio.tsx
2
+ 'use client';
3
+
4
+ import React, { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
5
+ import { AUDIO_CONFIG } from '@/config/audio';
6
+
7
+ type AudioAPI = {
8
+ /** true if we have an AudioContext */
9
+ ready: boolean;
10
+ /** Must be called in a user gesture (Click-to-Play). Safe to call multiple times. */
11
+ init: () => Promise<void>;
12
+ muted: boolean;
13
+ setMuted: (m: boolean) => void;
14
+
15
+ /** Set/replace the looping background music for the app (null = stop). Safe to call anytime. */
16
+ setMusic: (url: string | null, opts?: { fadeMs?: number; loop?: boolean }) => Promise<void>;
17
+ /** Stop background music (with an optional quick fade). */
18
+ stop: (fadeMs?: number) => void;
19
+
20
+ isLoading: boolean;
21
+ currentUrl: string | null;
22
+ };
23
+
24
+ const AudioCtx = createContext<AudioAPI | null>(null);
25
+ const DEFAULT_VOL = AUDIO_CONFIG?.MUSIC_VOLUME ?? 0.15;
26
+
27
+ export function AudioProvider({ children }: { children: React.ReactNode }) {
28
+ // Core nodes
29
+ const ctxRef = useRef<AudioContext | null>(null);
30
+ const masterGainRef = useRef<GainNode | null>(null);
31
+
32
+ // Current music chain
33
+ const musicSrcRef = useRef<AudioBufferSourceNode | null>(null);
34
+ const musicGainRef = useRef<GainNode | null>(null);
35
+
36
+ // State
37
+ const [muted, setMutedState] = useState(false);
38
+ const [isLoading, setIsLoading] = useState(false);
39
+ const [currentUrl, setCurrentUrl] = useState<string | null>(null);
40
+
41
+ // Support calling setMusic() before init()
42
+ const wantedUrlRef = useRef<string | null>(null);
43
+
44
+ // Cancel/ignore stale async decodes
45
+ const requestIdRef = useRef(0);
46
+
47
+ // Buffer cache
48
+ const cacheRef = useRef(new Map<string, AudioBuffer>());
49
+
50
+ const ready = !!ctxRef.current;
51
+
52
+ const ensureNodes = useCallback(async () => {
53
+ if (ctxRef.current) {
54
+ if (ctxRef.current.state === 'suspended') await ctxRef.current.resume();
55
+ return;
56
+ }
57
+
58
+ const AC = (window as typeof window & { AudioContext?: typeof AudioContext; webkitAudioContext?: typeof AudioContext }).AudioContext ||
59
+ (window as typeof window & { AudioContext?: typeof AudioContext; webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
60
+ const ac: AudioContext = new AC();
61
+
62
+ const master = ac.createGain();
63
+ master.gain.setValueAtTime(muted ? 0 : 1, ac.currentTime);
64
+ master.connect(ac.destination);
65
+
66
+ ctxRef.current = ac;
67
+ masterGainRef.current = master;
68
+
69
+ // If there was a queued music URL set before init(), apply it now
70
+ if (wantedUrlRef.current) {
71
+ // fire and forget (no await)
72
+ _switchTo(wantedUrlRef.current).catch(() => {});
73
+ }
74
+ }, [muted]);
75
+
76
+ const init = useCallback(async () => {
77
+ await ensureNodes();
78
+ }, [ensureNodes]);
79
+
80
+ const setMuted = useCallback((m: boolean) => {
81
+ setMutedState(m);
82
+ const ac = ctxRef.current;
83
+ const master = masterGainRef.current;
84
+ if (ac && master) {
85
+ const now = ac.currentTime;
86
+ master.gain.cancelScheduledValues(now);
87
+ master.gain.linearRampToValueAtTime(m ? 0 : 1, now + 0.05);
88
+ }
89
+ }, []);
90
+
91
+ const loadBuffer = useCallback(async (url: string) => {
92
+ const cached = cacheRef.current.get(url);
93
+ if (cached) return cached;
94
+ const ac = ctxRef.current;
95
+ if (!ac) throw new Error('Audio not initialized');
96
+ const res = await fetch(url);
97
+ const arr = await res.arrayBuffer();
98
+ const buf = await ac.decodeAudioData(arr);
99
+ cacheRef.current.set(url, buf);
100
+ return buf;
101
+ }, []);
102
+
103
+ const stop = useCallback((fadeMs: number = 100) => {
104
+ const ac = ctxRef.current;
105
+ if (!ac) return;
106
+ const src = musicSrcRef.current;
107
+ const gain = musicGainRef.current;
108
+ if (!src || !gain) return;
109
+
110
+ const t = ac.currentTime;
111
+ const fade = Math.max(0, fadeMs) / 1000;
112
+
113
+ gain.gain.cancelScheduledValues(t);
114
+ gain.gain.setValueAtTime(gain.gain.value, t);
115
+ gain.gain.linearRampToValueAtTime(0, t + fade);
116
+
117
+ // Slight delay before stopping to avoid clicks
118
+ try { src.stop(t + fade + 0.01); } catch {}
119
+
120
+ musicSrcRef.current = null;
121
+ musicGainRef.current = null;
122
+ setCurrentUrl(null);
123
+ }, []);
124
+
125
+ // Internal: switch to a new URL (atomically)
126
+ const _switchTo = useCallback(async (url: string, opts?: { fadeMs?: number; loop?: boolean }) => {
127
+ const ac = ctxRef.current;
128
+ if (!ac) {
129
+ // queue until init()
130
+ wantedUrlRef.current = url;
131
+ return;
132
+ }
133
+
134
+ const myId = ++requestIdRef.current;
135
+ setIsLoading(true);
136
+
137
+ let buffer: AudioBuffer | null = null;
138
+ try {
139
+ buffer = await loadBuffer(url);
140
+ } catch (e) {
141
+ if (myId !== requestIdRef.current) return; // superseded
142
+ setIsLoading(false);
143
+ console.error('Audio load failed:', e);
144
+ return;
145
+ }
146
+ if (myId !== requestIdRef.current) return; // superseded while decoding
147
+
148
+ // Stop any current track (short fade)
149
+ if (musicSrcRef.current) stop(opts?.fadeMs ?? 120);
150
+
151
+ // Build new chain: source -> musicGain -> masterGain -> destination
152
+ const src = ac.createBufferSource();
153
+ src.buffer = buffer!;
154
+ src.loop = opts?.loop ?? true;
155
+
156
+ const mg = ac.createGain();
157
+ mg.gain.setValueAtTime(0, ac.currentTime); // fade in
158
+ src.connect(mg);
159
+ mg.connect(masterGainRef.current!);
160
+
161
+ src.start();
162
+
163
+ musicSrcRef.current = src;
164
+ musicGainRef.current = mg;
165
+ setCurrentUrl(url);
166
+
167
+ // fade in to content volume (master handles mute)
168
+ const t = ac.currentTime;
169
+ mg.gain.linearRampToValueAtTime(DEFAULT_VOL, t + (opts?.fadeMs ?? 120) / 1000);
170
+
171
+ setIsLoading(false);
172
+ }, [loadBuffer, stop]);
173
+
174
+ const setMusic = useCallback(async (url: string | null, opts?: { fadeMs?: number; loop?: boolean }) => {
175
+ wantedUrlRef.current = url;
176
+ if (!url) {
177
+ stop(opts?.fadeMs ?? 120);
178
+ return;
179
+ }
180
+ // If not ready yet, we just record wantedUrl; _switchTo runs after init()
181
+ if (!ctxRef.current) return;
182
+ await _switchTo(url, opts);
183
+ }, [_switchTo, stop]);
184
+
185
+ // Resume audio when tab becomes visible (iOS/Safari behavior)
186
+ useEffect(() => {
187
+ const onVis = () => {
188
+ const ac = ctxRef.current;
189
+ if (document.visibilityState === 'visible' && ac && ac.state !== 'running') {
190
+ ac.resume().catch(() => {});
191
+ }
192
+ };
193
+ document.addEventListener('visibilitychange', onVis);
194
+ return () => document.removeEventListener('visibilitychange', onVis);
195
+ }, []);
196
+
197
+ const api = useMemo<AudioAPI>(() => ({
198
+ ready,
199
+ init,
200
+ muted,
201
+ setMuted,
202
+ setMusic,
203
+ stop,
204
+ isLoading,
205
+ currentUrl,
206
+ }), [ready, init, muted, setMuted, setMusic, stop, isLoading, currentUrl]);
207
+
208
+ return <AudioCtx.Provider value={api}>{children}</AudioCtx.Provider>;
209
+ }
210
+
211
+ export function useAudio(): AudioAPI {
212
+ const ctx = useContext(AudioCtx);
213
+ if (!ctx) throw new Error('useAudio must be used within AudioProvider');
214
+ return ctx;
215
+ }