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>
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.