@pictor-sdk/video-android 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 (92) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +40 -0
  3. package/android/CMakeLists.txt +30 -0
  4. package/android/build.gradle +119 -0
  5. package/android/src/main/AndroidManifest.xml +1 -0
  6. package/android/src/main/cpp/pictor_overlaystage.cpp +372 -0
  7. package/android/src/main/cpp/pictor_publish.cpp +87 -0
  8. package/android/src/main/cpp/pictor_videosurface.cpp +735 -0
  9. package/android/src/main/java/dev/pictor/video/AacPriming.kt +261 -0
  10. package/android/src/main/java/dev/pictor/video/AudioMixer.kt +1112 -0
  11. package/android/src/main/java/dev/pictor/video/AudioProbe.kt +162 -0
  12. package/android/src/main/java/dev/pictor/video/CadenceShaderProgram.kt +337 -0
  13. package/android/src/main/java/dev/pictor/video/CodecLedger.kt +126 -0
  14. package/android/src/main/java/dev/pictor/video/CompositionSpike.kt +978 -0
  15. package/android/src/main/java/dev/pictor/video/Diagnostics.kt +54 -0
  16. package/android/src/main/java/dev/pictor/video/EncoderGate.kt +476 -0
  17. package/android/src/main/java/dev/pictor/video/ExportGate.kt +1974 -0
  18. package/android/src/main/java/dev/pictor/video/ExportHeartbeat.kt +72 -0
  19. package/android/src/main/java/dev/pictor/video/ExportProgress.kt +103 -0
  20. package/android/src/main/java/dev/pictor/video/FrameClock.kt +591 -0
  21. package/android/src/main/java/dev/pictor/video/FramePending.kt +181 -0
  22. package/android/src/main/java/dev/pictor/video/GateAudio.kt +456 -0
  23. package/android/src/main/java/dev/pictor/video/GateDestination.kt +445 -0
  24. package/android/src/main/java/dev/pictor/video/GateFrames.kt +662 -0
  25. package/android/src/main/java/dev/pictor/video/GateMux.kt +185 -0
  26. package/android/src/main/java/dev/pictor/video/ManagedSources.kt +333 -0
  27. package/android/src/main/java/dev/pictor/video/Media3Player.kt +934 -0
  28. package/android/src/main/java/dev/pictor/video/MediaStoreSave.kt +419 -0
  29. package/android/src/main/java/dev/pictor/video/OverlayResources.kt +169 -0
  30. package/android/src/main/java/dev/pictor/video/PictorVideoModule.kt +1612 -0
  31. package/android/src/main/java/dev/pictor/video/PictorVideoPackage.kt +26 -0
  32. package/android/src/main/java/dev/pictor/video/PlaybackAnchor.kt +161 -0
  33. package/android/src/main/java/dev/pictor/video/PreviewAudioPlayer.kt +291 -0
  34. package/android/src/main/java/dev/pictor/video/PreviewConfig.kt +96 -0
  35. package/android/src/main/java/dev/pictor/video/PreviewMix.kt +396 -0
  36. package/android/src/main/java/dev/pictor/video/PreviewSync.kt +374 -0
  37. package/android/src/main/java/dev/pictor/video/PreviewTrace.kt +478 -0
  38. package/android/src/main/java/dev/pictor/video/PrimingTrim.kt +337 -0
  39. package/android/src/main/java/dev/pictor/video/RepeatingEncoderFactory.kt +97 -0
  40. package/android/src/main/java/dev/pictor/video/SceneProgram.kt +169 -0
  41. package/android/src/main/java/dev/pictor/video/SceneProgramNative.kt +299 -0
  42. package/android/src/main/java/dev/pictor/video/SkiaPassThroughEffect.kt +695 -0
  43. package/android/src/main/java/dev/pictor/video/SurfaceAttachment.kt +110 -0
  44. package/android/src/main/java/dev/pictor/video/ThumbnailDecoder.kt +171 -0
  45. package/android/src/main/java/dev/pictor/video/VideoSurface.kt +394 -0
  46. package/app.plugin.js +43 -0
  47. package/dist/index.d.mts +3622 -0
  48. package/dist/index.d.ts +3622 -0
  49. package/dist/index.js +3737 -0
  50. package/dist/index.js.map +1 -0
  51. package/dist/index.mjs +3625 -0
  52. package/dist/index.mjs.map +1 -0
  53. package/package.json +82 -0
  54. package/plugin/gradleProperties.d.ts +19 -0
  55. package/plugin/gradleProperties.js +64 -0
  56. package/react-native.config.js +13 -0
  57. package/src/NativePictorVideo.ts +574 -0
  58. package/src/audioProbe.ts +72 -0
  59. package/src/compositionSpike.ts +305 -0
  60. package/src/convergence.ts +112 -0
  61. package/src/encoderGate.ts +66 -0
  62. package/src/exportDestination.ts +149 -0
  63. package/src/exportJob.ts +232 -0
  64. package/src/exportProgress.ts +391 -0
  65. package/src/exportProgressPump.ts +190 -0
  66. package/src/exporter.ts +655 -0
  67. package/src/index.ts +276 -0
  68. package/src/managedSources.ts +252 -0
  69. package/src/media3.ts +412 -0
  70. package/src/mediaStore.ts +177 -0
  71. package/src/memoryGuard.ts +118 -0
  72. package/src/mixSources.ts +82 -0
  73. package/src/overlayStaging.ts +216 -0
  74. package/src/playbackLifecycle.ts +137 -0
  75. package/src/player.ts +39 -0
  76. package/src/playlistSession.ts +125 -0
  77. package/src/previewAudioDiagnostics.ts +67 -0
  78. package/src/previewAudioSession.ts +168 -0
  79. package/src/previewCadence.ts +950 -0
  80. package/src/previewMix.ts +212 -0
  81. package/src/previewTrace.ts +348 -0
  82. package/src/spikeExcursions.ts +405 -0
  83. package/src/submissionScoring.ts +680 -0
  84. package/src/surfaceOwner.ts +264 -0
  85. package/src/surfaceRecovery.ts +121 -0
  86. package/src/syncTrace.ts +189 -0
  87. package/src/thumbnailProvider.ts +163 -0
  88. package/src/tracePhases.ts +217 -0
  89. package/src/useCompositionPlayback.ts +327 -0
  90. package/src/useMedia3Playback.ts +492 -0
  91. package/src/usePreviewAudio.ts +305 -0
  92. package/src/useVideoSurface.ts +282 -0
@@ -0,0 +1,492 @@
1
+ import {
2
+ playIntent,
3
+ playableDuration,
4
+ playlistOf,
5
+ projectDuration,
6
+ projectTimeToWindow,
7
+ windowToProjectTime,
8
+ type PlayIntent,
9
+ type PlaylistItem,
10
+ type UnsupportedSource,
11
+ type VideoProject,
12
+ } from '@pictor-sdk/video-core'
13
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
14
+ import { AppState } from 'react-native'
15
+ import { Media3, type Media3State } from './media3'
16
+ import { createPlaybackLifecycle, type PlaybackLifecycle } from './playbackLifecycle'
17
+ import {
18
+ classifyStall,
19
+ shouldRecover,
20
+ type RecoverySample,
21
+ type StallKind,
22
+ } from './surfaceRecovery'
23
+ import {
24
+ NO_SURFACE,
25
+ afterPrepare,
26
+ idlePlaylistSession,
27
+ shouldPrepare,
28
+ } from './playlistSession'
29
+
30
+ /**
31
+ * Keep a Media3 playlist in step with a project, and the playhead with playback.
32
+ *
33
+ * Two directions, deliberately separated:
34
+ *
35
+ * - **Project → player.** When the clips, their order or their trims change,
36
+ * the playlist is replaced. The current *project time* is preserved across
37
+ * that, not the window index — deleting the second of four clips must not
38
+ * leave the playhead in whatever the second window now happens to be.
39
+ * - **Player → playhead.** While playing, the player's position is polled at a
40
+ * readable rate and mapped back to project time. This is a **throttled
41
+ * semantic update**, not a per-frame one: the picture is composed on the UI
42
+ * runtime from the external texture and never waits for React.
43
+ *
44
+ * The playlist is only replaced when it genuinely differs. A trim drag
45
+ * dispatches a command per finger sample, and rebuilding the playlist on each
46
+ * would restart decoding many times a second.
47
+ *
48
+ * @experimental
49
+ */
50
+
51
+ /** @experimental */
52
+ export interface UseMedia3PlaybackOptions {
53
+ project: VideoProject
54
+ /** Timeline time the host wants shown. */
55
+ playheadUs: number
56
+ /** Called with the project time as playback advances. Throttled. */
57
+ onPositionUs?: (timeUs: number) => void
58
+ /** How often to poll the player, in milliseconds. */
59
+ pollMs?: number
60
+ /**
61
+ * The live presentation surface generation, from `useVideoSurface`.
62
+ *
63
+ * Not a boolean. A playlist prepared against a retired surface renders into
64
+ * something nobody is watching — the player decodes, releases frames and
65
+ * reports no error — so the question is *which* surface, not whether one
66
+ * exists. Every change is a reason to reconcile, including the change to 0.
67
+ */
68
+ surfaceGeneration?: number
69
+ enabled?: boolean
70
+ }
71
+
72
+ /** @experimental */
73
+ export interface Media3Playback {
74
+ playing: boolean
75
+ /**
76
+ * Start or resume playback, replaying from zero when there is nothing left.
77
+ *
78
+ * Returns what it decided, so a host can show it or count it. See
79
+ * `playIntent` in `@pictor-sdk/video-core` for why the end is a special case.
80
+ */
81
+ play(): PlayIntent
82
+ pause(): void
83
+ setRate(rate: number): void
84
+ /** Seek in project time. Mapped to the player's own coordinates. */
85
+ seek(timeUs: number): void
86
+ state: Media3State | null
87
+ /** The windows currently loaded, for diagnostics and gates. */
88
+ items: readonly PlaylistItem[]
89
+ /**
90
+ * Clips the player cannot open. Non-empty means playback is **not** loaded.
91
+ *
92
+ * Surfaced rather than swallowed: the alternative is an editor that plays a
93
+ * shorter timeline than it draws, which reads as a synchronisation bug.
94
+ */
95
+ unsupported: readonly UnsupportedSource[]
96
+ /**
97
+ * Which half of the pipeline stalled after a surface restoration, or `null`.
98
+ *
99
+ * Non-null means a black picture was detected and the handshake re-run — a
100
+ * host should surface it rather than show a silent audio-only result.
101
+ */
102
+ stall: StallKind | null
103
+ /**
104
+ * This screen's ownership of the shared player, for diagnostics and gates.
105
+ *
106
+ * Exposed so a host can log which path stopped playback and assert that a
107
+ * screen released exactly once.
108
+ */
109
+ lifecycle: PlaybackLifecycle
110
+ /**
111
+ * How far the timeline and the player disagree, in microseconds. Zero when
112
+ * everything is playable, and asserted by the device gates.
113
+ */
114
+ divergenceUs: number
115
+ /**
116
+ * The player's position in project time, read now, without rendering.
117
+ *
118
+ * For a host driving a UI-thread playhead: it samples this a few times a
119
+ * second and advances between samples itself. Returns `null` when the player
120
+ * has no position to give — before a playlist is loaded, or after release.
121
+ */
122
+ positionUs(): number | null
123
+ /**
124
+ * The rate actually in force: clip speed and preview rate, composed and
125
+ * clamped by the adapter. A host showing a rate should show this one.
126
+ */
127
+ effectiveRate: number
128
+ /**
129
+ * The last seek this hook actually issued.
130
+ *
131
+ * Global time in, and the `{windowIndex, localUs}` it was converted to. A
132
+ * player reporting an unexpected position is either being sent the wrong
133
+ * thing or mishandling the right thing, and those need different fixes — this
134
+ * is what tells them apart instead of leaving it to arithmetic on a
135
+ * screenshot.
136
+ */
137
+ lastSeek: { globalUs: number; windowIndex: number; localUs: number } | null
138
+ }
139
+
140
+ const DEFAULT_POLL_MS = 100
141
+
142
+ /** How often a refused preparation is retried, in milliseconds. */
143
+ const RETRY_INTERVAL_MS = 250
144
+
145
+ /** @experimental */
146
+ export function useMedia3Playback(options: UseMedia3PlaybackOptions): Media3Playback {
147
+ const { project, playheadUs, enabled = true } = options
148
+ const surfaceGeneration = options.surfaceGeneration ?? NO_SURFACE
149
+ const pollMs = options.pollMs ?? DEFAULT_POLL_MS
150
+ const [state, setState] = useState<Media3State | null>(null)
151
+ const [playing, setPlaying] = useState(false)
152
+
153
+ const playlist = useMemo(() => playlistOf(project), [project])
154
+ const items = playlist.items
155
+ const divergenceUs = useMemo(
156
+ () => projectDuration(project) - playableDuration(playlist),
157
+ [project, playlist],
158
+ )
159
+
160
+ /**
161
+ * A stable description of the playlist.
162
+ *
163
+ * Compared as a string rather than by reference: every edit produces a new
164
+ * project object, but most edits — a selection, a playhead move, an opacity
165
+ * change — do not change what the player should be playing.
166
+ */
167
+ const signature = useMemo(
168
+ () =>
169
+ items
170
+ .map((i) => `${JSON.stringify(i.source)}|${i.startUs}|${i.endUs}|${i.speed}|${i.muted}`)
171
+ .join(';') +
172
+ // Unsupported clips belong in the signature even though they are not
173
+ // windows. Adding a photo between two videos leaves the video windows
174
+ // identical, so a signature built from items alone would not change and
175
+ // the player would keep playing a timeline that had grown underneath it —
176
+ // the exact divergence this is all meant to prevent.
177
+ `!${playlist.unsupported.map((u) => `${u.clipId}:${u.reason}`).join(',')}`,
178
+ [items, playlist.unsupported],
179
+ )
180
+
181
+ const playheadRef = useRef(playheadUs)
182
+ playheadRef.current = playheadUs
183
+ const itemsRef = useRef(items)
184
+ itemsRef.current = items
185
+
186
+ const session = useRef(idlePlaylistSession())
187
+ /** Bumped by the retry timer, purely to run the reconciliation again. */
188
+ const [retry, setRetry] = useState(0)
189
+
190
+ /**
191
+ * Reconcile what Media3 holds with what the document and the surface imply.
192
+ *
193
+ * Level-triggered. The old version fired on the playlist *changing*, which is
194
+ * the one moment it must not rely on: clips are added while the system picker
195
+ * is closing and there is no surface to prepare against, so the change has
196
+ * already happened by the time preparation becomes possible. See
197
+ * `playlistSession.ts`.
198
+ */
199
+ useEffect(() => {
200
+ if (!enabled) return
201
+ const intent = { signature, generation: surfaceGeneration }
202
+ if (!shouldPrepare(session.current, intent)) return
203
+
204
+ let ok: boolean
205
+ try {
206
+ if (playlist.unsupported.length > 0 || items.length === 0) {
207
+ // Deliberately not loaded. The host is told why through `unsupported`,
208
+ // and the timeline keeps drawing — what it must not do is play
209
+ // something shorter than what it shows and call that working.
210
+ ok = Media3.setPlaylist({ items: [], unsupported: [] }) !== false
211
+ } else {
212
+ // Preserve project time, not window index. The window that held the
213
+ // playhead may not be the same window — or may not exist — after the
214
+ // edit.
215
+ const start = projectTimeToWindow(items, playheadRef.current) ?? {
216
+ windowIndex: 0,
217
+ positionUs: 0,
218
+ }
219
+ ok = Media3.setPlaylist(playlist, start) !== false
220
+ }
221
+ } catch {
222
+ // A refusal, not a crash. Recording it as applied is what left an editor
223
+ // waiting for an edit the user had already made.
224
+ ok = false
225
+ }
226
+ session.current = afterPrepare(session.current, intent, ok)
227
+ }, [signature, items, playlist, enabled, surfaceGeneration, retry])
228
+
229
+ /**
230
+ * Retry a refused preparation without waiting for anything else to move.
231
+ *
232
+ * A surface can arrive without its generation changing again, and a user who
233
+ * has added their clips will not add them a second time to prod it.
234
+ */
235
+ useEffect(() => {
236
+ if (!enabled) return
237
+ if (!session.current.pending) return
238
+ const id = setInterval(() => setRetry((n) => n + 1), RETRY_INTERVAL_MS)
239
+ return () => clearInterval(id)
240
+ }, [enabled, retry, surfaceGeneration, signature])
241
+
242
+ /*
243
+ This screen's ownership of the process-wide player.
244
+
245
+ Media3's player outlives any screen, so leaving the editor while playing
246
+ left the audio running and backgrounding the app did the same — the picture
247
+ stopped because its surface was retired, and nothing had told the player.
248
+ One lifecycle per hook instance: a screen mounted later starts from nothing
249
+ rather than inheriting what the last one was doing.
250
+ */
251
+ const stallRef = useRef<StallKind | null>(null)
252
+ const surfaceGenerationRef = useRef(surfaceGeneration ?? 0)
253
+ surfaceGenerationRef.current = surfaceGeneration ?? 0
254
+ const lifecycleRef = useRef<PlaybackLifecycle | null>(null)
255
+ if (lifecycleRef.current === null) {
256
+ lifecycleRef.current = createPlaybackLifecycle({
257
+ pause: () => Media3.pause(),
258
+ release: () => Media3.release(),
259
+ })
260
+ }
261
+ const lifecycle = lifecycleRef.current
262
+ /*
263
+ The epoch, made visible to React.
264
+
265
+ The polls capture an epoch when they start and compare on every tick, so
266
+ resuming has to restart them under the new one — otherwise a screen that
267
+ came back from the background would poll forever under an epoch that no
268
+ longer matches and never report a position again.
269
+ */
270
+ const [lifecycleEpoch, setLifecycleEpoch] = useState(lifecycle.epoch)
271
+
272
+ /*
273
+ Backgrounding pauses; returning re-arms.
274
+
275
+ Suspend rather than teardown: the picker comes up over the editor as a
276
+ background transition, and rebuilding a player the screen never lost would
277
+ be a stall nobody asked for. The document is not touched either way —
278
+ playback stopping says nothing about where the playhead should be.
279
+ */
280
+ useEffect(() => {
281
+ const subscription = AppState.addEventListener('change', (next) => {
282
+ if (next === 'active') {
283
+ lifecycle.resume()
284
+ setLifecycleEpoch(lifecycle.epoch)
285
+ return
286
+ }
287
+ lifecycle.suspend('background')
288
+ setLifecycleEpoch(lifecycle.epoch)
289
+ setPlaying(false)
290
+ })
291
+ return () => subscription.remove()
292
+ }, [lifecycle])
293
+
294
+ /*
295
+ The screen going is the end of its playback, exactly once.
296
+
297
+ Last of the effects declared here, so it is the first to be cleaned up: a
298
+ teardown that ran after the polls were cleared would release a player the
299
+ polls were still reading.
300
+ */
301
+ useEffect(() => {
302
+ return () => {
303
+ lifecycle.teardown('unmount')
304
+ }
305
+ }, [lifecycle])
306
+
307
+ /*
308
+ The watchdog for a surface that came back half-attached.
309
+
310
+ The failure it exists for reports nothing: after a background or a picker
311
+ the clock advances, audio plays, no error is raised and the picture is
312
+ black. A sample is kept from the last time the pipeline looked healthy, and
313
+ when the clock has moved for long enough without a frame being latched the
314
+ handshake is run again. `classifyStall` names which half is broken, because
315
+ re-preparing fixes a stale generation and does nothing for a consumer that
316
+ has stopped pulling.
317
+ */
318
+ const healthy = useRef<RecoverySample | null>(null)
319
+ const [stall, setStall] = useState<StallKind | null>(null)
320
+
321
+ const lastMeaning = useRef('')
322
+
323
+ useEffect(() => {
324
+ if (!enabled) return
325
+ // Captured now, checked on every tick: an interval is not cleared the
326
+ // instant the screen goes, and a poll that lands afterwards must not write
327
+ // a position into a screen that is no longer there.
328
+ const epoch = lifecycle.epoch
329
+ const id = setInterval(() => {
330
+ if (!lifecycle.accepts(epoch)) return
331
+ const snapshot = Media3.state()
332
+ // Position is not part of this. Replacing the state object on every tick
333
+ // re-renders the whole editor ten times a second to move a line, which is
334
+ // both wasteful and — because it lands at ten hertz — visibly steppy. The
335
+ // meaning of the state changes far less often than its numbers do, so
336
+ // only a change in meaning is worth a render, and a host that wants the
337
+ // position drives it from `positionUs()` on the UI thread.
338
+ const meaning = [
339
+ snapshot.playing,
340
+ snapshot.ready,
341
+ snapshot.buffering,
342
+ snapshot.ended,
343
+ snapshot.released,
344
+ snapshot.width,
345
+ snapshot.height,
346
+ snapshot.windowIndex,
347
+ snapshot.windowCount,
348
+ snapshot.errorCount,
349
+ snapshot.errorMessage,
350
+ snapshot.rateConstrained,
351
+ ].join('|')
352
+ if (meaning !== lastMeaning.current) {
353
+ lastMeaning.current = meaning
354
+ setState(snapshot)
355
+ setPlaying(snapshot.playing)
356
+ }
357
+ const sample: RecoverySample = {
358
+ atMs: Date.now(),
359
+ positionUs: snapshot.windowPositionUs,
360
+ framesOffered: snapshot.framesOffered,
361
+ framesAvailable: snapshot.framesAvailable,
362
+ // Latched is not reported directly: what arrived, less what is still
363
+ // waiting, is the count the consumer has actually taken.
364
+ framesLatched: snapshot.framesAvailable - snapshot.pendingDepth,
365
+ framesRejected: snapshot.framesRejected,
366
+ pendingGeneration: snapshot.pendingGeneration,
367
+ consumerGeneration: surfaceGenerationRef.current,
368
+ }
369
+ const since = healthy.current
370
+ if (since === null) {
371
+ healthy.current = sample
372
+ } else if (classifyStall(since, sample) === null) {
373
+ // Healthy, or simply not running: either way this is the new baseline.
374
+ healthy.current = sample
375
+ if (stallRef.current !== null) setStall(null)
376
+ } else if (shouldRecover(since, sample)) {
377
+ const kind = classifyStall(since, sample)
378
+ healthy.current = sample
379
+ stallRef.current = kind
380
+ setStall(kind)
381
+ // Re-run the surface/playlist handshake. Never silently: a plausible
382
+ // audio-only result is the thing this refuses to leave alone.
383
+ session.current = { ...session.current, pending: true }
384
+ setRetry((n) => n + 1)
385
+ }
386
+
387
+ if (!snapshot.playing) return
388
+ const projectUs = windowToProjectTime(
389
+ itemsRef.current,
390
+ snapshot.windowIndex,
391
+ snapshot.windowPositionUs,
392
+ )
393
+ if (projectUs !== null) options.onPositionUs?.(projectUs)
394
+ }, pollMs)
395
+ return () => clearInterval(id)
396
+ // `onPositionUs` is read through the closure on each tick; depending on it
397
+ // would restart the interval whenever the host re-created the callback.
398
+ // eslint-disable-next-line react-hooks/exhaustive-deps
399
+ }, [enabled, pollMs, lifecycle, lifecycleEpoch])
400
+
401
+ const lastSeekRef = useRef<Media3Playback['lastSeek']>(null)
402
+
403
+ /**
404
+ * Seek in **global** timeline time.
405
+ *
406
+ * The store's playhead is global and stays global; the conversion to a window
407
+ * and a position inside it happens here, once, through `projectTimeToWindow` —
408
+ * the same mapping `play` and the playlist load use. Handing a player a global
409
+ * time as though it were window-local is silent: it clamps to the end of
410
+ * whichever clip it is in and reports a perfectly plausible position.
411
+ */
412
+ const seek = useCallback((timeUs: number) => {
413
+ const target = projectTimeToWindow(itemsRef.current, timeUs)
414
+ if (!target) return
415
+ lastSeekRef.current = {
416
+ globalUs: Math.round(timeUs),
417
+ windowIndex: target.windowIndex,
418
+ localUs: target.positionUs,
419
+ }
420
+ Media3.seekToWindow(target.windowIndex, target.positionUs)
421
+ }, [])
422
+
423
+ const positionUs = useCallback((): number | null => {
424
+ const snapshot = Media3.state()
425
+ if (snapshot.released || itemsRef.current.length === 0) return null
426
+ return windowToProjectTime(
427
+ itemsRef.current,
428
+ snapshot.windowIndex,
429
+ snapshot.windowPositionUs,
430
+ )
431
+ }, [])
432
+
433
+ const durationUsRef = useRef(0)
434
+ durationUsRef.current = projectDuration(project)
435
+
436
+ /**
437
+ * Play, replaying from the start if there is nothing left to play.
438
+ *
439
+ * The decision is `playIntent` in `@pictor-sdk/video-core`, not an `if` here: it
440
+ * is the same question a host's own transport has to answer, and two answers
441
+ * would eventually disagree. Read fresh from the player rather than from
442
+ * `state`, which only re-renders on a change of meaning and can be a poll
443
+ * behind at the moment of the press.
444
+ */
445
+ const play = useCallback(() => {
446
+ const snapshot = Media3.state()
447
+ const intent = playIntent({
448
+ positionUs:
449
+ windowToProjectTime(
450
+ itemsRef.current,
451
+ snapshot.windowIndex,
452
+ snapshot.windowPositionUs,
453
+ ) ?? 0,
454
+ durationUs: durationUsRef.current,
455
+ playbackState: snapshot.playbackState,
456
+ ended: snapshot.ended,
457
+ })
458
+ if (intent.seekToUs !== null) {
459
+ const target = projectTimeToWindow(itemsRef.current, intent.seekToUs)
460
+ if (target) Media3.seekToWindow(target.windowIndex, target.positionUs)
461
+ }
462
+ if (!intent.play) return intent
463
+ // Refused once the screen has given the player back: a late press from a
464
+ // control that has not unmounted yet must not start a released player.
465
+ if (lifecycle.released) return intent
466
+ Media3.play()
467
+ setPlaying(true)
468
+ return intent
469
+ }, [lifecycle])
470
+
471
+ return {
472
+ playing,
473
+ play,
474
+ get lastSeek() {
475
+ return lastSeekRef.current
476
+ },
477
+ positionUs,
478
+ effectiveRate: state?.effectiveRate ?? 1,
479
+ pause: useCallback(() => {
480
+ Media3.pause()
481
+ setPlaying(false)
482
+ }, []),
483
+ setRate: useCallback((rate: number) => Media3.setRate(rate), []),
484
+ seek,
485
+ state,
486
+ items,
487
+ unsupported: playlist.unsupported,
488
+ divergenceUs,
489
+ lifecycle,
490
+ stall,
491
+ }
492
+ }