diff --git a/ArchitectureOverview.xopp b/ArchitectureOverview.xopp
new file mode 100644
index 0000000..67e418a
Binary files /dev/null and b/ArchitectureOverview.xopp differ
diff --git a/NuGet.Config b/NuGet.Config
new file mode 100644
index 0000000..19d85b7
--- /dev/null
+++ b/NuGet.Config
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/QuickPlay.sln b/QuickPlay.sln
index 54c5126..d3d5f69 100644
--- a/QuickPlay.sln
+++ b/QuickPlay.sln
@@ -5,10 +5,13 @@ VisualStudioVersion = 16.0.31112.23
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "QuickPlay", "QuickPlay\QuickPlay.csproj", "{FDBCCBF8-7CA5-4719-8CBB-8E33C464B27C}"
EndProject
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MpcCore", "..\..\Third-party\mpcCore\src\MpcCore\MpcCore.csproj", "{D0A5AD05-B98C-45E6-B61D-4700F7AA72CF}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
+ Release-Stable|Any CPU = Release-Stable|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{FDBCCBF8-7CA5-4719-8CBB-8E33C464B27C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
@@ -17,6 +20,15 @@ Global
{FDBCCBF8-7CA5-4719-8CBB-8E33C464B27C}.Release|Any CPU.ActiveCfg = Release|Any CPU
{FDBCCBF8-7CA5-4719-8CBB-8E33C464B27C}.Release|Any CPU.Build.0 = Release|Any CPU
{FDBCCBF8-7CA5-4719-8CBB-8E33C464B27C}.Release|Any CPU.Deploy.0 = Release|Any CPU
+ {FDBCCBF8-7CA5-4719-8CBB-8E33C464B27C}.Release-Stable|Any CPU.ActiveCfg = Release|Any CPU
+ {FDBCCBF8-7CA5-4719-8CBB-8E33C464B27C}.Release-Stable|Any CPU.Build.0 = Release|Any CPU
+ {FDBCCBF8-7CA5-4719-8CBB-8E33C464B27C}.Release-Stable|Any CPU.Deploy.0 = Release|Any CPU
+ {D0A5AD05-B98C-45E6-B61D-4700F7AA72CF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {D0A5AD05-B98C-45E6-B61D-4700F7AA72CF}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {D0A5AD05-B98C-45E6-B61D-4700F7AA72CF}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {D0A5AD05-B98C-45E6-B61D-4700F7AA72CF}.Release|Any CPU.Build.0 = Release|Any CPU
+ {D0A5AD05-B98C-45E6-B61D-4700F7AA72CF}.Release-Stable|Any CPU.ActiveCfg = Release|Any CPU
+ {D0A5AD05-B98C-45E6-B61D-4700F7AA72CF}.Release-Stable|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
diff --git a/QuickPlay/Configuration.cs b/QuickPlay/Configuration.cs
index 723e4b4..188b8db 100644
--- a/QuickPlay/Configuration.cs
+++ b/QuickPlay/Configuration.cs
@@ -1,14 +1,133 @@
using System;
+using System.Net.Http;
using System.IO;
+using System.Threading.Tasks;
+using System.Collections.Generic;
-namespace QuickPlayer
+namespace QuickPlay
{
+#pragma warning disable CS0659 // This is basically an almost-singleton, which will never be put in any hash table.
///
/// Application (global) configuration data class
///
- class AppConfiguration
+ [Serializable]
+ public sealed class AppConfiguration
{
+ // XXX: All the fields need to be checked in overriden Equals method
+ // Also: sensible defaults should be provided in defaultConfiguration field
+ public string playerConfigUrl;
+ // XXX: Any other fields of this model need [NonSerialized] attribute
+ [NonSerialized]
+ public static readonly string configFilePath =
+ Path.Combine(
+ System.Environment.GetFolderPath(System.Environment.SpecialFolder.ApplicationData),
+ "appConfig.xml"
+ );
+ public static AppConfiguration loadConfiguration()
+ {
+ var cfg = loadSavedConfiguration();
+ if (cfg == null)
+ {
+ cfg = defaultConfiguration;
+ }
+ return cfg;
+ }
+ public static AppConfiguration loadSavedConfiguration()
+ {
+ var serializer = new System.Xml.Serialization.XmlSerializer(typeof(AppConfiguration));
+ AppConfiguration result = null;
+ try
+ {
+ using (var stream = new System.IO.FileStream(configFilePath, System.IO.FileMode.Open))
+ {
+ result = (AppConfiguration)serializer.Deserialize(stream);
+
+ }
+ } catch (FileNotFoundException)
+ {
+ // This is fine, we will return null anyway.
+ } catch (InvalidCastException) {
+ // This is not so much fine, TODO log it.
+ // Still, we cannot supply config, so treat it as if it does not exist.
+ }
+ return result;
+ }
+
+ public void saveConfiguration()
+ {
+ // First we save the config
+ var serializer = new System.Xml.Serialization.XmlSerializer(typeof(AppConfiguration));
+ using (var stream = new System.IO.FileStream(configFilePath, System.IO.FileMode.Create))
+ {
+ serializer.Serialize(stream, this);
+ }
+
+ // Make sure that the configuration is same
+ var newConfig = AppConfiguration.loadSavedConfiguration();
+ if (this != newConfig) throw new InvalidDataException("Saved configuration is different from the supplied one.");
+ }
+
+ [NonSerialized]
+ public static readonly AppConfiguration defaultConfiguration = new AppConfiguration
+ {
+ playerConfigUrl = "http://www.ms.mff.cuni.cz/~turinskp/znelky.ini",
+ };
+ public async Task GetPlayerConfig()
+ {
+ IPlayerConfigurationProvider cfgProvider;
+ if (playerConfigUrl.StartsWith("http:") || playerConfigUrl.StartsWith("https:"))
+ {
+ cfgProvider = new HttpConfigurationProvider(playerConfigUrl);
+ } else if (playerConfigUrl.StartsWith("file://"))
+ {
+ string filename = playerConfigUrl.Substring("file://".Length);
+ var reader = new StreamReader(filename);
+ cfgProvider = new FileConfigurationProvider(reader);
+ } else if (playerConfigUrl == "default") {
+ cfgProvider = new DummyConfigurationProvider();
+ } else
+ {
+ throw new InvalidOperationException("Schema of player config URL not supported");
+ }
+ PlayerConfiguration playercfg;
+ try
+ {
+ playercfg = await cfgProvider.GetConfigurationAsync();
+ }
+ // FIXME: These are provider-specific exceptions!
+ catch (Exception e) when (
+ e is Java.Net.UnknownHostException
+ || e is OperationCanceledException
+ || e is HttpRequestException
+ || e is System.Net.WebException
+ )
+ {
+ var ctx = Android.App.Application.Context;
+ var t = Android.Widget.Toast.MakeText(ctx, "Could not load config:" + e.Message, Android.Widget.ToastLength.Long);
+ t.Show();
+ cfgProvider = new DummyConfigurationProvider();
+ playercfg = await cfgProvider.GetConfigurationAsync();
+ }
+ return playercfg;
+ }
+
+ // We want to compare by values.
+ // It might actually be a bit more sensible to compare serialized
+ // versions of the objects, but that seems unneccessarily hard.
+ public override bool Equals(object obj)
+ {
+ var other = (AppConfiguration)obj;
+ // These fields have to match...
+ if (this.playerConfigUrl != other.playerConfigUrl) return false;
+ return true;
+ }
+ }
+#pragma warning restore CS0659
+
+ public enum PlayerType
+ {
+ MPD,
}
///
@@ -16,27 +135,77 @@ namespace QuickPlayer
///
/// Contains details about connection, configured songs &c.
///
- class PlayerConfiguration
+ public class PlayerConfiguration
{
+ public Dictionary songs = new Dictionary();
+ public string playerName = "";
+ public PlayerType playerType;
+ public string playerConnectionDetails = ""; // This is opaque to this class, the player will make sense of it.
+
public static PlayerConfiguration FromFile(StreamReader reader)
{
- return null; // FIXME: Implement
+ var parser = new IniParser(reader);
+ var ini = parser.Parse();
+ var result = new PlayerConfiguration();
+ foreach (var elem in ini)
+ {
+ // Two options: either general options, or song descriptions
+ if (elem.Key == "general")
+ {
+ result.playerName = elem.Value["name"];
+ result.playerType = PlayerType.MPD; //FIXME: not always.
+ result.playerConnectionDetails = elem.Value["connection"];
+ } else
+ {
+ var song = new Song(elem.Key, elem.Value);
+ result.songs[elem.Key] = song;
+ }
+ }
+ return result;
+ }
+
+ public IPlayer GetPlayer()
+ {
+ IPlayer result;
+ switch (playerType)
+ {
+ case PlayerType.MPD:
+ result = new MpdPlayer(this);
+ break;
+ default:
+ throw new InvalidOperationException("Cannot happen: Player had no type.");
+ }
+ return result;
}
}
interface IPlayerConfigurationProvider
{
- PlayerConfiguration GetConfiguration();
+ Task GetConfigurationAsync();
}
- sealed class NetworkConfigurationProvider : IPlayerConfigurationProvider
+ sealed class HttpConfigurationProvider : IPlayerConfigurationProvider
{
-
+ readonly string configUrl;
+ public HttpConfigurationProvider(string url)
+ {
+ configUrl = url;
+ }
+ public async Task GetConfigurationAsync()
+ {
+ return await Task.Run(async () =>
+ {
+ var client = new HttpClient();
+ var resp = await client.GetStreamAsync(configUrl);
+ var sr = new StreamReader(resp);
+ return PlayerConfiguration.FromFile(sr);
+ });
+ }
}
sealed class FileConfigurationProvider : IPlayerConfigurationProvider
{
- public readonly StreamReader reader;
+ StreamReader reader;
public FileConfigurationProvider(StreamReader reader)
{
this.reader = reader;
@@ -46,14 +215,36 @@ namespace QuickPlayer
{
this.reader = new StreamReader(filename);
}
- public PlayerConfiguration GetConfiguration()
+ public Task GetConfigurationAsync()
{
- return PlayerConfiguration.FromFile(reader);
+ return Task.FromResult(PlayerConfiguration.FromFile(reader));
}
}
sealed class DummyConfigurationProvider : IPlayerConfigurationProvider
{
- public PlayerConfiguration GetConfiguration() => null;
+ public Task GetConfigurationAsync()
+ {
+ PlayerConfiguration cfg = new PlayerConfiguration();
+ cfg.playerType = PlayerType.MPD;
+ cfg.playerName = "Dummy player";
+ cfg.playerConnectionDetails = "127.0.0.1";
+
+ return Task.FromResult(cfg);
+ }
+ }
+
+ class Song: IPlayable
+ {
+ public string Identifier { get; private set; }
+ public PlayableMetadata Metadata { get; private set; }
+ public Song(string id, Dictionary data) {
+ Identifier = id;
+ Metadata = new PlayableMetadata
+ {
+ usualPlayingTime = TimeSpan.Parse(data["time"]),
+ filePath = data["path"],
+ };
+ }
}
}
\ No newline at end of file
diff --git a/QuickPlay/IniParser.cs b/QuickPlay/IniParser.cs
new file mode 100644
index 0000000..4a20be6
--- /dev/null
+++ b/QuickPlay/IniParser.cs
@@ -0,0 +1,67 @@
+using Android.App;
+using Android.Content;
+using Android.OS;
+using Android.Runtime;
+using Android.Views;
+using Android.Widget;
+using System;
+using System.IO;
+using System.Collections.Generic;
+using System.Text;
+
+namespace QuickPlay
+{
+ ///
+ /// This class implements (yet another) INI-style parser.
+ ///
+ /// INI specification: everything is in sections, comments using both '#'
+ /// or ';', space around '=' is trimmed, last value assigned overrides
+ /// previous assignments, sections may not repeat. Section names may only
+ /// contain reasonable characters (at least [a-zA-Z0-9. ], not really
+ /// enforced). Everything is case-sensitive.
+ ///
+ /// Both CRLF and just LF should be supported as line ends.
+ ///
+ class IniParser
+ {
+ StreamReader reader;
+ string currentSection = null;
+ public IniParser(StreamReader sr)
+ {
+ reader = sr;
+ }
+
+ // The return type is dictionary by sections, which in turn holds the section key-value dictionary
+ // Maybe the code is a bit more Pythonic than it should be...
+ public Dictionary> Parse()
+ {
+ Dictionary> result = new Dictionary>();
+ while (!reader.EndOfStream)
+ {
+ var line = reader.ReadLine();
+ line = StripComments(line).Trim();
+ if (line.Length == 0) continue;
+ if (line.StartsWith('['))
+ {
+ // This does not really do the right thing, but for QuickPlay's use it is good enough.
+ currentSection = line.Split(new char[] { '[', ']' })[1];
+ if (result.ContainsKey(currentSection)) throw new InvalidOperationException("Multiple sections with same name");
+ result[currentSection] = new Dictionary();
+ }
+ else
+ {
+ // Other lines may only be assignments
+ int equalSignPosition = line.IndexOf('=');
+ string key = line.Substring(0, equalSignPosition).Trim();
+ string value = line.Substring(equalSignPosition + 1).Trim();
+ result[currentSection][key] = value;
+ }
+ }
+ return result;
+ }
+ string StripComments(string line)
+ {
+ return line.Split(new char[] { ';', '#' })[0];
+ }
+ }
+}
\ No newline at end of file
diff --git a/QuickPlay/Interfaces.cs b/QuickPlay/Interfaces.cs
index dd4c473..acf3783 100644
--- a/QuickPlay/Interfaces.cs
+++ b/QuickPlay/Interfaces.cs
@@ -1,44 +1,65 @@
using System;
using System.Collections.Generic;
+using System.Threading.Tasks;
// MPD client abstractions and simplifications
-namespace QuickPlayer
+namespace QuickPlay
{
///
/// Simplified abstraction of possible players. Only methods needed are included.
///
/// That means that the interface may be extended in the future, which is sad.
///
- interface IPlayer
+ public interface IPlayer
{
- void Play(string identifier);
+ public enum PlayerStatus
+ {
+ Disconnected, Playing, Stopped
+ }
+ Dictionary Songs { get; }
+ string PlayerName { get; }
+ Task Play(IPlayable playable);
float CurrentProgress { get; }
- bool IsReady { get; }
- void SetReasonableOptions(); //TODO
+ PlayerStatus Status { get; }
+ Task SetReasonableOptions();
+ ///
+ /// Attach to the real player.
+ ///
+ /// Since this operation can be asynchronous, we cannot put it in th
+ /// constructor. And this allows for some tweaks before connecting.
+ ///
+ ///
+ Task ConnectAsync();
}
///
/// A simple dataclass to hold auxiliary data of the Playable objects.
///
// This is not really an interface, but since it is a dataclass, I treat it
// more as a part of interface, so it belongs to this file.
- class PlayableMetadata
+
+ // Also, in order not to need boilerplate code, this should be semantically
+ // treated as immutable even though it's not.
+ public class PlayableMetadata
{
- public readonly TimeSpan usualPlayingTime;
+ public TimeSpan usualPlayingTime;
+ public string filePath;
}
- interface IPlayable
+ public interface IPlayable
{
- void Play();
string Identifier { get; }
PlayableMetadata Metadata { get; }
}
- interface ILayout
+ interface ILayoutStrategy
{
- // TODO
+ List LayOut(ICollection playables);
}
- interface ILayoutStrategy
+
+ public class CannotConnectException: Exception
{
- ILayout LayOut(ICollection playables);
+ public CannotConnectException(string msg, Exception e) : base(msg, e) { }
+ public CannotConnectException(string msg) : base(msg) { }
+ public CannotConnectException() : base() { }
}
}
\ No newline at end of file
diff --git a/QuickPlay/LayoutStrategies.cs b/QuickPlay/LayoutStrategies.cs
new file mode 100644
index 0000000..728996f
--- /dev/null
+++ b/QuickPlay/LayoutStrategies.cs
@@ -0,0 +1,15 @@
+using System;
+using System.Collections.Generic;
+
+namespace QuickPlay
+{
+ class LexicographicLayoutStrategy : ILayoutStrategy
+ {
+ public List LayOut(ICollection playables)
+ {
+ List list = new List(playables);
+ list.Sort((IPlayable x, IPlayable y) => x.Identifier.CompareTo(y.Identifier));
+ return list;
+ }
+ }
+}
\ No newline at end of file
diff --git a/QuickPlay/MainActivity.cs b/QuickPlay/MainActivity.cs
index 531edb4..d371843 100644
--- a/QuickPlay/MainActivity.cs
+++ b/QuickPlay/MainActivity.cs
@@ -1,60 +1,139 @@
using System;
using Android.App;
using Android.OS;
-using Android.Runtime;
using Android.Views;
-using AndroidX.AppCompat.Widget;
-using AndroidX.AppCompat.App;
-using Google.Android.Material.FloatingActionButton;
-using Google.Android.Material.Snackbar;
+using Android.Widget;
+using Android.Support.V7.App;
+using Toolbar = Android.Support.V7.Widget.Toolbar;
+using GridLayoutManager = Android.Support.V7.Widget.GridLayoutManager;
namespace QuickPlay
{
- [Activity(Label = "@string/app_name", Theme = "@style/AppTheme.NoActionBar", MainLauncher = true)]
+ [Activity(Label = "@string/app_name", Theme = "@style/AppTheme", MainLauncher = true)]
public class MainActivity : AppCompatActivity
{
- protected override void OnCreate(Bundle savedInstanceState)
+ private AppConfiguration appConfig;
+ private Android.Support.V7.Widget.RecyclerView recyclerView;
+ private IPlayer currentPlayer;
+
+ protected override async void OnCreate(Bundle savedInstanceState)
{
base.OnCreate(savedInstanceState);
- Xamarin.Essentials.Platform.Init(this, savedInstanceState);
+
+
+ // App initialization
+ appConfig = AppConfiguration.loadConfiguration();
+ var playerConfig = await appConfig.GetPlayerConfig();
+ currentPlayer = playerConfig.GetPlayer();
+ try
+ {
+ await currentPlayer.ConnectAsync();
+ } catch (CannotConnectException e)
+ {
+ //TODO: View a toast with details and change some colors?
+ var t = Toast.MakeText(this, e.Message + ": " + e.InnerException.Message ?? "", ToastLength.Long);
+ t.Show();
+ }
+
+ // UI initialization
SetContentView(Resource.Layout.activity_main);
- Toolbar toolbar = FindViewById(Resource.Id.toolbar);
+ // UI Toolbar initialization
+ var toolbar = FindViewById(Resource.Id.toolbar);
SetSupportActionBar(toolbar);
+ SupportActionBar.Title = "My Toolbar";
+
+ // Hide the play bar by default
+ var bar = FindViewById(Resource.Id.currentSongBar);
+ bar.Visibility = ViewStates.Invisible;
+
+ // Initialize the RecyclerView
+ // Since this is rather complicated, it is in a separate method
+ InitializeRecyclerView();
- FloatingActionButton fab = FindViewById(Resource.Id.fab);
- fab.Click += FabOnClick;
+ // FIXME: This should be in OnResume...
+ // Refresh player info
+ OnPlayerUpdate();
+ }
+
+ private void InitializeRecyclerView()
+ {
+ recyclerView = FindViewById(Resource.Id.recyclerView1);
+ var layoutStrategy = new LexicographicLayoutStrategy();
+ var adapter = new SongRecyclerAdapter(currentPlayer, layoutStrategy);
+ adapter.SongClick += OnSongClick;
+ recyclerView.SetAdapter(adapter);
+ var layoutManager = new GridLayoutManager(this, 2, GridLayoutManager.Vertical, false);
+ recyclerView.SetLayoutManager(layoutManager);
}
public override bool OnCreateOptionsMenu(IMenu menu)
{
MenuInflater.Inflate(Resource.Menu.menu_main, menu);
- return true;
+ return base.OnCreateOptionsMenu(menu);
}
public override bool OnOptionsItemSelected(IMenuItem item)
{
- int id = item.ItemId;
- if (id == Resource.Id.action_settings)
+ if (item.ItemId == Resource.Id.action_settings)
{
- return true;
+ // Show the play bar
+ var bar = FindViewById(Resource.Id.currentSongBar);
+ bar.Visibility = ViewStates.Visible;
}
+ if (item.ItemId == Resource.Id.action_edit)
+ {
+ // Hide the play bar
+ var bar = FindViewById(Resource.Id.currentSongBar);
+ bar.Visibility = ViewStates.Invisible;
+ }
return base.OnOptionsItemSelected(item);
}
-
- private void FabOnClick(object sender, EventArgs eventArgs)
+
+ ///
+ /// A callback for all player updates.
+ ///
+ /// The update should be easy on resources, so no need to have separate
+ /// partial updates. This simply performs full update of the activity
+ /// state and views.
+ ///
+ public void OnPlayerUpdate()
+ {
+ TextView playerName = FindViewById(Resource.Id.playerNameText);
+ playerName.Text = currentPlayer.PlayerName;
+ var bar = FindViewById(Resource.Id.currentSongBar);
+ switch (currentPlayer.Status)
+ {
+ case IPlayer.PlayerStatus.Disconnected:
+ Toolbar tb = FindViewById(Resource.Id.toolbar);
+ tb.SetBackgroundColor(Android.Graphics.Color.Red);
+ bar.Visibility = ViewStates.Invisible;
+ break;
+ case IPlayer.PlayerStatus.Playing:
+ ResetToolbarColor();
+ // Show progress bar
+ bar.Visibility = ViewStates.Visible;
+ break;
+ case IPlayer.PlayerStatus.Stopped:
+ ResetToolbarColor();
+ bar.Visibility = ViewStates.Invisible;
+ break;
+ }
+ }
+ private void ResetToolbarColor()
{
- View view = (View) sender;
- Snackbar.Make(view, "Replace with your own action", Snackbar.LengthLong)
- .SetAction("Action", (View.IOnClickListener)null).Show();
+ Toolbar tb = FindViewById(Resource.Id.toolbar);
+ tb.SetBackgroundColor(
+ new Android.Graphics.Color(
+ Android.Support.V4.Content.ContextCompat.GetColor(this, Resource.Color.colorPrimary)
+ )
+ );
}
- public override void OnRequestPermissionsResult(int requestCode, string[] permissions, [GeneratedEnum] Android.Content.PM.Permission[] grantResults)
+ public async void OnSongClick(object sender, IPlayable song)
{
- Xamarin.Essentials.Platform.OnRequestPermissionsResult(requestCode, permissions, grantResults);
-
- base.OnRequestPermissionsResult(requestCode, permissions, grantResults);
- }
- }
+ await currentPlayer.Play(song);
+ }
+ }
}
diff --git a/QuickPlay/MpdMonitorService.cs b/QuickPlay/MpdMonitorService.cs
new file mode 100644
index 0000000..641b80e
--- /dev/null
+++ b/QuickPlay/MpdMonitorService.cs
@@ -0,0 +1,32 @@
+using Android.App;
+using Android.Content;
+using Android.OS;
+using Android.Runtime;
+using Android.Views;
+using Android.Widget;
+using System;
+
+namespace QuickPlay
+{
+ [Service]
+ class MpdMonitorService : Service
+ {
+ public override void OnCreate()
+ {
+ base.OnCreate();
+ // TODO: Create the watching thread
+ }
+ public override IBinder OnBind(Intent intent)
+ {
+ throw new NotImplementedException();
+ }
+ public override bool OnUnbind(Intent intent)
+ {
+ return base.OnUnbind(intent);
+ }
+ public override void OnDestroy()
+ {
+ base.OnDestroy();
+ }
+ }
+}
\ No newline at end of file
diff --git a/QuickPlay/MpdPlayer.cs b/QuickPlay/MpdPlayer.cs
new file mode 100644
index 0000000..8bbdad2
--- /dev/null
+++ b/QuickPlay/MpdPlayer.cs
@@ -0,0 +1,68 @@
+using Android.Content;
+using System;
+using System.Collections.Generic;
+using System.Threading.Tasks;
+using MpcCore;
+using System.Net.Sockets;
+
+namespace QuickPlay
+{
+ class MpdPlayer: IPlayer
+ {
+ MpcCoreClient mpd;
+ // MpcCore uses strings, so be it
+ string mpdIP, mpdPort;
+ public IPlayer.PlayerStatus Status { get; private set; }
+
+ public Dictionary Songs { get; private set; }
+ public string PlayerName { get; private set; }
+ public float CurrentProgress { get {
+ throw new NotImplementedException();
+ } }
+ public async Task Play(IPlayable song)
+ {
+ await mpd.SendAsync(new MpcCore.Commands.Player.Stop());
+ await SetReasonableOptions();
+ await mpd.SendAsync(new MpcCore.Commands.Queue.ClearQueue());
+ await mpd.SendAsync(new MpcCore.Commands.Queue.AddToQueue(song.Metadata.filePath));
+ await mpd.SendAsync(new MpcCore.Commands.Player.Play());
+ }
+ public async Task SetReasonableOptions()
+ {
+ await mpd.SendAsync(new MpcCore.Commands.Options.SetConsume(true));
+ await mpd.SendAsync(new MpcCore.Commands.Options.SetRandom(false));
+ await mpd.SendAsync(new MpcCore.Commands.Options.SetRepeat(false));
+ await mpd.SendAsync(new MpcCore.Commands.Options.SetSingle(false));
+ }
+ public MpdPlayer(PlayerConfiguration cfg)
+ {
+ // Populate known fields/properties
+ Songs = cfg.songs;
+ PlayerName = cfg.playerName;
+ // NOTE: We separate the port by '@', since ':' could be part of IPv6 and we do not want to complicate parsing.
+ var connDetails = cfg.playerConnectionDetails.Split('@');
+ if (connDetails.Length > 2) throw new InvalidOperationException("Bad connection details");
+ mpdIP = connDetails[0];
+ mpdPort = connDetails.Length >=2 ? connDetails[1] : "6600"; // XXX: Unneccessary default here...
+ // Connecting and monitoring remote player is done in ConnectAsync.
+ // Therefore, we start disconnected
+ Status = IPlayer.PlayerStatus.Disconnected;
+ }
+ public async Task ConnectAsync()
+ {
+ // Create a persistent connection
+ var conn = new MpcCoreConnection(mpdIP, mpdPort);
+ mpd = new MpcCoreClient(conn);
+ try
+ {
+ await mpd.ConnectAsync();
+ } catch (SocketException e)
+ {
+ throw new CannotConnectException("MPD connect failed", e);
+ }
+ // Start the monitoring service
+ var ctx = Android.App.Application.Context;
+ var intent = new Intent(ctx, typeof(MpdMonitorService));
+ }
+ }
+}
\ No newline at end of file
diff --git a/QuickPlay/Properties/AndroidManifest.xml b/QuickPlay/Properties/AndroidManifest.xml
index 889595d..452677d 100644
--- a/QuickPlay/Properties/AndroidManifest.xml
+++ b/QuickPlay/Properties/AndroidManifest.xml
@@ -1,6 +1,6 @@
-
-
+
+
+
-
\ No newline at end of file
diff --git a/QuickPlay/Properties/AssemblyInfo.cs b/QuickPlay/Properties/AssemblyInfo.cs
index b67884d..fb72a46 100644
--- a/QuickPlay/Properties/AssemblyInfo.cs
+++ b/QuickPlay/Properties/AssemblyInfo.cs
@@ -15,6 +15,12 @@ using Android.App;
[assembly: AssemblyTrademark("")]
[assembly: AssemblyCulture("")]
[assembly: ComVisible(false)]
+#if DEBUG
+[assembly: Application(Debuggable=true)]
+#else
+[assembly: Application(Debuggable = false)]
+#endif
+
// Version information for an assembly consists of the following four values:
//
diff --git a/QuickPlay/QuickPlay.csproj b/QuickPlay/QuickPlay.csproj
index 0e5a4c9..ec15e2b 100644
--- a/QuickPlay/QuickPlay.csproj
+++ b/QuickPlay/QuickPlay.csproj
@@ -36,14 +36,17 @@
4
True
None
- true
+ false
false
false
false
false
+ false
+ false
+ Xamarin.Android.Net.AndroidClientHandler
- True
+ false
portable
True
bin\Release\
@@ -52,11 +55,20 @@
4
true
False
- SdkOnly
- True
+ Full
+ true
+ false
+ false
+ false
+ false
+ r8
+
+
+ d8
+
@@ -64,9 +76,16 @@
+
+
+
+
+
+
+
@@ -77,9 +96,6 @@
Designer
-
- Designer
-
@@ -105,15 +121,119 @@
-
+
-
-
- 2.1.0.8
+
+ 28.0.0.3
+
+
+ 28.0.0.3
-
-
+
+ 28.0.0.3
+
+
+
+
+ {d0a5ad05-b98c-45e6-b61d-4700f7aa72cf}
+ MpcCore
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Designer
+
+
+ android:layout_height="fill_parent"
+ android:layout_below="@id/commonTopBar"
+ android:orientation="vertical">
-
+ android:layout_height="match_parent"
+ android:id="@+id/recyclerView1"
+ />
-
-
-
-
-
+
+
+
+
+
+
-
+
+
\ No newline at end of file
diff --git a/QuickPlay/Resources/layout/commonTopBar.axml b/QuickPlay/Resources/layout/commonTopBar.axml
new file mode 100644
index 0000000..c78ecf0
--- /dev/null
+++ b/QuickPlay/Resources/layout/commonTopBar.axml
@@ -0,0 +1,37 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/QuickPlay/Resources/layout/content_main.xml b/QuickPlay/Resources/layout/content_main.xml
deleted file mode 100644
index 5662d6d..0000000
--- a/QuickPlay/Resources/layout/content_main.xml
+++ /dev/null
@@ -1,16 +0,0 @@
-
-
-
-
-
-
diff --git a/QuickPlay/Resources/layout/songLayout.xml b/QuickPlay/Resources/layout/songLayout.xml
new file mode 100644
index 0000000..e785907
--- /dev/null
+++ b/QuickPlay/Resources/layout/songLayout.xml
@@ -0,0 +1,31 @@
+
+
+
+
+
+
+
+
+
diff --git a/QuickPlay/Resources/menu/menu_main.xml b/QuickPlay/Resources/menu/menu_main.xml
index ace8c65..7a0f775 100644
--- a/QuickPlay/Resources/menu/menu_main.xml
+++ b/QuickPlay/Resources/menu/menu_main.xml
@@ -5,5 +5,12 @@
android:id="@+id/action_settings"
android:orderInCategory="100"
android:title="@string/action_settings"
- app:showAsAction="never" />
+ android:icon="@mipmap/material_settings"
+ app:showAsAction="ifRoom" />
+
diff --git a/QuickPlay/Resources/mipmap-anydpi-v26/material_edit.xml b/QuickPlay/Resources/mipmap-anydpi-v26/material_edit.xml
new file mode 100644
index 0000000..fbe5335
--- /dev/null
+++ b/QuickPlay/Resources/mipmap-anydpi-v26/material_edit.xml
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/QuickPlay/Resources/mipmap-anydpi-v26/material_settings.xml b/QuickPlay/Resources/mipmap-anydpi-v26/material_settings.xml
new file mode 100644
index 0000000..9d8ad76
--- /dev/null
+++ b/QuickPlay/Resources/mipmap-anydpi-v26/material_settings.xml
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/QuickPlay/Resources/mipmap-hdpi/material_edit.png b/QuickPlay/Resources/mipmap-hdpi/material_edit.png
new file mode 100644
index 0000000..0c198df
Binary files /dev/null and b/QuickPlay/Resources/mipmap-hdpi/material_edit.png differ
diff --git a/QuickPlay/Resources/mipmap-hdpi/material_settings.png b/QuickPlay/Resources/mipmap-hdpi/material_settings.png
new file mode 100644
index 0000000..89b0a0a
Binary files /dev/null and b/QuickPlay/Resources/mipmap-hdpi/material_settings.png differ
diff --git a/QuickPlay/Resources/mipmap-mdpi/material_edit.png b/QuickPlay/Resources/mipmap-mdpi/material_edit.png
new file mode 100644
index 0000000..09b5c62
Binary files /dev/null and b/QuickPlay/Resources/mipmap-mdpi/material_edit.png differ
diff --git a/QuickPlay/Resources/mipmap-mdpi/material_settings.png b/QuickPlay/Resources/mipmap-mdpi/material_settings.png
new file mode 100644
index 0000000..d5ec9c9
Binary files /dev/null and b/QuickPlay/Resources/mipmap-mdpi/material_settings.png differ
diff --git a/QuickPlay/Resources/mipmap-xhdpi/material_edit.png b/QuickPlay/Resources/mipmap-xhdpi/material_edit.png
new file mode 100644
index 0000000..6e0eee3
Binary files /dev/null and b/QuickPlay/Resources/mipmap-xhdpi/material_edit.png differ
diff --git a/QuickPlay/Resources/mipmap-xhdpi/material_settings.png b/QuickPlay/Resources/mipmap-xhdpi/material_settings.png
new file mode 100644
index 0000000..d09235a
Binary files /dev/null and b/QuickPlay/Resources/mipmap-xhdpi/material_settings.png differ
diff --git a/QuickPlay/Resources/mipmap-xxhdpi/material_edit.png b/QuickPlay/Resources/mipmap-xxhdpi/material_edit.png
new file mode 100644
index 0000000..1d393f6
Binary files /dev/null and b/QuickPlay/Resources/mipmap-xxhdpi/material_edit.png differ
diff --git a/QuickPlay/Resources/mipmap-xxhdpi/material_settings.png b/QuickPlay/Resources/mipmap-xxhdpi/material_settings.png
new file mode 100644
index 0000000..fe3cd78
Binary files /dev/null and b/QuickPlay/Resources/mipmap-xxhdpi/material_settings.png differ
diff --git a/QuickPlay/Resources/mipmap-xxxhdpi/material_edit.png b/QuickPlay/Resources/mipmap-xxxhdpi/material_edit.png
new file mode 100644
index 0000000..813515b
Binary files /dev/null and b/QuickPlay/Resources/mipmap-xxxhdpi/material_edit.png differ
diff --git a/QuickPlay/Resources/mipmap-xxxhdpi/material_settings.png b/QuickPlay/Resources/mipmap-xxxhdpi/material_settings.png
new file mode 100644
index 0000000..eafb20f
Binary files /dev/null and b/QuickPlay/Resources/mipmap-xxxhdpi/material_settings.png differ
diff --git a/QuickPlay/Resources/values/strings.xml b/QuickPlay/Resources/values/strings.xml
index 7903abe..057196d 100644
--- a/QuickPlay/Resources/values/strings.xml
+++ b/QuickPlay/Resources/values/strings.xml
@@ -1,4 +1,7 @@
QuickPlay
+ Not Connected!
+
Settings
+ Edit
diff --git a/QuickPlay/Resources/values/styles.xml b/QuickPlay/Resources/values/styles.xml
index fef37f9..c712d3d 100644
--- a/QuickPlay/Resources/values/styles.xml
+++ b/QuickPlay/Resources/values/styles.xml
@@ -1,11 +1,13 @@
-
-
+
-
+
diff --git a/QuickPlay/SongRecycler.cs b/QuickPlay/SongRecycler.cs
new file mode 100644
index 0000000..bf27117
--- /dev/null
+++ b/QuickPlay/SongRecycler.cs
@@ -0,0 +1,57 @@
+using Android.App;
+using Android.Content;
+using Android.OS;
+using Android.Runtime;
+using Android.Support.V7.Widget;
+using Android.Views;
+using Android.Widget;
+using System;
+using System.Collections.Generic;
+
+namespace QuickPlay
+{
+ class SongRecyclerAdapter : Android.Support.V7.Widget.RecyclerView.Adapter
+ {
+ public event EventHandler SongClick;
+ IPlayer player;
+ ILayoutStrategy layoutStrategy;
+ public SongRecyclerAdapter(IPlayer player, ILayoutStrategy layoutStrategy)
+ {
+ this.player = player;
+ this.layoutStrategy = layoutStrategy;
+ }
+ public override int ItemCount
+ {
+ get
+ {
+ return player.Songs.Count;
+ }
+ }
+ public override RecyclerView.ViewHolder OnCreateViewHolder(ViewGroup parent, int viewType)
+ {
+ // I admit I have little idea what I am doing.
+ View itemView = LayoutInflater.From(parent.Context).Inflate(Resource.Layout.songLayout, parent, false);
+ return new SongRecyclerViewHolder(itemView, OnItemClick);
+ }
+ public override void OnBindViewHolder(RecyclerView.ViewHolder holder, int position)
+ {
+ SongRecyclerViewHolder vh = (SongRecyclerViewHolder)holder;
+ List layout = layoutStrategy.LayOut(player.Songs.Values);
+ vh.SongName.Text = layout[position].Identifier;
+ }
+ void OnItemClick(int position)
+ {
+ IPlayable playable = layoutStrategy.LayOut(player.Songs.Values)[position];
+ if (SongClick != null) SongClick.Invoke(this, playable);
+ }
+ }
+ class SongRecyclerViewHolder : Android.Support.V7.Widget.RecyclerView.ViewHolder
+ {
+ public TextView SongName { get; private set; }
+ public SongRecyclerViewHolder(View itemView, Action callback) : base(itemView)
+ {
+ SongName = itemView.FindViewById(Resource.Id.songName);
+ itemView.Click += (sender, e) => callback(base.LayoutPosition);
+ }
+ }
+}
\ No newline at end of file
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..2ba2baa
--- /dev/null
+++ b/README.md
@@ -0,0 +1,117 @@
+QuickPlay
+===
+
+A simple Xamarin.Android app to allow fast playing of songs on remote players
+like MPD. Supports all Android versions since KitKat (to repurpose an old tablet).
+
+Setup of additional services
+===
+
+Since QuickPlay is only a frontend to MPD running somewhere, and the location
+has to be specified in the player configuration (see below).
+
+Currently, only unauthenticated MPD connections specified by IP address are supported.
+
+Usage
+===
+
+First, make sure that you have the player configuration INI file. Put that on
+the magic URL: . Now run the
+app, your songs will appear and you can play them.
+
+An example commented INI file is provided: `playerconfig.ini`. This is what you
+would see if you use that:
+
+![UI example](./uiexample.png)
+
+Architecture Overview
+===
+
+The app comprises of three main parts:
+
+- Configuration
+- Player -- the backend
+- Layout -- the frontend
+
+Configuration
+---
+
+The configuration is split into two parts: Applocation configuration and player
+configuration.
+
+Application configuration is local to the device and describes application
+behaviour and where to obtain the player configuration. It
+is just a XML-serializable class (for simplicity).
+
+Player configuration is stored on an HTTP server or local filesystem. It is an
+INI-like file designed to be written by hand. Since INI is not a well-specified
+format, the specific format used in QuickPlay is described in `IniParser.cs`
+file. (It was faster to implement own format than to find a usable one on
+NuGet.) For use as a player configuration, the section `[general]` has to be
+specified with connection details, other sections directly describe individual
+songs.
+
+The motivation for the split is to make player configuration easily shareable,
+e.g. by a QR code pointing to the config.
+
+Currently, there is no application configuration and the player configuration
+is always downloaded from ,
+
+Players
+---
+
+QuickPlay is designed to support other players, not just MPD. Therefore, a
+`Player` is just an interface that can play Playables and export some trivial
+state.
+
+A `Playable` is an abstract object that can be played. Usually it is a song
+(description thereof), but there is a possibility it could be for example a
+playlist. (In the extreme case that someone would like to play chess instead of
+songs, the individual chess pieces would be playables.)
+
+In order to allow players to show current state, `MainActivity` has method
+`OnPlayerUpdate`, which re-fetches player state and displays it.
+
+It is expected that the players will maintain a service to monitor the remote
+state. It is not required though, it is really implementation detail of the
+client. (`MainActivity.OnPlayerUpdate` still has to be run on UI thread.)
+
+### MpdPlayer details
+
+The MpdPlayer is just a thin wrapper around [slightly
+patched](https://github.com/LEdoian/mpcCore)
+[mpcCore](https://github.com/svanelten/mpcCore) library. It currently only
+sends commands to the MPD, but extension by service to monitor it is ready
+(`MpdMonitorService.cs`).
+
+
+Frontend
+---
+
+Frontend is just a bunch of boilerplate code to power the RecyclerView behind
+it. The only "original" part are layout strategies -- objects that determine in
+what order the playables will be shown, based on their metadata.
+
+At the moment, the only layout strategy implemented is lexicographic sorting by
+the shown name.
+
+
+Planned Features
+====
+
+- [ ] Working application configuration
+- [ ] Player configuration sharing via QR codes
+- [ ] Showing current song progress
+- [ ] Possibility to stop current song
+- [ ] MPD player monitoring service
+- [ ] Time-based layout strategy
+
+Bugs
+---
+
+- [ ] The MpcCore client seems to disconnect after some time for no apparent reason.
+
+The far future
+---
+- [ ] Editing the player config right on the device (with INI file export)
+- [ ] Tests. (The code should be testable, but no tests has been written yet.)
diff --git a/playerconfig.ini b/playerconfig.ini
new file mode 100644
index 0000000..7f9fcc3
--- /dev/null
+++ b/playerconfig.ini
@@ -0,0 +1,43 @@
+# This is a player configuration example for QuickPlay.
+# Everything after either ';' or '#' is ignored.
+# Spaces around '=' and at both ends of each line are trimmed.
+# Everything is case-sensitive and UTF-8.
+# Multiple sections are forbidden, so are same keys in same section.
+# (Really, this is just an encoding of a Dictionary>)
+
+# Section 'general' is special, it declares general config (well...) of the
+# player
+[general]
+# Name is shown in the app to aid distinguishing between multiple possible players
+name = Example client
+# Type will determine what kind of player should be used. Currently it is not used.
+# More generally, keys that are not used are ignored. They are still parsed,
+# though, and should be considered reserved by QuickPlay (i.e. not used arbitrarily).
+type = MPD
+# Connection specifies where to connect. Usually it is just IP address, but if
+# you want to specify port, use notation ip.ad.dr.ess@port (i.e. separate using '@').
+connection = 198.51.100.3
+
+# All other sections represent individual songs / playables.
+# The section name is the name the song will be visible as.
+[Breakfast]
+# Expected time of playing. Currently unused, will be used for sorting.
+# Allowed format: HH:MM.
+time = 15:00
+# Path, under which MPD can find the song. Can contain even strange characters,
+# but cannot start with whitespace due to trimming. (Frankly, if your
+# filenames start with whitespace, you have much bigger problems.)
+path = Unsorted/Kevin_MacLeod/Polkas/Snare Bounce Polka.mp3
+
+# Create as many playables as you wish. Remember, no repeating of section names.
+[Lunch]
+time = 16:00
+path = Classical/MacLeod K. - Dance of the Sugar Plum Fairy (P.I.Tchaikovsky).mp3
+
+# Even section names are UTF-8, so we can use non-plain-ASCII characters.
+[Café]
+time = 22:00
+path = Irish/Fiddles McGinty.mp3
+
+# All the mentioned songs are from Kevin MacLeod under CC BY 4.0 licence. Not
+# that you can hear them, but the attribution has to be somewhere :-)
diff --git a/specifikace.txt b/specifikace.txt
new file mode 100644
index 0000000..7ec414f
--- /dev/null
+++ b/specifikace.txt
@@ -0,0 +1,13 @@
+jako zápočtový program do C# (NPRG035) bych chtěl dělat aplikaci pro
+Android na rychlé spouštění skladeb pomocí Music Player Daemonu (MPD).
+Použili bychom to v rámci korespondenčního semináře M&M, kde tento
+přehrávač používáme jako "znělky" ke svolání účastníků soustředění na
+program.
+
+Program by tedy sestával z tlačítek pro různé znělky, při jejichž
+stisknutí by se daná znělka spustila. Tato tlačítka by byla popsána v
+nějakém konfiguračním souboru (asi INI), který by si uživatelé mohli
+stáhnout a nahrát.
+
+Očekávám, že bych to psal v Xamarinu s použitím existujících bindingů
+pro ovládáni MPD. Chtěl bych si vyzkoušet psaní mobilních aplikací.
diff --git a/uiexample.png b/uiexample.png
new file mode 100644
index 0000000..f332ba1
Binary files /dev/null and b/uiexample.png differ