@ericchen1990/pano-view 2.2.2 → 2.4.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.
package/README.md CHANGED
@@ -81,6 +81,7 @@ the stylesheet, the overlays still mount, but they render unstyled.
81
81
  - [`PanoVideo`](#panovideo) — 2:1 equirectangular video on the panorama sphere
82
82
  - [`Tile`](#tile) — krpano-style multires cube tiles
83
83
  - [`Scenes`](#scenes) — controlled multi-scene transitions
84
+ - [`BackgroundAudio`](#backgroundaudio) — one shared track, or a different track per scene
84
85
  - [`PanoFilter`](#panofilter) — color and artistic filters on the panorama source
85
86
 
86
87
  ### Controls
@@ -104,6 +105,7 @@ the stylesheet, the overlays still mount, but they render unstyled.
104
105
  - [`GraphicHotspot`](#graphichotspot) — built-in shapes or SVG at a position
105
106
  - [`SequenceHotspot`](#sequencehotspot) — sprite-sheet animation
106
107
  - [`VideoHotspot`](#videohotspot) — HTML video texture
108
+ - [`AudioHotspot`](#audiohotspot) — directional sound at a spherical position
107
109
  - [`TextHotspot`](#texthotspot) — rasterized plain text at a position
108
110
  - [`IframeHotspot`](#iframehotspot) — embedded document overlay at a position
109
111
  - [`PolygonHotspot`](#polygonhotspot) — closed spherical area
@@ -162,7 +164,242 @@ export function ControlledExample() {
162
164
  }
163
165
  ```
164
166
 
165
- The handle exposes `getView`, `setView`, `reset`, `startAutoRotate`, `stopAutoRotate`, and `toggleFullscreen`. Mouse, touch, and keyboard input are enabled by default — you do not need to render control components for ordinary viewing. Render `MouseControls`, `TouchControls`, or `KeyboardControls` only to override properties (the child replaces that channel's default instance). Tune shared behaviour through `controls` (`inertia`, `invert`, `bouncingLimits`, `fovSpeed`, `frictionStop`, `rotateDamping`, `zoomDamping`, and top-level `rotateSpeed` / `zoomSpeed`). Disable a channel with `controls.mouse` / `touch` / `keyboard` set to `false`, or pass an options object (including `enabled`) to override defaults without mounting a child. Auto-rotation is off until you render `AutoRotate`. The two auto-rotation handle methods remain supported for compatibility. A default context menu (Reset view / Enter fullscreen) is also mounted — see [`PanoContextMenu`](#panocontextmenu).
167
+ The handle exposes view, fullscreen, scene, VR, video, and background-audio controls so custom UIs can drive the viewer from a single ref:
168
+
169
+ **View** — `getView`, `setView`, `reset`.
170
+
171
+ **Fullscreen** — `enterFullscreen`, `exitFullscreen`, `toggleFullscreen`, `isFullscreen`.
172
+
173
+ **Scenes** (requires `<Scenes />`) — `setScene(id)`, `nextScene`, `previousScene`, `getActiveSceneId`, `getSceneIds`, `isSceneTransitioning`. `Scenes` now accepts an optional `defaultActiveSceneId` (uncontrolled) or keeps the existing `activeSceneId` (controlled). In controlled mode imperative calls invoke `onActiveSceneIdChange`.
174
+
175
+ **WebVR** (requires `<WebVR />`) — `enterVR`, `exitVR`, `toggleVR`, `isVRAvailable`, `isVREnabled`, `getVRMode`, `requestVRPermission`.
176
+
177
+ **Video** (requires `<PanoVideo />`) — `getVideo` returns the `PanoVideoController`, `subscribeVideo` subscribes to host changes, plus convenience aliases `playVideo`, `pauseVideo`, `toggleVideo`, `seekVideo`, `setVideoVolume`, `setVideoMuted`, `toggleVideoMuted`.
178
+
179
+ **Background Audio** (requires `<BackgroundAudio />`) — `getBackgroundAudio` returns the `BackgroundAudioController`, `subscribeBackgroundAudio` subscribes to host changes, plus convenience aliases `playBackgroundAudio`, `pauseBackgroundAudio`, `toggleBackgroundAudio`, `setBackgroundAudioVolume`, `setBackgroundAudioMuted`, `toggleBackgroundAudioMuted`. `BackgroundAudio` now accepts an optional `playing` prop (controlled) or uses `defaultPlaying` (uncontrolled).
180
+
181
+ Methods that depend on an unmounted child component (e.g. `enterVR` without `<WebVR />`) are safe no-ops — they return `false`, `void`, or `null`.
182
+
183
+ ### Fullscreen
184
+
185
+ ```tsx
186
+ import { useRef } from "react";
187
+ import { PanoViewer, Sphere, type PanoViewerHandle } from "@ericchen1990/pano-view";
188
+
189
+ function FullscreenExample() {
190
+ const ref = useRef<PanoViewerHandle>(null);
191
+
192
+ return (
193
+ <>
194
+ <button onClick={() => void ref.current?.enterFullscreen()}>Enter fullscreen</button>
195
+ <button onClick={() => void ref.current?.exitFullscreen()}>Exit fullscreen</button>
196
+ <button onClick={() => console.log(ref.current?.isFullscreen())}>Is fullscreen?</button>
197
+ <PanoViewer ref={ref} style={{ height: 560 }}>
198
+ <Sphere src="/panoramas/room.webp" previewUrl="preview.webp" />
199
+ </PanoViewer>
200
+ </>
201
+ );
202
+ }
203
+ ```
204
+
205
+ ### Imperative scene switching
206
+
207
+ Use `defaultActiveSceneId` for uncontrolled mode — imperative calls update the internal state directly. For controlled mode keep passing `activeSceneId`; imperative calls invoke `onActiveSceneIdChange` so the parent can update its state.
208
+
209
+ ```tsx
210
+ import { useRef } from "react";
211
+ import {
212
+ PanoViewer,
213
+ Scenes,
214
+ type PanoViewerHandle,
215
+ type Scene,
216
+ } from "@ericchen1990/pano-view";
217
+
218
+ const scenes: Scene[] = [
219
+ { id: "lobby", type: "sphere", src: "/lobby.webp", previewUrl: "/lobby-preview.webp" },
220
+ { id: "roof", type: "sphere", src: "/roof.webp", previewUrl: "/roof-preview.webp" },
221
+ ];
222
+
223
+ function SceneExample() {
224
+ const ref = useRef<PanoViewerHandle>(null);
225
+
226
+ return (
227
+ <>
228
+ <button onClick={() => ref.current?.setScene("roof")}>Go to roof</button>
229
+ <button onClick={() => ref.current?.nextScene()}>Next</button>
230
+ <button onClick={() => ref.current?.previousScene()}>Previous</button>
231
+ <p>Current: {ref.current?.getActiveSceneId()}</p>
232
+ <PanoViewer ref={ref} style={{ height: 560 }}>
233
+ <Scenes defaultActiveSceneId="lobby" scenes={scenes} transition="dissolve" />
234
+ </PanoViewer>
235
+ </>
236
+ );
237
+ }
238
+ ```
239
+
240
+ ### WebVR
241
+
242
+ `enterVR` and `requestVRPermission` must be called from a user gesture.
243
+
244
+ ```tsx
245
+ import { useRef } from "react";
246
+ import { PanoViewer, Sphere, WebVR, type PanoViewerHandle } from "@ericchen1990/pano-view";
247
+
248
+ function VRExample() {
249
+ const ref = useRef<PanoViewerHandle>(null);
250
+
251
+ return (
252
+ <>
253
+ <button
254
+ onClick={async () => {
255
+ await ref.current?.requestVRPermission();
256
+ await ref.current?.enterVR();
257
+ }}
258
+ >
259
+ Enter VR
260
+ </button>
261
+ <button onClick={() => void ref.current?.exitVR()}>Exit VR</button>
262
+ <button onClick={() => console.log(ref.current?.getVRMode())}>Mode?</button>
263
+ <PanoViewer ref={ref} style={{ height: 560 }}>
264
+ <WebVR />
265
+ <Sphere src="/panoramas/room.webp" previewUrl="preview.webp" />
266
+ </PanoViewer>
267
+ </>
268
+ );
269
+ }
270
+ ```
271
+
272
+ ### Custom video controls
273
+
274
+ Set `controls={false}` on `PanoVideo` and drive playback from the viewer ref. Use `subscribeVideo` with `useSyncExternalStore` for reactive snapshots.
275
+
276
+ ```tsx
277
+ import { useRef, useSyncExternalStore } from "react";
278
+ import {
279
+ PanoViewer,
280
+ PanoVideo,
281
+ type PanoViewerHandle,
282
+ } from "@ericchen1990/pano-view";
283
+
284
+ function VideoExample() {
285
+ const ref = useRef<PanoViewerHandle>(null);
286
+
287
+ const snapshot = useSyncExternalStore(
288
+ (cb) => ref.current?.subscribeVideo(cb) ?? (() => {}),
289
+ () => ref.current?.getVideo()?.getSnapshot() ?? null,
290
+ () => null,
291
+ );
292
+
293
+ return (
294
+ <>
295
+ <button onClick={() => void ref.current?.toggleVideo()}>
296
+ {snapshot?.playing ? "Pause" : "Play"}
297
+ </button>
298
+ <button onClick={() => ref.current?.toggleVideoMuted()}>
299
+ {snapshot?.muted ? "Unmute" : "Mute"}
300
+ </button>
301
+ {snapshot && (
302
+ <input
303
+ type="range"
304
+ min={0}
305
+ max={snapshot.duration}
306
+ value={snapshot.currentTime}
307
+ onChange={(e) => ref.current?.seekVideo(Number(e.target.value))}
308
+ />
309
+ )}
310
+ <PanoViewer ref={ref} style={{ height: 560 }}>
311
+ <PanoVideo controls={false} src="/tour.mp4" />
312
+ </PanoViewer>
313
+ </>
314
+ );
315
+ }
316
+ ```
317
+
318
+ ### Custom background audio controls
319
+
320
+ `BackgroundAudio` supports uncontrolled mode — omit `playing` and use `defaultPlaying` instead. Drive it from the viewer ref.
321
+
322
+ ```tsx
323
+ import { useRef, useSyncExternalStore } from "react";
324
+ import {
325
+ PanoViewer,
326
+ Sphere,
327
+ BackgroundAudio,
328
+ type PanoViewerHandle,
329
+ } from "@ericchen1990/pano-view";
330
+
331
+ function BGMExample() {
332
+ const ref = useRef<PanoViewerHandle>(null);
333
+
334
+ const snapshot = useSyncExternalStore(
335
+ (cb) => ref.current?.subscribeBackgroundAudio(cb) ?? (() => {}),
336
+ () => ref.current?.getBackgroundAudio()?.getSnapshot() ?? null,
337
+ () => null,
338
+ );
339
+
340
+ return (
341
+ <>
342
+ <button onClick={() => ref.current?.toggleBackgroundAudio()}>
343
+ {snapshot?.playing ? "Pause BGM" : "Play BGM"}
344
+ </button>
345
+ <button onClick={() => ref.current?.toggleBackgroundAudioMuted()}>
346
+ {snapshot?.muted ? "Unmute" : "Mute"}
347
+ </button>
348
+ <input
349
+ type="range"
350
+ min={0}
351
+ max={1}
352
+ step={0.01}
353
+ value={snapshot?.volume ?? 1}
354
+ onChange={(e) =>
355
+ ref.current?.setBackgroundAudioVolume(Number(e.target.value))
356
+ }
357
+ />
358
+ <PanoViewer ref={ref} style={{ height: 560 }}>
359
+ <BackgroundAudio defaultPlaying src="/bgm.mp3" />
360
+ <Sphere src="/panoramas/room.webp" previewUrl="preview.webp" />
361
+ </PanoViewer>
362
+ </>
363
+ );
364
+ }
365
+ ```
366
+
367
+ ### All-in-one example
368
+
369
+ ```tsx
370
+ import { useRef } from "react";
371
+ import {
372
+ PanoViewer,
373
+ Scenes,
374
+ WebVR,
375
+ PanoVideo,
376
+ BackgroundAudio,
377
+ type PanoViewerHandle,
378
+ } from "@ericchen1990/pano-view";
379
+
380
+ function TourPlayer() {
381
+ const ref = useRef<PanoViewerHandle>(null);
382
+
383
+ return (
384
+ <>
385
+ <nav>
386
+ <button onClick={() => ref.current?.previousScene()}>← Prev</button>
387
+ <button onClick={() => ref.current?.nextScene()}>Next →</button>
388
+ <button onClick={() => void ref.current?.toggleFullscreen()}>Fullscreen</button>
389
+ <button onClick={() => void ref.current?.enterVR()}>Enter VR</button>
390
+ <button onClick={() => ref.current?.toggleBackgroundAudio()}>BGM</button>
391
+ </nav>
392
+ <PanoViewer ref={ref} style={{ height: "100vh" }}>
393
+ <WebVR />
394
+ <BackgroundAudio defaultPlaying src="/bgm.mp3" />
395
+ <Scenes defaultActiveSceneId="lobby" scenes={scenes} transition="dissolve" />
396
+ </PanoViewer>
397
+ </>
398
+ );
399
+ }
400
+ ```
401
+
402
+ The two deprecated auto-rotation handle methods (`startAutoRotate`, `stopAutoRotate`) remain for compatibility. Mouse, touch, and keyboard input are enabled by default — you do not need to render control components for ordinary viewing. Render `MouseControls`, `TouchControls`, or `KeyboardControls` only to override properties (the child replaces that channel's default instance). Tune shared behaviour through `controls` (`inertia`, `invert`, `bouncingLimits`, `fovSpeed`, `frictionStop`, `rotateDamping`, `zoomDamping`, and top-level `rotateSpeed` / `zoomSpeed`). Disable a channel with `controls.mouse` / `touch` / `keyboard` set to `false`, or pass an options object (including `enabled`) to override defaults without mounting a child. Auto-rotation is off until you render `AutoRotate`. A default context menu (Reset view / Enter fullscreen) is also mounted — see [`PanoContextMenu`](#panocontextmenu).
166
403
 
167
404
  Drag and zoom update a target view that the camera follows smoothly. `rotateDamping` and `zoomDamping` control that following speed in seconds^-1 (defaults: `14` and `16` respectively); lower values feel softer, while `0` disables smoothing for that axis. Both values must be non-negative finite numbers. Imperative `setView()` and `reset()` remain immediate.
168
405
 
@@ -500,6 +737,71 @@ Auto-rotation is off by default. Render `AutoRotate` inside `PanoViewer` to enab
500
737
  </PanoViewer>
501
738
  ```
502
739
 
740
+ ## BackgroundAudio
741
+
742
+ `BackgroundAudio` plays background music or ambience for the viewer. It is not
743
+ a hotspot: there is no spherical position, no marker, and volume does not
744
+ follow the camera. Render it as a child of `PanoViewer`, **alongside** `Scenes`,
745
+ `Sphere`, or `Tile`. Do not put it in `Scenes`' `renderHotspots` — hotspots
746
+ unmount during scene transitions and would stop the audio.
747
+
748
+ `playing` is controlled by the host; the component does not change it. `loop`
749
+ defaults to `true`. Browsers may block autoplay; `onPlaybackStateChange` then
750
+ receives `"blocked"`, and the component retries `play()` after the next click
751
+ or keypress while `playing` remains true.
752
+
753
+ Pick one of the two patterns.
754
+
755
+ **One track for every scene** — pass `src` only; omit `sources`. Scene changes
756
+ do not restart the audio.
757
+
758
+ ```tsx
759
+ import { BackgroundAudio, PanoViewer, Scenes } from "@ericchen1990/pano-view";
760
+
761
+ <PanoViewer style={{ height: 560 }}>
762
+ <BackgroundAudio src="/bgm/tour.mp3" playing={isBgmPlaying} />
763
+ <Scenes scenes={scenes} activeSceneId={activeSceneId} />
764
+ </PanoViewer>;
765
+ ```
766
+
767
+ **A different track per scene** — pass `sources` (keys are `Scene.id`) and the
768
+ current `sceneId`. Omitting `sceneId` throws. When the file changes, playback
769
+ crossfades over `fadeMs` (default 400 ms; `0` is a hard cut). When two scenes
770
+ point at the same file, playback continues without restarting.
771
+
772
+ ```tsx
773
+ <PanoViewer style={{ height: 560 }}>
774
+ <BackgroundAudio
775
+ sources={{
776
+ lobby: "/bgm/lobby.mp3",
777
+ terrace: "/bgm/terrace.mp3",
778
+ }}
779
+ sceneId={activeSceneId}
780
+ playing={isBgmPlaying}
781
+ />
782
+ <Scenes scenes={scenes} activeSceneId={activeSceneId} />
783
+ </PanoViewer>;
784
+ ```
785
+
786
+ Optional: also pass `src` as the default for scenes missing from `sources`. To
787
+ silence a scene, set that id to `""` (this overrides `src`; it does not fall
788
+ back to the default track).
789
+
790
+ ```tsx
791
+ <BackgroundAudio
792
+ src="/bgm/default.mp3"
793
+ sources={{ lobby: "/bgm/courtyard.mp3", terrace: "" }}
794
+ sceneId={activeSceneId}
795
+ playing={isBgmPlaying}
796
+ />;
797
+ ```
798
+
799
+ In that example, `lobby` plays the courtyard clip, `terrace` is silent, and
800
+ every other scene plays `default.mp3`.
801
+
802
+ For a sound attached to a point in the panorama that pans with the view, use
803
+ `AudioHotspot`, not `BackgroundAudio`.
804
+
503
805
  ## Gyro
504
806
 
505
807
  `Gyro` is an opt-in device-orientation control and is disabled by default.
@@ -996,8 +1298,8 @@ color, border, corner radius, shadow, padding, and font size.
996
1298
 
997
1299
  For persistence or a host-owned editor, use the exported discriminated
998
1300
  `HotspotDefinition` union. Adding a variant is a TypeScript breaking change for
999
- exhaustive switches; handle `text` and `iframe` alongside the existing point
1000
- and path categories:
1301
+ exhaustive switches; handle `audio` alongside the existing point and path
1302
+ categories:
1001
1303
 
1002
1304
  ```ts
1003
1305
  import type { HotspotDefinition } from "@ericchen1990/pano-view";
@@ -1007,6 +1309,7 @@ const hotspots: HotspotDefinition[] = [
1007
1309
  { type: "graphic", id: "marker", position: { yaw: -18, pitch: 9 }, graphic: { kind: "ring" } },
1008
1310
  { type: "sequence", id: "pulse", position: { yaw: -42, pitch: -7 }, src: "/hotspots/pulse.png", frameCount: 20 },
1009
1311
  { type: "video", id: "clip", position: { yaw: 48, pitch: 6 }, src: "/hotspots/clip.webm" },
1312
+ { type: "audio", id: "fountain", position: { yaw: -30, pitch: -8 }, src: "/hotspots/fountain.mp3", range: 90 },
1010
1313
  { type: "text", id: "caption", position: { yaw: 0, pitch: -16 }, text: "Courtyard overlook" },
1011
1314
  { type: "iframe", id: "guide", position: { yaw: -62, pitch: 4 }, src: "/hotspots/embed.html" },
1012
1315
  { type: "polygon", id: "zone", vertices: [{ yaw: 12, pitch: 4 }, { yaw: 22, pitch: 4 }, { yaw: 18, pitch: 14 }] },
@@ -1281,6 +1584,55 @@ Video reports media and poster failures through `onError`. Like
1281
1584
  `SequenceHotspot`, it does not change `playing` by itself. This keeps source
1282
1585
  changes, unmounts, and React StrictMode lifecycles deterministic.
1283
1586
 
1587
+ ## AudioHotspot
1588
+
1589
+ `AudioHotspot` plays a directional sound at a spherical position. Stereo
1590
+ panning follows the camera look direction; `range` is the look-away angle in
1591
+ degrees at which volume reaches silence. The default `360` disables look
1592
+ attenuation (stereo pan only). Use a smaller `range` such as `90` for a
1593
+ spot source.
1594
+
1595
+ Playback uses Web Audio via Howler.js (bundled; hosts do not install
1596
+ `howler`). The file must decode before play starts — MP3 is the recommended
1597
+ format. `src` may be a string or an array of fallback URLs. Do not use HTML5
1598
+ streaming (`html5`); it drops stereo panning.
1599
+
1600
+ `playing` is controlled like `VideoHotspot`. `loop` defaults to `false`,
1601
+ `muted` to `false`, `volume` to `1`, and `pauseWhenHidden` to `true` (pause
1602
+ when the tab is hidden, resume if `playing` is still true). A first user
1603
+ gesture unlocks audio; if autoplay is blocked, the hotspot reports
1604
+ `"blocked"` and retries `play()` after the next pointer or key event while
1605
+ `playing` remains true.
1606
+
1607
+ The default marker switches between a built-in **stopped** icon (speaker)
1608
+ and a **playing** icon (speaker plus sound waves). Pass `icon`
1609
+ for a custom stopped image and `playingIcon` for a custom playing image.
1610
+ If only `icon` is set, it is used for both states. `marker={false}` hides
1611
+ the visual for an invisible positional source. For a
1612
+ non-spatial tour or per-scene soundtrack, use [`BackgroundAudio`](#backgroundaudio).
1613
+
1614
+ ```tsx
1615
+ import { AudioHotspot } from "@ericchen1990/pano-view";
1616
+
1617
+ <AudioHotspot
1618
+ id="fountain"
1619
+ ariaLabel="Fountain"
1620
+ position={{ yaw: -30, pitch: -8 }}
1621
+ src="/hotspots/fountain.mp3"
1622
+ playing={isFountainPlaying}
1623
+ loop
1624
+ volume={0.8}
1625
+ range={90}
1626
+ onClick={() => setFountainPlaying((playing) => !playing)}
1627
+ onEnded={() => setFountainPlaying(false)}
1628
+ onPlaybackStateChange={(state) => console.log(state)}
1629
+ onPlaybackError={(error) => console.error(error)}
1630
+ />;
1631
+ ```
1632
+
1633
+ Like the other media hotspots, `AudioHotspot` does not change `playing` by
1634
+ itself.
1635
+
1284
1636
  ## TextHotspot
1285
1637
 
1286
1638
  `TextHotspot` rasterizes plain text onto a canvas texture. It does not accept