Making Movie Monad

2016/08/15  

David Lettier  

Preview

Below you see the desktop video player we will build called, “Movie Monad.”

Caminandes and Big Buck Bunny from Blender Cloud.

“Caminandes” and “Big Buck Bunny” from Blender Cloud.

Under the hood, the application uses the Electron framework which in turn uses Chromium and Node.js. Using only HTML, CSS, and JavaScript, you can use Electron to develop a desktop application. The nice part about Movie Monad, however, is that we will create the HTML, CSS, and JavaScript using Haskell.

Setup

In order to establish our build pipeline, we will need to setup our environment with a few tools, directories, and project files.

Stack

Stack will handle all of our Haskell environment needs from installing the compiler to managing our Haskell dependencies. The instructions contain all of the installation information you will need.

For a general Linux installation you could do:

cd Downloads
wget https://www.stackage.org/stack/linux-x86_64
tar xvzf stack-1.1.2-linux-x86_64.tar.gz
cd ~
echo 'export PATH="$HOME/Downloads/stack-1.1.2-linux-x86_64/:$PATH"' >> .bashrc
source .bashrc
# Or if you use ZSH.
echo 'export PATH="$HOME/Downloads/stack-1.1.2-linux-x86_64/:$PATH"' >> .zshrc
source .zshrc
stack

Project Directory

With Stack installed, we will need to create our project directory.

cd ~
mkdir -p movieMonad

We will also need to define some additional directories for our project.

cd ~/movieMonad
mkdir -p src/html src/css src/js src/electronBoot branding conf dist bin

With the directories in place, create the empty project files.

cd ~/movieMonad
touch src/html/Main.hs src/css/Main.hs src/js/Main.hs src/electronBoot/Main.hs conf/package.js branding/icon.png makefile

YAML

To use Stack, you will need to define a stack.yaml file in the root of the directory.

cd ~/movieMonad
touch stack.yaml

Go ahead and open this file with your favorite text editor and copy this information into the stack.yaml file.

resolver: lts-6.11
packages:
- '.'
extra-deps: []
flags: {}
extra-package-dbs: []

Cabal

We will also need an movieMonad.cabal file which Stack will use to build our project. Make sure this file is located in the root of the project.

cd ~/movieMonad
touch movieMonad.cabal

Go ahead and open this file with your favorite text editor and copy this information into the movieMonad.cabal file.

name:                movieMonad
version:             0.1.0.0
synopsis:            Desktop video player.
description:         Please see README.md
homepage:            https://github.com/lettier/moviemonad
license:             Apache
license-file:        LICENSE
author:              David Lettier
copyright:           2016 David Lettier
category:            Desktop
build-type:          Simple
cabal-version:       >=1.10

executable movieMonadCss
  hs-source-dirs:       src/css
  main-is:              Main.hs
  default-language:     Haskell2010
  build-depends:        base >= 4.7 && < 5
                      , clay

executable movieMonadHtml
  hs-source-dirs:       src/html
  main-is:              Main.hs
  default-language:     Haskell2010
  build-depends:        base >= 4.7 && < 5
                      , blaze-html

executable movieMonadJs
  hs-source-dirs:       src/js
  main-is:              Main.hs
  default-language:     Haskell2010
  build-depends:        base >= 4.7 && < 5
                      , cpphs
                      , fay
                      , fay-base
                      , fay-builder
                      , fay-dom
                      , fay-jquery
                      , fay-text
                      , fay-uri
                      , hlint

executable movieMonadElectronBoot
  hs-source-dirs:       src/electronBoot
  main-is:              Main.hs
  default-language:     Haskell2010
  build-depends:        base >= 4.7 && < 5
                      , cpphs
                      , fay
                      , fay-base
                      , fay-builder
                      , fay-dom
                      , fay-jquery
                      , fay-text
                      , fay-uri

NVM

To install Electron we will need Node.js which we will get via NVM.

cd ~/Downloads
wget -qO- https://raw.githubusercontent.com/creationix/nvm/v0.31.2/install.sh | bash
cd ~/movieMonad
echo 'v4.0.0' > .nvmrc
nvm use

Electron and Electron Packager

We will need Electron and Electron Packager to run and box up Movie Monad for distribution.

cd ~/movieMonad
nvm use
npm install -g electron electron-packager

Package.json

Electron needs a package.json file to run. Copy the following into ~/movieMonad/conf/package.json.

{
  "name"    : "movieMonad",
  "productName" : "movieMonad",
  "author": {
    "name": "David Lettier",
    "url": "http://www.lettier.com/"
  },
  "version" : "0.0.0.1",
  "main"    : "boot.js"
}

GNU Make

To automate our build process, we will use make. If you have ever used NPM scripts, you will be able to use make. Install make for your platform and then put the following in ~/movieMonad/makefile

# David Lettier (C) 2106
# http://www.lettier.com/

.RECIPEPREFIX != ps
PROJECT_NAME = movieMonad
HASKELL_PACKAGE_SANDBOX = ~/.stack/snapshots/x86_64-linux/lts-6.11/7.10.3/pkgdb/
FAY_PACKAGES = fay-jquery,fay-text
FAY_CC = HASKELL_PACKAGE_SANDBOX=$(HASKELL_PACKAGE_SANDBOX) fay --package $(FAY_PACKAGES) -p --Wall

all: build runElectron

build: clean gatherDependencies buildHaskell copyConf copyIcon buildElectronDists

clean: cleanDist cleanBin

cleanDist:
  mkdir -p dist && mkdir -p dist_old && rm -rf dist_old && mv -f dist dist_old && mkdir -p dist

cleanBin:
  mkdir -p bin && mkdir -p bin_old && rm -rf bin_old && mv -f bin bin_old && mkdir -p bin

gatherDependencies: installStackDependencies downloadJquery

installStackDependencies:
  stack install --dependencies-only

downloadJquery:
  wget http://ajax.googleapis.com/ajax/libs/jquery/1.10.2/jquery.min.js -O dist/jquery.js

buildHaskell: buildHtml buildCss buildJs

buildHtml:
  stack ghc -- src/html/Main.hs -o bin/$(PROJECT_NAME)Html && bin/$(PROJECT_NAME)Html > dist/index.html

buildCss:
  stack ghc -- src/css/Main.hs -o bin/$(PROJECT_NAME)Css && bin/$(PROJECT_NAME)Css > dist/all.css

buildJs:
  $(FAY_CC) -o dist/all.js src/js/Main.hs && \
  $(FAY_CC) -o dist/boot.js src/electronBoot/Main.hs

copyIcon:
  cp branding/icon.png dist/

copyConf:
  cp -R conf/. dist/

buildElectronDists:
  mkdir -p dist/electronDists/ && electron-packager dist/ --all --version 0.37.2 --out dist/electronDists

runElectron:
  electron dist/

Project Directory Structure

With our build environment setup, our project directory should look like the following.

~/movieMonad
  src/
    html/
      Main.hs
    css/
      Main.hs
    js/
      Main.hs
    electronBoot/
      Main.hs
  conf/
    package.json
  branding/
    icon.png
  bin/
  dist/
  stack.yaml
  movieMonad.cabal
  makefile

Code

The source code to Movie Monad consists of four major files.

HTML

Let us start with defining the HTML of Movie Monad. Since the application is fairly simple, we only need to define a little bit of structure.

{-# LANGUAGE OverloadedStrings #-}

This language extension allows us to take a string literal such as "Some text." and use it for arguments that require type ByteString, Text, or String/[Char] (a ['l','i','s','t'] or array of characters).

import Text.Blaze.Html.Renderer.Pretty
import Text.Blaze.Html5 as H hiding (main)
import Text.Blaze.Html5.Attributes as A

Here we import the needed Blaze modules. We will hide main as we will be defining it ourselves. When you see A–think Text.Blaze.Html5.Attributes.

main :: IO ()
main = putStrLn $ renderHtml $ docTypeHtml $ do

We start off with setting the document type, rendering the HTML to a string, and printing that string out.

  H.head $ do
    H.title    "Movie Monad - Lettier.com"
    styleSheet "https://fonts.googleapis.com/css?family=Oswald"
    styleSheet "https://cdnjs.cloudflare.com/ajax/libs/foundicons/3.0.0/foundation-icons.min.css"
    styleSheet "all.css"
    scriptSrc  "jquery.js"
    H.script $ toHtml' "if ('require' in window) { window.$ = window.jQuery = require('./jquery.js'); }"
    scriptSrc  "all.js"

This is where we describe the <head><!-...-></head> section of our HTML document. We will be using a custom font from Google and icons from Foundation. The all.css and all.js files are our own custom CSS and JavaScript which we will define later. The jquery.js is a local copy of the jQuery JavaScript library. The JavaScript string requires the jQuery library and sets the jQuery object as a property of the window. If we run Movie Monad with Electron and without this line, an exception will be raised that jQuery is undefined. This little bit of JavaScript will be ignored if Movie Monad is loaded inside the browser–as a normal web page–instead of running it with Electron.

  H.body $
    H.div ! A.id "pageContainer" $ do
      H.input ! A.id "fileInput" ! A.name "fileInput" ! A.type_ "file"
      H.label ! A.for "fileInput" $ H.i ! A.class_ "fi-upload" ! A.title "Upload a Video File" $ empty
      H.div   ! A.id "videoContainer" $ empty
      H.div   ! A.id "statusMessage" $ empty

The body of the document consists of a page container that holds a file input, label, video container, and a status message container which allows us to relay any feedback we have for the user.

This will translate into the following.

  <body>
    <div id="pageContainer">
      <input id="fileInput" name="fileInput" type="file" />
      <label for="fileInput">
        <i class="fi-upload"></i>
      </label>
      <div id="videoContainer"></div>
      <div id="statusMessage"></div>
    </div>
  </body>

Notice how $ empty defines the inner HTML as blank for any defined tag.

toHtml' :: String -> Html
toHtml' = toHtml

empty :: Html
empty = toHtml' ""

styleSheet :: AttributeValue -> Html
styleSheet s = H.link ! A.href s ! A.rel "stylesheet" ! A.type_ "text/css"

scriptSrc :: AttributeValue -> Html
scriptSrc s = H.script ! A.src s ! A.type_ "text/javascript" $ empty

These are helper functions to DRY up some of the code. Each one returns data with type Html. stylesheet "link" translates to <link href="link" rel="stylesheet" type="text/css" />.

Below is the entire ~/movieMonad/src/html/Main.hs file–be sure to copy it over.

{-
  David Lettier (C) 2016
  http://www.lettier.com/
-}

{-# LANGUAGE OverloadedStrings #-}

import Text.Blaze.Html.Renderer.Pretty
import Text.Blaze.Html5 as H hiding (main)
import Text.Blaze.Html5.Attributes as A

main :: IO ()
main = putStrLn $ renderHtml $ docTypeHtml $ do
  H.head $ do
    H.title    "Movie Monad - Lettier.com"
    styleSheet "https://fonts.googleapis.com/css?family=Oswald"
    styleSheet "https://cdnjs.cloudflare.com/ajax/libs/foundicons/3.0.0/foundation-icons.min.css"
    styleSheet "all.css"
    scriptSrc  "jquery.js"
    H.script $ toHtml' "if ('require' in window) { window.$ = window.jQuery = require('./jquery.js'); }"
    scriptSrc  "all.js"
  H.body $
    H.div ! A.id "pageContainer" $ do
      H.input ! A.id "fileInput" ! A.name "fileInput" ! A.type_ "file"
      H.label ! A.for "fileInput" $ H.i ! A.class_ "fi-upload" ! A.title "Upload a Video File" $ empty
      H.div   ! A.id "videoContainer" $ empty
      H.div   ! A.id "statusMessage" $ empty

toHtml' :: String -> Html
toHtml' = toHtml

empty :: Html
empty = toHtml' ""

styleSheet :: AttributeValue -> Html
styleSheet s = H.link ! A.href s ! A.rel "stylesheet" ! A.type_ "text/css"

scriptSrc :: AttributeValue -> Html
scriptSrc s = H.script ! A.src s ! A.type_ "text/javascript" $ empty

CSS

The CSS is fairly straightforward and nearly reads like a normal CSS file. Clay is a preprocessor like Sass or Less but you have the full expressiveness of Haskell at your disposal.

{-# LANGUAGE OverloadedStrings #-}

import Clay
import Clay.Box
import Clay.Transform

Like before, we list our language extensions and necessary module imports.

main :: IO ()
main = putCss $ do
  body ? do
    backgroundColor "#323A45"
    color           "#eee"
    fontFamily      ["Oswald"] [sansSerif]
    overflow        hidden
    height          $ pct 100
    width           $ pct 100
    marginAll       $ px 0

putCss will print out the generated CSS to standard out. You can see how we are defining the style for the main body of the document. Oswald is the custom font provided by Google. pct translates to %.

This block will translate roughly to the following.

body {
  background-color: "#323A45";
  /* ... */
}
  label ? do
    borderBottomStyle   solid
    borderBottomColor   $ rgba' 26 105  94 0.7969
    backgroundColor     $ rgba' 31 187 166 0.8
    boxShadow           (px 0) (px 10) (px 5) (rgba' 27 39 35 0.39)
    fontSize            $ px 60
    paddingAll          $ px 20
    borderBottomWidth   $ px 10
    display             inlineBlock
    cursor              pointer

This the label for the file input box. It is here that we make it more like a button.

  video ? do
    height $ pct 100
    width  $ pct 100

The video dimensions should always fill the window to make it responsive. The user can adjust the window size and the video will scale to match while at the same time keeping its aspect ratio.

  "#pageContainer" ?
    textAlign (alignSide sideLeft)
  "#fileInput" ? do
    width        $ px 0
    outlineWidth $ px 0
    display      none
  "#videoContainer" ? do
    position absolute
    zIndex   (-1)
    top      $ px 0
    left     $ px 0
    height   $ pct 100
    width    $ pct 100
  "#statusMessage" ? do
    position   absolute
    minHeight  $ pct 100
    minWidth   $ pct 100
    paddingTop $ pct 25
    zIndex     (-2)
    top        $ px 0
    left       $ px 0
    width      $ pct 100
    height     $ pct 100
    fontSize   $ px 60
    textAlign  $ alignSide sideCenter

Here we use ID selectors to style the various containers, the file input, and the status message. The status message will always stay under the video.

paddingAll :: Size z -> Css
paddingAll s = padding s s s s

marginAll :: Size z -> Css
marginAll s = margin s s s s

rgba' :: Integer -> Integer -> Integer -> Double -> Color
rgba' r g b a = rgba r g b $ floor $ a * 255

These functions provide more convenience over the functions provided by Clay. The padding and margin functions allow us to have uniform sizes without having to specify the same size for all four sides. Typically the alpha channel (in rgba) is expressed in decimal with a range of zero to one instead of an integer with a range of zero to 255.

Below is the entire ~/movieMonad/src/css/Main.hs file–be sure to copy it over.

{-
  David Lettier (C) 2016
  http://www.lettier.com/
-}

{-# LANGUAGE OverloadedStrings #-}

import Clay
import Clay.Box
import Clay.Transform

-- Color scheme: http://flatcolors.net/palette/757-carbon-flat-colorful

main :: IO ()
main = putCss $ do
  body ? do
    backgroundColor "#323A45"
    color           "#eee"
    fontFamily      ["Oswald"] [sansSerif]
    overflow        hidden
    height          $ pct 100
    width           $ pct 100
    marginAll       $ px 0
  label ? do
    borderBottomStyle   solid
    borderBottomColor   $ rgba' 26 105  94 0.7969
    backgroundColor     $ rgba' 31 187 166 0.8
    boxShadow           (px 0) (px 10) (px 5) (rgba' 27 39 35 0.39)
    fontSize            $ px 60
    paddingAll          $ px 20
    borderBottomWidth   $ px 10
    display             inlineBlock
    cursor              pointer
  video ? do
    height $ pct 100
    width  $ pct 100
  "#pageContainer" ?
    textAlign (alignSide sideLeft)
  "#fileInput" ? do
    width        $ px 0
    outlineWidth $ px 0
    display      none
  "#videoContainer" ? do
    position absolute
    zIndex   (-1)
    top      $ px 0
    left     $ px 0
    height   $ pct 100
    width    $ pct 100
  "#statusMessage" ? do
    position   absolute
    minHeight  $ pct 100
    minWidth   $ pct 100
    paddingTop $ pct 25
    zIndex     (-2)
    top        $ px 0
    left       $ px 0
    width      $ pct 100
    height     $ pct 100
    fontSize   $ px 60
    textAlign  $ alignSide sideCenter

paddingAll :: Size z -> Css
paddingAll s = padding s s s s

marginAll :: Size z -> Css
marginAll s = margin s s s s

rgba' :: Integer -> Integer -> Integer -> Double -> Color
rgba' r g b a = rgba r g b $ floor $ a * 255

JavaScript

Now we get to the more exciting part where we can produce JavaScript by writing Haskell. The library that provides this functionality is Fay.

Application Logic

The logic to Movie Monad is straightforward.

{-# LANGUAGE RebindableSyntax, OverloadedStrings, EmptyDataDecls #-}

import Prelude
import FFI
import JQuery
import Fay.Text

We’ve seen OverloadedStrings strings before but not RebindableSyntax nor EmptyDataDecls. RebindableSyntax allows us to redefine (rebind) operators defined in the Prelude. Note that Fay has its own Prelude. EmptyDataDecls allows us to declare data types without specifying what they are equivalent to (no constructors). We will use this to define some custom data types for objects returned from the JavaScript world.

data File
data FileReader

These are our empty data type declarations. Notice that they do not have have constructors. File is a file object in the files property of the file input object. FileReader is the object we use to read in the video file selected by the user.

main :: Fay ()
main = startApp

startApp :: Fay ()
startApp = ready $ do
  fileInput' <- fileInput
  void $ change (const $ onFileInputChange fileInput') fileInput'

Instead of the IO monad, we operate in the Fay monad returning the unit type. In other words, we perform some side effects and return nothing of interest (think void).

The start app function gets the file input object and setups a change event. When the file input changes, it will call the onFileInputChange callback.

The const function take two parameters but if you call it with only one, it returns a function that takes any parameter and returns the result of the first parameter you originally called const with. This is useful because change expects a function that takes an event and returns the unit type. However, onFileInputChange does not take an event. change will call what const returns (passing it an event), the event passed will be ignored, and onFileInputChange will be called.

Prelude> static = const 1
Prelude> :t static
static :: Num a => b -> a
Prelude> static "2"
1
Prelude> static 1255.5
1
Prelude> static []
1
fileInput :: Fay JQuery
fileInput = select "#fileInput"

Using Fay-jQuery, we select the element with the #fileInput ID.

newFileReader :: Fay FileReader
newFileReader = ffi "new FileReader()"

ffi is short for foreign function interface. ffi allows us to call JavaScript from within our Haskell code. Notice how we return the FileReader data type (wrapped) within the Fay monad.

fileInputFiles :: JQuery -> Fay (Nullable [File])
fileInputFiles = ffi "%1['prop']('files')"

The syntax here is a little cryptic but it translates to $('#fileInput').prop('files'); in JavaScript. The %1 syntax is the first argument JQuery in the type signature where JQuery is some object returned by the select function. Our return type is Nullable [File] which means that we may either get back Null or a list of files [File]. The Null type translates to null in JavaScript.

fileSize :: File -> Fay Int
fileSize = ffi "%1['size']"

This takes a file object and calls size on it which returns an integer.

nullableFileSize :: Nullable File -> Fay Int
nullableFileSize nullableFile = case nullableFile of
                                  Nullable file -> fileSize file
                                  Null -> return 0

This function helps with getting the size of a file that may actually be null. If the file is null, just return zero.

fileSizeLimit :: Int
fileSizeLimit = 51000000

Because Movie Monad has to load the entire video file into memory, we have to limit the file size. If we do not limit the file size, the application could crash or freeze up for large video files. Ideally we would like to stream the video (play buffered chunks) from disk but the file reader API doesn’t allow for this. The file size limit number is in number of bytes–so the limit is roughly 48 megabytes.

fileInputFile :: JQuery -> Int -> Fay (Nullable File)
fileInputFile fileInput index = do
  nullableFiles <- fileInputFiles fileInput
  let files = case nullableFiles of
                Nullable files -> files
                Null -> []
  if Prelude.null files || (Prelude.length files <= index)
    then return Null
    else return (Nullable $ files!!index)

Taking the file input object, we gather up all of its input files. If there are no files, return null, otherwise return the requested file at the requested index.

fileReaderResult :: FileReader -> Fay Text
fileReaderResult = ffi "%1['result']"

Once the file reader has brought the file into memory, we can access it via its result property.

addFileReaderEventListener :: FileReader -> Text -> (Event -> Fay ()) -> Fay ()
addFileReaderEventListener = ffi "%1['addEventListener'](%2, %3)"

Taking a file reader object, specify a callback function to be called once the specified event type occurs.

// For example:

fileReader.addEventListener('load', function(event) {});
setUpNewFileRead :: Nullable File -> Fay ()
setUpNewFileRead Null = return ()
setUpNewFileRead (Nullable file) = do
  fr <- newFileReader
  let onLoadCallback = const $ handleVideoFile fr
  addFileReaderEventListener fr "load" onLoadCallback
  readAsDataURL fr file

If given a null file, do nothing. However, if we are given an actual file object, setup the callback and begin reading the file as a data URL.

onFileInputChange :: JQuery -> Fay ()
onFileInputChange fileInput = do
  emptyVideoContainer
  nullableFile <- fileInputFile fileInput 0
  nfs <- nullableFileSize nullableFile
  if (&&) (nfs > 0) (nfs <= fileSizeLimit)
    then setUpNewFileRead nullableFile
    else void $ setStatusMessage "File too small or large."

This is the callback we used in startApp. The video container may contain a previously loaded video. We clear it out making way for the new video or a possible status message. If the file size is within the valid range, read the file, otherwise let the user know that the file size is not valid.

File is too small or large.

File is too small or large.

setStatusMessage :: Text -> Fay JQuery
setStatusMessage text = select "#statusMessage" >>= setHtml text

Select the status message element and set its inner HTML to the text passed.

videoContainer :: Fay JQuery
videoContainer = select "#videoContainer"

Return the video container element.

addVideoElement :: Fay JQuery
addVideoElement = select "<video id='video' src='' controls/>" >>= addToVideoContainer

Much like jQuery, we can create a new document element by selecting it. Once created, we add it as a child element to the video container.

addToVideoContainer :: JQuery -> Fay JQuery
addToVideoContainer el = videoContainer >>= flip appendTo el

Given an element (el), grab the video container and append the element to it. appendTo takes two parameters where the first one is the parent and the second one is the child. flip turns this around making a new function where the first parameter is the child and the second one is the parent.

Prelude> x a b = a - b
Prelude> y = flip x
Prelude> x 1 2
-1
Prelude> y 1 2
1

This allows us to keep the syntax clean using a one-liner. Implicitly, flip appendTo el is being passed the video container like this:

addToVideoContainer el = videoContainer >>= (\ videoContainer' -> flip appendTo el videoContainer')

-- (>>=) :: Fay JQueryA        -> (JQueryA        -> Fay JQueryB) -> Fay JQueryB
-- (>>=) :: Fay videoContainer -> (videoContainer -> Fay el     ) -> Fay el

We could have written this function like the following.

addToVideoContainer :: JQuery -> Fay JQuery
addToVideoContainer el = do
  videoContainer' <- videoContainer
  appendTo videoContainer' el

-- Or like this:

addToVideoContainer :: JQuery -> Fay JQuery
addToVideoContainer el = videoContainer >>= (\ videoContainer' -> appendTo videoContainer' el)
emptyVideoContainer :: Fay JQuery
emptyVideoContainer = videoContainer >>= JQuery.empty

This clears out the video container document element.

handleVideoFile :: FileReader -> Fay ()
handleVideoFile fr = do
  url <- fileReaderResult fr
  if "data:video" `isPrefixOf` url
    then void $ addVideoElement >>= setAttr "src" url >> setStatusMessage ""
    else void $ setStatusMessage "Not a video file."

Here is where we check that the data URL has the video type. If it does not, we tell the user that the file is not a video file. However, if we are dealing with a video file, set the video element src to the data URL.

Not a video file.

Not a video file.

readAsDataURL :: FileReader -> File -> Fay ()
readAsDataURL = ffi "%1['readAsDataURL'](%2)"

At long last we reach the end of the application logic where we read the file in as a data URL such that we can set this URL as the video source.

Below is the entire ~/movieMonad/src/js/Main.hs file–be sure to copy it over.

{-
  David Lettier (C) 2016
  http://www.lettier.com/
-}

{-# LANGUAGE RebindableSyntax, OverloadedStrings, EmptyDataDecls #-}

import Prelude
import FFI
import JQuery
import Fay.Text

data File
data FileReader

main :: Fay ()
main = startApp

startApp :: Fay ()
startApp = ready $ do
  fileInput' <- fileInput
  void $ change (const $ onFileInputChange fileInput') fileInput'

fileInput :: Fay JQuery
fileInput = select "#fileInput"

newFileReader :: Fay FileReader
newFileReader = ffi "new FileReader()"

fileInputFiles :: JQuery -> Fay (Nullable [File])
fileInputFiles = ffi "%1['prop']('files')"

fileSize :: File -> Fay Int
fileSize = ffi "%1['size']"

nullableFileSize :: Nullable File -> Fay Int
nullableFileSize nullableFile = case nullableFile of
                                  Nullable file -> fileSize file
                                  Null -> return 0

fileSizeLimit :: Int
fileSizeLimit = 51000000

fileInputFile :: JQuery -> Int -> Fay (Nullable File)
fileInputFile fileInput index = do
  nullableFiles <- fileInputFiles fileInput
  let files = case nullableFiles of
                Nullable files -> files
                Null -> []
  if Prelude.null files || (Prelude.length files <= index)
    then return Null
    else return (Nullable $ files!!index)

fileReaderResult :: FileReader -> Fay Text
fileReaderResult = ffi "%1['result']"

addFileReaderEventListener :: FileReader -> Text -> (Event -> Fay ()) -> Fay ()
addFileReaderEventListener = ffi "%1['addEventListener'](%2, %3)"

setUpNewFileRead :: Nullable File -> Fay ()
setUpNewFileRead Null = return ()
setUpNewFileRead (Nullable file) = do
  fr <- newFileReader
  let onLoadCallback = const $ handleVideoFile fr
  addFileReaderEventListener fr "load" onLoadCallback
  readAsDataURL fr file

onFileInputChange :: JQuery -> Fay ()
onFileInputChange fileInput = do
  emptyVideoContainer
  nullableFile <- fileInputFile fileInput 0
  nfs <- nullableFileSize nullableFile
  if (&&) (nfs > 0) (nfs <= fileSizeLimit)
    then setUpNewFileRead nullableFile
    else void $ setStatusMessage "File too small or large."

setStatusMessage :: Text -> Fay JQuery
setStatusMessage text = select "#statusMessage" >>= setHtml text

videoContainer :: Fay JQuery
videoContainer = select "#videoContainer"

addVideoElement :: Fay JQuery
addVideoElement = select "<video id='video' src='' controls/>" >>= addToVideoContainer

addToVideoContainer :: JQuery -> Fay JQuery
addToVideoContainer el = videoContainer >>= flip appendTo el

emptyVideoContainer :: Fay JQuery
emptyVideoContainer = videoContainer >>= JQuery.empty

handleVideoFile :: FileReader -> Fay ()
handleVideoFile fr = do
  url <- fileReaderResult fr
  if "data:video" `isPrefixOf` url
    then void $ addVideoElement >>= setAttr "src" url >> setStatusMessage ""
    else void $ setStatusMessage "Not a video file."

readAsDataURL :: FileReader -> File -> Fay ()
readAsDataURL = ffi "%1['readAsDataURL'](%2)"

Electron Boilerplate

Electron needs/runs a JavaScript file that creates the application window (or windows), defines the window properties, and setups the application callbacks like ready or window-all-close.

{-# LANGUAGE RebindableSyntax, OverloadedStrings, EmptyDataDecls #-}

import Prelude
import FFI
import Fay.Text

The same language extensions and imports as before.

data Electron
data App
data BrowserWindow
data MainWindow

New data types representing the electron, app, browser window, and main window JavaScript objects.

class OnCaller a

instance OnCaller App
instance OnCaller MainWindow

Here we create a new type class and declare the data types App and MainWindow members of this type class. On caller means they can call on in the JavaScript world. Now we do not have to write an onEvent function just for App and another one just for MainWindow. The onEvent definition is listed below.

type DirName = Text

Here we create a type alias that says DirName is just an alias for the Fay Text type. This provides some clarity in a function type signature. Yes it expects the Text type, but more specifically, it is looking for the result returned by calling __dirname in JavaScript. The DirName is the directory path in which this (eventual) JavaScript file resides.

main :: Fay ()
main = bootApp

bootApp :: Fay ()
bootApp = do
  electron' <- electron
  app' <- app electron'
  onEvent app' "ready" setupMainWindow
  onEvent app' "window-all-closed" $ do
    processPlatform' <- processPlatform
    when (processPlatform' /= "darwin") $ callAppProp app' "quit"
  onEvent app' "activate" $ void $ do
    nullableMainWindow <- getMainWindow
    case nullableMainWindow of
      Nullable _ -> return ()
      Null -> setupMainWindow

Here we gather the electron object and then the app object using the electron object. Two app event listeners are setup where we listen for the window closing and the application activating. If the application window is closed, we call app.quit() in JavaScript. When the activate event fires, we setup the main window if it does not already exist.

electron :: Fay Electron
electron = ffi "require('electron')"

require loads the electron module and returns the module object.

processPlatform :: Fay Text
processPlatform = ffi "process.platform"

This gives us the platform that Movie Monad is being run on.

app :: Electron -> Fay App
app = ffi "%1['app']"

Get the app object from the electron object.

callAppProp :: App -> Text -> Fay ()
callAppProp = ffi "%1[%2]()"

This translates to app.someProp();.

browserWindow :: Electron -> Fay BrowserWindow
browserWindow = ffi "%1['BrowserWindow']"

Retrieve the BrowserWindow constructor.

dirName :: Fay DirName
dirName = ffi "(function () { return __dirname; })()"

Create an IIFE which returns the directory path where this JavaScript file resides. Notice that it returns the DirName type which is aliased to Text.

windowWidth :: Int
windowWidth = 800

windowHeight :: Int
windowHeight = 600

The initial window width and height.

windowIcon :: DirName -> Fay Text
windowIcon = ffi "(function (d) { return d + '/icon.png'; })(%1)"

This returns the location of the icon file which will eventually show up in the taskbar.

newMainWindow :: BrowserWindow -> Int -> Int -> Text -> Fay MainWindow
newMainWindow = ffi "(function (b) { return global['mainWindow'] = new b({ width: %2, height: %3, icon: %4 }); })(%1)"

This is the largest foreign function interface definition we create. As you can see, it creates a new BrowserWindow instance and saves that instance in the global object under the mainWindow property.

Keep a global reference of the window object, if you don’t, the window will be closed automatically when the JavaScript object is garbage collected.
getMainWindow :: Fay (Nullable MainWindow)
getMainWindow = ffi "(function () { var m = global['mainWindow']; return m ? m : null; })()"

Look up the main window in the global object. If the object is available, return it, otherwise return null. Because it is possible to return null, we set the return type as Nullable MainWindow.

onEvent :: OnCaller a => a -> Text -> Fay () -> Fay ()
onEvent = ffi "%1['on'](%2, %3)"

This can be used with any member of the OnCaller type class. Roughly translated: obj.on('eventType', callbackFunction);.

loadUrl :: MainWindow -> DirName -> Fay ()
loadUrl = ffi "%1['loadURL']('file://' + %2 + '/index.html')"

The start of the Movie Monad application begins with the HTML file we created earlier. This will load the user interface into the main application window.

setMainWindowNull :: Fay ()
setMainWindowNull = ffi "(function () { global['mainWindow'] = null; })()"

Sets the main window global property to null.

hideMainWindowMenu :: MainWindow -> Fay ()
hideMainWindowMenu = ffi "%1['setMenu'](null)"

To simply the interface, we hide the main menu bar. During development, it is best to leave this visible.

setupMainWindow :: Fay ()
setupMainWindow = do
  nullableMainWindow <- getMainWindow
  mainWindow <- case nullableMainWindow of
                  Nullable mainWindow -> return mainWindow
                  Null -> do
                    electron' <- electron
                    browserWindow' <- browserWindow electron'
                    iconFile <- dirName >>= windowIcon
                    newMainWindow browserWindow' windowWidth windowHeight iconFile
  dirName >>= loadUrl mainWindow
  hideMainWindowMenu mainWindow
  onEvent mainWindow "close" setMainWindowNull

Creates the main window if it does not already exist, hides the menu bar, and sets the main window to null when we receive a close event.

Below is the entire ~/movieMonad/src/ electronBoot/Main.hs file–be sure to copy it over.

{-
  David Lettier (C) 2016
  http://www.lettier.com/
-}

{-# LANGUAGE RebindableSyntax, OverloadedStrings, EmptyDataDecls #-}

import Prelude
import FFI
import Fay.Text

data Electron
data App
data BrowserWindow
data MainWindow

class OnCaller a

instance OnCaller App
instance OnCaller MainWindow

type DirName = Text

main :: Fay ()
main = bootApp

bootApp :: Fay ()
bootApp = do
  electron' <- electron
  app' <- app electron'
  onEvent app' "ready" setupMainWindow
  onEvent app' "window-all-closed" $ do
    processPlatform' <- processPlatform
    when (processPlatform' /= "darwin") $ callAppProp app' "quit"
  onEvent app' "activate" $ void $ do
    nullableMainWindow <- getMainWindow
    case nullableMainWindow of
      Nullable _ -> return ()
      Null -> setupMainWindow

electron :: Fay Electron
electron = ffi "require('electron')"

processPlatform :: Fay Text
processPlatform = ffi "process.platform"

app :: Electron -> Fay App
app = ffi "%1['app']"

callAppProp :: App -> Text -> Fay ()
callAppProp = ffi "%1[%2]()"

browserWindow :: Electron -> Fay BrowserWindow
browserWindow = ffi "%1['BrowserWindow']"

dirName :: Fay DirName
dirName = ffi "(function () { return __dirname; })()"

windowWidth :: Int
windowWidth = 800

windowHeight :: Int
windowHeight = 600

windowIcon :: DirName -> Fay Text
windowIcon = ffi "(function (d) { return d + '/icon.png'; })(%1)"

newMainWindow :: BrowserWindow -> Int -> Int -> Text -> Fay MainWindow
newMainWindow = ffi "(function (b) { return global['mainWindow'] = new b({ width: %2, height: %3, icon: %4 }); })(%1)"

getMainWindow :: Fay (Nullable MainWindow)
getMainWindow = ffi "(function () { var m = global['mainWindow']; return m ? m : null; })()"

onEvent :: OnCaller a => a -> Text -> Fay () -> Fay ()
onEvent = ffi "%1['on'](%2, %3)"

loadUrl :: MainWindow -> DirName -> Fay ()
loadUrl = ffi "%1['loadURL']('file://' + %2 + '/index.html')"

setMainWindowNull :: Fay ()
setMainWindowNull = ffi "(function () { global['mainWindow'] = null; })()"

hideMainWindowMenu :: MainWindow -> Fay ()
hideMainWindowMenu = ffi "%1['setMenu'](null)"

setupMainWindow :: Fay ()
setupMainWindow = do
  nullableMainWindow <- getMainWindow
  mainWindow <- case nullableMainWindow of
                  Nullable mainWindow -> return mainWindow
                  Null -> do
                    electron' <- electron
                    browserWindow' <- browserWindow electron'
                    iconFile <- dirName >>= windowIcon
                    newMainWindow browserWindow' windowWidth windowHeight iconFile
  dirName >>= loadUrl mainWindow
  hideMainWindowMenu mainWindow
  onEvent mainWindow "close" setMainWindowNull

Build

So far we setup our build environment and we wrote out the source code. We are now ready to build the entire project.

cd ~/movieMonad
nvm use
make

If all went well, you should see the Movie Monad window open and ready to play videos. Located in ~/movieMonad/dist/electronDists are the packaged Linux, Windows, and Mac versions.

~/movieMonad
  dist/
    electronDists/
      movieMonad-darwin-x64/
      movieMonad-linux-ia32/
      movieMonad-linux-x64/
      movieMonad-mas-x64/
      movieMonad-win32-ia32/
      movieMonad-win32-x64/

If later on you only want to do some portion of the build process, you can pass make the target name.

cd ~/movieMonad
nvm use
make buildHaskell

Each target or build step is listed in the makefile you created earlier.

Wrap-up

Using the Haskell libraries Blaze, Fay, and Clay, we generated the HTML, CSS, and JavaScript files necessary to build Movie Monad. These files, along with Electron and Electron Packager, allowed us to make a video playing desktop application and package it up for distribution to all major platforms.

If you enjoy web programming in Haskell, be sure to read Building a Haskell Web API and Haskell to JavaScript.