class jellyscrubPlugin { constructor({ playbackManager, events, ServerConnections }) { this.name = 'Jellyscrub Plugin'; this.type = 'input'; this.id = 'jellyscrubPlugin'; (async() => { await window.initCompleted; const enabled = window.jmpInfo.settings.plugins.jellyscrub; 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 = ServerConnections ? ServerConnections.currentApiClient() : window.ApiClient; return apiClient.serverAddress(); } function assignAuth(request) { const apiClient = ServerConnections ? ServerConnections.currentApiClient() : 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 `
`; } // 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;