From f91752cd3d1f46399caabbdfa4a8f768742a2f06 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Thu, 4 Dec 2025 20:35:34 +0000
Subject: [PATCH 1/9] Initial plan
From c5a95057a6024b148c72da735f4b95109a9f9877 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Thu, 4 Dec 2025 20:45:30 +0000
Subject: [PATCH 2/9] Add stream metadata service to fetch ICY metadata (now
playing info)
Co-authored-by: TheJoeFin <7809853+TheJoeFin@users.noreply.github.com>
---
.../BooleanToVisibilityConverter.cs | 25 ++
Trdo/Models/StreamMetadata.cs | 52 +++
Trdo/Pages/PlayingPage.xaml | 30 +-
Trdo/Services/RadioPlayerService.cs | 29 ++
Trdo/Services/StreamMetadataService.cs | 374 ++++++++++++++++++
Trdo/ViewModels/PlayerViewModel.cs | 24 ++
6 files changed, 532 insertions(+), 2 deletions(-)
create mode 100644 Trdo/Converters/BooleanToVisibilityConverter.cs
create mode 100644 Trdo/Models/StreamMetadata.cs
create mode 100644 Trdo/Services/StreamMetadataService.cs
diff --git a/Trdo/Converters/BooleanToVisibilityConverter.cs b/Trdo/Converters/BooleanToVisibilityConverter.cs
new file mode 100644
index 0000000..1af7a31
--- /dev/null
+++ b/Trdo/Converters/BooleanToVisibilityConverter.cs
@@ -0,0 +1,25 @@
+using Microsoft.UI.Xaml;
+using Microsoft.UI.Xaml.Data;
+using System;
+
+namespace Trdo.Converters;
+
+///
+/// Converts a boolean value to Visibility. Returns Visible when true, Collapsed when false.
+///
+public class BooleanToVisibilityConverter : IValueConverter
+{
+ public object Convert(object value, Type targetType, object parameter, string language)
+ {
+ if (value is bool boolValue)
+ {
+ return boolValue ? Visibility.Visible : Visibility.Collapsed;
+ }
+ return Visibility.Collapsed;
+ }
+
+ public object ConvertBack(object value, Type targetType, object parameter, string language)
+ {
+ throw new NotImplementedException();
+ }
+}
diff --git a/Trdo/Models/StreamMetadata.cs b/Trdo/Models/StreamMetadata.cs
new file mode 100644
index 0000000..5023c2e
--- /dev/null
+++ b/Trdo/Models/StreamMetadata.cs
@@ -0,0 +1,52 @@
+namespace Trdo.Models;
+
+///
+/// Represents metadata extracted from an internet radio stream, typically from ICY (Icecast/Shoutcast) protocol.
+///
+public class StreamMetadata
+{
+ ///
+ /// The full stream title string, typically containing song and artist info.
+ /// Format is usually "Artist - Title" or similar.
+ ///
+ public string StreamTitle { get; set; } = string.Empty;
+
+ ///
+ /// The artist name, if available.
+ ///
+ public string Artist { get; set; } = string.Empty;
+
+ ///
+ /// The song/track title, if available.
+ ///
+ public string Title { get; set; } = string.Empty;
+
+ ///
+ /// Indicates whether any meaningful metadata was found.
+ ///
+ public bool HasMetadata => !string.IsNullOrWhiteSpace(StreamTitle) ||
+ !string.IsNullOrWhiteSpace(Artist) ||
+ !string.IsNullOrWhiteSpace(Title);
+
+ ///
+ /// Gets a display-friendly string for the now playing information.
+ ///
+ public string DisplayText
+ {
+ get
+ {
+ if (!string.IsNullOrWhiteSpace(Artist) && !string.IsNullOrWhiteSpace(Title))
+ return $"{Artist} - {Title}";
+
+ if (!string.IsNullOrWhiteSpace(StreamTitle))
+ return StreamTitle;
+
+ return string.Empty;
+ }
+ }
+
+ ///
+ /// Creates a new StreamMetadata with no data.
+ ///
+ public static StreamMetadata Empty => new();
+}
diff --git a/Trdo/Pages/PlayingPage.xaml b/Trdo/Pages/PlayingPage.xaml
index ea66940..d49c123 100644
--- a/Trdo/Pages/PlayingPage.xaml
+++ b/Trdo/Pages/PlayingPage.xaml
@@ -11,9 +11,10 @@
+
-
+
@@ -201,8 +202,33 @@
Value="{x:Bind ViewModel.Volume, Mode=TwoWay}" />
+
+
+
+
+
+
+
+
-
+
diff --git a/Trdo/Services/RadioPlayerService.cs b/Trdo/Services/RadioPlayerService.cs
index ca8ab21..2f3280e 100644
--- a/Trdo/Services/RadioPlayerService.cs
+++ b/Trdo/Services/RadioPlayerService.cs
@@ -1,6 +1,7 @@
using Microsoft.UI.Dispatching;
using System;
using System.Diagnostics;
+using Trdo.Models;
using Windows.Media.Core;
using Windows.Media.Playback;
using Windows.Storage;
@@ -12,6 +13,7 @@ public sealed partial class RadioPlayerService : IDisposable
private readonly MediaPlayer _player;
private readonly DispatcherQueue _uiQueue;
private readonly StreamWatchdogService _watchdog;
+ private readonly StreamMetadataService _metadataService;
private double _volume = 0.5;
private const string VolumeKey = "RadioVolume";
private const string WatchdogEnabledKey = "WatchdogEnabled";
@@ -23,6 +25,7 @@ public sealed partial class RadioPlayerService : IDisposable
public event EventHandler? PlaybackStateChanged;
public event EventHandler? VolumeChanged;
public event EventHandler? BufferingStateChanged;
+ public event EventHandler? StreamMetadataChanged;
public bool IsPlaying
{
@@ -101,6 +104,11 @@ public TimeSpan Position
public StreamWatchdogService Watchdog => _watchdog;
+ ///
+ /// Gets the current stream metadata (now playing information).
+ ///
+ public StreamMetadata CurrentMetadata => _metadataService.CurrentMetadata;
+
public double Volume
{
get => _volume;
@@ -198,6 +206,14 @@ private RadioPlayerService()
_watchdog = new StreamWatchdogService(this);
Debug.WriteLine("[RadioPlayerService] StreamWatchdogService created");
+ _metadataService = new StreamMetadataService();
+ _metadataService.MetadataChanged += (_, metadata) =>
+ {
+ Debug.WriteLine($"[RadioPlayerService] Metadata changed: {metadata.DisplayText}");
+ TryEnqueueOnUi(() => StreamMetadataChanged?.Invoke(this, metadata));
+ };
+ Debug.WriteLine("[RadioPlayerService] StreamMetadataService created");
+
LoadSettings();
Debug.WriteLine("=== RadioPlayerService Constructor END ===");
@@ -349,6 +365,10 @@ public void Play()
_watchdog.NotifyUserIntentionToPlay();
Debug.WriteLine("[RadioPlayerService] Notified watchdog of user intention to play");
+
+ // Start metadata polling
+ _metadataService.StartPolling(_streamUrl);
+ Debug.WriteLine("[RadioPlayerService] Started metadata polling");
}
catch (Exception ex)
{
@@ -370,6 +390,10 @@ public void Play()
_watchdog.NotifyUserIntentionToPlay();
Debug.WriteLine("[RadioPlayerService] Notified watchdog of user intention to play");
+
+ // Start metadata polling
+ _metadataService.StartPolling(_streamUrl);
+ Debug.WriteLine("[RadioPlayerService] Started metadata polling (retry)");
}
catch (Exception retryEx)
{
@@ -420,6 +444,10 @@ public void Pause()
_watchdog.NotifyUserIntentionToPause();
Debug.WriteLine("[RadioPlayerService] Notified watchdog of user intention to pause");
+ // Stop metadata polling
+ _metadataService.StopPolling();
+ Debug.WriteLine("[RadioPlayerService] Stopped metadata polling");
+
// DO NOT prepare the stream here - let Play() or SetStreamUrl() handle it
// The previous code was creating a MediaSource with the current URL,
// but if the user then selects a different station, the MediaSource
@@ -481,6 +509,7 @@ public void Dispose()
{
Debug.WriteLine("[RadioPlayerService] Dispose called");
_watchdog.Dispose();
+ _metadataService.Dispose();
if (_player.Source is MediaSource media)
{
diff --git a/Trdo/Services/StreamMetadataService.cs b/Trdo/Services/StreamMetadataService.cs
new file mode 100644
index 0000000..66aa764
--- /dev/null
+++ b/Trdo/Services/StreamMetadataService.cs
@@ -0,0 +1,374 @@
+using System;
+using System.Diagnostics;
+using System.IO;
+using System.Net.Http;
+using System.Text;
+using System.Threading;
+using System.Threading.Tasks;
+using Trdo.Models;
+
+namespace Trdo.Services;
+
+///
+/// Service for extracting metadata from internet radio streams using the ICY (Icecast/Shoutcast) protocol.
+///
+public sealed class StreamMetadataService : IDisposable
+{
+ private readonly HttpClient _httpClient;
+ private CancellationTokenSource? _pollingCts;
+ private Task? _pollingTask;
+ private string? _currentStreamUrl;
+ private StreamMetadata _currentMetadata = StreamMetadata.Empty;
+ private bool _isDisposed;
+
+ ///
+ /// Polling interval for metadata updates.
+ ///
+ private readonly TimeSpan _pollingInterval = TimeSpan.FromSeconds(15);
+
+ ///
+ /// Event raised when stream metadata changes.
+ ///
+ public event EventHandler? MetadataChanged;
+
+ ///
+ /// Gets the current stream metadata.
+ ///
+ public StreamMetadata CurrentMetadata => _currentMetadata;
+
+ public StreamMetadataService()
+ {
+ SocketsHttpHandler handler = new()
+ {
+ UseProxy = false,
+ AutomaticDecompression = System.Net.DecompressionMethods.None,
+ // Short timeout for metadata requests since we only need headers
+ ConnectTimeout = TimeSpan.FromSeconds(10)
+ };
+
+ _httpClient = new HttpClient(handler)
+ {
+ Timeout = TimeSpan.FromSeconds(15)
+ };
+
+ // Request ICY metadata by adding the required header
+ _httpClient.DefaultRequestHeaders.Add("Icy-MetaData", "1");
+ _httpClient.DefaultRequestHeaders.Add("User-Agent", "Trdo/1.0");
+ }
+
+ ///
+ /// Starts polling for metadata from the specified stream URL.
+ ///
+ public void StartPolling(string streamUrl)
+ {
+ if (string.IsNullOrWhiteSpace(streamUrl))
+ {
+ Debug.WriteLine("[StreamMetadataService] Cannot start polling with empty URL");
+ return;
+ }
+
+ // Stop any existing polling
+ StopPolling();
+
+ _currentStreamUrl = streamUrl;
+ _pollingCts = new CancellationTokenSource();
+
+ Debug.WriteLine($"[StreamMetadataService] Starting metadata polling for: {streamUrl}");
+
+ _pollingTask = Task.Run(async () =>
+ {
+ // Fetch initial metadata immediately
+ await FetchMetadataAsync(_pollingCts.Token);
+
+ // Then poll periodically
+ while (!_pollingCts.Token.IsCancellationRequested)
+ {
+ try
+ {
+ await Task.Delay(_pollingInterval, _pollingCts.Token);
+ await FetchMetadataAsync(_pollingCts.Token);
+ }
+ catch (OperationCanceledException)
+ {
+ break;
+ }
+ catch (Exception ex)
+ {
+ Debug.WriteLine($"[StreamMetadataService] Polling error: {ex.Message}");
+ }
+ }
+ });
+ }
+
+ ///
+ /// Stops polling for metadata.
+ ///
+ public void StopPolling()
+ {
+ Debug.WriteLine("[StreamMetadataService] Stopping metadata polling");
+ _pollingCts?.Cancel();
+ _pollingCts?.Dispose();
+ _pollingCts = null;
+ _pollingTask = null;
+ _currentStreamUrl = null;
+
+ // Clear metadata when stopping
+ UpdateMetadata(StreamMetadata.Empty);
+ }
+
+ ///
+ /// Fetches metadata from the current stream URL.
+ ///
+ private async Task FetchMetadataAsync(CancellationToken cancellationToken)
+ {
+ if (string.IsNullOrWhiteSpace(_currentStreamUrl))
+ {
+ return;
+ }
+
+ try
+ {
+ using HttpRequestMessage request = new(HttpMethod.Get, _currentStreamUrl);
+
+ // Request only partial content to minimize bandwidth
+ using HttpResponseMessage response = await _httpClient.SendAsync(
+ request,
+ HttpCompletionOption.ResponseHeadersRead,
+ cancellationToken);
+
+ if (!response.IsSuccessStatusCode)
+ {
+ Debug.WriteLine($"[StreamMetadataService] HTTP {(int)response.StatusCode} from stream");
+ return;
+ }
+
+ // Check for ICY metadata interval header
+ int metaInterval = 0;
+ if (response.Headers.TryGetValues("icy-metaint", out var metaIntValues))
+ {
+ foreach (var metaIntStr in metaIntValues)
+ {
+ if (int.TryParse(metaIntStr, out int parsed))
+ {
+ metaInterval = parsed;
+ break;
+ }
+ }
+ }
+
+ // Also check for ICY metadata interval in content headers (some servers)
+ if (metaInterval == 0 && response.Content.Headers.TryGetValues("icy-metaint", out var contentMetaIntValues))
+ {
+ foreach (var val in contentMetaIntValues)
+ {
+ if (int.TryParse(val, out int parsed))
+ {
+ metaInterval = parsed;
+ break;
+ }
+ }
+ }
+
+ Debug.WriteLine($"[StreamMetadataService] icy-metaint: {metaInterval}");
+
+ if (metaInterval <= 0)
+ {
+ // No ICY metadata support
+ Debug.WriteLine("[StreamMetadataService] Stream does not support ICY metadata");
+ return;
+ }
+
+ // Read stream data to find metadata
+ using Stream stream = await response.Content.ReadAsStreamAsync(cancellationToken);
+ StreamMetadata? metadata = await ReadIcyMetadataAsync(stream, metaInterval, cancellationToken);
+
+ if (metadata is not null && metadata.HasMetadata)
+ {
+ UpdateMetadata(metadata);
+ }
+ }
+ catch (OperationCanceledException)
+ {
+ throw; // Re-throw cancellation
+ }
+ catch (HttpRequestException ex)
+ {
+ Debug.WriteLine($"[StreamMetadataService] HTTP error: {ex.Message}");
+ }
+ catch (Exception ex)
+ {
+ Debug.WriteLine($"[StreamMetadataService] Error fetching metadata: {ex.Message}");
+ }
+ }
+
+ ///
+ /// Reads ICY metadata from the stream.
+ ///
+ private static async Task ReadIcyMetadataAsync(
+ Stream stream,
+ int metaInterval,
+ CancellationToken cancellationToken)
+ {
+ try
+ {
+ // Read and discard audio data until we reach the metadata
+ byte[] audioBuffer = new byte[metaInterval];
+ int totalRead = 0;
+
+ while (totalRead < metaInterval)
+ {
+ int bytesRead = await stream.ReadAsync(
+ audioBuffer.AsMemory(totalRead, metaInterval - totalRead),
+ cancellationToken);
+
+ if (bytesRead == 0)
+ {
+ Debug.WriteLine("[StreamMetadataService] Stream ended before metadata");
+ return null;
+ }
+
+ totalRead += bytesRead;
+ }
+
+ // Read metadata length byte (multiply by 16 to get actual length)
+ int metaLengthByte = stream.ReadByte();
+ if (metaLengthByte < 0)
+ {
+ Debug.WriteLine("[StreamMetadataService] Could not read metadata length");
+ return null;
+ }
+
+ int metaLength = metaLengthByte * 16;
+ if (metaLength == 0)
+ {
+ // No metadata in this block
+ Debug.WriteLine("[StreamMetadataService] No metadata in current block");
+ return null;
+ }
+
+ // Read metadata
+ byte[] metaBuffer = new byte[metaLength];
+ totalRead = 0;
+
+ while (totalRead < metaLength)
+ {
+ int bytesRead = await stream.ReadAsync(
+ metaBuffer.AsMemory(totalRead, metaLength - totalRead),
+ cancellationToken);
+
+ if (bytesRead == 0)
+ {
+ Debug.WriteLine("[StreamMetadataService] Stream ended before full metadata");
+ return null;
+ }
+
+ totalRead += bytesRead;
+ }
+
+ // Parse metadata string (null-terminated, padded with zeros)
+ string metadataStr = Encoding.UTF8.GetString(metaBuffer).TrimEnd('\0');
+ Debug.WriteLine($"[StreamMetadataService] Raw metadata: {metadataStr}");
+
+ return ParseIcyMetadata(metadataStr);
+ }
+ catch (Exception ex)
+ {
+ Debug.WriteLine($"[StreamMetadataService] Error reading ICY metadata: {ex.Message}");
+ return null;
+ }
+ }
+
+ ///
+ /// Parses an ICY metadata string into a StreamMetadata object.
+ /// Format is typically: StreamTitle='Artist - Song';StreamUrl='...';
+ ///
+ private static StreamMetadata ParseIcyMetadata(string metadataStr)
+ {
+ StreamMetadata metadata = new();
+
+ if (string.IsNullOrWhiteSpace(metadataStr))
+ {
+ return metadata;
+ }
+
+ // Extract StreamTitle
+ const string streamTitleKey = "StreamTitle='";
+ int titleStart = metadataStr.IndexOf(streamTitleKey, StringComparison.OrdinalIgnoreCase);
+
+ if (titleStart >= 0)
+ {
+ titleStart += streamTitleKey.Length;
+ int titleEnd = metadataStr.IndexOf("';", titleStart, StringComparison.Ordinal);
+
+ if (titleEnd > titleStart)
+ {
+ metadata.StreamTitle = metadataStr[titleStart..titleEnd];
+ Debug.WriteLine($"[StreamMetadataService] StreamTitle: {metadata.StreamTitle}");
+
+ // Try to parse "Artist - Title" format
+ ParseArtistAndTitle(metadata);
+ }
+ }
+
+ return metadata;
+ }
+
+ ///
+ /// Attempts to parse Artist and Title from the StreamTitle using common formats.
+ ///
+ private static void ParseArtistAndTitle(StreamMetadata metadata)
+ {
+ if (string.IsNullOrWhiteSpace(metadata.StreamTitle))
+ {
+ return;
+ }
+
+ // Common separators: " - ", " – ", " — "
+ string[] separators = [" - ", " – ", " — "];
+
+ foreach (string separator in separators)
+ {
+ int separatorIndex = metadata.StreamTitle.IndexOf(separator, StringComparison.Ordinal);
+ if (separatorIndex > 0)
+ {
+ metadata.Artist = metadata.StreamTitle[..separatorIndex].Trim();
+ metadata.Title = metadata.StreamTitle[(separatorIndex + separator.Length)..].Trim();
+ Debug.WriteLine($"[StreamMetadataService] Parsed - Artist: {metadata.Artist}, Title: {metadata.Title}");
+ return;
+ }
+ }
+
+ // If no separator found, use the whole string as Title
+ metadata.Title = metadata.StreamTitle;
+ }
+
+ ///
+ /// Updates the current metadata and raises the MetadataChanged event if changed.
+ ///
+ private void UpdateMetadata(StreamMetadata newMetadata)
+ {
+ // Check if metadata actually changed
+ if (_currentMetadata.StreamTitle == newMetadata.StreamTitle &&
+ _currentMetadata.Artist == newMetadata.Artist &&
+ _currentMetadata.Title == newMetadata.Title)
+ {
+ return;
+ }
+
+ _currentMetadata = newMetadata;
+ Debug.WriteLine($"[StreamMetadataService] Metadata updated: {newMetadata.DisplayText}");
+ MetadataChanged?.Invoke(this, newMetadata);
+ }
+
+ public void Dispose()
+ {
+ if (_isDisposed)
+ {
+ return;
+ }
+
+ _isDisposed = true;
+ StopPolling();
+ _httpClient.Dispose();
+ }
+}
diff --git a/Trdo/ViewModels/PlayerViewModel.cs b/Trdo/ViewModels/PlayerViewModel.cs
index a49c015..1e773d3 100644
--- a/Trdo/ViewModels/PlayerViewModel.cs
+++ b/Trdo/ViewModels/PlayerViewModel.cs
@@ -52,6 +52,15 @@ public PlayerViewModel()
Debug.WriteLine($"[PlayerViewModel] Watchdog status: {WatchdogStatus}");
};
+ // Subscribe to stream metadata changes
+ _player.StreamMetadataChanged += (_, metadata) =>
+ {
+ Debug.WriteLine($"[PlayerViewModel] StreamMetadataChanged event fired. NowPlaying={metadata.DisplayText}");
+ OnPropertyChanged(nameof(CurrentMetadata));
+ OnPropertyChanged(nameof(NowPlaying));
+ OnPropertyChanged(nameof(HasNowPlaying));
+ };
+
// Load stations from settings
Debug.WriteLine("[PlayerViewModel] Loading stations from settings...");
List loadedStations = _stationService.LoadStations();
@@ -250,6 +259,21 @@ private set
public bool CanPlay => Stations.Count > 0 && SelectedStation != null;
+ ///
+ /// Gets the current stream metadata (now playing information).
+ ///
+ public StreamMetadata CurrentMetadata => _player.CurrentMetadata;
+
+ ///
+ /// Gets the current now playing text for display.
+ ///
+ public string NowPlaying => CurrentMetadata?.DisplayText ?? string.Empty;
+
+ ///
+ /// Indicates whether there is now playing information to display.
+ ///
+ public bool HasNowPlaying => CurrentMetadata?.HasMetadata ?? false;
+
public double Volume
{
get => _player.Volume;
From 4cc5330575967aefcaaa5b90752af9fe4e7e02cc Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Thu, 4 Dec 2025 20:52:01 +0000
Subject: [PATCH 3/9] Address code review feedback - improve thread safety and
memory efficiency
Co-authored-by: TheJoeFin <7809853+TheJoeFin@users.noreply.github.com>
---
Trdo/Services/StreamMetadataService.cs | 145 ++++++++++++++++---------
1 file changed, 94 insertions(+), 51 deletions(-)
diff --git a/Trdo/Services/StreamMetadataService.cs b/Trdo/Services/StreamMetadataService.cs
index 66aa764..08a189d 100644
--- a/Trdo/Services/StreamMetadataService.cs
+++ b/Trdo/Services/StreamMetadataService.cs
@@ -15,6 +15,7 @@ namespace Trdo.Services;
public sealed class StreamMetadataService : IDisposable
{
private readonly HttpClient _httpClient;
+ private readonly object _pollingLock = new();
private CancellationTokenSource? _pollingCts;
private Task? _pollingTask;
private string? _currentStreamUrl;
@@ -67,43 +68,71 @@ public void StartPolling(string streamUrl)
return;
}
- // Stop any existing polling
- StopPolling();
-
- _currentStreamUrl = streamUrl;
- _pollingCts = new CancellationTokenSource();
+ lock (_pollingLock)
+ {
+ // Stop any existing polling
+ StopPollingCore();
- Debug.WriteLine($"[StreamMetadataService] Starting metadata polling for: {streamUrl}");
+ _currentStreamUrl = streamUrl;
+ _pollingCts = new CancellationTokenSource();
- _pollingTask = Task.Run(async () =>
- {
- // Fetch initial metadata immediately
- await FetchMetadataAsync(_pollingCts.Token);
+ Debug.WriteLine($"[StreamMetadataService] Starting metadata polling for: {streamUrl}");
- // Then poll periodically
- while (!_pollingCts.Token.IsCancellationRequested)
+ CancellationToken token = _pollingCts.Token;
+ _pollingTask = Task.Run(async () =>
{
try
{
- await Task.Delay(_pollingInterval, _pollingCts.Token);
- await FetchMetadataAsync(_pollingCts.Token);
+ // Fetch initial metadata immediately
+ await FetchMetadataAsync(token);
+
+ // Then poll periodically
+ while (!token.IsCancellationRequested)
+ {
+ try
+ {
+ await Task.Delay(_pollingInterval, token);
+ await FetchMetadataAsync(token);
+ }
+ catch (OperationCanceledException)
+ {
+ break;
+ }
+ catch (Exception ex)
+ {
+ Debug.WriteLine($"[StreamMetadataService] Polling error: {ex.Message}");
+ }
+ }
}
catch (OperationCanceledException)
{
- break;
+ // Task was cancelled, this is expected during disposal
+ Debug.WriteLine("[StreamMetadataService] Polling task cancelled");
}
catch (Exception ex)
{
- Debug.WriteLine($"[StreamMetadataService] Polling error: {ex.Message}");
+ // Log any unhandled exceptions to prevent unobserved task exceptions
+ Debug.WriteLine($"[StreamMetadataService] Unhandled polling error: {ex.Message}");
}
- }
- });
+ });
+ }
}
///
/// Stops polling for metadata.
///
public void StopPolling()
+ {
+ lock (_pollingLock)
+ {
+ StopPollingCore();
+ }
+ }
+
+ ///
+ /// Core stop logic - must be called under lock.
+ ///
+ private void StopPollingCore()
{
Debug.WriteLine("[StreamMetadataService] Stopping metadata polling");
_pollingCts?.Cancel();
@@ -142,33 +171,8 @@ private async Task FetchMetadataAsync(CancellationToken cancellationToken)
return;
}
- // Check for ICY metadata interval header
- int metaInterval = 0;
- if (response.Headers.TryGetValues("icy-metaint", out var metaIntValues))
- {
- foreach (var metaIntStr in metaIntValues)
- {
- if (int.TryParse(metaIntStr, out int parsed))
- {
- metaInterval = parsed;
- break;
- }
- }
- }
-
- // Also check for ICY metadata interval in content headers (some servers)
- if (metaInterval == 0 && response.Content.Headers.TryGetValues("icy-metaint", out var contentMetaIntValues))
- {
- foreach (var val in contentMetaIntValues)
- {
- if (int.TryParse(val, out int parsed))
- {
- metaInterval = parsed;
- break;
- }
- }
- }
-
+ // Check for ICY metadata interval header in response headers and content headers
+ int metaInterval = GetIcyMetaInterval(response);
Debug.WriteLine($"[StreamMetadataService] icy-metaint: {metaInterval}");
if (metaInterval <= 0)
@@ -179,6 +183,9 @@ private async Task FetchMetadataAsync(CancellationToken cancellationToken)
}
// Read stream data to find metadata
+ // Using ResponseHeadersRead allows us to start processing the stream immediately
+ // The 'using' statement ensures the stream is disposed after reading just enough data
+ // to extract the first metadata block, minimizing bandwidth usage
using Stream stream = await response.Content.ReadAsStreamAsync(cancellationToken);
StreamMetadata? metadata = await ReadIcyMetadataAsync(stream, metaInterval, cancellationToken);
@@ -211,14 +218,17 @@ private async Task FetchMetadataAsync(CancellationToken cancellationToken)
{
try
{
- // Read and discard audio data until we reach the metadata
- byte[] audioBuffer = new byte[metaInterval];
+ // Read and discard audio data in chunks until we reach the metadata
+ // Using a fixed buffer size reduces memory allocation for large metaInterval values
+ const int chunkSize = 8192;
+ byte[] discardBuffer = new byte[Math.Min(chunkSize, metaInterval)];
int totalRead = 0;
while (totalRead < metaInterval)
{
+ int bytesToRead = Math.Min(discardBuffer.Length, metaInterval - totalRead);
int bytesRead = await stream.ReadAsync(
- audioBuffer.AsMemory(totalRead, metaInterval - totalRead),
+ discardBuffer.AsMemory(0, bytesToRead),
cancellationToken);
if (bytesRead == 0)
@@ -231,14 +241,15 @@ private async Task FetchMetadataAsync(CancellationToken cancellationToken)
}
// Read metadata length byte (multiply by 16 to get actual length)
- int metaLengthByte = stream.ReadByte();
- if (metaLengthByte < 0)
+ // Reuse the first byte of the discard buffer for efficiency
+ int lengthBytesRead = await stream.ReadAsync(discardBuffer.AsMemory(0, 1), cancellationToken);
+ if (lengthBytesRead == 0)
{
Debug.WriteLine("[StreamMetadataService] Could not read metadata length");
return null;
}
- int metaLength = metaLengthByte * 16;
+ int metaLength = discardBuffer[0] * 16;
if (metaLength == 0)
{
// No metadata in this block
@@ -342,6 +353,38 @@ private static void ParseArtistAndTitle(StreamMetadata metadata)
metadata.Title = metadata.StreamTitle;
}
+ ///
+ /// Extracts the ICY metadata interval from response headers.
+ ///
+ private static int GetIcyMetaInterval(HttpResponseMessage response)
+ {
+ // Check response headers first
+ if (response.Headers.TryGetValues("icy-metaint", out var metaIntValues))
+ {
+ foreach (var metaIntStr in metaIntValues)
+ {
+ if (int.TryParse(metaIntStr, out int parsed))
+ {
+ return parsed;
+ }
+ }
+ }
+
+ // Also check content headers (some servers send it there)
+ if (response.Content.Headers.TryGetValues("icy-metaint", out var contentMetaIntValues))
+ {
+ foreach (var val in contentMetaIntValues)
+ {
+ if (int.TryParse(val, out int parsed))
+ {
+ return parsed;
+ }
+ }
+ }
+
+ return 0;
+ }
+
///
/// Updates the current metadata and raises the MetadataChanged event if changed.
///
From f0afcaf0bcf69847cff6afbeebf1ba65db30f722 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Thu, 4 Dec 2025 21:23:33 +0000
Subject: [PATCH 4/9] Update tray icon tooltip with now playing song details
Co-authored-by: TheJoeFin <7809853+TheJoeFin@users.noreply.github.com>
---
Trdo/App.xaml.cs | 29 +++++++++++++++++++++++++++--
1 file changed, 27 insertions(+), 2 deletions(-)
diff --git a/Trdo/App.xaml.cs b/Trdo/App.xaml.cs
index b80c069..9ea8fd0 100644
--- a/Trdo/App.xaml.cs
+++ b/Trdo/App.xaml.cs
@@ -26,6 +26,11 @@ public partial class App : Application
private DispatcherQueueTimer? _trayIconWatchdogTimer;
private DispatcherQueueTimer? _restoreEventMonitorTimer;
+ ///
+ /// Maximum length for the now playing text in the tooltip before truncation.
+ ///
+ private const int MaxTooltipNowPlayingLength = 60;
+
public App()
{
InitializeComponent();
@@ -105,6 +110,12 @@ private void PlayerVmOnPropertyChanged(object? sender, PropertyChangedEventArgs
{
UpdatePlayPauseCommandText();
}
+ else if (e.PropertyName == nameof(PlayerViewModel.NowPlaying) ||
+ e.PropertyName == nameof(PlayerViewModel.HasNowPlaying))
+ {
+ // Update tooltip when now playing info changes
+ UpdatePlayPauseCommandText();
+ }
}
private void OnColorValuesChanged(UISettings sender, object args)
@@ -233,11 +244,25 @@ private void UpdatePlayPauseCommandText()
}
else if (_playerVm.IsPlaying)
{
- _trayIcon.Tooltip = "Trdo (Playing) - Click to Pause";
+ // Include now playing info if available
+ if (_playerVm.HasNowPlaying)
+ {
+ // Truncate long now playing text to keep tooltip readable
+ string nowPlaying = _playerVm.NowPlaying;
+ if (nowPlaying.Length > MaxTooltipNowPlayingLength)
+ {
+ nowPlaying = string.Concat(nowPlaying.AsSpan(0, MaxTooltipNowPlayingLength - 3), "...");
+ }
+ _trayIcon.Tooltip = $"Trdo (Playing)\n{nowPlaying}";
+ }
+ else
+ {
+ _trayIcon.Tooltip = "Trdo (Playing)";
+ }
}
else
{
- _trayIcon.Tooltip = "Trdo - Play";
+ _trayIcon.Tooltip = "Trdo (Paused)";
}
}
From f12e42313c1b5a587d55364e7dc5640ac350aad9 Mon Sep 17 00:00:00 2001
From: Joe Finney
Date: Thu, 4 Dec 2025 23:17:21 -0600
Subject: [PATCH 5/9] Add "Now Playing" feature with metadata display
Introduced a new "Now Playing" page to display detailed stream
metadata, including artist, title, and stream information. Added
a ViewModel to manage metadata and handle Discogs search. Updated
project file to include the new page in the build process.
---
Trdo/Pages/NowPlayingPage.xaml | 119 +++++++++++++++++++++++++
Trdo/Pages/NowPlayingPage.xaml.cs | 33 +++++++
Trdo/Trdo.csproj | 10 ++-
Trdo/ViewModels/NowPlayingViewModel.cs | 117 ++++++++++++++++++++++++
4 files changed, 278 insertions(+), 1 deletion(-)
create mode 100644 Trdo/Pages/NowPlayingPage.xaml
create mode 100644 Trdo/Pages/NowPlayingPage.xaml.cs
create mode 100644 Trdo/ViewModels/NowPlayingViewModel.cs
diff --git a/Trdo/Pages/NowPlayingPage.xaml b/Trdo/Pages/NowPlayingPage.xaml
new file mode 100644
index 0000000..5cbb723
--- /dev/null
+++ b/Trdo/Pages/NowPlayingPage.xaml
@@ -0,0 +1,119 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Trdo/Pages/NowPlayingPage.xaml.cs b/Trdo/Pages/NowPlayingPage.xaml.cs
new file mode 100644
index 0000000..bc82cca
--- /dev/null
+++ b/Trdo/Pages/NowPlayingPage.xaml.cs
@@ -0,0 +1,33 @@
+using Microsoft.UI.Xaml;
+using Microsoft.UI.Xaml.Controls;
+using System.Diagnostics;
+using Trdo.ViewModels;
+
+namespace Trdo.Pages;
+
+///
+/// A page that displays detailed stream metadata (now playing) information.
+///
+public sealed partial class NowPlayingPage : Page
+{
+ public NowPlayingViewModel ViewModel { get; }
+
+ public NowPlayingPage()
+ {
+ Debug.WriteLine("=== NowPlayingPage Constructor START ===");
+
+ InitializeComponent();
+ ViewModel = new NowPlayingViewModel();
+ DataContext = ViewModel;
+
+ Debug.WriteLine("[NowPlayingPage] ViewModel created and DataContext set");
+ Debug.WriteLine($"[NowPlayingPage] Current metadata: {ViewModel.DisplayText}");
+ Debug.WriteLine("=== NowPlayingPage Constructor END ===");
+ }
+
+ private async void DiscogsLink_Click(object sender, RoutedEventArgs e)
+ {
+ Debug.WriteLine("[NowPlayingPage] Discogs link clicked");
+ await ViewModel.SearchOnDiscogs();
+ }
+}
diff --git a/Trdo/Trdo.csproj b/Trdo/Trdo.csproj
index 4d817e2..f02d2b8 100644
--- a/Trdo/Trdo.csproj
+++ b/Trdo/Trdo.csproj
@@ -24,6 +24,9 @@
+
+
+
@@ -68,6 +71,11 @@
PreserveNewest
+
+
+ MSBuild:Compile
+
+
MSBuild:Compile
@@ -119,4 +127,4 @@
x64|arm64
-
\ No newline at end of file
+
diff --git a/Trdo/ViewModels/NowPlayingViewModel.cs b/Trdo/ViewModels/NowPlayingViewModel.cs
new file mode 100644
index 0000000..698dc89
--- /dev/null
+++ b/Trdo/ViewModels/NowPlayingViewModel.cs
@@ -0,0 +1,117 @@
+using System;
+using System.ComponentModel;
+using System.Runtime.CompilerServices;
+using System.Threading.Tasks;
+using Trdo.Models;
+using Trdo.Services;
+using Windows.System;
+
+namespace Trdo.ViewModels;
+
+public partial class NowPlayingViewModel : INotifyPropertyChanged
+{
+ private readonly RadioPlayerService _player = RadioPlayerService.Instance;
+
+ public event PropertyChangedEventHandler? PropertyChanged;
+
+ public NowPlayingViewModel()
+ {
+ // Subscribe to metadata changes
+ _player.StreamMetadataChanged += (_, _) =>
+ {
+ OnPropertyChanged(nameof(CurrentMetadata));
+ OnPropertyChanged(nameof(StreamTitle));
+ OnPropertyChanged(nameof(Artist));
+ OnPropertyChanged(nameof(Title));
+ OnPropertyChanged(nameof(DisplayText));
+ OnPropertyChanged(nameof(HasMetadata));
+ OnPropertyChanged(nameof(HasArtist));
+ OnPropertyChanged(nameof(HasTitle));
+ OnPropertyChanged(nameof(ShowStreamTitleOnly));
+ OnPropertyChanged(nameof(ShowRawStreamTitle));
+ OnPropertyChanged(nameof(DiscogsSearchQuery));
+ };
+ }
+
+ ///
+ /// Gets the current stream metadata.
+ ///
+ public StreamMetadata CurrentMetadata => _player.CurrentMetadata;
+
+ ///
+ /// Gets the full stream title string.
+ ///
+ public string StreamTitle => CurrentMetadata?.StreamTitle ?? string.Empty;
+
+ ///
+ /// Gets the artist name if available.
+ ///
+ public string Artist => CurrentMetadata?.Artist ?? string.Empty;
+
+ ///
+ /// Gets the song/track title if available.
+ ///
+ public string Title => CurrentMetadata?.Title ?? string.Empty;
+
+ ///
+ /// Gets the display-friendly now playing text.
+ ///
+ public string DisplayText => CurrentMetadata?.DisplayText ?? string.Empty;
+
+ ///
+ /// Indicates whether any meaningful metadata is available.
+ ///
+ public bool HasMetadata => CurrentMetadata?.HasMetadata ?? false;
+
+ ///
+ /// Indicates whether artist information is available.
+ ///
+ public bool HasArtist => !string.IsNullOrWhiteSpace(Artist);
+
+ ///
+ /// Indicates whether title information is available.
+ ///
+ public bool HasTitle => !string.IsNullOrWhiteSpace(Title);
+
+ ///
+ /// Indicates whether to show only the raw stream title (when we couldn't parse artist/title).
+ ///
+ public bool ShowStreamTitleOnly => HasMetadata && !HasArtist && !HasTitle && !string.IsNullOrWhiteSpace(StreamTitle);
+
+ ///
+ /// Indicates whether to show the raw stream title section (only when we have parsed data to compare).
+ ///
+ public bool ShowRawStreamTitle => HasMetadata && (HasArtist || HasTitle) && !string.IsNullOrWhiteSpace(StreamTitle);
+
+ ///
+ /// Gets the search query for Discogs, URL-encoded.
+ ///
+ public string DiscogsSearchQuery
+ {
+ get
+ {
+ string searchText = DisplayText;
+ if (string.IsNullOrWhiteSpace(searchText))
+ searchText = StreamTitle;
+
+ return Uri.EscapeDataString(searchText);
+ }
+ }
+
+ ///
+ /// Opens Discogs search with the current track information.
+ ///
+ public async Task SearchOnDiscogs()
+ {
+ if (!HasMetadata)
+ return;
+
+ string url = $"https://www.discogs.com/search?q={DiscogsSearchQuery}";
+ await Launcher.LaunchUriAsync(new Uri(url));
+ }
+
+ protected void OnPropertyChanged([CallerMemberName] string? propertyName = null)
+ {
+ PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
+ }
+}
From 0e8bdfa6baf1bf6331267b3e6092e854bdaa0dc8 Mon Sep 17 00:00:00 2001
From: Joe Finney
Date: Thu, 4 Dec 2025 23:18:06 -0600
Subject: [PATCH 6/9] Add "Now Playing" button and inversion logic support
Enhanced BooleanToVisibilityConverter with inversion logic.
Added "Now Playing" button to PlayingPage with navigation
support to details page. Updated ShellViewModel for page
navigation.
---
.../BooleanToVisibilityConverter.cs | 10 ++++
Trdo/Pages/PlayingPage.xaml | 47 +++++++++++++------
Trdo/Pages/PlayingPage.xaml.cs | 7 +++
Trdo/ViewModels/ShellViewModel.cs | 5 ++
4 files changed, 54 insertions(+), 15 deletions(-)
diff --git a/Trdo/Converters/BooleanToVisibilityConverter.cs b/Trdo/Converters/BooleanToVisibilityConverter.cs
index 1af7a31..840b065 100644
--- a/Trdo/Converters/BooleanToVisibilityConverter.cs
+++ b/Trdo/Converters/BooleanToVisibilityConverter.cs
@@ -6,6 +6,7 @@ namespace Trdo.Converters;
///
/// Converts a boolean value to Visibility. Returns Visible when true, Collapsed when false.
+/// Use ConverterParameter="Invert" to invert the logic.
///
public class BooleanToVisibilityConverter : IValueConverter
{
@@ -13,6 +14,15 @@ public object Convert(object value, Type targetType, object parameter, string la
{
if (value is bool boolValue)
{
+ // Check if we should invert the result
+ bool invert = parameter is string paramString &&
+ paramString.Equals("Invert", StringComparison.OrdinalIgnoreCase);
+
+ if (invert)
+ {
+ boolValue = !boolValue;
+ }
+
return boolValue ? Visibility.Visible : Visibility.Collapsed;
}
return Visibility.Collapsed;
diff --git a/Trdo/Pages/PlayingPage.xaml b/Trdo/Pages/PlayingPage.xaml
index d49c123..f208f39 100644
--- a/Trdo/Pages/PlayingPage.xaml
+++ b/Trdo/Pages/PlayingPage.xaml
@@ -203,29 +203,46 @@
-
-
+
+
+
+
+
+
+
+
+
-
-
-
+ Foreground="{ThemeResource TextFillColorTertiaryBrush}"
+ Glyph="" />
+
+
diff --git a/Trdo/Pages/PlayingPage.xaml.cs b/Trdo/Pages/PlayingPage.xaml.cs
index 0cc63a8..b49e9ad 100644
--- a/Trdo/Pages/PlayingPage.xaml.cs
+++ b/Trdo/Pages/PlayingPage.xaml.cs
@@ -285,6 +285,13 @@ private void InfoButton_Click(object sender, RoutedEventArgs e)
_shellViewModel?.NavigateToAboutPage();
}
+ private void NowPlayingInfo_Click(object sender, RoutedEventArgs e)
+ {
+ Debug.WriteLine("[PlayingPage] Now Playing info clicked");
+ // Navigate to Now Playing details page
+ _shellViewModel?.NavigateToNowPlayingPage();
+ }
+
private void EditStation_Click(object sender, RoutedEventArgs e)
{
if (sender is MenuFlyoutItem menuItem && menuItem.Tag is RadioStation station)
diff --git a/Trdo/ViewModels/ShellViewModel.cs b/Trdo/ViewModels/ShellViewModel.cs
index 8c55e67..6af5e2c 100644
--- a/Trdo/ViewModels/ShellViewModel.cs
+++ b/Trdo/ViewModels/ShellViewModel.cs
@@ -65,6 +65,11 @@ public void NavigateToAboutPage()
_navigationService.Navigate(typeof(AboutPage));
}
+ public void NavigateToNowPlayingPage()
+ {
+ _navigationService.Navigate(typeof(NowPlayingPage));
+ }
+
public void GoBack()
{
_navigationService.GoBack();
From ef98e6d2280b0edbb326a88b746d88870ec0a02d Mon Sep 17 00:00:00 2001
From: Joe Finney
Date: Fri, 5 Dec 2025 00:01:09 -0600
Subject: [PATCH 7/9] Refactor navigation and integrate MVVM utilities
Refactored `NavigationService` and `ShellViewModel` to use `ObservableObject` from `CommunityToolkit.Mvvm`, simplifying property change notifications. Added `RelayCommand` attributes for navigation commands in `ShellViewModel`. Updated `AddStation.xaml.cs` to reset navigation stack history after saving. Improved null checks and added debug logging in `NavigationService`. Included `CommunityToolkit.Mvvm` package reference in `Trdo.csproj`. Performed general cleanup and formatting improvements.
---
Trdo/Pages/AddStation.xaml.cs | 4 ++++
Trdo/Services/NavigationService.cs | 36 +++++++++++++-----------------
Trdo/Trdo.csproj | 1 +
Trdo/ViewModels/ShellViewModel.cs | 22 +++++++++---------
4 files changed, 31 insertions(+), 32 deletions(-)
diff --git a/Trdo/Pages/AddStation.xaml.cs b/Trdo/Pages/AddStation.xaml.cs
index 817557f..21384b1 100644
--- a/Trdo/Pages/AddStation.xaml.cs
+++ b/Trdo/Pages/AddStation.xaml.cs
@@ -3,6 +3,7 @@
using Microsoft.UI.Xaml.Media;
using Microsoft.UI.Xaml.Navigation;
using Trdo.Models;
+using Trdo.Services;
using Trdo.ViewModels;
namespace Trdo.Pages;
@@ -71,6 +72,9 @@ private void SaveButton_Click(object sender, RoutedEventArgs e)
{
// Navigate to main page after successful save
_shellViewModel?.NavigateToPlayingPage();
+
+ // Reset navigation stack history when going home after adding a station
+ NavigationService.Instance.ClearBackStack();
}
}
diff --git a/Trdo/Services/NavigationService.cs b/Trdo/Services/NavigationService.cs
index 826975e..9eca87b 100644
--- a/Trdo/Services/NavigationService.cs
+++ b/Trdo/Services/NavigationService.cs
@@ -1,11 +1,11 @@
+using CommunityToolkit.Mvvm.ComponentModel;
using Microsoft.UI.Xaml.Controls;
using System;
-using System.ComponentModel;
-using System.Runtime.CompilerServices;
+using System.Diagnostics;
namespace Trdo.Services;
-public class NavigationService : INotifyPropertyChanged
+public partial class NavigationService : ObservableObject
{
private static readonly Lazy _instance = new(() => new NavigationService());
private Frame? _frame;
@@ -16,7 +16,6 @@ private NavigationService()
{
}
- public event PropertyChangedEventHandler? PropertyChanged;
public event EventHandler? NavigationChanged;
public Frame? Frame
@@ -24,19 +23,16 @@ public Frame? Frame
get => _frame;
set
{
- if (_frame == value) return;
+ if (_frame == value)
+ return;
- if (_frame != null)
- {
+ if (_frame is not null)
_frame.Navigated -= OnNavigated;
- }
_frame = value;
- if (_frame != null)
- {
+ if (_frame is not null)
_frame.Navigated += OnNavigated;
- }
OnPropertyChanged();
OnPropertyChanged(nameof(CanGoBack));
@@ -45,6 +41,7 @@ public Frame? Frame
public bool CanGoBack => _frame?.CanGoBack ?? false;
+
private void OnNavigated(object sender, Microsoft.UI.Xaml.Navigation.NavigationEventArgs e)
{
OnPropertyChanged(nameof(CanGoBack));
@@ -53,7 +50,9 @@ private void OnNavigated(object sender, Microsoft.UI.Xaml.Navigation.NavigationE
public bool Navigate(Type pageType, object? parameter = null)
{
- if (_frame == null) return false;
+ if (_frame is null)
+ return false;
+
// Don't navigate if we're already on the same page and no parameter is passed
if (_frame.Content?.GetType() == pageType && parameter == null)
return false;
@@ -63,22 +62,17 @@ public bool Navigate(Type pageType, object? parameter = null)
public void GoBack()
{
- if (_frame?.CanGoBack == true)
- {
+ if (_frame?.CanGoBack is true)
_frame.GoBack();
- }
}
public void ClearBackStack()
{
- if (_frame == null) return;
+ if (_frame is null)
+ return;
_frame.BackStack.Clear();
OnPropertyChanged(nameof(CanGoBack));
- }
-
- protected void OnPropertyChanged([CallerMemberName] string? propertyName = null)
- {
- PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
+ Debug.WriteLine($"CanGoBack: {CanGoBack}");
}
}
diff --git a/Trdo/Trdo.csproj b/Trdo/Trdo.csproj
index f02d2b8..68420e5 100644
--- a/Trdo/Trdo.csproj
+++ b/Trdo/Trdo.csproj
@@ -50,6 +50,7 @@
+
diff --git a/Trdo/ViewModels/ShellViewModel.cs b/Trdo/ViewModels/ShellViewModel.cs
index 6af5e2c..bbd64f1 100644
--- a/Trdo/ViewModels/ShellViewModel.cs
+++ b/Trdo/ViewModels/ShellViewModel.cs
@@ -1,16 +1,14 @@
-using Microsoft.UI.Xaml.Controls;
-using System.ComponentModel;
-using System.Runtime.CompilerServices;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using Microsoft.UI.Xaml.Controls;
using Trdo.Models;
using Trdo.Pages;
using Trdo.Services;
namespace Trdo.ViewModels;
-public partial class ShellViewModel : INotifyPropertyChanged
+public partial class ShellViewModel : ObservableObject
{
- public event PropertyChangedEventHandler? PropertyChanged;
-
private readonly NavigationService _navigationService;
public ShellViewModel()
@@ -35,21 +33,25 @@ public Frame? ContentFrame
public bool CanGoBack => _navigationService.CanGoBack;
+ [RelayCommand]
public void NavigateToPlayingPage()
{
_navigationService.Navigate(typeof(PlayingPage));
}
+ [RelayCommand]
public void NavigateToSettingsPage()
{
_navigationService.Navigate(typeof(SettingsPage));
}
+ [RelayCommand]
public void NavigateToSearchStationPage()
{
_navigationService.Navigate(typeof(SearchStation));
}
+ [RelayCommand]
public void NavigateToAddStationPage(RadioStation? stationToEdit = null)
{
_navigationService.Navigate(typeof(AddStation), stationToEdit);
@@ -60,23 +62,21 @@ public void NavigateToAddStationPage(RadioBrowserStation? searchResult)
_navigationService.Navigate(typeof(AddStation), searchResult);
}
+ [RelayCommand]
public void NavigateToAboutPage()
{
_navigationService.Navigate(typeof(AboutPage));
}
+ [RelayCommand]
public void NavigateToNowPlayingPage()
{
_navigationService.Navigate(typeof(NowPlayingPage));
}
+ [RelayCommand]
public void GoBack()
{
_navigationService.GoBack();
}
-
- protected void OnPropertyChanged([CallerMemberName] string? propertyName = null)
- {
- PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
- }
}
From b0109ac5ef9c3097d72ee85c2105eca56cc9135d Mon Sep 17 00:00:00 2001
From: Joe Finney
Date: Fri, 5 Dec 2025 00:19:03 -0600
Subject: [PATCH 8/9] Add Spotify search functionality to Now Playing page
Introduced a "Search on Spotify" feature alongside the existing
"Search on Discogs" functionality. Updated the UI to include a
new button with an SVG icon for Spotify. Added backend logic
to handle Spotify searches via the app or web fallback. Included
the `spotify.svg` asset in the project.
---
Trdo/Assets/spotify.svg | 3 ++
Trdo/Pages/NowPlayingPage.xaml | 38 ++++++++++++-----
Trdo/Pages/NowPlayingPage.xaml.cs | 6 +++
Trdo/Trdo.csproj | 4 ++
Trdo/ViewModels/NowPlayingViewModel.cs | 59 +++++++++++++++++++++++++-
5 files changed, 98 insertions(+), 12 deletions(-)
create mode 100644 Trdo/Assets/spotify.svg
diff --git a/Trdo/Assets/spotify.svg b/Trdo/Assets/spotify.svg
new file mode 100644
index 0000000..0768e88
--- /dev/null
+++ b/Trdo/Assets/spotify.svg
@@ -0,0 +1,3 @@
+
diff --git a/Trdo/Pages/NowPlayingPage.xaml b/Trdo/Pages/NowPlayingPage.xaml
index 5cbb723..2e886ea 100644
--- a/Trdo/Pages/NowPlayingPage.xaml
+++ b/Trdo/Pages/NowPlayingPage.xaml
@@ -50,9 +50,7 @@
-
+
-
-
+
-
-
-
-
-
+ Orientation="Horizontal"
+ Spacing="16">
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Trdo/Pages/NowPlayingPage.xaml.cs b/Trdo/Pages/NowPlayingPage.xaml.cs
index bc82cca..1f0b491 100644
--- a/Trdo/Pages/NowPlayingPage.xaml.cs
+++ b/Trdo/Pages/NowPlayingPage.xaml.cs
@@ -30,4 +30,10 @@ private async void DiscogsLink_Click(object sender, RoutedEventArgs e)
Debug.WriteLine("[NowPlayingPage] Discogs link clicked");
await ViewModel.SearchOnDiscogs();
}
+
+ private async void SpotifyLink_Click(object sender, RoutedEventArgs e)
+ {
+ Debug.WriteLine("[NowPlayingPage] Spotify link clicked");
+ await ViewModel.SearchOnSpotify();
+ }
}
diff --git a/Trdo/Trdo.csproj b/Trdo/Trdo.csproj
index 68420e5..571c3b6 100644
--- a/Trdo/Trdo.csproj
+++ b/Trdo/Trdo.csproj
@@ -18,6 +18,7 @@
+
@@ -71,6 +72,9 @@
PreserveNewest
+
+ PreserveNewest
+
diff --git a/Trdo/ViewModels/NowPlayingViewModel.cs b/Trdo/ViewModels/NowPlayingViewModel.cs
index 698dc89..6ca6572 100644
--- a/Trdo/ViewModels/NowPlayingViewModel.cs
+++ b/Trdo/ViewModels/NowPlayingViewModel.cs
@@ -1,5 +1,6 @@
using System;
using System.ComponentModel;
+using System.Diagnostics;
using System.Runtime.CompilerServices;
using System.Threading.Tasks;
using Trdo.Models;
@@ -30,6 +31,7 @@ public NowPlayingViewModel()
OnPropertyChanged(nameof(ShowStreamTitleOnly));
OnPropertyChanged(nameof(ShowRawStreamTitle));
OnPropertyChanged(nameof(DiscogsSearchQuery));
+ OnPropertyChanged(nameof(SpotifySearchQuery));
};
}
@@ -93,7 +95,22 @@ public string DiscogsSearchQuery
string searchText = DisplayText;
if (string.IsNullOrWhiteSpace(searchText))
searchText = StreamTitle;
-
+
+ return Uri.EscapeDataString(searchText);
+ }
+ }
+
+ ///
+ /// Gets the search query for Spotify, URL-encoded.
+ ///
+ public string SpotifySearchQuery
+ {
+ get
+ {
+ string searchText = DisplayText;
+ if (string.IsNullOrWhiteSpace(searchText))
+ searchText = StreamTitle;
+
return Uri.EscapeDataString(searchText);
}
}
@@ -110,6 +127,46 @@ public async Task SearchOnDiscogs()
await Launcher.LaunchUriAsync(new Uri(url));
}
+ ///
+ /// Opens Spotify search with the current track information.
+ /// Tries to open the local Spotify app first, falls back to web.
+ ///
+ public async Task SearchOnSpotify()
+ {
+ if (!HasMetadata)
+ return;
+
+ // Try to open the Spotify app first using the spotify: URI scheme
+ string spotifyAppUri = $"spotify:search:{SpotifySearchQuery}";
+
+ try
+ {
+ bool success = await Launcher.LaunchUriAsync(new Uri(spotifyAppUri));
+
+ if (!success)
+ {
+ // Spotify app not installed or couldn't launch, fall back to web
+ Debug.WriteLine("[NowPlayingViewModel] Spotify app not available, falling back to web");
+ await OpenSpotifyWeb();
+ }
+ }
+ catch (Exception ex)
+ {
+ // URI scheme not recognized or other error, fall back to web
+ Debug.WriteLine($"[NowPlayingViewModel] Error launching Spotify app: {ex.Message}");
+ await OpenSpotifyWeb();
+ }
+ }
+
+ ///
+ /// Opens Spotify web search as a fallback.
+ ///
+ private async Task OpenSpotifyWeb()
+ {
+ string webUrl = $"https://open.spotify.com/search/{SpotifySearchQuery}";
+ await Launcher.LaunchUriAsync(new Uri(webUrl));
+ }
+
protected void OnPropertyChanged([CallerMemberName] string? propertyName = null)
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
From 920c7396e7ceacdb2820b250575beebe2205dc14 Mon Sep 17 00:00:00 2001
From: Joe Finney
Date: Fri, 5 Dec 2025 00:23:41 -0600
Subject: [PATCH 9/9] bump version to 1.4
---
Trdo/Package.appxmanifest | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/Trdo/Package.appxmanifest b/Trdo/Package.appxmanifest
index 3b20043..beaf2de 100644
--- a/Trdo/Package.appxmanifest
+++ b/Trdo/Package.appxmanifest
@@ -11,7 +11,7 @@
+ Version="1.4.0.0" />