@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.
- package/LICENSE +21 -0
- package/README.md +40 -0
- package/android/CMakeLists.txt +30 -0
- package/android/build.gradle +119 -0
- package/android/src/main/AndroidManifest.xml +1 -0
- package/android/src/main/cpp/pictor_overlaystage.cpp +372 -0
- package/android/src/main/cpp/pictor_publish.cpp +87 -0
- package/android/src/main/cpp/pictor_videosurface.cpp +735 -0
- package/android/src/main/java/dev/pictor/video/AacPriming.kt +261 -0
- package/android/src/main/java/dev/pictor/video/AudioMixer.kt +1112 -0
- package/android/src/main/java/dev/pictor/video/AudioProbe.kt +162 -0
- package/android/src/main/java/dev/pictor/video/CadenceShaderProgram.kt +337 -0
- package/android/src/main/java/dev/pictor/video/CodecLedger.kt +126 -0
- package/android/src/main/java/dev/pictor/video/CompositionSpike.kt +978 -0
- package/android/src/main/java/dev/pictor/video/Diagnostics.kt +54 -0
- package/android/src/main/java/dev/pictor/video/EncoderGate.kt +476 -0
- package/android/src/main/java/dev/pictor/video/ExportGate.kt +1974 -0
- package/android/src/main/java/dev/pictor/video/ExportHeartbeat.kt +72 -0
- package/android/src/main/java/dev/pictor/video/ExportProgress.kt +103 -0
- package/android/src/main/java/dev/pictor/video/FrameClock.kt +591 -0
- package/android/src/main/java/dev/pictor/video/FramePending.kt +181 -0
- package/android/src/main/java/dev/pictor/video/GateAudio.kt +456 -0
- package/android/src/main/java/dev/pictor/video/GateDestination.kt +445 -0
- package/android/src/main/java/dev/pictor/video/GateFrames.kt +662 -0
- package/android/src/main/java/dev/pictor/video/GateMux.kt +185 -0
- package/android/src/main/java/dev/pictor/video/ManagedSources.kt +333 -0
- package/android/src/main/java/dev/pictor/video/Media3Player.kt +934 -0
- package/android/src/main/java/dev/pictor/video/MediaStoreSave.kt +419 -0
- package/android/src/main/java/dev/pictor/video/OverlayResources.kt +169 -0
- package/android/src/main/java/dev/pictor/video/PictorVideoModule.kt +1612 -0
- package/android/src/main/java/dev/pictor/video/PictorVideoPackage.kt +26 -0
- package/android/src/main/java/dev/pictor/video/PlaybackAnchor.kt +161 -0
- package/android/src/main/java/dev/pictor/video/PreviewAudioPlayer.kt +291 -0
- package/android/src/main/java/dev/pictor/video/PreviewConfig.kt +96 -0
- package/android/src/main/java/dev/pictor/video/PreviewMix.kt +396 -0
- package/android/src/main/java/dev/pictor/video/PreviewSync.kt +374 -0
- package/android/src/main/java/dev/pictor/video/PreviewTrace.kt +478 -0
- package/android/src/main/java/dev/pictor/video/PrimingTrim.kt +337 -0
- package/android/src/main/java/dev/pictor/video/RepeatingEncoderFactory.kt +97 -0
- package/android/src/main/java/dev/pictor/video/SceneProgram.kt +169 -0
- package/android/src/main/java/dev/pictor/video/SceneProgramNative.kt +299 -0
- package/android/src/main/java/dev/pictor/video/SkiaPassThroughEffect.kt +695 -0
- package/android/src/main/java/dev/pictor/video/SurfaceAttachment.kt +110 -0
- package/android/src/main/java/dev/pictor/video/ThumbnailDecoder.kt +171 -0
- package/android/src/main/java/dev/pictor/video/VideoSurface.kt +394 -0
- package/app.plugin.js +43 -0
- package/dist/index.d.mts +3622 -0
- package/dist/index.d.ts +3622 -0
- package/dist/index.js +3737 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +3625 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +82 -0
- package/plugin/gradleProperties.d.ts +19 -0
- package/plugin/gradleProperties.js +64 -0
- package/react-native.config.js +13 -0
- package/src/NativePictorVideo.ts +574 -0
- package/src/audioProbe.ts +72 -0
- package/src/compositionSpike.ts +305 -0
- package/src/convergence.ts +112 -0
- package/src/encoderGate.ts +66 -0
- package/src/exportDestination.ts +149 -0
- package/src/exportJob.ts +232 -0
- package/src/exportProgress.ts +391 -0
- package/src/exportProgressPump.ts +190 -0
- package/src/exporter.ts +655 -0
- package/src/index.ts +276 -0
- package/src/managedSources.ts +252 -0
- package/src/media3.ts +412 -0
- package/src/mediaStore.ts +177 -0
- package/src/memoryGuard.ts +118 -0
- package/src/mixSources.ts +82 -0
- package/src/overlayStaging.ts +216 -0
- package/src/playbackLifecycle.ts +137 -0
- package/src/player.ts +39 -0
- package/src/playlistSession.ts +125 -0
- package/src/previewAudioDiagnostics.ts +67 -0
- package/src/previewAudioSession.ts +168 -0
- package/src/previewCadence.ts +950 -0
- package/src/previewMix.ts +212 -0
- package/src/previewTrace.ts +348 -0
- package/src/spikeExcursions.ts +405 -0
- package/src/submissionScoring.ts +680 -0
- package/src/surfaceOwner.ts +264 -0
- package/src/surfaceRecovery.ts +121 -0
- package/src/syncTrace.ts +189 -0
- package/src/thumbnailProvider.ts +163 -0
- package/src/tracePhases.ts +217 -0
- package/src/useCompositionPlayback.ts +327 -0
- package/src/useMedia3Playback.ts +492 -0
- package/src/usePreviewAudio.ts +305 -0
- package/src/useVideoSurface.ts +282 -0
|
@@ -0,0 +1,934 @@
|
|
|
1
|
+
package dev.pictor.video
|
|
2
|
+
|
|
3
|
+
import android.content.Context
|
|
4
|
+
import android.net.Uri
|
|
5
|
+
import android.os.Handler
|
|
6
|
+
import android.os.Looper
|
|
7
|
+
import androidx.media3.common.AudioAttributes
|
|
8
|
+
import androidx.media3.common.C
|
|
9
|
+
import androidx.media3.common.MediaItem
|
|
10
|
+
import androidx.media3.common.PlaybackException
|
|
11
|
+
import androidx.media3.common.PlaybackParameters
|
|
12
|
+
import androidx.media3.common.Player
|
|
13
|
+
import androidx.media3.exoplayer.ExoPlayer
|
|
14
|
+
import androidx.media3.exoplayer.video.VideoFrameMetadataListener
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Playback, by Media3.
|
|
18
|
+
*
|
|
19
|
+
* ExoPlayer owns decoding, buffering, audio focus, A/V synchronisation, seeking,
|
|
20
|
+
* speed and lifecycle. Pictor owns the document, the timeline and the Skia
|
|
21
|
+
* composition, and this class is only the translation between them.
|
|
22
|
+
*
|
|
23
|
+
* That division is the point of the change. The custom `MediaCodec`/`AudioTrack`
|
|
24
|
+
* backend reached one-frame synchronisation, and cost several slices on codec
|
|
25
|
+
* pacing, buffer ownership, async-flush races and a GL memory leak — none of
|
|
26
|
+
* which is Pictor's problem, and none of which accumulates the per-device
|
|
27
|
+
* workarounds ExoPlayer already carries.
|
|
28
|
+
*
|
|
29
|
+
* Video goes to the persistent `SurfaceTexture` from [VideoSurface], so the
|
|
30
|
+
* renderer keeps one external texture and one `SkImage` for the whole session.
|
|
31
|
+
*
|
|
32
|
+
* **No Media3 type leaves this class.** The module exposes numbers and strings;
|
|
33
|
+
* `ExoPlayer`, `MediaItem` and the rest stop here.
|
|
34
|
+
*/
|
|
35
|
+
internal class Media3Player private constructor(
|
|
36
|
+
private val player: ExoPlayer,
|
|
37
|
+
val widthHint: Int,
|
|
38
|
+
val heightHint: Int,
|
|
39
|
+
) {
|
|
40
|
+
@Volatile
|
|
41
|
+
private var released = false
|
|
42
|
+
|
|
43
|
+
/** Everything ExoPlayer is touched from is its own application thread. */
|
|
44
|
+
private val main = Handler(Looper.getMainLooper())
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Publishes the position anchor, on the player's thread.
|
|
48
|
+
*
|
|
49
|
+
* One re-posted Runnable, never a fresh lambda. 50 ms is far coarser than a
|
|
50
|
+
* frame and that is fine: consumers extrapolate between anchors rather than
|
|
51
|
+
* asking the player, which is what keeps the render path free of a thread
|
|
52
|
+
* hop.
|
|
53
|
+
*/
|
|
54
|
+
private val anchorPump = object : Runnable {
|
|
55
|
+
override fun run() {
|
|
56
|
+
if (released) return
|
|
57
|
+
PlaybackAnchor.publish(
|
|
58
|
+
PlaybackAnchor.Sample(
|
|
59
|
+
positionUs = player.currentPosition * 1000,
|
|
60
|
+
monotonicNs = System.nanoTime(),
|
|
61
|
+
rate = player.playbackParameters.speed,
|
|
62
|
+
playing = player.isPlaying,
|
|
63
|
+
buffering = player.playbackState == Player.STATE_BUFFERING,
|
|
64
|
+
discontinuity = false,
|
|
65
|
+
generation = FrameClock.generation,
|
|
66
|
+
windowIndex = player.currentMediaItemIndex,
|
|
67
|
+
),
|
|
68
|
+
)
|
|
69
|
+
// The coordinator runs from here rather than from a timer of its
|
|
70
|
+
// own: this pump already exists, already runs on the player's
|
|
71
|
+
// thread, and is the cadence everything else about position uses.
|
|
72
|
+
PreviewSync.tick(::holdTransport)
|
|
73
|
+
durationUs = if (player.duration == C.TIME_UNSET) 0L else player.duration * 1000
|
|
74
|
+
ended = player.playbackState == Player.STATE_ENDED
|
|
75
|
+
ready = player.playbackState == Player.STATE_READY
|
|
76
|
+
videoWidth = player.videoSize.width
|
|
77
|
+
videoHeight = player.videoSize.height
|
|
78
|
+
// Cached here, on the player's own thread. Reading it from state()
|
|
79
|
+
// throws "Player is accessed on the wrong thread" — state() is
|
|
80
|
+
// called from JS and must touch nothing but volatiles.
|
|
81
|
+
playbackState = player.playbackState
|
|
82
|
+
// The active format, for fault classification. Reported here rather
|
|
83
|
+
// than from state(): touching the player off its own thread throws.
|
|
84
|
+
val format = player.videoFormat
|
|
85
|
+
videoFormat = if (format == null) {
|
|
86
|
+
""
|
|
87
|
+
} else {
|
|
88
|
+
"${format.sampleMimeType} ${format.width}x${format.height}" +
|
|
89
|
+
" rot${format.rotationDegrees} @${format.frameRate}"
|
|
90
|
+
}
|
|
91
|
+
currentWindow = player.currentMediaItemIndex
|
|
92
|
+
FrameClock.windowIndex = currentWindow
|
|
93
|
+
windowPositionUs = player.currentPosition * 1000
|
|
94
|
+
windowCount = player.mediaItemCount
|
|
95
|
+
main.postDelayed(this, ANCHOR_INTERVAL_MS)
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Called from the analytics listener on the player's thread. */
|
|
100
|
+
private fun decoderInitialised(name: String) {
|
|
101
|
+
decoderName = name
|
|
102
|
+
surfaceGeneration += 1
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
private fun startAnchor() {
|
|
106
|
+
main.post(anchorPump)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Report source failures. Registered on the player's thread after
|
|
111
|
+
* construction, because it writes this instance's fields.
|
|
112
|
+
*
|
|
113
|
+
* Without it a failed source is invisible: the player simply stops, and the
|
|
114
|
+
* state reads exactly like an idle player nobody asked to do anything. That
|
|
115
|
+
* is how a dead endpoint looked in the remote gate — behaviour that was
|
|
116
|
+
* correct and entirely unreported.
|
|
117
|
+
*/
|
|
118
|
+
/**
|
|
119
|
+
* Take responsibility for attaching, for as long as this player exists.
|
|
120
|
+
*
|
|
121
|
+
* Registered when the player is built and cleared when it is released, so a
|
|
122
|
+
* surface created at any point — before this player, after it, or after a
|
|
123
|
+
* screen remount — reaches it without the host being involved.
|
|
124
|
+
*/
|
|
125
|
+
private fun ownSurfaceAttachment() {
|
|
126
|
+
VideoSurface.onSurfaceLive = { attachSurface() }
|
|
127
|
+
attachSurface()
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
private fun attachErrorListener() {
|
|
131
|
+
main.post {
|
|
132
|
+
if (released) return@post
|
|
133
|
+
// Which decoder Media3 chose. Registered here, on the player's own
|
|
134
|
+
// thread and after construction, because it writes instance state —
|
|
135
|
+
// the companion cannot reach it.
|
|
136
|
+
player.addAnalyticsListener(
|
|
137
|
+
object : androidx.media3.exoplayer.analytics.AnalyticsListener {
|
|
138
|
+
override fun onVideoDecoderInitialized(
|
|
139
|
+
eventTime: androidx.media3.exoplayer.analytics.AnalyticsListener.EventTime,
|
|
140
|
+
name: String,
|
|
141
|
+
initializedTimestampMs: Long,
|
|
142
|
+
initializationDurationMs: Long,
|
|
143
|
+
) {
|
|
144
|
+
decoderInitialised(name)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/*
|
|
148
|
+
The format the decoder is actually configured for, and the
|
|
149
|
+
size it actually produces.
|
|
150
|
+
|
|
151
|
+
Recorded from the player rather than inferred from the
|
|
152
|
+
device: "a Pixel 7 uses the Exynos decoder" is a guess
|
|
153
|
+
that happens to be checkable, and a guess that survives
|
|
154
|
+
because nobody checked it is how the last three
|
|
155
|
+
attributions in this area went wrong. A mixed-resolution
|
|
156
|
+
seam is also the one moment these two can disagree.
|
|
157
|
+
*/
|
|
158
|
+
override fun onVideoInputFormatChanged(
|
|
159
|
+
eventTime: androidx.media3.exoplayer.analytics.AnalyticsListener.EventTime,
|
|
160
|
+
format: androidx.media3.common.Format,
|
|
161
|
+
decoderReuseEvaluation:
|
|
162
|
+
androidx.media3.exoplayer.DecoderReuseEvaluation?,
|
|
163
|
+
) {
|
|
164
|
+
inputFormats += 1
|
|
165
|
+
inputWidth = format.width
|
|
166
|
+
inputHeight = format.height
|
|
167
|
+
inputRotation = format.rotationDegrees
|
|
168
|
+
// Whether Media3 kept the codec across the seam or built
|
|
169
|
+
// a new one. A reused codec that stops delivering and a
|
|
170
|
+
// replaced one that never starts are different faults.
|
|
171
|
+
decoderReuse = decoderReuseEvaluation?.result ?: -1
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
override fun onVideoSizeChanged(
|
|
175
|
+
eventTime: androidx.media3.exoplayer.analytics.AnalyticsListener.EventTime,
|
|
176
|
+
size: androidx.media3.common.VideoSize,
|
|
177
|
+
) {
|
|
178
|
+
outputSizes += 1
|
|
179
|
+
outputWidth = size.width
|
|
180
|
+
outputHeight = size.height
|
|
181
|
+
}
|
|
182
|
+
},
|
|
183
|
+
)
|
|
184
|
+
player.addListener(
|
|
185
|
+
object : Player.Listener {
|
|
186
|
+
/**
|
|
187
|
+
* A window change is a discontinuity for the frame clock.
|
|
188
|
+
*
|
|
189
|
+
* Frames queued for the outgoing clip must not be paired
|
|
190
|
+
* with a position in the incoming one — that is a seam that
|
|
191
|
+
* reports the wrong time for a few frames, which looks like
|
|
192
|
+
* clock drift and is not.
|
|
193
|
+
*/
|
|
194
|
+
/**
|
|
195
|
+
* The picture and the mix are held together through a
|
|
196
|
+
* stall, and released together when they agree.
|
|
197
|
+
*
|
|
198
|
+
* See [PreviewSync]. Pausing only the mix was tried first
|
|
199
|
+
* and left a seam transient of about 200 ms, because the
|
|
200
|
+
* picture resumed the moment its decoder was ready while
|
|
201
|
+
* the mix was still moving.
|
|
202
|
+
*/
|
|
203
|
+
override fun onPlaybackStateChanged(state: Int) {
|
|
204
|
+
val buffering = state == Player.STATE_BUFFERING
|
|
205
|
+
if (buffering == lastBufferingSeen) return
|
|
206
|
+
lastBufferingSeen = buffering
|
|
207
|
+
PreviewTrace.record("source.buffering") {
|
|
208
|
+
org.json.JSONObject()
|
|
209
|
+
.put("buffering", buffering)
|
|
210
|
+
.put("presentedUs", PreviewSync.presentedUs() ?: -1)
|
|
211
|
+
.put("decodedUs", PreviewSync.decodedUs(System.nanoTime()))
|
|
212
|
+
}
|
|
213
|
+
PreviewSync.onVideoBuffering(buffering, ::holdTransport)
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
override fun onMediaItemTransition(item: MediaItem?, reason: Int) {
|
|
217
|
+
currentWindow = player.currentMediaItemIndex
|
|
218
|
+
// Published for the frame clock in the same callback the
|
|
219
|
+
// window actually changes in, so a frame offered after
|
|
220
|
+
// the seam is tagged with the window it belongs to.
|
|
221
|
+
FrameClock.windowIndex = currentWindow
|
|
222
|
+
applyWindowSpeed(currentWindow)
|
|
223
|
+
applyWindowMute(currentWindow)
|
|
224
|
+
transitions += 1
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** Which decoder Media3 chose, and when it changed. */
|
|
228
|
+
override fun onVideoSizeChanged(size: androidx.media3.common.VideoSize) {
|
|
229
|
+
videoWidth = size.width
|
|
230
|
+
videoHeight = size.height
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
override fun onPlayerError(error: PlaybackException) {
|
|
234
|
+
errorCount += 1
|
|
235
|
+
errorCode = error.errorCode
|
|
236
|
+
errorMessage = "${error.errorCodeName}: ${error.message ?: ""}"
|
|
237
|
+
// Media3's own classification, which is the only honest
|
|
238
|
+
// basis for retrying: a network drop recovers, a
|
|
239
|
+
// malformed container will fail identically forever.
|
|
240
|
+
errorRecoverable =
|
|
241
|
+
error.errorCode == PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED ||
|
|
242
|
+
error.errorCode == PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_TIMEOUT ||
|
|
243
|
+
error.errorCode == PlaybackException.ERROR_CODE_IO_BAD_HTTP_STATUS
|
|
244
|
+
}
|
|
245
|
+
},
|
|
246
|
+
)
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
private fun onPlayer(block: ExoPlayer.() -> Unit) {
|
|
251
|
+
if (released) return
|
|
252
|
+
if (Looper.myLooper() == Looper.getMainLooper()) {
|
|
253
|
+
player.block()
|
|
254
|
+
} else {
|
|
255
|
+
main.post { if (!released) player.block() }
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
fun play() {
|
|
260
|
+
PreviewSync.onUserPlay()
|
|
261
|
+
// The dual-player preview shares the consumer, so it owns the same
|
|
262
|
+
// streaming flag: without it every frame would be paced against a
|
|
263
|
+
// schedule nobody set, or none would be paced at all.
|
|
264
|
+
FrameClock.setStreaming(true)
|
|
265
|
+
onPlayer {
|
|
266
|
+
// A hold may have taken the transport away; starting has to give it
|
|
267
|
+
// back or the press would do nothing.
|
|
268
|
+
transportHeld = false
|
|
269
|
+
play()
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
fun pause() {
|
|
274
|
+
PreviewSync.onUserStop("pause")
|
|
275
|
+
FrameClock.setStreaming(false)
|
|
276
|
+
onPlayer { pause() }
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
fun seekTo(positionUs: Long) {
|
|
280
|
+
// Before the seek, not after: frames already queued belong to the
|
|
281
|
+
// position being left, and pairing one with the new clip time would
|
|
282
|
+
// publish a position that never existed.
|
|
283
|
+
FrameClock.bump()
|
|
284
|
+
onPlayer { seekTo(positionUs / 1000) }
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* The user's preview rate, composed with whatever speed the clip carries.
|
|
289
|
+
*
|
|
290
|
+
* A 2x clip previewed at 0.5x plays at 1x. Overwriting the player's speed
|
|
291
|
+
* outright would silently discard the clip's own, which is part of the
|
|
292
|
+
* document and not a preview setting.
|
|
293
|
+
*/
|
|
294
|
+
fun setRate(rate: Float) {
|
|
295
|
+
userRate = if (rate > 0f) rate else 1f
|
|
296
|
+
onPlayer {
|
|
297
|
+
applyWindowSpeed(currentMediaItemIndex)
|
|
298
|
+
applyWindowMute(currentMediaItemIndex)
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* A snapshot, for a throttled read. Never called per frame.
|
|
304
|
+
*
|
|
305
|
+
* Positions are microseconds, because that is Pictor's unit; Media3 works
|
|
306
|
+
* in milliseconds and the conversion belongs here rather than in JS.
|
|
307
|
+
*/
|
|
308
|
+
/**
|
|
309
|
+
* A snapshot for a throttled read. **Never blocks.**
|
|
310
|
+
*
|
|
311
|
+
* Reads the anchor the player's own thread publishes rather than posting
|
|
312
|
+
* and waiting. The old version held a latch, which put a thread hop inside
|
|
313
|
+
* anything that called it — including the synchronisation measurement.
|
|
314
|
+
*/
|
|
315
|
+
/**
|
|
316
|
+
* Every floating-point value the state carries, as a bridge-safe `Double`.
|
|
317
|
+
*
|
|
318
|
+
* `Map<String, Any>` boxes whatever it is given, and a Kotlin `Float` boxes
|
|
319
|
+
* to `java.lang.Float` — which the bridge does not convert. The field then
|
|
320
|
+
* arrives as `undefined` rather than as a wrong number, so a consumer that
|
|
321
|
+
* spreads defaults underneath still sees `undefined`, and the first
|
|
322
|
+
* `.toFixed` on it is fatal.
|
|
323
|
+
*
|
|
324
|
+
* Named so the conversion is a decision at the boundary rather than a
|
|
325
|
+
* `.toDouble()` remembered per call site — the two that were missing had one
|
|
326
|
+
* and did not survive, which is exactly the kind of thing an audit misses
|
|
327
|
+
* and a single funnel cannot.
|
|
328
|
+
*/
|
|
329
|
+
private fun bridgeDouble(value: Float): Double = value.toDouble()
|
|
330
|
+
|
|
331
|
+
fun state(): Map<String, Any> {
|
|
332
|
+
if (released) return mapOf("released" to true)
|
|
333
|
+
val anchor = PlaybackAnchor.latest()
|
|
334
|
+
val counters = FrameClock.counters()
|
|
335
|
+
val granularity = PlaybackAnchor.granularity()
|
|
336
|
+
return mapOf(
|
|
337
|
+
"positionUs" to PlaybackAnchor.positionAt(System.nanoTime()),
|
|
338
|
+
"anchorPositionUs" to anchor.positionUs,
|
|
339
|
+
"durationUs" to durationUs,
|
|
340
|
+
"playing" to anchor.playing,
|
|
341
|
+
"buffering" to anchor.buffering,
|
|
342
|
+
"ended" to ended,
|
|
343
|
+
"ready" to ready,
|
|
344
|
+
"rate" to bridgeDouble(anchor.rate),
|
|
345
|
+
"width" to videoWidth,
|
|
346
|
+
"height" to videoHeight,
|
|
347
|
+
"generation" to counters[4],
|
|
348
|
+
"queueDepth" to FrameClock.depth(),
|
|
349
|
+
"overflowed" to counters[0],
|
|
350
|
+
"unpaired" to counters[1],
|
|
351
|
+
"stale" to counters[2],
|
|
352
|
+
"queueHighWater" to counters[3],
|
|
353
|
+
"threads" to threadCount(),
|
|
354
|
+
"nativeHeapBytes" to android.os.Debug.getNativeHeapAllocatedSize(),
|
|
355
|
+
"coalesced" to counters[5],
|
|
356
|
+
"anchorPublications" to granularity.publications,
|
|
357
|
+
"anchorChanges" to granularity.changes,
|
|
358
|
+
"anchorMaxStepUs" to granularity.maxStepUs,
|
|
359
|
+
"anchorMeanStepUs" to
|
|
360
|
+
(if (granularity.changes > 0) granularity.totalStepUs / granularity.changes else 0L),
|
|
361
|
+
"anchorMaxStaleNs" to granularity.maxStaleNs,
|
|
362
|
+
"windowIndex" to currentWindow,
|
|
363
|
+
"windowCount" to windowCount,
|
|
364
|
+
"windowPositionUs" to windowPositionUs,
|
|
365
|
+
"transitions" to transitions,
|
|
366
|
+
"missedLatches" to VideoSurface.missedLatches,
|
|
367
|
+
"updatesAttempted" to VideoSurface.updatesAttempted,
|
|
368
|
+
"updatesSucceeded" to VideoSurface.updatesSucceeded,
|
|
369
|
+
"updatesSkipped" to VideoSurface.updatesSkipped,
|
|
370
|
+
"updatesFailed" to VideoSurface.updatesFailed,
|
|
371
|
+
"framesOffered" to FrameClock.offered,
|
|
372
|
+
"framesAvailable" to FrameClock.available,
|
|
373
|
+
// The level, not the edge. `pendingDepth` is what a consumer that
|
|
374
|
+
// was disarmed when the frame arrived can still see.
|
|
375
|
+
"pendingDepth" to VideoSurface.pendingDepth,
|
|
376
|
+
"framesRejected" to VideoSurface.framesRejected,
|
|
377
|
+
"pendingGeneration" to VideoSurface.pendingGeneration,
|
|
378
|
+
"controllerId" to VideoSurface.controllerId.toLong(),
|
|
379
|
+
"pendingId" to VideoSurface.pendingId.toLong(),
|
|
380
|
+
"orphaned" to FrameClock.orphaned,
|
|
381
|
+
"seeksRequested" to seeksRequested,
|
|
382
|
+
"seeksAccepted" to seeksAccepted,
|
|
383
|
+
"pendingSeek" to pendingSeek,
|
|
384
|
+
"playbackState" to playbackState,
|
|
385
|
+
"videoFormat" to videoFormat,
|
|
386
|
+
"decoderName" to decoderName,
|
|
387
|
+
"inputFormats" to inputFormats,
|
|
388
|
+
"inputWidth" to inputWidth,
|
|
389
|
+
"inputHeight" to inputHeight,
|
|
390
|
+
"inputRotation" to inputRotation,
|
|
391
|
+
"decoderReuse" to decoderReuse,
|
|
392
|
+
"outputSizes" to outputSizes,
|
|
393
|
+
"outputWidth" to outputWidth,
|
|
394
|
+
"outputHeight" to outputHeight,
|
|
395
|
+
"surfaceGeneration" to surfaceGeneration,
|
|
396
|
+
"rateConstrained" to rateConstrained,
|
|
397
|
+
"requestedRate" to bridgeDouble(requestedRate),
|
|
398
|
+
"staleWindowSpeeds" to staleWindowSpeeds,
|
|
399
|
+
"windowSpeedCount" to (windowSpeeds?.size ?: 0).toLong(),
|
|
400
|
+
"effectiveRate" to bridgeDouble(
|
|
401
|
+
windowSpeeds?.getOrNull(currentWindow)?.times(userRate) ?: 1f,
|
|
402
|
+
),
|
|
403
|
+
"surfaceAttachments" to VideoSurface.attachment.attachments.toLong(),
|
|
404
|
+
"attachedSurfaceGeneration" to VideoSurface.attachment.attached,
|
|
405
|
+
"liveSurfaceGeneration" to VideoSurface.attachment.live,
|
|
406
|
+
"lastPairedPtsUs" to (FrameClock.latestSample()?.get(FrameClock.Field.PTS_US) ?: -1L),
|
|
407
|
+
"errorCount" to errorCount,
|
|
408
|
+
"errorCode" to errorCode,
|
|
409
|
+
"errorMessage" to errorMessage,
|
|
410
|
+
"errorRecoverable" to errorRecoverable,
|
|
411
|
+
"released" to false,
|
|
412
|
+
)
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* Ask the player to try the source again.
|
|
417
|
+
*
|
|
418
|
+
* `prepare()` after an error is Media3's documented recovery: it re-resolves
|
|
419
|
+
* the source and resumes from the current position, so a network source that
|
|
420
|
+
* went away and came back needs nothing else. Recoverable errors are the
|
|
421
|
+
* only ones worth retrying — a malformed container will fail identically
|
|
422
|
+
* however many times it is asked — so callers decide, using [errorRecoverable].
|
|
423
|
+
*/
|
|
424
|
+
fun retry() {
|
|
425
|
+
onPlayer {
|
|
426
|
+
clearError()
|
|
427
|
+
prepare()
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/** A new attempt owns its own outcome; the previous failure is not it. */
|
|
432
|
+
private fun clearError() {
|
|
433
|
+
errorCode = 0
|
|
434
|
+
errorMessage = ""
|
|
435
|
+
errorRecoverable = false
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// Written on the player's thread, read anywhere. Cheap fields the anchor
|
|
439
|
+
// does not carry.
|
|
440
|
+
@Volatile private var durationUs: Long = 0
|
|
441
|
+
@Volatile private var errorCode = 0
|
|
442
|
+
@Volatile private var errorMessage = ""
|
|
443
|
+
@Volatile private var errorRecoverable = false
|
|
444
|
+
@Volatile private var errorCount = 0L
|
|
445
|
+
@Volatile private var ended = false
|
|
446
|
+
@Volatile private var ready = false
|
|
447
|
+
@Volatile private var videoWidth = 0
|
|
448
|
+
@Volatile private var videoHeight = 0
|
|
449
|
+
@Volatile private var windowSpeeds: FloatArray? = null
|
|
450
|
+
/** The rate the user asked for, separate from the clip's own speed. */
|
|
451
|
+
@Volatile private var userRate = 1f
|
|
452
|
+
@Volatile private var currentWindow = 0
|
|
453
|
+
@Volatile private var windowPositionUs = 0L
|
|
454
|
+
@Volatile private var windowCount = 0
|
|
455
|
+
@Volatile private var transitions = 0L
|
|
456
|
+
private val mutedWindows = java.util.Collections.synchronizedSet(HashSet<Int>())
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* Silence every window, whatever the document says.
|
|
460
|
+
*
|
|
461
|
+
* Set while a mixed-audio preview is playing. The mix already contains the
|
|
462
|
+
* clips' own audio — it is compiled from the same program an export uses —
|
|
463
|
+
* so leaving the player audible would play the whole timeline twice, once
|
|
464
|
+
* mixed and once raw.
|
|
465
|
+
*/
|
|
466
|
+
@Volatile private var sourceAudioMuted = false
|
|
467
|
+
|
|
468
|
+
/**
|
|
469
|
+
* Apply the mute for a window.
|
|
470
|
+
*
|
|
471
|
+
* `mutedWindows` was populated by [setPlaylist] and read by nothing, which
|
|
472
|
+
* meant a muted clip was silent in an export and audible in preview — the
|
|
473
|
+
* two disagreeing about the document, quietly. Applied here beside the
|
|
474
|
+
* speed, at the same three moments a window becomes current.
|
|
475
|
+
*/
|
|
476
|
+
private fun applyWindowMute(windowIndex: Int) {
|
|
477
|
+
val silent = sourceAudioMuted || mutedWindows.contains(windowIndex)
|
|
478
|
+
val wanted = if (silent) 0f else 1f
|
|
479
|
+
if (player.volume != wanted) player.volume = wanted
|
|
480
|
+
publishedVolume = player.volume
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/** Silence, or restore, the player's own audio. */
|
|
484
|
+
fun setSourceAudioMuted(muted: Boolean) {
|
|
485
|
+
sourceAudioMuted = muted
|
|
486
|
+
PreviewTrace.record("source.mute") { org.json.JSONObject().put("muted", muted) }
|
|
487
|
+
onPlayer { applyWindowMute(currentMediaItemIndex) }
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/**
|
|
491
|
+
* The volume the source player is actually at.
|
|
492
|
+
*
|
|
493
|
+
* Published on every mute application rather than read on demand, because
|
|
494
|
+
* reading it needs the player's own thread and the trace samples from
|
|
495
|
+
* another one. A flag says what was asked for; this says what happened.
|
|
496
|
+
*/
|
|
497
|
+
@Volatile private var publishedVolume: Float = 1f
|
|
498
|
+
|
|
499
|
+
/** Where each window begins on the project timeline. */
|
|
500
|
+
@Volatile private var windowTimelineStartUs: LongArray = LongArray(0)
|
|
501
|
+
|
|
502
|
+
/** The last buffering state seen, so a repeat is not acted on twice. */
|
|
503
|
+
@Volatile private var lastBufferingSeen = false
|
|
504
|
+
|
|
505
|
+
/**
|
|
506
|
+
* Whether the picture is held for a coordinated resume.
|
|
507
|
+
*
|
|
508
|
+
* `playWhenReady` is the transport, and a hold takes it away without
|
|
509
|
+
* touching what the person asked for — [PreviewSync] keeps that.
|
|
510
|
+
*/
|
|
511
|
+
@Volatile private var transportHeld = false
|
|
512
|
+
|
|
513
|
+
/** Hold or release the picture's transport, on the player's own thread. */
|
|
514
|
+
private fun holdTransport(hold: Boolean) {
|
|
515
|
+
if (transportHeld == hold) return
|
|
516
|
+
transportHeld = hold
|
|
517
|
+
onPlayer { playWhenReady = !hold }
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/** Window-local media time to project time, as `windowToProjectTime` does. */
|
|
521
|
+
internal fun speedFor(windowIndex: Int): Float {
|
|
522
|
+
val speeds = windowSpeeds ?: return -1f
|
|
523
|
+
return speeds.getOrNull(windowIndex)?.takeIf { it > 0f } ?: -1f
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
private fun globalFor(windowIndex: Int, localUs: Long): Long {
|
|
527
|
+
val starts = windowTimelineStartUs
|
|
528
|
+
val speeds = windowSpeeds
|
|
529
|
+
if (windowIndex < 0 || windowIndex >= starts.size) return localUs
|
|
530
|
+
val speed = speeds?.getOrNull(windowIndex)?.takeIf { it > 0f } ?: 1f
|
|
531
|
+
return starts[windowIndex] + Math.round(localUs / speed.toDouble())
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
/**
|
|
535
|
+
* Point the existing player at another source, without rebuilding it.
|
|
536
|
+
*
|
|
537
|
+
* `stop` + `clearMediaItems` + `setMediaItem` + `prepare` is the documented
|
|
538
|
+
* reuse path. Whether it actually resets session state, or accumulates,
|
|
539
|
+
* is the question heap attribution answers — so it exists to be measured,
|
|
540
|
+
* not because reuse is assumed better.
|
|
541
|
+
*/
|
|
542
|
+
fun rebind(uriString: String) {
|
|
543
|
+
FrameClock.bump()
|
|
544
|
+
clearError()
|
|
545
|
+
val done = java.util.concurrent.CountDownLatch(1)
|
|
546
|
+
main.post {
|
|
547
|
+
runCatching {
|
|
548
|
+
player.stop()
|
|
549
|
+
player.clearMediaItems()
|
|
550
|
+
player.setMediaItem(MediaItem.fromUri(Uri.parse(uriString)))
|
|
551
|
+
player.prepare()
|
|
552
|
+
}
|
|
553
|
+
done.countDown()
|
|
554
|
+
}
|
|
555
|
+
done.await(RELEASE_TIMEOUT_MS, java.util.concurrent.TimeUnit.MILLISECONDS)
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
/**
|
|
559
|
+
* Replace the whole playlist, keeping the player, surface and texture.
|
|
560
|
+
*
|
|
561
|
+
* Two parallel arrays: the sources, and four numbers per entry — clipping
|
|
562
|
+
* start and end in microseconds, speed in milli-units, muted as 0/1. Flat,
|
|
563
|
+
* because this crosses the bridge and a map per clip would allocate a
|
|
564
|
+
* native-backed object per entry on every edit; the timeline can change on
|
|
565
|
+
* every frame of a trim drag.
|
|
566
|
+
*
|
|
567
|
+
* **The texture and the `SkImage` are untouched.** That is the whole point
|
|
568
|
+
* of a process-scoped presentation surface: reordering clips or deleting one
|
|
569
|
+
* is a playlist operation, not a reason to rebuild the GL objects the
|
|
570
|
+
* renderer holds.
|
|
571
|
+
*
|
|
572
|
+
* The generation bumps first, so frames already queued for the old playlist
|
|
573
|
+
* are rejected on arrival rather than paired with a time in the new one.
|
|
574
|
+
*/
|
|
575
|
+
fun setPlaylist(
|
|
576
|
+
uris: List<String>,
|
|
577
|
+
bounds: DoubleArray,
|
|
578
|
+
startWindow: Int,
|
|
579
|
+
startPositionUs: Long,
|
|
580
|
+
) {
|
|
581
|
+
FrameClock.bump()
|
|
582
|
+
clearError()
|
|
583
|
+
// Defensive only. Attachment happens when a surface becomes live; this
|
|
584
|
+
// is here so a playlist can never be loaded against a stale surface if
|
|
585
|
+
// that path is ever bypassed, not as the mechanism itself.
|
|
586
|
+
attachSurface()
|
|
587
|
+
val done = java.util.concurrent.CountDownLatch(1)
|
|
588
|
+
main.post {
|
|
589
|
+
runCatching {
|
|
590
|
+
val media = ArrayList<MediaItem>(uris.size)
|
|
591
|
+
val speeds = ArrayList<Float>(uris.size)
|
|
592
|
+
for ((index, uri) in uris.withIndex()) {
|
|
593
|
+
val base = index * FIELDS_PER_ITEM
|
|
594
|
+
val startUs = bounds[base].toLong()
|
|
595
|
+
val endUs = bounds[base + 1].toLong()
|
|
596
|
+
val speed = (bounds[base + 2] / 1000.0).toFloat()
|
|
597
|
+
val muted = bounds[base + 3] != 0.0
|
|
598
|
+
media.add(
|
|
599
|
+
MediaItem.Builder()
|
|
600
|
+
.setUri(Uri.parse(uri))
|
|
601
|
+
.setClippingConfiguration(
|
|
602
|
+
MediaItem.ClippingConfiguration.Builder()
|
|
603
|
+
// Media3 clips in milliseconds. The timeline is
|
|
604
|
+
// microseconds, so this is the one place the two
|
|
605
|
+
// units meet, and it rounds rather than truncates:
|
|
606
|
+
// truncation biases every trim earlier.
|
|
607
|
+
.setStartPositionMs((startUs + 500) / 1000)
|
|
608
|
+
.setEndPositionMs((endUs + 500) / 1000)
|
|
609
|
+
.build(),
|
|
610
|
+
)
|
|
611
|
+
.build(),
|
|
612
|
+
)
|
|
613
|
+
speeds.add(if (speed > 0f) speed else 1f)
|
|
614
|
+
if (muted) mutedWindows.add(index) else mutedWindows.remove(index)
|
|
615
|
+
}
|
|
616
|
+
windowSpeeds = speeds.toFloatArray()
|
|
617
|
+
/*
|
|
618
|
+
Where each window begins on the project timeline.
|
|
619
|
+
|
|
620
|
+
`player.currentPosition` is position *within the current
|
|
621
|
+
window*, so a consumer comparing it against a global time sees
|
|
622
|
+
it reset at every seam — measured as a 9.8 s apparent offset
|
|
623
|
+
when the trace first read it. The same cumulative sum
|
|
624
|
+
`windowToProjectTime` performs in `@pictor-sdk/video-core`, kept
|
|
625
|
+
here so a reader on another thread can convert without the
|
|
626
|
+
project.
|
|
627
|
+
*/
|
|
628
|
+
val starts = LongArray(speeds.size)
|
|
629
|
+
var at = 0L
|
|
630
|
+
for (i in speeds.indices) {
|
|
631
|
+
starts[i] = at
|
|
632
|
+
val base = i * 4
|
|
633
|
+
val startUs = bounds[base].toLong()
|
|
634
|
+
val endUs = bounds[base + 1].toLong()
|
|
635
|
+
val speed = speeds[i]
|
|
636
|
+
at += Math.round((endUs - startUs) / speed.toDouble())
|
|
637
|
+
}
|
|
638
|
+
windowTimelineStartUs = starts
|
|
639
|
+
player.setMediaItems(media, startWindow, startPositionUs / 1000)
|
|
640
|
+
applyWindowSpeed(startWindow)
|
|
641
|
+
applyWindowMute(startWindow)
|
|
642
|
+
player.prepare()
|
|
643
|
+
}
|
|
644
|
+
done.countDown()
|
|
645
|
+
}
|
|
646
|
+
done.await(RELEASE_TIMEOUT_MS, java.util.concurrent.TimeUnit.MILLISECONDS)
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
/**
|
|
650
|
+
* Per-window playback speed.
|
|
651
|
+
*
|
|
652
|
+
* ExoPlayer's speed is a property of the player, not of an item, so a
|
|
653
|
+
* timeline whose clips differ in speed has to change it as windows change.
|
|
654
|
+
* Applied on transition rather than continuously — setting it every tick
|
|
655
|
+
* would fight the user's own rate control.
|
|
656
|
+
*/
|
|
657
|
+
private fun applyWindowSpeed(windowIndex: Int) {
|
|
658
|
+
val speeds = windowSpeeds
|
|
659
|
+
// No playlist, or an index the playlist does not have.
|
|
660
|
+
//
|
|
661
|
+
// Both used to `return`, which leaves `playbackParameters` at whatever
|
|
662
|
+
// was last set. A cleared playlist therefore kept the rate of the
|
|
663
|
+
// playlist before it, and an index past the end kept the rate of a
|
|
664
|
+
// window that no longer exists — a stale value that outlives the thing
|
|
665
|
+
// that justified it. Unity is the only honest rate for a player with
|
|
666
|
+
// nothing to play, and it is a value rather than an absence, so it
|
|
667
|
+
// cannot be confused with "leave it alone".
|
|
668
|
+
if (speeds == null || speeds.isEmpty()) {
|
|
669
|
+
resetRate()
|
|
670
|
+
return
|
|
671
|
+
}
|
|
672
|
+
val speed = speeds.getOrNull(windowIndex)
|
|
673
|
+
if (speed == null) {
|
|
674
|
+
staleWindowSpeeds += 1
|
|
675
|
+
resetRate()
|
|
676
|
+
return
|
|
677
|
+
}
|
|
678
|
+
val requested = speed * userRate
|
|
679
|
+
// Clamped to the same range `composeRate` uses in @pictor-sdk/video-core.
|
|
680
|
+
// The two must agree: if the player quietly refused a rate the timeline
|
|
681
|
+
// still counted at, the picture would advance at one speed while the
|
|
682
|
+
// playhead moved at another — a drift that reads as a broken clock and
|
|
683
|
+
// is really an unreported refusal. Counted so it is visible rather than
|
|
684
|
+
// inferred.
|
|
685
|
+
requestedRate = requested
|
|
686
|
+
val effective = requested.coerceIn(MIN_RATE, MAX_RATE)
|
|
687
|
+
if (effective != requested) rateConstrained += 1
|
|
688
|
+
if (player.playbackParameters.speed != effective) {
|
|
689
|
+
player.playbackParameters = PlaybackParameters(effective)
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
/**
|
|
694
|
+
* Put the player back to unity.
|
|
695
|
+
*
|
|
696
|
+
* Separate from `applyWindowSpeed` so "there is no rate to apply" is an
|
|
697
|
+
* action rather than a missing one. Never clamps, so it can never be the
|
|
698
|
+
* path that selects a ceiling.
|
|
699
|
+
*/
|
|
700
|
+
private fun resetRate() {
|
|
701
|
+
if (player.playbackParameters.speed != 1f) {
|
|
702
|
+
player.playbackParameters = PlaybackParameters(1f)
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
/** Times a requested rate had to be clamped. Should stay zero in the UI. */
|
|
707
|
+
@Volatile private var rateConstrained = 0L
|
|
708
|
+
|
|
709
|
+
/**
|
|
710
|
+
* Times a window index had no speed.
|
|
711
|
+
*
|
|
712
|
+
* A playlist and a window index that disagree. Should stay zero; a non-zero
|
|
713
|
+
* value means the two were read at different moments.
|
|
714
|
+
*/
|
|
715
|
+
@Volatile private var staleWindowSpeeds = 0L
|
|
716
|
+
|
|
717
|
+
/** The rate asked for before clamping, so a ceiling is distinguishable. */
|
|
718
|
+
@Volatile private var requestedRate = 1f
|
|
719
|
+
|
|
720
|
+
/**
|
|
721
|
+
* Point the player at the surface that is live now.
|
|
722
|
+
*
|
|
723
|
+
* Called by [VideoSurface] the instant a surface becomes live, so a host
|
|
724
|
+
* never has to remember it. Idempotent by generation: re-attaching an
|
|
725
|
+
* unchanged surface makes Media3 drop and rebuild its output, which costs
|
|
726
|
+
* frames at exactly the wrong moment.
|
|
727
|
+
*/
|
|
728
|
+
private fun attachSurface() {
|
|
729
|
+
if (released || !VideoSurface.attachment.needsAttach()) return
|
|
730
|
+
val surface = VideoSurface.currentSurface() ?: return
|
|
731
|
+
val done = java.util.concurrent.CountDownLatch(1)
|
|
732
|
+
main.post {
|
|
733
|
+
runCatching {
|
|
734
|
+
if (!released && VideoSurface.attachment.attach()) {
|
|
735
|
+
player.setVideoSurface(surface)
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
done.countDown()
|
|
739
|
+
}
|
|
740
|
+
done.await(RELEASE_TIMEOUT_MS, java.util.concurrent.TimeUnit.MILLISECONDS)
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
/** Seek by window and offset, the player's own coordinates. */
|
|
744
|
+
fun seekToWindow(windowIndex: Int, positionUs: Long) {
|
|
745
|
+
FrameClock.bump()
|
|
746
|
+
seeksRequested += 1
|
|
747
|
+
pendingSeek = true
|
|
748
|
+
onPlayer {
|
|
749
|
+
seekTo(windowIndex, positionUs / 1000)
|
|
750
|
+
// Cleared on the player's thread once the seek has been accepted,
|
|
751
|
+
// so a `pendingSeek` that stays true is a seek the player never
|
|
752
|
+
// acted on — which is what a freeze would look like from here.
|
|
753
|
+
pendingSeek = false
|
|
754
|
+
seeksAccepted += 1
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
/**
|
|
759
|
+
* Counters for the cross-window freeze soak.
|
|
760
|
+
*
|
|
761
|
+
* Deliberately cheap: three longs and a boolean, incremented on paths that
|
|
762
|
+
* already exist. A freeze that never reproduces still leaves these behind,
|
|
763
|
+
* so the next occurrence is diagnosable instead of anecdotal.
|
|
764
|
+
*/
|
|
765
|
+
@Volatile private var seeksRequested = 0L
|
|
766
|
+
@Volatile private var seeksAccepted = 0L
|
|
767
|
+
@Volatile private var pendingSeek = false
|
|
768
|
+
@Volatile private var playbackState = 0
|
|
769
|
+
@Volatile private var videoFormat = ""
|
|
770
|
+
@Volatile private var decoderName = ""
|
|
771
|
+
@Volatile private var inputFormats = 0
|
|
772
|
+
@Volatile private var inputWidth = 0
|
|
773
|
+
@Volatile private var inputHeight = 0
|
|
774
|
+
@Volatile private var inputRotation = 0
|
|
775
|
+
@Volatile private var decoderReuse = -1
|
|
776
|
+
@Volatile private var outputSizes = 0
|
|
777
|
+
@Volatile private var outputWidth = 0
|
|
778
|
+
@Volatile private var outputHeight = 0
|
|
779
|
+
/** Bumped whenever a surface is attached, so re-attachment is visible. */
|
|
780
|
+
@Volatile private var surfaceGeneration = 0
|
|
781
|
+
|
|
782
|
+
/** End the source but keep the player, for the reuse configurations. */
|
|
783
|
+
fun stopKeepingPlayer() {
|
|
784
|
+
FrameClock.bump()
|
|
785
|
+
val done = java.util.concurrent.CountDownLatch(1)
|
|
786
|
+
main.post {
|
|
787
|
+
runCatching {
|
|
788
|
+
player.stop()
|
|
789
|
+
player.clearMediaItems()
|
|
790
|
+
}
|
|
791
|
+
done.countDown()
|
|
792
|
+
}
|
|
793
|
+
done.await(RELEASE_TIMEOUT_MS, java.util.concurrent.TimeUnit.MILLISECONDS)
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
/** Live thread count, for leak attribution. Diagnostics only. */
|
|
797
|
+
fun threadCount(): Int = Thread.getAllStackTraces().size
|
|
798
|
+
|
|
799
|
+
/** Explicit, on the player's own thread. Never from a finalizer. */
|
|
800
|
+
fun release() {
|
|
801
|
+
if (live === this) live = null
|
|
802
|
+
if (released) return
|
|
803
|
+
released = true
|
|
804
|
+
val latch = java.util.concurrent.CountDownLatch(1)
|
|
805
|
+
main.post {
|
|
806
|
+
// Listener off before the surface, surface before the player.
|
|
807
|
+
// Media3 takes a non-null listener, so a no-op replaces ours
|
|
808
|
+
// rather than clearing it — the player is about to go anyway, and
|
|
809
|
+
// this stops FrameClock being fed during teardown.
|
|
810
|
+
runCatching {
|
|
811
|
+
player.setVideoFrameMetadataListener { _, _, _, _ -> }
|
|
812
|
+
}
|
|
813
|
+
VideoSurface.onSurfaceLive = null
|
|
814
|
+
runCatching { player.setVideoSurface(null) }
|
|
815
|
+
runCatching { player.release() }
|
|
816
|
+
main.removeCallbacks(anchorPump)
|
|
817
|
+
FrameClock.bump()
|
|
818
|
+
PlaybackAnchor.reset()
|
|
819
|
+
latch.countDown()
|
|
820
|
+
}
|
|
821
|
+
latch.await(RELEASE_TIMEOUT_MS, java.util.concurrent.TimeUnit.MILLISECONDS)
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
companion object {
|
|
825
|
+
/**
|
|
826
|
+
* The live player's volume, or null when there is none.
|
|
827
|
+
*
|
|
828
|
+
* For the trace, which needs to prove that source audio is silent
|
|
829
|
+
* while a mix is playing — a mute flag says what was intended, and
|
|
830
|
+
* this says what the player is at.
|
|
831
|
+
*/
|
|
832
|
+
fun currentVolumeOrNull(): Double? = live?.publishedVolume?.toDouble()
|
|
833
|
+
|
|
834
|
+
/**
|
|
835
|
+
* The project-timeline position for a window-local one.
|
|
836
|
+
*
|
|
837
|
+
* Returns the input unchanged when there is no playlist to convert
|
|
838
|
+
* against, which is the honest answer for a single-item player.
|
|
839
|
+
*/
|
|
840
|
+
fun globalPositionUs(windowIndex: Int, localUs: Long): Long =
|
|
841
|
+
live?.globalFor(windowIndex, localUs) ?: localUs
|
|
842
|
+
|
|
843
|
+
/**
|
|
844
|
+
* The speed the local-to-global conversion is dividing by.
|
|
845
|
+
*
|
|
846
|
+
* Exposed for the trace: the conversion has two factors — the rate the
|
|
847
|
+
* player is running at and the speed this window is declared with — and
|
|
848
|
+
* a project clock advancing at the wrong rate cannot be attributed to
|
|
849
|
+
* one of them without seeing both.
|
|
850
|
+
*/
|
|
851
|
+
fun windowSpeedFor(windowIndex: Int): Float = live?.speedFor(windowIndex) ?: -1f
|
|
852
|
+
|
|
853
|
+
/** The player the module is holding, for read-only diagnostics. */
|
|
854
|
+
@Volatile internal var live: Media3Player? = null
|
|
855
|
+
|
|
856
|
+
private const val RELEASE_TIMEOUT_MS = 2_000L
|
|
857
|
+
|
|
858
|
+
/** Clipping start, clipping end, speed, muted — per playlist entry. */
|
|
859
|
+
private const val FIELDS_PER_ITEM = 4
|
|
860
|
+
|
|
861
|
+
/** Mirrors MIN/MAX_PLAYBACK_RATE in @pictor-sdk/video-core. */
|
|
862
|
+
private const val MIN_RATE = 0.1f
|
|
863
|
+
private const val MAX_RATE = 10f
|
|
864
|
+
|
|
865
|
+
/**
|
|
866
|
+
* How often the position anchor is refreshed.
|
|
867
|
+
*
|
|
868
|
+
* Coarser than a frame on purpose: consumers extrapolate between
|
|
869
|
+
* anchors, so this only bounds drift, and a tighter interval would buy
|
|
870
|
+
* nothing but wake-ups.
|
|
871
|
+
*/
|
|
872
|
+
private const val ANCHOR_INTERVAL_MS = 50L
|
|
873
|
+
|
|
874
|
+
/**
|
|
875
|
+
* Build a player rendering into the registered presentation surface.
|
|
876
|
+
*
|
|
877
|
+
* The surface has to exist first: it is created on the UI runtime,
|
|
878
|
+
* where Skia's GL context lives, and only then handed here.
|
|
879
|
+
*/
|
|
880
|
+
fun create(
|
|
881
|
+
context: Context,
|
|
882
|
+
uriString: String,
|
|
883
|
+
/** Configuration 1 of heap attribution: a player with no output. */
|
|
884
|
+
attachSurface: Boolean = true,
|
|
885
|
+
): Media3Player {
|
|
886
|
+
val surface = if (attachSurface) {
|
|
887
|
+
VideoSurface.currentSurface()
|
|
888
|
+
?: throw IllegalStateException(
|
|
889
|
+
"no presentation surface: create the external texture before preparing",
|
|
890
|
+
)
|
|
891
|
+
} else {
|
|
892
|
+
null
|
|
893
|
+
}
|
|
894
|
+
// A new source invalidates every queued frame.
|
|
895
|
+
FrameClock.bump()
|
|
896
|
+
val built = java.util.concurrent.CompletableFuture<ExoPlayer>()
|
|
897
|
+
Handler(Looper.getMainLooper()).post {
|
|
898
|
+
val player = ExoPlayer.Builder(context).build()
|
|
899
|
+
player.setAudioAttributes(
|
|
900
|
+
AudioAttributes.Builder()
|
|
901
|
+
.setUsage(C.USAGE_MEDIA)
|
|
902
|
+
.setContentType(C.AUDIO_CONTENT_TYPE_MOVIE)
|
|
903
|
+
.build(),
|
|
904
|
+
// Media3 handles audio focus, which the custom backend
|
|
905
|
+
// never did.
|
|
906
|
+
true,
|
|
907
|
+
)
|
|
908
|
+
// The clip's presentation time only exists here. It is paired
|
|
909
|
+
// with the renderer's frames positionally, which holds because
|
|
910
|
+
// this fires immediately before each frame is released to the
|
|
911
|
+
// surface and `onFrameAvailable` follows in the same order.
|
|
912
|
+
player.setVideoFrameMetadataListener(
|
|
913
|
+
VideoFrameMetadataListener { presentationTimeUs, releaseTimeNs, _, _ ->
|
|
914
|
+
FrameClock.offer(presentationTimeUs, releaseTimeNs)
|
|
915
|
+
},
|
|
916
|
+
)
|
|
917
|
+
if (surface != null) player.setVideoSurface(surface)
|
|
918
|
+
player.setMediaItem(MediaItem.fromUri(Uri.parse(uriString)))
|
|
919
|
+
player.prepare()
|
|
920
|
+
built.complete(player)
|
|
921
|
+
}
|
|
922
|
+
val player = built.get(5, java.util.concurrent.TimeUnit.SECONDS)
|
|
923
|
+
return Media3Player(player, 0, 0).also {
|
|
924
|
+
it.startAnchor()
|
|
925
|
+
it.attachErrorListener()
|
|
926
|
+
it.ownSurfaceAttachment()
|
|
927
|
+
// Held for read-only diagnostics: the trace has to be able to
|
|
928
|
+
// ask what volume the source player is actually at, and it
|
|
929
|
+
// samples from a thread that has no reference to it.
|
|
930
|
+
live = it
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
}
|