1
0
Fork 0
mirror of https://github.com/jellyfin/jellyfin-web synced 2025-03-30 19:56:21 +00:00

Merge branch 'jellyfin:master' into enable_hlsjs_with_webos

This commit is contained in:
Timi Tuohenmaa 2025-03-29 13:19:43 +02:00 committed by GitHub
commit 0318e1e1d8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
201 changed files with 5941 additions and 4725 deletions

View file

@ -1,6 +1,5 @@
{
"ecmaVersion": "es5",
"modules": "false",
"files": "./dist/**/*.js",
"not": [
"./dist/libraries/pdf.worker.js",

View file

@ -26,15 +26,15 @@ jobs:
show-progress: false
- name: Initialize CodeQL 🛠️
uses: github/codeql-action/init@b56ba49b26e50535fa1e7f7db0f4f7b4bf65d80d # v3.28.10
uses: github/codeql-action/init@1b549b9259bda1cb5ddde3b41741a82a2d15a841 # v3.28.13
with:
queries: security-and-quality
languages: ${{ matrix.language }}
- name: Autobuild 📦
uses: github/codeql-action/autobuild@b56ba49b26e50535fa1e7f7db0f4f7b4bf65d80d # v3.28.10
uses: github/codeql-action/autobuild@1b549b9259bda1cb5ddde3b41741a82a2d15a841 # v3.28.13
- name: Perform CodeQL Analysis 🧪
uses: github/codeql-action/analyze@b56ba49b26e50535fa1e7f7db0f4f7b4bf65d80d # v3.28.10
uses: github/codeql-action/analyze@1b549b9259bda1cb5ddde3b41741a82a2d15a841 # v3.28.13
with:
category: '/language:${{matrix.language}}'

View file

@ -29,13 +29,13 @@ jobs:
steps:
- name: Download workflow artifact ⬇️
uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4.1.8
uses: actions/download-artifact@95815c38cf2ff2164869cbab79da8d1f422bc89e # v4.2.1
with:
name: ${{ inputs.artifact_name }}
path: dist
- name: Publish to Cloudflare Pages 📃
uses: cloudflare/wrangler-action@392082e81ffbcb9ebdde27400634aa004b35ea37 # v3.14.0
uses: cloudflare/wrangler-action@da0e0dfe58b7a431659754fdf3f186c529afbe65 # v3.14.1
id: cf
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}

View file

@ -19,7 +19,7 @@ jobs:
ref: ${{ inputs.commit || github.sha }}
- name: Setup node environment
uses: actions/setup-node@1d0ff469b7ec7b3cb9d8673fde0c81c44821de2a # v4.2.0
uses: actions/setup-node@cdca7365b2dadb8aad0a33bc7601856ffabcc48e # v4.3.0
with:
node-version: 20
cache: npm
@ -39,7 +39,7 @@ jobs:
mv dist/config.tmp.json dist/config.json
- name: Upload artifact
uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # v4.6.1
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: frontend
path: dist

View file

@ -48,7 +48,7 @@ jobs:
show-progress: false
- name: Setup node environment ⚙️
uses: actions/setup-node@1d0ff469b7ec7b3cb9d8673fde0c81c44821de2a # v4.2.0
uses: actions/setup-node@cdca7365b2dadb8aad0a33bc7601856ffabcc48e # v4.3.0
with:
node-version: 20
cache: npm

View file

@ -85,7 +85,7 @@ jobs:
ref: ${{ github.event.pull_request.head.sha }}
- name: Setup node environment
uses: actions/setup-node@1d0ff469b7ec7b3cb9d8673fde0c81c44821de2a # v4.2.0
uses: actions/setup-node@cdca7365b2dadb8aad0a33bc7601856ffabcc48e # v4.3.0
with:
node-version: 20
cache: npm
@ -95,6 +95,6 @@ jobs:
run: npm ci --no-audit
- name: Run eslint
uses: CatChen/eslint-suggestion-action@3ba53ce078667d5f60a73a8005627cf95ab57dce # v4.1.9
uses: CatChen/eslint-suggestion-action@623ecbafb1dd3f127bc56596b1145e474fc26c6c # v4.1.11
with:
github-token: ${{ secrets.GITHUB_TOKEN }}

View file

@ -98,7 +98,7 @@ export default tseslint.config(
'sonarjs/fixme-tag': 'warn',
'sonarjs/todo-tag': 'off',
'sonarjs/deprecation': 'warn',
'sonarjs/deprecation': 'off',
'sonarjs/no-alphabetical-sort': 'warn',
'sonarjs/no-inverted-boolean-check': 'error',
'sonarjs/no-selector-parameter': 'off',
@ -329,6 +329,7 @@ export default tseslint.config(
}
}
],
'@typescript-eslint/no-deprecated': 'warn',
'@typescript-eslint/no-floating-promises': 'error',
'@typescript-eslint/prefer-string-starts-ends-with': 'error'
}
@ -366,7 +367,6 @@ export default tseslint.config(
rules: {
'@typescript-eslint/no-floating-promises': 'off',
'@typescript-eslint/no-this-alias': 'off',
'@typescript-eslint/no-unused-vars': 'warn',
'sonarjs/public-static-readonly': 'off',

5461
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -5,96 +5,97 @@
"repository": "https://github.com/jellyfin/jellyfin-web",
"license": "GPL-2.0-or-later",
"devDependencies": {
"@babel/core": "7.26.9",
"@babel/core": "7.26.10",
"@babel/plugin-transform-modules-umd": "7.25.9",
"@babel/preset-env": "7.26.9",
"@babel/preset-react": "7.26.3",
"@eslint-community/eslint-plugin-eslint-comments": "4.4.1",
"@eslint/js": "9.20.0",
"@stylistic/eslint-plugin": "3.1.0",
"@eslint/js": "9.23.0",
"@stylistic/eslint-plugin": "4.2.0",
"@stylistic/stylelint-plugin": "3.1.2",
"@types/dompurify": "3.0.5",
"@types/escape-html": "1.0.4",
"@types/loadable__component": "5.13.9",
"@types/lodash-es": "4.17.12",
"@types/markdown-it": "14.1.2",
"@types/react": "18.3.11",
"@types/react-dom": "18.3.1",
"@types/react": "18.3.19",
"@types/react-dom": "18.3.5",
"@types/react-lazy-load-image-component": "1.6.4",
"@types/sortablejs": "1.15.8",
"@typescript-eslint/parser": "8.24.1",
"@typescript-eslint/parser": "8.27.0",
"@uupaa/dynamic-import-polyfill": "1.0.2",
"@vitest/coverage-v8": "3.0.5",
"autoprefixer": "10.4.20",
"babel-loader": "9.2.1",
"@vitest/coverage-v8": "3.0.9",
"autoprefixer": "10.4.21",
"babel-loader": "10.0.0",
"clean-webpack-plugin": "4.0.0",
"confusing-browser-globals": "1.0.11",
"copy-webpack-plugin": "12.0.2",
"copy-webpack-plugin": "13.0.0",
"cross-env": "7.0.3",
"css-loader": "7.1.2",
"cssnano": "7.0.6",
"es-check": "7.2.1",
"eslint": "9.20.1",
"es-check": "8.0.2",
"eslint": "9.23.0",
"eslint-plugin-compat": "6.0.2",
"eslint-plugin-import": "2.31.0",
"eslint-plugin-jsx-a11y": "6.10.2",
"eslint-plugin-react": "7.37.4",
"eslint-plugin-react-hooks": "5.1.0",
"eslint-plugin-react-hooks": "5.2.0",
"eslint-plugin-sonarjs": "3.0.2",
"expose-loader": "5.0.1",
"fast-glob": "3.3.3",
"fork-ts-checker-webpack-plugin": "9.0.2",
"globals": "15.15.0",
"globals": "16.0.0",
"html-loader": "5.1.0",
"html-webpack-plugin": "5.6.3",
"jsdom": "25.0.1",
"jsdom": "26.0.0",
"mini-css-extract-plugin": "2.9.2",
"postcss": "8.5.2",
"postcss": "8.5.3",
"postcss-loader": "8.1.1",
"postcss-preset-env": "10.1.4",
"postcss-preset-env": "10.1.5",
"postcss-scss": "4.0.9",
"sass": "1.85.0",
"sass": "1.86.0",
"sass-loader": "16.0.5",
"source-map-loader": "5.0.0",
"speed-measure-webpack-plugin": "1.5.0",
"style-loader": "4.0.0",
"stylelint": "16.14.1",
"stylelint": "16.16.0",
"stylelint-config-rational-order": "0.1.2",
"stylelint-no-browser-hacks": "1.3.0",
"stylelint-order": "6.0.4",
"stylelint-scss": "6.11.0",
"stylelint-scss": "6.11.1",
"ts-loader": "9.5.2",
"typescript": "5.7.3",
"typescript-eslint": "8.24.1",
"vitest": "3.0.5",
"typescript": "5.8.2",
"typescript-eslint": "8.27.0",
"vitest": "3.0.9",
"webpack": "5.98.0",
"webpack-bundle-analyzer": "4.10.2",
"webpack-cli": "5.1.4",
"webpack-cli": "6.0.1",
"webpack-dev-server": "5.2.0",
"webpack-merge": "6.0.1",
"worker-loader": "3.0.8"
},
"dependencies": {
"@emotion/react": "11.13.3",
"@emotion/styled": "11.13.0",
"@fontsource/noto-sans": "5.1.1",
"@fontsource/noto-sans-hk": "5.1.1",
"@fontsource/noto-sans-jp": "5.1.1",
"@fontsource/noto-sans-kr": "5.1.1",
"@fontsource/noto-sans-sc": "5.1.1",
"@fontsource/noto-sans-tc": "5.1.1",
"@emotion/react": "11.14.0",
"@emotion/styled": "11.14.0",
"@fontsource/noto-sans": "5.2.6",
"@fontsource/noto-sans-hk": "5.2.5",
"@fontsource/noto-sans-jp": "5.2.5",
"@fontsource/noto-sans-kr": "5.2.5",
"@fontsource/noto-sans-sc": "5.2.5",
"@fontsource/noto-sans-tc": "5.2.5",
"@jellyfin/libass-wasm": "4.2.3",
"@jellyfin/sdk": "0.0.0-unstable.202502210501",
"@mui/icons-material": "5.16.14",
"@mui/material": "5.16.14",
"@mui/x-date-pickers": "7.26.0",
"@jellyfin/sdk": "0.0.0-unstable.202503260501",
"@mui/icons-material": "6.4.8",
"@mui/material": "6.4.8",
"@mui/x-date-pickers": "7.28.0",
"@react-hook/resize-observer": "2.0.2",
"@tanstack/react-query": "5.62.16",
"@tanstack/react-query-devtools": "5.62.16",
"@tanstack/react-query": "5.69.0",
"@tanstack/react-query-devtools": "5.69.0",
"abortcontroller-polyfill": "1.7.8",
"blurhash": "2.0.5",
"classlist.js": "https://github.com/eligrey/classList.js/archive/1.2.20180112.tar.gz",
"classnames": "2.5.1",
"core-js": "3.38.1",
"core-js": "3.41.0",
"date-fns": "2.30.0",
"dompurify": "2.5.8",
"epubjs": "0.3.93",
@ -113,14 +114,14 @@
"lodash-es": "4.17.21",
"markdown-it": "14.1.0",
"material-design-icons-iconfont": "6.7.0",
"material-react-table": "2.13.3",
"material-react-table": "3.2.1",
"native-promise-only": "0.8.1",
"pdfjs-dist": "3.11.174",
"react": "18.3.1",
"react-blurhash": "0.3.0",
"react-dom": "18.3.1",
"react-lazy-load-image-component": "1.6.2",
"react-router-dom": "6.27.0",
"react-lazy-load-image-component": "1.6.3",
"react-router-dom": "6.30.0",
"resize-observer-polyfill": "1.5.1",
"screenfull": "6.0.2",
"sortablejs": "1.15.6",
@ -130,7 +131,7 @@
"whatwg-fetch": "3.6.20"
},
"optionalDependencies": {
"sass-embedded": "1.85.0"
"sass-embedded": "1.86.0"
},
"browserslist": [
"last 2 Firefox versions",

View file

@ -11,6 +11,7 @@ import AppBody from 'components/AppBody';
import AppToolbar from 'components/toolbar/AppToolbar';
import ElevationScroll from 'components/ElevationScroll';
import { DRAWER_WIDTH } from 'components/ResponsiveDrawer';
import ThemeCss from 'components/ThemeCss';
import { useApi } from 'hooks/useApi';
import { useLocale } from 'hooks/useLocale';
@ -101,6 +102,7 @@ export const Component: FC = () => {
</AppBody>
</Box>
</Box>
<ThemeCss dashboard />
</LocalizationProvider>
);
};

View file

@ -1,84 +0,0 @@
<div id="dashboardGeneralPage" data-role="page" class="page type-interior dashboardHomePage" data-title="${General}">
<div>
<div class="content-primary">
<form class="dashboardGeneralForm">
<div class="verticalSection">
<div class="sectionTitleContainer flex align-items-center">
<h2 class="sectionTitle">${Settings}</h2>
</div>
</div>
<div class="verticalSection">
<div class="inputContainer">
<input is="emby-input" type="text" id="txtServerName" label="${LabelServerName}" />
<div class="fieldDescription">${LabelServerNameHelp}</div>
</div>
<div class="selectContainer">
<select is="emby-select" id="selectLocalizationLanguage" label="${LabelPreferredDisplayLanguage}"></select>
<div class="fieldDescription">
<div>${LabelDisplayLanguageHelp}</div>
<div style="margin-top: .25em;">
<a is="emby-linkbutton" rel="noopener noreferrer" class="button-link" href="https://jellyfin.org/docs/general/contributing/#translating" target="_blank">${LearnHowYouCanContribute}</a>
</div>
</div>
</div>
</div>
<div class="verticalSection verticalSection-extrabottompadding">
<h2>${HeaderPaths}</h2>
<div class="inputContainer">
<div style="display: flex; align-items: center;">
<div style="flex-grow:1;">
<input is="emby-input" id="txtCachePath" label="${LabelCachePath}" autocomplete="off" dir="ltr" />
</div>
<button type="button" is="paper-icon-button-light" id="btnSelectCachePath" title="${ButtonSelectDirectory}" class="emby-input-iconbutton"><span class="material-icons search" aria-hidden="true"></span></button>
</div>
<div class="fieldDescription">${LabelCachePathHelp}</div>
</div>
<div class="inputContainer">
<div style="display: flex; align-items: center;">
<div style="flex-grow:1;">
<input is="emby-input" id="txtMetadataPath" label="${LabelMetadataPath}" autocomplete="off" dir="ltr" />
</div>
<button type="button" is="paper-icon-button-light" id="btnSelectMetadataPath" title="${ButtonSelectDirectory}" class="emby-input-iconbutton"><span class="material-icons search" aria-hidden="true"></span></button>
</div>
<div class="fieldDescription">${LabelMetadataPathHelp}</div>
<input type="hidden" id="txtMetadataNetworkPath" />
</div>
</div>
<div class="verticalSection">
<div class="sectionTitleContainer flex align-items-center">
<h2 class="sectionTitle">${QuickConnect}</h2>
</div>
</div>
<div class="checkboxList paperList" style="padding:.5em 1em;">
<label>
<input type="checkbox" is="emby-checkbox" id="chkQuickConnectAvailable" />
<span>${EnableQuickConnect}</span>
</label>
</div>
<div class="verticalSection">
<h2>${HeaderPerformance}</h2>
<div class="inputContainer">
<input is="emby-input" id="txtLibraryScanFanoutConcurrency" label="${LibraryScanFanoutConcurrency}" placeholder="0" type="number" pattern="[0-9]*" min="0" step="1" />
<div class="fieldDescription">${LibraryScanFanoutConcurrencyHelp}</div>
</div>
<div class="inputContainer">
<input is="emby-input" id="txtParallelImageEncodingLimit" label="${LabelParallelImageEncodingLimit}" placeholder="0" type="number" pattern="[0-9]*" min="0" step="1" />
<div class="fieldDescription">${LabelParallelImageEncodingLimitHelp}</div>
</div>
</div>
<br />
<div>
<button is="emby-button" type="submit" class="raised button-submit block">
<span>${Save}</span>
</button>
</div>
</form>
</div>
</div>
</div>

View file

@ -1,105 +0,0 @@
import 'jquery';
import loading from 'components/loading/loading';
import globalize from 'lib/globalize';
import 'elements/emby-checkbox/emby-checkbox';
import 'elements/emby-textarea/emby-textarea';
import 'elements/emby-input/emby-input';
import 'elements/emby-select/emby-select';
import 'elements/emby-button/emby-button';
import Dashboard from 'utils/dashboard';
import alert from 'components/alert';
function loadPage(page, config, languageOptions, systemInfo) {
page.querySelector('#txtServerName').value = systemInfo.ServerName;
page.querySelector('#txtCachePath').value = systemInfo.CachePath || '';
page.querySelector('#chkQuickConnectAvailable').checked = config.QuickConnectAvailable === true;
page.querySelector('#txtMetadataPath').value = systemInfo.InternalMetadataPath || '';
page.querySelector('#txtMetadataNetworkPath').value = systemInfo.MetadataNetworkPath || '';
const localizationLanguageElem = page.querySelector('#selectLocalizationLanguage');
localizationLanguageElem.innerHTML = languageOptions.map(function (language) {
return '<option value="' + language.Value + '">' + language.Name + '</option>';
}).join('');
localizationLanguageElem.value = config.UICulture;
page.querySelector('#txtLibraryScanFanoutConcurrency').value = config.LibraryScanFanoutConcurrency || '';
page.querySelector('#txtParallelImageEncodingLimit').value = config.ParallelImageEncodingLimit || '';
loading.hide();
}
function onSubmit() {
loading.show();
const form = this;
ApiClient.getServerConfiguration().then(function (config) {
config.ServerName = form.querySelector('#txtServerName').value;
config.UICulture = form.querySelector('#selectLocalizationLanguage').value;
config.CachePath = form.querySelector('#txtCachePath').value;
config.MetadataPath = form.querySelector('#txtMetadataPath').value;
config.MetadataNetworkPath = form.querySelector('#txtMetadataNetworkPath').value;
config.QuickConnectAvailable = form.querySelector('#chkQuickConnectAvailable').checked;
config.LibraryScanFanoutConcurrency = parseInt(form.querySelector('#txtLibraryScanFanoutConcurrency').value || '0', 10);
config.ParallelImageEncodingLimit = parseInt(form.querySelector('#txtParallelImageEncodingLimit').value || '0', 10);
return ApiClient.updateServerConfiguration(config)
.then(() => {
Dashboard.processServerConfigurationUpdateResult();
}).catch(() => {
loading.hide();
alert(globalize.translate('ErrorDefault'));
});
});
return false;
}
export default function (view) {
$('#btnSelectCachePath', view).on('click.selectDirectory', function () {
import('components/directorybrowser/directorybrowser').then(({ default: DirectoryBrowser }) => {
const picker = new DirectoryBrowser();
picker.show({
callback: function (path) {
if (path) {
view.querySelector('#txtCachePath').value = path;
}
picker.close();
},
validateWriteable: true,
header: globalize.translate('HeaderSelectServerCachePath'),
instruction: globalize.translate('HeaderSelectServerCachePathHelp')
});
});
});
$('#btnSelectMetadataPath', view).on('click.selectDirectory', function () {
import('components/directorybrowser/directorybrowser').then(({ default: DirectoryBrowser }) => {
const picker = new DirectoryBrowser();
picker.show({
path: view.querySelector('#txtMetadataPath').value,
networkSharePath: view.querySelector('#txtMetadataNetworkPath').value,
callback: function (path, networkPath) {
if (path) {
view.querySelector('#txtMetadataPath').value = path;
}
if (networkPath) {
view.querySelector('#txtMetadataNetworkPath').value = networkPath;
}
picker.close();
},
validateWriteable: true,
header: globalize.translate('HeaderSelectMetadataPath'),
instruction: globalize.translate('HeaderSelectMetadataPathHelp')
});
});
});
$('.dashboardGeneralForm', view).off('submit', onSubmit).on('submit', onSubmit);
view.addEventListener('viewshow', function () {
const promiseConfig = ApiClient.getServerConfiguration();
const promiseLanguageOptions = ApiClient.getJSON(ApiClient.getUrl('Localization/Options'));
const promiseSystemInfo = ApiClient.getSystemInfo();
Promise.all([promiseConfig, promiseLanguageOptions, promiseSystemInfo]).then(function (responses) {
loadPage(view, responses[0], responses[1], responses[2]);
});
});
}

View file

@ -1,49 +0,0 @@
<div id="metadataNfoPage" data-role="page" class="page type-interior metadataConfigurationPage" data-title="${TabNfoSettings}">
<div>
<div class="content-primary">
<form class="metadataNfoForm">
<p>${HeaderKodiMetadataHelp}</p>
<br />
<div class="selectContainer">
<select is="emby-select" name="selectUser" id="selectUser" label="${LabelKodiMetadataUser}"></select>
<div class="fieldDescription">${LabelKodiMetadataUserHelp}</div>
</div>
<div class="selectContainer">
<select is="emby-select" name="selectReleaseDateFormat" id="selectReleaseDateFormat" label="${LabelKodiMetadataDateFormat}">
<option value="yyyy-MM-dd">yyyy-MM-dd</option>
</select>
<div class="fieldDescription">${LabelKodiMetadataDateFormatHelp}</div>
</div>
<div class="checkboxContainer checkboxContainer-withDescription">
<label>
<input type="checkbox" is="emby-checkbox" id="chkSaveImagePaths" />
<span>${LabelKodiMetadataSaveImagePaths}</span>
</label>
<div class="fieldDescription checkboxFieldDescription">${LabelKodiMetadataSaveImagePathsHelp}</div>
</div>
<div class="checkboxContainer checkboxContainer-withDescription">
<label>
<input type="checkbox" is="emby-checkbox" id="chkEnablePathSubstitution" />
<span>${LabelKodiMetadataEnablePathSubstitution}</span>
</label>
<div class="fieldDescription checkboxFieldDescription">
<div>${LabelKodiMetadataEnablePathSubstitutionHelp}</div>
</div>
</div>
<div class="checkboxContainer checkboxContainer-withDescription">
<label>
<input type="checkbox" is="emby-checkbox" id="chkEnableExtraThumbs" />
<span>${LabelKodiMetadataEnableExtraThumbs}</span>
</label>
<div class="fieldDescription checkboxFieldDescription">${LabelKodiMetadataEnableExtraThumbsHelp}</div>
</div>
<div><button is="emby-button" type="submit" class="raised button-submit block"><span>${Save}</span></button></div>
</form>
</div>
</div>
</div>

View file

@ -1,61 +0,0 @@
import escapeHtml from 'escape-html';
import 'jquery';
import loading from 'components/loading/loading';
import globalize from 'lib/globalize';
import Dashboard from 'utils/dashboard';
import alert from 'components/alert';
function loadPage(page, config, users) {
let html = '<option value="" selected="selected">' + globalize.translate('None') + '</option>';
html += users.map(function (user) {
return '<option value="' + user.Id + '">' + escapeHtml(user.Name) + '</option>';
}).join('');
const elem = page.querySelector('#selectUser');
elem.innerHTML = html;
elem.value = config.UserId || '';
page.querySelector('#selectReleaseDateFormat').value = config.ReleaseDateFormat;
page.querySelector('#chkSaveImagePaths').checked = config.SaveImagePathsInNfo;
page.querySelector('#chkEnablePathSubstitution').checked = config.EnablePathSubstitution;
page.querySelector('#chkEnableExtraThumbs').checked = config.EnableExtraThumbsDuplication;
loading.hide();
}
function onSubmit() {
loading.show();
const form = this;
ApiClient.getNamedConfiguration(metadataKey).then(function (config) {
config.UserId = form.querySelector('#selectUser').value || null;
config.ReleaseDateFormat = form.querySelector('#selectReleaseDateFormat').value;
config.SaveImagePathsInNfo = form.querySelector('#chkSaveImagePaths').checked;
config.EnablePathSubstitution = form.querySelector('#chkEnablePathSubstitution').checked;
config.EnableExtraThumbsDuplication = form.querySelector('#chkEnableExtraThumbs').checked;
ApiClient.updateNamedConfiguration(metadataKey, config).then(function () {
Dashboard.processServerConfigurationUpdateResult();
showConfirmMessage();
});
});
return false;
}
function showConfirmMessage() {
const msg = [];
msg.push(globalize.translate('MetadataSettingChangeHelp'));
alert({
text: msg.join('<br/><br/>')
});
}
const metadataKey = 'xbmcmetadata';
$(document).on('pageinit', '#metadataNfoPage', function () {
$('.metadataNfoForm').off('submit', onSubmit).on('submit', onSubmit);
}).on('pageshow', '#metadataNfoPage', function () {
loading.show();
const page = this;
const promise1 = ApiClient.getUsers();
const promise2 = ApiClient.getNamedConfiguration(metadataKey);
Promise.all([promise1, promise2]).then(function (responses) {
loadPage(page, responses[1], responses[0]);
});
});

View file

@ -1,84 +0,0 @@
<div id="scheduledTaskPage" data-role="page" class="page type-interior scheduledTasksConfigurationPage">
<div>
<div class="content-primary">
<div class="verticalSection">
<div class="sectionTitleContainer flex align-items-center">
<h2 class="sectionTitle taskName"></h2>
</div>
<p id="pTaskDescription"></p>
</div>
<div class="readOnlyContent">
<div>
<h2 style="vertical-align: middle; display: inline-block;">${HeaderTaskTriggers}</h2>
<button is="emby-button" type="button" class="fab fab-mini btnAddTrigger submit" style="margin-left: 1em;" title="${ButtonAddScheduledTaskTrigger}">
<span class="material-icons add" aria-hidden="true"></span>
</button>
</div>
<div class="taskTriggers"></div>
</div>
</div>
</div>
<div data-role="popup" id="popupAddTrigger" class="dialog dialog-fixedSize dialog-medium hide" style="position: fixed; top: 10%; z-index: 999999;">
<form class="addTriggerForm" style="padding:1em;">
<div class="ui-bar-a">
<h3>${ButtonAddScheduledTaskTrigger}</h3>
</div>
<div data-role="content">
<div class="selectContainer">
<select is="emby-select" id="selectTriggerType" class="selectTriggerType" label="${LabelTriggerType}">
<option value="DailyTrigger">${OptionDaily}</option>
<option value="WeeklyTrigger">${OptionWeekly}</option>
<option value="IntervalTrigger">${OptionOnInterval}</option>
<option value="StartupTrigger">${OnApplicationStartup}</option>
</select>
</div>
<div id="fldDayOfWeek" class="selectContainer">
<select is="emby-select" id="selectDayOfWeek" name="selectDayOfWeek" label="${LabelDay}">
<option value="Sunday">${Sunday}</option>
<option value="Monday">${Monday}</option>
<option value="Tuesday">${Tuesday}</option>
<option value="Wednesday">${Wednesday}</option>
<option value="Thursday">${Thursday}</option>
<option value="Friday">${Friday}</option>
<option value="Saturday">${Saturday}</option>
</select>
</div>
<div id="fldTimeOfDay" class="selectContainer">
<select is="emby-select" id="selectTimeOfDay" label="${LabelTime}"></select>
</div>
<div id="fldSelectSystemEvent" class="selectContainer">
<select is="emby-select" id="selectSystemEvent" name="selectSystemEvent" label="${LabelEvent}">
<option value="WakeFromSleep">${OptionWakeFromSleep}</option>
</select>
</div>
<div id="fldSelectInterval" class="selectContainer">
<select is="emby-select" id="selectInterval" label="${LabelEveryXMinutes}">
<option value="9000000000">15 minutes</option>
<option value="18000000000">30 minutes</option>
<option value="27000000000">45 minutes</option>
<option value="36000000000">1 hour</option>
<option value="72000000000">2 hours</option>
<option value="108000000000">3 hours</option>
<option value="144000000000">4 hours</option>
<option value="216000000000">6 hours</option>
<option value="288000000000">8 hours</option>
<option value="432000000000">12 hours</option>
<option value="864000000000">24 hours</option>
</select>
</div>
<div class="inputContainer">
<input is="emby-input" id="txtTimeLimit" type="number" pattern="[0-9]*" min="1" step=".5" label="${LabelTimeLimitHours}" />
</div>
<div>
<button is="emby-button" type="submit" class="raised button-submit block" data-icon="check">
<span>${Add}</span>
</button>
<button is="emby-button" type="button" class="raised button-cancel block" data-icon="delete" onclick="$(this).parents('.dialog').addClass('hide');">
<span>${ButtonCancel}</span>
</button>
</div>
</div>
</form>
</div>
</div>

View file

@ -1,236 +0,0 @@
import loading from 'components/loading/loading';
import datetime from 'scripts/datetime';
import dom from 'scripts/dom';
import globalize from 'lib/globalize';
import 'elements/emby-input/emby-input';
import 'elements/emby-button/emby-button';
import 'elements/emby-select/emby-select';
import confirm from 'components/confirm/confirm';
import { getParameterByName } from 'utils/url.ts';
function fillTimeOfDay(select) {
const options = [];
for (let i = 0; i < 86400000; i += 900000) {
options.push({
name: ScheduledTaskPage.getDisplayTime(i * 10000),
value: i * 10000
});
}
select.innerHTML = options.map(function (o) {
return '<option value="' + o.value + '">' + o.name + '</option>';
}).join('');
}
const ScheduledTaskPage = {
refreshScheduledTask: function (view) {
loading.show();
const id = getParameterByName('id');
ApiClient.getScheduledTask(id).then(function (task) {
ScheduledTaskPage.loadScheduledTask(view, task);
});
},
loadScheduledTask: function (view, task) {
view.querySelector('.taskName').innerHTML = task.Name;
view.querySelector('#pTaskDescription').innerHTML = task.Description;
import('components/listview/listview.scss').then(() => {
ScheduledTaskPage.loadTaskTriggers(view, task);
});
loading.hide();
},
loadTaskTriggers: function (context, task) {
let html = '';
html += '<div class="paperList">';
for (let i = 0, length = task.Triggers.length; i < length; i++) {
const trigger = task.Triggers[i];
html += '<div class="listItem listItem-border">';
html += '<span class="material-icons listItemIcon schedule" aria-hidden="true"></span>';
if (trigger.MaxRuntimeTicks) {
html += '<div class="listItemBody two-line">';
} else {
html += '<div class="listItemBody">';
}
html += "<div class='listItemBodyText'>" + ScheduledTaskPage.getTriggerFriendlyName(trigger) + '</div>';
if (trigger.MaxRuntimeTicks) {
html += '<div class="listItemBodyText secondary">';
const hours = trigger.MaxRuntimeTicks / 36e9;
if (hours == 1) {
html += globalize.translate('ValueTimeLimitSingleHour');
} else {
html += globalize.translate('ValueTimeLimitMultiHour', hours);
}
html += '</div>';
}
html += '</div>';
html += '<button class="btnDeleteTrigger" data-index="' + i + '" type="button" is="paper-icon-button-light" title="' + globalize.translate('Delete') + '"><span class="material-icons delete" aria-hidden="true"></span></button>';
html += '</div>';
}
html += '</div>';
context.querySelector('.taskTriggers').innerHTML = html;
},
// TODO: Replace this mess with date-fns and remove datetime completely
getTriggerFriendlyName: function (trigger) {
if (trigger.Type == 'DailyTrigger') {
return globalize.translate('DailyAt', ScheduledTaskPage.getDisplayTime(trigger.TimeOfDayTicks));
}
if (trigger.Type == 'WeeklyTrigger') {
// TODO: The day of week isn't localised as well
return globalize.translate('WeeklyAt', trigger.DayOfWeek, ScheduledTaskPage.getDisplayTime(trigger.TimeOfDayTicks));
}
if (trigger.Type == 'SystemEventTrigger' && trigger.SystemEvent == 'WakeFromSleep') {
return globalize.translate('OnWakeFromSleep');
}
if (trigger.Type == 'IntervalTrigger') {
const hours = trigger.IntervalTicks / 36e9;
if (hours == 0.25) {
return globalize.translate('EveryXMinutes', '15');
}
if (hours == 0.5) {
return globalize.translate('EveryXMinutes', '30');
}
if (hours == 0.75) {
return globalize.translate('EveryXMinutes', '45');
}
if (hours == 1) {
return globalize.translate('EveryHour');
}
return globalize.translate('EveryXHours', hours);
}
if (trigger.Type == 'StartupTrigger') {
return globalize.translate('OnApplicationStartup');
}
return trigger.Type;
},
getDisplayTime: function (ticks) {
const ms = ticks / 1e4;
const now = new Date();
now.setHours(0, 0, 0, 0);
now.setTime(now.getTime() + ms);
return datetime.getDisplayTime(now);
},
showAddTriggerPopup: function (view) {
view.querySelector('#selectTriggerType').value = 'DailyTrigger';
view.querySelector('#selectTriggerType').dispatchEvent(new CustomEvent('change', {}));
view.querySelector('#popupAddTrigger').classList.remove('hide');
},
confirmDeleteTrigger: function (view, index) {
confirm(globalize.translate('MessageDeleteTaskTrigger'), globalize.translate('HeaderDeleteTaskTrigger')).then(function () {
ScheduledTaskPage.deleteTrigger(view, index);
});
},
deleteTrigger: function (view, index) {
loading.show();
const id = getParameterByName('id');
ApiClient.getScheduledTask(id).then(function (task) {
task.Triggers.splice(index, 1);
ApiClient.updateScheduledTaskTriggers(task.Id, task.Triggers).then(function () {
ScheduledTaskPage.refreshScheduledTask(view);
});
});
},
refreshTriggerFields: function (page, triggerType) {
if (triggerType == 'DailyTrigger') {
page.querySelector('#fldTimeOfDay').classList.remove('hide');
page.querySelector('#fldDayOfWeek').classList.add('hide');
page.querySelector('#fldSelectSystemEvent').classList.add('hide');
page.querySelector('#fldSelectInterval').classList.add('hide');
page.querySelector('#selectTimeOfDay').setAttribute('required', 'required');
} else if (triggerType == 'WeeklyTrigger') {
page.querySelector('#fldTimeOfDay').classList.remove('hide');
page.querySelector('#fldDayOfWeek').classList.remove('hide');
page.querySelector('#fldSelectSystemEvent').classList.add('hide');
page.querySelector('#fldSelectInterval').classList.add('hide');
page.querySelector('#selectTimeOfDay').setAttribute('required', 'required');
} else if (triggerType == 'SystemEventTrigger') {
page.querySelector('#fldTimeOfDay').classList.add('hide');
page.querySelector('#fldDayOfWeek').classList.add('hide');
page.querySelector('#fldSelectSystemEvent').classList.remove('hide');
page.querySelector('#fldSelectInterval').classList.add('hide');
page.querySelector('#selectTimeOfDay').removeAttribute('required');
} else if (triggerType == 'IntervalTrigger') {
page.querySelector('#fldTimeOfDay').classList.add('hide');
page.querySelector('#fldDayOfWeek').classList.add('hide');
page.querySelector('#fldSelectSystemEvent').classList.add('hide');
page.querySelector('#fldSelectInterval').classList.remove('hide');
page.querySelector('#selectTimeOfDay').removeAttribute('required');
} else if (triggerType == 'StartupTrigger') {
page.querySelector('#fldTimeOfDay').classList.add('hide');
page.querySelector('#fldDayOfWeek').classList.add('hide');
page.querySelector('#fldSelectSystemEvent').classList.add('hide');
page.querySelector('#fldSelectInterval').classList.add('hide');
page.querySelector('#selectTimeOfDay').removeAttribute('required');
}
},
getTriggerToAdd: function (page) {
const trigger = {
Type: page.querySelector('#selectTriggerType').value
};
if (trigger.Type == 'DailyTrigger') {
trigger.TimeOfDayTicks = page.querySelector('#selectTimeOfDay').value;
} else if (trigger.Type == 'WeeklyTrigger') {
trigger.DayOfWeek = page.querySelector('#selectDayOfWeek').value;
trigger.TimeOfDayTicks = page.querySelector('#selectTimeOfDay').value;
} else if (trigger.Type == 'SystemEventTrigger') {
trigger.SystemEvent = page.querySelector('#selectSystemEvent').value;
} else if (trigger.Type == 'IntervalTrigger') {
trigger.IntervalTicks = page.querySelector('#selectInterval').value;
}
let timeLimit = page.querySelector('#txtTimeLimit').value || '0';
timeLimit = parseFloat(timeLimit) * 3600000;
trigger.MaxRuntimeTicks = timeLimit * 1e4 || null;
return trigger;
}
};
export default function (view) {
function onSubmit(e) {
loading.show();
const id = getParameterByName('id');
ApiClient.getScheduledTask(id).then(function (task) {
task.Triggers.push(ScheduledTaskPage.getTriggerToAdd(view));
ApiClient.updateScheduledTaskTriggers(task.Id, task.Triggers).then(function () {
document.querySelector('#popupAddTrigger').classList.add('hide');
ScheduledTaskPage.refreshScheduledTask(view);
});
});
e.preventDefault();
}
view.querySelector('.addTriggerForm').addEventListener('submit', onSubmit);
fillTimeOfDay(view.querySelector('#selectTimeOfDay'));
view.querySelector('#popupAddTrigger').parentNode.trigger(new Event('create'));
view.querySelector('.selectTriggerType').addEventListener('change', function () {
ScheduledTaskPage.refreshTriggerFields(view, this.value);
});
view.querySelector('.btnAddTrigger').addEventListener('click', function () {
ScheduledTaskPage.showAddTriggerPopup(view);
});
view.addEventListener('click', function (e) {
const btnDeleteTrigger = dom.parentWithClass(e.target, 'btnDeleteTrigger');
if (btnDeleteTrigger) {
ScheduledTaskPage.confirmDeleteTrigger(view, parseInt(btnDeleteTrigger.getAttribute('data-index'), 10));
}
});
view.addEventListener('viewshow', function () {
ScheduledTaskPage.refreshScheduledTask(view);
});
}

View file

@ -0,0 +1,21 @@
import { Api } from '@jellyfin/sdk';
import { getLocalizationApi } from '@jellyfin/sdk/lib/utils/api/localization-api';
import { useQuery } from '@tanstack/react-query';
import { useApi } from 'hooks/useApi';
import type { AxiosRequestConfig } from 'axios';
const fetchLocalizationOptions = async (api: Api, options?: AxiosRequestConfig) => {
const response = await getLocalizationApi(api).getLocalizationOptions(options);
return response.data;
};
export const useLocalizationOptions = () => {
const { api } = useApi();
return useQuery({
queryKey: [ 'LocalizationOptions' ],
queryFn: ({ signal }) => fetchLocalizationOptions(api!, { signal }),
enabled: !!api
});
};

View file

@ -0,0 +1,29 @@
import type { ScheduledTasksApiGetTaskRequest } from '@jellyfin/sdk/lib/generated-client/api/scheduled-tasks-api';
import type { AxiosRequestConfig } from 'axios';
import type { Api } from '@jellyfin/sdk';
import { getScheduledTasksApi } from '@jellyfin/sdk/lib/utils/api/scheduled-tasks-api';
import { useQuery } from '@tanstack/react-query';
import { useApi } from 'hooks/useApi';
import { QUERY_KEY } from './useTasks';
const fetchTask = async (
api: Api,
params: ScheduledTasksApiGetTaskRequest,
options?: AxiosRequestConfig
) => {
const response = await getScheduledTasksApi(api).getTask(params, options);
return response.data;
};
export const useTask = (params: ScheduledTasksApiGetTaskRequest) => {
const { api } = useApi();
return useQuery({
queryKey: [ QUERY_KEY, params.taskId ],
queryFn: ({ signal }) =>
fetchTask(api!, params, { signal }),
enabled: !!api
});
};

View file

@ -22,7 +22,7 @@ export const useTasks = (params?: ScheduledTasksApiGetTasksRequest) => {
const { api } = useApi();
return useQuery({
queryKey: [QUERY_KEY],
queryKey: [ QUERY_KEY ],
queryFn: ({ signal }) =>
fetchTasks(api!, params, { signal }),
enabled: !!api

View file

@ -0,0 +1,22 @@
import { ScheduledTasksApiUpdateTaskRequest } from '@jellyfin/sdk/lib/generated-client/api/scheduled-tasks-api';
import { getScheduledTasksApi } from '@jellyfin/sdk/lib/utils/api/scheduled-tasks-api';
import { useMutation } from '@tanstack/react-query';
import { useApi } from 'hooks/useApi';
import { queryClient } from 'utils/query/queryClient';
import { QUERY_KEY } from './useTasks';
export const useUpdateTask = () => {
const { api } = useApi();
return useMutation({
mutationFn: (params: ScheduledTasksApiUpdateTaskRequest) => (
getScheduledTasksApi(api!)
.updateTask(params)
),
onSuccess: (_data, params) => {
void queryClient.invalidateQueries({
queryKey: [ QUERY_KEY, params.taskId ]
});
}
});
};

View file

@ -0,0 +1,173 @@
import React, { FunctionComponent, useCallback, useMemo, useState } from 'react';
import Dialog from '@mui/material/Dialog';
import Button from '@mui/material/Button';
import DialogActions from '@mui/material/DialogActions';
import DialogContent from '@mui/material/DialogContent';
import DialogTitle from '@mui/material/DialogTitle';
import MenuItem from '@mui/material/MenuItem';
import Stack from '@mui/material/Stack';
import TextField from '@mui/material/TextField';
import type { TaskTriggerInfo } from '@jellyfin/sdk/lib/generated-client/models/task-trigger-info';
import { TaskTriggerInfoType } from '@jellyfin/sdk/lib/generated-client/models/task-trigger-info-type';
import { DayOfWeek } from '@jellyfin/sdk/lib/generated-client/models/day-of-week';
import globalize from 'lib/globalize';
import { getIntervalOptions, getTimeOfDayOptions } from '../utils/edit';
import { useLocale } from 'hooks/useLocale';
type IProps = {
open: boolean,
title: string,
onClose?: () => void,
onAdd?: (trigger: TaskTriggerInfo) => void
};
const NewTriggerForm: FunctionComponent<IProps> = ({ open, title, onClose, onAdd }: IProps) => {
const { dateFnsLocale } = useLocale();
const [triggerType, setTriggerType] = useState<TaskTriggerInfoType>(TaskTriggerInfoType.DailyTrigger);
const timeOfDayOptions = useMemo(() => getTimeOfDayOptions(dateFnsLocale), [dateFnsLocale]);
const intervalOptions = useMemo(() => getIntervalOptions(dateFnsLocale), [dateFnsLocale]);
const onTriggerTypeChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
setTriggerType(e.target.value as TaskTriggerInfoType);
}, []);
const onSubmit = useCallback((e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
const data = Object.fromEntries(formData.entries());
const trigger: TaskTriggerInfo = {
Type: data.TriggerType.toString() as TaskTriggerInfoType
};
if (trigger.Type == TaskTriggerInfoType.WeeklyTrigger) {
trigger.DayOfWeek = data.DayOfWeek.toString() as DayOfWeek;
}
if (trigger.Type == TaskTriggerInfoType.DailyTrigger || trigger.Type == TaskTriggerInfoType.WeeklyTrigger) {
trigger.TimeOfDayTicks = parseInt(data.TimeOfDay.toString(), 10);
}
if (trigger.Type == TaskTriggerInfoType.IntervalTrigger) {
trigger.IntervalTicks = parseInt(data.Interval.toString(), 10);
}
if (data.TimeLimit.toString()) {
trigger.MaxRuntimeTicks = parseFloat(data.TimeLimit.toString()) * 36e9;
}
if (onAdd) {
onAdd(trigger);
}
}, [ onAdd ]);
return (
<Dialog
open={open}
maxWidth={'xs'}
fullWidth
onClose={onClose}
PaperProps={{
component: 'form',
onSubmit: onSubmit
}}
>
<DialogTitle>{title}</DialogTitle>
<DialogContent>
<Stack spacing={3}>
<TextField
name='TriggerType'
select
fullWidth
value={triggerType}
onChange={onTriggerTypeChange}
label={globalize.translate('LabelTriggerType')}
>
<MenuItem value={TaskTriggerInfoType.DailyTrigger}>{globalize.translate('OptionDaily')}</MenuItem>
<MenuItem value={TaskTriggerInfoType.WeeklyTrigger}>{globalize.translate('OptionWeekly')}</MenuItem>
<MenuItem value={TaskTriggerInfoType.IntervalTrigger}>{globalize.translate('OptionOnInterval')}</MenuItem>
<MenuItem value={TaskTriggerInfoType.StartupTrigger}>{globalize.translate('OnApplicationStartup')}</MenuItem>
</TextField>
{triggerType == TaskTriggerInfoType.WeeklyTrigger && (
<TextField
name='DayOfWeek'
select
fullWidth
defaultValue={DayOfWeek.Sunday}
label={globalize.translate('LabelDay')}
>
<MenuItem value={DayOfWeek.Sunday}>{globalize.translate('Sunday')}</MenuItem>
<MenuItem value={DayOfWeek.Monday}>{globalize.translate('Monday')}</MenuItem>
<MenuItem value={DayOfWeek.Tuesday}>{globalize.translate('Tuesday')}</MenuItem>
<MenuItem value={DayOfWeek.Wednesday}>{globalize.translate('Wednesday')}</MenuItem>
<MenuItem value={DayOfWeek.Thursday}>{globalize.translate('Thursday')}</MenuItem>
<MenuItem value={DayOfWeek.Friday}>{globalize.translate('Friday')}</MenuItem>
<MenuItem value={DayOfWeek.Saturday}>{globalize.translate('Saturday')}</MenuItem>
</TextField>
)}
{(triggerType == TaskTriggerInfoType.DailyTrigger || triggerType == TaskTriggerInfoType.WeeklyTrigger) && (
<TextField
name='TimeOfDay'
select
fullWidth
defaultValue={'0'}
label={globalize.translate('LabelTime')}
>
{timeOfDayOptions.map((option) => {
return <MenuItem
key={option.value}
value={option.value}
>{option.name}</MenuItem>;
})}
</TextField>
)}
{triggerType == TaskTriggerInfoType.IntervalTrigger && (
<TextField
name='Interval'
select
fullWidth
defaultValue={intervalOptions[0].value}
label={globalize.translate('LabelEveryXMinutes')}
>
{intervalOptions.map((option) => {
return <MenuItem
key={option.value}
value={option.value}
>{option.name}</MenuItem>;
})}
</TextField>
)}
<TextField
name='TimeLimit'
fullWidth
defaultValue={''}
type='number'
label={globalize.translate('LabelTimeLimitHours')}
slotProps={{
htmlInput: {
min: 1,
step: 0.5
}
}}
/>
</Stack>
</DialogContent>
<DialogActions>
<Button
onClick={onClose}
color='error'
>{globalize.translate('ButtonCancel')}</Button>
<Button type='submit'>{globalize.translate('Add')}</Button>
</DialogActions>
</Dialog>
);
};
export default NewTriggerForm;

View file

@ -2,11 +2,9 @@ import React, { FunctionComponent, useCallback } from 'react';
import ListItem from '@mui/material/ListItem';
import Avatar from '@mui/material/Avatar';
import AccessTimeIcon from '@mui/icons-material/AccessTime';
import ListItemButton from '@mui/material/ListItemButton';
import ListItemAvatar from '@mui/material/ListItemAvatar';
import ListItemText from '@mui/material/ListItemText';
import Typography from '@mui/material/Typography';
import Dashboard from 'utils/dashboard';
import { TaskProps } from '../types/taskProps';
import TaskProgress from './TaskProgress';
import TaskLastRan from './TaskLastRan';
@ -15,18 +13,12 @@ import PlayArrow from '@mui/icons-material/PlayArrow';
import Stop from '@mui/icons-material/Stop';
import { useStartTask } from '../api/useStartTask';
import { useStopTask } from '../api/useStopTask';
import ListItemLink from 'components/ListItemLink';
const Task: FunctionComponent<TaskProps> = ({ task }: TaskProps) => {
const startTask = useStartTask();
const stopTask = useStopTask();
const navigateTaskEdit = useCallback(() => {
Dashboard.navigate(`/dashboard/tasks/edit?id=${task.Id}`)
.catch(err => {
console.error('[Task] failed to navigate to task edit page', err);
});
}, [task]);
const handleStartTask = useCallback(() => {
if (task.Id) {
startTask.mutate({ taskId: task.Id });
@ -48,7 +40,7 @@ const Task: FunctionComponent<TaskProps> = ({ task }: TaskProps) => {
</IconButton>
}
>
<ListItemButton onClick={navigateTaskEdit}>
<ListItemLink to={`/dashboard/tasks/${task.Id}`}>
<ListItemAvatar>
<Avatar sx={{ bgcolor: 'primary.main' }}>
<AccessTimeIcon sx={{ color: '#fff' }} />
@ -59,7 +51,7 @@ const Task: FunctionComponent<TaskProps> = ({ task }: TaskProps) => {
secondary={task.State == 'Running' ? <TaskProgress task={task} /> : <TaskLastRan task={task} />}
disableTypography
/>
</ListItemButton>
</ListItemLink>
</ListItem>
);
};

View file

@ -0,0 +1,34 @@
import React, { FC } from 'react';
import type { MRT_Cell, MRT_RowData } from 'material-react-table';
import { useLocale } from 'hooks/useLocale';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import { getTriggerFriendlyName } from '../utils/edit';
import type { TaskTriggerInfo } from '@jellyfin/sdk/lib/generated-client/models/task-trigger-info';
import globalize from 'lib/globalize';
interface CellProps {
cell: MRT_Cell<MRT_RowData>
}
const TaskTriggerCell: FC<CellProps> = ({ cell }) => {
const { dateFnsLocale } = useLocale();
const trigger = cell.getValue<TaskTriggerInfo>();
const timeLimitHours = trigger.MaxRuntimeTicks ? trigger.MaxRuntimeTicks / 36e9 : false;
return (
<Box>
<Typography variant='body1'>{getTriggerFriendlyName(trigger, dateFnsLocale)}</Typography>
{timeLimitHours && (
<Typography variant='body2' color={'text.secondary'}>
{timeLimitHours == 1 ?
globalize.translate('ValueTimeLimitSingleHour') :
globalize.translate('ValueTimeLimitMultiHour', timeLimitHours)}
</Typography>
)}
</Box>
);
};
export default TaskTriggerCell;

View file

@ -0,0 +1,13 @@
export const INTERVAL_DURATIONS: number[] = [
9000000000, // 15 minutes
18000000000, // 30 minutes
27000000000, // 45 minutes
36000000000, // 1 hour
72000000000, // 2 hours
108000000000, // 3 hours
144000000000, // 4 hours
216000000000, // 6 hours
288000000000, // 8 hours
432000000000, // 12 hours
864000000000 // 24 hours
];

View file

@ -0,0 +1,80 @@
import type { TaskTriggerInfo } from '@jellyfin/sdk/lib/generated-client/models/task-trigger-info';
import { format, formatDistanceStrict, Locale, parse } from 'date-fns';
import globalize from 'lib/globalize';
import { INTERVAL_DURATIONS } from '../constants/intervalDurations';
function getDisplayTime(ticks: number, locale: Locale) {
const ms = ticks / 1e4;
const now = new Date();
now.setHours(0, 0, 0, 0);
now.setTime(now.getTime() + ms);
return format(now, 'p', { locale: locale });
}
export function getTimeOfDayOptions(locale: Locale) {
const options = [];
for (let i = 0; i < 86400000; i += 900000) {
options.push({
name: getDisplayTime(i * 10000, locale),
value: i * 10000
});
}
return options;
}
export function getIntervalOptions(locale: Locale) {
const options = [];
for (const ticksDuration of INTERVAL_DURATIONS) {
const durationMs = Math.floor(ticksDuration / 1e4);
const unit = durationMs < 36e5 ? 'minute' : 'hour';
options.push({
name: formatDistanceStrict(0, durationMs, { locale: locale, unit: unit }),
value: ticksDuration
});
}
return options;
}
function getIntervalTriggerTime(ticks: number) {
const hours = ticks / 36e9;
switch (hours) {
case 0.25:
return globalize.translate('EveryXMinutes', '15');
case 0.5:
return globalize.translate('EveryXMinutes', '30');
case 0.75:
return globalize.translate('EveryXMinutes', '45');
case 1:
return globalize.translate('EveryHour');
default:
return globalize.translate('EveryXHours', hours);
}
}
function localizeDayOfWeek(dayOfWeek: string | null | undefined, locale: Locale) {
if (!dayOfWeek) return '';
const parsedDayOfWeek = parse(dayOfWeek, 'cccc', new Date());
return format(parsedDayOfWeek, 'cccc', { locale: locale });
}
export function getTriggerFriendlyName(trigger: TaskTriggerInfo, locale: Locale) {
switch (trigger.Type) {
case 'DailyTrigger':
return globalize.translate('DailyAt', getDisplayTime(trigger.TimeOfDayTicks || 0, locale));
case 'WeeklyTrigger':
return globalize.translate('WeeklyAt', localizeDayOfWeek(trigger.DayOfWeek, locale), getDisplayTime(trigger.TimeOfDayTicks || 0, locale));
case 'IntervalTrigger':
return getIntervalTriggerTime(trigger.IntervalTicks || 0);
case 'StartupTrigger':
return globalize.translate('OnApplicationStartup');
default:
return trigger.Type;
}
}

View file

@ -5,9 +5,11 @@ export const ASYNC_ADMIN_ROUTES: AsyncRoute[] = [
{ path: 'activity', type: AppType.Dashboard },
{ path: 'branding', type: AppType.Dashboard },
{ path: 'devices', type: AppType.Dashboard },
{ path: 'settings', type: AppType.Dashboard },
{ path: 'keys', type: AppType.Dashboard },
{ path: 'libraries/display', type: AppType.Dashboard },
{ path: 'libraries/metadata', type: AppType.Dashboard },
{ path: 'libraries/nfo', type: AppType.Dashboard },
{ path: 'logs', type: AppType.Dashboard },
{ path: 'logs/:file', page: 'logs/file', type: AppType.Dashboard },
{ path: 'playback/resume', type: AppType.Dashboard },
@ -15,6 +17,7 @@ export const ASYNC_ADMIN_ROUTES: AsyncRoute[] = [
{ path: 'playback/trickplay', type: AppType.Dashboard },
{ path: 'plugins/:pluginId', page: 'plugins/plugin', type: AppType.Dashboard },
{ path: 'tasks', type: AppType.Dashboard },
{ path: 'tasks/:id', page: 'tasks/task', type: AppType.Dashboard },
{ path: 'users', type: AppType.Dashboard },
{ path: 'users/access', type: AppType.Dashboard },
{ path: 'users/add', type: AppType.Dashboard },

View file

@ -9,13 +9,6 @@ export const LEGACY_ADMIN_ROUTES: LegacyRoute[] = [
controller: 'dashboard',
view: 'dashboard.html'
}
}, {
path: 'settings',
pageProps: {
appType: AppType.Dashboard,
controller: 'general',
view: 'general.html'
}
}, {
path: 'networking',
pageProps: {
@ -37,13 +30,6 @@ export const LEGACY_ADMIN_ROUTES: LegacyRoute[] = [
controller: 'encodingsettings',
view: 'encodingsettings.html'
}
}, {
path: 'libraries/nfo',
pageProps: {
appType: AppType.Dashboard,
controller: 'metadatanfo',
view: 'metadatanfo.html'
}
}, {
path: 'plugins/catalog',
pageProps: {
@ -93,12 +79,5 @@ export const LEGACY_ADMIN_ROUTES: LegacyRoute[] = [
controller: 'plugins/installed/index',
view: 'plugins/installed/index.html'
}
}, {
path: 'tasks/edit',
pageProps: {
appType: AppType.Dashboard,
controller: 'scheduledtasks/scheduledtask',
view: 'scheduledtasks/scheduledtask.html'
}
}
];

View file

@ -123,14 +123,16 @@ export const Component = () => {
multiline
minRows={5}
maxRows={5}
InputProps={{
className: 'textarea-mono'
}}
name={BrandingOption.LoginDisclaimer}
label={globalize.translate('LabelLoginDisclaimer')}
helperText={globalize.translate('LabelLoginDisclaimerHelp')}
value={brandingOptions?.LoginDisclaimer}
onChange={setBrandingOption}
slotProps={{
input: {
className: 'textarea-mono'
}
}}
/>
<TextField
@ -138,14 +140,16 @@ export const Component = () => {
multiline
minRows={5}
maxRows={20}
InputProps={{
className: 'textarea-mono'
}}
name={BrandingOption.CustomCss}
label={globalize.translate('LabelCustomCss')}
helperText={globalize.translate('LabelCustomCssHelp')}
value={brandingOptions?.CustomCss}
onChange={setBrandingOption}
slotProps={{
input: {
className: 'textarea-mono'
}
}}
/>
<Button

View file

@ -7,7 +7,7 @@ import FormControlLabel from '@mui/material/FormControlLabel';
import FormHelperText from '@mui/material/FormHelperText';
import MenuItem from '@mui/material/MenuItem';
import Stack from '@mui/material/Stack';
import Switch from '@mui/material/Switch';
import Checkbox from '@mui/material/Checkbox';
import TextField from '@mui/material/TextField';
import Typography from '@mui/material/Typography';
import Loading from 'components/loading/LoadingComponent';
@ -21,6 +21,8 @@ import { type ActionFunctionArgs, Form, useActionData, useNavigation } from 'rea
import { ActionData } from 'types/actionData';
import { queryClient } from 'utils/query/queryClient';
const CONFIG_KEY = 'metadata';
export const action = async ({ request }: ActionFunctionArgs) => {
const api = ServerConnections.getCurrentApi();
if (!api) throw new Error('No Api instance available');
@ -43,13 +45,13 @@ export const action = async ({ request }: ActionFunctionArgs) => {
.updateConfiguration({ serverConfiguration: config });
await getConfigurationApi(api)
.updateNamedConfiguration({ key: 'metadata', body: metadataConfig });
.updateNamedConfiguration({ key: CONFIG_KEY, body: metadataConfig });
void queryClient.invalidateQueries({
queryKey: [ CONFIG_QUERY_KEY ]
});
void queryClient.invalidateQueries({
queryKey: [ NAMED_CONFIG_QUERY_KEY, 'metadata' ]
queryKey: [ NAMED_CONFIG_QUERY_KEY, CONFIG_KEY ]
});
return {
@ -67,7 +69,7 @@ export const Component = () => {
data: namedConfig,
isPending: isNamedConfigPending,
isError: isNamedConfigError
} = useNamedConfiguration('metadata');
} = useNamedConfiguration(CONFIG_KEY);
const navigation = useNavigation();
const actionData = useActionData() as ActionData | undefined;
@ -109,7 +111,7 @@ export const Component = () => {
<FormControl>
<FormControlLabel
control={
<Switch
<Checkbox
name={'DisplayFolderView'}
defaultChecked={config.EnableFolderView}
/>
@ -122,7 +124,7 @@ export const Component = () => {
<FormControl>
<FormControlLabel
control={
<Switch
<Checkbox
name={'DisplaySpecialsWithinSeasons'}
defaultChecked={config.DisplaySpecialsWithinSeasons}
/>
@ -134,7 +136,7 @@ export const Component = () => {
<FormControl>
<FormControlLabel
control={
<Switch
<Checkbox
name={'GroupMoviesIntoCollections'}
defaultChecked={config.EnableGroupingIntoCollections}
/>
@ -147,7 +149,7 @@ export const Component = () => {
<FormControl>
<FormControlLabel
control={
<Switch
<Checkbox
name={'EnableExternalContentInSuggestions'}
defaultChecked={config.EnableExternalContentInSuggestions}
/>

View file

@ -127,12 +127,14 @@ export const Component = () => {
name={'DummyChapterDuration'}
defaultValue={config.DummyChapterDuration}
type='number'
inputProps={{
min: 0,
required: true
}}
label={globalize.translate('LabelDummyChapterDuration')}
helperText={globalize.translate('LabelDummyChapterDurationHelp')}
slotProps={{
htmlInput: {
min: 0,
required: true
}
}}
/>
<TextField

View file

@ -0,0 +1,186 @@
import { getConfigurationApi } from '@jellyfin/sdk/lib/utils/api/configuration-api';
import Alert from '@mui/material/Alert';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import FormControl from '@mui/material/FormControl';
import FormControlLabel from '@mui/material/FormControlLabel';
import FormHelperText from '@mui/material/FormHelperText';
import MenuItem from '@mui/material/MenuItem';
import Stack from '@mui/material/Stack';
import Checkbox from '@mui/material/Checkbox';
import TextField from '@mui/material/TextField';
import Typography from '@mui/material/Typography';
import Loading from 'components/loading/LoadingComponent';
import Page from 'components/Page';
import ServerConnections from 'components/ServerConnections';
import SimpleAlert from 'components/SimpleAlert';
import { QUERY_KEY, useNamedConfiguration } from 'hooks/useNamedConfiguration';
import { useUsers } from 'hooks/useUsers';
import globalize from 'lib/globalize';
import React, { useCallback, useState } from 'react';
import { type ActionFunctionArgs, Form, useActionData, useNavigation } from 'react-router-dom';
import { ActionData } from 'types/actionData';
import { queryClient } from 'utils/query/queryClient';
const CONFIG_KEY = 'xbmcmetadata';
interface NFOSettingsConfig {
UserId?: string;
EnableExtraThumbsDuplication?: boolean;
EnablePathSubstitution?: boolean;
ReleaseDateFormat?: string;
SaveImagePathsInNfo?: boolean;
};
export const action = async ({ request }: ActionFunctionArgs) => {
const api = ServerConnections.getCurrentApi();
if (!api) throw new Error('No Api instance available');
const formData = await request.formData();
const data = Object.fromEntries(formData);
const newConfig: NFOSettingsConfig = {
UserId: data.UserId?.toString(),
ReleaseDateFormat: 'yyyy-MM-dd',
SaveImagePathsInNfo: data.SaveImagePathsInNfo?.toString() === 'on',
EnablePathSubstitution: data.EnablePathSubstitution?.toString() === 'on',
EnableExtraThumbsDuplication: data.EnableExtraThumbsDuplication?.toString() === 'on'
};
await getConfigurationApi(api)
.updateNamedConfiguration({ key: CONFIG_KEY, body: newConfig });
void queryClient.invalidateQueries({
queryKey: [QUERY_KEY, CONFIG_KEY]
});
return {
isSaved: true
};
};
export const Component = () => {
const {
data: config,
isPending: isConfigPending,
isError: isConfigError
} = useNamedConfiguration(CONFIG_KEY);
const {
data: users,
isPending: isUsersPending,
isError: isUsersError
} = useUsers();
const navigation = useNavigation();
const actionData = useActionData() as ActionData | undefined;
const isSubmitting = navigation.state === 'submitting';
const [isAlertOpen, setIsAlertOpen] = useState(false);
const nfoConfig = config as NFOSettingsConfig;
const onAlertClose = useCallback(() => {
setIsAlertOpen(false);
}, []);
const onSubmit = useCallback(() => {
setIsAlertOpen(true);
}, []);
if (isConfigPending || isUsersPending) {
return <Loading />;
}
return (
<Page
id='metadataNfoPage'
title={globalize.translate('TabNfoSettings')}
className='type-interior mainAnimatedPage'
>
<SimpleAlert
open={isAlertOpen}
text={globalize.translate('MetadataSettingChangeHelp')}
onClose={onAlertClose}
/>
<Box className='content-primary'>
{isConfigError || isUsersError ? (
<Alert severity='error'>{globalize.translate('MetadataNfoLoadError')}</Alert>
) : (
<Form method='POST' onSubmit={onSubmit}>
<Stack spacing={3}>
{!isSubmitting && actionData?.isSaved && (
<Alert severity='success'>
{globalize.translate('SettingsSaved')}
</Alert>
)}
<Typography variant='h2'>{globalize.translate('TabNfoSettings')}</Typography>
<Typography>{globalize.translate('HeaderKodiMetadataHelp')}</Typography>
<TextField
name={'UserId'}
label={globalize.translate('LabelKodiMetadataUser')}
defaultValue={nfoConfig.UserId || ''}
select
SelectProps={{
displayEmpty: true
}}
InputLabelProps={{
shrink: true
}}
helperText={globalize.translate('LabelKodiMetadataUserHelp')}
>
<MenuItem value=''>{globalize.translate('None')}</MenuItem>
{users.map(user =>
<MenuItem key={user.Id} value={user.Id}>{user.Name}</MenuItem>
)}
</TextField>
<FormControl>
<FormControlLabel
control={
<Checkbox
name={'SaveImagePathsInNfo'}
defaultChecked={nfoConfig.SaveImagePathsInNfo}
/>
}
label={globalize.translate('LabelKodiMetadataSaveImagePaths')}
/>
<FormHelperText>{globalize.translate('LabelKodiMetadataSaveImagePathsHelp')}</FormHelperText>
</FormControl>
<FormControl>
<FormControlLabel
control={
<Checkbox
name={'EnablePathSubstitution'}
defaultChecked={nfoConfig.EnablePathSubstitution}
/>
}
label={globalize.translate('LabelKodiMetadataEnablePathSubstitution')}
/>
<FormHelperText>{globalize.translate('LabelKodiMetadataEnablePathSubstitutionHelp')}</FormHelperText>
</FormControl>
<FormControl>
<FormControlLabel
control={
<Checkbox
name={'EnableExtraThumbsDuplication'}
defaultChecked={nfoConfig.EnableExtraThumbsDuplication}
/>
}
label={globalize.translate('LabelKodiMetadataEnableExtraThumbs')}
/>
<FormHelperText>{globalize.translate('LabelKodiMetadataEnableExtraThumbsHelp')}</FormHelperText>
</FormControl>
<Button type='submit' size='large'>
{globalize.translate('Save')}
</Button>
</Stack>
</Form>
)}
</Box>
</Page>
);
};
Component.displayName = 'NFOSettingsPage';

View file

@ -8,7 +8,7 @@ import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import FormControlLabel from '@mui/material/FormControlLabel';
import Stack from '@mui/material/Stack';
import Switch from '@mui/material/Switch';
import Checkbox from '@mui/material/Checkbox';
import TextField from '@mui/material/TextField';
import Typography from '@mui/material/Typography';
import { type ActionFunctionArgs, Form, useActionData, useNavigation } from 'react-router-dom';
@ -98,7 +98,7 @@ export const Component = () => {
<FormControlLabel
control={
<Switch
<Checkbox
checked={configuration?.EnableSlowResponseWarning}
onChange={setLogWarningMessage}
name={'EnableWarningMessage'}

View file

@ -81,12 +81,14 @@ export const Component = () => {
name='MinResumePercentage'
type='number'
defaultValue={config?.MinResumePct}
inputProps={{
min: 0,
max: 100,
required: true
}}
helperText={globalize.translate('LabelMinResumePercentageHelp')}
slotProps={{
htmlInput: {
min: 0,
max: 100,
required: true
}
}}
/>
<TextField
@ -94,12 +96,14 @@ export const Component = () => {
name='MaxResumePercentage'
type='number'
defaultValue={config?.MaxResumePct}
inputProps={{
min: 1,
max: 100,
required: true
}}
helperText={globalize.translate('LabelMaxResumePercentageHelp')}
slotProps={{
htmlInput: {
min: 1,
max: 100,
required: true
}
}}
/>
<TextField
@ -107,12 +111,14 @@ export const Component = () => {
name='MinAudiobookResume'
type='number'
defaultValue={config?.MinAudiobookResume}
inputProps={{
min: 0,
max: 100,
required: true
}}
helperText={globalize.translate('LabelMinAudiobookResumeHelp')}
slotProps={{
htmlInput: {
min: 0,
max: 100,
required: true
}
}}
/>
<TextField
@ -120,12 +126,14 @@ export const Component = () => {
name='MaxAudiobookResume'
type='number'
defaultValue={config?.MaxAudiobookResume}
inputProps={{
min: 1,
max: 100,
required: true
}}
helperText={globalize.translate('LabelMaxAudiobookResumeHelp')}
slotProps={{
htmlInput: {
min: 1,
max: 100,
required: true
}
}}
/>
<TextField
@ -133,11 +141,13 @@ export const Component = () => {
name='MinResumeDuration'
type='number'
defaultValue={config?.MinResumeDurationSeconds}
inputProps={{
min: 0,
required: true
}}
helperText={globalize.translate('LabelMinResumeDurationHelp')}
slotProps={{
htmlInput: {
min: 0,
required: true
}
}}
/>
<Button

View file

@ -70,14 +70,16 @@ export const Component = () => {
<TextField
type='number'
inputMode='decimal'
inputProps={{
min: 0,
step: 0.25
}}
name='StreamingBitrateLimit'
label={globalize.translate('LabelRemoteClientBitrateLimit')}
helperText={globalize.translate('LabelRemoteClientBitrateLimitHelp')}
defaultValue={defaultConfiguration?.RemoteClientBitrateLimit ? defaultConfiguration?.RemoteClientBitrateLimit / 1e6 : ''}
slotProps={{
htmlInput: {
min: 0,
step: 0.25
}
}}
/>
<Button
type='submit'

View file

@ -9,7 +9,7 @@ import Stack from '@mui/material/Stack';
import Typography from '@mui/material/Typography';
import FormControlLabel from '@mui/material/FormControlLabel';
import FormControl from '@mui/material/FormControl';
import Switch from '@mui/material/Switch';
import Checkbox from '@mui/material/Checkbox';
import Loading from 'components/loading/LoadingComponent';
import FormHelperText from '@mui/material/FormHelperText';
import MenuItem from '@mui/material/MenuItem';
@ -92,7 +92,7 @@ export const Component = () => {
<FormControl>
<FormControlLabel
control={
<Switch
<Checkbox
name='HwAcceleration'
defaultChecked={defaultConfig.TrickplayOptions?.EnableHwAcceleration}
/>
@ -104,7 +104,7 @@ export const Component = () => {
<FormControl>
<FormControlLabel
control={
<Switch
<Checkbox
name='HwEncoding'
defaultChecked={defaultConfig.TrickplayOptions?.EnableHwEncoding}
/>
@ -117,7 +117,7 @@ export const Component = () => {
<FormControl>
<FormControlLabel
control={
<Switch
<Checkbox
name='KeyFrameOnlyExtraction'
defaultChecked={defaultConfig.TrickplayOptions?.EnableKeyFrameOnlyExtraction}
/>
@ -158,22 +158,26 @@ export const Component = () => {
type='number'
inputMode='numeric'
defaultValue={defaultConfig.TrickplayOptions?.Interval}
inputProps={{
min: 1,
required: true
}}
helperText={globalize.translate('LabelImageIntervalHelp')}
slotProps={{
htmlInput: {
min: 1,
required: true
}
}}
/>
<TextField
label={globalize.translate('LabelWidthResolutions')}
name='WidthResolutions'
defaultValue={defaultConfig.TrickplayOptions?.WidthResolutions}
inputProps={{
required: true,
pattern: '[0-9,]*'
}}
helperText={globalize.translate('LabelWidthResolutionsHelp')}
slotProps={{
htmlInput: {
required: true,
pattern: '[0-9,]*'
}
}}
/>
<TextField
@ -182,11 +186,13 @@ export const Component = () => {
type='number'
inputMode='numeric'
defaultValue={defaultConfig.TrickplayOptions?.TileWidth}
inputProps={{
min: 1,
required: true
}}
helperText={globalize.translate('LabelTileWidthHelp')}
slotProps={{
htmlInput: {
min: 1,
required: true
}
}}
/>
<TextField
@ -195,11 +201,13 @@ export const Component = () => {
type='number'
inputMode='numeric'
defaultValue={defaultConfig.TrickplayOptions?.TileHeight}
inputProps={{
min: 1,
required: true
}}
helperText={globalize.translate('LabelTileHeightHelp')}
slotProps={{
htmlInput: {
min: 1,
required: true
}
}}
/>
<TextField
@ -208,12 +216,14 @@ export const Component = () => {
type='number'
inputMode='numeric'
defaultValue={defaultConfig.TrickplayOptions?.JpegQuality}
inputProps={{
min: 1,
max: 100,
required: true
}}
helperText={globalize.translate('LabelJpegQualityHelp')}
slotProps={{
htmlInput: {
min: 1,
max: 100,
required: true
}
}}
/>
<TextField
@ -222,12 +232,14 @@ export const Component = () => {
type='number'
inputMode='numeric'
defaultValue={defaultConfig.TrickplayOptions?.Qscale}
inputProps={{
min: 2,
max: 31,
required: true
}}
helperText={globalize.translate('LabelQscaleHelp')}
slotProps={{
htmlInput: {
min: 2,
max: 31,
required: true
}
}}
/>
<TextField
@ -236,11 +248,13 @@ export const Component = () => {
type='number'
inputMode='numeric'
defaultValue={defaultConfig.TrickplayOptions?.ProcessThreads}
inputProps={{
min: 0,
required: true
}}
helperText={globalize.translate('LabelTrickplayThreadsHelp')}
slotProps={{
htmlInput: {
min: 0,
required: true
}
}}
/>
<Button

View file

@ -0,0 +1,277 @@
import Alert from '@mui/material/Alert';
import Box from '@mui/material/Box';
import IconButton from '@mui/material/IconButton';
import MenuItem from '@mui/material/MenuItem';
import Stack from '@mui/material/Stack';
import TextField from '@mui/material/TextField';
import Typography from '@mui/material/Typography';
import { useLocalizationOptions } from 'apps/dashboard/features/settings/api/useLocalizationOptions';
import Loading from 'components/loading/LoadingComponent';
import Page from 'components/Page';
import { QUERY_KEY, useConfiguration } from 'hooks/useConfiguration';
import { useSystemInfo } from 'hooks/useSystemInfo';
import globalize from 'lib/globalize';
import React, { useCallback, useEffect, useState } from 'react';
import { type ActionFunctionArgs, Form, useActionData, useNavigation } from 'react-router-dom';
import SearchIcon from '@mui/icons-material/Search';
import InputAdornment from '@mui/material/InputAdornment';
import FormControl from '@mui/material/FormControl';
import FormControlLabel from '@mui/material/FormControlLabel';
import Checkbox from '@mui/material/Checkbox';
import Button from '@mui/material/Button';
import Link from '@mui/material/Link';
import DirectoryBrowser from 'components/directorybrowser/directorybrowser';
import ServerConnections from 'components/ServerConnections';
import { getConfigurationApi } from '@jellyfin/sdk/lib/utils/api/configuration-api';
import { queryClient } from 'utils/query/queryClient';
import { ActionData } from 'types/actionData';
export const action = async ({ request }: ActionFunctionArgs) => {
const api = ServerConnections.getCurrentApi();
if (!api) throw new Error('No Api instance available');
const { data: config } = await getConfigurationApi(api).getConfiguration();
const formData = await request.formData();
config.ServerName = formData.get('ServerName')?.toString();
config.UICulture = formData.get('UICulture')?.toString();
config.CachePath = formData.get('CachePath')?.toString();
config.MetadataPath = formData.get('MetadataPath')?.toString();
config.QuickConnectAvailable = formData.get('QuickConnectAvailable')?.toString() === 'on';
config.LibraryScanFanoutConcurrency = parseInt(formData.get('LibraryScanFanoutConcurrency')?.toString() || '0', 10);
config.ParallelImageEncodingLimit = parseInt(formData.get('ParallelImageEncodingLimit')?.toString() || '0', 10);
await getConfigurationApi(api)
.updateConfiguration({ serverConfiguration: config });
void queryClient.invalidateQueries({
queryKey: [ QUERY_KEY ]
});
return {
isSaved: true
};
};
export const Component = () => {
const {
data: config,
isPending: isConfigPending,
isError: isConfigError
} = useConfiguration();
const {
data: languageOptions,
isPending: isLocalizationOptionsPending,
isError: isLocalizationOptionsError
} = useLocalizationOptions();
const {
data: systemInfo,
isPending: isSystemInfoPending,
isError: isSystemInfoError
} = useSystemInfo();
const navigation = useNavigation();
const actionData = useActionData() as ActionData | undefined;
const isSubmitting = navigation.state === 'submitting';
const [ cachePath, setCachePath ] = useState<string | null | undefined>('');
const [ metadataPath, setMetadataPath ] = useState<string | null | undefined>('');
const onCachePathChange = useCallback((event: React.ChangeEvent<HTMLTextAreaElement | HTMLInputElement>) => {
setCachePath(event.target.value);
}, []);
const onMetadataPathChange = useCallback((event: React.ChangeEvent<HTMLTextAreaElement | HTMLInputElement>) => {
setMetadataPath(event.target.value);
}, []);
const showCachePathPicker = useCallback(() => {
const picker = new DirectoryBrowser();
picker.show({
callback: function (path: string) {
if (path) {
setCachePath(path);
}
picker.close();
},
validateWriteable: true,
header: globalize.translate('HeaderSelectServerCachePath'),
instruction: globalize.translate('HeaderSelectServerCachePathHelp')
});
}, []);
const showMetadataPathPicker = useCallback(() => {
const picker = new DirectoryBrowser();
picker.show({
path: metadataPath,
callback: function (path: string) {
if (path) {
setMetadataPath(path);
}
picker.close();
},
validateWriteable: true,
header: globalize.translate('HeaderSelectMetadataPath'),
instruction: globalize.translate('HeaderSelectMetadataPathHelp')
});
}, [metadataPath]);
useEffect(() => {
if (!isSystemInfoPending && !isSystemInfoError) {
setCachePath(systemInfo.CachePath);
setMetadataPath(systemInfo.InternalMetadataPath);
}
}, [systemInfo, isSystemInfoPending, isSystemInfoError]);
if (isConfigPending || isLocalizationOptionsPending || isSystemInfoPending) {
return <Loading />;
}
return (
<Page
id='dashboardGeneralPage'
title={globalize.translate('General')}
className='type-interior mainAnimatedPage'
>
<Box className='content-primary'>
{isConfigError || isLocalizationOptionsError || isSystemInfoError ? (
<Alert severity='error'>{globalize.translate('SettingsPageLoadError')}</Alert>
) : (
<Form method='POST'>
<Stack spacing={3}>
<Typography variant='h1'>{globalize.translate('Settings')}</Typography>
{!isSubmitting && actionData?.isSaved && (
<Alert severity='success'>
{globalize.translate('SettingsSaved')}
</Alert>
)}
<TextField
name='ServerName'
label={globalize.translate('LabelServerName')}
helperText={globalize.translate('LabelServerNameHelp')}
defaultValue={systemInfo.ServerName}
/>
<TextField
select
name='UICulture'
label={globalize.translate('LabelPreferredDisplayLanguage')}
helperText={(
<>
<span>{globalize.translate('LabelDisplayLanguageHelp')}</span>
<Link href='https://jellyfin.org/docs/general/contributing/#translating'>
{globalize.translate('LearnHowYouCanContribute')}
</Link>
</>
)}
defaultValue={config.UICulture}
slotProps={{
formHelperText: { component: Stack }
}}
>
{languageOptions.map((language) =>
<MenuItem key={language.Name} value={language.Value || ''}>{language.Name}</MenuItem>
)}
</TextField>
<Typography variant='h2'>{globalize.translate('HeaderPaths')}</Typography>
<TextField
name='CachePath'
label={globalize.translate('LabelCachePath')}
helperText={globalize.translate('LabelCachePathHelp')}
value={cachePath}
onChange={onCachePathChange}
slotProps={{
input: {
endAdornment: (
<InputAdornment position='end'>
<IconButton edge='end' onClick={showCachePathPicker}>
<SearchIcon />
</IconButton>
</InputAdornment>
)
}
}}
/>
<TextField
name={'MetadataPath'}
label={globalize.translate('LabelMetadataPath')}
helperText={globalize.translate('LabelMetadataPathHelp')}
value={metadataPath}
onChange={onMetadataPathChange}
slotProps={{
input: {
endAdornment: (
<InputAdornment position='end'>
<IconButton edge='end' onClick={showMetadataPathPicker}>
<SearchIcon />
</IconButton>
</InputAdornment>
)
}
}}
/>
<Typography variant='h2'>{globalize.translate('QuickConnect')}</Typography>
<FormControl>
<FormControlLabel
control={
<Checkbox
name='QuickConnectAvailable'
defaultChecked={config.QuickConnectAvailable}
/>
}
label={globalize.translate('EnableQuickConnect')}
/>
</FormControl>
<Typography variant='h2'>{globalize.translate('HeaderPerformance')}</Typography>
<TextField
name='LibraryScanFanoutConcurrency'
type='number'
label={globalize.translate('LibraryScanFanoutConcurrency')}
helperText={globalize.translate('LibraryScanFanoutConcurrencyHelp')}
defaultValue={config.LibraryScanFanoutConcurrency || ''}
slotProps={{
htmlInput: {
min: 0,
step: 1
}
}}
/>
<TextField
name='ParallelImageEncodingLimit'
type='number'
label={globalize.translate('LabelParallelImageEncodingLimit')}
helperText={globalize.translate('LabelParallelImageEncodingLimitHelp')}
defaultValue={config.ParallelImageEncodingLimit || ''}
slotProps={{
htmlInput: {
min: 0,
step: 1
}
}}
/>
<Button type='submit' size='large'>
{globalize.translate('Save')}
</Button>
</Stack>
</Form>
)}
</Box>
</Page>
);
};
Component.displayName = 'SettingsPage';

View file

@ -3,10 +3,10 @@ import Page from 'components/Page';
import globalize from 'lib/globalize';
import Box from '@mui/material/Box';
import Stack from '@mui/material/Stack';
import { QUERY_KEY, useTasks } from '../../features/scheduledtasks/api/useTasks';
import { getCategories, getTasksByCategory } from '../../features/scheduledtasks/utils/tasks';
import { QUERY_KEY, useTasks } from '../../features/tasks/api/useTasks';
import { getCategories, getTasksByCategory } from '../../features/tasks/utils/tasks';
import Loading from 'components/loading/LoadingComponent';
import Tasks from '../../features/scheduledtasks/components/Tasks';
import Tasks from '../../features/tasks/components/Tasks';
import type { TaskInfo } from '@jellyfin/sdk/lib/generated-client/models/task-info';
import { SessionMessageType } from '@jellyfin/sdk/lib/generated-client/models/session-message-type';
import serverNotifications from 'scripts/serverNotifications';

View file

@ -0,0 +1,172 @@
import React, { useCallback, useMemo, useState } from 'react';
import Page from 'components/Page';
import { useParams } from 'react-router-dom';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import Stack from '@mui/material/Stack';
import Typography from '@mui/material/Typography';
import AddIcon from '@mui/icons-material/Add';
import IconButton from '@mui/material/IconButton';
import Tooltip from '@mui/material/Tooltip';
import RemoveCircleIcon from '@mui/icons-material/RemoveCircle';
import Loading from 'components/loading/LoadingComponent';
import { MRT_ColumnDef, MRT_Table, useMaterialReactTable } from 'material-react-table';
import type { TaskTriggerInfo } from '@jellyfin/sdk/lib/generated-client/models/task-trigger-info';
import globalize from '../../../../lib/globalize';
import { useTask } from 'apps/dashboard/features/tasks/api/useTask';
import { useUpdateTask } from 'apps/dashboard/features/tasks/api/useUpdateTask';
import ConfirmDialog from 'components/ConfirmDialog';
import TaskTriggerCell from 'apps/dashboard/features/tasks/components/TaskTriggerCell';
import NewTriggerForm from 'apps/dashboard/features/tasks/components/NewTriggerForm';
export const Component = () => {
const { id: taskId } = useParams();
const updateTask = useUpdateTask();
const { data: task, isLoading } = useTask({ taskId: taskId || '' });
const [ isAddTriggerDialogOpen, setIsAddTriggerDialogOpen ] = useState(false);
const [ isRemoveConfirmOpen, setIsRemoveConfirmOpen ] = useState(false);
const [ pendingDeleteTrigger, setPendingDeleteTrigger ] = useState<TaskTriggerInfo | null>(null);
const onCloseRemoveConfirmDialog = useCallback(() => {
setPendingDeleteTrigger(null);
setIsRemoveConfirmOpen(false);
}, []);
const onDeleteTrigger = useCallback((trigger: TaskTriggerInfo | null | undefined) => {
if (trigger) {
setPendingDeleteTrigger(trigger);
setIsRemoveConfirmOpen(true);
}
}, []);
const onConfirmDelete = useCallback(() => {
const triggersRemaining = task?.Triggers?.filter(trigger => trigger != pendingDeleteTrigger);
if (task?.Id && triggersRemaining) {
updateTask.mutate({
taskId: task.Id,
taskTriggerInfo: triggersRemaining
});
setIsRemoveConfirmOpen(false);
}
}, [task, pendingDeleteTrigger, updateTask]);
const showAddTriggerDialog = useCallback(() => {
setIsAddTriggerDialogOpen(true);
}, []);
const handleNewTriggerDialogClose = useCallback(() => {
setIsAddTriggerDialogOpen(false);
}, []);
const onNewTriggerAdd = useCallback((trigger: TaskTriggerInfo) => {
if (task?.Triggers && task?.Id) {
const triggers = [...task.Triggers, trigger];
updateTask.mutate({
taskId: task.Id,
taskTriggerInfo: triggers
});
setIsAddTriggerDialogOpen(false);
}
}, [task, updateTask]);
const columns = useMemo<MRT_ColumnDef<TaskTriggerInfo>[]>(() => [
{
id: 'TriggerTime',
accessorFn: row => row,
Cell: TaskTriggerCell,
header: globalize.translate('LabelTime')
}
], []);
const table = useMaterialReactTable({
columns,
data: task?.Triggers || [],
enableSorting: false,
enableFilters: false,
enableColumnActions: false,
enablePagination: false,
state: {
isLoading
},
muiTableContainerProps: {
sx: {
maxHeight: 'calc(100% - 7rem)' // 2 x 3.5rem for header and footer
}
},
// Custom actions
enableRowActions: true,
positionActionsColumn: 'last',
displayColumnDefOptions: {
'mrt-row-actions': {
header: ''
}
},
renderRowActions: ({ row }) => {
return (
<Box sx={{
display: 'flex',
justifyContent: 'flex-end'
}}>
<Tooltip disableInteractive title={globalize.translate('ButtonRemove')}>
<IconButton
color='error'
// eslint-disable-next-line react/jsx-no-bind
onClick={() => onDeleteTrigger(row.original)}
>
<RemoveCircleIcon />
</IconButton>
</Tooltip>
</Box>
);
}
});
if (isLoading || !task) {
return <Loading />;
}
return (
<Page
id='scheduledTaskPage'
className='mainAnimatedPage type-interior'
>
<ConfirmDialog
open={isRemoveConfirmOpen}
title={globalize.translate('HeaderDeleteTaskTrigger')}
text={globalize.translate('MessageDeleteTaskTrigger')}
onCancel={onCloseRemoveConfirmDialog}
onConfirm={onConfirmDelete}
confirmButtonColor='error'
confirmButtonText={globalize.translate('ButtonRemove')}
/>
<NewTriggerForm
open={isAddTriggerDialogOpen}
title={globalize.translate('ButtonAddScheduledTaskTrigger')}
onClose={handleNewTriggerDialogClose}
onAdd={onNewTriggerAdd}
/>
<Box className='content-primary'>
<Box className='readOnlyContent'>
<Stack spacing={2}>
<Typography variant='h2'>{task.Name}</Typography>
<Typography variant='body1'>{task.Description}</Typography>
<Button
sx={{ alignSelf: 'flex-start' }}
startIcon={<AddIcon />}
onClick={showAddTriggerDialog}
>{globalize.translate('ButtonAddScheduledTaskTrigger')}</Button>
<MRT_Table table={table} />
</Stack>
</Box>
</Box>
</Page>
);
};
Component.displayName = 'TaskPage';

View file

@ -298,7 +298,7 @@ const UserEdit = () => {
className='lnkEditUserPreferencesContainer'
style={{ paddingBottom: '1em' }}
>
<LinkButton className='lnkEditUserPreferences button-link' href={userDto?.Id ? `mypreferencesmenu.html?userId=${userDto.Id}` : undefined}>
<LinkButton className='lnkEditUserPreferences button-link' href={userDto?.Id ? `mypreferencesmenu?userId=${userDto.Id}` : undefined}>
{globalize.translate('ButtonEditOtherUserPreferences')}
</LinkButton>
</div>

View file

@ -6,8 +6,10 @@ import useMediaQuery from '@mui/material/useMediaQuery';
import { Outlet, useLocation } from 'react-router-dom';
import AppBody from 'components/AppBody';
import CustomCss from 'components/CustomCss';
import ElevationScroll from 'components/ElevationScroll';
import { DRAWER_WIDTH } from 'components/ResponsiveDrawer';
import ThemeCss from 'components/ThemeCss';
import { useApi } from 'hooks/useApi';
import AppToolbar from './components/AppToolbar';
@ -29,52 +31,56 @@ export const Component = () => {
}, [ isDrawerActive, setIsDrawerActive ]);
return (
<Box sx={{ position: 'relative', display: 'flex', height: '100%' }}>
<StrictMode>
<ElevationScroll elevate={false}>
<AppBar
position='fixed'
sx={{
width: {
xs: '100%',
md: isDrawerAvailable ? `calc(100% - ${DRAWER_WIDTH}px)` : '100%'
},
ml: {
xs: 0,
md: isDrawerAvailable ? DRAWER_WIDTH : 0
}
}}
>
<AppToolbar
isDrawerAvailable={!isMediumScreen && isDrawerAvailable}
isDrawerOpen={isDrawerOpen}
onDrawerButtonClick={onToggleDrawer}
/>
</AppBar>
</ElevationScroll>
<>
<Box sx={{ position: 'relative', display: 'flex', height: '100%' }}>
<StrictMode>
<ElevationScroll elevate={false}>
<AppBar
position='fixed'
sx={{
width: {
xs: '100%',
md: isDrawerAvailable ? `calc(100% - ${DRAWER_WIDTH}px)` : '100%'
},
ml: {
xs: 0,
md: isDrawerAvailable ? DRAWER_WIDTH : 0
}
}}
>
<AppToolbar
isDrawerAvailable={!isMediumScreen && isDrawerAvailable}
isDrawerOpen={isDrawerOpen}
onDrawerButtonClick={onToggleDrawer}
/>
</AppBar>
</ElevationScroll>
{
isDrawerAvailable && (
<AppDrawer
open={isDrawerOpen}
onClose={onToggleDrawer}
onOpen={onToggleDrawer}
/>
)
}
</StrictMode>
{
isDrawerAvailable && (
<AppDrawer
open={isDrawerOpen}
onClose={onToggleDrawer}
onOpen={onToggleDrawer}
/>
)
}
</StrictMode>
<Box
component='main'
sx={{
width: '100%',
flexGrow: 1
}}
>
<AppBody>
<Outlet />
</AppBody>
<Box
component='main'
sx={{
width: '100%',
flexGrow: 1
}}
>
<AppBody>
<Outlet />
</AppBody>
</Box>
</Box>
</Box>
<ThemeCss />
<CustomCss />
</>
);
};

View file

@ -17,6 +17,10 @@ $drawer-width: 240px;
left: $drawer-width;
}
}
// The fallback page has no drawer
#fallbackPage {
left: 0;
}
// Hide some items from the user "settings" page that are in the drawer
#myPreferencesMenuPage {

View file

@ -35,13 +35,13 @@ const SearchButton: FC<SearchButtonProps> = ({ isTabsAvailable }) => {
const location = useLocation();
const [searchParams] = useSearchParams();
const isSearchPath = location.pathname === '/search.html';
const isSearchPath = location.pathname === '/search';
const createSearchLink = isTabsAvailable ?
{
pathname: '/search.html',
pathname: '/search',
search: `?${createSearchParams(getUrlParams(searchParams))}`
} :
'/search.html';
'/search';
return (
<Tooltip title={globalize.translate('Search')}>

View file

@ -14,11 +14,11 @@ interface AppToolbarProps {
}
const PUBLIC_PATHS = [
'/addserver.html',
'/selectserver.html',
'/login.html',
'/forgotpassword.html',
'/forgotpasswordpin.html'
'/addserver',
'/selectserver',
'/login',
'/forgotpassword',
'/forgotpasswordpin'
];
const ExperimentalAppToolbar: FC<AppToolbarProps> = ({

View file

@ -30,7 +30,7 @@ const MainDrawerContent = () => {
const userViews = userViewsData?.Items || [];
const webConfig = useWebConfig();
const isHomeSelected = location.pathname === '/home.html' && (!location.search || location.search === '?tab=0');
const isHomeSelected = location.pathname === '/home' && (!location.search || location.search === '?tab=0');
return (
<>
@ -40,7 +40,7 @@ const MainDrawerContent = () => {
<DrawerHeaderLink />
</ListItem>
<ListItem disablePadding>
<ListItemLink to='/home.html' selected={isHomeSelected}>
<ListItemLink to='/home' selected={isHomeSelected}>
<ListItemIcon>
<Home />
</ListItemIcon>
@ -48,7 +48,7 @@ const MainDrawerContent = () => {
</ListItemLink>
</ListItem>
<ListItem disablePadding>
<ListItemLink to='/home.html?tab=1'>
<ListItemLink to='/home?tab=1'>
<ListItemIcon>
<Favorite />
</ListItemIcon>

View file

@ -36,7 +36,7 @@ export const getDefaultTabIndex = (path: string, libraryId?: string | null) => {
const TabRoutes: TabRoute[] = [
{
path: '/livetv.html',
path: '/livetv',
tabs: [
{
index: 0,
@ -72,7 +72,7 @@ const TabRoutes: TabRoute[] = [
]
},
{
path: '/movies.html',
path: '/movies',
tabs: [
{
index: 0,
@ -108,7 +108,7 @@ const TabRoutes: TabRoute[] = [
]
},
{
path: '/music.html',
path: '/music',
tabs: [
{
index: 0,
@ -149,7 +149,7 @@ const TabRoutes: TabRoute[] = [
]
},
{
path: '/tv.html',
path: '/tv',
tabs: [
{
index: 0,
@ -185,7 +185,7 @@ const TabRoutes: TabRoute[] = [
]
},
{
path: '/homevideos.html',
path: '/homevideos',
tabs: [
{
index: 0,

View file

@ -36,7 +36,7 @@ const CancelSeriesTimerButton: FC<CancelSeriesTimerButtonProps> = ({
onSuccess: async () => {
toast(globalize.translate('SeriesCancelled'));
loading.hide();
navigate('/livetv.html');
navigate('/livetv');
},
onError: (err: unknown) => {
loading.hide();

View file

@ -146,18 +146,20 @@ export function DisplayPreferences({ onChange, values }: Readonly<DisplayPrefere
<TextField
aria-describedby='display-settings-screensaver-interval-description'
value={values.screensaverInterval}
inputProps={{
inputMode: 'numeric',
max: '3600',
min: '1',
pattern: '[0-9]',
required: true,
step: '1',
type: 'number'
}}
label={globalize.translate('LabelBackdropScreensaverInterval')}
name='screensaverInterval'
onChange={onChange}
slotProps={{
htmlInput: {
inputMode: 'numeric',
max: '3600',
min: '1',
pattern: '[0-9]',
required: true,
step: '1',
type: 'number'
}
}}
/>
<FormHelperText id='display-settings-screensaver-interval-description'>
{globalize.translate('LabelBackdropScreensaverIntervalHelp')}

View file

@ -24,19 +24,21 @@ export function LibraryPreferences({ onChange, values }: Readonly<LibraryPrefere
<FormControl fullWidth>
<TextField
aria-describedby='display-settings-lib-pagesize-description'
inputProps={{
type: 'number',
inputMode: 'numeric',
max: '1000',
min: '0',
pattern: '[0-9]',
required: true,
step: '1'
}}
value={values.libraryPageSize}
label={globalize.translate('LabelLibraryPageSize')}
name='libraryPageSize'
onChange={onChange}
slotProps={{
htmlInput: {
type: 'number',
inputMode: 'numeric',
max: '1000',
min: '0',
pattern: '[0-9]',
required: true,
step: '1'
}
}}
/>
<FormHelperText id='display-settings-lib-pagesize-description'>
{globalize.translate('LabelLibraryPageSizeHelp')}

View file

@ -25,18 +25,20 @@ export function NextUpPreferences({ onChange, values }: Readonly<NextUpPreferenc
<TextField
aria-describedby='display-settings-max-days-next-up-description'
value={values.maxDaysForNextUp}
inputProps={{
type: 'number',
inputMode: 'numeric',
max: '1000',
min: '0',
pattern: '[0-9]',
required: true,
step: '1'
}}
label={globalize.translate('LabelMaxDaysForNextUp')}
name='maxDaysForNextUp'
onChange={onChange}
slotProps={{
htmlInput: {
type: 'number',
inputMode: 'numeric',
max: '1000',
min: '0',
pattern: '[0-9]',
required: true,
step: '1'
}
}}
/>
<FormHelperText id='display-settings-max-days-next-up-description'>
{globalize.translate('LabelMaxDaysForNextUpHelp')}

View file

@ -2,14 +2,14 @@ import { AsyncRoute } from 'components/router/AsyncRoute';
import { AppType } from 'constants/appType';
export const ASYNC_USER_ROUTES: AsyncRoute[] = [
{ path: 'home.html', page: 'home', type: AppType.Experimental },
{ path: 'home', page: 'home', type: AppType.Experimental },
{ path: 'quickconnect', page: 'quickConnect' },
{ path: 'search.html', page: 'search' },
{ path: 'userprofile.html', page: 'user/userprofile' },
{ path: 'movies.html', page: 'movies', type: AppType.Experimental },
{ path: 'tv.html', page: 'shows', type: AppType.Experimental },
{ path: 'music.html', page: 'music', type: AppType.Experimental },
{ path: 'livetv.html', page: 'livetv', type: AppType.Experimental },
{ path: 'mypreferencesdisplay.html', page: 'user/display', type: AppType.Experimental },
{ path: 'homevideos.html', page: 'homevideos', type: AppType.Experimental }
{ path: 'search', page: 'search' },
{ path: 'userprofile', page: 'user/userprofile' },
{ path: 'movies', page: 'movies', type: AppType.Experimental },
{ path: 'tv', page: 'shows', type: AppType.Experimental },
{ path: 'music', page: 'music', type: AppType.Experimental },
{ path: 'livetv', page: 'livetv', type: AppType.Experimental },
{ path: 'mypreferencesdisplay', page: 'user/display', type: AppType.Experimental },
{ path: 'homevideos', page: 'homevideos', type: AppType.Experimental }
];

View file

@ -2,77 +2,77 @@ import { LegacyRoute } from '../../../../components/router/LegacyRoute';
export const LEGACY_PUBLIC_ROUTES: LegacyRoute[] = [
{
path: 'addserver.html',
path: 'addserver',
pageProps: {
controller: 'session/addServer/index',
view: 'session/addServer/index.html'
}
},
{
path: 'selectserver.html',
path: 'selectserver',
pageProps: {
controller: 'session/selectServer/index',
view: 'session/selectServer/index.html'
}
},
{
path: 'login.html',
path: 'login',
pageProps: {
controller: 'session/login/index',
view: 'session/login/index.html'
}
},
{
path: 'forgotpassword.html',
path: 'forgotpassword',
pageProps: {
controller: 'session/forgotPassword/index',
view: 'session/forgotPassword/index.html'
}
},
{
path: 'forgotpasswordpin.html',
path: 'forgotpasswordpin',
pageProps: {
controller: 'session/resetPassword/index',
view: 'session/resetPassword/index.html'
}
},
{
path: 'wizardremoteaccess.html',
path: 'wizardremoteaccess',
pageProps: {
controller: 'wizard/remote/index',
view: 'wizard/remote/index.html'
}
},
{
path: 'wizardfinish.html',
path: 'wizardfinish',
pageProps: {
controller: 'wizard/finish/index',
view: 'wizard/finish/index.html'
}
},
{
path: 'wizardlibrary.html',
path: 'wizardlibrary',
pageProps: {
controller: 'wizard/library',
view: 'wizard/library.html'
}
},
{
path: 'wizardsettings.html',
path: 'wizardsettings',
pageProps: {
controller: 'wizard/settings/index',
view: 'wizard/settings/index.html'
}
},
{
path: 'wizardstart.html',
path: 'wizardstart',
pageProps: {
controller: 'wizard/start/index',
view: 'wizard/start/index.html'
}
},
{
path: 'wizarduser.html',
path: 'wizarduser',
pageProps: {
controller: 'wizard/user/index',
view: 'wizard/user/index.html'

View file

@ -8,7 +8,7 @@ export const LEGACY_USER_ROUTES: LegacyRoute[] = [
view: 'itemDetails/index.html'
}
}, {
path: 'list.html',
path: 'list',
pageProps: {
controller: 'list',
view: 'list.html'
@ -20,31 +20,31 @@ export const LEGACY_USER_ROUTES: LegacyRoute[] = [
view: 'lyrics.html'
}
}, {
path: 'mypreferencesmenu.html',
path: 'mypreferencesmenu',
pageProps: {
controller: 'user/menu/index',
view: 'user/menu/index.html'
}
}, {
path: 'mypreferencescontrols.html',
path: 'mypreferencescontrols',
pageProps: {
controller: 'user/controls/index',
view: 'user/controls/index.html'
}
}, {
path: 'mypreferenceshome.html',
path: 'mypreferenceshome',
pageProps: {
controller: 'user/home/index',
view: 'user/home/index.html'
}
}, {
path: 'mypreferencesplayback.html',
path: 'mypreferencesplayback',
pageProps: {
controller: 'user/playback/index',
view: 'user/playback/index.html'
}
}, {
path: 'mypreferencessubtitles.html',
path: 'mypreferencessubtitles',
pageProps: {
controller: 'user/subtitles/index',
view: 'user/subtitles/index.html'

View file

@ -5,6 +5,7 @@ import ConnectionRequired from 'components/ConnectionRequired';
import { toAsyncPageRoute } from 'components/router/AsyncRoute';
import { toViewManagerPageRoute } from 'components/router/LegacyRoute';
import ErrorBoundary from 'components/router/ErrorBoundary';
import FallbackRoute from 'components/router/FallbackRoute';
import { ASYNC_USER_ROUTES } from './asyncRoutes';
import { LEGACY_PUBLIC_ROUTES, LEGACY_USER_ROUTES } from './legacyRoutes';
@ -15,9 +16,11 @@ export const EXPERIMENTAL_APP_ROUTES: RouteObject[] = [
path: '/*',
lazy: () => import('../AppLayout'),
children: [
{ index: true, element: <Navigate replace to='/home' /> },
{
/* User routes: Any child route of this layout is authenticated */
element: <ConnectionRequired isUserRequired />,
/* User routes */
Component: ConnectionRequired,
children: [
...ASYNC_USER_ROUTES.map(toAsyncPageRoute),
...LEGACY_USER_ROUTES.map(toViewManagerPageRoute),
@ -25,15 +28,26 @@ export const EXPERIMENTAL_APP_ROUTES: RouteObject[] = [
// The video page is special since it combines new controls with the legacy view
{
path: 'video',
element: <VideoPage />
Component: VideoPage
}
],
ErrorBoundary
},
/* Public routes */
{ index: true, element: <Navigate replace to='/home.html' /> },
...LEGACY_PUBLIC_ROUTES.map(toViewManagerPageRoute)
{
/* Public routes */
element: <ConnectionRequired isUserRequired={false} />,
children: [
...LEGACY_PUBLIC_ROUTES.map(toViewManagerPageRoute),
/* Fallback route for invalid paths */
{
path: '*',
Component: FallbackRoute
}
]
}
]
}
];

View file

@ -2,11 +2,17 @@ import React from 'react';
import { Outlet } from 'react-router-dom';
import AppBody from 'components/AppBody';
import CustomCss from 'components/CustomCss';
import ThemeCss from 'components/ThemeCss';
export default function AppLayout() {
return (
<AppBody>
<Outlet />
</AppBody>
<>
<AppBody>
<Outlet />
</AppBody>
<ThemeCss />
<CustomCss />
</>
);
}

View file

@ -0,0 +1,23 @@
import { Api } from '@jellyfin/sdk/lib/api';
import { ItemsApiGetItemsRequest } from '@jellyfin/sdk/lib/generated-client/api/items-api';
import { getItemsApi } from '@jellyfin/sdk/lib/utils/api/items-api';
import { AxiosRequestConfig } from 'axios';
import { QUERY_OPTIONS } from '../constants/queryOptions';
export const fetchItemsByType = async (
api: Api,
userId?: string,
params?: ItemsApiGetItemsRequest,
options?: AxiosRequestConfig
) => {
const response = await getItemsApi(api).getItems(
{
...QUERY_OPTIONS,
userId,
recursive: true,
...params
},
options
);
return response.data;
};

View file

@ -0,0 +1,49 @@
import { Api } from '@jellyfin/sdk';
import { ArtistsApiGetArtistsRequest } from '@jellyfin/sdk/lib/generated-client/api/artists-api';
import { CollectionType } from '@jellyfin/sdk/lib/generated-client/models/collection-type';
import { getArtistsApi } from '@jellyfin/sdk/lib/utils/api/artists-api';
import { useQuery } from '@tanstack/react-query';
import { AxiosRequestConfig } from 'axios';
import { useApi } from 'hooks/useApi';
import { QUERY_OPTIONS } from '../constants/queryOptions';
import { isMusic } from '../utils/search';
const fetchArtists = async (
api: Api,
userId: string,
params?: ArtistsApiGetArtistsRequest,
options?: AxiosRequestConfig
) => {
const response = await getArtistsApi(api).getArtists(
{
...QUERY_OPTIONS,
userId,
...params
},
options
);
return response.data;
};
export const useArtistsSearch = (
parentId?: string,
collectionType?: CollectionType,
searchTerm?: string
) => {
const { api, user } = useApi();
const userId = user?.Id;
return useQuery({
queryKey: ['Search', 'Artists', collectionType, parentId, searchTerm],
queryFn: ({ signal }) => fetchArtists(
api!,
userId!,
{
parentId,
searchTerm
},
{ signal }
),
enabled: !!api && !!userId && (!collectionType || isMusic(collectionType))
});
};

View file

@ -0,0 +1,150 @@
import { Api } from '@jellyfin/sdk';
import { CollectionType } from '@jellyfin/sdk/lib/generated-client/models/collection-type';
import { useQuery } from '@tanstack/react-query';
import { useApi } from 'hooks/useApi';
import { addSection, isLivetv } from '../utils/search';
import { BaseItemKind } from '@jellyfin/sdk/lib/generated-client/models/base-item-kind';
import { LIVETV_CARD_OPTIONS } from '../constants/liveTvCardOptions';
import { CardShape } from 'utils/card';
import { Section } from '../types';
import { fetchItemsByType } from './fetchItemsByType';
const fetchLiveTv = (api: Api, userId: string | undefined, searchTerm: string | undefined, signal: AbortSignal) => {
const sections: Section[] = [];
// Movies row
const movies = fetchItemsByType(
api,
userId,
{
includeItemTypes: [ BaseItemKind.LiveTvProgram ],
isMovie: true,
searchTerm
},
{ signal }
).then(moviesData => {
addSection(sections, 'Movies', moviesData.Items, {
...LIVETV_CARD_OPTIONS,
shape: CardShape.PortraitOverflow
});
});
// Episodes row
const episodes = fetchItemsByType(
api,
userId,
{
includeItemTypes: [ BaseItemKind.LiveTvProgram ],
isMovie: false,
isSeries: true,
isSports: false,
isKids: false,
isNews: false,
searchTerm
},
{ signal }
).then(episodesData => {
addSection(sections, 'Episodes', episodesData.Items, {
...LIVETV_CARD_OPTIONS
});
});
// Sports row
const sports = fetchItemsByType(
api,
userId,
{
includeItemTypes: [ BaseItemKind.LiveTvProgram ],
isSports: true,
searchTerm
},
{ signal }
).then(sportsData => {
addSection(sections, 'Sports', sportsData.Items, {
...LIVETV_CARD_OPTIONS
});
});
// Kids row
const kids = fetchItemsByType(
api,
userId,
{
includeItemTypes: [ BaseItemKind.LiveTvProgram ],
isKids: true,
searchTerm
},
{ signal }
).then(kidsData => {
addSection(sections, 'Kids', kidsData.Items, {
...LIVETV_CARD_OPTIONS
});
});
// News row
const news = fetchItemsByType(
api,
userId,
{
includeItemTypes: [ BaseItemKind.LiveTvProgram ],
isNews: true,
searchTerm
},
{ signal }
).then(newsData => {
addSection(sections, 'News', newsData.Items, {
...LIVETV_CARD_OPTIONS
});
});
// Programs row
const programs = fetchItemsByType(
api,
userId,
{
includeItemTypes: [ BaseItemKind.LiveTvProgram ],
isMovie: false,
isSeries: false,
isSports: false,
isKids: false,
isNews: false,
searchTerm
},
{ signal }
).then(programsData => {
addSection(sections, 'Programs', programsData.Items, {
...LIVETV_CARD_OPTIONS
});
});
// Channels row
const channels = fetchItemsByType(
api,
userId,
{
includeItemTypes: [ BaseItemKind.TvChannel ],
searchTerm
},
{ signal }
).then(channelsData => {
addSection(sections, 'Channels', channelsData.Items);
});
return Promise.all([ movies, episodes, sports, kids, news, programs, channels ]).then(() => sections);
};
export const useLiveTvSearch = (
parentId?: string,
collectionType?: CollectionType,
searchTerm?: string
) => {
const { api, user } = useApi();
const userId = user?.Id;
return useQuery({
queryKey: ['Search', 'LiveTv', collectionType, parentId, searchTerm],
queryFn: ({ signal }) =>
fetchLiveTv(api!, userId!, searchTerm, signal),
enabled: !!api && !!userId && !!collectionType && !!isLivetv(collectionType)
});
};

View file

@ -0,0 +1,50 @@
import { Api } from '@jellyfin/sdk';
import { CollectionType } from '@jellyfin/sdk/lib/generated-client/models/collection-type';
import { useQuery } from '@tanstack/react-query';
import { AxiosRequestConfig } from 'axios';
import { useApi } from 'hooks/useApi';
import { QUERY_OPTIONS } from '../constants/queryOptions';
import { isMovies, isTVShows } from '../utils/search';
import { PersonsApiGetPersonsRequest } from '@jellyfin/sdk/lib/generated-client/api/persons-api';
import { getPersonsApi } from '@jellyfin/sdk/lib/utils/api/persons-api';
const fetchPeople = async (
api: Api,
userId: string,
params?: PersonsApiGetPersonsRequest,
options?: AxiosRequestConfig
) => {
const response = await getPersonsApi(api).getPersons(
{
...QUERY_OPTIONS,
userId,
...params
},
options
);
return response.data;
};
export const usePeopleSearch = (
parentId?: string,
collectionType?: CollectionType,
searchTerm?: string
) => {
const { api, user } = useApi();
const userId = user?.Id;
const isPeopleEnabled = (!collectionType || isMovies(collectionType) || isTVShows(collectionType));
return useQuery({
queryKey: ['Search', 'People', collectionType, parentId, searchTerm],
queryFn: ({ signal }) => fetchPeople(
api!,
userId!,
{
searchTerm
},
{ signal }
),
enabled: !!api && !!userId && isPeopleEnabled
});
};

View file

@ -0,0 +1,50 @@
import { Api } from '@jellyfin/sdk';
import { CollectionType } from '@jellyfin/sdk/lib/generated-client/models/collection-type';
import { useQuery } from '@tanstack/react-query';
import { AxiosRequestConfig } from 'axios';
import { useApi } from 'hooks/useApi';
import { BaseItemKind } from '@jellyfin/sdk/lib/generated-client/models/base-item-kind';
import { ItemsApiGetItemsRequest } from '@jellyfin/sdk/lib/generated-client/api/items-api';
import { fetchItemsByType } from './fetchItemsByType';
const fetchPrograms = async (
api: Api,
userId: string,
params?: ItemsApiGetItemsRequest,
options?: AxiosRequestConfig
) => {
const response = await fetchItemsByType(
api,
userId,
{
includeItemTypes: [BaseItemKind.LiveTvProgram],
...params
},
options
);
return response;
};
export const useProgramsSearch = (
parentId?: string,
collectionType?: CollectionType,
searchTerm?: string
) => {
const { api, user } = useApi();
const userId = user?.Id;
return useQuery({
queryKey: ['Search', 'Programs', collectionType, parentId, searchTerm],
queryFn: ({ signal }) => fetchPrograms(
api!,
userId!,
{
parentId,
searchTerm
},
{ signal }
),
enabled: !!api && !!userId && !collectionType
});
};

View file

@ -0,0 +1,98 @@
import { BaseItemKind } from '@jellyfin/sdk/lib/generated-client/models/base-item-kind';
import type { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models/base-item-dto';
import { CollectionType } from '@jellyfin/sdk/lib/generated-client/models/collection-type';
import { useQuery } from '@tanstack/react-query';
import { useApi } from '../../../../../hooks/useApi';
import { addSection, getCardOptionsFromType, getItemTypesFromCollectionType, getTitleFromType, isLivetv, isMovies, isMusic, isTVShows, sortSections } from '../utils/search';
import { useArtistsSearch } from './useArtistsSearch';
import { usePeopleSearch } from './usePeopleSearch';
import { useVideoSearch } from './useVideoSearch';
import { Section } from '../types';
import { useLiveTvSearch } from './useLiveTvSearch';
import { fetchItemsByType } from './fetchItemsByType';
import { useProgramsSearch } from './useProgramsSearch';
import { LIVETV_CARD_OPTIONS } from '../constants/liveTvCardOptions';
export const useSearchItems = (
parentId?: string,
collectionType?: CollectionType,
searchTerm?: string
) => {
const { data: artists, isPending: isArtistsPending } = useArtistsSearch(parentId, collectionType, searchTerm);
const { data: people, isPending: isPeoplePending } = usePeopleSearch(parentId, collectionType, searchTerm);
const { data: videos, isPending: isVideosPending } = useVideoSearch(parentId, collectionType, searchTerm);
const { data: programs, isPending: isProgramsPending } = useProgramsSearch(parentId, collectionType, searchTerm);
const { data: liveTvSections, isPending: isLiveTvPending } = useLiveTvSearch(parentId, collectionType, searchTerm);
const { api, user } = useApi();
const userId = user?.Id;
const isArtistsEnabled = !isArtistsPending || (collectionType && !isMusic(collectionType));
const isPeopleEnabled = !isPeoplePending || (collectionType && !isMovies(collectionType) && !isTVShows(collectionType));
const isVideosEnabled = !isVideosPending || collectionType;
const isProgramsEnabled = !isProgramsPending || collectionType;
const isLiveTvEnabled = !isLiveTvPending || !collectionType || !isLivetv(collectionType);
return useQuery({
queryKey: ['Search', 'Items', collectionType, parentId, searchTerm],
queryFn: async ({ signal }) => {
if (liveTvSections && collectionType && isLivetv(collectionType)) {
return sortSections(liveTvSections);
}
const sections: Section[] = [];
addSection(sections, 'Artists', artists?.Items, {
coverImage: true
});
addSection(sections, 'Programs', programs?.Items, {
...LIVETV_CARD_OPTIONS
});
addSection(sections, 'People', people?.Items, {
coverImage: true
});
addSection(sections, 'HeaderVideos', videos?.Items, {
showParentTitle: true
});
const itemTypes: BaseItemKind[] = getItemTypesFromCollectionType(collectionType);
const searchData = await fetchItemsByType(
api!,
userId,
{
includeItemTypes: itemTypes,
parentId,
searchTerm,
limit: 800
},
{ signal }
);
if (searchData.Items) {
for (const itemType of itemTypes) {
const items: BaseItemDto[] = [];
for (const searchItem of searchData.Items) {
if (searchItem.Type === itemType) {
items.push(searchItem);
}
}
addSection(sections, getTitleFromType(itemType), items, getCardOptionsFromType(itemType));
}
}
return sortSections(sections);
},
enabled: (
!!api
&& !!userId
&& !!isArtistsEnabled
&& !!isPeopleEnabled
&& !!isVideosEnabled
&& !!isLiveTvEnabled
&& !!isProgramsEnabled
)
});
};

View file

@ -4,20 +4,17 @@ import { ItemSortBy } from '@jellyfin/sdk/lib/generated-client';
import { BaseItemKind } from '@jellyfin/sdk/lib/generated-client/models/base-item-kind';
import { getItemsApi } from '@jellyfin/sdk/lib/utils/api/items-api';
import { useQuery } from '@tanstack/react-query';
import { useApi } from '../useApi';
import { useApi } from 'hooks/useApi';
const fetchGetItems = async (
api?: Api,
userId?: string,
api: Api,
userId: string,
parentId?: string,
options?: AxiosRequestConfig
) => {
if (!api) throw new Error('No API instance available');
if (!userId) throw new Error('No User ID provided');
const response = await getItemsApi(api).getItems(
{
userId: userId,
userId,
sortBy: [ItemSortBy.IsFavoriteOrLiked, ItemSortBy.Random],
includeItemTypes: [
BaseItemKind.Movie,
@ -28,7 +25,7 @@ const fetchGetItems = async (
recursive: true,
imageTypeLimit: 0,
enableImages: false,
parentId: parentId,
parentId,
enableTotalRecordCount: false
},
options
@ -43,7 +40,8 @@ export const useSearchSuggestions = (parentId?: string) => {
return useQuery({
queryKey: ['SearchSuggestions', { parentId }],
queryFn: ({ signal }) =>
fetchGetItems(api, userId, parentId, { signal }),
fetchGetItems(api!, userId!, parentId, { signal }),
refetchOnWindowFocus: false,
enabled: !!api && !!userId
});
};

View file

@ -0,0 +1,57 @@
import { Api } from '@jellyfin/sdk';
import { CollectionType } from '@jellyfin/sdk/lib/generated-client/models/collection-type';
import { useQuery } from '@tanstack/react-query';
import { AxiosRequestConfig } from 'axios';
import { useApi } from 'hooks/useApi';
import { QUERY_OPTIONS } from '../constants/queryOptions';
import { getItemsApi } from '@jellyfin/sdk/lib/utils/api/items-api';
import { MediaType } from '@jellyfin/sdk/lib/generated-client/models/media-type';
import { BaseItemKind } from '@jellyfin/sdk/lib/generated-client/models/base-item-kind';
import { ItemsApiGetItemsRequest } from '@jellyfin/sdk/lib/generated-client/api/items-api';
const fetchVideos = async (
api: Api,
userId: string,
params?: ItemsApiGetItemsRequest,
options?: AxiosRequestConfig
) => {
const response = await getItemsApi(api).getItems(
{
...QUERY_OPTIONS,
userId,
recursive: true,
mediaTypes: [MediaType.Video],
excludeItemTypes: [
BaseItemKind.Movie,
BaseItemKind.Episode,
BaseItemKind.TvChannel
],
...params
},
options
);
return response.data;
};
export const useVideoSearch = (
parentId?: string,
collectionType?: CollectionType,
searchTerm?: string
) => {
const { api, user } = useApi();
const userId = user?.Id;
return useQuery({
queryKey: ['Search', 'Video', collectionType, parentId, searchTerm],
queryFn: ({ signal }) => fetchVideos(
api!,
userId!,
{
parentId,
searchTerm
},
{ signal }
),
enabled: !!api && !!userId && !collectionType
});
};

View file

@ -1,11 +1,11 @@
import React, { type ChangeEvent, type FC, useCallback, useRef } from 'react';
import AlphaPicker from '../alphaPicker/AlphaPickerComponent';
import AlphaPicker from 'components/alphaPicker/AlphaPickerComponent';
import Input from 'elements/emby-input/Input';
import globalize from '../../lib/globalize';
import layoutManager from '../layoutManager';
import browser from '../../scripts/browser';
import globalize from 'lib/globalize';
import layoutManager from 'components/layoutManager';
import browser from 'scripts/browser';
import 'material-design-icons-iconfont';
import '../../styles/flexstyles.scss';
import 'styles/flexstyles.scss';
import './searchfields.scss';
interface SearchFieldsProps {

View file

@ -1,13 +1,16 @@
import React, { type FC } from 'react';
import { Section, useSearchItems } from 'hooks/searchHook';
import globalize from '../../lib/globalize';
import Loading from '../loading/LoadingComponent';
import { useSearchItems } from '../api/useSearchItems';
import globalize from 'lib/globalize';
import Loading from 'components/loading/LoadingComponent';
import SearchResultsRow from './SearchResultsRow';
import { CardShape } from 'utils/card';
import { CollectionType } from '@jellyfin/sdk/lib/generated-client/models/collection-type';
import { Section } from '../types';
import { Link } from 'react-router-dom';
interface SearchResultsProps {
parentId?: string;
collectionType?: string;
collectionType?: CollectionType;
query?: string;
}
@ -19,14 +22,22 @@ const SearchResults: FC<SearchResultsProps> = ({
collectionType,
query
}) => {
const { isLoading, data } = useSearchItems(parentId, collectionType, query);
const { data, isPending } = useSearchItems(parentId, collectionType, query?.trim());
if (isLoading) return <Loading />;
if (isPending) return <Loading />;
if (!data?.length) {
return (
<div className='noItemsMessage centerMessage'>
{globalize.translate('SearchResultsEmpty', query)}
{collectionType && (
<div>
<Link
className='emby-button'
to={`/search.html?query=${encodeURIComponent(query || '')}`}
>{globalize.translate('RetryWithGlobalSearch')}</Link>
</div>
)}
</div>
);
}
@ -51,7 +62,7 @@ const SearchResults: FC<SearchResultsProps> = ({
};
return (
<div className={'searchResults, padded-top, padded-bottom-page'}>
<div className={'searchResults padded-top padded-bottom-page'}>
{data.map((section, index) => renderSection(section, index))}
</div>
);

View file

@ -1,10 +1,10 @@
import type { BaseItemDto } from '@jellyfin/sdk/lib/generated-client';
import React, { type FC, useEffect, useRef } from 'react';
import cardBuilder from '../cardbuilder/cardBuilder';
import cardBuilder from 'components/cardbuilder/cardBuilder';
import type { CardOptions } from 'types/cardOptions';
import '../../elements/emby-scroller/emby-scroller';
import '../../elements/emby-itemscontainer/emby-itemscontainer';
import 'elements/emby-scroller/emby-scroller';
import 'elements/emby-itemscontainer/emby-itemscontainer';
// There seems to be some compatibility issues here between
// React and our legacy web components, so we need to inject

View file

@ -1,21 +1,21 @@
import React, { FunctionComponent } from 'react';
import Loading from 'components/loading/LoadingComponent';
import { appRouter } from '../router/appRouter';
import { useSearchSuggestions } from 'hooks/searchHook/useSearchSuggestions';
import { appRouter } from 'components/router/appRouter';
import { useSearchSuggestions } from '../api/useSearchSuggestions';
import globalize from 'lib/globalize';
import LinkButton from '../../elements/emby-button/LinkButton';
import LinkButton from 'elements/emby-button/LinkButton';
import '../../elements/emby-button/emby-button';
import 'elements/emby-button/emby-button';
type SearchSuggestionsProps = {
parentId?: string | null;
};
const SearchSuggestions: FunctionComponent<SearchSuggestionsProps> = ({ parentId }) => {
const { isLoading, data: suggestions } = useSearchSuggestions(parentId || undefined);
const { data: suggestions, isPending } = useSearchSuggestions(parentId || undefined);
if (isLoading) return <Loading />;
if (isPending) return <Loading />;
return (
<div

View file

@ -0,0 +1,11 @@
export const LIVETV_CARD_OPTIONS = {
preferThumb: true,
inheritThumb: false,
showParentTitleOrTitle: true,
showTitle: false,
coverImage: true,
overlayMoreButton: true,
showAirTime: true,
showAirDateTime: true,
showChannelName: true
};

View file

@ -0,0 +1,12 @@
import { ItemFields } from '@jellyfin/sdk/lib/generated-client/models/item-fields';
export const QUERY_OPTIONS = {
limit: 100,
fields: [
ItemFields.PrimaryImageAspectRatio,
ItemFields.CanDelete,
ItemFields.MediaSourceCount
],
enableTotalRecordCount: false,
imageTypeLimit: 1
};

View file

@ -0,0 +1,18 @@
export const SEARCH_SECTIONS_SORT_ORDER = [
'Movies',
'Shows',
'Episodes',
'People',
'Playlists',
'Artists',
'Albums',
'Songs',
'HeaderVideos',
'Programs',
'Channels',
'HeaderPhotoAlbums',
'Photos',
'HeaderAudioBooks',
'Books',
'Collections'
];

View file

@ -0,0 +1,8 @@
import type { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models/base-item-dto';
import { CardOptions } from 'types/cardOptions';
export interface Section {
title: string
items: BaseItemDto[];
cardOptions?: CardOptions;
};

View file

@ -0,0 +1,141 @@
import { BaseItemKind } from '@jellyfin/sdk/lib/generated-client/models/base-item-kind';
import { CollectionType } from '@jellyfin/sdk/lib/generated-client/models/collection-type';
import { CardShape } from 'utils/card';
import { Section } from '../types';
import { CardOptions } from 'types/cardOptions';
import type { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models/base-item-dto';
import { LIVETV_CARD_OPTIONS } from '../constants/liveTvCardOptions';
import { SEARCH_SECTIONS_SORT_ORDER } from '../constants/sectionSortOrder';
export const isMovies = (collectionType: string) =>
collectionType === CollectionType.Movies;
export const isTVShows = (collectionType: string) =>
collectionType === CollectionType.Tvshows;
export const isMusic = (collectionType: string) =>
collectionType === CollectionType.Music;
export const isLivetv = (collectionType: string) =>
collectionType === CollectionType.Livetv;
export function addSection(
sections: Section[],
title: string,
items: BaseItemDto[] | null | undefined,
cardOptions?: CardOptions
) {
if (items && items?.length > 0) {
sections.push({ title, items, cardOptions });
}
}
export function sortSections(sections: Section[]) {
return sections.sort((a, b) => {
const indexA = SEARCH_SECTIONS_SORT_ORDER.indexOf(a.title);
const indexB = SEARCH_SECTIONS_SORT_ORDER.indexOf(b.title);
if (indexA > indexB) {
return 1;
} else if (indexA < indexB) {
return -1;
} else {
return 0;
}
});
}
export function getCardOptionsFromType(type: BaseItemKind) {
switch (type) {
case BaseItemKind.Movie:
case BaseItemKind.Series:
case BaseItemKind.MusicAlbum:
return {
showYear: true
};
case BaseItemKind.Episode:
return {
coverImage: true,
showParentTitle: true
};
case BaseItemKind.MusicArtist:
return {
coverImage: true
};
case BaseItemKind.Audio:
return {
showParentTitle: true,
shape: CardShape.SquareOverflow
};
case BaseItemKind.LiveTvProgram:
return LIVETV_CARD_OPTIONS;
default:
return {};
}
}
export function getTitleFromType(type: BaseItemKind) {
switch (type) {
case BaseItemKind.Movie:
return 'Movies';
case BaseItemKind.Series:
return 'Shows';
case BaseItemKind.Episode:
return 'Episodes';
case BaseItemKind.Playlist:
return 'Playlists';
case BaseItemKind.MusicAlbum:
return 'Albums';
case BaseItemKind.Audio:
return 'Songs';
case BaseItemKind.LiveTvProgram:
return 'Programs';
case BaseItemKind.TvChannel:
return 'Channels';
case BaseItemKind.PhotoAlbum:
return 'HeaderPhotoAlbums';
case BaseItemKind.Photo:
return 'Photos';
case BaseItemKind.AudioBook:
return 'HeaderAudioBooks';
case BaseItemKind.Book:
return 'Books';
case BaseItemKind.BoxSet:
return 'Collections';
default:
return '';
}
}
export function getItemTypesFromCollectionType(collectionType: CollectionType | undefined) {
switch (collectionType) {
case CollectionType.Movies:
return [ BaseItemKind.Movie ];
case CollectionType.Tvshows:
return [
BaseItemKind.Series,
BaseItemKind.Episode
];
case CollectionType.Music:
return [
BaseItemKind.Playlist,
BaseItemKind.MusicAlbum,
BaseItemKind.Audio
];
default:
return [
BaseItemKind.Movie,
BaseItemKind.Series,
BaseItemKind.Episode,
BaseItemKind.Playlist,
BaseItemKind.MusicAlbum,
BaseItemKind.Audio,
BaseItemKind.TvChannel,
BaseItemKind.PhotoAlbum,
BaseItemKind.Photo,
BaseItemKind.AudioBook,
BaseItemKind.Book,
BaseItemKind.BoxSet
];
}
}

View file

@ -2,6 +2,6 @@ import { AsyncRoute } from '../../../../components/router/AsyncRoute';
export const ASYNC_USER_ROUTES: AsyncRoute[] = [
{ path: 'quickconnect', page: 'quickConnect' },
{ path: 'search.html', page: 'search' },
{ path: 'userprofile.html', page: 'user/userprofile' }
{ path: 'search', page: 'search' },
{ path: 'userprofile', page: 'user/userprofile' }
];

View file

@ -2,77 +2,77 @@ import { LegacyRoute } from '../../../../components/router/LegacyRoute';
export const LEGACY_PUBLIC_ROUTES: LegacyRoute[] = [
{
path: 'addserver.html',
path: 'addserver',
pageProps: {
controller: 'session/addServer/index',
view: 'session/addServer/index.html'
}
},
{
path: 'selectserver.html',
path: 'selectserver',
pageProps: {
controller: 'session/selectServer/index',
view: 'session/selectServer/index.html'
}
},
{
path: 'login.html',
path: 'login',
pageProps: {
controller: 'session/login/index',
view: 'session/login/index.html'
}
},
{
path: 'forgotpassword.html',
path: 'forgotpassword',
pageProps: {
controller: 'session/forgotPassword/index',
view: 'session/forgotPassword/index.html'
}
},
{
path: 'forgotpasswordpin.html',
path: 'forgotpasswordpin',
pageProps: {
controller: 'session/resetPassword/index',
view: 'session/resetPassword/index.html'
}
},
{
path: 'wizardremoteaccess.html',
path: 'wizardremoteaccess',
pageProps: {
controller: 'wizard/remote/index',
view: 'wizard/remote/index.html'
}
},
{
path: 'wizardfinish.html',
path: 'wizardfinish',
pageProps: {
controller: 'wizard/finish/index',
view: 'wizard/finish/index.html'
}
},
{
path: 'wizardlibrary.html',
path: 'wizardlibrary',
pageProps: {
controller: 'wizard/library',
view: 'wizard/library.html'
}
},
{
path: 'wizardsettings.html',
path: 'wizardsettings',
pageProps: {
controller: 'wizard/settings/index',
view: 'wizard/settings/index.html'
}
},
{
path: 'wizardstart.html',
path: 'wizardstart',
pageProps: {
controller: 'wizard/start/index',
view: 'wizard/start/index.html'
}
},
{
path: 'wizarduser.html',
path: 'wizarduser',
pageProps: {
controller: 'wizard/user/index',
view: 'wizard/user/index.html'

View file

@ -8,13 +8,13 @@ export const LEGACY_USER_ROUTES: LegacyRoute[] = [
view: 'itemDetails/index.html'
}
}, {
path: 'list.html',
path: 'list',
pageProps: {
controller: 'list',
view: 'list.html'
}
}, {
path: 'livetv.html',
path: 'livetv',
pageProps: {
controller: 'livetv/livetvsuggested',
view: 'livetv.html'
@ -26,49 +26,49 @@ export const LEGACY_USER_ROUTES: LegacyRoute[] = [
view: 'lyrics.html'
}
}, {
path: 'music.html',
path: 'music',
pageProps: {
controller: 'music/musicrecommended',
view: 'music/music.html'
}
}, {
path: 'mypreferencesmenu.html',
path: 'mypreferencesmenu',
pageProps: {
controller: 'user/menu/index',
view: 'user/menu/index.html'
}
}, {
path: 'mypreferencescontrols.html',
path: 'mypreferencescontrols',
pageProps: {
controller: 'user/controls/index',
view: 'user/controls/index.html'
}
}, {
path: 'mypreferencesdisplay.html',
path: 'mypreferencesdisplay',
pageProps: {
controller: 'user/display/index',
view: 'user/display/index.html'
}
}, {
path: 'mypreferenceshome.html',
path: 'mypreferenceshome',
pageProps: {
controller: 'user/home/index',
view: 'user/home/index.html'
}
}, {
path: 'mypreferencesplayback.html',
path: 'mypreferencesplayback',
pageProps: {
controller: 'user/playback/index',
view: 'user/playback/index.html'
}
}, {
path: 'mypreferencessubtitles.html',
path: 'mypreferencessubtitles',
pageProps: {
controller: 'user/subtitles/index',
view: 'user/subtitles/index.html'
}
}, {
path: 'tv.html',
path: 'tv',
pageProps: {
controller: 'shows/tvrecommended',
view: 'shows/tvrecommended.html'
@ -93,13 +93,13 @@ export const LEGACY_USER_ROUTES: LegacyRoute[] = [
isThemeMediaSupported: true
}
}, {
path: 'home.html',
path: 'home',
pageProps: {
controller: 'home',
view: 'home.html'
}
}, {
path: 'movies.html',
path: 'movies',
pageProps: {
controller: 'movies/moviesrecommended',
view: 'movies/movies.html'

View file

@ -84,7 +84,7 @@ const QuickConnectPage: FC = () => {
<p>
{globalize.translate('QuickConnectAuthorizeSuccess')}
</p>
<Link to='/home.html' className='button-link emby-button'>
<Link to='/home' className='button-link emby-button'>
{globalize.translate('GoHome')}
</Link>
</div>

View file

@ -5,6 +5,7 @@ import ConnectionRequired from 'components/ConnectionRequired';
import { toAsyncPageRoute } from 'components/router/AsyncRoute';
import { toViewManagerPageRoute } from 'components/router/LegacyRoute';
import ErrorBoundary from 'components/router/ErrorBoundary';
import FallbackRoute from 'components/router/FallbackRoute';
import AppLayout from '../AppLayout';
@ -16,9 +17,11 @@ export const STABLE_APP_ROUTES: RouteObject[] = [
path: '/*',
Component: AppLayout,
children: [
{ index: true, element: <Navigate replace to='/home' /> },
{
/* User routes */
element: <ConnectionRequired isUserRequired />,
Component: ConnectionRequired,
children: [
...ASYNC_USER_ROUTES.map(toAsyncPageRoute),
...LEGACY_USER_ROUTES.map(toViewManagerPageRoute)
@ -26,9 +29,19 @@ export const STABLE_APP_ROUTES: RouteObject[] = [
ErrorBoundary
},
/* Public routes */
{ index: true, element: <Navigate replace to='/home.html' /> },
...LEGACY_PUBLIC_ROUTES.map(toViewManagerPageRoute)
{
/* Public routes */
element: <ConnectionRequired isUserRequired={false} />,
children: [
...LEGACY_PUBLIC_ROUTES.map(toViewManagerPageRoute),
/* Fallback route for invalid paths */
{
path: '*',
Component: FallbackRoute
}
]
}
]
}
];

View file

@ -4,9 +4,10 @@ import { useDebounceValue } from 'usehooks-ts';
import { usePrevious } from 'hooks/usePrevious';
import globalize from 'lib/globalize';
import Page from 'components/Page';
import SearchFields from 'components/search/SearchFields';
import SearchSuggestions from 'components/search/SearchSuggestions';
import SearchResults from 'components/search/SearchResults';
import SearchFields from 'apps/stable/features/search/components/SearchFields';
import SearchSuggestions from 'apps/stable/features/search/components/SearchSuggestions';
import SearchResults from 'apps/stable/features/search/components/SearchResults';
import { CollectionType } from '@jellyfin/sdk/lib/generated-client/models/collection-type';
const COLLECTION_TYPE_PARAM = 'collectionType';
const PARENT_ID_PARAM = 'parentId';
@ -15,7 +16,7 @@ const QUERY_PARAM = 'query';
const Search: FC = () => {
const [searchParams, setSearchParams] = useSearchParams();
const parentIdQuery = searchParams.get(PARENT_ID_PARAM) || undefined;
const collectionTypeQuery = searchParams.get(COLLECTION_TYPE_PARAM) || undefined;
const collectionTypeQuery = (searchParams.get(COLLECTION_TYPE_PARAM) || undefined) as CollectionType | undefined;
const urlQuery = searchParams.get(QUERY_PARAM) || '';
const [query, setQuery] = useState(urlQuery);
const prevQuery = usePrevious(query, '');
@ -50,7 +51,7 @@ const Search: FC = () => {
className='mainAnimatedPage libraryPage allLibraryPage noSecondaryNavPage'
>
<SearchFields query={query} onSearch={setQuery} />
{!query ? (
{!debouncedQuery ? (
<SearchSuggestions
parentId={parentIdQuery}
/>

View file

@ -27,7 +27,7 @@ const ConfirmDialog: FC<ConfirmDialogProps> = ({
onConfirm,
...dialogProps
}) => (
<Dialog {...dialogProps}>
<Dialog onClose={onCancel} {...dialogProps}>
<DialogTitle>
{title}
</DialogTitle>

View file

@ -9,10 +9,10 @@ import globalize from '../lib/globalize';
import { ConnectionState } from '../utils/jellyfin-apiclient/ConnectionState';
enum BounceRoutes {
Home = '/home.html',
Login = '/login.html',
SelectServer = '/selectserver.html',
StartWizard = '/wizardstart.html'
Home = '/home',
Login = '/login',
SelectServer = '/selectserver',
StartWizard = '/wizardstart'
}
type ConnectionRequiredProps = {

View file

@ -0,0 +1,26 @@
import React, { type FC } from 'react';
import { useUserSettings } from 'hooks/useUserSettings';
import { useBrandingOptions } from 'apps/dashboard/features/branding/api/useBrandingOptions';
const CustomCss: FC = () => {
const { data: brandingOptions } = useBrandingOptions();
const { customCss: userCustomCss, disableCustomCss } = useUserSettings();
return (
<>
{!disableCustomCss && brandingOptions?.CustomCss && (
<style>
{brandingOptions.CustomCss}
</style>
)}
{userCustomCss && (
<style>
{userCustomCss}
</style>
)}
</>
);
};
export default CustomCss;

View file

@ -1,5 +1,5 @@
// NOTE: This is used for jsdoc return type
// eslint-disable-next-line no-unused-vars
// eslint-disable-next-line @typescript-eslint/no-unused-vars
import { Api } from '@jellyfin/sdk';
import { MINIMUM_VERSION } from '@jellyfin/sdk/lib/versions';
import { ConnectionManager, Credentials, ApiClient } from 'jellyfin-apiclient';
@ -18,7 +18,6 @@ const normalizeImageOptions = options => {
};
const getMaxBandwidth = () => {
/* eslint-disable compat/compat */
if (navigator.connection) {
let max = navigator.connection.downlinkMax;
if (max && max > 0 && max < Number.POSITIVE_INFINITY) {
@ -28,7 +27,6 @@ const getMaxBandwidth = () => {
return parseInt(max, 10);
}
}
/* eslint-enable compat/compat */
return null;
};

View file

@ -0,0 +1,38 @@
import Button from '@mui/material/Button';
import Dialog, { type DialogProps } from '@mui/material/Dialog';
import DialogActions from '@mui/material/DialogActions';
import DialogContent from '@mui/material/DialogContent';
import DialogContentText from '@mui/material/DialogContentText';
import DialogTitle from '@mui/material/DialogTitle';
import globalize from 'lib/globalize';
import React from 'react';
interface SimpleAlertDialog extends DialogProps {
title?: string;
text: string;
onClose: () => void
};
const SimpleAlert = ({ open, title, text, onClose }: SimpleAlertDialog) => {
return (
<Dialog open={open} onClose={onClose}>
{title && (
<DialogTitle>
{title}
</DialogTitle>
)}
<DialogContent>
<DialogContentText>
{text}
</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={onClose}>
{globalize.translate('ButtonGotIt')}
</Button>
</DialogActions>
</Dialog>
);
};
export default SimpleAlert;

View file

@ -0,0 +1,34 @@
import React, { type FC, useEffect, useState } from 'react';
import { useUserTheme } from 'hooks/useUserTheme';
import { getDefaultTheme } from 'scripts/settings/webSettings';
interface ThemeCssProps {
dashboard?: boolean
}
const getThemeUrl = (id: string) => `themes/${id}/theme.css`;;
const DEFAULT_THEME_URL = getThemeUrl(getDefaultTheme().id);
const ThemeCss: FC<ThemeCssProps> = ({
dashboard = false
}) => {
const { theme, dashboardTheme } = useUserTheme();
const [ themeUrl, setThemeUrl ] = useState(DEFAULT_THEME_URL);
useEffect(() => {
const id = dashboard ? dashboardTheme : theme;
if (id) setThemeUrl(getThemeUrl(id));
}, [dashboard, dashboardTheme, theme]);
return (
<link
rel='stylesheet'
type='text/css'
href={themeUrl}
/>
);
};
export default ThemeCss;

View file

@ -22,7 +22,9 @@ const UserAvatar: FC<UserAvatarProps> = ({ user }) => {
undefined
}
sx={{
bgcolor: theme.palette.primary.dark,
bgcolor: api && user.Id && user.PrimaryImageTag ?
theme.palette.background.paper :
theme.palette.primary.dark,
color: 'inherit'
}}
/>

View file

@ -452,7 +452,7 @@ let isHidden = false;
let hidden;
let visibilityChange;
if (typeof document.hidden !== 'undefined') { /* eslint-disable-line compat/compat */
if (typeof document.hidden !== 'undefined') {
hidden = 'hidden';
visibilityChange = 'visibilitychange';
} else if (typeof document.webkitHidden !== 'undefined') {
@ -461,7 +461,6 @@ if (typeof document.hidden !== 'undefined') { /* eslint-disable-line compat/comp
}
document.addEventListener(visibilityChange, function () {
/* eslint-disable-next-line compat/compat */
if (document[hidden]) {
onAppHidden();
} else {

View file

@ -484,7 +484,7 @@ function getAirTimeText(item, showAirDateTime, showAirEndTime) {
airTimeText += ' - ' + datetime.getDisplayTime(date);
}
} catch (e) {
console.error('error parsing date: ' + item.StartDate);
console.error('error parsing date: ' + item.StartDate, e);
}
}
@ -617,7 +617,7 @@ function getCardFooterText(item, apiClient, options, footerClass, progressHtml,
datetime.parseISO8601Date(item.PremiereDate),
{ weekday: 'long', month: 'long', day: 'numeric' }
));
} catch (err) {
} catch {
lines.push('');
}
} else {

View file

@ -678,6 +678,7 @@ describe('getDefaultBackgroundClass', () => {
});
test('randomization string provided', () => {
// eslint-disable-next-line sonarjs/pseudo-random
const generateRandomString = (stringLength: number): string => (Math.random() + 1).toString(36).substring(stringLength);
for (let i = 0; i < 100; i++) {

View file

@ -137,7 +137,7 @@ function loadSection(elem, userId, topParentId, section, isSingleSection) {
html += '<div class="sectionTitleContainer sectionTitleContainer-cards padded-left">';
if (!layoutManager.tv && options.Limit && result.Items.length >= options.Limit) {
html += '<a is="emby-linkbutton" href="' + ('#/list.html?serverId=' + ApiClient.serverId() + '&type=' + section.types + '&IsFavorite=true') + '" class="more button-flat button-flat-mini sectionTitleTextButton">';
html += '<a is="emby-linkbutton" href="' + ('#/list?serverId=' + ApiClient.serverId() + '&type=' + section.types + '&IsFavorite=true') + '" class="more button-flat button-flat-mini sectionTitleTextButton">';
html += '<h2 class="sectionTitle sectionTitle-cards">';
html += globalize.translate(section.name);
html += '</h2>';

View file

@ -14,6 +14,7 @@ function merge(resultItems, queryItems, delimiter) {
if (!queryItems) {
return resultItems;
}
// eslint-disable-next-line sonarjs/no-alphabetical-sort
return union(resultItems, queryItems.split(delimiter)).sort();
}

View file

@ -358,7 +358,7 @@ function Guide(options) {
if ((typeof date).toString().toLowerCase() === 'string') {
try {
date = datetime.parseISO8601Date(date, { toLocal: true });
} catch (err) {
} catch {
return date;
}
}
@ -392,7 +392,7 @@ function Guide(options) {
try {
program.StartDateLocal = datetime.parseISO8601Date(program.StartDate, { toLocal: true });
} catch (err) {
console.error('error parsing timestamp for start date');
console.error('error parsing timestamp for start date', err);
}
}
@ -400,7 +400,7 @@ function Guide(options) {
try {
program.EndDateLocal = datetime.parseISO8601Date(program.EndDate, { toLocal: true });
} catch (err) {
console.error('error parsing timestamp for end date');
console.error('error parsing timestamp for end date', err);
}
}

View file

@ -77,6 +77,7 @@ function setFiles(page, files) {
reader.readAsDataURL(file);
}
// eslint-disable-next-line sonarjs/no-invariant-returns
function onSubmit(e) {
const file = currentFile;

View file

@ -2,7 +2,7 @@ import Worker from './blurhash.worker.ts'; // eslint-disable-line import/default
import * as lazyLoader from '../lazyLoader/lazyLoaderIntersectionObserver';
import * as userSettings from '../../scripts/settings/userSettings';
import './style.scss';
// eslint-disable-next-line compat/compat
const worker = new Worker();
const targetDic = {};
worker.addEventListener(

Some files were not shown because too many files have changed in this diff Show more