2024-07-24 12:56:12 +02:00
|
|
|
const introSkipper = {
|
2022-11-25 01:37:27 -06:00
|
|
|
originalFetch: window.fetch.bind(window),
|
2024-07-24 12:56:12 +02:00
|
|
|
d: msg => console.debug("[intro skipper] ", msg),
|
|
|
|
setup() {
|
|
|
|
this.initializeState();
|
|
|
|
document.addEventListener("viewshow", this.viewShow.bind(this));
|
|
|
|
window.fetch = this.fetchWrapper.bind(this);
|
|
|
|
this.videoPositionChanged = this.videoPositionChanged.bind(this);
|
|
|
|
this.d("Registered hooks");
|
|
|
|
},
|
|
|
|
initializeState() {
|
2024-07-30 21:30:41 +02:00
|
|
|
Object.assign(this, { allowEnter: true, skipSegments: {}, videoPlayer: null, skipButton: null, osdElement: null, skipperData: null, currentEpisodeId: null, injectMetadata: false });
|
2024-07-24 12:56:12 +02:00
|
|
|
},
|
2024-07-30 21:30:41 +02:00
|
|
|
/** Wrapper around fetch() that retrieves skip segments for the currently playing item or metadata. */
|
2024-07-24 12:56:12 +02:00
|
|
|
async fetchWrapper(resource, options) {
|
|
|
|
const response = await this.originalFetch(resource, options);
|
2024-07-30 21:30:41 +02:00
|
|
|
this.processResource(resource);
|
|
|
|
return response;
|
|
|
|
},
|
|
|
|
async processResource(resource) {
|
2024-07-24 12:56:12 +02:00
|
|
|
try {
|
|
|
|
const url = new URL(resource);
|
2024-07-30 21:30:41 +02:00
|
|
|
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);
|
|
|
|
});
|
|
|
|
}
|
|
|
|
}
|
2024-07-24 12:56:12 +02:00
|
|
|
} catch (e) {
|
2024-07-30 21:30:41 +02:00
|
|
|
console.error("Error processing", resource, e);
|
2023-03-04 00:15:26 -06:00
|
|
|
}
|
2024-07-24 12:56:12 +02:00
|
|
|
},
|
|
|
|
/**
|
|
|
|
* 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}`);
|
2024-07-30 21:30:41 +02:00
|
|
|
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")
|
|
|
|
}
|
|
|
|
}
|
2024-07-24 12:56:12 +02:00
|
|
|
},
|
|
|
|
/**
|
|
|
|
* 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%;
|
2024-07-25 17:09:05 +02:00
|
|
|
background: rgba(0, 0, 0, 0.7);
|
2024-07-24 12:56:12 +02:00
|
|
|
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);
|
|
|
|
}
|
2024-07-25 17:09:05 +02:00
|
|
|
#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:not(:focus-visible) {
|
|
|
|
background: rgb(var(--accent));
|
2024-07-24 12:56:12 +02:00
|
|
|
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 = `
|
|
|
|
<button is="emby-button" type="button" class="btnSkipIntro injected">
|
|
|
|
<span id="btnSkipSegmentText"></span>
|
2024-07-30 22:15:58 +05:30
|
|
|
<span class="material-icons skip_next"></span>
|
2024-07-24 12:56:12 +02:00
|
|
|
</button>
|
|
|
|
`;
|
|
|
|
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 = () => {
|
|
|
|
if (!button.contains(document.activeElement)) {
|
|
|
|
button.originalBlur();
|
2024-06-01 14:15:30 +02:00
|
|
|
}
|
2024-07-24 12:56:12 +02:00
|
|
|
};
|
|
|
|
}
|
|
|
|
},
|
|
|
|
restoreBlur(button) {
|
|
|
|
if (button.originalBlur) {
|
|
|
|
button.blur = button.originalBlur;
|
|
|
|
delete button.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");
|
2024-08-02 10:36:56 +02:00
|
|
|
const segmentType = this.getCurrentSegment(this.videoPlayer.currentTime).SegmentType;
|
2024-07-24 12:56:12 +02:00
|
|
|
if (segmentType === "None") {
|
|
|
|
if (!this.skipButton.classList.contains('show')) return;
|
|
|
|
this.skipButton.classList.remove('show');
|
|
|
|
embyButton.addEventListener("transitionend", () => {
|
|
|
|
this.skipButton.classList.add("hide");
|
|
|
|
this.restoreBlur(embyButton);
|
|
|
|
embyButton.blur();
|
|
|
|
this.allowEnter = true;
|
2024-04-17 21:21:25 +02:00
|
|
|
}, { once: true });
|
2023-03-04 00:15:26 -06:00
|
|
|
return;
|
2024-07-24 12:56:12 +02:00
|
|
|
}
|
|
|
|
this.skipButton.querySelector("#btnSkipSegmentText").textContent = this.skipButton.dataset[segmentType];
|
|
|
|
if (!this.skipButton.classList.contains("hide")) {
|
2024-07-25 17:09:05 +02:00
|
|
|
if (!this.osdVisible() && !embyButton.contains(document.activeElement)) embyButton.focus();
|
2024-07-24 12:56:12 +02:00
|
|
|
return;
|
|
|
|
}
|
2024-07-07 22:42:45 +02:00
|
|
|
requestAnimationFrame(() => {
|
2024-07-24 12:56:12 +02:00
|
|
|
this.skipButton.classList.remove("hide");
|
|
|
|
requestAnimationFrame(() => {
|
|
|
|
this.skipButton.classList.add('show');
|
|
|
|
this.overrideBlur(embyButton);
|
2024-07-25 17:09:05 +02:00
|
|
|
embyButton.focus();
|
2024-07-24 12:56:12 +02:00
|
|
|
});
|
2024-07-07 22:42:45 +02:00
|
|
|
});
|
2024-07-24 12:56:12 +02:00
|
|
|
},
|
|
|
|
/** 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;
|
2024-07-07 22:42:45 +02:00
|
|
|
}
|
2024-07-24 12:56:12 +02:00
|
|
|
this.d(`Skipping ${segment.SegmentType}`);
|
|
|
|
this.allowEnter = false;
|
|
|
|
this.videoPlayer.currentTime = segment.SegmentType === "Credits" && this.videoPlayer.duration - segment.IntroEnd < 3
|
|
|
|
? this.videoPlayer.duration + 10
|
|
|
|
: segment.IntroEnd;
|
|
|
|
},
|
2024-07-30 21:30:41 +02:00
|
|
|
injectSkipperFields(metadataFormFields) {
|
|
|
|
const skipperFields = document.createElement('div');
|
|
|
|
skipperFields.className = 'detailSection introskipperSection';
|
|
|
|
skipperFields.innerHTML = `
|
|
|
|
<h2>Intro Skipper</h2>
|
|
|
|
<div class="inlineForm">
|
|
|
|
<div class="inputContainer">
|
|
|
|
<label class="inputLabel inputLabelUnfocused" for="introStart">Intro Start</label>
|
|
|
|
<input type="text" id="introStartDisplay" class="emby-input custom-time-input" readonly>
|
|
|
|
<input type="number" id="introStartEdit" class="emby-input custom-time-input" style="display: none;" step="any" min="0">
|
|
|
|
</div>
|
|
|
|
<div class="inputContainer">
|
|
|
|
<label class="inputLabel inputLabelUnfocused" for="introEnd">Intro End</label>
|
|
|
|
<input type="text" id="introEndDisplay" class="emby-input custom-time-input" readonly>
|
|
|
|
<input type="number" id="introEndEdit" class="emby-input custom-time-input" style="display: none;" step="any" min="0">
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
<div class="inlineForm">
|
|
|
|
<div class="inputContainer">
|
|
|
|
<label class="inputLabel inputLabelUnfocused" for="creditsStart">Credits Start</label>
|
|
|
|
<input type="text" id="creditsStartDisplay" class="emby-input custom-time-input" readonly>
|
|
|
|
<input type="number" id="creditsStartEdit" class="emby-input custom-time-input" style="display: none;" step="any" min="0">
|
|
|
|
</div>
|
|
|
|
<div class="inputContainer">
|
|
|
|
<label class="inputLabel inputLabelUnfocused" for="creditsEnd">Credits End</label>
|
|
|
|
<input type="text" id="creditsEndDisplay" class="emby-input custom-time-input" readonly>
|
|
|
|
<input type="number" id="creditsEndEdit" class="emby-input custom-time-input" style="display: none;" step="any" min="0">
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
`;
|
|
|
|
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.IntroStart || 0;
|
|
|
|
skipperFields.querySelector('#introEndEdit').value = Introduction.IntroEnd || 0;
|
|
|
|
skipperFields.querySelector('#creditsStartEdit').value = Credits.IntroStart || 0;
|
|
|
|
skipperFields.querySelector('#creditsEndEdit').value = Credits.IntroEnd || 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"]');
|
2024-08-06 18:36:33 +02:00
|
|
|
displayInput.addEventListener('pointerdown', () => this.switchToEdit(displayInput, editInput));
|
2024-07-30 21:30:41 +02:00
|
|
|
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: {
|
|
|
|
IntroStart: parseFloat(document.getElementById('introStartEdit').value || 0),
|
|
|
|
IntroEnd: parseFloat(document.getElementById('introEndEdit').value || 0)
|
|
|
|
},
|
|
|
|
Credits: {
|
|
|
|
IntroStart: parseFloat(document.getElementById('creditsStartEdit').value || 0),
|
|
|
|
IntroEnd: parseFloat(document.getElementById('creditsEndEdit').value || 0)
|
|
|
|
}
|
|
|
|
};
|
|
|
|
const { Introduction = {}, Credits = {} } = this.skipperData;
|
|
|
|
if (newTimestamps.Introduction.IntroStart !== (Introduction.IntroStart || 0) ||
|
|
|
|
newTimestamps.Introduction.IntroEnd !== (Introduction.IntroEnd || 0) ||
|
|
|
|
newTimestamps.Credits.IntroStart !== (Credits.IntroStart || 0) ||
|
|
|
|
newTimestamps.Credits.IntroEnd !== (Credits.IntroEnd || 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');
|
|
|
|
}
|
|
|
|
},
|
2024-07-24 12:56:12 +02:00
|
|
|
/** Make an authenticated fetch to the Jellyfin server and parse the response body as JSON. */
|
2024-07-30 21:30:41 +02:00
|
|
|
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;
|
2024-07-24 12:56:12 +02:00
|
|
|
},
|
|
|
|
/** Handle keydown events. */
|
|
|
|
eventHandler(e) {
|
|
|
|
if (e.key !== "Enter") return;
|
2024-06-01 14:15:30 +02:00
|
|
|
e.stopPropagation();
|
|
|
|
e.preventDefault();
|
2024-07-24 12:56:12 +02:00
|
|
|
this.doSkip();
|
2024-06-01 14:15:30 +02:00
|
|
|
}
|
2024-07-24 12:56:12 +02:00
|
|
|
};
|
2024-05-18 20:05:04 +02:00
|
|
|
introSkipper.setup();
|