123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539 |
- 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 `<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;
|