check if intro skip button is build-in and skip injecting (#240)
Co-authored-by: rlauuzo <46294892+rlauuzo@users.noreply.github.com> Co-authored-by: rlauu <46294892+rlauu@users.noreply.github.com>
This commit is contained in:
parent
25fd56d83c
commit
55ee501cf5
41
.github/workflows/webui.yml
vendored
Normal file
41
.github/workflows/webui.yml
vendored
Normal file
@ -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
|
@ -130,10 +130,9 @@ public class Plugin : BasePlugin<PluginConfiguration>, 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<PluginConfiguration>, IHasWebPages
|
||||
/// <summary>
|
||||
/// Inject the skip button script into the web interface.
|
||||
/// </summary>
|
||||
/// <param name="indexPath">Full path to index.html.</param>
|
||||
private void InjectSkipButton(string indexPath)
|
||||
/// <param name="webPath">Full path to index.html.</param>
|
||||
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 = "<script src=\"configurationpage?name=skip-intro-button.js&release=" + GetType().Assembly.GetName().Version + "\"></script>";
|
||||
string scriptTag = "<script src=\"configurationpage?name=skip-intro-button.js&release=" + GetType().Assembly.GetName().Version + "\"></script>";
|
||||
|
||||
// Only inject the script tag once
|
||||
if (contents.Contains(scriptTag, StringComparison.OrdinalIgnoreCase))
|
||||
@ -430,7 +447,7 @@ public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
|
||||
|
||||
// Inject a link to the script at the end of the <head> section.
|
||||
// A regex is used here to ensure the replacement is only done once.
|
||||
var headEnd = new Regex("</head>", RegexOptions.IgnoreCase);
|
||||
Regex headEnd = new Regex("</head>", RegexOptions.IgnoreCase);
|
||||
contents = headEnd.Replace(contents, scriptTag + "</head>", 1);
|
||||
|
||||
// Write the modified file contents
|
||||
|
@ -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.
|
||||
|
||||
* <b>Install from distro repositories -</b> the jellyfin-server will execute as `jellyfin` user while the web files will be owned by `root`, `www-data`, etc. This can <i>likely</i> 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)
|
||||
|
||||
|
59
docs/web_interface.md
Normal file
59
docs/web_interface.md
Normal file
@ -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.
|
226
webui.patch
Normal file
226
webui.patch
Normal file
@ -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 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="upNextContainer hide"></div>
|
||||
+ <div class="skipIntro hide">
|
||||
+ <button is="emby-button" type="button" class="btnSkipIntro injected">
|
||||
+ <span id="btnSkipSegmentText"></span>
|
||||
+ <span class="material-icons skip_next"></span>
|
||||
+ </button>
|
||||
+ </div>
|
||||
<div class="videoOsdBottom videoOsdBottom-maincontrols">
|
||||
<div class="osdControls">
|
||||
<div class="osdTextContainer osdMainTextContainer">
|
||||
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;
|
||||
+}
|
Loading…
x
Reference in New Issue
Block a user