diff --git a/Trdo/Controls/TutorialWindow.xaml b/Trdo/Controls/TutorialWindow.xaml index fe4d4f2..66d58f3 100644 --- a/Trdo/Controls/TutorialWindow.xaml +++ b/Trdo/Controls/TutorialWindow.xaml @@ -30,7 +30,7 @@ - + - + + Version="1.7.0.0" /> diff --git a/Trdo/Pages/AboutPage.xaml b/Trdo/Pages/AboutPage.xaml index 8cc1ff6..8dcf6be 100644 --- a/Trdo/Pages/AboutPage.xaml +++ b/Trdo/Pages/AboutPage.xaml @@ -17,7 +17,7 @@ Text="About" /> - + - + + + + + + + + + + @@ -183,103 +262,97 @@ TextWrapping="Wrap" /> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Trdo/Pages/AboutPage.xaml.cs b/Trdo/Pages/AboutPage.xaml.cs index ea0e9b0..6558e63 100644 --- a/Trdo/Pages/AboutPage.xaml.cs +++ b/Trdo/Pages/AboutPage.xaml.cs @@ -31,6 +31,45 @@ private void ReviewButton_Click(object sender, RoutedEventArgs e) ViewModel.OpenRatingWindow(); } + private void Star_Click(object sender, RoutedEventArgs e) + { + if (sender is Button button && button.Tag is string tagString && int.TryParse(tagString, out int rating)) + { + ViewModel.SelectedRating = rating; + UpdateStarVisuals(rating); + + // If 5 stars, immediately launch the store review + if (rating >= 4) + { + _ = ViewModel.OpenRatingWindow(); + } + } + } + + private void UpdateStarVisuals(int selectedRating) + { + // Update all star buttons to show filled or outline based on rating + UpdateStarButton("Star1", selectedRating >= 1); + UpdateStarButton("Star2", selectedRating >= 2); + UpdateStarButton("Star3", selectedRating >= 3); + UpdateStarButton("Star4", selectedRating >= 4); + UpdateStarButton("Star5", selectedRating >= 5); + } + + private void UpdateStarButton(string buttonName, bool isFilled) + { + if (FindName(buttonName) is Button starButton && starButton.Content is FontIcon icon) + { + // E735 is filled star, E734 is outline star + icon.Glyph = isFilled ? "\uE735" : "\uE734"; + } + } + + private void ContactDeveloperButton_Click(object sender, RoutedEventArgs e) + { + _ = ViewModel.ContactDeveloper(); + } + private void TutorialButton_Click(object sender, RoutedEventArgs e) { TutorialWindow tutorialWindow = new(); diff --git a/Trdo/Pages/AddStation.xaml b/Trdo/Pages/AddStation.xaml index 799ddd1..05d25e5 100644 --- a/Trdo/Pages/AddStation.xaml +++ b/Trdo/Pages/AddStation.xaml @@ -17,46 +17,48 @@ Text="{x:Bind ViewModel.PageTitle, Mode=OneWay}" /> - - - - - - + + + + + + + - - - - - + + + + + - - - - - + + + + + - - - - + + + + + - + diff --git a/Trdo/Pages/FavoritesPage.xaml b/Trdo/Pages/FavoritesPage.xaml index 3373011..591b8c1 100644 --- a/Trdo/Pages/FavoritesPage.xaml +++ b/Trdo/Pages/FavoritesPage.xaml @@ -21,7 +21,7 @@ Grid.Row="0" FontSize="16" FontWeight="SemiBold" - Text="Favorites" /> + Text="Favorite Songs" /> @@ -41,13 +41,13 @@ HorizontalAlignment="Center" FontSize="16" Foreground="{ThemeResource TextFillColorSecondaryBrush}" - Text="No favorites yet" + Text="No favorite songs yet" TextAlignment="Center" /> diff --git a/Trdo/Pages/NowPlayingPage.xaml b/Trdo/Pages/NowPlayingPage.xaml index 7e483e7..ee57468 100644 --- a/Trdo/Pages/NowPlayingPage.xaml +++ b/Trdo/Pages/NowPlayingPage.xaml @@ -88,27 +88,27 @@ @@ -121,7 +121,7 @@ Background="Transparent" BorderThickness="0" Click="FavoriteCurrentTrack_Click" - ToolTipService.ToolTip="Toggle favorite"> + ToolTipService.ToolTip="Favorite Song"> + ToolTipService.ToolTip="Favorite Song"> @@ -46,7 +47,7 @@ Background="Transparent" BorderBrush="Transparent" Click="FavoritesButton_Click" - ToolTipService.ToolTip="Favorites"> + ToolTipService.ToolTip="Favorite Songs"> @@ -91,6 +92,7 @@ CanReorderItems="True" DragItemsCompleted="StationsListView_DragItemsCompleted" ItemsSource="{x:Bind ViewModel.Stations, Mode=OneWay}" + SelectedItem="{x:Bind ViewModel.SelectedStation, Mode=TwoWay}" SelectionChanged="StationsListView_SelectionChanged" SelectionMode="Single"> @@ -266,6 +268,17 @@ FontSize="14" Foreground="{ThemeResource AccentFillColorDefaultBrush}" Glyph="" /> + + TextWrapping="NoWrap" + Visibility="Collapsed" /> - - + + + + diff --git a/Trdo/Pages/SettingsPage.xaml b/Trdo/Pages/SettingsPage.xaml index 642b893..c26cf1a 100644 --- a/Trdo/Pages/SettingsPage.xaml +++ b/Trdo/Pages/SettingsPage.xaml @@ -16,7 +16,7 @@ Text="Settings" /> - + @@ -56,7 +56,57 @@ Text="{x:Bind ViewModel.WatchdogToggleText, Mode=OneWay}" /> + + + + + + + + + + + + + + + + + + + + - + diff --git a/Trdo/Services/RadioPlayerService.cs b/Trdo/Services/RadioPlayerService.cs index afcb01c..a081d2e 100644 --- a/Trdo/Services/RadioPlayerService.cs +++ b/Trdo/Services/RadioPlayerService.cs @@ -1,7 +1,9 @@ using Microsoft.UI.Dispatching; using System; +using System.Collections.Generic; using System.Diagnostics; using System.Net.Http; +using System.Threading; using System.Threading.Tasks; using Trdo.Models; using Windows.Media; @@ -35,6 +37,7 @@ public sealed partial class RadioPlayerService : IDisposable private System.Threading.Timer? _internalStateChangeTimer; private DateTime _lastExternalPauseRecovery = DateTime.MinValue; private bool _hasPlayedOnce; + private bool _isManuallyBuffering; public static RadioPlayerService Instance { get; } = new(); @@ -60,13 +63,14 @@ public bool IsBuffering try { MediaPlaybackState state = _player.PlaybackSession.PlaybackState; - bool isBuffering = state is MediaPlaybackState.Opening or MediaPlaybackState.Buffering; - Debug.WriteLine($"[RadioPlayerService] IsBuffering getter: {isBuffering}, PlaybackState: {state}"); + bool isPlayerBuffering = state is MediaPlaybackState.Opening or MediaPlaybackState.Buffering; + bool isBuffering = isPlayerBuffering || _isManuallyBuffering; + Debug.WriteLine($"[RadioPlayerService] IsBuffering getter: {isBuffering} (Player: {isPlayerBuffering}, Manual: {_isManuallyBuffering}), PlaybackState: {state}"); return isBuffering; } catch { - return false; + return _isManuallyBuffering; } } } @@ -118,6 +122,69 @@ public TimeSpan Position } } + /// + /// Gets the total buffered duration using MediaPlaybackSession.GetBufferedRanges. + /// Returns the sum of all buffered time ranges. + /// + public TimeSpan TotalBufferedDuration + { + get + { + try + { + var bufferedRanges = _player.PlaybackSession.GetBufferedRanges(); + TimeSpan totalBuffered = TimeSpan.Zero; + foreach (var range in bufferedRanges) + { + totalBuffered += range.End - range.Start; + } + return totalBuffered; + } + catch + { + return TimeSpan.Zero; + } + } + } + + /// + /// Gets the buffered ranges from the playback session. + /// Each MediaTimeRange contains Start and End times representing buffered content. + /// + public IReadOnlyList GetBufferedRanges() + { + try + { + return _player.PlaybackSession.GetBufferedRanges(); + } + catch + { + return Array.Empty(); + } + } + + /// + /// Gets the minimum buffer duration required based on current buffer level setting. + /// + public TimeSpan RequiredBufferDuration => TimeSpan.FromMilliseconds(_watchdog.BufferDelayMs); + + /// + /// Checks if sufficient buffer is available for smooth playback based on current buffer level. + /// + public bool HasSufficientBuffer + { + get + { + TimeSpan required = RequiredBufferDuration; + if (required == TimeSpan.Zero) + { + // Default level - no minimum buffer required + return true; + } + return TotalBufferedDuration >= required; + } + } + public StreamWatchdogService Watchdog => _watchdog; /// @@ -429,7 +496,8 @@ public void SetStationFavicon(string? faviconUrl) } /// - /// Start playback of the current stream + /// Start playback of the current stream. + /// Always applies buffer settings based on current buffer level using GetBufferedRanges. /// public void Play() { @@ -440,6 +508,7 @@ public void Play() Debug.WriteLine($"[RadioPlayerService] Player.Source is null: {_player.Source == null}"); Debug.WriteLine($"[RadioPlayerService] Has played once: {_hasPlayedOnce}"); Debug.WriteLine($"[RadioPlayerService] Was external pause: {_wasExternalPause}"); + Debug.WriteLine($"[RadioPlayerService] Required buffer: {RequiredBufferDuration.TotalMilliseconds}ms"); if (string.IsNullOrWhiteSpace(_streamUrl)) { @@ -447,6 +516,20 @@ public void Play() throw new InvalidOperationException("No stream URL set. Call SetStreamUrl first."); } + // Always use buffer-aware playback to apply buffer settings + // Fire and forget - PlayWithBufferAsync handles everything including buffering + _ = PlayWithBufferInternalAsync(); + + Debug.WriteLine($"=== Play END ==="); + } + + /// + /// Internal method that handles buffered playback asynchronously. + /// This is called by Play() to apply buffer settings every time playback starts. + /// The stream is paused during buffering, then resumed once sufficient buffer is achieved. + /// + private async Task PlayWithBufferInternalAsync() + { try { // Only recreate MediaSource if: @@ -474,7 +557,7 @@ public void Play() } // Create fresh MediaSource - Uri uri = new(_streamUrl); + Uri uri = new(_streamUrl!); _player.Source = MediaSource.CreateFromUri(uri); Debug.WriteLine($"[RadioPlayerService] Created new MediaSource from URL: {_streamUrl}"); @@ -485,10 +568,59 @@ public void Play() Debug.WriteLine("[RadioPlayerService] Reusing existing MediaSource - resuming playback"); } - Debug.WriteLine("[RadioPlayerService] Calling _player.Play()..."); - SetInternalStateChange(true); - _player.Play(); - Debug.WriteLine("[RadioPlayerService] _player.Play() called successfully"); + // Check if we need to buffer before playing + bool needsBuffering = RequiredBufferDuration > TimeSpan.Zero; + + if (needsBuffering) + { + // Start playback briefly to initiate buffering + Debug.WriteLine("[RadioPlayerService] Starting playback to initiate buffering..."); + SetInternalStateChange(true); + _player.Play(); + + // Small delay to ensure play command is processed before pausing + await Task.Delay(100); + + // Pause to prevent audio from playing while we buffer + Debug.WriteLine("[RadioPlayerService] Pausing for buffering..."); + _player.Pause(); + + // Set manual buffering state so UI shows buffering during user-configured delay + SetManualBuffering(true); + Debug.WriteLine("[RadioPlayerService] Manual buffering state set to true"); + + // Wait for the user-set buffer amount of time + Debug.WriteLine($"[RadioPlayerService] Waiting for buffer time: {RequiredBufferDuration.TotalMilliseconds}ms..."); + await Task.Delay(RequiredBufferDuration); + + // Check if buffer is complete using GetBufferedRanges + TimeSpan bufferedDuration = TotalBufferedDuration; + Debug.WriteLine($"[RadioPlayerService] After wait - Buffered: {bufferedDuration.TotalMilliseconds}ms, Required: {RequiredBufferDuration.TotalMilliseconds}ms"); + + // If buffer is not yet sufficient, wait a bit more + if (bufferedDuration < RequiredBufferDuration) + { + Debug.WriteLine("[RadioPlayerService] Buffer not yet complete, waiting more..."); + await WaitForSufficientBufferAsync(); + } + + // Now resume playback + Debug.WriteLine("[RadioPlayerService] Buffer complete. Calling _player.Play()..."); + _player.Play(); + + // Clear manual buffering state as playback is resuming + SetManualBuffering(false); + Debug.WriteLine("[RadioPlayerService] Manual buffering state cleared"); + Debug.WriteLine("[RadioPlayerService] Playback resumed after buffering"); + } + else + { + // No buffering needed - start playback immediately + Debug.WriteLine("[RadioPlayerService] No buffering required (default level). Starting playback..."); + SetInternalStateChange(true); + _player.Play(); + Debug.WriteLine("[RadioPlayerService] _player.Play() called successfully"); + } // Clear the external pause flag _wasExternalPause = false; @@ -498,25 +630,57 @@ public void Play() Debug.WriteLine("[RadioPlayerService] Notified watchdog of user intention to play"); // Start metadata polling - _metadataService.StartPolling(_streamUrl); + _metadataService.StartPolling(_streamUrl!); Debug.WriteLine("[RadioPlayerService] Started metadata polling"); + + // Log final buffer state + LogBufferedRanges(); } catch (Exception ex) { - Debug.WriteLine($"[RadioPlayerService] EXCEPTION in Play: {ex.Message}"); + Debug.WriteLine($"[RadioPlayerService] EXCEPTION in PlayWithBufferInternalAsync: {ex.Message}"); Debug.WriteLine($"[RadioPlayerService] Exception details: {ex}"); + + // Clear manual buffering state on error + SetManualBuffering(false); + Debug.WriteLine("[RadioPlayerService] Cleared manual buffering state due to exception"); + Debug.WriteLine("[RadioPlayerService] Re-creating media source and trying again..."); - // Re-create the media source and try again + // Re-create the media source and try again with same buffering approach try { - Uri uri = new(_streamUrl); + Uri uri = new(_streamUrl!); _player.Source = MediaSource.CreateFromUri(uri); Debug.WriteLine($"[RadioPlayerService] Created new MediaSource from URL: {_streamUrl}"); - SetInternalStateChange(true); - _player.Play(); - Debug.WriteLine("[RadioPlayerService] _player.Play() called successfully (retry)"); + // Apply same buffering logic on retry + bool needsBuffering = RequiredBufferDuration > TimeSpan.Zero; + + if (needsBuffering) + { + SetInternalStateChange(true); + _player.Play(); + _player.Pause(); + + // Set manual buffering state for retry + SetManualBuffering(true); + Debug.WriteLine("[RadioPlayerService] Started and paused for buffering (retry), manual buffering set"); + + await Task.Delay(RequiredBufferDuration); + + _player.Play(); + + // Clear manual buffering state after retry + SetManualBuffering(false); + Debug.WriteLine("[RadioPlayerService] Playback resumed after buffering (retry), manual buffering cleared"); + } + else + { + SetInternalStateChange(true); + _player.Play(); + Debug.WriteLine("[RadioPlayerService] _player.Play() called successfully (retry)"); + } _wasExternalPause = false; _hasPlayedOnce = true; @@ -526,24 +690,286 @@ public void Play() Debug.WriteLine("[RadioPlayerService] Notified watchdog of user intention to play"); // Start metadata polling - _metadataService.StartPolling(_streamUrl); + _metadataService.StartPolling(_streamUrl!); Debug.WriteLine("[RadioPlayerService] Started metadata polling (retry)"); } catch (Exception retryEx) { SetInternalStateChange(false); + + // Clear manual buffering state on retry failure + SetManualBuffering(false); + Debug.WriteLine("[RadioPlayerService] Cleared manual buffering state due to retry exception"); + Debug.WriteLine($"[RadioPlayerService] EXCEPTION on retry: {retryEx.Message}"); - throw; + // Log the error but don't throw - this is a fire-and-forget async method + // The user will see the playback failed in the UI } } + } - Debug.WriteLine($"=== Play END ==="); + /// + /// Waits for sufficient buffer based on current buffer level settings. + /// Uses MediaPlaybackSession.GetBufferedRanges to monitor buffering progress. + /// + /// Optional cancellation token + private async Task WaitForSufficientBufferAsync(CancellationToken cancellationToken = default) + { + // Calculate timeout based on required buffer (minimum 10s, max 30s) + int timeoutMs = Math.Clamp((int)RequiredBufferDuration.TotalMilliseconds * 3, 10000, 30000); + const int checkIntervalMs = 250; // Check every 250ms + int elapsed = 0; + + Debug.WriteLine($"[RadioPlayerService] Waiting for {RequiredBufferDuration.TotalMilliseconds}ms of buffer (timeout: {timeoutMs}ms)..."); + + while (elapsed < timeoutMs) + { + if (cancellationToken.IsCancellationRequested) + { + Debug.WriteLine("[RadioPlayerService] Buffer wait cancelled"); + return; + } + + // Check if stream is fully buffered (BufferingProgress >= 1.0) + if (BufferingProgress >= 1.0) + { + Debug.WriteLine($"[RadioPlayerService] Stream fully buffered (BufferingProgress: {BufferingProgress:P0})"); + return; + } + + // Check buffered ranges using GetBufferedRanges + TimeSpan bufferedDuration = TotalBufferedDuration; + + if (bufferedDuration >= RequiredBufferDuration) + { + Debug.WriteLine($"[RadioPlayerService] Sufficient buffer achieved: {bufferedDuration.TotalMilliseconds}ms >= {RequiredBufferDuration.TotalMilliseconds}ms"); + return; + } + + try + { + await Task.Delay(checkIntervalMs, cancellationToken); + } + catch (OperationCanceledException) + { + Debug.WriteLine("[RadioPlayerService] Buffer wait cancelled during delay"); + return; + } + elapsed += checkIntervalMs; + } + + Debug.WriteLine($"[RadioPlayerService] Buffer wait timeout after {timeoutMs}ms. Current buffer: {TotalBufferedDuration.TotalMilliseconds}ms"); } /// - /// Stop playback and clean up resources + /// Starts playback and waits for sufficient buffer based on current buffer level setting. + /// Uses MediaPlaybackSession.GetBufferedRanges to monitor buffering progress. + /// The stream is paused during buffering, then resumed once sufficient buffer is achieved. + /// This method is primarily used by the watchdog for recovery scenarios that need cancellation support. /// - public void Pause() + /// Cancellation token to cancel waiting + /// True if playback started with sufficient buffer, false if cancelled or timeout + public async Task PlayWithBufferAsync(CancellationToken cancellationToken = default) + { + Debug.WriteLine($"=== PlayWithBufferAsync START ==="); + Debug.WriteLine($"[RadioPlayerService] Required buffer duration: {RequiredBufferDuration.TotalMilliseconds}ms"); + + if (string.IsNullOrWhiteSpace(_streamUrl)) + { + Debug.WriteLine("[RadioPlayerService] ERROR: No stream URL set"); + throw new InvalidOperationException("No stream URL set. Call SetStreamUrl first."); + } + + // Check if we need to buffer before playing + bool needsBuffering = RequiredBufferDuration > TimeSpan.Zero; + + try + { + // Start playback (this also initiates buffer monitoring) + // We need to do the core playback logic here to support cancellation + bool needsRecreation = (!_hasPlayedOnce && _player.Source == null) || _wasExternalPause; + + if (needsRecreation) + { + if (_player.Source is MediaSource oldMedia) + { + oldMedia.Reset(); + oldMedia.Dispose(); + } + + Uri uri = new(_streamUrl!); + _player.Source = MediaSource.CreateFromUri(uri); + _hasPlayedOnce = true; + } + + if (needsBuffering) + { + // mute initially so there isn't a blip during the initiate step + double lastVol = _player.Volume; + _player.Volume = 0; + + // Start playback briefly to initiate buffering + Debug.WriteLine("[RadioPlayerService] Starting playback to initiate buffering..."); + SetInternalStateChange(true); + _player.Play(); + + // Small delay to ensure play command is processed before pausing + await Task.Delay(100, cancellationToken); + + // Pause to prevent audio from playing while we buffer + Debug.WriteLine("[RadioPlayerService] Pausing for buffering..."); + _player.Pause(); + _player.Volume = lastVol; + + // Set manual buffering state so UI shows buffering during user-configured delay + SetManualBuffering(true); + Debug.WriteLine("[RadioPlayerService] Manual buffering state set to true"); + + // Wait for the user-set buffer amount of time + Debug.WriteLine($"[RadioPlayerService] Waiting for buffer time: {RequiredBufferDuration.TotalMilliseconds}ms..."); + try + { + await Task.Delay(RequiredBufferDuration, cancellationToken); + } + catch (OperationCanceledException) + { + Debug.WriteLine("[RadioPlayerService] Buffer wait cancelled during initial delay"); + SetManualBuffering(false); + return false; + } + + // Check if buffer is complete using GetBufferedRanges + TimeSpan bufferedDuration = TotalBufferedDuration; + Debug.WriteLine($"[RadioPlayerService] After wait - Buffered: {bufferedDuration.TotalMilliseconds}ms, Required: {RequiredBufferDuration.TotalMilliseconds}ms"); + + // If buffer is not yet sufficient, wait more with timeout + if (bufferedDuration < RequiredBufferDuration) + { + Debug.WriteLine("[RadioPlayerService] Buffer not yet complete, waiting more..."); + int additionalTimeoutMs = Math.Clamp((int)RequiredBufferDuration.TotalMilliseconds * 2, 5000, 20000); + const int checkIntervalMs = 250; + int elapsed = 0; + + while (elapsed < additionalTimeoutMs) + { + if (cancellationToken.IsCancellationRequested) + { + Debug.WriteLine("[RadioPlayerService] Buffer wait cancelled"); + SetManualBuffering(false); + return false; + } + + if (BufferingProgress >= 1.0) + { + Debug.WriteLine($"[RadioPlayerService] Stream fully buffered (BufferingProgress: {BufferingProgress:P0})"); + break; + } + + bufferedDuration = TotalBufferedDuration; + if (bufferedDuration >= RequiredBufferDuration) + { + Debug.WriteLine($"[RadioPlayerService] Sufficient buffer achieved: {bufferedDuration.TotalMilliseconds}ms >= {RequiredBufferDuration.TotalMilliseconds}ms"); + break; + } + + await Task.Delay(checkIntervalMs, cancellationToken); + elapsed += checkIntervalMs; + } + } + + // Now resume playback + Debug.WriteLine("[RadioPlayerService] Buffer complete. Calling _player.Play()..."); + _player.Play(); + + // Clear manual buffering state as playback is resuming + SetManualBuffering(false); + Debug.WriteLine("[RadioPlayerService] Manual buffering state cleared"); + Debug.WriteLine("[RadioPlayerService] Playback resumed after buffering"); + } + else + { + // No buffering needed - start playback immediately + Debug.WriteLine("[RadioPlayerService] No buffering required (default level). Starting playback..."); + SetInternalStateChange(true); + _player.Play(); + } + + _wasExternalPause = false; + _watchdog.NotifyUserIntentionToPlay(); + _metadataService.StartPolling(_streamUrl!); + + LogBufferedRanges(); + return true; + } + catch (OperationCanceledException) + { + Debug.WriteLine("[RadioPlayerService] PlayWithBufferAsync cancelled"); + SetManualBuffering(false); + return false; + } + catch (Exception ex) + { + Debug.WriteLine($"[RadioPlayerService] EXCEPTION in PlayWithBufferAsync: {ex.Message}"); + SetManualBuffering(false); + throw; + } + finally + { + Debug.WriteLine($"=== PlayWithBufferAsync END ==="); + } + } + + /// + /// Logs the current buffered ranges for debugging purposes. + /// + private void LogBufferedRanges() + { + try + { + var bufferedRanges = _player.PlaybackSession.GetBufferedRanges(); + if (bufferedRanges.Count == 0) + { + Debug.WriteLine("[RadioPlayerService] No buffered ranges available"); + return; + } + + Debug.WriteLine($"[RadioPlayerService] Buffered ranges ({bufferedRanges.Count}):"); + TimeSpan totalBuffered = TimeSpan.Zero; + foreach (var range in bufferedRanges) + { + var duration = range.End - range.Start; + totalBuffered += duration; + Debug.WriteLine($" - {range.Start.TotalSeconds:F2}s to {range.End.TotalSeconds:F2}s (duration: {duration.TotalMilliseconds:F0}ms)"); + } + Debug.WriteLine($"[RadioPlayerService] Total buffered: {totalBuffered.TotalMilliseconds:F0}ms"); + } + catch (Exception ex) + { + Debug.WriteLine($"[RadioPlayerService] Error logging buffered ranges: {ex.Message}"); + } + } + + /// + /// Sets the manual buffering state and notifies listeners. + /// + private void SetManualBuffering(bool isBuffering) + { + if (_isManuallyBuffering == isBuffering) return; + + Debug.WriteLine($"[RadioPlayerService] Manual buffering state changing from {_isManuallyBuffering} to {isBuffering}"); + _isManuallyBuffering = isBuffering; + + // Notify on UI thread + TryEnqueueOnUi(() => + { + BufferingStateChanged?.Invoke(this, IsBuffering); + }); + } + + /// + /// Stop playback and clean up resources + /// + public void Pause() { Debug.WriteLine($"=== Pause START ==="); Debug.WriteLine($"[RadioPlayerService] Pause called"); @@ -564,6 +990,10 @@ public void Pause() _player.Pause(); Debug.WriteLine("[RadioPlayerService] _player.Pause() called successfully"); + // Clear manual buffering state when pausing + SetManualBuffering(false); + Debug.WriteLine("[RadioPlayerService] Cleared manual buffering state"); + // Mark that pause occurred - next play should recreate MediaSource to ensure live position _wasExternalPause = true; Debug.WriteLine("[RadioPlayerService] Marked for MediaSource recreation on next play (ensures live position)"); diff --git a/Trdo/Services/StreamWatchdogService.cs b/Trdo/Services/StreamWatchdogService.cs index fca538d..31c5614 100644 --- a/Trdo/Services/StreamWatchdogService.cs +++ b/Trdo/Services/StreamWatchdogService.cs @@ -1,13 +1,16 @@ using Microsoft.UI.Dispatching; using System; +using System.Collections.Generic; using System.Diagnostics; using System.Threading; using System.Threading.Tasks; +using Windows.Storage; namespace Trdo.Services; /// /// Monitors the radio stream and automatically resumes playback when the stream stops unexpectedly. +/// Also tracks stutter patterns and can automatically increase buffer when stuttering is detected. /// public sealed class StreamWatchdogService : IDisposable { @@ -24,6 +27,13 @@ public sealed class StreamWatchdogService : IDisposable private double _lastBufferingProgress; private int _consecutiveSilentChecks; + // Stutter detection tracking + private readonly Queue _recoveryAttempts = new(); + private bool _autoBufferIncreaseEnabled; + private double _currentBufferLevel; + private const string AutoBufferIncreaseKey = "AutoBufferIncreaseEnabled"; + private const string BufferLevelKey = "BufferLevel"; + // Configuration private readonly TimeSpan _checkInterval = TimeSpan.FromSeconds(5); private readonly TimeSpan _recoveryDelay = TimeSpan.FromSeconds(3); @@ -32,7 +42,16 @@ public sealed class StreamWatchdogService : IDisposable private readonly TimeSpan _silenceDetectionThreshold = TimeSpan.FromSeconds(10); private readonly int _maxConsecutiveSilentChecks = 2; // 2 checks * 5 seconds = 10 seconds + // Stutter detection configuration + private const int StutterThreshold = 3; // Number of recovery attempts to trigger stutter detection + private readonly TimeSpan _stutterWindow = TimeSpan.FromMinutes(2); // Time window to count recovery attempts + private const double MaxBufferLevel = 3.0; // Maximum buffer level (0=default, 1=medium, 2=large, 3=extra large) + private const bool DefaultAutoBufferIncreaseEnabled = true; // Auto-buffer increase is enabled by default for better user experience + private const double DefaultBufferLevel = 0.0; // Start with default (no extra delay) buffer level + public event EventHandler? StreamStatusChanged; + public event EventHandler? StutterDetected; + public event EventHandler? BufferLevelChanged; public bool IsEnabled { @@ -49,6 +68,79 @@ public bool IsEnabled } } + /// + /// Gets or sets whether auto-buffer increase is enabled. + /// When enabled, the buffer level automatically increases when stutter is detected. + /// + public bool AutoBufferIncreaseEnabled + { + get => _autoBufferIncreaseEnabled; + set + { + if (_autoBufferIncreaseEnabled == value) return; + _autoBufferIncreaseEnabled = value; + SaveAutoBufferSettings(); + Debug.WriteLine($"[Watchdog] Auto-buffer increase set to: {value}"); + } + } + + /// + /// Gets or sets the current buffer level (0-3). + /// 0 = Default, 1 = Medium, 2 = Large, 3 = Extra Large + /// + public double BufferLevel + { + get => _currentBufferLevel; + set + { + double clampedValue = Math.Clamp(value, 0, MaxBufferLevel); + if (Math.Abs(_currentBufferLevel - clampedValue) < 0.0001) return; + double oldValue = _currentBufferLevel; + _currentBufferLevel = clampedValue; + SaveAutoBufferSettings(); + Debug.WriteLine($"[Watchdog] Buffer level changed from {oldValue} to {_currentBufferLevel}"); + RaiseBufferLevelChanged(clampedValue); + } + } + + /// + /// Gets the buffer delay in milliseconds based on current buffer level. + /// + public int BufferDelayMs + { + get + { + // Linear interpolation between buffer levels + // Level 0 = 0ms, Level 1 = 2000ms, Level 2 = 4000ms, Level 3 = 8000ms + if (_currentBufferLevel <= 0) return 0; + if (_currentBufferLevel >= 3) return 8000; + if (_currentBufferLevel <= 1) return (int)(_currentBufferLevel * 2000); + if (_currentBufferLevel <= 2) return (int)(2000 + (_currentBufferLevel - 1) * 2000); + return (int)(4000 + (_currentBufferLevel - 2) * 4000); + } + } + + /// + /// Gets a human-readable description of the current buffer level. + /// + public string BufferLevelDescription + { + get + { + return _currentBufferLevel switch + { + 0 => "Default", + 1 => "Medium", + 2 => "Large", + 3 => "Extra Large", + _ when _currentBufferLevel < 0.5 => "Default", + _ when _currentBufferLevel < 1.5 => "Medium", + _ when _currentBufferLevel < 2.5 => "Large", + _ => "Extra Large" + }; + } + } + public StreamWatchdogService(RadioPlayerService playerService) { _playerService = playerService ?? throw new ArgumentNullException(nameof(playerService)); @@ -59,6 +151,66 @@ public StreamWatchdogService(RadioPlayerService playerService) _lastPositionChangeTime = DateTime.UtcNow; _lastBufferingProgress = 0; _consecutiveSilentChecks = 0; + + // Load auto-buffer settings + LoadAutoBufferSettings(); + } + + private void LoadAutoBufferSettings() + { + try + { + if (ApplicationData.Current.LocalSettings.Values.TryGetValue(AutoBufferIncreaseKey, out object? autoBufferValue)) + { + _autoBufferIncreaseEnabled = autoBufferValue switch + { + bool b => b, + string s when bool.TryParse(s, out bool b2) => b2, + _ => DefaultAutoBufferIncreaseEnabled + }; + } + else + { + _autoBufferIncreaseEnabled = DefaultAutoBufferIncreaseEnabled; + } + + if (ApplicationData.Current.LocalSettings.Values.TryGetValue(BufferLevelKey, out object? bufferLevelValue)) + { + _currentBufferLevel = bufferLevelValue switch + { + double d => Math.Clamp(d, 0, MaxBufferLevel), + int i => Math.Clamp((double)i, 0, MaxBufferLevel), + string s when double.TryParse(s, out double d2) => Math.Clamp(d2, 0, MaxBufferLevel), + _ => DefaultBufferLevel + }; + } + else + { + _currentBufferLevel = DefaultBufferLevel; + } + + Debug.WriteLine($"[Watchdog] Loaded settings - AutoBufferIncrease: {_autoBufferIncreaseEnabled}, BufferLevel: {_currentBufferLevel}"); + } + catch (Exception ex) + { + Debug.WriteLine($"[Watchdog] Error loading auto-buffer settings: {ex.Message}"); + _autoBufferIncreaseEnabled = DefaultAutoBufferIncreaseEnabled; + _currentBufferLevel = DefaultBufferLevel; + } + } + + private void SaveAutoBufferSettings() + { + try + { + ApplicationData.Current.LocalSettings.Values[AutoBufferIncreaseKey] = _autoBufferIncreaseEnabled; + ApplicationData.Current.LocalSettings.Values[BufferLevelKey] = _currentBufferLevel; + Debug.WriteLine($"[Watchdog] Saved settings - AutoBufferIncrease: {_autoBufferIncreaseEnabled}, BufferLevel: {_currentBufferLevel}"); + } + catch (Exception ex) + { + Debug.WriteLine($"[Watchdog] Error saving auto-buffer settings: {ex.Message}"); + } } /// @@ -273,13 +425,21 @@ private async Task AttemptRecoveryAsync(CancellationToken cancellationToken) { Debug.WriteLine("[Watchdog] Attempting to resume stream..."); + // Track this recovery attempt for stutter detection + TrackRecoveryAttempt(); + // Wait a bit before attempting recovery + Debug.WriteLine($"[Watchdog] Waiting {_recoveryDelay.TotalMilliseconds}ms before recovery"); await Task.Delay(_recoveryDelay, cancellationToken); if (cancellationToken.IsCancellationRequested) return; // Attempt to restart playback on UI thread + // Use PlayWithBufferAsync to ensure sufficient buffer is accumulated using GetBufferedRanges + bool playbackStarted = false; + Exception? playbackException = null; + await RunOnUiThreadAsync(() => { try @@ -289,20 +449,37 @@ await RunOnUiThreadAsync(() => { // Reinitialize the stream _playerService.SetStreamUrl(streamUrl); - - // Resume playback - _playerService.Play(); - - Debug.WriteLine("[Watchdog] Stream recovery initiated"); - RaiseStatusChanged("Stream resumed", StreamWatchdogStatus.Recovering); + Debug.WriteLine("[Watchdog] Stream URL set, starting buffered playback"); } } catch (Exception ex) { - Debug.WriteLine($"[Watchdog] Failed to resume stream: {ex.Message}"); - RaiseStatusChanged($"Recovery failed: {ex.Message}", StreamWatchdogStatus.Error); + playbackException = ex; + Debug.WriteLine($"[Watchdog] Failed to set stream URL: {ex.Message}"); } }); + + if (playbackException != null) + { + RaiseStatusChanged($"Recovery failed: {playbackException.Message}", StreamWatchdogStatus.Error); + return; + } + + // Use PlayWithBufferAsync to wait for sufficient buffer based on GetBufferedRanges + // This ensures smooth playback by checking buffered content before audio starts + Debug.WriteLine($"[Watchdog] Starting playback with buffer monitoring (required: {_playerService.RequiredBufferDuration.TotalMilliseconds}ms)"); + playbackStarted = await _playerService.PlayWithBufferAsync(cancellationToken); + + if (playbackStarted) + { + Debug.WriteLine($"[Watchdog] Stream recovery successful with buffer: {_playerService.TotalBufferedDuration.TotalMilliseconds}ms"); + RaiseStatusChanged("Stream resumed with buffer", StreamWatchdogStatus.Recovering); + } + else + { + Debug.WriteLine("[Watchdog] Playback start was cancelled"); + RaiseStatusChanged("Recovery cancelled", StreamWatchdogStatus.Error); + } } catch (OperationCanceledException) { @@ -315,6 +492,64 @@ await RunOnUiThreadAsync(() => } } + /// + /// Tracks a recovery attempt and checks for stutter pattern. + /// If stuttering is detected and auto-buffer increase is enabled, increases the buffer level. + /// + private void TrackRecoveryAttempt() + { + DateTime now = DateTime.UtcNow; + _recoveryAttempts.Enqueue(now); + + // Remove old attempts outside the stutter window + while (_recoveryAttempts.Count > 0 && + now - _recoveryAttempts.Peek() > _stutterWindow) + { + _recoveryAttempts.Dequeue(); + } + + Debug.WriteLine($"[Watchdog] Recovery attempts in last {_stutterWindow.TotalMinutes}min: {_recoveryAttempts.Count}"); + + // Check if we've hit the stutter threshold + if (_recoveryAttempts.Count >= StutterThreshold) + { + int recoveryCount = _recoveryAttempts.Count; + Debug.WriteLine($"[Watchdog] STUTTER DETECTED - {recoveryCount} recoveries in {_stutterWindow.TotalMinutes}min window"); + + double previousLevel = _currentBufferLevel; + + // Auto-increase buffer if enabled and not at max + if (_autoBufferIncreaseEnabled && _currentBufferLevel < MaxBufferLevel) + { + BufferLevel = _currentBufferLevel + 1; + Debug.WriteLine($"[Watchdog] Auto-increased buffer level from {previousLevel} to {_currentBufferLevel} ({BufferLevelDescription})"); + + // Clear recovery attempts after buffer increase to give the new level a chance + _recoveryAttempts.Clear(); + } + + // Raise the stutter detected event + RaiseStutterDetected(new StutterDetectedEventArgs + { + RecoveryAttemptCount = recoveryCount, + TimeWindow = _stutterWindow, + PreviousBufferLevel = previousLevel, + NewBufferLevel = _currentBufferLevel, + BufferWasIncreased = Math.Abs(previousLevel - _currentBufferLevel) > 0.0001 + }); + } + } + + /// + /// Resets the buffer level to default. Call this when the user manually changes stations or wants to reset. + /// + public void ResetBufferLevel() + { + _recoveryAttempts.Clear(); + BufferLevel = 0; + Debug.WriteLine("[Watchdog] Buffer level reset to default"); + } + private Task RunOnUiThreadAsync(Action action) { TaskCompletionSource tcs = new(); @@ -367,6 +602,40 @@ private void RaiseStatusChanged(string message, StreamWatchdogStatus status) } } + private void RaiseStutterDetected(StutterDetectedEventArgs args) + { + Debug.WriteLine($"[Watchdog] Raising StutterDetected event - BufferIncreased: {args.BufferWasIncreased}, NewLevel: {args.NewBufferLevel}"); + + if (_uiQueue is null || _uiQueue.HasThreadAccess) + { + StutterDetected?.Invoke(this, args); + } + else + { + _uiQueue.TryEnqueue(() => + { + StutterDetected?.Invoke(this, args); + }); + } + } + + private void RaiseBufferLevelChanged(double newLevel) + { + Debug.WriteLine($"[Watchdog] Raising BufferLevelChanged event - NewLevel: {newLevel}"); + + if (_uiQueue is null || _uiQueue.HasThreadAccess) + { + BufferLevelChanged?.Invoke(this, newLevel); + } + else + { + _uiQueue.TryEnqueue(() => + { + BufferLevelChanged?.Invoke(this, newLevel); + }); + } + } + public void Dispose() { Stop(); @@ -402,3 +671,39 @@ public enum StreamWatchdogStatus BackingOff, Error } + +/// +/// Event arguments for stutter detection events. +/// +public class StutterDetectedEventArgs : EventArgs +{ + /// + /// Number of recovery attempts within the time window that triggered stutter detection. + /// + public int RecoveryAttemptCount { get; init; } + + /// + /// The time window used for stutter detection. + /// + public TimeSpan TimeWindow { get; init; } + + /// + /// The buffer level before auto-increase was applied. + /// + public double PreviousBufferLevel { get; init; } + + /// + /// The buffer level after auto-increase was applied (may be same as previous if at max or disabled). + /// + public double NewBufferLevel { get; init; } + + /// + /// Whether the buffer level was actually increased. + /// + public bool BufferWasIncreased { get; init; } + + /// + /// Timestamp when the stutter was detected. + /// + public DateTime Timestamp { get; } = DateTime.UtcNow; +} diff --git a/Trdo/Trdo.csproj b/Trdo/Trdo.csproj index 8167a96..326d4c2 100644 --- a/Trdo/Trdo.csproj +++ b/Trdo/Trdo.csproj @@ -54,6 +54,7 @@ + diff --git a/Trdo/ViewModels/AboutViewModel.cs b/Trdo/ViewModels/AboutViewModel.cs index 663a472..2a7f15f 100644 --- a/Trdo/ViewModels/AboutViewModel.cs +++ b/Trdo/ViewModels/AboutViewModel.cs @@ -11,6 +11,23 @@ public partial class AboutViewModel : INotifyPropertyChanged { public event PropertyChangedEventHandler? PropertyChanged; + private int _selectedRating; + public int SelectedRating + { + get => _selectedRating; + set + { + if (_selectedRating != value) + { + _selectedRating = value; + OnPropertyChanged(); + OnPropertyChanged(nameof(ShowContactDeveloper)); + } + } + } + + public bool ShowContactDeveloper => SelectedRating > 0 && SelectedRating <= 3; + public string AppName => "Trdo"; public string AppDescription => "A simple, elegant internet radio player for Windows"; public string Version @@ -54,6 +71,13 @@ public async Task OpenRatingWindow() _ = await Launcher.LaunchUriAsync(new Uri("ms-windows-store://review/?ProductId=9NXT4TGJVHVV")); } + public async Task ContactDeveloper() + { + string subject = Uri.EscapeDataString($"Trdo Feedback - {Version}"); + string mailtoUri = $"mailto:joe@joefinapps.com?subject={subject}"; + await Launcher.LaunchUriAsync(new Uri(mailtoUri)); + } + public async Task OpenRadioBrowser() { await Launcher.LaunchUriAsync(new Uri(RadioBrowserUrl)); diff --git a/Trdo/ViewModels/PlayerViewModel.cs b/Trdo/ViewModels/PlayerViewModel.cs index edd193b..466242a 100644 --- a/Trdo/ViewModels/PlayerViewModel.cs +++ b/Trdo/ViewModels/PlayerViewModel.cs @@ -54,6 +54,14 @@ public PlayerViewModel() Debug.WriteLine($"[PlayerViewModel] Watchdog status: {WatchdogStatus}"); }; + // Subscribe to buffer level changes (for auto-buffer increase) + _player.Watchdog.BufferLevelChanged += (_, newLevel) => + { + Debug.WriteLine($"[PlayerViewModel] BufferLevelChanged event fired. NewLevel={newLevel}"); + OnPropertyChanged(nameof(BufferLevel)); + OnPropertyChanged(nameof(BufferLevelDescription)); + }; + // Subscribe to stream metadata changes _player.StreamMetadataChanged += (_, metadata) => { @@ -256,6 +264,44 @@ public bool WatchdogEnabled } } + /// + /// Gets or sets whether auto-buffer increase is enabled. + /// When enabled, the buffer level automatically increases when stutter is detected. + /// + public bool AutoBufferIncreaseEnabled + { + get => _player.Watchdog.AutoBufferIncreaseEnabled; + set + { + if (value == _player.Watchdog.AutoBufferIncreaseEnabled) return; + Debug.WriteLine($"[PlayerViewModel] Setting AutoBufferIncreaseEnabled to {value}"); + _player.Watchdog.AutoBufferIncreaseEnabled = value; + OnPropertyChanged(); + } + } + + /// + /// Gets or sets the current buffer level (0-3). + /// 0 = Default, 1 = Medium, 2 = Large, 3 = Extra Large + /// + public double BufferLevel + { + get => _player.Watchdog.BufferLevel; + set + { + if (Math.Abs(value - _player.Watchdog.BufferLevel) < 0.0001) return; + Debug.WriteLine($"[PlayerViewModel] Setting BufferLevel to {value}"); + _player.Watchdog.BufferLevel = value; + OnPropertyChanged(); + OnPropertyChanged(nameof(BufferLevelDescription)); + } + } + + /// + /// Gets a human-readable description of the current buffer level. + /// + public string BufferLevelDescription => _player.Watchdog.BufferLevelDescription; + public string WatchdogStatus { get => _watchdogStatus; diff --git a/Trdo/ViewModels/SettingsViewModel.cs b/Trdo/ViewModels/SettingsViewModel.cs index 9eb9f0f..a424963 100644 --- a/Trdo/ViewModels/SettingsViewModel.cs +++ b/Trdo/ViewModels/SettingsViewModel.cs @@ -13,6 +13,7 @@ public class SettingsViewModel : INotifyPropertyChanged private bool _isStartupToggleEnabled = true; private string _startupToggleText = "Off"; private string _watchdogToggleText = "Off"; + private string _autoBufferToggleText = "Off"; private StartupTask? _startupTask; private bool _initDone; @@ -28,17 +29,30 @@ public SettingsViewModel() if (args.PropertyName == nameof(PlayerViewModel.WatchdogEnabled)) { OnPropertyChanged(nameof(IsWatchdogEnabled)); - WatchdogToggleText = _playerViewModel.WatchdogEnabled ? "On" : "Off"; + WatchdogToggleText = GetToggleText(_playerViewModel.WatchdogEnabled); + } + else if (args.PropertyName == nameof(PlayerViewModel.AutoBufferIncreaseEnabled)) + { + OnPropertyChanged(nameof(IsAutoBufferIncreaseEnabled)); + AutoBufferToggleText = GetToggleText(_playerViewModel.AutoBufferIncreaseEnabled); + } + else if (args.PropertyName == nameof(PlayerViewModel.BufferLevel)) + { + OnPropertyChanged(nameof(BufferLevel)); + OnPropertyChanged(nameof(BufferLevelDescription)); } }; - // Initialize watchdog toggle text - WatchdogToggleText = _playerViewModel.WatchdogEnabled ? "On" : "Off"; + // Initialize toggle text + WatchdogToggleText = GetToggleText(_playerViewModel.WatchdogEnabled); + AutoBufferToggleText = GetToggleText(_playerViewModel.AutoBufferIncreaseEnabled); // Initialize startup task _ = InitializeStartupTaskAsync(); } + private static string GetToggleText(bool enabled) => enabled ? "On" : "Off"; + public bool IsStartupEnabled { get => _isStartupEnabled; @@ -47,7 +61,7 @@ public bool IsStartupEnabled if (value == _isStartupEnabled) return; _isStartupEnabled = value; OnPropertyChanged(); - StartupToggleText = value ? "On" : "Off"; + StartupToggleText = GetToggleText(value); // Apply the change _ = ApplyStartupStateAsync(value); @@ -84,7 +98,7 @@ public bool IsWatchdogEnabled if (value == _playerViewModel.WatchdogEnabled) return; _playerViewModel.WatchdogEnabled = value; OnPropertyChanged(); - WatchdogToggleText = value ? "On" : "Off"; + WatchdogToggleText = GetToggleText(value); } } @@ -99,6 +113,54 @@ public string WatchdogToggleText } } + /// + /// Gets or sets whether auto-buffer increase is enabled. + /// When enabled, the buffer level automatically increases when stutter is detected. + /// + public bool IsAutoBufferIncreaseEnabled + { + get => _playerViewModel.AutoBufferIncreaseEnabled; + set + { + if (value == _playerViewModel.AutoBufferIncreaseEnabled) return; + _playerViewModel.AutoBufferIncreaseEnabled = value; + OnPropertyChanged(); + AutoBufferToggleText = GetToggleText(value); + } + } + + public string AutoBufferToggleText + { + get => _autoBufferToggleText; + set + { + if (value == _autoBufferToggleText) return; + _autoBufferToggleText = value; + OnPropertyChanged(); + } + } + + /// + /// Gets or sets the current buffer level (0-3). + /// 0 = Default, 1 = Medium, 2 = Large, 3 = Extra Large + /// + public double BufferLevel + { + get => _playerViewModel.BufferLevel; + set + { + if (Math.Abs(value - _playerViewModel.BufferLevel) < 0.0001) return; + _playerViewModel.BufferLevel = value; + OnPropertyChanged(); + OnPropertyChanged(nameof(BufferLevelDescription)); + } + } + + /// + /// Gets a human-readable description of the current buffer level. + /// + public string BufferLevelDescription => _playerViewModel.BufferLevelDescription; + private async Task InitializeStartupTaskAsync() { try @@ -125,13 +187,13 @@ private void UpdateStartupStateFromTask() IsStartupToggleEnabled = true; _isStartupEnabled = true; OnPropertyChanged(nameof(IsStartupEnabled)); - StartupToggleText = "On"; + StartupToggleText = GetToggleText(true); break; case StartupTaskState.Disabled: IsStartupToggleEnabled = true; _isStartupEnabled = false; OnPropertyChanged(nameof(IsStartupEnabled)); - StartupToggleText = "Off"; + StartupToggleText = GetToggleText(false); break; case StartupTaskState.DisabledByUser: case StartupTaskState.DisabledByPolicy: @@ -139,7 +201,7 @@ private void UpdateStartupStateFromTask() IsStartupToggleEnabled = false; _isStartupEnabled = false; OnPropertyChanged(nameof(IsStartupEnabled)); - StartupToggleText = "Off"; + StartupToggleText = GetToggleText(false); break; } }