Code/MediaPlayer.cs
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Bimp.Resolver;
namespace Bimp;
/// <summary>
/// A networked media player. The host owns the state (what's playing, when it started, paused, queue)
/// and every client plays it locally, keeping itself in sync with the host's clock.
/// <para>
/// Put this on a GameObject with a <see cref="WorldPanel"/> + <see cref="MediaScreen"/> for a TV/screen,
/// or with <see cref="MediaSpeaker"/> and <see cref="AudioOnlyPlayer"/> for a speaker. Add a collider and
/// <see cref="MediaInteract"/> so players can open the controls with the use key.
/// </para>
/// </summary>
[Title( "Media Player" ), Category( "Media" ), Icon( "smart_display" )]
public sealed class MediaPlayer : Component
{
//
// Inspector settings
//
/// <summary>
/// Who can control playback. The host can always control it.
/// </summary>
[Property, Group( "Access" )] public MediaPermission Permission { get; set; } = MediaPermission.Anyone;
/// <summary>
/// SteamIds allowed to control this player when <see cref="Permission"/> is Whitelist.
/// </summary>
[Property, Group( "Access" ), ShowIf( nameof( Permission ), MediaPermission.Whitelist )]
public List<long> AllowedSteamIds { get; set; } = new();
/// <summary>
/// Let anyone add to the queue, even if they can't control playback.
/// </summary>
[Property, Group( "Access" )] public bool AnyoneCanQueue { get; set; } = true;
[Property, Group( "Queue" ), Range( 1, 200 )] public int MaxQueue { get; set; } = 30;
/// <summary>
/// Replay the current item when it ends and the queue is empty.
/// </summary>
[Property, Group( "Queue" )] public bool Loop { get; set; }
/// <summary>
/// Played by the host when the scene starts.
/// </summary>
[Property, Group( "Playback" )] public string DefaultUrl { get; set; }
/// <summary>
/// Never show video, only play audio. For speakers, radios and jukeboxes.
/// </summary>
[Property, Group( "Playback" )] public bool AudioOnlyPlayer { get; set; }
[Property, Group( "Playback" ), Range( 0, 1 )] public float Volume { get; set; } = 1.0f;
/// <summary>
/// Sound comes from this object's position in the world and falls off with distance.
/// When off, everyone hears it at the same volume wherever they are.
/// </summary>
[Property, Group( "Audio" )] public bool Spatial { get; set; } = true;
/// <summary>
/// Distance at which the sound is fully faded out (see <see cref="AudioFalloff"/>).
/// </summary>
[Property, Group( "Audio" ), ShowIf( nameof( Spatial ), true )] public float AudioDistance { get; set; } = 4000.0f;
/// <summary>
/// How the sound fades out to <see cref="AudioDistance"/>: nearly full volume in the room with the screen, then a
/// smooth fade. The engine's own curve is made for its 15000 unit default range; over a screen's few thousand units
/// it was down to 22% three metres away and 4% at twelve.
/// </summary>
public static readonly Curve AudioFalloff = new( new( 0, 1 ), new( 0.15f, 0.92f ), new( 0.45f, 0.55f ), new( 0.75f, 0.2f ), new( 1, 0 ) );
/// <summary> 3D for this player: the player's own setting, unless they've turned 3D audio off (bimp_spatial). </summary>
public bool EffectiveSpatial => Spatial && MediaSettings.SpatialAudio;
/// <summary>
/// 2D for this player (bimp_spatial off, on a player that's otherwise 3D): the sound has no direction, but still
/// fades with the listener's distance from it, on the same curve as 3D. A player that isn't <see cref="Spatial"/>
/// at all is the same volume everywhere.
/// </summary>
bool FlatWithDistance => Spatial && !MediaSettings.SpatialAudio;
/// <summary> How loud this player is at a point, from <see cref="AudioFalloff"/> over <see cref="AudioDistance"/>. </summary>
float DistanceFade( Vector3 listener )
{
if ( AudioDistance <= 0 ) return 1;
return AudioFalloff.Evaluate( (listener.Distance( SoundPosition ) / AudioDistance).Clamp( 0, 1 ) );
}
/// <summary>
/// Where the sound comes from. Defaults to this GameObject.
/// </summary>
[Property, Group( "Audio" )] public GameObject SoundOrigin { get; set; }
//
// Synced state - only the host writes these
//
/// <summary> The url the user entered. </summary>
[Sync( SyncFlags.FromHost )] public string CurrentUrl { get; set; }
/// <summary> The url clients actually play. Empty when nothing is playing. </summary>
[Sync( SyncFlags.FromHost )] public string PlayUrl { get; set; }
[Sync( SyncFlags.FromHost )] public string Title { get; set; }
[Sync( SyncFlags.FromHost )] public string RequestedBy { get; set; }
/// <summary> Media length in seconds, 0 if unknown or live. </summary>
[Sync( SyncFlags.FromHost )] public float MediaDuration { get; set; }
[Sync( SyncFlags.FromHost )] public bool IsLive { get; set; }
[Sync( SyncFlags.FromHost )] public bool AudioOnly { get; set; }
[Sync( SyncFlags.FromHost )] public bool SeekByReload { get; set; }
/// <summary> Host time (<see cref="Time.NowDouble"/>) at which media time 0 would have played. </summary>
[Sync( SyncFlags.FromHost )] public double StartTime { get; set; }
[Sync( SyncFlags.FromHost )] public bool Paused { get; set; }
[Sync( SyncFlags.FromHost )] public float PausedAt { get; set; }
/// <summary> Increments every time something (re)starts, so replaying the same url reloads it. </summary>
[Sync( SyncFlags.FromHost )] public int PlayId { get; set; }
/// <summary> Increments on every explicit seek, so clients apply it right away rather than treating it as drift. </summary>
[Sync( SyncFlags.FromHost )] public int SeekId { get; set; }
/// <summary> Host status message: "Loading..." or an error. </summary>
[Sync( SyncFlags.FromHost )] public string Status { get; set; }
[Sync( SyncFlags.FromHost )] public NetList<MediaQueueItem> Queue { get; set; } = new();
/// <summary> Video heights the current media is available in (see <see cref="MediaQueueItem.Qualities"/>). </summary>
[Sync( SyncFlags.FromHost )] public string Qualities { get; set; }
/// <summary> Audio tracks of the current media (see <see cref="MediaQueueItem.AudioTracks"/>). </summary>
[Sync( SyncFlags.FromHost )] public string AudioTracks { get; set; }
//
// Per-client stream choices. Everyone shares the timeline, but picks their own resolution / audio language.
//
public IReadOnlyList<int> AvailableQualities => MediaStreamOptions.ParseQualities( Qualities );
public IReadOnlyList<MediaAudioTrack> AvailableAudioTracks => MediaStreamOptions.ParseAudioTracks( AudioTracks );
/// <summary> The height this client streams, 0 for automatic. From the bimp_quality convar. </summary>
public int SelectedQuality => MediaStreamOptions.ChooseQuality( AvailableQualities, MediaSettings.Quality );
/// <summary> The audio track this client streams, null for the original. From the bimp_audio_lang convar. </summary>
public MediaAudioTrack SelectedAudioTrack => MediaStreamOptions.ChooseAudioTrack( AvailableAudioTracks, MediaSettings.AudioLanguage );
/// <summary> <see cref="PlayUrl"/> plus this client's choices. Changing a choice reloads playback at the same time. </summary>
public string LocalStreamUrl => MediaStreamOptions.ApplyTo( PlayUrl, SelectedQuality, SelectedAudioTrack );
string CurrentKey => KeyFor( PlayId, LocalStreamUrl );
//
// Local state
//
/// <summary>
/// The local playback. Null on a dedicated server or when nothing is playing.
/// </summary>
public MediaBackend Backend { get; private set; }
string backendKey;
RealTimeSince sinceCorrection;
bool initialSeekPending;
int reportedDurationFor = -1;
int endedPlayId = -1;
int resolveToken;
/// <summary>
/// Local message for this client (permission denied, load failures), fades after a few seconds.
/// </summary>
public string LocalNotice { get; private set; }
RealTimeSince sinceNotice;
public bool HasMedia => !string.IsNullOrEmpty( PlayUrl );
/// <summary>
/// Are we the authority on the state? True for the host, or when not networked at all.
/// </summary>
public bool IsAuthority => !Networking.IsActive || Networking.IsHost;
/// <summary>
/// Where playback should be right now, according to the host.
/// </summary>
public float CurrentTime
{
get
{
if ( !HasMedia ) return 0;
var t = Paused ? PausedAt : (float)Math.Max( 0, Time.NowDouble - StartTime );
if ( MediaDuration > 0 ) t = MathF.Min( t, MediaDuration );
return t;
}
}
/// <summary>
/// The shared resolver (host only).
/// </summary>
static BimpResolver Resolver => BimpResolver.Instance;
public Vector3 SoundPosition => SoundOrigin.IsValid() ? SoundOrigin.WorldPosition : WorldPosition;
protected override void OnAwake()
{
// Sync'd properties only work on network objects. Make that the default so mappers don't have to.
if ( GameObject.NetworkMode == NetworkMode.Snapshot )
GameObject.NetworkMode = NetworkMode.Object;
}
protected override void OnStart()
{
if ( IsAuthority && !HasMedia && !string.IsNullOrWhiteSpace( DefaultUrl ) )
{
_ = PlayNowAsync( DefaultUrl, "Map" );
}
}
protected override void OnDestroy()
{
DisposeBackend();
}
protected override void OnDisabled()
{
DisposeBackend();
}
protected override void OnUpdate()
{
if ( IsAuthority )
UpdateAuthority();
if ( !Application.IsDedicatedServer )
{
UpdatePlayback();
RecentLinks.Saw( this );
}
MediaProbe.PresentFile();
LatencyLab.Tick();
MediaProbe.SampleLatency( this );
if ( MediaProbe.IsRecording( this ) )
MediaProbe.Sample( this );
if ( LocalNotice is not null && sinceNotice > 6 )
LocalNotice = null;
}
#region Permissions
/// <summary>
/// Can this connection control playback?
/// </summary>
public bool CanControl( Connection c )
{
if ( !Networking.IsActive ) return true;
if ( c is null ) return false;
if ( c.IsHost || c == Connection.Host ) return true;
return Permission switch
{
MediaPermission.Anyone => true,
MediaPermission.Whitelist => AllowedSteamIds?.Contains( c.SteamId.Value ) ?? false,
_ => false,
};
}
/// <summary>
/// Can this connection add to the queue?
/// </summary>
public bool CanQueue( Connection c ) => AnyoneCanQueue || CanControl( c );
/// <summary>
/// Can the local player control playback? Used by the UI.
/// </summary>
public bool CanLocalControl => CanControl( Connection.Local );
public bool CanLocalQueue => CanQueue( Connection.Local );
static string CallerName( Connection c ) => c?.DisplayName ?? "Someone";
#endregion
#region Requests (called by anyone, run on the host)
/// <summary>
/// Play this url now, replacing whatever is playing.
/// </summary>
[Rpc.Host]
public void RequestPlay( string url )
{
var caller = Rpc.Caller;
if ( !CanControl( caller ) ) { Deny( caller, "You don't have permission to control this player." ); return; }
_ = PlayNowAsync( url, CallerName( caller ) );
}
/// <summary>
/// Add this url to the queue. Plays immediately if nothing is playing.
/// </summary>
[Rpc.Host]
public void RequestEnqueue( string url )
{
var caller = Rpc.Caller;
if ( !CanQueue( caller ) ) { Deny( caller, "You don't have permission to queue on this player." ); return; }
if ( Queue.Count >= MaxQueue ) { Deny( caller, "The queue is full." ); return; }
_ = EnqueueAsync( url, CallerName( caller ), caller );
}
[Rpc.Host]
public void RequestSetPaused( bool paused )
{
if ( !CanControl( Rpc.Caller ) ) { Deny( Rpc.Caller, "You don't have permission to control this player." ); return; }
SetPaused( paused );
}
[Rpc.Host]
public void RequestSeek( float time )
{
if ( !CanControl( Rpc.Caller ) ) { Deny( Rpc.Caller, "You don't have permission to control this player." ); return; }
Seek( time );
}
[Rpc.Host]
public void RequestSkip()
{
if ( !CanControl( Rpc.Caller ) ) { Deny( Rpc.Caller, "You don't have permission to control this player." ); return; }
PlayNext();
}
[Rpc.Host]
public void RequestStop()
{
if ( !CanControl( Rpc.Caller ) ) { Deny( Rpc.Caller, "You don't have permission to control this player." ); return; }
Stop();
}
[Rpc.Host]
public void RequestRemove( int index )
{
if ( !CanControl( Rpc.Caller ) ) { Deny( Rpc.Caller, "You don't have permission to control this player." ); return; }
if ( index < 0 || index >= Queue.Count ) return;
Queue.RemoveAt( index );
}
[Rpc.Host]
public void RequestClearQueue()
{
if ( !CanControl( Rpc.Caller ) ) { Deny( Rpc.Caller, "You don't have permission to control this player." ); return; }
Queue.Clear();
}
/// <summary>
/// A client learned the duration of a direct file after loading it. The host takes the first answer.
/// </summary>
[Rpc.Host( NetFlags.Unreliable )]
void ReportDuration( int playId, float duration )
{
if ( playId != PlayId || MediaDuration > 0 || IsLive ) return;
if ( duration <= 0 || float.IsNaN( duration ) || float.IsInfinity( duration ) ) return;
MediaDuration = duration;
}
/// <summary>
/// Tell one client something went wrong with their request.
/// </summary>
void Deny( Connection caller, string message )
{
if ( caller is null || caller == Connection.Local || !Networking.IsActive )
{
ShowNotice( message );
return;
}
using ( Rpc.FilterInclude( caller ) )
{
ReceiveNotice( message );
}
}
[Rpc.Broadcast( NetFlags.HostOnly )]
void ReceiveNotice( string message )
{
ShowNotice( message );
}
void ShowNotice( string message )
{
LocalNotice = message;
sinceNotice = 0;
}
#endregion
#region Authority (host) logic
/// <summary>
/// Resolve and play right away. Host only.
/// </summary>
public async Task PlayNowAsync( string url, string requestedBy )
{
if ( !IsAuthority ) return;
var token = ++resolveToken;
Status = "Loading...";
try
{
var item = await Resolver.ResolveAsync( url, AudioOnlyPlayer );
if ( token != resolveToken || !this.IsValid() ) return; // something newer was requested
item.RequestedBy = requestedBy;
StartItem( item );
}
catch ( Exception e )
{
if ( token != resolveToken || !this.IsValid() ) return;
Status = e is ResolveException ? e.Message : $"Error: {e.Message}";
Log.Warning( $"[bimp] Couldn't play {url}: {e.Message}" );
}
}
/// <summary>
/// Resolve and add to the queue. Host only.
/// </summary>
public async Task EnqueueAsync( string url, string requestedBy, Connection caller = null )
{
if ( !IsAuthority ) return;
try
{
var item = await Resolver.ResolveAsync( url, AudioOnlyPlayer );
if ( !this.IsValid() ) return;
item.RequestedBy = requestedBy;
if ( !HasMedia && Status != "Loading..." )
{
StartItem( item );
return;
}
if ( Queue.Count >= MaxQueue ) { Deny( caller, "The queue is full." ); return; }
Queue.Add( item );
}
catch ( Exception e )
{
if ( !this.IsValid() ) return;
Deny( caller, e is ResolveException ? e.Message : $"Error: {e.Message}" );
}
}
void StartItem( MediaQueueItem item )
{
CurrentUrl = item.Url;
PlayUrl = item.PlayUrl;
Title = item.Title;
RequestedBy = item.RequestedBy;
MediaDuration = item.Duration;
IsLive = item.IsLive;
AudioOnly = item.AudioOnly || AudioOnlyPlayer;
SeekByReload = item.SeekByReload;
Qualities = item.Qualities;
AudioTracks = item.AudioTracks;
Paused = false;
PausedAt = 0;
StartTime = Time.NowDouble;
Status = null;
PlayId++;
}
/// <summary>
/// Play the next item in the queue, or stop. Host only.
/// </summary>
public void PlayNext()
{
if ( !IsAuthority ) return;
resolveToken++; // cancel any pending "play now"
if ( Queue.Count > 0 )
{
var next = Queue[0];
Queue.RemoveAt( 0 );
StartItem( next );
return;
}
Stop();
}
/// <summary>
/// Stop playback (the queue is kept). Host only.
/// </summary>
public void Stop()
{
if ( !IsAuthority ) return;
resolveToken++;
CurrentUrl = null;
PlayUrl = null;
Title = null;
RequestedBy = null;
Qualities = null;
AudioTracks = null;
MediaDuration = 0;
IsLive = false;
Paused = false;
PausedAt = 0;
Status = null;
PlayId++;
}
public void SetPaused( bool paused )
{
if ( !IsAuthority || !HasMedia || IsLive || Paused == paused ) return;
if ( paused )
{
// Freeze the shared timeline where the host's picture actually is, so the host's own video
// doesn't jump on pause. Clients are within their drift tolerance of it anyway.
PausedAt = LocalPlaybackPosition() ?? CurrentTime;
Paused = true;
}
else
{
StartTime = Time.NowDouble - PausedAt;
Paused = false;
}
}
public void Seek( float time )
{
if ( !IsAuthority || !HasMedia || IsLive ) return;
time = MathF.Max( 0, time );
if ( MediaDuration > 0 ) time = MathF.Min( time, MediaDuration - 0.5f );
if ( Paused ) PausedAt = time;
else StartTime = Time.NowDouble - time;
SeekId++;
// Don't let an old "ended" state trip us up after seeking back
endedPlayId = -1;
}
void UpdateAuthority()
{
if ( !HasMedia || Paused || IsLive ) return;
if ( endedPlayId == PlayId ) return;
var ended = false;
// Known duration - use the clock, works on dedicated servers too
if ( MediaDuration > 0 && Time.NowDouble - StartTime >= MediaDuration + 1.0 )
ended = true;
// Unknown duration - trust our own playback, if we have one
if ( MediaDuration <= 0 && Backend is not null && Backend.Finished && backendKey == CurrentKey )
ended = true;
if ( !ended ) return;
endedPlayId = PlayId;
OnMediaEnded();
}
void OnMediaEnded()
{
if ( Queue.Count > 0 )
{
PlayNext();
return;
}
if ( Loop )
{
Paused = false;
StartTime = Time.NowDouble;
PlayId++; // force everyone to reload, the decoder has stopped
return;
}
Stop();
}
#endregion
#region Local playback
static string KeyFor( int playId, string url ) => string.IsNullOrEmpty( url ) ? null : $"{playId}|{url}";
/// <summary>
/// Throw away local playback and start it again at the synced time.
/// </summary>
internal void ReloadBackend()
{
DisposeBackend();
backendKey = CurrentKey;
if ( backendKey is not null ) CreateBackend();
}
/// <summary>
/// Diagnostics: open merged media at t with an explicit time offset.
/// </summary>
internal void ReloadBackendAt( float t, float offset )
{
DisposeBackend();
backendKey = CurrentKey;
if ( backendKey is null ) return;
Backend = PlayToken.IsToken( LocalStreamUrl )
? MediaBackend.CreateNative( LocalStreamUrl, AudioOnly, t )
: MediaBackend.Create( LocalStreamUrl, AudioOnly, offset );
sinceCorrection = 0;
initialSeekPending = false;
appliedSeekId = SeekId;
lastAppliedPaused = !Paused;
}
void DisposeBackend()
{
Backend?.Dispose();
Backend = null;
backendKey = null;
}
void CreateBackend()
{
// Merged media starts at the synced time: segment files are written from the cue point before it,
// with absolute timestamps, so there's no time offset. Everything else starts at 0 and seeks once loaded.
var t = CurrentTime;
var url = LocalStreamUrl;
Backend = IsLive ? MediaBackend.CreateLive( url, Math.Clamp( MediaSettings.MaxVideoHeight, 144, 2160 ) )
: PlayToken.IsToken( url ) ? MediaBackend.CreateNative( url, AudioOnly, SeekByReload && !IsLive ? t : 0 )
: MediaBackend.Create( url, AudioOnly, 0 );
sinceCorrection = 0;
initialSeekPending = !IsLive; // once loaded, land exactly on the synced time (see CorrectDrift)
appliedSeekId = SeekId; // a fresh backend starts at the right time anyway (initial seek)
lastAppliedPaused = !Paused; // make sure the new backend gets told the pause state
if ( Backend.Error is not null )
{
notifiedError = Backend;
ShowNotice( $"Can't play this: {Backend.Error}" );
}
}
void UpdatePlayback()
{
var wantKey = CurrentKey;
if ( wantKey != backendKey )
{
DisposeBackend();
backendKey = wantKey;
if ( wantKey is not null )
CreateBackend();
}
if ( Backend is null ) return;
Backend.SyncedTime = Paused || IsLive ? null : CurrentTime;
Backend.Present();
var volume = Volume * MediaSettings.EffectiveVolume;
if ( FlatWithDistance ) volume *= DistanceFade( Sound.Listener.Position );
Backend.SetAudio( SoundPosition, EffectiveSpatial, volume, AudioDistance );
if ( Backend.Error is not null )
{
// native media fails after the backend was created (resolving / downloading) - say so once
if ( notifiedError != Backend )
{
notifiedError = Backend;
ShowNotice( $"Can't play this: {Backend.Error}" );
}
return;
}
if ( !Backend.Loaded )
{
if ( Backend.SinceCreated > 20 && LocalNotice is null )
ShowNotice( "This is taking a while to load..." );
return;
}
// Tell the host the duration of direct files, it can't know until something loads them
if ( MediaDuration <= 0 && !IsLive && !SeekByReload && reportedDurationFor != PlayId && Backend.Duration > 0 )
{
reportedDurationFor = PlayId;
ReportDuration( PlayId, Backend.Duration );
}
// Pausing/resuming gets a moment to settle (the decoder refills its buffers on resume) before
// anything is judged as drift or a stall - correcting then is what caused stutter after unpausing
// A freshly opened player (after a seek, join or reload) runs until it has landed on the synced time,
// even when paused - otherwise a seek while paused leaves the screen without the new frame
var wantPaused = Paused && !initialSeekPending;
if ( wantPaused != lastAppliedPaused )
{
lastAppliedPaused = wantPaused;
sinceCorrection = 0;
ResetStallTimer();
}
Backend.SetPaused( wantPaused );
// A stuck live stream (source hiccup, dropped connection) reconnects at the live edge
if ( CheckStalled() ) return;
if ( IsLive ) return; // nothing to sync to - everyone watches the live edge
CorrectDrift();
}
bool lastAppliedPaused;
int appliedSeekId;
MediaBackend notifiedError;
/// <summary>
/// Where this client's playback actually is, if it's loaded and roughly in sync (null otherwise).
/// </summary>
float? LocalPlaybackPosition()
{
if ( Backend is null || Backend.Error is not null || !Backend.Loaded || backendKey != CurrentKey ) return null;
var t = Backend.Time;
if ( MathF.Abs( t - CurrentTime ) > 3.0f ) return null; // way off (stalled / still seeking) - don't trust it
if ( MediaDuration > 0 ) t = MathF.Min( t, MediaDuration );
return MathF.Max( 0, t );
}
const float StallSeconds = 5.0f;
const int MaxStallReloads = 3;
float lastProgressTime;
RealTimeSince sinceProgress;
int stallReloads;
int stallPlayId = -1;
/// <summary>
/// If playback should be moving but hasn't for a while (the decoder got stuck after a seek,
/// the connection dropped...), throw the backend away and start again at the right time.
/// Returns true if it reloaded.
/// </summary>
bool CheckStalled()
{
if ( stallPlayId != PlayId )
{
stallPlayId = PlayId;
stallReloads = 0;
ResetStallTimer();
}
var t = Backend.Time;
if ( Paused || MathF.Abs( t - lastProgressTime ) > 0.05f )
{
lastProgressTime = t;
sinceProgress = 0;
return false;
}
// Reached the end, not stuck
if ( MediaDuration > 0 && t >= MediaDuration - 1.0f ) return false;
if ( sinceProgress < StallSeconds || stallReloads >= MaxStallReloads ) return false;
stallReloads++;
Log.Warning( $"[bimp] Playback stalled at {t:0.0}s, reloading ({stallReloads}/{MaxStallReloads})" );
ShowNotice( "Playback got stuck, reloading..." );
DisposeBackend();
backendKey = CurrentKey;
CreateBackend();
ResetStallTimer();
return true;
}
void ResetStallTimer()
{
lastProgressTime = -1;
sinceProgress = 0;
}
/// <summary> Merged media lands within this of the synced time without seeking (the drift threshold). </summary>
const float MergedLandingTolerance = 1.5f;
/// <summary> The play and seek that merged media last reopened for, after landing too far behind. </summary>
(int playId, int seekId) landingReloadFor = (-1, -1);
/// <summary>
/// Keep local playback close to where the host says it should be.
/// </summary>
void CorrectDrift()
{
var expected = CurrentTime;
var drift = Backend.Time - expected;
var absDrift = MathF.Abs( drift );
// Just loaded: land exactly on the synced time. For merged media the first segment starts at the keyframe
// cluster before it, so this is a short hop forward inside the first cluster (a few seconds at most),
// which the engine's player gets through in a blink.
if ( initialSeekPending )
{
// The loaded event can come a frame or two before the player's clock reports the segment's position
// (it reads 0) - wait for it, or we'd skip the hop and sit a second behind
if ( SeekByReload && absDrift >= 15.0f && Backend.SinceCreated < 10.0f ) return;
initialSeekPending = false;
appliedSeekId = SeekId;
// Merged media started at a keyframe just behind the synced time (or waited for the next one): it keeps that
// small lag rather than seeking forward, which means decoding everything in between while the sound runs
// on - at 4K the picture then crawled behind for seconds (see MediaBackend.StartNative).
MediaProbe.Note( $"initial landing: drift {drift * 1000:0}ms" );
var tolerance = SeekByReload ? MergedLandingTolerance : 0.25f;
if ( absDrift > tolerance && (!SeekByReload || absDrift < 15.0f) )
{
// Landed further behind than planned (a slow load): open again, now planned with the real load time,
// rather than seek forward. Once per play or seek, so a load that's always slow can't loop.
if ( SeekByReload && drift < 0 && landingReloadFor != (PlayId, SeekId) )
{
landingReloadFor = (PlayId, SeekId);
MediaProbe.Note( "landed too far behind: reopening" );
ReloadBackend();
return;
}
Backend.Seek( expected );
sinceCorrection = 0;
}
return;
}
// Someone seeked (button / progress bar): apply it now, not as drift after a cooldown
var explicitSeek = appliedSeekId != SeekId;
appliedSeekId = SeekId;
// The same tolerance applies while paused - pausing must never seek (our playback normally trails
// the synced clock by up to a second of load latency; correcting that on pause made it jump).
const float threshold = 1.5f;
var drifted = absDrift >= threshold && sinceCorrection >= 3.0f;
if ( !(explicitSeek && absDrift > 0.25f) && !drifted ) return;
if ( SeekByReload )
{
// ROOT CAUSE of "it keeps skipping forward frames": the engine's player can't seek forward in a
// stream. Seek() jumps its clock but it keeps reading and decoding sequentially from where it was,
// throwing away every frame as late until it catches up - fast forwarding for as long as the gap
// was (minutes, for a big seek), while drift correction kept firing more seeks. So merged
// media never asks it to jump: it reopens at the target, writing segment files that start there.
if ( MediaDuration > 0 && expected >= MediaDuration - 2 ) return;
ReloadBackend();
return;
}
// Direct files: the native seek (fine for backward seeks and short hops)
Backend.Seek( expected );
sinceCorrection = 0;
}
#endregion
}