diff --git a/.github/workflows/webui.yml b/.github/workflows/webui.yml new file mode 100644 index 0000000..3a4d5a9 --- /dev/null +++ b/.github/workflows/webui.yml @@ -0,0 +1,41 @@ +name: Create Jellyfin-web artifact +on: + release: + types: [published] + workflow_dispatch: +jobs: + build: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + strategy: + matrix: + jellyfin-web-version: [10.9.10] + steps: + - uses: actions/checkout@v4 + - name: Setup Node.js environment + uses: actions/setup-node@v4 + with: + node-version: '>=20' + - name: Checkout official jellyfin-web + uses: actions/checkout@v4 + with: + repository: jellyfin/jellyfin-web + ref: v${{ matrix.jellyfin-web-version }} + path: web + - name: Apply intro skipper patch + run: | + cd web + git apply ../webui.patch + - name: Build web interface + run: | + cd web + npm ci --no-audit + npm run build:production + - name: Upload web interface + uses: actions/upload-artifact@v4 + with: + name: jellyfin-web-${{ matrix.jellyfin-web-version }}+${{ github.sha }} + path: web/dist + if-no-files-found: error diff --git a/ConfusedPolarBear.Plugin.IntroSkipper/Plugin.cs b/ConfusedPolarBear.Plugin.IntroSkipper/Plugin.cs index f81f341..fe40417 100644 --- a/ConfusedPolarBear.Plugin.IntroSkipper/Plugin.cs +++ b/ConfusedPolarBear.Plugin.IntroSkipper/Plugin.cs @@ -130,10 +130,9 @@ public class Plugin : BasePlugin, IHasWebPages } // Inject the skip intro button code into the web interface. - var indexPath = Path.Join(applicationPaths.WebPath, "index.html"); try { - InjectSkipButton(indexPath); + InjectSkipButton(applicationPaths.WebPath); } catch (Exception ex) { @@ -405,17 +404,35 @@ public class Plugin : BasePlugin, IHasWebPages /// /// Inject the skip button script into the web interface. /// - /// Full path to index.html. - private void InjectSkipButton(string indexPath) + /// Full path to index.html. + private void InjectSkipButton(string webPath) { + // search for controllers/playback/video/index.html + string searchPattern = "playback-video-index-html.*.chunk.js"; + string[] filePaths = Directory.GetFiles(webPath, searchPattern, SearchOption.TopDirectoryOnly); + + // should be only one file but this safer + foreach (var file in filePaths) + { + // search for class btnSkipIntro + if (File.ReadAllText(file).Contains("btnSkipIntro", StringComparison.OrdinalIgnoreCase)) + { + _logger.LogInformation("jellyfin has build-in skip button"); + return; + } + } + + // Inject the skip intro button code into the web interface. + string indexPath = Path.Join(webPath, "index.html"); + // Parts of this code are based off of JellyScrub's script injection code. // https://github.com/nicknsy/jellyscrub/blob/main/Nick.Plugin.Jellyscrub/JellyscrubPlugin.cs#L38 _logger.LogDebug("Reading index.html from {Path}", indexPath); - var contents = File.ReadAllText(indexPath); + string contents = File.ReadAllText(indexPath); // change URL with every relase to prevent the Browers from caching - var scriptTag = ""; + string scriptTag = ""; // Only inject the script tag once if (contents.Contains(scriptTag, StringComparison.OrdinalIgnoreCase)) @@ -430,7 +447,7 @@ public class Plugin : BasePlugin, IHasWebPages // Inject a link to the script at the end of the section. // A regex is used here to ensure the replacement is only done once. - var headEnd = new Regex("", RegexOptions.IgnoreCase); + Regex headEnd = new Regex("", RegexOptions.IgnoreCase); contents = headEnd.Replace(contents, scriptTag + "", 1); // Write the modified file contents diff --git a/README.md b/README.md index 500fbec..e078b7d 100644 --- a/README.md +++ b/README.md @@ -90,6 +90,7 @@ These parameters can be configured by opening the plugin settings - **Windows:** Locate `index.html` in `C:\Program Files\Jellyfin\Server\jellyfin-web` and modify the permissions for your user to Full Control. After making this change, restart Jellyfin. * Install from distro repositories - the jellyfin-server will execute as `jellyfin` user while the web files will be owned by `root`, `www-data`, etc. This can likely be fixed by adding the `jellyfin` user (or whichever user executes the jellyfin server) to the same group that owns the jellyfin-web folders. **You should only do this if they are owned by a group other than root**. +- **If the above steps do not resolve the issue,** instructions on how to use the modified web interface can be found [here](docs/web_interface.md). - The official Android TV app do not support the skip button. For this app, you will need to use the autoskip option. Please note that there is currently an [issue](https://github.com/jumoog/intro-skipper/issues/168) with autoskip not working because the apps never receive the seek command from Jellyfin. ## Installation (MacOS) diff --git a/docs/web_interface.md b/docs/web_interface.md new file mode 100644 index 0000000..cfa58c9 --- /dev/null +++ b/docs/web_interface.md @@ -0,0 +1,59 @@ +# Installing the Modified Jellyfin Web Interface + +## Requirements + +- **Jellyfin Version**: 10.9 +- **Modified Web Interface**: Download the latest version from [GitHub Actions](https://github.com/jumoog/intro-skipper/actions/workflows/webui.yml) + 1. Open the most recent action run. + 2. In the "Artifacts" section, click the `jellyfin-web-VERSION+COMMIT.zip` link to download the pre-compiled web interface. *Note: You must be signed into GitHub to access this link.* + +## Native Installation (Linux/Windows) + +1. **Backup the Original Web Interface**: + - On **Linux**: The web interface is located at `/usr/share/jellyfin/web/`. + - On **Windows**: The web interface is located at `C:\Program Files\Jellyfin\Server\jellyfin-web`. + +2. **Install the Modified Web Interface**: + - Extract the contents of the downloaded zip file. + - Copy the extracted files into Jellyfin's web directory, replacing the existing files. + +3. **Plugin Installation**: + - Follow the plugin installation instructions provided in the main README. + +## Container Installation + +1. **Extract the Archive**: + - Extract the downloaded archive on your server. + - Note the full path to the `dist` folder. + +2. **Update Docker Compose**: + - Mount the `dist` folder in your container using the appropriate path: + ```yaml + services: + jellyfin: + ports: + - "8096:8096" + volumes: + - "/full/path/to/extracted/dist:/jellyfin/jellyfin-web:ro" # For the official container + - "/full/path/to/extracted/dist:/usr/share/jellyfin/web:ro" # For the linuxserver container + - "/config:/config" + - "/media:/media:ro" + image: "jellyfin/jellyfin:latest" + ``` + +3. **Clear Browser Cache**: + - Ensure you clear your browser's cache before testing the new web interface. + +### Unraid Users + +For Unraid users, follow these additional steps: + +1. In the **Docker** tab, click on the Jellyfin container. +2. Click on **Edit** and enable **Advanced View**. +3. Under **Extra Parameters**, add the appropriate volume mount command: + - For the `jellyfin/jellyfin` container: `--volume /full/path/to/extracted/dist:/jellyfin/jellyfin-web:ro` + - For the `linuxserver/jellyfin` container: `--volume /full/path/to/extracted/dist:/usr/share/jellyfin/web:ro` + +### Note for Jellyfin Media Player Users + +If you are using **Jellyfin Media Player (JMP)**, make sure that the "Intro Skipper Plugin" option is disabled in the JMP settings. This ensures compatibility with the modified web interface and avoids potential conflicts with the intro-skipping functionality. diff --git a/webui.patch b/webui.patch new file mode 100644 index 0000000..c2b1662 --- /dev/null +++ b/webui.patch @@ -0,0 +1,226 @@ +diff --git a/src/controllers/playback/video/index.html b/src/controllers/playback/video/index.html +index a460ee8f6a3..d7b344d4b1b 100644 +--- a/src/controllers/playback/video/index.html ++++ b/src/controllers/playback/video/index.html +@@ -6,6 +6,12 @@ + + +
++
++ ++
+
+
+
+diff --git a/src/controllers/playback/video/index.js b/src/controllers/playback/video/index.js +index 2adad5708c3..5b81eebc7f1 100644 +--- a/src/controllers/playback/video/index.js ++++ b/src/controllers/playback/video/index.js +@@ -365,7 +365,7 @@ export default function (view) { + toggleSubtitleSync('hide'); + + // Firefox does not blur by itself +- if (document.activeElement) { ++ if (document.activeElement && !skipButton.contains(document.activeElement)) { + document.activeElement.blur(); + } + } +@@ -517,9 +517,95 @@ export default function (view) { + updatePlaylist(); + enableStopOnBack(true); + updatePlaybackRate(player); ++ getIntroTimestamps(state.NowPlayingItem); + } + } + ++ function secureFetch(url) { ++ const apiClient = ServerConnections.currentApiClient(); ++ const address = apiClient.serverAddress(); ++ const reqInit = { ++ headers: { ++ "Authorization": `MediaBrowser Token=${apiClient.accessToken()}` ++ } ++ }; ++ return fetch(`${address}${url}`, reqInit).then(r => { ++ return r.ok ? r.json() : null; ++ }); ++ } ++ ++ function getIntroTimestamps(item) { ++ secureFetch(`/Episode/${item.Id}/IntroSkipperSegments`).then(segments => { ++ skipSegments = segments; ++ hasCreditsSegment = Object.keys(segments).some(key => key === "Credits"); ++ }).catch(err => { ++ skipSegments = {}; ++ hasCreditsSegment = false; }); ++ secureFetch(`/Intros/UserInterfaceConfiguration`).then(config => { ++ skipButton.dataset.Introduction = config.SkipButtonIntroText; ++ skipButton.dataset.Credits = config.SkipButtonEndCreditsText; ++ }).catch(err => { ++ skipButton.dataset.Introduction = 'Skip Intro'; ++ skipButton.dataset.Credits = 'Next'; }); ++ } ++ ++ function getCurrentSegment(position) { ++ for (const [key, segment] of Object.entries(skipSegments)) { ++ if ((position > segment.ShowSkipPromptAt && position < segment.HideSkipPromptAt - 1) || ++ (currentVisibleMenu === 'osd' && position > segment.IntroStart && position < segment.IntroEnd - 1)) { ++ segment.SegmentType = key; ++ return segment; ++ } ++ } ++ return { SegmentType: "None" }; ++ } ++ ++ function videoPositionChanged(currentTime) { ++ const embyButton = skipButton.querySelector(".emby-button"); ++ const segmentType = getCurrentSegment(currentTime / TICKS_PER_SECOND).SegmentType; ++ if (segmentType === "None") { ++ if (!skipButton.classList.contains('show')) return; ++ skipButton.classList.remove('show'); ++ embyButton.addEventListener("transitionend", () => { ++ skipButton.classList.add("hide"); ++ if (!currentVisibleMenu) { ++ embyButton.blur(); ++ } else { ++ _focus(osdBottomElement.querySelector('.btnPause')); ++ } ++ }, { once: true }); ++ return; ++ } ++ skipButton.querySelector("#btnSkipSegmentText").textContent = skipButton.dataset[segmentType]; ++ if (!skipButton.classList.contains("hide")) { ++ if (!currentVisibleMenu && !embyButton.contains(document.activeElement)) _focus(embyButton); ++ return; ++ } ++ requestAnimationFrame(() => { ++ skipButton.classList.remove("hide"); ++ requestAnimationFrame(() => { ++ skipButton.classList.add('show'); ++ _focus(embyButton); ++ }); ++ }); ++ } ++ ++ function doSkip() { ++ const segment = getCurrentSegment(playbackManager.currentTime(currentPlayer) / 1000); ++ if (segment.SegmentType === "None") { ++ console.warn("[intro skipper] doSkip() called without an active segment"); ++ return; ++ } ++ playbackManager.seek(segment.IntroEnd * TICKS_PER_SECOND, currentPlayer); ++ } ++ ++ function eventHandler(e) { ++ if (e.key !== "Enter") return; ++ e.stopPropagation(); ++ e.preventDefault(); ++ doSkip(); ++ } ++ + function onPlayPauseStateChanged() { + if (isEnabled) { + updatePlayPauseState(this.paused()); +@@ -637,12 +723,13 @@ export default function (view) { + const item = currentItem; + refreshProgramInfoIfNeeded(player, item); + showComingUpNextIfNeeded(player, item, currentTime, currentRuntimeTicks); ++ videoPositionChanged(currentTime); + } + } + } + + function showComingUpNextIfNeeded(player, currentItem, currentTimeTicks, runtimeTicks) { +- if (runtimeTicks && currentTimeTicks && !comingUpNextDisplayed && !currentVisibleMenu && currentItem.Type === 'Episode' && userSettings.enableNextVideoInfoOverlay()) { ++ if (!hasCreditsSegment && runtimeTicks && currentTimeTicks && !comingUpNextDisplayed && !currentVisibleMenu && currentItem.Type === 'Episode' && userSettings.enableNextVideoInfoOverlay()) { + let showAtSecondsLeft = 30; + if (runtimeTicks >= 50 * TICKS_PER_MINUTE) { + showAtSecondsLeft = 40; +@@ -1543,7 +1630,10 @@ export default function (view) { + let programEndDateMs = 0; + let playbackStartTimeTicks = 0; + let subtitleSyncOverlay; ++ let skipSegments = {}; ++ let hasCreditsSegment; + let trickplayResolution = null; ++ const skipButton = document.querySelector(".skipIntro"); + const nowPlayingVolumeSlider = view.querySelector('.osdVolumeSlider'); + const nowPlayingVolumeSliderContainer = view.querySelector('.osdVolumeSliderContainer'); + const nowPlayingPositionSlider = view.querySelector('.osdPositionSlider'); +@@ -1699,6 +1789,10 @@ export default function (view) { + let lastPointerDown = 0; + /* eslint-disable-next-line compat/compat */ + dom.addEventListener(view, window.PointerEvent ? 'pointerdown' : 'click', function (e) { ++ if (dom.parentWithClass(e.target, ['btnSkipIntro'])) { ++ return; ++ } ++ + if (dom.parentWithClass(e.target, ['videoOsdBottom', 'upNextContainer'])) { + showOsd(); + return; +@@ -1854,6 +1948,8 @@ export default function (view) { + }); + view.querySelector('.btnAudio').addEventListener('click', showAudioTrackSelection); + view.querySelector('.btnSubtitles').addEventListener('click', showSubtitleTrackSelection); ++ skipButton.addEventListener('click', doSkip); ++ skipButton.addEventListener("keydown", eventHandler); + + // HACK: Remove `emby-button` from the rating button to make it look like the other buttons + view.querySelector('.btnUserRating').classList.remove('emby-button'); +@@ -1964,4 +2060,3 @@ export default function (view) { + }); + } + } +- +diff --git a/src/styles/videoosd.scss b/src/styles/videoosd.scss +index 2c8c00e2601..336b2bacad3 100644 +--- a/src/styles/videoosd.scss ++++ b/src/styles/videoosd.scss +@@ -346,3 +346,44 @@ + transform: rotate(-360deg); + } + } ++ ++:root { ++ --rounding: 4px; ++ --accent: 0, 164, 220; ++} ++.skipIntro { ++ position: absolute; ++ bottom: 7.5em; ++ right: 5em; ++ background-color: transparent; ++} ++.skipIntro .emby-button { ++ color: #ffffff; ++ font-size: 110%; ++ background: rgba(0, 0, 0, 0.7); ++ border-radius: var(--rounding); ++ box-shadow: 0 0 4px rgba(0, 0, 0, 0.6); ++ transition: opacity 0.3s cubic-bezier(0.4,0,0.2,1), ++ transform 0.3s cubic-bezier(0.4,0,0.2,1), ++ background-color 0.2s ease-out, ++ box-shadow 0.2s ease-out; ++ opacity: 0; ++ transform: translateY(50%); ++} ++.skipIntro.show .emby-button { ++ opacity: 1; ++ transform: translateY(0); ++} ++.skipIntro .emby-button:hover { ++ background: rgb(var(--accent)); ++ box-shadow: 0 0 8px rgba(var(--accent), 0.6); ++ filter: brightness(1.2); ++} ++.skipIntro .emby-button:focus { ++ background: rgb(var(--accent)); ++ box-shadow: 0 0 8px rgba(var(--accent), 0.6); ++} ++.btnSkipSegmentText { ++ letter-spacing: 0.5px; ++ padding: 0 5px 0 5px; ++}