diff --git a/CHANGELOG.md b/CHANGELOG.md index 3931812..1071215 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ recorded by the `MandoCode` submodule. ## [Unreleased] ### Added +- **Music player.** A music icon on the left rail opens a compact player: play/pause, next, + stop, volume, and a playlist picker. While music plays the rail icon becomes an animated + gold equalizer, and hovering it names the current track. **Add playlist** points at any + folder of MP3s on a local disk (a junction under `~\.mandocode\music` — nothing is copied, + and the CLI sees the same playlists); **Remove** deletes only the pointer, never the files, + and only ever offers itself on playlists added this way. Tracks auto-advance through the + playlist (an engine fix that also benefits the CLI — see the MandoCode changelog). - **First-run guided setup.** A fresh install now walks through everything in the chat itself: reach Ollama (with a one-click winget install when it's missing), start the daemon, and pick a starter model from a curated list — cloud recommended, or local tiers with size and hardware diff --git a/MandoCode b/MandoCode index 7200b81..279ccb6 160000 --- a/MandoCode +++ b/MandoCode @@ -1 +1 @@ -Subproject commit 7200b81371c06762f5f521bb1361189ba9b86d5d +Subproject commit 279ccb667cc1e86e8e08e4eeded9889a94272b33 diff --git a/src/MandoCode.Desktop/MainWindow.Music.cs b/src/MandoCode.Desktop/MainWindow.Music.cs new file mode 100644 index 0000000..065a7ff --- /dev/null +++ b/src/MandoCode.Desktop/MainWindow.Music.cs @@ -0,0 +1,287 @@ +using MandoCode.Services; +using MandoCode.Desktop.Services; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; + +namespace MandoCode.Desktop; + +public sealed partial class MainWindow +{ + // ============================================================ + // Music flyout — UI over the harness's app-wide MusicPlayerService (one audio device). + // The service changes state on its own — auto-advance swaps CurrentTrack when a song + // ends, and a device failure stops playback with only AudioError to show for it — but + // exposes no events, so a 2-second poll watches for movement (WireMusicPolling). + // Playlist add/remove (directory junctions) lives in Services/MusicPlaylists. + // ============================================================ + + private readonly MusicPlayerService _music; + + /// Guards the playlist combo's SelectionChanged while RefreshMusicUi repopulates it. + private bool _loadingMusicUi; + + private void MusicFlyout_Opening(object sender, object e) => RefreshMusicUi(); + + /// Full rebuild: playlist list, selection, volume, empty state. Only for flyout + /// open and playlist add/remove — repopulating ItemsSource on every transport click would + /// churn selection (and close the dropdown if it's expanded under the user). + private void RefreshMusicUi() + { + _loadingMusicUi = true; + try + { + var genres = _music.GetAvailableGenres(); + MusicGenreCombo.ItemsSource = genres; + MusicGenreCombo.SelectedItem = genres.FirstOrDefault(g => MusicPlaylists.SameName(g, _music.Genre)) + ?? genres.FirstOrDefault(); + + var hasTracks = genres.Count > 0; + MusicGenreCombo.IsEnabled = hasTracks; + MusicPlayPauseButton.IsEnabled = hasTracks; + MusicVolumeSlider.Value = _music.Volume * 100; + + MusicHintText.Visibility = Visibility.Collapsed; + if (!hasTracks) + ShowMusicHint($"No MP3s found. A playlist is just a folder of MP3s under {_music.UserMusicPath} (e.g. \\lofi)."); + + UpdateRemovePlaylistButton(); + } + finally + { + _loadingMusicUi = false; + } + RefreshTransportState(); + } + + /// The parts that move during playback: track line, button states, play/pause + /// glyph, rail icon — and any AudioError, surfaced the moment the poll sees it. + private void RefreshTransportState() + { + MusicNextButton.IsEnabled = _music.IsPlaying; + MusicStopButton.IsEnabled = _music.IsPlaying || _music.IsPaused; + + // IsPlaying and IsPaused are mutually exclusive in the service — paused means + // IsPlaying == false — so IsPlaying alone answers "is audio actually flowing". + MusicTrackText.Text = _music.CurrentTrack is { } track + ? (_music.IsPaused ? $"Paused — {track.Name}" : $"{track.Name} · {track.Genre}") + : "Nothing playing"; + MusicPlayPauseIcon.Glyph = _music.IsPlaying ? "" : ""; // pause : play + + if (_music.AudioError is { } error) ShowMusicHint(error); + + UpdateMusicRailIcon(); + } + + // ============================================================ + // Rail icon + poll + // ============================================================ + + private string? _musicTooltip; + + /// The rail icon carries the state worth showing while the flyout is closed: an + /// animated gold equalizer while music plays (the glyph hides behind it), and a tooltip + /// naming the track — hover answers "what's this song" without opening anything. Runs on + /// the poll, so both only move on actual change: Begin() on a running storyboard visibly + /// restarts the bounce, and rewriting an open tooltip dismisses it. + private void UpdateMusicRailIcon() + { + var audible = _music.IsPlaying; + if (audible != (MusicEqPanel.Visibility == Visibility.Visible)) + { + NavMusicIcon.Visibility = audible ? Visibility.Collapsed : Visibility.Visible; + MusicEqPanel.Visibility = audible ? Visibility.Visible : Visibility.Collapsed; + if (audible) MusicEqStoryboard.Begin(); + else MusicEqStoryboard.Stop(); + } + + var tooltip = _music.CurrentTrack is { } track + ? (audible ? $"Playing — {track.Name}" : $"Paused — {track.Name}") + : "Music — background playlists while you work"; + if (tooltip != _musicTooltip) + { + _musicTooltip = tooltip; + ToolTipService.SetToolTip(NavMusic, tooltip); + } + } + + /// Kept in a field: a DispatcherQueueTimer referenced only by a local is + /// garbage-collected mid-flight and simply stops ticking. Stopped in MainWindow_Closed. + private Microsoft.UI.Dispatching.DispatcherQueueTimer? _musicPollTimer; + private string? _musicStateKey; + + /// Watches for state the service changes on its own (auto-advance, device + /// failure) or that /music chat commands change from outside this flyout. Each tick + /// compares a small state key and touches the UI only when it moved — the open flyout + /// gets a transport refresh (so the track line follows an auto-advance), the closed one + /// just the rail icon. Called once from the constructor. + private void WireMusicPolling() + { + _musicPollTimer = _dispatcher.CreateTimer(); + _musicPollTimer.Interval = TimeSpan.FromSeconds(2); + _musicPollTimer.Tick += (_, _) => + { + var key = $"{_music.IsPlaying}|{_music.IsPaused}|{_music.CurrentTrack?.Name}|{_music.AudioError}"; + if (key == _musicStateKey) return; + _musicStateKey = key; + + if (MusicFlyout.IsOpen) RefreshTransportState(); + else UpdateMusicRailIcon(); + }; + _musicPollTimer.Start(); + + UpdateMusicRailIcon(); // seed the icon and tooltip (the tooltip is only set here, not in XAML) + } + + // ============================================================ + // Transport + playlist selection + // ============================================================ + + private void MusicPlayPause_Click(object sender, RoutedEventArgs e) + { + if (_music.IsPlaying || _music.IsPaused) _music.TogglePause(); + else _music.Play(MusicGenreCombo.SelectedItem as string); + RefreshTransportState(); + } + + private void MusicNext_Click(object sender, RoutedEventArgs e) + { + _music.NextTrack(); + RefreshTransportState(); + } + + private void MusicStop_Click(object sender, RoutedEventArgs e) + { + _music.Stop(); + RefreshTransportState(); + } + + private void MusicGenre_SelectionChanged(object sender, SelectionChangedEventArgs e) + { + if (_loadingMusicUi || MusicGenreCombo.SelectedItem is not string genre) return; + + UpdateRemovePlaylistButton(); + + // Record the pick through the config owner even while idle. The flyout closes (and + // this combo unloads) around Add-playlist's folder picker, and RefreshMusicUi + // re-selects from music.Genre — without this the dropdown snaps back to the previous + // playlist on reopen. Saving also makes the pick survive a restart, like the volume + // already does via the service's own SavePreferences. + _configs.Defaults.Music.Genre = genre; + _configs.SaveDefaults(); + + // Switching playlist while playing jumps to it immediately; while idle it just + // becomes what the play button will start. + if (_music.IsPlaying || _music.IsPaused) + { + _music.Play(genre); + RefreshTransportState(); + } + } + + private void MusicVolume_ValueChanged(object sender, Microsoft.UI.Xaml.Controls.Primitives.RangeBaseValueChangedEventArgs e) + { + if (_loadingMusicUi) return; + _music.SetVolume((float)(e.NewValue / 100.0)); + } + + // ============================================================ + // Playlist add / remove — thin UI over Services/MusicPlaylists + // ============================================================ + + private async void MusicAddPlaylist_Click(object sender, RoutedEventArgs e) + { + var picker = new Windows.Storage.Pickers.FolderPicker(); + picker.FileTypeFilter.Add("*"); + // Unpackaged apps must initialize pickers with the window handle. + WinRT.Interop.InitializeWithWindow.Initialize(picker, WinRT.Interop.WindowNative.GetWindowHandle(this)); + + var folder = await picker.PickSingleFolderAsync(); + if (folder == null) return; + + // Re-adding a folder that's already a playlist selects the existing one. + if (MusicPlaylists.FindExistingFor(_music.UserMusicPath, folder.Path) is { } existing) + { + SelectPlaylist(existing); + ShowMusicHint($"“{existing}” already points at that folder — selected it."); + return; + } + + var name = MusicPlaylists.MakeUniqueName(_music.UserMusicPath, folder.Path); + try + { + await MusicPlaylists.CreateAsync(_music.UserMusicPath, name, folder.Path); + } + catch (Exception ex) + { + ShowMusicHint($"Couldn't add that playlist: {ex.Message}"); + return; + } + + // Off the UI thread: the rescan walks every playlist folder, including junctions + // into arbitrarily large directories. + var rediscovered = await Task.Run(() => MusicPlaylists.TryRediscover(_music)); + RefreshMusicUi(); + SelectPlaylist(name); + + string message; + if (!rediscovered) + { + message = "Playlist added — restart MandoCode to see it."; + } + else + { + var tracks = _music.GetAvailableTracks(name).Count; + message = tracks == 0 + ? "Playlist added, but no MP3s sit at the top level of that folder." + : $"Added “{name}” ({tracks} track{(tracks == 1 ? "" : "s")})."; + } + ShowMusicHint(message); + } + + private async void MusicRemovePlaylist_Click(object sender, RoutedEventArgs e) + { + if (MusicGenreCombo.SelectedItem is not string name) return; + // Pointers only, never a real folder of files — same gate as the button's visibility. + if (!MusicPlaylists.IsJunction(Path.Combine(_music.UserMusicPath, name))) return; + + try + { + if (MusicPlaylists.SameName(_music.Genre, name) && (_music.IsPlaying || _music.IsPaused)) + _music.Stop(); + MusicPlaylists.Remove(_music.UserMusicPath, name); + } + catch (Exception ex) + { + ShowMusicHint($"Couldn't remove the playlist: {ex.Message}"); + return; + } + + var rediscovered = await Task.Run(() => MusicPlaylists.TryRediscover(_music)); + RefreshMusicUi(); + ShowMusicHint(rediscovered ? $"Removed “{name}” — its folder is untouched." + : "Playlist removed — restart MandoCode to update the list."); + } + + /// Remove only offers itself for junction-backed playlists. Embedded genres have + /// no folder, and a real folder of files is not ours to delete from a flyout. + private void UpdateRemovePlaylistButton() + { + var visible = MusicGenreCombo.SelectedItem is string name + && MusicPlaylists.IsJunction(Path.Combine(_music.UserMusicPath, name)); + MusicRemovePlaylistButton.Visibility = visible ? Visibility.Visible : Visibility.Collapsed; + } + + /// Selects a playlist in the combo by name. Selection IS "loading": the change + /// handler records it as the service's genre and switches live playback to it. + private void SelectPlaylist(string name) + { + var match = MusicGenreCombo.Items.OfType().FirstOrDefault(g => MusicPlaylists.SameName(g, name)); + if (match != null) MusicGenreCombo.SelectedItem = match; + } + + private void ShowMusicHint(string text) + { + MusicHintText.Text = text; + MusicHintText.Visibility = Visibility.Visible; + } +} diff --git a/src/MandoCode.Desktop/MainWindow.xaml b/src/MandoCode.Desktop/MainWindow.xaml index 1e3058d..27e8b04 100644 --- a/src/MandoCode.Desktop/MainWindow.xaml +++ b/src/MandoCode.Desktop/MainWindow.xaml @@ -90,6 +90,101 @@ ToolTipService.ToolTip="Skills — reusable instructions the AI loads on demand"> + + + + + + + + + + + + + + + + + + + +