port module Main exposing (..) import Browser import DOM as Dom import Element exposing (Element, alignRight, centerY, el, fill, padding, rgb255, row, spacing, text, width) import Element.Background as Background import Element.Border as Border import Element.Font as Font import Element.Input as Input import Html import Html.Attributes import Html.Events import Json.Decode as Decode main : Program Decode.Value Model Msg main = Browser.application { init = \_ _ _ -> init , update = update , view = view , subscriptions = \_ -> Sub.none , onUrlChange = \_ -> Noop , onUrlRequest = \_ -> Noop } type alias Model = { url : String , playing : Bool , position : Float , duration : Float , loaded : List ( Float, Float ) , volume : Float , muted : Bool } type Msg = Noop | PlayPause | Seek Float | NowPlaying | NowPaused | NowHasDuration Float | NowAtPosition Float | NowAtVolume Float Bool | NowLoaded (List ( Float, Float )) init : ( Model, Cmd Msg ) init = ( Model "video/manifest.m3u8" False 0.0 1.0 [] 1.0 False, initVideo () ) update : Msg -> Model -> ( Model, Cmd Msg ) update msg model = case msg of Noop -> ( model, Cmd.none ) PlayPause -> ( model, playPause () ) Seek ratio -> ( model, seek (ratio * model.duration) ) NowPlaying -> ( { model | playing = True }, Cmd.none ) NowPaused -> ( { model | playing = False }, Cmd.none ) NowHasDuration duration -> ( { model | duration = duration }, Cmd.none ) NowAtPosition position -> ( { model | position = position }, Cmd.none ) NowAtVolume volume muted -> ( { model | volume = volume, muted = muted }, Cmd.none ) NowLoaded loaded -> ( { model | loaded = loaded }, Cmd.none ) view : Model -> Browser.Document Msg view model = { title = "Hello" , body = [ Element.layout [] (video model) ] } video : Model -> Element Msg video model = let seen = round (model.position * 1000) loaded = 0 remaining = round ((model.duration - model.position) * 1000) bar = Element.column [ Element.width Element.fill , Element.padding 10 , Element.alignBottom , Font.color (Element.rgb 1 1 1) , Background.gradient { angle = 0, steps = [ Element.rgba 0 0 0 0.75, Element.rgba 0 0 0 0 ] } ] [ Element.row (Element.width Element.fill :: Element.height (Element.px 30) :: Border.rounded 5 :: Element.behindContent (Element.el [ Background.color (Element.rgba 0.4 0.4 0.4 0.75) , Element.width Element.fill , Element.height (Element.px 5) , Element.centerY , Border.rounded 5 ] Element.none ) :: seekBarEvents ) [ Element.el [ Background.color (Element.rgba 1 0 0 0.75) , Element.width (Element.fillPortion seen) , Element.height Element.fill , Border.roundEach { topLeft = 5, topRight = 0, bottomLeft = 5, bottomRight = 0 } , Element.height (Element.px 5) , Element.centerY ] Element.none , Element.el [ Background.color (Element.rgba 0.7 0.7 0.7 0.75) , Element.width (Element.fillPortion loaded) , Element.height Element.fill , Element.height (Element.px 5) , Element.centerY ] Element.none , Element.el [ Element.width (Element.fillPortion remaining) ] Element.none ] , Element.row [ Element.spacing 10 ] [ playPauseButton model.playing , Element.text (formatTime model.position ++ " / " ++ formatTime model.duration) ] ] in Element.el [ Element.inFront bar, Element.width (Element.px 1000) ] (Element.html (Html.video videoEvents [])) playPauseButton : Bool -> Element Msg playPauseButton playing = let icon = if playing then "⏸" else "▶" in Input.button [] { label = Element.text icon , onPress = Just PlayPause } videoEvents : List (Html.Attribute Msg) videoEvents = [ Html.Attributes.id "video" , Html.Events.on "playing" (Decode.succeed NowPlaying) , Html.Events.on "pause" (Decode.succeed NowPaused) , Html.Events.on "durationchange" decodeDurationChanged , Html.Events.on "timeupdate" decodePosition , Html.Events.on "volumechange" decodeVolumeChange , Html.Events.on "progress" decodeProgress ] seekBarEvents : List (Element.Attribute Msg) seekBarEvents = List.map Element.htmlAttribute [ Html.Events.on "click" decodeSeek ] decodeDurationChanged : Decode.Decoder Msg decodeDurationChanged = Dom.target <| Decode.map NowHasDuration (Decode.field "duration" Decode.float) decodePosition : Decode.Decoder Msg decodePosition = Dom.target <| Decode.map NowAtPosition (Decode.field "currentTime" Decode.float) decodeVolumeChange : Decode.Decoder Msg decodeVolumeChange = Dom.target <| Decode.map2 NowAtVolume (Decode.field "volume" Decode.float) (Decode.field "muted" Decode.bool) decodeSeek : Decode.Decoder Msg decodeSeek = Decode.map2 (\x y -> Seek (toFloat x / toFloat y)) (Decode.field "layerX" Decode.int) (Dom.target <| Decode.field "offsetWidth" Decode.int) decodeProgress : Decode.Decoder Msg decodeProgress = Decode.map NowLoaded (Dom.target <| Decode.field "buffered" <| Decode.field "asArray" <| decodeTimeRanges ) decodeTimeRanges : Decode.Decoder (List ( Float, Float )) decodeTimeRanges = Decode.list decodeTimeRange decodeTimeRange : Decode.Decoder ( Float, Float ) decodeTimeRange = Decode.map2 (\x y -> ( x, y )) (Decode.field "start" Decode.float) (Decode.field "end" Decode.float) formatTime : Float -> String formatTime s = let seconds = round s minutes = seconds // 60 |> modBy 60 hours = seconds // 3600 secs = modBy 60 seconds secsString = if secs < 10 then "0" ++ String.fromInt secs else String.fromInt secs minutesString = if minutes < 10 && hours > 0 then "0" ++ String.fromInt minutes else String.fromInt minutes hoursString = if hours == 0 then "" else String.fromInt hours ++ ":" in hoursString ++ minutesString ++ ":" ++ secsString port initVideo : () -> Cmd msg port playPause : () -> Cmd msg port seek : Float -> Cmd msg