@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,978 @@
1
+ package dev.pictor.video
2
+
3
+ import android.content.Context
4
+ import android.os.Handler
5
+ import android.os.Looper
6
+ import androidx.media3.common.MediaItem
7
+ import androidx.media3.common.Player
8
+ import androidx.media3.common.audio.SpeedProvider
9
+ import androidx.media3.common.util.Size
10
+ import androidx.media3.transformer.Composition
11
+ import androidx.media3.transformer.CompositionPlayer
12
+ import androidx.media3.transformer.EditedMediaItem
13
+ import androidx.media3.transformer.EditedMediaItemSequence
14
+ import androidx.media3.transformer.Effects
15
+ import org.json.JSONArray
16
+ import org.json.JSONObject
17
+
18
+ /**
19
+ * One playback clock for the picture and the prepared mix.
20
+ *
21
+ * ## Why this exists
22
+ *
23
+ * The two-player preview cannot meet the 60 ms gate. Attribution said why, and
24
+ * none of it is tuneable: the two transports are compared through anchors
25
+ * polled at 50 ms, so a single observation carries up to ±56 ms of uncertainty
26
+ * against a 60 ms tolerance; the correction loop runs at 500 ms and each
27
+ * corrective seek costs 200-400 ms, so the loop's own latency exceeds what it
28
+ * is holding; and at a seam the project clock divides the local position by a
29
+ * speed the player has not applied for about 450 ms.
30
+ *
31
+ * ## The contract that makes this possible
32
+ *
33
+ * Verified against media3 1.9.4 as installed, not against a summary of it:
34
+ *
35
+ * - `Renderer.render(positionUs, ...)` takes **renderer time** — "a
36
+ * continuously increasing value across all input sample streams" — so a
37
+ * project-wide audio stream has a coherent axis to sit on.
38
+ * - `DefaultMediaClock` allows exactly one renderer to provide the clock
39
+ * ("Multiple renderer media clocks enabled" is an error), and
40
+ * `setPlaybackParameters` asks that clock first and takes back whatever it
41
+ * accepts. So the clock owner really is authoritative, and a clip's speed
42
+ * cannot be expressed as a global `PlaybackParameters` without dragging the
43
+ * mix along with it.
44
+ * - Therefore the per-clip speed has to live in the *source* mapping, not in
45
+ * the clock. `Effects.createExperimentalSpeedChangingEffect` returns exactly
46
+ * that pair — an audio processor and a video timestamp adjustment — and
47
+ * `CompositionPlayer` is documented to accept speed effects only from it.
48
+ *
49
+ * `CompositionPlayer` builds one `ExoPlayer` per sequence but hands them a
50
+ * single `Clock`, one playback thread, and one `PlaybackAudioGraphWrapper` that
51
+ * mixes every sequence into one sink. That is the difference from the current
52
+ * design: not "two clocks compared often" but one clock, with the picture timed
53
+ * against the audio that is actually being played.
54
+ *
55
+ * ## What this deliberately is not
56
+ *
57
+ * A spike, behind [Diagnostics]. It does not replace the preview, it does not
58
+ * touch preparation or export, and it claims nothing about live PCM gain or
59
+ * fades — those are a separate capability and the mix it plays is the same
60
+ * prepared file the current preview uses.
61
+ *
62
+ * Every timing value comes from the shared compiler: the host passes
63
+ * `playlistOf()`'s items verbatim and this maps them onto media items. There is
64
+ * no second interpretation of the project here, which is the invariant that
65
+ * made the relaxation in the first place tolerable.
66
+ */
67
+ /*
68
+ The preview's transport, and no longer only a spike.
69
+
70
+ Built as one, measured as one, and accepted for v1 by watching it: the object
71
+ keeps its name because the native bridge method names are generated from the
72
+ TurboModule spec and renaming them is churn without a reader — the editor
73
+ reaches this through `useCompositionPlayback` and never sees the word. The
74
+ rename is a tracked follow-up.
75
+
76
+ Transport and ownership — `start`, `claim`, `play`, `pause`, `seek`,
77
+ `release` — are production paths and are no longer gated on diagnostics. The
78
+ measurement surface still is: `drain` returns nothing without diagnostics,
79
+ because a release build has no reason to retain per-frame samples.
80
+ */
81
+ internal object CompositionSpike {
82
+
83
+ /** A constant speed for one clip. The compiler decided it; this carries it. */
84
+ private class ConstantSpeed(private val speed: Float) : SpeedProvider {
85
+ override fun getSpeed(timeUs: Long): Float = speed
86
+
87
+ /*
88
+ No further changes: a clip's speed is constant across the clip.
89
+
90
+ `C.TIME_UNSET` and not `Long.MIN_VALUE` — they differ by one and the
91
+ difference is not cosmetic. `SpeedProviderUtil` walks the speed
92
+ changes to compute the item's presentation duration, and
93
+ `Long.MIN_VALUE` failed its `checkState` with a bare
94
+ `IllegalStateException` naming nothing.
95
+ */
96
+ override fun getNextSpeedChangeTimeUs(timeUs: Long): Long =
97
+ androidx.media3.common.C.TIME_UNSET
98
+ }
99
+
100
+ private val main = Handler(Looper.getMainLooper())
101
+
102
+ @Volatile private var player: CompositionPlayer? = null
103
+ @Volatile private var lastError: String? = null
104
+ @Volatile private var startedAtNs = 0L
105
+ @Volatile private var readyAtNs = 0L
106
+ @Volatile private var seekStartedNs = 0L
107
+ @Volatile private var seekReadyNs = 0L
108
+
109
+ /**
110
+ * One reading of both clocks, taken together on the player's own thread.
111
+ *
112
+ * `CompositionPlayer` must be accessed from a single application thread —
113
+ * reading its position from the JS thread throws "Player is accessed on the
114
+ * wrong thread" — so the pairing is made here and published as a snapshot.
115
+ *
116
+ * This is polling, and it is not the polling the old design failed on.
117
+ * There, two independently-pumped anchors were subtracted, so the error was
118
+ * the sum of two staleness windows and could reach ±56 ms. Here both values
119
+ * are read in the same statement on the same thread, so the *pairing* is
120
+ * exact; the interval only affects how fresh the answer is, and the frame's
121
+ * own age is carried so a stale one can be excluded rather than averaged.
122
+ */
123
+ /**
124
+ * Which presentation boundary this measures.
125
+ *
126
+ * Three are observable in-process and they are not the same instant:
127
+ *
128
+ * - `METADATA_NS` Media3 says a frame is about to be rendered.
129
+ * - `RELEASE_NS` the time it *asks* the surface to present at. An
130
+ * intention, not an acknowledgement.
131
+ * - `CONSUMED_NS` the consumer called `updateTexImage` and latched it.
132
+ *
133
+ * The offset is computed from `CONSUMED_NS`, because it is the latest
134
+ * boundary this process can witness rather than request. Actual scanout is
135
+ * one compositor pass later and is **not observed** here: there is no
136
+ * present fence or `onFrameCommit` in this path, so nothing in these
137
+ * numbers proves when the panel lit up. `RELEASE_NS` is retained beside it
138
+ * so the gap between intended and latched is visible rather than assumed
139
+ * to be zero.
140
+ */
141
+ private class Pairing(
142
+ val atNs: Long,
143
+ val positionUs: Long,
144
+ val playing: Boolean,
145
+ val playbackState: Int,
146
+ val durationUs: Long,
147
+ val presentedUs: Long,
148
+ val presentedAgeNs: Long,
149
+ /** How long the read itself took, which bounds the pairing error. */
150
+ val readSpanNs: Long,
151
+ )
152
+
153
+ @Volatile private var pairing: Pairing? = null
154
+
155
+ /**
156
+ * A transition epoch, bumped by start and every seek.
157
+ *
158
+ * A frame carried over from before a seek is not a late frame, it is a
159
+ * frame about a position nobody is at. Pairing one with the clock after the
160
+ * seek produces an excursion the size of the seek, and no amount of
161
+ * averaging tells that apart from a real one.
162
+ */
163
+ @Volatile private var epoch = 0L
164
+ @Volatile private var epochAtNs = 0L
165
+
166
+ /**
167
+ * The newest preparation anybody has claimed.
168
+ *
169
+ * A preparation that finishes after a newer one was asked for describes a
170
+ * document that no longer exists. Refusing it here, at the moment of
171
+ * attachment, is the only place that can be certain: the host cannot know
172
+ * whether a promise it is still holding was overtaken, and a mix that
173
+ * attaches for a beat before being replaced is a beat of the wrong sound.
174
+ */
175
+ private val claimed = java.util.concurrent.atomic.AtomicLong(0)
176
+
177
+ /** Claim the next request id. The host does this before it starts preparing. */
178
+ fun claim(): String {
179
+ return JSONObject().put("ok", true).put("requestId", claimed.incrementAndGet()).toString()
180
+ }
181
+
182
+ /** Attachments refused because a newer request had already been claimed. */
183
+ @Volatile var superseded = 0L
184
+ private set
185
+
186
+ /*
187
+ Playback readiness, as transport behaviour rather than as a label.
188
+
189
+ Marking an interval "preparing" in a report while the audio keeps playing
190
+ describes the defect instead of fixing it. So the transport is actually
191
+ held: `intentPlaying` is what the person asked for, and the player is only
192
+ started once a frame belonging to the current epoch has been latched.
193
+
194
+ The decoder still runs while the hold is on — the player is paused, not
195
+ stopped, and Media3 renders the first frame after a seek even when it is
196
+ not playing. That is what keeps this from deadlocking: the condition the
197
+ hold waits for is produced by work the hold does not prevent.
198
+ */
199
+ @Volatile var intentPlaying = false
200
+ private set
201
+
202
+ @Volatile private var releasePending = false
203
+ @Volatile private var releaseArmedNs = 0L
204
+ /** Where this epoch is meant to be, in composition time. */
205
+ @Volatile private var epochTargetUs = 0L
206
+ @Volatile private var releasedAtNs = 0L
207
+ @Volatile private var readinessFailure: String? = null
208
+
209
+ /** Holds that ended in a release, and holds that timed out. */
210
+ @Volatile var releases = 0L
211
+ private set
212
+
213
+ @Volatile var readinessTimeouts = 0L
214
+ private set
215
+
216
+ /** How long a hold may wait before it is reported as a failure. */
217
+ private const val READINESS_TIMEOUT_NS = 3_000_000_000L
218
+
219
+ /**
220
+ * The clip boundaries, in composition time, and the hold that covers them.
221
+ *
222
+ * ## Why a hold at all
223
+ *
224
+ * Measured on a Pixel 7: at each boundary the video decoder is released and
225
+ * a new one allocated and initialised — 126 ms from `videoDecoderReleased`
226
+ * at mediaPos 9.90 to `videoDecoderInitialized` at 10.02 — and nothing
227
+ * reaches the renderer's output for 119 to 168 ms. `videoPrewarmingEnabled`
228
+ * is on by default, `createSecondaryRenderer` is gated on it, the internal
229
+ * ExoPlayer enables dynamic scheduling, and two decoders are created at
230
+ * startup, so prewarming is configured and running. It still does not carry
231
+ * this boundary, and the boundary has no format excuse: the two clips are
232
+ * both H.264 High, 1080x1920, yuv420p, level 40, 30 fps.
233
+ *
234
+ * So the clock waits instead of the picture falling behind it. **This is a
235
+ * playback pause, not seamless playback**, and its wall-clock duration is
236
+ * reported separately from synchronisation for exactly that reason.
237
+ *
238
+ * ## Armed before the boundary, not after it
239
+ *
240
+ * Noticing the missing frame afterwards is the reactive delay this
241
+ * replaces. The hold arms when the position crosses `seam - SEAM_ARM_LEAD`,
242
+ * which pauses the shared clock while the player goes on preparing.
243
+ */
244
+ @Volatile private var seamTargetsUs: LongArray = LongArray(0)
245
+
246
+ /**
247
+ * Off, because the hold does not work on this player.
248
+ *
249
+ * Measured twice, at both arming positions. Pausing at `seam - 250 ms` and
250
+ * pausing exactly on the boundary both ran to the 2 s bound and reported a
251
+ * timeout: with `playWhenReady` false `CompositionPlayer` does not advance
252
+ * across an item boundary, so the incoming frame the hold waits for is
253
+ * never produced. Holding the clock and waiting for the new stream are
254
+ * mutually exclusive here.
255
+ *
256
+ * The mechanism is kept, disabled, with its bound and its cancellation
257
+ * intact — the negative result is worth more than the deleted code. Set
258
+ * `"seamHold": true` in the spec to run it.
259
+ *
260
+ * **Diagnostics only, and not a candidate for production.** Both variants
261
+ * were measured and neither meets the gate: pausing alone deadlocks, and
262
+ * the seek variant prepares but leaves the run worse than doing nothing.
263
+ * It is preserved as evidence of what was tried, not as an alternative
264
+ * waiting for a different player.
265
+ */
266
+ @Volatile private var seamHoldEnabled = false
267
+
268
+ /**
269
+ * Prepare the incoming item with an explicit seek, rather than only pausing.
270
+ *
271
+ * Pausing natural traversal and explicitly preparing a seek target are not
272
+ * the same operation. `CompositionPlayer.handleSeek` calls
273
+ * `CompositionPlayerInternal.startSeek`, then `ExoPlayer.seekTo` on each
274
+ * sequence, then resets the packet release control and releases the
275
+ * aggregator's frames before `endSeek` — and `ExoPlayer.seekTo` resets the
276
+ * renderers' positions, which is the path that makes a paused seek produce
277
+ * a frame. It is the same path the existing seek readiness already uses and
278
+ * passes on.
279
+ *
280
+ * So the seam is held by seeking to the incoming item's own compiled start,
281
+ * which is where the clock already is: no timeline time is skipped, and the
282
+ * retained picture keeps the outgoing frame on screen while it happens.
283
+ *
284
+ * It works, and it is worse. Measured over both boundaries: two holds of
285
+ * 241 ms and 180 ms, no timeouts, released on verified incoming frames — so
286
+ * paused preparation is real. But the boundary's output gap grew from 156
287
+ * and 119 ms to 330 and 262 ms, the seek itself triggering further decoder
288
+ * initialisations, and the submission gate went from one excursion at 67 ms
289
+ * to two at 208.6 ms. The hold covers less of the gap than it adds.
290
+ */
291
+ @Volatile private var seamSeekEnabled = false
292
+
293
+ /** Seams already held for, so a hold arms once per boundary per pass. */
294
+ private val seamsHeld = java.util.concurrent.ConcurrentHashMap<Long, Boolean>()
295
+
296
+ @Volatile private var seamHoldTargetUs = -1L
297
+ @Volatile private var seamHoldStartedNs = 0L
298
+
299
+ /**
300
+ * The boundary this pass has committed to holding at, before reaching it.
301
+ *
302
+ * Two phases, because one does not work. Pausing at `seam - lead` was tried
303
+ * and deadlocked: with the clock stopped short of the boundary the player
304
+ * never crosses it, so the frame the hold is waiting for is never produced,
305
+ * and the hold ran to its 2 s bound and reported a timeout — which is the
306
+ * bound doing its job, and a negative result.
307
+ *
308
+ * So the decision is made early and the pause lands on the boundary. The
309
+ * transition starts, the decoder is torn down and rebuilt, and what waits
310
+ * is the clock rather than the picture's place in it.
311
+ */
312
+ @Volatile private var seamArmedUs = -1L
313
+
314
+ /** Completed holds, as wall-clock milliseconds. Reported, never averaged in. */
315
+ private val seamHoldMs = java.util.concurrent.CopyOnWriteArrayList<Long>()
316
+
317
+ @Volatile var seamHoldTimeouts = 0L
318
+ private set
319
+
320
+ /**
321
+ * How far before a boundary the hold arms.
322
+ *
323
+ * Longer than the measured 168 ms worst gap, so the pause begins before the
324
+ * preparation it is covering rather than inside it.
325
+ */
326
+ private const val SEAM_ARM_LEAD_US = 250_000L
327
+
328
+ /** A hold that has not resolved by here is reported, never released blind. */
329
+ private const val SEAM_HOLD_TIMEOUT_NS = 2_000_000_000L
330
+
331
+ /** Declare the boundaries this composition will cross. */
332
+ fun setSeamTargets(targets: LongArray) {
333
+ seamTargetsUs = targets
334
+ seamsHeld.clear()
335
+ seamHoldTargetUs = -1
336
+ seamArmedUs = -1
337
+ seamHoldMs.clear()
338
+ }
339
+
340
+ /** Drop any hold in flight. Pause, seek, background and teardown all do. */
341
+ private fun cancelSeamHold() {
342
+ seamHoldTargetUs = -1
343
+ seamArmedUs = -1
344
+ }
345
+
346
+ /**
347
+ * Arm a hold for the epoch that just began.
348
+ *
349
+ * Start and seek both enter preparing. The intent is untouched: a hold is
350
+ * about *when* playback may run, never about whether the person asked for
351
+ * it.
352
+ */
353
+ private fun armRelease(targetUs: Long) {
354
+ // Preparing is not streaming: the first frame for this position has to
355
+ // be latched as soon as it exists, not paced against a schedule that
356
+ // belongs to the position being left.
357
+ FrameClock.setStreaming(false)
358
+ releasePending = true
359
+ epochTargetUs = targetUs
360
+ releaseArmedNs = System.nanoTime()
361
+ releasedAtNs = 0
362
+ readinessFailure = null
363
+ }
364
+
365
+ /**
366
+ * Whether a frame for the current epoch has actually been latched.
367
+ *
368
+ * Three things, not one. The frame must belong to this epoch — latched at
369
+ * or after the epoch began — it must carry a usable project timestamp, and
370
+ * it must have been latched rather than merely offered: `CONSUMED_NS` is
371
+ * set by the consumer after `updateTexImage` returned a new timestamp.
372
+ */
373
+ private fun firstFrameReady(): Boolean {
374
+ val frame = FrameClock.latestSample() ?: return false
375
+ // Latched, not merely offered: the consumer sets this after
376
+ // `updateTexImage` returned a new timestamp.
377
+ if (frame[FrameClock.Field.CONSUMED_NS] <= 0L) return false
378
+ /*
379
+ The pixels and the timestamp must be the same frame.
380
+
381
+ Readiness releases transport on the claim "this frame is the one we
382
+ asked for", and a fallback pairing cannot support it: the timestamp is
383
+ the claimed metadata and the pixels are whatever was latched. Starting
384
+ on one is how playback showed frame 7 at frame 2's instant.
385
+ */
386
+ if (frame[FrameClock.Field.ASSOCIATION] != 1L) return false
387
+ val pts = frame[FrameClock.Field.PTS_US]
388
+ if (pts < 0L) return false
389
+ /*
390
+ Matched by position, not by arrival order.
391
+
392
+ Requiring a frame latched *after* the button press deadlocked at
393
+ startup: the player renders its first frame when it becomes ready,
394
+ which is before anyone presses play, so the condition could never be
395
+ met and three of four transitions timed out at 3 s. What the gate
396
+ actually needs is a frame showing *this* epoch's position — the same
397
+ test for a start, a seek and a resume.
398
+ */
399
+ /*
400
+ A seam hold wants the frame *after* the boundary, exactly.
401
+
402
+ The ordinary backward tolerance exists because a seek lands on the
403
+ preceding sample. At a seam the target is an exact known boundary, and
404
+ the outgoing clip's last frame sits one interval before it — inside
405
+ that tolerance. Releasing on it would end the hold on the picture the
406
+ hold exists to move past.
407
+ */
408
+ if (seamHoldTargetUs >= 0) return pts >= seamHoldTargetUs
409
+
410
+ return pts >= epochTargetUs - FRAME_MATCH_BACK_US &&
411
+ pts <= epochTargetUs + FRAME_MATCH_AHEAD_US
412
+ }
413
+
414
+ /**
415
+ * How far a frame may sit from the epoch's target and still be its first.
416
+ *
417
+ * Backwards by about a frame, because a seek lands on the preceding
418
+ * keyframe-derived sample; forwards by a second, because the first frame
419
+ * after a seek into a long GOP can legitimately be later than asked for.
420
+ */
421
+ private const val FRAME_MATCH_BACK_US = 40_000L
422
+ private const val FRAME_MATCH_AHEAD_US = 1_000_000L
423
+
424
+ /** Retained evidence, so an excursion can be looked at rather than inferred. */
425
+ private const val RING = 8192
426
+ private val ring = arrayOfNulls<LongArray>(RING)
427
+ private val written = java.util.concurrent.atomic.AtomicLong(0)
428
+
429
+ /** One retained sample. Packed: a gate reads thousands of these. */
430
+ private fun record(p: Pairing, frame: LongArray?, pipeline: LongArray) {
431
+ val seq = written.getAndIncrement()
432
+ val row = LongArray(19)
433
+ row[0] = seq
434
+ row[1] = p.atNs
435
+ row[2] = p.positionUs
436
+ row[3] = (if (p.playing) 1L else 0L) or (if (p.playbackState == Player.STATE_READY) 2L else 0L)
437
+ row[4] = epoch
438
+ row[5] = p.atNs - epochAtNs
439
+ row[6] = frame?.get(FrameClock.Field.ID) ?: -1
440
+ row[7] = frame?.get(FrameClock.Field.GENERATION) ?: -1
441
+ row[8] = frame?.get(FrameClock.Field.WINDOW_INDEX) ?: -1
442
+ row[9] = frame?.get(FrameClock.Field.PTS_US) ?: -1
443
+ row[10] = frame?.get(FrameClock.Field.RELEASE_NS) ?: -1
444
+ row[11] = frame?.get(FrameClock.Field.CONSUMED_NS) ?: -1
445
+ row[12] = frame?.get(FrameClock.Field.AVAILABLE_NS) ?: -1
446
+ row[13] = frame?.get(FrameClock.Field.COALESCED) ?: -1
447
+ row[14] = pipeline[0]
448
+ row[15] = pipeline[1]
449
+ row[16] = pipeline[2]
450
+ row[17] = p.readSpanNs
451
+ /*
452
+ The earliest boundary, and the one that decides the seam.
453
+
454
+ `METADATA_NS` is when Media3's renderer said a frame was about to go
455
+ to the surface. A gap that ends here produced nothing at all — the
456
+ decoder or the item's preparation. A gap between here and
457
+ `AVAILABLE_NS` is the video graph queueing it. Between `AVAILABLE_NS`
458
+ and `CONSUMED_NS` is this consumer. Three different owners, and
459
+ without this stamp the first two are one interval.
460
+ */
461
+ row[18] = frame?.get(FrameClock.Field.METADATA_NS) ?: -1
462
+ ring[(seq % RING).toInt()] = row
463
+ }
464
+
465
+ /** Retained samples from [fromSeq]. @return `{ok, rows, nextSeq, total, dropped}` */
466
+ fun drain(fromSeq: Long, limit: Int): String {
467
+ if (!Diagnostics.ENABLED) return Diagnostics.unavailable()
468
+ val total = written.get()
469
+ val oldest = maxOf(0L, total - RING)
470
+ // A gap is reported, never closed silently: a window that quietly lost
471
+ // its middle reads exactly like one that had nothing there.
472
+ val dropped = maxOf(0L, oldest - fromSeq)
473
+ var seq = maxOf(fromSeq, oldest)
474
+ val out = JSONArray()
475
+ while (seq < total && out.length() < limit) {
476
+ ring[(seq % RING).toInt()]?.let { row ->
477
+ if (row[0] == seq) out.put(JSONArray().also { a -> row.forEach { a.put(it) } })
478
+ }
479
+ seq += 1
480
+ }
481
+ return JSONObject()
482
+ .put("ok", true).put("rows", out).put("nextSeq", seq)
483
+ .put("total", total).put("dropped", dropped)
484
+ .toString()
485
+ }
486
+
487
+ private val pump = object : Runnable {
488
+ override fun run() {
489
+ val p = player ?: return
490
+ /*
491
+ The release decision runs here, on the player's own thread.
492
+
493
+ Same place the clocks are read, so the condition and the action
494
+ cannot be separated by a thread hop — and a newer seek that
495
+ re-arms between them would be acting on a hold that no longer
496
+ exists.
497
+ */
498
+ /*
499
+ Arm the seam hold before the boundary, on the player's own thread.
500
+
501
+ Same thread that reads the clock and issues the transport call, so
502
+ the crossing and the pause cannot be separated by a hop. A hold
503
+ already in flight, or a transport the person has paused, arms
504
+ nothing.
505
+ */
506
+ if (seamHoldEnabled && seamHoldTargetUs < 0 && releasePending.not() && intentPlaying && p.isPlaying) {
507
+ val positionUs = p.currentPosition * 1000
508
+ if (seamArmedUs < 0) {
509
+ for (seam in seamTargetsUs) {
510
+ if (seamsHeld.containsKey(seam)) continue
511
+ if (positionUs < seam - SEAM_ARM_LEAD_US || positionUs >= seam) continue
512
+ seamsHeld[seam] = true
513
+ seamArmedUs = seam
514
+ PreviewTrace.record("spike.seamArm") {
515
+ JSONObject().put("seamUs", seam).put("atUs", positionUs)
516
+ }
517
+ break
518
+ }
519
+ }
520
+ if (seamArmedUs >= 0 && positionUs >= seamArmedUs) {
521
+ /*
522
+ On the boundary, not before it. The clock stops here and
523
+ the outgoing picture stays on screen because nothing
524
+ replaces it; the transition is already under way.
525
+ */
526
+ seamHoldTargetUs = seamArmedUs
527
+ seamHoldStartedNs = System.nanoTime()
528
+ seamArmedUs = -1
529
+ runCatching { p.pause() }
530
+ armRelease(seamHoldTargetUs)
531
+ if (seamSeekEnabled) {
532
+ /*
533
+ The same seek the readiness gate already survives, to
534
+ the position the clock is already at. Frames in flight
535
+ belong to the outgoing item, so the epoch moves with
536
+ it; the picture on screen is the ring's retained one
537
+ and is not disturbed.
538
+ */
539
+ epoch += 1
540
+ epochAtNs = System.nanoTime()
541
+ FrameClock.bump()
542
+ runCatching { p.seekTo(seamHoldTargetUs / 1000) }
543
+ }
544
+ PreviewTrace.record("spike.seamHold") {
545
+ JSONObject().put("seamUs", seamHoldTargetUs).put("atUs", positionUs)
546
+ }
547
+ }
548
+ }
549
+ if (releasePending && intentPlaying) {
550
+ val waitedNs = System.nanoTime() - releaseArmedNs
551
+ val holding = seamHoldTargetUs >= 0
552
+ val limitNs = if (holding) SEAM_HOLD_TIMEOUT_NS else READINESS_TIMEOUT_NS
553
+ if (firstFrameReady()) {
554
+ if (holding) {
555
+ seamHoldMs.add((System.nanoTime() - seamHoldStartedNs) / 1_000_000)
556
+ seamHoldTargetUs = -1
557
+ }
558
+ releasePending = false
559
+ releasedAtNs = System.nanoTime()
560
+ releases += 1
561
+ // Playback is continuous from here, across seams included.
562
+ FrameClock.setStreaming(true)
563
+ // Retention makes enforcement affordable: the acquisition is
564
+ // never deferred, so honouring the schedule no longer costs
565
+ // a frame per wait.
566
+ FrameClock.enforcePresentationSchedule = true
567
+ PreviewTrace.record("spike.release") {
568
+ JSONObject().put("waitedMs", waitedNs / 1_000_000)
569
+ }
570
+ runCatching { p.play() }
571
+ } else if (waitedNs > limitNs) {
572
+ if (holding) {
573
+ /*
574
+ A hold that never resolves is a defect, and resuming
575
+ blind would hide it behind the excursion it causes.
576
+ Reported, and the transport is left for the person to
577
+ restart rather than started on an unverified frame.
578
+ */
579
+ seamHoldTimeouts += 1
580
+ seamHoldMs.add((System.nanoTime() - seamHoldStartedNs) / 1_000_000)
581
+ seamHoldTargetUs = -1
582
+ }
583
+ // Bounded, and reported rather than released blind: a hold
584
+ // that never resolves is a defect, and starting anyway
585
+ // would hide it behind the excursion it causes.
586
+ releasePending = false
587
+ readinessTimeouts += 1
588
+ readinessFailure = "no first frame within ${waitedNs / 1_000_000} ms"
589
+ PreviewTrace.record("spike.readinessTimeout") {
590
+ JSONObject().put("waitedMs", waitedNs / 1_000_000)
591
+ }
592
+ }
593
+ }
594
+ val before = System.nanoTime()
595
+ val position = p.currentPosition * 1000
596
+ val playing = p.isPlaying
597
+ val playbackState = p.playbackState
598
+ val duration = p.duration * 1000
599
+ val frame = FrameClock.latestSample()
600
+ val pipeline = FrameClock.pipeline()
601
+ val after = System.nanoTime()
602
+ val paired = Pairing(
603
+ atNs = after,
604
+ positionUs = position,
605
+ playing = playing,
606
+ playbackState = playbackState,
607
+ durationUs = duration,
608
+ presentedUs = frame?.get(FrameClock.Field.PTS_US) ?: -1,
609
+ /*
610
+ Aged from the *scheduled release*, not from the metadata callback.
611
+
612
+ `offer` is recorded when Media3 says a frame is about to be
613
+ rendered; the frame is actually presented at the release time
614
+ it hands the surface, which was measured at about 40 ms later.
615
+ Ageing from the callback overstates it by that much, and the
616
+ overstatement lands directly on the offset as a constant bias.
617
+ */
618
+ /*
619
+ Aged from the latch, the last boundary this process witnesses.
620
+
621
+ Not from `RELEASE_NS`: that is the time Media3 *asked* the
622
+ surface to present at, and an intended time is not an
623
+ acknowledgement. Both are retained, so the difference between
624
+ what was asked for and what was latched is measurable instead
625
+ of assumed away.
626
+ */
627
+ presentedAgeNs =
628
+ if (frame == null) -1 else after - frame[FrameClock.Field.CONSUMED_NS],
629
+ readSpanNs = after - before,
630
+ )
631
+ pairing = paired
632
+ record(paired, frame, pipeline)
633
+ // A frame's worth. Fine grained because it costs one main-thread
634
+ // read of already-computed values, and because the gate has to
635
+ // resolve a transient that lasts a second and a half.
636
+ main.postDelayed(this, 16)
637
+ }
638
+ }
639
+
640
+ /** Players built and released, so a teardown gate can bound them. */
641
+ @Volatile var built = 0L
642
+ private set
643
+
644
+ @Volatile var releasedCount = 0L
645
+ private set
646
+
647
+ fun state(): String {
648
+ val p = player
649
+ val json = JSONObject()
650
+ .put("ok", true)
651
+ .put("attached", p != null)
652
+ .put("built", built)
653
+ .put("released", releasedCount)
654
+ .put("superseded", superseded)
655
+ .put("claimed", claimed.get())
656
+ .put("intentPlaying", intentPlaying)
657
+ .put("releasePending", releasePending)
658
+ .put("releases", releases)
659
+ .put("readinessTimeouts", readinessTimeouts)
660
+ .put("readinessFailure", readinessFailure ?: JSONObject.NULL)
661
+ /*
662
+ Hold durations, listed rather than summarised.
663
+
664
+ Each is a wall-clock pause the person sees. An average would make
665
+ two 150 ms stalls read like steady playback, and none of this is
666
+ synchronisation — it is the price paid to keep synchronisation.
667
+ */
668
+ .put("seamHoldMs", org.json.JSONArray(seamHoldMs.toList()))
669
+ .put("seamHoldTimeouts", seamHoldTimeouts)
670
+ .put("seamHoldActive", seamHoldTargetUs >= 0)
671
+ .put("epochTargetUs", epochTargetUs)
672
+ .put(
673
+ "holdMs",
674
+ if (releasedAtNs > releaseArmedNs) (releasedAtNs - releaseArmedNs) / 1_000_000
675
+ else -1,
676
+ )
677
+ .put("error", lastError ?: JSONObject.NULL)
678
+ // Readiness is reported apart from synchronisation on purpose: a
679
+ // preparation pause is a latency figure, not an A/V error, and
680
+ // averaging one into the other is how a slow start reads as drift.
681
+ .put("startupMs", if (readyAtNs > startedAtNs) (readyAtNs - startedAtNs) / 1_000_000 else -1)
682
+ .put("seekMs", if (seekReadyNs > seekStartedNs) (seekReadyNs - seekStartedNs) / 1_000_000 else -1)
683
+ val snapshot = pairing
684
+ if (p == null || snapshot == null) return json.toString()
685
+ return json
686
+ /*
687
+ Both readings, taken here, in one call.
688
+
689
+ The position is the composition's own clock — driven by the audio
690
+ graph — and the presented frame is what the consumer actually
691
+ latched. Sampled together against one `System.nanoTime()`, the only
692
+ uncertainty left is the position's millisecond quantisation and the
693
+ frame's own age, and the age is reported so a sample taken during a
694
+ stall can be excluded rather than averaged in.
695
+ */
696
+ .put("atNs", snapshot.atNs)
697
+ .put("positionUs", snapshot.positionUs)
698
+ .put("playing", snapshot.playing)
699
+ .put("playbackState", snapshot.playbackState)
700
+ .put("durationUs", snapshot.durationUs)
701
+ .put("presentedUs", if (snapshot.presentedUs < 0) JSONObject.NULL else snapshot.presentedUs)
702
+ .put("presentedAgeNs", snapshot.presentedAgeNs)
703
+ .put("readSpanNs", snapshot.readSpanNs)
704
+ // How old this pairing is by the time it is read. Reported so a
705
+ // verdict is never taken from a snapshot the pump failed to refresh.
706
+ .put("pairingAgeNs", System.nanoTime() - snapshot.atNs)
707
+ .toString()
708
+ }
709
+
710
+ /**
711
+ * Build the composition and attach it to the surface the preview already owns.
712
+ *
713
+ * @param specJson `{items:[{uri,startUs,endUs,speed}], mixUri, width, height}`
714
+ * exactly as `playlistOf()` produced it.
715
+ */
716
+ fun start(context: Context, specJson: String): String {
717
+ return try {
718
+ val spec = JSONObject(specJson)
719
+ val itemsJson = spec.getJSONArray("items")
720
+ val width = spec.getInt("width")
721
+ val height = spec.getInt("height")
722
+ val mixUri = spec.optString("mixUri", "")
723
+ // Required, not optional. `CompositionPlayer` computes each
724
+ // sequence's duration up front and an audio-only item cannot supply
725
+ // one on its own, so a missing value fails with a bare
726
+ // `IllegalStateException` carrying only `C.TIME_UNSET`.
727
+ val mixDurationUs = spec.optLong("mixDurationUs", 0L)
728
+ /*
729
+ Refused before anything is built.
730
+
731
+ Zero means the caller did not claim, which is allowed and treated
732
+ as "no supersession in play" — the spike is also used for runs
733
+ that never race.
734
+ */
735
+ val requestId = spec.optLong("requestId", 0L)
736
+ if (requestId != 0L && requestId < claimed.get()) {
737
+ superseded += 1
738
+ return JSONObject()
739
+ .put("ok", false).put("superseded", true)
740
+ .put("requestId", requestId).put("claimed", claimed.get())
741
+ .toString()
742
+ }
743
+
744
+ val clips = ArrayList<EditedMediaItem>(itemsJson.length())
745
+ for (i in 0 until itemsJson.length()) {
746
+ clips.add(clipItem(itemsJson.getJSONObject(i)))
747
+ }
748
+ if (clips.isEmpty()) {
749
+ return JSONObject().put("ok", false).put("error", "no items").toString()
750
+ }
751
+
752
+ /*
753
+ The boundaries, from the same spec the composition is built from.
754
+
755
+ Each item occupies its trimmed source length divided by its speed,
756
+ and the seams are the running total. Taken from here rather than
757
+ asked of the player: the player reports where it *is*, and the
758
+ hold has to arm before it gets there.
759
+
760
+ The last boundary is the end of the composition and is not a seam;
761
+ nothing follows it to prepare.
762
+ */
763
+ val seams = ArrayList<Long>(itemsJson.length())
764
+ var at = 0L
765
+ for (i in 0 until itemsJson.length()) {
766
+ val item = itemsJson.getJSONObject(i)
767
+ val speed = item.optDouble("speed", 1.0).takeIf { it > 0 } ?: 1.0
768
+ at += ((item.optLong("endUs") - item.optLong("startUs")) / speed).toLong()
769
+ if (i < itemsJson.length() - 1) seams.add(at)
770
+ }
771
+ seamHoldEnabled = spec.optBoolean("seamHold", false)
772
+ seamSeekEnabled = spec.optBoolean("seamSeek", false)
773
+ setSeamTargets(seams.toLongArray())
774
+
775
+ val sequences = ArrayList<EditedMediaItemSequence>(2)
776
+ sequences.add(EditedMediaItemSequence.Builder(clips).build())
777
+ if (mixUri.isNotEmpty()) {
778
+ sequences.add(
779
+ EditedMediaItemSequence.Builder(
780
+ EditedMediaItem.Builder(MediaItem.fromUri(mixUri))
781
+ // Audio only. The mix already covers the whole
782
+ // project; a video track here would be a second
783
+ // picture nobody asked for.
784
+ .setRemoveVideo(true)
785
+ .setDurationUs(mixDurationUs)
786
+ .build(),
787
+ ).build(),
788
+ )
789
+ }
790
+ val composition = Composition.Builder(sequences).build()
791
+
792
+ val surface = VideoSurface.currentSurface()
793
+ ?: return JSONObject().put("ok", false).put("error", "no surface").toString()
794
+
795
+ val done = java.util.concurrent.CountDownLatch(1)
796
+ main.post {
797
+ runCatching {
798
+ releaseLocked()
799
+ lastError = null
800
+ startedAtNs = System.nanoTime()
801
+ readyAtNs = 0
802
+ val p = CompositionPlayer.Builder(context).build()
803
+ p.setVideoSurface(surface, Size(width, height))
804
+ // The same hook the two-player path uses, so the presented
805
+ // frame is measured by the machinery already trusted.
806
+ p.setVideoFrameMetadataListener { presentationTimeUs, releaseTimeNs, _, _ ->
807
+ FrameClock.offer(presentationTimeUs, releaseTimeNs)
808
+ }
809
+ p.addListener(object : Player.Listener {
810
+ override fun onPlaybackStateChanged(state: Int) {
811
+ if (state == Player.STATE_READY) {
812
+ if (readyAtNs == 0L) readyAtNs = System.nanoTime()
813
+ if (seekStartedNs > seekReadyNs) seekReadyNs = System.nanoTime()
814
+ }
815
+ }
816
+
817
+ override fun onPlayerError(error: androidx.media3.common.PlaybackException) {
818
+ lastError = "${error.errorCodeName}: ${error.message}"
819
+ }
820
+ })
821
+ // Checked again inside the post: the claim may have moved
822
+ // while this request was queued behind another.
823
+ if (requestId != 0L && requestId < claimed.get()) {
824
+ superseded += 1
825
+ runCatching { p.release() }
826
+ lastError = null
827
+ done.countDown()
828
+ return@post
829
+ }
830
+ p.setComposition(composition)
831
+ p.prepare()
832
+ player = p
833
+ built += 1
834
+ pairing = null
835
+ epoch += 1
836
+ epochAtNs = System.nanoTime()
837
+ intentPlaying = false
838
+ releasePending = false
839
+ epochTargetUs = 0
840
+ main.removeCallbacks(pump)
841
+ pump.run()
842
+ }.onFailure {
843
+ // With the stack, because a spike's first failure is
844
+ // usually a contract it is holding wrongly and the class
845
+ // name alone names none of them.
846
+ lastError = it.javaClass.simpleName + ": " + it.message
847
+ android.util.Log.e("PictorSpike", "composition start failed", it)
848
+ }
849
+ done.countDown()
850
+ }
851
+ done.await(5, java.util.concurrent.TimeUnit.SECONDS)
852
+ JSONObject().put("ok", lastError == null)
853
+ .put("error", lastError ?: JSONObject.NULL)
854
+ .put("sequences", sequences.size)
855
+ .put("clips", clips.size)
856
+ .toString()
857
+ } catch (e: Exception) {
858
+ JSONObject().put("ok", false).put("error", e.javaClass.simpleName + ": " + e.message)
859
+ .toString()
860
+ }
861
+ }
862
+
863
+ private fun clipItem(o: JSONObject): EditedMediaItem {
864
+ val startUs = o.getLong("startUs")
865
+ val endUs = o.getLong("endUs")
866
+ val speed = o.getDouble("speed").toFloat()
867
+ val media = MediaItem.Builder()
868
+ .setUri(o.getString("uri"))
869
+ .setClippingConfiguration(
870
+ MediaItem.ClippingConfiguration.Builder()
871
+ .setStartPositionMs(startUs / 1000)
872
+ .setEndPositionMs(endUs / 1000)
873
+ .build(),
874
+ )
875
+ .build()
876
+ val builder = EditedMediaItem.Builder(media)
877
+ /*
878
+ No clip audio, ever.
879
+
880
+ The prepared mix already contains it — that is what makes it a
881
+ mix — and letting the clip's own track through would play the
882
+ whole timeline twice. This is the same rule the two-player path
883
+ enforces by muting the source player.
884
+ */
885
+ .setRemoveAudio(true)
886
+ .setDurationUs(endUs - startUs)
887
+ if (speed != 1f) {
888
+ /*
889
+ Speed as a source mapping, not as a clock rate.
890
+
891
+ `CompositionPlayer` accepts speed effects only from this factory,
892
+ and the reason it has to is the clock contract: a global
893
+ `PlaybackParameters` speed would scale the one axis every sequence
894
+ shares, dragging the mix to 2x with the picture. The pair is an
895
+ audio processor and a video timestamp adjustment, which is exactly
896
+ "the clip's compiled source-time mapping" expressed where the
897
+ player can honour it.
898
+ */
899
+ val pair = Effects.createExperimentalSpeedChangingEffect(ConstantSpeed(speed))
900
+ builder.setEffects(Effects(listOf(pair.first), listOf(pair.second)))
901
+ }
902
+ return builder.build()
903
+ }
904
+
905
+ /**
906
+ * The person asked for playback.
907
+ *
908
+ * Recorded as intent and armed as a hold; the transport starts from the
909
+ * pump once a current-epoch frame exists. Starting here would be the very
910
+ * thing this gate exists to stop.
911
+ */
912
+ fun play(): String = onPlayer {
913
+ intentPlaying = true
914
+ // Resume targets where the transport already is.
915
+ armRelease(it.currentPosition * 1000)
916
+ }
917
+
918
+ fun pause(): String = onPlayer {
919
+ // Intent first: a pause must invalidate a release that is about to
920
+ // happen, not race it.
921
+ intentPlaying = false
922
+ releasePending = false
923
+ cancelSeamHold()
924
+ FrameClock.setStreaming(false)
925
+ it.pause()
926
+ }
927
+
928
+ fun seek(positionUs: Long): String = onPlayer {
929
+ // A seek supersedes any hold: the boundary it was covering is no longer
930
+ // where playback is going.
931
+ cancelSeamHold()
932
+ seekStartedNs = System.nanoTime()
933
+ seekReadyNs = 0
934
+ // A new epoch: frames still in flight belong to the position being left.
935
+ epoch += 1
936
+ epochAtNs = seekStartedNs
937
+ // Frames in flight describe the position being left; their release
938
+ // times must not pace the new one.
939
+ FrameClock.bump()
940
+ /*
941
+ Held across the seek, and the older pending release is invalidated by
942
+ the re-arm. The player is paused so the seek does not play through
943
+ the interval where the picture is still the old position.
944
+ */
945
+ it.pause()
946
+ armRelease(positionUs)
947
+ it.seekTo(positionUs / 1000)
948
+ }
949
+
950
+ fun release(): String {
951
+ val done = java.util.concurrent.CountDownLatch(1)
952
+ main.post { releaseLocked(); done.countDown() }
953
+ done.await(5, java.util.concurrent.TimeUnit.SECONDS)
954
+ return JSONObject().put("ok", true).put("released", releasedCount).toString()
955
+ }
956
+
957
+ /** Main-thread only: `CompositionPlayer` requires one application thread. */
958
+ private fun releaseLocked() {
959
+ main.removeCallbacks(pump)
960
+ pairing = null
961
+ // Unmount and background both come through here: a pending release must
962
+ // not survive the player it was about to start.
963
+ intentPlaying = false
964
+ releasePending = false
965
+ FrameClock.setStreaming(false)
966
+ player?.let {
967
+ runCatching { it.release() }
968
+ releasedCount += 1
969
+ }
970
+ player = null
971
+ }
972
+
973
+ private fun onPlayer(block: (CompositionPlayer) -> Unit): String {
974
+ val p = player ?: return JSONObject().put("ok", false).put("error", "not started").toString()
975
+ main.post { runCatching { block(p) }.onFailure { lastError = it.message } }
976
+ return JSONObject().put("ok", true).toString()
977
+ }
978
+ }