diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 3354d7c..142a1d1 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -16,10 +16,8 @@ jobs: - name: Setup .NET Core uses: actions/setup-dotnet@v1 with: - dotnet-version: 8.0.* + dotnet-version: 9.0.* - name: Install dependencies run: dotnet restore - name: Build - run: dotnet build --configuration Release --no-restore - - name: Test - run: dotnet test --no-restore --verbosity normal + run: dotnet build --configuration Release --no-restore \ No newline at end of file diff --git a/.gitignore b/.gitignore index e553c84..62540f3 100644 --- a/.gitignore +++ b/.gitignore @@ -364,3 +364,5 @@ FodyWeavers.xsd # Visual Studio Code .vscode + +.idea/ \ No newline at end of file diff --git a/.idea/.idea.MusicSharp/.idea/.gitignore b/.idea/.idea.MusicSharp/.idea/.gitignore deleted file mode 100644 index 97791b0..0000000 --- a/.idea/.idea.MusicSharp/.idea/.gitignore +++ /dev/null @@ -1,13 +0,0 @@ -# Default ignored files -/shelf/ -/workspace.xml -# Rider ignored files -/modules.xml -/contentModel.xml -/projectSettingsUpdater.xml -/.idea.MusicSharp.iml -# Editor-based HTTP Client requests -/httpRequests/ -# Datasource local storage ignored files -/dataSources/ -/dataSources.local.xml diff --git a/.idea/.idea.MusicSharp/.idea/encodings.xml b/.idea/.idea.MusicSharp/.idea/encodings.xml deleted file mode 100644 index df87cf9..0000000 --- a/.idea/.idea.MusicSharp/.idea/encodings.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - - \ No newline at end of file diff --git a/.idea/.idea.MusicSharp/.idea/indexLayout.xml b/.idea/.idea.MusicSharp/.idea/indexLayout.xml deleted file mode 100644 index 323a9b7..0000000 --- a/.idea/.idea.MusicSharp/.idea/indexLayout.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - - - . - - - - - \ No newline at end of file diff --git a/.idea/.idea.MusicSharp/.idea/vcs.xml b/.idea/.idea.MusicSharp/.idea/vcs.xml deleted file mode 100644 index 94a25f7..0000000 --- a/.idea/.idea.MusicSharp/.idea/vcs.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/MusicSharp.sln b/MusicSharp.sln index 7120784..d0869fd 100644 --- a/MusicSharp.sln +++ b/MusicSharp.sln @@ -12,8 +12,6 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution README.md = README.md EndProjectSection EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MusicSharpTests", "MusicSharpTests\MusicSharpTests.csproj", "{B0797164-0016-4ED1-BDDA-42DE8D414CD6}" -EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -24,10 +22,6 @@ Global {A1943162-1979-46C6-9082-7F49BEC191BA}.Debug|Any CPU.Build.0 = Debug|Any CPU {A1943162-1979-46C6-9082-7F49BEC191BA}.Release|Any CPU.ActiveCfg = Release|Any CPU {A1943162-1979-46C6-9082-7F49BEC191BA}.Release|Any CPU.Build.0 = Release|Any CPU - {B0797164-0016-4ED1-BDDA-42DE8D414CD6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {B0797164-0016-4ED1-BDDA-42DE8D414CD6}.Debug|Any CPU.Build.0 = Debug|Any CPU - {B0797164-0016-4ED1-BDDA-42DE8D414CD6}.Release|Any CPU.ActiveCfg = Release|Any CPU - {B0797164-0016-4ED1-BDDA-42DE8D414CD6}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/MusicSharpTests/MusicSharpTests.csproj b/MusicSharpTests/MusicSharpTests.csproj deleted file mode 100644 index 74f3a7b..0000000 --- a/MusicSharpTests/MusicSharpTests.csproj +++ /dev/null @@ -1,23 +0,0 @@ - - - - net8.0 - enable - enable - - false - - - - - - - - - - - - - - - diff --git a/MusicSharpTests/PlaylistLoaderTests.cs b/MusicSharpTests/PlaylistLoaderTests.cs deleted file mode 100644 index ae20955..0000000 --- a/MusicSharpTests/PlaylistLoaderTests.cs +++ /dev/null @@ -1,13 +0,0 @@ -using MusicSharp.PlaylistHandlers; - -namespace MusicSharpTests; - -public class Tests -{ - [Test] - public void Load_NullPlaylist() - { - // Act and assert - Assert.Throws(() => PlaylistLoader.LoadPlaylist(null)); - } -} \ No newline at end of file diff --git a/MusicSharpTests/SoundFlowPlayerTests.cs b/MusicSharpTests/SoundFlowPlayerTests.cs deleted file mode 100644 index 694b052..0000000 --- a/MusicSharpTests/SoundFlowPlayerTests.cs +++ /dev/null @@ -1,14 +0,0 @@ -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/Usings.cs b/MusicSharpTests/Usings.cs deleted file mode 100644 index cefced4..0000000 --- a/MusicSharpTests/Usings.cs +++ /dev/null @@ -1 +0,0 @@ -global using NUnit.Framework; \ No newline at end of file diff --git a/Properties/Resources.Designer.cs b/Properties/Resources.Designer.cs deleted file mode 100644 index d836cf5..0000000 --- a/Properties/Resources.Designer.cs +++ /dev/null @@ -1,63 +0,0 @@ -//------------------------------------------------------------------------------ -// -// This code was generated by a tool. -// Runtime Version:4.0.30319.42000 -// -// Changes to this file may cause incorrect behavior and will be lost if -// the code is regenerated. -// -//------------------------------------------------------------------------------ - -namespace MusicSharp.Properties { - using System; - - - /// - /// A strongly-typed resource class, for looking up localized strings, etc. - /// - // This class was auto-generated by the StronglyTypedResourceBuilder - // class via a tool like ResGen or Visual Studio. - // To add or remove a member, edit your .ResX file then rerun ResGen - // with the /str option, or rebuild your VS project. - [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "16.0.0.0")] - [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] - [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] - internal class Resources { - - private static global::System.Resources.ResourceManager resourceMan; - - private static global::System.Globalization.CultureInfo resourceCulture; - - [global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] - internal Resources() { - } - - /// - /// Returns the cached ResourceManager instance used by this class. - /// - [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] - internal static global::System.Resources.ResourceManager ResourceManager { - get { - if (object.ReferenceEquals(resourceMan, null)) { - global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("MusicSharp.Properties.Resources", typeof(Resources).Assembly); - resourceMan = temp; - } - return resourceMan; - } - } - - /// - /// Overrides the current thread's CurrentUICulture property for all - /// resource lookups using this strongly typed resource class. - /// - [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] - internal static global::System.Globalization.CultureInfo Culture { - get { - return resourceCulture; - } - set { - resourceCulture = value; - } - } - } -} diff --git a/Properties/Resources.resx b/Properties/Resources.resx deleted file mode 100644 index 4fdb1b6..0000000 --- a/Properties/Resources.resx +++ /dev/null @@ -1,101 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - text/microsoft-resx - - - 1.3 - - - System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.3500.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - - System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.3500.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - \ No newline at end of file diff --git a/README.md b/README.md index 1888844..e194387 100644 --- a/README.md +++ b/README.md @@ -1,31 +1,33 @@ # 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) -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# with the goal of being minimalist +and light on resources. -**Note**: A major rewrite of MusicSharp (version 2) is actively being developed in the -[musicsharp-v2 branch](https://github.com/markjamesm/MusicSharp/tree/musicsharp-v2). This version will feature: - -- A completely rewritten UI, making use of Terminal.Gui 2.0. -- Library functionality to manage audio files. -- Standalone application (no .NET runtime required). +![Screenshot of MusicSharp](screenshots/musicsharp.png?raw=true) ## Features -- Cross-platform support (Windows, Mac, Linux). -- Play audio files. -- Load and play from music playlists (M3U). -- Streaming support. +- Plays MP3, FLAC, and WAV files, with support for internet radio streams. +- Cross-platform (Windows, Mac, Linux). +- Standalone application (no .NET runtime required). +- M3U playlist support. ## Installation -Download the [latest release of MusicSharp](https://github.com/markjamesm/MusicSharp/releases) and follow the installation instructions. +Download the [latest release of MusicSharp](https://github.com/markjamesm/MusicSharp/releases) for your platform and follow the instructions below to launch it: + +### Windows + +Open MusicSharp.exe. -## Screenshot +### Linux & Mac -Screenshot of MusicSharp +Execute MusicSharp from your preferred terminal: -MusicSharp makes use of the [SoundFlow](https://github.com/LSXPrime/SoundFlow) and [Terminal.Gui](https://github.com/migueldeicaza/gui.cs) libraries. +```bash +./MusicSharp +``` ## Want to Contribute? diff --git a/screenshots/musicsharp.png b/screenshots/musicsharp.png new file mode 100644 index 0000000..0810c6f Binary files /dev/null and b/screenshots/musicsharp.png differ diff --git a/src/AudioPlayer/IPlayer.cs b/src/AudioPlayer/IPlayer.cs index ddafe1e..0133965 100644 --- a/src/AudioPlayer/IPlayer.cs +++ b/src/AudioPlayer/IPlayer.cs @@ -1,10 +1,7 @@ -// -// Licensed under the GNU GPL v3 License. See LICENSE in the project root for license information. -// - -using System; -using System.IO; -using MusicSharp.Enums; +using System; +using MusicSharp.Data; +using SoundFlow.Enums; +using SoundFlow.Structs; namespace MusicSharp.AudioPlayer; @@ -14,52 +11,50 @@ namespace MusicSharp.AudioPlayer; public interface IPlayer: IDisposable { /// - /// Gets or sets a value indicating whether the audio player is playing. + /// Gets a value indicating whether the audio player is playing. /// - EPlayerStatus PlayerStatus { get; set; } + PlaybackState State { get; } /// - /// Gets or sets the last file opened by the player. + /// Gets the list of audio playback devices. /// - string LastFileOpened { get; set; } - + DeviceInfo[] PlaybackDevices { get; } + /// - /// Method to play audio. + /// Returns the total length of the audio file. /// - /// The audio stream. - void Play(Stream stream); + float TrackLength { get; } /// - /// Method to play or pause depending on state. + /// Returns the current time played. /// - void PlayPause(); + float CurrentTime { get; } /// - /// Method to stop audio playback. + /// Returns the currently playing track. /// - void Stop(); + AudioFile? NowPlaying { get; set; } /// - /// Method to increase audio playback volume. + /// Changes the current audio playback device. /// - void IncreaseVolume(); - + void ChangePlaybackDevice(DeviceInfo device); + /// - /// Method to decrease audio playback volume. + /// Method to play and pause audio. /// - void DecreaseVolume(); - + /// The AudioFile. + void PlayPause(AudioFile audioFile); + /// - /// Returns the current playtime of the audioFileReader instance. + /// Method to stop audio playback. /// - /// The current time played as TimeSpan. - float CurrentTime(); + void Stop(); /// - /// Returns the total track length in timespan format. + /// Change audio volume /// - /// The length of the track in timespan format. - float TrackLength(); + void ChangeVolume(float amount); /// /// Skip ahead in the audio file 5s. @@ -69,5 +64,5 @@ public interface IPlayer: IDisposable /// /// Skip back in the audio file 5s. /// - public void SeekBackwards(); + public void SeekBackward(); } \ No newline at end of file diff --git a/src/AudioPlayer/IStreamConverter.cs b/src/AudioPlayer/IStreamConverter.cs deleted file mode 100644 index eccf77c..0000000 --- a/src/AudioPlayer/IStreamConverter.cs +++ /dev/null @@ -1,11 +0,0 @@ -using System.IO; -using System.Threading.Tasks; - -namespace MusicSharp.AudioPlayer; - -public interface IStreamConverter -{ - public Stream ConvertFileToStream(string path); - - public Task ConvertUrlToStream(string url); -} \ No newline at end of file diff --git a/src/AudioPlayer/SoundFlowPlayer.cs b/src/AudioPlayer/SoundFlowPlayer.cs index 141d622..1a26dca 100644 --- a/src/AudioPlayer/SoundFlowPlayer.cs +++ b/src/AudioPlayer/SoundFlowPlayer.cs @@ -1,103 +1,146 @@ using System; using System.IO; +using System.Linq; +using MusicSharp.Data; using MusicSharp.Enums; +using SoundFlow.Abstracts.Devices; using SoundFlow.Backends.MiniAudio; using SoundFlow.Components; +using SoundFlow.Enums; +using SoundFlow.Interfaces; using SoundFlow.Providers; +using SoundFlow.Structs; 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) + private readonly MiniAudioEngine _audioEngine; + private AudioPlaybackDevice _audioPlaybackDevice; + private readonly AudioFormat _audioFormat; + private ISoundDataProvider? _streamDataProvider; + private SoundPlayer? _player; + private float _volume; + + // If we don't know the state of the player, default to stopped? + public PlaybackState State => _player?.State ?? PlaybackState.Stopped; + public float TrackLength => _player?.Duration ?? 0; + public float CurrentTime => _player?.Time ?? 0; + public AudioFile? NowPlaying { get; set; } + public DeviceInfo[] PlaybackDevices => _audioEngine.PlaybackDevices; + + public SoundFlowPlayer(MiniAudioEngine audioEngine) { - if (_player != null) + _audioEngine = audioEngine; + + var defaultPlaybackDevice = _audioEngine.PlaybackDevices.FirstOrDefault(d => d.IsDefault); + + // Handle case no default playback device is found. + if (defaultPlaybackDevice.Id == IntPtr.Zero) { - _player.Stop(); } - - _player = new SoundPlayer(new StreamDataProvider(stream)); - Mixer.Master.AddComponent(_player); - _player.Play(); - PlayerStatus = EPlayerStatus.Playing; + _audioFormat = AudioFormat.DvdHq; + _audioPlaybackDevice = _audioEngine.InitializePlaybackDevice(defaultPlaybackDevice, _audioFormat); + _volume = 1f; } - public void PlayPause() + public void ChangePlaybackDevice(DeviceInfo device) { - switch (PlayerStatus) + _audioPlaybackDevice = _audioEngine.SwitchDevice(_audioPlaybackDevice, device); + } + + public void PlayPause(AudioFile audioFile) + { + if (_player != null && audioFile.Path.Equals(NowPlaying?.Path)) + { + switch (_player.State) + { + case PlaybackState.Playing: + _player.Pause(); + break; + case PlaybackState.Paused: + case PlaybackState.Stopped: + _player.Play(); + break; + default: + throw new ArgumentOutOfRangeException(); + } + } + + if (!audioFile.Path.Equals(NowPlaying?.Path)) { - case EPlayerStatus.Playing: - _player.Pause(); - PlayerStatus = EPlayerStatus.Paused; - break; - case EPlayerStatus.Paused: - case EPlayerStatus.Stopped: + if (_player != null && _player.State != PlaybackState.Stopped) + { + _player.Stop(); + _audioPlaybackDevice.Stop(); + NowPlaying = null; + } + + _streamDataProvider = audioFile.Type switch + { + EFileType.File => new StreamDataProvider(_audioEngine, _audioFormat, File.OpenRead(audioFile.Path)), + EFileType.Stream => new NetworkDataProvider(_audioEngine, _audioFormat, audioFile.Path), + _ => _streamDataProvider + }; + + if (_streamDataProvider != null) + { + _player = new SoundPlayer(_audioEngine, _audioFormat, _streamDataProvider); + _audioPlaybackDevice.MasterMixer.AddComponent(_player); + _audioPlaybackDevice.Start(); + _player.Volume = _volume; _player.Play(); - PlayerStatus = EPlayerStatus.Playing; - break; - default: - throw new ArgumentOutOfRangeException(); + NowPlaying = audioFile; + } } } public void Stop() { - if (PlayerStatus != EPlayerStatus.Stopped) + if (_player != null && _player.State != PlaybackState.Stopped) { - _player.Stop(); - PlayerStatus = EPlayerStatus.Stopped; + _player?.Stop(); + _audioPlaybackDevice.Stop(); + NowPlaying = null; } } - public void IncreaseVolume() + public void ChangeVolume(float amount) { - // Need to verify what SoundFlow's max volume level is - // For now this should be enough based on testing - _player.Volume = Math.Clamp(_player.Volume + .1f, 0f, 2f); - } - - public void DecreaseVolume() - { - _player.Volume = Math.Clamp(_player.Volume - .1f, 0f, 2f); + if (_player != null) + { + _player.Volume = Math.Clamp(amount, 0f, 2f); + _volume = _player.Volume; + } } public void SeekForward() { - _player.Seek(Math.Clamp(_player.Time + 5f, 0f, _player.Duration - 0.1f)); - } - - public void SeekBackwards() - { - _player.Seek(Math.Clamp(_player.Time - 5f, 0f, _player.Duration)); - } - - public float CurrentTime() - { - return _player.Time; + if (_streamDataProvider is StreamDataProvider && _player != null) + { + if (_player.State is PlaybackState.Playing or PlaybackState.Paused) + { + _player.Seek(Math.Clamp(_player.Time + 5f, 0f, _player.Duration - 0.1f)); + } + } } - public float TrackLength() + public void SeekBackward() { - return _player.Duration; + if (_streamDataProvider is StreamDataProvider && _player != null) + { + if (_player.State is PlaybackState.Playing or PlaybackState.Paused) + { + _player.Seek(Math.Clamp(_player.Time - 5f, 0f, _player.Duration)); + } + } } public void Dispose() { - _soundEngine.Dispose(); + _audioPlaybackDevice.Dispose(); + _streamDataProvider?.Dispose(); + _player?.Dispose(); } } \ No newline at end of file diff --git a/src/AudioPlayer/SoundFlowPlayerStreamConverter.cs b/src/AudioPlayer/SoundFlowPlayerStreamConverter.cs deleted file mode 100644 index 7522321..0000000 --- a/src/AudioPlayer/SoundFlowPlayerStreamConverter.cs +++ /dev/null @@ -1,26 +0,0 @@ -using System.IO; -using System.Net.Http; -using System.Threading.Tasks; - -namespace MusicSharp.AudioPlayer; - -public class SoundFlowPlayerStreamConverter : IStreamConverter -{ - private readonly HttpClient _httpClient; - - public SoundFlowPlayerStreamConverter(HttpClient httpClient) - { - _httpClient = httpClient; - } - - public 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/Data/AudioFile.cs b/src/Data/AudioFile.cs new file mode 100644 index 0000000..657515e --- /dev/null +++ b/src/Data/AudioFile.cs @@ -0,0 +1,11 @@ +using ATL; +using MusicSharp.Enums; + +namespace MusicSharp.Data; + +public class AudioFile(string filepath, EFileType fileType) +{ + public Track TrackInfo { get; } = new(filepath); + public string Path { get; } = filepath; + public EFileType Type { get; } = fileType; +} \ No newline at end of file diff --git a/src/Enums/EFileType.cs b/src/Enums/EFileType.cs index 1ea3943..9b80434 100644 --- a/src/Enums/EFileType.cs +++ b/src/Enums/EFileType.cs @@ -3,5 +3,6 @@ namespace MusicSharp.Enums; public enum EFileType { File, - Stream + Stream, + NotLoaded } \ No newline at end of file diff --git a/src/Enums/EPlayerStatus.cs b/src/Enums/EPlayerStatus.cs deleted file mode 100644 index 0870196..0000000 --- a/src/Enums/EPlayerStatus.cs +++ /dev/null @@ -1,11 +0,0 @@ -namespace MusicSharp.Enums; - -/// -/// The status of the audio player. -/// -public enum EPlayerStatus -{ - Playing, - Paused, - Stopped -} \ No newline at end of file diff --git a/src/MusicSharp.csproj b/src/MusicSharp.csproj index d8bcba9..3eb367c 100644 --- a/src/MusicSharp.csproj +++ b/src/MusicSharp.csproj @@ -2,42 +2,28 @@ Exe - net8.0 - 1.0.0 + net9.0 + true + true + true + osx-x64 + 2.0.0 Mark-James McDougall Mark-James McDougall enable - - MusicSharp.ico - + + - + + + + + - - - - - - - - - - True - True - Resources.resx - - - - - - ResXFileCodeGenerator - Resources.Designer.cs - - - + diff --git a/src/MusicSharp.ico b/src/MusicSharp.ico deleted file mode 100644 index dd2843d..0000000 Binary files a/src/MusicSharp.ico and /dev/null differ diff --git a/src/Playlist/PlaylistHelpers.cs b/src/Playlist/PlaylistHelpers.cs new file mode 100644 index 0000000..52621e1 --- /dev/null +++ b/src/Playlist/PlaylistHelpers.cs @@ -0,0 +1,34 @@ +using System.Collections.Generic; +using System.Linq; +using ATL.Playlist; +using MusicSharp.Data; + +namespace MusicSharp.Playlist; + +public static class PlaylistHelpers +{ + /// + /// Load an M3U playlist. + /// + /// Returns a list of playlist information. + /// The user specified playlist path. + public static List LoadPlaylist(string playlist) + { + var theReader = PlaylistIOFactory.GetInstance().GetPlaylistIO(playlist); + + // Fix space formatting as SoundFlow doesn't support encoded spaces + return theReader.FilePaths.Select(s => s.Replace("%20", " ")).ToList(); + } + + public static void SavePlaylistToFile(string playlistFilePath, List filesForPlaylist) + { + var pls = PlaylistIOFactory.GetInstance().GetPlaylistIO(playlistFilePath); + + foreach (var file in filesForPlaylist) + { + pls.FilePaths.Add(file.Path); + } + + pls.Save(); + } +} \ No newline at end of file diff --git a/src/PlaylistHandlers/PlaylistLoader.cs b/src/PlaylistHandlers/PlaylistLoader.cs deleted file mode 100644 index 071989b..0000000 --- a/src/PlaylistHandlers/PlaylistLoader.cs +++ /dev/null @@ -1,31 +0,0 @@ -// -// Licensed under the GNU GPL v3 License. See LICENSE in the project root for license information. -// - -using System.Collections.Generic; -using System.Linq; -using ATL.Playlist; - -namespace MusicSharp.PlaylistHandlers; - -/// -/// The PlaylistLoader class loads a playlist of a given type. -/// -public static class PlaylistLoader -{ - // This will be used in the future to allow for playlist types beyond M3U. - // public virtual void LoadPlaylist() { } - - /// - /// Load an M3U playlist. - /// - /// Returns a list of playlist information. - /// The user specified playlist path. - public static List LoadPlaylist(string userPlaylist) - { - var theReader = PlaylistIOFactory.GetInstance().GetPlaylistIO(userPlaylist); - - // 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/Program.cs b/src/Program.cs index b803d31..e477e9a 100644 --- a/src/Program.cs +++ b/src/Program.cs @@ -1,31 +1,20 @@ -// -// Licensed under the GNU GPL v3 License. See LICENSE in the project root for license information. -// - -using System.Net.Http; -using MusicSharp.UI; +using MusicSharp.UI; using MusicSharp.AudioPlayer; using SoundFlow.Backends.MiniAudio; -using SoundFlow.Enums; +using Terminal.Gui.App; namespace MusicSharp; -/// -/// Entry Point class. -/// public static class Program { - /// - /// Entry point. - /// public static void Main() { - var soundEngine = new MiniAudioEngine(44100, Capability.Playback); - using IPlayer player = new SoundFlowPlayer(soundEngine); - using var httpClient = new HttpClient(); - IStreamConverter streamConverter = new SoundFlowPlayerStreamConverter(httpClient); - var ui = new Tui(player, streamConverter); - - ui.Start(); + using var audioEngine = new MiniAudioEngine(); + using IPlayer player = new SoundFlowPlayer(audioEngine); + using var ui = new Tui(player); + + Application.Init(); + Application.Run(ui); + Application.Shutdown(); } } \ No newline at end of file diff --git a/src/UI/Tui.cs b/src/UI/Tui.cs index 3d3146a..920f776 100644 --- a/src/UI/Tui.cs +++ b/src/UI/Tui.cs @@ -1,397 +1,1008 @@ -// -// Licensed under the GNU GPL v3 License. See LICENSE in the project root for license information. -// - using System; +using System.Collections; using System.Collections.Generic; -using System.IO; -using MusicSharp.Enums; -using MusicSharp.PlaylistHandlers; +using System.Collections.ObjectModel; +using System.Collections.Specialized; +using System.Linq; +using System.Text; using MusicSharp.AudioPlayer; -using Terminal.Gui; +using MusicSharp.Data; +using MusicSharp.Enums; +using MusicSharp.Playlist; +using SoundFlow.Enums; +using SoundFlow.Structs; +using Terminal.Gui.App; +using Terminal.Gui.Drawing; +using Terminal.Gui.Input; +using Terminal.Gui.Text; +using Terminal.Gui.ViewBase; +using Terminal.Gui.Views; +using Attribute = Terminal.Gui.Drawing.Attribute; namespace MusicSharp.UI; -/// -/// The Gui class houses the CLI elements of MusicSharp. -/// -public class Tui +public class Tui : Toplevel { - private static List _playlistTracks; - private static ListView _playlistView; - private static FrameView _playlistPane; - private static FrameView _playbackControls; - private static FrameView _nowPlaying; - private static StatusBar _statusBar; - private static Label _trackName; - - /// - /// Create a new instance of the audio player engine. - /// private readonly IPlayer _player; - - private object _mainLoopTimeout = null; - - private List _playlist = new List(); - - private readonly IStreamConverter _streamConverter; - - /// - /// Initializes a new instance of the class. - /// - /// The player to be injected. - /// Helper class to convert files and urls to Stream type. - public Tui(IPlayer player, IStreamConverter streamConverter) + private readonly ProgressBar _progressBar; + private readonly Label _nowPlayingLabel; + private readonly Label _timePlayedLabel; + private readonly Button _playPauseButton; + private readonly ListView _playlistView; + private readonly ObservableCollection? _loadedPlaylist = []; + private int _playlistIndex; + private object? _mainLoopTimeout; + + private const uint MainLoopTimeoutTick = 100; // ms + + public Tui(IPlayer player) { _player = player; - _streamConverter = streamConverter; - } - - /// - /// Gets and sets the current audio file play progress. - /// - internal ProgressBar AudioProgressBar { get; private set; } - /// - /// Start the UI. - /// - public void Start() - { - // Creates an instance of MainLoop to process input events, handle timers and other sources of data. - Application.Init(); + #region Menus - var top = Application.Top; - var tframe = top.Frame; - - // Create the menubar. - var menu = new MenuBar(new MenuBarItem[] + var menuBar = new MenuBarv2() { - new MenuBarItem("_File", new MenuItem[] + Title = "MusicSharp", + BorderStyle = LineStyle.Rounded, + Menus = + [ + new MenuBarItemv2( + Title = "_File", + new MenuItemv2[] + { + new("_Open file", "Open audio file", OpenFile, Key.F1), + new("Open _stream", "Open a stream URL", OpenStream, Key.F2), + new("_Quit", "Quit MusicSharp", RequestStop, Key.Esc) + } + ), + new MenuBarItemv2( + Title = "_Playback", + new MenuItemv2[] + { + new("Seek backward", string.Empty, _player.SeekBackward, Key.N.WithCtrl), + new("Seek forward", string.Empty, _player.SeekForward, Key.M.WithCtrl), + new("Previous", string.Empty, SkipBackward, Key.CursorLeft.WithShift), + new("Next", string.Empty, SkipForward, Key.CursorRight.WithShift), + } + ), + new MenuBarItemv2( + Title = "Playlist", + new MenuItemv2[] + { + new("_Add to playlist", "Add track(s) to playlist", AddToPlaylist, Key.A.WithCtrl), + new("_Remove from playlist", "Remove selected track from playlist", RemoveFromPlaylist, + Key.R.WithCtrl), + new("Load _playlist", "Load a playlist", OpenPlaylist, Key.L.WithCtrl), + new("_Save playlist", "Save to playlist", SavePlaylist, Key.S.WithCtrl) + } + ), + new MenuBarItemv2( + Title = "Settings", + new MenuItemv2[] + { + new("Audio device", "Select playback device", SelectAudioDevice, Key.D.WithCtrl), + } + ), + new MenuBarItemv2( + Title = "Help", + new MenuItemv2[] + { + new("_About...", "About MusicSharp", () => MessageBox.Query( + "", + GetAboutMessage(), + wrapMessage: false, + buttons: "_Ok" + ), + Key.I.WithCtrl + ) + } + ), + ] + }; + + var statusBar = new StatusBar([ + new Shortcut + { + Text = "Open file", + Key = Key.F1, + Action = OpenFile + }, + new Shortcut + { + Text = "Open stream", + Key = Key.F2, + Action = OpenStream + }, + new Shortcut { - new MenuItem("_Open", "Open a music file", () => OpenFile()), + Text = "Load playlist", + Key = Key.L.WithCtrl, + Action = OpenPlaylist + }, + new Shortcut + { + Text = "Quit", + Key = Key.Esc, + Action = RequestStop + }, + ]); - new MenuItem("Open S_tream", "Open a music stream", () => OpenStream()), + #endregion Menus - new MenuItem("Open Pla_ylist", "Load a playlist", () => LoadPlaylist()), + _playlistView = new ListView + { + Title = "Playlist", + X = 0, + Y = Pos.Bottom(menuBar), + Width = Dim.Fill(), + Height = Dim.Fill(11), + CanFocus = true, + BorderStyle = LineStyle.Rounded, + Source = new TrackListDataSource(_loadedPlaylist), + AllowsMarking = false, + AllowsMultipleSelection = false + }; + _playlistView.RowRender += PlaylistView_RowRender; + _playlistView.VerticalScrollBar.AutoShow = true; + _playlistView.HorizontalScrollBar.AutoShow = true; + _playlistView.OpenSelectedItem += (sender, args) => + { + if (args.Value != null) + { + _playlistIndex = args.Item; + PlayHandler((AudioFile)args.Value); + } + }; - new MenuItem("_Quit", "Exit MusicSharp", () => Application.RequestStop()), - }), + _progressBar = new ProgressBar() + { + Title = "Progress", + X = 0, + Y = Pos.Bottom(_playlistView), + Width = Dim.Fill(), + Height = 3, + CanFocus = false, + BorderStyle = LineStyle.Rounded, + ProgressBarStyle = ProgressBarStyle.Continuous, + Fraction = 0f, + }; - new MenuBarItem("_Help", new MenuItem[] - { - new MenuItem("_About MusicSharp", string.Empty, () => - { - 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"); - }), - }), - }); + #region PlayBackControls - _statusBar = new StatusBar(new StatusItem[] + var playbackControls = new View() { - new StatusItem(Key.F1, "~F1~ Open file", () => OpenFile()), - new StatusItem(Key.F2, "~F2~ Open stream", () => OpenStream()), - new StatusItem(Key.F3, "~F3~ Load playlist", () => LoadPlaylist()), - new StatusItem(Key.F4, "~F4~ Quit", () => Application.RequestStop()), - new StatusItem(Key.Space, "~Space~ Play/Pause", () => PlayPause()), - }); + Title = "Playback", + X = 0, + Y = Pos.Bottom(_progressBar), + Width = Dim.Auto(), + Height = Dim.Auto(), + CanFocus = true, + BorderStyle = LineStyle.Rounded + }; - // Create the playback controls frame. - _playbackControls = new FrameView("Playback") + var seekBackwardButton = new Button { X = 0, - Y = 24, - Width = 55, - Height = 5, + Y = 0, CanFocus = true, + Text = "<<" + }; + seekBackwardButton.Accepting += (s, e) => + { + _player.SeekBackward(); + e.Handled = true; }; - var playPauseButton = new Button(1, 1, "Play/Pause"); - playPauseButton.Clicked += () => + _playPauseButton = new Button + { + X = Pos.Right(seekBackwardButton), + Y = 0, + CanFocus = true, + Text = "▶" + }; + _playPauseButton.Accepting += (s, e) => { - PlayPause(); + var selected = _playlistView.SelectedItem; + _playlistIndex = selected; + var selectedTrack = _loadedPlaylist?.ElementAtOrDefault(selected); + + if (selectedTrack != null) + { + PlayHandler(selectedTrack); + } - if (_player.PlayerStatus != EPlayerStatus.Stopped) + if (selectedTrack == null && _player.NowPlaying != null) { - UpdateProgressBar(); + PlayHandler(_player.NowPlaying); } + + e.Handled = true; }; - var stopButton = new Button(16, 1, "Stop"); - stopButton.Clicked += () => + var seekForwardButton = new Button { - _player.Stop(); - AudioProgressBar.Fraction = 0F; - TimePlayedLabel(); + X = Pos.Right(_playPauseButton), + Y = 0, + CanFocus = true, + Text = ">>" }; - - var seekForward = new Button(26, 0, "Seek 5s"); - seekForward.Clicked += () => + seekForwardButton.Accepting += (s, e) => { _player.SeekForward(); + e.Handled = true; }; - var seekBackward = new Button(26, 2, "Seek -5s"); - seekBackward.Clicked += () => + var stopButton = new Button { - _player.SeekBackwards(); + X = Pos.Right(seekForwardButton), + Y = 0, + CanFocus = true, + Text = "⏹︎", }; - - var increaseVolumeButton = new Button(39, 0, "+ Volume"); - increaseVolumeButton.Clicked += () => + stopButton.Accepting += (s, e) => { - _player.IncreaseVolume(); + _player.Stop(); + _progressBar.Fraction = 0; + TimePlayedLabel(); + _nowPlayingLabel!.Text = string.Empty; + _playPauseButton.Text = "▶"; + e.Handled = true; }; - var decreaseVolumeButton = new Button(39, 2, "- Volume"); - decreaseVolumeButton.Clicked += () => + // Verify what SoundFlow's max volume level is + // For now this should be enough based on testing + List volumeOptions = + [ + 0f, .1f, .2f, .4f, .6f, .8f, 1.0f, 1.2f, 1.4f, 1.6f, 1.8f, 2.0f + ]; + + var volumeSlider = new Slider(volumeOptions) { - _player.DecreaseVolume(); + Title = "Volume", + X = 0, + Y = Pos.Bottom(_playPauseButton), + Width = Dim.Fill(), + Height = Dim.Auto(), + Type = SliderType.LeftRange, + AllowEmpty = false, + ShowLegends = false, + BorderStyle = LineStyle.Rounded, }; + volumeSlider.SetOption(6); + volumeSlider.OptionsChanged += (s, e) => + { + var value = e.Options.FirstOrDefault().Value; + var volumeLevel = (float)value.Data; + _player.ChangeVolume(volumeLevel); + }; + + playbackControls.Add(_playPauseButton, stopButton, seekForwardButton, seekBackwardButton, + volumeSlider); - _playbackControls.Add(playPauseButton, stopButton, increaseVolumeButton, decreaseVolumeButton, seekBackward, seekForward); + #endregion - // Create the left-hand playlists view. - _playlistPane = new FrameView("Playlist Tracks") + #region PlaybackInfo + + var nowPlayingView = new View { - X = 0, - Y = 1, // for menu + Title = "Now playing", + X = Pos.Right(playbackControls), + Y = Pos.Bottom(_progressBar), Width = Dim.Fill(), - Height = 23, - CanFocus = false, + Height = Dim.Height(playbackControls), + BorderStyle = LineStyle.Rounded, }; - // The list of tracks in the playlist. - _playlistTracks = new List(); - - _playlistView = new ListView(_playlistTracks) + _nowPlayingLabel = new Label { + Text = string.Empty, X = 0, Y = 0, Width = Dim.Fill(), - Height = 23, - AllowsMarking = false, - CanFocus = true, + Height = Dim.Fill(), + TextDirection = TextDirection.LeftRight_TopBottom }; + _nowPlayingLabel.TextFormatter.WordWrap = true; - // Play the selection when a playlist path is clicked. - _playlistView.OpenSelectedItem += (a) => + _timePlayedLabel = new Label { - try - { - _player.LastFileOpened = a.Value.ToString(); - _player.Play(_streamConverter.ConvertFileToStream(a.Value.ToString())); - NowPlaying(_player.LastFileOpened); - UpdateProgressBar(); - } - catch (FileNotFoundException ex) - { - MessageBox.Query("Warning", "Invalid file path.", "Close"); - } + Text = "00:00 / 00:00", + X = Pos.Align(Alignment.End), + Y = Pos.Align(Alignment.End), }; - _playlistPane.Add(_playlistView); + nowPlayingView.Add(_nowPlayingLabel, _timePlayedLabel); - // Create the audio progress bar frame. - _nowPlaying = new FrameView("Now Playing") + // Add the views to the main window + Add(menuBar, _playlistView, _progressBar, playbackControls, nowPlayingView, statusBar); + } + + #endregion + + #region PlaybackMethods + + private void PlayHandler(AudioFile audioFile) + { + _player.PlayPause(audioFile); + + _playPauseButton.Text = _player.State switch { - X = 55, - Y = 24, - Width = Dim.Fill(), - Height = 5, - CanFocus = false, + PlaybackState.Stopped => "▶", + PlaybackState.Playing => "⏸︎", + PlaybackState.Paused => "▶", + _ => _playPauseButton.Text }; - AudioProgressBar = new ProgressBar() + if (_player.State == PlaybackState.Playing) { - X = 0, - Y = 2, - Width = Dim.Fill() - 15, - Height = 1, - ColorScheme = Colors.Base, - }; + _nowPlayingLabel.Text = audioFile.Type switch + { + EFileType.File => + $"{(string.IsNullOrWhiteSpace(audioFile.TrackInfo.Title) ? "Unknown" : audioFile.TrackInfo.Title)} - " + + $"{(string.IsNullOrWhiteSpace(audioFile.TrackInfo.Artist) ? "Unknown" : audioFile.TrackInfo.Artist)} - " + + $"{(string.IsNullOrWhiteSpace(audioFile.TrackInfo.Album) ? "Unknown" : audioFile.TrackInfo.Album)}", + EFileType.Stream => $"Web stream: {audioFile.Path}", + _ => _nowPlayingLabel.Text + }; - _nowPlaying.Add(AudioProgressBar); + RunMainLoop(); + } + } - // Add the layout elements and run the app. - top.Add(menu, _playlistPane, _playbackControls, _nowPlaying, _statusBar); + private void AutoPlayNextTrack() + { + if (Math.Abs(_player.TrackLength - _player.CurrentTime) < 0.5f) + { + if (_playlistIndex + 1 < _loadedPlaylist?.Count) + { + var nextTrack = _loadedPlaylist.ElementAtOrDefault(_playlistIndex + 1); - Application.Run(); + if (nextTrack != null) + { + PlayHandler(nextTrack); + _playlistIndex++; + _playlistView.SelectedItem = _playlistIndex; + } + } + } } - private void PlayPause() + private void SkipForward() { - try + if (_playlistIndex + 1 < _loadedPlaylist?.Count) { - _player.PlayPause(); + var nextTrack = _loadedPlaylist?.ElementAtOrDefault(_playlistIndex + 1); - if (_player.PlayerStatus == EPlayerStatus.Playing) + if (nextTrack != null) { - UpdateProgressBar(); + PlayHandler(nextTrack); + _playlistIndex++; + _playlistView.SelectedItem = _playlistIndex; } } - catch (Exception) + } + + private void SkipBackward() + { + if (_playlistIndex - 1 >= 0) { - MessageBox.Query("Warning", "Select a file or stream first.", "Close"); + var nextTrack = _loadedPlaylist?.ElementAtOrDefault(_playlistIndex - 1); + + if (nextTrack != null) + { + PlayHandler(nextTrack); + _playlistIndex--; + _playlistView.SelectedItem = _playlistIndex; + } } } + + #endregion + + #region OpenMethods - // Display a file open dialog and return the path of the user selected file. private void OpenFile() { - var d = new OpenDialog("Open", "Open an audio file") { AllowsMultipleSelection = false }; - - d.DirectoryPath = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + var d = new OpenDialog() + { + AllowsMultipleSelection = false, + Title = "Open an audio file", + AllowedTypes = [new AllowedType("Allowed filetypes", ".mp3", ".flac", ".wav")] + }; - // This will filter the dialog on basis of the allowed file types in the array. - d.AllowedFileTypes = [".mp3", ".wav", ".flac"]; Application.Run(d); if (!d.Canceled) { - if (File.Exists(d.FilePath.ToString())) - { - try - { - _player.LastFileOpened = d.FilePath.ToString(); - var stream = _streamConverter.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"); - } - } + var audioFile = new AudioFile(d.FilePaths[0], EFileType.File); + PlayHandler(audioFile); } } - // Open and play an audio stream. private void OpenStream() { - var d = new Dialog("Open Stream", 50, 15); + var streamDialog = new Dialog + { + Title = "Open an audio stream", + }; - var editLabel = new Label("Enter the url of the audio stream to load\n (.mp3 streams only):") + var uriLabel = new Label { - X = 0, + Text = "Enter the stream URI", + X = Pos.Center(), Y = 0, - Width = Dim.Fill(), + Width = Dim.Auto(), }; - var streamUrl = new TextField(string.Empty) + var streamUrl = new TextField { - X = 3, - Y = 4, - Width = 42, + X = Pos.Center(), + Y = Pos.Bottom(uriLabel), + Width = Dim.Fill(), + BorderStyle = LineStyle.Rounded, }; - var loadStream = new Button(12, 7, "Load Stream"); - loadStream.Clicked += async () => + var loadStreamButton = new Button + { + Text = "Open stream", + X = Pos.Center(), + Y = Pos.Bottom(streamUrl), + }; + loadStreamButton.Accepting += (s, e) => { - try + if (streamUrl.Text != string.Empty) { - var stream = await _streamConverter.ConvertUrlToStream(streamUrl.Text.ToString()); - _player.Play(stream); + var audioFile = new AudioFile(streamUrl.Text, EFileType.Stream); + PlayHandler(audioFile); } - catch (Exception ex) + + e.Handled = true; + RequestStop(); + }; + + var closeButton = new Button + { + Text = "Close", + X = Pos.Right(loadStreamButton), + Y = Pos.Bottom(streamUrl) + }; + closeButton.Accepting += (s, e) => + { + e.Handled = true; + RequestStop(); + }; + + streamDialog.Add(uriLabel, streamUrl, loadStreamButton, closeButton); + Application.Run(streamDialog); + } + + #endregion + + private void RunMainLoop() + { + _mainLoopTimeout = Application.AddTimeout( + TimeSpan.FromMilliseconds(MainLoopTimeoutTick), + () => { - MessageBox.Query("Warning", "Invalid URL.", "Close"); + while (_player.CurrentTime < _player.TrackLength && _player.State != PlaybackState.Stopped) + { + _progressBar.Fraction = _player.CurrentTime / _player.TrackLength; + TimePlayedLabel(); + return true; + } + + AutoPlayNextTrack(); + return false; } + ); + } + + #region PlaylistMethods + + private void AddToPlaylist() + { + var d = new OpenDialog() + { + AllowsMultipleSelection = true, + Title = "Add tracks to playlist", + AllowedTypes = [new AllowedType("Allowed filetypes", ".mp3", ".flac", ".wav")] }; - var cancelStream = new Button(29, 7, "Close"); - cancelStream.Clicked += () => + Application.Run(d); + + if (!d.Canceled) + { + foreach (var filepath in d.FilePaths) + { + var track = new AudioFile(filepath, EFileType.File); + _loadedPlaylist?.Add(track); + } + } + } + + private void RemoveFromPlaylist() + { + var s = _playlistView.SelectedItem; + _loadedPlaylist?.RemoveAt(s); + } + + private void OpenPlaylist() + { + var d = new OpenDialog() { - Application.RequestStop(); + AllowsMultipleSelection = false, + Title = "Open a playlist", + AllowedTypes = [new AllowedType("Allowed filetypes", ".m3u")] }; - d.AddButton(loadStream); - d.AddButton(cancelStream); - d.Add(editLabel, streamUrl); Application.Run(d); + + if (!d.Canceled) + { + var playlist = PlaylistHelpers.LoadPlaylist(d.FilePaths[0]); + + foreach (var track in playlist.Select(filepath => new AudioFile(filepath, EFileType.File))) + { + _loadedPlaylist?.Add(track); + } + } } - // Load a playlist file. Currently, only M3U is supported. - private void LoadPlaylist() + private void SavePlaylist() { - var d = new OpenDialog("Open", "Open a playlist") { AllowsMultipleSelection = false }; + var d = new SaveDialog + { + AllowsMultipleSelection = false, + AllowedTypes = [new AllowedType("Allowed filetypes", ".m3u")], + Title = "Save playlist in M3U format" + }; - // This will filter the dialog on basis of the allowed file types in the array. - d.AllowedFileTypes = [".m3u"]; Application.Run(d); if (!d.Canceled) { - _playlist = PlaylistLoader.LoadPlaylist(d.FilePath.ToString()); + if (_loadedPlaylist != null) + { + var currentTracks = _loadedPlaylist.ToList(); + PlaylistHelpers.SavePlaylistToFile(d.FileName, currentTracks); + } + } + } + + private void PlaylistView_RowRender(object? sender, ListViewRowEventArgs obj) + { + if (obj.Row == _playlistView.SelectedItem) + { + obj.RowAttribute = new Attribute(Color.White, Color.Blue); + + return; + } + + if (_playlistView.AllowsMarking && _playlistView.Source.IsMarked(obj.Row)) + { + obj.RowAttribute = new Attribute(Color.Black, Color.White); + + return; + } - if (_playlist == null) + if (obj.Row % 2 == 0) + { + obj.RowAttribute = new Attribute(Color.Green, Color.Black); + } + else + { + obj.RowAttribute = new Attribute(Color.Black, Color.Green); + } + } + + #endregion + + private void TimePlayedLabel() + { + if (_player.State != PlaybackState.Stopped) + { + if (_player.TrackLength > 3599) { - Application.RequestStop(); + 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"); + + _timePlayedLabel.Text = $"{timePlayed} / {trackLength}"; } + else { - foreach (var track in _playlist) - { - _playlistTracks.Add(track); - } + var timePlayed = TimeSpan.FromSeconds((double)new decimal(_player.CurrentTime)).ToString(@"mm\:ss"); + var trackLength = TimeSpan.FromSeconds((double)new decimal(_player.TrackLength)).ToString(@"mm\:ss"); - Application.Run(); + _timePlayedLabel.Text = $"{timePlayed} / {trackLength}"; } } + + else + { + _timePlayedLabel.Text = "00:00 / 00:00"; + } } - private static void NowPlaying(string track) + private void SelectAudioDevice() { - _trackName = new Label(track) + var deviceDialog = new Dialog + { + Title = "Select Audio Device", + X = Pos.Center(), + Y = Pos.Center(), + Width = Dim.Auto(), + Height = Dim.Auto() + }; + + var audioDeviceList = new ObservableCollection(); + + foreach (var device in _player.PlaybackDevices) + { + audioDeviceList.Add(device); + } + + var audioDeviceListView = new ListView() { + Title = "Audio Devices", X = 0, Y = 0, - Width = Dim.Fill(), + Width = Dim.Auto() + 10, + Height = Dim.Auto(), + Source = new AudioDeviceListDataSource(audioDeviceList), + AllowsMarking = true, + AllowsMultipleSelection = false }; + + var setAudioDeviceButton = new Button + { + Text = "Select", + X = 2, + Y = Pos.Bottom(audioDeviceListView) + 1 + }; + setAudioDeviceButton.Accepting += (s, e) => + { + var selectedDevice = audioDeviceList.ElementAt(audioDeviceListView.SelectedItem); + _player.ChangePlaybackDevice(selectedDevice); + + e.Handled = true; + RequestStop(); + }; + + var closeButton = new Button + { + Text = "Close", + X = Pos.Right(setAudioDeviceButton), + Y = Pos.Bottom(audioDeviceListView) + 1 + }; + closeButton.Accepting += (s, e) => + { + e.Handled = true; + RequestStop(); + }; + + deviceDialog.Add(audioDeviceListView, setAudioDeviceButton, closeButton); + Application.Run(deviceDialog); + } - _nowPlaying.Add(_trackName); + private static string GetAboutMessage() + { + var sb = new StringBuilder(); + sb.AppendLine(""" + __ ___ _ _____ __ + / |/ /_ _______(_)____/ ___// /_ ____ __________ + / /|_/ / / / / ___/ / ___/\__ \/ __ \/ __ `/ ___/ __ \ + / / / / /_/ (__ ) / /__ ___/ / / / / /_/ / / / /_/ / + /_/ /_/\__,_/____/_/\___//____/_/ /_/\__,_/_/ / .___/ + /_/ + """); + sb.AppendLine(); + sb.AppendLine("MusicSharp v2.0.0"); + sb.AppendLine("Created by Mark-James M."); + + return sb.ToString(); } - private void TimePlayedLabel() + #region IListDataSource + + private class AudioDeviceListDataSource : IListDataSource { - if (_player.PlayerStatus != EPlayerStatus.Stopped) + private int _count; + private BitArray _marks; + private ObservableCollection? _audioDeviceList; + private ObservableCollection? AudioDeviceList { - 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}") + get => _audioDeviceList; + set { - X = Pos.Right(AudioProgressBar), - Y = 2, - }; + if (value != null) + { + _count = value.Count; + _marks = new BitArray(_count); + _audioDeviceList = value; + Length = GetMaxLengthItem(); + } + } } - else + + public AudioDeviceListDataSource(ObservableCollection audioDeviceList) + { + AudioDeviceList = audioDeviceList; + } + + public bool IsMarked(int item) { - _trackName = new Label($"00:00 / 00:00") + if (item >= 0 && item < _count) { - X = Pos.Right(AudioProgressBar), - Y = 2, - }; + return _marks[item]; + } + + return false; + } + + public void Render( + ListView container, + bool selected, + int item, + int col, + int line, + int width, + int start = 0 + ) + { + container.Move(col, line); + var audioDevice = AudioDeviceList?[item].Name; + RenderUstr(container, $"{audioDevice}", col, line, width, start); + } + + // A slightly adapted method from: https://github.com/gui-cs/Terminal.Gui/blob/fc1faba7452ccbdf49028ac49f0c9f0f42bbae91/Terminal.Gui/Views/ListView.cs#L433-L461 + private static void RenderUstr(View view, string ustr, int col, int line, int width, int start = 0) + { + var used = 0; + var index = start; + + while (index < ustr.Length) + { + var (rune, size) = ustr.DecodeRune(index, index - ustr.Length); + var count = rune.GetColumns(); + + if (used + count >= width) + { + break; + } + + view.AddRune(rune); + used += count; + index += size; + } + + while (used < width) + { + view.AddRune((Rune)' '); + used++; + } + } + + private int GetMaxLengthItem() + { + if (_audioDeviceList?.Count == 0) + { + return 0; + } + + var maxLength = 0; + + for (var i = 0; i < _audioDeviceList.Count; i++) + { + var trackTitle = AudioDeviceList?[i].Name; + + var sc = $"{trackTitle}"; + var l = sc.Length; + + if (l > maxLength) + { + maxLength = l; + } + } + + return maxLength; } - _nowPlaying.Add(_trackName); + public void SetMark(int item, bool value) + { + if (item >= 0 && item < _count) + { + _marks[item] = value; + } + } + + public IList ToList() + { + return AudioDeviceList; + } + + public int Count => AudioDeviceList?.Count ?? 0; + public int Length { get; private set; } + public bool SuspendCollectionChangedEvent { get; set; } + +#pragma warning disable CS0067 + public event NotifyCollectionChangedEventHandler? CollectionChanged; +#pragma warning restore CS0067 + + public void Dispose() + { + _audioDeviceList = null; + } } - private void UpdateProgressBar() + private class TrackListDataSource : IListDataSource { - _mainLoopTimeout = Application.MainLoop.AddTimeout(TimeSpan.FromSeconds(1), (updateTimer) => + private const int TitleColumnWidth = 40; + private const int ArtistColumnWidth = 30; + private const int AlbumColumnWidth = 40; + private int _count; + private BitArray _marks; + private ObservableCollection? _loadedPlaylist; + private ObservableCollection? AudioFiles { - while (_player.CurrentTime() < _player.TrackLength() && _player.PlayerStatus is not EPlayerStatus.Stopped) + get => _loadedPlaylist; + set { - AudioProgressBar.Fraction = _player.CurrentTime() / _player.TrackLength(); - TimePlayedLabel(); + if (value != null) + { + _count = value.Count; + _marks = new BitArray(_count); + _loadedPlaylist = value; + Length = GetMaxLengthItem(); + } + } + } + + public TrackListDataSource(ObservableCollection? audioFiles) + { + AudioFiles = audioFiles; + } - return true; + public bool IsMarked(int item) + { + if (item >= 0 && item < _count) + { + return _marks[item]; } return false; - }); + } + +#pragma warning disable CS0067 + public event NotifyCollectionChangedEventHandler CollectionChanged; +#pragma warning restore CS0067 + + public int Count => AudioFiles?.Count ?? 0; + public int Length { get; private set; } + + public bool SuspendCollectionChangedEvent + { + get => throw new NotImplementedException(); + set => throw new NotImplementedException(); + } + + public void Render( + ListView container, + bool selected, + int item, + int col, + int line, + int width, + int start = 0 + ) + { + container.Move(col, line); + + // Equivalent to an interpolated string like $"{AudioFiles[item].Name, -widtestname}"; if it were possible + var trackTitle = string.Format( + string.Format("{{0,{0}}}", -TitleColumnWidth), + AudioFiles?[item].TrackInfo.Title + ); + + var artist = string.Format( + string.Format("{{0,{0}}}", -ArtistColumnWidth), + AudioFiles?[item].TrackInfo.Artist + ); + + var album = string.Format( + string.Format("{{0,{0}}}", -AlbumColumnWidth), + AudioFiles?[item].TrackInfo.Album + ); + + RenderUstr(container, $"{trackTitle} {artist} {album}", col, line, width, start); + } + + public void SetMark(int item, bool value) + { + if (item >= 0 && item < _count) + { + _marks[item] = value; + } + } + + public IList ToList() + { + return AudioFiles; + } + + private int GetMaxLengthItem() + { + if (_loadedPlaylist?.Count == 0) + { + return 0; + } + + var maxLength = 0; + + for (var i = 0; i < _loadedPlaylist.Count; i++) + { + var trackTitle = string.Format( + $"{{0,{-TitleColumnWidth}}}", + AudioFiles?[i].TrackInfo.Title + ); + + var artist = string.Format( + $"{{0,{-ArtistColumnWidth}}}", + AudioFiles?[i].TrackInfo.Artist + ); + + var album = string.Format( + $"{{0,{-AlbumColumnWidth}}}", + AudioFiles?[i].TrackInfo.Album + ); + + var sc = $"{trackTitle} {artist} {album}"; + var l = sc.Length; + + if (l > maxLength) + { + maxLength = l; + } + } + + return maxLength; + } + + // A slightly adapted method from: https://github.com/gui-cs/Terminal.Gui/blob/fc1faba7452ccbdf49028ac49f0c9f0f42bbae91/Terminal.Gui/Views/ListView.cs#L433-L461 + private static void RenderUstr(View view, string ustr, int col, int line, int width, int start = 0) + { + var used = 0; + var index = start; + + while (index < ustr.Length) + { + var (rune, size) = ustr.DecodeRune(index, index - ustr.Length); + var count = rune.GetColumns(); + + if (used + count >= width) + { + break; + } + + view.AddRune(rune); + used += count; + index += size; + } + + while (used < width) + { + view.AddRune((Rune)' '); + used++; + } + } + + public void Dispose() + { + _loadedPlaylist = null; + } } + + #endregion } \ No newline at end of file diff --git a/src/stylecop.json b/src/stylecop.json deleted file mode 100644 index 42def37..0000000 --- a/src/stylecop.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - // ACTION REQUIRED: This file was automatically added to your project, but it - // will not take effect until additional steps are taken to enable it. See the - // following page for additional information: - // - // https://github.com/DotNetAnalyzers/StyleCopAnalyzers/blob/master/documentation/EnableConfiguration.md - - "$schema": "https://raw.githubusercontent.com/DotNetAnalyzers/StyleCopAnalyzers/master/StyleCop.Analyzers/StyleCop.Analyzers/Settings/stylecop.schema.json", - "settings": { - "documentationRules": { - "companyName": "Mark-James McDougall", - "copyrightText": "Licensed under the GNU GPL v3 License. See LICENSE in the project root for license information.", - "headerDecoration": "-----------------------------------------------------------------------" - } - } -}