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.
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;
}
}