From f4e84d4d0724e375887b4e158e052d248e48ebbd Mon Sep 17 00:00:00 2001 From: ConfusedPolarBear <33811686+ConfusedPolarBear@users.noreply.github.com> Date: Sun, 6 Nov 2022 21:20:52 -0600 Subject: [PATCH] Inject skip button into web interface Text for the skip button and auto skip message can now be customized Closes #104, 105 --- ACKNOWLEDGEMENTS.md | 5 + .../AutoSkip.cs | 9 +- .../Configuration/PluginConfiguration.cs | 17 ++ .../UserInterfaceConfiguration.cs | 28 +++ .../Configuration/configPage.html | 45 +++- .../Configuration/inject.js | 224 ++++++++++++++++++ ...nfusedPolarBear.Plugin.IntroSkipper.csproj | 1 + .../Controllers/SkipIntroController.cs | 13 + .../Plugin.cs | 119 ++++++++-- 9 files changed, 436 insertions(+), 25 deletions(-) create mode 100644 ACKNOWLEDGEMENTS.md create mode 100644 ConfusedPolarBear.Plugin.IntroSkipper/Configuration/UserInterfaceConfiguration.cs create mode 100644 ConfusedPolarBear.Plugin.IntroSkipper/Configuration/inject.js 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 @@
Playback +
+ + +
+ If checked, a skip button will be displayed at the start of an episode's introduction. + This setting only applies to the web interface. +
+
+
+
- If checked, intros will be automatically skipped. If you access Jellyfin through a reverse proxy, it must be configured to proxy web + If checked, intros will be automatically skipped. If you access Jellyfin through a + reverse proxy, it must be configured to proxy web sockets.
@@ -246,6 +260,30 @@ Seconds of introduction that should be played. Defaults to 2. + +
+ User Interface Customization + +
+ + +
+ Text to display in the skip intro button. +
+
+ +
+ + +
+ Message sent to a user after automatically skipping an introduction. Leave blank to skip sending a notification. +
+
+
@@ -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"); + } }