@pictor-sdk/video-android 0.1.3 → 0.1.5

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pictor-sdk/video-android",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "description": "Optional Android video playback and export adapter for Pictor. Requires Android API 29+.",
5
5
  "license": "MIT",
6
6
  "main": "./dist/index.js",
@@ -34,6 +34,10 @@ export interface CompositionSpikeItem {
34
34
  /** Clipping bounds in **source** time, which is where speed is applied. */
35
35
  startUs: number
36
36
  endUs: number
37
+ /** Full source duration, before trimming or speed. Omit only for legacy callers. */
38
+ sourceDurationUs?: number
39
+ /** Exclusive project-time end from the playlist compiler, after trim and speed. */
40
+ timelineEndUs?: number
37
41
  speed: number
38
42
  }
39
43
 
@@ -122,6 +126,8 @@ export interface CompositionSpikeState {
122
126
  playbackState?: number
123
127
  durationUs?: number
124
128
  presentedUs?: number | null
129
+ /** Playlist window belonging to the latched frame, not the current playhead. */
130
+ presentedWindowIndex?: number
125
131
  /** How old the presented frame was when it was read, so a stalled sample can be excluded. */
126
132
  presentedAgeNs?: number
127
133
  /** How long the paired read itself took. Bounds the pairing error. */
@@ -148,6 +154,7 @@ export function compositionSpikeSpec(
148
154
  const playlist = playlistOf(project)
149
155
  const items: CompositionSpikeItem[] = []
150
156
  const unsupported = playlist.unsupported.map((u) => `${u.clipId}: ${u.reason}`)
157
+ const clips = new Map(project.clips.map((clip) => [clip.id, clip]))
151
158
  for (const item of playlist.items) {
152
159
  const uri = resolve(item.source as unknown as { kind: string; uri?: string })
153
160
  if (uri === null) {
@@ -158,6 +165,8 @@ export function compositionSpikeSpec(
158
165
  uri,
159
166
  startUs: Number(item.startUs),
160
167
  endUs: Number(item.endUs),
168
+ sourceDurationUs: clips.get(item.clipId)?.metadata.duration,
169
+ timelineEndUs: Number(item.timelineEndUs),
161
170
  speed: item.speed,
162
171
  })
163
172
  }
@@ -1,4 +1,5 @@
1
1
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
2
+ import { AppState } from 'react-native'
2
3
  import {
3
4
  playIntent,
4
5
  playlistOf,
@@ -213,13 +214,66 @@ export function useCompositionPlayback(
213
214
  setUnsupported(
214
215
  refused.map((reason) => ({ clipId: reason.split(':')[0] ?? '', reason }) as UnsupportedSource),
215
216
  )
216
- if (spec.items.length === 0) return
217
+ /*
218
+ A project with nothing in it is a reconciliation too.
219
+
220
+ This used to return here, and returning is not nothing: the player is a
221
+ process-wide singleton that goes on playing whatever it last loaded. So
222
+ removing the only clip *while it was playing* left the removed clip
223
+ audible until it reached its own end — measured on a Pixel 7 as roughly
224
+ four more seconds of a six-second clip, three times out of three — with
225
+ the picture already cleared and the clock already reading 0:00.000,
226
+ because those follow the document and only the player does not.
227
+
228
+ Pausing is the whole fix. The composition is deliberately **not**
229
+ released: emptying a timeline is usually a step on the way to putting
230
+ something else in it, and `loaded.current` is left alone so that undoing
231
+ the removal reuses the composition already loaded instead of rebuilding
232
+ one that has not changed. Releasing is what `PlaybackLifecycle` does when
233
+ the *screen* goes, which is a different question from what the document
234
+ currently holds.
235
+
236
+ Nothing here touches the document. Playback stopping says nothing about
237
+ where the playhead should be.
238
+ */
239
+ if (spec.items.length === 0) {
240
+ spikePause()
241
+ return
242
+ }
217
243
  const started = spikeStart(spec) as { ok?: boolean }
218
244
  // A refusal is not a load. Recording it as one is what left an editor
219
245
  // waiting for an edit the person had already made.
220
246
  if (started.ok === true) loaded.current = signature
221
247
  }, [enabled, signature, surfaceGeneration, project, mix])
222
248
 
249
+ /*
250
+ Backgrounding pauses; returning re-arms.
251
+
252
+ The same wiring `useMedia3Playback` has, and its absence here was the same
253
+ bug `PlaybackLifecycle` was written to fix, reintroduced by a newer playback
254
+ path that never got it. `useVideoSurface` already retires the presentation
255
+ surface when the app leaves, so the picture stopped and the sound carried
256
+ on — measured on a Pixel 7 as a composition still reporting `playing` with
257
+ its position advancing from 1.3 s to 4.4 s across a press of Home.
258
+
259
+ Suspend rather than teardown: the picker comes up over the editor as a
260
+ background transition, and rebuilding a player the screen never lost would
261
+ be a stall nobody asked for. The document is not touched either way.
262
+ */
263
+ useEffect(() => {
264
+ const subscription = AppState.addEventListener('change', (next) => {
265
+ if (next === 'active') {
266
+ lifecycle.resume()
267
+ setLifecycleEpoch(lifecycle.epoch)
268
+ return
269
+ }
270
+ lifecycle.suspend('background')
271
+ setLifecycleEpoch(lifecycle.epoch)
272
+ setPlaying(false)
273
+ })
274
+ return () => subscription.remove()
275
+ }, [lifecycle])
276
+
223
277
  useEffect(() => {
224
278
  return () => {
225
279
  lifecycle.teardown('unmount')