SoundBoardCoop.cs

Co-op helper for a soundboard. Manages whether co-op sharing is enabled, tracks recent remote/local pad highlights (player name, Steam ID, color, expiry), signals UI updates, and routes incoming network pad events to play remote sounds.

Networking
using System;
using System.Collections.Generic;

namespace SSoundboard;

/// <summary>
/// Local CO-OP preference + remote pad highlights (name + Steam avatar).
/// Starts ON — when the session is multiplayer, pad presses are shared.
/// Network transport lives in <see cref="SoundBoardNet"/>.
/// </summary>
public static class SoundBoardCoop
{
	/// <summary>When true, share presses and play/show everyone else's presses.</summary>
	public static bool Enabled { get; private set; } = true;

	/// <summary>Bumps when highlights or enabled flag change — UI rebuild signal.</summary>
	public static int Revision { get; private set; }

	public static event Action StateChanged;

	/// <summary>How long a remote (or co-op local) highlight stays on a pad.</summary>
	const float HighlightSeconds = 2.6f;

	static readonly Dictionary<string, Highlight> _highlights = new( StringComparer.OrdinalIgnoreCase );
	static float _lastExpireTick = -1f;

	public readonly struct Highlight
	{
		public string PlayerName { get; init; }
		public long SteamId { get; init; }
		public float Until { get; init; }
		public string Color { get; init; }
	}

	public static void Toggle()
	{
		SetEnabled( !Enabled );
	}

	public static void SetEnabled( bool on )
	{
		if ( Enabled == on )
			return;

		Enabled = on;

		if ( !Enabled )
		{
			// Instant off: drop name/pfp overlays and silence lobby-sourced audio.
			// Stay in the multiplayer session — only stop sharing/hearing presses.
			_highlights.Clear();
			SoundBoardManager.KillRemoteVoices();
		}
		else
		{
			SoundBoardNet.EnsureHostRelay();
		}

		Bump();
	}

	/// <summary>Own press while co-op is on — show your pfp/name locally too.</summary>
	public static void RegisterLocalPress( SoundPad pad )
	{
		if ( !Enabled || pad is null || string.IsNullOrWhiteSpace( pad.SoundId ) )
			return;

		var local = Connection.Local;
		var name = local?.DisplayName;
		if ( string.IsNullOrWhiteSpace( name ) )
			name = "You";

		long steamId = ResolveLocalSteamId( local );
		UpsertHighlight( pad.SoundId, name, steamId, pad.Color );
	}

	/// <summary>Incoming lobby press (or echo). Skips re-play for the original presser.</summary>
	public static void OnNetworkPad(
		string soundId,
		string label,
		string color,
		float volume,
		string playerName,
		long steamId )
	{
		if ( !Enabled )
			return;

		if ( string.IsNullOrWhiteSpace( soundId ) )
			return;

		if ( string.IsNullOrWhiteSpace( playerName ) )
			playerName = "Player";

		if ( string.IsNullOrWhiteSpace( color ) )
			color = "#7cf9ff";

		if ( volume <= 0f )
			volume = 1f;

		UpsertHighlight( soundId, playerName, steamId, color );

		// Already played locally for our own press.
		if ( IsLocalSteamId( steamId ) )
			return;

		var pad = new SoundPad
		{
			SoundId = soundId,
			Label = string.IsNullOrWhiteSpace( label ) ? "REMOTE" : label,
			Color = color,
			Volume = volume
		};

		SoundBoardManager.PlayRemote( pad, playerName );
	}

	public static bool TryGetHighlight( string soundId, out Highlight highlight )
	{
		highlight = default;
		if ( string.IsNullOrWhiteSpace( soundId ) )
			return false;

		if ( !_highlights.TryGetValue( soundId, out var h ) )
			return false;

		if ( Time.Now >= h.Until )
		{
			_highlights.Remove( soundId );
			return false;
		}

		highlight = h;
		return true;
	}

	public static void Tick()
	{
		// Cheap expire pass once per frame max.
		var now = Time.Now;
		if ( MathF.Abs( now - _lastExpireTick ) < 0.00001f )
			return;
		_lastExpireTick = now;

		// Host relay only when a multiplayer session already exists — never CreateLobby/Query here.
		if ( Enabled )
		{
			try
			{
				if ( Networking.IsActive )
					SoundBoardNet.EnsureHostRelay();
			}
			catch { /* never break the board */ }
		}

		if ( _highlights.Count == 0 )
			return;

		var removed = false;
		List<string> dead = null;
		foreach ( var kv in _highlights )
		{
			if ( now < kv.Value.Until )
				continue;
			dead ??= new List<string>( 4 );
			dead.Add( kv.Key );
		}

		if ( dead is null )
			return;

		for ( var i = 0; i < dead.Count; i++ )
		{
			_highlights.Remove( dead[i] );
			removed = true;
		}

		if ( removed )
			Bump();
	}

	public static string StatusHint()
	{
		if ( !Enabled )
			return "";

		try
		{
			if ( !Networking.IsActive )
				return "start multiplayer lobby";

			var n = 0;
			foreach ( var _ in Connection.All )
				n++;

			if ( n <= 1 )
				return "lobby · waiting for players";

			return $"lobby · {n} players";
		}
		catch
		{
			return "lobby";
		}
	}

	static void UpsertHighlight( string soundId, string playerName, long steamId, string color )
	{
		_highlights[soundId] = new Highlight
		{
			PlayerName = playerName,
			SteamId = steamId,
			Until = Time.Now + HighlightSeconds,
			Color = color ?? "#7cf9ff"
		};
		Bump();
	}

	static bool IsLocalSteamId( long steamId )
	{
		if ( steamId == 0 )
			return false;

		try
		{
			return ResolveLocalSteamId( Connection.Local ) == steamId;
		}
		catch
		{
			return false;
		}
	}

	static long ResolveLocalSteamId( Connection local )
	{
		try
		{
			if ( local is not null )
			{
				try
				{
					var fromFriend = ParseSteamIdObject( local.FriendInfo.Id );
					if ( fromFriend != 0 )
						return fromFriend;
				}
				catch { /* ignore */ }

				try
				{
					var fromConn = ParseSteamIdObject( local.SteamId );
					if ( fromConn != 0 )
						return fromConn;
				}
				catch { /* ignore */ }
			}
		}
		catch { /* fall through */ }

		try
		{
			return ParseSteamIdObject( Game.SteamId );
		}
		catch
		{
			return 0;
		}
	}

	static long ParseSteamIdObject( object value )
	{
		if ( value is null )
			return 0;

		try
		{
			switch ( value )
			{
				case long l:
					return l;
				case ulong ul:
					return unchecked( (long)ul );
				case int i:
					return i;
				case uint ui:
					return ui;
			}
		}
		catch { /* fall through */ }

		try
		{
			var s = value.ToString();
			if ( !string.IsNullOrWhiteSpace( s ) && long.TryParse( s, out var parsed ) )
				return parsed;
		}
		catch { /* ignore */ }

		return 0;
	}

	static void Bump()
	{
		Revision++;
		StateChanged?.Invoke();
	}
}