@ericchen1990/pano-view 2.4.0 → 2.5.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
@@ -113,6 +113,7 @@ the stylesheet, the overlays still mount, but they render unstyled.
113
113
 
114
114
  ### Hooks and helpers
115
115
 
116
+ - `usePanoViewer` — wrap the viewer ref in reusable parent-side commands
116
117
  - `usePanoEvents` — subscribe to viewer events inside a custom child ([`PanoEvents`](#panoevents))
117
118
  - Coordinate helpers — `normalizePanoPosition`, `normalizePanoYaw`, `clampPanoPitch`, `panoPositionToVector3`, `vector3ToPanoPosition` ([Panorama coordinate events](#panorama-coordinate-events))
118
119
  - `cycleSceneId` — wrap previous/next scene ids ([`KeyboardControls`](#keyboardcontrols))
@@ -126,25 +127,24 @@ the stylesheet, the overlays still mount, but they render unstyled.
126
127
  Angles are public degrees. Positive yaw looks right and positive pitch looks up.
127
128
 
128
129
  ```tsx
129
- import { useRef } from "react";
130
130
  import "@ericchen1990/pano-view/styles.css";
131
131
  import {
132
132
  PanoViewer,
133
133
  Sphere,
134
134
  AutoRotate,
135
- type PanoViewerHandle,
135
+ usePanoViewer,
136
136
  } from "@ericchen1990/pano-view";
137
137
 
138
138
  export function ControlledExample() {
139
- const ref = useRef<PanoViewerHandle>(null);
139
+ const viewer = usePanoViewer();
140
140
 
141
141
  return (
142
142
  <>
143
- <button onClick={() => ref.current?.setView({ yaw: 90, fov: 55 })}>
143
+ <button onClick={() => viewer.setView({ yaw: 90, fov: 55 })}>
144
144
  Look right
145
145
  </button>
146
146
  <PanoViewer
147
- ref={ref}
147
+ ref={viewer.ref}
148
148
  controls={{
149
149
  inertia: true,
150
150
  rotateDamping: 14,
@@ -183,18 +183,17 @@ Methods that depend on an unmounted child component (e.g. `enterVR` without `<We
183
183
  ### Fullscreen
184
184
 
185
185
  ```tsx
186
- import { useRef } from "react";
187
- import { PanoViewer, Sphere, type PanoViewerHandle } from "@ericchen1990/pano-view";
186
+ import { PanoViewer, Sphere, usePanoViewer } from "@ericchen1990/pano-view";
188
187
 
189
188
  function FullscreenExample() {
190
- const ref = useRef<PanoViewerHandle>(null);
189
+ const viewer = usePanoViewer();
191
190
 
192
191
  return (
193
192
  <>
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 }}>
193
+ <button onClick={() => void viewer.enterFullscreen()}>Enter fullscreen</button>
194
+ <button onClick={() => void viewer.exitFullscreen()}>Exit fullscreen</button>
195
+ <button onClick={() => console.log(viewer.isFullscreen())}>Is fullscreen?</button>
196
+ <PanoViewer ref={viewer.ref} style={{ height: 560 }}>
198
197
  <Sphere src="/panoramas/room.webp" previewUrl="preview.webp" />
199
198
  </PanoViewer>
200
199
  </>
@@ -204,14 +203,14 @@ function FullscreenExample() {
204
203
 
205
204
  ### Imperative scene switching
206
205
 
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.
206
+ Use `usePanoViewer()` to shorten parent-side commands to `viewer.setScene("roof")`. Keep using controlled `activeSceneId` when the host UI needs to render the current scene id reactively. For uncontrolled mode, `defaultActiveSceneId` still lets imperative calls update the internal state directly.
208
207
 
209
208
  ```tsx
210
- import { useRef } from "react";
209
+ import { useState } from "react";
211
210
  import {
212
211
  PanoViewer,
213
212
  Scenes,
214
- type PanoViewerHandle,
213
+ usePanoViewer,
215
214
  type Scene,
216
215
  } from "@ericchen1990/pano-view";
217
216
 
@@ -221,16 +220,23 @@ const scenes: Scene[] = [
221
220
  ];
222
221
 
223
222
  function SceneExample() {
224
- const ref = useRef<PanoViewerHandle>(null);
223
+ const viewer = usePanoViewer();
224
+ const [activeSceneId, setActiveSceneId] = useState("lobby");
225
225
 
226
226
  return (
227
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" />
228
+ <button onClick={() => setActiveSceneId("roof")}>Go to roof</button>
229
+ <button onClick={() => viewer.nextScene()}>Next</button>
230
+ <button onClick={() => viewer.previousScene()}>Previous</button>
231
+ <button onClick={() => viewer.setScene("roof")}>Jump via hook</button>
232
+ <p>Current: {activeSceneId}</p>
233
+ <PanoViewer ref={viewer.ref} style={{ height: 560 }}>
234
+ <Scenes
235
+ activeSceneId={activeSceneId}
236
+ onActiveSceneIdChange={setActiveSceneId}
237
+ scenes={scenes}
238
+ transition="dissolve"
239
+ />
234
240
  </PanoViewer>
235
241
  </>
236
242
  );
@@ -242,25 +248,24 @@ function SceneExample() {
242
248
  `enterVR` and `requestVRPermission` must be called from a user gesture.
243
249
 
244
250
  ```tsx
245
- import { useRef } from "react";
246
- import { PanoViewer, Sphere, WebVR, type PanoViewerHandle } from "@ericchen1990/pano-view";
251
+ import { PanoViewer, Sphere, WebVR, usePanoViewer } from "@ericchen1990/pano-view";
247
252
 
248
253
  function VRExample() {
249
- const ref = useRef<PanoViewerHandle>(null);
254
+ const viewer = usePanoViewer();
250
255
 
251
256
  return (
252
257
  <>
253
258
  <button
254
259
  onClick={async () => {
255
- await ref.current?.requestVRPermission();
256
- await ref.current?.enterVR();
260
+ await viewer.requestVRPermission();
261
+ await viewer.enterVR();
257
262
  }}
258
263
  >
259
264
  Enter VR
260
265
  </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 }}>
266
+ <button onClick={() => void viewer.exitVR()}>Exit VR</button>
267
+ <button onClick={() => console.log(viewer.getVRMode())}>Mode?</button>
268
+ <PanoViewer ref={viewer.ref} style={{ height: 560 }}>
264
269
  <WebVR />
265
270
  <Sphere src="/panoramas/room.webp" previewUrl="preview.webp" />
266
271
  </PanoViewer>
@@ -271,31 +276,31 @@ function VRExample() {
271
276
 
272
277
  ### Custom video controls
273
278
 
274
- Set `controls={false}` on `PanoVideo` and drive playback from the viewer ref. Use `subscribeVideo` with `useSyncExternalStore` for reactive snapshots.
279
+ Set `controls={false}` on `PanoVideo` and drive playback from `usePanoViewer()`. Use `subscribeVideo` with `useSyncExternalStore` for reactive snapshots.
275
280
 
276
281
  ```tsx
277
- import { useRef, useSyncExternalStore } from "react";
282
+ import { useSyncExternalStore } from "react";
278
283
  import {
279
284
  PanoViewer,
280
285
  PanoVideo,
281
- type PanoViewerHandle,
286
+ usePanoViewer,
282
287
  } from "@ericchen1990/pano-view";
283
288
 
284
289
  function VideoExample() {
285
- const ref = useRef<PanoViewerHandle>(null);
290
+ const viewer = usePanoViewer();
286
291
 
287
292
  const snapshot = useSyncExternalStore(
288
- (cb) => ref.current?.subscribeVideo(cb) ?? (() => {}),
289
- () => ref.current?.getVideo()?.getSnapshot() ?? null,
293
+ viewer.subscribeVideo,
294
+ () => viewer.getVideo()?.getSnapshot() ?? null,
290
295
  () => null,
291
296
  );
292
297
 
293
298
  return (
294
299
  <>
295
- <button onClick={() => void ref.current?.toggleVideo()}>
300
+ <button onClick={() => void viewer.toggleVideo()}>
296
301
  {snapshot?.playing ? "Pause" : "Play"}
297
302
  </button>
298
- <button onClick={() => ref.current?.toggleVideoMuted()}>
303
+ <button onClick={() => viewer.toggleVideoMuted()}>
299
304
  {snapshot?.muted ? "Unmute" : "Mute"}
300
305
  </button>
301
306
  {snapshot && (
@@ -304,10 +309,10 @@ function VideoExample() {
304
309
  min={0}
305
310
  max={snapshot.duration}
306
311
  value={snapshot.currentTime}
307
- onChange={(e) => ref.current?.seekVideo(Number(e.target.value))}
312
+ onChange={(e) => viewer.seekVideo(Number(e.target.value))}
308
313
  />
309
314
  )}
310
- <PanoViewer ref={ref} style={{ height: 560 }}>
315
+ <PanoViewer ref={viewer.ref} style={{ height: 560 }}>
311
316
  <PanoVideo controls={false} src="/tour.mp4" />
312
317
  </PanoViewer>
313
318
  </>
@@ -317,32 +322,32 @@ function VideoExample() {
317
322
 
318
323
  ### Custom background audio controls
319
324
 
320
- `BackgroundAudio` supports uncontrolled mode — omit `playing` and use `defaultPlaying` instead. Drive it from the viewer ref.
325
+ `BackgroundAudio` supports uncontrolled mode — omit `playing` and use `defaultPlaying` instead. Drive it from `usePanoViewer()`.
321
326
 
322
327
  ```tsx
323
- import { useRef, useSyncExternalStore } from "react";
328
+ import { useSyncExternalStore } from "react";
324
329
  import {
325
330
  PanoViewer,
326
331
  Sphere,
327
332
  BackgroundAudio,
328
- type PanoViewerHandle,
333
+ usePanoViewer,
329
334
  } from "@ericchen1990/pano-view";
330
335
 
331
336
  function BGMExample() {
332
- const ref = useRef<PanoViewerHandle>(null);
337
+ const viewer = usePanoViewer();
333
338
 
334
339
  const snapshot = useSyncExternalStore(
335
- (cb) => ref.current?.subscribeBackgroundAudio(cb) ?? (() => {}),
336
- () => ref.current?.getBackgroundAudio()?.getSnapshot() ?? null,
340
+ viewer.subscribeBackgroundAudio,
341
+ () => viewer.getBackgroundAudio()?.getSnapshot() ?? null,
337
342
  () => null,
338
343
  );
339
344
 
340
345
  return (
341
346
  <>
342
- <button onClick={() => ref.current?.toggleBackgroundAudio()}>
347
+ <button onClick={() => viewer.toggleBackgroundAudio()}>
343
348
  {snapshot?.playing ? "Pause BGM" : "Play BGM"}
344
349
  </button>
345
- <button onClick={() => ref.current?.toggleBackgroundAudioMuted()}>
350
+ <button onClick={() => viewer.toggleBackgroundAudioMuted()}>
346
351
  {snapshot?.muted ? "Unmute" : "Mute"}
347
352
  </button>
348
353
  <input
@@ -351,11 +356,9 @@ function BGMExample() {
351
356
  max={1}
352
357
  step={0.01}
353
358
  value={snapshot?.volume ?? 1}
354
- onChange={(e) =>
355
- ref.current?.setBackgroundAudioVolume(Number(e.target.value))
356
- }
359
+ onChange={(e) => viewer.setBackgroundAudioVolume(Number(e.target.value))}
357
360
  />
358
- <PanoViewer ref={ref} style={{ height: 560 }}>
361
+ <PanoViewer ref={viewer.ref} style={{ height: 560 }}>
359
362
  <BackgroundAudio defaultPlaying src="/bgm.mp3" />
360
363
  <Sphere src="/panoramas/room.webp" previewUrl="preview.webp" />
361
364
  </PanoViewer>
@@ -367,29 +370,28 @@ function BGMExample() {
367
370
  ### All-in-one example
368
371
 
369
372
  ```tsx
370
- import { useRef } from "react";
371
373
  import {
372
374
  PanoViewer,
373
375
  Scenes,
374
376
  WebVR,
375
377
  PanoVideo,
376
378
  BackgroundAudio,
377
- type PanoViewerHandle,
379
+ usePanoViewer,
378
380
  } from "@ericchen1990/pano-view";
379
381
 
380
382
  function TourPlayer() {
381
- const ref = useRef<PanoViewerHandle>(null);
383
+ const viewer = usePanoViewer();
382
384
 
383
385
  return (
384
386
  <>
385
387
  <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>
388
+ <button onClick={() => viewer.previousScene()}>← Prev</button>
389
+ <button onClick={() => viewer.nextScene()}>Next →</button>
390
+ <button onClick={() => void viewer.toggleFullscreen()}>Fullscreen</button>
391
+ <button onClick={() => void viewer.enterVR()}>Enter VR</button>
392
+ <button onClick={() => viewer.toggleBackgroundAudio()}>BGM</button>
391
393
  </nav>
392
- <PanoViewer ref={ref} style={{ height: "100vh" }}>
394
+ <PanoViewer ref={viewer.ref} style={{ height: "100vh" }}>
393
395
  <WebVR />
394
396
  <BackgroundAudio defaultPlaying src="/bgm.mp3" />
395
397
  <Scenes defaultActiveSceneId="lobby" scenes={scenes} transition="dissolve" />
package/README.zh-CN.md CHANGED
@@ -96,6 +96,7 @@ import "@ericchen1990/pano-view/styles.css";
96
96
 
97
97
  ### Hook 与辅助工具
98
98
 
99
+ - `usePanoViewer` — 将 viewer ref 封装成可复用的父组件侧命令
99
100
  - `usePanoEvents` — 在自定义子组件内订阅查看器事件([`PanoEvents`](#panoevents))
100
101
  - 坐标辅助函数 — `normalizePanoPosition`、`normalizePanoYaw`、`clampPanoPitch`、`panoPositionToVector3`、`vector3ToPanoPosition`([全景坐标事件](#全景坐标事件))
101
102
  - `cycleSceneId` — 循环上一/下一场景 id([`KeyboardControls`](#keyboardcontrols))
@@ -109,25 +110,24 @@ import "@ericchen1990/pano-view/styles.css";
109
110
  角度对外以度为单位。偏航(yaw)为正表示向右看,俯仰(pitch)为正表示向上看。
110
111
 
111
112
  ```tsx
112
- import { useRef } from "react";
113
113
  import "@ericchen1990/pano-view/styles.css";
114
114
  import {
115
115
  PanoViewer,
116
116
  Sphere,
117
117
  AutoRotate,
118
- type PanoViewerHandle,
118
+ usePanoViewer,
119
119
  } from "@ericchen1990/pano-view";
120
120
 
121
121
  export function ControlledExample() {
122
- const ref = useRef<PanoViewerHandle>(null);
122
+ const viewer = usePanoViewer();
123
123
 
124
124
  return (
125
125
  <>
126
- <button onClick={() => ref.current?.setView({ yaw: 90, fov: 55 })}>
126
+ <button onClick={() => viewer.setView({ yaw: 90, fov: 55 })}>
127
127
  Look right
128
128
  </button>
129
129
  <PanoViewer
130
- ref={ref}
130
+ ref={viewer.ref}
131
131
  controls={{
132
132
  inertia: true,
133
133
  rotateDamping: 14,
@@ -166,18 +166,17 @@ export function ControlledExample() {
166
166
  ### 全屏
167
167
 
168
168
  ```tsx
169
- import { useRef } from "react";
170
- import { PanoViewer, Sphere, type PanoViewerHandle } from "@ericchen1990/pano-view";
169
+ import { PanoViewer, Sphere, usePanoViewer } from "@ericchen1990/pano-view";
171
170
 
172
171
  function FullscreenExample() {
173
- const ref = useRef<PanoViewerHandle>(null);
172
+ const viewer = usePanoViewer();
174
173
 
175
174
  return (
176
175
  <>
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 }}>
176
+ <button onClick={() => void viewer.enterFullscreen()}>进入全屏</button>
177
+ <button onClick={() => void viewer.exitFullscreen()}>退出全屏</button>
178
+ <button onClick={() => console.log(viewer.isFullscreen())}>是否全屏?</button>
179
+ <PanoViewer ref={viewer.ref} style={{ height: 560 }}>
181
180
  <Sphere src="/panoramas/room.webp" previewUrl="preview.webp" />
182
181
  </PanoViewer>
183
182
  </>
@@ -187,14 +186,14 @@ function FullscreenExample() {
187
186
 
188
187
  ### 命令式切换场景
189
188
 
190
- 使用 `defaultActiveSceneId` 为非受控模式 — 命令式调用直接更新内部状态。受控模式继续传 `activeSceneId`,命令式调用会触发 `onActiveSceneIdChange` 以便父组件更新 state。
189
+ 使用 `usePanoViewer()` 可将父组件侧命令缩短为 `viewer.setScene("roof")`。当宿主 UI 需要响应式显示当前场景 id 时,仍建议继续使用受控 `activeSceneId`。若使用非受控模式,`defaultActiveSceneId` 依然允许命令式调用直接更新内部状态。
191
190
 
192
191
  ```tsx
193
- import { useRef } from "react";
192
+ import { useState } from "react";
194
193
  import {
195
194
  PanoViewer,
196
195
  Scenes,
197
- type PanoViewerHandle,
196
+ usePanoViewer,
198
197
  type Scene,
199
198
  } from "@ericchen1990/pano-view";
200
199
 
@@ -204,16 +203,23 @@ const scenes: Scene[] = [
204
203
  ];
205
204
 
206
205
  function SceneExample() {
207
- const ref = useRef<PanoViewerHandle>(null);
206
+ const viewer = usePanoViewer();
207
+ const [activeSceneId, setActiveSceneId] = useState("lobby");
208
208
 
209
209
  return (
210
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" />
211
+ <button onClick={() => setActiveSceneId("roof")}>去天台</button>
212
+ <button onClick={() => viewer.nextScene()}>下一场景</button>
213
+ <button onClick={() => viewer.previousScene()}>上一场景</button>
214
+ <button onClick={() => viewer.setScene("roof")}>通过 hook 跳转</button>
215
+ <p>当前: {activeSceneId}</p>
216
+ <PanoViewer ref={viewer.ref} style={{ height: 560 }}>
217
+ <Scenes
218
+ activeSceneId={activeSceneId}
219
+ onActiveSceneIdChange={setActiveSceneId}
220
+ scenes={scenes}
221
+ transition="dissolve"
222
+ />
217
223
  </PanoViewer>
218
224
  </>
219
225
  );
@@ -225,25 +231,24 @@ function SceneExample() {
225
231
  `enterVR` 与 `requestVRPermission` 须在用户手势中调用。
226
232
 
227
233
  ```tsx
228
- import { useRef } from "react";
229
- import { PanoViewer, Sphere, WebVR, type PanoViewerHandle } from "@ericchen1990/pano-view";
234
+ import { PanoViewer, Sphere, WebVR, usePanoViewer } from "@ericchen1990/pano-view";
230
235
 
231
236
  function VRExample() {
232
- const ref = useRef<PanoViewerHandle>(null);
237
+ const viewer = usePanoViewer();
233
238
 
234
239
  return (
235
240
  <>
236
241
  <button
237
242
  onClick={async () => {
238
- await ref.current?.requestVRPermission();
239
- await ref.current?.enterVR();
243
+ await viewer.requestVRPermission();
244
+ await viewer.enterVR();
240
245
  }}
241
246
  >
242
247
  进入 VR
243
248
  </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 }}>
249
+ <button onClick={() => void viewer.exitVR()}>退出 VR</button>
250
+ <button onClick={() => console.log(viewer.getVRMode())}>当前模式?</button>
251
+ <PanoViewer ref={viewer.ref} style={{ height: 560 }}>
247
252
  <WebVR />
248
253
  <Sphere src="/panoramas/room.webp" previewUrl="preview.webp" />
249
254
  </PanoViewer>
@@ -254,31 +259,31 @@ function VRExample() {
254
259
 
255
260
  ### 自定义视频控件
256
261
 
257
- 将 `PanoVideo` 的 `controls` 设为 `false`,从 viewer ref 驱动播放。配合 `useSyncExternalStore` 使用 `subscribeVideo` 获取响应式快照。
262
+ 将 `PanoVideo` 的 `controls` 设为 `false`,通过 `usePanoViewer()` 驱动播放。配合 `useSyncExternalStore` 使用 `subscribeVideo` 获取响应式快照。
258
263
 
259
264
  ```tsx
260
- import { useRef, useSyncExternalStore } from "react";
265
+ import { useSyncExternalStore } from "react";
261
266
  import {
262
267
  PanoViewer,
263
268
  PanoVideo,
264
- type PanoViewerHandle,
269
+ usePanoViewer,
265
270
  } from "@ericchen1990/pano-view";
266
271
 
267
272
  function VideoExample() {
268
- const ref = useRef<PanoViewerHandle>(null);
273
+ const viewer = usePanoViewer();
269
274
 
270
275
  const snapshot = useSyncExternalStore(
271
- (cb) => ref.current?.subscribeVideo(cb) ?? (() => {}),
272
- () => ref.current?.getVideo()?.getSnapshot() ?? null,
276
+ viewer.subscribeVideo,
277
+ () => viewer.getVideo()?.getSnapshot() ?? null,
273
278
  () => null,
274
279
  );
275
280
 
276
281
  return (
277
282
  <>
278
- <button onClick={() => void ref.current?.toggleVideo()}>
283
+ <button onClick={() => void viewer.toggleVideo()}>
279
284
  {snapshot?.playing ? "暂停" : "播放"}
280
285
  </button>
281
- <button onClick={() => ref.current?.toggleVideoMuted()}>
286
+ <button onClick={() => viewer.toggleVideoMuted()}>
282
287
  {snapshot?.muted ? "取消静音" : "静音"}
283
288
  </button>
284
289
  {snapshot && (
@@ -287,10 +292,10 @@ function VideoExample() {
287
292
  min={0}
288
293
  max={snapshot.duration}
289
294
  value={snapshot.currentTime}
290
- onChange={(e) => ref.current?.seekVideo(Number(e.target.value))}
295
+ onChange={(e) => viewer.seekVideo(Number(e.target.value))}
291
296
  />
292
297
  )}
293
- <PanoViewer ref={ref} style={{ height: 560 }}>
298
+ <PanoViewer ref={viewer.ref} style={{ height: 560 }}>
294
299
  <PanoVideo controls={false} src="/tour.mp4" />
295
300
  </PanoViewer>
296
301
  </>
@@ -300,32 +305,32 @@ function VideoExample() {
300
305
 
301
306
  ### 自定义背景音乐控件
302
307
 
303
- `BackgroundAudio` 支持非受控模式 — 省略 `playing`,改用 `defaultPlaying`。通过 viewer ref 驱动。
308
+ `BackgroundAudio` 支持非受控模式 — 省略 `playing`,改用 `defaultPlaying`。通过 `usePanoViewer()` 驱动。
304
309
 
305
310
  ```tsx
306
- import { useRef, useSyncExternalStore } from "react";
311
+ import { useSyncExternalStore } from "react";
307
312
  import {
308
313
  PanoViewer,
309
314
  Sphere,
310
315
  BackgroundAudio,
311
- type PanoViewerHandle,
316
+ usePanoViewer,
312
317
  } from "@ericchen1990/pano-view";
313
318
 
314
319
  function BGMExample() {
315
- const ref = useRef<PanoViewerHandle>(null);
320
+ const viewer = usePanoViewer();
316
321
 
317
322
  const snapshot = useSyncExternalStore(
318
- (cb) => ref.current?.subscribeBackgroundAudio(cb) ?? (() => {}),
319
- () => ref.current?.getBackgroundAudio()?.getSnapshot() ?? null,
323
+ viewer.subscribeBackgroundAudio,
324
+ () => viewer.getBackgroundAudio()?.getSnapshot() ?? null,
320
325
  () => null,
321
326
  );
322
327
 
323
328
  return (
324
329
  <>
325
- <button onClick={() => ref.current?.toggleBackgroundAudio()}>
330
+ <button onClick={() => viewer.toggleBackgroundAudio()}>
326
331
  {snapshot?.playing ? "暂停 BGM" : "播放 BGM"}
327
332
  </button>
328
- <button onClick={() => ref.current?.toggleBackgroundAudioMuted()}>
333
+ <button onClick={() => viewer.toggleBackgroundAudioMuted()}>
329
334
  {snapshot?.muted ? "取消静音" : "静音"}
330
335
  </button>
331
336
  <input
@@ -334,11 +339,9 @@ function BGMExample() {
334
339
  max={1}
335
340
  step={0.01}
336
341
  value={snapshot?.volume ?? 1}
337
- onChange={(e) =>
338
- ref.current?.setBackgroundAudioVolume(Number(e.target.value))
339
- }
342
+ onChange={(e) => viewer.setBackgroundAudioVolume(Number(e.target.value))}
340
343
  />
341
- <PanoViewer ref={ref} style={{ height: 560 }}>
344
+ <PanoViewer ref={viewer.ref} style={{ height: 560 }}>
342
345
  <BackgroundAudio defaultPlaying src="/bgm.mp3" />
343
346
  <Sphere src="/panoramas/room.webp" previewUrl="preview.webp" />
344
347
  </PanoViewer>
@@ -350,29 +353,28 @@ function BGMExample() {
350
353
  ### 一站式示例
351
354
 
352
355
  ```tsx
353
- import { useRef } from "react";
354
356
  import {
355
357
  PanoViewer,
356
358
  Scenes,
357
359
  WebVR,
358
360
  PanoVideo,
359
361
  BackgroundAudio,
360
- type PanoViewerHandle,
362
+ usePanoViewer,
361
363
  } from "@ericchen1990/pano-view";
362
364
 
363
365
  function TourPlayer() {
364
- const ref = useRef<PanoViewerHandle>(null);
366
+ const viewer = usePanoViewer();
365
367
 
366
368
  return (
367
369
  <>
368
370
  <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>
371
+ <button onClick={() => viewer.previousScene()}>← 上一场景</button>
372
+ <button onClick={() => viewer.nextScene()}>下一场景 →</button>
373
+ <button onClick={() => void viewer.toggleFullscreen()}>全屏</button>
374
+ <button onClick={() => void viewer.enterVR()}>进入 VR</button>
375
+ <button onClick={() => viewer.toggleBackgroundAudio()}>BGM</button>
374
376
  </nav>
375
- <PanoViewer ref={ref} style={{ height: "100vh" }}>
377
+ <PanoViewer ref={viewer.ref} style={{ height: "100vh" }}>
376
378
  <WebVR />
377
379
  <BackgroundAudio defaultPlaying src="/bgm.mp3" />
378
380
  <Scenes defaultActiveSceneId="lobby" scenes={scenes} transition="dissolve" />