From 56b69ae900802b3d0f3a916bbfa59b7cd3088ce5 Mon Sep 17 00:00:00 2001 From: towneh <25694892+towneh@users.noreply.github.com> Date: Mon, 20 Jul 2026 10:00:19 +0100 Subject: [PATCH 1/2] fix(mediaplayer): sync audio-only sources to remote players Loading an audio-only URL (.wav, and equally .mp3 / .m4a / .opus) played for whoever loaded it but never reached anyone else. Peers did nothing while a video was playing, and once their current VOD ended they replayed that video rather than the new source. Late joiners received the audio correctly, which isolates it to the live broadcast rather than the URL itself. Readiness was video-only. In BasisNativeVideoSource.Pump the whole readiness block sits behind the video frame counter and a non-null OutputTexture, neither of which an audio-only source ever produces, so OnReady never fired on any client. The engine handles audio-only correctly - it reaches Playing once audio frames are flowing on a source that announced no video track - but that state was never plumbed through to the C# event, which also left IsPrepared and Status wrong for audio-only. The URL broadcast then hung off that event. SetUrl skipped its up-front BroadcastFullState for directly-playable URLs and deferred to OnReady, which never ran, and FullState is the only message carrying a URL. currentSyncedUrl was set locally and never transmitted, so peers kept the source they already held and played that on the next bare Play command. Fire OnReady when the engine reaches Playing with an audio format and no video size - a pairing the engine only reports once the source has announced no video track, with split-stream excluded since its video leg announces later - and broadcast unconditionally from SetUrl. Broadcasting there happens before LoadUrl, while the player still holds the outgoing media, so the serialized state and playhead would describe the source being replaced and land on peers as the incoming source's start position. Mark that broadcast as a fresh load so it describes the load being started instead. The intent has to survive the pre-network-ready deferral queue as well, and be retired once the load reaches OnReady, after which the player's own state is the truth and later local commands must not be re-serialised as a pending load at position zero. Applying a resolved page URL also ignored the owner's advertised state: ApplyPendingRemoteState only ever stopped or paused, leaving playback to the peer's own AutoPlayOnSourceAssigned, so a peer with that unticked sat stopped while the owner played. The direct-URL path already forces this around LoadSource, so both paths now agree. IsPlaying and IsPaused are independent here, so a source that arrived paused is resumed rather than started. --- .../Runtime/BasisMediaPlayerNetworking.cs | 62 +++++++++++++------ .../Runtime/Native/BasisNativeVideoSource.cs | 12 ++++ 2 files changed, 55 insertions(+), 19 deletions(-) diff --git a/Basis/Packages/com.basis.mediaplayer/Runtime/BasisMediaPlayerNetworking.cs b/Basis/Packages/com.basis.mediaplayer/Runtime/BasisMediaPlayerNetworking.cs index e68943406..d3384306f 100644 --- a/Basis/Packages/com.basis.mediaplayer/Runtime/BasisMediaPlayerNetworking.cs +++ b/Basis/Packages/com.basis.mediaplayer/Runtime/BasisMediaPlayerNetworking.cs @@ -83,6 +83,7 @@ private enum SettingsFlags : byte /// The URL shared with peers for the current source — the input/page URL, not the per-client resolved stream. public string SyncedUrl => currentSyncedUrl; private bool sendOnNetworkReady; + private bool sendOnNetworkReadyFreshLoad; private bool applyingRemoteCommand; private bool eventsHooked; private ushort loadNonce; @@ -185,7 +186,9 @@ public override void OnNetworkReady() if (sendOnNetworkReady) { sendOnNetworkReady = false; - BroadcastFullState(); + bool freshLoad = sendOnNetworkReadyFreshLoad; + sendOnNetworkReadyFreshLoad = false; + BroadcastFullState(freshLoad); } } @@ -264,17 +267,14 @@ public async Task SetUrl(string url) loadNonce++; syncedUrlFromSetUrl = true; - // A page URL costs each client seconds of yt-dlp resolution. Broadcasting it up front - // lets peers resolve in parallel with us instead of starting only after our OnReady, - // which is what otherwise leaves them seconds behind. Peers auto-play their resolved - // source (AutoPlayOnSourceAssigned), the later OnReady broadcast settles state, and the - // position heartbeat keeps everyone converged. Directly-playable URLs keep the - // OnReady-gated path: no resolution latency to hide, and it avoids announcing a load we - // haven't confirmed we can open. - if (!BasisMediaUrlRouter.IsDirectlyPlayable(url)) - { - BroadcastFullState(); - } + // FullState is the only message carrying a URL, so it goes out up front rather than + // waiting on OnReady — peers that never see a broadcast never learn what to load. It + // also hides resolution latency: a page URL costs each client seconds of yt-dlp work, + // and announcing immediately lets peers resolve in parallel with us instead of starting + // only after our OnReady. Peers auto-play their resolved source + // (AutoPlayOnSourceAssigned), the later OnReady broadcast settles state, and the + // position heartbeat keeps everyone converged. + BroadcastFullState(freshLoad: true); mediaPlayer.LoadUrl(url); // OnReady fires BroadcastFullState once the source resolves, settling state/position. @@ -726,9 +726,20 @@ private void ApplyPendingRemoteState() return; } - if (pendingRemoteState == SyncedPlaybackState.Paused && !mediaPlayer.IsPaused) + // The owner's advertised state is authoritative here. Don't rely on the resolved + // source having auto-started: AutoPlayOnSourceAssigned is the peer's own setting + // and may be off, which would strand it stopped while the owner plays. The + // direct-URL path forces the same thing around LoadSource. IsPlaying and IsPaused + // are independent, so a paused source needs resuming rather than starting. + if (pendingRemoteState == SyncedPlaybackState.Playing) { - mediaPlayer.Pause(); + if (mediaPlayer.IsPlaying && mediaPlayer.IsPaused) mediaPlayer.Resume(); + else if (!mediaPlayer.IsPlaying) mediaPlayer.Play(); + } + else if (pendingRemoteState == SyncedPlaybackState.Paused) + { + if (!mediaPlayer.IsPlaying) mediaPlayer.Play(); + if (!mediaPlayer.IsPaused) mediaPlayer.Pause(); } if (pendingRemotePositionTicks > 0) @@ -845,6 +856,10 @@ private void HandleLocalReady() } syncedUrlFromSetUrl = false; + // The queued load has arrived, so a fresh-load broadcast still waiting on a network + // ID is superseded: from here the player's own state and position are the truth, and + // later local commands must not be re-serialised as a pending load at position zero. + sendOnNetworkReadyFreshLoad = false; BroadcastFullState(); } @@ -934,15 +949,18 @@ private void SendOwnerSimple(MessageId id) SendCustomNetworkEvent(payload, DeliveryMethod.ReliableOrdered); } - private void BroadcastFullState() + private void BroadcastFullState(bool freshLoad = false) { if (!HasNetworkID) { sendOnNetworkReady = true; + // A queued fresh load outranks a queued ordinary broadcast: the deferred send + // still has to describe the pending load, not the source being replaced. + sendOnNetworkReadyFreshLoad |= freshLoad; return; } - SendCustomNetworkEvent(SerializeFullState(), DeliveryMethod.ReliableOrdered); + SendCustomNetworkEvent(SerializeFullState(freshLoad), DeliveryMethod.ReliableOrdered); } private void SendFullStateTo(ushort[] recipients) @@ -980,7 +998,11 @@ private string GetActiveUrl() return media != null && !string.IsNullOrEmpty(media.Uri) ? media.Uri : string.Empty; } - private byte[] SerializeFullState() + // freshLoad describes the load we are about to start rather than the source still + // loaded: the player has not swapped over yet, so its state and position still belong + // to the outgoing media and would otherwise be applied as the new source's start + // position on peers. + private byte[] SerializeFullState(bool freshLoad = false) { string url = GetActiveUrl(); bool urlChanged = !string.Equals(cachedUrlBytesSource, url, StringComparison.Ordinal); @@ -1011,8 +1033,10 @@ private byte[] SerializeFullState() Buffer.BlockCopy(urlBytes, 0, fullStateScratch, FullStateHeaderSize, urlBytes.Length); } - fullStateScratch[1] = (byte)GetLocalState(); - long positionTicks = mediaPlayer.Duration > TimeSpan.Zero ? mediaPlayer.Position.Ticks : 0L; + fullStateScratch[1] = (byte)(freshLoad + ? (mediaPlayer.AutoPlayOnSourceAssigned ? SyncedPlaybackState.Playing : SyncedPlaybackState.Stopped) + : GetLocalState()); + long positionTicks = !freshLoad && mediaPlayer.Duration > TimeSpan.Zero ? mediaPlayer.Position.Ticks : 0L; WriteLong(fullStateScratch, 2, positionTicks); WriteUShort(fullStateScratch, FullStateNonceOffset, loadNonce); WriteSettingsBlock(fullStateScratch, FullStateSettingsOffset); diff --git a/Basis/Packages/com.basis.mediaplayer/Runtime/Native/BasisNativeVideoSource.cs b/Basis/Packages/com.basis.mediaplayer/Runtime/Native/BasisNativeVideoSource.cs index 17949854c..6e8764d99 100644 --- a/Basis/Packages/com.basis.mediaplayer/Runtime/Native/BasisNativeVideoSource.cs +++ b/Basis/Packages/com.basis.mediaplayer/Runtime/Native/BasisNativeVideoSource.cs @@ -337,6 +337,18 @@ public void Pump(bool verboseLogging = false) } } + // Audio-only sources never tick the video frame counter and never produce a + // texture, so the readiness check above can't fire for them. The engine only + // flips to Playing with no video size once the source has announced no video + // track at all, so that pairing is the audio-only ready signal. + if (!readyFired && engineState == BasisNativeMedia.State.Playing && + !(BasisNativeMedia.TryGetVideoSize(handle, out int vw, out int vh) && vw > 0 && vh > 0) && + BasisNativeMedia.TryGetAudioFormat(handle, out _, out int audioChannels) && audioChannels > 0) + { + readyFired = true; + OnReady?.Invoke(); + } + switch (engineState) { case BasisNativeMedia.State.Error when !errorRaised: From 755331e45f2eb814284fae0115158d261cabb4f2 Mon Sep 17 00:00:00 2001 From: towneh <25694892+towneh@users.noreply.github.com> Date: Mon, 20 Jul 2026 10:00:20 +0100 Subject: [PATCH 2/2] docs(mediaplayer): add a networked audio-only testing scenario Audio-only sources carry no video track, so a readiness regression on that path is invisible from the owner's client and only shows up on peers. Name the two-client scenario, including the load-while-peers-are-playing case and the peer with autoplay disabled. --- Basis/Packages/com.basis.mediaplayer/TESTING.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/Basis/Packages/com.basis.mediaplayer/TESTING.md b/Basis/Packages/com.basis.mediaplayer/TESTING.md index 1ee63f128..597e2bf57 100644 --- a/Basis/Packages/com.basis.mediaplayer/TESTING.md +++ b/Basis/Packages/com.basis.mediaplayer/TESTING.md @@ -268,6 +268,16 @@ header reports no duration and shows no seek bar. client resolves the URL independently (per-client CDN/bitrate differences are fine, state divergence is not). +**Networked audio-only** — the same two-client setup with an audio-only URL (`.wav`, `.mp3`, +`.m4a`, `.opus`). These carry no video track, so anything that waits on a video frame or an +output texture never fires for them, and a readiness regression here is invisible on the +owner's own client — it plays locally either way. Load one while the peers are mid-playback of +something else: they must switch to it, not carry on and then resume the old source when it +ends. Check the peer starts near the beginning rather than at the outgoing video's playhead, +and that a late joiner receives it too. Worth a pass on a peer with +`AutoPlayOnSourceAssigned` unticked, which is the case that relies on the owner's advertised +state rather than local autoplay. + **Panel UI** ("Media Players" panel, `Runtime/UI/BasisMediaPlayerPanelProvider.cs`) — URL load, transport buttons, seek slider (VOD only), volume, bitrate dropdown (HLS multi-variant), audio-track dropdown (multi-audio content), captions toggle + opacity sliders, subtitles