Skip to content

Commit 2758a2f

Browse files
committed
fix(windows): address review findings on native player refactor
Bugs: - Guard against clock-skew underflow when computing cached-sample heldMs - Stop audio thread before the presentation clock in StopPlayback - InitWASAPI no longer takes ownership of pSourceAudioFormat; caller transfers - Triple-buffer Skia bitmaps to remove producer/Compose read race - dispose() avoids runBlocking on AWT EDT to prevent potential deadlock Performance: - SeekMedia wakes the audio thread via event before taking csAudioFeed - Underflow-safe UINT32 arithmetic on framesFree Cleanup: - HLSPlayer stored via ComPtr<HLSPlayer> instead of raw pointer - HLSPlayer frame buffer uses std::vector<BYTE> instead of new[]/delete[] - Replace 0xC00D36C4 magic with MF_E_UNSUPPORTED_BYTESTREAM_TYPE - Extract NVP_MIN/MAX_PLAYBACK_SPEED constants in NativeVideoPlayer.h - Extract startVideoPipeline() helper to dedupe producer/consumer launches - Replace goto cleanup in InitWASAPI with a RAII scope guard - GetMediaDuration returns S_FALSE when duration is absent (vs hard error) - Shutdown logs a warning when live instances remain - Skip redundant 2ch/48kHz audio fallback when already canonical - Drop the default nullptr argument on InitWASAPI - Remove dead clearAllResourcesSync path
1 parent 413d330 commit 2758a2f

9 files changed

Lines changed: 184 additions & 174 deletions

File tree

mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/windows/WindowsVideoPlayerState.kt

Lines changed: 66 additions & 89 deletions
Original file line numberDiff line numberDiff line change
@@ -269,11 +269,12 @@ class WindowsVideoPlayerState : VideoPlayerState {
269269
private val videoReaderMutex = Mutex()
270270
private val isSeeking = AtomicBoolean(false)
271271

272-
// Memory optimization for frame processing
273-
private val frameQueueSize = 1
272+
// Frame channel: one slot, drop-oldest. With triple-buffering on the
273+
// producer side, overflow simply means the consumer was slow — safe to
274+
// drop. Capacity >1 would just let the pipeline pile up.
274275
private val frameChannel =
275276
Channel<FrameData>(
276-
capacity = frameQueueSize,
277+
capacity = 1,
277278
onBufferOverflow = BufferOverflow.DROP_OLDEST,
278279
)
279280

@@ -283,10 +284,13 @@ class WindowsVideoPlayerState : VideoPlayerState {
283284
val timestamp: Double,
284285
)
285286

286-
// Double-buffering for zero-copy frame rendering
287-
private var skiaBitmapA: Bitmap? = null
288-
private var skiaBitmapB: Bitmap? = null
289-
private var nextSkiaBitmapA: Boolean = true
287+
// Triple-buffering for zero-copy frame rendering: the consumer may still
288+
// be driving a frame onto Compose (via currentFrameState) when the
289+
// producer writes the next frame. Two bitmaps is racy — with three, the
290+
// buffer the producer writes is guaranteed to be distinct from both the
291+
// one currently bound to ImageBitmap and the one Compose just finished.
292+
private val skiaBitmaps = arrayOfNulls<Bitmap>(3)
293+
private var nextBitmapIndex: Int = 0
290294
@Volatile
291295
private var lastFrameHash: Int = Int.MIN_VALUE
292296
private var skiaBitmapWidth: Int = 0
@@ -359,13 +363,26 @@ class WindowsVideoPlayerState : VideoPlayerState {
359363
// StopAudioThread + MF teardown) but guarantees a clean shutdown.
360364
if (instance != 0L) {
361365
if (jobToJoin != null) {
362-
try {
363-
kotlinx.coroutines.runBlocking {
364-
kotlinx.coroutines.withTimeoutOrNull(500) {
365-
jobToJoin.join()
366-
}
366+
// Avoid runBlocking on the AWT Event Dispatch Thread: if any
367+
// child coroutine of `scope` ever chains on Dispatchers.Main
368+
// (even indirectly, e.g. Compose effects), joining here would
369+
// deadlock. Fall back to a plain Thread.join on EDT — the
370+
// 500 ms cap keeps the UI from hanging if the native side
371+
// stalls.
372+
val deadlineNs = System.nanoTime() + 500_000_000L
373+
if (java.awt.EventQueue.isDispatchThread()) {
374+
while (jobToJoin.isActive && System.nanoTime() < deadlineNs) {
375+
try { Thread.sleep(10) } catch (_: InterruptedException) { break }
367376
}
368-
} catch (_: Exception) { /* ignore */ }
377+
} else {
378+
try {
379+
kotlinx.coroutines.runBlocking {
380+
kotlinx.coroutines.withTimeoutOrNull(500) {
381+
jobToJoin.join()
382+
}
383+
}
384+
} catch (_: Exception) { /* ignore */ }
385+
}
369386
}
370387

371388
try {
@@ -389,81 +406,38 @@ class WindowsVideoPlayerState : VideoPlayerState {
389406
scope.cancel()
390407
}
391408

392-
private fun clearAllResourcesSync() {
393-
// Clear the frame channel synchronously
394-
while (frameChannel.tryReceive().isSuccess) {
395-
// Drain the channel
396-
}
397-
398-
// Free bitmaps and frame buffers
399-
bitmapLock.write {
400-
_currentFrame = null
401-
currentFrameState.value = null
402-
403-
// Don't close bitmaps — see comment in releaseAllResources().
404-
skiaBitmapA = null
405-
skiaBitmapB = null
406-
skiaBitmapWidth = 0
407-
skiaBitmapHeight = 0
408-
nextSkiaBitmapA = true
409-
lastFrameHash = Int.MIN_VALUE
410-
// Deferred-close queue: drop refs, let Skia cleaner finalize them
411-
// (AWT may still hold the newest one).
412-
pendingCloseBitmaps.clear()
413-
}
414-
415-
// Reset all state
416-
_currentTime = 0.0
417-
_duration = 0.0
418-
_progress = 0f
419-
_metadata = VideoMetadata()
420-
userPaused = false
421-
isLoading = false
422-
errorMessage = null
423-
_error = null
424-
425-
// Reset initialFrameRead flag to ensure we read an initial frame when reinitialized
426-
initialFrameRead.set(false)
427-
}
428-
429409
private fun releaseAllResources() {
430410
// Cancel any remaining jobs related to video processing
431411
videoJob?.cancel()
432412
resizeJob?.cancel()
433413

434-
// Drain the frame channel (tryReceive is non-suspending)
435414
clearFrameChannel()
436415

437-
// Free bitmaps and frame buffers
416+
// Free bitmaps and frame buffers.
417+
// Do NOT close the triple-buffer bitmaps here: the ImageBitmap exposed
418+
// via currentFrameState shares the same native pixel memory
419+
// (asComposeImageBitmap is zero-copy). Compose may still be rendering
420+
// the last frame on the AWT-EventQueue thread. Closing now would free
421+
// the native memory while Skia reads it, causing an access violation.
422+
// Nullifying the references lets the Skia Managed cleaner release them
423+
// once Compose (and any other holder) drops its reference.
438424
bitmapLock.write {
439425
_currentFrame = null
440426
currentFrameState.value = null
441427

442-
// Do NOT close the double-buffer bitmaps here: the ImageBitmap
443-
// exposed via currentFrameState shares the same native pixel memory
444-
// (asComposeImageBitmap is zero-copy). Compose may still be rendering
445-
// the last frame on the AWT-EventQueue thread. Closing now would free
446-
// the native memory while Skia reads it, causing an access violation.
447-
// Nullifying the references lets the Skia Managed cleaner release them
448-
// once Compose (and any other holder) drops its reference.
449-
skiaBitmapA = null
450-
skiaBitmapB = null
428+
for (i in skiaBitmaps.indices) skiaBitmaps[i] = null
451429
skiaBitmapWidth = 0
452430
skiaBitmapHeight = 0
453-
nextSkiaBitmapA = true
431+
nextBitmapIndex = 0
454432
lastFrameHash = Int.MIN_VALUE
455433
pendingCloseBitmaps.clear()
456434
}
457435

458-
// Reset initialFrameRead flag to ensure we read an initial frame when reinitialized
459436
initialFrameRead.set(false)
460437
}
461438

462439
private fun clearFrameChannel() {
463-
// Drain the frame channel to ensure all items are removed
464-
while (frameChannel.tryReceive().isSuccess) {
465-
// Intentionally empty - just draining the channel
466-
}
440+
while (frameChannel.tryReceive().isSuccess) { /* drain */ }
467441
}
468442

469443
/**
@@ -665,11 +639,7 @@ class WindowsVideoPlayerState : VideoPlayerState {
665639
_isPlaying = startPlayback
666640

667641
// Start video processing
668-
videoJob =
669-
scope.launch {
670-
launch { produceFrames() }
671-
launch { consumeFrames() }
672-
}
642+
videoJob = startVideoPipeline()
673643
}
674644
} catch (e: Exception) {
675645
setError("Error while opening media: ${e.message}")
@@ -681,6 +651,15 @@ class WindowsVideoPlayerState : VideoPlayerState {
681651
}
682652
}
683653

654+
/**
655+
* Launches the producer/consumer coroutine pair that reads frames from
656+
* the native side and pushes them to Compose.
657+
*/
658+
private fun startVideoPipeline(): Job = scope.launch {
659+
launch { produceFrames() }
660+
launch { consumeFrames() }
661+
}
662+
684663
/**
685664
* Zero-copy optimized frame producer using double-buffering and direct memory access.
686665
*
@@ -819,26 +798,31 @@ class WindowsVideoPlayerState : VideoPlayerState {
819798
}
820799
lastFrameHash = newHash
821800

822-
if (skiaBitmapA == null || skiaBitmapWidth != width || skiaBitmapHeight != height) {
801+
if (skiaBitmaps[0] == null || skiaBitmapWidth != width || skiaBitmapHeight != height) {
823802
bitmapLock.write {
824803
// Queue previous bitmaps for deferred close instead of leaking them
825804
// to the Skia managed cleaner: closing now would race with Compose
826805
// still drawing the last frame on the AWT thread.
827-
skiaBitmapA?.let { pendingCloseBitmaps.addLast(PendingCloseBitmap(it, pendingCloseGraceFrames)) }
828-
skiaBitmapB?.let { pendingCloseBitmaps.addLast(PendingCloseBitmap(it, pendingCloseGraceFrames)) }
806+
for (i in skiaBitmaps.indices) {
807+
skiaBitmaps[i]?.let {
808+
pendingCloseBitmaps.addLast(PendingCloseBitmap(it, pendingCloseGraceFrames))
809+
}
810+
skiaBitmaps[i] = null
811+
}
829812
val imageInfo = createVideoImageInfo()
830-
skiaBitmapA = Bitmap().apply { allocPixels(imageInfo) }
831-
skiaBitmapB = Bitmap().apply { allocPixels(imageInfo) }
813+
for (i in skiaBitmaps.indices) {
814+
skiaBitmaps[i] = Bitmap().apply { allocPixels(imageInfo) }
815+
}
832816
skiaBitmapWidth = width
833817
skiaBitmapHeight = height
834-
nextSkiaBitmapA = true
818+
nextBitmapIndex = 0
835819
}
836820
}
837821

838822
drainPendingCloseBitmaps()
839823

840-
val targetBitmap = if (nextSkiaBitmapA) skiaBitmapA!! else skiaBitmapB!!
841-
nextSkiaBitmapA = !nextSkiaBitmapA
824+
val targetBitmap = skiaBitmaps[nextBitmapIndex]!!
825+
nextBitmapIndex = (nextBitmapIndex + 1) % skiaBitmaps.size
842826

843827
val pixmap = targetBitmap.peekPixels()
844828
if (pixmap == null) {
@@ -990,11 +974,7 @@ class WindowsVideoPlayerState : VideoPlayerState {
990974
}
991975

992976
if (_hasMedia && (videoJob == null || videoJob?.isActive == false)) {
993-
videoJob =
994-
scope.launch {
995-
launch { produceFrames() }
996-
launch { consumeFrames() }
997-
}
977+
videoJob = startVideoPipeline()
998978
}
999979
}
1000980

@@ -1146,10 +1126,7 @@ class WindowsVideoPlayerState : VideoPlayerState {
11461126
// If the producer was never started (e.g. stop() was called
11471127
// before the first play), start it now so the new frame shows.
11481128
if (!isDisposing.get() && (videoJob == null || videoJob?.isActive == false)) {
1149-
videoJob = scope.launch {
1150-
launch { produceFrames() }
1151-
launch { consumeFrames() }
1152-
}
1129+
videoJob = startVideoPipeline()
11531130
}
11541131
}
11551132
} finally {

mediaplayer/src/jvmMain/native/windows/AudioManager.cpp

Lines changed: 33 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,8 @@ int FeedOneSample(VideoPlayerInstance* inst, IMFSourceReader* audioReader,
9595
{
9696
UINT32 framesPadding = 0;
9797
if (FAILED(inst->pAudioClient->GetCurrentPadding(&framesPadding))) return -1;
98-
UINT32 framesFree = engineBufferFrames - framesPadding;
98+
UINT32 framesFree = (framesPadding < engineBufferFrames)
99+
? engineBufferFrames - framesPadding : 0;
99100
if (framesFree == 0) return 0;
100101

101102
const UINT32 sampleRate = inst->pSourceAudioFormat
@@ -149,9 +150,13 @@ int FeedOneSample(VideoPlayerInstance* inst, IMFSourceReader* audioReader,
149150

150151
UINT32 wantFrames = (std::min)(totalOutputFrames - outputDone, framesFree);
151152
if (wantFrames == 0) {
153+
// Render buffer full. Wait on hAudioSamplesReadyEvent for the
154+
// driver to free room — short timeout keeps us responsive to
155+
// seek/shutdown signals.
152156
WaitForSingleObject(inst->hAudioSamplesReadyEvent.Get(), 5);
153157
if (FAILED(inst->pAudioClient->GetCurrentPadding(&framesPadding))) break;
154-
framesFree = engineBufferFrames - framesPadding;
158+
framesFree = (framesPadding < engineBufferFrames)
159+
? engineBufferFrames - framesPadding : 0;
155160
continue;
156161
}
157162

@@ -206,7 +211,8 @@ int FeedOneSample(VideoPlayerInstance* inst, IMFSourceReader* audioReader,
206211
outputDone += wantFrames;
207212

208213
if (FAILED(inst->pAudioClient->GetCurrentPadding(&framesPadding))) break;
209-
framesFree = engineBufferFrames - framesPadding;
214+
framesFree = (framesPadding < engineBufferFrames)
215+
? engineBufferFrames - framesPadding : 0;
210216
}
211217

212218
if (needsResample) {
@@ -276,7 +282,7 @@ DWORD WINAPI AudioThreadProc(LPVOID lpParam) {
276282
} // namespace
277283

278284
HRESULT InitWASAPI(VideoPlayerInstance* inst, const WAVEFORMATEX* srcFmt) {
279-
if (!inst) return E_INVALIDARG;
285+
if (!inst || !srcFmt) return E_INVALIDARG;
280286
if (inst->pAudioClient && inst->pRenderClient) {
281287
inst->bAudioInitialized = true;
282288
return S_OK;
@@ -285,74 +291,54 @@ HRESULT InitWASAPI(VideoPlayerInstance* inst, const WAVEFORMATEX* srcFmt) {
285291
IMMDeviceEnumerator* enumerator = MediaFoundation::GetDeviceEnumerator();
286292
if (!enumerator) return E_FAIL;
287293

294+
// RAII cleanup: released by name on the single success return.
295+
struct CleanupGuard {
296+
VideoPlayerInstance* inst;
297+
bool armed = true;
298+
~CleanupGuard() {
299+
if (!armed) return;
300+
inst->pRenderClient.Reset();
301+
inst->pAudioClient.Reset();
302+
inst->pAudioEndpointVolume.Reset();
303+
inst->pDevice.Reset();
304+
inst->hAudioSamplesReadyEvent.Reset();
305+
inst->bAudioInitialized = false;
306+
}
307+
} guard{inst};
308+
288309
HRESULT hr = enumerator->GetDefaultAudioEndpoint(
289310
eRender, eConsole, inst->pDevice.ReleaseAndGetAddressOf());
290311
if (FAILED(hr)) return hr;
291312

292313
hr = inst->pDevice->Activate(__uuidof(IAudioClient), CLSCTX_ALL, nullptr,
293314
reinterpret_cast<void**>(inst->pAudioClient.ReleaseAndGetAddressOf()));
294-
if (FAILED(hr)) goto cleanup;
315+
if (FAILED(hr)) return hr;
295316

296317
hr = inst->pDevice->Activate(__uuidof(IAudioEndpointVolume), CLSCTX_ALL, nullptr,
297318
reinterpret_cast<void**>(inst->pAudioEndpointVolume.ReleaseAndGetAddressOf()));
298-
if (FAILED(hr)) goto cleanup;
299-
300-
{
301-
WAVEFORMATEX* mixFmt = nullptr;
302-
const WAVEFORMATEX* useFmt = srcFmt;
303-
if (!useFmt) {
304-
hr = inst->pAudioClient->GetMixFormat(&mixFmt);
305-
if (FAILED(hr)) goto cleanup;
306-
useFmt = mixFmt;
307-
}
308-
309-
size_t totalSize = sizeof(WAVEFORMATEX) + useFmt->cbSize;
310-
inst->pSourceAudioFormat = static_cast<WAVEFORMATEX*>(CoTaskMemAlloc(totalSize));
311-
if (!inst->pSourceAudioFormat) {
312-
if (mixFmt) CoTaskMemFree(mixFmt);
313-
hr = E_OUTOFMEMORY;
314-
goto cleanup;
315-
}
316-
memcpy(inst->pSourceAudioFormat, useFmt, totalSize);
317-
if (mixFmt) CoTaskMemFree(mixFmt);
318-
}
319+
if (FAILED(hr)) return hr;
319320

320321
if (!inst->hAudioSamplesReadyEvent) {
321322
inst->hAudioSamplesReadyEvent.Reset(CreateEventW(nullptr, FALSE, FALSE, nullptr));
322-
if (!inst->hAudioSamplesReadyEvent) {
323-
hr = HRESULT_FROM_WIN32(GetLastError());
324-
goto cleanup;
325-
}
323+
if (!inst->hAudioSamplesReadyEvent) return HRESULT_FROM_WIN32(GetLastError());
326324
}
327325

328326
hr = inst->pAudioClient->Initialize(AUDCLNT_SHAREMODE_SHARED,
329327
AUDCLNT_STREAMFLAGS_EVENTCALLBACK,
330328
kTargetBufferDuration100ns, 0,
331-
inst->pSourceAudioFormat, nullptr);
332-
if (FAILED(hr)) goto cleanup;
329+
srcFmt, nullptr);
330+
if (FAILED(hr)) return hr;
333331

334332
hr = inst->pAudioClient->SetEventHandle(inst->hAudioSamplesReadyEvent.Get());
335-
if (FAILED(hr)) goto cleanup;
333+
if (FAILED(hr)) return hr;
336334

337335
hr = inst->pAudioClient->GetService(__uuidof(IAudioRenderClient),
338336
reinterpret_cast<void**>(inst->pRenderClient.ReleaseAndGetAddressOf()));
339-
if (FAILED(hr)) goto cleanup;
337+
if (FAILED(hr)) return hr;
340338

341339
inst->bAudioInitialized = true;
340+
guard.armed = false;
342341
return S_OK;
343-
344-
cleanup:
345-
inst->pRenderClient.Reset();
346-
inst->pAudioClient.Reset();
347-
inst->pAudioEndpointVolume.Reset();
348-
inst->pDevice.Reset();
349-
if (inst->pSourceAudioFormat) {
350-
CoTaskMemFree(inst->pSourceAudioFormat);
351-
inst->pSourceAudioFormat = nullptr;
352-
}
353-
inst->hAudioSamplesReadyEvent.Reset();
354-
inst->bAudioInitialized = false;
355-
return hr;
356342
}
357343

358344
HRESULT PreFillAudioBuffer(VideoPlayerInstance* inst) {

0 commit comments

Comments
 (0)