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" />