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: 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