Search code examples
javascripttimerarchitecturefrpelm

sending signal from subcomponent in elm


I'm making a small application in Elm. It displays a timer on the screen, and when the timer reaches zero, it plays a sound. I'm having trouble figuring out how to send a message(?) from the the timer to the sound-player.

Architecturally, I have three modules: a Clock module that represents the timer, a PlayAudio module that can play audio, and a Main module that ties together the Clock module and PlayAudio module.

Ideally, when the clock reaches zero, I want to do something like sending a signal from the Clock module. When the clock reaches zero, Clock will send a signal to Main, which will forward it to PlayAudio.

However, from reading the Elm documentation, it seems like having anything other than Main deal with signals is discouraged. So that leads me to my first question. What is a good way of modeling this change in state? Should the update function from Clock return whether or not it has ended? (This is how I am doing it below, but I would be very open to suggestions about how to do it better.)

My second question is about how to get the sound to play. I will be using raw Javascript to play the sound, which, I believe, means that I have to use ports. However, I'm not sure how to interact with a port defined in Main from my submodule, PlayAudio.

Below is the code I am using.

Clock.elm:

module Clock (Model, init, Action, signal, update, view) where

import Html (..)
import Html.Attributes (..)
import Html.Events (..)
import LocalChannel (..)
import Signal
import Time (..)

-- MODEL

type ClockState = Running | Ended

type alias Model =
    { time: Time
    , state: ClockState
    }

init : Time -> Model
init initialTime =
    { time = initialTime
    , state = Running
    }

-- UPDATE

type Action = Tick Time

update : Action -> Model -> (Model, Bool)
update action model =
  case action of
    Tick tickTime ->
        let hasEnded = model.time <= 1
            newModel = { model | time <-
                                    if hasEnded then 0 else model.time - tickTime
                               , state <-
                                    if hasEnded then Ended else Running }           
        in (newModel, hasEnded)

-- VIEW

view : Model -> Html
view model =
  div []
    [ (toString model.time ++ toString model.state) |> text ]

signal : Signal Action
signal = Signal.map (always (1 * second) >> Tick) (every second)

PlaySound.elm:

module PlaySound (Model, init, update, view) where

import Html (..)
import Html.Attributes (..)
import Html.Events (..)
import LocalChannel (..)
import Signal
import Time (..)

-- MODEL

type alias Model =
    { playing: Bool
    }

init : Model
init =
    { playing = False
    }

-- UPDATE

update : Bool -> Model -> Model
update shouldPlay model =
    { model | playing <- shouldPlay }

-- VIEW

view : Model -> Html
view model =
  let node = if model.playing
                then audio [ src "sounds/bell.wav"
                           , id "audiotag" ]
                           [] 
                else text "Not Playing"
  in div [] [node]

Main.elm:

module Main where

import Debug (..)
import Html (..)
import Html.Attributes (..)
import Html.Events (..)
import Html.Lazy (lazy, lazy2)
import Json.Decode as Json
import List
import LocalChannel as LC
import Maybe
import Signal
import String
import Time (..)
import Window

import Clock
import PlaySound

---- MODEL ----

-- The full application state of our todo app.
type alias Model =
    { clock    : Clock.Model
    , player : PlaySound.Model
    }

emptyModel : Model
emptyModel =
    { clock = 10 * second |> Clock.init
    , player = PlaySound.init
    }

---- UPDATE ----

type Action
    = NoOp
    | ClockAction Clock.Action

-- How we update our Model on a given Action?
update : Action -> Model -> Model
update action model =
    case action of
      NoOp -> model

      ClockAction clockAction -> 
          let (newClock, hasEnded) = Clock.update clockAction model.clock  
              newPlaySound = PlaySound.update hasEnded model.player
          in { model | clock <- newClock
                     , player <- newPlaySound }

---- VIEW ----

view : Model -> Html
view model =
    let context = Clock.Context (LC.create ClockAction actionChannel)
    in div [ ]
      [ Clock.view context model.clock
      , PlaySound.view model.player
      ]

---- INPUTS ----

-- wire the entire application together
main : Signal Html
main = Signal.map view model

-- manage the model of our application over time
model : Signal Model
model = Signal.foldp update initialModel allSignals

allSignals : Signal Action
allSignals = Signal.mergeMany
                [ Signal.map ClockAction Clock.signal
                , Signal.subscribe actionChannel
                ]

initialModel : Model
initialModel = emptyModel

-- updates from user input
actionChannel : Signal.Channel Action
actionChannel = Signal.channel NoOp

port playSound : Signal ()
port playSound = ???

index.html:

<!DOCTYPE html>
<html>
 <head>
  <meta charset="UTF-8">
  <script src="js/elm.js" type="text/javascript"></script>
  <link rel="stylesheet" href="style.css">
 </head>
 <body>
        <script type="text/javascript">
                 var todomvc = Elm.fullscreen(Elm.Main);
                todomvc.ports.playSound.subscribe(function() {
                                setTimeout(function() {
                                        document.getElementById('audiotag').play();
                                }, 50);
                });
        </script>
 </body>
</html>

Solution

  • This approach looks very principled and in accordance with the guidelines of the Elm Architecture post. In that document at the end is a section called One last pattern, which does exactly what you do: have the update function give back a pair if you need to signal from your component to another component.
    So I think you're doing it right. Of course following this architecture so strictly in such a small application does increase boilerplate/relevant code ratio.

    Anyway, the only changes you need to make are in Main.elm. You don't actually need a Channel to send a message from a subcomponent to Main, because Main starts the components and wires the update functions together. So you can just use the extra output of the update function of the component and split that off the model signal, into the port.

    ---- UPDATE ----
    
    -- How we update our Model on a given Action?
    update : Clock.Action -> Model -> (Model, Bool)
    update clockAction model =
        let (newClock, hasEnded) = Clock.update clockAction model.clock  
            newPlaySound = PlaySound.update hasEnded model.player
        in ( { model | clock <- newClock
                   , player <- newPlaySound }, hasEnded)
    
    ---- VIEW ----
    
    view : Model -> Html
    view model =
        div [ ]
          [ Clock.view model.clock
          , PlaySound.view model.player
          ]
    
    ---- INPUTS ----
    
    -- wire the entire application together
    main : Signal Html
    main = Signal.map (view << fst) model
    
    -- manage the model of our application over time
    model : Signal Model
    model = Signal.foldp update initialModel Clock.signal
    
    initialModel : Model
    initialModel = emptyModel
    
    port playSound : Signal ()
    port playSound =
      model
      |> Signal.map snd
      |> Signal.keepIf ((==) True)
      |> Signal.map (always ())
    

    Final note: Elm 0.15 is out, and will at the very least simplify your imports. But more importantly interop with JavaScript from within Elm (without ports) is made easier, so as soon as someone creates bindings to a sound library, you should be able to do away with that port.