diff --git a/QuickPlay/Configuration.cs b/QuickPlay/Configuration.cs
index 16771e0..fc3ec1c 100644
--- a/QuickPlay/Configuration.cs
+++ b/QuickPlay/Configuration.cs
@@ -1,30 +1,127 @@
using System;
+using System.Net.Http;
using System.IO;
+using System.Threading.Tasks;
+using System.Collections.Generic;
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
///
[Serializable]
- sealed class AppConfiguration
+ public sealed class AppConfiguration
{
- public readonly string playerConfigUrl;
+ // 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()
{
- throw new NotImplementedException();
+ 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();
+ }
+ catch (Exception e) when (e is Java.Net.UnknownHostException)
+ {
+ 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,
}
///
@@ -32,24 +129,68 @@ namespace QuickPlay
///
/// 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
{
- PlayerConfiguration IPlayerConfigurationProvider.GetConfiguration()
+ readonly string configUrl;
+ public HttpConfigurationProvider(string url)
{
- throw new NotImplementedException();
+ configUrl = url;
+ }
+ public async Task GetConfigurationAsync()
+ {
+ var client = new HttpClient() ;
+ var resp = await client.GetStreamAsync(configUrl);
+ var sr = new StreamReader(resp);
+ return PlayerConfiguration.FromFile(sr);
}
}
@@ -65,14 +206,39 @@ namespace QuickPlay
{
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"])
+ };
+ }
+ public void Play()
+ {
+ throw new NotImplementedException();
+ }
}
}
\ 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 0f3e44d..1d0e079 100644
--- a/QuickPlay/Interfaces.cs
+++ b/QuickPlay/Interfaces.cs
@@ -1,5 +1,7 @@
using System;
using System.Collections.Generic;
+using System.Threading.Tasks;
+using Android.Content;
// MPD client abstractions and simplifications
namespace QuickPlay
@@ -9,36 +11,52 @@ namespace QuickPlay
///
/// That means that the interface may be extended in the future, which is sad.
///
- interface IPlayer
+ public interface IPlayer
{
+ Dictionary Songs { get; }
+ string PlayerName { get; }
void Play(string identifier);
float CurrentProgress { get; }
bool IsReady { get; }
void SetReasonableOptions(); //TODO
+ ///
+ /// 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;
}
- 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..9d9d8b4
--- /dev/null
+++ b/QuickPlay/LayoutStrategies.cs
@@ -0,0 +1,21 @@
+using Android.App;
+using Android.Content;
+using Android.OS;
+using Android.Runtime;
+using Android.Views;
+using Android.Widget;
+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 7e8ac12..40d146c 100644
--- a/QuickPlay/MainActivity.cs
+++ b/QuickPlay/MainActivity.cs
@@ -10,6 +10,7 @@ using Android.Widget;
//using Google.Android.Material.Snackbar;
using Android.Support.V7.App;
using Toolbar = Android.Support.V7.Widget.Toolbar;
+using GridLayoutManager = Android.Support.V7.Widget.GridLayoutManager;
using System.Net;
//using MpcNET;
@@ -25,16 +26,27 @@ namespace QuickPlay
public class MainActivity : AppCompatActivity
{
private AppConfiguration appConfig;
- private List playerConfigs;
+ private Android.Support.V7.Widget.RecyclerView recyclerView;
+ private IPlayer currentPlayer;
- protected override void OnCreate(Bundle savedInstanceState)
+ protected override async void OnCreate(Bundle savedInstanceState)
{
base.OnCreate(savedInstanceState);
// App initialization
- //appConfig = AppConfiguration.loadSavedConfiguration();
- //playerConfigs = acquirePlayerConfigs();
+ 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);
@@ -47,6 +59,23 @@ namespace QuickPlay
// 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();
+
+ // Refresh player info
+ OnPlayerUpdate();
+ }
+
+ private void InitializeRecyclerView()
+ {
+ recyclerView = FindViewById(Resource.Id.recyclerView1);
+ var layoutStrategy = new LexicographicLayoutStrategy();
+ var adapter = new SongRecyclerAdapter(currentPlayer, layoutStrategy);
+ recyclerView.SetAdapter(adapter);
+ var layoutManager = new GridLayoutManager(this, 2, GridLayoutManager.Vertical, false);
+ recyclerView.SetLayoutManager(layoutManager);
}
public override bool OnCreateOptionsMenu(IMenu menu)
@@ -72,16 +101,22 @@ namespace QuickPlay
}
return base.OnOptionsItemSelected(item);
}
-
- List acquirePlayerConfigs()
+
+ ///
+ /// 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()
{
- // FIXME: Bad! We have IPlayerConfigurationProviders
- ///var url = appConfig.playerConfigUrl;
- // TODO: Learn cURL and get configs :-)
-
- return null;
- throw new NotImplementedException();
+ TextView playerName = FindViewById(Resource.Id.playerNameText);
+ playerName.Text = currentPlayer.PlayerName;
+ Toolbar tb = FindViewById(Resource.Id.toolbar);
+ // This code is seriously lovely. FML.
+ tb.SetBackgroundColor(currentPlayer.IsReady ? new Android.Graphics.Color(Android.Support.V4.Content.ContextCompat.GetColor(this, Resource.Color.colorPrimary)) : Android.Graphics.Color.Red);
+ // throw new NotImplementedException("Activity should update.");
}
-
}
}
diff --git a/QuickPlay/MpdMonitorService.cs b/QuickPlay/MpdMonitorService.cs
index bef31d7..3075524 100644
--- a/QuickPlay/MpdMonitorService.cs
+++ b/QuickPlay/MpdMonitorService.cs
@@ -11,12 +11,13 @@ using System.Text;
namespace QuickPlay
{
- [Service(Exported = true, Name = "cz.ledoian.quickplay.mpdmonior")]
+ [Service]
class MpdMonitorService : Service
{
public override void OnCreate()
{
base.OnCreate();
+ // TODO: Create the watching thread
}
public override IBinder OnBind(Intent intent)
{
diff --git a/QuickPlay/MpdPlayer.cs b/QuickPlay/MpdPlayer.cs
new file mode 100644
index 0000000..228710f
--- /dev/null
+++ b/QuickPlay/MpdPlayer.cs
@@ -0,0 +1,68 @@
+using Android.App;
+using Android.Content;
+using Android.OS;
+using Android.Runtime;
+using Android.Views;
+using Android.Widget;
+using System;
+using System.Collections.Generic;
+using System.Threading.Tasks;
+using System.Threading;
+using MpcCore;
+using System.Net.Sockets;
+
+namespace QuickPlay
+{
+ class MpdPlayer: IPlayer
+ {
+ MpcCoreClient mpd;
+ // MpcCore uses strings, so be it
+ string mpdIP, mpdPort;
+
+ public Dictionary Songs { get; private set; }
+ public string PlayerName { get; private set; }
+ public float CurrentProgress { get {
+ throw new NotImplementedException();
+ } }
+ public bool IsReady { get
+ {
+ return mpd.IsConnected;
+ } }
+ public void Play(string songId)
+ {
+ throw new NotImplementedException();
+ }
+ public void SetReasonableOptions()
+ {
+ throw new NotImplementedException();
+ }
+ 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.
+ }
+ 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/QuickPlay.csproj b/QuickPlay/QuickPlay.csproj
index 0894629..2f7d5e7 100644
--- a/QuickPlay/QuickPlay.csproj
+++ b/QuickPlay/QuickPlay.csproj
@@ -43,6 +43,7 @@
false
false
false
+ Xamarin.Android.Net.AndroidClientHandler
True
@@ -59,6 +60,7 @@
+
@@ -67,11 +69,15 @@
+
+
+
+
@@ -216,6 +222,11 @@
+
+
+ Designer
+
+
+
+
+
+
+
+
diff --git a/QuickPlay/SongRecycler.cs b/QuickPlay/SongRecycler.cs
new file mode 100644
index 0000000..39fbd88
--- /dev/null
+++ b/QuickPlay/SongRecycler.cs
@@ -0,0 +1,51 @@
+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;
+using System.Text;
+
+namespace QuickPlay
+{
+ class SongRecyclerAdapter : Android.Support.V7.Widget.RecyclerView.Adapter
+ {
+ 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);
+ }
+ 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;
+ }
+ }
+ class SongRecyclerViewHolder : Android.Support.V7.Widget.RecyclerView.ViewHolder
+ {
+ public TextView SongName { get; private set; }
+ public SongRecyclerViewHolder(View itemView) : base(itemView)
+ {
+ SongName = itemView.FindViewById(Resource.Id.songName);
+ }
+ }
+}
\ No newline at end of file