Inject skip button into web interface

Text for the skip button and auto skip message can now be customized

Closes #104, 105
This commit is contained in:
ConfusedPolarBear 2022-11-06 21:20:52 -06:00
parent ce52a0b979
commit f4e84d4d07
9 changed files with 436 additions and 25 deletions

5
ACKNOWLEDGEMENTS.md Normal file
View File

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

View File

@ -163,18 +163,21 @@ public class AutoSkip : IServerEntryPoint
}
// Notify the user that an introduction is being skipped for them.
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;

View File

@ -74,6 +74,11 @@ public class PluginConfiguration : BasePluginConfiguration
// ===== Playback settings =====
/// <summary>
/// Gets or sets a value indicating whether to show the skip intro button.
/// </summary>
public bool SkipButtonVisible { get; set; } = true;
/// <summary>
/// Gets or sets a value indicating whether introductions should be automatically skipped.
/// </summary>
@ -127,4 +132,16 @@ public class PluginConfiguration : BasePluginConfiguration
/// Gets or sets the minimum duration of audio (in seconds) that is considered silent.
/// </summary>
public double SilenceDetectionMinimumDuration { get; set; } = 0.33;
// ===== Localization support =====
/// <summary>
/// Gets or sets the text to display in the Skip Intro button.
/// </summary>
public string SkipButtonText { get; set; } = "Skip Intro";
/// <summary>
/// Gets or sets the notification text sent after automatically skipping an introduction.
/// </summary>
public string AutoSkipNotificationText { get; set; } = "Automatically skipped intro";
}

View File

@ -0,0 +1,28 @@
namespace ConfusedPolarBear.Plugin.IntroSkipper.Configuration;
/// <summary>
/// User interface configuration.
/// </summary>
public class UserInterfaceConfiguration
{
/// <summary>
/// Initializes a new instance of the <see cref="UserInterfaceConfiguration"/> class.
/// </summary>
/// <param name="visible">Skip button visibility.</param>
/// <param name="text">Skip button text.</param>
public UserInterfaceConfiguration(bool visible, string text)
{
SkipButtonVisible = visible;
SkipButtonText = text;
}
/// <summary>
/// Gets or sets a value indicating whether to show the skip intro button.
/// </summary>
public bool SkipButtonVisible { get; set; }
/// <summary>
/// Gets or sets the text to display in the skip intro button.
/// </summary>
public string SkipButtonText { get; set; }
}

View File

@ -194,6 +194,19 @@
<fieldset class="verticalSection-extrabottompadding">
<legend>Playback</legend>
<div class="checkboxContainer checkboxContainer-withDescription">
<label class="emby-checkbox-label">
<input id="SkipButtonVisible" type="checkbox" is="emby-checkbox" />
<span>Show skip intro button</span>
</label>
<div class="fieldDescription">
If checked, a skip button will be displayed at the start of an episode's introduction.
<strong>This setting only applies to the web interface.</strong>
<br />
</div>
</div>
<div class="checkboxContainer checkboxContainer-withDescription">
<label class="emby-checkbox-label">
<input id="AutoSkip" type="checkbox" is="emby-checkbox" />
@ -201,7 +214,8 @@
</label>
<div class="fieldDescription">
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.<br />
</div>
</div>
@ -246,6 +260,30 @@
Seconds of introduction that should be played. Defaults to 2.
</div>
</div>
<details>
<summary>User Interface Customization</summary>
<div class="inputContainer">
<label class="inputLabel" for="SkipButtonText">
Skip intro button text
</label>
<input id="SkipButtonText" type="text" is="emby-input" />
<div class="fieldDescription">
Text to display in the skip intro button.
</div>
</div>
<div class="inputContainer">
<label class="inputLabel" for="AutoSkipNotificationText">
Automatic skip notification message
</label>
<input id="AutoSkipNotificationText" type="text" is="emby-input" />
<div class="fieldDescription">
Message sent to a user after automatically skipping an introduction. Leave blank to skip sending a notification.
</div>
</div>
</details>
</fieldset>
<div>
@ -395,13 +433,16 @@
// internals
"SilenceDetectionMaximumNoise",
"SilenceDetectionMinimumDuration",
"SkipButtonText",
"AutoSkipNotificationText"
]
var booleanConfigurationFields = [
"AnalyzeSeasonZero",
"RegenerateEdlFiles",
"AutoSkip",
"SkipFirstEpisode"
"SkipFirstEpisode",
"SkipButtonVisible",
]
// visualizer elements

View File

@ -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 = `
<button is="paper-icon-button-light" class="btnSkipIntro paper-icon-button-light">
<span id="btnSkipIntroText"></span>
<span class="material-icons skip_next"></span>
</button>
`;
/*
* 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();

View File

@ -24,6 +24,7 @@
<None Remove="Configuration\configPage.html" />
<EmbeddedResource Include="Configuration\configPage.html" />
<EmbeddedResource Include="Configuration\visualizer.js" />
<EmbeddedResource Include="Configuration\inject.js" />
<EmbeddedResource Include="Configuration\version.txt" />
</ItemGroup>

View File

@ -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;
}
/// <summary>
/// Gets the user interface configuration.
/// </summary>
/// <response code="200">UserInterfaceConfiguration returned.</response>
/// <returns>UserInterfaceConfiguration.</returns>
[Route("Intros/UserInterfaceConfiguration")]
public ActionResult<UserInterfaceConfiguration> GetUserInterfaceConfiguration()
{
var config = Plugin.Instance!.Configuration;
return new UserInterfaceConfiguration(config.SkipButtonVisible, config.SkipButtonText);
}
}

View File

@ -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<PluginConfiguration>, 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<PluginConfiguration>, 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);
}
}
}
/// <summary>
@ -148,6 +180,29 @@ public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
}
}
/// <inheritdoc />
public IEnumerable<PluginPageInfo> 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<PluginConfiguration>, IHasWebPages
}
}
/// <inheritdoc />
public IEnumerable<PluginPageInfo> 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);
}
/// <summary>
/// Inject the skip button script into the web interface.
/// </summary>
/// <param name="indexPath">Full path to index.html.</param>
/// <param name="backupPath">Full path to create a backup of index.html at.</param>
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 = "<script src=\"configurationpage?name=skip-intro-button.js\"></script>";
// 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 <head> section.
// A regex is used here to ensure the replacement is only done once.
_logger.LogDebug("Injecting script tag");
var headEnd = new Regex("</head>", RegexOptions.IgnoreCase);
contents = headEnd.Replace(contents, scriptTag + "</head>", 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");
}
}