diff --git a/README.md b/README.md index 22a22c2..a8c710e 100644 --- a/README.md +++ b/README.md @@ -12,8 +12,10 @@ | --- | ------ | | **F1** | Open Settings Window | | **F2** | Load New Image | +| **S** | Save Track(s) | | **Space** | Toggle Play / Pause | | **Esc** | Stop Playback | +| **~** | Eject | | **→** | Next Track | | **←** | Previous Track | | **]** | Next Index | @@ -25,6 +27,10 @@ | **M** | Mute | | **E** | Toggle Emphasis | +For Save Track(s): +- Holding no modifying keys will prompt to save the current track +- Holding **Shift** will prompt to save all tracks (including hidden) + For both Volume Up and Volume Down: - Holding **Ctrl** will move in increments of 2 - Holding **Shift** will move in increments of 5 diff --git a/RedBookPlayer.GUI/ViewModels/MainViewModel.cs b/RedBookPlayer.GUI/ViewModels/MainViewModel.cs index 9faa1f5..c3a2a0e 100644 --- a/RedBookPlayer.GUI/ViewModels/MainViewModel.cs +++ b/RedBookPlayer.GUI/ViewModels/MainViewModel.cs @@ -40,6 +40,23 @@ namespace RedBookPlayer.GUI.ViewModels PlayerView?.ViewModel?.ExecuteLoad(); } + // Save track(s) + else if(e.Key == App.Settings.SaveTrackKey) + { + if(PlayerView?.ViewModel == null || !PlayerView.ViewModel.Initialized) + return; + + var dialog = new OpenFolderDialog(); + string path = await dialog.ShowAsync(App.MainWindow); + if(string.IsNullOrWhiteSpace(path)) + return; + + if(e.KeyModifiers.HasFlag(KeyModifiers.Shift)) + PlayerView.ViewModel.ExtractAllTracksToWav(path); + else + PlayerView.ViewModel.ExtractSingleTrackToWav((uint)PlayerView.ViewModel.CurrentTrackNumber, path); + } + // Toggle playback else if(e.Key == App.Settings.TogglePlaybackKey || e.Key == Key.MediaPlayPause) { @@ -52,6 +69,12 @@ namespace RedBookPlayer.GUI.ViewModels PlayerView?.ViewModel?.ExecuteStop(); } + // Eject + else if(e.Key == App.Settings.EjectKey) + { + PlayerView?.ViewModel?.ExecuteEject(); + } + // Next Track else if(e.Key == App.Settings.NextTrackKey || e.Key == Key.MediaNextTrack) { diff --git a/RedBookPlayer.GUI/ViewModels/PlayerViewModel.cs b/RedBookPlayer.GUI/ViewModels/PlayerViewModel.cs index 00f92d5..5c25eac 100644 --- a/RedBookPlayer.GUI/ViewModels/PlayerViewModel.cs +++ b/RedBookPlayer.GUI/ViewModels/PlayerViewModel.cs @@ -6,11 +6,9 @@ using System.Linq; using System.Reactive; using System.Threading.Tasks; using System.Xml; -using Avalonia; using Avalonia.Controls; using Avalonia.Markup.Xaml; using Avalonia.Media.Imaging; -using Avalonia.Platform; using Avalonia.Threading; using ReactiveUI; using RedBookPlayer.Models; @@ -193,6 +191,15 @@ namespace RedBookPlayer.GUI.ViewModels private set => this.RaiseAndSetIfChanged(ref _dataPlayback, value); } + /// + /// Indicates the repeat mode + /// + public RepeatMode RepeatMode + { + get => _repeatMode; + private set => this.RaiseAndSetIfChanged(ref _repeatMode, value); + } + /// /// Indicates if de-emphasis should be applied /// @@ -214,6 +221,7 @@ namespace RedBookPlayer.GUI.ViewModels private bool _initialized; private PlayerState _playerState; private DataPlayback _dataPlayback; + private RepeatMode _repeatMode; private bool _applyDeEmphasis; private int _volume; @@ -250,6 +258,11 @@ namespace RedBookPlayer.GUI.ViewModels /// public ReactiveCommand StopCommand { get; } + /// + /// Command for ejecting the current disc + /// + public ReactiveCommand EjectCommand { get; } + /// /// Command for moving to the next track /// @@ -334,6 +347,7 @@ namespace RedBookPlayer.GUI.ViewModels PauseCommand = ReactiveCommand.Create(ExecutePause); TogglePlayPauseCommand = ReactiveCommand.Create(ExecuteTogglePlayPause); StopCommand = ReactiveCommand.Create(ExecuteStop); + EjectCommand = ReactiveCommand.Create(ExecuteEject); NextTrackCommand = ReactiveCommand.Create(ExecuteNextTrack); PreviousTrackCommand = ReactiveCommand.Create(ExecutePreviousTrack); NextIndexCommand = ReactiveCommand.Create(ExecuteNextIndex); @@ -359,15 +373,16 @@ namespace RedBookPlayer.GUI.ViewModels /// /// Path to the disc image /// Options to pass to the optical disc factory + /// RepeatMode for sound output /// True if playback should begin immediately, false otherwise - public void Init(string path, OpticalDiscOptions options, bool autoPlay) + public void Init(string path, OpticalDiscOptions options, RepeatMode repeatMode, bool autoPlay) { // Stop current playback, if necessary if(PlayerState != PlayerState.NoDisc) ExecuteStop(); // Attempt to initialize Player - _player.Init(path, options, autoPlay); + _player.Init(path, options, repeatMode, autoPlay); if(_player.Initialized) { _player.PropertyChanged += PlayerStateChanged; @@ -397,6 +412,11 @@ namespace RedBookPlayer.GUI.ViewModels /// public void ExecuteStop() => _player?.Stop(); + /// + /// Eject the currently loaded disc + /// + public void ExecuteEject() => _player?.Eject(); + /// /// Move to the next playable track /// @@ -578,13 +598,14 @@ namespace RedBookPlayer.GUI.ViewModels DataPlayback = App.Settings.DataPlayback, GenerateMissingToc = App.Settings.GenerateMissingTOC, LoadHiddenTracks = App.Settings.PlayHiddenTracks, + SessionHandling = App.Settings.SessionHandling, }; // Ensure the context and view model are set App.PlayerView.DataContext = this; App.PlayerView.ViewModel = this; - Init(path, options, App.Settings.AutoPlay); + Init(path, options, App.Settings.RepeatMode, App.Settings.AutoPlay); if(Initialized) App.MainWindow.Title = "RedBookPlayer - " + path.Split('/').Last().Split('\\').Last(); @@ -599,8 +620,23 @@ namespace RedBookPlayer.GUI.ViewModels { SetDataPlayback(App.Settings.DataPlayback); SetLoadHiddenTracks(App.Settings.PlayHiddenTracks); + SetRepeatMode(App.Settings.RepeatMode); + SetSessionHandling(App.Settings.SessionHandling); } + /// + /// Extract a single track from the image to WAV + /// + /// + /// Output path to write data to _player?.ExtractSingleTrackToWav(trackNumber, outputDirectory); + + /// + /// Extract all tracks from the image to WAV + /// + /// Output path to write data to _player?.ExtractAllTracksToWav(outputDirectory); + /// /// Set data playback method [CompactDisc only] /// @@ -613,6 +649,18 @@ namespace RedBookPlayer.GUI.ViewModels /// True to enable loading hidden tracks, false otherwise public void SetLoadHiddenTracks(bool load) => _player?.SetLoadHiddenTracks(load); + /// + /// Set repeat mode + /// + /// New repeat mode value + public void SetRepeatMode(RepeatMode repeatMode) => _player?.SetRepeatMode(repeatMode); + + /// + /// Set session handling + /// + /// New session handling value + public void SetSessionHandling(SessionHandling sessionHandling) => _player?.SetSessionHandling(sessionHandling); + /// /// Generate the digit string to be interpreted by the frontend /// @@ -754,6 +802,7 @@ namespace RedBookPlayer.GUI.ViewModels CurrentTrackNumber = _player.CurrentTrackNumber; CurrentTrackIndex = _player.CurrentTrackIndex; + CurrentTrackSession = _player.CurrentTrackSession; CurrentSector = _player.CurrentSector; SectionStartSector = _player.SectionStartSector; @@ -766,6 +815,7 @@ namespace RedBookPlayer.GUI.ViewModels PlayerState = _player.PlayerState; DataPlayback = _player.DataPlayback; + RepeatMode = _player.RepeatMode; ApplyDeEmphasis = _player.ApplyDeEmphasis; Volume = _player.Volume; diff --git a/RedBookPlayer.GUI/ViewModels/SettingsViewModel.cs b/RedBookPlayer.GUI/ViewModels/SettingsViewModel.cs index c57e33d..6473f7c 100644 --- a/RedBookPlayer.GUI/ViewModels/SettingsViewModel.cs +++ b/RedBookPlayer.GUI/ViewModels/SettingsViewModel.cs @@ -21,6 +21,18 @@ namespace RedBookPlayer.GUI.ViewModels [JsonIgnore] public List DataPlaybackValues => GenerateDataPlaybackList(); + /// + /// List of all repeat mode values + /// + [JsonIgnore] + public List RepeatModeValues => GenerateRepeatModeList(); + + /// + /// List of all session handling values + /// + [JsonIgnore] + public List SessionHandlingValues => GenerateSessionHandlingList(); + /// /// List of all themes /// @@ -57,6 +69,16 @@ namespace RedBookPlayer.GUI.ViewModels /// public DataPlayback DataPlayback { get; set; } = DataPlayback.Skip; + /// + /// Indicates how to repeat tracks + /// + public RepeatMode RepeatMode { get; set; } = RepeatMode.All; + + /// + /// Indicates how to handle tracks on different sessions + /// + public SessionHandling SessionHandling { get; set; } = SessionHandling.FirstSessionOnly; + /// /// Indicates the default playback volume /// @@ -102,6 +124,11 @@ namespace RedBookPlayer.GUI.ViewModels /// public Key LoadImageKey { get; set; } = Key.F2; + /// + /// Key assigned to save the current track or all tracks + /// + public Key SaveTrackKey { get; set; } = Key.S; + /// /// Key assigned to toggle play and pause /// @@ -253,6 +280,16 @@ namespace RedBookPlayer.GUI.ViewModels /// private List GenerateKeyList() => Enum.GetValues(typeof(Key)).Cast().ToList(); + /// + /// Generate the list of RepeatMode values + /// + private List GenerateRepeatModeList() => Enum.GetValues(typeof(RepeatMode)).Cast().ToList(); + + /// + /// Generate the list of SessionHandling values + /// + private List GenerateSessionHandlingList() => Enum.GetValues(typeof(SessionHandling)).Cast().ToList(); + /// /// Generate the list of valid themes /// diff --git a/RedBookPlayer.GUI/Views/SettingsWindow.xaml b/RedBookPlayer.GUI/Views/SettingsWindow.xaml index ab0a8c3..4000a8a 100644 --- a/RedBookPlayer.GUI/Views/SettingsWindow.xaml +++ b/RedBookPlayer.GUI/Views/SettingsWindow.xaml @@ -30,6 +30,16 @@ + + Session Handling + + + + Repeat Mode + + Generate a TOC if the disc is missing one @@ -78,6 +88,12 @@ Items="{Binding KeyboardList}" SelectedItem="{Binding LoadImageKey, Mode=TwoWay}" HorizontalAlignment="Right" Margin="8,0,0,0" Width="120"/> + + Save Track(s) + + Toggle Play/Pause + public override ushort CurrentTrackSession + { + get => _currentTrackSession; + protected set => this.RaiseAndSetIfChanged(ref _currentTrackSession, value); + } + /// public override ulong CurrentSector { @@ -229,6 +240,11 @@ namespace RedBookPlayer.Models.Discs /// public bool LoadHiddenTracks { get; set; } = false; + /// + /// Indicates how tracks on different session should be handled + /// + public SessionHandling SessionHandling { get; set; } = SessionHandling.AllSessions; + private bool _quadChannel; private bool _isDataTrack; private bool _copyAllowed; @@ -248,6 +264,11 @@ namespace RedBookPlayer.Models.Discs /// private ushort _currentTrackIndex = 0; + /// + /// Current track session + /// + private ushort _currentTrackSession = 0; + /// /// Current sector number /// @@ -274,6 +295,7 @@ namespace RedBookPlayer.Models.Discs DataPlayback = options.DataPlayback; _generateMissingToc = options.GenerateMissingToc; LoadHiddenTracks = options.LoadHiddenTracks; + SessionHandling = options.SessionHandling; } /// @@ -417,6 +439,44 @@ namespace RedBookPlayer.Models.Discs #region Helpers + /// + public override void ExtractTrackToWav(uint trackNumber, string outputDirectory) + { + if(_image == null) + return; + + // Get the track with that value, if possible + Track track = _image.Tracks.FirstOrDefault(t => t.TrackSequence == trackNumber); + + // If the track isn't valid, we can't do anything + if(track == null || track.TrackType != TrackType.Audio) + return; + + // Read in the track data to a buffer + uint length = (uint)(track.TrackEndSector - track.TrackStartSector); + byte[] buffer = _image.ReadSectors(track.TrackStartSector, length); + + // Build the WAV output + string filename = Path.Combine(outputDirectory, $"Track {trackNumber.ToString().PadLeft(2, '0')}.wav"); + using(WaveWriter waveWriter = new WaveWriter(filename, new CSCore.WaveFormat())) + { + // Write out to the file + waveWriter.Write(buffer, 0, buffer.Length); + } + } + + /// + public override void ExtractAllTracksToWav(string outputDirectory) + { + if(_image == null) + return; + + foreach(Track track in _image.Tracks) + { + ExtractTrackToWav(track.TrackSequence, outputDirectory); + } + } + /// public override void LoadTrack(int trackNumber) { diff --git a/RedBookPlayer.Models/Discs/OpticalDiscBase.cs b/RedBookPlayer.Models/Discs/OpticalDiscBase.cs index e496901..9b041d3 100644 --- a/RedBookPlayer.Models/Discs/OpticalDiscBase.cs +++ b/RedBookPlayer.Models/Discs/OpticalDiscBase.cs @@ -23,6 +23,11 @@ namespace RedBookPlayer.Models.Discs /// public abstract ushort CurrentTrackIndex { get; protected set; } + /// + /// Current track session + /// + public abstract ushort CurrentTrackSession { get; protected set; } + /// /// Current sector number /// @@ -122,6 +127,19 @@ namespace RedBookPlayer.Models.Discs #region Helpers + /// + /// Extract a track to WAV + /// + /// Track number to extract + /// Output path to write data to + /// Extract all tracks to WAV + /// + /// Output path to write data to /// Load the desired track, if possible /// diff --git a/RedBookPlayer.Models/Discs/OpticalDiscOptions.cs b/RedBookPlayer.Models/Discs/OpticalDiscOptions.cs index 89b1d35..a636c39 100644 --- a/RedBookPlayer.Models/Discs/OpticalDiscOptions.cs +++ b/RedBookPlayer.Models/Discs/OpticalDiscOptions.cs @@ -19,6 +19,11 @@ namespace RedBookPlayer.Models.Discs /// public bool LoadHiddenTracks { get; set; } = false; + /// + /// Indicates how tracks on different session should be handled + /// + public SessionHandling SessionHandling { get; set; } = SessionHandling.AllSessions; + #endregion } } \ No newline at end of file diff --git a/RedBookPlayer.Models/Enums.cs b/RedBookPlayer.Models/Enums.cs index 7063312..4066c37 100644 --- a/RedBookPlayer.Models/Enums.cs +++ b/RedBookPlayer.Models/Enums.cs @@ -46,4 +46,41 @@ namespace RedBookPlayer.Models /// Playing, } + + /// + /// Playback repeat mode + /// + public enum RepeatMode + { + /// + /// No repeat + /// + None, + + /// + /// Repeat a single track + /// + Single, + + /// + /// Repeat all tracks + /// + All, + } + + /// + /// Determine how to handle different sessions + /// + public enum SessionHandling + { + /// + /// Allow playing tracks from all sessions + /// + AllSessions = 0, + + /// + /// Only play tracks from the first session + /// + FirstSessionOnly = 1, + } } \ No newline at end of file diff --git a/RedBookPlayer.Models/Hardware/Player.cs b/RedBookPlayer.Models/Hardware/Player.cs index 4f172ab..c22c23e 100644 --- a/RedBookPlayer.Models/Hardware/Player.cs +++ b/RedBookPlayer.Models/Hardware/Player.cs @@ -39,6 +39,15 @@ namespace RedBookPlayer.Models.Hardware private set => this.RaiseAndSetIfChanged(ref _currentTrackIndex, value); } + /// + /// Current track session + /// + public ushort CurrentTrackSession + { + get => _currentTrackSession; + private set => this.RaiseAndSetIfChanged(ref _currentTrackSession, value); + } + /// /// Current sector number /// @@ -129,6 +138,7 @@ namespace RedBookPlayer.Models.Hardware private int _currentTrackNumber; private ushort _currentTrackIndex; + private ushort _currentTrackSession; private ulong _currentSector; private ulong _sectionStartSector; @@ -160,6 +170,15 @@ namespace RedBookPlayer.Models.Hardware private set => this.RaiseAndSetIfChanged(ref _dataPlayback, value); } + /// + /// Indicates the repeat mode + /// + public RepeatMode RepeatMode + { + get => _repeatMode; + private set => this.RaiseAndSetIfChanged(ref _repeatMode, value); + } + /// /// Indicates if de-emphasis should be applied /// @@ -180,6 +199,7 @@ namespace RedBookPlayer.Models.Hardware private PlayerState _playerState; private DataPlayback _dataPlayback; + private RepeatMode _repeatMode; private bool _applyDeEmphasis; private int _volume; @@ -220,8 +240,9 @@ namespace RedBookPlayer.Models.Hardware /// /// Path to the disc image /// Options to pass to the optical disc factory + /// RepeatMode for sound output /// True if playback should begin immediately, false otherwise - public void Init(string path, OpticalDiscOptions options, bool autoPlay) + public void Init(string path, OpticalDiscOptions options, RepeatMode repeatMode, bool autoPlay) { // Reset initialization Initialized = false; @@ -235,7 +256,7 @@ namespace RedBookPlayer.Models.Hardware _opticalDisc.PropertyChanged += OpticalDiscStateChanged; // Initialize the sound output - _soundOutput.Init(_opticalDisc, autoPlay); + _soundOutput.Init(_opticalDisc, repeatMode, autoPlay); if(_soundOutput == null || !_soundOutput.Initialized) return; @@ -323,6 +344,23 @@ namespace RedBookPlayer.Models.Hardware PlayerState = PlayerState.Stopped; } + /// + /// Eject the currently loaded disc + /// + public void Eject() + { + if(_opticalDisc == null || !_opticalDisc.Initialized) + return; + else if(_soundOutput == null) + return; + + Stop(); + _soundOutput.Eject(); + _opticalDisc = null; + PlayerState = PlayerState.NoDisc; + Initialized = false; + } + /// /// Move to the next playable track /// @@ -493,6 +531,18 @@ namespace RedBookPlayer.Models.Hardware #region Helpers + /// + /// Extract a single track from the image to WAV + /// + /// + /// Output path to write data to _opticalDisc?.ExtractTrackToWav(trackNumber, outputDirectory); + + /// + /// Extract all tracks from the image to WAV + /// Output path to write data to _opticalDisc?.ExtractAllTracksToWav(outputDirectory); + /// /// Set data playback method [CompactDisc only] /// @@ -513,6 +563,22 @@ namespace RedBookPlayer.Models.Hardware compactDisc.LoadHiddenTracks = load; } + /// + /// Set repeat mode + /// + /// New repeat mode value + public void SetRepeatMode(RepeatMode repeatMode) => _soundOutput?.SetRepeatMode(repeatMode); + + /// + /// Set the value for session handling [CompactDisc only] + /// + /// New session handling value + public void SetSessionHandling(SessionHandling sessionHandling) + { + if(_opticalDisc is CompactDisc compactDisc) + compactDisc.SessionHandling = sessionHandling; + } + /// /// Update the player from the current OpticalDisc /// @@ -547,6 +613,7 @@ namespace RedBookPlayer.Models.Hardware private void SoundOutputStateChanged(object sender, PropertyChangedEventArgs e) { PlayerState = _soundOutput.PlayerState; + RepeatMode = _soundOutput.RepeatMode; ApplyDeEmphasis = _soundOutput.ApplyDeEmphasis; Volume = _soundOutput.Volume; } diff --git a/RedBookPlayer.Models/Hardware/SoundOutput.cs b/RedBookPlayer.Models/Hardware/SoundOutput.cs index 663e7c9..94b2935 100644 --- a/RedBookPlayer.Models/Hardware/SoundOutput.cs +++ b/RedBookPlayer.Models/Hardware/SoundOutput.cs @@ -31,6 +31,15 @@ namespace RedBookPlayer.Models.Hardware private set => this.RaiseAndSetIfChanged(ref _playerState, value); } + /// + /// Indicates the repeat mode + /// + public RepeatMode RepeatMode + { + get => _repeatMode; + private set => this.RaiseAndSetIfChanged(ref _repeatMode, value); + } + /// /// Indicates if de-emphasis should be applied /// @@ -60,6 +69,7 @@ namespace RedBookPlayer.Models.Hardware private bool _initialized; private PlayerState _playerState; + private RepeatMode _repeatMode; private bool _applyDeEmphasis; private int _volume; @@ -117,8 +127,9 @@ namespace RedBookPlayer.Models.Hardware /// Initialize the output with a given image /// /// OpticalDisc to load from + /// RepeatMode for sound output /// True if playback should begin immediately, false otherwise - public void Init(OpticalDiscBase opticalDisc, bool autoPlay) + public void Init(OpticalDiscBase opticalDisc, RepeatMode repeatMode, bool autoPlay) { // If we have an unusable disc, just return if(opticalDisc == null || !opticalDisc.Initialized) @@ -137,6 +148,9 @@ namespace RedBookPlayer.Models.Hardware // Setup the audio output SetupAudio(); + // Setup the repeat mode + RepeatMode = repeatMode; + // Initialize playback, if necessary if(autoPlay) _soundOut.Play(); @@ -149,6 +163,17 @@ namespace RedBookPlayer.Models.Hardware _source.Start(); } + /// + /// Reset the current internal state + /// + public void Reset() + { + _soundOut.Stop(); + _opticalDisc = null; + Initialized = false; + PlayerState = PlayerState.NoDisc; + } + /// /// Fill the current byte buffer with playable data /// @@ -189,6 +214,11 @@ namespace RedBookPlayer.Models.Hardware int currentTrack = _opticalDisc.CurrentTrackNumber; _opticalDisc.SetCurrentSector(_opticalDisc.CurrentSector + (ulong)(_currentSectorReadPosition / _opticalDisc.BytesPerSector)); _currentSectorReadPosition %= _opticalDisc.BytesPerSector; + + if(RepeatMode == RepeatMode.None && _opticalDisc.CurrentTrackNumber < currentTrack) + Stop(); + else if(RepeatMode == RepeatMode.Single && _opticalDisc.CurrentTrackNumber != currentTrack) + _opticalDisc.LoadTrack(currentTrack); } return count; @@ -229,6 +259,11 @@ namespace RedBookPlayer.Models.Hardware PlayerState = PlayerState.Stopped; } + /// + /// Eject the currently loaded disc + /// + public void Eject() => Reset(); + #endregion #region Helpers @@ -239,6 +274,12 @@ namespace RedBookPlayer.Models.Hardware /// New de-emphasis status public void SetDeEmphasis(bool apply) => ApplyDeEmphasis = apply; + /// + /// Set repeat mode + /// + /// New repeat mode value + public void SetRepeatMode(RepeatMode repeatMode) => RepeatMode = repeatMode; + /// /// Set the value for the volume /// @@ -253,8 +294,8 @@ namespace RedBookPlayer.Models.Hardware /// Number of zeroed sectors to concatenate private void DetermineReadAmount(int count, out ulong sectorsToRead, out ulong zeroSectorsAmount) { - // Attempt to read 5 more sectors than requested - sectorsToRead = ((ulong)count / (ulong)_opticalDisc.BytesPerSector) + 5; + // Attempt to read 10 more sectors than requested + sectorsToRead = ((ulong)count / (ulong)_opticalDisc.BytesPerSector) + 10; zeroSectorsAmount = 0; // Avoid overreads by padding with 0-byte data at the end