Add experimental support for client plugins.

This commit is contained in:
Ian Walton committed 2023-02-12 17:09:33 -05:00
1 parent aaa82f16b5
commit 6f2c467bbe
5 files changed
+733 -1

No files matched your search

+4
View File
@@ -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.
+535
View File
@@ -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;
+8 -1
View File
@@ -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) {
+173
View File
@@ -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": [