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(); } }