From 96dc3a976340a6c2819a7f5cbcdaaa3d97cbc330 Mon Sep 17 00:00:00 2001 From: rlauuzo <46294892+rlauuzo@users.noreply.github.com> Date: Wed, 24 Jul 2024 12:56:12 +0200 Subject: [PATCH] Refactor inject.js (#216) --- .../Configuration/inject.js | 527 ++++++++---------- 1 file changed, 219 insertions(+), 308 deletions(-) diff --git a/ConfusedPolarBear.Plugin.IntroSkipper/Configuration/inject.js b/ConfusedPolarBear.Plugin.IntroSkipper/Configuration/inject.js index 3a928c7..a654742 100644 --- a/ConfusedPolarBear.Plugin.IntroSkipper/Configuration/inject.js +++ b/ConfusedPolarBear.Plugin.IntroSkipper/Configuration/inject.js @@ -1,322 +1,233 @@ -let introSkipper = { - allowEnter: true, - skipSegments: {}, - videoPlayer: {}, - // .bind() is used here to prevent illegal invocation errors +const introSkipper = { 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); - document.body.addEventListener('keydown', introSkipper.eventHandler, true); - } - } - /** - * 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 = ` - :root { - --rounding: .2em; - --accent: 0, 164, 220; - } - #skipIntro.upNextContainer { - width: unset; - margin: unset; - } - #skipIntro { - position: absolute; - bottom: 6em; - right: 4.5em; - background-color: transparent; - font-size: 1.2em; - } - #skipIntro .emby-button { - text-shadow: 0 0 3px rgba(0, 0, 0, 0.7); - border-radius: var(--rounding); - background-color: rgba(0, 0, 0, 0.3); - will-change: opacity, transform; - opacity: 0; - transition: opacity 0.3s ease-in, transform 0.3s ease-out; - } - #skipIntro.show .emby-button { - opacity: 1; - } - #skipIntro .emby-button:hover, - #skipIntro .emby-button:focus { - background-color: rgba(var(--accent),0.7); - transform: scale(1.05); - } - #btnSkipSegmentText { - padding-right: 0.15em; - padding-left: 0.2em; - margin-top: -0.1em; - } - `; - 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.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; + d: msg => console.debug("[intro skipper] ", msg), + setup() { + this.initializeState(); + document.addEventListener("viewshow", this.viewShow.bind(this)); + window.fetch = this.fetchWrapper.bind(this); + this.videoPositionChanged = this.videoPositionChanged.bind(this); + this.d("Registered hooks"); + }, + initializeState() { + Object.assign(this, { allowEnter: true, skipSegments: {}, videoPlayer: null, skipButton: null, osdElement: null }); + }, + /** Wrapper around fetch() that retrieves skip segments for the currently playing item. */ + async fetchWrapper(resource, options) { + const response = await this.originalFetch(resource, options); + try { + const url = new URL(resource); + if (!url.pathname.includes("/PlaybackInfo")) return response; + this.d("Retrieving skip segments from URL", url.pathname); + const pathArr = url.pathname.split("/"); + const id = pathArr[1] === "Items" ? pathArr[2] : pathArr[3]; + this.skipSegments = await this.secureFetch(`Episode/${id}/IntroSkipperSegments`); + this.d("Successfully retrieved skip segments", this.skipSegments); + } catch (e) { + console.error("Unable to get skip segments from", resource, e); } - } - return { "SegmentType": "None" }; -} -introSkipper.overrideBlur = function(embyButton) { - if (!embyButton.originalBlur) { - embyButton.originalBlur = embyButton.blur; - } - embyButton.blur = function () { - if (!introSkipper.osdVisible() || !embyButton.contains(document.activeElement)) { - embyButton.originalBlur.call(this); + return response; + }, + /** + * Event handler that runs whenever the current view changes. + * Used to detect the start of video playback. + */ + viewShow() { + const location = window.location.hash; + this.d(`Location changed to ${location}`); + if (location !== "#/video") { + if (this.videoPlayer) this.initializeState(); + return; } - }; -}; -introSkipper.restoreBlur = function(embyButton) { - if (embyButton.originalBlur) { - embyButton.blur = embyButton.originalBlur; - delete embyButton.originalBlur; - } -}; -/** Playback position changed, check if the skip button needs to be displayed. */ -introSkipper.videoPositionChanged = function () { - const skipButton = document.querySelector("#skipIntro"); - if (introSkipper.videoPlayer.currentTime === 0 || !skipButton || !introSkipper.allowEnter) return; - const embyButton = skipButton.querySelector(".emby-button"); - const tvLayout = document.documentElement.classList.contains("layout-tv"); - const segment = introSkipper.getCurrentSegment(introSkipper.videoPlayer.currentTime); - switch (segment.SegmentType) { - case "None": - if (!skipButton.classList.contains('show')) return; - skipButton.classList.remove('show'); - embyButton.addEventListener("transitionend", () => { - skipButton.classList.add("hide"); - if (tvLayout) { - introSkipper.restoreBlur(embyButton); - embyButton.blur(); + this.injectCss(); + this.injectButton(); + this.videoPlayer = document.querySelector("video"); + if (this.videoPlayer) { + this.d("Hooking video timeupdate"); + this.videoPlayer.addEventListener("timeupdate", this.videoPositionChanged); + this.osdElement = document.querySelector("div.videoOsdBottom") + } + }, + /** + * Injects the CSS used by the skip intro button. + * Calling this function is a no-op if the CSS has already been injected. + */ + injectCss() { + if (document.querySelector("style#introSkipperCss")) { + this.d("CSS already added"); + return; + } + this.d("Adding CSS"); + const styleElement = document.createElement("style"); + styleElement.id = "introSkipperCss"; + styleElement.textContent = ` + :root { + --rounding: 4px; + --accent: 0, 164, 220; + } + #skipIntro.upNextContainer { + width: unset; + margin: unset; + } + #skipIntro { + position: absolute; + bottom: 7.5em; + right: 5em; + background-color: transparent; + } + #skipIntro .emby-button { + color: #ffffff; + font-size: 110%; + background: rgba(0, 0, 0, 0.8); + border-radius: var(--rounding); + box-shadow: 0 0 4px rgba(0, 0, 0, 0.6); + transition: opacity 0.3s cubic-bezier(0.4,0,0.2,1), + transform 0.3s cubic-bezier(0.4,0,0.2,1), + background-color 0.2s ease-out, + box-shadow 0.2s ease-out; + opacity: 0; + transform: translateY(50%); + } + #skipIntro.show .emby-button { + opacity: 1; + transform: translateY(0); + } + #skipIntro .emby-button:hover, + #skipIntro .emby-button:focus { + background: rgba(var(--accent), 1); + box-shadow: 0 0 8px rgba(var(--accent), 0.6); + } + #btnSkipSegmentText { + letter-spacing: 0.5px; + padding: 0 5px 0 5px; + } + `; + 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. + */ + async injectButton() { + // Ensure the button we're about to inject into the page doesn't conflict with a pre-existing one + const preExistingButton = document.querySelector("div.skipIntro"); + if (preExistingButton) { + preExistingButton.style.display = "none"; + } + if (document.querySelector(".btnSkipIntro.injected")) { + this.d("Button already added"); + this.skipButton = document.querySelector("#skipIntro"); + return; + } + const config = await this.secureFetch("Intros/UserInterfaceConfiguration"); + if (!config.SkipButtonVisible) { + this.d("Not adding button: not visible"); + return; + } + this.d("Adding button"); + this.skipButton = document.createElement("div"); + this.skipButton.id = "skipIntro"; + this.skipButton.classList.add("hide", "upNextContainer"); + this.skipButton.addEventListener("click", this.doSkip.bind(this)); + this.skipButton.addEventListener("keydown", this.eventHandler.bind(this)); + this.skipButton.innerHTML = ` + + `; + this.skipButton.dataset.Introduction = config.SkipButtonIntroText; + this.skipButton.dataset.Credits = config.SkipButtonEndCreditsText; + const controls = document.querySelector("div#videoOsdPage"); + controls.appendChild(this.skipButton); + }, + /** Tests if the OSD controls are visible. */ + osdVisible() { + return this.osdElement ? !this.osdElement.classList.contains("hide") : false; + }, + /** Get the currently playing skippable segment. */ + getCurrentSegment(position) { + for (const [key, segment] of Object.entries(this.skipSegments)) { + if ((position > segment.ShowSkipPromptAt && position < segment.HideSkipPromptAt - 1) || + (this.osdVisible() && position > segment.IntroStart && position < segment.IntroEnd - 1)) { + segment.SegmentType = key; + return segment; + } + } + return { SegmentType: "None" }; + }, + overrideBlur(button) { + if (!button.originalBlur) { + button.originalBlur = button.blur; + button.blur = () => { + if (!button.contains(document.activeElement)) { + button.originalBlur(); } + }; + } + }, + restoreBlur(button) { + if (button.originalBlur) { + button.blur = button.originalBlur; + delete button.originalBlur; + } + }, + /** Playback position changed, check if the skip button needs to be displayed. */ + videoPositionChanged() { + if (!this.skipButton) return; + const embyButton = this.skipButton.querySelector(".emby-button"); + const segmentType = introSkipper.getCurrentSegment(this.videoPlayer.currentTime).SegmentType; + if (segmentType === "None") { + if (!this.skipButton.classList.contains('show')) return; + this.skipButton.classList.remove('show'); + embyButton.addEventListener("transitionend", () => { + this.skipButton.classList.add("hide"); + this.restoreBlur(embyButton); + embyButton.blur(); + this.allowEnter = true; }, { 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; - requestAnimationFrame(() => { - skipButton.classList.remove("hide"); + } + this.skipButton.querySelector("#btnSkipSegmentText").textContent = this.skipButton.dataset[segmentType]; + if (!this.skipButton.classList.contains("hide")) { + if (!this.osdVisible() && !embyButton.contains(document.activeElement)) embyButton.focus({ focusVisible: true }); + return; + } requestAnimationFrame(() => { - skipButton.classList.add('show'); - if (tvLayout) { - introSkipper.overrideBlur(embyButton); + this.skipButton.classList.remove("hide"); + requestAnimationFrame(() => { + this.skipButton.classList.add('show'); + this.overrideBlur(embyButton); embyButton.focus({ focusVisible: true }); - } + }); }); - }); -} -introSkipper.throttle = function (func, limit) { - let inThrottle; - return function(...args) { - const context = this; - if (!inThrottle) { - func.apply(context, args); - inThrottle = true; - setTimeout(() => inThrottle = false, limit); + }, + /** Seeks to the end of the intro. */ + doSkip() { + if (!this.allowEnter) return; + const segment = this.getCurrentSegment(this.videoPlayer.currentTime); + if (segment.SegmentType === "None") { + console.warn("[intro skipper] doSkip() called without an active segment"); + return; } - }; -} -/** Seeks to the end of the intro. */ -introSkipper.doSkip = introSkipper.throttle(async function () { - 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; - } - const currentSrc = introSkipper.videoPlayer.currentSrc; - const controller = new AbortController(); - const signal = controller.signal; - const seekToSegmentEnd = () => new Promise((resolve) => { - const onSeeked = () => requestAnimationFrame(resolve); - introSkipper.videoPlayer.addEventListener('seeked', onSeeked, { once: true, signal }); - introSkipper.videoPlayer.currentTime = segment.IntroEnd; - }); - const nextEpisodeLoaded = () => new Promise(resolve => { - const onLoadStart = () => resolve(true); - const timeoutId = setTimeout(() => resolve(false), 500); - introSkipper.videoPlayer.addEventListener('loadstart', onLoadStart, { signal }); - signal.addEventListener('abort', () => clearTimeout(timeoutId)); - }); - // Disable keydown events - introSkipper.allowEnter = false; - try { - // Check if the segment is "Credits" and skipping would leave less than 3 seconds of video - if (segment.SegmentType === "Credits" && introSkipper.videoPlayer.duration - segment.IntroEnd < 3) { - // Simulate 'N' key press to go to the next episode - document.dispatchEvent(new KeyboardEvent('keydown', { key: 'N', shiftKey: true, bubbles: true })); - // Check if the next episode actually starts loading - const loaded = await nextEpisodeLoaded(); - // If the next episode didn't load, just seek to the end of the current segment - if (!loaded) { - await new Promise(resolve => setTimeout(resolve, 100)); // Short delay - if (introSkipper.videoPlayer.currentSrc === currentSrc) { - await seekToSegmentEnd(); - } - } - } else { - // Default behavior: seek to the end of the current segment - await seekToSegmentEnd(); - } - } finally { - introSkipper.allowEnter = true; // Always re-enable keydown events - controller.abort(); // Cleanup any remaining listeners - } -}, 3000); -/** 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(); -} -/** Handle keydown events. */ -introSkipper.eventHandler = function (e) { - const skipButton = document.querySelector("#skipIntro"); - if (!skipButton || skipButton.classList.contains("hide")) return; - // Ignore all keydown events - if (!introSkipper.allowEnter) { - e.preventDefault(); - return; - } - if (e.key !== "Enter") return; - const embyButton = skipButton.querySelector(".emby-button"); - if (document.documentElement.classList.contains("layout-tv") && embyButton.contains(document.activeElement)) { + this.d(`Skipping ${segment.SegmentType}`); + this.allowEnter = false; + this.videoPlayer.currentTime = segment.SegmentType === "Credits" && this.videoPlayer.duration - segment.IntroEnd < 3 + ? this.videoPlayer.duration + 10 + : segment.IntroEnd; + }, + /** Make an authenticated fetch to the Jellyfin server and parse the response body as JSON. */ + async secureFetch(url) { + url = new URL(url, ApiClient.serverAddress()); + const res = await fetch(url, { headers: { "Authorization": `MediaBrowser Token=${ApiClient.accessToken()}` } }); + if (!res.ok) throw new Error(`Expected status 200 from ${url}, but got ${res.status}`); + return res.json(); + }, + /** Handle keydown events. */ + eventHandler(e) { + if (e.key !== "Enter") return; e.stopPropagation(); - return; - } - if (document.documentElement.classList.contains("layout-desktop")) { e.preventDefault(); - e.stopPropagation(); - introSkipper.doSkip(); + this.doSkip(); } -} +}; introSkipper.setup();