mirror of
https://github.com/jellyfin/jellyfin-desktop.git
synced 2026-10-26 16:45:24 +00:00
Add experimental support for client plugins.
This commit is contained in:
1 parent
aaa82f16b5
commit
6f2c467bbe
5 files changed
+733
-1
No files matched your search
@@ -131,3 +131,7 @@ If you have problems:
|
||||
Jellyfin Media Player is licensed under GPL v2. See the ``LICENSE`` file.
|
||||
Licenses of dependencies are summarized under ``resources/misc/licenses.txt``.
|
||||
This file can also be printed at runtime when using the ``--licenses`` option.
|
||||
|
||||
## Unofficial Plugin Support
|
||||
|
||||
You can enable experimental support for [Jellyscrub](https://github.com/nicknsy/jellyscrub) and [Skip Intro](https://github.com/ConfusedPolarBear/intro-skipper) in client settings. These are included for convenience only and is not an endorsement or long-term commitment to ensure functionality. See `src/native` for details on what the plugins modify code-wise.
|
||||
@@ -0,0 +1,535 @@
|
||||
class jellyscrubPlugin {
|
||||
constructor({ playbackManager, events }) {
|
||||
this.name = 'Jellyscrub Plugin';
|
||||
this.type = 'input';
|
||||
this.id = 'jellyscrubPlugin';
|
||||
|
||||
(async() => {
|
||||
const api = await window.apiPromise;
|
||||
const enabled = await new Promise(resolve => {
|
||||
api.settings.value('plugins', 'skipintro', resolve);
|
||||
});
|
||||
console.log("JellyScrub Plugin enabled: " + enabled);
|
||||
if (!enabled) return;
|
||||
|
||||
// Copied from https://github.com/nicknsy/jellyscrub/blob/main/Nick.Plugin.Jellyscrub/Api/trickplay.js
|
||||
// Adapted for use in JMP
|
||||
const MANIFEST_ENDPOINT = '/Trickplay/{itemId}/GetManifest';
|
||||
const BIF_ENDPOINT = '/Trickplay/{itemId}/{width}/GetBIF';
|
||||
const RETRY_INTERVAL = 60_000; // ms (1 minute)
|
||||
|
||||
let mediaSourceId = null;
|
||||
let mediaRuntimeTicks = null; // NOT ms -- Microsoft DateTime.Ticks. Must be divided by 10,000.
|
||||
|
||||
let hasFailed = false;
|
||||
let trickplayManifest = null;
|
||||
let trickplayData = null;
|
||||
let currentTrickplayFrame = null;
|
||||
|
||||
let hiddenSliderBubble = null;
|
||||
let customSliderBubble = null;
|
||||
let customThumbImg = null;
|
||||
let customChapterText = null;
|
||||
|
||||
let osdPositionSlider = null;
|
||||
let osdOriginalBubbleHtml = null;
|
||||
let osdGetBubbleHtml = null;
|
||||
let osdGetBubbleHtmlLock = false;
|
||||
|
||||
/*
|
||||
* Utility methods
|
||||
*/
|
||||
|
||||
const LOG_PREFIX = '[jellyscrub] ';
|
||||
|
||||
function debug(msg) {
|
||||
console.debug(LOG_PREFIX + msg);
|
||||
}
|
||||
|
||||
function error(msg) {
|
||||
console.error(LOG_PREFIX + msg);
|
||||
}
|
||||
|
||||
function info(msg) {
|
||||
console.info(LOG_PREFIX + msg);
|
||||
}
|
||||
|
||||
/*
|
||||
* Get config values
|
||||
*/
|
||||
|
||||
// -- ApiClient hasn't loaded by this point... :(
|
||||
// -- Also needs to go in async function
|
||||
//const jellyscrubConfig = await ApiClient.getPluginConfiguration(JELLYSCRUB_GUID);
|
||||
//let STYLE_TRICKPLAY_CONTAINER = jellyscrubConfig.StyleTrickplayContainer ?? true;
|
||||
let STYLE_TRICKPLAY_CONTAINER = true;
|
||||
|
||||
/*
|
||||
* Inject style to be used for slider bubble popup
|
||||
*/
|
||||
|
||||
if (STYLE_TRICKPLAY_CONTAINER) {
|
||||
let jellyscrubStyle = document.createElement('style');
|
||||
jellyscrubStyle.id = 'jellscrubStyle';
|
||||
jellyscrubStyle.textContent += '.chapterThumbContainer {width: 15vw; overflow: hidden;}';
|
||||
jellyscrubStyle.textContent += '.chapterThumb {width: 100%; display: block; height: unset; min-height: unset; min-width: unset;}';
|
||||
jellyscrubStyle.textContent += '.chapterThumbTextContainer {position: relative; background: rgb(38, 38, 38); text-align: center;}';
|
||||
jellyscrubStyle.textContent += '.chapterThumbText {margin: 0; opacity: unset; padding: unset;}';
|
||||
document.body.appendChild(jellyscrubStyle);
|
||||
}
|
||||
|
||||
/*
|
||||
* Monitor current page to be used for trickplay load/unload
|
||||
*/
|
||||
|
||||
let videoPath = 'playback/video/index.html';
|
||||
let previousRoutePath = null;
|
||||
|
||||
document.addEventListener('viewshow', function () {
|
||||
let currentRoutePath = Emby.Page.currentRouteInfo.route.path;
|
||||
|
||||
if (currentRoutePath == videoPath) {
|
||||
loadVideoView();
|
||||
} else if (previousRoutePath == videoPath) {
|
||||
unloadVideoView();
|
||||
}
|
||||
|
||||
previousRoutePath = currentRoutePath;
|
||||
});
|
||||
|
||||
let sliderConfig = { attributeFilter: ['style', 'class'] };
|
||||
let sliderObserver = new MutationObserver(sliderCallback);
|
||||
|
||||
function sliderCallback(mutationList, observer) {
|
||||
if (!customSliderBubble || !trickplayData) return;
|
||||
|
||||
for (const mutation of mutationList) {
|
||||
switch (mutation.attributeName) {
|
||||
case 'style':
|
||||
customSliderBubble.setAttribute('style', mutation.target.getAttribute('style'));
|
||||
break;
|
||||
case 'class':
|
||||
if (mutation.target.classList.contains('hide')) {
|
||||
customSliderBubble.classList.add('hide');
|
||||
} else {
|
||||
customSliderBubble.classList.remove('hide');
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function loadVideoView() {
|
||||
debug('!!!!!!! Loading video view !!!!!!!');
|
||||
|
||||
let slider = document.getElementsByClassName('osdPositionSlider')[0];
|
||||
if (slider) {
|
||||
osdPositionSlider = slider;
|
||||
debug(`Found OSD slider: ${osdPositionSlider}`);
|
||||
|
||||
osdOriginalBubbleHtml = osdPositionSlider.getBubbleHtml;
|
||||
osdGetBubbleHtml = osdOriginalBubbleHtml;
|
||||
|
||||
Object.defineProperty(osdPositionSlider, 'getBubbleHtml', {
|
||||
get() { return osdGetBubbleHtml },
|
||||
set(value) { if (!osdGetBubbleHtmlLock) osdGetBubbleHtml = value; },
|
||||
configurable: true,
|
||||
enumerable: true
|
||||
});
|
||||
|
||||
let bubble = document.getElementsByClassName('sliderBubble')[0];
|
||||
if (bubble) {
|
||||
hiddenSliderBubble = bubble;
|
||||
|
||||
let customBubble = document.createElement('div');
|
||||
customBubble.classList.add('sliderBubble', 'hide');
|
||||
|
||||
let customThumbContainer = document.createElement('div');
|
||||
customThumbContainer.classList.add('chapterThumbContainer');
|
||||
|
||||
customThumbImg = document.createElement('img');
|
||||
customThumbImg.classList.add('chapterThumb');
|
||||
customThumbImg.src = 'data:,';
|
||||
// Fix for custom styles that set radius on EVERYTHING causing weird holes when both img and text container are rounded
|
||||
if (STYLE_TRICKPLAY_CONTAINER) customThumbImg.setAttribute('style', 'border-radius: unset !important;')
|
||||
customThumbContainer.appendChild(customThumbImg);
|
||||
|
||||
let customChapterTextContainer = document.createElement('div');
|
||||
customChapterTextContainer.classList.add('chapterThumbTextContainer');
|
||||
// Fix for custom styles that set radius on EVERYTHING causing weird holes when both img and text container are rounded
|
||||
if (STYLE_TRICKPLAY_CONTAINER) customChapterTextContainer.setAttribute('style', 'border-radius: unset !important;')
|
||||
|
||||
customChapterText = document.createElement('h2');
|
||||
customChapterText.classList.add('chapterThumbText');
|
||||
customChapterText.textContent = '--:--';
|
||||
customChapterTextContainer.appendChild(customChapterText);
|
||||
|
||||
customThumbContainer.appendChild(customChapterTextContainer);
|
||||
customBubble.appendChild(customThumbContainer);
|
||||
customSliderBubble = hiddenSliderBubble.parentElement.appendChild(customBubble);
|
||||
|
||||
sliderObserver.observe(hiddenSliderBubble, sliderConfig);
|
||||
}
|
||||
|
||||
// Main execution will first by triggered by the load video view method, but later (e.g. in the case of TV series)
|
||||
// will be triggered by the playback request interception
|
||||
if (!hasFailed && !trickplayData && mediaSourceId && mediaRuntimeTicks
|
||||
&& osdPositionSlider && hiddenSliderBubble && customSliderBubble) mainScriptExecution();
|
||||
}
|
||||
}
|
||||
|
||||
function unloadVideoView() {
|
||||
debug('!!!!!!! Unloading video view !!!!!!!');
|
||||
|
||||
// Clear old values
|
||||
clearTimeout(mainScriptExecution);
|
||||
|
||||
mediaSourceId = null;
|
||||
mediaRuntimeTicks = null;
|
||||
|
||||
hasFailed = false;
|
||||
trickplayManifest = null;
|
||||
trickplayData = null;
|
||||
currentTrickplayFrame = null;
|
||||
|
||||
hiddenSliderBubble = null;
|
||||
customSliderBubble = null;
|
||||
customThumbImg = null;
|
||||
customChapterText = null;
|
||||
|
||||
osdPositionSlider = null;
|
||||
osdOriginalBubbleHtml = null;
|
||||
osdGetBubbleHtml = null;
|
||||
osdGetBubbleHtmlLock = false;
|
||||
// Clear old values
|
||||
}
|
||||
|
||||
/*
|
||||
* Update mediaSourceId, runtime, and emby auth data
|
||||
*/
|
||||
|
||||
function onPlayback(e, player, state) {
|
||||
if (state.NowPlayingItem) {
|
||||
mediaRuntimeTicks = state.NowPlayingItem.RunTimeTicks;
|
||||
mediaSourceId = state.NowPlayingItem.Id;
|
||||
|
||||
changeCurrentMedia();
|
||||
}
|
||||
};
|
||||
events.on(playbackManager, 'playbackstart', onPlayback);
|
||||
|
||||
function changeCurrentMedia() {
|
||||
// Reset trickplay-related variables
|
||||
hasFailed = false;
|
||||
trickplayManifest = null;
|
||||
trickplayData = null;
|
||||
currentTrickplayFrame = null;
|
||||
|
||||
// Set bubble html back to default
|
||||
if (osdOriginalBubbleHtml) osdGetBubbleHtml = osdOriginalBubbleHtml;
|
||||
osdGetBubbleHtmlLock = false;
|
||||
|
||||
// Main execution will first by triggered by the load video view method, but later (e.g. in the case of TV series)
|
||||
// will be triggered by the playback request interception
|
||||
if (!hasFailed && !trickplayData && mediaSourceId && mediaRuntimeTicks
|
||||
&& osdPositionSlider && hiddenSliderBubble && customSliderBubble) mainScriptExecution();
|
||||
}
|
||||
|
||||
/*
|
||||
* Indexed UInt8Array
|
||||
*/
|
||||
|
||||
function Indexed8Array(buffer) {
|
||||
this.index = 0;
|
||||
this.array = new Uint8Array(buffer);
|
||||
}
|
||||
|
||||
Indexed8Array.prototype.read = function(len) {
|
||||
if (len) {
|
||||
const readData = [];
|
||||
for (let i = 0; i < len; i++) {
|
||||
readData.push(this.array[this.index++]);
|
||||
}
|
||||
|
||||
return readData;
|
||||
} else {
|
||||
return this.array[this.index++];
|
||||
}
|
||||
}
|
||||
|
||||
Indexed8Array.prototype.readArbitraryInt = function(len) {
|
||||
let num = 0;
|
||||
for (let i = 0; i < len; i++) {
|
||||
num += this.read() << (i << 3);
|
||||
}
|
||||
|
||||
return num;
|
||||
}
|
||||
|
||||
Indexed8Array.prototype.readInt32 = function() {
|
||||
return this.readArbitraryInt(4);
|
||||
}
|
||||
|
||||
/*
|
||||
* Code for BIF/Trickplay frames
|
||||
*/
|
||||
|
||||
const BIF_MAGIC_NUMBERS = [0x89, 0x42, 0x49, 0x46, 0x0D, 0x0A, 0x1A, 0x0A];
|
||||
const SUPPORTED_BIF_VERSION = 0;
|
||||
|
||||
function trickplayDecode(buffer) {
|
||||
info(`BIF file size: ${(buffer.byteLength / 1_048_576).toFixed(2)}MB`);
|
||||
|
||||
|
||||
let bifArray = new Indexed8Array(buffer);
|
||||
for (let i = 0; i < BIF_MAGIC_NUMBERS.length; i++) {
|
||||
if (bifArray.read() != BIF_MAGIC_NUMBERS[i]) {
|
||||
error('Attempted to read invalid bif file.');
|
||||
error(buffer);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
let bifVersion = bifArray.readInt32();
|
||||
if (bifVersion != SUPPORTED_BIF_VERSION) {
|
||||
error(`Client only supports BIF v${SUPPORTED_BIF_VERSION} but file is v${bifVersion}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
let bifImgCount = bifArray.readInt32();
|
||||
info(`BIF image count: ${bifImgCount}`);
|
||||
|
||||
let timestampMultiplier = bifArray.readInt32();
|
||||
if (timestampMultiplier == 0) timestampMultiplier = 1000;
|
||||
|
||||
bifArray.read(44); // Reserved
|
||||
|
||||
let bifIndex = [];
|
||||
for (let i = 0; i < bifImgCount; i++) {
|
||||
bifIndex.push({
|
||||
timestamp: bifArray.readInt32(),
|
||||
offset: bifArray.readInt32()
|
||||
});
|
||||
}
|
||||
|
||||
let bifImages = [];
|
||||
let indexEntry;
|
||||
for (let i = 0; i < bifIndex.length; i++) {
|
||||
indexEntry = bifIndex[i];
|
||||
const timestamp = indexEntry.timestamp;
|
||||
const offset = indexEntry.offset;
|
||||
const nextOffset = bifIndex[i + 1] ? bifIndex[i + 1].offset : buffer.length;
|
||||
|
||||
bifImages[timestamp] = buffer.slice(offset, nextOffset);
|
||||
}
|
||||
|
||||
return {
|
||||
version: bifVersion,
|
||||
timestampMultiplier: timestampMultiplier,
|
||||
imageCount: bifImgCount,
|
||||
images: bifImages
|
||||
};
|
||||
}
|
||||
|
||||
function getTrickplayFrame(playerTimestamp, data) {
|
||||
const multiplier = data.timestampMultiplier;
|
||||
const images = data.images;
|
||||
|
||||
const frame = Math.floor(playerTimestamp / multiplier);
|
||||
return images[frame];
|
||||
}
|
||||
|
||||
function getTrickplayFrameUrl(playerTimestamp, data) {
|
||||
let bufferImage = getTrickplayFrame(playerTimestamp, data);
|
||||
|
||||
if (bufferImage) {
|
||||
return URL.createObjectURL(new Blob([bufferImage], {type: 'image/jpeg'}));
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Main script execution -- not actually run first
|
||||
*/
|
||||
|
||||
function manifestLoad() {
|
||||
if (this.status == 200) {
|
||||
if (!this.response) {
|
||||
error(`Received 200 status from manifest endpoint but a null response. (RESPONSE URL: ${this.responseURL})`);
|
||||
hasFailed = true;
|
||||
return;
|
||||
}
|
||||
|
||||
trickplayManifest = this.response;
|
||||
setTimeout(mainScriptExecution, 0); // Hacky way of avoiding using fetch/await by returning then calling function again
|
||||
} else if (this.status == 503) {
|
||||
info(`Received 503 from server -- still generating manifest. Waiting ${RETRY_INTERVAL}ms then retrying...`);
|
||||
setTimeout(mainScriptExecution, RETRY_INTERVAL);
|
||||
} else {
|
||||
debug(`Failed to get manifest file: url ${this.responseURL}, error ${this.status}, ${this.responseText}`)
|
||||
hasFailed = true;
|
||||
}
|
||||
}
|
||||
|
||||
function bifLoad() {
|
||||
if (this.status == 200) {
|
||||
if (!this.response) {
|
||||
error(`Received 200 status from BIF endpoint but a null response. (RESPONSE URL: ${this.responseURL})`);
|
||||
hasFailed = true;
|
||||
return;
|
||||
}
|
||||
|
||||
trickplayData = trickplayDecode(this.response);
|
||||
setTimeout(mainScriptExecution, 0); // Hacky way of avoiding using fetch/await by returning then calling function again
|
||||
} else if (this.status == 503) {
|
||||
info(`Received 503 from server -- still generating BIF. Waiting ${RETRY_INTERVAL}ms then retrying...`);
|
||||
setTimeout(mainScriptExecution, RETRY_INTERVAL);
|
||||
} else {
|
||||
if (this.status == 404) error('Requested BIF file listed in manifest but server returned 404 not found.');
|
||||
|
||||
debug(`Failed to get BIF file: url ${this.responseURL}, error ${this.status}, ${this.responseText}`)
|
||||
hasFailed = true;
|
||||
}
|
||||
}
|
||||
|
||||
function getServerUrl() {
|
||||
const apiClient = window.ApiClient;
|
||||
return apiClient.serverAddress();
|
||||
}
|
||||
|
||||
function assignAuth(request) {
|
||||
const apiClient = window.ApiClient;
|
||||
const address = apiClient.serverAddress();
|
||||
|
||||
|
||||
request.setRequestHeader('Authorization', `MediaBrowser Token=${apiClient.accessToken()}`);
|
||||
}
|
||||
|
||||
function mainScriptExecution() {
|
||||
// Get trickplay manifest file
|
||||
if (!trickplayManifest) {
|
||||
let manifestUrl = getServerUrl() + MANIFEST_ENDPOINT.replace('{itemId}', mediaSourceId);
|
||||
console.log(manifestUrl)
|
||||
let manifestRequest = new XMLHttpRequest();
|
||||
manifestRequest.responseType = 'json';
|
||||
manifestRequest.addEventListener('load', manifestLoad);
|
||||
|
||||
manifestRequest.open('GET', manifestUrl);
|
||||
assignAuth(manifestRequest);
|
||||
|
||||
debug(`Requesting Manifest @ ${manifestUrl}`);
|
||||
manifestRequest.send();
|
||||
return;
|
||||
}
|
||||
|
||||
// Get trickplay BIF file
|
||||
if (!trickplayData && trickplayManifest) {
|
||||
// Determine which width to use
|
||||
// Prefer highest resolution @ less than 20% of total screen resolution width
|
||||
let resolutions = trickplayManifest.WidthResolutions;
|
||||
|
||||
if (resolutions && resolutions.length > 0)
|
||||
{
|
||||
resolutions.sort();
|
||||
let screenWidth = window.screen.width * window.devicePixelRatio;
|
||||
let width = resolutions[0];
|
||||
|
||||
// Prefer bigger trickplay images granted they are less than or equal to 20% of total screen width
|
||||
for (let i = 1; i < resolutions.length; i++)
|
||||
{
|
||||
let biggerWidth = resolutions[i];
|
||||
if (biggerWidth <= (screenWidth * .2)) width = biggerWidth;
|
||||
}
|
||||
info(`Requesting BIF file with width ${width}`);
|
||||
|
||||
let bifUrl = getServerUrl() + BIF_ENDPOINT.replace('{itemId}', mediaSourceId).replace('{width}', width);
|
||||
let bifRequest = new XMLHttpRequest();
|
||||
bifRequest.responseType = 'arraybuffer';
|
||||
bifRequest.addEventListener('load', bifLoad);
|
||||
|
||||
bifRequest.open('GET', bifUrl);
|
||||
assignAuth(bifRequest);
|
||||
|
||||
debug(`Requesting BIF @ ${bifUrl}`);
|
||||
bifRequest.send();
|
||||
return;
|
||||
} else {
|
||||
error(`Have manifest file with no listed resolutions: ${trickplayManifest}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Set the bubble function to our custom trickplay one
|
||||
if (trickplayData) {
|
||||
osdPositionSlider.getBubbleHtml = getBubbleHtmlTrickplay;
|
||||
osdGetBubbleHtmlLock = true;
|
||||
}
|
||||
}
|
||||
|
||||
function getBubbleHtmlTrickplay(sliderValue) {
|
||||
//showOsd();
|
||||
|
||||
let currentTicks = mediaRuntimeTicks * (sliderValue / 100);
|
||||
let currentTimeMs = currentTicks / 10_000
|
||||
let imageSrc = getTrickplayFrameUrl(currentTimeMs, trickplayData);
|
||||
|
||||
if (imageSrc) {
|
||||
if (currentTrickplayFrame) URL.revokeObjectURL(currentTrickplayFrame);
|
||||
currentTrickplayFrame = imageSrc;
|
||||
|
||||
customThumbImg.src = imageSrc;
|
||||
customChapterText.textContent = getDisplayRunningTime(currentTicks);
|
||||
}
|
||||
|
||||
return `<div style="min-width: ${customSliderBubble.offsetWidth}px; max-height: 0px"></div>`;
|
||||
}
|
||||
|
||||
// Not the same, but should be functionally equaivalent to --
|
||||
// https://github.com/jellyfin/jellyfin-web/blob/8ff9d63e25b40575e02fe638491259c480c89ba5/src/controllers/playback/video/index.js#L237
|
||||
/*
|
||||
function showOsd() {
|
||||
//document.getElementsByClassName('skinHeader')[0]?.classList.remove('osdHeader-hidden');
|
||||
// todo: actually can't be bothered so I'll wait and see if it works without it or not
|
||||
}
|
||||
*/
|
||||
|
||||
// Taken from https://github.com/jellyfin/jellyfin-web/blob/8ff9d63e25b40575e02fe638491259c480c89ba5/src/scripts/datetime.js#L76
|
||||
function getDisplayRunningTime(ticks) {
|
||||
const ticksPerHour = 36000000000;
|
||||
const ticksPerMinute = 600000000;
|
||||
const ticksPerSecond = 10000000;
|
||||
|
||||
const parts = [];
|
||||
|
||||
let hours = ticks / ticksPerHour;
|
||||
hours = Math.floor(hours);
|
||||
|
||||
if (hours) {
|
||||
parts.push(hours);
|
||||
}
|
||||
|
||||
ticks -= (hours * ticksPerHour);
|
||||
|
||||
let minutes = ticks / ticksPerMinute;
|
||||
minutes = Math.floor(minutes);
|
||||
|
||||
ticks -= (minutes * ticksPerMinute);
|
||||
|
||||
if (minutes < 10 && hours) {
|
||||
minutes = '0' + minutes;
|
||||
}
|
||||
parts.push(minutes);
|
||||
|
||||
let seconds = ticks / ticksPerSecond;
|
||||
seconds = Math.floor(seconds);
|
||||
|
||||
if (seconds < 10) {
|
||||
seconds = '0' + seconds;
|
||||
}
|
||||
parts.push(seconds);
|
||||
|
||||
return parts.join(':');
|
||||
}
|
||||
})();
|
||||
}
|
||||
}
|
||||
|
||||
window._jellyscrubPlugin = jellyscrubPlugin;
|
||||
@@ -22,7 +22,9 @@ const plugins = [
|
||||
'mpvVideoPlayer',
|
||||
'mpvAudioPlayer',
|
||||
'jmpInputPlugin',
|
||||
'jmpUpdatePlugin'
|
||||
'jmpUpdatePlugin',
|
||||
'jellyscrubPlugin',
|
||||
'skipIntroPlugin'
|
||||
];
|
||||
|
||||
function loadScript(src) {
|
||||
@@ -195,6 +197,11 @@ async function showSettingsModal() {
|
||||
legendHeader.textContent = section.key;
|
||||
legendHeader.style.textTransform = "capitalize";
|
||||
legend.appendChild(legendHeader);
|
||||
if (section.key == "plugins") {
|
||||
const legendSubHeader = document.createElement("h4");
|
||||
legendSubHeader.textContent = "Plugins are UNOFFICIAL and require a restart to take effect.";
|
||||
legend.appendChild(legendSubHeader);
|
||||
}
|
||||
group.appendChild(legend);
|
||||
|
||||
for (const setting of section.settings) {
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
let tvIntro;
|
||||
|
||||
class skipIntroPlugin {
|
||||
constructor({ events, playbackManager }) {
|
||||
this.name = 'Skip Intro Plugin';
|
||||
this.type = 'input';
|
||||
this.id = 'skipIntroPlugin';
|
||||
|
||||
(async() => {
|
||||
const api = await window.apiPromise;
|
||||
const enabled = await new Promise(resolve => {
|
||||
api.settings.value('plugins', 'skipintro', resolve);
|
||||
});
|
||||
console.log("Skip Intro Plugin enabled: " + enabled);
|
||||
if (!enabled) return;
|
||||
|
||||
// Based on https://github.com/jellyfin/jellyfin-web/compare/release-10.8.z...ConfusedPolarBear:jellyfin-web:intros
|
||||
// Adapted for use in JMP
|
||||
const stylesheet = `
|
||||
<style>
|
||||
@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 {
|
||||
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;
|
||||
}
|
||||
</style>
|
||||
`;
|
||||
|
||||
document.head.insertAdjacentHTML('beforeend', stylesheet);
|
||||
|
||||
|
||||
const skipIntroHtml = `
|
||||
<div class="skipIntro hide">
|
||||
<button is="paper-icon-button-light" class="btnSkipIntro paper-icon-button-light">
|
||||
Skip Intro
|
||||
<span class="material-icons skip_next"></span>
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
function waitForElement(element, maxWait = 10000) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const interval = setInterval(() => {
|
||||
const result = document.querySelector(element);
|
||||
if (result) {
|
||||
clearInterval(interval);
|
||||
resolve(result);
|
||||
}
|
||||
}, 100);
|
||||
|
||||
setTimeout(() => {
|
||||
clearInterval(interval);
|
||||
reject();
|
||||
}, maxWait);
|
||||
});
|
||||
}
|
||||
|
||||
async function injectSkipIntroHtml() {
|
||||
const playerContainer = await waitForElement('.upNextContainer', 5000);
|
||||
// inject only if it doesn't exist
|
||||
if (!document.querySelector('.skipIntro .btnSkipIntro')) {
|
||||
playerContainer.insertAdjacentHTML('afterend', skipIntroHtml);
|
||||
}
|
||||
|
||||
document.querySelector('.skipIntro .btnSkipIntro').addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
skipIntro();
|
||||
}, { useCapture: true });
|
||||
|
||||
if (window.PointerEvent) {
|
||||
document.querySelector('.skipIntro .btnSkipIntro').addEventListener('pointerdown', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}, { useCapture: true });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async function onPlayback(e, player, state) {
|
||||
if (state.NowPlayingItem) {
|
||||
await injectSkipIntroHtml();
|
||||
getIntroTimestamps(state.NowPlayingItem);
|
||||
}
|
||||
|
||||
const onTimeUpdate = () => {
|
||||
// Check if an introduction sequence was detected for this item.
|
||||
if (!tvIntro?.Valid) {
|
||||
return;
|
||||
}
|
||||
|
||||
const seconds = playbackManager.currentTime(player) / 1000;
|
||||
const skipIntro = document.querySelector(".skipIntro");
|
||||
|
||||
// If the skip prompt should be shown, show it.
|
||||
if (seconds >= tvIntro.ShowSkipPromptAt && seconds < tvIntro.HideSkipPromptAt) {
|
||||
skipIntro.classList.remove("hide");
|
||||
return;
|
||||
}
|
||||
|
||||
skipIntro.classList.add("hide");
|
||||
};
|
||||
|
||||
events.on(player, 'timeupdate', onTimeUpdate);
|
||||
|
||||
events.on(player, 'playbackstop', () => {
|
||||
events.off(player, 'timeupdate', onTimeUpdate);
|
||||
});
|
||||
};
|
||||
events.on(playbackManager, 'playbackstart', onPlayback);
|
||||
|
||||
|
||||
function getIntroTimestamps(item) {
|
||||
const apiClient = window.ApiClient;
|
||||
const address = apiClient.serverAddress();
|
||||
|
||||
const url = `${address}/Episode/${item.Id}/IntroTimestamps`;
|
||||
const reqInit = {
|
||||
headers: {
|
||||
"Authorization": `MediaBrowser Token=${apiClient.accessToken()}`
|
||||
}
|
||||
};
|
||||
|
||||
fetch(url, reqInit).then(r => {
|
||||
if (!r.ok) {
|
||||
tvIntro = null;
|
||||
return;
|
||||
}
|
||||
|
||||
return r.json();
|
||||
}).then(intro => {
|
||||
tvIntro = intro;
|
||||
}).catch(err => { tvIntro = null; });
|
||||
}
|
||||
|
||||
function skipIntro() {
|
||||
playbackManager.seekMs(tvIntro.IntroEnd * 1000);
|
||||
}
|
||||
})();
|
||||
}
|
||||
}
|
||||
|
||||
window._skipIntroPlugin = skipIntroPlugin;
|
||||
@@ -131,6 +131,19 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"section": "plugins",
|
||||
"values": [
|
||||
{
|
||||
"value": "skipintro",
|
||||
"default": false
|
||||
},
|
||||
{
|
||||
"value": "jellyscrub",
|
||||
"default": false
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"section": "audio",
|
||||
"values": [
|
||||
|
||||
Reference in new issue
Block a user