diff --git a/IntroSkipper/Configuration/inject.js b/IntroSkipper/Configuration/inject.js index abbb75e..00bf322 100644 --- a/IntroSkipper/Configuration/inject.js +++ b/IntroSkipper/Configuration/inject.js @@ -1,112 +1,137 @@ 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 = ` + 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; @@ -152,213 +177,258 @@ const introSkipper = { 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 = ` + 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 = ` + 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 - 1) || + (this.osdVisible() && + position > segment.IntroStart && + position < segment.IntroEnd - 1) + ) { + return { ...segment, SegmentType: key }; + } + } + 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 = ""; + for (const option of options) { + 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 = Number.parseInt(element.style.top, 10) || 0; + element.style.top = `${currentTop - reference.offsetHeight}px`; + const newWidth = Math.max(reference.offsetWidth - originalWidth, 0); + const originalLeft = Number.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

@@ -385,105 +455,144 @@ const introSkipper = {
`; - 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); - } - } + 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"); + for (const container of inputContainers) { + 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( + Number.parseFloat(editInput.value) || 0, + ); + } + }, + formatTime(totalSeconds) { + if (!totalSeconds) return "0 seconds"; + const totalRoundedSeconds = Math.round(totalSeconds); + const hours = Math.floor(totalRoundedSeconds / 3600); + const minutes = Math.floor((totalRoundedSeconds % 3600) / 60); + const seconds = totalRoundedSeconds % 60; + const result = []; + if (hours) result.push(`${hours} hour${hours !== 1 ? "s" : ""}`); + if (minutes) result.push(`${minutes} minute${minutes !== 1 ? "s" : ""}`); + if (seconds || !result.length) + 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( + Number.parseFloat(editInput.value) || 0, + ); + }, + async saveSkipperData() { + const newTimestamps = { + Introduction: { + Start: Number.parseFloat( + document.getElementById("introStartEdit").value || 0, + ), + End: Number.parseFloat( + document.getElementById("introEndEdit").value || 0, + ), + }, + Credits: { + Start: Number.parseFloat( + document.getElementById("creditsStartEdit").value || 0, + ), + End: Number.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();