diff --git a/.cursorindexingignore b/.cursorindexingignore new file mode 100644 index 0000000..953908e --- /dev/null +++ b/.cursorindexingignore @@ -0,0 +1,3 @@ + +# Don't index SpecStory auto-save files, but allow explicit context inclusion via @ references +.specstory/** diff --git a/.specstory/.gitignore b/.specstory/.gitignore new file mode 100644 index 0000000..c5b4129 --- /dev/null +++ b/.specstory/.gitignore @@ -0,0 +1,4 @@ +# SpecStory project identity file +/.project.json +# SpecStory explanation file +/.what-is-this.md diff --git a/Audiomatic/MainWindow.xaml b/Audiomatic/MainWindow.xaml index bc2f921..918d45c 100644 --- a/Audiomatic/MainWindow.xaml +++ b/Audiomatic/MainWindow.xaml @@ -177,7 +177,7 @@ ToolTipService.ToolTip="Shuffle" VerticalAlignment="Center"> - - - - + @@ -351,6 +355,97 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Audiomatic/MainWindow.xaml.cs b/Audiomatic/MainWindow.xaml.cs index 5d74608..a45fe95 100644 --- a/Audiomatic/MainWindow.xaml.cs +++ b/Audiomatic/MainWindow.xaml.cs @@ -33,10 +33,20 @@ public sealed partial class MainWindow : Window private bool _sortAscending = true; // Playlist navigation - private enum ViewMode { Library, PlaylistList, PlaylistDetail, Queue, Visualizer, MediaControl } + private enum ViewMode { Library, PlaylistList, PlaylistDetail, Queue, Radio, Podcast, PodcastEpisodes, Visualizer, MediaControl } private ViewMode _viewMode = ViewMode.Library; private PlaylistInfo? _currentPlaylist; + // Radio + private List _radioStations = []; + private bool _isRadioPlaying; + + // Podcast + private List _podcastSubscriptions = []; + private PodcastInfo? _currentPodcast; + private PodcastEpisode? _currentEpisode; + private HashSet _readEpisodes = []; + // Visualizer private readonly SpectrumAnalyzer _spectrum = new(); private DispatcherTimer? _spectrumTimer; @@ -171,6 +181,7 @@ public MainWindow() _player.MediaEnded += OnMediaEnded; _player.MediaFailed += OnMediaFailed; _player.PositionChanged += OnPositionChanged; + _player.BufferingChanged += OnBufferingChanged; // Load settings var settings = SettingsManager.Load(); @@ -181,6 +192,11 @@ public MainWindow() SortAscending.IsChecked = _sortAscending; UpdateSortChecks(); + // Load radio stations and podcast subscriptions + _radioStations = SettingsManager.LoadRadioStations(); + _podcastSubscriptions = PodcastService.LoadSubscriptions(); + _readEpisodes = PodcastService.LoadReadEpisodes(); + // Initialize library and load tracks LibraryManager.Initialize(); LoadTracks(); @@ -492,6 +508,19 @@ private void RebuildTrackList() private void OnMediaOpened() { + if (_player.IsStream) + { + _isSeeking = true; + TimelineSlider.Maximum = 1; + TimelineSlider.Value = 0; + TimelineSlider.IsEnabled = false; + DurationText.Text = "LIVE"; + PositionText.Text = ""; + _isSeeking = false; + return; + } + + TimelineSlider.IsEnabled = true; var dur = _player.Duration; if (dur.TotalSeconds > 0) { @@ -506,6 +535,15 @@ private void OnMediaOpened() private async void OnMediaEnded() { + // Auto-mark podcast episode as read when playback ends + if (_currentEpisode != null) + { + _readEpisodes.Add(_currentEpisode.AudioUrl); + PodcastService.SaveReadEpisodes(_readEpisodes); + _currentEpisode = null; + RefreshEpisodeList(); + } + var next = _queue.Next(); if (next != null) { @@ -532,6 +570,12 @@ private void OnMediaFailed(string error) TrackArtist.Text = $"Error: {error}"; } + private void OnBufferingChanged(bool isBuffering) + { + if (_player.IsStream) + RadioStatusText.Text = isBuffering ? "Buffering..." : "Playing: " + (RadioUrlBox.Text?.Trim() ?? ""); + } + private void OnPositionChanged(TimeSpan pos) { if (_isSeeking) return; @@ -544,6 +588,7 @@ private void OnPositionChanged(TimeSpan pos) private void UpdateNowPlaying(TrackInfo track) { + _currentEpisode = null; // Clear podcast episode when playing a track TrackTitle.Text = track.Title; TrackArtist.Text = track.Artist; TrackAlbum.Text = track.Album; @@ -551,6 +596,7 @@ private void UpdateNowPlaying(TrackInfo track) MiniPlayPauseIcon.Glyph = "\uE769"; LoadAlbumArt(track.Path); UpdateMiniPlayer(track); + UpdateTransportControls(); // Re-highlight current track in the appropriate view if (_viewMode == ViewMode.Visualizer) PrepareSpectrumForCurrentTrack(); @@ -663,6 +709,15 @@ private async void LoadAlbumArt(string filePath) private async void PlayPause_Click(object sender, RoutedEventArgs e) { + // If a radio stream is active, just toggle play/pause + if (_player.IsStream) + { + _player.TogglePlayPause(); + PlayPauseIcon.Glyph = _player.IsPlaying ? "\uE769" : "\uE768"; + MiniPlayPauseIcon.Glyph = PlayPauseIcon.Glyph; + return; + } + if (_player.CurrentTrack == null) { // Start playing first track if nothing is loaded @@ -688,6 +743,19 @@ private async void PlayPause_Click(object sender, RoutedEventArgs e) MiniPlayPauseIcon.Glyph = PlayPauseIcon.Glyph; } + private void UpdateTransportControls() + { + bool isStream = _player.IsStream; + ShuffleButton.IsEnabled = !isStream; + RepeatButton.IsEnabled = !isStream; + PrevButton.IsEnabled = !isStream; + NextButton.IsEnabled = !isStream; + ShuffleButton.Opacity = isStream ? 0.4 : 1; + RepeatButton.Opacity = isStream ? 0.4 : 1; + PrevButton.Opacity = isStream ? 0.4 : 1; + NextButton.Opacity = isStream ? 0.4 : 1; + } + private async void Prev_Click(object sender, RoutedEventArgs e) { // If more than 3 seconds in, restart current track @@ -944,18 +1012,25 @@ void SetTab(TextBlock tb, bool active) SetTab(NavLibraryText, _viewMode == ViewMode.Library); SetTab(NavPlaylistsText, _viewMode == ViewMode.PlaylistList); SetTab(NavQueueText, _viewMode == ViewMode.Queue); - SetTab(NavVisualizerText, _viewMode == ViewMode.Visualizer); - SetTab(NavMediaText, _viewMode == ViewMode.MediaControl); + SetTab(NavRadioText, _viewMode == ViewMode.Radio); + SetTab(NavPodcastText, _viewMode == ViewMode.Podcast || _viewMode == ViewMode.PodcastEpisodes); + SetTab(NavMoreText, _viewMode == ViewMode.Visualizer || _viewMode == ViewMode.MediaControl); // Show/hide search & sort SearchSortRow.Visibility = (_viewMode == ViewMode.Library || _viewMode == ViewMode.PlaylistDetail) ? Visibility.Visible : Visibility.Collapsed; // Show/hide content containers based on view mode - var isTrackView = _viewMode != ViewMode.Visualizer && _viewMode != ViewMode.MediaControl; + var isPodcast = _viewMode == ViewMode.Podcast || _viewMode == ViewMode.PodcastEpisodes; + var isTrackView = _viewMode != ViewMode.Visualizer && _viewMode != ViewMode.MediaControl + && _viewMode != ViewMode.Radio && !isPodcast; TrackListView.Visibility = isTrackView ? Visibility.Visible : Visibility.Collapsed; WaveformContainer.Visibility = _viewMode == ViewMode.Visualizer ? Visibility.Visible : Visibility.Collapsed; + RadioContainer.Visibility = _viewMode == ViewMode.Radio + ? Visibility.Visible : Visibility.Collapsed; + PodcastContainer.Visibility = isPodcast + ? Visibility.Visible : Visibility.Collapsed; MediaContainer.Visibility = _viewMode == ViewMode.MediaControl ? Visibility.Visible : Visibility.Collapsed; @@ -972,6 +1047,8 @@ private void AnimateViewTransition(Action buildNewContent, bool slideFromRight = // Target the visible content container FrameworkElement target = _viewMode == ViewMode.Visualizer ? WaveformContainer : _viewMode == ViewMode.MediaControl ? MediaContainer + : _viewMode == ViewMode.Radio ? RadioContainer + : (_viewMode == ViewMode.Podcast || _viewMode == ViewMode.PodcastEpisodes) ? PodcastContainer : TrackListView; if (target.RenderTransform is not TranslateTransform) @@ -1012,6 +1089,8 @@ private void AnimateViewTransition(Action buildNewContent, bool slideFromRight = // Re-target if container changed (e.g. Library→Visualizer) FrameworkElement newTarget = _viewMode == ViewMode.Visualizer ? WaveformContainer : _viewMode == ViewMode.MediaControl ? MediaContainer + : _viewMode == ViewMode.Radio ? RadioContainer + : (_viewMode == ViewMode.Podcast || _viewMode == ViewMode.PodcastEpisodes) ? PodcastContainer : TrackListView; if (newTarget.RenderTransform is not TranslateTransform) @@ -1605,6 +1684,750 @@ private void ClearQueue_Click(object sender, RoutedEventArgs e) BuildQueueView(); } + // -- Radio view ----------------------------------------------- + + private void NavRadio_Click(object sender, RoutedEventArgs e) + { + if (_viewMode == ViewMode.Radio) return; + _viewMode = ViewMode.Radio; + _currentPlaylist = null; + UpdateNavigation(); + UpdateSpectrumTimer(); + UpdateMediaTimer(); + AnimateViewTransition(() => UpdateRadioHistoryList()); + } + + private void RadioUrlBox_KeyDown(object sender, KeyRoutedEventArgs e) + { + if (e.Key == Windows.System.VirtualKey.Enter) + _ = PlayRadioStreamAsync(); + } + + private void RadioPlay_Click(object sender, RoutedEventArgs e) + { + _ = PlayRadioStreamAsync(); + } + + private void RadioHistory_ItemClick(object sender, ItemClickEventArgs e) + { + if (e.ClickedItem is Grid grid && grid.Tag is RadioStation station) + { + RadioUrlBox.Text = station.Url; + _ = PlayRadioStreamAsync(); + } + } + + private async Task PlayRadioStreamAsync() + { + var url = RadioUrlBox.Text?.Trim(); + if (string.IsNullOrEmpty(url)) return; + + // Basic URL validation + if (!Uri.TryCreate(url, UriKind.Absolute, out var uri) || + (uri.Scheme != "http" && uri.Scheme != "https")) + { + RadioStatusText.Text = "Invalid URL. Please enter a valid http/https stream URL."; + return; + } + + RadioStatusText.Text = "Connecting..."; + RadioPlayBtn.IsEnabled = false; + + try + { + _player.Stop(); + _currentEpisode = null; + _isRadioPlaying = true; + await _player.PlayStreamAsync(uri); + + RadioStatusText.Text = "Playing: " + url; + RadioUrlBox.Text = ""; + + // Add to stations (most recent first, no duplicates by URL) + var existing = _radioStations.FindIndex(s => s.Url == url); + if (existing >= 0) + { + var station = _radioStations[existing]; + _radioStations.RemoveAt(existing); + _radioStations.Insert(0, station); + } + else + { + _radioStations.Insert(0, new RadioStation(url, uri.Host)); + } + if (_radioStations.Count > 50) _radioStations.RemoveAt(50); + SettingsManager.SaveRadioStations(_radioStations); + UpdateRadioHistoryList(); + + // Update now-playing display + var displayName = _radioStations[0].Name; + TrackTitle.Text = displayName; + TrackArtist.Text = "Radio"; + TrackAlbum.Text = ""; + AlbumArtImage.Source = null; + AlbumArtPlaceholder.Visibility = Visibility.Visible; + + // Update play/pause icons + PlayPauseIcon.Glyph = "\uE769"; + MiniPlayPauseIcon.Glyph = "\uE769"; + MiniTrackText.Text = "Radio — " + displayName; + + // Hide duration/timeline for live stream + DurationText.Text = "LIVE"; + + // Start loopback capture for visualizer + _spectrum.StartLoopback(); + + // Disable queue-related transport controls + UpdateTransportControls(); + } + catch (Exception ex) + { + RadioStatusText.Text = "Error: " + ex.Message; + _isRadioPlaying = false; + } + finally + { + RadioPlayBtn.IsEnabled = true; + } + } + + private void UpdateRadioHistoryList() + { + RadioHistoryList.Items.Clear(); + foreach (var station in _radioStations) + { + var grid = new Grid { Tag = station, Padding = new Thickness(4, 6, 4, 6) }; + grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); + grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); + + var info = new StackPanel { Spacing = 1 }; + info.Children.Add(new TextBlock + { + Text = station.Name, + FontSize = 13, + TextTrimming = TextTrimming.CharacterEllipsis, + MaxLines = 1 + }); + info.Children.Add(new TextBlock + { + Text = station.Url, + FontSize = 11, + Foreground = ThemeHelper.Brush("TextFillColorTertiaryBrush"), + TextTrimming = TextTrimming.CharacterEllipsis, + MaxLines = 1 + }); + Grid.SetColumn(info, 0); + grid.Children.Add(info); + + var playIcon = new FontIcon + { + Glyph = "\uE768", + FontSize = 12, + Foreground = ThemeHelper.Brush("TextFillColorSecondaryBrush"), + VerticalAlignment = VerticalAlignment.Center + }; + Grid.SetColumn(playIcon, 1); + grid.Children.Add(playIcon); + + // Context flyout + var ctxFlyout = new Flyout(); + ctxFlyout.FlyoutPresenterStyle = ActionPanel.CreateFlyoutPresenterStyle(); + var capturedStation = station; + ctxFlyout.Opening += (_, _) => + { + ctxFlyout.Content = BuildRadioStationContextContent(ctxFlyout, capturedStation); + }; + grid.ContextFlyout = ctxFlyout; + + RadioHistoryList.Items.Add(grid); + } + } + + private StackPanel BuildRadioStationContextContent(Flyout flyout, RadioStation station) + { + var panel = new StackPanel { Spacing = 0 }; + + panel.Children.Add(ActionPanel.CreateButton("\uE768", "Play", [], () => + { + flyout.Hide(); + RadioUrlBox.Text = station.Url; + _ = PlayRadioStreamAsync(); + })); + panel.Children.Add(ActionPanel.CreateButton("\uE8AC", "Rename", [], () => + { + flyout.Hide(); + ShowRadioRenameFlyout(station); + })); + panel.Children.Add(ActionPanel.CreateSeparator()); + panel.Children.Add(ActionPanel.CreateButton("\uE74D", "Delete", [], () => + { + flyout.Hide(); + _radioStations.RemoveAll(s => s.Url == station.Url); + SettingsManager.SaveRadioStations(_radioStations); + UpdateRadioHistoryList(); + }, isDestructive: true)); + + return panel; + } + + private void ShowRadioRenameFlyout(RadioStation station) + { + var renameFlyout = new Flyout(); + renameFlyout.FlyoutPresenterStyle = ActionPanel.CreateFlyoutPresenterStyle(); + + var panel = new StackPanel { Spacing = 8 }; + panel.Children.Add(ActionPanel.CreateSectionHeader("Rename Station")); + + var input = new TextBox + { + Text = station.Name, + FontSize = 13, + Padding = new Thickness(8, 6, 8, 6), + CornerRadius = new CornerRadius(6) + }; + + void DoRename() + { + if (!string.IsNullOrWhiteSpace(input.Text)) + { + var idx = _radioStations.FindIndex(s => s.Url == station.Url); + if (idx >= 0) + { + _radioStations[idx] = station with { Name = input.Text.Trim() }; + SettingsManager.SaveRadioStations(_radioStations); + UpdateRadioHistoryList(); + } + } + renameFlyout.Hide(); + } + + var confirmBtn = ActionPanel.CreateButton("\uE73E", "Confirm", [], DoRename); + + input.KeyDown += (_, e) => + { + if (e.Key == Windows.System.VirtualKey.Enter) + { + e.Handled = true; + DoRename(); + } + }; + + panel.Children.Add(input); + panel.Children.Add(confirmBtn); + renameFlyout.Content = panel; + + // Select all text on open + renameFlyout.Opened += (_, _) => + { + input.Focus(FocusState.Programmatic); + input.SelectAll(); + }; + + renameFlyout.ShowAt(RadioContainer); + } + + // -- Podcast view ----------------------------------------------- + + private void NavPodcast_Click(object sender, RoutedEventArgs e) + { + if (_viewMode == ViewMode.Podcast) return; + _viewMode = ViewMode.Podcast; + _currentPlaylist = null; + _currentPodcast = null; + UpdateNavigation(); + UpdateSpectrumTimer(); + UpdateMediaTimer(); + AnimateViewTransition(() => BuildPodcastSubscriptionList()); + } + + private void PodcastBack_Click(object sender, RoutedEventArgs e) + { + _viewMode = ViewMode.Podcast; + _currentPodcast = null; + PodcastBackBtn.Visibility = Visibility.Collapsed; + PodcastSearchBox.PlaceholderText = "Search podcasts..."; + PodcastSearchBox.Text = ""; + BuildPodcastSubscriptionList(); + } + + private void PodcastSearchBox_KeyDown(object sender, KeyRoutedEventArgs e) + { + if (e.Key == Windows.System.VirtualKey.Enter) + _ = SearchPodcastsAsync(); + } + + private void PodcastSearch_Click(object sender, RoutedEventArgs e) + { + _ = SearchPodcastsAsync(); + } + + private async Task SearchPodcastsAsync() + { + var query = PodcastSearchBox.Text?.Trim(); + if (string.IsNullOrEmpty(query)) return; + + PodcastSearchBtn.IsEnabled = false; + PodcastListView.Items.Clear(); + PodcastListView.Items.Add(new TextBlock + { + Text = "Searching...", + FontSize = 13, + Foreground = ThemeHelper.Brush("TextFillColorTertiaryBrush"), + Margin = new Thickness(8, 12, 8, 0) + }); + + try + { + var results = await PodcastService.SearchAsync(query); + PodcastListView.Items.Clear(); + + if (results.Count == 0) + { + PodcastListView.Items.Add(new TextBlock + { + Text = "No podcasts found.", + FontSize = 13, + Foreground = ThemeHelper.Brush("TextFillColorTertiaryBrush"), + Margin = new Thickness(8, 12, 8, 0) + }); + return; + } + + foreach (var podcast in results) + PodcastListView.Items.Add(BuildPodcastItem(podcast, isSearchResult: true)); + } + catch (Exception ex) + { + PodcastListView.Items.Clear(); + PodcastListView.Items.Add(new TextBlock + { + Text = "Error: " + ex.Message, + FontSize = 13, + Foreground = ThemeHelper.Brush("TextFillColorTertiaryBrush"), + Margin = new Thickness(8, 12, 8, 0), + TextWrapping = TextWrapping.Wrap + }); + } + finally + { + PodcastSearchBtn.IsEnabled = true; + } + } + + private void BuildPodcastSubscriptionList() + { + PodcastBackBtn.Visibility = Visibility.Collapsed; + PodcastSearchBox.PlaceholderText = "Search podcasts..."; + PodcastListView.Items.Clear(); + + if (_podcastSubscriptions.Count == 0) + { + PodcastListView.Items.Add(new TextBlock + { + Text = "No subscriptions yet. Search for podcasts to subscribe.", + FontSize = 13, + Foreground = ThemeHelper.Brush("TextFillColorTertiaryBrush"), + Margin = new Thickness(8, 12, 8, 0), + TextWrapping = TextWrapping.Wrap + }); + return; + } + + foreach (var podcast in _podcastSubscriptions) + PodcastListView.Items.Add(BuildPodcastItem(podcast, isSearchResult: false)); + } + + private Grid BuildPodcastItem(PodcastInfo podcast, bool isSearchResult) + { + var grid = new Grid { Tag = podcast, Padding = new Thickness(4, 6, 4, 6) }; + grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(44, GridUnitType.Pixel) }); + grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); + grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); + + // Artwork + var artGrid = new Grid + { + Width = 44, Height = 44, + CornerRadius = new CornerRadius(6), + Background = ThemeHelper.Brush("CardBackgroundFillColorSecondaryBrush") + }; + if (!string.IsNullOrEmpty(podcast.ArtworkUrl)) + { + var img = new Image + { + Width = 44, Height = 44, + Stretch = Microsoft.UI.Xaml.Media.Stretch.UniformToFill + }; + img.Source = new BitmapImage(new Uri(podcast.ArtworkUrl)); + artGrid.Children.Add(img); + } + else + { + artGrid.Children.Add(new FontIcon + { + Glyph = "\uE8D6", FontSize = 18, + HorizontalAlignment = HorizontalAlignment.Center, + VerticalAlignment = VerticalAlignment.Center, + Foreground = ThemeHelper.Brush("TextFillColorTertiaryBrush") + }); + } + Grid.SetColumn(artGrid, 0); + grid.Children.Add(artGrid); + + // Info + var info = new StackPanel { Spacing = 1, Margin = new Thickness(10, 0, 0, 0), VerticalAlignment = VerticalAlignment.Center }; + info.Children.Add(new TextBlock + { + Text = podcast.Name, FontSize = 13, + TextTrimming = TextTrimming.CharacterEllipsis, MaxLines = 1 + }); + info.Children.Add(new TextBlock + { + Text = podcast.Author, FontSize = 11, + Foreground = ThemeHelper.Brush("TextFillColorTertiaryBrush"), + TextTrimming = TextTrimming.CharacterEllipsis, MaxLines = 1 + }); + Grid.SetColumn(info, 1); + grid.Children.Add(info); + + // Subscribe/Subscribed indicator + bool isSubscribed = _podcastSubscriptions.Any(p => p.FeedUrl == podcast.FeedUrl); + if (isSearchResult) + { + var subIcon = new FontIcon + { + Glyph = isSubscribed ? "\uE73E" : "\uE710", + FontSize = 12, + Foreground = isSubscribed + ? ThemeHelper.Brush("AccentTextFillColorPrimaryBrush") + : ThemeHelper.Brush("TextFillColorSecondaryBrush"), + VerticalAlignment = VerticalAlignment.Center + }; + Grid.SetColumn(subIcon, 2); + grid.Children.Add(subIcon); + } + + // Context flyout + var ctxFlyout = new Flyout(); + ctxFlyout.FlyoutPresenterStyle = ActionPanel.CreateFlyoutPresenterStyle(); + var captured = podcast; + ctxFlyout.Opening += (_, _) => + { + ctxFlyout.Content = BuildPodcastContextContent(ctxFlyout, captured); + }; + grid.ContextFlyout = ctxFlyout; + + return grid; + } + + private StackPanel BuildPodcastContextContent(Flyout flyout, PodcastInfo podcast) + { + var panel = new StackPanel { Spacing = 0 }; + bool isSubscribed = _podcastSubscriptions.Any(p => p.FeedUrl == podcast.FeedUrl); + + panel.Children.Add(ActionPanel.CreateButton("\uE8D6", "Episodes", [], () => + { + flyout.Hide(); + _ = ShowPodcastEpisodesAsync(podcast); + })); + + if (isSubscribed) + { + panel.Children.Add(ActionPanel.CreateSeparator()); + panel.Children.Add(ActionPanel.CreateButton("\uE74D", "Unsubscribe", [], () => + { + flyout.Hide(); + _podcastSubscriptions.RemoveAll(p => p.FeedUrl == podcast.FeedUrl); + PodcastService.SaveSubscriptions(_podcastSubscriptions); + if (_viewMode == ViewMode.Podcast) + BuildPodcastSubscriptionList(); + }, isDestructive: true)); + } + else + { + panel.Children.Add(ActionPanel.CreateButton("\uE710", "Subscribe", [], () => + { + flyout.Hide(); + _podcastSubscriptions.Insert(0, podcast); + PodcastService.SaveSubscriptions(_podcastSubscriptions); + if (_viewMode == ViewMode.Podcast) + BuildPodcastSubscriptionList(); + })); + } + + return panel; + } + + private void PodcastList_ItemClick(object sender, ItemClickEventArgs e) + { + if (e.ClickedItem is Grid grid && grid.Tag is PodcastInfo podcast) + { + _ = ShowPodcastEpisodesAsync(podcast); + } + else if (e.ClickedItem is Grid epGrid && epGrid.Tag is PodcastEpisode episode) + { + _ = PlayPodcastEpisodeAsync(episode); + } + } + + private async Task ShowPodcastEpisodesAsync(PodcastInfo podcast) + { + _viewMode = ViewMode.PodcastEpisodes; + _currentPodcast = podcast; + UpdateNavigation(); + + PodcastBackBtn.Visibility = Visibility.Visible; + PodcastSearchBox.PlaceholderText = podcast.Name; + PodcastSearchBox.Text = ""; + + PodcastListView.Items.Clear(); + PodcastListView.Items.Add(new TextBlock + { + Text = "Loading episodes...", + FontSize = 13, + Foreground = ThemeHelper.Brush("TextFillColorTertiaryBrush"), + Margin = new Thickness(8, 12, 8, 0) + }); + + try + { + var episodes = await PodcastService.FetchEpisodesAsync(podcast.FeedUrl); + PodcastListView.Items.Clear(); + + // Subscribe button at the top + bool isSubscribed = _podcastSubscriptions.Any(p => p.FeedUrl == podcast.FeedUrl); + var subBtn = new Button + { + HorizontalAlignment = HorizontalAlignment.Stretch, + HorizontalContentAlignment = HorizontalAlignment.Center, + Padding = new Thickness(8, 6, 8, 6), + Margin = new Thickness(8, 4, 8, 8), + CornerRadius = new CornerRadius(6), + Background = isSubscribed + ? ThemeHelper.Brush("ControlFillColorDefaultBrush") + : ThemeHelper.Brush("AccentFillColorDefaultBrush") + }; + var subText = new TextBlock + { + Text = isSubscribed ? "Subscribed" : "Subscribe", + FontSize = 12, + Foreground = isSubscribed + ? ThemeHelper.Brush("TextFillColorPrimaryBrush") + : new Microsoft.UI.Xaml.Media.SolidColorBrush(Microsoft.UI.Colors.White) + }; + subBtn.Content = subText; + var capturedPodcast = podcast; + subBtn.Click += (_, _) => + { + if (_podcastSubscriptions.Any(p => p.FeedUrl == capturedPodcast.FeedUrl)) + { + _podcastSubscriptions.RemoveAll(p => p.FeedUrl == capturedPodcast.FeedUrl); + subText.Text = "Subscribe"; + subBtn.Background = ThemeHelper.Brush("AccentFillColorDefaultBrush"); + subText.Foreground = new Microsoft.UI.Xaml.Media.SolidColorBrush(Microsoft.UI.Colors.White); + } + else + { + _podcastSubscriptions.Insert(0, capturedPodcast); + subText.Text = "Subscribed"; + subBtn.Background = ThemeHelper.Brush("ControlFillColorDefaultBrush"); + subText.Foreground = ThemeHelper.Brush("TextFillColorPrimaryBrush"); + } + PodcastService.SaveSubscriptions(_podcastSubscriptions); + }; + PodcastListView.Items.Add(subBtn); + + if (episodes.Count == 0) + { + PodcastListView.Items.Add(new TextBlock + { + Text = "No episodes found.", + FontSize = 13, + Foreground = ThemeHelper.Brush("TextFillColorTertiaryBrush"), + Margin = new Thickness(8, 8, 8, 0) + }); + return; + } + + foreach (var ep in episodes) + PodcastListView.Items.Add(BuildEpisodeItem(ep)); + } + catch (Exception ex) + { + PodcastListView.Items.Clear(); + PodcastListView.Items.Add(new TextBlock + { + Text = "Error loading episodes: " + ex.Message, + FontSize = 13, + Foreground = ThemeHelper.Brush("TextFillColorTertiaryBrush"), + Margin = new Thickness(8, 12, 8, 0), + TextWrapping = TextWrapping.Wrap + }); + } + } + + private Grid BuildEpisodeItem(PodcastEpisode episode) + { + bool isRead = _readEpisodes.Contains(episode.AudioUrl); + + var grid = new Grid { Tag = episode, Padding = new Thickness(4, 6, 4, 6) }; + if (isRead) grid.Opacity = 0.5; + grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); + grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); + + var titleBrush = isRead + ? ThemeHelper.Brush("TextFillColorTertiaryBrush") + : ThemeHelper.Brush("TextFillColorPrimaryBrush"); + + var info = new StackPanel { Spacing = 2 }; + info.Children.Add(new TextBlock + { + Text = episode.Title, FontSize = 13, + Foreground = titleBrush, + TextTrimming = TextTrimming.CharacterEllipsis, MaxLines = 2, + TextWrapping = TextWrapping.Wrap + }); + + var meta = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8 }; + if (!string.IsNullOrEmpty(episode.Published)) + meta.Children.Add(new TextBlock + { + Text = episode.Published, FontSize = 11, + Foreground = ThemeHelper.Brush("TextFillColorTertiaryBrush") + }); + if (!string.IsNullOrEmpty(episode.Duration)) + meta.Children.Add(new TextBlock + { + Text = episode.Duration, FontSize = 11, + Foreground = ThemeHelper.Brush("TextFillColorTertiaryBrush") + }); + if (isRead) + meta.Children.Add(new TextBlock + { + Text = "Played", FontSize = 11, + Foreground = ThemeHelper.Brush("TextFillColorTertiaryBrush"), + FontStyle = Windows.UI.Text.FontStyle.Italic + }); + info.Children.Add(meta); + + Grid.SetColumn(info, 0); + grid.Children.Add(info); + + var playIcon = new FontIcon + { + Glyph = isRead ? "\uE73E" : "\uE768", FontSize = 14, + Foreground = ThemeHelper.Brush("TextFillColorSecondaryBrush"), + VerticalAlignment = VerticalAlignment.Center, + Margin = new Thickness(8, 0, 0, 0) + }; + Grid.SetColumn(playIcon, 1); + grid.Children.Add(playIcon); + + // Context flyout + var ctxFlyout = new Flyout(); + ctxFlyout.FlyoutPresenterStyle = ActionPanel.CreateFlyoutPresenterStyle(); + var capturedEp = episode; + ctxFlyout.Opening += (_, _) => + { + ctxFlyout.Content = BuildEpisodeContextContent(ctxFlyout, capturedEp); + }; + grid.ContextFlyout = ctxFlyout; + + return grid; + } + + private StackPanel BuildEpisodeContextContent(Flyout flyout, PodcastEpisode episode) + { + var panel = new StackPanel { Spacing = 0 }; + bool isRead = _readEpisodes.Contains(episode.AudioUrl); + + panel.Children.Add(ActionPanel.CreateButton("\uE768", "Play", [], () => + { + flyout.Hide(); + _ = PlayPodcastEpisodeAsync(episode); + })); + + panel.Children.Add(ActionPanel.CreateSeparator()); + + if (isRead) + { + panel.Children.Add(ActionPanel.CreateButton("\uE7BA", "Mark as unread", [], () => + { + flyout.Hide(); + _readEpisodes.Remove(episode.AudioUrl); + PodcastService.SaveReadEpisodes(_readEpisodes); + RefreshEpisodeList(); + })); + } + else + { + panel.Children.Add(ActionPanel.CreateButton("\uE73E", "Mark as read", [], () => + { + flyout.Hide(); + _readEpisodes.Add(episode.AudioUrl); + PodcastService.SaveReadEpisodes(_readEpisodes); + RefreshEpisodeList(); + })); + } + + return panel; + } + + private void RefreshEpisodeList() + { + if (_viewMode == ViewMode.PodcastEpisodes && _currentPodcast != null) + _ = ShowPodcastEpisodesAsync(_currentPodcast); + } + + private async Task PlayPodcastEpisodeAsync(PodcastEpisode episode) + { + if (!Uri.TryCreate(episode.AudioUrl, UriKind.Absolute, out var uri)) return; + + try + { + _player.Stop(); + _currentEpisode = episode; + await _player.PlayStreamAsync(uri); + + // Update now-playing display + TrackTitle.Text = episode.Title; + TrackArtist.Text = _currentPodcast?.Name ?? "Podcast"; + TrackAlbum.Text = episode.Published; + AlbumArtImage.Source = _currentPodcast != null && !string.IsNullOrEmpty(_currentPodcast.ArtworkUrl) + ? new BitmapImage(new Uri(_currentPodcast.ArtworkUrl)) : null; + AlbumArtPlaceholder.Visibility = AlbumArtImage.Source == null ? Visibility.Visible : Visibility.Collapsed; + + PlayPauseIcon.Glyph = "\uE769"; + MiniPlayPauseIcon.Glyph = "\uE769"; + MiniTrackText.Text = episode.Title; + UpdateTransportControls(); + } + catch (Exception ex) + { + TrackArtist.Text = "Error: " + ex.Message; + } + } + + // -- More menu (Visualizer + Media) ---------------------------- + + private void NavMore_Click(object sender, RoutedEventArgs e) + { + var flyout = new Flyout(); + flyout.FlyoutPresenterStyle = ActionPanel.CreateFlyoutPresenterStyle(minWidth: 160, maxWidth: 200); + + var panel = new StackPanel { Spacing = 0 }; + panel.Children.Add(ActionPanel.CreateButton("\uE9D9", "Visualizer", [], + () => { flyout.Hide(); NavVisualizer_Click(sender, e); }, + isActive: _viewMode == ViewMode.Visualizer)); + panel.Children.Add(ActionPanel.CreateButton("\uE93C", "Media", [], + () => { flyout.Hide(); NavMedia_Click(sender, e); }, + isActive: _viewMode == ViewMode.MediaControl)); + + flyout.Content = panel; + flyout.ShowAt(sender as FrameworkElement ?? NavMoreBtn); + } + // -- Visualizer ----------------------------------------------- private void NavVisualizer_Click(object sender, RoutedEventArgs e) @@ -1739,7 +2562,7 @@ private void UpdateSpectrumTimer() _vizRenderer = new VisualizerRenderer( _spectrum, () => _player.Position, - () => _player.CurrentTrack != null); + () => _player.CurrentTrack != null || _player.IsStream); _vizRenderer.OnModeChanged = () => UpdateSpectrumTimer(); var selector = _vizRenderer.BuildSelector(); VisualizerSelector.Children.Clear(); @@ -1808,6 +2631,13 @@ public void ApplyVisualizerFps(int fps) private async void PrepareSpectrumForCurrentTrack() { + if (_player.IsStream) + { + _spectrum.StartLoopback(); + return; + } + + _spectrum.StopLoopback(); var track = _player.CurrentTrack; if (track != null) await _spectrum.PrepareAsync(track.Path); @@ -1819,7 +2649,7 @@ private void DrawVisualization() var h = WaveformContainer.ActualHeight; if (w <= 0 || h <= 0) return; - if (_player.CurrentTrack == null) + if (_player.CurrentTrack == null && !_player.IsStream) { // Show "no track" only if not already shown if (_vizNoTrackText == null) @@ -2717,10 +3547,16 @@ private void ToggleCollapse() NavRow.Visibility = Visibility.Visible; SearchSortRow.Visibility = (_viewMode == ViewMode.Library || _viewMode == ViewMode.PlaylistDetail) ? Visibility.Visible : Visibility.Collapsed; - TrackListView.Visibility = _viewMode != ViewMode.Visualizer && _viewMode != ViewMode.MediaControl - ? Visibility.Visible : Visibility.Collapsed; + var isPodcast = _viewMode == ViewMode.Podcast || _viewMode == ViewMode.PodcastEpisodes; + var isTrackView = _viewMode != ViewMode.Visualizer && _viewMode != ViewMode.MediaControl + && _viewMode != ViewMode.Radio && !isPodcast; + TrackListView.Visibility = isTrackView ? Visibility.Visible : Visibility.Collapsed; WaveformContainer.Visibility = _viewMode == ViewMode.Visualizer ? Visibility.Visible : Visibility.Collapsed; + RadioContainer.Visibility = _viewMode == ViewMode.Radio + ? Visibility.Visible : Visibility.Collapsed; + PodcastContainer.Visibility = isPodcast + ? Visibility.Visible : Visibility.Collapsed; MediaContainer.Visibility = _viewMode == ViewMode.MediaControl ? Visibility.Visible : Visibility.Collapsed; BottomBar.Visibility = Visibility.Visible; @@ -2742,6 +3578,8 @@ private void ToggleCollapse() SearchSortRow.Visibility = Visibility.Collapsed; TrackListView.Visibility = Visibility.Collapsed; WaveformContainer.Visibility = Visibility.Collapsed; + RadioContainer.Visibility = Visibility.Collapsed; + PodcastContainer.Visibility = Visibility.Collapsed; MediaContainer.Visibility = Visibility.Collapsed; BottomBar.Visibility = Visibility.Collapsed; MiniPlayerBar.Visibility = Visibility.Visible; diff --git a/Audiomatic/Services/AudioPlayerService.cs b/Audiomatic/Services/AudioPlayerService.cs index 3106025..bcfaf82 100644 --- a/Audiomatic/Services/AudioPlayerService.cs +++ b/Audiomatic/Services/AudioPlayerService.cs @@ -30,6 +30,7 @@ public sealed class AudioPlayerService : IDisposable public event Action? MediaOpened; public event Action? MediaFailed; public event Action? PositionChanged; + public event Action? BufferingChanged; public TrackInfo? CurrentTrack { get; private set; } public bool IsPlaying { get; private set; } @@ -184,6 +185,79 @@ public async Task PlayTrackAsync(TrackInfo track) } } + public bool IsStream { get; private set; } + + public async Task PlayStreamAsync(Uri streamUri) + { + Stop(); + IsStream = true; + _useNAudio = false; + CurrentTrack = null; + + try + { + var source = MediaSource.CreateFromUri(streamUri); + var item = new MediaPlaybackItem(source); + + _mediaPlayer.Source = item; + + // Wait for the source to open before playing to let MediaPlayer buffer + var tcs = new TaskCompletionSource(); + void onOpened(MediaPlayer mp, object args) + { + _mediaPlayer.MediaOpened -= onOpened; + tcs.TrySetResult(true); + } + void onFailed(MediaPlayer mp, MediaPlayerFailedEventArgs args) + { + _mediaPlayer.MediaFailed -= onFailed; + tcs.TrySetException(new Exception(args.ErrorMessage)); + } + _mediaPlayer.MediaOpened += onOpened; + _mediaPlayer.MediaFailed += onFailed; + + // Subscribe to buffering events + var session = _mediaPlayer.PlaybackSession; + session.BufferingStarted += (_, _) => + _dispatcherQueue?.TryEnqueue(() => BufferingChanged?.Invoke(true)); + session.BufferingEnded += (_, _) => + _dispatcherQueue?.TryEnqueue(() => BufferingChanged?.Invoke(false)); + + _mediaPlayer.Volume = Volume; + _mediaPlayer.Play(); + + // Wait up to 15s for the stream to open + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15)); + cts.Token.Register(() => tcs.TrySetException(new TimeoutException("Stream connection timed out"))); + await tcs.Task; + + IsPlaying = true; + + var smtc = _mediaPlayer.SystemMediaTransportControls; + smtc.IsEnabled = true; + smtc.IsPlayEnabled = true; + smtc.IsPauseEnabled = true; + smtc.PlaybackStatus = MediaPlaybackStatus.Playing; + + var updater = smtc.DisplayUpdater; + updater.Type = MediaPlaybackType.Music; + updater.MusicProperties.Title = "Radio Stream"; + updater.MusicProperties.Artist = streamUri.Host; + updater.Update(); + + _dispatcherQueue?.TryEnqueue(() => + { + PlaybackStarted?.Invoke(); + }); + } + catch (Exception ex) + { + IsStream = false; + _dispatcherQueue?.TryEnqueue(() => MediaFailed?.Invoke(ex.Message)); + throw; + } + } + public void Play() { if (_useNAudio) @@ -228,6 +302,7 @@ public void Stop() _mediaPlayer.Source = null; } IsPlaying = false; + IsStream = false; PlaybackStopped?.Invoke(); } diff --git a/Audiomatic/Services/PodcastService.cs b/Audiomatic/Services/PodcastService.cs new file mode 100644 index 0000000..b01f721 --- /dev/null +++ b/Audiomatic/Services/PodcastService.cs @@ -0,0 +1,140 @@ +using System.Net.Http; +using System.Text.Json; +using System.Xml.Linq; + +namespace Audiomatic.Services; + +public record PodcastInfo(string Name, string Author, string FeedUrl, string ArtworkUrl); + +public record PodcastEpisode(string Title, string Published, string Duration, string AudioUrl, string Description); + +public static class PodcastService +{ + private static readonly HttpClient Http = new(); + private static readonly string PodcastsPath = + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Audiomatic", "podcasts.json"); + + private static readonly JsonSerializerOptions JsonOpts = new() + { + WriteIndented = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }; + + /// + /// Search podcasts via iTunes Search API. + /// + public static async Task> SearchAsync(string query, int limit = 20) + { + if (string.IsNullOrWhiteSpace(query)) return []; + + var url = $"https://itunes.apple.com/search?term={Uri.EscapeDataString(query)}&media=podcast&limit={limit}"; + var json = await Http.GetStringAsync(url); + var doc = JsonDocument.Parse(json); + + var results = new List(); + foreach (var item in doc.RootElement.GetProperty("results").EnumerateArray()) + { + var name = item.TryGetProperty("collectionName", out var n) ? n.GetString() ?? "" : ""; + var author = item.TryGetProperty("artistName", out var a) ? a.GetString() ?? "" : ""; + var feedUrl = item.TryGetProperty("feedUrl", out var f) ? f.GetString() ?? "" : ""; + var artwork = item.TryGetProperty("artworkUrl100", out var art) ? art.GetString() ?? "" : ""; + + if (!string.IsNullOrEmpty(feedUrl)) + results.Add(new PodcastInfo(name, author, feedUrl, artwork)); + } + return results; + } + + /// + /// Fetch episodes from a podcast RSS feed. + /// + public static async Task> FetchEpisodesAsync(string feedUrl, int limit = 50) + { + var xml = await Http.GetStringAsync(feedUrl); + var doc = XDocument.Parse(xml); + XNamespace itunes = "http://www.itunes.com/dtds/podcast-1.0.dtd"; + + var episodes = new List(); + var items = doc.Descendants("item"); + + foreach (var item in items.Take(limit)) + { + var title = item.Element("title")?.Value ?? ""; + var pubDate = item.Element("pubDate")?.Value ?? ""; + var duration = item.Element(itunes + "duration")?.Value ?? ""; + var enclosure = item.Element("enclosure"); + var audioUrl = enclosure?.Attribute("url")?.Value ?? ""; + var description = item.Element("description")?.Value ?? ""; + + // Clean up published date + if (DateTime.TryParse(pubDate, out var dt)) + pubDate = dt.ToString("dd MMM yyyy"); + + if (!string.IsNullOrEmpty(audioUrl)) + episodes.Add(new PodcastEpisode(title, pubDate, duration, audioUrl, description)); + } + return episodes; + } + + // -- Read/unread episode tracking -- + + private static readonly string ReadPath = + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Audiomatic", "podcast_read.json"); + + public static HashSet LoadReadEpisodes() + { + try + { + if (File.Exists(ReadPath)) + { + var json = File.ReadAllText(ReadPath); + var list = JsonSerializer.Deserialize>(json, JsonOpts); + return list != null ? new HashSet(list) : []; + } + } + catch { } + return []; + } + + public static void SaveReadEpisodes(HashSet readUrls) + { + try + { + Directory.CreateDirectory(Path.GetDirectoryName(ReadPath)!); + var json = JsonSerializer.Serialize(readUrls.ToList(), JsonOpts); + File.WriteAllText(ReadPath, json); + } + catch { } + } + + /// + /// Load saved podcast subscriptions. + /// + public static List LoadSubscriptions() + { + try + { + if (File.Exists(PodcastsPath)) + { + var json = File.ReadAllText(PodcastsPath); + return JsonSerializer.Deserialize>(json, JsonOpts) ?? []; + } + } + catch { } + return []; + } + + /// + /// Save podcast subscriptions. + /// + public static void SaveSubscriptions(List podcasts) + { + try + { + Directory.CreateDirectory(Path.GetDirectoryName(PodcastsPath)!); + var json = JsonSerializer.Serialize(podcasts, JsonOpts); + File.WriteAllText(PodcastsPath, json); + } + catch { } + } +} diff --git a/Audiomatic/Services/SpectrumAnalyzer.cs b/Audiomatic/Services/SpectrumAnalyzer.cs index 58b65b3..0d7f6a9 100644 --- a/Audiomatic/Services/SpectrumAnalyzer.cs +++ b/Audiomatic/Services/SpectrumAnalyzer.cs @@ -1,3 +1,4 @@ +using NAudio.CoreAudioApi; using NAudio.Dsp; using NAudio.Wave; @@ -22,12 +23,22 @@ public sealed class SpectrumAnalyzer : IDisposable private readonly float[] _mags = new float[FftSize / 2]; private float[] _bands = []; + // Loopback capture for live streams + private WasapiLoopbackCapture? _loopback; + private float[] _loopbackRing = new float[FftSize * 2]; + private int _loopbackWritePos; + private int _loopbackSampleRate; + private volatile bool _loopbackActive; + private readonly object _loopbackLock = new(); + /// /// Pre-decodes the audio file to a mono float array in memory. /// Fast random access afterward — no file seeking needed. /// public async Task PrepareAsync(string filePath) { + StopLoopback(); + if (filePath == _currentPath && _decoded != null) return; _currentPath = filePath; _decoded = null; @@ -67,11 +78,79 @@ public async Task PrepareAsync(string filePath) _decoding = false; } + /// + /// Starts WASAPI loopback capture for real-time spectrum of streams. + /// + public void StartLoopback() + { + if (_loopbackActive) return; + + _decoded = null; + _currentPath = null; + + try + { + _loopback = new WasapiLoopbackCapture(); + _loopbackSampleRate = _loopback.WaveFormat.SampleRate; + _loopbackRing = new float[FftSize * 2]; + _loopbackWritePos = 0; + + _loopback.DataAvailable += (_, args) => + { + int channels = _loopback.WaveFormat.Channels; + int bytesPerSample = _loopback.WaveFormat.BitsPerSample / 8; + int sampleCount = args.BytesRecorded / bytesPerSample; + + lock (_loopbackLock) + { + for (int i = 0; i < sampleCount; i += channels) + { + float sample = 0; + for (int c = 0; c < channels && (i + c) * bytesPerSample + bytesPerSample <= args.BytesRecorded; c++) + { + int offset = (i + c) * bytesPerSample; + sample += BitConverter.ToSingle(args.Buffer, offset); + } + sample /= channels; + _loopbackRing[_loopbackWritePos % _loopbackRing.Length] = sample; + _loopbackWritePos++; + } + } + }; + + _loopback.StartRecording(); + _loopbackActive = true; + } + catch + { + _loopback?.Dispose(); + _loopback = null; + } + } + + public void StopLoopback() + { + if (!_loopbackActive) return; + _loopbackActive = false; + + try + { + _loopback?.StopRecording(); + _loopback?.Dispose(); + } + catch { } + _loopback = null; + } + /// /// Returns frequency band magnitudes (0..1) at the given playback position. + /// For file-based tracks uses pre-decoded data; for streams uses loopback capture. /// public float[] GetSpectrum(TimeSpan position, int bandCount) { + if (_loopbackActive) + return GetLoopbackSpectrum(bandCount); + if (_decoded == null || _sampleRate == 0) return Decay(bandCount); @@ -87,6 +166,35 @@ public float[] GetSpectrum(TimeSpan position, int bandCount) _fftBuffer[i].X = _decoded[start + i] * window; _fftBuffer[i].Y = 0; } + + return ComputeBands(bandCount, _sampleRate); + } + + private float[] GetLoopbackSpectrum(int bandCount) + { + lock (_loopbackLock) + { + if (_loopbackWritePos < FftSize) + return Decay(bandCount); + + int ringLen = _loopbackRing.Length; + int readStart = _loopbackWritePos - FftSize; + + for (int i = 0; i < FftSize; i++) + { + int idx = (readStart + i) % ringLen; + if (idx < 0) idx += ringLen; + float window = 0.54f - 0.46f * MathF.Cos(2f * MathF.PI * i / (FftSize - 1)); + _fftBuffer[i].X = _loopbackRing[idx] * window; + _fftBuffer[i].Y = 0; + } + } + + return ComputeBands(bandCount, _loopbackSampleRate); + } + + private float[] ComputeBands(int bandCount, int sampleRate) + { FastFourierTransform.FFT(true, FftLog2, _fftBuffer); // Magnitude spectrum — reusable buffer @@ -95,9 +203,9 @@ public float[] GetSpectrum(TimeSpan position, int bandCount) _mags[i] = MathF.Sqrt(_fftBuffer[i].X * _fftBuffer[i].X + _fftBuffer[i].Y * _fftBuffer[i].Y); // Logarithmic frequency bands - float freqPerBin = (float)_sampleRate / FftSize; + float freqPerBin = (float)sampleRate / FftSize; const float minFreq = 30f; - float maxFreq = MathF.Min(_sampleRate / 2f, 18000f); + float maxFreq = MathF.Min(sampleRate / 2f, 18000f); float logMin = MathF.Log2(minFreq); float logMax = MathF.Log2(maxFreq); @@ -131,7 +239,7 @@ public float[] GetSpectrum(TimeSpan position, int bandCount) return _smoothed; } - public bool IsReady => _decoded != null; + public bool IsReady => _decoded != null || _loopbackActive; public bool IsDecoding => _decoding; private float[] Decay(int bandCount) @@ -145,6 +253,7 @@ private float[] Decay(int bandCount) public void Reset() { + StopLoopback(); _decoded = null; _currentPath = null; _smoothed = []; @@ -152,6 +261,7 @@ public void Reset() public void Dispose() { + StopLoopback(); _decoded = null; _currentPath = null; } diff --git a/Audiomatic/SettingsManager.cs b/Audiomatic/SettingsManager.cs index 062cdd4..bde154a 100644 --- a/Audiomatic/SettingsManager.cs +++ b/Audiomatic/SettingsManager.cs @@ -2,6 +2,8 @@ namespace Audiomatic; +public record RadioStation(string Url, string Name); + public record BackdropSettings( string Type = "acrylic", double TintOpacity = 1.0, @@ -81,6 +83,35 @@ public static void SaveTheme(string theme) Save(current with { Theme = theme }); } + // -- Radio stations persistence -- + + private static readonly string RadioPath = Path.Combine(SettingsDir, "radio_stations.json"); + + public static List LoadRadioStations() + { + try + { + if (File.Exists(RadioPath)) + { + var json = File.ReadAllText(RadioPath); + return JsonSerializer.Deserialize>(json, JsonOpts) ?? []; + } + } + catch { } + return []; + } + + public static void SaveRadioStations(List stations) + { + try + { + Directory.CreateDirectory(SettingsDir); + var json = JsonSerializer.Serialize(stations, JsonOpts); + File.WriteAllText(RadioPath, json); + } + catch { } + } + private static AppSettings CreateDefault() => new( Backdrop: new BackdropSettings(), Volume: 1.0, diff --git a/README.md b/README.md index 74daf49..85eab2f 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,25 @@ A modern, compact desktop audio player for Windows 11, built with WinUI 3 and .N - Clear queue or remove individual items - Queue state persisted across sessions (`queue.json`) +### Radio Streaming + +- **Listen to online radio** — enter any HTTP/HTTPS stream URL to play live radio +- **Station management** — rename and delete saved stations via Raycast-style context menu +- **Persistent stations** — saved to `radio_stations.json`, restored on next launch +- **Live indicator** — timeline shows "LIVE" with disabled seek bar +- **Smart transport controls** — shuffle, repeat, previous, and next are disabled during radio playback +- **Visualizer support** — real-time WASAPI loopback spectrum analysis for live streams + +### Podcasts + +- **Search podcasts** — discover podcasts via the iTunes Search API +- **Subscribe** — add podcasts to your subscription list, persisted across sessions +- **Episode browsing** — view episode list with title, date, duration, and description +- **Direct playback** — play any episode directly in the built-in player +- **Read/unread tracking** — episodes automatically marked as read when fully listened +- **Manual toggle** — mark episodes as read or unread via Raycast-style context menu +- **Subscription management** — unsubscribe from podcasts via context menu + ### Search & Sort - Real-time filtering by title, artist, or album @@ -56,8 +75,10 @@ A modern, compact desktop audio player for Windows 11, built with WinUI 3 and .N | **Playlists** | Manage playlists and view track counts | | **Playlist Detail** | View and reorder tracks within a playlist | | **Queue** | View and manage the current playback queue | -| **Visualizer** | Real-time FFT spectrum analyzer with mirror mode | -| **Media Control** | Monitor and control background media players (SMTC) | +| **Radio** | Play online radio streams with station management | +| **Podcasts** | Search, subscribe, browse episodes, and play podcasts | +| **Visualizer** | Real-time FFT spectrum analyzer with mirror mode (via "..." menu) | +| **Media Control** | Monitor and control background media players via "..." menu | ### Metadata Editor @@ -124,6 +145,9 @@ All application data is stored in `%LOCALAPPDATA%\Audiomatic\`: - `library.db` — SQLite database (tracks, playlists, favorites, folders) - `settings.json` — User preferences - `queue.json` — Current queue state +- `radio_stations.json` — Saved radio stations +- `podcasts.json` — Podcast subscriptions +- `podcast_read.json` — Read/unread episode tracking ## Building diff --git a/installer.iss b/installer.iss index 504a86c..0c10c3c 100644 --- a/installer.iss +++ b/installer.iss @@ -1,6 +1,6 @@ [Setup] AppName=Audiomatic -AppVersion=0.0.3 +AppVersion=0.0.4 AppPublisher=OhMyCode DefaultDirName={localappdata}\Programs\Audiomatic DefaultGroupName=Audiomatic @@ -14,6 +14,11 @@ PrivilegesRequired=lowest UninstallDisplayIcon={app}\Audiomatic.exe WizardStyle=modern SetupIconFile=Audiomatic\app.ico +ShowLanguageDialog=yes + +[Languages] +Name: "english"; MessagesFile: "compiler:Default.isl" +Name: "french"; MessagesFile: "compiler:Languages\French.isl" [Files] Source: "Audiomatic\bin\Release\net8.0-windows10.0.19041.0\win-x64\publish\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs @@ -24,8 +29,12 @@ Name: "{autodesktop}\Audiomatic"; Filename: "{app}\Audiomatic.exe"; Tasks: deskt Name: "{userstartup}\Audiomatic"; Filename: "{app}\Audiomatic.exe"; Tasks: startupicon [Tasks] -Name: "desktopicon"; Description: "Raccourci sur le Bureau"; GroupDescription: "Raccourcis:"; Flags: checkedonce -Name: "startupicon"; Description: "Lancer au démarrage de Windows"; GroupDescription: "Raccourcis:"; Flags: unchecked +Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: checkedonce +Name: "startupicon"; Description: "{cm:AutoStartProgram,Audiomatic}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: unchecked [Run] -Filename: "{app}\Audiomatic.exe"; Description: "Lancer Audiomatic"; Flags: nowait postinstall skipifsilent +Filename: "{app}\Audiomatic.exe"; Description: "{cm:LaunchProgram,Audiomatic}"; Flags: nowait postinstall skipifsilent + +[CustomMessages] +english.AutoStartProgram=Start %1 with Windows +french.AutoStartProgram=Lancer %1 au démarrage de Windows