jellyscrubPlugin.js 23 KB

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