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