port module Main exposing (..) import Browser import Browser.Events 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 Icons import Json.Decode as Decode import Simple.Animation as Animation exposing (Animation) import Simple.Animation.Animated as Animated import Simple.Animation.Property as P main : Program String Model Msg main = Browser.application { init = \url _ _ -> init url , update = update , view = view , subscriptions = \_ -> Sub.batch [ nowHasQualities NowHasQualities , Browser.Events.onAnimationFrameDelta AnimationFrameDelta ] , onUrlChange = \_ -> Noop , onUrlRequest = \_ -> Noop } type alias Model = { url : String , playing : Bool , position : Float , duration : Float , loaded : List ( Float, Float ) , volume : Float , muted : Bool , isFullscreen : Bool , qualities : List Int , showBar : Bool , animationFrame : Float } type Msg = Noop | PlayPause | Seek Float | RequestFullscreen | ExitFullscreen | AnimationFrameDelta Float | MouseMove | NowPlaying | NowPaused | NowHasDuration Float | NowAtPosition Float | NowAtVolume Float Bool | NowLoaded (List ( Float, Float )) | NowIsFullscreen Bool | NowHasQualities (List Int) init : String -> ( Model, Cmd Msg ) init url = ( Model url False 0.0 1.0 [] 1.0 False False [] True 0 , initVideo url ) 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) ) RequestFullscreen -> ( model, requestFullscreen () ) ExitFullscreen -> ( model, exitFullscreen () ) AnimationFrameDelta delta -> ( { model | animationFrame = model.animationFrame + delta }, Cmd.none ) MouseMove -> ( { model | animationFrame = 0 }, Cmd.none ) 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 ) NowIsFullscreen fullscreen -> ( { model | isFullscreen = fullscreen }, Cmd.none ) NowHasQualities qualities -> ( { model | qualities = qualities }, Cmd.none ) view : Model -> Browser.Document Msg view model = { title = "Hello" , body = [ Element.layout [ Element.height Element.fill ] (video model) ] } video : Model -> Element Msg video model = let seen = round (model.position * 1000) loaded = List.filter (\( start, end ) -> start < model.position) model.loaded loadedToShow = every model.duration loaded showRange : ( Float, Float, Bool ) -> Element msg showRange ( start, end, isLoaded ) = let portion = round (1000 * (end - start)) in Element.el [ Element.width (Element.fillPortion portion) , Element.height Element.fill , if isLoaded then Background.color (Element.rgba 1 1 1 0.5) else Background.color (Element.rgba 1 1 1 0) ] Element.none loadedElement = Element.row [ Element.width Element.fill , Element.height (Element.px 5) , Element.centerY , Border.rounded 5 ] (List.map showRange loadedToShow) remaining = round ((model.duration - model.position) * 1000) bar = animatedEl (if model.animationFrame < 3000 then fadeIn else fadeOut ) [ Element.width Element.fill, Element.height Element.fill ] (Element.column [ Element.width Element.fill , Element.padding 10 , Element.alignBottom , Font.color (Element.rgba 1 1 1 0.85) , 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 1 1 1 0.25) , Element.width Element.fill , Element.height (Element.px 5) , Element.centerY , Border.rounded 5 ] Element.none ) , Element.behindContent loadedElement , Element.inFront (Element.el (Element.width Element.fill :: Element.height Element.fill :: Element.pointer :: seekBarEvents ) Element.none ) ] [ 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 [ Element.width (Element.fillPortion remaining) ] Element.none ] , Element.row [ Element.spacing 10, Element.width Element.fill ] [ playPauseButton model.playing , Element.el [ Element.moveDown 2.5 ] (Element.text (formatTime model.position ++ " / " ++ formatTime model.duration)) , Element.row [ Element.spacing 10, Element.alignRight ] [ fullscreenButton model.isFullscreen ] ] ] ) in Element.el (Element.inFront bar :: Element.width Element.fill :: Element.height Element.fill :: Background.color (Element.rgb 0 0 0) :: Element.htmlAttribute (Html.Attributes.id "full") :: playerEvents ) (Element.html (Html.video (Html.Attributes.class "hf" :: videoEvents) [] ) ) playPauseButton : Bool -> Element Msg playPauseButton playing = let icon = if playing then Icons.pause True else Icons.play True in Input.button [] { label = icon , onPress = Just PlayPause } fullscreenButton : Bool -> Element Msg fullscreenButton isFullscreen = Input.button [] (if isFullscreen then { label = Icons.minimize False , onPress = Just ExitFullscreen } else { label = Icons.maximize False , onPress = Just RequestFullscreen } ) playerEvents : List (Element.Attribute Msg) playerEvents = List.map Element.htmlAttribute [ Html.Events.on "fullscreenchange" decodeFullscreenChange , Html.Events.on "mousemove" (Decode.succeed MouseMove) ] 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 = decodeTimeRanges |> Decode.field "asArray" |> Decode.field "buffered" |> Dom.target |> Decode.map NowLoaded 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) decodeFullscreenChange : Decode.Decoder Msg decodeFullscreenChange = Decode.value |> Decode.nullable |> Decode.field "fullscreenElement" |> Decode.field "document" |> Dom.target |> Decode.map (\x -> NowIsFullscreen (x /= Nothing)) every : Float -> List ( Float, Float ) -> List ( Float, Float, Bool ) every duration input = everyAux duration 0.0 [] input |> List.reverse |> List.filter (\( x, y, _ ) -> x /= y) everyAux : Float -> Float -> List ( Float, Float, Bool ) -> List ( Float, Float ) -> List ( Float, Float, Bool ) everyAux duration currentTime currentState input = case input of [] -> ( currentTime, duration, False ) :: currentState [ ( start, end ) ] -> ( end, duration, False ) :: ( start, end, True ) :: ( currentTime, start, False ) :: currentState ( start, end ) :: t -> everyAux duration end (( start, end, True ) :: ( currentTime, start, False ) :: currentState) t 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 : String -> Cmd msg port playPause : () -> Cmd msg port seek : Float -> Cmd msg port requestFullscreen : () -> Cmd msg port exitFullscreen : () -> Cmd msg port nowHasQualities : (List Int -> msg) -> Sub msg fadeIn : Animation fadeIn = Animation.fromTo { duration = 500 , options = [] } [ P.opacity 0 ] [ P.opacity 1 ] fadeOut : Animation fadeOut = Animation.fromTo { duration = 500 , options = [] } [ P.opacity 1 ] [ P.opacity 0 ] animatedEl : Animation -> List (Element.Attribute msg) -> Element msg -> Element msg animatedEl = animatedUi Element.el animatedUi = Animated.ui { behindContent = Element.behindContent , htmlAttribute = Element.htmlAttribute , html = Element.html }