SoundPreloadService.cs

Service that preloads sound resources on demand for a soundboard. It tracks per-sound load state, queues prioritized loads, limits concurrent async loads, supports enqueueing a play when ready, and signals state changes via events.

NetworkingFile Access
using System;
using System.Collections.Generic;
using System.Threading.Tasks;

namespace SSoundboard;

/// <summary>
/// On-demand sound loading. Prefer TryGet (already compiled); LoadAsync only on click.
/// </summary>
public static class SoundPreloadService
{
	enum LoadState { Pending, Loading, Ready, Failed }

	static readonly Dictionary<string, LoadState> _states = new( StringComparer.OrdinalIgnoreCase );
	static readonly List<string> _priority = new();
	static readonly Dictionary<string, SoundPad> _pendingPlays = new( StringComparer.OrdinalIgnoreCase );

	static int _loaded;
	static int _registered;
	static int _activeLoads;
	static bool _initialized;
	static bool _startupComplete;

	const int MaxConcurrentLoads = 2;
	const int StartupWarmCap = 32;

	public static event Action Changed;

	public static int LoadedCount => _loaded;
	public static bool IsInitialized => _initialized;
	public static bool IsStartupComplete => _startupComplete;
	/// <summary>Bumps when pending-play set changes — cheap UI rebuild signal.</summary>
	public static int PendingRevision { get; private set; }

	public static void Initialize()
	{
		if ( _initialized )
			return;

		_initialized = true;

		var warmed = 0;
		foreach ( var soundId in SoundBoardData.StartupSoundIds )
		{
			if ( string.IsNullOrWhiteSpace( soundId ) )
				continue;

			if ( TryMarkReadyIfPresent( Normalize( soundId ) ) )
				warmed++;

			if ( warmed >= StartupWarmCap )
				break;
		}

		_startupComplete = true;
		Changed?.Invoke();
	}

	static void EnsureInitialized()
	{
		if ( !_initialized )
			Initialize();
	}

	public static bool IsReady( string soundId )
	{
		if ( string.IsNullOrWhiteSpace( soundId ) )
			return false;

		EnsureInitialized();
		var key = Normalize( soundId );

		if ( _states.TryGetValue( key, out var state ) )
		{
			if ( state == LoadState.Ready )
				return true;
			if ( state is LoadState.Failed or LoadState.Loading )
				return false;
		}

		return TryMarkReadyIfPresent( key );
	}

	public static void BumpPriority( string soundId )
	{
		if ( string.IsNullOrWhiteSpace( soundId ) )
			return;

		EnsureInitialized();
		var key = Normalize( soundId );

		if ( IsReady( key ) )
			return;

		if ( _states.TryGetValue( key, out var state ) )
		{
			if ( state is LoadState.Loading or LoadState.Ready )
				return;

			if ( state == LoadState.Failed )
				_states[key] = LoadState.Pending;
		}
		else
		{
			_states[key] = LoadState.Pending;
			_registered++;
		}

		_priority.Remove( key );
		_priority.Insert( 0, key );
		Changed?.Invoke();
	}

	public static void QueuePlayWhenReady( SoundPad pad )
	{
		if ( pad is null || string.IsNullOrWhiteSpace( pad.SoundId ) )
			return;

		var key = Normalize( pad.SoundId );
		_pendingPlays[key] = pad;
		PendingRevision++;
		BumpPriority( key );
	}

	public static bool TryTakePendingPlay( string soundId, out SoundPad pad )
	{
		var removed = _pendingPlays.Remove( Normalize( soundId ), out pad );
		if ( removed )
			PendingRevision++;
		return removed;
	}

	public static bool IsPendingPlay( string soundId )
	{
		return _pendingPlays.ContainsKey( Normalize( soundId ) );
	}

	public static void Tick()
	{
		EnsureInitialized();
		TryPumpLoads();
	}

	static void TryPumpLoads()
	{
		while ( _activeLoads < MaxConcurrentLoads && TryDequeue( out var soundId ) )
		{
			if ( TryMarkReadyIfPresent( soundId ) )
			{
				SoundBoardManager.OnSoundReady( soundId );
				continue;
			}

			_activeLoads++;
			_states[soundId] = LoadState.Loading;
			_ = LoadOneAsync( soundId );
		}
	}

	static bool TryDequeue( out string soundId )
	{
		while ( _priority.Count > 0 )
		{
			var next = _priority[0];
			_priority.RemoveAt( 0 );

			if ( !_states.TryGetValue( next, out var st ) || st != LoadState.Pending )
				continue;

			soundId = next;
			return true;
		}

		soundId = null;
		return false;
	}

	static async Task LoadOneAsync( string soundId )
	{
		var path = ToSoundPath( soundId );

		try
		{
			if ( ResourceLibrary.TryGet<SoundEvent>( path, out _ ) )
			{
				MarkReady( soundId );
				SoundBoardManager.OnSoundReady( soundId );
				return;
			}

			var loaded = await ResourceLibrary.LoadAsync<SoundEvent>( path );
			if ( loaded is null )
			{
				MarkFailed( soundId );
				return;
			}

			try { Sound.Preload( path ); }
			catch { /* playable without preload */ }

			MarkReady( soundId );
			SoundBoardManager.OnSoundReady( soundId );
		}
		catch ( Exception e )
		{
			Log.Warning( $"[S&Soundboard] Sound load skipped: {path} ({e.GetType().Name})" );
			MarkFailed( soundId );
		}
		finally
		{
			_activeLoads = Math.Max( 0, _activeLoads - 1 );
		}
	}

	static bool TryMarkReadyIfPresent( string key )
	{
		try
		{
			if ( !ResourceLibrary.TryGet<SoundEvent>( ToSoundPath( key ), out _ ) )
				return false;
		}
		catch
		{
			return false;
		}

		MarkReady( key );
		return true;
	}

	static void MarkReady( string key )
	{
		var already = _states.TryGetValue( key, out var prev ) && prev == LoadState.Ready;
		_states[key] = LoadState.Ready;
		if ( !already )
			_loaded++;
		Changed?.Invoke();
	}

	static void MarkFailed( string key )
	{
		_states[key] = LoadState.Failed;
		if ( _pendingPlays.Remove( key ) )
			PendingRevision++;
		Changed?.Invoke();
	}

	static string Normalize( string soundId )
	{
		if ( string.IsNullOrWhiteSpace( soundId ) )
			return soundId;

		soundId = soundId.Replace( '\\', '/' ).Trim();
		return soundId.EndsWith( ".sound", StringComparison.OrdinalIgnoreCase )
			? soundId[..^6]
			: soundId;
	}

	static string ToSoundPath( string soundId ) => $"{Normalize( soundId )}.sound";
}