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

[FeatureRequest] Make skip button active #149

Closed
Bretterteig opened this issue May 8, 2024 · 22 comments
Closed

[FeatureRequest] Make skip button active #149

Bretterteig opened this issue May 8, 2024 · 22 comments

Comments

@Bretterteig
Copy link

Bretterteig commented May 8, 2024

When using TV mode it would be great if the spawned button would also be the active element so that you dont have to navigate to the button but immidiatly confirm the skip. Currenlty it takes multiple navigation clicks to get there.

Edit: Maybe make the :hov effect also play when active :)

@jumoog
Copy link
Owner

jumoog commented May 17, 2024

It's not easy to do. How about waiting for, say, 3 seconds and then clicking automatically?

@AbandonedCart
Copy link
Collaborator

Wouldn't that be the same as auto skip, though? I think what you mean is an inverted version of the button where you click to keep it from skipping.

@jumoog
Copy link
Owner

jumoog commented May 17, 2024

Wouldn't that be the same as auto skip, though?

true

@jumoog
Copy link
Owner

jumoog commented May 17, 2024

Not possible without creating our own docker image.

@Bretterteig
Copy link
Author

Bretterteig commented May 18, 2024

Please note I am not a developer.

Wouldn't something like skipButton.focus({ focusVisible: true }); next to the unhide line suffice?

@rlauuzo
Copy link
Collaborator

rlauuzo commented May 18, 2024

I tried it. Pressing the button with a remote/controller also pauses the video if the OSD isn't visible. I'm unsure how to prevent that from happening.

@jumoog jumoog closed this as completed May 18, 2024
@jumoog jumoog reopened this May 18, 2024
@jumoog
Copy link
Owner

jumoog commented May 18, 2024

extrem dirty hack

let introSkipper = {
    skipSegments: {},
    videoPlayer: {},
    // .bind() is used here to prevent illegal invocation errors
    originalFetch: window.fetch.bind(window),
};
introSkipper.d = function (msg) {
    console.debug("[intro skipper] ", msg);
  }
  /** Setup event listeners */
  introSkipper.setup = function () {
    document.addEventListener("viewshow", introSkipper.viewShow);
    window.fetch = introSkipper.fetchWrapper;
    introSkipper.d("Registered hooks");
  }
  /** Wrapper around fetch() that retrieves skip segments for the currently playing item. */
  introSkipper.fetchWrapper = async function (...args) {
    // Based on JellyScrub's trickplay.js
    let [resource, options] = args;
    let response = await introSkipper.originalFetch(resource, options);
    // Bail early if this isn't a playback info URL
    try {
      let path = new URL(resource).pathname;
      if (!path.includes("/PlaybackInfo")) { return response; }
      introSkipper.d("Retrieving skip segments from URL");
      introSkipper.d(path);
      
      // Check for context root and set id accordingly
      let path_arr = path.split("/");
      let id = "";
      if (path_arr[1] == "Items") {
        id = path_arr[2];
      } else {
        id = path_arr[3];
      }

      introSkipper.skipSegments = await introSkipper.secureFetch(`Episode/${id}/IntroSkipperSegments`);
      introSkipper.d("Successfully retrieved skip segments");
      introSkipper.d(introSkipper.skipSegments);
    }
    catch (e) {
      console.error("Unable to get skip segments from", resource, e);
    }
    return response;
  }
  /**
  * Event handler that runs whenever the current view changes.
  * Used to detect the start of video playback.
  */
  introSkipper.viewShow = function () {
    const location = window.location.hash;
    introSkipper.d("Location changed to " + location);
    if (location !== "#/video") {
      introSkipper.d("Ignoring location change");
      return;
    }
    introSkipper.injectCss();
    introSkipper.injectButton();
    introSkipper.videoPlayer = document.querySelector("video");
    if (introSkipper.videoPlayer != null) {
      introSkipper.d("Hooking video timeupdate");
      introSkipper.videoPlayer.addEventListener("timeupdate", introSkipper.videoPositionChanged);
    }
  }
  /**
  * Injects the CSS used by the skip intro button.
  * Calling this function is a no-op if the CSS has already been injected.
  */
  introSkipper.injectCss = function () {
    if (introSkipper.testElement("style#introSkipperCss")) {
      introSkipper.d("CSS already added");
      return;
    }
    introSkipper.d("Adding CSS");
    let styleElement = document.createElement("style");
    styleElement.id = "introSkipperCss";
    styleElement.innerText = `
    @media (hover:hover) and (pointer:fine) {
        #skipIntro .paper-icon-button-light:hover:not(:disabled) {
            color: black !important;
            background-color: rgba(47, 93, 98, 0) !important;
        }
    }
    #skipIntro .paper-icon-button-light.show-focus:focus {
        transform: scale(1.04) !important;
    }
    #skipIntro.upNextContainer {
        width: unset;
    }
    #skipIntro {
        padding: 0 1px;
        position: absolute;
        right: 10em;
        bottom: 9em;
        background-color: rgba(25, 25, 25, 0.66);
        border: 1px solid;
        border-radius: 0px;
        display: inline-block;
        cursor: pointer;
        opacity: 0;
        box-shadow: inset 0 0 0 0 #f9f9f9;
        -webkit-transition: ease-out 0.4s;
        -moz-transition: ease-out 0.4s;
        transition: ease-out 0.4s;
    }
    #skipIntro #btnSkipSegmentText {
        padding-right: 3px;
        padding-bottom: 2px;
    }
    @media (max-width: 1080px) {
        #skipIntro {
            right: 10%;
        }
    }
    #skipIntro:hover {
        box-shadow: inset 400px 0 0 0 #f9f9f9;
        -webkit-transition: ease-in 1s;
        -moz-transition: ease-in 1s;
        transition: ease-in 1s;
    }
    `;
    document.querySelector("head").appendChild(styleElement);
}
/**
 * Inject the skip intro button into the video player.
 * Calling this function is a no-op if the CSS has already been injected.
 */
introSkipper.injectButton = async function () {
    // Ensure the button we're about to inject into the page doesn't conflict with a pre-existing one
    const preExistingButton = introSkipper.testElement("div.skipIntro");
    if (preExistingButton) {
        preExistingButton.style.display = "none";
    }
    if (introSkipper.testElement(".btnSkipIntro.injected")) {
        introSkipper.d("Button already added");
        return;
    }
    introSkipper.d("Adding button");
    let config = await introSkipper.secureFetch("Intros/UserInterfaceConfiguration");
    if (!config.SkipButtonVisible) {
        introSkipper.d("Not adding button: not visible");
        return;
    }
    // Construct the skip button div
    const button = document.createElement("div");
    button.id = "skipIntro"
    button.classList.add("hide");
    button.addEventListener("click", introSkipper.doSkip);
    button.innerHTML = `
    <button is="paper-icon-button-light" class="btnSkipIntro paper-icon-button-light injected">
        <span id="btnSkipSegmentText"></span>
        <span class="material-icons skip_next"></span>
    </button>
    `;
    button.dataset["intro_text"] = config.SkipButtonIntroText;
    button.dataset["credits_text"] = config.SkipButtonEndCreditsText;
    /*
    * Alternative workaround for #44. Jellyfin's video component registers a global click handler
    * (located at src/controllers/playback/video/index.js:1492) that pauses video playback unless
    * the clicked element has a parent with the class "videoOsdBottom" or "upNextContainer".
    */
    button.classList.add("upNextContainer");
    // Append the button to the video OSD
    let controls = document.querySelector("div#videoOsdPage");
    controls.appendChild(button);
}
/** Tests if the OSD controls are visible. */
introSkipper.osdVisible = function () {
    const osd = document.querySelector("div.videoOsdBottom");
    return osd ? !osd.classList.contains("hide") : false;
}
/** Get the currently playing skippable segment. */
introSkipper.getCurrentSegment = function (position) {
    for (let key in introSkipper.skipSegments) {
        const segment = introSkipper.skipSegments[key];
        if ((position >= segment.ShowSkipPromptAt && position < segment.HideSkipPromptAt) || (introSkipper.osdVisible() && position >= segment.IntroStart && position < segment.IntroEnd)) {
            segment["SegmentType"] = key;
            return segment;
        }
    }
    return { "SegmentType": "None" };
}
/** Playback position changed, check if the skip button needs to be displayed. */
introSkipper.videoPositionChanged = function () {
    const skipButton = document.querySelector("#skipIntro");
    if (!skipButton) {
        return;
    }
    const segment = introSkipper.getCurrentSegment(introSkipper.videoPlayer.currentTime);
    switch (segment["SegmentType"]) {
        case "None":
            if (skipButton.style.opacity === '0') return;

            skipButton.style.opacity = '0';
            skipButton.addEventListener("transitionend", () => {
                skipButton.classList.add("hide");
            }, { once: true });
+           introSkipper.videoPlayer.play();
+           document.body.removeEventListener('keydown', introSkipper.doSkip, true);
+           introSkipper.videoPlayer.play();
            return;
        case "Introduction":
            skipButton.querySelector("#btnSkipSegmentText").textContent =
                skipButton.dataset["intro_text"];
+          document.body.addEventListener('keydown', introSkipper.doSkip, true);
            break;
        case "Credits":
            skipButton.querySelector("#btnSkipSegmentText").textContent =
                skipButton.dataset["credits_text"];
+          document.body.addEventListener('keydown', introSkipper.doSkip, true);
            break;
    }
    if (!skipButton.classList.contains("hide")) return;

    skipButton.classList.remove("hide");
    requestAnimationFrame(() => {
        requestAnimationFrame(() => {
            skipButton.style.opacity = '1';
        });
    });
}
/** Seeks to the end of the intro. */
introSkipper.doSkip = function (e) {
    introSkipper.d("Skipping intro");
    introSkipper.d(introSkipper.skipSegments);
    const segment = introSkipper.getCurrentSegment(introSkipper.videoPlayer.currentTime);
    if (segment["SegmentType"] === "None") {
        console.warn("[intro skipper] doSkip() called without an active segment");
        return;
    }
    introSkipper.videoPlayer.currentTime = segment["IntroEnd"];
}
/** Tests if an element with the provided selector exists. */
introSkipper.testElement = function (selector) { return document.querySelector(selector); }
/** Make an authenticated fetch to the Jellyfin server and parse the response body as JSON. */
introSkipper.secureFetch = async function (url) {
    url = ApiClient.serverAddress() + "/" + url;
    const reqInit = { headers: { "Authorization": "MediaBrowser Token=" + ApiClient.accessToken() } };
    const res = await fetch(url, reqInit);
    if (res.status !== 200) { throw new Error(`Expected status 200 from ${url}, but got ${res.status}`); }
    return await res.json();
}
introSkipper.setup();

@AbandonedCart
Copy link
Collaborator

AbandonedCart commented May 18, 2024

Something like that should definitely be filtered for the platform, though. Possibly a separate instance of the case that includes a condition.

case "None" and platform:
 case "None":

@rlauuzo
Copy link
Collaborator

rlauuzo commented May 18, 2024

Would if (document.body.classList.contains('layout-tv')) be enough to restrict it to tvs and consoles?

@jumoog
Copy link
Owner

jumoog commented May 18, 2024

or install the keydown event listener once with a debounce and redirect to the pause button if the skip button is not visible.

@AbandonedCart
Copy link
Collaborator

Would if (document.body.classList.contains('layout-tv')) be enough to restrict it to tvs and consoles?

Probably. Every suggestion I've found eventually leads to @media tv

@jumoog
Copy link
Owner

jumoog commented May 18, 2024

It works. I am now testing with an LG TV. Enter is ignored for 5 seconds. No pause.

let introSkipper = {
    allowEnter: true,
    skipSegments: {},
    videoPlayer: {},
    // .bind() is used here to prevent illegal invocation errors
    originalFetch: window.fetch.bind(window),
};
introSkipper.d = function (msg) {
    console.debug("[intro skipper] ", msg);
  }
  /** Setup event listeners */
  introSkipper.setup = function () {
    document.addEventListener("viewshow", introSkipper.viewShow);
    window.fetch = introSkipper.fetchWrapper;
    introSkipper.d("Registered hooks");
  }
  /** Wrapper around fetch() that retrieves skip segments for the currently playing item. */
  introSkipper.fetchWrapper = async function (...args) {
    // Based on JellyScrub's trickplay.js
    let [resource, options] = args;
    let response = await introSkipper.originalFetch(resource, options);
    // Bail early if this isn't a playback info URL
    try {
      let path = new URL(resource).pathname;
      if (!path.includes("/PlaybackInfo")) { return response; }
      introSkipper.d("Retrieving skip segments from URL");
      introSkipper.d(path);
      
      // Check for context root and set id accordingly
      let path_arr = path.split("/");
      let id = "";
      if (path_arr[1] == "Items") {
        id = path_arr[2];
      } else {
        id = path_arr[3];
      }

      introSkipper.skipSegments = await introSkipper.secureFetch(`Episode/${id}/IntroSkipperSegments`);
      introSkipper.d("Successfully retrieved skip segments");
      introSkipper.d(introSkipper.skipSegments);
    }
    catch (e) {
      console.error("Unable to get skip segments from", resource, e);
    }
    return response;
  }
  /**
  * Event handler that runs whenever the current view changes.
  * Used to detect the start of video playback.
  */
  introSkipper.viewShow = function () {
    const location = window.location.hash;
    introSkipper.d("Location changed to " + location);
    if (location !== "#/video") {
      introSkipper.d("Ignoring location change");
      return;
    }
    introSkipper.injectCss();
    introSkipper.injectButton();
    document.body.addEventListener('keydown', introSkipper.eventHandler, true);
    introSkipper.videoPlayer = document.querySelector("video");
    if (introSkipper.videoPlayer != null) {
      introSkipper.d("Hooking video timeupdate");
      introSkipper.videoPlayer.addEventListener("timeupdate", introSkipper.videoPositionChanged);
    }
  }
  /**
  * Injects the CSS used by the skip intro button.
  * Calling this function is a no-op if the CSS has already been injected.
  */
  introSkipper.injectCss = function () {
    if (introSkipper.testElement("style#introSkipperCss")) {
      introSkipper.d("CSS already added");
      return;
    }
    introSkipper.d("Adding CSS");
    let styleElement = document.createElement("style");
    styleElement.id = "introSkipperCss";
    styleElement.innerText = `
    @media (hover:hover) and (pointer:fine) {
        #skipIntro .paper-icon-button-light:hover:not(:disabled) {
            color: black !important;
            background-color: rgba(47, 93, 98, 0) !important;
        }
    }
    #skipIntro .paper-icon-button-light.show-focus:focus {
        transform: scale(1.04) !important;
    }
    #skipIntro.upNextContainer {
        width: unset;
    }
    #skipIntro {
        padding: 0 1px;
        position: absolute;
        right: 10em;
        bottom: 9em;
        background-color: rgba(25, 25, 25, 0.66);
        border: 1px solid;
        border-radius: 0px;
        display: inline-block;
        cursor: pointer;
        opacity: 0;
        box-shadow: inset 0 0 0 0 #f9f9f9;
        -webkit-transition: ease-out 0.4s;
        -moz-transition: ease-out 0.4s;
        transition: ease-out 0.4s;
    }
    #skipIntro #btnSkipSegmentText {
        padding-right: 3px;
        padding-bottom: 2px;
    }
    @media (max-width: 1080px) {
        #skipIntro {
            right: 10%;
        }
    }
    #skipIntro:hover {
        box-shadow: inset 400px 0 0 0 #f9f9f9;
        -webkit-transition: ease-in 1s;
        -moz-transition: ease-in 1s;
        transition: ease-in 1s;
    }
    `;
    document.querySelector("head").appendChild(styleElement);
}
/**
 * Inject the skip intro button into the video player.
 * Calling this function is a no-op if the CSS has already been injected.
 */
introSkipper.injectButton = async function () {
    // Ensure the button we're about to inject into the page doesn't conflict with a pre-existing one
    const preExistingButton = introSkipper.testElement("div.skipIntro");
    if (preExistingButton) {
        preExistingButton.style.display = "none";
    }
    if (introSkipper.testElement(".btnSkipIntro.injected")) {
        introSkipper.d("Button already added");
        return;
    }
    introSkipper.d("Adding button");
    let config = await introSkipper.secureFetch("Intros/UserInterfaceConfiguration");
    if (!config.SkipButtonVisible) {
        introSkipper.d("Not adding button: not visible");
        return;
    }
    // Construct the skip button div
    const button = document.createElement("div");
    button.id = "skipIntro"
    button.classList.add("hide");
    button.addEventListener("click", introSkipper.doSkip);
    button.innerHTML = `
    <button is="paper-icon-button-light" class="btnSkipIntro paper-icon-button-light injected">
        <span id="btnSkipSegmentText"></span>
        <span class="material-icons skip_next"></span>
    </button>
    `;
    button.dataset["intro_text"] = config.SkipButtonIntroText;
    button.dataset["credits_text"] = config.SkipButtonEndCreditsText;
    /*
    * Alternative workaround for #44. Jellyfin's video component registers a global click handler
    * (located at src/controllers/playback/video/index.js:1492) that pauses video playback unless
    * the clicked element has a parent with the class "videoOsdBottom" or "upNextContainer".
    */
    button.classList.add("upNextContainer");
    // Append the button to the video OSD
    let controls = document.querySelector("div#videoOsdPage");
    controls.appendChild(button);
}
/** Tests if the OSD controls are visible. */
introSkipper.osdVisible = function () {
    const osd = document.querySelector("div.videoOsdBottom");
    return osd ? !osd.classList.contains("hide") : false;
}
/** Get the currently playing skippable segment. */
introSkipper.getCurrentSegment = function (position) {
    for (let key in introSkipper.skipSegments) {
        const segment = introSkipper.skipSegments[key];
        if ((position >= segment.ShowSkipPromptAt && position < segment.HideSkipPromptAt) || (introSkipper.osdVisible() && position >= segment.IntroStart && position < segment.IntroEnd)) {
            segment["SegmentType"] = key;
            return segment;
        }
    }
    return { "SegmentType": "None" };
}
/** Playback position changed, check if the skip button needs to be displayed. */
introSkipper.videoPositionChanged = function () {
    const skipButton = document.querySelector("#skipIntro");
    if (!skipButton) {
        return;
    }
    const segment = introSkipper.getCurrentSegment(introSkipper.videoPlayer.currentTime);
    switch (segment["SegmentType"]) {
        case "None":
            if (skipButton.style.opacity === '0') return;

            skipButton.style.opacity = '0';
            skipButton.addEventListener("transitionend", () => {
                skipButton.classList.add("hide");
            }, { once: true });
            return;
        case "Introduction":
            skipButton.querySelector("#btnSkipSegmentText").textContent =
                skipButton.dataset["intro_text"];
            break;
        case "Credits":
            skipButton.querySelector("#btnSkipSegmentText").textContent =
                skipButton.dataset["credits_text"];
            break;
    }
    if (!skipButton.classList.contains("hide")) return;

    skipButton.classList.remove("hide");
    requestAnimationFrame(() => {
        requestAnimationFrame(() => {
            skipButton.style.opacity = '1';
        });
    });
}
/** Seeks to the end of the intro. */
introSkipper.doSkip = function (e) {
    introSkipper.d("Skipping intro");
    introSkipper.d(introSkipper.skipSegments);
    const segment = introSkipper.getCurrentSegment(introSkipper.videoPlayer.currentTime);
    if (segment["SegmentType"] === "None") {
        console.warn("[intro skipper] doSkip() called without an active segment");
        return;
    }
    introSkipper.videoPlayer.currentTime = segment["IntroEnd"];
}
/** Tests if an element with the provided selector exists. */
introSkipper.testElement = function (selector) { return document.querySelector(selector); }
/** Make an authenticated fetch to the Jellyfin server and parse the response body as JSON. */
introSkipper.secureFetch = async function (url) {
    url = ApiClient.serverAddress() + "/" + url;
    const reqInit = { headers: { "Authorization": "MediaBrowser Token=" + ApiClient.accessToken() } };
    const res = await fetch(url, reqInit);
    if (res.status !== 200) { throw new Error(`Expected status 200 from ${url}, but got ${res.status}`); }
    return await res.json();
}
introSkipper.eventHandler = function (e) {
    console.log(e);
    if (!introSkipper.allowEnter) {
        event.preventDefault();
    }
    else if (e.key === "Enter" && document.querySelector("#skipIntro").style.opacity !== '0') {
        e.preventDefault();
        e.stopPropagation();
        introSkipper.doSkip();
        introSkipper.allowEnter = false
        setTimeout(() => {
          introSkipper.allowEnter = true;
        }, 5000);
    }
}
introSkipper.setup();

It works on LG!

@jumoog
Copy link
Owner

jumoog commented May 18, 2024

lol on amazon prime the skip button for the intro does not have a focus on the web version.

@jumoog
Copy link
Owner

jumoog commented May 18, 2024

it's in v0.2.0.5

@jumoog jumoog closed this as completed May 18, 2024
@Bretterteig
Copy link
Author

Just installed .5. Skipping was working, however leaving the stream immediatly did prohibit me from selectibg menu items (without cursor on LG webOS)

@jumoog
Copy link
Owner

jumoog commented May 18, 2024

Just installed .5. Skipping was working, however leaving the stream immediatly did prohibit me from selectibg menu items (without cursor on LG webOS)

Nach Drücken der Enter-Taste (LG Fernbedienung) ist die Eingabe für 5 Sekunden gesperrt.

@AbandonedCart
Copy link
Collaborator

That’s why I was suggesting moving it to the completion of onSkip

@jumoog
Copy link
Owner

jumoog commented May 18, 2024

Sorry but I dont understand the problem

@Bretterteig
Copy link
Author

After skipping the intro:

  • OSD controls work at some point
  • Going back to the menu (leaving the player) the focused items are not reacting to the return key.

@jumoog
Copy link
Owner

jumoog commented May 18, 2024

After skipping the intro:

* OSD controls work at some point

* Going back to the menu (leaving the player) the focused items are not reacting to the return key.

can you wait 5 seconds and try "Going back to the menu (leaving the player)" again?

@Bretterteig
Copy link
Author

Does not change the behaviour.
When skipping I can see the button reappear for like .1 sec. Could this be related?

@jumoog
Copy link
Owner

jumoog commented May 18, 2024

I have clarified the problem in the discord call. I will roll back the commit for now and create a new version

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

4 participants