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