Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Html.map + timeupdate event after DOM removal creates invalid Msg #171

Open
Yarith opened this issue Jul 11, 2020 · 0 comments
Open

Html.map + timeupdate event after DOM removal creates invalid Msg #171

Yarith opened this issue Jul 11, 2020 · 0 comments

Comments

@Yarith
Copy link

Yarith commented Jul 11, 2020

Problem

This issue provides only another SSCCE to the #103 issue. In my opinion the explanation in the other issue applies to this SSCCE too. Like #103 this SSCCE could be added to the list in #105 as well.

Version

OS: Windows 10 1909 (Build 18363.900)
Elm: 0.19.1

Thrown exceptions

The thrown exceptions differ between Chrome and Firefox. Added so it is easier to find for other people. But the exception text may vary between the expected types.

Chrome 83.0.4103.116 (64-Bit)

Uncaught TypeError: Cannot read property 'a' of undefined

Firefox 78.0.2 (64-Bit)

TypeError: _v0 is undefined

SSCCEE

https://ellie-app.com/9pbMRZHxRkVa1

module Main exposing (main)

import Browser
import Html exposing (Html, br, button, div, source, text, video)
import Html.Attributes exposing (autoplay, id, loop, property, src, style, type_)
import Html.Events exposing (on, onClick)
import Json.Decode as JD
import Json.Encode as JE


type alias Model =
    { currentTime : Float
    , duration : Float
    , videoVisible : Bool
    , selection : Maybe ( GroupId, ItemId )
    }


initialModel : Model
initialModel =
    { currentTime = 0
    , duration = 0
    , videoVisible = False
    , selection = Nothing
    }


type MediaMsg
    = MediaMsgTimeupdate { currentTime : Float, duration : Float }


type VideoPageMsg
    = VideoPageMsgLeave
    | VideoPageMsgMediaMsg MediaMsg


type GroupId
    = GroupId String


groupIdEncoder : GroupId -> JE.Value
groupIdEncoder (GroupId value) =
    JE.string value


type ItemId
    = ItemId String


itemIdEncoder : ItemId -> JE.Value
itemIdEncoder (ItemId value) =
    JE.string value


type ListPageMsg
    = ListPageMsgSelect GroupId ItemId


type Msg
    = VideoPage VideoPageMsg
    | ListPage ListPageMsg


update : Msg -> Model -> Model
update msg model =
    case msg of
        VideoPage videoPageMsg ->
            case videoPageMsg of
                VideoPageMsgMediaMsg (MediaMsgTimeupdate { currentTime, duration }) ->
                    { model | currentTime = currentTime, duration = duration }

                VideoPageMsgLeave ->
                    { model | videoVisible = False }

        ListPage listPageMsg ->
            case listPageMsg of
                ListPageMsgSelect groupId itemId ->
                    let
                        _ =
                            Debug.log "Attempt to store selection with" listPageMsg

                        _ =
                            -- In my case i am storing the selection in the local storage,
                            -- so i can just use F5 to reload the page, without reselecting.
                            Debug.log "Selection json which can be sent to local storage" <|
                                JE.encode 0 <|
                                    JE.object
                                        [ ( "groupId", groupIdEncoder groupId )
                                        , ( "itemId", itemIdEncoder itemId )
                                        ]
                    in
                    { model | selection = Just ( groupId, itemId ), videoVisible = True }


timeupdateDecoder : JD.Decoder { currentTime : Float, duration : Float }
timeupdateDecoder =
    JD.map2 (\currentTime duration -> { currentTime = currentTime, duration = duration })
        (JD.at [ "target", "currentTime" ] JD.float)
        (JD.at [ "target", "duration" ] JD.float)


viewList : Html ListPageMsg
viewList =
    div []
        [ button [ onClick <| ListPageMsgSelect (GroupId "fpie73") (ItemId "72ba27hs") ]
            [ text "Open video" ]
        ]


viewVideo : Html VideoPageMsg
viewVideo =
    div []
        [ button [ onClick <| VideoPageMsgLeave ] [ text "Leave video" ]
        , br [] []
        , br [] []
        , video
            [ autoplay True
            , property "muted" (JE.string "muted")
            , loop True
            , on "timeupdate" (timeupdateDecoder |> JD.map MediaMsgTimeupdate)
            ]
            [ source
                [ id "mp4"
                , src "http://www.w3schools.com/html/movie.mp4"
                , type_ "video/mp4"
                ]
                []
            ]
            |> Html.map VideoPageMsgMediaMsg
        ]


view : Model -> Html Msg
view model =
    div
        [ style "transform" "scale(2, 2)"
        , style "transform-origin" "left top"
        ]
        [ if model.videoVisible then
            viewVideo |> Html.map VideoPage

          else
            viewList |> Html.map ListPage
        , div []
            [ br [] []
            , text <| String.fromFloat model.currentTime ++ "/" ++ String.fromFloat model.duration
            ]
        ]


main : Program () Model Msg
main =
    Browser.sandbox
        { init = initialModel
        , view = view
        , update = update
        }

Workaround

In #103 are also workarounds described. Additional Html.map identity on the pages with same "map depth" works, but is not safe, because in future there could be another page with the same "map depth".

In my case i have wrapped the event in a custom element, which disconnects the event listener as soon as the element is removed from the DOM. The custom element controls the playstate too, so i do not need a port for pause/resume. Place this video-control custom element inside the video element. The custom element is defined as followed:

// This is based on the idea from baffalop on Slack
// https://elmlang.slack.com/archives/C0CJ3SBBM/p1593872335052000
// Place this custom element inside the video element.
customElements.define('video-control',
    class VideoControl extends HTMLElement {
        static get observedAttributes() { return ['playstate']; }

        constructor() {
            super();

            this.boundVideoTimeupdated = this.videoTimeupdated.bind(this);
        }

        attributeChangedCallback(name, oldValue, newValue) {
            const parentElement = this.parentElement;
            if (name === "playstate") {
                VideoControl.updatePlayState(parentElement, newValue);
            }
        }

        /**
        * @param {HTMLVideoElement} videoElement 
        * @param {boolean} value
        * @return {string}
        */
        static updatePlayState(videoElement, value) {
            if (!videoElement)
                return;

            if (value === "play") {
                videoElement.play();
            } else if (value == "pause") {
                videoElement.pause();
            }
        }

        /**
        * @param {HTMLVideoElement} videoElement 
        */
        videoTimeupdated() {
            const videoElement = this.videoElement;
            if (!videoElement || videoElement.tagName !== "VIDEO")
                return;

            this.dispatchEvent(new CustomEvent("timeupdate", {
                detail: { currentTime: videoElement.currentTime, duration: videoElement.duration }
            }));
        }

        /**
        * @param {HTMLVideoElement} videoElement 
        */
        connectVideoElement(videoElement) {
            this.disconnectVideoElement();

            if (!videoElement || videoElement.tagName !== "VIDEO")
                return;

            this.videoElement = videoElement;

            // Wir müssen timeupdate hier kapseln, da das timeupdate vom Video-Element
            // einen Laufzeitfehler erzeugt, sollte die Seite verlassen werden, bevor
            // das Video angehalten wurde.
            VideoControl.updatePlayState(videoElement, this.getAttribute("playstate"));

            videoElement.addEventListener('timeupdate', this.boundVideoTimeupdated, false);
        }

        /**
        * @param {HTMLVideoElement} videoElement 
        */
        disconnectVideoElement() {
            const videoElement = this.videoElement;
            if (!videoElement || videoElement.tagName !== "VIDEO")
                return;

            videoElement.removeEventListener('timeupdate', this.boundVideoTimeupdated, false);

            this.videoElement = null;
        }

        connectedCallback() {
            this.connectVideoElement(this.parentElement);
        }

        disconnectedCallback() {
            this.disconnectVideoElement();
        }
    });
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

1 participant