diff --git a/index.html b/index.html index b7bdf8e..f8fbaa7 100644 --- a/index.html +++ b/index.html @@ -40,10 +40,12 @@ } }); + let hls; + app.ports.initVideo.subscribe(function(arg) { const video = document.getElementById('video'); if (Hls.isSupported()) { - const hls = new Hls(); + hls = new Hls(); hls.loadSource(arg); hls.on(Hls.Events.MANIFEST_PARSED, function (event, data) { @@ -54,16 +56,10 @@ }); hls.on(Hls.Events.LEVEL_SWITCHED, function (event, data) { - // var span = document.querySelector(".plyr__menu__container [data-plyr='quality'][value='0'] span"); - // if (hls.autoLevelEnabled) { - // span.innerHTML = "Auto (" + hls.levels[data.level].height + "p)"; - // } else { - // span.innerHTML = "Auto"; - // } - // var x = document.querySelectorAll(".plyr__menu__container [data-plyr='settings'] span")[3]; - // if (x.innerHTML.startsWith("Auto") || x.innerHTML === "0p") { - // x.innerHTML = span.innerHTML; - // } + app.ports.nowHasQuality.send({ + auto: hls.autoLevelEnabled, + height: hls.levels[data.level].height + }); }) hls.attachMedia(video); @@ -93,6 +89,30 @@ app.ports.exitFullscreen.subscribe(function() { document.exitFullscreen(); }); + + app.ports.setPlaybackRate.subscribe(function(arg) { + const video = document.getElementById('video'); + video.playbackRate = arg; + }); + + app.ports.setQuality.subscribe(function(arg) { + var old = hls.currentLevel; + if (arg.auto) { + hls.currentLevel = -1; + } else { + hls.levels.forEach((level, levelIndex) => { + if (level.height === arg.height) { + hls.currentLevel = levelIndex; + } + }); + } + if (old === hls.currentLevel) { + app.ports.nowHasQuality.send({ + auto: hls.autoLevelEnabled, + height: hls.currentLevel === -1 ? 0 : hls.levels[hls.currentLevel].height + }); + } + }); diff --git a/src/Icons.elm b/src/Icons.elm index c54ad1e..6dc6793 100644 --- a/src/Icons.elm +++ b/src/Icons.elm @@ -1,10 +1,4 @@ -module Icons exposing - ( maximize - , minimize - , pause - , play - , playCircle - ) +module Icons exposing (..) import Element exposing (Element) import Html exposing (Html) @@ -69,3 +63,18 @@ playCircle = [ Svg.circle [ cx "12", cy "12", r "10" ] [] , Svg.polygon [ points "10 8 16 12 10 16 10 8" ] [] ] + + +settings : Bool -> Element msg +settings = + svgFeatherIcon "settings" + [ Svg.circle [ cx "12", cy "12", r "3" ] [] + , Svg.path [ d "M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z" ] [] + ] + + +check : Bool -> Element msg +check = + svgFeatherIcon "check" + [ Svg.polyline [ points "20 6 9 17 4 12" ] [] + ] diff --git a/src/Main.elm b/src/Main.elm index 37bb0f5..3fa39f5 100644 --- a/src/Main.elm +++ b/src/Main.elm @@ -3,7 +3,7 @@ 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 exposing (Element) import Element.Background as Background import Element.Border as Border import Element.Font as Font @@ -13,6 +13,7 @@ import Html.Attributes import Html.Events import Icons import Json.Decode as Decode +import Quality import Simple.Animation as Animation exposing (Animation) import Simple.Animation.Animated as Animated import Simple.Animation.Property as P @@ -28,6 +29,15 @@ main = \_ -> Sub.batch [ nowHasQualities NowHasQualities + , nowHasQuality + (\x -> + case Decode.decodeValue Quality.decode x of + Ok s -> + NowHasQuality s + + _ -> + Noop + ) , Browser.Events.onAnimationFrameDelta AnimationFrameDelta , Browser.Events.onResize (\x y -> NowHasWindowSize ( x, y )) ] @@ -45,18 +55,32 @@ type alias Model = , volume : Float , muted : Bool , isFullscreen : Bool + , quality : Maybe Quality.Quality , qualities : List Int , showBar : Bool , animationFrame : Float , videoSize : ( Int, Int ) , screenSize : ( Int, Int ) + , playbackRate : Float + , settings : Settings + , showSettings : Bool } +type Settings + = All + | Speed + | Quality + + type Msg = Noop | PlayPause | Seek Float + | ToggleSettings + | SetSettings Settings + | SetPlaybackRate Float + | SetQuality Quality.Quality | RequestFullscreen | ExitFullscreen | AnimationFrameDelta Float @@ -69,8 +93,10 @@ type Msg | NowLoaded (List ( Float, Float )) | NowIsFullscreen Bool | NowHasQualities (List Int) + | NowHasQuality Quality.Quality | NowHasVideoSize ( Int, Int ) | NowHasWindowSize ( Int, Int ) + | NowHasPlaybackRate Float init : Decode.Value -> ( Model, Cmd Msg ) @@ -97,11 +123,15 @@ init flags = 1.0 False False + Nothing [] True 0 ( 0, 0 ) ( width, height ) + 1.0 + All + False , initVideo url ) @@ -118,14 +148,30 @@ update msg model = Seek ratio -> ( model, seek (ratio * model.duration) ) + SetPlaybackRate rate -> + ( { model | showSettings = False, settings = All }, setPlaybackRate rate ) + + ToggleSettings -> + ( { model | showSettings = not model.showSettings }, Cmd.none ) + + SetSettings s -> + ( { model | settings = s }, Cmd.none ) + RequestFullscreen -> ( model, requestFullscreen () ) ExitFullscreen -> ( model, exitFullscreen () ) + SetQuality q -> + ( { model | showSettings = False, settings = All }, setQuality q ) + AnimationFrameDelta delta -> - ( { model | animationFrame = model.animationFrame + delta }, Cmd.none ) + if model.animationFrame + delta > 3500 then + ( { model | animationFrame = model.animationFrame + delta, showSettings = False, settings = All }, Cmd.none ) + + else + ( { model | animationFrame = model.animationFrame + delta }, Cmd.none ) MouseMove -> ( { model | animationFrame = 0 }, Cmd.none ) @@ -154,12 +200,18 @@ update msg model = NowHasQualities qualities -> ( { model | qualities = qualities }, Cmd.none ) + NowHasQuality quality -> + ( { model | quality = Just quality }, Cmd.none ) + NowHasVideoSize size -> ( { model | videoSize = size }, Cmd.none ) NowHasWindowSize size -> ( { model | screenSize = size }, Cmd.none ) + NowHasPlaybackRate rate -> + ( { model | playbackRate = rate }, Cmd.none ) + view : Model -> Browser.Document Msg view model = @@ -220,53 +272,58 @@ video model = [ 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 + [ settings model + , Element.column [ 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.padding 10 + , 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 - , 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.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 ] + [ settingsButton, fullscreenButton model.isFullscreen ] ] - 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 ] ] ] ) @@ -318,6 +375,134 @@ video model = ) +settings : Model -> Element Msg +settings model = + let + makeMenuButton : Settings -> Element Msg -> Element Msg -> Element Msg + makeMenuButton s key value = + Input.button [ Element.width Element.fill, Element.paddingXY 0 10 ] + { label = + Element.row [ Element.width Element.fill, Element.spacing 20 ] + [ Element.el [ Font.bold, Element.alignLeft ] key + , Element.el [ Element.alignRight ] value + ] + , onPress = Just (SetSettings s) + } + + speedButton = + makeMenuButton Speed (Element.text "Speed") (Element.text ("x" ++ String.fromFloat model.playbackRate)) + + qualityButton = + case model.quality of + Just q -> + makeMenuButton Quality (Element.text "Quality") (Element.text (Quality.toString q)) + + _ -> + Element.none + + returnButton = + Input.button + [ Element.width Element.fill + , Element.paddingXY 0 10 + , Border.widthEach + { bottom = 1 + , top = 0 + , left = 0 + , right = 0 + } + , Border.color (Element.rgba 0.5 0.5 0.5 0.75) + ] + { label = Element.text "Return" + , onPress = Just (SetSettings All) + } + + speedOptions = + [ 0.5, 0.75, 1, 1.5, 2 ] + |> List.map + (\x -> + Input.button [ Element.width Element.fill, Element.paddingXY 0 10 ] + { label = + Element.row [ Element.width Element.fill ] + [ if x == model.playbackRate then + Icons.check False + + else + Element.el [ Font.color (Element.rgba 0 0 0 0) ] (Icons.check False) + , Element.el + [ Element.paddingEach + { left = 10 + , right = 0 + , top = 0 + , bottom = 0 + } + ] + (Element.text ("x" ++ String.fromFloat x)) + ] + , onPress = Just (SetPlaybackRate x) + } + ) + |> (\x -> returnButton :: x) + + qualityOptions = + model.qualities + |> List.map + (\x -> + Input.button [ Element.width Element.fill, Element.paddingXY 0 10 ] + { label = + Element.row [ Element.width Element.fill ] + [ if Quality.isSameOption (Just { auto = False, height = x }) model.quality then + Icons.check False + + else + Element.el [ Font.color (Element.rgba 0 0 0 0) ] (Icons.check False) + , Element.el + [ Element.paddingEach + { left = 10 + , right = 0 + , top = 0 + , bottom = 0 + } + ] + (Element.text (Quality.toString { auto = False, height = x })) + ] + , onPress = Just (SetQuality { auto = x == 0, height = x }) + } + ) + |> (\x -> returnButton :: x) + + buttons = + case model.settings of + All -> + [ speedButton, qualityButton ] + + Speed -> + speedOptions + + Quality -> + qualityOptions + in + animatedEl + (if model.showSettings then + fadeIn + + else + fadeOut + ) + [ Element.padding 10 + , Element.width Element.fill + , Element.height Element.fill + , Element.moveDown 20 + ] + (Element.column + [ Background.color (Element.rgba 0.2 0.2 0.2 0.75) + , Element.alignRight + , Element.paddingXY 20 10 + , Border.rounded 10 + ] + buttons + ) + + playPauseButton : Bool -> Element Msg playPauseButton playing = let @@ -349,6 +534,14 @@ fullscreenButton isFullscreen = ) +settingsButton : Element Msg +settingsButton = + Input.button [] + { label = Icons.settings False + , onPress = Just ToggleSettings + } + + playerEvents : List (Element.Attribute Msg) playerEvents = List.map Element.htmlAttribute @@ -367,6 +560,7 @@ videoEvents = , Html.Events.on "volumechange" decodeVolumeChange , Html.Events.on "progress" decodeProgress , Html.Events.on "resize" decodeVideoResize + , Html.Events.on "ratechange" decodePlaybackRateChange ] @@ -422,7 +616,7 @@ decodeTimeRanges = decodeTimeRange : Decode.Decoder ( Float, Float ) decodeTimeRange = - Decode.map2 (\x y -> ( x, y )) + Decode.map2 Tuple.pair (Decode.field "start" Decode.float) (Decode.field "end" Decode.float) @@ -445,6 +639,13 @@ decodeVideoResize = (Decode.field "videoHeight" Decode.int) +decodePlaybackRateChange : Decode.Decoder Msg +decodePlaybackRateChange = + Dom.target <| + Decode.map NowHasPlaybackRate + (Decode.field "playbackRate" Decode.float) + + 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) @@ -517,9 +718,18 @@ port requestFullscreen : () -> Cmd msg port exitFullscreen : () -> Cmd msg +port setPlaybackRate : Float -> Cmd msg + + +port setQuality : Quality.Quality -> Cmd msg + + port nowHasQualities : (List Int -> msg) -> Sub msg +port nowHasQuality : (Decode.Value -> msg) -> Sub msg + + fadeIn : Animation fadeIn = Animation.fromTo diff --git a/src/Quality.elm b/src/Quality.elm new file mode 100644 index 0000000..e78f878 --- /dev/null +++ b/src/Quality.elm @@ -0,0 +1,50 @@ +module Quality exposing (Quality, decode, isSameOption, toString) + +import Json.Decode as Decode + + +type alias Quality = + { auto : Bool + , height : Int + } + + +toString : Quality -> String +toString { auto, height } = + if height == 0 then + "Auto" + + else if auto then + "Auto (" ++ String.fromInt height ++ "p)" + + else + String.fromInt height ++ "p" + + +isSameOption : Maybe Quality -> Maybe Quality -> Bool +isSameOption quality1 quality2 = + case ( quality1, quality2 ) of + ( Just q1, Just q2 ) -> + autoHeight q1 == autoHeight q2 + + ( Nothing, Nothing ) -> + True + + _ -> + False + + +autoHeight : Quality -> Int +autoHeight { auto, height } = + if auto then + 0 + + else + height + + +decode : Decode.Decoder Quality +decode = + Decode.map2 Quality + (Decode.field "auto" Decode.bool) + (Decode.field "height" Decode.int)