const introSkipper = { originalFetch: window.fetch.bind(window), d: msg => console.debug("[intro skipper] ", msg), setup() { this.initializeState(); this.initializeObserver(); this.currentOption = localStorage.getItem('introskipperOption') || 'Show Button'; document.addEventListener("viewshow", this.viewShow.bind(this)); window.fetch = this.fetchWrapper.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); }); }, /** Wrapper around fetch() that retrieves skip segments for the currently playing item or metadata. */ async fetchWrapper(resource, options) { const response = await this.originalFetch(resource, options); this.processResource(resource); return response; }, async processResource(resource) { try { const url = new URL(resource); const pathname = url.pathname; if (pathname.includes("/PlaybackInfo")) { this.d(`Retrieving skip segments from URL ${pathname}`); const pathArr = pathname.split("/"); const id = pathArr[pathArr.indexOf("Items") + 1] || pathArr[3]; this.skipSegments = await this.secureFetch(`Episode/${id}/IntroSkipperSegments`); this.d("Retrieved skip segments", this.skipSegments); } else if (this.injectMetadata && pathname.includes("/MetadataEditor")) { this.d(`Metadata editor detected, URL ${pathname}`); const pathArr = pathname.split("/"); this.currentEpisodeId = pathArr[pathArr.indexOf("Items") + 1] || pathArr[3]; this.skipperData = await this.secureFetch(`Episode/${this.currentEpisodeId}/Timestamps`); if (this.skipperData) { requestAnimationFrame(() => { const metadataFormFields = document.querySelector('.metadataFormFields'); metadataFormFields && this.injectSkipperFields(metadataFormFields); }); } } } catch (e) { console.error("Error processing", resource, e); } }, /** * 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(' ', '-')}`, `
${option}
`, () => this.selectOption(option)); scroller.appendChild(button); } }); const backdrop = document.createElement('div'); backdrop.className = 'dialogBackdrop dialogBackdropOpened'; document.body.append(backdrop, submenu); const actionSheet = submenu.querySelector('.actionSheet'); if (actionSheet.classList.contains('actionsheet-not-fullscreen')) { this.adjustPosition(actionSheet, document.querySelector('.btnVideoOsdSettings')); submenu.addEventListener('click', () => this.closeSubmenu(false)); } else { submenu.querySelector('.btnCloseActionSheet').addEventListener('click', () => this.closeSubmenu(true)) scroller.addEventListener('click', () => this.closeSubmenu(true)) document.addEventListener('keydown', this.handleEscapeKey); setTimeout(() => scroller.firstElementChild.focus(), 240); } }, selectOption(option) { this.currentOption = option; localStorage.setItem('introskipperOption', option); this.d(`Introskipper option selected and saved: ${option}`); }, isAutoSkipLocked(config) { const isAutoSkip = config.AutoSkip && config.AutoSkipCredits; const isAutoSkipClient = new Set(config.ClientList.split(',')).has(ApiClient.appName()); return isAutoSkip || (config.SkipButtonVisible && isAutoSkipClient); }, async injectIntroSkipperOptions(actionSheet) { if (!this.skipButton) return; const config = await this.secureFetch("Intros/UserInterfaceConfiguration"); if (this.isAutoSkipLocked(config)) { this.d("Auto skip enforced by server"); return; } const statsButton = actionSheet.querySelector('[data-id="stats"]'); if (!statsButton) return; const menuItem = this.createButton(statsButton, 'introskipperMenu', `
Intro Skipper
${this.currentOption}
`, () => this.openSubmenu(statsButton, actionSheet.closest('.dialogContainer'))); const originalWidth = actionSheet.offsetWidth; statsButton.before(menuItem); if (actionSheet.classList.contains('actionsheet-not-fullscreen')) this.adjustPosition(actionSheet, menuItem, originalWidth); }, adjustPosition(element, reference, originalWidth) { if (originalWidth) { const currentTop = parseInt(element.style.top, 10) || 0; element.style.top = `${currentTop - reference.offsetHeight}px`; const newWidth = Math.max(reference.offsetWidth - originalWidth, 0); const originalLeft = parseInt(element.style.left, 10) || 0; element.style.left = `${originalLeft - newWidth / 2}px`; } else { const rect = reference.getBoundingClientRect(); element.style.left = `${Math.min(rect.left - (element.offsetWidth - rect.width) / 2, window.innerWidth - element.offsetWidth - 10)}px`; element.style.top = `${rect.top - element.offsetHeight + rect.height}px`; } }, injectSkipperFields(metadataFormFields) { const skipperFields = document.createElement('div'); skipperFields.className = 'detailSection introskipperSection'; skipperFields.innerHTML = `

Intro Skipper

`; metadataFormFields.querySelector('#metadataSettingsCollapsible').insertAdjacentElement('afterend', skipperFields); this.attachSaveListener(metadataFormFields); this.updateSkipperFields(skipperFields); this.setTimeInputs(skipperFields); }, updateSkipperFields(skipperFields) { const { Introduction = {}, Credits = {} } = this.skipperData; skipperFields.querySelector('#introStartEdit').value = Introduction.Start || 0; skipperFields.querySelector('#introEndEdit').value = Introduction.End || 0; skipperFields.querySelector('#creditsStartEdit').value = Credits.Start || 0; skipperFields.querySelector('#creditsEndEdit').value = Credits.End || 0; }, attachSaveListener(metadataFormFields) { const saveButton = metadataFormFields.querySelector('.formDialogFooter .btnSave'); if (saveButton) { saveButton.addEventListener('click', this.saveSkipperData.bind(this)); } else { console.error('Save button not found'); } }, setTimeInputs(skipperFields) { const inputContainers = skipperFields.querySelectorAll('.inputContainer'); inputContainers.forEach(container => { const displayInput = container.querySelector('[id$="Display"]'); const editInput = container.querySelector('[id$="Edit"]'); displayInput.addEventListener('pointerdown', (e) => { e.preventDefault(); this.switchToEdit(displayInput, editInput); }); editInput.addEventListener('blur', () => this.switchToDisplay(displayInput, editInput)); displayInput.value = this.formatTime(parseFloat(editInput.value) || 0); }); }, formatTime(totalSeconds) { const totalRoundedSeconds = Math.round(totalSeconds); const hours = Math.floor(totalRoundedSeconds / 3600); const minutes = Math.floor((totalRoundedSeconds % 3600) / 60); const seconds = totalRoundedSeconds % 60; let result = []; if (hours > 0) result.push(`${hours} hour${hours !== 1 ? 's' : ''}`); if (minutes > 0) result.push(`${minutes} minute${minutes !== 1 ? 's' : ''}`); if (seconds > 0 || result.length === 0) result.push(`${seconds} second${seconds !== 1 ? 's' : ''}`); return result.join(' '); }, switchToEdit(displayInput, editInput) { displayInput.style.display = 'none'; editInput.style.display = ''; editInput.focus(); }, switchToDisplay(displayInput, editInput) { editInput.style.display = 'none'; displayInput.style.display = ''; displayInput.value = this.formatTime(parseFloat(editInput.value) || 0); }, async saveSkipperData() { const newTimestamps = { Introduction: { Start: parseFloat(document.getElementById('introStartEdit').value || 0), End: parseFloat(document.getElementById('introEndEdit').value || 0) }, Credits: { Start: parseFloat(document.getElementById('creditsStartEdit').value || 0), End: parseFloat(document.getElementById('creditsEndEdit').value || 0) } }; const { Introduction = {}, Credits = {} } = this.skipperData; if (newTimestamps.Introduction.Start !== (Introduction.Start || 0) || newTimestamps.Introduction.End !== (Introduction.End || 0) || newTimestamps.Credits.Start !== (Credits.Start || 0) || newTimestamps.Credits.End !== (Credits.End || 0)) { const response = await this.secureFetch(`Episode/${this.currentEpisodeId}/Timestamps`, "POST", JSON.stringify(newTimestamps)); this.d(response.ok ? 'Timestamps updated successfully' : 'Failed to update timestamps:', response.status); } else { this.d('Timestamps have not changed, skipping update'); } }, /** Make an authenticated fetch to the Jellyfin server and parse the response body as JSON. */ async secureFetch(url, method = "GET", body = null) { const response = await fetch(`${ApiClient.serverAddress()}/${url}`, { method, headers: Object.assign({ "Authorization": `MediaBrowser Token=${ApiClient.accessToken()}` }, method === "POST" ? {"Content-Type": "application/json"} : {}), body }); return response.ok ? (method === "POST" ? response : response.json()) : response.status === 404 ? null : console.error(`Error ${response.status} from ${url}`) || null; }, /** Handle keydown events. */ eventHandler(e) { if (e.key !== "Enter") return; e.stopPropagation(); e.preventDefault(); this.doSkip(); }, handleEscapeKey(e) { if (e.key === 'Escape' || e.keyCode === 461 || e.keyCode === 10009) { e.stopPropagation(); this.closeSubmenu(true); } } }; introSkipper.setup();