Browse Source

Add experimental support for client plugins.

Ian Walton 2 years ago
parent
commit
6f2c467bbe

+ 4 - 0
README.md

@@ -131,3 +131,7 @@ If you have problems:
 Jellyfin Media Player is licensed under GPL v2. See the ``LICENSE`` file.
 Licenses of dependencies are summarized under ``resources/misc/licenses.txt``.
 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.

+ 535 - 0
native/jellyscrubPlugin.js

@@ -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;

+ 8 - 1
native/nativeshell.js

@@ -22,7 +22,9 @@ const plugins = [
     'mpvVideoPlayer',
     'mpvAudioPlayer',
     'jmpInputPlugin',
-    'jmpUpdatePlugin'
+    'jmpUpdatePlugin',
+    'jellyscrubPlugin',
+    'skipIntroPlugin'
 ];
 
 function loadScript(src) {
@@ -195,6 +197,11 @@ async function showSettingsModal() {
             legendHeader.textContent = section.key;
             legendHeader.style.textTransform = "capitalize";
             legend.appendChild(legendHeader);
+            if (section.key == "plugins") {
+                const legendSubHeader = document.createElement("h4");
+                legendSubHeader.textContent = "Plugins are UNOFFICIAL and require a restart to take effect.";
+                legend.appendChild(legendSubHeader);
+            }
             group.appendChild(legend);
 
             for (const setting of section.settings) {

+ 173 - 0
native/skipIntroPlugin.js

@@ -0,0 +1,173 @@
+let tvIntro;
+
+class skipIntroPlugin {
+    constructor({ events, playbackManager }) {
+        this.name = 'Skip Intro Plugin';
+        this.type = 'input';
+        this.id = 'skipIntroPlugin';
+
+        (async() => {
+            const api = await window.apiPromise;
+            const enabled = await new Promise(resolve => {
+                api.settings.value('plugins', 'skipintro', resolve);
+            });
+            console.log("Skip Intro Plugin enabled: " + enabled);
+            if (!enabled) return;
+
+            // Based on https://github.com/jellyfin/jellyfin-web/compare/release-10.8.z...ConfusedPolarBear:jellyfin-web:intros
+            // Adapted for use in JMP
+            const stylesheet = `
+            <style>
+            @media (hover:hover) and (pointer:fine) {
+                .skipIntro .paper-icon-button-light:hover:not(:disabled) {
+                    color:black !important;
+                    background-color:rgba(47,93,98,0) !important;
+                }
+            }
+            
+            .skipIntro {
+                padding: 0 1px;
+                position: absolute;
+                right: 10em;
+                bottom: 9em;
+                background-color:rgba(25, 25, 25, 0.66);
+                border: 1px solid;
+                border-radius: 0px;
+                display: inline-block;
+                cursor: pointer;
+                box-shadow: inset 0 0 0 0 #f9f9f9;
+                -webkit-transition: ease-out 0.4s;
+                -moz-transition: ease-out 0.4s;
+                transition: ease-out 0.4s;
+            }
+            
+            @media (max-width: 1080px) {
+                .skipIntro {
+                    right: 10%;
+                }
+            }
+            
+            .skipIntro:hover {
+                box-shadow: inset 400px 0 0 0 #f9f9f9;
+                -webkit-transition: ease-in 1s;
+                -moz-transition: ease-in 1s;
+                transition: ease-in 1s;
+            }
+            </style>
+            `;
+
+            document.head.insertAdjacentHTML('beforeend', stylesheet);
+
+
+            const skipIntroHtml = `
+            <div class="skipIntro hide">
+                <button is="paper-icon-button-light" class="btnSkipIntro paper-icon-button-light">
+                    Skip Intro
+                    <span class="material-icons skip_next"></span>
+                </button>
+            </div>
+            `;
+
+            function waitForElement(element, maxWait = 10000) {
+                return new Promise((resolve, reject) => {
+                    const interval = setInterval(() => {
+                        const result = document.querySelector(element);
+                        if (result) {
+                            clearInterval(interval);
+                            resolve(result);
+                        }
+                    }, 100);
+
+                    setTimeout(() => {
+                        clearInterval(interval);
+                        reject();
+                    }, maxWait);
+                });
+            }
+
+            async function injectSkipIntroHtml() {
+                const playerContainer = await waitForElement('.upNextContainer', 5000);
+                // inject only if it doesn't exist
+                if (!document.querySelector('.skipIntro .btnSkipIntro')) {
+                    playerContainer.insertAdjacentHTML('afterend', skipIntroHtml);
+                }
+
+                document.querySelector('.skipIntro .btnSkipIntro').addEventListener('click', (e) => {
+                    e.preventDefault();
+                    e.stopPropagation();
+                    skipIntro();
+                }, { useCapture: true });
+
+                if (window.PointerEvent) {
+                    document.querySelector('.skipIntro .btnSkipIntro').addEventListener('pointerdown', (e) => {
+                        e.preventDefault();
+                        e.stopPropagation();
+                    }, { useCapture: true });
+                }
+            }
+
+            
+            async function onPlayback(e, player, state) {
+                if (state.NowPlayingItem) {
+                    await injectSkipIntroHtml();
+                    getIntroTimestamps(state.NowPlayingItem);
+                }
+
+                const onTimeUpdate = () => {
+                    // Check if an introduction sequence was detected for this item.
+                    if (!tvIntro?.Valid) {
+                        return;
+                    }
+
+                    const seconds = playbackManager.currentTime(player) / 1000;
+                    const skipIntro = document.querySelector(".skipIntro");
+
+                    // If the skip prompt should be shown, show it.
+                    if (seconds >= tvIntro.ShowSkipPromptAt && seconds < tvIntro.HideSkipPromptAt) {
+                        skipIntro.classList.remove("hide");
+                        return;
+                    }
+
+                    skipIntro.classList.add("hide");
+                };
+
+                events.on(player, 'timeupdate', onTimeUpdate);
+
+                events.on(player, 'playbackstop', () => {
+                    events.off(player, 'timeupdate', onTimeUpdate);
+                });
+            };
+            events.on(playbackManager, 'playbackstart', onPlayback);
+            
+
+            function getIntroTimestamps(item) {
+                const apiClient = window.ApiClient;
+                const address = apiClient.serverAddress();
+
+                const url = `${address}/Episode/${item.Id}/IntroTimestamps`;
+                const reqInit = {
+                    headers: {
+                        "Authorization": `MediaBrowser Token=${apiClient.accessToken()}`
+                    }
+                };
+
+                fetch(url, reqInit).then(r => {
+                    if (!r.ok) {
+                        tvIntro = null;
+                        return;
+                    }
+
+                    return r.json();
+                }).then(intro => {
+                    tvIntro = intro;
+                }).catch(err => { tvIntro = null; });
+            }
+
+            function skipIntro() {
+                playbackManager.seekMs(tvIntro.IntroEnd * 1000);
+            }
+        })();
+    }
+}
+
+window._skipIntroPlugin = skipIntroPlugin;

+ 13 - 0
resources/settings/settings_description.json

@@ -131,6 +131,19 @@
       }
     ]
   },
+  {
+    "section": "plugins",
+    "values": [
+      {
+        "value": "skipintro",
+        "default": false
+      },
+      {
+        "value": "jellyscrub",
+        "default": false
+      }
+    ]
+  },
   {
     "section": "audio",
     "values": [