Browse Source

Add extra transcoder settings and make global reactive settings object.

Ian Walton 1 year ago
parent
commit
43e068c5f7

+ 14 - 15
for-web-developers.md

@@ -2,13 +2,7 @@
 
 ## Detection
 
-You can detect JMP by querying the injected utilities script:
-
-```js
-window?.NativeShell?.AppHost?.appName() === "Jellyfin Media Player"
-```
-
-This is not async. It can be queried immediately.
+You can detect JMP by looking for the object `window.jmpInfo`, which is defined immediately.
 
 ## Device Profile
 
@@ -28,7 +22,7 @@ The following functions are available immediately:
  - `window.NativeShell.AppHost.appVersion()` - Returns version number in `x.y.z` format.
  - `window.NativeShell.AppHost.deviceName()` - Returns computer hostname.
 
-The following functions require calling `window.NativeShell.AppHost.init()` before using them:
+The following functions are available after less than a second:
 
  - `window.NativeShell.openUrl(url)` - Opens a URL in the user's browser.
  - `window.NativeShell.openClientSettings()` - Opens prebuilt settings modal.
@@ -210,7 +204,17 @@ Current Settings:
      - `size: int`: Controls subtitle size. Default is `32`.
          - Provided options: [see enum](https://github.com/jellyfin/jellyfin-media-player/blob/7d5943becc1ca672d599887cac9107836c38d337/resources/settings/settings_description.json#L376-L382)
 
-API methods:
+The global `window.jmpInfo` object contains settings for the application in the form of `window.jmpInfo.settings.[section][key] = value`.
+Settings descriptions are stored in the form `window.jmpInfo.settingsDescriptions.[section] = [{ key, options }]`
+
+ - These are kept up-to-date in response to user changes.
+ - If you want to change any settings, `await window.initCompleted` first.
+ - To change a setting, simply set the setting to a new value. Don't overwrite an entire section.
+ - You can subscribe to settings changes by adding a `callback(section, changes)` to `window.jmpInfo.settingsUpdate`.
+ - Similarly, you can subscribe to settings description updates by adding a `callback(section, changes)` to `window.jmpInfo.settingsDescriptionsUpdate`.
+     - This is useful for reactive settings, for instance when you change the audio device type.
+
+You can also use the API to set and query settings. This is largely not needed unless you want to be sure a setting change is complete before you do something:
 
  - `api.settings.settingDescriptions(callback)`: Get list of all setting descriptions and options.
      - You may need to re-query this if you update certain settings.
@@ -218,13 +222,8 @@ API methods:
  - `api.settings.value(section, key, callback)`: Get a specific setting.
  - `api.settings.setValue(section, key, value, callback)`: Set a specific setting.
 
-You can also listen for setting and setting group option updates. I don't currently use this, but you can see
-details of how to do that [from the signals here](https://github.com/jellyfin/jellyfin-media-player/blob/7d5943becc1ca672d599887cac9107836c38d337/src/settings/SettingsComponent.h#L76-L83).
-
 ## Checking for Updates
 
-You need to call `window.NativeShell.AppHost.init()` before you can use this.
-
 ```js
 const updatePlugin = await window.jmpUpdatePlugin();
 updatePlugin({
@@ -261,7 +260,7 @@ async function createApi() {
 ```
 
 The injected utilities script makes `window.apiPromise` available immediately and `window.api` available
-after less than a second available if you call `window.NativeShell.AppHost.init()`.
+after less than a second.
 
 ### Client API Usage
 

+ 4 - 29
native/find-webclient.js

@@ -1,24 +1,3 @@
-function loadScript(src) {
-    return new Promise((resolve, reject) => {
-        const s = document.createElement('script');
-        s.src = src;
-        s.onload = resolve;
-        s.onerror = reject;
-        document.head.appendChild(s);
-    });
-}
-
-async function createApi() {
-    await loadScript('qrc:///qtwebchannel/qwebchannel.js');
-    const channel = await new Promise((resolve) => {
-        /*global QWebChannel */
-        new QWebChannel(window.qt.webChannelTransport, resolve);
-    });
-    return channel.objects;
-}
-
-window.apiPromise = createApi();
-
 async function tryConnect(server) {
     document.getElementById('connect-button').disabled = true;
 
@@ -70,10 +49,9 @@ async function tryConnect(server) {
                 window.location = server;
             }
 
-            const api = await window.apiPromise;
-            await new Promise(resolve => {
-                api.settings.setValue('main', 'userWebClient', server, resolve);
-            });
+            await window.initCompleted;
+            window.jmpInfo.settings.main.userWebClient = server;
+
             return true;
         }
     } catch (e) {
@@ -99,10 +77,7 @@ document.getElementById('connect-fail-button').addEventListener('click', () => {
 
 // load the server if we have one
 (async() => {
-    const api = await window.apiPromise;
-    const savedServer = await new Promise(resolve => {
-        api.settings.value('main', 'userWebClient', resolve);
-    });
+    const savedServer = window.jmpInfo.settings.main.userWebClient;
 
     if (!savedServer || !(await tryConnect(savedServer))) {
         document.getElementById('splash').style.display = 'none';

+ 3 - 4
native/jellyscrubPlugin.js

@@ -5,10 +5,9 @@ class jellyscrubPlugin {
         this.id = 'jellyscrubPlugin';
 
         (async() => {
-            const api = await window.apiPromise;
-            const enabled = await new Promise(resolve => {
-                api.settings.value('plugins', 'jellyscrub', resolve);
-            });
+            await window.initCompleted;
+            const enabled = window.jmpInfo.settings.plugins.jellyscrub;
+
             console.log("JellyScrub Plugin enabled: " + enabled);
             if (!enabled) return;
 

+ 188 - 45
native/nativeshell.js

@@ -1,4 +1,5 @@
-const viewdata = JSON.parse(window.atob("@@data@@"));
+const jmpInfo = JSON.parse(window.atob("@@data@@"));
+window.jmpInfo = jmpInfo;
 
 const features = [
     "filedownload",
@@ -40,7 +41,7 @@ function loadScript(src) {
 // Add plugin loaders
 for (const plugin of plugins) {
     window[plugin] = async () => {
-        await loadScript(`${viewdata.scriptPath}${plugin}.js`);
+        await loadScript(`${jmpInfo.scriptPath}${plugin}.js`);
         return window["_" + plugin];
     };
 }
@@ -77,7 +78,7 @@ function getDeviceProfile() {
         }
     ];
 
-    if (viewdata.force_transcode_hdr) {
+    if (jmpInfo.settings.video.force_transcode_hdr) {
         CodecProfiles.push({
             'Type': 'Video',
             'Conditions': [
@@ -90,6 +91,82 @@ function getDeviceProfile() {
         });
     }
 
+    if (jmpInfo.settings.video.force_transcode_hi10p) {
+        CodecProfiles.push({
+            'Type': 'Video',
+            'Conditions': [
+                {
+                    'Condition': 'LessThanEqual',
+                    'Property': 'VideoBitDepth',
+                    'Value': '8',
+                }
+            ]
+        });
+    }
+
+    if (jmpInfo.settings.video.force_transcode_hevc) {
+        CodecProfiles.push({
+            'Type': 'Video',
+            'Codec': 'hevc',
+            'Conditions': [
+                {
+                    'Condition': 'Equals',
+                    'Property': 'Width',
+                    'Value': '0',
+                }
+            ],
+        });
+        CodecProfiles.push({
+            'Type': 'Video',
+            'Codec': 'h265',
+            'Conditions': [
+                {
+                    'Condition': 'Equals',
+                    'Property': 'Width',
+                    'Value': '0',
+                }
+            ],
+        });
+    }
+
+    if (jmpInfo.settings.video.force_transcode_av1) {
+        CodecProfiles.push({
+            'Type': 'Video',
+            'Codec': 'av1',
+            'Conditions': [
+                {
+                    'Condition': 'Equals',
+                    'Property': 'Width',
+                    'Value': '0',
+                }
+            ],
+        });
+    }
+
+    if (jmpInfo.settings.video.force_transcode_4k) {
+        CodecProfiles.push({
+            'Type': 'Video',
+            'Conditions': [
+                {
+                    'Condition': 'LessThanEqual',
+                    'Property': 'Width',
+                    'Value': '1920',
+                },
+                {
+                    'Condition': 'LessThanEqual',
+                    'Property': 'Height',
+                    'Value': '1080',
+                }
+            ]
+        });
+    }
+
+    const DirectPlayProfiles = [{'Type': 'Audio'}, {'Type': 'Photo'}];
+
+    if (!jmpInfo.settings.video.always_force_transcode) {
+        DirectPlayProfiles.push({'Type': 'Video'});
+    }
+
     return {
         'Name': 'Jellyfin Media Player',
         'MaxStaticBitrate': 1000000000,
@@ -102,14 +179,18 @@ function getDeviceProfile() {
                 'Type': 'Video',
                 'Protocol': 'hls',
                 'AudioCodec': 'aac,mp3,ac3,opus,flac,vorbis',
-                'VideoCodec': viewdata.allow_transcode_to_hevc
-                    ? 'h264,h265,hevc,mpeg4,mpeg2video'
+                'VideoCodec': jmpInfo.settings.video.allow_transcode_to_hevc
+                    ? (
+                        jmpInfo.settings.video.prefer_transcode_to_h265
+                         ? 'h265,hevc,h264,mpeg4,mpeg2video'
+                         : 'h264,h265,hevc,mpeg4,mpeg2video'
+                    )
                     : 'h264,mpeg4,mpeg2video',
                 'MaxAudioChannels': '6'
             },
             {'Container': 'jpeg', 'Type': 'Photo'}
         ],
-        'DirectPlayProfiles': [{'Type': 'Video'}, {'Type': 'Audio'}, {'Type': 'Photo'}],
+        DirectPlayProfiles,
         'ResponseProfiles': [],
         'ContainerProfiles': [],
         CodecProfiles,
@@ -134,6 +215,11 @@ function getDeviceProfile() {
 
 async function createApi() {
     try {
+        // Can't append script until document exists
+        await new Promise(resolve => {
+            document.addEventListener('DOMContentLoaded', resolve);
+        });
+
         await loadScript('qrc:///qtwebchannel/qwebchannel.js');
     } catch (e) {
         // try clearing out any cached CSPs
@@ -157,15 +243,80 @@ async function createApi() {
     return channel.objects;
 }
 
+let rawSettings = {};
+Object.assign(rawSettings, jmpInfo.settings);
+const settingsFromStorage = window.sessionStorage.getItem('settings');
+if (settingsFromStorage) {
+    rawSettings = JSON.parse(settingsFromStorage);
+    Object.assign(jmpInfo.settings, rawSettings);
+}
+
+const settingsDescriptionsFromStorage = window.sessionStorage.getItem('settingsDescriptions');
+if (settingsDescriptionsFromStorage) {
+    jmpInfo.settingsDescriptions = JSON.parse(settingsDescriptionsFromStorage);
+}
+
+jmpInfo.settingsDescriptionsUpdate = [];
+jmpInfo.settingsUpdate = [];
+window.apiPromise = createApi();
+window.initCompleted = new Promise(async (resolve) => {
+    window.api = await window.apiPromise;
+    const settingUpdate = (section, key) => (
+        (data) => new Promise(resolve => {
+            rawSettings[section][key] = data;
+            window.sessionStorage.setItem("settings", JSON.stringify(rawSettings));
+            window.api.settings.setValue(section, key, data, resolve);
+        })
+    );
+    const setSetting = (section, key) => {
+        Object.defineProperty(jmpInfo.settings[section], key, {
+            set: settingUpdate(section, key),
+            get: () => rawSettings[section][key]
+        });
+    };
+    for (const settingGroup of Object.keys(rawSettings)) {
+        jmpInfo.settings[settingGroup] = {};
+        for (const setting of Object.keys(rawSettings[settingGroup])) {
+            setSetting(settingGroup, setting, jmpInfo.settings[settingGroup][setting]);
+        }
+    }
+    window.api.settings.sectionValueUpdate.connect(
+        (section, data) => {
+            Object.assign(rawSettings[section], data);
+            for (const callback of jmpInfo.settingsUpdate) {
+                try {
+                    callback(section, data);
+                } catch (e) {
+                    console.error("Update handler failed:", e);
+                }
+            }
+
+            // Settings will be outdated if page reloads, so save them to session storage
+            window.sessionStorage.setItem("settings", JSON.stringify(rawSettings));
+        }
+    );
+    window.api.settings.groupUpdate.connect(
+        (section, data) => {
+            jmpInfo.settingsDescriptions[section] = data.settings;
+            for (const callback of jmpInfo.settingsDescriptionsUpdate) {
+                try {
+                    callback(section, data);
+                } catch (e) {
+                    console.error("Description update handler failed:", e);
+                }
+            }
+
+            // Settings will be outdated if page reloads, so save them to session storage
+            window.sessionStorage.setItem("settingsDescriptions", JSON.stringify(jmpInfo.settingsDescriptions));
+        }
+    );
+    resolve();
+});
+
 window.NativeShell.AppHost = {
-    init() {
-        window.apiPromise = createApi();
-        (async () => {
-            window.api = await window.apiPromise;
-        })();
-    },
+    init() {},
     getDefaultLayout() {
-        return viewdata.mode;
+        return jmpInfo.mode;
     },
     supports(command) {
         return features.includes(command.toLowerCase());
@@ -179,7 +330,7 @@ window.NativeShell.AppHost = {
         return navigator.userAgent.split(" ")[1];
     },
     deviceName() {
-        return viewdata.deviceName;
+        return jmpInfo.deviceName;
     },
     exit() {
         window.api.system.exit();
@@ -187,9 +338,7 @@ window.NativeShell.AppHost = {
 };
 
 async function showSettingsModal() {
-    let settings = await new Promise(resolve => {
-        window.api.settings.settingDescriptions(resolve);
-    });
+    await initCompleted;
 
     const modalContainer = document.createElement("div");
     modalContainer.className = "dialogContainer";
@@ -220,7 +369,8 @@ async function showSettingsModal() {
     modalContents.style.marginBottom = "6.2em";
     modalContainer2.appendChild(modalContents);
     
-    for (let section of settings) {
+    const settingUpdateHandlers = {};
+    for (const section of Object.keys(jmpInfo.settingsDescriptions)) {
         const group = document.createElement("fieldset");
         group.className = "editItemMetadataForm editMetadataForm dialog-content-centered";
         group.style.border = 0;
@@ -232,23 +382,22 @@ async function showSettingsModal() {
                 group.innerHTML = "";
             }
 
-            const values = await new Promise(resolve => {
-                window.api.settings.allValues(section.key, resolve);
-            });
+            const values = jmpInfo.settings[section];
+            const settings = jmpInfo.settingsDescriptions[section];
 
             const legend = document.createElement("legend");
             const legendHeader = document.createElement("h2");
-            legendHeader.textContent = section.key;
+            legendHeader.textContent = section;
             legendHeader.style.textTransform = "capitalize";
             legend.appendChild(legendHeader);
-            if (section.key == "plugins") {
+            if (section == "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) {
+            for (const setting of settings) {
                 const label = document.createElement("label");
                 label.className = "inputContainer";
                 label.style.marginBottom = "1.8em";
@@ -264,8 +413,8 @@ async function showSettingsModal() {
                         opt.value = option.value;
                         opt.selected = option.value == values[setting.key];
                         let optionName = option.title;
-                        const swTest = `${section.key}.${setting.key}.`;
-                        const swTest2 = `${section.key}.`;
+                        const swTest = `${section}.${setting.key}.`;
+                        const swTest2 = `${section}.`;
                         if (optionName.startsWith(swTest)) {
                             optionName = optionName.substring(swTest.length);
                         } else if (optionName.startsWith(swTest2)) {
@@ -275,16 +424,7 @@ async function showSettingsModal() {
                         control.appendChild(opt);
                     }
                     control.addEventListener("change", async (e) => {
-                        await new Promise(resolve => {
-                            window.api.settings.setValue(section.key, setting.key, safeValues[e.target.value], resolve);
-                        });
-
-                        if (setting.key == "devicetype") {
-                            section = (await new Promise(resolve => {
-                                window.api.settings.settingDescriptions(resolve);
-                            })).filter(x => x.key == section.key)[0];
-                            createSection(true);
-                        }
+                        jmpInfo.settings[section][setting.key] = safeValues[e.target.value];
                     });
                     const labelText = document.createElement('label');
                     labelText.className = "inputLabel";
@@ -296,7 +436,7 @@ async function showSettingsModal() {
                     control.type = "checkbox";
                     control.checked = values[setting.key];
                     control.addEventListener("change", e => {
-                        window.api.settings.setValue(section.key, setting.key, e.target.checked);
+                        jmpInfo.settings[section][setting.key] = e.target.checked;
                     });
                     label.appendChild(control);
                     label.appendChild(document.createTextNode(" " + setting.key));
@@ -304,14 +444,19 @@ async function showSettingsModal() {
                 group.appendChild(label);
             }
         };
+        settingUpdateHandlers[section] = () => createSection(true);
         createSection();
     }
 
-    const savedServer = await new Promise(resolve => {
-        window.api.settings.value('main', 'userWebClient', resolve);
-    });
+    const onSectionUpdate = (section) => {
+        if (section in settingUpdateHandlers) {
+            settingUpdateHandlers[section]();
+        }
+    };
+    jmpInfo.settingsDescriptionsUpdate.push(onSectionUpdate);
+    jmpInfo.settingsUpdate.push(onSectionUpdate);
 
-    if (savedServer) {
+    if (jmpInfo.settings.main.userWebClient) {
         const group = document.createElement("fieldset");
         group.className = "editItemMetadataForm editMetadataForm dialog-content-centered";
         group.style.border = 0;
@@ -337,10 +482,8 @@ async function showSettingsModal() {
         resetSavedServer.style.marginRight = "auto";
         resetSavedServer.style.maxWidth = "50%";
         resetSavedServer.addEventListener("click", async () => {
-            await new Promise(resolve => {
-                window.api.settings.setValue('main', 'userWebClient', '', resolve);
-            });
-            window.location.href = viewdata.scriptPath + "/find-webclient.html";
+            window.jmpInfo.settings.main.userWebClient = '';
+            window.location.href = jmpInfo.scriptPath + "/find-webclient.html";
         });
         group.appendChild(resetSavedServer);
     }

+ 3 - 4
native/skipIntroPlugin.js

@@ -7,10 +7,9 @@ class skipIntroPlugin {
         this.id = 'skipIntroPlugin';
 
         (async() => {
-            const api = await window.apiPromise;
-            const enabled = await new Promise(resolve => {
-                api.settings.value('plugins', 'skipintro', resolve);
-            });
+            await window.initCompleted;
+            const enabled = window.jmpInfo.settings.plugins.skipintro;
+
             console.log("Skip Intro Plugin enabled: " + enabled);
             if (!enabled) return;
 

+ 24 - 0
resources/settings/settings_description.json

@@ -268,6 +268,30 @@
         "value": "force_transcode_hdr",
         "default": false
       },
+      {
+        "value": "prefer_transcode_to_h265",
+        "default": false
+      },
+      {
+        "value": "force_transcode_hi10p",
+        "default": false
+      },
+      {
+        "value": "force_transcode_hevc",
+        "default": false
+      },
+      {
+        "value": "force_transcode_av1",
+        "default": false
+      },
+      {
+        "value": "force_transcode_4k",
+        "default": false
+      },
+      {
+        "value": "always_force_transcode",
+        "default": false
+      },
       {
         "value": "sync_mode",
         "default": "audio",

+ 8 - 2
src/system/SystemComponent.cpp

@@ -331,8 +331,14 @@ QString SystemComponent::getNativeShellScript()
   clientData.insert("deviceName", QJsonValue::fromVariant(SettingsComponent::Get().getClientName()));
   clientData.insert("scriptPath", QJsonValue::fromVariant("file:///" + path));
   clientData.insert("mode", QJsonValue::fromVariant(SettingsComponent::Get().value(SETTINGS_SECTION_MAIN, "layout").toString()));
-  clientData.insert("allow_transcode_to_hevc", QJsonValue::fromVariant(SettingsComponent::Get().value(SETTINGS_SECTION_VIDEO, "allow_transcode_to_hevc").toBool()));
-  clientData.insert("force_transcode_hdr", QJsonValue::fromVariant(SettingsComponent::Get().value(SETTINGS_SECTION_VIDEO, "force_transcode_hdr").toBool()));
+  QVariantList settingsDescriptionsList = SettingsComponent::Get().settingDescriptions();
+  QVariantMap settingsDescriptions = QVariantMap();
+  for (auto setting : settingsDescriptionsList) {
+    QVariantMap settingMap = setting.toMap();
+    settingsDescriptions.insert(settingMap["key"].toString(), settingMap["settings"]);
+  }
+  clientData.insert("settingsDescriptions", QJsonValue::fromVariant(settingsDescriptions));
+  clientData.insert("settings", QJsonValue::fromVariant(SettingsComponent::Get().allValues()));
   nativeshellString.replace("@@data@@", QJsonDocument(clientData).toJson(QJsonDocument::Compact).toBase64());
   return nativeshellString;
 }