Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ private enum SettingsFlags : byte
/// <summary>The URL shared with peers for the current source — the input/page URL, not the per-client resolved stream.</summary>
public string SyncedUrl => currentSyncedUrl;
private bool sendOnNetworkReady;
private bool sendOnNetworkReadyFreshLoad;
private bool applyingRemoteCommand;
private bool eventsHooked;
private ushort loadNonce;
Expand Down Expand Up @@ -185,7 +186,9 @@ public override void OnNetworkReady()
if (sendOnNetworkReady)
{
sendOnNetworkReady = false;
BroadcastFullState();
bool freshLoad = sendOnNetworkReadyFreshLoad;
sendOnNetworkReadyFreshLoad = false;
BroadcastFullState(freshLoad);
}
}

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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();
}

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
10 changes: 10 additions & 0 deletions Basis/Packages/com.basis.mediaplayer/TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading