nativeshell.js 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504
  1. const jmpInfo = JSON.parse(window.atob("@@data@@"));
  2. window.jmpInfo = jmpInfo;
  3. const features = [
  4. "filedownload",
  5. "displaylanguage",
  6. "htmlaudioautoplay",
  7. "htmlvideoautoplay",
  8. "externallinks",
  9. "clientsettings",
  10. "multiserver",
  11. "exitmenu",
  12. "remotecontrol",
  13. "fullscreenchange",
  14. "filedownload",
  15. "remotevideo",
  16. "displaymode",
  17. "screensaver",
  18. "fileinput"
  19. ];
  20. const plugins = [
  21. 'mpvVideoPlayer',
  22. 'mpvAudioPlayer',
  23. 'jmpInputPlugin',
  24. 'jmpUpdatePlugin',
  25. 'jellyscrubPlugin',
  26. 'skipIntroPlugin'
  27. ];
  28. function loadScript(src) {
  29. return new Promise((resolve, reject) => {
  30. const s = document.createElement('script');
  31. s.src = src;
  32. s.onload = resolve;
  33. s.onerror = reject;
  34. document.head.appendChild(s);
  35. });
  36. }
  37. // Add plugin loaders
  38. for (const plugin of plugins) {
  39. window[plugin] = async () => {
  40. await loadScript(`${jmpInfo.scriptPath}${plugin}.js`);
  41. return window["_" + plugin];
  42. };
  43. }
  44. window.NativeShell = {
  45. openUrl(url, target) {
  46. window.api.system.openExternalUrl(url);
  47. },
  48. downloadFile(downloadInfo) {
  49. window.api.system.openExternalUrl(downloadInfo.url);
  50. },
  51. openClientSettings() {
  52. showSettingsModal();
  53. },
  54. getPlugins() {
  55. return plugins;
  56. }
  57. };
  58. function getDeviceProfile() {
  59. const CodecProfiles = [];
  60. if (jmpInfo.settings.video.force_transcode_dovi) {
  61. CodecProfiles.push({
  62. 'Type': 'Video',
  63. 'Conditions': [
  64. {
  65. 'Condition': 'NotEquals',
  66. 'Property': 'VideoRangeType',
  67. 'Value': 'DOVI'
  68. }
  69. ]
  70. });
  71. }
  72. if (jmpInfo.settings.video.force_transcode_hdr) {
  73. CodecProfiles.push({
  74. 'Type': 'Video',
  75. 'Conditions': [
  76. {
  77. 'Condition': 'Equals',
  78. 'Property': 'VideoRangeType',
  79. 'Value': 'SDR'
  80. }
  81. ]
  82. });
  83. }
  84. if (jmpInfo.settings.video.force_transcode_hi10p) {
  85. CodecProfiles.push({
  86. 'Type': 'Video',
  87. 'Conditions': [
  88. {
  89. 'Condition': 'LessThanEqual',
  90. 'Property': 'VideoBitDepth',
  91. 'Value': '8',
  92. }
  93. ]
  94. });
  95. }
  96. if (jmpInfo.settings.video.force_transcode_hevc) {
  97. CodecProfiles.push({
  98. 'Type': 'Video',
  99. 'Codec': 'hevc',
  100. 'Conditions': [
  101. {
  102. 'Condition': 'Equals',
  103. 'Property': 'Width',
  104. 'Value': '0',
  105. }
  106. ],
  107. });
  108. CodecProfiles.push({
  109. 'Type': 'Video',
  110. 'Codec': 'h265',
  111. 'Conditions': [
  112. {
  113. 'Condition': 'Equals',
  114. 'Property': 'Width',
  115. 'Value': '0',
  116. }
  117. ],
  118. });
  119. }
  120. if (jmpInfo.settings.video.force_transcode_av1) {
  121. CodecProfiles.push({
  122. 'Type': 'Video',
  123. 'Codec': 'av1',
  124. 'Conditions': [
  125. {
  126. 'Condition': 'Equals',
  127. 'Property': 'Width',
  128. 'Value': '0',
  129. }
  130. ],
  131. });
  132. }
  133. if (jmpInfo.settings.video.force_transcode_4k) {
  134. CodecProfiles.push({
  135. 'Type': 'Video',
  136. 'Conditions': [
  137. {
  138. 'Condition': 'LessThanEqual',
  139. 'Property': 'Width',
  140. 'Value': '1920',
  141. },
  142. {
  143. 'Condition': 'LessThanEqual',
  144. 'Property': 'Height',
  145. 'Value': '1080',
  146. }
  147. ]
  148. });
  149. }
  150. const DirectPlayProfiles = [{'Type': 'Audio'}, {'Type': 'Photo'}];
  151. if (!jmpInfo.settings.video.always_force_transcode) {
  152. DirectPlayProfiles.push({'Type': 'Video'});
  153. }
  154. return {
  155. 'Name': 'Jellyfin Media Player',
  156. 'MaxStaticBitrate': 1000000000,
  157. 'MusicStreamingTranscodingBitrate': 1280000,
  158. 'TimelineOffsetSeconds': 5,
  159. 'TranscodingProfiles': [
  160. {'Type': 'Audio'},
  161. {
  162. 'Container': 'ts',
  163. 'Type': 'Video',
  164. 'Protocol': 'hls',
  165. 'AudioCodec': 'aac,mp3,ac3,opus,vorbis',
  166. 'VideoCodec': jmpInfo.settings.video.allow_transcode_to_hevc
  167. ? (
  168. jmpInfo.settings.video.prefer_transcode_to_h265
  169. ? 'h265,hevc,h264,mpeg4,mpeg2video'
  170. : 'h264,h265,hevc,mpeg4,mpeg2video'
  171. )
  172. : 'h264,mpeg4,mpeg2video',
  173. 'MaxAudioChannels': jmpInfo.settings.audio.channels === "2.0" ? '2' : '6'
  174. },
  175. {'Container': 'jpeg', 'Type': 'Photo'}
  176. ],
  177. DirectPlayProfiles,
  178. 'ResponseProfiles': [],
  179. 'ContainerProfiles': [],
  180. CodecProfiles,
  181. 'SubtitleProfiles': [
  182. {'Format': 'srt', 'Method': 'External'},
  183. {'Format': 'srt', 'Method': 'Embed'},
  184. {'Format': 'ass', 'Method': 'External'},
  185. {'Format': 'ass', 'Method': 'Embed'},
  186. {'Format': 'sub', 'Method': 'Embed'},
  187. {'Format': 'sub', 'Method': 'External'},
  188. {'Format': 'ssa', 'Method': 'Embed'},
  189. {'Format': 'ssa', 'Method': 'External'},
  190. {'Format': 'smi', 'Method': 'Embed'},
  191. {'Format': 'smi', 'Method': 'External'},
  192. {'Format': 'pgssub', 'Method': 'Embed'},
  193. {'Format': 'dvdsub', 'Method': 'Embed'},
  194. {'Format': 'dvbsub', 'Method': 'Embed'},
  195. {'Format': 'pgs', 'Method': 'Embed'}
  196. ]
  197. };
  198. }
  199. async function createApi() {
  200. try {
  201. // Can't append script until document exists
  202. await new Promise(resolve => {
  203. document.addEventListener('DOMContentLoaded', resolve);
  204. });
  205. await loadScript('qrc:///qtwebchannel/qwebchannel.js');
  206. } catch (e) {
  207. // try clearing out any cached CSPs
  208. let foundCache = false;
  209. for (const cache of await caches.keys()) {
  210. const dataDeleted = await caches.delete(cache);
  211. if (dataDeleted) {
  212. foundCache = true;
  213. }
  214. }
  215. if (foundCache) {
  216. window.location.reload();
  217. }
  218. throw e;
  219. }
  220. const channel = await new Promise((resolve) => {
  221. /*global QWebChannel */
  222. new QWebChannel(window.qt.webChannelTransport, resolve);
  223. });
  224. return channel.objects;
  225. }
  226. let rawSettings = {};
  227. Object.assign(rawSettings, jmpInfo.settings);
  228. const settingsFromStorage = window.sessionStorage.getItem('settings');
  229. if (settingsFromStorage) {
  230. rawSettings = JSON.parse(settingsFromStorage);
  231. Object.assign(jmpInfo.settings, rawSettings);
  232. }
  233. const settingsDescriptionsFromStorage = window.sessionStorage.getItem('settingsDescriptions');
  234. if (settingsDescriptionsFromStorage) {
  235. jmpInfo.settingsDescriptions = JSON.parse(settingsDescriptionsFromStorage);
  236. }
  237. jmpInfo.settingsDescriptionsUpdate = [];
  238. jmpInfo.settingsUpdate = [];
  239. window.apiPromise = createApi();
  240. window.initCompleted = new Promise(async (resolve) => {
  241. window.api = await window.apiPromise;
  242. const settingUpdate = (section, key) => (
  243. (data) => new Promise(resolve => {
  244. rawSettings[section][key] = data;
  245. window.sessionStorage.setItem("settings", JSON.stringify(rawSettings));
  246. window.api.settings.setValue(section, key, data, resolve);
  247. })
  248. );
  249. const setSetting = (section, key) => {
  250. Object.defineProperty(jmpInfo.settings[section], key, {
  251. set: settingUpdate(section, key),
  252. get: () => rawSettings[section][key]
  253. });
  254. };
  255. for (const settingGroup of Object.keys(rawSettings)) {
  256. jmpInfo.settings[settingGroup] = {};
  257. for (const setting of Object.keys(rawSettings[settingGroup])) {
  258. setSetting(settingGroup, setting, jmpInfo.settings[settingGroup][setting]);
  259. }
  260. }
  261. window.api.settings.sectionValueUpdate.connect(
  262. (section, data) => {
  263. Object.assign(rawSettings[section], data);
  264. for (const callback of jmpInfo.settingsUpdate) {
  265. try {
  266. callback(section, data);
  267. } catch (e) {
  268. console.error("Update handler failed:", e);
  269. }
  270. }
  271. // Settings will be outdated if page reloads, so save them to session storage
  272. window.sessionStorage.setItem("settings", JSON.stringify(rawSettings));
  273. }
  274. );
  275. window.api.settings.groupUpdate.connect(
  276. (section, data) => {
  277. jmpInfo.settingsDescriptions[section] = data.settings;
  278. for (const callback of jmpInfo.settingsDescriptionsUpdate) {
  279. try {
  280. callback(section, data);
  281. } catch (e) {
  282. console.error("Description update handler failed:", e);
  283. }
  284. }
  285. // Settings will be outdated if page reloads, so save them to session storage
  286. window.sessionStorage.setItem("settingsDescriptions", JSON.stringify(jmpInfo.settingsDescriptions));
  287. }
  288. );
  289. resolve();
  290. });
  291. window.NativeShell.AppHost = {
  292. init() {},
  293. getDefaultLayout() {
  294. return jmpInfo.mode;
  295. },
  296. supports(command) {
  297. return features.includes(command.toLowerCase());
  298. },
  299. getDeviceProfile,
  300. getSyncProfile: getDeviceProfile,
  301. appName() {
  302. return "Jellyfin Media Player";
  303. },
  304. appVersion() {
  305. return navigator.userAgent.split(" ")[1];
  306. },
  307. deviceName() {
  308. return jmpInfo.deviceName;
  309. },
  310. exit() {
  311. window.api.system.exit();
  312. }
  313. };
  314. async function showSettingsModal() {
  315. await initCompleted;
  316. const modalContainer = document.createElement("div");
  317. modalContainer.className = "dialogContainer";
  318. modalContainer.style.backgroundColor = "rgba(0,0,0,0.5)";
  319. modalContainer.addEventListener("click", e => {
  320. if (e.target == modalContainer) {
  321. modalContainer.remove();
  322. }
  323. });
  324. document.body.appendChild(modalContainer);
  325. const modalContainer2 = document.createElement("div");
  326. modalContainer2.className = "focuscontainer dialog dialog-fixedSize dialog-small formDialog opened";
  327. modalContainer.appendChild(modalContainer2);
  328. const modalHeader = document.createElement("div");
  329. modalHeader.className = "formDialogHeader";
  330. modalContainer2.appendChild(modalHeader);
  331. const title = document.createElement("h3");
  332. title.className = "formDialogHeaderTitle";
  333. title.textContent = "Jellyfin Media Player Settings";
  334. modalHeader.appendChild(title);
  335. const modalContents = document.createElement("div");
  336. modalContents.className = "formDialogContent smoothScrollY";
  337. modalContents.style.paddingTop = "2em";
  338. modalContents.style.marginBottom = "6.2em";
  339. modalContainer2.appendChild(modalContents);
  340. const settingUpdateHandlers = {};
  341. for (const section of Object.keys(jmpInfo.settingsDescriptions)) {
  342. const group = document.createElement("fieldset");
  343. group.className = "editItemMetadataForm editMetadataForm dialog-content-centered";
  344. group.style.border = 0;
  345. group.style.outline = 0;
  346. modalContents.appendChild(group);
  347. const createSection = async (clear) => {
  348. if (clear) {
  349. group.innerHTML = "";
  350. }
  351. const values = jmpInfo.settings[section];
  352. const settings = jmpInfo.settingsDescriptions[section];
  353. const legend = document.createElement("legend");
  354. const legendHeader = document.createElement("h2");
  355. legendHeader.textContent = section;
  356. legendHeader.style.textTransform = "capitalize";
  357. legend.appendChild(legendHeader);
  358. if (section == "plugins") {
  359. const legendSubHeader = document.createElement("h4");
  360. legendSubHeader.textContent = "Plugins are UNOFFICIAL and require a restart to take effect.";
  361. legend.appendChild(legendSubHeader);
  362. }
  363. group.appendChild(legend);
  364. for (const setting of settings) {
  365. const label = document.createElement("label");
  366. label.className = "inputContainer";
  367. label.style.marginBottom = "1.8em";
  368. label.style.display = "block";
  369. label.style.textTransform = "capitalize";
  370. if (setting.options) {
  371. const safeValues = {};
  372. const control = document.createElement("select");
  373. control.className = "emby-select-withcolor emby-select";
  374. for (const option of setting.options) {
  375. safeValues[String(option.value)] = option.value;
  376. const opt = document.createElement("option");
  377. opt.value = option.value;
  378. opt.selected = option.value == values[setting.key];
  379. let optionName = option.title;
  380. const swTest = `${section}.${setting.key}.`;
  381. const swTest2 = `${section}.`;
  382. if (optionName.startsWith(swTest)) {
  383. optionName = optionName.substring(swTest.length);
  384. } else if (optionName.startsWith(swTest2)) {
  385. optionName = optionName.substring(swTest2.length);
  386. }
  387. opt.appendChild(document.createTextNode(optionName));
  388. control.appendChild(opt);
  389. }
  390. control.addEventListener("change", async (e) => {
  391. jmpInfo.settings[section][setting.key] = safeValues[e.target.value];
  392. });
  393. const labelText = document.createElement('label');
  394. labelText.className = "inputLabel";
  395. labelText.textContent = setting.key + ": ";
  396. label.appendChild(labelText);
  397. label.appendChild(control);
  398. } else {
  399. const control = document.createElement("input");
  400. control.type = "checkbox";
  401. control.checked = values[setting.key];
  402. control.addEventListener("change", e => {
  403. jmpInfo.settings[section][setting.key] = e.target.checked;
  404. });
  405. label.appendChild(control);
  406. label.appendChild(document.createTextNode(" " + setting.key));
  407. }
  408. group.appendChild(label);
  409. }
  410. };
  411. settingUpdateHandlers[section] = () => createSection(true);
  412. createSection();
  413. }
  414. const onSectionUpdate = (section) => {
  415. if (section in settingUpdateHandlers) {
  416. settingUpdateHandlers[section]();
  417. }
  418. };
  419. jmpInfo.settingsDescriptionsUpdate.push(onSectionUpdate);
  420. jmpInfo.settingsUpdate.push(onSectionUpdate);
  421. if (jmpInfo.settings.main.userWebClient) {
  422. const group = document.createElement("fieldset");
  423. group.className = "editItemMetadataForm editMetadataForm dialog-content-centered";
  424. group.style.border = 0;
  425. group.style.outline = 0;
  426. modalContents.appendChild(group);
  427. const legend = document.createElement("legend");
  428. const legendHeader = document.createElement("h2");
  429. legendHeader.textContent = "Saved Server";
  430. legend.appendChild(legendHeader);
  431. const legendSubHeader = document.createElement("h4");
  432. legendSubHeader.textContent = (
  433. "The server you first connected to is your saved server. " +
  434. "It provides the web client for Jellyfin Media Player in the absence of a bundled one. " +
  435. "You can use this option to change it to another one. This does NOT log you off."
  436. );
  437. legend.appendChild(legendSubHeader);
  438. group.appendChild(legend);
  439. const resetSavedServer = document.createElement("button");
  440. resetSavedServer.className = "raised button-cancel block btnCancel emby-button";
  441. resetSavedServer.textContent = "Reset Saved Server"
  442. resetSavedServer.style.marginLeft = "auto";
  443. resetSavedServer.style.marginRight = "auto";
  444. resetSavedServer.style.maxWidth = "50%";
  445. resetSavedServer.addEventListener("click", async () => {
  446. window.jmpInfo.settings.main.userWebClient = '';
  447. window.location.href = jmpInfo.scriptPath + "/find-webclient.html";
  448. });
  449. group.appendChild(resetSavedServer);
  450. }
  451. const closeContainer = document.createElement("div");
  452. closeContainer.className = "formDialogFooter";
  453. modalContents.appendChild(closeContainer);
  454. const close = document.createElement("button");
  455. close.className = "raised button-cancel block btnCancel formDialogFooterItem emby-button";
  456. close.textContent = "Close"
  457. close.addEventListener("click", () => {
  458. modalContainer.remove();
  459. });
  460. closeContainer.appendChild(close);
  461. }