diff --git a/Trdo/App.xaml.cs b/Trdo/App.xaml.cs
index e1857ad..65d57c5 100644
--- a/Trdo/App.xaml.cs
+++ b/Trdo/App.xaml.cs
@@ -1,6 +1,7 @@
using Microsoft.UI.Dispatching;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
+using Microsoft.Win32;
using System;
using System.ComponentModel;
using System.Linq;
@@ -240,12 +241,15 @@ private static bool IsSystemInDarkMode()
{
try
{
- UISettings uiSettings = new();
- Color foregroundColor = uiSettings.GetColorValue(UIColorType.Foreground);
+ // Read the system (taskbar) theme, not the app theme.
+ // SystemUsesLightTheme = 0 means dark taskbar, 1 means light taskbar.
+ using RegistryKey? key = Registry.CurrentUser.OpenSubKey(
+ @"SOFTWARE\Microsoft\Windows\CurrentVersion\Themes\Personalize");
+ object? value = key?.GetValue("SystemUsesLightTheme");
+ if (value is int intVal)
+ return intVal == 0;
- // In dark mode, foreground color is light (high RGB values)
- // In light mode, foreground color is dark (low RGB values)
- return (foregroundColor.R + foregroundColor.G + foregroundColor.B) > 384;
+ return true;
}
catch
{
diff --git a/Trdo/Package.appxmanifest b/Trdo/Package.appxmanifest
index d62c29c..09ba118 100644
--- a/Trdo/Package.appxmanifest
+++ b/Trdo/Package.appxmanifest
@@ -11,7 +11,7 @@
+ Version="1.8.0.0" />
diff --git a/Trdo/Pages/PlayingPage.xaml b/Trdo/Pages/PlayingPage.xaml
index 44c63cf..b19d889 100644
--- a/Trdo/Pages/PlayingPage.xaml
+++ b/Trdo/Pages/PlayingPage.xaml
@@ -18,6 +18,18 @@
+
+
+
+
+
+
+
+
+
@@ -222,7 +234,17 @@
-
+
+
+
+
+
+
diff --git a/Trdo/Pages/PlayingPage.xaml.cs b/Trdo/Pages/PlayingPage.xaml.cs
index 50dc91f..4da53e9 100644
--- a/Trdo/Pages/PlayingPage.xaml.cs
+++ b/Trdo/Pages/PlayingPage.xaml.cs
@@ -1,6 +1,7 @@
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Controls.Primitives;
+using Microsoft.UI.Xaml.Input;
using Microsoft.UI.Xaml.Media;
using System;
using System.Diagnostics;
@@ -64,6 +65,11 @@ private void PlayingPage_Loaded(object sender, RoutedEventArgs e)
UpdateStationSelection();
UpdateFavoriteButtonState();
+ // Restore volume slider visibility from persisted setting
+ VolumeControlGrid.Visibility = SettingsService.IsVolumeSliderVisible
+ ? Visibility.Visible
+ : Visibility.Collapsed;
+
// Find the ShellViewModel from the parent page
_shellViewModel = FindShellViewModel();
Debug.WriteLine($"[PlayingPage] ShellViewModel found: {_shellViewModel != null}");
@@ -396,4 +402,32 @@ private void StationsListView_SelectionChanged(object sender, SelectionChangedEv
Debug.WriteLine("=== StationsListView_SelectionChanged END ===");
}
+
+ private void VolumeControl_PointerWheelChanged(object sender, PointerRoutedEventArgs e)
+ {
+ int delta = e.GetCurrentPoint((UIElement)sender).Properties.MouseWheelDelta;
+ double change = (delta / 120.0) * 0.02;
+ ViewModel.Volume = Math.Clamp(ViewModel.Volume + change, 0, 1);
+ e.Handled = true;
+ }
+
+ private void HideVolumeSlider_Click(object sender, RoutedEventArgs e)
+ {
+ VolumeControlGrid.Visibility = Visibility.Collapsed;
+ SettingsService.IsVolumeSliderVisible = false;
+ }
+
+ private void ShowVolumeSlider_Click(object sender, RoutedEventArgs e)
+ {
+ VolumeControlGrid.Visibility = Visibility.Visible;
+ SettingsService.IsVolumeSliderVisible = true;
+ }
+
+ private void PageContextMenu_Opening(object sender, object e)
+ {
+ if (VolumeControlGrid.Visibility == Visibility.Visible)
+ {
+ ((MenuFlyout)sender).Hide();
+ }
+ }
}
diff --git a/Trdo/Pages/SearchStation.xaml b/Trdo/Pages/SearchStation.xaml
index b46863f..4b36c0e 100644
--- a/Trdo/Pages/SearchStation.xaml
+++ b/Trdo/Pages/SearchStation.xaml
@@ -73,67 +73,90 @@
ItemsSource="{x:Bind ViewModel.SearchResults, Mode=OneWay}"
SelectionMode="None"
Visibility="{x:Bind ViewModel.SearchResults.Count, Mode=OneWay, Converter={StaticResource HasItemsVisibilityConverter}}">
+
+
+
-
-
-
+
-
-
-
-
+ ColumnDefinitions="Auto,*"
+ ColumnSpacing="12"
+ RowDefinitions="Auto,Auto,Auto"
+ RowSpacing="4">
-
-
+
+
+
+
+
+
-
-
-
-
-
-
+
+
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Trdo/Pages/SearchStation.xaml.cs b/Trdo/Pages/SearchStation.xaml.cs
index f59dfbd..ecc3ad6 100644
--- a/Trdo/Pages/SearchStation.xaml.cs
+++ b/Trdo/Pages/SearchStation.xaml.cs
@@ -1,7 +1,9 @@
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Media;
+using System;
using Trdo.Models;
+using Trdo.Services;
using Trdo.ViewModels;
namespace Trdo.Pages;
@@ -11,8 +13,13 @@ namespace Trdo.Pages;
///
public sealed partial class SearchStation : Page
{
+ private const string PlayGlyph = "\uE768";
+ private const string PauseGlyph = "\uE769";
+
public SearchStationViewModel ViewModel { get; }
private ShellViewModel? _shellViewModel;
+ private Button? _activePreviewButton;
+ private string? _previewingStationUrl;
public SearchStation()
{
@@ -21,6 +28,7 @@ public SearchStation()
DataContext = ViewModel;
Loaded += SearchStation_Loaded;
+ Unloaded += SearchStation_Unloaded;
}
private void SearchStation_Loaded(object sender, RoutedEventArgs e)
@@ -28,6 +36,14 @@ private void SearchStation_Loaded(object sender, RoutedEventArgs e)
// Find the ShellViewModel from the parent page
_shellViewModel = FindShellViewModel();
SearchTextBox.Focus(FocusState.Programmatic);
+
+ RadioPlayerService.Instance.PlaybackStateChanged += OnPlaybackStateChanged;
+ }
+
+ private void SearchStation_Unloaded(object sender, RoutedEventArgs e)
+ {
+ RadioPlayerService.Instance.PlaybackStateChanged -= OnPlaybackStateChanged;
+ StopPreview();
}
private ShellViewModel? FindShellViewModel()
@@ -45,10 +61,76 @@ private void SearchStation_Loaded(object sender, RoutedEventArgs e)
return null;
}
+ private void PreviewButton_Click(object sender, RoutedEventArgs e)
+ {
+ if (sender is not Button button || button.Tag is not RadioBrowserStation station)
+ return;
+
+ string stationUrl = station.GetStreamUrl();
+
+ if (_previewingStationUrl == stationUrl && RadioPlayerService.Instance.IsPlaying)
+ {
+ // Same station is playing, pause it
+ RadioPlayerService.Instance.Pause();
+ SetButtonGlyph(button, PlayGlyph);
+ _activePreviewButton = null;
+ _previewingStationUrl = null;
+ return;
+ }
+
+ // Reset old preview button icon if switching stations
+ if (_activePreviewButton != null && _activePreviewButton != button)
+ {
+ SetButtonGlyph(_activePreviewButton, PlayGlyph);
+ }
+
+ // Start previewing the new station
+ RadioPlayerService.Instance.SetStreamUrl(stationUrl);
+ RadioPlayerService.Instance.SetStationName(station.Name);
+ RadioPlayerService.Instance.Play();
+
+ SetButtonGlyph(button, PauseGlyph);
+ _activePreviewButton = button;
+ _previewingStationUrl = stationUrl;
+ }
+
+ private void OnPlaybackStateChanged(object? sender, bool isPlaying)
+ {
+ if (_activePreviewButton == null)
+ return;
+
+ DispatcherQueue.TryEnqueue(() =>
+ {
+ if (_activePreviewButton != null)
+ {
+ SetButtonGlyph(_activePreviewButton, isPlaying ? PauseGlyph : PlayGlyph);
+ }
+ });
+ }
+
+ private static void SetButtonGlyph(Button button, string glyph)
+ {
+ if (button.Content is FontIcon icon)
+ {
+ icon.Glyph = glyph;
+ }
+ }
+
+ private void StopPreview()
+ {
+ if (_previewingStationUrl != null)
+ {
+ RadioPlayerService.Instance.Pause();
+ _activePreviewButton = null;
+ _previewingStationUrl = null;
+ }
+ }
+
private void AddStationButton_Click(object sender, RoutedEventArgs e)
{
if (sender is Button button && button.Tag is RadioBrowserStation station)
{
+ StopPreview();
// Navigate to AddStation page with the selected station
_shellViewModel?.NavigateToAddStationPage(station);
}
@@ -56,12 +138,14 @@ private void AddStationButton_Click(object sender, RoutedEventArgs e)
private void ManualEntryButton_Click(object sender, RoutedEventArgs e)
{
+ StopPreview();
// Navigate to manual entry page
_shellViewModel?.NavigateToAddStationPage();
}
private void CancelButton_Click(object sender, RoutedEventArgs e)
{
+ StopPreview();
// Navigate back without adding
_shellViewModel?.GoBack();
}
diff --git a/Trdo/Pages/SettingsPage.xaml b/Trdo/Pages/SettingsPage.xaml
index c26cf1a..d1a31c0 100644
--- a/Trdo/Pages/SettingsPage.xaml
+++ b/Trdo/Pages/SettingsPage.xaml
@@ -3,10 +3,15 @@
x:Class="Trdo.Pages.SettingsPage"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
+ xmlns:converters="using:Trdo.Converters"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d">
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -106,6 +190,30 @@
Visibility="Collapsed" />
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Trdo/Pages/SettingsPage.xaml.cs b/Trdo/Pages/SettingsPage.xaml.cs
index 3bdd215..f159cb9 100644
--- a/Trdo/Pages/SettingsPage.xaml.cs
+++ b/Trdo/Pages/SettingsPage.xaml.cs
@@ -1,15 +1,136 @@
+using System;
+using System.Runtime.InteropServices;
+using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
+using Trdo.Services;
using Trdo.ViewModels;
namespace Trdo.Pages;
public sealed partial class SettingsPage : Page
{
+ [DllImport("user32.dll")]
+ private static extern nint GetActiveWindow();
+
+ private float _displayLevel;
+
public SettingsViewModel ViewModel { get; }
public SettingsPage()
{
InitializeComponent();
ViewModel = new SettingsViewModel();
+
+ Loaded += OnLoaded;
+ Unloaded += OnUnloaded;
+ }
+
+ private void OnLoaded(object sender, RoutedEventArgs e)
+ {
+ RadioPlayerService.Instance.Watchdog.AudioLevelUpdated += OnAudioLevelUpdated;
+ }
+
+ private void OnUnloaded(object sender, RoutedEventArgs e)
+ {
+ RadioPlayerService.Instance.Watchdog.AudioLevelUpdated -= OnAudioLevelUpdated;
+ }
+
+ private void OnAudioLevelUpdated(float rms)
+ {
+ // Marshal to UI thread — the event fires on the NAudio capture thread
+ DispatcherQueue.TryEnqueue(() => UpdateLevelBars(rms));
+ }
+
+ private void UpdateLevelBars(float rms)
+ {
+ // Logarithmic (dB) normalisation – maps silence..full-scale to 0..1
+ // Using a 50 dB range gives more visible movement in the mid-levels
+ float db = rms > 0 ? 20f * MathF.Log10(rms) : -50f;
+ float normalized = Math.Clamp((db + 50f) / 50f, 0f, 1f);
+
+ // Fast attack, quick decay so bars visibly bounce with every beat
+ const float decayFactor = 0.55f;
+ _displayLevel = normalized > _displayLevel
+ ? normalized
+ : _displayLevel * (1f - decayFactor) + normalized * decayFactor;
+
+ // Each bar has a threshold; opacity ramps proportionally above it
+ // so even small fluctuations produce visible movement.
+ const float dimOpacity = 0.12f;
+ Bar1.Opacity = BarOpacity(_displayLevel, 0.05f, dimOpacity);
+ Bar2.Opacity = BarOpacity(_displayLevel, 0.22f, dimOpacity);
+ Bar3.Opacity = BarOpacity(_displayLevel, 0.42f, dimOpacity);
+ Bar4.Opacity = BarOpacity(_displayLevel, 0.62f, dimOpacity);
+ Bar5.Opacity = BarOpacity(_displayLevel, 0.80f, dimOpacity);
+ }
+
+ ///
+ /// Returns a proportional opacity for a single bar.
+ /// Below the bar is dim; above it the bar ramps
+ /// smoothly from dim → fully lit over a short range so small level changes
+ /// are clearly visible.
+ ///
+ private static double BarOpacity(float level, float threshold, float dim)
+ {
+ if (level <= threshold) return dim;
+ // Ramp from dim → 1.0 over 0.15 of level range above the threshold
+ float t = Math.Min((level - threshold) / 0.15f, 1f);
+ return dim + t * (1.0 - dim);
+ }
+
+ private async void ImportButton_Click(object sender, RoutedEventArgs e)
+ {
+ try
+ {
+ nint hwnd = GetActiveWindow();
+ int count = await ViewModel.ImportStationsAsync(hwnd);
+
+ if (count > 0)
+ {
+ ImportExportInfoBar.Severity = InfoBarSeverity.Success;
+ ImportExportInfoBar.Message = $"Imported {count} station{(count == 1 ? "" : "s")}.";
+ }
+ else
+ {
+ ImportExportInfoBar.Severity = InfoBarSeverity.Informational;
+ ImportExportInfoBar.Message = "No stations were imported.";
+ }
+
+ ImportExportInfoBar.IsOpen = true;
+ }
+ catch (Exception ex)
+ {
+ ImportExportInfoBar.Severity = InfoBarSeverity.Error;
+ ImportExportInfoBar.Message = $"Import failed: {ex.Message}";
+ ImportExportInfoBar.IsOpen = true;
+ }
+ }
+
+ private async void ExportButton_Click(object sender, RoutedEventArgs e)
+ {
+ try
+ {
+ nint hwnd = GetActiveWindow();
+ bool exported = await ViewModel.ExportStationsAsync(hwnd);
+
+ if (exported)
+ {
+ ImportExportInfoBar.Severity = InfoBarSeverity.Success;
+ ImportExportInfoBar.Message = "Stations exported successfully.";
+ }
+ else
+ {
+ ImportExportInfoBar.Severity = InfoBarSeverity.Informational;
+ ImportExportInfoBar.Message = "Export cancelled or no stations to export.";
+ }
+
+ ImportExportInfoBar.IsOpen = true;
+ }
+ catch (Exception ex)
+ {
+ ImportExportInfoBar.Severity = InfoBarSeverity.Error;
+ ImportExportInfoBar.Message = $"Export failed: {ex.Message}";
+ ImportExportInfoBar.IsOpen = true;
+ }
}
}
diff --git a/Trdo/Services/AudioSilenceMonitorService.cs b/Trdo/Services/AudioSilenceMonitorService.cs
new file mode 100644
index 0000000..ba165d1
--- /dev/null
+++ b/Trdo/Services/AudioSilenceMonitorService.cs
@@ -0,0 +1,226 @@
+using NAudio.Wave;
+using System;
+using System.Diagnostics;
+
+namespace Trdo.Services;
+
+///
+/// Monitors the system audio output using WASAPI loopback capture to detect silence.
+/// When the captured audio RMS falls below a threshold for a configurable duration,
+/// raises a event.
+///
+public sealed partial class AudioSilenceMonitorService : IDisposable
+{
+ private WasapiLoopbackCapture? _capture;
+ private readonly object _lock = new();
+ private volatile bool _isMonitoring;
+ private DateTime _silenceStartTime;
+ private volatile bool _isSilent;
+ private double _silenceTimeoutSeconds = 5.0;
+
+ // RMS threshold below which audio is considered "silent".
+ // 32-bit float samples: typical quiet system noise sits around 0.0001–0.001.
+ private const float SilenceRmsThreshold = 0.001f;
+
+ // Throttle UI-facing level updates to ~30 fps for responsive visualisation
+ private DateTime _lastLevelUpdate = DateTime.MinValue;
+ private const double LevelUpdateIntervalMs = 33;
+
+ // Track the peak RMS between UI updates so short transients aren't lost
+ private float _peakSinceLastUpdate;
+
+ ///
+ /// Raised when silence has been detected for longer than .
+ ///
+ public event EventHandler? SilenceDetected;
+
+ ///
+ /// Raised periodically (~10 fps) with the current RMS audio level.
+ /// Consumers can use this for visual feedback (e.g. level bars).
+ /// Fires on the NAudio capture thread — marshal to UI thread before touching XAML.
+ ///
+ public event Action? AudioLevelUpdated;
+
+ ///
+ /// Gets or sets the silence timeout in seconds.
+ /// If audio output is silent for longer than this value the event fires.
+ /// Clamped to [1, 60].
+ ///
+ public double SilenceTimeoutSeconds
+ {
+ get => _silenceTimeoutSeconds;
+ set => _silenceTimeoutSeconds = Math.Clamp(value, 1.0, 60.0);
+ }
+
+ ///
+ /// Gets whether the monitor is currently capturing audio.
+ ///
+ public bool IsMonitoring => _isMonitoring;
+
+ ///
+ /// Starts monitoring the default audio output device for silence.
+ ///
+ public void Start()
+ {
+ lock (_lock)
+ {
+ if (_isMonitoring) return;
+
+ try
+ {
+ WasapiLoopbackCapture capture = new();
+ capture.DataAvailable += OnDataAvailable;
+ capture.RecordingStopped += OnRecordingStopped;
+
+ _isSilent = false;
+ _peakSinceLastUpdate = 0;
+ _capture = capture;
+ capture.StartRecording();
+ _isMonitoring = true;
+ Debug.WriteLine("[SilenceMonitor] Started monitoring audio output");
+ }
+ catch (Exception ex)
+ {
+ Debug.WriteLine($"[SilenceMonitor] Failed to start: {ex.Message}");
+ DisposeCapture();
+ }
+ }
+ }
+
+ ///
+ /// Stops monitoring and releases the capture device.
+ ///
+ public void Stop()
+ {
+ bool wasMonitoring;
+ lock (_lock)
+ {
+ wasMonitoring = _isMonitoring;
+ _isMonitoring = false;
+ _isSilent = false;
+ }
+
+ if (wasMonitoring)
+ {
+ DisposeCapture();
+ Debug.WriteLine("[SilenceMonitor] Stopped monitoring");
+ }
+ }
+
+ private void OnDataAvailable(object? sender, WaveInEventArgs e)
+ {
+ if (!_isMonitoring || e.BytesRecorded == 0) return;
+
+ float rms = CalculateRms(e.Buffer, e.BytesRecorded);
+
+ // Track peak between UI frames so short transients aren't swallowed
+ if (rms > _peakSinceLastUpdate)
+ _peakSinceLastUpdate = rms;
+
+ // Throttled level update for UI visualisation
+ DateTime now = DateTime.UtcNow;
+ if ((now - _lastLevelUpdate).TotalMilliseconds >= LevelUpdateIntervalMs)
+ {
+ _lastLevelUpdate = now;
+ AudioLevelUpdated?.Invoke(_peakSinceLastUpdate);
+ _peakSinceLastUpdate = 0;
+ }
+
+ if (rms < SilenceRmsThreshold)
+ {
+ if (!_isSilent)
+ {
+ _isSilent = true;
+ _silenceStartTime = DateTime.UtcNow;
+ Debug.WriteLine($"[SilenceMonitor] Silence started (RMS: {rms:F6})");
+ }
+
+ double silenceDuration = (DateTime.UtcNow - _silenceStartTime).TotalSeconds;
+ if (silenceDuration >= _silenceTimeoutSeconds)
+ {
+ Debug.WriteLine($"[SilenceMonitor] Silence threshold exceeded: {silenceDuration:F1}s >= {_silenceTimeoutSeconds}s");
+ _isSilent = false; // Reset to avoid repeated triggers until audio resumes
+ SilenceDetected?.Invoke(this, EventArgs.Empty);
+ }
+ }
+ else
+ {
+ if (_isSilent)
+ {
+ double silenceDuration = (DateTime.UtcNow - _silenceStartTime).TotalSeconds;
+ Debug.WriteLine($"[SilenceMonitor] Audio resumed after {silenceDuration:F1}s silence (RMS: {rms:F6})");
+ }
+ _isSilent = false;
+ }
+ }
+
+ private void OnRecordingStopped(object? sender, StoppedEventArgs e)
+ {
+ if (e.Exception != null)
+ {
+ Debug.WriteLine($"[SilenceMonitor] Recording stopped with error: {e.Exception.Message}");
+ }
+ else
+ {
+ Debug.WriteLine("[SilenceMonitor] Recording stopped");
+ }
+ }
+
+ ///
+ /// Calculates the Root Mean Square of 32-bit IEEE float audio samples.
+ ///
+ private static float CalculateRms(byte[] buffer, int bytesRecorded)
+ {
+ int sampleCount = bytesRecorded / 4; // 32-bit float = 4 bytes per sample
+ if (sampleCount == 0) return 0f;
+
+ double sumOfSquares = 0;
+ for (int i = 0; i + 4 <= bytesRecorded; i += 4)
+ {
+ float sample = BitConverter.ToSingle(buffer, i);
+ sumOfSquares += sample * sample;
+ }
+
+ return (float)Math.Sqrt(sumOfSquares / sampleCount);
+ }
+
+ ///
+ /// Safely disposes the current capture device outside the lock to prevent
+ /// deadlocks with NAudio's internal capture thread.
+ ///
+ private void DisposeCapture()
+ {
+ WasapiLoopbackCapture? capture;
+ lock (_lock)
+ {
+ capture = _capture;
+ _capture = null;
+ }
+
+ if (capture != null)
+ {
+ try
+ {
+ capture.DataAvailable -= OnDataAvailable;
+ capture.RecordingStopped -= OnRecordingStopped;
+ capture.StopRecording();
+ }
+ catch { }
+
+ try
+ {
+ capture.Dispose();
+ }
+ catch (Exception ex)
+ {
+ Debug.WriteLine($"[SilenceMonitor] Error disposing capture: {ex.Message}");
+ }
+ }
+ }
+
+ public void Dispose()
+ {
+ _isMonitoring = false;
+ DisposeCapture();
+ }
+}
diff --git a/Trdo/Services/PlaylistImportExportService.cs b/Trdo/Services/PlaylistImportExportService.cs
new file mode 100644
index 0000000..c7e97b0
--- /dev/null
+++ b/Trdo/Services/PlaylistImportExportService.cs
@@ -0,0 +1,207 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Text;
+using Trdo.Models;
+
+namespace Trdo.Services;
+
+public static class PlaylistImportExportService
+{
+ ///
+ /// Parses radio stations from the contents of a playlist file.
+ /// Supports M3U/M3U8 and PLS formats.
+ ///
+ public static List ImportFromFile(string filePath, string content)
+ {
+ string extension = Path.GetExtension(filePath).ToLowerInvariant();
+
+ return extension switch
+ {
+ ".pls" => ParsePls(content),
+ _ => ParseM3u(content), // .m3u, .m3u8, or fallback
+ };
+ }
+
+ ///
+ /// Exports radio stations to M3U format.
+ ///
+ public static string ExportToM3u(IEnumerable stations)
+ {
+ StringBuilder sb = new();
+ sb.AppendLine("#EXTM3U");
+
+ foreach (RadioStation station in stations)
+ {
+ sb.AppendLine($"#EXTINF:-1,{station.Name}");
+
+ if (!string.IsNullOrWhiteSpace(station.Homepage))
+ sb.AppendLine($"#EXTVLCOPT:url={station.Homepage}");
+
+ if (!string.IsNullOrWhiteSpace(station.FaviconUrl))
+ sb.AppendLine($"#EXTIMG:{station.FaviconUrl}");
+
+ sb.AppendLine(station.StreamUrl);
+ }
+
+ return sb.ToString();
+ }
+
+ ///
+ /// Exports radio stations to PLS format.
+ ///
+ public static string ExportToPls(IEnumerable stations)
+ {
+ List list = new(stations);
+ StringBuilder sb = new();
+ sb.AppendLine("[playlist]");
+ sb.AppendLine($"NumberOfEntries={list.Count}");
+
+ for (int i = 0; i < list.Count; i++)
+ {
+ int num = i + 1;
+ sb.AppendLine($"File{num}={list[i].StreamUrl}");
+ sb.AppendLine($"Title{num}={list[i].Name}");
+ sb.AppendLine($"Length{num}=-1");
+ }
+
+ sb.AppendLine("Version=2");
+ return sb.ToString();
+ }
+
+ private static List ParseM3u(string content)
+ {
+ List stations = [];
+ string[] lines = content.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries);
+
+ string? currentName = null;
+ string? currentHomepage = null;
+ string? currentFavicon = null;
+
+ foreach (string rawLine in lines)
+ {
+ string line = rawLine.Trim();
+
+ if (line.StartsWith("#EXTM3U", StringComparison.OrdinalIgnoreCase))
+ continue;
+
+ if (line.StartsWith("#EXTINF:", StringComparison.OrdinalIgnoreCase))
+ {
+ // Format: #EXTINF:-1,Station Name
+ int commaIndex = line.IndexOf(',');
+ if (commaIndex >= 0 && commaIndex < line.Length - 1)
+ currentName = line[(commaIndex + 1)..].Trim();
+
+ continue;
+ }
+
+ if (line.StartsWith("#EXTVLCOPT:url=", StringComparison.OrdinalIgnoreCase))
+ {
+ currentHomepage = line["#EXTVLCOPT:url=".Length..].Trim();
+ continue;
+ }
+
+ if (line.StartsWith("#EXTIMG:", StringComparison.OrdinalIgnoreCase))
+ {
+ currentFavicon = line["#EXTIMG:".Length..].Trim();
+ continue;
+ }
+
+ if (line.StartsWith('#'))
+ continue;
+
+ // This should be a URL line
+ if (!string.IsNullOrWhiteSpace(line))
+ {
+ string streamUrl = line;
+ string name = currentName ?? GetNameFromUrl(streamUrl);
+
+ stations.Add(new RadioStation
+ {
+ Name = name,
+ StreamUrl = streamUrl,
+ Homepage = currentHomepage,
+ FaviconUrl = currentFavicon,
+ });
+
+ currentName = null;
+ currentHomepage = null;
+ currentFavicon = null;
+ }
+ }
+
+ return stations;
+ }
+
+ private static List ParsePls(string content)
+ {
+ List stations = [];
+ string[] lines = content.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries);
+
+ Dictionary files = [];
+ Dictionary titles = [];
+
+ foreach (string rawLine in lines)
+ {
+ string line = rawLine.Trim();
+
+ if (line.StartsWith("File", StringComparison.OrdinalIgnoreCase))
+ {
+ (int index, string value) = ParsePlsEntry(line, "File");
+ if (index > 0)
+ files[index] = value;
+ }
+ else if (line.StartsWith("Title", StringComparison.OrdinalIgnoreCase))
+ {
+ (int index, string value) = ParsePlsEntry(line, "Title");
+ if (index > 0)
+ titles[index] = value;
+ }
+ }
+
+ foreach (KeyValuePair kvp in files)
+ {
+ string streamUrl = kvp.Value;
+ string name = titles.TryGetValue(kvp.Key, out string? title) && !string.IsNullOrWhiteSpace(title)
+ ? title
+ : GetNameFromUrl(streamUrl);
+
+ stations.Add(new RadioStation
+ {
+ Name = name,
+ StreamUrl = streamUrl,
+ });
+ }
+
+ return stations;
+ }
+
+ private static (int Index, string Value) ParsePlsEntry(string line, string prefix)
+ {
+ // Format: File1=http://... or Title1=Station Name
+ int eqIndex = line.IndexOf('=');
+ if (eqIndex < 0)
+ return (0, string.Empty);
+
+ string key = line[..eqIndex];
+ string value = line[(eqIndex + 1)..].Trim();
+
+ if (int.TryParse(key[prefix.Length..], out int index))
+ return (index, value);
+
+ return (0, string.Empty);
+ }
+
+ private static string GetNameFromUrl(string url)
+ {
+ try
+ {
+ Uri uri = new(url);
+ return uri.Host;
+ }
+ catch
+ {
+ return "Unknown Station";
+ }
+ }
+}
diff --git a/Trdo/Services/SettingsService.cs b/Trdo/Services/SettingsService.cs
index f193dbc..a16f265 100644
--- a/Trdo/Services/SettingsService.cs
+++ b/Trdo/Services/SettingsService.cs
@@ -8,6 +8,46 @@ namespace Trdo.Services;
public static class SettingsService
{
private const string IsFirstRunKey = "IsFirstRun";
+ private const string IsVolumeSliderVisibleKey = "IsVolumeSliderVisible";
+
+ ///
+ /// Gets or sets whether the volume slider is visible on the playing page.
+ /// Defaults to true when no saved value exists.
+ ///
+ public static bool IsVolumeSliderVisible
+ {
+ get
+ {
+ try
+ {
+ if (ApplicationData.Current.LocalSettings.Values.TryGetValue(IsVolumeSliderVisibleKey, out object? value))
+ {
+ return value switch
+ {
+ bool b => b,
+ string s when bool.TryParse(s, out bool b2) => b2,
+ _ => true
+ };
+ }
+ return true;
+ }
+ catch
+ {
+ return true;
+ }
+ }
+ set
+ {
+ try
+ {
+ ApplicationData.Current.LocalSettings.Values[IsVolumeSliderVisibleKey] = value;
+ }
+ catch
+ {
+ // Silently fail if unable to save
+ }
+ }
+ }
///
/// Gets whether this is the first run of the application
diff --git a/Trdo/Services/StreamWatchdogService.cs b/Trdo/Services/StreamWatchdogService.cs
index 31c5614..786ca8b 100644
--- a/Trdo/Services/StreamWatchdogService.cs
+++ b/Trdo/Services/StreamWatchdogService.cs
@@ -12,10 +12,11 @@ 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
+public sealed partial class StreamWatchdogService : IDisposable
{
private readonly RadioPlayerService _playerService;
private readonly DispatcherQueue _uiQueue;
+ private readonly AudioSilenceMonitorService _silenceMonitor;
private CancellationTokenSource? _cts;
private Task? _monitoringTask;
private bool _isEnabled;
@@ -25,7 +26,7 @@ public sealed class StreamWatchdogService : IDisposable
private TimeSpan _lastPosition;
private DateTime _lastPositionChangeTime;
private double _lastBufferingProgress;
- private int _consecutiveSilentChecks;
+ private volatile bool _isRecovering;
// Stutter detection tracking
private readonly Queue _recoveryAttempts = new();
@@ -33,14 +34,14 @@ public sealed class StreamWatchdogService : IDisposable
private double _currentBufferLevel;
private const string AutoBufferIncreaseKey = "AutoBufferIncreaseEnabled";
private const string BufferLevelKey = "BufferLevel";
+ private const string SilenceTimeoutKey = "SilenceTimeoutSeconds";
+ private const double DefaultSilenceTimeoutSeconds = 5.0;
// Configuration
private readonly TimeSpan _checkInterval = TimeSpan.FromSeconds(5);
private readonly TimeSpan _recoveryDelay = TimeSpan.FromSeconds(3);
private readonly int _maxConsecutiveFailures = 3;
private readonly TimeSpan _backoffDelay = TimeSpan.FromSeconds(30);
- 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
@@ -53,6 +54,30 @@ public sealed class StreamWatchdogService : IDisposable
public event EventHandler? StutterDetected;
public event EventHandler? BufferLevelChanged;
+ ///
+ /// Raised periodically with the current audio output RMS level (0–1 scale).
+ /// Forwarded from the NAudio silence monitor for UI visualisation.
+ /// Fires on a background thread — marshal to the UI thread before touching XAML.
+ ///
+ public event Action? AudioLevelUpdated;
+
+ ///
+ /// Gets or sets the silence detection timeout in seconds.
+ /// If the audio output is silent for longer than this while the stream is supposed to be playing,
+ /// a recovery attempt is triggered. Persisted to local settings.
+ ///
+ public double SilenceTimeoutSeconds
+ {
+ get => _silenceMonitor.SilenceTimeoutSeconds;
+ set
+ {
+ if (Math.Abs(_silenceMonitor.SilenceTimeoutSeconds - value) < 0.01) return;
+ _silenceMonitor.SilenceTimeoutSeconds = value;
+ SaveSilenceTimeoutSetting();
+ Debug.WriteLine($"[Watchdog] Silence timeout set to: {value}s");
+ }
+ }
+
public bool IsEnabled
{
get => _isEnabled;
@@ -150,10 +175,15 @@ public StreamWatchdogService(RadioPlayerService playerService)
_lastPosition = TimeSpan.Zero;
_lastPositionChangeTime = DateTime.UtcNow;
_lastBufferingProgress = 0;
- _consecutiveSilentChecks = 0;
- // Load auto-buffer settings
+ // Initialize NAudio silence monitor
+ _silenceMonitor = new AudioSilenceMonitorService();
+ _silenceMonitor.SilenceDetected += OnSilenceDetected;
+ _silenceMonitor.AudioLevelUpdated += OnAudioLevelFromMonitor;
+
+ // Load settings
LoadAutoBufferSettings();
+ LoadSilenceTimeoutSetting();
}
private void LoadAutoBufferSettings()
@@ -213,6 +243,47 @@ private void SaveAutoBufferSettings()
}
}
+ private void LoadSilenceTimeoutSetting()
+ {
+ try
+ {
+ if (ApplicationData.Current.LocalSettings.Values.TryGetValue(SilenceTimeoutKey, out object? value))
+ {
+ double timeout = value switch
+ {
+ double d => d,
+ int i => (double)i,
+ string s when double.TryParse(s, out double d2) => d2,
+ _ => DefaultSilenceTimeoutSeconds
+ };
+ _silenceMonitor.SilenceTimeoutSeconds = Math.Clamp(timeout, 1.0, 60.0);
+ }
+ else
+ {
+ _silenceMonitor.SilenceTimeoutSeconds = DefaultSilenceTimeoutSeconds;
+ }
+ Debug.WriteLine($"[Watchdog] Loaded silence timeout: {_silenceMonitor.SilenceTimeoutSeconds}s");
+ }
+ catch (Exception ex)
+ {
+ Debug.WriteLine($"[Watchdog] Error loading silence timeout: {ex.Message}");
+ _silenceMonitor.SilenceTimeoutSeconds = DefaultSilenceTimeoutSeconds;
+ }
+ }
+
+ private void SaveSilenceTimeoutSetting()
+ {
+ try
+ {
+ ApplicationData.Current.LocalSettings.Values[SilenceTimeoutKey] = _silenceMonitor.SilenceTimeoutSeconds;
+ Debug.WriteLine($"[Watchdog] Saved silence timeout: {_silenceMonitor.SilenceTimeoutSeconds}s");
+ }
+ catch (Exception ex)
+ {
+ Debug.WriteLine($"[Watchdog] Error saving silence timeout: {ex.Message}");
+ }
+ }
+
///
/// Notify the watchdog that the user intentionally started playback.
///
@@ -220,8 +291,8 @@ public void NotifyUserIntentionToPlay()
{
_userIntendedPlayback = true;
_consecutiveFailures = 0;
- _consecutiveSilentChecks = 0;
_lastPositionChangeTime = DateTime.UtcNow;
+ StartSilenceMonitor();
Debug.WriteLine("[Watchdog] User started playback - monitoring active");
}
@@ -232,7 +303,7 @@ public void NotifyUserIntentionToPause()
{
_userIntendedPlayback = false;
_consecutiveFailures = 0;
- _consecutiveSilentChecks = 0;
+ StopSilenceMonitor();
Debug.WriteLine("[Watchdog] User paused playback - recovery disabled");
}
@@ -246,10 +317,12 @@ public void Start()
_cts = new CancellationTokenSource();
_consecutiveFailures = 0;
- _consecutiveSilentChecks = 0;
_lastPositionChangeTime = DateTime.UtcNow;
_monitoringTask = Task.Run(() => MonitorStreamAsync(_cts.Token));
+ if (_userIntendedPlayback)
+ StartSilenceMonitor();
+
RaiseStatusChanged("Watchdog started", StreamWatchdogStatus.Monitoring);
}
@@ -261,6 +334,7 @@ public void Stop()
_cts?.Cancel();
_monitoringTask = null;
_userIntendedPlayback = false;
+ StopSilenceMonitor();
RaiseStatusChanged("Watchdog stopped", StreamWatchdogStatus.Stopped);
}
@@ -334,7 +408,6 @@ await RunOnUiThreadAsync(() =>
if (timeSinceLastCheck > _checkInterval)
{
_consecutiveFailures++;
- _consecutiveSilentChecks = 0; // Reset silent checks when not playing
Debug.WriteLine($"[Watchdog] Stream stopped unexpectedly. Attempt {_consecutiveFailures}/{_maxConsecutiveFailures}");
RaiseStatusChanged($"Stream stopped. Recovery attempt {_consecutiveFailures}/{_maxConsecutiveFailures}",
@@ -360,55 +433,30 @@ await RunOnUiThreadAsync(() =>
return;
}
- // Stream is playing - check if audio is actually progressing
- // Don't overwrite user intention if they manually started
+ // Stream is playing - NAudio silence monitor handles actual audio detection
if (!_userIntendedPlayback)
{
_userIntendedPlayback = true;
- Debug.WriteLine("[Watchdog] Stream is playing - monitoring active");
+ StartSilenceMonitor();
+ Debug.WriteLine("[Watchdog] Stream is playing - silence monitoring active");
}
_consecutiveFailures = 0;
- // Check if position or buffering progress has changed
+ // Log position/buffering for debugging (silence detection is handled by NAudio)
bool positionChanged = currentPosition != _lastPosition;
bool bufferingProgressChanged = Math.Abs(currentBufferingProgress - _lastBufferingProgress) > 0.01;
if (positionChanged || bufferingProgressChanged)
{
- // Stream is healthy - audio is progressing
_lastPosition = currentPosition;
_lastBufferingProgress = currentBufferingProgress;
_lastPositionChangeTime = DateTime.UtcNow;
- _consecutiveSilentChecks = 0;
Debug.WriteLine($"[Watchdog] Stream healthy - Position: {currentPosition}, Buffering: {currentBufferingProgress:P0}");
}
else
{
- // Position hasn't changed - potential silent stream
- TimeSpan silenceDuration = DateTime.UtcNow - _lastPositionChangeTime;
-
- if (silenceDuration > _silenceDetectionThreshold)
- {
- _consecutiveSilentChecks++;
- Debug.WriteLine($"[Watchdog] Silent stream detected for {silenceDuration.TotalSeconds:F1}s. Check {_consecutiveSilentChecks}/{_maxConsecutiveSilentChecks}");
-
- if (_consecutiveSilentChecks >= _maxConsecutiveSilentChecks)
- {
- // Stream is playing but no audio for too long - attempt recovery
- Debug.WriteLine("[Watchdog] Stream is silent - attempting recovery");
- RaiseStatusChanged("Stream is silent - refreshing", StreamWatchdogStatus.Recovering);
-
- _consecutiveSilentChecks = 0;
- _lastPositionChangeTime = DateTime.UtcNow;
-
- await AttemptRecoveryAsync(cancellationToken);
- }
- }
- else
- {
- // Within threshold, keep monitoring
- Debug.WriteLine($"[Watchdog] Position unchanged for {silenceDuration.TotalSeconds:F1}s (threshold: {_silenceDetectionThreshold.TotalSeconds}s)");
- }
+ TimeSpan stalledDuration = DateTime.UtcNow - _lastPositionChangeTime;
+ Debug.WriteLine($"[Watchdog] Position unchanged for {stalledDuration.TotalSeconds:F1}s (NAudio silence monitor active)");
}
_lastStateCheck = DateTime.UtcNow;
@@ -550,6 +598,63 @@ public void ResetBufferLevel()
Debug.WriteLine("[Watchdog] Buffer level reset to default");
}
+ ///
+ /// Starts the NAudio silence monitor if the watchdog is enabled and user intends playback.
+ ///
+ private void StartSilenceMonitor()
+ {
+ if (_isEnabled && _userIntendedPlayback)
+ {
+ _silenceMonitor.Start();
+ }
+ }
+
+ ///
+ /// Stops the NAudio silence monitor.
+ ///
+ private void StopSilenceMonitor()
+ {
+ _silenceMonitor.Stop();
+ }
+
+ private void OnAudioLevelFromMonitor(float level) => AudioLevelUpdated?.Invoke(level);
+
+ ///
+ /// Called by the NAudio silence monitor when audio output has been silent
+ /// for longer than .
+ ///
+ private async void OnSilenceDetected(object? sender, EventArgs e)
+ {
+ if (!_isEnabled || !_userIntendedPlayback || _isRecovering)
+ return;
+
+ try
+ {
+ _isRecovering = true;
+ StopSilenceMonitor();
+
+ Debug.WriteLine("[Watchdog] NAudio silence detected - attempting stream recovery");
+ RaiseStatusChanged("Stream is silent - refreshing", StreamWatchdogStatus.Recovering);
+
+ CancellationToken token = _cts?.Token ?? CancellationToken.None;
+ await AttemptRecoveryAsync(token);
+ }
+ catch (OperationCanceledException)
+ {
+ Debug.WriteLine("[Watchdog] Silence recovery cancelled");
+ }
+ catch (Exception ex)
+ {
+ Debug.WriteLine($"[Watchdog] Error during silence recovery: {ex.Message}");
+ RaiseStatusChanged($"Recovery error: {ex.Message}", StreamWatchdogStatus.Error);
+ }
+ finally
+ {
+ _isRecovering = false;
+ StartSilenceMonitor();
+ }
+ }
+
private Task RunOnUiThreadAsync(Action action)
{
TaskCompletionSource tcs = new();
@@ -639,6 +744,9 @@ private void RaiseBufferLevelChanged(double newLevel)
public void Dispose()
{
Stop();
+ _silenceMonitor.SilenceDetected -= OnSilenceDetected;
+ _silenceMonitor.AudioLevelUpdated -= OnAudioLevelFromMonitor;
+ _silenceMonitor.Dispose();
_cts?.Dispose();
}
}
diff --git a/Trdo/Trdo.csproj b/Trdo/Trdo.csproj
index 326d4c2..a5f24ad 100644
--- a/Trdo/Trdo.csproj
+++ b/Trdo/Trdo.csproj
@@ -56,8 +56,9 @@
-
-
+
+
+
diff --git a/Trdo/ViewModels/PlayerViewModel.cs b/Trdo/ViewModels/PlayerViewModel.cs
index 466242a..53f21ff 100644
--- a/Trdo/ViewModels/PlayerViewModel.cs
+++ b/Trdo/ViewModels/PlayerViewModel.cs
@@ -302,6 +302,22 @@ public double BufferLevel
///
public string BufferLevelDescription => _player.Watchdog.BufferLevelDescription;
+ ///
+ /// Gets or sets the silence detection timeout in seconds.
+ /// If audio is silent for longer than this, the stream will be restarted.
+ ///
+ public double SilenceTimeoutSeconds
+ {
+ get => _player.Watchdog.SilenceTimeoutSeconds;
+ set
+ {
+ if (Math.Abs(value - _player.Watchdog.SilenceTimeoutSeconds) < 0.01) return;
+ Debug.WriteLine($"[PlayerViewModel] Setting SilenceTimeoutSeconds to {value}");
+ _player.Watchdog.SilenceTimeoutSeconds = value;
+ OnPropertyChanged();
+ }
+ }
+
public string WatchdogStatus
{
get => _watchdogStatus;
diff --git a/Trdo/ViewModels/SettingsViewModel.cs b/Trdo/ViewModels/SettingsViewModel.cs
index a424963..213852c 100644
--- a/Trdo/ViewModels/SettingsViewModel.cs
+++ b/Trdo/ViewModels/SettingsViewModel.cs
@@ -1,12 +1,18 @@
using System;
+using System.Collections.Generic;
using System.ComponentModel;
+using System.IO;
using System.Runtime.CompilerServices;
using System.Threading.Tasks;
+using Trdo.Models;
+using Trdo.Services;
using Windows.ApplicationModel;
+using Windows.Storage;
+using Windows.Storage.Pickers;
namespace Trdo.ViewModels;
-public class SettingsViewModel : INotifyPropertyChanged
+public partial class SettingsViewModel : INotifyPropertyChanged
{
private readonly PlayerViewModel _playerViewModel;
private bool _isStartupEnabled;
@@ -41,6 +47,11 @@ public SettingsViewModel()
OnPropertyChanged(nameof(BufferLevel));
OnPropertyChanged(nameof(BufferLevelDescription));
}
+ else if (args.PropertyName == nameof(PlayerViewModel.SilenceTimeoutSeconds))
+ {
+ OnPropertyChanged(nameof(SilenceTimeoutSeconds));
+ OnPropertyChanged(nameof(SilenceTimeoutDisplay));
+ }
};
// Initialize toggle text
@@ -161,6 +172,27 @@ public double BufferLevel
///
public string BufferLevelDescription => _playerViewModel.BufferLevelDescription;
+ ///
+ /// Gets or sets the silence detection timeout in seconds.
+ /// If audio is silent for longer than this, the stream will be restarted.
+ ///
+ public double SilenceTimeoutSeconds
+ {
+ get => _playerViewModel.SilenceTimeoutSeconds;
+ set
+ {
+ if (Math.Abs(value - _playerViewModel.SilenceTimeoutSeconds) < 0.01) return;
+ _playerViewModel.SilenceTimeoutSeconds = value;
+ OnPropertyChanged();
+ OnPropertyChanged(nameof(SilenceTimeoutDisplay));
+ }
+ }
+
+ ///
+ /// Gets a formatted display string for the current silence timeout value.
+ ///
+ public string SilenceTimeoutDisplay => $"{SilenceTimeoutSeconds:0}s";
+
private async Task InitializeStartupTaskAsync()
{
try
@@ -236,6 +268,87 @@ private async Task ApplyStartupStateAsync(bool enable)
UpdateStartupStateFromTask();
}
+ ///
+ /// Imports radio stations from a playlist file (M3U, M3U8, or PLS).
+ ///
+ public async Task ImportStationsAsync(nint windowHandle)
+ {
+ FileOpenPicker picker = new();
+ WinRT.Interop.InitializeWithWindow.Initialize(picker, windowHandle);
+ picker.SuggestedStartLocation = PickerLocationId.MusicLibrary;
+ picker.FileTypeFilter.Add(".m3u");
+ picker.FileTypeFilter.Add(".m3u8");
+ picker.FileTypeFilter.Add(".pls");
+
+ StorageFile? file = await picker.PickSingleFileAsync();
+ if (file is null)
+ return 0;
+
+ string content = await FileIO.ReadTextAsync(file);
+ List imported = PlaylistImportExportService.ImportFromFile(file.Path, content);
+
+ if (imported.Count == 0)
+ return 0;
+
+ PlayerViewModel player = PlayerViewModel.Shared;
+ int addedCount = 0;
+ foreach (RadioStation station in imported)
+ {
+ bool alreadyExists = false;
+ foreach (RadioStation existing in player.Stations)
+ {
+ if (string.Equals(existing.Name, station.Name, StringComparison.Ordinal) &&
+ string.Equals(existing.StreamUrl, station.StreamUrl, StringComparison.Ordinal) &&
+ string.Equals(existing.Homepage, station.Homepage, StringComparison.Ordinal) &&
+ string.Equals(existing.FaviconUrl, station.FaviconUrl, StringComparison.Ordinal))
+ {
+ alreadyExists = true;
+ break;
+ }
+ }
+
+ if (!alreadyExists)
+ {
+ player.Stations.Add(station);
+ addedCount++;
+ }
+ }
+
+ if (addedCount > 0)
+ RadioStationService.Instance.SaveStations(player.Stations);
+
+ return addedCount;
+ }
+
+ ///
+ /// Exports all radio stations to a playlist file (M3U, M3U8, or PLS).
+ ///
+ public async Task ExportStationsAsync(nint windowHandle)
+ {
+ PlayerViewModel player = PlayerViewModel.Shared;
+ if (player.Stations.Count == 0)
+ return false;
+
+ FileSavePicker picker = new();
+ WinRT.Interop.InitializeWithWindow.Initialize(picker, windowHandle);
+ picker.SuggestedStartLocation = PickerLocationId.MusicLibrary;
+ picker.SuggestedFileName = "Trdo Stations";
+ picker.FileTypeChoices.Add("M3U Playlist", [".m3u"]);
+ picker.FileTypeChoices.Add("PLS Playlist", [".pls"]);
+
+ StorageFile? file = await picker.PickSaveFileAsync();
+ if (file is null)
+ return false;
+
+ string extension = Path.GetExtension(file.Name).ToLowerInvariant();
+ string content = extension == ".pls"
+ ? PlaylistImportExportService.ExportToPls(player.Stations)
+ : PlaylistImportExportService.ExportToM3u(player.Stations);
+
+ await FileIO.WriteTextAsync(file, content);
+ return true;
+ }
+
protected void OnPropertyChanged([CallerMemberName] string? propertyName = null)
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
diff --git a/nuget.config b/nuget.config
new file mode 100644
index 0000000..a610900
--- /dev/null
+++ b/nuget.config
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+