Skip Applying RemainingSecondsOfIntro for Segments at the End of the Video

This commit is contained in:
rlauu 2024-10-23 15:10:37 +02:00
parent 3445bbaee4
commit fe57d4defa
2 changed files with 519 additions and 388 deletions

View File

@ -1,94 +1,132 @@
const introSkipper = { const introSkipper = {
originalFetch: window.fetch.bind(window), originalFetch: window.fetch.bind(window),
d: msg => console.debug("[intro skipper] ", msg), d: (msg) => console.debug("[intro skipper] ", msg),
setup() { setup() {
this.initializeState(); const self = this;
this.initializeObserver(); this.initializeState();
this.currentOption = localStorage.getItem('introskipperOption') || 'Show Button'; this.initializeObserver();
document.addEventListener("viewshow", this.viewShow.bind(this)); this.currentOption =
window.fetch = this.fetchWrapper.bind(this); localStorage.getItem("introskipperOption") || "Show Button";
this.videoPositionChanged = this.videoPositionChanged.bind(this); window.fetch = this.fetchWrapper.bind(this);
this.handleEscapeKey = this.handleEscapeKey.bind(this); document.addEventListener("viewshow", this.viewShow.bind(this));
this.d("Registered hooks"); this.videoPositionChanged = this.videoPositionChanged.bind(this);
}, this.handleEscapeKey = this.handleEscapeKey.bind(this);
initializeState() { this.d("Registered hooks");
Object.assign(this, { allowEnter: true, skipSegments: {}, videoPlayer: null, skipButton: null, osdElement: null, skipperData: null, currentEpisodeId: null, injectMetadata: false }); },
}, initializeState() {
initializeObserver() { Object.assign(this, {
this.observer = new MutationObserver(mutations => { allowEnter: true,
const actionSheet = mutations[mutations.length - 1].target.querySelector('.actionSheet'); skipSegments: {},
if (actionSheet && !actionSheet.querySelector(`[data-id="${'introskipperMenu'}"]`)) this.injectIntroSkipperOptions(actionSheet); videoPlayer: null,
}); skipButton: null,
}, osdElement: null,
/** Wrapper around fetch() that retrieves skip segments for the currently playing item or metadata. */ skipperData: null,
async fetchWrapper(resource, options) { currentEpisodeId: null,
const response = await this.originalFetch(resource, options); injectMetadata: false,
this.processResource(resource); });
return response; },
}, initializeObserver() {
async processResource(resource) { this.observer = new MutationObserver((mutations) => {
try { const actionSheet =
const url = new URL(resource); mutations[mutations.length - 1].target.querySelector(".actionSheet");
const pathname = url.pathname; if (
if (pathname.includes("/PlaybackInfo")) { actionSheet &&
this.d(`Retrieving skip segments from URL ${pathname}`); !actionSheet.querySelector(`[data-id="${"introskipperMenu"}"]`)
const pathArr = pathname.split("/"); )
const id = pathArr[pathArr.indexOf("Items") + 1] || pathArr[3]; this.injectIntroSkipperOptions(actionSheet);
this.skipSegments = await this.secureFetch(`Episode/${id}/IntroSkipperSegments`); });
this.d("Retrieved skip segments", this.skipSegments); },
} else if (this.injectMetadata && pathname.includes("/MetadataEditor")) { fetchWrapper(resource, options) {
this.d(`Metadata editor detected, URL ${pathname}`); const response = this.originalFetch(resource, options);
const pathArr = pathname.split("/"); const url = new URL(resource);
this.currentEpisodeId = pathArr[pathArr.indexOf("Items") + 1] || pathArr[3]; if (url.pathname.includes("/PlaybackInfo")) {
this.skipperData = await this.secureFetch(`Episode/${this.currentEpisodeId}/Timestamps`); this.processPlaybackInfo(url.pathname);
if (this.skipperData) { }
requestAnimationFrame(() => { else if (this.injectMetadata && url.pathname.includes("/MetadataEditor")) {
const metadataFormFields = document.querySelector('.metadataFormFields'); this.processMetadata(url.pathname);
metadataFormFields && this.injectSkipperFields(metadataFormFields); }
}); return response;
} },
} async processPlaybackInfo(url) {
} catch (e) { const id = this.extractId(url);
console.error("Error processing", resource, e); if (id) {
} try {
}, this.skipSegments = await this.secureFetch(
/** `Episode/${id}/IntroSkipperSegments`,
* Event handler that runs whenever the current view changes. );
* Used to detect the start of video playback. } catch (error) {
*/ this.d(`Error fetching skip segments: ${error.message}`);
viewShow() { }
const location = window.location.hash; }
this.d(`Location changed to ${location}`); },
this.allowEnter = true; async processMetadata(url) {
this.injectMetadata = /#\/(tv|details|home|search)/.test(location); const id = this.extractId(url);
if (location === "#/video") { if (id) {
this.injectCss(); try {
this.injectButton(); this.skipperData = await this.secureFetch(`Episode/${id}/Timestamps`);
this.videoPlayer = document.querySelector("video"); if (this.skipperData) {
if (this.videoPlayer) { this.currentEpisodeId = id;
this.d("Hooking video timeupdate"); requestAnimationFrame(() => {
this.videoPlayer.addEventListener("timeupdate", this.videoPositionChanged); const metadataFormFields = document.querySelector(
this.osdElement = document.querySelector("div.videoOsdBottom") ".metadataFormFields",
this.observer.observe(document.body, { childList: true, subtree: false }); );
} metadataFormFields && this.injectSkipperFields(metadataFormFields);
} });
else { }
this.observer.disconnect(); } catch (e) {
} console.error("Error processing", e);
}, }
/** }
* Injects the CSS used by the skip intro button. },
* Calling this function is a no-op if the CSS has already been injected. extractId(searchString) {
*/ const startIndex = searchString.indexOf("Items/") + 6;
injectCss() { const endIndex = searchString.indexOf("/", startIndex);
if (document.querySelector("style#introSkipperCss")) { return endIndex !== -1
this.d("CSS already added"); ? searchString.substring(startIndex, endIndex)
return; : searchString.substring(startIndex);
} },
this.d("Adding CSS"); /**
const styleElement = document.createElement("style"); * Event handler that runs whenever the current view changes.
styleElement.id = "introSkipperCss"; * Used to detect the start of video playback.
styleElement.textContent = ` */
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 { :root {
--rounding: 4px; --rounding: 4px;
--accent: 0, 164, 220; --accent: 0, 164, 220;
@ -134,213 +172,256 @@ const introSkipper = {
padding: 0 5px 0 5px; padding: 0 5px 0 5px;
} }
`; `;
document.querySelector("head").appendChild(styleElement); document.querySelector("head").appendChild(styleElement);
}, },
/** /**
* Inject the skip intro button into the video player. * Inject the skip intro button into the video player.
* Calling this function is a no-op if the CSS has already been injected. * Calling this function is a no-op if the CSS has already been injected.
*/ */
async injectButton() { async injectButton() {
// Ensure the button we're about to inject into the page doesn't conflict with a pre-existing one // 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"); const preExistingButton = document.querySelector("div.skipIntro");
if (preExistingButton) { if (preExistingButton) {
preExistingButton.style.display = "none"; preExistingButton.style.display = "none";
} }
if (document.querySelector(".btnSkipIntro.injected")) { if (document.querySelector(".btnSkipIntro.injected")) {
this.d("Button already added"); this.d("Button already added");
this.skipButton = document.querySelector("#skipIntro"); this.skipButton = document.querySelector("#skipIntro");
return; return;
} }
const config = await this.secureFetch("Intros/UserInterfaceConfiguration"); const config = await this.secureFetch("Intros/UserInterfaceConfiguration");
if (!config.SkipButtonVisible) { if (!config.SkipButtonVisible) {
this.d("Not adding button: not visible"); this.d("Not adding button: not visible");
return; return;
} }
this.d("Adding button"); this.d("Adding button");
this.skipButton = document.createElement("div"); this.skipButton = document.createElement("div");
this.skipButton.id = "skipIntro"; this.skipButton.id = "skipIntro";
this.skipButton.classList.add("hide", "upNextContainer"); this.skipButton.classList.add("hide", "upNextContainer");
this.skipButton.addEventListener("click", this.doSkip.bind(this)); this.skipButton.addEventListener("click", this.doSkip.bind(this));
this.skipButton.addEventListener("keydown", this.eventHandler.bind(this)); this.skipButton.addEventListener("keydown", this.eventHandler.bind(this));
this.skipButton.innerHTML = ` this.skipButton.innerHTML = `
<button is="emby-button" type="button" class="btnSkipIntro injected"> <button is="emby-button" type="button" class="btnSkipIntro injected">
<span id="btnSkipSegmentText"></span> <span id="btnSkipSegmentText"></span>
<span class="material-icons skip_next"></span> <span class="material-icons skip_next"></span>
</button> </button>
`; `;
this.skipButton.dataset.Introduction = config.SkipButtonIntroText; this.skipButton.dataset.Introduction = config.SkipButtonIntroText;
this.skipButton.dataset.Credits = config.SkipButtonEndCreditsText; this.skipButton.dataset.Credits = config.SkipButtonEndCreditsText;
const controls = document.querySelector("div#videoOsdPage"); const controls = document.querySelector("div#videoOsdPage");
controls.appendChild(this.skipButton); controls.appendChild(this.skipButton);
}, },
/** Tests if the OSD controls are visible. */ /** Tests if the OSD controls are visible. */
osdVisible() { osdVisible() {
return this.osdElement ? !this.osdElement.classList.contains("hide") : false; return this.osdElement
}, ? !this.osdElement.classList.contains("hide")
/** Get the currently playing skippable segment. */ : false;
getCurrentSegment(position) { },
for (const [key, segment] of Object.entries(this.skipSegments)) { /** Get the currently playing skippable segment. */
if ((position > segment.ShowSkipPromptAt && position < segment.HideSkipPromptAt - 1) || getCurrentSegment(position) {
(this.osdVisible() && position > segment.IntroStart && position < segment.IntroEnd - 1)) { for (const key in this.skipSegments) {
segment.SegmentType = key; const segment = this.skipSegments[key];
return segment; if (
(position > segment.ShowSkipPromptAt &&
position < segment.HideSkipPromptAt) ||
(this.osdVisible() &&
position > segment.IntroStart &&
position < segment.IntroEnd - 3)
) {
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 || !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;
return { SegmentType: "None" }; }
}, requestAnimationFrame(() => {
overrideBlur(button) { this.skipButton.classList.remove("hide");
if (!button.originalBlur) { requestAnimationFrame(() => {
button.originalBlur = button.blur; this.skipButton.classList.add("show");
button.blur = function() { this.overrideBlur(button);
if (!this.contains(document.activeElement)) { button.focus();
this.originalBlur(); });
} });
}; },
} hideSkipButton() {
}, if (this.skipButton.classList.contains("show")) {
/** Playback position changed, check if the skip button needs to be displayed. */ this.skipButton.classList.remove("show");
videoPositionChanged() { const button = this.skipButton.querySelector(".emby-button");
if (!this.skipButton) return; button.addEventListener(
const embyButton = this.skipButton.querySelector(".emby-button"); "transitionend",
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"); this.skipButton.classList.add("hide");
if (this.osdVisible()) { if (this.osdVisible()) {
this.osdElement.querySelector('button.btnPause').focus(); this.osdElement.querySelector("button.btnPause").focus();
} else { } else {
embyButton.originalBlur(); button.originalBlur();
} }
}, { once: true }); },
} { 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(' ', '-')}`,
`<span class="actionsheetMenuItemIcon listItemIcon listItemIcon-transparent material-icons check" aria-hidden="true" style="visibility:${option === this.currentOption ? 'visible' : 'hidden'};"></span><div class="listItemBody actionsheetListItemBody"><div class="listItemBodyText actionSheetItemText">${option}</div></div>`,
() => 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) { /** Seeks to the end of the intro. */
this.currentOption = option; doSkip() {
localStorage.setItem('introskipperOption', option); if (!this.allowEnter) return;
this.d(`Introskipper option selected and saved: ${option}`); const segment = this.getCurrentSegment(this.videoPlayer.currentTime);
}, if (segment.SegmentType === "None") {
isAutoSkipLocked(config) { console.warn("[intro skipper] doSkip() called without an active segment");
const isAutoSkip = config.AutoSkip && config.AutoSkipCredits; return;
const isAutoSkipClient = new Set(config.ClientList.split(',')).has(ApiClient.appName()); }
return isAutoSkip || (config.SkipButtonVisible && isAutoSkipClient); this.d(`Skipping ${segment.SegmentType}`);
}, const seekedHandler = () => {
async injectIntroSkipperOptions(actionSheet) { this.videoPlayer.removeEventListener("seeked", seekedHandler);
if (!this.skipButton) return; setTimeout(() => {
const config = await this.secureFetch("Intros/UserInterfaceConfiguration"); this.allowEnter = true;
if (this.isAutoSkipLocked(config)) { }, 700);
this.d("Auto skip enforced by server"); };
return; this.videoPlayer.addEventListener("seeked", seekedHandler);
} this.videoPlayer.currentTime = segment.IntroEnd;
const statsButton = actionSheet.querySelector('[data-id="stats"]'); this.hideSkipButton();
if (!statsButton) return; },
const menuItem = this.createButton(statsButton, 'introskipperMenu', createButton(ref, id, innerHTML, clickHandler) {
`<div class="listItemBody actionsheetListItemBody"><div class="listItemBodyText actionSheetItemText">Intro Skipper</div></div><div class="listItemAside actionSheetItemAsideText">${this.currentOption}</div>`, const button = ref.cloneNode(true);
() => this.openSubmenu(statsButton, actionSheet.closest('.dialogContainer'))); button.setAttribute("data-id", id);
const originalWidth = actionSheet.offsetWidth; button.innerHTML = innerHTML;
statsButton.before(menuItem); button.addEventListener("click", clickHandler);
if (actionSheet.classList.contains('actionsheet-not-fullscreen')) this.adjustPosition(actionSheet, menuItem, originalWidth); return button;
}, },
adjustPosition(element, reference, originalWidth) { closeSubmenu(fullscreen) {
if (originalWidth) { document.querySelector(".dialogContainer").remove();
const currentTop = parseInt(element.style.top, 10) || 0; document.querySelector(".dialogBackdrop").remove();
element.style.top = `${currentTop - reference.offsetHeight}px`; document.dispatchEvent(new KeyboardEvent("keydown", { key: "Control" }));
const newWidth = Math.max(reference.offsetWidth - originalWidth, 0); if (!fullscreen) return;
const originalLeft = parseInt(element.style.left, 10) || 0; document.removeEventListener("keydown", this.handleEscapeKey);
element.style.left = `${originalLeft - newWidth / 2}px`; document.querySelector(".btnVideoOsdSettings").focus();
} else { },
const rect = reference.getBoundingClientRect(); openSubmenu(ref, menu) {
element.style.left = `${Math.min(rect.left - (element.offsetWidth - rect.width) / 2, window.innerWidth - element.offsetWidth - 10)}px`; const options = [
element.style.top = `${rect.top - element.offsetHeight + rect.height}px`; "Show Button",
} "Button w/ auto PiP",
}, "Automatically Skip",
injectSkipperFields(metadataFormFields) { "Off",
const skipperFields = document.createElement('div'); ];
skipperFields.className = 'detailSection introskipperSection'; const submenu = menu.cloneNode(true);
skipperFields.innerHTML = ` 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(" ", "-")}`,
`<span class="actionsheetMenuItemIcon listItemIcon listItemIcon-transparent material-icons check" aria-hidden="true" style="visibility:${option === this.currentOption ? "visible" : "hidden"};"></span><div class="listItemBody actionsheetListItemBody"><div class="listItemBodyText actionSheetItemText">${option}</div></div>`,
() => 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",
`<div class="listItemBody actionsheetListItemBody"><div class="listItemBodyText actionSheetItemText">Intro Skipper</div></div><div class="listItemAside actionSheetItemAsideText">${this.currentOption}</div>`,
() =>
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 = `
<h2>Intro Skipper</h2> <h2>Intro Skipper</h2>
<div class="inlineForm"> <div class="inlineForm">
<div class="inputContainer"> <div class="inputContainer">
@ -367,105 +448,144 @@ const introSkipper = {
</div> </div>
</div> </div>
`; `;
metadataFormFields.querySelector('#metadataSettingsCollapsible').insertAdjacentElement('afterend', skipperFields); metadataFormFields
this.attachSaveListener(metadataFormFields); .querySelector("#metadataSettingsCollapsible")
this.updateSkipperFields(skipperFields); .insertAdjacentElement("afterend", skipperFields);
this.setTimeInputs(skipperFields); this.attachSaveListener(metadataFormFields);
}, this.updateSkipperFields(skipperFields);
updateSkipperFields(skipperFields) { this.setTimeInputs(skipperFields);
const { Introduction = {}, Credits = {} } = this.skipperData; },
skipperFields.querySelector('#introStartEdit').value = Introduction.Start || 0; updateSkipperFields(skipperFields) {
skipperFields.querySelector('#introEndEdit').value = Introduction.End || 0; const { Introduction = {}, Credits = {} } = this.skipperData;
skipperFields.querySelector('#creditsStartEdit').value = Credits.Start || 0; skipperFields.querySelector("#introStartEdit").value =
skipperFields.querySelector('#creditsEndEdit').value = Credits.End || 0; Introduction.Start || 0;
}, skipperFields.querySelector("#introEndEdit").value = Introduction.End || 0;
attachSaveListener(metadataFormFields) { skipperFields.querySelector("#creditsStartEdit").value = Credits.Start || 0;
const saveButton = metadataFormFields.querySelector('.formDialogFooter .btnSave'); skipperFields.querySelector("#creditsEndEdit").value = Credits.End || 0;
if (saveButton) { },
saveButton.addEventListener('click', this.saveSkipperData.bind(this)); attachSaveListener(metadataFormFields) {
} else { const saveButton = metadataFormFields.querySelector(
console.error('Save button not found'); ".formDialogFooter .btnSave",
} );
}, if (saveButton) {
setTimeInputs(skipperFields) { saveButton.addEventListener("click", this.saveSkipperData.bind(this));
const inputContainers = skipperFields.querySelectorAll('.inputContainer'); } else {
inputContainers.forEach(container => { console.error("Save button not found");
const displayInput = container.querySelector('[id$="Display"]'); }
const editInput = container.querySelector('[id$="Edit"]'); },
displayInput.addEventListener('pointerdown', (e) => { setTimeInputs(skipperFields) {
e.preventDefault(); const inputContainers = skipperFields.querySelectorAll(".inputContainer");
this.switchToEdit(displayInput, editInput); for (const container of inputContainers) {
}); const displayInput = container.querySelector('[id$="Display"]');
editInput.addEventListener('blur', () => this.switchToDisplay(displayInput, editInput)); const editInput = container.querySelector('[id$="Edit"]');
displayInput.value = this.formatTime(parseFloat(editInput.value) || 0); displayInput.addEventListener("pointerdown", (e) => {
}); e.preventDefault();
}, this.switchToEdit(displayInput, editInput);
formatTime(totalSeconds) { });
const totalRoundedSeconds = Math.round(totalSeconds); editInput.addEventListener("blur", () =>
const hours = Math.floor(totalRoundedSeconds / 3600); this.switchToDisplay(displayInput, editInput),
const minutes = Math.floor((totalRoundedSeconds % 3600) / 60); );
const seconds = totalRoundedSeconds % 60; displayInput.value = this.formatTime(
let result = []; Number.parseFloat(editInput.value) || 0,
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(' '); formatTime(totalSeconds) {
}, if (!totalSeconds) return "0 seconds";
switchToEdit(displayInput, editInput) { const totalRoundedSeconds = Math.round(totalSeconds);
displayInput.style.display = 'none'; const hours = Math.floor(totalRoundedSeconds / 3600);
editInput.style.display = ''; const minutes = Math.floor((totalRoundedSeconds % 3600) / 60);
editInput.focus(); const seconds = totalRoundedSeconds % 60;
}, const result = [];
switchToDisplay(displayInput, editInput) { if (hours) result.push(`${hours} hour${hours !== 1 ? "s" : ""}`);
editInput.style.display = 'none'; if (minutes) result.push(`${minutes} minute${minutes !== 1 ? "s" : ""}`);
displayInput.style.display = ''; if (seconds || !result.length)
displayInput.value = this.formatTime(parseFloat(editInput.value) || 0); result.push(`${seconds} second${seconds !== 1 ? "s" : ""}`);
}, return result.join(" ");
async saveSkipperData() { },
const newTimestamps = { switchToEdit(displayInput, editInput) {
Introduction: { displayInput.style.display = "none";
Start: parseFloat(document.getElementById('introStartEdit').value || 0), editInput.style.display = "";
End: parseFloat(document.getElementById('introEndEdit').value || 0) editInput.focus();
}, },
Credits: { switchToDisplay(displayInput, editInput) {
Start: parseFloat(document.getElementById('creditsStartEdit').value || 0), editInput.style.display = "none";
End: parseFloat(document.getElementById('creditsEndEdit').value || 0) displayInput.style.display = "";
} displayInput.value = this.formatTime(
}; Number.parseFloat(editInput.value) || 0,
const { Introduction = {}, Credits = {} } = this.skipperData; );
if (newTimestamps.Introduction.Start !== (Introduction.Start || 0) || },
newTimestamps.Introduction.End !== (Introduction.End || 0) || async saveSkipperData() {
newTimestamps.Credits.Start !== (Credits.Start || 0) || const newTimestamps = {
newTimestamps.Credits.End !== (Credits.End || 0)) { Introduction: {
const response = await this.secureFetch(`Episode/${this.currentEpisodeId}/Timestamps`, "POST", JSON.stringify(newTimestamps)); Start: Number.parseFloat(
this.d(response.ok ? 'Timestamps updated successfully' : 'Failed to update timestamps:', response.status); document.getElementById("introStartEdit").value || 0,
} else { ),
this.d('Timestamps have not changed, skipping update'); End: Number.parseFloat(
} document.getElementById("introEndEdit").value || 0,
}, ),
/** Make an authenticated fetch to the Jellyfin server and parse the response body as JSON. */ },
async secureFetch(url, method = "GET", body = null) { Credits: {
const response = await fetch(`${ApiClient.serverAddress()}/${url}`, { Start: Number.parseFloat(
method, document.getElementById("creditsStartEdit").value || 0,
headers: Object.assign({ "Authorization": `MediaBrowser Token=${ApiClient.accessToken()}` }, ),
method === "POST" ? {"Content-Type": "application/json"} : {}), End: Number.parseFloat(
body }); document.getElementById("creditsEndEdit").value || 0,
return response.ok ? (method === "POST" ? response : response.json()) : ),
response.status === 404 ? null : },
console.error(`Error ${response.status} from ${url}`) || null; };
}, const { Introduction = {}, Credits = {} } = this.skipperData;
/** Handle keydown events. */ if (
eventHandler(e) { newTimestamps.Introduction.Start !== (Introduction.Start || 0) ||
if (e.key !== "Enter") return; newTimestamps.Introduction.End !== (Introduction.End || 0) ||
e.stopPropagation(); newTimestamps.Credits.Start !== (Credits.Start || 0) ||
e.preventDefault(); newTimestamps.Credits.End !== (Credits.End || 0)
this.doSkip(); ) {
}, const response = await this.secureFetch(
handleEscapeKey(e) { `Episode/${this.currentEpisodeId}/Timestamps`,
if (e.key === 'Escape' || e.keyCode === 461 || e.keyCode === 10009) { "POST",
e.stopPropagation(); JSON.stringify(newTimestamps),
this.closeSubmenu(true); );
} 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(); introSkipper.setup();

View File

@ -157,18 +157,21 @@ public class SkipIntroController : ControllerBase
var segment = new Intro(timestamp); var segment = new Intro(timestamp);
var config = Plugin.Instance!.Configuration; var config = Plugin.Instance!.Configuration;
segment.IntroEnd -= config.RemainingSecondsOfIntro; segment.IntroEnd = mode == AnalysisMode.Credits
? GetAdjustedIntroEnd(id, segment.IntroEnd, config)
: segment.IntroEnd - config.RemainingSecondsOfIntro;
if (config.PersistSkipButton) if (config.PersistSkipButton)
{ {
segment.ShowSkipPromptAt = segment.IntroStart; segment.ShowSkipPromptAt = segment.IntroStart;
segment.HideSkipPromptAt = segment.IntroEnd; segment.HideSkipPromptAt = segment.IntroEnd - 3;
} }
else else
{ {
segment.ShowSkipPromptAt = Math.Max(0, segment.IntroStart - config.ShowPromptAdjustment); segment.ShowSkipPromptAt = Math.Max(0, segment.IntroStart - config.ShowPromptAdjustment);
segment.HideSkipPromptAt = Math.Min( segment.HideSkipPromptAt = Math.Min(
segment.IntroStart + config.HidePromptAdjustment, segment.IntroStart + config.HidePromptAdjustment,
segment.IntroEnd); segment.IntroEnd - 3);
} }
return segment; return segment;
@ -179,6 +182,14 @@ public class SkipIntroController : ControllerBase
} }
} }
private static double GetAdjustedIntroEnd(Guid id, double segmentEnd, PluginConfiguration config)
{
var runTime = TimeSpan.FromTicks(Plugin.Instance!.GetItem(id)?.RunTimeTicks ?? 0).TotalSeconds;
return runTime > 0 && runTime < segmentEnd + 1
? runTime
: segmentEnd - config.RemainingSecondsOfIntro;
}
/// <summary> /// <summary>
/// Erases all previously discovered introduction timestamps. /// Erases all previously discovered introduction timestamps.
/// </summary> /// </summary>