From c0708528f0d22d61a20ae92a3c797e924162b1e2 Mon Sep 17 00:00:00 2001
From: rlauuzo <46294892+rlauuzo@users.noreply.github.com>
Date: Tue, 30 Jul 2024 21:30:41 +0200
Subject: [PATCH] Inject Timestamps into Episode Details (#224)
Co-authored-by: rlauu <46294892+rlauu@users.noreply.github.com>
---
.../Configuration/inject.js | 177 +++++++++++++++---
1 file changed, 150 insertions(+), 27 deletions(-)
diff --git a/ConfusedPolarBear.Plugin.IntroSkipper/Configuration/inject.js b/ConfusedPolarBear.Plugin.IntroSkipper/Configuration/inject.js
index 9cc5ae8..b7c2e08 100644
--- a/ConfusedPolarBear.Plugin.IntroSkipper/Configuration/inject.js
+++ b/ConfusedPolarBear.Plugin.IntroSkipper/Configuration/inject.js
@@ -9,23 +9,39 @@ const introSkipper = {
this.d("Registered hooks");
},
initializeState() {
- Object.assign(this, { allowEnter: true, skipSegments: {}, videoPlayer: null, skipButton: null, osdElement: null });
+ Object.assign(this, { allowEnter: true, skipSegments: {}, videoPlayer: null, skipButton: null, osdElement: null, skipperData: null, currentEpisodeId: null, injectMetadata: false });
},
- /** Wrapper around fetch() that retrieves skip segments for the currently playing item. */
+ /** Wrapper around fetch() that retrieves skip segments for the currently playing item or metadata. */
async fetchWrapper(resource, options) {
const response = await this.originalFetch(resource, options);
+ this.processResource(resource);
+ return response;
+ },
+ async processResource(resource) {
try {
const url = new URL(resource);
- if (!url.pathname.includes("/PlaybackInfo")) return response;
- this.d("Retrieving skip segments from URL", url.pathname);
- const pathArr = url.pathname.split("/");
- const id = pathArr[1] === "Items" ? pathArr[2] : pathArr[3];
- this.skipSegments = await this.secureFetch(`Episode/${id}/IntroSkipperSegments`);
- this.d("Successfully retrieved skip segments", this.skipSegments);
+ const pathname = url.pathname;
+ if (pathname.includes("/PlaybackInfo")) {
+ this.d(`Retrieving skip segments from URL ${pathname}`);
+ const pathArr = pathname.split("/");
+ const id = pathArr[pathArr.indexOf("Items") + 1] || pathArr[3];
+ this.skipSegments = await this.secureFetch(`Episode/${id}/IntroSkipperSegments`);
+ this.d("Retrieved skip segments", this.skipSegments);
+ } else if (this.injectMetadata && pathname.includes("/MetadataEditor")) {
+ this.d(`Metadata editor detected, URL ${pathname}`);
+ const pathArr = pathname.split("/");
+ this.currentEpisodeId = pathArr[pathArr.indexOf("Items") + 1] || pathArr[3];
+ this.skipperData = await this.secureFetch(`Episode/${this.currentEpisodeId}/Timestamps`);
+ if (this.skipperData) {
+ requestAnimationFrame(() => {
+ const metadataFormFields = document.querySelector('.metadataFormFields');
+ metadataFormFields && this.injectSkipperFields(metadataFormFields);
+ });
+ }
+ }
} catch (e) {
- console.error("Unable to get skip segments from", resource, e);
+ console.error("Error processing", resource, e);
}
- return response;
},
/**
* Event handler that runs whenever the current view changes.
@@ -34,18 +50,18 @@ const introSkipper = {
viewShow() {
const location = window.location.hash;
this.d(`Location changed to ${location}`);
- if (location !== "#/video") {
- if (this.videoPlayer) this.initializeState();
- return;
- }
- 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.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")
+ }
+ }
},
/**
* Injects the CSS used by the skip intro button.
@@ -219,12 +235,119 @@ const introSkipper = {
? this.videoPlayer.duration + 10
: segment.IntroEnd;
},
+ injectSkipperFields(metadataFormFields) {
+ const skipperFields = document.createElement('div');
+ skipperFields.className = 'detailSection introskipperSection';
+ skipperFields.innerHTML = `
+
Intro Skipper
+
+
+ `;
+ metadataFormFields.querySelector('#metadataSettingsCollapsible').insertAdjacentElement('afterend', skipperFields);
+ this.attachSaveListener(metadataFormFields);
+ this.updateSkipperFields(skipperFields);
+ this.setTimeInputs(skipperFields);
+ },
+ updateSkipperFields(skipperFields) {
+ const { Introduction = {}, Credits = {} } = this.skipperData;
+ skipperFields.querySelector('#introStartEdit').value = Introduction.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"]');
+ displayInput.addEventListener('click', () => 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: {
+ 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');
+ }
+ },
/** Make an authenticated fetch to the Jellyfin server and parse the response body as JSON. */
- async secureFetch(url) {
- url = new URL(url, ApiClient.serverAddress());
- const res = await fetch(url, { headers: { "Authorization": `MediaBrowser Token=${ApiClient.accessToken()}` } });
- if (!res.ok) throw new Error(`Expected status 200 from ${url}, but got ${res.status}`);
- return res.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) {