diff --git a/MusicSharpTests/SoundFlowPlayerTests.cs b/MusicSharpTests/SoundFlowPlayerTests.cs new file mode 100644 index 0000000..694b052 --- /dev/null +++ b/MusicSharpTests/SoundFlowPlayerTests.cs @@ -0,0 +1,14 @@ +namespace MusicSharpTests; + +public class SoundFlowPlayerTests +{ + [Test] + public void Play_NullFile() + { + // arrange + var isFileValid = File.Exists("thisisafail.exe"); + + // act and assert + Assert.That(isFileValid, Is.False); + } +} \ No newline at end of file diff --git a/MusicSharpTests/WinPlayerTests.cs b/MusicSharpTests/WinPlayerTests.cs deleted file mode 100644 index c1b4310..0000000 --- a/MusicSharpTests/WinPlayerTests.cs +++ /dev/null @@ -1,26 +0,0 @@ -using MusicSharp.SoundEngines; - -namespace MusicSharpTests; - -public class WinPlayerTests -{ - [Test] - public void Play_NullFile() - { - // arrange - var isFileValid = File.Exists("thisisafail.exe"); - - // act and assert - Assert.That(isFileValid, Is.False); - } - - [Test] - public void PlayFromPlaylist_NullFile() - { - // arrange - var player = new WinPlayer(); - - // act and assert - Assert.Throws(() => player.PlayFromPlaylist(null)); - } -} \ No newline at end of file diff --git a/README.md b/README.md index e8f6439..38fbe9a 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,9 @@ # MusicSharp [![.NET](https://github.com/markjamesm/Baseball-Sharp/actions/workflows/dotnet.yml/badge.svg?branch=master)](https://github.com/markjamesm/MusicSharp/actions) [![C#](https://img.shields.io/badge/Language-CSharp-darkgreen.svg)](https://en.wikipedia.org/wiki/C_Sharp_(programming_language)) [![License](https://img.shields.io/badge/License-GPL-orange.svg)](https://www.gnu.org/licenses/gpl-3.0.en.html) -MusicSharp is a cross-platform Terminal User Interface (TUI) music player written in C# (.NET 8) with the goal of being minimalistic and light on resources. +A cross-platform Terminal User Interface (TUI) music player written in C# (.NET 8) with the goal of being minimalistic and light on resources. -Currently in beta, MusicSharp makes use of the [NAudio](https://github.com/naudio/NAudio) and [Terminal.Gui](https://github.com/migueldeicaza/gui.cs) libraries. A project build log can be [found here](https://markjames.dev/blog/developing-a-cli-music-player-csharp/) +MusicSharp makes use of the [SoundFlow](https://github.com/LSXPrime/SoundFlow) and [Terminal.Gui](https://github.com/migueldeicaza/gui.cs) libraries. ## Screenshot @@ -11,15 +11,10 @@ Currently in beta, MusicSharp makes use of the [NAudio](https://github.com/naudi ## Features +- Cross-platform support (Windows, Mac, Linux). - Play audio files. -- Load music playlists (M3U) -- Audio streaming. -- Lightweight - -## Planned - -- Save playlists. -- Cross platform support. +- Load and play from music playlists (M3U). +- Streaming support. ## Installation diff --git a/src/SoundEngines/IPlayer.cs b/src/AudioPlayer/IPlayer.cs similarity index 71% rename from src/SoundEngines/IPlayer.cs rename to src/AudioPlayer/IPlayer.cs index 7827f12..765dec8 100644 --- a/src/SoundEngines/IPlayer.cs +++ b/src/AudioPlayer/IPlayer.cs @@ -2,19 +2,21 @@ // Licensed under the GNU GPL v3 License. See LICENSE in the project root for license information. // +using System; +using System.IO; using MusicSharp.Enums; -namespace MusicSharp.SoundEngines; +namespace MusicSharp.AudioPlayer; /// /// Defines the methods an audio player class should implement. /// -public interface IPlayer +public interface IPlayer: IDisposable { /// /// Gets or sets a value indicating whether the audio player is playing. /// - ePlayerStatus PlayerStatus { get; set; } + EPlayerStatus PlayerStatus { get; set; } /// /// Gets or sets the last file opened by the player. @@ -25,19 +27,14 @@ public interface IPlayer /// Method to play audio. /// /// The filepath of the audio file to play. - void OpenFile(string path); - - /// - /// Method to play an audio stream from a URL. - /// - /// The stream URL of the audio file to play. - void OpenStream(string streamUrl); - + /// /// The audio stream. + void Play(Stream stream); + /// - /// Method to pause audio playback. + /// Method to play or pause depending on state. /// void PlayPause(); - + /// /// Method to stop audio playback. /// @@ -53,23 +50,17 @@ public interface IPlayer /// void DecreaseVolume(); - /// - /// Play an audio file contained in a playlist. - /// - /// The path to the audio file. - void PlayFromPlaylist(string path); - /// /// Returns the current playtime of the audioFileReader instance. /// /// The current time played as TimeSpan. - System.TimeSpan CurrentTime(); + float CurrentTime(); /// /// Returns the total track length in timespan format. /// /// The length of the track in timespan format. - System.TimeSpan TrackLength(); + float TrackLength(); /// /// Skip ahead in the audio file 5s. diff --git a/src/AudioPlayer/SoundFlowPlayer.cs b/src/AudioPlayer/SoundFlowPlayer.cs new file mode 100644 index 0000000..cc65b1e --- /dev/null +++ b/src/AudioPlayer/SoundFlowPlayer.cs @@ -0,0 +1,124 @@ +using System; +using System.IO; +using MusicSharp.Enums; +using SoundFlow.Backends.MiniAudio; +using SoundFlow.Components; +using SoundFlow.Providers; + +namespace MusicSharp.AudioPlayer; + +// Cross-platform sound engine that works for all devices which +// the .NET platform runs on. +public sealed class SoundFlowPlayer : IPlayer +{ + private readonly MiniAudioEngine _soundEngine; + private SoundPlayer _player; + + public EPlayerStatus PlayerStatus { get; set; } + public string LastFileOpened { get; set; } + + + public SoundFlowPlayer(MiniAudioEngine soundEngine) + { + _soundEngine = soundEngine; + } + + public void Play(Stream stream) + { + if (_player != null) + { + _player.Stop(); + } + + _player = new SoundPlayer(new StreamDataProvider(stream)); + + // Add the player to the master mixer. This connects the player's output to the audio engine's output. + Mixer.Master.AddComponent(_player); + + _player.Play(); + PlayerStatus = EPlayerStatus.Playing; + } + + public void PlayPause() + { + switch (PlayerStatus) + { + case EPlayerStatus.Playing: + _player.Pause(); + PlayerStatus = EPlayerStatus.Paused; + break; + case EPlayerStatus.Paused: + case EPlayerStatus.Stopped: + _player.Play(); + PlayerStatus = EPlayerStatus.Playing; + break; + default: + throw new ArgumentOutOfRangeException(); + } + } + + public void Stop() + { + if (PlayerStatus != EPlayerStatus.Stopped) + { + _player.Stop(); + PlayerStatus = EPlayerStatus.Stopped; + } + } + + public void IncreaseVolume() + { + // Need to verify what SoundFlow's max volume level is + // For now this should be enough based on testing + if (_player.Volume < 2.0f) + { + _player.Volume += .1f; + } + } + + public void DecreaseVolume() + { + // Ensure that the volume isn't negative + // otherwise the player will crash + if (_player.Volume > .1f) + { + _player.Volume -= .1f; + } + + if (_player.Volume <= .1f) + { + _player.Volume = 0f; + } + } + + public void SeekForward() + { + if (_player.Time < _player.Duration - 5f) + { + _player.Seek(_player.Time + 5f); + } + } + + public void SeekBackwards() + { + if (_player.Time > 5f) + { + _player.Seek(_player.Time - 5f); + } + } + + public float CurrentTime() + { + return _player.Time; + } + + public float TrackLength() + { + return _player.Duration; + } + + public void Dispose() + { + _soundEngine.Dispose(); + } +} \ No newline at end of file diff --git a/src/Enums/EFileType.cs b/src/Enums/EFileType.cs new file mode 100644 index 0000000..1ea3943 --- /dev/null +++ b/src/Enums/EFileType.cs @@ -0,0 +1,7 @@ +namespace MusicSharp.Enums; + +public enum EFileType +{ + File, + Stream +} \ No newline at end of file diff --git a/src/Enums/ePlayerStatus.cs b/src/Enums/EPlayerStatus.cs similarity index 82% rename from src/Enums/ePlayerStatus.cs rename to src/Enums/EPlayerStatus.cs index e998289..0870196 100644 --- a/src/Enums/ePlayerStatus.cs +++ b/src/Enums/EPlayerStatus.cs @@ -3,9 +3,9 @@ /// /// The status of the audio player. /// -public enum ePlayerStatus +public enum EPlayerStatus { Playing, Paused, Stopped -} +} \ No newline at end of file diff --git a/src/Helpers/Converters.cs b/src/Helpers/Converters.cs new file mode 100644 index 0000000..9607934 --- /dev/null +++ b/src/Helpers/Converters.cs @@ -0,0 +1,26 @@ +using System.IO; +using System.Net.Http; +using System.Threading.Tasks; + +namespace MusicSharp.Helpers; + +public class Converters +{ + private readonly HttpClient _httpClient; + + public Converters(HttpClient httpClient) + { + _httpClient = httpClient; + } + + public static Stream ConvertFileToStream(string path) + { + return File.OpenRead(path); + } + + public async Task ConvertUrlToStream(string url) + { + var stream = await _httpClient.GetStreamAsync(url); + return stream; + } +} \ No newline at end of file diff --git a/src/Models/PlaylistLoader.cs b/src/Models/PlaylistLoader.cs index ac6ba0a..41c62c1 100644 --- a/src/Models/PlaylistLoader.cs +++ b/src/Models/PlaylistLoader.cs @@ -3,6 +3,7 @@ // using System.Collections.Generic; +using System.Linq; using ATL.Playlist; namespace MusicSharp.Models; @@ -22,14 +23,9 @@ public static class PlaylistLoader /// The user specified playlist path. public static List LoadPlaylist(string userPlaylist) { - var filePaths = new List(); var theReader = PlaylistIOFactory.GetInstance().GetPlaylistIO(userPlaylist); - foreach (var s in theReader.FilePaths) - { - filePaths.Add(s); - } - - return filePaths; + // Fix space formatting as SoundFlow doesn't support encoded spaces + return theReader.FilePaths.Select(s => s.Replace("%20", " ")).ToList(); } } \ No newline at end of file diff --git a/src/MusicSharp.csproj b/src/MusicSharp.csproj index d92021d..d8bcba9 100644 --- a/src/MusicSharp.csproj +++ b/src/MusicSharp.csproj @@ -3,9 +3,10 @@ Exe net8.0 - 0.4.9 + 1.0.0 Mark-James McDougall Mark-James McDougall + enable MusicSharp.ico @@ -19,7 +20,7 @@ - + diff --git a/src/Program.cs b/src/Program.cs index bf00623..b205d50 100644 --- a/src/Program.cs +++ b/src/Program.cs @@ -2,8 +2,14 @@ // Licensed under the GNU GPL v3 License. See LICENSE in the project root for license information. // -using MusicSharp.SoundEngines; -using MusicSharp.View; +using System.Net.Http; +using MusicSharp.UI; +using MusicSharp.AudioPlayer; +using MusicSharp.Helpers; +using SoundFlow.Backends.MiniAudio; +using SoundFlow.Enums; + +namespace MusicSharp; /// /// Entry Point class. @@ -15,9 +21,14 @@ public static class Program /// public static void Main() { - var player = new WinPlayer(); - var gui = new Tui(player); + var soundEngine = new MiniAudioEngine(44100, Capability.Playback); + using IPlayer player = new SoundFlowPlayer(soundEngine); + + using var httpClient = new HttpClient(); + var converters = new Converters(httpClient); + + var ui = new Tui(player, converters); - gui.Start(); + ui.Start(); } } \ No newline at end of file diff --git a/src/SoundEngines/WinPlayer.cs b/src/SoundEngines/WinPlayer.cs deleted file mode 100644 index 97e2934..0000000 --- a/src/SoundEngines/WinPlayer.cs +++ /dev/null @@ -1,214 +0,0 @@ -// -// Licensed under the GNU GPL v3 License. See LICENSE in the project root for license information. -// - -using MusicSharp.Enums; -using System; -using System.IO; -using NAudio.Wave; - -namespace MusicSharp.SoundEngines; - -/// -/// The audio player implementation for Windows using NAudio. -/// -public class WinPlayer : IPlayer -{ - private readonly WaveOutEvent _outputDevice; - private AudioFileReader _audioFileReader; - - /// - /// Initializes a new instance of the class. - /// - public WinPlayer() - { - _outputDevice = new WaveOutEvent(); - _outputDevice.PlaybackStopped += OnPlaybackStopped; - } - - /// - public ePlayerStatus PlayerStatus { get; set; } = ePlayerStatus.Stopped; - - /// - public string LastFileOpened { get; set; } - - /// - public void Stop() - { - _outputDevice.Stop(); - PlayerStatus = ePlayerStatus.Stopped; - } - - /// - /// Opens an audio file and then plays it. - /// - /// The filepath. - public void OpenFile(string path) - { - var isFileValid = File.Exists(path); - - if (isFileValid) - { - try - { - _audioFileReader = new AudioFileReader(path); - _outputDevice.Init(_audioFileReader); - _outputDevice.Play(); - PlayerStatus = ePlayerStatus.Playing; - } - catch (FileNotFoundException) - { - } - } - - // Space for error message, should one be wanted/needed. - } - - /// - /// Method to play and pause audio playback depending on PlaybackState. - /// - public void PlayPause() - { - if (_outputDevice.PlaybackState == PlaybackState.Stopped) - { - _outputDevice.Play(); - PlayerStatus = ePlayerStatus.Playing; - return; - } - if (_outputDevice.PlaybackState == PlaybackState.Paused) - { - _outputDevice.Play(); - PlayerStatus = ePlayerStatus.Playing; - return; - } - if (_outputDevice.PlaybackState == PlaybackState.Playing) - { - _outputDevice.Pause(); - PlayerStatus = ePlayerStatus.Paused; - } - } - - /// - public void PlayFromPlaylist(string path) - { - if (_outputDevice != null) - { - _outputDevice.Dispose(); - - try - { - _audioFileReader = new AudioFileReader(path); - _outputDevice.Init(_audioFileReader); - _outputDevice.Play(); - PlayerStatus = ePlayerStatus.Playing; - } - catch (FileNotFoundException) - { - } - } - } - - /// - /// Dispose of our device once playback is stopped. - /// - /// The object sender. - /// The StoppedEventArgs. - public void OnPlaybackStopped(object sender, StoppedEventArgs args) - { - if (_audioFileReader is not null) - { - _audioFileReader.Dispose(); - } - - _outputDevice.Dispose(); - } - - /// - /// Method to increase audio playback volume. - /// - public void IncreaseVolume() - { - // Use this construct to prevent edge cases going over 1.0f - // This is caused by using floats in WaveOutEvent - if (_outputDevice.Volume > 0.9f) - { - _outputDevice.Volume = 1.0f; - return; - } - - _outputDevice.Volume += 0.1f; - } - - /// - /// Method to decrease audio playback volume. - /// - public void DecreaseVolume() - { - // Use this construct to prevent edge cases going under 0.0f - // This is caused by using floats in WaveOutEvent - if (_outputDevice.Volume < 0.1f) - { - _outputDevice.Volume = 0.0f; - return; - } - - _outputDevice.Volume -= 0.1f; - } - - /// - /// Method to open an audio stream. - /// - /// The URL of the stream. - public void OpenStream(string streamUrl) - { - try - { - using var mf = new MediaFoundationReader(streamUrl); - - _outputDevice.Init(mf); - _outputDevice.Play(); - } - catch (NullReferenceException) - { - } - } - - /// - public TimeSpan CurrentTime() - { - var zeroTime = new TimeSpan(0); - - if (_outputDevice.PlaybackState != PlaybackState.Stopped) - { - return _audioFileReader.CurrentTime; - } - else - { - return zeroTime; - } - } - - /// - public TimeSpan TrackLength() - { - return _audioFileReader.TotalTime; - } - - /// - public void SeekForward() - { - if (_audioFileReader != null && _audioFileReader.CurrentTime <= _audioFileReader.TotalTime) - { - _audioFileReader.CurrentTime = _audioFileReader.CurrentTime.Add(TimeSpan.FromSeconds(5)); - } - } - - /// - public void SeekBackwards() - { - if (_audioFileReader != null && _audioFileReader.CurrentTime >= TimeSpan.FromSeconds(5)) - { - _audioFileReader.CurrentTime = _audioFileReader.CurrentTime.Subtract(TimeSpan.FromSeconds(5)); - } - } -} \ No newline at end of file diff --git a/src/view/Tui.cs b/src/UI/Tui.cs similarity index 76% rename from src/view/Tui.cs rename to src/UI/Tui.cs index ebf4dc2..7d7d179 100644 --- a/src/view/Tui.cs +++ b/src/UI/Tui.cs @@ -2,15 +2,16 @@ // Licensed under the GNU GPL v3 License. See LICENSE in the project root for license information. // -using MusicSharp.Enums; -using MusicSharp.SoundEngines; using System; using System.Collections.Generic; using System.IO; +using MusicSharp.Enums; using MusicSharp.Models; +using MusicSharp.AudioPlayer; +using MusicSharp.Helpers; using Terminal.Gui; -namespace MusicSharp.View; +namespace MusicSharp.UI; /// /// The Gui class houses the CLI elements of MusicSharp. @@ -33,14 +34,18 @@ public class Tui private object _mainLoopTimeout = null; private List _playlist = new List(); + + private readonly Converters _converters; /// /// Initializes a new instance of the class. /// /// The player to be injected. - public Tui(IPlayer player) + /// Helper class to convert files and urls to Stream type. + public Tui(IPlayer player, Converters converters) { _player = player; + _converters = converters; } /// @@ -53,7 +58,7 @@ public Tui(IPlayer player) /// public void Start() { - // Creates a instance of MainLoop to process input events, handle timers and other sources of data. + // Creates an instance of MainLoop to process input events, handle timers and other sources of data. Application.Init(); var top = Application.Top; @@ -77,7 +82,7 @@ public void Start() { new MenuItem("_About MusicSharp", string.Empty, () => { - MessageBox.Query("Music Sharp 0.7.5", "\nMusic Sharp is a lightweight CLI\n music player written in C#.\n\nDeveloped by Mark-James McDougall\nand licensed under the GPL v3.\n ", "Close"); + MessageBox.Query("Music Sharp 1.0.0", "\nMusic Sharp is a lightweight CLI\n music player written in C#.\n\nDeveloped by Mark-James McDougall\nand licensed under the GPL v3.\n ", "Close"); }), }), }); @@ -106,7 +111,7 @@ public void Start() { PlayPause(); - if (_player.PlayerStatus != ePlayerStatus.Stopped) + if (_player.PlayerStatus != EPlayerStatus.Stopped) { UpdateProgressBar(); } @@ -172,10 +177,17 @@ public void Start() // Play the selection when a playlist path is clicked. _playlistView.OpenSelectedItem += (a) => { - _player.LastFileOpened = a.Value.ToString(); - _player.PlayFromPlaylist(_player.LastFileOpened); - NowPlaying(_player.LastFileOpened); - UpdateProgressBar(); + try + { + _player.LastFileOpened = a.Value.ToString(); + _player.Play(Converters.ConvertFileToStream(_player.LastFileOpened)); + NowPlaying(_player.LastFileOpened); + UpdateProgressBar(); + } + catch (FileNotFoundException ex) + { + MessageBox.Query("Warning", "Invalid file path.", "Close"); + } }; _playlistPane.Add(_playlistView); @@ -213,7 +225,7 @@ private void PlayPause() { _player.PlayPause(); - if (_player.PlayerStatus == ePlayerStatus.Playing) + if (_player.PlayerStatus == EPlayerStatus.Playing) { UpdateProgressBar(); } @@ -232,23 +244,27 @@ private void OpenFile() d.DirectoryPath = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); // This will filter the dialog on basis of the allowed file types in the array. - d.AllowedFileTypes = new string[] { ".mp3", ".wav", ".flac" }; + d.AllowedFileTypes = [".mp3", ".wav", ".flac"]; Application.Run(d); if (!d.Canceled) { if (File.Exists(d.FilePath.ToString())) { - _player.LastFileOpened = d.FilePath.ToString(); - _player.OpenFile(_player.LastFileOpened); - NowPlaying(_player.LastFileOpened); - AudioProgressBar.Fraction = 0F; - UpdateProgressBar(); - TimePlayedLabel(); - } - else - { - // This is a good spot for an error message, should one be wanted/needed + try + { + _player.LastFileOpened = d.FilePath.ToString(); + var stream = Converters.ConvertFileToStream(d.FilePath.ToString()); + _player.Play(stream); + NowPlaying(_player.LastFileOpened); + AudioProgressBar.Fraction = 0F; + UpdateProgressBar(); + TimePlayedLabel(); + } + catch (FileNotFoundException ex) + { + MessageBox.Query("Warning", "Invalid file path.", "Close"); + } } } } @@ -258,14 +274,14 @@ private void OpenStream() { var d = new Dialog("Open Stream", 50, 15); - var editLabel = new Label("Enter the url of the audio stream to load:\n(.mp3 only)") + var editLabel = new Label("Enter the url of the audio stream to load\n (.mp3 streams only):") { X = 0, Y = 0, Width = Dim.Fill(), }; - var streamURL = new TextField(string.Empty) + var streamUrl = new TextField(string.Empty) { X = 3, Y = 4, @@ -273,13 +289,20 @@ private void OpenStream() }; var loadStream = new Button(12, 7, "Load Stream"); - loadStream.Clicked += () => + loadStream.Clicked += async () => { - _player.OpenStream(streamURL.Text.ToString()); - Application.RequestStop(); + try + { + var stream = await _converters.ConvertUrlToStream(streamUrl.Text.ToString()); + _player.Play(stream); + } + catch (Exception ex) + { + MessageBox.Query("Warning", "Invalid URL.", "Close"); + } }; - var cancelStream = new Button(29, 7, "Cancel"); + var cancelStream = new Button(29, 7, "Close"); cancelStream.Clicked += () => { Application.RequestStop(); @@ -287,7 +310,7 @@ private void OpenStream() d.AddButton(loadStream); d.AddButton(cancelStream); - d.Add(editLabel, streamURL); + d.Add(editLabel, streamUrl); Application.Run(d); } @@ -297,7 +320,7 @@ private void LoadPlaylist() var d = new OpenDialog("Open", "Open a playlist") { AllowsMultipleSelection = false }; // This will filter the dialog on basis of the allowed file types in the array. - d.AllowedFileTypes = new string[] { ".m3u" }; + d.AllowedFileTypes = [".m3u"]; Application.Run(d); if (!d.Canceled) @@ -320,7 +343,7 @@ private void LoadPlaylist() } } - private void NowPlaying(string track) + private static void NowPlaying(string track) { _trackName = new Label(track) { @@ -334,10 +357,11 @@ private void NowPlaying(string track) private void TimePlayedLabel() { - if (_player.PlayerStatus != ePlayerStatus.Stopped) + if (_player.PlayerStatus != EPlayerStatus.Stopped) { - var timePlayed = _player.CurrentTime().ToString(@"mm\:ss"); - var trackLength = _player.TrackLength().ToString(@"mm\:ss"); + var timePlayed = TimeSpan.FromSeconds((double)(new decimal(_player.CurrentTime()))).ToString(@"hh\:mm\:ss"); + var trackLength = TimeSpan.FromSeconds((double)(new decimal(_player.TrackLength()))).ToString(@"hh\:mm\:ss"); + _trackName = new Label($"{timePlayed} / {trackLength}") { X = Pos.Right(AudioProgressBar), @@ -360,9 +384,9 @@ private void UpdateProgressBar() { _mainLoopTimeout = Application.MainLoop.AddTimeout(TimeSpan.FromSeconds(1), (updateTimer) => { - while (_player.CurrentTime().Seconds < _player.TrackLength().TotalSeconds && _player.PlayerStatus is not ePlayerStatus.Stopped) + while (_player.CurrentTime() < _player.TrackLength() && _player.PlayerStatus is not EPlayerStatus.Stopped) { - AudioProgressBar.Fraction = (float)(_player.CurrentTime().Seconds / _player.TrackLength().TotalSeconds); + AudioProgressBar.Fraction = _player.CurrentTime() / _player.TrackLength(); TimePlayedLabel(); return true;