diff --git a/Trdo/App.xaml.cs b/Trdo/App.xaml.cs index 9ea8fd0..d1103b2 100644 --- a/Trdo/App.xaml.cs +++ b/Trdo/App.xaml.cs @@ -5,7 +5,9 @@ using System.ComponentModel; using System.Threading; using System.Threading.Tasks; +using Trdo.Controls; using Trdo.Pages; +using Trdo.Services; using Trdo.ViewModels; using Windows.UI; using Windows.UI.ViewManagement; @@ -36,10 +38,21 @@ public App() InitializeComponent(); _playerVm.PropertyChanged += PlayerVmOnPropertyChanged; + // Initialize PlaylistHistoryService early so it captures metadata from the start + PlaylistHistoryService.EnsureInitialized(); + // Subscribe to theme change events _uiSettings.ColorValuesChanged += OnColorValuesChanged; } + public void TryShowFlyout() + { + if (_trayIcon is null) + return; + + // TODO: find a way to programmatically show the flyout on the Icon + } + protected override async void OnLaunched(LaunchActivatedEventArgs args) { // Check for single instance using a named mutex @@ -131,6 +144,13 @@ private void InitializeTrayIcon() _trayIcon.Selected += TrayIcon_Selected; _trayIcon.ContextMenu += TrayIcon_ContextMenu; _trayIcon.IsVisible = true; + + // Only show tutorial window on first run + if (SettingsService.IsFirstRun) + { + TutorialWindow tutorialWindow = new(); + tutorialWindow.Show(); + } } private void TrayIcon_ContextMenu(TrayIcon sender, TrayIconEventArgs args) diff --git a/Trdo/Assets/Tutorial.gif b/Trdo/Assets/Tutorial.gif new file mode 100644 index 0000000..5b492e4 Binary files /dev/null and b/Trdo/Assets/Tutorial.gif differ diff --git a/Trdo/Controls/TutorialWindow.xaml b/Trdo/Controls/TutorialWindow.xaml new file mode 100644 index 0000000..fe4d4f2 --- /dev/null +++ b/Trdo/Controls/TutorialWindow.xaml @@ -0,0 +1,257 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Trdo/Controls/TutorialWindow.xaml.cs b/Trdo/Controls/TutorialWindow.xaml.cs new file mode 100644 index 0000000..0f8327f --- /dev/null +++ b/Trdo/Controls/TutorialWindow.xaml.cs @@ -0,0 +1,28 @@ +using Trdo.Services; +using WinUIEx; + +namespace Trdo.Controls; +/// +/// An empty window that can be used on its own or navigated to within a Frame. +/// +public sealed partial class TutorialWindow : WindowEx +{ + public TutorialWindow() + { + InitializeComponent(); + + ExtendsContentIntoTitleBar = true; + SetTitleBar(ModernTitlebar); + } + + private void Button_Click(object sender, Microsoft.UI.Xaml.RoutedEventArgs e) + { + // Mark first run as complete + SettingsService.MarkFirstRunComplete(); + + Close(); + + if (App.Current is App currentApp) + currentApp.TryShowFlyout(); + } +} diff --git a/Trdo/Converters/BooleanToFavoriteGlyphConverter.cs b/Trdo/Converters/BooleanToFavoriteGlyphConverter.cs new file mode 100644 index 0000000..62e9940 --- /dev/null +++ b/Trdo/Converters/BooleanToFavoriteGlyphConverter.cs @@ -0,0 +1,35 @@ +using Microsoft.UI.Xaml.Data; +using System; + +namespace Trdo.Converters; + +/// +/// Converts a boolean favorite status to the appropriate star glyph. +/// +public class BooleanToFavoriteGlyphConverter : IValueConverter +{ + /// + /// Filled star glyph (favorited). + /// + private const string FilledStar = "\uE735"; + + /// + /// Outline star glyph (not favorited). + /// + private const string OutlineStar = "\uE734"; + + public object Convert(object value, Type targetType, object parameter, string language) + { + if (value is bool isFavorited) + { + return isFavorited ? FilledStar : OutlineStar; + } + + return OutlineStar; + } + + public object ConvertBack(object value, Type targetType, object parameter, string language) + { + throw new NotImplementedException(); + } +} diff --git a/Trdo/Converters/NullToVisibilityConverter.cs b/Trdo/Converters/NullToVisibilityConverter.cs index 5cd36f3..50a735b 100644 --- a/Trdo/Converters/NullToVisibilityConverter.cs +++ b/Trdo/Converters/NullToVisibilityConverter.cs @@ -5,13 +5,19 @@ namespace Trdo.Converters; /// -/// Converts null to Visibility. Returns Visible when value is not null, Collapsed when null. +/// Converts null or empty string to Visibility. Returns Visible when value is not null/empty, Collapsed when null/empty. /// public class NullToVisibilityConverter : IValueConverter { public object Convert(object value, Type targetType, object parameter, string language) { - return value != null ? Visibility.Visible : Visibility.Collapsed; + if (value == null) + return Visibility.Collapsed; + + if (value is string str) + return string.IsNullOrWhiteSpace(str) ? Visibility.Collapsed : Visibility.Visible; + + return Visibility.Visible; } public object ConvertBack(object value, Type targetType, object parameter, string language) diff --git a/Trdo/Converters/StringToImageSourceConverter.cs b/Trdo/Converters/StringToImageSourceConverter.cs new file mode 100644 index 0000000..5103202 --- /dev/null +++ b/Trdo/Converters/StringToImageSourceConverter.cs @@ -0,0 +1,36 @@ +using Microsoft.UI.Xaml.Data; +using Microsoft.UI.Xaml.Media.Imaging; +using System; + +namespace Trdo.Converters; + +/// +/// Converts a string URL to a BitmapImage, returning null for invalid or empty URLs. +/// +public class StringToImageSourceConverter : IValueConverter +{ + public object? Convert(object value, Type targetType, object parameter, string language) + { + if (value is not string urlString || string.IsNullOrWhiteSpace(urlString)) + return null; + + try + { + if (Uri.TryCreate(urlString, UriKind.Absolute, out Uri? uri)) + { + return new BitmapImage(uri); + } + } + catch + { + // If URI creation fails, return null + } + + return null; + } + + public object ConvertBack(object value, Type targetType, object parameter, string language) + { + throw new NotImplementedException(); + } +} diff --git a/Trdo/MainWindow.xaml b/Trdo/MainWindow.xaml deleted file mode 100644 index 9094f30..0000000 --- a/Trdo/MainWindow.xaml +++ /dev/null @@ -1,109 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - + + + + + diff --git a/Trdo/Pages/AboutPage.xaml.cs b/Trdo/Pages/AboutPage.xaml.cs index fcd0c74..ea0e9b0 100644 --- a/Trdo/Pages/AboutPage.xaml.cs +++ b/Trdo/Pages/AboutPage.xaml.cs @@ -1,5 +1,6 @@ using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Controls; +using Trdo.Controls; using Trdo.ViewModels; namespace Trdo.Pages; @@ -29,4 +30,25 @@ private void ReviewButton_Click(object sender, RoutedEventArgs e) { ViewModel.OpenRatingWindow(); } -} + + private void TutorialButton_Click(object sender, RoutedEventArgs e) + { + TutorialWindow tutorialWindow = new(); + tutorialWindow.Activate(); + } + + private void RadioBrowserButton_Click(object sender, RoutedEventArgs e) + { + ViewModel.OpenRadioBrowser(); + } + + private void WinUIExButton_Click(object sender, RoutedEventArgs e) + { + ViewModel.OpenWinUIEx(); + } + + private void CommunityToolkitButton_Click(object sender, RoutedEventArgs e) + { + ViewModel.OpenCommunityToolkit(); + } + } diff --git a/Trdo/Pages/AddStation.xaml b/Trdo/Pages/AddStation.xaml index 461f398..799ddd1 100644 --- a/Trdo/Pages/AddStation.xaml +++ b/Trdo/Pages/AddStation.xaml @@ -36,6 +36,26 @@ Text="{x:Bind ViewModel.StreamUrl, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" TextWrapping="Wrap" /> + + + + + + + + + + + + diff --git a/Trdo/Pages/FavoritesPage.xaml b/Trdo/Pages/FavoritesPage.xaml new file mode 100644 index 0000000..3373011 --- /dev/null +++ b/Trdo/Pages/FavoritesPage.xaml @@ -0,0 +1,171 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Trdo/Pages/FavoritesPage.xaml.cs b/Trdo/Pages/FavoritesPage.xaml.cs new file mode 100644 index 0000000..aac58b9 --- /dev/null +++ b/Trdo/Pages/FavoritesPage.xaml.cs @@ -0,0 +1,136 @@ +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Media; +using System; +using System.Diagnostics; +using Trdo.Models; +using Trdo.ViewModels; +using Windows.System; + +namespace Trdo.Pages; + +/// +/// A page that displays the user's favorited tracks. +/// +public sealed partial class FavoritesPage : Page +{ + private ListViewItem? _previouslySelectedContainer; + + public FavoritesViewModel ViewModel { get; } + + public FavoritesPage() + { + Debug.WriteLine("=== FavoritesPage Constructor START ==="); + + InitializeComponent(); + ViewModel = new FavoritesViewModel(); + DataContext = ViewModel; + + Debug.WriteLine($"[FavoritesPage] ViewModel created with {ViewModel.Favorites.Count} favorites"); + Debug.WriteLine("=== FavoritesPage Constructor END ==="); + } + + private void RemoveFavorite_Click(object sender, RoutedEventArgs e) + { + if (sender is Button button && button.Tag is FavoriteTrack track) + { + Debug.WriteLine($"[FavoritesPage] Remove clicked for: {track.DisplayText}"); + ViewModel.RemoveFavorite(track); + } + } + + private void FavoritesListView_SelectionChanged(object sender, SelectionChangedEventArgs e) + { + // Collapse the previously selected item + if (_previouslySelectedContainer != null) + { + StackPanel? previousExpandedContent = FindDescendant(_previouslySelectedContainer, "ExpandedContent"); + if (previousExpandedContent != null) + { + previousExpandedContent.Visibility = Visibility.Collapsed; + } + } + + // Expand the newly selected item + if (sender is ListView listView && listView.SelectedItem is FavoriteTrack) + { + ListViewItem? container = listView.ContainerFromItem(listView.SelectedItem) as ListViewItem; + if (container != null) + { + StackPanel? expandedContent = FindDescendant(container, "ExpandedContent"); + if (expandedContent != null) + { + expandedContent.Visibility = Visibility.Visible; + } + _previouslySelectedContainer = container; + } + } + else + { + _previouslySelectedContainer = null; + } + } + + private async void SpotifyLink_Click(object sender, RoutedEventArgs e) + { + if (sender is HyperlinkButton button && button.Tag is FavoriteTrack track) + { + Debug.WriteLine($"[FavoritesPage] Spotify search for: {track.DisplayText}"); + string searchQuery = Uri.EscapeDataString(track.DisplayText); + + // Try Spotify app first + string spotifyAppUri = $"spotify:search:{searchQuery}"; + try + { + bool success = await Launcher.LaunchUriAsync(new Uri(spotifyAppUri)); + if (!success) + { + // Fall back to web + string webUrl = $"https://open.spotify.com/search/{searchQuery}"; + await Launcher.LaunchUriAsync(new Uri(webUrl)); + } + } + catch + { + // Fall back to web + string webUrl = $"https://open.spotify.com/search/{searchQuery}"; + await Launcher.LaunchUriAsync(new Uri(webUrl)); + } + } + } + + private async void DiscogsLink_Click(object sender, RoutedEventArgs e) + { + if (sender is HyperlinkButton button && button.Tag is FavoriteTrack track) + { + Debug.WriteLine($"[FavoritesPage] Discogs search for: {track.DisplayText}"); + string searchQuery = Uri.EscapeDataString(track.DisplayText); + string url = $"https://www.discogs.com/search?q={searchQuery}"; + await Launcher.LaunchUriAsync(new Uri(url)); + } + } + + private T? FindDescendant(DependencyObject parent, string name = "") where T : DependencyObject + { + int childCount = VisualTreeHelper.GetChildrenCount(parent); + for (int i = 0; i < childCount; i++) + { + DependencyObject child = VisualTreeHelper.GetChild(parent, i); + + if (child is T typedChild) + { + if (string.IsNullOrEmpty(name) || (child is FrameworkElement fe && fe.Name == name)) + { + return typedChild; + } + } + + T? result = FindDescendant(child, name); + if (result != null) + { + return result; + } + } + return null; + } +} diff --git a/Trdo/Pages/NowPlayingPage.xaml b/Trdo/Pages/NowPlayingPage.xaml index 2e886ea..7e483e7 100644 --- a/Trdo/Pages/NowPlayingPage.xaml +++ b/Trdo/Pages/NowPlayingPage.xaml @@ -6,22 +6,25 @@ xmlns:converters="using:Trdo.Converters" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" + xmlns:models="using:Trdo.Models" mc:Ignorable="d"> + + - + - - + + - + - + + + + + + + + + + + + + - - - - - - - - - - - - + + + + + + + + + + + + + + + - - - + + + - + - - - - + + + + - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Trdo/Pages/NowPlayingPage.xaml.cs b/Trdo/Pages/NowPlayingPage.xaml.cs index 1f0b491..2afada0 100644 --- a/Trdo/Pages/NowPlayingPage.xaml.cs +++ b/Trdo/Pages/NowPlayingPage.xaml.cs @@ -1,6 +1,7 @@ using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Controls; using System.Diagnostics; +using Trdo.Models; using Trdo.ViewModels; namespace Trdo.Pages; @@ -36,4 +37,19 @@ private async void SpotifyLink_Click(object sender, RoutedEventArgs e) Debug.WriteLine("[NowPlayingPage] Spotify link clicked"); await ViewModel.SearchOnSpotify(); } + + private void FavoriteCurrentTrack_Click(object sender, RoutedEventArgs e) + { + Debug.WriteLine("[NowPlayingPage] Favorite current track clicked"); + ViewModel.ToggleCurrentTrackFavorite(); + } + + private void FavoriteHistoryItem_Click(object sender, RoutedEventArgs e) + { + if (sender is Button button && button.Tag is PlaylistHistoryItem item) + { + Debug.WriteLine($"[NowPlayingPage] Favorite history item clicked: {item.DisplayText}"); + ViewModel.ToggleHistoryItemFavorite(item); + } + } } diff --git a/Trdo/Pages/PlayingPage.xaml b/Trdo/Pages/PlayingPage.xaml index f208f39..b1d8924 100644 --- a/Trdo/Pages/PlayingPage.xaml +++ b/Trdo/Pages/PlayingPage.xaml @@ -12,6 +12,8 @@ + + @@ -22,6 +24,7 @@ + @@ -35,13 +38,25 @@ - + + + + UpdateFavoriteButtonState(); + // Wait for loaded to access named elements Loaded += PlayingPage_Loaded; @@ -52,6 +62,7 @@ private void PlayingPage_Loaded(object sender, RoutedEventArgs e) UpdatePlayButtonState(); UpdateStationSelection(); + UpdateFavoriteButtonState(); // Find the ShellViewModel from the parent page _shellViewModel = FindShellViewModel(); @@ -108,6 +119,21 @@ private void ViewModel_PropertyChanged(object? sender, System.ComponentModel.Pro Debug.WriteLine($"[PlayingPage] SelectedStation changed to: {ViewModel.SelectedStation?.Name ?? "null"}"); UpdateStationSelection(); } + else if (e.PropertyName == nameof(PlayerViewModel.CurrentMetadata) || + e.PropertyName == nameof(PlayerViewModel.HasNowPlaying)) + { + UpdateFavoriteButtonState(); + } + } + + private void UpdateFavoriteButtonState() + { + if (FavoriteIcon == null) + return; + + bool isFavorited = _favoritesService.IsFavorited(ViewModel.CurrentMetadata); + FavoriteIcon.Glyph = isFavorited ? FilledStar : OutlineStar; + Debug.WriteLine($"[PlayingPage] Favorite button updated. IsFavorited: {isFavorited}"); } private async void ViewModel_PlaybackError(object? sender, string errorMessage) @@ -292,6 +318,39 @@ private void NowPlayingInfo_Click(object sender, RoutedEventArgs e) _shellViewModel?.NavigateToNowPlayingPage(); } + private void FavoriteButton_Click(object sender, RoutedEventArgs e) + { + Debug.WriteLine("[PlayingPage] Favorite button clicked"); + + if (ViewModel.CurrentMetadata?.HasMetadata != true) + { + Debug.WriteLine("[PlayingPage] No metadata to favorite"); + return; + } + + string stationName = ViewModel.SelectedStation?.Name ?? "Unknown Station"; + bool isFavorited = _favoritesService.ToggleFavorite(ViewModel.CurrentMetadata, stationName); + Debug.WriteLine($"[PlayingPage] Track favorite toggled. IsFavorited: {isFavorited}"); + + // UpdateFavoriteButtonState will be called via the FavoritesChanged event + } + + private void FavoritesButton_Click(object sender, RoutedEventArgs e) + { + Debug.WriteLine("[PlayingPage] Favorites button clicked"); + _shellViewModel?.NavigateToFavoritesPage(); + } + + private void VisitSite_Click(object sender, RoutedEventArgs e) + { + if (sender is MenuFlyoutItem menuItem && menuItem.Tag is RadioStation station) + { + Debug.WriteLine($"[PlayingPage] Visit Station Site clicked: {station.Name}"); + // Navigate to AddStation page in edit mode with the station data + ViewModel.VisitWebsite(station); + } + } + private void EditStation_Click(object sender, RoutedEventArgs e) { if (sender is MenuFlyoutItem menuItem && menuItem.Tag is RadioStation station) diff --git a/Trdo/Pages/SearchStation.xaml b/Trdo/Pages/SearchStation.xaml index b526b4a..8a2abf2 100644 --- a/Trdo/Pages/SearchStation.xaml +++ b/Trdo/Pages/SearchStation.xaml @@ -11,9 +11,11 @@ + + - + + + + - + @@ -66,13 +77,42 @@ + + + + + + + + + - + + + + + - - + + + + diff --git a/Trdo/Services/FavoritesJsonContext.cs b/Trdo/Services/FavoritesJsonContext.cs new file mode 100644 index 0000000..8b28e30 --- /dev/null +++ b/Trdo/Services/FavoritesJsonContext.cs @@ -0,0 +1,16 @@ +using System.Collections.Generic; +using System.Text.Json.Serialization; +using Trdo.Models; + +namespace Trdo.Services; + +/// +/// JSON source generation context for FavoriteTrack storage. +/// This ensures JSON serialization works correctly even when the app is trimmed in Release mode. +/// +[JsonSerializable(typeof(List))] +[JsonSerializable(typeof(FavoriteTrack))] +[JsonSourceGenerationOptions(PropertyNameCaseInsensitive = true)] +internal partial class FavoritesJsonContext : JsonSerializerContext +{ +} diff --git a/Trdo/Services/FavoritesService.cs b/Trdo/Services/FavoritesService.cs new file mode 100644 index 0000000..58c22a7 --- /dev/null +++ b/Trdo/Services/FavoritesService.cs @@ -0,0 +1,179 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using Trdo.Models; +using Windows.Storage; + +namespace Trdo.Services; + +/// +/// Service for managing favorite tracks persistence. +/// +public class FavoritesService +{ + private const string FavoritesKey = "FavoriteTracks"; + + private static readonly Lazy _instance = new(() => new FavoritesService()); + public static FavoritesService Instance => _instance.Value; + + private static readonly JsonSerializerOptions _jsonOptions = new() + { + TypeInfoResolver = FavoritesJsonContext.Default + }; + + private List _cachedFavorites = []; + + public event EventHandler? FavoritesChanged; + + private FavoritesService() + { + // Load favorites on initialization + _cachedFavorites = LoadFavoritesInternal(); + } + + /// + /// Gets all favorite tracks. + /// + public List GetFavorites() + { + return [.. _cachedFavorites]; + } + + /// + /// Adds a track to favorites. + /// + public bool AddFavorite(FavoriteTrack track) + { + if (track == null || string.IsNullOrWhiteSpace(track.DisplayText)) + return false; + + // Check if already favorited (by unique key) + if (_cachedFavorites.Any(f => f.UniqueKey == track.UniqueKey)) + return false; + + _cachedFavorites.Insert(0, track); // Add to beginning (most recent first) + SaveFavorites(); + FavoritesChanged?.Invoke(this, EventArgs.Empty); + return true; + } + + /// + /// Adds a track to favorites from metadata. + /// + public bool AddFavorite(StreamMetadata metadata, string stationName) + { + if (metadata == null || !metadata.HasMetadata) + return false; + + FavoriteTrack track = FavoriteTrack.FromMetadata(metadata, stationName); + return AddFavorite(track); + } + + /// + /// Removes a track from favorites by ID. + /// + public bool RemoveFavorite(string id) + { + FavoriteTrack? track = _cachedFavorites.FirstOrDefault(f => f.Id == id); + if (track == null) + return false; + + _cachedFavorites.Remove(track); + SaveFavorites(); + FavoritesChanged?.Invoke(this, EventArgs.Empty); + return true; + } + + /// + /// Removes a favorite by matching metadata. + /// + public bool RemoveFavoriteByMetadata(StreamMetadata metadata) + { + if (metadata == null) + return false; + + string uniqueKey = $"{metadata.Artist?.ToLowerInvariant()}|{metadata.Title?.ToLowerInvariant()}|{metadata.StreamTitle?.ToLowerInvariant()}".Trim(); + FavoriteTrack? track = _cachedFavorites.FirstOrDefault(f => f.UniqueKey == uniqueKey); + + if (track == null) + return false; + + _cachedFavorites.Remove(track); + SaveFavorites(); + FavoritesChanged?.Invoke(this, EventArgs.Empty); + return true; + } + + /// + /// Toggles favorite status for a track. + /// + public bool ToggleFavorite(StreamMetadata metadata, string stationName) + { + if (IsFavorited(metadata)) + { + RemoveFavoriteByMetadata(metadata); + return false; // Now unfavorited + } + else + { + AddFavorite(metadata, stationName); + return true; // Now favorited + } + } + + /// + /// Checks if a track is favorited. + /// + public bool IsFavorited(StreamMetadata? metadata) + { + if (metadata == null || !metadata.HasMetadata) + return false; + + string uniqueKey = $"{metadata.Artist?.ToLowerInvariant()}|{metadata.Title?.ToLowerInvariant()}|{metadata.StreamTitle?.ToLowerInvariant()}".Trim(); + return _cachedFavorites.Any(f => f.UniqueKey == uniqueKey); + } + + /// + /// Checks if a track (by unique key) is favorited. + /// + public bool IsFavorited(string uniqueKey) + { + return _cachedFavorites.Any(f => f.UniqueKey == uniqueKey); + } + + private void SaveFavorites() + { + try + { + string json = JsonSerializer.Serialize(_cachedFavorites, _jsonOptions); + ApplicationData.Current.LocalSettings.Values[FavoritesKey] = json; + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"[FavoritesService] Error saving favorites: {ex.Message}"); + } + } + + private List LoadFavoritesInternal() + { + try + { + if (ApplicationData.Current.LocalSettings.Values.TryGetValue(FavoritesKey, out object? value) && + value is string json) + { + List? favorites = JsonSerializer.Deserialize>(json, _jsonOptions); + if (favorites != null) + { + return favorites; + } + } + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"[FavoritesService] Error loading favorites: {ex.Message}"); + } + + return []; + } +} diff --git a/Trdo/Services/PlaylistHistoryService.cs b/Trdo/Services/PlaylistHistoryService.cs new file mode 100644 index 0000000..6795042 --- /dev/null +++ b/Trdo/Services/PlaylistHistoryService.cs @@ -0,0 +1,124 @@ +using System; +using System.Collections.ObjectModel; +using System.Diagnostics; +using Trdo.Models; +using Trdo.ViewModels; + +namespace Trdo.Services; + +/// +/// Singleton service that manages playlist history persistence across page navigations. +/// History is maintained as long as the app is running and the stream is active. +/// +public class PlaylistHistoryService +{ + private const int MaxHistoryItems = 25; + + private static readonly Lazy _instance = new(() => new PlaylistHistoryService()); + public static PlaylistHistoryService Instance => _instance.Value; + + private readonly RadioPlayerService _player = RadioPlayerService.Instance; + private readonly FavoritesService _favoritesService = FavoritesService.Instance; + + /// + /// The playlist history showing recent tracks (most recent first). + /// + public ObservableCollection History { get; } = []; + + /// + /// Event raised when the history changes. + /// + public event EventHandler? HistoryChanged; + + private PlaylistHistoryService() + { + // Subscribe to metadata changes from the player + _player.StreamMetadataChanged += OnStreamMetadataChanged; + + // Subscribe to favorites changes to update history items + _favoritesService.FavoritesChanged += OnFavoritesChanged; + + // Check if there's already metadata (stream started before this service was accessed) + if (_player.CurrentMetadata?.HasMetadata == true) + { + Debug.WriteLine("[PlaylistHistoryService] Found existing metadata on init, adding to history"); + AddToHistory(_player.CurrentMetadata); + } + + Debug.WriteLine("[PlaylistHistoryService] Initialized and subscribed to metadata changes"); + } + + /// + /// Ensures the service is initialized. Call this early in app startup. + /// + public static void EnsureInitialized() + { + // Accessing Instance triggers the lazy initialization + _ = Instance; + Debug.WriteLine("[PlaylistHistoryService] EnsureInitialized called"); + } + + private void OnStreamMetadataChanged(object? sender, StreamMetadata metadata) + { + if (metadata?.HasMetadata != true) + return; + + AddToHistory(metadata); + } + + private void OnFavoritesChanged(object? sender, EventArgs e) + { + // Refresh favorite status on all history items + foreach (PlaylistHistoryItem item in History) + { + item.RefreshFavoriteStatus(); + } + } + + /// + /// Adds a track to the history. + /// + public void AddToHistory(StreamMetadata metadata) + { + if (metadata?.HasMetadata != true) + return; + + string stationName = PlayerViewModel.Shared.SelectedStation?.Name ?? "Unknown Station"; + PlaylistHistoryItem newItem = PlaylistHistoryItem.FromMetadata(metadata, stationName); + + // Check if this track is already at the top of the history (avoid duplicates for same track) + // This handles pause/resume within the same track + if (History.Count > 0) + { + PlaylistHistoryItem topItem = History[0]; + if (topItem.UniqueKey == newItem.UniqueKey) + { + Debug.WriteLine("[PlaylistHistoryService] Track already at top of history, skipping duplicate"); + return; + } + } + + // Insert at the beginning (most recent first) + History.Insert(0, newItem); + Debug.WriteLine($"[PlaylistHistoryService] Added to history: {newItem.DisplayText} (Total: {History.Count})"); + + // Trim history if it exceeds max + while (History.Count > MaxHistoryItems) + { + History.RemoveAt(History.Count - 1); + Debug.WriteLine($"[PlaylistHistoryService] Trimmed history to {MaxHistoryItems} items"); + } + + HistoryChanged?.Invoke(this, EventArgs.Empty); + } + + /// + /// Clears all history. + /// + public void ClearHistory() + { + History.Clear(); + Debug.WriteLine("[PlaylistHistoryService] History cleared"); + HistoryChanged?.Invoke(this, EventArgs.Empty); + } +} diff --git a/Trdo/Services/RadioPlayerService.cs b/Trdo/Services/RadioPlayerService.cs index 2f3280e..afcb01c 100644 --- a/Trdo/Services/RadioPlayerService.cs +++ b/Trdo/Services/RadioPlayerService.cs @@ -1,10 +1,14 @@ using Microsoft.UI.Dispatching; using System; using System.Diagnostics; +using System.Net.Http; +using System.Threading.Tasks; using Trdo.Models; +using Windows.Media; using Windows.Media.Core; using Windows.Media.Playback; using Windows.Storage; +using Windows.Storage.Streams; namespace Trdo.Services; @@ -14,11 +18,23 @@ public sealed partial class RadioPlayerService : IDisposable private readonly DispatcherQueue _uiQueue; private readonly StreamWatchdogService _watchdog; private readonly StreamMetadataService _metadataService; + private readonly SystemMediaTransportControls? _systemMediaControls; + private readonly HttpClient _httpClient; private double _volume = 0.5; private const string VolumeKey = "RadioVolume"; private const string WatchdogEnabledKey = "WatchdogEnabled"; private string? _streamUrl; + private string? _currentStationName; + private string? _currentStationFaviconUrl; + private string? _currentAlbumArtUrl; private bool _isInternalStateChange; + private bool _wasExternalPause; + private System.Threading.Timer? _smtcUpdateTimer; + private bool _smtcUpdatePending; + private readonly object _smtcUpdateLock = new(); + private System.Threading.Timer? _internalStateChangeTimer; + private DateTime _lastExternalPauseRecovery = DateTime.MinValue; + private bool _hasPlayedOnce; public static RadioPlayerService Instance { get; } = new(); @@ -44,7 +60,7 @@ public bool IsBuffering try { MediaPlaybackState state = _player.PlaybackSession.PlaybackState; - bool isBuffering = state == MediaPlaybackState.Opening || state == MediaPlaybackState.Buffering; + bool isBuffering = state is MediaPlaybackState.Opening or MediaPlaybackState.Buffering; Debug.WriteLine($"[RadioPlayerService] IsBuffering getter: {isBuffering}, PlaybackState: {state}"); return isBuffering; } @@ -150,6 +166,12 @@ private RadioPlayerService() _uiQueue = DispatcherQueue.GetForCurrentThread(); Debug.WriteLine($"[RadioPlayerService] DispatcherQueue obtained: {_uiQueue != null}"); + _httpClient = new HttpClient + { + Timeout = TimeSpan.FromSeconds(30) + }; + Debug.WriteLine("[RadioPlayerService] HttpClient created for album art downloads"); + _player = new MediaPlayer { AudioCategory = MediaPlayerAudioCategory.Media, @@ -168,7 +190,7 @@ private RadioPlayerService() { currentState = _player.PlaybackSession.PlaybackState; isPlaying = currentState == MediaPlaybackState.Playing; - isBuffering = currentState == MediaPlaybackState.Opening || currentState == MediaPlaybackState.Buffering; + isBuffering = currentState is MediaPlaybackState.Opening or MediaPlaybackState.Buffering; Debug.WriteLine($"[RadioPlayerService] PlaybackStateChanged event: IsPlaying={isPlaying}, IsBuffering={isBuffering}, State={currentState}, IsInternalChange={_isInternalStateChange}"); // If state change was not initiated internally (e.g., from hardware buttons), @@ -180,12 +202,27 @@ private RadioPlayerService() { _watchdog.NotifyUserIntentionToPlay(); Debug.WriteLine("[RadioPlayerService] Notified watchdog of user intention to play (hardware button)"); + + // Mark that an external play was triggered + // The Play() method will handle MediaSource recreation if needed + if (_wasExternalPause) + { + Debug.WriteLine("[RadioPlayerService] External play detected after external pause - will be handled by Play() method"); + } } else if (currentState == MediaPlaybackState.Paused) { // Only notify pause intent if explicitly paused (not buffering, opening, or other states) _watchdog.NotifyUserIntentionToPause(); Debug.WriteLine("[RadioPlayerService] Notified watchdog of user intention to pause (hardware button)"); + + // Mark that this was an external pause + _wasExternalPause = true; + Debug.WriteLine("[RadioPlayerService] Marked as external pause - will refresh stream on next play"); + + // Stop metadata polling when paused + _metadataService.StopPolling(); + Debug.WriteLine("[RadioPlayerService] Stopped metadata polling after external pause"); } // For other states (Buffering, Opening, None), don't change watchdog intent // This allows the watchdog to recover if a stream stops unexpectedly @@ -196,10 +233,11 @@ private RadioPlayerService() Debug.WriteLine($"[RadioPlayerService] EXCEPTION in PlaybackStateChanged: {ex.Message}"); return; } - TryEnqueueOnUi(() => + TryEnqueueOnUi(() => { PlaybackStateChanged?.Invoke(this, isPlaying); BufferingStateChanged?.Invoke(this, isBuffering); + ScheduleSystemMediaTransportControlsUpdate(); }); }; @@ -210,10 +248,36 @@ private RadioPlayerService() _metadataService.MetadataChanged += (_, metadata) => { Debug.WriteLine($"[RadioPlayerService] Metadata changed: {metadata.DisplayText}"); - TryEnqueueOnUi(() => StreamMetadataChanged?.Invoke(this, metadata)); + TryEnqueueOnUi(() => + { + StreamMetadataChanged?.Invoke(this, metadata); + ScheduleSystemMediaTransportControlsUpdate(); + }); }; Debug.WriteLine("[RadioPlayerService] StreamMetadataService created"); + // Initialize SystemMediaTransportControls + try + { + _systemMediaControls = _player.SystemMediaTransportControls; + if (_systemMediaControls != null) + { + _systemMediaControls.IsEnabled = true; + _systemMediaControls.IsPlayEnabled = true; + _systemMediaControls.IsPauseEnabled = true; + _systemMediaControls.IsStopEnabled = false; + _systemMediaControls.IsNextEnabled = false; + _systemMediaControls.IsPreviousEnabled = false; + + _systemMediaControls.ButtonPressed += OnSystemMediaButtonPressed; + Debug.WriteLine("[RadioPlayerService] SystemMediaTransportControls initialized"); + } + } + catch (Exception ex) + { + Debug.WriteLine($"[RadioPlayerService] Failed to initialize SystemMediaTransportControls: {ex.Message}"); + } + LoadSettings(); Debug.WriteLine("=== RadioPlayerService Constructor END ==="); @@ -279,7 +343,15 @@ public void Initialize(string streamUrl) } Debug.WriteLine("[RadioPlayerService] Calling SetStreamUrl..."); + + // Mark this as internal so we don't trigger external pause detection + SetInternalStateChange(true); SetStreamUrl(streamUrl); + + // Allow time for the MediaSource to fully initialize before first play + // This prevents the initial play from causing cascading state changes + Debug.WriteLine("[RadioPlayerService] MediaSource created and ready for playback"); + Debug.WriteLine($"=== Initialize END ==="); } @@ -301,6 +373,13 @@ public void SetStreamUrl(string streamUrl) Uri uri = new(streamUrl); // Will throw if invalid URL Debug.WriteLine($"[RadioPlayerService] URI created successfully: {uri}"); + // If changing stations, reset the first play flag + if (_streamUrl != streamUrl) + { + _hasPlayedOnce = false; + Debug.WriteLine("[RadioPlayerService] Station changed - reset first play flag"); + } + // Update the stream URL _streamUrl = streamUrl; Debug.WriteLine($"[RadioPlayerService] _streamUrl updated to: {_streamUrl}"); @@ -322,9 +401,33 @@ public void SetStreamUrl(string streamUrl) Debug.WriteLine($"[RadioPlayerService] Creating new MediaSource from URI: {uri}"); _player.Source = MediaSource.CreateFromUri(uri); Debug.WriteLine("[RadioPlayerService] New MediaSource set on player"); + + // Update SMTC with new station + ScheduleSystemMediaTransportControlsUpdate(); + Debug.WriteLine($"=== SetStreamUrl END ==="); } + /// + /// Set the current station name for display in system media controls. + /// + public void SetStationName(string stationName) + { + Debug.WriteLine($"[RadioPlayerService] Setting station name to: {stationName}"); + _currentStationName = stationName; + ScheduleSystemMediaTransportControlsUpdate(); + } + + /// + /// Set the current station favicon URL for display in system media controls. + /// + public void SetStationFavicon(string? faviconUrl) + { + Debug.WriteLine($"[RadioPlayerService] Setting station favicon to: {faviconUrl}"); + _currentStationFaviconUrl = faviconUrl; + ScheduleSystemMediaTransportControlsUpdate(); + } + /// /// Start playback of the current stream /// @@ -335,6 +438,8 @@ public void Play() Debug.WriteLine($"[RadioPlayerService] Current stream URL: {_streamUrl}"); Debug.WriteLine($"[RadioPlayerService] Current IsPlaying: {_player.PlaybackSession.PlaybackState}"); Debug.WriteLine($"[RadioPlayerService] Player.Source is null: {_player.Source == null}"); + Debug.WriteLine($"[RadioPlayerService] Has played once: {_hasPlayedOnce}"); + Debug.WriteLine($"[RadioPlayerService] Was external pause: {_wasExternalPause}"); if (string.IsNullOrWhiteSpace(_streamUrl)) { @@ -344,25 +449,51 @@ public void Play() try { - // Ensure we have a fresh media source - if (_player.Source == null) + // Only recreate MediaSource if: + // 1. First play and no source exists, OR + // 2. Following an external pause (to ensure live stream position) + bool needsRecreation = (!_hasPlayedOnce && _player.Source == null) || _wasExternalPause; + + if (needsRecreation) { - Debug.WriteLine("[RadioPlayerService] Player.Source is null, creating new MediaSource"); + if (_wasExternalPause) + { + Debug.WriteLine("[RadioPlayerService] Recreating MediaSource after external pause to seek to live position"); + } + else + { + Debug.WriteLine("[RadioPlayerService] First play - creating MediaSource"); + } + + // Dispose old source if exists + if (_player.Source is MediaSource oldMedia) + { + Debug.WriteLine("[RadioPlayerService] Disposing existing MediaSource"); + oldMedia.Reset(); + oldMedia.Dispose(); + } + + // Create fresh MediaSource Uri uri = new(_streamUrl); _player.Source = MediaSource.CreateFromUri(uri); Debug.WriteLine($"[RadioPlayerService] Created new MediaSource from URL: {_streamUrl}"); + + _hasPlayedOnce = true; } else { - Debug.WriteLine($"[RadioPlayerService] Player.Source exists, current state: {(_player.Source as MediaSource)?.State}"); + Debug.WriteLine("[RadioPlayerService] Reusing existing MediaSource - resuming playback"); } Debug.WriteLine("[RadioPlayerService] Calling _player.Play()..."); - _isInternalStateChange = true; + SetInternalStateChange(true); _player.Play(); - _isInternalStateChange = false; Debug.WriteLine("[RadioPlayerService] _player.Play() called successfully"); + // Clear the external pause flag + _wasExternalPause = false; + Debug.WriteLine("[RadioPlayerService] Cleared external pause flag"); + _watchdog.NotifyUserIntentionToPlay(); Debug.WriteLine("[RadioPlayerService] Notified watchdog of user intention to play"); @@ -383,11 +514,14 @@ public void Play() _player.Source = MediaSource.CreateFromUri(uri); Debug.WriteLine($"[RadioPlayerService] Created new MediaSource from URL: {_streamUrl}"); - _isInternalStateChange = true; + SetInternalStateChange(true); _player.Play(); - _isInternalStateChange = false; Debug.WriteLine("[RadioPlayerService] _player.Play() called successfully (retry)"); + _wasExternalPause = false; + _hasPlayedOnce = true; + Debug.WriteLine("[RadioPlayerService] Cleared external pause flag (retry)"); + _watchdog.NotifyUserIntentionToPlay(); Debug.WriteLine("[RadioPlayerService] Notified watchdog of user intention to play"); @@ -397,7 +531,7 @@ public void Play() } catch (Exception retryEx) { - _isInternalStateChange = false; + SetInternalStateChange(false); Debug.WriteLine($"[RadioPlayerService] EXCEPTION on retry: {retryEx.Message}"); throw; } @@ -426,20 +560,13 @@ public void Pause() try { Debug.WriteLine("[RadioPlayerService] Calling _player.Pause()..."); - _isInternalStateChange = true; + SetInternalStateChange(true); _player.Pause(); - _isInternalStateChange = false; Debug.WriteLine("[RadioPlayerService] _player.Pause() called successfully"); - // Clean up the media source for live streams - if (_player.Source is MediaSource media) - { - Debug.WriteLine("[RadioPlayerService] Disposing MediaSource"); - media.Reset(); - media.Dispose(); - } - _player.Source = null; - Debug.WriteLine("[RadioPlayerService] Player.Source set to null"); + // Mark that pause occurred - next play should recreate MediaSource to ensure live position + _wasExternalPause = true; + Debug.WriteLine("[RadioPlayerService] Marked for MediaSource recreation on next play (ensures live position)"); _watchdog.NotifyUserIntentionToPause(); Debug.WriteLine("[RadioPlayerService] Notified watchdog of user intention to pause"); @@ -448,15 +575,13 @@ public void Pause() _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 - // would be in "Opening" state with the OLD URL, preventing the new station from playing - Debug.WriteLine("[RadioPlayerService] Stream cleanup complete, ready for next operation"); + // Keep the media source intact so media controls remain available + // The Play() method will dispose and recreate it to ensure fresh stream + Debug.WriteLine("[RadioPlayerService] Media source kept intact for media controls"); } catch (Exception ex) { - _isInternalStateChange = false; + SetInternalStateChange(false); Debug.WriteLine($"[RadioPlayerService] EXCEPTION in Pause: {ex.Message}"); Debug.WriteLine($"[RadioPlayerService] Exception details: {ex}"); } @@ -505,11 +630,333 @@ private void TryEnqueueOnUi(DispatcherQueueHandler action) } } + private void OnSystemMediaButtonPressed(SystemMediaTransportControls sender, SystemMediaTransportControlsButtonPressedEventArgs args) + { + Debug.WriteLine($"[RadioPlayerService] System media button pressed: {args.Button}"); + + TryEnqueueOnUi(() => + { + switch (args.Button) + { + case SystemMediaTransportControlsButton.Play: + Debug.WriteLine("[RadioPlayerService] Play button pressed from system controls"); + Play(); + break; + case SystemMediaTransportControlsButton.Pause: + Debug.WriteLine("[RadioPlayerService] Pause button pressed from system controls"); + Pause(); + break; + default: + Debug.WriteLine($"[RadioPlayerService] Unhandled button: {args.Button}"); + break; + } + }); + } + + /// + /// Sets the internal state change flag with a timer to automatically clear it. + /// This ensures the flag remains true long enough to cover asynchronous state changes. + /// + /// True to mark state changes as internal, false to clear immediately + private void SetInternalStateChange(bool isInternal) + { + _isInternalStateChange = isInternal; + + if (isInternal) + { + Debug.WriteLine("[RadioPlayerService] Internal state change flag SET (will auto-clear in 1000ms)"); + + // Clear any existing timer + _internalStateChangeTimer?.Dispose(); + + // Set a timer to clear the flag after 1000ms + // This gives enough time for all async state changes from Play()/Pause() to complete + // Increased from 500ms to 1000ms to better handle slower network connections + _internalStateChangeTimer = new System.Threading.Timer( + callback: _ => + { + _isInternalStateChange = false; + Debug.WriteLine("[RadioPlayerService] Internal state change flag AUTO-CLEARED"); + }, + state: null, + dueTime: 1000, // 1000ms to cover all async state transitions including network delays + period: System.Threading.Timeout.Infinite + ); + } + else + { + Debug.WriteLine("[RadioPlayerService] Internal state change flag CLEARED immediately"); + _internalStateChangeTimer?.Dispose(); + _internalStateChangeTimer = null; + } + } + + /// + /// Schedules a debounced update to the System Media Transport Controls. + /// Multiple rapid calls will be coalesced into a single update after 100ms of inactivity. + /// + private void ScheduleSystemMediaTransportControlsUpdate() + { + lock (_smtcUpdateLock) + { + // Mark that an update is pending + _smtcUpdatePending = true; + + // Reset the timer - this will delay the update by another 100ms + _smtcUpdateTimer?.Dispose(); + _smtcUpdateTimer = new System.Threading.Timer( + callback: _ => ExecuteSystemMediaTransportControlsUpdate(), + state: null, + dueTime: 100, // 100ms delay + period: System.Threading.Timeout.Infinite // Don't repeat + ); + + Debug.WriteLine("[RadioPlayerService] SMTC update scheduled (debounced)"); + } + } + + /// + /// Executes the actual System Media Transport Controls update on the UI thread. + /// + private void ExecuteSystemMediaTransportControlsUpdate() + { + lock (_smtcUpdateLock) + { + if (!_smtcUpdatePending) + { + return; + } + + _smtcUpdatePending = false; + Debug.WriteLine("[RadioPlayerService] Executing debounced SMTC update"); + } + + // Execute the update on the UI thread + TryEnqueueOnUi(() => + { + UpdateSystemMediaTransportControls(); + }); + } + + private void UpdateSystemMediaTransportControls() + { + if (_systemMediaControls == null) + return; + + try + { + // Get the display updater + SystemMediaTransportControlsDisplayUpdater updater = _systemMediaControls.DisplayUpdater; + + // Always set the type to Music for radio stations + updater.Type = MediaPlaybackType.Music; + + // Update playback status + _systemMediaControls.PlaybackStatus = IsPlaying + ? MediaPlaybackStatus.Playing + : MediaPlaybackStatus.Paused; + + StreamMetadata metadata = CurrentMetadata; + + // Set artist and title from metadata if available + if (metadata.HasMetadata) + { + // Set artist - prefer metadata artist, fall back to station name + if (!string.IsNullOrWhiteSpace(metadata.Artist)) + { + updater.MusicProperties.Artist = metadata.Artist; + // Only set AlbumArtist when we don't have artist info, otherwise it takes precedence + updater.MusicProperties.AlbumArtist = string.Empty; + } + else + { + updater.MusicProperties.Artist = _currentStationName ?? "Radio Station"; + updater.MusicProperties.AlbumArtist = string.Empty; + } + + // Set title from metadata + if (!string.IsNullOrWhiteSpace(metadata.Title)) + { + updater.MusicProperties.Title = metadata.Title; + } + else if (!string.IsNullOrWhiteSpace(metadata.StreamTitle)) + { + updater.MusicProperties.Title = metadata.StreamTitle; + } + else + { + updater.MusicProperties.Title = "Now Playing"; + } + + // Set album title to station name for additional context + updater.MusicProperties.AlbumTitle = _currentStationName ?? "Radio Station"; + + Debug.WriteLine($"[RadioPlayerService] SMTC updated with metadata - Artist: {updater.MusicProperties.Artist}, Title: {updater.MusicProperties.Title}, Album: {updater.MusicProperties.AlbumTitle}"); + } + else + { + // No metadata available, show station name + updater.MusicProperties.Artist = _currentStationName ?? "Radio Station"; + updater.MusicProperties.Title = "Streaming..."; + updater.MusicProperties.AlbumArtist = string.Empty; + updater.MusicProperties.AlbumTitle = string.Empty; + Debug.WriteLine($"[RadioPlayerService] SMTC updated with station name: {_currentStationName}"); + } + + // Handle album art with proper priority: metadata album art > station favicon > none + // Use async/await pattern to handle fallback properly + _ = Task.Run(async () => + { + bool thumbnailSet = false; + + // Try metadata album art first + if (!string.IsNullOrWhiteSpace(metadata.AlbumArtUrl)) + { + if (metadata.AlbumArtUrl != _currentAlbumArtUrl) + { + Debug.WriteLine($"[RadioPlayerService] Attempting to set album art from metadata: {metadata.AlbumArtUrl}"); + thumbnailSet = await SetAlbumArtAsync(updater, metadata.AlbumArtUrl); + + if (thumbnailSet) + { + _currentAlbumArtUrl = metadata.AlbumArtUrl; + Debug.WriteLine($"[RadioPlayerService] Successfully set album art from metadata"); + } + else + { + Debug.WriteLine($"[RadioPlayerService] Failed to set album art from metadata, will try favicon"); + } + } + else + { + thumbnailSet = true; // Already set, no need to update + TryEnqueueOnUi(() => updater.Update()); + } + } + + // If metadata album art failed or wasn't available, try station favicon + if (!thumbnailSet && !string.IsNullOrWhiteSpace(_currentStationFaviconUrl)) + { + if (_currentStationFaviconUrl != _currentAlbumArtUrl) + { + Debug.WriteLine($"[RadioPlayerService] Attempting to set favicon as fallback: {_currentStationFaviconUrl}"); + thumbnailSet = await SetAlbumArtAsync(updater, _currentStationFaviconUrl); + + if (thumbnailSet) + { + _currentAlbumArtUrl = _currentStationFaviconUrl; + Debug.WriteLine($"[RadioPlayerService] Successfully set favicon as thumbnail"); + } + else + { + Debug.WriteLine($"[RadioPlayerService] Failed to set favicon as thumbnail"); + _currentAlbumArtUrl = null; // Reset so we can retry later + } + } + else + { + thumbnailSet = true; // Already set, no need to update + TryEnqueueOnUi(() => updater.Update()); + } + } + + // If both failed or weren't available, clear the thumbnail + if (!thumbnailSet) + { + TryEnqueueOnUi(() => + { + if (!string.IsNullOrWhiteSpace(_currentAlbumArtUrl)) + { + _currentAlbumArtUrl = null; + updater.Thumbnail = null; + Debug.WriteLine("[RadioPlayerService] Cleared album art"); + } + updater.Update(); + }); + } + }); + } + catch (Exception ex) + { + Debug.WriteLine($"[RadioPlayerService] Failed to update SystemMediaTransportControls: {ex.Message}"); + } + } + + private async Task SetAlbumArtAsync(SystemMediaTransportControlsDisplayUpdater updater, string imageUrl) + { + try + { + Debug.WriteLine($"[RadioPlayerService] Downloading album art from: {imageUrl}"); + + // Download the image + byte[] imageData = await _httpClient.GetByteArrayAsync(imageUrl); + Debug.WriteLine($"[RadioPlayerService] Downloaded {imageData.Length} bytes of album art"); + + // Create a random access stream from the image data + InMemoryRandomAccessStream stream = new(); + DataWriter writer = new(stream.GetOutputStreamAt(0)); + writer.WriteBytes(imageData); + await writer.StoreAsync(); + await writer.FlushAsync(); + writer.DetachStream(); + writer.Dispose(); + + // Seek to the beginning of the stream + stream.Seek(0); + + // Create a RandomAccessStreamReference from the stream + RandomAccessStreamReference thumbnail = RandomAccessStreamReference.CreateFromStream(stream); + + // Set the thumbnail on the UI thread + bool success = false; + TryEnqueueOnUi(() => + { + try + { + updater.Thumbnail = thumbnail; + updater.Update(); + Debug.WriteLine("[RadioPlayerService] Album art set successfully"); + success = true; + } + catch (Exception ex) + { + Debug.WriteLine($"[RadioPlayerService] Failed to set album art thumbnail: {ex.Message}"); + } + finally + { + // Dispose the stream after setting the thumbnail + stream?.Dispose(); + } + }); + return success; + } + catch (HttpRequestException ex) + { + Debug.WriteLine($"[RadioPlayerService] Failed to download album art: {ex.Message}"); + return false; + } + catch (Exception ex) + { + Debug.WriteLine($"[RadioPlayerService] Error setting album art: {ex.Message}"); + return false; + } + } + public void Dispose() { Debug.WriteLine("[RadioPlayerService] Dispose called"); + + // Dispose the debounce timer + _smtcUpdateTimer?.Dispose(); + _smtcUpdateTimer = null; + + // Dispose the internal state change timer + _internalStateChangeTimer?.Dispose(); + _internalStateChangeTimer = null; + _watchdog.Dispose(); _metadataService.Dispose(); + _httpClient.Dispose(); if (_player.Source is MediaSource media) { diff --git a/Trdo/Services/SettingsService.cs b/Trdo/Services/SettingsService.cs new file mode 100644 index 0000000..f193dbc --- /dev/null +++ b/Trdo/Services/SettingsService.cs @@ -0,0 +1,55 @@ +using Windows.Storage; + +namespace Trdo.Services; + +/// +/// Centralized service for managing application settings +/// +public static class SettingsService +{ + private const string IsFirstRunKey = "IsFirstRun"; + + /// + /// Gets whether this is the first run of the application + /// + public static bool IsFirstRun + { + get + { + try + { + if (ApplicationData.Current.LocalSettings.Values.TryGetValue(IsFirstRunKey, out object? value)) + { + return value switch + { + bool b => b, + string s when bool.TryParse(s, out bool b2) => b2, + _ => true // Default to true if value is unexpected + }; + } + // If key doesn't exist, it's the first run + return true; + } + catch + { + // If any error occurs, default to true + return true; + } + } + } + + /// + /// Marks that the first run has been completed + /// + public static void MarkFirstRunComplete() + { + try + { + ApplicationData.Current.LocalSettings.Values[IsFirstRunKey] = false; + } + catch + { + // Silently fail if unable to save + } + } +} diff --git a/Trdo/Services/StreamMetadataService.cs b/Trdo/Services/StreamMetadataService.cs index 08a189d..1de8bb0 100644 --- a/Trdo/Services/StreamMetadataService.cs +++ b/Trdo/Services/StreamMetadataService.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Diagnostics; using System.IO; using System.Net.Http; @@ -24,8 +25,9 @@ public sealed class StreamMetadataService : IDisposable /// /// Polling interval for metadata updates. + /// Using a longer interval (30s) to reduce network load and minimize potential interference with the main stream. /// - private readonly TimeSpan _pollingInterval = TimeSpan.FromSeconds(15); + private readonly TimeSpan _pollingInterval = TimeSpan.FromSeconds(30); /// /// Event raised when stream metadata changes. @@ -43,18 +45,23 @@ public StreamMetadataService() { UseProxy = false, AutomaticDecompression = System.Net.DecompressionMethods.None, - // Short timeout for metadata requests since we only need headers - ConnectTimeout = TimeSpan.FromSeconds(10) + // Enable connection pooling and reuse to reduce overhead + PooledConnectionLifetime = TimeSpan.FromMinutes(2), + PooledConnectionIdleTimeout = TimeSpan.FromMinutes(1), + // Shorter timeout for metadata requests since we only read a small amount of data + ConnectTimeout = TimeSpan.FromSeconds(5) }; _httpClient = new HttpClient(handler) { - Timeout = TimeSpan.FromSeconds(15) + // Reduced timeout since we abort after reading metadata + Timeout = TimeSpan.FromSeconds(10) }; // Request ICY metadata by adding the required header _httpClient.DefaultRequestHeaders.Add("Icy-MetaData", "1"); _httpClient.DefaultRequestHeaders.Add("User-Agent", "Trdo/1.0"); + _httpClient.DefaultRequestHeaders.Connection.Add("keep-alive"); } /// @@ -292,6 +299,8 @@ private async Task FetchMetadataAsync(CancellationToken cancellationToken) /// /// Parses an ICY metadata string into a StreamMetadata object. /// Format is typically: StreamTitle='Artist - Song';StreamUrl='...'; + /// Alternative format: Exploring title="Song",artist="Artist",url="...",amgArtworkURL="..." + /// Another format: StreamTitle='Artist - text="Song" amgArtworkURL="..."'; /// private static StreamMetadata ParseIcyMetadata(string metadataStr) { @@ -302,6 +311,16 @@ private static StreamMetadata ParseIcyMetadata(string metadataStr) return metadata; } + Debug.WriteLine($"[StreamMetadataService] Raw metadata String: {metadataStr}"); + + // Check for "Exploring" format (used by iHeartRadio and some other stations) + if (metadataStr.Contains("Exploring ", StringComparison.OrdinalIgnoreCase)) + { + ParseExploringFormat(metadataStr, metadata); + return metadata; + } + + // Standard ICY format parsing // Extract StreamTitle const string streamTitleKey = "StreamTitle='"; int titleStart = metadataStr.IndexOf(streamTitleKey, StringComparison.OrdinalIgnoreCase); @@ -316,14 +335,170 @@ private static StreamMetadata ParseIcyMetadata(string metadataStr) metadata.StreamTitle = metadataStr[titleStart..titleEnd]; Debug.WriteLine($"[StreamMetadataService] StreamTitle: {metadata.StreamTitle}"); - // Try to parse "Artist - Title" format - ParseArtistAndTitle(metadata); + // Check if this is the "Artist - text=" format (iHeartRadio variant) + if (metadata.StreamTitle.Contains(" - text=\"", StringComparison.OrdinalIgnoreCase)) + { + ParseIHeartRadioFormat(metadata.StreamTitle, metadata); + } + else + { + // Try to parse "Artist - Title" format + ParseArtistAndTitle(metadata); + } + + // Try to extract album artwork from within StreamTitle if present + string? artworkUrl = ExtractAttribute(metadata.StreamTitle, "amgArtworkURL"); + if (!string.IsNullOrWhiteSpace(artworkUrl) && IsImageUrl(artworkUrl)) + { + metadata.AlbumArtUrl = artworkUrl; + Debug.WriteLine($"[StreamMetadataService] AlbumArtUrl from StreamTitle: {metadata.AlbumArtUrl}"); + } + } + } + + // Extract StreamUrl (often contains album art URL) + const string streamUrlKey = "StreamUrl='"; + int urlStart = metadataStr.IndexOf(streamUrlKey, StringComparison.OrdinalIgnoreCase); + + if (urlStart >= 0) + { + urlStart += streamUrlKey.Length; + int urlEnd = metadataStr.IndexOf("';", urlStart, StringComparison.Ordinal); + + if (urlEnd > urlStart) + { + string streamUrl = metadataStr[urlStart..urlEnd]; + // Check if it's an image URL + if (IsImageUrl(streamUrl)) + { + metadata.AlbumArtUrl = streamUrl; + Debug.WriteLine($"[StreamMetadataService] AlbumArtUrl: {metadata.AlbumArtUrl}"); + } } } return metadata; } + /// + /// Parses the iHeartRadio variant format where StreamTitle contains structured data. + /// Format: "Artist - text="Song Title" amgArtworkURL="..." ..." + /// + private static void ParseIHeartRadioFormat(string streamTitle, StreamMetadata metadata) + { + // Find the " - text=" separator + int separatorIndex = streamTitle.IndexOf(" - text=\"", StringComparison.OrdinalIgnoreCase); + if (separatorIndex < 0) + return; + + // Extract artist (everything before " - text=") + string artist = streamTitle[..separatorIndex].Trim(); + if (!string.IsNullOrWhiteSpace(artist)) + { + metadata.Artist = artist; + Debug.WriteLine($"[StreamMetadataService] iHeartRadio format - Artist: {artist}"); + } + + // Extract title from text attribute + string? title = ExtractAttribute(streamTitle, "text"); + if (!string.IsNullOrWhiteSpace(title)) + { + metadata.Title = title; + Debug.WriteLine($"[StreamMetadataService] iHeartRadio format - Title: {title}"); + } + + // Update StreamTitle to clean format + if (!string.IsNullOrWhiteSpace(artist) && !string.IsNullOrWhiteSpace(title)) + { + metadata.StreamTitle = $"{artist} - {title}"; + Debug.WriteLine($"[StreamMetadataService] iHeartRadio format - Cleaned StreamTitle: {metadata.StreamTitle}"); + } + } + + /// + /// Parses the "Exploring" metadata format used by iHeartRadio and similar stations. + /// Format: Exploring title="Song",artist="Artist",amgArtworkURL="http://..." + /// + private static void ParseExploringFormat(string metadataStr, StreamMetadata metadata) + { + // Extract title + string? title = ExtractAttribute(metadataStr, "title"); + if (!string.IsNullOrWhiteSpace(title)) + { + metadata.Title = title; + Debug.WriteLine($"[StreamMetadataService] Exploring format - Title: {title}"); + } + + // Extract artist + string? artist = ExtractAttribute(metadataStr, "artist"); + if (!string.IsNullOrWhiteSpace(artist)) + { + metadata.Artist = artist; + Debug.WriteLine($"[StreamMetadataService] Exploring format - Artist: {artist}"); + } + + // Build StreamTitle from artist and title + if (!string.IsNullOrWhiteSpace(artist) && !string.IsNullOrWhiteSpace(title)) + { + metadata.StreamTitle = $"{artist} - {title}"; + } + else if (!string.IsNullOrWhiteSpace(title)) + { + metadata.StreamTitle = title; + } + + // Extract album artwork URL (try multiple possible attribute names) + string? artworkUrl = ExtractAttribute(metadataStr, "amgArtworkURL") + ?? ExtractAttribute(metadataStr, "artworkURL") + ?? ExtractAttribute(metadataStr, "url"); + + if (!string.IsNullOrWhiteSpace(artworkUrl) && IsImageUrl(artworkUrl)) + { + metadata.AlbumArtUrl = artworkUrl; + Debug.WriteLine($"[StreamMetadataService] Exploring format - AlbumArtUrl: {artworkUrl}"); + } + } + + /// + /// Extracts an attribute value from a metadata string. + /// Example: title="Without Me" returns "Without Me" + /// + private static string? ExtractAttribute(string metadataStr, string attributeName) + { + string pattern = $"{attributeName}=\""; + int startIndex = metadataStr.IndexOf(pattern, StringComparison.OrdinalIgnoreCase); + + if (startIndex < 0) + return null; + + startIndex += pattern.Length; + int endIndex = metadataStr.IndexOf('"', startIndex); + + if (endIndex < 0) + return null; + + return metadataStr[startIndex..endIndex]; + } + + /// + /// Checks if a URL appears to be an image URL based on extension. + /// + private static bool IsImageUrl(string url) + { + if (string.IsNullOrWhiteSpace(url)) + return false; + + string lowerUrl = url.ToLowerInvariant(); + return lowerUrl.EndsWith(".jpg") || + lowerUrl.EndsWith(".jpeg") || + lowerUrl.EndsWith(".png") || + lowerUrl.EndsWith(".gif") || + lowerUrl.EndsWith(".webp") || + lowerUrl.Contains(".jpg?") || + lowerUrl.Contains(".jpeg?") || + lowerUrl.Contains(".png?"); + } + /// /// Attempts to parse Artist and Title from the StreamTitle using common formats. /// @@ -359,9 +534,9 @@ private static void ParseArtistAndTitle(StreamMetadata metadata) private static int GetIcyMetaInterval(HttpResponseMessage response) { // Check response headers first - if (response.Headers.TryGetValues("icy-metaint", out var metaIntValues)) + if (response.Headers.TryGetValues("icy-metaint", out IEnumerable? metaIntValues)) { - foreach (var metaIntStr in metaIntValues) + foreach (string metaIntStr in metaIntValues) { if (int.TryParse(metaIntStr, out int parsed)) { @@ -371,9 +546,9 @@ private static int GetIcyMetaInterval(HttpResponseMessage response) } // Also check content headers (some servers send it there) - if (response.Content.Headers.TryGetValues("icy-metaint", out var contentMetaIntValues)) + if (response.Content.Headers.TryGetValues("icy-metaint", out IEnumerable? contentMetaIntValues)) { - foreach (var val in contentMetaIntValues) + foreach (string val in contentMetaIntValues) { if (int.TryParse(val, out int parsed)) { @@ -393,7 +568,8 @@ private void UpdateMetadata(StreamMetadata newMetadata) // Check if metadata actually changed if (_currentMetadata.StreamTitle == newMetadata.StreamTitle && _currentMetadata.Artist == newMetadata.Artist && - _currentMetadata.Title == newMetadata.Title) + _currentMetadata.Title == newMetadata.Title && + _currentMetadata.AlbumArtUrl == newMetadata.AlbumArtUrl) { return; } diff --git a/Trdo/Trdo.csproj b/Trdo/Trdo.csproj index 571c3b6..e93e86f 100644 --- a/Trdo/Trdo.csproj +++ b/Trdo/Trdo.csproj @@ -19,7 +19,10 @@ + + + @@ -75,12 +78,20 @@ PreserveNewest + + PreserveNewest + MSBuild:Compile + + + MSBuild:Compile + + MSBuild:Compile @@ -106,6 +117,11 @@ MSBuild:Compile + + + MSBuild:Compile + +