From 6d780936fdb1fcc7d079346cf91351cc04d337e5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 19 Jan 2026 15:26:48 +0000 Subject: [PATCH 01/14] Initial plan From 1c5647b1e729fa1a20ccd232a9f30a57d01a9e4f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 19 Jan 2026 15:42:23 +0000 Subject: [PATCH 02/14] Implement stutter detection watchdog with auto-buffer increase feature - Add recovery attempt tracking with timestamp queue in StreamWatchdogService - Detect stutter when 3+ recovery attempts occur within 2 minutes - Auto-increase buffer level (0-3) when stutter is detected - Add BufferLevelChanged event for UI updates - Add settings persistence for auto-buffer configuration - Update PlayerViewModel to expose buffer settings - Update SettingsViewModel with auto-buffer properties - Add Settings UI for auto-buffer toggle and buffer level slider - Use consistent GetToggleText helper for toggle state display Co-authored-by: TheJoeFin <7809853+TheJoeFin@users.noreply.github.com> --- Trdo/Pages/SettingsPage.xaml | 49 +++++ Trdo/Services/StreamWatchdogService.cs | 274 ++++++++++++++++++++++++- Trdo/ViewModels/PlayerViewModel.cs | 46 +++++ Trdo/ViewModels/SettingsViewModel.cs | 78 ++++++- 4 files changed, 437 insertions(+), 10 deletions(-) diff --git a/Trdo/Pages/SettingsPage.xaml b/Trdo/Pages/SettingsPage.xaml index 642b893..f685fa5 100644 --- a/Trdo/Pages/SettingsPage.xaml +++ b/Trdo/Pages/SettingsPage.xaml @@ -56,6 +56,55 @@ Text="{x:Bind ViewModel.WatchdogToggleText, Mode=OneWay}" /> + + + + + + + + + + + + + + + + + + + + diff --git a/Trdo/Services/StreamWatchdogService.cs b/Trdo/Services/StreamWatchdogService.cs index fca538d..b6f1700 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 int _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 int MaxBufferLevel = 3; // 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 int DefaultBufferLevel = 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,65 @@ 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 int BufferLevel + { + get => _currentBufferLevel; + set + { + int clampedValue = Math.Clamp(value, 0, MaxBufferLevel); + if (_currentBufferLevel == clampedValue) return; + int 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 => _currentBufferLevel switch + { + 0 => 0, // Default - no additional delay + 1 => 2000, // Medium - 2 second buffer + 2 => 4000, // Large - 4 second buffer + 3 => 8000, // Extra Large - 8 second buffer + _ => 0 + }; + + /// + /// Gets a human-readable description of the current buffer level. + /// + public string BufferLevelDescription => _currentBufferLevel switch + { + 0 => "Default", + 1 => "Medium", + 2 => "Large", + 3 => "Extra Large", + _ => "Default" + }; + public StreamWatchdogService(RadioPlayerService playerService) { _playerService = playerService ?? throw new ArgumentNullException(nameof(playerService)); @@ -59,6 +137,65 @@ 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 + { + int i => Math.Clamp(i, 0, MaxBufferLevel), + string s when int.TryParse(s, out int i2) => Math.Clamp(i2, 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,8 +410,13 @@ private async Task AttemptRecoveryAsync(CancellationToken cancellationToken) { Debug.WriteLine("[Watchdog] Attempting to resume stream..."); - // Wait a bit before attempting recovery - await Task.Delay(_recoveryDelay, cancellationToken); + // Track this recovery attempt for stutter detection + TrackRecoveryAttempt(); + + // Wait a bit before attempting recovery, adding buffer delay + int totalDelay = (int)_recoveryDelay.TotalMilliseconds + BufferDelayMs; + Debug.WriteLine($"[Watchdog] Waiting {totalDelay}ms before recovery (base: {_recoveryDelay.TotalMilliseconds}ms, buffer: {BufferDelayMs}ms)"); + await Task.Delay(totalDelay, cancellationToken); if (cancellationToken.IsCancellationRequested) return; @@ -315,6 +457,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"); + + int 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 = previousLevel != _currentBufferLevel + }); + } + } + + /// + /// 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 +567,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(int 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 +636,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 int PreviousBufferLevel { get; init; } + + /// + /// The buffer level after auto-increase was applied (may be same as previous if at max or disabled). + /// + public int 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/ViewModels/PlayerViewModel.cs b/Trdo/ViewModels/PlayerViewModel.cs index edd193b..4015b92 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 int BufferLevel + { + get => _player.Watchdog.BufferLevel; + set + { + if (value == _player.Watchdog.BufferLevel) 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..b35979d 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 int BufferLevel + { + get => _playerViewModel.BufferLevel; + set + { + if (value == _playerViewModel.BufferLevel) 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; } } From 51b83f085e90457fd75642180c51e2e899ae33f3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 19 Jan 2026 16:21:59 +0000 Subject: [PATCH 03/14] Use MediaPlaybackSession.GetBufferedRanges for buffer monitoring - Add TotalBufferedDuration property using GetBufferedRanges() - Add GetBufferedRanges() method to expose buffered time ranges - Add RequiredBufferDuration and HasSufficientBuffer properties - Add PlayWithBufferAsync() method that waits for sufficient buffer - Add LogBufferedRanges() helper for debugging buffer state - Update AttemptRecoveryAsync to use buffer-aware playback - Calculate dynamic timeout based on required buffer duration Co-authored-by: TheJoeFin <7809853+TheJoeFin@users.noreply.github.com> --- Trdo/Services/RadioPlayerService.cs | 192 +++++++++++++++++++++++++ Trdo/Services/StreamWatchdogService.cs | 44 ++++-- 2 files changed, 224 insertions(+), 12 deletions(-) diff --git a/Trdo/Services/RadioPlayerService.cs b/Trdo/Services/RadioPlayerService.cs index afcb01c..a9f361c 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; @@ -118,6 +120,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; /// @@ -440,6 +505,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)) { @@ -485,11 +551,16 @@ public void Play() Debug.WriteLine("[RadioPlayerService] Reusing existing MediaSource - resuming playback"); } + // Start playback - the buffering will happen automatically + // Use PlayWithBufferAsync for buffer-aware playback Debug.WriteLine("[RadioPlayerService] Calling _player.Play()..."); SetInternalStateChange(true); _player.Play(); Debug.WriteLine("[RadioPlayerService] _player.Play() called successfully"); + // Log buffered ranges for debugging + LogBufferedRanges(); + // Clear the external pause flag _wasExternalPause = false; Debug.WriteLine("[RadioPlayerService] Cleared external pause flag"); @@ -540,6 +611,127 @@ public void Play() Debug.WriteLine($"=== Play END ==="); } + /// + /// Starts playback and waits for sufficient buffer based on current buffer level setting. + /// Uses MediaPlaybackSession.GetBufferedRanges to monitor buffering progress. + /// + /// 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."); + } + + try + { + // First, start playback to begin buffering + Play(); + + // If no additional buffer is required, we're done + if (RequiredBufferDuration == TimeSpan.Zero) + { + Debug.WriteLine("[RadioPlayerService] No additional buffer required (default level)"); + return true; + } + + // Calculate timeout based on required buffer (minimum 10s, max 30s) + // Give extra time beyond the required buffer duration + 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 false; + } + + // Check if stream is fully buffered (BufferingProgress >= 1.0) + // This indicates the platform has finished buffering what it needs + if (BufferingProgress >= 1.0) + { + Debug.WriteLine($"[RadioPlayerService] Stream fully buffered (BufferingProgress: {BufferingProgress:P0})"); + return true; + } + + // Check buffered ranges using GetBufferedRanges + TimeSpan bufferedDuration = TotalBufferedDuration; + + Debug.WriteLine($"[RadioPlayerService] Current buffered: {bufferedDuration.TotalMilliseconds}ms, Required: {RequiredBufferDuration.TotalMilliseconds}ms, Progress: {BufferingProgress:P0}"); + + if (bufferedDuration >= RequiredBufferDuration) + { + Debug.WriteLine($"[RadioPlayerService] Sufficient buffer achieved: {bufferedDuration.TotalMilliseconds}ms >= {RequiredBufferDuration.TotalMilliseconds}ms"); + return true; + } + + await Task.Delay(checkIntervalMs, cancellationToken); + elapsed += checkIntervalMs; + } + + // Timeout reached - log final buffer state + LogBufferedRanges(); + Debug.WriteLine($"[RadioPlayerService] Buffer wait timeout after {timeoutMs}ms. Current buffer: {TotalBufferedDuration.TotalMilliseconds}ms, Progress: {BufferingProgress:P0}"); + + // Return false to indicate timeout - playback started but didn't reach desired buffer level + return false; + } + catch (OperationCanceledException) + { + Debug.WriteLine("[RadioPlayerService] PlayWithBufferAsync cancelled"); + return false; + } + catch (Exception ex) + { + Debug.WriteLine($"[RadioPlayerService] EXCEPTION in PlayWithBufferAsync: {ex.Message}"); + 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}"); + } + } + /// /// Stop playback and clean up resources /// diff --git a/Trdo/Services/StreamWatchdogService.cs b/Trdo/Services/StreamWatchdogService.cs index b6f1700..c0df1a8 100644 --- a/Trdo/Services/StreamWatchdogService.cs +++ b/Trdo/Services/StreamWatchdogService.cs @@ -413,15 +413,18 @@ private async Task AttemptRecoveryAsync(CancellationToken cancellationToken) // Track this recovery attempt for stutter detection TrackRecoveryAttempt(); - // Wait a bit before attempting recovery, adding buffer delay - int totalDelay = (int)_recoveryDelay.TotalMilliseconds + BufferDelayMs; - Debug.WriteLine($"[Watchdog] Waiting {totalDelay}ms before recovery (base: {_recoveryDelay.TotalMilliseconds}ms, buffer: {BufferDelayMs}ms)"); - await Task.Delay(totalDelay, cancellationToken); + // 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 @@ -431,20 +434,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) { From 4b668ce3fa0b6c611e3996c063088390d7ed9e74 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 19 Jan 2026 17:02:06 +0000 Subject: [PATCH 04/14] Apply buffer settings on every Play() call, not just during stutter recovery - Modified Play() to always use buffer-aware playback via PlayWithBufferInternalAsync() - Added PlayWithBufferInternalAsync() private method for async buffered playback - Added cancellation token support to WaitForSufficientBufferAsync() - Buffer settings using GetBufferedRanges are now applied every time playback starts - Fixed null handling consistency across methods Co-authored-by: TheJoeFin <7809853+TheJoeFin@users.noreply.github.com> --- Trdo/Services/RadioPlayerService.cs | 143 ++++++++++++++++++++++------ 1 file changed, 116 insertions(+), 27 deletions(-) diff --git a/Trdo/Services/RadioPlayerService.cs b/Trdo/Services/RadioPlayerService.cs index a9f361c..7a89121 100644 --- a/Trdo/Services/RadioPlayerService.cs +++ b/Trdo/Services/RadioPlayerService.cs @@ -494,7 +494,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() { @@ -513,6 +514,19 @@ 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. + /// + private async Task PlayWithBufferInternalAsync() + { try { // Only recreate MediaSource if: @@ -540,7 +554,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}"); @@ -551,16 +565,12 @@ public void Play() Debug.WriteLine("[RadioPlayerService] Reusing existing MediaSource - resuming playback"); } - // Start playback - the buffering will happen automatically - // Use PlayWithBufferAsync for buffer-aware playback + // Start playback to begin buffering Debug.WriteLine("[RadioPlayerService] Calling _player.Play()..."); SetInternalStateChange(true); _player.Play(); Debug.WriteLine("[RadioPlayerService] _player.Play() called successfully"); - // Log buffered ranges for debugging - LogBufferedRanges(); - // Clear the external pause flag _wasExternalPause = false; Debug.WriteLine("[RadioPlayerService] Cleared external pause flag"); @@ -569,19 +579,34 @@ 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"); + + // Now wait for sufficient buffer if buffer level > 0 + // This uses GetBufferedRanges to monitor buffering progress + if (RequiredBufferDuration > TimeSpan.Zero) + { + Debug.WriteLine($"[RadioPlayerService] Waiting for buffer: {RequiredBufferDuration.TotalMilliseconds}ms..."); + await WaitForSufficientBufferAsync(); + } + else + { + Debug.WriteLine("[RadioPlayerService] No additional buffer required (default level)"); + } + + // 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}"); Debug.WriteLine("[RadioPlayerService] Re-creating media source and trying again..."); // Re-create the media source and try again try { - Uri uri = new(_streamUrl); + Uri uri = new(_streamUrl!); _player.Source = MediaSource.CreateFromUri(uri); Debug.WriteLine($"[RadioPlayerService] Created new MediaSource from URL: {_streamUrl}"); @@ -597,23 +622,76 @@ 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); 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"); } /// /// Starts playback and waits for sufficient buffer based on current buffer level setting. /// Uses MediaPlaybackSession.GetBufferedRanges to monitor buffering progress. + /// This method is primarily used by the watchdog for recovery scenarios that need cancellation support. /// /// Cancellation token to cancel waiting /// True if playback started with sufficient buffer, false if cancelled or timeout @@ -630,8 +708,28 @@ public async Task PlayWithBufferAsync(CancellationToken cancellationToken try { - // First, start playback to begin buffering - Play(); + // 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; + } + + SetInternalStateChange(true); + _player.Play(); + _wasExternalPause = false; + _watchdog.NotifyUserIntentionToPlay(); + _metadataService.StartPolling(_streamUrl!); // If no additional buffer is required, we're done if (RequiredBufferDuration == TimeSpan.Zero) @@ -640,10 +738,9 @@ public async Task PlayWithBufferAsync(CancellationToken cancellationToken return true; } - // Calculate timeout based on required buffer (minimum 10s, max 30s) - // Give extra time beyond the required buffer duration + // Wait for sufficient buffer with cancellation support int timeoutMs = Math.Clamp((int)RequiredBufferDuration.TotalMilliseconds * 3, 10000, 30000); - const int checkIntervalMs = 250; // Check every 250ms + const int checkIntervalMs = 250; int elapsed = 0; Debug.WriteLine($"[RadioPlayerService] Waiting for {RequiredBufferDuration.TotalMilliseconds}ms of buffer (timeout: {timeoutMs}ms)..."); @@ -656,19 +753,14 @@ public async Task PlayWithBufferAsync(CancellationToken cancellationToken return false; } - // Check if stream is fully buffered (BufferingProgress >= 1.0) - // This indicates the platform has finished buffering what it needs if (BufferingProgress >= 1.0) { Debug.WriteLine($"[RadioPlayerService] Stream fully buffered (BufferingProgress: {BufferingProgress:P0})"); return true; } - // Check buffered ranges using GetBufferedRanges TimeSpan bufferedDuration = TotalBufferedDuration; - Debug.WriteLine($"[RadioPlayerService] Current buffered: {bufferedDuration.TotalMilliseconds}ms, Required: {RequiredBufferDuration.TotalMilliseconds}ms, Progress: {BufferingProgress:P0}"); - if (bufferedDuration >= RequiredBufferDuration) { Debug.WriteLine($"[RadioPlayerService] Sufficient buffer achieved: {bufferedDuration.TotalMilliseconds}ms >= {RequiredBufferDuration.TotalMilliseconds}ms"); @@ -679,11 +771,8 @@ public async Task PlayWithBufferAsync(CancellationToken cancellationToken elapsed += checkIntervalMs; } - // Timeout reached - log final buffer state LogBufferedRanges(); - Debug.WriteLine($"[RadioPlayerService] Buffer wait timeout after {timeoutMs}ms. Current buffer: {TotalBufferedDuration.TotalMilliseconds}ms, Progress: {BufferingProgress:P0}"); - - // Return false to indicate timeout - playback started but didn't reach desired buffer level + Debug.WriteLine($"[RadioPlayerService] Buffer wait timeout after {timeoutMs}ms. Current buffer: {TotalBufferedDuration.TotalMilliseconds}ms"); return false; } catch (OperationCanceledException) From 5dbc77e7a3f9950f9aea52d55e385101d0dca331 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 19 Jan 2026 17:34:07 +0000 Subject: [PATCH 05/14] Mute audio during buffering to prevent playback until buffer is full - Modified PlayWithBufferInternalAsync() to mute player (volume=0) while buffering - Audio remains silent during buffer accumulation using GetBufferedRanges - Once sufficient buffer is achieved, volume is restored and audio becomes audible - Modified PlayWithBufferAsync() with same muting behavior for watchdog recovery - Volume is always restored in finally block for all exit paths Co-authored-by: TheJoeFin <7809853+TheJoeFin@users.noreply.github.com> --- Trdo/Services/RadioPlayerService.cs | 50 +++++++++++++++++++++++++---- 1 file changed, 44 insertions(+), 6 deletions(-) diff --git a/Trdo/Services/RadioPlayerService.cs b/Trdo/Services/RadioPlayerService.cs index 7a89121..67ce67c 100644 --- a/Trdo/Services/RadioPlayerService.cs +++ b/Trdo/Services/RadioPlayerService.cs @@ -524,6 +524,7 @@ public void Play() /// /// Internal method that handles buffered playback asynchronously. /// This is called by Play() to apply buffer settings every time playback starts. + /// The stream is muted during buffering, then unmuted once sufficient buffer is achieved. /// private async Task PlayWithBufferInternalAsync() { @@ -565,11 +566,22 @@ private async Task PlayWithBufferInternalAsync() Debug.WriteLine("[RadioPlayerService] Reusing existing MediaSource - resuming playback"); } + // Check if we need to buffer before playing audibly + bool needsBuffering = RequiredBufferDuration > TimeSpan.Zero; + double savedVolume = _volume; + + if (needsBuffering) + { + // Mute the player while buffering so audio doesn't play yet + Debug.WriteLine($"[RadioPlayerService] Muting player for buffering (saved volume: {savedVolume})"); + _player.Volume = 0; + } + // Start playback to begin buffering - Debug.WriteLine("[RadioPlayerService] Calling _player.Play()..."); + Debug.WriteLine("[RadioPlayerService] Calling _player.Play() to start buffering..."); SetInternalStateChange(true); _player.Play(); - Debug.WriteLine("[RadioPlayerService] _player.Play() called successfully"); + Debug.WriteLine("[RadioPlayerService] _player.Play() called - stream is buffering"); // Clear the external pause flag _wasExternalPause = false; @@ -582,12 +594,16 @@ private async Task PlayWithBufferInternalAsync() _metadataService.StartPolling(_streamUrl!); Debug.WriteLine("[RadioPlayerService] Started metadata polling"); - // Now wait for sufficient buffer if buffer level > 0 - // This uses GetBufferedRanges to monitor buffering progress - if (RequiredBufferDuration > TimeSpan.Zero) + // Wait for sufficient buffer if buffer level > 0 + // During this time, the stream is buffering but audio is muted + if (needsBuffering) { Debug.WriteLine($"[RadioPlayerService] Waiting for buffer: {RequiredBufferDuration.TotalMilliseconds}ms..."); await WaitForSufficientBufferAsync(); + + // Restore volume - audio will now be audible + Debug.WriteLine($"[RadioPlayerService] Buffer complete. Restoring volume to {savedVolume}"); + _player.Volume = savedVolume; } else { @@ -603,6 +619,9 @@ private async Task PlayWithBufferInternalAsync() Debug.WriteLine($"[RadioPlayerService] Exception details: {ex}"); Debug.WriteLine("[RadioPlayerService] Re-creating media source and trying again..."); + // Restore volume in case we were muted + _player.Volume = _volume; + // Re-create the media source and try again try { @@ -691,6 +710,7 @@ private async Task WaitForSufficientBufferAsync(CancellationToken cancellationTo /// /// Starts playback and waits for sufficient buffer based on current buffer level setting. /// Uses MediaPlaybackSession.GetBufferedRanges to monitor buffering progress. + /// The stream is muted during buffering, then unmuted once sufficient buffer is achieved. /// This method is primarily used by the watchdog for recovery scenarios that need cancellation support. /// /// Cancellation token to cancel waiting @@ -706,6 +726,10 @@ public async Task PlayWithBufferAsync(CancellationToken cancellationToken throw new InvalidOperationException("No stream URL set. Call SetStreamUrl first."); } + // Check if we need to buffer before playing audibly + bool needsBuffering = RequiredBufferDuration > TimeSpan.Zero; + double savedVolume = _volume; + try { // Start playback (this also initiates buffer monitoring) @@ -725,6 +749,13 @@ public async Task PlayWithBufferAsync(CancellationToken cancellationToken _hasPlayedOnce = true; } + if (needsBuffering) + { + // Mute the player while buffering so audio doesn't play yet + Debug.WriteLine($"[RadioPlayerService] Muting player for buffering (saved volume: {savedVolume})"); + _player.Volume = 0; + } + SetInternalStateChange(true); _player.Play(); _wasExternalPause = false; @@ -732,7 +763,7 @@ public async Task PlayWithBufferAsync(CancellationToken cancellationToken _metadataService.StartPolling(_streamUrl!); // If no additional buffer is required, we're done - if (RequiredBufferDuration == TimeSpan.Zero) + if (!needsBuffering) { Debug.WriteLine("[RadioPlayerService] No additional buffer required (default level)"); return true; @@ -750,6 +781,7 @@ public async Task PlayWithBufferAsync(CancellationToken cancellationToken if (cancellationToken.IsCancellationRequested) { Debug.WriteLine("[RadioPlayerService] Buffer wait cancelled"); + _player.Volume = savedVolume; // Restore volume even if cancelled return false; } @@ -787,6 +819,12 @@ public async Task PlayWithBufferAsync(CancellationToken cancellationToken } finally { + // Always restore volume when exiting, in case we were muted for buffering + if (needsBuffering) + { + Debug.WriteLine($"[RadioPlayerService] Ensuring volume is restored to {savedVolume}"); + _player.Volume = savedVolume; + } Debug.WriteLine($"=== PlayWithBufferAsync END ==="); } } From 23b8fdfa801f2b3c097946e88f42fef4a140e0ff Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 19 Jan 2026 18:51:36 +0000 Subject: [PATCH 06/14] Use Pause/Play approach for buffering instead of mute/unmute - Call _player.Play() to initiate buffering - Add 100ms delay to ensure play command is processed - Call _player.Pause() to stop audio while buffering - Wait for user-set buffer time using Task.Delay(RequiredBufferDuration) - Check buffer is complete using GetBufferedRanges - Call _player.Play() to resume playback - Applied same approach to PlayWithBufferAsync and retry handlers Co-authored-by: TheJoeFin <7809853+TheJoeFin@users.noreply.github.com> --- Trdo/Services/RadioPlayerService.cs | 225 +++++++++++++++++----------- 1 file changed, 136 insertions(+), 89 deletions(-) diff --git a/Trdo/Services/RadioPlayerService.cs b/Trdo/Services/RadioPlayerService.cs index 67ce67c..e9b14fa 100644 --- a/Trdo/Services/RadioPlayerService.cs +++ b/Trdo/Services/RadioPlayerService.cs @@ -524,7 +524,7 @@ public void Play() /// /// Internal method that handles buffered playback asynchronously. /// This is called by Play() to apply buffer settings every time playback starts. - /// The stream is muted during buffering, then unmuted once sufficient buffer is achieved. + /// The stream is paused during buffering, then resumed once sufficient buffer is achieved. /// private async Task PlayWithBufferInternalAsync() { @@ -566,22 +566,51 @@ private async Task PlayWithBufferInternalAsync() Debug.WriteLine("[RadioPlayerService] Reusing existing MediaSource - resuming playback"); } - // Check if we need to buffer before playing audibly + // Check if we need to buffer before playing bool needsBuffering = RequiredBufferDuration > TimeSpan.Zero; - double savedVolume = _volume; if (needsBuffering) { - // Mute the player while buffering so audio doesn't play yet - Debug.WriteLine($"[RadioPlayerService] Muting player for buffering (saved volume: {savedVolume})"); - _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); + + // Pause to prevent audio from playing while we buffer + Debug.WriteLine("[RadioPlayerService] Pausing for buffering..."); + _player.Pause(); + + // 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(); + 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"); } - - // Start playback to begin buffering - Debug.WriteLine("[RadioPlayerService] Calling _player.Play() to start buffering..."); - SetInternalStateChange(true); - _player.Play(); - Debug.WriteLine("[RadioPlayerService] _player.Play() called - stream is buffering"); // Clear the external pause flag _wasExternalPause = false; @@ -594,22 +623,6 @@ private async Task PlayWithBufferInternalAsync() _metadataService.StartPolling(_streamUrl!); Debug.WriteLine("[RadioPlayerService] Started metadata polling"); - // Wait for sufficient buffer if buffer level > 0 - // During this time, the stream is buffering but audio is muted - if (needsBuffering) - { - Debug.WriteLine($"[RadioPlayerService] Waiting for buffer: {RequiredBufferDuration.TotalMilliseconds}ms..."); - await WaitForSufficientBufferAsync(); - - // Restore volume - audio will now be audible - Debug.WriteLine($"[RadioPlayerService] Buffer complete. Restoring volume to {savedVolume}"); - _player.Volume = savedVolume; - } - else - { - Debug.WriteLine("[RadioPlayerService] No additional buffer required (default level)"); - } - // Log final buffer state LogBufferedRanges(); } @@ -619,19 +632,34 @@ private async Task PlayWithBufferInternalAsync() Debug.WriteLine($"[RadioPlayerService] Exception details: {ex}"); Debug.WriteLine("[RadioPlayerService] Re-creating media source and trying again..."); - // Restore volume in case we were muted - _player.Volume = _volume; - - // 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!); _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(); + Debug.WriteLine("[RadioPlayerService] Started and paused for buffering (retry)"); + + await Task.Delay(RequiredBufferDuration); + + _player.Play(); + Debug.WriteLine("[RadioPlayerService] Playback resumed after buffering (retry)"); + } + else + { + SetInternalStateChange(true); + _player.Play(); + Debug.WriteLine("[RadioPlayerService] _player.Play() called successfully (retry)"); + } _wasExternalPause = false; _hasPlayedOnce = true; @@ -710,7 +738,7 @@ private async Task WaitForSufficientBufferAsync(CancellationToken cancellationTo /// /// Starts playback and waits for sufficient buffer based on current buffer level setting. /// Uses MediaPlaybackSession.GetBufferedRanges to monitor buffering progress. - /// The stream is muted during buffering, then unmuted once sufficient buffer is achieved. + /// 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. /// /// Cancellation token to cancel waiting @@ -726,9 +754,8 @@ public async Task PlayWithBufferAsync(CancellationToken cancellationToken throw new InvalidOperationException("No stream URL set. Call SetStreamUrl first."); } - // Check if we need to buffer before playing audibly + // Check if we need to buffer before playing bool needsBuffering = RequiredBufferDuration > TimeSpan.Zero; - double savedVolume = _volume; try { @@ -751,61 +778,87 @@ public async Task PlayWithBufferAsync(CancellationToken cancellationToken if (needsBuffering) { - // Mute the player while buffering so audio doesn't play yet - Debug.WriteLine($"[RadioPlayerService] Muting player for buffering (saved volume: {savedVolume})"); - _player.Volume = 0; - } - - SetInternalStateChange(true); - _player.Play(); - _wasExternalPause = false; - _watchdog.NotifyUserIntentionToPlay(); - _metadataService.StartPolling(_streamUrl!); - - // If no additional buffer is required, we're done - if (!needsBuffering) - { - Debug.WriteLine("[RadioPlayerService] No additional buffer required (default level)"); - return true; - } - - // Wait for sufficient buffer with cancellation support - int timeoutMs = Math.Clamp((int)RequiredBufferDuration.TotalMilliseconds * 3, 10000, 30000); - const int checkIntervalMs = 250; - int elapsed = 0; - - Debug.WriteLine($"[RadioPlayerService] Waiting for {RequiredBufferDuration.TotalMilliseconds}ms of buffer (timeout: {timeoutMs}ms)..."); - - while (elapsed < timeoutMs) - { - if (cancellationToken.IsCancellationRequested) + // 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(); + + // Wait for the user-set buffer amount of time + Debug.WriteLine($"[RadioPlayerService] Waiting for buffer time: {RequiredBufferDuration.TotalMilliseconds}ms..."); + try { - Debug.WriteLine("[RadioPlayerService] Buffer wait cancelled"); - _player.Volume = savedVolume; // Restore volume even if cancelled - return false; + await Task.Delay(RequiredBufferDuration, cancellationToken); } - - if (BufferingProgress >= 1.0) + catch (OperationCanceledException) { - Debug.WriteLine($"[RadioPlayerService] Stream fully buffered (BufferingProgress: {BufferingProgress:P0})"); - return true; + Debug.WriteLine("[RadioPlayerService] Buffer wait cancelled during initial delay"); + return false; } - + + // Check if buffer is complete using GetBufferedRanges TimeSpan bufferedDuration = TotalBufferedDuration; - - if (bufferedDuration >= RequiredBufferDuration) + 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] Sufficient buffer achieved: {bufferedDuration.TotalMilliseconds}ms >= {RequiredBufferDuration.TotalMilliseconds}ms"); - return true; - } + 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"); + return false; + } - await Task.Delay(checkIntervalMs, cancellationToken); - elapsed += checkIntervalMs; + 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(); + 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(); - Debug.WriteLine($"[RadioPlayerService] Buffer wait timeout after {timeoutMs}ms. Current buffer: {TotalBufferedDuration.TotalMilliseconds}ms"); - return false; + return true; } catch (OperationCanceledException) { @@ -819,12 +872,6 @@ public async Task PlayWithBufferAsync(CancellationToken cancellationToken } finally { - // Always restore volume when exiting, in case we were muted for buffering - if (needsBuffering) - { - Debug.WriteLine($"[RadioPlayerService] Ensuring volume is restored to {savedVolume}"); - _player.Volume = savedVolume; - } Debug.WriteLine($"=== PlayWithBufferAsync END ==="); } } From 582afc44d53a3e57bd0c8f31559e8b896a52d387 Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Tue, 20 Jan 2026 17:33:46 -0600 Subject: [PATCH 07/14] Adjust buffer slider step and hide buffer level description Updated slider to allow 0.5 increments and hid the buffer level description text for a cleaner UI. --- Trdo/Pages/SettingsPage.xaml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/Trdo/Pages/SettingsPage.xaml b/Trdo/Pages/SettingsPage.xaml index f685fa5..f6cd2b1 100644 --- a/Trdo/Pages/SettingsPage.xaml +++ b/Trdo/Pages/SettingsPage.xaml @@ -96,13 +96,14 @@ Maximum="3" Minimum="0" SnapsTo="StepValues" - StepFrequency="1" + StepFrequency="0.5" Value="{x:Bind ViewModel.BufferLevel, Mode=TwoWay}" /> + Text="{x:Bind ViewModel.BufferLevelDescription, Mode=OneWay}" + Visibility="Collapsed" /> From 588fd8a639f659f5177351135fbedc3b6134038e Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Tue, 20 Jan 2026 17:59:26 -0600 Subject: [PATCH 08/14] Synchronize ListView selection with ViewModel state Ensures the ListView's selected item stays in sync with the ViewModel's SelectedStation property for consistent UI and data state. --- Trdo/Pages/PlayingPage.xaml | 1 + Trdo/Pages/PlayingPage.xaml.cs | 7 +++++++ 2 files changed, 8 insertions(+) diff --git a/Trdo/Pages/PlayingPage.xaml b/Trdo/Pages/PlayingPage.xaml index efe029e..03b8954 100644 --- a/Trdo/Pages/PlayingPage.xaml +++ b/Trdo/Pages/PlayingPage.xaml @@ -91,6 +91,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"> diff --git a/Trdo/Pages/PlayingPage.xaml.cs b/Trdo/Pages/PlayingPage.xaml.cs index a09ae0f..50dc91f 100644 --- a/Trdo/Pages/PlayingPage.xaml.cs +++ b/Trdo/Pages/PlayingPage.xaml.cs @@ -188,6 +188,13 @@ private void UpdateStationSelection() return; } + // Ensure ListView SelectedItem is synchronized with ViewModel + if (StationsListView.SelectedItem != ViewModel.SelectedStation) + { + StationsListView.SelectedItem = ViewModel.SelectedStation; + Debug.WriteLine($"[PlayingPage] Synchronized ListView.SelectedItem to {ViewModel.SelectedStation?.Name ?? "null"}"); + } + for (int i = 0; i < ViewModel.Stations.Count; i++) { if (StationsListView.ContainerFromIndex(i) is not ListViewItem container) From fe3be10937315c97ce797483982afe5cef69e19e Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Tue, 20 Jan 2026 18:06:42 -0600 Subject: [PATCH 09/14] Support fractional buffer levels for smoother control Enable double-based buffer levels and linear delay interpolation for finer granularity in stream buffering. --- Trdo/Services/StreamWatchdogService.cs | 73 ++++++++++++++++---------- Trdo/ViewModels/PlayerViewModel.cs | 4 +- Trdo/ViewModels/SettingsViewModel.cs | 4 +- 3 files changed, 48 insertions(+), 33 deletions(-) diff --git a/Trdo/Services/StreamWatchdogService.cs b/Trdo/Services/StreamWatchdogService.cs index c0df1a8..31c5614 100644 --- a/Trdo/Services/StreamWatchdogService.cs +++ b/Trdo/Services/StreamWatchdogService.cs @@ -30,7 +30,7 @@ public sealed class StreamWatchdogService : IDisposable // Stutter detection tracking private readonly Queue _recoveryAttempts = new(); private bool _autoBufferIncreaseEnabled; - private int _currentBufferLevel; + private double _currentBufferLevel; private const string AutoBufferIncreaseKey = "AutoBufferIncreaseEnabled"; private const string BufferLevelKey = "BufferLevel"; @@ -45,13 +45,13 @@ public sealed class StreamWatchdogService : IDisposable // 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 int MaxBufferLevel = 3; // Maximum buffer level (0=default, 1=medium, 2=large, 3=extra large) + 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 int DefaultBufferLevel = 0; // Start with default (no extra delay) buffer level + 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 event EventHandler? BufferLevelChanged; public bool IsEnabled { @@ -88,14 +88,14 @@ public bool AutoBufferIncreaseEnabled /// Gets or sets the current buffer level (0-3). /// 0 = Default, 1 = Medium, 2 = Large, 3 = Extra Large /// - public int BufferLevel + public double BufferLevel { get => _currentBufferLevel; set { - int clampedValue = Math.Clamp(value, 0, MaxBufferLevel); - if (_currentBufferLevel == clampedValue) return; - int oldValue = _currentBufferLevel; + 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}"); @@ -106,26 +106,40 @@ public int BufferLevel /// /// Gets the buffer delay in milliseconds based on current buffer level. /// - public int BufferDelayMs => _currentBufferLevel switch + public int BufferDelayMs { - 0 => 0, // Default - no additional delay - 1 => 2000, // Medium - 2 second buffer - 2 => 4000, // Large - 4 second buffer - 3 => 8000, // Extra Large - 8 second buffer - _ => 0 - }; + 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 => _currentBufferLevel switch + public string BufferLevelDescription { - 0 => "Default", - 1 => "Medium", - 2 => "Large", - 3 => "Extra Large", - _ => "Default" - }; + 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) { @@ -164,8 +178,9 @@ string s when bool.TryParse(s, out bool b2) => b2, { _currentBufferLevel = bufferLevelValue switch { - int i => Math.Clamp(i, 0, MaxBufferLevel), - string s when int.TryParse(s, out int i2) => Math.Clamp(i2, 0, MaxBufferLevel), + 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 }; } @@ -501,7 +516,7 @@ private void TrackRecoveryAttempt() int recoveryCount = _recoveryAttempts.Count; Debug.WriteLine($"[Watchdog] STUTTER DETECTED - {recoveryCount} recoveries in {_stutterWindow.TotalMinutes}min window"); - int previousLevel = _currentBufferLevel; + double previousLevel = _currentBufferLevel; // Auto-increase buffer if enabled and not at max if (_autoBufferIncreaseEnabled && _currentBufferLevel < MaxBufferLevel) @@ -520,7 +535,7 @@ private void TrackRecoveryAttempt() TimeWindow = _stutterWindow, PreviousBufferLevel = previousLevel, NewBufferLevel = _currentBufferLevel, - BufferWasIncreased = previousLevel != _currentBufferLevel + BufferWasIncreased = Math.Abs(previousLevel - _currentBufferLevel) > 0.0001 }); } } @@ -604,7 +619,7 @@ private void RaiseStutterDetected(StutterDetectedEventArgs args) } } - private void RaiseBufferLevelChanged(int newLevel) + private void RaiseBufferLevelChanged(double newLevel) { Debug.WriteLine($"[Watchdog] Raising BufferLevelChanged event - NewLevel: {newLevel}"); @@ -675,12 +690,12 @@ public class StutterDetectedEventArgs : EventArgs /// /// The buffer level before auto-increase was applied. /// - public int PreviousBufferLevel { get; init; } + public double PreviousBufferLevel { get; init; } /// /// The buffer level after auto-increase was applied (may be same as previous if at max or disabled). /// - public int NewBufferLevel { get; init; } + public double NewBufferLevel { get; init; } /// /// Whether the buffer level was actually increased. diff --git a/Trdo/ViewModels/PlayerViewModel.cs b/Trdo/ViewModels/PlayerViewModel.cs index 4015b92..466242a 100644 --- a/Trdo/ViewModels/PlayerViewModel.cs +++ b/Trdo/ViewModels/PlayerViewModel.cs @@ -284,12 +284,12 @@ public bool AutoBufferIncreaseEnabled /// Gets or sets the current buffer level (0-3). /// 0 = Default, 1 = Medium, 2 = Large, 3 = Extra Large /// - public int BufferLevel + public double BufferLevel { get => _player.Watchdog.BufferLevel; set { - if (value == _player.Watchdog.BufferLevel) return; + if (Math.Abs(value - _player.Watchdog.BufferLevel) < 0.0001) return; Debug.WriteLine($"[PlayerViewModel] Setting BufferLevel to {value}"); _player.Watchdog.BufferLevel = value; OnPropertyChanged(); diff --git a/Trdo/ViewModels/SettingsViewModel.cs b/Trdo/ViewModels/SettingsViewModel.cs index b35979d..a424963 100644 --- a/Trdo/ViewModels/SettingsViewModel.cs +++ b/Trdo/ViewModels/SettingsViewModel.cs @@ -144,12 +144,12 @@ public string AutoBufferToggleText /// Gets or sets the current buffer level (0-3). /// 0 = Default, 1 = Medium, 2 = Large, 3 = Extra Large /// - public int BufferLevel + public double BufferLevel { get => _playerViewModel.BufferLevel; set { - if (value == _playerViewModel.BufferLevel) return; + if (Math.Abs(value - _playerViewModel.BufferLevel) < 0.0001) return; _playerViewModel.BufferLevel = value; OnPropertyChanged(); OnPropertyChanged(nameof(BufferLevelDescription)); From 1aa80d42b9f2c79e082af8e499d703d5b9323620 Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Tue, 20 Jan 2026 18:23:06 -0600 Subject: [PATCH 10/14] Add manual buffering state for accurate UI feedback Improves buffering indicator accuracy and UI responsiveness during custom buffer delays by tracking manual buffering state and ensuring it is reset on pause, error, or cancellation. Also allows finer buffer slider control. --- Trdo/Pages/SettingsPage.xaml | 2 +- Trdo/Services/RadioPlayerService.cs | 116 +++++++++++++++++++++------- 2 files changed, 91 insertions(+), 27 deletions(-) diff --git a/Trdo/Pages/SettingsPage.xaml b/Trdo/Pages/SettingsPage.xaml index f6cd2b1..7c999a1 100644 --- a/Trdo/Pages/SettingsPage.xaml +++ b/Trdo/Pages/SettingsPage.xaml @@ -96,7 +96,7 @@ Maximum="3" Minimum="0" SnapsTo="StepValues" - StepFrequency="0.5" + StepFrequency="0.1" Value="{x:Bind ViewModel.BufferLevel, Mode=TwoWay}" /> TimeSpan.Zero; - + if (needsBuffering) { SetInternalStateChange(true); _player.Play(); _player.Pause(); - Debug.WriteLine("[RadioPlayerService] Started and paused for buffering (retry)"); - + + // 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(); - Debug.WriteLine("[RadioPlayerService] Playback resumed after buffering (retry)"); + + // Clear manual buffering state after retry + SetManualBuffering(false); + Debug.WriteLine("[RadioPlayerService] Playback resumed after buffering (retry), manual buffering cleared"); } else { @@ -675,6 +696,11 @@ private async Task PlayWithBufferInternalAsync() 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}"); // 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 @@ -778,18 +804,27 @@ public async Task PlayWithBufferAsync(CancellationToken cancellationToken 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 @@ -799,13 +834,14 @@ public async Task PlayWithBufferAsync(CancellationToken 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) { @@ -813,12 +849,13 @@ public async Task PlayWithBufferAsync(CancellationToken cancellationToken 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; } @@ -839,10 +876,14 @@ public async Task PlayWithBufferAsync(CancellationToken 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 @@ -863,11 +904,13 @@ public async Task PlayWithBufferAsync(CancellationToken cancellationToken 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 @@ -903,13 +946,30 @@ private void LogBufferedRanges() catch (Exception ex) { Debug.WriteLine($"[RadioPlayerService] Error logging buffered ranges: {ex.Message}"); + } } - } - /// - /// Stop playback and clean up resources - /// - public void Pause() + /// + /// 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"); @@ -930,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)"); From 6807296bfd263b4b737d28a290ff4f9e92f78569 Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Tue, 20 Jan 2026 18:58:32 -0600 Subject: [PATCH 11/14] Update UI text to clarify favorite songs labeling Improves clarity and consistency by updating labels and tooltips to explicitly reference "songs" in favorite-related UI elements. --- Trdo/Pages/FavoritesPage.xaml | 6 +++--- Trdo/Pages/NowPlayingPage.xaml | 4 ++-- Trdo/Pages/PlayingPage.xaml | 4 ++-- 3 files changed, 7 insertions(+), 7 deletions(-) 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..094f1d2 100644 --- a/Trdo/Pages/NowPlayingPage.xaml +++ b/Trdo/Pages/NowPlayingPage.xaml @@ -121,7 +121,7 @@ Background="Transparent" BorderThickness="0" Click="FavoriteCurrentTrack_Click" - ToolTipService.ToolTip="Toggle favorite"> + ToolTipService.ToolTip="Favorite Song"> + ToolTipService.ToolTip="Favorite Song"> + ToolTipService.ToolTip="Favorite Songs"> @@ -284,7 +284,7 @@ Background="Transparent" BorderThickness="0" Click="FavoriteButton_Click" - ToolTipService.ToolTip="Toggle favorite"> + ToolTipService.ToolTip="Favorite Song"> Date: Wed, 21 Jan 2026 23:56:56 -0600 Subject: [PATCH 12/14] Replace ScrollViewer with ScrollView, add MarqueeText support Updated XAML to use ScrollView, enabled marquee for Now Playing, and added required package reference. No logic changes. --- Trdo/Controls/TutorialWindow.xaml | 4 +- Trdo/Pages/AboutPage.xaml | 176 +++++++++++++++--------------- Trdo/Pages/AddStation.xaml | 74 +++++++------ Trdo/Pages/NowPlayingPage.xaml | 12 +- Trdo/Pages/PlayingPage.xaml | 15 ++- Trdo/Pages/SearchStation.xaml | 40 +++---- Trdo/Pages/SettingsPage.xaml | 4 +- Trdo/Trdo.csproj | 1 + 8 files changed, 166 insertions(+), 160 deletions(-) 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 @@ - + - + - + - - - - - - + + + + + + - - - + + + - + - - - - - - - + + + + + + + - - - + + + - + - - - - - - - + + + + + + + - - - - - - - - + + + + + + + + - - - + + + - + - - - - - - + + + + + + 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/NowPlayingPage.xaml b/Trdo/Pages/NowPlayingPage.xaml index 094f1d2..ee57468 100644 --- a/Trdo/Pages/NowPlayingPage.xaml +++ b/Trdo/Pages/NowPlayingPage.xaml @@ -88,27 +88,27 @@ diff --git a/Trdo/Pages/PlayingPage.xaml b/Trdo/Pages/PlayingPage.xaml index 57a95d5..44c63cf 100644 --- a/Trdo/Pages/PlayingPage.xaml +++ b/Trdo/Pages/PlayingPage.xaml @@ -5,6 +5,7 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:converters="using:Trdo.Converters" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" + xmlns:labs="using:CommunityToolkit.Labs.WinUI.MarqueeTextRns" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:models="using:Trdo.Models" mc:Ignorable="d"> @@ -267,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 7c999a1..c26cf1a 100644 --- a/Trdo/Pages/SettingsPage.xaml +++ b/Trdo/Pages/SettingsPage.xaml @@ -16,7 +16,7 @@ Text="Settings" /> - + @@ -107,6 +107,6 @@ - + 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 @@ + From f523a63ea260cbd3998f9d260f6db1b5f135b938 Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Thu, 22 Jan 2026 00:06:55 -0600 Subject: [PATCH 13/14] Bump app version to 1.7.0.0 Updated the application version in Package.appxmanifest. --- Trdo/Package.appxmanifest | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Trdo/Package.appxmanifest b/Trdo/Package.appxmanifest index 600594a..cade50d 100644 --- a/Trdo/Package.appxmanifest +++ b/Trdo/Package.appxmanifest @@ -11,7 +11,7 @@ + Version="1.7.0.0" /> From d09c27bf96771400bfca643f67414a739bc73ee5 Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Thu, 22 Jan 2026 00:07:04 -0600 Subject: [PATCH 14/14] Add interactive star rating and feedback to About page Enhanced About page with star rating, direct review, and contact developer options for improved user feedback experience. --- Trdo/Pages/AboutPage.xaml | 91 +++++++++++++++++++++++++++++-- Trdo/Pages/AboutPage.xaml.cs | 39 +++++++++++++ Trdo/ViewModels/AboutViewModel.cs | 24 ++++++++ 3 files changed, 148 insertions(+), 6 deletions(-) diff --git a/Trdo/Pages/AboutPage.xaml b/Trdo/Pages/AboutPage.xaml index 631262b..8dcf6be 100644 --- a/Trdo/Pages/AboutPage.xaml +++ b/Trdo/Pages/AboutPage.xaml @@ -80,17 +80,96 @@ - + + + + + + + + + + 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/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));