Merge branch 'master' into Sorting-Only-Thumbnail-Fix-5584

This commit is contained in:
Marco 2023-10-16 21:15:04 +01:00 committed by GitHub
commit fa40c3feae
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
76 changed files with 2156 additions and 1909 deletions

65
.github/workflows/build.yml vendored Normal file
View File

@ -0,0 +1,65 @@
name: Build
concurrency:
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
cancel-in-progress: true
on:
push:
branches: [ master, release* ]
pull_request:
branches: [ master, release* ]
workflow_dispatch:
jobs:
run-build-prod:
name: Run production build
runs-on: ubuntu-latest
steps:
- name: Check out Git repository
uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3
- name: Setup node environment
uses: actions/setup-node@e33196f7422957bea03ed53f6fbb155025ffc7b8 # v3.7.0
with:
node-version: 20
check-latest: true
cache: npm
- name: Install Node.js dependencies
run: npm ci --no-audit
- name: Run a production build
run: npm run build:production
- name: Upload artifact
uses: actions/upload-artifact@v3.1.3
with:
name: jellyfin-web__prod
path: |
dist
pr_context:
name: Save PR context as artifact
if: ${{ always() && !cancelled() && github.event_name == 'pull_request' }}
runs-on: ubuntu-latest
needs:
- run-build-prod
steps:
- name: Save PR context
env:
PR_NUMBER: ${{ github.event.number }}
PR_SHA: ${{ github.sha }}
run: |
echo $PR_NUMBER > PR_number
echo $PR_SHA > PR_sha
- name: Upload PR number as artifact
uses: actions/upload-artifact@v3.1.3
with:
name: PR_context
path: |
PR_number
PR_sha

View File

@ -1,31 +1,34 @@
name: "CodeQL"
name: CodeQL
concurrency:
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
cancel-in-progress: true
on:
push:
branches: [ master ]
branches: [ master, release* ]
pull_request:
branches: [ master ]
branches: [ master, release* ]
schedule:
- cron: '30 7 * * 6'
jobs:
analyze:
name: Analyze
codeql:
name: Run CodeQL
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
language: [ 'javascript' ]
steps:
- name: Checkout repository
uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3
- name: Initialize CodeQL
uses: github/codeql-action/init@46ed16ded91731b2df79a2893d3aea8e9f03b5c4 # v2.20.3
with:
languages: ${{ matrix.language }}
languages: javascript
queries: +security-extended
- name: Autobuild
uses: github/codeql-action/autobuild@46ed16ded91731b2df79a2893d3aea8e9f03b5c4 # v2.20.3
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@46ed16ded91731b2df79a2893d3aea8e9f03b5c4 # v2.20.3

65
.github/workflows/job-messages.yml vendored Normal file
View File

@ -0,0 +1,65 @@
name: Job messages
on:
workflow_call:
inputs:
branch:
required: false
type: string
commit:
required: true
type: string
preview_url:
required: false
type: string
build_workflow_run_id:
required: false
type: number
commenting_workflow_run_id:
required: true
type: string
in_progress:
required: true
type: boolean
outputs:
msg:
description: The composed message
value: ${{ jobs.msg.outputs.msg }}
marker:
description: Hidden marker to detect PR comments composed by the bot
value: "CFPages-deployment"
jobs:
msg:
name: Deployment status
runs-on: ubuntu-latest
outputs:
msg: ${{ env.msg }}
steps:
- name: Compose message
if: ${{ always() }}
id: compose
env:
COMMIT: ${{ inputs.commit }}
PREVIEW_URL: ${{ inputs.preview_url != '' && (inputs.branch != 'master' && inputs.preview_url || format('https://jellyfin-web.pages.dev ({0})', inputs.preview_url)) || 'Not available' }}
DEPLOY_STATUS: ${{ inputs.in_progress && '🔄 Deploying...' || (inputs.preview_url != '' && '✅ Deployed!' || '❌ Failure. Check workflow logs for details') }}
DEPLOYMENT_TYPE: ${{ inputs.branch != 'master' && '🔀 Preview' || '⚙️ Production' }}
BUILD_WORKFLOW_RUN: ${{ !inputs.in_progress && format('**[View build logs](https://github.com/{0}/actions/runs/{1})**', 'jellyfin/jellyfin-web', inputs.build_workflow_run_id) || '' }}
COMMENTING_WORKFLOW_RUN: ${{ format('**[View bot logs](https://github.com/{0}/actions/runs/{1})**', 'jellyfin/jellyfin-web', inputs.commenting_workflow_run_id) }}
# EOF is needed for multiline environment variables in a GitHub Actions context
run: |
echo "## Cloudflare Pages deployment" > $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "| **Latest commit** | <code>${COMMIT::7}</code> |" >> $GITHUB_STEP_SUMMARY
echo "|------------------------- |:----------------------------: |" >> $GITHUB_STEP_SUMMARY
echo "| **Status** | $DEPLOY_STATUS |" >> $GITHUB_STEP_SUMMARY
echo "| **Preview URL** | $PREVIEW_URL |" >> $GITHUB_STEP_SUMMARY
echo "| **Type** | $DEPLOYMENT_TYPE |" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "$BUILD_WORKFLOW_RUN" >> $GITHUB_STEP_SUMMARY
echo "$COMMENTING_WORKFLOW_RUN" >> $GITHUB_STEP_SUMMARY
COMPOSED_MSG=$(cat $GITHUB_STEP_SUMMARY)
echo "msg<<EOF" >> $GITHUB_ENV
echo "$COMPOSED_MSG" >> $GITHUB_ENV
echo "EOF" >> $GITHUB_ENV

97
.github/workflows/publish.yml vendored Normal file
View File

@ -0,0 +1,97 @@
name: Publish
on:
workflow_run:
workflows:
- Build
types:
- completed
jobs:
publish:
permissions:
contents: read
deployments: write
name: Deploy to Cloudflare Pages
runs-on: ubuntu-latest
outputs:
url: ${{ steps.cf.outputs.url }}
steps:
- name: Download workflow artifact
uses: dawidd6/action-download-artifact@v2.27.0
with:
run_id: ${{ github.event.workflow_run.id }}
name: jellyfin-web__prod
path: dist
- name: Publish
id: cf
uses: cloudflare/pages-action@1
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
projectName: jellyfin-web
branch: ${{ github.event.workflow_run.head_branch }}
directory: dist
gitHubToken: ${{ secrets.GITHUB_TOKEN }}
pr-context:
name: PR context
if: ${{ always() && github.event.workflow_run.event == 'pull_request' }}
runs-on: ubuntu-latest
outputs:
commit: ${{ env.pr_sha }}
pr_number: ${{ env.pr_number }}
steps:
- name: Get PR context
uses: dawidd6/action-download-artifact@v2.27.0
id: pr_context
with:
run_id: ${{ github.event.workflow_run.id }}
name: PR_context
- name: Set PR context environment variables
if: ${{ steps.pr_context.conclusion == 'success' }}
run: |
echo "pr_number=$(cat PR_number)" >> $GITHUB_ENV
echo "pr_sha=$(cat PR_sha)" >> $GITHUB_ENV
compose-comment:
name: Compose comment
if: ${{ always() }}
uses: ./.github/workflows/job-messages.yml
needs:
- publish
- pr-context
with:
branch: ${{ github.event.workflow_run.head_branch }}
commit: ${{ needs.pr-context.outputs.commit != '' && needs.pr-context.outputs.commit || github.event.workflow_run.head_sha }}
preview_url: ${{ needs.publish.outputs.url }}
build_workflow_run_id: ${{ github.event.workflow_run.id }}
commenting_workflow_run_id: ${{ github.run_id }}
in_progress: false
comment-status:
name: Create comment status
if: |
always() &&
github.event.workflow_run.event == 'pull_request' &&
needs.pr-context.outputs.pr_number != ''
runs-on: ubuntu-latest
needs:
- compose-comment
- pr-context
steps:
- name: Update job summary in PR comment
uses: thollander/actions-comment-pull-request@v2.4.2
with:
GITHUB_TOKEN: ${{ secrets.JF_BOT_TOKEN }}
message: ${{ needs.compose-comment.outputs.msg }}
pr_number: ${{ needs.pr-context.outputs.pr_number }}
comment_tag: ${{ needs.compose-comment.outputs.marker }}
mode: recreate

View File

@ -1,4 +1,8 @@
name: Lint
name: Quality checks
concurrency:
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
cancel-in-progress: true
on:
push:
@ -99,3 +103,45 @@ jobs:
- name: Run stylelint
run: npm run stylelint:scss
run-tsc:
name: Run TypeScript build check
runs-on: ubuntu-latest
steps:
- name: Check out Git repository
uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3
- name: Setup node environment
uses: actions/setup-node@e33196f7422957bea03ed53f6fbb155025ffc7b8 # v3.7.0
with:
node-version: 20
check-latest: true
cache: npm
- name: Install Node.js dependencies
run: npm ci --no-audit
- name: Run tsc
run: npm run build:check
run-test:
name: Run tests
runs-on: ubuntu-latest
steps:
- name: Check out Git repository
uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3
- name: Setup node environment
uses: actions/setup-node@e33196f7422957bea03ed53f6fbb155025ffc7b8 # v3.7.0
with:
node-version: 20
check-latest: true
cache: npm
- name: Install Node.js dependencies
run: npm ci --no-audit
- name: Run test suite
run: npm run test

View File

@ -1,32 +0,0 @@
name: TypeScript Build Check
on:
push:
branches: [ master, release* ]
pull_request:
branches: [ master, release* ]
jobs:
tsc:
name: Run TypeScript build check
runs-on: ubuntu-latest
steps:
- name: Check out Git repository
uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3
- name: Setup node environment
uses: actions/setup-node@e33196f7422957bea03ed53f6fbb155025ffc7b8 # v3.7.0
with:
node-version: 20
check-latest: true
cache: npm
- name: Install Node.js dependencies
run: npm ci --no-audit
- name: Run tsc
run: npm run build:check
- name: Run test suite
run: npm run test

View File

@ -68,6 +68,7 @@
- [TheMelmacian](https://github.com/TheMelmacian)
- [v0idMrK](https://github.com/v0idMrK)
- [tehciolo](https://github.com/tehciolo)
- [scampower3](https://github.com/scampower3)
# Emby Contributors

View File

@ -14,7 +14,9 @@ RUN yum update -y \
&& yum install -y epel-release \
&& yum install -y rpmdevtools git autoconf automake glibc-devel gcc-c++ make \
&& yum install https://rpm.nodesource.com/pub_20.x/nodistro/repo/nodesource-release-nodistro-1.noarch.rpm -y \
&& yum install nodejs -y --setopt=nodesource-nodejs.module_hotfixes=1
&& yum install nodejs -y --setopt=nodesource-nodejs.module_hotfixes=1 \
&& yum clean all \
&& rm -rf /var/cache/dnf
# Link to build script
RUN ln -sf ${SOURCE_DIR}/deployment/build.centos /build.sh

View File

@ -17,7 +17,8 @@ RUN apt-get update \
&& curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg \
&& echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_20.x nodistro main" | tee /etc/apt/sources.list.d/nodesource.list \
&& apt-get update \
&& apt-get install -y nodejs
&& apt-get install -y nodejs \
&& rm -rf /var/lib/apt/lists/* /var/cache/apt/archives/*
# Link to build script
RUN ln -sf ${SOURCE_DIR}/deployment/build.debian /build.sh

View File

@ -3,7 +3,7 @@ FROM node:20-alpine
ARG SOURCE_DIR=/src
ARG ARTIFACT_DIR=/jellyfin-web
RUN apk add autoconf g++ make libpng-dev gifsicle alpine-sdk automake libtool make gcc musl-dev nasm python3
RUN apk --no-cache add autoconf g++ make libpng-dev gifsicle alpine-sdk automake libtool make gcc musl-dev nasm python3
WORKDIR ${SOURCE_DIR}
COPY . .

View File

@ -11,8 +11,10 @@ ENV IS_DOCKER=YES
# Prepare Fedora environment
RUN dnf update -y \
&& yum install https://rpm.nodesource.com/pub_20.x/nodistro/repo/nodesource-release-nodistro-1.noarch.rpm -y \
&& dnf install -y @buildsys-build rpmdevtools git dnf-plugins-core nodejs autoconf automake glibc-devel make --setopt=nodesource-nodejs.module_hotfixes=1
&& dnf install https://rpm.nodesource.com/pub_20.x/nodistro/repo/nodesource-release-nodistro-1.noarch.rpm -y \
&& dnf install -y @buildsys-build rpmdevtools git dnf-plugins-core nodejs autoconf automake glibc-devel make --setopt=nodesource-nodejs.module_hotfixes=1 \
&& dnf clean all \
&& rm -rf /var/cache/dnf
# Link to build script
RUN ln -sf ${SOURCE_DIR}/deployment/build.fedora /build.sh

View File

@ -16,7 +16,8 @@ RUN apt-get update \
&& curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg \
&& echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_20.x nodistro main" | tee /etc/apt/sources.list.d/nodesource.list \
&& apt-get update \
&& apt-get install -y nodejs
&& apt-get install -y nodejs \
&& rm -rf /var/lib/apt/lists/* /var/cache/apt/archives/*
# Link to build script
RUN ln -sf ${SOURCE_DIR}/deployment/build.portable /build.sh

View File

@ -53,11 +53,11 @@ const DashboardApp = () => (
<ServerContentPage view='/web/configurationpage' />
} />
</Route>
{/* Suppress warnings for unhandled routes */}
<Route path='*' element={null} />
</Route>
{/* Suppress warnings for unhandled routes */}
<Route path='*' element={null} />
{/* Redirects for old paths */}
{REDIRECTS.map(toRedirectRoute)}
</Routes>

View File

@ -5,10 +5,11 @@ import globalize from 'scripts/globalize';
import Loading from 'components/loading/LoadingComponent';
import GenresSectionContainer from './GenresSectionContainer';
import { CollectionType } from 'types/collectionType';
import { ParentId } from 'types/library';
interface GenresItemsContainerProps {
parentId?: string | null;
collectionType?: CollectionType;
parentId: ParentId;
collectionType: CollectionType;
itemType: BaseItemKind;
}

View File

@ -12,10 +12,11 @@ import Loading from 'components/loading/LoadingComponent';
import { appRouter } from 'components/router/appRouter';
import SectionContainer from './SectionContainer';
import { CollectionType } from 'types/collectionType';
import { ParentId } from 'types/library';
interface GenresSectionContainerProps {
parentId?: string | null;
collectionType?: CollectionType;
parentId: ParentId;
collectionType: CollectionType;
itemType: BaseItemKind;
genre: BaseItemDto;
}

View File

@ -1,16 +1,16 @@
import React, { FC, useEffect, useRef } from 'react';
import ItemsContainerElement from '../../elements/ItemsContainerElement';
import imageLoader from '../images/imageLoader';
import '../../elements/emby-itemscontainer/emby-itemscontainer';
import { ViewQuerySettings } from '../../types/interface';
import ItemsContainerElement from 'elements/ItemsContainerElement';
import imageLoader from 'components/images/imageLoader';
import 'elements/emby-itemscontainer/emby-itemscontainer';
import { LibraryViewSettings, ViewMode } from 'types/library';
interface ItemsContainerI {
viewQuerySettings: ViewQuerySettings;
libraryViewSettings: LibraryViewSettings;
getItemsHtml: () => string
}
const ItemsContainer: FC<ItemsContainerI> = ({ viewQuerySettings, getItemsHtml }) => {
const ItemsContainer: FC<ItemsContainerI> = ({ libraryViewSettings, getItemsHtml }) => {
const element = useRef<HTMLDivElement>(null);
useEffect(() => {
@ -19,7 +19,7 @@ const ItemsContainer: FC<ItemsContainerI> = ({ viewQuerySettings, getItemsHtml }
imageLoader.lazyChildren(itemsContainer);
}, [getItemsHtml]);
const cssClass = viewQuerySettings.imageType == 'list' ? 'vertical-list' : 'vertical-wrap';
const cssClass = libraryViewSettings.ViewMode === ViewMode.ListView ? 'vertical-list' : 'vertical-wrap';
return (
<div ref={element}>

View File

@ -0,0 +1,272 @@
import type { BaseItemKind } from '@jellyfin/sdk/lib/generated-client/models/base-item-kind';
import { ImageType } from '@jellyfin/sdk/lib/generated-client';
import { ItemSortBy } from '@jellyfin/sdk/lib/models/api/item-sort-by';
import React, { FC, useCallback } from 'react';
import Box from '@mui/material/Box';
import { useLocalStorage } from 'hooks/useLocalStorage';
import { useGetItem, useGetItemsViewByType } from 'hooks/useFetchItems';
import { getDefaultLibraryViewSettings, getSettingsKey } from 'utils/items';
import Loading from 'components/loading/LoadingComponent';
import listview from 'components/listview/listview';
import cardBuilder from 'components/cardbuilder/cardBuilder';
import { playbackManager } from 'components/playback/playbackmanager';
import globalize from 'scripts/globalize';
import AlphabetPicker from './AlphabetPicker';
import FilterButton from './filter/FilterButton';
import ItemsContainer from './ItemsContainer';
import NewCollectionButton from './NewCollectionButton';
import Pagination from './Pagination';
import PlayAllButton from './PlayAllButton';
import QueueButton from './QueueButton';
import ShuffleButton from './ShuffleButton';
import SortButton from './SortButton';
import GridListViewButton from './GridListViewButton';
import { LibraryViewSettings, ParentId, ViewMode } from 'types/library';
import { CollectionType } from 'types/collectionType';
import { LibraryTab } from 'types/libraryTab';
import { CardOptions } from 'types/cardOptions';
interface ItemsViewProps {
viewType: LibraryTab;
parentId: ParentId;
itemType: BaseItemKind[];
collectionType?: CollectionType;
isBtnPlayAllEnabled?: boolean;
isBtnQueueEnabled?: boolean;
isBtnShuffleEnabled?: boolean;
isBtnSortEnabled?: boolean;
isBtnFilterEnabled?: boolean;
isBtnNewCollectionEnabled?: boolean;
isBtnGridListEnabled?: boolean;
isAlphabetPickerEnabled?: boolean;
noItemsMessage: string;
}
const ItemsView: FC<ItemsViewProps> = ({
viewType,
parentId,
collectionType,
isBtnPlayAllEnabled = false,
isBtnQueueEnabled = false,
isBtnShuffleEnabled = false,
isBtnSortEnabled = true,
isBtnFilterEnabled = true,
isBtnNewCollectionEnabled = false,
isBtnGridListEnabled = true,
isAlphabetPickerEnabled = true,
itemType,
noItemsMessage
}) => {
const [libraryViewSettings, setLibraryViewSettings] =
useLocalStorage<LibraryViewSettings>(
getSettingsKey(viewType, parentId),
getDefaultLibraryViewSettings(viewType)
);
const {
isLoading,
data: itemsResult,
isPreviousData
} = useGetItemsViewByType(
viewType,
parentId,
itemType,
libraryViewSettings
);
const { data: item } = useGetItem(parentId);
const getCardOptions = useCallback(() => {
let shape;
let preferThumb;
let preferDisc;
let preferLogo;
let lines = libraryViewSettings.ShowTitle ? 2 : 0;
if (libraryViewSettings.ImageType === ImageType.Banner) {
shape = 'banner';
} else if (libraryViewSettings.ImageType === ImageType.Disc) {
shape = 'square';
preferDisc = true;
} else if (libraryViewSettings.ImageType === ImageType.Logo) {
shape = 'backdrop';
preferLogo = true;
} else if (libraryViewSettings.ImageType === ImageType.Thumb) {
shape = 'backdrop';
preferThumb = true;
} else {
shape = '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,
overlayPlayButton: false,
overlayMoreButton: true,
overlayText: !libraryViewSettings.ShowTitle
};
if (
viewType === LibraryTab.Songs
|| viewType === LibraryTab.Albums
|| viewType === LibraryTab.Episodes
) {
cardOptions.showParentTitle = libraryViewSettings.ShowTitle;
} else if (viewType === LibraryTab.Artists) {
cardOptions.showYear = false;
lines = 1;
}
cardOptions.lines = lines;
return cardOptions;
}, [
libraryViewSettings.ShowTitle,
libraryViewSettings.ImageType,
libraryViewSettings.ShowYear,
libraryViewSettings.CardLayout,
collectionType,
viewType
]);
const getItemsHtml = useCallback(() => {
let html = '';
if (libraryViewSettings.ViewMode === ViewMode.ListView) {
html = listview.getListViewHtml({
items: itemsResult?.Items ?? [],
context: collectionType
});
} else {
html = cardBuilder.getCardsHtml(
itemsResult?.Items ?? [],
getCardOptions()
);
}
if (!itemsResult?.Items?.length) {
html += '<div class="noItemsMessage centerMessage">';
html += '<h1>' + globalize.translate('MessageNothingHere') + '</h1>';
html += '<p>' + globalize.translate(noItemsMessage) + '</p>';
html += '</div>';
}
return html;
}, [
libraryViewSettings.ViewMode,
itemsResult?.Items,
collectionType,
getCardOptions,
noItemsMessage
]);
const totalRecordCount = itemsResult?.TotalRecordCount ?? 0;
const items = itemsResult?.Items ?? [];
const hasFilters = Object.values(libraryViewSettings.Filters ?? {}).some(
(filter) => !!filter
);
const hasSortName = libraryViewSettings.SortBy.includes(
ItemSortBy.SortName
);
return (
<Box>
<Box className='flex align-items-center justify-content-center flex-wrap-wrap padded-top padded-left padded-right padded-bottom focuscontainer-x'>
<Pagination
totalRecordCount={totalRecordCount}
libraryViewSettings={libraryViewSettings}
isPreviousData={isPreviousData}
setLibraryViewSettings={setLibraryViewSettings}
/>
{isBtnPlayAllEnabled && (
<PlayAllButton
item={item}
items={items}
viewType={viewType}
hasFilters={hasFilters}
libraryViewSettings={libraryViewSettings}
/>
)}
{isBtnQueueEnabled
&& item
&& playbackManager.canQueue(item) && (
<QueueButton
item={item}
items={items}
hasFilters={hasFilters}
/>
)}
{isBtnShuffleEnabled && totalRecordCount > 1 && (
<ShuffleButton
item={item}
items={items}
viewType={viewType}
hasFilters={hasFilters}
libraryViewSettings={libraryViewSettings}
/>
)}
{isBtnSortEnabled && (
<SortButton
viewType={viewType}
libraryViewSettings={libraryViewSettings}
setLibraryViewSettings={setLibraryViewSettings}
/>
)}
{isBtnFilterEnabled && (
<FilterButton
parentId={parentId}
itemType={itemType}
viewType={viewType}
hasFilters={hasFilters}
libraryViewSettings={libraryViewSettings}
setLibraryViewSettings={setLibraryViewSettings}
/>
)}
{isBtnNewCollectionEnabled && <NewCollectionButton />}
{isBtnGridListEnabled && (
<GridListViewButton
viewType={viewType}
libraryViewSettings={libraryViewSettings}
setLibraryViewSettings={setLibraryViewSettings}
/>
)}
</Box>
{isAlphabetPickerEnabled && hasSortName && (
<AlphabetPicker
libraryViewSettings={libraryViewSettings}
setLibraryViewSettings={setLibraryViewSettings}
/>
)}
{isLoading ? (
<Loading />
) : (
<ItemsContainer
libraryViewSettings={libraryViewSettings}
getItemsHtml={getItemsHtml}
/>
)}
<Box className='flex align-items-center justify-content-center flex-wrap-wrap padded-top padded-left padded-right padded-bottom focuscontainer-x'>
<Pagination
totalRecordCount={totalRecordCount}
libraryViewSettings={libraryViewSettings}
isPreviousData={isPreviousData}
setLibraryViewSettings={setLibraryViewSettings}
/>
</Box>
</Box>
);
};
export default ItemsView;

View File

@ -13,15 +13,17 @@ interface PaginationProps {
libraryViewSettings: LibraryViewSettings;
setLibraryViewSettings: React.Dispatch<React.SetStateAction<LibraryViewSettings>>;
totalRecordCount: number;
isPreviousData: boolean
}
const Pagination: FC<PaginationProps> = ({
libraryViewSettings,
setLibraryViewSettings,
totalRecordCount
totalRecordCount,
isPreviousData
}) => {
const limit = userSettings.libraryPageSize(undefined);
const startIndex = libraryViewSettings.StartIndex || 0;
const startIndex = libraryViewSettings.StartIndex ?? 0;
const recordsStart = totalRecordCount ? startIndex + 1 : 0;
const recordsEnd = limit ?
Math.min(startIndex + limit, totalRecordCount) :
@ -29,23 +31,19 @@ const Pagination: FC<PaginationProps> = ({
const showControls = limit > 0 && limit < totalRecordCount;
const onNextPageClick = useCallback(() => {
if (limit > 0) {
const newIndex = startIndex + limit;
setLibraryViewSettings((prevState) => ({
...prevState,
StartIndex: newIndex
}));
}
const newIndex = startIndex + limit;
setLibraryViewSettings((prevState) => ({
...prevState,
StartIndex: newIndex
}));
}, [limit, setLibraryViewSettings, startIndex]);
const onPreviousPageClick = useCallback(() => {
if (limit > 0) {
const newIndex = Math.max(0, startIndex - limit);
setLibraryViewSettings((prevState) => ({
...prevState,
StartIndex: newIndex
}));
}
const newIndex = Math.max(0, startIndex - limit);
setLibraryViewSettings((prevState) => ({
...prevState,
StartIndex: newIndex
}));
}, [limit, setLibraryViewSettings, startIndex]);
return (
@ -67,7 +65,7 @@ const Pagination: FC<PaginationProps> = ({
<IconButton
title={globalize.translate('Previous')}
className='paper-icon-button-light btnPreviousPage autoSize'
disabled={startIndex == 0}
disabled={startIndex == 0 || isPreviousData}
onClick={onPreviousPageClick}
>
<ArrowBackIcon />
@ -76,7 +74,7 @@ const Pagination: FC<PaginationProps> = ({
<IconButton
title={globalize.translate('Next')}
className='paper-icon-button-light btnNextPage autoSize'
disabled={startIndex + limit >= totalRecordCount }
disabled={startIndex + limit >= totalRecordCount || isPreviousData }
onClick={onNextPageClick}
>
<ArrowForwardIcon />

View File

@ -49,7 +49,7 @@ const RecommendationContainer: FC<RecommendationContainerProps> = ({
return (
<SectionContainer
sectionTitle={escapeHTML(title)}
items={recommendation.Items || []}
items={recommendation.Items ?? []}
cardOptions={{
shape: 'overflowPortrait',
showYear: true,

View File

@ -5,6 +5,7 @@ import React, { FC } from 'react';
import * as userSettings from 'scripts/settings/userSettings';
import SuggestionsSectionContainer from './SuggestionsSectionContainer';
import { Sections, SectionsView, SectionsViewType } from 'types/suggestionsSections';
import { ParentId } from 'types/library';
const getSuggestionsSections = (): Sections[] => {
return [
@ -178,7 +179,7 @@ const getSuggestionsSections = (): Sections[] => {
};
interface SuggestionsItemsContainerProps {
parentId?: string | null;
parentId: ParentId;
sectionsView: SectionsView[];
}

View File

@ -7,9 +7,10 @@ import { appRouter } from 'components/router/appRouter';
import SectionContainer from './SectionContainer';
import { Sections } from 'types/suggestionsSections';
import { ParentId } from 'types/library';
interface SuggestionsSectionContainerProps {
parentId?: string | null;
parentId: ParentId;
section: Sections;
}
@ -37,7 +38,7 @@ const SuggestionsSectionContainer: FC<SuggestionsSectionContainerProps> = ({
return (
<SectionContainer
sectionTitle={globalize.translate(section.name)}
items={items || []}
items={items ?? []}
url={getRouteUrl()}
cardOptions={{
...section.cardOptions

View File

@ -28,7 +28,7 @@ import FiltersTags from './FiltersTags';
import FiltersVideoTypes from './FiltersVideoTypes';
import FiltersYears from './FiltersYears';
import { LibraryViewSettings } from 'types/library';
import { LibraryViewSettings, ParentId } from 'types/library';
import { LibraryTab } from 'types/libraryTab';
const Accordion = styled((props: AccordionProps) => (
@ -73,9 +73,10 @@ const AccordionDetails = styled(MuiAccordionDetails)(({ theme }) => ({
}));
interface FilterButtonProps {
parentId: string | null | undefined;
itemType: BaseItemKind;
parentId: ParentId;
itemType: BaseItemKind[];
viewType: LibraryTab;
hasFilters: boolean;
libraryViewSettings: LibraryViewSettings;
setLibraryViewSettings: React.Dispatch<
React.SetStateAction<LibraryViewSettings>
@ -86,6 +87,7 @@ const FilterButton: FC<FilterButtonProps> = ({
parentId,
itemType,
viewType,
hasFilters,
libraryViewSettings,
setLibraryViewSettings
}) => {
@ -153,16 +155,13 @@ const FilterButton: FC<FilterButtonProps> = ({
return viewType === LibraryTab.Episodes;
};
const hasFilters =
Object.values(libraryViewSettings.Filters || {}).some((filter) => !!filter);
return (
<Box>
<IconButton
title={globalize.translate('Filter')}
sx={{ ml: 2 }}
aria-describedby={id}
className='paper-icon-button-light btnShuffle autoSize'
className='paper-icon-button-light btnFilter autoSize'
onClick={handleClick}
>
<Badge color='info' variant='dot' invisible={!hasFilters}>

View File

@ -27,7 +27,7 @@ type ControllerProps = {
const Home: FunctionComponent = () => {
const [ searchParams ] = useSearchParams();
const initialTabIndex = parseInt(searchParams.get('tab') || '0', 10);
const initialTabIndex = parseInt(searchParams.get('tab') ?? '0', 10);
const tabController = useRef<ControllerProps | null>();
const tabControllers = useMemo<ControllerProps[]>(() => [], []);

View File

@ -1,30 +1,22 @@
import React, { FC, useCallback } from 'react';
import { BaseItemKind } from '@jellyfin/sdk/lib/generated-client/models/base-item-kind';
import React, { FC } from 'react';
import ViewItemsContainer from 'components/common/ViewItemsContainer';
import ItemsView from '../../components/library/ItemsView';
import { LibraryViewProps } from 'types/library';
import { CollectionType } from 'types/collectionType';
import { LibraryTab } from 'types/libraryTab';
const CollectionsView: FC<LibraryViewProps> = ({ parentId }) => {
const getBasekey = useCallback(() => {
return 'collections';
}, []);
const getItemTypes = useCallback(() => {
return ['BoxSet'];
}, []);
const getNoItemsMessage = useCallback(() => {
return 'MessageNoCollectionsAvailable';
}, []);
return (
<ViewItemsContainer
topParentId={parentId}
<ItemsView
viewType={LibraryTab.Collections}
parentId={parentId}
collectionType={CollectionType.Movies}
isBtnFilterEnabled={false}
isBtnNewCollectionEnabled={true}
isAlphaPickerEnabled={false}
getBasekey={getBasekey}
getItemTypes={getItemTypes}
getNoItemsMessage={getNoItemsMessage}
isAlphabetPickerEnabled={false}
itemType={[BaseItemKind.BoxSet]}
noItemsMessage='MessageNoCollectionsAvailable'
/>
);
};

View File

@ -1,27 +1,17 @@
import React, { FC, useCallback } from 'react';
import { BaseItemKind } from '@jellyfin/sdk/lib/generated-client/models/base-item-kind';
import React, { FC } from 'react';
import ViewItemsContainer from 'components/common/ViewItemsContainer';
import ItemsView from '../../components/library/ItemsView';
import { LibraryViewProps } from 'types/library';
import { LibraryTab } from 'types/libraryTab';
const FavoritesView: FC<LibraryViewProps> = ({ parentId }) => {
const getBasekey = useCallback(() => {
return 'favorites';
}, []);
const getItemTypes = useCallback(() => {
return ['Movie'];
}, []);
const getNoItemsMessage = useCallback(() => {
return 'MessageNoFavoritesAvailable';
}, []);
return (
<ViewItemsContainer
topParentId={parentId}
getBasekey={getBasekey}
getItemTypes={getItemTypes}
getNoItemsMessage={getNoItemsMessage}
<ItemsView
viewType={LibraryTab.Favorites}
parentId={parentId}
itemType={[BaseItemKind.Movie]}
noItemsMessage='MessageNoFavoritesAvailable'
/>
);
};

View File

@ -1,28 +1,20 @@
import React, { FC, useCallback } from 'react';
import { BaseItemKind } from '@jellyfin/sdk/lib/generated-client/models/base-item-kind';
import React, { FC } from 'react';
import ViewItemsContainer from 'components/common/ViewItemsContainer';
import ItemsView from '../../components/library/ItemsView';
import { LibraryViewProps } from 'types/library';
import { CollectionType } from 'types/collectionType';
import { LibraryTab } from 'types/libraryTab';
const MoviesView: FC<LibraryViewProps> = ({ parentId }) => {
const getBasekey = useCallback(() => {
return 'movies';
}, []);
const getItemTypes = useCallback(() => {
return ['Movie'];
}, []);
const getNoItemsMessage = useCallback(() => {
return 'MessageNoItemsAvailable';
}, []);
return (
<ViewItemsContainer
topParentId={parentId}
<ItemsView
viewType={LibraryTab.Movies}
parentId={parentId}
collectionType={CollectionType.Movies}
isBtnShuffleEnabled={true}
getBasekey={getBasekey}
getItemTypes={getItemTypes}
getNoItemsMessage={getNoItemsMessage}
itemType={[BaseItemKind.Movie]}
noItemsMessage='MessageNoItemsAvailable'
/>
);
};

View File

@ -1,28 +1,17 @@
import { BaseItemKind } from '@jellyfin/sdk/lib/generated-client/models/base-item-kind';
import React, { FC } from 'react';
import React, { FC, useCallback } from 'react';
import ViewItemsContainer from 'components/common/ViewItemsContainer';
import ItemsView from '../../components/library/ItemsView';
import { LibraryViewProps } from 'types/library';
import { LibraryTab } from 'types/libraryTab';
const TrailersView: FC<LibraryViewProps> = ({ parentId }) => {
const getBasekey = useCallback(() => {
return 'trailers';
}, []);
const getItemTypes = useCallback(() => {
return ['Trailer'];
}, []);
const getNoItemsMessage = useCallback(() => {
return 'MessageNoTrailersFound';
}, []);
return (
<ViewItemsContainer
topParentId={parentId}
getBasekey={getBasekey}
getItemTypes={getItemTypes}
getNoItemsMessage={getNoItemsMessage}
<ItemsView
viewType={LibraryTab.Trailers}
parentId={parentId}
itemType={[BaseItemKind.Trailer]}
noItemsMessage='MessageNoTrailersFound'
/>
);
};

View File

@ -1,13 +1,8 @@
import 'elements/emby-scroller/emby-scroller';
import 'elements/emby-itemscontainer/emby-itemscontainer';
import 'elements/emby-tabs/emby-tabs';
import 'elements/emby-button/emby-button';
import React, { FC } from 'react';
import { useLocation, useSearchParams } from 'react-router-dom';
import Page from 'components/Page';
import { getDefaultTabIndex } from '../../components/tabs/tabRoutes';
import Page from 'components/Page';
import CollectionsView from './CollectionsView';
import FavoritesView from './FavoritesView';
import GenresView from './GenresView';

View File

@ -33,11 +33,11 @@ import '../guide/programs.scss';
const enableFocusTransform = !browser.slow && !browser.edge;
/**
* Generate the HTML markup for cards for a set of items.
* @param items - The items used to generate cards.
* @param options - The options of the cards.
* @returns {string} The HTML markup for the cards.
*/
* Generate the HTML markup for cards for a set of items.
* @param items - The items used to generate cards.
* @param [options] - The options of the cards.
* @returns {string} The HTML markup for the cards.
*/
export function getCardsHtml(items, options) {
if (arguments.length === 1) {
options = arguments[0];
@ -48,10 +48,10 @@ export function getCardsHtml(items, options) {
}
/**
* Checks if the window is resizable.
* @param {number} windowWidth - Width of the device's screen.
* @returns {boolean} - Result of the check.
*/
* Checks if the window is resizable.
* @param {number} windowWidth - Width of the device's screen.
* @returns {boolean} - Result of the check.
*/
function isResizable(windowWidth) {
const screen = window.screen;
if (screen) {
@ -66,22 +66,22 @@ function isResizable(windowWidth) {
}
/**
* Gets the width of a card's image according to the shape and amount of cards per row.
* @param {string} shape - Shape of the card.
* @param {number} screenWidth - Width of the screen.
* @param {boolean} isOrientationLandscape - Flag for the orientation of the screen.
* @returns {number} Width of the image for a card.
*/
* Gets the width of a card's image according to the shape and amount of cards per row.
* @param {string} shape - Shape of the card.
* @param {number} screenWidth - Width of the screen.
* @param {boolean} isOrientationLandscape - Flag for the orientation of the screen.
* @returns {number} Width of the image for a card.
*/
function getImageWidth(shape, screenWidth, isOrientationLandscape) {
const imagesPerRow = cardBuilderUtils.getPostersPerRow(shape, screenWidth, isOrientationLandscape, layoutManager.tv);
return Math.round(screenWidth / imagesPerRow);
}
/**
* Normalizes the options for a card.
* @param {Object} items - A set of items.
* @param {Object} options - Options for handling the items.
*/
* Normalizes the options for a card.
* @param {Object} items - A set of items.
* @param {Object} options - Options for handling the items.
*/
function setCardData(items, options) {
options.shape = options.shape || 'auto';
@ -138,11 +138,11 @@ function setCardData(items, options) {
}
/**
* Generates the internal HTML markup for cards.
* @param {Object} items - Items for which to generate the markup.
* @param {Object} options - Options for generating the markup.
* @returns {string} The internal HTML markup of the cards.
*/
* Generates the internal HTML markup for cards.
* @param {Object} items - Items for which to generate the markup.
* @param {Object} options - Options for generating the markup.
* @returns {string} The internal HTML markup of the cards.
*/
function buildCardsHtmlInternal(items, options) {
let isVertical = false;
@ -256,20 +256,20 @@ function buildCardsHtmlInternal(items, options) {
}
/**
* @typedef {Object} CardImageUrl
* @property {string} imgUrl - Image URL.
* @property {string} blurhash - Image blurhash.
* @property {boolean} forceName - Force name.
* @property {boolean} coverImage - Use cover style.
*/
* @typedef {Object} CardImageUrl
* @property {string} imgUrl - Image URL.
* @property {string} blurhash - Image blurhash.
* @property {boolean} forceName - Force name.
* @property {boolean} coverImage - Use cover style.
*/
/** Get the URL of the card's image.
* @param {Object} item - Item for which to generate a card.
* @param {Object} apiClient - API client object.
* @param {Object} options - Options of the card.
* @param {string} shape - Shape of the desired image.
* @returns {CardImageUrl} Object representing the URL of the card's image.
*/
* @param {Object} item - Item for which to generate a card.
* @param {Object} apiClient - API client object.
* @param {Object} options - Options of the card.
* @param {string} shape - Shape of the desired image.
* @returns {CardImageUrl} Object representing the URL of the card's image.
*/
function getCardImageUrl(item, apiClient, options, shape) {
item = item.ProgramInfo || item;
@ -412,10 +412,10 @@ function getCardImageUrl(item, apiClient, options, shape) {
}
/**
* Generates an index used to select the default color of a card based on a string.
* @param {?string} [str] - String to use for generating the index.
* @returns {number} Index of the color.
*/
* Generates an index used to select the default color of a card based on a string.
* @param {?string} [str] - String to use for generating the index.
* @returns {number} Index of the color.
*/
function getDefaultColorIndex(str) {
const numRandomColors = 5;
@ -435,16 +435,16 @@ function getDefaultColorIndex(str) {
}
/**
* Generates the HTML markup for a card's text.
* @param {Array} lines - Array containing the text lines.
* @param {string} cssClass - Base CSS class to use for the lines.
* @param {boolean} forceLines - Flag to force the rendering of all lines.
* @param {boolean} isOuterFooter - Flag to mark the text lines as outer footer.
* @param {string} cardLayout - DEPRECATED
* @param {boolean} addRightMargin - Flag to add a right margin to the text.
* @param {number} maxLines - Maximum number of lines to render.
* @returns {string} HTML markup for the card's text.
*/
* Generates the HTML markup for a card's text.
* @param {Array} lines - Array containing the text lines.
* @param {string} cssClass - Base CSS class to use for the lines.
* @param {boolean} forceLines - Flag to force the rendering of all lines.
* @param {boolean} isOuterFooter - Flag to mark the text lines as outer footer.
* @param {string} cardLayout - DEPRECATED
* @param {boolean} addRightMargin - Flag to add a right margin to the text.
* @param {number} maxLines - Maximum number of lines to render.
* @returns {string} HTML markup for the card's text.
*/
function getCardTextLines(lines, cssClass, forceLines, isOuterFooter, cardLayout, addRightMargin, maxLines) {
let html = '';
@ -488,21 +488,21 @@ function getCardTextLines(lines, cssClass, forceLines, isOuterFooter, cardLayout
}
/**
* Determines if the item is live TV.
* @param {Object} item - Item to use for the check.
* @returns {boolean} Flag showing if the item is live TV.
*/
* Determines if the item is live TV.
* @param {Object} item - Item to use for the check.
* @returns {boolean} Flag showing if the item is live TV.
*/
function isUsingLiveTvNaming(item) {
return item.Type === 'Program' || item.Type === 'Timer' || item.Type === 'Recording';
}
/**
* Returns the air time text for the item based on the given times.
* @param {object} item - Item used to generate the air time text.
* @param {boolean} showAirDateTime - ISO8601 date for the start of the show.
* @param {boolean} showAirEndTime - ISO8601 date for the end of the show.
* @returns {string} The air time text for the item based on the given dates.
*/
* Returns the air time text for the item based on the given times.
* @param {object} item - Item used to generate the air time text.
* @param {boolean} showAirDateTime - ISO8601 date for the start of the show.
* @param {boolean} showAirEndTime - ISO8601 date for the end of the show.
* @returns {string} The air time text for the item based on the given dates.
*/
function getAirTimeText(item, showAirDateTime, showAirEndTime) {
let airTimeText = '';
@ -529,16 +529,16 @@ function getAirTimeText(item, showAirDateTime, showAirEndTime) {
}
/**
* Generates the HTML markup for the card's footer text.
* @param {Object} item - Item used to generate the footer text.
* @param {Object} apiClient - API client instance.
* @param {Object} options - Options used to generate the footer text.
* @param {string} footerClass - CSS classes of the footer element.
* @param {string} progressHtml - HTML markup of the progress bar element.
* @param {Object} flags - Various flags for the footer
* @param {Object} urls - Various urls for the footer
* @returns {string} HTML markup of the card's footer text element.
*/
* Generates the HTML markup for the card's footer text.
* @param {Object} item - Item used to generate the footer text.
* @param {Object} apiClient - API client instance.
* @param {Object} options - Options used to generate the footer text.
* @param {string} footerClass - CSS classes of the footer element.
* @param {string} progressHtml - HTML markup of the progress bar element.
* @param {Object} flags - Various flags for the footer
* @param {Object} urls - Various urls for the footer
* @returns {string} HTML markup of the card's footer text element.
*/
function getCardFooterText(item, apiClient, options, footerClass, progressHtml, flags, urls) {
item = item.ProgramInfo || item;
let html = '';
@ -669,7 +669,7 @@ function getCardFooterText(item, apiClient, options, footerClass, progressHtml,
lines.push(globalize.translate('SeriesYearToPresent', productionYear || ''));
} else if (item.EndDate && item.ProductionYear) {
const endYear = datetime.toLocaleString(datetime.parseISO8601Date(item.EndDate).getFullYear(), { useGrouping: false });
lines.push(productionYear + ((endYear === item.ProductionYear) ? '' : (' - ' + endYear)));
lines.push(productionYear + ((endYear === productionYear) ? '' : (' - ' + endYear)));
} else {
lines.push(productionYear || '');
}
@ -771,12 +771,12 @@ function getCardFooterText(item, apiClient, options, footerClass, progressHtml,
}
/**
* Generates the HTML markup for the action button.
* @param {Object} item - Item used to generate the action button.
* @param {string} text - Text of the action button.
* @param {string} serverId - ID of the server.
* @returns {string} HTML markup of the action button.
*/
* Generates the HTML markup for the action button.
* @param {Object} item - Item used to generate the action button.
* @param {string} text - Text of the action button.
* @param {string} serverId - ID of the server.
* @returns {string} HTML markup of the action button.
*/
function getTextActionButton(item, text, serverId) {
if (!text) {
text = itemHelper.getDisplayName(item);
@ -797,11 +797,11 @@ function getTextActionButton(item, text, serverId) {
}
/**
* Generates HTML markup for the item count indicator.
* @param {Object} options - Options used to generate the item count.
* @param {Object} item - Item used to generate the item count.
* @returns {string} HTML markup for the item count indicator.
*/
* Generates HTML markup for the item count indicator.
* @param {Object} options - Options used to generate the item count.
* @param {Object} item - Item used to generate the item count.
* @returns {string} HTML markup for the item count indicator.
*/
function getItemCountsHtml(options, item) {
const counts = [];
let childText;
@ -879,8 +879,8 @@ function getItemCountsHtml(options, item) {
let refreshIndicatorLoaded;
/**
* Imports the refresh indicator element.
*/
* Imports the refresh indicator element.
*/
function importRefreshIndicator() {
if (!refreshIndicatorLoaded) {
refreshIndicatorLoaded = true;
@ -889,22 +889,22 @@ function importRefreshIndicator() {
}
/**
* Returns the default background class for a card based on a string.
* @param {?string} [str] - Text used to generate the background class.
* @returns {string} CSS classes for default card backgrounds.
*/
* Returns the default background class for a card based on a string.
* @param {?string} [str] - Text used to generate the background class.
* @returns {string} CSS classes for default card backgrounds.
*/
export function getDefaultBackgroundClass(str) {
return 'defaultCardBackground defaultCardBackground' + getDefaultColorIndex(str);
}
/**
* Builds the HTML markup for an individual card.
* @param {number} index - Index of the card
* @param {object} item - Item used to generate the card.
* @param {object} apiClient - API client instance.
* @param {object} options - Options used to generate the card.
* @returns {string} HTML markup for the generated card.
*/
* Builds the HTML markup for an individual card.
* @param {number} index - Index of the card
* @param {object} item - Item used to generate the card.
* @param {object} apiClient - API client instance.
* @param {object} options - Options used to generate the card.
* @returns {string} HTML markup for the generated card.
*/
function buildCard(index, item, apiClient, options) {
let action = options.action || 'link';
@ -1211,11 +1211,11 @@ function buildCard(index, item, apiClient, options) {
}
/**
* Generates HTML markup for the card overlay.
* @param {object} item - Item used to generate the card overlay.
* @param {string} action - Action assigned to the overlay.
* @returns {string} HTML markup of the card overlay.
*/
* Generates HTML markup for the card overlay.
* @param {object} item - Item used to generate the card overlay.
* @param {string} action - Action assigned to the overlay.
* @returns {string} HTML markup of the card overlay.
*/
function getHoverMenuHtml(item, action) {
let html = '';
@ -1253,11 +1253,11 @@ function getHoverMenuHtml(item, action) {
}
/**
* Generates the text or icon used for default card backgrounds.
* @param {object} item - Item used to generate the card overlay.
* @param {object} options - Options used to generate the card overlay.
* @returns {string} HTML markup of the card overlay.
*/
* Generates the text or icon used for default card backgrounds.
* @param {object} item - Item used to generate the card overlay.
* @param {object} options - Options used to generate the card overlay.
* @returns {string} HTML markup of the card overlay.
*/
export function getDefaultText(item, options) {
if (item.CollectionType) {
return '<span class="cardImageIcon material-icons ' + imageHelper.getLibraryIcon(item.CollectionType) + '" aria-hidden="true"></span>';
@ -1301,10 +1301,10 @@ export function getDefaultText(item, options) {
}
/**
* Builds a set of cards and inserts them into the page.
* @param {Array} items - Array of items used to build the cards.
* @param {options} options - Options of the cards to build.
*/
* Builds a set of cards and inserts them into the page.
* @param {Array} items - Array of items used to build the cards.
* @param {options} options - Options of the cards to build.
*/
export function buildCards(items, options) {
// Abort if the container has been disposed
if (!document.body.contains(options.itemsContainer)) {
@ -1345,11 +1345,11 @@ export function buildCards(items, options) {
}
/**
* Ensures the indicators for a card exist and creates them if they don't exist.
* @param {HTMLDivElement} card - DOM element of the card.
* @param {HTMLDivElement} indicatorsElem - DOM element of the indicators.
* @returns {HTMLDivElement} - DOM element of the indicators.
*/
* Ensures the indicators for a card exist and creates them if they don't exist.
* @param {HTMLDivElement} card - DOM element of the card.
* @param {HTMLDivElement} indicatorsElem - DOM element of the indicators.
* @returns {HTMLDivElement} - DOM element of the indicators.
*/
function ensureIndicators(card, indicatorsElem) {
if (indicatorsElem) {
return indicatorsElem;
@ -1368,10 +1368,10 @@ function ensureIndicators(card, indicatorsElem) {
}
/**
* Adds user data to the card such as progress indicators and played status.
* @param {HTMLDivElement} card - DOM element of the card.
* @param {Object} userData - User data to apply to the card.
*/
* Adds user data to the card such as progress indicators and played status.
* @param {HTMLDivElement} card - DOM element of the card.
* @param {Object} userData - User data to apply to the card.
*/
function updateUserData(card, userData) {
const type = card.getAttribute('data-type');
const enableCountIndicator = type === 'Series' || type === 'BoxSet' || type === 'Season';
@ -1447,10 +1447,10 @@ function updateUserData(card, userData) {
}
/**
* Handles when user data has changed.
* @param {Object} userData - User data to apply to the card.
* @param {HTMLElement} scope - DOM element to use as a scope when selecting cards.
*/
* Handles when user data has changed.
* @param {Object} userData - User data to apply to the card.
* @param {HTMLElement} scope - DOM element to use as a scope when selecting cards.
*/
export function onUserDataChanged(userData, scope) {
const cards = (scope || document.body).querySelectorAll('.card-withuserdata[data-id="' + userData.ItemId + '"]');
@ -1460,11 +1460,11 @@ export function onUserDataChanged(userData, scope) {
}
/**
* Handles when a timer has been created.
* @param {string} programId - ID of the program.
* @param {string} newTimerId - ID of the new timer.
* @param {HTMLElement} itemsContainer - DOM element of the itemsContainer.
*/
* Handles when a timer has been created.
* @param {string} programId - ID of the program.
* @param {string} newTimerId - ID of the new timer.
* @param {HTMLElement} itemsContainer - DOM element of the itemsContainer.
*/
export function onTimerCreated(programId, newTimerId, itemsContainer) {
const cells = itemsContainer.querySelectorAll('.card[data-id="' + programId + '"]');
@ -1480,10 +1480,10 @@ export function onTimerCreated(programId, newTimerId, itemsContainer) {
}
/**
* Handles when a timer has been cancelled.
* @param {string} timerId - ID of the cancelled timer.
* @param {HTMLElement} itemsContainer - DOM element of the itemsContainer.
*/
* Handles when a timer has been cancelled.
* @param {string} timerId - ID of the cancelled timer.
* @param {HTMLElement} itemsContainer - DOM element of the itemsContainer.
*/
export function onTimerCancelled(timerId, itemsContainer) {
const cells = itemsContainer.querySelectorAll('.card[data-timerid="' + timerId + '"]');
@ -1497,10 +1497,10 @@ export function onTimerCancelled(timerId, itemsContainer) {
}
/**
* Handles when a series timer has been cancelled.
* @param {string} cancelledTimerId - ID of the cancelled timer.
* @param {HTMLElement} itemsContainer - DOM element of the itemsContainer.
*/
* Handles when a series timer has been cancelled.
* @param {string} cancelledTimerId - ID of the cancelled timer.
* @param {HTMLElement} itemsContainer - DOM element of the itemsContainer.
*/
export function onSeriesTimerCancelled(cancelledTimerId, itemsContainer) {
const cells = itemsContainer.querySelectorAll('.card[data-seriestimerid="' + cancelledTimerId + '"]');

View File

@ -1,59 +0,0 @@
import React, { FC, useCallback, useEffect, useRef, useState } from 'react';
import AlphaPicker from '../alphaPicker/alphaPicker';
import { ViewQuerySettings } from '../../types/interface';
interface AlphaPickerContainerProps {
viewQuerySettings: ViewQuerySettings;
setViewQuerySettings: React.Dispatch<React.SetStateAction<ViewQuerySettings>>;
}
const AlphaPickerContainer: FC<AlphaPickerContainerProps> = ({ viewQuerySettings, setViewQuerySettings }) => {
const [ alphaPicker, setAlphaPicker ] = useState<AlphaPicker>();
const element = useRef<HTMLDivElement>(null);
alphaPicker?.updateControls(viewQuerySettings);
const onAlphaPickerChange = useCallback((e) => {
const newValue = (e as CustomEvent).detail.value;
let updatedValue: React.SetStateAction<ViewQuerySettings>;
if (newValue === '#') {
updatedValue = {
NameLessThan: 'A',
NameStartsWith: undefined
};
} else {
updatedValue = {
NameLessThan: undefined,
NameStartsWith: newValue
};
}
setViewQuerySettings((prevState) => ({
...prevState,
StartIndex: 0,
...updatedValue
}));
}, [setViewQuerySettings]);
useEffect(() => {
const alphaPickerElement = element.current;
setAlphaPicker(new AlphaPicker({
element: alphaPickerElement,
valueChangeEvent: 'click'
}));
if (alphaPickerElement) {
alphaPickerElement.addEventListener('alphavaluechanged', onAlphaPickerChange);
}
return () => {
alphaPickerElement?.removeEventListener('alphavaluechanged', onAlphaPickerChange);
};
}, [onAlphaPickerChange]);
return (
<div ref={element} className='alphaPicker alphaPicker-fixed alphaPicker-fixed-right alphaPicker-vertical alphabetPicker-right' />
);
};
export default AlphaPickerContainer;

View File

@ -1,65 +0,0 @@
import React, { FC, useCallback, useEffect, useRef } from 'react';
import IconButtonElement from '../../elements/IconButtonElement';
import { ViewQuerySettings } from '../../types/interface';
interface FilterProps {
topParentId?: string | null;
getItemTypes: () => string[];
getFilterMenuOptions: () => Record<string, never>;
getVisibleFilters: () => string[];
viewQuerySettings: ViewQuerySettings;
setViewQuerySettings: React.Dispatch<React.SetStateAction<ViewQuerySettings>>;
}
const Filter: FC<FilterProps> = ({
topParentId,
getItemTypes,
getVisibleFilters,
getFilterMenuOptions,
viewQuerySettings,
setViewQuerySettings
}) => {
const element = useRef<HTMLDivElement>(null);
const showFilterMenu = useCallback(() => {
import('../filtermenu/filtermenu').then(({ default: FilterMenu }) => {
const filterMenu = new FilterMenu();
filterMenu.show({
settings: viewQuerySettings,
visibleSettings: getVisibleFilters(),
parentId: topParentId,
itemTypes: getItemTypes(),
serverId: window.ApiClient.serverId(),
filterMenuOptions: getFilterMenuOptions(),
setfilters: setViewQuerySettings
}).catch(() => {
// filter menu closed
});
}).catch(err => {
console.error('[Filter] failed to load filter menu', err);
});
}, [viewQuerySettings, getVisibleFilters, topParentId, getItemTypes, getFilterMenuOptions, setViewQuerySettings]);
useEffect(() => {
const btnFilter = element.current?.querySelector('.btnFilter');
btnFilter?.addEventListener('click', showFilterMenu);
return () => {
btnFilter?.removeEventListener('click', showFilterMenu);
};
}, [showFilterMenu]);
return (
<div ref={element}>
<IconButtonElement
is='paper-icon-button-light'
className='btnFilter autoSize'
title='Filter'
icon='material-icons filter_list'
/>
</div>
);
};
export default Filter;

View File

@ -1,42 +0,0 @@
import React, { FC, useCallback, useEffect, useRef } from 'react';
import IconButtonElement from '../../elements/IconButtonElement';
const NewCollection: FC = () => {
const element = useRef<HTMLDivElement>(null);
const showCollectionEditor = useCallback(() => {
import('../collectionEditor/collectionEditor').then(({ default: CollectionEditor }) => {
const serverId = window.ApiClient.serverId();
const collectionEditor = new CollectionEditor();
collectionEditor.show({
items: [],
serverId: serverId
}).catch(() => {
// closed collection editor
});
}).catch(err => {
console.error('[NewCollection] failed to load collection editor', err);
});
}, []);
useEffect(() => {
const btnNewCollection = element.current?.querySelector('.btnNewCollection');
if (btnNewCollection) {
btnNewCollection.addEventListener('click', showCollectionEditor);
}
}, [showCollectionEditor]);
return (
<div ref={element}>
<IconButtonElement
is='paper-icon-button-light'
className='btnNewCollection autoSize'
title='Add'
icon='material-icons add'
/>
</div>
);
};
export default NewCollection;

View File

@ -1,97 +0,0 @@
import type { BaseItemDtoQueryResult } from '@jellyfin/sdk/lib/generated-client';
import React, { FC, useCallback, useEffect, useRef } from 'react';
import IconButtonElement from '../../elements/IconButtonElement';
import globalize from '../../scripts/globalize';
import * as userSettings from '../../scripts/settings/userSettings';
import { ViewQuerySettings } from '../../types/interface';
interface PaginationProps {
viewQuerySettings: ViewQuerySettings;
setViewQuerySettings: React.Dispatch<React.SetStateAction<ViewQuerySettings>>;
itemsResult?: BaseItemDtoQueryResult;
}
const Pagination: FC<PaginationProps> = ({ viewQuerySettings, setViewQuerySettings, itemsResult = {} }) => {
const limit = userSettings.libraryPageSize(undefined);
const totalRecordCount = itemsResult.TotalRecordCount || 0;
const startIndex = viewQuerySettings.StartIndex || 0;
const recordsStart = totalRecordCount ? startIndex + 1 : 0;
const recordsEnd = limit ? Math.min(startIndex + limit, totalRecordCount) : totalRecordCount;
const showControls = limit > 0 && limit < totalRecordCount;
const element = useRef<HTMLDivElement>(null);
const onNextPageClick = useCallback(() => {
if (limit > 0) {
const newIndex = startIndex + limit;
setViewQuerySettings((prevState) => ({
...prevState,
StartIndex: newIndex
}));
}
}, [limit, setViewQuerySettings, startIndex]);
const onPreviousPageClick = useCallback(() => {
if (limit > 0) {
const newIndex = Math.max(0, startIndex - limit);
setViewQuerySettings((prevState) => ({
...prevState,
StartIndex: newIndex
}));
}
}, [limit, setViewQuerySettings, startIndex]);
useEffect(() => {
const btnNextPage = element.current?.querySelector('.btnNextPage') as HTMLButtonElement;
if (btnNextPage) {
if (startIndex + limit >= totalRecordCount) {
btnNextPage.disabled = true;
} else {
btnNextPage.disabled = false;
}
btnNextPage.addEventListener('click', onNextPageClick);
}
const btnPreviousPage = element.current?.querySelector('.btnPreviousPage') as HTMLButtonElement;
if (btnPreviousPage) {
if (startIndex) {
btnPreviousPage.disabled = false;
} else {
btnPreviousPage.disabled = true;
}
btnPreviousPage.addEventListener('click', onPreviousPageClick);
}
return () => {
btnNextPage?.removeEventListener('click', onNextPageClick);
btnPreviousPage?.removeEventListener('click', onPreviousPageClick);
};
}, [totalRecordCount, onNextPageClick, onPreviousPageClick, limit, startIndex]);
return (
<div ref={element}>
<div className='paging'>
<div className='listPaging' style={{ display: 'flex', alignItems: 'center' }}>
<span>
{globalize.translate('ListPaging', recordsStart, recordsEnd, totalRecordCount)}
</span>
{showControls && (
<>
<IconButtonElement
is='paper-icon-button-light'
className='btnPreviousPage autoSize'
icon='material-icons arrow_back'
/>
<IconButtonElement
is='paper-icon-button-light'
className='btnNextPage autoSize'
icon='material-icons arrow_forward'
/>
</>
)}
</div>
</div>
</div>
);
};
export default Pagination;

View File

@ -1,54 +0,0 @@
import React, { FC, useCallback, useEffect, useRef } from 'react';
import IconButtonElement from '../../elements/IconButtonElement';
import { ViewQuerySettings } from '../../types/interface';
interface SelectViewProps {
getVisibleViewSettings: () => string[];
viewQuerySettings: ViewQuerySettings;
setViewQuerySettings: React.Dispatch<React.SetStateAction<ViewQuerySettings>>;
}
const SelectView: FC<SelectViewProps> = ({
getVisibleViewSettings,
viewQuerySettings,
setViewQuerySettings
}) => {
const element = useRef<HTMLDivElement>(null);
const showViewSettingsMenu = useCallback(() => {
import('../viewSettings/viewSettings').then(({ default: ViewSettings }) => {
const viewsettings = new ViewSettings();
viewsettings.show({
settings: viewQuerySettings,
visibleSettings: getVisibleViewSettings(),
setviewsettings: setViewQuerySettings
}).catch(() => {
// view settings closed
});
}).catch(err => {
console.error('[SelectView] failed to load view settings', err);
});
}, [getVisibleViewSettings, viewQuerySettings, setViewQuerySettings]);
useEffect(() => {
const btnSelectView = element.current?.querySelector('.btnSelectView') as HTMLButtonElement;
btnSelectView?.addEventListener('click', showViewSettingsMenu);
return () => {
btnSelectView?.removeEventListener('click', showViewSettingsMenu);
};
}, [showViewSettingsMenu]);
return (
<div ref={element}>
<IconButtonElement
is='paper-icon-button-light'
className='btnSelectView autoSize'
title='ButtonSelectView'
icon='material-icons view_comfy'
/>
</div>
);
};
export default SelectView;

View File

@ -1,45 +0,0 @@
import type { BaseItemDtoQueryResult } from '@jellyfin/sdk/lib/generated-client';
import React, { FC, useCallback, useEffect, useRef } from 'react';
import { playbackManager } from '../playback/playbackmanager';
import IconButtonElement from '../../elements/IconButtonElement';
interface ShuffleProps {
itemsResult?: BaseItemDtoQueryResult;
topParentId: string | null;
}
const Shuffle: FC<ShuffleProps> = ({ itemsResult = {}, topParentId }) => {
const element = useRef<HTMLDivElement>(null);
const shuffle = useCallback(() => {
window.ApiClient.getItem(
window.ApiClient.getCurrentUserId(),
topParentId as string
).then((item) => {
playbackManager.shuffle(item);
}).catch(err => {
console.error('[Shuffle] failed to fetch items', err);
});
}, [topParentId]);
useEffect(() => {
const btnShuffle = element.current?.querySelector('.btnShuffle');
if (btnShuffle) {
btnShuffle.addEventListener('click', shuffle);
}
}, [itemsResult.TotalRecordCount, shuffle]);
return (
<div ref={element}>
<IconButtonElement
is='paper-icon-button-light'
className='btnShuffle autoSize'
title='Shuffle'
icon='material-icons shuffle'
/>
</div>
);
};
export default Shuffle;

View File

@ -1,58 +0,0 @@
import React, { FC, useCallback, useEffect, useRef } from 'react';
import IconButtonElement from '../../elements/IconButtonElement';
import { ViewQuerySettings } from '../../types/interface';
interface SortProps {
getSortMenuOptions: () => {
name: string;
value: string;
}[];
viewQuerySettings: ViewQuerySettings;
setViewQuerySettings: React.Dispatch<React.SetStateAction<ViewQuerySettings>>;
}
const Sort: FC<SortProps> = ({
getSortMenuOptions,
viewQuerySettings,
setViewQuerySettings
}) => {
const element = useRef<HTMLDivElement>(null);
const showSortMenu = useCallback(() => {
import('../sortmenu/sortmenu').then(({ default: SortMenu }) => {
const sortMenu = new SortMenu();
sortMenu.show({
settings: viewQuerySettings,
sortOptions: getSortMenuOptions(),
setSortValues: setViewQuerySettings
}).catch(() => {
// sort menu closed
});
}).catch(err => {
console.error('[Sort] failed to load sort menu', err);
});
}, [getSortMenuOptions, viewQuerySettings, setViewQuerySettings]);
useEffect(() => {
const btnSort = element.current?.querySelector('.btnSort');
btnSort?.addEventListener('click', showSortMenu);
return () => {
btnSort?.removeEventListener('click', showSortMenu);
};
}, [showSortMenu]);
return (
<div ref={element}>
<IconButtonElement
is='paper-icon-button-light'
className='btnSort autoSize'
title='Sort'
icon='material-icons sort_by_alpha'
/>
</div>
);
};
export default Sort;

View File

@ -1,411 +0,0 @@
import { type BaseItemDtoQueryResult, ItemFields, ItemFilter } from '@jellyfin/sdk/lib/generated-client';
import React, { FC, useCallback, useEffect, useRef, useState } from 'react';
import loading from '../loading/loading';
import * as userSettings from '../../scripts/settings/userSettings';
import AlphaPickerContainer from './AlphaPickerContainer';
import Filter from './Filter';
import ItemsContainer from './ItemsContainer';
import Pagination from './Pagination';
import SelectView from './SelectView';
import Shuffle from './Shuffle';
import Sort from './Sort';
import NewCollection from './NewCollection';
import globalize from '../../scripts/globalize';
import ServerConnections from '../ServerConnections';
import { useLocalStorage } from '../../hooks/useLocalStorage';
import listview from '../listview/listview';
import cardBuilder from '../cardbuilder/cardBuilder';
import { ViewQuerySettings } from '../../types/interface';
import { CardOptions } from '../../types/cardOptions';
interface ViewItemsContainerProps {
topParentId: string | null;
isBtnShuffleEnabled?: boolean;
isBtnFilterEnabled?: boolean;
isBtnNewCollectionEnabled?: boolean;
isAlphaPickerEnabled?: boolean;
getBasekey: () => string;
getItemTypes: () => string[];
getNoItemsMessage: () => string;
}
const getDefaultSortBy = () => {
return 'SortName';
};
const getFields = (viewQuerySettings: ViewQuerySettings) => {
const fields: ItemFields[] = [
ItemFields.BasicSyncInfo,
ItemFields.MediaSourceCount
];
if (viewQuerySettings.imageType === 'primary') {
fields.push(ItemFields.PrimaryImageAspectRatio);
}
return fields.join(',');
};
const getFilters = (viewQuerySettings: ViewQuerySettings) => {
const filters: ItemFilter[] = [];
if (viewQuerySettings.IsPlayed) {
filters.push(ItemFilter.IsPlayed);
}
if (viewQuerySettings.IsUnplayed) {
filters.push(ItemFilter.IsUnplayed);
}
if (viewQuerySettings.IsFavorite) {
filters.push(ItemFilter.IsFavorite);
}
if (viewQuerySettings.IsResumable) {
filters.push(ItemFilter.IsResumable);
}
return filters;
};
const getVisibleViewSettings = () => {
return [
'showTitle',
'showYear',
'imageType',
'cardLayout'
];
};
const getFilterMenuOptions = () => {
return {};
};
const getVisibleFilters = () => {
return [
'IsUnplayed',
'IsPlayed',
'IsFavorite',
'IsResumable',
'VideoType',
'HasSubtitles',
'HasTrailer',
'HasSpecialFeature',
'HasThemeSong',
'HasThemeVideo'
];
};
const getSortMenuOptions = () => {
return [{
name: globalize.translate('Name'),
value: 'SortName,ProductionYear'
}, {
name: globalize.translate('OptionRandom'),
value: 'Random'
}, {
name: globalize.translate('OptionImdbRating'),
value: 'CommunityRating,SortName,ProductionYear'
}, {
name: globalize.translate('OptionCriticRating'),
value: 'CriticRating,SortName,ProductionYear'
}, {
name: globalize.translate('OptionDateAdded'),
value: 'DateCreated,SortName,ProductionYear'
}, {
name: globalize.translate('OptionDatePlayed'),
value: 'DatePlayed,SortName,ProductionYear'
}, {
name: globalize.translate('OptionParentalRating'),
value: 'OfficialRating,SortName,ProductionYear'
}, {
name: globalize.translate('OptionPlayCount'),
value: 'PlayCount,SortName,ProductionYear'
}, {
name: globalize.translate('OptionReleaseDate'),
value: 'PremiereDate,SortName,ProductionYear'
}, {
name: globalize.translate('Runtime'),
value: 'Runtime,SortName,ProductionYear'
}];
};
const defaultViewQuerySettings: ViewQuerySettings = {
showTitle: true,
showYear: true,
imageType: 'primary',
viewType: '',
cardLayout: false,
SortBy: getDefaultSortBy(),
SortOrder: 'Ascending',
IsPlayed: false,
IsUnplayed: false,
IsFavorite: false,
IsResumable: false,
Is4K: null,
IsHD: null,
IsSD: null,
Is3D: null,
VideoTypes: '',
SeriesStatus: '',
HasSubtitles: null,
HasTrailer: null,
HasSpecialFeature: null,
HasThemeSong: null,
HasThemeVideo: null,
GenreIds: '',
StartIndex: 0
};
const ViewItemsContainer: FC<ViewItemsContainerProps> = ({
topParentId,
isBtnShuffleEnabled = false,
isBtnFilterEnabled = true,
isBtnNewCollectionEnabled = false,
isAlphaPickerEnabled = true,
getBasekey,
getItemTypes,
getNoItemsMessage
}) => {
const getSettingsKey = useCallback(() => {
return `${topParentId} - ${getBasekey()}`;
}, [getBasekey, topParentId]);
const [isLoading, setisLoading] = useState(false);
const [viewQuerySettings, setViewQuerySettings] = useLocalStorage<ViewQuerySettings>(
`viewQuerySettings - ${getSettingsKey()}`,
defaultViewQuerySettings
);
const [ itemsResult, setItemsResult ] = useState<BaseItemDtoQueryResult>({});
const element = useRef<HTMLDivElement>(null);
const getContext = useCallback(() => {
const itemType = getItemTypes().join(',');
if (itemType === 'Movie' || itemType === 'BoxSet') {
return 'movies';
}
return null;
}, [getItemTypes]);
const getCardOptions = useCallback(() => {
let shape;
let preferThumb;
let preferDisc;
let preferLogo;
if (viewQuerySettings.imageType === 'banner') {
shape = 'banner';
} else if (viewQuerySettings.imageType === 'disc') {
shape = 'square';
preferDisc = true;
} else if (viewQuerySettings.imageType === 'logo') {
shape = 'backdrop';
preferLogo = true;
} else if (viewQuerySettings.imageType === 'thumb') {
shape = 'backdrop';
preferThumb = true;
} else {
shape = 'autoVertical';
}
const cardOptions: CardOptions = {
shape: shape,
showTitle: viewQuerySettings.showTitle,
showYear: viewQuerySettings.showYear,
cardLayout: viewQuerySettings.cardLayout,
centerText: true,
context: getContext(),
coverImage: true,
preferThumb: preferThumb,
preferDisc: preferDisc,
preferLogo: preferLogo,
overlayPlayButton: false,
overlayMoreButton: true,
overlayText: !viewQuerySettings.showTitle
};
cardOptions.items = itemsResult.Items || [];
return cardOptions;
}, [
getContext,
itemsResult.Items,
viewQuerySettings.cardLayout,
viewQuerySettings.imageType,
viewQuerySettings.showTitle,
viewQuerySettings.showYear
]);
const getItemsHtml = useCallback(() => {
let html = '';
if (viewQuerySettings.imageType === 'list') {
html = listview.getListViewHtml({
items: itemsResult.Items || [],
context: getContext()
});
} else {
html = cardBuilder.getCardsHtml(itemsResult.Items || [], getCardOptions());
}
if (!itemsResult.Items?.length) {
html += '<div class="noItemsMessage centerMessage">';
html += '<h1>' + globalize.translate('MessageNothingHere') + '</h1>';
html += '<p>' + globalize.translate(getNoItemsMessage()) + '</p>';
html += '</div>';
}
return html;
}, [getCardOptions, getContext, itemsResult.Items, getNoItemsMessage, viewQuerySettings.imageType]);
const getQuery = useCallback(() => {
const queryFilters = getFilters(viewQuerySettings);
let queryIsHD;
if (viewQuerySettings.IsHD) {
queryIsHD = true;
}
if (viewQuerySettings.IsSD) {
queryIsHD = false;
}
return {
SortBy: viewQuerySettings.SortBy,
SortOrder: viewQuerySettings.SortOrder,
IncludeItemTypes: getItemTypes().join(','),
Recursive: true,
Fields: getFields(viewQuerySettings),
ImageTypeLimit: 1,
EnableImageTypes: 'Primary,Backdrop,Banner,Thumb,Disc,Logo',
Limit: userSettings.libraryPageSize(undefined) || undefined,
IsFavorite: getBasekey() === 'favorites' ? true : null,
VideoTypes: viewQuerySettings.VideoTypes,
GenreIds: viewQuerySettings.GenreIds,
Is4K: viewQuerySettings.Is4K ? true : null,
IsHD: queryIsHD,
Is3D: viewQuerySettings.Is3D ? true : null,
HasSubtitles: viewQuerySettings.HasSubtitles ? true : null,
HasTrailer: viewQuerySettings.HasTrailer ? true : null,
HasSpecialFeature: viewQuerySettings.HasSpecialFeature ? true : null,
HasThemeSong: viewQuerySettings.HasThemeSong ? true : null,
HasThemeVideo: viewQuerySettings.HasThemeVideo ? true : null,
Filters: queryFilters.length ? queryFilters.join(',') : null,
StartIndex: viewQuerySettings.StartIndex,
NameLessThan: viewQuerySettings.NameLessThan,
NameStartsWith: viewQuerySettings.NameStartsWith,
ParentId: topParentId
};
}, [
viewQuerySettings,
getItemTypes,
getBasekey,
topParentId
]);
const fetchData = useCallback(() => {
loading.show();
const apiClient = ServerConnections.getApiClient(window.ApiClient.serverId());
return apiClient.getItems(
apiClient.getCurrentUserId(),
{
...getQuery()
}
);
}, [getQuery]);
const reloadItems = useCallback(() => {
const page = element.current;
if (!page) {
console.error('Unexpected null reference');
return;
}
setisLoading(false);
fetchData().then((result) => {
setItemsResult(result);
window.scrollTo(0, 0);
import('../../components/autoFocuser').then(({ default: autoFocuser }) => {
autoFocuser.autoFocus(page);
}).catch(err => {
console.error('[ViewItemsContainer] failed to load autofocuser', err);
});
loading.hide();
setisLoading(true);
}).catch(err => {
console.error('[ViewItemsContainer] failed to fetch data', err);
});
}, [fetchData]);
useEffect(() => {
reloadItems();
}, [reloadItems]);
return (
<div ref={element}>
<div className='flex align-items-center justify-content-center flex-wrap-wrap padded-top padded-left padded-right padded-bottom focuscontainer-x'>
<Pagination
itemsResult= {itemsResult}
viewQuerySettings={viewQuerySettings}
setViewQuerySettings={setViewQuerySettings}
/>
{isBtnShuffleEnabled && <Shuffle itemsResult={itemsResult} topParentId={topParentId} />}
<SelectView
getVisibleViewSettings={getVisibleViewSettings}
viewQuerySettings={viewQuerySettings}
setViewQuerySettings={setViewQuerySettings}
/>
<Sort
getSortMenuOptions={getSortMenuOptions}
viewQuerySettings={viewQuerySettings}
setViewQuerySettings={setViewQuerySettings}
/>
{isBtnFilterEnabled && <Filter
topParentId={topParentId}
getItemTypes={getItemTypes}
getVisibleFilters={getVisibleFilters}
getFilterMenuOptions={getFilterMenuOptions}
viewQuerySettings={viewQuerySettings}
setViewQuerySettings={setViewQuerySettings}
/>}
{isBtnNewCollectionEnabled && <NewCollection />}
</div>
{isAlphaPickerEnabled && <AlphaPickerContainer
viewQuerySettings={viewQuerySettings}
setViewQuerySettings={setViewQuerySettings}
/>}
{isLoading && <ItemsContainer
viewQuerySettings={viewQuerySettings}
getItemsHtml={getItemsHtml}
/>}
<div className='flex align-items-center justify-content-center flex-wrap-wrap padded-top padded-left padded-right padded-bottom focuscontainer-x'>
<Pagination
itemsResult= {itemsResult}
viewQuerySettings={viewQuerySettings}
setViewQuerySettings={setViewQuerySettings}
/>
</div>
</div>
);
};
export default ViewItemsContainer;

View File

@ -103,37 +103,29 @@ function onInputCommand(e) {
}
}
function saveValues(context, settings, settingsKey, setfilters) {
let elems = context.querySelectorAll('.simpleFilter');
// Video type
const videoTypes = [];
elems = context.querySelectorAll('.chkVideoTypeFilter');
for (let i = 0, length = elems.length; i < length; i++) {
if (elems[i].checked) {
videoTypes.push(elems[i].getAttribute('data-filter'));
context.querySelectorAll('.chkVideoTypeFilter').forEach(elem => {
if (elem.checked) {
videoTypes.push(elem.getAttribute('data-filter'));
}
}
});
// Series status
const seriesStatuses = [];
elems = context.querySelectorAll('.chkSeriesStatus');
for (let i = 0, length = elems.length; i < length; i++) {
if (elems[i].checked) {
seriesStatuses.push(elems[i].getAttribute('data-filter'));
context.querySelectorAll('.chkSeriesStatus').forEach(elem => {
if (elem.checked) {
seriesStatuses.push(elem.getAttribute('data-filter'));
}
}
});
// Genres
const genres = [];
elems = context.querySelectorAll('.chkGenreFilter');
for (let i = 0, length = elems.length; i < length; i++) {
if (elems[i].checked) {
genres.push(elems[i].getAttribute('data-filter'));
context.querySelectorAll('.chkGenreFilter').forEach(elem => {
if (elem.checked) {
genres.push(elem.getAttribute('data-filter'));
}
}
});
if (setfilters) {
setfilters((prevState) => ({
@ -157,13 +149,13 @@ function saveValues(context, settings, settingsKey, setfilters) {
GenreIds: genres.join(',')
}));
} else {
for (let i = 0, length = elems.length; i < length; i++) {
if (elems[i].tagName === 'INPUT') {
setBasicFilter(context, settingsKey + '-filter-' + elems[i].getAttribute('data-settingname'), elems[i]);
context.querySelectorAll('.simpleFilter').forEach(elem => {
if (elem.tagName === 'INPUT') {
setBasicFilter(context, settingsKey + '-filter-' + elem.getAttribute('data-settingname'), elem);
} else {
setBasicFilter(context, settingsKey + '-filter-' + elems[i].getAttribute('data-settingname'), elems[i].querySelector('input'));
setBasicFilter(context, settingsKey + '-filter-' + elem.getAttribute('data-settingname'), elem.querySelector('input'));
}
}
});
userSettings.setFilter(settingsKey + '-filter-GenreIds', genres.join(','));
}

View File

@ -1,15 +1,15 @@
import escapeHtml from 'escape-html';
import layoutManager from 'components/layoutManager';
import globalize from 'scripts/globalize';
import imageHelper from 'scripts/imagehelper';
import { getBackdropShape, getPortraitShape, getSquareShape } from 'utils/card';
import { DEFAULT_SECTIONS, HomeSectionType } from 'types/homeSectionType';
import Dashboard from 'utils/dashboard';
import cardBuilder from '../cardbuilder/cardBuilder';
import imageLoader from '../images/imageLoader';
import layoutManager from '../layoutManager';
import { appRouter } from '../router/appRouter';
import ServerConnections from '../ServerConnections';
import { loadRecordings } from './sections/activeRecordings';
import { loadLibraryButtons } from './sections/libraryButtons';
import { loadLibraryTiles } from './sections/libraryTiles';
import { loadLiveTV } from './sections/liveTv';
import { loadNextUp } from './sections/nextUp';
import { loadRecentlyAdded } from './sections/recentlyAdded';
import { loadResume } from './sections/resume';
import 'elements/emby-button/paper-icon-button-light';
import 'elements/emby-itemscontainer/emby-itemscontainer';
@ -19,26 +19,8 @@ import 'elements/emby-button/emby-button';
import './homesections.scss';
export function getDefaultSection(index) {
switch (index) {
case 0:
return 'smalllibrarytiles';
case 1:
return 'resume';
case 2:
return 'resumeaudio';
case 3:
return 'resumebook';
case 4:
return 'livetv';
case 5:
return 'nextup';
case 6:
return 'latestmedia';
case 7:
return 'none';
default:
return '';
}
if (index < 0 || index > DEFAULT_SECTIONS.length) return '';
return DEFAULT_SECTIONS[index];
}
function getAllSectionsToShow(userSettings, sectionCount) {
@ -52,6 +34,18 @@ function getAllSectionsToShow(userSettings, sectionCount) {
sections.push(section);
}
// Ensure libraries are visible in TV layout
if (
layoutManager.tv
&& !sections.includes(HomeSectionType.SmallLibraryTiles)
&& !sections.includes(HomeSectionType.LibraryButtons)
) {
return [
HomeSectionType.SmallLibraryTiles,
...sections
];
}
return sections;
}
@ -60,8 +54,10 @@ export function loadSections(elem, apiClient, user, userSettings) {
let html = '';
if (userViews.length) {
const sectionCount = 7;
for (let i = 0; i < sectionCount; i++) {
const userSectionCount = 7;
// TV layout can have an extra section to ensure libraries are visible
const totalSectionCount = layoutManager.tv ? userSectionCount + 1 : userSectionCount;
for (let i = 0; i < totalSectionCount; i++) {
html += '<div class="verticalSection section' + i + '"></div>';
}
@ -69,7 +65,7 @@ export function loadSections(elem, apiClient, user, userSettings) {
elem.classList.add('homeSectionsContainer');
const promises = [];
const sections = getAllSectionsToShow(userSettings, sectionCount);
const sections = getAllSectionsToShow(userSettings, userSectionCount);
for (let i = 0; i < sections.length; i++) {
promises.push(loadSection(elem, apiClient, user, userSettings, userViews, sections, i));
}
@ -138,29 +134,36 @@ export function resume(elem, options) {
function loadSection(page, apiClient, user, userSettings, userViews, allSections, index) {
const section = allSections[index];
const elem = page.querySelector('.section' + index);
const options = { enableOverflow: enableScrollX() };
if (section === 'latestmedia') {
loadRecentlyAdded(elem, apiClient, user, userViews);
} else if (section === 'librarytiles' || section === 'smalllibrarytiles' || section === 'smalllibrarytiles-automobile' || section === 'librarytiles-automobile') {
loadLibraryTiles(elem, apiClient, user, userSettings, 'smallBackdrop', userViews);
} else if (section === 'librarybuttons') {
loadlibraryButtons(elem, apiClient, user, userSettings, userViews);
} else if (section === 'resume') {
return loadResume(elem, apiClient, 'HeaderContinueWatching', 'Video', userSettings);
} else if (section === 'resumeaudio') {
return loadResume(elem, apiClient, 'HeaderContinueListening', 'Audio', userSettings);
} else if (section === 'activerecordings') {
loadLatestLiveTvRecordings(elem, true, apiClient);
} else if (section === 'nextup') {
loadNextUp(elem, apiClient, userSettings);
} else if (section === 'onnow' || section === 'livetv') {
return loadOnNow(elem, apiClient, user);
} else if (section === 'resumebook') {
return loadResume(elem, apiClient, 'HeaderContinueReading', 'Book', userSettings);
} else {
elem.innerHTML = '';
return Promise.resolve();
switch (section) {
case HomeSectionType.ActiveRecordings:
loadRecordings(elem, true, apiClient, options);
break;
case HomeSectionType.LatestMedia:
loadRecentlyAdded(elem, apiClient, user, userViews, options);
break;
case HomeSectionType.LibraryButtons:
loadLibraryButtons(elem, userViews);
break;
case HomeSectionType.LiveTv:
return loadLiveTV(elem, apiClient, user, options);
case HomeSectionType.NextUp:
loadNextUp(elem, apiClient, userSettings, options);
break;
case HomeSectionType.Resume:
return loadResume(elem, apiClient, 'HeaderContinueWatching', 'Video', userSettings, options);
case HomeSectionType.ResumeAudio:
return loadResume(elem, apiClient, 'HeaderContinueListening', 'Audio', userSettings, options);
case HomeSectionType.ResumeBook:
return loadResume(elem, apiClient, 'HeaderContinueReading', 'Book', userSettings, options);
case HomeSectionType.SmallLibraryTiles:
loadLibraryTiles(elem, userViews, options);
break;
default:
elem.innerHTML = '';
}
return Promise.resolve();
}
@ -174,573 +177,11 @@ function enableScrollX() {
return true;
}
function getLibraryButtonsHtml(items) {
let html = '';
html += '<div class="verticalSection verticalSection-extrabottompadding">';
html += '<h2 class="sectionTitle sectionTitle-cards padded-left">' + globalize.translate('HeaderMyMedia') + '</h2>';
html += '<div is="emby-itemscontainer" class="itemsContainer padded-left padded-right vertical-wrap focuscontainer-x" data-multiselect="false">';
// library card background images
for (let i = 0, length = items.length; i < length; i++) {
const item = items[i];
const icon = imageHelper.getLibraryIcon(item.CollectionType);
html += '<a is="emby-linkbutton" href="' + appRouter.getRouteUrl(item) + '" class="raised homeLibraryButton"><span class="material-icons homeLibraryIcon ' + icon + '" aria-hidden="true"></span><span class="homeLibraryText">' + escapeHtml(item.Name) + '</span></a>';
}
html += '</div>';
html += '</div>';
return html;
}
function loadlibraryButtons(elem, apiClient, user, userSettings, userViews) {
elem.classList.remove('verticalSection');
const html = getLibraryButtonsHtml(userViews);
elem.innerHTML = html;
imageLoader.lazyChildren(elem);
}
function getFetchLatestItemsFn(serverId, parentId, collectionType) {
return function () {
const apiClient = ServerConnections.getApiClient(serverId);
let limit = 16;
if (enableScrollX()) {
if (collectionType === 'music') {
limit = 30;
}
} else if (collectionType === 'tvshows') {
limit = 5;
} else if (collectionType === 'music') {
limit = 9;
} else {
limit = 8;
}
const options = {
Limit: limit,
Fields: 'PrimaryImageAspectRatio,BasicSyncInfo,Path',
ImageTypeLimit: 1,
EnableImageTypes: 'Primary,Backdrop,Thumb',
ParentId: parentId
};
return apiClient.getLatestItems(options);
};
}
function getLatestItemsHtmlFn(itemType, viewType) {
return function (items) {
const cardLayout = false;
let shape;
if (itemType === 'Channel' || viewType === 'movies' || viewType === 'books' || viewType === 'tvshows') {
shape = getPortraitShape(enableScrollX());
} else if (viewType === 'music' || viewType === 'homevideos') {
shape = getSquareShape(enableScrollX());
} else {
shape = getBackdropShape(enableScrollX());
}
return cardBuilder.getCardsHtml({
items: items,
shape: shape,
preferThumb: viewType !== 'movies' && viewType !== 'tvshows' && itemType !== 'Channel' && viewType !== 'music' ? 'auto' : null,
showUnplayedIndicator: false,
showChildCountIndicator: true,
context: 'home',
overlayText: false,
centerText: !cardLayout,
overlayPlayButton: viewType !== 'photos',
allowBottomPadding: !enableScrollX() && !cardLayout,
cardLayout: cardLayout,
showTitle: viewType !== 'photos',
showYear: viewType === 'movies' || viewType === 'tvshows' || !viewType,
showParentTitle: viewType === 'music' || viewType === 'tvshows' || !viewType || (cardLayout && (viewType === 'tvshows')),
lines: 2
});
};
}
function renderLatestSection(elem, apiClient, user, parent) {
let html = '';
html += '<div class="sectionTitleContainer sectionTitleContainer-cards padded-left">';
if (!layoutManager.tv) {
html += '<a is="emby-linkbutton" href="' + appRouter.getRouteUrl(parent, {
section: 'latest'
}) + '" class="more button-flat button-flat-mini sectionTitleTextButton">';
html += '<h2 class="sectionTitle sectionTitle-cards">';
html += globalize.translate('LatestFromLibrary', escapeHtml(parent.Name));
html += '</h2>';
html += '<span class="material-icons chevron_right" aria-hidden="true"></span>';
html += '</a>';
} else {
html += '<h2 class="sectionTitle sectionTitle-cards">' + globalize.translate('LatestFromLibrary', escapeHtml(parent.Name)) + '</h2>';
}
html += '</div>';
if (enableScrollX()) {
html += '<div is="emby-scroller" class="padded-top-focusscale padded-bottom-focusscale" data-centerfocus="true">';
html += '<div is="emby-itemscontainer" class="itemsContainer scrollSlider focuscontainer-x">';
} else {
html += '<div is="emby-itemscontainer" class="itemsContainer focuscontainer-x padded-left padded-right vertical-wrap">';
}
if (enableScrollX()) {
html += '</div>';
}
html += '</div>';
elem.innerHTML = html;
const itemsContainer = elem.querySelector('.itemsContainer');
itemsContainer.fetchData = getFetchLatestItemsFn(apiClient.serverId(), parent.Id, parent.CollectionType);
itemsContainer.getItemsHtml = getLatestItemsHtmlFn(parent.Type, parent.CollectionType);
itemsContainer.parentContainer = elem;
}
function loadRecentlyAdded(elem, apiClient, user, userViews) {
elem.classList.remove('verticalSection');
const excludeViewTypes = ['playlists', 'livetv', 'boxsets', 'channels'];
for (let i = 0, length = userViews.length; i < length; i++) {
const item = userViews[i];
if (user.Configuration.LatestItemsExcludes.indexOf(item.Id) !== -1) {
continue;
}
if (excludeViewTypes.indexOf(item.CollectionType || []) !== -1) {
continue;
}
const frag = document.createElement('div');
frag.classList.add('verticalSection');
frag.classList.add('hide');
elem.appendChild(frag);
renderLatestSection(frag, apiClient, user, item);
}
}
export function loadLibraryTiles(elem, apiClient, user, userSettings, shape, userViews) {
let html = '';
if (userViews.length) {
html += '<h2 class="sectionTitle sectionTitle-cards padded-left">' + globalize.translate('HeaderMyMedia') + '</h2>';
if (enableScrollX()) {
html += '<div is="emby-scroller" class="padded-top-focusscale padded-bottom-focusscale" data-centerfocus="true">';
html += '<div is="emby-itemscontainer" class="itemsContainer scrollSlider focuscontainer-x">';
} else {
html += '<div is="emby-itemscontainer" class="itemsContainer padded-left padded-right focuscontainer-x vertical-wrap">';
}
html += cardBuilder.getCardsHtml({
items: userViews,
shape: getBackdropShape(enableScrollX()),
showTitle: true,
centerText: true,
overlayText: false,
lazy: true,
transition: false,
allowBottomPadding: !enableScrollX()
});
if (enableScrollX()) {
html += '</div>';
}
html += '</div>';
}
elem.innerHTML = html;
imageLoader.lazyChildren(elem);
}
const dataMonitorHints = {
'Audio': 'audioplayback,markplayed',
'Video': 'videoplayback,markplayed'
};
function loadResume(elem, apiClient, headerText, mediaType, userSettings) {
let html = '';
const dataMonitor = dataMonitorHints[mediaType] || 'markplayed';
html += '<h2 class="sectionTitle sectionTitle-cards padded-left">' + globalize.translate(headerText) + '</h2>';
if (enableScrollX()) {
html += '<div is="emby-scroller" class="padded-top-focusscale padded-bottom-focusscale" data-centerfocus="true">';
html += `<div is="emby-itemscontainer" class="itemsContainer scrollSlider focuscontainer-x" data-monitor="${dataMonitor}">`;
} else {
html += `<div is="emby-itemscontainer" class="itemsContainer padded-left padded-right vertical-wrap focuscontainer-x" data-monitor="${dataMonitor}">`;
}
if (enableScrollX()) {
html += '</div>';
}
html += '</div>';
elem.classList.add('hide');
elem.innerHTML = html;
const itemsContainer = elem.querySelector('.itemsContainer');
itemsContainer.fetchData = getItemsToResumeFn(mediaType, apiClient.serverId());
itemsContainer.getItemsHtml = getItemsToResumeHtmlFn(userSettings.useEpisodeImagesInNextUpAndResume(), mediaType);
itemsContainer.parentContainer = elem;
}
function getItemsToResumeFn(mediaType, serverId) {
return function () {
const apiClient = ServerConnections.getApiClient(serverId);
const limit = enableScrollX() ? 12 : 5;
const options = {
Limit: limit,
Recursive: true,
Fields: 'PrimaryImageAspectRatio,BasicSyncInfo',
ImageTypeLimit: 1,
EnableImageTypes: 'Primary,Backdrop,Thumb',
EnableTotalRecordCount: false,
MediaTypes: mediaType
};
return apiClient.getResumableItems(apiClient.getCurrentUserId(), options);
};
}
function getItemsToResumeHtmlFn(useEpisodeImages, mediaType) {
return function (items) {
const cardLayout = false;
return cardBuilder.getCardsHtml({
items: items,
preferThumb: true,
inheritThumb: !useEpisodeImages,
shape: (mediaType === 'Book') ?
getPortraitShape(enableScrollX()) :
getBackdropShape(enableScrollX()),
overlayText: false,
showTitle: true,
showParentTitle: true,
lazy: true,
showDetailsMenu: true,
overlayPlayButton: true,
context: 'home',
centerText: !cardLayout,
allowBottomPadding: false,
cardLayout: cardLayout,
showYear: true,
lines: 2
});
};
}
function getOnNowFetchFn(serverId) {
return function () {
const apiClient = ServerConnections.getApiClient(serverId);
return apiClient.getLiveTvRecommendedPrograms({
userId: apiClient.getCurrentUserId(),
IsAiring: true,
limit: 24,
ImageTypeLimit: 1,
EnableImageTypes: 'Primary,Thumb,Backdrop',
EnableTotalRecordCount: false,
Fields: 'ChannelInfo,PrimaryImageAspectRatio'
});
};
}
function getOnNowItemsHtml(items) {
return cardBuilder.getCardsHtml({
items: items,
preferThumb: 'auto',
inheritThumb: false,
shape: (enableScrollX() ? 'autooverflow' : 'auto'),
showParentTitleOrTitle: true,
showTitle: true,
centerText: true,
coverImage: true,
overlayText: false,
allowBottomPadding: !enableScrollX(),
showAirTime: true,
showChannelName: false,
showAirDateTime: false,
showAirEndTime: true,
defaultShape: getBackdropShape(enableScrollX()),
lines: 3,
overlayPlayButton: true
});
}
function loadOnNow(elem, apiClient, user) {
if (!user.Policy.EnableLiveTvAccess) {
return Promise.resolve();
}
return apiClient.getLiveTvRecommendedPrograms({
userId: apiClient.getCurrentUserId(),
IsAiring: true,
limit: 1,
ImageTypeLimit: 1,
EnableImageTypes: 'Primary,Thumb,Backdrop',
EnableTotalRecordCount: false,
Fields: 'ChannelInfo,PrimaryImageAspectRatio'
}).then(function (result) {
let html = '';
if (result.Items.length) {
elem.classList.remove('padded-left');
elem.classList.remove('padded-right');
elem.classList.remove('padded-bottom');
elem.classList.remove('verticalSection');
html += '<div class="verticalSection">';
html += '<div class="sectionTitleContainer sectionTitleContainer-cards padded-left">';
html += '<h2 class="sectionTitle sectionTitle-cards">' + globalize.translate('LiveTV') + '</h2>';
html += '</div>';
if (enableScrollX()) {
html += '<div is="emby-scroller" class="padded-top-focusscale padded-bottom-focusscale" data-centerfocus="true" data-scrollbuttons="false">';
html += '<div class="padded-top padded-bottom scrollSlider focuscontainer-x">';
} else {
html += '<div class="padded-top padded-bottom focuscontainer-x">';
}
html += '<a is="emby-linkbutton" href="' + appRouter.getRouteUrl('livetv', {
serverId: apiClient.serverId(),
section: 'programs'
}) + '" class="raised"><span>' + globalize.translate('Programs') + '</span></a>';
html += '<a is="emby-linkbutton" href="' + appRouter.getRouteUrl('livetv', {
serverId: apiClient.serverId(),
section: 'guide'
}) + '" class="raised"><span>' + globalize.translate('Guide') + '</span></a>';
html += '<a is="emby-linkbutton" href="' + appRouter.getRouteUrl('livetv', {
serverId: apiClient.serverId(),
section: 'channels'
}) + '" class="raised"><span>' + globalize.translate('Channels') + '</span></a>';
html += '<a is="emby-linkbutton" href="' + appRouter.getRouteUrl('recordedtv', {
serverId: apiClient.serverId()
}) + '" class="raised"><span>' + globalize.translate('Recordings') + '</span></a>';
html += '<a is="emby-linkbutton" href="' + appRouter.getRouteUrl('livetv', {
serverId: apiClient.serverId(),
section: 'dvrschedule'
}) + '" class="raised"><span>' + globalize.translate('Schedule') + '</span></a>';
html += '<a is="emby-linkbutton" href="' + appRouter.getRouteUrl('livetv', {
serverId: apiClient.serverId(),
section: 'seriesrecording'
}) + '" class="raised"><span>' + globalize.translate('Series') + '</span></a>';
html += '</div>';
if (enableScrollX()) {
html += '</div>';
}
html += '</div>';
html += '</div>';
html += '<div class="verticalSection">';
html += '<div class="sectionTitleContainer sectionTitleContainer-cards padded-left">';
if (!layoutManager.tv) {
html += '<a is="emby-linkbutton" href="' + appRouter.getRouteUrl('livetv', {
serverId: apiClient.serverId(),
section: 'onnow'
}) + '" class="more button-flat button-flat-mini sectionTitleTextButton">';
html += '<h2 class="sectionTitle sectionTitle-cards">';
html += globalize.translate('HeaderOnNow');
html += '</h2>';
html += '<span class="material-icons chevron_right" aria-hidden="true"></span>';
html += '</a>';
} else {
html += '<h2 class="sectionTitle sectionTitle-cards">' + globalize.translate('HeaderOnNow') + '</h2>';
}
html += '</div>';
if (enableScrollX()) {
html += '<div is="emby-scroller" class="padded-top-focusscale padded-bottom-focusscale" data-centerfocus="true">';
html += '<div is="emby-itemscontainer" class="itemsContainer scrollSlider focuscontainer-x">';
} else {
html += '<div is="emby-itemscontainer" class="itemsContainer padded-left padded-right vertical-wrap focuscontainer-x">';
}
if (enableScrollX()) {
html += '</div>';
}
html += '</div>';
html += '</div>';
elem.innerHTML = html;
const itemsContainer = elem.querySelector('.itemsContainer');
itemsContainer.parentContainer = elem;
itemsContainer.fetchData = getOnNowFetchFn(apiClient.serverId());
itemsContainer.getItemsHtml = getOnNowItemsHtml;
}
});
}
function getNextUpFetchFn(serverId, userSettings) {
return function () {
const apiClient = ServerConnections.getApiClient(serverId);
const oldestDateForNextUp = new Date();
oldestDateForNextUp.setDate(oldestDateForNextUp.getDate() - userSettings.maxDaysForNextUp());
return apiClient.getNextUpEpisodes({
Limit: enableScrollX() ? 24 : 15,
Fields: 'PrimaryImageAspectRatio,DateCreated,BasicSyncInfo,Path,MediaSourceCount',
UserId: apiClient.getCurrentUserId(),
ImageTypeLimit: 1,
EnableImageTypes: 'Primary,Backdrop,Banner,Thumb',
EnableTotalRecordCount: false,
DisableFirstEpisode: false,
NextUpDateCutoff: oldestDateForNextUp.toISOString(),
EnableRewatching: userSettings.enableRewatchingInNextUp()
});
};
}
function getNextUpItemsHtmlFn(useEpisodeImages) {
return function (items) {
const cardLayout = false;
return cardBuilder.getCardsHtml({
items: items,
preferThumb: true,
inheritThumb: !useEpisodeImages,
shape: getBackdropShape(enableScrollX()),
overlayText: false,
showTitle: true,
showParentTitle: true,
lazy: true,
overlayPlayButton: true,
context: 'home',
centerText: !cardLayout,
allowBottomPadding: !enableScrollX(),
cardLayout: cardLayout
});
};
}
function loadNextUp(elem, apiClient, userSettings) {
let html = '';
html += '<div class="sectionTitleContainer sectionTitleContainer-cards padded-left">';
if (!layoutManager.tv) {
html += '<a is="emby-linkbutton" href="' + appRouter.getRouteUrl('nextup', {
serverId: apiClient.serverId()
}) + '" class="button-flat button-flat-mini sectionTitleTextButton">';
html += '<h2 class="sectionTitle sectionTitle-cards">';
html += globalize.translate('NextUp');
html += '</h2>';
html += '<span class="material-icons chevron_right" aria-hidden="true"></span>';
html += '</a>';
} else {
html += '<h2 class="sectionTitle sectionTitle-cards">';
html += globalize.translate('NextUp');
html += '</h2>';
}
html += '</div>';
if (enableScrollX()) {
html += '<div is="emby-scroller" class="padded-top-focusscale padded-bottom-focusscale" data-centerfocus="true">';
html += '<div is="emby-itemscontainer" class="itemsContainer scrollSlider focuscontainer-x" data-monitor="videoplayback,markplayed">';
} else {
html += '<div is="emby-itemscontainer" class="itemsContainer padded-left padded-right vertical-wrap focuscontainer-x" data-monitor="videoplayback,markplayed">';
}
if (enableScrollX()) {
html += '</div>';
}
html += '</div>';
elem.classList.add('hide');
elem.innerHTML = html;
const itemsContainer = elem.querySelector('.itemsContainer');
itemsContainer.fetchData = getNextUpFetchFn(apiClient.serverId(), userSettings);
itemsContainer.getItemsHtml = getNextUpItemsHtmlFn(userSettings.useEpisodeImagesInNextUpAndResume());
itemsContainer.parentContainer = elem;
}
function getLatestRecordingsFetchFn(serverId, activeRecordingsOnly) {
return function () {
const apiClient = ServerConnections.getApiClient(serverId);
return apiClient.getLiveTvRecordings({
userId: apiClient.getCurrentUserId(),
Limit: enableScrollX() ? 12 : 5,
Fields: 'PrimaryImageAspectRatio,BasicSyncInfo',
EnableTotalRecordCount: false,
IsLibraryItem: activeRecordingsOnly ? null : false,
IsInProgress: activeRecordingsOnly ? true : null
});
};
}
function getLatestRecordingItemsHtml(activeRecordingsOnly) {
return function (items) {
return cardBuilder.getCardsHtml({
items: items,
shape: enableScrollX() ? 'autooverflow' : 'auto',
showTitle: true,
showParentTitle: true,
coverImage: true,
lazy: true,
showDetailsMenu: true,
centerText: true,
overlayText: false,
showYear: true,
lines: 2,
overlayPlayButton: !activeRecordingsOnly,
allowBottomPadding: !enableScrollX(),
preferThumb: true,
cardLayout: false,
overlayMoreButton: activeRecordingsOnly,
action: activeRecordingsOnly ? 'none' : null,
centerPlayButton: activeRecordingsOnly
});
};
}
function loadLatestLiveTvRecordings(elem, activeRecordingsOnly, apiClient) {
const title = activeRecordingsOnly ?
globalize.translate('HeaderActiveRecordings') :
globalize.translate('HeaderLatestRecordings');
let html = '';
html += '<div class="sectionTitleContainer sectionTitleContainer-cards">';
html += '<h2 class="sectionTitle sectionTitle-cards padded-left">' + title + '</h2>';
html += '</div>';
if (enableScrollX()) {
html += '<div is="emby-scroller" class="padded-top-focusscale padded-bottom-focusscale" data-centerfocus="true">';
html += '<div is="emby-itemscontainer" class="itemsContainer scrollSlider focuscontainer-x">';
} else {
html += '<div is="emby-itemscontainer" class="itemsContainer padded-left padded-right vertical-wrap focuscontainer-x">';
}
if (enableScrollX()) {
html += '</div>';
}
html += '</div>';
elem.classList.add('hide');
elem.innerHTML = html;
const itemsContainer = elem.querySelector('.itemsContainer');
itemsContainer.fetchData = getLatestRecordingsFetchFn(apiClient.serverId(), activeRecordingsOnly);
itemsContainer.getItemsHtml = getLatestRecordingItemsHtml(activeRecordingsOnly);
itemsContainer.parentContainer = elem;
}
export default {
loadLibraryTiles: loadLibraryTiles,
getDefaultSection: getDefaultSection,
loadSections: loadSections,
destroySections: destroySections,
pause: pause,
resume: resume
getDefaultSection,
loadSections,
destroySections,
pause,
resume
};

View File

@ -0,0 +1,92 @@
import type { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models/base-item-dto';
import type { ApiClient } from 'jellyfin-apiclient';
import ServerConnections from 'components/ServerConnections';
import cardBuilder from 'components/cardbuilder/cardBuilder';
import globalize from 'scripts/globalize';
import type { SectionContainerElement, SectionOptions } from './section';
function getLatestRecordingsFetchFn(
serverId: string,
activeRecordingsOnly: boolean,
{ enableOverflow }: SectionOptions
) {
return function () {
const apiClient = ServerConnections.getApiClient(serverId);
return apiClient.getLiveTvRecordings({
userId: apiClient.getCurrentUserId(),
Limit: enableOverflow ? 12 : 5,
Fields: 'PrimaryImageAspectRatio,BasicSyncInfo',
EnableTotalRecordCount: false,
IsLibraryItem: activeRecordingsOnly ? null : false,
IsInProgress: activeRecordingsOnly ? true : null
});
};
}
function getLatestRecordingItemsHtml(
activeRecordingsOnly: boolean,
{ enableOverflow }: SectionOptions
) {
return function (items: BaseItemDto[]) {
return cardBuilder.getCardsHtml({
items: items,
shape: enableOverflow ? 'autooverflow' : 'auto',
showTitle: true,
showParentTitle: true,
coverImage: true,
lazy: true,
showDetailsMenu: true,
centerText: true,
overlayText: false,
showYear: true,
lines: 2,
overlayPlayButton: !activeRecordingsOnly,
allowBottomPadding: !enableOverflow,
preferThumb: true,
cardLayout: false,
overlayMoreButton: activeRecordingsOnly,
action: activeRecordingsOnly ? 'none' : null,
centerPlayButton: activeRecordingsOnly
});
};
}
export function loadRecordings(
elem: HTMLElement,
activeRecordingsOnly: boolean,
apiClient: ApiClient,
options: SectionOptions
) {
const title = activeRecordingsOnly ?
globalize.translate('HeaderActiveRecordings') :
globalize.translate('HeaderLatestRecordings');
let html = '';
html += '<div class="sectionTitleContainer sectionTitleContainer-cards">';
html += '<h2 class="sectionTitle sectionTitle-cards padded-left">' + title + '</h2>';
html += '</div>';
if (options.enableOverflow) {
html += '<div is="emby-scroller" class="padded-top-focusscale padded-bottom-focusscale" data-centerfocus="true">';
html += '<div is="emby-itemscontainer" class="itemsContainer scrollSlider focuscontainer-x">';
} else {
html += '<div is="emby-itemscontainer" class="itemsContainer padded-left padded-right vertical-wrap focuscontainer-x">';
}
if (options.enableOverflow) {
html += '</div>';
}
html += '</div>';
elem.classList.add('hide');
elem.innerHTML = html;
const itemsContainer: SectionContainerElement | null = elem.querySelector('.itemsContainer');
if (!itemsContainer) return;
itemsContainer.fetchData = getLatestRecordingsFetchFn(apiClient.serverId(), activeRecordingsOnly, options);
itemsContainer.getItemsHtml = getLatestRecordingItemsHtml(activeRecordingsOnly, options);
itemsContainer.parentContainer = elem;
}

View File

@ -0,0 +1,36 @@
import type { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models/base-item-dto';
import escapeHtml from 'escape-html';
import imageLoader from 'components/images/imageLoader';
import { appRouter } from 'components/router/appRouter';
import globalize from 'scripts/globalize';
import imageHelper from 'scripts/imagehelper';
function getLibraryButtonsHtml(items: BaseItemDto[]) {
let html = '';
html += '<div class="verticalSection verticalSection-extrabottompadding">';
html += '<h2 class="sectionTitle sectionTitle-cards padded-left">' + globalize.translate('HeaderMyMedia') + '</h2>';
html += '<div is="emby-itemscontainer" class="itemsContainer padded-left padded-right vertical-wrap focuscontainer-x" data-multiselect="false">';
// library card background images
for (let i = 0, length = items.length; i < length; i++) {
const item = items[i];
const icon = imageHelper.getLibraryIcon(item.CollectionType);
html += '<a is="emby-linkbutton" href="' + appRouter.getRouteUrl(item) + '" class="raised homeLibraryButton"><span class="material-icons homeLibraryIcon ' + icon + '" aria-hidden="true"></span><span class="homeLibraryText">' + escapeHtml(item.Name) + '</span></a>';
}
html += '</div>';
html += '</div>';
return html;
}
export function loadLibraryButtons(elem: HTMLElement, userViews: BaseItemDto[]) {
elem.classList.remove('verticalSection');
const html = getLibraryButtonsHtml(userViews);
elem.innerHTML = html;
imageLoader.lazyChildren(elem);
}

View File

@ -0,0 +1,46 @@
import type { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models/base-item-dto';
import cardBuilder from 'components/cardbuilder/cardBuilder';
import imageLoader from 'components/images/imageLoader';
import globalize from 'scripts/globalize';
import { getBackdropShape } from 'utils/card';
import type { SectionOptions } from './section';
export function loadLibraryTiles(
elem: HTMLElement,
userViews: BaseItemDto[],
{
enableOverflow
}: SectionOptions
) {
let html = '';
if (userViews.length) {
html += '<h2 class="sectionTitle sectionTitle-cards padded-left">' + globalize.translate('HeaderMyMedia') + '</h2>';
if (enableOverflow) {
html += '<div is="emby-scroller" class="padded-top-focusscale padded-bottom-focusscale" data-centerfocus="true">';
html += '<div is="emby-itemscontainer" class="itemsContainer scrollSlider focuscontainer-x">';
} else {
html += '<div is="emby-itemscontainer" class="itemsContainer padded-left padded-right focuscontainer-x vertical-wrap">';
}
html += cardBuilder.getCardsHtml({
items: userViews,
shape: getBackdropShape(enableOverflow),
showTitle: true,
centerText: true,
overlayText: false,
lazy: true,
transition: false,
allowBottomPadding: !enableOverflow
});
if (enableOverflow) {
html += '</div>';
}
html += '</div>';
}
elem.innerHTML = html;
imageLoader.lazyChildren(elem);
}

View File

@ -0,0 +1,181 @@
import type { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models/base-item-dto';
import type { UserDto } from '@jellyfin/sdk/lib/generated-client/models/user-dto';
import type { ApiClient } from 'jellyfin-apiclient';
import { appRouter } from 'components/router/appRouter';
import cardBuilder from 'components/cardbuilder/cardBuilder';
import layoutManager from 'components/layoutManager';
import ServerConnections from 'components/ServerConnections';
import globalize from 'scripts/globalize';
import { getBackdropShape } from 'utils/card';
import type { SectionContainerElement, SectionOptions } from './section';
function getOnNowFetchFn(
serverId: string
) {
return function () {
const apiClient = ServerConnections.getApiClient(serverId);
return apiClient.getLiveTvRecommendedPrograms({
userId: apiClient.getCurrentUserId(),
IsAiring: true,
limit: 24,
ImageTypeLimit: 1,
EnableImageTypes: 'Primary,Thumb,Backdrop',
EnableTotalRecordCount: false,
Fields: 'ChannelInfo,PrimaryImageAspectRatio'
});
};
}
function getOnNowItemsHtmlFn(
{ enableOverflow }: SectionOptions
) {
return (items: BaseItemDto[]) => (
cardBuilder.getCardsHtml({
items: items,
preferThumb: 'auto',
inheritThumb: false,
shape: (enableOverflow ? 'autooverflow' : 'auto'),
showParentTitleOrTitle: true,
showTitle: true,
centerText: true,
coverImage: true,
overlayText: false,
allowBottomPadding: !enableOverflow,
showAirTime: true,
showChannelName: false,
showAirDateTime: false,
showAirEndTime: true,
defaultShape: getBackdropShape(enableOverflow),
lines: 3,
overlayPlayButton: true
})
);
}
function buildSection(
elem: HTMLElement,
serverId: string,
options: SectionOptions
) {
let html = '';
elem.classList.remove('padded-left');
elem.classList.remove('padded-right');
elem.classList.remove('padded-bottom');
elem.classList.remove('verticalSection');
html += '<div class="verticalSection">';
html += '<div class="sectionTitleContainer sectionTitleContainer-cards padded-left">';
html += '<h2 class="sectionTitle sectionTitle-cards">' + globalize.translate('LiveTV') + '</h2>';
html += '</div>';
if (options.enableOverflow) {
html += '<div is="emby-scroller" class="padded-top-focusscale padded-bottom-focusscale" data-centerfocus="true" data-scrollbuttons="false">';
html += '<div class="padded-top padded-bottom scrollSlider focuscontainer-x">';
} else {
html += '<div class="padded-top padded-bottom focuscontainer-x">';
}
html += '<a is="emby-linkbutton" href="' + appRouter.getRouteUrl('livetv', {
serverId,
section: 'programs'
}) + '" class="raised"><span>' + globalize.translate('Programs') + '</span></a>';
html += '<a is="emby-linkbutton" href="' + appRouter.getRouteUrl('livetv', {
serverId,
section: 'guide'
}) + '" class="raised"><span>' + globalize.translate('Guide') + '</span></a>';
html += '<a is="emby-linkbutton" href="' + appRouter.getRouteUrl('livetv', {
serverId,
section: 'channels'
}) + '" class="raised"><span>' + globalize.translate('Channels') + '</span></a>';
html += '<a is="emby-linkbutton" href="' + appRouter.getRouteUrl('recordedtv', {
serverId
}) + '" class="raised"><span>' + globalize.translate('Recordings') + '</span></a>';
html += '<a is="emby-linkbutton" href="' + appRouter.getRouteUrl('livetv', {
serverId,
section: 'dvrschedule'
}) + '" class="raised"><span>' + globalize.translate('Schedule') + '</span></a>';
html += '<a is="emby-linkbutton" href="' + appRouter.getRouteUrl('livetv', {
serverId,
section: 'seriesrecording'
}) + '" class="raised"><span>' + globalize.translate('Series') + '</span></a>';
html += '</div>';
if (options.enableOverflow) {
html += '</div>';
}
html += '</div>';
html += '</div>';
html += '<div class="verticalSection">';
html += '<div class="sectionTitleContainer sectionTitleContainer-cards padded-left">';
if (!layoutManager.tv) {
html += '<a is="emby-linkbutton" href="' + appRouter.getRouteUrl('livetv', {
serverId,
section: 'onnow'
}) + '" class="more button-flat button-flat-mini sectionTitleTextButton">';
html += '<h2 class="sectionTitle sectionTitle-cards">';
html += globalize.translate('HeaderOnNow');
html += '</h2>';
html += '<span class="material-icons chevron_right" aria-hidden="true"></span>';
html += '</a>';
} else {
html += '<h2 class="sectionTitle sectionTitle-cards">' + globalize.translate('HeaderOnNow') + '</h2>';
}
html += '</div>';
if (options.enableOverflow) {
html += '<div is="emby-scroller" class="padded-top-focusscale padded-bottom-focusscale" data-centerfocus="true">';
html += '<div is="emby-itemscontainer" class="itemsContainer scrollSlider focuscontainer-x">';
} else {
html += '<div is="emby-itemscontainer" class="itemsContainer padded-left padded-right vertical-wrap focuscontainer-x">';
}
if (options.enableOverflow) {
html += '</div>';
}
html += '</div>';
html += '</div>';
elem.innerHTML = html;
const itemsContainer: SectionContainerElement | null = elem.querySelector('.itemsContainer');
if (!itemsContainer) return;
itemsContainer.parentContainer = elem;
itemsContainer.fetchData = getOnNowFetchFn(serverId);
itemsContainer.getItemsHtml = getOnNowItemsHtmlFn(options);
}
export function loadLiveTV(
elem: HTMLElement,
apiClient: ApiClient,
user: UserDto,
options: SectionOptions
) {
if (!user.Policy?.EnableLiveTvAccess) {
return Promise.resolve();
}
return apiClient.getLiveTvRecommendedPrograms({
userId: apiClient.getCurrentUserId(),
IsAiring: true,
limit: 1,
ImageTypeLimit: 1,
EnableImageTypes: 'Primary,Thumb,Backdrop',
EnableTotalRecordCount: false,
Fields: 'ChannelInfo,PrimaryImageAspectRatio'
}).then(function (result) {
if (result.Items?.length) {
buildSection(elem, apiClient.serverId(), options);
}
});
}

View File

@ -0,0 +1,106 @@
import type { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models/base-item-dto';
import type { ApiClient } from 'jellyfin-apiclient';
import cardBuilder from 'components/cardbuilder/cardBuilder';
import layoutManager from 'components/layoutManager';
import { appRouter } from 'components/router/appRouter';
import ServerConnections from 'components/ServerConnections';
import globalize from 'scripts/globalize';
import type { UserSettings } from 'scripts/settings/userSettings';
import { getBackdropShape } from 'utils/card';
import type { SectionContainerElement, SectionOptions } from './section';
function getNextUpFetchFn(
serverId: string,
userSettings: UserSettings,
{ enableOverflow }: SectionOptions
) {
return function () {
const apiClient = ServerConnections.getApiClient(serverId);
const oldestDateForNextUp = new Date();
oldestDateForNextUp.setDate(oldestDateForNextUp.getDate() - userSettings.maxDaysForNextUp());
return apiClient.getNextUpEpisodes({
Limit: enableOverflow ? 24 : 15,
Fields: 'PrimaryImageAspectRatio,DateCreated,BasicSyncInfo,Path,MediaSourceCount',
UserId: apiClient.getCurrentUserId(),
ImageTypeLimit: 1,
EnableImageTypes: 'Primary,Backdrop,Banner,Thumb',
EnableTotalRecordCount: false,
DisableFirstEpisode: false,
NextUpDateCutoff: oldestDateForNextUp.toISOString(),
EnableRewatching: userSettings.enableRewatchingInNextUp()
});
};
}
function getNextUpItemsHtmlFn(
useEpisodeImages: boolean,
{ enableOverflow }: SectionOptions
) {
return function (items: BaseItemDto[]) {
const cardLayout = false;
return cardBuilder.getCardsHtml({
items: items,
preferThumb: true,
inheritThumb: !useEpisodeImages,
shape: getBackdropShape(enableOverflow),
overlayText: false,
showTitle: true,
showParentTitle: true,
lazy: true,
overlayPlayButton: true,
context: 'home',
centerText: !cardLayout,
allowBottomPadding: !enableOverflow,
cardLayout: cardLayout
});
};
}
export function loadNextUp(
elem: HTMLElement,
apiClient: ApiClient,
userSettings: UserSettings,
options: SectionOptions
) {
let html = '';
html += '<div class="sectionTitleContainer sectionTitleContainer-cards padded-left">';
if (!layoutManager.tv) {
html += '<a is="emby-linkbutton" href="' + appRouter.getRouteUrl('nextup', {
serverId: apiClient.serverId()
}) + '" class="button-flat button-flat-mini sectionTitleTextButton">';
html += '<h2 class="sectionTitle sectionTitle-cards">';
html += globalize.translate('NextUp');
html += '</h2>';
html += '<span class="material-icons chevron_right" aria-hidden="true"></span>';
html += '</a>';
} else {
html += '<h2 class="sectionTitle sectionTitle-cards">';
html += globalize.translate('NextUp');
html += '</h2>';
}
html += '</div>';
if (options.enableOverflow) {
html += '<div is="emby-scroller" class="padded-top-focusscale padded-bottom-focusscale" data-centerfocus="true">';
html += '<div is="emby-itemscontainer" class="itemsContainer scrollSlider focuscontainer-x" data-monitor="videoplayback,markplayed">';
} else {
html += '<div is="emby-itemscontainer" class="itemsContainer padded-left padded-right vertical-wrap focuscontainer-x" data-monitor="videoplayback,markplayed">';
}
if (options.enableOverflow) {
html += '</div>';
}
html += '</div>';
elem.classList.add('hide');
elem.innerHTML = html;
const itemsContainer: SectionContainerElement | null = elem.querySelector('.itemsContainer');
if (!itemsContainer) return;
itemsContainer.fetchData = getNextUpFetchFn(apiClient.serverId(), userSettings, options);
itemsContainer.getItemsHtml = getNextUpItemsHtmlFn(userSettings.useEpisodeImagesInNextUpAndResume(), options);
itemsContainer.parentContainer = elem;
}

View File

@ -0,0 +1,158 @@
import type { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models/base-item-dto';
import { BaseItemKind } from '@jellyfin/sdk/lib/generated-client/models/base-item-kind';
import type { UserDto } from '@jellyfin/sdk/lib/generated-client/models/user-dto';
import escapeHtml from 'escape-html';
import type { ApiClient } from 'jellyfin-apiclient';
import layoutManager from 'components/layoutManager';
import { appRouter } from 'components/router/appRouter';
import globalize from 'scripts/globalize';
import ServerConnections from 'components/ServerConnections';
import cardBuilder from 'components/cardbuilder/cardBuilder';
import { getBackdropShape, getPortraitShape, getSquareShape } from 'utils/card';
import type { SectionContainerElement, SectionOptions } from './section';
function getFetchLatestItemsFn(
serverId: string,
parentId: string | undefined,
collectionType: string | null | undefined,
{ enableOverflow }: SectionOptions
) {
return function () {
const apiClient = ServerConnections.getApiClient(serverId);
let limit = 16;
if (enableOverflow) {
if (collectionType === 'music') {
limit = 30;
}
} else if (collectionType === 'tvshows') {
limit = 5;
} else if (collectionType === 'music') {
limit = 9;
} else {
limit = 8;
}
const options = {
Limit: limit,
Fields: 'PrimaryImageAspectRatio,BasicSyncInfo,Path',
ImageTypeLimit: 1,
EnableImageTypes: 'Primary,Backdrop,Thumb',
ParentId: parentId
};
return apiClient.getLatestItems(options);
};
}
function getLatestItemsHtmlFn(
itemType: BaseItemKind | undefined,
viewType: string | null | undefined,
{ enableOverflow }: SectionOptions
) {
return function (items: BaseItemDto[]) {
const cardLayout = false;
let shape;
if (itemType === 'Channel' || viewType === 'movies' || viewType === 'books' || viewType === 'tvshows') {
shape = getPortraitShape(enableOverflow);
} else if (viewType === 'music' || viewType === 'homevideos') {
shape = getSquareShape(enableOverflow);
} else {
shape = getBackdropShape(enableOverflow);
}
return cardBuilder.getCardsHtml({
items: items,
shape: shape,
preferThumb: viewType !== 'movies' && viewType !== 'tvshows' && itemType !== 'Channel' && viewType !== 'music' ? 'auto' : null,
showUnplayedIndicator: false,
showChildCountIndicator: true,
context: 'home',
overlayText: false,
centerText: !cardLayout,
overlayPlayButton: viewType !== 'photos',
allowBottomPadding: !enableOverflow && !cardLayout,
cardLayout: cardLayout,
showTitle: viewType !== 'photos',
showYear: viewType === 'movies' || viewType === 'tvshows' || !viewType,
showParentTitle: viewType === 'music' || viewType === 'tvshows' || !viewType || (cardLayout && (viewType === 'tvshows')),
lines: 2
});
};
}
function renderLatestSection(
elem: HTMLElement,
apiClient: ApiClient,
user: UserDto,
parent: BaseItemDto,
options: SectionOptions
) {
let html = '';
html += '<div class="sectionTitleContainer sectionTitleContainer-cards padded-left">';
if (!layoutManager.tv) {
html += '<a is="emby-linkbutton" href="' + appRouter.getRouteUrl(parent, {
section: 'latest'
}) + '" class="more button-flat button-flat-mini sectionTitleTextButton">';
html += '<h2 class="sectionTitle sectionTitle-cards">';
html += globalize.translate('LatestFromLibrary', escapeHtml(parent.Name));
html += '</h2>';
html += '<span class="material-icons chevron_right" aria-hidden="true"></span>';
html += '</a>';
} else {
html += '<h2 class="sectionTitle sectionTitle-cards">' + globalize.translate('LatestFromLibrary', escapeHtml(parent.Name)) + '</h2>';
}
html += '</div>';
if (options.enableOverflow) {
html += '<div is="emby-scroller" class="padded-top-focusscale padded-bottom-focusscale" data-centerfocus="true">';
html += '<div is="emby-itemscontainer" class="itemsContainer scrollSlider focuscontainer-x">';
} else {
html += '<div is="emby-itemscontainer" class="itemsContainer focuscontainer-x padded-left padded-right vertical-wrap">';
}
if (options.enableOverflow) {
html += '</div>';
}
html += '</div>';
elem.innerHTML = html;
const itemsContainer: SectionContainerElement | null = elem.querySelector('.itemsContainer');
if (!itemsContainer) return;
itemsContainer.fetchData = getFetchLatestItemsFn(apiClient.serverId(), parent.Id, parent.CollectionType, options);
itemsContainer.getItemsHtml = getLatestItemsHtmlFn(parent.Type, parent.CollectionType, options);
itemsContainer.parentContainer = elem;
}
export function loadRecentlyAdded(
elem: HTMLElement,
apiClient: ApiClient,
user: UserDto,
userViews: BaseItemDto[],
options: SectionOptions
) {
elem.classList.remove('verticalSection');
const excludeViewTypes = ['playlists', 'livetv', 'boxsets', 'channels'];
const userExcludeItems = user.Configuration?.LatestItemsExcludes ?? [];
userViews.forEach(item => {
if (!item.Id || userExcludeItems.indexOf(item.Id) !== -1) {
return;
}
if (!item.CollectionType || excludeViewTypes.indexOf(item.CollectionType) !== -1) {
return;
}
const frag = document.createElement('div');
frag.classList.add('verticalSection');
frag.classList.add('hide');
elem.appendChild(frag);
renderLatestSection(frag, apiClient, user, item, options);
});
}

View File

@ -0,0 +1,105 @@
import type { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models/base-item-dto';
import type { BaseItemKind } from '@jellyfin/sdk/lib/generated-client/models/base-item-kind';
import type { ApiClient } from 'jellyfin-apiclient';
import ServerConnections from 'components/ServerConnections';
import cardBuilder from 'components/cardbuilder/cardBuilder';
import globalize from 'scripts/globalize';
import type { UserSettings } from 'scripts/settings/userSettings';
import { getBackdropShape, getPortraitShape } from 'utils/card';
import type { SectionContainerElement, SectionOptions } from './section';
const dataMonitorHints: Record<string, string> = {
Audio: 'audioplayback,markplayed',
Video: 'videoplayback,markplayed'
};
function getItemsToResumeFn(
mediaType: BaseItemKind,
serverId: string,
{ enableOverflow }: SectionOptions
) {
return function () {
const apiClient = ServerConnections.getApiClient(serverId);
const limit = enableOverflow ? 12 : 5;
const options = {
Limit: limit,
Recursive: true,
Fields: 'PrimaryImageAspectRatio,BasicSyncInfo',
ImageTypeLimit: 1,
EnableImageTypes: 'Primary,Backdrop,Thumb',
EnableTotalRecordCount: false,
MediaTypes: mediaType
};
return apiClient.getResumableItems(apiClient.getCurrentUserId(), options);
};
}
function getItemsToResumeHtmlFn(
useEpisodeImages: boolean,
mediaType: BaseItemKind,
{ enableOverflow }: SectionOptions
) {
return function (items: BaseItemDto[]) {
const cardLayout = false;
return cardBuilder.getCardsHtml({
items: items,
preferThumb: true,
inheritThumb: !useEpisodeImages,
shape: (mediaType === 'Book') ?
getPortraitShape(enableOverflow) :
getBackdropShape(enableOverflow),
overlayText: false,
showTitle: true,
showParentTitle: true,
lazy: true,
showDetailsMenu: true,
overlayPlayButton: true,
context: 'home',
centerText: !cardLayout,
allowBottomPadding: false,
cardLayout: cardLayout,
showYear: true,
lines: 2
});
};
}
export function loadResume(
elem: HTMLElement,
apiClient: ApiClient,
titleLabel: string,
mediaType: BaseItemKind,
userSettings: UserSettings,
options: SectionOptions
) {
let html = '';
const dataMonitor = dataMonitorHints[mediaType] ?? 'markplayed';
html += '<h2 class="sectionTitle sectionTitle-cards padded-left">' + globalize.translate(titleLabel) + '</h2>';
if (options.enableOverflow) {
html += '<div is="emby-scroller" class="padded-top-focusscale padded-bottom-focusscale" data-centerfocus="true">';
html += `<div is="emby-itemscontainer" class="itemsContainer scrollSlider focuscontainer-x" data-monitor="${dataMonitor}">`;
} else {
html += `<div is="emby-itemscontainer" class="itemsContainer padded-left padded-right vertical-wrap focuscontainer-x" data-monitor="${dataMonitor}">`;
}
if (options.enableOverflow) {
html += '</div>';
}
html += '</div>';
elem.classList.add('hide');
elem.innerHTML = html;
const itemsContainer: SectionContainerElement | null = elem.querySelector('.itemsContainer');
if (!itemsContainer) return;
itemsContainer.fetchData = getItemsToResumeFn(mediaType, apiClient.serverId(), options);
itemsContainer.getItemsHtml = getItemsToResumeHtmlFn(userSettings.useEpisodeImagesInNextUpAndResume(), mediaType, options);
itemsContainer.parentContainer = elem;
}

View File

@ -0,0 +1,12 @@
import type { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models/base-item-dto';
import type { BaseItemDtoQueryResult } from '@jellyfin/sdk/lib/generated-client/models/base-item-dto-query-result';
export interface SectionOptions {
enableOverflow: boolean
}
export type SectionContainerElement = {
fetchData: () => Promise<BaseItemDtoQueryResult | BaseItemDto[]>
getItemsHtml: (items: BaseItemDto[]) => void
parentContainer: HTMLElement
} & Element;

View File

@ -1849,6 +1849,56 @@ class PlaybackManager {
SortBy: options.shuffle ? 'Random' : 'SortName',
MediaTypes: 'Audio'
}, queryOptions));
} else if (firstItem.Type === 'Series' || firstItem.Type === 'Season') {
const apiClient = ServerConnections.getApiClient(firstItem.ServerId);
promise = apiClient.getEpisodes(firstItem.SeriesId || firstItem.Id, {
IsVirtualUnaired: false,
IsMissing: false,
UserId: apiClient.getCurrentUserId(),
Fields: 'Chapters'
}).then(function (episodesResult) {
const originalResults = episodesResult.Items;
const isSeries = firstItem.Type === 'Series';
let foundItem = false;
episodesResult.Items = episodesResult.Items.filter(function (e) {
if (foundItem) {
return true;
}
if (!e.UserData.Played && (isSeries || e.SeasonId === firstItem.Id)) {
foundItem = true;
return true;
}
return false;
});
if (episodesResult.Items.length === 0) {
if (isSeries) {
episodesResult.Items = originalResults;
} else {
episodesResult.Items = originalResults.filter(function (e) {
if (foundItem) {
return true;
}
if (e.SeasonId === firstItem.Id) {
foundItem = true;
return true;
}
return false;
});
}
}
episodesResult.TotalRecordCount = episodesResult.Items.length;
return episodesResult;
});
} else if (firstItem.IsFolder && firstItem.CollectionType === 'homevideos') {
promise = getItemsForPlayback(serverId, mergePlaybackQueries({
ParentId: firstItem.Id,

View File

@ -49,7 +49,7 @@ const SearchResults: FunctionComponent<SearchResultsProps> = ({ serverId = windo
const getDefaultParameters = useCallback(() => ({
ParentId: parentId,
searchTerm: query,
Limit: 24,
Limit: 100,
Fields: 'PrimaryImageAspectRatio,CanDelete,BasicSyncInfo,MediaSourceCount',
Recursive: true,
EnableTotalRecordCount: false,

View File

@ -1,12 +1,11 @@
import type { ItemsApiGetItemsRequest } from '@jellyfin/sdk/lib/generated-client';
import { AxiosRequestConfig } from 'axios';
import { BaseItemKind } from '@jellyfin/sdk/lib/generated-client/models/base-item-kind';
import type { 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 { ItemFilter } from '@jellyfin/sdk/lib/generated-client/models/item-filter';
import { SortOrder } from '@jellyfin/sdk/lib/generated-client/models/sort-order';
import { ItemSortBy } from '@jellyfin/sdk/lib/models/api/item-sort-by';
import { getArtistsApi } from '@jellyfin/sdk/lib/utils/api/artists-api';
import { getFilterApi } from '@jellyfin/sdk/lib/utils/api/filter-api';
import { getGenresApi } from '@jellyfin/sdk/lib/utils/api/genres-api';
import { getItemsApi } from '@jellyfin/sdk/lib/utils/api/items-api';
@ -14,11 +13,14 @@ import { getMoviesApi } from '@jellyfin/sdk/lib/utils/api/movies-api';
import { getStudiosApi } from '@jellyfin/sdk/lib/utils/api/studios-api';
import { getTvShowsApi } from '@jellyfin/sdk/lib/utils/api/tv-shows-api';
import { getUserLibraryApi } from '@jellyfin/sdk/lib/utils/api/user-library-api';
import { AxiosRequestConfig } from 'axios';
import { useQuery } from '@tanstack/react-query';
import { JellyfinApiContext, useApi } from './useApi';
import { getAlphaPickerQuery, getFieldsQuery, getFiltersQuery, getLimitQuery } from 'utils/items';
import { Sections, SectionsViewType } from 'types/suggestionsSections';
import { ParentId } from 'types/library';
import { LibraryViewSettings, ParentId } from 'types/library';
import { LibraryTab } from 'types/libraryTab';
const fetchGetItem = async (
currentApi: JellyfinApiContext,
@ -291,7 +293,7 @@ export const useGetGenres = (itemType: BaseItemKind, parentId: ParentId) => {
const fetchGetStudios = async (
currentApi: JellyfinApiContext,
parentId: ParentId,
itemType: BaseItemKind,
itemType: BaseItemKind[],
options?: AxiosRequestConfig
) => {
const { api, user } = currentApi;
@ -299,7 +301,7 @@ const fetchGetStudios = async (
const response = await getStudiosApi(api).getStudios(
{
userId: user.Id,
includeItemTypes: [itemType],
includeItemTypes: itemType,
fields: [
ItemFields.DateCreated,
ItemFields.PrimaryImageAspectRatio
@ -316,7 +318,7 @@ const fetchGetStudios = async (
}
};
export const useGetStudios = (parentId: ParentId, itemType: BaseItemKind) => {
export const useGetStudios = (parentId: ParentId, itemType: BaseItemKind[]) => {
const currentApi = useApi();
return useQuery({
queryKey: ['Studios', parentId, itemType],
@ -329,7 +331,7 @@ export const useGetStudios = (parentId: ParentId, itemType: BaseItemKind) => {
const fetchGetQueryFiltersLegacy = async (
currentApi: JellyfinApiContext,
parentId: ParentId,
itemType: BaseItemKind,
itemType: BaseItemKind[],
options?: AxiosRequestConfig
) => {
const { api, user } = currentApi;
@ -338,7 +340,7 @@ const fetchGetQueryFiltersLegacy = async (
{
userId: user.Id,
parentId: parentId ?? undefined,
includeItemTypes: [itemType]
includeItemTypes: itemType
},
{
signal: options?.signal
@ -350,7 +352,7 @@ const fetchGetQueryFiltersLegacy = async (
export const useGetQueryFiltersLegacy = (
parentId: ParentId,
itemType: BaseItemKind
itemType: BaseItemKind[]
) => {
const currentApi = useApi();
return useQuery({
@ -362,3 +364,148 @@ export const useGetQueryFiltersLegacy = (
enabled: !!parentId
});
};
const fetchGetItemsViewByType = async (
currentApi: JellyfinApiContext,
viewType: LibraryTab,
parentId: ParentId,
itemType: BaseItemKind[],
libraryViewSettings: LibraryViewSettings,
options?: AxiosRequestConfig
) => {
const { api, user } = currentApi;
if (api && user?.Id) {
let response;
switch (viewType) {
case LibraryTab.AlbumArtists: {
response = await getArtistsApi(api).getAlbumArtists(
{
userId: user.Id,
parentId: parentId ?? undefined,
enableImageTypes: [libraryViewSettings.ImageType, ImageType.Backdrop],
...getFieldsQuery(viewType, libraryViewSettings),
...getFiltersQuery(viewType, libraryViewSettings),
...getLimitQuery(),
...getAlphaPickerQuery(libraryViewSettings),
sortBy: [libraryViewSettings.SortBy],
sortOrder: [libraryViewSettings.SortOrder],
includeItemTypes: itemType,
startIndex: libraryViewSettings.StartIndex
},
{
signal: options?.signal
}
);
break;
}
case LibraryTab.Artists: {
response = await getArtistsApi(api).getArtists(
{
userId: user.Id,
parentId: parentId ?? undefined,
enableImageTypes: [libraryViewSettings.ImageType, ImageType.Backdrop],
...getFieldsQuery(viewType, libraryViewSettings),
...getFiltersQuery(viewType, libraryViewSettings),
...getLimitQuery(),
...getAlphaPickerQuery(libraryViewSettings),
sortBy: [libraryViewSettings.SortBy],
sortOrder: [libraryViewSettings.SortOrder],
includeItemTypes: itemType,
startIndex: libraryViewSettings.StartIndex
},
{
signal: options?.signal
}
);
break;
}
case LibraryTab.Networks:
response = await getStudiosApi(api).getStudios(
{
userId: user.Id,
parentId: parentId ?? undefined,
...getFieldsQuery(viewType, libraryViewSettings),
includeItemTypes: itemType,
enableImageTypes: [ImageType.Thumb],
startIndex: libraryViewSettings.StartIndex
},
{
signal: options?.signal
}
);
break;
default: {
response = await getItemsApi(api).getItems(
{
userId: user.Id,
recursive: true,
imageTypeLimit: 1,
parentId: parentId ?? undefined,
enableImageTypes: [libraryViewSettings.ImageType, ImageType.Backdrop],
...getFieldsQuery(viewType, libraryViewSettings),
...getFiltersQuery(viewType, libraryViewSettings),
...getLimitQuery(),
...getAlphaPickerQuery(libraryViewSettings),
isFavorite: viewType === LibraryTab.Favorites ? true : undefined,
sortBy: [libraryViewSettings.SortBy],
sortOrder: [libraryViewSettings.SortOrder],
includeItemTypes: itemType,
startIndex: libraryViewSettings.StartIndex
},
{
signal: options?.signal
}
);
break;
}
}
return response.data;
}
};
export const useGetItemsViewByType = (
viewType: LibraryTab,
parentId: ParentId,
itemType: BaseItemKind[],
libraryViewSettings: LibraryViewSettings
) => {
const currentApi = useApi();
return useQuery({
queryKey: [
'ItemsViewByType',
viewType,
parentId,
itemType,
libraryViewSettings
],
queryFn: ({ signal }) =>
fetchGetItemsViewByType(
currentApi,
viewType,
parentId,
itemType,
libraryViewSettings,
{ signal }
),
refetchOnWindowFocus: false,
keepPreviousData : true,
enabled:
[
LibraryTab.Movies,
LibraryTab.Favorites,
LibraryTab.Collections,
LibraryTab.Trailers,
LibraryTab.Series,
LibraryTab.Episodes,
LibraryTab.Networks,
LibraryTab.Albums,
LibraryTab.AlbumArtists,
LibraryTab.Artists,
LibraryTab.Playlists,
LibraryTab.Songs,
LibraryTab.Books,
LibraryTab.Photos,
LibraryTab.Videos
].includes(viewType) && !!parentId
});
};

View File

@ -38,7 +38,7 @@ const _GAMEPAD_LEFT_THUMBSTICK_UP_KEY = 'GamepadLeftThumbStickUp';
const _GAMEPAD_LEFT_THUMBSTICK_DOWN_KEY = 'GamepadLeftThumbStickDown';
const _GAMEPAD_LEFT_THUMBSTICK_LEFT_KEY = 'GamepadLeftThumbStickLeft';
const _GAMEPAD_LEFT_THUMBSTICK_RIGHT_KEY = 'GamepadLeftThumbStickRight';
const _GAMEPAD_A_KEYCODE = 0;
const _GAMEPAD_A_KEYCODE = 13;
const _GAMEPAD_B_KEYCODE = 27;
const _GAMEPAD_DPAD_UP_KEYCODE = 38;
const _GAMEPAD_DPAD_DOWN_KEYCODE = 40;

View File

@ -301,7 +301,7 @@ export class UserSettings {
/**
* Get or set 'Use Episode Images in Next Up and Continue Watching' state.
* @param {string|boolean|undefined} val - Flag to enable 'Use Episode Images in Next Up and Continue Watching' or undefined.
* @param {string|boolean|undefined} [val] - Flag to enable 'Use Episode Images in Next Up and Continue Watching' or undefined.
* @return {boolean} 'Use Episode Images in Next Up' state.
*/
useEpisodeImagesInNextUpAndResume(val) {
@ -463,7 +463,7 @@ export class UserSettings {
/**
* Get or set max days for next up list.
* @param {number|undefined} val - Max days for next up.
* @param {number|undefined} [val] - Max days for next up.
* @return {number} Max days for a show to stay in next up without being watched.
*/
maxDaysForNextUp(val) {
@ -482,7 +482,7 @@ export class UserSettings {
/**
* Get or set rewatching in next up.
* @param {boolean|undefined} val - If rewatching items should be included in next up.
* @param {boolean|undefined} [val] - If rewatching items should be included in next up.
* @returns {boolean} Rewatching in next up state.
*/
enableRewatchingInNextUp(val) {

View File

@ -1732,7 +1732,7 @@
"EnableAudioNormalizationHelp": "Audio normalisation will add a constant gain to keep the average at a desired level (-18dB).",
"EnableAudioNormalization": "Audio Normalisation",
"LabelEnableLUFSScan": "Enable LUFS scan",
"LabelEnableLUFSScanHelp": "Enable LUFS scan for music (This will take longer and more resources).",
"LabelEnableLUFSScanHelp": "Clients can normalise audio playback to get equal loudness across tracks. This will make library scans longer and take more resources.",
"AllowCollectionManagement": "Allow this user to manage collections",
"GetThePlugin": "Get the Plugin",
"Notifications": "Notifications",
@ -1773,5 +1773,8 @@
"MachineTranslated": "Machine Translated",
"ForeignPartsOnly": "Forced/Foreign parts only",
"HearingImpairedShort": "HI/SDH",
"HeaderGuestCast": "Guest Stars"
"HeaderGuestCast": "Guest Stars",
"LabelIsHearingImpaired": "For hearing impaired (SDH)",
"BackdropScreensaver": "Backdrop Screensaver",
"LogoScreensaver": "Logo Screensaver"
}

View File

@ -1264,7 +1264,7 @@
"AskAdminToCreateLibrary": "Pide a un administrador crear una biblioteca.",
"Artist": "Artista",
"AllowFfmpegThrottlingHelp": "Cuando una transcodificación o remuxeado se adelanta lo suficiente de la posición de reproducción actual, se pausa el proceso para que consuma menos recursos. Esto es más útil cuando se mira sin buscar con frecuencia. Apaga esto si experimentas problemas de reproducción.",
"AllowFfmpegThrottling": "Regular transcodificaciones",
"AllowFfmpegThrottling": "Limitar transcodificaciones",
"AlbumArtist": "Artista del álbum",
"Album": "Album",
"Yadif": "YADIF",
@ -1756,10 +1756,10 @@
"AllowSegmentDeletion": "Borrar segmentos",
"HeaderEpisodesStatus": "Estatus de los Episodios",
"AllowSegmentDeletionHelp": "Borrar los viejos segmentos después de que hayan sido enviados al cliente. Esto previene que se tenga almacenado la totalidad de la transcodificación en el disco. Esto funciona unicamente cuando se tenga habilitado el throttling. Apagar esta opción cuando se tengan problemas de reproducción.",
"LabelThrottleDelaySeconds": "Acelerar después",
"LabelThrottleDelaySeconds": "Limitar después de",
"LabelThrottleDelaySecondsHelp": "Tiempo en segundos después de que la transcodificación entre en aceleración. Deben ser los suficientes para que el buffer del cliente siga operando. Unicamente funciona si la aceleración está habilitada.",
"LabelSegmentKeepSeconds": "Tiempo para guardar segmentos",
"LabelSegmentKeepSecondsHelp": "Tiempo en segundos en los que los segmentos deben permanecer antes de que sean sobrescritos. Estos deben de ser mayores a los indicados en \"Acelerar despues de\". Esto funciona unicamente si esta habilitada la opción de eliminar el segmento.",
"LabelSegmentKeepSecondsHelp": "Tiempo en segundos en los que los segmentos deben permanecer antes de que sean sobrescritos. Estos deben de ser mayores a los indicados en \"Limitar después de\". Esto funciona unicamente si esta habilitada la opción de eliminar el segmento.",
"AllowAv1Encoding": "Permitir encodificación en formato AV1",
"GoHome": "Ir a Inicio",
"UnknownError": "Un error desconocido ocurrió.",

View File

@ -1246,7 +1246,7 @@
"LabelDroppedFrames": "Frames perdidos",
"LabelCorruptedFrames": "Frames corruptos",
"AskAdminToCreateLibrary": "Pídele a un administrador que cree una biblioteca.",
"AllowFfmpegThrottling": "Acelerar las conversiones",
"AllowFfmpegThrottling": "Limitar transcodificaciones",
"ClientSettings": "Ajustes de cliente",
"PreferEmbeddedEpisodeInfosOverFileNames": "Priorizar la información embebida sobre los nombres de archivos",
"PreferEmbeddedEpisodeInfosOverFileNamesHelp": "Usar la información de episodio de los metadatos embebidos si está disponible.",

View File

@ -1307,7 +1307,7 @@
"AllowRemoteAccessHelp": "Si no se marca, se bloquearán todas las conexiones remotas.",
"AllowRemoteAccess": "Permitir conexiones remotas a este servidor",
"AllowFfmpegThrottlingHelp": "Cuando una transcodificación o remuxeado se adelanta lo suficiente de la posición de reproducción actual, se pausa el proceso para que consuma menos recursos. Esto es más útil cuando se mira sin buscar con frecuencia. Apaga esto si experimentas problemas de reproducción.",
"AllowFfmpegThrottling": "Regular transcodificaciones",
"AllowFfmpegThrottling": "Limitar transcodificaciones",
"AllowOnTheFlySubtitleExtractionHelp": "Los subtítulos incrustados pueden extraerse de los videos y entregarse a los clientes en texto plano para ayudar a evitar la transcodificación de video. En algunos sistemas, esto puede tardar mucho tiempo y provocar que la reproducción de video se detenga durante el proceso de extracción. Deshabilite esta opción para que los subtítulos incrustados se graben con transcodificación de video cuando no estén soportados de forma nativa por el dispositivo cliente.",
"AllowOnTheFlySubtitleExtraction": "Permitir la extracción de subtítulos sobre la marcha",
"AllowMediaConversionHelp": "Permitir o denegar acceso a la función de convertir medios.",
@ -1578,5 +1578,62 @@
"LabelMaxDaysForNextUp": "Días máximos en «A continuación»",
"LabelEnableAudioVbrHelp": "La tasa de bits variable ofrece mejor calidad a la tasa media de bits promedio, pero en algunos raros casos puede causar almacenamiento en búfer y problemas de compatibilidad.",
"LabelEnableAudioVbr": "Habilitar codificación VBR de audio",
"HeaderPerformance": "Rendimiento"
"HeaderPerformance": "Rendimiento",
"HeaderEpisodesStatus": "Status de los Episodios",
"LabelBackdropScreensaverInterval": "Intervalo del Protector de Pantalla de Fondo",
"LabelBackdropScreensaverIntervalHelp": "Intervalo en segundos entre los diferentes fondos cuando se usa el protector de pantalla de fondo.",
"LabelEnableLUFSScanHelp": "Permite a los clientes normalizar la reproducción de audio para tener el mismo volumen en todas las pistas. Esto hará que los escaneos de biblioteca tomen más tiempo y utilicen más recursos.",
"LabelSyncPlaySettingsSpeedToSyncHelp": "Método de corrección de sincronización que consiste en acelerar la reproducción. La corrección de sincronización debe estar activada.",
"LabelSyncPlaySettingsSkipToSyncHelp": "Método de corrección de sincronización que consiste en saltar a la posición de reproducción estimada. La Corrección de Sincronización debe estar activada.",
"ListView": "Vista en Lista",
"MessageNoItemsAvailable": "No hay elementos disponibles actualmente.",
"Bold": "Negrita",
"Larger": "Más grande",
"LabelSyncPlaySettingsMinDelaySkipToSyncHelp": "Retraso mínimo (en milisegundos) después del cual SkipToSync intentará corregir la posición de reproducción.",
"Localization": "Localización",
"GoHome": "Ir a Inicio",
"LabelSyncPlaySettingsSpeedToSync": "Activar SpeedToSync",
"LabelDeveloper": "Desarrollador",
"LabelLevel": "Nivel",
"LabelDate": "Fecha",
"EnableCardLayout": "Mostrar CardBox visual",
"LabelMediaDetails": "Detalles de multimedia",
"LabelSyncPlaySettingsSkipToSync": "Activar SkipToSync",
"LabelSystem": "Sistema",
"LogLevel.Debug": "Depurar",
"LogLevel.Trace": "Rastreo",
"LogLevel.Warning": "Advertencia",
"LogLevel.Error": "Error",
"LogLevel.Critical": "Crítico",
"GridView": "Vista en Cuadrícula",
"GetThePlugin": "Obtener el Plugin",
"LabelSyncPlayNoGroups": "No hay grupos disponibles",
"LabelTextWeight": "Grosor del texto",
"LogLevel.None": "Ningún",
"LabelSyncPlaySettingsSpeedToSyncDuration": "Duración de SpeedToSync",
"LabelSyncPlaySettingsMinDelaySkipToSync": "Retraso mínimo de SkipToSync",
"EnableAudioNormalizationHelp": "La normalización de audio añadirá una ganancia constante para mantener la media en el nivel deseado (-18dB).",
"LabelEnableLUFSScan": "Activar escaneo LUFS",
"MenuOpen": "Abrir Menú",
"MenuClose": "Cerrar Menú",
"BackdropScreensaver": "Protector de Pantalla de Fondo",
"LabelSyncPlaySettingsMaxDelaySpeedToSyncHelp": "Retraso máximo de reproducción (en milisegundos) después del cual SkipToSync se usará en lugar de SpeedToSync.",
"LogoScreensaver": "Protector de Pantalla de Logo",
"MessageNoFavoritesAvailable": "No hay favoritos disponibles actualmente.",
"LabelSyncPlaySettingsSpeedToSyncDurationHelp": "Cantidad de milisegundos que SpeedToSync utilizará para corregir la posición de reproducción.",
"HeaderConfirmRepositoryInstallation": "Confirma la instalación del repositorio de plugins",
"EnableAudioNormalization": "Normalización de audio",
"LogLevel.Information": "Información",
"AllowCollectionManagement": "Permitir a este usuario gestionar colecciones",
"Lyricist": "Letrista",
"AllowSegmentDeletion": "Borrar segmentos",
"AllowSegmentDeletionHelp": "Borrar segmentos viejos que ya hayan sido enviados al cliente. Esto evita tener que guardar la totalidad del archivo transcodificado en el disco. Funciona solamente si la opción \"regular transcodificaciones\" también está activada. Si se experimentan problemas en la reproducción, desactivar esta opción.",
"LabelThrottleDelaySeconds": "Limitar después de",
"LabelThrottleDelaySecondsHelp": "Duración en segundos después de la cual la transcodificación será limitada. Debe ser suficiente para permitirle al cliente mantener un buffer adecuado. Solo funciona si la limitación de la transcodificación está activada.",
"LabelSegmentKeepSeconds": "Tiempo de retención de segmentos",
"LabelSegmentKeepSecondsHelp": "Duración en segundos que se retendrán los segmentos antes de ser sobreescritos. Debe ser mayor que \"Limitar después de\". Solo funciona si la opción \"Borrar segmentos\" está activada.",
"LabelParallelImageEncodingLimit": "Límite de codificaciones paralelas de imágenes",
"LabelParallelImageEncodingLimitHelp": "Cantidad máxima de codificaciones de imágenes que pueden ejecutarse en paralelo. Al establecer 0 se elegirá un límite basado en las especificaciones de su sistema.",
"MediaInfoTitle": "Título",
"HeaderGuestCast": "Estrellas Invitadas"
}

View File

@ -1602,5 +1602,8 @@
"IgnoreDtsHelp": "غیر فعال کردن این گزینه ممکن است برخی اشکالات را رفع کند، مثل نبودن صدا بر روی کانال هایی که جریان صدا و تصویر جداگانه دارند.",
"LabelDummyChapterDurationHelp": "وقفه استخراج تصاویر فصل به ثانیه.",
"HeaderDummyChapter": "تصاویر فصل",
"EnableAudioNormalization": "معمول سازی صوت"
"EnableAudioNormalization": "معمول سازی صوت",
"AllowCollectionManagement": "به این کاربر اجازه مدیریت مجموعه را بده",
"AllowSegmentDeletion": "تکه ها را پاک کن",
"AllowSegmentDeletionHelp": "پاک کردن تکه های قدیمی را بعد از فرستادن به کلاینت. این کار از ذخیره کل فایل transcode شده بر روی هارد جلوگیری می‌ کند. این تنها زمانی کار می کند که throttling فعال باشد. درصورت مشکل در هنگام پخش این ویژگی را غیرفعال کنید."
}

View File

@ -1437,7 +1437,7 @@
"QuickConnectNotActive": "Pikayhdistys ei ole tällä palvelimella käytössä",
"QuickConnectNotAvailable": "Pyydä palvelimen ylläpitoa ottamaan Pikayhdistys käyttöön",
"QuickConnectInvalidCode": "Virheellinen Pikayhdistyskoodi",
"QuickConnectDescription": "Kirjautuaksesi Pikayhdistyksellä, valitse 'Pikayhdistys'-painike laitteelta, josta yrität kirjautua ja syötä alla oleva koodi.",
"QuickConnectDescription": "Kirjautuaksesi Pikayhdistyksellä, valitse 'Pikayhdistys' laitteelta, josta yrität kirjautua ja syötä näytettävä koodi alle.",
"QuickConnectDeactivated": "Pikayhdistys katkaistiin ennen kirjautumispyynnön hyväksyntää",
"QuickConnectAuthorizeSuccess": "Laitteesi on todennettu!",
"QuickConnectAuthorizeFail": "Tuntematon Pikayhdistyskoodi",

View File

@ -212,5 +212,14 @@
"ValueMusicVideoCount": "{0} tónleika sjónbond",
"ValueOneAlbum": "1 album",
"ValueOneSong": "1 sangur",
"ValueSongCount": "{0} sangir"
"ValueSongCount": "{0} sangir",
"ButtonForgotPassword": "Gloymt loyniorð",
"ButtonSignIn": "Innrita",
"ButtonSignOut": "Útrita",
"Channels": "Rásir",
"Favorites": "Yndis",
"Folders": "Mappur",
"Collections": "Søvn",
"Default": "Sjálvgildi",
"Genres": "Greinar"
}

View File

@ -1202,5 +1202,13 @@
"LabelKodiMetadataEnableExtraThumbsHelp": "תמונות שהורדו יכולות להישמר לשדות extrafanart ו-extrathumbs בו זמנית לצורך התאמה מירבית לסקינים של קודי.",
"LabelKodiMetadataEnableExtraThumbs": "העתק extrafanart לשדה extrathumbs",
"LabelGroupMoviesIntoCollectionsHelp": "כל הסרטים באוסף יופיעו בתור פריט מקובץ אחד ברשימות סרטים.",
"LabelHomeScreenSectionValue": "איזור {0} בעמוד הבית"
"LabelHomeScreenSectionValue": "איזור {0} בעמוד הבית",
"LabelExtractChaptersDuringLibraryScan": "חלץ את תמונות הפרק תוך כדי סריקת הספריה",
"LabelBackdropScreensaverInterval": "interval שומר המסך של הרקע",
"LabelEnableLUFSScan": "הפעלת סריקת LUFS",
"LabelEnableAutomaticPortMapHelp": "בצע port forward בצורה אוטומטית מפורטים בראוטר אל פורטים לוקאליים על השרת באמצעות UPnP. ייתכן שזה לא יעבוד עם דגמי נתב מסוימים או עם קונפיגורציות רשת מסוימות. שינויים לא יחולו עד לאחר הפעלה מחדש של השרת.",
"BackdropScreensaver": "שומר המסך של הרקע",
"LabelExtractChaptersDuringLibraryScanHelp": "צור תמונות פרקים כאשר סרטונים מיובאים תוך כדי סריקת הספריה. אחרת, הם יחולצו תוך כדי המשימה המתוזמנת העוסקת בתמונות הפרק, מה שיאפשר לסריקת הספריה הרגילה להסתיים מהר יותר.",
"LabelThrottleDelaySecondsHelp": "כמות הזמן בשניות שלאחריה המקודד יגיע ל-throttle. חייב להיות גדול מספיק כדי שהלקוח ישמור על buffer גדול מספיק. עובד רק אם throttling מופעל.",
"HeaderGuestCast": "כוכבים אורחים"
}

View File

@ -1627,7 +1627,7 @@
"AllowEmbeddedSubtitlesAllowImageOption": "Kép engedélyezése",
"AllowEmbeddedSubtitlesAllowNoneOption": "Összes tiltása",
"AllowEmbeddedSubtitlesAllowAllOption": "Összes engedélyezése",
"AllowEmbeddedSubtitlesHelp": "Tiltsa le a médiatárolókba csomagolt feliratokat. Teljes könyvtárfrissítést igényel.",
"AllowEmbeddedSubtitlesHelp": "Letiltja a médiatárolókba csomagolt feliratokat. Teljes könyvtárfrissítést igényel.",
"AllowEmbeddedSubtitles": "Különféle típusú beágyazott feliratok letiltása",
"ShowParentImages": "Sorozatképek megjelenítése",
"NextUpRewatching": "Újranézés",
@ -1767,5 +1767,6 @@
"AllowAv1Encoding": "AV1 kódolás engedélyezése",
"GridView": "Rács Nézet",
"ListView": "Lista Nézet",
"MachineTranslated": "Gépi fordítás"
"MachineTranslated": "Gépi fordítás",
"AiTranslated": "Gépi fordítás"
}

View File

@ -22,7 +22,7 @@
"TabNotifications": "Tilkynningar",
"WelcomeToProject": "Velkomin/n í Jellyfin!",
"Anytime": "Hvenær sem er",
"Genres": "Tegundir",
"Genres": "Stefnur",
"ButtonAddImage": "Bæta við ljósmynd",
"ButtonAddServer": "Bæta við þjón",
"ButtonAddUser": "Bæta við notenda",
@ -39,7 +39,7 @@
"PasswordMatchError": "Lykilorð og ítrekun lykilorðs þarf að passa.",
"PasswordResetConfirmation": "Ertu viss um að þú viljir endursetja lykilorðið þitt?",
"PinCodeResetConfirmation": "Ertu viss um að þú viljir endursetja PIN kóðann þinn?",
"HeaderAlbumArtists": "Höfundur plötu",
"HeaderAlbumArtists": "Listamaður á umslagi",
"HeaderContinueWatching": "Halda áfram að horfa",
"Favorites": "Uppáhalds",
"Play": "Spila",
@ -70,7 +70,7 @@
"AroundTime": "Um {0}",
"Art": "List",
"AllComplexFormats": "Öll flókin form (ASS, SSA, VobSub, PGS, SUB, IDX, …)",
"Artists": "Listamaður",
"Artists": "Listamenn",
"AsManyAsPossible": "Eins margir og mögulegt er",
"Ascending": "Í vaxandi röð",
"AspectRatio": "Skjáhlutfall",
@ -89,7 +89,7 @@
"Yes": "Já",
"People": "Fólk",
"PerfectMatch": "Passar fullkomlega",
"Channels": "Stöðvar",
"Channels": "Rásir",
"Collections": "Söfn",
"OptionUnairedEpisode": "Ófrumsýndir þættir",
"OptionWeekdays": "Vikudagar",
@ -121,7 +121,7 @@
"PlayNext": "Spila næsta",
"PlayNextEpisodeAutomatically": "Spila næsta þátt sjálfkrafa",
"Played": "Spilað",
"Photos": "Myndir",
"Photos": "Ljósmyndir",
"Movies": "Kvikmyndir",
"HeaderProfileInformation": "Upplýsingar um prófíl",
"HeaderPassword": "Lykilorð",
@ -205,11 +205,11 @@
"AlwaysPlaySubtitlesHelp": "Allir textar sem samsvara við túngumáli valið verða alltaf hlaðnir óháð hljóðmáls túngumáli.",
"AllowedRemoteAddressesHelp": "Kommu aðskilinn listi yfir ip tölur eða ip-númeramát fyrir net sem mega fjartengjas. Ef þetta er autt eru allar fjartengingar leyfðar.",
"AllowHWTranscodingHelp": "Leyfa viðtæki að umbreyta straumi í rauntíma.Þetta getur minnkað álag á þjón.",
"ValueSpecialEpisodeName": "Sérstakt - {0}",
"Shows": "Sýningar",
"Playlists": "Spilunarlisti",
"ValueSpecialEpisodeName": "Sérstaktur - {0}",
"Shows": "Þættir",
"Playlists": "Efnisskrár",
"ButtonScanAllLibraries": "Skanna Öll Gagnasöfn",
"AllLibraries": "Öll gagnasöfn",
"AllLibraries": "Öll efnissöfn",
"RefreshMetadata": "Endurhlaða lýsigögn",
"Refresh": "Endurhlaða",
"ReleaseDate": "Útgáfudagur",
@ -472,11 +472,12 @@
"EnablePlugin": "Virkja",
"Bwdif": "BWDIF",
"DisablePlugin": "Slökkva",
"EnableAutoCast": "Setja sem Sjálfgefið",
"EnableAutoCast": "Setja sem sjálfgefið",
"EnableDetailsBanner": "Upplýsingar borði",
"DeleteAll": "Eyða Öllu",
"ButtonBackspace": "Backspace",
"ButtonUseQuickConnect": "Nota hraðtengingu",
"Digital": "Stafrænt",
"DownloadAll": "Sækja allt"
"DownloadAll": "Sækja allt",
"AllowCollectionManagement": "Leyfa þessum notanda að sýsla með söfn"
}

View File

@ -251,5 +251,12 @@
"AllowSegmentDeletionHelp": "Padam segmen lama setelah ia dihantar ke pelayan. Ini menghalang file transcode disimpan dalam disk. Ia akan berfungsi dengan penghad haju dihidupkan. Matikan tetapan ini jika anda mengalami isu dengan pemain video.",
"LabelThrottleDelaySeconds": "Penghad laju setelah",
"Settings": "Tetapan",
"SelectServer": "Pilih pelayan"
"SelectServer": "Pilih pelayan",
"ButtonBackspace": "pendikit",
"ButtonSpace": "Ruang",
"BackdropScreensaver": "Penyimpan skrin latar belakang",
"Cursive": "Sambung",
"DefaultSubtitlesHelp": "Sari kata dimuatkan berdasarkan bendera lalai dan paksa dalam metadata yang disematkan. Keutamaan bahasa diambil kira apabila terdapat beberapa pilihan.",
"LabelThrottleDelaySecondsHelp": "Masa dalam saat selepas mana transkoder akan dihadkan. Mesti cukup besar untuk klien mengekalkan penimbal yang sihat. Hanya berfungsi jika penghadan diaktifkan.",
"LabelSegmentKeepSeconds": "Masa untuk mengekalkan segmen"
}

View File

@ -1698,5 +1698,36 @@
"SubtitleGreen": "Verde",
"SubtitleWhite": "Alb",
"SubtitleYellow": "Galben",
"VideoRangeTypeNotSupported": "Tipul intervalului video nu este suportat"
"VideoRangeTypeNotSupported": "Tipul intervalului video nu este suportat",
"Unreleased": "Indisponibil momentan",
"LabelIsHearingImpaired": "Pentru deficiente de auz (SDH)",
"PasswordRequiredForAdmin": "O parola este necesara pentru conturile de administrator.",
"Studio": "Studio",
"LabelSyncPlayNoGroups": "Niciun grup disponibil",
"Notifications": "Notificari",
"LabelDate": "Data",
"LabelLevel": "Nivel",
"LabelSystem": "Sistem",
"LabelMediaDetails": "Detalii media",
"LogLevel.Information": "Informatie",
"Unknown": "Necunoscut",
"GoHome": "Catre Ecranul Principal",
"UnknownError": "A aparut o eroare neprevazuta.",
"LogLevel.Warning": "Avertisment",
"LogLevel.Error": "Eroare",
"LogLevel.Critical": "Critic",
"LabelDeveloper": "Dezvoltator",
"AllowCollectionManagement": "Permite acestui utilizator sa administreze colectiile",
"ListView": "Vizualizare ca lista",
"MenuOpen": "Deschideti Meniul",
"MenuClose": "Inchidere Meniu",
"UserMenu": "Meniu Utilizator",
"Select": "Selecteaza",
"EnableAudioNormalizationHelp": "Normalizarea audio va adauga o crestere constanta pentru a mentine media la un nivel dorit (-18 decibeli).",
"AiTranslated": "Tradus de catre IA",
"AllowSegmentDeletion": "Sterge segmentele",
"AllowSegmentDeletionHelp": "Sterge segmentele vechi dupa ce acestea au fost trimise catre client. Previne necesitatea de a stoca intregul fisier transcodificat pe disc. Va functiona doar cu throttling activat. Opriti aceasta optiune daca intampinati probleme de redare.",
"LabelThrottleDelaySeconds": "Limitare dupa",
"LabelSegmentKeepSeconds": "Timpul pentru a pastra segmentele",
"EnableAudioNormalization": "Normalizare audio"
}

View File

@ -1,6 +1,6 @@
{
"Absolute": "Абсолютный",
"AccessRestrictedTryAgainLater": "В настоящее время доступ запрещён. Повторите попытку позже.",
"AccessRestrictedTryAgainLater": "В настоящее время доступ ограничен. Повторите попытку позже.",
"Actor": "Актёр",
"Add": "Добавить",
"AddToCollection": "Добавить в коллекцию",
@ -1401,7 +1401,7 @@
"QuickConnectDescription": "Чтобы войти в систему с помощью Быстрого подключения, нажмите кнопку «Быстрое подключение» на устройстве, с которого вы выполняете вход, и введите указанный ниже код.",
"QuickConnectDeactivated": "Быстрое подключение было деактивировано до утверждения запроса на вход",
"QuickConnectAuthorizeFail": "Неопознанный код Быстрого подключения",
"QuickConnectAuthorizeSuccess": "Запрос авторизован",
"QuickConnectAuthorizeSuccess": "Вы успешно авторизовали ваше устройство!",
"QuickConnectAuthorizeCode": "Введите код {0} для входа",
"QuickConnectActivationSuccessful": "Активировано успешно",
"QuickConnect": "Быстрое подключение",
@ -1424,7 +1424,7 @@
"LabelTonemappingPeakHelp": "Этим значением перекрывается сигнальный/номинальный/эталонный пик. Полезно, когда встроенная информация о пиках в метаданных дисплея ненадёжна или при тонмаппинге из узкого диапазона в более широкий. Значения рекомендуемое и по умолчанию - 100 и 0.",
"LabelTonemappingThresholdHelp": "Параметры алгоритма тонмаппинга подстраиваются для каждой сцены. А порог используется, чтобы определить, изменилась ли сцена или нет. Если дистанция между средней яркостью текущего кадра и текущим скользящим средним превышает пороговое значение, мы пересчитаем среднюю и пиковую яркость сцены. Значения рекомендуемое и по умолчанию - 0.8 и 0.2.",
"TonemappingAlgorithmHelp": "Тонмаппинг можно подстроить. Если вы не уверены с этими параметрами, оставьте значения по умолчанию. Рекомендуемое значение - \"BT.2390\".",
"AllowTonemappingHelp": "Тонмаппинг может преобразовать динамический диапазон видео из HDR в SDR, сохраняя детали изображения и цвета, которые являются очень важной информацией для представления исходной сцены. В настоящее время работает только с 10-бит HDR10, HLGи DoVi видео. Для этого требуется соответствующая среда выполнения OpenCL или CUDA.",
"AllowTonemappingHelp": "Тонмаппинг может преобразовать динамический диапазон видео из HDR в SDR, сохраняя детали изображения и цвета, которые являются очень важной информацией для представления исходного видео. В настоящее время работает только с 10-бит HDR10, HLG и DoVi. Для этого требуется соответствующая среда выполнения OpenCL или CUDA.",
"LabelOpenclDeviceHelp": "Это устройство OpenCL, которое используется для тонмаппинга. Слева от точки - номер платформы, а справа - это номер устройства на платформе. Значение по умолчанию - 0.0. Требуется файл приложения FFmpeg, содержащий метод аппаратного ускорения OpenCL.",
"OptionAllowContentDownloadHelp": "Пользователи могут загружать медиафайлы и хранить их на своих устройствах. Это не то же самое, как функция синхронизации. Для правильной работы книжных медиатек это необходимо.",
"HeaderDeleteDevices": "Удалить все устройства",
@ -1742,7 +1742,7 @@
"LogLevel.Critical": "Критично",
"LogLevel.None": "Ничего",
"HeaderEpisodesStatus": "Статус эпизодов",
"LabelEnableLUFSScanHelp": "Включите сканирование LUFS для музыки (это займет больше времени и ресурсов).",
"LabelEnableLUFSScanHelp": "Клиенты могут нормализировать воспроизведения звука, чтобы обеспечить одинаковую громкость в разных треках. Сканированния библеотек займет больше времени и ресурсов.",
"LabelLevel": "Уровень",
"LogLevel.Debug": "Отладка",
"MessageRepositoryInstallDisclaimer": "ПРЕДУПРЕЖДЕНИЕ: Установка стороннего репозитория плагинов несет определенные риски. Он может содержать нестабильный или вредоносный код и может изменяться в любое время. Устанавливайте репозитории только от авторов, которым вы доверяете.",
@ -1763,5 +1763,18 @@
"LabelSegmentKeepSeconds": "Время сохранения сегментов",
"LogLevel.Error": "Ошибка",
"LabelBackdropScreensaverInterval": "Интервал между фонами у заставки",
"LabelBackdropScreensaverIntervalHelp": "Время в секундах между разными фонами, когда используется заставка."
"LabelBackdropScreensaverIntervalHelp": "Время в секундах между разными фонами, когда используется заставка.",
"BackdropScreensaver": "Фоновая заставка",
"GoHome": "Домой",
"GridView": "Отображение сеткой",
"LabelIsHearingImpaired": "Для людей со слабым слухом (SDH)",
"AllowAv1Encoding": "Разрешить кодирование AV1 формата",
"LogoScreensaver": "Логотип заставки",
"HeaderGuestCast": "Приглашенные звезды",
"UnknownError": "Возникла не известная ошибка.",
"ListView": "Отображение списком",
"AiTranslated": "Переведено при помощи ИИ",
"MachineTranslated": "Машинный перевод",
"HearingImpairedShort": "HI/SDH",
"ForeignPartsOnly": "Только для принудительных и иностранных частей"
}

View File

@ -662,7 +662,7 @@
"TabLatest": "Najnovšie pridané",
"TabMusic": "Hudba",
"TabMyPlugins": "Moje zásuvné moduly",
"TabNetworks": "TV Siete",
"TabNetworks": "Stanice",
"TabNfoSettings": "NFO nastavenia",
"TabNotifications": "Upozornenia",
"TabOther": "Ostatné",
@ -953,7 +953,7 @@
"GuideProviderSelectListings": "Výber zobrazenia",
"GroupVersions": "Skupinové verzie",
"FetchingData": "Načítavanie dodatočných dát",
"Extras": "Extras",
"Extras": "Bonusové materiály",
"FastForward": "Rýchlo dopredu",
"FFmpegSavePathNotFound": "Nepodarilo sa nám nájsť FFmpeg pomocou vami zadanej cesty. FFprobe je taktiež potrebná a musí existovať v rovnakom priečinku. Tieto komponenty sú za normálnych okolností zabalené spolu do toho istého priečinku. Prosím, overte zadanú cestu a skúste to znova.",
"ErrorSavingTvProvider": "Nastala chyba pri ukladaní sprostredkovateľa TV vysielania. Prosím, uistite sa, že je prístupný a skúste to znova.",
@ -1081,11 +1081,11 @@
"MessagePluginInstallDisclaimer": "UPOZORNENIE: Inštalácia zásuvného modulu tretej strany má určité riziká. Modul môže obsahovať nestabilný alebo škodlivý kód a môže sa kedykoľvek zmeniť. Inštalujte zásuvné moduly len od autorov, ktorým dôverujete a majte na vedomí ich potenciálne následky, vrátane kontaktovania externých služieb, dlhšieho prehľadávanie knižníc alebo dodatočných procesov na pozadí.",
"MessagePluginConfigurationRequiresLocalAccess": "Pre konfiguráciu tohoto zásuvného modulu sa prihláste priamo na lokálny server.",
"MessagePlayAccessRestricted": "Prehrávanie tohoto obsahu je aktuálne obmedzené. Prosím, kontaktujte svojho administrátora servera pre viac informácií.",
"MessagePasswordResetForUsers": "Nasledujúci používatelia si nechali obnoviť heslo. Teraz sa môžu prihlásiť s PIN kódmi, ktoré použili k pri obnove hesla.",
"MessagePasswordResetForUsers": "Nasledujúcim používateľom boli resetované heslá. Teraz sa môžu prihlásiť pomocou kódov PIN, ktoré boli použité na resetovanie.",
"MessageNoServersAvailable": "Žiadne servery neboli nájdené pomocou automatického objavovania serverov.",
"MessageNoMovieSuggestionsAvailable": "V súčasnosti nie sú k dispozícií žiadne filmové návrhy. Začnite pozerať a hodnotiť vaše filmy, potom sa sem vráťte pre vaše odporúčania.",
"MessageNoCollectionsAvailable": "Kolekcie vám umožnia užiť si vlastné zoskupenia filmov, seriálov a albumov. Kliknite na tlačítko '+' pre začatie vytvárania kolekcie.",
"MessageImageTypeNotSelected": "Prosím, vyberte typ obrázku z rozbaľovacieho menu.",
"MessageImageTypeNotSelected": "Z rozbaľovacej ponuky vyberte typ obrázka.",
"MessageForgotPasswordInNetworkRequired": "Prosím, skúste to znova vo vašej domácej sieti pre zahájenie procesu obnovy hesla.",
"MessageForgotPasswordFileCreated": "Nasledujúci súbor bol vytvorený na vašom serveri a obsahuje inštrukcie, ako postupovať",
"MessageDownloadQueued": "Sťahovanie zaradené do fronty.",
@ -1095,7 +1095,7 @@
"MessageCreateAccountAt": "Vytvoriť účet v {0}",
"MessageContactAdminToResetPassword": "Prosím, kontaktujte vášho systémového administrátora k obnoveniu hesla.",
"MessageConfirmRevokeApiKey": "Ste si istý, že chcete odňať tento API kľúč? Aplikácie pripojené k tomuto serveru budú rázne ukončené.",
"Menu": "Menu",
"Menu": "Ponuka",
"MediaIsBeingConverted": "Médium sa konvertuje do formátu, ktorý je kompatibilný so zariadením, kde sa médium prehráva.",
"MediaInfoSampleRate": "Vzorkovacia frekvencia",
"MediaInfoRefFrames": "Ref snímky",
@ -1397,7 +1397,7 @@
"SelectServer": "Vybrať server",
"Restart": "Reštartovať",
"ResetPassword": "Obnoviť heslo",
"QuickConnectAuthorizeSuccess": "Požiadavka autorizovaná",
"QuickConnectAuthorizeSuccess": "Úspešne ste overili vaše zariadenie!",
"QuickConnectAuthorizeFail": "Neznámy kód pre Rýchle pripojenie",
"QuickConnectAuthorizeCode": "Zadajte kód {0} pre prihlásenie",
"QuickConnectActivationSuccessful": "Úspešne aktivované",
@ -1447,7 +1447,7 @@
"AspectRatioCover": "Obal",
"VideoAudio": "Video Zvuk",
"Video": "Video",
"AllowTonemappingHelp": "Tone-mapping can transform the dynamic range of a video from HDR to SDR while maintaining image details and colors, which are very important information for representing the original scene. Currently works only with 10bit HDR10, HLG and DoVi videos. This requires the corresponding OpenCL or CUDA runtime.",
"AllowTonemappingHelp": "Mapovanie tónov umožňuje zmeniť dynamický rozsah videa z HDR na SDR bez straty dôležitých informácií pôvodného obrazu, ako sú detaily a farby. Táto funkcia v súčasnosti funguje len pre videá, ktoré obsahujú 10-bitové HDR10, HLG alebo Dolby Vision. Funkcia vyžaduje OpenCL alebo CUDA.",
"LabelTonemappingThresholdHelp": "Parametre algoritmu mapovania tónov sú prispôsobené jednotlivým scénam. A tento prah sa používa na zistenie, či sa scéna zmenila alebo nie. Pokiaľ rozdiel medzi súčasnou priemernou svetlosťou snímku a priebežným priemerom tento prah prekročí, bude priemerná a vrchná svetlosť scény prepočítaná. Doporučené a predvolené hodnoty sú 0.8 a 0.2.",
"LabelUDPPortRangeHelp": "Obmedzí UDP pripojenie Jellyfinu na tento rozsah. (Predvolená hodnota je 1024 - 65535).<br/> Poznámka: Niektoré funkcie vyžadujú určité porty, ktoré sa môžu nachádzať mimo tohto rozsahu.",
"Remuxing": "Remuxovanie",
@ -1508,7 +1508,7 @@
"DisablePlugin": "Zakázať",
"EnablePlugin": "Povoliť",
"Framerate": "Snímková frekvencia",
"DirectPlayHelp": "Zdrojový súbor je s klientom plne kompatibilný a relácia ho preto prijíma bez dodatočných modifikácií.",
"DirectPlayHelp": "Zdrojový súbor je úplne kompatibilný s týmto klientom a relácia prijíma súbor bez úprav.",
"HeaderContinueReading": "Pokračovať v čítaní",
"LabelSyncPlaySettingsDescription": "Zmeniť nastavenia SyncPlay",
"LabelSlowResponseTime": "Čas v milisekundách, ktorý je považovaný za pomalú odozvu",
@ -1679,7 +1679,7 @@
"OptionDateShowAdded": "Dátum pridania seriálu",
"OptionDateEpisodeAdded": "Dátum pridania epizódy",
"IgnoreDtsHelp": "Vypnutím sa môžu vyriešiť niektoré problémy, napr. chýbajúci zvuk pri kanáloch so samostatnými zvukovými a video streamami.",
"IgnoreDts": "Ignorovať DTS (dekódovacia časová pečiatka)",
"IgnoreDts": "Ignorovať DTS (dekódovacia časová značka)",
"Unreleased": "Zatiaľ nevydané",
"EnableCardLayout": "Zobraziť vizuálny CardBox",
"MessageNoItemsAvailable": "Momentálne nie sú k dispozícii žiadne položky.",
@ -1694,11 +1694,11 @@
"LabelDummyChapterCount": "Limit",
"LabelDummyChapterCountHelp": "Maximálny počet obrázkov kapitol, ktoré budú extrahované pre každý mediálny súbor.",
"LabelChapterImageResolution": "Rozlíšenie",
"LabelChapterImageResolutionHelp": "Rozlíšenie extrahovaných obrázkov kapitol. Zmena tohto nastavenia nemá žiaden vplyv na existujúce kapitoly.",
"LabelChapterImageResolutionHelp": "Rozlíšenie extrahovaných obrázkov kapitol. Zmena tohto nastavenia nebude mať žiadny vplyv na existujúce fiktívne kapitoly.",
"ResolutionMatchSource": "Rovnaké ako zdroj",
"SaveRecordingNFOHelp": "Uloží metadáta z EPG položiek sprievodcu spolu s médiami.",
"SaveRecordingImages": "Uložiť obrázky EPG nahrávky",
"LabelDummyChapterDurationHelp": "Interval medzi kapitolami. Vytváranie kapitol je možné vypnúť nastavením na 0. Zmena tohto nastavenia nemá žiaden vplyv na existujúce kapitoly.",
"LabelDummyChapterDurationHelp": "Interval medzi fiktívnymi kapitolami. Nastavením na 0 vypnete generovanie fiktívnych kapitol. Zmena tejto hodnoty nebude mať žiadny vplyv na existujúce fiktívne kapitoly.",
"SaveRecordingNFO": "Uložiť metadáta nahrávky zo sprievodcu EPG do NFO",
"SaveRecordingImagesHelp": "Uloží obrázky z EPG položiek sprievodcu spolu s médiami.",
"HeaderRecordingMetadataSaving": "Metadáta nahrávok",
@ -1706,36 +1706,75 @@
"LabelEnableAudioVbr": "Povoliť kódovanie zvuku VBR",
"HeaderPerformance": "Výkon",
"AllowCollectionManagement": "Povoliť tomuto používateľovi spravovať kolekcie",
"LabelParallelImageEncodingLimitHelp": "Maximálny počet kódovania obrázkov, ktoré môžu naraz bežať. Nastavením na 0 bude limit nastavení podľa parametrov systému.",
"TonemappingModeHelp": "Vyberte režim mapovania tónu. Ak narazíte na preexponované svetlé miesta, skúste prepnúť na režim RGB.",
"LabelParallelImageEncodingLimitHelp": "Maximálny počet kódovaní obrazu, ktoré môžu bežať paralelne. Nastavením tejto hodnoty na 0 sa zvolí limit na základe špecifikácie vášho systému.",
"TonemappingModeHelp": "Vyberte režim mapovania tónov. Ak sa vyskytnú preexponované svetlé miesta, skúste prepnúť na režim RGB.",
"Featurette": "Stredne dlhý film",
"Short": "Krátky film",
"PasswordRequiredForAdmin": "Administrátorské úcty musia mať nastavené heslo.",
"LabelTonemappingMode": "Režim mapovania tónu",
"SubtitleCyan": "Tyrkysová",
"SubtitleGray": "Sivá",
"PasswordRequiredForAdmin": "Pre administrátorské účty sa vyžaduje heslo.",
"LabelTonemappingMode": "Režim mapovania tónov",
"SubtitleCyan": "Azúrová",
"SubtitleGray": "Šedá",
"Studio": "Štúdio",
"SubtitleBlue": "Modrá",
"SubtitleBlack": "Čierna",
"SubtitleGreen": "Zelená",
"SubtitleLightGray": "Svetlo sivá",
"SubtitleLightGray": "Svetlo šedá",
"SubtitleRed": "Červená",
"SubtitleYellow": "Žltá",
"SubtitleWhite": "Biela",
"Select": "Vybrať",
"EnableAudioNormalization": "Normalizácia hlasitosti",
"EnableAudioNormalization": "Normalizácia zvuku",
"GetThePlugin": "Získať zásuvný modul",
"LabelParallelImageEncodingLimit": "Limit paralelného kódovania obrázkov",
"Notifications": "Upozornenia",
"NotificationsMovedMessage": "Funkcia upozornení bola presunutá do zásuvného modulu Webhook.",
"PreferEmbeddedExtrasTitlesOverFileNames": "Preferovať vložené názvy pred názvami súborov pre doplnky",
"PreferEmbeddedExtrasTitlesOverFileNamesHelp": "Doplnky väčšinou majú totožní vložení názov ako nadriadená položka. Zaškrtnutím ich môžete napriek uprednostniť.",
"SubtitleMagenta": "Fialová",
"LabelEnableAudioVbrHelp": "Premenlivý bitový tok ponúka lepší pomer medzi kvalitou a priemerným bitovým tokom, ale niekdy môže spôsobiť dodatočné načítavanie alebo problémy s kompatibilitou.",
"PreferEmbeddedExtrasTitlesOverFileNames": "Preferovať vložené názvy pred názvami súborov pre bonusové materiály",
"PreferEmbeddedExtrasTitlesOverFileNamesHelp": "Bonusové materiály majú často rovnaký vložený názov ako nadradená položka, zaškrtnite túto možnosť, aby ste aj tak použili vložené názvy.",
"SubtitleMagenta": "Magenta",
"LabelEnableAudioVbrHelp": "Variabilný dátový tok ponúka lepší pomer kvality a priemerného dátového toku, ale v niektorých zriedkavých prípadoch môže spôsobiť problémy s vyrovnávacou pamäťou a kompatibilitou.",
"MenuClose": "Zatvoriť ponuku",
"UserMenu": "Užívateľská ponuka",
"UserMenu": "Používateľská ponuka",
"LabelEnableLUFSScan": "Povoliť skenovanie LUFS",
"LabelEnableLUFSScanHelp": "Povoliť skenovanie LUFS pre hudbu (Predlžuje skenovanie a je náročnejšie na výkon).",
"LabelEnableLUFSScanHelp": "Klienti môžu normalizovať prehrávanie zvuku, aby sa dosiahla rovnaká hlasitosť všetkých stôp. (Predlžuje prehľadávanie knižnice a je náročnejšie na výkon).",
"MenuOpen": "Otvoriť ponuku",
"AllowSegmentDeletion": "Zmazať oddiel"
"AllowSegmentDeletion": "Zmazať segmenty",
"LogoScreensaver": "Šetrič obrazovky s logom",
"AllowAv1Encoding": "Povoliť kódovanie do formátu AV1",
"BackdropScreensaver": "Šetrič obrazovky s pozadím",
"PleaseConfirmRepositoryInstallation": "Kliknutím na tlačidlo OK potvrďte, že ste si prečítali vyššie uvedené informácie a chcete pokračovať v inštalácii repozitára zásuvných modulov.",
"UnknownError": "Došlo k neznámej chybe.",
"LabelIsHearingImpaired": "Titulky pre nepočujúcich",
"LabelSyncPlayNoGroups": "Nie sú k dispozícii žiadne skupiny",
"HeaderGuestCast": "Hosťujúce hviezdy",
"LabelDate": "Dátum",
"LabelLevel": "Úroveň",
"MessageRepositoryInstallDisclaimer": "UPOZORNENIE: Inštalácia repozitárov zásuvných modulov tretích strán so sebou prináša určité riziká. Môžu obsahovať nestabilný alebo škodlivý kód a môžu sa kedykoľvek zmeniť. Inštalujte len repozitáre od autorov, ktorým dôverujete.",
"HeaderEpisodesStatus": "Stav epizód",
"LabelSystem": "Systém",
"LogLevel.Trace": "Stopa",
"LogLevel.Debug": "Debug",
"GoHome": "Prejsť na domovskú obrazovku",
"LabelBackdropScreensaverInterval": "Interval šetriča obrazovky s pozadím",
"LabelBackdropScreensaverIntervalHelp": "Čas v sekundách medzi rôznymi pozadiami pri použití šetriča obrazovky s pozadím.",
"LogLevel.Information": "Informácia",
"LabelDeveloper": "Vývojár",
"LabelMediaDetails": "Podrobnosti o médiách",
"GridView": "Zobrazenie v mriežke",
"ListView": "Zobrazenie v zozname",
"LogLevel.Warning": "Upozornenie",
"LogLevel.Error": "Chyba",
"LogLevel.Critical": "Kritická",
"LogLevel.None": "Žiadny",
"EnableAudioNormalizationHelp": "Normalizácia zvuku pridá konštantné zosilnenie, aby sa priemer hlasitosti udržal na požadovanej úrovni (-18 dB).",
"HeaderConfirmRepositoryInstallation": "Potvrdiť inštaláciu repozitára zásuvných modulov",
"Unknown": "Neznámy",
"AiTranslated": "Preložené pomocou AI",
"MachineTranslated": "Strojovo preložené",
"ForeignPartsOnly": "Iba vynútené",
"HearingImpairedShort": "Titulky pre nepočujúcich",
"LabelThrottleDelaySeconds": "Obmedziť po",
"LabelSegmentKeepSeconds": "Doba ponechania segmentov",
"LabelThrottleDelaySecondsHelp": "Čas v sekundách, po ktorom bude prekódovanie obmedzené. Musí byť dostatočne veľký, aby mal klient v rezerve dostatočné množstvo prehrávaného súboru. Funguje len vtedy, ak je povolená funkcia Obmedziť prekódovanie.",
"AllowSegmentDeletionHelp": "Odstránenie starých segmentov po ich odoslaní klientovi. Tým sa zabráni tomu, aby sa celý prekódovaný súbor musel ukladať na disk. Funguje len so zapnutou funkciou Obmedziť prekódovanie. Ak sa vyskytnú problémy s prehrávaním, vypnite túto funkciu.",
"LabelSegmentKeepSecondsHelp": "Čas v sekundách, počas ktorého budú segmenty uložené. Musí byť dlhší ako je čas určený v \"Obmedziť po\". Funguje len vtedy, ak je povolená funkcia Zmazania segmentov."
}

View File

@ -648,7 +648,7 @@
"MessageNoMovieSuggestionsAvailable": "Trenutno ni na voljo nobenih predlogov za filme. Začnite gledati in ocenjevati vaše filme, ter se nato vrnite sem in si oglejte predloge.",
"LabelSelectFolderGroups": "Samodejno združi vsebine iz spodnjih map v poglede kot so 'Filmi', 'Glasba' in 'TV'",
"TitlePlayback": "Predvajanje",
"MessagePasswordResetForUsers": "Gesla naslednjih uporabnikov so bila ponastavljena. Zdaj se lahko prijavijo z Enostavnimi PIN kodami, ki so bile uporabljene za ponastavitev.",
"MessagePasswordResetForUsers": "Gesla naslednjih uporabnikov so bila ponastavljena. Zdaj se lahko prijavijo s PIN kodami, ki so bile uporabljene za ponastavitev.",
"OptionHideUserFromLoginHelp": "Koristno za zasebne ali skrite skrbniške račune. Uporabnik se bo moral prijaviti ročno z vpisom svojega uporabniškega imena in gesla.",
"OnlyForcedSubtitlesHelp": "Naložijo se zgolj podnapisi, ki so označeni kot prisiljeni.",
"OptionEnableExternalContentInSuggestionsHelp": "Dovoli, da so spletni napovedniki in TV kanali v živo vključeni med priporočenimi vsebinami.",
@ -810,7 +810,7 @@
"OptionAutomaticallyGroupSeries": "Samodejno združi serije, ki so razdeljene po več mapah",
"OptionAllowUserToManageServer": "Dovoli temu uporabniku upravljanje strežnika",
"MessageTheFollowingLocationWillBeRemovedFromLibrary": "Naslednja mesta predstavnosti bodo odstranjena iz vaše knjižnice",
"MessagePluginInstallDisclaimer": "Dodatki ustvarjeni s strani članov skupnosti so odličen način za izboljšanje vaše izkušnje z dodatnimi funkcijami in prednostmi. Preden namestite dodatke se zavedajte, da imajo lahko ti različne vplive na vaš strežnik, kot na primer počasnejše preiskovanje knjižnic, dodatna obdelava podatkov v ozadju in zmanjšana stabilnost sistema.",
"MessagePluginInstallDisclaimer": "POZOR: Nameščanje dodatkov tretjih oseb predstavlja tveganje. Vsebujejo lahko nestabilno ali zlonamerno kodo, ki se lahko kadarkoli spremeni. Namestite zgolj dodatke avtorjev ki jim zaupate. Zavedajte se morebitnih stranskih učinkov, kot na primer komunikacija z zunanjimi storitvami, podaljšan čas pregledovanja knjižnice in dodatno procesiranje v ozadju.",
"MessagePleaseWait": "Prosimo, počakajte. To lahko traja nekaj minut.",
"MessagePleaseEnsureInternetMetadata": "Prosimo poskrbite, da je prenašanje spletnih metapodatkov omogočeno.",
"MessageNothingHere": "Tu ni ničesar.",
@ -1308,7 +1308,7 @@
"QuickConnectDescription": "Za vpis s Hitro povezavo izberi 'Hitra povezava' na napravi preko katere se vpisuješ in vnesi kodo.",
"QuickConnectDeactivated": "Hitra povezava je bila onemogočena pred dokončanjem vpisa",
"QuickConnectAuthorizeFail": "Neznana koda za Hitro povezavo",
"QuickConnectAuthorizeSuccess": "Avtorizirano",
"QuickConnectAuthorizeSuccess": "Uspešno ste overili svojo napravo!",
"QuickConnectAuthorizeCode": "Vnesi kodo {0} za vpis",
"QuickConnectActivationSuccessful": "Aktivirano",
"QuickConnect": "Hitra povezava",
@ -1402,7 +1402,7 @@
"LabelTonemappingAlgorithm": "Izberite algoritem za preslikavo barv",
"LabelOpenclDeviceHelp": "To je naprava OpenCL, ki bo uporabljena za preslikavo barv. Na levi strani pike je številka platforme, desno je številka naprave na tej platformi. Privzeta vrednost je 0.0. Zahtevana je datoteka FFmpeg, ki vsebuje metodo strojnega pospeševanja OpenCL.",
"LabelColorPrimaries": "Barvni prostor",
"AllowTonemappingHelp": "Preslikava barv lahko preslika dinamični razpon videa HDR v SDR, pri tem pa ohranja podrobnosti in barve, kar je zelo pomembno za predstavitev izvorne scene. Trenutno deluje zgolj z HDR10 in HLG videi. Zahteva ustrezne OpenCL ali CUDA knjižnice.",
"AllowTonemappingHelp": "Preslikava barv lahko preslika dinamični razpon videa iz HDR v SDR, pri tem pa ohranja podrobnosti in barve, kar je zelo pomembno za predstavitev izvorne scene. Trenutno deluje zgolj z HDR10 in HLG videi. Zahteva ustrezne OpenCL ali CUDA knjižnice.",
"MediaInfoVideoRange": "Barvni razpon",
"LabelVideoRange": "Barvni razpon",
"LabelSonyAggregationFlags": "Sonyjeve agregacijske oznake",
@ -1647,10 +1647,10 @@
"LabelUserMaxActiveSessions": "Največje število hkratnih uporabniških sej",
"LabelUDPPortRangeHelp": "Omeji Jellyfin na ta razpon vrat za UDP komunikacije. (Privzeto 1024 - 645535).<br/> Opomba: Nekatere funkcije zahtevajo fiksna vrata, ki so lahko izven tega razpona.",
"LabelUDPPortRange": "Razpon komunikacij UDP",
"LabelVppTonemappingContrastHelp": "Uporabi povečanje kontrasta pri VPP preslikavi barv. Priporočena in privzeta vrednost je 0.",
"LabelVppTonemappingContrastHelp": "Uporabi povečanje kontrasta pri VPP preslikavi barv. Priporočeni in privzeti vrednosti sta 1.",
"LabelVppTonemappingBrightness": "VPP preslikava barv povečanje svetlosti",
"LabelVppTonemappingContrast": "VPP preslikava barv povečanje kontrasta",
"LabelVppTonemappingBrightnessHelp": "Uporabi povečanje svetlosti pri VPP preslikavi barv. Priporočena in privzeta vrednost je 0.",
"LabelVppTonemappingBrightnessHelp": "Uporabi povečanje svetlosti pri VPP preslikavi barv. Priporočeni in privzeti vrednosti sta 16 in 0.",
"AllowVppTonemappingHelp": "Preslikava barv v celoti na podlagi gonilnikov Intel. Trenutno deluje zgolj na določeni strojni opremi z HDR10 videi. Ima prednost pred drugimi OpenCL implementacijami.",
"TonemappingAlgorithmHelp": "Preslikavo barv lahko podrobno nastavite. Če teh možnosti ne poznate, pustite privzete vrednosti. Priporočena vrednost je 'BT.2390'.",
"LabelTonemappingThresholdHelp": "Parametri preslikave barv so natančno nastavljeni za vsak prizor. Prag je uporabljen za zaznavanje ali se je prizor spremenil ali ne. Če je razlika med povprečno svetlostjo trenutne sličice in tekočim povprečjem večja od nastavljenega praga, se vrednosti za povprečno in najvišjo svetlost prizora znova izračunajo. Priporočena in privzeta vrednost je 0,8 in 0,2.",
@ -1693,11 +1693,11 @@
"PreferEmbeddedExtrasTitlesOverFileNames": "Raje uporabi vdelane naslove kot imena datotek",
"SaveRecordingNFOHelp": "Shrani metapodatke v isto mapo.",
"LabelDummyChapterDuration": "interval",
"LabelDummyChapterDurationHelp": "Interval ekstrakcije slike poglavja v sekundah.",
"LabelDummyChapterDurationHelp": "Interval med navideznimi poglavji. Nastavite na 0 za onemogočanje ustvarjanja navideznih poglavij. Sprememba ne bo imela vpliva na obstoječa navidezna poglavja.",
"LabelDummyChapterCount": "Limit",
"LabelDummyChapterCountHelp": "Največje število slik poglavij, ki bodo ekstrahirane za vsako medijsko datoteko.",
"LabelChapterImageResolution": "Resolucija",
"LabelChapterImageResolutionHelp": "Ločljivost slik poglavij.",
"LabelChapterImageResolutionHelp": "Ločljivost slik poglavij. Spreminjanje ne bo vplivalo na obstoječa lažna poglavja.",
"ResolutionMatchSource": "ujemanje z virom",
"HeaderRecordingMetadataSaving": "Snemanje metapodatkov",
"AllowCollectionManagement": "Dovoli uporabniku upravljanje zbirk",
@ -1714,5 +1714,61 @@
"LabelSegmentKeepSeconds": "Čas ohranitve segmentov",
"AllowSegmentDeletionHelp": "Izbriši segmente, ki so če bili poslani odjemalcu. S tem se prepreči, da bi na disku bila shranjena celotna prekodirana datoteka. To deluje le, če je omogočeno zaviranje prekodiranja. Če se pojavijo težave pri predvajanju, onemogočite to možnost.",
"LabelThrottleDelaySecondsHelp": "Čas v sekundah, po katerem se bo prekodirnik upočasnil. Mora biti dovolj, da odjemalec ohranja ustrezen medpomnilnik. Deluje le, če je zaviranje prekodiranja omogočeno.",
"LabelSegmentKeepSecondsHelp": "Čas v skundah, ko naj se segmenti ohranijo, preden se jih prepiše. Čas mora biti večji od \"Zaviraj po\". Deluje le, če je brisanje segmentov omogočeno."
"LabelSegmentKeepSecondsHelp": "Čas v skundah, ko naj se segmenti ohranijo, preden se jih prepiše. Čas mora biti večji od \"Zaviraj po\". Deluje le, če je brisanje segmentov omogočeno.",
"MessageRepositoryInstallDisclaimer": "POZOR: Nameščanje skladišča dodatkov tretjih oseb predstavlja tveganje. Lahko vsebuje nestabilno ali zlonamerno kodo, ki se lahko kadarkoli spremeni. Namestite zgolj skladišča avtorjev ki jim zaupate.",
"Studio": "Studio",
"TonemappingModeHelp": "Izberi način preslikave barv. Če opazite razbarvanje svetlih delov slike, poskusite uporabiti način RGB.",
"LabelIsHearingImpaired": "Za naglušne (SDH)",
"AllowAv1Encoding": "Dovoli kodiranje v AV1 format",
"BackdropScreensaver": "Ozadje ohranjevalnika zaslona",
"LogoScreensaver": "Ohranjevalnik zaslona z logotipom",
"Short": "Kratki film",
"PasswordRequiredForAdmin": "Administratorski računi zahtevajo geslo.",
"SaveRecordingImages": "Shrani posnetke slik EPG",
"SubtitleBlack": "Črna",
"SubtitleRed": "Rdeča",
"LabelSyncPlayNoGroups": "Nobena skupina ni na voljo",
"NotificationsMovedMessage": "Funkcionalnost obvestil se je premaknila v dodatek Webhook.",
"SecondarySubtitles": "Sekundarni podnapisi",
"LabelDeveloper": "Razvijalec",
"LabelEnableAudioVbrHelp": "Spremenljiva bitna hitrost omogoča boljše razmerje med kvaliteto zvoka in povprečno bitno hitrostjo vendar lahko povzroči težave z medpomnjenjem in kompatibilnostjo.",
"LogLevel.None": "Brez",
"ForeignPartsOnly": "Samo vsiljeni/tuji deli",
"MachineTranslated": "Strojno prevedeno",
"LabelDate": "Datum",
"LabelEnableLUFSScan": "Omogoči skeniranje LUFS",
"LabelEnableLUFSScanHelp": "Odjemalci lahko normalizirajo glasnost zvoka za enako glasnost med skladbami. Pregledovanje knjižnice bo počasnejše in bo porabilo več sistemskih virov.",
"LabelLevel": "Nivo",
"LabelSystem": "Sistem",
"LogLevel.Trace": "",
"LogLevel.Warning": "Opozorilo",
"LogLevel.Information": "Informacije",
"Select": "Izberi",
"LogLevel.Error": "Napaka",
"LogLevel.Critical": "Kritično",
"GoHome": "Domov",
"LabelMediaDetails": "Podrobnosti predstavnosti",
"SubtitleWhite": "Bela",
"UnknownError": "Prišlo je do neznane napake.",
"Unknown": "Neznano",
"LabelBackdropScreensaverInterval": "Interval ozadja ohranjevalnika zaslona",
"LabelBackdropScreensaverIntervalHelp": "Čas v sekundah med različnimi ozadji za ohranjevalnik zaslona.",
"Notifications": "Obvestila",
"LabelEnableAudioVbr": "Omogoči VBR kodiranje zvoka",
"ListView": "Pogled seznama",
"PleaseConfirmRepositoryInstallation": "S klikom na OK potrjujete, da ste prebrali zgornje opozorilo in želite nadaljevati z namestitvijo skladišča dodatkov.",
"MenuOpen": "Odpri meni",
"MenuClose": "Zapri meni",
"UserMenu": "Uporabniški meni",
"LabelTonemappingMode": "Način preslikave barv",
"LabelParallelImageEncodingLimit": "Omejitev vzporednega kodiranja slik",
"SubtitleYellow": "Rumena",
"SubtitleBlue": "Modra",
"SubtitleCyan": "Cian",
"SubtitleGray": "Siva",
"SubtitleGreen": "Zelena",
"SubtitleLightGray": "Svetlo siva",
"SubtitleMagenta": "Magenta",
"LabelParallelImageEncodingLimitHelp": "Največje dovoljeno število vzporednih kodiranj slik. Nastavite na 0 za samodejno omejitev glede na zmogljivost vašega sistema.",
"AiTranslated": "AI prevedeno"
}

View File

@ -1,6 +1,6 @@
{
"Add": "Thêm",
"All": "Tất cả",
"All": "Tất cả",
"MessageBrowsePluginCatalog": "Duyệt danh mục plugin của chúng tôi để xem các plugin có sẵn.",
"ButtonAddUser": "Thêm Người Dùng",
"ButtonCancel": "Hủy bỏ",

View File

@ -1353,7 +1353,7 @@
"LabelOpenclDevice": "OpenCL 设备",
"LabelOpenclDeviceHelp": "这是用于色调映射的 OpenCL 设备。 点左边是平台号,右边是平台上的设备号。 默认值为 0.0。 需要支持OpenCL 硬件加速的 FFmpeg 应用程序。",
"EnableTonemapping": "启用色调映射",
"AllowTonemappingHelp": "色调映射可以将视频的动态范围从HDR转换为SDR同时保持图像细节和颜色这些都是表示原始场景的非常重要的信息。目前只适用于10bit HDR10、HLG和DoVi视频。这需要相应的OpenCL或CUDA运行库。",
"AllowTonemappingHelp": "色调映射可以将视频的动态范围从 HDR 变换成 SDR同时保持图像细节与颜色等对于表现原始场景非常重要的信息。目前仅对 10bit HDR10HLG 和 DoVi 视频生效。此项需要对应的 OpenCL 或 CUDA 运行库。",
"LabelTonemappingAlgorithm": "选择要使用的色调映射算法",
"TonemappingAlgorithmHelp": "色调映射可以微调。如果你不是很熟悉这些选项,保持默认即可。建议值为 'BT.2390'。",
"LabelTonemappingRange": "色调映射范围",
@ -1764,7 +1764,7 @@
"HeaderEpisodesStatus": "剧集状态",
"LabelBackdropScreensaverInterval": "屏幕保护程序间隔",
"LabelBackdropScreensaverIntervalHelp": "不同屏幕保护切换的时间间隔秒数。",
"AllowAv1Encoding": "允许以AV1格式进行编码",
"AllowAv1Encoding": "允许以 AV1 格式进行编码",
"HeaderGuestCast": "特邀嘉宾",
"GridView": "网格视图",
"ListView": "列表视图",
@ -1773,5 +1773,8 @@
"ForeignPartsOnly": "仅限强制开启/外语部分",
"HearingImpairedShort": "听障/聋哑人士字幕",
"UnknownError": "发生未知错误。",
"GoHome": "回家"
"GoHome": "回家",
"BackdropScreensaver": "背景屏保",
"LogoScreensaver": "徽标屏保",
"LabelIsHearingImpaired": "用于听障/聋哑人士"
}

View File

@ -822,7 +822,7 @@
"LabelFormat": "格式",
"LabelFriendlyName": "好聽的名字",
"LabelGroupMoviesIntoCollections": "將電影分組",
"LabelKodiMetadataDateFormat": "釋出日期格式",
"LabelKodiMetadataDateFormat": "發行日期格式",
"LabelIconMaxWidth": "Icon 最寬寬度",
"LabelGroupMoviesIntoCollectionsHelp": "選擇檢視電影清單時,集合中的電影將作為一個分組項目顯示。",
"LabelEncoderPreset": "預設編碼",

View File

@ -1,4 +1,5 @@
import type { BaseItemDto } from '@jellyfin/sdk/lib/generated-client';
import { CollectionType } from './collectionType';
export interface CardOptions {
itemsContainer?: HTMLElement | null;
@ -32,7 +33,7 @@ export interface CardOptions {
showUnplayedIndicator?: boolean;
showChildCountIndicator?: boolean;
lines?: number;
context?: string | null;
context?: CollectionType;
action?: string | null;
defaultShape?: string;
indexBy?: string;

View File

@ -0,0 +1,27 @@
// NOTE: This should be included in the OpenAPI spec ideally
// https://github.com/jellyfin/jellyfin/blob/1b4394199a2f9883cd601bdb8c9d66015397aa52/Jellyfin.Data/Enums/HomeSectionType.cs
export enum HomeSectionType {
None = 'none',
SmallLibraryTiles = 'smalllibrarytiles',
LibraryButtons = 'librarybuttons',
ActiveRecordings = 'activerecordings',
Resume = 'resume',
ResumeAudio = 'resumeaudio',
LatestMedia = 'latestmedia',
NextUp = 'nextup',
LiveTv = 'livetv',
ResumeBook = 'resumebook'
}
// NOTE: This needs to match the server defaults
// https://github.com/jellyfin/jellyfin/blob/1b4394199a2f9883cd601bdb8c9d66015397aa52/Jellyfin.Api/Controllers/DisplayPreferencesController.cs#L120
export const DEFAULT_SECTIONS: HomeSectionType[] = [
HomeSectionType.SmallLibraryTiles,
HomeSectionType.Resume,
HomeSectionType.ResumeAudio,
HomeSectionType.ResumeBook,
HomeSectionType.LiveTv,
HomeSectionType.NextUp,
HomeSectionType.LatestMedia,
HomeSectionType.None
];

View File

@ -1,29 +0,0 @@
export interface ViewQuerySettings {
showTitle?: boolean;
showYear?: boolean;
imageType?: string;
viewType?: string;
cardLayout?: boolean;
SortBy?: string | null;
SortOrder?: string | null;
IsPlayed?: boolean | null;
IsUnplayed?: boolean | null;
IsFavorite?: boolean | null;
IsResumable?: boolean | null;
Is4K?: boolean | null;
IsHD?: boolean | null;
IsSD?: boolean | null;
Is3D?: boolean | null;
VideoTypes?: string | null;
SeriesStatus?: string | null;
HasSubtitles?: boolean | null;
HasTrailer?: boolean | null;
HasSpecialFeature?: boolean | null;
ParentIndexNumber?: boolean | null;
HasThemeSong?: boolean | null;
HasThemeVideo?: boolean | null;
GenreIds?: string | null;
NameLessThan?: string | null;
NameStartsWith?: string | null;
StartIndex?: number;
}

View File

@ -8,7 +8,7 @@ import { ItemSortBy } from '@jellyfin/sdk/lib/models/api/item-sort-by';
export type ParentId = string | null | undefined;
export interface LibraryViewProps {
parentId: string | null;
parentId: ParentId;
}
export enum FeatureFilters {

View File

@ -144,12 +144,12 @@ export const getSettingsKey = (viewType: LibraryTab, parentId: ParentId) => {
return `${viewType} - ${parentId}`;
};
export const getDefaultLibraryViewSettings = (): LibraryViewSettings => {
export const getDefaultLibraryViewSettings = (viewType: LibraryTab): LibraryViewSettings => {
return {
ShowTitle: true,
ShowYear: false,
ViewMode: ViewMode.GridView,
ImageType: ImageType.Primary,
ViewMode: viewType === LibraryTab.Songs ? ViewMode.ListView : ViewMode.GridView,
ImageType: viewType === LibraryTab.Networks ? ImageType.Thumb : ImageType.Primary,
CardLayout: false,
SortBy: ItemSortBy.SortName,
SortOrder: SortOrder.Ascending,