For a long time now, I've had this problem that I haven't liked any media players on Android for listening to long files, especially podcasts. While many media players come with a 30-second rewind, they don't give me the level of control I want, which is to be able to skip forward 30 seconds and move around in 5-10 second intervals. Well, what can you do, right?
I had in mind to either do this with React Native and Web. Well, considering I only need to pick a single file, control playback, and target my own Android devices (which are all > 4.4), this wasn't that hard of a decision. This also saved me the trouble of trying to get this published.
I picked Halogen since the way it handles effects in response to queries makes it quite easy for me to do everything I need.
One thing I found out I needed was ObjectURL hashes, since security features mandate that a cilent's actual filepath to a file is not allowed to be shown. So I made a newtype for that.
newtype ObjectURL = ObjectURL String
derive instance newtypeFilePath :: Newtype ObjectURL _
The derived Newtype
instance lets me use some other nice functions to allow functions to be applied "over" my newtype. We'll come back to this later.
The actual application state that I care about is only whether or not I have a file to work with, as the actual running state of the audio element is something I really don't want to carry around. This makes for some boring state though.
type State =
{ file :: Maybe ObjectURL
}
Next, I need my actual Query data type that I'll be using with Halogen. This isn't too sophisticated either:
data Query a
= FileSet a
| Skip SkipDir SkipSize a
data SkipDir = Bck | Fwd
data SkipSize = Sm | Md | Lg
When the file is set, I'll be just triggering the action to actually get the file hash to set it.
With the skip, I wanted to make sure a given button has what direction and size of skip defined to emit, so that I can use that to make the effects as needed.
Finally, I have the type of effects that will run in my Halogen component as an Aff
:
type AppEffects eff =
Aff
( console :: CONSOLE
, dom :: DOM
| eff)
As our application only needs to be a simple single component, there are features of Halogen we won't be using here (and that's a good thing!), and our ui definition looks simple enough:
import Halogen as H
import Halogen.HTML as HH
ui :: forall eff. H.Component HH.HTML Query Unit Void (AppEffects eff)
ui =
H.component
{ initialState: const initialState
, render
, eval
, receiver: const Nothing
}
where
-- [...]
The Halogen Component definition has a ton of type variables, but there are only three we are actually using here.
- The first, the type of what we're rendering, which will be HTML.
- The second, the query algebra, which is the query data type with a parameter we defined earlier.
- The last (fifth), the monad used for our effects, which is the AppEffects Aff we defined earlier.
The other two are for input and output that are used for parent-child communication that we won't be using here.
The actual spec passed to component seems simple enough: a record with initialState being a function for state using the Unit input we specified, a render function, and eval function, and a receiver function that we don't use here. In the where block, we define what we use here:
initialState =
{ file: Nothing
}
Unsurprisingly, we have nothing for the file at the start.
import Halogen as H
import Halogen.Aff as HA
import Halogen.HTML as HH
import Halogen.HTML.Events as HE
import Halogen.HTML.Properties as HP
render :: State -> H.ComponentHTML Query
render state =
HH.div
[ HP.class_ $ wrap "container" ]
[ HH.div
[ HP.class_ $ wrap "root" ]
[ HH.h1_ [HH.text "glorious web audio thing"]
, HH.div_
[ HH.input
[ HP.ref $ wrap "input"
, HP.type_ HP.InputFile
, HP.prop (wrap "accept") "audio/*"
, HE.onChange (HE.input_ FileSet)
]
]
, HH.div_
[ HH.audio
[ HP.ref $ wrap "audio"
, HP.src $ unwrap $ fromMaybe (wrap "") state.file
, HP.controls true
, HP.autoplay true
]
[]
]
, HH.div
[ HP.class_ $ wrap "buttons" ]
[ HH.button [HE.onClick (HE.input_ $ Skip Bck Lg)] [HH.text "<<<"]
, HH.button [HE.onClick (HE.input_ $ Skip Bck Md)] [HH.text "<<"]
, HH.button [HE.onClick (HE.input_ $ Skip Bck Sm)] [HH.text "<"]
, HH.button [HE.onClick (HE.input_ $ Skip Fwd Sm)] [HH.text ">"]
, HH.button [HE.onClick (HE.input_ $ Skip Fwd Md)] [HH.text ">>"]
, HH.button [HE.onClick (HE.input_ $ Skip Fwd Lg)] [HH.text ">>>"]
]
]
]
Not much is involved here either, but we should keep note of a few things:
[ HH.input
[ HP.ref $ wrap "input"
, HP.type_ HP.InputFile
, HP.prop (wrap "accept") "audio/*"
, HE.onChange (HE.input_ FileSet)
]
]
The onchange handler here is set using a HE.input_ function, which will simply return the query FileSet
to our eval function. Likewise is done for the skip buttons.
eval :: Query ~> H.ComponentDSL State Query Void (AppEffects eff)
This is where the meat and bones of our app lives. Let's first see how we handle FileSet
:
eval (FileSet next) = do
input <- H.getHTMLElementRef $ wrap "input"
case input >>= fromHTMLElement of
Just el -> do
nxs <- H.liftEff <<< files $ el
case toMaybe nxs >>= toMaybe <<< item 0 of
Just file -> do
url <- H.liftEff $ url =<< window
blob <- H.liftEff $ createObjectURL file url
prevBlob <- H.gets _.file
case prevBlob of
Just x ->
H.liftEff $ revokeObjectURL (unwrap x) url
_ -> pure unit
H.modify \s ->
s {file = Just <<< wrap $ blob}
_ -> H.liftAff $ log "No file found"
_ -> H.liftAff $ log "No input ref found"
pure next
First, we use the ref "input" from earlier to get our element. We then transform it using fromHTMLElement
into the file input type that we will be using, where if the element parses correctly, then we can continue. Otherwise, nothing is done.
Note that we use liftEff
to "transform" our Eff
actions into our Halogen component context.
Once the element has been found and typed, we then use its files
property to get the first item. We can't use the value due to how the file input is designed. We then take the window.URL property to use URL.createObjectURL to get the ObjectURL we need.
Once we have our ObjectURL, we make sure to revoke the previous ObjectURL so that the browser is explicitly told to remove handlers to the file, and then modify our state to set the file.
This will load up our file to our audio element, allowing it to be played. Let's now look at how skipping is done:
eval (Skip dir size next) = do
audio <- H.getHTMLElementRef $ wrap "audio"
case audio >>= fromHTMLElement of
Just el -> do
let el' = htmlAudioElementToHTMLMediaElement el
current <- H.liftEff $ currentTime el'
H.liftEff $ setCurrentTime (current + delta) el'
_ -> H.liftAff $ log "No audio ref found"
pure next
where
skip = case size of
Lg -> 30.0
Md -> 10.0
Sm -> 5.0
delta = skip * case dir of
Bck -> -1.0
_ -> 1.0
We grab the "audio" ref to get the element and attempt to type it as above. When we have our audio element, we then convert it to a media element to control it as such.
Using the size and direction, we are able to compute the delta we want, and we then grab the current time of the control, apply our delta, and set the current time of the audio element to the result. That's about it!
import Halogen.Aff as HA
import Halogen.VDom.Driver as D
main = HA.runHalogenAff do
body <- HA.awaitBody
io <- D.runUI ui unit body
log "Running"
We use Halogen's runHalogenAff utility to run our Aff, which consists of waiting for the body and running our UI using our ui.
To use offline capabilities, we need to register a service worker and have a manifest file. It ends up being not much more than just some boilerplate:
<!--in head-->
<link rel="manifest" href="manifest.json">
<script>
if('serviceWorker' in navigator) {
navigator.serviceWorker
.register('sw.js')
.then(function() { console.log("Service Worker Registered"); });
}
</script>
{
"name": "My Web Audio Player",
"short_name": "WebAudio",
"icons": [{
"src": "icon-256x256.png",
"sizes": "256x256",
"type": "image/png"
}],
"start_url": "/purescript-web-audio-player-demo/index.html",
"display": "standalone",
"orientation": "portrait",
"background_color": "#3E4EB8",
"theme_color": "#2F3BA2"
}
self.addEventListener('install', function(e) {
e.waitUntil(
caches.open('airhorner').then(function(cache) {
return cache.addAll([
'./',
'./index.html',
'./dist/app.js',
'./dist/app.css'
]);
})
);
});
After all that, we have our app looking like this on Android:
And we can add it to the home-screen like so:
By using the existing web platform, we were able to build this audio player that works offline on mobile without any native mess or too much work.
I hope this has shown that building simple apps in Purescript is fun. I hope this helps you build your own Purescript apps. Let me know on Twitter what you think!
Also, please do try it out yourself! https://justinwoo.github.io/purescript-web-audio-player-demo/