Hello there! Last time I wrote about using Avalonia and Avalonia.FuncUI to write a simple todo app, you can check that here:
{% post tunaxor/desktop-apps-with-avalonia-and-fsharp-4n21 %}
this time we'll do something similarly simple...
A Cross-platform music player!
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?
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
let play =
Canvas.create [
Canvas.width 24.0
Canvas.height 24.0
Canvas.children [
Path.create [
Path.fill "black"
Path.data "M8,5.14V19.14L19,12.14L8,5.14Z"
]
]
]
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!
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
Icons.fs
Types.fs
Songs.fs
PlayerLib.fs
Dialogs.fs
Extensions.fs
Playlist.fs
Player.fs
Shell.fs
Program.fs
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.
type ShellWindow() as this =
inherit HostWindow()
do
let player = PlayerLib.getEmptyPlayer
let programInit (window, player) = init window player, Cmd.none
#if DEBUG
this.AttachDevTools(KeyGesture(Key.F12))
#endif
/// we use this function because sometimes we dispatch messages
/// from another thread
let syncDispatch (dispatch: Dispatch<'msg>): Dispatch<'msg> =
match Dispatcher.UIThread.CheckAccess() with
| true -> fun msg -> Dispatcher.UIThread.Post(fun () -> dispatch msg)
| false -> fun msg -> dispatch msg
Program.mkProgram programInit update view
|> Program.withHost this
|> Program.withSyncDispatch syncDispatch
|> Program.withSubscription (fun _ -> Subs.playing player)
|> Program.withSubscription (fun _ -> Subs.paused player)
|> Program.withSubscription (fun _ -> Subs.stoped player)
|> Program.withSubscription (fun _ -> Subs.ended player)
|> Program.withSubscription (fun _ -> Subs.timechanged player)
|> Program.withSubscription (fun _ -> Subs.lengthchanged player)
|> Program.withSubscription (fun _ -> Subs.chapterchanged player)
#if DEBUG
|> 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.
type Msg =
| PlayerMsg of Player.Msg
| PlaylistMsg of Playlist.Msg
| SetTitle of string
| OpenFiles
| OpenFolder
| AfterSelectFolder of string
| AfterSelectFiles of string array
(* Handle Media Player Events *)
| Playing
| Paused
| Stopped
| Ended
| TimeChanged of int64
| ChapterChanged of int
| LengthChanged of int64
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
let update (msg: Msg) (state: State) =
match msg with
// 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.map PlaylistMsg (Cmd.ofMsg (Playlist.Msg.GetNext))
| TimeChanged time -> state, Cmd.map PlayerMsg (Cmd.ofMsg (Player.Msg.SetPos time))
| ChapterChanged chapter -> state, Cmd.none
| LengthChanged length -> 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
type ExternalMsg =
| Next
| Previous
| Play
| Shuffle
| SetLoopState of Types.LoopState
type Msg =
| Play of Types.SongRecord
| Seek of double
| SetPos of int64
| SetLength of int64
| SetLoopState of Types.LoopState
| Previous
| Pause
| Stop
| PlayInternal
| Next
| Shuffle
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:
/// Player.fs
let update msg state =
match msg with
/// ... omitted code ...
| Shuffle -> state, Cmd.none, Some ExternalMsg.Shuffle
/// ... omitted code ...
/// Shell.fs
let private handlePlayerExternal (msg: Player.ExternalMsg option) =
match msg with
| None -> Cmd.none
| Some msg ->
match msg with
/// ... omitted code ...
| Player.ExternalMsg.Shuffle -> Cmd.ofMsg (PlaylistMsg(Playlist.Msg.Shuffle))
/// ... omitted code ...
let update msg state =
match msg with
/// ... omitted code...
| PlayerMsg playermsg ->
let s, cmd, external = Player.update playermsg state.playerState
let handled = handlePlayerExternal external
let mapped = Cmd.map PlayerMsg cmd
let batch = Cmd.batch [ mapped; handled ]
{ state with playerState = s }, batch
/// ... omitted code ...
/// Playlist.fs
type Msg =
/// ... omitted cases ...
| Shuffle
/// ... omitted cases ...
let update msg state =
match msg with
/// ... omitted code ...
| Shuffle ->
match state.songList with
| Some songs ->
let shuffled = shuffle songs
{ state with
songList = Some shuffled
currentIndex = 0 }, Cmd.none, None
| None -> state, Cmd.none, None
The chain of messages would be like this
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
handlePlayerExternal external
which in turns matches the correct message type to the correct command, in this case Cmd.ofMsg (PlaylistMsg(Playlist.Msg.Shuffle))
To play a song it's a similar flow
PlaySong of Types.SongRecord
message.PlaySong of index: int * song: Types.SongRecord
PlaylistMsg
and calls the update function from the PlaylistModulehandlePlaylistExternal external
to match the correct commandPlayer.fs
we handle the Play of Types.SongRecord
message and play the newly assigned mediaNow, 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
module PlayerLib =
open LibVLCSharp.Shared
let getMediaFromlocal (source: string) =
use libvlc = new LibVLC()
new Media(libvlc, source, FromType.FromPath)
let getEmptyPlayer =
use libvlc = new LibVLC()
new MediaPlayer(libvlc)
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.
Where can I find this? Two places:
{% github AvaloniaCommunity/Avalonia.FuncUI %}
in the src/Examples/MusicPlayer
section which I just PR'd yesterday (It's an awesome feeling to contribute something to an open-source project 🐱💻)
or the unpolished and unorganized here {% github AngelMunoz/Raznor %}
Either way, do you have questions or comments? Leave them below or reach me in twitter :) Have a nice week!
Is there something wrong? Raise an issue!
Or if it's simpler, find me in Threads!