diff --git a/README.md b/README.md index 7b0ec36..22a22c2 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,8 @@ * This project is fully sponsored by the [Game Preservation Society](https://www.gamepres.org/en/). +[OpenAL](https://www.openal.org/) is required to run this application. Please install it using the most recent instructions for your operating system of choice. + ## Default Player Controls | Key | Action | diff --git a/RedBookPlayer.GUI/App.xaml.cs b/RedBookPlayer.GUI/App.xaml.cs index 20e6a9d..08dedee 100644 --- a/RedBookPlayer.GUI/App.xaml.cs +++ b/RedBookPlayer.GUI/App.xaml.cs @@ -5,14 +5,20 @@ using Avalonia; using Avalonia.Controls; using Avalonia.Controls.ApplicationLifetimes; using Avalonia.Markup.Xaml; -using RedBookPlayer.GUI; +using RedBookPlayer.GUI.ViewModels; using RedBookPlayer.GUI.Views; namespace RedBookPlayer { public class App : Application { - public static Settings Settings; + public static MainWindow MainWindow; + public static SettingsViewModel Settings; + + /// + /// Read-only access to the current player view + /// + public static PlayerView PlayerView => MainWindow?.ViewModel?.PlayerView; static App() => Directory.SetCurrentDirectory(Path.GetDirectoryName(Process.GetCurrentProcess().MainModule.FileName)); @@ -24,6 +30,7 @@ namespace RedBookPlayer Console.WriteLine(((Exception)f.ExceptionObject).ToString()); }; + Settings = SettingsViewModel.Load("settings.json"); AvaloniaXamlLoader.Load(this); } @@ -31,10 +38,11 @@ namespace RedBookPlayer { if(ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) { - desktop.MainWindow = new MainWindow(); + MainWindow = new MainWindow(); + desktop.MainWindow = MainWindow; desktop.ShutdownMode = ShutdownMode.OnMainWindowClose; - Settings = Settings.Load("settings.json"); + PlayerView.ViewModel.ApplyTheme(Settings.SelectedTheme); } base.OnFrameworkInitializationCompleted(); diff --git a/RedBookPlayer.GUI/Program.cs b/RedBookPlayer.GUI/Program.cs index b1919b6..55247e4 100644 --- a/RedBookPlayer.GUI/Program.cs +++ b/RedBookPlayer.GUI/Program.cs @@ -4,6 +4,7 @@ using System.Runtime.InteropServices; #endif using Avalonia; using Avalonia.Logging.Serilog; +using Avalonia.ReactiveUI; namespace RedBookPlayer.GUI { @@ -24,6 +25,6 @@ namespace RedBookPlayer.GUI static extern bool AllocConsole(); #endif - public static AppBuilder BuildAvaloniaApp() => AppBuilder.Configure().UsePlatformDetect().LogToDebug(); + public static AppBuilder BuildAvaloniaApp() => AppBuilder.Configure().UseReactiveUI().UsePlatformDetect().LogToDebug(); } } \ No newline at end of file diff --git a/RedBookPlayer.GUI/RedBookPlayer.GUI.csproj b/RedBookPlayer.GUI/RedBookPlayer.GUI.csproj index 36d6bb4..22e946e 100644 --- a/RedBookPlayer.GUI/RedBookPlayer.GUI.csproj +++ b/RedBookPlayer.GUI/RedBookPlayer.GUI.csproj @@ -13,7 +13,7 @@ %(Filename) - + Designer @@ -28,4 +28,11 @@ + + + Always + Always + true + + diff --git a/RedBookPlayer.GUI/ViewModels/MainViewModel.cs b/RedBookPlayer.GUI/ViewModels/MainViewModel.cs new file mode 100644 index 0000000..9faa1f5 --- /dev/null +++ b/RedBookPlayer.GUI/ViewModels/MainViewModel.cs @@ -0,0 +1,159 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel; +using Avalonia.Controls; +using Avalonia.Input; +using RedBookPlayer.GUI.Views; + +namespace RedBookPlayer.GUI.ViewModels +{ + public class MainViewModel + { + /// + /// Read-only access to the control + /// + public ContentControl ContentControl => App.MainWindow.FindControl("Content"); + + /// + /// Read-only access to the view + /// + public PlayerView PlayerView => ContentControl?.Content as PlayerView; + + #region Helpers + + /// + /// Execute the result of a keypress + /// + public async void ExecuteKeyPress(object sender, KeyEventArgs e) + { + // Open settings window + if(e.Key == App.Settings.OpenSettingsKey) + { + SettingsWindow settingsWindow = new SettingsWindow() { DataContext = App.Settings }; + settingsWindow.Closed += OnSettingsClosed; + settingsWindow.ShowDialog(App.MainWindow); + } + + // Load image + else if(e.Key == App.Settings.LoadImageKey) + { + PlayerView?.ViewModel?.ExecuteLoad(); + } + + // Toggle playback + else if(e.Key == App.Settings.TogglePlaybackKey || e.Key == Key.MediaPlayPause) + { + PlayerView?.ViewModel?.ExecuteTogglePlayPause(); + } + + // Stop playback + else if(e.Key == App.Settings.StopPlaybackKey || e.Key == Key.MediaStop) + { + PlayerView?.ViewModel?.ExecuteStop(); + } + + // Next Track + else if(e.Key == App.Settings.NextTrackKey || e.Key == Key.MediaNextTrack) + { + PlayerView?.ViewModel?.ExecuteNextTrack(); + } + + // Previous Track + else if(e.Key == App.Settings.PreviousTrackKey || e.Key == Key.MediaPreviousTrack) + { + PlayerView?.ViewModel?.ExecutePreviousTrack(); + } + + // Next Index + else if(e.Key == App.Settings.NextIndexKey) + { + PlayerView?.ViewModel?.ExecuteNextIndex(); + } + + // Previous Index + else if(e.Key == App.Settings.PreviousIndexKey) + { + PlayerView?.ViewModel?.ExecutePreviousIndex(); + } + + // Fast Foward + else if(e.Key == App.Settings.FastForwardPlaybackKey) + { + PlayerView?.ViewModel?.ExecuteFastForward(); + } + + // Rewind + else if(e.Key == App.Settings.RewindPlaybackKey) + { + PlayerView?.ViewModel?.ExecuteRewind(); + } + + // Volume Up + else if(e.Key == App.Settings.VolumeUpKey || e.Key == Key.VolumeUp) + { + int increment = 1; + if(e.KeyModifiers.HasFlag(KeyModifiers.Control)) + increment *= 2; + if(e.KeyModifiers.HasFlag(KeyModifiers.Shift)) + increment *= 5; + + if(PlayerView?.ViewModel?.Volume != null) + PlayerView.ViewModel.ExecuteSetVolume(PlayerView.ViewModel.Volume + increment); + } + + // Volume Down + else if(e.Key == App.Settings.VolumeDownKey || e.Key == Key.VolumeDown) + { + int decrement = 1; + if(e.KeyModifiers.HasFlag(KeyModifiers.Control)) + decrement *= 2; + if(e.KeyModifiers.HasFlag(KeyModifiers.Shift)) + decrement *= 5; + + if(PlayerView?.ViewModel?.Volume != null) + PlayerView.ViewModel.ExecuteSetVolume(PlayerView.ViewModel.Volume - decrement); + } + + // Mute Toggle + else if(e.Key == App.Settings.ToggleMuteKey || e.Key == Key.VolumeMute) + { + PlayerView?.ViewModel?.ExecuteToggleMute(); + } + + // Emphasis Toggle + else if(e.Key == App.Settings.ToggleDeEmphasisKey) + { + PlayerView?.ViewModel?.ExecuteToggleDeEmphasis(); + } + } + + /// + /// Load the first valid drag-and-dropped disc image + /// + public async void ExecuteLoadDragDrop(object sender, DragEventArgs e) + { + if(PlayerView?.ViewModel == null) + return; + + IEnumerable fileNames = e.Data.GetFileNames(); + foreach(string filename in fileNames) + { + bool loaded = await PlayerView.ViewModel.LoadImage(filename); + if(loaded) + break; + } + } + + /// + /// Stop current playback + /// + public void ExecuteStop(object sender, CancelEventArgs e) => PlayerView?.ViewModel?.ExecuteStop(); + + /// + /// Handle the settings window closing + /// + private void OnSettingsClosed(object sender, EventArgs e) => PlayerView?.ViewModel?.RefreshFromSettings(); + + #endregion + } +} \ No newline at end of file diff --git a/RedBookPlayer.GUI/ViewModels/PlayerViewModel.cs b/RedBookPlayer.GUI/ViewModels/PlayerViewModel.cs index 41edc50..00f92d5 100644 --- a/RedBookPlayer.GUI/ViewModels/PlayerViewModel.cs +++ b/RedBookPlayer.GUI/ViewModels/PlayerViewModel.cs @@ -5,13 +5,16 @@ using System.IO; 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.GUI.Views; +using RedBookPlayer.Models; +using RedBookPlayer.Models.Discs; using RedBookPlayer.Models.Hardware; namespace RedBookPlayer.GUI.ViewModels @@ -21,7 +24,7 @@ namespace RedBookPlayer.GUI.ViewModels /// /// Player representing the internal state /// - private Player _player; + private readonly Player _player; /// /// Set of images representing the digits for the UI @@ -50,6 +53,15 @@ namespace RedBookPlayer.GUI.ViewModels 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 /// @@ -140,6 +152,7 @@ namespace RedBookPlayer.GUI.ViewModels private int _currentTrackNumber; private ushort _currentTrackIndex; + private ushort _currentTrackSession; private ulong _currentSector; private ulong _sectionStartSector; @@ -156,15 +169,28 @@ namespace RedBookPlayer.GUI.ViewModels /// /// Indicate if the model is ready to be used /// - public bool Initialized => _player?.Initialized ?? false; + public bool Initialized + { + get => _initialized; + private set => this.RaiseAndSetIfChanged(ref _initialized, value); + } /// /// Indicate if the output is playing /// - public bool? Playing + public PlayerState PlayerState { - get => _playing; - private set => this.RaiseAndSetIfChanged(ref _playing, value); + get => _playerState; + private set => this.RaiseAndSetIfChanged(ref _playerState, value); + } + + /// + /// Indicates how to handle playback of data tracks + /// + public DataPlayback DataPlayback + { + get => _dataPlayback; + private set => this.RaiseAndSetIfChanged(ref _dataPlayback, value); } /// @@ -185,7 +211,9 @@ namespace RedBookPlayer.GUI.ViewModels private set => this.RaiseAndSetIfChanged(ref _volume, value); } - private bool? _playing; + private bool _initialized; + private PlayerState _playerState; + private DataPlayback _dataPlayback; private bool _applyDeEmphasis; private int _volume; @@ -299,6 +327,7 @@ namespace RedBookPlayer.GUI.ViewModels /// public PlayerViewModel() { + // Initialize commands LoadCommand = ReactiveCommand.Create(ExecuteLoad); PlayCommand = ReactiveCommand.Create(ExecutePlay); @@ -319,25 +348,27 @@ namespace RedBookPlayer.GUI.ViewModels EnableDeEmphasisCommand = ReactiveCommand.Create(ExecuteEnableDeEmphasis); DisableDeEmphasisCommand = ReactiveCommand.Create(ExecuteDisableDeEmphasis); ToggleDeEmphasisCommand = ReactiveCommand.Create(ExecuteToggleDeEmphasis); + + // Initialize Player + _player = new Player(App.Settings.Volume); + PlayerState = PlayerState.NoDisc; } /// /// Initialize the view model with a given image path /// /// Path to the disc image - /// Generate a TOC if the disc is missing one [CompactDisc only] - /// Load hidden tracks for playback [CompactDisc only] - /// Load data tracks for playback [CompactDisc only] + /// Options to pass to the optical disc factory /// True if playback should begin immediately, false otherwise - /// Default volume between 0 and 100 to use when starting playback - public void Init(string path, bool generateMissingToc, bool loadHiddenTracks, bool loadDataTracks, bool autoPlay, int defaultVolume) + public void Init(string path, OpticalDiscOptions options, bool autoPlay) { // Stop current playback, if necessary - if(Playing != null) ExecuteStop(); + if(PlayerState != PlayerState.NoDisc) + ExecuteStop(); - // Create and attempt to initialize new Player - _player = new Player(path, generateMissingToc, loadHiddenTracks, loadDataTracks, autoPlay, defaultVolume); - if(Initialized) + // Attempt to initialize Player + _player.Init(path, options, autoPlay); + if(_player.Initialized) { _player.PropertyChanged += PlayerStateChanged; PlayerStateChanged(this, null); @@ -444,6 +475,46 @@ namespace RedBookPlayer.GUI.ViewModels #region Helpers + /// + /// Apply a custom theme to the player + /// + /// Path to the theme under the themes directory + public void ApplyTheme(string theme) + { + // If the PlayerView isn't set, don't do anything + if(App.PlayerView == null) + return; + + // If no theme path is provided, we can ignore + if(string.IsNullOrWhiteSpace(theme)) + return; + + string themeDirectory = $"{Directory.GetCurrentDirectory()}/themes/{theme}"; + string xamlPath = $"{themeDirectory}/view.xaml"; + + if(!File.Exists(xamlPath)) + { + Console.WriteLine("Warning: specified theme doesn't exist, reverting to default"); + return; + } + + try + { + string xaml = File.ReadAllText(xamlPath); + xaml = xaml.Replace("Source=\"", $"Source=\"file://{themeDirectory}/"); + LoadTheme(xaml); + } + catch(XmlException ex) + { + Console.WriteLine($"Error: invalid theme XAML ({ex.Message}), reverting to default"); + LoadTheme(null); + } + + App.MainWindow.Width = App.PlayerView.Width; + App.MainWindow.Height = App.PlayerView.Height; + InitializeDigits(); + } + /// /// Load a disc image from a selection box /// @@ -461,35 +532,36 @@ namespace RedBookPlayer.GUI.ViewModels /// public void InitializeDigits() { - PlayerView playerView = MainWindow.Instance.ContentControl.Content as PlayerView; + if(App.PlayerView == null) + return; _digits = new Image[] { - playerView.FindControl("TrackDigit1"), - playerView.FindControl("TrackDigit2"), + App.PlayerView.FindControl("TrackDigit1"), + App.PlayerView.FindControl("TrackDigit2"), - playerView.FindControl("IndexDigit1"), - playerView.FindControl("IndexDigit2"), + App.PlayerView.FindControl("IndexDigit1"), + App.PlayerView.FindControl("IndexDigit2"), - playerView.FindControl("TimeDigit1"), - playerView.FindControl("TimeDigit2"), - playerView.FindControl("TimeDigit3"), - playerView.FindControl("TimeDigit4"), - playerView.FindControl("TimeDigit5"), - playerView.FindControl("TimeDigit6"), + App.PlayerView.FindControl("TimeDigit1"), + App.PlayerView.FindControl("TimeDigit2"), + App.PlayerView.FindControl("TimeDigit3"), + App.PlayerView.FindControl("TimeDigit4"), + App.PlayerView.FindControl("TimeDigit5"), + App.PlayerView.FindControl("TimeDigit6"), - playerView.FindControl("TotalTracksDigit1"), - playerView.FindControl("TotalTracksDigit2"), + App.PlayerView.FindControl("TotalTracksDigit1"), + App.PlayerView.FindControl("TotalTracksDigit2"), - playerView.FindControl("TotalIndexesDigit1"), - playerView.FindControl("TotalIndexesDigit2"), + App.PlayerView.FindControl("TotalIndexesDigit1"), + App.PlayerView.FindControl("TotalIndexesDigit2"), - playerView.FindControl("TotalTimeDigit1"), - playerView.FindControl("TotalTimeDigit2"), - playerView.FindControl("TotalTimeDigit3"), - playerView.FindControl("TotalTimeDigit4"), - playerView.FindControl("TotalTimeDigit5"), - playerView.FindControl("TotalTimeDigit6"), + App.PlayerView.FindControl("TotalTimeDigit1"), + App.PlayerView.FindControl("TotalTimeDigit2"), + App.PlayerView.FindControl("TotalTimeDigit3"), + App.PlayerView.FindControl("TotalTimeDigit4"), + App.PlayerView.FindControl("TotalTimeDigit5"), + App.PlayerView.FindControl("TotalTimeDigit6"), }; } @@ -501,19 +573,39 @@ namespace RedBookPlayer.GUI.ViewModels { return await Dispatcher.UIThread.InvokeAsync(() => { - Init(path, App.Settings.GenerateMissingTOC, App.Settings.PlayHiddenTracks, App.Settings.PlayDataTracks, App.Settings.AutoPlay, App.Settings.Volume); + OpticalDiscOptions options = new OpticalDiscOptions + { + DataPlayback = App.Settings.DataPlayback, + GenerateMissingToc = App.Settings.GenerateMissingTOC, + LoadHiddenTracks = App.Settings.PlayHiddenTracks, + }; + + // Ensure the context and view model are set + App.PlayerView.DataContext = this; + App.PlayerView.ViewModel = this; + + Init(path, options, App.Settings.AutoPlay); if(Initialized) - MainWindow.Instance.Title = "RedBookPlayer - " + path.Split('/').Last().Split('\\').Last(); + App.MainWindow.Title = "RedBookPlayer - " + path.Split('/').Last().Split('\\').Last(); return Initialized; }); } /// - /// Set the value for loading data tracks [CompactDisc only] + /// Refresh the view model from the current settings /// - /// True to enable loading data tracks, false otherwise - public void SetLoadDataTracks(bool load) => _player?.SetLoadDataTracks(load); + public void RefreshFromSettings() + { + SetDataPlayback(App.Settings.DataPlayback); + SetLoadHiddenTracks(App.Settings.PlayHiddenTracks); + } + + /// + /// Set data playback method [CompactDisc only] + /// + /// New playback value + public void SetDataPlayback(DataPlayback dataPlayback) => _player?.SetDataPlayback(dataPlayback); /// /// Set the value for loading hidden tracks [CompactDisc only] @@ -569,18 +661,9 @@ namespace RedBookPlayer.GUI.ViewModels { try { - if(App.Settings.SelectedTheme == "default") - { - IAssetLoader assets = AvaloniaLocator.Current.GetService(); - - return new Bitmap(assets.Open(new Uri($"avares://RedBookPlayer/Assets/{character}.png"))); - } - else - { - string themeDirectory = $"{Directory.GetCurrentDirectory()}/themes/{App.Settings.SelectedTheme}"; - using FileStream stream = File.Open($"{themeDirectory}/{character}.png", FileMode.Open); - return new Bitmap(stream); - } + string themeDirectory = $"{Directory.GetCurrentDirectory()}/themes/{App.Settings.SelectedTheme}"; + using FileStream stream = File.Open($"{themeDirectory}/{character}.png", FileMode.Open); + return new Bitmap(stream); } catch { @@ -619,18 +702,56 @@ namespace RedBookPlayer.GUI.ViewModels Extensions = knownExtensions.ConvertAll(e => e.TrimStart('.')) }); - return (await dialog.ShowAsync(MainWindow.Instance))?.FirstOrDefault(); + return (await dialog.ShowAsync(App.MainWindow))?.FirstOrDefault(); }); } + /// + /// Load the theme from a XAML, if possible + /// + /// XAML data representing the theme, null for default + private void LoadTheme(string xaml) + { + // If the view is null, we can't load the theme + if(App.PlayerView == null) + return; + + try + { + if(xaml != null) + new AvaloniaXamlLoader().Load(xaml, null, App.PlayerView); + else + AvaloniaXamlLoader.Load(App.PlayerView); + } + catch(Exception ex) + { + Console.Error.WriteLine(ex); + } + + // Ensure the context and view model are set + App.PlayerView.DataContext = this; + App.PlayerView.ViewModel = this; + UpdateDigits(); + } + /// /// Update the view-model from the Player /// private void PlayerStateChanged(object sender, PropertyChangedEventArgs e) { - if(_player?.Initialized != true) + if(_player == null) return; + if(!_player.Initialized) + { + Dispatcher.UIThread.InvokeAsync(() => + { + App.MainWindow.Title = "RedBookPlayer"; + }); + } + + Initialized = _player.Initialized; + CurrentTrackNumber = _player.CurrentTrackNumber; CurrentTrackIndex = _player.CurrentTrackIndex; CurrentSector = _player.CurrentSector; @@ -643,7 +764,8 @@ namespace RedBookPlayer.GUI.ViewModels CopyAllowed = _player.CopyAllowed; TrackHasEmphasis = _player.TrackHasEmphasis; - Playing = _player.Playing; + PlayerState = _player.PlayerState; + DataPlayback = _player.DataPlayback; ApplyDeEmphasis = _player.ApplyDeEmphasis; Volume = _player.Volume; @@ -655,6 +777,10 @@ namespace RedBookPlayer.GUI.ViewModels /// private void UpdateDigits() { + // Ensure the digits + if(_digits == null) + InitializeDigits(); + Dispatcher.UIThread.Post(() => { string digitString = GenerateDigitString() ?? string.Empty.PadLeft(20, '-'); diff --git a/RedBookPlayer.GUI/Settings.cs b/RedBookPlayer.GUI/ViewModels/SettingsViewModel.cs similarity index 57% rename from RedBookPlayer.GUI/Settings.cs rename to RedBookPlayer.GUI/ViewModels/SettingsViewModel.cs index df14848..c57e33d 100644 --- a/RedBookPlayer.GUI/Settings.cs +++ b/RedBookPlayer.GUI/ViewModels/SettingsViewModel.cs @@ -1,15 +1,32 @@ using System; +using System.Collections.Generic; using System.IO; +using System.Linq; +using System.Reactive; using System.Text.Json; +using System.Text.Json.Serialization; using Avalonia.Input; -using RedBookPlayer.GUI.Views; +using ReactiveUI; +using RedBookPlayer.Models; -namespace RedBookPlayer.GUI +namespace RedBookPlayer.GUI.ViewModels { - public class Settings + public class SettingsViewModel : ReactiveObject { #region Player Settings + /// + /// List of all data playback values + /// + [JsonIgnore] + public List DataPlaybackValues => GenerateDataPlaybackList(); + + /// + /// List of all themes + /// + [JsonIgnore] + public List ThemeValues => GenerateThemeList(); + /// /// Indicates if discs should start playing on load /// @@ -30,42 +47,51 @@ namespace RedBookPlayer.GUI /// public bool PlayHiddenTracks { get; set; } = false; - /// - /// Indicates if data tracks should be played like old, non-compliant players - /// - public bool PlayDataTracks { get; set; } = false; - /// /// Generate a TOC if the disc is missing one /// public bool GenerateMissingTOC { get; set; } = true; + /// + /// Indicates how to deal with data tracks + /// + public DataPlayback DataPlayback { get; set; } = DataPlayback.Skip; + /// /// Indicates the default playback volume /// public int Volume { get => _volume; - set + private set { + int tempValue; if(value > 100) - _volume = 100; + tempValue = 100; else if(value < 0) - _volume = 0; + tempValue = 0; else - _volume = value; + tempValue = value; + + this.RaiseAndSetIfChanged(ref _volume, tempValue); } } /// /// Indicates the currently selected theme /// - public string SelectedTheme { get; set; } = "default"; + public string SelectedTheme { get; set; } = "Default"; #endregion #region Key Mappings + /// + /// List of all keyboard keys + /// + [JsonIgnore] + public List KeyboardList => GenerateKeyList(); + /// /// Key assigned to open settings /// @@ -86,6 +112,11 @@ namespace RedBookPlayer.GUI /// public Key StopPlaybackKey { get; set; } = Key.Escape; + /// + /// Key assigned to eject the disc + /// + public Key EjectKey { get; set; } = Key.OemTilde; + /// /// Key assigned to move to the next track /// @@ -138,6 +169,15 @@ namespace RedBookPlayer.GUI #endregion + #region Commands + + /// + /// Command for applying settings + /// + public ReactiveCommand ApplySettingsCommand { get; } + + #endregion + /// /// Path to the settings file /// @@ -148,44 +188,50 @@ namespace RedBookPlayer.GUI /// private int _volume = 100; - public Settings() {} + public SettingsViewModel() : this(null) { } - public Settings(string filePath) => _filePath = filePath; + public SettingsViewModel(string filePath) + { + _filePath = filePath; + + ApplySettingsCommand = ReactiveCommand.Create(ExecuteApplySettings); + } /// /// Load settings from a file /// /// Path to the settings JSON file /// Settings derived from the input file, if possible - public static Settings Load(string filePath) + public static SettingsViewModel Load(string filePath) { if(File.Exists(filePath)) { try { - Settings settings = JsonSerializer.Deserialize(File.ReadAllText(filePath)); + SettingsViewModel settings = JsonSerializer.Deserialize(File.ReadAllText(filePath)); settings._filePath = filePath; - MainWindow.ApplyTheme(settings.SelectedTheme); - return settings; } catch(JsonException) { Console.WriteLine("Couldn't parse settings, reverting to default"); - return new Settings(filePath); + return new SettingsViewModel(filePath); } } - return new Settings(filePath); + return new SettingsViewModel(filePath); } /// - /// Save settings to a file + /// Apply settings from the UI /// - public void Save() + public void ExecuteApplySettings() { + if(!string.IsNullOrWhiteSpace(SelectedTheme)) + App.PlayerView?.ViewModel?.ApplyTheme(SelectedTheme); + var options = new JsonSerializerOptions { WriteIndented = true @@ -194,5 +240,45 @@ namespace RedBookPlayer.GUI string json = JsonSerializer.Serialize(this, options); File.WriteAllText(_filePath, json); } + + #region Generation + + /// + /// Generate the list of DataPlayback values + /// + private List GenerateDataPlaybackList() => Enum.GetValues(typeof(DataPlayback)).Cast().ToList(); + + /// + /// Generate the list of Key values + /// + private List GenerateKeyList() => Enum.GetValues(typeof(Key)).Cast().ToList(); + + /// + /// Generate the list of valid themes + /// + private List GenerateThemeList() + { + // Create a list of all found themes + List items = new List(); + + // Ensure the theme directory exists + if(!Directory.Exists("themes/")) + Directory.CreateDirectory("themes/"); + + // Add all theme directories if they're valid + foreach(string dir in Directory.EnumerateDirectories("themes/")) + { + string themeName = dir.Split('/')[1]; + + if(!File.Exists($"themes/{themeName}/view.xaml")) + continue; + + items.Add(themeName); + } + + return items; + } + + #endregion } } \ No newline at end of file diff --git a/RedBookPlayer.GUI/Views/MainWindow.xaml b/RedBookPlayer.GUI/Views/MainWindow.xaml index 1a3e211..eb7576d 100644 --- a/RedBookPlayer.GUI/Views/MainWindow.xaml +++ b/RedBookPlayer.GUI/Views/MainWindow.xaml @@ -1,7 +1,17 @@ - - - \ No newline at end of file + CanResize="False" DragDrop.AllowDrop="True"> + + + + + + + + + \ No newline at end of file diff --git a/RedBookPlayer.GUI/Views/MainWindow.xaml.cs b/RedBookPlayer.GUI/Views/MainWindow.xaml.cs index 3e5a26a..6592b05 100644 --- a/RedBookPlayer.GUI/Views/MainWindow.xaml.cs +++ b/RedBookPlayer.GUI/Views/MainWindow.xaml.cs @@ -1,73 +1,13 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Xml; -using Avalonia.Controls; using Avalonia.Input; using Avalonia.Markup.Xaml; +using Avalonia.ReactiveUI; using RedBookPlayer.GUI.ViewModels; namespace RedBookPlayer.GUI.Views { - public class MainWindow : Window + public class MainWindow : ReactiveWindow { - public static MainWindow Instance; - public ContentControl ContentControl; - public Window settingsWindow; - - public MainWindow() - { - Instance = this; - InitializeComponent(); - } - - /// - /// Apply a custom theme to the player - /// - /// Path to the theme under the themes directory - public static void ApplyTheme(string theme) - { - // If no theme path is provided, we can ignore - if(string.IsNullOrWhiteSpace(theme)) - return; - - // If we already have a view, cache the view model - PlayerViewModel pvm = ((PlayerView)Instance.ContentControl.Content).PlayerViewModel; - - // If the theme name is "default", we assume the internal theme is used - if(theme.Equals("default", StringComparison.CurrentCultureIgnoreCase)) - { - Instance.ContentControl.Content = new PlayerView(pvm); - } - else - { - string themeDirectory = $"{Directory.GetCurrentDirectory()}/themes/{theme}"; - string xamlPath = $"{themeDirectory}/view.xaml"; - - if(!File.Exists(xamlPath)) - { - Console.WriteLine("Warning: specified theme doesn't exist, reverting to default"); - return; - } - - try - { - string xaml = File.ReadAllText(xamlPath); - xaml = xaml.Replace("Source=\"", $"Source=\"file://{themeDirectory}/"); - Instance.ContentControl.Content = new PlayerView(xaml, pvm); - } - catch(XmlException ex) - { - Console.WriteLine($"Error: invalid theme XAML ({ex.Message}), reverting to default"); - Instance.ContentControl.Content = new PlayerView(pvm); - } - } - - Instance.Width = ((PlayerView)Instance.ContentControl.Content).Width; - Instance.Height = ((PlayerView)Instance.ContentControl.Content).Height; - - pvm.InitializeDigits(); - } + public MainWindow() => InitializeComponent(); /// /// Initialize the main window @@ -76,162 +16,10 @@ namespace RedBookPlayer.GUI.Views { AvaloniaXamlLoader.Load(this); - ContentControl = this.FindControl("Content"); - ContentControl.Content = new PlayerView(); - - Instance.MaxWidth = ((PlayerView)Instance.ContentControl.Content).Width; - Instance.MaxHeight = ((PlayerView)Instance.ContentControl.Content).Height; - - ContentControl.Content = new PlayerView(); - - ((PlayerView)ContentControl.Content).PlayerViewModel.InitializeDigits(); - - CanResize = false; - - KeyDown += OnKeyDown; - - Closing += (s, e) => - { - settingsWindow?.Close(); - settingsWindow = null; - }; - - Closing += (e, f) => - { - ((PlayerView)ContentControl.Content).PlayerViewModel.ExecuteStop(); - }; - - AddHandler(DragDrop.DropEvent, MainWindow_Drop); + // Add handlers + Closing += ViewModel.ExecuteStop; + AddHandler(DragDrop.DropEvent, ViewModel.ExecuteLoadDragDrop); + KeyDown += ViewModel.ExecuteKeyPress; } - - #region Event Handlers - - public async void MainWindow_Drop(object sender, DragEventArgs e) - { - PlayerView playerView = ContentControl.Content as PlayerView; - if(playerView == null) - return; - - IEnumerable fileNames = e.Data.GetFileNames(); - foreach(string filename in fileNames) - { - bool loaded = await playerView?.PlayerViewModel?.LoadImage(filename); - if(loaded) - break; - } - } - - public void OnKeyDown(object sender, KeyEventArgs e) - { - PlayerView playerView = ContentControl.Content as PlayerView; - - // Open settings window - if(e.Key == App.Settings.OpenSettingsKey) - { - settingsWindow = new SettingsWindow(App.Settings); - settingsWindow.Closed += OnSettingsClosed; - settingsWindow.Show(); - } - - // Load image - else if (e.Key == App.Settings.LoadImageKey) - { - playerView?.PlayerViewModel?.ExecuteLoad(); - } - - // Toggle playback - else if(e.Key == App.Settings.TogglePlaybackKey || e.Key == Key.MediaPlayPause) - { - playerView?.PlayerViewModel?.ExecuteTogglePlayPause(); - } - - // Stop playback - else if(e.Key == App.Settings.StopPlaybackKey || e.Key == Key.MediaStop) - { - playerView?.PlayerViewModel?.ExecuteStop(); - } - - // Next Track - else if(e.Key == App.Settings.NextTrackKey || e.Key == Key.MediaNextTrack) - { - playerView?.PlayerViewModel?.ExecuteNextTrack(); - } - - // Previous Track - else if(e.Key == App.Settings.PreviousTrackKey || e.Key == Key.MediaPreviousTrack) - { - playerView?.PlayerViewModel?.ExecutePreviousTrack(); - } - - // Next Index - else if(e.Key == App.Settings.NextIndexKey) - { - playerView?.PlayerViewModel?.ExecuteNextIndex(); - } - - // Previous Index - else if(e.Key == App.Settings.PreviousIndexKey) - { - playerView?.PlayerViewModel?.ExecutePreviousIndex(); - } - - // Fast Foward - else if(e.Key == App.Settings.FastForwardPlaybackKey) - { - playerView?.PlayerViewModel?.ExecuteFastForward(); - } - - // Rewind - else if(e.Key == App.Settings.RewindPlaybackKey) - { - playerView?.PlayerViewModel?.ExecuteRewind(); - } - - // Volume Up - else if(e.Key == App.Settings.VolumeUpKey || e.Key == Key.VolumeUp) - { - int increment = 1; - if(e.KeyModifiers.HasFlag(KeyModifiers.Control)) - increment *= 2; - if(e.KeyModifiers.HasFlag(KeyModifiers.Shift)) - increment *= 5; - - if(playerView?.PlayerViewModel?.Volume != null) - playerView.PlayerViewModel.ExecuteSetVolume(playerView.PlayerViewModel.Volume + increment); - } - - // Volume Down - else if(e.Key == App.Settings.VolumeDownKey || e.Key == Key.VolumeDown) - { - int decrement = 1; - if(e.KeyModifiers.HasFlag(KeyModifiers.Control)) - decrement *= 2; - if(e.KeyModifiers.HasFlag(KeyModifiers.Shift)) - decrement *= 5; - - if (playerView?.PlayerViewModel?.Volume != null) - playerView.PlayerViewModel.ExecuteSetVolume(playerView.PlayerViewModel.Volume - decrement); - } - - // Mute Toggle - else if(e.Key == App.Settings.ToggleMuteKey || e.Key == Key.VolumeMute) - { - playerView?.PlayerViewModel?.ExecuteToggleMute(); - } - - // Emphasis Toggle - else if(e.Key == App.Settings.ToggleDeEmphasisKey) - { - playerView?.PlayerViewModel?.ExecuteToggleDeEmphasis(); - } - } - - public void OnSettingsClosed(object sender, EventArgs e) - { - PlayerView playerView = ContentControl.Content as PlayerView; - playerView?.UpdateViewModel(); - } - - #endregion } } \ No newline at end of file diff --git a/RedBookPlayer.GUI/Views/PlayerView.xaml b/RedBookPlayer.GUI/Views/PlayerView.xaml index 941522e..c599c86 100644 --- a/RedBookPlayer.GUI/Views/PlayerView.xaml +++ b/RedBookPlayer.GUI/Views/PlayerView.xaml @@ -1,7 +1,12 @@ - + xmlns:rxui="clr-namespace:Avalonia;assembly=Avalonia.ReactiveUI" + xmlns:viewModels="clr-namespace:RedBookPlayer.GUI.ViewModels;assembly=RedBookPlayer.GUI" + x:Class="RedBookPlayer.GUI.Views.PlayerView" Width="900" Height="400" Background="White"> + + + @@ -101,4 +106,4 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/RedBookPlayer.GUI/Views/PlayerView.xaml.cs b/RedBookPlayer.GUI/Views/PlayerView.xaml.cs index 253fd9b..4e396b9 100644 --- a/RedBookPlayer.GUI/Views/PlayerView.xaml.cs +++ b/RedBookPlayer.GUI/Views/PlayerView.xaml.cs @@ -1,73 +1,9 @@ -using Avalonia.Controls; -using Avalonia.Markup.Xaml; +using Avalonia.ReactiveUI; using RedBookPlayer.GUI.ViewModels; namespace RedBookPlayer.GUI.Views { - public class PlayerView : UserControl + public class PlayerView : ReactiveUserControl { - /// - /// Read-only access to the view model - /// - public PlayerViewModel PlayerViewModel => DataContext as PlayerViewModel; - - /// - /// Initialize the UI based on the default theme - /// - public PlayerView() : this(null, null) { } - - /// - /// Initialize the UI based on the default theme with an existing view model - /// - /// XAML data representing the theme, null for default - /// Existing PlayerViewModel to load in instead of creating a new one - public PlayerView(PlayerViewModel playerViewModel) : this(null, playerViewModel) { } - - /// - /// Initialize the UI based on the currently selected theme - /// - /// XAML data representing the theme, null for default - /// Existing PlayerViewModel to load in instead of creating a new one - public PlayerView(string xaml, PlayerViewModel playerViewModel) - { - LoadTheme(xaml); - - if(playerViewModel != null) - DataContext = playerViewModel; - else - DataContext = new PlayerViewModel(); - } - - #region Helpers - - /// - /// Update the view model with new settings - /// - public void UpdateViewModel() - { - PlayerViewModel.SetLoadDataTracks(App.Settings.PlayDataTracks); - PlayerViewModel.SetLoadHiddenTracks(App.Settings.PlayHiddenTracks); - } - - /// - /// Load the theme from a XAML, if possible - /// - /// XAML data representing the theme, null for default - private void LoadTheme(string xaml) - { - try - { - if(xaml != null) - new AvaloniaXamlLoader().Load(xaml, null, this); - else - AvaloniaXamlLoader.Load(this); - } - catch - { - AvaloniaXamlLoader.Load(this); - } - } - - #endregion } } \ No newline at end of file diff --git a/RedBookPlayer.GUI/Views/SettingsWindow.xaml b/RedBookPlayer.GUI/Views/SettingsWindow.xaml index d64c388..ab0a8c3 100644 --- a/RedBookPlayer.GUI/Views/SettingsWindow.xaml +++ b/RedBookPlayer.GUI/Views/SettingsWindow.xaml @@ -1,7 +1,12 @@ - + + + @@ -21,8 +26,9 @@ Play hidden tracks - - Play data tracks like old, non-compliant players + Data Track Playback + @@ -33,11 +39,12 @@ - + - + @@ -61,62 +68,96 @@ + + Load Image - + - Toggle Play/Pause - + Toggle Play/Pause + - Stop Playback - + Stop Playback + + + + Eject Disc + - Next Track - + Next Track + - Previous Track - + Previous Track + - Next Index - + Next Index + - Previous Index - + Previous Index + - Fast-Forward - + Fast-Forward + - Rewind - + Rewind + - Volume Up - + Volume Up + - Volume Down - + Volume Down + - Toggle Mute - + Toggle Mute + - Toggle De-Emphasis - + Toggle De-Emphasis + - + - \ No newline at end of file + \ No newline at end of file diff --git a/RedBookPlayer.GUI/Views/SettingsWindow.xaml.cs b/RedBookPlayer.GUI/Views/SettingsWindow.xaml.cs index 9b5c15d..5e0e8ed 100644 --- a/RedBookPlayer.GUI/Views/SettingsWindow.xaml.cs +++ b/RedBookPlayer.GUI/Views/SettingsWindow.xaml.cs @@ -1,186 +1,9 @@ -using System; -using System.Collections.Generic; -using System.IO; -using Avalonia.Controls; -using Avalonia.Input; -using Avalonia.Interactivity; -using Avalonia.Markup.Xaml; +using Avalonia.ReactiveUI; +using RedBookPlayer.GUI.ViewModels; namespace RedBookPlayer.GUI.Views { - public class SettingsWindow : Window + public class SettingsWindow : ReactiveWindow { - private readonly Settings _settings; - private string _selectedTheme; - private ListBox _themeList; - - public SettingsWindow() {} - - public SettingsWindow(Settings settings) - { - DataContext = _settings = settings; - InitializeComponent(); - } - - public void ThemeList_SelectionChanged(object sender, SelectionChangedEventArgs e) - { - if (e.AddedItems.Count == 0) - return; - - _selectedTheme = (string)e.AddedItems[0]; - } - - public void ApplySettings(object sender, RoutedEventArgs e) - { - if (!string.IsNullOrWhiteSpace(_selectedTheme)) - { - _settings.SelectedTheme = _selectedTheme; - MainWindow.ApplyTheme(_selectedTheme); - } - - SaveKeyboardList(); - _settings.Save(); - } - - public void UpdateView() => this.FindControl("VolumeLabel").Text = _settings.Volume.ToString(); - - void InitializeComponent() - { - AvaloniaXamlLoader.Load(this); - - PopulateThemes(); - PopulateKeyboardList(); - - this.FindControl + + + + + + + + + + + Rewind + Fast Forward + + + + TRACK + + + + + + + INDEX + + + + + + + TIME + + + + + + + + + + + + + + + TRACKS + + + + + + + INDEXES + + + + + + + TOTAL + + + + + + + + + + + + + + + + + + AUDIO + AUDIO + DATA + DATA + EMPHASIS + EMPHASIS + COPY + COPY + 4CH + 4CH + HIDDEN + HIDDEN + + + + \ No newline at end of file diff --git a/RedBookPlayer.Models/Discs/CompactDisc.cs b/RedBookPlayer.Models/Discs/CompactDisc.cs index 6690b6e..ff523f3 100644 --- a/RedBookPlayer.Models/Discs/CompactDisc.cs +++ b/RedBookPlayer.Models/Discs/CompactDisc.cs @@ -26,7 +26,7 @@ namespace RedBookPlayer.Models.Discs return; // Data tracks only and flag disabled means we can't do anything - if(_image.Tracks.All(t => t.TrackType != TrackType.Audio) && !LoadDataTracks) + if(_image.Tracks.All(t => t.TrackType != TrackType.Audio) && DataPlayback == DataPlayback.Skip) return; // Cache the value and the current track number @@ -69,8 +69,10 @@ namespace RedBookPlayer.Models.Discs SetTrackFlags(track); // If the track is playable, just return - if(TrackType == TrackType.Audio || LoadDataTracks) + if(TrackType == TrackType.Audio || DataPlayback != DataPlayback.Skip) + { break; + } // If we're not playing the track, skip if(increment) @@ -143,26 +145,28 @@ namespace RedBookPlayer.Models.Discs if(_image == null) return; + // If the sector is over the end of the image, then loop + ulong tempSector = value; + if(tempSector > _image.Info.Sectors) + tempSector = 0; + else if(tempSector < 0) + tempSector = _image.Info.Sectors - 1; + // Cache the current track for easy access Track track = GetTrack(CurrentTrackNumber); if(track == null) return; - this.RaiseAndSetIfChanged(ref _currentSector, value); + this.RaiseAndSetIfChanged(ref _currentSector, tempSector); - if((CurrentTrackNumber < _image.Tracks.Count - 1 && CurrentSector >= (GetTrack(CurrentTrackNumber + 1)?.TrackStartSector ?? 0)) - || (CurrentTrackNumber > 0 && CurrentSector < track.TrackStartSector)) + // If the current sector is outside of the last known track, seek to the right one + if(CurrentSector < track.TrackStartSector || CurrentSector > track.TrackEndSector) { - foreach(Track trackData in _image.Tracks.ToArray().Reverse()) - { - if(CurrentSector >= trackData.TrackStartSector) - { - CurrentTrackNumber = (int)trackData.TrackSequence; - break; - } - } + track = _image.Tracks.Last(t => CurrentSector >= t.TrackStartSector); + CurrentTrackNumber = (int)track.TrackSequence; } + // Set the new index, if necessary foreach((ushort key, int i) in track.Indexes.Reverse()) { if((int)CurrentSector >= i) @@ -216,9 +220,9 @@ namespace RedBookPlayer.Models.Discs } /// - /// Indicate if data tracks should be loaded + /// Indicate how data tracks should be handled /// - public bool LoadDataTracks { get; set; } = false; + public DataPlayback DataPlayback { get; set; } = DataPlayback.Skip; /// /// Indicate if hidden tracks should be loaded @@ -264,14 +268,12 @@ namespace RedBookPlayer.Models.Discs /// /// Constructor /// - /// Generate a TOC if the disc is missing one - /// Load hidden tracks for playback - /// Load data tracks for playback - public CompactDisc(bool generateMissingToc, bool loadHiddenTracks, bool loadDataTracks) + /// Set of options for a new disc + public CompactDisc(OpticalDiscOptions options) { - _generateMissingToc = generateMissingToc; - LoadHiddenTracks = loadHiddenTracks; - LoadDataTracks = loadDataTracks; + DataPlayback = options.DataPlayback; + _generateMissingToc = options.GenerateMissingToc; + LoadHiddenTracks = options.LoadHiddenTracks; } /// @@ -392,7 +394,7 @@ namespace RedBookPlayer.Models.Discs } // If the previous index has an invalid offset, change tracks if needed - else if (track.Indexes[(ushort)(CurrentTrackIndex - 1)] < 0) + else if(track.Indexes[(ushort)(CurrentTrackIndex - 1)] < 0) { if(changeTrack) { @@ -401,7 +403,7 @@ namespace RedBookPlayer.Models.Discs return true; } } - + // Otherwise, just move to the previous index else { @@ -416,23 +418,7 @@ namespace RedBookPlayer.Models.Discs #region Helpers /// - public override void LoadFirstTrack() - { - CurrentTrackNumber = 1; - LoadTrack(CurrentTrackNumber); - } - - /// - public override void SetTotalIndexes() - { - if(_image == null) - return; - - TotalIndexes = GetTrack(CurrentTrackNumber)?.Indexes.Keys.Max() ?? 0; - } - - /// - protected override void LoadTrack(int trackNumber) + public override void LoadTrack(int trackNumber) { if(_image == null) return; @@ -448,6 +434,33 @@ namespace RedBookPlayer.Models.Discs CurrentSector = (ulong)(track?.Indexes.OrderBy(kvp => kvp.Key).First(kvp => kvp.Value >= 0).Value ?? 0); } + /// + public override void LoadFirstTrack() + { + CurrentTrackNumber = 1; + LoadTrack(CurrentTrackNumber); + } + + /// + public override byte[] ReadSectors(uint sectorsToRead) + { + if(TrackType == TrackType.Audio || DataPlayback == DataPlayback.Play) + return base.ReadSectors(sectorsToRead); + else if(DataPlayback == DataPlayback.Blank) + return new byte[sectorsToRead * BytesPerSector]; + else + return new byte[0]; + } + + /// + public override void SetTotalIndexes() + { + if(_image == null) + return; + + TotalIndexes = GetTrack(CurrentTrackNumber)?.Indexes.Keys.Max() ?? 0; + } + /// /// Get the track with the given sequence value, if possible /// @@ -580,4 +593,4 @@ namespace RedBookPlayer.Models.Discs #endregion } -} +} \ No newline at end of file diff --git a/RedBookPlayer.Models/Discs/OpticalDiscBase.cs b/RedBookPlayer.Models/Discs/OpticalDiscBase.cs index f2ce155..e496901 100644 --- a/RedBookPlayer.Models/Discs/OpticalDiscBase.cs +++ b/RedBookPlayer.Models/Discs/OpticalDiscBase.cs @@ -122,6 +122,12 @@ namespace RedBookPlayer.Models.Discs #region Helpers + /// + /// Load the desired track, if possible + /// + /// Track number to load + public abstract void LoadTrack(int track); + /// /// Load the first valid track in the image /// @@ -132,7 +138,7 @@ namespace RedBookPlayer.Models.Discs /// /// Current number of sectors to read /// Byte array representing the read sectors, if possible - public byte[] ReadSectors(uint sectorsToRead) => _image.ReadSectors(CurrentSector, sectorsToRead); + public virtual byte[] ReadSectors(uint sectorsToRead) => _image.ReadSectors(CurrentSector, sectorsToRead); /// /// Set the total indexes from the current track @@ -145,12 +151,6 @@ namespace RedBookPlayer.Models.Discs /// New sector number to use public void SetCurrentSector(ulong sector) => CurrentSector = sector; - /// - /// Load the desired track, if possible - /// - /// Track number to load - protected abstract void LoadTrack(int track); - #endregion } -} +} \ No newline at end of file diff --git a/RedBookPlayer.Models/Discs/OpticalDiscOptions.cs b/RedBookPlayer.Models/Discs/OpticalDiscOptions.cs new file mode 100644 index 0000000..89b1d35 --- /dev/null +++ b/RedBookPlayer.Models/Discs/OpticalDiscOptions.cs @@ -0,0 +1,24 @@ +namespace RedBookPlayer.Models.Discs +{ + public class OpticalDiscOptions + { + #region CompactDisc + + /// + /// Indicate how data tracks should be handled + /// + public DataPlayback DataPlayback { get; set; } = DataPlayback.Skip; + + /// + /// Indicate if a TOC should be generated if missing + /// + public bool GenerateMissingToc { get; set; } = false; + + /// + /// Indicate if hidden tracks should be loaded + /// + public bool LoadHiddenTracks { get; set; } = false; + + #endregion + } +} \ No newline at end of file diff --git a/RedBookPlayer.Models/Enums.cs b/RedBookPlayer.Models/Enums.cs new file mode 100644 index 0000000..7063312 --- /dev/null +++ b/RedBookPlayer.Models/Enums.cs @@ -0,0 +1,49 @@ +namespace RedBookPlayer.Models +{ + /// + /// Determine how to handle data tracks + /// + public enum DataPlayback + { + /// + /// Skip playing all data tracks + /// + Skip = 0, + + /// + /// Play silence for all data tracks + /// + Blank = 1, + + /// + /// Play the data from all data tracks + /// + Play = 2, + } + + /// + /// Current player state + /// + public enum PlayerState + { + /// + /// No disc is loaded + /// + NoDisc, + + /// + /// Disc is loaded, playback is stopped + /// + Stopped, + + /// + /// Disc is loaded, playback is paused + /// + Paused, + + /// + /// Disc is loaded, playback enabled + /// + Playing, + } +} \ No newline at end of file diff --git a/RedBookPlayer.Models/Factories/OpticalDiscFactory.cs b/RedBookPlayer.Models/Factories/OpticalDiscFactory.cs index d09beaf..5023a6b 100644 --- a/RedBookPlayer.Models/Factories/OpticalDiscFactory.cs +++ b/RedBookPlayer.Models/Factories/OpticalDiscFactory.cs @@ -13,12 +13,10 @@ namespace RedBookPlayer.Models.Factories /// Generate an OpticalDisc from an input path /// /// Path to load the image from - /// Generate a TOC if the disc is missing one [CompactDisc only] - /// Load hidden tracks for playback [CompactDisc only] - /// Load data tracks for playback [CompactDisc only] + /// Options to pass to the optical disc factory /// True if the image should be playable immediately, false otherwise /// Instantiated OpticalDisc, if possible - public static OpticalDiscBase GenerateFromPath(string path, bool generateMissingToc, bool loadHiddenTracks, bool loadDataTracks, bool autoPlay) + public static OpticalDiscBase GenerateFromPath(string path, OpticalDiscOptions options, bool autoPlay) { try { @@ -34,7 +32,7 @@ namespace RedBookPlayer.Models.Factories image.Open(filter); // Generate and instantiate the disc - return GenerateFromImage(image, generateMissingToc, loadHiddenTracks, loadDataTracks, autoPlay); + return GenerateFromImage(image, options, autoPlay); } catch { @@ -47,12 +45,10 @@ namespace RedBookPlayer.Models.Factories /// Generate an OpticalDisc from an input IOpticalMediaImage /// /// IOpticalMediaImage to create from - /// Generate a TOC if the disc is missing one [CompactDisc only] - /// Load hidden tracks for playback [CompactDisc only] - /// Load data tracks for playback [CompactDisc only] + /// Options to pass to the optical disc factory /// True if the image should be playable immediately, false otherwise /// Instantiated OpticalDisc, if possible - public static OpticalDiscBase GenerateFromImage(IOpticalMediaImage image, bool generateMissingToc, bool loadHiddenTracks, bool loadDataTracks, bool autoPlay) + public static OpticalDiscBase GenerateFromImage(IOpticalMediaImage image, OpticalDiscOptions options, bool autoPlay) { // If the image is not usable, we don't do anything if(!IsUsableImage(image)) @@ -66,7 +62,7 @@ namespace RedBookPlayer.Models.Factories { case "Compact Disc": case "GD": - opticalDisc = new CompactDisc(generateMissingToc, loadHiddenTracks, loadDataTracks); + opticalDisc = new CompactDisc(options); break; default: opticalDisc = null; @@ -118,4 +114,4 @@ namespace RedBookPlayer.Models.Factories }; } } -} +} \ No newline at end of file diff --git a/RedBookPlayer.Models/Hardware/Player.cs b/RedBookPlayer.Models/Hardware/Player.cs index 7db9f0c..4f172ab 100644 --- a/RedBookPlayer.Models/Hardware/Player.cs +++ b/RedBookPlayer.Models/Hardware/Player.cs @@ -1,4 +1,3 @@ -using System; using System.ComponentModel; using Aaru.CommonTypes.Enums; using ReactiveUI; @@ -12,7 +11,13 @@ namespace RedBookPlayer.Models.Hardware /// /// Indicate if the player is ready to be used /// - public bool Initialized { get; private set; } = false; + public bool Initialized + { + get => _initialized; + private set => this.RaiseAndSetIfChanged(ref _initialized, value); + } + + private bool _initialized; #region OpticalDisc Passthrough @@ -100,27 +105,27 @@ namespace RedBookPlayer.Models.Hardware /// /// Represents the total tracks on the disc /// - public int TotalTracks => _opticalDisc.TotalTracks; + public int TotalTracks => _opticalDisc?.TotalTracks ?? 0; /// /// Represents the total indices on the disc /// - public int TotalIndexes => _opticalDisc.TotalIndexes; + public int TotalIndexes => _opticalDisc?.TotalIndexes ?? 0; /// /// Total sectors in the image /// - public ulong TotalSectors => _opticalDisc.TotalSectors; + public ulong TotalSectors => _opticalDisc?.TotalSectors ?? 0; /// /// Represents the time adjustment offset for the disc /// - public ulong TimeOffset => _opticalDisc.TimeOffset; + public ulong TimeOffset => _opticalDisc?.TimeOffset ?? 0; /// /// Represents the total playing time for the disc /// - public ulong TotalTime => _opticalDisc.TotalTime; + public ulong TotalTime => _opticalDisc?.TotalTime ?? 0; private int _currentTrackNumber; private ushort _currentTrackIndex; @@ -138,12 +143,21 @@ namespace RedBookPlayer.Models.Hardware #region SoundOutput Passthrough /// - /// Indicate if the output is playing + /// Indicates the current player state /// - public bool? Playing + public PlayerState PlayerState { - get => _playing; - private set => this.RaiseAndSetIfChanged(ref _playing, value); + get => _playerState; + private set => this.RaiseAndSetIfChanged(ref _playerState, value); + } + + /// + /// Indicates how to handle playback of data tracks + /// + public DataPlayback DataPlayback + { + get => _dataPlayback; + private set => this.RaiseAndSetIfChanged(ref _dataPlayback, value); } /// @@ -164,7 +178,8 @@ namespace RedBookPlayer.Models.Hardware private set => this.RaiseAndSetIfChanged(ref _volume, value); } - private bool? _playing; + private PlayerState _playerState; + private DataPlayback _dataPlayback; private bool _applyDeEmphasis; private int _volume; @@ -180,7 +195,7 @@ namespace RedBookPlayer.Models.Hardware /// /// OpticalDisc object /// - private readonly OpticalDiscBase _opticalDisc; + private OpticalDiscBase _opticalDisc; /// /// Last volume for mute toggling @@ -190,23 +205,29 @@ namespace RedBookPlayer.Models.Hardware #endregion /// - /// Create a new Player from a given image path + /// Constructor + /// + /// Default volume between 0 and 100 to use when starting playback + public Player(int defaultVolume) + { + Initialized = false; + _soundOutput = new SoundOutput(defaultVolume); + _soundOutput.SetDeEmphasis(false); + } + + /// + /// Initializes player from a given image path /// /// Path to the disc image - /// Generate a TOC if the disc is missing one [CompactDisc only] - /// Load hidden tracks for playback [CompactDisc only] - /// Load data tracks for playback [CompactDisc only] + /// Options to pass to the optical disc factory /// True if playback should begin immediately, false otherwise - /// Default volume between 0 and 100 to use when starting playback - public Player(string path, bool generateMissingToc, bool loadHiddenTracks, bool loadDataTracks, bool autoPlay, int defaultVolume) + public void Init(string path, OpticalDiscOptions options, bool autoPlay) { - // Set the internal state for initialization + // Reset initialization Initialized = false; - _soundOutput = new SoundOutput(); - _soundOutput.SetDeEmphasis(false); // Initalize the disc - _opticalDisc = OpticalDiscFactory.GenerateFromPath(path, generateMissingToc, loadHiddenTracks, loadDataTracks, autoPlay); + _opticalDisc = OpticalDiscFactory.GenerateFromPath(path, options, autoPlay); if(_opticalDisc == null || !_opticalDisc.Initialized) return; @@ -214,7 +235,7 @@ namespace RedBookPlayer.Models.Hardware _opticalDisc.PropertyChanged += OpticalDiscStateChanged; // Initialize the sound output - _soundOutput.Init(_opticalDisc, autoPlay, defaultVolume); + _soundOutput.Init(_opticalDisc, autoPlay); if(_soundOutput == null || !_soundOutput.Initialized) return; @@ -240,12 +261,12 @@ namespace RedBookPlayer.Models.Hardware return; else if(_soundOutput == null) return; - else if(_soundOutput.Playing) + else if(_soundOutput.PlayerState != PlayerState.Paused && _soundOutput.PlayerState != PlayerState.Stopped) return; _soundOutput.Play(); _opticalDisc.SetTotalIndexes(); - Playing = true; + PlayerState = PlayerState.Playing; } /// @@ -257,11 +278,11 @@ namespace RedBookPlayer.Models.Hardware return; else if(_soundOutput == null) return; - else if(!_soundOutput.Playing) + else if(_soundOutput.PlayerState != PlayerState.Playing) return; - _soundOutput?.Stop(); - Playing = false; + _soundOutput?.Pause(); + PlayerState = PlayerState.Paused; } /// @@ -269,10 +290,20 @@ namespace RedBookPlayer.Models.Hardware /// public void TogglePlayback() { - if(Playing == true) - Pause(); - else - Play(); + switch(PlayerState) + { + case PlayerState.NoDisc: + break; + case PlayerState.Stopped: + Play(); + break; + case PlayerState.Paused: + Play(); + break; + case PlayerState.Playing: + Pause(); + break; + } } /// @@ -284,12 +315,12 @@ namespace RedBookPlayer.Models.Hardware return; else if(_soundOutput == null) return; - else if(!_soundOutput.Playing) + else if(_soundOutput.PlayerState != PlayerState.Playing && _soundOutput.PlayerState != PlayerState.Paused) return; - _soundOutput?.Stop(); + _soundOutput.Stop(); _opticalDisc.LoadFirstTrack(); - Playing = null; + PlayerState = PlayerState.Stopped; } /// @@ -300,14 +331,16 @@ namespace RedBookPlayer.Models.Hardware if(_opticalDisc == null || !_opticalDisc.Initialized) return; - bool? wasPlaying = Playing; - if(wasPlaying == true) Pause(); + PlayerState wasPlaying = PlayerState; + if(wasPlaying == PlayerState.Playing) + Pause(); _opticalDisc.NextTrack(); if(_opticalDisc is CompactDisc compactDisc) _soundOutput.SetDeEmphasis(compactDisc.TrackHasEmphasis); - if(wasPlaying == true) Play(); + if(wasPlaying == PlayerState.Playing) + Play(); } /// @@ -318,14 +351,16 @@ namespace RedBookPlayer.Models.Hardware if(_opticalDisc == null || !_opticalDisc.Initialized) return; - bool? wasPlaying = Playing; - if(wasPlaying == true) Pause(); + PlayerState wasPlaying = PlayerState; + if(wasPlaying == PlayerState.Playing) + Pause(); _opticalDisc.PreviousTrack(); if(_opticalDisc is CompactDisc compactDisc) _soundOutput.SetDeEmphasis(compactDisc.TrackHasEmphasis); - if(wasPlaying == true) Play(); + if(wasPlaying == PlayerState.Playing) + Play(); } /// @@ -337,14 +372,16 @@ namespace RedBookPlayer.Models.Hardware if(_opticalDisc == null || !_opticalDisc.Initialized) return; - bool? wasPlaying = Playing; - if(wasPlaying == true) Pause(); + PlayerState wasPlaying = PlayerState; + if(wasPlaying == PlayerState.Playing) + Pause(); _opticalDisc.NextIndex(changeTrack); if(_opticalDisc is CompactDisc compactDisc) _soundOutput.SetDeEmphasis(compactDisc.TrackHasEmphasis); - if(wasPlaying == true) Play(); + if(wasPlaying == PlayerState.Playing) + Play(); } /// @@ -356,37 +393,38 @@ namespace RedBookPlayer.Models.Hardware if(_opticalDisc == null || !_opticalDisc.Initialized) return; - bool? wasPlaying = Playing; - if(wasPlaying == true) Pause(); + PlayerState wasPlaying = PlayerState; + if(wasPlaying == PlayerState.Playing) + Pause(); _opticalDisc.PreviousIndex(changeTrack); if(_opticalDisc is CompactDisc compactDisc) _soundOutput.SetDeEmphasis(compactDisc.TrackHasEmphasis); - if(wasPlaying == true) Play(); + if(wasPlaying == PlayerState.Playing) + Play(); } /// - /// Fast-forward playback by 75 sectors, if possible + /// Fast-forward playback by 75 sectors /// public void FastForward() { if(_opticalDisc == null || !_opticalDisc.Initialized) return; - _opticalDisc.SetCurrentSector(Math.Min(_opticalDisc.TotalSectors, _opticalDisc.CurrentSector + 75)); + _opticalDisc.SetCurrentSector(_opticalDisc.CurrentSector + 75); } /// - /// Rewind playback by 75 sectors, if possible + /// Rewind playback by 75 sectors /// public void Rewind() { if(_opticalDisc == null || !_opticalDisc.Initialized) return; - if(_opticalDisc.CurrentSector >= 75) - _opticalDisc.SetCurrentSector(_opticalDisc.CurrentSector - 75); + _opticalDisc.SetCurrentSector(_opticalDisc.CurrentSector - 75); } #endregion @@ -456,13 +494,13 @@ namespace RedBookPlayer.Models.Hardware #region Helpers /// - /// Set the value for loading data tracks [CompactDisc only] + /// Set data playback method [CompactDisc only] /// - /// True to enable loading data tracks, false otherwise - public void SetLoadDataTracks(bool load) + /// New playback value + public void SetDataPlayback(DataPlayback dataPlayback) { if(_opticalDisc is CompactDisc compactDisc) - compactDisc.LoadDataTracks = load; + compactDisc.DataPlayback = dataPlayback; } /// @@ -508,7 +546,7 @@ namespace RedBookPlayer.Models.Hardware /// private void SoundOutputStateChanged(object sender, PropertyChangedEventArgs e) { - Playing = _soundOutput.Playing; + PlayerState = _soundOutput.PlayerState; ApplyDeEmphasis = _soundOutput.ApplyDeEmphasis; Volume = _soundOutput.Volume; } diff --git a/RedBookPlayer.Models/Hardware/SoundOutput.cs b/RedBookPlayer.Models/Hardware/SoundOutput.cs index 743c35b..663e7c9 100644 --- a/RedBookPlayer.Models/Hardware/SoundOutput.cs +++ b/RedBookPlayer.Models/Hardware/SoundOutput.cs @@ -16,15 +16,19 @@ namespace RedBookPlayer.Models.Hardware /// /// Indicate if the output is ready to be used /// - public bool Initialized { get; private set; } = false; + public bool Initialized + { + get => _initialized; + private set => this.RaiseAndSetIfChanged(ref _initialized, value); + } /// - /// Indicate if the output is playing + /// Indicates the current player state /// - public bool Playing + public PlayerState PlayerState { - get => _playing; - private set => this.RaiseAndSetIfChanged(ref _playing, value); + get => _playerState; + private set => this.RaiseAndSetIfChanged(ref _playerState, value); } /// @@ -54,7 +58,8 @@ namespace RedBookPlayer.Models.Hardware } } - private bool _playing; + private bool _initialized; + private PlayerState _playerState; private bool _applyDeEmphasis; private int _volume; @@ -102,13 +107,18 @@ namespace RedBookPlayer.Models.Hardware #endregion + /// + /// Constructor + /// + /// Default volume between 0 and 100 to use when starting playback + public SoundOutput(int defaultVolume = 100) => Volume = defaultVolume; + /// /// Initialize the output with a given image /// /// OpticalDisc to load from /// True if playback should begin immediately, false otherwise - /// Default volume between 0 and 100 to use when starting playback - public void Init(OpticalDiscBase opticalDisc, bool autoPlay = false, int defaultVolume = 100) + public void Init(OpticalDiscBase opticalDisc, bool autoPlay) { // If we have an unusable disc, just return if(opticalDisc == null || !opticalDisc.Initialized) @@ -117,9 +127,6 @@ namespace RedBookPlayer.Models.Hardware // Save a reference to the disc _opticalDisc = opticalDisc; - // Set the initial playback volume - Volume = defaultVolume; - // Enable de-emphasis for CDs, if necessary if(opticalDisc is CompactDisc compactDisc) ApplyDeEmphasis = compactDisc.TrackHasEmphasis; @@ -136,6 +143,7 @@ namespace RedBookPlayer.Models.Hardware // Mark the output as ready Initialized = true; + PlayerState = PlayerState.Stopped; // Begin loading data _source.Start(); @@ -163,57 +171,14 @@ namespace RedBookPlayer.Models.Hardware // Determine how many sectors we can read DetermineReadAmount(count, out ulong sectorsToRead, out ulong zeroSectorsAmount); - // Create padding data for overreads - byte[] zeroSectors = new byte[(int)zeroSectorsAmount * _opticalDisc.BytesPerSector]; - byte[] audioData; - - // Attempt to read the required number of sectors - var readSectorTask = Task.Run(() => - { - lock(_readingImage) - { - for(int i = 0; i < 4; i++) - { - try - { - return _opticalDisc.ReadSectors((uint)sectorsToRead).Concat(zeroSectors).ToArray(); - } - catch(ArgumentOutOfRangeException) - { - _opticalDisc.LoadFirstTrack(); - } - } - - return zeroSectors; - } - }); - - // Wait 100ms at longest for the read to occur - if(readSectorTask.Wait(TimeSpan.FromMilliseconds(100))) - { - audioData = readSectorTask.Result; - } - else + // Get data to return + byte[] audioDataSegment = ReadData(count, sectorsToRead, zeroSectorsAmount); + if(audioDataSegment == null) { Array.Clear(buffer, offset, count); return count; } - // Load only the requested audio segment - byte[] audioDataSegment = new byte[count]; - int copyAmount = Math.Min(count, audioData.Length - _currentSectorReadPosition); - if(Math.Max(0, copyAmount) == 0) - { - Array.Clear(buffer, offset, count); - return count; - } - - Array.Copy(audioData, _currentSectorReadPosition, audioDataSegment, 0, copyAmount); - - // Apply de-emphasis filtering, only if enabled - if(ApplyDeEmphasis) - ProcessDeEmphasis(audioDataSegment); - // Write out the audio data to the buffer Array.Copy(audioDataSegment, 0, buffer, offset, count); @@ -221,6 +186,7 @@ namespace RedBookPlayer.Models.Hardware _currentSectorReadPosition += count; if(_currentSectorReadPosition >= _opticalDisc.BytesPerSector) { + int currentTrack = _opticalDisc.CurrentTrackNumber; _opticalDisc.SetCurrentSector(_opticalDisc.CurrentSector + (ulong)(_currentSectorReadPosition / _opticalDisc.BytesPerSector)); _currentSectorReadPosition %= _opticalDisc.BytesPerSector; } @@ -235,10 +201,10 @@ namespace RedBookPlayer.Models.Hardware /// public void Play() { - if (_soundOut.PlaybackState != PlaybackState.Playing) + if(_soundOut.PlaybackState != PlaybackState.Playing) _soundOut.Play(); - Playing = _soundOut.PlaybackState == PlaybackState.Playing; + PlayerState = PlayerState.Playing; } /// @@ -249,7 +215,7 @@ namespace RedBookPlayer.Models.Hardware if(_soundOut.PlaybackState != PlaybackState.Paused) _soundOut.Pause(); - Playing = _soundOut.PlaybackState == PlaybackState.Playing; + PlayerState = PlayerState.Paused; } /// @@ -260,7 +226,7 @@ namespace RedBookPlayer.Models.Hardware if(_soundOut.PlaybackState != PlaybackState.Stopped) _soundOut.Stop(); - Playing = _soundOut.PlaybackState == PlaybackState.Playing; + PlayerState = PlayerState.Stopped; } #endregion @@ -270,7 +236,7 @@ namespace RedBookPlayer.Models.Hardware /// /// Set de-emphasis status /// - /// + /// New de-emphasis status public void SetDeEmphasis(bool apply) => ApplyDeEmphasis = apply; /// @@ -287,30 +253,19 @@ namespace RedBookPlayer.Models.Hardware /// Number of zeroed sectors to concatenate private void DetermineReadAmount(int count, out ulong sectorsToRead, out ulong zeroSectorsAmount) { - do + // Attempt to read 5 more sectors than requested + sectorsToRead = ((ulong)count / (ulong)_opticalDisc.BytesPerSector) + 5; + zeroSectorsAmount = 0; + + // Avoid overreads by padding with 0-byte data at the end + if(_opticalDisc.CurrentSector + sectorsToRead > _opticalDisc.TotalSectors) { - // Attempt to read 2 more sectors than requested - sectorsToRead = ((ulong)count / (ulong)_opticalDisc.BytesPerSector) + 2; - zeroSectorsAmount = 0; + ulong oldSectorsToRead = sectorsToRead; + sectorsToRead = _opticalDisc.TotalSectors - _opticalDisc.CurrentSector; - // Avoid overreads by padding with 0-byte data at the end - if(_opticalDisc.CurrentSector + sectorsToRead > _opticalDisc.TotalSectors) - { - ulong oldSectorsToRead = sectorsToRead; - sectorsToRead = _opticalDisc.TotalSectors - _opticalDisc.CurrentSector; - - int tempZeroSectorCount = (int)(oldSectorsToRead - sectorsToRead); - zeroSectorsAmount = (ulong)(tempZeroSectorCount < 0 ? 0 : tempZeroSectorCount); - } - - // If we're reading past the last sector of the disc, wrap around - // TODO: Have past-end reads looping back controlled by a flag instead (Repeat? Repeat All?) - if(sectorsToRead <= 0) - { - _opticalDisc.LoadFirstTrack(); - _currentSectorReadPosition = 0; - } - } while(sectorsToRead <= 0); + int tempZeroSectorCount = (int)(oldSectorsToRead - sectorsToRead); + zeroSectorsAmount = (ulong)(tempZeroSectorCount < 0 ? 0 : tempZeroSectorCount); + } } /// @@ -333,6 +288,58 @@ namespace RedBookPlayer.Models.Hardware ByteConverter.FromFloats16Bit(floatAudioData, audioData); } + /// + /// Read the requested amount of data from an input + /// + /// Number of bytes to load + /// Number of sectors to read + /// Number of zeroed sectors to concatenate + /// The requested amount of data, if possible + private byte[] ReadData(int count, ulong sectorsToRead, ulong zeroSectorsAmount) + { + // Create padding data for overreads + byte[] zeroSectors = new byte[(int)zeroSectorsAmount * _opticalDisc.BytesPerSector]; + byte[] audioData; + + // Attempt to read the required number of sectors + var readSectorTask = Task.Run(() => + { + lock(_readingImage) + { + for(int i = 0; i < 4; i++) + { + try + { + return _opticalDisc.ReadSectors((uint)sectorsToRead).Concat(zeroSectors).ToArray(); + } + catch { } + } + + return zeroSectors; + } + }); + + // Wait 100ms at longest for the read to occur + if(readSectorTask.Wait(TimeSpan.FromMilliseconds(100))) + audioData = readSectorTask.Result; + else + return null; + + // Load only the requested audio segment + byte[] audioDataSegment = new byte[count]; + int copyAmount = Math.Min(count, audioData.Length - _currentSectorReadPosition); + if(Math.Max(0, copyAmount) == 0) + return null; + + Array.Copy(audioData, _currentSectorReadPosition, audioDataSegment, 0, copyAmount); + + // Apply de-emphasis filtering, only if enabled + if(ApplyDeEmphasis) + ProcessDeEmphasis(audioDataSegment); + + return audioDataSegment; + } + /// /// Sets or resets the de-emphasis filters /// @@ -369,4 +376,4 @@ namespace RedBookPlayer.Models.Hardware #endregion } -} +} \ No newline at end of file