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:
parent
ce52a0b979
commit
f4e84d4d07
5
ACKNOWLEDGEMENTS.md
Normal file
5
ACKNOWLEDGEMENTS.md
Normal 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)
|
@ -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;
|
||||
|
@ -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";
|
||||
}
|
||||
|
@ -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; }
|
||||
}
|
@ -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
|
||||
|
224
ConfusedPolarBear.Plugin.IntroSkipper/Configuration/inject.js
Normal file
224
ConfusedPolarBear.Plugin.IntroSkipper/Configuration/inject.js
Normal 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();
|
@ -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>
|
||||
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user