Vehicles/Vehicle.Radio.cs

The file defines radio station data and controls vehicle radio playback. It includes a list of radio stations and manages the lifecycle of the audio streams for a vehicle.

NetworkingHttp Calls
🌐 https://streaming.nrjaudio.fm/oumvmk8fnozc?origine=fluxradios, https://icecast.skyrock.net/s/natio_mp3_128k?tvr_name=tunein16&tvr_section1=64mp3, https://streaming.nrjaudio.fm/ouuku85n3nje?origine=fluxradios, http://icecast.rtl.fr/rtl-1-44-128?listen=webCwsBCggNCQgLDQUGBAcGBg, http://audio.bfmtv.com/rmcradio_128.mp3, http://stream.europe1.fr/europe1.mp3, http://europe2.lmn.fm/europe2.mp3
namespace HorizonRP;

/// <summary>Liste fermée : aucun client ne transmet d'URL au serveur ou aux autres joueurs.</summary>
public static class VehicleRadioStations
{
	public sealed record Station( string Id, string Name, string Url, float Gain = 1f );
	// Propriété : la liste est relue après un rechargement à chaud.
	public static Station[] All => new[]
	{
		new Station( "nrj", "NRJ", "https://streaming.nrjaudio.fm/oumvmk8fnozc?origine=fluxradios" ),
		new Station( "skyrock", "Skyrock", "https://icecast.skyrock.net/s/natio_mp3_128k?tvr_name=tunein16&tvr_section1=64mp3" ),
		new Station( "cherie", "Chérie FM", "https://streaming.nrjaudio.fm/ouuku85n3nje?origine=fluxradios" ),
		new Station( "rtl", "RTL", "http://icecast.rtl.fr/rtl-1-44-128?listen=webCwsBCggNCQgLDQUGBAcGBg" ),
		new Station( "rmc", "RMC", "http://audio.bfmtv.com/rmcradio_128.mp3", 2.3f ),
		new Station( "europe1", "Europe 1", "http://stream.europe1.fr/europe1.mp3", 2f ),
		new Station( "europe2", "Europe 2", "http://europe2.lmn.fm/europe2.mp3", 1.65f )
	};
	public static Station Get( string id ) => All.FirstOrDefault( x => x.Id == id ) ?? All[0];
	public static Station Step( string id, int direction )
	{
		var stations = All;
		var index = Array.FindIndex( stations, x => x.Id == Get( id ).Id );
		return stations[(index + (direction < 0 ? -1 : 1) + stations.Length) % stations.Length];
	}
}

public sealed partial class Vehicle
{
	/// <summary>État propre au véhicule, décidé par l'hôte, même quand sa physique appartient au conducteur.</summary>
	[Sync( SyncFlags.FromHost )] public string RadioStationId { get; set; } = "nrj";
	[Sync( SyncFlags.FromHost )] public bool RadioEnabled { get; set; } = true;
	public VehicleRadioStations.Station RadioStation => VehicleRadioStations.Get( RadioStationId );
	public const float RadioVolume = 0.08f;
	const string RadioOnSound = "sounds/vehicules/radio_turn_on.mp3";
	const string RadioOffSound = "sounds/vehicules/radio_turn_off.mp3";
	RealTimeSince sinceRadioCommand = 10;

	/// <summary>Priorité aux commandes de radio au volant, sans changer les touches à pied.</summary>
	public static bool RadioUsesKey( string action ) => OfLocalPlayer is { } car && car.Driver == RoleplayPlayer.Local
		&& new[] { "RadioToggle", "RadioPrevious", "RadioNext" }.Any( x => Keybinds.KeyOf( x ) == Keybinds.KeyOf( action ) );

	/// <summary>R : 0 ; U : -1 ; I : +1. Le serveur contrôle l'identité du conducteur et les bornes.</summary>
	[Rpc.Host]
	public void RequestRadio( int command )
	{
		var driver = Driver;
		if ( Rpc.Caller is null || driver is null || driver.Network.OwnerId != Rpc.Caller.Id )
			return;
		ApplyRadioCommand( driver, command );
	}

	internal bool ApplyRadioCommand( RoleplayPlayer player, int command )
	{
		if ( !Networking.IsHost || player is null || Driver != player || player.Downed || player.Cuffed
			|| !Usable || command is < -1 or > 1 || sinceRadioCommand < 0.18f )
			return false;
		sinceRadioCommand = 0;
		var wasEnabled = RadioEnabled;
		if ( command == 0 )
			RadioEnabled = !RadioEnabled;
		else
		{
			RadioStationId = VehicleRadioStations.Step( RadioStationId, command ).Id;
			RadioEnabled = true;
		}
		if ( command != 0 || wasEnabled != RadioEnabled ) PlayRadioSwitch( RadioEnabled );
		RequestSaveState();
		return true;
	}

	[Rpc.Broadcast( NetFlags.HostOnly )]
	void PlayRadioSwitch( bool enabled )
	{
		if ( !Application.IsDedicatedServer && OfLocalPlayer == this )
			Sounds.PlayFile( enabled ? RadioOnSound : RadioOffSound, 0.3f );
	}

    internal sealed class RadioStream
    {
        public MusicPlayer Player;
        public bool Ended, Failed;
        public float RetryAt, Playback;
        public RealTimeSince ProgressSince;
    }

    // Un lecteur par station visitée, au maximum sept pour toute la scène du client.
    // Une commutation mute l'ancien et réutilise le nouveau ; elle ne détruit aucun décodeur.
    Dictionary<string, RadioStream> radioCache;
    VehicleRadioAudio radioAudio;
    MusicPlayer radioPlayer;
    string playingRadioId;
    float radioWarmAt, radioPlayback;
    bool radioFailed;
    internal double LastRadioSwitchMilliseconds { get; private set; }

    Dictionary<string, RadioStream> RadioCache
    {
        get
        {
            if ( !radioAudio.IsValid() )
            {
                radioAudio = Scene.GetAllComponents<VehicleRadioAudio>().FirstOrDefault();
                if ( !radioAudio.IsValid() )
                {
                    var go = new GameObject( Scene ) { Name = "Radio locale (cache de session)", NetworkMode = NetworkMode.Never };
                    radioAudio = go.Components.Create<VehicleRadioAudio>();
                }
            }
            // Migration des lecteurs existants lors du rechargement à chaud.
            if ( radioCache is not null )
            {
                foreach ( var pair in radioCache ) radioAudio.Cache[pair.Key] = pair.Value;
                radioCache = null;
                radioAudio.Active = this;
            }
            return radioAudio.Cache;
        }
    }

    public string RadioStatus => !RadioEnabled ? "Coupée" : !Usable ? "Indisponible"
        : radioFailed ? "Reconnexion…" : radioPlayer is not null && radioPlayback > 0.05f ? "En direct" : "Connexion…";

    RadioStream GetRadioStream( VehicleRadioStations.Station station )
    {
        if ( !RadioCache.TryGetValue( station.Id, out var entry ) )
            RadioCache[station.Id] = entry = new RadioStream();
        if ( entry.Player is not null || RealTime.Now < entry.RetryAt ) return entry;
        try
        {
            entry.Player = MusicPlayer.PlayUrl( station.Url );
            entry.Player.Volume = 0;
            entry.Player.ListenLocal = true;
            entry.Player.Position = Vector3.Zero;
            entry.Player.TargetMixer = Sandbox.Audio.Mixer.FindMixerByName( "Music" );
            entry.Player.OnFinished = () => entry.Ended = true;
            entry.Ended = false;
            entry.Playback = 0;
            entry.ProgressSince = 0;
        }
        catch ( Exception e )
        {
            CloseRadioStream( entry );
            entry.Failed = true;
            entry.RetryAt = RealTime.Now + 15;
            Log.Warning( $"[Horizon] Radio {station.Name} : {e.Message}" );
        }
        return entry;
    }

    void UpdateRadio()
    {
        if ( Application.IsDedicatedServer ) return;
        if ( radioCache is not null ) _ = RadioCache;
        if ( OfLocalPlayer == this && Driver == RoleplayPlayer.Local && Ready && Usable
            && RoleplayPlayer.Local is { Downed: false, Cuffed: false } && !UiLock.IsLocked
            && Sandbox.UI.InputFocus.Current is null && Keybinds.Capturing is null )
        {
            if ( Keybinds.WasPressed( "RadioToggle" ) ) RequestRadio( 0 );
            else if ( Keybinds.WasPressed( "RadioPrevious" ) ) RequestRadio( -1 );
            else if ( Keybinds.WasPressed( "RadioNext" ) ) RequestRadio( 1 );
        }
        if ( OfLocalPlayer != this || !Usable )
        {
            StopRadio();
            return;
        }
        if ( !RadioEnabled )
        {
            // Éteindre doit être immédiat : Dispose peut attendre le décodeur réseau.
            // Garder le cache muet tant que le joueur reste dans cette voiture.
            if ( radioPlayer is not null ) radioPlayer.Volume = 0;
            return;
        }
        _ = RadioCache;
        radioAudio.Active = this;
        var station = RadioStation;
        var timer = System.Diagnostics.Stopwatch.StartNew();
        var changed = playingRadioId != station.Id;
        if ( changed )
        {
            // Adopter l'ancien lecteur avant de changer son identifiant (hotload).
            _ = RadioCache;
            if ( radioPlayer is not null ) radioPlayer.Volume = 0;
            playingRadioId = station.Id;
            radioWarmAt = RealTime.Now + 0.8f;
        }
        var entry = GetRadioStream( station );
        radioPlayer = entry.Player;
        if ( radioPlayer is not null )
        {
            var time = radioPlayer.PlaybackTime;
            if ( time > entry.Playback + 0.01f )
            {
                entry.Playback = time;
                entry.ProgressSince = 0;
                entry.Failed = false;
            }
            if ( entry.Ended || entry.ProgressSince >= 20 )
            {
                CloseRadioStream( entry );
                entry.Failed = true;
                entry.RetryAt = RealTime.Now + 5;
                radioPlayer = null;
            }
            else radioPlayer.Volume = RadioVolume * station.Gain;
        }
        radioPlayback = entry.Playback;
        radioFailed = entry.Failed;
        if ( changed ) LastRadioSwitchMilliseconds = timer.Elapsed.TotalMilliseconds;

        // Préchauffage progressif des voisines : pas de sept connexions lancées à la fois.
        if ( RealTime.Now < radioWarmAt || radioPlayback <= 0.05f ) return;
        foreach ( var neighbor in new[] { VehicleRadioStations.Step( station.Id, 1 ), VehicleRadioStations.Step( station.Id, -1 ) } )
        {
            if ( RadioCache.ContainsKey( neighbor.Id ) ) continue;
            GetRadioStream( neighbor );
            radioWarmAt = RealTime.Now + 0.8f;
            break;
        }
    }

    static void CloseRadioStream( RadioStream entry )
    {
        if ( entry.Player is not null )
        {
            entry.Player.OnFinished = null;
            entry.Player.Volume = 0;
            entry.Player.Dispose();
            entry.Player = null;
        }
        entry.Ended = false;
    }

    void StopRadio()
    {
        if ( radioCache is not null ) _ = RadioCache;
        if ( radioAudio.IsValid() && radioAudio.Active == this )
        {
            foreach ( var entry in radioAudio.Cache.Values )
                if ( entry.Player is not null ) entry.Player.Volume = 0;
            radioAudio.Active = null;
        }
        radioPlayer = null;
        playingRadioId = null;
        radioPlayback = radioWarmAt = 0;
        radioFailed = false;
    }
    protected override void OnDisabled() => StopRadio();
}

/// <summary>Lecteurs locaux partagés entre les voitures, libérés seulement en quittant la scène.</summary>
public sealed class VehicleRadioAudio : Component
{
    internal readonly Dictionary<string, Vehicle.RadioStream> Cache = new();
    internal Vehicle Active;

    protected override void OnDestroy()
    {
        foreach ( var entry in Cache.Values )
        {
            if ( entry.Player is null ) continue;
            entry.Player.OnFinished = null;
            entry.Player.Volume = 0;
            entry.Player.Dispose();
            entry.Player = null;
        }
        Cache.Clear();
        Active = null;
    }
}