diff --git a/Audiomatic/MainWindow.xaml b/Audiomatic/MainWindow.xaml index 48433d0..15e28c7 100644 --- a/Audiomatic/MainWindow.xaml +++ b/Audiomatic/MainWindow.xaml @@ -273,6 +273,18 @@ TextTrimming="CharacterEllipsis" MaxLines="1"/> + + + + + + + + + + + + + + + diff --git a/Audiomatic/MainWindow.xaml.cs b/Audiomatic/MainWindow.xaml.cs index 5c5da48..7ad0426 100644 --- a/Audiomatic/MainWindow.xaml.cs +++ b/Audiomatic/MainWindow.xaml.cs @@ -33,7 +33,7 @@ public sealed partial class MainWindow : Window private bool _sortAscending = true; // Playlist navigation - private enum ViewMode { Library, PlaylistList, PlaylistDetail, Queue, Radio, Podcast, PodcastEpisodes, Visualizer, Equalizer, MediaControl } + private enum ViewMode { Library, PlaylistList, PlaylistDetail, Queue, Radio, Podcast, PodcastEpisodes, Visualizer, Equalizer, MediaControl, Albums, AlbumDetail } private ViewMode _viewMode = ViewMode.Library; private PlaylistInfo? _currentPlaylist; @@ -65,6 +65,9 @@ private enum ViewMode { Library, PlaylistList, PlaylistDetail, Queue, Radio, Pod private bool _eqUiBuilt; private bool _eqUpdatingFromPreset; + // Albums + private string? _currentAlbumName; + // View transition animation private bool _isViewTransitioning; @@ -311,11 +314,14 @@ private void ApplyFilterAndSort() return; } - if (_viewMode == ViewMode.Visualizer) + if (_viewMode == ViewMode.Visualizer || _viewMode == ViewMode.Albums) return; List source = _viewMode == ViewMode.PlaylistDetail && _currentPlaylist != null ? LibraryManager.GetPlaylistTracks(_currentPlaylist.Id) + : _viewMode == ViewMode.AlbumDetail && _currentAlbumName != null + ? _allTracks.Where(t => string.Equals(t.Album, _currentAlbumName, StringComparison.OrdinalIgnoreCase)) + .OrderBy(t => t.TrackNumber).ThenBy(t => t.Title).ToList() : _allTracks; var query = SearchBox.Text?.Trim() ?? ""; @@ -986,6 +992,11 @@ private void NavBack_Click(object sender, RoutedEventArgs e) NavPlaylists_Click(sender, e); } + private void AlbumBack_Click(object sender, RoutedEventArgs e) + { + NavAlbums_Click(sender, e); + } + private async void NewPlaylist_Click(object sender, RoutedEventArgs e) { var dialog = new ContentDialog @@ -1009,11 +1020,13 @@ private async void NewPlaylist_Click(object sender, RoutedEventArgs e) private void UpdateNavigation() { - // Toggle tab bar vs playlist detail header - NavTabs.Visibility = _viewMode != ViewMode.PlaylistDetail - ? Visibility.Visible : Visibility.Collapsed; + // Toggle tab bar vs detail headers + var isDetailView = _viewMode == ViewMode.PlaylistDetail || _viewMode == ViewMode.AlbumDetail; + NavTabs.Visibility = !isDetailView ? Visibility.Visible : Visibility.Collapsed; PlaylistHeader.Visibility = _viewMode == ViewMode.PlaylistDetail ? Visibility.Visible : Visibility.Collapsed; + AlbumHeader.Visibility = _viewMode == ViewMode.AlbumDetail + ? Visibility.Visible : Visibility.Collapsed; NewPlaylistBtn.Visibility = _viewMode == ViewMode.PlaylistList ? Visibility.Visible : Visibility.Collapsed; ClearQueueBtn.Visibility = _viewMode == ViewMode.Queue @@ -1034,17 +1047,22 @@ void SetTab(TextBlock tb, bool active) SetTab(NavQueueText, _viewMode == ViewMode.Queue); SetTab(NavRadioText, _viewMode == ViewMode.Radio); SetTab(NavPodcastText, _viewMode == ViewMode.Podcast || _viewMode == ViewMode.PodcastEpisodes); - SetTab(NavMoreText, _viewMode == ViewMode.Visualizer || _viewMode == ViewMode.Equalizer || _viewMode == ViewMode.MediaControl); + SetTab(NavMoreText, _viewMode == ViewMode.Visualizer || _viewMode == ViewMode.Equalizer + || _viewMode == ViewMode.MediaControl || _viewMode == ViewMode.Albums || _viewMode == ViewMode.AlbumDetail); // Show/hide search & sort - SearchSortRow.Visibility = (_viewMode == ViewMode.Library || _viewMode == ViewMode.PlaylistDetail) + SearchSortRow.Visibility = (_viewMode == ViewMode.Library || _viewMode == ViewMode.PlaylistDetail + || _viewMode == ViewMode.AlbumDetail) ? Visibility.Visible : Visibility.Collapsed; // Show/hide content containers based on view mode var isPodcast = _viewMode == ViewMode.Podcast || _viewMode == ViewMode.PodcastEpisodes; - var isTrackView = _viewMode != ViewMode.Visualizer && _viewMode != ViewMode.MediaControl - && _viewMode != ViewMode.Radio && _viewMode != ViewMode.Equalizer && !isPodcast; + var isTrackView = _viewMode == ViewMode.Library || _viewMode == ViewMode.PlaylistList + || _viewMode == ViewMode.PlaylistDetail || _viewMode == ViewMode.Queue + || _viewMode == ViewMode.AlbumDetail; TrackListView.Visibility = isTrackView ? Visibility.Visible : Visibility.Collapsed; + AlbumsGridView.Visibility = _viewMode == ViewMode.Albums + ? Visibility.Visible : Visibility.Collapsed; WaveformContainer.Visibility = _viewMode == ViewMode.Visualizer ? Visibility.Visible : Visibility.Collapsed; EqualizerContainer.Visibility = _viewMode == ViewMode.Equalizer @@ -1056,9 +1074,11 @@ void SetTab(TextBlock tb, bool active) MediaContainer.Visibility = _viewMode == ViewMode.MediaControl ? Visibility.Visible : Visibility.Collapsed; - // Playlist detail name + // Detail header names if (_currentPlaylist != null) PlaylistNameText.Text = _currentPlaylist.Name; + if (_currentAlbumName != null) + AlbumNameText.Text = _currentAlbumName; } private void AnimateViewTransition(Action buildNewContent, bool slideFromRight = true) @@ -1071,6 +1091,7 @@ private void AnimateViewTransition(Action buildNewContent, bool slideFromRight = : _viewMode == ViewMode.Equalizer ? EqualizerContainer : _viewMode == ViewMode.MediaControl ? MediaContainer : _viewMode == ViewMode.Radio ? RadioContainer + : _viewMode == ViewMode.Albums ? AlbumsGridView : (_viewMode == ViewMode.Podcast || _viewMode == ViewMode.PodcastEpisodes) ? PodcastContainer : TrackListView; @@ -1114,6 +1135,7 @@ private void AnimateViewTransition(Action buildNewContent, bool slideFromRight = : _viewMode == ViewMode.Equalizer ? EqualizerContainer : _viewMode == ViewMode.MediaControl ? MediaContainer : _viewMode == ViewMode.Radio ? RadioContainer + : _viewMode == ViewMode.Albums ? AlbumsGridView : (_viewMode == ViewMode.Podcast || _viewMode == ViewMode.PodcastEpisodes) ? PodcastContainer : TrackListView; @@ -2441,6 +2463,9 @@ private void NavMore_Click(object sender, RoutedEventArgs e) flyout.FlyoutPresenterStyle = ActionPanel.CreateFlyoutPresenterStyle(minWidth: 160, maxWidth: 200); var panel = new StackPanel { Spacing = 0 }; + panel.Children.Add(ActionPanel.CreateButton("\uE93F", "Albums", [], + () => { flyout.Hide(); NavAlbums_Click(sender, e); }, + isActive: _viewMode == ViewMode.Albums || _viewMode == ViewMode.AlbumDetail)); panel.Children.Add(ActionPanel.CreateButton("\uE9D9", "Visualizer", [], () => { flyout.Hide(); NavVisualizer_Click(sender, e); }, isActive: _viewMode == ViewMode.Visualizer)); @@ -2455,6 +2480,184 @@ private void NavMore_Click(object sender, RoutedEventArgs e) flyout.ShowAt(sender as FrameworkElement ?? NavMoreBtn); } + // -- Albums --------------------------------------------------- + + private void NavAlbums_Click(object sender, RoutedEventArgs e) + { + if (_viewMode == ViewMode.Albums) return; + _viewMode = ViewMode.Albums; + _currentPlaylist = null; + _currentAlbumName = null; + UpdateNavigation(); + UpdateSpectrumTimer(); + UpdateMediaTimer(); + AnimateViewTransition(() => BuildAlbumsGrid()); + } + + private void BuildAlbumsGrid() + { + AlbumsGridView.Items.Clear(); + + var albumGroups = _allTracks + .Where(t => !string.IsNullOrWhiteSpace(t.Album)) + .GroupBy(t => t.Album, StringComparer.OrdinalIgnoreCase) + .Select(g => ( + Album: g.Key, + Artist: g.GroupBy(t => t.Artist).OrderByDescending(ag => ag.Count()).First().Key, + TrackCount: g.Count(), + SampleTrackPath: g.First().Path + )) + .OrderBy(a => a.Album) + .ToList(); + + TrackCountText.Text = $"{albumGroups.Count:N0} albums"; + + foreach (var album in albumGroups) + { + var card = new StackPanel + { + Width = 150, + Spacing = 4, + Padding = new Thickness(4), + Tag = album.Album + }; + + // Album art container + var artGrid = new Grid + { + Width = 142, + Height = 142, + CornerRadius = new CornerRadius(8), + Background = (Brush)Application.Current.Resources["CardBackgroundFillColorSecondaryBrush"] + }; + + var artImage = new Image + { + Stretch = Stretch.UniformToFill, + Width = 142, + Height = 142, + Visibility = Visibility.Collapsed + }; + + var placeholder = new FontIcon + { + Glyph = "\uE93C", + FontSize = 36, + HorizontalAlignment = HorizontalAlignment.Center, + VerticalAlignment = VerticalAlignment.Center, + Foreground = ThemeHelper.Brush("TextFillColorTertiaryBrush") + }; + + artGrid.Children.Add(placeholder); + artGrid.Children.Add(artImage); + card.Children.Add(artGrid); + + // Album title + card.Children.Add(new TextBlock + { + Text = album.Album, + FontSize = 12, + FontWeight = Microsoft.UI.Text.FontWeights.SemiBold, + TextTrimming = TextTrimming.CharacterEllipsis, + MaxLines = 1, + Margin = new Thickness(2, 2, 0, 0) + }); + + // Artist + track count + var artistText = string.IsNullOrWhiteSpace(album.Artist) ? "" : album.Artist; + card.Children.Add(new TextBlock + { + Text = $"{artistText} \u00B7 {album.TrackCount} tracks", + FontSize = 11, + Foreground = ThemeHelper.Brush("TextFillColorSecondaryBrush"), + TextTrimming = TextTrimming.CharacterEllipsis, + MaxLines = 1, + Margin = new Thickness(2, 0, 0, 0) + }); + + AlbumsGridView.Items.Add(card); + + // Load artwork async + var capturedImage = artImage; + var capturedPlaceholder = placeholder; + var capturedPath = album.SampleTrackPath; + _ = LoadAlbumCardArtAsync(capturedImage, capturedPlaceholder, capturedPath); + } + + if (albumGroups.Count == 0) + { + var empty = new TextBlock + { + Text = "No albums found. Add music folders in Settings.", + Foreground = ThemeHelper.Brush("TextFillColorTertiaryBrush"), + FontSize = 13, + Margin = new Thickness(8) + }; + AlbumsGridView.Items.Add(empty); + } + } + + private async Task LoadAlbumCardArtAsync(Image artImage, FontIcon placeholder, string trackPath) + { + try + { + byte[]? artData = null; + string? coverPath = null; + + await Task.Run(() => + { + try + { + using var tagFile = TagLib.File.Create(trackPath); + if (tagFile.Tag.Pictures.Length > 0) + artData = tagFile.Tag.Pictures[0].Data.Data; + } + catch { } + + if (artData == null) + { + var folder = System.IO.Path.GetDirectoryName(trackPath); + if (folder != null) + coverPath = FindCoverFile(folder); + } + }); + + if (artData != null) + { + using var stream = new Windows.Storage.Streams.InMemoryRandomAccessStream(); + using var writer = new Windows.Storage.Streams.DataWriter(stream.GetOutputStreamAt(0)); + writer.WriteBytes(artData); + await writer.StoreAsync(); + stream.Seek(0); + + var bitmap = new BitmapImage { DecodePixelWidth = 142 }; + bitmap.SetSource(stream); + artImage.Source = bitmap; + artImage.Visibility = Visibility.Visible; + placeholder.Visibility = Visibility.Collapsed; + } + else if (coverPath != null) + { + var bitmap = new BitmapImage { DecodePixelWidth = 142, UriSource = new Uri(coverPath) }; + artImage.Source = bitmap; + artImage.Visibility = Visibility.Visible; + placeholder.Visibility = Visibility.Collapsed; + } + } + catch { } + } + + private void AlbumGrid_ItemClick(object sender, ItemClickEventArgs e) + { + if (e.ClickedItem is not StackPanel card || card.Tag is not string albumName) return; + + _currentAlbumName = albumName; + _viewMode = ViewMode.AlbumDetail; + _currentPlaylist = null; + UpdateNavigation(); + AnimateViewTransition(() => ApplyFilterAndSort()); + } + // -- Equalizer ------------------------------------------------ private void NavEqualizer_Click(object sender, RoutedEventArgs e) @@ -4184,12 +4387,16 @@ private void ToggleCollapse() MiniPlayerBar.Visibility = Visibility.Collapsed; VolumeRow.Visibility = Visibility.Visible; NavRow.Visibility = Visibility.Visible; - SearchSortRow.Visibility = (_viewMode == ViewMode.Library || _viewMode == ViewMode.PlaylistDetail) + SearchSortRow.Visibility = (_viewMode == ViewMode.Library || _viewMode == ViewMode.PlaylistDetail + || _viewMode == ViewMode.AlbumDetail) ? Visibility.Visible : Visibility.Collapsed; var isPodcast = _viewMode == ViewMode.Podcast || _viewMode == ViewMode.PodcastEpisodes; - var isTrackView = _viewMode != ViewMode.Visualizer && _viewMode != ViewMode.MediaControl - && _viewMode != ViewMode.Radio && _viewMode != ViewMode.Equalizer && !isPodcast; + var isTrackView = _viewMode == ViewMode.Library || _viewMode == ViewMode.PlaylistList + || _viewMode == ViewMode.PlaylistDetail || _viewMode == ViewMode.Queue + || _viewMode == ViewMode.AlbumDetail; TrackListView.Visibility = isTrackView ? Visibility.Visible : Visibility.Collapsed; + AlbumsGridView.Visibility = _viewMode == ViewMode.Albums + ? Visibility.Visible : Visibility.Collapsed; WaveformContainer.Visibility = _viewMode == ViewMode.Visualizer ? Visibility.Visible : Visibility.Collapsed; EqualizerContainer.Visibility = _viewMode == ViewMode.Equalizer @@ -4214,6 +4421,7 @@ private void ToggleCollapse() // Hide everything immediately before animation CustomTitleBar.Visibility = Visibility.Collapsed; NowPlayingCard.Visibility = Visibility.Collapsed; + AlbumsGridView.Visibility = Visibility.Collapsed; VolumeRow.Visibility = Visibility.Collapsed; NavRow.Visibility = Visibility.Collapsed; SearchSortRow.Visibility = Visibility.Collapsed; @@ -4252,6 +4460,7 @@ private void AnimTick(object? sender, object e) NavRow.Visibility = Visibility.Collapsed; SearchSortRow.Visibility = Visibility.Collapsed; TrackListView.Visibility = Visibility.Collapsed; + AlbumsGridView.Visibility = Visibility.Collapsed; WaveformContainer.Visibility = Visibility.Collapsed; EqualizerContainer.Visibility = Visibility.Collapsed; MediaContainer.Visibility = Visibility.Collapsed; @@ -4267,6 +4476,7 @@ private void AnimTick(object? sender, object e) NavRow.Visibility = Visibility.Collapsed; SearchSortRow.Visibility = Visibility.Collapsed; TrackListView.Visibility = Visibility.Collapsed; + AlbumsGridView.Visibility = Visibility.Collapsed; WaveformContainer.Visibility = Visibility.Collapsed; EqualizerContainer.Visibility = Visibility.Collapsed; MediaContainer.Visibility = Visibility.Collapsed; diff --git a/Audiomatic/Services/AudioPlayerService.cs b/Audiomatic/Services/AudioPlayerService.cs index 31b8c09..f9eff4d 100644 --- a/Audiomatic/Services/AudioPlayerService.cs +++ b/Audiomatic/Services/AudioPlayerService.cs @@ -17,6 +17,7 @@ public sealed class AudioPlayerService : IDisposable private AudioFileReader? _audioReader; private bool _useNAudio; private bool _isMuted; + private double _volume = 1.0; private InMemoryRandomAccessStream? _albumArtStream; // Equalizer @@ -60,12 +61,13 @@ public TimeSpan Duration } public double Volume { - get => _mediaPlayer.Volume; + get => _volume; set { - _mediaPlayer.Volume = Math.Clamp(value, 0, 1); - if (_waveOut != null) - _waveOut.Volume = (float)Math.Clamp(value, 0, 1); + _volume = Math.Clamp(value, 0, 1); + _mediaPlayer.Volume = _volume; + if (_audioReader != null) + _audioReader.Volume = _isMuted ? 0f : (float)_volume; } } public bool IsMuted @@ -75,8 +77,8 @@ public bool IsMuted { _isMuted = value; _mediaPlayer.IsMuted = value; - if (_waveOut != null) - _waveOut.Volume = value ? 0f : (float)Math.Clamp(_mediaPlayer.Volume, 0, 1); + if (_audioReader != null) + _audioReader.Volume = value ? 0f : (float)_volume; } } @@ -146,9 +148,9 @@ public async Task PlayTrackAsync(TrackInfo track) _equalizer.SetAllBands(_eqGains); _equalizer.Preamp = DbToLinear(_eqPreampDb); + _audioReader.Volume = _isMuted ? 0f : (float)_volume; _waveOut = new WasapiOut(); _waveOut.Init(new NAudio.Wave.SampleProviders.SampleToWaveProvider16(_equalizer)); - _waveOut.Volume = _isMuted ? 0f : (float)Math.Clamp(_mediaPlayer.Volume, 0, 1); _waveOut.PlaybackStopped += (_, _) => { if (_audioReader != null && _audioReader.CurrentTime >= _audioReader.TotalTime - TimeSpan.FromMilliseconds(500)) @@ -210,7 +212,7 @@ void onFailed(MediaPlayer mp, MediaPlayerFailedEventArgs args) session.BufferingEnded += (_, _) => _dispatcherQueue?.TryEnqueue(() => BufferingChanged?.Invoke(false)); - _mediaPlayer.Volume = Volume; + _mediaPlayer.Volume = _volume; _mediaPlayer.Play(); // Wait up to 15s for the stream to open diff --git a/README.md b/README.md index 85eab2f..ca88c8a 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ A modern, compact desktop audio player for Windows 11, built with WinUI 3 and .N ### Audio Playback -- **Dual audio engine** — MediaPlayer for standard formats, NAudio (WASAPI) for advanced ones +- **NAudio-powered engine** — all file playback routed through NAudio (WASAPI) with equalizer processing; MediaPlayer used for radio streams - **Supported formats**: MP3, FLAC, WAV, OGG, AAC, WMA, M4A, OPUS, APE, AIFF - **Playback controls**: Play/Pause, Previous, Next, timeline seeking - **Volume control** with mute toggle and dynamic icon states @@ -56,6 +56,14 @@ A modern, compact desktop audio player for Windows 11, built with WinUI 3 and .N - **Manual toggle** — mark episodes as read or unread via Raycast-style context menu - **Subscription management** — unsubscribe from podcasts via context menu +### Equalizer + +- **10-band graphic equalizer** — adjustable gain per band (-12 to +12 dB) at 32, 64, 125, 250, 500, 1k, 2k, 4k, 8k, and 16k Hz +- **Presets** — Flat, Bass Boost, Treble Boost, Rock, Pop, Jazz, Classical, Electronic, Hip-Hop, Vocal +- **Preamp control** — global gain adjustment (-12 to +12 dB) +- **Enable/disable toggle** — bypass the equalizer without losing your settings +- **Persistent settings** — band gains, preset, preamp, and enabled state saved across sessions + ### Search & Sort - Real-time filtering by title, artist, or album @@ -77,6 +85,7 @@ A modern, compact desktop audio player for Windows 11, built with WinUI 3 and .N | **Queue** | View and manage the current playback queue | | **Radio** | Play online radio streams with station management | | **Podcasts** | Search, subscribe, browse episodes, and play podcasts | +| **Equalizer** | 10-band graphic EQ with presets, preamp, and per-band control | | **Visualizer** | Real-time FFT spectrum analyzer with mirror mode (via "..." menu) | | **Media Control** | Monitor and control background media players via "..." menu | @@ -104,6 +113,7 @@ A modern, compact desktop audio player for Windows 11, built with WinUI 3 and .N - **Always-on-Top** pin mode - **Backdrop options**: Acrylic, Mica, Mica Alt, None - **Theme support**: System, Light, Dark +- **Custom accent colors** — 24 preset color swatches + custom hex input, applied across all themes - **Window position** remembered across restarts - **Custom draggable title bar** - **Raycast-style context menus** for tracks, playlists, and queue items @@ -123,7 +133,9 @@ A modern, compact desktop audio player for Windows 11, built with WinUI 3 and .N - Library management (add folders, rescan, reset) - Backdrop and theme selection +- Custom accent color picker (24 presets + hex input) - Display mode cycling and pin-on-top toggles +- Equalizer configuration (bands, presets, preamp) - Visualizer FPS selection (30 / 60 FPS) - All preferences persisted in `settings.json` diff --git a/installer.iss b/installer.iss index c32ee64..2f48027 100644 --- a/installer.iss +++ b/installer.iss @@ -1,6 +1,6 @@ [Setup] AppName=Audiomatic -AppVersion=0.0.5 +AppVersion=0.0.6 AppPublisher=OhMyCode DefaultDirName={localappdata}\Programs\Audiomatic DefaultGroupName=Audiomatic