diff --git a/README.md b/README.md index 4c4ca41..776d749 100644 --- a/README.md +++ b/README.md @@ -82,7 +82,7 @@ sudo ldconfig cd ~/jmp/ git clone https://github.com/jellyfin/jellyfin-media-player.git cd jellyfin-media-player/ -./download_webclient.sh +./download_webclient.sh cd build/ cmake -DCMAKE_BUILD_TYPE=Debug -DCMAKE_INSTALL_PREFIX=/usr/local/ .. make -j`nproc` @@ -185,7 +185,7 @@ This file can also be printed at runtime when using the ``--licenses`` option. ## Unofficial Plugin Support -You can enable experimental support for [Jellyscrub](https://github.com/nicknsy/jellyscrub) and [Skip Intro](https://github.com/ConfusedPolarBear/intro-skipper) in client settings. These are included for convenience only and is not an endorsement or long-term commitment to ensure functionality. See `src/native` for details on what the plugins modify code-wise. +You can enable experimental support for [Skip Intro](https://github.com/ConfusedPolarBear/intro-skipper) in client settings. These are included for convenience only and is not an endorsement or long-term commitment to ensure functionality. See `src/native` for details on what the plugins modify code-wise. ## Known Issues diff --git a/for-web-developers.md b/for-web-developers.md index 6c2be2b..85530f0 100644 --- a/for-web-developers.md +++ b/for-web-developers.md @@ -87,7 +87,7 @@ Note: The callback does NOT mean the video actually finished loading. You need t metadata: { // Only used for system UI integration, eg. taskbar/windows media. // Optional. Comes from Jellyfin metadata item. - // Known used items are documented below. + // Known used items are documented below. Id: 'media uuid', MediaType: 'video', ImageTags: { @@ -158,7 +158,6 @@ Current Settings: - `userWebClient: string`: Hidden option. Set it back to an empty string to allow the user to select a different webclient path. - Plugins Section (`plugins`): - `skipintro: boolean`: Enables or disabled the plugin. You likely won't use this. - - `jellyscrub: boolean`: Enables or disabled the plugin. You likely won't use this. - Audio Section (`audio`): - `devicetype: string enum`: Sets the device type. Can be `basic`, `spdif`, or `hdmi`. - `basic` disables passthrough @@ -178,7 +177,7 @@ Current Settings: - `passthrough.dts: boolean`: Enable dts passthrough. Requires `spdif` or `hdmi` device type. Default: `false` - `passthrough.eac3: boolean`: Enable ac3 passthrough. Requires `hdmi` device type. Default: `false` - `passthrough.dts-hd": boolean`: Enable ac3 passthrough. Requires `hdmi` device type. Default: `false` - - `passthrough.truehd: boolean`: Enable ac3 passthrough. Requires `hdmi` device type. Default: `false` + - `passthrough.truehd: boolean`: Enable ac3 passthrough. Requires `hdmi` device type. Default: `false` - Video Section (`video`): - `refreshrate.auto_switch: boolean`: Sets the display refresh rate to the media refresh rate. Default: `false` - This is only enabled while the player is fullscreen. diff --git a/native/jellyscrubPlugin.js b/native/jellyscrubPlugin.js deleted file mode 100644 index f21d25a..0000000 --- a/native/jellyscrubPlugin.js +++ /dev/null @@ -1,539 +0,0 @@ -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; diff --git a/native/nativeshell.js b/native/nativeshell.js index 82013a9..4a5c46b 100644 --- a/native/nativeshell.js +++ b/native/nativeshell.js @@ -24,7 +24,6 @@ const plugins = [ 'mpvAudioPlayer', 'jmpInputPlugin', 'jmpUpdatePlugin', - 'jellyscrubPlugin', 'skipIntroPlugin' ]; diff --git a/resources/settings/settings_description.json b/resources/settings/settings_description.json index 6c573e9..5e34260 100644 --- a/resources/settings/settings_description.json +++ b/resources/settings/settings_description.json @@ -161,10 +161,6 @@ { "value": "skipintro", "default": false - }, - { - "value": "jellyscrub", - "default": false } ] },