Render a mobile-friendly piano

For now since I'm the only customer and I'm primarily making this for myself,
I'm styling the app specifically for my Google Pixel 4. If I find this app
useful, I will consider supporting other devices.

I'm using the Icons that I bought when I purchased the "Refactoring UI" book.

Other news:
- I bought the domain learnpianochords.app!

What's left:
- Style the "fine tune" tab of the preferences view
- Better support non-mobile devices like the browser and tablet devices
- Deploy the application to learnpianochords.app
- Redesign the "key" tab of the preferences view to sort the keys according to
  the circle of fifths
- Dogfood
- Simplify until I cannot simplify anymore
This commit is contained in:
William Carroll 2020-04-17 12:38:08 +01:00
parent 1d427c4921
commit 5ca0fa2fcd
5 changed files with 236 additions and 116 deletions

View file

@ -10,6 +10,7 @@
"elm/core": "1.0.5", "elm/core": "1.0.5",
"elm/html": "1.0.0", "elm/html": "1.0.0",
"elm/random": "1.0.0", "elm/random": "1.0.0",
"elm/svg": "1.0.1",
"elm/time": "1.0.0", "elm/time": "1.0.0",
"elm-community/list-extra": "8.2.3", "elm-community/list-extra": "8.2.3",
"elm-community/maybe-extra": "5.2.0", "elm-community/maybe-extra": "5.2.0",

View file

@ -0,0 +1,44 @@
module Icon exposing (..)
import Svg exposing (node, svg)
import Svg.Attributes exposing (..)
import UI
svgColor color =
let
classes =
case color of
UI.Primary ->
[ "text-gray-500", "fill-current" ]
UI.Secondary ->
[ "text-gray-300", "fill-current" ]
in
class <| String.join " " classes
cog =
svg [ class "icon-cog", viewBox "0 0 24 24", xmlLang "http://www.w3.org/2000/svg" ]
[ Svg.path
[ svgColor UI.Primary
, d "M6.8 3.45c.87-.52 1.82-.92 2.83-1.17a2.5 2.5 0 0 0 4.74 0c1.01.25 1.96.65 2.82 1.17a2.5 2.5 0 0 0 3.36 3.36c.52.86.92 1.8 1.17 2.82a2.5 2.5 0 0 0 0 4.74c-.25 1.01-.65 1.96-1.17 2.82a2.5 2.5 0 0 0-3.36 3.36c-.86.52-1.8.92-2.82 1.17a2.5 2.5 0 0 0-4.74 0c-1.01-.25-1.96-.65-2.82-1.17a2.5 2.5 0 0 0-3.36-3.36 9.94 9.94 0 0 1-1.17-2.82 2.5 2.5 0 0 0 0-4.74c.25-1.01.65-1.96 1.17-2.82a2.5 2.5 0 0 0 3.36-3.36zM12 16a4 4 0 1 0 0-8 4 4 0 0 0 0 8z"
, fill "red"
]
[]
, node "circle"
[ svgColor UI.Secondary, cx "12", cy "12", r "2" ]
[]
]
close =
svg [ class "icon-close", viewBox "0 0 24 24", xmlLang "http://www.w3.org/2000/svg" ]
[ Svg.path
[ svgColor UI.Primary
, d "M15.78 14.36a1 1 0 0 1-1.42 1.42l-2.82-2.83-2.83 2.83a1 1 0 1 1-1.42-1.42l2.83-2.82L7.3 8.7a1 1 0 0 1 1.42-1.42l2.83 2.83 2.82-2.83a1 1 0 0 1 1.42 1.42l-2.83 2.83 2.83 2.82z"
, fill "red"
, fillRule "evenodd"
]
[]
]

View file

@ -4,6 +4,7 @@ import Browser
import Html exposing (..) import Html exposing (..)
import Html.Attributes exposing (..) import Html.Attributes exposing (..)
import Html.Events exposing (..) import Html.Events exposing (..)
import Icon
import Piano import Piano
import Random import Random
import Random.List import Random.List
@ -57,6 +58,7 @@ type Msg
| SetPracticeMode PracticeMode | SetPracticeMode PracticeMode
| SelectAllKeys | SelectAllKeys
| DeselectAllKeys | DeselectAllKeys
| SetView View
{-| The amount by which we increase or decrease tempo. {-| The amount by which we increase or decrease tempo.
@ -84,7 +86,7 @@ init : Model
init = init =
let let
( firstNote, lastNote ) = ( firstNote, lastNote ) =
( Theory.A1, Theory.C8 ) ( Theory.C3, Theory.C6 )
inversions = inversions =
Theory.allInversions Theory.allInversions
@ -124,7 +126,7 @@ init =
, tempo = 30 , tempo = 30
, firstNote = firstNote , firstNote = firstNote
, lastNote = lastNote , lastNote = lastNote
, view = Preferences , view = Practice
} }
@ -153,6 +155,13 @@ update msg model =
, Cmd.none , Cmd.none
) )
SetView x ->
( { model
| view = x
}
, Cmd.none
)
SelectAllKeys -> SelectAllKeys ->
( { model ( { model
| whitelistedKeys = Theory.allKeys | whitelistedKeys = Theory.allKeys
@ -412,28 +421,6 @@ keyCheckboxes model =
] ]
displayChord :
{ chord : Theory.Chord
, firstNote : Theory.Note
, lastNote : Theory.Note
}
-> Html Msg
displayChord { chord, firstNote, lastNote } =
div []
[ p [] [ text (Theory.viewChord chord) ]
, case Theory.notesForChord chord of
Just x ->
Piano.render
{ highlight = x
, start = firstNote
, end = lastNote
}
Nothing ->
p [] [ text "No chord to show" ]
]
practiceModeButtons : Model -> Html Msg practiceModeButtons : Model -> Html Msg
practiceModeButtons model = practiceModeButtons model =
div [ class "text-center" ] div [ class "text-center" ]
@ -465,10 +452,29 @@ practiceModeButtons model =
] ]
openPreferences : Html Msg
openPreferences =
button
[ class "w-48 h-48 absolute left-0 top-0 z-20"
, onClick (SetView Preferences)
]
[ Icon.cog ]
closePreferences : Html Msg
closePreferences =
button
[ class "w-48 h-48 absolute right-0 top-0 z-10"
, onClick (SetView Practice)
]
[ Icon.close ]
preferences : Model -> Html Msg preferences : Model -> Html Msg
preferences model = preferences model =
div [ class "pt-10 pb-20 px-10" ] div [ class "pt-10 pb-20 px-10" ]
[ Tempo.render [ closePreferences
, Tempo.render
{ tempo = model.tempo { tempo = model.tempo
, handleInput = SetTempo , handleInput = SetTempo
} }
@ -487,18 +493,36 @@ preferences model =
practice : Model -> Html Msg practice : Model -> Html Msg
practice model = practice model =
div [] let
[ playPause model classes =
, case model.selectedChord of [ "bg-gray-600"
Just chord -> , "h-screen"
displayChord , "w-full"
{ chord = chord , "absolute"
, firstNote = model.firstNote , "z-10"
, lastNote = model.lastNote , "text-6xl"
} ]
Nothing -> ( handleClick, extraClasses, buttonText ) =
p [] [ text "No chord to display" ] if model.isPaused then
( Play, [ "opacity-50" ], "Press to resume" )
else
( Pause, [ "opacity-0" ], "" )
in
div []
[ button
[ [ classes, extraClasses ] |> List.concat |> UI.tw |> class
, onClick handleClick
]
[ text buttonText
]
, openPreferences
, Piano.render
{ highlight = model.selectedChord |> Maybe.andThen Theory.notesForChord |> Maybe.withDefault []
, start = model.firstNote
, end = model.lastNote
}
] ]

View file

@ -8,10 +8,18 @@ import List.Extra
import Theory import Theory
{-| On mobile phones, the keyboard displays vertically.
-}
type Direction
= Horizontal
| Vertical
type alias KeyMarkup a = type alias KeyMarkup a =
{ offset : Int { offset : Int
, isHighlit : Bool , isHighlit : Bool
, note : Theory.Note , note : Theory.Note
, direction : Direction
} }
-> Html a -> Html a
@ -32,121 +40,149 @@ pixelate x =
{-| Pixel width of the white keys. {-| Pixel width of the white keys.
-} -}
naturalWidth : Int naturalWidth : Direction -> Int
naturalWidth = naturalWidth direction =
case direction of
Vertical ->
-- Right now, I'm designing this specifically for my Google Pixel 4
-- phone, which has a screen width of 1080px.
1080
Horizontal ->
45 45
{-| Pixel height of the white keys. {-| Pixel height of the white keys.
-} -}
naturalHeight : Int naturalHeight : Direction -> Int
naturalHeight = naturalHeight direction =
case direction of
Vertical ->
-- Right now, I'm designing this specifically for my Google Pixel 4
-- phone, which has a screen height of 2280px. 2280 / 21
-- (i.e. no. natural keys) ~= 108
108
Horizontal ->
250 250
{-| Pixel width of the black keys. {-| Pixel width of the black keys.
-} -}
accidentalWidth : Int accidentalWidth : Direction -> Int
accidentalWidth = accidentalWidth direction =
round (toFloat naturalWidth * 0.4) case direction of
Vertical ->
round (toFloat (naturalWidth direction) * 0.6)
Horizontal ->
round (toFloat (naturalWidth direction) * 0.4)
{-| Pixel height of the black keys. {-| Pixel height of the black keys.
-} -}
accidentalHeight : Int accidentalHeight : Direction -> Int
accidentalHeight = accidentalHeight direction =
round (toFloat naturalHeight * 0.63) case direction of
Vertical ->
round (toFloat (naturalHeight direction) * 0.63)
Horizontal ->
round (toFloat (naturalHeight direction) * 0.63)
{-| These are the white keys on most modern pianos. {-| Return the markup for either a white or a black key.
-} -}
natural : KeyMarkup a pianoKey : KeyMarkup a
natural { offset, isHighlit, note } = pianoKey { offset, isHighlit, note, direction } =
let
sharedClasses =
[ "box-border" ]
{ keyWidth, keyHeight, keyColor, offsetEdge, extraClasses } =
case ( Theory.keyClass note, direction ) of
( Theory.Natural, Vertical ) ->
{ keyWidth = naturalWidth Vertical
, keyHeight = naturalHeight Vertical
, keyColor = "white"
, offsetEdge = "top"
, extraClasses = []
}
( Theory.Natural, Horizontal ) ->
{ keyWidth = naturalWidth Horizontal
, keyHeight = naturalHeight Horizontal
, keyColor = "white"
, offsetEdge = "left"
, extraClasses = []
}
( Theory.Accidental, Vertical ) ->
{ keyWidth = accidentalWidth Vertical
, keyHeight = accidentalHeight Vertical
, keyColor = "black"
, offsetEdge = "top"
, extraClasses = [ "z-10" ]
}
( Theory.Accidental, Horizontal ) ->
{ keyWidth = accidentalWidth Horizontal
, keyHeight = accidentalHeight Horizontal
, keyColor = "black"
, offsetEdge = "left"
, extraClasses = [ "z-10" ]
}
in
div div
[ style "background-color" [ style "background-color"
(if isHighlit then (if isHighlit then
"red" "red"
else else
"white" keyColor
) )
, style "border-right" "1px solid black"
, style "border-top" "1px solid black" , style "border-top" "1px solid black"
, style "border-bottom" "1px solid black" , style "border-bottom" "1px solid black"
, style "width" (pixelate naturalWidth)
, style "height" (pixelate naturalHeight)
, style "position" "absolute"
, style "left" (String.fromInt offset ++ "px")
]
[]
{-| These are the black keys on most modern pianos.
-}
accidental : KeyMarkup a
accidental { offset, isHighlit, note } =
div
[ style "background-color"
(if isHighlit then
"red"
else
"black"
)
, style "border-top" "1px solid black"
, style "border-left" "1px solid black" , style "border-left" "1px solid black"
, style "border-right" "1px solid black" , style "border-right" "1px solid black"
, style "border-bottom" "1px solid black" , style "width" (pixelate keyWidth)
, style "width" (pixelate accidentalWidth) , style "height" (pixelate keyHeight)
, style "height" (pixelate accidentalHeight)
, style "position" "absolute" , style "position" "absolute"
, style "left" (String.fromInt offset ++ "px") , style offsetEdge (String.fromInt offset ++ "px")
, style "z-index" "1" , class <| String.join " " (List.concat [ sharedClasses, extraClasses ])
] ]
[] []
makeKey : List Theory.Note -> Theory.Note -> (Int -> Html a) {-| A section of the piano consisting of all twelve notes.
makeKey highlight note =
if Theory.isNatural note then
\x ->
natural
{ offset = x
, isHighlit = List.member note highlight
, note = note
}
else
\x ->
accidental
{ offset = x
, isHighlit = List.member note highlight
, note = note
}
{-| A section of the piano consisting of all twelve notes. The name octave
implies eight notes, which most scales (not the blues scale) honor.
-} -}
octave : Theory.Note -> Theory.Note -> List Theory.Note -> List (Html a) keys : Direction -> Theory.Note -> Theory.Note -> List Theory.Note -> List (Html a)
octave start end highlight = keys direction start end highlight =
let let
isHighlit note = isHighlit note =
List.member note highlight List.member note highlight
spacing prevOffset prev curr = spacing prevOffset prev curr =
case ( Theory.keyClass prev, Theory.keyClass curr ) of case ( Theory.keyClass prev, Theory.keyClass curr, direction ) of
( Theory.Natural, Theory.Accidental ) -> -- Horizontal
-- idk this calculation yet ( Theory.Natural, Theory.Accidental, Horizontal ) ->
prevOffset + naturalWidth - round (toFloat accidentalWidth / 2) prevOffset + naturalWidth direction - round (toFloat (accidentalWidth direction) / 2)
( Theory.Accidental, Theory.Natural ) -> ( Theory.Accidental, Theory.Natural, Horizontal ) ->
-- accidentalWidth / 2 prevOffset + round (toFloat (accidentalWidth direction) / 2)
prevOffset + round (toFloat accidentalWidth / 2)
( Theory.Natural, Theory.Natural ) -> ( Theory.Natural, Theory.Natural, Horizontal ) ->
-- naturalWidth prevOffset + naturalWidth direction
prevOffset + naturalWidth
-- Vertical
( Theory.Natural, Theory.Accidental, Vertical ) ->
prevOffset + naturalHeight direction - round (toFloat (accidentalHeight direction) / 2)
( Theory.Accidental, Theory.Natural, Vertical ) ->
prevOffset + round (toFloat (accidentalHeight direction) / 2)
( Theory.Natural, Theory.Natural, Vertical ) ->
prevOffset + naturalHeight direction
-- This pattern should never hit. -- This pattern should never hit.
_ -> _ ->
@ -158,7 +194,16 @@ octave start end highlight =
(\curr ( prevOffset, prev, result ) -> (\curr ( prevOffset, prev, result ) ->
case ( prevOffset, prev ) of case ( prevOffset, prev ) of
( Nothing, Nothing ) -> ( Nothing, Nothing ) ->
( Just 0, Just curr, makeKey highlight curr 0 :: result ) ( Just 0
, Just curr
, pianoKey
{ offset = 0
, isHighlit = List.member curr highlight
, note = curr
, direction = direction
}
:: result
)
( Just po, Just p ) -> ( Just po, Just p ) ->
let let
@ -167,7 +212,13 @@ octave start end highlight =
in in
( Just offset ( Just offset
, Just curr , Just curr
, makeKey highlight curr offset :: result , pianoKey
{ offset = offset
, isHighlit = List.member curr highlight
, note = curr
, direction = direction
}
:: result
) )
-- This pattern should never hit. -- This pattern should never hit.
@ -184,4 +235,4 @@ octave start end highlight =
render : Props -> Html a render : Props -> Html a
render { highlight, start, end } = render { highlight, start, end } =
div [ style "display" "flex" ] div [ style "display" "flex" ]
(octave start end highlight |> List.reverse |> List.repeat 1 |> List.concat) (keys Vertical start end highlight |> List.reverse |> List.repeat 1 |> List.concat)

View file

@ -79,7 +79,7 @@ textToggleButton { label, toggled, handleClick, classes } =
buttonClasses = buttonClasses =
[ textColor [ textColor
, textTreatment , textTreatment
, "py-10" , "py-8"
, "px-10" , "px-10"
, "text-5xl" , "text-5xl"
] ]