jellyscrubPlugin.js 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539
  1. class jellyscrubPlugin {
  2. constructor({ playbackManager, events, ServerConnections }) {
  3. this.name = 'Jellyscrub Plugin';
  4. this.type = 'input';
  5. this.id = 'jellyscrubPlugin';
  6. (async() => {
  7. await window.initCompleted;
  8. const enabled = window.jmpInfo.settings.plugins.jellyscrub;
  9. console.log("JellyScrub Plugin enabled: " + enabled);
  10. if (!enabled) return;
  11. // Copied from https://github.com/nicknsy/jellyscrub/blob/main/Nick.Plugin.Jellyscrub/Api/trickplay.js
  12. // Adapted for use in JMP
  13. const MANIFEST_ENDPOINT = '/Trickplay/{itemId}/GetManifest';
  14. const BIF_ENDPOINT = '/Trickplay/{itemId}/{width}/GetBIF';
  15. const RETRY_INTERVAL = 60_000; // ms (1 minute)
  16. let mediaSourceId = null;
  17. let mediaRuntimeTicks = null; // NOT ms -- Microsoft DateTime.Ticks. Must be divided by 10,000.
  18. let hasFailed = false;
  19. let trickplayManifest = null;
  20. let trickplayData = null;
  21. let currentTrickplayFrame = null;
  22. let hiddenSliderBubble = null;
  23. let customSliderBubble = null;
  24. let customThumbImg = null;
  25. let customChapterText = null;
  26. let osdPositionSlider = null;
  27. let osdOriginalBubbleHtml = null;
  28. let osdGetBubbleHtml = null;
  29. let osdGetBubbleHtmlLock = false;
  30. /*
  31. * Utility methods
  32. */
  33. const LOG_PREFIX = '[jellyscrub] ';
  34. function debug(msg) {
  35. console.debug(LOG_PREFIX + msg);
  36. }
  37. function error(msg) {
  38. console.error(LOG_PREFIX + msg);
  39. }
  40. function info(msg) {
  41. console.info(LOG_PREFIX + msg);
  42. }
  43. /*
  44. * Get config values
  45. */
  46. // -- ApiClient hasn't loaded by this point... :(
  47. // -- Also needs to go in async function
  48. //const jellyscrubConfig = await ApiClient.getPluginConfiguration(JELLYSCRUB_GUID);
  49. //let STYLE_TRICKPLAY_CONTAINER = jellyscrubConfig.StyleTrickplayContainer ?? true;
  50. let STYLE_TRICKPLAY_CONTAINER = true;
  51. /*
  52. * Inject style to be used for slider bubble popup
  53. */
  54. if (STYLE_TRICKPLAY_CONTAINER) {
  55. let jellyscrubStyle = document.createElement('style');
  56. jellyscrubStyle.id = 'jellscrubStyle';
  57. jellyscrubStyle.textContent += '.chapterThumbContainer {width: 15vw; overflow: hidden;}';
  58. jellyscrubStyle.textContent += '.chapterThumb {width: 100%; display: block; height: unset; min-height: unset; min-width: unset;}';
  59. jellyscrubStyle.textContent += '.chapterThumbTextContainer {position: relative; background: rgb(38, 38, 38); text-align: center;}';
  60. jellyscrubStyle.textContent += '.chapterThumbText {margin: 0; opacity: unset; padding: unset;}';
  61. document.body.appendChild(jellyscrubStyle);
  62. }
  63. /*
  64. * Monitor current page to be used for trickplay load/unload
  65. */
  66. let videoPath = 'playback/video/index.html';
  67. let previousRoutePath = null;
  68. document.addEventListener('viewshow', function () {
  69. let currentRoutePath = Emby.Page.currentRouteInfo.route.path;
  70. if (currentRoutePath == videoPath) {
  71. loadVideoView();
  72. } else if (previousRoutePath == videoPath) {
  73. unloadVideoView();
  74. }
  75. previousRoutePath = currentRoutePath;
  76. });
  77. let sliderConfig = { attributeFilter: ['style', 'class'] };
  78. let sliderObserver = new MutationObserver(sliderCallback);
  79. function sliderCallback(mutationList, observer) {
  80. if (!customSliderBubble || !trickplayData) return;
  81. for (const mutation of mutationList) {
  82. switch (mutation.attributeName) {
  83. case 'style':
  84. customSliderBubble.setAttribute('style', mutation.target.getAttribute('style'));
  85. break;
  86. case 'class':
  87. if (mutation.target.classList.contains('hide')) {
  88. customSliderBubble.classList.add('hide');
  89. } else {
  90. customSliderBubble.classList.remove('hide');
  91. }
  92. break;
  93. }
  94. }
  95. }
  96. function loadVideoView() {
  97. debug('!!!!!!! Loading video view !!!!!!!');
  98. let slider = document.getElementsByClassName('osdPositionSlider')[0];
  99. if (slider) {
  100. osdPositionSlider = slider;
  101. debug(`Found OSD slider: ${osdPositionSlider}`);
  102. osdOriginalBubbleHtml = osdPositionSlider.getBubbleHtml;
  103. osdGetBubbleHtml = osdOriginalBubbleHtml;
  104. Object.defineProperty(osdPositionSlider, 'getBubbleHtml', {
  105. get() { return osdGetBubbleHtml },
  106. set(value) { if (!osdGetBubbleHtmlLock) osdGetBubbleHtml = value; },
  107. configurable: true,
  108. enumerable: true
  109. });
  110. let bubble = document.getElementsByClassName('sliderBubble')[0];
  111. if (bubble) {
  112. hiddenSliderBubble = bubble;
  113. let customBubble = document.createElement('div');
  114. customBubble.classList.add('sliderBubble', 'hide');
  115. let customThumbContainer = document.createElement('div');
  116. customThumbContainer.classList.add('chapterThumbContainer');
  117. customThumbImg = document.createElement('img');
  118. customThumbImg.classList.add('chapterThumb');
  119. customThumbImg.src = 'data:,';
  120. // Fix for custom styles that set radius on EVERYTHING causing weird holes when both img and text container are rounded
  121. if (STYLE_TRICKPLAY_CONTAINER) customThumbImg.setAttribute('style', 'border-radius: unset !important;')
  122. customThumbContainer.appendChild(customThumbImg);
  123. let customChapterTextContainer = document.createElement('div');
  124. customChapterTextContainer.classList.add('chapterThumbTextContainer');
  125. // Fix for custom styles that set radius on EVERYTHING causing weird holes when both img and text container are rounded
  126. if (STYLE_TRICKPLAY_CONTAINER) customChapterTextContainer.setAttribute('style', 'border-radius: unset !important;')
  127. customChapterText = document.createElement('h2');
  128. customChapterText.classList.add('chapterThumbText');
  129. customChapterText.textContent = '--:--';
  130. customChapterTextContainer.appendChild(customChapterText);
  131. customThumbContainer.appendChild(customChapterTextContainer);
  132. customBubble.appendChild(customThumbContainer);
  133. customSliderBubble = hiddenSliderBubble.parentElement.appendChild(customBubble);
  134. sliderObserver.observe(hiddenSliderBubble, sliderConfig);
  135. }
  136. // Main execution will first by triggered by the load video view method, but later (e.g. in the case of TV series)
  137. // will be triggered by the playback request interception
  138. if (!hasFailed && !trickplayData && mediaSourceId && mediaRuntimeTicks
  139. && osdPositionSlider && hiddenSliderBubble && customSliderBubble) mainScriptExecution();
  140. }
  141. }
  142. function unloadVideoView() {
  143. debug('!!!!!!! Unloading video view !!!!!!!');
  144. // Clear old values
  145. clearTimeout(mainScriptExecution);
  146. mediaSourceId = null;
  147. mediaRuntimeTicks = null;
  148. hasFailed = false;
  149. trickplayManifest = null;
  150. trickplayData = null;
  151. currentTrickplayFrame = null;
  152. hiddenSliderBubble = null;
  153. customSliderBubble = null;
  154. customThumbImg = null;
  155. customChapterText = null;
  156. osdPositionSlider = null;
  157. osdOriginalBubbleHtml = null;
  158. osdGetBubbleHtml = null;
  159. osdGetBubbleHtmlLock = false;
  160. // Clear old values
  161. }
  162. /*
  163. * Update mediaSourceId, runtime, and emby auth data
  164. */
  165. function onPlayback(e, player, state) {
  166. if (state.NowPlayingItem) {
  167. mediaRuntimeTicks = state.NowPlayingItem.RunTimeTicks;
  168. mediaSourceId = state.NowPlayingItem.Id;
  169. changeCurrentMedia();
  170. }
  171. };
  172. events.on(playbackManager, 'playbackstart', onPlayback);
  173. function changeCurrentMedia() {
  174. // Reset trickplay-related variables
  175. hasFailed = false;
  176. trickplayManifest = null;
  177. trickplayData = null;
  178. currentTrickplayFrame = null;
  179. // Set bubble html back to default
  180. if (osdOriginalBubbleHtml) osdGetBubbleHtml = osdOriginalBubbleHtml;
  181. osdGetBubbleHtmlLock = false;
  182. // Main execution will first by triggered by the load video view method, but later (e.g. in the case of TV series)
  183. // will be triggered by the playback request interception
  184. if (!hasFailed && !trickplayData && mediaSourceId && mediaRuntimeTicks
  185. && osdPositionSlider && hiddenSliderBubble && customSliderBubble) mainScriptExecution();
  186. }
  187. /*
  188. * Indexed UInt8Array
  189. */
  190. function Indexed8Array(buffer) {
  191. this.index = 0;
  192. this.array = new Uint8Array(buffer);
  193. }
  194. Indexed8Array.prototype.read = function(len) {
  195. if (len) {
  196. const readData = [];
  197. for (let i = 0; i < len; i++) {
  198. readData.push(this.array[this.index++]);
  199. }
  200. return readData;
  201. } else {
  202. return this.array[this.index++];
  203. }
  204. }
  205. Indexed8Array.prototype.readArbitraryInt = function(len) {
  206. let num = 0;
  207. for (let i = 0; i < len; i++) {
  208. num += this.read() << (i << 3);
  209. }
  210. return num;
  211. }
  212. Indexed8Array.prototype.readInt32 = function() {
  213. return this.readArbitraryInt(4);
  214. }
  215. /*
  216. * Code for BIF/Trickplay frames
  217. */
  218. const BIF_MAGIC_NUMBERS = [0x89, 0x42, 0x49, 0x46, 0x0D, 0x0A, 0x1A, 0x0A];
  219. const SUPPORTED_BIF_VERSION = 0;
  220. function trickplayDecode(buffer) {
  221. info(`BIF file size: ${(buffer.byteLength / 1_048_576).toFixed(2)}MB`);
  222. let bifArray = new Indexed8Array(buffer);
  223. for (let i = 0; i < BIF_MAGIC_NUMBERS.length; i++) {
  224. if (bifArray.read() != BIF_MAGIC_NUMBERS[i]) {
  225. error('Attempted to read invalid bif file.');
  226. error(buffer);
  227. return null;
  228. }
  229. }
  230. let bifVersion = bifArray.readInt32();
  231. if (bifVersion != SUPPORTED_BIF_VERSION) {
  232. error(`Client only supports BIF v${SUPPORTED_BIF_VERSION} but file is v${bifVersion}`);
  233. return null;
  234. }
  235. let bifImgCount = bifArray.readInt32();
  236. info(`BIF image count: ${bifImgCount}`);
  237. let timestampMultiplier = bifArray.readInt32();
  238. if (timestampMultiplier == 0) timestampMultiplier = 1000;
  239. bifArray.read(44); // Reserved
  240. let bifIndex = [];
  241. for (let i = 0; i < bifImgCount; i++) {
  242. bifIndex.push({
  243. timestamp: bifArray.readInt32(),
  244. offset: bifArray.readInt32()
  245. });
  246. }
  247. let bifImages = [];
  248. let indexEntry;
  249. for (let i = 0; i < bifIndex.length; i++) {
  250. indexEntry = bifIndex[i];
  251. const timestamp = indexEntry.timestamp;
  252. const offset = indexEntry.offset;
  253. const nextOffset = bifIndex[i + 1] ? bifIndex[i + 1].offset : buffer.length;
  254. bifImages[timestamp] = buffer.slice(offset, nextOffset);
  255. }
  256. return {
  257. version: bifVersion,
  258. timestampMultiplier: timestampMultiplier,
  259. imageCount: bifImgCount,
  260. images: bifImages
  261. };
  262. }
  263. function getTrickplayFrame(playerTimestamp, data) {
  264. const multiplier = data.timestampMultiplier;
  265. const images = data.images;
  266. const frame = Math.floor(playerTimestamp / multiplier);
  267. return images[frame];
  268. }
  269. function getTrickplayFrameUrl(playerTimestamp, data) {
  270. let bufferImage = getTrickplayFrame(playerTimestamp, data);
  271. if (bufferImage) {
  272. return URL.createObjectURL(new Blob([bufferImage], {type: 'image/jpeg'}));
  273. }
  274. }
  275. /*
  276. * Main script execution -- not actually run first
  277. */
  278. function manifestLoad() {
  279. if (this.status == 200) {
  280. if (!this.response) {
  281. error(`Received 200 status from manifest endpoint but a null response. (RESPONSE URL: ${this.responseURL})`);
  282. hasFailed = true;
  283. return;
  284. }
  285. trickplayManifest = this.response;
  286. setTimeout(mainScriptExecution, 0); // Hacky way of avoiding using fetch/await by returning then calling function again
  287. } else if (this.status == 503) {
  288. info(`Received 503 from server -- still generating manifest. Waiting ${RETRY_INTERVAL}ms then retrying...`);
  289. setTimeout(mainScriptExecution, RETRY_INTERVAL);
  290. } else {
  291. debug(`Failed to get manifest file: url ${this.responseURL}, error ${this.status}, ${this.responseText}`)
  292. hasFailed = true;
  293. }
  294. }
  295. function bifLoad() {
  296. if (this.status == 200) {
  297. if (!this.response) {
  298. error(`Received 200 status from BIF endpoint but a null response. (RESPONSE URL: ${this.responseURL})`);
  299. hasFailed = true;
  300. return;
  301. }
  302. trickplayData = trickplayDecode(this.response);
  303. setTimeout(mainScriptExecution, 0); // Hacky way of avoiding using fetch/await by returning then calling function again
  304. } else if (this.status == 503) {
  305. info(`Received 503 from server -- still generating BIF. Waiting ${RETRY_INTERVAL}ms then retrying...`);
  306. setTimeout(mainScriptExecution, RETRY_INTERVAL);
  307. } else {
  308. if (this.status == 404) error('Requested BIF file listed in manifest but server returned 404 not found.');
  309. debug(`Failed to get BIF file: url ${this.responseURL}, error ${this.status}, ${this.responseText}`)
  310. hasFailed = true;
  311. }
  312. }
  313. function getServerUrl() {
  314. const apiClient = ServerConnections
  315. ? ServerConnections.currentApiClient()
  316. : window.ApiClient;
  317. return apiClient.serverAddress();
  318. }
  319. function assignAuth(request) {
  320. const apiClient = ServerConnections
  321. ? ServerConnections.currentApiClient()
  322. : window.ApiClient;
  323. const address = apiClient.serverAddress();
  324. request.setRequestHeader('Authorization', `MediaBrowser Token=${apiClient.accessToken()}`);
  325. }
  326. function mainScriptExecution() {
  327. // Get trickplay manifest file
  328. if (!trickplayManifest) {
  329. let manifestUrl = getServerUrl() + MANIFEST_ENDPOINT.replace('{itemId}', mediaSourceId);
  330. console.log(manifestUrl)
  331. let manifestRequest = new XMLHttpRequest();
  332. manifestRequest.responseType = 'json';
  333. manifestRequest.addEventListener('load', manifestLoad);
  334. manifestRequest.open('GET', manifestUrl);
  335. assignAuth(manifestRequest);
  336. debug(`Requesting Manifest @ ${manifestUrl}`);
  337. manifestRequest.send();
  338. return;
  339. }
  340. // Get trickplay BIF file
  341. if (!trickplayData && trickplayManifest) {
  342. // Determine which width to use
  343. // Prefer highest resolution @ less than 20% of total screen resolution width
  344. let resolutions = trickplayManifest.WidthResolutions;
  345. if (resolutions && resolutions.length > 0)
  346. {
  347. resolutions.sort();
  348. let screenWidth = window.screen.width * window.devicePixelRatio;
  349. let width = resolutions[0];
  350. // Prefer bigger trickplay images granted they are less than or equal to 20% of total screen width
  351. for (let i = 1; i < resolutions.length; i++)
  352. {
  353. let biggerWidth = resolutions[i];
  354. if (biggerWidth <= (screenWidth * .2)) width = biggerWidth;
  355. }
  356. info(`Requesting BIF file with width ${width}`);
  357. let bifUrl = getServerUrl() + BIF_ENDPOINT.replace('{itemId}', mediaSourceId).replace('{width}', width);
  358. let bifRequest = new XMLHttpRequest();
  359. bifRequest.responseType = 'arraybuffer';
  360. bifRequest.addEventListener('load', bifLoad);
  361. bifRequest.open('GET', bifUrl);
  362. assignAuth(bifRequest);
  363. debug(`Requesting BIF @ ${bifUrl}`);
  364. bifRequest.send();
  365. return;
  366. } else {
  367. error(`Have manifest file with no listed resolutions: ${trickplayManifest}`);
  368. }
  369. }
  370. // Set the bubble function to our custom trickplay one
  371. if (trickplayData) {
  372. osdPositionSlider.getBubbleHtml = getBubbleHtmlTrickplay;
  373. osdGetBubbleHtmlLock = true;
  374. }
  375. }
  376. function getBubbleHtmlTrickplay(sliderValue) {
  377. //showOsd();
  378. let currentTicks = mediaRuntimeTicks * (sliderValue / 100);
  379. let currentTimeMs = currentTicks / 10_000
  380. let imageSrc = getTrickplayFrameUrl(currentTimeMs, trickplayData);
  381. if (imageSrc) {
  382. if (currentTrickplayFrame) URL.revokeObjectURL(currentTrickplayFrame);
  383. currentTrickplayFrame = imageSrc;
  384. customThumbImg.src = imageSrc;
  385. customChapterText.textContent = getDisplayRunningTime(currentTicks);
  386. }
  387. return `<div style="min-width: ${customSliderBubble.offsetWidth}px; max-height: 0px"></div>`;
  388. }
  389. // Not the same, but should be functionally equaivalent to --
  390. // https://github.com/jellyfin/jellyfin-web/blob/8ff9d63e25b40575e02fe638491259c480c89ba5/src/controllers/playback/video/index.js#L237
  391. /*
  392. function showOsd() {
  393. //document.getElementsByClassName('skinHeader')[0]?.classList.remove('osdHeader-hidden');
  394. // todo: actually can't be bothered so I'll wait and see if it works without it or not
  395. }
  396. */
  397. // Taken from https://github.com/jellyfin/jellyfin-web/blob/8ff9d63e25b40575e02fe638491259c480c89ba5/src/scripts/datetime.js#L76
  398. function getDisplayRunningTime(ticks) {
  399. const ticksPerHour = 36000000000;
  400. const ticksPerMinute = 600000000;
  401. const ticksPerSecond = 10000000;
  402. const parts = [];
  403. let hours = ticks / ticksPerHour;
  404. hours = Math.floor(hours);
  405. if (hours) {
  406. parts.push(hours);
  407. }
  408. ticks -= (hours * ticksPerHour);
  409. let minutes = ticks / ticksPerMinute;
  410. minutes = Math.floor(minutes);
  411. ticks -= (minutes * ticksPerMinute);
  412. if (minutes < 10 && hours) {
  413. minutes = '0' + minutes;
  414. }
  415. parts.push(minutes);
  416. let seconds = ticks / ticksPerSecond;
  417. seconds = Math.floor(seconds);
  418. if (seconds < 10) {
  419. seconds = '0' + seconds;
  420. }
  421. parts.push(seconds);
  422. return parts.join(':');
  423. }
  424. })();
  425. }
  426. }
  427. window._jellyscrubPlugin = jellyscrubPlugin;