const introSkipper = { originalFetch: window.fetch.bind(window), originalXHROpen: XMLHttpRequest.prototype.open, originalXHRSend: XMLHttpRequest.prototype.send, 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); }; XMLHttpRequest.prototype.send = function(...args) { self.xhrSendWrapper(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, async, user, password) { this.d(`XHR Open: ${method} ${url}`); xhr._introSkipperUrl = url; return this.originalXHROpen.apply(xhr, [method, url, async, user, password]); }, xhrSendWrapper(xhr, body) { const originalOnReadyStateChange = xhr.onreadystatechange; xhr.onreadystatechange = () => { if (xhr.readyState === 4 && xhr._introSkipperUrl.includes("/PlaybackInfo")) { this.processPlaybackInfo(xhr._introSkipperUrl); } if (originalOnReadyStateChange) { originalOnReadyStateChange.apply(xhr, arguments); } }; return this.originalXHRSend.apply(xhr, [body]); }, 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, 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 = function() { if (!this.contains(document.activeElement)) { this.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 = this.getCurrentSegment(this.videoPlayer.currentTime).SegmentType; if (segmentType === "None" || this.currentOption === "Off" || !this.allowEnter) { if (this.skipButton.classList.contains('show')) { this.skipButton.classList.remove('show'); embyButton.addEventListener("transitionend", () => { this.skipButton.classList.add("hide"); if (this.osdVisible()) { this.osdElement.querySelector('button.btnPause').focus(); } else { embyButton.originalBlur(); } }, { once: true }); } return; } if (this.currentOption === "Automatically Skip" || (this.currentOption === "Button w/ auto PiP" && document.pictureInPictureElement)) { this.doSkip(); return; } this.skipButton.querySelector("#btnSkipSegmentText").textContent = this.skipButton.dataset[segmentType]; if (!this.skipButton.classList.contains("hide")) { if (!this.osdVisible() && !embyButton.contains(document.activeElement)) embyButton.focus(); return; } requestAnimationFrame(() => { this.skipButton.classList.remove("hide"); requestAnimationFrame(() => { this.skipButton.classList.add('show'); this.overrideBlur(embyButton); embyButton.focus(); }); }); }, /** 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}`); this.allowEnter = false; const seekedHandler = () => { this.videoPlayer.removeEventListener('seeked', seekedHandler); setTimeout(() => { this.allowEnter = true; }, 500); }; this.videoPlayer.addEventListener('seeked', seekedHandler); this.videoPlayer.currentTime = segment.SegmentType === "Credits" && this.videoPlayer.duration - segment.IntroEnd < 3 ? this.videoPlayer.duration + 10 : segment.IntroEnd; }, 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 = ''; options.forEach(option => { if (option !== 'Button w/ auto PiP' || document.pictureInPictureEnabled) { const button = this.createButton(ref, `introskipper-${option.toLowerCase().replace(' ', '-')}`, `