diff --git a/for-web-developers.md b/for-web-developers.md index 6355c32..0bb6008 100644 --- a/for-web-developers.md +++ b/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 diff --git a/native/find-webclient.js b/native/find-webclient.js index cdbf979..2423a42 100644 --- a/native/find-webclient.js +++ b/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'; diff --git a/native/jellyscrubPlugin.js b/native/jellyscrubPlugin.js index 3ef20f8..f21d25a 100644 --- a/native/jellyscrubPlugin.js +++ b/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; diff --git a/native/nativeshell.js b/native/nativeshell.js index e899a24..35357d5 100644 --- a/native/nativeshell.js +++ b/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); } diff --git a/native/skipIntroPlugin.js b/native/skipIntroPlugin.js index ff5307a..c07481b 100644 --- a/native/skipIntroPlugin.js +++ b/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; diff --git a/resources/settings/settings_description.json b/resources/settings/settings_description.json index b7f5b9e..ae674bd 100644 --- a/resources/settings/settings_description.json +++ b/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", diff --git a/src/system/SystemComponent.cpp b/src/system/SystemComponent.cpp index 7a6eb54..a3a8bf0 100644 --- a/src/system/SystemComponent.cpp +++ b/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; }