One of the points I discussed on my last post was about how Avalonia can replace Electron in some circumstances and I went to look into electron apps and found out that there are lots and lots of music players and youtube downloaders
and thought to myself
I've never done a music player before
I've always believed anything in my desktop is way more complicated of what I feel I'm capable of (the same goes for the libraries I use) but I felt this time I was ready to at least try and if I failed I would have learned something new for sure and if not... Let's go for it!
First, I set some goals what do I want this thing to do?
I want it to play music .mp3 and .wav are enough.
I want to have a dead-simple UI just a playlist and a media bar.
I don't want/need to play music from the internet.
Of course in my mind, I had waaaaaay more objectives... I was having a brainstorm sickness I was already building a startup from it... but I came back to earth and settled on those three realistic goals, If I ever continued with that I would have already a solid foundation to grow those ideas up.
Second, What do I need to play music?
I needed a solution that worked in Windows, MacOS, and Ubuntu and if I'm telling everyone that Avalonia is cross-platform I can't expect everyone to have to write native code for that... so I went on the look on .netcore land and I found out that the VLC library has .net standard libraries to support Windows/Linux/MacOS/Android/iOS/UWP with the help of Xamarin that also meant that the core functionality was on a .netstandard library and of course that also meant it's cross-platform ✅
Third Icons...
no app looks cool without an icon or two... I was kind of lost here since I was not going to use a ton of icons and I didn't want to rely on the internet this had to be 100% offline. Thankfully the mdi icon library provides thousands of icons in a web font or an svg/xaml (canvas)/xaml (DrawImage), I went for XAML/Canvas since I could just do the canvas icons I would need you can find those definitions here let's see a quick sample
that is the icon that represents the "Play" button
with that in mind it seems I already have most of my needs done, let's build something meaningful this time!
File Structure
Spoilers, it's flat (shocker! 😱), yeah the way F# handles files it's in a top/down manner, so even if you add folders the files have to be in order in the [PROJECT_NAME].fsproj file
the most important files here are Shell, Player, Playlist and PlayerLib.
Shell is the main module and the one that handles external messages from internal controls. If we'd need to add a different "page"/view it would be in this place we also have a subscriptions module inside of Shell which will help us to handle important messages from our media player like if it's playing, pausing, stopped if the media is ending and things like those.
typeShellWindow()asthis=inheritHostWindow()doletplayer=PlayerLib.getEmptyPlayerletprogramInit(window,player)=initwindowplayer,Cmd.none#ifDEBUGthis.AttachDevTools(KeyGesture(Key.F12))#endif/// we use this function because sometimes we dispatch messages/// from another threadletsyncDispatch(dispatch:Dispatch<'msg>):Dispatch<'msg>=matchDispatcher.UIThread.CheckAccess()with|true->funmsg->Dispatcher.UIThread.Post(fun()->dispatchmsg)|false->funmsg->dispatchmsgProgram.mkProgramprogramInitupdateview|>Program.withHostthis|>Program.withSyncDispatchsyncDispatch|>Program.withSubscription(fun_->Subs.playingplayer)|>Program.withSubscription(fun_->Subs.pausedplayer)|>Program.withSubscription(fun_->Subs.stopedplayer)|>Program.withSubscription(fun_->Subs.endedplayer)|>Program.withSubscription(fun_->Subs.timechangedplayer)|>Program.withSubscription(fun_->Subs.lengthchangedplayer)|>Program.withSubscription(fun_->Subs.chapterchangedplayer)#ifDEBUG|>Program.withConsoleTrace#endif|>Program.runWith(this,player)
that is our main window, usually, you will see this one called MainWindow. We are registering out subscriptions here so we're able to handle these messages within our Elmish module
A good relevant part here is that there are a few messages that belong only to the Shell, and then the rest of the messages are specific to handle media player elements these messages get called by the subscriptions we used when using our Program.mkProgram function call.
typeMsg=|PlayerMsgofPlayer.Msg|PlaylistMsgofPlaylist.Msg|SetTitleofstring|OpenFiles|OpenFolder|AfterSelectFolderofstring|AfterSelectFilesofstringarray(* Handle Media Player Events *)|Playing|Paused|Stopped|Ended|TimeChangedofint64|ChapterChangedofint|LengthChangedofint64
One of the most useful ones here is Stopped, TimeChanged and LengthChanged because that allows us to present a more accurate representation of the media bar. These may or may not trigger a special update within the children's controls.
The update function looks like this
letupdate(msg:Msg)(state:State)=matchmsgwith// omitted code... (* The following messages are fired from the player's subscriptions
I feel these are can help to handle updates accross the whole application
There are a lot more of events the Player Emits, but for the moment
we'll work with these *)|Playing->state,Cmd.none|Paused->state,Cmd.none|Stopped->state,Cmd.none|Ended->state,Cmd.mapPlaylistMsg(Cmd.ofMsg(Playlist.Msg.GetNext))|TimeChangedtime->state,Cmd.mapPlayerMsg(Cmd.ofMsg(Player.Msg.SetPostime))|ChapterChangedchapter->state,Cmd.none|LengthChangedlength->state,Cmd.none
Some of these events don't actually do anything... why?
They are here for demonstration purposes, let's say you want to save to your persistent storage when a song has been played but only after the user waited for it to finish, then you'll need to handle the Ended message perhaps you want to send an update yo your API to what was the last action the user did, you may need to send a request from any of these options. Most of these are just Commands that you'll be passing around to children controls like Ended and TimeChanged where we tell Playlist and Player that they need to do something (GetNext and SetPos (time) respectively) and you can always remove what you don't need.
Player is the control that contains our play/pause/previous/next/shuffle/repeat buttons it communicates to other controls via external messages. Here we show the two sets of messages this control can dispatch
Why do we use external messages? The external messages is a way for us to leverage the Elmish Top/Down communication seamlessly as we return an ExternalMsg Option type in our update function for this module.
As an example:
dispatch the Shuffle event that returns a state, a command and an external message
the Shell.fs grabs the general PlayerMsg (defined in Shell.Msg) and applies the update function from the Player module
let s, cmd, external = Player.update playermsg state.playerState
We call handlePlayerExternal external which in turns matches the correct message type to the correct command, in this case Cmd.ofMsg (PlaylistMsg(Playlist.Msg.Shuffle))
Inside Playlist.fs we capture the Shuffle message and do the corresponding shuffle logic
To play a song it's a similar flow
double click a song in the playlist and dispatch the PlaySong of Types.SongRecord message.
find the index of the song in the list and then update the state with the currentIndex. Return the updated state, an empty command and the external command PlaySong of index: int * song: Types.SongRecord
The Shell module grabs the general PlaylistMsg and calls the update function from the PlaylistModule
call handlePlaylistExternal external to match the correct command
Inside Player.fs we handle the Play of Types.SongRecord message and play the newly assigned media
Now, you might think well this is a lot of code for a simple event, I'd just register an event listener for the event from the children component in javascript but I'll also say that it's not that simple... In Vue, you might need to use an event bus in aurelia you might need a Event Aggregator in react I think you would be using something like Redux in which case it's already quite similar (read "Prior Art").
And those same 5 steps are shared with almost any other external message it's a predictable way to handle external updates to other controls.
PlayerLib is a module with two special functions that allow us to do the whole player thing working
LibVlCSharp has quite a lot of features including playing media from the network, finding devices like Chromecast and a ton of other media playing information in this case we just need a media player and a media object to play within our media player.
What does that look like?
As I said before, it's just dead simple only pick files and play them... No more, no less.
On my last post also I mentioned the following
When I want to do things for myself, I don't want to have a lot of
resources being consumed by a note-taking app
And I did verify that
As you can see the app is playing a song and it is using almost 60MB of RAM and nearly 2% CPU usage. I'd say this is the Note Taking App version of the Music Players. I can safely say that I wouldn't mind running the same amount of Avalonia Apps as the Electron ones I currently run (on my main pc) on my old 4gb desktop pc that's somewhere in here.
I won't say anything related to Spotify there because it's not fair comparison Spotify does do a lot more than what my dead simple player does.
Develop cross-plattform GUI Applications using F# and Avalonia!
Avalonia FuncUI
Develop cross-platform GUI Applications using F# and AvaloniaUI!
About
FuncUI is a thin layer built on top of AvaloniaUI. It contains abstractions
for writing UI applications in different programming styles. It also contains a component & state management system.