Merge remote-tracking branch 'upstream/master' into libbitsub

This commit is contained in:
altqx
2026-07-03 09:39:23 +07:00
530 changed files with 16303 additions and 8290 deletions
+1 -1
View File
@@ -11,7 +11,7 @@ jobs:
name: Merge conflict labeling 🏷️
runs-on: ubuntu-latest
steps:
- uses: eps1lon/actions-label-merge-conflict@1df065ebe6e3310545d4f4c4e862e43bdca146f0 # v3.0.3
- uses: eps1lon/actions-label-merge-conflict@0273be72a0bbd58fcd71d0d6c02c209b50d1e5e1 # v3.1.0
with:
dirtyLabel: 'merge conflict'
commentOnDirty: 'This pull request has merge conflicts. Please resolve the conflicts so the PR can be successfully reviewed and merged.'
+4 -4
View File
@@ -20,21 +20,21 @@ jobs:
steps:
- name: Checkout repository ⬇️
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
ref: ${{ inputs.commit }}
show-progress: false
- name: Initialize CodeQL 🛠️
uses: github/codeql-action/init@38697555549f1db7851b81482ff19f1fa5c4fedc # v4.34.1
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
with:
queries: security-and-quality
languages: ${{ matrix.language }}
- name: Autobuild 📦
uses: github/codeql-action/autobuild@38697555549f1db7851b81482ff19f1fa5c4fedc # v4.34.1
uses: github/codeql-action/autobuild@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
- name: Perform CodeQL Analysis 🧪
uses: github/codeql-action/analyze@38697555549f1db7851b81482ff19f1fa5c4fedc # v4.34.1
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
with:
category: '/language:${{matrix.language}}'
+2 -2
View File
@@ -48,7 +48,7 @@ jobs:
name: CloudFlare Pages 📃
runs-on: ubuntu-latest
environment:
name: ${{ inputs.branch == 'jellyfin/jellyfin-web/master' && 'Production' || 'Preview' }}
name: ${{ inputs.branch == github.event.repository.default_branch && 'Production' || 'Preview' }}
url: ${{ steps.cf.outputs.deployment-url }}
outputs:
url: ${{ steps.cf.outputs.deployment-url }}
@@ -62,7 +62,7 @@ jobs:
github-token: ${{ github.token }}
- name: Publish to Cloudflare Pages 📃
uses: cloudflare/wrangler-action@da0e0dfe58b7a431659754fdf3f186c529afbe65 # v3.14.1
uses: cloudflare/wrangler-action@9acf94ace14e7dc412b076f2c5c20b8ce93c79cd # v3.15.0
id: cf
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
+3 -3
View File
@@ -14,12 +14,12 @@ jobs:
steps:
- name: Check out Git repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
ref: ${{ inputs.commit || github.sha }}
- name: Setup node environment
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: .nvmrc
cache: npm
@@ -39,7 +39,7 @@ jobs:
mv dist/config.tmp.json dist/config.json
- name: Upload artifact
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: frontend
path: dist
+3 -3
View File
@@ -14,7 +14,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout Repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
ref: ${{ inputs.commit }}
show-progress: false
@@ -42,13 +42,13 @@ jobs:
steps:
- name: Checkout ⬇️
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
ref: ${{ inputs.commit }}
show-progress: false
- name: Setup node environment ⚙️
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: .nvmrc
cache: npm
+3 -3
View File
@@ -46,12 +46,12 @@ jobs:
steps:
- name: Check out Git repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
ref: ${{ github.event.pull_request.head.sha }}
- name: Setup node environment
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: .nvmrc
cache: npm
@@ -61,6 +61,6 @@ jobs:
run: npm ci --no-audit
- name: Run eslint
uses: CatChen/eslint-suggestion-action@efbe3942e76df120000621539d93ce7e0b54f7a7 # v4.1.27
uses: CatChen/eslint-suggestion-action@2b07b898c0dd5a016586d22b54282199b79cd358 # v4.1.33
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
+1 -1
View File
@@ -51,7 +51,7 @@ jobs:
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
with:
branch: ${{ format('{0}/{1}', github.repository, github.ref_name) }}
branch: ${{ github.ref_name }}
commit: ${{ github.sha }}
workflow_run_id: ${{ github.run_id }}
comment: false
+1 -1
View File
@@ -14,7 +14,7 @@ jobs:
runs-on: ubuntu-latest
if: ${{ contains(github.repository, 'jellyfin/') }}
steps:
- uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0
- uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0
with:
repo-token: ${{ secrets.JF_BOT_TOKEN }}
operations-per-run: 75
+11 -10
View File
@@ -12,6 +12,7 @@ If you have any questions, please join one of our [development chat rooms](https
* New files **MUST** be written in TypeScript.
* API interactions **MUST** be made using the Jellyfin TypeScript SDK.
* New app pages **MUST** be written using React components using `src/components/Page.tsx` as the base component.
* **SHOULD** be covered by unit tests when possible (legacy component/view code is deemed untestable).
* **SHOULD** avoid whitespace only changes in unchanged sections of code.
* **SHOULD NOT** overuse dynamic imports. We use dynamic imports at the page level; otherwise we should let our build tooling deal with code-splitting for the best bundle sizes.
@@ -30,6 +31,7 @@ If you have any questions, please join one of our [development chat rooms](https
* **SHOULD NOT** rebase once reviews are in progress.
* **MUST** follow the [LLM development policy](https://jellyfin.org/docs/general/contributing/llm-policies).
* **MUST** test that the change works as expected before marking a PR as ready for review.
* **MUST** fully complete the PR template. Failing to do so will result in the PR being closed as invalid without review.
* **SHOULD** represent a singular focus (i.e. a PR to fix a bug should not include unrelated refactoring).
* **SHOULD NOT** update from `master` needlessly once opened (only update if conflicts exist).
@@ -51,7 +53,7 @@ You may be asked to update your Pull Request to target a release branch as part
* [React](https://react.dev/reference/react) — User Interface library
* [React Router](https://reactrouter.com/) — Routing library
* [TanStack Query](https://tanstack.com/query/latest/docs/framework/react/overview) — State management library for server data
* [MUI](https://mui.com/material-ui/getting-started/) components (in Dashboard and Experimental layouts) — UI component library
* [MUI](https://mui.com/material-ui/getting-started/) components (in Dashboard and Modern layouts) — UI component library
* [Webpack](https://webpack.js.org/concepts/) — Bundler / build tooling
* [Vitest](https://vitest.dev/api/) — Test library
@@ -59,7 +61,7 @@ You may be asked to update your Pull Request to target a release branch as part
| Library | Replacement |
| --- | --- |
| [Emby WebComponents](./src/elements) | MUI components (Dashboard + Experimental apps ONLY; Untested on TVs) |
| [Emby WebComponents](./src/elements) | MUI components (Dashboard + Modern apps ONLY; Untested on TVs) |
| [App Router](./src/components/router/appRouter.js) | React Router |
| [View Manager](./src/components/viewManager) | React Router |
| [Jellyfin ApiClient](https://github.com/jellyfin-archive/jellyfin-apiclient-javascript) | Jellyfin TypeScript SDK |
@@ -73,13 +75,13 @@ The official list of supported browser versions can be found in the `browserlist
## Application Components
* Stable App `src/apps/stable`
* Modern App `src/apps/modern`
* Ongoing rewrite of the main user interface using MUI components
* Currently reuses content from the Legacy App to maintain parity
* Does not currently support TV layout!
* Legacy App `src/apps/legacy`
* Main user application
* Supports TV layout
* Experimental App `src/apps/experimental`
* Ongoing rewrite of the Stable App using MUI components
* Currently reuses (most) pages from the Stable App to maintain parity
* Does not currently support TV layout!
* Dashboard App `src/apps/dashboard`
* Admin dashboard and metadata editor pages
* Almost completely rewritten using MUI components
@@ -103,13 +105,12 @@ The official list of supported browser versions can be found in the `browserlist
└── src
├── apps
│   ├── dashboard # Admin dashboard app
│   ├── experimental # New experimental app
│   ├── stable # Classic (stable) app
│   ├── legacy # Legacy app
│   ├── modern # New modern (React based) app
│   └── wizard # Startup wizard app
├── assets # Static assets
├── components # Higher order visual components and React components
├── constants # Common constant values
├── controllers # Legacy page views and controllers 🧹 ❌
├── elements # Basic webcomponents and React equivalents 🧹
├── hooks # Custom React hooks
├── lib # Reusable libraries
+4
View File
@@ -98,13 +98,17 @@
- [Jxiced](https://github.com/Jxiced)
- [Derek Huber](https://github.com/Derek4aty1)
- [StableCrimson](https://github.com/StableCrimson)
- [hazemKrimi](https://github.com/hazemKrimi)
- [diegoeche](https://github.com/diegoeche)
- [Free O'Toole](https://github.com/freeotoole)
- [TheBosZ](https://github.com/thebosz)
- [qm3jp](https://github.com/qm3jp)
- [johnnyg](https://github.com/johnnyg)
- [Lampan-git](https://github.com/Lampan-git)
- [lmaotrigine](https://github.com/lmaotrigine)
- [bjorntp](https://github.com/bjorntp)
- [austinhardaway](https://github.com/austinhardaway)
- [shindouj](https://github.com/shindouj)
## Emby Contributors
+9 -2
View File
@@ -59,8 +59,7 @@ export default tseslint.config(
'new-cap': [
'error',
{
'capIsNewExceptions': ['jQuery.Deferred'],
'newIsCapExceptionPattern': '\\.default$'
'newIsCapExceptionPattern': String.raw`\.default$`
}
],
'no-duplicate-imports': 'error',
@@ -71,6 +70,13 @@ export default tseslint.config(
'no-redeclare': 'off',
'@typescript-eslint/no-redeclare': ['error', { builtinGlobals: false }],
'no-restricted-globals': ['error'].concat(restrictedGlobals),
'no-restricted-properties': [
'error',
{
property: 'replaceChildren',
message: 'replaceChildren is not supported in all target browsers'
}
],
'no-return-assign': 'error',
'no-return-await': 'error',
'no-sequences': ['error', { 'allowInParentheses': false }],
@@ -103,6 +109,7 @@ export default tseslint.config(
'sonarjs/no-inverted-boolean-check': 'error',
'sonarjs/no-selector-parameter': 'off',
'sonarjs/pseudo-random': 'warn',
'sonarjs/aws-restricted-ip-admin-access': 'off',
// TODO: Enable the following sonarjs rules and fix issues
'sonarjs/no-duplicate-string': 'off',
'sonarjs/no-nested-functions': 'warn',
+1649 -1365
View File
File diff suppressed because it is too large Load Diff
+40 -38
View File
@@ -1,15 +1,15 @@
{
"name": "jellyfin-web",
"version": "10.12.0",
"version": "12.0.0",
"description": "Web interface for Jellyfin",
"repository": "https://github.com/jellyfin/jellyfin-web",
"license": "GPL-2.0-or-later",
"devDependencies": {
"@babel/core": "7.29.0",
"@babel/plugin-transform-modules-umd": "7.27.1",
"@babel/preset-env": "7.29.2",
"@babel/preset-react": "7.28.5",
"@eslint-community/eslint-plugin-eslint-comments": "4.7.1",
"@babel/core": "7.29.7",
"@babel/plugin-transform-modules-umd": "7.29.7",
"@babel/preset-env": "7.29.7",
"@babel/preset-react": "7.29.7",
"@eslint-community/eslint-plugin-eslint-comments": "4.7.2",
"@eslint/js": "9.39.4",
"@stylistic/eslint-plugin": "4.4.1",
"@stylistic/stylelint-plugin": "3.1.3",
@@ -18,23 +18,23 @@
"@types/loadable__component": "5.13.10",
"@types/lodash-es": "4.17.12",
"@types/markdown-it": "14.1.2",
"@types/react": "18.3.28",
"@types/react": "18.3.31",
"@types/react-dom": "18.3.7",
"@types/react-lazy-load-image-component": "1.6.5",
"@types/sortablejs": "1.15.9",
"@typescript-eslint/parser": "8.57.0",
"@typescript-eslint/parser": "8.62.0",
"@uupaa/dynamic-import-polyfill": "1.0.2",
"@vitest/coverage-v8": "3.2.4",
"autoprefixer": "10.4.27",
"@vitest/coverage-v8": "3.2.6",
"autoprefixer": "10.5.2",
"babel-loader": "10.1.1",
"caniuse-lite": "1.0.30001781",
"caniuse-lite": "1.0.30001799",
"clean-webpack-plugin": "4.0.0",
"confusing-browser-globals": "1.0.11",
"copy-webpack-plugin": "14.0.0",
"cross-env": "10.1.0",
"css-loader": "7.1.4",
"cssnano": "7.1.3",
"es-check": "9.6.2",
"cssnano": "7.1.9",
"es-check": "9.6.4",
"eslint": "9.39.4",
"eslint-plugin-compat": "6.2.1",
"eslint-plugin-import": "2.32.0",
@@ -45,34 +45,34 @@
"expose-loader": "5.0.1",
"fast-glob": "3.3.3",
"fork-ts-checker-webpack-plugin": "9.1.0",
"globals": "17.4.0",
"globals": "17.7.0",
"html-loader": "5.1.0",
"html-webpack-plugin": "5.6.6",
"jsdom": "28.1.0",
"mini-css-extract-plugin": "2.10.1",
"postcss": "8.5.8",
"html-webpack-plugin": "5.6.7",
"jsdom": "29.1.1",
"mini-css-extract-plugin": "2.10.2",
"postcss": "8.5.15",
"postcss-loader": "8.2.1",
"postcss-preset-env": "10.6.1",
"postcss-scss": "4.0.9",
"sass": "1.98.0",
"sass-loader": "16.0.7",
"sass": "1.101.0",
"sass-loader": "16.0.8",
"source-map-loader": "5.0.0",
"speed-measure-webpack-plugin": "1.5.0",
"speed-measure-webpack-plugin": "1.6.0",
"style-loader": "4.0.0",
"stylelint": "16.26.1",
"stylelint-config-rational-order": "0.1.2",
"stylelint-no-browser-hacks": "2.0.0",
"stylelint-no-browser-hacks": "2.0.3",
"stylelint-order": "7.0.1",
"stylelint-scss": "6.14.0",
"ts-loader": "9.5.4",
"ts-loader": "9.6.2",
"typescript": "5.9.3",
"typescript-eslint": "8.57.0",
"typescript-eslint": "8.62.0",
"vite-tsconfig-paths": "6.1.1",
"vitest": "3.2.4",
"webpack": "5.105.4",
"webpack-bundle-analyzer": "5.2.0",
"webpack-cli": "7.0.2",
"webpack-dev-server": "5.2.3",
"vitest": "3.2.6",
"webpack": "5.107.2",
"webpack-bundle-analyzer": "5.3.0",
"webpack-cli": "7.0.3",
"webpack-dev-server": "5.2.5",
"webpack-merge": "6.0.1",
"worker-loader": "3.0.8"
},
@@ -86,16 +86,17 @@
"@fontsource/noto-sans-sc": "5.2.9",
"@fontsource/noto-sans-tc": "5.2.9",
"@jellyfin/libass-wasm": "4.2.4",
"@jellyfin/sdk": "0.0.0-unstable.202603300541",
"@jellyfin/sdk": "0.0.0-unstable.202607010629",
"@jellyfin/ux-web": "1.0.0",
"@mui/icons-material": "6.5.0",
"@mui/material": "6.5.0",
"@mui/x-date-pickers": "7.29.4",
"@react-hook/resize-observer": "2.0.2",
"@tanstack/react-query": "5.91.2",
"@tanstack/react-query-devtools": "5.91.3",
"@tanstack/react-query": "5.101.1",
"@tanstack/react-query-devtools": "5.101.1",
"@tanstack/react-query-persist-client": "5.101.1",
"abortcontroller-polyfill": "1.7.8",
"axios": "1.13.6",
"axios": "1.18.1",
"blurhash": "2.0.5",
"classlist.js": "https://github.com/eligrey/classList.js/archive/1.2.20180112.tar.gz",
"classnames": "2.5.1",
@@ -109,7 +110,8 @@
"flv.js": "1.6.2",
"headroom.js": "0.12.0",
"history": "5.3.0",
"hls.js": "1.6.15",
"hls.js": "1.6.16",
"idb-keyval": "6.2.5",
"intersection-observer": "0.12.2",
"jellyfin-apiclient": "1.11.0",
"jquery": "3.7.1",
@@ -117,7 +119,7 @@
"libarchive.js": "2.0.2",
"libbitsub": "1.10.1",
"lodash-es": "4.18.1",
"markdown-it": "14.1.1",
"markdown-it": "14.2.0",
"material-design-icons-iconfont": "6.7.0",
"material-react-table": "3.2.1",
"native-promise-only": "0.8.1",
@@ -127,17 +129,17 @@
"react-blurhash": "0.3.0",
"react-dom": "18.3.1",
"react-lazy-load-image-component": "1.6.3",
"react-router-dom": "6.30.3",
"react-router-dom": "6.30.4",
"resize-observer-polyfill": "1.5.1",
"screenfull": "6.0.2",
"sortablejs": "1.15.7",
"swiper": "12.1.2",
"swiper": "12.2.0",
"usehooks-ts": "3.1.1",
"webcomponents.js": "0.7.24",
"whatwg-fetch": "3.6.20"
},
"optionalDependencies": {
"sass-embedded": "1.98.0"
"sass-embedded": "1.100.0"
},
"browserslist": [
"last 2 Firefox versions",
+12 -4
View File
@@ -1,12 +1,13 @@
import { QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import { PersistQueryClientProvider } from '@tanstack/react-query-persist-client';
import React from 'react';
import QueryClientEventHandler from 'components/QueryClientEventHandler';
import { ApiProvider } from 'hooks/useApi';
import { UserSettingsProvider } from 'hooks/useUserSettings';
import { WebConfigProvider } from 'hooks/useWebConfig';
import browser from 'scripts/browser';
import { queryClient } from 'utils/query/queryClient';
import { persister, queryClient } from 'utils/query/queryClient';
import RootAppRouter from 'RootAppRouter';
@@ -14,10 +15,17 @@ const useReactQueryDevtools = window.Proxy // '@tanstack/query-devtools' require
&& !browser.tv; // Don't use devtools on the TV as the navigation is weird
const RootApp = () => (
<QueryClientProvider client={queryClient}>
<PersistQueryClientProvider
client={queryClient}
persistOptions={{
buster: __JF_BUILD_VERSION__,
persister
}}
>
<ApiProvider>
<UserSettingsProvider>
<WebConfigProvider>
<QueryClientEventHandler />
<RootAppRouter />
</WebConfigProvider>
</UserSettingsProvider>
@@ -25,7 +33,7 @@ const RootApp = () => (
{useReactQueryDevtools && (
<ReactQueryDevtools initialIsOpen={false} />
)}
</QueryClientProvider>
</PersistQueryClientProvider>
);
export default RootApp;
+5 -10
View File
@@ -8,27 +8,22 @@ import {
} from 'react-router-dom';
import { DASHBOARD_APP_PATHS, DASHBOARD_APP_ROUTES } from 'apps/dashboard/routes/routes';
import { EXPERIMENTAL_APP_ROUTES } from 'apps/experimental/routes/routes';
import { STABLE_APP_ROUTES } from 'apps/stable/routes/routes';
import { APP_ROUTES as MODERN_APP_ROUTES } from 'apps/modern/routes/routes';
import { APP_ROUTES as LEGACY_APP_ROUTES } from 'apps/legacy/routes/routes';
import { WIZARD_APP_ROUTES } from 'apps/wizard/routes/routes';
import AppHeader from 'components/AppHeader';
import Backdrop from 'components/Backdrop';
import { SETTING_KEY as LAYOUT_SETTING_KEY } from 'components/layoutManager';
import layoutManager from 'components/layoutManager';
import BangRedirect from 'components/router/BangRedirect';
import { createRouterHistory } from 'components/router/routerHistory';
import { LayoutMode } from 'constants/layoutMode';
import browser from 'scripts/browser';
import appTheme from 'themes';
import { ThemeStorageManager } from 'themes/themeStorageManager';
const layoutMode = browser.tv ? LayoutMode.Tv : localStorage.getItem(LAYOUT_SETTING_KEY);
const isExperimentalLayout = !layoutMode || layoutMode === LayoutMode.Experimental;
const router = createHashRouter([
{
element: <RootAppLayout />,
children: [
...(isExperimentalLayout ? EXPERIMENTAL_APP_ROUTES : STABLE_APP_ROUTES),
...(layoutManager.modern ? MODERN_APP_ROUTES : LEGACY_APP_ROUTES),
...DASHBOARD_APP_ROUTES,
...WIZARD_APP_ROUTES,
{
@@ -61,7 +56,7 @@ function RootAppLayout() {
storageManager={ThemeStorageManager}
>
<Backdrop />
<AppHeader isHidden={isExperimentalLayout || isNewLayoutPath} />
<AppHeader isHidden={layoutManager.modern || isNewLayoutPath} />
<Outlet />
</ThemeProvider>
-3
View File
@@ -166,7 +166,6 @@ declare module 'jellyfin-apiclient' {
getMusicGenres(userId: string, options?: any): Promise<BaseItemDtoQueryResult>;
getNamedConfiguration(name: string): Promise<any>;
getNetworkDevices(): Promise<any>;
getNetworkShares(path: string): Promise<FileSystemEntryInfo[]>;
getNewLiveTvTimerDefaults(options?: any): Promise<SeriesTimerInfoDto>;
getNextUpEpisodes(options?: any): Promise<BaseItemDtoQueryResult>;
getNotificationSummary(userId: string): Promise<NotificationsSummaryDto>;
@@ -256,7 +255,6 @@ declare module 'jellyfin-apiclient' {
requestSyncPlaySetRepeatMode(options?: SetRepeatModeRequestDto): Promise<void>;
requestSyncPlaySetShuffleMode(options?: SetShuffleModeRequestDto): Promise<void>;
requestSyncPlayUnpause(): Promise<void>;
resetEasyPassword(userId: string): Promise<void>;
resetLiveTvTuner(id: string): Promise<void>;
resetUserPassword(userId: string): Promise<void>;
restartServer(): Promise<void>;
@@ -281,7 +279,6 @@ declare module 'jellyfin-apiclient' {
uninstallPluginByVersion(id: string, version: string): Promise<void>;
uninstallPlugin(id: string): Promise<void>;
updateDisplayPreferences(id: string, obj: DisplayPreferencesDto, userId: string, app: string): Promise<void>;
updateEasyPassword(userId: string, newPassword: string): Promise<void>;
updateFavoriteStatus(userId: string, itemId: string, isFavorite: boolean): Promise<UserItemDataDto>;
updateItemImageIndex(itemId: string, imageType: ImageType, imageIndex: number, newIndex: number): Promise<any>;
updateItem(item: BaseItemDto): Promise<void>;
+37 -32
View File
@@ -1,4 +1,3 @@
import AppBar from '@mui/material/AppBar';
import Box from '@mui/material/Box';
import { type Theme } from '@mui/material/styles';
import useMediaQuery from '@mui/material/useMediaQuery';
@@ -8,9 +7,9 @@ import React, { FC, StrictMode, useCallback, useEffect, useState } from 'react';
import { Outlet, useLocation } from 'react-router-dom';
import AppBody from 'components/AppBody';
import OffsetAppBar from 'components/OffsetAppBar';
import AppToolbar from 'components/toolbar/AppToolbar';
import ServerButton from 'components/toolbar/ServerButton';
import ElevationScroll from 'components/ElevationScroll';
import { DRAWER_WIDTH } from 'components/ResponsiveDrawer';
import { appRouter } from 'components/router/appRouter';
import ThemeCss from 'components/ThemeCss';
@@ -50,39 +49,44 @@ export const Component: FC = () => {
return (
<LocalizationProvider dateAdapter={AdapterDateFns} adapterLocale={dateFnsLocale}>
<Box sx={{ display: 'flex' }}>
<Box
sx={{
display: 'flex',
flexDirection: 'column',
minHeight: '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
}
}}
<OffsetAppBar
dense
elevation={4}
sx={{
width: {
xs: '100%',
md: isDrawerAvailable ? `calc(100% - ${DRAWER_WIDTH}px)` : '100%'
},
ml: {
xs: 0,
md: isDrawerAvailable ? DRAWER_WIDTH : 0
}
}}
>
<AppToolbar
isBackButtonAvailable={appRouter.canGoBack()}
isDrawerAvailable={!isMediumScreen && isDrawerAvailable}
isDrawerOpen={isDrawerOpen}
onDrawerButtonClick={onToggleDrawer}
buttons={
<HelpButton />
}
>
<AppToolbar
isBackButtonAvailable={appRouter.canGoBack()}
isDrawerAvailable={!isMediumScreen && isDrawerAvailable}
isDrawerOpen={isDrawerOpen}
onDrawerButtonClick={onToggleDrawer}
buttons={
<HelpButton />
}
>
{isMetadataManager && (
<ServerButton />
)}
{isMetadataManager && (
<ServerButton />
)}
<AppTabs isDrawerOpen={isDrawerOpen} />
</AppToolbar>
</AppBar>
</ElevationScroll>
<AppTabs isDrawerOpen={isDrawerOpen} />
</AppToolbar>
</OffsetAppBar>
{
isDrawerAvailable && (
@@ -98,6 +102,7 @@ export const Component: FC = () => {
<Box
component='main'
sx={{
position: 'relative',
width: '100%',
flexGrow: 1
}}
+2 -14
View File
@@ -19,20 +19,8 @@ $drawer-width: 240px;
position: unset !important;
}
// Fix the padding of dashboard pages
.content-primary {
padding-top: 3.25rem;
}
// Tabbed pages
.withTabs .content-primary {
padding-top: 6.5rem;
@media all and (min-width: $mui-bp-lg) {
padding-top: 3.25rem;
}
}
// Fix the padding of the metadata editor
.metadataEditorPage {
padding-top: 3.25rem !important;
padding-top: 0 !important;
}
}
@@ -2,7 +2,7 @@ import ListItem from '@mui/material/ListItem';
import List from '@mui/material/List';
import React, { FC } from 'react';
import DrawerHeaderLink from 'apps/experimental/components/drawers/DrawerHeaderLink';
import DrawerHeaderLink from 'apps/modern/components/drawers/DrawerHeaderLink';
import ResponsiveDrawer, { ResponsiveDrawerProps } from 'components/ResponsiveDrawer';
import ServerDrawerSection from './sections/ServerDrawerSection';
@@ -1,5 +1,5 @@
import Extension from '@mui/icons-material/Extension';
import Folder from '@mui/icons-material/Folder';
import Icon from '@mui/material/Icon';
import List from '@mui/material/List';
import ListItemIcon from '@mui/material/ListItemIcon';
import ListItemText from '@mui/material/ListItemText';
@@ -50,8 +50,7 @@ const PluginDrawerSection = () => {
to={`/${Dashboard.getPluginUrl(pageInfo.Name)}`}
>
<ListItemIcon>
{/* TODO: Support different icons? */}
<Folder />
<Icon>{pageInfo.MenuIcon ?? 'folder'}</Icon>
</ListItemIcon>
<ListItemText primary={pageInfo.DisplayName} />
</ListItemLink>
@@ -76,7 +76,8 @@ const ServerInfoWidget = ({
sx={{
fontWeight: 'bold'
}}
disabled={isScanning}
loading={isScanning}
loadingPosition='start'
>
{globalize.translate('ButtonScanAllLibraries')}
</Button>
@@ -1,4 +1,4 @@
import type { ActivityLogApiGetLogEntriesRequest } from '@jellyfin/sdk/lib/generated-client';
import type { ActivityLogApiGetLogEntriesRequest } from '@jellyfin/sdk/lib/generated-client/api/activity-log-api';
import type { AxiosRequestConfig } from 'axios';
import type { Api } from '@jellyfin/sdk';
import { getActivityLogApi } from '@jellyfin/sdk/lib/utils/api/activity-log-api';
@@ -27,6 +27,7 @@ export const useLogEntries = (
queryFn: ({ signal }) =>
fetchLogEntries(api!, requestParams, { signal }),
enabled: !!api,
refetchOnMount: false
refetchOnMount: false,
staleTime: 0 // ensure we load the latest log entries
});
};
@@ -1,4 +1,4 @@
import type { DevicesApiGetDevicesRequest } from '@jellyfin/sdk/lib/generated-client';
import type { DevicesApiGetDevicesRequest } from '@jellyfin/sdk/lib/generated-client/api/devices-api';
import type { AxiosRequestConfig } from 'axios';
import type { Api } from '@jellyfin/sdk';
import { getDevicesApi } from '@jellyfin/sdk/lib/utils/api/devices-api';
@@ -28,6 +28,7 @@ export const useDevices = (
queryKey: [QUERY_KEY, requestParams],
queryFn: ({ signal }) =>
fetchDevices(api!, requestParams, { signal }),
enabled: !!api
enabled: !!api,
staleTime: 0 // ensure we load the latest device data
});
};
@@ -1,4 +1,4 @@
import type { ApiKeyApiRevokeKeyRequest } from '@jellyfin/sdk/lib/generated-client';
import type { ApiKeyApiRevokeKeyRequest } from '@jellyfin/sdk/lib/generated-client/api/api-key-api';
import { getApiKeyApi } from '@jellyfin/sdk/lib/utils/api/api-key-api';
import { useMutation } from '@tanstack/react-query';
import { useApi } from 'hooks/useApi';
@@ -0,0 +1,22 @@
import type { UserDto } from '@jellyfin/sdk/lib/generated-client/models/user-dto';
import type { VirtualFolderInfo } from '@jellyfin/sdk/lib/generated-client/models/virtual-folder-info';
import { queryClient } from 'utils/query/queryClient';
import { QUERY_KEY } from './useVirtualFolders';
/** Invalidates queries related to libraries/virtual folders. */
export const invalidateVirtualFolders = (user?: UserDto, virtualFolder?: VirtualFolderInfo) => {
// Admin API - Virtual Folders
void queryClient.invalidateQueries({ queryKey: [QUERY_KEY] });
if (user) {
// User API - Views
void queryClient.invalidateQueries({ queryKey: ['User', user.Id, 'Views'] });
if (virtualFolder?.ItemId) {
// Items API - Library children
void queryClient.invalidateQueries({ queryKey: ['User', user.Id, 'Items', virtualFolder.ItemId] });
}
}
};
@@ -3,19 +3,18 @@ import { LibraryStructureApiRemoveVirtualFolderRequest } from '@jellyfin/sdk/lib
import { useMutation } from '@tanstack/react-query';
import { useApi } from 'hooks/useApi';
import { queryClient } from 'utils/query/queryClient';
import { invalidateVirtualFolders } from './invalidateVirtualFolders';
export const useRemoveVirtualFolder = () => {
const { api } = useApi();
const { api, user } = useApi();
return useMutation({
mutationFn: (params: LibraryStructureApiRemoveVirtualFolderRequest) => (
getLibraryStructureApi(api!)
.removeVirtualFolder(params)
),
onSuccess: () => {
void queryClient.invalidateQueries({
queryKey: [ 'VirtualFolders' ]
});
invalidateVirtualFolders(user);
}
});
};
@@ -3,19 +3,18 @@ import { LibraryStructureApiRenameVirtualFolderRequest } from '@jellyfin/sdk/lib
import { useMutation } from '@tanstack/react-query';
import { useApi } from 'hooks/useApi';
import { queryClient } from 'utils/query/queryClient';
import { invalidateVirtualFolders } from './invalidateVirtualFolders';
export const useRenameVirtualFolder = () => {
const { api } = useApi();
const { api, user } = useApi();
return useMutation({
mutationFn: (params: LibraryStructureApiRenameVirtualFolderRequest) => (
getLibraryStructureApi(api!)
.renameVirtualFolder(params)
),
onSuccess: () => {
void queryClient.invalidateQueries({
queryKey: [ 'VirtualFolders' ]
});
invalidateVirtualFolders(user);
}
});
};
@@ -4,6 +4,8 @@ import { useApi } from 'hooks/useApi';
import type { AxiosRequestConfig } from 'axios';
import { getLibraryStructureApi } from '@jellyfin/sdk/lib/utils/api/library-structure-api';
export const QUERY_KEY = 'VirtualFolders';
const fetchVirtualFolders = async (api: Api, options?: AxiosRequestConfig) => {
const response = await getLibraryStructureApi(api).getVirtualFolders(options);
@@ -14,7 +16,7 @@ export const useVirtualFolders = () => {
const { api } = useApi();
return useQuery({
queryKey: [ 'VirtualFolders' ],
queryKey: [ QUERY_KEY ],
queryFn: ({ signal }) => fetchVirtualFolders(api!, { signal }),
enabled: !!api
});
@@ -6,7 +6,6 @@ import globalize from 'lib/globalize';
import Icon from '@mui/material/Icon';
import { getLibraryIcon } from 'utils/image';
import MediaLibraryEditor from 'components/mediaLibraryEditor/mediaLibraryEditor';
import { queryClient } from 'utils/query/queryClient';
import Menu from '@mui/material/Menu';
import MenuItem from '@mui/material/MenuItem';
import ListItemIcon from '@mui/material/ListItemIcon';
@@ -27,13 +26,14 @@ import { getImageApi } from '@jellyfin/sdk/lib/utils/api/image-api';
import { useApi } from 'hooks/useApi';
import { ImageType } from '@jellyfin/sdk/lib/generated-client/models/image-type';
import dom from 'utils/dom';
import { invalidateVirtualFolders } from '../api/invalidateVirtualFolders';
type LibraryCardProps = {
virtualFolder: VirtualFolderInfo;
};
const LibraryCard = ({ virtualFolder }: LibraryCardProps) => {
const { api } = useApi();
const { api, user } = useApi();
const actionRef = useRef<HTMLButtonElement | null>(null);
const [ anchorEl, setAnchorEl ] = useState<HTMLElement | null>(null);
const [ isMenuOpen, setIsMenuOpen ] = useState(false);
@@ -44,9 +44,10 @@ const LibraryCard = ({ virtualFolder }: LibraryCardProps) => {
const imageUrl = useMemo(() => {
if (virtualFolder.PrimaryImageItemId && virtualFolder.ItemId && api) {
const dpr = window?.devicePixelRatio || 1;
return getImageApi(api)
.getItemImageUrlById(virtualFolder.ItemId, ImageType.Primary, {
maxWidth: Math.round(dom.getScreenWidth() * 0.40)
maxWidth: Math.round(dom.getScreenWidth() * dpr * 0.40)
});
}
}, [ api, virtualFolder ]);
@@ -105,17 +106,14 @@ const LibraryCard = ({ virtualFolder }: LibraryCardProps) => {
setIsMenuOpen(false);
const mediaLibraryEditor = new MediaLibraryEditor({
library: virtualFolder
library: virtualFolder,
refresh: true
}) as Promise<boolean>;
void mediaLibraryEditor.then((hasChanges: boolean) => {
if (hasChanges) {
void queryClient.invalidateQueries({
queryKey: ['VirtualFolders']
});
}
if (hasChanges) invalidateVirtualFolders(user, virtualFolder);
});
}, [ virtualFolder ]);
}, [ user, virtualFolder ]);
const showImageEditor = useCallback(() => {
setAnchorEl(null);
@@ -125,13 +123,11 @@ const LibraryCard = ({ virtualFolder }: LibraryCardProps) => {
itemId: virtualFolder.ItemId,
serverId: ServerConnections.currentApiClient()?.serverId()
}).then(() => {
void queryClient.invalidateQueries({
queryKey: ['VirtualFolders']
});
invalidateVirtualFolders(user);
}).catch(() => {
/* pop up closed */
});
}, [ virtualFolder ]);
}, [ user, virtualFolder ]);
const showDeleteLibraryDialog = useCallback(() => {
setAnchorEl(null);
@@ -27,6 +27,7 @@ export const useServerLog = (name: string, refetchInterval: false | number) => {
queryKey: ['ServerLog', name],
queryFn: ({ signal }) => fetchServerLog(api!, name, { signal }),
enabled: !!api,
refetchInterval
refetchInterval,
staleTime: 0 // ensure we load the latest log data
});
};
@@ -16,6 +16,7 @@ export const useServerLogs = () => {
return useQuery({
queryKey: [ 'ServerLogs' ],
queryFn: ({ signal }) => fetchServerLogs(api!, { signal }),
enabled: !!api
enabled: !!api,
staleTime: 0 // ensure we load the latest log entries
});
};
@@ -1,22 +1,17 @@
import type { SessionInfoDto } from '@jellyfin/sdk/lib/generated-client/models/session-info-dto';
import { SessionMessageType } from '@jellyfin/sdk/lib/generated-client/models/session-message-type';
import { useApi } from 'hooks/useApi';
import { ApiClient } from 'jellyfin-apiclient';
import { useCallback, useEffect } from 'react';
import serverNotifications from 'scripts/serverNotifications';
import Events, { Event } from 'utils/events';
import { QUERY_KEY, useSessions } from '../api/useSessions';
import { queryClient } from 'utils/query/queryClient';
import filterSessions from '../utils/filterSessions';
import { OutboundWebSocketMessageType } from '@jellyfin/sdk/lib/websocket';
const QUERY_PARAMS = {
activeWithinSeconds: 960
};
const FALLBACK_POLL_INTERVAL_MS = 2000;
const useLiveSessions = () => {
const { __legacyApiClient__ } = useApi();
const { api } = useApi();
const sessionsQuery = useSessions(QUERY_PARAMS);
@@ -41,27 +36,11 @@ const useLiveSessions = () => {
}, []);
useEffect(() => {
const onSessionsUpdate = (evt: Event, apiClient: ApiClient, info: SessionInfoDto[]) => {
queryClient.setQueryData([ QUERY_KEY, QUERY_PARAMS ], updateSessions(info));
};
const fallbackInterval = setInterval(() => {
if (!__legacyApiClient__?.isMessageChannelOpen()) {
void queryClient.invalidateQueries({
queryKey: [ QUERY_KEY ]
});
}
}, FALLBACK_POLL_INTERVAL_MS);
__legacyApiClient__?.sendMessage(SessionMessageType.SessionsStart, '0,1500');
Events.on(serverNotifications, SessionMessageType.Sessions, onSessionsUpdate);
return () => {
clearInterval(fallbackInterval);
__legacyApiClient__?.sendMessage(SessionMessageType.SessionsStop, null);
Events.off(serverNotifications, SessionMessageType.Sessions, onSessionsUpdate);
};
}, []);
// Return function for unsubscribing
return api?.subscribe([OutboundWebSocketMessageType.Sessions], ({ Data }) => {
queryClient.setQueryData([ QUERY_KEY, QUERY_PARAMS ], updateSessions(Data ?? []));
});
}, [api, updateSessions]);
return sessionsQuery;
};
@@ -7,6 +7,10 @@ const getNowPlayingImageUrl = (item: BaseItemDto) => {
if (!item.ServerId) return null;
const apiClient = ServerConnections.getApiClient(item.ServerId);
if (!apiClient) {
console.error('[getNowPlayingImageUrl] No ApiClient instance available for serverId', item.ServerId);
return null;
}
/* Screen width is multiplied by 0.2, as the there is currently no way to get the width of
elements that aren't created yet. */
@@ -35,25 +35,28 @@ const getNowPlayingName = (session: SessionInfo): NowPlayingInfo => {
bottomText = nowPlayingItem.ProductionYear.toString();
}
if (nowPlayingItem.ImageTags?.Logo) {
imgUrl = ServerConnections.getApiClient(session.ServerId!).getScaledImageUrl(nowPlayingItem.Id!, {
tag: nowPlayingItem.ImageTags.Logo,
maxHeight: 24,
maxWidth: 130,
type: 'Logo'
});
} else if (nowPlayingItem.ParentLogoImageTag) {
imgUrl = ServerConnections.getApiClient(session.ServerId!).getScaledImageUrl(nowPlayingItem.ParentLogoItemId!, {
tag: nowPlayingItem.ParentLogoImageTag,
maxHeight: 24,
maxWidth: 130,
type: 'Logo'
});
const apiClient = ServerConnections.getApiClient(session.ServerId);
if (apiClient) {
if (nowPlayingItem.ImageTags?.Logo) {
imgUrl = apiClient.getScaledImageUrl(nowPlayingItem.Id!, {
tag: nowPlayingItem.ImageTags.Logo,
maxHeight: 24,
maxWidth: 130,
type: 'Logo'
});
} else if (nowPlayingItem.ParentLogoImageTag) {
imgUrl = apiClient.getScaledImageUrl(nowPlayingItem.ParentLogoItemId!, {
tag: nowPlayingItem.ParentLogoImageTag,
maxHeight: 24,
maxWidth: 130,
type: 'Logo'
});
}
}
return {
topText: topText,
bottomText: bottomText,
topText,
bottomText,
image: imgUrl
};
};
@@ -1,4 +1,5 @@
import type { FolderStorageDto } from '@jellyfin/sdk/lib/generated-client';
import Box from '@mui/material/Box';
import LinearProgress from '@mui/material/LinearProgress';
import ListItem from '@mui/material/ListItem';
import ListItemIcon from '@mui/material/ListItemIcon';
@@ -79,15 +80,29 @@ const StorageListItem: FC<StorageListItemProps> = ({
color={statusColor}
value={usedPercentage}
/>
<Typography
variant='body2'
color='textSecondary'
<Box
sx={{
textAlign: 'end'
display: 'flex',
flexWrap: 'wrap'
}}
>
{`${readableUsedSpace} / ${readableTotalSpace}`}
</Typography>
<Typography
variant='body2'
color='textSecondary'
>
{globalize.translate('OnFilesystem', folder?.DeviceId || '?')}
</Typography>
<Typography
variant='body2'
color='textSecondary'
sx={{
flexGrow: 1,
textAlign: 'end'
}}
>
{`${readableUsedSpace} / ${readableTotalSpace}`}
</Typography>
</Box>
</>
}
slots={{
@@ -43,7 +43,7 @@ const Task: FunctionComponent<TaskProps> = ({ task }: TaskProps) => {
<ListItemLink to={`/dashboard/tasks/${task.Id}`}>
<ListItemAvatar>
<Avatar sx={{ bgcolor: 'primary.main' }}>
<AccessTimeIcon sx={{ color: '#fff' }} />
<AccessTimeIcon sx={{ color: '#fff', fontSize: 24 }} />
</Avatar>
</ListItemAvatar>
<ListItemText
@@ -3,41 +3,17 @@ import { useApi } from 'hooks/useApi';
import { QUERY_KEY, useTasks } from '../api/useTasks';
import type { ScheduledTasksApiGetTasksRequest } from '@jellyfin/sdk/lib/generated-client/api/scheduled-tasks-api';
import { queryClient } from 'utils/query/queryClient';
import { ApiClient } from 'jellyfin-apiclient';
import type { TaskInfo } from '@jellyfin/sdk/lib/generated-client/models/task-info';
import Events, { Event } from 'utils/events';
import serverNotifications from 'scripts/serverNotifications';
import { SessionMessageType } from '@jellyfin/sdk/lib/generated-client/models/session-message-type';
const FALLBACK_POLL_INTERVAL_MS = 10000;
import { OutboundWebSocketMessageType } from '@jellyfin/sdk/lib/websocket';
const useLiveTasks = (params: ScheduledTasksApiGetTasksRequest) => {
const { __legacyApiClient__ } = useApi();
const { api } = useApi();
const tasksQuery = useTasks(params);
// TODO: Replace usage of the legacy apiclient when websocket support is added to the TS SDK.
useEffect(() => {
const onScheduledTasksUpdate = (_e: Event, _apiClient: ApiClient, info: TaskInfo[]) => {
queryClient.setQueryData([ QUERY_KEY ], info);
};
const fallbackInterval = setInterval(() => {
if (!__legacyApiClient__?.isMessageChannelOpen()) {
void queryClient.invalidateQueries({
queryKey: [ QUERY_KEY ]
});
}
}, FALLBACK_POLL_INTERVAL_MS);
__legacyApiClient__?.sendMessage(SessionMessageType.ScheduledTasksInfoStart, '1000,1000');
Events.on(serverNotifications, SessionMessageType.ScheduledTasksInfo, onScheduledTasksUpdate);
return () => {
clearInterval(fallbackInterval);
__legacyApiClient__?.sendMessage(SessionMessageType.ScheduledTasksInfoStop, null);
Events.off(serverNotifications, SessionMessageType.ScheduledTasksInfo, onScheduledTasksUpdate);
};
}, [ __legacyApiClient__ ]);
return api?.subscribe([OutboundWebSocketMessageType.ScheduledTasksInfo], ({ Data }) => {
queryClient.setQueryData([ QUERY_KEY ], Data ?? []);
});
}, [api]);
return tasksQuery;
};
@@ -1,11 +1,12 @@
import { Api } from '@jellyfin/sdk';
import { useQuery } from '@tanstack/react-query';
import { useApi } from 'hooks/useApi';
import { getSessionApi } from '@jellyfin/sdk/lib/utils/api/session-api';
import type { AxiosRequestConfig } from 'axios';
import { useApi } from 'hooks/useApi';
import { getAuthenticationApi } from 'utils/sdk/authentication-api';
const fetchAuthProviders = async (api: Api, options?: AxiosRequestConfig) => {
const response = await getSessionApi(api).getAuthProviders(options);
const response = await getAuthenticationApi(api).getAuthProviders(options);
return response.data;
};
@@ -1,11 +1,12 @@
import { Api } from '@jellyfin/sdk';
import { useQuery } from '@tanstack/react-query';
import { useApi } from 'hooks/useApi';
import { getSessionApi } from '@jellyfin/sdk/lib/utils/api/session-api';
import type { AxiosRequestConfig } from 'axios';
import { useApi } from 'hooks/useApi';
import { getAuthenticationApi } from 'utils/sdk/authentication-api';
const fetchPasswordResetProviders = async (api: Api, options?: AxiosRequestConfig) => {
const response = await getSessionApi(api).getPasswordResetProviders(options);
const response = await getAuthenticationApi(api).getPasswordResetProviders(options);
return response.data;
};
@@ -3,7 +3,7 @@ import { getUserApi } from '@jellyfin/sdk/lib/utils/api/user-api';
import { useMutation } from '@tanstack/react-query';
import { useApi } from 'hooks/useApi';
import { queryClient } from 'utils/query/queryClient';
import { QUERY_KEY } from './useUser';
import { QUERY_KEY } from 'hooks/api/useUser';
export const useUpdateUser = () => {
const { api } = useApi();
@@ -3,7 +3,7 @@ import { getUserApi } from '@jellyfin/sdk/lib/utils/api/user-api';
import { useMutation } from '@tanstack/react-query';
import { useApi } from 'hooks/useApi';
import { queryClient } from 'utils/query/queryClient';
import { QUERY_KEY } from './useUser';
import { QUERY_KEY } from 'hooks/api/useUser';
export const useUpdateUserPolicy = () => {
const { api } = useApi();
@@ -1,24 +0,0 @@
import { Api } from '@jellyfin/sdk';
import { UserApiGetUserByIdRequest } from '@jellyfin/sdk/lib/generated-client/api/user-api';
import { getUserApi } from '@jellyfin/sdk/lib/utils/api/user-api';
import { useQuery } from '@tanstack/react-query';
import { useApi } from 'hooks/useApi';
import type { AxiosRequestConfig } from 'axios';
export const QUERY_KEY = 'User';
const fetchUser = async (api: Api, params: UserApiGetUserByIdRequest, options?: AxiosRequestConfig) => {
const response = await getUserApi(api).getUserById(params, options);
return response.data;
};
export const useUser = (params?: UserApiGetUserByIdRequest) => {
const { api } = useApi();
return useQuery({
queryKey: [ QUERY_KEY, params?.userId ],
queryFn: ({ signal }) => fetchUser(api!, params!, { signal }),
enabled: !!api && !!params
});
};
@@ -304,8 +304,14 @@ const Access = ({ userId }: AccessProps) => {
key={Item.Id}
className='chkDevice'
itemId={Item.Id}
itemName={Item.CustomName || Item.Name}
itemAppName={Item.AppName}
itemName={
[
Item.CustomName || Item.Name,
Item.AppName
]
.filter(Boolean)
.join(' - ')
}
itemCheckedAttribute={Item.checkedAttribute}
/>
))}
+83 -21
View File
@@ -1,13 +1,11 @@
import parseISO from 'date-fns/parseISO';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import type { ActivityLogEntry } from '@jellyfin/sdk/lib/generated-client/models/activity-log-entry';
import { LogLevel } from '@jellyfin/sdk/lib/generated-client/models/log-level';
import { SortOrder } from '@jellyfin/sdk/lib/generated-client/models/sort-order';
import { useTheme } from '@mui/material/styles';
import ToggleButton from '@mui/material/ToggleButton';
import ToggleButtonGroup from '@mui/material/ToggleButtonGroup';
import { type MRT_ColumnDef, type MRT_Theme, useMaterialReactTable } from 'material-react-table';
import { useSearchParams } from 'react-router-dom';
import { type MRT_ColumnDef, type MRT_Theme, type MRT_ColumnFiltersState, type MRT_SortingState, useMaterialReactTable } from 'material-react-table';
import DateTimeCell from 'apps/dashboard/components/table/DateTimeCell';
import TablePage, { DEFAULT_TABLE_OPTIONS } from 'apps/dashboard/components/table/TablePage';
import { useLogEntries } from 'apps/dashboard/features/activity/api/useLogEntries';
@@ -18,10 +16,12 @@ import UserAvatarButton from 'apps/dashboard/components/UserAvatarButton';
import type { ActivityLogEntryCell } from 'apps/dashboard/features/activity/types/ActivityLogEntryCell';
import { type UsersRecords, useUsersDetails } from 'hooks/useUsers';
import globalize from 'lib/globalize';
import type { ActivityLogEntry } from '@jellyfin/sdk/lib/generated-client/models/activity-log-entry';
import { ActivityLogSortBy } from '@jellyfin/sdk/lib/generated-client/models/activity-log-sort-by';
import { toBoolean } from 'utils/string';
import { useSearchParams } from 'react-router-dom';
const DEFAULT_PAGE_SIZE = 25;
const VIEW_PARAM = 'useractivity';
const enum ActivityView {
All = 'All',
@@ -29,6 +29,8 @@ const enum ActivityView {
System = 'System'
}
const VIEW_PARAM = 'useractivity';
const getActivityView = (param: string | null) => {
if (param === null) return ActivityView.All;
if (toBoolean(param)) return ActivityView.User;
@@ -43,10 +45,12 @@ const getUserCell = (users: UsersRecords) => function UserCell({ row }: Activity
export const Component = () => {
const [ searchParams, setSearchParams ] = useSearchParams();
const [ columnFilters, setColumnFilters ] = useState<MRT_ColumnFiltersState>([]);
const [ activityView, setActivityView ] = useState(
getActivityView(searchParams.get(VIEW_PARAM)));
const [ sorting, setSorting ] = useState<MRT_SortingState>([{ id: 'Date', desc: true }]);
const [ pagination, setPagination ] = useState({
pageIndex: 0,
pageSize: DEFAULT_PAGE_SIZE
@@ -63,11 +67,49 @@ export const Component = () => {
const UserCell = getUserCell(users);
const activityParams = useMemo(() => ({
startIndex: pagination.pageIndex * pagination.pageSize,
limit: pagination.pageSize,
hasUserId: activityView !== ActivityView.All ? activityView === ActivityView.User : undefined
}), [activityView, pagination.pageIndex, pagination.pageSize]);
const activityParams = useMemo(() => {
const getFilter = (id: string) => columnFilters.find(f => f.id === id)?.value;
const sortFields: ActivityLogSortBy[] = [];
const sortOrders: SortOrder[] = [];
const mapSortField = (id: string): ActivityLogSortBy => {
switch (id) {
case 'Date': return ActivityLogSortBy.DateCreated;
case 'Severity': return ActivityLogSortBy.LogSeverity;
case 'Name': return ActivityLogSortBy.Name;
case 'Type': return ActivityLogSortBy.Type;
case 'Overview': return ActivityLogSortBy.ShortOverview;
case 'User': return ActivityLogSortBy.Username;
default: return ActivityLogSortBy.DateCreated;
}
};
if (sorting.length === 0) {
sortFields.push(ActivityLogSortBy.DateCreated);
sortOrders.push(SortOrder.Descending);
} else {
sorting.forEach(sort => {
sortFields.push(mapSortField(sort.id));
sortOrders.push(sort.desc ? SortOrder.Descending : SortOrder.Ascending);
});
}
return {
startIndex: pagination.pageIndex * pagination.pageSize,
limit: pagination.pageSize,
hasUserId: activityView !== ActivityView.All ? activityView === ActivityView.User : undefined,
name: getFilter('Name') as string || undefined,
type: getFilter('Type') as string || undefined,
shortOverview: getFilter('Overview') as string || undefined,
username: getFilter('User') as string || undefined,
severity: getFilter('Severity') as LogLevel || undefined,
minDate: (getFilter('Date') as string[] | undefined)?.[0] ?? undefined,
maxDate: (getFilter('Date') as string[] | undefined)?.[1] ?? undefined,
sortBy: sortFields,
sortOrder: sortOrders
};
}, [ pagination, columnFilters, sorting, activityView ]);
const {
data,
@@ -88,13 +130,13 @@ export const Component = () => {
id: 'User',
accessorFn: row => row.UserId && users[row.UserId]?.Name,
header: globalize.translate('LabelUser'),
size: 75,
size: 100,
Cell: UserCell,
enableResizing: false,
muiTableBodyCellProps: {
align: 'center'
},
filterVariant: 'multi-select',
filterVariant: 'select',
filterSelectOptions: userNames
}], [ activityView, userNames, users, UserCell ]);
@@ -105,7 +147,9 @@ export const Component = () => {
header: globalize.translate('LabelTime'),
size: 160,
Cell: DateTimeCell,
filterVariant: 'datetime-range'
filterVariant: 'datetime-range',
grow: true,
maxSize: 320
},
{
accessorKey: 'Severity',
@@ -116,26 +160,34 @@ export const Component = () => {
muiTableBodyCellProps: {
align: 'center'
},
filterVariant: 'multi-select',
filterSelectOptions: Object.values(LogLevel).map(level => globalize.translate(`LogLevel.${level}`))
filterVariant: 'select',
filterSelectOptions: Object.values(LogLevel).map(level => ({
text: globalize.translate(`LogLevel.${level}`),
value: level
}))
},
...userColumn,
{
accessorKey: 'Name',
header: globalize.translate('LabelName'),
size: 270
size: 270,
grow: true
},
{
id: 'Overview',
accessorFn: row => row.ShortOverview || row.Overview,
header: globalize.translate('LabelOverview'),
size: 170,
Cell: OverviewCell
Cell: OverviewCell,
grow: true,
maxSize: 220
},
{
accessorKey: 'Type',
header: globalize.translate('LabelType'),
size: 150
size: 150,
grow: true,
maxSize: 220
},
{
id: 'Actions',
@@ -183,13 +235,23 @@ export const Component = () => {
// State
initialState: {
density: 'compact'
density: 'compact',
showColumnFilters: false
},
state: {
isLoading,
pagination
columnFilters,
pagination,
sorting
},
manualFiltering: true,
manualSorting: true,
onColumnFiltersChange: setColumnFilters,
onSortingChange: setSorting,
enableMultiSort: true,
enableGlobalFilter: false,
// Server pagination
manualPagination: true,
onPaginationChange: setPagination,
+48 -2
View File
@@ -22,6 +22,9 @@ import { useRestoreBackup } from 'apps/dashboard/features/backups/api/useRestore
import RestoreProgressDialog from 'apps/dashboard/features/backups/components/RestoreProgressDialog';
import { useApi } from 'hooks/useApi';
import { getSystemApi } from '@jellyfin/sdk/lib/utils/api/system-api';
import useLiveTasks from 'apps/dashboard/features/tasks/hooks/useLiveTasks';
import { TaskState } from '@jellyfin/sdk/lib/generated-client/models/task-state';
import ConfirmDialog from 'components/ConfirmDialog';
export const Component = () => {
const { api } = useApi();
@@ -33,9 +36,16 @@ export const Component = () => {
const [ isErrorOccurred, setIsErrorOccurred ] = useState(false);
const [ isRestoreDialogOpen, setIsRestoreDialogOpen ] = useState(false);
const [ backupToRestore, setBackupToRestore ] = useState<BackupManifestDto | null>(null);
const [ isConfirmBackupOpen, setIsConfirmBackupOpen ] = useState(false);
const [ pendingBackupOptions, setPendingBackupOptions ] = useState<BackupOptionsDto | null>(null);
const createBackup = useCreateBackup();
const restoreBackup = useRestoreBackup();
const {
data: tasks,
isPending: isTasksPending
} = useLiveTasks({ isHidden: false });
const onCreateClick = useCallback(() => {
setIsCreateFormOpen(true);
}, []);
@@ -56,7 +66,11 @@ export const Component = () => {
setIsRestoreSuccess(false);
}, []);
const onBackupCreate = useCallback((backupOptions: BackupOptionsDto) => {
const onCancelBackupCreateDialog = useCallback(() => {
setIsConfirmBackupOpen(false);
}, []);
const startBackupProcess = useCallback((backupOptions: BackupOptionsDto) => {
setBackupInProgress(true);
setIsCreateFormOpen(false);
createBackup.mutate(backupOptions, {
@@ -69,6 +83,24 @@ export const Component = () => {
});
}, [ createBackup ]);
const startPendingBackup = useCallback(() => {
setIsConfirmBackupOpen(false);
if (pendingBackupOptions) {
startBackupProcess(pendingBackupOptions);
}
}, [ startBackupProcess, pendingBackupOptions ]);
const onBackupCreate = useCallback((backupOptions: BackupOptionsDto) => {
const isTasksRunning = tasks?.some(task => task?.State !== TaskState.Idle);
if (isTasksRunning) {
setPendingBackupOptions(backupOptions);
setIsCreateFormOpen(false);
setIsConfirmBackupOpen(true);
} else {
startBackupProcess(backupOptions);
}
}, [ startBackupProcess, tasks ]);
const promptRestore = useCallback((backup: BackupManifestDto) => {
setIsRestoreDialogOpen(true);
setBackupToRestore(backup);
@@ -110,7 +142,7 @@ export const Component = () => {
}
}, [api, restoreInProgress]);
if (isPending) {
if (isPending || isTasksPending) {
return <Loading />;
}
@@ -122,6 +154,20 @@ export const Component = () => {
>
<BackupProgressDialog open={backupInProgress} />
<RestoreProgressDialog open={restoreInProgress} />
<ConfirmDialog
open={isConfirmBackupOpen}
title={globalize.translate('HeaderBackupWarning')}
text={
globalize.translate('LabelBackupWarning')
+ '\n\n'
+ tasks?.filter(task => task.State !== TaskState.Idle)
.map(task => '- ' + task.Name).join('\n')
}
confirmButtonText={globalize.translate('Create')}
confirmButtonColor='warning'
onConfirm={startPendingBackup}
onCancel={onCancelBackupCreateDialog}
/>
<CreateBackupForm
open={isCreateFormOpen}
onClose={onCreateFormClose}
+6 -3
View File
@@ -33,7 +33,7 @@ const BrandingOption = {
};
export const action = async ({ request }: ActionFunctionArgs) => {
const api = ServerConnections.getCurrentApi();
const api = ServerConnections.getApi();
if (!api) throw new Error('No Api instance available');
const formData = await request.formData();
@@ -60,9 +60,12 @@ export const action = async ({ request }: ActionFunctionArgs) => {
};
};
export const loader = () => {
export const loader = async () => {
const api = ServerConnections.getApi();
if (!api) return {};
return queryClient.ensureQueryData(
getBrandingOptionsQuery(ServerConnections.getCurrentApi()));
getBrandingOptionsQuery(api));
};
export const Component = () => {
@@ -25,7 +25,7 @@ import type { MetadataConfiguration } from '@jellyfin/sdk/lib/generated-client/m
const CONFIG_KEY = 'metadata';
export const action = async ({ request }: ActionFunctionArgs) => {
const api = ServerConnections.getCurrentApi();
const api = ServerConnections.getApi();
if (!api) throw new Error('No Api instance available');
const formData = await request.formData();
@@ -14,12 +14,14 @@ import LibraryCard from 'apps/dashboard/features/libraries/components/LibraryCar
import Loading from 'components/loading/LoadingComponent';
import MediaLibraryCreator from 'components/mediaLibraryCreator/mediaLibraryCreator';
import getCollectionTypeOptions from 'apps/dashboard/features/libraries/utils/collectionTypeOptions';
import { queryClient } from 'utils/query/queryClient';
import RefreshIcon from '@mui/icons-material/Refresh';
import Add from '@mui/icons-material/Add';
import Alert from '@mui/material/Alert';
import { useApi } from 'hooks/useApi';
import { invalidateVirtualFolders } from 'apps/dashboard/features/libraries/api/invalidateVirtualFolders';
export const Component = () => {
const { user } = useApi();
const { data: virtualFolders, isPending: isVirtualFoldersPending, isError: isVirtualFoldersError } = useVirtualFolders();
const startTask = useStartTask();
const { data: tasks, isPending: isLiveTasksPending } = useLiveTasks({ isHidden: false });
@@ -35,13 +37,9 @@ export const Component = () => {
}) as Promise<boolean>;
void mediaLibraryCreator.then((hasChanges: boolean) => {
if (hasChanges) {
void queryClient.invalidateQueries({
queryKey: ['VirtualFolders']
});
}
if (hasChanges) invalidateVirtualFolders(user);
});
}, []);
}, [ user ]);
const onScanLibraries = useCallback(() => {
if (librariesTask?.Id) {
@@ -21,7 +21,7 @@ import { ActionData } from 'types/actionData';
import { queryClient } from 'utils/query/queryClient';
export const action = async ({ request }: ActionFunctionArgs) => {
const api = ServerConnections.getCurrentApi();
const api = ServerConnections.getApi();
if (!api) throw new Error('No Api instance available');
const formData = await request.formData();
+1 -1
View File
@@ -26,7 +26,7 @@ import type { XbmcMetadataOptions } from '@jellyfin/sdk/lib/generated-client/mod
const CONFIG_KEY = 'xbmcmetadata';
export const action = async ({ request }: ActionFunctionArgs) => {
const api = ServerConnections.getCurrentApi();
const api = ServerConnections.getApi();
if (!api) throw new Error('No Api instance available');
const formData = await request.formData();
@@ -28,7 +28,7 @@ import { ActionData } from 'types/actionData';
const CONFIG_KEY = 'livetv';
export const action = async ({ request }: ActionFunctionArgs) => {
const api = ServerConnections.getCurrentApi();
const api = ServerConnections.getApi();
if (!api) throw new Error('No Api instance available');
const data = await request.json() as LiveTvOptions;
+1 -1
View File
@@ -20,7 +20,7 @@ import { ActionData } from 'types/actionData';
import LogItemList from 'apps/dashboard/features/logs/components/LogItemList';
export const action = async ({ request }: ActionFunctionArgs) => {
const api = ServerConnections.getCurrentApi();
const api = ServerConnections.getApi();
if (!api) throw new Error('No Api instance available');
const formData = await request.formData();
@@ -31,7 +31,7 @@ import Switch from '@mui/material/Switch';
const CONFIG_KEY = 'network';
export const action = async ({ request }: ActionFunctionArgs) => {
const api = ServerConnections.getCurrentApi();
const api = ServerConnections.getApi();
if (!api) throw new Error('No Api instance available');
const formData = await request.formData();
@@ -16,7 +16,7 @@ import { getConfigurationApi } from '@jellyfin/sdk/lib/utils/api/configuration-a
import { queryClient } from 'utils/query/queryClient';
export const action = async ({ request }: ActionFunctionArgs) => {
const api = ServerConnections.getCurrentApi();
const api = ServerConnections.getApi();
if (!api) throw new Error('No Api instance available');
const { data: config } = await getConfigurationApi(api).getConfiguration();
@@ -16,7 +16,7 @@ import { ActionData } from 'types/actionData';
import { queryClient } from 'utils/query/queryClient';
export const action = async ({ request }: ActionFunctionArgs) => {
const api = ServerConnections.getCurrentApi();
const api = ServerConnections.getApi();
if (!api) throw new Error('No Api instance available');
const { data: config } = await getConfigurationApi(api).getConfiguration();
@@ -33,7 +33,7 @@ import SimpleAlert from 'components/SimpleAlert';
const CONFIG_KEY = 'encoding';
export const action = async ({ request }: ActionFunctionArgs) => {
const api = ServerConnections.getCurrentApi();
const api = ServerConnections.getApi();
if (!api) throw new Error('No Api instance available');
const data = await request.json() as EncodingOptions;
@@ -24,7 +24,7 @@ import { ActionData } from 'types/actionData';
import { queryClient } from 'utils/query/queryClient';
export const action = async ({ request }: ActionFunctionArgs) => {
const api = ServerConnections.getCurrentApi();
const api = ServerConnections.getApi();
if (!api) throw new Error('No Api instance available');
const formData = await request.formData();
+7 -3
View File
@@ -8,7 +8,8 @@ import Typography from '@mui/material/Typography';
import { useLocalizationOptions } from 'apps/dashboard/features/settings/api/useLocalizationOptions';
import Loading from 'components/loading/LoadingComponent';
import Page from 'components/Page';
import { QUERY_KEY, useConfiguration } from 'hooks/useConfiguration';
import { QUERY_KEY as CONFIGURATION_QUERY_KEY, useConfiguration } from 'hooks/useConfiguration';
import { QUERY_KEY as SYSTEM_INFO_QUERY_KEY } from 'hooks/useSystemInfo';
import globalize from 'lib/globalize';
import { ServerConnections } from 'lib/jellyfin-apiclient';
import React, { useCallback, useEffect, useState } from 'react';
@@ -26,7 +27,7 @@ import { queryClient } from 'utils/query/queryClient';
import { ActionData } from 'types/actionData';
export const action = async ({ request }: ActionFunctionArgs) => {
const api = ServerConnections.getCurrentApi();
const api = ServerConnections.getApi();
if (!api) throw new Error('No Api instance available');
const { data: config } = await getConfigurationApi(api).getConfiguration();
@@ -44,7 +45,10 @@ export const action = async ({ request }: ActionFunctionArgs) => {
.updateConfiguration({ serverConfiguration: config });
void queryClient.invalidateQueries({
queryKey: [ QUERY_KEY ]
queryKey: [ CONFIGURATION_QUERY_KEY ]
});
void queryClient.invalidateQueries({
queryKey: [ SYSTEM_INFO_QUERY_KEY ]
});
return {
+2 -2
View File
@@ -1,5 +1,5 @@
import React, { useCallback } from 'react';
import { useUser } from 'apps/dashboard/features/users/api/useUser';
import { useUser } from 'hooks/api/useUser';
import Loading from 'components/loading/LoadingComponent';
import Page from 'components/Page';
import { useNavigate, useParams } from 'react-router-dom';
@@ -19,7 +19,7 @@ import Alert from '@mui/material/Alert';
export const Component = () => {
const navigate = useNavigate();
const { userId, tab } = useParams();
const { data: user, isPending, isError } = useUser(userId ? { userId: userId } : undefined);
const { data: user, isPending, isError } = useUser({ userId });
const handleTabChange = useCallback((event: React.SyntheticEvent, newValue: UserTab) => {
navigate(`/dashboard/users/${userId}/${newValue}`);
+1 -1
View File
@@ -28,7 +28,7 @@ const UserProfiles = () => {
const [ isSettingsSavedToastOpen, setIsSettingsSavedToastOpen ] = useState(false);
const element = useRef<HTMLDivElement>(null);
const navigate = useNavigate();
const { data: users, isPending } = useUsers();
const { data: users, isPending } = useUsers(undefined, { forceRefresh: true });
const deleteUser = useDeleteUser();
const handleToastClose = useCallback(() => {
@@ -1,51 +0,0 @@
import type { BaseItemDto } from '@jellyfin/sdk/lib/generated-client';
import ListItemIcon from '@mui/material/ListItemIcon/ListItemIcon';
import ListItemText from '@mui/material/ListItemText/ListItemText';
import Menu, { type MenuProps } from '@mui/material/Menu/Menu';
import MenuItem from '@mui/material/MenuItem/MenuItem';
import React, { FC } from 'react';
import { Link } from 'react-router-dom';
import LibraryIcon from 'apps/experimental/components/LibraryIcon';
import { appRouter } from 'components/router/appRouter';
interface UserViewsMenuProps extends MenuProps {
userViews: BaseItemDto[]
selectedId?: string
includeGlobalViews?: boolean
onMenuClose: () => void
}
const UserViewsMenu: FC<UserViewsMenuProps> = ({
userViews,
selectedId,
onMenuClose,
...props
}) => {
return (
<Menu
{...props}
keepMounted
onClose={onMenuClose}
>
{userViews.map(view => (
<MenuItem
key={view.Id}
component={Link}
to={appRouter.getRouteUrl(view, { context: view.CollectionType }).substring(1)}
onClick={onMenuClose}
selected={view.Id === selectedId}
>
<ListItemIcon>
<LibraryIcon item={view} />
</ListItemIcon>
<ListItemText>
{view.Name}
</ListItemText>
</MenuItem>
))}
</Menu>
);
};
export default UserViewsMenu;
@@ -1,420 +0,0 @@
import type { BaseItemKind } from '@jellyfin/sdk/lib/generated-client/models/base-item-kind';
import { CollectionType } from '@jellyfin/sdk/lib/generated-client/models/collection-type';
import { ImageType } from '@jellyfin/sdk/lib/generated-client/models/image-type';
import { ItemSortBy } from '@jellyfin/sdk/lib/generated-client/models/item-sort-by';
import Box from '@mui/material/Box';
import ButtonGroup from '@mui/material/ButtonGroup';
import type { Theme } from '@mui/material/styles';
import useMediaQuery from '@mui/material/useMediaQuery';
import classNames from 'classnames';
import React, { type FC, useCallback } from 'react';
import { CardShape } from 'components/cardbuilder/utils/shape';
import { ItemAction } from 'constants/itemAction';
import { useApi } from 'hooks/useApi';
import { useLocalStorage } from 'hooks/useLocalStorage';
import { useGetItemsViewByType } from 'hooks/useFetchItems';
import { getDefaultLibraryViewSettings, getSettingsKey } from 'utils/items';
import Loading from 'components/loading/LoadingComponent';
import { playbackManager } from 'components/playback/playbackmanager';
import ItemsContainer from 'elements/emby-itemscontainer/ItemsContainer';
import NoItemsMessage from 'components/common/NoItemsMessage';
import Lists from 'components/listview/List/Lists';
import Cards from 'components/cardbuilder/Card/Cards';
import { LibraryTab } from 'types/libraryTab';
import { type LibraryViewSettings, type ParentId, ViewMode } from 'types/library';
import type { CardOptions } from 'types/cardOptions';
import type { ListOptions } from 'types/listOptions';
import { useItem } from 'hooks/useItem';
import AlphabetPicker from './AlphabetPicker';
import FilterButton from './filter/FilterButton';
import NewCollectionButton from './NewCollectionButton';
import NewPlaylistButton from './NewPlaylistButton';
import Pagination from './Pagination';
import PlayAllButton from './PlayAllButton';
import QueueButton from './QueueButton';
import ShuffleButton from './ShuffleButton';
import SortButton from './SortButton';
import LibraryViewMenu from './LibraryViewMenu';
import ViewSettingsButton from './ViewSettingsButton';
interface ItemsViewProps {
viewType: LibraryTab;
parentId: ParentId;
itemType: BaseItemKind[];
collectionType?: CollectionType;
isPaginationEnabled?: boolean;
isBtnPlayAllEnabled?: boolean;
isBtnQueueEnabled?: boolean;
isBtnShuffleEnabled?: boolean;
isBtnSortEnabled?: boolean;
isBtnFilterEnabled?: boolean;
isBtnNewCollectionEnabled?: boolean;
isBtnNewPlaylistEnabled?: boolean;
isBtnGridListEnabled?: boolean;
isAlphabetPickerEnabled?: boolean;
noItemsMessage: string;
}
const ItemsView: FC<ItemsViewProps> = ({
viewType,
parentId,
collectionType,
isPaginationEnabled = true,
isBtnPlayAllEnabled = false,
isBtnQueueEnabled = false,
isBtnShuffleEnabled = false,
isBtnSortEnabled = true,
isBtnFilterEnabled = true,
isBtnNewCollectionEnabled = false,
isBtnNewPlaylistEnabled = false,
isBtnGridListEnabled = true,
isAlphabetPickerEnabled = true,
itemType,
noItemsMessage
}) => {
const [libraryViewSettings, setLibraryViewSettings] =
useLocalStorage<LibraryViewSettings>(
getSettingsKey(viewType, parentId),
getDefaultLibraryViewSettings(viewType)
);
const isSmallScreen = useMediaQuery((t: Theme) => t.breakpoints.up('sm'));
const { __legacyApiClient__ } = useApi();
const {
isPending,
data: itemsResult,
isPlaceholderData,
refetch
} = useGetItemsViewByType(
viewType,
parentId,
itemType,
libraryViewSettings
);
const { data: item } = useItem(parentId || undefined);
const getListOptions = useCallback(() => {
const listOptions: ListOptions = {
items: itemsResult?.Items ?? [],
context: collectionType
};
if (viewType === LibraryTab.Songs) {
listOptions.showParentTitle = true;
listOptions.action = ItemAction.PlayAllFromHere;
listOptions.smallIcon = true;
listOptions.showArtist = true;
listOptions.addToListButton = true;
} else if (viewType === LibraryTab.Albums) {
listOptions.sortBy = libraryViewSettings.SortBy;
listOptions.addToListButton = true;
} else if (viewType === LibraryTab.Episodes) {
listOptions.showParentTitle = true;
}
return listOptions;
}, [itemsResult?.Items, collectionType, viewType, libraryViewSettings.SortBy]);
const getCardOptions = useCallback(() => {
let shape;
let preferThumb;
let preferDisc;
let preferLogo;
if (libraryViewSettings.ImageType === ImageType.Banner) {
shape = CardShape.Banner;
} else if (libraryViewSettings.ImageType === ImageType.Disc) {
shape = CardShape.Square;
preferDisc = true;
} else if (libraryViewSettings.ImageType === ImageType.Logo) {
shape = CardShape.Backdrop;
preferLogo = true;
} else if (libraryViewSettings.ImageType === ImageType.Thumb) {
shape = CardShape.Backdrop;
preferThumb = true;
} else {
shape = CardShape.Auto;
}
const cardOptions: CardOptions = {
shape: shape,
showTitle: libraryViewSettings.ShowTitle,
showYear: libraryViewSettings.ShowYear,
cardLayout: libraryViewSettings.CardLayout,
centerText: true,
context: collectionType,
coverImage: true,
preferThumb: preferThumb,
preferDisc: preferDisc,
preferLogo: preferLogo,
overlayText: !libraryViewSettings.ShowTitle,
imageType: libraryViewSettings.ImageType,
queryKey: ['ItemsViewByType'],
serverId: __legacyApiClient__?.serverId()
};
if (
viewType === LibraryTab.Songs
|| viewType === LibraryTab.Albums
|| viewType === LibraryTab.Episodes
) {
cardOptions.showParentTitle = libraryViewSettings.ShowTitle;
cardOptions.overlayPlayButton = true;
} else if (viewType === LibraryTab.Artists) {
cardOptions.lines = 1;
cardOptions.showYear = false;
cardOptions.overlayPlayButton = true;
} else if (viewType === LibraryTab.Channels) {
cardOptions.shape = CardShape.Square;
cardOptions.showDetailsMenu = true;
cardOptions.showCurrentProgram = true;
cardOptions.showCurrentProgramTime = true;
} else if (viewType === LibraryTab.SeriesTimers) {
cardOptions.shape = CardShape.Backdrop;
cardOptions.showSeriesTimerTime = true;
cardOptions.showSeriesTimerChannel = true;
cardOptions.overlayMoreButton = true;
cardOptions.lines = 3;
} else if (viewType === LibraryTab.Movies) {
cardOptions.overlayPlayButton = true;
} else if (viewType === LibraryTab.Series || viewType === LibraryTab.Networks) {
cardOptions.overlayMoreButton = true;
}
return cardOptions;
}, [
libraryViewSettings.ShowTitle,
libraryViewSettings.ImageType,
libraryViewSettings.ShowYear,
libraryViewSettings.CardLayout,
collectionType,
viewType
]);
const getItems = useCallback(() => {
if (!itemsResult?.Items?.length) {
return <NoItemsMessage message={noItemsMessage} />;
}
if (libraryViewSettings.ViewMode === ViewMode.ListView) {
return (
<Lists
items={itemsResult?.Items ?? []}
listOptions={getListOptions()}
/>
);
}
return (
<Cards
items={itemsResult?.Items ?? []}
cardOptions={getCardOptions()}
/>
);
}, [
libraryViewSettings.ViewMode,
itemsResult?.Items,
getListOptions,
getCardOptions,
noItemsMessage
]);
const totalRecordCount = itemsResult?.TotalRecordCount ?? 0;
const items = itemsResult?.Items ?? [];
const hasFilters = Object.values(libraryViewSettings.Filters ?? {}).some(
(filter) => !!filter
);
const hasSortName = libraryViewSettings.SortBy !== ItemSortBy.Random;
const itemsContainerClass = classNames(
'padded-left padded-right padded-right-withalphapicker',
libraryViewSettings.ViewMode === ViewMode.ListView ?
'vertical-list' :
'vertical-wrap'
);
return (
<Box className='padded-bottom-page'>
<Box
className={classNames(
'padded-top padded-left padded-right',
{ 'padded-right-withalphapicker': isAlphabetPickerEnabled }
)}
sx={{
display: 'flex',
flexWrap: 'wrap',
alignItems: 'center'
}}
>
<Box
sx={{ marginRight: 1 }}
>
<LibraryViewMenu />
</Box>
<Box
sx={{
flexGrow: {
xs: 1,
sm: 0
},
marginRight: 1
}}
>
<ButtonGroup
color='inherit'
variant='text'
>
{isBtnFilterEnabled && (
<FilterButton
parentId={parentId}
itemType={itemType}
viewType={viewType}
hasFilters={hasFilters}
libraryViewSettings={libraryViewSettings}
setLibraryViewSettings={setLibraryViewSettings}
/>
)}
{isBtnSortEnabled && (
<SortButton
viewType={viewType}
libraryViewSettings={libraryViewSettings}
setLibraryViewSettings={setLibraryViewSettings}
/>
)}
{isBtnGridListEnabled && (
<ViewSettingsButton
viewType={viewType}
libraryViewSettings={libraryViewSettings}
setLibraryViewSettings={setLibraryViewSettings}
/>
)}
</ButtonGroup>
</Box>
<Box
sx={{
display: 'flex',
flexGrow: {
xs: 1,
sm: 0
},
justifyContent: 'flex-end'
}}
>
{!isPending && (
<>
<ButtonGroup
variant='contained'
>
{isBtnPlayAllEnabled && (
<PlayAllButton
item={item}
items={items}
viewType={viewType}
hasFilters={hasFilters}
isTextVisible={isSmallScreen}
libraryViewSettings={libraryViewSettings}
/>
)}
{isBtnShuffleEnabled && totalRecordCount > 1 && (
<ShuffleButton
item={item}
items={items}
viewType={viewType}
hasFilters={hasFilters}
isTextVisible={isSmallScreen && !isBtnPlayAllEnabled}
libraryViewSettings={libraryViewSettings}
/>
)}
{isBtnQueueEnabled && item && playbackManager.canQueue(item) && (
<QueueButton
item={item}
items={items}
hasFilters={hasFilters}
isTextVisible={isSmallScreen && !isBtnPlayAllEnabled && !isBtnShuffleEnabled}
/>
)}
</ButtonGroup>
{isBtnNewCollectionEnabled && <NewCollectionButton isTextVisible={isSmallScreen} />}
{isBtnNewPlaylistEnabled && <NewPlaylistButton isTextVisible={isSmallScreen} />}
</>
)}
</Box>
<Box
sx={{
display: 'flex',
justifyContent: 'end',
flexBasis: {
xs: '100%',
sm: 'auto'
},
flexGrow: 1,
marginTop: {
xs: 0.5,
sm: 0
}
}}
>
{!isPending && isPaginationEnabled && (
<Pagination
totalRecordCount={totalRecordCount}
libraryViewSettings={libraryViewSettings}
isPlaceholderData={isPlaceholderData}
setLibraryViewSettings={setLibraryViewSettings}
/>
)}
</Box>
</Box>
{isAlphabetPickerEnabled && hasSortName && (
<AlphabetPicker
libraryViewSettings={libraryViewSettings}
setLibraryViewSettings={setLibraryViewSettings}
/>
)}
{isPending ? (
<Loading />
) : (
<ItemsContainer
className={itemsContainerClass}
parentId={parentId}
reloadItems={refetch}
queryKey={['ItemsViewByType']}
>
{getItems()}
</ItemsContainer>
)}
{!isPending && isPaginationEnabled && (
<Box
className={classNames(
'padded-left padded-right',
{ 'padded-right-withalphapicker': isAlphabetPickerEnabled }
)}
sx={{
display: 'flex',
justifyContent: 'flex-end'
}}
>
<Pagination
totalRecordCount={totalRecordCount}
libraryViewSettings={libraryViewSettings}
isPlaceholderData={isPlaceholderData}
setLibraryViewSettings={setLibraryViewSettings}
/>
</Box>
)}
</Box>
);
};
export default ItemsView;
@@ -1,127 +0,0 @@
import Box from '@mui/material/Box/Box';
import React, { type FC } from 'react';
import SuggestionsSectionView from './SuggestionsSectionView';
import UpcomingView from './UpcomingView';
import GenresView from './GenresView';
import ItemsView from './ItemsView';
import GuideView from './GuideView';
import ProgramsSectionView from './ProgramsSectionView';
import { LibraryTab } from 'types/libraryTab';
import type { ParentId } from 'types/library';
import type { LibraryTabContent } from 'types/libraryTabContent';
import LibraryViewMenu from './LibraryViewMenu';
interface PageTabContentProps {
parentId: ParentId;
currentTab: LibraryTabContent;
}
const PageTabContent: FC<PageTabContentProps> = ({ parentId, currentTab }) => {
if (currentTab.viewType === LibraryTab.Suggestions) {
return (
<>
<Box className='padded-top padded-left padded-right padded-bottom'>
<LibraryViewMenu />
</Box>
<SuggestionsSectionView
parentId={parentId}
sectionType={
currentTab.sectionsView?.suggestionSections ?? []
}
isMovieRecommendationEnabled={
currentTab.sectionsView?.isMovieRecommendations
}
/>
</>
);
}
if (currentTab.viewType === LibraryTab.Programs || currentTab.viewType === LibraryTab.Recordings || currentTab.viewType === LibraryTab.Schedule) {
return (
<>
<Box className='padded-top padded-left padded-right padded-bottom'>
<LibraryViewMenu />
</Box>
<ProgramsSectionView
parentId={parentId}
sectionType={
currentTab.sectionsView?.programSections ?? []
}
isUpcomingRecordingsEnabled={currentTab.sectionsView?.isLiveTvUpcomingRecordings}
/>
</>
);
}
if (currentTab.viewType === LibraryTab.Upcoming) {
return (
<>
<Box className='padded-top padded-left padded-right padded-bottom'>
<LibraryViewMenu />
</Box>
<UpcomingView parentId={parentId} />
</>
);
}
if (currentTab.viewType === LibraryTab.Genres) {
return (
<>
<Box className='padded-top padded-left padded-right padded-bottom'>
<LibraryViewMenu />
</Box>
<GenresView
parentId={parentId}
collectionType={currentTab.collectionType}
itemType={currentTab.itemType || []}
/>
</>
);
}
if (currentTab.viewType === LibraryTab.Guide) {
return (
<>
<Box
className='padded-top padded-left padded-right padded-bottom'
sx={{
position: 'relative',
zIndex: 2
}}
>
<LibraryViewMenu />
</Box>
<GuideView />
</>
);
}
return (
<ItemsView
viewType={currentTab.viewType}
parentId={parentId}
collectionType={currentTab.collectionType}
isPaginationEnabled={currentTab.isPaginationEnabled}
isBtnPlayAllEnabled={currentTab.isBtnPlayAllEnabled}
isBtnQueueEnabled={currentTab.isBtnQueueEnabled}
isBtnShuffleEnabled={currentTab.isBtnShuffleEnabled}
isBtnNewCollectionEnabled={currentTab.isBtnNewCollectionEnabled}
isBtnNewPlaylistEnabled={currentTab.isBtnNewPlaylistEnabled}
isBtnFilterEnabled={currentTab.isBtnFilterEnabled}
isBtnGridListEnabled={currentTab.isBtnGridListEnabled}
isBtnSortEnabled={currentTab.isBtnSortEnabled}
isAlphabetPickerEnabled={currentTab.isAlphabetPickerEnabled}
itemType={currentTab.itemType || []}
noItemsMessage={
currentTab.noItemsMessage || 'MessageNoItemsAvailable'
}
/>
);
};
export default PageTabContent;
@@ -1,141 +0,0 @@
import React, { FC, useCallback } from 'react';
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import ArrowForwardIcon from '@mui/icons-material/ArrowForward';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import ButtonGroup from '@mui/material/ButtonGroup';
import Stack from '@mui/material/Stack';
import type { Theme } from '@mui/material/styles';
import Typography from '@mui/material/Typography';
import useMediaQuery from '@mui/material/useMediaQuery';
import globalize from 'lib/globalize';
import * as userSettings from 'scripts/settings/userSettings';
import { LibraryViewSettings } from 'types/library';
interface PaginationProps {
libraryViewSettings: LibraryViewSettings;
setLibraryViewSettings: React.Dispatch<React.SetStateAction<LibraryViewSettings>>;
totalRecordCount: number;
isPlaceholderData: boolean
}
const Pagination: FC<PaginationProps> = ({
libraryViewSettings,
setLibraryViewSettings,
totalRecordCount,
isPlaceholderData
}) => {
const isSmallScreen = useMediaQuery((t: Theme) => t.breakpoints.up('sm'));
const limit = userSettings.libraryPageSize(undefined);
const startIndex = libraryViewSettings.StartIndex ?? 0;
const recordsStart = totalRecordCount ? startIndex + 1 : 0;
const recordsEnd = limit ?
Math.min(startIndex + limit, totalRecordCount) :
totalRecordCount;
const showControls = limit > 0 && limit < totalRecordCount;
const onNextPageClick = useCallback(() => {
const newIndex = startIndex + limit;
setLibraryViewSettings((prevState) => ({
...prevState,
StartIndex: newIndex
}));
}, [limit, setLibraryViewSettings, startIndex]);
const onPreviousPageClick = useCallback(() => {
const newIndex = Math.max(0, startIndex - limit);
setLibraryViewSettings((prevState) => ({
...prevState,
StartIndex: newIndex
}));
}, [limit, setLibraryViewSettings, startIndex]);
return (
<Stack
direction='row'
spacing={0.5}
sx={{
alignItems: 'center',
flexGrow: {
xs: 1,
sm: 0
},
marginLeft: {
xs: 0,
sm: 0.5
}
}}
>
{!isSmallScreen && (
<Button
color='inherit'
variant='text'
title={globalize.translate('Previous')}
disabled={!showControls || startIndex == 0 || isPlaceholderData}
onClick={onPreviousPageClick}
>
<ArrowBackIcon />
</Button>
)}
<Box
sx={{
display: 'flex',
flexGrow: 1,
alignItems: 'center',
justifyContent: 'center',
marginLeft: 1,
marginRight: 1
}}
>
<Typography variant='body2'>
{globalize.translate(
'ListPaging',
recordsStart,
recordsEnd,
totalRecordCount
)}
</Typography>
</Box>
{isSmallScreen && (
<ButtonGroup
color='inherit'
variant='text'
>
<Button
title={globalize.translate('Previous')}
disabled={!showControls || startIndex == 0 || isPlaceholderData}
onClick={onPreviousPageClick}
>
<ArrowBackIcon />
</Button>
<Button
title={globalize.translate('Next')}
disabled={!showControls || startIndex + limit >= totalRecordCount || isPlaceholderData }
onClick={onNextPageClick}
>
<ArrowForwardIcon />
</Button>
</ButtonGroup>
)}
{!isSmallScreen && (
<Button
color='inherit'
variant='text'
title={globalize.translate('Next')}
disabled={!showControls || startIndex + limit >= totalRecordCount || isPlaceholderData }
onClick={onNextPageClick}
>
<ArrowForwardIcon />
</Button>
)}
</Stack>
);
};
export default Pagination;
@@ -1,228 +0,0 @@
import { ImageType } from '@jellyfin/sdk/lib/generated-client/models/image-type';
import React, { FC, useCallback } from 'react';
import Check from '@mui/icons-material/Check';
import MoreVert from '@mui/icons-material/MoreVert';
import Button from '@mui/material/Button';
import Checkbox from '@mui/material/Checkbox';
import Divider from '@mui/material/Divider';
import FormControl from '@mui/material/FormControl';
import FormControlLabel from '@mui/material/FormControlLabel';
import FormGroup from '@mui/material/FormGroup';
import InputLabel from '@mui/material/InputLabel';
import ListItemIcon from '@mui/material/ListItemIcon';
import ListItemText from '@mui/material/ListItemText';
import MenuItem from '@mui/material/MenuItem';
import MenuList from '@mui/material/MenuList';
import Popover from '@mui/material/Popover';
import Select, { SelectChangeEvent } from '@mui/material/Select';
import Typography from '@mui/material/Typography';
import globalize from 'lib/globalize';
import { LibraryViewSettings, ViewMode } from 'types/library';
import { LibraryTab } from 'types/libraryTab';
const IMAGE_TYPE_EXCLUDED_VIEWS = [
LibraryTab.Episodes,
LibraryTab.Artists,
LibraryTab.AlbumArtists,
LibraryTab.Albums
];
const imageTypesOptions = [
{ label: 'Primary', value: ImageType.Primary },
{ label: 'Banner', value: ImageType.Banner },
{ label: 'Disc', value: ImageType.Disc },
{ label: 'Logo', value: ImageType.Logo },
{ label: 'Thumb', value: ImageType.Thumb }
];
interface ViewSettingsButtonProps {
viewType: LibraryTab;
libraryViewSettings: LibraryViewSettings;
setLibraryViewSettings: React.Dispatch<
React.SetStateAction<LibraryViewSettings>
>;
}
const ViewSettingsButton: FC<ViewSettingsButtonProps> = ({
viewType,
libraryViewSettings,
setLibraryViewSettings
}) => {
const [anchorEl, setAnchorEl] = React.useState<null | HTMLElement>(null);
const open = Boolean(anchorEl);
const id = open ? 'selectview-popover' : undefined;
const handleClick = useCallback((event: React.MouseEvent<HTMLElement>) => {
setAnchorEl(event.currentTarget);
}, []);
const handleClose = useCallback(() => {
setAnchorEl(null);
}, []);
const handleChange = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
const name = event.target.name;
setLibraryViewSettings((prevState) => ({
...prevState,
[name]: event.target.checked
}));
},
[setLibraryViewSettings]
);
const onGridViewClick = useCallback(() => {
setLibraryViewSettings(prevState => ({
...prevState,
ViewMode: ViewMode.GridView
}));
}, [ setLibraryViewSettings ]);
const onListViewClick = useCallback(() => {
setLibraryViewSettings(prevState => ({
...prevState,
ViewMode: ViewMode.ListView
}));
}, [ setLibraryViewSettings ]);
const onSelectChange = useCallback(
(event: SelectChangeEvent) => {
setLibraryViewSettings((prevState) => ({
...prevState,
ImageType: event.target.value as ImageType
}));
},
[setLibraryViewSettings]
);
const isGridView = libraryViewSettings.ViewMode === ViewMode.GridView;
const isImageTypeVisible = !IMAGE_TYPE_EXCLUDED_VIEWS.includes(viewType);
return (
<>
<Button
title={globalize.translate('ViewSettings')}
aria-describedby={id}
onClick={handleClick}
>
<MoreVert />
</Button>
<Popover
id={id}
open={open}
anchorEl={anchorEl}
onClose={handleClose}
anchorOrigin={{
vertical: 'bottom',
horizontal: 'center'
}}
transformOrigin={{
vertical: 'top',
horizontal: 'center'
}}
sx={{
'& .MuiFormControl-root': { m: 1, width: 220 }
}}
>
<MenuList>
<MenuItem
onClick={onGridViewClick}
>
{isGridView && (
<ListItemIcon><Check /></ListItemIcon>
)}
<ListItemText inset={!isGridView}>
{globalize.translate('GridView')}
</ListItemText>
</MenuItem>
<MenuItem
onClick={onListViewClick}
>
{!isGridView && (
<ListItemIcon><Check /></ListItemIcon>
)}
<ListItemText inset={isGridView}>
{globalize.translate('ListView')}
</ListItemText>
</MenuItem>
{isGridView && (
<>
<Divider />
{isImageTypeVisible && (
<>
<FormControl>
<InputLabel>
<Typography component='span'>
{globalize.translate('LabelImageType')}
</Typography>
</InputLabel>
<Select
value={libraryViewSettings.ImageType}
label={globalize.translate('LabelImageType')}
onChange={onSelectChange}
>
{imageTypesOptions.map((imageType) => (
<MenuItem
key={imageType.value}
value={imageType.value}
>
<Typography component='span'>
{globalize.translate(imageType.label)}
</Typography>
</MenuItem>
))}
</Select>
</FormControl>
<Divider />
</>
)}
<FormControl>
<FormGroup>
<FormControlLabel
control={
<Checkbox
checked={libraryViewSettings.ShowTitle}
onChange={handleChange}
name='ShowTitle'
/>
}
label={globalize.translate('ShowTitle')}
/>
{isImageTypeVisible && (
<FormControlLabel
control={
<Checkbox
checked={libraryViewSettings.ShowYear}
onChange={handleChange}
name='ShowYear'
/>
}
label={globalize.translate('ShowYear')}
/>
)}
<FormControlLabel
control={
<Checkbox
checked={libraryViewSettings.CardLayout}
onChange={handleChange}
name='CardLayout'
/>
}
label={globalize.translate('EnableCardLayout')}
/>
</FormGroup>
</FormControl>
</>
)}
</MenuList>
</Popover>
</>
);
};
export default ViewSettingsButton;
@@ -1,18 +0,0 @@
import { AsyncRoute } from 'components/router/AsyncRoute';
import { AppType } from 'constants/appType';
export const ASYNC_USER_ROUTES: AsyncRoute[] = [
{ path: 'home', type: AppType.Experimental },
{ path: 'homevideos', type: AppType.Experimental },
{ path: 'livetv', type: AppType.Experimental },
{ path: 'movies', type: AppType.Experimental },
{ path: 'music', type: AppType.Experimental },
{ path: 'books', type: AppType.Experimental },
{ path: 'musicvideos', type: AppType.Experimental },
{ path: 'mypreferencesdisplay', page: 'user/display', type: AppType.Experimental },
{ path: 'mypreferencesmenu', page: 'user/settings' },
{ path: 'quickconnect', page: 'quickConnect' },
{ path: 'search' },
{ path: 'tv', page: 'shows', type: AppType.Experimental },
{ path: 'userprofile', page: 'user/userprofile' }
];
@@ -1,60 +0,0 @@
import { BaseItemKind } from '@jellyfin/sdk/lib/generated-client/models/base-item-kind';
import React, { FC } from 'react';
import useCurrentTab from 'hooks/useCurrentTab';
import Page from 'components/Page';
import PageTabContent from '../../components/library/PageTabContent';
import { LibraryTab } from 'types/libraryTab';
import { CollectionType } from '@jellyfin/sdk/lib/generated-client/models/collection-type';
import { LibraryTabContent, LibraryTabMapping } from 'types/libraryTabContent';
import { BookSuggestionsSectionsView } from 'types/sections';
const booksTabContent: LibraryTabContent = {
viewType: LibraryTab.Books,
collectionType: CollectionType.Books,
itemType: [BaseItemKind.Book]
};
const suggestionsTabContent: LibraryTabContent = {
viewType: LibraryTab.Suggestions,
collectionType: CollectionType.Books,
sectionsView: BookSuggestionsSectionsView
};
const genresTabContent: LibraryTabContent = {
viewType: LibraryTab.Genres,
collectionType: CollectionType.Books,
itemType: [BaseItemKind.Book]
};
const favoritesTabContent: LibraryTabContent = {
viewType: LibraryTab.Favorites,
collectionType: CollectionType.Books,
itemType: [BaseItemKind.Book]
};
const booksTabMapping: LibraryTabMapping = {
0: booksTabContent,
1: suggestionsTabContent,
2: genresTabContent,
3: favoritesTabContent
};
const Books: FC = () => {
const { libraryId, activeTab } = useCurrentTab();
const currentTab = booksTabMapping[activeTab];
return (
<Page
id='booksPage'
className='mainAnimatedPage libraryPage pageWithAbsoluteTabs withTabs'
>
<PageTabContent
key={`${currentTab.viewType} - ${libraryId}`}
currentTab={currentTab}
parentId={libraryId}
/>
</Page>
);
};
export default Books;
@@ -1,71 +0,0 @@
import React, { FC } from 'react';
import useCurrentTab from 'hooks/useCurrentTab';
import Page from 'components/Page';
import PageTabContent from '../../components/library/PageTabContent';
import { LibraryTab } from 'types/libraryTab';
import { LibraryTabContent, LibraryTabMapping } from 'types/libraryTabContent';
import { ProgramSectionsView, RecordingsSectionsView, ScheduleSectionsView } from 'types/sections';
const seriestimersTabContent: LibraryTabContent = {
viewType: LibraryTab.SeriesTimers,
isPaginationEnabled: false,
isBtnFilterEnabled: false,
isBtnGridListEnabled: false,
isBtnSortEnabled: false,
isAlphabetPickerEnabled: false
};
const scheduleTabContent: LibraryTabContent = {
viewType: LibraryTab.Schedule,
sectionsView: ScheduleSectionsView
};
const recordingsTabContent: LibraryTabContent = {
viewType: LibraryTab.Recordings,
sectionsView: RecordingsSectionsView
};
const channelsTabContent: LibraryTabContent = {
viewType: LibraryTab.Channels,
isBtnGridListEnabled: false,
isBtnSortEnabled: false,
isAlphabetPickerEnabled: false
};
const programsTabContent: LibraryTabContent = {
viewType: LibraryTab.Programs,
sectionsView: ProgramSectionsView
};
const guideTabContent: LibraryTabContent = {
viewType: LibraryTab.Guide
};
const liveTvTabMapping: LibraryTabMapping = {
0: programsTabContent,
1: guideTabContent,
2: channelsTabContent,
3: recordingsTabContent,
4: scheduleTabContent,
5: seriestimersTabContent
};
const LiveTv: FC = () => {
const { libraryId, activeTab } = useCurrentTab();
const currentTab = liveTvTabMapping[activeTab];
return (
<Page
id='liveTvPage'
className='mainAnimatedPage libraryPage collectionEditorPage pageWithAbsoluteTabs withTabs'
>
<PageTabContent
key={`${currentTab.viewType} - ${libraryId}`}
currentTab={currentTab}
parentId={libraryId}
/>
</Page>
);
};
export default LiveTv;
@@ -1,58 +0,0 @@
import { BaseItemKind } from '@jellyfin/sdk/lib/generated-client/models/base-item-kind';
import React, { type FC } from 'react';
import useCurrentTab from 'hooks/useCurrentTab';
import Page from 'components/Page';
import PageTabContent from '../../components/library/PageTabContent';
import { LibraryTab } from 'types/libraryTab';
import { CollectionType } from '@jellyfin/sdk/lib/generated-client/models/collection-type';
import { LibraryTabContent, LibraryTabMapping } from 'types/libraryTabContent';
import { MusicVideoSuggestionsSectionsView } from 'types/sections';
const musicVideosTabContent: LibraryTabContent = {
viewType: LibraryTab.MusicVideos,
collectionType: CollectionType.Musicvideos,
isBtnPlayAllEnabled: true,
isBtnShuffleEnabled: true,
itemType: [BaseItemKind.MusicVideo]
};
const suggestionsTabContent: LibraryTabContent = {
viewType: LibraryTab.Suggestions,
collectionType: CollectionType.Musicvideos,
sectionsView: MusicVideoSuggestionsSectionsView
};
const foldersTabContent: LibraryTabContent = {
viewType: LibraryTab.Folders,
collectionType: CollectionType.Musicvideos,
isBtnPlayAllEnabled: false,
isBtnShuffleEnabled: false,
itemType: [BaseItemKind.Folder, BaseItemKind.MusicVideo]
};
const musicVideosTabMapping: LibraryTabMapping = {
0: musicVideosTabContent,
1: suggestionsTabContent,
2: foldersTabContent
};
const MusicVideos: FC = () => {
const { libraryId, activeTab } = useCurrentTab();
const currentTab = musicVideosTabMapping[activeTab];
return (
<Page
id='musicvideos'
className='mainAnimatedPage libraryPage backdropPage collectionEditorPage pageWithAbsoluteTabs withTabs'
backDropType='video, photo'
>
<PageTabContent
key={`${currentTab.viewType} - ${libraryId}`}
currentTab={currentTab}
parentId={libraryId}
/>
</Page>
);
};
export default MusicVideos;
@@ -5,7 +5,7 @@ function reload(context, itemId) {
loading.show();
if (itemId) {
import('../components/metadataEditor/metadataEditor').then(({ default: metadataEditor }) => {
import('components/metadataEditor/metadataEditor').then(({ default: metadataEditor }) => {
metadataEditor.embed(context.querySelector('.editPageInnerContent'), itemId, ApiClient.serverInfo().Id);
});
} else {
@@ -1,5 +1,8 @@
import { BaseItemKind } from '@jellyfin/sdk/lib/generated-client/models/base-item-kind';
import { ImageType } from '@jellyfin/sdk/lib/generated-client/models/image-type';
import { ItemFields } from '@jellyfin/sdk/lib/generated-client/models/item-fields';
import { ItemSortBy } from '@jellyfin/sdk/lib/generated-client/models/item-sort-by';
import { getStudiosApi } from '@jellyfin/sdk/lib/utils/api/studios-api';
import cardBuilder from 'components/cardbuilder/cardBuilder';
import { getBackdropShape, getPortraitShape, getSquareShape } from 'components/cardbuilder/utils/shape';
@@ -92,6 +95,15 @@ function getSections() {
centerText: true,
overlayPlayButton: true,
coverImage: true
}, {
name: 'Studios',
types: BaseItemKind.Studio,
shape: getBackdropShape(enableScrollX()),
preferThumb: true,
showTitle: true,
overlayText: false,
centerText: true,
overlayPlayButton: true
}, {
name: 'People',
types: 'Person',
@@ -189,6 +201,25 @@ function getFetchDataFn(section) {
options.Limit = 20;
const userId = apiClient.getCurrentUserId();
if (section.types === BaseItemKind.Studio) {
const api = ServerConnections.getApi(apiClient.serverId());
if (!api) {
console.error('[Favorites] no Api instance available for server', apiClient.serverId());
return Promise.resolve(undefined);
}
return getStudiosApi(api)
.getStudios({
userId,
isFavorite: true,
fields: [ItemFields.PrimaryImageAspectRatio],
enableImageTypes: [ImageType.Thumb],
enableTotalRecordCount: false,
limit: options.Limit
})
.then(({ data }) => data);
}
if (section.types === 'MusicArtist') {
return apiClient.getArtists(userId, options);
}
@@ -350,4 +381,3 @@ class FavoritesTab {
}
export default FavoritesTab;
@@ -1,9 +1,9 @@
import TabbedView from '../components/tabbedview/tabbedview';
import globalize from '../lib/globalize';
import '../elements/emby-tabs/emby-tabs';
import '../elements/emby-button/emby-button';
import '../elements/emby-scroller/emby-scroller';
import LibraryMenu from '../scripts/libraryMenu';
import TabbedView from 'components/tabbedview/tabbedview';
import globalize from 'lib/globalize';
import 'elements/emby-tabs/emby-tabs';
import 'elements/emby-button/emby-button';
import 'elements/emby-scroller/emby-scroller';
import LibraryMenu from 'scripts/libraryMenu';
class HomeView extends TabbedView {
setTitle() {
@@ -1,9 +1,9 @@
import * as userSettings from '../scripts/settings/userSettings';
import focusManager from '../components/focusManager';
import homeSections from '../components/homesections/homesections';
import * as userSettings from 'scripts/settings/userSettings';
import focusManager from 'components/focusManager';
import homeSections from 'components/homesections/homesections';
import { ServerConnections } from 'lib/jellyfin-apiclient';
import '../elements/emby-itemscontainer/emby-itemscontainer';
import 'elements/emby-itemscontainer/emby-itemscontainer';
class HomeTab {
constructor(view, params) {
@@ -232,6 +232,13 @@
</div>
</div>
<div id="collectionsCollapsible" class="verticalSection detailVerticalSection verticalSection-extrabottompadding hide">
<h2 class="sectionTitle sectionTitle-cards padded-right">${Collections}</h2>
<div is="emby-scroller" class="padded-top-focusscale padded-bottom-focusscale no-padding" data-centerfocus="true">
<div id="collectionsContent" is="emby-itemscontainer" class="scrollSlider focuscontainer-x itemsContainer collectionsContent"></div>
</div>
</div>
<div id="similarCollapsible" class="verticalSection detailVerticalSection verticalSection-extrabottompadding hide">
<h2 class="sectionTitle sectionTitle-cards padded-right">${HeaderMoreLikeThis}</h2>
<div is="emby-scroller" class="padded-top-focusscale padded-bottom-focusscale no-padding" data-centerfocus="true">
@@ -1,6 +1,8 @@
import { BaseItemKind } from '@jellyfin/sdk/lib/generated-client/models/base-item-kind';
import { ItemFields } from '@jellyfin/sdk/lib/generated-client/models/item-fields';
import { PersonKind } from '@jellyfin/sdk/lib/generated-client/models/person-kind';
import { getLibraryApi } from '@jellyfin/sdk/lib/utils/api/library-api';
import { getUserLibraryApi } from '@jellyfin/sdk/lib/utils/api/user-library-api';
import { intervalToDuration } from 'date-fns';
import DOMPurify from 'dompurify';
import escapeHtml from 'escape-html';
@@ -25,6 +27,7 @@ import { playbackManager } from 'components/playback/playbackmanager';
import { appRouter } from 'components/router/appRouter';
import itemShortcuts from 'components/shortcuts';
import { AppFeature } from 'constants/appFeature';
import { EventType } from 'constants/eventType';
import { ItemAction } from 'constants/itemAction';
import globalize from 'lib/globalize';
import { ServerConnections } from 'lib/jellyfin-apiclient';
@@ -38,7 +41,7 @@ import * as userSettings from 'scripts/settings/userSettings';
import Dashboard from 'utils/dashboard';
import Events from 'utils/events';
import { getItemBackdropImageUrl } from 'utils/jellyfin-apiclient/backdropImage';
import { toApi } from 'utils/jellyfin-apiclient/compat';
import { OutboundWebSocketMessageType } from '@jellyfin/sdk/lib/websocket';
import 'elements/emby-itemscontainer/emby-itemscontainer';
import 'elements/emby-checkbox/emby-checkbox';
@@ -59,7 +62,7 @@ const LIST_VIEW_TYPES = [
];
function autoFocus(container) {
import('../../components/autoFocuser').then(({ default: autoFocuser }) => {
import('components/autoFocuser').then(({ default: autoFocuser }) => {
autoFocuser.autoFocus(container);
});
}
@@ -176,7 +179,7 @@ function renderSeriesTimerEditor(page, item, apiClient, user) {
}
if (user.Policy.EnableLiveTvManagement) {
import('../../components/recordingcreator/seriesrecordingeditor').then(({ default: seriesRecordingEditor }) => {
import('components/recordingcreator/seriesrecordingeditor').then(({ default: seriesRecordingEditor }) => {
seriesRecordingEditor.embed(item, apiClient.serverId(), {
context: page.querySelector('.seriesRecordingEditor')
});
@@ -706,7 +709,7 @@ function showRecordingFields(instance, page, item, user) {
const recordingFieldsElement = page.querySelector('.recordingFields');
if (item.Type == 'Program' && user.Policy.EnableLiveTvManagement) {
import('../../components/recordingcreator/recordingfields').then(({ default: RecordingFields }) => {
import('components/recordingcreator/recordingfields').then(({ default: RecordingFields }) => {
instance.currentRecordingFields = new RecordingFields({
parent: recordingFieldsElement,
programId: item.Id,
@@ -825,7 +828,7 @@ function setInitialCollapsibleState(page, item, apiClient, context, user) {
} else if (item.Type == 'Studio' || item.Type == 'Person' || item.Type == 'Genre' || item.Type == 'MusicGenre' || item.Type == 'MusicArtist') {
page.querySelector('#listChildrenCollapsible').classList.remove('hide');
page.querySelector('#childrenCollapsible').classList.add('hide');
renderItemsByName(page, item);
renderItemsByName(page, item, user);
} else if (item.IsFolder) {
if (item.Type == 'BoxSet') {
page.querySelector('#listChildrenCollapsible').classList.add('hide');
@@ -859,6 +862,9 @@ function setInitialCollapsibleState(page, item, apiClient, context, user) {
(item.People || []).forEach(p => {
if (p.Type === PersonKind.GuestStar) {
guestCast.push(p);
} else if (p.Type === PersonKind.Artist || p.Type === PersonKind.AlbumArtist) {
// TODO remove this exclusion when artists are migrated to the persons endpoint
return;
} else {
cast.push(p);
}
@@ -980,10 +986,11 @@ function renderDetails(page, instance, item, apiClient, context) {
const itemDetailsGroup = page.querySelector('.itemDetailsGroup');
if (itemDetailsGroup) {
itemDetailsGroup.replaceChildren();
itemDetailsGroup.innerHTML = '';
const metadataTypes = [
PersonKind.Author,
PersonKind.Creator,
PersonKind.Director,
PersonKind.Writer,
BaseItemKind.Studio,
@@ -999,6 +1006,7 @@ function renderDetails(page, instance, item, apiClient, context) {
}
}
renderItemCollections(page, item, apiClient, context);
renderSimilarItems(page, item, context);
renderMoreFromSeason(page, item, apiClient);
renderMoreFromArtist(page, item, apiClient);
@@ -1155,6 +1163,57 @@ function renderMoreFromArtist(view, item, apiClient) {
}
}
function renderItemCollections(page, item, apiClient, context) {
const section = page.querySelector('#collectionsCollapsible');
if (!section) {
return;
}
const api = ServerConnections.getApi(item.ServerId);
const userId = apiClient.getCurrentUserId();
if (!api) {
console.error('[renderItemCollections] No Api instance available for server', item.ServerId);
section.classList.add('hide');
return;
}
getLibraryApi(api)
.getItemCollections({
itemId: item.Id,
userId,
fields: [ItemFields.PrimaryImageAspectRatio]
})
.then(response => response.data?.Items || [])
.then(collectionItems => {
if (!collectionItems.length) {
section.classList.add('hide');
return;
}
section.classList.remove('hide');
cardBuilder.buildCards(collectionItems, {
parentContainer: section,
itemsContainer: section.querySelector('.collectionsContent'),
shape: 'overflowPortrait',
sectionTitleTagName: 'h2',
scalable: true,
centerText: true,
showTitle: true,
showParentTitle: false,
overlayText: false,
overlayPlayButton: false,
coverImage: true,
// Similar to "More Like This"
showYear: item.Type === 'Movie' || item.Type === 'Trailer' || item.Type === 'Series',
context
});
}).catch(() => {
section.classList.add('hide');
});
}
function renderSimilarItems(page, item, context) {
const similarCollapsible = page.querySelector('#similarCollapsible');
@@ -1450,14 +1509,14 @@ function renderChildren(page, item) {
}
}
function renderItemsByName(page, item) {
import('../../scripts/itemsByName').then(({ default: ItemsByName }) => {
ItemsByName.renderItems(page, item);
function renderItemsByName(page, item, user) {
import('scripts/itemsByName').then(({ default: ItemsByName }) => {
ItemsByName.renderItems(page, item, user);
});
}
function renderPlaylistItems(page, item) {
import('../../scripts/playlistViewer').then(({ default: PlaylistViewer }) => {
import('scripts/playlistViewer').then(({ default: PlaylistViewer }) => {
PlaylistViewer.render(page, item);
});
}
@@ -1734,7 +1793,7 @@ function renderScenes(page, item) {
page.querySelector('#scenesCollapsible').classList.remove('hide');
const scenesContent = page.querySelector('#scenesContent');
import('../../components/cardbuilder/chaptercardbuilder').then(({ default: chaptercardbuilder }) => {
import('components/cardbuilder/chaptercardbuilder').then(({ default: chaptercardbuilder }) => {
chaptercardbuilder.buildChapterCards(item, chapters, {
itemsContainer: scenesContent,
backdropShape: 'overflowBackdrop',
@@ -1776,7 +1835,7 @@ function renderCast(page, item, people) {
page.querySelector('#castCollapsible').classList.remove('hide');
const castContent = page.querySelector('#castContent');
import('../../components/cardbuilder/peoplecardbuilder').then(({ default: peoplecardbuilder }) => {
import('components/cardbuilder/peoplecardbuilder').then(({ default: peoplecardbuilder }) => {
peoplecardbuilder.buildPeopleCards(people, {
itemsContainer: castContent,
coverImage: true,
@@ -1796,7 +1855,7 @@ function renderGuestCast(page, item, people) {
page.querySelector('#guestCastCollapsible').classList.remove('hide');
const guestCastContent = page.querySelector('#guestCastContent');
import('../../components/cardbuilder/peoplecardbuilder').then(({ default: peoplecardbuilder }) => {
import('components/cardbuilder/peoplecardbuilder').then(({ default: peoplecardbuilder }) => {
peoplecardbuilder.buildPeopleCards(people, {
itemsContainer: guestCastContent,
coverImage: true,
@@ -1840,7 +1899,7 @@ window.ItemDetailPage = new ItemDetailPage();
export default function (view, params) {
function getApiClient() {
return params.serverId ? ServerConnections.getApiClient(params.serverId) : ApiClient;
return params.serverId ? ServerConnections.getApiClient(params.serverId) : ServerConnections.currentApiClient();
}
function reload(instance, page, pageParams) {
@@ -1857,16 +1916,21 @@ export default function (view, params) {
}
function splitVersions(instance, page, apiClient, pageParams) {
confirm('Are you sure you wish to split the media sources into separate items?', 'Split Media Apart').then(function () {
loading.show();
apiClient.ajax({
type: 'DELETE',
url: apiClient.getUrl('Videos/' + pageParams.id + '/AlternateSources')
}).then(function () {
loading.hide();
confirm(globalize.translate('ConfirmSplitMedia'), globalize.translate('HeaderSplitMedia'))
.then(() => {
loading.show();
return apiClient.ajax({
type: 'DELETE',
url: apiClient.getUrl('Videos/' + pageParams.id + '/AlternateSources')
});
})
.then(() => {
reload(instance, page, pageParams);
Events.trigger(document, EventType.REFRESH_NEEDED);
})
.finally(() => {
loading.hide();
});
});
}
function getPlayOptions(startPosition) {
@@ -1926,7 +1990,7 @@ export default function (view, params) {
}
function onCancelSeriesTimerClick() {
import('../../components/recordingcreator/recordinghelper').then(({ default: recordingHelper }) => {
import('components/recordingcreator/recordinghelper').then(({ default: recordingHelper }) => {
recordingHelper.cancelSeriesTimerWithConfirmation(currentItem.Id, currentItem.ServerId).then(function () {
Dashboard.navigate('livetv');
});
@@ -1934,7 +1998,7 @@ export default function (view, params) {
}
function onCancelTimerClick() {
import('../../components/recordingcreator/recordinghelper').then(({ default: recordingHelper }) => {
import('components/recordingcreator/recordinghelper').then(({ default: recordingHelper }) => {
recordingHelper.cancelTimer(ServerConnections.getApiClient(currentItem.ServerId), currentItem.TimerId).then(function () {
reload(self, view, params);
});
@@ -1946,7 +2010,12 @@ export default function (view, params) {
}
function onDownloadClick() {
const api = toApi(getApiClient());
const api = ServerConnections.getApi(currentItem.ServerId);
if (!api) {
console.error('[ItemDetails] no Api instance available for server', currentItem.ServerId);
return;
}
const url = getLibraryApi(api).getDownloadUrl({ itemId: currentItem.Id });
download([{
url,
@@ -1992,21 +2061,18 @@ export default function (view, params) {
setTrailerButtonVisibility(view, currentItem);
}
function onWebSocketMessage(e, data) {
const msg = data;
function onUserDataChanged({ Data }) {
const apiClient = getApiClient();
if (msg.MessageType === 'UserDataChanged' && currentItem && msg.Data.UserId == apiClient.getCurrentUserId()) {
const key = currentItem.UserData.Key;
const userData = msg.Data.UserDataList.filter(function (u) {
return u.Key == key;
})[0];
if (!currentItem || Data?.UserId != apiClient.getCurrentUserId()) return;
if (userData) {
currentItem.UserData = userData;
reloadPlayButtons(view, currentItem);
autoFocus(view);
}
const key = currentItem.UserData.Key;
const userData = (Data?.UserDataList ?? []).find(u => u.Key == key);
if (userData) {
currentItem.UserData = userData;
reloadPlayButtons(view, currentItem);
autoFocus(view);
}
}
@@ -2036,6 +2102,7 @@ export default function (view, params) {
renderAudioSelections(view, self._currentPlaybackMediaSources);
renderSubtitleSelections(view, self._currentPlaybackMediaSources);
updateMiscInfo();
refreshSelectedVersion();
});
view.addEventListener('viewshow', function (e) {
const page = this;
@@ -2052,14 +2119,18 @@ export default function (view, params) {
reload(self, page, params);
}
Events.on(apiClient, 'message', onWebSocketMessage);
self._unsubscribeUserData = apiClient.subscribe(
[OutboundWebSocketMessageType.UserDataChanged],
onUserDataChanged
);
Events.on(playbackManager, 'playerchange', onPlayerChange);
itemShortcuts.on(view.querySelector('.nameContainer'));
});
view.addEventListener('viewbeforehide', function () {
itemShortcuts.off(view.querySelector('.nameContainer'));
Events.off(apiClient, 'message', onWebSocketMessage);
self._unsubscribeUserData?.();
self._unsubscribeUserData = null;
Events.off(playbackManager, 'playerchange', onPlayerChange);
libraryMenu.setTransparentMenu(false);
});
@@ -2081,5 +2152,46 @@ export default function (view, params) {
});
}
// When the user picks an alternate version, fetch that Video item's own DTO
// and refresh the play/user-data buttons so resume position, watched state, and
// stack-part count reflect the selected version rather than the primary's.
function refreshSelectedVersion() {
const selectedId = view.querySelector('.selectSource').value;
if (!selectedId || !currentItem || selectedId === currentItem.Id) {
reloadPlayButtons(view, currentItem);
reloadUserDataButtons(view, currentItem);
return;
}
const apiClient = ServerConnections.getApiClient(currentItem.ServerId);
const api = ServerConnections.getApi(currentItem.ServerId);
if (!api) {
console.error('[ItemDetails] no Api instance available for server', currentItem.ServerId);
return;
}
getUserLibraryApi(api).getItem({
userId: apiClient?.getCurrentUserId(),
itemId: selectedId
}).then(function ({ data: altItem }) {
if (view.querySelector('.selectSource').value !== selectedId) {
return;
}
// Keep primary's shared metadata (overview, cast, etc.) and override the
// fields that are intrinsic to the playback target (UserData, runtime, parts).
const merged = {
...currentItem,
UserData: altItem.UserData,
RunTimeTicks: altItem.RunTimeTicks,
PartCount: altItem.PartCount,
MediaStreams: altItem.MediaStreams
};
reloadPlayButtons(view, merged);
reloadUserDataButtons(view, merged);
}).catch(function (err) {
console.error('failed to load alternate version item', err);
});
}
init();
}
@@ -1,17 +1,17 @@
import globalize from '../lib/globalize';
import listView from '../components/listview/listview';
import * as userSettings from '../scripts/settings/userSettings';
import focusManager from '../components/focusManager';
import cardBuilder from '../components/cardbuilder/cardBuilder';
import loading from '../components/loading/loading';
import AlphaNumericShortcuts from '../scripts/alphanumericshortcuts';
import libraryBrowser from '../scripts/libraryBrowser';
import { playbackManager } from '../components/playback/playbackmanager';
import AlphaPicker from '../components/alphaPicker/alphaPicker';
import globalize from 'lib/globalize';
import listView from 'components/listview/listview';
import * as userSettings from 'scripts/settings/userSettings';
import focusManager from 'components/focusManager';
import cardBuilder from 'components/cardbuilder/cardBuilder';
import loading from 'components/loading/loading';
import AlphaNumericShortcuts from 'scripts/alphanumericshortcuts';
import libraryBrowser from 'scripts/libraryBrowser';
import { playbackManager } from 'components/playback/playbackmanager';
import AlphaPicker from 'components/alphaPicker/alphaPicker';
import { ServerConnections } from 'lib/jellyfin-apiclient';
import '../elements/emby-itemscontainer/emby-itemscontainer';
import '../elements/emby-scroller/emby-scroller';
import LibraryMenu from '../scripts/libraryMenu';
import 'elements/emby-itemscontainer/emby-itemscontainer';
import 'elements/emby-scroller/emby-scroller';
import LibraryMenu from 'scripts/libraryMenu';
import { CollectionType } from '@jellyfin/sdk/lib/generated-client/models/collection-type';
import { ItemSortBy } from '@jellyfin/sdk/lib/generated-client/models/item-sort-by';
import { stopMultiSelect } from 'components/multiSelect/multiSelect';
@@ -315,7 +315,7 @@ function getItems(instance, params, item, sortBy, startIndex, limit) {
} else if (item.CollectionType === CollectionType.Tvshows) {
query.IncludeItemTypes = 'Series';
} else if (item.Type === 'Genre') {
query.IncludeItemTypes = 'Movie,Series,Video';
query.IncludeItemTypes = 'Audiobook,Book,BoxSet,Movie,Series,Video';
} else if (item.Type === 'Person') {
query.IncludeItemTypes = params.type;
}
@@ -332,7 +332,11 @@ function getItems(instance, params, item, sortBy, startIndex, limit) {
SortBy: sortBy
};
if (sortBy === 'Random') {
if (params.type) {
instance.queryRecursive = true;
query.Recursive = true;
query.IncludeItemTypes = params.type;
} else if (sortBy === 'Random') {
instance.queryRecursive = true;
query.IncludeItemTypes = 'Video,Movie,Series,Music,MusicVideo';
query.Recursive = true;
@@ -359,7 +363,7 @@ function getItem(params) {
function showViewSettingsMenu() {
const instance = this;
import('../components/viewSettings/viewSettings').then(({ default: ViewSettings }) => {
import('components/viewSettings/viewSettings').then(({ default: ViewSettings }) => {
new ViewSettings().show({
settingsKey: instance.getSettingsKey(),
settings: instance.getViewSettings(),
@@ -374,7 +378,7 @@ function showViewSettingsMenu() {
function showFilterMenu() {
const instance = this;
import('../components/filtermenu/filtermenu').then(({ default: FilterMenu }) => {
import('components/filtermenu/filtermenu').then(({ default: FilterMenu }) => {
new FilterMenu().show({
settingsKey: instance.getSettingsKey(),
settings: instance.getFilters(),
@@ -393,7 +397,7 @@ function showFilterMenu() {
function showSortMenu() {
const instance = this;
import('../components/sortmenu/sortmenu').then(({ default: SortMenu }) => {
import('components/sortmenu/sortmenu').then(({ default: SortMenu }) => {
new SortMenu().show({
settingsKey: instance.getSettingsKey(),
settings: instance.getSortValues(),
@@ -411,7 +415,7 @@ function showSortMenu() {
function onNewItemClick() {
const instance = this;
import('../components/playlisteditor/playlisteditor').then(({ default: PlaylistEditor }) => {
import('components/playlisteditor/playlisteditor').then(({ default: PlaylistEditor }) => {
const playlistEditor = new PlaylistEditor();
playlistEditor.show({
items: [],
@@ -796,7 +800,7 @@ class ItemsView {
}
function autoFocus() {
import('../components/autoFocuser').then(({ default: autoFocuser }) => {
import('components/autoFocuser').then(({ default: autoFocuser }) => {
autoFocuser.autoFocus(view);
});
}
@@ -1,12 +1,12 @@
import cardBuilder from '../../components/cardbuilder/cardBuilder';
import imageLoader from '../../components/images/imageLoader';
import libraryBrowser from '../../scripts/libraryBrowser';
import loading from '../../components/loading/loading';
import * as userSettings from '../../scripts/settings/userSettings';
import Events from '../../utils/events.ts';
import cardBuilder from 'components/cardbuilder/cardBuilder';
import imageLoader from 'components/images/imageLoader';
import libraryBrowser from 'scripts/libraryBrowser';
import loading from 'components/loading/loading';
import * as userSettings from 'scripts/settings/userSettings';
import Events from 'utils/events';
import { setFilterStatus } from 'components/filterdialog/filterIndicator';
import '../../elements/emby-itemscontainer/emby-itemscontainer';
import 'elements/emby-itemscontainer/emby-itemscontainer';
export default function (view, params, tabContent) {
function getPageData() {
@@ -101,7 +101,7 @@ export default function (view, params, tabContent) {
}
function showFilterMenu(context) {
import('../../components/filterdialog/filterdialog').then(({ default: FilterDialog }) => {
import('components/filterdialog/filterdialog').then(({ default: FilterDialog }) => {
const filterDialog = new FilterDialog({
query: getQuery(),
mode: 'livetvchannels',
@@ -127,7 +127,7 @@ export default function (view, params, tabContent) {
loading.hide();
isLoading = false;
import('../../components/autoFocuser').then(({ default: autoFocuser }) => {
import('components/autoFocuser').then(({ default: autoFocuser }) => {
autoFocuser.autoFocus(context);
});
});
@@ -1,4 +1,4 @@
import Guide from '../../components/guide/guide';
import Guide from 'components/guide/guide';
export default function (view, params, tabContent) {
let guideInstance;
@@ -1,8 +1,8 @@
import cardBuilder from '../../components/cardbuilder/cardBuilder';
import imageLoader from '../../components/images/imageLoader';
import loading from '../../components/loading/loading';
import '../../elements/emby-button/paper-icon-button-light';
import '../../elements/emby-button/emby-button';
import cardBuilder from 'components/cardbuilder/cardBuilder';
import imageLoader from 'components/images/imageLoader';
import loading from 'components/loading/loading';
import 'elements/emby-button/paper-icon-button-light';
import 'elements/emby-button/emby-button';
function renderTimers(context, timers) {
const html = cardBuilder.getCardsHtml({
@@ -51,7 +51,7 @@ function loadRecommendedPrograms(page) {
});
loading.hide();
import('../../components/autoFocuser').then(({ default: autoFocuser }) => {
import('components/autoFocuser').then(({ default: autoFocuser }) => {
autoFocuser.autoFocus(page);
});
});
@@ -1,7 +1,7 @@
import { getLyricsApi } from '@jellyfin/sdk/lib/utils/api/lyrics-api';
import escapeHtml from 'escape-html';
import { AutoScroll } from 'apps/stable/features/lyrics/constants/autoScroll';
import { AutoScroll } from 'apps/legacy/features/lyrics/constants/autoScroll';
import autoFocuser from 'components/autoFocuser';
import { appRouter } from 'components/router/appRouter';
import layoutManager from 'components/layoutManager';
@@ -13,9 +13,8 @@ import { ServerConnections } from 'lib/jellyfin-apiclient';
import keyboardNavigation from 'scripts/keyboardNavigation';
import LibraryMenu from 'scripts/libraryMenu';
import Events from 'utils/events';
import { toApi } from 'utils/jellyfin-apiclient/compat';
import '../styles/lyrics.scss';
import 'styles/lyrics.scss';
let currentPlayer;
let currentItem;
@@ -132,10 +131,13 @@ export default function (view) {
}
function getLyrics(serverId, itemId) {
const apiClient = ServerConnections.getApiClient(serverId);
const lyricsApi = getLyricsApi(toApi(apiClient));
const api = ServerConnections.getApi(serverId);
if (!api) {
console.error('[Lyrics] no Api instance available for server', serverId);
return;
}
return lyricsApi.getLyrics({ itemId })
return getLyricsApi(api).getLyrics({ itemId })
.then(({ data }) => {
if (!data.Lyrics?.length) {
throw new Error('No lyrics returned');
@@ -1,12 +1,12 @@
import loading from '../../components/loading/loading';
import libraryBrowser from '../../scripts/libraryBrowser';
import { AlphaPicker } from '../../components/alphaPicker/alphaPicker';
import imageLoader from '../../components/images/imageLoader';
import listView from '../../components/listview/listview';
import cardBuilder from '../../components/cardbuilder/cardBuilder';
import * as userSettings from '../../scripts/settings/userSettings';
import globalize from '../../lib/globalize';
import '../../elements/emby-itemscontainer/emby-itemscontainer';
import loading from 'components/loading/loading';
import libraryBrowser from 'scripts/libraryBrowser';
import { AlphaPicker } from 'components/alphaPicker/alphaPicker';
import imageLoader from 'components/images/imageLoader';
import listView from 'components/listview/listview';
import cardBuilder from 'components/cardbuilder/cardBuilder';
import * as userSettings from 'scripts/settings/userSettings';
import globalize from 'lib/globalize';
import 'elements/emby-itemscontainer/emby-itemscontainer';
export default function (view, params, tabContent) {
function getPageData() {
@@ -189,7 +189,7 @@ export default function (view, params, tabContent) {
loading.hide();
isLoading = false;
import('../../components/autoFocuser').then(({ default: autoFocuser }) => {
import('components/autoFocuser').then(({ default: autoFocuser }) => {
autoFocuser.autoFocus(page);
});
});
@@ -242,7 +242,7 @@ export default function (view, params, tabContent) {
reloadItems(tabElement);
});
tabElement.querySelector('.btnNewCollection').addEventListener('click', () => {
import('../../components/collectionEditor/collectionEditor').then(({ default: CollectionEditor }) => {
import('components/collectionEditor/collectionEditor').then(({ default: CollectionEditor }) => {
const serverId = ApiClient.serverInfo().Id;
const collectionEditor = new CollectionEditor();
collectionEditor.show({
@@ -1,15 +1,15 @@
import loading from '../../components/loading/loading';
import * as userSettings from '../../scripts/settings/userSettings';
import libraryBrowser from '../../scripts/libraryBrowser';
import { AlphaPicker } from '../../components/alphaPicker/alphaPicker';
import listView from '../../components/listview/listview';
import cardBuilder from '../../components/cardbuilder/cardBuilder';
import globalize from '../../lib/globalize';
import Events from '../../utils/events.ts';
import { playbackManager } from '../../components/playback/playbackmanager';
import loading from 'components/loading/loading';
import * as userSettings from 'scripts/settings/userSettings';
import libraryBrowser from 'scripts/libraryBrowser';
import { AlphaPicker } from 'components/alphaPicker/alphaPicker';
import listView from 'components/listview/listview';
import cardBuilder from 'components/cardbuilder/cardBuilder';
import globalize from 'lib/globalize';
import Events from 'utils/events';
import { playbackManager } from 'components/playback/playbackmanager';
import { getFilterStatus, setFilterStatus } from 'components/filterdialog/filterIndicator';
import '../../elements/emby-itemscontainer/emby-itemscontainer';
import 'elements/emby-itemscontainer/emby-itemscontainer';
export default function (view, params, tabContent, options) {
const onViewStyleChange = () => {
@@ -108,7 +108,7 @@ export default function (view, params, tabContent, options) {
isLoading = false;
loading.hide();
import('../../components/autoFocuser').then(({ default: autoFocuser }) => {
import('components/autoFocuser').then(({ default: autoFocuser }) => {
autoFocuser.autoFocus(tabContent);
});
};
@@ -307,7 +307,7 @@ export default function (view, params, tabContent, options) {
query = userSettings.loadQuerySettings(savedQueryKey, query);
this.showFilterMenu = function () {
import('../../components/filterdialog/filterdialog').then(({ default: FilterDialog }) => {
import('components/filterdialog/filterdialog').then(({ default: FilterDialog }) => {
const filterDialog = new FilterDialog({
query: query,
mode: 'movies',
@@ -149,7 +149,7 @@ function getRecommendationHtml(recommendation) {
return html;
}
function loadSuggestions(page, userId) {
function loadSuggestions(page, userId, parentId) {
const screenWidth = dom.getWindowSize().innerWidth;
let itemLimit = 5;
if (screenWidth >= 1600) {
@@ -160,6 +160,7 @@ function loadSuggestions(page, userId) {
const url = ApiClient.getUrl('Movies/Recommendations', {
userId: userId,
ParentId: parentId,
categoryLimit: 6,
ItemLimit: itemLimit,
Fields: 'PrimaryImageAspectRatio,MediaSourceCount',
@@ -185,7 +186,7 @@ function loadSuggestions(page, userId) {
}
function autoFocus(page) {
import('../../components/autoFocuser').then(({ default: autoFocuser }) => {
import('components/autoFocuser').then(({ default: autoFocuser }) => {
autoFocuser.autoFocus(page);
});
}
@@ -223,7 +224,7 @@ function loadSuggestionsTab(view, params, tabContent) {
const userId = ApiClient.getCurrentUserId();
loadResume(tabContent, userId, parentId);
loadLatest(tabContent, userId, parentId);
loadSuggestions(tabContent, userId);
loadSuggestions(tabContent, userId, parentId);
}
function getTabs() {
@@ -403,4 +404,3 @@ export default function (view, params) {
}
}
}
@@ -1,16 +1,16 @@
import { playbackManager } from '../../components/playback/playbackmanager';
import loading from '../../components/loading/loading';
import libraryBrowser from '../../scripts/libraryBrowser';
import imageLoader from '../../components/images/imageLoader';
import AlphaPicker from '../../components/alphaPicker/alphaPicker';
import listView from '../../components/listview/listview';
import cardBuilder from '../../components/cardbuilder/cardBuilder';
import * as userSettings from '../../scripts/settings/userSettings';
import globalize from '../../lib/globalize';
import Events from '../../utils/events.ts';
import { playbackManager } from 'components/playback/playbackmanager';
import loading from 'components/loading/loading';
import libraryBrowser from 'scripts/libraryBrowser';
import imageLoader from 'components/images/imageLoader';
import AlphaPicker from 'components/alphaPicker/alphaPicker';
import listView from 'components/listview/listview';
import cardBuilder from 'components/cardbuilder/cardBuilder';
import * as userSettings from 'scripts/settings/userSettings';
import globalize from 'lib/globalize';
import Events from 'utils/events';
import { setFilterStatus } from 'components/filterdialog/filterIndicator';
import '../../elements/emby-itemscontainer/emby-itemscontainer';
import 'elements/emby-itemscontainer/emby-itemscontainer';
export default function (view, params, tabContent) {
function playAll() {
@@ -177,7 +177,7 @@ export default function (view, params, tabContent) {
loading.hide();
isLoading = false;
import('../../components/autoFocuser').then(({ default: autoFocuser }) => {
import('components/autoFocuser').then(({ default: autoFocuser }) => {
autoFocuser.autoFocus(tabContent);
});
});
@@ -187,7 +187,7 @@ export default function (view, params, tabContent) {
let isLoading = false;
this.showFilterMenu = function () {
import('../../components/filterdialog/filterdialog').then(({ default: FilterDialog }) => {
import('components/filterdialog/filterdialog').then(({ default: FilterDialog }) => {
const filterDialog = new FilterDialog({
query: getQuery(),
mode: 'albums',
@@ -296,4 +296,3 @@ export default function (view, params, tabContent) {
this.alphaPicker?.updateControls(getQuery());
};
}
@@ -1,14 +1,14 @@
import loading from '../../components/loading/loading';
import libraryBrowser from '../../scripts/libraryBrowser';
import imageLoader from '../../components/images/imageLoader';
import { AlphaPicker } from '../../components/alphaPicker/alphaPicker';
import listView from '../../components/listview/listview';
import cardBuilder from '../../components/cardbuilder/cardBuilder';
import * as userSettings from '../../scripts/settings/userSettings';
import Events from '../../utils/events.ts';
import loading from 'components/loading/loading';
import libraryBrowser from 'scripts/libraryBrowser';
import imageLoader from 'components/images/imageLoader';
import { AlphaPicker } from 'components/alphaPicker/alphaPicker';
import listView from 'components/listview/listview';
import cardBuilder from 'components/cardbuilder/cardBuilder';
import * as userSettings from 'scripts/settings/userSettings';
import Events from 'utils/events';
import { setFilterStatus } from 'components/filterdialog/filterIndicator';
import '../../elements/emby-itemscontainer/emby-itemscontainer';
import 'elements/emby-itemscontainer/emby-itemscontainer';
export default function (view, params, tabContent, options) {
function getPageData() {
@@ -159,7 +159,7 @@ export default function (view, params, tabContent, options) {
loading.hide();
isLoading = false;
import('../../components/autoFocuser').then(({ default: autoFocuser }) => {
import('components/autoFocuser').then(({ default: autoFocuser }) => {
autoFocuser.autoFocus(tabContent);
});
});
@@ -169,7 +169,7 @@ export default function (view, params, tabContent, options) {
let isLoading = false;
this.showFilterMenu = function () {
import('../../components/filterdialog/filterdialog').then(({ default: FilterDialog }) => {
import('components/filterdialog/filterdialog').then(({ default: FilterDialog }) => {
const filterDialog = new FilterDialog({
query: getQuery(),
mode: options.mode,
@@ -238,4 +238,3 @@ export default function (view, params, tabContent, options) {
this.alphaPicker?.updateControls(getQuery());
};
}
@@ -1,7 +1,7 @@
import * as userSettings from '../../scripts/settings/userSettings';
import cardBuilder from '../../components/cardbuilder/cardBuilder';
import imageLoader from '../../components/images/imageLoader';
import loading from '../../components/loading/loading';
import * as userSettings from 'scripts/settings/userSettings';
import cardBuilder from 'components/cardbuilder/cardBuilder';
import imageLoader from 'components/images/imageLoader';
import loading from 'components/loading/loading';
export default function (view, params, tabContent) {
function getPageData() {
@@ -90,7 +90,7 @@ export default function (view, params, tabContent) {
userSettings.saveQuerySettings(getSavedQueryKey(), query);
loading.hide();
import('../../components/autoFocuser').then(({ default: autoFocuser }) => {
import('components/autoFocuser').then(({ default: autoFocuser }) => {
autoFocuser.autoFocus(context);
});
});
@@ -1,7 +1,7 @@
import * as userSettings from '../../scripts/settings/userSettings';
import cardBuilder from '../../components/cardbuilder/cardBuilder';
import imageLoader from '../../components/images/imageLoader';
import loading from '../../components/loading/loading';
import * as userSettings from 'scripts/settings/userSettings';
import cardBuilder from 'components/cardbuilder/cardBuilder';
import imageLoader from 'components/images/imageLoader';
import loading from 'components/loading/loading';
export default function (view, params, tabContent) {
function getPageData() {
@@ -60,7 +60,7 @@ export default function (view, params, tabContent) {
userSettings.saveQuerySettings(getSavedQueryKey(), query);
loading.hide();
import('../../components/autoFocuser').then(({ default: autoFocuser }) => {
import('components/autoFocuser').then(({ default: autoFocuser }) => {
autoFocuser.autoFocus(context);
});
});
@@ -73,7 +73,7 @@ function loadLatest(page, parentId) {
imageLoader.lazyChildren(elem);
loading.hide();
import('../../components/autoFocuser').then(({ default: autoFocuser }) => {
import('components/autoFocuser').then(({ default: autoFocuser }) => {
autoFocuser.autoFocus(page);
});
});
@@ -169,7 +169,7 @@ function loadSuggestionsTab(page, tabContent, parentId) {
loadRecentlyPlayed(tabContent, parentId);
loadFrequentlyPlayed(tabContent, parentId);
import('../../components/favoriteitems').then(({ default: favoriteItems }) => {
import('components/favoriteitems').then(({ default: favoriteItems }) => {
favoriteItems.render(tabContent, ApiClient.getCurrentUserId(), parentId, ['favoriteArtists', 'favoriteAlbums', 'favoriteSongs']);
});
}

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