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

Merge branch 'master' of https://github.com/jellyfin/jellyfin-web into fix-accese

# Conflicts:
#	src/scripts/site.js
This commit is contained in:
grafixeyehero 2021-09-08 02:38:25 +03:00
commit 8e724d3119
168 changed files with 12834 additions and 5898 deletions

View file

@ -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: {
@ -191,6 +210,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'
]
}
]
};

View file

@ -1,13 +1,13 @@
version: 2
updates:
- package-ecosystem: npm
directory: /
schedule:
interval: monthly
open-pull-requests-limit: 10
- package-ecosystem: npm
directory: /
schedule:
interval: weekly
open-pull-requests-limit: 10
- package-ecosystem: github-actions
directory: '/'
schedule:
interval: monthly
open-pull-requests-limit: 10
- package-ecosystem: github-actions
directory: /
schedule:
interval: weekly
open-pull-requests-limit: 10

17
.github/stale.yml vendored
View file

@ -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
View 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
View 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 }}

View file

@ -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
@ -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
@ -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

View file

@ -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 }}

View file

@ -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

View file

@ -11,6 +11,14 @@ module.exports = {
useBuiltIns: 'usage',
corejs: 3
}
],
'@babel/preset-react',
[
'@babel/preset-typescript',
{
isTSX: true,
allExtensions: true
}
]
],
plugins: [

9970
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -5,82 +5,98 @@
"repository": "https://github.com/jellyfin/jellyfin-web",
"license": "GPL-2.0-or-later",
"devDependencies": {
"@babel/core": "^7.14.0",
"@babel/eslint-parser": "^7.13.8",
"@babel/eslint-plugin": "^7.13.16",
"@babel/plugin-proposal-class-properties": "^7.12.13",
"@babel/plugin-proposal-private-methods": "^7.12.13",
"@babel/plugin-transform-modules-umd": "^7.14.0",
"@babel/preset-env": "^7.14.1",
"@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.2.4",
"cssnano": "^4.1.10",
"eslint": "^7.25.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-import": "^2.24.2",
"eslint-plugin-jsx-a11y": "^6.4.1",
"eslint-plugin-promise": "^5.1.0",
"expose-loader": "^2.0.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.12",
"sass-loader": "^11.0.1",
"source-map-loader": "^2.0.1",
"style-loader": "^2.0.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.36.2",
"webpack-cli": "^4.6.0",
"webpack-dev-server": "^3.11.2",
"webpack-merge": "^4.2.2",
"workbox-webpack-plugin": "^6.1.5",
"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.11.2",
"date-fns": "^2.21.1",
"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.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.9",
"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",

View file

@ -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 & {
background-attachment: initial;
margin-top: 3rem;
@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-mobile .itemBackdrop {
display: none;
}
.layout-desktop .itemBackdrop::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 {
padding-left: 5%;
padding-right: 5%;
}
.layout-mobile & {
padding-left: 5%;
padding-right: 5%;
}
.layout-desktop .detailPageContent .emby-scroller,
.layout-tv .detailPageContent .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;
.layout-desktop &,
.layout-tv & {
.emby-scroller {
margin-left: 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 {
display: block;
}
.layout-mobile & {
display: block;
position: relative;
padding: 0.5rem 5%;
}
.layout-mobile .detailPagePrimaryContainer {
flex-wrap: wrap;
position: relative;
padding: 4.5rem 3.3% 0.5rem;
}
.layout-desktop & {
position: relative;
padding-left: 32.45vw;
}
.layout-tv #itemDetailPage:not(.noBackdrop) .detailPagePrimaryContainer,
.layout-desktop #itemDetailPage:not(.noBackdrop) .detailPagePrimaryContainer {
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 {
top: 35%;
}
.cardBox {
margin: 0;
}
.detailImageContainer .card.squareCard {
top: 40%;
}
&.backdropCard {
top: 35%;
}
.layout-desktop .noBackdrop .detailImageContainer,
.layout-tv .noBackdrop .detailImageContainer {
margin-top: 0;
&.squareCard {
top: 40%;
}
.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 {

View 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;

View file

@ -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,14 +25,23 @@ 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() {
window.addEventListener('popstate', () => {
this.popstateOccurred = true;
// 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', () => {
@ -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
},
@ -621,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);
};
}

View file

@ -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 {};

View file

@ -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');
}

View file

@ -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());

View file

@ -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>

View file

@ -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);

View file

@ -60,6 +60,7 @@
}
.layout-tv .formDialogFooter {
position: relative;
align-items: center;
justify-content: center;
flex-wrap: wrap;

View file

@ -144,17 +144,17 @@ import ServerConnections from '../ServerConnections';
} else if (section === 'librarybuttons') {
loadlibraryButtons(elem, apiClient, user, userSettings, userViews);
} else if (section === 'resume') {
return loadResume(elem, apiClient, 'HeaderContinueWatching', 'Video');
return loadResume(elem, apiClient, 'HeaderContinueWatching', 'Video', userSettings);
} else if (section === 'resumeaudio') {
return loadResume(elem, apiClient, 'HeaderContinueListening', 'Audio');
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');
return loadResume(elem, apiClient, 'HeaderContinueReading', 'Book', userSettings);
} else {
elem.innerHTML = '';
return Promise.resolve();
@ -374,7 +374,7 @@ import ServerConnections from '../ServerConnections';
'Video': 'videoplayback,markplayed'
};
function loadResume(elem, apiClient, headerText, mediaType) {
function loadResume(elem, apiClient, headerText, mediaType, userSettings) {
let html = '';
const dataMonitor = dataMonitorHints[mediaType] || 'markplayed';
@ -397,7 +397,7 @@ import ServerConnections from '../ServerConnections';
const itemsContainer = elem.querySelector('.itemsContainer');
itemsContainer.fetchData = getItemsToResumeFn(mediaType, apiClient.serverId());
itemsContainer.getItemsHtml = getItemsToResumeHtml;
itemsContainer.getItemsHtml = getItemsToResumeHtmlFn(userSettings.useEpisodeImagesInNextUpAndResume(), mediaType);
itemsContainer.parentContainer = elem;
}
@ -428,25 +428,28 @@ import ServerConnections from '../ServerConnections';
};
}
function getItemsToResumeHtml(items) {
const cardLayout = false;
return cardBuilder.getCardsHtml({
items: items,
preferThumb: true,
defaultShape: 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 getItemsToResumeHtmlFn(useEpisodeImages, mediaType) {
return function (items) {
const cardLayout = false;
return cardBuilder.getCardsHtml({
items: items,
preferThumb: true,
inheritThumb: !useEpisodeImages,
shape: (mediaType === 'Book') ? getPortraitShape() : 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 getOnNowFetchFn(serverId) {
@ -607,25 +610,28 @@ import ServerConnections from '../ServerConnections';
};
}
function getNextUpItemsHtml(items) {
const cardLayout = false;
return cardBuilder.getCardsHtml({
items: items,
preferThumb: true,
shape: getThumbShape(),
overlayText: false,
showTitle: true,
showParentTitle: true,
lazy: true,
overlayPlayButton: true,
context: 'home',
centerText: !cardLayout,
allowBottomPadding: !enableScrollX(),
cardLayout: cardLayout
});
function getNextUpItemsHtmlFn(useEpisodeImages) {
return function (items) {
const cardLayout = false;
return cardBuilder.getCardsHtml({
items: items,
preferThumb: true,
inheritThumb: !useEpisodeImages,
shape: getThumbShape(),
overlayText: false,
showTitle: true,
showParentTitle: true,
lazy: true,
overlayPlayButton: true,
context: 'home',
centerText: !cardLayout,
allowBottomPadding: !enableScrollX(),
cardLayout: cardLayout
});
};
}
function loadNextUp(elem, apiClient) {
function loadNextUp(elem, apiClient, userSettings) {
let html = '';
html += '<div class="sectionTitleContainer sectionTitleContainer-cards padded-left">';
@ -660,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;
}

View file

@ -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();

View file

@ -78,14 +78,10 @@ import '../elements/emby-button/emby-button';
}
export function setTabs(view, selectedIndex, getTabsFn, getTabContainersFn, onBeforeTabChange, onTabChange, setSelectedIndex) {
ensureElements();
if (!view) {
if (tabOwnerView) {
if (!headerTabsContainer) {
headerTabsContainer = queryScope.querySelector('.headerTabs');
}
ensureElements();
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 {

View file

@ -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);

View file

@ -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>';

View file

@ -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>

View 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;

View file

@ -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);
@ -2106,7 +2107,7 @@ class PlaybackManager {
}
}
function playInternal(item, playOptions, onPlaybackStartedFn) {
function playInternal(item, playOptions, onPlaybackStartedFn, prevSource) {
if (item.IsPlaceHolder) {
loading.hide();
showPlaybackInfoErrorMessage(self, 'PlaybackErrorPlaceHolder');
@ -2131,7 +2132,7 @@ class PlaybackManager {
const mediaType = item.MediaType;
const onBitrateDetectionFailure = function () {
return playAfterBitrateDetect(getSavedMaxStreamingBitrate(ServerConnections.getApiClient(item.ServerId), mediaType), item, playOptions, onPlaybackStartedFn);
return playAfterBitrateDetect(getSavedMaxStreamingBitrate(ServerConnections.getApiClient(item.ServerId), mediaType), item, playOptions, onPlaybackStartedFn, prevSource);
};
if (!isServerItem(item) || itemHelper.isLocalItem(item)) {
@ -2144,7 +2145,7 @@ class PlaybackManager {
return apiClient.detectBitrate().then(function (bitrate) {
appSettings.maxStreamingBitrate(endpointInfo.IsInNetwork, mediaType, bitrate);
return playAfterBitrateDetect(bitrate, item, playOptions, onPlaybackStartedFn);
return playAfterBitrateDetect(bitrate, item, playOptions, onPlaybackStartedFn, prevSource);
}, onBitrateDetectionFailure);
} else {
onBitrateDetectionFailure();
@ -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();
}
@ -2222,7 +2226,104 @@ class PlaybackManager {
});
}
function playAfterBitrateDetect(maxBitrate, item, playOptions, onPlaybackStartedFn) {
function rankStreamType(prevIndex, prevSource, mediaSource, streamType) {
if (prevIndex == -1) {
console.debug(`AutoSet ${streamType} - No Stream Set`);
if (streamType == 'Subtitle')
mediaSource.DefaultSubtitleStreamIndex = -1;
return;
}
if (!prevSource.MediaStreams || !mediaSource.MediaStreams) {
console.debug(`AutoSet ${streamType} - No MediaStreams`);
return;
}
let bestStreamIndex = null;
let bestStreamScore = 0;
const prevStream = prevSource.MediaStreams[prevIndex];
if (!prevStream) {
console.debug(`AutoSet ${streamType} - No prevStream`);
return;
}
console.debug(`AutoSet ${streamType} - Previous was ${prevStream.Index} - ${prevStream.DisplayTitle}`);
let prevRelIndex = 0;
for (const stream of prevSource.MediaStreams) {
if (stream.Type != streamType)
continue;
if (stream.Index == prevIndex)
break;
prevRelIndex += 1;
}
let newRelIndex = 0;
for (const stream of mediaSource.MediaStreams) {
if (stream.Type != streamType)
continue;
let score = 0;
if (prevStream.Codec == stream.Codec)
score += 1;
if (prevRelIndex == newRelIndex)
score += 1;
if (prevStream.Title && prevStream.Title == stream.Title)
score += 2;
if (prevStream.Language && prevStream.Language != 'und' && prevStream.Language == stream.Language)
score += 2;
console.debug(`AutoSet ${streamType} - Score ${score} for ${stream.Index} - ${stream.DisplayTitle}`);
if (score > bestStreamScore && score >= 3) {
bestStreamScore = score;
bestStreamIndex = stream.Index;
}
newRelIndex += 1;
}
if (bestStreamIndex != null) {
console.debug(`AutoSet ${streamType} - Using ${bestStreamIndex} score ${bestStreamScore}.`);
if (streamType == 'Subtitle')
mediaSource.DefaultSubtitleStreamIndex = bestStreamIndex;
if (streamType == 'Audio')
mediaSource.DefaultAudioStreamIndex = bestStreamIndex;
} else {
console.debug(`AutoSet ${streamType} - Threshold not met. Using default.`);
}
}
function autoSetNextTracks(prevSource, mediaSource) {
try {
if (!prevSource) return;
if (!mediaSource) {
console.warn('AutoSet - No mediaSource');
return;
}
if (typeof prevSource.DefaultAudioStreamIndex != 'number'
|| typeof prevSource.DefaultSubtitleStreamIndex != 'number')
return;
if (typeof mediaSource.DefaultAudioStreamIndex != 'number'
|| typeof mediaSource.DefaultSubtitleStreamIndex != 'number') {
console.warn('AutoSet - No stream indexes (but prevSource has them)');
return;
}
rankStreamType(prevSource.DefaultAudioStreamIndex, prevSource, mediaSource, 'Audio');
rankStreamType(prevSource.DefaultSubtitleStreamIndex, prevSource, mediaSource, 'Subtitle');
} catch (e) {
console.error(`AutoSet - Caught unexpected error: ${e}`);
}
}
function playAfterBitrateDetect(maxBitrate, item, playOptions, onPlaybackStartedFn, prevSource) {
const startPosition = playOptions.startPositionTicks;
const player = getPlayer(item, playOptions);
@ -2238,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);
@ -2272,7 +2382,10 @@ class PlaybackManager {
playOptions.items = null;
return getPlaybackMediaSource(player, apiClient, deviceProfile, maxBitrate, item, startPosition, mediaSourceId, audioStreamIndex, subtitleStreamIndex).then(function (mediaSource) {
const streamInfo = createStreamInfo(apiClient, item.MediaType, item, mediaSource, startPosition);
if (userSettings.enableSetUsingLastTracks())
autoSetNextTracks(prevSource, mediaSource);
const streamInfo = createStreamInfo(apiClient, item.MediaType, item, mediaSource, startPosition, player);
streamInfo.fullscreen = playOptions.fullscreen;
@ -2311,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);
});
});
});
@ -2337,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;
@ -2349,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);
@ -2622,6 +2743,16 @@ class PlaybackManager {
return self.previousTrack(player);
};
function getPreviousSource(player) {
const prevSource = self.currentMediaSource(player);
const prevPlayerData = getPlayerData(player);
return {
...prevSource,
DefaultAudioStreamIndex: prevPlayerData.audioStreamIndex,
DefaultSubtitleStreamIndex: prevPlayerData.subtitleStreamIndex
};
}
self.nextTrack = function (player) {
player = player || self._currentPlayer;
if (player && !enableLocalPlaylistManagement(player)) {
@ -2637,7 +2768,7 @@ class PlaybackManager {
playInternal(newItemInfo.item, newItemPlayOptions, function () {
setPlaylistState(newItemInfo.item.PlaylistItemId, newItemInfo.index);
});
}, getPreviousSource(player));
}
};
@ -2658,7 +2789,7 @@ class PlaybackManager {
playInternal(newItem, newItemPlayOptions, function () {
setPlaylistState(newItem.PlaylistItemId, newIndex);
});
}, getPreviousSource(player));
}
}
};
@ -3008,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)) {

View file

@ -195,6 +195,7 @@ import template from './playbackSettings.template.html';
context.querySelector('.chkPreferFmp4HlsContainer').checked = userSettings.preferFmp4HlsContainer();
context.querySelector('.chkEnableCinemaMode').checked = userSettings.enableCinemaMode();
context.querySelector('.chkEnableNextVideoOverlay').checked = userSettings.enableNextVideoInfoOverlay();
context.querySelector('.chkSetUsingLastTracks').checked = userSettings.enableSetUsingLastTracks();
context.querySelector('.chkExternalVideoPlayer').checked = appSettings.enableSystemExternalPlayers();
setMaxBitrateIntoField(context.querySelector('.selectVideoInNetworkQuality'), true, 'Video');
@ -236,6 +237,7 @@ import template from './playbackSettings.template.html';
userSettingsInstance.enableCinemaMode(context.querySelector('.chkEnableCinemaMode').checked);
userSettingsInstance.enableNextVideoInfoOverlay(context.querySelector('.chkEnableNextVideoOverlay').checked);
userSettingsInstance.enableSetUsingLastTracks(context.querySelector('.chkSetUsingLastTracks').checked);
userSettingsInstance.chromecastVersion(context.querySelector('.selectChromecastVersion').value);
userSettingsInstance.skipForwardLength(context.querySelector('.selectSkipForwardLength').value);
userSettingsInstance.skipBackLength(context.querySelector('.selectSkipBackLength').value);

View file

@ -82,6 +82,14 @@
</label>
</div>
<div class="checkboxContainer checkboxContainer-withDescription">
<label>
<input type="checkbox" is="emby-checkbox" class="chkSetUsingLastTracks" />
<span>${SetUsingLastTracks}</span>
</label>
<div class="fieldDescription checkboxFieldDescription">${SetUsingLastTracksHelp}</div>
</div>
<div class="checkboxContainer checkboxContainer-withDescription fldEnableNextVideoOverlay hide">
<label>
<input type="checkbox" is="emby-checkbox" class="chkEnableNextVideoOverlay" />

View file

@ -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;

View file

@ -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}`);

View 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);
});
}
};

View file

@ -388,7 +388,7 @@
.btnPlayPause {
padding: 0;
margin: 0;
font-size: 1.7em;
font-size: 2em;
}
.nowPlayingPageImage {

View file

@ -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) {
scrollToHelper(xScroller, {left: scrollX, behavior: scrollBehavior});
scrollToHelper(yScroller, {top: scrollY, behavior: scrollBehavior});
} else {
if (xScroller) {
scrollToHelper(xScroller, {left: scrollX, behavior: scrollBehavior});
}
if (yScroller) {
scrollToHelper(yScroller, {top: scrollY, behavior: scrollBehavior});
}
} 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,30 +541,51 @@ 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);
const scrollX = calcScroll(xScrollerData, xPos, elementRect.width, scrollCenterX);
let 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
if (isFixed && elementRect.bottom < 0) {
scrollY = 0;
// Exit, since we have no control over scrolling in this container
if (xScroller === yScroller && (xScrollerData.custom || yScrollerData.custom)) {
return;
}
// HACK: Ensure we are at the top
// FIXME: Need a marker to scroll top/bottom
if (scrollY < minimumScrollY() && yScroller === documentScroller) {
scrollY = 0;
// 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
if (isFixed && elementRect.bottom < 0) {
scrollY = 0;
}
// HACK: Ensure we are at the top
// FIXME: Need a marker to scroll top/bottom
if (scrollY < minimumScrollY() && yScroller === documentScroller) {
scrollY = 0;
}
} else {
yScroller = null;
}
doScroll(xScroller, scrollX, yScroller, scrollY, smooth);

View 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;

View 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;

View 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;

View 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;

View 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;

View file

@ -1,115 +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) {
elem.innerHTML = globalize.translateHtml(template, 'core');
elem.classList.add('searchFields');
const txtSearch = elem.querySelector('.searchfields-txtSearch');
if (layoutManager.tv && !browser.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 */

View file

@ -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>

View file

@ -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 */

View file

@ -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>

View file

@ -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.

View file

@ -78,8 +78,7 @@ export function getItemsForPlayback(apiClient, query) {
return apiClient.getItem(apiClient.getCurrentUserId(), itemId).then(function (item) {
return {
Items: [item],
TotalRecordCount: 1
Items: [item]
};
});
} else {

View file

@ -47,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);
@ -73,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.

View file

@ -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;

View file

@ -167,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();

View 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);
}

View file

@ -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);
}

View file

@ -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;
}
}

View file

@ -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) => {
console.error('SyncPlay: unexpected error listing groups:', 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) => {
console.error('SyncPlay: unexpected error showing group menu:', error);
if (error) {
console.error('SyncPlay: unexpected error showing group menu:', error);
}
});
loading.hide();

View file

@ -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(() => {

View file

@ -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();
};
}
}
/**

View file

@ -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.
*/

View 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;

View 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>

View file

@ -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;
}
// 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
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');
});
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);

View file

@ -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) {

View file

@ -23,6 +23,7 @@
"id": "wmc"
}
],
"menuLinks": [],
"servers": [],
"plugins": [
"playAccessValidation/plugin",

View file

@ -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>

View file

@ -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">

View file

@ -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;

View file

@ -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>

View file

@ -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();
});
}

View file

@ -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);

View file

@ -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>

View file

@ -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);
});
}

View file

@ -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>

View file

@ -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);

View file

@ -1115,7 +1115,11 @@ class ItemsView {
let imageType = userSettings.get(basekey + '-imageType');
if (!imageType && params.type === 'nextup') {
imageType = 'thumb';
if (userSettings.useEpisodeImagesInNextUpAndResume()) {
imageType = 'primary';
} else {
imageType = 'thumb';
}
}
return {

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -35,6 +35,10 @@
<span class="xlargePaperIconButton material-icons skip_previous"></span>
</button>
<button is="paper-icon-button-light" class="btnPreviousChapter autoSize hide" title="${PreviousChapter}">
<span class="xlargePaperIconButton material-icons undo"></span>
</button>
<button is="paper-icon-button-light" class="btnRewind" title="${Rewind} (j)">
<span class="xlargePaperIconButton material-icons fast_rewind"></span>
</button>
@ -47,6 +51,10 @@
<span class="xlargePaperIconButton material-icons fast_forward"></span>
</button>
<button is="paper-icon-button-light" class="btnNextChapter autoSize hide" title="${NextChapter}">
<span class="xlargePaperIconButton material-icons redo"></span>
</button>
<button is="paper-icon-button-light" class="btnNextTrack autoSize hide" title="${NextTrack}">
<span class="xlargePaperIconButton material-icons skip_next"></span>
</button>

View file

@ -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';
@ -180,6 +181,14 @@ import { appRouter } from '../../../components/appRouter';
} else {
view.querySelector('.btnAudio').classList.add('hide');
}
if (currentItem.Chapters.length > 1) {
view.querySelector('.btnPreviousChapter').classList.remove('hide');
view.querySelector('.btnNextChapter').classList.remove('hide');
} else {
view.querySelector('.btnPreviousChapter').classList.add('hide');
view.querySelector('.btnNextChapter').classList.add('hide');
}
}
function setTitle(item, parentName) {
@ -312,8 +321,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 +553,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 +648,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 +690,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 +725,8 @@ import { appRouter } from '../../../components/appRouter';
nowPlayingPositionSlider.value = 0;
}
if (runtimeTicks && positionTicks != null && currentRuntimeTicks && !enableProgressByTimeOfDay && currentItem.RunTimeTicks && currentItem.Type !== 'Recording') {
endsAtText.innerHTML = '&nbsp;&nbsp;&nbsp;&nbsp;' + mediaInfo.getEndsAtFromPosition(runtimeTicks, positionTicks, true);
if (runtimeTicks && positionTicks != null && currentRuntimeTicks && !enableProgressByTimeOfDay && currentItem.RunTimeTicks && currentItem.Type !== 'Recording' && playbackRate !== null) {
endsAtText.innerHTML = '&nbsp;&nbsp;&nbsp;&nbsp;' + mediaInfo.getEndsAtFromPosition(runtimeTicks, positionTicks, playbackRate, true);
} else {
endsAtText.innerHTML = '';
}
@ -987,14 +996,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) {
playbackManager.playPause(currentPlayer);
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 +1329,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 +1370,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');
@ -1490,11 +1521,14 @@ import { appRouter } from '../../../components/appRouter';
view.querySelector('.btnPreviousTrack').addEventListener('click', function () {
playbackManager.previousTrack(currentPlayer);
});
view.querySelector('.btnPreviousChapter').addEventListener('click', function () {
playbackManager.previousChapter(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);
}
playbackManager.playPause(currentPlayer);
});
view.querySelector('.btnNextChapter').addEventListener('click', function () {
playbackManager.nextChapter(currentPlayer);
});
view.querySelector('.btnNextTrack').addEventListener('click', function () {
playbackManager.nextTrack(currentPlayer);

View file

@ -1,4 +1,2 @@
<div id="searchPage" data-role="page" class="page libraryPage allLibraryPage noSecondaryNavPage" data-title="${Search}" data-backbutton="true">
<div class="padded-left padded-right searchFields"></div>
<div class="searchResults padded-bottom-page padded-top"></div>
</div>

View file

@ -1,36 +0,0 @@
import SearchFields from '../components/search/searchfields';
import SearchResults from '../components/search/searchresults';
import { Events } from 'jellyfin-apiclient';
export default function (view, params) {
function onSearch(e, value) {
self.searchResults.search(value);
}
const self = this;
view.addEventListener('viewshow', function () {
if (!self.searchFields) {
self.searchFields = new SearchFields({
element: view.querySelector('.searchFields')
});
self.searchResults = new SearchResults({
element: view.querySelector('.searchResults'),
serverId: params.serverId || ApiClient.serverId(),
parentId: params.parentId,
collectionType: params.collectionType
});
Events.on(self.searchFields, 'search', onSearch);
}
});
view.addEventListener('viewdestroy', function () {
if (self.searchFields) {
self.searchFields.destroy();
self.searchFields = null;
}
if (self.searchResults) {
self.searchResults.destroy();
self.searchResults = null;
}
});
}

View file

@ -76,7 +76,7 @@ import cardBuilder from '../../../components/cardbuilder/cardBuilder';
dialogHelper.close(dlg);
}
const result = await apiClient.quickConnect(data.Authentication);
const result = await apiClient.quickConnect(data.Secret);
onLoginSuccessful(result.User.Id, result.AccessToken, apiClient);
}, function (e) {
clearInterval(interval);
@ -260,9 +260,9 @@ import cardBuilder from '../../../components/cardbuilder/cardBuilder';
const apiClient = getApiClient();
apiClient.getQuickConnect('Status')
.then(status => {
if (status !== 'Unavailable') {
apiClient.getQuickConnect('Enabled')
.then(enabled => {
if (enabled === true) {
view.querySelector('.btnQuick').classList.remove('hide');
}
})

View file

@ -1,7 +1,7 @@
<div id="tvRecommendedPage" data-dom-cache="true" data-role="page" class="page libraryPage backdropPage pageWithAbsoluteTabs withTabs" data-backdroptype="series">
<div class="pageTabContent" id="seriesTab" 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>
@ -11,7 +11,7 @@
<div is="emby-itemscontainer" class="itemsContainer padded-left padded-right"></div>
<div class="alphaPicker alphaPicker-fixed alphaPicker-vertical"></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>
@ -55,7 +55,7 @@
<div is="emby-itemscontainer" id="items" class="itemsContainer padded-left padded-right padded-top vertical-wrap" style="text-align: center;"></div>
</div>
<div class="pageTabContent" id="episodesTab" 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="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>
@ -63,7 +63,7 @@
</div>
<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>

View file

@ -119,6 +119,7 @@ import autoFocuser from '../../components/autoFocuser';
cardBuilder.buildCards(result.Items, {
itemsContainer: container,
preferThumb: true,
inheritThumb: !userSettings.useEpisodeImagesInNextUpAndResume(),
shape: getThumbShape(),
scalable: true,
overlayPlayButton: true,
@ -197,6 +198,7 @@ import autoFocuser from '../../components/autoFocuser';
parentContainer: section,
itemsContainer: container,
preferThumb: true,
inheritThumb: !userSettings.useEpisodeImagesInNextUpAndResume(),
shape: 'backdrop',
scalable: true,
showTitle: true,

View file

@ -0,0 +1,24 @@
<div id="controlsPreferencesPage" data-role="page" class="page libraryPage userPreferencesPage noSecondaryNavPage" data-title="${Controls}" data-menubutton="true">
<div class="padded-left padded-right padded-bottom-page">
<form style="margin: 0 auto;">
<div class="verticalSection verticalSection-extrabottompadding">
<h2 class="sectionTitle">
${Controls}
</h2>
<div class="checkboxContainer checkboxContainer-withDescription">
<label>
<input type="checkbox" is="emby-checkbox" class="chkEnableGamepad" />
<span>${LabelEnableGamepad}</span>
</label>
<div class="fieldDescription checkboxFieldDescription">${EnableGamepadHelp}</div>
<div class="fieldDescription checkboxFieldDescription">${LabelPleaseRestart}</div>
</div>
</div>
<button is="emby-button" type="submit" class="raised button-submit block btnSave hide">
<span>${Save}</span>
</button>
</form>
</div>
</div>

View file

@ -0,0 +1,28 @@
import { Events } from 'jellyfin-apiclient';
import toast from '../../../components/toast/toast';
import globalize from '../../../scripts/globalize';
import appSettings from '../../../scripts/settings/appSettings';
export default function (view) {
function submit(e) {
appSettings.enableGamepad(view.querySelector('.chkEnableGamepad').checked);
toast(globalize.translate('SettingsSaved'));
Events.trigger(view, 'saved');
e?.preventDefault();
return false;
}
view.addEventListener('viewshow', function () {
view.querySelector('.chkEnableGamepad').checked = appSettings.enableGamepad();
view.querySelector('form').addEventListener('submit', submit);
view.querySelector('.btnSave').classList.remove('hide');
import('../../../components/autoFocuser').then(({default: autoFocuser}) => {
autoFocuser.autoFocus(view);
});
});
}

View file

@ -12,6 +12,15 @@
</div>
</a>
<a is="emby-linkbutton" data-ripple="false" href="#" style="display:block;padding:0;margin:0;" class="lnkQuickConnectPreferences listItem-border hide">
<div class="listItem">
<em class="material-icons listItemIcon listItemIcon-transparent">tap_and_play</em>
<div class="listItemBody">
<div class="listItemBodyText">${QuickConnect}</div>
</div>
</div>
</a>
<a is="emby-linkbutton" data-ripple="false" href="#" style="display:block;padding:0;margin:0;" class="lnkDisplayPreferences listItem-border">
<div class="listItem">
<span class="material-icons listItemIcon listItemIcon-transparent tv"></span>
@ -48,16 +57,6 @@
</div>
</a>
<a is="emby-linkbutton" data-ripple="false" href="#" style="display:block;padding:0;margin:0;" class="lnkQuickConnectPreferences listItem-border hide">
<div class="listItem">
<em class="material-icons listItemIcon listItemIcon-transparent">tap_and_play</em>
<div class="listItemBody">
<div class="listItemBodyText">${QuickConnect}</div>
</div>
</div>
</a>
<a is="emby-linkbutton" data-ripple="false" href="#" style="display:block;padding:0;margin:0;" class="clientSettings listItem-border">
<div class="listItem">
<span class="material-icons listItemIcon listItemIcon-transparent devices_other"></span>
@ -66,6 +65,15 @@
</div>
</div>
</a>
<a is="emby-linkbutton" data-ripple="false" href="#" style="display:block;padding:0;margin:0;" class="lnkControlsPreferences listItem-border">
<div class="listItem">
<span class="material-icons listItemIcon listItemIcon-transparent keyboard"></span>
<div class="listItemBody">
<div class="listItemBodyText">${Controls}</div>
</div>
</div>
</a>
</div>
<div class="adminSection verticalSection verticalSection-extrabottompadding hide">
<h2 class="sectionTitle" style="padding-left:.25em;">${HeaderAdmin}</h2>

View file

@ -28,6 +28,7 @@ export default function (view, params) {
page.querySelector('.lnkPlaybackPreferences').setAttribute('href', '#!/mypreferencesplayback.html?userId=' + userId);
page.querySelector('.lnkSubtitlePreferences').setAttribute('href', '#!/mypreferencessubtitles.html?userId=' + userId);
page.querySelector('.lnkQuickConnectPreferences').setAttribute('href', '#!/mypreferencesquickconnect.html');
page.querySelector('.lnkControlsPreferences').setAttribute('href', '#!/mypreferencescontrols.html?userId=' + userId);
const supportsClientSettings = appHost.supports('clientsettings');
page.querySelector('.clientSettings').classList.toggle('hide', !supportsClientSettings);
@ -35,16 +36,17 @@ export default function (view, params) {
const supportsMultiServer = appHost.supports('multiserver');
page.querySelector('.selectServer').classList.toggle('hide', !supportsMultiServer);
ApiClient.getQuickConnect('Status')
.then(status => {
if (status !== 'Unavailable') {
page.querySelector('.lnkControlsPreferences').classList.toggle('hide', layoutManager.mobile);
ApiClient.getQuickConnect('Enabled')
.then(enabled => {
if (enabled === true) {
page.querySelector('.lnkQuickConnectPreferences').classList.remove('hide');
}
})
.catch(() => {
console.debug('Failed to get QuickConnect status');
});
ApiClient.getUser(userId).then(function (user) {
page.querySelector('.headerUsername').innerHTML = user.Name;
if (user.Policy.IsAdministrator && !layoutManager.tv) {
@ -56,6 +58,7 @@ export default function (view, params) {
if (params.userId && params.userId !== Dashboard.getCurrentUserId) {
page.querySelector('.userSection').classList.add('hide');
page.querySelector('.adminSection').classList.add('hide');
page.querySelector('.lnkControlsPreferences').classList.add('hide');
}
import('../../../components/autoFocuser').then(({default: autoFocuser}) => {

View file

@ -1,6 +1,5 @@
import globalize from '../../../scripts/globalize';
import toast from '../../../components/toast/toast';
import Dashboard from '../../../scripts/clientUtils';
export const authorize = (code) => {
const url = ApiClient.getUrl('/QuickConnect/Authorize?Code=' + code);
@ -16,22 +15,3 @@ export const authorize = (code) => {
// prevent bubbling
return false;
};
export const activate = () => {
const url = ApiClient.getUrl('/QuickConnect/Activate');
return ApiClient.ajax({
type: 'POST',
url: url
}).then(() => {
toast(globalize.translate('QuickConnectActivationSuccessful'));
return true;
}).catch((e) => {
console.error('Error activating quick connect. Error:', e);
Dashboard.alert({
title: globalize.translate('HeaderError'),
message: globalize.translate('DefaultErrorMessage')
});
throw e;
});
};

View file

@ -1,19 +1,15 @@
<div id="quickConnectPreferencesPage" data-role="page" class="page libraryPage userPreferencesPage noSecondaryNavPage" data-title="${QuickConnect}" data-backbutton="true" style="margin: 0 auto; max-width: 54em">
<div class="settingsContainer padded-left padded-right padded-bottom-page">
<button is="emby-button" id="btnQuickConnectActivate" type="button" class="raised button-submit block">
<span>${ButtonActivate}</span>
</button>
<form class="quickConnectSettingsContainer">
<div style="margin-bottom: 1em">
${QuickConnectDescription}
</div>
<form class="quickConnectSettingsContainer">
<div class="verticalSection">
<h2 class="sectionTitle">${QuickConnect}</h2>
<div>${QuickConnectDescription}</div>
<br />
<div class="inputContainer">
<input is="emby-input" type="number" min="0" max="999999" required id="txtQuickConnectCode" label="${LabelQuickConnectCode}" autocomplete="off" />
</div>
<button id="btnQuickConnectAuthorize" is="emby-button" type="submit" class="raised button-submit block">
<span>${Authorize}</span>
</button>
</form>
</div>
</div>
</form>
</div>

View file

@ -1,4 +1,4 @@
import { activate, authorize } from './helper';
import { authorize } from './helper';
import globalize from '../../../scripts/globalize';
import toast from '../../../components/toast/toast';
@ -6,52 +6,16 @@ export default function (view) {
view.addEventListener('viewshow', function () {
const codeElement = view.querySelector('#txtQuickConnectCode');
view.querySelector('#btnQuickConnectActivate').addEventListener('click', () => {
activate().then(() => {
renderPage();
});
});
view.querySelector('.quickConnectSettingsContainer').addEventListener('submit', (e) => {
e.preventDefault();
view.querySelector('#btnQuickConnectAuthorize').addEventListener('click', () => {
if (!codeElement.validity.valid) {
toast(globalize.translate('QuickConnectInvalidCode'));
return;
}
const code = codeElement.value;
authorize(code);
authorize(codeElement.value);
});
view.querySelector('.quickConnectSettingsContainer').addEventListener('submit', (e) => {
e.preventDefault();
});
renderPage();
});
function renderPage(forceActive = false) {
ApiClient.getQuickConnect('Status').then((status) => {
const btn = view.querySelector('#btnQuickConnectActivate');
const container = view.querySelector('.quickConnectSettingsContainer');
// The activation button should only be visible when quick connect is unavailable (with the text replaced with an error) or when it is available (so it can be activated)
// The authorization container is only usable when quick connect is active, so it should be hidden otherwise
container.style.display = 'none';
if (status === 'Unavailable') {
btn.textContent = globalize.translate('QuickConnectNotAvailable');
btn.disabled = true;
btn.classList.remove('button-submit');
btn.classList.add('button');
} else if (status === 'Active' || forceActive) {
container.style.display = '';
btn.style.display = 'none';
}
return true;
}).catch((e) => {
throw e;
});
}
}

View file

@ -3,6 +3,7 @@ import dom from '../../scripts/dom';
import scroller from '../../libraries/scroller';
import browser from '../../scripts/browser';
import focusManager from '../../components/focusManager';
import layoutManager from '../../components/layoutManager';
import './emby-tabs.scss';
import '../../assets/css/scrollstyles.scss';
@ -100,6 +101,14 @@ import '../../assets/css/scrollstyles.scss';
}
}
function onFocusIn(e) {
const tabs = this;
const tabButton = dom.parentWithClass(e.target, buttonClass);
if (tabButton && tabs.scroller) {
tabs.scroller.toCenter(tabButton, false);
}
}
function onFocusOut(e) {
const parentContainer = e.target.parentNode;
const previousFocus = parentContainer.querySelector('.lastFocused');
@ -155,10 +164,14 @@ import '../../assets/css/scrollstyles.scss';
passive: true
});
if (layoutManager.tv) {
dom.addEventListener(this, 'focusin', onFocusIn, { passive: true });
}
dom.addEventListener(this, 'focusout', onFocusOut);
};
EmbyTabs.focus = function onFocusIn() {
EmbyTabs.focus = function () {
const selectedTab = this.querySelector('.' + activeButtonClass);
const lastFocused = this.querySelector('.lastFocused');
@ -210,6 +223,10 @@ import '../../assets/css/scrollstyles.scss';
dom.removeEventListener(this, 'click', onClick, {
passive: true
});
if (layoutManager.tv) {
dom.removeEventListener(this, 'focusin', onFocusIn, { passive: true });
}
};
function getSelectedTabButton(elem) {

View file

@ -56,7 +56,6 @@ import '../emby-input/emby-input';
textarea.style.height = 'auto';
newHeight = textarea.scrollHeight/* - offset*/;
}
$('.customCssContainer').css('height', newHeight + 'px');
textarea.style.height = newHeight + 'px';
}

5
src/global.d.ts vendored Normal file
View file

@ -0,0 +1,5 @@
export declare global {
interface Window {
ApiClient: any;
}
}

View file

@ -41,7 +41,7 @@
<!-- iPhone Xs -->
<link href="assets/splash/iphonexsmax_splash.png" media="screen and (device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)" rel="apple-touch-startup-image" />
<link href="assets/splash/iphonexsmax_splashl.png" media="screen and (device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)" rel="apple-touch-startup-image" />
<link href="assets/splash/iphonexsmax_splash_l.png" media="screen and (device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)" rel="apple-touch-startup-image" />
<!-- iPad -->
<link href="assets/splash/ipad_splash.png" media="screen and (device-width: 768px) and (device-height: 1024px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)" rel="apple-touch-startup-image" />

View file

@ -630,6 +630,8 @@ const scrollerFactory = function (frame, options) {
//passive: true
});
scrollSource.removeAttribute(`data-scroll-mode-${o.horizontal ? 'x' : 'y'}`);
// Reset initialized status and return the instance
self.initialized = 0;
return self;
@ -751,6 +753,8 @@ const scrollerFactory = function (frame, options) {
}
}
scrollSource.setAttribute(`data-scroll-mode-${o.horizontal ? 'x' : 'y'}`, 'custom');
if (transform || layoutManager.tv) {
// This can prevent others from being able to listen to mouse events
dom.addEventListener(dragSourceElement, 'mousedown', dragInitSlidee, {

View file

@ -5,6 +5,7 @@ import loading from '../../components/loading/loading';
import keyboardnavigation from '../../scripts/keyboardNavigation';
import dialogHelper from '../../components/dialogHelper/dialogHelper';
import ServerConnections from '../../components/ServerConnections';
import * as Screenfull from 'screenfull';
import TableOfContents from './tableOfContents';
import dom from '../../scripts/dom';
import { translateHtml } from '../../scripts/globalize';
@ -151,6 +152,7 @@ export class BookPlayer {
elem.addEventListener('close', this.onDialogClosed, {once: true});
elem.querySelector('#btnBookplayerExit').addEventListener('click', this.onDialogClosed, {once: true});
elem.querySelector('#btnBookplayerToc').addEventListener('click', this.openTableOfContents);
elem.querySelector('#btnBookplayerFullscreen').addEventListener('click', this.toggleFullscreen);
elem.querySelector('#btnBookplayerPrev')?.addEventListener('click', this.previous);
elem.querySelector('#btnBookplayerNext')?.addEventListener('click', this.next);
}
@ -170,6 +172,7 @@ export class BookPlayer {
elem.removeEventListener('close', this.onDialogClosed);
elem.querySelector('#btnBookplayerExit').removeEventListener('click', this.onDialogClosed);
elem.querySelector('#btnBookplayerToc').removeEventListener('click', this.openTableOfContents);
elem.querySelector('#btnBookplayerFullscreen').removeEventListener('click', this.toggleFullscreen);
elem.querySelector('#btnBookplayerPrev')?.removeEventListener('click', this.previous);
elem.querySelector('#btnBookplayerNext')?.removeEventListener('click', this.next);
}
@ -191,6 +194,15 @@ export class BookPlayer {
}
}
toggleFullscreen() {
if (Screenfull.isEnabled) {
const icon = document.querySelector('#btnBookplayerFullscreen .material-icons');
icon.classList.remove(Screenfull.isFullscreen ? 'fullscreen_exit' : 'fullscreen');
icon.classList.add(Screenfull.isFullscreen ? 'fullscreen' : 'fullscreen_exit');
Screenfull.toggle();
}
}
previous(e) {
e?.preventDefault();
if (this.rendition) {
@ -246,15 +258,23 @@ export class BookPlayer {
const serverId = item.ServerId;
const apiClient = ServerConnections.getApiClient(serverId);
if (!Screenfull.isEnabled) {
document.getElementById('btnBookplayerFullscreen').display = 'none';
}
return new Promise((resolve, reject) => {
import('epubjs').then(({default: epubjs}) => {
const downloadHref = apiClient.getItemDownloadUrl(item.Id);
const book = epubjs(downloadHref, {openAs: 'epub'});
// We need to calculate the height of the window beforehand because using 100% is not accurate when the dialog is opening.
// In addition we don't render to the full height so that we have space for the top buttons.
const clientHeight = document.body.clientHeight;
const renderHeight = clientHeight - (clientHeight * 0.0425);
const rendition = book.renderTo('bookPlayerContainer', {
width: '100%',
// Calculate the height of the window because using 100% is not accurate when the dialog is opening
height: document.body.clientHeight,
height: renderHeight,
// TODO: Add option for scrolled-doc
flow: 'paginated'
});

View file

@ -11,8 +11,6 @@
.topButtons {
z-index: 1002;
position: absolute;
top: 0;
width: 100%;
color: #000;
opacity: 0.7;

View file

@ -11,6 +11,9 @@
<button is="paper-icon-button-light" id="btnBookplayerExit" class="autoSize bookplayerButton hide-mouse-idle-tv" tabindex="-1">
<span class="material-icons bookplayerButtonIcon close"></span>
</button>
<button is="paper-icon-button-light" id="btnBookplayerFullscreen" class="autoSize bookplayerButton hide-mouse-idle-tv" tabindex="-1">
<span class="material-icons bookplayerButtonIcon fullscreen"></span>
</button>
</div>
<div id="bookPlayerContainer" class="bookPlayerContainer"></div>

View file

@ -457,14 +457,14 @@ class HtmlAudioPlayer {
setVolume(val) {
const mediaElement = this._mediaElement;
if (mediaElement) {
mediaElement.volume = val / 100;
mediaElement.volume = Math.pow(val / 100, 3);
}
}
getVolume() {
const mediaElement = this._mediaElement;
if (mediaElement) {
return Math.min(Math.round(mediaElement.volume * 100), 100);
return Math.min(Math.round(Math.pow(mediaElement.volume, 1 / 3) * 100), 100);
}
}

View file

@ -385,7 +385,6 @@ function tryRemoveElement(elem) {
return new Promise((resolve, reject) => {
requireHlsPlayer(async () => {
let maxBufferLength = 30;
let maxMaxBufferLength = 600;
// Some browsers cannot handle huge fragments in high bitrate.
// This issue usually happens when using HWA encoders with a high bitrate setting.
@ -393,7 +392,6 @@ function tryRemoveElement(elem) {
// https://github.com/video-dev/hls.js/issues/876
if ((browser.chrome || browser.edgeChromium || browser.firefox) && playbackManager.getMaxStreamingBitrate(this) >= 25000000) {
maxBufferLength = 6;
maxMaxBufferLength = 6;
}
const includeCorsCredentials = await getIncludeCorsCredentials();
@ -401,7 +399,6 @@ function tryRemoveElement(elem) {
const hls = new Hls({
manifestLoadingTimeOut: 20000,
maxBufferLength: maxBufferLength,
maxMaxBufferLength: maxMaxBufferLength,
xhrSetup(xhr) {
xhr.withCredentials = includeCorsCredentials;
}
@ -1708,14 +1705,14 @@ function tryRemoveElement(elem) {
setVolume(val) {
const mediaElement = this.#mediaElement;
if (mediaElement) {
mediaElement.volume = val / 100;
mediaElement.volume = Math.pow(val / 100, 3);
}
}
getVolume() {
const mediaElement = this.#mediaElement;
if (mediaElement) {
return Math.min(Math.round(mediaElement.volume * 100), 100);
return Math.min(Math.round(Math.pow(mediaElement.volume, 1 / 3) * 100), 100);
}
}

View file

@ -203,7 +203,7 @@ export class PdfPlayer {
const percentageTicks = options.startPositionTicks / 10000;
if (percentageTicks !== 0) {
this.loadPage(percentageTicks);
this.loadPage(percentageTicks + 1);
this.progress = percentageTicks;
} else {
this.loadPage(1);

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