diff --git a/ACKNOWLEDGEMENTS.md b/ACKNOWLEDGEMENTS.md
new file mode 100644
index 0000000..cd4e356
--- /dev/null
+++ b/ACKNOWLEDGEMENTS.md
@@ -0,0 +1,5 @@
+Intro Skipper is made possible by the following open source projects:
+
+* License: MIT
+ * [acoustid-match](https://github.com/dnknth/acoustid-match)
+ * [JellyScrub](https://github.com/nicknsy/jellyscrub)
diff --git a/ConfusedPolarBear.Plugin.IntroSkipper/AutoSkip.cs b/ConfusedPolarBear.Plugin.IntroSkipper/AutoSkip.cs
index 81a87f8..d77b922 100644
--- a/ConfusedPolarBear.Plugin.IntroSkipper/AutoSkip.cs
+++ b/ConfusedPolarBear.Plugin.IntroSkipper/AutoSkip.cs
@@ -163,18 +163,21 @@ public class AutoSkip : IServerEntryPoint
}
// Notify the user that an introduction is being skipped for them.
- _sessionManager.SendMessageCommand(
+ var notificationText = Plugin.Instance!.Configuration.AutoSkipNotificationText;
+ if (!string.IsNullOrWhiteSpace(notificationText))
+ {
+ _sessionManager.SendMessageCommand(
session.Id,
session.Id,
new MessageCommand()
{
Header = string.Empty, // some clients require header to be a string instead of null
- Text = "Automatically skipped intro",
+ Text = notificationText,
TimeoutMs = 2000,
},
CancellationToken.None);
+ }
- // Send the seek command
_logger.LogDebug("Sending seek command to {Session}", deviceId);
var introEnd = (long)intro.IntroEnd - Plugin.Instance!.Configuration.SecondsOfIntroToPlay;
diff --git a/ConfusedPolarBear.Plugin.IntroSkipper/Configuration/PluginConfiguration.cs b/ConfusedPolarBear.Plugin.IntroSkipper/Configuration/PluginConfiguration.cs
index e382bf2..f0def72 100644
--- a/ConfusedPolarBear.Plugin.IntroSkipper/Configuration/PluginConfiguration.cs
+++ b/ConfusedPolarBear.Plugin.IntroSkipper/Configuration/PluginConfiguration.cs
@@ -74,6 +74,11 @@ public class PluginConfiguration : BasePluginConfiguration
// ===== Playback settings =====
+ ///
+ /// Gets or sets a value indicating whether to show the skip intro button.
+ ///
+ public bool SkipButtonVisible { get; set; } = true;
+
///
/// Gets or sets a value indicating whether introductions should be automatically skipped.
///
@@ -127,4 +132,16 @@ public class PluginConfiguration : BasePluginConfiguration
/// Gets or sets the minimum duration of audio (in seconds) that is considered silent.
///
public double SilenceDetectionMinimumDuration { get; set; } = 0.33;
+
+ // ===== Localization support =====
+
+ ///
+ /// Gets or sets the text to display in the Skip Intro button.
+ ///
+ public string SkipButtonText { get; set; } = "Skip Intro";
+
+ ///
+ /// Gets or sets the notification text sent after automatically skipping an introduction.
+ ///
+ public string AutoSkipNotificationText { get; set; } = "Automatically skipped intro";
}
diff --git a/ConfusedPolarBear.Plugin.IntroSkipper/Configuration/UserInterfaceConfiguration.cs b/ConfusedPolarBear.Plugin.IntroSkipper/Configuration/UserInterfaceConfiguration.cs
new file mode 100644
index 0000000..a5a2028
--- /dev/null
+++ b/ConfusedPolarBear.Plugin.IntroSkipper/Configuration/UserInterfaceConfiguration.cs
@@ -0,0 +1,28 @@
+namespace ConfusedPolarBear.Plugin.IntroSkipper.Configuration;
+
+///
+/// User interface configuration.
+///
+public class UserInterfaceConfiguration
+{
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// Skip button visibility.
+ /// Skip button text.
+ public UserInterfaceConfiguration(bool visible, string text)
+ {
+ SkipButtonVisible = visible;
+ SkipButtonText = text;
+ }
+
+ ///
+ /// Gets or sets a value indicating whether to show the skip intro button.
+ ///
+ public bool SkipButtonVisible { get; set; }
+
+ ///
+ /// Gets or sets the text to display in the skip intro button.
+ ///
+ public string SkipButtonText { get; set; }
+}
diff --git a/ConfusedPolarBear.Plugin.IntroSkipper/Configuration/configPage.html b/ConfusedPolarBear.Plugin.IntroSkipper/Configuration/configPage.html
index 401bd92..33cf2e5 100644
--- a/ConfusedPolarBear.Plugin.IntroSkipper/Configuration/configPage.html
+++ b/ConfusedPolarBear.Plugin.IntroSkipper/Configuration/configPage.html
@@ -194,6 +194,19 @@
@@ -395,13 +433,16 @@
// internals
"SilenceDetectionMaximumNoise",
"SilenceDetectionMinimumDuration",
+ "SkipButtonText",
+ "AutoSkipNotificationText"
]
var booleanConfigurationFields = [
"AnalyzeSeasonZero",
"RegenerateEdlFiles",
"AutoSkip",
- "SkipFirstEpisode"
+ "SkipFirstEpisode",
+ "SkipButtonVisible",
]
// visualizer elements
diff --git a/ConfusedPolarBear.Plugin.IntroSkipper/Configuration/inject.js b/ConfusedPolarBear.Plugin.IntroSkipper/Configuration/inject.js
new file mode 100644
index 0000000..4b91943
--- /dev/null
+++ b/ConfusedPolarBear.Plugin.IntroSkipper/Configuration/inject.js
@@ -0,0 +1,224 @@
+let nowPlayingItemSkipSegments = {};
+let videoPlayer = {};
+
+function d(msg) {
+ console.debug("[intro skipper]", msg);
+}
+
+/** Setup event listeners */
+function setup() {
+ document.addEventListener("viewshow", viewshow);
+ d("Registered hooks");
+}
+
+/**
+ * Event handler that runs whenever the current view changes.
+ * Used to detect the start of video playback.
+ */
+function viewshow() {
+ const location = window.location.hash;
+ d("Location changed to " + location);
+
+ if (location !== "#!/video") {
+ d("Ignoring location change");
+ return;
+ }
+
+ d("Adding button CSS and element");
+ injectSkipButtonCss();
+ injectSkipButtonElement();
+
+ d("Hooking video timeupdate");
+ videoPlayer = document.querySelector("video");
+ videoPlayer.addEventListener("timeupdate", videoPositionChanged);
+
+ d("Getting timestamps of introduction");
+ getIntroTimestamps();
+}
+
+/** Get skip button UI configuration */
+async function getUserInterfaceConfiguration() {
+ const reqInit = {
+ headers: {
+ "Authorization": "MediaBrowser Token=" + ApiClient.accessToken()
+ }
+ }
+
+ const res = await fetch("/Intros/UserInterfaceConfiguration", reqInit);
+ return await res.json();
+}
+
+/**
+ * Injects the CSS used by the skip intro button.
+ * Calling this function is a no-op if the CSS has already been injected.
+ */
+function injectSkipButtonCss() {
+ if (testElement("style#introSkipperCss"))
+ {
+ d("CSS already added");
+ return;
+ }
+
+ d("Adding CSS");
+
+ let styleElement = document.createElement("style");
+ styleElement.id = "introSkipperCss";
+ styleElement.innerText = `
+ @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.upNextContainer {
+ width: unset;
+ }
+
+ #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;
+ }
+ `;
+ document.querySelector("head").appendChild(styleElement);
+}
+
+/**
+ * Inject the skip intro button into the video player.
+ * Calling this function is a no-op if the CSS has already been injected.
+ */
+async function injectSkipButtonElement() {
+ if (testElement(".btnSkipIntro")) {
+ d("Button already added");
+ return;
+ }
+
+ d("Adding button");
+
+ let config = await getUserInterfaceConfiguration();
+ if (!config.SkipButtonVisible) {
+ d("Not adding button: not visible");
+ return;
+ }
+
+ // Construct the skip button div
+ const button = document.createElement("div");
+ button.id = "skipIntro"
+ button.classList.add("hide");
+ button.addEventListener("click", skipIntro);
+ button.innerHTML = `
+
+ `;
+
+ /*
+ * Alternative workaround for #44. Jellyfin's video component registers a global click handler
+ * (located at src/controllers/playback/video/index.js:1492) that pauses video playback unless
+ * the clicked element has a parent with the class "videoOsdBottom" or "upNextContainer".
+ */
+ button.classList.add("upNextContainer");
+
+ // Append the button to the video OSD
+ let controls = document.querySelector("div#videoOsdPage");
+ controls.appendChild(button);
+
+ document.querySelector("#btnSkipIntroText").textContent = config.SkipButtonText;
+}
+
+/** Gets the introduction timestamps of the currently playing item. */
+async function getIntroTimestamps() {
+ let id = await getNowPlayingItemId();
+
+ const address = ApiClient.serverAddress();
+
+ const url = `${address}/Episode/${id}/IntroTimestamps`;
+ const reqInit = {
+ headers: {
+ "Authorization": `MediaBrowser Token=${ApiClient.accessToken()}`
+ }
+ };
+
+ fetch(url, reqInit).then(r => {
+ if (!r.ok) {
+ return;
+ }
+
+ return r.json();
+ }).then(intro => {
+ nowPlayingItemSkipSegments = intro;
+ });
+}
+
+/** Playback position changed, check if the skip button needs to be displayed. */
+function videoPositionChanged() {
+ // Ensure a skip segment was found.
+ if (!nowPlayingItemSkipSegments?.Valid) {
+ return;
+ }
+
+ const skipButton = document.querySelector("#skipIntro");
+ if (!skipButton) {
+ return;
+ }
+
+ const position = videoPlayer.currentTime;
+ if (position >= nowPlayingItemSkipSegments.ShowSkipPromptAt &&
+ position < nowPlayingItemSkipSegments.HideSkipPromptAt) {
+ skipButton.classList.remove("hide");
+ return;
+ }
+
+ skipButton.classList.add("hide");
+}
+
+/** Seeks to the end of the intro. */
+function skipIntro(e) {
+ d("Skipping intro");
+ d(nowPlayingItemSkipSegments);
+ videoPlayer.currentTime = nowPlayingItemSkipSegments.IntroEnd;
+}
+
+/** Looks up the ID of the currently playing item. */
+async function getNowPlayingItemId() {
+ d("Looking up ID of currently playing item");
+
+ let id = await ApiClient.getCurrentUserId();
+
+ let sessions = await ApiClient.getSessions();
+ let filtered = sessions.filter(x => (x.UserId === id) && x.NowPlayingItem);
+
+ d("Filtered " + sessions.length + " sessions down to " + filtered.length);
+
+ return filtered[0].NowPlayingItem.Id;
+}
+
+/** Tests if an element with the provided selector exists. */
+function testElement(selector) { return document.querySelector(selector); }
+
+setup();
diff --git a/ConfusedPolarBear.Plugin.IntroSkipper/ConfusedPolarBear.Plugin.IntroSkipper.csproj b/ConfusedPolarBear.Plugin.IntroSkipper/ConfusedPolarBear.Plugin.IntroSkipper.csproj
index 9ff36b5..a50c359 100644
--- a/ConfusedPolarBear.Plugin.IntroSkipper/ConfusedPolarBear.Plugin.IntroSkipper.csproj
+++ b/ConfusedPolarBear.Plugin.IntroSkipper/ConfusedPolarBear.Plugin.IntroSkipper.csproj
@@ -24,6 +24,7 @@
+
diff --git a/ConfusedPolarBear.Plugin.IntroSkipper/Controllers/SkipIntroController.cs b/ConfusedPolarBear.Plugin.IntroSkipper/Controllers/SkipIntroController.cs
index d6c97f6..3c89ea4 100644
--- a/ConfusedPolarBear.Plugin.IntroSkipper/Controllers/SkipIntroController.cs
+++ b/ConfusedPolarBear.Plugin.IntroSkipper/Controllers/SkipIntroController.cs
@@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.Net.Mime;
+using ConfusedPolarBear.Plugin.IntroSkipper.Configuration;
using MediaBrowser.Controller.Entities.TV;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
@@ -104,4 +105,16 @@ public class SkipIntroController : ControllerBase
return intros;
}
+
+ ///
+ /// Gets the user interface configuration.
+ ///
+ /// UserInterfaceConfiguration returned.
+ /// UserInterfaceConfiguration.
+ [Route("Intros/UserInterfaceConfiguration")]
+ public ActionResult GetUserInterfaceConfiguration()
+ {
+ var config = Plugin.Instance!.Configuration;
+ return new UserInterfaceConfiguration(config.SkipButtonVisible, config.SkipButtonText);
+ }
}
diff --git a/ConfusedPolarBear.Plugin.IntroSkipper/Plugin.cs b/ConfusedPolarBear.Plugin.IntroSkipper/Plugin.cs
index 94604d9..3c565e0 100644
--- a/ConfusedPolarBear.Plugin.IntroSkipper/Plugin.cs
+++ b/ConfusedPolarBear.Plugin.IntroSkipper/Plugin.cs
@@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.IO;
+using System.Text.RegularExpressions;
using ConfusedPolarBear.Plugin.IntroSkipper.Configuration;
using MediaBrowser.Common.Configuration;
using MediaBrowser.Common.Plugins;
@@ -47,9 +48,11 @@ public class Plugin : BasePlugin, IHasWebPages
_libraryManager = libraryManager;
_logger = logger;
- FingerprintCachePath = Path.Join(applicationPaths.PluginConfigurationsPath, "intros", "cache");
FFmpegPath = serverConfiguration.GetEncodingOptions().EncoderAppPathDisplay;
- _introPath = Path.Join(applicationPaths.PluginConfigurationsPath, "intros", "intros.xml");
+
+ var introsDirectory = Path.Join(applicationPaths.PluginConfigurationsPath, "intros");
+ FingerprintCachePath = Path.Join(introsDirectory, "cache");
+ _introPath = Path.Join(introsDirectory, "intros.xml");
// Create the base & cache directories (if needed).
if (!Directory.Exists(FingerprintCachePath))
@@ -68,6 +71,35 @@ public class Plugin : BasePlugin, IHasWebPages
{
_logger.LogWarning("Unable to load introduction timestamps: {Exception}", ex);
}
+
+ // Inject the skip intro button code into the web interface.
+ var indexPath = Path.Join(applicationPaths.WebPath, "index.html");
+ try
+ {
+ InjectSkipButton(indexPath, Path.Join(introsDirectory, "index-pre-skip-button.html"));
+ }
+ catch (Exception ex)
+ {
+ // WarningManager.SetFlag(PluginWarning.UnableToAddSkipButton);
+
+ if (ex is UnauthorizedAccessException)
+ {
+ var suggestion = OperatingSystem.IsLinux() ?
+ "running `sudo chown jellyfin PATH` (if this is a native installation)" :
+ "changing the permissions of PATH";
+
+ suggestion = suggestion.Replace("PATH", indexPath, StringComparison.Ordinal);
+
+ _logger.LogError(
+ "Failed to add skip button to web interface. Try {Suggestion} and restarting the server. Error: {Error}",
+ suggestion,
+ ex);
+ }
+ else
+ {
+ _logger.LogError("Unknown error encountered while adding skip button: {Error}", ex);
+ }
+ }
}
///
@@ -148,6 +180,29 @@ public class Plugin : BasePlugin, IHasWebPages
}
}
+ ///
+ public IEnumerable GetPages()
+ {
+ return new[]
+ {
+ new PluginPageInfo
+ {
+ Name = this.Name,
+ EmbeddedResourcePath = GetType().Namespace + ".Configuration.configPage.html"
+ },
+ new PluginPageInfo
+ {
+ Name = "visualizer.js",
+ EmbeddedResourcePath = GetType().Namespace + ".Configuration.visualizer.js"
+ },
+ new PluginPageInfo
+ {
+ Name = "skip-intro-button.js",
+ EmbeddedResourcePath = GetType().Namespace + ".Configuration.inject.js"
+ }
+ };
+ }
+
internal BaseItem GetItem(Guid id)
{
return _libraryManager.GetItemById(id);
@@ -176,26 +231,50 @@ public class Plugin : BasePlugin, IHasWebPages
}
}
- ///
- public IEnumerable GetPages()
- {
- return new[]
- {
- new PluginPageInfo
- {
- Name = this.Name,
- EmbeddedResourcePath = GetType().Namespace + ".Configuration.configPage.html"
- },
- new PluginPageInfo
- {
- Name = "visualizer.js",
- EmbeddedResourcePath = GetType().Namespace + ".Configuration.visualizer.js"
- }
- };
- }
-
private void OnConfigurationChanged(object? sender, BasePluginConfiguration e)
{
AutoSkipChanged?.Invoke(this, EventArgs.Empty);
}
+
+ ///
+ /// Inject the skip button script into the web interface.
+ ///
+ /// Full path to index.html.
+ /// Full path to create a backup of index.html at.
+ private void InjectSkipButton(string indexPath, string backupPath)
+ {
+ // Parts of this code are based off of JellyScrub's script injection code.
+ // https://github.com/nicknsy/jellyscrub/blob/4ce806f602988a662cfe3cdbaac35ee8046b7ec4/Nick.Plugin.Jellyscrub/JellyscrubPlugin.cs
+
+ _logger.LogInformation("Adding skip button to {Path}", indexPath);
+
+ _logger.LogDebug("Reading index.html from {Path}", indexPath);
+ var contents = File.ReadAllText(indexPath);
+ _logger.LogDebug("Successfully read index.html");
+
+ var scriptTag = "";
+
+ // Only inject the script tag once
+ if (contents.Contains(scriptTag, StringComparison.OrdinalIgnoreCase))
+ {
+ _logger.LogInformation("Skip button already added");
+ return;
+ }
+
+ // Backup the original version of the web interface
+ _logger.LogInformation("Backing up index.html to {Backup}", backupPath);
+ File.WriteAllText(backupPath, contents);
+
+ // 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.
+ _logger.LogDebug("Injecting script tag");
+ var headEnd = new Regex("", RegexOptions.IgnoreCase);
+ contents = headEnd.Replace(contents, scriptTag + "", 1);
+
+ // Write the modified file contents
+ _logger.LogDebug("Saving modified file");
+ File.WriteAllText(indexPath, contents);
+
+ _logger.LogInformation("Skip intro button successfully added to web interface");
+ }
}