@using DeepDrftPublic.Client.Services @if (_streamMessage is not null) {

@_streamMessage

} @implements IDisposable @code { [Parameter, EditorRequired] public required string ButtonClass { get; set; } [Parameter, EditorRequired] public required string ButtonLabel { get; set; } [Parameter] public string LoadingLabel { get; set; } = "Finding a track…"; [Parameter] public EventCallback OnStreamStarted { get; set; } [CascadingParameter] public IStreamingPlayerService? PlayerService { get; set; } [Inject] public required ITrackDataService TrackData { get; set; } private bool _streamLoading; private bool _findingTrack; private string? _streamMessage; private CancellationTokenSource? _messageCts; private const string EmptyLibraryMessage = "No tracks yet — check back soon."; private const string FetchFailedMessage = "Couldn't reach the library — try again."; private async Task StreamNow() { // Re-entrancy guard: the button is disabled while loading, but guard in code too so a // double-dispatch can never start two concurrent streams. if (_streamLoading) return; _streamLoading = true; _findingTrack = true; _streamMessage = null; // Warm the AudioContext FIRST, inside the gesture's call stack and before the network // await below. Safari only lets a suspended AudioContext resume while the originating // user gesture is still active; awaiting GetRandomTrack() first would consume the gesture // and leave playback silently refused. PlayerService is null only outside the // AudioPlayerProvider cascade (it should always be present in the public layout). var warmTask = PlayerService?.WarmAudioContext() ?? Task.CompletedTask; try { await warmTask; var result = await TrackData.GetRandomTrack(); if (!result.Success) { ShowTransientMessage(FetchFailedMessage); return; } if (result.Value is not { } track) { ShowTransientMessage(EmptyLibraryMessage); return; } await OnStreamStarted.InvokeAsync(); // Track is found — flip only the label flag so the button reverts to // its resting label before the stream begins, while _streamLoading stays true // to keep the button disabled and the re-entrancy guard intact. _findingTrack = false; StateHasChanged(); if (PlayerService is not null) await PlayerService.SelectTrackStreaming(track); } catch (Exception) { ShowTransientMessage(FetchFailedMessage); } finally { _streamLoading = false; _findingTrack = false; } } private void ShowTransientMessage(string message) { _streamMessage = message; // Cancel any in-flight clear timer so the newest message gets its full display window. _messageCts?.Cancel(); _messageCts?.Dispose(); _messageCts = new CancellationTokenSource(); var token = _messageCts.Token; _ = ClearMessageAfterDelayAsync(token); } private async Task ClearMessageAfterDelayAsync(CancellationToken token) { try { await Task.Delay(TimeSpan.FromSeconds(4), token); } catch (TaskCanceledException) { return; } _streamMessage = null; await InvokeAsync(StateHasChanged); } public void Dispose() { _messageCts?.Cancel(); _messageCts?.Dispose(); } }