mirror of
https://github.com/jellyfin/jellyfin-web
synced 2025-03-30 19:56:21 +00:00
Merge branch 'master' into auto-set-tracks
This commit is contained in:
commit
af2b05044c
200 changed files with 14959 additions and 6532 deletions
41
.eslintrc.js
41
.eslintrc.js
|
@ -4,6 +4,7 @@ module.exports = {
|
|||
root: true,
|
||||
plugins: [
|
||||
'@babel',
|
||||
'react',
|
||||
'promise',
|
||||
'import',
|
||||
'eslint-comments'
|
||||
|
@ -18,11 +19,13 @@ module.exports = {
|
|||
ecmaVersion: 2020,
|
||||
sourceType: 'module',
|
||||
ecmaFeatures: {
|
||||
impliedStrict: true
|
||||
impliedStrict: true,
|
||||
jsx: true
|
||||
}
|
||||
},
|
||||
extends: [
|
||||
'eslint:recommended',
|
||||
'plugin:react/recommended',
|
||||
// 'plugin:promise/recommended',
|
||||
'plugin:import/errors',
|
||||
'plugin:eslint-comments/recommended',
|
||||
|
@ -36,6 +39,7 @@ module.exports = {
|
|||
'comma-spacing': ['error'],
|
||||
'eol-last': ['error'],
|
||||
'indent': ['error', 4, { 'SwitchCase': 1 }],
|
||||
'jsx-quotes': ['error', 'prefer-single'],
|
||||
'keyword-spacing': ['error'],
|
||||
'max-statements-per-line': ['error'],
|
||||
'no-floating-decimal': ['error'],
|
||||
|
@ -54,10 +58,25 @@ module.exports = {
|
|||
'space-infix-ops': 'error',
|
||||
'yoda': 'error'
|
||||
},
|
||||
settings: {
|
||||
react: {
|
||||
version: 'detect'
|
||||
},
|
||||
'import/extensions': [
|
||||
'.js',
|
||||
'.ts',
|
||||
'.jsx',
|
||||
'.tsx'
|
||||
],
|
||||
'import/parsers': {
|
||||
'@typescript-eslint/parser': [ '.ts', '.tsx' ]
|
||||
}
|
||||
},
|
||||
overrides: [
|
||||
{
|
||||
files: [
|
||||
'./src/**/*.js'
|
||||
'./src/**/*.js',
|
||||
'./src/**/*.ts'
|
||||
],
|
||||
parser: '@babel/eslint-parser',
|
||||
env: {
|
||||
|
@ -93,6 +112,7 @@ module.exports = {
|
|||
'LibraryMenu': 'writable',
|
||||
'LinkParser': 'writable',
|
||||
'LiveTvHelpers': 'writable',
|
||||
'Loading': 'writable',
|
||||
'MetadataEditor': 'writable',
|
||||
'PlaylistViewer': 'writable',
|
||||
'UserParentalControlPage': 'writable',
|
||||
|
@ -187,6 +207,23 @@ module.exports = {
|
|||
'document.querySelector'
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
files: [
|
||||
'./src/**/*.ts',
|
||||
'./src/**/*.tsx'
|
||||
],
|
||||
parser: '@typescript-eslint/parser',
|
||||
plugins: ['@typescript-eslint'],
|
||||
extends: [
|
||||
'eslint:recommended',
|
||||
'plugin:import/typescript',
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
'plugin:eslint-comments/recommended',
|
||||
'plugin:react/recommended',
|
||||
'plugin:react-hooks/recommended',
|
||||
'plugin:jsx-a11y/recommended'
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
4
.github/SUPPORT.md
vendored
4
.github/SUPPORT.md
vendored
|
@ -13,8 +13,8 @@ question in these venues:
|
|||
If you didn't find an answer in the resources above, contributors and other
|
||||
users are reachable through the following channels:
|
||||
|
||||
* #jellyfin on [Matrix](https://matrix.to/#/#jellyfin:matrix.org%22) or [IRC](https://webchat.freenode.net/#jellyfin)
|
||||
* #jellyfin-troubleshooting on [Matrix](https://matrix.to/#/#jellyfin-troubleshooting:matrix.org) or [IRC](https://webchat.freenode.net/#jellyfin-troubleshooting)
|
||||
* #jellyfin on [Matrix](https://matrix.to/#/#jellyfin:matrix.org%22) or [IRC](ircs://irc.libera.chat:6697/#jellyfin)
|
||||
* #jellyfin-troubleshooting on [Matrix](https://matrix.to/#/#jellyfin-troubleshooting:matrix.org) or [IRC](ircs://irc.libera.chat:6697/#jellyfin-troubleshooting)
|
||||
* [/r/jellyfin on Reddit](https://www.reddit.com/r/jellyfin)
|
||||
|
||||
GitHub issues are for tracking enhancements and bugs, not general support.
|
||||
|
|
6
.github/dependabot.yaml
vendored
6
.github/dependabot.yaml
vendored
|
@ -3,11 +3,11 @@ updates:
|
|||
- package-ecosystem: npm
|
||||
directory: /
|
||||
schedule:
|
||||
interval: monthly
|
||||
interval: weekly
|
||||
open-pull-requests-limit: 10
|
||||
|
||||
- package-ecosystem: github-actions
|
||||
directory: '/'
|
||||
directory: /
|
||||
schedule:
|
||||
interval: monthly
|
||||
interval: weekly
|
||||
open-pull-requests-limit: 10
|
||||
|
|
17
.github/stale.yml
vendored
17
.github/stale.yml
vendored
|
@ -1,10 +1,12 @@
|
|||
# Number of days of inactivity before an issue becomes stale
|
||||
daysUntilStale: 90
|
||||
daysUntilStale: 120
|
||||
# Number of days of inactivity before a stale issue is closed
|
||||
daysUntilClose: 14
|
||||
daysUntilClose: 21
|
||||
# Issues with these labels will never be considered stale
|
||||
exemptLabels:
|
||||
- regression
|
||||
- security
|
||||
- roadmap
|
||||
- future
|
||||
- feature
|
||||
- enhancement
|
||||
|
@ -13,8 +15,13 @@ exemptLabels:
|
|||
staleLabel: stale
|
||||
# Comment to post when marking an issue as stale. Set to `false` to disable
|
||||
markComment: >
|
||||
Issues go stale after 90d of inactivity. Mark the issue as fresh by adding a comment or commit. Stale issues close after an additional 14d of inactivity.
|
||||
If this issue is safe to close now please do so.
|
||||
If you have any questions you can reach us on [Matrix or Social Media](https://docs.jellyfin.org/general/getting-help.html).
|
||||
This issue has gone 120 days without comment. To avoid abandoned issues, it will be closed in 21 days if there are no new comments.
|
||||
|
||||
If you're the original submitter of this issue, please comment confirming if this issue still affects you in the latest release or nightlies, or close the issue if it has been fixed. If you're another user also affected by this bug, please comment confirming so. Either action will remove the stale label.
|
||||
This bot exists to prevent issues from becoming stale and forgotten. Jellyfin is always moving forward, and bugs are often fixed as side effects of other changes. We therefore ask that bug report authors remain vigilant about their issues to ensure they are closed if fixed, or re-confirmed - perhaps with fresh logs or reproduction examples - regularly. If you have any questions you can reach us on [Matrix or Social Media](https://docs.jellyfin.org/general/getting-help.html).
|
||||
# Comment to post when closing a stale issue. Set to `false` to disable
|
||||
closeComment: false
|
||||
|
||||
# Disable automatic closing of pull requests
|
||||
pulls:
|
||||
daysUntilClose: false
|
||||
|
|
20
.github/workflows/automation.yml
vendored
Normal file
20
.github/workflows/automation.yml
vendored
Normal file
|
@ -0,0 +1,20 @@
|
|||
name: 'Automation'
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
pull_request_target:
|
||||
types:
|
||||
- synchronize
|
||||
|
||||
jobs:
|
||||
triage:
|
||||
name: 'Merge conflict labeling'
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.repository == 'jellyfin/jellyfin-web' }}
|
||||
steps:
|
||||
- uses: eps1lon/actions-label-merge-conflict@v2.0.1
|
||||
with:
|
||||
dirtyLabel: 'merge conflict'
|
||||
repoToken: ${{ secrets.JF_BOT_TOKEN }}
|
28
.github/workflows/commands.yml
vendored
Normal file
28
.github/workflows/commands.yml
vendored
Normal file
|
@ -0,0 +1,28 @@
|
|||
name: Commands
|
||||
on:
|
||||
issue_comment:
|
||||
types:
|
||||
- created
|
||||
- edited
|
||||
|
||||
jobs:
|
||||
rebase:
|
||||
name: Rebase
|
||||
if: github.event.issue.pull_request != '' && contains(github.event.comment.body, '@jellyfin-bot rebase') && github.event.comment.author_association == 'MEMBER'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Notify as seen
|
||||
uses: peter-evans/create-or-update-comment@v1.4.5
|
||||
with:
|
||||
token: ${{ secrets.JF_BOT_TOKEN }}
|
||||
comment-id: ${{ github.event.comment.id }}
|
||||
reactions: '+1'
|
||||
- name: Checkout the latest code
|
||||
uses: actions/checkout@v2.3.4
|
||||
with:
|
||||
token: ${{ secrets.JF_BOT_TOKEN }}
|
||||
fetch-depth: 0
|
||||
- name: Automatic Rebase
|
||||
uses: cirrus-actions/rebase@1.5
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.JF_BOT_TOKEN }}
|
12
.github/workflows/lint.yml
vendored
12
.github/workflows/lint.yml
vendored
|
@ -16,7 +16,7 @@ jobs:
|
|||
uses: actions/checkout@v2
|
||||
|
||||
- name: Setup node environment
|
||||
uses: actions/setup-node@v2.1.5
|
||||
uses: actions/setup-node@v2.4.0
|
||||
with:
|
||||
node-version: 12
|
||||
check-latest: true
|
||||
|
@ -26,7 +26,7 @@ jobs:
|
|||
run: echo "::set-output name=dir::$(npm config get cache)"
|
||||
|
||||
- name: Cache node_modules
|
||||
uses: actions/cache@v2.1.4
|
||||
uses: actions/cache@v2.1.6
|
||||
id: npm-cache
|
||||
with:
|
||||
path: ${{ steps.npm-cache-dir-path.outputs.dir }}
|
||||
|
@ -51,7 +51,7 @@ jobs:
|
|||
uses: actions/checkout@v2
|
||||
|
||||
- name: Setup node environment
|
||||
uses: actions/setup-node@v2.1.5
|
||||
uses: actions/setup-node@v2.4.0
|
||||
with:
|
||||
node-version: 12
|
||||
check-latest: true
|
||||
|
@ -61,7 +61,7 @@ jobs:
|
|||
run: echo "::set-output name=dir::$(npm config get cache)"
|
||||
|
||||
- name: Cache node_modules
|
||||
uses: actions/cache@v2.1.4
|
||||
uses: actions/cache@v2.1.6
|
||||
id: npm-cache
|
||||
with:
|
||||
path: ${{ steps.npm-cache-dir-path.outputs.dir }}
|
||||
|
@ -89,7 +89,7 @@ jobs:
|
|||
uses: actions/checkout@v2
|
||||
|
||||
- name: Setup node environment
|
||||
uses: actions/setup-node@v2.1.5
|
||||
uses: actions/setup-node@v2.4.0
|
||||
with:
|
||||
node-version: 12
|
||||
check-latest: true
|
||||
|
@ -99,7 +99,7 @@ jobs:
|
|||
run: echo "::set-output name=dir::$(npm config get cache)"
|
||||
|
||||
- name: Cache node_modules
|
||||
uses: actions/cache@v2.1.4
|
||||
uses: actions/cache@v2.1.6
|
||||
id: npm-cache
|
||||
with:
|
||||
path: ${{ steps.npm-cache-dir-path.outputs.dir }}
|
||||
|
|
18
.github/workflows/merge-conflicts.yml
vendored
18
.github/workflows/merge-conflicts.yml
vendored
|
@ -1,18 +0,0 @@
|
|||
name: "Merge Conflicts"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
pull_request:
|
||||
types:
|
||||
- synchronize
|
||||
jobs:
|
||||
triage:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.repository == 'jellyfin/jellyfin-web'
|
||||
steps:
|
||||
- uses: mschilde/auto-label-merge-conflicts@master
|
||||
with:
|
||||
CONFLICT_LABEL_NAME: "merge conflict"
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
@ -45,6 +45,9 @@
|
|||
- [Camc314](https://github.com/camc314)
|
||||
- [danieladov](https://github.com/danieladov)
|
||||
- [Stephane Senart](https://github.com/ssenart)
|
||||
- [Ömer Erdinç Yağmurlu](https://github.com/omeryagmurlu)
|
||||
- [Keegan Dahm](https://github.com/keegandahm)
|
||||
- [GodTamIt](https://github.com/GodTamIt)
|
||||
|
||||
# Emby Contributors
|
||||
|
||||
|
|
|
@ -11,6 +11,14 @@ module.exports = {
|
|||
useBuiltIns: 'usage',
|
||||
corejs: 3
|
||||
}
|
||||
],
|
||||
'@babel/preset-react',
|
||||
[
|
||||
'@babel/preset-typescript',
|
||||
{
|
||||
isTSX: true,
|
||||
allExtensions: true
|
||||
}
|
||||
]
|
||||
],
|
||||
plugins: [
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
FROM node:alpine
|
||||
FROM node:lts-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 python
|
||||
RUN apk add autoconf g++ make libpng-dev gifsicle alpine-sdk automake libtool make gcc musl-dev nasm python3
|
||||
|
||||
WORKDIR ${SOURCE_DIR}
|
||||
COPY . .
|
||||
|
|
10068
package-lock.json
generated
10068
package-lock.json
generated
File diff suppressed because it is too large
Load diff
114
package.json
114
package.json
|
@ -5,82 +5,98 @@
|
|||
"repository": "https://github.com/jellyfin/jellyfin-web",
|
||||
"license": "GPL-2.0-or-later",
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.13.8",
|
||||
"@babel/eslint-parser": "^7.13.8",
|
||||
"@babel/eslint-plugin": "^7.13.0",
|
||||
"@babel/plugin-proposal-class-properties": "^7.12.13",
|
||||
"@babel/plugin-proposal-private-methods": "^7.12.13",
|
||||
"@babel/plugin-transform-modules-umd": "^7.12.13",
|
||||
"@babel/preset-env": "^7.13.8",
|
||||
"@babel/core": "^7.15.5",
|
||||
"@babel/eslint-parser": "^7.15.4",
|
||||
"@babel/eslint-plugin": "^7.14.5",
|
||||
"@babel/plugin-proposal-class-properties": "^7.14.5",
|
||||
"@babel/plugin-proposal-private-methods": "^7.14.5",
|
||||
"@babel/plugin-transform-modules-umd": "^7.14.5",
|
||||
"@babel/preset-env": "^7.15.4",
|
||||
"@babel/preset-react": "^7.14.5",
|
||||
"@babel/preset-typescript": "^7.15.0",
|
||||
"@typescript-eslint/eslint-plugin": "^4.31.0",
|
||||
"@typescript-eslint/parser": "^4.30.0",
|
||||
"@uupaa/dynamic-import-polyfill": "^1.0.2",
|
||||
"autoprefixer": "^9.8.6",
|
||||
"autoprefixer": "^10.3.3",
|
||||
"babel-loader": "^8.2.2",
|
||||
"babel-plugin-dynamic-import-polyfill": "^1.0.0",
|
||||
"clean-webpack-plugin": "^3.0.0",
|
||||
"clean-webpack-plugin": "^4.0.0",
|
||||
"confusing-browser-globals": "^1.0.10",
|
||||
"copy-webpack-plugin": "^8.1.0",
|
||||
"css-loader": "^5.1.0",
|
||||
"cssnano": "^4.1.10",
|
||||
"eslint": "^7.21.0",
|
||||
"eslint-plugin-compat": "^3.9.0",
|
||||
"copy-webpack-plugin": "^9.0.1",
|
||||
"css-loader": "^5.2.6",
|
||||
"cssnano": "^5.0.8",
|
||||
"eslint": "^7.32.0",
|
||||
"eslint-plugin-compat": "^3.13.0",
|
||||
"eslint-plugin-eslint-comments": "^3.2.0",
|
||||
"eslint-plugin-import": "^2.22.1",
|
||||
"eslint-plugin-promise": "^4.3.1",
|
||||
"expose-loader": "^2.0.0",
|
||||
"eslint-plugin-import": "^2.24.2",
|
||||
"eslint-plugin-jsx-a11y": "^6.4.1",
|
||||
"eslint-plugin-promise": "^5.1.0",
|
||||
"eslint-plugin-react": "^7.25.1",
|
||||
"eslint-plugin-react-hooks": "^4.2.0",
|
||||
"expose-loader": "^3.0.0",
|
||||
"file-loader": "^6.2.0",
|
||||
"html-loader": "^1.1.0",
|
||||
"html-webpack-plugin": "^5.3.1",
|
||||
"postcss-loader": "^3.0.0",
|
||||
"html-loader": "^2.1.2",
|
||||
"html-webpack-plugin": "^5.3.2",
|
||||
"postcss": "^8.3.6",
|
||||
"postcss-loader": "^6.1.1",
|
||||
"postcss-preset-env": "^6.7.0",
|
||||
"sass": "^1.32.8",
|
||||
"sass-loader": "^11.0.1",
|
||||
"source-map-loader": "^2.0.1",
|
||||
"style-loader": "^2.0.0",
|
||||
"stylelint": "^13.11.0",
|
||||
"sass": "^1.39.0",
|
||||
"sass-loader": "^12.1.0",
|
||||
"source-map-loader": "^3.0.0",
|
||||
"style-loader": "^3.2.1",
|
||||
"stylelint": "^13.13.1",
|
||||
"stylelint-config-rational-order": "^0.1.2",
|
||||
"stylelint-no-browser-hacks": "^1.2.1",
|
||||
"stylelint-order": "^4.1.0",
|
||||
"stylelint-scss": "^3.19.0",
|
||||
"webpack": "^5.24.2",
|
||||
"webpack-cli": "^4.6.0",
|
||||
"webpack-dev-server": "^3.11.2",
|
||||
"webpack-merge": "^4.2.2",
|
||||
"workbox-webpack-plugin": "^6.1.1",
|
||||
"worker-plugin": "^5.0.0"
|
||||
"stylelint-scss": "^3.20.1",
|
||||
"ts-loader": "^9.2.5",
|
||||
"typescript": "^4.4.2",
|
||||
"webpack": "^5.52.0",
|
||||
"webpack-cli": "^4.8.0",
|
||||
"webpack-dev-server": "^4.1.0",
|
||||
"webpack-merge": "^5.8.0",
|
||||
"workbox-webpack-plugin": "^6.2.4",
|
||||
"worker-plugin": "^5.0.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource/noto-sans": "^4.2.1",
|
||||
"@fontsource/noto-sans-hk": "^4.2.1",
|
||||
"@fontsource/noto-sans-jp": "^4.2.1",
|
||||
"@fontsource/noto-sans-kr": "^4.2.2",
|
||||
"@fontsource/noto-sans-sc": "^4.2.1",
|
||||
"blurhash": "^1.1.3",
|
||||
"@fontsource/noto-sans": "^4.5.0",
|
||||
"@fontsource/noto-sans-hk": "^4.5.0",
|
||||
"@fontsource/noto-sans-jp": "^4.5.0",
|
||||
"@fontsource/noto-sans-kr": "^4.5.0",
|
||||
"@fontsource/noto-sans-sc": "^4.5.0",
|
||||
"blurhash": "^1.1.4",
|
||||
"classlist.js": "https://github.com/eligrey/classList.js/archive/1.2.20180112.tar.gz",
|
||||
"core-js": "^3.9.1",
|
||||
"date-fns": "^2.17.0",
|
||||
"classnames": "^2.3.1",
|
||||
"core-js": "^3.17.2",
|
||||
"date-fns": "^2.23.0",
|
||||
"dompurify": "^2.3.1",
|
||||
"epubjs": "^0.3.85",
|
||||
"fast-text-encoding": "^1.0.3",
|
||||
"flv.js": "^1.5.0",
|
||||
"flv.js": "^1.6.1",
|
||||
"headroom.js": "^0.12.0",
|
||||
"hls.js": "^0.14.17",
|
||||
"hls.js": "^1.0.10",
|
||||
"intersection-observer": "^0.12.0",
|
||||
"jellyfin-apiclient": "^1.7.0",
|
||||
"jellyfin-apiclient": "^1.8.0",
|
||||
"jquery": "^3.5.1",
|
||||
"jstree": "^3.3.11",
|
||||
"jstree": "^3.3.12",
|
||||
"libarchive.js": "^1.3.0",
|
||||
"libass-wasm": "https://github.com/jellyfin/JavascriptSubtitlesOctopus#4.0.0-jf-smarttv",
|
||||
"libass-wasm": "git+https://github.com/jellyfin/JavascriptSubtitlesOctopus.git#4.0.0-jf-smarttv",
|
||||
"lodash-es": "^4.17.21",
|
||||
"marked": "^3.0.2",
|
||||
"material-design-icons-iconfont": "^6.1.0",
|
||||
"native-promise-only": "^0.8.0-a",
|
||||
"page": "^1.11.6",
|
||||
"pdfjs-dist": "2.6.347",
|
||||
"react": "^17.0.2",
|
||||
"react-dom": "^17.0.2",
|
||||
"resize-observer-polyfill": "^1.5.1",
|
||||
"screenfull": "^5.1.0",
|
||||
"sortablejs": "^1.13.0",
|
||||
"swiper": "^6.5.0",
|
||||
"sortablejs": "^1.14.0",
|
||||
"swiper": "^6.8.4",
|
||||
"webcomponents.js": "^0.7.24",
|
||||
"whatwg-fetch": "^3.6.2",
|
||||
"workbox-core": "^5.1.4",
|
||||
"workbox-precaching": "^5.1.4"
|
||||
"workbox-core": "^6.2.4",
|
||||
"workbox-precaching": "^6.2.4"
|
||||
},
|
||||
"browserslist": [
|
||||
"last 2 Firefox versions",
|
||||
|
|
|
@ -214,6 +214,7 @@ div[data-role=controlgroup] a.ui-btn-active {
|
|||
background-repeat: no-repeat;
|
||||
background-position: center center;
|
||||
position: absolute;
|
||||
border-radius: 0.2em;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
|
@ -345,11 +346,15 @@ div[data-role=controlgroup] a.ui-btn-active {
|
|||
right: 0;
|
||||
bottom: 0;
|
||||
font-weight: 400;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.darkenContent {
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
color: #ddd;
|
||||
}
|
||||
|
||||
.sessionAppName {
|
||||
vertical-align: top;
|
||||
max-width: 200px;
|
||||
|
|
|
@ -52,8 +52,6 @@
|
|||
z-index: 1;
|
||||
margin: 0 !important;
|
||||
top: 6.9em !important;
|
||||
-webkit-transition: -webkit-transform 0.2s ease-out;
|
||||
-o-transition: transform 0.2s ease-out;
|
||||
transition: transform 0.2s ease-out;
|
||||
}
|
||||
|
||||
|
@ -62,17 +60,14 @@
|
|||
}
|
||||
|
||||
.headerUserImage {
|
||||
-webkit-background-size: contain;
|
||||
background-size: contain;
|
||||
background-repeat: no-repeat;
|
||||
background-position: center center;
|
||||
-webkit-border-radius: 100em;
|
||||
border-radius: 100em;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.headerUserButtonRound div {
|
||||
-webkit-border-radius: 100em;
|
||||
border-radius: 100em;
|
||||
background-size: cover;
|
||||
background-repeat: no-repeat;
|
||||
|
@ -80,7 +75,6 @@
|
|||
}
|
||||
|
||||
.headerButton {
|
||||
-webkit-flex-shrink: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
|
@ -90,23 +84,15 @@
|
|||
|
||||
.headerLeft {
|
||||
display: flex;
|
||||
-webkit-align-items: center;
|
||||
align-items: center;
|
||||
-webkit-box-flex: 1;
|
||||
-webkit-flex-grow: 1;
|
||||
flex-grow: 1;
|
||||
overflow: hidden;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.headerRight {
|
||||
display: -webkit-box;
|
||||
display: -webkit-flex;
|
||||
display: flex;
|
||||
-webkit-align-items: center;
|
||||
align-items: center;
|
||||
-webkit-box-pack: end;
|
||||
-webkit-justify-content: flex-end;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
|
@ -116,15 +102,10 @@
|
|||
}
|
||||
|
||||
.pageTitle {
|
||||
display: -webkit-inline-box;
|
||||
display: -webkit-inline-flex;
|
||||
display: inline-flex;
|
||||
margin: 0 0 0 0.5em;
|
||||
height: 1.7em;
|
||||
-webkit-box-align: center;
|
||||
-webkit-align-items: center;
|
||||
align-items: center;
|
||||
-webkit-flex-shrink: 1;
|
||||
flex-shrink: 1;
|
||||
}
|
||||
|
||||
|
@ -134,21 +115,16 @@
|
|||
|
||||
.headerLeft,
|
||||
.skinHeader {
|
||||
display: -webkit-box;
|
||||
display: -webkit-flex;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.detailButton,
|
||||
.skinHeader {
|
||||
flex-direction: column;
|
||||
-webkit-flex-direction: column;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-box-direction: normal;
|
||||
}
|
||||
|
||||
.pageTitleWithLogo {
|
||||
background-position: left center;
|
||||
-webkit-background-size: contain;
|
||||
background-size: contain;
|
||||
background-repeat: no-repeat;
|
||||
width: 13.2em;
|
||||
|
@ -194,27 +170,19 @@
|
|||
}
|
||||
|
||||
.navMenuOption {
|
||||
display: -webkit-box !important;
|
||||
display: -webkit-flex !important;
|
||||
display: flex !important;
|
||||
-webkit-box-align: center;
|
||||
-webkit-align-items: center;
|
||||
align-items: center;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
padding: 0.9em 0 0.9em 2.4em !important;
|
||||
-webkit-box-flex: 1;
|
||||
-webkit-flex-grow: 1;
|
||||
flex-grow: 1;
|
||||
font-weight: 400 !important;
|
||||
margin: 0 !important;
|
||||
-webkit-border-radius: 0 !important;
|
||||
border-radius: 0 !important;
|
||||
}
|
||||
|
||||
.navMenuOptionIcon {
|
||||
margin-right: 1.2em;
|
||||
-webkit-flex-shrink: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
|
@ -229,8 +197,6 @@
|
|||
}
|
||||
|
||||
.dashboardDocument .skinBody {
|
||||
-webkit-transition: left ease-in-out 0.3s, padding ease-in-out 0.3s;
|
||||
-o-transition: left ease-in-out 0.3s, padding ease-in-out 0.3s;
|
||||
transition: left ease-in-out 0.3s, padding ease-in-out 0.3s;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
|
@ -250,26 +216,6 @@
|
|||
padding-bottom: 10vh;
|
||||
}
|
||||
|
||||
.primaryImageWrapper {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.primaryImageWrapper > img {
|
||||
display: block;
|
||||
margin: 0 auto;
|
||||
max-width: 80vw;
|
||||
max-height: 50vh;
|
||||
}
|
||||
|
||||
.primaryImageWrapper > img.aspect-square {
|
||||
max-height: 45vh;
|
||||
}
|
||||
|
||||
.layout-mobile .primaryImageWrapper {
|
||||
display: block;
|
||||
flex: 1 0 auto;
|
||||
}
|
||||
|
||||
@media all and (min-width: 40em) {
|
||||
.dashboardDocument .adminDrawerLogo,
|
||||
.dashboardDocument .mainDrawerButton {
|
||||
|
@ -280,9 +226,7 @@
|
|||
z-index: inherit !important;
|
||||
left: 0 !important;
|
||||
top: 0 !important;
|
||||
-webkit-transform: none !important;
|
||||
transform: none !important;
|
||||
-webkit-box-shadow: none !important;
|
||||
box-shadow: none !important;
|
||||
width: 20.205em !important;
|
||||
font-size: 94%;
|
||||
|
@ -317,14 +261,9 @@
|
|||
}
|
||||
|
||||
.headerTabs {
|
||||
-webkit-align-self: center;
|
||||
align-self: center;
|
||||
width: auto;
|
||||
-webkit-box-align: center;
|
||||
-webkit-align-items: center;
|
||||
align-items: center;
|
||||
-webkit-box-pack: center;
|
||||
-webkit-justify-content: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
margin-top: -4.3em;
|
||||
|
@ -381,8 +320,6 @@
|
|||
}
|
||||
|
||||
.flexPageTabContent.is-active {
|
||||
display: -webkit-box !important;
|
||||
display: -webkit-flex !important;
|
||||
display: flex !important;
|
||||
}
|
||||
|
||||
|
@ -403,13 +340,17 @@
|
|||
margin: 1.5em 0;
|
||||
background: #222;
|
||||
padding: 0.8em 0.8em 0.8em 3em;
|
||||
-webkit-border-radius: 0.3em;
|
||||
border-radius: 0.3em;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.detailLogo,
|
||||
.itemBackdrop {
|
||||
.detailLogo {
|
||||
width: 25vw;
|
||||
height: 16vh;
|
||||
position: absolute;
|
||||
top: 10vh;
|
||||
right: 25vw;
|
||||
background-size: contain;
|
||||
background-repeat: no-repeat;
|
||||
background-position: center center;
|
||||
}
|
||||
|
@ -462,30 +403,33 @@
|
|||
}
|
||||
|
||||
.itemBackdrop {
|
||||
-webkit-background-size: cover;
|
||||
background-size: cover;
|
||||
background-repeat: no-repeat;
|
||||
background-position: center;
|
||||
background-position: center 0;
|
||||
background-attachment: fixed;
|
||||
height: 40vh;
|
||||
position: relative;
|
||||
animation: backdrop-fadein 800ms ease-in normal both;
|
||||
}
|
||||
|
||||
.layout-mobile .itemBackdrop {
|
||||
display: none;
|
||||
}
|
||||
.layout-mobile & {
|
||||
background-attachment: initial;
|
||||
margin-top: 3rem;
|
||||
|
||||
.layout-desktop .itemBackdrop::after {
|
||||
@media all and (orientation: portrait) and (max-width: 40em) {
|
||||
height: 30vh;
|
||||
}
|
||||
}
|
||||
|
||||
.layout-desktop &::after {
|
||||
content: "";
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0, 0, 0, 0.65);
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.layout-tv .itemBackdrop,
|
||||
.layout-desktop .noBackdrop .itemBackdrop {
|
||||
.layout-tv .itemBackdrop {
|
||||
display: none;
|
||||
}
|
||||
|
||||
|
@ -494,26 +438,18 @@
|
|||
flex-direction: column;
|
||||
padding-left: 32.45vw;
|
||||
padding-right: 2%;
|
||||
}
|
||||
|
||||
.layout-mobile .detailPageContent {
|
||||
.layout-mobile & {
|
||||
padding-left: 5%;
|
||||
padding-right: 5%;
|
||||
}
|
||||
}
|
||||
|
||||
.layout-desktop .detailPageContent .emby-scroller,
|
||||
.layout-tv .detailPageContent .emby-scroller {
|
||||
.layout-desktop &,
|
||||
.layout-tv & {
|
||||
.emby-scroller {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.layout-desktop .noBackdrop .detailPageContent,
|
||||
.layout-tv .noBackdrop .detailPageContent {
|
||||
margin-top: 2.5em;
|
||||
}
|
||||
|
||||
.layout-desktop .noBackdrop .detailImageContainer img,
|
||||
.layout-tv .noBackdrop .detailImageContainer img {
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.detailSectionContent a {
|
||||
|
@ -559,8 +495,6 @@
|
|||
|
||||
.mainDetailButtons {
|
||||
display: flex;
|
||||
-webkit-box-align: center;
|
||||
-webkit-align-items: center;
|
||||
align-items: center;
|
||||
margin: 1em 0;
|
||||
}
|
||||
|
@ -568,13 +502,19 @@
|
|||
.detailButton,
|
||||
.mainDetailButtons {
|
||||
display: flex;
|
||||
display: -webkit-box;
|
||||
display: -webkit-flex;
|
||||
}
|
||||
|
||||
.itemName {
|
||||
margin: 0.5em 0;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
|
||||
.layout-mobile & {
|
||||
white-space: normal;
|
||||
overflow: visible;
|
||||
}
|
||||
}
|
||||
|
||||
.itemName.originalTitle {
|
||||
|
@ -613,14 +553,19 @@
|
|||
}
|
||||
|
||||
.itemMiscInfo {
|
||||
display: -webkit-box;
|
||||
display: -webkit-flex;
|
||||
display: flex;
|
||||
-webkit-flex-wrap: wrap;
|
||||
flex-wrap: wrap;
|
||||
-webkit-box-align: center;
|
||||
-webkit-align-items: center;
|
||||
align-items: center;
|
||||
|
||||
.layout-mobile & {
|
||||
@media all and (orientation: portrait) and (max-width: 40em) {
|
||||
margin-bottom: 0 !important;
|
||||
|
||||
.mediaInfoItem {
|
||||
margin-top: 0.5em;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.layout-mobile .parentName,
|
||||
|
@ -633,9 +578,14 @@
|
|||
}
|
||||
|
||||
.layout-mobile .mainDetailButtons {
|
||||
flex: 2 0 70%;
|
||||
margin-top: 0.5em;
|
||||
margin-top: 1em;
|
||||
margin-bottom: 0.5em;
|
||||
margin-left: 37.5%;
|
||||
|
||||
@media all and (max-width: 32em) {
|
||||
margin-bottom: 0;
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
|
@ -651,23 +601,22 @@
|
|||
align-items: center;
|
||||
align-content: center;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.layout-tv .detailPagePrimaryContainer {
|
||||
.layout-mobile & {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.layout-mobile .detailPagePrimaryContainer {
|
||||
flex-wrap: wrap;
|
||||
position: relative;
|
||||
padding: 4.5rem 3.3% 0.5rem;
|
||||
}
|
||||
padding: 0.5rem 5%;
|
||||
}
|
||||
|
||||
.layout-tv #itemDetailPage:not(.noBackdrop) .detailPagePrimaryContainer,
|
||||
.layout-desktop #itemDetailPage:not(.noBackdrop) .detailPagePrimaryContainer {
|
||||
.layout-desktop & {
|
||||
position: relative;
|
||||
top: 0;
|
||||
padding-left: 32.45vw;
|
||||
}
|
||||
|
||||
.layout-tv & {
|
||||
display: block;
|
||||
padding-left: 32.45vw;
|
||||
}
|
||||
}
|
||||
|
||||
.layout-desktop .detailRibbon {
|
||||
|
@ -680,30 +629,24 @@
|
|||
height: inherit;
|
||||
}
|
||||
|
||||
.layout-desktop .noBackdrop .detailRibbon,
|
||||
.layout-tv .noBackdrop .detailRibbon {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.infoWrapper {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
flex: 1 0 0;
|
||||
}
|
||||
|
||||
.layout-mobile .infoWrapper {
|
||||
flex: 2 0 70%;
|
||||
.layout-mobile & {
|
||||
padding-left: 37.5%;
|
||||
|
||||
@media all and (max-width: 32em) {
|
||||
position: relative;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.infoText {
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
text-align: left;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.layout-mobile .infoText {
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.detailPageSecondaryContainer {
|
||||
|
@ -714,46 +657,63 @@
|
|||
margin: 1em 0;
|
||||
}
|
||||
|
||||
.layout-mobile .detailImageContainer {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.detailImageContainer .card {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
float: left;
|
||||
width: 25vw;
|
||||
// important is needed here to override :focus setting
|
||||
// the position to relative in the tv layout
|
||||
position: absolute !important;
|
||||
top: 20%;
|
||||
max-width: 25vw;
|
||||
max-height: 80vh;
|
||||
z-index: 3;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.detailImageContainer .card.backdropCard {
|
||||
.cardBox {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
&.backdropCard {
|
||||
top: 35%;
|
||||
}
|
||||
}
|
||||
|
||||
.detailImageContainer .card.squareCard {
|
||||
&.squareCard {
|
||||
top: 40%;
|
||||
}
|
||||
}
|
||||
|
||||
.layout-desktop .noBackdrop .detailImageContainer,
|
||||
.layout-tv .noBackdrop .detailImageContainer {
|
||||
margin-top: 0;
|
||||
.layout-mobile & {
|
||||
left: 5%;
|
||||
bottom: 1rem;
|
||||
max-width: 30vw;
|
||||
filter: drop-shadow(0 0 0.5rem #000);
|
||||
|
||||
@media all and (max-width: 32em) {
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
&,
|
||||
&.backdropCard,
|
||||
&.squareCard {
|
||||
top: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.layout-desktop & {
|
||||
left: 3.3%;
|
||||
top: -80%;
|
||||
width: 25vw;
|
||||
}
|
||||
|
||||
.layout-tv & {
|
||||
left: 5%;
|
||||
top: 50%;
|
||||
width: 25vw;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
}
|
||||
|
||||
.detailPagePrimaryContent {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.detailLogo {
|
||||
width: 25vw;
|
||||
height: 16vh;
|
||||
position: absolute;
|
||||
top: 10vh;
|
||||
right: 25vw;
|
||||
background-size: contain;
|
||||
}
|
||||
|
||||
.noBackdrop .detailLogo,
|
||||
.layout-mobile .detailLogo {
|
||||
display: none;
|
||||
}
|
||||
|
@ -766,7 +726,6 @@
|
|||
|
||||
.itemDetailImage {
|
||||
width: 100% !important;
|
||||
-webkit-box-shadow: 0 0.1em 0.5em 0 rgba(0, 0, 0, 0.75);
|
||||
box-shadow: 0 0.1em 0.5em 0 rgba(0, 0, 0, 0.75);
|
||||
}
|
||||
|
||||
|
@ -873,7 +832,6 @@ div.itemDetailGalleryLink.defaultCardBackground {
|
|||
.recordingFields button {
|
||||
margin-left: 0;
|
||||
margin-right: 0.5em;
|
||||
-webkit-flex-shrink: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
|
@ -884,11 +842,7 @@ div.itemDetailGalleryLink.defaultCardBackground {
|
|||
.detailButton {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
-webkit-box-pack: center;
|
||||
-webkit-justify-content: center;
|
||||
justify-content: center;
|
||||
-webkit-box-align: center;
|
||||
-webkit-align-items: center;
|
||||
align-items: center;
|
||||
margin: 0 !important;
|
||||
padding: 0.7em 0.7em !important;
|
||||
|
@ -916,18 +870,9 @@ div.itemDetailGalleryLink.defaultCardBackground {
|
|||
}
|
||||
|
||||
.detailButton-content {
|
||||
display: -webkit-box;
|
||||
display: -webkit-flex;
|
||||
display: flex;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-box-direction: normal;
|
||||
-webkit-flex-direction: column;
|
||||
flex-direction: column;
|
||||
-webkit-box-pack: center;
|
||||
-webkit-justify-content: center;
|
||||
justify-content: center;
|
||||
-webkit-box-align: center;
|
||||
-webkit-align-items: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
|
@ -984,8 +929,6 @@ div.itemDetailGalleryLink.defaultCardBackground {
|
|||
@media all and (max-width: 31.25em) {
|
||||
.mobileDetails .itemMiscInfo {
|
||||
text-align: center;
|
||||
-webkit-box-pack: center;
|
||||
-webkit-justify-content: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
|
@ -1007,11 +950,6 @@ div.itemDetailGalleryLink.defaultCardBackground {
|
|||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.layout-desktop .noBackdrop .detailPageWrapperContainer,
|
||||
.layout-tv .noBackdrop .detailPageWrapperContainer {
|
||||
margin-top: 3.8em;
|
||||
}
|
||||
|
||||
.mediaInfoStream {
|
||||
margin: 0 3em 0 0;
|
||||
display: inline-block;
|
||||
|
@ -1071,14 +1009,9 @@ div.itemDetailGalleryLink.defaultCardBackground {
|
|||
}
|
||||
|
||||
.mediaInfoIcons {
|
||||
display: -webkit-box;
|
||||
display: -webkit-flex;
|
||||
display: flex;
|
||||
-webkit-box-align: center;
|
||||
-webkit-align-items: center;
|
||||
align-items: center;
|
||||
margin: 1em 0;
|
||||
-webkit-flex-wrap: wrap;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
|
@ -1126,7 +1059,6 @@ div:not(.sectionTitleContainer-cards) > .sectionTitle-cards {
|
|||
|
||||
.sectionTitleButton {
|
||||
margin-left: 1.5em !important;
|
||||
-webkit-flex-shrink: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
|
@ -1136,22 +1068,17 @@ div:not(.sectionTitleContainer-cards) > .sectionTitle-cards {
|
|||
|
||||
.sectionTitleIconButton {
|
||||
margin-left: 1.5em !important;
|
||||
-webkit-flex-shrink: 0;
|
||||
flex-shrink: 0;
|
||||
font-size: 84% !important;
|
||||
padding: 0.5em !important;
|
||||
}
|
||||
|
||||
.horizontalItemsContainer {
|
||||
display: -webkit-box;
|
||||
display: -webkit-flex;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.sectionTitleTextButton {
|
||||
margin: 0 !important;
|
||||
display: -webkit-inline-box !important;
|
||||
display: -webkit-inline-flex !important;
|
||||
display: inline-flex !important;
|
||||
color: inherit !important;
|
||||
}
|
||||
|
@ -1219,8 +1146,6 @@ div:not(.sectionTitleContainer-cards) > .sectionTitle-cards {
|
|||
}
|
||||
|
||||
.itemsViewSettingsContainer {
|
||||
-webkit-box-pack: center;
|
||||
-webkit-justify-content: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
|
@ -1245,7 +1170,7 @@ div:not(.sectionTitleContainer-cards) > .sectionTitle-cards {
|
|||
}
|
||||
|
||||
.itemDetailsGroup {
|
||||
margin-bottom: 1.5em;
|
||||
margin-top: 1.5em;
|
||||
}
|
||||
|
||||
.trackSelections {
|
||||
|
|
|
@ -8,8 +8,12 @@ class ServerConnections extends ConnectionManager {
|
|||
super(...arguments);
|
||||
this.localApiClient = null;
|
||||
|
||||
Events.on(this, 'localusersignedout', function () {
|
||||
Events.on(this, 'localusersignedout', function (eventName, logoutInfo) {
|
||||
setUserInfo(null, null);
|
||||
|
||||
if (window.NativeShell && typeof window.NativeShell.onLocalUserSignedOut === 'function') {
|
||||
window.NativeShell.onLocalUserSignedOut(logoutInfo);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -62,7 +66,12 @@ class ServerConnections extends ConnectionManager {
|
|||
onLocalUserSignedIn(user) {
|
||||
const apiClient = this.getApiClient(user.ServerId);
|
||||
this.setLocalApiClient(apiClient);
|
||||
return setUserInfo(user.Id, apiClient);
|
||||
return setUserInfo(user.Id, apiClient).then(() => {
|
||||
if (window.NativeShell && typeof window.NativeShell.onLocalUserSignedIn === 'function') {
|
||||
return window.NativeShell.onLocalUserSignedIn(user, apiClient.accessToken());
|
||||
}
|
||||
return Promise.resolve();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
37
src/components/alphaPicker/AlphaPickerComponent.tsx
Normal file
37
src/components/alphaPicker/AlphaPickerComponent.tsx
Normal file
|
@ -0,0 +1,37 @@
|
|||
import React, { FunctionComponent, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import AlphaPicker from './alphaPicker';
|
||||
|
||||
type AlphaPickerProps = {
|
||||
onAlphaPicked?: () => void
|
||||
};
|
||||
|
||||
// React compatibility wrapper component for alphaPicker.js
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
const AlphaPickerComponent: FunctionComponent<AlphaPickerProps> = ({ onAlphaPicked = () => {} }: AlphaPickerProps) => {
|
||||
const [ alphaPicker, setAlphaPicker ] = useState(null);
|
||||
const element = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
setAlphaPicker(new AlphaPicker({
|
||||
element: element.current,
|
||||
mode: 'keyboard'
|
||||
}));
|
||||
|
||||
element.current?.addEventListener('alphavalueclicked', onAlphaPicked);
|
||||
|
||||
return () => {
|
||||
alphaPicker?.destroy();
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- Disabled for wrapper components
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={element}
|
||||
className='alphaPicker align-items-center'
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default AlphaPickerComponent;
|
|
@ -11,6 +11,7 @@ import viewManager from './viewManager/viewManager';
|
|||
import Dashboard from '../scripts/clientUtils';
|
||||
import ServerConnections from './ServerConnections';
|
||||
import alert from './alert';
|
||||
import reactControllerFactory from './reactControllerFactory';
|
||||
|
||||
class AppRouter {
|
||||
allRoutes = [];
|
||||
|
@ -24,15 +25,24 @@ class AppRouter {
|
|||
msgTimeout;
|
||||
popstateOccurred = false;
|
||||
resolveOnNextShow;
|
||||
previousRoute = {};
|
||||
/**
|
||||
* Pages of "no return" (when "Go back" should behave differently, probably quitting the application).
|
||||
*/
|
||||
startPages = ['home', 'login', 'selectserver'];
|
||||
|
||||
constructor() {
|
||||
// WebKit fires a popstate event on document load
|
||||
// Skip it using timeout
|
||||
// For Tizen 2.x
|
||||
// https://stackoverflow.com/a/12214354
|
||||
window.addEventListener('load', () => {
|
||||
setTimeout(() => {
|
||||
window.addEventListener('popstate', () => {
|
||||
this.popstateOccurred = true;
|
||||
});
|
||||
}, 0);
|
||||
});
|
||||
|
||||
document.addEventListener('viewshow', () => {
|
||||
const resolve = this.resolveOnNextShow;
|
||||
|
@ -341,7 +351,9 @@ class AppRouter {
|
|||
this.sendRouteToViewManager(ctx, next, route, controllerFactory);
|
||||
};
|
||||
|
||||
if (route.controller) {
|
||||
if (route.pageComponent) {
|
||||
onInitComplete(reactControllerFactory);
|
||||
} else if (route.controller) {
|
||||
import('../controllers/' + route.controller).then(onInitComplete);
|
||||
} else {
|
||||
onInitComplete();
|
||||
|
@ -373,6 +385,7 @@ class AppRouter {
|
|||
fullscreen: route.fullscreen,
|
||||
controllerFactory: controllerFactory,
|
||||
options: {
|
||||
pageComponent: route.pageComponent,
|
||||
supportsThemeMedia: route.supportsThemeMedia || false,
|
||||
enableMediaControl: route.enableMediaControl !== false
|
||||
},
|
||||
|
@ -503,7 +516,8 @@ class AppRouter {
|
|||
const firstResult = this.firstConnectionResult;
|
||||
|
||||
this.firstConnectionResult = null;
|
||||
if (firstResult && firstResult.State === 'ServerSignIn') {
|
||||
if (firstResult) {
|
||||
if (firstResult.State === 'ServerSignIn') {
|
||||
const url = firstResult.ApiClient.serverAddress() + '/System/Info/Public';
|
||||
fetch(url).then(response => {
|
||||
if (!response.ok) return Promise.reject('fetch failed');
|
||||
|
@ -520,6 +534,10 @@ class AppRouter {
|
|||
});
|
||||
|
||||
return;
|
||||
} else if (firstResult.State !== 'SignedIn') {
|
||||
this.handleConnectionResult(firstResult);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const apiClient = ServerConnections.currentApiClient();
|
||||
|
@ -616,8 +634,13 @@ class AppRouter {
|
|||
getHandler(route) {
|
||||
return (ctx, next) => {
|
||||
ctx.isBack = this.popstateOccurred;
|
||||
this.handleRoute(ctx, next, route);
|
||||
this.popstateOccurred = false;
|
||||
|
||||
const ignore = route.dummyRoute === true || this.previousRoute.dummyRoute === true;
|
||||
this.previousRoute = route;
|
||||
if (ignore) return;
|
||||
|
||||
this.handleRoute(ctx, next, route);
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { version as appVersion } from '../../package.json';
|
||||
import Package from '../../package.json';
|
||||
import appSettings from '../scripts/settings/appSettings';
|
||||
import browser from '../scripts/browser';
|
||||
import { Events } from 'jellyfin-apiclient';
|
||||
|
@ -33,7 +33,7 @@ function getDeviceProfile(item) {
|
|||
let profile;
|
||||
|
||||
if (window.NativeShell) {
|
||||
profile = window.NativeShell.AppHost.getDeviceProfile(profileBuilder, appVersion);
|
||||
profile = window.NativeShell.AppHost.getDeviceProfile(profileBuilder, Package.version);
|
||||
} else {
|
||||
const builderOpts = getBaseProfileOptions(item);
|
||||
profile = profileBuilder(builderOpts);
|
||||
|
@ -275,7 +275,7 @@ const supportedFeatures = function () {
|
|||
*/
|
||||
function doExit() {
|
||||
try {
|
||||
if (window.NativeShell) {
|
||||
if (window.NativeShell?.AppHost?.exit) {
|
||||
window.NativeShell.AppHost.exit();
|
||||
} else if (browser.tizen) {
|
||||
tizen.application.getCurrentApplication().exit();
|
||||
|
@ -360,16 +360,20 @@ export const appHost = {
|
|||
};
|
||||
},
|
||||
deviceName: function () {
|
||||
return window.NativeShell ? window.NativeShell.AppHost.deviceName() : getDeviceName();
|
||||
return window.NativeShell?.AppHost?.deviceName
|
||||
? window.NativeShell.AppHost.deviceName() : getDeviceName();
|
||||
},
|
||||
deviceId: function () {
|
||||
return window.NativeShell ? window.NativeShell.AppHost.deviceId() : getDeviceId();
|
||||
return window.NativeShell?.AppHost?.deviceId
|
||||
? window.NativeShell.AppHost.deviceId() : getDeviceId();
|
||||
},
|
||||
appName: function () {
|
||||
return window.NativeShell ? window.NativeShell.AppHost.appName() : appName;
|
||||
return window.NativeShell?.AppHost?.appName
|
||||
? window.NativeShell.AppHost.appName() : appName;
|
||||
},
|
||||
appVersion: function () {
|
||||
return window.NativeShell ? window.NativeShell.AppHost.appVersion() : appVersion;
|
||||
return window.NativeShell?.AppHost?.appVersion
|
||||
? window.NativeShell.AppHost.appVersion() : Package.version;
|
||||
},
|
||||
getPushTokenInfo: function () {
|
||||
return {};
|
||||
|
|
|
@ -110,6 +110,7 @@ button::-moz-focus-inner {
|
|||
.card.show-focus:not(.show-animation) .cardBox.visualCardBox,
|
||||
.card.show-focus:not(.show-animation) .cardBox:not(.visualCardBox) .cardScalable {
|
||||
border: 0.5em solid transparent;
|
||||
border-radius: 0.7em; /* card border + card border-radius */
|
||||
}
|
||||
|
||||
.card.show-animation:focus > .cardBox {
|
||||
|
@ -160,6 +161,7 @@ button::-moz-focus-inner {
|
|||
background-size: cover;
|
||||
background-repeat: no-repeat;
|
||||
background-position: center center;
|
||||
border-radius: 0.2em;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
@ -194,6 +196,7 @@ button::-moz-focus-inner {
|
|||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
border-radius: 0.2em;
|
||||
|
||||
/* Needed in case this is a button */
|
||||
display: block;
|
||||
|
@ -222,12 +225,12 @@ button::-moz-focus-inner {
|
|||
}
|
||||
|
||||
.cardBox:not(.visualCardBox) .cardPadder {
|
||||
border-radius: 0.2em;
|
||||
background-color: #242424;
|
||||
}
|
||||
|
||||
.visualCardBox .cardContent {
|
||||
border-top-left-radius: 0.2em;
|
||||
border-top-right-radius: 0.2em;
|
||||
.blurhash-canvas {
|
||||
border-radius: 0.2em;
|
||||
}
|
||||
|
||||
.cardContent-shadow,
|
||||
|
@ -263,7 +266,7 @@ button::-moz-focus-inner {
|
|||
|
||||
.visualCardBox {
|
||||
box-shadow: 0 0.0725em 0.29em 0 rgba(0, 0, 0, 0.37);
|
||||
border-radius: 0.145em;
|
||||
border-radius: 0.2em;
|
||||
}
|
||||
|
||||
.innerCardFooter {
|
||||
|
@ -769,6 +772,14 @@ button::-moz-focus-inner {
|
|||
bottom: 0;
|
||||
right: 0;
|
||||
user-select: none;
|
||||
border-radius: 0.2em;
|
||||
}
|
||||
|
||||
.visualCardBox .blurhash-canvas,
|
||||
.visualCardBox .cardContent,
|
||||
.visualCardBox .cardOverlayContainer {
|
||||
border-bottom-left-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
}
|
||||
|
||||
.card-hoverable:hover .cardOverlayContainer {
|
||||
|
|
|
@ -87,7 +87,7 @@ import '../../assets/css/scrollstyles.scss';
|
|||
if (!self.closedByBack && isHistoryEnabled(dlg)) {
|
||||
const state = window.history.state || {};
|
||||
if (state.dialogId === hash) {
|
||||
window.history.back();
|
||||
appRouter.back();
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -142,7 +142,7 @@ import '../../assets/css/scrollstyles.scss';
|
|||
animateDialogOpen(dlg);
|
||||
|
||||
if (isHistoryEnabled(dlg)) {
|
||||
appRouter.pushState({ dialogId: hash }, 'Dialog', `#${hash}`);
|
||||
appRouter.show(`/dialog?dlg=${hash}`, { dialogId: hash });
|
||||
|
||||
window.addEventListener('popstate', onHashChange);
|
||||
} else {
|
||||
|
@ -213,7 +213,7 @@ import '../../assets/css/scrollstyles.scss';
|
|||
export function close(dlg) {
|
||||
if (isOpened(dlg)) {
|
||||
if (isHistoryEnabled(dlg)) {
|
||||
window.history.back();
|
||||
appRouter.back();
|
||||
} else {
|
||||
closeDialog(dlg);
|
||||
}
|
||||
|
@ -379,7 +379,7 @@ import '../../assets/css/scrollstyles.scss';
|
|||
dlg.setAttribute('data-lockscroll', 'true');
|
||||
}
|
||||
|
||||
if (options.enableHistory === true) {
|
||||
if (options.enableHistory !== false) {
|
||||
dlg.setAttribute('data-history', 'true');
|
||||
}
|
||||
|
||||
|
|
|
@ -11,6 +11,7 @@ import { Events } from 'jellyfin-apiclient';
|
|||
import '../../elements/emby-select/emby-select';
|
||||
import '../../elements/emby-checkbox/emby-checkbox';
|
||||
import '../../elements/emby-button/emby-button';
|
||||
import '../../elements/emby-textarea/emby-textarea';
|
||||
import ServerConnections from '../ServerConnections';
|
||||
import toast from '../toast/toast';
|
||||
import template from './displaySettings.template.html';
|
||||
|
@ -122,6 +123,10 @@ import template from './displaySettings.template.html';
|
|||
context.querySelector('#chkBlurhash').checked = userSettings.enableBlurhash();
|
||||
context.querySelector('#chkBackdrops').checked = userSettings.enableBackdrops();
|
||||
context.querySelector('#chkDetailsBanner').checked = userSettings.detailsBanner();
|
||||
context.querySelector('#chkUseEpisodeImagesInNextUp').checked = userSettings.useEpisodeImagesInNextUpAndResume();
|
||||
|
||||
context.querySelector('#chkDisableCustomCss').checked = userSettings.disableCustomCss();
|
||||
context.querySelector('#txtLocalCustomCss').value = userSettings.customCss();
|
||||
|
||||
context.querySelector('#selectLanguage').value = userSettings.language() || '';
|
||||
context.querySelector('.selectDateTimeLocale').value = userSettings.dateTimeLocale() || '';
|
||||
|
@ -156,6 +161,10 @@ import template from './displaySettings.template.html';
|
|||
userSettingsInstance.enableBlurhash(context.querySelector('#chkBlurhash').checked);
|
||||
userSettingsInstance.enableBackdrops(context.querySelector('#chkBackdrops').checked);
|
||||
userSettingsInstance.detailsBanner(context.querySelector('#chkDetailsBanner').checked);
|
||||
userSettingsInstance.useEpisodeImagesInNextUpAndResume(context.querySelector('#chkUseEpisodeImagesInNextUp').checked);
|
||||
|
||||
userSettingsInstance.disableCustomCss(context.querySelector('#chkDisableCustomCss').checked);
|
||||
userSettingsInstance.customCss(context.querySelector('#txtLocalCustomCss').value);
|
||||
|
||||
if (user.Id === apiClient.getCurrentUserId()) {
|
||||
skinManager.setTheme(userSettingsInstance.theme());
|
||||
|
|
|
@ -156,6 +156,19 @@
|
|||
<select id="selectTheme" is="emby-select" label="${LabelTheme}"></select>
|
||||
</div>
|
||||
|
||||
<div class="checkboxContainer checkboxContainer-withDescription">
|
||||
<label>
|
||||
<input type="checkbox" is="emby-checkbox" id="chkDisableCustomCss" />
|
||||
<span>${DisableCustomCss}</span>
|
||||
</label>
|
||||
<div class="fieldDescription checkboxFieldDescription">${LabelDisableCustomCss}</div>
|
||||
</div>
|
||||
|
||||
<div class="inputContainer customCssContainer">
|
||||
<textarea is="emby-textarea" id="txtLocalCustomCss" label="${LabelCustomCss}" class="textarea-mono"></textarea>
|
||||
<div class="fieldDescription">${LabelLocalCustomCss}</div>
|
||||
</div>
|
||||
|
||||
<div class="selectContainer selectDashboardThemeContainer hide">
|
||||
<select id="selectDashboardTheme" is="emby-select" label="${LabelDashboardTheme}"></select>
|
||||
</div>
|
||||
|
@ -225,6 +238,14 @@
|
|||
<div class="fieldDescription checkboxFieldDescription">${DisplayMissingEpisodesWithinSeasonsHelp}</div>
|
||||
</div>
|
||||
|
||||
<div class="checkboxContainer checkboxContainer-withDescription fldUseEpisodeImagesInNextUp">
|
||||
<label>
|
||||
<input type="checkbox" is="emby-checkbox" id="chkUseEpisodeImagesInNextUp" />
|
||||
<span>${UseEpisodeImagesInNextUp}</span>
|
||||
</label>
|
||||
<div class="fieldDescription checkboxFieldDescription">${UseEpisodeImagesInNextUpHelp}</div>
|
||||
</div>
|
||||
|
||||
<button is="emby-button" type="submit" class="raised button-submit block btnSave hide">
|
||||
<span>${Save}</span>
|
||||
</button>
|
||||
|
|
|
@ -32,9 +32,6 @@ import template from './filterdialog.template.html';
|
|||
}
|
||||
|
||||
function renderFilters(context, result, query) {
|
||||
if (result.Tags) {
|
||||
result.Tags.length = Math.min(result.Tags.length, 50);
|
||||
}
|
||||
renderOptions(context, '.genreFilters', 'chkGenreFilter', result.Genres, function (i) {
|
||||
const delimeter = '|';
|
||||
return (delimeter + (query.Genres || '') + delimeter).includes(delimeter + i + delimeter);
|
||||
|
|
|
@ -3,18 +3,18 @@
|
|||
<div is="emby-collapse" title="${Filters}">
|
||||
<div class="collapseContent">
|
||||
<div class="checkboxList">
|
||||
<label>
|
||||
<input type="checkbox" is="emby-checkbox" class="chkStandardFilter videoStandard"
|
||||
<label class="videoStandard">
|
||||
<input type="checkbox" is="emby-checkbox" class="chkStandardFilter"
|
||||
data-filter="IsPlayed" />
|
||||
<span>${Played}</span>
|
||||
</label>
|
||||
<label>
|
||||
<input type="checkbox" is="emby-checkbox" class="chkStandardFilter videoStandard"
|
||||
<label class="videoStandard">
|
||||
<input type="checkbox" is="emby-checkbox" class="chkStandardFilter"
|
||||
data-filter="IsUnPlayed" />
|
||||
<span>${Unplayed}</span>
|
||||
</label>
|
||||
<label>
|
||||
<input type="checkbox" is="emby-checkbox" class="chkStandardFilter videoStandard"
|
||||
<label class="videoStandard">
|
||||
<input type="checkbox" is="emby-checkbox" class="chkStandardFilter"
|
||||
data-filter="IsResumable" />
|
||||
<span>${OptionResumable}</span>
|
||||
</label>
|
||||
|
|
|
@ -60,6 +60,7 @@
|
|||
}
|
||||
|
||||
.layout-tv .formDialogFooter {
|
||||
position: relative;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
|
|
|
@ -275,12 +275,12 @@ import template from './homeScreenSettings.template.html';
|
|||
return html;
|
||||
}
|
||||
|
||||
function renderPerLibrarySettings(context, user, userViews) {
|
||||
function renderPerLibrarySettings(context, user, userViews, userSettings) {
|
||||
const elem = context.querySelector('.perLibrarySettings');
|
||||
let html = '';
|
||||
|
||||
for (let i = 0, length = userViews.length; i < length; i++) {
|
||||
html += getPerLibrarySettingsHtml(userViews[i], user);
|
||||
html += getPerLibrarySettingsHtml(userViews[i], user, userSettings);
|
||||
}
|
||||
|
||||
elem.innerHTML = html;
|
||||
|
@ -297,7 +297,7 @@ import template from './homeScreenSettings.template.html';
|
|||
Promise.all([promise1, promise2]).then(responses => {
|
||||
renderViewOrder(context, user, responses[0]);
|
||||
|
||||
renderPerLibrarySettings(context, user, responses[0].Items);
|
||||
renderPerLibrarySettings(context, user, responses[0].Items, userSettings);
|
||||
|
||||
renderViews(context, user, responses[1]);
|
||||
|
||||
|
|
|
@ -24,6 +24,7 @@
|
|||
<option value="activerecordings">${HeaderActiveRecordings}</option>
|
||||
<option value="resume">${HeaderContinueWatching}</option>
|
||||
<option value="resumeaudio">${HeaderContinueListening}</option>
|
||||
<option value="resumebook">${HeaderContinueReading}</option>
|
||||
<option value="latestmedia">${HeaderLatestMedia}</option>
|
||||
<option value="nextup">${NextUp}</option>
|
||||
<option value="livetv">${LiveTV}</option>
|
||||
|
@ -37,6 +38,7 @@
|
|||
<option value="activerecordings">${HeaderActiveRecordings}</option>
|
||||
<option value="resume">${HeaderContinueWatching}</option>
|
||||
<option value="resumeaudio">${HeaderContinueListening}</option>
|
||||
<option value="resumebook">${HeaderContinueReading}</option>
|
||||
<option value="latestmedia">${HeaderLatestMedia}</option>
|
||||
<option value="nextup">${NextUp}</option>
|
||||
<option value="livetv">${LiveTV}</option>
|
||||
|
@ -50,6 +52,7 @@
|
|||
<option value="activerecordings">${HeaderActiveRecordings}</option>
|
||||
<option value="resume">${HeaderContinueWatching}</option>
|
||||
<option value="resumeaudio">${HeaderContinueListening}</option>
|
||||
<option value="resumebook">${HeaderContinueReading}</option>
|
||||
<option value="latestmedia">${HeaderLatestMedia}</option>
|
||||
<option value="nextup">${NextUp}</option>
|
||||
<option value="livetv">${LiveTV}</option>
|
||||
|
@ -63,6 +66,7 @@
|
|||
<option value="activerecordings">${HeaderActiveRecordings}</option>
|
||||
<option value="resume">${HeaderContinueWatching}</option>
|
||||
<option value="resumeaudio">${HeaderContinueListening}</option>
|
||||
<option value="resumebook">${HeaderContinueReading}</option>
|
||||
<option value="latestmedia">${HeaderLatestMedia}</option>
|
||||
<option value="nextup">${NextUp}</option>
|
||||
<option value="livetv">${LiveTV}</option>
|
||||
|
@ -76,6 +80,7 @@
|
|||
<option value="activerecordings">${HeaderActiveRecordings}</option>
|
||||
<option value="resume">${HeaderContinueWatching}</option>
|
||||
<option value="resumeaudio">${HeaderContinueListening}</option>
|
||||
<option value="resumebook">${HeaderContinueReading}</option>
|
||||
<option value="latestmedia">${HeaderLatestMedia}</option>
|
||||
<option value="nextup">${NextUp}</option>
|
||||
<option value="livetv">${LiveTV}</option>
|
||||
|
@ -89,6 +94,7 @@
|
|||
<option value="activerecordings">${HeaderActiveRecordings}</option>
|
||||
<option value="resume">${HeaderContinueWatching}</option>
|
||||
<option value="resumeaudio">${HeaderContinueListening}</option>
|
||||
<option value="resumebook">${HeaderContinueReading}</option>
|
||||
<option value="latestmedia">${HeaderLatestMedia}</option>
|
||||
<option value="nextup">${NextUp}</option>
|
||||
<option value="livetv">${LiveTV}</option>
|
||||
|
@ -102,6 +108,7 @@
|
|||
<option value="activerecordings">${HeaderActiveRecordings}</option>
|
||||
<option value="resume">${HeaderContinueWatching}</option>
|
||||
<option value="resumeaudio">${HeaderContinueListening}</option>
|
||||
<option value="resumebook">${HeaderContinueReading}</option>
|
||||
<option value="latestmedia">${HeaderLatestMedia}</option>
|
||||
<option value="nextup">${NextUp}</option>
|
||||
<option value="livetv">${LiveTV}</option>
|
||||
|
|
|
@ -24,12 +24,14 @@ import ServerConnections from '../ServerConnections';
|
|||
case 2:
|
||||
return 'resumeaudio';
|
||||
case 3:
|
||||
return 'livetv';
|
||||
return 'resumebook';
|
||||
case 4:
|
||||
return 'nextup';
|
||||
return 'livetv';
|
||||
case 5:
|
||||
return 'latestmedia';
|
||||
return 'nextup';
|
||||
case 6:
|
||||
return 'latestmedia';
|
||||
case 7:
|
||||
return 'none';
|
||||
default:
|
||||
return '';
|
||||
|
@ -142,15 +144,17 @@ import ServerConnections from '../ServerConnections';
|
|||
} else if (section === 'librarybuttons') {
|
||||
loadlibraryButtons(elem, apiClient, user, userSettings, userViews);
|
||||
} else if (section === 'resume') {
|
||||
loadResumeVideo(elem, apiClient);
|
||||
return loadResume(elem, apiClient, 'HeaderContinueWatching', 'Video', userSettings);
|
||||
} else if (section === 'resumeaudio') {
|
||||
loadResumeAudio(elem, apiClient);
|
||||
return loadResume(elem, apiClient, 'HeaderContinueListening', 'Audio', userSettings);
|
||||
} else if (section === 'activerecordings') {
|
||||
loadLatestLiveTvRecordings(elem, true, apiClient);
|
||||
} else if (section === 'nextup') {
|
||||
loadNextUp(elem, apiClient);
|
||||
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();
|
||||
|
@ -365,7 +369,39 @@ import ServerConnections from '../ServerConnections';
|
|||
imageLoader.lazyChildren(elem);
|
||||
}
|
||||
|
||||
function getContinueWatchingFetchFn(serverId) {
|
||||
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 screenWidth = dom.getWindowSize().innerWidth;
|
||||
|
@ -385,19 +421,21 @@ import ServerConnections from '../ServerConnections';
|
|||
ImageTypeLimit: 1,
|
||||
EnableImageTypes: 'Primary,Backdrop,Thumb',
|
||||
EnableTotalRecordCount: false,
|
||||
MediaTypes: 'Video'
|
||||
MediaTypes: mediaType
|
||||
};
|
||||
|
||||
return apiClient.getResumableItems(apiClient.getCurrentUserId(), options);
|
||||
};
|
||||
}
|
||||
|
||||
function getContinueWatchingItemsHtml(items) {
|
||||
function getItemsToResumeHtmlFn(useEpisodeImages, mediaType) {
|
||||
return function (items) {
|
||||
const cardLayout = false;
|
||||
return cardBuilder.getCardsHtml({
|
||||
items: items,
|
||||
preferThumb: true,
|
||||
shape: getThumbShape(),
|
||||
inheritThumb: !useEpisodeImages,
|
||||
shape: (mediaType === 'Book') ? getPortraitShape() : getThumbShape(),
|
||||
overlayText: false,
|
||||
showTitle: true,
|
||||
showParentTitle: true,
|
||||
|
@ -411,104 +449,7 @@ import ServerConnections from '../ServerConnections';
|
|||
showYear: true,
|
||||
lines: 2
|
||||
});
|
||||
}
|
||||
|
||||
function loadResumeVideo(elem, apiClient) {
|
||||
let html = '';
|
||||
|
||||
html += '<h2 class="sectionTitle sectionTitle-cards padded-left">' + globalize.translate('HeaderContinueWatching') + '</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="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 = getContinueWatchingFetchFn(apiClient.serverId());
|
||||
itemsContainer.getItemsHtml = getContinueWatchingItemsHtml;
|
||||
itemsContainer.parentContainer = elem;
|
||||
}
|
||||
|
||||
function getContinueListeningFetchFn(serverId) {
|
||||
return function () {
|
||||
const apiClient = ServerConnections.getApiClient(serverId);
|
||||
const screenWidth = dom.getWindowSize().innerWidth;
|
||||
|
||||
let limit;
|
||||
if (enableScrollX()) {
|
||||
limit = 12;
|
||||
} else {
|
||||
limit = screenWidth >= 1920 ? 8 : (screenWidth >= 1600 ? 8 : (screenWidth >= 1200 ? 9 : 6));
|
||||
limit = Math.min(limit, 5);
|
||||
}
|
||||
|
||||
const options = {
|
||||
Limit: limit,
|
||||
Recursive: true,
|
||||
Fields: 'PrimaryImageAspectRatio,BasicSyncInfo',
|
||||
ImageTypeLimit: 1,
|
||||
EnableImageTypes: 'Primary,Backdrop,Thumb',
|
||||
EnableTotalRecordCount: false,
|
||||
MediaTypes: 'Audio'
|
||||
};
|
||||
|
||||
return apiClient.getResumableItems(apiClient.getCurrentUserId(), options);
|
||||
};
|
||||
}
|
||||
|
||||
function getContinueListeningItemsHtml(items) {
|
||||
const cardLayout = false;
|
||||
return cardBuilder.getCardsHtml({
|
||||
items: items,
|
||||
preferThumb: true,
|
||||
shape: getThumbShape(),
|
||||
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 loadResumeAudio(elem, apiClient) {
|
||||
let html = '';
|
||||
|
||||
html += '<h2 class="sectionTitle sectionTitle-cards padded-left">' + globalize.translate('HeaderContinueListening') + '</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="audioplayback,markplayed">';
|
||||
} else {
|
||||
html += '<div is="emby-itemscontainer" class="itemsContainer padded-left padded-right vertical-wrap focuscontainer-x" data-monitor="audioplayback,markplayed">';
|
||||
}
|
||||
|
||||
if (enableScrollX()) {
|
||||
html += '</div>';
|
||||
}
|
||||
html += '</div>';
|
||||
|
||||
elem.classList.add('hide');
|
||||
elem.innerHTML = html;
|
||||
|
||||
const itemsContainer = elem.querySelector('.itemsContainer');
|
||||
itemsContainer.fetchData = getContinueListeningFetchFn(apiClient.serverId());
|
||||
itemsContainer.getItemsHtml = getContinueListeningItemsHtml;
|
||||
itemsContainer.parentContainer = elem;
|
||||
}
|
||||
|
||||
function getOnNowFetchFn(serverId) {
|
||||
|
@ -669,11 +610,13 @@ import ServerConnections from '../ServerConnections';
|
|||
};
|
||||
}
|
||||
|
||||
function getNextUpItemsHtml(items) {
|
||||
function getNextUpItemsHtmlFn(useEpisodeImages) {
|
||||
return function (items) {
|
||||
const cardLayout = false;
|
||||
return cardBuilder.getCardsHtml({
|
||||
items: items,
|
||||
preferThumb: true,
|
||||
inheritThumb: !useEpisodeImages,
|
||||
shape: getThumbShape(),
|
||||
overlayText: false,
|
||||
showTitle: true,
|
||||
|
@ -685,9 +628,10 @@ import ServerConnections from '../ServerConnections';
|
|||
allowBottomPadding: !enableScrollX(),
|
||||
cardLayout: cardLayout
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
function loadNextUp(elem, apiClient) {
|
||||
function loadNextUp(elem, apiClient, userSettings) {
|
||||
let html = '';
|
||||
|
||||
html += '<div class="sectionTitleContainer sectionTitleContainer-cards padded-left">';
|
||||
|
@ -722,7 +666,7 @@ import ServerConnections from '../ServerConnections';
|
|||
|
||||
const itemsContainer = elem.querySelector('.itemsContainer');
|
||||
itemsContainer.fetchData = getNextUpFetchFn(apiClient.serverId());
|
||||
itemsContainer.getItemsHtml = getNextUpItemsHtml;
|
||||
itemsContainer.getItemsHtml = getNextUpItemsHtmlFn(userSettings.useEpisodeImagesInNextUpAndResume());
|
||||
itemsContainer.parentContainer = elem;
|
||||
}
|
||||
|
||||
|
|
|
@ -79,7 +79,7 @@ import template from './imageDownloader.template.html';
|
|||
let html = '';
|
||||
|
||||
for (let i = 0, length = imagesResult.Images.length; i < length; i++) {
|
||||
html += getRemoteImageHtml(imagesResult.Images[i], imageType, apiClient);
|
||||
html += getRemoteImageHtml(imagesResult.Images[i], imageType);
|
||||
}
|
||||
|
||||
const availableImagesList = page.querySelector('.availableImagesList');
|
||||
|
@ -150,11 +150,7 @@ import template from './imageDownloader.template.html';
|
|||
});
|
||||
}
|
||||
|
||||
function getDisplayUrl(url, apiClient) {
|
||||
return apiClient.getUrl('Images/Remote', { imageUrl: url });
|
||||
}
|
||||
|
||||
function getRemoteImageHtml(image, imageType, apiClient) {
|
||||
function getRemoteImageHtml(image, imageType) {
|
||||
const tagName = layoutManager.tv ? 'button' : 'div';
|
||||
const enableFooterButtons = !layoutManager.tv;
|
||||
|
||||
|
@ -209,9 +205,9 @@ import template from './imageDownloader.template.html';
|
|||
html += '<div class="cardContent">';
|
||||
|
||||
if (layoutManager.tv || !appHost.supports('externallinks')) {
|
||||
html += '<div class="cardImageContainer lazy" data-src="' + getDisplayUrl(image.Url, apiClient) + '" style="background-position:center center;background-size:contain;"></div>';
|
||||
html += '<div class="cardImageContainer lazy" data-src="' + image.Url + '" style="background-position:center center;background-size:contain;"></div>';
|
||||
} else {
|
||||
html += '<a is="emby-linkbutton" target="_blank" href="' + getDisplayUrl(image.Url, apiClient) + '" class="button-link cardImageContainer lazy" data-src="' + getDisplayUrl(image.Url, apiClient) + '" style="background-position:center center;background-size:contain"></a>';
|
||||
html += '<a is="emby-linkbutton" target="_blank" href="' + image.Url + '" class="button-link cardImageContainer lazy" data-src="' + image.Url + '" style="background-position:center center;background-size:contain"></a>';
|
||||
}
|
||||
|
||||
html += '</div>';
|
||||
|
|
|
@ -471,7 +471,7 @@ import toast from './toast/toast';
|
|||
navigator.share({
|
||||
title: item.Name,
|
||||
text: item.Overview,
|
||||
url: `${apiClient.serverAddress()}/web/index.html#!/${appRouter.getRouteUrl(item)}`
|
||||
url: `${apiClient.serverAddress()}/web/index.html${appRouter.getRouteUrl(item)}`
|
||||
});
|
||||
break;
|
||||
case 'album':
|
||||
|
|
|
@ -171,9 +171,7 @@ import template from './itemidentifier.template.html';
|
|||
let resultHtml = lines.join('<br/>');
|
||||
|
||||
if (identifyResult.ImageUrl) {
|
||||
const displayUrl = getSearchImageDisplayUrl(identifyResult.ImageUrl, identifyResult.SearchProviderName);
|
||||
|
||||
resultHtml = `<div style="display:flex;align-items:center;"><img src="${displayUrl}" style="max-height:240px;" /><div style="margin-left:1em;">${resultHtml}</div>`;
|
||||
resultHtml = `<div style="display:flex;align-items:center;"><img src="${identifyResult.ImageUrl}" style="max-height:240px;" /><div style="margin-left:1em;">${resultHtml}</div>`;
|
||||
}
|
||||
|
||||
page.querySelector('.selectedSearchResult').innerHTML = resultHtml;
|
||||
|
@ -218,9 +216,7 @@ import template from './itemidentifier.template.html';
|
|||
html += '<div class="cardContent searchImage">';
|
||||
|
||||
if (result.ImageUrl) {
|
||||
const displayUrl = getSearchImageDisplayUrl(result.ImageUrl, result.SearchProviderName);
|
||||
|
||||
html += `<div class="cardImageContainer coveredImage" style="background-image:url('${displayUrl}');"></div>`;
|
||||
html += `<div class="cardImageContainer coveredImage" style="background-image:url('${result.ImageUrl}');"></div>`;
|
||||
} else {
|
||||
html += `<div class="cardImageContainer coveredImage defaultCardBackground defaultCardBackground1"><div class="cardText cardCenteredText">${result.Name}</div></div>`;
|
||||
}
|
||||
|
@ -258,16 +254,6 @@ import template from './itemidentifier.template.html';
|
|||
return html;
|
||||
}
|
||||
|
||||
function getSearchImageDisplayUrl(url, provider) {
|
||||
const apiClient = getApiClient();
|
||||
|
||||
return apiClient.getUrl('Items/RemoteSearch/Image', {
|
||||
imageUrl: url,
|
||||
ProviderName: provider,
|
||||
api_key: apiClient.accessToken()
|
||||
});
|
||||
}
|
||||
|
||||
function submitIdentficationResult(page) {
|
||||
loading.show();
|
||||
|
||||
|
|
|
@ -15,6 +15,10 @@ function setLayout(instance, layout, selectedLayout) {
|
|||
}
|
||||
|
||||
class LayoutManager {
|
||||
tv = false;
|
||||
mobile = false;
|
||||
desktop = false;
|
||||
|
||||
setLayout(layout, save) {
|
||||
if (!layout || layout === 'auto') {
|
||||
this.autoLayout();
|
||||
|
|
|
@ -68,7 +68,11 @@ export function hide() {
|
|||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
show: show,
|
||||
hide: hide
|
||||
const loading = {
|
||||
show,
|
||||
hide
|
||||
};
|
||||
|
||||
window.Loading = loading;
|
||||
|
||||
export default loading;
|
||||
|
|
|
@ -78,14 +78,10 @@ import '../elements/emby-button/emby-button';
|
|||
}
|
||||
|
||||
export function setTabs(view, selectedIndex, getTabsFn, getTabContainersFn, onBeforeTabChange, onTabChange, setSelectedIndex) {
|
||||
if (!view) {
|
||||
if (tabOwnerView) {
|
||||
if (!headerTabsContainer) {
|
||||
headerTabsContainer = queryScope.querySelector('.headerTabs');
|
||||
}
|
||||
|
||||
ensureElements();
|
||||
|
||||
if (!view) {
|
||||
if (tabOwnerView) {
|
||||
document.body.classList.remove('withSectionTabs');
|
||||
|
||||
headerTabsContainer.innerHTML = '';
|
||||
|
@ -99,8 +95,6 @@ import '../elements/emby-button/emby-button';
|
|||
};
|
||||
}
|
||||
|
||||
ensureElements();
|
||||
|
||||
const tabsContainerElem = headerTabsContainer;
|
||||
|
||||
if (!tabOwnerView) {
|
||||
|
@ -178,18 +172,13 @@ import '../elements/emby-button/emby-button';
|
|||
|
||||
return {
|
||||
tabsContainer: tabsContainerElem,
|
||||
tabs: tabsContainerElem.querySelector('[is="emby-tabs"]'),
|
||||
tabs: tabsElem,
|
||||
replaced: true
|
||||
};
|
||||
}
|
||||
|
||||
if (!tabsElem) {
|
||||
tabsElem = tabsContainerElem.querySelector('[is="emby-tabs"]');
|
||||
}
|
||||
|
||||
tabsElem.selectedIndex(selectedIndex);
|
||||
|
||||
tabOwnerView = view;
|
||||
return {
|
||||
tabsContainer: tabsContainerElem,
|
||||
tabs: tabsElem,
|
||||
|
@ -198,12 +187,6 @@ import '../elements/emby-button/emby-button';
|
|||
}
|
||||
|
||||
export function selectedTabIndex(index) {
|
||||
const tabsContainerElem = headerTabsContainer;
|
||||
|
||||
if (!tabsElem) {
|
||||
tabsElem = tabsContainerElem.querySelector('[is="emby-tabs"]');
|
||||
}
|
||||
|
||||
if (index != null) {
|
||||
tabsElem.selectedIndex(index);
|
||||
} else {
|
||||
|
|
|
@ -330,8 +330,8 @@ import '../../elements/emby-button/emby-button';
|
|||
return null;
|
||||
}
|
||||
|
||||
export function getEndsAtFromPosition(runtimeTicks, positionTicks, includeText) {
|
||||
let endDate = new Date().getTime() + ((runtimeTicks - (positionTicks || 0)) / 10000);
|
||||
export function getEndsAtFromPosition(runtimeTicks, positionTicks, playbackRate, includeText) {
|
||||
let endDate = new Date().getTime() + (1 / playbackRate) * ((runtimeTicks - (positionTicks || 0)) / 10000);
|
||||
endDate = new Date(endDate);
|
||||
|
||||
const displayTime = datetime.getDisplayTime(endDate);
|
||||
|
|
|
@ -925,7 +925,9 @@ import template from './metadataEditor.template.html';
|
|||
html += '</div>';
|
||||
|
||||
if (person.Role && person.Role !== lastType) {
|
||||
html += '<div class="secondary">' + (person.Role) + '</div>';
|
||||
html += '<div class="secondary">' + person.Role + '</div>';
|
||||
} else {
|
||||
html += '<div class="secondary">' + globalize.translate(person.Type) + '</div>';
|
||||
}
|
||||
|
||||
html += '</button>';
|
||||
|
|
|
@ -20,6 +20,12 @@
|
|||
<option value="GuestStar">${GuestStar}</option>
|
||||
<option value="Producer">${Producer}</option>
|
||||
<option value="Writer">${Writer}</option>
|
||||
<option value="Conductor">${Conductor}</option>
|
||||
<option value="Lyricist">${Lyricist}</option>
|
||||
<option value="Arranger">${Arranger}</option>
|
||||
<option value="Engineer">${Engineer}</option>
|
||||
<option value="Mixer">${Mixer}</option>
|
||||
<option value="Remixer">${Remixer}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
|
|
42
src/components/pages/SearchPage.tsx
Normal file
42
src/components/pages/SearchPage.tsx
Normal file
|
@ -0,0 +1,42 @@
|
|||
import React, { FunctionComponent, useState } from 'react';
|
||||
|
||||
import SearchFields from '../search/SearchFields';
|
||||
import SearchResults from '../search/SearchResults';
|
||||
import SearchSuggestions from '../search/SearchSuggestions';
|
||||
import LiveTVSearchResults from '../search/LiveTVSearchResults';
|
||||
|
||||
type SearchProps = {
|
||||
serverId?: string,
|
||||
parentId?: string,
|
||||
collectionType?: string
|
||||
};
|
||||
|
||||
const SearchPage: FunctionComponent<SearchProps> = ({ serverId, parentId, collectionType }: SearchProps) => {
|
||||
const [ query, setQuery ] = useState(null);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SearchFields onSearch={setQuery} />
|
||||
{!query &&
|
||||
<SearchSuggestions
|
||||
serverId={serverId || window.ApiClient.serverId()}
|
||||
parentId={parentId}
|
||||
/>
|
||||
}
|
||||
<SearchResults
|
||||
serverId={serverId || window.ApiClient.serverId()}
|
||||
parentId={parentId}
|
||||
collectionType={collectionType}
|
||||
query={query}
|
||||
/>
|
||||
<LiveTVSearchResults
|
||||
serverId={serverId || window.ApiClient.serverId()}
|
||||
parentId={parentId}
|
||||
collectionType={collectionType}
|
||||
query={query}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default SearchPage;
|
|
@ -429,7 +429,7 @@ function getPlaybackInfo(player,
|
|||
enableDirectStream,
|
||||
allowVideoStreamCopy,
|
||||
allowAudioStreamCopy) {
|
||||
if (!itemHelper.isLocalItem(item) && item.MediaType === 'Audio') {
|
||||
if (!itemHelper.isLocalItem(item) && item.MediaType === 'Audio' && !player.useServerPlaybackInfoForAudio) {
|
||||
return Promise.resolve({
|
||||
MediaSources: [
|
||||
{
|
||||
|
@ -1692,7 +1692,7 @@ class PlaybackManager {
|
|||
if (validatePlaybackInfoResult(self, result)) {
|
||||
currentMediaSource = result.MediaSources[0];
|
||||
|
||||
const streamInfo = createStreamInfo(apiClient, currentItem.MediaType, currentItem, currentMediaSource, ticks);
|
||||
const streamInfo = createStreamInfo(apiClient, currentItem.MediaType, currentItem, currentMediaSource, ticks, player);
|
||||
streamInfo.fullscreen = currentPlayOptions.fullscreen;
|
||||
streamInfo.lastMediaInfoQuery = lastMediaInfoQuery;
|
||||
|
||||
|
@ -1960,6 +1960,7 @@ class PlaybackManager {
|
|||
|
||||
state.PlayState.PositionTicks = getCurrentTicks(player);
|
||||
state.PlayState.PlaybackStartTimeTicks = self.playbackStartTime(player);
|
||||
state.PlayState.PlaybackRate = self.getPlaybackRate(player);
|
||||
|
||||
state.PlayState.SubtitleStreamIndex = self.getSubtitleStreamIndex(player);
|
||||
state.PlayState.AudioStreamIndex = self.getAudioStreamIndex(player);
|
||||
|
@ -2153,7 +2154,7 @@ class PlaybackManager {
|
|||
}, onInterceptorRejection);
|
||||
}
|
||||
|
||||
function onInterceptorRejection() {
|
||||
function cancelPlayback() {
|
||||
const player = self._currentPlayer;
|
||||
|
||||
if (player) {
|
||||
|
@ -2162,7 +2163,10 @@ class PlaybackManager {
|
|||
}
|
||||
|
||||
Events.trigger(self, 'playbackcancelled');
|
||||
}
|
||||
|
||||
function onInterceptorRejection() {
|
||||
cancelPlayback();
|
||||
return Promise.reject();
|
||||
}
|
||||
|
||||
|
@ -2335,6 +2339,15 @@ class PlaybackManager {
|
|||
promise = Promise.resolve();
|
||||
}
|
||||
|
||||
if (!player) {
|
||||
return promise.then(() => {
|
||||
cancelPlayback();
|
||||
loading.hide();
|
||||
console.error(`No player found for the requested media: ${item.Url}`);
|
||||
showPlaybackInfoErrorMessage(self, 'ErrorPlayerNotFound');
|
||||
});
|
||||
}
|
||||
|
||||
if (!isServerItem(item) || item.MediaType === 'Book') {
|
||||
return promise.then(function () {
|
||||
const streamInfo = createStreamInfoFromUrlItem(item);
|
||||
|
@ -2372,7 +2385,7 @@ class PlaybackManager {
|
|||
if (userSettings.enableSetUsingLastTracks())
|
||||
autoSetNextTracks(prevSource, mediaSource);
|
||||
|
||||
const streamInfo = createStreamInfo(apiClient, item.MediaType, item, mediaSource, startPosition);
|
||||
const streamInfo = createStreamInfo(apiClient, item.MediaType, item, mediaSource, startPosition, player);
|
||||
|
||||
streamInfo.fullscreen = playOptions.fullscreen;
|
||||
|
||||
|
@ -2411,7 +2424,7 @@ class PlaybackManager {
|
|||
|
||||
return player.getDeviceProfile(item).then(function (deviceProfile) {
|
||||
return getPlaybackMediaSource(player, apiClient, deviceProfile, maxBitrate, item, startPosition, options.mediaSourceId, options.audioStreamIndex, options.subtitleStreamIndex).then(function (mediaSource) {
|
||||
return createStreamInfo(apiClient, item.MediaType, item, mediaSource, startPosition);
|
||||
return createStreamInfo(apiClient, item.MediaType, item, mediaSource, startPosition, player);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -2437,7 +2450,7 @@ class PlaybackManager {
|
|||
});
|
||||
};
|
||||
|
||||
function createStreamInfo(apiClient, type, item, mediaSource, startPosition) {
|
||||
function createStreamInfo(apiClient, type, item, mediaSource, startPosition, player) {
|
||||
let mediaUrl;
|
||||
let contentType;
|
||||
let transcodingOffsetTicks = 0;
|
||||
|
@ -2449,6 +2462,14 @@ class PlaybackManager {
|
|||
const mediaSourceContainer = (mediaSource.Container || '').toLowerCase();
|
||||
let directOptions;
|
||||
|
||||
if (mediaSource.MediaStreams && player.useFullSubtitleUrls) {
|
||||
mediaSource.MediaStreams.forEach(stream => {
|
||||
if (stream.DeliveryUrl && stream.DeliveryUrl.startsWith('/')) {
|
||||
stream.DeliveryUrl = apiClient.getUrl(stream.DeliveryUrl);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (type === 'Video' || type === 'Audio') {
|
||||
contentType = getMimeType(type.toLowerCase(), mediaSourceContainer);
|
||||
|
||||
|
@ -3118,6 +3139,9 @@ class PlaybackManager {
|
|||
}
|
||||
|
||||
return promise.then(function () {
|
||||
// Clear the data since we were not listening 'stopped'
|
||||
getPlayerData(activePlayer).streamInfo = null;
|
||||
|
||||
bindStopped(activePlayer);
|
||||
|
||||
if (enableLocalPlaylistManagement(activePlayer)) {
|
||||
|
|
|
@ -173,6 +173,12 @@ import ServerConnections from '../ServerConnections';
|
|||
value: session.TranscodingInfo.TranscodeReasons.map(translateReason).join('<br/>')
|
||||
});
|
||||
}
|
||||
if (session.TranscodingInfo.HardwareAccelerationType) {
|
||||
sessionStats.push({
|
||||
label: globalize.translate('LabelHardwareEncoding'),
|
||||
value: session.TranscodingInfo.HardwareAccelerationType
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return sessionStats;
|
||||
|
|
|
@ -3,6 +3,11 @@ import globalize from '../scripts/globalize';
|
|||
import loading from './loading/loading';
|
||||
import appSettings from '../scripts/settings/appSettings';
|
||||
import { playbackManager } from './playback/playbackmanager';
|
||||
import { appHost } from '../components/apphost';
|
||||
import { appRouter } from '../components/appRouter';
|
||||
import * as inputManager from '../scripts/inputManager';
|
||||
import toast from '../components/toast/toast';
|
||||
import confirm from '../components/confirm/confirm';
|
||||
|
||||
/* eslint-disable indent */
|
||||
|
||||
|
@ -90,7 +95,13 @@ import { playbackManager } from './playback/playbackmanager';
|
|||
events: Events,
|
||||
loading,
|
||||
appSettings,
|
||||
playbackManager
|
||||
playbackManager,
|
||||
globalize,
|
||||
appHost,
|
||||
appRouter,
|
||||
inputManager,
|
||||
toast,
|
||||
confirm
|
||||
});
|
||||
} else {
|
||||
console.debug(`Loading plugin (via dynamic import): ${pluginSpec}`);
|
||||
|
|
17
src/components/reactControllerFactory.js
Normal file
17
src/components/reactControllerFactory.js
Normal file
|
@ -0,0 +1,17 @@
|
|||
import React from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
|
||||
export default (view, params, { detail }) => {
|
||||
if (detail.options?.pageComponent) {
|
||||
// Fetch and render the page component to the view
|
||||
import(/* webpackChunkName: "[request]" */ `./pages/${detail.options.pageComponent}`)
|
||||
.then(({ default: component }) => {
|
||||
ReactDOM.render(React.createElement(component, params), view);
|
||||
});
|
||||
|
||||
// Unmount component when view is destroyed
|
||||
view.addEventListener('viewdestroy', () => {
|
||||
ReactDOM.unmountComponentAtNode(view);
|
||||
});
|
||||
}
|
||||
};
|
|
@ -225,10 +225,6 @@ function onRecordChange(e) {
|
|||
}
|
||||
}
|
||||
|
||||
function sendToast(msg) {
|
||||
toast(msg);
|
||||
}
|
||||
|
||||
function onRecordSeriesChange(e) {
|
||||
this.changed = true;
|
||||
|
||||
|
@ -252,7 +248,7 @@ function onRecordSeriesChange(e) {
|
|||
} else {
|
||||
if (this.SeriesTimerId) {
|
||||
apiClient.cancelLiveTvSeriesTimer(this.SeriesTimerId).then(function () {
|
||||
sendToast(globalize.translate('RecordingCancelled'));
|
||||
toast(globalize.translate('RecordingCancelled'));
|
||||
fetchData(self);
|
||||
});
|
||||
}
|
||||
|
|
|
@ -16,7 +16,7 @@ function changeRecordingToSeries(apiClient, timerId, programId, confirmTimerCanc
|
|||
return apiClient.getNewLiveTvTimerDefaults({ programId: programId }).then(function (timerDefaults) {
|
||||
return apiClient.createLiveTvSeriesTimer(timerDefaults).then(function () {
|
||||
loading.hide();
|
||||
sendToast(globalize.translate('SeriesRecordingScheduled'));
|
||||
toast(globalize.translate('SeriesRecordingScheduled'));
|
||||
});
|
||||
});
|
||||
} else {
|
||||
|
@ -76,7 +76,7 @@ function cancelTimer(apiClient, timerId, hideLoading) {
|
|||
return apiClient.cancelLiveTvTimer(timerId).then(function () {
|
||||
if (hideLoading !== false) {
|
||||
loading.hide();
|
||||
sendToast(globalize.translate('RecordingCancelled'));
|
||||
toast(globalize.translate('RecordingCancelled'));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
@ -90,15 +90,11 @@ function createRecording(apiClient, programId, isSeries) {
|
|||
|
||||
return promise.then(function () {
|
||||
loading.hide();
|
||||
sendToast(globalize.translate('RecordingScheduled'));
|
||||
toast(globalize.translate('RecordingScheduled'));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function sendToast(msg) {
|
||||
toast(msg);
|
||||
}
|
||||
|
||||
function showMultiCancellationPrompt(serverId, programId, timerId, timerStatus, seriesTimerId) {
|
||||
return new Promise(function (resolve, reject) {
|
||||
const items = [];
|
||||
|
|
|
@ -865,7 +865,7 @@ export default function () {
|
|||
}, currentPlayer);
|
||||
form.querySelector('input').value = '';
|
||||
|
||||
toast('Message sent.');
|
||||
toast(globalize.translate('MessageSent'));
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
@ -882,7 +882,7 @@ export default function () {
|
|||
}, currentPlayer);
|
||||
form.querySelector('input').value = '';
|
||||
|
||||
toast('Text sent.');
|
||||
toast(globalize.translate('TextSent'));
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
|
|
@ -388,7 +388,7 @@
|
|||
.btnPlayPause {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
font-size: 1.7em;
|
||||
font-size: 2em;
|
||||
}
|
||||
|
||||
.nowPlayingPageImage {
|
||||
|
|
|
@ -173,6 +173,15 @@ import layoutManager from './layoutManager';
|
|||
return Math.min(document.documentElement.clientHeight, document.body.clientHeight);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns attribute value.
|
||||
* @param {string} attributeName - Attibute name.
|
||||
* @return {string} Attibute value.
|
||||
*/
|
||||
getAttribute(attributeName) {
|
||||
return document.body.getAttribute(attributeName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns bounding client rect.
|
||||
* @return {Rect} Bounding client rect.
|
||||
|
@ -201,6 +210,21 @@ import layoutManager from './layoutManager';
|
|||
*/
|
||||
const documentScroller = new DocumentScroller();
|
||||
|
||||
const scrollerHints = {
|
||||
x: {
|
||||
nameScroll: 'scrollWidth',
|
||||
nameClient: 'clientWidth',
|
||||
nameStyle: 'overflowX',
|
||||
nameScrollMode: 'data-scroll-mode-x'
|
||||
},
|
||||
y: {
|
||||
nameScroll: 'scrollHeight',
|
||||
nameClient: 'clientHeight',
|
||||
nameStyle: 'overflowY',
|
||||
nameScrollMode: 'data-scroll-mode-y'
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns parent element that can be scrolled. If no such, returns document scroller.
|
||||
*
|
||||
|
@ -210,22 +234,28 @@ import layoutManager from './layoutManager';
|
|||
*/
|
||||
function getScrollableParent(element, vertical) {
|
||||
if (element) {
|
||||
let nameScroll = 'scrollWidth';
|
||||
let nameClient = 'clientWidth';
|
||||
let nameClass = 'scrollX';
|
||||
|
||||
if (vertical) {
|
||||
nameScroll = 'scrollHeight';
|
||||
nameClient = 'clientHeight';
|
||||
nameClass = 'scrollY';
|
||||
}
|
||||
const scrollerHint = vertical ? scrollerHints.y : scrollerHints.x;
|
||||
|
||||
let parent = element.parentElement;
|
||||
|
||||
while (parent) {
|
||||
// Skip 'emby-scroller' because it scrolls by itself
|
||||
if (!parent.classList.contains('emby-scroller') &&
|
||||
parent[nameScroll] > parent[nameClient] && parent.classList.contains(nameClass)) {
|
||||
while (parent && parent !== document.body) {
|
||||
const scrollMode = parent.getAttribute(scrollerHint.nameScrollMode);
|
||||
|
||||
// Stop on self-scrolled containers
|
||||
if (scrollMode === 'custom') {
|
||||
return parent;
|
||||
}
|
||||
|
||||
const styles = window.getComputedStyle(parent);
|
||||
|
||||
// Stop on fixed parent
|
||||
if (styles.position === 'fixed') {
|
||||
return parent;
|
||||
}
|
||||
|
||||
const overflow = styles[scrollerHint.nameStyle];
|
||||
|
||||
if (overflow === 'scroll' || overflow === 'auto' && parent[scrollerHint.nameScroll] > parent[scrollerHint.nameClient]) {
|
||||
return parent;
|
||||
}
|
||||
|
||||
|
@ -241,6 +271,8 @@ import layoutManager from './layoutManager';
|
|||
* @property {number} scrollPos - Current scroll position.
|
||||
* @property {number} scrollSize - Scroll size.
|
||||
* @property {number} clientSize - Client size.
|
||||
* @property {string} mode - Scrolling mode.
|
||||
* @property {boolean} custom - Custom scrolling mode.
|
||||
*/
|
||||
|
||||
/**
|
||||
|
@ -257,12 +289,16 @@ import layoutManager from './layoutManager';
|
|||
data.scrollPos = scroller.scrollLeft;
|
||||
data.scrollSize = scroller.scrollWidth;
|
||||
data.clientSize = scroller.clientWidth;
|
||||
data.mode = scroller.getAttribute(scrollerHints.x.nameScrollMode);
|
||||
} else {
|
||||
data.scrollPos = scroller.scrollTop;
|
||||
data.scrollSize = scroller.scrollHeight;
|
||||
data.clientSize = scroller.clientHeight;
|
||||
data.mode = scroller.getAttribute(scrollerHints.y.nameScrollMode);
|
||||
}
|
||||
|
||||
data.custom = data.mode === 'custom';
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
|
@ -347,9 +383,13 @@ import layoutManager from './layoutManager';
|
|||
const scrollBehavior = smooth ? 'smooth' : 'instant';
|
||||
|
||||
if (xScroller !== yScroller) {
|
||||
if (xScroller) {
|
||||
scrollToHelper(xScroller, {left: scrollX, behavior: scrollBehavior});
|
||||
}
|
||||
if (yScroller) {
|
||||
scrollToHelper(yScroller, {top: scrollY, behavior: scrollBehavior});
|
||||
} else {
|
||||
}
|
||||
} else if (xScroller) {
|
||||
scrollToHelper(xScroller, {left: scrollX, top: scrollY, behavior: scrollBehavior});
|
||||
}
|
||||
}
|
||||
|
@ -376,8 +416,8 @@ import layoutManager from './layoutManager';
|
|||
* @param {number} scrollY - Vertical coordinate.
|
||||
*/
|
||||
function animateScroll(xScroller, scrollX, yScroller, scrollY) {
|
||||
const ox = xScroller.scrollLeft;
|
||||
const oy = yScroller.scrollTop;
|
||||
const ox = xScroller ? xScroller.scrollLeft : scrollX;
|
||||
const oy = yScroller ? yScroller.scrollTop : scrollY;
|
||||
const dx = scrollX - ox;
|
||||
const dy = scrollY - oy;
|
||||
|
||||
|
@ -501,19 +541,37 @@ import layoutManager from './layoutManager';
|
|||
scrollCenterX = scrollCenterY = false;
|
||||
}
|
||||
|
||||
const xScroller = getScrollableParent(element, false);
|
||||
const yScroller = getScrollableParent(element, true);
|
||||
|
||||
const elementRect = element.getBoundingClientRect();
|
||||
let xScroller = getScrollableParent(element, false);
|
||||
let yScroller = getScrollableParent(element, true);
|
||||
|
||||
const xScrollerData = getScrollerData(xScroller, false);
|
||||
const yScrollerData = getScrollerData(yScroller, true);
|
||||
|
||||
const xPos = getScrollerChildPos(xScroller, element, false);
|
||||
const yPos = getScrollerChildPos(yScroller, element, true);
|
||||
// Exit, since we have no control over scrolling in this container
|
||||
if (xScroller === yScroller && (xScrollerData.custom || yScrollerData.custom)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const scrollX = calcScroll(xScrollerData, xPos, elementRect.width, scrollCenterX);
|
||||
let scrollY = calcScroll(yScrollerData, yPos, elementRect.height, scrollCenterY);
|
||||
// Exit, since we have no control over scrolling in these containers
|
||||
if (xScrollerData.custom && yScrollerData.custom) {
|
||||
return;
|
||||
}
|
||||
|
||||
const elementRect = element.getBoundingClientRect();
|
||||
|
||||
let scrollX = 0;
|
||||
let scrollY = 0;
|
||||
|
||||
if (!xScrollerData.custom) {
|
||||
const xPos = getScrollerChildPos(xScroller, element, false);
|
||||
scrollX = calcScroll(xScrollerData, xPos, elementRect.width, scrollCenterX);
|
||||
} else {
|
||||
xScroller = null;
|
||||
}
|
||||
|
||||
if (!yScrollerData.custom) {
|
||||
const yPos = getScrollerChildPos(yScroller, element, true);
|
||||
scrollY = calcScroll(yScrollerData, yPos, elementRect.height, scrollCenterY);
|
||||
|
||||
// HACK: Scroll to top for top menu because it is hidden
|
||||
// FIXME: Need a marker to scroll top/bottom
|
||||
|
@ -526,6 +584,9 @@ import layoutManager from './layoutManager';
|
|||
if (scrollY < minimumScrollY() && yScroller === documentScroller) {
|
||||
scrollY = 0;
|
||||
}
|
||||
} else {
|
||||
yScroller = null;
|
||||
}
|
||||
|
||||
doScroll(xScroller, scrollX, yScroller, scrollY, smooth);
|
||||
}
|
||||
|
|
189
src/components/search/LiveTVSearchResults.tsx
Normal file
189
src/components/search/LiveTVSearchResults.tsx
Normal file
|
@ -0,0 +1,189 @@
|
|||
import classNames from 'classnames';
|
||||
import React, { FunctionComponent, useEffect, useState } from 'react';
|
||||
|
||||
import globalize from '../../scripts/globalize';
|
||||
import ServerConnections from '../ServerConnections';
|
||||
import SearchResultsRow from './SearchResultsRow';
|
||||
|
||||
const CARD_OPTIONS = {
|
||||
preferThumb: true,
|
||||
inheritThumb: false,
|
||||
showParentTitleOrTitle: true,
|
||||
showTitle: false,
|
||||
coverImage: true,
|
||||
overlayMoreButton: true,
|
||||
showAirTime: true,
|
||||
showAirDateTime: true,
|
||||
showChannelName: true
|
||||
};
|
||||
|
||||
type LiveTVSearchResultsProps = {
|
||||
serverId?: string;
|
||||
parentId?: string;
|
||||
collectionType?: string;
|
||||
query?: string;
|
||||
}
|
||||
|
||||
/*
|
||||
* React component to display search result rows for live tv library search
|
||||
*/
|
||||
const LiveTVSearchResults: FunctionComponent<LiveTVSearchResultsProps> = ({ serverId, parentId, collectionType, query }: LiveTVSearchResultsProps) => {
|
||||
const [ movies, setMovies ] = useState([]);
|
||||
const [ episodes, setEpisodes ] = useState([]);
|
||||
const [ sports, setSports ] = useState([]);
|
||||
const [ kids, setKids ] = useState([]);
|
||||
const [ news, setNews ] = useState([]);
|
||||
const [ programs, setPrograms ] = useState([]);
|
||||
const [ channels, setChannels ] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
const getDefaultParameters = () => ({
|
||||
ParentId: parentId,
|
||||
searchTerm: query,
|
||||
Limit: 24,
|
||||
Fields: 'PrimaryImageAspectRatio,CanDelete,BasicSyncInfo,MediaSourceCount',
|
||||
Recursive: true,
|
||||
EnableTotalRecordCount: false,
|
||||
ImageTypeLimit: 1,
|
||||
IncludePeople: false,
|
||||
IncludeMedia: false,
|
||||
IncludeGenres: false,
|
||||
IncludeStudios: false,
|
||||
IncludeArtists: false
|
||||
});
|
||||
|
||||
// FIXME: This query does not support Live TV filters
|
||||
const fetchItems = (apiClient, params = {}) => apiClient?.getItems(
|
||||
apiClient?.getCurrentUserId(),
|
||||
{
|
||||
...getDefaultParameters(),
|
||||
IncludeMedia: true,
|
||||
...params
|
||||
}
|
||||
);
|
||||
|
||||
// Reset state
|
||||
setMovies([]);
|
||||
setEpisodes([]);
|
||||
setSports([]);
|
||||
setKids([]);
|
||||
setNews([]);
|
||||
setPrograms([]);
|
||||
setChannels([]);
|
||||
|
||||
if (query && collectionType === 'livetv') {
|
||||
// TODO: Remove type casting once we're using a properly typed API client
|
||||
const apiClient = (ServerConnections as any).getApiClient(serverId);
|
||||
|
||||
// Movies row
|
||||
fetchItems(apiClient, {
|
||||
IncludeItemTypes: 'LiveTvProgram',
|
||||
IsMovie: true,
|
||||
IsSeries: false,
|
||||
IsSports: false,
|
||||
IsKids: false,
|
||||
IsNews: false
|
||||
}).then(result => setMovies(result.Items));
|
||||
// Episodes row
|
||||
fetchItems(apiClient, {
|
||||
IncludeItemTypes: 'LiveTvProgram',
|
||||
IsMovie: false,
|
||||
IsSeries: true,
|
||||
IsSports: false,
|
||||
IsKids: false,
|
||||
IsNews: false
|
||||
}).then(result => setEpisodes(result.Items));
|
||||
// Sports row
|
||||
fetchItems(apiClient, {
|
||||
IncludeItemTypes: 'LiveTvProgram',
|
||||
IsMovie: false,
|
||||
IsSeries: false,
|
||||
IsSports: true,
|
||||
IsKids: false,
|
||||
IsNews: false
|
||||
}).then(result => setSports(result.Items));
|
||||
// Kids row
|
||||
fetchItems(apiClient, {
|
||||
IncludeItemTypes: 'LiveTvProgram',
|
||||
IsMovie: false,
|
||||
IsSeries: false,
|
||||
IsSports: false,
|
||||
IsKids: true,
|
||||
IsNews: false
|
||||
}).then(result => setKids(result.Items));
|
||||
// News row
|
||||
fetchItems(apiClient, {
|
||||
IncludeItemTypes: 'LiveTvProgram',
|
||||
IsMovie: false,
|
||||
IsSeries: false,
|
||||
IsSports: false,
|
||||
IsKids: false,
|
||||
IsNews: true
|
||||
}).then(result => setNews(result.Items));
|
||||
// Programs row
|
||||
fetchItems(apiClient, {
|
||||
IncludeItemTypes: 'LiveTvProgram',
|
||||
IsMovie: false,
|
||||
IsSeries: false,
|
||||
IsSports: false,
|
||||
IsKids: false,
|
||||
IsNews: false
|
||||
}).then(result => setPrograms(result.Items));
|
||||
// Channels row
|
||||
fetchItems(apiClient, { IncludeItemTypes: 'TvChannel' })
|
||||
.then(result => setChannels(result.Items));
|
||||
}
|
||||
}, [collectionType, parentId, query, serverId]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
'searchResults',
|
||||
'padded-bottom-page',
|
||||
'padded-top',
|
||||
{ 'hide': !query || !(collectionType === 'livetv') }
|
||||
)}
|
||||
>
|
||||
<SearchResultsRow
|
||||
title={globalize.translate('Movies')}
|
||||
items={movies}
|
||||
cardOptions={{
|
||||
...CARD_OPTIONS,
|
||||
shape: 'overflowPortrait'
|
||||
}}
|
||||
/>
|
||||
<SearchResultsRow
|
||||
title={globalize.translate('Episodes')}
|
||||
items={episodes}
|
||||
cardOptions={CARD_OPTIONS}
|
||||
/>
|
||||
<SearchResultsRow
|
||||
title={globalize.translate('Sports')}
|
||||
items={sports}
|
||||
cardOptions={CARD_OPTIONS}
|
||||
/>
|
||||
<SearchResultsRow
|
||||
title={globalize.translate('Kids')}
|
||||
items={kids}
|
||||
cardOptions={CARD_OPTIONS}
|
||||
/>
|
||||
<SearchResultsRow
|
||||
title={globalize.translate('News')}
|
||||
items={news}
|
||||
cardOptions={CARD_OPTIONS}
|
||||
/>
|
||||
<SearchResultsRow
|
||||
title={globalize.translate('Programs')}
|
||||
items={programs}
|
||||
cardOptions={CARD_OPTIONS}
|
||||
/>
|
||||
<SearchResultsRow
|
||||
title={globalize.translate('Channels')}
|
||||
items={channels}
|
||||
cardOptions={{ shape: 'square' }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LiveTVSearchResults;
|
90
src/components/search/SearchFields.tsx
Normal file
90
src/components/search/SearchFields.tsx
Normal file
|
@ -0,0 +1,90 @@
|
|||
import debounce from 'lodash-es/debounce';
|
||||
import React, { FunctionComponent, useEffect, useMemo, useRef } from 'react';
|
||||
|
||||
import AlphaPicker from '../alphaPicker/AlphaPickerComponent';
|
||||
import globalize from '../../scripts/globalize';
|
||||
|
||||
import 'material-design-icons-iconfont';
|
||||
|
||||
import '../../elements/emby-input/emby-input';
|
||||
import '../../assets/css/flexstyles.scss';
|
||||
import './searchfields.scss';
|
||||
import layoutManager from '../layoutManager';
|
||||
import browser from '../../scripts/browser';
|
||||
|
||||
// There seems to be some compatibility issues here between
|
||||
// React and our legacy web components, so we need to inject
|
||||
// them as an html string for now =/
|
||||
const createInputElement = () => ({
|
||||
__html: `<input
|
||||
is="emby-input"
|
||||
class="searchfields-txtSearch"
|
||||
type="text"
|
||||
data-keyboard="true"
|
||||
placeholder="${globalize.translate('Search')}"
|
||||
autocomplete="off"
|
||||
maxlength="40"
|
||||
autofocus
|
||||
/>`
|
||||
});
|
||||
|
||||
const normalizeInput = (value = '') => value.trim();
|
||||
|
||||
type SearchFieldsProps = {
|
||||
onSearch?: () => void
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
const SearchFields: FunctionComponent<SearchFieldsProps> = ({ onSearch = () => {} }: SearchFieldsProps) => {
|
||||
const element = useRef(null);
|
||||
|
||||
const getSearchInput = () => element?.current?.querySelector('.searchfields-txtSearch');
|
||||
|
||||
const debouncedOnSearch = useMemo(() => debounce(onSearch, 400), [onSearch]);
|
||||
|
||||
useEffect(() => {
|
||||
getSearchInput()?.addEventListener('input', e => {
|
||||
debouncedOnSearch(normalizeInput(e.target?.value));
|
||||
});
|
||||
getSearchInput()?.focus();
|
||||
|
||||
return () => {
|
||||
debouncedOnSearch.cancel();
|
||||
};
|
||||
}, [debouncedOnSearch]);
|
||||
|
||||
const onAlphaPicked = e => {
|
||||
const value = e.detail.value;
|
||||
const searchInput = getSearchInput();
|
||||
|
||||
if (value === 'backspace') {
|
||||
const currentValue = searchInput.value;
|
||||
searchInput.value = currentValue.length ? currentValue.substring(0, currentValue.length - 1) : '';
|
||||
} else {
|
||||
searchInput.value += value;
|
||||
}
|
||||
|
||||
searchInput.dispatchEvent(new CustomEvent('input', { bubbles: true }));
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className='padded-left padded-right searchFields'
|
||||
ref={element}
|
||||
>
|
||||
<div className='searchFieldsInner flex align-items-center justify-content-center'>
|
||||
<span className='searchfields-icon material-icons search' />
|
||||
<div
|
||||
className='inputContainer flex-grow'
|
||||
style={{ marginBottom: 0 }}
|
||||
dangerouslySetInnerHTML={createInputElement()}
|
||||
/>
|
||||
</div>
|
||||
{layoutManager.tv && !browser.tv &&
|
||||
<AlphaPicker onAlphaPicked={onAlphaPicked} />
|
||||
}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SearchFields;
|
268
src/components/search/SearchResults.tsx
Normal file
268
src/components/search/SearchResults.tsx
Normal file
|
@ -0,0 +1,268 @@
|
|||
import classNames from 'classnames';
|
||||
import React, { FunctionComponent, useEffect, useState } from 'react';
|
||||
|
||||
import globalize from '../../scripts/globalize';
|
||||
import ServerConnections from '../ServerConnections';
|
||||
import SearchResultsRow from './SearchResultsRow';
|
||||
|
||||
type SearchResultsProps = {
|
||||
serverId?: string;
|
||||
parentId?: string;
|
||||
collectionType?: string;
|
||||
query?: string;
|
||||
}
|
||||
|
||||
/*
|
||||
* React component to display search result rows for global search and non-live tv library search
|
||||
*/
|
||||
const SearchResults: FunctionComponent<SearchResultsProps> = ({ serverId, parentId, collectionType, query }: SearchResultsProps) => {
|
||||
const [ movies, setMovies ] = useState([]);
|
||||
const [ shows, setShows ] = useState([]);
|
||||
const [ episodes, setEpisodes ] = useState([]);
|
||||
const [ videos, setVideos ] = useState([]);
|
||||
const [ programs, setPrograms ] = useState([]);
|
||||
const [ channels, setChannels ] = useState([]);
|
||||
const [ playlists, setPlaylists ] = useState([]);
|
||||
const [ artists, setArtists ] = useState([]);
|
||||
const [ albums, setAlbums ] = useState([]);
|
||||
const [ songs, setSongs ] = useState([]);
|
||||
const [ photoAlbums, setPhotoAlbums ] = useState([]);
|
||||
const [ photos, setPhotos ] = useState([]);
|
||||
const [ audioBooks, setAudioBooks ] = useState([]);
|
||||
const [ books, setBooks ] = useState([]);
|
||||
const [ people, setPeople ] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
const getDefaultParameters = () => ({
|
||||
ParentId: parentId,
|
||||
searchTerm: query,
|
||||
Limit: 24,
|
||||
Fields: 'PrimaryImageAspectRatio,CanDelete,BasicSyncInfo,MediaSourceCount',
|
||||
Recursive: true,
|
||||
EnableTotalRecordCount: false,
|
||||
ImageTypeLimit: 1,
|
||||
IncludePeople: false,
|
||||
IncludeMedia: false,
|
||||
IncludeGenres: false,
|
||||
IncludeStudios: false,
|
||||
IncludeArtists: false
|
||||
});
|
||||
|
||||
const fetchArtists = (apiClient, params = {}) => apiClient?.getArtists(
|
||||
apiClient?.getCurrentUserId(),
|
||||
{
|
||||
...getDefaultParameters(),
|
||||
IncludeArtists: true,
|
||||
...params
|
||||
}
|
||||
);
|
||||
|
||||
const fetchItems = (apiClient, params = {}) => apiClient?.getItems(
|
||||
apiClient?.getCurrentUserId(),
|
||||
{
|
||||
...getDefaultParameters(),
|
||||
IncludeMedia: true,
|
||||
...params
|
||||
}
|
||||
);
|
||||
|
||||
const fetchPeople = (apiClient, params = {}) => apiClient?.getPeople(
|
||||
apiClient?.getCurrentUserId(),
|
||||
{
|
||||
...getDefaultParameters(),
|
||||
IncludePeople: true,
|
||||
...params
|
||||
}
|
||||
);
|
||||
|
||||
const isMovies = () => collectionType === 'movies';
|
||||
|
||||
const isMusic = () => collectionType === 'music';
|
||||
|
||||
const isTVShows = () => collectionType === 'tvshows' || collectionType === 'tv';
|
||||
|
||||
// Reset state
|
||||
setMovies([]);
|
||||
setShows([]);
|
||||
setEpisodes([]);
|
||||
setVideos([]);
|
||||
setPrograms([]);
|
||||
setChannels([]);
|
||||
setPlaylists([]);
|
||||
setArtists([]);
|
||||
setAlbums([]);
|
||||
setSongs([]);
|
||||
setPhotoAlbums([]);
|
||||
setPhotos([]);
|
||||
setAudioBooks([]);
|
||||
setBooks([]);
|
||||
setPeople([]);
|
||||
|
||||
if (query) {
|
||||
// TODO: Remove type casting once we're using a properly typed API client
|
||||
const apiClient = (ServerConnections as any).getApiClient(serverId);
|
||||
|
||||
// Movie libraries
|
||||
if (!collectionType || isMovies()) {
|
||||
// Movies row
|
||||
fetchItems(apiClient, { IncludeItemTypes: 'Movie' })
|
||||
.then(result => setMovies(result.Items));
|
||||
}
|
||||
|
||||
// TV Show libraries
|
||||
if (!collectionType || isTVShows()) {
|
||||
// Shows row
|
||||
fetchItems(apiClient, { IncludeItemTypes: 'Series' })
|
||||
.then(result => setShows(result.Items));
|
||||
// Episodes row
|
||||
fetchItems(apiClient, { IncludeItemTypes: 'Episode' })
|
||||
.then(result => setEpisodes(result.Items));
|
||||
}
|
||||
|
||||
// People are included for Movies and TV Shows
|
||||
if (!collectionType || isMovies() || isTVShows()) {
|
||||
// People row
|
||||
fetchPeople(apiClient).then(result => setPeople(result.Items));
|
||||
}
|
||||
|
||||
// Music libraries
|
||||
if (!collectionType || isMusic()) {
|
||||
// Playlists row
|
||||
fetchItems(apiClient, { IncludeItemTypes: 'Playlist' })
|
||||
.then(results => setPlaylists(results.Items));
|
||||
// Artists row
|
||||
fetchArtists(apiClient).then(result => setArtists(result.Items));
|
||||
// Albums row
|
||||
fetchItems(apiClient, { IncludeItemTypes: 'MusicAlbum' })
|
||||
.then(result => setAlbums(result.Items));
|
||||
// Songs row
|
||||
fetchItems(apiClient, { IncludeItemTypes: 'Audio' })
|
||||
.then(result => setSongs(result.Items));
|
||||
}
|
||||
|
||||
// Other libraries do not support in-library search currently
|
||||
if (!collectionType) {
|
||||
// Videos row
|
||||
fetchItems(apiClient, {
|
||||
MediaTypes: 'Video',
|
||||
ExcludeItemTypes: 'Movie,Episode,TvChannel'
|
||||
}).then(result => setVideos(result.Items));
|
||||
// Programs row
|
||||
fetchItems(apiClient, { IncludeItemTypes: 'LiveTvProgram' })
|
||||
.then(result => setPrograms(result.Items));
|
||||
// Channels row
|
||||
fetchItems(apiClient, { IncludeItemTypes: 'TvChannel' })
|
||||
.then(result => setChannels(result.Items));
|
||||
// Photo Albums row
|
||||
fetchItems(apiClient, { IncludeItemTypes: 'PhotoAlbum' })
|
||||
.then(results => setPhotoAlbums(results.Items));
|
||||
// Photos row
|
||||
fetchItems(apiClient, { IncludeItemTypes: 'Photo' })
|
||||
.then(results => setPhotos(results.Items));
|
||||
// Audio Books row
|
||||
fetchItems(apiClient, { IncludeItemTypes: 'AudioBook' })
|
||||
.then(results => setAudioBooks(results.Items));
|
||||
// Books row
|
||||
fetchItems(apiClient, { IncludeItemTypes: 'Book' })
|
||||
.then(results => setBooks(results.Items));
|
||||
}
|
||||
}
|
||||
}, [collectionType, parentId, query, serverId]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
'searchResults',
|
||||
'padded-bottom-page',
|
||||
'padded-top',
|
||||
{ 'hide': !query || collectionType === 'livetv' }
|
||||
)}
|
||||
>
|
||||
<SearchResultsRow
|
||||
title={globalize.translate('Movies')}
|
||||
items={movies}
|
||||
cardOptions={{ showYear: true }}
|
||||
/>
|
||||
<SearchResultsRow
|
||||
title={globalize.translate('Shows')}
|
||||
items={shows}
|
||||
cardOptions={{ showYear: true }}
|
||||
/>
|
||||
<SearchResultsRow
|
||||
title={globalize.translate('Episodes')}
|
||||
items={episodes}
|
||||
cardOptions={{
|
||||
coverImage: true,
|
||||
showParentTitle: true
|
||||
}}
|
||||
/>
|
||||
<SearchResultsRow
|
||||
title={globalize.translate('HeaderVideos')}
|
||||
items={videos}
|
||||
cardOptions={{ showParentTitle: true }}
|
||||
/>
|
||||
<SearchResultsRow
|
||||
title={globalize.translate('Programs')}
|
||||
items={programs}
|
||||
cardOptions={{
|
||||
preferThumb: true,
|
||||
inheritThumb: false,
|
||||
showParentTitleOrTitle: true,
|
||||
showTitle: false,
|
||||
coverImage: true,
|
||||
overlayMoreButton: true,
|
||||
showAirTime: true,
|
||||
showAirDateTime: true,
|
||||
showChannelName: true
|
||||
}}
|
||||
/>
|
||||
<SearchResultsRow
|
||||
title={globalize.translate('Channels')}
|
||||
items={channels}
|
||||
cardOptions={{ shape: 'square' }}
|
||||
/>
|
||||
<SearchResultsRow
|
||||
title={globalize.translate('Playlists')}
|
||||
items={playlists}
|
||||
/>
|
||||
<SearchResultsRow
|
||||
title={globalize.translate('Artists')}
|
||||
items={artists}
|
||||
cardOptions={{ coverImage: true }}
|
||||
/>
|
||||
<SearchResultsRow
|
||||
title={globalize.translate('Albums')}
|
||||
items={albums}
|
||||
cardOptions={{ showParentTitle: true }}
|
||||
/>
|
||||
<SearchResultsRow
|
||||
title={globalize.translate('Songs')}
|
||||
items={songs}
|
||||
cardOptions={{ showParentTitle: true }}
|
||||
/>
|
||||
<SearchResultsRow
|
||||
title={globalize.translate('HeaderPhotoAlbums')}
|
||||
items={photoAlbums}
|
||||
/>
|
||||
<SearchResultsRow
|
||||
title={globalize.translate('Photos')}
|
||||
items={photos}
|
||||
/>
|
||||
<SearchResultsRow
|
||||
title={globalize.translate('HeaderAudioBooks')}
|
||||
items={audioBooks}
|
||||
/>
|
||||
<SearchResultsRow
|
||||
title={globalize.translate('Books')}
|
||||
items={books}
|
||||
/>
|
||||
<SearchResultsRow
|
||||
title={globalize.translate('People')}
|
||||
items={people}
|
||||
cardOptions={{ coverImage: true }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SearchResults;
|
50
src/components/search/SearchResultsRow.tsx
Normal file
50
src/components/search/SearchResultsRow.tsx
Normal file
|
@ -0,0 +1,50 @@
|
|||
import React, { FunctionComponent, useEffect, useRef } from 'react';
|
||||
|
||||
import cardBuilder from '../cardbuilder/cardBuilder';
|
||||
|
||||
import '../../elements/emby-scroller/emby-scroller';
|
||||
import '../../elements/emby-itemscontainer/emby-itemscontainer';
|
||||
|
||||
// There seems to be some compatibility issues here between
|
||||
// React and our legacy web components, so we need to inject
|
||||
// them as an html string for now =/
|
||||
const createScroller = ({ title = '' }) => ({
|
||||
__html: `<h2 class="sectionTitle sectionTitle-cards focuscontainer-x padded-left padded-right">${title}</h2>
|
||||
<div is="emby-scroller" data-horizontal="true" data-centerfocus="card" class="padded-top-focusscale padded-bottom-focusscale">
|
||||
<div is="emby-itemscontainer" class="focuscontainer-x itemsContainer scrollSlider"></div>
|
||||
</div>`
|
||||
});
|
||||
|
||||
type SearchResultsRowProps = {
|
||||
title?: string;
|
||||
items?: Array<any>; // TODO: Should be Array<BaseItemDto> once we have a typed API client
|
||||
cardOptions?: Record<string, any>;
|
||||
}
|
||||
|
||||
const SearchResultsRow: FunctionComponent<SearchResultsRowProps> = ({ title, items = [], cardOptions = {} }: SearchResultsRowProps) => {
|
||||
const element = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
cardBuilder.buildCards(items, {
|
||||
itemsContainer: element.current?.querySelector('.itemsContainer'),
|
||||
parentContainer: element.current,
|
||||
shape: 'autooverflow',
|
||||
scalable: true,
|
||||
showTitle: true,
|
||||
overlayText: false,
|
||||
centerText: true,
|
||||
allowBottomPadding: false,
|
||||
...cardOptions
|
||||
});
|
||||
}, [cardOptions, items]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={element}
|
||||
className='verticalSection'
|
||||
dangerouslySetInnerHTML={createScroller({ title })}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default SearchResultsRow;
|
71
src/components/search/SearchSuggestions.tsx
Normal file
71
src/components/search/SearchSuggestions.tsx
Normal file
|
@ -0,0 +1,71 @@
|
|||
import React, { FunctionComponent, useEffect, useState } from 'react';
|
||||
|
||||
import { appRouter } from '../appRouter';
|
||||
import globalize from '../../scripts/globalize';
|
||||
import ServerConnections from '../ServerConnections';
|
||||
|
||||
import '../../elements/emby-button/emby-button';
|
||||
|
||||
// There seems to be some compatibility issues here between
|
||||
// React and our legacy web components, so we need to inject
|
||||
// them as an html string for now =/
|
||||
const createSuggestionLink = ({name, href}) => ({
|
||||
__html: `<a
|
||||
is='emby-linkbutton'
|
||||
class='button-link'
|
||||
style='display: inline-block; padding: 0.5em 1em;'
|
||||
href='${href}'
|
||||
>${name}</a>`
|
||||
});
|
||||
|
||||
type SearchSuggestionsProps = {
|
||||
serverId?: string;
|
||||
parentId?: string;
|
||||
}
|
||||
|
||||
const SearchSuggestions: FunctionComponent<SearchSuggestionsProps> = ({ serverId, parentId }: SearchSuggestionsProps) => {
|
||||
const [ suggestions, setSuggestions ] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
// TODO: Remove type casting once we're using a properly typed API client
|
||||
const apiClient = (ServerConnections as any).getApiClient(serverId);
|
||||
|
||||
apiClient.getItems(apiClient.getCurrentUserId(), {
|
||||
SortBy: 'IsFavoriteOrLiked,Random',
|
||||
IncludeItemTypes: 'Movie,Series,MusicArtist',
|
||||
Limit: 20,
|
||||
Recursive: true,
|
||||
ImageTypeLimit: 0,
|
||||
EnableImages: false,
|
||||
ParentId: parentId,
|
||||
EnableTotalRecordCount: false
|
||||
}).then(result => setSuggestions(result.Items));
|
||||
}, [parentId, serverId]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className='verticalSection searchSuggestions'
|
||||
style={{ textAlign: 'center' }}
|
||||
>
|
||||
<div>
|
||||
<h2 className='sectionTitle padded-left padded-right'>
|
||||
{globalize.translate('Suggestions')}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className='searchSuggestionsList padded-left padded-right'>
|
||||
{suggestions.map(item => (
|
||||
<div
|
||||
key={`suggestion-${item.Id}`}
|
||||
dangerouslySetInnerHTML={createSuggestionLink({
|
||||
name: item.Name,
|
||||
href: appRouter.getRouteUrl(item)
|
||||
})}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SearchSuggestions;
|
|
@ -1,121 +0,0 @@
|
|||
import layoutManager from '../layoutManager';
|
||||
import globalize from '../../scripts/globalize';
|
||||
import { Events } from 'jellyfin-apiclient';
|
||||
import browser from '../../scripts/browser';
|
||||
import AlphaPicker from '../alphaPicker/alphaPicker';
|
||||
import '../../elements/emby-input/emby-input';
|
||||
import '../../assets/css/flexstyles.scss';
|
||||
import 'material-design-icons-iconfont';
|
||||
import './searchfields.scss';
|
||||
import template from './searchfields.template.html';
|
||||
|
||||
/* eslint-disable indent */
|
||||
|
||||
function onSearchTimeout() {
|
||||
const instance = this;
|
||||
let value = instance.nextSearchValue;
|
||||
|
||||
value = (value || '').trim();
|
||||
Events.trigger(instance, 'search', [value]);
|
||||
}
|
||||
|
||||
function triggerSearch(instance, value) {
|
||||
if (instance.searchTimeout) {
|
||||
clearTimeout(instance.searchTimeout);
|
||||
}
|
||||
|
||||
instance.nextSearchValue = value;
|
||||
instance.searchTimeout = setTimeout(onSearchTimeout.bind(instance), 400);
|
||||
}
|
||||
|
||||
function onAlphaValueClicked(e) {
|
||||
const value = e.detail.value;
|
||||
const searchFieldsInstance = this;
|
||||
|
||||
const txtSearch = searchFieldsInstance.options.element.querySelector('.searchfields-txtSearch');
|
||||
|
||||
if (value === 'backspace') {
|
||||
const val = txtSearch.value;
|
||||
txtSearch.value = val.length ? val.substring(0, val.length - 1) : '';
|
||||
} else {
|
||||
txtSearch.value += value;
|
||||
}
|
||||
|
||||
txtSearch.dispatchEvent(new CustomEvent('input', {
|
||||
bubbles: true
|
||||
}));
|
||||
}
|
||||
|
||||
function initAlphaPicker(alphaPickerElement, instance) {
|
||||
instance.alphaPicker = new AlphaPicker({
|
||||
element: alphaPickerElement,
|
||||
mode: 'keyboard'
|
||||
});
|
||||
|
||||
alphaPickerElement.addEventListener('alphavalueclicked', onAlphaValueClicked.bind(instance));
|
||||
}
|
||||
|
||||
function onSearchInput(e) {
|
||||
const value = e.target.value;
|
||||
const searchFieldsInstance = this;
|
||||
triggerSearch(searchFieldsInstance, value);
|
||||
}
|
||||
|
||||
function embed(elem, instance) {
|
||||
let html = globalize.translateHtml(template, 'core');
|
||||
|
||||
if (browser.tizen || browser.orsay) {
|
||||
html = html.replace('<input ', '<input readonly ');
|
||||
}
|
||||
|
||||
elem.innerHTML = html;
|
||||
|
||||
elem.classList.add('searchFields');
|
||||
|
||||
const txtSearch = elem.querySelector('.searchfields-txtSearch');
|
||||
|
||||
if (layoutManager.tv) {
|
||||
const alphaPickerElement = elem.querySelector('.alphaPicker');
|
||||
|
||||
elem.querySelector('.alphaPicker').classList.remove('hide');
|
||||
initAlphaPicker(alphaPickerElement, instance);
|
||||
}
|
||||
|
||||
txtSearch.addEventListener('input', onSearchInput.bind(instance));
|
||||
|
||||
instance.focus();
|
||||
}
|
||||
|
||||
class SearchFields {
|
||||
constructor(options) {
|
||||
this.options = options;
|
||||
embed(options.element, this);
|
||||
}
|
||||
focus() {
|
||||
this.options.element.querySelector('.searchfields-txtSearch').focus();
|
||||
}
|
||||
destroy() {
|
||||
const options = this.options;
|
||||
if (options) {
|
||||
options.element.classList.remove('searchFields');
|
||||
}
|
||||
this.options = null;
|
||||
|
||||
const alphaPicker = this.alphaPicker;
|
||||
if (alphaPicker) {
|
||||
alphaPicker.destroy();
|
||||
}
|
||||
this.alphaPicker = null;
|
||||
|
||||
const searchTimeout = this.searchTimeout;
|
||||
if (searchTimeout) {
|
||||
clearTimeout(searchTimeout);
|
||||
}
|
||||
this.searchTimeout = null;
|
||||
this.nextSearchValue = null;
|
||||
}
|
||||
}
|
||||
|
||||
export default SearchFields;
|
||||
|
||||
/* eslint-enable indent */
|
|
@ -1,7 +0,0 @@
|
|||
<div class="searchFieldsInner flex align-items-center justify-content-center">
|
||||
<span class="searchfields-icon material-icons search"></span>
|
||||
<div class="inputContainer flex-grow" style="margin-bottom: 0;">
|
||||
<input is="emby-input" class="searchfields-txtSearch" type="text" data-keyboard="true" placeholder="${Search}" autocomplete="off" maxlength="40" autofocus />
|
||||
</div>
|
||||
</div>
|
||||
<div class="alphaPicker align-items-center hide"></div>
|
|
@ -1,624 +0,0 @@
|
|||
import layoutManager from '../layoutManager';
|
||||
import globalize from '../../scripts/globalize';
|
||||
import cardBuilder from '../cardbuilder/cardBuilder';
|
||||
import { appRouter } from '../appRouter';
|
||||
import '../../elements/emby-scroller/emby-scroller';
|
||||
import '../../elements/emby-itemscontainer/emby-itemscontainer';
|
||||
import '../../elements/emby-button/emby-button';
|
||||
import ServerConnections from '../ServerConnections';
|
||||
import template from './searchresults.template.html';
|
||||
|
||||
/* eslint-disable indent */
|
||||
|
||||
function loadSuggestions(instance, context, apiClient) {
|
||||
const options = {
|
||||
|
||||
SortBy: 'IsFavoriteOrLiked,Random',
|
||||
IncludeItemTypes: 'Movie,Series,MusicArtist',
|
||||
Limit: 20,
|
||||
Recursive: true,
|
||||
ImageTypeLimit: 0,
|
||||
EnableImages: false,
|
||||
ParentId: instance.options.parentId,
|
||||
EnableTotalRecordCount: false
|
||||
};
|
||||
|
||||
apiClient.getItems(apiClient.getCurrentUserId(), options).then(function (result) {
|
||||
if (instance.mode !== 'suggestions') {
|
||||
result.Items = [];
|
||||
}
|
||||
|
||||
const html = result.Items.map(function (i) {
|
||||
const href = appRouter.getRouteUrl(i);
|
||||
|
||||
let itemHtml = '<div><a is="emby-linkbutton" class="button-link" style="display:inline-block;padding:.5em 1em;" href="' + href + '">';
|
||||
itemHtml += i.Name;
|
||||
itemHtml += '</a></div>';
|
||||
return itemHtml;
|
||||
}).join('');
|
||||
|
||||
const searchSuggestions = context.querySelector('.searchSuggestions');
|
||||
searchSuggestions.querySelector('.searchSuggestionsList').innerHTML = html;
|
||||
|
||||
if (result.Items.length) {
|
||||
searchSuggestions.classList.remove('hide');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function getSearchHints(instance, apiClient, query) {
|
||||
if (!query.searchTerm) {
|
||||
return Promise.resolve({
|
||||
SearchHints: []
|
||||
});
|
||||
}
|
||||
|
||||
let allowSearch = true;
|
||||
|
||||
const queryIncludeItemTypes = query.IncludeItemTypes;
|
||||
|
||||
if (instance.options.collectionType === 'tvshows') {
|
||||
if (query.IncludeArtists) {
|
||||
allowSearch = false;
|
||||
} else if (queryIncludeItemTypes === 'Movie' ||
|
||||
queryIncludeItemTypes === 'LiveTvProgram' ||
|
||||
queryIncludeItemTypes === 'MusicAlbum' ||
|
||||
queryIncludeItemTypes === 'Audio' ||
|
||||
queryIncludeItemTypes === 'Book' ||
|
||||
queryIncludeItemTypes === 'AudioBook' ||
|
||||
queryIncludeItemTypes === 'Playlist' ||
|
||||
queryIncludeItemTypes === 'PhotoAlbum' ||
|
||||
query.MediaTypes === 'Video' ||
|
||||
query.MediaTypes === 'Photo') {
|
||||
allowSearch = false;
|
||||
}
|
||||
} else if (instance.options.collectionType === 'movies') {
|
||||
if (query.IncludeArtists) {
|
||||
allowSearch = false;
|
||||
} else if (queryIncludeItemTypes === 'Series' ||
|
||||
queryIncludeItemTypes === 'Episode' ||
|
||||
queryIncludeItemTypes === 'LiveTvProgram' ||
|
||||
queryIncludeItemTypes === 'MusicAlbum' ||
|
||||
queryIncludeItemTypes === 'Audio' ||
|
||||
queryIncludeItemTypes === 'Book' ||
|
||||
queryIncludeItemTypes === 'AudioBook' ||
|
||||
queryIncludeItemTypes === 'Playlist' ||
|
||||
queryIncludeItemTypes === 'PhotoAlbum' ||
|
||||
query.MediaTypes === 'Video' ||
|
||||
query.MediaTypes === 'Photo') {
|
||||
allowSearch = false;
|
||||
}
|
||||
} else if (instance.options.collectionType === 'music') {
|
||||
if (query.People) {
|
||||
allowSearch = false;
|
||||
} else if (queryIncludeItemTypes === 'Series' ||
|
||||
queryIncludeItemTypes === 'Episode' ||
|
||||
queryIncludeItemTypes === 'LiveTvProgram' ||
|
||||
queryIncludeItemTypes === 'Movie') {
|
||||
allowSearch = false;
|
||||
}
|
||||
} else if (instance.options.collectionType === 'livetv') {
|
||||
if (query.IncludeArtists || query.IncludePeople) {
|
||||
allowSearch = false;
|
||||
} else if (queryIncludeItemTypes === 'Series' ||
|
||||
queryIncludeItemTypes === 'Episode' ||
|
||||
queryIncludeItemTypes === 'MusicAlbum' ||
|
||||
queryIncludeItemTypes === 'Audio' ||
|
||||
queryIncludeItemTypes === 'Book' ||
|
||||
queryIncludeItemTypes === 'AudioBook' ||
|
||||
queryIncludeItemTypes === 'PhotoAlbum' ||
|
||||
queryIncludeItemTypes === 'Movie' ||
|
||||
query.MediaTypes === 'Video' ||
|
||||
query.MediaTypes === 'Photo') {
|
||||
allowSearch = false;
|
||||
}
|
||||
}
|
||||
if (queryIncludeItemTypes === 'NullType') {
|
||||
allowSearch = false;
|
||||
}
|
||||
|
||||
if (!allowSearch) {
|
||||
return Promise.resolve({
|
||||
SearchHints: []
|
||||
});
|
||||
}
|
||||
|
||||
// Convert the search hint query to a regular item query
|
||||
if (apiClient.isMinServerVersion('3.4.1.31')) {
|
||||
query.Fields = 'PrimaryImageAspectRatio,CanDelete,BasicSyncInfo,MediaSourceCount';
|
||||
query.Recursive = true;
|
||||
query.EnableTotalRecordCount = false;
|
||||
query.ImageTypeLimit = 1;
|
||||
|
||||
let methodName = 'getItems';
|
||||
|
||||
if (!query.IncludeMedia) {
|
||||
if (query.IncludePeople) {
|
||||
methodName = 'getPeople';
|
||||
} else if (query.IncludeArtists) {
|
||||
methodName = 'getArtists';
|
||||
}
|
||||
}
|
||||
|
||||
return apiClient[methodName](apiClient.getCurrentUserId(), query);
|
||||
}
|
||||
|
||||
query.UserId = apiClient.getCurrentUserId();
|
||||
|
||||
return apiClient.getSearchHints(query);
|
||||
}
|
||||
|
||||
function search(instance, apiClient, context, value) {
|
||||
if (value || layoutManager.tv) {
|
||||
instance.mode = 'search';
|
||||
context.querySelector('.searchSuggestions').classList.add('hide');
|
||||
} else {
|
||||
instance.mode = 'suggestions';
|
||||
loadSuggestions(instance, context, apiClient);
|
||||
}
|
||||
|
||||
if (instance.options.collectionType === 'livetv') {
|
||||
searchType(instance, apiClient, {
|
||||
searchTerm: value,
|
||||
IncludePeople: false,
|
||||
IncludeMedia: true,
|
||||
IncludeGenres: false,
|
||||
IncludeStudios: false,
|
||||
IncludeArtists: false,
|
||||
IncludeItemTypes: 'LiveTvProgram',
|
||||
IsMovie: true,
|
||||
IsKids: false,
|
||||
IsNews: false
|
||||
|
||||
}, context, '.movieResults', {
|
||||
|
||||
preferThumb: true,
|
||||
inheritThumb: false,
|
||||
shape: (enableScrollX() ? 'overflowPortrait' : 'portrait'),
|
||||
showParentTitleOrTitle: true,
|
||||
showTitle: false,
|
||||
centerText: true,
|
||||
coverImage: true,
|
||||
overlayText: false,
|
||||
overlayMoreButton: true,
|
||||
showAirTime: true,
|
||||
showAirDateTime: true,
|
||||
showChannelName: true
|
||||
});
|
||||
} else {
|
||||
searchType(instance, apiClient, {
|
||||
searchTerm: value,
|
||||
IncludePeople: false,
|
||||
IncludeMedia: true,
|
||||
IncludeGenres: false,
|
||||
IncludeStudios: false,
|
||||
IncludeArtists: false,
|
||||
IncludeItemTypes: 'Movie'
|
||||
|
||||
}, context, '.movieResults', {
|
||||
|
||||
showTitle: true,
|
||||
overlayText: false,
|
||||
centerText: true,
|
||||
showYear: true
|
||||
});
|
||||
}
|
||||
|
||||
searchType(instance, apiClient, {
|
||||
searchTerm: value,
|
||||
IncludePeople: false,
|
||||
IncludeMedia: true,
|
||||
IncludeGenres: false,
|
||||
IncludeStudios: false,
|
||||
IncludeArtists: false,
|
||||
IncludeItemTypes: 'Series'
|
||||
|
||||
}, context, '.seriesResults', {
|
||||
|
||||
showTitle: true,
|
||||
overlayText: false,
|
||||
centerText: true,
|
||||
showYear: true
|
||||
});
|
||||
|
||||
if (instance.options.collectionType === 'livetv') {
|
||||
searchType(instance, apiClient, {
|
||||
searchTerm: value,
|
||||
IncludePeople: false,
|
||||
IncludeMedia: true,
|
||||
IncludeGenres: false,
|
||||
IncludeStudios: false,
|
||||
IncludeArtists: false,
|
||||
IncludeItemTypes: 'LiveTvProgram',
|
||||
IsSeries: true,
|
||||
IsSports: false,
|
||||
IsKids: false,
|
||||
IsNews: false
|
||||
|
||||
}, context, '.episodeResults', {
|
||||
|
||||
preferThumb: true,
|
||||
inheritThumb: false,
|
||||
shape: (enableScrollX() ? 'overflowBackdrop' : 'backdrop'),
|
||||
showParentTitleOrTitle: true,
|
||||
showTitle: false,
|
||||
centerText: true,
|
||||
coverImage: true,
|
||||
overlayText: false,
|
||||
overlayMoreButton: true,
|
||||
showAirTime: true,
|
||||
showAirDateTime: true,
|
||||
showChannelName: true
|
||||
});
|
||||
} else {
|
||||
searchType(instance, apiClient, {
|
||||
searchTerm: value,
|
||||
IncludePeople: false,
|
||||
IncludeMedia: true,
|
||||
IncludeGenres: false,
|
||||
IncludeStudios: false,
|
||||
IncludeArtists: false,
|
||||
IncludeItemTypes: 'Episode'
|
||||
|
||||
}, context, '.episodeResults', {
|
||||
|
||||
coverImage: true,
|
||||
showTitle: true,
|
||||
showParentTitle: true
|
||||
});
|
||||
}
|
||||
|
||||
searchType(instance, apiClient, {
|
||||
searchTerm: value,
|
||||
IncludePeople: false,
|
||||
IncludeMedia: true,
|
||||
IncludeGenres: false,
|
||||
IncludeStudios: false,
|
||||
IncludeArtists: false,
|
||||
// NullType to hide
|
||||
IncludeItemTypes: instance.options.collectionType === 'livetv' ? 'LiveTvProgram' : 'NullType',
|
||||
IsSports: true
|
||||
|
||||
}, context, '.sportsResults', {
|
||||
|
||||
preferThumb: true,
|
||||
inheritThumb: false,
|
||||
shape: (enableScrollX() ? 'overflowBackdrop' : 'backdrop'),
|
||||
showParentTitleOrTitle: true,
|
||||
showTitle: false,
|
||||
centerText: true,
|
||||
coverImage: true,
|
||||
overlayText: false,
|
||||
overlayMoreButton: true,
|
||||
showAirTime: true,
|
||||
showAirDateTime: true,
|
||||
showChannelName: true
|
||||
|
||||
});
|
||||
|
||||
searchType(instance, apiClient, {
|
||||
searchTerm: value,
|
||||
IncludePeople: false,
|
||||
IncludeMedia: true,
|
||||
IncludeGenres: false,
|
||||
IncludeStudios: false,
|
||||
IncludeArtists: false,
|
||||
// NullType to hide
|
||||
IncludeItemTypes: instance.options.collectionType === 'livetv' ? 'LiveTvProgram' : 'NullType',
|
||||
IsKids: true
|
||||
|
||||
}, context, '.kidsResults', {
|
||||
|
||||
preferThumb: true,
|
||||
inheritThumb: false,
|
||||
shape: (enableScrollX() ? 'overflowBackdrop' : 'backdrop'),
|
||||
showParentTitleOrTitle: true,
|
||||
showTitle: false,
|
||||
centerText: true,
|
||||
coverImage: true,
|
||||
overlayText: false,
|
||||
overlayMoreButton: true,
|
||||
showAirTime: true,
|
||||
showAirDateTime: true,
|
||||
showChannelName: true
|
||||
|
||||
});
|
||||
|
||||
searchType(instance, apiClient, {
|
||||
searchTerm: value,
|
||||
IncludePeople: false,
|
||||
IncludeMedia: true,
|
||||
IncludeGenres: false,
|
||||
IncludeStudios: false,
|
||||
IncludeArtists: false,
|
||||
// NullType to hide
|
||||
IncludeItemTypes: instance.options.collectionType === 'livetv' ? 'LiveTvProgram' : 'NullType',
|
||||
IsNews: true
|
||||
|
||||
}, context, '.newsResults', {
|
||||
|
||||
preferThumb: true,
|
||||
inheritThumb: false,
|
||||
shape: (enableScrollX() ? 'overflowBackdrop' : 'backdrop'),
|
||||
showParentTitleOrTitle: true,
|
||||
showTitle: false,
|
||||
centerText: true,
|
||||
coverImage: true,
|
||||
overlayText: false,
|
||||
overlayMoreButton: true,
|
||||
showAirTime: true,
|
||||
showAirDateTime: true,
|
||||
showChannelName: true
|
||||
|
||||
});
|
||||
|
||||
searchType(instance, apiClient, {
|
||||
searchTerm: value,
|
||||
IncludePeople: false,
|
||||
IncludeMedia: true,
|
||||
IncludeGenres: false,
|
||||
IncludeStudios: false,
|
||||
IncludeArtists: false,
|
||||
IncludeItemTypes: 'LiveTvProgram',
|
||||
IsMovie: instance.options.collectionType === 'livetv' ? false : null,
|
||||
IsSeries: instance.options.collectionType === 'livetv' ? false : null,
|
||||
IsSports: instance.options.collectionType === 'livetv' ? false : null,
|
||||
IsKids: instance.options.collectionType === 'livetv' ? false : null,
|
||||
IsNews: instance.options.collectionType === 'livetv' ? false : null
|
||||
|
||||
}, context, '.programResults', {
|
||||
|
||||
preferThumb: true,
|
||||
inheritThumb: false,
|
||||
shape: (enableScrollX() ? 'overflowBackdrop' : 'backdrop'),
|
||||
showParentTitleOrTitle: true,
|
||||
showTitle: false,
|
||||
centerText: true,
|
||||
coverImage: true,
|
||||
overlayText: false,
|
||||
overlayMoreButton: true,
|
||||
showAirTime: true,
|
||||
showAirDateTime: true,
|
||||
showChannelName: true
|
||||
|
||||
});
|
||||
|
||||
searchType(instance, apiClient, {
|
||||
searchTerm: value,
|
||||
IncludePeople: false,
|
||||
IncludeMedia: true,
|
||||
IncludeGenres: false,
|
||||
IncludeStudios: false,
|
||||
IncludeArtists: false,
|
||||
MediaTypes: 'Video',
|
||||
ExcludeItemTypes: 'Movie,Episode'
|
||||
|
||||
}, context, '.videoResults', {
|
||||
|
||||
showParentTitle: true,
|
||||
showTitle: true,
|
||||
overlayText: false,
|
||||
centerText: true
|
||||
});
|
||||
|
||||
searchType(instance, apiClient, {
|
||||
searchTerm: value,
|
||||
IncludePeople: true,
|
||||
IncludeMedia: false,
|
||||
IncludeGenres: false,
|
||||
IncludeStudios: false,
|
||||
IncludeArtists: false
|
||||
|
||||
}, context, '.peopleResults', {
|
||||
|
||||
coverImage: true,
|
||||
showTitle: true
|
||||
});
|
||||
|
||||
searchType(instance, apiClient, {
|
||||
searchTerm: value,
|
||||
IncludePeople: false,
|
||||
IncludeMedia: false,
|
||||
IncludeGenres: false,
|
||||
IncludeStudios: false,
|
||||
IncludeArtists: true
|
||||
|
||||
}, context, '.artistResults', {
|
||||
coverImage: true,
|
||||
showTitle: true
|
||||
});
|
||||
|
||||
searchType(instance, apiClient, {
|
||||
searchTerm: value,
|
||||
IncludePeople: false,
|
||||
IncludeMedia: true,
|
||||
IncludeGenres: false,
|
||||
IncludeStudios: false,
|
||||
IncludeArtists: false,
|
||||
IncludeItemTypes: 'MusicAlbum'
|
||||
|
||||
}, context, '.albumResults', {
|
||||
|
||||
showParentTitle: true,
|
||||
showTitle: true,
|
||||
overlayText: false,
|
||||
centerText: true
|
||||
});
|
||||
|
||||
searchType(instance, apiClient, {
|
||||
searchTerm: value,
|
||||
IncludePeople: false,
|
||||
IncludeMedia: true,
|
||||
IncludeGenres: false,
|
||||
IncludeStudios: false,
|
||||
IncludeArtists: false,
|
||||
IncludeItemTypes: 'Audio'
|
||||
|
||||
}, context, '.songResults', {
|
||||
|
||||
showParentTitle: true,
|
||||
showTitle: true,
|
||||
overlayText: false,
|
||||
centerText: true,
|
||||
overlayPlayButton: true
|
||||
|
||||
});
|
||||
|
||||
searchType(instance, apiClient, {
|
||||
searchTerm: value,
|
||||
IncludePeople: false,
|
||||
IncludeMedia: true,
|
||||
IncludeGenres: false,
|
||||
IncludeStudios: false,
|
||||
IncludeArtists: false,
|
||||
MediaTypes: 'Photo'
|
||||
|
||||
}, context, '.photoResults', {
|
||||
|
||||
showParentTitle: false,
|
||||
showTitle: true,
|
||||
overlayText: false,
|
||||
centerText: true
|
||||
});
|
||||
|
||||
searchType(instance, apiClient, {
|
||||
searchTerm: value,
|
||||
IncludePeople: false,
|
||||
IncludeMedia: true,
|
||||
IncludeGenres: false,
|
||||
IncludeStudios: false,
|
||||
IncludeArtists: false,
|
||||
IncludeItemTypes: 'PhotoAlbum'
|
||||
|
||||
}, context, '.photoAlbumResults', {
|
||||
|
||||
showTitle: true,
|
||||
overlayText: false,
|
||||
centerText: true
|
||||
});
|
||||
|
||||
searchType(instance, apiClient, {
|
||||
searchTerm: value,
|
||||
IncludePeople: false,
|
||||
IncludeMedia: true,
|
||||
IncludeGenres: false,
|
||||
IncludeStudios: false,
|
||||
IncludeArtists: false,
|
||||
IncludeItemTypes: 'Book'
|
||||
|
||||
}, context, '.bookResults', {
|
||||
|
||||
showTitle: true,
|
||||
overlayText: false,
|
||||
centerText: true
|
||||
|
||||
});
|
||||
|
||||
searchType(instance, apiClient, {
|
||||
searchTerm: value,
|
||||
IncludePeople: false,
|
||||
IncludeMedia: true,
|
||||
IncludeGenres: false,
|
||||
IncludeStudios: false,
|
||||
IncludeArtists: false,
|
||||
IncludeItemTypes: 'AudioBook'
|
||||
|
||||
}, context, '.audioBookResults', {
|
||||
|
||||
showTitle: true,
|
||||
overlayText: false,
|
||||
centerText: true
|
||||
});
|
||||
|
||||
searchType(instance, apiClient, {
|
||||
searchTerm: value,
|
||||
IncludePeople: false,
|
||||
IncludeMedia: true,
|
||||
IncludeGenres: false,
|
||||
IncludeStudios: false,
|
||||
IncludeArtists: false,
|
||||
IncludeItemTypes: 'Playlist'
|
||||
|
||||
}, context, '.playlistResults', {
|
||||
|
||||
showTitle: true,
|
||||
overlayText: false,
|
||||
centerText: true
|
||||
});
|
||||
}
|
||||
|
||||
function searchType(instance, apiClient, query, context, section, cardOptions) {
|
||||
query.Limit = enableScrollX() ? 24 : 16;
|
||||
query.ParentId = instance.options.parentId;
|
||||
|
||||
getSearchHints(instance, apiClient, query).then(function (result) {
|
||||
populateResults(result, context, section, cardOptions);
|
||||
});
|
||||
}
|
||||
|
||||
function populateResults(result, context, section, cardOptions) {
|
||||
section = context.querySelector(section);
|
||||
|
||||
const items = result.Items || result.SearchHints;
|
||||
|
||||
const itemsContainer = section.querySelector('.itemsContainer');
|
||||
|
||||
cardBuilder.buildCards(items, Object.assign({
|
||||
|
||||
itemsContainer: itemsContainer,
|
||||
parentContainer: section,
|
||||
shape: enableScrollX() ? 'autooverflow' : 'auto',
|
||||
scalable: true,
|
||||
overlayText: false,
|
||||
centerText: true,
|
||||
allowBottomPadding: !enableScrollX()
|
||||
|
||||
}, cardOptions || {}));
|
||||
}
|
||||
|
||||
function enableScrollX() {
|
||||
return true;
|
||||
}
|
||||
|
||||
function replaceAll(originalString, strReplace, strWith) {
|
||||
const reg = new RegExp(strReplace, 'ig');
|
||||
return originalString.replace(reg, strWith);
|
||||
}
|
||||
|
||||
function embed(elem, instance) {
|
||||
let workingTemplate = template;
|
||||
if (!enableScrollX()) {
|
||||
workingTemplate = replaceAll(workingTemplate, 'data-horizontal="true"', 'data-horizontal="false"');
|
||||
workingTemplate = replaceAll(workingTemplate, 'itemsContainer scrollSlider', 'itemsContainer scrollSlider vertical-wrap');
|
||||
}
|
||||
|
||||
const html = globalize.translateHtml(workingTemplate, 'core');
|
||||
|
||||
elem.innerHTML = html;
|
||||
|
||||
elem.classList.add('searchResults');
|
||||
instance.search('');
|
||||
}
|
||||
|
||||
class SearchResults {
|
||||
constructor(options) {
|
||||
this.options = options;
|
||||
embed(options.element, this);
|
||||
}
|
||||
search(value) {
|
||||
const apiClient = ServerConnections.getApiClient(this.options.serverId);
|
||||
|
||||
search(this, apiClient, this.options.element, value);
|
||||
}
|
||||
destroy() {
|
||||
const options = this.options;
|
||||
if (options) {
|
||||
options.element.classList.remove('searchFields');
|
||||
}
|
||||
this.options = null;
|
||||
}
|
||||
}
|
||||
|
||||
export default SearchResults;
|
||||
|
||||
/* eslint-enable indent */
|
|
@ -1,145 +0,0 @@
|
|||
<div class="hide verticalSection searchSuggestions" style="text-align:center;">
|
||||
|
||||
<div>
|
||||
<h2 class="sectionTitle padded-left padded-right">${Suggestions}</h2>
|
||||
</div>
|
||||
|
||||
<div class="searchSuggestionsList padded-left padded-right">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="hide verticalSection movieResults">
|
||||
<h2 class="sectionTitle sectionTitle-cards focuscontainer-x padded-left padded-right">${Movies}</h2>
|
||||
|
||||
<div is="emby-scroller" data-horizontal="true" data-centerfocus="card" class="padded-top-focusscale padded-bottom-focusscale">
|
||||
<div is="emby-itemscontainer" class="focuscontainer-x itemsContainer scrollSlider"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="hide verticalSection seriesResults">
|
||||
<h2 class="sectionTitle sectionTitle-cards focuscontainer-x padded-left padded-right">${Shows}</h2>
|
||||
|
||||
<div is="emby-scroller" data-horizontal="true" data-centerfocus="card" class="padded-top-focusscale padded-bottom-focusscale">
|
||||
<div is="emby-itemscontainer" class="focuscontainer-x itemsContainer scrollSlider"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="hide verticalSection episodeResults">
|
||||
<h2 class="sectionTitle sectionTitle-cards focuscontainer-x padded-left padded-right">${Episodes}</h2>
|
||||
|
||||
<div is="emby-scroller" data-horizontal="true" data-centerfocus="card" class="padded-top-focusscale padded-bottom-focusscale">
|
||||
<div is="emby-itemscontainer" class="focuscontainer-x itemsContainer scrollSlider"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="hide verticalSection sportsResults">
|
||||
<h2 class="sectionTitle sectionTitle-cards focuscontainer-x padded-left padded-right">${Sports}</h2>
|
||||
|
||||
<div is="emby-scroller" data-horizontal="true" data-centerfocus="card" class="padded-top-focusscale padded-bottom-focusscale">
|
||||
<div is="emby-itemscontainer" class="focuscontainer-x itemsContainer scrollSlider"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="hide verticalSection kidsResults">
|
||||
<h2 class="sectionTitle sectionTitle-cards focuscontainer-x padded-left padded-right">${Kids}</h2>
|
||||
|
||||
<div is="emby-scroller" data-horizontal="true" data-centerfocus="card" class="padded-top-focusscale padded-bottom-focusscale">
|
||||
<div is="emby-itemscontainer" class="focuscontainer-x itemsContainer scrollSlider"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="hide verticalSection newsResults">
|
||||
<h2 class="sectionTitle sectionTitle-cards focuscontainer-x padded-left padded-right">${News}</h2>
|
||||
|
||||
<div is="emby-scroller" data-horizontal="true" data-centerfocus="card" class="padded-top-focusscale padded-bottom-focusscale">
|
||||
<div is="emby-itemscontainer" class="focuscontainer-x itemsContainer scrollSlider"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="hide verticalSection programResults">
|
||||
<h2 class="sectionTitle sectionTitle-cards focuscontainer-x padded-left padded-right">${Programs}</h2>
|
||||
|
||||
<div is="emby-scroller" data-horizontal="true" data-centerfocus="card" class="padded-top-focusscale padded-bottom-focusscale">
|
||||
<div is="emby-itemscontainer" class="focuscontainer-x itemsContainer scrollSlider"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="hide verticalSection videoResults">
|
||||
<h2 class="sectionTitle sectionTitle-cards focuscontainer-x padded-left padded-right">${Videos}</h2>
|
||||
|
||||
<div is="emby-scroller" data-horizontal="true" data-centerfocus="card" class="padded-top-focusscale padded-bottom-focusscale">
|
||||
<div is="emby-itemscontainer" class="focuscontainer-x itemsContainer scrollSlider"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="hide verticalSection playlistResults">
|
||||
<h2 class="sectionTitle sectionTitle-cards focuscontainer-x padded-left padded-right">${Playlists}</h2>
|
||||
|
||||
<div is="emby-scroller" data-horizontal="true" data-centerfocus="card" class="padded-top-focusscale padded-bottom-focusscale">
|
||||
<div is="emby-itemscontainer" class="focuscontainer-x itemsContainer scrollSlider"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="hide verticalSection artistResults">
|
||||
<h2 class="sectionTitle sectionTitle-cards focuscontainer-x padded-left padded-right">${Artists}</h2>
|
||||
|
||||
<div is="emby-scroller" data-horizontal="true" data-centerfocus="card" class="padded-top-focusscale padded-bottom-focusscale">
|
||||
<div is="emby-itemscontainer" class="focuscontainer-x itemsContainer scrollSlider"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="hide verticalSection albumResults">
|
||||
<h2 class="sectionTitle sectionTitle-cards focuscontainer-x padded-left padded-right">${Albums}</h2>
|
||||
|
||||
<div is="emby-scroller" data-horizontal="true" data-centerfocus="card" class="padded-top-focusscale padded-bottom-focusscale">
|
||||
<div is="emby-itemscontainer" class="focuscontainer-x itemsContainer scrollSlider"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="hide verticalSection songResults">
|
||||
<h2 class="sectionTitle sectionTitle-cards focuscontainer-x padded-left padded-right">${Songs}</h2>
|
||||
|
||||
<div is="emby-scroller" data-horizontal="true" data-centerfocus="card" class="padded-top-focusscale padded-bottom-focusscale">
|
||||
<div is="emby-itemscontainer" class="focuscontainer-x itemsContainer scrollSlider"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="hide verticalSection photoAlbumResults">
|
||||
<h2 class="sectionTitle sectionTitle-cards focuscontainer-x padded-left padded-right">${HeaderPhotoAlbums}</h2>
|
||||
|
||||
<div is="emby-scroller" data-horizontal="true" data-centerfocus="card" class="padded-top-focusscale padded-bottom-focusscale">
|
||||
<div is="emby-itemscontainer" class="focuscontainer-x itemsContainer scrollSlider"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="hide verticalSection photoResults">
|
||||
<h2 class="sectionTitle sectionTitle-cards focuscontainer-x padded-left padded-right">${Photos}</h2>
|
||||
|
||||
<div is="emby-scroller" data-horizontal="true" data-centerfocus="card" class="padded-top-focusscale padded-bottom-focusscale">
|
||||
<div is="emby-itemscontainer" class="focuscontainer-x itemsContainer scrollSlider"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="hide verticalSection audioBookResults">
|
||||
<h2 class="sectionTitle sectionTitle-cards focuscontainer-x padded-left padded-right">${HeaderAudioBooks}</h2>
|
||||
|
||||
<div is="emby-scroller" data-horizontal="true" data-centerfocus="card" class="padded-top-focusscale padded-bottom-focusscale">
|
||||
<div is="emby-itemscontainer" class="focuscontainer-x itemsContainer scrollSlider"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="hide verticalSection bookResults">
|
||||
<h2 class="sectionTitle sectionTitle-cards focuscontainer-x padded-left padded-right">${Books}</h2>
|
||||
|
||||
<div is="emby-scroller" data-horizontal="true" data-centerfocus="card" class="padded-top-focusscale padded-bottom-focusscale">
|
||||
<div is="emby-itemscontainer" class="focuscontainer-x itemsContainer scrollSlider"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="hide verticalSection peopleResults">
|
||||
<h2 class="sectionTitle sectionTitle-cards focuscontainer-x padded-left padded-right">${People}</h2>
|
||||
|
||||
<div is="emby-scroller" data-horizontal="true" data-centerfocus="card" class="padded-top-focusscale padded-bottom-focusscale">
|
||||
<div is="emby-itemscontainer" class="focuscontainer-x itemsContainer scrollSlider"></div>
|
||||
</div>
|
||||
</div>
|
|
@ -167,10 +167,6 @@ import toast from './toast/toast';
|
|||
});
|
||||
}
|
||||
|
||||
function sendToast(text) {
|
||||
toast(text);
|
||||
}
|
||||
|
||||
function executeAction(card, target, action) {
|
||||
target = target || card;
|
||||
|
||||
|
@ -222,7 +218,7 @@ import toast from './toast/toast';
|
|||
ids: [playableItemId],
|
||||
serverId: serverId
|
||||
});
|
||||
sendToast(globalize.translate('MediaQueued'));
|
||||
toast(globalize.translate('MediaQueued'));
|
||||
} else {
|
||||
playbackManager.queue({
|
||||
ids: [playableItemId],
|
||||
|
|
|
@ -28,6 +28,7 @@
|
|||
<option value="">${Auto}</option>
|
||||
<option value="onlyimageformats">${OnlyImageFormats}</option>
|
||||
<option value="allcomplexformats">${AllComplexFormats}</option>
|
||||
<option value="all">${All}</option>
|
||||
</select>
|
||||
<div class="fieldDescription">${BurnSubtitlesHelp}</div>
|
||||
</div>
|
||||
|
|
|
@ -100,6 +100,18 @@ class Controller {
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the playlist of a SyncPlay group.
|
||||
* @param {Array} clearPlayingItem Whether to remove the playing item as well.
|
||||
*/
|
||||
clearPlaylist(clearPlayingItem = false) {
|
||||
const apiClient = this.manager.getApiClient();
|
||||
apiClient.requestSyncPlayRemoveFromPlaylist({
|
||||
ClearPlaylist: true,
|
||||
ClearPlayingItem: clearPlayingItem
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes items from SyncPlay group playlist.
|
||||
* @param {Array} playlistItemIds The items to remove.
|
||||
|
|
|
@ -72,27 +72,13 @@ export function stringToGuid(input) {
|
|||
return input.replace(/([0-z]{8})([0-z]{4})([0-z]{4})([0-z]{4})([0-z]{12})/, '$1-$2-$3-$4-$5');
|
||||
}
|
||||
|
||||
/**
|
||||
* Triggers a show-message event.
|
||||
* @param {Object} syncPlayManager The SyncPlay manager.
|
||||
* @param {string} message The message name.
|
||||
* @param {Array} args Extra data needed for the message, optional.
|
||||
*/
|
||||
export function showMessage(syncPlayManager, message, args = []) {
|
||||
Events.trigger(syncPlayManager, 'show-message', [{
|
||||
message: message,
|
||||
args: args
|
||||
}]);
|
||||
}
|
||||
|
||||
export function getItemsForPlayback(apiClient, query) {
|
||||
if (query.Ids && query.Ids.split(',').length === 1) {
|
||||
const itemId = query.Ids.split(',');
|
||||
|
||||
return apiClient.getItem(apiClient.getCurrentUserId(), itemId).then(function (item) {
|
||||
return {
|
||||
Items: [item],
|
||||
TotalRecordCount: 1
|
||||
Items: [item]
|
||||
};
|
||||
});
|
||||
} else {
|
||||
|
|
|
@ -9,6 +9,8 @@ import TimeSyncCore from './timeSync/TimeSyncCore';
|
|||
import PlaybackCore from './PlaybackCore';
|
||||
import QueueCore from './QueueCore';
|
||||
import Controller from './Controller';
|
||||
import toast from '../../toast/toast';
|
||||
import globalize from '../../../scripts/globalize';
|
||||
|
||||
/**
|
||||
* Class that manages the SyncPlay feature.
|
||||
|
@ -45,12 +47,8 @@ class Manager {
|
|||
* @param {Object} apiClient The ApiClient.
|
||||
*/
|
||||
init(apiClient) {
|
||||
if (!apiClient) {
|
||||
throw new Error('ApiClient is null!');
|
||||
}
|
||||
|
||||
// Set ApiClient.
|
||||
this.apiClient = apiClient;
|
||||
this.updateApiClient(apiClient);
|
||||
|
||||
// Get default player wrapper.
|
||||
this.playerWrapper = this.playerFactory.getDefaultWrapper(this);
|
||||
|
@ -71,6 +69,18 @@ class Manager {
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update active ApiClient.
|
||||
* @param {Object} apiClient The ApiClient.
|
||||
*/
|
||||
updateApiClient(apiClient) {
|
||||
if (!apiClient) {
|
||||
throw new Error('ApiClient is null!');
|
||||
}
|
||||
|
||||
this.apiClient = apiClient;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the time sync core.
|
||||
* @returns {TimeSyncCore} The time sync core.
|
||||
|
@ -183,17 +193,17 @@ class Manager {
|
|||
this.queueCore.updatePlayQueue(apiClient, cmd.Data);
|
||||
break;
|
||||
case 'UserJoined':
|
||||
Helper.showMessage(this, 'MessageSyncPlayUserJoined', [cmd.Data]);
|
||||
toast(globalize.translate('MessageSyncPlayUserJoined', cmd.Data));
|
||||
break;
|
||||
case 'UserLeft':
|
||||
Helper.showMessage(this, 'MessageSyncPlayUserLeft', [cmd.Data]);
|
||||
toast(globalize.translate('MessageSyncPlayUserLeft', cmd.Data));
|
||||
break;
|
||||
case 'GroupJoined':
|
||||
cmd.Data.LastUpdatedAt = new Date(cmd.Data.LastUpdatedAt);
|
||||
this.enableSyncPlay(apiClient, cmd.Data, true);
|
||||
break;
|
||||
case 'SyncPlayIsDisabled':
|
||||
Helper.showMessage(this, 'MessageSyncPlayIsDisabled');
|
||||
toast(globalize.translate('MessageSyncPlayIsDisabled'));
|
||||
break;
|
||||
case 'NotInGroup':
|
||||
case 'GroupLeft':
|
||||
|
@ -208,16 +218,16 @@ class Manager {
|
|||
console.debug(`SyncPlay processGroupUpdate: state changed to ${cmd.Data.State} because ${cmd.Data.Reason}.`);
|
||||
break;
|
||||
case 'GroupDoesNotExist':
|
||||
Helper.showMessage(this, 'MessageSyncPlayGroupDoesNotExist');
|
||||
toast(globalize.translate('MessageSyncPlayGroupDoesNotExist'));
|
||||
break;
|
||||
case 'CreateGroupDenied':
|
||||
Helper.showMessage(this, 'MessageSyncPlayCreateGroupDenied');
|
||||
toast(globalize.translate('MessageSyncPlayCreateGroupDenied'));
|
||||
break;
|
||||
case 'JoinGroupDenied':
|
||||
Helper.showMessage(this, 'MessageSyncPlayJoinGroupDenied');
|
||||
toast(globalize.translate('MessageSyncPlayJoinGroupDenied'));
|
||||
break;
|
||||
case 'LibraryAccessDenied':
|
||||
Helper.showMessage(this, 'MessageSyncPlayLibraryAccessDenied');
|
||||
toast(globalize.translate('MessageSyncPlayLibraryAccessDenied'));
|
||||
break;
|
||||
default:
|
||||
console.error(`SyncPlay processGroupUpdate: command ${cmd.Type} not recognised.`);
|
||||
|
@ -371,7 +381,7 @@ class Manager {
|
|||
this.timeSyncCore.forceUpdate();
|
||||
|
||||
if (showMessage) {
|
||||
Helper.showMessage(this, 'MessageSyncPlayEnabled');
|
||||
toast(globalize.translate('MessageSyncPlayEnabled'));
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -390,7 +400,7 @@ class Manager {
|
|||
this.playerWrapper.unbindFromPlayer();
|
||||
|
||||
if (showMessage) {
|
||||
Helper.showMessage(this, 'MessageSyncPlayDisabled');
|
||||
toast(globalize.translate('MessageSyncPlayDisabled'));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -4,7 +4,9 @@
|
|||
*/
|
||||
|
||||
import { Events } from 'jellyfin-apiclient';
|
||||
import { toBoolean, toFloat } from '../../../scripts/stringUtils';
|
||||
import * as Helper from './Helper';
|
||||
import { getSetting } from './Settings';
|
||||
|
||||
/**
|
||||
* Class that manages the playback of SyncPlay.
|
||||
|
@ -25,6 +27,8 @@ class PlaybackCore {
|
|||
this.lastCommand = null; // Last scheduled playback command, might not be the latest one.
|
||||
this.scheduledCommandTimeout = null;
|
||||
this.syncTimeout = null;
|
||||
|
||||
this.loadPreferences();
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -35,26 +39,35 @@ class PlaybackCore {
|
|||
this.manager = syncPlayManager;
|
||||
this.timeSyncCore = syncPlayManager.getTimeSyncCore();
|
||||
|
||||
Events.on(this.manager, 'settings-update', () => {
|
||||
this.loadPreferences();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads preferences from saved settings.
|
||||
*/
|
||||
loadPreferences() {
|
||||
// Minimum required delay for SpeedToSync to kick in, in milliseconds.
|
||||
this.minDelaySpeedToSync = 60.0;
|
||||
this.minDelaySpeedToSync = toFloat(getSetting('minDelaySpeedToSync'), 60.0);
|
||||
|
||||
// Maximum delay after which SkipToSync is used instead of SpeedToSync, in milliseconds.
|
||||
this.maxDelaySpeedToSync = 3000.0;
|
||||
this.maxDelaySpeedToSync = toFloat(getSetting('maxDelaySpeedToSync'), 3000.0);
|
||||
|
||||
// Time during which the playback is sped up, in milliseconds.
|
||||
this.speedToSyncDuration = 1000.0;
|
||||
this.speedToSyncDuration = toFloat(getSetting('speedToSyncDuration'), 1000.0);
|
||||
|
||||
// Minimum required delay for SkipToSync to kick in, in milliseconds.
|
||||
this.minDelaySkipToSync = 400.0;
|
||||
this.minDelaySkipToSync = toFloat(getSetting('minDelaySkipToSync'), 400.0);
|
||||
|
||||
// Whether SpeedToSync should be used.
|
||||
this.useSpeedToSync = true;
|
||||
this.useSpeedToSync = toBoolean(getSetting('useSpeedToSync'), true);
|
||||
|
||||
// Whether SkipToSync should be used.
|
||||
this.useSkipToSync = true;
|
||||
this.useSkipToSync = toBoolean(getSetting('useSkipToSync'), true);
|
||||
|
||||
// Whether sync correction during playback is active.
|
||||
this.enableSyncCorrection = true;
|
||||
this.enableSyncCorrection = toBoolean(getSetting('enableSyncCorrection'), true);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -118,9 +131,11 @@ class PlaybackCore {
|
|||
* Sends a buffering request to the server.
|
||||
* @param {boolean} isBuffering Whether this client is buffering or not.
|
||||
*/
|
||||
sendBufferingRequest(isBuffering = true) {
|
||||
async sendBufferingRequest(isBuffering = true) {
|
||||
const playerWrapper = this.manager.getPlayerWrapper();
|
||||
const currentPosition = playerWrapper.currentTime();
|
||||
const currentPosition = (playerWrapper.currentTimeAsync
|
||||
? await playerWrapper.currentTimeAsync()
|
||||
: playerWrapper.currentTime());
|
||||
const currentPositionTicks = Math.round(currentPosition * Helper.TicksPerMillisecond);
|
||||
const isPlaying = playerWrapper.isPlaying();
|
||||
|
||||
|
@ -155,7 +170,7 @@ class PlaybackCore {
|
|||
* Applies a command and checks the playback state if a duplicate command is received.
|
||||
* @param {Object} command The playback command.
|
||||
*/
|
||||
applyCommand(command) {
|
||||
async applyCommand(command) {
|
||||
// Check if duplicate.
|
||||
if (this.lastCommand &&
|
||||
this.lastCommand.When.getTime() === command.When.getTime() &&
|
||||
|
@ -177,7 +192,9 @@ class PlaybackCore {
|
|||
} else {
|
||||
// Check if playback state matches requested command.
|
||||
const playerWrapper = this.manager.getPlayerWrapper();
|
||||
const currentPositionTicks = Math.round(playerWrapper.currentTime() * Helper.TicksPerMillisecond);
|
||||
const currentPositionTicks = Math.round((playerWrapper.currentTimeAsync
|
||||
? await playerWrapper.currentTimeAsync()
|
||||
: playerWrapper.currentTime()) * Helper.TicksPerMillisecond);
|
||||
const isPlaying = playerWrapper.isPlaying();
|
||||
|
||||
switch (command.Command) {
|
||||
|
@ -255,14 +272,16 @@ class PlaybackCore {
|
|||
* @param {Date} playAtTime The server's UTC time at which to resume playback.
|
||||
* @param {number} positionTicks The PositionTicks from where to resume.
|
||||
*/
|
||||
scheduleUnpause(playAtTime, positionTicks) {
|
||||
async scheduleUnpause(playAtTime, positionTicks) {
|
||||
this.clearScheduledCommand();
|
||||
const enableSyncTimeout = this.maxDelaySpeedToSync / 2.0;
|
||||
const currentTime = new Date();
|
||||
const playAtTimeLocal = this.timeSyncCore.remoteDateToLocal(playAtTime);
|
||||
|
||||
const playerWrapper = this.manager.getPlayerWrapper();
|
||||
const currentPositionTicks = playerWrapper.currentTime() * Helper.TicksPerMillisecond;
|
||||
const currentPositionTicks = (playerWrapper.currentTimeAsync
|
||||
? await playerWrapper.currentTimeAsync()
|
||||
: playerWrapper.currentTime()) * Helper.TicksPerMillisecond;
|
||||
|
||||
if (playAtTimeLocal > currentTime) {
|
||||
const playTimeout = playAtTimeLocal - currentTime;
|
||||
|
@ -520,7 +539,9 @@ class PlaybackCore {
|
|||
// Diff might be caused by the player internally starting the playback.
|
||||
const diffMillis = (serverPositionTicks - currentPositionTicks) / Helper.TicksPerMillisecond;
|
||||
|
||||
// Notify update for playback sync.
|
||||
this.playbackDiffMillis = diffMillis;
|
||||
Events.trigger(this.manager, 'playback-diff', [this.playbackDiffMillis]);
|
||||
|
||||
// Avoid overloading the browser.
|
||||
const elapsed = currentTime - this.lastSyncTime;
|
||||
|
|
|
@ -3,6 +3,8 @@
|
|||
* @module components/syncPlay/core/QueueCore
|
||||
*/
|
||||
|
||||
import globalize from '../../../scripts/globalize';
|
||||
import toast from '../../toast/toast';
|
||||
import * as Helper from './Helper';
|
||||
|
||||
/**
|
||||
|
@ -165,14 +167,16 @@ class QueueCore {
|
|||
* @param {string} origin The origin of the wait call, used for debug.
|
||||
*/
|
||||
scheduleReadyRequestOnPlaybackStart(apiClient, origin) {
|
||||
Helper.waitForEventOnce(this.manager, 'playbackstart', Helper.WaitForEventDefaultTimeout, ['playbackerror']).then(() => {
|
||||
Helper.waitForEventOnce(this.manager, 'playbackstart', Helper.WaitForEventDefaultTimeout, ['playbackerror']).then(async () => {
|
||||
console.debug('SyncPlay scheduleReadyRequestOnPlaybackStart: local pause and notify server.');
|
||||
const playerWrapper = this.manager.getPlayerWrapper();
|
||||
playerWrapper.localPause();
|
||||
|
||||
const currentTime = new Date();
|
||||
const now = this.manager.timeSyncCore.localDateToRemote(currentTime);
|
||||
const currentPosition = playerWrapper.currentTime();
|
||||
const currentPosition = (playerWrapper.currentTimeAsync
|
||||
? await playerWrapper.currentTimeAsync()
|
||||
: playerWrapper.currentTime());
|
||||
const currentPositionTicks = Math.round(currentPosition * Helper.TicksPerMillisecond);
|
||||
const isPlaying = playerWrapper.isPlaying();
|
||||
|
||||
|
@ -185,7 +189,7 @@ class QueueCore {
|
|||
}).catch((error) => {
|
||||
console.error('Error while waiting for `playbackstart` event!', origin, error);
|
||||
if (!this.manager.isSyncPlayEnabled()) {
|
||||
Helper.showMessage(this.manager, 'MessageSyncPlayErrorMedia');
|
||||
toast(globalize.translate('MessageSyncPlayErrorMedia'));
|
||||
}
|
||||
|
||||
this.manager.haltGroupPlayback(apiClient);
|
||||
|
@ -234,7 +238,7 @@ class QueueCore {
|
|||
this.scheduleReadyRequestOnPlaybackStart(apiClient, 'startPlayback');
|
||||
}).catch((error) => {
|
||||
console.error(error);
|
||||
Helper.showMessage(this.manager, 'MessageSyncPlayErrorMedia');
|
||||
toast(globalize.translate('MessageSyncPlayErrorMedia'));
|
||||
});
|
||||
}
|
||||
|
||||
|
|
28
src/components/syncPlay/core/Settings.js
Normal file
28
src/components/syncPlay/core/Settings.js
Normal file
|
@ -0,0 +1,28 @@
|
|||
/**
|
||||
* Module that manages SyncPlay settings.
|
||||
* @module components/syncPlay/core/Settings
|
||||
*/
|
||||
import appSettings from '../../../scripts/settings/appSettings';
|
||||
|
||||
/**
|
||||
* Prefix used when saving SyncPlay settings.
|
||||
*/
|
||||
const PREFIX = 'syncPlay';
|
||||
|
||||
/**
|
||||
* Gets the value of a setting.
|
||||
* @param {string} name The name of the setting.
|
||||
* @returns {string} The value.
|
||||
*/
|
||||
export function getSetting(name) {
|
||||
return appSettings.get(name, PREFIX);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the value of a setting. Triggers an update if the new value differs from the old one.
|
||||
* @param {string} name The name of the setting.
|
||||
* @param {Object} value The value of the setting.
|
||||
*/
|
||||
export function setSetting(name, value) {
|
||||
return appSettings.set(name, value, PREFIX);
|
||||
}
|
|
@ -44,13 +44,15 @@ class PlayerFactory {
|
|||
return this.getDefaultWrapper(syncPlayManager);
|
||||
}
|
||||
|
||||
console.debug('SyncPlay WrapperFactory getWrapper:', player.id);
|
||||
const Wrapper = this.wrappers[player.id];
|
||||
const playerId = player.syncPlayWrapAs || player.id;
|
||||
|
||||
console.debug('SyncPlay WrapperFactory getWrapper:', playerId);
|
||||
const Wrapper = this.wrappers[playerId];
|
||||
if (Wrapper) {
|
||||
return new Wrapper(player, syncPlayManager);
|
||||
}
|
||||
|
||||
console.debug(`SyncPlay WrapperFactory getWrapper: unknown player ${player.id}, using default wrapper.`);
|
||||
console.debug(`SyncPlay WrapperFactory getWrapper: unknown player ${playerId}, using default wrapper.`);
|
||||
return this.getDefaultWrapper(syncPlayManager);
|
||||
}
|
||||
|
||||
|
|
|
@ -4,8 +4,21 @@
|
|||
*/
|
||||
|
||||
import { Events } from 'jellyfin-apiclient';
|
||||
import appSettings from '../../../../scripts/settings/appSettings';
|
||||
import { toFloat } from '../../../../scripts/stringUtils';
|
||||
import { getSetting } from '../Settings';
|
||||
import TimeSyncServer from './TimeSyncServer';
|
||||
|
||||
/**
|
||||
* Utility function to offset a given date by a given amount of milliseconds.
|
||||
* @param {Date} date The date.
|
||||
* @param {number} offset The offset, in milliseconds.
|
||||
* @returns {Date} The offset date.
|
||||
*/
|
||||
function offsetDate(date, offset) {
|
||||
return new Date(date.getTime() + offset);
|
||||
}
|
||||
|
||||
/**
|
||||
* Class that manages time syncing with several devices.
|
||||
*/
|
||||
|
@ -13,6 +26,9 @@ class TimeSyncCore {
|
|||
constructor() {
|
||||
this.manager = null;
|
||||
this.timeSyncServer = null;
|
||||
|
||||
this.timeSyncDeviceId = getSetting('timeSyncDevice') || 'server';
|
||||
this.extraTimeOffset = toFloat(getSetting('extraTimeOffset'), 0.0);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -31,6 +47,12 @@ class TimeSyncCore {
|
|||
|
||||
Events.trigger(this, 'time-sync-server-update', [timeOffset, ping]);
|
||||
});
|
||||
|
||||
Events.on(appSettings, 'change', function (e, name) {
|
||||
if (name === 'extraTimeOffset') {
|
||||
this.extraTimeOffset = toFloat(getSetting('extraTimeOffset'), 0.0);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -54,7 +76,8 @@ class TimeSyncCore {
|
|||
* @returns {Date} Local time.
|
||||
*/
|
||||
remoteDateToLocal(remote) {
|
||||
return this.timeSyncServer.remoteDateToLocal(remote);
|
||||
const date = this.timeSyncServer.remoteDateToLocal(remote);
|
||||
return offsetDate(date, -this.extraTimeOffset);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -63,15 +86,16 @@ class TimeSyncCore {
|
|||
* @returns {Date} Server time.
|
||||
*/
|
||||
localDateToRemote(local) {
|
||||
return this.timeSyncServer.localDateToRemote(local);
|
||||
const date = this.timeSyncServer.localDateToRemote(local);
|
||||
return offsetDate(date, this.extraTimeOffset);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets time offset that should be used for time syncing, in milliseconds.
|
||||
* Gets time offset that should be used for time syncing, in milliseconds. Takes into account server and active device selected for syncing.
|
||||
* @returns {number} The time offset.
|
||||
*/
|
||||
getTimeOffset() {
|
||||
return this.timeSyncServer.getTimeOffset();
|
||||
return this.timeSyncServer.getTimeOffset() + this.extraTimeOffset;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import { Events } from 'jellyfin-apiclient';
|
||||
import SyncPlay from '../core';
|
||||
import SyncPlaySettingsEditor from './settings/SettingsEditor';
|
||||
import loading from '../../loading/loading';
|
||||
import toast from '../../toast/toast';
|
||||
import actionsheet from '../../actionSheet/actionSheet';
|
||||
|
@ -62,7 +63,6 @@ class GroupSelectionMenu {
|
|||
title: globalize.translate('HeaderSyncPlaySelectGroup'),
|
||||
items: menuItems,
|
||||
positionTo: button,
|
||||
resolveOnClick: true,
|
||||
border: true
|
||||
};
|
||||
|
||||
|
@ -77,7 +77,9 @@ class GroupSelectionMenu {
|
|||
});
|
||||
}
|
||||
}).catch((error) => {
|
||||
if (error) {
|
||||
console.error('SyncPlay: unexpected error listing groups:', error);
|
||||
}
|
||||
});
|
||||
|
||||
loading.hide();
|
||||
|
@ -119,6 +121,14 @@ class GroupSelectionMenu {
|
|||
});
|
||||
}
|
||||
|
||||
menuItems.push({
|
||||
name: globalize.translate('Settings'),
|
||||
icon: 'video_settings',
|
||||
id: 'settings',
|
||||
selected: false,
|
||||
secondaryText: globalize.translate('LabelSyncPlaySettingsDescription')
|
||||
});
|
||||
|
||||
menuItems.push({
|
||||
name: globalize.translate('LabelSyncPlayLeaveGroup'),
|
||||
icon: 'meeting_room',
|
||||
|
@ -131,7 +141,6 @@ class GroupSelectionMenu {
|
|||
title: groupInfo.GroupName,
|
||||
items: menuItems,
|
||||
positionTo: button,
|
||||
resolveOnClick: true,
|
||||
border: true
|
||||
};
|
||||
|
||||
|
@ -142,9 +151,19 @@ class GroupSelectionMenu {
|
|||
SyncPlay.Manager.haltGroupPlayback(apiClient);
|
||||
} else if (id == 'leave-group') {
|
||||
apiClient.leaveSyncPlayGroup();
|
||||
} else if (id == 'settings') {
|
||||
new SyncPlaySettingsEditor(apiClient, SyncPlay.Manager.getTimeSyncCore(), { groupInfo: groupInfo })
|
||||
.embed()
|
||||
.catch(error => {
|
||||
if (error) {
|
||||
console.error('Error creating SyncPlay settings editor', error);
|
||||
}
|
||||
});
|
||||
}
|
||||
}).catch((error) => {
|
||||
if (error) {
|
||||
console.error('SyncPlay: unexpected error showing group menu:', error);
|
||||
}
|
||||
});
|
||||
|
||||
loading.hide();
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
import { appHost } from '../../apphost';
|
||||
|
||||
/**
|
||||
* Creates an audio element that plays a silent sound.
|
||||
* @returns {HTMLMediaElement} The audio element.
|
||||
|
@ -33,6 +35,10 @@ class PlaybackPermissionManager {
|
|||
* @returns {Promise} Promise that resolves succesfully if playback permission is allowed.
|
||||
*/
|
||||
check () {
|
||||
if (appHost.supports('htmlaudioautoplay')) {
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const media = createTestMediaElement();
|
||||
media.play().then(() => {
|
||||
|
|
|
@ -17,6 +17,16 @@ class HtmlVideoPlayer extends NoActivePlayer {
|
|||
this.isPlayerActive = false;
|
||||
this.savedPlaybackRate = 1.0;
|
||||
this.minBufferingThresholdMillis = 3000;
|
||||
|
||||
if (player.currentTimeAsync) {
|
||||
/**
|
||||
* Gets current playback position.
|
||||
* @returns {Promise<number>} The player position, in milliseconds.
|
||||
*/
|
||||
this.currentTimeAsync = () => {
|
||||
return this.player.currentTimeAsync();
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -45,6 +45,7 @@ class NoActivePlayer extends SyncPlay.Players.GenericPlayer {
|
|||
|
||||
playbackManager._localPlay = playbackManager.play;
|
||||
playbackManager._localSetCurrentPlaylistItem = playbackManager.setCurrentPlaylistItem;
|
||||
playbackManager._localClearQueue = playbackManager.clearQueue;
|
||||
playbackManager._localRemoveFromPlaylist = playbackManager.removeFromPlaylist;
|
||||
playbackManager._localMovePlaylistItem = playbackManager.movePlaylistItem;
|
||||
playbackManager._localQueue = playbackManager.queue;
|
||||
|
@ -62,6 +63,7 @@ class NoActivePlayer extends SyncPlay.Players.GenericPlayer {
|
|||
|
||||
playbackManager.play = this.playRequest;
|
||||
playbackManager.setCurrentPlaylistItem = this.setCurrentPlaylistItemRequest;
|
||||
playbackManager.clearQueue = this.clearQueueRequest;
|
||||
playbackManager.removeFromPlaylist = this.removeFromPlaylistRequest;
|
||||
playbackManager.movePlaylistItem = this.movePlaylistItemRequest;
|
||||
playbackManager.queue = this.queueRequest;
|
||||
|
@ -93,6 +95,7 @@ class NoActivePlayer extends SyncPlay.Players.GenericPlayer {
|
|||
|
||||
playbackManager.play = playbackManager._localPlay;
|
||||
playbackManager.setCurrentPlaylistItem = playbackManager._localSetCurrentPlaylistItem;
|
||||
playbackManager.clearQueue = this._localClearQueue;
|
||||
playbackManager.removeFromPlaylist = playbackManager._localRemoveFromPlaylist;
|
||||
playbackManager.movePlaylistItem = playbackManager._localMovePlaylistItem;
|
||||
playbackManager.queue = playbackManager._localQueue;
|
||||
|
@ -247,6 +250,14 @@ class NoActivePlayer extends SyncPlay.Players.GenericPlayer {
|
|||
controller.setCurrentPlaylistItem(playlistItemId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Overrides PlaybackManager's clearQueue method.
|
||||
*/
|
||||
clearQueueRequest(clearPlayingItem) {
|
||||
const controller = syncPlayManager.getController();
|
||||
controller.clearPlaylist(clearPlayingItem);
|
||||
}
|
||||
|
||||
/**
|
||||
* Overrides PlaybackManager's removeFromPlaylist method.
|
||||
*/
|
||||
|
|
147
src/components/syncPlay/ui/settings/SettingsEditor.js
Normal file
147
src/components/syncPlay/ui/settings/SettingsEditor.js
Normal file
|
@ -0,0 +1,147 @@
|
|||
/**
|
||||
* Module that displays an editor for changing SyncPlay settings.
|
||||
* @module components/syncPlay/settings/SettingsEditor
|
||||
*/
|
||||
|
||||
import { Events } from 'jellyfin-apiclient';
|
||||
import SyncPlay from '../../core';
|
||||
import { getSetting, setSetting } from '../../core/Settings';
|
||||
import dialogHelper from '../../../dialogHelper/dialogHelper';
|
||||
import layoutManager from '../../../layoutManager';
|
||||
import loading from '../../../loading/loading';
|
||||
import toast from '../../../toast/toast';
|
||||
import globalize from '../../../../scripts/globalize';
|
||||
import { toBoolean, toFloat } from '../../../../scripts/stringUtils';
|
||||
|
||||
import 'material-design-icons-iconfont';
|
||||
import '../../../../elements/emby-input/emby-input';
|
||||
import '../../../../elements/emby-select/emby-select';
|
||||
import '../../../../elements/emby-button/emby-button';
|
||||
import '../../../../elements/emby-button/paper-icon-button-light';
|
||||
import '../../../../elements/emby-checkbox/emby-checkbox';
|
||||
import '../../../listview/listview.scss';
|
||||
import '../../../formdialog.scss';
|
||||
|
||||
function centerFocus(elem, horiz, on) {
|
||||
import('../../../../scripts/scrollHelper').then((scrollHelper) => {
|
||||
const fn = on ? 'on' : 'off';
|
||||
scrollHelper.centerFocus[fn](elem, horiz);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Class that displays an editor for changing SyncPlay settings.
|
||||
*/
|
||||
class SettingsEditor {
|
||||
constructor(apiClient, timeSyncCore, options = {}) {
|
||||
this.apiClient = apiClient;
|
||||
this.timeSyncCore = timeSyncCore;
|
||||
this.options = options;
|
||||
}
|
||||
|
||||
async embed() {
|
||||
const dialogOptions = {
|
||||
removeOnClose: true,
|
||||
scrollY: true
|
||||
};
|
||||
|
||||
if (layoutManager.tv) {
|
||||
dialogOptions.size = 'fullscreen';
|
||||
} else {
|
||||
dialogOptions.size = 'small';
|
||||
}
|
||||
|
||||
this.context = dialogHelper.createDialog(dialogOptions);
|
||||
this.context.classList.add('formDialog');
|
||||
|
||||
const { default: editorTemplate } = await import('./editor.html');
|
||||
this.context.innerHTML = globalize.translateHtml(editorTemplate, 'core');
|
||||
|
||||
// Set callbacks for form submission
|
||||
this.context.querySelector('form').addEventListener('submit', (event) => {
|
||||
// Disable default form submission
|
||||
if (event) {
|
||||
event.preventDefault();
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
this.context.querySelector('.btnSave').addEventListener('click', () => {
|
||||
this.onSubmit();
|
||||
});
|
||||
|
||||
this.context.querySelector('.btnCancel').addEventListener('click', () => {
|
||||
dialogHelper.close(this.context);
|
||||
});
|
||||
|
||||
await this.initEditor();
|
||||
|
||||
if (layoutManager.tv) {
|
||||
centerFocus(this.context.querySelector('.formDialogContent'), false, true);
|
||||
}
|
||||
|
||||
return dialogHelper.open(this.context).then(() => {
|
||||
if (layoutManager.tv) {
|
||||
centerFocus(this.context.querySelector('.formDialogContent'), false, false);
|
||||
}
|
||||
|
||||
if (this.context.submitted) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
return Promise.reject();
|
||||
});
|
||||
}
|
||||
|
||||
async initEditor() {
|
||||
const { context } = this;
|
||||
|
||||
context.querySelector('#txtExtraTimeOffset').value = toFloat(getSetting('extraTimeOffset'), 0.0);
|
||||
context.querySelector('#chkSyncCorrection').checked = toBoolean(getSetting('enableSyncCorrection'), true);
|
||||
context.querySelector('#txtMinDelaySpeedToSync').value = toFloat(getSetting('minDelaySpeedToSync'), 60.0);
|
||||
context.querySelector('#txtMaxDelaySpeedToSync').value = toFloat(getSetting('maxDelaySpeedToSync'), 3000.0);
|
||||
context.querySelector('#txtSpeedToSyncDuration').value = toFloat(getSetting('speedToSyncDuration'), 1000.0);
|
||||
context.querySelector('#txtMinDelaySkipToSync').value = toFloat(getSetting('minDelaySkipToSync'), 400.0);
|
||||
context.querySelector('#chkSpeedToSync').checked = toBoolean(getSetting('useSpeedToSync'), true);
|
||||
context.querySelector('#chkSkipToSync').checked = toBoolean(getSetting('useSkipToSync'), true);
|
||||
}
|
||||
|
||||
onSubmit() {
|
||||
this.save();
|
||||
dialogHelper.close(this.context);
|
||||
}
|
||||
|
||||
async save() {
|
||||
loading.show();
|
||||
await this.saveToAppSettings();
|
||||
loading.hide();
|
||||
toast(globalize.translate('SettingsSaved'));
|
||||
Events.trigger(this, 'saved');
|
||||
}
|
||||
|
||||
async saveToAppSettings() {
|
||||
const { context } = this;
|
||||
|
||||
const extraTimeOffset = context.querySelector('#txtExtraTimeOffset').value;
|
||||
const syncCorrection = context.querySelector('#chkSyncCorrection').checked;
|
||||
const minDelaySpeedToSync = context.querySelector('#txtMinDelaySpeedToSync').value;
|
||||
const maxDelaySpeedToSync = context.querySelector('#txtMaxDelaySpeedToSync').value;
|
||||
const speedToSyncDuration = context.querySelector('#txtSpeedToSyncDuration').value;
|
||||
const minDelaySkipToSync = context.querySelector('#txtMinDelaySkipToSync').value;
|
||||
const useSpeedToSync = context.querySelector('#chkSpeedToSync').checked;
|
||||
const useSkipToSync = context.querySelector('#chkSkipToSync').checked;
|
||||
|
||||
setSetting('extraTimeOffset', extraTimeOffset);
|
||||
setSetting('enableSyncCorrection', syncCorrection);
|
||||
setSetting('minDelaySpeedToSync', minDelaySpeedToSync);
|
||||
setSetting('maxDelaySpeedToSync', maxDelaySpeedToSync);
|
||||
setSetting('speedToSyncDuration', speedToSyncDuration);
|
||||
setSetting('minDelaySkipToSync', minDelaySkipToSync);
|
||||
setSetting('useSpeedToSync', useSpeedToSync);
|
||||
setSetting('useSkipToSync', useSkipToSync);
|
||||
|
||||
Events.trigger(SyncPlay.Manager, 'settings-update');
|
||||
}
|
||||
}
|
||||
|
||||
export default SettingsEditor;
|
75
src/components/syncPlay/ui/settings/editor.html
Normal file
75
src/components/syncPlay/ui/settings/editor.html
Normal file
|
@ -0,0 +1,75 @@
|
|||
<div class="formDialogHeader">
|
||||
<button is="paper-icon-button-light" class="btnCancel autoSize" tabindex="-1">
|
||||
<span class="material-icons arrow_back"></span>
|
||||
</button>
|
||||
<h3 class="formDialogHeaderTitle">${HeaderSyncPlaySettings}</h3>
|
||||
</div>
|
||||
<div class="formDialogContent smoothScrollY">
|
||||
<div class="dialogContentInner dialog-content-centered">
|
||||
|
||||
<form style="margin: auto;">
|
||||
<h2 class="sectionTitle">${HeaderSyncPlayPlaybackSettings}</h2>
|
||||
|
||||
<!-- Sync Correction Setting -->
|
||||
<div class="checkboxContainer checkboxContainer-withDescription">
|
||||
<label>
|
||||
<input type="checkbox" is="emby-checkbox" id="chkSyncCorrection" />
|
||||
<span>${LabelSyncPlaySettingsSyncCorrection}</span>
|
||||
</label>
|
||||
<div class="fieldDescription checkboxFieldDescription">${LabelSyncPlaySettingsSyncCorrectionHelp}</div>
|
||||
</div>
|
||||
|
||||
<!-- SpeedToSync Settings -->
|
||||
<div class="checkboxContainer checkboxContainer-withDescription">
|
||||
<label>
|
||||
<input type="checkbox" is="emby-checkbox" id="chkSpeedToSync" />
|
||||
<span>${LabelSyncPlaySettingsSpeedToSync}</span>
|
||||
</label>
|
||||
<div class="fieldDescription checkboxFieldDescription">${LabelSyncPlaySettingsSpeedToSyncHelp}</div>
|
||||
</div>
|
||||
<div class="inputContainer inputContainer-withDescription">
|
||||
<input type="number" is="emby-input" id="txtMinDelaySpeedToSync" pattern="[0-9]*"
|
||||
label="${LabelSyncPlaySettingsMinDelaySpeedToSync}" />
|
||||
<div class="fieldDescription">${LabelSyncPlaySettingsMinDelaySpeedToSyncHelp}</div>
|
||||
</div>
|
||||
<div class="inputContainer inputContainer-withDescription">
|
||||
<input type="number" is="emby-input" id="txtMaxDelaySpeedToSync" pattern="[0-9]*"
|
||||
label="${LabelSyncPlaySettingsMaxDelaySpeedToSync}" />
|
||||
<div class="fieldDescription">${LabelSyncPlaySettingsMaxDelaySpeedToSyncHelp}</div>
|
||||
</div>
|
||||
<div class="inputContainer inputContainer-withDescription">
|
||||
<input type="number" is="emby-input" id="txtSpeedToSyncDuration" pattern="[0-9]*"
|
||||
label="${LabelSyncPlaySettingsSpeedToSyncDuration}" />
|
||||
<div class="fieldDescription">${LabelSyncPlaySettingsSpeedToSyncDurationHelp}</div>
|
||||
</div>
|
||||
|
||||
<!-- SkipToSync Settings -->
|
||||
<div class="checkboxContainer checkboxContainer-withDescription">
|
||||
<label>
|
||||
<input type="checkbox" is="emby-checkbox" id="chkSkipToSync" />
|
||||
<span>${LabelSyncPlaySettingsSkipToSync}</span>
|
||||
</label>
|
||||
<div class="fieldDescription checkboxFieldDescription">${LabelSyncPlaySettingsSkipToSyncHelp}</div>
|
||||
</div>
|
||||
<div class="inputContainer inputContainer-withDescription">
|
||||
<input type="number" is="emby-input" id="txtMinDelaySkipToSync" pattern="[0-9]*"
|
||||
label="${LabelSyncPlaySettingsMinDelaySkipToSync}" />
|
||||
<div class="fieldDescription">${LabelSyncPlaySettingsMinDelaySkipToSyncHelp}</div>
|
||||
</div>
|
||||
|
||||
<!-- Time Settings -->
|
||||
<h2 class="sectionTitle">${HeaderSyncPlayTimeSyncSettings}</h2>
|
||||
<div class="inputContainer inputContainer-withDescription">
|
||||
<input type="number" is="emby-input" id="txtExtraTimeOffset" pattern="[0-9]*"
|
||||
label="${LabelSyncPlaySettingsExtraTimeOffset}" />
|
||||
<div class="fieldDescription">${LabelSyncPlaySettingsExtraTimeOffsetHelp}</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="formDialogFooter" id="footer">
|
||||
<button is="emby-button" type="submit" class="raised button-submit block btnSave formDialogFooterItem">
|
||||
<span id="saveButtonText">${Save}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
|
@ -1,34 +0,0 @@
|
|||
/**
|
||||
* Module that notifies user about SyncPlay messages using toasts.
|
||||
* @module components/syncPlay/syncPlayToasts
|
||||
*/
|
||||
|
||||
import { Events } from 'jellyfin-apiclient';
|
||||
import toast from '../../toast/toast';
|
||||
import globalize from '../../../scripts/globalize';
|
||||
import SyncPlay from '../core';
|
||||
|
||||
/**
|
||||
* Class that notifies user about SyncPlay messages using toasts.
|
||||
*/
|
||||
class SyncPlayToasts {
|
||||
constructor() {
|
||||
// Do nothing.
|
||||
}
|
||||
|
||||
/**
|
||||
* Listens for messages to show.
|
||||
*/
|
||||
init() {
|
||||
Events.on(SyncPlay.Manager, 'show-message', (event, data) => {
|
||||
const { message, args = [] } = data;
|
||||
toast({
|
||||
text: globalize.translate(message, ...args)
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** SyncPlayToasts singleton. */
|
||||
const syncPlayToasts = new SyncPlayToasts();
|
||||
export default syncPlayToasts;
|
|
@ -22,7 +22,7 @@ export default function (options) {
|
|||
|
||||
const elem = document.createElement('div');
|
||||
elem.classList.add('toast');
|
||||
elem.innerHTML = options.text;
|
||||
elem.textContent = options.text;
|
||||
|
||||
document.body.appendChild(elem);
|
||||
|
||||
|
|
|
@ -94,13 +94,13 @@ import '../../assets/css/flexstyles.scss';
|
|||
}
|
||||
}
|
||||
|
||||
function onStartNowClick() {
|
||||
async function onStartNowClick() {
|
||||
const options = this.options;
|
||||
|
||||
if (options) {
|
||||
const player = options.player;
|
||||
|
||||
this.hide();
|
||||
await this.hide();
|
||||
|
||||
playbackManager.nextTrack(player);
|
||||
}
|
||||
|
@ -139,7 +139,7 @@ import '../../assets/css/flexstyles.scss';
|
|||
Events.trigger(instance, 'hide');
|
||||
}
|
||||
|
||||
function hideComingUpNext() {
|
||||
async function hideComingUpNext() {
|
||||
const instance = this;
|
||||
clearCountdownTextTimeout(this);
|
||||
|
||||
|
@ -159,17 +159,21 @@ import '../../assets/css/flexstyles.scss';
|
|||
return;
|
||||
}
|
||||
|
||||
const fn = onHideAnimationComplete.bind(instance);
|
||||
instance._onHideAnimationComplete = fn;
|
||||
|
||||
const transitionEvent = await new Promise((resolve) => {
|
||||
dom.addEventListener(elem, transitionEndEventName, resolve, {
|
||||
once: true
|
||||
});
|
||||
|
||||
// trigger a reflow to force it to animate again
|
||||
void elem.offsetWidth;
|
||||
|
||||
elem.classList.add('upNextDialog-hidden');
|
||||
|
||||
const fn = onHideAnimationComplete.bind(instance);
|
||||
instance._onHideAnimationComplete = fn;
|
||||
|
||||
dom.addEventListener(elem, transitionEndEventName, fn, {
|
||||
once: true
|
||||
});
|
||||
|
||||
instance._onHideAnimationComplete(transitionEvent);
|
||||
}
|
||||
|
||||
function getTimeRemainingMs(instance) {
|
||||
|
@ -226,8 +230,8 @@ class UpNextDialog {
|
|||
|
||||
startComingUpNextHideTimer(this);
|
||||
}
|
||||
hide() {
|
||||
hideComingUpNext.call(this);
|
||||
async hide() {
|
||||
await hideComingUpNext.bind(this)();
|
||||
}
|
||||
destroy() {
|
||||
hideComingUpNext.call(this);
|
||||
|
|
|
@ -21,9 +21,9 @@ viewContainer.setOnBeforeChange(function (newView, isRestored, options) {
|
|||
newView.initComplete = true;
|
||||
|
||||
if (typeof options.controllerFactory === 'function') {
|
||||
new options.controllerFactory(newView, eventDetail.detail.params);
|
||||
new options.controllerFactory(newView, eventDetail.detail.params, eventDetail);
|
||||
} else if (options.controllerFactory && typeof options.controllerFactory.default === 'function') {
|
||||
new options.controllerFactory.default(newView, eventDetail.detail.params);
|
||||
new options.controllerFactory.default(newView, eventDetail.detail.params, eventDetail);
|
||||
}
|
||||
|
||||
if (!options.controllerFactory || dispatchPageEvents) {
|
||||
|
|
|
@ -23,6 +23,7 @@
|
|||
"id": "wmc"
|
||||
}
|
||||
],
|
||||
"menuLinks": [],
|
||||
"servers": [],
|
||||
"plugins": [
|
||||
"playAccessValidation/plugin",
|
||||
|
|
|
@ -270,7 +270,7 @@ import confirm from '../../components/confirm/confirm';
|
|||
html += '<div class="sessionNowPlayingContent"></div>';
|
||||
}
|
||||
|
||||
html += '<div class="sessionNowPlayingInnerContent">';
|
||||
html += `<div class="sessionNowPlayingInnerContent ${imgUrl ? 'darkenContent' : ''}">`;
|
||||
html += '<div class="sessionAppInfo">';
|
||||
const clientImage = DashboardPage.getClientImage(session);
|
||||
|
||||
|
@ -608,8 +608,10 @@ import confirm from '../../components/confirm/confirm';
|
|||
|
||||
if (imgUrl) {
|
||||
imgElem.classList.add('sessionNowPlayingContent-withbackground');
|
||||
row.querySelector('.sessionNowPlayingInnerContent').classList.add('darkenContent');
|
||||
} else {
|
||||
imgElem.classList.remove('sessionNowPlayingContent-withbackground');
|
||||
row.querySelector('.sessionNowPlayingInnerContent').classList.remove('darkenContent');
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
@ -635,7 +635,7 @@ import toast from '../../../components/toast/toast';
|
|||
data: JSON.stringify(profile),
|
||||
contentType: 'application/json'
|
||||
}).then(function () {
|
||||
toast('Settings saved.');
|
||||
toast(globalize.translate('SettingsSaved'));
|
||||
}, Dashboard.processErrorResponse);
|
||||
} else {
|
||||
ApiClient.ajax({
|
||||
|
|
|
@ -134,6 +134,7 @@
|
|||
<option value="reinhard">Reinhard</option>
|
||||
<option value="hable">Hable</option>
|
||||
<option value="mobius">Mobius</option>
|
||||
<option value="bt2390">BT.2390</option>
|
||||
</select>
|
||||
<div class="fieldDescription">
|
||||
<a is="emby-linkbutton" rel="noopener noreferrer" class="button-link" href="http://ffmpeg.org/ffmpeg-all.html#tonemap_005fopencl" target="_blank">${TonemappingAlgorithmHelp}</a>
|
||||
|
|
|
@ -49,6 +49,19 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div class="verticalSection">
|
||||
<div class="sectionTitleContainer flex align-items-center">
|
||||
<h2 class="sectionTitle">${QuickConnect}</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="checkboxList paperList" style="padding:.5em 1em;">
|
||||
<label>
|
||||
<input type="checkbox" is="emby-checkbox" id="chkQuickConnectAvailable" />
|
||||
<span>${EnableQuickConnect}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="verticalSection">
|
||||
<h2>${HeaderBranding}</h2>
|
||||
<div class="inputContainer">
|
||||
|
|
|
@ -14,6 +14,7 @@ import alert from '../../components/alert';
|
|||
function loadPage(page, config, languageOptions, systemInfo) {
|
||||
page.querySelector('#txtServerName').value = systemInfo.ServerName;
|
||||
page.querySelector('#txtCachePath').value = systemInfo.CachePath || '';
|
||||
page.querySelector('#chkQuickConnectAvailable').checked = config.QuickConnectAvailable === true;
|
||||
$('#txtMetadataPath', page).val(systemInfo.InternalMetadataPath || '');
|
||||
$('#txtMetadataNetworkPath', page).val(systemInfo.MetadataNetworkPath || '');
|
||||
$('#selectLocalizationLanguage', page).html(languageOptions.map(function (language) {
|
||||
|
@ -33,6 +34,7 @@ import alert from '../../components/alert';
|
|||
config.CachePath = form.querySelector('#txtCachePath').value;
|
||||
config.MetadataPath = $('#txtMetadataPath', form).val();
|
||||
config.MetadataNetworkPath = $('#txtMetadataNetworkPath', form).val();
|
||||
config.QuickConnectAvailable = form.querySelector('#chkQuickConnectAvailable').checked;
|
||||
ApiClient.updateServerConfiguration(config).then(function() {
|
||||
ApiClient.getNamedConfiguration(brandingConfigKey).then(function(brandingConfig) {
|
||||
brandingConfig.LoginDisclaimer = form.querySelector('#txtLoginDisclaimer').value;
|
||||
|
|
|
@ -1,6 +1,31 @@
|
|||
<div id="logPage" data-role="page" class="page type-interior">
|
||||
<div>
|
||||
<div class="content-primary">
|
||||
<form class="logsForm">
|
||||
<div class="verticalSection">
|
||||
<div class="sectionTitleContainer flex align-items-center">
|
||||
<h2 class="sectionTitle">${TabLogs}</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="verticalSection">
|
||||
<div class="checkboxContainer checkboxContainer-withDescription">
|
||||
<label>
|
||||
<input type="checkbox" is="emby-checkbox" id="chkSlowResponseWarning" />
|
||||
<span>${LabelSlowResponseEnabled}</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="inputContainer">
|
||||
<input is="emby-input" type="number" id="txtSlowResponseWarning" label="${LabelSlowResponseTime}" />
|
||||
</div>
|
||||
</div>
|
||||
<br />
|
||||
<div>
|
||||
<button is="emby-button" type="submit" class="raised button-submit block">
|
||||
<span>${Save}</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
<div class="serverLogs readOnlyContent">
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -1,12 +1,32 @@
|
|||
import datetime from '../../scripts/datetime';
|
||||
import loading from '../../components/loading/loading';
|
||||
import globalize from '../../scripts/globalize';
|
||||
import '../../elements/emby-button/emby-button';
|
||||
import '../../components/listview/listview.scss';
|
||||
import '../../assets/css/flexstyles.scss';
|
||||
import Dashboard from '../../scripts/clientUtils';
|
||||
import alert from '../../components/alert';
|
||||
|
||||
/* eslint-disable indent */
|
||||
|
||||
function onSubmit() {
|
||||
loading.show();
|
||||
const form = this;
|
||||
ApiClient.getServerConfiguration().then(function (config) {
|
||||
config.EnableSlowResponseWarning = form.querySelector('#chkSlowResponseWarning').checked;
|
||||
config.SlowResponseThresholdMs = form.querySelector('#txtSlowResponseWarning').value;
|
||||
ApiClient.updateServerConfiguration(config).then(function() {
|
||||
Dashboard.processServerConfigurationUpdateResult();
|
||||
}, function () {
|
||||
alert(globalize.translate('ErrorDefault'));
|
||||
Dashboard.processServerConfigurationUpdateResult();
|
||||
});
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
export default function(view) {
|
||||
view.querySelector('.logsForm').addEventListener('submit', onSubmit);
|
||||
view.addEventListener('viewbeforeshow', function() {
|
||||
loading.show();
|
||||
const apiClient = ApiClient;
|
||||
|
@ -32,8 +52,14 @@ import '../../assets/css/flexstyles.scss';
|
|||
}).join('');
|
||||
html += '</div>';
|
||||
view.querySelector('.serverLogs').innerHTML = html;
|
||||
loading.hide();
|
||||
});
|
||||
|
||||
apiClient.getServerConfiguration().then(function (config) {
|
||||
view.querySelector('#chkSlowResponseWarning').checked = config.EnableSlowResponseWarning;
|
||||
view.querySelector('#txtSlowResponseWarning').value = config.SlowResponseThresholdMs;
|
||||
});
|
||||
|
||||
loading.hide();
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
@ -1,4 +1,6 @@
|
|||
import 'jquery';
|
||||
import marked from 'marked';
|
||||
import DOMPurify from 'dompurify';
|
||||
import loading from '../../../../components/loading/loading';
|
||||
import globalize from '../../../../scripts/globalize';
|
||||
import '../../../../elements/emby-button/emby-button';
|
||||
|
@ -13,7 +15,7 @@ function populateHistory(packageInfo, page) {
|
|||
for (let i = 0; i < length; i++) {
|
||||
const version = packageInfo.versions[i];
|
||||
html += '<h2 style="margin:.5em 0;">' + version.version + '</h2>';
|
||||
html += '<div style="margin-bottom:1.5em;">' + version.changelog + '</div>';
|
||||
html += '<div style="margin-bottom:1.5em;">' + DOMPurify.sanitize(marked(version.changelog)) + '</div>';
|
||||
}
|
||||
|
||||
$('#revisionHistory', page).html(html);
|
||||
|
|
|
@ -1,24 +0,0 @@
|
|||
<div id="quickConnectPage" data-role="page" class="page type-interior advancedConfigurationPage">
|
||||
<div class="content-primary">
|
||||
<form class="quickConnectSettings">
|
||||
<div class="verticalSection">
|
||||
<div class="sectionTitleContainer flex align-items-center">
|
||||
<h2 class="sectionTitle">${QuickConnect}</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>${LabelCurrentStatus}<span id="quickConnectStatus" style="padding:0 0.4em;"></span></div>
|
||||
|
||||
<div class="checkboxList paperList" style="padding:.5em 1em;">
|
||||
<label>
|
||||
<input type="checkbox" is="emby-checkbox" id="chkQuickConnectAvailable" />
|
||||
<span>${EnableQuickConnect}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button is="emby-button" id="btnQuickConnectSubmit" type="submit" class="raised button-submit block">
|
||||
<span>${Save}</span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
|
@ -1,58 +0,0 @@
|
|||
import loading from '../../components/loading/loading';
|
||||
import toast from '../../components/toast/toast';
|
||||
import globalize from '../../scripts/globalize';
|
||||
|
||||
const unavailable = 'Unavailable';
|
||||
const available = 'Available';
|
||||
const active = 'Active';
|
||||
let page;
|
||||
|
||||
export default function(view) {
|
||||
view.addEventListener('viewshow', function () {
|
||||
page = this;
|
||||
loading.show();
|
||||
page.querySelector('#btnQuickConnectSubmit').onclick = onSubmit;
|
||||
updatePage();
|
||||
});
|
||||
}
|
||||
|
||||
function loadPage(status) {
|
||||
const check = status === available || status === active;
|
||||
|
||||
page.querySelector('#quickConnectStatus').textContent = status.toLocaleLowerCase();
|
||||
page.querySelector('#chkQuickConnectAvailable').checked = check;
|
||||
|
||||
loading.hide();
|
||||
}
|
||||
|
||||
function onSubmit() {
|
||||
loading.show();
|
||||
|
||||
const newStatus = page.querySelector('#chkQuickConnectAvailable').checked ? available : unavailable;
|
||||
|
||||
const url = ApiClient.getUrl('/QuickConnect/Available?Status=' + newStatus);
|
||||
|
||||
ApiClient.ajax({
|
||||
type: 'POST',
|
||||
url: url
|
||||
}, true).then(() => {
|
||||
toast(globalize.translate('SettingsSaved'));
|
||||
setTimeout(updatePage, 500);
|
||||
|
||||
return true;
|
||||
}).catch((e) => {
|
||||
console.error('Unable to set quick connect status. error:', e);
|
||||
});
|
||||
|
||||
loading.hide();
|
||||
return false;
|
||||
}
|
||||
|
||||
function updatePage() {
|
||||
ApiClient.getQuickConnect('Status').then((response) => {
|
||||
loadPage(response);
|
||||
return true;
|
||||
}).catch((e) => {
|
||||
console.error('Unable to get quick connect status. error:', e);
|
||||
});
|
||||
}
|
|
@ -256,6 +256,8 @@ import ServerConnections from '../components/ServerConnections';
|
|||
}
|
||||
|
||||
elem.innerHTML = html;
|
||||
window.CustomElements.upgradeSubtree(elem);
|
||||
|
||||
const elems = elem.querySelectorAll('.itemsContainer');
|
||||
|
||||
for (let i = 0, length = elems.length; i < length; i++) {
|
||||
|
|
|
@ -1,15 +1,12 @@
|
|||
<div id="itemDetailPage" data-role="page" class="page libraryPage itemDetailPage noSecondaryNavPage selfBackdropPage" data-backbutton="true">
|
||||
<div id="itemBackdrop" class="itemBackdrop">
|
||||
</div>
|
||||
<div id="itemBackdrop" class="itemBackdrop"></div>
|
||||
|
||||
<div class="detailLogo"></div>
|
||||
|
||||
<div class="detailPageWrapperContainer padded-bottom-page">
|
||||
<div class="detailPagePrimaryContainer padded-left padded-right">
|
||||
<div class="primaryImageWrapper hide">
|
||||
<img id="primaryImage" />
|
||||
</div>
|
||||
|
||||
<div class="infoWrapper infoText">
|
||||
<div class="infoWrapper">
|
||||
<div class="detailImageContainer padded-left"></div>
|
||||
<div class="nameContainer"></div>
|
||||
<div class="itemMiscInfo itemMiscInfo-primary" style="margin-bottom: 0.6em;"></div>
|
||||
<div class="itemMiscInfo itemMiscInfo-secondary" style="margin-bottom: 0.6em;"></div>
|
||||
|
@ -90,29 +87,11 @@
|
|||
</div>
|
||||
</div>
|
||||
<div class="detailPageSecondaryContainer">
|
||||
<div class="detailImageContainer padded-left"></div>
|
||||
<div class="detailPageContent">
|
||||
<div class="detailPagePrimaryContent padded-right">
|
||||
<div class="detailSection">
|
||||
<div class="itemMiscInfo nativeName hide"></div>
|
||||
|
||||
<div class="itemDetailsGroup">
|
||||
<div class="detailsGroupItem genresGroup hide">
|
||||
<div class="genresLabel label"></div>
|
||||
<div class="genres content focuscontainer-x"></div>
|
||||
</div>
|
||||
|
||||
<div class="detailsGroupItem directorsGroup hide">
|
||||
<div class="directorsLabel label"></div>
|
||||
<div class="directors content focuscontainer-x"></div>
|
||||
</div>
|
||||
|
||||
<div class="detailsGroupItem writersGroup hide">
|
||||
<div class="writersLabel label"></div>
|
||||
<div class="writers content focuscontainer-x"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form class="trackSelections hide focuscontainer-x">
|
||||
<div class="selectContainer selectSourceContainer hide trackSelectionFieldContainer flex-shrink-zero">
|
||||
<select is="emby-select" class="selectSource detailTrackSelect" label=""></select>
|
||||
|
@ -129,6 +108,7 @@
|
|||
</form>
|
||||
|
||||
<div class="recordingFields hide" style="margin: 0.5em 0 1.5em;"></div>
|
||||
|
||||
<div class="detailSectionContent">
|
||||
<div class="itemLastPlayed hide"></div>
|
||||
|
||||
|
@ -147,6 +127,23 @@
|
|||
<div class="itemExternalLinks focuscontainer-x hide" style="margin: 0.7em 0; font-size: 92%;"></div>
|
||||
<div class="seriesRecordingEditor"></div>
|
||||
</div>
|
||||
|
||||
<div class="itemDetailsGroup">
|
||||
<div class="detailsGroupItem genresGroup hide">
|
||||
<div class="genresLabel label"></div>
|
||||
<div class="genres content focuscontainer-x"></div>
|
||||
</div>
|
||||
|
||||
<div class="detailsGroupItem directorsGroup hide">
|
||||
<div class="directorsLabel label"></div>
|
||||
<div class="directors content focuscontainer-x"></div>
|
||||
</div>
|
||||
|
||||
<div class="detailsGroupItem writersGroup hide">
|
||||
<div class="writersLabel label"></div>
|
||||
<div class="writers content focuscontainer-x"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
@ -367,6 +367,14 @@ function reloadPlayButtons(page, item) {
|
|||
hideAll(page, 'btnShuffle');
|
||||
}
|
||||
|
||||
const btnResume = page.querySelector('.mainDetailButtons .btnResume');
|
||||
const btnPlay = page.querySelector('.mainDetailButtons .btnPlay');
|
||||
if (layoutManager.tv && !btnResume.classList.contains('hide')) {
|
||||
btnResume.classList.add('fab');
|
||||
} else if (layoutManager.tv && btnResume.classList.contains('hide')) {
|
||||
btnPlay.classList.add('fab');
|
||||
}
|
||||
|
||||
return canPlay;
|
||||
}
|
||||
|
||||
|
@ -552,14 +560,20 @@ function renderBackdrop(item) {
|
|||
}
|
||||
|
||||
function renderDetailPageBackdrop(page, item, apiClient) {
|
||||
// Details banner is disabled in user settings
|
||||
if (!userSettings.detailsBanner()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Disable item backdrop for books and people because they only have primary images
|
||||
if (item.Type === 'Person' || item.Type === 'Book') {
|
||||
return false;
|
||||
}
|
||||
|
||||
let imgUrl;
|
||||
let hasbackdrop = false;
|
||||
const itemBackdropElement = page.querySelector('#itemBackdrop');
|
||||
|
||||
if (layoutManager.mobile || !userSettings.detailsBanner()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (item.BackdropImageTags && item.BackdropImageTags.length) {
|
||||
imgUrl = apiClient.getScaledImageUrl(item.Id, {
|
||||
type: 'Backdrop',
|
||||
|
@ -593,24 +607,6 @@ function renderDetailPageBackdrop(page, item, apiClient) {
|
|||
return hasbackdrop;
|
||||
}
|
||||
|
||||
function renderPrimaryImage(page, item, apiClient) {
|
||||
if (item?.ImageTags?.Primary) {
|
||||
const imageUrl = apiClient.getScaledImageUrl(item.Id, {
|
||||
type: 'Primary',
|
||||
maxWidth: dom.getScreenWidth(),
|
||||
tag: item.ImageTags.Primary
|
||||
});
|
||||
|
||||
const imageElem = page.querySelector('#primaryImage');
|
||||
imageElem.src = imageUrl;
|
||||
imageElem.alt = item.Name;
|
||||
if (item.PrimaryImageAspectRatio === 1) {
|
||||
imageElem.classList.add('aspect-square');
|
||||
}
|
||||
page.querySelector('.primaryImageWrapper')?.classList.remove('hide');
|
||||
}
|
||||
}
|
||||
|
||||
function reloadFromItem(instance, page, params, item, user) {
|
||||
const apiClient = ServerConnections.getApiClient(item.ServerId);
|
||||
|
||||
|
@ -623,9 +619,7 @@ function reloadFromItem(instance, page, params, item, user) {
|
|||
renderLogo(page, item, apiClient);
|
||||
renderDetailPageBackdrop(page, item, apiClient);
|
||||
}
|
||||
if (layoutManager.mobile) {
|
||||
renderPrimaryImage(page, item, apiClient);
|
||||
}
|
||||
|
||||
renderBackdrop(item);
|
||||
|
||||
// Render the main information for the item
|
||||
|
@ -812,8 +806,8 @@ function renderDetailImage(elem, item, imageLoader) {
|
|||
overlayText: false,
|
||||
transition: false,
|
||||
disableIndicators: true,
|
||||
overlayPlayButton: true,
|
||||
action: 'play',
|
||||
overlayPlayButton: layoutManager.mobile ? false : true,
|
||||
action: layoutManager.mobile ? 'none' : 'play',
|
||||
width: dom.getWindowSize().innerWidth * 0.25
|
||||
});
|
||||
|
||||
|
@ -1216,11 +1210,9 @@ function renderMoreFromArtist(view, item, apiClient) {
|
|||
};
|
||||
|
||||
if (item.Type === 'MusicArtist') {
|
||||
query.ContributingArtistIds = item.Id;
|
||||
} else if (apiClient.isMinServerVersion('3.4.1.18')) {
|
||||
query.AlbumArtistIds = item.AlbumArtists[0].Id;
|
||||
query.AlbumArtistIds = item.Id;
|
||||
} else {
|
||||
query.ArtistIds = item.AlbumArtists[0].Id;
|
||||
query.AlbumArtistIds = item.AlbumArtists[0].Id;
|
||||
}
|
||||
|
||||
apiClient.getItems(apiClient.getCurrentUserId(), query).then(function (result) {
|
||||
|
@ -2063,16 +2055,6 @@ export default function (view, params) {
|
|||
function init() {
|
||||
const apiClient = getApiClient();
|
||||
|
||||
const btnResume = view.querySelector('.mainDetailButtons .btnResume');
|
||||
const btnPlay = view.querySelector('.mainDetailButtons .btnPlay');
|
||||
if (layoutManager.tv && !btnResume.classList.contains('hide')) {
|
||||
btnResume.classList.add('fab');
|
||||
btnResume.classList.add('detailFloatingButton');
|
||||
} else if (layoutManager.tv && btnResume.classList.contains('hide')) {
|
||||
btnPlay.classList.add('fab');
|
||||
btnPlay.classList.add('detailFloatingButton');
|
||||
}
|
||||
|
||||
view.querySelectorAll('.btnPlay');
|
||||
bindAll(view, '.btnPlay', 'click', onPlayClick);
|
||||
bindAll(view, '.btnResume', 'click', onPlayClick);
|
||||
|
|
|
@ -1115,8 +1115,12 @@ class ItemsView {
|
|||
let imageType = userSettings.get(basekey + '-imageType');
|
||||
|
||||
if (!imageType && params.type === 'nextup') {
|
||||
if (userSettings.useEpisodeImagesInNextUpAndResume()) {
|
||||
imageType = 'primary';
|
||||
} else {
|
||||
imageType = 'thumb';
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
showTitle: showTitle,
|
||||
|
|
|
@ -61,7 +61,7 @@
|
|||
<div class="pageTabContent flexPageTabContent absolutePageTabContent" id="guideTab" data-index="1" style="width:auto;padding-top:0; padding-bottom: 0!important;">
|
||||
</div>
|
||||
<div class="pageTabContent" id="channelsTab" data-index="2">
|
||||
<div class="flex align-items-center justify-content-center flex-wrap-wrap padded-top padded-left padded-right padded-bottom">
|
||||
<div class="flex align-items-center justify-content-center flex-wrap-wrap padded-top padded-left padded-right padded-bottom focuscontainer-x">
|
||||
<div class="paging"></div>
|
||||
<button is="paper-icon-button-light" class="btnFilter sectionTitleButton" title="${Filter}"><span class="material-icons filter_list"></span></button>
|
||||
</div>
|
||||
|
|
|
@ -234,7 +234,7 @@ import '../../elements/emby-itemscontainer/emby-itemscontainer';
|
|||
});
|
||||
});
|
||||
const btnSelectView = tabContent.querySelector('.btnSelectView');
|
||||
btnSelectView.addEventListener('click', function (e) {
|
||||
btnSelectView.addEventListener('click', (e) => {
|
||||
libraryBrowser.showLayoutMenu(e.target, this.getCurrentViewStyle(), 'List,Poster,PosterCard,Thumb,ThumbCard'.split(','));
|
||||
});
|
||||
btnSelectView.addEventListener('layoutchange', function (e) {
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
<div id="moviesPage" data-role="page" data-dom-cache="true" class="page libraryPage backdropPage collectionEditorPage pageWithAbsoluteTabs withTabs" data-backdroptype="movie">
|
||||
|
||||
<div class="pageTabContent" id="moviesTab" data-index="0">
|
||||
<div class="flex align-items-center justify-content-center flex-wrap-wrap padded-top padded-left padded-right padded-bottom">
|
||||
<div class="flex align-items-center justify-content-center flex-wrap-wrap padded-top padded-left padded-right padded-bottom focuscontainer-x">
|
||||
<div class="paging"></div>
|
||||
<button is="paper-icon-button-light" class="btnSelectView autoSize" title="${ButtonSelectView}"><span class="material-icons view_comfy"></span></button>
|
||||
<button is="paper-icon-button-light" class="btnSort autoSize" title="${Sort}"><span class="material-icons sort_by_alpha"></span></button>
|
||||
|
@ -13,7 +13,7 @@
|
|||
|
||||
<div is="emby-itemscontainer" class="itemsContainer padded-left padded-right">
|
||||
</div>
|
||||
<div class="flex align-items-center justify-content-center flex-wrap-wrap padded-top padded-left padded-right padded-bottom">
|
||||
<div class="flex align-items-center justify-content-center flex-wrap-wrap padded-top padded-left padded-right padded-bottom focuscontainer-x">
|
||||
<div class="paging"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -44,7 +44,7 @@
|
|||
</div>
|
||||
</div>
|
||||
<div class="pageTabContent" id="trailersTab" data-index="2">
|
||||
<div class="flex align-items-center justify-content-center flex-wrap-wrap padded-top padded-left padded-right padded-bottom">
|
||||
<div class="flex align-items-center justify-content-center flex-wrap-wrap padded-top padded-left padded-right padded-bottom focuscontainer-x">
|
||||
<div class="paging"></div>
|
||||
<button is="paper-icon-button-light" class="btnSort autoSize" title="${Sort}"><span class="material-icons sort_by_alpha"></span></button>
|
||||
<button is="paper-icon-button-light" class="btnFilter autoSize" title="${Filter}"><span class="material-icons filter_list"></span></button>
|
||||
|
@ -55,24 +55,24 @@
|
|||
|
||||
<div is="emby-itemscontainer" class="itemsContainer vertical-wrap padded-left padded-right">
|
||||
</div>
|
||||
<div class="flex align-items-center justify-content-center flex-wrap-wrap padded-top padded-left padded-right padded-bottom">
|
||||
<div class="flex align-items-center justify-content-center flex-wrap-wrap padded-top padded-left padded-right padded-bottom focuscontainer-x">
|
||||
<div class="paging"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pageTabContent" id="favoritesTab" data-index="3">
|
||||
<div class="flex align-items-center justify-content-center flex-wrap-wrap padded-top padded-left padded-right padded-bottom">
|
||||
<div class="flex align-items-center justify-content-center flex-wrap-wrap padded-top padded-left padded-right padded-bottom focuscontainer-x">
|
||||
<div class="paging"></div>
|
||||
<button is="paper-icon-button-light" class="btnSelectView autoSize" title="${ButtonSelectView}"><span class="material-icons view_comfy"></span></button>
|
||||
</div>
|
||||
|
||||
<div is="emby-itemscontainer" class="itemsContainer padded-left padded-right">
|
||||
</div>
|
||||
<div class="flex align-items-center justify-content-center flex-wrap-wrap padded-top padded-left padded-right padded-bottom">
|
||||
<div class="flex align-items-center justify-content-center flex-wrap-wrap padded-top padded-left padded-right padded-bottom focuscontainer-x">
|
||||
<div class="paging"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pageTabContent" id="collectionsTab" data-index="4">
|
||||
<div class="flex align-items-center justify-content-center flex-wrap-wrap padded-top padded-left padded-right padded-bottom">
|
||||
<div class="flex align-items-center justify-content-center flex-wrap-wrap padded-top padded-left padded-right padded-bottom focuscontainer-x">
|
||||
<div class="paging"></div>
|
||||
<button is="paper-icon-button-light" class="btnSelectView autoSize" title="${ButtonSelectView}"><span class="material-icons view_comfy"></span></button>
|
||||
<button is="paper-icon-button-light" class="btnSort autoSize" title="${Sort}"><span class="material-icons sort_by_alpha"></span></button>
|
||||
|
@ -81,7 +81,7 @@
|
|||
|
||||
<div is="emby-itemscontainer" class="itemsContainer vertical-wrap centered padded-left padded-right" style="text-align:center;">
|
||||
</div>
|
||||
<div class="flex align-items-center justify-content-center flex-wrap-wrap padded-top padded-left padded-right padded-bottom">
|
||||
<div class="flex align-items-center justify-content-center flex-wrap-wrap padded-top padded-left padded-right padded-bottom focuscontainer-x">
|
||||
<div class="paging"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -238,8 +238,8 @@ import '../../elements/emby-itemscontainer/emby-itemscontainer';
|
|||
});
|
||||
}
|
||||
const btnSelectView = tabContent.querySelector('.btnSelectView');
|
||||
btnSelectView.addEventListener('click', function (e) {
|
||||
libraryBrowser.showLayoutMenu(e.target, this.getCurrentViewStyle, 'Banner,List,Poster,PosterCard,Thumb,ThumbCard'.split(','));
|
||||
btnSelectView.addEventListener('click', (e) => {
|
||||
libraryBrowser.showLayoutMenu(e.target, this.getCurrentViewStyle(), 'Banner,List,Poster,PosterCard,Thumb,ThumbCard'.split(','));
|
||||
});
|
||||
btnSelectView.addEventListener('layoutchange', function (e) {
|
||||
const viewStyle = e.detail.viewStyle;
|
||||
|
|
|
@ -229,7 +229,7 @@ import '../../elements/emby-itemscontainer/emby-itemscontainer';
|
|||
alphaPickerElement.classList.add('alphaPicker-fixed-right');
|
||||
itemsContainer.classList.add('padded-right-withalphapicker');
|
||||
|
||||
tabContent.querySelector('.btnFilter').addEventListener('click', function () {
|
||||
tabContent.querySelector('.btnFilter').addEventListener('click', () => {
|
||||
this.showFilterMenu();
|
||||
});
|
||||
tabContent.querySelector('.btnSort').addEventListener('click', function (e) {
|
||||
|
|
|
@ -9,7 +9,7 @@
|
|||
}
|
||||
</style>
|
||||
<div class="pageTabContent pageTabContent" id="albumsTab" data-index="0">
|
||||
<div class="flex align-items-center justify-content-center flex-wrap-wrap padded-top padded-left padded-right padded-bottom">
|
||||
<div class="flex align-items-center justify-content-center flex-wrap-wrap padded-top padded-left padded-right padded-bottom focuscontainer-x">
|
||||
<div class="paging"></div>
|
||||
<button is="paper-icon-button-light" class="btnPlayAll musicglobalButton" title="${HeaderPlayAll}"><span class="material-icons play_arrow"></span></button>
|
||||
<button is="paper-icon-button-light" class="btnShuffle musicglobalButton" title="${Shuffle}"><span class="material-icons shuffle"></span></button>
|
||||
|
@ -23,7 +23,7 @@
|
|||
|
||||
<div is="emby-itemscontainer" class="itemsContainer padded-left padded-right">
|
||||
</div>
|
||||
<div class="flex align-items-center justify-content-center flex-wrap-wrap padded-top padded-left padded-right padded-bottom">
|
||||
<div class="flex align-items-center justify-content-center flex-wrap-wrap padded-top padded-left padded-right padded-bottom focuscontainer-x">
|
||||
<div class="paging"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -54,7 +54,7 @@
|
|||
<div class="favoriteSections verticalSection"></div>
|
||||
</div>
|
||||
<div class="pageTabContent" id="albumArtistsTab" data-index="2">
|
||||
<div class="flex align-items-center justify-content-center flex-wrap-wrap padded-top padded-left padded-right padded-bottom">
|
||||
<div class="flex align-items-center justify-content-center flex-wrap-wrap padded-top padded-left padded-right padded-bottom focuscontainer-x">
|
||||
<div class="paging"></div>
|
||||
<button is="paper-icon-button-light" class="btnSelectView autoSize" title="${ButtonSelectView}"><span class="material-icons view_comfy"></span></button>
|
||||
<button is="paper-icon-button-light" class="btnFilter autoSize" title="${Filter}"><span class="material-icons filter_list"></span></button>
|
||||
|
@ -65,12 +65,12 @@
|
|||
|
||||
<div is="emby-itemscontainer" class="itemsContainer padded-left padded-right">
|
||||
</div>
|
||||
<div class="flex align-items-center justify-content-center flex-wrap-wrap padded-top padded-left padded-right padded-bottom">
|
||||
<div class="flex align-items-center justify-content-center flex-wrap-wrap padded-top padded-left padded-right padded-bottom focuscontainer-x">
|
||||
<div class="paging"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pageTabContent" id="artistsTab" data-index="3">
|
||||
<div class="flex align-items-center justify-content-center flex-wrap-wrap padded-top padded-left padded-right padded-bottom">
|
||||
<div class="flex align-items-center justify-content-center flex-wrap-wrap padded-top padded-left padded-right padded-bottom focuscontainer-x">
|
||||
<div class="paging"></div>
|
||||
<button is="paper-icon-button-light" class="btnSelectView autoSize" title="${ButtonSelectView}"><span class="material-icons view_comfy"></span></button>
|
||||
<button is="paper-icon-button-light" class="btnFilter autoSize" title="${Filter}"><span class="material-icons filter_list"></span></button>
|
||||
|
@ -81,7 +81,7 @@
|
|||
|
||||
<div is="emby-itemscontainer" class="itemsContainer padded-left padded-right">
|
||||
</div>
|
||||
<div class="flex align-items-center justify-content-center flex-wrap-wrap padded-top padded-left padded-right padded-bottom">
|
||||
<div class="flex align-items-center justify-content-center flex-wrap-wrap padded-top padded-left padded-right padded-bottom focuscontainer-x">
|
||||
<div class="paging"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -90,7 +90,7 @@
|
|||
<div is="emby-itemscontainer" id="items" class="itemsContainer padded-left padded-right padded-top vertical-wrap centered"></div>
|
||||
</div>
|
||||
<div class="pageTabContent" id="songsTab" data-index="5">
|
||||
<div class="flex align-items-center justify-content-center flex-wrap-wrap padded-top padded-left padded-right padded-bottom">
|
||||
<div class="flex align-items-center justify-content-center flex-wrap-wrap padded-top padded-left padded-right padded-bottom focuscontainer-x">
|
||||
<div class="paging"></div>
|
||||
<button is="paper-icon-button-light" class="btnSort autoSize" title="${Sort}"><span class="material-icons sort_by_alpha"></span></button>
|
||||
<button is="paper-icon-button-light" class="btnFilter autoSize" title="${Filter}"><span class="material-icons filter_list"></span></button>
|
||||
|
@ -98,7 +98,7 @@
|
|||
|
||||
<div is="emby-itemscontainer" id="items" class="itemsContainer vertical-list" style="max-width:67.5em;margin: 0 auto;"></div>
|
||||
|
||||
<div class="flex align-items-center justify-content-center flex-wrap-wrap padded-top padded-left padded-right padded-bottom">
|
||||
<div class="flex align-items-center justify-content-center flex-wrap-wrap padded-top padded-left padded-right padded-bottom focuscontainer-x">
|
||||
<div class="paging"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -218,7 +218,7 @@ import '../../elements/emby-itemscontainer/emby-itemscontainer';
|
|||
this.showFilterMenu();
|
||||
});
|
||||
const btnSelectView = tabContent.querySelector('.btnSelectView');
|
||||
btnSelectView.addEventListener('click', function (e) {
|
||||
btnSelectView.addEventListener('click', (e) => {
|
||||
libraryBrowser.showLayoutMenu(e.target, this.getCurrentViewStyle(), 'List,Poster,PosterCard'.split(','));
|
||||
});
|
||||
btnSelectView.addEventListener('layoutchange', function (e) {
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import { playbackManager } from '../../../components/playback/playbackmanager';
|
||||
import SyncPlay from '../../../components/syncPlay/core';
|
||||
import browser from '../../../scripts/browser';
|
||||
import dom from '../../../scripts/dom';
|
||||
import inputManager from '../../../scripts/inputManager';
|
||||
import mouseManager from '../../../scripts/mouseManager';
|
||||
|
@ -312,8 +313,8 @@ import { appRouter } from '../../../components/appRouter';
|
|||
|
||||
function onPointerMove(e) {
|
||||
if ((e.pointerType || (layoutManager.mobile ? 'touch' : 'mouse')) === 'mouse') {
|
||||
const eventX = e.screenX || 0;
|
||||
const eventY = e.screenY || 0;
|
||||
const eventX = e.screenX || e.clientX || 0;
|
||||
const eventY = e.screenY || e.clientY || 0;
|
||||
const obj = lastPointerMoveData;
|
||||
|
||||
if (!obj) {
|
||||
|
@ -544,7 +545,7 @@ import { appRouter } from '../../../components/appRouter';
|
|||
const player = this;
|
||||
currentRuntimeTicks = playbackManager.duration(player);
|
||||
const currentTime = playbackManager.currentTime(player) * 10000;
|
||||
updateTimeDisplay(currentTime, currentRuntimeTicks, playbackManager.playbackStartTime(player), playbackManager.getBufferedRanges(player));
|
||||
updateTimeDisplay(currentTime, currentRuntimeTicks, playbackManager.playbackStartTime(player), playbackManager.getPlaybackRate(player), playbackManager.getBufferedRanges(player));
|
||||
const item = currentItem;
|
||||
refreshProgramInfoIfNeeded(player, item);
|
||||
showComingUpNextIfNeeded(player, item, currentTime, currentRuntimeTicks);
|
||||
|
@ -639,7 +640,7 @@ import { appRouter } from '../../../components/appRouter';
|
|||
btnRewind.disabled = !playState.CanSeek;
|
||||
const nowPlayingItem = state.NowPlayingItem || {};
|
||||
playbackStartTimeTicks = playState.PlaybackStartTimeTicks;
|
||||
updateTimeDisplay(playState.PositionTicks, nowPlayingItem.RunTimeTicks, playState.PlaybackStartTimeTicks, playState.BufferedRanges || []);
|
||||
updateTimeDisplay(playState.PositionTicks, nowPlayingItem.RunTimeTicks, playState.PlaybackStartTimeTicks, playState.PlaybackRate, playState.BufferedRanges || []);
|
||||
updateNowPlayingInfo(player, state);
|
||||
|
||||
if (state.MediaSource && state.MediaSource.SupportsTranscoding && supportedCommands.indexOf('SetMaxStreamingBitrate') !== -1) {
|
||||
|
@ -681,7 +682,7 @@ import { appRouter } from '../../../components/appRouter';
|
|||
return (currentTimeMs - programStartDateMs) / programRuntimeMs * 100;
|
||||
}
|
||||
|
||||
function updateTimeDisplay(positionTicks, runtimeTicks, playbackStartTimeTicks, bufferedRanges) {
|
||||
function updateTimeDisplay(positionTicks, runtimeTicks, playbackStartTimeTicks, playbackRate, bufferedRanges) {
|
||||
if (enableProgressByTimeOfDay) {
|
||||
if (nowPlayingPositionSlider && !nowPlayingPositionSlider.dragging) {
|
||||
if (programStartDateMs && programEndDateMs) {
|
||||
|
@ -716,8 +717,8 @@ import { appRouter } from '../../../components/appRouter';
|
|||
nowPlayingPositionSlider.value = 0;
|
||||
}
|
||||
|
||||
if (runtimeTicks && positionTicks != null && currentRuntimeTicks && !enableProgressByTimeOfDay && currentItem.RunTimeTicks && currentItem.Type !== 'Recording') {
|
||||
endsAtText.innerHTML = ' ' + mediaInfo.getEndsAtFromPosition(runtimeTicks, positionTicks, true);
|
||||
if (runtimeTicks && positionTicks != null && currentRuntimeTicks && !enableProgressByTimeOfDay && currentItem.RunTimeTicks && currentItem.Type !== 'Recording' && playbackRate !== null) {
|
||||
endsAtText.innerHTML = ' ' + mediaInfo.getEndsAtFromPosition(runtimeTicks, positionTicks, playbackRate, true);
|
||||
} else {
|
||||
endsAtText.innerHTML = '';
|
||||
}
|
||||
|
@ -987,14 +988,30 @@ import { appRouter } from '../../../components/appRouter';
|
|||
*/
|
||||
let clickedElement;
|
||||
|
||||
function onClickCapture(e) {
|
||||
// Firefox/Edge emits `click` even if `preventDefault` was used on `keydown`
|
||||
// Ignore 'click' if another element was originally clicked
|
||||
if (!e.target.contains(clickedElement)) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function onKeyDown(e) {
|
||||
clickedElement = e.target;
|
||||
|
||||
const key = keyboardnavigation.getKeyName(e);
|
||||
const isKeyModified = e.ctrlKey || e.altKey || e.metaKey;
|
||||
|
||||
if (!currentVisibleMenu && e.keyCode === 32) {
|
||||
if (e.keyCode === 32) {
|
||||
if (e.target.tagName !== 'BUTTON' || !layoutManager.tv) {
|
||||
playbackManager.playPause(currentPlayer);
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
// Trick Firefox with a null element to skip next click
|
||||
clickedElement = null;
|
||||
}
|
||||
showOsd();
|
||||
return;
|
||||
}
|
||||
|
@ -1304,6 +1321,9 @@ import { appRouter } from '../../../components/appRouter';
|
|||
capture: true,
|
||||
passive: true
|
||||
});
|
||||
if (browser.firefox || browser.edge) {
|
||||
dom.addEventListener(document, 'click', onClickCapture, { capture: true });
|
||||
}
|
||||
} catch (e) {
|
||||
appRouter.goHome();
|
||||
}
|
||||
|
@ -1342,6 +1362,9 @@ import { appRouter } from '../../../components/appRouter';
|
|||
capture: true,
|
||||
passive: true
|
||||
});
|
||||
if (browser.firefox || browser.edge) {
|
||||
dom.removeEventListener(document, 'click', onClickCapture, { capture: true });
|
||||
}
|
||||
stopOsdHideTimer();
|
||||
headerElement.classList.remove('osdHeader');
|
||||
headerElement.classList.remove('osdHeader-hidden');
|
||||
|
@ -1491,10 +1514,7 @@ import { appRouter } from '../../../components/appRouter';
|
|||
playbackManager.previousTrack(currentPlayer);
|
||||
});
|
||||
view.querySelector('.btnPause').addEventListener('click', function () {
|
||||
// Ignore 'click' if another element was originally clicked (Firefox/Edge issue)
|
||||
if (this.contains(clickedElement)) {
|
||||
playbackManager.playPause(currentPlayer);
|
||||
}
|
||||
});
|
||||
view.querySelector('.btnNextTrack').addEventListener('click', function () {
|
||||
playbackManager.nextTrack(currentPlayer);
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue