Merge branch 'wip_recycler' into random_but_working

develop
LEdoian 3 years ago
commit a4ecbd84ed

@ -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.
/// <summary>
/// Application (global) configuration data class
/// </summary>
[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<PlayerConfiguration> 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,
}
/// <summary>
@ -32,24 +129,68 @@ namespace QuickPlay
///
/// Contains details about connection, configured songs &c.
/// </summary>
class PlayerConfiguration
public class PlayerConfiguration
{
public Dictionary<string, IPlayable> songs = new Dictionary<string, IPlayable>();
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<PlayerConfiguration> 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<PlayerConfiguration> 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<PlayerConfiguration> GetConfigurationAsync()
{
return PlayerConfiguration.FromFile(reader);
return Task.FromResult(PlayerConfiguration.FromFile(reader));
}
}
sealed class DummyConfigurationProvider : IPlayerConfigurationProvider
{
public PlayerConfiguration GetConfiguration() => null;
public Task<PlayerConfiguration> 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<string, string> data) {
Identifier = id;
Metadata = new PlayableMetadata
{
usualPlayingTime = TimeSpan.Parse(data["time"])
};
}
public void Play()
{
throw new NotImplementedException();
}
}
}

@ -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
{
/// <summary>
/// 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.
/// </summary>
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<string, Dictionary<string, string>> Parse()
{
Dictionary<string, Dictionary<string, string>> result = new Dictionary<string, Dictionary<string, string>>();
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<string, string>();
}
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];
}
}
}

@ -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.
/// </summary>
interface IPlayer
public interface IPlayer
{
Dictionary<string, IPlayable> Songs { get; }
string PlayerName { get; }
void Play(string identifier);
float CurrentProgress { get; }
bool IsReady { get; }
void SetReasonableOptions(); //TODO
/// <summary>
/// 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.
/// </summary>
/// <returns></returns>
Task ConnectAsync();
}
/// <summary>
/// A simple dataclass to hold auxiliary data of the Playable objects.
/// </summary>
// 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<IPlayable> LayOut(ICollection<IPlayable> playables);
}
interface ILayoutStrategy
public class CannotConnectException: Exception
{
ILayout LayOut(ICollection<IPlayable> playables);
public CannotConnectException(string msg, Exception e) : base(msg, e) { }
public CannotConnectException(string msg) : base(msg) { }
public CannotConnectException() : base() { }
}
}

@ -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<IPlayable> LayOut(ICollection<IPlayable> playables)
{
List<IPlayable> list = new List<IPlayable>(playables);
list.Sort((IPlayable x, IPlayable y) => x.Identifier.CompareTo(y.Identifier));
return list;
}
}
}

@ -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<PlayerConfiguration> 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<Android.Support.V7.Widget.RecyclerView>(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<PlayerConfiguration> acquirePlayerConfigs()
/// <summary>
/// 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.
/// </summary>
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<TextView>(Resource.Id.playerNameText);
playerName.Text = currentPlayer.PlayerName;
Toolbar tb = FindViewById<Toolbar>(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.");
}
}
}

@ -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)
{

@ -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<string, IPlayable> 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));
}
}
}

@ -43,6 +43,7 @@
<BundleAssemblies>false</BundleAssemblies>
<AndroidKeyStore>false</AndroidKeyStore>
<AndroidUseAapt2>false</AndroidUseAapt2>
<AndroidHttpClientHandlerType>Xamarin.Android.Net.AndroidClientHandler</AndroidHttpClientHandlerType>
</PropertyGroup>
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Release|AnyCPU' ">
<DebugSymbols>True</DebugSymbols>
@ -59,6 +60,7 @@
</PropertyGroup>
<ItemGroup>
<Reference Include="System" />
<Reference Include="System.Net.Http" />
<Reference Include="System.Xml" />
<Reference Include="System.Core" />
<Reference Include="Mono.Android" />
@ -67,11 +69,15 @@
</ItemGroup>
<ItemGroup>
<Compile Include="Configuration.cs" />
<Compile Include="IniParser.cs" />
<Compile Include="Interfaces.cs" />
<Compile Include="LayoutStrategies.cs" />
<Compile Include="MpdMonitorService.cs" />
<Compile Include="MainActivity.cs" />
<Compile Include="MpdPlayer.cs" />
<Compile Include="Resources\Resource.designer.cs" />
<Compile Include="Properties\AssemblyInfo.cs" />
<Compile Include="SongRecycler.cs" />
</ItemGroup>
<ItemGroup>
<None Include="Resources\AboutResources.txt" />
@ -216,6 +222,11 @@
<ItemGroup>
<AndroidResource Include="Resources\layout\commonTopBar.axml" />
</ItemGroup>
<ItemGroup>
<AndroidResource Include="Resources\layout\songLayout.xml">
<SubType>Designer</SubType>
</AndroidResource>
</ItemGroup>
<Import Project="$(MSBuildExtensionsPath)\Xamarin\Android\Xamarin.Android.CSharp.targets" />
<!-- To modify your build process, add your task inside one of the targets below and uncomment it.
Other similar extension points exist, see Microsoft.Common.targets.

@ -3091,8 +3091,8 @@ namespace QuickPlay
// aapt resource value: 0x7f0900be
public const int action_divider = 2131296446;
// aapt resource value: 0x7f0900c4
public const int action_edit = 2131296452;
// aapt resource value: 0x7f0900c5
public const int action_edit = 2131296453;
// aapt resource value: 0x7f0900b4
public const int action_image = 2131296436;
@ -3112,8 +3112,8 @@ namespace QuickPlay
// aapt resource value: 0x7f09006b
public const int action_mode_close_button = 2131296363;
// aapt resource value: 0x7f0900c3
public const int action_settings = 2131296451;
// aapt resource value: 0x7f0900c4
public const int action_settings = 2131296452;
// aapt resource value: 0x7f0900b5
public const int action_text = 2131296437;
@ -3331,8 +3331,8 @@ namespace QuickPlay
// aapt resource value: 0x7f090070
public const int list_item = 2131296368;
// aapt resource value: 0x7f0900c2
public const int masked = 2131296450;
// aapt resource value: 0x7f0900c3
public const int masked = 2131296451;
// aapt resource value: 0x7f09009c
public const int message = 2131296412;
@ -3502,6 +3502,9 @@ namespace QuickPlay
// aapt resource value: 0x7f09004b
public const int snapMargins = 2131296331;
// aapt resource value: 0x7f0900c1
public const int songName = 2131296449;
// aapt resource value: 0x7f090073
public const int spacer = 2131296371;
@ -3625,8 +3628,8 @@ namespace QuickPlay
// aapt resource value: 0x7f09001b
public const int view_offset_helper = 2131296283;
// aapt resource value: 0x7f0900c1
public const int visible = 2131296449;
// aapt resource value: 0x7f0900c2
public const int visible = 2131296450;
// aapt resource value: 0x7f090043
public const int withText = 2131296323;
@ -3896,7 +3899,10 @@ namespace QuickPlay
public const int select_dialog_singlechoice_material = 2130968630;
// aapt resource value: 0x7f040037
public const int support_simple_spinner_dropdown_item = 2130968631;
public const int songLayout = 2130968631;
// aapt resource value: 0x7f040038
public const int support_simple_spinner_dropdown_item = 2130968632;
static Layout()
{

@ -0,0 +1,31 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:orientation="vertical"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<!-- I am going to assume that the match_parent width respects the two-column layout. -->
<!-- The CardView just adds some beauty -->
<android.support.v7.widget.CardView
android:layout_width="match_parent"
android:layout_height="match_parent"
android:minHeight="100dp"
android:layout_gravity="center"
app:cardElevation="4dp"
app:cardUseCompatPadding="true"
app:cardCornerRadius="5dp"
>
<TextView
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"
android:layout_gravity="center"
android:text="Song Name"
android:id="@+id/songName"
/>
</android.support.v7.widget.CardView>
</FrameLayout>

@ -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<IPlayable> 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<TextView>(Resource.Id.songName);
}
}
}
Loading…
Cancel
Save