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/MainWindow.xaml.cs b/Trdo/MainWindow.xaml.cs
deleted file mode 100644
index db409d8..0000000
--- a/Trdo/MainWindow.xaml.cs
+++ /dev/null
@@ -1,327 +0,0 @@
-using Microsoft.UI.Windowing;
-using Microsoft.UI.Xaml;
-using Microsoft.UI.Xaml.Controls.Primitives;
-using System;
-using System.Diagnostics;
-using System.Runtime.InteropServices;
-using Trdo.ViewModels;
-using Windows.ApplicationModel;
-using Windows.Graphics;
-
-namespace Trdo;
-
-public sealed partial class MainWindow : Window
-{
- private readonly PlayerViewModel _vm = PlayerViewModel.Shared; // Use shared instance
- private bool _initDone;
- private StartupTask? _startupTask;
-
- public MainWindow()
- {
- Debug.WriteLine("=== MainWindow Constructor START ===");
-
- InitializeComponent();
- WindowHelper.Track(this);
-
- // Use WinAppSDK TitleBar control as custom title bar
- try
- {
- ExtendsContentIntoTitleBar = true;
- SetTitleBar(SimpleTitleBar);
- }
- catch { }
-
- // Position window small at bottom-right on first show
- TryPositionBottomRightSmall();
-
- // Intercept window closing to just hide instead of exiting the app
- try
- {
- AppWindow.Closing += OnAppWindowClosing;
- }
- catch { /* AppWindow may not be available in some environments */ }
-
- Debug.WriteLine($"[MainWindow] Initial ViewModel state - IsPlaying: {_vm.IsPlaying}, Volume: {_vm.Volume}");
- Debug.WriteLine($"[MainWindow] Initial selected station: {_vm.SelectedStation?.Name ?? "null"}");
- Debug.WriteLine($"[MainWindow] Initial stream URL: {_vm.StreamUrl}");
-
- UpdatePlayPauseButton();
- VolumeSlider.Value = _vm.Volume;
- VolumeValue.Text = ((int)(_vm.Volume * 100)).ToString();
-
- // Display current stream URL (read-only display)
- if (StreamUrlTextBox != null)
- {
- StreamUrlTextBox.Text = _vm.StreamUrl;
- StreamUrlTextBox.IsReadOnly = true; // Make it read-only as stations manage URLs now
- }
-
- // Initialize watchdog toggle
- WatchdogToggle.IsOn = _vm.WatchdogEnabled;
- Debug.WriteLine($"[MainWindow] Watchdog enabled: {_vm.WatchdogEnabled}");
-
- _vm.PropertyChanged += (_, args) =>
- {
- Debug.WriteLine($"[MainWindow] ViewModel PropertyChanged: {args.PropertyName}");
-
- if (args.PropertyName == nameof(PlayerViewModel.IsPlaying))
- {
- Debug.WriteLine($"[MainWindow] IsPlaying changed to: {_vm.IsPlaying}");
- UpdatePlayPauseButton();
- }
- else if (args.PropertyName == nameof(PlayerViewModel.Volume))
- {
- if (Math.Abs(VolumeSlider.Value - _vm.Volume) > 0.0001)
- {
- Debug.WriteLine($"[MainWindow] Volume changed to: {_vm.Volume}");
- VolumeSlider.Value = _vm.Volume;
- }
- VolumeValue.Text = ((int)(_vm.Volume * 100)).ToString();
- }
- else if (args.PropertyName == nameof(PlayerViewModel.StreamUrl))
- {
- Debug.WriteLine($"[MainWindow] StreamUrl changed to: {_vm.StreamUrl}");
- if (StreamUrlTextBox != null && StreamUrlTextBox.Text != _vm.StreamUrl)
- StreamUrlTextBox.Text = _vm.StreamUrl;
- }
- else if (args.PropertyName == nameof(PlayerViewModel.WatchdogStatus))
- {
- if (WatchdogStatusText != null)
- WatchdogStatusText.Text = _vm.WatchdogStatus;
- }
- else if (args.PropertyName == nameof(PlayerViewModel.WatchdogEnabled))
- {
- Debug.WriteLine($"[MainWindow] WatchdogEnabled changed to: {_vm.WatchdogEnabled}");
- if (WatchdogToggle.IsOn != _vm.WatchdogEnabled)
- WatchdogToggle.IsOn = _vm.WatchdogEnabled;
- }
- };
-
- _ = InitializeStartupToggleAsync();
-
- Debug.WriteLine("=== MainWindow Constructor END ===");
- }
-
- private void TryPositionBottomRightSmall()
- {
- try
- {
- AppWindow? appWin = this.AppWindow;
- if (appWin is null)
- return;
-
- // Base size in DIPs; scale to monitor DPI to get pixels
- double scale = GetScaleForCurrentWindow();
- int width = (int)Math.Round(420 * scale);
- int height = (int)Math.Round(260 * scale);
- int margin = (int)Math.Round(12 * scale);
-
- // Use the display area for this window
- DisplayArea displayArea = DisplayArea.GetFromWindowId(appWin.Id, DisplayAreaFallback.Primary);
- RectInt32 workArea = displayArea.WorkArea; // work area accounts for taskbar
-
- // Ensure we don't exceed work area
- width = Math.Min(width, workArea.Width);
- height = Math.Min(height, workArea.Height);
-
- int x = workArea.X + Math.Max(0, workArea.Width - width - margin);
- int y = workArea.Y + Math.Max(0, workArea.Height - height - margin);
-
- RectInt32 rect = new(x, y, width, height);
- appWin.MoveAndResize(rect);
-
- if (appWin.Presenter is OverlappedPresenter presenter)
- {
- presenter.IsResizable = true;
- presenter.IsMaximizable = true;
- presenter.IsMinimizable = true;
- }
- }
- catch
- {
- // best-effort; ignore if positioning fails
- }
- }
-
- private static class NativeMethods
- {
- public const uint MONITOR_DEFAULTTONEAREST = 0x00000002;
-
- [DllImport("user32.dll")]
- public static extern IntPtr MonitorFromWindow(IntPtr hwnd, uint dwFlags);
-
- [DllImport("Shcore.dll")]
- public static extern int GetDpiForMonitor(IntPtr hmonitor, Monitor_DPI_Type dpiType, out uint dpiX, out uint dpiY);
-
- [DllImport("user32.dll")]
- public static extern uint GetDpiForWindow(IntPtr hwnd);
- }
-
- private enum Monitor_DPI_Type
- {
- MDT_EFFECTIVE_DPI = 0,
- MDT_ANGULAR_DPI = 1,
- MDT_RAW_DPI = 2,
- MDT_DEFAULT = MDT_EFFECTIVE_DPI
- }
-
- private double GetScaleForCurrentWindow()
- {
- try
- {
- nint hwnd = WinRT.Interop.WindowNative.GetWindowHandle(this);
- if (hwnd != IntPtr.Zero)
- {
- // Try monitor DPI first
- IntPtr hmon = NativeMethods.MonitorFromWindow(hwnd, NativeMethods.MONITOR_DEFAULTTONEAREST);
- if (hmon != IntPtr.Zero)
- {
- if (NativeMethods.GetDpiForMonitor(hmon, Monitor_DPI_Type.MDT_EFFECTIVE_DPI, out uint dx, out uint _) == 0 && dx > 0)
- {
- return dx / 96.0;
- }
- }
-
- // Fallback to window DPI
- uint dpi = NativeMethods.GetDpiForWindow(hwnd);
- if (dpi > 0)
- {
- return dpi / 96.0;
- }
- }
- }
- catch
- {
- // ignore and use default scale
- }
- return 1.0;
- }
-
- private void OnAppWindowClosing(AppWindow sender, AppWindowClosingEventArgs args)
- {
- // Cancel the close and just hide the window so playback continues from tray
- args.Cancel = true;
- try { sender.Hide(); } catch { }
- }
-
- private void PlayPauseButton_Click(object sender, RoutedEventArgs e)
- {
- Debug.WriteLine("=== PlayPauseButton_Click (MainWindow) START ===");
- Debug.WriteLine($"[MainWindow] Current IsPlaying: {_vm.IsPlaying}");
- Debug.WriteLine($"[MainWindow] Current selected station: {_vm.SelectedStation?.Name ?? "null"}");
- Debug.WriteLine($"[MainWindow] Current stream URL: {_vm.StreamUrl}");
-
- _vm.Toggle();
- Debug.WriteLine($"[MainWindow] After Toggle - IsPlaying: {_vm.IsPlaying}");
-
- UpdatePlayPauseButton();
-
- Debug.WriteLine("=== PlayPauseButton_Click (MainWindow) END ===");
- }
-
- private void VolumeSlider_ValueChanged(object sender, RangeBaseValueChangedEventArgs e)
- {
- Debug.WriteLine($"[MainWindow] Volume slider changed to: {e.NewValue}");
- _vm.Volume = e.NewValue;
- VolumeValue.Text = ((int)(_vm.Volume * 100)).ToString();
- }
-
- private void UpdatePlayPauseButton()
- {
- if (PlayPauseButton is not null)
- {
- string newContent = _vm.IsPlaying ? "Pause" : "Play";
- Debug.WriteLine($"[MainWindow] Updating PlayPauseButton to: {newContent}");
- PlayPauseButton.Content = newContent;
- }
- }
-
- private async System.Threading.Tasks.Task InitializeStartupToggleAsync()
- {
- try
- {
- _startupTask = await StartupTask.GetAsync("TrdoStartup");
- _initDone = true;
- UpdateStartupToggleFromState();
- }
- catch
- {
- // Could not get StartupTask (likely unpackaged). Disable toggle.
- if (StartupToggle != null)
- {
- StartupToggle.IsEnabled = false;
- StartupToggle.IsOn = false;
- }
- }
- }
-
- private void UpdateStartupToggleFromState()
- {
- if (_startupTask is null || StartupToggle is null) return;
- switch (_startupTask.State)
- {
- case StartupTaskState.Enabled:
- StartupToggle.IsEnabled = true;
- StartupToggle.IsOn = true;
- StartupToggle.Header = "Start with Windows";
- break;
- case StartupTaskState.Disabled:
- StartupToggle.IsEnabled = true;
- StartupToggle.IsOn = false;
- StartupToggle.Header = "Start with Windows";
- break;
- case StartupTaskState.DisabledByUser:
- StartupToggle.IsEnabled = false;
- StartupToggle.IsOn = false;
- StartupToggle.Header = "Start with Windows (disabled in Settings)";
- break;
- case StartupTaskState.DisabledByPolicy:
- default:
- StartupToggle.IsEnabled = false;
- StartupToggle.IsOn = false;
- StartupToggle.Header = "Start with Windows (disabled by policy)";
- break;
- }
- }
-
- private async void StartupToggle_Toggled(object sender, RoutedEventArgs e)
- {
- if (!_initDone || _startupTask is null || StartupToggle is null) return;
-
- try
- {
- if (StartupToggle.IsOn)
- {
- switch (_startupTask.State)
- {
- case StartupTaskState.Disabled:
- StartupTaskState result = await _startupTask.RequestEnableAsync();
- break;
- case StartupTaskState.DisabledByUser:
- // no-op: cannot enable programmatically
- break;
- }
- }
- else
- {
- if (_startupTask.State == StartupTaskState.Enabled)
- {
- _startupTask.Disable();
- }
- }
- }
- catch
- {
- // ignore errors
- }
-
- // Reflect actual state after operation
- UpdateStartupToggleFromState();
- }
-
- private void WatchdogToggle_Toggled(object sender, RoutedEventArgs e)
- {
- Debug.WriteLine($"[MainWindow] WatchdogToggle toggled to: {WatchdogToggle.IsOn}");
- _vm.WatchdogEnabled = WatchdogToggle.IsOn;
- }
-}
diff --git a/Trdo/Models/FavoriteTrack.cs b/Trdo/Models/FavoriteTrack.cs
new file mode 100644
index 0000000..677bc56
--- /dev/null
+++ b/Trdo/Models/FavoriteTrack.cs
@@ -0,0 +1,76 @@
+using System;
+
+namespace Trdo.Models;
+
+///
+/// Represents a track that has been favorited by the user.
+///
+public class FavoriteTrack
+{
+ ///
+ /// Unique identifier for this favorite track.
+ ///
+ public string Id { get; set; } = Guid.NewGuid().ToString();
+
+ ///
+ /// The name of the radio station this track was playing on.
+ ///
+ public string StationName { 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;
+
+ ///
+ /// The full stream title string from the metadata.
+ ///
+ public string StreamTitle { get; set; } = string.Empty;
+
+ ///
+ /// When this track was favorited.
+ ///
+ public DateTime FavoritedAt { get; set; } = DateTime.Now;
+
+ ///
+ /// Gets a display-friendly string for the track.
+ ///
+ 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 unique key for comparison purposes (to avoid duplicate favorites).
+ ///
+ public string UniqueKey => $"{Artist?.ToLowerInvariant()}|{Title?.ToLowerInvariant()}|{StreamTitle?.ToLowerInvariant()}".Trim();
+
+ ///
+ /// Creates a FavoriteTrack from stream metadata.
+ ///
+ public static FavoriteTrack FromMetadata(StreamMetadata metadata, string stationName)
+ {
+ return new FavoriteTrack
+ {
+ StationName = stationName,
+ Artist = metadata.Artist,
+ Title = metadata.Title,
+ StreamTitle = metadata.StreamTitle,
+ FavoritedAt = DateTime.Now
+ };
+ }
+}
diff --git a/Trdo/Models/PlaylistHistoryItem.cs b/Trdo/Models/PlaylistHistoryItem.cs
new file mode 100644
index 0000000..e3ab37a
--- /dev/null
+++ b/Trdo/Models/PlaylistHistoryItem.cs
@@ -0,0 +1,209 @@
+using System;
+using System.ComponentModel;
+using System.Runtime.CompilerServices;
+using Trdo.Services;
+
+namespace Trdo.Models;
+
+///
+/// Represents an item in the playlist history, wrapping stream metadata with additional context.
+///
+public class PlaylistHistoryItem : INotifyPropertyChanged
+{
+ private readonly FavoritesService _favoritesService = FavoritesService.Instance;
+
+ private string _artist = string.Empty;
+ private string _title = string.Empty;
+ private string _streamTitle = string.Empty;
+ private string _stationName = string.Empty;
+ private DateTime _playedAt = DateTime.Now;
+ private bool _isFavorited;
+
+ public event PropertyChangedEventHandler? PropertyChanged;
+
+ public string Artist
+ {
+ get => _artist;
+ set
+ {
+ if (_artist == value) return;
+ _artist = value;
+ OnPropertyChanged();
+ OnPropertyChanged(nameof(DisplayText));
+ OnPropertyChanged(nameof(HasArtist));
+ OnPropertyChanged(nameof(UniqueKey));
+ }
+ }
+
+ public string Title
+ {
+ get => _title;
+ set
+ {
+ if (_title == value) return;
+ _title = value;
+ OnPropertyChanged();
+ OnPropertyChanged(nameof(DisplayText));
+ OnPropertyChanged(nameof(HasTitle));
+ OnPropertyChanged(nameof(UniqueKey));
+ }
+ }
+
+ public string StreamTitle
+ {
+ get => _streamTitle;
+ set
+ {
+ if (_streamTitle == value) return;
+ _streamTitle = value;
+ OnPropertyChanged();
+ OnPropertyChanged(nameof(DisplayText));
+ OnPropertyChanged(nameof(UniqueKey));
+ }
+ }
+
+ public string StationName
+ {
+ get => _stationName;
+ set
+ {
+ if (_stationName == value) return;
+ _stationName = value;
+ OnPropertyChanged();
+ }
+ }
+
+ public DateTime PlayedAt
+ {
+ get => _playedAt;
+ set
+ {
+ if (_playedAt == value) return;
+ _playedAt = value;
+ OnPropertyChanged();
+ OnPropertyChanged(nameof(FormattedTime));
+ OnPropertyChanged(nameof(ShowDate));
+ }
+ }
+
+ public bool IsFavorited
+ {
+ get => _isFavorited;
+ set
+ {
+ if (_isFavorited == value) return;
+ _isFavorited = value;
+ OnPropertyChanged();
+ }
+ }
+
+ ///
+ /// Gets a display-friendly string for the track.
+ ///
+ public string DisplayText
+ {
+ get
+ {
+ if (!string.IsNullOrWhiteSpace(Artist) && !string.IsNullOrWhiteSpace(Title))
+ return $"{Artist} - {Title}";
+
+ if (!string.IsNullOrWhiteSpace(StreamTitle))
+ return StreamTitle;
+
+ return string.Empty;
+ }
+ }
+
+ ///
+ /// Gets a formatted time string for display.
+ /// Shows time only if today, otherwise shows date and time.
+ ///
+ public string FormattedTime
+ {
+ get
+ {
+ if (PlayedAt.Date == DateTime.Today)
+ {
+ return PlayedAt.ToString("h:mm tt");
+ }
+ else
+ {
+ return PlayedAt.ToString("M/d h:mm tt");
+ }
+ }
+ }
+
+ ///
+ /// Indicates whether to show the date (true if not today).
+ ///
+ public bool ShowDate => PlayedAt.Date != DateTime.Today;
+
+ ///
+ /// Indicates whether this item has artist info.
+ ///
+ public bool HasArtist => !string.IsNullOrWhiteSpace(Artist);
+
+ ///
+ /// Indicates whether this item has title info.
+ ///
+ public bool HasTitle => !string.IsNullOrWhiteSpace(Title);
+
+ ///
+ /// Gets a unique key for comparison purposes.
+ ///
+ public string UniqueKey => $"{Artist?.ToLowerInvariant()}|{Title?.ToLowerInvariant()}|{StreamTitle?.ToLowerInvariant()}".Trim();
+
+ ///
+ /// Creates a PlaylistHistoryItem from stream metadata.
+ ///
+ public static PlaylistHistoryItem FromMetadata(StreamMetadata metadata, string stationName)
+ {
+ FavoritesService favoritesService = FavoritesService.Instance;
+
+ return new PlaylistHistoryItem
+ {
+ Artist = metadata.Artist,
+ Title = metadata.Title,
+ StreamTitle = metadata.StreamTitle,
+ StationName = stationName,
+ PlayedAt = DateTime.Now,
+ IsFavorited = favoritesService.IsFavorited(metadata)
+ };
+ }
+
+ ///
+ /// Converts this history item to StreamMetadata for favorites operations.
+ ///
+ public StreamMetadata ToStreamMetadata()
+ {
+ return new StreamMetadata
+ {
+ Artist = Artist,
+ Title = Title,
+ StreamTitle = StreamTitle
+ };
+ }
+
+ ///
+ /// Toggles the favorite status of this track.
+ ///
+ public void ToggleFavorite()
+ {
+ StreamMetadata metadata = ToStreamMetadata();
+ IsFavorited = _favoritesService.ToggleFavorite(metadata, StationName);
+ }
+
+ ///
+ /// Updates the favorited status from the service.
+ ///
+ public void RefreshFavoriteStatus()
+ {
+ StreamMetadata metadata = ToStreamMetadata();
+ IsFavorited = _favoritesService.IsFavorited(metadata);
+ }
+
+ protected void OnPropertyChanged([CallerMemberName] string? propertyName = null)
+ {
+ PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
+ }
+}
diff --git a/Trdo/Models/RadioBrowserStation.cs b/Trdo/Models/RadioBrowserStation.cs
index 97fa961..5235fe7 100644
--- a/Trdo/Models/RadioBrowserStation.cs
+++ b/Trdo/Models/RadioBrowserStation.cs
@@ -68,7 +68,9 @@ public RadioStation ToRadioStation()
return new RadioStation
{
Name = Name,
- StreamUrl = GetStreamUrl()
+ StreamUrl = GetStreamUrl(),
+ Homepage = !string.IsNullOrWhiteSpace(Homepage) ? Homepage : null,
+ FaviconUrl = !string.IsNullOrWhiteSpace(Favicon) ? Favicon : null
};
}
}
diff --git a/Trdo/Models/RadioStation.cs b/Trdo/Models/RadioStation.cs
index 2319b21..464439f 100644
--- a/Trdo/Models/RadioStation.cs
+++ b/Trdo/Models/RadioStation.cs
@@ -3,10 +3,12 @@
namespace Trdo.Models;
-public class RadioStation : INotifyPropertyChanged
+public partial class RadioStation : INotifyPropertyChanged
{
private string _name = string.Empty;
private string _streamUrl = string.Empty;
+ private string? _homepage;
+ private string? _faviconUrl;
public event PropertyChangedEventHandler? PropertyChanged;
@@ -32,6 +34,34 @@ public required string StreamUrl
}
}
+ ///
+ /// The homepage URL of the radio station, if available.
+ ///
+ public string? Homepage
+ {
+ get => _homepage;
+ set
+ {
+ if (value == _homepage) return;
+ _homepage = value;
+ OnPropertyChanged();
+ }
+ }
+
+ ///
+ /// The URL to the station's favicon/logo image, if available.
+ ///
+ public string? FaviconUrl
+ {
+ get => _faviconUrl;
+ set
+ {
+ if (value == _faviconUrl) return;
+ _faviconUrl = value;
+ OnPropertyChanged();
+ }
+ }
+
protected void OnPropertyChanged([CallerMemberName] string? propertyName = null)
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
diff --git a/Trdo/Models/StreamMetadata.cs b/Trdo/Models/StreamMetadata.cs
index 5023c2e..2feec98 100644
--- a/Trdo/Models/StreamMetadata.cs
+++ b/Trdo/Models/StreamMetadata.cs
@@ -21,6 +21,11 @@ public class StreamMetadata
///
public string Title { get; set; } = string.Empty;
+ ///
+ /// The URL to album artwork, if available.
+ ///
+ public string? AlbumArtUrl { get; set; }
+
///
/// Indicates whether any meaningful metadata was found.
///
diff --git a/Trdo/Package.appxmanifest b/Trdo/Package.appxmanifest
index beaf2de..56d55d0 100644
--- a/Trdo/Package.appxmanifest
+++ b/Trdo/Package.appxmanifest
@@ -11,7 +11,7 @@
+ Version="1.5.0.0" />
diff --git a/Trdo/Pages/AboutPage.xaml b/Trdo/Pages/AboutPage.xaml
index a45dbe7..8cc1ff6 100644
--- a/Trdo/Pages/AboutPage.xaml
+++ b/Trdo/Pages/AboutPage.xaml
@@ -183,22 +183,103 @@
TextWrapping="Wrap" />
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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 @@
-
+
+
+
+