323 lines
13 KiB
JavaScript
Raw Normal View History

2022-11-25 01:37:27 -06:00
let introSkipper = {
allowEnter: true,
2022-11-25 01:37:27 -06:00
skipSegments: {},
videoPlayer: {},
// .bind() is used here to prevent illegal invocation errors
originalFetch: window.fetch.bind(window),
};
introSkipper.d = function (msg) {
2024-03-05 10:08:29 -05:00
console.debug("[intro skipper] ", msg);
}
/** Setup event listeners */
introSkipper.setup = function () {
2022-11-25 01:37:27 -06:00
document.addEventListener("viewshow", introSkipper.viewShow);
window.fetch = introSkipper.fetchWrapper;
introSkipper.d("Registered hooks");
}
/** Wrapper around fetch() that retrieves skip segments for the currently playing item. */
introSkipper.fetchWrapper = async function (...args) {
2022-11-17 01:19:44 -06:00
// Based on JellyScrub's trickplay.js
let [resource, options] = args;
2022-11-25 01:37:27 -06:00
let response = await introSkipper.originalFetch(resource, options);
2022-11-17 01:19:44 -06:00
// Bail early if this isn't a playback info URL
2022-11-25 01:37:27 -06:00
try {
let path = new URL(resource).pathname;
if (!path.includes("/PlaybackInfo")) { return response; }
introSkipper.d("Retrieving skip segments from URL");
introSkipper.d(path);
// Check for context root and set id accordingly
let path_arr = path.split("/");
let id = "";
if (path_arr[1] == "Items") {
id = path_arr[2];
} else {
id = path_arr[3];
}
introSkipper.skipSegments = await introSkipper.secureFetch(`Episode/${id}/IntroSkipperSegments`);
introSkipper.d("Successfully retrieved skip segments");
introSkipper.d(introSkipper.skipSegments);
2022-11-17 01:19:44 -06:00
}
2022-11-25 01:37:27 -06:00
catch (e) {
console.error("Unable to get skip segments from", resource, e);
2022-11-17 01:19:44 -06:00
}
return response;
}
/**
* Event handler that runs whenever the current view changes.
* Used to detect the start of video playback.
*/
introSkipper.viewShow = function () {
const location = window.location.hash;
2022-11-25 01:37:27 -06:00
introSkipper.d("Location changed to " + location);
2024-05-13 00:02:18 +02:00
if (location !== "#/video") {
introSkipper.d("Ignoring location change");
return;
}
2022-11-25 01:37:27 -06:00
introSkipper.injectCss();
introSkipper.injectButton();
introSkipper.videoPlayer = document.querySelector("video");
2024-03-07 15:41:00 -05:00
if (introSkipper.videoPlayer != null) {
introSkipper.d("Hooking video timeupdate");
introSkipper.videoPlayer.addEventListener("timeupdate", introSkipper.videoPositionChanged);
document.body.addEventListener('keydown', introSkipper.eventHandler, true);
2024-03-07 15:41:00 -05:00
}
}
/**
* Injects the CSS used by the skip intro button.
* Calling this function is a no-op if the CSS has already been injected.
*/
introSkipper.injectCss = function () {
2022-11-25 01:37:27 -06:00
if (introSkipper.testElement("style#introSkipperCss")) {
introSkipper.d("CSS already added");
return;
}
2022-11-25 01:37:27 -06:00
introSkipper.d("Adding CSS");
let styleElement = document.createElement("style");
styleElement.id = "introSkipperCss";
styleElement.innerText = `
2024-05-18 20:05:04 +02:00
:root {
--rounding: .2em;
--accent: 0, 164, 220;
}
#skipIntro.upNextContainer {
width: unset;
margin: unset;
}
#skipIntro {
position: absolute;
2024-05-18 20:05:04 +02:00
bottom: 6em;
right: 4.5em;
background-color: transparent;
font-size: 1.2em;
}
#skipIntro .emby-button {
text-shadow: 0 0 3px rgba(0, 0, 0, 0.7);
border-radius: var(--rounding);
background-color: rgba(0, 0, 0, 0.3);
will-change: opacity, transform;
opacity: 0;
2024-05-18 20:05:04 +02:00
transition: opacity 0.3s ease-in, transform 0.3s ease-out;
}
#skipIntro.show .emby-button {
opacity: 1;
}
2024-05-18 20:05:04 +02:00
#skipIntro .emby-button:hover,
#skipIntro .emby-button:focus {
background-color: rgba(var(--accent),0.7);
transform: scale(1.05);
}
2024-05-18 20:05:04 +02:00
#btnSkipSegmentText {
padding-right: 0.15em;
padding-left: 0.2em;
margin-top: -0.1em;
}
`;
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.
*/
2022-11-25 01:37:27 -06:00
introSkipper.injectButton = async function () {
2023-03-13 21:00:21 -05:00
// Ensure the button we're about to inject into the page doesn't conflict with a pre-existing one
const preExistingButton = introSkipper.testElement("div.skipIntro");
if (preExistingButton) {
preExistingButton.style.display = "none";
}
if (introSkipper.testElement(".btnSkipIntro.injected")) {
2022-11-25 01:37:27 -06:00
introSkipper.d("Button already added");
return;
}
2022-11-25 01:37:27 -06:00
introSkipper.d("Adding button");
let config = await introSkipper.secureFetch("Intros/UserInterfaceConfiguration");
if (!config.SkipButtonVisible) {
2022-11-25 01:37:27 -06:00
introSkipper.d("Not adding button: not visible");
return;
}
// Construct the skip button div
const button = document.createElement("div");
button.id = "skipIntro"
button.classList.add("hide");
2022-11-25 01:37:27 -06:00
button.addEventListener("click", introSkipper.doSkip);
button.innerHTML = `
2024-05-18 20:05:04 +02:00
<button is="emby-button" type="button" class="btnSkipIntro injected">
<span id="btnSkipSegmentText"></span>
<span class="material-icons skip_next"></span>
</button>
`;
button.dataset["intro_text"] = config.SkipButtonIntroText;
button.dataset["credits_text"] = config.SkipButtonEndCreditsText;
/*
* Alternative workaround for #44. Jellyfin's video component registers a global click handler
* (located at src/controllers/playback/video/index.js:1492) that pauses video playback unless
* the clicked element has a parent with the class "videoOsdBottom" or "upNextContainer".
*/
button.classList.add("upNextContainer");
// Append the button to the video OSD
let controls = document.querySelector("div#videoOsdPage");
controls.appendChild(button);
}
2023-03-22 00:19:27 -05:00
/** Tests if the OSD controls are visible. */
2023-03-16 05:07:20 +01:00
introSkipper.osdVisible = function () {
2023-03-22 00:19:27 -05:00
const osd = document.querySelector("div.videoOsdBottom");
return osd ? !osd.classList.contains("hide") : false;
2023-03-16 05:07:20 +01:00
}
/** Get the currently playing skippable segment. */
introSkipper.getCurrentSegment = function (position) {
for (let key in introSkipper.skipSegments) {
const segment = introSkipper.skipSegments[key];
2023-03-16 05:07:20 +01:00
if ((position >= segment.ShowSkipPromptAt && position < segment.HideSkipPromptAt) || (introSkipper.osdVisible() && position >= segment.IntroStart && position < segment.IntroEnd)) {
segment["SegmentType"] = key;
return segment;
}
}
return { "SegmentType": "None" };
}
introSkipper.overrideBlur = function(embyButton) {
if (!embyButton.originalBlur) {
embyButton.originalBlur = embyButton.blur;
}
embyButton.blur = function () {
if (!introSkipper.osdVisible() || !embyButton.contains(document.activeElement)) {
embyButton.originalBlur.call(this);
}
};
};
introSkipper.restoreBlur = function(embyButton) {
if (embyButton.originalBlur) {
embyButton.blur = embyButton.originalBlur;
delete embyButton.originalBlur;
}
};
/** Playback position changed, check if the skip button needs to be displayed. */
2022-11-25 01:37:27 -06:00
introSkipper.videoPositionChanged = function () {
const skipButton = document.querySelector("#skipIntro");
if (introSkipper.videoPlayer.currentTime === 0 || !skipButton || !introSkipper.allowEnter) return;
2024-05-18 20:05:04 +02:00
const embyButton = skipButton.querySelector(".emby-button");
const tvLayout = document.documentElement.classList.contains("layout-tv");
const segment = introSkipper.getCurrentSegment(introSkipper.videoPlayer.currentTime);
2024-05-18 20:05:04 +02:00
switch (segment.SegmentType) {
case "None":
if (!skipButton.classList.contains('show')) return;
skipButton.classList.remove('show');
2024-05-18 20:05:04 +02:00
embyButton.addEventListener("transitionend", () => {
skipButton.classList.add("hide");
if (tvLayout) {
introSkipper.restoreBlur(embyButton);
embyButton.blur();
}
}, { once: true });
return;
case "Introduction":
2024-05-18 20:05:04 +02:00
skipButton.querySelector("#btnSkipSegmentText").textContent = skipButton.dataset.intro_text;
break;
case "Credits":
2024-05-18 20:05:04 +02:00
skipButton.querySelector("#btnSkipSegmentText").textContent = skipButton.dataset.credits_text;
break;
2022-11-25 01:37:27 -06:00
}
if (!skipButton.classList.contains("hide")) return;
requestAnimationFrame(() => {
skipButton.classList.remove("hide");
requestAnimationFrame(() => {
skipButton.classList.add('show');
if (tvLayout) {
introSkipper.overrideBlur(embyButton);
embyButton.focus({ focusVisible: true });
}
});
});
}
introSkipper.throttle = function (func, limit) {
let inThrottle;
return function(...args) {
const context = this;
if (!inThrottle) {
func.apply(context, args);
inThrottle = true;
setTimeout(() => inThrottle = false, limit);
}
};
}
/** Seeks to the end of the intro. */
2024-07-02 20:11:31 +02:00
introSkipper.doSkip = introSkipper.throttle(async function () {
2022-11-25 01:37:27 -06:00
introSkipper.d("Skipping intro");
introSkipper.d(introSkipper.skipSegments);
const segment = introSkipper.getCurrentSegment(introSkipper.videoPlayer.currentTime);
if (segment.SegmentType === "None") {
console.warn("[intro skipper] doSkip() called without an active segment");
return;
}
const currentSrc = introSkipper.videoPlayer.currentSrc;
const controller = new AbortController();
const signal = controller.signal;
2024-07-02 20:11:31 +02:00
const seekToSegmentEnd = () => new Promise((resolve) => {
const onSeeked = () => requestAnimationFrame(resolve);
introSkipper.videoPlayer.addEventListener('seeked', onSeeked, { once: true, signal });
2024-07-02 20:11:31 +02:00
introSkipper.videoPlayer.currentTime = segment.IntroEnd;
});
const nextEpisodeLoaded = () => new Promise(resolve => {
const onLoadStart = () => resolve(true);
const timeoutId = setTimeout(() => resolve(false), 500);
introSkipper.videoPlayer.addEventListener('loadstart', onLoadStart, { signal });
signal.addEventListener('abort', () => clearTimeout(timeoutId));
});
// Disable keydown events
introSkipper.allowEnter = false;
try {
// Check if the segment is "Credits" and skipping would leave less than 3 seconds of video
if (segment.SegmentType === "Credits" && introSkipper.videoPlayer.duration - segment.IntroEnd < 3) {
// Simulate 'N' key press to go to the next episode
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'N', shiftKey: true, bubbles: true }));
// Check if the next episode actually starts loading
const loaded = await nextEpisodeLoaded();
// If the next episode didn't load, just seek to the end of the current segment
if (!loaded) {
await new Promise(resolve => setTimeout(resolve, 100)); // Short delay
if (introSkipper.videoPlayer.currentSrc === currentSrc) {
await seekToSegmentEnd();
}
}
} else {
// Default behavior: seek to the end of the current segment
await seekToSegmentEnd();
}
} finally {
introSkipper.allowEnter = true; // Always re-enable keydown events
controller.abort(); // Cleanup any remaining listeners
2024-07-02 20:11:31 +02:00
}
}, 3000);
2022-11-17 01:19:44 -06:00
/** Tests if an element with the provided selector exists. */
2022-11-25 01:37:27 -06:00
introSkipper.testElement = function (selector) { return document.querySelector(selector); }
2022-11-17 01:19:44 -06:00
/** Make an authenticated fetch to the Jellyfin server and parse the response body as JSON. */
2022-11-25 01:37:27 -06:00
introSkipper.secureFetch = async function (url) {
2022-11-17 01:19:44 -06:00
url = ApiClient.serverAddress() + "/" + url;
2024-03-05 10:08:29 -05:00
const reqInit = { headers: { "Authorization": "MediaBrowser Token=" + ApiClient.accessToken() } };
2022-11-17 01:19:44 -06:00
const res = await fetch(url, reqInit);
2024-03-05 10:08:29 -05:00
if (res.status !== 200) { throw new Error(`Expected status 200 from ${url}, but got ${res.status}`); }
2022-11-17 01:19:44 -06:00
return await res.json();
}
/** Handle keydown events. */
introSkipper.eventHandler = function (e) {
const skipButton = document.querySelector("#skipIntro");
if (!skipButton || skipButton.classList.contains("hide")) return;
// Ignore all keydown events
if (!introSkipper.allowEnter) {
e.preventDefault();
return;
}
if (e.key !== "Enter") return;
const embyButton = skipButton.querySelector(".emby-button");
if (document.documentElement.classList.contains("layout-tv") && embyButton.contains(document.activeElement)) {
e.stopPropagation();
return;
}
if (document.documentElement.classList.contains("layout-desktop")) {
e.preventDefault();
e.stopPropagation();
introSkipper.doSkip();
}
}
2024-05-18 20:05:04 +02:00
introSkipper.setup();