const introSkipper = { originalFetch: window.fetch.bind(window), originalXHROpen: XMLHttpRequest.prototype.open, d: (msg) => console.debug("[intro skipper] ", msg), setup() { const self = this; this.initializeState(); this.initializeObserver(); this.currentOption = localStorage.getItem("introskipperOption") || "Show Button"; window.fetch = this.fetchWrapper.bind(this); XMLHttpRequest.prototype.open = function (...args) { self.xhrOpenWrapper(this, ...args); }; document.addEventListener("viewshow", this.viewShow.bind(this)); this.videoPositionChanged = this.videoPositionChanged.bind(this); this.handleEscapeKey = this.handleEscapeKey.bind(this); this.d("Registered hooks"); }, initializeState() { Object.assign(this, { allowEnter: true, skipSegments: {}, videoPlayer: null, skipButton: null, osdElement: null, skipperData: null, currentEpisodeId: null, injectMetadata: false, }); }, initializeObserver() { this.observer = new MutationObserver((mutations) => { const actionSheet = mutations[mutations.length - 1].target.querySelector(".actionSheet"); if ( actionSheet && !actionSheet.querySelector(`[data-id="${"introskipperMenu"}"]`) ) this.injectIntroSkipperOptions(actionSheet); }); }, fetchWrapper(resource, options) { const response = this.originalFetch(resource, options); const url = new URL(resource); if (this.injectMetadata && url.pathname.includes("/MetadataEditor")) { this.processMetadata(url.pathname); } return response; }, xhrOpenWrapper(xhr, method, url, ...rest) { url.includes("/PlaybackInfo") && this.processPlaybackInfo(url); return this.originalXHROpen.apply(xhr, [method, url, ...rest]); }, async processPlaybackInfo(url) { const id = this.extractId(url); if (id) { try { this.skipSegments = await this.secureFetch( `Episode/${id}/IntroSkipperSegments`, ); } catch (error) { this.d(`Error fetching skip segments: ${error.message}`); } } }, async processMetadata(url) { const id = this.extractId(url); if (id) { try { this.skipperData = await this.secureFetch(`Episode/${id}/Timestamps`); if (this.skipperData) { this.currentEpisodeId = id; requestAnimationFrame(() => { const metadataFormFields = document.querySelector( ".metadataFormFields", ); metadataFormFields && this.injectSkipperFields(metadataFormFields); }); } } catch (e) { console.error("Error processing", e); } } }, extractId(searchString) { const startIndex = searchString.indexOf("Items/") + 6; const endIndex = searchString.indexOf("/", startIndex); return endIndex !== -1 ? searchString.substring(startIndex, endIndex) : searchString.substring(startIndex); }, /** * 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}`); this.allowEnter = true; this.injectMetadata = /#\/(tv|details|home|search)/.test(location); if (location === "#/video") { 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"); this.observer.observe(document.body, { childList: true, subtree: false, }); } } else { this.observer.disconnect(); } }, /** * 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.7); 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 { background: rgb(var(--accent)); box-shadow: 0 0 8px rgba(var(--accent), 0.6); filter: brightness(1.2); } #skipIntro .emby-button:focus { background: rgb(var(--accent)); 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 in this.skipSegments) { const segment = this.skipSegments[key]; if ( (position > segment.ShowSkipPromptAt && position < segment.HideSkipPromptAt) || (this.osdVisible() && position > segment.IntroStart && position < segment.IntroEnd - 3) ) { segment["SegmentType"] = key; return segment; } } return { SegmentType: "None" }; }, overrideBlur(button) { if (!button.originalBlur) { button.originalBlur = button.blur; button.blur = function () { if (!this.contains(document.activeElement)) { this.originalBlur(); } }; } }, /** Playback position changed, check if the skip button needs to be displayed. */ videoPositionChanged() { if (!this.skipButton || !this.allowEnter) return; const { SegmentType: segmentType } = this.getCurrentSegment(this.videoPlayer.currentTime); if ( segmentType === "None" || this.currentOption === "Off" ) { this.hideSkipButton(); return; } if ( this.currentOption === "Automatically Skip" || (this.currentOption === "Button w/ auto PiP" && document.pictureInPictureElement) ) { this.doSkip(); return; } const button = this.skipButton.querySelector(".emby-button"); this.skipButton.querySelector("#btnSkipSegmentText").textContent = this.skipButton.dataset[segmentType]; if (!this.skipButton.classList.contains("hide")) { if (!this.osdVisible() && !button.contains(document.activeElement)) { button.focus(); } return; } requestAnimationFrame(() => { this.skipButton.classList.remove("hide"); requestAnimationFrame(() => { this.skipButton.classList.add("show"); this.overrideBlur(button); button.focus(); }); }); }, hideSkipButton() { if (this.skipButton.classList.contains("show")) { this.skipButton.classList.remove("show"); const button = this.skipButton.querySelector(".emby-button"); button.addEventListener( "transitionend", () => { this.skipButton.classList.add("hide"); if (this.osdVisible()) { this.osdElement.querySelector("button.btnPause").focus(); } else { button.originalBlur(); } }, { once: true }, ); } }, /** 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; } this.d(`Skipping ${segment.SegmentType}`); const seekedHandler = () => { this.videoPlayer.removeEventListener("seeked", seekedHandler); setTimeout(() => { this.allowEnter = true; }, 700); }; this.videoPlayer.addEventListener("seeked", seekedHandler); this.videoPlayer.currentTime = segment.IntroEnd; this.hideSkipButton(); }, createButton(ref, id, innerHTML, clickHandler) { const button = ref.cloneNode(true); button.setAttribute("data-id", id); button.innerHTML = innerHTML; button.addEventListener("click", clickHandler); return button; }, closeSubmenu(fullscreen) { document.querySelector(".dialogContainer").remove(); document.querySelector(".dialogBackdrop").remove(); document.dispatchEvent(new KeyboardEvent("keydown", { key: "Control" })); if (!fullscreen) return; document.removeEventListener("keydown", this.handleEscapeKey); document.querySelector(".btnVideoOsdSettings").focus(); }, openSubmenu(ref, menu) { const options = [ "Show Button", "Button w/ auto PiP", "Automatically Skip", "Off", ]; const submenu = menu.cloneNode(true); const scroller = submenu.querySelector(".actionSheetScroller"); scroller.innerHTML = ""; for (const option of options) { if (option !== "Button w/ auto PiP" || document.pictureInPictureEnabled) { const button = this.createButton( ref, `introskipper-${option.toLowerCase().replace(" ", "-")}`, `