mirror of
https://github.com/jellyfin/jellyfin-web
synced 2025-03-30 19:56:21 +00:00
Merge branch 'master' into master
This commit is contained in:
commit
bac03437f7
108 changed files with 2005 additions and 1723 deletions
|
@ -1,6 +1,5 @@
|
|||
{
|
||||
"ecmaVersion": "es5",
|
||||
"modules": "false",
|
||||
"files": "./dist/**/*.js",
|
||||
"not": [
|
||||
"./dist/libraries/pdf.worker.js",
|
||||
|
|
6
.github/workflows/__codeql.yml
vendored
6
.github/workflows/__codeql.yml
vendored
|
@ -26,15 +26,15 @@ jobs:
|
|||
show-progress: false
|
||||
|
||||
- name: Initialize CodeQL 🛠️
|
||||
uses: github/codeql-action/init@5f8171a638ada777af81d42b55959a643bb29017 # v3.28.12
|
||||
uses: github/codeql-action/init@1b549b9259bda1cb5ddde3b41741a82a2d15a841 # v3.28.13
|
||||
with:
|
||||
queries: security-and-quality
|
||||
languages: ${{ matrix.language }}
|
||||
|
||||
- name: Autobuild 📦
|
||||
uses: github/codeql-action/autobuild@5f8171a638ada777af81d42b55959a643bb29017 # v3.28.12
|
||||
uses: github/codeql-action/autobuild@1b549b9259bda1cb5ddde3b41741a82a2d15a841 # v3.28.13
|
||||
|
||||
- name: Perform CodeQL Analysis 🧪
|
||||
uses: github/codeql-action/analyze@5f8171a638ada777af81d42b55959a643bb29017 # v3.28.12
|
||||
uses: github/codeql-action/analyze@1b549b9259bda1cb5ddde3b41741a82a2d15a841 # v3.28.13
|
||||
with:
|
||||
category: '/language:${{matrix.language}}'
|
||||
|
|
|
@ -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',
|
||||
|
||||
|
|
1418
package-lock.json
generated
1418
package-lock.json
generated
File diff suppressed because it is too large
Load diff
39
package.json
39
package.json
|
@ -5,12 +5,12 @@
|
|||
"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.22.0",
|
||||
"@eslint/js": "9.23.0",
|
||||
"@stylistic/eslint-plugin": "4.2.0",
|
||||
"@stylistic/stylelint-plugin": "3.1.2",
|
||||
"@types/dompurify": "3.0.5",
|
||||
|
@ -18,13 +18,13 @@
|
|||
"@types/loadable__component": "5.13.9",
|
||||
"@types/lodash-es": "4.17.12",
|
||||
"@types/markdown-it": "14.1.2",
|
||||
"@types/react": "18.3.18",
|
||||
"@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.26.1",
|
||||
"@typescript-eslint/parser": "8.27.0",
|
||||
"@uupaa/dynamic-import-polyfill": "1.0.2",
|
||||
"@vitest/coverage-v8": "3.0.8",
|
||||
"@vitest/coverage-v8": "3.0.9",
|
||||
"autoprefixer": "10.4.21",
|
||||
"babel-loader": "10.0.0",
|
||||
"clean-webpack-plugin": "4.0.0",
|
||||
|
@ -33,8 +33,8 @@
|
|||
"cross-env": "7.0.3",
|
||||
"css-loader": "7.1.2",
|
||||
"cssnano": "7.0.6",
|
||||
"es-check": "7.2.1",
|
||||
"eslint": "9.22.0",
|
||||
"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",
|
||||
|
@ -42,6 +42,7 @@
|
|||
"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": "16.0.0",
|
||||
"html-loader": "5.1.0",
|
||||
|
@ -52,7 +53,7 @@
|
|||
"postcss-loader": "8.1.1",
|
||||
"postcss-preset-env": "10.1.5",
|
||||
"postcss-scss": "4.0.9",
|
||||
"sass": "1.85.1",
|
||||
"sass": "1.86.0",
|
||||
"sass-loader": "16.0.5",
|
||||
"source-map-loader": "5.0.0",
|
||||
"speed-measure-webpack-plugin": "1.5.0",
|
||||
|
@ -64,8 +65,8 @@
|
|||
"stylelint-scss": "6.11.1",
|
||||
"ts-loader": "9.5.2",
|
||||
"typescript": "5.8.2",
|
||||
"typescript-eslint": "8.26.1",
|
||||
"vitest": "3.0.8",
|
||||
"typescript-eslint": "8.27.0",
|
||||
"vitest": "3.0.9",
|
||||
"webpack": "5.98.0",
|
||||
"webpack-bundle-analyzer": "4.10.2",
|
||||
"webpack-cli": "6.0.1",
|
||||
|
@ -76,20 +77,20 @@
|
|||
"dependencies": {
|
||||
"@emotion/react": "11.14.0",
|
||||
"@emotion/styled": "11.14.0",
|
||||
"@fontsource/noto-sans": "5.2.5",
|
||||
"@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.202503230501",
|
||||
"@mui/icons-material": "6.4.7",
|
||||
"@mui/material": "6.4.7",
|
||||
"@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.68.0",
|
||||
"@tanstack/react-query-devtools": "5.68.0",
|
||||
"@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",
|
||||
|
@ -113,7 +114,7 @@
|
|||
"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",
|
||||
|
@ -130,7 +131,7 @@
|
|||
"whatwg-fetch": "3.6.20"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"sass-embedded": "1.85.1"
|
||||
"sass-embedded": "1.86.0"
|
||||
},
|
||||
"browserslist": [
|
||||
"last 2 Firefox versions",
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -148,11 +148,13 @@ const NewTriggerForm: FunctionComponent<IProps> = ({ open, title, onClose, onAdd
|
|||
fullWidth
|
||||
defaultValue={''}
|
||||
type='number'
|
||||
inputProps={{
|
||||
min: 1,
|
||||
step: 0.5
|
||||
}}
|
||||
label={globalize.translate('LabelTimeLimitHours')}
|
||||
slotProps={{
|
||||
htmlInput: {
|
||||
min: 1,
|
||||
step: 0.5
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
</DialogContent>
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -161,7 +161,6 @@ export const Component = () => {
|
|||
select
|
||||
name='UICulture'
|
||||
label={globalize.translate('LabelPreferredDisplayLanguage')}
|
||||
FormHelperTextProps={{ component: Stack }}
|
||||
helperText={(
|
||||
<>
|
||||
<span>{globalize.translate('LabelDisplayLanguageHelp')}</span>
|
||||
|
@ -171,6 +170,9 @@ export const Component = () => {
|
|||
</>
|
||||
)}
|
||||
defaultValue={config.UICulture}
|
||||
slotProps={{
|
||||
formHelperText: { component: Stack }
|
||||
}}
|
||||
>
|
||||
{languageOptions.map((language) =>
|
||||
<MenuItem key={language.Name} value={language.Value || ''}>{language.Name}</MenuItem>
|
||||
|
@ -185,14 +187,16 @@ export const Component = () => {
|
|||
helperText={globalize.translate('LabelCachePathHelp')}
|
||||
value={cachePath}
|
||||
onChange={onCachePathChange}
|
||||
InputProps={{
|
||||
endAdornment: (
|
||||
<InputAdornment position='end'>
|
||||
<IconButton edge='end' onClick={showCachePathPicker}>
|
||||
<SearchIcon />
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
)
|
||||
slotProps={{
|
||||
input: {
|
||||
endAdornment: (
|
||||
<InputAdornment position='end'>
|
||||
<IconButton edge='end' onClick={showCachePathPicker}>
|
||||
<SearchIcon />
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
|
@ -202,14 +206,16 @@ export const Component = () => {
|
|||
helperText={globalize.translate('LabelMetadataPathHelp')}
|
||||
value={metadataPath}
|
||||
onChange={onMetadataPathChange}
|
||||
InputProps={{
|
||||
endAdornment: (
|
||||
<InputAdornment position='end'>
|
||||
<IconButton edge='end' onClick={showMetadataPathPicker}>
|
||||
<SearchIcon />
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
)
|
||||
slotProps={{
|
||||
input: {
|
||||
endAdornment: (
|
||||
<InputAdornment position='end'>
|
||||
<IconButton edge='end' onClick={showMetadataPathPicker}>
|
||||
<SearchIcon />
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
|
@ -232,25 +238,29 @@ export const Component = () => {
|
|||
<TextField
|
||||
name='LibraryScanFanoutConcurrency'
|
||||
type='number'
|
||||
inputProps={{
|
||||
min: 0,
|
||||
step: 1
|
||||
}}
|
||||
label={globalize.translate('LibraryScanFanoutConcurrency')}
|
||||
helperText={globalize.translate('LibraryScanFanoutConcurrencyHelp')}
|
||||
defaultValue={config.LibraryScanFanoutConcurrency || ''}
|
||||
slotProps={{
|
||||
htmlInput: {
|
||||
min: 0,
|
||||
step: 1
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
name='ParallelImageEncodingLimit'
|
||||
type='number'
|
||||
inputProps={{
|
||||
min: 0,
|
||||
step: 1
|
||||
}}
|
||||
label={globalize.translate('LabelParallelImageEncodingLimit')}
|
||||
helperText={globalize.translate('LabelParallelImageEncodingLimitHelp')}
|
||||
defaultValue={config.ParallelImageEncodingLimit || ''}
|
||||
slotProps={{
|
||||
htmlInput: {
|
||||
min: 0,
|
||||
step: 1
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<Button type='submit' size='large'>
|
||||
|
|
|
@ -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 />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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')}
|
||||
|
|
|
@ -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')}
|
||||
|
|
|
@ -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')}
|
||||
|
|
|
@ -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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
23
src/apps/stable/features/search/api/fetchItemsByType.ts
Normal file
23
src/apps/stable/features/search/api/fetchItemsByType.ts
Normal 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;
|
||||
};
|
49
src/apps/stable/features/search/api/useArtistsSearch.ts
Normal file
49
src/apps/stable/features/search/api/useArtistsSearch.ts
Normal 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))
|
||||
});
|
||||
};
|
150
src/apps/stable/features/search/api/useLiveTvSearch.ts
Normal file
150
src/apps/stable/features/search/api/useLiveTvSearch.ts
Normal 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)
|
||||
});
|
||||
};
|
50
src/apps/stable/features/search/api/usePeopleSearch.ts
Normal file
50
src/apps/stable/features/search/api/usePeopleSearch.ts
Normal 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
|
||||
});
|
||||
};
|
50
src/apps/stable/features/search/api/useProgramsSearch.ts
Normal file
50
src/apps/stable/features/search/api/useProgramsSearch.ts
Normal 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
|
||||
});
|
||||
};
|
98
src/apps/stable/features/search/api/useSearchItems.ts
Normal file
98
src/apps/stable/features/search/api/useSearchItems.ts
Normal 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
|
||||
)
|
||||
});
|
||||
};
|
|
@ -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
|
||||
});
|
||||
};
|
57
src/apps/stable/features/search/api/useVideoSearch.ts
Normal file
57
src/apps/stable/features/search/api/useVideoSearch.ts
Normal 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
|
||||
});
|
||||
};
|
|
@ -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 {
|
|
@ -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>
|
||||
);
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
||||
};
|
12
src/apps/stable/features/search/constants/queryOptions.ts
Normal file
12
src/apps/stable/features/search/constants/queryOptions.ts
Normal 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
|
||||
};
|
|
@ -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'
|
||||
];
|
8
src/apps/stable/features/search/types.ts
Normal file
8
src/apps/stable/features/search/types.ts
Normal 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;
|
||||
};
|
141
src/apps/stable/features/search/utils/search.ts
Normal file
141
src/apps/stable/features/search/utils/search.ts
Normal 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
|
||||
];
|
||||
}
|
||||
}
|
|
@ -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}
|
||||
/>
|
||||
|
|
26
src/components/CustomCss.tsx
Normal file
26
src/components/CustomCss.tsx
Normal 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;
|
|
@ -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;
|
||||
};
|
||||
|
|
34
src/components/ThemeCss.tsx
Normal file
34
src/components/ThemeCss.tsx
Normal 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;
|
|
@ -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 {
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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++) {
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -25,7 +25,6 @@ const Lists: FC<ListsProps> = ({ items = [], listOptions = {} }) => {
|
|||
const renderListItem = (item: ItemDto, index: number) => {
|
||||
return (
|
||||
<List
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
key={`${item.Id}-${index}`}
|
||||
index={index}
|
||||
item={item}
|
||||
|
|
|
@ -23,6 +23,7 @@ import toast from '../toast/toast';
|
|||
import confirm from '../confirm/confirm';
|
||||
import template from './mediaLibraryEditor.template.html';
|
||||
|
||||
// eslint-disable-next-line sonarjs/no-invariant-returns
|
||||
function onEditLibrary() {
|
||||
if (isCreating) {
|
||||
return false;
|
||||
|
|
|
@ -60,7 +60,7 @@ function getProgramInfoHtml(item, options) {
|
|||
|
||||
miscInfo.push(text);
|
||||
} catch (e) {
|
||||
console.error('error parsing date:', item.StartDate);
|
||||
console.error('error parsing date:', item.StartDate, e);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -140,7 +140,7 @@ export function getMediaInfoHtml(item, options = {}) {
|
|||
text = datetime.toLocaleDateString(date);
|
||||
miscInfo.push(text);
|
||||
} catch (e) {
|
||||
console.error('error parsing date:', item.PremiereDate);
|
||||
console.error('error parsing date:', item.PremiereDate, e);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -170,7 +170,7 @@ export function getMediaInfoHtml(item, options = {}) {
|
|||
miscInfo.push(text);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('error parsing date:', item.StartDate);
|
||||
console.error('error parsing date:', item.StartDate, e);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -188,7 +188,7 @@ export function getMediaInfoHtml(item, options = {}) {
|
|||
text += ` - ${endYear}`;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('error parsing date:', item.EndDate);
|
||||
console.error('error parsing date:', item.EndDate, e);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -238,7 +238,7 @@ export function getMediaInfoHtml(item, options = {}) {
|
|||
text = globalize.translate('OriginalAirDateValue', datetime.toLocaleDateString(date));
|
||||
miscInfo.push(text);
|
||||
} catch (e) {
|
||||
console.error('error parsing date:', program.PremiereDate);
|
||||
console.error('error parsing date:', program.PremiereDate, e);
|
||||
}
|
||||
} else if (program.ProductionYear && options.year !== false ) {
|
||||
miscInfo.push(program.ProductionYear);
|
||||
|
@ -255,7 +255,7 @@ export function getMediaInfoHtml(item, options = {}) {
|
|||
text = datetime.toLocaleString(datetime.parseISO8601Date(item.PremiereDate).getFullYear(), { useGrouping: false });
|
||||
miscInfo.push(text);
|
||||
} catch (e) {
|
||||
console.error('error parsing date:', item.PremiereDate);
|
||||
console.error('error parsing date:', item.PremiereDate, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -821,7 +821,7 @@ function fillItemInfo(context, item, parentalRatingOptions) {
|
|||
date = datetime.parseISO8601Date(item.DateCreated, true);
|
||||
|
||||
context.querySelector('#txtDateAdded').value = date.toISOString().slice(0, 10);
|
||||
} catch (e) {
|
||||
} catch {
|
||||
context.querySelector('#txtDateAdded').value = '';
|
||||
}
|
||||
} else {
|
||||
|
@ -833,7 +833,7 @@ function fillItemInfo(context, item, parentalRatingOptions) {
|
|||
date = datetime.parseISO8601Date(item.PremiereDate, true);
|
||||
|
||||
context.querySelector('#txtPremiereDate').value = date.toISOString().slice(0, 10);
|
||||
} catch (e) {
|
||||
} catch {
|
||||
context.querySelector('#txtPremiereDate').value = '';
|
||||
}
|
||||
} else {
|
||||
|
@ -845,7 +845,7 @@ function fillItemInfo(context, item, parentalRatingOptions) {
|
|||
date = datetime.parseISO8601Date(item.EndDate, true);
|
||||
|
||||
context.querySelector('#txtEndDate').value = date.toISOString().slice(0, 10);
|
||||
} catch (e) {
|
||||
} catch {
|
||||
context.querySelector('#txtEndDate').value = '';
|
||||
}
|
||||
} else {
|
||||
|
|
|
@ -13,7 +13,6 @@ function onOneDocumentClick() {
|
|||
|
||||
// don't request notification permissions if they're already granted or denied
|
||||
if (window.Notification && window.Notification.permission === 'default') {
|
||||
/* eslint-disable-next-line compat/compat */
|
||||
Notification.requestPermission();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -17,7 +17,6 @@ Events.on(playbackManager, 'playbackstart', function (e, player) {
|
|||
const isLocalVideo = player.isLocalPlayer && !player.isExternalPlayer && playbackManager.isPlayingVideo(player);
|
||||
|
||||
if (isLocalVideo && layoutManager.mobile) {
|
||||
/* eslint-disable-next-line compat/compat */
|
||||
const lockOrientation = window.screen.lockOrientation || window.screen.mozLockOrientation || window.screen.msLockOrientation || (window.screen.orientation?.lock);
|
||||
|
||||
if (lockOrientation) {
|
||||
|
@ -38,7 +37,6 @@ Events.on(playbackManager, 'playbackstart', function (e, player) {
|
|||
|
||||
Events.on(playbackManager, 'playbackstop', function (e, playbackStopInfo) {
|
||||
if (orientationLocked && !playbackStopInfo.nextMediaType) {
|
||||
/* eslint-disable-next-line compat/compat */
|
||||
const unlockOrientation = window.screen.unlockOrientation || window.screen.mozUnlockOrientation || window.screen.msUnlockOrientation || (window.screen.orientation?.unlock);
|
||||
|
||||
if (unlockOrientation) {
|
||||
|
|
|
@ -265,7 +265,7 @@ export default function (view) {
|
|||
document.addEventListener('keydown', onKeyDown);
|
||||
try {
|
||||
onLoad();
|
||||
} catch (e) {
|
||||
} catch {
|
||||
appRouter.goHome();
|
||||
}
|
||||
});
|
||||
|
|
|
@ -713,7 +713,7 @@ export default function (view) {
|
|||
}, state);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('error parsing date: ' + program.EndDate);
|
||||
console.error('error parsing date: ' + program.EndDate, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1352,7 +1352,7 @@ export default function (view) {
|
|||
case 'GamepadDPadLeft':
|
||||
case 'GamepadLeftThumbstickLeft':
|
||||
// Ignores gamepad events that are always triggered, even when not focused.
|
||||
if (document.hasFocus()) { /* eslint-disable-line compat/compat */
|
||||
if (document.hasFocus()) {
|
||||
playbackManager.rewind(currentPlayer);
|
||||
showOsd(btnRewind);
|
||||
}
|
||||
|
@ -1361,7 +1361,7 @@ export default function (view) {
|
|||
case 'GamepadDPadRight':
|
||||
case 'GamepadLeftThumbstickRight':
|
||||
// Ignores gamepad events that are always triggered, even when not focused.
|
||||
if (document.hasFocus()) { /* eslint-disable-line compat/compat */
|
||||
if (document.hasFocus()) {
|
||||
playbackManager.fastForward(currentPlayer);
|
||||
showOsd(btnFastForward);
|
||||
}
|
||||
|
@ -1712,7 +1712,7 @@ export default function (view) {
|
|||
if (browser.firefox || browser.edge) {
|
||||
dom.addEventListener(document, 'click', onClickCapture, { capture: true });
|
||||
}
|
||||
} catch (e) {
|
||||
} catch {
|
||||
setBackdropTransparency(TRANSPARENCY_LEVEL.None); // reset state set in viewbeforeshow
|
||||
appRouter.goHome();
|
||||
}
|
||||
|
|
|
@ -61,7 +61,7 @@ function renderUpcoming(elem, items) {
|
|||
day: 'numeric'
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('error parsing timestamp for upcoming tv shows');
|
||||
console.error('error parsing timestamp for upcoming tv shows', err);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -1,2 +0,0 @@
|
|||
export * from './useSearchItems';
|
||||
export * from './useSearchSuggestions';
|
|
@ -1,509 +0,0 @@
|
|||
import type { AxiosRequestConfig } from 'axios';
|
||||
import type { Api } from '@jellyfin/sdk';
|
||||
import type {
|
||||
ArtistsApiGetArtistsRequest,
|
||||
BaseItemDto,
|
||||
ItemsApiGetItemsRequest,
|
||||
PersonsApiGetPersonsRequest
|
||||
} from '@jellyfin/sdk/lib/generated-client';
|
||||
import { BaseItemKind } from '@jellyfin/sdk/lib/generated-client/models/base-item-kind';
|
||||
import { CollectionType } from '@jellyfin/sdk/lib/generated-client/models/collection-type';
|
||||
import { ItemFields } from '@jellyfin/sdk/lib/generated-client/models/item-fields';
|
||||
import { MediaType } from '@jellyfin/sdk/lib/generated-client/models/media-type';
|
||||
import { getItemsApi } from '@jellyfin/sdk/lib/utils/api/items-api';
|
||||
import { getPersonsApi } from '@jellyfin/sdk/lib/utils/api/persons-api';
|
||||
import { getArtistsApi } from '@jellyfin/sdk/lib/utils/api/artists-api';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useApi } from '../useApi';
|
||||
import type { CardOptions } from 'types/cardOptions';
|
||||
import { CardShape } from 'utils/card';
|
||||
|
||||
const QUERY_OPTIONS = {
|
||||
limit: 100,
|
||||
fields: [
|
||||
ItemFields.PrimaryImageAspectRatio,
|
||||
ItemFields.CanDelete,
|
||||
ItemFields.MediaSourceCount
|
||||
],
|
||||
enableTotalRecordCount: false,
|
||||
imageTypeLimit: 1
|
||||
};
|
||||
|
||||
const fetchItemsByType = async (
|
||||
api: Api,
|
||||
userId?: string,
|
||||
params?: ItemsApiGetItemsRequest,
|
||||
options?: AxiosRequestConfig
|
||||
) => {
|
||||
const response = await getItemsApi(api).getItems(
|
||||
{
|
||||
...QUERY_OPTIONS,
|
||||
userId: userId,
|
||||
recursive: true,
|
||||
...params
|
||||
},
|
||||
options
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
const fetchPeople = async (
|
||||
api: Api,
|
||||
userId: string,
|
||||
params?: PersonsApiGetPersonsRequest,
|
||||
options?: AxiosRequestConfig
|
||||
) => {
|
||||
const response = await getPersonsApi(api).getPersons(
|
||||
{
|
||||
...QUERY_OPTIONS,
|
||||
userId: userId,
|
||||
...params
|
||||
},
|
||||
options
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
const fetchArtists = async (
|
||||
api: Api,
|
||||
userId: string,
|
||||
params?: ArtistsApiGetArtistsRequest,
|
||||
options?: AxiosRequestConfig
|
||||
) => {
|
||||
const response = await getArtistsApi(api).getArtists(
|
||||
{
|
||||
...QUERY_OPTIONS,
|
||||
userId: userId,
|
||||
...params
|
||||
},
|
||||
options
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
const isMovies = (collectionType: string) =>
|
||||
collectionType === CollectionType.Movies;
|
||||
|
||||
const isMusic = (collectionType: string) =>
|
||||
collectionType === CollectionType.Music;
|
||||
|
||||
const isTVShows = (collectionType: string) =>
|
||||
collectionType === CollectionType.Tvshows;
|
||||
|
||||
const isLivetv = (collectionType: string) =>
|
||||
collectionType === CollectionType.Livetv;
|
||||
|
||||
const LIVETV_CARD_OPTIONS = {
|
||||
preferThumb: true,
|
||||
inheritThumb: false,
|
||||
showParentTitleOrTitle: true,
|
||||
showTitle: false,
|
||||
coverImage: true,
|
||||
overlayMoreButton: true,
|
||||
showAirTime: true,
|
||||
showAirDateTime: true,
|
||||
showChannelName: true
|
||||
};
|
||||
|
||||
export interface Section {
|
||||
title: string
|
||||
items: BaseItemDto[];
|
||||
cardOptions?: CardOptions;
|
||||
}
|
||||
|
||||
export const useSearchItems = (
|
||||
parentId?: string,
|
||||
collectionType?: string,
|
||||
searchTerm?: string
|
||||
) => {
|
||||
const { api, user } = useApi();
|
||||
const userId = user?.Id;
|
||||
|
||||
return useQuery({
|
||||
queryKey: ['SearchItems', { parentId, collectionType, searchTerm }],
|
||||
queryFn: async ({ signal }) => {
|
||||
if (!api) throw new Error('No API instance available');
|
||||
if (!userId) throw new Error('No User ID provided');
|
||||
|
||||
const sections: Section[] = [];
|
||||
|
||||
const addSection = (
|
||||
title: string,
|
||||
items: BaseItemDto[] | null | undefined,
|
||||
cardOptions?: CardOptions
|
||||
) => {
|
||||
if (items && items?.length > 0) {
|
||||
sections.push({ title, items, cardOptions });
|
||||
}
|
||||
};
|
||||
|
||||
// Livetv libraries
|
||||
if (collectionType && isLivetv(collectionType)) {
|
||||
// Movies row
|
||||
const moviesData = await fetchItemsByType(
|
||||
api,
|
||||
userId,
|
||||
{
|
||||
includeItemTypes: [BaseItemKind.LiveTvProgram],
|
||||
isMovie: true,
|
||||
searchTerm: searchTerm
|
||||
},
|
||||
{ signal }
|
||||
);
|
||||
addSection('Movies', moviesData.Items, {
|
||||
...LIVETV_CARD_OPTIONS,
|
||||
shape: CardShape.PortraitOverflow
|
||||
});
|
||||
|
||||
// Episodes row
|
||||
const episodesData = await fetchItemsByType(
|
||||
api,
|
||||
userId,
|
||||
{
|
||||
includeItemTypes: [BaseItemKind.LiveTvProgram],
|
||||
isMovie: false,
|
||||
isSeries: true,
|
||||
isSports: false,
|
||||
isKids: false,
|
||||
isNews: false,
|
||||
searchTerm: searchTerm
|
||||
},
|
||||
{ signal }
|
||||
);
|
||||
addSection('Episodes', episodesData.Items, {
|
||||
...LIVETV_CARD_OPTIONS
|
||||
});
|
||||
|
||||
// Sports row
|
||||
const sportsData = await fetchItemsByType(
|
||||
api,
|
||||
userId,
|
||||
{
|
||||
includeItemTypes: [BaseItemKind.LiveTvProgram],
|
||||
isSports: true,
|
||||
searchTerm: searchTerm
|
||||
},
|
||||
{ signal }
|
||||
);
|
||||
addSection('Sports', sportsData.Items, {
|
||||
...LIVETV_CARD_OPTIONS
|
||||
});
|
||||
|
||||
// Kids row
|
||||
const kidsData = await fetchItemsByType(
|
||||
api,
|
||||
userId,
|
||||
{
|
||||
includeItemTypes: [BaseItemKind.LiveTvProgram],
|
||||
isKids: true,
|
||||
searchTerm: searchTerm
|
||||
},
|
||||
{ signal }
|
||||
);
|
||||
addSection('Kids', kidsData.Items, {
|
||||
...LIVETV_CARD_OPTIONS
|
||||
});
|
||||
|
||||
// News row
|
||||
const newsData = await fetchItemsByType(
|
||||
api,
|
||||
userId,
|
||||
{
|
||||
includeItemTypes: [BaseItemKind.LiveTvProgram],
|
||||
isNews: true,
|
||||
searchTerm: searchTerm
|
||||
},
|
||||
{ signal }
|
||||
);
|
||||
addSection('News', newsData.Items, {
|
||||
...LIVETV_CARD_OPTIONS
|
||||
});
|
||||
|
||||
// Programs row
|
||||
const programsData = await fetchItemsByType(
|
||||
api,
|
||||
userId,
|
||||
{
|
||||
includeItemTypes: [BaseItemKind.LiveTvProgram],
|
||||
isMovie: false,
|
||||
isSeries: false,
|
||||
isSports: false,
|
||||
isKids: false,
|
||||
isNews: false,
|
||||
searchTerm: searchTerm
|
||||
},
|
||||
{ signal }
|
||||
);
|
||||
addSection('Programs', programsData.Items, {
|
||||
...LIVETV_CARD_OPTIONS
|
||||
});
|
||||
|
||||
// Channels row
|
||||
const channelsData = await fetchItemsByType(
|
||||
api,
|
||||
userId,
|
||||
{
|
||||
includeItemTypes: [BaseItemKind.TvChannel],
|
||||
searchTerm: searchTerm
|
||||
},
|
||||
{ signal }
|
||||
);
|
||||
addSection('Channels', channelsData.Items);
|
||||
}
|
||||
|
||||
// Movie libraries
|
||||
if (!collectionType || isMovies(collectionType)) {
|
||||
// Movies row
|
||||
const moviesData = await fetchItemsByType(
|
||||
api,
|
||||
userId,
|
||||
{
|
||||
includeItemTypes: [BaseItemKind.Movie],
|
||||
parentId: parentId,
|
||||
searchTerm: searchTerm
|
||||
},
|
||||
{ signal }
|
||||
);
|
||||
addSection('Movies', moviesData.Items, {
|
||||
showYear: true
|
||||
});
|
||||
}
|
||||
|
||||
// TV Show libraries
|
||||
if (!collectionType || isTVShows(collectionType)) {
|
||||
// Shows row
|
||||
const showsData = await fetchItemsByType(
|
||||
api,
|
||||
userId,
|
||||
{
|
||||
includeItemTypes: [BaseItemKind.Series],
|
||||
parentId: parentId,
|
||||
searchTerm: searchTerm
|
||||
},
|
||||
{ signal }
|
||||
);
|
||||
addSection('Shows', showsData.Items, {
|
||||
showYear: true
|
||||
});
|
||||
|
||||
// Episodes row
|
||||
const episodesData = await fetchItemsByType(
|
||||
api,
|
||||
userId,
|
||||
{
|
||||
includeItemTypes: [BaseItemKind.Episode],
|
||||
parentId: parentId,
|
||||
isMissing: user?.Configuration?.DisplayMissingEpisodes,
|
||||
searchTerm: searchTerm
|
||||
},
|
||||
{ signal }
|
||||
);
|
||||
addSection('Episodes', episodesData.Items, {
|
||||
coverImage: true,
|
||||
showParentTitle: true
|
||||
});
|
||||
}
|
||||
|
||||
// People are included for Movies and TV Shows
|
||||
if (
|
||||
!collectionType
|
||||
|| isMovies(collectionType)
|
||||
|| isTVShows(collectionType)
|
||||
) {
|
||||
// People row
|
||||
const peopleData = await fetchPeople(
|
||||
api,
|
||||
userId,
|
||||
{
|
||||
searchTerm: searchTerm
|
||||
},
|
||||
{ signal }
|
||||
);
|
||||
addSection('People', peopleData.Items, {
|
||||
coverImage: true
|
||||
});
|
||||
}
|
||||
|
||||
// Music libraries
|
||||
if (!collectionType || isMusic(collectionType)) {
|
||||
// Playlists row
|
||||
const playlistsData = await fetchItemsByType(
|
||||
api,
|
||||
userId,
|
||||
{
|
||||
includeItemTypes: [BaseItemKind.Playlist],
|
||||
parentId: parentId,
|
||||
searchTerm: searchTerm
|
||||
},
|
||||
{ signal }
|
||||
);
|
||||
addSection('Playlists', playlistsData.Items);
|
||||
|
||||
// Artists row
|
||||
const artistsData = await fetchArtists(
|
||||
api,
|
||||
userId,
|
||||
{
|
||||
parentId: parentId,
|
||||
searchTerm: searchTerm
|
||||
},
|
||||
{ signal }
|
||||
);
|
||||
addSection('Artists', artistsData.Items, {
|
||||
coverImage: true
|
||||
});
|
||||
|
||||
// Albums row
|
||||
const albumsData = await fetchItemsByType(
|
||||
api,
|
||||
userId,
|
||||
{
|
||||
includeItemTypes: [BaseItemKind.MusicAlbum],
|
||||
parentId: parentId,
|
||||
searchTerm: searchTerm
|
||||
},
|
||||
{ signal }
|
||||
);
|
||||
addSection('Albums', albumsData.Items, {
|
||||
showYear: true
|
||||
});
|
||||
|
||||
// Songs row
|
||||
const songsData = await fetchItemsByType(
|
||||
api,
|
||||
userId,
|
||||
{
|
||||
includeItemTypes: [BaseItemKind.Audio],
|
||||
parentId: parentId,
|
||||
searchTerm: searchTerm
|
||||
},
|
||||
{ signal }
|
||||
);
|
||||
addSection('Songs', songsData.Items, {
|
||||
showParentTitle: true,
|
||||
shape: CardShape.SquareOverflow
|
||||
});
|
||||
}
|
||||
|
||||
// Other libraries do not support in-library search currently
|
||||
if (!collectionType) {
|
||||
// Videos row
|
||||
const videosData = await fetchItemsByType(
|
||||
api,
|
||||
userId,
|
||||
{
|
||||
mediaTypes: [MediaType.Video],
|
||||
excludeItemTypes: [
|
||||
BaseItemKind.Movie,
|
||||
BaseItemKind.Episode,
|
||||
BaseItemKind.TvChannel
|
||||
],
|
||||
parentId: parentId,
|
||||
searchTerm: searchTerm
|
||||
},
|
||||
{ signal }
|
||||
);
|
||||
|
||||
addSection('HeaderVideos', videosData.Items, {
|
||||
showParentTitle: true
|
||||
});
|
||||
|
||||
// Programs row
|
||||
const programsData = await fetchItemsByType(
|
||||
api,
|
||||
userId,
|
||||
{
|
||||
includeItemTypes: [BaseItemKind.LiveTvProgram],
|
||||
parentId: parentId,
|
||||
searchTerm: searchTerm
|
||||
},
|
||||
{ signal }
|
||||
);
|
||||
addSection('Programs', programsData.Items, {
|
||||
...LIVETV_CARD_OPTIONS
|
||||
});
|
||||
|
||||
// Channels row
|
||||
const channelsData = await fetchItemsByType(
|
||||
api,
|
||||
userId,
|
||||
{
|
||||
includeItemTypes: [BaseItemKind.TvChannel],
|
||||
parentId: parentId,
|
||||
searchTerm: searchTerm
|
||||
},
|
||||
{ signal }
|
||||
);
|
||||
addSection('Channels', channelsData.Items);
|
||||
|
||||
// Photo Albums row
|
||||
const photoAlbumsData = await fetchItemsByType(
|
||||
api,
|
||||
userId,
|
||||
{
|
||||
includeItemTypes: [BaseItemKind.PhotoAlbum],
|
||||
parentId: parentId,
|
||||
searchTerm: searchTerm
|
||||
},
|
||||
{ signal }
|
||||
);
|
||||
addSection('HeaderPhotoAlbums', photoAlbumsData.Items);
|
||||
|
||||
// Photos row
|
||||
const photosData = await fetchItemsByType(
|
||||
api,
|
||||
userId,
|
||||
{
|
||||
includeItemTypes: [BaseItemKind.Photo],
|
||||
parentId: parentId,
|
||||
searchTerm: searchTerm
|
||||
},
|
||||
{ signal }
|
||||
);
|
||||
addSection('Photos', photosData.Items);
|
||||
|
||||
// Audio Books row
|
||||
const audioBooksData = await fetchItemsByType(
|
||||
api,
|
||||
userId,
|
||||
{
|
||||
includeItemTypes: [BaseItemKind.AudioBook],
|
||||
parentId: parentId,
|
||||
searchTerm: searchTerm
|
||||
},
|
||||
{ signal }
|
||||
);
|
||||
addSection('HeaderAudioBooks', audioBooksData.Items);
|
||||
|
||||
// Books row
|
||||
const booksData = await fetchItemsByType(
|
||||
api,
|
||||
userId,
|
||||
{
|
||||
includeItemTypes: [BaseItemKind.Book],
|
||||
parentId: parentId,
|
||||
searchTerm: searchTerm
|
||||
},
|
||||
{ signal }
|
||||
);
|
||||
addSection('Books', booksData.Items);
|
||||
|
||||
// Collections row
|
||||
const collectionsData = await fetchItemsByType(
|
||||
api,
|
||||
userId,
|
||||
{
|
||||
includeItemTypes: [BaseItemKind.BoxSet],
|
||||
parentId: parentId,
|
||||
searchTerm: searchTerm
|
||||
},
|
||||
{ signal }
|
||||
);
|
||||
addSection('Collections', collectionsData.Items);
|
||||
}
|
||||
|
||||
return sections;
|
||||
},
|
||||
enabled: !!api && !!userId
|
||||
});
|
||||
};
|
|
@ -7,6 +7,8 @@ import Events, { type Event } from 'utils/events';
|
|||
import { useApi } from './useApi';
|
||||
|
||||
interface UserSettings {
|
||||
customCss?: string
|
||||
disableCustomCss: boolean
|
||||
theme?: string
|
||||
dashboardTheme?: string
|
||||
dateTimeLocale?: string
|
||||
|
@ -15,6 +17,9 @@ interface UserSettings {
|
|||
|
||||
// NOTE: This is an incomplete list of only the settings that are currently being used
|
||||
const UserSettingField = {
|
||||
// Custom CSS
|
||||
CustomCss: 'customCss',
|
||||
DisableCustomCss: 'disableCustomCss',
|
||||
// Theme settings
|
||||
Theme: 'appTheme',
|
||||
DashboardTheme: 'dashboardTheme',
|
||||
|
@ -23,11 +28,15 @@ const UserSettingField = {
|
|||
Language: 'language'
|
||||
};
|
||||
|
||||
const UserSettingsContext = createContext<UserSettings>({});
|
||||
const UserSettingsContext = createContext<UserSettings>({
|
||||
disableCustomCss: false
|
||||
});
|
||||
|
||||
export const useUserSettings = () => useContext(UserSettingsContext);
|
||||
|
||||
export const UserSettingsProvider: FC<PropsWithChildren<unknown>> = ({ children }) => {
|
||||
const [ customCss, setCustomCss ] = useState<string>();
|
||||
const [ disableCustomCss, setDisableCustomCss ] = useState(false);
|
||||
const [ theme, setTheme ] = useState<string>();
|
||||
const [ dashboardTheme, setDashboardTheme ] = useState<string>();
|
||||
const [ dateTimeLocale, setDateTimeLocale ] = useState<string>();
|
||||
|
@ -36,14 +45,25 @@ export const UserSettingsProvider: FC<PropsWithChildren<unknown>> = ({ children
|
|||
const { user } = useApi();
|
||||
|
||||
const context = useMemo<UserSettings>(() => ({
|
||||
customCss,
|
||||
disableCustomCss,
|
||||
theme,
|
||||
dashboardTheme,
|
||||
dateTimeLocale,
|
||||
locale: language
|
||||
}), [ theme, dashboardTheme, dateTimeLocale, language ]);
|
||||
}), [
|
||||
customCss,
|
||||
disableCustomCss,
|
||||
theme,
|
||||
dashboardTheme,
|
||||
dateTimeLocale,
|
||||
language
|
||||
]);
|
||||
|
||||
// Update the values of the user settings
|
||||
const updateUserSettings = useCallback(() => {
|
||||
setCustomCss(userSettings.customCss());
|
||||
setDisableCustomCss(userSettings.disableCustomCss());
|
||||
setTheme(userSettings.theme());
|
||||
setDashboardTheme(userSettings.dashboardTheme());
|
||||
setDateTimeLocale(userSettings.dateTimeLocale());
|
||||
|
|
|
@ -17,7 +17,6 @@ import { loadCoreDictionary } from 'lib/globalize/loader';
|
|||
import { initialize as initializeAutoCast } from 'scripts/autocast';
|
||||
import browser from './scripts/browser';
|
||||
import keyboardNavigation from './scripts/keyboardNavigation';
|
||||
import { currentSettings } from './scripts/settings/userSettings';
|
||||
import { getPlugins } from './scripts/settings/webSettings';
|
||||
import taskButton from './scripts/taskbutton';
|
||||
import { pageClassOn, serverAddress } from './utils/dashboard';
|
||||
|
@ -116,9 +115,6 @@ build: ${__JF_BUILD_VERSION__}`);
|
|||
// Load platform specific features
|
||||
loadPlatformFeatures();
|
||||
|
||||
// Load custom CSS styles
|
||||
loadCustomCss();
|
||||
|
||||
// Enable navigation controls
|
||||
keyboardNavigation.enable();
|
||||
autoFocuser.enable();
|
||||
|
@ -191,54 +187,7 @@ function loadPlatformFeatures() {
|
|||
}
|
||||
}
|
||||
|
||||
function loadCustomCss() {
|
||||
// Apply custom CSS
|
||||
const apiClient = ServerConnections.currentApiClient();
|
||||
if (apiClient) {
|
||||
const brandingCss = fetch(apiClient.getUrl('Branding/Css'))
|
||||
.then(function(response) {
|
||||
if (!response.ok) {
|
||||
throw new Error(response.status + ' ' + response.statusText);
|
||||
}
|
||||
return response.text();
|
||||
})
|
||||
.catch(function(err) {
|
||||
console.warn('Error applying custom css', err);
|
||||
});
|
||||
|
||||
const handleStyleChange = async () => {
|
||||
let style = document.querySelector('#cssBranding');
|
||||
if (!style) {
|
||||
// Inject the branding css as a dom element in body so it will take
|
||||
// precedence over other stylesheets
|
||||
style = document.createElement('style');
|
||||
style.id = 'cssBranding';
|
||||
document.body.appendChild(style);
|
||||
}
|
||||
|
||||
const css = [];
|
||||
// Only add branding CSS when enabled
|
||||
if (!currentSettings.disableCustomCss()) css.push(await brandingCss);
|
||||
// Always add user CSS
|
||||
css.push(currentSettings.customCss());
|
||||
|
||||
style.textContent = css.join('\n');
|
||||
};
|
||||
|
||||
Events.on(ServerConnections, 'localusersignedin', handleStyleChange);
|
||||
Events.on(ServerConnections, 'localusersignedout', handleStyleChange);
|
||||
Events.on(currentSettings, 'change', (e, prop) => {
|
||||
if (prop == 'disableCustomCss' || prop == 'customCss') {
|
||||
handleStyleChange();
|
||||
}
|
||||
});
|
||||
|
||||
handleStyleChange();
|
||||
}
|
||||
}
|
||||
|
||||
function registerServiceWorker() {
|
||||
/* eslint-disable compat/compat */
|
||||
if (navigator.serviceWorker && window.appMode !== 'cordova' && window.appMode !== 'android') {
|
||||
navigator.serviceWorker.register('serviceworker.js').then(() =>
|
||||
console.log('serviceWorker registered')
|
||||
|
@ -248,7 +197,6 @@ function registerServiceWorker() {
|
|||
} else {
|
||||
console.warn('serviceWorker unsupported');
|
||||
}
|
||||
/* eslint-enable compat/compat */
|
||||
}
|
||||
|
||||
async function renderApp() {
|
||||
|
|
|
@ -79,7 +79,7 @@ export function updateCurrentCulture() {
|
|||
let culture;
|
||||
try {
|
||||
culture = userSettings.language();
|
||||
} catch (err) {
|
||||
} catch {
|
||||
console.error('no language set in user settings');
|
||||
}
|
||||
culture = culture || getDefaultLanguage();
|
||||
|
@ -92,7 +92,7 @@ export function updateCurrentCulture() {
|
|||
let dateTimeCulture;
|
||||
try {
|
||||
dateTimeCulture = userSettings.dateTimeLocale();
|
||||
} catch (err) {
|
||||
} catch {
|
||||
console.error('no date format set in user settings');
|
||||
}
|
||||
|
||||
|
|
|
@ -22,7 +22,7 @@
|
|||
// text/html parsing is natively supported
|
||||
return;
|
||||
}
|
||||
} catch (ex) { /* noop */ }
|
||||
} catch { /* noop */ }
|
||||
|
||||
DOMParserPrototype.parseFromString = function (markup, type) {
|
||||
if (/^\s*text\/html\s*(?:;|$)/i.test(type)) {
|
||||
|
|
|
@ -8,7 +8,7 @@
|
|||
|
||||
try {
|
||||
new window.KeyboardEvent('event', { bubbles: true, cancelable: true });
|
||||
} catch (e) {
|
||||
} catch {
|
||||
// We can't use `KeyboardEvent` in old WebKit because `initKeyboardEvent`
|
||||
// doesn't seem to populate some properties (`keyCode`, `which`) that
|
||||
// are read-only.
|
||||
|
|
|
@ -13,7 +13,7 @@
|
|||
if (window.Headers) {
|
||||
try {
|
||||
new window.Headers(undefined);
|
||||
} catch (_) {
|
||||
} catch {
|
||||
console.debug('patch \'Headers\' to accept \'undefined\'');
|
||||
|
||||
const _Headers = window.Headers;
|
||||
|
|
|
@ -1109,7 +1109,8 @@ class ChromecastPlayer {
|
|||
return this.getPlayerStateInternal()?.NowPlayingItem?.IndexNumber;
|
||||
}
|
||||
|
||||
clearQueue(currentTime) { // eslint-disable-line no-unused-vars
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
clearQueue(currentTime) {
|
||||
// not supported yet
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1512,7 +1512,7 @@ export class HtmlVideoPlayer {
|
|||
trackElement.removeCue(trackElement.cues[0]);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('error removing cue from textTrack');
|
||||
console.error('error removing cue from textTrack', e);
|
||||
}
|
||||
|
||||
trackElement.mode = 'disabled';
|
||||
|
|
|
@ -221,6 +221,7 @@ class PlaybackCore {
|
|||
// Account for player imperfections, we got half a second of tollerance we can play with
|
||||
// (the server tollerates a range of values when client reports that is ready).
|
||||
const rangeWidth = 100; // In milliseconds.
|
||||
// eslint-disable-next-line sonarjs/pseudo-random
|
||||
const randomOffsetTicks = Math.round((Math.random() - 0.5) * rangeWidth) * Helper.TicksPerMillisecond;
|
||||
this.scheduleSeek(command.When, command.PositionTicks + randomOffsetTicks);
|
||||
console.debug('SyncPlay applyCommand: adding random offset to force seek:', randomOffsetTicks, command);
|
||||
|
|
|
@ -158,7 +158,7 @@ class GenericPlayer {
|
|||
* Sets the playback rate, if supported.
|
||||
* @param {number} value The playback rate.
|
||||
*/
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
setPlaybackRate(value) {
|
||||
// Do nothing.
|
||||
}
|
||||
|
@ -197,7 +197,7 @@ class GenericPlayer {
|
|||
* Seeks the player to the specified position.
|
||||
* @param {number} positionTicks The new position.
|
||||
*/
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
localSeek(positionTicks) {
|
||||
// Override
|
||||
}
|
||||
|
@ -213,7 +213,7 @@ class GenericPlayer {
|
|||
* Sends a command to the player.
|
||||
* @param {Object} command The command.
|
||||
*/
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
localSendCommand(command) {
|
||||
// Override
|
||||
}
|
||||
|
@ -222,7 +222,7 @@ class GenericPlayer {
|
|||
* Starts playback.
|
||||
* @param {Object} options Playback data.
|
||||
*/
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
localPlay(options) {
|
||||
// Override
|
||||
}
|
||||
|
@ -231,7 +231,7 @@ class GenericPlayer {
|
|||
* Sets playing item from playlist.
|
||||
* @param {string} playlistItemId The item to play.
|
||||
*/
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
localSetCurrentPlaylistItem(playlistItemId) {
|
||||
// Override
|
||||
}
|
||||
|
@ -240,7 +240,7 @@ class GenericPlayer {
|
|||
* Removes items from playlist.
|
||||
* @param {Array} playlistItemIds The items to remove.
|
||||
*/
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
localRemoveFromPlaylist(playlistItemIds) {
|
||||
// Override
|
||||
}
|
||||
|
@ -250,7 +250,7 @@ class GenericPlayer {
|
|||
* @param {string} playlistItemId The item to move.
|
||||
* @param {number} newIndex The new position.
|
||||
*/
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
localMovePlaylistItem(playlistItemId, newIndex) {
|
||||
// Override
|
||||
}
|
||||
|
@ -259,7 +259,7 @@ class GenericPlayer {
|
|||
* Queues in the playlist.
|
||||
* @param {Object} options Queue data.
|
||||
*/
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
localQueue(options) {
|
||||
// Override
|
||||
}
|
||||
|
@ -268,7 +268,7 @@ class GenericPlayer {
|
|||
* Queues after the playing item in the playlist.
|
||||
* @param {Object} options Queue data.
|
||||
*/
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
localQueueNext(options) {
|
||||
// Override
|
||||
}
|
||||
|
@ -291,7 +291,7 @@ class GenericPlayer {
|
|||
* Sets repeat mode.
|
||||
* @param {string} value The repeat mode.
|
||||
*/
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
localSetRepeatMode(value) {
|
||||
// Override
|
||||
}
|
||||
|
@ -300,7 +300,7 @@ class GenericPlayer {
|
|||
* Sets shuffle mode.
|
||||
* @param {string} value The shuffle mode.
|
||||
*/
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
localSetQueueShuffleMode(value) {
|
||||
// Override
|
||||
}
|
||||
|
|
|
@ -913,6 +913,19 @@ export default function (options) {
|
|||
|
||||
profile.ContainerProfiles = [];
|
||||
|
||||
if (browser.tizen) {
|
||||
// Tizen doesn't support more than 32 streams in a single file
|
||||
profile.ContainerProfiles.push({
|
||||
Type: 'Video',
|
||||
Conditions: [{
|
||||
Condition: 'LessThanEqual',
|
||||
Property: 'NumStreams',
|
||||
Value: '32',
|
||||
IsRequired: false
|
||||
}]
|
||||
});
|
||||
}
|
||||
|
||||
profile.CodecProfiles = [];
|
||||
|
||||
const supportsSecondaryAudio = canPlaySecondaryAudio(videoTestElement);
|
||||
|
|
|
@ -211,7 +211,7 @@ export function getDisplayDateTime(date) {
|
|||
if (typeof date === 'string') {
|
||||
try {
|
||||
date = parseISO8601Date(date, true);
|
||||
} catch (err) {
|
||||
} catch {
|
||||
return date;
|
||||
}
|
||||
}
|
||||
|
@ -227,7 +227,7 @@ export function getDisplayTime(date) {
|
|||
if (typeof date === 'string') {
|
||||
try {
|
||||
date = parseISO8601Date(date, true);
|
||||
} catch (err) {
|
||||
} catch {
|
||||
return date;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -178,7 +178,7 @@ function resetThrottle(key) {
|
|||
const isElectron = navigator.userAgent.toLowerCase().indexOf('electron') !== -1;
|
||||
function allowInput() {
|
||||
// This would be nice but always seems to return true with electron
|
||||
if (!isElectron && document.hidden) { /* eslint-disable-line compat/compat */
|
||||
if (!isElectron && document.hidden) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
@ -356,7 +356,6 @@ function isGamepadConnected() {
|
|||
}
|
||||
|
||||
function onFocusOrGamepadAttach() {
|
||||
/* eslint-disable-next-line compat/compat */
|
||||
if (isGamepadConnected() && document.hasFocus()) {
|
||||
console.log('Gamepad connected! Starting input loop');
|
||||
startInputLoop();
|
||||
|
@ -364,7 +363,6 @@ function onFocusOrGamepadAttach() {
|
|||
}
|
||||
|
||||
function onFocusOrGamepadDetach() {
|
||||
/* eslint-disable-next-line compat/compat */
|
||||
if (!isGamepadConnected() || !document.hasFocus()) {
|
||||
console.log('Gamepad disconnected! No other gamepads are connected, stopping input loop');
|
||||
stopInputLoop();
|
||||
|
|
|
@ -64,7 +64,7 @@ let hasFieldKey = false;
|
|||
try {
|
||||
hasFieldKey = 'key' in new KeyboardEvent('keydown');
|
||||
} catch (e) {
|
||||
console.error("error checking 'key' field");
|
||||
console.error("error checking 'key' field", e);
|
||||
}
|
||||
|
||||
if (!hasFieldKey) {
|
||||
|
@ -239,7 +239,7 @@ function attachGamepadScript() {
|
|||
}
|
||||
|
||||
// No need to check for gamepads manually at load time, the eventhandler will be fired for that
|
||||
if (navigator.getGamepads && appSettings.enableGamepad()) { /* eslint-disable-line compat/compat */
|
||||
if (navigator.getGamepads && appSettings.enableGamepad()) {
|
||||
window.addEventListener('gamepadconnected', attachGamepadScript);
|
||||
}
|
||||
|
||||
|
|
|
@ -31,7 +31,7 @@ function getScreensaverPlugin(isLoggedIn) {
|
|||
let option;
|
||||
try {
|
||||
option = userSettings.get('screensaver', false);
|
||||
} catch (err) {
|
||||
} catch {
|
||||
option = isLoggedIn ? 'backdropscreensaver' : 'logoscreensaver';
|
||||
}
|
||||
|
||||
|
|
|
@ -1,16 +1,7 @@
|
|||
import { getDefaultTheme, getThemes as getConfiguredThemes } from './settings/webSettings';
|
||||
|
||||
let themeStyleElement = document.querySelector('#cssTheme');
|
||||
let currentThemeId;
|
||||
|
||||
function unloadTheme() {
|
||||
const elem = themeStyleElement;
|
||||
if (elem) {
|
||||
elem.removeAttribute('href');
|
||||
currentThemeId = null;
|
||||
}
|
||||
}
|
||||
|
||||
function getThemes() {
|
||||
return getConfiguredThemes();
|
||||
}
|
||||
|
@ -29,11 +20,7 @@ function getThemeStylesheetInfo(id) {
|
|||
theme = getDefaultTheme();
|
||||
}
|
||||
|
||||
return {
|
||||
stylesheetPath: 'themes/' + theme.id + '/theme.css',
|
||||
themeId: theme.id,
|
||||
color: theme.color
|
||||
};
|
||||
return theme;
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -45,36 +32,12 @@ function setTheme(id) {
|
|||
}
|
||||
|
||||
getThemeStylesheetInfo(id).then(function (info) {
|
||||
if (currentThemeId && currentThemeId === info.themeId) {
|
||||
if (currentThemeId && currentThemeId === info.id) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
const linkUrl = info.stylesheetPath;
|
||||
unloadTheme();
|
||||
|
||||
let link = themeStyleElement;
|
||||
|
||||
if (!link) {
|
||||
// Inject the theme css as a dom element in body so it will take
|
||||
// precedence over other stylesheets
|
||||
link = document.createElement('link');
|
||||
link.id = 'cssTheme';
|
||||
link.setAttribute('rel', 'stylesheet');
|
||||
link.setAttribute('type', 'text/css');
|
||||
document.body.appendChild(link);
|
||||
}
|
||||
|
||||
const onLoad = function (e) {
|
||||
e.target.removeEventListener('load', onLoad);
|
||||
resolve();
|
||||
};
|
||||
|
||||
link.addEventListener('load', onLoad);
|
||||
|
||||
link.setAttribute('href', linkUrl);
|
||||
themeStyleElement = link;
|
||||
currentThemeId = info.themeId;
|
||||
currentThemeId = info.id;
|
||||
|
||||
document.getElementById('themeColor').content = info.color;
|
||||
});
|
||||
|
@ -82,6 +45,6 @@ function setTheme(id) {
|
|||
}
|
||||
|
||||
export default {
|
||||
getThemes: getThemes,
|
||||
setTheme: setTheme
|
||||
getThemes,
|
||||
setTheme
|
||||
};
|
||||
|
|
|
@ -729,7 +729,7 @@
|
|||
"XmlTvSportsCategoriesHelp": "البرامج من هذه التصنيفات ستعرض كبرامج رياضية. إفصل الإدخالات المتعددة برمز \"|\".",
|
||||
"Yesterday": "البارحة",
|
||||
"ConfirmDeleteImage": "حذف الصورة؟",
|
||||
"ConfigureDateAdded": "قم بإعداد كيفية تحديد البيانات الوصفية ل \"تاريخ الإضافة\" في لوحة المعلومات> المكتبات> إعدادات NFO",
|
||||
"ConfigureDateAdded": "قم بإعداد كيفية تحديد البيانات الوصفية ل \"تاريخ الإضافة\" في لوحة المعلومات> المكتبات> العرض",
|
||||
"Composer": "ألحان",
|
||||
"CommunityRating": "تقييم الجمهور",
|
||||
"ColorTransfer": "نقل اللون",
|
||||
|
@ -886,7 +886,7 @@
|
|||
"ButtonTogglePlaylist": "قائمة التشغيل",
|
||||
"BoxSet": "طقم",
|
||||
"ButtonSplit": "تقسيم",
|
||||
"AllowFfmpegThrottlingHelp": "عند تفعيلها؛ فسوف تتوقف عملية الترميز transcoding توقفا مؤقتا كلما تقدمت العملية عن موضع التشغيل بنسبة كافية، تهدف هذه الخاصية إلى التقليل من استهلاك الطاقة، وتكون ذات منفعة كبيرة عندما تتم عملية المشاهدة بانتظام دون القفز عدة دقائق في المشاهدة ما بين الحينة والأخرى. كما ينطبق الأمر ذاته على عملية نسخ الملف إلى حاوية أخرى لتتوافق مع الجهاز remuxing.",
|
||||
"AllowFfmpegThrottlingHelp": "عند تقدم اي تحويل كود او remux لمسافة مناسبة امام نقطة اعادة التشغيل الحالية، اوقف العملية حتى يتم استهلاك موارد أقل. هذا سوف يكون مفيد عندما يتم المشاهدة بدون التقدم بشكل مستمر. قم بإطفاء الخاصية هذه عندما تواجه مشاكل في إعادة التشغيل.",
|
||||
"InstallingPackage": "تثبيت {0} (الإصدار {1})",
|
||||
"Images": "الصور",
|
||||
"Identify": "التعرف على الوسائط",
|
||||
|
@ -1052,7 +1052,7 @@
|
|||
"DashboardArchitecture": "المعمارية: {0}",
|
||||
"DailyAt": "يومياً على {0}",
|
||||
"ClearQueue": "مسح القائمة المؤقتة",
|
||||
"Bwdif": "BWDIF",
|
||||
"Bwdif": "فلتر بوب ويفر لإزالة التداخل (BWDIF)",
|
||||
"ButtonPlayer": "المشغل",
|
||||
"ButtonCast": "إرسال وسائط إلى جهاز",
|
||||
"HeaderSyncPlayTimeSyncSettings": "تزامن الوقت",
|
||||
|
@ -1722,7 +1722,7 @@
|
|||
"LabelEnableAudioVbrHelp": "معدل البِت المتغير ينتج على جودة أفضل مقارنة بمعدل البت المتوسط، ولكن في بعض الحالات النادرة قد يسبب مشاكل في التخزين المؤقت والتوافق.",
|
||||
"LabelSegmentKeepSecondsHelp": "الزمن بالثواني الذي يجب الاحتفاظ به للشرائح بعد أن يتم تحميلها من قبل العميل. يعمل هذا ألأعداد فقط إذا كان حذف الشرائح مفعلًا.",
|
||||
"AiTranslated": "مترجمة من قبل ذكاء اسطناعي",
|
||||
"SelectAudioNormalizationHelp": "كسب الالبوم-تعديل الصوت لكل مسار لكي يعملون بنفس مستوى- كسب الالبوم- تعديل مستوى الصوت لكل المسارات في البوم واحد مع ابقاء على النطاق الديناميكي للألبوم.",
|
||||
"SelectAudioNormalizationHelp": "كسب الالبوم-تعديل الصوت لكل مسار لكي يعملون بنفس المستوى- كسب الالبوم- تعديل مستوى الصوت لكل المسارات في البوم واحد مع ابقاء على النطاق الديناميكي للألبوم. التحويل بين (إيقاف) والخيارات الاخرى يتطلب إعادة تشغيل playback الحالي.",
|
||||
"ButtonEditUser": "تعديل مستخدم",
|
||||
"AllowSubtitleManagement": "اسمح لهذا المستخدم تعديل الترجمات",
|
||||
"HeaderDeleteSeries": "حذف مسلسل",
|
||||
|
@ -1826,5 +1826,28 @@
|
|||
"LabelDisableVbrAudioEncoding": "تعطيل VBR لترميز الصوت",
|
||||
"HeaderNextVideo": "الفيديو التالي",
|
||||
"LabelDevice": "الجهاز",
|
||||
"LabelEnablePlugin": "تفعيل البرنامج الإضافي"
|
||||
"LabelEnablePlugin": "تفعيل البرنامج الإضافي",
|
||||
"CopyLogSuccess": "محتويات السجل نُسخت بنجاح.",
|
||||
"Illustrator": "الرسام",
|
||||
"Creator": "المنشئ",
|
||||
"HeaderUploadLyrics": "رفع كلمات المحتوى",
|
||||
"DeleteServerConfirmation": "هل أنت متاكد انك تريد حذف الخادم؟",
|
||||
"HeaderVideoAdvanced": "فيديو متقدم",
|
||||
"HeaderNewPlaylist": "قائمة تشغيل جديدة",
|
||||
"HeaderNoLyrics": "لم يتم ايجاد كلمات محتوى",
|
||||
"LabelSelectPreferredTranscodeVideoAudioCodec": "برنامج ترميز الصوت المفضل في تشغيل الفيديو",
|
||||
"LabelBackdropScreensaverInterval": "فاصل شاشة التوقف الخلفية",
|
||||
"LabelAllowFmp4TranscodingContainer": "السماح بحاوية تحويل الترميز بـfMP4",
|
||||
"LabelAudioTagSettings": "إعدادات العلامات الصوتية",
|
||||
"LabelCustomTagDelimiters": "فاصل العلامة المخصصة",
|
||||
"LabelAlbumGain": "كسب الألبوم",
|
||||
"Inker": "الحبر",
|
||||
"LabelAllowContentWithTags": "السماح بالعناصر مع العلامات",
|
||||
"DisplayLoadError": "خطأ حصل اثناء تحميل بيانات إعدادات العرض.",
|
||||
"HeaderPreviewLyrics": "استعراض كلمات المحتوى",
|
||||
"LabelAlwaysRemuxMp3AudioFiles": "السماح دائماً بعمل remux لملفات MP3 الصوتية",
|
||||
"LabelAlwaysRemuxFlacAudioFiles": "السماح دائماً بعمل remux لملفات FLAC الصوتية",
|
||||
"LabelAllowStreamSharing": "السماح بمشاركة البث",
|
||||
"HeaderLyricDownloads": "تحميلات كلمات المحتوى",
|
||||
"HeaderMediaSegmentActions": "إجراءات قطاع الوسائط"
|
||||
}
|
||||
|
|
|
@ -1955,5 +1955,9 @@
|
|||
"LabelMediaSegmentProviders": "Пастаўшчыкі сегментаў медыяфайлаў",
|
||||
"MediaSegmentProvidersHelp": "Уключыце і расстаўце вашых пераважных пастаўшчыкоў сегментаў медыяфайлаў у парадку прыярытэту.",
|
||||
"MediaSegmentType.Commercial": "Рэклама",
|
||||
"MediaSegmentAction.None": "Няма"
|
||||
"MediaSegmentAction.None": "Няма",
|
||||
"HeaderNextVideo": "Наступнае відэа",
|
||||
"HeaderPageNotFound": "Старонка не знойдзена",
|
||||
"LabelSaveTrickplayLocally": "Захаваць выявы trickplay побач з медыяфайламі",
|
||||
"LabelSaveTrickplayLocallyHelp": "Захаванне выяў trickplay у тэчках з медыяфайламі дазволіць размясціць іх побач з вашымі медыяфайламі для зручнай міграцыі і доступу."
|
||||
}
|
||||
|
|
|
@ -2012,5 +2012,7 @@
|
|||
"MetadataNfoLoadError": "Načtení nastavení metadat v souborech NFO se nezdařilo",
|
||||
"HeaderPageNotFound": "Stránka nebyla nalezena",
|
||||
"PageNotFound": "Toto není stránka, kterou hledáš.",
|
||||
"SettingsPageLoadError": "Načtení stránky nastavení se nezdařilo"
|
||||
"SettingsPageLoadError": "Načtení stránky nastavení se nezdařilo",
|
||||
"RetryWithGlobalSearch": "Zkusit hledat globálně",
|
||||
"StreamCountExceedsLimit": "Počet streamů překračuje limit"
|
||||
}
|
||||
|
|
|
@ -1993,7 +1993,7 @@
|
|||
"LabelMediaSegmentProviders": "Mediesegment tilbydere",
|
||||
"MediaSegmentProvidersHelp": "Aktiver og arranger dine foretrukne mediesegment-tilbydere efter prioritering.",
|
||||
"DeleteServerConfirmation": "Er du sikker på at du ønsker slette denne server?",
|
||||
"AutoSubtitleStylingHelp": "Denne tilstand vil automatisk skifte mellem oprindelig og brugerdefineret stylings-mekanismer baseret på din enheds type.",
|
||||
"AutoSubtitleStylingHelp": "Denne tilstand vil automatisk skifte mellem oprindelig og brugerdefineret undertekst stylings-mekanismer baseret på din enheds type.",
|
||||
"Custom": "Brugerdefineret",
|
||||
"CustomSubtitleStylingHelp": "Undertekse styling vil virke på de fleste enheder, men kommer med en præstations pris.",
|
||||
"LabelSubtitleStyling": "Undertekst Styling",
|
||||
|
|
|
@ -1885,7 +1885,7 @@
|
|||
"Lyric": "Στίχος",
|
||||
"LogoScreensaver": "Λογότυπο Προφύλαξης Οθόνης",
|
||||
"MediaSegmentProvidersHelp": "Ενεργοποίηση και ταξινόμηση των προτιμωμένων παροχέων τμημάτων πολυμέσων σε σειρά προτεραιότητας.",
|
||||
"LibraryInvalidItemIdError": "H βιβλιοθήκη βρίσκεται σε μη έγκυρη κατάσταση και δεν μπορεί να τροποποιηθεί. Πιθανότατα αντιμετοπίζετε σφάλμα: η διαδρομή στην βάση δεδομένη δεν είναι η σωστή στον δίσκο.",
|
||||
"LibraryInvalidItemIdError": "H βιβλιοθήκη βρίσκεται σε μη έγκυρη κατάσταση και δεν μπορεί να τροποποιηθεί. Πιθανότατα αντιμετωπίζετε σφάλμα: η διαδρομή στην βάση δεδομένη δεν είναι η σωστή στο σύστημα αρχείων.",
|
||||
"Lyrics": "Στίχοι",
|
||||
"MediaInfoRotation": "Προσανατολισμός",
|
||||
"LimitSupportedVideoResolution": "Περιορισμός μέγιστης υποστηριζόμενης ανάλυσης βίντεο",
|
||||
|
@ -1963,5 +1963,6 @@
|
|||
"PlaybackError.MEDIA_NOT_SUPPORTED": "Η αναπαραγωγή απέτυχε διότι το μέσο δεν υποστηρίζεται από αυτό το πρόγραμμα.",
|
||||
"PlaybackError.MEDIA_DECODE_ERROR": "Η αναπαραγωγή απέτυχε λόγω σφάλματος στην αποκωδικοποίηση του μέσου.",
|
||||
"MetadataImagesLoadError": "Αποτυχία φόρτωσης ρυθμίσεων των μεταδεδομένων",
|
||||
"PlaybackError.FATAL_HLS_ERROR": "Παρουσιάστηκε κρίσιμο σφάλμα στην ροή HLS."
|
||||
"PlaybackError.FATAL_HLS_ERROR": "Παρουσιάστηκε κρίσιμο σφάλμα στην ροή HLS.",
|
||||
"SettingsPageLoadError": "Αποτυχία φόρτωσης σελίδας ρυθμίσεων"
|
||||
}
|
||||
|
|
|
@ -1719,7 +1719,7 @@
|
|||
"Short": "Short",
|
||||
"HeaderPerformance": "Performance",
|
||||
"LabelParallelImageEncodingLimit": "Parallel image encoding limit",
|
||||
"LabelParallelImageEncodingLimitHelp": "Maximum number of image encodings that are allowed to run in parallel. Setting this to 0 will choose a limit based on your systems core count.",
|
||||
"LabelParallelImageEncodingLimitHelp": "Maximum number of image encodings that are allowed to run in parallel. Leaving this empty will choose a limit based on your systems core count.",
|
||||
"LabelEnableAudioVbr": "Enable VBR audio encoding",
|
||||
"LabelEnableAudioVbrHelp": "Variable bitrate offers better quality to average bitrate ratio, but in some rare cases may cause buffering and compatibility issues.",
|
||||
"LabelTonemappingMode": "Tone mapping mode",
|
||||
|
@ -1880,7 +1880,7 @@
|
|||
"LabelSelectPreferredTranscodeVideoAudioCodec": "Preferred transcode audio codec in video playback",
|
||||
"Letterer": "Letterer",
|
||||
"LibraryScanFanoutConcurrency": "Parallel library scan tasks limit",
|
||||
"LibraryScanFanoutConcurrencyHelp": "Maximum number of parallel tasks during library scans. Setting this to 0 will choose a limit based on your systems core count. WARNING: Setting this number too high may cause issues with network file systems; if you encounter problems lower this number.",
|
||||
"LibraryScanFanoutConcurrencyHelp": "Maximum number of parallel tasks during library scans. Leaving this empty will choose a limit based on your system's core count. WARNING: Setting this number too high may cause issues with network file systems; if you encounter problems lower this number.",
|
||||
"Penciller": "Penciler",
|
||||
"PlaylistError.AddFailed": "Error adding to playlist",
|
||||
"PlaylistError.CreateFailed": "Error creating playlist",
|
||||
|
@ -2011,5 +2011,7 @@
|
|||
"DisplayLoadError": "An error occurred while loading display configuration data.",
|
||||
"HeaderPageNotFound": "Page not found",
|
||||
"PageNotFound": "This is not the page you are looking for.",
|
||||
"MetadataImagesLoadError": "Failed to load metadata settings"
|
||||
"MetadataImagesLoadError": "Failed to load metadata settings",
|
||||
"SettingsPageLoadError": "Failed to load settings page",
|
||||
"RetryWithGlobalSearch": "Retry with a global search"
|
||||
}
|
||||
|
|
|
@ -1451,6 +1451,7 @@
|
|||
"ReplaceExistingImages": "Replace existing images",
|
||||
"ReplaceTrickplayImages": "Replace existing trickplay images",
|
||||
"Retry": "Retry",
|
||||
"RetryWithGlobalSearch": "Retry with a global search",
|
||||
"Reset": "Reset",
|
||||
"ResetPassword": "Reset Password",
|
||||
"ResolutionMatchSource": "Match Source",
|
||||
|
@ -1530,6 +1531,7 @@
|
|||
"StoryArc": "Story Arc",
|
||||
"StopPlayback": "Stop playback",
|
||||
"StopRecording": "Stop recording",
|
||||
"StreamCountExceedsLimit": "The number of streams exceeds the limit",
|
||||
"Studio": "Studio",
|
||||
"Studios": "Studios",
|
||||
"Subtitle": "Subtitle",
|
||||
|
|
|
@ -1664,8 +1664,8 @@
|
|||
"MediaInfoVideoRangeType": "Videon aluetyyppi",
|
||||
"LabelVideoRangeType": "Videon aluetyyppi",
|
||||
"VideoRangeTypeNotSupported": "Videon aluetyyppiä ei tueta",
|
||||
"LabelVppTonemappingContrastHelp": "Käytä kontrastin vahvistusta VPP-sävykartoituksen kanssa. Suositus- ja oletusarvo on 1.",
|
||||
"LabelVppTonemappingBrightnessHelp": "Käytä kirkkauden vahvistusta VPP-sävykartoituksen kanssa. Suositus- ja oletusarvot ovat 10 ja 0.",
|
||||
"LabelVppTonemappingContrastHelp": "Käytä kontrastin vahvistusta VPP-sävykartoituksen kanssa. Suositusarvo on 1.",
|
||||
"LabelVppTonemappingBrightnessHelp": "Käytä kirkkauden vahvistusta VPP-sävykartoituksen kanssa. Suositusarvo on 16.",
|
||||
"LabelVppTonemappingContrast": "VPP-sävykartoituksen kontrastin vahvistus",
|
||||
"LabelVppTonemappingBrightness": "VPP-sävykartoituksen kirkkauden vahvistus",
|
||||
"IgnoreDtsHelp": "Valinnan poistaminen voi korjata joitakin ongelmia, kuten puuttuvan äänen kanavilla joilla on erilliset ääni- ja videovirrat.",
|
||||
|
@ -1754,7 +1754,7 @@
|
|||
"LogLevel.Critical": "Kriittinen",
|
||||
"LogLevel.None": "Ei mitään",
|
||||
"HeaderEpisodesStatus": "Jaksojen tila",
|
||||
"AllowSegmentDeletionHelp": "Poista vanhat osiot kun ne on lähetetty päätteelle. Tämän ansiosta transkoodattua tiedostoa ei tarvitse säilyttää kokonaan. Toimii vain rajoituksen ollessa käytössä. Poista käytöstä, jos kohtaat toisto-ongelmia.",
|
||||
"AllowSegmentDeletionHelp": "Poista vanhat osiot kun ne on ladattu päätteelle. Tämän ansiosta transkoodattua tiedostoa ei tarvitse säilyttää kokonaan. Poista käytöstä, jos kohtaat toisto-ongelmia.",
|
||||
"AllowSegmentDeletion": "Poista osiot",
|
||||
"LabelThrottleDelaySeconds": "Rajoita kun on kulunut",
|
||||
"LabelThrottleDelaySecondsHelp": "Aika sekunneissa, jonka jälkeen transkoodausta rajoitetaan. Tämän on oltava riittävän suuri, jotta päätelaite kykenee ylläpitämään reilua puskuria. Toimii vain rajoituksen ollessa käytössä.",
|
||||
|
@ -1920,11 +1920,11 @@
|
|||
"AllowTonemappingSoftwareHelp": "Sävykartoitus voi muuttaa videon dynaamista aluetta HDR:stä SDR:ään säilyttäen silti kuvan yksityiskohdat ja värin, jotka ovat erittäin tärkeitä alkuperäisen kohtauksen tiedon säilyttämiseksi. Tällä hetkellä se toimii ainoastaan 10bit HDR10, -HLG, ja DoVi-videoiden kanssa.",
|
||||
"Editor": "Ohjaus",
|
||||
"Letterer": "Kirjoittaja",
|
||||
"LibraryScanFanoutConcurrencyHelp": "Samanaikaisten suoritettavien kirjastoskannausten maksimimäärä. Mikäli tämä arvo on asetettu 0, määrä valitaan järjestelmän prosessorin säikeiden lukumäärän mukaan. VAROITUS: Tämän arvon asettaminen liian korkeaksi voi aiheuttaa ongelmia verkkotiedostojärjestelmissä. Jos koet ongelmatilanteista, laske tätä numeroa.",
|
||||
"LibraryScanFanoutConcurrencyHelp": "Samanaikaisten suoritettavien kirjastoskannausten maksimimäärä. Mikäli tämä arvo on jätetty tyhjäksi, määrä valitaan järjestelmän prosessorin säikeiden lukumäärän mukaan. VAROITUS: Tämän arvon asettaminen liian korkeaksi voi aiheuttaa ongelmia verkkotiedostojärjestelmissä. Jos koet ongelmatilanteista, laske tätä numeroa.",
|
||||
"SaveLyricsIntoMediaFoldersHelp": "Sanoitusten tallentaminen äänitiedoston kanssa samaan sijaintiin helpottaa niiden hallintaa.",
|
||||
"SelectPreferredTranscodeVideoAudioCodecHelp": "Valitse ensisijainen äänikoodekki videomateriaalin transkoodaamiseen. Jos ensisijainen koodekki ei ole tuettu, serveri käyttää seuraavaksi parasta koodekkia.",
|
||||
"LabelTrickplayAccelEncoding": "Käyttöönota rautakiihdytetty MJPEG enkoodaus",
|
||||
"LabelTrickplayAccelEncodingHelp": "Tällä hetkellä ainoastaan käytettävissä QSV, VAAPI ja VideoToolbox, tällä valinnalla ei ole vaikutusta muihin rautakiihdytysmetodeihin.",
|
||||
"LabelTrickplayAccelEncodingHelp": "Tällä hetkellä ainoastaan käytettävissä QSV, VA-API, VideoToolbox ja RKMPP. Tällä valinnalla ei ole vaikutusta muihin rautakiihdytysmetodeihin.",
|
||||
"HeaderVideoAdvanced": "Edistynyt video",
|
||||
"PlaylistPublicDescription": "Salli tämän soittolistan katsominen jokaiselle kirjautuneelle käyttäjälle.",
|
||||
"DateModified": "Muokkauspäivämäärä",
|
||||
|
@ -1985,5 +1985,12 @@
|
|||
"Retry": "Yritä uudelleen",
|
||||
"Reset": "Nollaa",
|
||||
"ReplaceTrickplayImages": "Korvaa nykyiset trickplay kuvat",
|
||||
"RenderPgsSubtitleHelp": "Renderöidäänkö PGS tekstitykset laitteen toimesta. Tällä voidaan välttää raskas tekstitysten poltto kiinteästi kuvaan palvelimen toimesta, mutta lisätään laitteen renderöintikuormaa."
|
||||
"RenderPgsSubtitleHelp": "Renderöidäänkö PGS tekstitykset laitteen toimesta. Tällä voidaan välttää raskas tekstitysten poltto kiinteästi kuvaan palvelimen toimesta, mutta lisätään laitteen renderöintikuormaa.",
|
||||
"HeaderPageNotFound": "Sivua ei löytynyt",
|
||||
"PageNotFound": "Tämä ei ole etsimäsi sivu.",
|
||||
"CopyLogSuccess": "Lokitietojen kopiointi onnistui.",
|
||||
"DeleteServerConfirmation": "Haluatko varmasti poistaa tämän palvelimen?",
|
||||
"LabelDevice": "Laite",
|
||||
"MetadataImagesLoadError": "Metadata-asetusten lataus epäonnistui",
|
||||
"LibraryNameInvalid": "Kirjastolla tulee olla nimi."
|
||||
}
|
||||
|
|
|
@ -215,7 +215,7 @@
|
|||
"LabelVideo": "Vidéo",
|
||||
"DashboardArchitecture": "Architecture : {0}",
|
||||
"DashboardOperatingSystem": "Système d'exploitation : {0}",
|
||||
"ConfigureDateAdded": "Définissez la façon dont la métadonnée \"Date d'ajout\" est déterminée dans le Tableau de bord > Bibliothèques > Paramètres NFO",
|
||||
"ConfigureDateAdded": "Définissez la façon dont la métadonnée \"Date d'ajout\" est déterminée dans le Tableau de bord > Bibliothèques > Affichage",
|
||||
"Composer": "Compositeur(trice)",
|
||||
"CommunityRating": "Évaluation de la communauté",
|
||||
"ColorTransfer": "Transfert de couleur",
|
||||
|
@ -1183,7 +1183,7 @@
|
|||
"LabelNumberOfGuideDays": "Nombre de jours de guide à télécharger",
|
||||
"LabelOpenclDeviceHelp": "Le périphérique OpenCL qui sera utilisé pour le « tone mapping » HDR. Le chiffre a gauche du point est le numéro de plateforme, et celui de droite est le numéro de périphérique sur la plateforme. La valeur par défaut est 0.0. Le fichier d’application FFmpeg prenant en charge l’accélération OpenCL est requis.",
|
||||
"LabelParentNumber": "Numéro parent",
|
||||
"LabelParallelImageEncodingLimitHelp": "Nombre maximal d’encodages d’images qui peuvent être exécutés en parallèle. Une valeur de 0 entraînera une sélection automatique d’une limite selon le nombre de coeurs de votre système.",
|
||||
"LabelParallelImageEncodingLimitHelp": "Nombre maximal d’encodages d’images qui peuvent être exécutés en parallèle. Une valeur vide entraînera une sélection automatique d’une limite selon le nombre de coeurs de votre système.",
|
||||
"LabelPasswordResetProvider": "Fournisseur de récupération de mot de passe",
|
||||
"LabelPlayDefaultAudioTrack": "Lire la piste audio par défaut peu importe la langue",
|
||||
"LabelPostProcessor": "Application de post-traitement",
|
||||
|
@ -1353,7 +1353,7 @@
|
|||
"Letterer": "Lettreur",
|
||||
"Next": "Suivant",
|
||||
"LibraryScanFanoutConcurrency": "Limite de tâches de scan de médiathèque en parallèle",
|
||||
"LibraryScanFanoutConcurrencyHelp": "Nombre maximal de tâches en parallèle pour les scans de médiathèque. Une valeur de 0 laissera le système choisir une limite en fonction du nombre de coeurs. ATTENTION: Définir une valeur trop élevée peut causer des problèmes avec les systèmes de fichers réseau. Si vous avez des problèmes, réduisez cette valeur.",
|
||||
"LibraryScanFanoutConcurrencyHelp": "Nombre maximal de tâches en parallèle pour les scans de médiathèque. Une valeur vide laissera le système choisir une limite en fonction du nombre de coeurs. ATTENTION: Définir une valeur trop élevée peut causer des problèmes avec les systèmes de fichers réseau. Si vous avez des problèmes, réduisez cette valeur.",
|
||||
"OptionEveryday": "Tous les jours",
|
||||
"OptionForceRemoteSourceTranscoding": "Forcer le transcodage pour les sources de média externes comme la télé en direct",
|
||||
"OptionHasThemeVideo": "Générique",
|
||||
|
@ -1943,5 +1943,20 @@
|
|||
"LabelProcessPriorityHelp": "Un réglage inférieur ou supérieur déterminera la manière dont le processeur donne la priorité au processus de génération de trickplay ffmpeg par rapport aux autres processus. Si vous remarquez un ralentissement lors de la génération d'images trickplay mais que vous ne souhaitez pas arrêter complètement leur génération, essayez de réduire ce ralentissement en modifiant le nombre de threads.",
|
||||
"LabelTileWidthHelp": "Nombre maximum d'images par tuile dans la direction X.",
|
||||
"LabelTileHeight": "Hauteur des tuiles",
|
||||
"LabelTileHeightHelp": "Nombre maximum d'images par tuile dans la direction Y."
|
||||
"LabelTileHeightHelp": "Nombre maximum d'images par tuile dans la direction Y.",
|
||||
"SettingsPageLoadError": "Échec du chargement de la page des paramètres",
|
||||
"RetryWithGlobalSearch": "Réessayez avec une recherche globale",
|
||||
"HeaderPageNotFound": "Page introuvable",
|
||||
"PageNotFound": "Ceci n'est pas la page que vous cherchez.",
|
||||
"MetadataNfoLoadError": "Échec du chargement des paramètres NFO des métadonnées",
|
||||
"CopyLogSuccess": "Le contenu des journaux a été copié avec succès.",
|
||||
"Retry": "Réessayer",
|
||||
"DisplayLoadError": "Une erreur est survenue lors du chargement des données de configuration d'affichage.",
|
||||
"LogLoadFailure": "Échec du chargement du fichier journal. Il est possible qu'il soit encore en cours d'écriture.",
|
||||
"DeleteServerConfirmation": "Êtes-vous sûr de vouloir supprimer ce serveur ?",
|
||||
"LastActive": "Dernière activation",
|
||||
"LabelDevice": "Appareil",
|
||||
"MetadataImagesLoadError": "Échec du chargement des paramètres de métadonnées",
|
||||
"LibraryNameInvalid": "Le nom de la bibliothèque ne peut pas être vide.",
|
||||
"StreamCountExceedsLimit": "Le nombre de flux dépasse la limite"
|
||||
}
|
||||
|
|
|
@ -2012,5 +2012,7 @@
|
|||
"LibraryNameInvalid": "Le nom de la bibliothèque ne peut pas être vide.",
|
||||
"HeaderPageNotFound": "Page introuvable",
|
||||
"PageNotFound": "Ceci n'est pas la page que vous cherchez.",
|
||||
"SettingsPageLoadError": "Échec du chargement de la page des paramètres"
|
||||
"SettingsPageLoadError": "Échec du chargement de la page des paramètres",
|
||||
"RetryWithGlobalSearch": "Réessayez avec une recherche globale",
|
||||
"StreamCountExceedsLimit": "Le nombre de flux dépasse la limite"
|
||||
}
|
||||
|
|
|
@ -1133,7 +1133,7 @@
|
|||
"ClearQueue": "Očisti red",
|
||||
"Bwdif": "BWDIF",
|
||||
"ButtonPlayer": "Reproduktor",
|
||||
"AllowTonemappingHelp": "Tonsko preslikavanje može transformirati dinamički raspon videozapisa iz HDR u SDR zadržavajući detalje slike i boje, što su vrlo važne informacije za predstavljanje izvorne scene. Trenutačno radi samo pri transkodiranju videozapisa s ugrađenim HDR10 ili HLG metapodacima. Ako reprodukcija nije glatka ili ne uspije, razmislite o isključivanju odgovarajućeg hardverskog dekodera HDR10 ili HLG videozapisa. Ovo zahtijeva odgovarajuće OpenCL ili CUDA runtime.",
|
||||
"AllowTonemappingHelp": "Tonsko preslikavanje može transformirati dinamički raspon videozapisa iz HDR u SDR zadržavajući detalje slike i boje, što su vrlo važne informacije za predstavljanje izvorne scene. Trenutačno radi samo pri prekodiranju videozapisa s ugrađenim HDR10 ili HLG metapodatcima. Ako reprodukcija nije glatka ili ne uspije, razmislite o isključivanju odgovarajućeg hardverskog dekodera HDR10 ili HLG videozapisa. Ovo zahtijeva odgovarajući GPGPU runtime.",
|
||||
"LabelCreateHttpPortMap": "Omogući automatsko mapiranje ulaza za HTTP i HTTPS promet.",
|
||||
"LabelChromecastVersion": "Google Cast verzija",
|
||||
"LabelCertificatePasswordHelp": "Ako Vaš certifikat zahtjeva lozinku, molimo unesite je ovdje.",
|
||||
|
@ -1570,7 +1570,7 @@
|
|||
"DisplayLoadError": "Dogodila se pogreška tijekom prikazivanja podataka za konfiguraciju.",
|
||||
"EnableLibrary": "Uključite biblioteku",
|
||||
"EnableLibraryHelp": "Isključivanje bibliotekeće ju sakriti od svih korisnika.",
|
||||
"AlwaysBurnInSubtitleWhenTranscoding": "Uvijek ureži titlove tijekom transkodiranja",
|
||||
"AlwaysBurnInSubtitleWhenTranscoding": "Uvijek ureži titlove tijekom prekodiranja",
|
||||
"AlwaysRemuxFlacAudioFilesHelp": "Ako imate datoteke koje Vaš preglednik ne želi izvoditi ili kada neprecizno izračuna vremenske oznake, uključite ovo kao zaobilazak.",
|
||||
"AlwaysRemuxMp3AudioFilesHelp": "Ako imate datoteke za koje Vaš preglednik neprecizno izračunava vremenske oznake, uključite ovo kao zaobilazak.",
|
||||
"EditLyrics": "Uredi tekst pjesme",
|
||||
|
|
|
@ -1976,5 +1976,21 @@
|
|||
"LabelTrickplayKeyFrameOnlyExtractionHelp": "Csak kulcsképkockák kinyerése a jelentősen gyorsabb számítás érdekében, de kevésbé pontos időzítéssel. Ha a beállított hardveres dekódoló nem támogatja ezt a módot, akkor a szoftveres dekódoló lesz használva.",
|
||||
"DeleteServerConfirmation": "Biztos, hogy törli ezt a kiszolgálót?",
|
||||
"VideoCodecTagNotSupported": "A videókodek-címke nem támogatott",
|
||||
"LabelTrickplayKeyFrameOnlyExtraction": "Képek előállítása csak kulcsképkockákból"
|
||||
"LabelTrickplayKeyFrameOnlyExtraction": "Képek előállítása csak kulcsképkockákból",
|
||||
"HeaderPageNotFound": "Az oldal nem található",
|
||||
"LabelMediaSegmentProviders": "Médiaszegmens szolgáltatók",
|
||||
"MediaSegmentProvidersHelp": "Engedélyezd és tedd sorba a médiaszegmens szolgáltatókat preferencia (prioritás) alapján.",
|
||||
"MetadataNfoLoadError": "Nem sikerült a metaadat NFO beállításokat betölteni",
|
||||
"PageNotFound": "Ez nem az oldal, amit keresel.",
|
||||
"SettingsPageLoadError": "Nem sikerült betölteni a beállítások oldalt",
|
||||
"CustomSubtitleStylingHelp": "A feliratstílus működni fog a legtöbb eszközön, de további teljesítményt igényel.",
|
||||
"LabelSubtitleStyling": "Feliratstílus",
|
||||
"CopyLogSuccess": "A napló tartalma sikeresen másolva lett.",
|
||||
"DisplayLoadError": "Egy hibába ütköztünk a kijelző beállításainak betöltése közben.",
|
||||
"LabelDevice": "Eszköz",
|
||||
"LastActive": "Legutóbb aktív",
|
||||
"PreferNonstandardArtistsTagHelp": "Használja a nem szabványos ARTISTS címkét az ARTIST címke helyett, ha elérhető.",
|
||||
"Penciller": "Grafikus",
|
||||
"MetadataImagesLoadError": "Nem sikerült betölteni a metaadat beállításokat",
|
||||
"LibraryNameInvalid": "A könyvtár neve nem lehet üres."
|
||||
}
|
||||
|
|
|
@ -2012,5 +2012,6 @@
|
|||
"MetadataNfoLoadError": "Errore nel caricamento dei metadati NFO",
|
||||
"HeaderPageNotFound": "Pagina non trovata",
|
||||
"PageNotFound": "Questa non è la pagina che stai cercando.",
|
||||
"SettingsPageLoadError": "Errore nel caricamento della pagina di configurazione"
|
||||
"SettingsPageLoadError": "Errore nel caricamento della pagina di configurazione",
|
||||
"RetryWithGlobalSearch": "Prova di nuovo con la ricerca globale"
|
||||
}
|
||||
|
|
|
@ -689,7 +689,7 @@
|
|||
"LabelReleaseDate": "Izlaiduma datums",
|
||||
"LabelPreferredSubtitleLanguage": "Ieteicamā subtitru valoda",
|
||||
"LabelPlayerDimensions": "Atskaņotāja dimensijas",
|
||||
"LabelParentalRating": "Vecāku reitings",
|
||||
"LabelParentalRating": "Vecuma reitings",
|
||||
"LabelMonitorUsers": "Uzraudzīt aktivitāti no",
|
||||
"LabelMinResumePercentageHelp": "Vienumi tiek uzskatīti par neatskaņotiem, ja apturēti pirms šī laika.",
|
||||
"LabelMinResumePercentage": "Minimālais turpināšanas procents",
|
||||
|
@ -1349,7 +1349,7 @@
|
|||
"LabelDummyChapterCountHelp": "Maksimālais nodaļu attēlu skaits, kas tiks ekstraktēts no katra multivides faila.",
|
||||
"LabelChapterImageResolutionHelp": "Izvilkto nodaļu attēlu izšķirtspēja. Šīs vērtības maiņa neietekmēs esošās fiktīvās nodaļas.",
|
||||
"LabelParallelImageEncodingLimit": "Paralēlas attēlu kodēšanas limits",
|
||||
"LabelParallelImageEncodingLimitHelp": "Maksimālais attēlu kodējumu skaits, kurus atļauts palaist paralēli. Nosakot 0, tiks izvēlēts ierobežojums, kas balstīts uz jūsu sistēmas kodolu skaitu.",
|
||||
"LabelParallelImageEncodingLimitHelp": "Maksimālais attēlu kodējumu skaits, kurus atļauts palaist paralēli. Atstājot tukšu, tiks izvēlēts ierobežojums, kas balstīts uz jūsu sistēmas kodolu skaitu.",
|
||||
"HeaderDummyChapter": "Nodaļu attēli",
|
||||
"EnableCardLayout": "Padarīt redzamu CardBox",
|
||||
"MessageConfirmDeleteGuideProvider": "Vai tiešām vēlaties izdzēst šo ceļveža pakalpojumu sniedzēju?",
|
||||
|
@ -1748,7 +1748,7 @@
|
|||
"LabelBuildVersion": "Kompilācijas versija",
|
||||
"SelectAudioNormalizationHelp": "Audioceliņa pastiprinājums — pielāgo katra celiņa skaļumu, lai tie tiktu atskaņoti ar tādu pašu skaļumu. Albuma pastiprinājums - pielāgo visus albuma audio, saglabājot albuma dinamisko diapazonu. Pārslēdzoties starp \"Izslēgts\" un pārējām iespējām, ir nepieciešams pārstartēt pašreizējo atskaņošanu.",
|
||||
"LibraryScanFanoutConcurrency": "Paralēlās bibliotēkas skenēšanas uzdevumu ierobežojums",
|
||||
"LibraryScanFanoutConcurrencyHelp": "Maksimālais paralēlo uzdevumu skaits bibliotēkas skenēšanas laikā. Iestatot 0, tiks izvēlēts ierobežojums, pamatojoties uz jūsu sistēmas kodolu skaitu. BRĪDINĀJUMS: Pārāk liels skaitlis var radīt problēmas tīkla failu sistēmām. Ja novērojat problēmas, samaziniet šo skaitli.",
|
||||
"LibraryScanFanoutConcurrencyHelp": "Maksimālais paralēlo uzdevumu skaits bibliotēkas skenēšanas laikā. Atstājot tukšu, tiks izvēlēts ierobežojums, pamatojoties uz jūsu sistēmas kodolu skaitu. BRĪDINĀJUMS: Pārāk liels skaitlis var radīt problēmas tīkla failu sistēmām. Ja novērojat problēmas, samaziniet šo skaitli.",
|
||||
"PlaylistPublic": "Atļaut publisku piekļuvi",
|
||||
"PlaylistPublicDescription": "Ļaut šo atskaņošanas sarakstu skatīt jebkuram autentificētam lietotājam.",
|
||||
"Rate": "Vertējums",
|
||||
|
@ -1946,5 +1946,6 @@
|
|||
"ExtractTrickplayImagesHelp": "Trickplay attēli līdzinās sadaļu attēliem, bet tie tiek saģenerēti visam satura garumam un tiek lietoti kā priekšskatījums kad ātri ritina cauri video.",
|
||||
"LabelExtractTrickplayDuringLibraryScan": "Izgūt trickplay attēlus bibliotēkas skenēšanas laikā",
|
||||
"LabelJpegQualityHelp": "Trickplay attēlu JPEG kompresijas kvalitātes lielums.",
|
||||
"LogLoadFailure": "Neizdevās ielādēt žurnālfailu. Iespējams tas tiek aizvien izmantots žurnāla ierakstu saglabāšanai."
|
||||
"LogLoadFailure": "Neizdevās ielādēt žurnālfailu. Iespējams tas tiek aizvien izmantots žurnāla ierakstu saglabāšanai.",
|
||||
"SettingsPageLoadError": "Neizdevās ielādēt iestatījumu lapu"
|
||||
}
|
||||
|
|
|
@ -173,7 +173,7 @@
|
|||
"HeaderBranding": "Merking",
|
||||
"HeaderCancelRecording": "Avbryt opptak",
|
||||
"HeaderCancelSeries": "Avbryt serie",
|
||||
"HeaderCastAndCrew": "Skuespillere & mannskap",
|
||||
"HeaderCastAndCrew": "Medvirkende",
|
||||
"HeaderChannelAccess": "Kanal-tilgang",
|
||||
"HeaderCodecProfile": "Kodekprofil",
|
||||
"HeaderCodecProfileHelp": "Kodekprofiler indikerer begrensningene til en enhet ved avspilling av bestemte kodeker. Hvis en begrensning gjelder vil mediet bli omkodet, selv om kodeken er konfigurert for direkteavspilling.",
|
||||
|
@ -1353,7 +1353,7 @@
|
|||
"MessageGetInstalledPluginsError": "En feil oppstod ved henting av listen over installerte tillegg.",
|
||||
"MessagePluginInstallError": "En feil oppstod ved installasjon av tillegget.",
|
||||
"ThumbCard": "Miniatyrbildekort",
|
||||
"SpecialFeatures": "Spesialfunksjoner",
|
||||
"SpecialFeatures": "Ekstra innhold",
|
||||
"PosterCard": "Plakatkort",
|
||||
"Video": "Video",
|
||||
"Subtitle": "Undertekst",
|
||||
|
@ -1720,7 +1720,7 @@
|
|||
"Studio": "Studio",
|
||||
"SubtitleCyan": "Turkis",
|
||||
"UserMenu": "Brukermenyen",
|
||||
"Featurette": "Novellefilm",
|
||||
"Featurette": "Featurette",
|
||||
"LabelTonemappingMode": "Tonemappingsmodus",
|
||||
"PreferEmbeddedExtrasTitlesOverFileNamesHelp": "Ekstramateriale har ofte det samme innebygde navnet som det opprinnelige materialet. Kryss av for denne for å bruke den innebygde tittelen likevel.",
|
||||
"LabelSyncPlayNoGroups": "Ingen grupper tilgjengelig",
|
||||
|
|
|
@ -2011,5 +2011,6 @@
|
|||
"MetadataNfoLoadError": "Laden van metadata-NFO-instellingen mislukt",
|
||||
"PageNotFound": "Dit is niet de pagina die je zoekt.",
|
||||
"HeaderPageNotFound": "Pagina niet gevonden",
|
||||
"SettingsPageLoadError": "Laden van instellingenpagina mislukt"
|
||||
"SettingsPageLoadError": "Laden van instellingenpagina mislukt",
|
||||
"RetryWithGlobalSearch": "Alles doorzoeken"
|
||||
}
|
||||
|
|
|
@ -2012,5 +2012,7 @@
|
|||
"MetadataNfoLoadError": "Nie udało się załadować ustawień metadanych NFO",
|
||||
"HeaderPageNotFound": "Nie znaleziono strony",
|
||||
"PageNotFound": "To nie jest strona, której szukasz.",
|
||||
"SettingsPageLoadError": "Nie udało się załadować strony ustawień"
|
||||
"SettingsPageLoadError": "Nie udało się załadować strony ustawień",
|
||||
"RetryWithGlobalSearch": "Ponów, korzystając z wyszukiwania globalnego",
|
||||
"StreamCountExceedsLimit": "Liczba strumieni przekracza limit"
|
||||
}
|
||||
|
|
|
@ -2003,5 +2003,7 @@
|
|||
"Retry": "Tentar novamente",
|
||||
"LogLoadFailure": "Falha ao carregar o ficheiro de registos. É possível que atualmente esteja a ser escrito.",
|
||||
"MetadataNfoLoadError": "Falha ao carregar as definições de metadados NFO",
|
||||
"SettingsPageLoadError": "Falha ao carregar a página de definições"
|
||||
"SettingsPageLoadError": "Falha ao carregar a página de definições",
|
||||
"RetryWithGlobalSearch": "Tentar novamente com uma pesquisa global",
|
||||
"StreamCountExceedsLimit": "O número de transmissões excede o limite"
|
||||
}
|
||||
|
|
|
@ -1998,5 +1998,7 @@
|
|||
"DeleteServerConfirmation": "Tens a certeza de que queres eliminar este servidor?",
|
||||
"LibraryNameInvalid": "O nome da biblioteca não pode estar vazio.",
|
||||
"MetadataNfoLoadError": "Falha ao carregar as definições de metadados NFO",
|
||||
"SettingsPageLoadError": "Falha ao carregar a página de definições"
|
||||
"SettingsPageLoadError": "Falha ao carregar a página de definições",
|
||||
"RetryWithGlobalSearch": "Tentar novamente com uma pesquisa global",
|
||||
"StreamCountExceedsLimit": "O número de transmissões excede o limite"
|
||||
}
|
||||
|
|
|
@ -2012,5 +2012,7 @@
|
|||
"MetadataImagesLoadError": "Не удалось загрузить настройки метаданных",
|
||||
"HeaderPageNotFound": "Станица не найдена",
|
||||
"PageNotFound": "Это не та страница, которую вы искали.",
|
||||
"SettingsPageLoadError": "Не удалось загрузить страницу параметров"
|
||||
"SettingsPageLoadError": "Не удалось загрузить страницу параметров",
|
||||
"RetryWithGlobalSearch": "Повторите попытку с помощью глобального поиска",
|
||||
"StreamCountExceedsLimit": "Количество потоков превышает предельное значение"
|
||||
}
|
||||
|
|
|
@ -2012,5 +2012,7 @@
|
|||
"MetadataNfoLoadError": "Nepodarilo sa načítať nastavenia NFO metadát",
|
||||
"HeaderPageNotFound": "Stránka nebola nájdená",
|
||||
"PageNotFound": "Toto nie je stránka, ktorú hľadáš.",
|
||||
"SettingsPageLoadError": "Nepodarilo sa načítať stránku s nastaveniami"
|
||||
"SettingsPageLoadError": "Nepodarilo sa načítať stránku s nastaveniami",
|
||||
"StreamCountExceedsLimit": "Počet streamov prekračuje limit",
|
||||
"RetryWithGlobalSearch": "Skúsiť globálne vyhľadávanie"
|
||||
}
|
||||
|
|
|
@ -2009,5 +2009,7 @@
|
|||
"MetadataNfoLoadError": "Не вдалося завантажити налаштування метаданих NFO",
|
||||
"HeaderPageNotFound": "Сторінку не знайдено",
|
||||
"PageNotFound": "Це не та сторінка, яку ви шукаєте.",
|
||||
"SettingsPageLoadError": "Не вдалося завантажити сторінку налаштувань"
|
||||
"SettingsPageLoadError": "Не вдалося завантажити сторінку налаштувань",
|
||||
"StreamCountExceedsLimit": "Кількість потоків перевищує ліміт",
|
||||
"RetryWithGlobalSearch": "Повторити спробу з глобальним пошуком"
|
||||
}
|
||||
|
|
|
@ -2009,5 +2009,7 @@
|
|||
"MetadataNfoLoadError": "Tải cài đặt dữ liệu mô tả NFO thất bại",
|
||||
"PageNotFound": "Đây không phải là trang bạn đang tìm kiếm.",
|
||||
"HeaderPageNotFound": "Không tìm thấy trang",
|
||||
"SettingsPageLoadError": "Tải trang cài đặt thất bại"
|
||||
"SettingsPageLoadError": "Tải trang cài đặt thất bại",
|
||||
"StreamCountExceedsLimit": "Số lượng luồng vượt quá giới hạn",
|
||||
"RetryWithGlobalSearch": "Thử lại với tìm kiếm toàn hệ thống"
|
||||
}
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue