|
@@ -0,0 +1,535 @@
|
|
|
+class jellyscrubPlugin {
|
|
|
+ constructor({ playbackManager, events }) {
|
|
|
+ this.name = 'Jellyscrub Plugin';
|
|
|
+ this.type = 'input';
|
|
|
+ this.id = 'jellyscrubPlugin';
|
|
|
+
|
|
|
+ (async() => {
|
|
|
+ const api = await window.apiPromise;
|
|
|
+ const enabled = await new Promise(resolve => {
|
|
|
+ api.settings.value('plugins', 'skipintro', resolve);
|
|
|
+ });
|
|
|
+ console.log("JellyScrub Plugin enabled: " + enabled);
|
|
|
+ if (!enabled) return;
|
|
|
+
|
|
|
+ // Copied from https://github.com/nicknsy/jellyscrub/blob/main/Nick.Plugin.Jellyscrub/Api/trickplay.js
|
|
|
+ // Adapted for use in JMP
|
|
|
+ const MANIFEST_ENDPOINT = '/Trickplay/{itemId}/GetManifest';
|
|
|
+ const BIF_ENDPOINT = '/Trickplay/{itemId}/{width}/GetBIF';
|
|
|
+ const RETRY_INTERVAL = 60_000; // ms (1 minute)
|
|
|
+
|
|
|
+ let mediaSourceId = null;
|
|
|
+ let mediaRuntimeTicks = null; // NOT ms -- Microsoft DateTime.Ticks. Must be divided by 10,000.
|
|
|
+
|
|
|
+ let hasFailed = false;
|
|
|
+ let trickplayManifest = null;
|
|
|
+ let trickplayData = null;
|
|
|
+ let currentTrickplayFrame = null;
|
|
|
+
|
|
|
+ let hiddenSliderBubble = null;
|
|
|
+ let customSliderBubble = null;
|
|
|
+ let customThumbImg = null;
|
|
|
+ let customChapterText = null;
|
|
|
+
|
|
|
+ let osdPositionSlider = null;
|
|
|
+ let osdOriginalBubbleHtml = null;
|
|
|
+ let osdGetBubbleHtml = null;
|
|
|
+ let osdGetBubbleHtmlLock = false;
|
|
|
+
|
|
|
+ /*
|
|
|
+ * Utility methods
|
|
|
+ */
|
|
|
+
|
|
|
+ const LOG_PREFIX = '[jellyscrub] ';
|
|
|
+
|
|
|
+ function debug(msg) {
|
|
|
+ console.debug(LOG_PREFIX + msg);
|
|
|
+ }
|
|
|
+
|
|
|
+ function error(msg) {
|
|
|
+ console.error(LOG_PREFIX + msg);
|
|
|
+ }
|
|
|
+
|
|
|
+ function info(msg) {
|
|
|
+ console.info(LOG_PREFIX + msg);
|
|
|
+ }
|
|
|
+
|
|
|
+ /*
|
|
|
+ * Get config values
|
|
|
+ */
|
|
|
+
|
|
|
+ // -- ApiClient hasn't loaded by this point... :(
|
|
|
+ // -- Also needs to go in async function
|
|
|
+ //const jellyscrubConfig = await ApiClient.getPluginConfiguration(JELLYSCRUB_GUID);
|
|
|
+ //let STYLE_TRICKPLAY_CONTAINER = jellyscrubConfig.StyleTrickplayContainer ?? true;
|
|
|
+ let STYLE_TRICKPLAY_CONTAINER = true;
|
|
|
+
|
|
|
+ /*
|
|
|
+ * Inject style to be used for slider bubble popup
|
|
|
+ */
|
|
|
+
|
|
|
+ if (STYLE_TRICKPLAY_CONTAINER) {
|
|
|
+ let jellyscrubStyle = document.createElement('style');
|
|
|
+ jellyscrubStyle.id = 'jellscrubStyle';
|
|
|
+ jellyscrubStyle.textContent += '.chapterThumbContainer {width: 15vw; overflow: hidden;}';
|
|
|
+ jellyscrubStyle.textContent += '.chapterThumb {width: 100%; display: block; height: unset; min-height: unset; min-width: unset;}';
|
|
|
+ jellyscrubStyle.textContent += '.chapterThumbTextContainer {position: relative; background: rgb(38, 38, 38); text-align: center;}';
|
|
|
+ jellyscrubStyle.textContent += '.chapterThumbText {margin: 0; opacity: unset; padding: unset;}';
|
|
|
+ document.body.appendChild(jellyscrubStyle);
|
|
|
+ }
|
|
|
+
|
|
|
+ /*
|
|
|
+ * Monitor current page to be used for trickplay load/unload
|
|
|
+ */
|
|
|
+
|
|
|
+ let videoPath = 'playback/video/index.html';
|
|
|
+ let previousRoutePath = null;
|
|
|
+
|
|
|
+ document.addEventListener('viewshow', function () {
|
|
|
+ let currentRoutePath = Emby.Page.currentRouteInfo.route.path;
|
|
|
+
|
|
|
+ if (currentRoutePath == videoPath) {
|
|
|
+ loadVideoView();
|
|
|
+ } else if (previousRoutePath == videoPath) {
|
|
|
+ unloadVideoView();
|
|
|
+ }
|
|
|
+
|
|
|
+ previousRoutePath = currentRoutePath;
|
|
|
+ });
|
|
|
+
|
|
|
+ let sliderConfig = { attributeFilter: ['style', 'class'] };
|
|
|
+ let sliderObserver = new MutationObserver(sliderCallback);
|
|
|
+
|
|
|
+ function sliderCallback(mutationList, observer) {
|
|
|
+ if (!customSliderBubble || !trickplayData) return;
|
|
|
+
|
|
|
+ for (const mutation of mutationList) {
|
|
|
+ switch (mutation.attributeName) {
|
|
|
+ case 'style':
|
|
|
+ customSliderBubble.setAttribute('style', mutation.target.getAttribute('style'));
|
|
|
+ break;
|
|
|
+ case 'class':
|
|
|
+ if (mutation.target.classList.contains('hide')) {
|
|
|
+ customSliderBubble.classList.add('hide');
|
|
|
+ } else {
|
|
|
+ customSliderBubble.classList.remove('hide');
|
|
|
+ }
|
|
|
+ break;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ function loadVideoView() {
|
|
|
+ debug('!!!!!!! Loading video view !!!!!!!');
|
|
|
+
|
|
|
+ let slider = document.getElementsByClassName('osdPositionSlider')[0];
|
|
|
+ if (slider) {
|
|
|
+ osdPositionSlider = slider;
|
|
|
+ debug(`Found OSD slider: ${osdPositionSlider}`);
|
|
|
+
|
|
|
+ osdOriginalBubbleHtml = osdPositionSlider.getBubbleHtml;
|
|
|
+ osdGetBubbleHtml = osdOriginalBubbleHtml;
|
|
|
+
|
|
|
+ Object.defineProperty(osdPositionSlider, 'getBubbleHtml', {
|
|
|
+ get() { return osdGetBubbleHtml },
|
|
|
+ set(value) { if (!osdGetBubbleHtmlLock) osdGetBubbleHtml = value; },
|
|
|
+ configurable: true,
|
|
|
+ enumerable: true
|
|
|
+ });
|
|
|
+
|
|
|
+ let bubble = document.getElementsByClassName('sliderBubble')[0];
|
|
|
+ if (bubble) {
|
|
|
+ hiddenSliderBubble = bubble;
|
|
|
+
|
|
|
+ let customBubble = document.createElement('div');
|
|
|
+ customBubble.classList.add('sliderBubble', 'hide');
|
|
|
+
|
|
|
+ let customThumbContainer = document.createElement('div');
|
|
|
+ customThumbContainer.classList.add('chapterThumbContainer');
|
|
|
+
|
|
|
+ customThumbImg = document.createElement('img');
|
|
|
+ customThumbImg.classList.add('chapterThumb');
|
|
|
+ customThumbImg.src = 'data:,';
|
|
|
+ // Fix for custom styles that set radius on EVERYTHING causing weird holes when both img and text container are rounded
|
|
|
+ if (STYLE_TRICKPLAY_CONTAINER) customThumbImg.setAttribute('style', 'border-radius: unset !important;')
|
|
|
+ customThumbContainer.appendChild(customThumbImg);
|
|
|
+
|
|
|
+ let customChapterTextContainer = document.createElement('div');
|
|
|
+ customChapterTextContainer.classList.add('chapterThumbTextContainer');
|
|
|
+ // Fix for custom styles that set radius on EVERYTHING causing weird holes when both img and text container are rounded
|
|
|
+ if (STYLE_TRICKPLAY_CONTAINER) customChapterTextContainer.setAttribute('style', 'border-radius: unset !important;')
|
|
|
+
|
|
|
+ customChapterText = document.createElement('h2');
|
|
|
+ customChapterText.classList.add('chapterThumbText');
|
|
|
+ customChapterText.textContent = '--:--';
|
|
|
+ customChapterTextContainer.appendChild(customChapterText);
|
|
|
+
|
|
|
+ customThumbContainer.appendChild(customChapterTextContainer);
|
|
|
+ customBubble.appendChild(customThumbContainer);
|
|
|
+ customSliderBubble = hiddenSliderBubble.parentElement.appendChild(customBubble);
|
|
|
+
|
|
|
+ sliderObserver.observe(hiddenSliderBubble, sliderConfig);
|
|
|
+ }
|
|
|
+
|
|
|
+ // Main execution will first by triggered by the load video view method, but later (e.g. in the case of TV series)
|
|
|
+ // will be triggered by the playback request interception
|
|
|
+ if (!hasFailed && !trickplayData && mediaSourceId && mediaRuntimeTicks
|
|
|
+ && osdPositionSlider && hiddenSliderBubble && customSliderBubble) mainScriptExecution();
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ function unloadVideoView() {
|
|
|
+ debug('!!!!!!! Unloading video view !!!!!!!');
|
|
|
+
|
|
|
+ // Clear old values
|
|
|
+ clearTimeout(mainScriptExecution);
|
|
|
+
|
|
|
+ mediaSourceId = null;
|
|
|
+ mediaRuntimeTicks = null;
|
|
|
+
|
|
|
+ hasFailed = false;
|
|
|
+ trickplayManifest = null;
|
|
|
+ trickplayData = null;
|
|
|
+ currentTrickplayFrame = null;
|
|
|
+
|
|
|
+ hiddenSliderBubble = null;
|
|
|
+ customSliderBubble = null;
|
|
|
+ customThumbImg = null;
|
|
|
+ customChapterText = null;
|
|
|
+
|
|
|
+ osdPositionSlider = null;
|
|
|
+ osdOriginalBubbleHtml = null;
|
|
|
+ osdGetBubbleHtml = null;
|
|
|
+ osdGetBubbleHtmlLock = false;
|
|
|
+ // Clear old values
|
|
|
+ }
|
|
|
+
|
|
|
+ /*
|
|
|
+ * Update mediaSourceId, runtime, and emby auth data
|
|
|
+ */
|
|
|
+
|
|
|
+ function onPlayback(e, player, state) {
|
|
|
+ if (state.NowPlayingItem) {
|
|
|
+ mediaRuntimeTicks = state.NowPlayingItem.RunTimeTicks;
|
|
|
+ mediaSourceId = state.NowPlayingItem.Id;
|
|
|
+
|
|
|
+ changeCurrentMedia();
|
|
|
+ }
|
|
|
+ };
|
|
|
+ events.on(playbackManager, 'playbackstart', onPlayback);
|
|
|
+
|
|
|
+ function changeCurrentMedia() {
|
|
|
+ // Reset trickplay-related variables
|
|
|
+ hasFailed = false;
|
|
|
+ trickplayManifest = null;
|
|
|
+ trickplayData = null;
|
|
|
+ currentTrickplayFrame = null;
|
|
|
+
|
|
|
+ // Set bubble html back to default
|
|
|
+ if (osdOriginalBubbleHtml) osdGetBubbleHtml = osdOriginalBubbleHtml;
|
|
|
+ osdGetBubbleHtmlLock = false;
|
|
|
+
|
|
|
+ // Main execution will first by triggered by the load video view method, but later (e.g. in the case of TV series)
|
|
|
+ // will be triggered by the playback request interception
|
|
|
+ if (!hasFailed && !trickplayData && mediaSourceId && mediaRuntimeTicks
|
|
|
+ && osdPositionSlider && hiddenSliderBubble && customSliderBubble) mainScriptExecution();
|
|
|
+ }
|
|
|
+
|
|
|
+ /*
|
|
|
+ * Indexed UInt8Array
|
|
|
+ */
|
|
|
+
|
|
|
+ function Indexed8Array(buffer) {
|
|
|
+ this.index = 0;
|
|
|
+ this.array = new Uint8Array(buffer);
|
|
|
+ }
|
|
|
+
|
|
|
+ Indexed8Array.prototype.read = function(len) {
|
|
|
+ if (len) {
|
|
|
+ const readData = [];
|
|
|
+ for (let i = 0; i < len; i++) {
|
|
|
+ readData.push(this.array[this.index++]);
|
|
|
+ }
|
|
|
+
|
|
|
+ return readData;
|
|
|
+ } else {
|
|
|
+ return this.array[this.index++];
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ Indexed8Array.prototype.readArbitraryInt = function(len) {
|
|
|
+ let num = 0;
|
|
|
+ for (let i = 0; i < len; i++) {
|
|
|
+ num += this.read() << (i << 3);
|
|
|
+ }
|
|
|
+
|
|
|
+ return num;
|
|
|
+ }
|
|
|
+
|
|
|
+ Indexed8Array.prototype.readInt32 = function() {
|
|
|
+ return this.readArbitraryInt(4);
|
|
|
+ }
|
|
|
+
|
|
|
+ /*
|
|
|
+ * Code for BIF/Trickplay frames
|
|
|
+ */
|
|
|
+
|
|
|
+ const BIF_MAGIC_NUMBERS = [0x89, 0x42, 0x49, 0x46, 0x0D, 0x0A, 0x1A, 0x0A];
|
|
|
+ const SUPPORTED_BIF_VERSION = 0;
|
|
|
+
|
|
|
+ function trickplayDecode(buffer) {
|
|
|
+ info(`BIF file size: ${(buffer.byteLength / 1_048_576).toFixed(2)}MB`);
|
|
|
+
|
|
|
+
|
|
|
+ let bifArray = new Indexed8Array(buffer);
|
|
|
+ for (let i = 0; i < BIF_MAGIC_NUMBERS.length; i++) {
|
|
|
+ if (bifArray.read() != BIF_MAGIC_NUMBERS[i]) {
|
|
|
+ error('Attempted to read invalid bif file.');
|
|
|
+ error(buffer);
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ let bifVersion = bifArray.readInt32();
|
|
|
+ if (bifVersion != SUPPORTED_BIF_VERSION) {
|
|
|
+ error(`Client only supports BIF v${SUPPORTED_BIF_VERSION} but file is v${bifVersion}`);
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+
|
|
|
+ let bifImgCount = bifArray.readInt32();
|
|
|
+ info(`BIF image count: ${bifImgCount}`);
|
|
|
+
|
|
|
+ let timestampMultiplier = bifArray.readInt32();
|
|
|
+ if (timestampMultiplier == 0) timestampMultiplier = 1000;
|
|
|
+
|
|
|
+ bifArray.read(44); // Reserved
|
|
|
+
|
|
|
+ let bifIndex = [];
|
|
|
+ for (let i = 0; i < bifImgCount; i++) {
|
|
|
+ bifIndex.push({
|
|
|
+ timestamp: bifArray.readInt32(),
|
|
|
+ offset: bifArray.readInt32()
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ let bifImages = [];
|
|
|
+ let indexEntry;
|
|
|
+ for (let i = 0; i < bifIndex.length; i++) {
|
|
|
+ indexEntry = bifIndex[i];
|
|
|
+ const timestamp = indexEntry.timestamp;
|
|
|
+ const offset = indexEntry.offset;
|
|
|
+ const nextOffset = bifIndex[i + 1] ? bifIndex[i + 1].offset : buffer.length;
|
|
|
+
|
|
|
+ bifImages[timestamp] = buffer.slice(offset, nextOffset);
|
|
|
+ }
|
|
|
+
|
|
|
+ return {
|
|
|
+ version: bifVersion,
|
|
|
+ timestampMultiplier: timestampMultiplier,
|
|
|
+ imageCount: bifImgCount,
|
|
|
+ images: bifImages
|
|
|
+ };
|
|
|
+ }
|
|
|
+
|
|
|
+ function getTrickplayFrame(playerTimestamp, data) {
|
|
|
+ const multiplier = data.timestampMultiplier;
|
|
|
+ const images = data.images;
|
|
|
+
|
|
|
+ const frame = Math.floor(playerTimestamp / multiplier);
|
|
|
+ return images[frame];
|
|
|
+ }
|
|
|
+
|
|
|
+ function getTrickplayFrameUrl(playerTimestamp, data) {
|
|
|
+ let bufferImage = getTrickplayFrame(playerTimestamp, data);
|
|
|
+
|
|
|
+ if (bufferImage) {
|
|
|
+ return URL.createObjectURL(new Blob([bufferImage], {type: 'image/jpeg'}));
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /*
|
|
|
+ * Main script execution -- not actually run first
|
|
|
+ */
|
|
|
+
|
|
|
+ function manifestLoad() {
|
|
|
+ if (this.status == 200) {
|
|
|
+ if (!this.response) {
|
|
|
+ error(`Received 200 status from manifest endpoint but a null response. (RESPONSE URL: ${this.responseURL})`);
|
|
|
+ hasFailed = true;
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ trickplayManifest = this.response;
|
|
|
+ setTimeout(mainScriptExecution, 0); // Hacky way of avoiding using fetch/await by returning then calling function again
|
|
|
+ } else if (this.status == 503) {
|
|
|
+ info(`Received 503 from server -- still generating manifest. Waiting ${RETRY_INTERVAL}ms then retrying...`);
|
|
|
+ setTimeout(mainScriptExecution, RETRY_INTERVAL);
|
|
|
+ } else {
|
|
|
+ debug(`Failed to get manifest file: url ${this.responseURL}, error ${this.status}, ${this.responseText}`)
|
|
|
+ hasFailed = true;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ function bifLoad() {
|
|
|
+ if (this.status == 200) {
|
|
|
+ if (!this.response) {
|
|
|
+ error(`Received 200 status from BIF endpoint but a null response. (RESPONSE URL: ${this.responseURL})`);
|
|
|
+ hasFailed = true;
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ trickplayData = trickplayDecode(this.response);
|
|
|
+ setTimeout(mainScriptExecution, 0); // Hacky way of avoiding using fetch/await by returning then calling function again
|
|
|
+ } else if (this.status == 503) {
|
|
|
+ info(`Received 503 from server -- still generating BIF. Waiting ${RETRY_INTERVAL}ms then retrying...`);
|
|
|
+ setTimeout(mainScriptExecution, RETRY_INTERVAL);
|
|
|
+ } else {
|
|
|
+ if (this.status == 404) error('Requested BIF file listed in manifest but server returned 404 not found.');
|
|
|
+
|
|
|
+ debug(`Failed to get BIF file: url ${this.responseURL}, error ${this.status}, ${this.responseText}`)
|
|
|
+ hasFailed = true;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ function getServerUrl() {
|
|
|
+ const apiClient = window.ApiClient;
|
|
|
+ return apiClient.serverAddress();
|
|
|
+ }
|
|
|
+
|
|
|
+ function assignAuth(request) {
|
|
|
+ const apiClient = window.ApiClient;
|
|
|
+ const address = apiClient.serverAddress();
|
|
|
+
|
|
|
+
|
|
|
+ request.setRequestHeader('Authorization', `MediaBrowser Token=${apiClient.accessToken()}`);
|
|
|
+ }
|
|
|
+
|
|
|
+ function mainScriptExecution() {
|
|
|
+ // Get trickplay manifest file
|
|
|
+ if (!trickplayManifest) {
|
|
|
+ let manifestUrl = getServerUrl() + MANIFEST_ENDPOINT.replace('{itemId}', mediaSourceId);
|
|
|
+ console.log(manifestUrl)
|
|
|
+ let manifestRequest = new XMLHttpRequest();
|
|
|
+ manifestRequest.responseType = 'json';
|
|
|
+ manifestRequest.addEventListener('load', manifestLoad);
|
|
|
+
|
|
|
+ manifestRequest.open('GET', manifestUrl);
|
|
|
+ assignAuth(manifestRequest);
|
|
|
+
|
|
|
+ debug(`Requesting Manifest @ ${manifestUrl}`);
|
|
|
+ manifestRequest.send();
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ // Get trickplay BIF file
|
|
|
+ if (!trickplayData && trickplayManifest) {
|
|
|
+ // Determine which width to use
|
|
|
+ // Prefer highest resolution @ less than 20% of total screen resolution width
|
|
|
+ let resolutions = trickplayManifest.WidthResolutions;
|
|
|
+
|
|
|
+ if (resolutions && resolutions.length > 0)
|
|
|
+ {
|
|
|
+ resolutions.sort();
|
|
|
+ let screenWidth = window.screen.width * window.devicePixelRatio;
|
|
|
+ let width = resolutions[0];
|
|
|
+
|
|
|
+ // Prefer bigger trickplay images granted they are less than or equal to 20% of total screen width
|
|
|
+ for (let i = 1; i < resolutions.length; i++)
|
|
|
+ {
|
|
|
+ let biggerWidth = resolutions[i];
|
|
|
+ if (biggerWidth <= (screenWidth * .2)) width = biggerWidth;
|
|
|
+ }
|
|
|
+ info(`Requesting BIF file with width ${width}`);
|
|
|
+
|
|
|
+ let bifUrl = getServerUrl() + BIF_ENDPOINT.replace('{itemId}', mediaSourceId).replace('{width}', width);
|
|
|
+ let bifRequest = new XMLHttpRequest();
|
|
|
+ bifRequest.responseType = 'arraybuffer';
|
|
|
+ bifRequest.addEventListener('load', bifLoad);
|
|
|
+
|
|
|
+ bifRequest.open('GET', bifUrl);
|
|
|
+ assignAuth(bifRequest);
|
|
|
+
|
|
|
+ debug(`Requesting BIF @ ${bifUrl}`);
|
|
|
+ bifRequest.send();
|
|
|
+ return;
|
|
|
+ } else {
|
|
|
+ error(`Have manifest file with no listed resolutions: ${trickplayManifest}`);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // Set the bubble function to our custom trickplay one
|
|
|
+ if (trickplayData) {
|
|
|
+ osdPositionSlider.getBubbleHtml = getBubbleHtmlTrickplay;
|
|
|
+ osdGetBubbleHtmlLock = true;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ function getBubbleHtmlTrickplay(sliderValue) {
|
|
|
+ //showOsd();
|
|
|
+
|
|
|
+ let currentTicks = mediaRuntimeTicks * (sliderValue / 100);
|
|
|
+ let currentTimeMs = currentTicks / 10_000
|
|
|
+ let imageSrc = getTrickplayFrameUrl(currentTimeMs, trickplayData);
|
|
|
+
|
|
|
+ if (imageSrc) {
|
|
|
+ if (currentTrickplayFrame) URL.revokeObjectURL(currentTrickplayFrame);
|
|
|
+ currentTrickplayFrame = imageSrc;
|
|
|
+
|
|
|
+ customThumbImg.src = imageSrc;
|
|
|
+ customChapterText.textContent = getDisplayRunningTime(currentTicks);
|
|
|
+ }
|
|
|
+
|
|
|
+ return `<div style="min-width: ${customSliderBubble.offsetWidth}px; max-height: 0px"></div>`;
|
|
|
+ }
|
|
|
+
|
|
|
+ // Not the same, but should be functionally equaivalent to --
|
|
|
+ // https://github.com/jellyfin/jellyfin-web/blob/8ff9d63e25b40575e02fe638491259c480c89ba5/src/controllers/playback/video/index.js#L237
|
|
|
+ /*
|
|
|
+ function showOsd() {
|
|
|
+ //document.getElementsByClassName('skinHeader')[0]?.classList.remove('osdHeader-hidden');
|
|
|
+ // todo: actually can't be bothered so I'll wait and see if it works without it or not
|
|
|
+ }
|
|
|
+ */
|
|
|
+
|
|
|
+ // Taken from https://github.com/jellyfin/jellyfin-web/blob/8ff9d63e25b40575e02fe638491259c480c89ba5/src/scripts/datetime.js#L76
|
|
|
+ function getDisplayRunningTime(ticks) {
|
|
|
+ const ticksPerHour = 36000000000;
|
|
|
+ const ticksPerMinute = 600000000;
|
|
|
+ const ticksPerSecond = 10000000;
|
|
|
+
|
|
|
+ const parts = [];
|
|
|
+
|
|
|
+ let hours = ticks / ticksPerHour;
|
|
|
+ hours = Math.floor(hours);
|
|
|
+
|
|
|
+ if (hours) {
|
|
|
+ parts.push(hours);
|
|
|
+ }
|
|
|
+
|
|
|
+ ticks -= (hours * ticksPerHour);
|
|
|
+
|
|
|
+ let minutes = ticks / ticksPerMinute;
|
|
|
+ minutes = Math.floor(minutes);
|
|
|
+
|
|
|
+ ticks -= (minutes * ticksPerMinute);
|
|
|
+
|
|
|
+ if (minutes < 10 && hours) {
|
|
|
+ minutes = '0' + minutes;
|
|
|
+ }
|
|
|
+ parts.push(minutes);
|
|
|
+
|
|
|
+ let seconds = ticks / ticksPerSecond;
|
|
|
+ seconds = Math.floor(seconds);
|
|
|
+
|
|
|
+ if (seconds < 10) {
|
|
|
+ seconds = '0' + seconds;
|
|
|
+ }
|
|
|
+ parts.push(seconds);
|
|
|
+
|
|
|
+ return parts.join(':');
|
|
|
+ }
|
|
|
+ })();
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+window._jellyscrubPlugin = jellyscrubPlugin;
|