using System; using System.Collections.Generic; using System.IO; using BepInEx; using BepInEx.Configuration; using BepInEx.Logging; using BepInEx.Unity.IL2CPP; using FMODUnity; using Il2CppInterop.Runtime; using Il2CppInterop.Runtime.Injection; using Il2CppInterop.Runtime.InteropTypes.Arrays; using Skopje.Modding; using UnityEngine; namespace Skopje.DomRadio { [BepInPlugin(PluginGuid, PluginName, PluginVersion)] public class DomRadioPlugin : BasePlugin { public const string PluginGuid = "com.skopje.domradio"; public const string PluginName = "Skopje Dom Radio"; public const string PluginVersion = "1.0.0"; internal static ManualLogSource LogSource; internal static ConfigEntry Toggle; internal static ConfigEntry NextTrack; internal static ConfigEntry PreviousTrack; internal static ConfigEntry Shuffle; internal static ConfigEntry MuteGameMusic; internal static ConfigEntry MusicBusFilter; internal static ConfigEntry Folder; internal static ConfigEntry ShowNowPlaying; internal static SkopjePlayer LocalPlayer; static GameObject _host; static Il2CppSystem.Action _onPlayerStarted; static Il2CppSystem.Action _onPlayerStopped; public override void Load() { LogSource = Log; Toggle = Config.Bind("Radio", "Toggle", KeyCode.F8, "Turn the radio on or off."); NextTrack = Config.Bind("Radio", "NextTrack", KeyCode.F10, "Skip to the next file."); PreviousTrack = Config.Bind("Radio", "PreviousTrack", KeyCode.F9, "Go back to the previous file."); Shuffle = Config.Bind("Radio", "Shuffle", false, "Play the folder in a random order. Off plays files by name."); MuteGameMusic = Config.Bind("Radio", "MuteGameMusic", true, "While the radio is on, mute the game's music buses. That includes the Dom bus track. Sound effects stay on."); MusicBusFilter = Config.Bind("Radio", "MusicBusFilter", "Music", "Mute FMOD buses whose path contains this text. Music covers the Dom bus home track."); Folder = Config.Bind("Radio", "Folder", "", "Folder of .mp3 and .wav files. Leave empty to use BepInEx/plugins/DomRadio/Music."); ShowNowPlaying = Config.Bind("Radio", "ShowNowPlaying", true, "Show the current file name while the radio is on."); ClassInjector.RegisterTypeInIl2Cpp(); try { Directory.CreateDirectory(MusicDirectory); } catch (Exception ex) { Log.LogWarning("Could not create the music folder: " + ex.Message); } _onPlayerStarted = DelegateSupport.ConvertDelegate>(new Action(OnPlayerStarted)); _onPlayerStopped = DelegateSupport.ConvertDelegate>(new Action(OnPlayerStopped)); ModAPI.add_OnPlayerStartedClient(_onPlayerStarted); ModAPI.add_OnPlayerStoppedClient(_onPlayerStopped); _host = new GameObject("SkopjeDomRadio"); UnityEngine.Object.DontDestroyOnLoad(_host); _host.hideFlags = HideFlags.HideAndDontSave; _host.AddComponent(); Log.LogInfo($"{PluginName} {PluginVersion} loaded. Put .mp3 and .wav files in {MusicDirectory}. Toggle {Toggle.Value}, next {NextTrack.Value}, previous {PreviousTrack.Value}."); } public override bool Unload() { if (DomRadioPlayer.Instance != null) DomRadioPlayer.Instance.TurnOff(false); GameMusic.Release(); LocalPlayer = null; if (_onPlayerStarted != null) ModAPI.remove_OnPlayerStartedClient(_onPlayerStarted); if (_onPlayerStopped != null) ModAPI.remove_OnPlayerStoppedClient(_onPlayerStopped); if (_host != null) { UnityEngine.Object.Destroy(_host); _host = null; } return true; } internal static string MusicDirectory { get { string custom = Folder == null ? null : Folder.Value; if (!string.IsNullOrWhiteSpace(custom)) return custom; return Path.Combine(Paths.PluginPath, "DomRadio", "Music"); } } static void OnPlayerStarted(SkopjePlayer player) { if (player != null && player.IsOwner) LocalPlayer = player; } static void OnPlayerStopped(SkopjePlayer player) { if (LocalPlayer == player) LocalPlayer = null; } internal static SkopjePlayer FindLocalPlayer() { if (LocalPlayer != null) return LocalPlayer; var players = UnityEngine.Object.FindObjectsOfType(); if (players == null) return null; for (int i = 0; i < players.Length; i++) { SkopjePlayer player = players[i]; if (player != null && player.IsOwner) { LocalPlayer = player; return player; } } return null; } internal static float MusicLevel() { try { OptionsManager manager = OptionsManager.Instance; if (manager == null || manager.OptionsData == null) return 1f; return Unit(manager.OptionsData.MasterVolume) * Unit(manager.OptionsData.MusicVolume); } catch (Exception) { return 1f; } } static float Unit(float value) { if (value > 1f) value *= 0.01f; return Mathf.Clamp01(value); } } public class DomRadioPlayer : MonoBehaviour { const float MissedPlaySeconds = 2f; public static DomRadioPlayer Instance { get; private set; } readonly List _tracks = new List(); FMOD.Sound _sound; FMOD.Channel _channel; bool _hasSound; int _index; int _failures; bool _on; bool _suspended; bool _skipPending; bool _heardPlayback; bool _loggedEmpty; float _playDeadline; string _nowPlaying = string.Empty; string _notice = string.Empty; float _noticeUntil; GUIStyle _label; Texture2D _backdrop; int _styleHeight; public DomRadioPlayer(IntPtr pointer) : base(pointer) { } void Awake() { Instance = this; } void OnDestroy() { TurnOff(false); if (_backdrop != null) Destroy(_backdrop); if (Instance == this) Instance = null; } void Update() { bool inside = InDom(); if (Pressed(DomRadioPlugin.Toggle.Value)) { if (_on) TurnOff(true); else if (inside) TurnOn(); else Notify("Radio only plays in the Dom", 2f); } if (!_on) return; if (!inside) { if (!_suspended) Suspend(); return; } if (_suspended) Resume(); if (Pressed(DomRadioPlugin.NextTrack.Value)) Advance(1); else if (Pressed(DomRadioPlugin.PreviousTrack.Value)) Advance(-1); PumpLoad(); // Mute the Dom track only while one of our files is actually audible. if (DomRadioPlugin.MuteGameMusic.Value && IsAudible()) GameMusic.Hold(); else if (!DomRadioPlugin.MuteGameMusic.Value) GameMusic.Release(); } void OnGUI() { if (Event.current == null || Event.current.type != EventType.Repaint || GUI.skin == null) return; EnsureStyle(); const float width = 640f; const float height = 36f; float textWidth = width - _label.padding.left - _label.padding.right; string text = null; if (_on && !_suspended && DomRadioPlugin.ShowNowPlaying.Value && !string.IsNullOrEmpty(_nowPlaying)) text = Fit(_nowPlaying, textWidth); else if (Time.unscaledTime <= _noticeUntil && !string.IsNullOrEmpty(_notice)) text = Fit(_notice, textWidth); if (text == null) return; var rect = new Rect((Screen.width - width) * 0.5f, Screen.height - 96f, width, height); Color previous = GUI.color; GUI.color = new Color(0f, 0f, 0f, 0.62f); GUI.DrawTexture(rect, _backdrop); GUI.color = previous; GUI.Label(rect, text, _label); } public void TurnOn() { ReloadTracks(); if (_tracks.Count == 0) { if (!_loggedEmpty) { _loggedEmpty = true; DomRadioPlugin.LogSource.LogInfo("Radio disabled. No .mp3 or .wav files in " + DomRadioPlugin.MusicDirectory + ". Dom music will keep playing."); } return; } _loggedEmpty = false; _on = true; _failures = 0; _index = 0; _nowPlaying = TrackLabel(_tracks[0]); try { PlayFile(_tracks[0]); } catch (Exception ex) { DomRadioPlugin.LogSource.LogWarning("Could not start the radio: " + ex.Message); TurnOff(false); DomRadioPlugin.LogSource.LogInfo("Radio disabled. Dom music restored."); return; } DomRadioPlugin.LogSource.LogInfo("Radio on. " + _tracks.Count + " file(s) in " + DomRadioPlugin.MusicDirectory); } public void TurnOff(bool announce) { _on = false; _suspended = false; _skipPending = false; _heardPlayback = false; _nowPlaying = string.Empty; StopSound(); GameMusic.Release(); if (announce) { Notify("Radio off", 2f); DomRadioPlugin.LogSource.LogInfo("Radio off. Dom music restored."); } } void Advance(int direction) { if (_tracks.Count == 0) { TurnOff(true); return; } int next = _index + direction; if (next >= _tracks.Count) next = 0; else if (next < 0) next = _tracks.Count - 1; _index = next; _nowPlaying = TrackLabel(_tracks[_index]); PlayFile(_tracks[_index]); } void ReloadTracks() { _tracks.Clear(); string folder = DomRadioPlugin.MusicDirectory; if (!Directory.Exists(folder)) { try { Directory.CreateDirectory(folder); } catch (Exception ex) { DomRadioPlugin.LogSource.LogWarning("Could not create the music folder: " + ex.Message); return; } } string[] files; try { files = Directory.GetFiles(folder); } catch (Exception ex) { DomRadioPlugin.LogSource.LogWarning("Could not read the music folder: " + ex.Message); return; } for (int i = 0; i < files.Length; i++) { string extension = Path.GetExtension(files[i]); if (extension.Equals(".mp3", StringComparison.OrdinalIgnoreCase) || extension.Equals(".wav", StringComparison.OrdinalIgnoreCase)) _tracks.Add(files[i]); } _tracks.Sort(StringComparer.OrdinalIgnoreCase); if (DomRadioPlugin.Shuffle.Value) ShuffleTracks(); } void ShuffleTracks() { var random = new System.Random(); for (int i = _tracks.Count - 1; i > 0; i--) { int swap = random.Next(i + 1); string file = _tracks[i]; _tracks[i] = _tracks[swap]; _tracks[swap] = file; } } void PlayFile(string path) { StopSound(); _heardPlayback = false; _nowPlaying = TrackLabel(path); if (!RuntimeManager.IsInitialized) { DomRadioPlugin.LogSource.LogWarning("Audio system is not ready yet."); _skipPending = true; return; } FMOD.System core = RuntimeManager.CoreSystem; if (!core.hasHandle()) { DomRadioPlugin.LogSource.LogWarning("Audio system is not ready yet."); _skipPending = true; return; } FMOD.MODE mode = FMOD.MODE.CREATESTREAM | FMOD.MODE._2D | FMOD.MODE.LOOP_OFF; FMOD.Sound sound; FMOD.RESULT result = core.createSound(path, mode, out sound); if (result != FMOD.RESULT.OK || !sound.hasHandle()) { DomRadioPlugin.LogSource.LogWarning("Could not open " + Path.GetFileName(path) + " (" + result + ")."); _skipPending = true; return; } FMOD.ChannelGroup master; core.getMasterChannelGroup(out master); FMOD.Channel channel; result = core.playSound(sound, master, false, out channel); if (result != FMOD.RESULT.OK || !channel.hasHandle()) { sound.release(); DomRadioPlugin.LogSource.LogWarning("Could not play " + Path.GetFileName(path) + " (" + result + ")."); _skipPending = true; return; } channel.setVolume(DomRadioPlugin.MusicLevel()); _sound = sound; _channel = channel; _hasSound = true; _playDeadline = Time.unscaledTime + MissedPlaySeconds; } void PumpLoad() { if (_skipPending) { _skipPending = false; OnTrackFailed(); return; } if (!_hasSound) return; if (IsAudible()) { _heardPlayback = true; _failures = 0; _channel.setVolume(DomRadioPlugin.MusicLevel()); return; } if (_heardPlayback || Time.unscaledTime >= _playDeadline) Advance(1); } bool IsAudible() { if (!_hasSound || !_channel.hasHandle()) return false; bool playing = false; return _channel.isPlaying(out playing) == FMOD.RESULT.OK && playing; } void OnTrackFailed() { _failures++; if (_tracks.Count == 0 || _failures >= _tracks.Count) { TurnOff(false); DomRadioPlugin.LogSource.LogInfo("Radio disabled. No playable files in " + DomRadioPlugin.MusicDirectory + ". Dom music restored."); return; } Advance(1); } void StopSound() { if (_hasSound && _channel.hasHandle()) _channel.stop(); if (_hasSound && _sound.hasHandle()) _sound.release(); _hasSound = false; } void Notify(string text, float seconds) { _notice = text ?? string.Empty; _noticeUntil = Time.unscaledTime + seconds; } static bool _loggedInput; static bool Pressed(KeyCode key) { if (key == KeyCode.None) return false; try { return Input.GetKeyDown(key); } catch (Exception ex) { if (!_loggedInput) { _loggedInput = true; DomRadioPlugin.LogSource.LogWarning("Keyboard input is unavailable: " + ex.Message); } return false; } } bool InDom() { SkopjePlayer player = DomRadioPlugin.FindLocalPlayer(); if (player == null) return false; try { return player.InsideVehicle != null; } catch (Exception) { DomRadioPlugin.LocalPlayer = null; return false; } } void Suspend() { _suspended = true; if (_hasSound && _channel.hasHandle()) _channel.setPaused(true); GameMusic.Release(); } void Resume() { _suspended = false; if (_hasSound && _channel.hasHandle()) { _channel.setPaused(false); _channel.setVolume(DomRadioPlugin.MusicLevel()); return; } if (_index >= 0 && _index < _tracks.Count) PlayFile(_tracks[_index]); } string Fit(string text, float width) { if (string.IsNullOrEmpty(text) || _label.CalcSize(new GUIContent(text)).x <= width) return text; const string ellipsis = "..."; int low = 0; int high = text.Length; while (low < high) { int mid = (low + high + 1) / 2; string candidate = text.Substring(0, mid).TrimEnd() + ellipsis; if (_label.CalcSize(new GUIContent(candidate)).x <= width) low = mid; else high = mid - 1; } if (low <= 0) return ellipsis; return text.Substring(0, low).TrimEnd() + ellipsis; } static string TrackLabel(string path) { return "Radio — " + Path.GetFileNameWithoutExtension(path); } void EnsureStyle() { if (_backdrop == null) { _backdrop = new Texture2D(1, 1); _backdrop.SetPixel(0, 0, Color.white); _backdrop.Apply(); } if (_label != null && _styleHeight == Screen.height) return; _styleHeight = Screen.height; _label = new GUIStyle(GUI.skin.label); _label.alignment = TextAnchor.MiddleCenter; _label.wordWrap = false; _label.clipping = TextClipping.Clip; _label.padding = new RectOffset(12, 12, 0, 0); _label.fontSize = Mathf.Clamp(Mathf.RoundToInt(Screen.height * 0.018f), 14, 28); _label.normal.textColor = Color.white; } } // The Dom bus soundtrack is event:/Music/Bus/Home, on the game's Music buses. // Muting those buses leaves the events running, so the track returns when the radio turns off. static class GameMusic { sealed class HeldBus { public FMOD.Studio.Bus Bus; public bool WasMuted; } static readonly List _held = new List(); static bool _loggedMiss; static float _nextDiscover; public static void Hold() { if (!DomRadioPlugin.MuteGameMusic.Value) return; if (!RuntimeManager.IsInitialized) return; if (_held.Count == 0 && Time.unscaledTime >= _nextDiscover) { _nextDiscover = Time.unscaledTime + 1f; Discover(); } int valid = 0; for (int i = 0; i < _held.Count; i++) { FMOD.Studio.Bus bus = _held[i].Bus; if (!bus.isValid()) continue; valid++; bus.setMute(true); } // Banks reload with a new level. Drop dead handles so the next pass can find the music buses again. if (_held.Count > 0 && valid == 0) { _held.Clear(); _nextDiscover = 0f; } } public static void Release() { for (int i = 0; i < _held.Count; i++) { FMOD.Studio.Bus bus = _held[i].Bus; if (bus.isValid()) bus.setMute(_held[i].WasMuted); } _held.Clear(); _loggedMiss = false; _nextDiscover = 0f; } static void Discover() { try { var studio = RuntimeManager.StudioSystem; if (!studio.isValid()) return; Il2CppStructArray banks = null; if (studio.getBankList(out banks) != FMOD.RESULT.OK || banks == null || banks.Length == 0) return; string filter = DomRadioPlugin.MusicBusFilter.Value ?? string.Empty; var seen = new HashSet(StringComparer.OrdinalIgnoreCase); var others = new List(); for (int i = 0; i < banks.Length; i++) { FMOD.Studio.Bank bank = banks[i]; if (!bank.isValid()) continue; Il2CppStructArray buses = null; if (bank.getBusList(out buses) != FMOD.RESULT.OK || buses == null) continue; for (int b = 0; b < buses.Length; b++) { FMOD.Studio.Bus bus = buses[b]; if (!bus.isValid()) continue; string path = null; if (bus.getPath(out path) != FMOD.RESULT.OK || string.IsNullOrEmpty(path)) continue; if (!seen.Add(path)) continue; if (filter.Length == 0 || path.IndexOf(filter, StringComparison.OrdinalIgnoreCase) < 0) { others.Add(path); continue; } bool wasMuted = false; bus.getMute(out wasMuted); _held.Add(new HeldBus { Bus = bus, WasMuted = wasMuted }); DomRadioPlugin.LogSource.LogInfo("Muting " + path); } } if (_held.Count == 0 && !_loggedMiss) { _loggedMiss = true; DomRadioPlugin.LogSource.LogWarning("No music bus matched \"" + filter + "\". Dom music will keep playing. Buses: " + string.Join(", ", others)); } } catch (Exception ex) { DomRadioPlugin.LogSource.LogWarning("Could not find the music buses: " + ex.Message); } } } }