Make yourself a web audio player with Purescript and Halogen

Make yourself a web audio player with Purescript and Halogen

Talk given at Helsinki Haskell user group 11 May 2017


Justin Woo

May 11, 2017


  1. Make yourself a web audio player with Purescript and Halogen

    Justin Woo 11 May 2017
  2. Why? • I want to listen to my 10 -

    50 min mp3s ◦ ...and seek forward 5/10/30 seconds ◦ ...and seek backward 5/10/30 seconds • Haven’t found anything I like • Don’t want to make native app • Want it to be easy to update
  3. What is web audio? • My working definition: ◦ Thingies

    for audio to be played back on browser • What I need: ◦ Way to use local files for <audio> sources ◦ Use built-in play/pause, seek ◦ Need to be able to get and set current time
  4. What is Purescript? • Strongly typed language inspired by and

    written in Haskell • Strictly evaluated • Higher Kinded Types • Row types • Mature JS backend • Allows easy FFI
  5. • Browsers • Node.js • Toasters (e.g. rpi, hopefully not

    internet of shit) • Reuse and adapt existing libraries ◦ E.g. purescript-echarts - do you really want to rewrite this? ▪ Purescript-echarts • 9 people 115 commits from Jan 2015 ▪ Baidu EFE echarts • 37 people 3780 commits from May 2013 Why JS and easy FFI?
  6. What is Halogen? • Component-based UI library by Slamdata •

    Based on common ideas, with user-defined: ◦ State types ◦ Query type ◦ Input types (when a component used as child) ◦ Output message types • Render of State -> HTML Query ◦ Parameterized HTML since before even Elm got on board • Query eval eval :: forall m. Query ~> H.ComponentDSL State Query Message m
  7. Query eval • We get put/modify/get for state-related things •

    LiftEff/LiftAff as needed • And some more, but these are all I usually need
  8. Gettin’ them types ready First, it’d be nice to use

    a newtype for ObjectURL The only sensible state to keep is our file ObjectURL Then we only need to handle two query types: setting a file and skipping newtype ObjectURL = ObjectURL String type State = { file :: Maybe ObjectURL } data Query a = FileSet a | Skip SkipDir SkipSize a data SkipDir = Bck | Fwd data SkipSize = Sm | Md | Lg
  9. Just a bit of EFFin’ ceremony We need to declare

    the core row of effects in our application In our app, we’ll be writing to the console and using DOM methods a lot Of course, some qualified imports to keep in mind type AppEffects eff = ( console :: CONSOLE , dom :: DOM | eff ) 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 import Halogen.VDom.Driver as D
  10. Component Types Since we don’t need any fancy parent/child components

    for this project, the params are simple: • HTML • Our Query type • Input type (no inputs) • Output type (no outputs) • Our type for non-component effects, using Aff for asynchronous effects ui :: forall eff. H.Component HH.HTML Query Unit Void (Aff (AppEffects eff))
  11. Component Spec Now we can create our component spec! Initial

    state is defined simply as such, and the rest we will define as we go along Receiver used for inputs, which we won’t be using ui = H.component { initialState: const initialState , render , eval , receiver: const Nothing } where initialState = { file: Nothing }
  12. Rendering Renders from state to Query-parameterized HTML Ref: for grabbing

    HTML reference Rest are normal indexed properties onChange: attached event handler to input our FileSet data render :: State -> H.ComponentHTML Query -- ... [ HH.input [ HP.ref (wrap "input") , HP.type_ HP.InputFile , HP.prop (wrap "accept") "audio/*" , HE.onChange (HE.input_ FileSet) ]]
  13. Rendering audio Well… yup [ [ HP.ref $ wrap

    "audio" , HP.src $ fromMaybe "" (unwrap <$> state.file) , HP.controls true , HP.autoplay true ] []
  14. Rendering buttons More of the same HH.button [ HE.onClick $

    HE.input_ (Skip Bck Lg) ] [ HH.label_ [HH.text "<<<"] ]
  15. Eval First, let’s see our type Natural transformation of our

    Query into the Component DSL using our State, Query, no outgoing messages, and non-component Aff of AppEffects eff. eval :: Query ~> H.ComponentDSL State Query Void (Aff (AppEffects eff))
  16. Eval (FileSet next) Originally written at 2AM in the simplest

    way possible Uses the ref we defined earlier to get the generic HTML element Then uses purescript-dom-classy to read the input as an input element On success, we then continue handling our input eval (FileSet next) = do input <- H.getHTMLElementRef $ wrap "input" case fromHTMLElement =<< input of Nothing -> log' "No input ref found" Just el -> handleInput el pure next
  17. Eval (FileSet next) continued More ugly code censored for your

    viewing pleasure window.URL is used create an object url string from it and set it to our state This is then put into the component state using modify handleInput el = -- ... handleFile file = do url <- H.liftEff $ url =<< window blob <- H.liftEff $ createObjectURL file url -- ... H.modify \s -> s {file = pure $ wrap blob}
  18. Eval (Skip dir size next) More straightforward, since we just

    need to find this element and set the time eval (Skip dir size next) = do audio <- H.getHTMLElementRef $ wrap "audio" case htmlAudioElementToHTMLMediaElement <$> (fromHTMLElement =<< audio) of Just el -> do current <- H.liftEff $ currentTime el H.liftEff $ setCurrentTime (current + delta) el _ -> log' "No audio ref found" pure next
  19. Getting the delta for skip Might as well do it

    the easiest way possible skip = case size of Lg -> 30.0 Md -> 10.0 Sm -> 5.0 delta = skip * case dir of Bck -> -1.0 _ -> 1.0
  20. Main runHalogenAff just runs our Aff in a fire-and-forget way

    We grab the document.body and run our component on it That’s it! main = HA.runHalogenAff do body <- HA.awaitBody io <- D.runUI ui unit body log "Running"
  21. That’s all, folks!

  22. Result

  23. Links • Repo ◦ • Blog post ◦

    • Halogen ◦