@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.zh-CN.md CHANGED
@@ -64,6 +64,7 @@ import "@ericchen1990/pano-view/styles.css";
64
64
  - [`PanoVideo`](#panovideo) — 全景球面上的 2:1 等距圆柱视频
65
65
  - [`Tile`](#tile) — krpano 风格多分辨率立方体 tile
66
66
  - [`Scenes`](#scenes) — 受控多场景过渡
67
+ - [`BackgroundAudio`](#backgroundaudio) — 全部场景共用、或按场景切换的背景声
67
68
  - [`PanoFilter`](#panofilter) — 对全景源应用色彩与艺术滤镜
68
69
 
69
70
  ### 控件
@@ -87,6 +88,7 @@ import "@ericchen1990/pano-view/styles.css";
87
88
  - [`GraphicHotspot`](#graphichotspot) — 内置形状或 SVG
88
89
  - [`SequenceHotspot`](#sequencehotspot) — 精灵图动画
89
90
  - [`VideoHotspot`](#videohotspot) — HTML 视频纹理
91
+ - [`AudioHotspot`](#audiohotspot) — 球面位置上的定向声源
90
92
  - [`TextHotspot`](#texthotspot) — 球面位置上的栅格化纯文本
91
93
  - [`IframeHotspot`](#iframehotspot) — 球面位置上的嵌入文档叠加层
92
94
  - [`PolygonHotspot`](#polygonhotspot) — 闭合球面区域
@@ -145,7 +147,242 @@ export function ControlledExample() {
145
147
  }
146
148
  ```
147
149
 
148
- 句柄暴露 `getView`、`setView`、`reset`、`startAutoRotate`、`stopAutoRotate` 与 `toggleFullscreen`。鼠标、触控与键盘输入默认启用 — 普通浏览无需再渲染控件组件。仅当需要覆盖属性时渲染 `MouseControls`、`TouchControls` 或 `KeyboardControls`(子组件会替换该通道的默认实例)。通过 `controls` 调节共享行为(`inertia`、`invert`、`bouncingLimits`、`fovSpeed`、`frictionStop`、`rotateDamping`、`zoomDamping`,以及顶层的 `rotateSpeed` / `zoomSpeed`)。将 `controls.mouse` / `touch` / `keyboard` 设为 `false` 可关闭某通道,或传入选项对象(含 `enabled`)在不挂载子组件的情况下覆盖默认值。自动旋转在渲染 `AutoRotate` 之前处于关闭状态。两个自动旋转句柄方法仍保留以兼容旧用法。默认右键菜单(重置视图 / 进入全屏)也会挂载 — 见 [`PanoContextMenu`](#panocontextmenu)。
150
+ 句柄从单个 `ref` 暴露视图、全屏、场景、VR、视频与背景音乐控制,方便宿主自绘 UI:
151
+
152
+ **视图** — `getView`、`setView`、`reset`。
153
+
154
+ **全屏** — `enterFullscreen`、`exitFullscreen`、`toggleFullscreen`、`isFullscreen`。
155
+
156
+ **场景**(需 `<Scenes />`)— `setScene(id)`、`nextScene`、`previousScene`、`getActiveSceneId`、`getSceneIds`、`isSceneTransitioning`。`Scenes` 现支持可选 `defaultActiveSceneId`(非受控)或继续传 `activeSceneId`(受控)。受控模式下命令式调用会触发 `onActiveSceneIdChange`。
157
+
158
+ **WebVR**(需 `<WebVR />`)— `enterVR`、`exitVR`、`toggleVR`、`isVRAvailable`、`isVREnabled`、`getVRMode`、`requestVRPermission`。
159
+
160
+ **视频**(需 `<PanoVideo />`)— `getVideo` 返回 `PanoVideoController`,`subscribeVideo` 订阅宿主变更,另有快捷方法 `playVideo`、`pauseVideo`、`toggleVideo`、`seekVideo`、`setVideoVolume`、`setVideoMuted`、`toggleVideoMuted`。
161
+
162
+ **背景音乐**(需 `<BackgroundAudio />`)— `getBackgroundAudio` 返回 `BackgroundAudioController`,`subscribeBackgroundAudio` 订阅宿主变更,另有快捷方法 `playBackgroundAudio`、`pauseBackgroundAudio`、`toggleBackgroundAudio`、`setBackgroundAudioVolume`、`setBackgroundAudioMuted`、`toggleBackgroundAudioMuted`。`BackgroundAudio` 现支持可选 `playing`(受控)或 `defaultPlaying`(非受控)。
163
+
164
+ 依赖未挂载子组件的方法(如无 `<WebVR />` 时调 `enterVR`)安全空操作,返回 `false`、`void` 或 `null`。
165
+
166
+ ### 全屏
167
+
168
+ ```tsx
169
+ import { useRef } from "react";
170
+ import { PanoViewer, Sphere, type PanoViewerHandle } from "@ericchen1990/pano-view";
171
+
172
+ function FullscreenExample() {
173
+ const ref = useRef<PanoViewerHandle>(null);
174
+
175
+ return (
176
+ <>
177
+ <button onClick={() => void ref.current?.enterFullscreen()}>进入全屏</button>
178
+ <button onClick={() => void ref.current?.exitFullscreen()}>退出全屏</button>
179
+ <button onClick={() => console.log(ref.current?.isFullscreen())}>是否全屏?</button>
180
+ <PanoViewer ref={ref} style={{ height: 560 }}>
181
+ <Sphere src="/panoramas/room.webp" previewUrl="preview.webp" />
182
+ </PanoViewer>
183
+ </>
184
+ );
185
+ }
186
+ ```
187
+
188
+ ### 命令式切换场景
189
+
190
+ 使用 `defaultActiveSceneId` 为非受控模式 — 命令式调用直接更新内部状态。受控模式继续传 `activeSceneId`,命令式调用会触发 `onActiveSceneIdChange` 以便父组件更新 state。
191
+
192
+ ```tsx
193
+ import { useRef } from "react";
194
+ import {
195
+ PanoViewer,
196
+ Scenes,
197
+ type PanoViewerHandle,
198
+ type Scene,
199
+ } from "@ericchen1990/pano-view";
200
+
201
+ const scenes: Scene[] = [
202
+ { id: "lobby", type: "sphere", src: "/lobby.webp", previewUrl: "/lobby-preview.webp" },
203
+ { id: "roof", type: "sphere", src: "/roof.webp", previewUrl: "/roof-preview.webp" },
204
+ ];
205
+
206
+ function SceneExample() {
207
+ const ref = useRef<PanoViewerHandle>(null);
208
+
209
+ return (
210
+ <>
211
+ <button onClick={() => ref.current?.setScene("roof")}>去天台</button>
212
+ <button onClick={() => ref.current?.nextScene()}>下一场景</button>
213
+ <button onClick={() => ref.current?.previousScene()}>上一场景</button>
214
+ <p>当前: {ref.current?.getActiveSceneId()}</p>
215
+ <PanoViewer ref={ref} style={{ height: 560 }}>
216
+ <Scenes defaultActiveSceneId="lobby" scenes={scenes} transition="dissolve" />
217
+ </PanoViewer>
218
+ </>
219
+ );
220
+ }
221
+ ```
222
+
223
+ ### WebVR
224
+
225
+ `enterVR` 与 `requestVRPermission` 须在用户手势中调用。
226
+
227
+ ```tsx
228
+ import { useRef } from "react";
229
+ import { PanoViewer, Sphere, WebVR, type PanoViewerHandle } from "@ericchen1990/pano-view";
230
+
231
+ function VRExample() {
232
+ const ref = useRef<PanoViewerHandle>(null);
233
+
234
+ return (
235
+ <>
236
+ <button
237
+ onClick={async () => {
238
+ await ref.current?.requestVRPermission();
239
+ await ref.current?.enterVR();
240
+ }}
241
+ >
242
+ 进入 VR
243
+ </button>
244
+ <button onClick={() => void ref.current?.exitVR()}>退出 VR</button>
245
+ <button onClick={() => console.log(ref.current?.getVRMode())}>当前模式?</button>
246
+ <PanoViewer ref={ref} style={{ height: 560 }}>
247
+ <WebVR />
248
+ <Sphere src="/panoramas/room.webp" previewUrl="preview.webp" />
249
+ </PanoViewer>
250
+ </>
251
+ );
252
+ }
253
+ ```
254
+
255
+ ### 自定义视频控件
256
+
257
+ 将 `PanoVideo` 的 `controls` 设为 `false`,从 viewer ref 驱动播放。配合 `useSyncExternalStore` 使用 `subscribeVideo` 获取响应式快照。
258
+
259
+ ```tsx
260
+ import { useRef, useSyncExternalStore } from "react";
261
+ import {
262
+ PanoViewer,
263
+ PanoVideo,
264
+ type PanoViewerHandle,
265
+ } from "@ericchen1990/pano-view";
266
+
267
+ function VideoExample() {
268
+ const ref = useRef<PanoViewerHandle>(null);
269
+
270
+ const snapshot = useSyncExternalStore(
271
+ (cb) => ref.current?.subscribeVideo(cb) ?? (() => {}),
272
+ () => ref.current?.getVideo()?.getSnapshot() ?? null,
273
+ () => null,
274
+ );
275
+
276
+ return (
277
+ <>
278
+ <button onClick={() => void ref.current?.toggleVideo()}>
279
+ {snapshot?.playing ? "暂停" : "播放"}
280
+ </button>
281
+ <button onClick={() => ref.current?.toggleVideoMuted()}>
282
+ {snapshot?.muted ? "取消静音" : "静音"}
283
+ </button>
284
+ {snapshot && (
285
+ <input
286
+ type="range"
287
+ min={0}
288
+ max={snapshot.duration}
289
+ value={snapshot.currentTime}
290
+ onChange={(e) => ref.current?.seekVideo(Number(e.target.value))}
291
+ />
292
+ )}
293
+ <PanoViewer ref={ref} style={{ height: 560 }}>
294
+ <PanoVideo controls={false} src="/tour.mp4" />
295
+ </PanoViewer>
296
+ </>
297
+ );
298
+ }
299
+ ```
300
+
301
+ ### 自定义背景音乐控件
302
+
303
+ `BackgroundAudio` 支持非受控模式 — 省略 `playing`,改用 `defaultPlaying`。通过 viewer ref 驱动。
304
+
305
+ ```tsx
306
+ import { useRef, useSyncExternalStore } from "react";
307
+ import {
308
+ PanoViewer,
309
+ Sphere,
310
+ BackgroundAudio,
311
+ type PanoViewerHandle,
312
+ } from "@ericchen1990/pano-view";
313
+
314
+ function BGMExample() {
315
+ const ref = useRef<PanoViewerHandle>(null);
316
+
317
+ const snapshot = useSyncExternalStore(
318
+ (cb) => ref.current?.subscribeBackgroundAudio(cb) ?? (() => {}),
319
+ () => ref.current?.getBackgroundAudio()?.getSnapshot() ?? null,
320
+ () => null,
321
+ );
322
+
323
+ return (
324
+ <>
325
+ <button onClick={() => ref.current?.toggleBackgroundAudio()}>
326
+ {snapshot?.playing ? "暂停 BGM" : "播放 BGM"}
327
+ </button>
328
+ <button onClick={() => ref.current?.toggleBackgroundAudioMuted()}>
329
+ {snapshot?.muted ? "取消静音" : "静音"}
330
+ </button>
331
+ <input
332
+ type="range"
333
+ min={0}
334
+ max={1}
335
+ step={0.01}
336
+ value={snapshot?.volume ?? 1}
337
+ onChange={(e) =>
338
+ ref.current?.setBackgroundAudioVolume(Number(e.target.value))
339
+ }
340
+ />
341
+ <PanoViewer ref={ref} style={{ height: 560 }}>
342
+ <BackgroundAudio defaultPlaying src="/bgm.mp3" />
343
+ <Sphere src="/panoramas/room.webp" previewUrl="preview.webp" />
344
+ </PanoViewer>
345
+ </>
346
+ );
347
+ }
348
+ ```
349
+
350
+ ### 一站式示例
351
+
352
+ ```tsx
353
+ import { useRef } from "react";
354
+ import {
355
+ PanoViewer,
356
+ Scenes,
357
+ WebVR,
358
+ PanoVideo,
359
+ BackgroundAudio,
360
+ type PanoViewerHandle,
361
+ } from "@ericchen1990/pano-view";
362
+
363
+ function TourPlayer() {
364
+ const ref = useRef<PanoViewerHandle>(null);
365
+
366
+ return (
367
+ <>
368
+ <nav>
369
+ <button onClick={() => ref.current?.previousScene()}>← 上一场景</button>
370
+ <button onClick={() => ref.current?.nextScene()}>下一场景 →</button>
371
+ <button onClick={() => void ref.current?.toggleFullscreen()}>全屏</button>
372
+ <button onClick={() => void ref.current?.enterVR()}>进入 VR</button>
373
+ <button onClick={() => ref.current?.toggleBackgroundAudio()}>BGM</button>
374
+ </nav>
375
+ <PanoViewer ref={ref} style={{ height: "100vh" }}>
376
+ <WebVR />
377
+ <BackgroundAudio defaultPlaying src="/bgm.mp3" />
378
+ <Scenes defaultActiveSceneId="lobby" scenes={scenes} transition="dissolve" />
379
+ </PanoViewer>
380
+ </>
381
+ );
382
+ }
383
+ ```
384
+
385
+ 两个已废弃的自动旋转方法(`startAutoRotate`、`stopAutoRotate`)继续保留以兼容。鼠标、触控与键盘输入默认启用 — 普通浏览无需再渲染控件组件。仅当需要覆盖属性时渲染 `MouseControls`、`TouchControls` 或 `KeyboardControls`(子组件会替换该通道的默认实例)。通过 `controls` 调节共享行为(`inertia`、`invert`、`bouncingLimits`、`fovSpeed`、`frictionStop`、`rotateDamping`、`zoomDamping`,以及顶层的 `rotateSpeed` / `zoomSpeed`)。将 `controls.mouse` / `touch` / `keyboard` 设为 `false` 可关闭某通道,或传入选项对象(含 `enabled`)在不挂载子组件的情况下覆盖默认值。自动旋转在渲染 `AutoRotate` 之前处于关闭状态。默认右键菜单(重置视图 / 进入全屏)也会挂载 — 见 [`PanoContextMenu`](#panocontextmenu)。
149
386
 
150
387
  拖拽与缩放会更新目标视图,相机平滑跟随。`rotateDamping` 与 `zoomDamping` 控制跟随速度,单位为秒⁻¹(默认分别为 `14` 与 `16`);数值越低手感越柔和,`0` 表示该轴不做平滑。两者必须为非负有限数。命令式 `setView()` 与 `reset()` 仍为即时生效。
151
388
 
@@ -418,6 +655,56 @@ const scenes: Scene[] = [
418
655
  </PanoViewer>
419
656
  ```
420
657
 
658
+ ## BackgroundAudio
659
+
660
+ `BackgroundAudio` 在查看器里播放背景音乐或环境声。它不是热点:没有球面位置、没有标记、音量也不随视角变化。必须作为 `PanoViewer` 的子组件,与 `Scenes`、`Sphere` 或 `Tile` **并列**。不要写进 `Scenes` 的 `renderHotspots`:场景过渡时热点会卸载,背景声也会被停掉。
661
+
662
+ `playing` 由宿主控制,组件不会自行改这个值。`loop` 默认 `true`。浏览器可能拦住自动播放;此时 `onPlaybackStateChange` 收到 `"blocked"`,在 `playing` 仍为 true 时会于下一次点击或按键后重试。
663
+
664
+ 有两种用法,选一种即可。
665
+
666
+ **全部场景共用一条音轨** — 只传 `src`,不要传 `sources`。切换场景时音频继续播放,不会重头开始。
667
+
668
+ ```tsx
669
+ import { BackgroundAudio, PanoViewer, Scenes } from "@ericchen1990/pano-view";
670
+
671
+ <PanoViewer style={{ height: 560 }}>
672
+ <BackgroundAudio src="/bgm/tour.mp3" playing={isBgmPlaying} />
673
+ <Scenes scenes={scenes} activeSceneId={activeSceneId} />
674
+ </PanoViewer>;
675
+ ```
676
+
677
+ **每个场景一条音轨** — 传 `sources`(键为 `Scene.id`)和当前的 `sceneId`。省略 `sceneId` 会抛错。切换场景时,若文件变了会按 `fadeMs` 交叉淡化(默认 400 毫秒;`0` 为硬切);若两个场景指向同一文件则继续播,不会重启。
678
+
679
+ ```tsx
680
+ <PanoViewer style={{ height: 560 }}>
681
+ <BackgroundAudio
682
+ sources={{
683
+ lobby: "/bgm/lobby.mp3",
684
+ terrace: "/bgm/terrace.mp3",
685
+ }}
686
+ sceneId={activeSceneId}
687
+ playing={isBgmPlaying}
688
+ />
689
+ <Scenes scenes={scenes} activeSceneId={activeSceneId} />
690
+ </PanoViewer>;
691
+ ```
692
+
693
+ 可选:同时传 `src` 作为未列出场景的默认音轨。某个场景要静音时,把该 id 写成 `""`(会覆盖 `src`,而不是回退到默认轨)。
694
+
695
+ ```tsx
696
+ <BackgroundAudio
697
+ src="/bgm/default.mp3"
698
+ sources={{ lobby: "/bgm/courtyard.mp3", terrace: "" }}
699
+ sceneId={activeSceneId}
700
+ playing={isBgmPlaying}
701
+ />;
702
+ ```
703
+
704
+ 上例中:`lobby` 播院子声,`terrace` 静音,其它场景播 `default.mp3`。
705
+
706
+ 需要绑在全景某一点、随视角左右移动的声音,请用 `AudioHotspot`,不要用 `BackgroundAudio`。
707
+
421
708
  ## Gyro
422
709
 
423
710
  `Gyro` 为可选的设备方向控制,默认关闭。在 `PanoViewer` 内渲染并通过 `enabled` 控制。相对模式(默认)将启用时的视图作为起始方向。设 `absolute` 可跟随罗盘朝向,用 `north` 指定指向北方的全景 yaw。
@@ -818,7 +1105,7 @@ import {
818
1105
  />
819
1106
  ```
820
1107
 
821
- 持久化或宿主编辑器可使用导出的判别联合类型 `HotspotDefinition`。新增变体会破坏 exhaustive switch 的类型兼容;除现有点与路径类别外,还需处理 `text` 与 `iframe`:
1108
+ 持久化或宿主编辑器可使用导出的判别联合类型 `HotspotDefinition`。新增变体会破坏 exhaustive switch 的类型兼容;除现有点与路径类别外,还需处理 `audio`:
822
1109
 
823
1110
  ```ts
824
1111
  import type { HotspotDefinition } from "@ericchen1990/pano-view";
@@ -828,6 +1115,7 @@ const hotspots: HotspotDefinition[] = [
828
1115
  { type: "graphic", id: "marker", position: { yaw: -18, pitch: 9 }, graphic: { kind: "ring" } },
829
1116
  { type: "sequence", id: "pulse", position: { yaw: -42, pitch: -7 }, src: "/hotspots/pulse.png", frameCount: 20 },
830
1117
  { type: "video", id: "clip", position: { yaw: 48, pitch: 6 }, src: "/hotspots/clip.webm" },
1118
+ { type: "audio", id: "fountain", position: { yaw: -30, pitch: -8 }, src: "/hotspots/fountain.mp3", range: 90 },
831
1119
  { type: "text", id: "caption", position: { yaw: 0, pitch: -16 }, text: "Courtyard overlook" },
832
1120
  { type: "iframe", id: "guide", position: { yaw: -62, pitch: 4 }, src: "/hotspots/embed.html" },
833
1121
  { type: "polygon", id: "zone", vertices: [{ yaw: 12, pitch: 4 }, { yaw: 22, pitch: 4 }, { yaw: 18, pitch: 14 }] },
@@ -1047,6 +1335,37 @@ import { VideoHotspot } from "@ericchen1990/pano-view";
1047
1335
 
1048
1336
  视频通过 `onError` 报告媒体与 poster 失败。与 `SequenceHotspot` 相同,不会自行改变 `playing`,以保证源变更、卸载与 React StrictMode 生命周期可预测。
1049
1337
 
1338
+ ## AudioHotspot
1339
+
1340
+ `AudioHotspot` 在球面位置播放定向声音。立体声像跟随相机朝向;`range` 为看离声源多少度后音量降到静音。默认 `360` 表示不因视角衰减(仅左右声像)。定点声源可使用较小值,例如 `90`。
1341
+
1342
+ 播放走 Howler.js 的 Web Audio(已打进本包,宿主不必安装 `howler`)。文件需先解码再起播,推荐 MP3。`src` 可以是字符串或回退 URL 数组。不要用 HTML5 流式播放,否则会失去立体声像。
1343
+
1344
+ `playing` 与 `VideoHotspot` 一样受控。`loop` 默认 `false`,`muted` 默认 `false`,`volume` 默认 `1`,`pauseWhenHidden` 默认 `true`(标签页隐藏时暂停,仍为 `playing` 则回来后恢复)。首次用户手势会解锁音频;若自动播放被拦,会报告 `"blocked"`,并在 `playing` 仍为 true 时于下一次指针或按键后重试 `play()`。
1345
+
1346
+ 默认标记在 **停止**(扬声器)与 **播放**(扬声器 + 声波)之间切换。可用 `icon` 自定义停止图标、`playingIcon` 自定义播放图标。只设 `icon` 时两种状态共用该图。`marker={false}` 则无视觉,仍作为定点声源。非空间的全 tour 或按场景背景声请使用 [`BackgroundAudio`](#backgroundaudio)。
1347
+
1348
+ ```tsx
1349
+ import { AudioHotspot } from "@ericchen1990/pano-view";
1350
+
1351
+ <AudioHotspot
1352
+ id="fountain"
1353
+ ariaLabel="Fountain"
1354
+ position={{ yaw: -30, pitch: -8 }}
1355
+ src="/hotspots/fountain.mp3"
1356
+ playing={isFountainPlaying}
1357
+ loop
1358
+ volume={0.8}
1359
+ range={90}
1360
+ onClick={() => setFountainPlaying((playing) => !playing)}
1361
+ onEnded={() => setFountainPlaying(false)}
1362
+ onPlaybackStateChange={(state) => console.log(state)}
1363
+ onPlaybackError={(error) => console.error(error)}
1364
+ />;
1365
+ ```
1366
+
1367
+ 与其他媒体热点相同,`AudioHotspot` 不会自行改变 `playing`。
1368
+
1050
1369
  ## TextHotspot
1051
1370
 
1052
1371
  `TextHotspot` 将纯文本栅格化到 canvas 纹理。不接受 HTML 或 Markdown。`\n` 为硬换行;`whiteSpace="normal"`(默认)在热点角向宽度内自动换行。尺寸与其他点热点一样为度。排版使用与 CSS 相同的名称(`fontFamily`、`fontSize`、`fontWeight`、`fontStyle`),但绘制在 canvas 上,非 DOM 样式。`fontSize` 为 canvas 像素(默认 96),不随面板高度缩放。`fontWeight` 默认 `600`,`fontStyle` 默认 `"normal"`。`padding` 与 `borderRadius` 仍为纹理较短边的比例,故仍随 `width` / `height` 变化。