32 Commits

Author SHA1 Message Date
3b55c79d30 Merge branch 'main' into shuffle 2022-11-03 22:20:37 +01:00
b33ae3b9be Update publish profile 2022-11-03 22:20:21 +01:00
67667c74da Update CHANGELOG to 1.3.1 2022-11-03 02:03:37 +01:00
8033aebb23 Restore auto-reconnect 2022-11-03 00:39:03 +01:00
0898d5cf11 Fix connection change not working 2022-11-03 00:19:35 +01:00
cb741349fa Ugly patch to avoid exception in GetAlbumCover 2022-10-29 23:46:43 +02:00
cabfb165da Update .NET version to 6.0 2022-10-29 22:44:41 +02:00
5142477b5e GetPlaylist more cleverly 2022-04-19 01:17:48 +02:00
a6b7ad9c1e Merge branch 'main' into shuffle 2022-04-19 00:23:59 +02:00
b074a4e975 Cover icon when a radio is playing 2022-04-18 23:37:49 +02:00
53d7668fd6 Disable Snapcast and Radios if disconnected 2022-04-18 19:22:12 +02:00
124b263499 Disable radios if not connected to MPD 2022-04-18 01:20:43 +02:00
3f74b1a4e7 Fix could not connect with keyboard in password textbox 2022-04-18 01:20:02 +02:00
1d3515a39d Cleaned things for shuffle, but still has some deadlocks and crashes 2022-04-18 01:02:26 +02:00
792437b839 Update readme and screenshots 2022-04-17 16:04:28 +02:00
5bfa7d3b5b Basic shuffle features, must fix deadlocks 2022-04-17 16:02:09 +02:00
c5e8534af7 Merge branch 'main' into shuffle 2022-04-14 01:08:49 +02:00
5d6e3b6d1e Update MpcNET from 1.3 to 1.4 to use filters 2022-04-14 01:06:33 +02:00
ded5908ca2 Spanish translation 2022-04-13 14:07:09 +02:00
519fe4968e MPD password support 2022-04-13 00:43:04 +02:00
f95b884d16 Fix Radios crash 2022-04-12 23:14:13 +02:00
4136c13d5b Add support for readpicture, aka embedded cover art 2022-04-11 12:37:12 +02:00
0ab1afc2f8 More resilient handle of disconnection and reconnection 2022-04-10 02:25:41 +02:00
3b59e51368 Fix Snapcast not working with hostname 2022-04-08 01:39:59 +02:00
c7a93c2d82 Add CHANGELOG file 2022-04-07 13:41:46 +02:00
43350aed36 Trim album release date 2022-04-07 13:41:18 +02:00
6ad4d9c813 Merge branch 'main' into shuffle 2022-04-07 00:59:00 +02:00
c93a9a326e Better working continuous shuffle 2021-10-07 22:14:09 +02:00
c055c59de7 Handle no response when querying stats 2021-10-07 22:13:18 +02:00
4c71d6a6e0 Rough working shuffle system 2021-10-06 22:54:55 +02:00
3685c369b4 Merge branch 'main' into shuffle 2021-10-05 19:20:00 +02:00
e0d640532c Shuffle window (only visual) 2021-10-05 14:23:23 +02:00
32 changed files with 1569 additions and 303 deletions

View File

@ -9,12 +9,15 @@ namespace unison
private TaskbarIcon _systray;
private HotkeyHandler _hotkeys;
private SnapcastHandler _snapcast;
private ShuffleHandler _shuffle;
private MPDHandler _mpd;
protected override void OnStartup(StartupEventArgs e)
{
//debug language
//unison.Resources.Resources.Culture = CultureInfo.GetCultureInfo("fr-FR");
//unison.Resources.Resources.Culture = CultureInfo.GetCultureInfo("es-ES");
base.OnStartup(e);
@ -27,6 +30,9 @@ namespace unison
_snapcast = new SnapcastHandler();
Current.Properties["snapcast"] = _snapcast;
_shuffle = new ShuffleHandler();
Current.Properties["shuffle"] = _shuffle;
Current.MainWindow = new MainWindow();
_systray = (TaskbarIcon)FindResource("SystrayTaskbar");

51
CHANGELOG.md Normal file
View File

@ -0,0 +1,51 @@
# Changelog
## v1.3.1
*Released: 03/11/2022*
* Update .NET version from 5.0 to 6.0
* Fix: simple patch to avoid a crash concerning GetAlbumCover
* Fix: connection change now working
## v1.3
*Released: 18/04/2022*
* New feature: add support for readpicture, aka embedded cover art
* New feature: add support for MPD password
* Spanish translation
* Trim album release date
* Cover icon when a radio is playing
* Update MpcNET package from 1.3 to 1.4
* Fix: Snapcast not working with hostname
* Fix: some radios crash
* Fix: disable/enable radios and Snapcast with connection
## v1.2
*Released: 07/04/2022*
* New feature: support for custom shortcuts
* New feature: MPD stats
* Add GitHub repository link in settings
* MpcNET NuGet package integration
* Update Snapcast from v0.25 to v0.26
* Fix: hostname supported for connection
* Fix: crash when using invalid IP or Hostname
* Fix: crash when no internet connection when using RadioBrowser
## v1.1
*Released: 04/10/2021*
* Radio browser
* Mute shortcut
* Enter key works in settings textboxes
* Share current song by double-clicking
## v1.0
*Released: 03/09/2021*
* First release of unison

View File

@ -2,6 +2,7 @@
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Net;
using System.Text.RegularExpressions;
using System.Threading;
@ -34,7 +35,7 @@ namespace unison
public class MPDHandler
{
private bool _connected;
public string _version;
private string _version;
private int _currentVolume;
private int _previousVolume;
private bool _currentRandom;
@ -43,6 +44,7 @@ namespace unison
private bool _currentConsume;
private double _currentTime;
private double _totalTime;
private IEnumerable<IMpdFile> _Playlist;
private MpdStatus _currentStatus;
private IMpdFile _currentSong;
@ -54,7 +56,7 @@ namespace unison
bool _isUpdatingStatus = false;
bool _isUpdatingSong = false;
bool _invalidIp = false;
public IPAddress _ipAddress;
private event EventHandler ConnectionChanged;
private event EventHandler StatusChanged;
@ -64,19 +66,19 @@ namespace unison
private MpcConnection _connection;
private MpcConnection _commandConnection;
private IPEndPoint _mpdEndpoint;
private CancellationTokenSource cancelToken;
private CancellationTokenSource _cancelCommand;
private CancellationTokenSource _cancelConnect;
public MPDHandler()
{
cancelToken = new CancellationTokenSource();
Initialize(null, null);
Startup(null, null);
_stats = new Statistics();
_retryTimer = new DispatcherTimer();
_retryTimer.Interval = TimeSpan.FromSeconds(5);
_retryTimer.Tick += Initialize;
_retryTimer.Tick += Startup;
_elapsedTimer = new System.Timers.Timer(500);
_elapsedTimer.Elapsed += new System.Timers.ElapsedEventHandler(ElapsedTimer);
@ -97,7 +99,7 @@ namespace unison
void OnConnectionChanged(object sender, EventArgs e)
{
if (!_connected && !_invalidIp)
if (!_connected)
_retryTimer.Start();
else
_retryTimer.Stop();
@ -149,7 +151,7 @@ namespace unison
public async Task<T> SafelySendCommandAsync<T>(IMpcCommand<T> command)
{
if (_commandConnection == null)
if (_commandConnection == null || !IsConnected())
{
Trace.WriteLine("[SafelySendCommandAsync] no command connection");
return default(T);
@ -160,7 +162,7 @@ namespace unison
IMpdMessage<T> response = await _commandConnection.SendAsync(command);
if (!response.IsResponseValid)
{
var mpdError = response.Response?.Result?.MpdError;
string mpdError = response.Response?.Result?.MpdError;
if (mpdError != null && mpdError != "")
throw new Exception(mpdError);
else
@ -177,27 +179,62 @@ namespace unison
return default(T);
}
private void Initialize(object sender, EventArgs e)
public async void Startup(object sender, EventArgs e)
{
if (!_connected)
Connect();
await Initialize();
}
public async void Connect()
public async Task Initialize()
{
CancellationToken token = cancelToken.Token;
Trace.WriteLine("Initializing");
Disconnected();
if (!_connected)
await Connect();
}
public void Disconnected()
{
_connected = false;
ConnectionChanged?.Invoke(this, EventArgs.Empty);
_commandConnection?.DisconnectAsync();
_connection?.DisconnectAsync();
_cancelConnect?.Cancel();
_cancelConnect = new CancellationTokenSource();
_cancelCommand?.Cancel();
_cancelCommand = new CancellationTokenSource();
_connection = null;
_commandConnection = null;
Trace.WriteLine("Disconnected");
}
public async Task Connect()
{
Trace.WriteLine("Connecting");
if (_cancelCommand.IsCancellationRequested || _cancelConnect.IsCancellationRequested)
return;
try
{
_connection = await ConnectInternal(token);
_commandConnection = await ConnectInternal(token);
_connection = await ConnectInternal(_cancelConnect.Token);
_commandConnection = await ConnectInternal(_cancelCommand.Token);
}
catch(MpcNET.Exceptions.MpcConnectException)
catch (MpcNET.Exceptions.MpcConnectException e)
{
_invalidIp = true;
_connected = false;
Trace.WriteLine($"Error in connect: {e.Message}");
ConnectionChanged?.Invoke(this, EventArgs.Empty);
return;
}
if (_connection != null && _commandConnection != null)
{
_invalidIp = false;
if (_connection.IsConnected && _commandConnection.IsConnected)
{
_connected = true;
@ -207,6 +244,7 @@ namespace unison
}
else
{
_connected = false;
ConnectionChanged?.Invoke(this, EventArgs.Empty);
return;
}
@ -214,21 +252,29 @@ namespace unison
await UpdateStatusAsync();
await UpdateSongAsync();
Loop(token);
Loop(_cancelCommand.Token);
}
private async Task<MpcConnection> ConnectInternal(CancellationToken token)
{
IPAddress.TryParse(Properties.Settings.Default.mpd_host, out IPAddress ipAddress);
if (token.IsCancellationRequested)
return null;
if (ipAddress == null)
IPAddress.TryParse(Properties.Settings.Default.mpd_host, out _ipAddress);
if (_ipAddress == null)
{
IPAddress[] addrList;
try
{
addrList = Dns.GetHostAddresses(Properties.Settings.Default.mpd_host);
IPAddress[] addrList = Dns.GetHostAddresses(Properties.Settings.Default.mpd_host);
if (addrList.Length > 0)
ipAddress = addrList[0];
{
foreach (IPAddress addr in addrList)
{
if (addr.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork)
_ipAddress = addr;
}
}
}
catch (Exception)
{
@ -236,11 +282,11 @@ namespace unison
}
}
_mpdEndpoint = new IPEndPoint(ipAddress, Properties.Settings.Default.mpd_port);
_mpdEndpoint = new IPEndPoint(_ipAddress, Properties.Settings.Default.mpd_port);
MpcConnection connection = new MpcConnection(_mpdEndpoint);
await connection.ConnectAsync(token);
/*if (!string.IsNullOrEmpty(Properties.Settings.Default.mpd_password))
if (!string.IsNullOrEmpty(Properties.Settings.Default.mpd_password))
{
IMpdMessage<string> result = await connection.SendAsync(new PasswordCommand(Properties.Settings.Default.mpd_password));
if (!result.IsResponseValid)
@ -248,21 +294,11 @@ namespace unison
string mpdError = result.Response?.Result?.MpdError;
Trace.WriteLine(mpdError);
}
}*/
}
return connection;
}
private void Disconnected()
{
_connected = false;
_connection = null;
_commandConnection = null;
ConnectionChanged?.Invoke(this, EventArgs.Empty);
}
private void Loop(CancellationToken token)
{
Task.Run(async () =>
@ -271,24 +307,31 @@ namespace unison
{
try
{
if (token.IsCancellationRequested || _connection == null)
if (token.IsCancellationRequested || _connection == null || !IsConnected())
break;
var idleChanges = await _connection.SendAsync(new IdleCommand("stored_playlist playlist player mixer output options"));
IMpdMessage<string> idleChanges = await _connection.SendAsync(new IdleCommand("stored_playlist playlist player mixer output options update"));
if (idleChanges.IsResponseValid)
await HandleIdleResponseAsync(idleChanges.Response.Content);
else
{
Trace.WriteLine($"Error in Idle connection thread (1): {idleChanges.Response?.Content}");
throw new Exception(idleChanges.Response?.Content);
}
}
catch (Exception e)
{
Trace.WriteLine($"Error in Idle connection thread: {e.Message}");
Disconnected();
if (token.IsCancellationRequested)
Trace.WriteLine($"Idle connection cancelled.");
else
Trace.WriteLine($"Error in Idle connection thread: {e.Message}");
await Initialize();
break;
}
}
}).ConfigureAwait(false);
}, token).ConfigureAwait(false);
}
private async Task HandleIdleResponseAsync(string subsystems)
@ -306,6 +349,7 @@ namespace unison
catch (Exception e)
{
Trace.WriteLine($"Error in Idle connection thread: {e.Message}");
await Initialize();
}
}
@ -330,6 +374,7 @@ namespace unison
catch (Exception e)
{
Trace.WriteLine($"Error in Idle connection thread: {e.Message}");
await Initialize();
}
_isUpdatingStatus = false;
@ -337,6 +382,8 @@ namespace unison
private async Task UpdateSongAsync()
{
Trace.WriteLine("Updating song");
if (_connection == null || _isUpdatingSong)
return;
@ -356,16 +403,21 @@ namespace unison
catch (Exception e)
{
Trace.WriteLine($"Error in Idle connection thread: {e.Message}");
await Initialize();
}
_isUpdatingSong = false;
}
private async void GetAlbumCover(string path, CancellationToken token = default)
private async void GetAlbumCover(string path, CancellationToken token)
{
if (token.IsCancellationRequested)
return;
List<byte> data = new List<byte>();
try
{
bool ReadPictureFailed = true;
long totalBinarySize = 9999;
long currentSize = 0;
@ -374,21 +426,47 @@ namespace unison
if (_connection == null)
return;
var albumReq = await _connection.SendAsync(new AlbumArtCommand(path, currentSize));
IMpdMessage<MpdBinaryData> albumReq = await _connection.SendAsync(new AlbumArtCommand(path, currentSize));
if (!albumReq.IsResponseValid)
break;
var response = albumReq.Response.Content;
if (response.Binary == 0)
MpdBinaryData response = albumReq.Response.Content;
if (response == null || response.Binary == 0)
break;
ReadPictureFailed = false;
totalBinarySize = response.Size;
currentSize += response.Binary;
data.AddRange(response.Data);
}
while (currentSize < totalBinarySize && !token.IsCancellationRequested);
do
{
if (!ReadPictureFailed)
break;
if (_connection == null)
return;
IMpdMessage<MpdBinaryData> albumReq = await _connection.SendAsync(new ReadPictureCommand(path, currentSize));
if (!albumReq.IsResponseValid)
break;
MpdBinaryData response = albumReq.Response.Content;
if (response == null || response.Binary == 0)
break;
totalBinarySize = response.Size;
currentSize += response.Binary;
data.AddRange(response.Data);
} while (currentSize < totalBinarySize && !token.IsCancellationRequested);
}
while (currentSize < totalBinarySize && !token.IsCancellationRequested);
}
catch (Exception e)
{
if (token.IsCancellationRequested)
return;
Trace.WriteLine("Exception caught while getting albumart: " + e);
return;
}
@ -402,7 +480,7 @@ namespace unison
{
_cover = BitmapFrame.Create(stream, BitmapCreateOptions.None, BitmapCacheOption.OnLoad);
}
catch (System.NotSupportedException)
catch
{
_cover = null;
}
@ -437,7 +515,7 @@ namespace unison
SongChanged?.Invoke(this, EventArgs.Empty);
string uri = Regex.Escape(_currentSong.Path);
GetAlbumCover(uri);
GetAlbumCover(uri, _cancelCommand.Token);
}
public void UpdateCover()
@ -451,13 +529,27 @@ namespace unison
public string GetVersion() => _version;
public Statistics GetStats() => _stats;
public double GetCurrentTime() => _currentTime;
public IEnumerable<IMpdFile> GetPlaylist() => _Playlist;
public bool IsConnected() => _connected;
public bool IsPlaying() => _currentStatus?.State == MpdState.Play;
public void Prev() => SendCommand(new PreviousCommand());
public void Next() => SendCommand(new NextCommand());
public bool CanPrevNext = true;
public void Prev()
{
if (CanPrevNext)
SendCommand(new PreviousCommand());
}
public void Next()
{
if (CanPrevNext)
SendCommand(new NextCommand());
}
public void PlayPause() => SendCommand(new PauseResumeCommand());
public void Play(int pos) => SendCommand(new PlayCommand(pos));
public void Random() => SendCommand(new RandomCommand(!_currentRandom));
public void Repeat() => SendCommand(new RepeatCommand(!_currentRepeat));
@ -503,7 +595,6 @@ namespace unison
public void AddSong(string Uri)
{
Debug.WriteLine("AddCommand path: " + Uri);
SendCommand(new AddCommand(Uri));
}
@ -513,23 +604,34 @@ namespace unison
SendCommand(commandList);
}
public async Task QueryPlaylist() => _Playlist = await SafelySendCommandAsync(new PlaylistCommand());
public int GetPlaylistCount()
{
if (_Playlist == null)
return 0;
return _Playlist.ToArray().Count();
}
public async void QueryStats()
{
Dictionary<string, string> response = await SafelySendCommandAsync(new StatsCommand());
Dictionary<string, string> Response = await SafelySendCommandAsync(new StatsCommand());
if (Response == null)
return;
_stats.Songs = int.Parse(response["songs"]);
_stats.Albums = int.Parse(response["albums"]);
_stats.Artists = int.Parse(response["artists"]);
_stats.Songs = int.Parse(Response["songs"]);
_stats.Albums = int.Parse(Response["albums"]);
_stats.Artists = int.Parse(Response["artists"]);
TimeSpan time;
time = TimeSpan.FromSeconds(int.Parse(response["uptime"]));
time = TimeSpan.FromSeconds(int.Parse(Response["uptime"]));
_stats.Uptime = time.ToString(@"dd\:hh\:mm\:ss");
time = TimeSpan.FromSeconds(int.Parse(response["db_playtime"]));
time = TimeSpan.FromSeconds(int.Parse(Response["db_playtime"]));
_stats.TotalPlaytime = time.ToString(@"dd\:hh\:mm\:ss");
time = TimeSpan.FromSeconds(int.Parse(response["playtime"]));
time = TimeSpan.FromSeconds(int.Parse(Response["playtime"]));
_stats.TotalTimePlayed = time.ToString(@"dd\:hh\:mm\:ss");
DateTime date = new DateTime(1970, 1, 1).AddSeconds(int.Parse(response["db_update"])).ToLocalTime();
DateTime date = new DateTime(1970, 1, 1).AddSeconds(int.Parse(Response["db_update"])).ToLocalTime();
_stats.DatabaseUpdate = date.ToString("dd/MM/yyyy @ HH:mm");
}
}

134
Handlers/ShuffleHandler.cs Normal file
View File

@ -0,0 +1,134 @@
using MpcNET.Commands.Database;
using MpcNET.Commands.Queue;
using MpcNET.Tags;
using MpcNET.Types;
using MpcNET.Types.Filters;
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Threading.Tasks;
using System.Windows;
namespace unison
{
class ShuffleHandler
{
private MPDHandler _mpd;
public List<string> _songList { get; }
public int AddedSongs = 0;
public ShuffleHandler()
{
_songList = new();
_mpd = (MPDHandler)Application.Current.Properties["mpd"];
}
private bool IsOnMainThread()
{
return Application.Current.Dispatcher.Thread == System.Threading.Thread.CurrentThread;
}
public async Task GetSongsFromFilter(List<IFilter> filter)
{
Debug.WriteLine("[GetSongsFromFilterBefore] is on main thread => " + IsOnMainThread());
await Task.Run(async() =>
{
Debug.WriteLine("[GetSongsFromFilterAfter] is on main thread => " + IsOnMainThread());
_songList.Clear();
int song = _mpd.GetStats().Songs;
IEnumerable<IMpdFile> response = await _mpd.SafelySendCommandAsync(new SearchCommand(filter, 0, song + 1));
Debug.WriteLine("got response => " + response.Count());
foreach (IMpdFile file in response)
{
_songList.Add(file.Path);
Debug.WriteLine(file.Path);
}
});
}
public async Task AddToQueueRandom(int SongNumber)
{
Debug.WriteLine("Add To Queue Random");
await Task.Run(async () =>
{
int AddedSongs = 0;
Debug.WriteLine("song to add => " + SongNumber);
for (int i = 0; i < SongNumber; i++)
{
// generate random number
int song = new Random().Next(0, _mpd.GetStats().Songs - 1);
Debug.WriteLine("song " + song + " - song total " + _mpd.GetStats().Songs);
IEnumerable<IMpdFile> Response = await _mpd.SafelySendCommandAsync(new SearchCommand(new FilterTag(MpdTags.Title, "", FilterOperator.Contains), song, song + 1));
Debug.WriteLine("got response");
await Task.Delay(1);
if (Response.Count() > 0)
{
string filePath = Response.First().Path;
_mpd.AddSong(filePath);
Debug.WriteLine("song path => " + filePath);
if (i == 0)
{
if (!_mpd.IsPlaying())
_mpd.Play(0);
}
AddedSongs++;
}
}
});
Debug.WriteLine("Add To Queue Random - finished");
}
public async Task AddToQueueFilter(int SongNumber)
{
Debug.WriteLine("Add To Queue Filter");
await Task.Run(async () =>
{
int AddedSongs = 0;
Debug.WriteLine("song to add => " + SongNumber);
// more requested songs than available => add everything
if (SongNumber > _songList.Count)
{
foreach (string path in _songList)
{
await Task.Delay(1);
_mpd.AddSong(path);
Debug.WriteLine("song path => " + path);
AddedSongs++;
}
}
// more available songs than requested =>
// we add unique indexes until we reach the requested amount
else
{
HashSet<int> SongIndex = new();
Debug.WriteLine("while - before");
while (SongIndex.Count < SongNumber)
{
int MaxIndex = new Random().Next(0, _songList.Count - 1);
SongIndex.Add(MaxIndex);
}
foreach (int index in SongIndex)
_mpd.AddSong(_songList[index]);
Debug.WriteLine("while - after");
}
});
Debug.WriteLine("Add To Queue Filter - finished");
}
}
}

View File

@ -14,7 +14,7 @@ namespace unison
{
if (Properties.Settings.Default.snapcast_startup)
{
var mpd = (MPDHandler)Application.Current.Properties["mpd"];
MPDHandler mpd = (MPDHandler)Application.Current.Properties["mpd"];
if (mpd.IsConnected())
LaunchOrExit();
}
@ -37,8 +37,10 @@ namespace unison
{
if (!HasStarted && !ForceExit)
{
MPDHandler mpd = (MPDHandler)Application.Current.Properties["mpd"];
_snapcast.StartInfo.FileName = Properties.Settings.Default.snapcast_path + @"\snapclient.exe";
_snapcast.StartInfo.Arguments = $"--host {Properties.Settings.Default.mpd_host}";
_snapcast.StartInfo.Arguments = $"--host {mpd._ipAddress}";
_snapcast.StartInfo.CreateNoWindow = !Properties.Settings.Default.snapcast_window;
try
{

View File

@ -4,14 +4,14 @@ https://go.microsoft.com/fwlink/?LinkID=208121.
-->
<Project ToolsVersion="4.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<PropertyGroup>
<Configuration>Release</Configuration>
<Configuration>Release-Stable</Configuration>
<Platform>Any CPU</Platform>
<PublishDir>publish\</PublishDir>
<PublishProtocol>FileSystem</PublishProtocol>
<TargetFramework>net5.0-windows</TargetFramework>
<TargetFramework>net6.0-windows</TargetFramework>
<SelfContained>false</SelfContained>
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
<PublishSingleFile>True</PublishSingleFile>
<PublishReadyToRun>False</PublishReadyToRun>
<PublishSingleFile>true</PublishSingleFile>
<PublishReadyToRun>false</PublishReadyToRun>
</PropertyGroup>
</Project>

View File

@ -1,13 +1,14 @@
# unison
![Main window](Screenshots/screen1.png)
![Main window](Screenshots/screen-mainwindow.png)
**unison** is a very simple [Music Player Daemon (MPD)](https://www.musicpd.org/) daemon client with the following goals:
* lightweight window that can be toggled with shortcuts
* music control through rebindable shortcuts
* shuffle panel
* [Snapcast](https://mjaggard.github.io/snapcast/) integration
* Radio stations
* radio stations
## Features
@ -15,13 +16,25 @@
By default, unison works as a daemon in the taskbar system tray. You can display the main window when needed at any time with a shortcut.
![Systray](Screenshots/screen2.png)
![Systray](Screenshots/screen-systray.png)
### Shortcuts
You can control your music at anytime with the shortcuts. They are usable system-wide, even if the window is not visible. They are of course fully rebindable.
![Settings => shortcuts](Screenshots/screen3.png)
![Settings => shortcuts](Screenshots/screen-shortcuts.png)
### Shuffle panel
One of unison's main feature is a complete shuffle system based on criterias, aka a smart playlist.
You have two options:
* **Add to queue** allows you to add a defined number of songs to the queue.
* **Continuous shuffle** allows you, as long as the program is running, to automatically add songs to the queue.
Each of these options work with filters, but if none are selected, it is based on the entire library.
![Shuffle panel](Screenshots/screen-shuffle.png)
### Snapcast
@ -31,19 +44,14 @@ Embedding a Snapcast client allows to listen to music on multiple devices. For e
Through [Radio-Browser](https://www.radio-browser.info), a community database, you can play radio streams directly from unison. There are more than 28,000 stations recorded on this service, so it is a nice way to discover new music and cultures.
![Radio stations](Screenshots/screen4.png)
![Radio stations](Screenshots/screen-radio.png)
## Caveats
## Planned features
### Missing features
* MPD passwords: I don't really see the point, but if asked, I will integrate them.
### Planned features
* A complete shuffle system based on set criteria, aka a smart playlist.
* Playlist, queue and library management. I use other software to do it, but I will implement them at some point.
* Playlist, queue and library management
* More options for the shuffle panel
* Dark mode
## Translations
unison is translated in English and French. You can contribute if you want!
unison is translated in English, French and Spanish. You can contribute if you want!

View File

@ -196,7 +196,25 @@ namespace unison.Resources {
}
/// <summary>
/// Looks up a localized string similar to Connected to MPD.
/// Looks up a localized string similar to Please note that since MPD passwords are not secure (they are sent in plain text to the server), there are saved as is in the setting file..
/// </summary>
public static string Settings_ConnectionPasswordInfo {
get {
return ResourceManager.GetString("Settings_ConnectionPasswordInfo", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Status:.
/// </summary>
public static string Settings_ConnectionStatus {
get {
return ResourceManager.GetString("Settings_ConnectionStatus", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to connected to MPD.
/// </summary>
public static string Settings_ConnectionStatusConnected {
get {
@ -205,7 +223,7 @@ namespace unison.Resources {
}
/// <summary>
/// Looks up a localized string similar to Connecting....
/// Looks up a localized string similar to connecting....
/// </summary>
public static string Settings_ConnectionStatusConnecting {
get {
@ -214,7 +232,7 @@ namespace unison.Resources {
}
/// <summary>
/// Looks up a localized string similar to Not connected..
/// Looks up a localized string similar to not connected..
/// </summary>
public static string Settings_ConnectionStatusOffline {
get {
@ -258,6 +276,15 @@ namespace unison.Resources {
}
}
/// <summary>
/// Looks up a localized string similar to Password.
/// </summary>
public static string Settings_Password {
get {
return ResourceManager.GetString("Settings_Password", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Play / pause.
/// </summary>

View File

@ -0,0 +1,306 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<!--
Microsoft ResX Schema
Version 2.0
The primary goals of this format is to allow a simple XML format
that is mostly human readable. The generation and parsing of the
various data types are done through the TypeConverter classes
associated with the data types.
Example:
... ado.net/XML headers & schema ...
<resheader name="resmimetype">text/microsoft-resx</resheader>
<resheader name="version">2.0</resheader>
<resheader name="reader">System.Resources.ResXResourceReader, System.Windows.Forms, ...</resheader>
<resheader name="writer">System.Resources.ResXResourceWriter, System.Windows.Forms, ...</resheader>
<data name="Name1"><value>this is my long string</value><comment>this is a comment</comment></data>
<data name="Color1" type="System.Drawing.Color, System.Drawing">Blue</data>
<data name="Bitmap1" mimetype="application/x-microsoft.net.object.binary.base64">
<value>[base64 mime encoded serialized .NET Framework object]</value>
</data>
<data name="Icon1" type="System.Drawing.Icon, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64">
<value>[base64 mime encoded string representing a byte array form of the .NET Framework object]</value>
<comment>This is a comment</comment>
</data>
There are any number of "resheader" rows that contain simple
name/value pairs.
Each data row contains a name, and value. The row also contains a
type or mimetype. Type corresponds to a .NET class that support
text/value conversion through the TypeConverter architecture.
Classes that don't support this are serialized and stored with the
mimetype set.
The mimetype is used for serialized objects, and tells the
ResXResourceReader how to depersist the object. This is currently not
extensible. For a given mimetype the value must be set accordingly:
Note - application/x-microsoft.net.object.binary.base64 is the format
that the ResXResourceWriter will generate, however the reader can
read any of the formats listed below.
mimetype: application/x-microsoft.net.object.binary.base64
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Binary.BinaryFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.soap.base64
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Soap.SoapFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.bytearray.base64
value : The object must be serialized into a byte array
: using a System.ComponentModel.TypeConverter
: and then encoded with base64 encoding.
-->
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
<xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
<xsd:element name="root" msdata:IsDataSet="true">
<xsd:complexType>
<xsd:choice maxOccurs="unbounded">
<xsd:element name="metadata">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" />
</xsd:sequence>
<xsd:attribute name="name" use="required" type="xsd:string" />
<xsd:attribute name="type" type="xsd:string" />
<xsd:attribute name="mimetype" type="xsd:string" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="assembly">
<xsd:complexType>
<xsd:attribute name="alias" type="xsd:string" />
<xsd:attribute name="name" type="xsd:string" />
</xsd:complexType>
</xsd:element>
<xsd:element name="data">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" />
<xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
<xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="resheader">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" />
</xsd:complexType>
</xsd:element>
</xsd:choice>
</xsd:complexType>
</xsd:element>
</xsd:schema>
<resheader name="resmimetype">
<value>text/microsoft-resx</value>
</resheader>
<resheader name="version">
<value>2.0</value>
</resheader>
<resheader name="reader">
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=5.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=5.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<data name="Exit" xml:space="preserve">
<value>Salir</value>
</data>
<data name="Radios" xml:space="preserve">
<value>Radios</value>
</data>
<data name="Radio_Country" xml:space="preserve">
<value>Países</value>
</data>
<data name="Radio_Loading" xml:space="preserve">
<value>Cargando estaciones...</value>
</data>
<data name="Radio_Name" xml:space="preserve">
<value>Nombre</value>
</data>
<data name="Radio_NotFound" xml:space="preserve">
<value>¡No estaciones encontradas!</value>
</data>
<data name="Radio_Reset" xml:space="preserve">
<value>Reinicializar</value>
</data>
<data name="Radio_Search" xml:space="preserve">
<value>Búsqueda</value>
</data>
<data name="Radio_SearchStation" xml:space="preserve">
<value>Buscar estación</value>
</data>
<data name="Radio_Tags" xml:space="preserve">
<value>Tags</value>
</data>
<data name="Settings" xml:space="preserve">
<value>Ajustes</value>
</data>
<data name="Settings_About" xml:space="preserve">
<value>Acerca de</value>
</data>
<data name="Settings_AboutInfo" xml:space="preserve">
<value>unison es un software libre. Es construido con las siguientes tecnologías:</value>
</data>
<data name="Settings_ConnectButton" xml:space="preserve">
<value>Conexión</value>
</data>
<data name="Settings_Connection" xml:space="preserve">
<value>Conexión</value>
</data>
<data name="Settings_ConnectionPasswordInfo" xml:space="preserve">
<value>Tenga en cuenta que, dado que las contraseñas de MPD no son seguras (se envían en texto sin formato al servidor), se guardan tal cual en el archivo de configuración.</value>
</data>
<data name="Settings_ConnectionStatus" xml:space="preserve">
<value>Estado:</value>
</data>
<data name="Settings_ConnectionStatusConnected" xml:space="preserve">
<value>conectado a MPD</value>
</data>
<data name="Settings_ConnectionStatusConnecting" xml:space="preserve">
<value>conectando...</value>
</data>
<data name="Settings_ConnectionStatusOffline" xml:space="preserve">
<value>no conectado.</value>
</data>
<data name="Settings_Host" xml:space="preserve">
<value>Host</value>
</data>
<data name="Settings_License" xml:space="preserve">
<value>Licencia</value>
</data>
<data name="Settings_MadeBy" xml:space="preserve">
<value>Creado por</value>
</data>
<data name="Settings_NextTrack" xml:space="preserve">
<value>Pista siguiente</value>
</data>
<data name="Settings_Password" xml:space="preserve">
<value>Contraseña</value>
</data>
<data name="Settings_PlayPause" xml:space="preserve">
<value>Jugar / pausa</value>
</data>
<data name="Settings_Port" xml:space="preserve">
<value>Puerto</value>
</data>
<data name="Settings_PreviousTrack" xml:space="preserve">
<value>Pista precedente</value>
</data>
<data name="Settings_Shortcuts" xml:space="preserve">
<value>Atajos</value>
</data>
<data name="Settings_ShortcutsInfo" xml:space="preserve">
<value>Tenga en cuenta que si no está reconocida la tecla, esto se debe a una limitación en el funcionamiento de las teclas virtuales.</value>
</data>
<data name="Settings_ShortcutsKey" xml:space="preserve">
<value>Entre una tecla...</value>
</data>
<data name="Settings_ShowWindow" xml:space="preserve">
<value>Mostrar ventana</value>
</data>
<data name="Settings_SnapcastInfo1" xml:space="preserve">
<value>Puede cambiar a su propia versión instalada localmente del cliente Snapcast con una ruta</value>
</data>
<data name="Settings_SnapcastInfo2" xml:space="preserve">
<value> absoluta</value>
</data>
<data name="Settings_SnapcastInfo3" xml:space="preserve">
<value>.</value>
</data>
<data name="Settings_SnapcastLauch" xml:space="preserve">
<value>Lanzar al inicio</value>
</data>
<data name="Settings_SnapcastPath" xml:space="preserve">
<value>Ruta del ejecutable</value>
</data>
<data name="Settings_SnapcastPort" xml:space="preserve">
<value>Puerto</value>
</data>
<data name="Settings_SnapcastResetButton" xml:space="preserve">
<value>Reinicializar</value>
</data>
<data name="Settings_SnapcastWindow" xml:space="preserve">
<value>Mostrar ventana de Snapcast</value>
</data>
<data name="Settings_SourceCode1" xml:space="preserve">
<value>Código fuente disponible gratuitamente</value>
</data>
<data name="Settings_SourceCode2" xml:space="preserve">
<value>aquí</value>
</data>
<data name="Settings_Version" xml:space="preserve">
<value>Versión:</value>
</data>
<data name="Settings_VolumeDown" xml:space="preserve">
<value>Bajar volumen</value>
</data>
<data name="Settings_VolumeMute" xml:space="preserve">
<value>Volumen mudo</value>
</data>
<data name="Settings_VolumeOffset" xml:space="preserve">
<value>Intervalo de volumen</value>
</data>
<data name="Settings_VolumeUp" xml:space="preserve">
<value>Subir volumen</value>
</data>
<data name="ShowWindow" xml:space="preserve">
<value>Mostrar ventana</value>
</data>
<data name="Snapcast_Popup1" xml:space="preserve">
<value>Error Snapcast</value>
</data>
<data name="Snapcast_Popup2" xml:space="preserve">
<value>Ruta no válida:</value>
</data>
<data name="Snapcast_Popup3" xml:space="preserve">
<value>Ruta actual:</value>
</data>
<data name="Snapcast_Popup4" xml:space="preserve">
<value>Puede restablecerlo en la configuración si es necesario.</value>
</data>
<data name="StartSnapcast" xml:space="preserve">
<value>Iniciar Snapcast</value>
</data>
<data name="Stats" xml:space="preserve">
<value>Estadísticas</value>
</data>
<data name="Stats_Albums" xml:space="preserve">
<value>Álbumes:</value>
</data>
<data name="Stats_Artists" xml:space="preserve">
<value>Artistas:</value>
</data>
<data name="Stats_LastDatabaseUpdate" xml:space="preserve">
<value>Última actualización de la base de datos:</value>
</data>
<data name="Stats_Songs" xml:space="preserve">
<value>Canciones:</value>
</data>
<data name="Stats_TotalPlaytime" xml:space="preserve">
<value>Tiempo de juego total:</value>
</data>
<data name="Stats_TotalTimePlayed" xml:space="preserve">
<value>Tiempo total jugado:</value>
</data>
<data name="Stats_Uptime" xml:space="preserve">
<value>Tiempo de actividad de MPD:</value>
</data>
<data name="StopSnapcast" xml:space="preserve">
<value>Parar Snapcast</value>
</data>
</root>

View File

@ -133,7 +133,7 @@
<value>Nom</value>
</data>
<data name="Radio_NotFound" xml:space="preserve">
<value>Aucun station trouvée !</value>
<value>Aucune station trouvée !</value>
</data>
<data name="Radio_Reset" xml:space="preserve">
<value>Réinitialiser</value>
@ -162,14 +162,20 @@
<data name="Settings_Connection" xml:space="preserve">
<value>Connexion</value>
</data>
<data name="Settings_ConnectionPasswordInfo" xml:space="preserve">
<value>Veuillez noter que comme les mots de passe de MPD ne sont pas sécurisés (ils sont envoyés en clair au serveur), ils sont sauvegardés tels quels.</value>
</data>
<data name="Settings_ConnectionStatus" xml:space="preserve">
<value>Statut :</value>
</data>
<data name="Settings_ConnectionStatusConnected" xml:space="preserve">
<value>Connecté à MPD</value>
<value>connecté à MPD</value>
</data>
<data name="Settings_ConnectionStatusConnecting" xml:space="preserve">
<value>Connexion en cours...</value>
<value>connexion en cours...</value>
</data>
<data name="Settings_ConnectionStatusOffline" xml:space="preserve">
<value>Non connecté.</value>
<value>non connecté.</value>
</data>
<data name="Settings_Host" xml:space="preserve">
<value>Hôte</value>
@ -183,6 +189,9 @@
<data name="Settings_NextTrack" xml:space="preserve">
<value>Piste suivante</value>
</data>
<data name="Settings_Password" xml:space="preserve">
<value>Mot de passe</value>
</data>
<data name="Settings_PlayPause" xml:space="preserve">
<value>Jouer / pause</value>
</data>

View File

@ -162,14 +162,20 @@
<data name="Settings_Connection" xml:space="preserve">
<value>Connection</value>
</data>
<data name="Settings_ConnectionPasswordInfo" xml:space="preserve">
<value>Please note that since MPD passwords are not secure (they are sent in plain text to the server), there are saved as is in the setting file.</value>
</data>
<data name="Settings_ConnectionStatus" xml:space="preserve">
<value>Status:</value>
</data>
<data name="Settings_ConnectionStatusConnected" xml:space="preserve">
<value>Connected to MPD</value>
<value>connected to MPD</value>
</data>
<data name="Settings_ConnectionStatusConnecting" xml:space="preserve">
<value>Connecting...</value>
<value>connecting...</value>
</data>
<data name="Settings_ConnectionStatusOffline" xml:space="preserve">
<value>Not connected.</value>
<value>not connected.</value>
</data>
<data name="Settings_Host" xml:space="preserve">
<value>Host</value>
@ -183,6 +189,9 @@
<data name="Settings_NextTrack" xml:space="preserve">
<value>Next track</value>
</data>
<data name="Settings_Password" xml:space="preserve">
<value>Password</value>
</data>
<data name="Settings_PlayPause" xml:space="preserve">
<value>Play / pause</value>
</data>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 13 KiB

BIN
Resources/nothing.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 369 B

BIN
Resources/radio.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

View File

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

View File

@ -104,20 +104,22 @@
<VisualBrush Visual="{Binding ElementName=mask}"/>
</StackPanel.OpacityMask>
<Image x:Name="Cover" HorizontalAlignment="Center" VerticalAlignment="Center" Source="/Resources/nocover.png" Visibility="Collapsed" />
<Image x:Name="NoCover" HorizontalAlignment="Center" VerticalAlignment="Center" Source="/Resources/nocover.png" />
<Image x:Name="NoCover" HorizontalAlignment="Center" VerticalAlignment="Center" Source="/Resources/nocover.png" Visibility="Collapsed" />
<Image x:Name="RadioCover" HorizontalAlignment="Center" VerticalAlignment="Center" Source="/Resources/radio.png" Visibility="Collapsed" />
</StackPanel>
</Grid>
</Border>
</Grid>
<Grid x:Name="BottomLayout" HorizontalAlignment="Stretch" VerticalAlignment="Bottom" Background="{DynamicResource {x:Static SystemColors.ControlLightBrushKey}}" Width="Auto" MinHeight="40">
<StackPanel HorizontalAlignment="Left" Orientation="Horizontal" VerticalAlignment="Center" Margin="0,0,10,0">
<Button x:Name="Snapcast" HorizontalAlignment="Left" VerticalAlignment="Center" Click="Snapcast_Clicked" Margin="10,0,0,0" Padding="5, 2" Background="{DynamicResource {x:Static SystemColors.ControlBrushKey}}" FocusVisualStyle="{x:Null}" IsEnabled="False">
<StackPanel HorizontalAlignment="Left" Orientation="Horizontal" VerticalAlignment="Center" Margin="10,0,0,0">
<Button x:Name="Shuffle" Padding="5, 2" Click="Shuffle_Clicked" Background="{DynamicResource {x:Static SystemColors.ControlBrushKey}}" FocusVisualStyle="{x:Null}" Margin="0,0,10,0">
<StackPanel Orientation="Horizontal">
<emoji:TextBlock Text="🔊" Padding="0,0,0,2"/>
<TextBlock x:Name="SnapcastText" Text="{x:Static properties:Resources.StartSnapcast}" Margin="5, 0, 0, 0"/>
<emoji:TextBlock Text="🔁" Padding="0,0,0,2"/>
<TextBlock Text="Shuffle" Margin="5, 0, 0, 0"/>
</StackPanel>
</Button>
<Button x:Name="Radio" Padding="5, 2" HorizontalAlignment="Left" Click="Radios_Clicked" Margin="5,0,10,0" Background="{DynamicResource {x:Static SystemColors.ControlBrushKey}}" FocusVisualStyle="{x:Null}" IsEnabled="False">
<Button x:Name="Radio" Padding="5, 2" HorizontalAlignment="Left" Click="Radios_Clicked" Background="{DynamicResource {x:Static SystemColors.ControlBrushKey}}" FocusVisualStyle="{x:Null}" IsEnabled="False">
<StackPanel Orientation="Horizontal">
<emoji:TextBlock Text="📻" Padding="0,0,0,2"/>
<TextBlock Text="{x:Static properties:Resources.Radios}" Margin="5, 0, 0, 0"/>
@ -135,12 +137,13 @@
<TextBlock x:Name="Connection" HorizontalAlignment="Center" Text="Not connected" TextWrapping="Wrap" VerticalAlignment="Top" TextAlignment="Center" Foreground="{DynamicResource {x:Static SystemColors.ControlDarkDarkBrushKey}}" Margin="5,0,0,0" />
</StackPanel>
<StackPanel HorizontalAlignment="Right" Orientation="Horizontal" VerticalAlignment="Center" Margin="0,0,10,0">
<!--<Button x:Name="Shuffle" Padding="5, 2" HorizontalAlignment="Right" Margin="0,0,10,0" Background="{DynamicResource {x:Static SystemColors.ControlBrushKey}}">
<Button x:Name="Snapcast" HorizontalAlignment="Left" VerticalAlignment="Center" Click="Snapcast_Clicked" Margin="0,0,10,0" Padding="5, 2" Background="{DynamicResource {x:Static SystemColors.ControlBrushKey}}" FocusVisualStyle="{x:Null}" IsEnabled="False">
<StackPanel Orientation="Horizontal">
<emoji:TextBlock Text="🔁" Padding="0,0,0,2"/>
<TextBlock Text="Shuffle" Margin="5, 0, 0, 0"/>
<emoji:TextBlock Text="🔊" Padding="0,0,0,2"/>
<TextBlock x:Name="SnapcastText" Text="{x:Static properties:Resources.StartSnapcast}" Margin="5, 0, 0, 0"/>
</StackPanel>
</Button>-->
</Button>
<Button x:Name="Settings" Padding="5, 2" Click="Settings_Clicked" Background="{DynamicResource {x:Static SystemColors.ControlBrushKey}}" FocusVisualStyle="{x:Null}">
<StackPanel Orientation="Horizontal">
<emoji:TextBlock Text="🛠️" Padding="0,0,0,2"/>

View File

@ -6,6 +6,9 @@ using System.Windows.Threading;
using System.Windows.Interop;
using System.Windows.Input;
using System.Windows.Controls.Primitives;
using System.Diagnostics;
using System.Linq;
using System.Threading.Tasks;
namespace unison
{
@ -13,6 +16,7 @@ namespace unison
{
private readonly Settings _settingsWin;
private readonly Radios _radiosWin;
private readonly Shuffle _shuffleWin;
private readonly DispatcherTimer _timer;
private readonly MPDHandler _mpd;
@ -25,6 +29,7 @@ namespace unison
_settingsWin = new Settings();
_radiosWin = new Radios();
_shuffleWin = new Shuffle();
_timer = new DispatcherTimer();
_mpd = (MPDHandler)Application.Current.Properties["mpd"];
@ -46,9 +51,17 @@ namespace unison
{
if (_mpd.IsConnected())
{
Snapcast.IsEnabled = true;
_mpd.QueryStats();
_settingsWin.UpdateStats();
ConnectionOkIcon.Visibility = Visibility.Visible;
ConnectionFailIcon.Visibility = Visibility.Collapsed;
Snapcast.IsEnabled = true;
if (_radiosWin.IsConnected())
Radio.IsEnabled = true;
_shuffleWin.Initialize();
}
else
{
@ -56,12 +69,15 @@ namespace unison
DefaultState(true);
ConnectionOkIcon.Visibility = Visibility.Collapsed;
ConnectionFailIcon.Visibility = Visibility.Visible;
Snapcast.IsEnabled = false;
Radio.IsEnabled = false;
}
_settingsWin.UpdateConnectionStatus();
Connection.Text = $"{Properties.Settings.Default.mpd_host}:{Properties.Settings.Default.mpd_port}";
}
public void OnSongChanged(object sender, EventArgs e)
public async void OnSongChanged(object sender, EventArgs e)
{
if (_mpd.GetCurrentSong() == null)
return;
@ -83,7 +99,7 @@ namespace unison
SongAlbum.Text = _mpd.GetCurrentSong().Album;
if (_mpd.GetCurrentSong().Date != null)
SongAlbum.Text += $" ({ _mpd.GetCurrentSong().Date})";
SongAlbum.Text += $" ({_mpd.GetCurrentSong().Date.Split("-")[0]})";
SongGenre.Text = _mpd.GetCurrentSong().Genre;
SongFormat.Text = _mpd.GetCurrentSong().Path.Substring(_mpd.GetCurrentSong().Path.LastIndexOf(".") + 1);
@ -107,6 +123,22 @@ namespace unison
_timer.Start();
EndTime.Text = FormatSeconds(_mpd.GetCurrentSong().Time);
}
Debug.WriteLine("Song changed called!");
if (!_shuffleWin.GetContinuous())
return;
Debug.WriteLine("playlist length => " + _mpd.GetStatus().PlaylistLength);
if (_mpd.GetStatus().PlaylistLength > 4)
return;
Debug.WriteLine("start continuous handling");
_mpd.CanPrevNext = false;
await _shuffleWin.HandleContinuous();
_mpd.CanPrevNext = true;
Debug.WriteLine("finished continuous");
}
public void OnStatusChanged(object sender, EventArgs e)
@ -135,9 +167,6 @@ namespace unison
DefaultState();
}
}
_mpd.QueryStats();
_settingsWin.UpdateStats();
}
private void DefaultState(bool LostConnection = false)
@ -153,8 +182,9 @@ namespace unison
PlayPause.Text = (string)Application.Current.FindResource("pauseButton");
TimeSlider.Value = 50;
TimeSlider.IsEnabled = false;
NoCover.Visibility = Visibility.Visible;
NoCover.Visibility = Visibility.Collapsed;
Cover.Visibility = Visibility.Collapsed;
RadioCover.Visibility = Visibility.Collapsed;
if (LostConnection)
{
@ -166,16 +196,18 @@ namespace unison
public void OnCoverChanged(object sender, EventArgs e)
{
if (_mpd.GetCover() == null)
{
NoCover.Visibility = Visibility.Collapsed;
Cover.Visibility = Visibility.Collapsed;
RadioCover.Visibility = Visibility.Collapsed;
if (_mpd.GetCurrentSong().Time == -1)
RadioCover.Visibility = Visibility.Visible;
else if (_mpd.GetCover() == null)
NoCover.Visibility = Visibility.Visible;
Cover.Visibility = Visibility.Collapsed;
}
else if (Cover.Source != _mpd.GetCover())
{
Cover.Source = _mpd.GetCover();
Cover.Visibility = Visibility.Visible;
NoCover.Visibility = Visibility.Collapsed;
}
}
@ -188,11 +220,6 @@ namespace unison
SnapcastText.Text = unison.Resources.Resources.StartSnapcast;
}
public void OnRadioBrowserConnected()
{
Radio.IsEnabled = true;
}
public void UpdateButton(ref Border border, bool b)
{
border.Style = b ? (Style)Resources["SelectedButton"] : (Style)Resources["UnselectedButton"];
@ -212,7 +239,6 @@ namespace unison
public void Pause_Clicked(object sender, RoutedEventArgs e) => _mpd.PlayPause();
public void Previous_Clicked(object sender, RoutedEventArgs e) => _mpd.Prev();
public void Next_Clicked(object sender, RoutedEventArgs e) => _mpd.Next();
public void Random_Clicked(object sender, RoutedEventArgs e) => _mpd.Random();
public void Repeat_Clicked(object sender, RoutedEventArgs e) => _mpd.Repeat();
public void Single_Clicked(object sender, RoutedEventArgs e) => _mpd.Single();
@ -234,6 +260,15 @@ namespace unison
_radiosWin.WindowState = WindowState.Normal;
}
public void Shuffle_Clicked(object sender, RoutedEventArgs e)
{
_shuffleWin.Show();
_shuffleWin.Activate();
if (_shuffleWin.WindowState == WindowState.Minimized)
_shuffleWin.WindowState = WindowState.Normal;
}
public void Settings_Clicked(object sender, RoutedEventArgs e)
{
_settingsWin.Show();

View File

@ -54,6 +54,9 @@ namespace unison
{
private RadioBrowserClient _radioBrowser;
private MPDHandler _mpd;
private bool _connected = true;
public bool IsConnected() => _connected;
public Radios()
{
@ -69,25 +72,30 @@ namespace unison
Debug.WriteLine("Exception while connecting to RadioBrowser: " + e.Message);
return;
}
Application.Current.Dispatcher.Invoke(() =>
{
MainWindow MainWin = (MainWindow)Application.Current.MainWindow;
MainWin.OnRadioBrowserConnected();
});
_connected = true;
}
public async void Initialize()
{
List<NameAndCount> Countries = await _radioBrowser.Lists.GetCountriesAsync();
CountryList.Items.Add(new CountryListItem { Name = "", Count = 0 });
foreach (NameAndCount Country in Countries)
try
{
CountryList.Items.Add(new CountryListItem
List<NameAndCount> Countries = await _radioBrowser.Lists.GetCountriesAsync();
CountryList.Items.Add(new CountryListItem { Name = "", Count = 0 });
foreach (NameAndCount Country in Countries)
{
Name = Country.Name,
Count = Country.Stationcount
});
CountryList.Items.Add(new CountryListItem
{
Name = Country.Name,
Count = Country.Stationcount
});
}
}
catch (Exception e)
{
Debug.WriteLine("Exception while getting countries in RadioBrowser: " + e.Message);
return;
}
}

View File

@ -33,20 +33,25 @@
<StackPanel>
<StackPanel>
<TextBlock Text="{x:Static properties:Resources.Settings_Host}" TextWrapping="Wrap" Margin="5,0,0,0"/>
<TextBox x:Name="MpdHost" KeyDown="ConnectHandler" TextWrapping="Wrap" Width="250" Margin="10,2,0,0"/>
<TextBox x:Name="MpdHost" KeyDown="ConnectHandler" TextChanged="MpdConnectTextBox" TextWrapping="Wrap" Width="250" Margin="10,2,0,0"/>
</StackPanel>
<StackPanel Margin="0,5,0,0">
<TextBlock Text="{x:Static properties:Resources.Settings_Port}" TextWrapping="Wrap" Margin="5,0,0,0"/>
<TextBox x:Name="MpdPort" KeyDown="ConnectHandler" MaxLength="5" PreviewTextInput="NumberValidationTextBox" TextWrapping="Wrap" Width="250" Margin="10,2,0,0"/>
<TextBox x:Name="MpdPort" KeyDown="ConnectHandler" PreviewTextInput="NumberValidationTextBox" MaxLength="5" TextWrapping="Wrap" Width="250" Margin="10,2,0,0"/>
</StackPanel>
<!--<StackPanel Margin="0,5,0,0">
<TextBlock Text="Password" TextWrapping="Wrap" Margin="5,0,0,0"/>
<TextBox x:Name="MpdPassword" TextWrapping="Wrap" Width="250" Margin="10,2,0,0"/>
</StackPanel>-->
<StackPanel Margin="0,5,0,0">
<TextBlock Text="{x:Static properties:Resources.Settings_Password}" TextWrapping="Wrap" Margin="5,0,0,0"/>
<PasswordBox x:Name="MpdPassword" KeyDown="ConnectHandler" Width="250" Margin="10,2,0,0"/>
<TextBlock Text="{x:Static properties:Resources.Settings_ConnectionPasswordInfo}" TextWrapping="Wrap" Margin="10,5,0,0" MaxWidth="250" HorizontalAlignment="Left"/>
</StackPanel>
<TextBlock TextWrapping="Wrap" Margin="5,10,0,0">
<Run Text="{x:Static properties:Resources.Settings_ConnectionStatus}" FontWeight="Bold" />
<Run x:Name="ConnectionStatus" Text="{x:Static properties:Resources.Settings_ConnectionStatusOffline}"/>
</TextBlock>
<TextBlock x:Name="ConnectionStatus" Text="{x:Static properties:Resources.Settings_ConnectionStatusOffline}" TextWrapping="Wrap" Margin="5,10,0,0"/>
<Button x:Name="ConnectButton" Content="{x:Static properties:Resources.Settings_ConnectButton}" Margin="0,10,0,0" Width="120" Click="MPDConnect_Clicked"/>
</StackPanel>
</Grid>
@ -54,60 +59,6 @@
</DockPanel>
</TabItem>
<TabItem Header="{x:Static properties:Resources.Stats}">
<DockPanel Margin="8">
<GroupBox DockPanel.Dock="Top" Padding="0,4,0,0">
<GroupBox.Header>
<TextBlock>
<emoji:EmojiInline Text="📊"/>
<Run Text="{x:Static properties:Resources.Stats}"/>
</TextBlock>
</GroupBox.Header>
<Grid VerticalAlignment="Top">
<TextBlock>
<Run Text="{x:Static properties:Resources.Stats_Songs}"/><Run Text=" "/><Run x:Name="StatSong"/><LineBreak/>
<Run Text="{x:Static properties:Resources.Stats_Albums}"/><Run Text=" "/><Run x:Name="StatAlbum"/><LineBreak/>
<Run Text="{x:Static properties:Resources.Stats_Artists}"/><Run Text=" "/><Run x:Name="StatArtist"/><LineBreak/>
<Run Text="{x:Static properties:Resources.Stats_TotalPlaytime}"/><Run Text=" "/><Run x:Name="StatTotalPlaytime"/><LineBreak/><LineBreak/>
<Run Text="{x:Static properties:Resources.Stats_Uptime}"/><Run Text=" "/><Run x:Name="StatUptime"/><LineBreak/>
<Run Text="{x:Static properties:Resources.Stats_TotalTimePlayed}"/><Run Text=" "/><Run x:Name="StatTotalTimePlayed"/><LineBreak/>
<Run Text="{x:Static properties:Resources.Stats_LastDatabaseUpdate}"/><Run Text=" "/><Run x:Name="StatDatabaseUpdate"/>
</TextBlock>
</Grid>
</GroupBox>
</DockPanel>
</TabItem>
<TabItem Header="Snapcast">
<DockPanel Margin="8">
<GroupBox DockPanel.Dock="Top" Padding="0,4,0,0">
<GroupBox.Header>
<emoji:TextBlock Text="🔊 Snapcast"/>
</GroupBox.Header>
<Grid VerticalAlignment="Top">
<StackPanel>
<StackPanel>
<CheckBox x:Name="SnapcastStartup" Margin="5, 5, 0, 0">
<TextBlock Text="{x:Static properties:Resources.Settings_SnapcastLauch}" TextWrapping="Wrap"/>
</CheckBox>
<CheckBox x:Name="SnapcastWindow" Margin="5,2.5,0,0">
<TextBlock Text="{x:Static properties:Resources.Settings_SnapcastWindow}" TextWrapping="Wrap"/>
</CheckBox>
<TextBlock Text="{x:Static properties:Resources.Settings_SnapcastPort}" TextWrapping="Wrap" Margin="5,5,0,0"/>
<TextBox x:Name="SnapcastPort" MaxLength="5" PreviewTextInput="NumberValidationTextBox" TextWrapping="Wrap" Width="250" Margin="10,2,5,0" HorizontalAlignment="Left"/>
<TextBlock Text="{x:Static properties:Resources.Settings_SnapcastPath}" TextWrapping="Wrap" Margin="5,5,0,0"/>
<TextBox x:Name="SnapcastPath" TextWrapping="Wrap" Width="250" Margin="10,2,5,0" HorizontalAlignment="Left"/>
<TextBlock TextWrapping="Wrap" Margin="5,5,0,0" TextAlignment="Left" Width="250">
<Run Text="{x:Static properties:Resources.Settings_SnapcastInfo1}" /><Run Text="{x:Static properties:Resources.Settings_SnapcastInfo2}" FontStyle="Italic" FontWeight="DemiBold" /><Run Text="{x:Static properties:Resources.Settings_SnapcastInfo3}" />
</TextBlock>
<Button Content="{x:Static properties:Resources.Settings_SnapcastResetButton}" Margin="0,10,0,0" Width="120" Click="SnapcastReset_Clicked"/>
</StackPanel>
</StackPanel>
</Grid>
</GroupBox>
</DockPanel>
</TabItem>
<TabItem Header="{x:Static properties:Resources.Settings_Shortcuts}">
<DockPanel Margin="8">
<GroupBox DockPanel.Dock="Top" Padding="0,4,0,0">
@ -205,17 +156,98 @@
</Grid>
<StackPanel Orientation="Horizontal" Margin="0,10,0,0">
<TextBlock Text="{x:Static properties:Resources.Settings_ShortcutsInfo}" TextWrapping="Wrap" Margin="0,2,0,0" MaxWidth="420" />
<TextBlock Text="{x:Static properties:Resources.Settings_ShortcutsInfo}" TextWrapping="Wrap" MaxWidth="440" TextAlignment="Justify" />
</StackPanel>
<Button Content="{x:Static properties:Resources.Settings_SnapcastResetButton}" Margin="0,10,0,0" Width="120" Click="ShortcutsReset_Clicked"/>
</StackPanel>
</Grid>
</GroupBox>
</DockPanel>
</TabItem>
<TabItem Header="Snapcast">
<DockPanel Margin="8">
<GroupBox DockPanel.Dock="Top" Padding="0,4,0,0">
<GroupBox.Header>
<emoji:TextBlock Text="🔊 Snapcast"/>
</GroupBox.Header>
<Grid VerticalAlignment="Top">
<StackPanel>
<StackPanel>
<CheckBox x:Name="SnapcastStartup" Margin="5, 5, 0, 0">
<TextBlock Text="{x:Static properties:Resources.Settings_SnapcastLauch}" TextWrapping="Wrap"/>
</CheckBox>
<CheckBox x:Name="SnapcastWindow" Margin="5,2.5,0,0">
<TextBlock Text="{x:Static properties:Resources.Settings_SnapcastWindow}" TextWrapping="Wrap"/>
</CheckBox>
<TextBlock Text="{x:Static properties:Resources.Settings_SnapcastPort}" TextWrapping="Wrap" Margin="5,5,0,0"/>
<TextBox x:Name="SnapcastPort" MaxLength="5" PreviewTextInput="NumberValidationTextBox" TextWrapping="Wrap" Width="250" Margin="10,2,5,0" HorizontalAlignment="Left"/>
<TextBlock Text="{x:Static properties:Resources.Settings_SnapcastPath}" TextWrapping="Wrap" Margin="5,5,0,0"/>
<TextBox x:Name="SnapcastPath" TextWrapping="Wrap" Width="250" Margin="10,2,5,0" HorizontalAlignment="Left"/>
<TextBlock TextWrapping="Wrap" Margin="5,5,0,0" TextAlignment="Left" Width="250">
<Run Text="{x:Static properties:Resources.Settings_SnapcastInfo1}" /><Run Text="{x:Static properties:Resources.Settings_SnapcastInfo2}" FontStyle="Italic" FontWeight="DemiBold" /><Run Text="{x:Static properties:Resources.Settings_SnapcastInfo3}" />
</TextBlock>
<Button Content="{x:Static properties:Resources.Settings_SnapcastResetButton}" Margin="0,10,0,0" Width="120" Click="SnapcastReset_Clicked"/>
</StackPanel>
</StackPanel>
</Grid>
</GroupBox>
</DockPanel>
</TabItem>
<TabItem Header="Shuffle">
<DockPanel Margin="8">
<GroupBox DockPanel.Dock="Top" Padding="0,4,0,0">
<GroupBox.Header>
<TextBlock>
<emoji:EmojiInline Text="🔁 "/>
<Run Text="Shuffle"></Run>
</TextBlock>
</GroupBox.Header>
<Grid MaxWidth="500">
<StackPanel>
<TextBlock TextWrapping="Wrap">
<Run>The shuffle window allows to add random songs to your queue. Both options take into account the filter.</Run>
<Run>If the filter is empty, the entire music library is taken into account.</Run><LineBreak/><LineBreak/>
<Run FontWeight="Bold">Add to queue</Run><LineBreak/>
<Run>Add a fixed number of songs to the queue. It can take a long time to add more than 100 songs, so the option is limited to 1000 songs.</Run>
<LineBreak/><LineBreak/>
<Run FontWeight="Bold">Continuous shuffle</Run><LineBreak/>
<Run>By enabling this option, unison will automatically add songs to the queue so you never run out of songs to listen to.</Run>
</TextBlock>
</StackPanel>
</Grid>
</GroupBox>
</DockPanel>
</TabItem>
<TabItem Header="{x:Static properties:Resources.Stats}">
<DockPanel Margin="8">
<GroupBox DockPanel.Dock="Top" Padding="0,4,0,0">
<GroupBox.Header>
<TextBlock>
<emoji:EmojiInline Text="📊"/>
<Run Text="{x:Static properties:Resources.Stats}"/>
</TextBlock>
</GroupBox.Header>
<Grid VerticalAlignment="Top">
<TextBlock>
<Run Text="{x:Static properties:Resources.Stats_Songs}"/><Run Text=" "/><Run x:Name="StatSong"/><LineBreak/>
<Run Text="{x:Static properties:Resources.Stats_Albums}"/><Run Text=" "/><Run x:Name="StatAlbum"/><LineBreak/>
<Run Text="{x:Static properties:Resources.Stats_Artists}"/><Run Text=" "/><Run x:Name="StatArtist"/><LineBreak/>
<Run Text="{x:Static properties:Resources.Stats_TotalPlaytime}"/><Run Text=" "/><Run x:Name="StatTotalPlaytime"/><LineBreak/><LineBreak/>
<Run Text="{x:Static properties:Resources.Stats_Uptime}"/><Run Text=" "/><Run x:Name="StatUptime"/><LineBreak/>
<Run Text="{x:Static properties:Resources.Stats_TotalTimePlayed}"/><Run Text=" "/><Run x:Name="StatTotalTimePlayed"/><LineBreak/>
<Run Text="{x:Static properties:Resources.Stats_LastDatabaseUpdate}"/><Run Text=" "/><Run x:Name="StatDatabaseUpdate"/>
</TextBlock>
</Grid>
</GroupBox>
</DockPanel>
</TabItem>
<TabItem Header="{x:Static properties:Resources.Settings_About}" Height="20" VerticalAlignment="Bottom">
<DockPanel Margin="8">
<GroupBox DockPanel.Dock="Top" Padding="0,4,0,0">

View File

@ -37,6 +37,7 @@ namespace unison
HotkeyHandler _hotkeys = (HotkeyHandler)Application.Current.Properties["hotkeys"];
public Settings()
{
InitHwnd();
@ -52,7 +53,7 @@ namespace unison
{
MpdHost.Text = Properties.Settings.Default.mpd_host;
MpdPort.Text = Properties.Settings.Default.mpd_port.ToString();
//MpdPassword.Text = Properties.Settings.Default.mpd_password;
MpdPassword.Password = Properties.Settings.Default.mpd_password;
SnapcastStartup.IsChecked = Properties.Settings.Default.snapcast_startup;
SnapcastWindow.IsChecked = Properties.Settings.Default.snapcast_window;
SnapcastPath.Text = Properties.Settings.Default.snapcast_path;
@ -62,6 +63,131 @@ namespace unison
InitializeShortcuts();
}
public void UpdateConnectionStatus()
{
MPDHandler mpd = (MPDHandler)Application.Current.Properties["mpd"];
if (mpd.IsConnected())
{
ConnectionStatus.Text = $"{unison.Resources.Resources.Settings_ConnectionStatusConnected} {mpd.GetVersion()}.";
ConnectButton.IsEnabled = false;
}
else
{
ConnectionStatus.Text = unison.Resources.Resources.Settings_ConnectionStatusOffline;
ConnectButton.IsEnabled = true;
}
}
private void NumberValidationTextBox(object sender, TextCompositionEventArgs e)
{
Regex regex = new Regex("[^0-9]+");
e.Handled = regex.IsMatch(e.Text);
}
private void MpdConnectTextBox(object sender, TextChangedEventArgs e)
{
TextBox textBox = (TextBox)sender;
if (textBox.Text == Properties.Settings.Default.mpd_host)
ConnectButton.IsEnabled = false;
else
ConnectButton.IsEnabled = true;
e.Handled = true;
}
private void Hyperlink_RequestNavigate(object sender, RequestNavigateEventArgs e)
{
ProcessStartInfo psi = new(e.Uri.AbsoluteUri);
psi.UseShellExecute = true;
Process.Start(psi);
e.Handled = true;
}
private void MPDConnect_Clicked(object sender, RoutedEventArgs e)
{
if (!ConnectButton.IsEnabled)
return;
SaveSettings();
ConnectButton.IsEnabled = false;
ConnectionStatus.Text = unison.Resources.Resources.Settings_ConnectionStatusConnecting;
MPDHandler mpd = (MPDHandler)Application.Current.Properties["mpd"];
System.Threading.Tasks.Task.Run(async () => { await mpd.Initialize(); });
}
private void SnapcastReset_Clicked(object sender, RoutedEventArgs e)
{
SnapcastPath.Text = (string)Application.Current.FindResource("snapcastPath");
SnapcastPort.Text = (string)Application.Current.FindResource("snapcastPort");
}
public void UpdateStats()
{
MPDHandler mpd = (MPDHandler)Application.Current.Properties["mpd"];
StatSong.Text = mpd.GetStats().Songs.ToString();
StatAlbum.Text = mpd.GetStats().Albums.ToString();
StatArtist.Text = mpd.GetStats().Artists.ToString();
StatTotalPlaytime.Text = mpd.GetStats().TotalPlaytime.ToString();
StatUptime.Text = mpd.GetStats().Uptime.ToString();
StatTotalTimePlayed.Text = mpd.GetStats().TotalTimePlayed.ToString();
StatDatabaseUpdate.Text = mpd.GetStats().DatabaseUpdate.ToString();
}
private void ConnectHandler(object sender, KeyEventArgs e)
{
if (e.Key == Key.Return)
MPDConnect_Clicked(null, null);
}
private void Window_Closing(object sender, CancelEventArgs e)
{
e.Cancel = true;
SaveSettings();
WindowState = WindowState.Minimized;
Hide();
}
public void InitHwnd()
{
WindowInteropHelper helper = new(this);
helper.EnsureHandle();
}
public void SaveSettings()
{
Properties.Settings.Default.mpd_host = MpdHost.Text;
Properties.Settings.Default.mpd_port = int.Parse(MpdPort.Text, CultureInfo.InvariantCulture);
Properties.Settings.Default.mpd_password = MpdPassword.Password;
Properties.Settings.Default.snapcast_startup = (bool)SnapcastStartup.IsChecked;
Properties.Settings.Default.snapcast_window = (bool)SnapcastWindow.IsChecked;
Properties.Settings.Default.snapcast_path = SnapcastPath.Text;
Properties.Settings.Default.snapcast_port = int.Parse(SnapcastPort.Text, CultureInfo.InvariantCulture);
Properties.Settings.Default.volume_offset = int.Parse(VolumeOffset.Text, CultureInfo.InvariantCulture);
Properties.Settings.Default.nextTrack_mod = GetMod(Shortcut_NextTrack);
Properties.Settings.Default.nextTrack_vk = GetVk(Shortcut_NextTrack);
Properties.Settings.Default.previousTrack_mod = GetMod(Shortcut_PreviousTrack);
Properties.Settings.Default.previousTrack_vk = GetVk(Shortcut_PreviousTrack);
Properties.Settings.Default.playPause_mod = GetMod(Shortcut_PlayPause);
Properties.Settings.Default.playPause_vk = GetVk(Shortcut_PlayPause);
Properties.Settings.Default.volumeUp_mod = GetMod(Shortcut_VolumeUp);
Properties.Settings.Default.volumeUp_vk = GetVk(Shortcut_VolumeUp);
Properties.Settings.Default.volumeDown_mod = GetMod(Shortcut_VolumeDown);
Properties.Settings.Default.volumeDown_vk = GetVk(Shortcut_VolumeDown);
Properties.Settings.Default.volumeMute_mod = GetMod(Shortcut_VolumeMute);
Properties.Settings.Default.volumeMute_vk = GetVk(Shortcut_VolumeMute);
Properties.Settings.Default.showWindow_mod = GetMod(Shortcut_ShowWindow);
Properties.Settings.Default.showWindow_vk = GetVk(Shortcut_ShowWindow);
Properties.Settings.Default.Save();
}
// Hotkeys
void InitializeShortcuts()
{
System.Collections.Generic.IEnumerable<StackPanel> stackPanelCollection = RebindKeyWrapper.Children.OfType<StackPanel>();
@ -108,55 +234,6 @@ namespace unison
}
}
private void NumberValidationTextBox(object sender, TextCompositionEventArgs e)
{
Regex regex = new Regex("[^0-9]+");
e.Handled = regex.IsMatch(e.Text);
}
private void Hyperlink_RequestNavigate(object sender, RequestNavigateEventArgs e)
{
ProcessStartInfo psi = new(e.Uri.AbsoluteUri);
psi.UseShellExecute = true;
Process.Start(psi);
e.Handled = true;
}
public void UpdateConnectionStatus()
{
MPDHandler mpd = (MPDHandler)Application.Current.Properties["mpd"];
if (mpd.IsConnected())
ConnectionStatus.Text = $"{unison.Resources.Resources.Settings_ConnectionStatusConnected} {mpd.GetVersion()}.";
else
ConnectionStatus.Text = unison.Resources.Resources.Settings_ConnectionStatusOffline;
}
private void MPDConnect_Clicked(object sender, RoutedEventArgs e)
{
SaveSettings();
ConnectionStatus.Text = unison.Resources.Resources.Settings_ConnectionStatusConnecting;
MPDHandler mpd = (MPDHandler)Application.Current.Properties["mpd"];
mpd.Connect();
}
private void SnapcastReset_Clicked(object sender, RoutedEventArgs e)
{
SnapcastPath.Text = (string)Application.Current.FindResource("snapcastPath");
SnapcastPort.Text = (string)Application.Current.FindResource("snapcastPort");
}
public void UpdateStats()
{
MPDHandler mpd = (MPDHandler)Application.Current.Properties["mpd"];
StatSong.Text = mpd.GetStats().Songs.ToString();
StatAlbum.Text = mpd.GetStats().Albums.ToString();
StatArtist.Text = mpd.GetStats().Artists.ToString();
StatTotalPlaytime.Text = mpd.GetStats().TotalPlaytime.ToString();
StatUptime.Text = mpd.GetStats().Uptime.ToString();
StatTotalTimePlayed.Text = mpd.GetStats().TotalTimePlayed.ToString();
StatDatabaseUpdate.Text = mpd.GetStats().DatabaseUpdate.ToString();
}
private void HotkeyChanged()
{
_hotkeys.RemoveHotkeys();
@ -307,54 +384,5 @@ namespace unison
TextBlock textBlock = (TextBlock)button.Content;
return (uint)(HotkeyHandler.VK)System.Enum.Parse(typeof(HotkeyHandler.VK), textBlock.Text, true);
}
public void SaveSettings()
{
Properties.Settings.Default.mpd_host = MpdHost.Text;
Properties.Settings.Default.mpd_port = int.Parse(MpdPort.Text, CultureInfo.InvariantCulture);
//Properties.Settings.Default.mpd_password = MpdPassword.Text;
Properties.Settings.Default.snapcast_startup = (bool)SnapcastStartup.IsChecked;
Properties.Settings.Default.snapcast_window = (bool)SnapcastWindow.IsChecked;
Properties.Settings.Default.snapcast_path = SnapcastPath.Text;
Properties.Settings.Default.snapcast_port = int.Parse(SnapcastPort.Text, CultureInfo.InvariantCulture);
Properties.Settings.Default.volume_offset = int.Parse(VolumeOffset.Text, CultureInfo.InvariantCulture);
Properties.Settings.Default.nextTrack_mod = GetMod(Shortcut_NextTrack);
Properties.Settings.Default.nextTrack_vk = GetVk(Shortcut_NextTrack);
Properties.Settings.Default.previousTrack_mod = GetMod(Shortcut_PreviousTrack);
Properties.Settings.Default.previousTrack_vk = GetVk(Shortcut_PreviousTrack);
Properties.Settings.Default.playPause_mod = GetMod(Shortcut_PlayPause);
Properties.Settings.Default.playPause_vk = GetVk(Shortcut_PlayPause);
Properties.Settings.Default.volumeUp_mod = GetMod(Shortcut_VolumeUp);
Properties.Settings.Default.volumeUp_vk = GetVk(Shortcut_VolumeUp);
Properties.Settings.Default.volumeDown_mod = GetMod(Shortcut_VolumeDown);
Properties.Settings.Default.volumeDown_vk = GetVk(Shortcut_VolumeDown);
Properties.Settings.Default.volumeMute_mod = GetMod(Shortcut_VolumeMute);
Properties.Settings.Default.volumeMute_vk = GetVk(Shortcut_VolumeMute);
Properties.Settings.Default.showWindow_mod = GetMod(Shortcut_ShowWindow);
Properties.Settings.Default.showWindow_vk = GetVk(Shortcut_ShowWindow);
Properties.Settings.Default.Save();
}
private void ConnectHandler(object sender, KeyEventArgs e)
{
if (e.Key == Key.Return)
MPDConnect_Clicked(null, null);
}
private void Window_Closing(object sender, CancelEventArgs e)
{
e.Cancel = true;
SaveSettings();
WindowState = WindowState.Minimized;
Hide();
}
public void InitHwnd()
{
WindowInteropHelper helper = new(this);
helper.EnsureHandle();
}
}
}

117
Views/Shuffle.xaml Normal file
View File

@ -0,0 +1,117 @@
<Window x:Class="unison.Shuffle"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:emoji="clr-namespace:Emoji.Wpf;assembly=Emoji.Wpf"
xmlns:local="clr-namespace:unison" xmlns:sys="clr-namespace:System;assembly=System.Runtime"
mc:Ignorable="d"
Title="Shuffle" Closing="Window_Closing" SizeToContent="WidthAndHeight" ResizeMode="NoResize">
<Window.Resources>
<x:Array x:Key="FilterType" Type="sys:String">
<sys:String>Song</sys:String>
<sys:String>Artist</sys:String>
<sys:String>Album</sys:String>
<sys:String>Year</sys:String>
<sys:String>Genre</sys:String>
<sys:String>Directory</sys:String>
</x:Array>
<x:Array x:Key="OperatorTypeA" Type="sys:String">
<sys:String>contains</sys:String>
<sys:String>is</sys:String>
<sys:String>is not</sys:String>
</x:Array>
<x:Array x:Key="OperatorTypeB" Type="sys:String">
<sys:String>is</sys:String>
<sys:String>is not</sys:String>
</x:Array>
<x:Array x:Key="OperatorTypeC" Type="sys:String">
<sys:String>is</sys:String>
</x:Array>
<DataTemplate x:Key="FilterPanel">
<StackPanel Orientation="Horizontal" Margin="0,5,0,0">
<ComboBox x:Name="FilterType" SelectionChanged="FilterType_SelectionChanged" ItemsSource="{StaticResource FilterType}" SelectedIndex="0" Width="100" ScrollViewer.CanContentScroll="False" FocusVisualStyle="{x:Null}"/>
<ComboBox x:Name="FilterOperator" SelectionChanged="OperatorType_SelectionChanged" ItemsSource="{StaticResource OperatorTypeA}" SelectedIndex="0" Width="80" ScrollViewer.CanContentScroll="False" Margin="5,0,0,0" FocusVisualStyle="{x:Null}"/>
<ComboBox x:Name="FilterList" SelectedIndex="0" Width="240" Visibility="Collapsed" ScrollViewer.CanContentScroll="False" Margin="5,0,0,0" FocusVisualStyle="{x:Null}"/>
<TextBox x:Name="FilterValue" Width="240" Margin="5,0,0,0"/>
<Button Content="-" Padding="5, 2" Click="RemoveFilter_Clicked" Width="20" VerticalAlignment="Bottom" HorizontalAlignment="Left" FocusVisualStyle="{x:Null}" Margin="5,0,0,0"/>
<Button Content="+" Padding="5, 2" Click="AddFilter_Clicked" Width="20" VerticalAlignment="Bottom" HorizontalAlignment="Left" FocusVisualStyle="{x:Null}" Margin="5,0,0,0"/>
</StackPanel>
</DataTemplate>
</Window.Resources>
<Grid>
<StackPanel>
<StackPanel HorizontalAlignment="Left" Orientation="Vertical" Margin="5,0,5,5">
<GroupBox DockPanel.Dock="Top" Padding="0,4,0,0">
<GroupBox.Header>
<TextBlock>
<emoji:EmojiInline Text="🔡"/>
<Run Text="Filter"/>
</TextBlock>
</GroupBox.Header>
<StackPanel Orientation="Vertical" Margin="5,0,5,0">
<StackPanel x:Name="FilterPanel">
<ContentPresenter ContentTemplate="{StaticResource FilterPanel}"/>
</StackPanel>
<StackPanel x:Name="SongFilterPanel" Margin="0,10,0,0">
<TextBlock>
<Run Text="Number of songs in filter: "/><Run x:Name="SongFilterNumber" FontWeight="Bold"/>
</TextBlock>
</StackPanel>
<StackPanel Margin="0,5,0,0">
<StackPanel Orientation="Horizontal" Margin="0,5,0,5">
<Button Content="Query filter" Click="UpdateFilter_Clicked" Padding="5, 2" VerticalAlignment="Bottom" HorizontalAlignment="Left" FocusVisualStyle="{x:Null}" Margin="0,0,10,0"/>
<Button Content="Reset" Click="Reset_Clicked" Padding="5, 2" VerticalAlignment="Bottom" HorizontalAlignment="Left" FocusVisualStyle="{x:Null}"/>
<TextBlock Text="Querying filter..." Margin="15,3,0,0" FontStyle="Italic" Visibility="Visible" />
</StackPanel>
</StackPanel>
</StackPanel>
</GroupBox>
<StackPanel Orientation="Horizontal" Margin="0,5,0,0">
<GroupBox DockPanel.Dock="Right" Padding="0,4,0,0" Width="248">
<GroupBox.Header>
<TextBlock>
<emoji:EmojiInline Text=""/>
<Run Text="Add to queue"/>
</TextBlock>
</GroupBox.Header>
<StackPanel Orientation="Vertical" Margin="5,5,5,0">
<StackPanel Orientation="Horizontal" HorizontalAlignment="Left">
<TextBlock Text="Songs to add" Margin="0,0,5,5"/>
<TextBox x:Name="SongNumber" PreviewTextInput="QueueValidationTextBox" MaxLength="4" Text="15" Width="35" HorizontalAlignment="Left" VerticalAlignment="Top"/>
</StackPanel>
<StackPanel Orientation="Horizontal" Margin="0,5,0,0">
<Button Content="Add to queue" Click="AddToQueue_Clicked" Padding="5, 2" HorizontalAlignment="Left" FocusVisualStyle="{x:Null}"/>
<TextBlock x:Name="SearchStatus" Margin="15,3,0,0" FontStyle="Italic" Visibility="Collapsed">
<Run Text="Added "/><Run x:Name="NumberAddedSongs"/><Run Text=" songs"/>
</TextBlock>
</StackPanel>
</StackPanel>
</GroupBox>
<GroupBox DockPanel.Dock="Left" Padding="0,4,0,0" Width="248" Margin="0,0,5,0">
<GroupBox.Header>
<TextBlock>
<emoji:EmojiInline Text="♾️"/>
<Run Text="Continuous shuffle"/>
</TextBlock>
</GroupBox.Header>
<StackPanel Orientation="Horizontal" Margin="5,7,5,0">
<CheckBox x:Name="ContinuousShuffle" Checked="ContinuousShuffle_Checked" Unchecked="ContinuousShuffle_Checked" FocusVisualStyle="{x:Null}" VerticalAlignment="Top">
<TextBlock Text="Enable continuous shuffle" TextWrapping="Wrap"/>
</CheckBox>
</StackPanel>
</GroupBox>
</StackPanel>
</StackPanel>
</StackPanel>
</Grid>
</Window>

366
Views/Shuffle.xaml.cs Normal file
View File

@ -0,0 +1,366 @@
using MpcNET.Commands.Database;
using MpcNET.Tags;
using MpcNET.Types;
using MpcNET.Types.Filters;
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Diagnostics;
using System.Threading.Tasks;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Interop;
using System.Windows.Media;
using System.Linq;
using System.Windows.Input;
using System.Text.RegularExpressions;
namespace unison
{
public partial class Shuffle : Window
{
private MPDHandler _mpd;
private ShuffleHandler _shuffle;
bool _continuous = false;
List<string> _genreList { get; }
List<string> _folderList { get; }
List<IFilter> _filters { get; }
public Shuffle()
{
InitializeComponent();
_genreList = new();
_folderList = new();
_filters = new();
SongFilterNumber.Text = "0";
_mpd = (MPDHandler)Application.Current.Properties["mpd"];
_shuffle = (ShuffleHandler)Application.Current.Properties["shuffle"];
}
public void Initialize()
{
ListGenre();
ListFolder();
}
public async void ListGenre()
{
if (_genreList.Count != 0)
return;
List<string> Response = await _mpd.SafelySendCommandAsync(new ListCommand(MpdTags.Genre, null, null));
if (Response == null)
return;
foreach (string genre in Response)
_genreList.Add(genre);
}
public async void ListFolder()
{
if (_folderList.Count != 0)
return;
IEnumerable<IMpdFilePath> Response = await _mpd.SafelySendCommandAsync(new LsInfoCommand(""));
if (Response == null)
return;
foreach (IMpdFilePath folder in Response)
_folderList.Add(folder.Name);
}
private bool IsFilterEmpty()
{
if (_filters.Count() == 0)
return true;
return false;
}
private void Window_Closing(object sender, CancelEventArgs e)
{
e.Cancel = true;
WindowState = WindowState.Minimized;
Hide();
}
public void InitHwnd()
{
WindowInteropHelper helper = new(this);
helper.EnsureHandle();
}
private bool IsOnMainThread()
{
return App.Current.Dispatcher.Thread == System.Threading.Thread.CurrentThread;
}
private T FindParent<T>(DependencyObject child) where T : DependencyObject
{
var parent = VisualTreeHelper.GetParent(child);
if (parent == null)
return null;
if (parent is T)
return parent as T;
else
return FindParent<T>(parent);
}
private void AddFilter_Clicked(object sender, RoutedEventArgs e)
{
FilterPanel.Children.Add(new ContentPresenter { ContentTemplate = (DataTemplate)FindResource("FilterPanel") });
SongFilterNumber.Text = "0";
}
private void RemoveFilter_Clicked(object sender, RoutedEventArgs e)
{
if (FilterPanel.Children.Count > 1)
FilterPanel.Children.Remove(FindParent<ContentPresenter>(sender as Button));
else
Reset_Clicked(null, null);
SongFilterNumber.Text = "0";
}
private void Reset_Clicked(object sender, RoutedEventArgs e)
{
FilterPanel.Children.RemoveRange(0, FilterPanel.Children.Count);
FilterPanel.Children.Add(new ContentPresenter { ContentTemplate = (DataTemplate)FindResource("FilterPanel") });
SongFilterNumber.Text = "0";
_shuffle._songList.Clear();
}
private ITag FilterEquivalence_Type(string value)
{
if (value == "Song")
return MpdTags.Title;
else if (value == "Artist")
return MpdTags.Artist;
else if (value == "Album")
return MpdTags.Album;
else if (value == "Year")
return MpdTags.Date;
else if (value == "Genre")
return MpdTags.Genre;
return MpdTags.Title;
}
private FilterOperator FilterEquivalence_Operator(string value)
{
if (value == "contains")
return FilterOperator.Contains;
else if (value == "is")
return FilterOperator.Equal;
else if (value == "is not")
return FilterOperator.Different;
return FilterOperator.Equal;
}
private async void UpdateFilter_Clicked(object sender, RoutedEventArgs e)
{
await UpdateFilter();
}
private async Task UpdateFilter()
{
Debug.WriteLine("update filter => start");
_filters.Clear();
Debug.WriteLine("is on main thread => " + IsOnMainThread());
foreach (ContentPresenter superChild in FilterPanel.Children)
{
ITag tag = MpdTags.Title;
FilterOperator op = FilterOperator.None;
string value = "";
bool isDir = false;
StackPanel stackPanel = VisualTreeHelper.GetChild(superChild, 0) as StackPanel;
foreach (TextBox child in stackPanel.Children.OfType<TextBox>())
{
if (child.Name == "FilterValue")
value = child.Text;
}
foreach (ComboBox child in stackPanel.Children.OfType<ComboBox>())
{
if (child.Name == "FilterType")
{
if (child.SelectedItem.ToString() == "Directory")
isDir = true;
else
tag = FilterEquivalence_Type(child.SelectedItem.ToString());
}
if (child.Name == "FilterOperator")
op = FilterEquivalence_Operator(child.SelectedItem.ToString());
if (child.Name == "FilterList" && child.Visibility == Visibility.Visible)
value = child.SelectedItem.ToString();
}
if (value != "")
{
if (!isDir)
_filters.Add(new FilterTag(tag, value, op));
else
_filters.Add(new FilterBase(value, FilterOperator.None));
await _shuffle.GetSongsFromFilter(_filters);
SongFilterPanel.Visibility = Visibility.Visible;
SongFilterNumber.Text = _shuffle._songList.Count.ToString();
}
}
Debug.WriteLine("update filter => stop");
}
private void FilterType_Change(object sender, string Operator, List<string> Listing)
{
ComboBox comboBox = sender as ComboBox;
StackPanel stackPanel = comboBox.Parent as StackPanel;
foreach (ComboBox child in stackPanel.Children.OfType<ComboBox>())
{
if (child.Name == "FilterOperator")
{
child.ItemsSource = (Array)FindResource(Operator);
child.SelectedItem = child.Items[0];
}
if (child.Name == "FilterList")
{
child.Visibility = Visibility.Visible;
child.ItemsSource = Listing;
child.SelectedItem = child.Items[0];
}
}
foreach (TextBox child in stackPanel.Children.OfType<TextBox>())
{
if (child.Name == "FilterValue")
child.Visibility = Visibility.Collapsed;
}
SongFilterNumber.Text = "0";
}
private void FilterType_SelectionChanged(object sender, SelectionChangedEventArgs e)
{
string item = e.AddedItems[0].ToString();
if (item == "Genre")
FilterType_Change(sender, "OperatorTypeB", _genreList);
else if (item == "Directory")
FilterType_Change(sender, "OperatorTypeC", _folderList);
else
{
ComboBox combobox = sender as ComboBox;
StackPanel stackpanel = combobox.Parent as StackPanel;
foreach (ComboBox child in stackpanel.Children.OfType<ComboBox>())
{
if (child.Name == "FilterOperator")
{
child.ItemsSource = (Array)FindResource("OperatorTypeA");
child.SelectedItem = child.Items[0];
}
if (child.Name == "FilterList")
child.Visibility = Visibility.Collapsed;
}
foreach (TextBox child in stackpanel.Children.OfType<TextBox>())
{
if (child.Name == "FilterValue")
child.Visibility = Visibility.Visible;
}
}
SongFilterNumber.Text = "0";
}
private void OperatorType_SelectionChanged(object sender, SelectionChangedEventArgs e)
{
Debug.WriteLine("selection changed => operator type");
SongFilterNumber.Text = "0";
}
private void QueueValidationTextBox(object sender, TextCompositionEventArgs e)
{
Regex regex = new Regex("[^0-9]+");
e.Handled = regex.IsMatch(e.Text);
}
private void QueueValidationNumber()
{
if (int.Parse(SongNumber.Text) < 1)
SongNumber.Text = "1";
if (int.Parse(SongNumber.Text) > 1000)
SongNumber.Text = "1000";
}
private async void AddToQueue_Clicked(object sender, RoutedEventArgs e)
{
QueueValidationNumber();
if (_mpd.GetStats() == null)
return;
NumberAddedSongs.Text = "0";
SearchStatus.Visibility = Visibility.Visible;
// start dispatcher
// write _shuffle.AddedSongs in dispatcher
await AddToQueue(int.Parse(SongNumber.Text));
Debug.WriteLine("add to queue finished");
SearchStatus.Visibility = Visibility.Collapsed;
}
private async Task AddToQueue(int NumberToAdd)
{
await UpdateFilter();
Debug.WriteLine("check filters");
if (IsFilterEmpty())
await _shuffle.AddToQueueRandom(NumberToAdd);
else
{
Debug.WriteLine("add to queue filter - before");
await _shuffle.AddToQueueFilter(NumberToAdd);
Debug.WriteLine("add to queue filter - after");
}
Debug.WriteLine("add to queue finished");
}
public bool GetContinuous()
{
return _continuous;
}
public async Task HandleContinuous()
{
if (!_continuous)
{
Debug.WriteLine("continuous return nothing!");
return;
}
Debug.WriteLine("continuous __before__ add to queue");
await AddToQueue(5);
Debug.WriteLine("continuous __after__ add to queue");
}
private async void ContinuousShuffle_Checked(object sender, RoutedEventArgs e)
{
if (ContinuousShuffle.IsChecked == true)
_continuous = true;
else
_continuous = false;
if (_mpd.GetStatus().PlaylistLength < 5)
await HandleContinuous();
}
}
}

View File

@ -30,11 +30,11 @@
<Image Width="16" Height="16" emoji:Image.Source="📻" />
</MenuItem.Icon>
</MenuItem>
<!--<MenuItem Header="Shuffle" Command="{Binding Shuffle}">
<MenuItem Header="Shuffle" Command="{Binding Shuffle}">
<MenuItem.Icon>
<Image Width="16" Height="16" emoji:Image.Source="🔀" />
</MenuItem.Icon>
</MenuItem>-->
</MenuItem>
<MenuItem Header="{x:Static properties:Resources.Settings}" Command="{Binding Settings}">
<MenuItem.Icon>
<Image Width="16" Height="16" emoji:Image.Source="🛠️" />

View File

@ -71,6 +71,18 @@ namespace unison
}
}
public ICommand Shuffle
{
get
{
return new DelegateCommand
{
CommandAction = () => ((MainWindow)Application.Current.MainWindow).Shuffle_Clicked(null, null),
CanExecuteFunc = () => true
};
}
}
public ICommand Settings
{
get

View File

@ -2,17 +2,17 @@
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net5.0-windows</TargetFramework>
<TargetFramework>net6.0-windows</TargetFramework>
<UseWPF>true</UseWPF>
<ApplicationIcon>Resources\icon-full.ico</ApplicationIcon>
<Win32Resource></Win32Resource>
<StartupObject>unison.App</StartupObject>
<Version>1.2</Version>
<Version>1.3.1</Version>
<Company />
<Authors>Théo Marchal</Authors>
<PackageLicenseFile>LICENSE</PackageLicenseFile>
<PackageProjectUrl>https://git.n700.ovh/keb/unison</PackageProjectUrl>
<RepositoryUrl>https://git.n700.ovh/keb/unison</RepositoryUrl>
<PackageProjectUrl>https://github.com/ZetaKebab/unison</PackageProjectUrl>
<RepositoryUrl>https://github.com/ZetaKebab/unison</RepositoryUrl>
<Copyright>Théo Marchal</Copyright>
<PackageIconUrl />
</PropertyGroup>
@ -22,6 +22,8 @@
<None Remove="Resources\icon-mini.ico" />
<None Remove="Resources\nocover.png" />
<None Remove="LICENSE" />
<None Remove="Resources\nothing.png" />
<None Remove="Resources\radio.png" />
<None Include="LICENSE">
<Pack>True</Pack>
<PackagePath></PackagePath>
@ -38,6 +40,12 @@
<Resource Include="Resources\nocover.png">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Resource>
<Resource Include="Resources\nothing.png">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Resource>
<Resource Include="Resources\radio.png">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Resource>
<Content Include="LICENSE">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
@ -47,7 +55,7 @@
<PackageReference Include="Emoji.Wpf" Version="0.3.3" />
<PackageReference Include="Hardcodet.NotifyIcon.Wpf" Version="1.1.0" />
<PackageReference Include="RadioBrowser" Version="0.6.1" />
<PackageReference Include="MpcNET" Version="1.3.0" />
<PackageReference Include="MpcNET" Version="1.4.0" />
</ItemGroup>
<ItemGroup>
@ -64,6 +72,9 @@
</ItemGroup>
<ItemGroup>
<EmbeddedResource Update="Resources\Resources.es-ES.resx">
<Generator>PublicResXFileCodeGenerator</Generator>
</EmbeddedResource>
<EmbeddedResource Update="Resources\Resources.fr-FR.resx">
<Generator>PublicResXFileCodeGenerator</Generator>
</EmbeddedResource>