Files
jellyfin-desktop/native/nativeshell.js
T

589 lines
21 KiB
JavaScript

const features = [
"filedownload",
"displaylanguage",
"htmlaudioautoplay",
"htmlvideoautoplay",
"externallinks",
"clientsettings",
"multiserver",
"exitmenu",
"remotecontrol",
"fullscreenchange",
"filedownload",
"remotevideo",
"displaymode",
"screensaver",
"fileinput"
];
const getPlugins = () => {
const basePlugins = [
'inputPlugin',
'updatePlugin'
];
const mpvEnabled = jmpInfo.settings?.main?.enableMPV !== false;
if (mpvEnabled) {
return [
'mpvVideoPlayer',
'mpvAudioPlayer',
...basePlugins
];
}
return basePlugins;
};
const plugins = getPlugins();
// Plugins are bundled, return class directly
for (const plugin of plugins) {
window[plugin] = () => {
return window["_" + plugin];
};
}
window.NativeShell = {
openUrl(url, target) {
window.api.system.openExternalUrl(url);
},
downloadFile(downloadInfo) {
window.api.system.openExternalUrl(downloadInfo.url);
},
openClientSettings() {
showSettingsModal();
},
getPlugins() {
return plugins;
}
};
function getDeviceProfile() {
const CodecProfiles = [];
if (jmpInfo.settings.video.force_transcode_dovi) {
CodecProfiles.push({
'Type': 'Video',
'Conditions': [
{
'Condition': 'NotEquals',
'Property': 'VideoRangeType',
'Value': 'DOVI'
}
]
});
}
if (jmpInfo.settings.video.force_transcode_hdr) {
CodecProfiles.push({
'Type': 'Video',
'Conditions': [
{
'Condition': 'Equals',
'Property': 'VideoRangeType',
'Value': 'SDR'
}
]
});
}
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',
'MaxStaticBitrate': 1000000000,
'MusicStreamingTranscodingBitrate': 1280000,
'TimelineOffsetSeconds': 5,
'TranscodingProfiles': [
{ 'Type': 'Audio' },
{
'Container': 'ts',
'Type': 'Video',
'Protocol': 'hls',
'AudioCodec': 'aac,mp3,ac3,opus,vorbis',
'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': jmpInfo.settings.audio.channels === "2.0" ? '2' : '6'
},
{ 'Container': 'jpeg', 'Type': 'Photo' }
],
DirectPlayProfiles,
'ResponseProfiles': [],
'ContainerProfiles': [],
CodecProfiles,
'SubtitleProfiles': [
{ 'Format': 'srt', 'Method': 'External' },
{ 'Format': 'srt', 'Method': 'Embed' },
{ 'Format': 'ass', 'Method': 'External' },
{ 'Format': 'ass', 'Method': 'Embed' },
{ 'Format': 'sub', 'Method': 'Embed' },
{ 'Format': 'sub', 'Method': 'External' },
{ 'Format': 'ssa', 'Method': 'Embed' },
{ 'Format': 'ssa', 'Method': 'External' },
{ 'Format': 'smi', 'Method': 'Embed' },
{ 'Format': 'smi', 'Method': 'External' },
{ 'Format': 'pgssub', 'Method': 'Embed' },
{ 'Format': 'dvdsub', 'Method': 'Embed' },
{ 'Format': 'dvbsub', 'Method': 'Embed' },
{ 'Format': 'pgs', 'Method': 'Embed' }
]
};
}
async function createApi() {
// Can't append script until document exists
await new Promise(resolve => {
document.addEventListener('DOMContentLoaded', resolve);
});
const channel = await new Promise((resolve) => {
/*global QWebChannel */
new QWebChannel(window.qt.webChannelTransport, resolve);
});
return channel.objects;
}
const sectionsFromStorage = window.sessionStorage.getItem('sections');
if (sectionsFromStorage) {
jmpInfo.sections = JSON.parse(sectionsFromStorage);
}
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));
}
);
// Sync cursor visibility with jellyfin-web's mouse idle state
const observer = new MutationObserver((mutations) => {
for (const mutation of mutations) {
if (mutation.attributeName === 'class') {
const isIdle = document.body.classList.contains('mouseIdle');
window.api.window.setCursorVisibility(!isIdle);
}
}
});
observer.observe(document.body, { attributes: true, attributeFilter: ['class'] });
resolve();
});
window.NativeShell.AppHost = {
init() { },
getDefaultLayout() {
return jmpInfo.mode;
},
supports(command) {
return features.includes(command.toLowerCase());
},
getDeviceProfile,
getSyncProfile: getDeviceProfile,
appName() {
return "Jellyfin";
},
appVersion() {
return navigator.userAgent.split(" ")[1];
},
deviceName() {
return jmpInfo.deviceName;
},
exit() {
window.api.system.exit();
}
};
async function showSettingsModal() {
await initCompleted;
const tooltipCSS = `
.tooltip {
position: relative;
display: inline-block;
margin-left: 0.5rem;
font-size: 18px;
vertical-align: sub;
}
.tooltip .tooltip-text {
visibility: hidden;
width: max-content;
max-width: 40em;
background-color: black;
color: white;
text-align: left;
position: absolute;
z-index: 1;
border-radius: 6px;
padding: 5px;
top: -4px;
left: 25px;
border: solid 1px grey;
font-size: 12px;
}
.tooltip:hover .tooltip-text {
visibility: visible;
}`;
var style = document.createElement('style')
style.innerText = tooltipCSS
document.head.appendChild(style)
const modalContainer = document.createElement("div");
modalContainer.className = "dialogContainer";
modalContainer.style.backgroundColor = "rgba(0,0,0,0.5)";
modalContainer.addEventListener("click", e => {
if (e.target == modalContainer) {
modalContainer.remove();
}
});
document.body.appendChild(modalContainer);
const modalContainer2 = document.createElement("div");
modalContainer2.className = "focuscontainer dialog dialog-fixedSize dialog-small formDialog opened";
modalContainer.appendChild(modalContainer2);
const modalHeader = document.createElement("div");
modalHeader.className = "formDialogHeader";
modalContainer2.appendChild(modalHeader);
const title = document.createElement("h3");
title.className = "formDialogHeaderTitle";
title.textContent = "Client Settings";
modalHeader.appendChild(title);
const modalContents = document.createElement("div");
modalContents.className = "formDialogContent smoothScrollY";
modalContents.style.paddingTop = "2em";
modalContents.style.marginBottom = "6.2em";
modalContainer2.appendChild(modalContents);
const settingUpdateHandlers = {};
for (const sectionOrder of jmpInfo.sections.sort((a, b) => a.order - b.order)) {
const section = sectionOrder.key;
const group = document.createElement("fieldset");
group.className = "editItemMetadataForm editMetadataForm dialog-content-centered";
group.style.border = 0;
group.style.outline = 0;
modalContents.appendChild(group);
const createSection = async (clear) => {
if (clear) {
group.innerHTML = "";
}
const values = jmpInfo.settings[section];
const settings = jmpInfo.settingsDescriptions[section];
const legend = document.createElement("legend");
const legendHeader = document.createElement("h2");
legendHeader.textContent = section;
legendHeader.style.textTransform = "capitalize";
legend.appendChild(legendHeader);
if (section == "other") {
const legendSubHeader = document.createElement("h4");
legendSubHeader.textContent = "Use this section to input custom MPV configuration. These will override the above settings.";
legend.appendChild(legendSubHeader);
}
group.appendChild(legend);
for (const setting of settings) {
const label = document.createElement("label");
label.className = "inputContainer";
label.style.marginBottom = "1.8em";
label.style.display = "block";
let helpElement;
if (setting.help) {
helpElement = document.createElement("div");
helpElement.className = "tooltip";
const helpIcon = document.createElement("span");
helpIcon.style.fontSize = "18px"
helpIcon.className = "material-icons help_outline";
helpElement.appendChild(helpIcon);
const tooltipElement = document.createElement("span");
tooltipElement.className = "tooltip-text";
tooltipElement.innerText = setting.help;
helpElement.appendChild(tooltipElement);
}
if (setting.options) {
const safeValues = {};
const control = document.createElement("select");
control.className = "emby-select-withcolor emby-select";
for (const option of setting.options) {
safeValues[String(option.value)] = option.value;
const opt = document.createElement("option");
opt.value = option.value;
opt.selected = option.value == values[setting.key];
let optionName = option.title;
const swTest = `${section}.${setting.key}.`;
const swTest2 = `${section}.`;
if (optionName.startsWith(swTest)) {
optionName = optionName.substring(swTest.length);
} else if (optionName.startsWith(swTest2)) {
optionName = optionName.substring(swTest2.length);
}
opt.appendChild(document.createTextNode(optionName));
control.appendChild(opt);
}
control.addEventListener("change", async (e) => {
jmpInfo.settings[section][setting.key] = safeValues[e.target.value];
});
const labelText = document.createElement('label');
labelText.className = "inputLabel";
labelText.textContent = (setting.displayName ? setting.displayName : setting.key) + ": ";
label.appendChild(labelText);
if (helpElement) label.appendChild(helpElement);
label.appendChild(control);
} else if (setting.inputType === "textarea") {
const control = document.createElement("textarea");
control.className = "emby-select-withcolor emby-select";
control.style = "resize: none;"
control.value = values[setting.key];
control.rows = 5;
control.addEventListener("change", e => {
jmpInfo.settings[section][setting.key] = e.target.value;
});
const labelText = document.createElement('label');
labelText.className = "inputLabel";
labelText.textContent = (setting.displayName ? setting.displayName : setting.key) + ": ";
label.appendChild(labelText);
if (helpElement) label.appendChild(helpElement);
label.appendChild(control);
} else {
const control = document.createElement("input");
control.type = "checkbox";
control.checked = values[setting.key];
control.addEventListener("change", e => {
jmpInfo.settings[section][setting.key] = e.target.checked;
});
label.appendChild(control);
label.appendChild(document.createTextNode(" " + (setting.displayName ? setting.displayName : setting.key)));
if (helpElement) label.appendChild(helpElement);
}
group.appendChild(label);
}
};
settingUpdateHandlers[section] = () => createSection(true);
createSection();
}
const onSectionUpdate = (section) => {
if (section in settingUpdateHandlers) {
settingUpdateHandlers[section]();
}
};
jmpInfo.settingsDescriptionsUpdate.push(onSectionUpdate);
jmpInfo.settingsUpdate.push(onSectionUpdate);
if (jmpInfo.settings.main.userWebClient) {
const group = document.createElement("fieldset");
group.className = "editItemMetadataForm editMetadataForm dialog-content-centered";
group.style.border = 0;
group.style.outline = 0;
modalContents.appendChild(group);
const legend = document.createElement("legend");
const legendHeader = document.createElement("h2");
legendHeader.textContent = "Saved Server";
legend.appendChild(legendHeader);
const legendSubHeader = document.createElement("h4");
legendSubHeader.textContent = (
"The server you first connected to is your saved server. " +
"It provides the web client for Jellyfin in the absence of a bundled one. " +
"You can use this option to change it to another one. This does NOT log you off."
);
legend.appendChild(legendSubHeader);
group.appendChild(legend);
const resetSavedServer = document.createElement("button");
resetSavedServer.className = "raised button-cancel block btnCancel emby-button";
resetSavedServer.textContent = "Reset Saved Server"
resetSavedServer.style.marginLeft = "auto";
resetSavedServer.style.marginRight = "auto";
resetSavedServer.style.maxWidth = "50%";
resetSavedServer.addEventListener("click", async () => {
window.jmpInfo.settings.main.userWebClient = '';
window.location.href = jmpInfo.scriptPath + "/find-webclient.html";
});
group.appendChild(resetSavedServer);
}
const closeContainer = document.createElement("div");
closeContainer.className = "formDialogFooter";
modalContents.appendChild(closeContainer);
const close = document.createElement("button");
close.className = "raised button-cancel block btnCancel formDialogFooterItem emby-button";
close.textContent = "Close"
close.addEventListener("click", () => {
modalContainer.remove();
});
closeContainer.appendChild(close);
}
let lastFullscreenState = window.jmpInfo.settings.main.fullscreen;
window.jmpInfo.settingsUpdate.push(function(section) {
if (section === 'main') {
const currentFullscreenState = window.jmpInfo.settings.main.fullscreen;
if (currentFullscreenState !== lastFullscreenState) {
lastFullscreenState = currentFullscreenState;
if (window.api && window.api.player) {
window.api.player.notifyFullscreenChange(currentFullscreenState);
console.log('Player fullscreen notified');
}
if (window.Events && window.playbackManager && window.playbackManager._currentPlayer) {
window.Events.trigger(window.playbackManager._currentPlayer, 'fullscreenchange');
}
}
}
});