Remove old jellyscrub plugin.

This commit is contained in:
Izzie Walton committed 2024-05-13 17:38:38 -04:00
1 parent fd02677b27
commit 83507a4a9b
5 files changed
+4 -549

No files matched your search

+2 -2
View File
@@ -82,7 +82,7 @@ sudo ldconfig
cd ~/jmp/
git clone https://github.com/jellyfin/jellyfin-media-player.git
cd jellyfin-media-player/
./download_webclient.sh
./download_webclient.sh
cd build/
cmake -DCMAKE_BUILD_TYPE=Debug -DCMAKE_INSTALL_PREFIX=/usr/local/ ..
make -j`nproc`
@@ -185,7 +185,7 @@ 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.
You can enable experimental support for [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.
## Known Issues
+2 -3
View File
@@ -87,7 +87,7 @@ Note: The callback does NOT mean the video actually finished loading. You need t
metadata: {
// Only used for system UI integration, eg. taskbar/windows media.
// Optional. Comes from Jellyfin metadata item.
// Known used items are documented below.
// Known used items are documented below.
Id: 'media uuid',
MediaType: 'video',
ImageTags: {
@@ -158,7 +158,6 @@ Current Settings:
- `userWebClient: string`: Hidden option. Set it back to an empty string to allow the user to select a different webclient path.
- Plugins Section (`plugins`):
- `skipintro: boolean`: Enables or disabled the plugin. You likely won't use this.
- `jellyscrub: boolean`: Enables or disabled the plugin. You likely won't use this.
- Audio Section (`audio`):
- `devicetype: string enum`: Sets the device type. Can be `basic`, `spdif`, or `hdmi`.
- `basic` disables passthrough
@@ -178,7 +177,7 @@ Current Settings:
- `passthrough.dts: boolean`: Enable dts passthrough. Requires `spdif` or `hdmi` device type. Default: `false`
- `passthrough.eac3: boolean`: Enable ac3 passthrough. Requires `hdmi` device type. Default: `false`
- `passthrough.dts-hd": boolean`: Enable ac3 passthrough. Requires `hdmi` device type. Default: `false`
- `passthrough.truehd: boolean`: Enable ac3 passthrough. Requires `hdmi` device type. Default: `false`
- `passthrough.truehd: boolean`: Enable ac3 passthrough. Requires `hdmi` device type. Default: `false`
- Video Section (`video`):
- `refreshrate.auto_switch: boolean`: Sets the display refresh rate to the media refresh rate. Default: `false`
- This is only enabled while the player is fullscreen.
-539
View File
@@ -1,539 +0,0 @@
class jellyscrubPlugin {
constructor({ playbackManager, events, ServerConnections }) {
this.name = 'Jellyscrub Plugin';
this.type = 'input';
this.id = 'jellyscrubPlugin';
(async() => {
await window.initCompleted;
const enabled = window.jmpInfo.settings.plugins.jellyscrub;
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 = ServerConnections
? ServerConnections.currentApiClient()
: window.ApiClient;
return apiClient.serverAddress();
}
function assignAuth(request) {
const apiClient = ServerConnections
? ServerConnections.currentApiClient()
: 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;
-1
View File
@@ -24,7 +24,6 @@ const plugins = [
'mpvAudioPlayer',
'jmpInputPlugin',
'jmpUpdatePlugin',
'jellyscrubPlugin',
'skipIntroPlugin'
];
@@ -161,10 +161,6 @@
{
"value": "skipintro",
"default": false
},
{
"value": "jellyscrub",
"default": false
}
]
},