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)";
}
}
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/Converters/BooleanToVisibilityConverter.cs b/Trdo/Converters/BooleanToVisibilityConverter.cs
new file mode 100644
index 0000000..840b065
--- /dev/null
+++ b/Trdo/Converters/BooleanToVisibilityConverter.cs
@@ -0,0 +1,35 @@
+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.
+/// Use ConverterParameter="Invert" to invert the logic.
+///
+public class BooleanToVisibilityConverter : IValueConverter
+{
+ public object Convert(object value, Type targetType, object parameter, string language)
+ {
+ 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;
+ }
+
+ 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/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" />
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/Pages/NowPlayingPage.xaml b/Trdo/Pages/NowPlayingPage.xaml
new file mode 100644
index 0000000..2e886ea
--- /dev/null
+++ b/Trdo/Pages/NowPlayingPage.xaml
@@ -0,0 +1,135 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Trdo/Pages/NowPlayingPage.xaml.cs b/Trdo/Pages/NowPlayingPage.xaml.cs
new file mode 100644
index 0000000..1f0b491
--- /dev/null
+++ b/Trdo/Pages/NowPlayingPage.xaml.cs
@@ -0,0 +1,39 @@
+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();
+ }
+
+ private async void SpotifyLink_Click(object sender, RoutedEventArgs e)
+ {
+ Debug.WriteLine("[NowPlayingPage] Spotify link clicked");
+ await ViewModel.SearchOnSpotify();
+ }
+}
diff --git a/Trdo/Pages/PlayingPage.xaml b/Trdo/Pages/PlayingPage.xaml
index ea66940..f208f39 100644
--- a/Trdo/Pages/PlayingPage.xaml
+++ b/Trdo/Pages/PlayingPage.xaml
@@ -11,9 +11,10 @@
+
-
+
@@ -201,8 +202,50 @@
Value="{x:Bind ViewModel.Volume, Mode=TwoWay}" />
+
+
+
-
+
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/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/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..08a189d
--- /dev/null
+++ b/Trdo/Services/StreamMetadataService.cs
@@ -0,0 +1,417 @@
+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 readonly object _pollingLock = new();
+ 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;
+ }
+
+ lock (_pollingLock)
+ {
+ // Stop any existing polling
+ StopPollingCore();
+
+ _currentStreamUrl = streamUrl;
+ _pollingCts = new CancellationTokenSource();
+
+ Debug.WriteLine($"[StreamMetadataService] Starting metadata polling for: {streamUrl}");
+
+ CancellationToken token = _pollingCts.Token;
+ _pollingTask = Task.Run(async () =>
+ {
+ try
+ {
+ // 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)
+ {
+ // Task was cancelled, this is expected during disposal
+ Debug.WriteLine("[StreamMetadataService] Polling task cancelled");
+ }
+ catch (Exception ex)
+ {
+ // 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();
+ _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 in response headers and content headers
+ int metaInterval = GetIcyMetaInterval(response);
+ 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 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);
+
+ 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 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(
+ discardBuffer.AsMemory(0, bytesToRead),
+ 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)
+ // 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 = discardBuffer[0] * 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;
+ }
+
+ ///
+ /// 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.
+ ///
+ 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/Trdo.csproj b/Trdo/Trdo.csproj
index 4d817e2..571c3b6 100644
--- a/Trdo/Trdo.csproj
+++ b/Trdo/Trdo.csproj
@@ -18,12 +18,16 @@
+
+
+
+
@@ -47,6 +51,7 @@
+
@@ -67,6 +72,14 @@
PreserveNewest
+
+ PreserveNewest
+
+
+
+
+ MSBuild:Compile
+
@@ -119,4 +132,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..6ca6572
--- /dev/null
+++ b/Trdo/ViewModels/NowPlayingViewModel.cs
@@ -0,0 +1,174 @@
+using System;
+using System.ComponentModel;
+using System.Diagnostics;
+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));
+ OnPropertyChanged(nameof(SpotifySearchQuery));
+ };
+ }
+
+ ///
+ /// 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);
+ }
+ }
+
+ ///
+ /// 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);
+ }
+ }
+
+ ///
+ /// 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));
+ }
+
+ ///
+ /// 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));
+ }
+}
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;
diff --git a/Trdo/ViewModels/ShellViewModel.cs b/Trdo/ViewModels/ShellViewModel.cs
index 8c55e67..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,18 +62,21 @@ public void NavigateToAddStationPage(RadioBrowserStation? searchResult)
_navigationService.Navigate(typeof(AddStation), searchResult);
}
+ [RelayCommand]
public void NavigateToAboutPage()
{
_navigationService.Navigate(typeof(AboutPage));
}
- public void GoBack()
+ [RelayCommand]
+ public void NavigateToNowPlayingPage()
{
- _navigationService.GoBack();
+ _navigationService.Navigate(typeof(NowPlayingPage));
}
- protected void OnPropertyChanged([CallerMemberName] string? propertyName = null)
+ [RelayCommand]
+ public void GoBack()
{
- PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
+ _navigationService.GoBack();
}
}