From 743c1d9a310e6d038ddaec083abd4c2438256243 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fernando=20Fern=C3=A1ndez?= Date: Thu, 31 Dec 2020 13:26:35 +0100 Subject: [PATCH 01/10] chore(lint): make linting stricter and auto lint on commit and save --- .editorconfig | 7 +- .eslintrc.js | 155 +++- .gitignore | 1 - .prettierrc | 7 +- .vscode/settings.json | 3 + commitlint.config.js | 3 + package-lock.json | 1862 ++++++++++++++++++++++++++++++++++++----- package.json | 27 +- stylelint.config.js | 7 + tsconfig.json | 2 +- webpack.config.ts | 169 ++-- 11 files changed, 1890 insertions(+), 353 deletions(-) create mode 100644 .vscode/settings.json create mode 100644 commitlint.config.js create mode 100644 stylelint.config.js diff --git a/.editorconfig b/.editorconfig index 81eba8cc..4a7ea303 100644 --- a/.editorconfig +++ b/.editorconfig @@ -2,8 +2,11 @@ root = true [*] indent_style = space -indent_size = 4 +indent_size = 2 +end_of_line = lf charset = utf-8 trim_trailing_whitespace = true insert_final_newline = true -end_of_line = lf + +[*.md] +trim_trailing_whitespace = false diff --git a/.eslintrc.js b/.eslintrc.js index 298442d8..7c149d25 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -1,50 +1,113 @@ module.exports = { - root: true, - plugins: ['@typescript-eslint', 'promise', 'import', 'jsdoc'], - env: { - node: true, - es6: true, - browser: true - }, - extends: [ - 'eslint:recommended', - 'plugin:promise/recommended', - 'plugin:@typescript-eslint/recommended', - 'plugin:jsdoc/recommended', - 'plugin:import/errors', - 'plugin:import/warnings', - 'plugin:import/typescript', - 'prettier' - ], - rules: { - '@typescript-eslint/explicit-function-return-type': 'error', - '@typescript-eslint/no-explicit-any': 'warn', - 'import/newline-after-import': 'error', - 'import/order': 'error' + root: true, + env: { + node: true, + browser: true, + es6: true + }, + extends: [ + 'eslint:recommended', + 'plugin:jsdoc/recommended', + 'plugin:json/recommended', + 'plugin:@typescript-eslint/recommended', + 'prettier', + 'plugin:prettier/recommended', + 'plugin:promise/recommended', + 'plugin:import/errors', + 'plugin:import/warnings', + 'plugin:import/typescript' + ], + plugins: ['prettier', 'promise', 'import', 'jsdoc'], + rules: { + 'import/newline-after-import': 'error', + 'import/order': 'error', + 'jsdoc/require-hyphen-before-param-description': 'error', + 'jsdoc/require-description': 'warn', + 'jsdoc/require-jsdoc': 'error', + //TypeScript and IntelliSense already provides us information about the function typings while hovering and + // eslint-jsdoc doesn't detect a mismatch between what's declared in the function and what's declared in + // JSDOC. + 'jsdoc/require-param-type': 'off', + 'jsdoc/require-returns-type': 'off', + 'jsdoc/check-indentation': 'error', + 'jsdoc/check-syntax': 'error', + 'jsdoc/check-param-names': 'error', + 'jsdoc/check-property-names': 'error', + 'jsdoc/check-tag-names': 'error', + 'jsdoc/no-types': 'error', + 'jsdoc/valid-types': 'off', + 'promise/no-nesting': 'error', + 'promise/no-return-in-finally': 'error', + 'promise/prefer-await-to-callbacks': 'error', + 'promise/prefer-await-to-then': 'error', + '@typescript-eslint/explicit-function-return-type': 'error', + '@typescript-eslint/prefer-ts-expect-error': 'error', + '@typescript-eslint/no-unused-vars': 'error', + 'prefer-arrow-callback': 'error', + 'prefer-template': 'error', + curly: 'error', + 'padding-line-between-statements': [ + 'error', + // Always require blank lines after directives (like 'use-strict'), except between directives + { blankLine: 'always', prev: 'directive', next: '*' }, + { blankLine: 'any', prev: 'directive', next: 'directive' }, + // Always require blank lines after import, except between imports + { blankLine: 'always', prev: 'import', next: '*' }, + { blankLine: 'any', prev: 'import', next: 'import' }, + // Always require blank lines before and after every sequence of variable declarations and export + { + blankLine: 'always', + prev: '*', + next: ['const', 'let', 'var', 'export'] + }, + { + blankLine: 'always', + prev: ['const', 'let', 'var', 'export'], + next: '*' + }, + { + blankLine: 'any', + prev: ['const', 'let', 'var', 'export'], + next: ['const', 'let', 'var', 'export'] + }, + // Always require blank lines before and after class declaration, if, do/while, switch, try + { + blankLine: 'always', + prev: '*', + next: ['if', 'class', 'for', 'do', 'while', 'switch', 'try'] + }, + { + blankLine: 'always', + prev: ['if', 'class', 'for', 'do', 'while', 'switch', 'try'], + next: '*' + }, + // Always require blank lines before return statements + { blankLine: 'always', prev: '*', next: 'return' } + ] + }, + overrides: [ + { + files: ['.js', '.ts'], + env: { + node: false, + browser: true, + es6: true + }, + globals: { + cast: 'readonly', + PRODUCTION: 'readonly', + $scope: 'writable' + } + } + ], + settings: { + 'import/parsers': { + '@typescript-eslint/parser': ['.ts', '.tsx'] }, - overrides: [ - { - files: ['.js', '.ts'], - env: { - node: false, - browser: true, - es6: true - }, - globals: { - cast: 'readonly', - PRODUCTION: 'readonly', - $scope: 'writable' - } - } - ], - settings: { - 'import/parsers': { - '@typescript-eslint/parser': ['.ts', '.tsx'] - }, - 'import/resolver': { - typescript: { - alwaysTryTypes: true - } - } + 'import/resolver': { + typescript: { + alwaysTryTypes: true + } } + } }; diff --git a/.gitignore b/.gitignore index 08a14460..c4bb4e85 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,5 @@ # ide .idea -.vscode tags # npm/yarn diff --git a/.prettierrc b/.prettierrc index 3b774b71..38bc8e09 100644 --- a/.prettierrc +++ b/.prettierrc @@ -1,6 +1,5 @@ { - "singleQuote": true, - "trailingComma": "none", - "semi": true, - "tabWidth": 4 + "semi": true, + "singleQuote": true, + "trailingComma": "none" } diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 00000000..f89ed5f1 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "editor.formatOnSave": true +} diff --git a/commitlint.config.js b/commitlint.config.js new file mode 100644 index 00000000..c34aa79d --- /dev/null +++ b/commitlint.config.js @@ -0,0 +1,3 @@ +module.exports = { + extends: ['@commitlint/config-conventional'] +}; diff --git a/package-lock.json b/package-lock.json index 9268ac04..f8923969 100644 --- a/package-lock.json +++ b/package-lock.json @@ -488,6 +488,302 @@ "minimist": "^1.2.0" } }, + "@commitlint/cli": { + "version": "12.1.1", + "resolved": "https://registry.npmjs.org/@commitlint/cli/-/cli-12.1.1.tgz", + "integrity": "sha512-SB67/s6VJ50seoPx/Sr2gj1fMzKrx+udgarecGdr8h43ah+M2e22gjQJ7xHv5KwyPQ+6ug1YOMCL34ubT4zupQ==", + "dev": true, + "requires": { + "@commitlint/format": "^12.1.1", + "@commitlint/lint": "^12.1.1", + "@commitlint/load": "^12.1.1", + "@commitlint/read": "^12.1.1", + "@commitlint/types": "^12.1.1", + "get-stdin": "8.0.0", + "lodash": "^4.17.19", + "resolve-from": "5.0.0", + "resolve-global": "1.0.0", + "yargs": "^16.2.0" + }, + "dependencies": { + "cliui": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", + "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==", + "dev": true, + "requires": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^7.0.0" + } + }, + "resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true + }, + "wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "requires": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + } + }, + "y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true + }, + "yargs": { + "version": "16.2.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz", + "integrity": "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==", + "dev": true, + "requires": { + "cliui": "^7.0.2", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.0", + "y18n": "^5.0.5", + "yargs-parser": "^20.2.2" + } + }, + "yargs-parser": { + "version": "20.2.7", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.7.tgz", + "integrity": "sha512-FiNkvbeHzB/syOjIUxFDCnhSfzAL8R5vs40MgLFBorXACCOAEaWu0gRZl14vG8MR9AOJIZbmkjhusqBYZ3HTHw==", + "dev": true + } + } + }, + "@commitlint/config-conventional": { + "version": "12.1.1", + "resolved": "https://registry.npmjs.org/@commitlint/config-conventional/-/config-conventional-12.1.1.tgz", + "integrity": "sha512-15CqbXMsQiEb0qbzjEHe2OkzaXPYSp7RxaS6KoSVk/4W0QiigquavQ+M0huBZze92h0lMS6Pxoq4AJ5CQ3D+iQ==", + "dev": true, + "requires": { + "conventional-changelog-conventionalcommits": "^4.3.1" + } + }, + "@commitlint/ensure": { + "version": "12.1.1", + "resolved": "https://registry.npmjs.org/@commitlint/ensure/-/ensure-12.1.1.tgz", + "integrity": "sha512-XEUQvUjzBVQM7Uv8vYz+c7PDukFvx0AvQEyX/V+PaTkCK/xPvexu7FLbFwvypjSt9BPMf+T/rhB1hVmldkd6lw==", + "dev": true, + "requires": { + "@commitlint/types": "^12.1.1", + "lodash": "^4.17.19" + } + }, + "@commitlint/execute-rule": { + "version": "12.1.1", + "resolved": "https://registry.npmjs.org/@commitlint/execute-rule/-/execute-rule-12.1.1.tgz", + "integrity": "sha512-6mplMGvLCKF5LieL7BRhydpg32tm6LICnWQADrWU4S5g9PKi2utNvhiaiuNPoHUXr29RdbNaGNcyyPv8DSjJsQ==", + "dev": true + }, + "@commitlint/format": { + "version": "12.1.1", + "resolved": "https://registry.npmjs.org/@commitlint/format/-/format-12.1.1.tgz", + "integrity": "sha512-bTAoOryTFLqls17JTaRwk2WDVOP0NwuG4F/JPK8RaF6DMZNVQTfajkgTxFENNZRnESfau1BvivvEXfUAW2ZsvA==", + "dev": true, + "requires": { + "@commitlint/types": "^12.1.1", + "chalk": "^4.0.0" + } + }, + "@commitlint/is-ignored": { + "version": "12.1.1", + "resolved": "https://registry.npmjs.org/@commitlint/is-ignored/-/is-ignored-12.1.1.tgz", + "integrity": "sha512-Sn4fsnWX+wLAJOD/UZeoVruB98te1TyPYRiDEq0MhRJAQIrP+7jE/O3/ass68AAMq00HvH3OK9kt4UBXggcGjA==", + "dev": true, + "requires": { + "@commitlint/types": "^12.1.1", + "semver": "7.3.5" + }, + "dependencies": { + "semver": { + "version": "7.3.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.5.tgz", + "integrity": "sha512-PoeGJYh8HK4BTO/a9Tf6ZG3veo/A7ZVsYrSA6J8ny9nb3B1VrpkuN+z9OE5wfE5p6H4LchYZsegiQgbJD94ZFQ==", + "dev": true, + "requires": { + "lru-cache": "^6.0.0" + } + } + } + }, + "@commitlint/lint": { + "version": "12.1.1", + "resolved": "https://registry.npmjs.org/@commitlint/lint/-/lint-12.1.1.tgz", + "integrity": "sha512-FFFPpku/E0svL1jaUVqosuZJDDWiNWYBlUw5ZEljh3MwWRcoaWtMIX5bseX+IvHpFZsCTAiBs1kCgNulCi0UvA==", + "dev": true, + "requires": { + "@commitlint/is-ignored": "^12.1.1", + "@commitlint/parse": "^12.1.1", + "@commitlint/rules": "^12.1.1", + "@commitlint/types": "^12.1.1" + } + }, + "@commitlint/load": { + "version": "12.1.1", + "resolved": "https://registry.npmjs.org/@commitlint/load/-/load-12.1.1.tgz", + "integrity": "sha512-qOQtgNdJRULUQWP9jkpTwhj7aEtnqUtqeUpbQ9rjS+GIUST65HZbteNUX4S0mAEGPWqy2aK5xGd73cUfFSvuuw==", + "dev": true, + "requires": { + "@commitlint/execute-rule": "^12.1.1", + "@commitlint/resolve-extends": "^12.1.1", + "@commitlint/types": "^12.1.1", + "chalk": "^4.0.0", + "cosmiconfig": "^7.0.0", + "lodash": "^4.17.19", + "resolve-from": "^5.0.0" + }, + "dependencies": { + "resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true + } + } + }, + "@commitlint/message": { + "version": "12.1.1", + "resolved": "https://registry.npmjs.org/@commitlint/message/-/message-12.1.1.tgz", + "integrity": "sha512-RakDSLAiOligXjhbLahV8HowF4K75pZIcs0+Ii9Q8Gz5H3DWf1Ngit7alFTWfcbf/+DTjSzVPov5HiwQZPIBUg==", + "dev": true + }, + "@commitlint/parse": { + "version": "12.1.1", + "resolved": "https://registry.npmjs.org/@commitlint/parse/-/parse-12.1.1.tgz", + "integrity": "sha512-nuljIvAbBDr93DgL0wCArftEIhjSghawAwhvrKNV9FFcqAJqfVqitwMxJrNDCQ5pgUMCSKULLOEv+dA0bLlTEQ==", + "dev": true, + "requires": { + "@commitlint/types": "^12.1.1", + "conventional-changelog-angular": "^5.0.11", + "conventional-commits-parser": "^3.0.0" + } + }, + "@commitlint/read": { + "version": "12.1.1", + "resolved": "https://registry.npmjs.org/@commitlint/read/-/read-12.1.1.tgz", + "integrity": "sha512-1k0CQEoZIdixvmqZRKEcWdj2XiKS7SlizEOJ1SE99Qui5d5FlBey8eaooTGgmpR6zObpIHJehtEPzM3VzUT3qA==", + "dev": true, + "requires": { + "@commitlint/top-level": "^12.1.1", + "@commitlint/types": "^12.1.1", + "fs-extra": "^9.0.0", + "git-raw-commits": "^2.0.0" + } + }, + "@commitlint/resolve-extends": { + "version": "12.1.1", + "resolved": "https://registry.npmjs.org/@commitlint/resolve-extends/-/resolve-extends-12.1.1.tgz", + "integrity": "sha512-/DXRt0S0U3o9lq5cc8OL1Lkx0IjW0HcDWjUkUXshAajBIKBYSJB8x/loNCi1krNEJ8SwLXUEFt5OLxNO6wE9yQ==", + "dev": true, + "requires": { + "import-fresh": "^3.0.0", + "lodash": "^4.17.19", + "resolve-from": "^5.0.0", + "resolve-global": "^1.0.0" + }, + "dependencies": { + "resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true + } + } + }, + "@commitlint/rules": { + "version": "12.1.1", + "resolved": "https://registry.npmjs.org/@commitlint/rules/-/rules-12.1.1.tgz", + "integrity": "sha512-oCcLF/ykcJfhM2DeeaDyrgdaiuKsqIPNocugdPj2WEyhSYqmx1/u18CV96LAtW+WyyiOLCCeiZwiQutx3T5nXg==", + "dev": true, + "requires": { + "@commitlint/ensure": "^12.1.1", + "@commitlint/message": "^12.1.1", + "@commitlint/to-lines": "^12.1.1", + "@commitlint/types": "^12.1.1" + } + }, + "@commitlint/to-lines": { + "version": "12.1.1", + "resolved": "https://registry.npmjs.org/@commitlint/to-lines/-/to-lines-12.1.1.tgz", + "integrity": "sha512-W23AH2XF5rI27MOAPSSr0TUDoRe7ZbFoRtYhFnPu2MBmcuDA9Tmfd9N5sM2tBXtdE26uq3SazwKqGt1OoGAilQ==", + "dev": true + }, + "@commitlint/top-level": { + "version": "12.1.1", + "resolved": "https://registry.npmjs.org/@commitlint/top-level/-/top-level-12.1.1.tgz", + "integrity": "sha512-g7uRbr81QEIg+pbii0OkE17Zh/2C/f6dSmiMDVRn1S0+hNHR1bENCh18hVUKcV/qKTUsKkFlhhWXM9mQBfxQJw==", + "dev": true, + "requires": { + "find-up": "^5.0.0" + }, + "dependencies": { + "find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "requires": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + } + }, + "locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "requires": { + "p-locate": "^5.0.0" + } + }, + "p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "requires": { + "yocto-queue": "^0.1.0" + } + }, + "p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "requires": { + "p-limit": "^3.0.2" + } + }, + "path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true + } + } + }, + "@commitlint/types": { + "version": "12.1.1", + "resolved": "https://registry.npmjs.org/@commitlint/types/-/types-12.1.1.tgz", + "integrity": "sha512-+qGH+s2Lo6qwacV2X3/ZypZwaAI84ift+1HBjXdXtI/q0F5NtmXucV3lcQOTviMTNiJhq4qWON2fjci2NItASw==", + "dev": true, + "requires": { + "chalk": "^4.0.0" + } + }, "@discoveryjs/json-ext": { "version": "0.5.2", "resolved": "https://registry.npmjs.org/@discoveryjs/json-ext/-/json-ext-0.5.2.tgz", @@ -1623,6 +1919,16 @@ "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==", "dev": true }, + "JSONStream": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/JSONStream/-/JSONStream-1.3.5.tgz", + "integrity": "sha512-E+iruNOY8VV9s4JEbe1aNEm6MiszPRr/UfcHMz0TQh1BXSxHK+ASV1R6W4HpjBhSeS+54PIsAMCBmwD06LLsqQ==", + "dev": true, + "requires": { + "jsonparse": "^1.2.0", + "through": ">=2.2.7 <3" + } + }, "abab": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/abab/-/abab-2.0.5.tgz", @@ -1778,18 +2084,18 @@ "integrity": "sha1-45sJrqne+Gao8gbiiK9jkZuuOcQ=", "dev": true }, - "array-differ": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/array-differ/-/array-differ-3.0.0.tgz", - "integrity": "sha512-THtfYS6KtME/yIAhKjZ2ul7XI96lQGHRputJQHO80LAWQnuGP4iCIN8vdMRboGbIEYBwU33q8Tch1os2+X0kMg==", - "dev": true - }, "array-flatten": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-2.1.2.tgz", "integrity": "sha512-hNfzcOV8W4NdualtqBFPyVO+54DSJuZGY9qT4pRroB6S9e3iiido2ISIC5h9R2sPJ8H3FHCIiEnsv1lPXO3KtQ==", "dev": true }, + "array-ify": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/array-ify/-/array-ify-1.0.0.tgz", + "integrity": "sha1-nlKHYrSpBmrRY6aWKjZEGOlibs4=", + "dev": true + }, "array-includes": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.3.tgz", @@ -1832,12 +2138,6 @@ "es-abstract": "^1.18.0-next.1" } }, - "arrify": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/arrify/-/arrify-2.0.1.tgz", - "integrity": "sha512-3duEwti880xqi4eAMN8AyR4a0ByT90zoYdLlevfrvU43vb0YZwZVfxOgxWrLXXXpyugL0hNZc9G6BiB5B3nUug==", - "dev": true - }, "asn1": { "version": "0.2.4", "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.4.tgz", @@ -1892,6 +2192,12 @@ "integrity": "sha1-x57Zf380y48robyXkLzDZkdLS3k=", "dev": true }, + "at-least-node": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz", + "integrity": "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==", + "dev": true + }, "atob": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/atob/-/atob-2.1.2.tgz", @@ -2340,6 +2646,12 @@ "unset-value": "^1.0.0" } }, + "cachedir": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/cachedir/-/cachedir-2.2.0.tgz", + "integrity": "sha512-VvxA0xhNqIIfg0V9AmJkDg91DaJwryutH5rVEZAhcNi4iJFj9f+QxmAjgK1LT9I8OgToX27fypX6/MeCXVbBjQ==", + "dev": true + }, "call-bind": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz", @@ -2454,6 +2766,12 @@ "integrity": "sha512-mKKUkUbhPpQlCOfIuZkvSEgktjPFIsZKRRbC6KWVEMvlzblj3i3asQv5ODsrwt0N3pHAEvjP8KTQPHkp0+6jOg==", "dev": true }, + "chardet": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/chardet/-/chardet-0.7.0.tgz", + "integrity": "sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==", + "dev": true + }, "chokidar": { "version": "2.1.8", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-2.1.8.tgz", @@ -2708,6 +3026,21 @@ } } }, + "cli-cursor": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-2.1.0.tgz", + "integrity": "sha1-s12sN2R5+sw+lHR9QdDQ9SOP/LU=", + "dev": true, + "requires": { + "restore-cursor": "^2.0.0" + } + }, + "cli-width": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-2.2.1.tgz", + "integrity": "sha512-GRMWDxpOB6Dgk2E5Uo+3eEBvtOOlimMmpbFiKuLFnQzYDavtLFY3K5ona41jgN/WdRZtG7utuVSVTL4HbZHGkw==", + "dev": true + }, "cliui": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", @@ -2866,63 +3199,208 @@ "integrity": "sha512-AOdq0i8ghZudnYv8RUnHrhTgafUGs61Rdz9jemU5x2lnZwAWyOq7vySo626K59e1fVKH1xSRorJwPVRLSWOoAQ==", "dev": true }, - "component-emitter": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.0.tgz", - "integrity": "sha512-Rd3se6QB+sO1TwqZjscQrurpEPIfO0/yYnSin6Q/rD3mOutHvUrCAhJub3r90uNb+SESBuE0QYoB90YdfatsRg==", - "dev": true - }, - "compressible": { - "version": "2.0.18", - "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", - "integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==", - "dev": true, - "requires": { - "mime-db": ">= 1.43.0 < 2" - } - }, - "compression": { - "version": "1.7.4", - "resolved": "https://registry.npmjs.org/compression/-/compression-1.7.4.tgz", - "integrity": "sha512-jaSIDzP9pZVS4ZfQ+TzvtiWhdpFhE2RDHz8QJkpX9SIpLq88VueF5jJw6t+6CUQcAoA6t+x89MLrWAqpfDE8iQ==", - "dev": true, - "requires": { - "accepts": "~1.3.5", - "bytes": "3.0.0", - "compressible": "~2.0.16", - "debug": "2.6.9", - "on-headers": "~1.0.2", - "safe-buffer": "5.1.2", - "vary": "~1.1.2" + "commitizen": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/commitizen/-/commitizen-4.2.3.tgz", + "integrity": "sha512-pYlYEng7XMV2TW4xtjDKBGqeJ0Teq2zyRSx2S3Ml1XAplHSlJZK8vm1KdGclpMEZuGafbS5TeHXIVnHk8RWIzQ==", + "dev": true, + "requires": { + "cachedir": "2.2.0", + "cz-conventional-changelog": "3.2.0", + "dedent": "0.7.0", + "detect-indent": "6.0.0", + "find-node-modules": "2.0.0", + "find-root": "1.1.0", + "fs-extra": "8.1.0", + "glob": "7.1.4", + "inquirer": "6.5.2", + "is-utf8": "^0.2.1", + "lodash": "^4.17.20", + "minimist": "1.2.5", + "strip-bom": "4.0.0", + "strip-json-comments": "3.0.1" }, "dependencies": { - "debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", "dev": true, "requires": { - "ms": "2.0.0" + "color-convert": "^1.9.0" } }, - "ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=", - "dev": true + "chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dev": true, + "requires": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + } }, - "safe-buffer": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", - "dev": true - } - } - }, - "concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=", + "color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "dev": true, + "requires": { + "color-name": "1.1.3" + } + }, + "color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=", + "dev": true + }, + "cz-conventional-changelog": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/cz-conventional-changelog/-/cz-conventional-changelog-3.2.0.tgz", + "integrity": "sha512-yAYxeGpVi27hqIilG1nh4A9Bnx4J3Ov+eXy4koL3drrR+IO9GaWPsKjik20ht608Asqi8TQPf0mczhEeyAtMzg==", + "dev": true, + "requires": { + "@commitlint/load": ">6.1.1", + "chalk": "^2.4.1", + "commitizen": "^4.0.3", + "conventional-commit-types": "^3.0.0", + "lodash.map": "^4.5.1", + "longest": "^2.0.1", + "word-wrap": "^1.0.3" + } + }, + "fs-extra": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", + "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", + "dev": true, + "requires": { + "graceful-fs": "^4.2.0", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + } + }, + "glob": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.4.tgz", + "integrity": "sha512-hkLPepehmnKk41pUGm3sYxoFs/umurYfYJCerbXEyFIWcAzvpipAgVkBqqT9RBKMGjnq6kMuyYwha6csxbiM1A==", + "dev": true, + "requires": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.0.4", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + } + }, + "has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0=", + "dev": true + }, + "jsonfile": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", + "integrity": "sha1-h3Gq4HmbZAdrdmQPygWPnBDjPss=", + "dev": true, + "requires": { + "graceful-fs": "^4.1.6" + } + }, + "strip-bom": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz", + "integrity": "sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==", + "dev": true + }, + "strip-json-comments": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.0.1.tgz", + "integrity": "sha512-VTyMAUfdm047mwKl+u79WIdrZxtFtn+nBxHeb844XBQ9uMNTuTHdx2hc5RiAJYqwTj3wc/xe5HLSdJSkJ+WfZw==", + "dev": true + }, + "supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "requires": { + "has-flag": "^3.0.0" + } + } + } + }, + "compare-func": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/compare-func/-/compare-func-2.0.0.tgz", + "integrity": "sha512-zHig5N+tPWARooBnb0Zx1MFcdfpyJrfTJ3Y5L+IFvUm8rM74hHz66z0gw0x4tijh5CorKkKUCnW82R2vmpeCRA==", + "dev": true, + "requires": { + "array-ify": "^1.0.0", + "dot-prop": "^5.1.0" + } + }, + "component-emitter": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.0.tgz", + "integrity": "sha512-Rd3se6QB+sO1TwqZjscQrurpEPIfO0/yYnSin6Q/rD3mOutHvUrCAhJub3r90uNb+SESBuE0QYoB90YdfatsRg==", + "dev": true + }, + "compressible": { + "version": "2.0.18", + "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", + "integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==", + "dev": true, + "requires": { + "mime-db": ">= 1.43.0 < 2" + } + }, + "compression": { + "version": "1.7.4", + "resolved": "https://registry.npmjs.org/compression/-/compression-1.7.4.tgz", + "integrity": "sha512-jaSIDzP9pZVS4ZfQ+TzvtiWhdpFhE2RDHz8QJkpX9SIpLq88VueF5jJw6t+6CUQcAoA6t+x89MLrWAqpfDE8iQ==", + "dev": true, + "requires": { + "accepts": "~1.3.5", + "bytes": "3.0.0", + "compressible": "~2.0.16", + "debug": "2.6.9", + "on-headers": "~1.0.2", + "safe-buffer": "5.1.2", + "vary": "~1.1.2" + }, + "dependencies": { + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "requires": { + "ms": "2.0.0" + } + }, + "ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=", + "dev": true + }, + "safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true + } + } + }, + "concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=", "dev": true }, "connect-history-api-fallback": { @@ -2954,12 +3432,232 @@ } } }, - "content-type": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.4.tgz", - "integrity": "sha512-hIP3EEPs8tB9AT1L+NUqtwOAps4mk2Zob89MWXMHjHWg9milF/j4osnnQLXBCBFBk/tvIG/tUc9mOUJiPBhPXA==", - "dev": true - }, + "content-type": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.4.tgz", + "integrity": "sha512-hIP3EEPs8tB9AT1L+NUqtwOAps4mk2Zob89MWXMHjHWg9milF/j4osnnQLXBCBFBk/tvIG/tUc9mOUJiPBhPXA==", + "dev": true + }, + "conventional-changelog-angular": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/conventional-changelog-angular/-/conventional-changelog-angular-5.0.12.tgz", + "integrity": "sha512-5GLsbnkR/7A89RyHLvvoExbiGbd9xKdKqDTrArnPbOqBqG/2wIosu0fHwpeIRI8Tl94MhVNBXcLJZl92ZQ5USw==", + "dev": true, + "requires": { + "compare-func": "^2.0.0", + "q": "^1.5.1" + } + }, + "conventional-changelog-conventionalcommits": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/conventional-changelog-conventionalcommits/-/conventional-changelog-conventionalcommits-4.5.0.tgz", + "integrity": "sha512-buge9xDvjjOxJlyxUnar/+6i/aVEVGA7EEh4OafBCXPlLUQPGbRUBhBUveWRxzvR8TEjhKEP4BdepnpG2FSZXw==", + "dev": true, + "requires": { + "compare-func": "^2.0.0", + "lodash": "^4.17.15", + "q": "^1.5.1" + } + }, + "conventional-commit-types": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/conventional-commit-types/-/conventional-commit-types-3.0.0.tgz", + "integrity": "sha512-SmmCYnOniSsAa9GqWOeLqc179lfr5TRu5b4QFDkbsrJ5TZjPJx85wtOr3zn+1dbeNiXDKGPbZ72IKbPhLXh/Lg==", + "dev": true + }, + "conventional-commits-parser": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/conventional-commits-parser/-/conventional-commits-parser-3.2.1.tgz", + "integrity": "sha512-OG9kQtmMZBJD/32NEw5IhN5+HnBqVjy03eC+I71I0oQRFA5rOgA4OtPOYG7mz1GkCfCNxn3gKIX8EiHJYuf1cA==", + "dev": true, + "requires": { + "JSONStream": "^1.0.4", + "is-text-path": "^1.0.1", + "lodash": "^4.17.15", + "meow": "^8.0.0", + "split2": "^3.0.0", + "through2": "^4.0.0", + "trim-off-newlines": "^1.0.0" + }, + "dependencies": { + "find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "requires": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + } + }, + "hosted-git-info": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.0.2.tgz", + "integrity": "sha512-c9OGXbZ3guC/xOlCg1Ci/VgWlwsqDv1yMQL1CWqXDL0hDjXuNcq0zuR4xqPSuasI3kqFDhqSyTjREz5gzq0fXg==", + "dev": true, + "requires": { + "lru-cache": "^6.0.0" + } + }, + "locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "requires": { + "p-locate": "^4.1.0" + } + }, + "meow": { + "version": "8.1.2", + "resolved": "https://registry.npmjs.org/meow/-/meow-8.1.2.tgz", + "integrity": "sha512-r85E3NdZ+mpYk1C6RjPFEMSE+s1iZMuHtsHAqY0DT3jZczl0diWUZ8g6oU7h0M9cD2EL+PzaYghhCLzR0ZNn5Q==", + "dev": true, + "requires": { + "@types/minimist": "^1.2.0", + "camelcase-keys": "^6.2.2", + "decamelize-keys": "^1.1.0", + "hard-rejection": "^2.1.0", + "minimist-options": "4.1.0", + "normalize-package-data": "^3.0.0", + "read-pkg-up": "^7.0.1", + "redent": "^3.0.0", + "trim-newlines": "^3.0.0", + "type-fest": "^0.18.0", + "yargs-parser": "^20.2.3" + } + }, + "normalize-package-data": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-3.0.2.tgz", + "integrity": "sha512-6CdZocmfGaKnIHPVFhJJZ3GuR8SsLKvDANFp47Jmy51aKIr8akjAWTSxtpI+MBgBFdSMRyo4hMpDlT6dTffgZg==", + "dev": true, + "requires": { + "hosted-git-info": "^4.0.1", + "resolve": "^1.20.0", + "semver": "^7.3.4", + "validate-npm-package-license": "^3.0.1" + } + }, + "p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "requires": { + "p-try": "^2.0.0" + } + }, + "p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "requires": { + "p-limit": "^2.2.0" + } + }, + "p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "dev": true + }, + "parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + } + }, + "path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true + }, + "read-pkg": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-5.2.0.tgz", + "integrity": "sha512-Ug69mNOpfvKDAc2Q8DRpMjjzdtrnv9HcSMX+4VsZxD1aZ6ZzrIE7rlzXBtWTyhULSMKg076AW6WR5iZpD0JiOg==", + "dev": true, + "requires": { + "@types/normalize-package-data": "^2.4.0", + "normalize-package-data": "^2.5.0", + "parse-json": "^5.0.0", + "type-fest": "^0.6.0" + }, + "dependencies": { + "hosted-git-info": { + "version": "2.8.9", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.8.9.tgz", + "integrity": "sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==", + "dev": true + }, + "normalize-package-data": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-2.5.0.tgz", + "integrity": "sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA==", + "dev": true, + "requires": { + "hosted-git-info": "^2.1.4", + "resolve": "^1.10.0", + "semver": "2 || 3 || 4 || 5", + "validate-npm-package-license": "^3.0.1" + } + }, + "semver": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz", + "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==", + "dev": true + }, + "type-fest": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.6.0.tgz", + "integrity": "sha512-q+MB8nYR1KDLrgr4G5yemftpMC7/QLqVndBmEEdqzmNj5dcFOO4Oo8qlwZE3ULT3+Zim1F8Kq4cBnikNhlCMlg==", + "dev": true + } + } + }, + "read-pkg-up": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/read-pkg-up/-/read-pkg-up-7.0.1.tgz", + "integrity": "sha512-zK0TB7Xd6JpCLmlLmufqykGE+/TlOePD6qKClNW7hHDKFh/J7/7gCWGR7joEQEW1bKq3a3yUZSObOoWLFQ4ohg==", + "dev": true, + "requires": { + "find-up": "^4.1.0", + "read-pkg": "^5.2.0", + "type-fest": "^0.8.1" + }, + "dependencies": { + "type-fest": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.8.1.tgz", + "integrity": "sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA==", + "dev": true + } + } + }, + "type-fest": { + "version": "0.18.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.18.1.tgz", + "integrity": "sha512-OIAYXk8+ISY+qTOwkHtKqzAuxchoMiD9Udx+FSGQDuiRR+PJKJHc2NJAXlbhkGwTt/4/nKZxELY1w3ReWOL8mw==", + "dev": true + }, + "yargs-parser": { + "version": "20.2.7", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.7.tgz", + "integrity": "sha512-FiNkvbeHzB/syOjIUxFDCnhSfzAL8R5vs40MgLFBorXACCOAEaWu0gRZl14vG8MR9AOJIZbmkjhusqBYZ3HTHw==", + "dev": true + } + } + }, "convert-source-map": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.7.0.tgz", @@ -3200,6 +3898,79 @@ } } }, + "cz-conventional-changelog": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/cz-conventional-changelog/-/cz-conventional-changelog-3.3.0.tgz", + "integrity": "sha512-U466fIzU5U22eES5lTNiNbZ+d8dfcHcssH4o7QsdWaCcRs/feIPCxKYSWkYBNs5mny7MvEfwpTLWjvbm94hecw==", + "dev": true, + "requires": { + "@commitlint/load": ">6.1.1", + "chalk": "^2.4.1", + "commitizen": "^4.0.3", + "conventional-commit-types": "^3.0.0", + "lodash.map": "^4.5.1", + "longest": "^2.0.1", + "word-wrap": "^1.0.3" + }, + "dependencies": { + "ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dev": true, + "requires": { + "color-convert": "^1.9.0" + } + }, + "chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dev": true, + "requires": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + } + }, + "color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "dev": true, + "requires": { + "color-name": "1.1.3" + } + }, + "color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=", + "dev": true + }, + "has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0=", + "dev": true + }, + "supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "requires": { + "has-flag": "^3.0.0" + } + } + } + }, + "dargs": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/dargs/-/dargs-7.0.0.tgz", + "integrity": "sha512-2iy1EkLdlBzQGvbweYRFxmFath8+K7+AKB0TlhHWkNuH+TmovaMH/Wp7V7R4u7f4SnX3OgLsU9t1NI9ioDnUpg==", + "dev": true + }, "dashdash": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz", @@ -3265,6 +4036,12 @@ "integrity": "sha1-6zkTMzRYd1y4TNGh+uBiEGu4dUU=", "dev": true }, + "dedent": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/dedent/-/dedent-0.7.0.tgz", + "integrity": "sha1-JJXduvbrh0q7Dhvp3yLS5aVEMmw=", + "dev": true + }, "deep-equal": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-1.1.1.tgz", @@ -3416,6 +4193,18 @@ "integrity": "sha1-l4hXRCxEdJ5CBmE+N5RiBYJqvYA=", "dev": true }, + "detect-file": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/detect-file/-/detect-file-1.0.0.tgz", + "integrity": "sha1-8NZtA2cqglyxtzvbP+YjEMjlUrc=", + "dev": true + }, + "detect-indent": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/detect-indent/-/detect-indent-6.0.0.tgz", + "integrity": "sha512-oSyFlqaTHCItVRGK5RmrmjB+CmaMOW7IaNA/kdxqhoa6d17j/5ce9O9eWXmV/KEdRwqpQA+Vqe8a8Bsybu4YnA==", + "dev": true + }, "detect-newline": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", @@ -3527,6 +4316,15 @@ } } }, + "dot-prop": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-5.3.0.tgz", + "integrity": "sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q==", + "dev": true, + "requires": { + "is-obj": "^2.0.0" + } + }, "ecc-jsbn": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz", @@ -3940,6 +4738,16 @@ "spdx-expression-parse": "^3.0.1" } }, + "eslint-plugin-json": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-json/-/eslint-plugin-json-2.1.2.tgz", + "integrity": "sha512-isM/fsUxS4wN1+nLsWoV5T4gLgBQnsql3nMTr8u+cEls1bL8rRQO5CP5GtxJxaOfbcKqnz401styw+H/P+e78Q==", + "dev": true, + "requires": { + "lodash": "^4.17.19", + "vscode-json-languageservice": "^3.7.0" + } + }, "eslint-plugin-prettier": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-3.3.1.tgz", @@ -4223,6 +5031,15 @@ } } }, + "expand-tilde": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/expand-tilde/-/expand-tilde-2.0.2.tgz", + "integrity": "sha1-l+gBqgUt8CRU3kawK/YhZCzchQI=", + "dev": true, + "requires": { + "homedir-polyfill": "^1.0.1" + } + }, "expect": { "version": "26.6.2", "resolved": "https://registry.npmjs.org/expect/-/expect-26.6.2.tgz", @@ -4337,6 +5154,17 @@ } } }, + "external-editor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/external-editor/-/external-editor-3.1.0.tgz", + "integrity": "sha512-hMQ4CX1p1izmuLYyZqLMO/qGNw10wSv9QDCPfzXfyFrOaCSSoRfqE1Kf1s5an66J5JZC62NewG+mK49jOCtQew==", + "dev": true, + "requires": { + "chardet": "^0.7.0", + "iconv-lite": "^0.4.24", + "tmp": "^0.0.33" + } + }, "extglob": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/extglob/-/extglob-2.0.4.tgz", @@ -4485,6 +5313,15 @@ "bser": "2.1.1" } }, + "figures": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/figures/-/figures-2.0.0.tgz", + "integrity": "sha1-OrGi0qYsi/tDGgyUy3l6L84nyWI=", + "dev": true, + "requires": { + "escape-string-regexp": "^1.0.5" + } + }, "file-entry-cache": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", @@ -4558,6 +5395,22 @@ } } }, + "find-node-modules": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/find-node-modules/-/find-node-modules-2.0.0.tgz", + "integrity": "sha512-8MWIBRgJi/WpjjfVXumjPKCtmQ10B+fjx6zmSA+770GMJirLhWIzg8l763rhjl9xaeaHbnxPNRQKq2mgMhr+aw==", + "dev": true, + "requires": { + "findup-sync": "^3.0.0", + "merge": "^1.2.1" + } + }, + "find-root": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz", + "integrity": "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==", + "dev": true + }, "find-up": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-2.1.0.tgz", @@ -4567,6 +5420,123 @@ "locate-path": "^2.0.0" } }, + "findup-sync": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/findup-sync/-/findup-sync-3.0.0.tgz", + "integrity": "sha512-YbffarhcicEhOrm4CtrwdKBdCuz576RLdhJDsIfvNtxUuhdRet1qZcsMjqbePtAseKdAnDyM/IyXbu7PRPRLYg==", + "dev": true, + "requires": { + "detect-file": "^1.0.0", + "is-glob": "^4.0.0", + "micromatch": "^3.0.4", + "resolve-dir": "^1.0.1" + }, + "dependencies": { + "braces": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/braces/-/braces-2.3.2.tgz", + "integrity": "sha512-aNdbnj9P8PjdXU4ybaWLK2IF3jc/EoDYbC7AazW6to3TRsfXxscC9UXOB5iDiEQrkyIbWp2SLQda4+QAa7nc3w==", + "dev": true, + "requires": { + "arr-flatten": "^1.1.0", + "array-unique": "^0.3.2", + "extend-shallow": "^2.0.1", + "fill-range": "^4.0.0", + "isobject": "^3.0.1", + "repeat-element": "^1.1.2", + "snapdragon": "^0.8.1", + "snapdragon-node": "^2.0.1", + "split-string": "^3.0.2", + "to-regex": "^3.0.1" + }, + "dependencies": { + "extend-shallow": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", + "integrity": "sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=", + "dev": true, + "requires": { + "is-extendable": "^0.1.0" + } + } + } + }, + "fill-range": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-4.0.0.tgz", + "integrity": "sha1-1USBHUKPmOsGpj3EAtJAPDKMOPc=", + "dev": true, + "requires": { + "extend-shallow": "^2.0.1", + "is-number": "^3.0.0", + "repeat-string": "^1.6.1", + "to-regex-range": "^2.1.0" + }, + "dependencies": { + "extend-shallow": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", + "integrity": "sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=", + "dev": true, + "requires": { + "is-extendable": "^0.1.0" + } + } + } + }, + "is-number": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-3.0.0.tgz", + "integrity": "sha1-JP1iAaR4LPUFYcgQJ2r8fRLXEZU=", + "dev": true, + "requires": { + "kind-of": "^3.0.2" + }, + "dependencies": { + "kind-of": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", + "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=", + "dev": true, + "requires": { + "is-buffer": "^1.1.5" + } + } + } + }, + "micromatch": { + "version": "3.1.10", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-3.1.10.tgz", + "integrity": "sha512-MWikgl9n9M3w+bpsY3He8L+w9eF9338xRl8IAO5viDizwSzziFEyUzo2xrrloB64ADbTf8uA8vRqqttDTOmccg==", + "dev": true, + "requires": { + "arr-diff": "^4.0.0", + "array-unique": "^0.3.2", + "braces": "^2.3.1", + "define-property": "^2.0.2", + "extend-shallow": "^3.0.2", + "extglob": "^2.0.4", + "fragment-cache": "^0.2.1", + "kind-of": "^6.0.2", + "nanomatch": "^1.2.9", + "object.pick": "^1.3.0", + "regex-not": "^1.0.0", + "snapdragon": "^0.8.1", + "to-regex": "^3.0.2" + } + }, + "to-regex-range": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-2.1.1.tgz", + "integrity": "sha1-fIDBe53+vlmeJzZ+DU3VWQFB2zg=", + "dev": true, + "requires": { + "is-number": "^3.0.0", + "repeat-string": "^1.6.1" + } + } + } + }, "flat-cache": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.0.4.tgz", @@ -4643,6 +5613,26 @@ "integrity": "sha1-PYyt2Q2XZWn6g1qx+OSyOhBWBac=", "dev": true }, + "fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "dev": true, + "requires": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "dependencies": { + "universalify": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.0.tgz", + "integrity": "sha512-hAZsKq7Yy11Zu1DE0OzWjw7nnLZmJZYTDZZyEFHZdUhV8FkH5MCfoU1XMaxXovpyW5nq5scPqq0ZDP9Zyl04oQ==", + "dev": true + } + } + }, "fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", @@ -4727,6 +5717,197 @@ "assert-plus": "^1.0.0" } }, + "git-raw-commits": { + "version": "2.0.10", + "resolved": "https://registry.npmjs.org/git-raw-commits/-/git-raw-commits-2.0.10.tgz", + "integrity": "sha512-sHhX5lsbG9SOO6yXdlwgEMQ/ljIn7qMpAbJZCGfXX2fq5T8M5SrDnpYk9/4HswTildcIqatsWa91vty6VhWSaQ==", + "dev": true, + "requires": { + "dargs": "^7.0.0", + "lodash": "^4.17.15", + "meow": "^8.0.0", + "split2": "^3.0.0", + "through2": "^4.0.0" + }, + "dependencies": { + "find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "requires": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + } + }, + "hosted-git-info": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.0.2.tgz", + "integrity": "sha512-c9OGXbZ3guC/xOlCg1Ci/VgWlwsqDv1yMQL1CWqXDL0hDjXuNcq0zuR4xqPSuasI3kqFDhqSyTjREz5gzq0fXg==", + "dev": true, + "requires": { + "lru-cache": "^6.0.0" + } + }, + "locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "requires": { + "p-locate": "^4.1.0" + } + }, + "meow": { + "version": "8.1.2", + "resolved": "https://registry.npmjs.org/meow/-/meow-8.1.2.tgz", + "integrity": "sha512-r85E3NdZ+mpYk1C6RjPFEMSE+s1iZMuHtsHAqY0DT3jZczl0diWUZ8g6oU7h0M9cD2EL+PzaYghhCLzR0ZNn5Q==", + "dev": true, + "requires": { + "@types/minimist": "^1.2.0", + "camelcase-keys": "^6.2.2", + "decamelize-keys": "^1.1.0", + "hard-rejection": "^2.1.0", + "minimist-options": "4.1.0", + "normalize-package-data": "^3.0.0", + "read-pkg-up": "^7.0.1", + "redent": "^3.0.0", + "trim-newlines": "^3.0.0", + "type-fest": "^0.18.0", + "yargs-parser": "^20.2.3" + } + }, + "normalize-package-data": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-3.0.2.tgz", + "integrity": "sha512-6CdZocmfGaKnIHPVFhJJZ3GuR8SsLKvDANFp47Jmy51aKIr8akjAWTSxtpI+MBgBFdSMRyo4hMpDlT6dTffgZg==", + "dev": true, + "requires": { + "hosted-git-info": "^4.0.1", + "resolve": "^1.20.0", + "semver": "^7.3.4", + "validate-npm-package-license": "^3.0.1" + } + }, + "p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "requires": { + "p-try": "^2.0.0" + } + }, + "p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "requires": { + "p-limit": "^2.2.0" + } + }, + "p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "dev": true + }, + "parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + } + }, + "path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true + }, + "read-pkg": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-5.2.0.tgz", + "integrity": "sha512-Ug69mNOpfvKDAc2Q8DRpMjjzdtrnv9HcSMX+4VsZxD1aZ6ZzrIE7rlzXBtWTyhULSMKg076AW6WR5iZpD0JiOg==", + "dev": true, + "requires": { + "@types/normalize-package-data": "^2.4.0", + "normalize-package-data": "^2.5.0", + "parse-json": "^5.0.0", + "type-fest": "^0.6.0" + }, + "dependencies": { + "hosted-git-info": { + "version": "2.8.9", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.8.9.tgz", + "integrity": "sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==", + "dev": true + }, + "normalize-package-data": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-2.5.0.tgz", + "integrity": "sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA==", + "dev": true, + "requires": { + "hosted-git-info": "^2.1.4", + "resolve": "^1.10.0", + "semver": "2 || 3 || 4 || 5", + "validate-npm-package-license": "^3.0.1" + } + }, + "semver": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz", + "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==", + "dev": true + }, + "type-fest": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.6.0.tgz", + "integrity": "sha512-q+MB8nYR1KDLrgr4G5yemftpMC7/QLqVndBmEEdqzmNj5dcFOO4Oo8qlwZE3ULT3+Zim1F8Kq4cBnikNhlCMlg==", + "dev": true + } + } + }, + "read-pkg-up": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/read-pkg-up/-/read-pkg-up-7.0.1.tgz", + "integrity": "sha512-zK0TB7Xd6JpCLmlLmufqykGE+/TlOePD6qKClNW7hHDKFh/J7/7gCWGR7joEQEW1bKq3a3yUZSObOoWLFQ4ohg==", + "dev": true, + "requires": { + "find-up": "^4.1.0", + "read-pkg": "^5.2.0", + "type-fest": "^0.8.1" + }, + "dependencies": { + "type-fest": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.8.1.tgz", + "integrity": "sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA==", + "dev": true + } + } + }, + "type-fest": { + "version": "0.18.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.18.1.tgz", + "integrity": "sha512-OIAYXk8+ISY+qTOwkHtKqzAuxchoMiD9Udx+FSGQDuiRR+PJKJHc2NJAXlbhkGwTt/4/nKZxELY1w3ReWOL8mw==", + "dev": true + }, + "yargs-parser": { + "version": "20.2.7", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.7.tgz", + "integrity": "sha512-FiNkvbeHzB/syOjIUxFDCnhSfzAL8R5vs40MgLFBorXACCOAEaWu0gRZl14vG8MR9AOJIZbmkjhusqBYZ3HTHw==", + "dev": true + } + } + }, "glob": { "version": "7.1.6", "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz", @@ -4756,6 +5937,15 @@ "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", "dev": true }, + "global-dirs": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/global-dirs/-/global-dirs-0.1.1.tgz", + "integrity": "sha1-sxnA3UYH81PzvpzKTHL8FIxJ9EU=", + "dev": true, + "requires": { + "ini": "^1.3.4" + } + }, "global-modules": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/global-modules/-/global-modules-2.0.0.tgz", @@ -4959,6 +6149,15 @@ "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", "dev": true }, + "homedir-polyfill": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/homedir-polyfill/-/homedir-polyfill-1.0.3.tgz", + "integrity": "sha512-eSmmWE5bZTK2Nou4g0AI3zZ9rswp7GRKoKXS1BLUkvPviOqs4YTN1djQIqrXy9k5gEtdLPy86JjRwsNM9tnDcA==", + "dev": true, + "requires": { + "parse-passwd": "^1.0.0" + } + }, "hosted-git-info": { "version": "2.8.8", "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.8.8.tgz", @@ -5384,6 +6583,135 @@ "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", "dev": true }, + "inquirer": { + "version": "6.5.2", + "resolved": "https://registry.npmjs.org/inquirer/-/inquirer-6.5.2.tgz", + "integrity": "sha512-cntlB5ghuB0iuO65Ovoi8ogLHiWGs/5yNrtUcKjFhSSiVeAIVpD7koaSU9RM8mpXw5YDi9RdYXGQMaOURB7ycQ==", + "dev": true, + "requires": { + "ansi-escapes": "^3.2.0", + "chalk": "^2.4.2", + "cli-cursor": "^2.1.0", + "cli-width": "^2.0.0", + "external-editor": "^3.0.3", + "figures": "^2.0.0", + "lodash": "^4.17.12", + "mute-stream": "0.0.7", + "run-async": "^2.2.0", + "rxjs": "^6.4.0", + "string-width": "^2.1.0", + "strip-ansi": "^5.1.0", + "through": "^2.3.6" + }, + "dependencies": { + "ansi-escapes": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-3.2.0.tgz", + "integrity": "sha512-cBhpre4ma+U0T1oM5fXg7Dy1Jw7zzwv7lt/GoCpr+hDQJoYnKVPLL4dCvSEFMmQurOQvSrwT7SL/DAlhBI97RQ==", + "dev": true + }, + "ansi-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-3.0.0.tgz", + "integrity": "sha1-7QMXwyIGT3lGbAKWa922Bas32Zg=", + "dev": true + }, + "ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dev": true, + "requires": { + "color-convert": "^1.9.0" + } + }, + "chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dev": true, + "requires": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + } + }, + "color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "dev": true, + "requires": { + "color-name": "1.1.3" + } + }, + "color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=", + "dev": true + }, + "has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0=", + "dev": true + }, + "is-fullwidth-code-point": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz", + "integrity": "sha1-o7MKXE8ZkYMWeqq5O+764937ZU8=", + "dev": true + }, + "string-width": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-2.1.1.tgz", + "integrity": "sha512-nOqH59deCq9SRHlxq1Aw85Jnt4w6KvLKqWVik6oA9ZklXLNIOlqg4F2yrT1MVaTjAqvVwdfeZ7w7aCvJD7ugkw==", + "dev": true, + "requires": { + "is-fullwidth-code-point": "^2.0.0", + "strip-ansi": "^4.0.0" + }, + "dependencies": { + "strip-ansi": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-4.0.0.tgz", + "integrity": "sha1-qEeQIusaw2iocTibY1JixQXuNo8=", + "dev": true, + "requires": { + "ansi-regex": "^3.0.0" + } + } + } + }, + "strip-ansi": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-5.2.0.tgz", + "integrity": "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==", + "dev": true, + "requires": { + "ansi-regex": "^4.1.0" + }, + "dependencies": { + "ansi-regex": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.0.tgz", + "integrity": "sha512-1apePfXM1UOSqw0o9IiFAovVz9M5S1Dg+4TrDwfMewQ6p/rmMueb7tWZjQ1rx4Loy1ArBggoqGpfqqdI4rondg==", + "dev": true + } + } + }, + "supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "requires": { + "has-flag": "^3.0.0" + } + } + } + }, "internal-ip": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/internal-ip/-/internal-ip-4.3.0.tgz", @@ -5644,6 +6972,12 @@ "integrity": "sha512-zohwelOAur+5uXtk8O3GPQ1eAcu4ZX3UwxQhUlfFFMNpUd83gXgjbhJh6HmB6LUNV/ieOLQuDwJO3dWJosUeMw==", "dev": true }, + "is-obj": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-2.0.0.tgz", + "integrity": "sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==", + "dev": true + }, "is-path-cwd": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/is-path-cwd/-/is-path-cwd-2.2.0.tgz", @@ -5735,12 +7069,27 @@ "has-symbols": "^1.0.1" } }, + "is-text-path": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-text-path/-/is-text-path-1.0.1.tgz", + "integrity": "sha1-Thqg+1G/vLPpJogAE5cgLBd1tm4=", + "dev": true, + "requires": { + "text-extensions": "^1.0.0" + } + }, "is-typedarray": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", "integrity": "sha1-5HnICFjfDBsR3dppQPlgEfzaSpo=", "dev": true }, + "is-utf8": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-utf8/-/is-utf8-0.2.1.tgz", + "integrity": "sha1-Sw2hRCEE0bM2NA6AeX6GXPOffXI=", + "dev": true + }, "is-windows": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/is-windows/-/is-windows-1.0.2.tgz", @@ -6538,6 +7887,36 @@ "minimist": "^1.2.5" } }, + "jsonc-parser": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.0.0.tgz", + "integrity": "sha512-fQzRfAbIBnR0IQvftw9FJveWiHp72Fg20giDrHz6TdfB12UH/uue0D3hm57UB5KgAVuniLMCaS8P1IMj9NR7cA==", + "dev": true + }, + "jsonfile": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", + "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", + "dev": true, + "requires": { + "graceful-fs": "^4.1.6", + "universalify": "^2.0.0" + }, + "dependencies": { + "universalify": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.0.tgz", + "integrity": "sha512-hAZsKq7Yy11Zu1DE0OzWjw7nnLZmJZYTDZZyEFHZdUhV8FkH5MCfoU1XMaxXovpyW5nq5scPqq0ZDP9Zyl04oQ==", + "dev": true + } + } + }, + "jsonparse": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/jsonparse/-/jsonparse-1.3.1.tgz", + "integrity": "sha1-P02uSpH6wxX3EGL4UhzCOfE2YoA=", + "dev": true + }, "jsprim": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/jsprim/-/jsprim-1.4.1.tgz", @@ -6661,6 +8040,12 @@ "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", "dev": true }, + "lodash.map": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/lodash.map/-/lodash.map-4.6.0.tgz", + "integrity": "sha1-dx7Hg540c9nEzeKLGTlMNWL09tM=", + "dev": true + }, "lodash.sortby": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/lodash.sortby/-/lodash.sortby-4.7.0.tgz", @@ -6682,6 +8067,12 @@ "integrity": "sha512-Hesni4s5UkWkwCGJMQGAh71PaLUmKFM60dHvq0zi/vDhhrzuk+4GgNbTXJ12YYQJn6ZKBDNIjYcuQGKudvqrIw==", "dev": true }, + "longest": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/longest/-/longest-2.0.1.tgz", + "integrity": "sha1-eB4YMpaqlPbU2RbcM10NF676I/g=", + "dev": true + }, "longest-streak": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-2.0.4.tgz", @@ -7039,6 +8430,12 @@ } } }, + "merge": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/merge/-/merge-1.2.1.tgz", + "integrity": "sha512-VjFo4P5Whtj4vsLzsYBu5ayHhoHJ0UqNm7ibvShmbmoz7tGi0vXaoJbGdB+GmDMLUdg8DpQXEIeVDAe8MaABvQ==", + "dev": true + }, "merge-descriptors": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz", @@ -7192,12 +8589,6 @@ "minimist": "^1.2.5" } }, - "mri": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/mri/-/mri-1.1.6.tgz", - "integrity": "sha512-oi1b3MfbyGa7FJMP9GmLTttni5JoICpYBRlq+x5V16fZbLsnL9N3wFqqIm/nIG43FjUFkFh9Epzp/kzUGUnJxQ==", - "dev": true - }, "ms": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", @@ -7220,18 +8611,11 @@ "integrity": "sha1-iZ8R2WhuXgXLkbNdXw5jt3PPyQE=", "dev": true }, - "multimatch": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/multimatch/-/multimatch-4.0.0.tgz", - "integrity": "sha512-lDmx79y1z6i7RNx0ZGCPq1bzJ6ZoDDKbvh7jxr9SJcWLkShMzXrHbYVpTdnhNM5MXpDUxCQ4DgqVttVXlBgiBQ==", - "dev": true, - "requires": { - "@types/minimatch": "^3.0.3", - "array-differ": "^3.0.0", - "array-union": "^2.1.0", - "arrify": "^2.0.1", - "minimatch": "^3.0.4" - } + "mute-stream": { + "version": "0.0.7", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.7.tgz", + "integrity": "sha1-MHXOk7whuPq0PhvE2n6BFe0ee6s=", + "dev": true }, "nan": { "version": "2.14.2", @@ -7619,6 +9003,12 @@ "url-parse": "^1.4.3" } }, + "os-tmpdir": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz", + "integrity": "sha1-u+Z0BseaqFxc/sdm/lc0VV36EnQ=", + "dev": true + }, "p-each-series": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/p-each-series/-/p-each-series-2.2.0.tgz", @@ -7726,6 +9116,12 @@ "error-ex": "^1.2.0" } }, + "parse-passwd": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/parse-passwd/-/parse-passwd-1.0.0.tgz", + "integrity": "sha1-bVuTSkVpk7I9N/QKOC1vFmao5cY=", + "dev": true + }, "parse5": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/parse5/-/parse5-6.0.1.tgz", @@ -8427,122 +9823,6 @@ "react-is": "^17.0.1" } }, - "pretty-quick": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/pretty-quick/-/pretty-quick-3.1.0.tgz", - "integrity": "sha512-DtxIxksaUWCgPFN7E1ZZk4+Aav3CCuRdhrDSFZENb404sYMtuo9Zka823F+Mgeyt8Zt3bUiCjFzzWYE9LYqkmQ==", - "dev": true, - "requires": { - "chalk": "^3.0.0", - "execa": "^4.0.0", - "find-up": "^4.1.0", - "ignore": "^5.1.4", - "mri": "^1.1.5", - "multimatch": "^4.0.0" - }, - "dependencies": { - "chalk": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-3.0.0.tgz", - "integrity": "sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==", - "dev": true, - "requires": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - } - }, - "execa": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/execa/-/execa-4.1.0.tgz", - "integrity": "sha512-j5W0//W7f8UxAn8hXVnwG8tLwdiUy4FJLcSupCg6maBYZDpyBvTApK7KyuI4bKj8KOh1r2YH+6ucuYtJv1bTZA==", - "dev": true, - "requires": { - "cross-spawn": "^7.0.0", - "get-stream": "^5.0.0", - "human-signals": "^1.1.1", - "is-stream": "^2.0.0", - "merge-stream": "^2.0.0", - "npm-run-path": "^4.0.0", - "onetime": "^5.1.0", - "signal-exit": "^3.0.2", - "strip-final-newline": "^2.0.0" - } - }, - "find-up": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", - "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", - "dev": true, - "requires": { - "locate-path": "^5.0.0", - "path-exists": "^4.0.0" - } - }, - "get-stream": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", - "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", - "dev": true, - "requires": { - "pump": "^3.0.0" - } - }, - "is-stream": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.0.tgz", - "integrity": "sha512-XCoy+WlUr7d1+Z8GgSuXmpuUFC9fOhRXglJMx+dwLKTkL44Cjd4W1Z5P+BQZpr+cR93aGP4S/s7Ftw6Nd/kiEw==", - "dev": true - }, - "locate-path": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", - "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", - "dev": true, - "requires": { - "p-locate": "^4.1.0" - } - }, - "npm-run-path": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", - "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", - "dev": true, - "requires": { - "path-key": "^3.0.0" - } - }, - "p-limit": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", - "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", - "dev": true, - "requires": { - "p-try": "^2.0.0" - } - }, - "p-locate": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", - "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", - "dev": true, - "requires": { - "p-limit": "^2.2.0" - } - }, - "p-try": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", - "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", - "dev": true - }, - "path-exists": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", - "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true - } - } - }, "process-nextick-args": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", @@ -9213,18 +10493,99 @@ } } }, + "resolve-dir": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/resolve-dir/-/resolve-dir-1.0.1.tgz", + "integrity": "sha1-eaQGRMNivoLybv/nOcm7U4IEb0M=", + "dev": true, + "requires": { + "expand-tilde": "^2.0.0", + "global-modules": "^1.0.0" + }, + "dependencies": { + "global-modules": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/global-modules/-/global-modules-1.0.0.tgz", + "integrity": "sha512-sKzpEkf11GpOFuw0Zzjzmt4B4UZwjOcG757PPvrfhxcLFbq0wpsgpOqxpxtxFiCG4DtG93M6XRVbF2oGdev7bg==", + "dev": true, + "requires": { + "global-prefix": "^1.0.1", + "is-windows": "^1.0.1", + "resolve-dir": "^1.0.0" + } + }, + "global-prefix": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/global-prefix/-/global-prefix-1.0.2.tgz", + "integrity": "sha1-2/dDxsFJklk8ZVVoy2btMsASLr4=", + "dev": true, + "requires": { + "expand-tilde": "^2.0.2", + "homedir-polyfill": "^1.0.1", + "ini": "^1.3.4", + "is-windows": "^1.0.1", + "which": "^1.2.14" + } + }, + "which": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", + "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", + "dev": true, + "requires": { + "isexe": "^2.0.0" + } + } + } + }, "resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", "dev": true }, + "resolve-global": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-global/-/resolve-global-1.0.0.tgz", + "integrity": "sha512-zFa12V4OLtT5XUX/Q4VLvTfBf+Ok0SPc1FNGM/z9ctUdiU618qwKpWnd0CHs3+RqROfyEg/DhuHbMWYqcgljEw==", + "dev": true, + "requires": { + "global-dirs": "^0.1.1" + } + }, "resolve-url": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/resolve-url/-/resolve-url-0.2.1.tgz", "integrity": "sha1-LGN/53yJOv0qZj/iGqkIAGjiBSo=", "dev": true }, + "restore-cursor": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-2.0.0.tgz", + "integrity": "sha1-n37ih/gv0ybU/RYpI9YhKe7g368=", + "dev": true, + "requires": { + "onetime": "^2.0.0", + "signal-exit": "^3.0.2" + }, + "dependencies": { + "mimic-fn": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-1.2.0.tgz", + "integrity": "sha512-jf84uxzwiuiIVKiOLpfYk7N46TSy8ubTonmneY9vrpHNAnp0QBt2BxWV9dO3/j+BoVAb+a5G6YDPW3M5HOdMWQ==", + "dev": true + }, + "onetime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-2.0.1.tgz", + "integrity": "sha1-BnQoIw/WdEOyeUsiu6UotoZ5YtQ=", + "dev": true, + "requires": { + "mimic-fn": "^1.0.0" + } + } + } + }, "ret": { "version": "0.1.15", "resolved": "https://registry.npmjs.org/ret/-/ret-0.1.15.tgz", @@ -9258,6 +10619,12 @@ "integrity": "sha512-nfMOlASu9OnRJo1mbEk2cz0D56a1MBNrJ7orjRZQG10XDyuvwksKbuXNp6qa+kbn839HwjwhBzhFmdsaEAfauA==", "dev": true }, + "run-async": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/run-async/-/run-async-2.4.1.tgz", + "integrity": "sha512-tvVnVv01b8c1RrA6Ep7JkStj85Guv/YrMcwqYQnwjsAS2cTmmPGBBjAjpCW7RrSodNSoE2/qg9O4bceNvUuDgQ==", + "dev": true + }, "run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -9267,6 +10634,15 @@ "queue-microtask": "^1.2.2" } }, + "rxjs": { + "version": "6.6.7", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-6.6.7.tgz", + "integrity": "sha512-hTdwr+7yYNIT5n4AMYp85KA6yw2Va0FLa3Rguvbpa4W3I5xynaBZo41cM3XM+4Q6fRMj3sBYIR1VAmZMXYJvRQ==", + "dev": true, + "requires": { + "tslib": "^1.9.0" + } + }, "safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -10012,6 +11388,15 @@ "extend-shallow": "^3.0.0" } }, + "split2": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/split2/-/split2-3.2.2.tgz", + "integrity": "sha512-9NThjpgZnifTkJpzTZ7Eue85S49QwpNhZTq6GRJwObb6jnLFNGB7Qm73V5HewTROPyxD0C29xqmaI68bQtV+hg==", + "dev": true, + "requires": { + "readable-stream": "^3.0.0" + } + }, "sprintf-js": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", @@ -10673,6 +12058,12 @@ "minimatch": "^3.0.4" } }, + "text-extensions": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/text-extensions/-/text-extensions-1.9.0.tgz", + "integrity": "sha512-wiBrwC1EhBelW12Zy26JeOUkQ5mRu+5o8rpsJk5+2t+Y5vE7e842qtZDQ2g1NpX/29HdyFeJ4nSIhI47ENSxlQ==", + "dev": true + }, "text-table": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", @@ -10685,12 +12076,36 @@ "integrity": "sha512-fcwX4mndzpLQKBS1DVYhGAcYaYt7vsHNIvQV+WXMvnow5cgjPphq5CaayLaGsjRdSCKZFNGt7/GYAuXaNOiYCA==", "dev": true }, + "through": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", + "integrity": "sha1-DdTJ/6q8NXlgsbckEV1+Doai4fU=", + "dev": true + }, + "through2": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/through2/-/through2-4.0.2.tgz", + "integrity": "sha512-iOqSav00cVxEEICeD7TjLB1sueEL+81Wpzp2bY17uZjZN0pWZPuo4suZ/61VujxmqSGFfgOcNuTZ85QJwNZQpw==", + "dev": true, + "requires": { + "readable-stream": "3" + } + }, "thunky": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/thunky/-/thunky-1.1.0.tgz", "integrity": "sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA==", "dev": true }, + "tmp": { + "version": "0.0.33", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz", + "integrity": "sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==", + "dev": true, + "requires": { + "os-tmpdir": "~1.0.2" + } + }, "tmpl": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.4.tgz", @@ -10776,6 +12191,12 @@ "integrity": "sha512-C4+gOpvmxaSMKuEf9Qc134F1ZuOHVXKRbtEflf4NTtuuJDEIJ9p5PXsalL8SkeRw+qit1Mo+yuvMPAKwWg/1hA==", "dev": true }, + "trim-off-newlines": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/trim-off-newlines/-/trim-off-newlines-1.0.1.tgz", + "integrity": "sha1-n5up2e+odkw4dpi8v+sshI8RrbM=", + "dev": true + }, "trough": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/trough/-/trough-1.0.5.tgz", @@ -11257,6 +12678,43 @@ "unist-util-stringify-position": "^2.0.0" } }, + "vscode-json-languageservice": { + "version": "3.11.0", + "resolved": "https://registry.npmjs.org/vscode-json-languageservice/-/vscode-json-languageservice-3.11.0.tgz", + "integrity": "sha512-QxI+qV97uD7HHOCjh3MrM1TfbdwmTXrMckri5Tus1/FQiG3baDZb2C9Y0y8QThs7PwHYBIQXcAc59ZveCRZKPA==", + "dev": true, + "requires": { + "jsonc-parser": "^3.0.0", + "vscode-languageserver-textdocument": "^1.0.1", + "vscode-languageserver-types": "3.16.0-next.2", + "vscode-nls": "^5.0.0", + "vscode-uri": "^2.1.2" + } + }, + "vscode-languageserver-textdocument": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/vscode-languageserver-textdocument/-/vscode-languageserver-textdocument-1.0.1.tgz", + "integrity": "sha512-UIcJDjX7IFkck7cSkNNyzIz5FyvpQfY7sdzVy+wkKN/BLaD4DQ0ppXQrKePomCxTS7RrolK1I0pey0bG9eh8dA==", + "dev": true + }, + "vscode-languageserver-types": { + "version": "3.16.0-next.2", + "resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.16.0-next.2.tgz", + "integrity": "sha512-QjXB7CKIfFzKbiCJC4OWC8xUncLsxo19FzGVp/ADFvvi87PlmBSCAtZI5xwGjF5qE0xkLf0jjKUn3DzmpDP52Q==", + "dev": true + }, + "vscode-nls": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/vscode-nls/-/vscode-nls-5.0.0.tgz", + "integrity": "sha512-u0Lw+IYlgbEJFF6/qAqG2d1jQmJl0eyAGJHoAJqr2HT4M2BNuQYSEiSE75f52pXHSJm8AlTjnLLbBFPrdz2hpA==", + "dev": true + }, + "vscode-uri": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-2.1.2.tgz", + "integrity": "sha512-8TEXQxlldWAuIODdukIb+TR5s+9Ds40eSJrw+1iDDA9IFORPjMELarNQE3myz5XIkWWpdprmJjm1/SxMlWOC8A==", + "dev": true + }, "w3c-hr-time": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/w3c-hr-time/-/w3c-hr-time-1.0.2.tgz", diff --git a/package.json b/package.json index 51f76ca0..8bccf183 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,8 @@ "axios": "^0.21.1" }, "devDependencies": { + "@commitlint/cli": "^12.1.1", + "@commitlint/config-conventional": "^12.1.1", "@types/chromecast-caf-receiver": "^5.0.12", "@types/jest": "^26.0.22", "@types/node": "^14.14.39", @@ -20,12 +22,14 @@ "clean-webpack-plugin": "^3.0.0", "cross-env": "^7.0.3", "css-loader": "^5.2.1", + "cz-conventional-changelog": "^3.3.0", "eslint": "^7.24.0", "eslint-config-prettier": "^8.2.0", - "eslint-import-resolver-typescript": "^2.3.0", + "eslint-import-resolver-typescript": "^2.4.0", "eslint-plugin-import": "^2.22.1", "eslint-plugin-jsdoc": "^32.3.0", - "eslint-plugin-prettier": "^3.3.0", + "eslint-plugin-json": "^2.1.2", + "eslint-plugin-prettier": "^3.3.1", "eslint-plugin-promise": "^5.1.0", "file-loader": "^6.2.0", "html-loader": "^2.1.2", @@ -35,10 +39,9 @@ "imagemin-svgo": "^8.0.0", "jest": "^26.6.3", "prettier": "^2.2.1", - "pretty-quick": "^3.1.0", "source-map-loader": "^2.0.1", "style-loader": "^2.0.0", - "stylelint": "^13.8.0", + "stylelint": "^13.12.0", "stylelint-config-prettier": "^8.0.2", "stylelint-config-standard": "^21.0.0", "ts-jest": "^26.5.4", @@ -52,15 +55,15 @@ "webpack-dev-server": "^3.11.0", "webpack-merge": "^5.4.1" }, + "config": { + "commitizen": { + "path": "./node_modules/cz-conventional-changelog" + } + }, "engines": { "yarn": "YARN NO LONGER USED - use npm instead." }, "homepage": "https://jellyfin.org/", - "husky": { - "hooks": { - "pre-commit": "pretty-quick --staged" - } - }, "license": "GPL-2.0-or-later", "repository": { "type": "git", @@ -70,9 +73,9 @@ "build:development": "TS_NODE_PROJECT=\"tsconfig-webpack.json\" webpack --config webpack.config.ts --mode=development", "build:production": "TS_NODE_PROJECT=\"tsconfig-webpack.json\" webpack --config webpack.config.ts --mode=production", "lint": "npm run lint:code && npm run lint:css && npm run prettier", - "lint:code": "eslint --ext .ts,.js .", - "lint:css": "stylelint ./src/css/*.css", - "prepare": "npm run build:production && husky install", + "lint:code": "eslint --ext .ts,.js,.json .", + "lint:css": "stylelint **/*.css", + "prepare": "npm run build:production", "prettier": "prettier --check .", "start": "TS_NODE_PROJECT=\"tsconfig-webpack.json\" webpack serve --config webpack.config.ts", "test": "jest --passWithNoTests", diff --git a/stylelint.config.js b/stylelint.config.js new file mode 100644 index 00000000..802e6ddb --- /dev/null +++ b/stylelint.config.js @@ -0,0 +1,7 @@ +module.exports = { + syntax: 'css', + extends: ['stylelint-config-standard', 'stylelint-config-prettier'], + rules: { + 'at-rule-no-unknown': null + } +}; diff --git a/tsconfig.json b/tsconfig.json index 61f2c12e..d7578dac 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -18,5 +18,5 @@ "@types/node", "@types/chromecast-caf-receiver" ] - } + } } diff --git a/webpack.config.ts b/webpack.config.ts index 9010da6c..a0057aec 100644 --- a/webpack.config.ts +++ b/webpack.config.ts @@ -7,102 +7,101 @@ import { merge } from 'webpack-merge'; import { CleanWebpackPlugin } from 'clean-webpack-plugin'; import HtmlWebpackPlugin from 'html-webpack-plugin'; import ImageMinimizerPlugin from 'image-minimizer-webpack-plugin'; -import version from './package.json'; +import { version } from './package.json'; const common: webpack.Configuration = { - context: path.resolve(__dirname, 'src'), - entry: './app.ts', - output: { - filename: '[name].[fullhash].js', - path: path.resolve(__dirname, 'dist'), - publicPath: './' - }, - resolve: { - extensions: ['.ts', '.js'] - }, - plugins: [ - // @ts-expect-error - Typings mismatch between versions - new CleanWebpackPlugin(), - new HtmlWebpackPlugin({ - filename: 'index.html', - template: 'index.html', - hash: false, - favicon: 'favicon.ico' - }), - new ImageMinimizerPlugin({ - minimizerOptions: { - plugins: [ - [ - 'svgo', - { - plugins: [ - { - removeComments: false - } - ] - } - ] - ] - } - }) - ], - module: { - rules: [ - { test: /\.html$/, loader: 'html-loader' }, - { - test: /\.(png|svg|jpg|gif)$/, - use: 'file-loader' - }, + context: path.resolve(__dirname, 'src'), + entry: './app.ts', + output: { + filename: '[name].[fullhash].js', + path: path.resolve(__dirname, 'dist'), + publicPath: './' + }, + resolve: { + extensions: ['.ts', '.js'] + }, + plugins: [ + // @ts-expect-error - Typings mismatch between versions + new CleanWebpackPlugin(), + new HtmlWebpackPlugin({ + filename: 'index.html', + template: 'index.html', + hash: false, + favicon: 'favicon.ico' + }), + new ImageMinimizerPlugin({ + minimizerOptions: { + plugins: [ + [ + 'svgo', { - test: /\.(ttf|eot|woff(2)?)(\?[a-z0-9=&.]+)?$/, - loader: 'file-loader' - }, - { test: /\.css$/i, use: ['style-loader', 'css-loader'] }, - { test: /\.tsx?$/, loader: 'ts-loader' }, - { test: /\.js$/, loader: 'source-map-loader' } + plugins: [ + { + removeComments: false + } + ] + } + ] ] - } + } + }) + ], + module: { + rules: [ + { test: /\.html$/, loader: 'html-loader' }, + { + test: /\.(png|svg|jpg|gif)$/, + use: 'file-loader' + }, + { + test: /\.(ttf|eot|woff(2)?)(\?[a-z0-9=&.]+)?$/, + loader: 'file-loader' + }, + { test: /\.css$/i, use: ['style-loader', 'css-loader'] }, + { test: /\.tsx?$/, loader: 'ts-loader' }, + { test: /\.js$/, loader: 'source-map-loader' } + ] + } }; const development: webpack.Configuration = { - mode: 'development', - devtool: 'inline-source-map', - // @ts-expect-error - Typings mismatch between versions - devServer: { - contentBase: path.join(__dirname, 'dist'), - compress: true, - port: process.env.RECEIVER_PORT - ? Number.parseInt(process.env.RECEIVER_PORT, 10) - : 9000, - publicPath: '/' - }, - plugins: [ - new DefinePlugin({ - PRODUCTION: JSON.stringify(false), - RECEIVERVERSION: JSON.stringify(version) - }) - ] + mode: 'development', + devtool: 'inline-source-map', + // @ts-expect-error - Typings mismatch between versions + devServer: { + contentBase: path.join(__dirname, 'dist'), + compress: true, + port: process.env.RECEIVER_PORT + ? Number.parseInt(process.env.RECEIVER_PORT, 10) + : 9000, + publicPath: '/' + }, + plugins: [ + new DefinePlugin({ + PRODUCTION: JSON.stringify(false), + RECEIVERVERSION: JSON.stringify(version) + }) + ] }; const production: webpack.Configuration = { - mode: 'production', - plugins: [ - new DefinePlugin({ - PRODUCTION: JSON.stringify(true), - RECEIVERVERSION: JSON.stringify(version) - }) - ] + mode: 'production', + plugins: [ + new DefinePlugin({ + PRODUCTION: JSON.stringify(true), + RECEIVERVERSION: JSON.stringify(version) + }) + ] }; -module.exports = ( - argv: { [key: string]: string } -): webpack.Configuration => { - let config; - if (argv.mode === 'production') { - config = merge(common, production); - } else { - config = merge(common, development); - } +module.exports = (argv: { [key: string]: string }): webpack.Configuration => { + let config; + + if (argv.mode === 'production') { + config = merge(common, production); + } else { + config = merge(common, development); + } - return config; + return config; }; From a639c91f2faf07537a44bcc3fd16bd3050371ff4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fernando=20Fern=C3=A1ndez?= Date: Fri, 1 Jan 2021 19:03:25 +0100 Subject: [PATCH 02/10] chore(lint): automatic eslint fix --- jest.config.js | 4 +- src/api/credentialManager.ts | 115 +- src/app.ts | 10 +- src/components/castDevices.ts | 47 +- src/components/codecSupportHelper.ts | 161 +-- src/components/commandHandler.ts | 405 +++---- src/components/deviceprofileBuilder.ts | 610 +++++----- src/components/documentManager.ts | 1418 ++++++++++++------------ src/components/fetchhelper.ts | 222 ++-- src/components/jellyfinActions.ts | 551 ++++----- src/components/jellyfinApi.ts | 233 ++-- src/components/maincontroller.ts | 1230 ++++++++++---------- src/components/playbackManager.ts | 377 ++++--- src/helpers.ts | 1209 ++++++++++---------- src/types/global.d.ts | 122 +- 15 files changed, 3447 insertions(+), 3267 deletions(-) diff --git a/jest.config.js b/jest.config.js index 9f1e9c74..eef6b07c 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,4 +1,4 @@ module.exports = { - preset: 'ts-jest', - testEnvironment: 'node' + preset: 'ts-jest', + testEnvironment: 'node' }; diff --git a/src/api/credentialManager.ts b/src/api/credentialManager.ts index e4e49307..7016266f 100644 --- a/src/api/credentialManager.ts +++ b/src/api/credentialManager.ts @@ -1,74 +1,77 @@ import { Configuration } from './generated/configuration'; interface CredentialStore { - [id: string]: Configuration; + [id: string]: Configuration; } export class credentialManager { - /** - * Store for credentials - * - * @private - */ - private credentialStore: CredentialStore = {}; + /** + * Store for credentials + * + * @private + */ + private credentialStore: CredentialStore = {}; - /** - * Get credentials for the provided server ID - * - * @param serverId - ID of the server the credentials belong to - * @returns Credentials for the provided server ID - * or undefined if the store has no server with that ID - */ - get(serverId: string): Configuration | undefined { - if (serverId in this.credentialStore) { - return this.credentialStore[serverId]; - } + /** + * Get credentials for the provided server ID + * + * @param serverId - ID of the server the credentials belong to + * @returns Credentials for the provided server ID + * or undefined if the store has no server with that ID + */ + get(serverId: string): Configuration | undefined { + if (serverId in this.credentialStore) { + return this.credentialStore[serverId]; } + } - /** - * Update credentials for the provided server ID - * - * @param serverId - ID of the server to update - * @param newConfig - Updated Credentials - * @returns True if the value was updated, false if it wasn't - */ - update(serverId: string, newConfig: Configuration): boolean { - if (serverId in this.credentialStore) { - this.credentialStore[serverId] = newConfig; - return true; - } + /** + * Update credentials for the provided server ID + * + * @param serverId - ID of the server to update + * @param newConfig - Updated Credentials + * @returns True if the value was updated, false if it wasn't + */ + update(serverId: string, newConfig: Configuration): boolean { + if (serverId in this.credentialStore) { + this.credentialStore[serverId] = newConfig; - return false; + return true; } - /** - * Add a new credential to store. Only accepts new entries. - * - * @param serverId - ID of the server the credentials belong to - * @param configuration - Credentials of the server - * @returns True if server was added, false if it wasn't - */ - add(serverId: string, configuration: Configuration): boolean { - if (serverId in this.credentialStore) { - return false; - } + return false; + } - this.credentialStore[serverId] = configuration; - return true; + /** + * Add a new credential to store. Only accepts new entries. + * + * @param serverId - ID of the server the credentials belong to + * @param configuration - Credentials of the server + * @returns True if server was added, false if it wasn't + */ + add(serverId: string, configuration: Configuration): boolean { + if (serverId in this.credentialStore) { + return false; } - /** - * Add a new credential to store. Only accepts new entries. - * - * @param serverId - ID of the server the credentials belong to - * @returns True if server was added, false if it wasn't - */ - remove(serverId: string): boolean { - if (serverId in this.credentialStore) { - delete this.credentialStore[serverId]; - return true; - } + this.credentialStore[serverId] = configuration; - return false; + return true; + } + + /** + * Add a new credential to store. Only accepts new entries. + * + * @param serverId - ID of the server the credentials belong to + * @returns True if server was added, false if it wasn't + */ + remove(serverId: string): boolean { + if (serverId in this.credentialStore) { + delete this.credentialStore[serverId]; + + return true; } + + return false; + } } diff --git a/src/app.ts b/src/app.ts index 18b4dd16..8a86dae7 100644 --- a/src/app.ts +++ b/src/app.ts @@ -5,14 +5,12 @@ import './css/jellyfin.css'; const senders = cast.framework.CastReceiverContext.getInstance().getSenders(); const id = - senders.length !== 0 && senders[0].id - ? senders[0].id - : new Date().getTime(); + senders.length !== 0 && senders[0].id ? senders[0].id : new Date().getTime(); window.deviceInfo = { - deviceId: id, - deviceName: 'Google Cast', - versionNumber: RECEIVERVERSION + deviceId: id, + deviceName: 'Google Cast', + versionNumber: RECEIVERVERSION }; window.mediaElement = document.getElementById('video-player'); diff --git a/src/components/castDevices.ts b/src/components/castDevices.ts index 7da95f26..25fbd9c9 100644 --- a/src/components/castDevices.ts +++ b/src/components/castDevices.ts @@ -2,12 +2,12 @@ const castContext = cast.framework.CastReceiverContext.getInstance(); // Device Ids export enum deviceIds { - GEN1AND2, - AUDIO, - GEN3, - ULTRA, - NESTHUBANDMAX, //Nest hub and Nest hub max - CCGTV //Chromecast Google TV + GEN1AND2, + AUDIO, + GEN3, + ULTRA, + NESTHUBANDMAX, //Nest hub and Nest hub max + CCGTV //Chromecast Google TV } // cached device id, avoid looking it up again and again @@ -20,21 +20,24 @@ let deviceId: number | null = null; * @returns Active Cast device Id. */ export function getActiveDeviceId(): number { - if (deviceId !== null) return deviceId; - - if ( - castContext.canDisplayType('video/mp4', 'hev1.1.6.L153.B0') && - castContext.canDisplayType('video/webm', 'vp9') - ) { - deviceId = deviceIds.ULTRA; - } else if (castContext.canDisplayType('video/webm', 'vp9')) { - deviceId = deviceIds.NESTHUBANDMAX; - } else if (castContext.canDisplayType('video/mp4', 'avc1.64002A')) { - deviceId = deviceIds.GEN3; - } else if (castContext.canDisplayType('video/mp4', 'avc1.640029')) { - deviceId = deviceIds.GEN1AND2; - } else { - deviceId = deviceIds.AUDIO; - } + if (deviceId !== null) { return deviceId; + } + + if ( + castContext.canDisplayType('video/mp4', 'hev1.1.6.L153.B0') && + castContext.canDisplayType('video/webm', 'vp9') + ) { + deviceId = deviceIds.ULTRA; + } else if (castContext.canDisplayType('video/webm', 'vp9')) { + deviceId = deviceIds.NESTHUBANDMAX; + } else if (castContext.canDisplayType('video/mp4', 'avc1.64002A')) { + deviceId = deviceIds.GEN3; + } else if (castContext.canDisplayType('video/mp4', 'avc1.640029')) { + deviceId = deviceIds.GEN1AND2; + } else { + deviceId = deviceIds.AUDIO; + } + + return deviceId; } diff --git a/src/components/codecSupportHelper.ts b/src/components/codecSupportHelper.ts index 0318ae05..2f310d0b 100644 --- a/src/components/codecSupportHelper.ts +++ b/src/components/codecSupportHelper.ts @@ -13,8 +13,8 @@ const castContext = cast.framework.CastReceiverContext.getInstance(); * @returns true if E-AC-3 can be played */ export function hasEAC3Support(): boolean { - //return castContext.canDisplayType('audio/mp4', 'ec-3'); - return false; + //return castContext.canDisplayType('audio/mp4', 'ec-3'); + return false; } /** @@ -29,8 +29,8 @@ export function hasEAC3Support(): boolean { * */ export function hasAC3Support(): boolean { - //return castContext.canDisplayType('audio/mp4', 'ac-3'); - return false; + //return castContext.canDisplayType('audio/mp4', 'ac-3'); + return false; } /** @@ -44,8 +44,8 @@ export function hasAC3Support(): boolean { * @returns true if surround codecs can be played */ export function hasSurroundSupport(): boolean { - // This will turn on surround support if passthrough is available. - return hasAC3Support(); + // This will turn on surround support if passthrough is available. + return hasAC3Support(); } /** @@ -54,7 +54,7 @@ export function hasSurroundSupport(): boolean { * @returns true if HEVC is supported */ export function hasH265Support(): boolean { - return castContext.canDisplayType('video/mp4', 'hev1.1.6.L150.B0'); + return castContext.canDisplayType('video/mp4', 'hev1.1.6.L150.B0'); } /** @@ -62,11 +62,11 @@ export function hasH265Support(): boolean { * This is not supported on Chromecast Audio, * but otherwise is. * - * @param deviceId the device id + * @param deviceId - the device id * @returns true if text tracks are supported */ export function hasTextTrackSupport(deviceId: number): boolean { - return deviceId !== deviceIds.AUDIO; + return deviceId !== deviceIds.AUDIO; } /** @@ -75,7 +75,7 @@ export function hasTextTrackSupport(deviceId: number): boolean { * @returns true if VP-8 is supported */ export function hasVP8Support(): boolean { - return castContext.canDisplayType('video/webm', 'vp8'); + return castContext.canDisplayType('video/webm', 'vp8'); } /** @@ -84,7 +84,7 @@ export function hasVP8Support(): boolean { * @returns true if VP-9 is supported */ export function hasVP9Support(): boolean { - return castContext.canDisplayType('video/webm', 'vp9'); + return castContext.canDisplayType('video/webm', 'vp9'); } /** @@ -93,69 +93,69 @@ export function hasVP9Support(): boolean { * @returns Max supported bitrate. */ export function getMaxBitrateSupport(): number { - // FIXME: We should get this dynamically or hardcode this to values - // we see fit for each Cast device. More testing is needed. - // 120Mb/s ? - return 120000000; + // FIXME: We should get this dynamically or hardcode this to values + // we see fit for each Cast device. More testing is needed. + // 120Mb/s ? + return 120000000; } /** * Get the max supported video width the active Cast device supports. * - * @param deviceId Cast device id. + * @param deviceId - Cast device id. * @returns Max supported width. */ export function getMaxWidthSupport(deviceId: number): number { - switch (deviceId) { - case deviceIds.ULTRA: - case deviceIds.CCGTV: - return 3840; - case deviceIds.GEN1AND2: - case deviceIds.GEN3: - return 1920; - case deviceIds.NESTHUBANDMAX: - return 1280; - } + switch (deviceId) { + case deviceIds.ULTRA: + case deviceIds.CCGTV: + return 3840; + case deviceIds.GEN1AND2: + case deviceIds.GEN3: + return 1920; + case deviceIds.NESTHUBANDMAX: + return 1280; + } - return 0; + return 0; } /** * Get all H.26x profiles supported by the active Cast device. * - * @param {number} deviceId Cast device id. - * @returns {string} All supported H.26x profiles. + * @param deviceId - Cast device id. + * @returns All supported H.26x profiles. */ export function getH26xProfileSupport(deviceId: number): string { - // These are supported by all Cast devices, excluding audio only devices. - let h26xProfiles = 'high|main|baseline|constrained baseline'; + // These are supported by all Cast devices, excluding audio only devices. + let h26xProfiles = 'high|main|baseline|constrained baseline'; - if (deviceId === deviceIds.ULTRA || deviceId === deviceIds.CCGTV) { - h26xProfiles += '|high 10'; - } + if (deviceId === deviceIds.ULTRA || deviceId === deviceIds.CCGTV) { + h26xProfiles += '|high 10'; + } - return h26xProfiles; + return h26xProfiles; } /** * Get the highest H.26x level supported by the active Cast device. * - * @param deviceId Cast device id. + * @param deviceId - Cast device id. * @returns The highest supported H.26x level. */ export function getH26xLevelSupport(deviceId: number): number { - switch (deviceId) { - case deviceIds.NESTHUBANDMAX: - case deviceIds.GEN1AND2: - return 41; - case deviceIds.GEN3: - return 42; - case deviceIds.ULTRA: - case deviceIds.CCGTV: - return 52; - } + switch (deviceId) { + case deviceIds.NESTHUBANDMAX: + case deviceIds.GEN1AND2: + return 41; + case deviceIds.GEN3: + return 42; + case deviceIds.ULTRA: + case deviceIds.CCGTV: + return 52; + } - return 0; + return 0; } /** @@ -164,16 +164,17 @@ export function getH26xLevelSupport(deviceId: number): number { * @returns Supported VPX codecs. */ export function getSupportedVPXVideoCodecs(): Array { - const codecs = []; - if (hasVP8Support()) { - codecs.push('VP8'); - } + const codecs = []; - if (hasVP9Support()) { - codecs.push('VP9'); - } + if (hasVP8Support()) { + codecs.push('VP8'); + } - return codecs; + if (hasVP9Support()) { + codecs.push('VP9'); + } + + return codecs; } /** @@ -182,14 +183,14 @@ export function getSupportedVPXVideoCodecs(): Array { * @returns Supported MP4 video codecs. */ export function getSupportedMP4VideoCodecs(): Array { - const codecs = ['h264']; + const codecs = ['h264']; - if (hasH265Support()) { - codecs.push('h265'); - codecs.push('hevc'); - } + if (hasH265Support()) { + codecs.push('h265'); + codecs.push('hevc'); + } - return codecs; + return codecs; } /** @@ -198,16 +199,20 @@ export function getSupportedMP4VideoCodecs(): Array { * @returns Supported MP4 audio codecs. */ export function getSupportedMP4AudioCodecs(): Array { - const codecs = []; - if (hasEAC3Support()) { - codecs.push('eac3'); - } - if (hasAC3Support()) { - codecs.push('ac3'); - } - codecs.push('aac'); - codecs.push('mp3'); - return codecs; + const codecs = []; + + if (hasEAC3Support()) { + codecs.push('eac3'); + } + + if (hasAC3Support()) { + codecs.push('ac3'); + } + + codecs.push('aac'); + codecs.push('mp3'); + + return codecs; } /** @@ -216,9 +221,9 @@ export function getSupportedMP4AudioCodecs(): Array { * @returns Supported HLS video codecs. */ export function getSupportedHLSVideoCodecs(): Array { - // Currently the server does not support fmp4 which is required - // by the HLS spec for streaming H.265 video. - return ['h264']; + // Currently the server does not support fmp4 which is required + // by the HLS spec for streaming H.265 video. + return ['h264']; } /** @@ -227,8 +232,8 @@ export function getSupportedHLSVideoCodecs(): Array { * @returns All supported HLS audio codecs. */ export function getSupportedHLSAudioCodecs(): Array { - // HLS basically supports whatever MP4 supports. - return getSupportedMP4AudioCodecs(); + // HLS basically supports whatever MP4 supports. + return getSupportedMP4AudioCodecs(); } /** @@ -237,7 +242,7 @@ export function getSupportedHLSAudioCodecs(): Array { * @returns All supported WebM audio codecs. */ export function getSupportedWebMAudioCodecs(): Array { - return ['vorbis', 'opus']; + return ['vorbis', 'opus']; } /** @@ -246,5 +251,5 @@ export function getSupportedWebMAudioCodecs(): Array { * @returns All supported WebM audio codecs. */ export function getSupportedAudioCodecs(): Array { - return ['opus', 'mp3', 'aac', 'flac', 'webma', 'wav']; + return ['opus', 'mp3', 'aac', 'flac', 'webma', 'wav']; } diff --git a/src/components/commandHandler.ts b/src/components/commandHandler.ts index cf09065c..1bc1299e 100644 --- a/src/components/commandHandler.ts +++ b/src/components/commandHandler.ts @@ -1,20 +1,20 @@ import { getReportingParams } from '../helpers'; import { - DataMessage, - DisplayRequest, - PlayRequest, - SeekRequest, - SetIndexRequest, - SetRepeatModeRequest, - SupportedCommands + DataMessage, + DisplayRequest, + PlayRequest, + SeekRequest, + SetIndexRequest, + SetRepeatModeRequest, + SupportedCommands } from '../types/global'; import { - translateItems, - shuffle, - instantMix, - setAudioStreamIndex, - setSubtitleStreamIndex, - seek + translateItems, + shuffle, + instantMix, + setAudioStreamIndex, + setSubtitleStreamIndex, + seek } from './maincontroller'; import { reportPlaybackProgress } from './jellyfinActions'; @@ -24,193 +24,194 @@ import { playbackManager } from './playbackManager'; import { DocumentManager } from './documentManager'; export abstract class CommandHandler { - private static playerManager: framework.PlayerManager; - private static playbackManager: playbackManager; - private static supportedCommands: SupportedCommands = { - PlayNext: CommandHandler.playNextHandler, - PlayNow: CommandHandler.playNowHandler, - PlayLast: CommandHandler.playLastHandler, - Shuffle: CommandHandler.shuffleHandler, - InstantMix: CommandHandler.instantMixHandler, - DisplayContent: CommandHandler.displayContentHandler, - NextTrack: CommandHandler.nextTrackHandler, - PreviousTrack: CommandHandler.previousTrackHandler, - SetAudioStreamIndex: CommandHandler.setAudioStreamIndexHandler, - SetSubtitleStreamIndex: CommandHandler.setSubtitleStreamIndexHandler, - VolumeUp: CommandHandler.VolumeUpHandler, - VolumeDown: CommandHandler.VolumeDownHandler, - ToggleMute: CommandHandler.ToggleMuteHandler, - Identify: CommandHandler.IdentifyHandler, - SetVolume: CommandHandler.SetVolumeHandler, - Seek: CommandHandler.SeekHandler, - Mute: CommandHandler.MuteHandler, - Unmute: CommandHandler.MuteHandler, - Stop: CommandHandler.StopHandler, - PlayPause: CommandHandler.PlayPauseHandler, - Pause: CommandHandler.PauseHandler, - SetRepeatMode: CommandHandler.SetRepeatModeHandler, - Unpause: CommandHandler.UnpauseHandler - }; - - static configure( - playerManager: framework.PlayerManager, - playbackManager: playbackManager - ): void { - this.playerManager = playerManager; - this.playbackManager = playbackManager; - } - - static playNextHandler(data: DataMessage): void { - translateItems(data, data.options, data.command); - } - - static playNowHandler(data: DataMessage): void { - translateItems(data, data.options, data.command); - } - - static playLastHandler(data: DataMessage): void { - translateItems(data, data.options, data.command); - } - - static shuffleHandler(data: DataMessage): void { - shuffle( - data, - data.options, - (data.options).items[0] - ); - } - - static instantMixHandler(data: DataMessage): void { - instantMix( - data, - data.options, - (data.options).items[0] - ); - } - - static displayContentHandler(data: DataMessage): void { - if (!this.playbackManager.isPlaying()) { - DocumentManager.showItemId((data.options).ItemId); - } - } - - static nextTrackHandler(): void { - if ( - window.playlist && - window.currentPlaylistIndex < window.playlist.length - 1 - ) { - this.playbackManager.playNextItem({}, true); - } - } - - static previousTrackHandler(): void { - if (window.playlist && window.currentPlaylistIndex > 0) { - this.playbackManager.playPreviousItem({}); - } - } - - static setAudioStreamIndexHandler(data: DataMessage): void { - setAudioStreamIndex($scope, (data.options).index); - } - - static setSubtitleStreamIndexHandler(data: DataMessage): void { - setSubtitleStreamIndex($scope, (data.options).index); - } - - // VolumeUp, VolumeDown and ToggleMute commands seem to be handled on the sender in the current implementation. - // From what I can tell there's no convenient way for the receiver to get its own volume. - // We should probably remove these commands in the future. - static VolumeUpHandler(): void { - console.log('VolumeUp handler not implemented'); - } - - static VolumeDownHandler(): void { - console.log('VolumeDown handler not implemented'); - } - - static ToggleMuteHandler(): void { - console.log('ToggleMute handler not implemented'); - } - - static SetVolumeHandler(): void { - // This is now implemented on the sender - console.log('SetVolume handler not implemented'); - } - - static IdentifyHandler(): void { - if (!this.playbackManager.isPlaying()) { - DocumentManager.startBackdropInterval(); - } else { - // When a client connects send back the initial device state (volume etc) via a playbackstop message - reportPlaybackProgress( - $scope, - getReportingParams($scope), - true, - 'playbackstop' - ); - } - } - - static SeekHandler(data: DataMessage): void { - seek((data.options).position * 10000000); - } - - static MuteHandler(): void { - // CommandHandler is now implemented on the sender - console.log('Mute handler not implemented'); - } - - static UnmuteHandler(): void { - // CommandHandler is now implemented on the sender - console.log('Unmute handler not implemented'); - } - - static StopHandler(): void { - this.playerManager.stop(); - } - - static PlayPauseHandler(): void { - if ( - this.playerManager.getPlayerState() === - cast.framework.messages.PlayerState.PAUSED - ) { - this.playerManager.play(); - } else { - this.playerManager.pause(); - } - } - - static PauseHandler(): void { - this.playerManager.pause(); - } - - static SetRepeatModeHandler(data: DataMessage): void { - window.repeatMode = (data.options).RepeatMode; - window.reportEventType = 'repeatmodechange'; - } - - static UnpauseHandler(): void { - this.playerManager.play(); - } - - // We should avoid using a defaulthandler that has a purpose other than informing the dev/user - // Currently all unhandled commands will be treated as play commands. - static defaultHandler(data: DataMessage): void { - translateItems(data, data.options, 'play'); - } - - static processMessage(data: DataMessage, command: string): void { - const commandHandler = this.supportedCommands[command]; - if (typeof commandHandler === 'function') { - console.debug( - `Command "${command}" received. Identified handler, calling identified handler.` - ); - commandHandler.bind(this)(data); - } else { - console.log( - `Command "${command}" received. Could not identify handler, calling default handler.` - ); - this.defaultHandler(data); - } - } + private static playerManager: framework.PlayerManager; + private static playbackManager: playbackManager; + private static supportedCommands: SupportedCommands = { + PlayNext: CommandHandler.playNextHandler, + PlayNow: CommandHandler.playNowHandler, + PlayLast: CommandHandler.playLastHandler, + Shuffle: CommandHandler.shuffleHandler, + InstantMix: CommandHandler.instantMixHandler, + DisplayContent: CommandHandler.displayContentHandler, + NextTrack: CommandHandler.nextTrackHandler, + PreviousTrack: CommandHandler.previousTrackHandler, + SetAudioStreamIndex: CommandHandler.setAudioStreamIndexHandler, + SetSubtitleStreamIndex: CommandHandler.setSubtitleStreamIndexHandler, + VolumeUp: CommandHandler.VolumeUpHandler, + VolumeDown: CommandHandler.VolumeDownHandler, + ToggleMute: CommandHandler.ToggleMuteHandler, + Identify: CommandHandler.IdentifyHandler, + SetVolume: CommandHandler.SetVolumeHandler, + Seek: CommandHandler.SeekHandler, + Mute: CommandHandler.MuteHandler, + Unmute: CommandHandler.MuteHandler, + Stop: CommandHandler.StopHandler, + PlayPause: CommandHandler.PlayPauseHandler, + Pause: CommandHandler.PauseHandler, + SetRepeatMode: CommandHandler.SetRepeatModeHandler, + Unpause: CommandHandler.UnpauseHandler + }; + + static configure( + playerManager: framework.PlayerManager, + playbackManager: playbackManager + ): void { + this.playerManager = playerManager; + this.playbackManager = playbackManager; + } + + static playNextHandler(data: DataMessage): void { + translateItems(data, data.options, data.command); + } + + static playNowHandler(data: DataMessage): void { + translateItems(data, data.options, data.command); + } + + static playLastHandler(data: DataMessage): void { + translateItems(data, data.options, data.command); + } + + static shuffleHandler(data: DataMessage): void { + shuffle( + data, + data.options, + (data.options).items[0] + ); + } + + static instantMixHandler(data: DataMessage): void { + instantMix( + data, + data.options, + (data.options).items[0] + ); + } + + static displayContentHandler(data: DataMessage): void { + if (!this.playbackManager.isPlaying()) { + DocumentManager.showItemId((data.options).ItemId); + } + } + + static nextTrackHandler(): void { + if ( + window.playlist && + window.currentPlaylistIndex < window.playlist.length - 1 + ) { + this.playbackManager.playNextItem({}, true); + } + } + + static previousTrackHandler(): void { + if (window.playlist && window.currentPlaylistIndex > 0) { + this.playbackManager.playPreviousItem({}); + } + } + + static setAudioStreamIndexHandler(data: DataMessage): void { + setAudioStreamIndex($scope, (data.options).index); + } + + static setSubtitleStreamIndexHandler(data: DataMessage): void { + setSubtitleStreamIndex($scope, (data.options).index); + } + + // VolumeUp, VolumeDown and ToggleMute commands seem to be handled on the sender in the current implementation. + // From what I can tell there's no convenient way for the receiver to get its own volume. + // We should probably remove these commands in the future. + static VolumeUpHandler(): void { + console.log('VolumeUp handler not implemented'); + } + + static VolumeDownHandler(): void { + console.log('VolumeDown handler not implemented'); + } + + static ToggleMuteHandler(): void { + console.log('ToggleMute handler not implemented'); + } + + static SetVolumeHandler(): void { + // This is now implemented on the sender + console.log('SetVolume handler not implemented'); + } + + static IdentifyHandler(): void { + if (!this.playbackManager.isPlaying()) { + DocumentManager.startBackdropInterval(); + } else { + // When a client connects send back the initial device state (volume etc) via a playbackstop message + reportPlaybackProgress( + $scope, + getReportingParams($scope), + true, + 'playbackstop' + ); + } + } + + static SeekHandler(data: DataMessage): void { + seek((data.options).position * 10000000); + } + + static MuteHandler(): void { + // CommandHandler is now implemented on the sender + console.log('Mute handler not implemented'); + } + + static UnmuteHandler(): void { + // CommandHandler is now implemented on the sender + console.log('Unmute handler not implemented'); + } + + static StopHandler(): void { + this.playerManager.stop(); + } + + static PlayPauseHandler(): void { + if ( + this.playerManager.getPlayerState() === + cast.framework.messages.PlayerState.PAUSED + ) { + this.playerManager.play(); + } else { + this.playerManager.pause(); + } + } + + static PauseHandler(): void { + this.playerManager.pause(); + } + + static SetRepeatModeHandler(data: DataMessage): void { + window.repeatMode = (data.options).RepeatMode; + window.reportEventType = 'repeatmodechange'; + } + + static UnpauseHandler(): void { + this.playerManager.play(); + } + + // We should avoid using a defaulthandler that has a purpose other than informing the dev/user + // Currently all unhandled commands will be treated as play commands. + static defaultHandler(data: DataMessage): void { + translateItems(data, data.options, 'play'); + } + + static processMessage(data: DataMessage, command: string): void { + const commandHandler = this.supportedCommands[command]; + + if (typeof commandHandler === 'function') { + console.debug( + `Command "${command}" received. Identified handler, calling identified handler.` + ); + commandHandler.bind(this)(data); + } else { + console.log( + `Command "${command}" received. Could not identify handler, calling default handler.` + ); + this.defaultHandler(data); + } + } } diff --git a/src/components/deviceprofileBuilder.ts b/src/components/deviceprofileBuilder.ts index 4b1087c1..2ced0f48 100644 --- a/src/components/deviceprofileBuilder.ts +++ b/src/components/deviceprofileBuilder.ts @@ -16,375 +16,373 @@ import { ProfileConditionValue } from '../api/generated/models/profile-condition import { deviceIds, getActiveDeviceId } from './castDevices'; import { - hasSurroundSupport, - hasTextTrackSupport, - hasVP8Support, - hasVP9Support, - getMaxWidthSupport, - getH26xProfileSupport, - getH26xLevelSupport, - getSupportedVPXVideoCodecs, - getSupportedMP4VideoCodecs, - getSupportedMP4AudioCodecs, - getSupportedHLSVideoCodecs, - getSupportedHLSAudioCodecs, - getSupportedWebMAudioCodecs, - getSupportedAudioCodecs + hasSurroundSupport, + hasTextTrackSupport, + hasVP8Support, + hasVP9Support, + getMaxWidthSupport, + getH26xProfileSupport, + getH26xLevelSupport, + getSupportedVPXVideoCodecs, + getSupportedMP4VideoCodecs, + getSupportedMP4AudioCodecs, + getSupportedHLSVideoCodecs, + getSupportedHLSAudioCodecs, + getSupportedWebMAudioCodecs, + getSupportedAudioCodecs } from './codecSupportHelper'; interface ProfileOptions { - enableHls: boolean; - bitrateSetting: number; + enableHls: boolean; + bitrateSetting: number; } let profileOptions: ProfileOptions; let currentDeviceId: number; /** - * @param {ProfileConditionValue} Property What property the condition should test. - * @param {ProfileConditionType} Condition The condition to test the values for. - * @param {string} Value The value to compare against. - * @param {boolean} [IsRequired=false] Don't permit unknown values - * @returns {ProfileCondition} A profile condition created from the parameters. + * @param Property - What property the condition should test. + * @param Condition - The condition to test the values for. + * @param Value - The value to compare against. + * @param [IsRequired=false] - Don't permit unknown values + * @returns A profile condition created from the parameters. */ function createProfileCondition( - Property: ProfileConditionValue, - Condition: ProfileConditionType, - Value: string, - IsRequired = false + Property: ProfileConditionValue, + Condition: ProfileConditionType, + Value: string, + IsRequired = false ): ProfileCondition { - return { - Condition, - Property, - Value, - IsRequired - }; + return { + Condition, + Property, + Value, + IsRequired + }; } /** * @returns Container profiles. */ function getContainerProfiles(): Array { - return []; + return []; } /** * @returns Response profiles. */ function getResponseProfiles(): Array { - // This seems related to DLNA, it might not be needed? - return [ - { - Type: DlnaProfileType.Video, - Container: 'm4v', - MimeType: 'video/mp4' - } - ]; + // This seems related to DLNA, it might not be needed? + return [ + { + Type: DlnaProfileType.Video, + Container: 'm4v', + MimeType: 'video/mp4' + } + ]; } /** * @returns Direct play profiles. */ function getDirectPlayProfiles(): Array { - const DirectPlayProfiles: Array = []; - - if (currentDeviceId !== deviceIds.AUDIO) { - const mp4VideoCodecs = getSupportedMP4VideoCodecs(); - const mp4AudioCodecs = getSupportedMP4AudioCodecs(); - const vpxVideoCodecs = getSupportedVPXVideoCodecs(); - const webmAudioCodecs = getSupportedWebMAudioCodecs(); - - for (const codec of vpxVideoCodecs) { - DirectPlayProfiles.push({ - Container: 'webm', - Type: DlnaProfileType.Video, - AudioCodec: webmAudioCodecs.join(','), - VideoCodec: codec - }); - } - - DirectPlayProfiles.push({ - Container: 'mp4,m4v', - Type: DlnaProfileType.Video, - VideoCodec: mp4VideoCodecs.join(','), - AudioCodec: mp4AudioCodecs.join(',') - }); + const DirectPlayProfiles: Array = []; + + if (currentDeviceId !== deviceIds.AUDIO) { + const mp4VideoCodecs = getSupportedMP4VideoCodecs(); + const mp4AudioCodecs = getSupportedMP4AudioCodecs(); + const vpxVideoCodecs = getSupportedVPXVideoCodecs(); + const webmAudioCodecs = getSupportedWebMAudioCodecs(); + + for (const codec of vpxVideoCodecs) { + DirectPlayProfiles.push({ + Container: 'webm', + Type: DlnaProfileType.Video, + AudioCodec: webmAudioCodecs.join(','), + VideoCodec: codec + }); + } + + DirectPlayProfiles.push({ + Container: 'mp4,m4v', + Type: DlnaProfileType.Video, + VideoCodec: mp4VideoCodecs.join(','), + AudioCodec: mp4AudioCodecs.join(',') + }); + } + + const supportedAudio = getSupportedAudioCodecs(); + + for (const audioFormat of supportedAudio) { + if (audioFormat === 'mp3') { + DirectPlayProfiles.push({ + Container: audioFormat, + Type: DlnaProfileType.Audio, + AudioCodec: audioFormat + }); + } else if (audioFormat === 'webma') { + DirectPlayProfiles.push({ + Container: 'webma,webm', + Type: DlnaProfileType.Audio + }); + } else { + DirectPlayProfiles.push({ + Container: audioFormat, + Type: DlnaProfileType.Audio + }); } - const supportedAudio = getSupportedAudioCodecs(); - - for (const audioFormat of supportedAudio) { - if (audioFormat === 'mp3') { - DirectPlayProfiles.push({ - Container: audioFormat, - Type: DlnaProfileType.Audio, - AudioCodec: audioFormat - }); - } else if (audioFormat === 'webma') { - DirectPlayProfiles.push({ - Container: 'webma,webm', - Type: DlnaProfileType.Audio - }); - } else { - DirectPlayProfiles.push({ - Container: audioFormat, - Type: DlnaProfileType.Audio - }); - } - - // aac also appears in the m4a and m4b container - if (audioFormat === 'aac') { - DirectPlayProfiles.push({ - Container: 'm4a,m4b', - AudioCodec: audioFormat, - Type: DlnaProfileType.Audio - }); - } + // aac also appears in the m4a and m4b container + if (audioFormat === 'aac') { + DirectPlayProfiles.push({ + Container: 'm4a,m4b', + AudioCodec: audioFormat, + Type: DlnaProfileType.Audio + }); } + } - return DirectPlayProfiles; + return DirectPlayProfiles; } /** * @returns Codec profiles. */ function getCodecProfiles(): Array { - const CodecProfiles: Array = []; - - const audioConditions: CodecProfile = { - Type: CodecType.Audio, - Codec: 'flac', - Conditions: [ - createProfileCondition( - ProfileConditionValue.AudioSampleRate, - ProfileConditionType.LessThanEqual, - '96000' - ), - createProfileCondition( - ProfileConditionValue.AudioBitDepth, - ProfileConditionType.LessThanEqual, - '24' - ) - ] - }; - - CodecProfiles.push(audioConditions); - - // If device is audio only, don't add all the video related stuff - if (currentDeviceId == deviceIds.AUDIO) { - return CodecProfiles; - } - - const aacConditions: CodecProfile = { - Type: CodecType.VideoAudio, - Codec: 'aac', - Conditions: [ - // Not sure what secondary audio means in this context. Multiple audio tracks? - createProfileCondition( - ProfileConditionValue.IsSecondaryAudio, - ProfileConditionType.Equals, - 'false' - ), - createProfileCondition( - ProfileConditionValue.IsSecondaryAudio, - ProfileConditionType.LessThanEqual, - '2' - ) - ] - }; - - CodecProfiles.push(aacConditions); - - const maxWidth: number = getMaxWidthSupport(currentDeviceId); - const h26xLevel: number = getH26xLevelSupport(currentDeviceId); - const h26xProfile: string = getH26xProfileSupport(currentDeviceId); - - const h26xConditions: CodecProfile = { - Type: CodecType.Video, - Codec: 'h264', - Conditions: [ - createProfileCondition( - ProfileConditionValue.IsAnamorphic, - ProfileConditionType.NotEquals, - 'true' - ), - createProfileCondition( - ProfileConditionValue.VideoProfile, - ProfileConditionType.EqualsAny, - h26xProfile - ), - createProfileCondition( - ProfileConditionValue.VideoLevel, - ProfileConditionType.LessThanEqual, - h26xLevel.toString() - ), - createProfileCondition( - ProfileConditionValue.Width, - ProfileConditionType.LessThanEqual, - maxWidth.toString(), - true - ) - ] - }; - - CodecProfiles.push(h26xConditions); - - const videoConditions: CodecProfile = { - Type: CodecType.Video, - Conditions: [ - createProfileCondition( - ProfileConditionValue.Width, - ProfileConditionType.LessThanEqual, - maxWidth.toString(), - true - ) - ] - }; - - CodecProfiles.push(videoConditions); - - const videoAudioConditions: CodecProfile = { - Type: CodecType.VideoAudio, - Conditions: [ - createProfileCondition( - ProfileConditionValue.IsSecondaryAudio, - ProfileConditionType.Equals, - 'false' - ) - ] - }; - - CodecProfiles.push(videoAudioConditions); - + const CodecProfiles: Array = []; + + const audioConditions: CodecProfile = { + Type: CodecType.Audio, + Codec: 'flac', + Conditions: [ + createProfileCondition( + ProfileConditionValue.AudioSampleRate, + ProfileConditionType.LessThanEqual, + '96000' + ), + createProfileCondition( + ProfileConditionValue.AudioBitDepth, + ProfileConditionType.LessThanEqual, + '24' + ) + ] + }; + + CodecProfiles.push(audioConditions); + + // If device is audio only, don't add all the video related stuff + if (currentDeviceId == deviceIds.AUDIO) { return CodecProfiles; + } + + const aacConditions: CodecProfile = { + Type: CodecType.VideoAudio, + Codec: 'aac', + Conditions: [ + // Not sure what secondary audio means in this context. Multiple audio tracks? + createProfileCondition( + ProfileConditionValue.IsSecondaryAudio, + ProfileConditionType.Equals, + 'false' + ), + createProfileCondition( + ProfileConditionValue.IsSecondaryAudio, + ProfileConditionType.LessThanEqual, + '2' + ) + ] + }; + + CodecProfiles.push(aacConditions); + + const maxWidth: number = getMaxWidthSupport(currentDeviceId); + const h26xLevel: number = getH26xLevelSupport(currentDeviceId); + const h26xProfile: string = getH26xProfileSupport(currentDeviceId); + + const h26xConditions: CodecProfile = { + Type: CodecType.Video, + Codec: 'h264', + Conditions: [ + createProfileCondition( + ProfileConditionValue.IsAnamorphic, + ProfileConditionType.NotEquals, + 'true' + ), + createProfileCondition( + ProfileConditionValue.VideoProfile, + ProfileConditionType.EqualsAny, + h26xProfile + ), + createProfileCondition( + ProfileConditionValue.VideoLevel, + ProfileConditionType.LessThanEqual, + h26xLevel.toString() + ), + createProfileCondition( + ProfileConditionValue.Width, + ProfileConditionType.LessThanEqual, + maxWidth.toString(), + true + ) + ] + }; + + CodecProfiles.push(h26xConditions); + + const videoConditions: CodecProfile = { + Type: CodecType.Video, + Conditions: [ + createProfileCondition( + ProfileConditionValue.Width, + ProfileConditionType.LessThanEqual, + maxWidth.toString(), + true + ) + ] + }; + + CodecProfiles.push(videoConditions); + + const videoAudioConditions: CodecProfile = { + Type: CodecType.VideoAudio, + Conditions: [ + createProfileCondition( + ProfileConditionValue.IsSecondaryAudio, + ProfileConditionType.Equals, + 'false' + ) + ] + }; + + CodecProfiles.push(videoAudioConditions); + + return CodecProfiles; } /** * @returns Transcoding profiles. */ function getTranscodingProfiles(): Array { - const TranscodingProfiles: Array = []; - - const hlsAudioCodecs = getSupportedHLSAudioCodecs(); - const audioChannels: number = hasSurroundSupport() ? 6 : 2; - - if (profileOptions.enableHls !== false) { - TranscodingProfiles.push({ - Container: 'ts', - Type: DlnaProfileType.Audio, - AudioCodec: hlsAudioCodecs.join(','), - Context: EncodingContext.Streaming, - Protocol: 'hls', - MaxAudioChannels: audioChannels.toString(), - MinSegments: 1, - BreakOnNonKeyFrames: false - }); - } - - const supportedAudio = getSupportedAudioCodecs(); - - // audio only profiles here - for (const audioFormat of supportedAudio) { - TranscodingProfiles.push({ - Container: audioFormat, - Type: DlnaProfileType.Audio, - AudioCodec: audioFormat, - Context: EncodingContext.Streaming, - Protocol: 'http', - MaxAudioChannels: audioChannels.toString() - }); - } - - // If device is audio only, don't add all the video related stuff - if (currentDeviceId == deviceIds.AUDIO) { - return TranscodingProfiles; - } - - const hlsVideoCodecs = getSupportedHLSVideoCodecs(); - if ( - hlsVideoCodecs.length && - hlsAudioCodecs.length && - profileOptions.enableHls !== false - ) { - TranscodingProfiles.push({ - Container: 'ts', - Type: DlnaProfileType.Video, - AudioCodec: hlsAudioCodecs.join(','), - VideoCodec: hlsVideoCodecs.join(','), - Context: EncodingContext.Streaming, - Protocol: 'hls', - MaxAudioChannels: audioChannels.toString(), - MinSegments: 1, - BreakOnNonKeyFrames: false - }); - } - - if (hasVP8Support() || hasVP9Support()) { - TranscodingProfiles.push({ - Container: 'webm', - Type: DlnaProfileType.Video, - AudioCodec: 'vorbis', - VideoCodec: 'vpx', - Context: EncodingContext.Streaming, - Protocol: 'http', - // If audio transcoding is needed, limit channels to number of physical audio channels - // Trying to transcode to 5 channels when there are only 2 speakers generally does not sound good - MaxAudioChannels: audioChannels.toString() - }); - } - + const TranscodingProfiles: Array = []; + + const hlsAudioCodecs = getSupportedHLSAudioCodecs(); + const audioChannels: number = hasSurroundSupport() ? 6 : 2; + + if (profileOptions.enableHls !== false) { + TranscodingProfiles.push({ + Container: 'ts', + Type: DlnaProfileType.Audio, + AudioCodec: hlsAudioCodecs.join(','), + Context: EncodingContext.Streaming, + Protocol: 'hls', + MaxAudioChannels: audioChannels.toString(), + MinSegments: 1, + BreakOnNonKeyFrames: false + }); + } + + const supportedAudio = getSupportedAudioCodecs(); + + // audio only profiles here + for (const audioFormat of supportedAudio) { + TranscodingProfiles.push({ + Container: audioFormat, + Type: DlnaProfileType.Audio, + AudioCodec: audioFormat, + Context: EncodingContext.Streaming, + Protocol: 'http', + MaxAudioChannels: audioChannels.toString() + }); + } + + // If device is audio only, don't add all the video related stuff + if (currentDeviceId == deviceIds.AUDIO) { return TranscodingProfiles; + } + + const hlsVideoCodecs = getSupportedHLSVideoCodecs(); + + if ( + hlsVideoCodecs.length && + hlsAudioCodecs.length && + profileOptions.enableHls !== false + ) { + TranscodingProfiles.push({ + Container: 'ts', + Type: DlnaProfileType.Video, + AudioCodec: hlsAudioCodecs.join(','), + VideoCodec: hlsVideoCodecs.join(','), + Context: EncodingContext.Streaming, + Protocol: 'hls', + MaxAudioChannels: audioChannels.toString(), + MinSegments: 1, + BreakOnNonKeyFrames: false + }); + } + + if (hasVP8Support() || hasVP9Support()) { + TranscodingProfiles.push({ + Container: 'webm', + Type: DlnaProfileType.Video, + AudioCodec: 'vorbis', + VideoCodec: 'vpx', + Context: EncodingContext.Streaming, + Protocol: 'http', + // If audio transcoding is needed, limit channels to number of physical audio channels + // Trying to transcode to 5 channels when there are only 2 speakers generally does not sound good + MaxAudioChannels: audioChannels.toString() + }); + } + + return TranscodingProfiles; } /** * @returns Subtitle profiles. */ function getSubtitleProfiles(): Array { - const subProfiles: Array = []; - - if (hasTextTrackSupport(currentDeviceId)) { - subProfiles.push({ - Format: 'vtt', - Method: SubtitleDeliveryMethod.External - }); - - subProfiles.push({ - Format: 'vtt', - Method: SubtitleDeliveryMethod.Hls - }); - } + const subProfiles: Array = []; + + if (hasTextTrackSupport(currentDeviceId)) { + subProfiles.push({ + Format: 'vtt', + Method: SubtitleDeliveryMethod.External + }); + + subProfiles.push({ + Format: 'vtt', + Method: SubtitleDeliveryMethod.Hls + }); + } - return subProfiles; + return subProfiles; } /** * Creates a device profile containing supported codecs for the active Cast device. * - * @param options Profile options + * @param options - Profile options * @returns Device profile. */ export function getDeviceProfile(options: ProfileOptions): DeviceProfile { - profileOptions = options; - currentDeviceId = getActiveDeviceId(); - - // MaxStaticBitrate seems to be for offline sync only - const profile: DeviceProfile = { - MaxStreamingBitrate: options.bitrateSetting, - MaxStaticBitrate: options.bitrateSetting, - MusicStreamingTranscodingBitrate: Math.min( - options.bitrateSetting, - 192000 - ) - }; - - profile.DirectPlayProfiles = getDirectPlayProfiles(); - profile.TranscodingProfiles = getTranscodingProfiles(); - profile.ContainerProfiles = getContainerProfiles(); - profile.CodecProfiles = getCodecProfiles(); - profile.SubtitleProfiles = getSubtitleProfiles(); - profile.ResponseProfiles = getResponseProfiles(); - - return profile; + profileOptions = options; + currentDeviceId = getActiveDeviceId(); + + // MaxStaticBitrate seems to be for offline sync only + const profile: DeviceProfile = { + MaxStreamingBitrate: options.bitrateSetting, + MaxStaticBitrate: options.bitrateSetting, + MusicStreamingTranscodingBitrate: Math.min(options.bitrateSetting, 192000) + }; + + profile.DirectPlayProfiles = getDirectPlayProfiles(); + profile.TranscodingProfiles = getTranscodingProfiles(); + profile.ContainerProfiles = getContainerProfiles(); + profile.CodecProfiles = getCodecProfiles(); + profile.SubtitleProfiles = getSubtitleProfiles(); + profile.ResponseProfiles = getResponseProfiles(); + + return profile; } export default getDeviceProfile; diff --git a/src/components/documentManager.ts b/src/components/documentManager.ts index b0476096..3f361e35 100644 --- a/src/components/documentManager.ts +++ b/src/components/documentManager.ts @@ -4,775 +4,781 @@ import { deviceIds, getActiveDeviceId } from './castDevices'; import { BaseItemDto } from '~/api/generated/models/base-item-dto'; export abstract class DocumentManager { - // Duration between each backdrop switch in ms - private static backdropPeriodMs: number | null = 30000; - // Timer state - so that we don't start the interval more than necessary - private static backdropTimer = 0; - - // TODO make enum - private static status = ''; - - /** - * Hide the document body on chromecast audio to save resources - */ - public static initialize(): void { - if (getActiveDeviceId() === deviceIds.AUDIO) - document.body.style.display = 'none'; - } - - /** - * Set the background image for a html element, without preload. - * You should do the preloading first with preloadImage. - * - * @param {HTMLElement} element HTML Element - * @param {string | null} src URL to the image or null to remove the active one - */ - private static setBackgroundImage( - element: HTMLElement, - src: string | null - ): void { - if (src) { - element.style.backgroundImage = `url(${src})`; - } else { - element.style.backgroundImage = ''; - } + // Duration between each backdrop switch in ms + private static backdropPeriodMs: number | null = 30000; + // Timer state - so that we don't start the interval more than necessary + private static backdropTimer = 0; + + // TODO make enum + private static status = ''; + + /** + * Hide the document body on chromecast audio to save resources + */ + public static initialize(): void { + if (getActiveDeviceId() === deviceIds.AUDIO) { + document.body.style.display = 'none'; } - - /** - * Preload an image - * - * @param {string | null} src URL to the image or null - * @returns {Promise} wait for the preload and return the url to use. Might be nulled after loading error. - */ - private static preloadImage(src: string | null): Promise { - if (src) { - return new Promise((resolve, reject) => { - const preload = new Image(); - preload.src = src; - preload.addEventListener('load', () => { - resolve(src); - }); - preload.addEventListener('error', () => { - // might also resolve and return null here, to have the caller take away the background. - reject(); - }); - }); - } else { - return Promise.resolve(null); - } + } + + /** + * Set the background image for a html element, without preload. + * You should do the preloading first with preloadImage. + * + * @param element - HTML Element + * @param src - URL to the image or null to remove the active one + */ + private static setBackgroundImage( + element: HTMLElement, + src: string | null + ): void { + if (src) { + element.style.backgroundImage = `url(${src})`; + } else { + element.style.backgroundImage = ''; } - - /** - * Get url for primary image for a given item - * - * @param {BaseItemDto} item to look up - * @returns {Promise} url to image after preload - */ - private static getPrimaryImageUrl( - item: BaseItemDto - ): Promise { - let src: string | null = null; - - if (item.AlbumPrimaryImageTag && item.AlbumId) { - src = JellyfinApi.createImageUrl( - item.AlbumId, - 'Primary', - item.AlbumPrimaryImageTag - ); - } else if (item.ImageTags?.Primary && item.Id) { - src = JellyfinApi.createImageUrl( - item.Id, - 'Primary', - item.ImageTags.Primary - ); - } - - if ( - item?.UserData?.PlayedPercentage && - item?.UserData?.PlayedPercentage < 100 && - !item.IsFolder && - src != null - ) { - src += `&PercentPlayed=${item.UserData.PlayedPercentage}`; - } - - return this.preloadImage(src); - } - - /** - * Get url for logo image for a given item - * - * @param {BaseItemDto} item to look up - * @returns {Promise} url to logo image after preload - */ - private static getLogoUrl(item: BaseItemDto): Promise { - let src: string | null = null; - if (item.ImageTags?.Logo && item.Id) { - src = JellyfinApi.createImageUrl( - item.Id, - 'Logo', - item.ImageTags.Logo - ); - } else if (item.ParentLogoItemId && item.ParentLogoImageTag) { - src = JellyfinApi.createImageUrl( - item.ParentLogoItemId, - 'Logo', - item.ParentLogoImageTag - ); - } - - return this.preloadImage(src); - } - - /** - * This fucntion takes an item and shows details about it - * on the details page. This happens when no media is playing, - * and the connected client is browsing the library. - * - * @param {BaseItemDto} item to show information about - * @returns {Promise} for the page to load - */ - public static showItem(item: BaseItemDto): Promise { - // no showItem for cc audio - if (getActiveDeviceId() === deviceIds.AUDIO) { - return Promise.resolve(); - } - - // stop cycling backdrops - this.clearBackdropInterval(); - - return Promise.all([ - this.getWaitingBackdropUrl(item), - this.getPrimaryImageUrl(item), - this.getLogoUrl(item) - ]).then((urls) => { - requestAnimationFrame(() => { - this.setWaitingBackdrop(urls[0], item); - this.setDetailImage(urls[1]); - this.setLogo(urls[2]); - - this.setOverview(item.Overview ?? null); - this.setGenres(item?.Genres?.join(' / ') ?? null); - this.setDisplayName(item); - this.setMiscInfo(item); - - this.setRating(item); - - if (item?.UserData?.Played) { - this.setPlayedIndicator(true); - } else if (item?.UserData?.UnplayedItemCount) { - this.setPlayedIndicator(item?.UserData?.UnplayedItemCount); - } else { - this.setPlayedIndicator(false); - } - - if ( - item?.UserData?.PlayedPercentage && - item?.UserData?.PlayedPercentage < 100 && - !item.IsFolder - ) { - this.setHasPlayedPercentage(false); - this.setPlayedPercentage(item.UserData.PlayedPercentage); - } else { - this.setHasPlayedPercentage(false); - this.setPlayedPercentage(0); - } - - // Switch visible view! - this.setAppStatus('details'); - }); + } + + /** + * Preload an image + * + * @param src - URL to the image or null + * @returns wait for the preload and return the url to use. Might be nulled after loading error. + */ + private static preloadImage(src: string | null): Promise { + if (src) { + return new Promise((resolve, reject) => { + const preload = new Image(); + + preload.src = src; + preload.addEventListener('load', () => { + resolve(src); }); + preload.addEventListener('error', () => { + // might also resolve and return null here, to have the caller take away the background. + reject(); + }); + }); + } else { + return Promise.resolve(null); } - - /** - * Set value of played indicator - * - * @param {boolean | number} value True = played, false = not visible, number = number of unplayed items - */ - private static setPlayedIndicator(value: boolean | number): void { - const playedIndicatorOk = this.getElementById('played-indicator-ok'); - const playedIndicatorValue = this.getElementById( - 'played-indicator-value' - ); - - if (value === true) { - // All items played - this.setVisibility(playedIndicatorValue, false); - this.setVisibility(playedIndicatorOk, true); - } else if (value === false) { - // No indicator - this.setVisibility(playedIndicatorValue, false); - this.setVisibility(playedIndicatorOk, false); - } else { - // number - playedIndicatorValue.innerHTML = value.toString(); - this.setVisibility(playedIndicatorValue, true); - this.setVisibility(playedIndicatorOk, false); - } + } + + /** + * Get url for primary image for a given item + * + * @param item - to look up + * @returns url to image after preload + */ + private static getPrimaryImageUrl(item: BaseItemDto): Promise { + let src: string | null = null; + + if (item.AlbumPrimaryImageTag && item.AlbumId) { + src = JellyfinApi.createImageUrl( + item.AlbumId, + 'Primary', + item.AlbumPrimaryImageTag + ); + } else if (item.ImageTags?.Primary && item.Id) { + src = JellyfinApi.createImageUrl( + item.Id, + 'Primary', + item.ImageTags.Primary + ); } - /** - * Show item, but from just the id number, not an actual item. - * Looks up the item and then calls showItem - * - * @param {string} itemId id of item to look up - * @returns {Promise} promise that resolves when the item is shown - */ - public static async showItemId(itemId: string): Promise { - // no showItemId for cc audio - if (getActiveDeviceId() === deviceIds.AUDIO) return; - - const item: BaseItemDto = await JellyfinApi.authAjaxUser( - 'Items/' + itemId, - { - dataType: 'json', - type: 'GET' - } - ); + if ( + item?.UserData?.PlayedPercentage && + item?.UserData?.PlayedPercentage < 100 && + !item.IsFolder && + src != null + ) { + src += `&PercentPlayed=${item.UserData.PlayedPercentage}`; + } - DocumentManager.showItem(item); + return this.preloadImage(src); + } + + /** + * Get url for logo image for a given item + * + * @param item - to look up + * @returns url to logo image after preload + */ + private static getLogoUrl(item: BaseItemDto): Promise { + let src: string | null = null; + + if (item.ImageTags?.Logo && item.Id) { + src = JellyfinApi.createImageUrl(item.Id, 'Logo', item.ImageTags.Logo); + } else if (item.ParentLogoItemId && item.ParentLogoImageTag) { + src = JellyfinApi.createImageUrl( + item.ParentLogoItemId, + 'Logo', + item.ParentLogoImageTag + ); } - /** - * Update item rating elements - * - * @param {BaseItemDto} item to look up - */ - private static setRating(item: BaseItemDto): void { - const starRating = this.getElementById('star-rating'); - const starRatingValue = this.getElementById('star-rating-value'); + return this.preloadImage(src); + } + + /** + * This fucntion takes an item and shows details about it + * on the details page. This happens when no media is playing, + * and the connected client is browsing the library. + * + * @param item - to show information about + * @returns for the page to load + */ + public static showItem(item: BaseItemDto): Promise { + // no showItem for cc audio + if (getActiveDeviceId() === deviceIds.AUDIO) { + return Promise.resolve(); + } - if (item.CommunityRating != null) { - starRatingValue.innerHTML = item.CommunityRating.toFixed(1); - this.setVisibility(starRating, true); - this.setVisibility(starRatingValue, true); + // stop cycling backdrops + this.clearBackdropInterval(); + + return Promise.all([ + this.getWaitingBackdropUrl(item), + this.getPrimaryImageUrl(item), + this.getLogoUrl(item) + ]).then((urls) => { + requestAnimationFrame(() => { + this.setWaitingBackdrop(urls[0], item); + this.setDetailImage(urls[1]); + this.setLogo(urls[2]); + + this.setOverview(item.Overview ?? null); + this.setGenres(item?.Genres?.join(' / ') ?? null); + this.setDisplayName(item); + this.setMiscInfo(item); + + this.setRating(item); + + if (item?.UserData?.Played) { + this.setPlayedIndicator(true); + } else if (item?.UserData?.UnplayedItemCount) { + this.setPlayedIndicator(item?.UserData?.UnplayedItemCount); } else { - this.setVisibility(starRating, false); - this.setVisibility(starRatingValue, false); + this.setPlayedIndicator(false); } - const criticRating = this.getElementById('critic-rating'); - const criticRatingValue = this.getElementById('critic-rating-value'); - - if (item.CriticRating != null) { - const verdict = item.CriticRating >= 60 ? 'fresh' : 'rotten'; - - criticRating.classList.add(verdict); - criticRating.classList.remove( - verdict == 'fresh' ? 'rotten' : 'fresh' - ); - - criticRatingValue.innerHTML = item.CriticRating.toString(); - - this.setVisibility(criticRating, true); - this.setVisibility(criticRatingValue, true); + if ( + item?.UserData?.PlayedPercentage && + item?.UserData?.PlayedPercentage < 100 && + !item.IsFolder + ) { + this.setHasPlayedPercentage(false); + this.setPlayedPercentage(item.UserData.PlayedPercentage); } else { - this.setVisibility(criticRating, false); - this.setVisibility(criticRatingValue, false); + this.setHasPlayedPercentage(false); + this.setPlayedPercentage(0); } + + // Switch visible view! + this.setAppStatus('details'); + }); + }); + } + + /** + * Set value of played indicator + * + * @param value - True = played, false = not visible, number = number of unplayed items + */ + private static setPlayedIndicator(value: boolean | number): void { + const playedIndicatorOk = this.getElementById('played-indicator-ok'); + const playedIndicatorValue = this.getElementById('played-indicator-value'); + + if (value === true) { + // All items played + this.setVisibility(playedIndicatorValue, false); + this.setVisibility(playedIndicatorOk, true); + } else if (value === false) { + // No indicator + this.setVisibility(playedIndicatorValue, false); + this.setVisibility(playedIndicatorOk, false); + } else { + // number + playedIndicatorValue.innerHTML = value.toString(); + this.setVisibility(playedIndicatorValue, true); + this.setVisibility(playedIndicatorOk, false); + } + } + + /** + * Show item, but from just the id number, not an actual item. + * Looks up the item and then calls showItem + * + * @param itemId - id of item to look up + * @returns promise that resolves when the item is shown + */ + public static async showItemId(itemId: string): Promise { + // no showItemId for cc audio + if (getActiveDeviceId() === deviceIds.AUDIO) { + return; } - /** - * Set the status of the app, and switch the visible view - * to the corresponding one. - * - * @param {string} status to set - */ - public static setAppStatus(status: string): void { - this.status = status; - document.body.className = status; - } - - /** - * Get the status of the app - * - * @returns {string} app status - */ - public static getAppStatus(): string { - return this.status; - } - - // BACKDROP LOGIC - - /** - * Get url to the backdrop image, and return a preload promise. - * - * @param {BaseItemDto | null} item Item to use for waiting backdrop, null to remove it. - * @returns {Promise} promise for the preload to complete - */ - public static getWaitingBackdropUrl( - item: BaseItemDto | null - ): Promise { - // no backdrop as a fallback - let src: string | null = null; - - if (item != null) { - if ( - item.BackdropImageTags && - item.BackdropImageTags.length && - item.Id - ) { - // get first backdrop of image if applicable - src = JellyfinApi.createImageUrl( - item.Id, - 'Backdrop', - item.BackdropImageTags[0] - ); - } else if ( - item.ParentBackdropItemId && - item.ParentBackdropImageTags && - item.ParentBackdropImageTags.length - ) { - // otherwise get first backdrop from parent - src = JellyfinApi.createImageUrl( - item.ParentBackdropItemId, - 'Backdrop', - item.ParentBackdropImageTags[0] - ); - } - } + const item: BaseItemDto = await JellyfinApi.authAjaxUser( + `Items/${itemId}`, + { + dataType: 'json', + type: 'GET' + } + ); + + DocumentManager.showItem(item); + } + + /** + * Update item rating elements + * + * @param item - to look up + */ + private static setRating(item: BaseItemDto): void { + const starRating = this.getElementById('star-rating'); + const starRatingValue = this.getElementById('star-rating-value'); + + if (item.CommunityRating != null) { + starRatingValue.innerHTML = item.CommunityRating.toFixed(1); + this.setVisibility(starRating, true); + this.setVisibility(starRatingValue, true); + } else { + this.setVisibility(starRating, false); + this.setVisibility(starRatingValue, false); + } - return this.preloadImage(src); - } - - /** - * Backdrops are set on the waiting container. - * They are switched around every 30 seconds by default - * (governed by startBackdropInterval) - * - * @param {string | null} src Url to image - * @param {BaseItemDto | null} item Item to use for waiting backdrop, null to remove it. - */ - public static async setWaitingBackdrop( - src: string | null, - item: BaseItemDto | null - ): Promise { - let element: HTMLElement = this.querySelector( - '#waiting-container-backdrop' - ); + const criticRating = this.getElementById('critic-rating'); + const criticRatingValue = this.getElementById('critic-rating-value'); - this.setBackgroundImage(element, src); - - element = this.getElementById('waiting-description'); - element.innerHTML = item?.Name ?? ''; - } - - /** - * Set a random backdrop on the waiting container - * - * @returns {Promise} promise waiting for the backdrop to be set - */ - private static async setRandomUserBackdrop(): Promise { - const result = await JellyfinApi.authAjaxUser('Items', { - dataType: 'json', - type: 'GET', - query: { - SortBy: 'Random', - IncludeItemTypes: 'Movie,Series', - ImageTypes: 'Backdrop', - Recursive: true, - Limit: 1, - // Although we're limiting to what the user has access to, - // not everyone will want to see adult backdrops rotating on their TV. - MaxOfficialRating: 'PG-13' - } - }); + if (item.CriticRating != null) { + const verdict = item.CriticRating >= 60 ? 'fresh' : 'rotten'; - let src: string | null = null; - let item: BaseItemDto | null = null; + criticRating.classList.add(verdict); + criticRating.classList.remove(verdict == 'fresh' ? 'rotten' : 'fresh'); - if (result.Items && result.Items[0]) { - item = result.Items[0]; - src = await DocumentManager.getWaitingBackdropUrl(item); - } + criticRatingValue.innerHTML = item.CriticRating.toString(); - requestAnimationFrame(() => { - DocumentManager.setWaitingBackdrop(src, item); - }); + this.setVisibility(criticRating, true); + this.setVisibility(criticRatingValue, true); + } else { + this.setVisibility(criticRating, false); + this.setVisibility(criticRatingValue, false); + } + } + + /** + * Set the status of the app, and switch the visible view + * to the corresponding one. + * + * @param status - to set + */ + public static setAppStatus(status: string): void { + this.status = status; + document.body.className = status; + } + + /** + * Get the status of the app + * + * @returns app status + */ + public static getAppStatus(): string { + return this.status; + } + + // BACKDROP LOGIC + + /** + * Get url to the backdrop image, and return a preload promise. + * + * @param item - Item to use for waiting backdrop, null to remove it. + * @returns promise for the preload to complete + */ + public static getWaitingBackdropUrl( + item: BaseItemDto | null + ): Promise { + // no backdrop as a fallback + let src: string | null = null; + + if (item != null) { + if (item.BackdropImageTags && item.BackdropImageTags.length && item.Id) { + // get first backdrop of image if applicable + src = JellyfinApi.createImageUrl( + item.Id, + 'Backdrop', + item.BackdropImageTags[0] + ); + } else if ( + item.ParentBackdropItemId && + item.ParentBackdropImageTags && + item.ParentBackdropImageTags.length + ) { + // otherwise get first backdrop from parent + src = JellyfinApi.createImageUrl( + item.ParentBackdropItemId, + 'Backdrop', + item.ParentBackdropImageTags[0] + ); + } } - /** - * Stop the backdrop rotation - */ - public static clearBackdropInterval(): void { - if (this.backdropTimer !== 0) { - clearInterval(this.backdropTimer); - this.backdropTimer = 0; - } + return this.preloadImage(src); + } + + /** + * Backdrops are set on the waiting container. + * They are switched around every 30 seconds by default + * (governed by startBackdropInterval) + * + * @param src - Url to image + * @param item - Item to use for waiting backdrop, null to remove it. + */ + public static async setWaitingBackdrop( + src: string | null, + item: BaseItemDto | null + ): Promise { + let element: HTMLElement = this.querySelector( + '#waiting-container-backdrop' + ); + + this.setBackgroundImage(element, src); + + element = this.getElementById('waiting-description'); + element.innerHTML = item?.Name ?? ''; + } + + /** + * Set a random backdrop on the waiting container + * + * @returns promise waiting for the backdrop to be set + */ + private static async setRandomUserBackdrop(): Promise { + const result = await JellyfinApi.authAjaxUser('Items', { + dataType: 'json', + type: 'GET', + query: { + SortBy: 'Random', + IncludeItemTypes: 'Movie,Series', + ImageTypes: 'Backdrop', + Recursive: true, + Limit: 1, + // Although we're limiting to what the user has access to, + // not everyone will want to see adult backdrops rotating on their TV. + MaxOfficialRating: 'PG-13' + } + }); + + let src: string | null = null; + let item: BaseItemDto | null = null; + + if (result.Items && result.Items[0]) { + item = result.Items[0]; + src = await DocumentManager.getWaitingBackdropUrl(item); } - /** - * Start the backdrop rotation, restart if running, stop if disabled - * - * @returns {Promise} promise for the first backdrop to be set - */ - public static async startBackdropInterval(): Promise { - // no backdrop rotation for cc audio - if (getActiveDeviceId() === deviceIds.AUDIO) return; + requestAnimationFrame(() => { + DocumentManager.setWaitingBackdrop(src, item); + }); + } + + /** + * Stop the backdrop rotation + */ + public static clearBackdropInterval(): void { + if (this.backdropTimer !== 0) { + clearInterval(this.backdropTimer); + this.backdropTimer = 0; + } + } + + /** + * Start the backdrop rotation, restart if running, stop if disabled + * + * @returns promise for the first backdrop to be set + */ + public static async startBackdropInterval(): Promise { + // no backdrop rotation for cc audio + if (getActiveDeviceId() === deviceIds.AUDIO) { + return; + } - // avoid running it multiple times - this.clearBackdropInterval(); + // avoid running it multiple times + this.clearBackdropInterval(); - // skip out if it's disabled - if (!this.backdropPeriodMs) { - this.setWaitingBackdrop(null, null); - return; - } + // skip out if it's disabled + if (!this.backdropPeriodMs) { + this.setWaitingBackdrop(null, null); - this.backdropTimer = ( - setInterval( - () => DocumentManager.setRandomUserBackdrop(), - this.backdropPeriodMs - ) - ); + return; + } - await this.setRandomUserBackdrop(); + this.backdropTimer = ( + setInterval( + () => DocumentManager.setRandomUserBackdrop(), + this.backdropPeriodMs + ) + ); + + await this.setRandomUserBackdrop(); + } + + /** + * Set interval between backdrop changes, null to disable + * + * @param period - in milliseconds or null + */ + public static setBackdropPeriodMs(period: number | null): void { + if (period !== this.backdropPeriodMs) { + this.backdropPeriodMs = period; + + // If the timer was running, restart it + if (this.backdropTimer !== 0) { + // startBackdropInterval will also clear the previous one + this.startBackdropInterval(); + } + + if (period === null) { + // No backdrop is wanted, and the timer has been cleared. + // This call will remove any present backdrop. + this.setWaitingBackdrop(null, null); + } + } + } + + /** + * Set background behind the media player, + * this is shown while the media is loading. + * + * @param item - to get backdrop from + */ + public static setPlayerBackdrop(item: BaseItemDto): void { + // no backdrop rotation for cc audio + if (getActiveDeviceId() === deviceIds.AUDIO) { + return; } - /** - * Set interval between backdrop changes, null to disable - * - * @param {number | null} period in milliseconds or null - */ - public static setBackdropPeriodMs(period: number | null): void { - if (period !== this.backdropPeriodMs) { - this.backdropPeriodMs = period; + let backdropUrl: string | null = null; + + if (item.BackdropImageTags && item.BackdropImageTags.length && item.Id) { + backdropUrl = JellyfinApi.createImageUrl( + item.Id, + 'Backdrop', + item.BackdropImageTags[0] + ); + } else if ( + item.ParentBackdropItemId && + item.ParentBackdropImageTags && + item.ParentBackdropImageTags.length + ) { + backdropUrl = JellyfinApi.createImageUrl( + item.ParentBackdropItemId, + 'Backdrop', + item.ParentBackdropImageTags[0] + ); + } - // If the timer was running, restart it - if (this.backdropTimer !== 0) { - // startBackdropInterval will also clear the previous one - this.startBackdropInterval(); - } + if (backdropUrl != null) { + window.mediaElement?.style.setProperty( + '--background-image', + `url("${backdropUrl}")` + ); + } else { + window.mediaElement?.style.removeProperty('--background-image'); + } + } + /* /BACKDROP LOGIC */ + + /** + * Set the URL to the item logo, or null to remove it + * + * @param src - Source url or null + */ + public static setLogo(src: string | null): void { + const element: HTMLElement = this.querySelector('.detailLogo'); + + this.setBackgroundImage(element, src); + } + + /** + * Set the URL to the item banner image (I think?), + * or null to remove it + * + * @param src - Source url or null + */ + public static setDetailImage(src: string | null): void { + const element: HTMLElement = this.querySelector('.detailImage'); + + this.setBackgroundImage(element, src); + } + + /** + * Set the human readable name for an item + * + * This combines the old statement setDisplayName(getDisplayName(item)) + * into setDisplayName(item). + * + * @param item - source for the displayed name + */ + private static setDisplayName(item: BaseItemDto): void { + const name: string = item.EpisodeTitle ?? item.Name; + + let displayName: string = name; + + if (item.Type == 'TvChannel') { + if (item.Number) { + displayName = `${item.Number} ${name}`; + } + } else if ( + item.Type == 'Episode' && + item.IndexNumber != null && + item.ParentIndexNumber != null + ) { + let episode = `S${item.ParentIndexNumber}, E${item.IndexNumber}`; + + if (item.IndexNumberEnd) { + episode += `-${item.IndexNumberEnd}`; + } + + displayName = `${episode} - ${name}`; + } - if (period === null) { - // No backdrop is wanted, and the timer has been cleared. - // This call will remove any present backdrop. - this.setWaitingBackdrop(null, null); - } - } + const element = this.querySelector('.displayName'); + + element.innerHTML = displayName || ''; + } + + /** + * Set the html of the genres container + * + * @param name - String/html for genres box, null to empty + */ + private static setGenres(name: string | null): void { + const element = this.querySelector('.genres'); + + element.innerHTML = name || ''; + } + + /** + * Set the html of the overview container + * + * @param name - string or html to insert + */ + private static setOverview(name: string | null): void { + const element = this.querySelector('.overview'); + + element.innerHTML = name || ''; + } + + /** + * Set the progress of the progress bar in the + * item details page. (Not the same as the playback ui) + * + * @param value - Percentage to set + */ + private static setPlayedPercentage(value = 0): void { + const element = this.querySelector('.itemProgressBar'); + + element.value = value.toString(); + } + + /** + * Set the visibility of the item progress bar in the + * item details page + * + * @param value - If true, show progress on details page + */ + private static setHasPlayedPercentage(value: boolean): void { + const element = this.querySelector('.detailImageProgressContainer'); + + if (value) { + (element).classList.remove('d-none'); + } else { + (element).classList.add('d-none'); + } + } + + /** + * Get a human readable representation of the current position + * in ticks + * + * @param ticks - tick position + * @returns human readable position + */ + private static formatRunningTime(ticks: number): string { + const ticksPerHour = 36000000000; + const ticksPerMinute = 600000000; + const ticksPerSecond = 10000000; + + const parts: string[] = []; + + const hours: number = Math.floor(ticks / ticksPerHour); + + if (hours) { + parts.push(hours.toString()); } - /** - * Set background behind the media player, - * this is shown while the media is loading. - * - * @param {BaseItemDto} item to get backdrop from - */ - public static setPlayerBackdrop(item: BaseItemDto): void { - // no backdrop rotation for cc audio - if (getActiveDeviceId() === deviceIds.AUDIO) return; + ticks -= hours * ticksPerHour; - let backdropUrl: string | null = null; + const minutes: number = Math.floor(ticks / ticksPerMinute); - if ( - item.BackdropImageTags && - item.BackdropImageTags.length && - item.Id - ) { - backdropUrl = JellyfinApi.createImageUrl( - item.Id, - 'Backdrop', - item.BackdropImageTags[0] - ); - } else if ( - item.ParentBackdropItemId && - item.ParentBackdropImageTags && - item.ParentBackdropImageTags.length - ) { - backdropUrl = JellyfinApi.createImageUrl( - item.ParentBackdropItemId, - 'Backdrop', - item.ParentBackdropImageTags[0] - ); - } + ticks -= minutes * ticksPerMinute; - if (backdropUrl != null) { - window.mediaElement?.style.setProperty( - '--background-image', - `url("${backdropUrl}")` - ); - } else { - window.mediaElement?.style.removeProperty('--background-image'); - } + if (minutes < 10 && hours) { + parts.push(`0${minutes.toString()}`); + } else { + parts.push(minutes.toString()); } - /* /BACKDROP LOGIC */ - - /** - * Set the URL to the item logo, or null to remove it - * - * @param {string | null} src Source url or null - */ - public static setLogo(src: string | null): void { - const element: HTMLElement = this.querySelector('.detailLogo'); - this.setBackgroundImage(element, src); - } - - /** - * Set the URL to the item banner image (I think?), - * or null to remove it - * - * @param {string | null} src Source url or null - */ - public static setDetailImage(src: string | null): void { - const element: HTMLElement = this.querySelector('.detailImage'); - this.setBackgroundImage(element, src); - } - - /** - * Set the human readable name for an item - * - * This combines the old statement setDisplayName(getDisplayName(item)) - * into setDisplayName(item). - * - * @param {BaseItemDto} item source for the displayed name - */ - private static setDisplayName(item: BaseItemDto): void { - const name: string = item.EpisodeTitle ?? item.Name; - - let displayName: string = name; - - if (item.Type == 'TvChannel') { - if (item.Number) displayName = `${item.Number} ${name}`; - } else if ( - item.Type == 'Episode' && - item.IndexNumber != null && - item.ParentIndexNumber != null - ) { - let episode = `S${item.ParentIndexNumber}, E${item.IndexNumber}`; - if (item.IndexNumberEnd) { - episode += `-${item.IndexNumberEnd}`; - } + const seconds: number = Math.floor(ticks / ticksPerSecond); - displayName = `${episode} - ${name}`; - } + if (seconds < 10) { + parts.push(`0${seconds.toString()}`); + } else { + parts.push(seconds.toString()); + } - const element = this.querySelector('.displayName'); - element.innerHTML = displayName || ''; - } - - /** - * Set the html of the genres container - * - * @param {string | null} name String/html for genres box, null to empty - */ - private static setGenres(name: string | null): void { - const element = this.querySelector('.genres'); - element.innerHTML = name || ''; - } - - /** - * Set the html of the overview container - * - * @param {string | null} name string or html to insert - */ - private static setOverview(name: string | null): void { - const element = this.querySelector('.overview'); - element.innerHTML = name || ''; - } - - /** - * Set the progress of the progress bar in the - * item details page. (Not the same as the playback ui) - * - * @param {number} value Percentage to set - */ - private static setPlayedPercentage(value = 0): void { - const element = ( - this.querySelector('.itemProgressBar') - ); - element.value = value.toString(); - } - - /** - * Set the visibility of the item progress bar in the - * item details page - * - * @param {boolean} value If true, show progress on details page - */ - private static setHasPlayedPercentage(value: boolean): void { - const element = this.querySelector('.detailImageProgressContainer'); - if (value) (element).classList.remove('d-none'); - else (element).classList.add('d-none'); - } - - /** - * Get a human readable representation of the current position - * in ticks - * - * @param {number} ticks tick position - * @returns {string} human readable position - */ - private static formatRunningTime(ticks: number): string { - const ticksPerHour = 36000000000; - const ticksPerMinute = 600000000; - const ticksPerSecond = 10000000; - - const parts: string[] = []; - - const hours: number = Math.floor(ticks / ticksPerHour); - - if (hours) { - parts.push(hours.toString()); + return parts.join(':'); + } + + /** + * Set information about mostly episodes or series + * on the item details page + * + * @param item - to look up + */ + private static setMiscInfo(item: BaseItemDto): void { + const info: Array = []; + + if (item.Type == 'Episode') { + if (item.PremiereDate) { + try { + info.push(parseISO8601Date(item.PremiereDate).toLocaleDateString()); + } catch (e) { + console.log(`Error parsing date: ${item.PremiereDate}`); } + } + } - ticks -= hours * ticksPerHour; + if (item.StartDate) { + try { + info.push(parseISO8601Date(item.StartDate).toLocaleDateString()); + } catch (e) { + console.log(`Error parsing date: ${item.PremiereDate}`); + } + } - const minutes: number = Math.floor(ticks / ticksPerMinute); + if (item.ProductionYear && item.Type == 'Series') { + if (item.Status == 'Continuing') { + info.push(`${item.ProductionYear}-Present`); + } else if (item.ProductionYear) { + let text: string = item.ProductionYear.toString(); - ticks -= minutes * ticksPerMinute; + if (item.EndDate) { + try { + const endYear = parseISO8601Date(item.EndDate).getFullYear(); - if (minutes < 10 && hours) { - parts.push('0' + minutes.toString()); - } else { - parts.push(minutes.toString()); + if (endYear != item.ProductionYear) { + text += `-${parseISO8601Date(item.EndDate).getFullYear()}`; + } + } catch (e) { + console.log(`Error parsing date: ${item.EndDate}`); + } } - const seconds: number = Math.floor(ticks / ticksPerSecond); + info.push(text); + } + } - if (seconds < 10) { - parts.push('0' + seconds.toString()); - } else { - parts.push(seconds.toString()); + if (item.Type != 'Series' && item.Type != 'Episode') { + if (item.ProductionYear) { + info.push(item.ProductionYear.toString()); + } else if (item.PremiereDate) { + try { + info.push( + parseISO8601Date(item.PremiereDate).getFullYear().toString() + ); + } catch (e) { + console.log(`Error parsing date: ${item.PremiereDate}`); } + } + } - return parts.join(':'); - } - - /** - * Set information about mostly episodes or series - * on the item details page - * - * @param {BaseItemDto} item to look up - */ - private static setMiscInfo(item: BaseItemDto): void { - const info: Array = []; - if (item.Type == 'Episode') { - if (item.PremiereDate) { - try { - info.push( - parseISO8601Date(item.PremiereDate).toLocaleDateString() - ); - } catch (e) { - console.log('Error parsing date: ' + item.PremiereDate); - } - } - } - if (item.StartDate) { - try { - info.push( - parseISO8601Date(item.StartDate).toLocaleDateString() - ); - } catch (e) { - console.log('Error parsing date: ' + item.PremiereDate); - } - } - if (item.ProductionYear && item.Type == 'Series') { - if (item.Status == 'Continuing') { - info.push(`${item.ProductionYear}-Present`); - } else if (item.ProductionYear) { - let text: string = item.ProductionYear.toString(); - if (item.EndDate) { - try { - const endYear = parseISO8601Date( - item.EndDate - ).getFullYear(); - if (endYear != item.ProductionYear) { - text += - '-' + - parseISO8601Date(item.EndDate).getFullYear(); - } - } catch (e) { - console.log('Error parsing date: ' + item.EndDate); - } - } - info.push(text); - } - } - if (item.Type != 'Series' && item.Type != 'Episode') { - if (item.ProductionYear) { - info.push(item.ProductionYear.toString()); - } else if (item.PremiereDate) { - try { - info.push( - parseISO8601Date(item.PremiereDate) - .getFullYear() - .toString() - ); - } catch (e) { - console.log('Error parsing date: ' + item.PremiereDate); - } - } - } - let minutes; - if (item.RunTimeTicks && item.Type != 'Series') { - if (item.Type == 'Audio') { - info.push(this.formatRunningTime(item.RunTimeTicks)); - } else { - minutes = item.RunTimeTicks / 600000000; - minutes = minutes || 1; - info.push(Math.round(minutes) + 'min'); - } - } - if ( - item.OfficialRating && - item.Type !== 'Season' && - item.Type !== 'Episode' - ) { - info.push(item.OfficialRating); - } - if (item.Video3DFormat) { - info.push('3D'); - } + let minutes; - const element = this.getElementById('miscInfo'); - element.innerHTML = info.join('    '); + if (item.RunTimeTicks && item.Type != 'Series') { + if (item.Type == 'Audio') { + info.push(this.formatRunningTime(item.RunTimeTicks)); + } else { + minutes = item.RunTimeTicks / 600000000; + minutes = minutes || 1; + info.push(`${Math.round(minutes)}min`); + } } - // Generic / Helper functions - /** - * Set the visibility of an element - * - * @param {HTMLElement} element Element to set visibility on - * @param {boolean} visible True if the element should be visible. - */ - private static setVisibility(element: HTMLElement, visible: boolean): void { - if (visible) { - element.classList.remove('d-none'); - } else { - element.classList.add('d-none'); - } + if ( + item.OfficialRating && + item.Type !== 'Season' && + item.Type !== 'Episode' + ) { + info.push(item.OfficialRating); } - /** - * Get a HTMLElement from id or throw an error - * - * @param {string} id ID to look up - * @returns {HTMLElement} HTML Element - */ - private static getElementById(id: string): HTMLElement { - const element = document.getElementById(id); - if (!element) { - throw new ReferenceError(`Cannot find element ${id} by id`); - } + if (item.Video3DFormat) { + info.push('3D'); + } - return element; + const element = this.getElementById('miscInfo'); + + element.innerHTML = info.join('    '); + } + + // Generic / Helper functions + /** + * Set the visibility of an element + * + * @param element - Element to set visibility on + * @param visible - True if the element should be visible. + */ + private static setVisibility(element: HTMLElement, visible: boolean): void { + if (visible) { + element.classList.remove('d-none'); + } else { + element.classList.add('d-none'); + } + } + + /** + * Get a HTMLElement from id or throw an error + * + * @param id - ID to look up + * @returns HTML Element + */ + private static getElementById(id: string): HTMLElement { + const element = document.getElementById(id); + + if (!element) { + throw new ReferenceError(`Cannot find element ${id} by id`); } - /** - * Get a HTMLElement by class - * - * @param {string} cls Class to look up - * @returns {HTMLElement} HTML Element - */ - private static querySelector(cls: string): HTMLElement { - const element: HTMLElement | null = document.querySelector(cls); - if (!element) { - throw new ReferenceError(`Cannot find element ${cls} by class`); - } + return element; + } + + /** + * Get a HTMLElement by class + * + * @param cls - Class to look up + * @returns HTML Element + */ + private static querySelector(cls: string): HTMLElement { + const element: HTMLElement | null = document.querySelector(cls); - return element; + if (!element) { + throw new ReferenceError(`Cannot find element ${cls} by class`); } + + return element; + } } DocumentManager.initialize(); diff --git a/src/components/fetchhelper.ts b/src/components/fetchhelper.ts index 90363090..1e829a88 100644 --- a/src/components/fetchhelper.ts +++ b/src/components/fetchhelper.ts @@ -1,135 +1,145 @@ /** * Function to send a request, with or without the timeout option * - * @param {any} request Custom request object, mostly modeled after RequestInit. - * @returns {Promise} response promise + * @param request - Custom request object, mostly modeled after RequestInit. + * @returns response promise */ function getFetchPromise(request: any): Promise { - const headers = request.headers || {}; - if (request.dataType === 'json') headers.accept = 'application/json'; - const fetchRequest: RequestInit = { - headers: headers, - method: request.type, - credentials: 'same-origin' - }; - let contentType = request.contentType; - if (request.data) { - if (typeof request.data == 'string') { - fetchRequest.body = request.data; - } else { - fetchRequest.body = paramsToString(request.data); - contentType = - contentType || - 'application/x-www-form-urlencoded; charset=UTF-8'; - } - } - if (contentType) { - headers['Content-Type'] = contentType; - } - let url = request.url; - if (request.query) { - const paramString = paramsToString(request.query); - paramString && (url += '?' + paramString); + const headers = request.headers || {}; + + if (request.dataType === 'json') { + headers.accept = 'application/json'; + } + + const fetchRequest: RequestInit = { + headers: headers, + method: request.type, + credentials: 'same-origin' + }; + let contentType = request.contentType; + + if (request.data) { + if (typeof request.data == 'string') { + fetchRequest.body = request.data; + } else { + fetchRequest.body = paramsToString(request.data); + contentType = + contentType || 'application/x-www-form-urlencoded; charset=UTF-8'; } - return request.timeout - ? fetchWithTimeout(url, fetchRequest, request.timeout) - : fetch(url, fetchRequest); + } + + if (contentType) { + headers['Content-Type'] = contentType; + } + + let url = request.url; + + if (request.query) { + const paramString = paramsToString(request.query); + + paramString && (url += `?${paramString}`); + } + + return request.timeout + ? fetchWithTimeout(url, fetchRequest, request.timeout) + : fetch(url, fetchRequest); } /** * Timeout wrapper for fetch() * - * @param {string} url url to get - * @param {RequestInit} options RequestInit with additional options - * @param {number} timeoutMs request timeout in ms - * @returns {Promise} response promise + * @param url - url to get + * @param options - RequestInit with additional options + * @param timeoutMs - request timeout in ms + * @returns response promise */ function fetchWithTimeout( - url: string, - options: RequestInit, - timeoutMs: number + url: string, + options: RequestInit, + timeoutMs: number ): Promise { - console.log('fetchWithTimeout: timeoutMs: ' + timeoutMs + ', url: ' + url); - return new Promise(function (resolve, reject) { - const timeout = setTimeout(reject, timeoutMs); - options = options || {}; - options.credentials = 'same-origin'; - fetch(url, options).then( - function (response) { - clearTimeout(timeout); - console.log( - 'fetchWithTimeout: succeeded connecting to url: ' + url - ); - resolve(response); - }, - function () { - clearTimeout(timeout); - console.log( - 'fetchWithTimeout: timed out connecting to url: ' + url - ); - reject(); - } - ); - }); + console.log(`fetchWithTimeout: timeoutMs: ${timeoutMs}, url: ${url}`); + + return new Promise((resolve, reject) => { + const timeout = setTimeout(reject, timeoutMs); + + options = options || {}; + options.credentials = 'same-origin'; + fetch(url, options).then( + (response) => { + clearTimeout(timeout); + console.log(`fetchWithTimeout: succeeded connecting to url: ${url}`); + resolve(response); + }, + () => { + clearTimeout(timeout); + console.log(`fetchWithTimeout: timed out connecting to url: ${url}`); + reject(); + } + ); + }); } /** * Urlencode a dictionary of strings for use in POST form or GET requests * - * @param {Record} params Dictionary to encode - * @returns {string} string with encoded values + * @param params - Dictionary to encode + * @returns string with encoded values */ function paramsToString(params: Record): string { - const values = []; - for (const key in params) { - const value = params[key]; - null !== value && - void 0 !== value && - '' !== value && - values.push( - encodeURIComponent(key) + '=' + encodeURIComponent(value) - ); - } - return values.join('&'); + const values = []; + + for (const key in params) { + const value = params[key]; + + null !== value && + void 0 !== value && + '' !== value && + values.push(`${encodeURIComponent(key)}=${encodeURIComponent(value)}`); + } + + return values.join('&'); } /** * Make an ajax request * - * @param {any} request RequestInit-like structure but with url/type/timeout parameters as well - * @returns {Promise} response promise, may be automatically unpacked based on request datatype + * @param request - RequestInit-like structure but with url/type/timeout parameters as well + * @returns response promise, may be automatically unpacked based on request datatype */ export function ajax(request: any): Promise { - if (!request) throw new Error('Request cannot be null'); - request.headers = request.headers || {}; - console.log('requesting url: ' + request.url); - - return getFetchPromise(request).then( - (response: Response) => { - console.log( - 'response status: ' + response.status + ', url: ' + request.url - ); - if (response.status >= 400) { - return Promise.reject(response); - } else if ( - request.dataType === 'json' || - request.headers?.accept === 'application/json' - ) { - return response.json(); - } else if ( - request.dataType === 'text' || - (response.headers.get('Content-Type') || '') - .toLowerCase() - .indexOf('text/') === 0 - ) { - return response.text(); - } else { - return response; - } - }, - function (err) { - console.log('request failed to url: ' + request.url); - throw err; - } - ); + if (!request) { + throw new Error('Request cannot be null'); + } + + request.headers = request.headers || {}; + console.log(`requesting url: ${request.url}`); + + return getFetchPromise(request).then( + (response: Response) => { + console.log(`response status: ${response.status}, url: ${request.url}`); + + if (response.status >= 400) { + return Promise.reject(response); + } else if ( + request.dataType === 'json' || + request.headers?.accept === 'application/json' + ) { + return response.json(); + } else if ( + request.dataType === 'text' || + (response.headers.get('Content-Type') || '') + .toLowerCase() + .indexOf('text/') === 0 + ) { + return response.text(); + } else { + return response; + } + }, + (err) => { + console.log(`request failed to url: ${request.url}`); + throw err; + } + ); } diff --git a/src/components/jellyfinActions.ts b/src/components/jellyfinActions.ts index 5f9b9561..a9f84811 100644 --- a/src/components/jellyfinActions.ts +++ b/src/components/jellyfinActions.ts @@ -1,8 +1,8 @@ import { - getSenderReportingData, - resetPlaybackScope, - extend, - broadcastToMessageBus + getSenderReportingData, + resetPlaybackScope, + extend, + broadcastToMessageBus } from '../helpers'; import { GlobalScope } from '../types/global'; @@ -16,12 +16,12 @@ import { JellyfinApi } from './jellyfinApi'; import { DocumentManager } from './documentManager'; interface PlayRequestQuery extends PlayRequest { - UserId?: string; - StartTimeTicks?: number; - MaxStreamingBitrate?: number; - LiveStreamId?: string; - ItemId?: string; - PlaySessionId?: string; + UserId?: string; + StartTimeTicks?: number; + MaxStreamingBitrate?: number; + LiveStreamId?: string; + ItemId?: string; + PlaySessionId?: string; } let pingInterval: number; @@ -32,20 +32,20 @@ let lastTranscoderPing = 0; * * This is used to keep the transcode available during pauses * - * @param $scope global context - * @param reportingParams parameters to report to the server + * @param $scope - global context + * @param reportingParams - parameters to report to the server */ function restartPingInterval( - $scope: GlobalScope, - reportingParams: PlaybackProgressInfo + $scope: GlobalScope, + reportingParams: PlaybackProgressInfo ): void { - stopPingInterval(); + stopPingInterval(); - if (reportingParams.PlayMethod == 'Transcode') { - pingInterval = setInterval(function () { - pingTranscoder(reportingParams); - }, 1000); - } + if (reportingParams.PlayMethod == 'Transcode') { + pingInterval = setInterval(() => { + pingTranscoder(reportingParams); + }, 1000); + } } /** @@ -54,101 +54,101 @@ function restartPingInterval( * Needed to stop the pinging when it's not needed anymore */ export function stopPingInterval(): void { - if (pingInterval !== 0) { - clearInterval(pingInterval); - pingInterval = 0; - } + if (pingInterval !== 0) { + clearInterval(pingInterval); + pingInterval = 0; + } } /** * Report to the server that playback has started. * - * @param $scope global scope - * @param reportingParams parameters to send to the server + * @param $scope - global scope + * @param reportingParams - parameters to send to the server * @returns promise to wait for the request */ export function reportPlaybackStart( - $scope: GlobalScope, - reportingParams: PlaybackProgressInfo + $scope: GlobalScope, + reportingParams: PlaybackProgressInfo ): Promise { - // it's just "reporting" that the playback is starting - // but it's also disabling the rotating backdrops - // in the line below. - // TODO move the responsibility to the caller. - DocumentManager.clearBackdropInterval(); - - broadcastToMessageBus({ - //TODO: convert these to use a defined type in the type field - type: 'playbackstart', - data: getSenderReportingData($scope, reportingParams) - }); - - restartPingInterval($scope, reportingParams); - - return JellyfinApi.authAjax('Sessions/Playing', { - type: 'POST', - data: JSON.stringify(reportingParams), - contentType: 'application/json' - }); + // it's just "reporting" that the playback is starting + // but it's also disabling the rotating backdrops + // in the line below. + // TODO move the responsibility to the caller. + DocumentManager.clearBackdropInterval(); + + broadcastToMessageBus({ + //TODO: convert these to use a defined type in the type field + type: 'playbackstart', + data: getSenderReportingData($scope, reportingParams) + }); + + restartPingInterval($scope, reportingParams); + + return JellyfinApi.authAjax('Sessions/Playing', { + type: 'POST', + data: JSON.stringify(reportingParams), + contentType: 'application/json' + }); } /** * Report to the server the progress of the playback. * - * @param $scope global scope - * @param reportingParams parameters for jellyfin - * @param reportToServer if jellyfin should be informed - * @param broadcastEventName name of event to send to the cast sender - * @returns {Promise} Promise for the http request + * @param $scope - global scope + * @param reportingParams - parameters for jellyfin + * @param reportToServer - if jellyfin should be informed + * @param broadcastEventName - name of event to send to the cast sender + * @returns Promise for the http request */ export function reportPlaybackProgress( - $scope: GlobalScope, - reportingParams: PlaybackProgressInfo, - reportToServer = true, - broadcastEventName = 'playbackprogress' + $scope: GlobalScope, + reportingParams: PlaybackProgressInfo, + reportToServer = true, + broadcastEventName = 'playbackprogress' ): Promise { - broadcastToMessageBus({ - type: broadcastEventName, - data: getSenderReportingData($scope, reportingParams) - }); - - if (reportToServer === false) { - return Promise.resolve(); - } - - restartPingInterval($scope, reportingParams); - lastTranscoderPing = new Date().getTime(); - - return JellyfinApi.authAjax('Sessions/Playing/Progress', { - type: 'POST', - data: JSON.stringify(reportingParams), - contentType: 'application/json' - }); + broadcastToMessageBus({ + type: broadcastEventName, + data: getSenderReportingData($scope, reportingParams) + }); + + if (reportToServer === false) { + return Promise.resolve(); + } + + restartPingInterval($scope, reportingParams); + lastTranscoderPing = new Date().getTime(); + + return JellyfinApi.authAjax('Sessions/Playing/Progress', { + type: 'POST', + data: JSON.stringify(reportingParams), + contentType: 'application/json' + }); } /** * Report to the server that playback has stopped. * - * @param $scope global scope - * @param reportingParams parameters to send to the server + * @param $scope - global scope + * @param reportingParams - parameters to send to the server * @returns promise for waiting for the request */ export function reportPlaybackStopped( - $scope: GlobalScope, - reportingParams: PlaybackProgressInfo + $scope: GlobalScope, + reportingParams: PlaybackProgressInfo ): Promise { - stopPingInterval(); - - broadcastToMessageBus({ - type: 'playbackstop', - data: getSenderReportingData($scope, reportingParams) - }); - - return JellyfinApi.authAjax('Sessions/Playing/Stopped', { - type: 'POST', - data: JSON.stringify(reportingParams), - contentType: 'application/json' - }); + stopPingInterval(); + + broadcastToMessageBus({ + type: 'playbackstop', + data: getSenderReportingData($scope, reportingParams) + }); + + return JellyfinApi.authAjax('Sessions/Playing/Stopped', { + type: 'POST', + data: JSON.stringify(reportingParams), + contentType: 'application/json' + }); } /** @@ -157,58 +157,59 @@ export function reportPlaybackStopped( * The web client calls that during pause, but this endpoint gets the job done * as well. * - * @param reportingParams progress information to carry + * @param reportingParams - progress information to carry * @returns promise for waiting for the request */ export function pingTranscoder( - reportingParams: PlaybackProgressInfo + reportingParams: PlaybackProgressInfo ): Promise { - const now = new Date().getTime(); - - // 10s is the timeout value, so use half that to report often enough - if (now - lastTranscoderPing < 5000) { - console.debug('Skipping ping due to recent progress check-in'); - return new Promise(function (resolve) { - resolve(undefined); - }); - } + const now = new Date().getTime(); + + // 10s is the timeout value, so use half that to report often enough + if (now - lastTranscoderPing < 5000) { + console.debug('Skipping ping due to recent progress check-in'); - lastTranscoderPing = new Date().getTime(); - - // 10.7 oddly wants it as a query string parameter. This is a server bug for now. - return JellyfinApi.authAjax( - 'Sessions/Playing/Ping?playSessionId=' + reportingParams.PlaySessionId, - { - type: 'POST', - data: JSON.stringify({ - // jellyfin <= 10.6 wants it in the post data. - PlaySessionId: reportingParams.PlaySessionId - }), - contentType: 'application/json' - } - ); + return new Promise((resolve) => { + resolve(undefined); + }); + } + + lastTranscoderPing = new Date().getTime(); + + // 10.7 oddly wants it as a query string parameter. This is a server bug for now. + return JellyfinApi.authAjax( + `Sessions/Playing/Ping?playSessionId=${reportingParams.PlaySessionId}`, + { + type: 'POST', + data: JSON.stringify({ + // jellyfin <= 10.6 wants it in the post data. + PlaySessionId: reportingParams.PlaySessionId + }), + contentType: 'application/json' + } + ); } /** * Update the context about the item we are playing. * - * @param $scope global context - * @param customData data to set on $scope - * @param serverItem item that is playing + * @param $scope - global context + * @param customData - data to set on $scope + * @param serverItem - item that is playing */ export function load( - $scope: GlobalScope, - customData: PlaybackProgressInfo, - serverItem: BaseItemDto + $scope: GlobalScope, + customData: PlaybackProgressInfo, + serverItem: BaseItemDto ): void { - resetPlaybackScope($scope); + resetPlaybackScope($scope); - extend($scope, customData); + extend($scope, customData); - $scope.item = serverItem; + $scope.item = serverItem; - DocumentManager.setAppStatus('backdrop'); - $scope.mediaType = serverItem?.MediaType; + DocumentManager.setAppStatus('backdrop'); + $scope.mediaType = serverItem?.MediaType; } /** @@ -219,116 +220,140 @@ export function load( * * TODO: rename these * - * @param $scope global scope + * @param $scope - global scope */ export function play($scope: GlobalScope): void { - if ( - DocumentManager.getAppStatus() == 'backdrop' || - DocumentManager.getAppStatus() == 'playing-with-controls' || - DocumentManager.getAppStatus() == 'playing' || - DocumentManager.getAppStatus() == 'audio' - ) { - setTimeout(function () { - window.mediaManager.play(); - - if ($scope.mediaType == 'Audio') { - DocumentManager.setAppStatus('audio'); - } else { - DocumentManager.setAppStatus('playing-with-controls'); - } - }, 20); - } + if ( + DocumentManager.getAppStatus() == 'backdrop' || + DocumentManager.getAppStatus() == 'playing-with-controls' || + DocumentManager.getAppStatus() == 'playing' || + DocumentManager.getAppStatus() == 'audio' + ) { + setTimeout(() => { + window.mediaManager.play(); + + if ($scope.mediaType == 'Audio') { + DocumentManager.setAppStatus('audio'); + } else { + DocumentManager.setAppStatus('playing-with-controls'); + } + }, 20); + } } /** * Don't actually stop, just show the idle view after 20ms */ export function stop(): void { - setTimeout(function () { - DocumentManager.setAppStatus('waiting'); - }, 20); + setTimeout(() => { + DocumentManager.setAppStatus('waiting'); + }, 20); } +/** + * @param item + * @param maxBitrate + * @param deviceProfile + * @param startPosition + * @param mediaSourceId + * @param audioStreamIndex + * @param subtitleStreamIndex + * @param liveStreamId + */ export function getPlaybackInfo( - item: BaseItemDto, - maxBitrate: number, - deviceProfile: DeviceProfile, - startPosition: number, - mediaSourceId: string, - audioStreamIndex: number, - subtitleStreamIndex: number, - liveStreamId: string | null = null + item: BaseItemDto, + maxBitrate: number, + deviceProfile: DeviceProfile, + startPosition: number, + mediaSourceId: string, + audioStreamIndex: number, + subtitleStreamIndex: number, + liveStreamId: string | null = null ): Promise { - const postData = { - DeviceProfile: deviceProfile - }; - - // TODO: PlayRequestQuery might not be the proper type for this - const query: PlayRequestQuery = { - UserId: JellyfinApi.userId ?? undefined, - StartTimeTicks: startPosition || 0, - MaxStreamingBitrate: maxBitrate - }; - - if (audioStreamIndex != null) { - query.AudioStreamIndex = audioStreamIndex; - } - if (subtitleStreamIndex != null) { - query.SubtitleStreamIndex = subtitleStreamIndex; - } - if (mediaSourceId) { - query.MediaSourceId = mediaSourceId; - } - if (liveStreamId) { - query.LiveStreamId = liveStreamId; - } - - return JellyfinApi.authAjax('Items/' + item.Id + '/PlaybackInfo', { - query: query, - type: 'POST', - dataType: 'json', - data: JSON.stringify(postData), - contentType: 'application/json' - }); + const postData = { + DeviceProfile: deviceProfile + }; + + // TODO: PlayRequestQuery might not be the proper type for this + const query: PlayRequestQuery = { + UserId: JellyfinApi.userId ?? undefined, + StartTimeTicks: startPosition || 0, + MaxStreamingBitrate: maxBitrate + }; + + if (audioStreamIndex != null) { + query.AudioStreamIndex = audioStreamIndex; + } + + if (subtitleStreamIndex != null) { + query.SubtitleStreamIndex = subtitleStreamIndex; + } + + if (mediaSourceId) { + query.MediaSourceId = mediaSourceId; + } + + if (liveStreamId) { + query.LiveStreamId = liveStreamId; + } + + return JellyfinApi.authAjax(`Items/${item.Id}/PlaybackInfo`, { + query: query, + type: 'POST', + dataType: 'json', + data: JSON.stringify(postData), + contentType: 'application/json' + }); } +/** + * @param item + * @param playSessionId + * @param maxBitrate + * @param deviceProfile + * @param startPosition + * @param mediaSource + * @param audioStreamIndex + * @param subtitleStreamIndex + */ export function getLiveStream( - item: BaseItemDto, - playSessionId: string, - maxBitrate: number, - deviceProfile: DeviceProfile, - startPosition: number, - mediaSource: MediaSourceInfo, - audioStreamIndex: number | null, - subtitleStreamIndex: number | null + item: BaseItemDto, + playSessionId: string, + maxBitrate: number, + deviceProfile: DeviceProfile, + startPosition: number, + mediaSource: MediaSourceInfo, + audioStreamIndex: number | null, + subtitleStreamIndex: number | null ): Promise { - const postData = { - DeviceProfile: deviceProfile, - OpenToken: mediaSource.OpenToken - }; - - const query: PlayRequestQuery = { - UserId: JellyfinApi.userId ?? undefined, - StartTimeTicks: startPosition || 0, - ItemId: item.Id, - MaxStreamingBitrate: maxBitrate, - PlaySessionId: playSessionId - }; - - if (audioStreamIndex != null) { - query.AudioStreamIndex = audioStreamIndex; - } - if (subtitleStreamIndex != null) { - query.SubtitleStreamIndex = subtitleStreamIndex; - } - - return JellyfinApi.authAjax('LiveStreams/Open', { - query: query, - type: 'POST', - dataType: 'json', - data: JSON.stringify(postData), - contentType: 'application/json' - }); + const postData = { + DeviceProfile: deviceProfile, + OpenToken: mediaSource.OpenToken + }; + + const query: PlayRequestQuery = { + UserId: JellyfinApi.userId ?? undefined, + StartTimeTicks: startPosition || 0, + ItemId: item.Id, + MaxStreamingBitrate: maxBitrate, + PlaySessionId: playSessionId + }; + + if (audioStreamIndex != null) { + query.AudioStreamIndex = audioStreamIndex; + } + + if (subtitleStreamIndex != null) { + query.SubtitleStreamIndex = subtitleStreamIndex; + } + + return JellyfinApi.authAjax('LiveStreams/Open', { + query: query, + type: 'POST', + dataType: 'json', + data: JSON.stringify(postData), + contentType: 'application/json' + }); } /** @@ -336,69 +361,69 @@ export function getLiveStream( * * The API has a 10MB limit. * - * @param byteSize number of bytes to request + * @param byteSize - number of bytes to request * @returns the bitrate in bits/s */ export function getDownloadSpeed(byteSize: number): Promise { - const path = 'Playback/BitrateTest?size=' + byteSize; + const path = `Playback/BitrateTest?size=${byteSize}`; - const now = new Date().getTime(); + const now = new Date().getTime(); - return JellyfinApi.authAjax(path, { - type: 'GET', - timeout: 5000 + return JellyfinApi.authAjax(path, { + type: 'GET', + timeout: 5000 + }) + .then((response) => { + // Need to wait for the whole response before calculating speed + return response.blob(); }) - .then(function (response) { - // Need to wait for the whole response before calculating speed - return response.blob(); - }) - .then(function () { - const responseTimeSeconds = (new Date().getTime() - now) / 1000; - const bytesPerSecond = byteSize / responseTimeSeconds; - const bitrate = Math.round(bytesPerSecond * 8); - - return bitrate; - }); + .then(() => { + const responseTimeSeconds = (new Date().getTime() - now) / 1000; + const bytesPerSecond = byteSize / responseTimeSeconds; + const bitrate = Math.round(bytesPerSecond * 8); + + return bitrate; + }); } /** * Function to detect the bitrate. * It first tries 1MB and if bitrate is above 1Mbit/s it tries again with 2.4MB. * - * @returns {Promise} bitrate in bits/s + * @returns bitrate in bits/s */ export function detectBitrate(): Promise { - // First try a small amount so that we don't hang up their mobile connection - return getDownloadSpeed(1000000).then(function (bitrate) { - if (bitrate < 1000000) { - return Math.round(bitrate * 0.8); - } else { - // If that produced a fairly high speed, try again with a larger size to get a more accurate result - return getDownloadSpeed(2400000).then(function (bitrate) { - return Math.round(bitrate * 0.8); - }); - } - }); + // First try a small amount so that we don't hang up their mobile connection + return getDownloadSpeed(1000000).then((bitrate) => { + if (bitrate < 1000000) { + return Math.round(bitrate * 0.8); + } else { + // If that produced a fairly high speed, try again with a larger size to get a more accurate result + return getDownloadSpeed(2400000).then((bitrate) => { + return Math.round(bitrate * 0.8); + }); + } + }); } /** * Tell Jellyfin to kill off our active transcoding session * - * @param {GlobalScope} $scope Global scope variable - * @returns {Promise} Promise for the http request to go through + * @param $scope - Global scope variable + * @returns Promise for the http request to go through */ export function stopActiveEncodings($scope: GlobalScope): Promise { - const options = { - deviceId: window.deviceInfo.deviceId, - PlaySessionId: undefined - }; - - if ($scope.playSessionId) { - options.PlaySessionId = $scope.playSessionId; - } - - return JellyfinApi.authAjax('Videos/ActiveEncodings', { - type: 'DELETE', - query: options - }); + const options = { + deviceId: window.deviceInfo.deviceId, + PlaySessionId: undefined + }; + + if ($scope.playSessionId) { + options.PlaySessionId = $scope.playSessionId; + } + + return JellyfinApi.authAjax('Videos/ActiveEncodings', { + type: 'DELETE', + query: options + }); } diff --git a/src/components/jellyfinApi.ts b/src/components/jellyfinApi.ts index c463d230..a27cb34c 100644 --- a/src/components/jellyfinApi.ts +++ b/src/components/jellyfinApi.ts @@ -2,131 +2,140 @@ import { ajax } from './fetchhelper'; import { Dictionary } from '~/types/global'; export abstract class JellyfinApi { - // userId that we are connecting as currently - public static userId: string | null = null; - - // Security token to prove authentication - public static accessToken: string | null = null; - - // Address of server - public static serverAddress: string | null = null; - - public static setServerInfo( - userId: string, - accessToken: string, - serverAddress: string - ): void { - console.debug( - `JellyfinApi.setServerInfo: user:${userId}, token:${accessToken}, server:${serverAddress}` - ); - this.userId = userId; - this.accessToken = accessToken; - this.serverAddress = serverAddress; + // userId that we are connecting as currently + public static userId: string | null = null; + + // Security token to prove authentication + public static accessToken: string | null = null; + + // Address of server + public static serverAddress: string | null = null; + + public static setServerInfo( + userId: string, + accessToken: string, + serverAddress: string + ): void { + console.debug( + `JellyfinApi.setServerInfo: user:${userId}, token:${accessToken}, server:${serverAddress}` + ); + this.userId = userId; + this.accessToken = accessToken; + this.serverAddress = serverAddress; + } + + // create the necessary headers for authentication + private static getSecurityHeaders(): Dictionary { + // TODO throw error if this fails + + let auth = + `Emby Client="Chromecast", ` + + `Device="${window.deviceInfo.deviceName}", ` + + `DeviceId="${window.deviceInfo.deviceId}", ` + + `Version="${window.deviceInfo.versionNumber}"`; + + if (this.userId) { + auth += `, UserId="${this.userId}"`; } - // create the necessary headers for authentication - private static getSecurityHeaders(): Dictionary { - // TODO throw error if this fails + const headers: Dictionary = { + Authorization: auth + }; - let auth = - `Emby Client="Chromecast", ` + - `Device="${window.deviceInfo.deviceName}", ` + - `DeviceId="${window.deviceInfo.deviceId}", ` + - `Version="${window.deviceInfo.versionNumber}"`; - - if (this.userId) { - auth += `, UserId="${this.userId}"`; - } + if (this.accessToken != null) { + headers['X-MediaBrowser-Token'] = this.accessToken; + } - const headers: Dictionary = { - Authorization: auth - }; + return headers; + } - if (this.accessToken != null) - headers['X-MediaBrowser-Token'] = this.accessToken; + // Create a basic url. + // Cannot start with /. + public static createUrl(path: string): string { + if (this.serverAddress === null) { + console.error('JellyfinApi.createUrl: no server address present'); - return headers; + return ''; } - // Create a basic url. - // Cannot start with /. - public static createUrl(path: string): string { - if (this.serverAddress === null) { - console.error('JellyfinApi.createUrl: no server address present'); - return ''; - } - // Remove leading slashes - while (path.charAt(0) === '/') path = path.substring(1); - - return this.serverAddress + '/' + path; + // Remove leading slashes + while (path.charAt(0) === '/') { + path = path.substring(1); } - // create a path in /Users/userId/ - public static createUserUrl(path: string | null = null): string { - if (path) { - return this.createUrl('Users/' + this.userId + '/' + path); - } else { - return this.createUrl('Users/' + this.userId); - } - } + return `${this.serverAddress}/${path}`; + } - /** - * Create url to image - * - * @param {string} itemId Item id - * @param {string} imgType Image type: Primary, Logo, Backdrop - * @param {string} imgTag Image tag - * @param {number} imgIdx Image index, default 0 - * @returns {string} URL - */ - public static createImageUrl( - itemId: string, - imgType: string, - imgTag: string, - imgIdx = 0 - ): string { - return this.createUrl( - `Items/${itemId}/Images/${imgType}/${imgIdx.toString()}?tag=${imgTag}` - ); + // create a path in /Users/userId/ + public static createUserUrl(path: string | null = null): string { + if (path) { + return this.createUrl(`Users/${this.userId}/${path}`); + } else { + return this.createUrl(`Users/${this.userId}`); } - - // Authenticated ajax - public static authAjax(path: string, args: any): Promise { - if ( - this.userId === null || - this.accessToken === null || - this.serverAddress === null - ) { - console.error( - 'JellyfinApi.authAjax: No userid/accesstoken/serverAddress present. Skipping request' - ); - return Promise.reject('no server info present'); - } - const params = { - url: this.createUrl(path), - headers: this.getSecurityHeaders() - }; - - return ajax({ ...params, ...args }); + } + + /** + * Create url to image + * + * @param itemId - Item id + * @param imgType - Image type: Primary, Logo, Backdrop + * @param imgTag - Image tag + * @param imgIdx - Image index, default 0 + * @returns URL + */ + public static createImageUrl( + itemId: string, + imgType: string, + imgTag: string, + imgIdx = 0 + ): string { + return this.createUrl( + `Items/${itemId}/Images/${imgType}/${imgIdx.toString()}?tag=${imgTag}` + ); + } + + // Authenticated ajax + public static authAjax(path: string, args: any): Promise { + if ( + this.userId === null || + this.accessToken === null || + this.serverAddress === null + ) { + console.error( + 'JellyfinApi.authAjax: No userid/accesstoken/serverAddress present. Skipping request' + ); + + return Promise.reject('no server info present'); } - // Authenticated ajax - public static authAjaxUser(path: string, args: any): Promise { - if ( - this.userId === null || - this.accessToken === null || - this.serverAddress === null - ) { - console.error( - 'JellyfinApi.authAjaxUser: No userid/accesstoken/serverAddress present. Skipping request' - ); - return Promise.reject('no server info present'); - } - const params = { - url: this.createUserUrl(path), - headers: this.getSecurityHeaders() - }; - - return ajax({ ...params, ...args }); + const params = { + url: this.createUrl(path), + headers: this.getSecurityHeaders() + }; + + return ajax({ ...params, ...args }); + } + + // Authenticated ajax + public static authAjaxUser(path: string, args: any): Promise { + if ( + this.userId === null || + this.accessToken === null || + this.serverAddress === null + ) { + console.error( + 'JellyfinApi.authAjaxUser: No userid/accesstoken/serverAddress present. Skipping request' + ); + + return Promise.reject('no server info present'); } + + const params = { + url: this.createUserUrl(path), + headers: this.getSecurityHeaders() + }; + + return ajax({ ...params, ...args }); + } } diff --git a/src/components/maincontroller.ts b/src/components/maincontroller.ts index 9dc037e7..07726d58 100644 --- a/src/components/maincontroller.ts +++ b/src/components/maincontroller.ts @@ -1,25 +1,25 @@ import { - getCurrentPositionTicks, - getReportingParams, - resetPlaybackScope, - getMetadata, - createStreamInfo, - getStreamByIndex, - getShuffleItems, - getInstantMixItems, - translateRequestedItems, - extend, - broadcastToMessageBus, - broadcastConnectionErrorMessage, - cleanName + getCurrentPositionTicks, + getReportingParams, + resetPlaybackScope, + getMetadata, + createStreamInfo, + getStreamByIndex, + getShuffleItems, + getInstantMixItems, + translateRequestedItems, + extend, + broadcastToMessageBus, + broadcastConnectionErrorMessage, + cleanName } from '../helpers'; import { - reportPlaybackProgress, - reportPlaybackStopped, - play, - getPlaybackInfo, - stopActiveEncodings, - detectBitrate + reportPlaybackProgress, + reportPlaybackStopped, + play, + getPlaybackInfo, + stopActiveEncodings, + detectBitrate } from './jellyfinActions'; import { getDeviceProfile } from './deviceprofileBuilder'; import { JellyfinApi } from './jellyfinApi'; @@ -48,685 +48,792 @@ let broadcastToServer = new Date(); let hasReportedCapabilities = false; +/** + * + */ export function onMediaElementTimeUpdate(): void { - if ($scope.isChangingStream) { - return; - } + if ($scope.isChangingStream) { + return; + } - const now = new Date(); + const now = new Date(); - const elapsed = now.valueOf() - broadcastToServer.valueOf(); + const elapsed = now.valueOf() - broadcastToServer.valueOf(); - if (elapsed > 5000) { - // TODO use status as input - reportPlaybackProgress($scope, getReportingParams($scope)); - broadcastToServer = now; - } else if (elapsed > 1500) { - // TODO use status as input - reportPlaybackProgress($scope, getReportingParams($scope), false); - } + if (elapsed > 5000) { + // TODO use status as input + reportPlaybackProgress($scope, getReportingParams($scope)); + broadcastToServer = now; + } else if (elapsed > 1500) { + // TODO use status as input + reportPlaybackProgress($scope, getReportingParams($scope), false); + } } +/** + * + */ export function onMediaElementPause(): void { - if ($scope.isChangingStream) { - return; - } + if ($scope.isChangingStream) { + return; + } - reportEvent('playstatechange', true); + reportEvent('playstatechange', true); } +/** + * + */ export function onMediaElementPlaying(): void { - if ($scope.isChangingStream) { - return; - } - reportEvent('playstatechange', true); + if ($scope.isChangingStream) { + return; + } + + reportEvent('playstatechange', true); } +/** + * @param event + */ function onMediaElementVolumeChange(event: framework.system.Event): void { - window.volume = (event).data; - console.log('Received volume update: ' + window.volume.level); - if (JellyfinApi.serverAddress !== null) reportEvent('volumechange', true); + window.volume = (event).data; + console.log(`Received volume update: ${window.volume.level}`); + + if (JellyfinApi.serverAddress !== null) { + reportEvent('volumechange', true); + } } +/** + * + */ export function enableTimeUpdateListener(): void { - window.mediaManager.addEventListener( - cast.framework.events.EventType.TIME_UPDATE, - onMediaElementTimeUpdate - ); - window.castReceiverContext.addEventListener( - cast.framework.system.EventType.SYSTEM_VOLUME_CHANGED, - onMediaElementVolumeChange - ); - window.mediaManager.addEventListener( - cast.framework.events.EventType.PAUSE, - onMediaElementPause - ); - window.mediaManager.addEventListener( - cast.framework.events.EventType.PLAYING, - onMediaElementPlaying - ); + window.mediaManager.addEventListener( + cast.framework.events.EventType.TIME_UPDATE, + onMediaElementTimeUpdate + ); + window.castReceiverContext.addEventListener( + cast.framework.system.EventType.SYSTEM_VOLUME_CHANGED, + onMediaElementVolumeChange + ); + window.mediaManager.addEventListener( + cast.framework.events.EventType.PAUSE, + onMediaElementPause + ); + window.mediaManager.addEventListener( + cast.framework.events.EventType.PLAYING, + onMediaElementPlaying + ); } +/** + * + */ export function disableTimeUpdateListener(): void { - window.mediaManager.removeEventListener( - cast.framework.events.EventType.TIME_UPDATE, - onMediaElementTimeUpdate - ); - window.castReceiverContext.removeEventListener( - cast.framework.system.EventType.SYSTEM_VOLUME_CHANGED, - onMediaElementVolumeChange - ); - window.mediaManager.removeEventListener( - cast.framework.events.EventType.PAUSE, - onMediaElementPause - ); - window.mediaManager.removeEventListener( - cast.framework.events.EventType.PLAYING, - onMediaElementPlaying - ); + window.mediaManager.removeEventListener( + cast.framework.events.EventType.TIME_UPDATE, + onMediaElementTimeUpdate + ); + window.castReceiverContext.removeEventListener( + cast.framework.system.EventType.SYSTEM_VOLUME_CHANGED, + onMediaElementVolumeChange + ); + window.mediaManager.removeEventListener( + cast.framework.events.EventType.PAUSE, + onMediaElementPause + ); + window.mediaManager.removeEventListener( + cast.framework.events.EventType.PLAYING, + onMediaElementPlaying + ); } enableTimeUpdateListener(); -window.addEventListener('beforeunload', function () { - // Try to cleanup after ourselves before the page closes - disableTimeUpdateListener(); - reportPlaybackStopped($scope, getReportingParams($scope)); +window.addEventListener('beforeunload', () => { + // Try to cleanup after ourselves before the page closes + disableTimeUpdateListener(); + reportPlaybackStopped($scope, getReportingParams($scope)); }); mgr.addEventListener(cast.framework.events.EventType.PLAY, (): void => { - play($scope); - reportPlaybackProgress($scope, getReportingParams($scope)); + play($scope); + reportPlaybackProgress($scope, getReportingParams($scope)); }); mgr.addEventListener(cast.framework.events.EventType.PAUSE, (): void => { - reportPlaybackProgress($scope, getReportingParams($scope)); + reportPlaybackProgress($scope, getReportingParams($scope)); }); +/** + * + */ function defaultOnStop(): void { - playbackMgr.stop(); + playbackMgr.stop(); } mgr.addEventListener( - cast.framework.events.EventType.MEDIA_FINISHED, - defaultOnStop + cast.framework.events.EventType.MEDIA_FINISHED, + defaultOnStop ); mgr.addEventListener(cast.framework.events.EventType.ABORT, defaultOnStop); -mgr.addEventListener(cast.framework.events.EventType.ENDED, function () { - // Ignore - if ($scope.isChangingStream) { - return; - } +mgr.addEventListener(cast.framework.events.EventType.ENDED, () => { + // Ignore + if ($scope.isChangingStream) { + return; + } - reportPlaybackStopped($scope, getReportingParams($scope)); - resetPlaybackScope($scope); + reportPlaybackStopped($scope, getReportingParams($scope)); + resetPlaybackScope($scope); - if (!playbackMgr.playNextItem()) { - window.playlist = []; - window.currentPlaylistIndex = -1; - DocumentManager.startBackdropInterval(); - } + if (!playbackMgr.playNextItem()) { + window.playlist = []; + window.currentPlaylistIndex = -1; + DocumentManager.startBackdropInterval(); + } }); // Set the active subtitle track once the player has loaded window.mediaManager.addEventListener( - cast.framework.events.EventType.PLAYER_LOAD_COMPLETE, - () => { - setTextTrack( - window.mediaManager.getMediaInformation().customData - .subtitleStreamIndex - ); - } + cast.framework.events.EventType.PLAYER_LOAD_COMPLETE, + () => { + setTextTrack( + window.mediaManager.getMediaInformation().customData.subtitleStreamIndex + ); + } ); +/** + * + */ export function reportDeviceCapabilities(): Promise { - return getMaxBitrate().then((maxBitrate) => { - const deviceProfile = getDeviceProfile({ - enableHls: true, - bitrateSetting: maxBitrate - }); - - const capabilities = { - PlayableMediaTypes: ['Audio', 'Video'], - SupportsPersistentIdentifier: false, - SupportsMediaControl: true, - DeviceProfile: deviceProfile - }; - hasReportedCapabilities = true; - - return JellyfinApi.authAjax('Sessions/Capabilities/Full', { - type: 'POST', - data: JSON.stringify(capabilities), - contentType: 'application/json' - }); + return getMaxBitrate().then((maxBitrate) => { + const deviceProfile = getDeviceProfile({ + enableHls: true, + bitrateSetting: maxBitrate + }); + + const capabilities = { + PlayableMediaTypes: ['Audio', 'Video'], + SupportsPersistentIdentifier: false, + SupportsMediaControl: true, + DeviceProfile: deviceProfile + }; + + hasReportedCapabilities = true; + + return JellyfinApi.authAjax('Sessions/Capabilities/Full', { + type: 'POST', + data: JSON.stringify(capabilities), + contentType: 'application/json' }); + }); } +/** + * @param data + */ export function processMessage(data: any): void { - if ( - !data.command || - !data.serverAddress || - !data.userId || - !data.accessToken - ) { - console.log('Invalid message sent from sender. Sending error response'); - - broadcastToMessageBus({ - type: 'error', - message: - 'Missing one or more required params - command,options,userId,accessToken,serverAddress' - }); - return; - } + if ( + !data.command || + !data.serverAddress || + !data.userId || + !data.accessToken + ) { + console.log('Invalid message sent from sender. Sending error response'); + + broadcastToMessageBus({ + type: 'error', + message: + 'Missing one or more required params - command,options,userId,accessToken,serverAddress' + }); - // Items will have properties - Id, Name, Type, MediaType, IsFolder + return; + } - JellyfinApi.setServerInfo( - data.userId, - data.accessToken, - data.serverAddress - ); + // Items will have properties - Id, Name, Type, MediaType, IsFolder - if (data.subtitleAppearance) { - window.subtitleAppearance = data.subtitleAppearance; - } + JellyfinApi.setServerInfo(data.userId, data.accessToken, data.serverAddress); - // Report device capabilities - if (!hasReportedCapabilities) { - reportDeviceCapabilities(); - } + if (data.subtitleAppearance) { + window.subtitleAppearance = data.subtitleAppearance; + } - data.options = data.options || {}; - const cleanReceiverName = cleanName(data.receiverName || ''); - window.deviceInfo.deviceName = - cleanReceiverName || window.deviceInfo.deviceName; - // deviceId just needs to be unique-ish - window.deviceInfo.deviceId = cleanReceiverName - ? btoa(cleanReceiverName) - : window.deviceInfo.deviceId; - - if (data.maxBitrate) { - window.MaxBitrate = data.maxBitrate; - } + // Report device capabilities + if (!hasReportedCapabilities) { + reportDeviceCapabilities(); + } - CommandHandler.processMessage(data, data.command); - - if (window.reportEventType) { - const report = (): Promise => - reportPlaybackProgress($scope, getReportingParams($scope)); - reportPlaybackProgress( - $scope, - getReportingParams($scope), - true, - window.reportEventType - ); - setTimeout(report, 100); - setTimeout(report, 500); - } + data.options = data.options || {}; + + const cleanReceiverName = cleanName(data.receiverName || ''); + + window.deviceInfo.deviceName = + cleanReceiverName || window.deviceInfo.deviceName; + // deviceId just needs to be unique-ish + window.deviceInfo.deviceId = cleanReceiverName + ? btoa(cleanReceiverName) + : window.deviceInfo.deviceId; + + if (data.maxBitrate) { + window.MaxBitrate = data.maxBitrate; + } + + CommandHandler.processMessage(data, data.command); + + if (window.reportEventType) { + const report = (): Promise => + reportPlaybackProgress($scope, getReportingParams($scope)); + + reportPlaybackProgress( + $scope, + getReportingParams($scope), + true, + window.reportEventType + ); + setTimeout(report, 100); + setTimeout(report, 500); + } } +/** + * @param name + * @param reportToServer + */ export function reportEvent( - name: string, - reportToServer: boolean + name: string, + reportToServer: boolean ): Promise { - return reportPlaybackProgress( - $scope, - getReportingParams($scope), - reportToServer, - name - ); + return reportPlaybackProgress( + $scope, + getReportingParams($scope), + reportToServer, + name + ); } +/** + * @param $scope + * @param index + */ export function setSubtitleStreamIndex( - $scope: GlobalScope, - index: number + $scope: GlobalScope, + index: number ): void { - console.log('setSubtitleStreamIndex. index: ' + index); + console.log(`setSubtitleStreamIndex. index: ${index}`); - let positionTicks; + let positionTicks; - const currentSubtitleStream = $scope.mediaSource.MediaStreams.filter( - function (m: any) { - return ( - m.Index == $scope.subtitleStreamIndex && m.Type == 'Subtitle' - ); - } - )[0]; - const currentDeliveryMethod = currentSubtitleStream - ? currentSubtitleStream.DeliveryMethod - : null; - - if (index == -1 || index == null) { - // Need to change the stream to turn off the subs - if (currentDeliveryMethod == 'Encode') { - console.log('setSubtitleStreamIndex video url change required'); - positionTicks = getCurrentPositionTicks($scope); - changeStream(positionTicks, { - SubtitleStreamIndex: -1 - }); - } else { - $scope.subtitleStreamIndex = -1; - setTextTrack(null); - } - return; + const currentSubtitleStream = $scope.mediaSource.MediaStreams.filter( + (m: any) => { + return m.Index == $scope.subtitleStreamIndex && m.Type == 'Subtitle'; + } + )[0]; + const currentDeliveryMethod = currentSubtitleStream + ? currentSubtitleStream.DeliveryMethod + : null; + + if (index == -1 || index == null) { + // Need to change the stream to turn off the subs + if (currentDeliveryMethod == 'Encode') { + console.log('setSubtitleStreamIndex video url change required'); + positionTicks = getCurrentPositionTicks($scope); + changeStream(positionTicks, { + SubtitleStreamIndex: -1 + }); + } else { + $scope.subtitleStreamIndex = -1; + setTextTrack(null); } - const mediaStreams = $scope.PlaybackMediaSource.MediaStreams; + return; + } - const subtitleStream = getStreamByIndex(mediaStreams, 'Subtitle', index); + const mediaStreams = $scope.PlaybackMediaSource.MediaStreams; - if (!subtitleStream) { - console.log( - 'setSubtitleStreamIndex error condition - subtitle stream not found.' - ); - return; - } + const subtitleStream = getStreamByIndex(mediaStreams, 'Subtitle', index); + if (!subtitleStream) { console.log( - 'setSubtitleStreamIndex DeliveryMethod:' + subtitleStream.DeliveryMethod + 'setSubtitleStreamIndex error condition - subtitle stream not found.' ); - if ( - subtitleStream.DeliveryMethod == 'External' || - currentDeliveryMethod == 'Encode' - ) { - const textStreamUrl = subtitleStream.IsExternalUrl - ? subtitleStream.DeliveryUrl - : JellyfinApi.createUrl(subtitleStream.DeliveryUrl); - - console.log('Subtitle url: ' + textStreamUrl); - setTextTrack(index); - $scope.subtitleStreamIndex = subtitleStream.Index; - return; - } else { - console.log('setSubtitleStreamIndex video url change required'); - positionTicks = getCurrentPositionTicks($scope); - changeStream(positionTicks, { - SubtitleStreamIndex: index - }); - } + return; + } + + console.log( + `setSubtitleStreamIndex DeliveryMethod:${subtitleStream.DeliveryMethod}` + ); + + if ( + subtitleStream.DeliveryMethod == 'External' || + currentDeliveryMethod == 'Encode' + ) { + const textStreamUrl = subtitleStream.IsExternalUrl + ? subtitleStream.DeliveryUrl + : JellyfinApi.createUrl(subtitleStream.DeliveryUrl); + + console.log(`Subtitle url: ${textStreamUrl}`); + setTextTrack(index); + $scope.subtitleStreamIndex = subtitleStream.Index; + + return; + } else { + console.log('setSubtitleStreamIndex video url change required'); + positionTicks = getCurrentPositionTicks($scope); + changeStream(positionTicks, { + SubtitleStreamIndex: index + }); + } } +/** + * @param $scope + * @param index + */ export function setAudioStreamIndex( - $scope: GlobalScope, - index: number + $scope: GlobalScope, + index: number ): Promise { - const positionTicks = getCurrentPositionTicks($scope); - return changeStream(positionTicks, { - AudioStreamIndex: index - }); + const positionTicks = getCurrentPositionTicks($scope); + + return changeStream(positionTicks, { + AudioStreamIndex: index + }); } +/** + * @param ticks + */ export function seek(ticks: number): Promise { - return changeStream(ticks); + return changeStream(ticks); } +/** + * @param ticks + * @param params + */ export function changeStream( - ticks: number, - params: any = undefined + ticks: number, + params: any = undefined ): Promise { - if ( - window.mediaManager.getMediaInformation().customData.canClientSeek && - params == null - ) { - window.mediaManager.seek(ticks / 10000000); - reportPlaybackProgress($scope, getReportingParams($scope)); - return Promise.resolve(); - } + if ( + window.mediaManager.getMediaInformation().customData.canClientSeek && + params == null + ) { + window.mediaManager.seek(ticks / 10000000); + reportPlaybackProgress($scope, getReportingParams($scope)); - params = params || {}; - - const playSessionId = $scope.playSessionId; - const liveStreamId = $scope.liveStreamId; - - const item = $scope.item; - - return getMaxBitrate().then(async (maxBitrate) => { - const deviceProfile = getDeviceProfile({ - enableHls: true, - bitrateSetting: maxBitrate - }); - const audioStreamIndex = - params.AudioStreamIndex == null - ? $scope.audioStreamIndex - : params.AudioStreamIndex; - const subtitleStreamIndex = - params.SubtitleStreamIndex == null - ? $scope.subtitleStreamIndex - : params.SubtitleStreamIndex; - - const playbackInformation = await getPlaybackInfo( - item, - maxBitrate, - deviceProfile, - ticks, - $scope.mediaSourceId, - audioStreamIndex, - subtitleStreamIndex, - liveStreamId - ); - if (!validatePlaybackInfoResult(playbackInformation)) { - return; - } + return Promise.resolve(); + } - const mediaSource = playbackInformation.MediaSources[0]; - const streamInfo = createStreamInfo(item, mediaSource, ticks); + params = params || {}; - if (!streamInfo.url) { - showPlaybackInfoErrorMessage('NoCompatibleStream'); - return; - } + const playSessionId = $scope.playSessionId; + const liveStreamId = $scope.liveStreamId; - const mediaInformation = createMediaInformation( - playSessionId, - item, - streamInfo - ); - const loadRequest = new cast.framework.messages.LoadRequestData(); - loadRequest.media = mediaInformation; - loadRequest.autoplay = true; - - // TODO something to do with HLS? - const requiresStoppingTranscoding = false; - if (requiresStoppingTranscoding) { - window.mediaManager.pause(); - await stopActiveEncodings(playSessionId); - } - window.mediaManager.load(loadRequest); - window.mediaManager.play(); - $scope.subtitleStreamIndex = subtitleStreamIndex; - $scope.audioStreamIndex = audioStreamIndex; + const item = $scope.item; + + return getMaxBitrate().then(async (maxBitrate) => { + const deviceProfile = getDeviceProfile({ + enableHls: true, + bitrateSetting: maxBitrate }); + const audioStreamIndex = + params.AudioStreamIndex == null + ? $scope.audioStreamIndex + : params.AudioStreamIndex; + const subtitleStreamIndex = + params.SubtitleStreamIndex == null + ? $scope.subtitleStreamIndex + : params.SubtitleStreamIndex; + + const playbackInformation = await getPlaybackInfo( + item, + maxBitrate, + deviceProfile, + ticks, + $scope.mediaSourceId, + audioStreamIndex, + subtitleStreamIndex, + liveStreamId + ); + + if (!validatePlaybackInfoResult(playbackInformation)) { + return; + } + + const mediaSource = playbackInformation.MediaSources[0]; + const streamInfo = createStreamInfo(item, mediaSource, ticks); + + if (!streamInfo.url) { + showPlaybackInfoErrorMessage('NoCompatibleStream'); + + return; + } + + const mediaInformation = createMediaInformation( + playSessionId, + item, + streamInfo + ); + const loadRequest = new cast.framework.messages.LoadRequestData(); + + loadRequest.media = mediaInformation; + loadRequest.autoplay = true; + + // TODO something to do with HLS? + const requiresStoppingTranscoding = false; + + if (requiresStoppingTranscoding) { + window.mediaManager.pause(); + await stopActiveEncodings(playSessionId); + } + + window.mediaManager.load(loadRequest); + window.mediaManager.play(); + $scope.subtitleStreamIndex = subtitleStreamIndex; + $scope.audioStreamIndex = audioStreamIndex; + }); } // Create a message handler for the custome namespace channel // TODO save namespace somewhere global? window.castReceiverContext.addCustomMessageListener( - 'urn:x-cast:com.connectsdk', - function (evt: any) { - let data: any = evt.data; - - // Apparently chromium likes to pass it as json, not as object. - // chrome on android works fine - if (typeof data === 'string') { - console.log('Event data is a string.. Chromium detected..'); - data = JSON.parse(data); - } + 'urn:x-cast:com.connectsdk', + (evt: any) => { + let data: any = evt.data; + + // Apparently chromium likes to pass it as json, not as object. + // chrome on android works fine + if (typeof data === 'string') { + console.log('Event data is a string.. Chromium detected..'); + data = JSON.parse(data); + } - data.options = data.options || {}; - data.options.senderId = evt.senderId; - // TODO set it somewhere better perhaps - window.senderId = evt.senderId; + data.options = data.options || {}; + data.options.senderId = evt.senderId; + // TODO set it somewhere better perhaps + window.senderId = evt.senderId; - console.log('Received message: ' + JSON.stringify(data)); - processMessage(data); - } + console.log(`Received message: ${JSON.stringify(data)}`); + processMessage(data); + } ); +/** + * @param data + * @param options + * @param method + */ export function translateItems( - data: any, - options: PlayRequest, - method: string + data: any, + options: PlayRequest, + method: string ): Promise { - const playNow = method != 'PlayNext' && method != 'PlayLast'; - return translateRequestedItems(data.userId, options.items, playNow).then( - function (result: BaseItemDtoQueryResult) { - if (result.Items) options.items = result.Items; - - if (method == 'PlayNext' || method == 'PlayLast') { - for ( - let i = 0, length = options.items.length; - i < length; - i++ - ) { - window.playlist.push(options.items[i]); - } - } else { - playbackMgr.playFromOptions(data.options); - } + const playNow = method != 'PlayNext' && method != 'PlayLast'; + + return translateRequestedItems(data.userId, options.items, playNow).then( + (result: BaseItemDtoQueryResult) => { + if (result.Items) { + options.items = result.Items; + } + + if (method == 'PlayNext' || method == 'PlayLast') { + for (let i = 0, length = options.items.length; i < length; i++) { + window.playlist.push(options.items[i]); } - ); + } else { + playbackMgr.playFromOptions(data.options); + } + } + ); } +/** + * @param data + * @param options + * @param item + */ export function instantMix( - data: any, - options: any, - item: BaseItemDto + data: any, + options: any, + item: BaseItemDto ): Promise { - return getInstantMixItems(data.userId, item).then(function (result) { - options.items = result.Items; - playbackMgr.playFromOptions(data.options); - }); + return getInstantMixItems(data.userId, item).then((result) => { + options.items = result.Items; + playbackMgr.playFromOptions(data.options); + }); } +/** + * @param data + * @param options + * @param item + */ export function shuffle( - data: any, - options: any, - item: BaseItemDto + data: any, + options: any, + item: BaseItemDto ): Promise { - return getShuffleItems(data.userId, item).then(function (result) { - options.items = result.Items; - playbackMgr.playFromOptions(data.options); - }); + return getShuffleItems(data.userId, item).then((result) => { + options.items = result.Items; + playbackMgr.playFromOptions(data.options); + }); } +/** + * @param item + * @param options + */ export function onStopPlayerBeforePlaybackDone( - item: BaseItemDto, - options: any + item: BaseItemDto, + options: any ): Promise { - return JellyfinApi.authAjaxUser('Items/' + item.Id, { - dataType: 'json', - type: 'GET' - }).then(function (data) { - // Attach the custom properties we created like userId, serverAddress, itemId, etc - extend(data, item); - - playbackMgr.playItemInternal(data, options); - }, broadcastConnectionErrorMessage); + return JellyfinApi.authAjaxUser(`Items/${item.Id}`, { + dataType: 'json', + type: 'GET' + }).then((data) => { + // Attach the custom properties we created like userId, serverAddress, itemId, etc + extend(data, item); + + playbackMgr.playItemInternal(data, options); + }, broadcastConnectionErrorMessage); } let lastBitrateDetect = 0; let detectedBitrate = 0; +/** + * + */ export function getMaxBitrate(): Promise { - console.log('getMaxBitrate'); + console.log('getMaxBitrate'); - return new Promise(function (resolve) { - // The client can set this number - if (window.MaxBitrate) { - console.log('bitrate is set to ' + window.MaxBitrate); + return new Promise((resolve) => { + // The client can set this number + if (window.MaxBitrate) { + console.log(`bitrate is set to ${window.MaxBitrate}`); - resolve(window.MaxBitrate); - return; - } + resolve(window.MaxBitrate); - if ( - detectedBitrate && - new Date().getTime() - lastBitrateDetect < 600000 - ) { - console.log( - 'returning previous detected bitrate of ' + detectedBitrate - ); - resolve(detectedBitrate); - return; - } + return; + } - console.log('detecting bitrate'); - - detectBitrate().then( - (bitrate) => { - console.log('Max bitrate auto detected to ' + bitrate); - lastBitrateDetect = new Date().getTime(); - detectedBitrate = bitrate; - - resolve(detectedBitrate); - }, - () => { - console.log( - 'Error detecting bitrate, will return device maximum.' - ); - resolve(getMaxBitrateSupport()); - } - ); - }); + if (detectedBitrate && new Date().getTime() - lastBitrateDetect < 600000) { + console.log(`returning previous detected bitrate of ${detectedBitrate}`); + resolve(detectedBitrate); + + return; + } + + console.log('detecting bitrate'); + + detectBitrate().then( + (bitrate) => { + console.log(`Max bitrate auto detected to ${bitrate}`); + lastBitrateDetect = new Date().getTime(); + detectedBitrate = bitrate; + + resolve(detectedBitrate); + }, + () => { + console.log('Error detecting bitrate, will return device maximum.'); + resolve(getMaxBitrateSupport()); + } + ); + }); } +/** + * @param result + */ export function validatePlaybackInfoResult(result: any): boolean { - if (result.ErrorCode) { - showPlaybackInfoErrorMessage(result.ErrorCode); - return false; - } - return true; + if (result.ErrorCode) { + showPlaybackInfoErrorMessage(result.ErrorCode); + + return false; + } + + return true; } +/** + * @param error + */ export function showPlaybackInfoErrorMessage(error: string): void { - broadcastToMessageBus({ type: 'playbackerror', message: error }); + broadcastToMessageBus({ type: 'playbackerror', message: error }); } +/** + * @param versions + */ export function getOptimalMediaSource(versions: Array): any { - let optimalVersion = versions.filter(function (v) { - checkDirectPlay(v); - return v.SupportsDirectPlay; - })[0]; + let optimalVersion = versions.filter((v) => { + checkDirectPlay(v); - if (!optimalVersion) { - optimalVersion = versions.filter(function (v) { - return v.SupportsDirectStream; - })[0]; - } + return v.SupportsDirectPlay; + })[0]; - return ( - optimalVersion || - versions.filter(function (s) { - return s.SupportsTranscoding; - })[0] - ); + if (!optimalVersion) { + optimalVersion = versions.filter((v) => { + return v.SupportsDirectStream; + })[0]; + } + + return ( + optimalVersion || + versions.filter((s) => { + return s.SupportsTranscoding; + })[0] + ); } // Disable direct play on non-http sources +/** + * @param mediaSource + */ export function checkDirectPlay(mediaSource: MediaSourceInfo): void { - if ( - mediaSource.SupportsDirectPlay && - mediaSource.Protocol == 'Http' && - (!mediaSource.RequiredHttpHeaders || - !mediaSource.RequiredHttpHeaders.length) - ) { - return; - } - mediaSource.SupportsDirectPlay = false; + if ( + mediaSource.SupportsDirectPlay && + mediaSource.Protocol == 'Http' && + (!mediaSource.RequiredHttpHeaders || + !mediaSource.RequiredHttpHeaders.length) + ) { + return; + } + + mediaSource.SupportsDirectPlay = false; } +/** + * @param index + */ export function setTextTrack(index: number | null): void { - try { - const textTracksManager = window.mediaManager.getTextTracksManager(); - if (index == null) { - // docs: null is okay - // typescript definitions: Must be Array - textTracksManager.setActiveByIds([]); - return; + try { + const textTracksManager = window.mediaManager.getTextTracksManager(); + + if (index == null) { + // docs: null is okay + // typescript definitions: Must be Array + textTracksManager.setActiveByIds([]); + + return; + } + + const tracks: Array = textTracksManager.getTracks(); + const subtitleTrack: framework.messages.Track | undefined = tracks.find( + (track: framework.messages.Track) => { + return track.trackId === index; + } + ); + + if (subtitleTrack && subtitleTrack.trackId !== undefined) { + textTracksManager.setActiveByIds([subtitleTrack.trackId]); + + const subtitleAppearance = window.subtitleAppearance; + + if (subtitleAppearance) { + const textTrackStyle = new cast.framework.messages.TextTrackStyle(); + + if (subtitleAppearance.dropShadow != null) { + // Empty string is DROP_SHADOW + textTrackStyle.edgeType = + subtitleAppearance.dropShadow.toUpperCase() || + cast.framework.messages.TextTrackEdgeType.DROP_SHADOW; + textTrackStyle.edgeColor = '#000000FF'; + } + + if (subtitleAppearance.font) { + textTrackStyle.fontFamily = subtitleAppearance.font; } - const tracks: Array = textTracksManager.getTracks(); - const subtitleTrack: framework.messages.Track | undefined = tracks.find( - (track: framework.messages.Track) => { - return track.trackId === index; - }); - if (subtitleTrack && subtitleTrack.trackId !== undefined) { - textTracksManager.setActiveByIds([subtitleTrack.trackId]); - const subtitleAppearance = window.subtitleAppearance; - if (subtitleAppearance) { - const textTrackStyle = new cast.framework.messages.TextTrackStyle(); - if (subtitleAppearance.dropShadow != null) { - // Empty string is DROP_SHADOW - textTrackStyle.edgeType = - subtitleAppearance.dropShadow.toUpperCase() || - cast.framework.messages.TextTrackEdgeType.DROP_SHADOW; - textTrackStyle.edgeColor = '#000000FF'; - } - - if (subtitleAppearance.font) { - textTrackStyle.fontFamily = subtitleAppearance.font; - } - - if (subtitleAppearance.textColor) { - // Append the transparency, hardcoded to 100% - textTrackStyle.foregroundColor = - subtitleAppearance.textColor + 'FF'; - } - - if (subtitleAppearance.textBackground === 'transparent') { - textTrackStyle.backgroundColor = '#00000000'; // RGBA - } - - switch (subtitleAppearance.textSize) { - case 'smaller': - textTrackStyle.fontScale = 0.6; - break; - case 'small': - textTrackStyle.fontScale = 0.8; - break; - case 'large': - textTrackStyle.fontScale = 1.15; - break; - case 'larger': - textTrackStyle.fontScale = 1.3; - break; - case 'extralarge': - textTrackStyle.fontScale = 1.45; - break; - default: - textTrackStyle.fontScale = 1.0; - break; - } - textTracksManager.setTextTrackStyle(textTrackStyle); - } + if (subtitleAppearance.textColor) { + // Append the transparency, hardcoded to 100% + textTrackStyle.foregroundColor = `${subtitleAppearance.textColor}FF`; } - } catch (e) { - console.log('Setting subtitle track failed: ' + e); + + if (subtitleAppearance.textBackground === 'transparent') { + textTrackStyle.backgroundColor = '#00000000'; // RGBA + } + + switch (subtitleAppearance.textSize) { + case 'smaller': + textTrackStyle.fontScale = 0.6; + break; + case 'small': + textTrackStyle.fontScale = 0.8; + break; + case 'large': + textTrackStyle.fontScale = 1.15; + break; + case 'larger': + textTrackStyle.fontScale = 1.3; + break; + case 'extralarge': + textTrackStyle.fontScale = 1.45; + break; + default: + textTrackStyle.fontScale = 1.0; + break; + } + + textTracksManager.setTextTrackStyle(textTrackStyle); + } } + } catch (e) { + console.log(`Setting subtitle track failed: ${e}`); + } } // TODO no any types +/** + * @param playSessionId + * @param item + * @param streamInfo + */ export function createMediaInformation( - playSessionId: string, - item: BaseItemDto, - streamInfo: any + playSessionId: string, + item: BaseItemDto, + streamInfo: any ): framework.messages.MediaInformation { - const mediaInfo = new cast.framework.messages.MediaInformation(); - mediaInfo.contentId = streamInfo.url; - mediaInfo.contentType = streamInfo.contentType; - mediaInfo.customData = { - startPositionTicks: streamInfo.startPositionTicks || 0, - itemId: item.Id, - mediaSourceId: streamInfo.mediaSource.Id, - audioStreamIndex: streamInfo.audioStreamIndex, - subtitleStreamIndex: streamInfo.subtitleStreamIndex, - playMethod: streamInfo.isStatic ? 'DirectStream' : 'Transcode', - runtimeTicks: streamInfo.mediaSource.RunTimeTicks, - liveStreamId: streamInfo.mediaSource.LiveStreamId, - canSeek: streamInfo.canSeek, - canClientSeek: streamInfo.canClientSeek, - playSessionId: playSessionId - }; - - mediaInfo.metadata = getMetadata(item); - - mediaInfo.streamType = cast.framework.messages.StreamType.BUFFERED; - mediaInfo.tracks = streamInfo.tracks; - - if (streamInfo.mediaSource.RunTimeTicks) { - mediaInfo.duration = Math.floor( - streamInfo.mediaSource.RunTimeTicks / 10000000 - ); - } + const mediaInfo = new cast.framework.messages.MediaInformation(); + + mediaInfo.contentId = streamInfo.url; + mediaInfo.contentType = streamInfo.contentType; + mediaInfo.customData = { + startPositionTicks: streamInfo.startPositionTicks || 0, + itemId: item.Id, + mediaSourceId: streamInfo.mediaSource.Id, + audioStreamIndex: streamInfo.audioStreamIndex, + subtitleStreamIndex: streamInfo.subtitleStreamIndex, + playMethod: streamInfo.isStatic ? 'DirectStream' : 'Transcode', + runtimeTicks: streamInfo.mediaSource.RunTimeTicks, + liveStreamId: streamInfo.mediaSource.LiveStreamId, + canSeek: streamInfo.canSeek, + canClientSeek: streamInfo.canClientSeek, + playSessionId: playSessionId + }; + + mediaInfo.metadata = getMetadata(item); + + mediaInfo.streamType = cast.framework.messages.StreamType.BUFFERED; + mediaInfo.tracks = streamInfo.tracks; + + if (streamInfo.mediaSource.RunTimeTicks) { + mediaInfo.duration = Math.floor( + streamInfo.mediaSource.RunTimeTicks / 10000000 + ); + } - mediaInfo.customData.startPositionTicks = streamInfo.startPosition || 0; + mediaInfo.customData.startPositionTicks = streamInfo.startPosition || 0; - return mediaInfo; + return mediaInfo; } // Set the available buttons in the UI controls. const controls = cast.framework.ui.Controls.getInstance(); + controls.clearDefaultSlotAssignments(); /* Disabled for now, dynamically set controls for each media type in the future. @@ -737,35 +844,36 @@ controls.assignButton( );*/ controls.assignButton( - cast.framework.ui.ControlsSlot.SLOT_PRIMARY_1, - cast.framework.ui.ControlsButton.SEEK_BACKWARD_15 + cast.framework.ui.ControlsSlot.SLOT_PRIMARY_1, + cast.framework.ui.ControlsButton.SEEK_BACKWARD_15 ); controls.assignButton( - cast.framework.ui.ControlsSlot.SLOT_PRIMARY_2, - cast.framework.ui.ControlsButton.SEEK_FORWARD_15 + cast.framework.ui.ControlsSlot.SLOT_PRIMARY_2, + cast.framework.ui.ControlsButton.SEEK_FORWARD_15 ); const options = new cast.framework.CastReceiverOptions(); + // Global variable set by Webpack if (!PRODUCTION) { - window.castReceiverContext.setLoggerLevel(cast.framework.LoggerLevel.DEBUG); - // Don't time out on me :( - // This is only normally allowed for non media apps, but in this case - // it's for debugging purposes. - options.disableIdleTimeout = true; - // This alternative seems to close sooner; I think it - // quits once the client closes the connection. - // options.maxInactivity = 3600; - - window.mediaManager.addEventListener( - cast.framework.events.category.CORE, - (event: framework.events.Event) => { - console.log('Core event: ' + event.type); - console.log(event); - } - ); + window.castReceiverContext.setLoggerLevel(cast.framework.LoggerLevel.DEBUG); + // Don't time out on me :( + // This is only normally allowed for non media apps, but in this case + // it's for debugging purposes. + options.disableIdleTimeout = true; + // This alternative seems to close sooner; I think it + // quits once the client closes the connection. + // options.maxInactivity = 3600; + + window.mediaManager.addEventListener( + cast.framework.events.category.CORE, + (event: framework.events.Event) => { + console.log(`Core event: ${event.type}`); + console.log(event); + } + ); } else { - window.castReceiverContext.setLoggerLevel(cast.framework.LoggerLevel.NONE); + window.castReceiverContext.setLoggerLevel(cast.framework.LoggerLevel.NONE); } options.playbackConfig = new cast.framework.PlaybackConfig(); diff --git a/src/components/playbackManager.ts b/src/components/playbackManager.ts index f628c1a4..73b55613 100644 --- a/src/components/playbackManager.ts +++ b/src/components/playbackManager.ts @@ -1,29 +1,29 @@ import { - getNextPlaybackItemInfo, - getIntros, - broadcastConnectionErrorMessage, - getReportingParams, - createStreamInfo + getNextPlaybackItemInfo, + getIntros, + broadcastConnectionErrorMessage, + getReportingParams, + createStreamInfo } from '../helpers'; import { - getPlaybackInfo, - getLiveStream, - load, - reportPlaybackStart, - stop, - stopPingInterval, - reportPlaybackStopped + getPlaybackInfo, + getLiveStream, + load, + reportPlaybackStart, + stop, + stopPingInterval, + reportPlaybackStopped } from './jellyfinActions'; import { getDeviceProfile } from './deviceprofileBuilder'; import { - onStopPlayerBeforePlaybackDone, - getMaxBitrate, - getOptimalMediaSource, - showPlaybackInfoErrorMessage, - checkDirectPlay, - createMediaInformation + onStopPlayerBeforePlaybackDone, + getMaxBitrate, + getOptimalMediaSource, + showPlaybackInfoErrorMessage, + checkDirectPlay, + createMediaInformation } from './maincontroller'; import { DocumentManager } from './documentManager'; @@ -31,219 +31,216 @@ import { BaseItemDto } from '~/api/generated/models/base-item-dto'; import { MediaSourceInfo } from '~/api/generated/models/media-source-info'; export class playbackManager { - private playerManager: framework.PlayerManager; - // TODO remove any - private activePlaylist: Array; - private activePlaylistIndex: number; - - constructor(playerManager: framework.PlayerManager) { - // Parameters - this.playerManager = playerManager; - - // Properties - this.activePlaylist = []; - this.activePlaylistIndex = 0; + private playerManager: framework.PlayerManager; + // TODO remove any + private activePlaylist: Array; + private activePlaylistIndex: number; + + constructor(playerManager: framework.PlayerManager) { + // Parameters + this.playerManager = playerManager; + + // Properties + this.activePlaylist = []; + this.activePlaylistIndex = 0; + } + + /* This is used to check if we can switch to + * some other info overlay. + * + * Returns true when playing or paused. + * (before: true only when playing) + * */ + isPlaying(): boolean { + return ( + this.playerManager.getPlayerState() === + cast.framework.messages.PlayerState.PLAYING || + this.playerManager.getPlayerState() === + cast.framework.messages.PlayerState.PAUSED + ); + } + + async playFromOptions(options: any): Promise { + const firstItem = options.items[0]; + + if (options.startPositionTicks || firstItem.MediaType !== 'Video') { + return this.playFromOptionsInternal(options); } - /* This is used to check if we can switch to - * some other info overlay. - * - * Returns true when playing or paused. - * (before: true only when playing) - * */ - isPlaying(): boolean { - return ( - this.playerManager.getPlayerState() === - cast.framework.messages.PlayerState.PLAYING || - this.playerManager.getPlayerState() === - cast.framework.messages.PlayerState.PAUSED - ); - } + const intros = await getIntros(firstItem); - async playFromOptions(options: any): Promise { - const firstItem = options.items[0]; + options.items = intros.Items?.concat(options.items); - if (options.startPositionTicks || firstItem.MediaType !== 'Video') { - return this.playFromOptionsInternal(options); - } + return this.playFromOptionsInternal(options); + } - const intros = await getIntros(firstItem); - options.items = intros.Items?.concat(options.items); - return this.playFromOptionsInternal(options); - } + playFromOptionsInternal(options: any): boolean { + const stopPlayer = this.activePlaylist && this.activePlaylist.length > 0; + + this.activePlaylist = options.items; + window.currentPlaylistIndex = -1; + window.playlist = this.activePlaylist; + + return this.playNextItem(options, stopPlayer); + } + + playNextItem(options: any = {}, stopPlayer = false): boolean { + const nextItemInfo = getNextPlaybackItemInfo(); + + if (nextItemInfo) { + this.activePlaylistIndex = nextItemInfo.index; - playFromOptionsInternal(options: any): boolean { - const stopPlayer = - this.activePlaylist && this.activePlaylist.length > 0; + const item = nextItemInfo.item; - this.activePlaylist = options.items; - window.currentPlaylistIndex = -1; - window.playlist = this.activePlaylist; + this.playItem(item, options, stopPlayer); - return this.playNextItem(options, stopPlayer); + return true; } - playNextItem(options: any = {}, stopPlayer = false): boolean { - const nextItemInfo = getNextPlaybackItemInfo(); + return false; + } - if (nextItemInfo) { - this.activePlaylistIndex = nextItemInfo.index; + playPreviousItem(options: any = {}): boolean { + if (this.activePlaylist && this.activePlaylistIndex > 0) { + this.activePlaylistIndex--; - const item = nextItemInfo.item; + const item = this.activePlaylist[this.activePlaylistIndex]; - this.playItem(item, options, stopPlayer); - return true; - } + this.playItem(item, options, true); - return false; + return true; } - playPreviousItem(options: any = {}): boolean { - if (this.activePlaylist && this.activePlaylistIndex > 0) { - this.activePlaylistIndex--; + return false; + } - const item = this.activePlaylist[this.activePlaylistIndex]; + async playItem( + item: BaseItemDto, + options: any, + stopPlayer = false + ): Promise { + if (stopPlayer) { + await this.stop(true); + } - this.playItem(item, options, true); - return true; - } - return false; + return await onStopPlayerBeforePlaybackDone(item, options); + } + + async playItemInternal(item: BaseItemDto, options: any): Promise { + $scope.isChangingStream = false; + DocumentManager.setAppStatus('loading'); + + const maxBitrate = await getMaxBitrate(); + const deviceProfile = getDeviceProfile({ + enableHls: true, + bitrateSetting: maxBitrate + }); + const playbackInfo = await getPlaybackInfo( + item, + maxBitrate, + deviceProfile, + options.startPositionTicks, + options.mediaSourceId, + options.audioStreamIndex, + options.subtitleStreamIndex + ).catch(broadcastConnectionErrorMessage); + + if (playbackInfo.ErrorCode) { + return showPlaybackInfoErrorMessage(playbackInfo.ErrorCode); } - async playItem( - item: BaseItemDto, - options: any, - stopPlayer = false - ): Promise { - if (stopPlayer) { - await this.stop(true); - } + const mediaSource = await getOptimalMediaSource(playbackInfo.MediaSources); - return await onStopPlayerBeforePlaybackDone(item, options); + if (!mediaSource) { + return showPlaybackInfoErrorMessage('NoCompatibleStream'); } - async playItemInternal(item: BaseItemDto, options: any): Promise { - $scope.isChangingStream = false; - DocumentManager.setAppStatus('loading'); - - const maxBitrate = await getMaxBitrate(); - const deviceProfile = getDeviceProfile({ - enableHls: true, - bitrateSetting: maxBitrate - }); - const playbackInfo = await getPlaybackInfo( - item, - maxBitrate, - deviceProfile, - options.startPositionTicks, - options.mediaSourceId, - options.audioStreamIndex, - options.subtitleStreamIndex - ).catch(broadcastConnectionErrorMessage); - - if (playbackInfo.ErrorCode) { - return showPlaybackInfoErrorMessage(playbackInfo.ErrorCode); - } - - const mediaSource = await getOptimalMediaSource( - playbackInfo.MediaSources - ); - if (!mediaSource) { - return showPlaybackInfoErrorMessage('NoCompatibleStream'); - } - - let itemToPlay = mediaSource; - if (mediaSource.RequiresOpening) { - const openLiveStreamResult = await getLiveStream( - item, - playbackInfo.PlaySessionId, - maxBitrate, - deviceProfile, - options.startPositionTicks, - mediaSource, - null, - null - ); - if (openLiveStreamResult.MediaSource) { - checkDirectPlay(openLiveStreamResult.MediaSource); - itemToPlay = openLiveStreamResult.MediaSource; - } - } - - this.playMediaSource( - playbackInfo.PlaySessionId, - item, - itemToPlay, - options - ); + let itemToPlay = mediaSource; + + if (mediaSource.RequiresOpening) { + const openLiveStreamResult = await getLiveStream( + item, + playbackInfo.PlaySessionId, + maxBitrate, + deviceProfile, + options.startPositionTicks, + mediaSource, + null, + null + ); + + if (openLiveStreamResult.MediaSource) { + checkDirectPlay(openLiveStreamResult.MediaSource); + itemToPlay = openLiveStreamResult.MediaSource; + } } - // TODO eradicate any - playMediaSource( - playSessionId: string, - item: BaseItemDto, - mediaSource: MediaSourceInfo, - options: any - ): void { - DocumentManager.setAppStatus('loading'); + this.playMediaSource(playbackInfo.PlaySessionId, item, itemToPlay, options); + } - const streamInfo = createStreamInfo( - item, - mediaSource, - options.startPositionTicks - ); + // TODO eradicate any + playMediaSource( + playSessionId: string, + item: BaseItemDto, + mediaSource: MediaSourceInfo, + options: any + ): void { + DocumentManager.setAppStatus('loading'); - const url = streamInfo.url; + const streamInfo = createStreamInfo( + item, + mediaSource, + options.startPositionTicks + ); - const mediaInfo = createMediaInformation( - playSessionId, - item, - streamInfo - ); - const loadRequestData = new cast.framework.messages.LoadRequestData(); - loadRequestData.media = mediaInfo; - loadRequestData.autoplay = true; + const url = streamInfo.url; - load($scope, mediaInfo.customData, item); - this.playerManager.load(loadRequestData); + const mediaInfo = createMediaInformation(playSessionId, item, streamInfo); + const loadRequestData = new cast.framework.messages.LoadRequestData(); - $scope.PlaybackMediaSource = mediaSource; + loadRequestData.media = mediaInfo; + loadRequestData.autoplay = true; - console.log('setting src to ' + url); - $scope.mediaSource = mediaSource; + load($scope, mediaInfo.customData, item); + this.playerManager.load(loadRequestData); - DocumentManager.setPlayerBackdrop(item); + $scope.PlaybackMediaSource = mediaSource; - reportPlaybackStart($scope, getReportingParams($scope)); + console.log(`setting src to ${url}`); + $scope.mediaSource = mediaSource; - // We use false as we do not want to broadcast the new status yet - // we will broadcast manually when the media has been loaded, this - // is to be sure the duration has been updated in the media element - this.playerManager.setMediaInformation(mediaInfo, false); - } + DocumentManager.setPlayerBackdrop(item); + + reportPlaybackStart($scope, getReportingParams($scope)); - stop(continuing = false): Promise { - $scope.playNextItem = continuing; - stop(); + // We use false as we do not want to broadcast the new status yet + // we will broadcast manually when the media has been loaded, this + // is to be sure the duration has been updated in the media element + this.playerManager.setMediaInformation(mediaInfo, false); + } - const reportingParams = getReportingParams($scope); + stop(continuing = false): Promise { + $scope.playNextItem = continuing; + stop(); - let promise; + const reportingParams = getReportingParams($scope); - stopPingInterval(); + let promise; - if (reportingParams.ItemId) { - promise = reportPlaybackStopped($scope, reportingParams); - } + stopPingInterval(); - this.playerManager.stop(); + if (reportingParams.ItemId) { + promise = reportPlaybackStopped($scope, reportingParams); + } - this.activePlaylist = []; - this.activePlaylistIndex = -1; - DocumentManager.startBackdropInterval(); + this.playerManager.stop(); - promise = promise || Promise.resolve(); + this.activePlaylist = []; + this.activePlaylistIndex = -1; + DocumentManager.startBackdropInterval(); - return promise; - } + promise = promise || Promise.resolve(); + + return promise; + } } diff --git a/src/helpers.ts b/src/helpers.ts index 43d468a5..1558efbf 100644 --- a/src/helpers.ts +++ b/src/helpers.ts @@ -12,48 +12,49 @@ import { GlobalScope, BusMessage, ItemIndex, ItemQuery } from './types/global'; /** * Get current playback position in ticks, adjusted for server seeking * - * @param $scope global context variable + * @param $scope - global context variable * @returns position in ticks */ export function getCurrentPositionTicks($scope: GlobalScope): number { - let positionTicks = window.mediaManager.getCurrentTimeSec() * 10000000; - const mediaInformation = window.mediaManager.getMediaInformation(); - if (mediaInformation && !mediaInformation.customData.canClientSeek) { - positionTicks += $scope.startPositionTicks || 0; - } + let positionTicks = window.mediaManager.getCurrentTimeSec() * 10000000; + const mediaInformation = window.mediaManager.getMediaInformation(); + + if (mediaInformation && !mediaInformation.customData.canClientSeek) { + positionTicks += $scope.startPositionTicks || 0; + } - return positionTicks; + return positionTicks; } /** * Get parameters used for playback reporting * - * @param $scope global context variable + * @param $scope - global context variable * @returns progress information for use with the reporting APIs */ export function getReportingParams($scope: GlobalScope): PlaybackProgressInfo { - /* Math.round() calls: - * on 10.7, any floating point will give an API error, - * so it's actually really important to make sure that - * those fields are always rounded. - */ - return { - PositionTicks: Math.round(getCurrentPositionTicks($scope)), - IsPaused: - window.mediaManager.getPlayerState() === - cast.framework.messages.PlayerState.PAUSED, - IsMuted: window.volume.muted, - AudioStreamIndex: $scope.audioStreamIndex, - SubtitleStreamIndex: $scope.subtitleStreamIndex, - VolumeLevel: Math.round(window.volume.level * 100), - ItemId: $scope.itemId, - MediaSourceId: $scope.mediaSourceId, - CanSeek: $scope.canSeek, - PlayMethod: $scope.playMethod, - LiveStreamId: $scope.liveStreamId, - PlaySessionId: $scope.playSessionId, - RepeatMode: window.repeatMode - }; + /* Math.round() calls: + * on 10.7, any floating point will give an API error, + * so it's actually really important to make sure that + * those fields are always rounded. + */ + return { + PositionTicks: Math.round(getCurrentPositionTicks($scope)), + IsPaused: + window.mediaManager.getPlayerState() === + cast.framework.messages.PlayerState.PAUSED, + IsMuted: window.volume.muted, + AudioStreamIndex: $scope.audioStreamIndex, + SubtitleStreamIndex: $scope.subtitleStreamIndex, + VolumeLevel: Math.round(window.volume.level * 100), + ItemId: $scope.itemId, + MediaSourceId: $scope.mediaSourceId, + CanSeek: $scope.canSeek, + PlayMethod: $scope.playMethod, + LiveStreamId: $scope.liveStreamId, + PlaySessionId: $scope.playSessionId, + RepeatMode: window.repeatMode + }; } /** @@ -62,42 +63,45 @@ export function getReportingParams($scope: GlobalScope): PlaybackProgressInfo { * @returns ItemIndex including item and index, or null to end playback */ export function getNextPlaybackItemInfo(): ItemIndex | null { - const playlist = window.playlist; + const playlist = window.playlist; - if (!playlist) { - return null; - } - - let newIndex: number; - - if (window.currentPlaylistIndex == -1) { - newIndex = 0; - } else { - switch (window.repeatMode) { - case 'RepeatOne': - newIndex = window.currentPlaylistIndex; - break; - case 'RepeatAll': - newIndex = window.currentPlaylistIndex + 1; - if (newIndex >= window.playlist.length) { - newIndex = 0; - } - break; - default: - newIndex = window.currentPlaylistIndex + 1; - break; + if (!playlist) { + return null; + } + + let newIndex: number; + + if (window.currentPlaylistIndex == -1) { + newIndex = 0; + } else { + switch (window.repeatMode) { + case 'RepeatOne': + newIndex = window.currentPlaylistIndex; + break; + case 'RepeatAll': + newIndex = window.currentPlaylistIndex + 1; + + if (newIndex >= window.playlist.length) { + newIndex = 0; } + + break; + default: + newIndex = window.currentPlaylistIndex + 1; + break; } + } - if (newIndex < playlist.length) { - const item = playlist[newIndex]; + if (newIndex < playlist.length) { + const item = playlist[newIndex]; - return { - item: item, - index: newIndex - }; - } - return null; + return { + item: item, + index: newIndex + }; + } + + return null; } /** @@ -105,392 +109,401 @@ export function getNextPlaybackItemInfo(): ItemIndex | null { * about the item that is currently playing. This is sent over the cast protocol over to * the connected client (or clients?). * - * @param $scope global context - * @param reportingData object full of random information + * @param $scope - global context + * @param reportingData - object full of random information * @returns lots of data for the connected client */ export function getSenderReportingData( - $scope: GlobalScope, - reportingData: PlaybackProgressInfo + $scope: GlobalScope, + reportingData: PlaybackProgressInfo ): any { - const state: any = { - ItemId: reportingData.ItemId, - PlayState: reportingData, - QueueableMediaTypes: ['Audio', 'Video'] - }; + const state: any = { + ItemId: reportingData.ItemId, + PlayState: reportingData, + QueueableMediaTypes: ['Audio', 'Video'] + }; - state.NowPlayingItem = { - Id: reportingData.ItemId, - RunTimeTicks: $scope.runtimeTicks - }; + state.NowPlayingItem = { + Id: reportingData.ItemId, + RunTimeTicks: $scope.runtimeTicks + }; - const item = $scope.item; - - if (item) { - const nowPlayingItem = state.NowPlayingItem; - - nowPlayingItem.ServerId = item.ServerId; - nowPlayingItem.Chapters = item.Chapters || []; - - // TODO: Fill these - const mediaSource = item.MediaSources.filter(function (m: any) { - return m.Id == reportingData.MediaSourceId; - })[0]; - - nowPlayingItem.MediaStreams = mediaSource - ? mediaSource.MediaStreams - : []; - - nowPlayingItem.MediaType = item.MediaType; - nowPlayingItem.Type = item.Type; - nowPlayingItem.Name = item.Name; - - nowPlayingItem.IndexNumber = item.IndexNumber; - nowPlayingItem.IndexNumberEnd = item.IndexNumberEnd; - nowPlayingItem.ParentIndexNumber = item.ParentIndexNumber; - nowPlayingItem.ProductionYear = item.ProductionYear; - nowPlayingItem.PremiereDate = item.PremiereDate; - nowPlayingItem.SeriesName = item.SeriesName; - nowPlayingItem.Album = item.Album; - nowPlayingItem.Artists = item.Artists; - - const imageTags = item.ImageTags || {}; - - if (item.SeriesPrimaryImageTag) { - nowPlayingItem.PrimaryImageItemId = item.SeriesId; - nowPlayingItem.PrimaryImageTag = item.SeriesPrimaryImageTag; - } else if (imageTags.Primary) { - nowPlayingItem.PrimaryImageItemId = item.Id; - nowPlayingItem.PrimaryImageTag = imageTags.Primary; - } else if (item.AlbumPrimaryImageTag) { - nowPlayingItem.PrimaryImageItemId = item.AlbumId; - nowPlayingItem.PrimaryImageTag = item.AlbumPrimaryImageTag; - } + const item = $scope.item; - if (item.BackdropImageTags && item.BackdropImageTags.length) { - nowPlayingItem.BackdropItemId = item.Id; - nowPlayingItem.BackdropImageTag = item.BackdropImageTags[0]; - } else if ( - item.ParentBackdropImageTags && - item.ParentBackdropImageTags.length - ) { - nowPlayingItem.BackdropItemId = item.ParentBackdropItemId; - nowPlayingItem.BackdropImageTag = item.ParentBackdropImageTags[0]; - } + if (item) { + const nowPlayingItem = state.NowPlayingItem; - if (imageTags.Thumb) { - nowPlayingItem.ThumbItemId = item.Id; - nowPlayingItem.ThumbImageTag = imageTags.Thumb; - } + nowPlayingItem.ServerId = item.ServerId; + nowPlayingItem.Chapters = item.Chapters || []; - if (imageTags.Logo) { - nowPlayingItem.LogoItemId = item.Id; - nowPlayingItem.LogoImageTag = imageTags.Logo; - } else if (item.ParentLogoImageTag) { - nowPlayingItem.LogoItemId = item.ParentLogoItemId; - nowPlayingItem.LogoImageTag = item.ParentLogoImageTag; - } + // TODO: Fill these + const mediaSource = item.MediaSources.filter((m: any) => { + return m.Id == reportingData.MediaSourceId; + })[0]; - if ($scope.playNextItem) { - const nextItemInfo = getNextPlaybackItemInfo(); + nowPlayingItem.MediaStreams = mediaSource ? mediaSource.MediaStreams : []; + + nowPlayingItem.MediaType = item.MediaType; + nowPlayingItem.Type = item.Type; + nowPlayingItem.Name = item.Name; + + nowPlayingItem.IndexNumber = item.IndexNumber; + nowPlayingItem.IndexNumberEnd = item.IndexNumberEnd; + nowPlayingItem.ParentIndexNumber = item.ParentIndexNumber; + nowPlayingItem.ProductionYear = item.ProductionYear; + nowPlayingItem.PremiereDate = item.PremiereDate; + nowPlayingItem.SeriesName = item.SeriesName; + nowPlayingItem.Album = item.Album; + nowPlayingItem.Artists = item.Artists; + + const imageTags = item.ImageTags || {}; + + if (item.SeriesPrimaryImageTag) { + nowPlayingItem.PrimaryImageItemId = item.SeriesId; + nowPlayingItem.PrimaryImageTag = item.SeriesPrimaryImageTag; + } else if (imageTags.Primary) { + nowPlayingItem.PrimaryImageItemId = item.Id; + nowPlayingItem.PrimaryImageTag = imageTags.Primary; + } else if (item.AlbumPrimaryImageTag) { + nowPlayingItem.PrimaryImageItemId = item.AlbumId; + nowPlayingItem.PrimaryImageTag = item.AlbumPrimaryImageTag; + } - if (nextItemInfo) { - state.NextMediaType = nextItemInfo.item.MediaType; - } - } + if (item.BackdropImageTags && item.BackdropImageTags.length) { + nowPlayingItem.BackdropItemId = item.Id; + nowPlayingItem.BackdropImageTag = item.BackdropImageTags[0]; + } else if ( + item.ParentBackdropImageTags && + item.ParentBackdropImageTags.length + ) { + nowPlayingItem.BackdropItemId = item.ParentBackdropItemId; + nowPlayingItem.BackdropImageTag = item.ParentBackdropImageTags[0]; + } + + if (imageTags.Thumb) { + nowPlayingItem.ThumbItemId = item.Id; + nowPlayingItem.ThumbImageTag = imageTags.Thumb; } - return state; + if (imageTags.Logo) { + nowPlayingItem.LogoItemId = item.Id; + nowPlayingItem.LogoImageTag = imageTags.Logo; + } else if (item.ParentLogoImageTag) { + nowPlayingItem.LogoItemId = item.ParentLogoItemId; + nowPlayingItem.LogoImageTag = item.ParentLogoImageTag; + } + + if ($scope.playNextItem) { + const nextItemInfo = getNextPlaybackItemInfo(); + + if (nextItemInfo) { + state.NextMediaType = nextItemInfo.item.MediaType; + } + } + } + + return state; } /** * Attempt to clean the receiver state. * - * @param $scope global context variable + * @param $scope - global context variable */ export function resetPlaybackScope($scope: GlobalScope): void { - DocumentManager.setAppStatus('waiting'); - - $scope.startPositionTicks = 0; - DocumentManager.setWaitingBackdrop(null, null); - $scope.mediaType = ''; - $scope.itemId = ''; - - $scope.audioStreamIndex = null; - $scope.subtitleStreamIndex = null; - $scope.mediaSource = null; - $scope.mediaSourceId = ''; - $scope.PlaybackMediaSource = null; - - $scope.playMethod = ''; - $scope.canSeek = false; - $scope.canClientSeek = false; - $scope.isChangingStream = false; - $scope.playNextItem = true; - - $scope.item = null; - $scope.liveStreamId = ''; - $scope.playSessionId = ''; - - // Detail content - DocumentManager.setLogo(null); - DocumentManager.setDetailImage(null); + DocumentManager.setAppStatus('waiting'); + + $scope.startPositionTicks = 0; + DocumentManager.setWaitingBackdrop(null, null); + $scope.mediaType = ''; + $scope.itemId = ''; + + $scope.audioStreamIndex = null; + $scope.subtitleStreamIndex = null; + $scope.mediaSource = null; + $scope.mediaSourceId = ''; + $scope.PlaybackMediaSource = null; + + $scope.playMethod = ''; + $scope.canSeek = false; + $scope.canClientSeek = false; + $scope.isChangingStream = false; + $scope.playNextItem = true; + + $scope.item = null; + $scope.liveStreamId = ''; + $scope.playSessionId = ''; + + // Detail content + DocumentManager.setLogo(null); + DocumentManager.setDetailImage(null); } /** * Create CAF-native metadata for a given item * - * @param item item to look up + * @param item - item to look up * @returns one of the metadata classes in cast.framework.messages.*Metadata */ export function getMetadata(item: BaseItemDto): any { - let metadata: any; - let posterUrl = ''; + let metadata: any; + let posterUrl = ''; - if (item.SeriesPrimaryImageTag) - posterUrl = JellyfinApi.createUrl( - `Items/${item.SeriesId}/Images/Primary?tag=${item.SeriesPrimaryImageTag}` - ); - else if (item.AlbumPrimaryImageTag) - posterUrl = JellyfinApi.createUrl( - `Items/${item.AlbumId}/Images/Primary?tag=${item.AlbumPrimaryImageTag}` - ); - else if (item.ImageTags?.Primary) - posterUrl = JellyfinApi.createUrl( - `Items/${item.Id}/Images/Primary?tag=${item.ImageTags.Primary}` - ); + if (item.SeriesPrimaryImageTag) { + posterUrl = JellyfinApi.createUrl( + `Items/${item.SeriesId}/Images/Primary?tag=${item.SeriesPrimaryImageTag}` + ); + } else if (item.AlbumPrimaryImageTag) { + posterUrl = JellyfinApi.createUrl( + `Items/${item.AlbumId}/Images/Primary?tag=${item.AlbumPrimaryImageTag}` + ); + } else if (item.ImageTags?.Primary) { + posterUrl = JellyfinApi.createUrl( + `Items/${item.Id}/Images/Primary?tag=${item.ImageTags.Primary}` + ); + } - if (item.Type == 'Episode') { - metadata = new cast.framework.messages.TvShowMediaMetadata(); - metadata.seriesTitle = item.SeriesName; - - if (item.PremiereDate) - metadata.originalAirdate = parseISO8601Date( - item.PremiereDate - ).toISOString(); - if (item.IndexNumber != null) metadata.episode = item.IndexNumber; - if (item.ParentIndexNumber != null) - metadata.season = item.ParentIndexNumber; - } else if (item.Type == 'Photo') { - metadata = new cast.framework.messages.PhotoMediaMetadata(); - - if (item.PremiereDate) - metadata.creationDateTime = parseISO8601Date( - item.PremiereDate - ).toISOString(); - // TODO more metadata? - } else if (item.Type == 'Audio') { - metadata = new cast.framework.messages.MusicTrackMediaMetadata(); - metadata.songName = item.Name; - metadata.artist = - item.Artists && item.Artists.length ? item.Artists.join(', ') : ''; - metadata.albumArtist = item.AlbumArtist; - metadata.albumName = item.Album; - - if (item.PremiereDate) - metadata.releaseDate = parseISO8601Date( - item.PremiereDate - ).toISOString(); - if (item.IndexNumber != null) metadata.trackNumber = item.IndexNumber; - if (item.ParentIndexNumber != null) - metadata.discNumber = item.ParentIndexNumber; - // previously: p.PersonType == 'Type'.. wtf? - const composer = (item.People || []).filter( - (p: BaseItemPerson) => p.Type == 'Composer' - )[0]; - if (composer) metadata.composer = composer.Name; - } else if (item.Type == 'Movie') { - metadata = new cast.framework.messages.MovieMediaMetadata(); - if (item.PremiereDate) - metadata.releaseDate = parseISO8601Date( - item.PremiereDate - ).toISOString(); - } else { - metadata = new cast.framework.messages.GenericMediaMetadata(); - - if (item.PremiereDate) - metadata.releaseDate = parseISO8601Date( - item.PremiereDate - ).toISOString(); - if (item.Studios && item.Studios.length) - metadata.studio = item.Studios[0]; + if (item.Type == 'Episode') { + metadata = new cast.framework.messages.TvShowMediaMetadata(); + metadata.seriesTitle = item.SeriesName; + + if (item.PremiereDate) { + metadata.originalAirdate = parseISO8601Date( + item.PremiereDate + ).toISOString(); + } + + if (item.IndexNumber != null) { + metadata.episode = item.IndexNumber; + } + + if (item.ParentIndexNumber != null) { + metadata.season = item.ParentIndexNumber; + } + } else if (item.Type == 'Photo') { + metadata = new cast.framework.messages.PhotoMediaMetadata(); + + if (item.PremiereDate) { + metadata.creationDateTime = parseISO8601Date( + item.PremiereDate + ).toISOString(); + } + // TODO more metadata? + } else if (item.Type == 'Audio') { + metadata = new cast.framework.messages.MusicTrackMediaMetadata(); + metadata.songName = item.Name; + metadata.artist = + item.Artists && item.Artists.length ? item.Artists.join(', ') : ''; + metadata.albumArtist = item.AlbumArtist; + metadata.albumName = item.Album; + + if (item.PremiereDate) { + metadata.releaseDate = parseISO8601Date(item.PremiereDate).toISOString(); + } + + if (item.IndexNumber != null) { + metadata.trackNumber = item.IndexNumber; + } + + if (item.ParentIndexNumber != null) { + metadata.discNumber = item.ParentIndexNumber; + } + + // previously: p.PersonType == 'Type'.. wtf? + const composer = (item.People || []).filter( + (p: BaseItemPerson) => p.Type == 'Composer' + )[0]; + + if (composer) { + metadata.composer = composer.Name; + } + } else if (item.Type == 'Movie') { + metadata = new cast.framework.messages.MovieMediaMetadata(); + + if (item.PremiereDate) { + metadata.releaseDate = parseISO8601Date(item.PremiereDate).toISOString(); + } + } else { + metadata = new cast.framework.messages.GenericMediaMetadata(); + + if (item.PremiereDate) { + metadata.releaseDate = parseISO8601Date(item.PremiereDate).toISOString(); + } + + if (item.Studios && item.Studios.length) { + metadata.studio = item.Studios[0]; } + } - metadata.title = item.Name ?? '????'; - metadata.images = [new cast.framework.messages.Image(posterUrl)]; - return metadata; + metadata.title = item.Name ?? '????'; + metadata.images = [new cast.framework.messages.Image(posterUrl)]; + + return metadata; } /** * Create the necessary information about an item * needed for playback * - * @param item Item to play - * @param mediaSource MediaSourceInfo for the item - * @param startPosition Where to seek to (possibly server seeking) + * @param item - Item to play + * @param mediaSource - MediaSourceInfo for the item + * @param startPosition - Where to seek to (possibly server seeking) * @returns object with enough information to start playback */ export function createStreamInfo( - item: BaseItemDto, - mediaSource: MediaSourceInfo, - startPosition: number | null + item: BaseItemDto, + mediaSource: MediaSourceInfo, + startPosition: number | null ): any { - let mediaUrl; - let contentType; - - // server seeking - const startPositionInSeekParam = startPosition - ? startPosition / 10000000 - : 0; - const seekParam = startPositionInSeekParam - ? '#t=' + startPositionInSeekParam - : ''; - - let isStatic = false; - let streamContainer = mediaSource.Container; - - let playerStartPositionTicks = 0; - - const type = item.MediaType?.toLowerCase(); - - if (type == 'video') { - contentType = 'video/' + mediaSource.Container; - - if (mediaSource.SupportsDirectPlay) { - mediaUrl = mediaSource.Path; - isStatic = true; - } else if (mediaSource.SupportsDirectStream) { - mediaUrl = JellyfinApi.createUrl( - `videos/${item.Id}/stream.${mediaSource.Container}?mediaSourceId=${mediaSource.Id}&api_key=${JellyfinApi.accessToken}&static=true${seekParam}` - ); - isStatic = true; - playerStartPositionTicks = startPosition || 0; - } else { - // TODO deal with !TranscodingUrl - mediaUrl = JellyfinApi.createUrl( - mediaSource.TranscodingUrl - ); - - if (mediaSource.TranscodingSubProtocol == 'hls') { - mediaUrl += seekParam; - playerStartPositionTicks = startPosition || 0; - contentType = 'application/x-mpegURL'; - streamContainer = 'm3u8'; - } else { - contentType = 'video/' + mediaSource.TranscodingContainer; - streamContainer = mediaSource.TranscodingContainer; - - if ( - mediaUrl.toLowerCase().indexOf('copytimestamps=true') != -1 - ) { - startPosition = 0; - } - } - } + let mediaUrl; + let contentType; + + // server seeking + const startPositionInSeekParam = startPosition ? startPosition / 10000000 : 0; + const seekParam = startPositionInSeekParam + ? `#t=${startPositionInSeekParam}` + : ''; + + let isStatic = false; + let streamContainer = mediaSource.Container; + + let playerStartPositionTicks = 0; + + const type = item.MediaType?.toLowerCase(); + + if (type == 'video') { + contentType = `video/${mediaSource.Container}`; + + if (mediaSource.SupportsDirectPlay) { + mediaUrl = mediaSource.Path; + isStatic = true; + } else if (mediaSource.SupportsDirectStream) { + mediaUrl = JellyfinApi.createUrl( + `videos/${item.Id}/stream.${mediaSource.Container}?mediaSourceId=${mediaSource.Id}&api_key=${JellyfinApi.accessToken}&static=true${seekParam}` + ); + isStatic = true; + playerStartPositionTicks = startPosition || 0; } else { - contentType = 'audio/' + mediaSource.Container; - - if (mediaSource.SupportsDirectPlay) { - mediaUrl = mediaSource.Path; - isStatic = true; - playerStartPositionTicks = startPosition || 0; - } else { - const isDirectStream = mediaSource.SupportsDirectStream; - - if (isDirectStream) { - const outputContainer = ( - mediaSource.Container || '' - ).toLowerCase(); - - mediaUrl = JellyfinApi.createUrl( - `Audio/${item.Id}/stream.${outputContainer}?mediaSourceId=${mediaSource.Id}&api_key=${JellyfinApi.accessToken}&static=true${seekParam}` - ); - isStatic = true; - } else { - streamContainer = mediaSource.TranscodingContainer; - contentType = 'audio/' + mediaSource.TranscodingContainer; - - // TODO deal with !TranscodingUrl - mediaUrl = JellyfinApi.createUrl( - mediaSource.TranscodingUrl - ); - } + // TODO deal with !TranscodingUrl + mediaUrl = JellyfinApi.createUrl(mediaSource.TranscodingUrl); + + if (mediaSource.TranscodingSubProtocol == 'hls') { + mediaUrl += seekParam; + playerStartPositionTicks = startPosition || 0; + contentType = 'application/x-mpegURL'; + streamContainer = 'm3u8'; + } else { + contentType = `video/${mediaSource.TranscodingContainer}`; + streamContainer = mediaSource.TranscodingContainer; + + if (mediaUrl.toLowerCase().indexOf('copytimestamps=true') != -1) { + startPosition = 0; } + } } + } else { + contentType = `audio/${mediaSource.Container}`; - // TODO: Remove the second half of the expression by supporting changing the mediaElement src dynamically. - // It is a pain and will require unbinding all event handlers during the operation - const canSeek = (mediaSource.RunTimeTicks || 0) > 0; - - const info: any = { - url: mediaUrl, - mediaSource: mediaSource, - isStatic: isStatic, - contentType: contentType, - streamContainer: streamContainer, - canSeek: canSeek, - canClientSeek: isStatic || (canSeek && streamContainer == 'm3u8'), - audioStreamIndex: mediaSource.DefaultAudioStreamIndex, - subtitleStreamIndex: mediaSource.DefaultSubtitleStreamIndex, - playerStartPositionTicks: playerStartPositionTicks, - startPositionTicks: startPosition - }; + if (mediaSource.SupportsDirectPlay) { + mediaUrl = mediaSource.Path; + isStatic = true; + playerStartPositionTicks = startPosition || 0; + } else { + const isDirectStream = mediaSource.SupportsDirectStream; - const subtitleStreams = - mediaSource.MediaStreams?.filter(function (stream: any) { - return stream.Type === 'Subtitle'; - }) ?? []; - const subtitleTracks: Array = []; - subtitleStreams.forEach(function (subtitleStream: any) { - if (subtitleStream.DeliveryUrl === undefined) { - /* The CAF v3 player only supports vtt currently, - * SRT subs can be "transcoded" to vtt by jellyfin. - * The server will do that in accordance with the device profiles and - * give us a DeliveryUrl if that is the case. - * Support for more could be added with a custom implementation - **/ - return; - } - const textStreamUrl = subtitleStream.IsExternalUrl - ? subtitleStream.DeliveryUrl - : JellyfinApi.createUrl(subtitleStream.DeliveryUrl); + if (isDirectStream) { + const outputContainer = (mediaSource.Container || '').toLowerCase(); - const track = new cast.framework.messages.Track( - info.subtitleStreamIndex, - cast.framework.messages.TrackType.TEXT + mediaUrl = JellyfinApi.createUrl( + `Audio/${item.Id}/stream.${outputContainer}?mediaSourceId=${mediaSource.Id}&api_key=${JellyfinApi.accessToken}&static=true${seekParam}` ); - track.trackId = subtitleStream.Index; - track.trackContentId = textStreamUrl; - track.language = subtitleStream.Language; - track.name = subtitleStream.DisplayTitle; - // TODO this should not be hardcoded but we only support VTT currently - track.trackContentType = 'text/vtt'; - track.subtype = cast.framework.messages.TextTrackType.SUBTITLES; - subtitleTracks.push(track); - console.log('Subtitle url: ' + info.subtitleStreamUrl); - }); + isStatic = true; + } else { + streamContainer = mediaSource.TranscodingContainer; + contentType = `audio/${mediaSource.TranscodingContainer}`; + + // TODO deal with !TranscodingUrl + mediaUrl = JellyfinApi.createUrl(mediaSource.TranscodingUrl); + } + } + } + + // TODO: Remove the second half of the expression by supporting changing the mediaElement src dynamically. + // It is a pain and will require unbinding all event handlers during the operation + const canSeek = (mediaSource.RunTimeTicks || 0) > 0; + + const info: any = { + url: mediaUrl, + mediaSource: mediaSource, + isStatic: isStatic, + contentType: contentType, + streamContainer: streamContainer, + canSeek: canSeek, + canClientSeek: isStatic || (canSeek && streamContainer == 'm3u8'), + audioStreamIndex: mediaSource.DefaultAudioStreamIndex, + subtitleStreamIndex: mediaSource.DefaultSubtitleStreamIndex, + playerStartPositionTicks: playerStartPositionTicks, + startPositionTicks: startPosition + }; + + const subtitleStreams = + mediaSource.MediaStreams?.filter((stream: any) => { + return stream.Type === 'Subtitle'; + }) ?? []; + const subtitleTracks: Array = []; + + subtitleStreams.forEach((subtitleStream: any) => { + if (subtitleStream.DeliveryUrl === undefined) { + /* The CAF v3 player only supports vtt currently, + * SRT subs can be "transcoded" to vtt by jellyfin. + * The server will do that in accordance with the device profiles and + * give us a DeliveryUrl if that is the case. + * Support for more could be added with a custom implementation + **/ + return; + } - info.tracks = subtitleTracks; + const textStreamUrl = subtitleStream.IsExternalUrl + ? subtitleStream.DeliveryUrl + : JellyfinApi.createUrl(subtitleStream.DeliveryUrl); - return info; + const track = new cast.framework.messages.Track( + info.subtitleStreamIndex, + cast.framework.messages.TrackType.TEXT + ); + + track.trackId = subtitleStream.Index; + track.trackContentId = textStreamUrl; + track.language = subtitleStream.Language; + track.name = subtitleStream.DisplayTitle; + // TODO this should not be hardcoded but we only support VTT currently + track.trackContentType = 'text/vtt'; + track.subtype = cast.framework.messages.TextTrackType.SUBTITLES; + subtitleTracks.push(track); + console.log(`Subtitle url: ${info.subtitleStreamUrl}`); + }); + + info.tracks = subtitleTracks; + + return info; } /** * Get stream by its index while making a type assertion * - * @param streams array streams to consider - * @param type type of stream - * @param index index of stream + * @param streams - array streams to consider + * @param type - type of stream + * @param index - index of stream * @returns first first matching stream */ export function getStreamByIndex( - streams: Array, - type: string, - index: number + streams: Array, + type: string, + index: number ): any { - return streams.filter(function (s) { - return s.Type == type && s.Index == index; - })[0]; + return streams.filter((s) => { + return s.Type == type && s.Index == index; + })[0]; } // defined for use in the 3 next functions @@ -506,34 +519,34 @@ const requiredItemFields = 'MediaSources,Chapters'; * * TODO: JellyfinApi.userId should be fine for this. * - * @param userId User ID to look up items with - * @param item Parent item of shuffle search + * @param userId - User ID to look up items with + * @param item - Parent item of shuffle search * @returns items for the queue */ export function getShuffleItems( - userId: string, - item: BaseItemDto + userId: string, + item: BaseItemDto ): Promise { - const query: ItemQuery = { - UserId: userId, - Fields: requiredItemFields, - Limit: 50, - Filters: 'IsNotFolder', - Recursive: true, - SortBy: 'Random' - }; - - if (item.Type == 'MusicArtist') { - query.MediaTypes = 'Audio'; - query.ArtistIds = item.Id; - } else if (item.Type == 'MusicGenre') { - query.MediaTypes = 'Audio'; - query.Genres = item.Name ?? undefined; - } else { - query.ParentId = item.Id; - } - - return getItemsForPlayback(userId, query); + const query: ItemQuery = { + UserId: userId, + Fields: requiredItemFields, + Limit: 50, + Filters: 'IsNotFolder', + Recursive: true, + SortBy: 'Random' + }; + + if (item.Type == 'MusicArtist') { + query.MediaTypes = 'Audio'; + query.ArtistIds = item.Id; + } else if (item.Type == 'MusicGenre') { + query.MediaTypes = 'Audio'; + query.Genres = item.Name ?? undefined; + } else { + query.ParentId = item.Id; + } + + return getItemsForPlayback(userId, query); } /** @@ -542,120 +555,120 @@ export function getShuffleItems( * * TODO: JellyfinApi.userId should be fine for this. * - * @param userId User ID to look up items with - * @param item Parent item of the search + * @param userId - User ID to look up items with + * @param item - Parent item of the search * @returns items for the queue */ export function getInstantMixItems( - userId: string, - item: BaseItemDto + userId: string, + item: BaseItemDto ): Promise { - const query: any = { - UserId: userId, - Fields: requiredItemFields, - Limit: 50 - }; - - let url: string | null = null; - - if (item.Type == 'MusicArtist') { - url = 'Artists/InstantMix'; - query.Id = item.Id; - } else if (item.Type == 'MusicGenre') { - url = 'MusicGenres/InstantMix'; - query.Id = item.Id; - } else if (item.Type == 'MusicAlbum') { - url = 'Albums/' + item.Id + '/InstantMix'; - } else if (item.Type == 'Audio') { - url = 'Songs/' + item.Id + '/InstantMix'; - } else if (item.Type == 'Playlist') { - url = 'Playlists/' + item.Id + '/InstantMix'; - } - - if (url) { - return JellyfinApi.authAjax(url, { - query: query, - type: 'GET', - dataType: 'json' - }); - } else { - return Promise.reject('InstantMix: Unknown item type: ' + item.Type); - } + const query: any = { + UserId: userId, + Fields: requiredItemFields, + Limit: 50 + }; + + let url: string | null = null; + + if (item.Type == 'MusicArtist') { + url = 'Artists/InstantMix'; + query.Id = item.Id; + } else if (item.Type == 'MusicGenre') { + url = 'MusicGenres/InstantMix'; + query.Id = item.Id; + } else if (item.Type == 'MusicAlbum') { + url = `Albums/${item.Id}/InstantMix`; + } else if (item.Type == 'Audio') { + url = `Songs/${item.Id}/InstantMix`; + } else if (item.Type == 'Playlist') { + url = `Playlists/${item.Id}/InstantMix`; + } + + if (url) { + return JellyfinApi.authAjax(url, { + query: query, + type: 'GET', + dataType: 'json' + }); + } else { + return Promise.reject(`InstantMix: Unknown item type: ${item.Type}`); + } } /** * Get items to be played back * - * @param userId user for the search - * @param query specification on what to search for + * @param userId - user for the search + * @param query - specification on what to search for * @returns items to be played back */ export function getItemsForPlayback( - userId: string, - query: ItemQuery + userId: string, + query: ItemQuery ): Promise { - query.UserId = userId; - query.Limit = query.Limit || 100; - query.Fields = requiredItemFields; - query.ExcludeLocationTypes = 'Virtual'; - - if (query.Ids && query.Ids.split(',').length == 1) { - return JellyfinApi.authAjaxUser('Items/' + query.Ids.split(',')[0], { - type: 'GET', - dataType: 'json' - }).then(function (item) { - return { - Items: [item], - TotalRecordCount: 1 - }; - }); - } else { - return JellyfinApi.authAjaxUser('Items', { - query: query, - type: 'GET', - dataType: 'json' - }); - } + query.UserId = userId; + query.Limit = query.Limit || 100; + query.Fields = requiredItemFields; + query.ExcludeLocationTypes = 'Virtual'; + + if (query.Ids && query.Ids.split(',').length == 1) { + return JellyfinApi.authAjaxUser(`Items/${query.Ids.split(',')[0]}`, { + type: 'GET', + dataType: 'json' + }).then((item) => { + return { + Items: [item], + TotalRecordCount: 1 + }; + }); + } else { + return JellyfinApi.authAjaxUser('Items', { + query: query, + type: 'GET', + dataType: 'json' + }); + } } /** * Get episodes for a show given by seriesId * - * @param userId userid to use - * @param seriesId series to look up - * @param query query parameters to build on + * @param userId - userid to use + * @param seriesId - series to look up + * @param query - query parameters to build on * @returns episode items */ export function getEpisodesForPlayback( - userId: string, - seriesId: string, - query: ItemQuery = {} + userId: string, + seriesId: string, + query: ItemQuery = {} ): Promise { - query.UserId = userId; - query.Fields = requiredItemFields; - query.ExcludeLocationTypes = 'Virtual'; - - return JellyfinApi.authAjax('Shows/' + seriesId + '/Episodes', { - query: query, - type: 'GET', - dataType: 'json' - }); + query.UserId = userId; + query.Fields = requiredItemFields; + query.ExcludeLocationTypes = 'Virtual'; + + return JellyfinApi.authAjax(`Shows/${seriesId}/Episodes`, { + query: query, + type: 'GET', + dataType: 'json' + }); } /** * Get intros for a given item. This item should be a video * type for this to make sense * - * @param firstItem item to get intros for + * @param firstItem - item to get intros for * @returns intro items */ export function getIntros( - firstItem: BaseItemDto + firstItem: BaseItemDto ): Promise { - return JellyfinApi.authAjaxUser('Items/' + firstItem.Id + '/Intros', { - dataType: 'json', - type: 'GET' - }); + return JellyfinApi.authAjaxUser(`Items/${firstItem.Id}/Intros`, { + dataType: 'json', + type: 'GET' + }); } /** @@ -664,10 +677,10 @@ export function getIntros( * @returns user object */ export function getUser(): Promise { - return JellyfinApi.authAjaxUser('', { - dataType: 'json', - type: 'GET' - }); + return JellyfinApi.authAjaxUser('', { + dataType: 'json', + type: 'GET' + }); } /** @@ -675,101 +688,104 @@ export function getUser(): Promise { * by resolving things like folders to playable items. * * - * @param userId userId to use - * @param items items to resolve - * @param smart If enabled it will try to find the next episode given the + * @param userId - userId to use + * @param items - items to resolve + * @param smart - If enabled it will try to find the next episode given the * current one, if the connected user has enabled that in their settings - * @returns {Promise} Promise for search result containing items to play + * @returns Promise for search result containing items to play */ export async function translateRequestedItems( - userId: string, - items: Array, - smart = false + userId: string, + items: Array, + smart = false ): Promise { - const firstItem = items[0]; - - if (firstItem.Type == 'Playlist') { - return await getItemsForPlayback(userId, { - ParentId: firstItem.Id - }); - } else if (firstItem.Type == 'MusicArtist') { - return await getItemsForPlayback(userId, { - ArtistIds: firstItem.Id, - Filters: 'IsNotFolder', - Recursive: true, - SortBy: 'SortName', - MediaTypes: 'Audio' - }); - } else if (firstItem.Type == 'MusicGenre') { - return await getItemsForPlayback(userId, { - Genres: firstItem.Name ?? undefined, - Filters: 'IsNotFolder', - Recursive: true, - SortBy: 'SortName', - MediaTypes: 'Audio' - }); - } else if (firstItem.IsFolder) { - return await getItemsForPlayback(userId, { - ParentId: firstItem.Id, - Filters: 'IsNotFolder', - Recursive: true, - SortBy: 'SortName', - MediaTypes: 'Audio,Video' - }); - } else if (smart && firstItem.Type == 'Episode' && items.length == 1) { - const user = await getUser(); - - if (!user.Configuration?.EnableNextEpisodeAutoPlay) { - return { - Items: items - }; - } + const firstItem = items[0]; - const result = await getItemsForPlayback(userId, { - Ids: firstItem.Id - }); + if (firstItem.Type == 'Playlist') { + return await getItemsForPlayback(userId, { + ParentId: firstItem.Id + }); + } else if (firstItem.Type == 'MusicArtist') { + return await getItemsForPlayback(userId, { + ArtistIds: firstItem.Id, + Filters: 'IsNotFolder', + Recursive: true, + SortBy: 'SortName', + MediaTypes: 'Audio' + }); + } else if (firstItem.Type == 'MusicGenre') { + return await getItemsForPlayback(userId, { + Genres: firstItem.Name ?? undefined, + Filters: 'IsNotFolder', + Recursive: true, + SortBy: 'SortName', + MediaTypes: 'Audio' + }); + } else if (firstItem.IsFolder) { + return await getItemsForPlayback(userId, { + ParentId: firstItem.Id, + Filters: 'IsNotFolder', + Recursive: true, + SortBy: 'SortName', + MediaTypes: 'Audio,Video' + }); + } else if (smart && firstItem.Type == 'Episode' && items.length == 1) { + const user = await getUser(); - if (!result.Items || result.Items.length < 1) return result; + if (!user.Configuration?.EnableNextEpisodeAutoPlay) { + return { + Items: items + }; + } - const episode = result.Items[0]; + const result = await getItemsForPlayback(userId, { + Ids: firstItem.Id + }); - if (!episode.SeriesId) { - return result; - } + if (!result.Items || result.Items.length < 1) { + return result; + } - const episodesResult = await getEpisodesForPlayback( - userId, - episode.SeriesId, - { - IsVirtualUnaired: false, - IsMissing: false, - UserId: userId - } - ); + const episode = result.Items[0]; - let foundItem = false; - episodesResult.Items = episodesResult.Items?.filter(function ( - e: BaseItemDto - ) { - if (foundItem) { - return true; - } - if (e.Id == episode.Id) { - foundItem = true; - return true; - } + if (!episode.SeriesId) { + return result; + } - return false; - }); + const episodesResult = await getEpisodesForPlayback( + userId, + episode.SeriesId, + { + IsVirtualUnaired: false, + IsMissing: false, + UserId: userId + } + ); - episodesResult.TotalRecordCount = episodesResult.Items?.length || 0; + let foundItem = false; - return episodesResult; - } + episodesResult.Items = episodesResult.Items?.filter((e: BaseItemDto) => { + if (foundItem) { + return true; + } - return { - Items: items - }; + if (e.Id == episode.Id) { + foundItem = true; + + return true; + } + + return false; + }); + + episodesResult.TotalRecordCount = episodesResult.Items?.length || 0; + + return episodesResult; + } + + return { + Items: items + }; } /** @@ -777,15 +793,16 @@ export async function translateRequestedItems( * * TODO can we remove this crap * - * @param target object that gets populated with entries - * @param source object that the entries are copied from - * @returns {any} reference to target object + * @param target - object that gets populated with entries + * @param source - object that the entries are copied from + * @returns reference to target object */ export function extend(target: any, source: any): any { - for (const i in source) { - target[i] = source[i]; - } - return target; + for (const i in source) { + target[i] = source[i]; + } + + return target; } /** @@ -793,39 +810,39 @@ export function extend(target: any, source: any): any { * but could be useful to deal with weird date strings * in the future. * - * @param date string date to parse + * @param date - string date to parse * @returns date object */ export function parseISO8601Date(date: string): Date { - return new Date(date); + return new Date(date); } /** * Send a message over the custom message transport * - * @param message to send + * @param message - to send */ export function broadcastToMessageBus(message: BusMessage): void { - window.castReceiverContext.sendCustomMessage( - 'urn:x-cast:com.connectsdk', - window.senderId, - message - ); + window.castReceiverContext.sendCustomMessage( + 'urn:x-cast:com.connectsdk', + window.senderId, + message + ); } /** * Inform the cast sender that we couldn't connect */ export function broadcastConnectionErrorMessage(): void { - broadcastToMessageBus({ type: 'connectionerror', message: '' }); + broadcastToMessageBus({ type: 'connectionerror', message: '' }); } /** * Remove all special characters from a string * - * @param name input string + * @param name - input string * @returns string with non-whitespace non-word characters removed */ export function cleanName(name: string): string { - return name.replace(/[^\w\s]/gi, ''); + return name.replace(/[^\w\s]/gi, ''); } diff --git a/src/types/global.d.ts b/src/types/global.d.ts index ea259f5f..2113c819 100644 --- a/src/types/global.d.ts +++ b/src/types/global.d.ts @@ -1,24 +1,24 @@ import { cast } from 'chromecast-caf-receiver'; import { - CastReceiverContext, - PlayerManager + CastReceiverContext, + PlayerManager } from 'chromecast-caf-receiver/cast.framework'; import { SystemVolumeData } from 'chromecast-caf-receiver/cast.framework.system'; import { RepeatMode } from '../api/generated/models/repeat-mode'; import { BaseItemDto } from '../api/generated/models/base-item-dto'; export interface DeviceInfo { - deviceId: string | number; - deviceName: string; - versionNumber: string; + deviceId: string | number; + deviceName: string; + versionNumber: string; } export interface GlobalScope { - [key: string]: any; + [key: string]: any; } export interface Dictionary { - [Key: string]: T; + [Key: string]: T; } // Jellyfin Server @@ -26,93 +26,93 @@ export interface Dictionary { /* Combined item query. * Valid for item endpoints */ export interface ItemQuery { - UserId?: string; - Limit?: number; - Fields?: string; - Filters?: string; - Recursive?: boolean; - ExcludeLocationTypes?: string; - Ids?: string; - SortBy?: string; - IsVirtualUnaired?: boolean; - IsMissing?: boolean; - ParentId?: string; - MediaTypes?: string; - Genres?: string; - ArtistIds?: string; + UserId?: string; + Limit?: number; + Fields?: string; + Filters?: string; + Recursive?: boolean; + ExcludeLocationTypes?: string; + Ids?: string; + SortBy?: string; + IsVirtualUnaired?: boolean; + IsMissing?: boolean; + ParentId?: string; + MediaTypes?: string; + Genres?: string; + ArtistIds?: string; } // Messagebus message export interface BusMessage { - type: string; - message?: string; - data?: string; + type: string; + message?: string; + data?: string; } // // For the old queue stuff // export interface ItemIndex { - item: BaseItemDto; - index: number; + item: BaseItemDto; + index: number; } // From commandHandler export interface PlayRequest { - items: BaseItemDto[]; - startPositionTicks: number | undefined; - mediaSourceId: string | undefined; - audioStreamIndex: number | undefined; - subtitleStreamIndex: number | undefined; - liveStreamId: string | undefined; + items: BaseItemDto[]; + startPositionTicks: number | undefined; + mediaSourceId: string | undefined; + audioStreamIndex: number | undefined; + subtitleStreamIndex: number | undefined; + liveStreamId: string | undefined; } export interface DisplayRequest { - ItemId: string; + ItemId: string; } export interface SetIndexRequest { - index: number; + index: number; } export interface SetRepeatModeRequest { - RepeatMode: RepeatMode; + RepeatMode: RepeatMode; } export interface SeekRequest { - position: number; // seconds + position: number; // seconds } export interface DataMessage { - options: - | PlayRequest - | DisplayRequest - | SetIndexRequest - | SetRepeatModeRequest - | SeekRequest; - command: string; + options: + | PlayRequest + | DisplayRequest + | SetIndexRequest + | SetRepeatModeRequest + | SeekRequest; + command: string; } interface SupportedCommands { - [command: string]: (data: DataMessage) => void; + [command: string]: (data: DataMessage) => void; } // /From commandHandler declare global { - export const PRODUCTION: boolean; - export const RECEIVERVERSION: string; - export const $scope: GlobalScope; - export interface Window { - deviceInfo: DeviceInfo; - mediaElement: HTMLElement | null; - mediaManager: PlayerManager; - castReceiverContext: CastReceiverContext; - playlist: Array; - currentPlaylistIndex: number; - repeatMode: RepeatMode; - reportEventType: 'repeatmodechange'; - subtitleAppearance: any; - MaxBitrate: number | undefined; - senderId: string | undefined; - volume: SystemVolumeData; - } + export const PRODUCTION: boolean; + export const RECEIVERVERSION: string; + export const $scope: GlobalScope; + export interface Window { + deviceInfo: DeviceInfo; + mediaElement: HTMLElement | null; + mediaManager: PlayerManager; + castReceiverContext: CastReceiverContext; + playlist: Array; + currentPlaylistIndex: number; + repeatMode: RepeatMode; + reportEventType: 'repeatmodechange'; + subtitleAppearance: any; + MaxBitrate: number | undefined; + senderId: string | undefined; + volume: SystemVolumeData; + } } From e517da3bfd4b640ff5de2511c20f2376bae58aaa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fernando=20Fern=C3=A1ndez?= Date: Tue, 13 Apr 2021 01:43:38 +0200 Subject: [PATCH 03/10] chore(lint): automatic prettier fix --- .ci/azure-pipelines.yml | 334 ++++++++++----------- .github/ISSUE_TEMPLATE/bug_report.md | 8 +- .github/dependabot.yml | 54 ++-- .stylelintrc.json | 4 +- .vscode/settings.json | 2 +- CHANGELOG.md | 6 +- CONTRIBUTING.md | 10 +- src/css/glyphicons.css | 432 +++++++++++++-------------- src/css/jellyfin.css | 324 ++++++++++---------- src/index.html | 113 ++++--- tsconfig-webpack.json | 26 +- tsconfig.json | 35 +-- 12 files changed, 669 insertions(+), 679 deletions(-) diff --git a/.ci/azure-pipelines.yml b/.ci/azure-pipelines.yml index 9d191321..b41b4897 100644 --- a/.ci/azure-pipelines.yml +++ b/.ci/azure-pipelines.yml @@ -1,171 +1,171 @@ trigger: - batch: true - branches: - include: - - '*' - tags: - include: - - '*' + batch: true + branches: + include: + - '*' + tags: + include: + - '*' pr: - branches: - include: - - '*' + branches: + include: + - '*' jobs: - - job: Build - displayName: 'Build' - - strategy: - matrix: - Development: - BuildConfiguration: development - Production: - BuildConfiguration: production - - pool: - vmImage: 'ubuntu-latest' - - steps: - - task: NodeTool@0 - displayName: 'Install Node' - inputs: - versionSpec: '12.x' - - - task: Cache@2 - displayName: 'Check Cache' - inputs: - key: 'npm | package-lock.json' - path: 'node_modules' - cacheHitVar: CACHE_RESTORED - - - script: 'npm ci --no-audit' - displayName: 'Install Dependencies' - condition: ne(variables.CACHE_RESTORED, 'true') - - - script: 'sh ./scripts/updateversion.sh' - displayName: 'Update version in package.json' - - - script: 'npm run build:development' - displayName: 'Build Development' - condition: eq(variables['BuildConfiguration'], 'development') - - - script: 'npm run build:production' - displayName: 'Build Bundle' - condition: eq(variables['BuildConfiguration'], 'production') - - - script: 'test -d dist' - displayName: 'Check Build' - - - script: 'mv dist jellyfin-chromecast' - displayName: 'Rename Directory' - - - task: ArchiveFiles@2 - displayName: 'Archive Directory' - inputs: - rootFolderOrFile: 'jellyfin-chromecast' - includeRootFolder: true - archiveFile: 'jellyfin-chromecast-$(BuildConfiguration)' - - - task: PublishPipelineArtifact@1 - displayName: 'Publish Release' - inputs: - targetPath: '$(Build.SourcesDirectory)/jellyfin-chromecast-$(BuildConfiguration).zip' - artifactName: 'jellyfin-chromecast-$(BuildConfiguration)' - - - job: Publish - displayName: 'Publish' - - dependsOn: Build - condition: or(eq(variables['Build.SourceBranch'], 'refs/heads/master'), startsWith(variables['Build.SourceBranch'], 'refs/tags')) - - strategy: - matrix: - Development: - BuildConfiguration: development - Production: - BuildConfiguration: production - - pool: - vmImage: 'ubuntu-latest' - - steps: - - script: 'echo "##vso[task.setvariable variable=TAG]$(git describe --tags)"' - displayName: 'Set Tag Variable' - - - task: DownloadPipelineArtifact@2 - displayName: 'Download Artifact' - inputs: - source: 'current' - artifact: 'jellyfin-chromecast-$(BuildConfiguration)' - path: '$(System.ArtifactsDirectory)' - runVersion: 'latest' - - - task: GithubRelease@0 - displayName: 'GitHub Upload' - condition: startsWith(variables['Build.SourceBranch'], 'refs/tags') - inputs: - gitHubConnection: Jellyfin Release Download - repositoryName: jellyfin/jellyfin-chromecast - assets: '$(System.ArtifactsDirectory)/*.zip' - action: 'edit' - assetUploadMode: 'replace' - tag: '$(TAG)' - - - task: CopyFilesOverSSH@0 - displayName: 'Upload to Repository' - inputs: - sshEndpoint: repository - sourceFolder: '$(System.ArtifactsDirectory)' - contents: '**' - targetFolder: '/srv/repository/releases/client/chromecast/versions/$(TAG)' - - - task: SSH@0 - displayName: 'Symlink Latest Version' - inputs: - sshEndpoint: repository - runOptions: 'inline' - inline: 'cd /srv/repository/releases/client/chromecast && rm -rf *.zip && ln -s versions/$(TAG)/jellyfin-chromecast-$(BuildConfiguration)-$(TAG).zip .' - - - job: Production - displayName: 'Production' - - dependsOn: Build - condition: or(eq(variables['Build.SourceBranch'], 'refs/heads/master'), startsWith(variables['Build.SourceBranch'], 'refs/tags')) - - pool: - vmImage: 'ubuntu-latest' - - steps: - - task: DownloadPipelineArtifact@2 - displayName: 'Download Artifact' - inputs: - source: 'current' - artifact: 'jellyfin-chromecast-production' - path: '$(System.ArtifactsDirectory)' - runVersion: 'latest' - - - task: ExtractFiles@1 - displayName: 'Extract Source' - inputs: - archiveFilePatterns: '$(System.ArtifactsDirectory)/*.zip' - destinationFolder: '$(System.ArtifactsDirectory)/artifact' - cleanDestinationFolder: true - - - task: CopyFilesOverSSH@0 - displayName: 'Update Nightly' - inputs: - sshEndpoint: chromecast - sourceFolder: '$(System.ArtifactsDirectory)/artifact/jellyfin-chromecast' - cleanTargetFolder: true - contents: '**' - targetFolder: '/srv/chromecast/nightly' - - - task: CopyFilesOverSSH@0 - displayName: 'Update Stable' - condition: startsWith(variables['Build.SourceBranch'], 'refs/tags') - inputs: - sshEndpoint: chromecast - sourceFolder: '$(System.ArtifactsDirectory)/artifact/jellyfin-chromecast' - cleanTargetFolder: true - contents: '**' - targetFolder: '/srv/chromecast/stable' + - job: Build + displayName: 'Build' + + strategy: + matrix: + Development: + BuildConfiguration: development + Production: + BuildConfiguration: production + + pool: + vmImage: 'ubuntu-latest' + + steps: + - task: NodeTool@0 + displayName: 'Install Node' + inputs: + versionSpec: '12.x' + + - task: Cache@2 + displayName: 'Check Cache' + inputs: + key: 'npm | package-lock.json' + path: 'node_modules' + cacheHitVar: CACHE_RESTORED + + - script: 'npm ci --no-audit' + displayName: 'Install Dependencies' + condition: ne(variables.CACHE_RESTORED, 'true') + + - script: 'sh ./scripts/updateversion.sh' + displayName: 'Update version in package.json' + + - script: 'npm run build:development' + displayName: 'Build Development' + condition: eq(variables['BuildConfiguration'], 'development') + + - script: 'npm run build:production' + displayName: 'Build Bundle' + condition: eq(variables['BuildConfiguration'], 'production') + + - script: 'test -d dist' + displayName: 'Check Build' + + - script: 'mv dist jellyfin-chromecast' + displayName: 'Rename Directory' + + - task: ArchiveFiles@2 + displayName: 'Archive Directory' + inputs: + rootFolderOrFile: 'jellyfin-chromecast' + includeRootFolder: true + archiveFile: 'jellyfin-chromecast-$(BuildConfiguration)' + + - task: PublishPipelineArtifact@1 + displayName: 'Publish Release' + inputs: + targetPath: '$(Build.SourcesDirectory)/jellyfin-chromecast-$(BuildConfiguration).zip' + artifactName: 'jellyfin-chromecast-$(BuildConfiguration)' + + - job: Publish + displayName: 'Publish' + + dependsOn: Build + condition: or(eq(variables['Build.SourceBranch'], 'refs/heads/master'), startsWith(variables['Build.SourceBranch'], 'refs/tags')) + + strategy: + matrix: + Development: + BuildConfiguration: development + Production: + BuildConfiguration: production + + pool: + vmImage: 'ubuntu-latest' + + steps: + - script: 'echo "##vso[task.setvariable variable=TAG]$(git describe --tags)"' + displayName: 'Set Tag Variable' + + - task: DownloadPipelineArtifact@2 + displayName: 'Download Artifact' + inputs: + source: 'current' + artifact: 'jellyfin-chromecast-$(BuildConfiguration)' + path: '$(System.ArtifactsDirectory)' + runVersion: 'latest' + + - task: GithubRelease@0 + displayName: 'GitHub Upload' + condition: startsWith(variables['Build.SourceBranch'], 'refs/tags') + inputs: + gitHubConnection: Jellyfin Release Download + repositoryName: jellyfin/jellyfin-chromecast + assets: '$(System.ArtifactsDirectory)/*.zip' + action: 'edit' + assetUploadMode: 'replace' + tag: '$(TAG)' + + - task: CopyFilesOverSSH@0 + displayName: 'Upload to Repository' + inputs: + sshEndpoint: repository + sourceFolder: '$(System.ArtifactsDirectory)' + contents: '**' + targetFolder: '/srv/repository/releases/client/chromecast/versions/$(TAG)' + + - task: SSH@0 + displayName: 'Symlink Latest Version' + inputs: + sshEndpoint: repository + runOptions: 'inline' + inline: 'cd /srv/repository/releases/client/chromecast && rm -rf *.zip && ln -s versions/$(TAG)/jellyfin-chromecast-$(BuildConfiguration)-$(TAG).zip .' + + - job: Production + displayName: 'Production' + + dependsOn: Build + condition: or(eq(variables['Build.SourceBranch'], 'refs/heads/master'), startsWith(variables['Build.SourceBranch'], 'refs/tags')) + + pool: + vmImage: 'ubuntu-latest' + + steps: + - task: DownloadPipelineArtifact@2 + displayName: 'Download Artifact' + inputs: + source: 'current' + artifact: 'jellyfin-chromecast-production' + path: '$(System.ArtifactsDirectory)' + runVersion: 'latest' + + - task: ExtractFiles@1 + displayName: 'Extract Source' + inputs: + archiveFilePatterns: '$(System.ArtifactsDirectory)/*.zip' + destinationFolder: '$(System.ArtifactsDirectory)/artifact' + cleanDestinationFolder: true + + - task: CopyFilesOverSSH@0 + displayName: 'Update Nightly' + inputs: + sshEndpoint: chromecast + sourceFolder: '$(System.ArtifactsDirectory)/artifact/jellyfin-chromecast' + cleanTargetFolder: true + contents: '**' + targetFolder: '/srv/chromecast/nightly' + + - task: CopyFilesOverSSH@0 + displayName: 'Update Stable' + condition: startsWith(variables['Build.SourceBranch'], 'refs/tags') + inputs: + sshEndpoint: chromecast + sourceFolder: '$(System.ArtifactsDirectory)/artifact/jellyfin-chromecast' + cleanTargetFolder: true + contents: '**' + targetFolder: '/srv/chromecast/stable' diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 5a6d5753..7555eb2f 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -33,10 +33,10 @@ assignees: '' **System (please complete the following information):** -- OS: [e.g. Docker, Debian, Windows] -- Browser: [e.g. Firefox, Chrome, Safari] -- Jellyfin Version: [e.g. 10.0.1] -- Cast client: [e.g Ultra] +- OS: [e.g. Docker, Debian, Windows] +- Browser: [e.g. Firefox, Chrome, Safari] +- Jellyfin Version: [e.g. 10.0.1] +- Cast client: [e.g Ultra] **Additional context** diff --git a/.github/dependabot.yml b/.github/dependabot.yml index f05e2b74..1d203f64 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,30 +1,30 @@ version: 2 updates: -# Fetch and update latest `npm` packages -- package-ecosystem: npm - directory: '/' - schedule: - interval: daily - time: '00:00' - open-pull-requests-limit: 10 - reviewers: - - YouKnowBlom - - ferferga - - hawken93 - assignees: - - YouKnowBlom - - ferferga - - hawken93 - commit-message: - prefix: fix - prefix-development: chore - include: scope + # Fetch and update latest `npm` packages + - package-ecosystem: npm + directory: '/' + schedule: + interval: daily + time: '00:00' + open-pull-requests-limit: 10 + reviewers: + - YouKnowBlom + - ferferga + - hawken93 + assignees: + - YouKnowBlom + - ferferga + - hawken93 + commit-message: + prefix: fix + prefix-development: chore + include: scope -- package-ecosystem: github-actions - directory: '/' - schedule: - interval: daily - time: '00:00' - open-pull-requests-limit: 10 - commit-message: - prefix: actions + - package-ecosystem: github-actions + directory: '/' + schedule: + interval: daily + time: '00:00' + open-pull-requests-limit: 10 + commit-message: + prefix: actions diff --git a/.stylelintrc.json b/.stylelintrc.json index c765e3d3..ed191056 100644 --- a/.stylelintrc.json +++ b/.stylelintrc.json @@ -1,4 +1,4 @@ { - "extends": ["stylelint-config-standard", "stylelint-config-prettier"], - "rules": {} + "extends": ["stylelint-config-standard", "stylelint-config-prettier"], + "rules": {} } diff --git a/.vscode/settings.json b/.vscode/settings.json index f89ed5f1..ad92582b 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,3 +1,3 @@ { - "editor.formatOnSave": true + "editor.formatOnSave": true } diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d8fd4ee..e7bdb490 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,6 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ### Added -- Updated web pack config -- Prettier -- Changelog +- Updated web pack config +- Prettier +- Changelog diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index cef39b00..1cdacf43 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -6,14 +6,14 @@ The development environment is setup with editorconfig. Code style is enforced by prettier and eslint for Javascript/Typescript linting -- [editorconfig](https://editorconfig.org/) -- [prettier](https://prettier.io/) -- [eslint](https://eslint.org/) +- [editorconfig](https://editorconfig.org/) +- [prettier](https://prettier.io/) +- [eslint](https://eslint.org/) ### Environment variables -| name | required | description | default if not set | -| ------------- | -------- | ---------------------------------------------------------- | ------------------ | +| name | required | description | default if not set | +| ------------- | -------- | --------------------------------------------------------- | ------------------ | | RECEIVER_PORT | No | The port used for the dev server when `npm start` is used | 9000 | ### Building/Using diff --git a/src/css/glyphicons.css b/src/css/glyphicons.css index 3b891427..e10bbc76 100644 --- a/src/css/glyphicons.css +++ b/src/css/glyphicons.css @@ -1,821 +1,821 @@ @font-face { - font-family: 'Glyphicons Halflings'; - src: url('../fonts/glyphicons-halflings-regular.eot'); - src: url('../fonts/glyphicons-halflings-regular.eot?#iefix') - format('embedded-opentype'), - url('../fonts/glyphicons-halflings-regular.woff') format('woff'), - url('../fonts/glyphicons-halflings-regular.ttf') format('truetype'), - url('../fonts/glyphicons-halflings-regular.svg#glyphicons-halflingsregular') - format('svg'); + font-family: 'Glyphicons Halflings'; + src: url('../fonts/glyphicons-halflings-regular.eot'); + src: url('../fonts/glyphicons-halflings-regular.eot?#iefix') + format('embedded-opentype'), + url('../fonts/glyphicons-halflings-regular.woff') format('woff'), + url('../fonts/glyphicons-halflings-regular.ttf') format('truetype'), + url('../fonts/glyphicons-halflings-regular.svg#glyphicons-halflingsregular') + format('svg'); } .glyphicon { - position: relative; - top: 1px; - display: inline-block; - font-family: 'Glyphicons Halflings'; /* stylelint-disable-line font-family-no-missing-generic-family-keyword */ - font-style: normal; - font-weight: normal; - line-height: 1; - -webkit-font-smoothing: antialiased; + position: relative; + top: 1px; + display: inline-block; + font-family: 'Glyphicons Halflings'; /* stylelint-disable-line font-family-no-missing-generic-family-keyword */ + font-style: normal; + font-weight: normal; + line-height: 1; + -webkit-font-smoothing: antialiased; } .glyphicon-asterisk::before { - content: '\2a'; + content: '\2a'; } .glyphicon-plus::before { - content: '\2b'; + content: '\2b'; } .glyphicon-euro::before { - content: '\20ac'; + content: '\20ac'; } .glyphicon-minus::before { - content: '\2212'; + content: '\2212'; } .glyphicon-cloud::before { - content: '\2601'; + content: '\2601'; } .glyphicon-envelope::before { - content: '\2709'; + content: '\2709'; } .glyphicon-pencil::before { - content: '\270f'; + content: '\270f'; } .glyphicon-glass::before { - content: '\e001'; + content: '\e001'; } .glyphicon-music::before { - content: '\e002'; + content: '\e002'; } .glyphicon-search::before { - content: '\e003'; + content: '\e003'; } .glyphicon-heart::before { - content: '\e005'; + content: '\e005'; } .glyphicon-star::before { - content: '\e006'; + content: '\e006'; } .glyphicon-star-empty::before { - content: '\e007'; + content: '\e007'; } .glyphicon-user::before { - content: '\e008'; + content: '\e008'; } .glyphicon-film::before { - content: '\e009'; + content: '\e009'; } .glyphicon-th-large::before { - content: '\e010'; + content: '\e010'; } .glyphicon-th::before { - content: '\e011'; + content: '\e011'; } .glyphicon-th-list::before { - content: '\e012'; + content: '\e012'; } .glyphicon-ok::before { - content: '\e013'; + content: '\e013'; } .glyphicon-remove::before { - content: '\e014'; + content: '\e014'; } .glyphicon-zoom-in::before { - content: '\e015'; + content: '\e015'; } .glyphicon-zoom-out::before { - content: '\e016'; + content: '\e016'; } .glyphicon-off::before { - content: '\e017'; + content: '\e017'; } .glyphicon-signal::before { - content: '\e018'; + content: '\e018'; } .glyphicon-cog::before { - content: '\e019'; + content: '\e019'; } .glyphicon-trash::before { - content: '\e020'; + content: '\e020'; } .glyphicon-home::before { - content: '\e021'; + content: '\e021'; } .glyphicon-file::before { - content: '\e022'; + content: '\e022'; } .glyphicon-time::before { - content: '\e023'; + content: '\e023'; } .glyphicon-road::before { - content: '\e024'; + content: '\e024'; } .glyphicon-download-alt::before { - content: '\e025'; + content: '\e025'; } .glyphicon-download::before { - content: '\e026'; + content: '\e026'; } .glyphicon-upload::before { - content: '\e027'; + content: '\e027'; } .glyphicon-inbox::before { - content: '\e028'; + content: '\e028'; } .glyphicon-play-circle::before { - content: '\e029'; + content: '\e029'; } .glyphicon-repeat::before { - content: '\e030'; + content: '\e030'; } .glyphicon-refresh::before { - content: '\e031'; + content: '\e031'; } .glyphicon-list-alt::before { - content: '\e032'; + content: '\e032'; } .glyphicon-flag::before { - content: '\e034'; + content: '\e034'; } .glyphicon-headphones::before { - content: '\e035'; + content: '\e035'; } .glyphicon-volume-off::before { - content: '\e036'; + content: '\e036'; } .glyphicon-volume-down::before { - content: '\e037'; + content: '\e037'; } .glyphicon-volume-up::before { - content: '\e038'; + content: '\e038'; } .glyphicon-qrcode::before { - content: '\e039'; + content: '\e039'; } .glyphicon-barcode::before { - content: '\e040'; + content: '\e040'; } .glyphicon-tag::before { - content: '\e041'; + content: '\e041'; } .glyphicon-tags::before { - content: '\e042'; + content: '\e042'; } .glyphicon-book::before { - content: '\e043'; + content: '\e043'; } .glyphicon-print::before { - content: '\e045'; + content: '\e045'; } .glyphicon-font::before { - content: '\e047'; + content: '\e047'; } .glyphicon-bold::before { - content: '\e048'; + content: '\e048'; } .glyphicon-italic::before { - content: '\e049'; + content: '\e049'; } .glyphicon-text-height::before { - content: '\e050'; + content: '\e050'; } .glyphicon-text-width::before { - content: '\e051'; + content: '\e051'; } .glyphicon-align-left::before { - content: '\e052'; + content: '\e052'; } .glyphicon-align-center::before { - content: '\e053'; + content: '\e053'; } .glyphicon-align-right::before { - content: '\e054'; + content: '\e054'; } .glyphicon-align-justify::before { - content: '\e055'; + content: '\e055'; } .glyphicon-list::before { - content: '\e056'; + content: '\e056'; } .glyphicon-indent-left::before { - content: '\e057'; + content: '\e057'; } .glyphicon-indent-right::before { - content: '\e058'; + content: '\e058'; } .glyphicon-facetime-video::before { - content: '\e059'; + content: '\e059'; } .glyphicon-picture::before { - content: '\e060'; + content: '\e060'; } .glyphicon-map-marker::before { - content: '\e062'; + content: '\e062'; } .glyphicon-adjust::before { - content: '\e063'; + content: '\e063'; } .glyphicon-tint::before { - content: '\e064'; + content: '\e064'; } .glyphicon-edit::before { - content: '\e065'; + content: '\e065'; } .glyphicon-share::before { - content: '\e066'; + content: '\e066'; } .glyphicon-check::before { - content: '\e067'; + content: '\e067'; } .glyphicon-move::before { - content: '\e068'; + content: '\e068'; } .glyphicon-step-backward::before { - content: '\e069'; + content: '\e069'; } .glyphicon-fast-backward::before { - content: '\e070'; + content: '\e070'; } .glyphicon-backward::before { - content: '\e071'; + content: '\e071'; } .glyphicon-play::before { - content: '\e072'; + content: '\e072'; } .glyphicon-pause::before { - content: '\e073'; + content: '\e073'; } .glyphicon-stop::before { - content: '\e074'; + content: '\e074'; } .glyphicon-forward::before { - content: '\e075'; + content: '\e075'; } .glyphicon-fast-forward::before { - content: '\e076'; + content: '\e076'; } .glyphicon-step-forward::before { - content: '\e077'; + content: '\e077'; } .glyphicon-eject::before { - content: '\e078'; + content: '\e078'; } .glyphicon-chevron-left::before { - content: '\e079'; + content: '\e079'; } .glyphicon-chevron-right::before { - content: '\e080'; + content: '\e080'; } .glyphicon-plus-sign::before { - content: '\e081'; + content: '\e081'; } .glyphicon-minus-sign::before { - content: '\e082'; + content: '\e082'; } .glyphicon-remove-sign::before { - content: '\e083'; + content: '\e083'; } .glyphicon-ok-sign::before { - content: '\e084'; + content: '\e084'; } .glyphicon-question-sign::before { - content: '\e085'; + content: '\e085'; } .glyphicon-info-sign::before { - content: '\e086'; + content: '\e086'; } .glyphicon-screenshot::before { - content: '\e087'; + content: '\e087'; } .glyphicon-remove-circle::before { - content: '\e088'; + content: '\e088'; } .glyphicon-ok-circle::before { - content: '\e089'; + content: '\e089'; } .glyphicon-ban-circle::before { - content: '\e090'; + content: '\e090'; } .glyphicon-arrow-left::before { - content: '\e091'; + content: '\e091'; } .glyphicon-arrow-right::before { - content: '\e092'; + content: '\e092'; } .glyphicon-arrow-up::before { - content: '\e093'; + content: '\e093'; } .glyphicon-arrow-down::before { - content: '\e094'; + content: '\e094'; } .glyphicon-share-alt::before { - content: '\e095'; + content: '\e095'; } .glyphicon-resize-full::before { - content: '\e096'; + content: '\e096'; } .glyphicon-resize-small::before { - content: '\e097'; + content: '\e097'; } .glyphicon-exclamation-sign::before { - content: '\e101'; + content: '\e101'; } .glyphicon-gift::before { - content: '\e102'; + content: '\e102'; } .glyphicon-leaf::before { - content: '\e103'; + content: '\e103'; } .glyphicon-eye-open::before { - content: '\e105'; + content: '\e105'; } .glyphicon-eye-close::before { - content: '\e106'; + content: '\e106'; } .glyphicon-warning-sign::before { - content: '\e107'; + content: '\e107'; } .glyphicon-plane::before { - content: '\e108'; + content: '\e108'; } .glyphicon-random::before { - content: '\e110'; + content: '\e110'; } .glyphicon-comment::before { - content: '\e111'; + content: '\e111'; } .glyphicon-magnet::before { - content: '\e112'; + content: '\e112'; } .glyphicon-chevron-up::before { - content: '\e113'; + content: '\e113'; } .glyphicon-chevron-down::before { - content: '\e114'; + content: '\e114'; } .glyphicon-retweet::before { - content: '\e115'; + content: '\e115'; } .glyphicon-shopping-cart::before { - content: '\e116'; + content: '\e116'; } .glyphicon-folder-close::before { - content: '\e117'; + content: '\e117'; } .glyphicon-folder-open::before { - content: '\e118'; + content: '\e118'; } .glyphicon-resize-vertical::before { - content: '\e119'; + content: '\e119'; } .glyphicon-resize-horizontal::before { - content: '\e120'; + content: '\e120'; } .glyphicon-hdd::before { - content: '\e121'; + content: '\e121'; } .glyphicon-bullhorn::before { - content: '\e122'; + content: '\e122'; } .glyphicon-certificate::before { - content: '\e124'; + content: '\e124'; } .glyphicon-thumbs-up::before { - content: '\e125'; + content: '\e125'; } .glyphicon-thumbs-down::before { - content: '\e126'; + content: '\e126'; } .glyphicon-hand-right::before { - content: '\e127'; + content: '\e127'; } .glyphicon-hand-left::before { - content: '\e128'; + content: '\e128'; } .glyphicon-hand-up::before { - content: '\e129'; + content: '\e129'; } .glyphicon-hand-down::before { - content: '\e130'; + content: '\e130'; } .glyphicon-circle-arrow-right::before { - content: '\e131'; + content: '\e131'; } .glyphicon-circle-arrow-left::before { - content: '\e132'; + content: '\e132'; } .glyphicon-circle-arrow-up::before { - content: '\e133'; + content: '\e133'; } .glyphicon-circle-arrow-down::before { - content: '\e134'; + content: '\e134'; } .glyphicon-globe::before { - content: '\e135'; + content: '\e135'; } .glyphicon-tasks::before { - content: '\e137'; + content: '\e137'; } .glyphicon-filter::before { - content: '\e138'; + content: '\e138'; } .glyphicon-fullscreen::before { - content: '\e140'; + content: '\e140'; } .glyphicon-dashboard::before { - content: '\e141'; + content: '\e141'; } .glyphicon-heart-empty::before { - content: '\e143'; + content: '\e143'; } .glyphicon-link::before { - content: '\e144'; + content: '\e144'; } .glyphicon-phone::before { - content: '\e145'; + content: '\e145'; } .glyphicon-usd::before { - content: '\e148'; + content: '\e148'; } .glyphicon-gbp::before { - content: '\e149'; + content: '\e149'; } .glyphicon-sort::before { - content: '\e150'; + content: '\e150'; } .glyphicon-sort-by-alphabet::before { - content: '\e151'; + content: '\e151'; } .glyphicon-sort-by-alphabet-alt::before { - content: '\e152'; + content: '\e152'; } .glyphicon-sort-by-order::before { - content: '\e153'; + content: '\e153'; } .glyphicon-sort-by-order-alt::before { - content: '\e154'; + content: '\e154'; } .glyphicon-sort-by-attributes::before { - content: '\e155'; + content: '\e155'; } .glyphicon-sort-by-attributes-alt::before { - content: '\e156'; + content: '\e156'; } .glyphicon-unchecked::before { - content: '\e157'; + content: '\e157'; } .glyphicon-expand::before { - content: '\e158'; + content: '\e158'; } .glyphicon-collapse-down::before { - content: '\e159'; + content: '\e159'; } .glyphicon-collapse-up::before { - content: '\e160'; + content: '\e160'; } .glyphicon-log-in::before { - content: '\e161'; + content: '\e161'; } .glyphicon-flash::before { - content: '\e162'; + content: '\e162'; } .glyphicon-log-out::before { - content: '\e163'; + content: '\e163'; } .glyphicon-new-window::before { - content: '\e164'; + content: '\e164'; } .glyphicon-record::before { - content: '\e165'; + content: '\e165'; } .glyphicon-save::before { - content: '\e166'; + content: '\e166'; } .glyphicon-open::before { - content: '\e167'; + content: '\e167'; } .glyphicon-saved::before { - content: '\e168'; + content: '\e168'; } .glyphicon-import::before { - content: '\e169'; + content: '\e169'; } .glyphicon-export::before { - content: '\e170'; + content: '\e170'; } .glyphicon-send::before { - content: '\e171'; + content: '\e171'; } .glyphicon-floppy-disk::before { - content: '\e172'; + content: '\e172'; } .glyphicon-floppy-saved::before { - content: '\e173'; + content: '\e173'; } .glyphicon-floppy-remove::before { - content: '\e174'; + content: '\e174'; } .glyphicon-floppy-save::before { - content: '\e175'; + content: '\e175'; } .glyphicon-floppy-open::before { - content: '\e176'; + content: '\e176'; } .glyphicon-credit-card::before { - content: '\e177'; + content: '\e177'; } .glyphicon-transfer::before { - content: '\e178'; + content: '\e178'; } .glyphicon-cutlery::before { - content: '\e179'; + content: '\e179'; } .glyphicon-header::before { - content: '\e180'; + content: '\e180'; } .glyphicon-compressed::before { - content: '\e181'; + content: '\e181'; } .glyphicon-earphone::before { - content: '\e182'; + content: '\e182'; } .glyphicon-phone-alt::before { - content: '\e183'; + content: '\e183'; } .glyphicon-tower::before { - content: '\e184'; + content: '\e184'; } .glyphicon-stats::before { - content: '\e185'; + content: '\e185'; } .glyphicon-sd-video::before { - content: '\e186'; + content: '\e186'; } .glyphicon-hd-video::before { - content: '\e187'; + content: '\e187'; } .glyphicon-subtitles::before { - content: '\e188'; + content: '\e188'; } .glyphicon-sound-stereo::before { - content: '\e189'; + content: '\e189'; } .glyphicon-sound-dolby::before { - content: '\e190'; + content: '\e190'; } .glyphicon-sound-5-1::before { - content: '\e191'; + content: '\e191'; } .glyphicon-sound-6-1::before { - content: '\e192'; + content: '\e192'; } .glyphicon-sound-7-1::before { - content: '\e193'; + content: '\e193'; } .glyphicon-copyright-mark::before { - content: '\e194'; + content: '\e194'; } .glyphicon-registration-mark::before { - content: '\e195'; + content: '\e195'; } .glyphicon-cloud-download::before { - content: '\e197'; + content: '\e197'; } .glyphicon-cloud-upload::before { - content: '\e198'; + content: '\e198'; } .glyphicon-tree-conifer::before { - content: '\e199'; + content: '\e199'; } .glyphicon-tree-deciduous::before { - content: '\e200'; + content: '\e200'; } .glyphicon-briefcase::before { - content: '\1f4bc'; + content: '\1f4bc'; } .glyphicon-calendar::before { - content: '\1f4c5'; + content: '\1f4c5'; } .glyphicon-pushpin::before { - content: '\1f4cc'; + content: '\1f4cc'; } .glyphicon-paperclip::before { - content: '\1f4ce'; + content: '\1f4ce'; } .glyphicon-camera::before { - content: '\1f4f7'; + content: '\1f4f7'; } .glyphicon-lock::before { - content: '\1f512'; + content: '\1f512'; } .glyphicon-bell::before { - content: '\1f514'; + content: '\1f514'; } .glyphicon-bookmark::before { - content: '\1f516'; + content: '\1f516'; } .glyphicon-fire::before { - content: '\1f525'; + content: '\1f525'; } .glyphicon-wrench::before { - content: '\1f527'; + content: '\1f527'; } diff --git a/src/css/jellyfin.css b/src/css/jellyfin.css index 1562d008..5f3b209a 100644 --- a/src/css/jellyfin.css +++ b/src/css/jellyfin.css @@ -1,16 +1,16 @@ html, body { - height: 100%; - width: 100%; + height: 100%; + width: 100%; } body { - font-family: 'Quicksand', sans-serif; - font-weight: 300; - color: #ddd; - background-color: #000; - margin: 0; - padding: 0; + font-family: 'Quicksand', sans-serif; + font-weight: 300; + color: #ddd; + background-color: #000; + margin: 0; + padding: 0; } #waiting-container, @@ -19,274 +19,274 @@ body { .details > #video-player, .detailContent, .detailLogo { - /* There is an open bug on the chromecast, transitions are buggy and sometimes are not triggered. + /* There is an open bug on the chromecast, transitions are buggy and sometimes are not triggered. opacity: 0; -webkit-transition: opacity .25s ease-in-out; transition: opacity .25s ease-in-out; */ - display: none; + display: none; } .d-none { - display: none !important; + display: none !important; } #waiting-container-backdrop { - position: absolute; - top: 0; - left: 0; - right: 0; - bottom: 0; - background-color: #000; - background-position: center; - background-size: cover; - background-repeat: no-repeat; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: #000; + background-position: center; + background-size: cover; + background-repeat: no-repeat; } #waiting-container { - background-position: center; - background-size: cover; - background-repeat: no-repeat; + background-position: center; + background-size: cover; + background-repeat: no-repeat; - /* Layer on top of the backdrop image: */ - background-color: rgba(15, 15, 15, 0.6); - position: absolute; - top: 0; - left: 0; - right: 0; - bottom: 0; - padding: 18px 32px; + /* Layer on top of the backdrop image: */ + background-color: rgba(15, 15, 15, 0.6); + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + padding: 18px 32px; } .detailContent { - background-position: center; - background-size: cover; - background-repeat: no-repeat; - position: absolute; - top: 0; - left: 0; - right: 0; - bottom: 0; - background-color: rgba(15, 15, 15, 0.82); + background-position: center; + background-size: cover; + background-repeat: no-repeat; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(15, 15, 15, 0.82); } .detailLogo { - height: 50px; - width: 300px; - background-position: left top; - background-size: contain; - background-repeat: no-repeat; - position: absolute; - top: 35px; - left: 50px; + height: 50px; + width: 300px; + background-position: left top; + background-size: contain; + background-repeat: no-repeat; + position: absolute; + top: 35px; + left: 50px; } .detailImage { - background-position: left top; - background-size: contain; - background-repeat: no-repeat; - position: absolute; - top: 22%; - height: 63%; - left: 8%; - width: 20%; + background-position: left top; + background-size: contain; + background-repeat: no-repeat; + position: absolute; + top: 22%; + height: 63%; + left: 8%; + width: 20%; } .playedIndicator { - display: block; - position: absolute; - top: 5px; - right: 5px; - text-align: center; - width: 1.8vw; - height: 1.6vw; - padding-top: 0.1vw; - border-radius: 50%; - color: #fff; - background: rgba(0, 128, 0, 0.8); - font-size: 1.1vw; + display: block; + position: absolute; + top: 5px; + right: 5px; + text-align: center; + width: 1.8vw; + height: 1.6vw; + padding-top: 0.1vw; + border-radius: 50%; + color: #fff; + background: rgba(0, 128, 0, 0.8); + font-size: 1.1vw; } .detailImageProgressContainer { - position: absolute; - bottom: 10px; - right: 0; - left: 0; - text-align: center; + position: absolute; + bottom: 10px; + right: 0; + left: 0; + text-align: center; } .detailImageProgressContainer progress { - width: 100%; - margin: 0 auto; - height: 6px; + width: 100%; + margin: 0 auto; + height: 6px; } /* Chrome */ .itemProgressBar::-webkit-progress-value { - border-radius: 0; - background-image: none; - background-color: #52b54b; + border-radius: 0; + background-image: none; + background-color: #52b54b; } /* Polyfill */ .itemProgressBar[aria-valuenow]::before { - border-radius: 0; - background-image: none; - background-color: #52b54b; + border-radius: 0; + background-image: none; + background-color: #52b54b; } .itemProgressBar { - background: #000 !important; - appearance: none; - -moz-appearance: none; - -webkit-appearance: none; - border: 0; - border: 0 solid #222; - border-radius: 0; + background: #000 !important; + appearance: none; + -moz-appearance: none; + -webkit-appearance: none; + border: 0; + border: 0 solid #222; + border-radius: 0; } .detailInfo { - position: absolute; - top: 22%; - height: 63%; - left: 30.5%; - font-size: 1.2vw; - width: 60%; + position: absolute; + top: 22%; + height: 63%; + left: 30.5%; + font-size: 1.2vw; + width: 60%; } .detailInfo p { - margin: 10px 0; + margin: 10px 0; } .detailRating { - margin: -4px 0 0; + margin: -4px 0 0; } .displayNameContainer { - margin-top: -6px !important; + margin-top: -6px !important; } .displayName { - font-size: 3vw; + font-size: 3vw; } #miscInfo { - font-size: 1.5vw; - margin-left: 2vw; + font-size: 1.5vw; + margin-left: 2vw; } .starRating { - background-image: url('../img/stars.svg'); - background-position: left center; - background-repeat: no-repeat; - background-size: cover; - width: 1.6vw; - height: 1.4vw; - display: inline-block; - vertical-align: text-bottom; - top: 6px; + background-image: url('../img/stars.svg'); + background-position: left center; + background-repeat: no-repeat; + background-size: cover; + width: 1.6vw; + height: 1.4vw; + display: inline-block; + vertical-align: text-bottom; + top: 6px; } .starRatingValue { - display: inline-block; - margin-left: 1px; + display: inline-block; + margin-left: 1px; } .rottentomatoesicon { - display: inline-block; - width: 1.4vw; - height: 1.4vw; - background-size: cover; - background-position: left center; - background-repeat: no-repeat; - vertical-align: text-bottom; - top: 6px; + display: inline-block; + width: 1.4vw; + height: 1.4vw; + background-size: cover; + background-position: left center; + background-repeat: no-repeat; + vertical-align: text-bottom; + top: 6px; } .starRatingValue + .rottentomatoesicon { - margin-left: 1em; + margin-left: 1em; } .fresh { - background-image: url('../img/fresh.svg'); + background-image: url('../img/fresh.svg'); } .rotten { - background-image: url('../img/rotten.svg'); + background-image: url('../img/rotten.svg'); } .metascorehigh { - background-color: rgba(102, 204, 51, 0.7); + background-color: rgba(102, 204, 51, 0.7); } .metascoremid { - background-color: rgba(255, 204, 51, 0.7); + background-color: rgba(255, 204, 51, 0.7); } .metascorelow { - background-color: rgba(240, 0, 0, 0.7); + background-color: rgba(240, 0, 0, 0.7); } .criticRating + .metascore, .starRatingValue + .metascore { - margin-left: 1em; + margin-left: 1em; } .criticRating { - display: inline-block; - margin-left: 1px; + display: inline-block; + margin-left: 1px; } .overview { - max-height: 350px; - overflow: hidden; - text-overflow: ellipsis; + max-height: 350px; + overflow: hidden; + text-overflow: ellipsis; } /* Container for "ready to cast" and the logo */ .waitingContent { - position: fixed; - bottom: 0; - left: 0; - text-align: center; - font-size: 3vw; - margin-bottom: 3%; - margin-left: 5%; + position: fixed; + bottom: 0; + left: 0; + text-align: center; + font-size: 3vw; + margin-bottom: 3%; + margin-left: 5%; } /* Container for backdrop description */ .waitingDescription { - position: fixed; - bottom: 0; - right: 0; - margin-right: 5%; - margin-bottom: 3%; - font-size: 1.5vw; + position: fixed; + bottom: 0; + right: 0; + margin-right: 5%; + margin-bottom: 3%; + font-size: 1.5vw; } #waiting-container h1, #waiting-container h2 { - margin: 25px 0; + margin: 25px 0; } #waiting-container h1 { - font-size: 45px; - font-weight: 300; + font-size: 45px; + font-weight: 300; } /* stylelint-disable no-descending-specificity */ .error-container h2, #waiting-container h2 { - font-size: 30px; - font-weight: 300; + font-size: 30px; + font-weight: 300; } /* stylelint-enable no-descending-specificity */ /* jellyfin logo in the waiting container */ #waiting-container .logo { - width: 4vw; - display: inline-block; - vertical-align: text-bottom; + width: 4vw; + display: inline-block; + vertical-align: text-bottom; } .waiting > #waiting-container-backdrop, @@ -294,18 +294,18 @@ body { .details .detailContent, .details .detailLogo, .details #waiting-container-backdrop { - /* opacity: 1; */ - display: initial; + /* opacity: 1; */ + display: initial; } /* stylelint-disable selector-type-no-unknown */ cast-media-player { - --spinner-image: url('../img/spinner.png'); - --playback-logo-image: url('../img/banner.svg'); - --watermark-image: url('../img/banner.svg'); - --watermark-size: 225px; - --watermark-position: top right; - --theme-hue: 195.3; /* Jellyfin blue */ - --progress-color: #00a4dc; + --spinner-image: url('../img/spinner.png'); + --playback-logo-image: url('../img/banner.svg'); + --watermark-image: url('../img/banner.svg'); + --watermark-size: 225px; + --watermark-position: top right; + --theme-hue: 195.3; /* Jellyfin blue */ + --progress-color: #00a4dc; } /* stylelint-enable selector-type-no-unknown */ diff --git a/src/index.html b/src/index.html index 339c68a2..a02f4e0b 100644 --- a/src/index.html +++ b/src/index.html @@ -1,66 +1,59 @@ - - - Jellyfin - - - - - -
-
-
- - Ready to cast -
-
-
- -
-
-
-
+ + + Jellyfin + + + + + +
+
+
+ + Ready to cast +
+
+
+ +
+
+
+
-
- -
-
+
+ +
+
-
-

- -

-
-
-
-
-
-
-

-

-
+
+

+ +

+
+
+
+
+
- - +

+

+
+
+ + diff --git a/tsconfig-webpack.json b/tsconfig-webpack.json index f8add636..acf696cf 100644 --- a/tsconfig-webpack.json +++ b/tsconfig-webpack.json @@ -1,15 +1,15 @@ { - "compilerOptions": { - "module": "commonjs", - "target": "es5", - "esModuleInterop": true, - "resolveJsonModule": true, - "allowSyntheticDefaultImports": true, - "types": [ - "@types/node", - "@types/webpack", - "@types/webpack-dev-server", - "@types/webpack-merge" - ] - } + "compilerOptions": { + "module": "commonjs", + "target": "es5", + "esModuleInterop": true, + "resolveJsonModule": true, + "allowSyntheticDefaultImports": true, + "types": [ + "@types/node", + "@types/webpack", + "@types/webpack-dev-server", + "@types/webpack-merge" + ] + } } diff --git a/tsconfig.json b/tsconfig.json index d7578dac..ff7ba7f4 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,22 +1,19 @@ { - "compilerOptions": { - "target": "ES2018", - "module": "ESNext", - "moduleResolution": "Node", - "lib": ["dom", "ESNext"], - "resolveJsonModule": true, - "allowSyntheticDefaultImports": true, - "allowJs": true, - "sourceMap": true, - "outDir": "./dist/", - "strict": true, - "baseUrl": "./src", - "paths": { - "~/*": ["./*"] - }, - "types": [ - "@types/node", - "@types/chromecast-caf-receiver" - ] + "compilerOptions": { + "target": "ES2018", + "module": "ESNext", + "moduleResolution": "Node", + "lib": ["dom", "ESNext"], + "resolveJsonModule": true, + "allowSyntheticDefaultImports": true, + "allowJs": true, + "sourceMap": true, + "outDir": "./dist/", + "strict": true, + "baseUrl": "./src", + "paths": { + "~/*": ["./*"] + }, + "types": ["@types/node", "@types/chromecast-caf-receiver"] } } From 19b417873769086902f30b6011247cdd89de8839 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fernando=20Fern=C3=A1ndez?= Date: Tue, 13 Apr 2021 00:21:57 +0200 Subject: [PATCH 04/10] chore(lint): manual lint fixes --- src/api/credentialManager.ts | 3 +- src/components/documentManager.ts | 80 +++++----- src/components/fetchhelper.ts | 89 +++++------ src/components/jellyfinActions.ts | 45 +++--- src/components/maincontroller.ts | 256 +++++++++++++++--------------- src/helpers.ts | 31 ++-- src/types/global.d.ts | 2 +- 7 files changed, 245 insertions(+), 261 deletions(-) diff --git a/src/api/credentialManager.ts b/src/api/credentialManager.ts index 7016266f..8f0ef9d8 100644 --- a/src/api/credentialManager.ts +++ b/src/api/credentialManager.ts @@ -16,8 +16,7 @@ export class credentialManager { * Get credentials for the provided server ID * * @param serverId - ID of the server the credentials belong to - * @returns Credentials for the provided server ID - * or undefined if the store has no server with that ID + * @returns Credentials for the provided server ID or undefined if the store has no server with that ID */ get(serverId: string): Configuration | undefined { if (serverId in this.credentialStore) { diff --git a/src/components/documentManager.ts b/src/components/documentManager.ts index 3f361e35..4ae57a75 100644 --- a/src/components/documentManager.ts +++ b/src/components/documentManager.ts @@ -129,55 +129,55 @@ export abstract class DocumentManager { * @param item - to show information about * @returns for the page to load */ - public static showItem(item: BaseItemDto): Promise { + public static async showItem(item: BaseItemDto): Promise { // no showItem for cc audio if (getActiveDeviceId() === deviceIds.AUDIO) { - return Promise.resolve(); + return; } // stop cycling backdrops this.clearBackdropInterval(); - return Promise.all([ - this.getWaitingBackdropUrl(item), - this.getPrimaryImageUrl(item), - this.getLogoUrl(item) - ]).then((urls) => { - requestAnimationFrame(() => { - this.setWaitingBackdrop(urls[0], item); - this.setDetailImage(urls[1]); - this.setLogo(urls[2]); - - this.setOverview(item.Overview ?? null); - this.setGenres(item?.Genres?.join(' / ') ?? null); - this.setDisplayName(item); - this.setMiscInfo(item); - - this.setRating(item); - - if (item?.UserData?.Played) { - this.setPlayedIndicator(true); - } else if (item?.UserData?.UnplayedItemCount) { - this.setPlayedIndicator(item?.UserData?.UnplayedItemCount); - } else { - this.setPlayedIndicator(false); - } + const urls = [ + await this.getWaitingBackdropUrl(item), + await this.getPrimaryImageUrl(item), + await this.getLogoUrl(item) + ]; - if ( - item?.UserData?.PlayedPercentage && - item?.UserData?.PlayedPercentage < 100 && - !item.IsFolder - ) { - this.setHasPlayedPercentage(false); - this.setPlayedPercentage(item.UserData.PlayedPercentage); - } else { - this.setHasPlayedPercentage(false); - this.setPlayedPercentage(0); - } + requestAnimationFrame(() => { + this.setWaitingBackdrop(urls[0], item); + this.setDetailImage(urls[1]); + this.setLogo(urls[2]); - // Switch visible view! - this.setAppStatus('details'); - }); + this.setOverview(item.Overview ?? null); + this.setGenres(item?.Genres?.join(' / ') ?? null); + this.setDisplayName(item); + this.setMiscInfo(item); + + this.setRating(item); + + if (item?.UserData?.Played) { + this.setPlayedIndicator(true); + } else if (item?.UserData?.UnplayedItemCount) { + this.setPlayedIndicator(item?.UserData?.UnplayedItemCount); + } else { + this.setPlayedIndicator(false); + } + + if ( + item?.UserData?.PlayedPercentage && + item?.UserData?.PlayedPercentage < 100 && + !item.IsFolder + ) { + this.setHasPlayedPercentage(false); + this.setPlayedPercentage(item.UserData.PlayedPercentage); + } else { + this.setHasPlayedPercentage(false); + this.setPlayedPercentage(0); + } + + // Switch visible view! + this.setAppStatus('details'); }); } diff --git a/src/components/fetchhelper.ts b/src/components/fetchhelper.ts index 1e829a88..0195e5b6 100644 --- a/src/components/fetchhelper.ts +++ b/src/components/fetchhelper.ts @@ -41,7 +41,7 @@ function getFetchPromise(request: any): Promise { } return request.timeout - ? fetchWithTimeout(url, fetchRequest, request.timeout) + ? fetchWithCredentials(url, fetchRequest) : fetch(url, fetchRequest); } @@ -50,34 +50,28 @@ function getFetchPromise(request: any): Promise { * * @param url - url to get * @param options - RequestInit with additional options - * @param timeoutMs - request timeout in ms * @returns response promise */ -function fetchWithTimeout( +async function fetchWithCredentials( url: string, - options: RequestInit, - timeoutMs: number + options: RequestInit ): Promise { - console.log(`fetchWithTimeout: timeoutMs: ${timeoutMs}, url: ${url}`); - - return new Promise((resolve, reject) => { - const timeout = setTimeout(reject, timeoutMs); + console.log(`fetchWithCredentials: ${url}`); + try { options = options || {}; options.credentials = 'same-origin'; - fetch(url, options).then( - (response) => { - clearTimeout(timeout); - console.log(`fetchWithTimeout: succeeded connecting to url: ${url}`); - resolve(response); - }, - () => { - clearTimeout(timeout); - console.log(`fetchWithTimeout: timed out connecting to url: ${url}`); - reject(); - } + + const response = await fetch(url, options); + + console.log(`fetchWithCredentials: succeeded connecting to url: ${url}`); + + return response; + } catch (e) { + throw new Error( + `fetchWithCredentials: timed out connecting to url: ${url}` ); - }); + } } /** @@ -107,7 +101,7 @@ function paramsToString(params: Record): string { * @param request - RequestInit-like structure but with url/type/timeout parameters as well * @returns response promise, may be automatically unpacked based on request datatype */ -export function ajax(request: any): Promise { +export async function ajax(request: any): Promise { if (!request) { throw new Error('Request cannot be null'); } @@ -115,31 +109,30 @@ export function ajax(request: any): Promise { request.headers = request.headers || {}; console.log(`requesting url: ${request.url}`); - return getFetchPromise(request).then( - (response: Response) => { - console.log(`response status: ${response.status}, url: ${request.url}`); - - if (response.status >= 400) { - return Promise.reject(response); - } else if ( - request.dataType === 'json' || - request.headers?.accept === 'application/json' - ) { - return response.json(); - } else if ( - request.dataType === 'text' || - (response.headers.get('Content-Type') || '') - .toLowerCase() - .indexOf('text/') === 0 - ) { - return response.text(); - } else { - return response; - } - }, - (err) => { - console.log(`request failed to url: ${request.url}`); - throw err; + try { + const response = await getFetchPromise(request); + + console.log(`response status: ${response.status}, url: ${request.url}`); + + if (response.status >= 400) { + return Promise.reject(response); + } else if ( + request.dataType === 'json' || + request.headers?.accept === 'application/json' + ) { + return response.json(); + } else if ( + request.dataType === 'text' || + (response.headers.get('Content-Type') || '') + .toLowerCase() + .indexOf('text/') === 0 + ) { + return response.text(); + } else { + return response; } - ); + } catch (err) { + console.log(`request failed to url: ${request.url}`); + throw err; + } } diff --git a/src/components/jellyfinActions.ts b/src/components/jellyfinActions.ts index a9f84811..0f51ba0d 100644 --- a/src/components/jellyfinActions.ts +++ b/src/components/jellyfinActions.ts @@ -364,26 +364,21 @@ export function getLiveStream( * @param byteSize - number of bytes to request * @returns the bitrate in bits/s */ -export function getDownloadSpeed(byteSize: number): Promise { +export async function getDownloadSpeed(byteSize: number): Promise { const path = `Playback/BitrateTest?size=${byteSize}`; const now = new Date().getTime(); - return JellyfinApi.authAjax(path, { + await JellyfinApi.authAjax(path, { type: 'GET', timeout: 5000 - }) - .then((response) => { - // Need to wait for the whole response before calculating speed - return response.blob(); - }) - .then(() => { - const responseTimeSeconds = (new Date().getTime() - now) / 1000; - const bytesPerSecond = byteSize / responseTimeSeconds; - const bitrate = Math.round(bytesPerSecond * 8); - - return bitrate; - }); + }); + + const responseTimeSeconds = (new Date().getTime() - now) / 1000; + const bytesPerSecond = byteSize / responseTimeSeconds; + const bitrate = Math.round(bytesPerSecond * 8); + + return bitrate; } /** @@ -392,18 +387,18 @@ export function getDownloadSpeed(byteSize: number): Promise { * * @returns bitrate in bits/s */ -export function detectBitrate(): Promise { +export async function detectBitrate(): Promise { // First try a small amount so that we don't hang up their mobile connection - return getDownloadSpeed(1000000).then((bitrate) => { - if (bitrate < 1000000) { - return Math.round(bitrate * 0.8); - } else { - // If that produced a fairly high speed, try again with a larger size to get a more accurate result - return getDownloadSpeed(2400000).then((bitrate) => { - return Math.round(bitrate * 0.8); - }); - } - }); + + let bitrate = await getDownloadSpeed(1000000); + + if (bitrate < 1000000) { + return Math.round(bitrate * 0.8); + } + + bitrate = await getDownloadSpeed(2400000); + + return Math.round(bitrate * 0.8); } /** diff --git a/src/components/maincontroller.ts b/src/components/maincontroller.ts index 07726d58..54b161fb 100644 --- a/src/components/maincontroller.ts +++ b/src/components/maincontroller.ts @@ -27,8 +27,6 @@ import { playbackManager } from './playbackManager'; import { CommandHandler } from './commandHandler'; import { getMaxBitrateSupport } from './codecSupportHelper'; import { DocumentManager } from './documentManager'; - -import { BaseItemDtoQueryResult } from '~/api/generated/models/base-item-dto-query-result'; import { BaseItemDto } from '~/api/generated/models/base-item-dto'; import { MediaSourceInfo } from '~/api/generated/models/media-source-info'; import { GlobalScope, PlayRequest } from '~/types/global'; @@ -207,27 +205,27 @@ window.mediaManager.addEventListener( /** * */ -export function reportDeviceCapabilities(): Promise { - return getMaxBitrate().then((maxBitrate) => { - const deviceProfile = getDeviceProfile({ - enableHls: true, - bitrateSetting: maxBitrate - }); +export async function reportDeviceCapabilities(): Promise { + const maxBitrate = await getMaxBitrate(); + + const deviceProfile = getDeviceProfile({ + enableHls: true, + bitrateSetting: maxBitrate + }); - const capabilities = { - PlayableMediaTypes: ['Audio', 'Video'], - SupportsPersistentIdentifier: false, - SupportsMediaControl: true, - DeviceProfile: deviceProfile - }; + const capabilities = { + PlayableMediaTypes: ['Audio', 'Video'], + SupportsPersistentIdentifier: false, + SupportsMediaControl: true, + DeviceProfile: deviceProfile + }; - hasReportedCapabilities = true; + hasReportedCapabilities = true; - return JellyfinApi.authAjax('Sessions/Capabilities/Full', { - type: 'POST', - data: JSON.stringify(capabilities), - contentType: 'application/json' - }); + return JellyfinApi.authAjax('Sessions/Capabilities/Full', { + type: 'POST', + data: JSON.stringify(capabilities), + contentType: 'application/json' }); } @@ -414,7 +412,7 @@ export function seek(ticks: number): Promise { * @param ticks * @param params */ -export function changeStream( +export async function changeStream( ticks: number, params: any = undefined ): Promise { @@ -434,68 +432,67 @@ export function changeStream( const liveStreamId = $scope.liveStreamId; const item = $scope.item; + const maxBitrate = await getMaxBitrate(); - return getMaxBitrate().then(async (maxBitrate) => { - const deviceProfile = getDeviceProfile({ - enableHls: true, - bitrateSetting: maxBitrate - }); - const audioStreamIndex = - params.AudioStreamIndex == null - ? $scope.audioStreamIndex - : params.AudioStreamIndex; - const subtitleStreamIndex = - params.SubtitleStreamIndex == null - ? $scope.subtitleStreamIndex - : params.SubtitleStreamIndex; - - const playbackInformation = await getPlaybackInfo( - item, - maxBitrate, - deviceProfile, - ticks, - $scope.mediaSourceId, - audioStreamIndex, - subtitleStreamIndex, - liveStreamId - ); + const deviceProfile = getDeviceProfile({ + enableHls: true, + bitrateSetting: maxBitrate + }); + const audioStreamIndex = + params.AudioStreamIndex == null + ? $scope.audioStreamIndex + : params.AudioStreamIndex; + const subtitleStreamIndex = + params.SubtitleStreamIndex == null + ? $scope.subtitleStreamIndex + : params.SubtitleStreamIndex; + + const playbackInformation = await getPlaybackInfo( + item, + maxBitrate, + deviceProfile, + ticks, + $scope.mediaSourceId, + audioStreamIndex, + subtitleStreamIndex, + liveStreamId + ); - if (!validatePlaybackInfoResult(playbackInformation)) { - return; - } + if (!validatePlaybackInfoResult(playbackInformation)) { + return; + } - const mediaSource = playbackInformation.MediaSources[0]; - const streamInfo = createStreamInfo(item, mediaSource, ticks); + const mediaSource = playbackInformation.MediaSources[0]; + const streamInfo = createStreamInfo(item, mediaSource, ticks); - if (!streamInfo.url) { - showPlaybackInfoErrorMessage('NoCompatibleStream'); + if (!streamInfo.url) { + showPlaybackInfoErrorMessage('NoCompatibleStream'); - return; - } + return; + } - const mediaInformation = createMediaInformation( - playSessionId, - item, - streamInfo - ); - const loadRequest = new cast.framework.messages.LoadRequestData(); + const mediaInformation = createMediaInformation( + playSessionId, + item, + streamInfo + ); + const loadRequest = new cast.framework.messages.LoadRequestData(); - loadRequest.media = mediaInformation; - loadRequest.autoplay = true; + loadRequest.media = mediaInformation; + loadRequest.autoplay = true; - // TODO something to do with HLS? - const requiresStoppingTranscoding = false; + // TODO something to do with HLS? + const requiresStoppingTranscoding = false; - if (requiresStoppingTranscoding) { - window.mediaManager.pause(); - await stopActiveEncodings(playSessionId); - } + if (requiresStoppingTranscoding) { + window.mediaManager.pause(); + await stopActiveEncodings(playSessionId); + } - window.mediaManager.load(loadRequest); - window.mediaManager.play(); - $scope.subtitleStreamIndex = subtitleStreamIndex; - $scope.audioStreamIndex = audioStreamIndex; - }); + window.mediaManager.load(loadRequest); + window.mediaManager.play(); + $scope.subtitleStreamIndex = subtitleStreamIndex; + $scope.audioStreamIndex = audioStreamIndex; } // Create a message handler for the custome namespace channel @@ -527,28 +524,30 @@ window.castReceiverContext.addCustomMessageListener( * @param options * @param method */ -export function translateItems( +export async function translateItems( data: any, options: PlayRequest, method: string ): Promise { const playNow = method != 'PlayNext' && method != 'PlayLast'; - return translateRequestedItems(data.userId, options.items, playNow).then( - (result: BaseItemDtoQueryResult) => { - if (result.Items) { - options.items = result.Items; - } + const result = await translateRequestedItems( + data.userId, + options.items, + playNow + ); - if (method == 'PlayNext' || method == 'PlayLast') { - for (let i = 0, length = options.items.length; i < length; i++) { - window.playlist.push(options.items[i]); - } - } else { - playbackMgr.playFromOptions(data.options); - } + if (result.Items) { + options.items = result.Items; + } + + if (method == 'PlayNext' || method == 'PlayLast') { + for (let i = 0, length = options.items.length; i < length; i++) { + window.playlist.push(options.items[i]); } - ); + } else { + playbackMgr.playFromOptions(data.options); + } } /** @@ -556,15 +555,15 @@ export function translateItems( * @param options * @param item */ -export function instantMix( +export async function instantMix( data: any, options: any, item: BaseItemDto ): Promise { - return getInstantMixItems(data.userId, item).then((result) => { - options.items = result.Items; - playbackMgr.playFromOptions(data.options); - }); + const result = await getInstantMixItems(data.userId, item); + + options.items = result.Items; + playbackMgr.playFromOptions(data.options); } /** @@ -572,34 +571,34 @@ export function instantMix( * @param options * @param item */ -export function shuffle( +export async function shuffle( data: any, options: any, item: BaseItemDto ): Promise { - return getShuffleItems(data.userId, item).then((result) => { - options.items = result.Items; - playbackMgr.playFromOptions(data.options); - }); + const result = await getShuffleItems(data.userId, item); + + options.items = result.Items; + playbackMgr.playFromOptions(data.options); } /** * @param item * @param options */ -export function onStopPlayerBeforePlaybackDone( +export async function onStopPlayerBeforePlaybackDone( item: BaseItemDto, options: any ): Promise { - return JellyfinApi.authAjaxUser(`Items/${item.Id}`, { + const data = await JellyfinApi.authAjaxUser(`Items/${item.Id}`, { dataType: 'json', type: 'GET' - }).then((data) => { - // Attach the custom properties we created like userId, serverAddress, itemId, etc - extend(data, item); + }); - playbackMgr.playItemInternal(data, options); - }, broadcastConnectionErrorMessage); + // Attach the custom properties we created like userId, serverAddress, itemId, etc + extend(data, item); + playbackMgr.playItemInternal(data, options); + broadcastConnectionErrorMessage(); } let lastBitrateDetect = 0; @@ -607,42 +606,37 @@ let detectedBitrate = 0; /** * */ -export function getMaxBitrate(): Promise { +export async function getMaxBitrate(): Promise { console.log('getMaxBitrate'); - return new Promise((resolve) => { - // The client can set this number - if (window.MaxBitrate) { - console.log(`bitrate is set to ${window.MaxBitrate}`); + if (window.MaxBitrate) { + console.log(`bitrate is set to ${window.MaxBitrate}`); - resolve(window.MaxBitrate); + return window.MaxBitrate; + } - return; - } + if (detectedBitrate && new Date().getTime() - lastBitrateDetect < 600000) { + console.log(`returning previous detected bitrate of ${detectedBitrate}`); - if (detectedBitrate && new Date().getTime() - lastBitrateDetect < 600000) { - console.log(`returning previous detected bitrate of ${detectedBitrate}`); - resolve(detectedBitrate); + return detectedBitrate; + } - return; - } + console.log('detecting bitrate'); + + const bitrate = await detectBitrate(); - console.log('detecting bitrate'); + try { + console.log(`Max bitrate auto detected to ${bitrate}`); + lastBitrateDetect = new Date().getTime(); + detectedBitrate = bitrate; - detectBitrate().then( - (bitrate) => { - console.log(`Max bitrate auto detected to ${bitrate}`); - lastBitrateDetect = new Date().getTime(); - detectedBitrate = bitrate; + return detectedBitrate; + } catch (e) { + // The client can set this number + console.log('Error detecting bitrate, will return device maximum.'); - resolve(detectedBitrate); - }, - () => { - console.log('Error detecting bitrate, will return device maximum.'); - resolve(getMaxBitrateSupport()); - } - ); - }); + return getMaxBitrateSupport(); + } } /** diff --git a/src/helpers.ts b/src/helpers.ts index 1558efbf..9ce2015f 100644 --- a/src/helpers.ts +++ b/src/helpers.ts @@ -559,7 +559,7 @@ export function getShuffleItems( * @param item - Parent item of the search * @returns items for the queue */ -export function getInstantMixItems( +export async function getInstantMixItems( userId: string, item: BaseItemDto ): Promise { @@ -592,7 +592,7 @@ export function getInstantMixItems( dataType: 'json' }); } else { - return Promise.reject(`InstantMix: Unknown item type: ${item.Type}`); + throw new Error(`InstantMix: Unknown item type: ${item.Type}`); } } @@ -603,7 +603,7 @@ export function getInstantMixItems( * @param query - specification on what to search for * @returns items to be played back */ -export function getItemsForPlayback( +export async function getItemsForPlayback( userId: string, query: ItemQuery ): Promise { @@ -613,15 +613,18 @@ export function getItemsForPlayback( query.ExcludeLocationTypes = 'Virtual'; if (query.Ids && query.Ids.split(',').length == 1) { - return JellyfinApi.authAjaxUser(`Items/${query.Ids.split(',')[0]}`, { - type: 'GET', - dataType: 'json' - }).then((item) => { - return { - Items: [item], - TotalRecordCount: 1 - }; - }); + const item = await JellyfinApi.authAjaxUser( + `Items/${query.Ids.split(',')[0]}`, + { + type: 'GET', + dataType: 'json' + } + ); + + return { + Items: [item], + TotalRecordCount: 1 + }; } else { return JellyfinApi.authAjaxUser('Items', { query: query, @@ -690,8 +693,8 @@ export function getUser(): Promise { * * @param userId - userId to use * @param items - items to resolve - * @param smart - If enabled it will try to find the next episode given the - * current one, if the connected user has enabled that in their settings + * @param smart - If enabled it will try to find the next episode given the current one, + * if the connected user has enabled that in their settings * @returns Promise for search result containing items to play */ export async function translateRequestedItems( diff --git a/src/types/global.d.ts b/src/types/global.d.ts index 2113c819..33090d55 100644 --- a/src/types/global.d.ts +++ b/src/types/global.d.ts @@ -1,4 +1,3 @@ -import { cast } from 'chromecast-caf-receiver'; import { CastReceiverContext, PlayerManager @@ -25,6 +24,7 @@ export interface Dictionary { // Why doesn't the API have a type for this? /* Combined item query. * Valid for item endpoints */ +// TODO: API has an endpoint for this. Replace on https://github.com/jellyfin/jellyfin-chromecast/pull/109 export interface ItemQuery { UserId?: string; Limit?: number; From 907d244a53235f2dcad56eb725adf7a14c0e33da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fernando=20Fern=C3=A1ndez?= Date: Thu, 15 Apr 2021 00:13:09 +0200 Subject: [PATCH 05/10] chore: add jsdoc/require-param-description rule --- .eslintrc.js | 1 + 1 file changed, 1 insertion(+) diff --git a/.eslintrc.js b/.eslintrc.js index 7c149d25..09a0deff 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -23,6 +23,7 @@ module.exports = { 'import/order': 'error', 'jsdoc/require-hyphen-before-param-description': 'error', 'jsdoc/require-description': 'warn', + 'jsdoc/require-param-description': 'warn', 'jsdoc/require-jsdoc': 'error', //TypeScript and IntelliSense already provides us information about the function typings while hovering and // eslint-jsdoc doesn't detect a mismatch between what's declared in the function and what's declared in From e5c97147c8558cec5ec05d1c6caadb1ddf553f6d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fernando=20Fern=C3=A1ndez?= Date: Thu, 13 May 2021 11:30:14 +0200 Subject: [PATCH 06/10] chore: Revert identation changes from 2 to 4 --- .editorconfig | 5 +---- .prettierignore | 1 + .prettierrc | 1 + 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/.editorconfig b/.editorconfig index 4a7ea303..3c44241c 100644 --- a/.editorconfig +++ b/.editorconfig @@ -2,11 +2,8 @@ root = true [*] indent_style = space -indent_size = 2 +indent_size = 4 end_of_line = lf charset = utf-8 trim_trailing_whitespace = true insert_final_newline = true - -[*.md] -trim_trailing_whitespace = false diff --git a/.prettierignore b/.prettierignore index 05c049de..7e8856fc 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,5 +1,6 @@ dist/ node_modules/ src/api/generated/ +package-lock.json LICENSE.md diff --git a/.prettierrc b/.prettierrc index 38bc8e09..e953a718 100644 --- a/.prettierrc +++ b/.prettierrc @@ -1,5 +1,6 @@ { "semi": true, "singleQuote": true, + "tabWidth": 4, "trailingComma": "none" } From bd84bdc251f66c9e23490e0895d2f64a9d08e50f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fernando=20Fern=C3=A1ndez?= Date: Thu, 13 May 2021 11:31:05 +0200 Subject: [PATCH 07/10] chore: automatic prettier fix --- .ci/azure-pipelines.yml | 334 +++--- .eslintrc.js | 216 ++-- .github/ISSUE_TEMPLATE/bug_report.md | 8 +- .github/dependabot.yml | 54 +- .github/workflows/lint.yaml | 74 +- .github/workflows/test.yaml | 74 +- .prettierrc | 8 +- .stylelintrc.json | 4 +- .vscode/settings.json | 2 +- CHANGELOG.md | 6 +- CONTRIBUTING.md | 6 +- commitlint.config.js | 2 +- jest.config.js | 4 +- package.json | 162 +-- src/api/credentialManager.ts | 114 +- src/app.ts | 10 +- src/components/castDevices.ts | 48 +- src/components/codecSupportHelper.ts | 146 +-- src/components/commandHandler.ts | 406 +++---- src/components/deviceprofileBuilder.ts | 599 +++++----- src/components/documentManager.ts | 1450 ++++++++++++------------ src/components/fetchhelper.ts | 179 +-- src/components/jellyfinActions.ts | 472 ++++---- src/components/jellyfinApi.ts | 240 ++-- src/components/maincontroller.ts | 1125 +++++++++--------- src/components/playbackManager.ts | 380 ++++--- src/css/glyphicons.css | 432 +++---- src/css/jellyfin.css | 324 +++--- src/helpers.ts | 1148 ++++++++++--------- src/index.html | 113 +- src/types/global.d.ts | 122 +- stylelint.config.js | 10 +- tsconfig-webpack.json | 26 +- tsconfig.json | 34 +- webpack.config.ts | 162 +-- 35 files changed, 4291 insertions(+), 4203 deletions(-) diff --git a/.ci/azure-pipelines.yml b/.ci/azure-pipelines.yml index b41b4897..9d191321 100644 --- a/.ci/azure-pipelines.yml +++ b/.ci/azure-pipelines.yml @@ -1,171 +1,171 @@ trigger: - batch: true - branches: - include: - - '*' - tags: - include: - - '*' + batch: true + branches: + include: + - '*' + tags: + include: + - '*' pr: - branches: - include: - - '*' + branches: + include: + - '*' jobs: - - job: Build - displayName: 'Build' - - strategy: - matrix: - Development: - BuildConfiguration: development - Production: - BuildConfiguration: production - - pool: - vmImage: 'ubuntu-latest' - - steps: - - task: NodeTool@0 - displayName: 'Install Node' - inputs: - versionSpec: '12.x' - - - task: Cache@2 - displayName: 'Check Cache' - inputs: - key: 'npm | package-lock.json' - path: 'node_modules' - cacheHitVar: CACHE_RESTORED - - - script: 'npm ci --no-audit' - displayName: 'Install Dependencies' - condition: ne(variables.CACHE_RESTORED, 'true') - - - script: 'sh ./scripts/updateversion.sh' - displayName: 'Update version in package.json' - - - script: 'npm run build:development' - displayName: 'Build Development' - condition: eq(variables['BuildConfiguration'], 'development') - - - script: 'npm run build:production' - displayName: 'Build Bundle' - condition: eq(variables['BuildConfiguration'], 'production') - - - script: 'test -d dist' - displayName: 'Check Build' - - - script: 'mv dist jellyfin-chromecast' - displayName: 'Rename Directory' - - - task: ArchiveFiles@2 - displayName: 'Archive Directory' - inputs: - rootFolderOrFile: 'jellyfin-chromecast' - includeRootFolder: true - archiveFile: 'jellyfin-chromecast-$(BuildConfiguration)' - - - task: PublishPipelineArtifact@1 - displayName: 'Publish Release' - inputs: - targetPath: '$(Build.SourcesDirectory)/jellyfin-chromecast-$(BuildConfiguration).zip' - artifactName: 'jellyfin-chromecast-$(BuildConfiguration)' - - - job: Publish - displayName: 'Publish' - - dependsOn: Build - condition: or(eq(variables['Build.SourceBranch'], 'refs/heads/master'), startsWith(variables['Build.SourceBranch'], 'refs/tags')) - - strategy: - matrix: - Development: - BuildConfiguration: development - Production: - BuildConfiguration: production - - pool: - vmImage: 'ubuntu-latest' - - steps: - - script: 'echo "##vso[task.setvariable variable=TAG]$(git describe --tags)"' - displayName: 'Set Tag Variable' - - - task: DownloadPipelineArtifact@2 - displayName: 'Download Artifact' - inputs: - source: 'current' - artifact: 'jellyfin-chromecast-$(BuildConfiguration)' - path: '$(System.ArtifactsDirectory)' - runVersion: 'latest' - - - task: GithubRelease@0 - displayName: 'GitHub Upload' - condition: startsWith(variables['Build.SourceBranch'], 'refs/tags') - inputs: - gitHubConnection: Jellyfin Release Download - repositoryName: jellyfin/jellyfin-chromecast - assets: '$(System.ArtifactsDirectory)/*.zip' - action: 'edit' - assetUploadMode: 'replace' - tag: '$(TAG)' - - - task: CopyFilesOverSSH@0 - displayName: 'Upload to Repository' - inputs: - sshEndpoint: repository - sourceFolder: '$(System.ArtifactsDirectory)' - contents: '**' - targetFolder: '/srv/repository/releases/client/chromecast/versions/$(TAG)' - - - task: SSH@0 - displayName: 'Symlink Latest Version' - inputs: - sshEndpoint: repository - runOptions: 'inline' - inline: 'cd /srv/repository/releases/client/chromecast && rm -rf *.zip && ln -s versions/$(TAG)/jellyfin-chromecast-$(BuildConfiguration)-$(TAG).zip .' - - - job: Production - displayName: 'Production' - - dependsOn: Build - condition: or(eq(variables['Build.SourceBranch'], 'refs/heads/master'), startsWith(variables['Build.SourceBranch'], 'refs/tags')) - - pool: - vmImage: 'ubuntu-latest' - - steps: - - task: DownloadPipelineArtifact@2 - displayName: 'Download Artifact' - inputs: - source: 'current' - artifact: 'jellyfin-chromecast-production' - path: '$(System.ArtifactsDirectory)' - runVersion: 'latest' - - - task: ExtractFiles@1 - displayName: 'Extract Source' - inputs: - archiveFilePatterns: '$(System.ArtifactsDirectory)/*.zip' - destinationFolder: '$(System.ArtifactsDirectory)/artifact' - cleanDestinationFolder: true - - - task: CopyFilesOverSSH@0 - displayName: 'Update Nightly' - inputs: - sshEndpoint: chromecast - sourceFolder: '$(System.ArtifactsDirectory)/artifact/jellyfin-chromecast' - cleanTargetFolder: true - contents: '**' - targetFolder: '/srv/chromecast/nightly' - - - task: CopyFilesOverSSH@0 - displayName: 'Update Stable' - condition: startsWith(variables['Build.SourceBranch'], 'refs/tags') - inputs: - sshEndpoint: chromecast - sourceFolder: '$(System.ArtifactsDirectory)/artifact/jellyfin-chromecast' - cleanTargetFolder: true - contents: '**' - targetFolder: '/srv/chromecast/stable' + - job: Build + displayName: 'Build' + + strategy: + matrix: + Development: + BuildConfiguration: development + Production: + BuildConfiguration: production + + pool: + vmImage: 'ubuntu-latest' + + steps: + - task: NodeTool@0 + displayName: 'Install Node' + inputs: + versionSpec: '12.x' + + - task: Cache@2 + displayName: 'Check Cache' + inputs: + key: 'npm | package-lock.json' + path: 'node_modules' + cacheHitVar: CACHE_RESTORED + + - script: 'npm ci --no-audit' + displayName: 'Install Dependencies' + condition: ne(variables.CACHE_RESTORED, 'true') + + - script: 'sh ./scripts/updateversion.sh' + displayName: 'Update version in package.json' + + - script: 'npm run build:development' + displayName: 'Build Development' + condition: eq(variables['BuildConfiguration'], 'development') + + - script: 'npm run build:production' + displayName: 'Build Bundle' + condition: eq(variables['BuildConfiguration'], 'production') + + - script: 'test -d dist' + displayName: 'Check Build' + + - script: 'mv dist jellyfin-chromecast' + displayName: 'Rename Directory' + + - task: ArchiveFiles@2 + displayName: 'Archive Directory' + inputs: + rootFolderOrFile: 'jellyfin-chromecast' + includeRootFolder: true + archiveFile: 'jellyfin-chromecast-$(BuildConfiguration)' + + - task: PublishPipelineArtifact@1 + displayName: 'Publish Release' + inputs: + targetPath: '$(Build.SourcesDirectory)/jellyfin-chromecast-$(BuildConfiguration).zip' + artifactName: 'jellyfin-chromecast-$(BuildConfiguration)' + + - job: Publish + displayName: 'Publish' + + dependsOn: Build + condition: or(eq(variables['Build.SourceBranch'], 'refs/heads/master'), startsWith(variables['Build.SourceBranch'], 'refs/tags')) + + strategy: + matrix: + Development: + BuildConfiguration: development + Production: + BuildConfiguration: production + + pool: + vmImage: 'ubuntu-latest' + + steps: + - script: 'echo "##vso[task.setvariable variable=TAG]$(git describe --tags)"' + displayName: 'Set Tag Variable' + + - task: DownloadPipelineArtifact@2 + displayName: 'Download Artifact' + inputs: + source: 'current' + artifact: 'jellyfin-chromecast-$(BuildConfiguration)' + path: '$(System.ArtifactsDirectory)' + runVersion: 'latest' + + - task: GithubRelease@0 + displayName: 'GitHub Upload' + condition: startsWith(variables['Build.SourceBranch'], 'refs/tags') + inputs: + gitHubConnection: Jellyfin Release Download + repositoryName: jellyfin/jellyfin-chromecast + assets: '$(System.ArtifactsDirectory)/*.zip' + action: 'edit' + assetUploadMode: 'replace' + tag: '$(TAG)' + + - task: CopyFilesOverSSH@0 + displayName: 'Upload to Repository' + inputs: + sshEndpoint: repository + sourceFolder: '$(System.ArtifactsDirectory)' + contents: '**' + targetFolder: '/srv/repository/releases/client/chromecast/versions/$(TAG)' + + - task: SSH@0 + displayName: 'Symlink Latest Version' + inputs: + sshEndpoint: repository + runOptions: 'inline' + inline: 'cd /srv/repository/releases/client/chromecast && rm -rf *.zip && ln -s versions/$(TAG)/jellyfin-chromecast-$(BuildConfiguration)-$(TAG).zip .' + + - job: Production + displayName: 'Production' + + dependsOn: Build + condition: or(eq(variables['Build.SourceBranch'], 'refs/heads/master'), startsWith(variables['Build.SourceBranch'], 'refs/tags')) + + pool: + vmImage: 'ubuntu-latest' + + steps: + - task: DownloadPipelineArtifact@2 + displayName: 'Download Artifact' + inputs: + source: 'current' + artifact: 'jellyfin-chromecast-production' + path: '$(System.ArtifactsDirectory)' + runVersion: 'latest' + + - task: ExtractFiles@1 + displayName: 'Extract Source' + inputs: + archiveFilePatterns: '$(System.ArtifactsDirectory)/*.zip' + destinationFolder: '$(System.ArtifactsDirectory)/artifact' + cleanDestinationFolder: true + + - task: CopyFilesOverSSH@0 + displayName: 'Update Nightly' + inputs: + sshEndpoint: chromecast + sourceFolder: '$(System.ArtifactsDirectory)/artifact/jellyfin-chromecast' + cleanTargetFolder: true + contents: '**' + targetFolder: '/srv/chromecast/nightly' + + - task: CopyFilesOverSSH@0 + displayName: 'Update Stable' + condition: startsWith(variables['Build.SourceBranch'], 'refs/tags') + inputs: + sshEndpoint: chromecast + sourceFolder: '$(System.ArtifactsDirectory)/artifact/jellyfin-chromecast' + cleanTargetFolder: true + contents: '**' + targetFolder: '/srv/chromecast/stable' diff --git a/.eslintrc.js b/.eslintrc.js index 09a0deff..4d2cb800 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -1,114 +1,114 @@ module.exports = { - root: true, - env: { - node: true, - browser: true, - es6: true - }, - extends: [ - 'eslint:recommended', - 'plugin:jsdoc/recommended', - 'plugin:json/recommended', - 'plugin:@typescript-eslint/recommended', - 'prettier', - 'plugin:prettier/recommended', - 'plugin:promise/recommended', - 'plugin:import/errors', - 'plugin:import/warnings', - 'plugin:import/typescript' - ], - plugins: ['prettier', 'promise', 'import', 'jsdoc'], - rules: { - 'import/newline-after-import': 'error', - 'import/order': 'error', - 'jsdoc/require-hyphen-before-param-description': 'error', - 'jsdoc/require-description': 'warn', - 'jsdoc/require-param-description': 'warn', - 'jsdoc/require-jsdoc': 'error', - //TypeScript and IntelliSense already provides us information about the function typings while hovering and - // eslint-jsdoc doesn't detect a mismatch between what's declared in the function and what's declared in - // JSDOC. - 'jsdoc/require-param-type': 'off', - 'jsdoc/require-returns-type': 'off', - 'jsdoc/check-indentation': 'error', - 'jsdoc/check-syntax': 'error', - 'jsdoc/check-param-names': 'error', - 'jsdoc/check-property-names': 'error', - 'jsdoc/check-tag-names': 'error', - 'jsdoc/no-types': 'error', - 'jsdoc/valid-types': 'off', - 'promise/no-nesting': 'error', - 'promise/no-return-in-finally': 'error', - 'promise/prefer-await-to-callbacks': 'error', - 'promise/prefer-await-to-then': 'error', - '@typescript-eslint/explicit-function-return-type': 'error', - '@typescript-eslint/prefer-ts-expect-error': 'error', - '@typescript-eslint/no-unused-vars': 'error', - 'prefer-arrow-callback': 'error', - 'prefer-template': 'error', - curly: 'error', - 'padding-line-between-statements': [ - 'error', - // Always require blank lines after directives (like 'use-strict'), except between directives - { blankLine: 'always', prev: 'directive', next: '*' }, - { blankLine: 'any', prev: 'directive', next: 'directive' }, - // Always require blank lines after import, except between imports - { blankLine: 'always', prev: 'import', next: '*' }, - { blankLine: 'any', prev: 'import', next: 'import' }, - // Always require blank lines before and after every sequence of variable declarations and export - { - blankLine: 'always', - prev: '*', - next: ['const', 'let', 'var', 'export'] - }, - { - blankLine: 'always', - prev: ['const', 'let', 'var', 'export'], - next: '*' - }, - { - blankLine: 'any', - prev: ['const', 'let', 'var', 'export'], - next: ['const', 'let', 'var', 'export'] - }, - // Always require blank lines before and after class declaration, if, do/while, switch, try - { - blankLine: 'always', - prev: '*', - next: ['if', 'class', 'for', 'do', 'while', 'switch', 'try'] - }, - { - blankLine: 'always', - prev: ['if', 'class', 'for', 'do', 'while', 'switch', 'try'], - next: '*' - }, - // Always require blank lines before return statements - { blankLine: 'always', prev: '*', next: 'return' } - ] - }, - overrides: [ - { - files: ['.js', '.ts'], - env: { - node: false, + root: true, + env: { + node: true, browser: true, es6: true - }, - globals: { - cast: 'readonly', - PRODUCTION: 'readonly', - $scope: 'writable' - } - } - ], - settings: { - 'import/parsers': { - '@typescript-eslint/parser': ['.ts', '.tsx'] }, - 'import/resolver': { - typescript: { - alwaysTryTypes: true - } + extends: [ + 'eslint:recommended', + 'plugin:jsdoc/recommended', + 'plugin:json/recommended', + 'plugin:@typescript-eslint/recommended', + 'prettier', + 'plugin:prettier/recommended', + 'plugin:promise/recommended', + 'plugin:import/errors', + 'plugin:import/warnings', + 'plugin:import/typescript' + ], + plugins: ['prettier', 'promise', 'import', 'jsdoc'], + rules: { + 'import/newline-after-import': 'error', + 'import/order': 'error', + 'jsdoc/require-hyphen-before-param-description': 'error', + 'jsdoc/require-description': 'warn', + 'jsdoc/require-param-description': 'warn', + 'jsdoc/require-jsdoc': 'error', + //TypeScript and IntelliSense already provides us information about the function typings while hovering and + // eslint-jsdoc doesn't detect a mismatch between what's declared in the function and what's declared in + // JSDOC. + 'jsdoc/require-param-type': 'off', + 'jsdoc/require-returns-type': 'off', + 'jsdoc/check-indentation': 'error', + 'jsdoc/check-syntax': 'error', + 'jsdoc/check-param-names': 'error', + 'jsdoc/check-property-names': 'error', + 'jsdoc/check-tag-names': 'error', + 'jsdoc/no-types': 'error', + 'jsdoc/valid-types': 'off', + 'promise/no-nesting': 'error', + 'promise/no-return-in-finally': 'error', + 'promise/prefer-await-to-callbacks': 'error', + 'promise/prefer-await-to-then': 'error', + '@typescript-eslint/explicit-function-return-type': 'error', + '@typescript-eslint/prefer-ts-expect-error': 'error', + '@typescript-eslint/no-unused-vars': 'error', + 'prefer-arrow-callback': 'error', + 'prefer-template': 'error', + curly: 'error', + 'padding-line-between-statements': [ + 'error', + // Always require blank lines after directives (like 'use-strict'), except between directives + { blankLine: 'always', prev: 'directive', next: '*' }, + { blankLine: 'any', prev: 'directive', next: 'directive' }, + // Always require blank lines after import, except between imports + { blankLine: 'always', prev: 'import', next: '*' }, + { blankLine: 'any', prev: 'import', next: 'import' }, + // Always require blank lines before and after every sequence of variable declarations and export + { + blankLine: 'always', + prev: '*', + next: ['const', 'let', 'var', 'export'] + }, + { + blankLine: 'always', + prev: ['const', 'let', 'var', 'export'], + next: '*' + }, + { + blankLine: 'any', + prev: ['const', 'let', 'var', 'export'], + next: ['const', 'let', 'var', 'export'] + }, + // Always require blank lines before and after class declaration, if, do/while, switch, try + { + blankLine: 'always', + prev: '*', + next: ['if', 'class', 'for', 'do', 'while', 'switch', 'try'] + }, + { + blankLine: 'always', + prev: ['if', 'class', 'for', 'do', 'while', 'switch', 'try'], + next: '*' + }, + // Always require blank lines before return statements + { blankLine: 'always', prev: '*', next: 'return' } + ] + }, + overrides: [ + { + files: ['.js', '.ts'], + env: { + node: false, + browser: true, + es6: true + }, + globals: { + cast: 'readonly', + PRODUCTION: 'readonly', + $scope: 'writable' + } + } + ], + settings: { + 'import/parsers': { + '@typescript-eslint/parser': ['.ts', '.tsx'] + }, + 'import/resolver': { + typescript: { + alwaysTryTypes: true + } + } } - } }; diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 7555eb2f..5a6d5753 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -33,10 +33,10 @@ assignees: '' **System (please complete the following information):** -- OS: [e.g. Docker, Debian, Windows] -- Browser: [e.g. Firefox, Chrome, Safari] -- Jellyfin Version: [e.g. 10.0.1] -- Cast client: [e.g Ultra] +- OS: [e.g. Docker, Debian, Windows] +- Browser: [e.g. Firefox, Chrome, Safari] +- Jellyfin Version: [e.g. 10.0.1] +- Cast client: [e.g Ultra] **Additional context** diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 1d203f64..c9020f46 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,30 +1,30 @@ version: 2 updates: - # Fetch and update latest `npm` packages - - package-ecosystem: npm - directory: '/' - schedule: - interval: daily - time: '00:00' - open-pull-requests-limit: 10 - reviewers: - - YouKnowBlom - - ferferga - - hawken93 - assignees: - - YouKnowBlom - - ferferga - - hawken93 - commit-message: - prefix: fix - prefix-development: chore - include: scope + # Fetch and update latest `npm` packages + - package-ecosystem: npm + directory: '/' + schedule: + interval: daily + time: '00:00' + open-pull-requests-limit: 10 + reviewers: + - YouKnowBlom + - ferferga + - hawken93 + assignees: + - YouKnowBlom + - ferferga + - hawken93 + commit-message: + prefix: fix + prefix-development: chore + include: scope - - package-ecosystem: github-actions - directory: '/' - schedule: - interval: daily - time: '00:00' - open-pull-requests-limit: 10 - commit-message: - prefix: actions + - package-ecosystem: github-actions + directory: '/' + schedule: + interval: daily + time: '00:00' + open-pull-requests-limit: 10 + commit-message: + prefix: actions diff --git a/.github/workflows/lint.yaml b/.github/workflows/lint.yaml index 7c593b94..c6e6d03b 100644 --- a/.github/workflows/lint.yaml +++ b/.github/workflows/lint.yaml @@ -1,42 +1,42 @@ name: Lint on: - push: - branches: - - master - pull_request: - branches: - - master + push: + branches: + - master + pull_request: + branches: + - master jobs: - lint: - name: Lint TS and CSS - runs-on: ubuntu-latest - - steps: - - name: Checkout - uses: actions/checkout@v2.3.4 - - - name: Setup node env - uses: actions/setup-node@v2.1.5 - with: - node-version: 14 - - - name: Get npm cache directory path - id: npm-cache-dir-path - run: echo "::set-output name=dir::$(npm config get cache)" - - - name: Cache node_modules - uses: actions/cache@v2.1.5 - id: npm-cache - with: - path: ${{ steps.npm-cache-dir-path.outputs.dir }} - key: ${{ runner.os }}-npm-${{ hashFiles('**/package-lock.json') }} - restore-keys: | - ${{ runner.os }}-npm- - - - name: Install dependencies - run: npm ci --no-audit - - - name: Run ESLint - run: npm run lint + lint: + name: Lint TS and CSS + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v2.3.4 + + - name: Setup node env + uses: actions/setup-node@v2.1.5 + with: + node-version: 14 + + - name: Get npm cache directory path + id: npm-cache-dir-path + run: echo "::set-output name=dir::$(npm config get cache)" + + - name: Cache node_modules + uses: actions/cache@v2.1.5 + id: npm-cache + with: + path: ${{ steps.npm-cache-dir-path.outputs.dir }} + key: ${{ runner.os }}-npm-${{ hashFiles('**/package-lock.json') }} + restore-keys: | + ${{ runner.os }}-npm- + + - name: Install dependencies + run: npm ci --no-audit + + - name: Run ESLint + run: npm run lint diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 47506eee..bbf977ab 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -1,42 +1,42 @@ name: Test on: - push: - branches: - - master - pull_request: - branches: - - master + push: + branches: + - master + pull_request: + branches: + - master jobs: - jest: - name: Jest - runs-on: ubuntu-latest - - steps: - - name: Checkout - uses: actions/checkout@v2.3.4 - - - name: Setup node env - uses: actions/setup-node@v2.1.5 - with: - node-version: 14 - - - name: Get npm cache directory path - id: npm-cache-dir-path - run: echo "::set-output name=dir::$(npm config get cache)" - - - name: Cache node_modules - uses: actions/cache@v2.1.5 - id: npm-cache - with: - path: ${{ steps.npm-cache-dir-path.outputs.dir }} - key: ${{ runner.os }}-npm-${{ hashFiles('**/package-lock.json') }} - restore-keys: | - ${{ runner.os }}-npm- - - - name: Install dependencies - run: npm ci --no-audit - - - name: Run tests - run: npm run test + jest: + name: Jest + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v2.3.4 + + - name: Setup node env + uses: actions/setup-node@v2.1.5 + with: + node-version: 14 + + - name: Get npm cache directory path + id: npm-cache-dir-path + run: echo "::set-output name=dir::$(npm config get cache)" + + - name: Cache node_modules + uses: actions/cache@v2.1.5 + id: npm-cache + with: + path: ${{ steps.npm-cache-dir-path.outputs.dir }} + key: ${{ runner.os }}-npm-${{ hashFiles('**/package-lock.json') }} + restore-keys: | + ${{ runner.os }}-npm- + + - name: Install dependencies + run: npm ci --no-audit + + - name: Run tests + run: npm run test diff --git a/.prettierrc b/.prettierrc index e953a718..d65b3153 100644 --- a/.prettierrc +++ b/.prettierrc @@ -1,6 +1,6 @@ { - "semi": true, - "singleQuote": true, - "tabWidth": 4, - "trailingComma": "none" + "semi": true, + "singleQuote": true, + "tabWidth": 4, + "trailingComma": "none" } diff --git a/.stylelintrc.json b/.stylelintrc.json index ed191056..c765e3d3 100644 --- a/.stylelintrc.json +++ b/.stylelintrc.json @@ -1,4 +1,4 @@ { - "extends": ["stylelint-config-standard", "stylelint-config-prettier"], - "rules": {} + "extends": ["stylelint-config-standard", "stylelint-config-prettier"], + "rules": {} } diff --git a/.vscode/settings.json b/.vscode/settings.json index ad92582b..f89ed5f1 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,3 +1,3 @@ { - "editor.formatOnSave": true + "editor.formatOnSave": true } diff --git a/CHANGELOG.md b/CHANGELOG.md index e7bdb490..0d8fd4ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,6 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ### Added -- Updated web pack config -- Prettier -- Changelog +- Updated web pack config +- Prettier +- Changelog diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1cdacf43..465e9c98 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -6,9 +6,9 @@ The development environment is setup with editorconfig. Code style is enforced by prettier and eslint for Javascript/Typescript linting -- [editorconfig](https://editorconfig.org/) -- [prettier](https://prettier.io/) -- [eslint](https://eslint.org/) +- [editorconfig](https://editorconfig.org/) +- [prettier](https://prettier.io/) +- [eslint](https://eslint.org/) ### Environment variables diff --git a/commitlint.config.js b/commitlint.config.js index c34aa79d..a989bfc0 100644 --- a/commitlint.config.js +++ b/commitlint.config.js @@ -1,3 +1,3 @@ module.exports = { - extends: ['@commitlint/config-conventional'] + extends: ['@commitlint/config-conventional'] }; diff --git a/jest.config.js b/jest.config.js index eef6b07c..9f1e9c74 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,4 +1,4 @@ module.exports = { - preset: 'ts-jest', - testEnvironment: 'node' + preset: 'ts-jest', + testEnvironment: 'node' }; diff --git a/package.json b/package.json index 8bccf183..6196e1a4 100644 --- a/package.json +++ b/package.json @@ -1,84 +1,84 @@ { - "name": "jellyfin-chromecast", - "description": "Cast receiver for Jellyfin", - "version": "3.0.0", - "bugs": { - "url": "https://github.com/jellyfin/jellyfin-chromecast/issues" - }, - "dependencies": { - "axios": "^0.21.1" - }, - "devDependencies": { - "@commitlint/cli": "^12.1.1", - "@commitlint/config-conventional": "^12.1.1", - "@types/chromecast-caf-receiver": "^5.0.12", - "@types/jest": "^26.0.22", - "@types/node": "^14.14.39", - "@types/webpack": "^5.28.0", - "@types/webpack-dev-server": "^3.11.3", - "@types/webpack-merge": "^5.0.0", - "@typescript-eslint/eslint-plugin": "^4.22.0", - "@typescript-eslint/parser": "^4.22.0", - "clean-webpack-plugin": "^3.0.0", - "cross-env": "^7.0.3", - "css-loader": "^5.2.1", - "cz-conventional-changelog": "^3.3.0", - "eslint": "^7.24.0", - "eslint-config-prettier": "^8.2.0", - "eslint-import-resolver-typescript": "^2.4.0", - "eslint-plugin-import": "^2.22.1", - "eslint-plugin-jsdoc": "^32.3.0", - "eslint-plugin-json": "^2.1.2", - "eslint-plugin-prettier": "^3.3.1", - "eslint-plugin-promise": "^5.1.0", - "file-loader": "^6.2.0", - "html-loader": "^2.1.2", - "html-webpack-plugin": "^5.3.1", - "husky": "^6.0.0", - "image-minimizer-webpack-plugin": "^2.1.0", - "imagemin-svgo": "^8.0.0", - "jest": "^26.6.3", - "prettier": "^2.2.1", - "source-map-loader": "^2.0.1", - "style-loader": "^2.0.0", - "stylelint": "^13.12.0", - "stylelint-config-prettier": "^8.0.2", - "stylelint-config-standard": "^21.0.0", - "ts-jest": "^26.5.4", - "ts-loader": "^8.1.0", - "ts-node": "^9.1.1", - "tsconfig-paths": "^3.9.0", - "typescript": "^4.2.4", - "url-loader": "^4.1.1", - "webpack": "^5.33.2", - "webpack-cli": "^4.6.0", - "webpack-dev-server": "^3.11.0", - "webpack-merge": "^5.4.1" - }, - "config": { - "commitizen": { - "path": "./node_modules/cz-conventional-changelog" + "name": "jellyfin-chromecast", + "description": "Cast receiver for Jellyfin", + "version": "3.0.0", + "bugs": { + "url": "https://github.com/jellyfin/jellyfin-chromecast/issues" + }, + "dependencies": { + "axios": "^0.21.1" + }, + "devDependencies": { + "@commitlint/cli": "^12.1.1", + "@commitlint/config-conventional": "^12.1.1", + "@types/chromecast-caf-receiver": "^5.0.12", + "@types/jest": "^26.0.22", + "@types/node": "^14.14.39", + "@types/webpack": "^5.28.0", + "@types/webpack-dev-server": "^3.11.3", + "@types/webpack-merge": "^5.0.0", + "@typescript-eslint/eslint-plugin": "^4.22.0", + "@typescript-eslint/parser": "^4.22.0", + "clean-webpack-plugin": "^3.0.0", + "cross-env": "^7.0.3", + "css-loader": "^5.2.1", + "cz-conventional-changelog": "^3.3.0", + "eslint": "^7.24.0", + "eslint-config-prettier": "^8.2.0", + "eslint-import-resolver-typescript": "^2.4.0", + "eslint-plugin-import": "^2.22.1", + "eslint-plugin-jsdoc": "^32.3.0", + "eslint-plugin-json": "^2.1.2", + "eslint-plugin-prettier": "^3.3.1", + "eslint-plugin-promise": "^5.1.0", + "file-loader": "^6.2.0", + "html-loader": "^2.1.2", + "html-webpack-plugin": "^5.3.1", + "husky": "^6.0.0", + "image-minimizer-webpack-plugin": "^2.1.0", + "imagemin-svgo": "^8.0.0", + "jest": "^26.6.3", + "prettier": "^2.2.1", + "source-map-loader": "^2.0.1", + "style-loader": "^2.0.0", + "stylelint": "^13.12.0", + "stylelint-config-prettier": "^8.0.2", + "stylelint-config-standard": "^21.0.0", + "ts-jest": "^26.5.4", + "ts-loader": "^8.1.0", + "ts-node": "^9.1.1", + "tsconfig-paths": "^3.9.0", + "typescript": "^4.2.4", + "url-loader": "^4.1.1", + "webpack": "^5.33.2", + "webpack-cli": "^4.6.0", + "webpack-dev-server": "^3.11.0", + "webpack-merge": "^5.4.1" + }, + "config": { + "commitizen": { + "path": "./node_modules/cz-conventional-changelog" + } + }, + "engines": { + "yarn": "YARN NO LONGER USED - use npm instead." + }, + "homepage": "https://jellyfin.org/", + "license": "GPL-2.0-or-later", + "repository": { + "type": "git", + "url": "git+https://github.com/jellyfin/jellyfin-chromecast.git" + }, + "scripts": { + "build:development": "TS_NODE_PROJECT=\"tsconfig-webpack.json\" webpack --config webpack.config.ts --mode=development", + "build:production": "TS_NODE_PROJECT=\"tsconfig-webpack.json\" webpack --config webpack.config.ts --mode=production", + "lint": "npm run lint:code && npm run lint:css && npm run prettier", + "lint:code": "eslint --ext .ts,.js,.json .", + "lint:css": "stylelint **/*.css", + "prepare": "npm run build:production", + "prettier": "prettier --write .", + "start": "TS_NODE_PROJECT=\"tsconfig-webpack.json\" webpack serve --config webpack.config.ts", + "test": "jest --passWithNoTests", + "watch": "TS_NODE_PROJECT=\"tsconfig-webpack.json\" webpack --config webpack.config.ts --watch" } - }, - "engines": { - "yarn": "YARN NO LONGER USED - use npm instead." - }, - "homepage": "https://jellyfin.org/", - "license": "GPL-2.0-or-later", - "repository": { - "type": "git", - "url": "git+https://github.com/jellyfin/jellyfin-chromecast.git" - }, - "scripts": { - "build:development": "TS_NODE_PROJECT=\"tsconfig-webpack.json\" webpack --config webpack.config.ts --mode=development", - "build:production": "TS_NODE_PROJECT=\"tsconfig-webpack.json\" webpack --config webpack.config.ts --mode=production", - "lint": "npm run lint:code && npm run lint:css && npm run prettier", - "lint:code": "eslint --ext .ts,.js,.json .", - "lint:css": "stylelint **/*.css", - "prepare": "npm run build:production", - "prettier": "prettier --check .", - "start": "TS_NODE_PROJECT=\"tsconfig-webpack.json\" webpack serve --config webpack.config.ts", - "test": "jest --passWithNoTests", - "watch": "TS_NODE_PROJECT=\"tsconfig-webpack.json\" webpack --config webpack.config.ts --watch" - } } diff --git a/src/api/credentialManager.ts b/src/api/credentialManager.ts index 8f0ef9d8..d2e59404 100644 --- a/src/api/credentialManager.ts +++ b/src/api/credentialManager.ts @@ -1,76 +1,76 @@ import { Configuration } from './generated/configuration'; interface CredentialStore { - [id: string]: Configuration; + [id: string]: Configuration; } export class credentialManager { - /** - * Store for credentials - * - * @private - */ - private credentialStore: CredentialStore = {}; + /** + * Store for credentials + * + * @private + */ + private credentialStore: CredentialStore = {}; - /** - * Get credentials for the provided server ID - * - * @param serverId - ID of the server the credentials belong to - * @returns Credentials for the provided server ID or undefined if the store has no server with that ID - */ - get(serverId: string): Configuration | undefined { - if (serverId in this.credentialStore) { - return this.credentialStore[serverId]; + /** + * Get credentials for the provided server ID + * + * @param serverId - ID of the server the credentials belong to + * @returns Credentials for the provided server ID or undefined if the store has no server with that ID + */ + get(serverId: string): Configuration | undefined { + if (serverId in this.credentialStore) { + return this.credentialStore[serverId]; + } } - } - /** - * Update credentials for the provided server ID - * - * @param serverId - ID of the server to update - * @param newConfig - Updated Credentials - * @returns True if the value was updated, false if it wasn't - */ - update(serverId: string, newConfig: Configuration): boolean { - if (serverId in this.credentialStore) { - this.credentialStore[serverId] = newConfig; + /** + * Update credentials for the provided server ID + * + * @param serverId - ID of the server to update + * @param newConfig - Updated Credentials + * @returns True if the value was updated, false if it wasn't + */ + update(serverId: string, newConfig: Configuration): boolean { + if (serverId in this.credentialStore) { + this.credentialStore[serverId] = newConfig; - return true; + return true; + } + + return false; } - return false; - } + /** + * Add a new credential to store. Only accepts new entries. + * + * @param serverId - ID of the server the credentials belong to + * @param configuration - Credentials of the server + * @returns True if server was added, false if it wasn't + */ + add(serverId: string, configuration: Configuration): boolean { + if (serverId in this.credentialStore) { + return false; + } - /** - * Add a new credential to store. Only accepts new entries. - * - * @param serverId - ID of the server the credentials belong to - * @param configuration - Credentials of the server - * @returns True if server was added, false if it wasn't - */ - add(serverId: string, configuration: Configuration): boolean { - if (serverId in this.credentialStore) { - return false; - } + this.credentialStore[serverId] = configuration; - this.credentialStore[serverId] = configuration; + return true; + } - return true; - } + /** + * Add a new credential to store. Only accepts new entries. + * + * @param serverId - ID of the server the credentials belong to + * @returns True if server was added, false if it wasn't + */ + remove(serverId: string): boolean { + if (serverId in this.credentialStore) { + delete this.credentialStore[serverId]; - /** - * Add a new credential to store. Only accepts new entries. - * - * @param serverId - ID of the server the credentials belong to - * @returns True if server was added, false if it wasn't - */ - remove(serverId: string): boolean { - if (serverId in this.credentialStore) { - delete this.credentialStore[serverId]; + return true; + } - return true; + return false; } - - return false; - } } diff --git a/src/app.ts b/src/app.ts index 8a86dae7..18b4dd16 100644 --- a/src/app.ts +++ b/src/app.ts @@ -5,12 +5,14 @@ import './css/jellyfin.css'; const senders = cast.framework.CastReceiverContext.getInstance().getSenders(); const id = - senders.length !== 0 && senders[0].id ? senders[0].id : new Date().getTime(); + senders.length !== 0 && senders[0].id + ? senders[0].id + : new Date().getTime(); window.deviceInfo = { - deviceId: id, - deviceName: 'Google Cast', - versionNumber: RECEIVERVERSION + deviceId: id, + deviceName: 'Google Cast', + versionNumber: RECEIVERVERSION }; window.mediaElement = document.getElementById('video-player'); diff --git a/src/components/castDevices.ts b/src/components/castDevices.ts index 25fbd9c9..6c873847 100644 --- a/src/components/castDevices.ts +++ b/src/components/castDevices.ts @@ -2,12 +2,12 @@ const castContext = cast.framework.CastReceiverContext.getInstance(); // Device Ids export enum deviceIds { - GEN1AND2, - AUDIO, - GEN3, - ULTRA, - NESTHUBANDMAX, //Nest hub and Nest hub max - CCGTV //Chromecast Google TV + GEN1AND2, + AUDIO, + GEN3, + ULTRA, + NESTHUBANDMAX, //Nest hub and Nest hub max + CCGTV //Chromecast Google TV } // cached device id, avoid looking it up again and again @@ -20,24 +20,24 @@ let deviceId: number | null = null; * @returns Active Cast device Id. */ export function getActiveDeviceId(): number { - if (deviceId !== null) { - return deviceId; - } + if (deviceId !== null) { + return deviceId; + } - if ( - castContext.canDisplayType('video/mp4', 'hev1.1.6.L153.B0') && - castContext.canDisplayType('video/webm', 'vp9') - ) { - deviceId = deviceIds.ULTRA; - } else if (castContext.canDisplayType('video/webm', 'vp9')) { - deviceId = deviceIds.NESTHUBANDMAX; - } else if (castContext.canDisplayType('video/mp4', 'avc1.64002A')) { - deviceId = deviceIds.GEN3; - } else if (castContext.canDisplayType('video/mp4', 'avc1.640029')) { - deviceId = deviceIds.GEN1AND2; - } else { - deviceId = deviceIds.AUDIO; - } + if ( + castContext.canDisplayType('video/mp4', 'hev1.1.6.L153.B0') && + castContext.canDisplayType('video/webm', 'vp9') + ) { + deviceId = deviceIds.ULTRA; + } else if (castContext.canDisplayType('video/webm', 'vp9')) { + deviceId = deviceIds.NESTHUBANDMAX; + } else if (castContext.canDisplayType('video/mp4', 'avc1.64002A')) { + deviceId = deviceIds.GEN3; + } else if (castContext.canDisplayType('video/mp4', 'avc1.640029')) { + deviceId = deviceIds.GEN1AND2; + } else { + deviceId = deviceIds.AUDIO; + } - return deviceId; + return deviceId; } diff --git a/src/components/codecSupportHelper.ts b/src/components/codecSupportHelper.ts index 2f310d0b..bc4ffb2f 100644 --- a/src/components/codecSupportHelper.ts +++ b/src/components/codecSupportHelper.ts @@ -13,8 +13,8 @@ const castContext = cast.framework.CastReceiverContext.getInstance(); * @returns true if E-AC-3 can be played */ export function hasEAC3Support(): boolean { - //return castContext.canDisplayType('audio/mp4', 'ec-3'); - return false; + //return castContext.canDisplayType('audio/mp4', 'ec-3'); + return false; } /** @@ -29,8 +29,8 @@ export function hasEAC3Support(): boolean { * */ export function hasAC3Support(): boolean { - //return castContext.canDisplayType('audio/mp4', 'ac-3'); - return false; + //return castContext.canDisplayType('audio/mp4', 'ac-3'); + return false; } /** @@ -44,8 +44,8 @@ export function hasAC3Support(): boolean { * @returns true if surround codecs can be played */ export function hasSurroundSupport(): boolean { - // This will turn on surround support if passthrough is available. - return hasAC3Support(); + // This will turn on surround support if passthrough is available. + return hasAC3Support(); } /** @@ -54,7 +54,7 @@ export function hasSurroundSupport(): boolean { * @returns true if HEVC is supported */ export function hasH265Support(): boolean { - return castContext.canDisplayType('video/mp4', 'hev1.1.6.L150.B0'); + return castContext.canDisplayType('video/mp4', 'hev1.1.6.L150.B0'); } /** @@ -66,7 +66,7 @@ export function hasH265Support(): boolean { * @returns true if text tracks are supported */ export function hasTextTrackSupport(deviceId: number): boolean { - return deviceId !== deviceIds.AUDIO; + return deviceId !== deviceIds.AUDIO; } /** @@ -75,7 +75,7 @@ export function hasTextTrackSupport(deviceId: number): boolean { * @returns true if VP-8 is supported */ export function hasVP8Support(): boolean { - return castContext.canDisplayType('video/webm', 'vp8'); + return castContext.canDisplayType('video/webm', 'vp8'); } /** @@ -84,7 +84,7 @@ export function hasVP8Support(): boolean { * @returns true if VP-9 is supported */ export function hasVP9Support(): boolean { - return castContext.canDisplayType('video/webm', 'vp9'); + return castContext.canDisplayType('video/webm', 'vp9'); } /** @@ -93,10 +93,10 @@ export function hasVP9Support(): boolean { * @returns Max supported bitrate. */ export function getMaxBitrateSupport(): number { - // FIXME: We should get this dynamically or hardcode this to values - // we see fit for each Cast device. More testing is needed. - // 120Mb/s ? - return 120000000; + // FIXME: We should get this dynamically or hardcode this to values + // we see fit for each Cast device. More testing is needed. + // 120Mb/s ? + return 120000000; } /** @@ -106,18 +106,18 @@ export function getMaxBitrateSupport(): number { * @returns Max supported width. */ export function getMaxWidthSupport(deviceId: number): number { - switch (deviceId) { - case deviceIds.ULTRA: - case deviceIds.CCGTV: - return 3840; - case deviceIds.GEN1AND2: - case deviceIds.GEN3: - return 1920; - case deviceIds.NESTHUBANDMAX: - return 1280; - } + switch (deviceId) { + case deviceIds.ULTRA: + case deviceIds.CCGTV: + return 3840; + case deviceIds.GEN1AND2: + case deviceIds.GEN3: + return 1920; + case deviceIds.NESTHUBANDMAX: + return 1280; + } - return 0; + return 0; } /** @@ -127,14 +127,14 @@ export function getMaxWidthSupport(deviceId: number): number { * @returns All supported H.26x profiles. */ export function getH26xProfileSupport(deviceId: number): string { - // These are supported by all Cast devices, excluding audio only devices. - let h26xProfiles = 'high|main|baseline|constrained baseline'; + // These are supported by all Cast devices, excluding audio only devices. + let h26xProfiles = 'high|main|baseline|constrained baseline'; - if (deviceId === deviceIds.ULTRA || deviceId === deviceIds.CCGTV) { - h26xProfiles += '|high 10'; - } + if (deviceId === deviceIds.ULTRA || deviceId === deviceIds.CCGTV) { + h26xProfiles += '|high 10'; + } - return h26xProfiles; + return h26xProfiles; } /** @@ -144,18 +144,18 @@ export function getH26xProfileSupport(deviceId: number): string { * @returns The highest supported H.26x level. */ export function getH26xLevelSupport(deviceId: number): number { - switch (deviceId) { - case deviceIds.NESTHUBANDMAX: - case deviceIds.GEN1AND2: - return 41; - case deviceIds.GEN3: - return 42; - case deviceIds.ULTRA: - case deviceIds.CCGTV: - return 52; - } + switch (deviceId) { + case deviceIds.NESTHUBANDMAX: + case deviceIds.GEN1AND2: + return 41; + case deviceIds.GEN3: + return 42; + case deviceIds.ULTRA: + case deviceIds.CCGTV: + return 52; + } - return 0; + return 0; } /** @@ -164,17 +164,17 @@ export function getH26xLevelSupport(deviceId: number): number { * @returns Supported VPX codecs. */ export function getSupportedVPXVideoCodecs(): Array { - const codecs = []; + const codecs = []; - if (hasVP8Support()) { - codecs.push('VP8'); - } + if (hasVP8Support()) { + codecs.push('VP8'); + } - if (hasVP9Support()) { - codecs.push('VP9'); - } + if (hasVP9Support()) { + codecs.push('VP9'); + } - return codecs; + return codecs; } /** @@ -183,14 +183,14 @@ export function getSupportedVPXVideoCodecs(): Array { * @returns Supported MP4 video codecs. */ export function getSupportedMP4VideoCodecs(): Array { - const codecs = ['h264']; + const codecs = ['h264']; - if (hasH265Support()) { - codecs.push('h265'); - codecs.push('hevc'); - } + if (hasH265Support()) { + codecs.push('h265'); + codecs.push('hevc'); + } - return codecs; + return codecs; } /** @@ -199,20 +199,20 @@ export function getSupportedMP4VideoCodecs(): Array { * @returns Supported MP4 audio codecs. */ export function getSupportedMP4AudioCodecs(): Array { - const codecs = []; + const codecs = []; - if (hasEAC3Support()) { - codecs.push('eac3'); - } + if (hasEAC3Support()) { + codecs.push('eac3'); + } - if (hasAC3Support()) { - codecs.push('ac3'); - } + if (hasAC3Support()) { + codecs.push('ac3'); + } - codecs.push('aac'); - codecs.push('mp3'); + codecs.push('aac'); + codecs.push('mp3'); - return codecs; + return codecs; } /** @@ -221,9 +221,9 @@ export function getSupportedMP4AudioCodecs(): Array { * @returns Supported HLS video codecs. */ export function getSupportedHLSVideoCodecs(): Array { - // Currently the server does not support fmp4 which is required - // by the HLS spec for streaming H.265 video. - return ['h264']; + // Currently the server does not support fmp4 which is required + // by the HLS spec for streaming H.265 video. + return ['h264']; } /** @@ -232,8 +232,8 @@ export function getSupportedHLSVideoCodecs(): Array { * @returns All supported HLS audio codecs. */ export function getSupportedHLSAudioCodecs(): Array { - // HLS basically supports whatever MP4 supports. - return getSupportedMP4AudioCodecs(); + // HLS basically supports whatever MP4 supports. + return getSupportedMP4AudioCodecs(); } /** @@ -242,7 +242,7 @@ export function getSupportedHLSAudioCodecs(): Array { * @returns All supported WebM audio codecs. */ export function getSupportedWebMAudioCodecs(): Array { - return ['vorbis', 'opus']; + return ['vorbis', 'opus']; } /** @@ -251,5 +251,5 @@ export function getSupportedWebMAudioCodecs(): Array { * @returns All supported WebM audio codecs. */ export function getSupportedAudioCodecs(): Array { - return ['opus', 'mp3', 'aac', 'flac', 'webma', 'wav']; + return ['opus', 'mp3', 'aac', 'flac', 'webma', 'wav']; } diff --git a/src/components/commandHandler.ts b/src/components/commandHandler.ts index 1bc1299e..3c98f22f 100644 --- a/src/components/commandHandler.ts +++ b/src/components/commandHandler.ts @@ -1,20 +1,20 @@ import { getReportingParams } from '../helpers'; import { - DataMessage, - DisplayRequest, - PlayRequest, - SeekRequest, - SetIndexRequest, - SetRepeatModeRequest, - SupportedCommands + DataMessage, + DisplayRequest, + PlayRequest, + SeekRequest, + SetIndexRequest, + SetRepeatModeRequest, + SupportedCommands } from '../types/global'; import { - translateItems, - shuffle, - instantMix, - setAudioStreamIndex, - setSubtitleStreamIndex, - seek + translateItems, + shuffle, + instantMix, + setAudioStreamIndex, + setSubtitleStreamIndex, + seek } from './maincontroller'; import { reportPlaybackProgress } from './jellyfinActions'; @@ -24,194 +24,194 @@ import { playbackManager } from './playbackManager'; import { DocumentManager } from './documentManager'; export abstract class CommandHandler { - private static playerManager: framework.PlayerManager; - private static playbackManager: playbackManager; - private static supportedCommands: SupportedCommands = { - PlayNext: CommandHandler.playNextHandler, - PlayNow: CommandHandler.playNowHandler, - PlayLast: CommandHandler.playLastHandler, - Shuffle: CommandHandler.shuffleHandler, - InstantMix: CommandHandler.instantMixHandler, - DisplayContent: CommandHandler.displayContentHandler, - NextTrack: CommandHandler.nextTrackHandler, - PreviousTrack: CommandHandler.previousTrackHandler, - SetAudioStreamIndex: CommandHandler.setAudioStreamIndexHandler, - SetSubtitleStreamIndex: CommandHandler.setSubtitleStreamIndexHandler, - VolumeUp: CommandHandler.VolumeUpHandler, - VolumeDown: CommandHandler.VolumeDownHandler, - ToggleMute: CommandHandler.ToggleMuteHandler, - Identify: CommandHandler.IdentifyHandler, - SetVolume: CommandHandler.SetVolumeHandler, - Seek: CommandHandler.SeekHandler, - Mute: CommandHandler.MuteHandler, - Unmute: CommandHandler.MuteHandler, - Stop: CommandHandler.StopHandler, - PlayPause: CommandHandler.PlayPauseHandler, - Pause: CommandHandler.PauseHandler, - SetRepeatMode: CommandHandler.SetRepeatModeHandler, - Unpause: CommandHandler.UnpauseHandler - }; - - static configure( - playerManager: framework.PlayerManager, - playbackManager: playbackManager - ): void { - this.playerManager = playerManager; - this.playbackManager = playbackManager; - } - - static playNextHandler(data: DataMessage): void { - translateItems(data, data.options, data.command); - } - - static playNowHandler(data: DataMessage): void { - translateItems(data, data.options, data.command); - } - - static playLastHandler(data: DataMessage): void { - translateItems(data, data.options, data.command); - } - - static shuffleHandler(data: DataMessage): void { - shuffle( - data, - data.options, - (data.options).items[0] - ); - } - - static instantMixHandler(data: DataMessage): void { - instantMix( - data, - data.options, - (data.options).items[0] - ); - } - - static displayContentHandler(data: DataMessage): void { - if (!this.playbackManager.isPlaying()) { - DocumentManager.showItemId((data.options).ItemId); - } - } - - static nextTrackHandler(): void { - if ( - window.playlist && - window.currentPlaylistIndex < window.playlist.length - 1 - ) { - this.playbackManager.playNextItem({}, true); - } - } - - static previousTrackHandler(): void { - if (window.playlist && window.currentPlaylistIndex > 0) { - this.playbackManager.playPreviousItem({}); - } - } - - static setAudioStreamIndexHandler(data: DataMessage): void { - setAudioStreamIndex($scope, (data.options).index); - } - - static setSubtitleStreamIndexHandler(data: DataMessage): void { - setSubtitleStreamIndex($scope, (data.options).index); - } - - // VolumeUp, VolumeDown and ToggleMute commands seem to be handled on the sender in the current implementation. - // From what I can tell there's no convenient way for the receiver to get its own volume. - // We should probably remove these commands in the future. - static VolumeUpHandler(): void { - console.log('VolumeUp handler not implemented'); - } - - static VolumeDownHandler(): void { - console.log('VolumeDown handler not implemented'); - } - - static ToggleMuteHandler(): void { - console.log('ToggleMute handler not implemented'); - } - - static SetVolumeHandler(): void { - // This is now implemented on the sender - console.log('SetVolume handler not implemented'); - } - - static IdentifyHandler(): void { - if (!this.playbackManager.isPlaying()) { - DocumentManager.startBackdropInterval(); - } else { - // When a client connects send back the initial device state (volume etc) via a playbackstop message - reportPlaybackProgress( - $scope, - getReportingParams($scope), - true, - 'playbackstop' - ); - } - } - - static SeekHandler(data: DataMessage): void { - seek((data.options).position * 10000000); - } - - static MuteHandler(): void { - // CommandHandler is now implemented on the sender - console.log('Mute handler not implemented'); - } - - static UnmuteHandler(): void { - // CommandHandler is now implemented on the sender - console.log('Unmute handler not implemented'); - } - - static StopHandler(): void { - this.playerManager.stop(); - } - - static PlayPauseHandler(): void { - if ( - this.playerManager.getPlayerState() === - cast.framework.messages.PlayerState.PAUSED - ) { - this.playerManager.play(); - } else { - this.playerManager.pause(); - } - } - - static PauseHandler(): void { - this.playerManager.pause(); - } - - static SetRepeatModeHandler(data: DataMessage): void { - window.repeatMode = (data.options).RepeatMode; - window.reportEventType = 'repeatmodechange'; - } - - static UnpauseHandler(): void { - this.playerManager.play(); - } - - // We should avoid using a defaulthandler that has a purpose other than informing the dev/user - // Currently all unhandled commands will be treated as play commands. - static defaultHandler(data: DataMessage): void { - translateItems(data, data.options, 'play'); - } - - static processMessage(data: DataMessage, command: string): void { - const commandHandler = this.supportedCommands[command]; - - if (typeof commandHandler === 'function') { - console.debug( - `Command "${command}" received. Identified handler, calling identified handler.` - ); - commandHandler.bind(this)(data); - } else { - console.log( - `Command "${command}" received. Could not identify handler, calling default handler.` - ); - this.defaultHandler(data); - } - } + private static playerManager: framework.PlayerManager; + private static playbackManager: playbackManager; + private static supportedCommands: SupportedCommands = { + PlayNext: CommandHandler.playNextHandler, + PlayNow: CommandHandler.playNowHandler, + PlayLast: CommandHandler.playLastHandler, + Shuffle: CommandHandler.shuffleHandler, + InstantMix: CommandHandler.instantMixHandler, + DisplayContent: CommandHandler.displayContentHandler, + NextTrack: CommandHandler.nextTrackHandler, + PreviousTrack: CommandHandler.previousTrackHandler, + SetAudioStreamIndex: CommandHandler.setAudioStreamIndexHandler, + SetSubtitleStreamIndex: CommandHandler.setSubtitleStreamIndexHandler, + VolumeUp: CommandHandler.VolumeUpHandler, + VolumeDown: CommandHandler.VolumeDownHandler, + ToggleMute: CommandHandler.ToggleMuteHandler, + Identify: CommandHandler.IdentifyHandler, + SetVolume: CommandHandler.SetVolumeHandler, + Seek: CommandHandler.SeekHandler, + Mute: CommandHandler.MuteHandler, + Unmute: CommandHandler.MuteHandler, + Stop: CommandHandler.StopHandler, + PlayPause: CommandHandler.PlayPauseHandler, + Pause: CommandHandler.PauseHandler, + SetRepeatMode: CommandHandler.SetRepeatModeHandler, + Unpause: CommandHandler.UnpauseHandler + }; + + static configure( + playerManager: framework.PlayerManager, + playbackManager: playbackManager + ): void { + this.playerManager = playerManager; + this.playbackManager = playbackManager; + } + + static playNextHandler(data: DataMessage): void { + translateItems(data, data.options, data.command); + } + + static playNowHandler(data: DataMessage): void { + translateItems(data, data.options, data.command); + } + + static playLastHandler(data: DataMessage): void { + translateItems(data, data.options, data.command); + } + + static shuffleHandler(data: DataMessage): void { + shuffle( + data, + data.options, + (data.options).items[0] + ); + } + + static instantMixHandler(data: DataMessage): void { + instantMix( + data, + data.options, + (data.options).items[0] + ); + } + + static displayContentHandler(data: DataMessage): void { + if (!this.playbackManager.isPlaying()) { + DocumentManager.showItemId((data.options).ItemId); + } + } + + static nextTrackHandler(): void { + if ( + window.playlist && + window.currentPlaylistIndex < window.playlist.length - 1 + ) { + this.playbackManager.playNextItem({}, true); + } + } + + static previousTrackHandler(): void { + if (window.playlist && window.currentPlaylistIndex > 0) { + this.playbackManager.playPreviousItem({}); + } + } + + static setAudioStreamIndexHandler(data: DataMessage): void { + setAudioStreamIndex($scope, (data.options).index); + } + + static setSubtitleStreamIndexHandler(data: DataMessage): void { + setSubtitleStreamIndex($scope, (data.options).index); + } + + // VolumeUp, VolumeDown and ToggleMute commands seem to be handled on the sender in the current implementation. + // From what I can tell there's no convenient way for the receiver to get its own volume. + // We should probably remove these commands in the future. + static VolumeUpHandler(): void { + console.log('VolumeUp handler not implemented'); + } + + static VolumeDownHandler(): void { + console.log('VolumeDown handler not implemented'); + } + + static ToggleMuteHandler(): void { + console.log('ToggleMute handler not implemented'); + } + + static SetVolumeHandler(): void { + // This is now implemented on the sender + console.log('SetVolume handler not implemented'); + } + + static IdentifyHandler(): void { + if (!this.playbackManager.isPlaying()) { + DocumentManager.startBackdropInterval(); + } else { + // When a client connects send back the initial device state (volume etc) via a playbackstop message + reportPlaybackProgress( + $scope, + getReportingParams($scope), + true, + 'playbackstop' + ); + } + } + + static SeekHandler(data: DataMessage): void { + seek((data.options).position * 10000000); + } + + static MuteHandler(): void { + // CommandHandler is now implemented on the sender + console.log('Mute handler not implemented'); + } + + static UnmuteHandler(): void { + // CommandHandler is now implemented on the sender + console.log('Unmute handler not implemented'); + } + + static StopHandler(): void { + this.playerManager.stop(); + } + + static PlayPauseHandler(): void { + if ( + this.playerManager.getPlayerState() === + cast.framework.messages.PlayerState.PAUSED + ) { + this.playerManager.play(); + } else { + this.playerManager.pause(); + } + } + + static PauseHandler(): void { + this.playerManager.pause(); + } + + static SetRepeatModeHandler(data: DataMessage): void { + window.repeatMode = (data.options).RepeatMode; + window.reportEventType = 'repeatmodechange'; + } + + static UnpauseHandler(): void { + this.playerManager.play(); + } + + // We should avoid using a defaulthandler that has a purpose other than informing the dev/user + // Currently all unhandled commands will be treated as play commands. + static defaultHandler(data: DataMessage): void { + translateItems(data, data.options, 'play'); + } + + static processMessage(data: DataMessage, command: string): void { + const commandHandler = this.supportedCommands[command]; + + if (typeof commandHandler === 'function') { + console.debug( + `Command "${command}" received. Identified handler, calling identified handler.` + ); + commandHandler.bind(this)(data); + } else { + console.log( + `Command "${command}" received. Could not identify handler, calling default handler.` + ); + this.defaultHandler(data); + } + } } diff --git a/src/components/deviceprofileBuilder.ts b/src/components/deviceprofileBuilder.ts index 2ced0f48..f14414f5 100644 --- a/src/components/deviceprofileBuilder.ts +++ b/src/components/deviceprofileBuilder.ts @@ -16,25 +16,25 @@ import { ProfileConditionValue } from '../api/generated/models/profile-condition import { deviceIds, getActiveDeviceId } from './castDevices'; import { - hasSurroundSupport, - hasTextTrackSupport, - hasVP8Support, - hasVP9Support, - getMaxWidthSupport, - getH26xProfileSupport, - getH26xLevelSupport, - getSupportedVPXVideoCodecs, - getSupportedMP4VideoCodecs, - getSupportedMP4AudioCodecs, - getSupportedHLSVideoCodecs, - getSupportedHLSAudioCodecs, - getSupportedWebMAudioCodecs, - getSupportedAudioCodecs + hasSurroundSupport, + hasTextTrackSupport, + hasVP8Support, + hasVP9Support, + getMaxWidthSupport, + getH26xProfileSupport, + getH26xLevelSupport, + getSupportedVPXVideoCodecs, + getSupportedMP4VideoCodecs, + getSupportedMP4AudioCodecs, + getSupportedHLSVideoCodecs, + getSupportedHLSAudioCodecs, + getSupportedWebMAudioCodecs, + getSupportedAudioCodecs } from './codecSupportHelper'; interface ProfileOptions { - enableHls: boolean; - bitrateSetting: number; + enableHls: boolean; + bitrateSetting: number; } let profileOptions: ProfileOptions; @@ -48,314 +48,314 @@ let currentDeviceId: number; * @returns A profile condition created from the parameters. */ function createProfileCondition( - Property: ProfileConditionValue, - Condition: ProfileConditionType, - Value: string, - IsRequired = false + Property: ProfileConditionValue, + Condition: ProfileConditionType, + Value: string, + IsRequired = false ): ProfileCondition { - return { - Condition, - Property, - Value, - IsRequired - }; + return { + Condition, + Property, + Value, + IsRequired + }; } /** * @returns Container profiles. */ function getContainerProfiles(): Array { - return []; + return []; } /** * @returns Response profiles. */ function getResponseProfiles(): Array { - // This seems related to DLNA, it might not be needed? - return [ - { - Type: DlnaProfileType.Video, - Container: 'm4v', - MimeType: 'video/mp4' - } - ]; + // This seems related to DLNA, it might not be needed? + return [ + { + Type: DlnaProfileType.Video, + Container: 'm4v', + MimeType: 'video/mp4' + } + ]; } /** * @returns Direct play profiles. */ function getDirectPlayProfiles(): Array { - const DirectPlayProfiles: Array = []; - - if (currentDeviceId !== deviceIds.AUDIO) { - const mp4VideoCodecs = getSupportedMP4VideoCodecs(); - const mp4AudioCodecs = getSupportedMP4AudioCodecs(); - const vpxVideoCodecs = getSupportedVPXVideoCodecs(); - const webmAudioCodecs = getSupportedWebMAudioCodecs(); - - for (const codec of vpxVideoCodecs) { - DirectPlayProfiles.push({ - Container: 'webm', - Type: DlnaProfileType.Video, - AudioCodec: webmAudioCodecs.join(','), - VideoCodec: codec - }); - } - - DirectPlayProfiles.push({ - Container: 'mp4,m4v', - Type: DlnaProfileType.Video, - VideoCodec: mp4VideoCodecs.join(','), - AudioCodec: mp4AudioCodecs.join(',') - }); - } - - const supportedAudio = getSupportedAudioCodecs(); - - for (const audioFormat of supportedAudio) { - if (audioFormat === 'mp3') { - DirectPlayProfiles.push({ - Container: audioFormat, - Type: DlnaProfileType.Audio, - AudioCodec: audioFormat - }); - } else if (audioFormat === 'webma') { - DirectPlayProfiles.push({ - Container: 'webma,webm', - Type: DlnaProfileType.Audio - }); - } else { - DirectPlayProfiles.push({ - Container: audioFormat, - Type: DlnaProfileType.Audio - }); + const DirectPlayProfiles: Array = []; + + if (currentDeviceId !== deviceIds.AUDIO) { + const mp4VideoCodecs = getSupportedMP4VideoCodecs(); + const mp4AudioCodecs = getSupportedMP4AudioCodecs(); + const vpxVideoCodecs = getSupportedVPXVideoCodecs(); + const webmAudioCodecs = getSupportedWebMAudioCodecs(); + + for (const codec of vpxVideoCodecs) { + DirectPlayProfiles.push({ + Container: 'webm', + Type: DlnaProfileType.Video, + AudioCodec: webmAudioCodecs.join(','), + VideoCodec: codec + }); + } + + DirectPlayProfiles.push({ + Container: 'mp4,m4v', + Type: DlnaProfileType.Video, + VideoCodec: mp4VideoCodecs.join(','), + AudioCodec: mp4AudioCodecs.join(',') + }); } - // aac also appears in the m4a and m4b container - if (audioFormat === 'aac') { - DirectPlayProfiles.push({ - Container: 'm4a,m4b', - AudioCodec: audioFormat, - Type: DlnaProfileType.Audio - }); + const supportedAudio = getSupportedAudioCodecs(); + + for (const audioFormat of supportedAudio) { + if (audioFormat === 'mp3') { + DirectPlayProfiles.push({ + Container: audioFormat, + Type: DlnaProfileType.Audio, + AudioCodec: audioFormat + }); + } else if (audioFormat === 'webma') { + DirectPlayProfiles.push({ + Container: 'webma,webm', + Type: DlnaProfileType.Audio + }); + } else { + DirectPlayProfiles.push({ + Container: audioFormat, + Type: DlnaProfileType.Audio + }); + } + + // aac also appears in the m4a and m4b container + if (audioFormat === 'aac') { + DirectPlayProfiles.push({ + Container: 'm4a,m4b', + AudioCodec: audioFormat, + Type: DlnaProfileType.Audio + }); + } } - } - return DirectPlayProfiles; + return DirectPlayProfiles; } /** * @returns Codec profiles. */ function getCodecProfiles(): Array { - const CodecProfiles: Array = []; - - const audioConditions: CodecProfile = { - Type: CodecType.Audio, - Codec: 'flac', - Conditions: [ - createProfileCondition( - ProfileConditionValue.AudioSampleRate, - ProfileConditionType.LessThanEqual, - '96000' - ), - createProfileCondition( - ProfileConditionValue.AudioBitDepth, - ProfileConditionType.LessThanEqual, - '24' - ) - ] - }; - - CodecProfiles.push(audioConditions); - - // If device is audio only, don't add all the video related stuff - if (currentDeviceId == deviceIds.AUDIO) { + const CodecProfiles: Array = []; + + const audioConditions: CodecProfile = { + Type: CodecType.Audio, + Codec: 'flac', + Conditions: [ + createProfileCondition( + ProfileConditionValue.AudioSampleRate, + ProfileConditionType.LessThanEqual, + '96000' + ), + createProfileCondition( + ProfileConditionValue.AudioBitDepth, + ProfileConditionType.LessThanEqual, + '24' + ) + ] + }; + + CodecProfiles.push(audioConditions); + + // If device is audio only, don't add all the video related stuff + if (currentDeviceId == deviceIds.AUDIO) { + return CodecProfiles; + } + + const aacConditions: CodecProfile = { + Type: CodecType.VideoAudio, + Codec: 'aac', + Conditions: [ + // Not sure what secondary audio means in this context. Multiple audio tracks? + createProfileCondition( + ProfileConditionValue.IsSecondaryAudio, + ProfileConditionType.Equals, + 'false' + ), + createProfileCondition( + ProfileConditionValue.IsSecondaryAudio, + ProfileConditionType.LessThanEqual, + '2' + ) + ] + }; + + CodecProfiles.push(aacConditions); + + const maxWidth: number = getMaxWidthSupport(currentDeviceId); + const h26xLevel: number = getH26xLevelSupport(currentDeviceId); + const h26xProfile: string = getH26xProfileSupport(currentDeviceId); + + const h26xConditions: CodecProfile = { + Type: CodecType.Video, + Codec: 'h264', + Conditions: [ + createProfileCondition( + ProfileConditionValue.IsAnamorphic, + ProfileConditionType.NotEquals, + 'true' + ), + createProfileCondition( + ProfileConditionValue.VideoProfile, + ProfileConditionType.EqualsAny, + h26xProfile + ), + createProfileCondition( + ProfileConditionValue.VideoLevel, + ProfileConditionType.LessThanEqual, + h26xLevel.toString() + ), + createProfileCondition( + ProfileConditionValue.Width, + ProfileConditionType.LessThanEqual, + maxWidth.toString(), + true + ) + ] + }; + + CodecProfiles.push(h26xConditions); + + const videoConditions: CodecProfile = { + Type: CodecType.Video, + Conditions: [ + createProfileCondition( + ProfileConditionValue.Width, + ProfileConditionType.LessThanEqual, + maxWidth.toString(), + true + ) + ] + }; + + CodecProfiles.push(videoConditions); + + const videoAudioConditions: CodecProfile = { + Type: CodecType.VideoAudio, + Conditions: [ + createProfileCondition( + ProfileConditionValue.IsSecondaryAudio, + ProfileConditionType.Equals, + 'false' + ) + ] + }; + + CodecProfiles.push(videoAudioConditions); + return CodecProfiles; - } - - const aacConditions: CodecProfile = { - Type: CodecType.VideoAudio, - Codec: 'aac', - Conditions: [ - // Not sure what secondary audio means in this context. Multiple audio tracks? - createProfileCondition( - ProfileConditionValue.IsSecondaryAudio, - ProfileConditionType.Equals, - 'false' - ), - createProfileCondition( - ProfileConditionValue.IsSecondaryAudio, - ProfileConditionType.LessThanEqual, - '2' - ) - ] - }; - - CodecProfiles.push(aacConditions); - - const maxWidth: number = getMaxWidthSupport(currentDeviceId); - const h26xLevel: number = getH26xLevelSupport(currentDeviceId); - const h26xProfile: string = getH26xProfileSupport(currentDeviceId); - - const h26xConditions: CodecProfile = { - Type: CodecType.Video, - Codec: 'h264', - Conditions: [ - createProfileCondition( - ProfileConditionValue.IsAnamorphic, - ProfileConditionType.NotEquals, - 'true' - ), - createProfileCondition( - ProfileConditionValue.VideoProfile, - ProfileConditionType.EqualsAny, - h26xProfile - ), - createProfileCondition( - ProfileConditionValue.VideoLevel, - ProfileConditionType.LessThanEqual, - h26xLevel.toString() - ), - createProfileCondition( - ProfileConditionValue.Width, - ProfileConditionType.LessThanEqual, - maxWidth.toString(), - true - ) - ] - }; - - CodecProfiles.push(h26xConditions); - - const videoConditions: CodecProfile = { - Type: CodecType.Video, - Conditions: [ - createProfileCondition( - ProfileConditionValue.Width, - ProfileConditionType.LessThanEqual, - maxWidth.toString(), - true - ) - ] - }; - - CodecProfiles.push(videoConditions); - - const videoAudioConditions: CodecProfile = { - Type: CodecType.VideoAudio, - Conditions: [ - createProfileCondition( - ProfileConditionValue.IsSecondaryAudio, - ProfileConditionType.Equals, - 'false' - ) - ] - }; - - CodecProfiles.push(videoAudioConditions); - - return CodecProfiles; } /** * @returns Transcoding profiles. */ function getTranscodingProfiles(): Array { - const TranscodingProfiles: Array = []; - - const hlsAudioCodecs = getSupportedHLSAudioCodecs(); - const audioChannels: number = hasSurroundSupport() ? 6 : 2; - - if (profileOptions.enableHls !== false) { - TranscodingProfiles.push({ - Container: 'ts', - Type: DlnaProfileType.Audio, - AudioCodec: hlsAudioCodecs.join(','), - Context: EncodingContext.Streaming, - Protocol: 'hls', - MaxAudioChannels: audioChannels.toString(), - MinSegments: 1, - BreakOnNonKeyFrames: false - }); - } - - const supportedAudio = getSupportedAudioCodecs(); - - // audio only profiles here - for (const audioFormat of supportedAudio) { - TranscodingProfiles.push({ - Container: audioFormat, - Type: DlnaProfileType.Audio, - AudioCodec: audioFormat, - Context: EncodingContext.Streaming, - Protocol: 'http', - MaxAudioChannels: audioChannels.toString() - }); - } - - // If device is audio only, don't add all the video related stuff - if (currentDeviceId == deviceIds.AUDIO) { + const TranscodingProfiles: Array = []; + + const hlsAudioCodecs = getSupportedHLSAudioCodecs(); + const audioChannels: number = hasSurroundSupport() ? 6 : 2; + + if (profileOptions.enableHls !== false) { + TranscodingProfiles.push({ + Container: 'ts', + Type: DlnaProfileType.Audio, + AudioCodec: hlsAudioCodecs.join(','), + Context: EncodingContext.Streaming, + Protocol: 'hls', + MaxAudioChannels: audioChannels.toString(), + MinSegments: 1, + BreakOnNonKeyFrames: false + }); + } + + const supportedAudio = getSupportedAudioCodecs(); + + // audio only profiles here + for (const audioFormat of supportedAudio) { + TranscodingProfiles.push({ + Container: audioFormat, + Type: DlnaProfileType.Audio, + AudioCodec: audioFormat, + Context: EncodingContext.Streaming, + Protocol: 'http', + MaxAudioChannels: audioChannels.toString() + }); + } + + // If device is audio only, don't add all the video related stuff + if (currentDeviceId == deviceIds.AUDIO) { + return TranscodingProfiles; + } + + const hlsVideoCodecs = getSupportedHLSVideoCodecs(); + + if ( + hlsVideoCodecs.length && + hlsAudioCodecs.length && + profileOptions.enableHls !== false + ) { + TranscodingProfiles.push({ + Container: 'ts', + Type: DlnaProfileType.Video, + AudioCodec: hlsAudioCodecs.join(','), + VideoCodec: hlsVideoCodecs.join(','), + Context: EncodingContext.Streaming, + Protocol: 'hls', + MaxAudioChannels: audioChannels.toString(), + MinSegments: 1, + BreakOnNonKeyFrames: false + }); + } + + if (hasVP8Support() || hasVP9Support()) { + TranscodingProfiles.push({ + Container: 'webm', + Type: DlnaProfileType.Video, + AudioCodec: 'vorbis', + VideoCodec: 'vpx', + Context: EncodingContext.Streaming, + Protocol: 'http', + // If audio transcoding is needed, limit channels to number of physical audio channels + // Trying to transcode to 5 channels when there are only 2 speakers generally does not sound good + MaxAudioChannels: audioChannels.toString() + }); + } + return TranscodingProfiles; - } - - const hlsVideoCodecs = getSupportedHLSVideoCodecs(); - - if ( - hlsVideoCodecs.length && - hlsAudioCodecs.length && - profileOptions.enableHls !== false - ) { - TranscodingProfiles.push({ - Container: 'ts', - Type: DlnaProfileType.Video, - AudioCodec: hlsAudioCodecs.join(','), - VideoCodec: hlsVideoCodecs.join(','), - Context: EncodingContext.Streaming, - Protocol: 'hls', - MaxAudioChannels: audioChannels.toString(), - MinSegments: 1, - BreakOnNonKeyFrames: false - }); - } - - if (hasVP8Support() || hasVP9Support()) { - TranscodingProfiles.push({ - Container: 'webm', - Type: DlnaProfileType.Video, - AudioCodec: 'vorbis', - VideoCodec: 'vpx', - Context: EncodingContext.Streaming, - Protocol: 'http', - // If audio transcoding is needed, limit channels to number of physical audio channels - // Trying to transcode to 5 channels when there are only 2 speakers generally does not sound good - MaxAudioChannels: audioChannels.toString() - }); - } - - return TranscodingProfiles; } /** * @returns Subtitle profiles. */ function getSubtitleProfiles(): Array { - const subProfiles: Array = []; - - if (hasTextTrackSupport(currentDeviceId)) { - subProfiles.push({ - Format: 'vtt', - Method: SubtitleDeliveryMethod.External - }); - - subProfiles.push({ - Format: 'vtt', - Method: SubtitleDeliveryMethod.Hls - }); - } + const subProfiles: Array = []; + + if (hasTextTrackSupport(currentDeviceId)) { + subProfiles.push({ + Format: 'vtt', + Method: SubtitleDeliveryMethod.External + }); + + subProfiles.push({ + Format: 'vtt', + Method: SubtitleDeliveryMethod.Hls + }); + } - return subProfiles; + return subProfiles; } /** @@ -365,24 +365,27 @@ function getSubtitleProfiles(): Array { * @returns Device profile. */ export function getDeviceProfile(options: ProfileOptions): DeviceProfile { - profileOptions = options; - currentDeviceId = getActiveDeviceId(); - - // MaxStaticBitrate seems to be for offline sync only - const profile: DeviceProfile = { - MaxStreamingBitrate: options.bitrateSetting, - MaxStaticBitrate: options.bitrateSetting, - MusicStreamingTranscodingBitrate: Math.min(options.bitrateSetting, 192000) - }; - - profile.DirectPlayProfiles = getDirectPlayProfiles(); - profile.TranscodingProfiles = getTranscodingProfiles(); - profile.ContainerProfiles = getContainerProfiles(); - profile.CodecProfiles = getCodecProfiles(); - profile.SubtitleProfiles = getSubtitleProfiles(); - profile.ResponseProfiles = getResponseProfiles(); - - return profile; + profileOptions = options; + currentDeviceId = getActiveDeviceId(); + + // MaxStaticBitrate seems to be for offline sync only + const profile: DeviceProfile = { + MaxStreamingBitrate: options.bitrateSetting, + MaxStaticBitrate: options.bitrateSetting, + MusicStreamingTranscodingBitrate: Math.min( + options.bitrateSetting, + 192000 + ) + }; + + profile.DirectPlayProfiles = getDirectPlayProfiles(); + profile.TranscodingProfiles = getTranscodingProfiles(); + profile.ContainerProfiles = getContainerProfiles(); + profile.CodecProfiles = getCodecProfiles(); + profile.SubtitleProfiles = getSubtitleProfiles(); + profile.ResponseProfiles = getResponseProfiles(); + + return profile; } export default getDeviceProfile; diff --git a/src/components/documentManager.ts b/src/components/documentManager.ts index 4ae57a75..12ce29de 100644 --- a/src/components/documentManager.ts +++ b/src/components/documentManager.ts @@ -4,781 +4,811 @@ import { deviceIds, getActiveDeviceId } from './castDevices'; import { BaseItemDto } from '~/api/generated/models/base-item-dto'; export abstract class DocumentManager { - // Duration between each backdrop switch in ms - private static backdropPeriodMs: number | null = 30000; - // Timer state - so that we don't start the interval more than necessary - private static backdropTimer = 0; - - // TODO make enum - private static status = ''; - - /** - * Hide the document body on chromecast audio to save resources - */ - public static initialize(): void { - if (getActiveDeviceId() === deviceIds.AUDIO) { - document.body.style.display = 'none'; - } - } - - /** - * Set the background image for a html element, without preload. - * You should do the preloading first with preloadImage. - * - * @param element - HTML Element - * @param src - URL to the image or null to remove the active one - */ - private static setBackgroundImage( - element: HTMLElement, - src: string | null - ): void { - if (src) { - element.style.backgroundImage = `url(${src})`; - } else { - element.style.backgroundImage = ''; - } - } - - /** - * Preload an image - * - * @param src - URL to the image or null - * @returns wait for the preload and return the url to use. Might be nulled after loading error. - */ - private static preloadImage(src: string | null): Promise { - if (src) { - return new Promise((resolve, reject) => { - const preload = new Image(); - - preload.src = src; - preload.addEventListener('load', () => { - resolve(src); - }); - preload.addEventListener('error', () => { - // might also resolve and return null here, to have the caller take away the background. - reject(); - }); - }); - } else { - return Promise.resolve(null); - } - } - - /** - * Get url for primary image for a given item - * - * @param item - to look up - * @returns url to image after preload - */ - private static getPrimaryImageUrl(item: BaseItemDto): Promise { - let src: string | null = null; - - if (item.AlbumPrimaryImageTag && item.AlbumId) { - src = JellyfinApi.createImageUrl( - item.AlbumId, - 'Primary', - item.AlbumPrimaryImageTag - ); - } else if (item.ImageTags?.Primary && item.Id) { - src = JellyfinApi.createImageUrl( - item.Id, - 'Primary', - item.ImageTags.Primary - ); + // Duration between each backdrop switch in ms + private static backdropPeriodMs: number | null = 30000; + // Timer state - so that we don't start the interval more than necessary + private static backdropTimer = 0; + + // TODO make enum + private static status = ''; + + /** + * Hide the document body on chromecast audio to save resources + */ + public static initialize(): void { + if (getActiveDeviceId() === deviceIds.AUDIO) { + document.body.style.display = 'none'; + } } - if ( - item?.UserData?.PlayedPercentage && - item?.UserData?.PlayedPercentage < 100 && - !item.IsFolder && - src != null - ) { - src += `&PercentPlayed=${item.UserData.PlayedPercentage}`; + /** + * Set the background image for a html element, without preload. + * You should do the preloading first with preloadImage. + * + * @param element - HTML Element + * @param src - URL to the image or null to remove the active one + */ + private static setBackgroundImage( + element: HTMLElement, + src: string | null + ): void { + if (src) { + element.style.backgroundImage = `url(${src})`; + } else { + element.style.backgroundImage = ''; + } } - return this.preloadImage(src); - } - - /** - * Get url for logo image for a given item - * - * @param item - to look up - * @returns url to logo image after preload - */ - private static getLogoUrl(item: BaseItemDto): Promise { - let src: string | null = null; - - if (item.ImageTags?.Logo && item.Id) { - src = JellyfinApi.createImageUrl(item.Id, 'Logo', item.ImageTags.Logo); - } else if (item.ParentLogoItemId && item.ParentLogoImageTag) { - src = JellyfinApi.createImageUrl( - item.ParentLogoItemId, - 'Logo', - item.ParentLogoImageTag - ); + /** + * Preload an image + * + * @param src - URL to the image or null + * @returns wait for the preload and return the url to use. Might be nulled after loading error. + */ + private static preloadImage(src: string | null): Promise { + if (src) { + return new Promise((resolve, reject) => { + const preload = new Image(); + + preload.src = src; + preload.addEventListener('load', () => { + resolve(src); + }); + preload.addEventListener('error', () => { + // might also resolve and return null here, to have the caller take away the background. + reject(); + }); + }); + } else { + return Promise.resolve(null); + } } - return this.preloadImage(src); - } - - /** - * This fucntion takes an item and shows details about it - * on the details page. This happens when no media is playing, - * and the connected client is browsing the library. - * - * @param item - to show information about - * @returns for the page to load - */ - public static async showItem(item: BaseItemDto): Promise { - // no showItem for cc audio - if (getActiveDeviceId() === deviceIds.AUDIO) { - return; - } + /** + * Get url for primary image for a given item + * + * @param item - to look up + * @returns url to image after preload + */ + private static getPrimaryImageUrl( + item: BaseItemDto + ): Promise { + let src: string | null = null; + + if (item.AlbumPrimaryImageTag && item.AlbumId) { + src = JellyfinApi.createImageUrl( + item.AlbumId, + 'Primary', + item.AlbumPrimaryImageTag + ); + } else if (item.ImageTags?.Primary && item.Id) { + src = JellyfinApi.createImageUrl( + item.Id, + 'Primary', + item.ImageTags.Primary + ); + } - // stop cycling backdrops - this.clearBackdropInterval(); - - const urls = [ - await this.getWaitingBackdropUrl(item), - await this.getPrimaryImageUrl(item), - await this.getLogoUrl(item) - ]; - - requestAnimationFrame(() => { - this.setWaitingBackdrop(urls[0], item); - this.setDetailImage(urls[1]); - this.setLogo(urls[2]); - - this.setOverview(item.Overview ?? null); - this.setGenres(item?.Genres?.join(' / ') ?? null); - this.setDisplayName(item); - this.setMiscInfo(item); - - this.setRating(item); - - if (item?.UserData?.Played) { - this.setPlayedIndicator(true); - } else if (item?.UserData?.UnplayedItemCount) { - this.setPlayedIndicator(item?.UserData?.UnplayedItemCount); - } else { - this.setPlayedIndicator(false); - } - - if ( - item?.UserData?.PlayedPercentage && - item?.UserData?.PlayedPercentage < 100 && - !item.IsFolder - ) { - this.setHasPlayedPercentage(false); - this.setPlayedPercentage(item.UserData.PlayedPercentage); - } else { - this.setHasPlayedPercentage(false); - this.setPlayedPercentage(0); - } - - // Switch visible view! - this.setAppStatus('details'); - }); - } - - /** - * Set value of played indicator - * - * @param value - True = played, false = not visible, number = number of unplayed items - */ - private static setPlayedIndicator(value: boolean | number): void { - const playedIndicatorOk = this.getElementById('played-indicator-ok'); - const playedIndicatorValue = this.getElementById('played-indicator-value'); - - if (value === true) { - // All items played - this.setVisibility(playedIndicatorValue, false); - this.setVisibility(playedIndicatorOk, true); - } else if (value === false) { - // No indicator - this.setVisibility(playedIndicatorValue, false); - this.setVisibility(playedIndicatorOk, false); - } else { - // number - playedIndicatorValue.innerHTML = value.toString(); - this.setVisibility(playedIndicatorValue, true); - this.setVisibility(playedIndicatorOk, false); - } - } - - /** - * Show item, but from just the id number, not an actual item. - * Looks up the item and then calls showItem - * - * @param itemId - id of item to look up - * @returns promise that resolves when the item is shown - */ - public static async showItemId(itemId: string): Promise { - // no showItemId for cc audio - if (getActiveDeviceId() === deviceIds.AUDIO) { - return; + if ( + item?.UserData?.PlayedPercentage && + item?.UserData?.PlayedPercentage < 100 && + !item.IsFolder && + src != null + ) { + src += `&PercentPlayed=${item.UserData.PlayedPercentage}`; + } + + return this.preloadImage(src); + } + + /** + * Get url for logo image for a given item + * + * @param item - to look up + * @returns url to logo image after preload + */ + private static getLogoUrl(item: BaseItemDto): Promise { + let src: string | null = null; + + if (item.ImageTags?.Logo && item.Id) { + src = JellyfinApi.createImageUrl( + item.Id, + 'Logo', + item.ImageTags.Logo + ); + } else if (item.ParentLogoItemId && item.ParentLogoImageTag) { + src = JellyfinApi.createImageUrl( + item.ParentLogoItemId, + 'Logo', + item.ParentLogoImageTag + ); + } + + return this.preloadImage(src); + } + + /** + * This fucntion takes an item and shows details about it + * on the details page. This happens when no media is playing, + * and the connected client is browsing the library. + * + * @param item - to show information about + * @returns for the page to load + */ + public static async showItem(item: BaseItemDto): Promise { + // no showItem for cc audio + if (getActiveDeviceId() === deviceIds.AUDIO) { + return; + } + + // stop cycling backdrops + this.clearBackdropInterval(); + + const urls = [ + await this.getWaitingBackdropUrl(item), + await this.getPrimaryImageUrl(item), + await this.getLogoUrl(item) + ]; + + requestAnimationFrame(() => { + this.setWaitingBackdrop(urls[0], item); + this.setDetailImage(urls[1]); + this.setLogo(urls[2]); + + this.setOverview(item.Overview ?? null); + this.setGenres(item?.Genres?.join(' / ') ?? null); + this.setDisplayName(item); + this.setMiscInfo(item); + + this.setRating(item); + + if (item?.UserData?.Played) { + this.setPlayedIndicator(true); + } else if (item?.UserData?.UnplayedItemCount) { + this.setPlayedIndicator(item?.UserData?.UnplayedItemCount); + } else { + this.setPlayedIndicator(false); + } + + if ( + item?.UserData?.PlayedPercentage && + item?.UserData?.PlayedPercentage < 100 && + !item.IsFolder + ) { + this.setHasPlayedPercentage(false); + this.setPlayedPercentage(item.UserData.PlayedPercentage); + } else { + this.setHasPlayedPercentage(false); + this.setPlayedPercentage(0); + } + + // Switch visible view! + this.setAppStatus('details'); + }); } - const item: BaseItemDto = await JellyfinApi.authAjaxUser( - `Items/${itemId}`, - { - dataType: 'json', - type: 'GET' - } - ); - - DocumentManager.showItem(item); - } - - /** - * Update item rating elements - * - * @param item - to look up - */ - private static setRating(item: BaseItemDto): void { - const starRating = this.getElementById('star-rating'); - const starRatingValue = this.getElementById('star-rating-value'); - - if (item.CommunityRating != null) { - starRatingValue.innerHTML = item.CommunityRating.toFixed(1); - this.setVisibility(starRating, true); - this.setVisibility(starRatingValue, true); - } else { - this.setVisibility(starRating, false); - this.setVisibility(starRatingValue, false); + /** + * Set value of played indicator + * + * @param value - True = played, false = not visible, number = number of unplayed items + */ + private static setPlayedIndicator(value: boolean | number): void { + const playedIndicatorOk = this.getElementById('played-indicator-ok'); + const playedIndicatorValue = this.getElementById( + 'played-indicator-value' + ); + + if (value === true) { + // All items played + this.setVisibility(playedIndicatorValue, false); + this.setVisibility(playedIndicatorOk, true); + } else if (value === false) { + // No indicator + this.setVisibility(playedIndicatorValue, false); + this.setVisibility(playedIndicatorOk, false); + } else { + // number + playedIndicatorValue.innerHTML = value.toString(); + this.setVisibility(playedIndicatorValue, true); + this.setVisibility(playedIndicatorOk, false); + } } - const criticRating = this.getElementById('critic-rating'); - const criticRatingValue = this.getElementById('critic-rating-value'); + /** + * Show item, but from just the id number, not an actual item. + * Looks up the item and then calls showItem + * + * @param itemId - id of item to look up + * @returns promise that resolves when the item is shown + */ + public static async showItemId(itemId: string): Promise { + // no showItemId for cc audio + if (getActiveDeviceId() === deviceIds.AUDIO) { + return; + } - if (item.CriticRating != null) { - const verdict = item.CriticRating >= 60 ? 'fresh' : 'rotten'; + const item: BaseItemDto = await JellyfinApi.authAjaxUser( + `Items/${itemId}`, + { + dataType: 'json', + type: 'GET' + } + ); - criticRating.classList.add(verdict); - criticRating.classList.remove(verdict == 'fresh' ? 'rotten' : 'fresh'); + DocumentManager.showItem(item); + } + + /** + * Update item rating elements + * + * @param item - to look up + */ + private static setRating(item: BaseItemDto): void { + const starRating = this.getElementById('star-rating'); + const starRatingValue = this.getElementById('star-rating-value'); + + if (item.CommunityRating != null) { + starRatingValue.innerHTML = item.CommunityRating.toFixed(1); + this.setVisibility(starRating, true); + this.setVisibility(starRatingValue, true); + } else { + this.setVisibility(starRating, false); + this.setVisibility(starRatingValue, false); + } - criticRatingValue.innerHTML = item.CriticRating.toString(); + const criticRating = this.getElementById('critic-rating'); + const criticRatingValue = this.getElementById('critic-rating-value'); - this.setVisibility(criticRating, true); - this.setVisibility(criticRatingValue, true); - } else { - this.setVisibility(criticRating, false); - this.setVisibility(criticRatingValue, false); + if (item.CriticRating != null) { + const verdict = item.CriticRating >= 60 ? 'fresh' : 'rotten'; + + criticRating.classList.add(verdict); + criticRating.classList.remove( + verdict == 'fresh' ? 'rotten' : 'fresh' + ); + + criticRatingValue.innerHTML = item.CriticRating.toString(); + + this.setVisibility(criticRating, true); + this.setVisibility(criticRatingValue, true); + } else { + this.setVisibility(criticRating, false); + this.setVisibility(criticRatingValue, false); + } } - } - - /** - * Set the status of the app, and switch the visible view - * to the corresponding one. - * - * @param status - to set - */ - public static setAppStatus(status: string): void { - this.status = status; - document.body.className = status; - } - - /** - * Get the status of the app - * - * @returns app status - */ - public static getAppStatus(): string { - return this.status; - } - - // BACKDROP LOGIC - - /** - * Get url to the backdrop image, and return a preload promise. - * - * @param item - Item to use for waiting backdrop, null to remove it. - * @returns promise for the preload to complete - */ - public static getWaitingBackdropUrl( - item: BaseItemDto | null - ): Promise { - // no backdrop as a fallback - let src: string | null = null; - - if (item != null) { - if (item.BackdropImageTags && item.BackdropImageTags.length && item.Id) { - // get first backdrop of image if applicable - src = JellyfinApi.createImageUrl( - item.Id, - 'Backdrop', - item.BackdropImageTags[0] - ); - } else if ( - item.ParentBackdropItemId && - item.ParentBackdropImageTags && - item.ParentBackdropImageTags.length - ) { - // otherwise get first backdrop from parent - src = JellyfinApi.createImageUrl( - item.ParentBackdropItemId, - 'Backdrop', - item.ParentBackdropImageTags[0] + + /** + * Set the status of the app, and switch the visible view + * to the corresponding one. + * + * @param status - to set + */ + public static setAppStatus(status: string): void { + this.status = status; + document.body.className = status; + } + + /** + * Get the status of the app + * + * @returns app status + */ + public static getAppStatus(): string { + return this.status; + } + + // BACKDROP LOGIC + + /** + * Get url to the backdrop image, and return a preload promise. + * + * @param item - Item to use for waiting backdrop, null to remove it. + * @returns promise for the preload to complete + */ + public static getWaitingBackdropUrl( + item: BaseItemDto | null + ): Promise { + // no backdrop as a fallback + let src: string | null = null; + + if (item != null) { + if ( + item.BackdropImageTags && + item.BackdropImageTags.length && + item.Id + ) { + // get first backdrop of image if applicable + src = JellyfinApi.createImageUrl( + item.Id, + 'Backdrop', + item.BackdropImageTags[0] + ); + } else if ( + item.ParentBackdropItemId && + item.ParentBackdropImageTags && + item.ParentBackdropImageTags.length + ) { + // otherwise get first backdrop from parent + src = JellyfinApi.createImageUrl( + item.ParentBackdropItemId, + 'Backdrop', + item.ParentBackdropImageTags[0] + ); + } + } + + return this.preloadImage(src); + } + + /** + * Backdrops are set on the waiting container. + * They are switched around every 30 seconds by default + * (governed by startBackdropInterval) + * + * @param src - Url to image + * @param item - Item to use for waiting backdrop, null to remove it. + */ + public static async setWaitingBackdrop( + src: string | null, + item: BaseItemDto | null + ): Promise { + let element: HTMLElement = this.querySelector( + '#waiting-container-backdrop' ); - } + + this.setBackgroundImage(element, src); + + element = this.getElementById('waiting-description'); + element.innerHTML = item?.Name ?? ''; + } + + /** + * Set a random backdrop on the waiting container + * + * @returns promise waiting for the backdrop to be set + */ + private static async setRandomUserBackdrop(): Promise { + const result = await JellyfinApi.authAjaxUser('Items', { + dataType: 'json', + type: 'GET', + query: { + SortBy: 'Random', + IncludeItemTypes: 'Movie,Series', + ImageTypes: 'Backdrop', + Recursive: true, + Limit: 1, + // Although we're limiting to what the user has access to, + // not everyone will want to see adult backdrops rotating on their TV. + MaxOfficialRating: 'PG-13' + } + }); + + let src: string | null = null; + let item: BaseItemDto | null = null; + + if (result.Items && result.Items[0]) { + item = result.Items[0]; + src = await DocumentManager.getWaitingBackdropUrl(item); + } + + requestAnimationFrame(() => { + DocumentManager.setWaitingBackdrop(src, item); + }); } - return this.preloadImage(src); - } - - /** - * Backdrops are set on the waiting container. - * They are switched around every 30 seconds by default - * (governed by startBackdropInterval) - * - * @param src - Url to image - * @param item - Item to use for waiting backdrop, null to remove it. - */ - public static async setWaitingBackdrop( - src: string | null, - item: BaseItemDto | null - ): Promise { - let element: HTMLElement = this.querySelector( - '#waiting-container-backdrop' - ); - - this.setBackgroundImage(element, src); - - element = this.getElementById('waiting-description'); - element.innerHTML = item?.Name ?? ''; - } - - /** - * Set a random backdrop on the waiting container - * - * @returns promise waiting for the backdrop to be set - */ - private static async setRandomUserBackdrop(): Promise { - const result = await JellyfinApi.authAjaxUser('Items', { - dataType: 'json', - type: 'GET', - query: { - SortBy: 'Random', - IncludeItemTypes: 'Movie,Series', - ImageTypes: 'Backdrop', - Recursive: true, - Limit: 1, - // Although we're limiting to what the user has access to, - // not everyone will want to see adult backdrops rotating on their TV. - MaxOfficialRating: 'PG-13' - } - }); - - let src: string | null = null; - let item: BaseItemDto | null = null; - - if (result.Items && result.Items[0]) { - item = result.Items[0]; - src = await DocumentManager.getWaitingBackdropUrl(item); + /** + * Stop the backdrop rotation + */ + public static clearBackdropInterval(): void { + if (this.backdropTimer !== 0) { + clearInterval(this.backdropTimer); + this.backdropTimer = 0; + } } - requestAnimationFrame(() => { - DocumentManager.setWaitingBackdrop(src, item); - }); - } - - /** - * Stop the backdrop rotation - */ - public static clearBackdropInterval(): void { - if (this.backdropTimer !== 0) { - clearInterval(this.backdropTimer); - this.backdropTimer = 0; + /** + * Start the backdrop rotation, restart if running, stop if disabled + * + * @returns promise for the first backdrop to be set + */ + public static async startBackdropInterval(): Promise { + // no backdrop rotation for cc audio + if (getActiveDeviceId() === deviceIds.AUDIO) { + return; + } + + // avoid running it multiple times + this.clearBackdropInterval(); + + // skip out if it's disabled + if (!this.backdropPeriodMs) { + this.setWaitingBackdrop(null, null); + + return; + } + + this.backdropTimer = ( + setInterval( + () => DocumentManager.setRandomUserBackdrop(), + this.backdropPeriodMs + ) + ); + + await this.setRandomUserBackdrop(); } - } - - /** - * Start the backdrop rotation, restart if running, stop if disabled - * - * @returns promise for the first backdrop to be set - */ - public static async startBackdropInterval(): Promise { - // no backdrop rotation for cc audio - if (getActiveDeviceId() === deviceIds.AUDIO) { - return; + + /** + * Set interval between backdrop changes, null to disable + * + * @param period - in milliseconds or null + */ + public static setBackdropPeriodMs(period: number | null): void { + if (period !== this.backdropPeriodMs) { + this.backdropPeriodMs = period; + + // If the timer was running, restart it + if (this.backdropTimer !== 0) { + // startBackdropInterval will also clear the previous one + this.startBackdropInterval(); + } + + if (period === null) { + // No backdrop is wanted, and the timer has been cleared. + // This call will remove any present backdrop. + this.setWaitingBackdrop(null, null); + } + } } - // avoid running it multiple times - this.clearBackdropInterval(); + /** + * Set background behind the media player, + * this is shown while the media is loading. + * + * @param item - to get backdrop from + */ + public static setPlayerBackdrop(item: BaseItemDto): void { + // no backdrop rotation for cc audio + if (getActiveDeviceId() === deviceIds.AUDIO) { + return; + } - // skip out if it's disabled - if (!this.backdropPeriodMs) { - this.setWaitingBackdrop(null, null); + let backdropUrl: string | null = null; + + if ( + item.BackdropImageTags && + item.BackdropImageTags.length && + item.Id + ) { + backdropUrl = JellyfinApi.createImageUrl( + item.Id, + 'Backdrop', + item.BackdropImageTags[0] + ); + } else if ( + item.ParentBackdropItemId && + item.ParentBackdropImageTags && + item.ParentBackdropImageTags.length + ) { + backdropUrl = JellyfinApi.createImageUrl( + item.ParentBackdropItemId, + 'Backdrop', + item.ParentBackdropImageTags[0] + ); + } - return; + if (backdropUrl != null) { + window.mediaElement?.style.setProperty( + '--background-image', + `url("${backdropUrl}")` + ); + } else { + window.mediaElement?.style.removeProperty('--background-image'); + } } + /* /BACKDROP LOGIC */ + + /** + * Set the URL to the item logo, or null to remove it + * + * @param src - Source url or null + */ + public static setLogo(src: string | null): void { + const element: HTMLElement = this.querySelector('.detailLogo'); - this.backdropTimer = ( - setInterval( - () => DocumentManager.setRandomUserBackdrop(), - this.backdropPeriodMs - ) - ); - - await this.setRandomUserBackdrop(); - } - - /** - * Set interval between backdrop changes, null to disable - * - * @param period - in milliseconds or null - */ - public static setBackdropPeriodMs(period: number | null): void { - if (period !== this.backdropPeriodMs) { - this.backdropPeriodMs = period; - - // If the timer was running, restart it - if (this.backdropTimer !== 0) { - // startBackdropInterval will also clear the previous one - this.startBackdropInterval(); - } - - if (period === null) { - // No backdrop is wanted, and the timer has been cleared. - // This call will remove any present backdrop. - this.setWaitingBackdrop(null, null); - } + this.setBackgroundImage(element, src); } - } - - /** - * Set background behind the media player, - * this is shown while the media is loading. - * - * @param item - to get backdrop from - */ - public static setPlayerBackdrop(item: BaseItemDto): void { - // no backdrop rotation for cc audio - if (getActiveDeviceId() === deviceIds.AUDIO) { - return; + + /** + * Set the URL to the item banner image (I think?), + * or null to remove it + * + * @param src - Source url or null + */ + public static setDetailImage(src: string | null): void { + const element: HTMLElement = this.querySelector('.detailImage'); + + this.setBackgroundImage(element, src); } - let backdropUrl: string | null = null; - - if (item.BackdropImageTags && item.BackdropImageTags.length && item.Id) { - backdropUrl = JellyfinApi.createImageUrl( - item.Id, - 'Backdrop', - item.BackdropImageTags[0] - ); - } else if ( - item.ParentBackdropItemId && - item.ParentBackdropImageTags && - item.ParentBackdropImageTags.length - ) { - backdropUrl = JellyfinApi.createImageUrl( - item.ParentBackdropItemId, - 'Backdrop', - item.ParentBackdropImageTags[0] - ); + /** + * Set the human readable name for an item + * + * This combines the old statement setDisplayName(getDisplayName(item)) + * into setDisplayName(item). + * + * @param item - source for the displayed name + */ + private static setDisplayName(item: BaseItemDto): void { + const name: string = item.EpisodeTitle ?? item.Name; + + let displayName: string = name; + + if (item.Type == 'TvChannel') { + if (item.Number) { + displayName = `${item.Number} ${name}`; + } + } else if ( + item.Type == 'Episode' && + item.IndexNumber != null && + item.ParentIndexNumber != null + ) { + let episode = `S${item.ParentIndexNumber}, E${item.IndexNumber}`; + + if (item.IndexNumberEnd) { + episode += `-${item.IndexNumberEnd}`; + } + + displayName = `${episode} - ${name}`; + } + + const element = this.querySelector('.displayName'); + + element.innerHTML = displayName || ''; } - if (backdropUrl != null) { - window.mediaElement?.style.setProperty( - '--background-image', - `url("${backdropUrl}")` - ); - } else { - window.mediaElement?.style.removeProperty('--background-image'); + /** + * Set the html of the genres container + * + * @param name - String/html for genres box, null to empty + */ + private static setGenres(name: string | null): void { + const element = this.querySelector('.genres'); + + element.innerHTML = name || ''; } - } - /* /BACKDROP LOGIC */ - - /** - * Set the URL to the item logo, or null to remove it - * - * @param src - Source url or null - */ - public static setLogo(src: string | null): void { - const element: HTMLElement = this.querySelector('.detailLogo'); - - this.setBackgroundImage(element, src); - } - - /** - * Set the URL to the item banner image (I think?), - * or null to remove it - * - * @param src - Source url or null - */ - public static setDetailImage(src: string | null): void { - const element: HTMLElement = this.querySelector('.detailImage'); - - this.setBackgroundImage(element, src); - } - - /** - * Set the human readable name for an item - * - * This combines the old statement setDisplayName(getDisplayName(item)) - * into setDisplayName(item). - * - * @param item - source for the displayed name - */ - private static setDisplayName(item: BaseItemDto): void { - const name: string = item.EpisodeTitle ?? item.Name; - - let displayName: string = name; - - if (item.Type == 'TvChannel') { - if (item.Number) { - displayName = `${item.Number} ${name}`; - } - } else if ( - item.Type == 'Episode' && - item.IndexNumber != null && - item.ParentIndexNumber != null - ) { - let episode = `S${item.ParentIndexNumber}, E${item.IndexNumber}`; - - if (item.IndexNumberEnd) { - episode += `-${item.IndexNumberEnd}`; - } - - displayName = `${episode} - ${name}`; + + /** + * Set the html of the overview container + * + * @param name - string or html to insert + */ + private static setOverview(name: string | null): void { + const element = this.querySelector('.overview'); + + element.innerHTML = name || ''; } - const element = this.querySelector('.displayName'); - - element.innerHTML = displayName || ''; - } - - /** - * Set the html of the genres container - * - * @param name - String/html for genres box, null to empty - */ - private static setGenres(name: string | null): void { - const element = this.querySelector('.genres'); - - element.innerHTML = name || ''; - } - - /** - * Set the html of the overview container - * - * @param name - string or html to insert - */ - private static setOverview(name: string | null): void { - const element = this.querySelector('.overview'); - - element.innerHTML = name || ''; - } - - /** - * Set the progress of the progress bar in the - * item details page. (Not the same as the playback ui) - * - * @param value - Percentage to set - */ - private static setPlayedPercentage(value = 0): void { - const element = this.querySelector('.itemProgressBar'); - - element.value = value.toString(); - } - - /** - * Set the visibility of the item progress bar in the - * item details page - * - * @param value - If true, show progress on details page - */ - private static setHasPlayedPercentage(value: boolean): void { - const element = this.querySelector('.detailImageProgressContainer'); - - if (value) { - (element).classList.remove('d-none'); - } else { - (element).classList.add('d-none'); + /** + * Set the progress of the progress bar in the + * item details page. (Not the same as the playback ui) + * + * @param value - Percentage to set + */ + private static setPlayedPercentage(value = 0): void { + const element = ( + this.querySelector('.itemProgressBar') + ); + + element.value = value.toString(); } - } - - /** - * Get a human readable representation of the current position - * in ticks - * - * @param ticks - tick position - * @returns human readable position - */ - private static formatRunningTime(ticks: number): string { - const ticksPerHour = 36000000000; - const ticksPerMinute = 600000000; - const ticksPerSecond = 10000000; - - const parts: string[] = []; - - const hours: number = Math.floor(ticks / ticksPerHour); - - if (hours) { - parts.push(hours.toString()); + + /** + * Set the visibility of the item progress bar in the + * item details page + * + * @param value - If true, show progress on details page + */ + private static setHasPlayedPercentage(value: boolean): void { + const element = this.querySelector('.detailImageProgressContainer'); + + if (value) { + (element).classList.remove('d-none'); + } else { + (element).classList.add('d-none'); + } } - ticks -= hours * ticksPerHour; + /** + * Get a human readable representation of the current position + * in ticks + * + * @param ticks - tick position + * @returns human readable position + */ + private static formatRunningTime(ticks: number): string { + const ticksPerHour = 36000000000; + const ticksPerMinute = 600000000; + const ticksPerSecond = 10000000; - const minutes: number = Math.floor(ticks / ticksPerMinute); + const parts: string[] = []; - ticks -= minutes * ticksPerMinute; + const hours: number = Math.floor(ticks / ticksPerHour); - if (minutes < 10 && hours) { - parts.push(`0${minutes.toString()}`); - } else { - parts.push(minutes.toString()); - } + if (hours) { + parts.push(hours.toString()); + } - const seconds: number = Math.floor(ticks / ticksPerSecond); + ticks -= hours * ticksPerHour; - if (seconds < 10) { - parts.push(`0${seconds.toString()}`); - } else { - parts.push(seconds.toString()); - } + const minutes: number = Math.floor(ticks / ticksPerMinute); - return parts.join(':'); - } - - /** - * Set information about mostly episodes or series - * on the item details page - * - * @param item - to look up - */ - private static setMiscInfo(item: BaseItemDto): void { - const info: Array = []; - - if (item.Type == 'Episode') { - if (item.PremiereDate) { - try { - info.push(parseISO8601Date(item.PremiereDate).toLocaleDateString()); - } catch (e) { - console.log(`Error parsing date: ${item.PremiereDate}`); - } - } - } + ticks -= minutes * ticksPerMinute; - if (item.StartDate) { - try { - info.push(parseISO8601Date(item.StartDate).toLocaleDateString()); - } catch (e) { - console.log(`Error parsing date: ${item.PremiereDate}`); - } - } + if (minutes < 10 && hours) { + parts.push(`0${minutes.toString()}`); + } else { + parts.push(minutes.toString()); + } - if (item.ProductionYear && item.Type == 'Series') { - if (item.Status == 'Continuing') { - info.push(`${item.ProductionYear}-Present`); - } else if (item.ProductionYear) { - let text: string = item.ProductionYear.toString(); + const seconds: number = Math.floor(ticks / ticksPerSecond); - if (item.EndDate) { - try { - const endYear = parseISO8601Date(item.EndDate).getFullYear(); + if (seconds < 10) { + parts.push(`0${seconds.toString()}`); + } else { + parts.push(seconds.toString()); + } - if (endYear != item.ProductionYear) { - text += `-${parseISO8601Date(item.EndDate).getFullYear()}`; + return parts.join(':'); + } + + /** + * Set information about mostly episodes or series + * on the item details page + * + * @param item - to look up + */ + private static setMiscInfo(item: BaseItemDto): void { + const info: Array = []; + + if (item.Type == 'Episode') { + if (item.PremiereDate) { + try { + info.push( + parseISO8601Date(item.PremiereDate).toLocaleDateString() + ); + } catch (e) { + console.log(`Error parsing date: ${item.PremiereDate}`); + } } - } catch (e) { - console.log(`Error parsing date: ${item.EndDate}`); - } } - info.push(text); - } - } + if (item.StartDate) { + try { + info.push( + parseISO8601Date(item.StartDate).toLocaleDateString() + ); + } catch (e) { + console.log(`Error parsing date: ${item.PremiereDate}`); + } + } - if (item.Type != 'Series' && item.Type != 'Episode') { - if (item.ProductionYear) { - info.push(item.ProductionYear.toString()); - } else if (item.PremiereDate) { - try { - info.push( - parseISO8601Date(item.PremiereDate).getFullYear().toString() - ); - } catch (e) { - console.log(`Error parsing date: ${item.PremiereDate}`); - } - } - } + if (item.ProductionYear && item.Type == 'Series') { + if (item.Status == 'Continuing') { + info.push(`${item.ProductionYear}-Present`); + } else if (item.ProductionYear) { + let text: string = item.ProductionYear.toString(); + + if (item.EndDate) { + try { + const endYear = parseISO8601Date( + item.EndDate + ).getFullYear(); + + if (endYear != item.ProductionYear) { + text += `-${parseISO8601Date( + item.EndDate + ).getFullYear()}`; + } + } catch (e) { + console.log(`Error parsing date: ${item.EndDate}`); + } + } + + info.push(text); + } + } - let minutes; + if (item.Type != 'Series' && item.Type != 'Episode') { + if (item.ProductionYear) { + info.push(item.ProductionYear.toString()); + } else if (item.PremiereDate) { + try { + info.push( + parseISO8601Date(item.PremiereDate) + .getFullYear() + .toString() + ); + } catch (e) { + console.log(`Error parsing date: ${item.PremiereDate}`); + } + } + } - if (item.RunTimeTicks && item.Type != 'Series') { - if (item.Type == 'Audio') { - info.push(this.formatRunningTime(item.RunTimeTicks)); - } else { - minutes = item.RunTimeTicks / 600000000; - minutes = minutes || 1; - info.push(`${Math.round(minutes)}min`); - } - } + let minutes; - if ( - item.OfficialRating && - item.Type !== 'Season' && - item.Type !== 'Episode' - ) { - info.push(item.OfficialRating); - } + if (item.RunTimeTicks && item.Type != 'Series') { + if (item.Type == 'Audio') { + info.push(this.formatRunningTime(item.RunTimeTicks)); + } else { + minutes = item.RunTimeTicks / 600000000; + minutes = minutes || 1; + info.push(`${Math.round(minutes)}min`); + } + } - if (item.Video3DFormat) { - info.push('3D'); - } + if ( + item.OfficialRating && + item.Type !== 'Season' && + item.Type !== 'Episode' + ) { + info.push(item.OfficialRating); + } + + if (item.Video3DFormat) { + info.push('3D'); + } + + const element = this.getElementById('miscInfo'); - const element = this.getElementById('miscInfo'); - - element.innerHTML = info.join('    '); - } - - // Generic / Helper functions - /** - * Set the visibility of an element - * - * @param element - Element to set visibility on - * @param visible - True if the element should be visible. - */ - private static setVisibility(element: HTMLElement, visible: boolean): void { - if (visible) { - element.classList.remove('d-none'); - } else { - element.classList.add('d-none'); + element.innerHTML = info.join('    '); } - } - - /** - * Get a HTMLElement from id or throw an error - * - * @param id - ID to look up - * @returns HTML Element - */ - private static getElementById(id: string): HTMLElement { - const element = document.getElementById(id); - - if (!element) { - throw new ReferenceError(`Cannot find element ${id} by id`); + + // Generic / Helper functions + /** + * Set the visibility of an element + * + * @param element - Element to set visibility on + * @param visible - True if the element should be visible. + */ + private static setVisibility(element: HTMLElement, visible: boolean): void { + if (visible) { + element.classList.remove('d-none'); + } else { + element.classList.add('d-none'); + } } - return element; - } + /** + * Get a HTMLElement from id or throw an error + * + * @param id - ID to look up + * @returns HTML Element + */ + private static getElementById(id: string): HTMLElement { + const element = document.getElementById(id); - /** - * Get a HTMLElement by class - * - * @param cls - Class to look up - * @returns HTML Element - */ - private static querySelector(cls: string): HTMLElement { - const element: HTMLElement | null = document.querySelector(cls); + if (!element) { + throw new ReferenceError(`Cannot find element ${id} by id`); + } - if (!element) { - throw new ReferenceError(`Cannot find element ${cls} by class`); + return element; } - return element; - } + /** + * Get a HTMLElement by class + * + * @param cls - Class to look up + * @returns HTML Element + */ + private static querySelector(cls: string): HTMLElement { + const element: HTMLElement | null = document.querySelector(cls); + + if (!element) { + throw new ReferenceError(`Cannot find element ${cls} by class`); + } + + return element; + } } DocumentManager.initialize(); diff --git a/src/components/fetchhelper.ts b/src/components/fetchhelper.ts index 0195e5b6..e1bf18de 100644 --- a/src/components/fetchhelper.ts +++ b/src/components/fetchhelper.ts @@ -5,44 +5,45 @@ * @returns response promise */ function getFetchPromise(request: any): Promise { - const headers = request.headers || {}; - - if (request.dataType === 'json') { - headers.accept = 'application/json'; - } - - const fetchRequest: RequestInit = { - headers: headers, - method: request.type, - credentials: 'same-origin' - }; - let contentType = request.contentType; - - if (request.data) { - if (typeof request.data == 'string') { - fetchRequest.body = request.data; - } else { - fetchRequest.body = paramsToString(request.data); - contentType = - contentType || 'application/x-www-form-urlencoded; charset=UTF-8'; + const headers = request.headers || {}; + + if (request.dataType === 'json') { + headers.accept = 'application/json'; } - } - if (contentType) { - headers['Content-Type'] = contentType; - } + const fetchRequest: RequestInit = { + headers: headers, + method: request.type, + credentials: 'same-origin' + }; + let contentType = request.contentType; + + if (request.data) { + if (typeof request.data == 'string') { + fetchRequest.body = request.data; + } else { + fetchRequest.body = paramsToString(request.data); + contentType = + contentType || + 'application/x-www-form-urlencoded; charset=UTF-8'; + } + } - let url = request.url; + if (contentType) { + headers['Content-Type'] = contentType; + } - if (request.query) { - const paramString = paramsToString(request.query); + let url = request.url; - paramString && (url += `?${paramString}`); - } + if (request.query) { + const paramString = paramsToString(request.query); + + paramString && (url += `?${paramString}`); + } - return request.timeout - ? fetchWithCredentials(url, fetchRequest) - : fetch(url, fetchRequest); + return request.timeout + ? fetchWithCredentials(url, fetchRequest) + : fetch(url, fetchRequest); } /** @@ -53,25 +54,27 @@ function getFetchPromise(request: any): Promise { * @returns response promise */ async function fetchWithCredentials( - url: string, - options: RequestInit + url: string, + options: RequestInit ): Promise { - console.log(`fetchWithCredentials: ${url}`); + console.log(`fetchWithCredentials: ${url}`); - try { - options = options || {}; - options.credentials = 'same-origin'; + try { + options = options || {}; + options.credentials = 'same-origin'; - const response = await fetch(url, options); + const response = await fetch(url, options); - console.log(`fetchWithCredentials: succeeded connecting to url: ${url}`); + console.log( + `fetchWithCredentials: succeeded connecting to url: ${url}` + ); - return response; - } catch (e) { - throw new Error( - `fetchWithCredentials: timed out connecting to url: ${url}` - ); - } + return response; + } catch (e) { + throw new Error( + `fetchWithCredentials: timed out connecting to url: ${url}` + ); + } } /** @@ -81,18 +84,20 @@ async function fetchWithCredentials( * @returns string with encoded values */ function paramsToString(params: Record): string { - const values = []; + const values = []; - for (const key in params) { - const value = params[key]; + for (const key in params) { + const value = params[key]; - null !== value && - void 0 !== value && - '' !== value && - values.push(`${encodeURIComponent(key)}=${encodeURIComponent(value)}`); - } + null !== value && + void 0 !== value && + '' !== value && + values.push( + `${encodeURIComponent(key)}=${encodeURIComponent(value)}` + ); + } - return values.join('&'); + return values.join('&'); } /** @@ -102,37 +107,37 @@ function paramsToString(params: Record): string { * @returns response promise, may be automatically unpacked based on request datatype */ export async function ajax(request: any): Promise { - if (!request) { - throw new Error('Request cannot be null'); - } - - request.headers = request.headers || {}; - console.log(`requesting url: ${request.url}`); - - try { - const response = await getFetchPromise(request); - - console.log(`response status: ${response.status}, url: ${request.url}`); - - if (response.status >= 400) { - return Promise.reject(response); - } else if ( - request.dataType === 'json' || - request.headers?.accept === 'application/json' - ) { - return response.json(); - } else if ( - request.dataType === 'text' || - (response.headers.get('Content-Type') || '') - .toLowerCase() - .indexOf('text/') === 0 - ) { - return response.text(); - } else { - return response; + if (!request) { + throw new Error('Request cannot be null'); + } + + request.headers = request.headers || {}; + console.log(`requesting url: ${request.url}`); + + try { + const response = await getFetchPromise(request); + + console.log(`response status: ${response.status}, url: ${request.url}`); + + if (response.status >= 400) { + return Promise.reject(response); + } else if ( + request.dataType === 'json' || + request.headers?.accept === 'application/json' + ) { + return response.json(); + } else if ( + request.dataType === 'text' || + (response.headers.get('Content-Type') || '') + .toLowerCase() + .indexOf('text/') === 0 + ) { + return response.text(); + } else { + return response; + } + } catch (err) { + console.log(`request failed to url: ${request.url}`); + throw err; } - } catch (err) { - console.log(`request failed to url: ${request.url}`); - throw err; - } } diff --git a/src/components/jellyfinActions.ts b/src/components/jellyfinActions.ts index 0f51ba0d..9f3b25fa 100644 --- a/src/components/jellyfinActions.ts +++ b/src/components/jellyfinActions.ts @@ -1,8 +1,8 @@ import { - getSenderReportingData, - resetPlaybackScope, - extend, - broadcastToMessageBus + getSenderReportingData, + resetPlaybackScope, + extend, + broadcastToMessageBus } from '../helpers'; import { GlobalScope } from '../types/global'; @@ -16,12 +16,12 @@ import { JellyfinApi } from './jellyfinApi'; import { DocumentManager } from './documentManager'; interface PlayRequestQuery extends PlayRequest { - UserId?: string; - StartTimeTicks?: number; - MaxStreamingBitrate?: number; - LiveStreamId?: string; - ItemId?: string; - PlaySessionId?: string; + UserId?: string; + StartTimeTicks?: number; + MaxStreamingBitrate?: number; + LiveStreamId?: string; + ItemId?: string; + PlaySessionId?: string; } let pingInterval: number; @@ -36,16 +36,16 @@ let lastTranscoderPing = 0; * @param reportingParams - parameters to report to the server */ function restartPingInterval( - $scope: GlobalScope, - reportingParams: PlaybackProgressInfo + $scope: GlobalScope, + reportingParams: PlaybackProgressInfo ): void { - stopPingInterval(); + stopPingInterval(); - if (reportingParams.PlayMethod == 'Transcode') { - pingInterval = setInterval(() => { - pingTranscoder(reportingParams); - }, 1000); - } + if (reportingParams.PlayMethod == 'Transcode') { + pingInterval = setInterval(() => { + pingTranscoder(reportingParams); + }, 1000); + } } /** @@ -54,10 +54,10 @@ function restartPingInterval( * Needed to stop the pinging when it's not needed anymore */ export function stopPingInterval(): void { - if (pingInterval !== 0) { - clearInterval(pingInterval); - pingInterval = 0; - } + if (pingInterval !== 0) { + clearInterval(pingInterval); + pingInterval = 0; + } } /** @@ -68,28 +68,28 @@ export function stopPingInterval(): void { * @returns promise to wait for the request */ export function reportPlaybackStart( - $scope: GlobalScope, - reportingParams: PlaybackProgressInfo + $scope: GlobalScope, + reportingParams: PlaybackProgressInfo ): Promise { - // it's just "reporting" that the playback is starting - // but it's also disabling the rotating backdrops - // in the line below. - // TODO move the responsibility to the caller. - DocumentManager.clearBackdropInterval(); - - broadcastToMessageBus({ - //TODO: convert these to use a defined type in the type field - type: 'playbackstart', - data: getSenderReportingData($scope, reportingParams) - }); - - restartPingInterval($scope, reportingParams); - - return JellyfinApi.authAjax('Sessions/Playing', { - type: 'POST', - data: JSON.stringify(reportingParams), - contentType: 'application/json' - }); + // it's just "reporting" that the playback is starting + // but it's also disabling the rotating backdrops + // in the line below. + // TODO move the responsibility to the caller. + DocumentManager.clearBackdropInterval(); + + broadcastToMessageBus({ + //TODO: convert these to use a defined type in the type field + type: 'playbackstart', + data: getSenderReportingData($scope, reportingParams) + }); + + restartPingInterval($scope, reportingParams); + + return JellyfinApi.authAjax('Sessions/Playing', { + type: 'POST', + data: JSON.stringify(reportingParams), + contentType: 'application/json' + }); } /** @@ -102,28 +102,28 @@ export function reportPlaybackStart( * @returns Promise for the http request */ export function reportPlaybackProgress( - $scope: GlobalScope, - reportingParams: PlaybackProgressInfo, - reportToServer = true, - broadcastEventName = 'playbackprogress' + $scope: GlobalScope, + reportingParams: PlaybackProgressInfo, + reportToServer = true, + broadcastEventName = 'playbackprogress' ): Promise { - broadcastToMessageBus({ - type: broadcastEventName, - data: getSenderReportingData($scope, reportingParams) - }); - - if (reportToServer === false) { - return Promise.resolve(); - } - - restartPingInterval($scope, reportingParams); - lastTranscoderPing = new Date().getTime(); - - return JellyfinApi.authAjax('Sessions/Playing/Progress', { - type: 'POST', - data: JSON.stringify(reportingParams), - contentType: 'application/json' - }); + broadcastToMessageBus({ + type: broadcastEventName, + data: getSenderReportingData($scope, reportingParams) + }); + + if (reportToServer === false) { + return Promise.resolve(); + } + + restartPingInterval($scope, reportingParams); + lastTranscoderPing = new Date().getTime(); + + return JellyfinApi.authAjax('Sessions/Playing/Progress', { + type: 'POST', + data: JSON.stringify(reportingParams), + contentType: 'application/json' + }); } /** @@ -134,21 +134,21 @@ export function reportPlaybackProgress( * @returns promise for waiting for the request */ export function reportPlaybackStopped( - $scope: GlobalScope, - reportingParams: PlaybackProgressInfo + $scope: GlobalScope, + reportingParams: PlaybackProgressInfo ): Promise { - stopPingInterval(); - - broadcastToMessageBus({ - type: 'playbackstop', - data: getSenderReportingData($scope, reportingParams) - }); - - return JellyfinApi.authAjax('Sessions/Playing/Stopped', { - type: 'POST', - data: JSON.stringify(reportingParams), - contentType: 'application/json' - }); + stopPingInterval(); + + broadcastToMessageBus({ + type: 'playbackstop', + data: getSenderReportingData($scope, reportingParams) + }); + + return JellyfinApi.authAjax('Sessions/Playing/Stopped', { + type: 'POST', + data: JSON.stringify(reportingParams), + contentType: 'application/json' + }); } /** @@ -161,33 +161,33 @@ export function reportPlaybackStopped( * @returns promise for waiting for the request */ export function pingTranscoder( - reportingParams: PlaybackProgressInfo + reportingParams: PlaybackProgressInfo ): Promise { - const now = new Date().getTime(); + const now = new Date().getTime(); - // 10s is the timeout value, so use half that to report often enough - if (now - lastTranscoderPing < 5000) { - console.debug('Skipping ping due to recent progress check-in'); + // 10s is the timeout value, so use half that to report often enough + if (now - lastTranscoderPing < 5000) { + console.debug('Skipping ping due to recent progress check-in'); - return new Promise((resolve) => { - resolve(undefined); - }); - } - - lastTranscoderPing = new Date().getTime(); - - // 10.7 oddly wants it as a query string parameter. This is a server bug for now. - return JellyfinApi.authAjax( - `Sessions/Playing/Ping?playSessionId=${reportingParams.PlaySessionId}`, - { - type: 'POST', - data: JSON.stringify({ - // jellyfin <= 10.6 wants it in the post data. - PlaySessionId: reportingParams.PlaySessionId - }), - contentType: 'application/json' + return new Promise((resolve) => { + resolve(undefined); + }); } - ); + + lastTranscoderPing = new Date().getTime(); + + // 10.7 oddly wants it as a query string parameter. This is a server bug for now. + return JellyfinApi.authAjax( + `Sessions/Playing/Ping?playSessionId=${reportingParams.PlaySessionId}`, + { + type: 'POST', + data: JSON.stringify({ + // jellyfin <= 10.6 wants it in the post data. + PlaySessionId: reportingParams.PlaySessionId + }), + contentType: 'application/json' + } + ); } /** @@ -198,18 +198,18 @@ export function pingTranscoder( * @param serverItem - item that is playing */ export function load( - $scope: GlobalScope, - customData: PlaybackProgressInfo, - serverItem: BaseItemDto + $scope: GlobalScope, + customData: PlaybackProgressInfo, + serverItem: BaseItemDto ): void { - resetPlaybackScope($scope); + resetPlaybackScope($scope); - extend($scope, customData); + extend($scope, customData); - $scope.item = serverItem; + $scope.item = serverItem; - DocumentManager.setAppStatus('backdrop'); - $scope.mediaType = serverItem?.MediaType; + DocumentManager.setAppStatus('backdrop'); + $scope.mediaType = serverItem?.MediaType; } /** @@ -223,31 +223,31 @@ export function load( * @param $scope - global scope */ export function play($scope: GlobalScope): void { - if ( - DocumentManager.getAppStatus() == 'backdrop' || - DocumentManager.getAppStatus() == 'playing-with-controls' || - DocumentManager.getAppStatus() == 'playing' || - DocumentManager.getAppStatus() == 'audio' - ) { - setTimeout(() => { - window.mediaManager.play(); - - if ($scope.mediaType == 'Audio') { - DocumentManager.setAppStatus('audio'); - } else { - DocumentManager.setAppStatus('playing-with-controls'); - } - }, 20); - } + if ( + DocumentManager.getAppStatus() == 'backdrop' || + DocumentManager.getAppStatus() == 'playing-with-controls' || + DocumentManager.getAppStatus() == 'playing' || + DocumentManager.getAppStatus() == 'audio' + ) { + setTimeout(() => { + window.mediaManager.play(); + + if ($scope.mediaType == 'Audio') { + DocumentManager.setAppStatus('audio'); + } else { + DocumentManager.setAppStatus('playing-with-controls'); + } + }, 20); + } } /** * Don't actually stop, just show the idle view after 20ms */ export function stop(): void { - setTimeout(() => { - DocumentManager.setAppStatus('waiting'); - }, 20); + setTimeout(() => { + DocumentManager.setAppStatus('waiting'); + }, 20); } /** @@ -261,49 +261,49 @@ export function stop(): void { * @param liveStreamId */ export function getPlaybackInfo( - item: BaseItemDto, - maxBitrate: number, - deviceProfile: DeviceProfile, - startPosition: number, - mediaSourceId: string, - audioStreamIndex: number, - subtitleStreamIndex: number, - liveStreamId: string | null = null + item: BaseItemDto, + maxBitrate: number, + deviceProfile: DeviceProfile, + startPosition: number, + mediaSourceId: string, + audioStreamIndex: number, + subtitleStreamIndex: number, + liveStreamId: string | null = null ): Promise { - const postData = { - DeviceProfile: deviceProfile - }; - - // TODO: PlayRequestQuery might not be the proper type for this - const query: PlayRequestQuery = { - UserId: JellyfinApi.userId ?? undefined, - StartTimeTicks: startPosition || 0, - MaxStreamingBitrate: maxBitrate - }; - - if (audioStreamIndex != null) { - query.AudioStreamIndex = audioStreamIndex; - } - - if (subtitleStreamIndex != null) { - query.SubtitleStreamIndex = subtitleStreamIndex; - } - - if (mediaSourceId) { - query.MediaSourceId = mediaSourceId; - } - - if (liveStreamId) { - query.LiveStreamId = liveStreamId; - } - - return JellyfinApi.authAjax(`Items/${item.Id}/PlaybackInfo`, { - query: query, - type: 'POST', - dataType: 'json', - data: JSON.stringify(postData), - contentType: 'application/json' - }); + const postData = { + DeviceProfile: deviceProfile + }; + + // TODO: PlayRequestQuery might not be the proper type for this + const query: PlayRequestQuery = { + UserId: JellyfinApi.userId ?? undefined, + StartTimeTicks: startPosition || 0, + MaxStreamingBitrate: maxBitrate + }; + + if (audioStreamIndex != null) { + query.AudioStreamIndex = audioStreamIndex; + } + + if (subtitleStreamIndex != null) { + query.SubtitleStreamIndex = subtitleStreamIndex; + } + + if (mediaSourceId) { + query.MediaSourceId = mediaSourceId; + } + + if (liveStreamId) { + query.LiveStreamId = liveStreamId; + } + + return JellyfinApi.authAjax(`Items/${item.Id}/PlaybackInfo`, { + query: query, + type: 'POST', + dataType: 'json', + data: JSON.stringify(postData), + contentType: 'application/json' + }); } /** @@ -317,43 +317,43 @@ export function getPlaybackInfo( * @param subtitleStreamIndex */ export function getLiveStream( - item: BaseItemDto, - playSessionId: string, - maxBitrate: number, - deviceProfile: DeviceProfile, - startPosition: number, - mediaSource: MediaSourceInfo, - audioStreamIndex: number | null, - subtitleStreamIndex: number | null + item: BaseItemDto, + playSessionId: string, + maxBitrate: number, + deviceProfile: DeviceProfile, + startPosition: number, + mediaSource: MediaSourceInfo, + audioStreamIndex: number | null, + subtitleStreamIndex: number | null ): Promise { - const postData = { - DeviceProfile: deviceProfile, - OpenToken: mediaSource.OpenToken - }; - - const query: PlayRequestQuery = { - UserId: JellyfinApi.userId ?? undefined, - StartTimeTicks: startPosition || 0, - ItemId: item.Id, - MaxStreamingBitrate: maxBitrate, - PlaySessionId: playSessionId - }; - - if (audioStreamIndex != null) { - query.AudioStreamIndex = audioStreamIndex; - } - - if (subtitleStreamIndex != null) { - query.SubtitleStreamIndex = subtitleStreamIndex; - } - - return JellyfinApi.authAjax('LiveStreams/Open', { - query: query, - type: 'POST', - dataType: 'json', - data: JSON.stringify(postData), - contentType: 'application/json' - }); + const postData = { + DeviceProfile: deviceProfile, + OpenToken: mediaSource.OpenToken + }; + + const query: PlayRequestQuery = { + UserId: JellyfinApi.userId ?? undefined, + StartTimeTicks: startPosition || 0, + ItemId: item.Id, + MaxStreamingBitrate: maxBitrate, + PlaySessionId: playSessionId + }; + + if (audioStreamIndex != null) { + query.AudioStreamIndex = audioStreamIndex; + } + + if (subtitleStreamIndex != null) { + query.SubtitleStreamIndex = subtitleStreamIndex; + } + + return JellyfinApi.authAjax('LiveStreams/Open', { + query: query, + type: 'POST', + dataType: 'json', + data: JSON.stringify(postData), + contentType: 'application/json' + }); } /** @@ -365,20 +365,20 @@ export function getLiveStream( * @returns the bitrate in bits/s */ export async function getDownloadSpeed(byteSize: number): Promise { - const path = `Playback/BitrateTest?size=${byteSize}`; + const path = `Playback/BitrateTest?size=${byteSize}`; - const now = new Date().getTime(); + const now = new Date().getTime(); - await JellyfinApi.authAjax(path, { - type: 'GET', - timeout: 5000 - }); + await JellyfinApi.authAjax(path, { + type: 'GET', + timeout: 5000 + }); - const responseTimeSeconds = (new Date().getTime() - now) / 1000; - const bytesPerSecond = byteSize / responseTimeSeconds; - const bitrate = Math.round(bytesPerSecond * 8); + const responseTimeSeconds = (new Date().getTime() - now) / 1000; + const bytesPerSecond = byteSize / responseTimeSeconds; + const bitrate = Math.round(bytesPerSecond * 8); - return bitrate; + return bitrate; } /** @@ -388,17 +388,17 @@ export async function getDownloadSpeed(byteSize: number): Promise { * @returns bitrate in bits/s */ export async function detectBitrate(): Promise { - // First try a small amount so that we don't hang up their mobile connection + // First try a small amount so that we don't hang up their mobile connection - let bitrate = await getDownloadSpeed(1000000); + let bitrate = await getDownloadSpeed(1000000); - if (bitrate < 1000000) { - return Math.round(bitrate * 0.8); - } + if (bitrate < 1000000) { + return Math.round(bitrate * 0.8); + } - bitrate = await getDownloadSpeed(2400000); + bitrate = await getDownloadSpeed(2400000); - return Math.round(bitrate * 0.8); + return Math.round(bitrate * 0.8); } /** @@ -408,17 +408,17 @@ export async function detectBitrate(): Promise { * @returns Promise for the http request to go through */ export function stopActiveEncodings($scope: GlobalScope): Promise { - const options = { - deviceId: window.deviceInfo.deviceId, - PlaySessionId: undefined - }; - - if ($scope.playSessionId) { - options.PlaySessionId = $scope.playSessionId; - } - - return JellyfinApi.authAjax('Videos/ActiveEncodings', { - type: 'DELETE', - query: options - }); + const options = { + deviceId: window.deviceInfo.deviceId, + PlaySessionId: undefined + }; + + if ($scope.playSessionId) { + options.PlaySessionId = $scope.playSessionId; + } + + return JellyfinApi.authAjax('Videos/ActiveEncodings', { + type: 'DELETE', + query: options + }); } diff --git a/src/components/jellyfinApi.ts b/src/components/jellyfinApi.ts index a27cb34c..976fe8a3 100644 --- a/src/components/jellyfinApi.ts +++ b/src/components/jellyfinApi.ts @@ -2,140 +2,140 @@ import { ajax } from './fetchhelper'; import { Dictionary } from '~/types/global'; export abstract class JellyfinApi { - // userId that we are connecting as currently - public static userId: string | null = null; - - // Security token to prove authentication - public static accessToken: string | null = null; - - // Address of server - public static serverAddress: string | null = null; - - public static setServerInfo( - userId: string, - accessToken: string, - serverAddress: string - ): void { - console.debug( - `JellyfinApi.setServerInfo: user:${userId}, token:${accessToken}, server:${serverAddress}` - ); - this.userId = userId; - this.accessToken = accessToken; - this.serverAddress = serverAddress; - } - - // create the necessary headers for authentication - private static getSecurityHeaders(): Dictionary { - // TODO throw error if this fails - - let auth = - `Emby Client="Chromecast", ` + - `Device="${window.deviceInfo.deviceName}", ` + - `DeviceId="${window.deviceInfo.deviceId}", ` + - `Version="${window.deviceInfo.versionNumber}"`; - - if (this.userId) { - auth += `, UserId="${this.userId}"`; + // userId that we are connecting as currently + public static userId: string | null = null; + + // Security token to prove authentication + public static accessToken: string | null = null; + + // Address of server + public static serverAddress: string | null = null; + + public static setServerInfo( + userId: string, + accessToken: string, + serverAddress: string + ): void { + console.debug( + `JellyfinApi.setServerInfo: user:${userId}, token:${accessToken}, server:${serverAddress}` + ); + this.userId = userId; + this.accessToken = accessToken; + this.serverAddress = serverAddress; } - const headers: Dictionary = { - Authorization: auth - }; + // create the necessary headers for authentication + private static getSecurityHeaders(): Dictionary { + // TODO throw error if this fails - if (this.accessToken != null) { - headers['X-MediaBrowser-Token'] = this.accessToken; - } + let auth = + `Emby Client="Chromecast", ` + + `Device="${window.deviceInfo.deviceName}", ` + + `DeviceId="${window.deviceInfo.deviceId}", ` + + `Version="${window.deviceInfo.versionNumber}"`; - return headers; - } + if (this.userId) { + auth += `, UserId="${this.userId}"`; + } - // Create a basic url. - // Cannot start with /. - public static createUrl(path: string): string { - if (this.serverAddress === null) { - console.error('JellyfinApi.createUrl: no server address present'); + const headers: Dictionary = { + Authorization: auth + }; - return ''; - } + if (this.accessToken != null) { + headers['X-MediaBrowser-Token'] = this.accessToken; + } - // Remove leading slashes - while (path.charAt(0) === '/') { - path = path.substring(1); + return headers; } - return `${this.serverAddress}/${path}`; - } + // Create a basic url. + // Cannot start with /. + public static createUrl(path: string): string { + if (this.serverAddress === null) { + console.error('JellyfinApi.createUrl: no server address present'); + + return ''; + } + + // Remove leading slashes + while (path.charAt(0) === '/') { + path = path.substring(1); + } - // create a path in /Users/userId/ - public static createUserUrl(path: string | null = null): string { - if (path) { - return this.createUrl(`Users/${this.userId}/${path}`); - } else { - return this.createUrl(`Users/${this.userId}`); + return `${this.serverAddress}/${path}`; } - } - - /** - * Create url to image - * - * @param itemId - Item id - * @param imgType - Image type: Primary, Logo, Backdrop - * @param imgTag - Image tag - * @param imgIdx - Image index, default 0 - * @returns URL - */ - public static createImageUrl( - itemId: string, - imgType: string, - imgTag: string, - imgIdx = 0 - ): string { - return this.createUrl( - `Items/${itemId}/Images/${imgType}/${imgIdx.toString()}?tag=${imgTag}` - ); - } - - // Authenticated ajax - public static authAjax(path: string, args: any): Promise { - if ( - this.userId === null || - this.accessToken === null || - this.serverAddress === null - ) { - console.error( - 'JellyfinApi.authAjax: No userid/accesstoken/serverAddress present. Skipping request' - ); - - return Promise.reject('no server info present'); + + // create a path in /Users/userId/ + public static createUserUrl(path: string | null = null): string { + if (path) { + return this.createUrl(`Users/${this.userId}/${path}`); + } else { + return this.createUrl(`Users/${this.userId}`); + } } - const params = { - url: this.createUrl(path), - headers: this.getSecurityHeaders() - }; - - return ajax({ ...params, ...args }); - } - - // Authenticated ajax - public static authAjaxUser(path: string, args: any): Promise { - if ( - this.userId === null || - this.accessToken === null || - this.serverAddress === null - ) { - console.error( - 'JellyfinApi.authAjaxUser: No userid/accesstoken/serverAddress present. Skipping request' - ); - - return Promise.reject('no server info present'); + /** + * Create url to image + * + * @param itemId - Item id + * @param imgType - Image type: Primary, Logo, Backdrop + * @param imgTag - Image tag + * @param imgIdx - Image index, default 0 + * @returns URL + */ + public static createImageUrl( + itemId: string, + imgType: string, + imgTag: string, + imgIdx = 0 + ): string { + return this.createUrl( + `Items/${itemId}/Images/${imgType}/${imgIdx.toString()}?tag=${imgTag}` + ); } - const params = { - url: this.createUserUrl(path), - headers: this.getSecurityHeaders() - }; + // Authenticated ajax + public static authAjax(path: string, args: any): Promise { + if ( + this.userId === null || + this.accessToken === null || + this.serverAddress === null + ) { + console.error( + 'JellyfinApi.authAjax: No userid/accesstoken/serverAddress present. Skipping request' + ); + + return Promise.reject('no server info present'); + } + + const params = { + url: this.createUrl(path), + headers: this.getSecurityHeaders() + }; + + return ajax({ ...params, ...args }); + } - return ajax({ ...params, ...args }); - } + // Authenticated ajax + public static authAjaxUser(path: string, args: any): Promise { + if ( + this.userId === null || + this.accessToken === null || + this.serverAddress === null + ) { + console.error( + 'JellyfinApi.authAjaxUser: No userid/accesstoken/serverAddress present. Skipping request' + ); + + return Promise.reject('no server info present'); + } + + const params = { + url: this.createUserUrl(path), + headers: this.getSecurityHeaders() + }; + + return ajax({ ...params, ...args }); + } } diff --git a/src/components/maincontroller.ts b/src/components/maincontroller.ts index 54b161fb..0b28d4ed 100644 --- a/src/components/maincontroller.ts +++ b/src/components/maincontroller.ts @@ -1,25 +1,25 @@ import { - getCurrentPositionTicks, - getReportingParams, - resetPlaybackScope, - getMetadata, - createStreamInfo, - getStreamByIndex, - getShuffleItems, - getInstantMixItems, - translateRequestedItems, - extend, - broadcastToMessageBus, - broadcastConnectionErrorMessage, - cleanName + getCurrentPositionTicks, + getReportingParams, + resetPlaybackScope, + getMetadata, + createStreamInfo, + getStreamByIndex, + getShuffleItems, + getInstantMixItems, + translateRequestedItems, + extend, + broadcastToMessageBus, + broadcastConnectionErrorMessage, + cleanName } from '../helpers'; import { - reportPlaybackProgress, - reportPlaybackStopped, - play, - getPlaybackInfo, - stopActiveEncodings, - detectBitrate + reportPlaybackProgress, + reportPlaybackStopped, + play, + getPlaybackInfo, + stopActiveEncodings, + detectBitrate } from './jellyfinActions'; import { getDeviceProfile } from './deviceprofileBuilder'; import { JellyfinApi } from './jellyfinApi'; @@ -50,249 +50,254 @@ let hasReportedCapabilities = false; * */ export function onMediaElementTimeUpdate(): void { - if ($scope.isChangingStream) { - return; - } + if ($scope.isChangingStream) { + return; + } - const now = new Date(); + const now = new Date(); - const elapsed = now.valueOf() - broadcastToServer.valueOf(); + const elapsed = now.valueOf() - broadcastToServer.valueOf(); - if (elapsed > 5000) { - // TODO use status as input - reportPlaybackProgress($scope, getReportingParams($scope)); - broadcastToServer = now; - } else if (elapsed > 1500) { - // TODO use status as input - reportPlaybackProgress($scope, getReportingParams($scope), false); - } + if (elapsed > 5000) { + // TODO use status as input + reportPlaybackProgress($scope, getReportingParams($scope)); + broadcastToServer = now; + } else if (elapsed > 1500) { + // TODO use status as input + reportPlaybackProgress($scope, getReportingParams($scope), false); + } } /** * */ export function onMediaElementPause(): void { - if ($scope.isChangingStream) { - return; - } + if ($scope.isChangingStream) { + return; + } - reportEvent('playstatechange', true); + reportEvent('playstatechange', true); } /** * */ export function onMediaElementPlaying(): void { - if ($scope.isChangingStream) { - return; - } + if ($scope.isChangingStream) { + return; + } - reportEvent('playstatechange', true); + reportEvent('playstatechange', true); } /** * @param event */ function onMediaElementVolumeChange(event: framework.system.Event): void { - window.volume = (event).data; - console.log(`Received volume update: ${window.volume.level}`); + window.volume = (event).data; + console.log(`Received volume update: ${window.volume.level}`); - if (JellyfinApi.serverAddress !== null) { - reportEvent('volumechange', true); - } + if (JellyfinApi.serverAddress !== null) { + reportEvent('volumechange', true); + } } /** * */ export function enableTimeUpdateListener(): void { - window.mediaManager.addEventListener( - cast.framework.events.EventType.TIME_UPDATE, - onMediaElementTimeUpdate - ); - window.castReceiverContext.addEventListener( - cast.framework.system.EventType.SYSTEM_VOLUME_CHANGED, - onMediaElementVolumeChange - ); - window.mediaManager.addEventListener( - cast.framework.events.EventType.PAUSE, - onMediaElementPause - ); - window.mediaManager.addEventListener( - cast.framework.events.EventType.PLAYING, - onMediaElementPlaying - ); + window.mediaManager.addEventListener( + cast.framework.events.EventType.TIME_UPDATE, + onMediaElementTimeUpdate + ); + window.castReceiverContext.addEventListener( + cast.framework.system.EventType.SYSTEM_VOLUME_CHANGED, + onMediaElementVolumeChange + ); + window.mediaManager.addEventListener( + cast.framework.events.EventType.PAUSE, + onMediaElementPause + ); + window.mediaManager.addEventListener( + cast.framework.events.EventType.PLAYING, + onMediaElementPlaying + ); } /** * */ export function disableTimeUpdateListener(): void { - window.mediaManager.removeEventListener( - cast.framework.events.EventType.TIME_UPDATE, - onMediaElementTimeUpdate - ); - window.castReceiverContext.removeEventListener( - cast.framework.system.EventType.SYSTEM_VOLUME_CHANGED, - onMediaElementVolumeChange - ); - window.mediaManager.removeEventListener( - cast.framework.events.EventType.PAUSE, - onMediaElementPause - ); - window.mediaManager.removeEventListener( - cast.framework.events.EventType.PLAYING, - onMediaElementPlaying - ); + window.mediaManager.removeEventListener( + cast.framework.events.EventType.TIME_UPDATE, + onMediaElementTimeUpdate + ); + window.castReceiverContext.removeEventListener( + cast.framework.system.EventType.SYSTEM_VOLUME_CHANGED, + onMediaElementVolumeChange + ); + window.mediaManager.removeEventListener( + cast.framework.events.EventType.PAUSE, + onMediaElementPause + ); + window.mediaManager.removeEventListener( + cast.framework.events.EventType.PLAYING, + onMediaElementPlaying + ); } enableTimeUpdateListener(); window.addEventListener('beforeunload', () => { - // Try to cleanup after ourselves before the page closes - disableTimeUpdateListener(); - reportPlaybackStopped($scope, getReportingParams($scope)); + // Try to cleanup after ourselves before the page closes + disableTimeUpdateListener(); + reportPlaybackStopped($scope, getReportingParams($scope)); }); mgr.addEventListener(cast.framework.events.EventType.PLAY, (): void => { - play($scope); - reportPlaybackProgress($scope, getReportingParams($scope)); + play($scope); + reportPlaybackProgress($scope, getReportingParams($scope)); }); mgr.addEventListener(cast.framework.events.EventType.PAUSE, (): void => { - reportPlaybackProgress($scope, getReportingParams($scope)); + reportPlaybackProgress($scope, getReportingParams($scope)); }); /** * */ function defaultOnStop(): void { - playbackMgr.stop(); + playbackMgr.stop(); } mgr.addEventListener( - cast.framework.events.EventType.MEDIA_FINISHED, - defaultOnStop + cast.framework.events.EventType.MEDIA_FINISHED, + defaultOnStop ); mgr.addEventListener(cast.framework.events.EventType.ABORT, defaultOnStop); mgr.addEventListener(cast.framework.events.EventType.ENDED, () => { - // Ignore - if ($scope.isChangingStream) { - return; - } - - reportPlaybackStopped($scope, getReportingParams($scope)); - resetPlaybackScope($scope); - - if (!playbackMgr.playNextItem()) { - window.playlist = []; - window.currentPlaylistIndex = -1; - DocumentManager.startBackdropInterval(); - } + // Ignore + if ($scope.isChangingStream) { + return; + } + + reportPlaybackStopped($scope, getReportingParams($scope)); + resetPlaybackScope($scope); + + if (!playbackMgr.playNextItem()) { + window.playlist = []; + window.currentPlaylistIndex = -1; + DocumentManager.startBackdropInterval(); + } }); // Set the active subtitle track once the player has loaded window.mediaManager.addEventListener( - cast.framework.events.EventType.PLAYER_LOAD_COMPLETE, - () => { - setTextTrack( - window.mediaManager.getMediaInformation().customData.subtitleStreamIndex - ); - } + cast.framework.events.EventType.PLAYER_LOAD_COMPLETE, + () => { + setTextTrack( + window.mediaManager.getMediaInformation().customData + .subtitleStreamIndex + ); + } ); /** * */ export async function reportDeviceCapabilities(): Promise { - const maxBitrate = await getMaxBitrate(); - - const deviceProfile = getDeviceProfile({ - enableHls: true, - bitrateSetting: maxBitrate - }); - - const capabilities = { - PlayableMediaTypes: ['Audio', 'Video'], - SupportsPersistentIdentifier: false, - SupportsMediaControl: true, - DeviceProfile: deviceProfile - }; - - hasReportedCapabilities = true; - - return JellyfinApi.authAjax('Sessions/Capabilities/Full', { - type: 'POST', - data: JSON.stringify(capabilities), - contentType: 'application/json' - }); + const maxBitrate = await getMaxBitrate(); + + const deviceProfile = getDeviceProfile({ + enableHls: true, + bitrateSetting: maxBitrate + }); + + const capabilities = { + PlayableMediaTypes: ['Audio', 'Video'], + SupportsPersistentIdentifier: false, + SupportsMediaControl: true, + DeviceProfile: deviceProfile + }; + + hasReportedCapabilities = true; + + return JellyfinApi.authAjax('Sessions/Capabilities/Full', { + type: 'POST', + data: JSON.stringify(capabilities), + contentType: 'application/json' + }); } /** * @param data */ export function processMessage(data: any): void { - if ( - !data.command || - !data.serverAddress || - !data.userId || - !data.accessToken - ) { - console.log('Invalid message sent from sender. Sending error response'); - - broadcastToMessageBus({ - type: 'error', - message: - 'Missing one or more required params - command,options,userId,accessToken,serverAddress' - }); - - return; - } + if ( + !data.command || + !data.serverAddress || + !data.userId || + !data.accessToken + ) { + console.log('Invalid message sent from sender. Sending error response'); + + broadcastToMessageBus({ + type: 'error', + message: + 'Missing one or more required params - command,options,userId,accessToken,serverAddress' + }); + + return; + } - // Items will have properties - Id, Name, Type, MediaType, IsFolder + // Items will have properties - Id, Name, Type, MediaType, IsFolder - JellyfinApi.setServerInfo(data.userId, data.accessToken, data.serverAddress); + JellyfinApi.setServerInfo( + data.userId, + data.accessToken, + data.serverAddress + ); - if (data.subtitleAppearance) { - window.subtitleAppearance = data.subtitleAppearance; - } + if (data.subtitleAppearance) { + window.subtitleAppearance = data.subtitleAppearance; + } - // Report device capabilities - if (!hasReportedCapabilities) { - reportDeviceCapabilities(); - } + // Report device capabilities + if (!hasReportedCapabilities) { + reportDeviceCapabilities(); + } - data.options = data.options || {}; + data.options = data.options || {}; - const cleanReceiverName = cleanName(data.receiverName || ''); + const cleanReceiverName = cleanName(data.receiverName || ''); - window.deviceInfo.deviceName = - cleanReceiverName || window.deviceInfo.deviceName; - // deviceId just needs to be unique-ish - window.deviceInfo.deviceId = cleanReceiverName - ? btoa(cleanReceiverName) - : window.deviceInfo.deviceId; + window.deviceInfo.deviceName = + cleanReceiverName || window.deviceInfo.deviceName; + // deviceId just needs to be unique-ish + window.deviceInfo.deviceId = cleanReceiverName + ? btoa(cleanReceiverName) + : window.deviceInfo.deviceId; - if (data.maxBitrate) { - window.MaxBitrate = data.maxBitrate; - } + if (data.maxBitrate) { + window.MaxBitrate = data.maxBitrate; + } - CommandHandler.processMessage(data, data.command); + CommandHandler.processMessage(data, data.command); - if (window.reportEventType) { - const report = (): Promise => - reportPlaybackProgress($scope, getReportingParams($scope)); + if (window.reportEventType) { + const report = (): Promise => + reportPlaybackProgress($scope, getReportingParams($scope)); - reportPlaybackProgress( - $scope, - getReportingParams($scope), - true, - window.reportEventType - ); - setTimeout(report, 100); - setTimeout(report, 500); - } + reportPlaybackProgress( + $scope, + getReportingParams($scope), + true, + window.reportEventType + ); + setTimeout(report, 100); + setTimeout(report, 500); + } } /** @@ -300,15 +305,15 @@ export function processMessage(data: any): void { * @param reportToServer */ export function reportEvent( - name: string, - reportToServer: boolean + name: string, + reportToServer: boolean ): Promise { - return reportPlaybackProgress( - $scope, - getReportingParams($scope), - reportToServer, - name - ); + return reportPlaybackProgress( + $scope, + getReportingParams($scope), + reportToServer, + name + ); } /** @@ -316,74 +321,76 @@ export function reportEvent( * @param index */ export function setSubtitleStreamIndex( - $scope: GlobalScope, - index: number + $scope: GlobalScope, + index: number ): void { - console.log(`setSubtitleStreamIndex. index: ${index}`); + console.log(`setSubtitleStreamIndex. index: ${index}`); - let positionTicks; + let positionTicks; - const currentSubtitleStream = $scope.mediaSource.MediaStreams.filter( - (m: any) => { - return m.Index == $scope.subtitleStreamIndex && m.Type == 'Subtitle'; - } - )[0]; - const currentDeliveryMethod = currentSubtitleStream - ? currentSubtitleStream.DeliveryMethod - : null; - - if (index == -1 || index == null) { - // Need to change the stream to turn off the subs - if (currentDeliveryMethod == 'Encode') { - console.log('setSubtitleStreamIndex video url change required'); - positionTicks = getCurrentPositionTicks($scope); - changeStream(positionTicks, { - SubtitleStreamIndex: -1 - }); - } else { - $scope.subtitleStreamIndex = -1; - setTextTrack(null); + const currentSubtitleStream = $scope.mediaSource.MediaStreams.filter( + (m: any) => { + return ( + m.Index == $scope.subtitleStreamIndex && m.Type == 'Subtitle' + ); + } + )[0]; + const currentDeliveryMethod = currentSubtitleStream + ? currentSubtitleStream.DeliveryMethod + : null; + + if (index == -1 || index == null) { + // Need to change the stream to turn off the subs + if (currentDeliveryMethod == 'Encode') { + console.log('setSubtitleStreamIndex video url change required'); + positionTicks = getCurrentPositionTicks($scope); + changeStream(positionTicks, { + SubtitleStreamIndex: -1 + }); + } else { + $scope.subtitleStreamIndex = -1; + setTextTrack(null); + } + + return; } - return; - } + const mediaStreams = $scope.PlaybackMediaSource.MediaStreams; - const mediaStreams = $scope.PlaybackMediaSource.MediaStreams; + const subtitleStream = getStreamByIndex(mediaStreams, 'Subtitle', index); - const subtitleStream = getStreamByIndex(mediaStreams, 'Subtitle', index); + if (!subtitleStream) { + console.log( + 'setSubtitleStreamIndex error condition - subtitle stream not found.' + ); + + return; + } - if (!subtitleStream) { console.log( - 'setSubtitleStreamIndex error condition - subtitle stream not found.' + `setSubtitleStreamIndex DeliveryMethod:${subtitleStream.DeliveryMethod}` ); - return; - } - - console.log( - `setSubtitleStreamIndex DeliveryMethod:${subtitleStream.DeliveryMethod}` - ); - - if ( - subtitleStream.DeliveryMethod == 'External' || - currentDeliveryMethod == 'Encode' - ) { - const textStreamUrl = subtitleStream.IsExternalUrl - ? subtitleStream.DeliveryUrl - : JellyfinApi.createUrl(subtitleStream.DeliveryUrl); - - console.log(`Subtitle url: ${textStreamUrl}`); - setTextTrack(index); - $scope.subtitleStreamIndex = subtitleStream.Index; - - return; - } else { - console.log('setSubtitleStreamIndex video url change required'); - positionTicks = getCurrentPositionTicks($scope); - changeStream(positionTicks, { - SubtitleStreamIndex: index - }); - } + if ( + subtitleStream.DeliveryMethod == 'External' || + currentDeliveryMethod == 'Encode' + ) { + const textStreamUrl = subtitleStream.IsExternalUrl + ? subtitleStream.DeliveryUrl + : JellyfinApi.createUrl(subtitleStream.DeliveryUrl); + + console.log(`Subtitle url: ${textStreamUrl}`); + setTextTrack(index); + $scope.subtitleStreamIndex = subtitleStream.Index; + + return; + } else { + console.log('setSubtitleStreamIndex video url change required'); + positionTicks = getCurrentPositionTicks($scope); + changeStream(positionTicks, { + SubtitleStreamIndex: index + }); + } } /** @@ -391,21 +398,21 @@ export function setSubtitleStreamIndex( * @param index */ export function setAudioStreamIndex( - $scope: GlobalScope, - index: number + $scope: GlobalScope, + index: number ): Promise { - const positionTicks = getCurrentPositionTicks($scope); + const positionTicks = getCurrentPositionTicks($scope); - return changeStream(positionTicks, { - AudioStreamIndex: index - }); + return changeStream(positionTicks, { + AudioStreamIndex: index + }); } /** * @param ticks */ export function seek(ticks: number): Promise { - return changeStream(ticks); + return changeStream(ticks); } /** @@ -413,110 +420,110 @@ export function seek(ticks: number): Promise { * @param params */ export async function changeStream( - ticks: number, - params: any = undefined + ticks: number, + params: any = undefined ): Promise { - if ( - window.mediaManager.getMediaInformation().customData.canClientSeek && - params == null - ) { - window.mediaManager.seek(ticks / 10000000); - reportPlaybackProgress($scope, getReportingParams($scope)); + if ( + window.mediaManager.getMediaInformation().customData.canClientSeek && + params == null + ) { + window.mediaManager.seek(ticks / 10000000); + reportPlaybackProgress($scope, getReportingParams($scope)); + + return Promise.resolve(); + } + + params = params || {}; + + const playSessionId = $scope.playSessionId; + const liveStreamId = $scope.liveStreamId; + + const item = $scope.item; + const maxBitrate = await getMaxBitrate(); + + const deviceProfile = getDeviceProfile({ + enableHls: true, + bitrateSetting: maxBitrate + }); + const audioStreamIndex = + params.AudioStreamIndex == null + ? $scope.audioStreamIndex + : params.AudioStreamIndex; + const subtitleStreamIndex = + params.SubtitleStreamIndex == null + ? $scope.subtitleStreamIndex + : params.SubtitleStreamIndex; + + const playbackInformation = await getPlaybackInfo( + item, + maxBitrate, + deviceProfile, + ticks, + $scope.mediaSourceId, + audioStreamIndex, + subtitleStreamIndex, + liveStreamId + ); + + if (!validatePlaybackInfoResult(playbackInformation)) { + return; + } + + const mediaSource = playbackInformation.MediaSources[0]; + const streamInfo = createStreamInfo(item, mediaSource, ticks); - return Promise.resolve(); - } - - params = params || {}; - - const playSessionId = $scope.playSessionId; - const liveStreamId = $scope.liveStreamId; - - const item = $scope.item; - const maxBitrate = await getMaxBitrate(); - - const deviceProfile = getDeviceProfile({ - enableHls: true, - bitrateSetting: maxBitrate - }); - const audioStreamIndex = - params.AudioStreamIndex == null - ? $scope.audioStreamIndex - : params.AudioStreamIndex; - const subtitleStreamIndex = - params.SubtitleStreamIndex == null - ? $scope.subtitleStreamIndex - : params.SubtitleStreamIndex; - - const playbackInformation = await getPlaybackInfo( - item, - maxBitrate, - deviceProfile, - ticks, - $scope.mediaSourceId, - audioStreamIndex, - subtitleStreamIndex, - liveStreamId - ); - - if (!validatePlaybackInfoResult(playbackInformation)) { - return; - } - - const mediaSource = playbackInformation.MediaSources[0]; - const streamInfo = createStreamInfo(item, mediaSource, ticks); - - if (!streamInfo.url) { - showPlaybackInfoErrorMessage('NoCompatibleStream'); - - return; - } - - const mediaInformation = createMediaInformation( - playSessionId, - item, - streamInfo - ); - const loadRequest = new cast.framework.messages.LoadRequestData(); - - loadRequest.media = mediaInformation; - loadRequest.autoplay = true; - - // TODO something to do with HLS? - const requiresStoppingTranscoding = false; - - if (requiresStoppingTranscoding) { - window.mediaManager.pause(); - await stopActiveEncodings(playSessionId); - } - - window.mediaManager.load(loadRequest); - window.mediaManager.play(); - $scope.subtitleStreamIndex = subtitleStreamIndex; - $scope.audioStreamIndex = audioStreamIndex; + if (!streamInfo.url) { + showPlaybackInfoErrorMessage('NoCompatibleStream'); + + return; + } + + const mediaInformation = createMediaInformation( + playSessionId, + item, + streamInfo + ); + const loadRequest = new cast.framework.messages.LoadRequestData(); + + loadRequest.media = mediaInformation; + loadRequest.autoplay = true; + + // TODO something to do with HLS? + const requiresStoppingTranscoding = false; + + if (requiresStoppingTranscoding) { + window.mediaManager.pause(); + await stopActiveEncodings(playSessionId); + } + + window.mediaManager.load(loadRequest); + window.mediaManager.play(); + $scope.subtitleStreamIndex = subtitleStreamIndex; + $scope.audioStreamIndex = audioStreamIndex; } // Create a message handler for the custome namespace channel // TODO save namespace somewhere global? window.castReceiverContext.addCustomMessageListener( - 'urn:x-cast:com.connectsdk', - (evt: any) => { - let data: any = evt.data; - - // Apparently chromium likes to pass it as json, not as object. - // chrome on android works fine - if (typeof data === 'string') { - console.log('Event data is a string.. Chromium detected..'); - data = JSON.parse(data); - } + 'urn:x-cast:com.connectsdk', + (evt: any) => { + let data: any = evt.data; + + // Apparently chromium likes to pass it as json, not as object. + // chrome on android works fine + if (typeof data === 'string') { + console.log('Event data is a string.. Chromium detected..'); + data = JSON.parse(data); + } - data.options = data.options || {}; - data.options.senderId = evt.senderId; - // TODO set it somewhere better perhaps - window.senderId = evt.senderId; + data.options = data.options || {}; + data.options.senderId = evt.senderId; + // TODO set it somewhere better perhaps + window.senderId = evt.senderId; - console.log(`Received message: ${JSON.stringify(data)}`); - processMessage(data); - } + console.log(`Received message: ${JSON.stringify(data)}`); + processMessage(data); + } ); /** @@ -525,29 +532,29 @@ window.castReceiverContext.addCustomMessageListener( * @param method */ export async function translateItems( - data: any, - options: PlayRequest, - method: string + data: any, + options: PlayRequest, + method: string ): Promise { - const playNow = method != 'PlayNext' && method != 'PlayLast'; + const playNow = method != 'PlayNext' && method != 'PlayLast'; - const result = await translateRequestedItems( - data.userId, - options.items, - playNow - ); + const result = await translateRequestedItems( + data.userId, + options.items, + playNow + ); - if (result.Items) { - options.items = result.Items; - } + if (result.Items) { + options.items = result.Items; + } - if (method == 'PlayNext' || method == 'PlayLast') { - for (let i = 0, length = options.items.length; i < length; i++) { - window.playlist.push(options.items[i]); + if (method == 'PlayNext' || method == 'PlayLast') { + for (let i = 0, length = options.items.length; i < length; i++) { + window.playlist.push(options.items[i]); + } + } else { + playbackMgr.playFromOptions(data.options); } - } else { - playbackMgr.playFromOptions(data.options); - } } /** @@ -556,14 +563,14 @@ export async function translateItems( * @param item */ export async function instantMix( - data: any, - options: any, - item: BaseItemDto + data: any, + options: any, + item: BaseItemDto ): Promise { - const result = await getInstantMixItems(data.userId, item); + const result = await getInstantMixItems(data.userId, item); - options.items = result.Items; - playbackMgr.playFromOptions(data.options); + options.items = result.Items; + playbackMgr.playFromOptions(data.options); } /** @@ -572,14 +579,14 @@ export async function instantMix( * @param item */ export async function shuffle( - data: any, - options: any, - item: BaseItemDto + data: any, + options: any, + item: BaseItemDto ): Promise { - const result = await getShuffleItems(data.userId, item); + const result = await getShuffleItems(data.userId, item); - options.items = result.Items; - playbackMgr.playFromOptions(data.options); + options.items = result.Items; + playbackMgr.playFromOptions(data.options); } /** @@ -587,18 +594,18 @@ export async function shuffle( * @param options */ export async function onStopPlayerBeforePlaybackDone( - item: BaseItemDto, - options: any + item: BaseItemDto, + options: any ): Promise { - const data = await JellyfinApi.authAjaxUser(`Items/${item.Id}`, { - dataType: 'json', - type: 'GET' - }); - - // Attach the custom properties we created like userId, serverAddress, itemId, etc - extend(data, item); - playbackMgr.playItemInternal(data, options); - broadcastConnectionErrorMessage(); + const data = await JellyfinApi.authAjaxUser(`Items/${item.Id}`, { + dataType: 'json', + type: 'GET' + }); + + // Attach the custom properties we created like userId, serverAddress, itemId, etc + extend(data, item); + playbackMgr.playItemInternal(data, options); + broadcastConnectionErrorMessage(); } let lastBitrateDetect = 0; @@ -607,80 +614,82 @@ let detectedBitrate = 0; * */ export async function getMaxBitrate(): Promise { - console.log('getMaxBitrate'); + console.log('getMaxBitrate'); - if (window.MaxBitrate) { - console.log(`bitrate is set to ${window.MaxBitrate}`); + if (window.MaxBitrate) { + console.log(`bitrate is set to ${window.MaxBitrate}`); - return window.MaxBitrate; - } + return window.MaxBitrate; + } - if (detectedBitrate && new Date().getTime() - lastBitrateDetect < 600000) { - console.log(`returning previous detected bitrate of ${detectedBitrate}`); + if (detectedBitrate && new Date().getTime() - lastBitrateDetect < 600000) { + console.log( + `returning previous detected bitrate of ${detectedBitrate}` + ); - return detectedBitrate; - } + return detectedBitrate; + } - console.log('detecting bitrate'); + console.log('detecting bitrate'); - const bitrate = await detectBitrate(); + const bitrate = await detectBitrate(); - try { - console.log(`Max bitrate auto detected to ${bitrate}`); - lastBitrateDetect = new Date().getTime(); - detectedBitrate = bitrate; + try { + console.log(`Max bitrate auto detected to ${bitrate}`); + lastBitrateDetect = new Date().getTime(); + detectedBitrate = bitrate; - return detectedBitrate; - } catch (e) { - // The client can set this number - console.log('Error detecting bitrate, will return device maximum.'); + return detectedBitrate; + } catch (e) { + // The client can set this number + console.log('Error detecting bitrate, will return device maximum.'); - return getMaxBitrateSupport(); - } + return getMaxBitrateSupport(); + } } /** * @param result */ export function validatePlaybackInfoResult(result: any): boolean { - if (result.ErrorCode) { - showPlaybackInfoErrorMessage(result.ErrorCode); + if (result.ErrorCode) { + showPlaybackInfoErrorMessage(result.ErrorCode); - return false; - } + return false; + } - return true; + return true; } /** * @param error */ export function showPlaybackInfoErrorMessage(error: string): void { - broadcastToMessageBus({ type: 'playbackerror', message: error }); + broadcastToMessageBus({ type: 'playbackerror', message: error }); } /** * @param versions */ export function getOptimalMediaSource(versions: Array): any { - let optimalVersion = versions.filter((v) => { - checkDirectPlay(v); - - return v.SupportsDirectPlay; - })[0]; + let optimalVersion = versions.filter((v) => { + checkDirectPlay(v); - if (!optimalVersion) { - optimalVersion = versions.filter((v) => { - return v.SupportsDirectStream; + return v.SupportsDirectPlay; })[0]; - } - - return ( - optimalVersion || - versions.filter((s) => { - return s.SupportsTranscoding; - })[0] - ); + + if (!optimalVersion) { + optimalVersion = versions.filter((v) => { + return v.SupportsDirectStream; + })[0]; + } + + return ( + optimalVersion || + versions.filter((s) => { + return s.SupportsTranscoding; + })[0] + ); } // Disable direct play on non-http sources @@ -688,96 +697,96 @@ export function getOptimalMediaSource(versions: Array): any { * @param mediaSource */ export function checkDirectPlay(mediaSource: MediaSourceInfo): void { - if ( - mediaSource.SupportsDirectPlay && - mediaSource.Protocol == 'Http' && - (!mediaSource.RequiredHttpHeaders || - !mediaSource.RequiredHttpHeaders.length) - ) { - return; - } - - mediaSource.SupportsDirectPlay = false; + if ( + mediaSource.SupportsDirectPlay && + mediaSource.Protocol == 'Http' && + (!mediaSource.RequiredHttpHeaders || + !mediaSource.RequiredHttpHeaders.length) + ) { + return; + } + + mediaSource.SupportsDirectPlay = false; } /** * @param index */ export function setTextTrack(index: number | null): void { - try { - const textTracksManager = window.mediaManager.getTextTracksManager(); - - if (index == null) { - // docs: null is okay - // typescript definitions: Must be Array - textTracksManager.setActiveByIds([]); - - return; - } - - const tracks: Array = textTracksManager.getTracks(); - const subtitleTrack: framework.messages.Track | undefined = tracks.find( - (track: framework.messages.Track) => { - return track.trackId === index; - } - ); + try { + const textTracksManager = window.mediaManager.getTextTracksManager(); - if (subtitleTrack && subtitleTrack.trackId !== undefined) { - textTracksManager.setActiveByIds([subtitleTrack.trackId]); + if (index == null) { + // docs: null is okay + // typescript definitions: Must be Array + textTracksManager.setActiveByIds([]); - const subtitleAppearance = window.subtitleAppearance; - - if (subtitleAppearance) { - const textTrackStyle = new cast.framework.messages.TextTrackStyle(); - - if (subtitleAppearance.dropShadow != null) { - // Empty string is DROP_SHADOW - textTrackStyle.edgeType = - subtitleAppearance.dropShadow.toUpperCase() || - cast.framework.messages.TextTrackEdgeType.DROP_SHADOW; - textTrackStyle.edgeColor = '#000000FF'; - } - - if (subtitleAppearance.font) { - textTrackStyle.fontFamily = subtitleAppearance.font; - } - - if (subtitleAppearance.textColor) { - // Append the transparency, hardcoded to 100% - textTrackStyle.foregroundColor = `${subtitleAppearance.textColor}FF`; - } - - if (subtitleAppearance.textBackground === 'transparent') { - textTrackStyle.backgroundColor = '#00000000'; // RGBA + return; } - switch (subtitleAppearance.textSize) { - case 'smaller': - textTrackStyle.fontScale = 0.6; - break; - case 'small': - textTrackStyle.fontScale = 0.8; - break; - case 'large': - textTrackStyle.fontScale = 1.15; - break; - case 'larger': - textTrackStyle.fontScale = 1.3; - break; - case 'extralarge': - textTrackStyle.fontScale = 1.45; - break; - default: - textTrackStyle.fontScale = 1.0; - break; + const tracks: Array = textTracksManager.getTracks(); + const subtitleTrack: framework.messages.Track | undefined = tracks.find( + (track: framework.messages.Track) => { + return track.trackId === index; + } + ); + + if (subtitleTrack && subtitleTrack.trackId !== undefined) { + textTracksManager.setActiveByIds([subtitleTrack.trackId]); + + const subtitleAppearance = window.subtitleAppearance; + + if (subtitleAppearance) { + const textTrackStyle = new cast.framework.messages.TextTrackStyle(); + + if (subtitleAppearance.dropShadow != null) { + // Empty string is DROP_SHADOW + textTrackStyle.edgeType = + subtitleAppearance.dropShadow.toUpperCase() || + cast.framework.messages.TextTrackEdgeType.DROP_SHADOW; + textTrackStyle.edgeColor = '#000000FF'; + } + + if (subtitleAppearance.font) { + textTrackStyle.fontFamily = subtitleAppearance.font; + } + + if (subtitleAppearance.textColor) { + // Append the transparency, hardcoded to 100% + textTrackStyle.foregroundColor = `${subtitleAppearance.textColor}FF`; + } + + if (subtitleAppearance.textBackground === 'transparent') { + textTrackStyle.backgroundColor = '#00000000'; // RGBA + } + + switch (subtitleAppearance.textSize) { + case 'smaller': + textTrackStyle.fontScale = 0.6; + break; + case 'small': + textTrackStyle.fontScale = 0.8; + break; + case 'large': + textTrackStyle.fontScale = 1.15; + break; + case 'larger': + textTrackStyle.fontScale = 1.3; + break; + case 'extralarge': + textTrackStyle.fontScale = 1.45; + break; + default: + textTrackStyle.fontScale = 1.0; + break; + } + + textTracksManager.setTextTrackStyle(textTrackStyle); + } } - - textTracksManager.setTextTrackStyle(textTrackStyle); - } + } catch (e) { + console.log(`Setting subtitle track failed: ${e}`); } - } catch (e) { - console.log(`Setting subtitle track failed: ${e}`); - } } // TODO no any types @@ -787,42 +796,42 @@ export function setTextTrack(index: number | null): void { * @param streamInfo */ export function createMediaInformation( - playSessionId: string, - item: BaseItemDto, - streamInfo: any + playSessionId: string, + item: BaseItemDto, + streamInfo: any ): framework.messages.MediaInformation { - const mediaInfo = new cast.framework.messages.MediaInformation(); - - mediaInfo.contentId = streamInfo.url; - mediaInfo.contentType = streamInfo.contentType; - mediaInfo.customData = { - startPositionTicks: streamInfo.startPositionTicks || 0, - itemId: item.Id, - mediaSourceId: streamInfo.mediaSource.Id, - audioStreamIndex: streamInfo.audioStreamIndex, - subtitleStreamIndex: streamInfo.subtitleStreamIndex, - playMethod: streamInfo.isStatic ? 'DirectStream' : 'Transcode', - runtimeTicks: streamInfo.mediaSource.RunTimeTicks, - liveStreamId: streamInfo.mediaSource.LiveStreamId, - canSeek: streamInfo.canSeek, - canClientSeek: streamInfo.canClientSeek, - playSessionId: playSessionId - }; - - mediaInfo.metadata = getMetadata(item); - - mediaInfo.streamType = cast.framework.messages.StreamType.BUFFERED; - mediaInfo.tracks = streamInfo.tracks; - - if (streamInfo.mediaSource.RunTimeTicks) { - mediaInfo.duration = Math.floor( - streamInfo.mediaSource.RunTimeTicks / 10000000 - ); - } + const mediaInfo = new cast.framework.messages.MediaInformation(); + + mediaInfo.contentId = streamInfo.url; + mediaInfo.contentType = streamInfo.contentType; + mediaInfo.customData = { + startPositionTicks: streamInfo.startPositionTicks || 0, + itemId: item.Id, + mediaSourceId: streamInfo.mediaSource.Id, + audioStreamIndex: streamInfo.audioStreamIndex, + subtitleStreamIndex: streamInfo.subtitleStreamIndex, + playMethod: streamInfo.isStatic ? 'DirectStream' : 'Transcode', + runtimeTicks: streamInfo.mediaSource.RunTimeTicks, + liveStreamId: streamInfo.mediaSource.LiveStreamId, + canSeek: streamInfo.canSeek, + canClientSeek: streamInfo.canClientSeek, + playSessionId: playSessionId + }; + + mediaInfo.metadata = getMetadata(item); + + mediaInfo.streamType = cast.framework.messages.StreamType.BUFFERED; + mediaInfo.tracks = streamInfo.tracks; + + if (streamInfo.mediaSource.RunTimeTicks) { + mediaInfo.duration = Math.floor( + streamInfo.mediaSource.RunTimeTicks / 10000000 + ); + } - mediaInfo.customData.startPositionTicks = streamInfo.startPosition || 0; + mediaInfo.customData.startPositionTicks = streamInfo.startPosition || 0; - return mediaInfo; + return mediaInfo; } // Set the available buttons in the UI controls. @@ -838,36 +847,36 @@ controls.assignButton( );*/ controls.assignButton( - cast.framework.ui.ControlsSlot.SLOT_PRIMARY_1, - cast.framework.ui.ControlsButton.SEEK_BACKWARD_15 + cast.framework.ui.ControlsSlot.SLOT_PRIMARY_1, + cast.framework.ui.ControlsButton.SEEK_BACKWARD_15 ); controls.assignButton( - cast.framework.ui.ControlsSlot.SLOT_PRIMARY_2, - cast.framework.ui.ControlsButton.SEEK_FORWARD_15 + cast.framework.ui.ControlsSlot.SLOT_PRIMARY_2, + cast.framework.ui.ControlsButton.SEEK_FORWARD_15 ); const options = new cast.framework.CastReceiverOptions(); // Global variable set by Webpack if (!PRODUCTION) { - window.castReceiverContext.setLoggerLevel(cast.framework.LoggerLevel.DEBUG); - // Don't time out on me :( - // This is only normally allowed for non media apps, but in this case - // it's for debugging purposes. - options.disableIdleTimeout = true; - // This alternative seems to close sooner; I think it - // quits once the client closes the connection. - // options.maxInactivity = 3600; - - window.mediaManager.addEventListener( - cast.framework.events.category.CORE, - (event: framework.events.Event) => { - console.log(`Core event: ${event.type}`); - console.log(event); - } - ); + window.castReceiverContext.setLoggerLevel(cast.framework.LoggerLevel.DEBUG); + // Don't time out on me :( + // This is only normally allowed for non media apps, but in this case + // it's for debugging purposes. + options.disableIdleTimeout = true; + // This alternative seems to close sooner; I think it + // quits once the client closes the connection. + // options.maxInactivity = 3600; + + window.mediaManager.addEventListener( + cast.framework.events.category.CORE, + (event: framework.events.Event) => { + console.log(`Core event: ${event.type}`); + console.log(event); + } + ); } else { - window.castReceiverContext.setLoggerLevel(cast.framework.LoggerLevel.NONE); + window.castReceiverContext.setLoggerLevel(cast.framework.LoggerLevel.NONE); } options.playbackConfig = new cast.framework.PlaybackConfig(); diff --git a/src/components/playbackManager.ts b/src/components/playbackManager.ts index 73b55613..8942af30 100644 --- a/src/components/playbackManager.ts +++ b/src/components/playbackManager.ts @@ -1,29 +1,29 @@ import { - getNextPlaybackItemInfo, - getIntros, - broadcastConnectionErrorMessage, - getReportingParams, - createStreamInfo + getNextPlaybackItemInfo, + getIntros, + broadcastConnectionErrorMessage, + getReportingParams, + createStreamInfo } from '../helpers'; import { - getPlaybackInfo, - getLiveStream, - load, - reportPlaybackStart, - stop, - stopPingInterval, - reportPlaybackStopped + getPlaybackInfo, + getLiveStream, + load, + reportPlaybackStart, + stop, + stopPingInterval, + reportPlaybackStopped } from './jellyfinActions'; import { getDeviceProfile } from './deviceprofileBuilder'; import { - onStopPlayerBeforePlaybackDone, - getMaxBitrate, - getOptimalMediaSource, - showPlaybackInfoErrorMessage, - checkDirectPlay, - createMediaInformation + onStopPlayerBeforePlaybackDone, + getMaxBitrate, + getOptimalMediaSource, + showPlaybackInfoErrorMessage, + checkDirectPlay, + createMediaInformation } from './maincontroller'; import { DocumentManager } from './documentManager'; @@ -31,216 +31,228 @@ import { BaseItemDto } from '~/api/generated/models/base-item-dto'; import { MediaSourceInfo } from '~/api/generated/models/media-source-info'; export class playbackManager { - private playerManager: framework.PlayerManager; - // TODO remove any - private activePlaylist: Array; - private activePlaylistIndex: number; - - constructor(playerManager: framework.PlayerManager) { - // Parameters - this.playerManager = playerManager; - - // Properties - this.activePlaylist = []; - this.activePlaylistIndex = 0; - } - - /* This is used to check if we can switch to - * some other info overlay. - * - * Returns true when playing or paused. - * (before: true only when playing) - * */ - isPlaying(): boolean { - return ( - this.playerManager.getPlayerState() === - cast.framework.messages.PlayerState.PLAYING || - this.playerManager.getPlayerState() === - cast.framework.messages.PlayerState.PAUSED - ); - } - - async playFromOptions(options: any): Promise { - const firstItem = options.items[0]; - - if (options.startPositionTicks || firstItem.MediaType !== 'Video') { - return this.playFromOptionsInternal(options); + private playerManager: framework.PlayerManager; + // TODO remove any + private activePlaylist: Array; + private activePlaylistIndex: number; + + constructor(playerManager: framework.PlayerManager) { + // Parameters + this.playerManager = playerManager; + + // Properties + this.activePlaylist = []; + this.activePlaylistIndex = 0; } - const intros = await getIntros(firstItem); - - options.items = intros.Items?.concat(options.items); - - return this.playFromOptionsInternal(options); - } + /* This is used to check if we can switch to + * some other info overlay. + * + * Returns true when playing or paused. + * (before: true only when playing) + * */ + isPlaying(): boolean { + return ( + this.playerManager.getPlayerState() === + cast.framework.messages.PlayerState.PLAYING || + this.playerManager.getPlayerState() === + cast.framework.messages.PlayerState.PAUSED + ); + } - playFromOptionsInternal(options: any): boolean { - const stopPlayer = this.activePlaylist && this.activePlaylist.length > 0; + async playFromOptions(options: any): Promise { + const firstItem = options.items[0]; - this.activePlaylist = options.items; - window.currentPlaylistIndex = -1; - window.playlist = this.activePlaylist; + if (options.startPositionTicks || firstItem.MediaType !== 'Video') { + return this.playFromOptionsInternal(options); + } - return this.playNextItem(options, stopPlayer); - } + const intros = await getIntros(firstItem); - playNextItem(options: any = {}, stopPlayer = false): boolean { - const nextItemInfo = getNextPlaybackItemInfo(); + options.items = intros.Items?.concat(options.items); - if (nextItemInfo) { - this.activePlaylistIndex = nextItemInfo.index; + return this.playFromOptionsInternal(options); + } - const item = nextItemInfo.item; + playFromOptionsInternal(options: any): boolean { + const stopPlayer = + this.activePlaylist && this.activePlaylist.length > 0; - this.playItem(item, options, stopPlayer); + this.activePlaylist = options.items; + window.currentPlaylistIndex = -1; + window.playlist = this.activePlaylist; - return true; + return this.playNextItem(options, stopPlayer); } - return false; - } + playNextItem(options: any = {}, stopPlayer = false): boolean { + const nextItemInfo = getNextPlaybackItemInfo(); + + if (nextItemInfo) { + this.activePlaylistIndex = nextItemInfo.index; - playPreviousItem(options: any = {}): boolean { - if (this.activePlaylist && this.activePlaylistIndex > 0) { - this.activePlaylistIndex--; + const item = nextItemInfo.item; - const item = this.activePlaylist[this.activePlaylistIndex]; + this.playItem(item, options, stopPlayer); - this.playItem(item, options, true); + return true; + } - return true; + return false; } - return false; - } + playPreviousItem(options: any = {}): boolean { + if (this.activePlaylist && this.activePlaylistIndex > 0) { + this.activePlaylistIndex--; - async playItem( - item: BaseItemDto, - options: any, - stopPlayer = false - ): Promise { - if (stopPlayer) { - await this.stop(true); - } + const item = this.activePlaylist[this.activePlaylistIndex]; - return await onStopPlayerBeforePlaybackDone(item, options); - } - - async playItemInternal(item: BaseItemDto, options: any): Promise { - $scope.isChangingStream = false; - DocumentManager.setAppStatus('loading'); - - const maxBitrate = await getMaxBitrate(); - const deviceProfile = getDeviceProfile({ - enableHls: true, - bitrateSetting: maxBitrate - }); - const playbackInfo = await getPlaybackInfo( - item, - maxBitrate, - deviceProfile, - options.startPositionTicks, - options.mediaSourceId, - options.audioStreamIndex, - options.subtitleStreamIndex - ).catch(broadcastConnectionErrorMessage); - - if (playbackInfo.ErrorCode) { - return showPlaybackInfoErrorMessage(playbackInfo.ErrorCode); - } + this.playItem(item, options, true); - const mediaSource = await getOptimalMediaSource(playbackInfo.MediaSources); + return true; + } - if (!mediaSource) { - return showPlaybackInfoErrorMessage('NoCompatibleStream'); + return false; } - let itemToPlay = mediaSource; - - if (mediaSource.RequiresOpening) { - const openLiveStreamResult = await getLiveStream( - item, - playbackInfo.PlaySessionId, - maxBitrate, - deviceProfile, - options.startPositionTicks, - mediaSource, - null, - null - ); - - if (openLiveStreamResult.MediaSource) { - checkDirectPlay(openLiveStreamResult.MediaSource); - itemToPlay = openLiveStreamResult.MediaSource; - } + async playItem( + item: BaseItemDto, + options: any, + stopPlayer = false + ): Promise { + if (stopPlayer) { + await this.stop(true); + } + + return await onStopPlayerBeforePlaybackDone(item, options); } - this.playMediaSource(playbackInfo.PlaySessionId, item, itemToPlay, options); - } + async playItemInternal(item: BaseItemDto, options: any): Promise { + $scope.isChangingStream = false; + DocumentManager.setAppStatus('loading'); + + const maxBitrate = await getMaxBitrate(); + const deviceProfile = getDeviceProfile({ + enableHls: true, + bitrateSetting: maxBitrate + }); + const playbackInfo = await getPlaybackInfo( + item, + maxBitrate, + deviceProfile, + options.startPositionTicks, + options.mediaSourceId, + options.audioStreamIndex, + options.subtitleStreamIndex + ).catch(broadcastConnectionErrorMessage); + + if (playbackInfo.ErrorCode) { + return showPlaybackInfoErrorMessage(playbackInfo.ErrorCode); + } + + const mediaSource = await getOptimalMediaSource( + playbackInfo.MediaSources + ); + + if (!mediaSource) { + return showPlaybackInfoErrorMessage('NoCompatibleStream'); + } + + let itemToPlay = mediaSource; + + if (mediaSource.RequiresOpening) { + const openLiveStreamResult = await getLiveStream( + item, + playbackInfo.PlaySessionId, + maxBitrate, + deviceProfile, + options.startPositionTicks, + mediaSource, + null, + null + ); + + if (openLiveStreamResult.MediaSource) { + checkDirectPlay(openLiveStreamResult.MediaSource); + itemToPlay = openLiveStreamResult.MediaSource; + } + } + + this.playMediaSource( + playbackInfo.PlaySessionId, + item, + itemToPlay, + options + ); + } - // TODO eradicate any - playMediaSource( - playSessionId: string, - item: BaseItemDto, - mediaSource: MediaSourceInfo, - options: any - ): void { - DocumentManager.setAppStatus('loading'); + // TODO eradicate any + playMediaSource( + playSessionId: string, + item: BaseItemDto, + mediaSource: MediaSourceInfo, + options: any + ): void { + DocumentManager.setAppStatus('loading'); - const streamInfo = createStreamInfo( - item, - mediaSource, - options.startPositionTicks - ); + const streamInfo = createStreamInfo( + item, + mediaSource, + options.startPositionTicks + ); - const url = streamInfo.url; + const url = streamInfo.url; - const mediaInfo = createMediaInformation(playSessionId, item, streamInfo); - const loadRequestData = new cast.framework.messages.LoadRequestData(); + const mediaInfo = createMediaInformation( + playSessionId, + item, + streamInfo + ); + const loadRequestData = new cast.framework.messages.LoadRequestData(); - loadRequestData.media = mediaInfo; - loadRequestData.autoplay = true; + loadRequestData.media = mediaInfo; + loadRequestData.autoplay = true; - load($scope, mediaInfo.customData, item); - this.playerManager.load(loadRequestData); + load($scope, mediaInfo.customData, item); + this.playerManager.load(loadRequestData); - $scope.PlaybackMediaSource = mediaSource; + $scope.PlaybackMediaSource = mediaSource; - console.log(`setting src to ${url}`); - $scope.mediaSource = mediaSource; + console.log(`setting src to ${url}`); + $scope.mediaSource = mediaSource; - DocumentManager.setPlayerBackdrop(item); + DocumentManager.setPlayerBackdrop(item); - reportPlaybackStart($scope, getReportingParams($scope)); + reportPlaybackStart($scope, getReportingParams($scope)); - // We use false as we do not want to broadcast the new status yet - // we will broadcast manually when the media has been loaded, this - // is to be sure the duration has been updated in the media element - this.playerManager.setMediaInformation(mediaInfo, false); - } + // We use false as we do not want to broadcast the new status yet + // we will broadcast manually when the media has been loaded, this + // is to be sure the duration has been updated in the media element + this.playerManager.setMediaInformation(mediaInfo, false); + } - stop(continuing = false): Promise { - $scope.playNextItem = continuing; - stop(); + stop(continuing = false): Promise { + $scope.playNextItem = continuing; + stop(); - const reportingParams = getReportingParams($scope); + const reportingParams = getReportingParams($scope); - let promise; + let promise; - stopPingInterval(); + stopPingInterval(); - if (reportingParams.ItemId) { - promise = reportPlaybackStopped($scope, reportingParams); - } + if (reportingParams.ItemId) { + promise = reportPlaybackStopped($scope, reportingParams); + } - this.playerManager.stop(); + this.playerManager.stop(); - this.activePlaylist = []; - this.activePlaylistIndex = -1; - DocumentManager.startBackdropInterval(); + this.activePlaylist = []; + this.activePlaylistIndex = -1; + DocumentManager.startBackdropInterval(); - promise = promise || Promise.resolve(); + promise = promise || Promise.resolve(); - return promise; - } + return promise; + } } diff --git a/src/css/glyphicons.css b/src/css/glyphicons.css index e10bbc76..3b891427 100644 --- a/src/css/glyphicons.css +++ b/src/css/glyphicons.css @@ -1,821 +1,821 @@ @font-face { - font-family: 'Glyphicons Halflings'; - src: url('../fonts/glyphicons-halflings-regular.eot'); - src: url('../fonts/glyphicons-halflings-regular.eot?#iefix') - format('embedded-opentype'), - url('../fonts/glyphicons-halflings-regular.woff') format('woff'), - url('../fonts/glyphicons-halflings-regular.ttf') format('truetype'), - url('../fonts/glyphicons-halflings-regular.svg#glyphicons-halflingsregular') - format('svg'); + font-family: 'Glyphicons Halflings'; + src: url('../fonts/glyphicons-halflings-regular.eot'); + src: url('../fonts/glyphicons-halflings-regular.eot?#iefix') + format('embedded-opentype'), + url('../fonts/glyphicons-halflings-regular.woff') format('woff'), + url('../fonts/glyphicons-halflings-regular.ttf') format('truetype'), + url('../fonts/glyphicons-halflings-regular.svg#glyphicons-halflingsregular') + format('svg'); } .glyphicon { - position: relative; - top: 1px; - display: inline-block; - font-family: 'Glyphicons Halflings'; /* stylelint-disable-line font-family-no-missing-generic-family-keyword */ - font-style: normal; - font-weight: normal; - line-height: 1; - -webkit-font-smoothing: antialiased; + position: relative; + top: 1px; + display: inline-block; + font-family: 'Glyphicons Halflings'; /* stylelint-disable-line font-family-no-missing-generic-family-keyword */ + font-style: normal; + font-weight: normal; + line-height: 1; + -webkit-font-smoothing: antialiased; } .glyphicon-asterisk::before { - content: '\2a'; + content: '\2a'; } .glyphicon-plus::before { - content: '\2b'; + content: '\2b'; } .glyphicon-euro::before { - content: '\20ac'; + content: '\20ac'; } .glyphicon-minus::before { - content: '\2212'; + content: '\2212'; } .glyphicon-cloud::before { - content: '\2601'; + content: '\2601'; } .glyphicon-envelope::before { - content: '\2709'; + content: '\2709'; } .glyphicon-pencil::before { - content: '\270f'; + content: '\270f'; } .glyphicon-glass::before { - content: '\e001'; + content: '\e001'; } .glyphicon-music::before { - content: '\e002'; + content: '\e002'; } .glyphicon-search::before { - content: '\e003'; + content: '\e003'; } .glyphicon-heart::before { - content: '\e005'; + content: '\e005'; } .glyphicon-star::before { - content: '\e006'; + content: '\e006'; } .glyphicon-star-empty::before { - content: '\e007'; + content: '\e007'; } .glyphicon-user::before { - content: '\e008'; + content: '\e008'; } .glyphicon-film::before { - content: '\e009'; + content: '\e009'; } .glyphicon-th-large::before { - content: '\e010'; + content: '\e010'; } .glyphicon-th::before { - content: '\e011'; + content: '\e011'; } .glyphicon-th-list::before { - content: '\e012'; + content: '\e012'; } .glyphicon-ok::before { - content: '\e013'; + content: '\e013'; } .glyphicon-remove::before { - content: '\e014'; + content: '\e014'; } .glyphicon-zoom-in::before { - content: '\e015'; + content: '\e015'; } .glyphicon-zoom-out::before { - content: '\e016'; + content: '\e016'; } .glyphicon-off::before { - content: '\e017'; + content: '\e017'; } .glyphicon-signal::before { - content: '\e018'; + content: '\e018'; } .glyphicon-cog::before { - content: '\e019'; + content: '\e019'; } .glyphicon-trash::before { - content: '\e020'; + content: '\e020'; } .glyphicon-home::before { - content: '\e021'; + content: '\e021'; } .glyphicon-file::before { - content: '\e022'; + content: '\e022'; } .glyphicon-time::before { - content: '\e023'; + content: '\e023'; } .glyphicon-road::before { - content: '\e024'; + content: '\e024'; } .glyphicon-download-alt::before { - content: '\e025'; + content: '\e025'; } .glyphicon-download::before { - content: '\e026'; + content: '\e026'; } .glyphicon-upload::before { - content: '\e027'; + content: '\e027'; } .glyphicon-inbox::before { - content: '\e028'; + content: '\e028'; } .glyphicon-play-circle::before { - content: '\e029'; + content: '\e029'; } .glyphicon-repeat::before { - content: '\e030'; + content: '\e030'; } .glyphicon-refresh::before { - content: '\e031'; + content: '\e031'; } .glyphicon-list-alt::before { - content: '\e032'; + content: '\e032'; } .glyphicon-flag::before { - content: '\e034'; + content: '\e034'; } .glyphicon-headphones::before { - content: '\e035'; + content: '\e035'; } .glyphicon-volume-off::before { - content: '\e036'; + content: '\e036'; } .glyphicon-volume-down::before { - content: '\e037'; + content: '\e037'; } .glyphicon-volume-up::before { - content: '\e038'; + content: '\e038'; } .glyphicon-qrcode::before { - content: '\e039'; + content: '\e039'; } .glyphicon-barcode::before { - content: '\e040'; + content: '\e040'; } .glyphicon-tag::before { - content: '\e041'; + content: '\e041'; } .glyphicon-tags::before { - content: '\e042'; + content: '\e042'; } .glyphicon-book::before { - content: '\e043'; + content: '\e043'; } .glyphicon-print::before { - content: '\e045'; + content: '\e045'; } .glyphicon-font::before { - content: '\e047'; + content: '\e047'; } .glyphicon-bold::before { - content: '\e048'; + content: '\e048'; } .glyphicon-italic::before { - content: '\e049'; + content: '\e049'; } .glyphicon-text-height::before { - content: '\e050'; + content: '\e050'; } .glyphicon-text-width::before { - content: '\e051'; + content: '\e051'; } .glyphicon-align-left::before { - content: '\e052'; + content: '\e052'; } .glyphicon-align-center::before { - content: '\e053'; + content: '\e053'; } .glyphicon-align-right::before { - content: '\e054'; + content: '\e054'; } .glyphicon-align-justify::before { - content: '\e055'; + content: '\e055'; } .glyphicon-list::before { - content: '\e056'; + content: '\e056'; } .glyphicon-indent-left::before { - content: '\e057'; + content: '\e057'; } .glyphicon-indent-right::before { - content: '\e058'; + content: '\e058'; } .glyphicon-facetime-video::before { - content: '\e059'; + content: '\e059'; } .glyphicon-picture::before { - content: '\e060'; + content: '\e060'; } .glyphicon-map-marker::before { - content: '\e062'; + content: '\e062'; } .glyphicon-adjust::before { - content: '\e063'; + content: '\e063'; } .glyphicon-tint::before { - content: '\e064'; + content: '\e064'; } .glyphicon-edit::before { - content: '\e065'; + content: '\e065'; } .glyphicon-share::before { - content: '\e066'; + content: '\e066'; } .glyphicon-check::before { - content: '\e067'; + content: '\e067'; } .glyphicon-move::before { - content: '\e068'; + content: '\e068'; } .glyphicon-step-backward::before { - content: '\e069'; + content: '\e069'; } .glyphicon-fast-backward::before { - content: '\e070'; + content: '\e070'; } .glyphicon-backward::before { - content: '\e071'; + content: '\e071'; } .glyphicon-play::before { - content: '\e072'; + content: '\e072'; } .glyphicon-pause::before { - content: '\e073'; + content: '\e073'; } .glyphicon-stop::before { - content: '\e074'; + content: '\e074'; } .glyphicon-forward::before { - content: '\e075'; + content: '\e075'; } .glyphicon-fast-forward::before { - content: '\e076'; + content: '\e076'; } .glyphicon-step-forward::before { - content: '\e077'; + content: '\e077'; } .glyphicon-eject::before { - content: '\e078'; + content: '\e078'; } .glyphicon-chevron-left::before { - content: '\e079'; + content: '\e079'; } .glyphicon-chevron-right::before { - content: '\e080'; + content: '\e080'; } .glyphicon-plus-sign::before { - content: '\e081'; + content: '\e081'; } .glyphicon-minus-sign::before { - content: '\e082'; + content: '\e082'; } .glyphicon-remove-sign::before { - content: '\e083'; + content: '\e083'; } .glyphicon-ok-sign::before { - content: '\e084'; + content: '\e084'; } .glyphicon-question-sign::before { - content: '\e085'; + content: '\e085'; } .glyphicon-info-sign::before { - content: '\e086'; + content: '\e086'; } .glyphicon-screenshot::before { - content: '\e087'; + content: '\e087'; } .glyphicon-remove-circle::before { - content: '\e088'; + content: '\e088'; } .glyphicon-ok-circle::before { - content: '\e089'; + content: '\e089'; } .glyphicon-ban-circle::before { - content: '\e090'; + content: '\e090'; } .glyphicon-arrow-left::before { - content: '\e091'; + content: '\e091'; } .glyphicon-arrow-right::before { - content: '\e092'; + content: '\e092'; } .glyphicon-arrow-up::before { - content: '\e093'; + content: '\e093'; } .glyphicon-arrow-down::before { - content: '\e094'; + content: '\e094'; } .glyphicon-share-alt::before { - content: '\e095'; + content: '\e095'; } .glyphicon-resize-full::before { - content: '\e096'; + content: '\e096'; } .glyphicon-resize-small::before { - content: '\e097'; + content: '\e097'; } .glyphicon-exclamation-sign::before { - content: '\e101'; + content: '\e101'; } .glyphicon-gift::before { - content: '\e102'; + content: '\e102'; } .glyphicon-leaf::before { - content: '\e103'; + content: '\e103'; } .glyphicon-eye-open::before { - content: '\e105'; + content: '\e105'; } .glyphicon-eye-close::before { - content: '\e106'; + content: '\e106'; } .glyphicon-warning-sign::before { - content: '\e107'; + content: '\e107'; } .glyphicon-plane::before { - content: '\e108'; + content: '\e108'; } .glyphicon-random::before { - content: '\e110'; + content: '\e110'; } .glyphicon-comment::before { - content: '\e111'; + content: '\e111'; } .glyphicon-magnet::before { - content: '\e112'; + content: '\e112'; } .glyphicon-chevron-up::before { - content: '\e113'; + content: '\e113'; } .glyphicon-chevron-down::before { - content: '\e114'; + content: '\e114'; } .glyphicon-retweet::before { - content: '\e115'; + content: '\e115'; } .glyphicon-shopping-cart::before { - content: '\e116'; + content: '\e116'; } .glyphicon-folder-close::before { - content: '\e117'; + content: '\e117'; } .glyphicon-folder-open::before { - content: '\e118'; + content: '\e118'; } .glyphicon-resize-vertical::before { - content: '\e119'; + content: '\e119'; } .glyphicon-resize-horizontal::before { - content: '\e120'; + content: '\e120'; } .glyphicon-hdd::before { - content: '\e121'; + content: '\e121'; } .glyphicon-bullhorn::before { - content: '\e122'; + content: '\e122'; } .glyphicon-certificate::before { - content: '\e124'; + content: '\e124'; } .glyphicon-thumbs-up::before { - content: '\e125'; + content: '\e125'; } .glyphicon-thumbs-down::before { - content: '\e126'; + content: '\e126'; } .glyphicon-hand-right::before { - content: '\e127'; + content: '\e127'; } .glyphicon-hand-left::before { - content: '\e128'; + content: '\e128'; } .glyphicon-hand-up::before { - content: '\e129'; + content: '\e129'; } .glyphicon-hand-down::before { - content: '\e130'; + content: '\e130'; } .glyphicon-circle-arrow-right::before { - content: '\e131'; + content: '\e131'; } .glyphicon-circle-arrow-left::before { - content: '\e132'; + content: '\e132'; } .glyphicon-circle-arrow-up::before { - content: '\e133'; + content: '\e133'; } .glyphicon-circle-arrow-down::before { - content: '\e134'; + content: '\e134'; } .glyphicon-globe::before { - content: '\e135'; + content: '\e135'; } .glyphicon-tasks::before { - content: '\e137'; + content: '\e137'; } .glyphicon-filter::before { - content: '\e138'; + content: '\e138'; } .glyphicon-fullscreen::before { - content: '\e140'; + content: '\e140'; } .glyphicon-dashboard::before { - content: '\e141'; + content: '\e141'; } .glyphicon-heart-empty::before { - content: '\e143'; + content: '\e143'; } .glyphicon-link::before { - content: '\e144'; + content: '\e144'; } .glyphicon-phone::before { - content: '\e145'; + content: '\e145'; } .glyphicon-usd::before { - content: '\e148'; + content: '\e148'; } .glyphicon-gbp::before { - content: '\e149'; + content: '\e149'; } .glyphicon-sort::before { - content: '\e150'; + content: '\e150'; } .glyphicon-sort-by-alphabet::before { - content: '\e151'; + content: '\e151'; } .glyphicon-sort-by-alphabet-alt::before { - content: '\e152'; + content: '\e152'; } .glyphicon-sort-by-order::before { - content: '\e153'; + content: '\e153'; } .glyphicon-sort-by-order-alt::before { - content: '\e154'; + content: '\e154'; } .glyphicon-sort-by-attributes::before { - content: '\e155'; + content: '\e155'; } .glyphicon-sort-by-attributes-alt::before { - content: '\e156'; + content: '\e156'; } .glyphicon-unchecked::before { - content: '\e157'; + content: '\e157'; } .glyphicon-expand::before { - content: '\e158'; + content: '\e158'; } .glyphicon-collapse-down::before { - content: '\e159'; + content: '\e159'; } .glyphicon-collapse-up::before { - content: '\e160'; + content: '\e160'; } .glyphicon-log-in::before { - content: '\e161'; + content: '\e161'; } .glyphicon-flash::before { - content: '\e162'; + content: '\e162'; } .glyphicon-log-out::before { - content: '\e163'; + content: '\e163'; } .glyphicon-new-window::before { - content: '\e164'; + content: '\e164'; } .glyphicon-record::before { - content: '\e165'; + content: '\e165'; } .glyphicon-save::before { - content: '\e166'; + content: '\e166'; } .glyphicon-open::before { - content: '\e167'; + content: '\e167'; } .glyphicon-saved::before { - content: '\e168'; + content: '\e168'; } .glyphicon-import::before { - content: '\e169'; + content: '\e169'; } .glyphicon-export::before { - content: '\e170'; + content: '\e170'; } .glyphicon-send::before { - content: '\e171'; + content: '\e171'; } .glyphicon-floppy-disk::before { - content: '\e172'; + content: '\e172'; } .glyphicon-floppy-saved::before { - content: '\e173'; + content: '\e173'; } .glyphicon-floppy-remove::before { - content: '\e174'; + content: '\e174'; } .glyphicon-floppy-save::before { - content: '\e175'; + content: '\e175'; } .glyphicon-floppy-open::before { - content: '\e176'; + content: '\e176'; } .glyphicon-credit-card::before { - content: '\e177'; + content: '\e177'; } .glyphicon-transfer::before { - content: '\e178'; + content: '\e178'; } .glyphicon-cutlery::before { - content: '\e179'; + content: '\e179'; } .glyphicon-header::before { - content: '\e180'; + content: '\e180'; } .glyphicon-compressed::before { - content: '\e181'; + content: '\e181'; } .glyphicon-earphone::before { - content: '\e182'; + content: '\e182'; } .glyphicon-phone-alt::before { - content: '\e183'; + content: '\e183'; } .glyphicon-tower::before { - content: '\e184'; + content: '\e184'; } .glyphicon-stats::before { - content: '\e185'; + content: '\e185'; } .glyphicon-sd-video::before { - content: '\e186'; + content: '\e186'; } .glyphicon-hd-video::before { - content: '\e187'; + content: '\e187'; } .glyphicon-subtitles::before { - content: '\e188'; + content: '\e188'; } .glyphicon-sound-stereo::before { - content: '\e189'; + content: '\e189'; } .glyphicon-sound-dolby::before { - content: '\e190'; + content: '\e190'; } .glyphicon-sound-5-1::before { - content: '\e191'; + content: '\e191'; } .glyphicon-sound-6-1::before { - content: '\e192'; + content: '\e192'; } .glyphicon-sound-7-1::before { - content: '\e193'; + content: '\e193'; } .glyphicon-copyright-mark::before { - content: '\e194'; + content: '\e194'; } .glyphicon-registration-mark::before { - content: '\e195'; + content: '\e195'; } .glyphicon-cloud-download::before { - content: '\e197'; + content: '\e197'; } .glyphicon-cloud-upload::before { - content: '\e198'; + content: '\e198'; } .glyphicon-tree-conifer::before { - content: '\e199'; + content: '\e199'; } .glyphicon-tree-deciduous::before { - content: '\e200'; + content: '\e200'; } .glyphicon-briefcase::before { - content: '\1f4bc'; + content: '\1f4bc'; } .glyphicon-calendar::before { - content: '\1f4c5'; + content: '\1f4c5'; } .glyphicon-pushpin::before { - content: '\1f4cc'; + content: '\1f4cc'; } .glyphicon-paperclip::before { - content: '\1f4ce'; + content: '\1f4ce'; } .glyphicon-camera::before { - content: '\1f4f7'; + content: '\1f4f7'; } .glyphicon-lock::before { - content: '\1f512'; + content: '\1f512'; } .glyphicon-bell::before { - content: '\1f514'; + content: '\1f514'; } .glyphicon-bookmark::before { - content: '\1f516'; + content: '\1f516'; } .glyphicon-fire::before { - content: '\1f525'; + content: '\1f525'; } .glyphicon-wrench::before { - content: '\1f527'; + content: '\1f527'; } diff --git a/src/css/jellyfin.css b/src/css/jellyfin.css index 5f3b209a..1562d008 100644 --- a/src/css/jellyfin.css +++ b/src/css/jellyfin.css @@ -1,16 +1,16 @@ html, body { - height: 100%; - width: 100%; + height: 100%; + width: 100%; } body { - font-family: 'Quicksand', sans-serif; - font-weight: 300; - color: #ddd; - background-color: #000; - margin: 0; - padding: 0; + font-family: 'Quicksand', sans-serif; + font-weight: 300; + color: #ddd; + background-color: #000; + margin: 0; + padding: 0; } #waiting-container, @@ -19,274 +19,274 @@ body { .details > #video-player, .detailContent, .detailLogo { - /* There is an open bug on the chromecast, transitions are buggy and sometimes are not triggered. + /* There is an open bug on the chromecast, transitions are buggy and sometimes are not triggered. opacity: 0; -webkit-transition: opacity .25s ease-in-out; transition: opacity .25s ease-in-out; */ - display: none; + display: none; } .d-none { - display: none !important; + display: none !important; } #waiting-container-backdrop { - position: absolute; - top: 0; - left: 0; - right: 0; - bottom: 0; - background-color: #000; - background-position: center; - background-size: cover; - background-repeat: no-repeat; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: #000; + background-position: center; + background-size: cover; + background-repeat: no-repeat; } #waiting-container { - background-position: center; - background-size: cover; - background-repeat: no-repeat; + background-position: center; + background-size: cover; + background-repeat: no-repeat; - /* Layer on top of the backdrop image: */ - background-color: rgba(15, 15, 15, 0.6); - position: absolute; - top: 0; - left: 0; - right: 0; - bottom: 0; - padding: 18px 32px; + /* Layer on top of the backdrop image: */ + background-color: rgba(15, 15, 15, 0.6); + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + padding: 18px 32px; } .detailContent { - background-position: center; - background-size: cover; - background-repeat: no-repeat; - position: absolute; - top: 0; - left: 0; - right: 0; - bottom: 0; - background-color: rgba(15, 15, 15, 0.82); + background-position: center; + background-size: cover; + background-repeat: no-repeat; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(15, 15, 15, 0.82); } .detailLogo { - height: 50px; - width: 300px; - background-position: left top; - background-size: contain; - background-repeat: no-repeat; - position: absolute; - top: 35px; - left: 50px; + height: 50px; + width: 300px; + background-position: left top; + background-size: contain; + background-repeat: no-repeat; + position: absolute; + top: 35px; + left: 50px; } .detailImage { - background-position: left top; - background-size: contain; - background-repeat: no-repeat; - position: absolute; - top: 22%; - height: 63%; - left: 8%; - width: 20%; + background-position: left top; + background-size: contain; + background-repeat: no-repeat; + position: absolute; + top: 22%; + height: 63%; + left: 8%; + width: 20%; } .playedIndicator { - display: block; - position: absolute; - top: 5px; - right: 5px; - text-align: center; - width: 1.8vw; - height: 1.6vw; - padding-top: 0.1vw; - border-radius: 50%; - color: #fff; - background: rgba(0, 128, 0, 0.8); - font-size: 1.1vw; + display: block; + position: absolute; + top: 5px; + right: 5px; + text-align: center; + width: 1.8vw; + height: 1.6vw; + padding-top: 0.1vw; + border-radius: 50%; + color: #fff; + background: rgba(0, 128, 0, 0.8); + font-size: 1.1vw; } .detailImageProgressContainer { - position: absolute; - bottom: 10px; - right: 0; - left: 0; - text-align: center; + position: absolute; + bottom: 10px; + right: 0; + left: 0; + text-align: center; } .detailImageProgressContainer progress { - width: 100%; - margin: 0 auto; - height: 6px; + width: 100%; + margin: 0 auto; + height: 6px; } /* Chrome */ .itemProgressBar::-webkit-progress-value { - border-radius: 0; - background-image: none; - background-color: #52b54b; + border-radius: 0; + background-image: none; + background-color: #52b54b; } /* Polyfill */ .itemProgressBar[aria-valuenow]::before { - border-radius: 0; - background-image: none; - background-color: #52b54b; + border-radius: 0; + background-image: none; + background-color: #52b54b; } .itemProgressBar { - background: #000 !important; - appearance: none; - -moz-appearance: none; - -webkit-appearance: none; - border: 0; - border: 0 solid #222; - border-radius: 0; + background: #000 !important; + appearance: none; + -moz-appearance: none; + -webkit-appearance: none; + border: 0; + border: 0 solid #222; + border-radius: 0; } .detailInfo { - position: absolute; - top: 22%; - height: 63%; - left: 30.5%; - font-size: 1.2vw; - width: 60%; + position: absolute; + top: 22%; + height: 63%; + left: 30.5%; + font-size: 1.2vw; + width: 60%; } .detailInfo p { - margin: 10px 0; + margin: 10px 0; } .detailRating { - margin: -4px 0 0; + margin: -4px 0 0; } .displayNameContainer { - margin-top: -6px !important; + margin-top: -6px !important; } .displayName { - font-size: 3vw; + font-size: 3vw; } #miscInfo { - font-size: 1.5vw; - margin-left: 2vw; + font-size: 1.5vw; + margin-left: 2vw; } .starRating { - background-image: url('../img/stars.svg'); - background-position: left center; - background-repeat: no-repeat; - background-size: cover; - width: 1.6vw; - height: 1.4vw; - display: inline-block; - vertical-align: text-bottom; - top: 6px; + background-image: url('../img/stars.svg'); + background-position: left center; + background-repeat: no-repeat; + background-size: cover; + width: 1.6vw; + height: 1.4vw; + display: inline-block; + vertical-align: text-bottom; + top: 6px; } .starRatingValue { - display: inline-block; - margin-left: 1px; + display: inline-block; + margin-left: 1px; } .rottentomatoesicon { - display: inline-block; - width: 1.4vw; - height: 1.4vw; - background-size: cover; - background-position: left center; - background-repeat: no-repeat; - vertical-align: text-bottom; - top: 6px; + display: inline-block; + width: 1.4vw; + height: 1.4vw; + background-size: cover; + background-position: left center; + background-repeat: no-repeat; + vertical-align: text-bottom; + top: 6px; } .starRatingValue + .rottentomatoesicon { - margin-left: 1em; + margin-left: 1em; } .fresh { - background-image: url('../img/fresh.svg'); + background-image: url('../img/fresh.svg'); } .rotten { - background-image: url('../img/rotten.svg'); + background-image: url('../img/rotten.svg'); } .metascorehigh { - background-color: rgba(102, 204, 51, 0.7); + background-color: rgba(102, 204, 51, 0.7); } .metascoremid { - background-color: rgba(255, 204, 51, 0.7); + background-color: rgba(255, 204, 51, 0.7); } .metascorelow { - background-color: rgba(240, 0, 0, 0.7); + background-color: rgba(240, 0, 0, 0.7); } .criticRating + .metascore, .starRatingValue + .metascore { - margin-left: 1em; + margin-left: 1em; } .criticRating { - display: inline-block; - margin-left: 1px; + display: inline-block; + margin-left: 1px; } .overview { - max-height: 350px; - overflow: hidden; - text-overflow: ellipsis; + max-height: 350px; + overflow: hidden; + text-overflow: ellipsis; } /* Container for "ready to cast" and the logo */ .waitingContent { - position: fixed; - bottom: 0; - left: 0; - text-align: center; - font-size: 3vw; - margin-bottom: 3%; - margin-left: 5%; + position: fixed; + bottom: 0; + left: 0; + text-align: center; + font-size: 3vw; + margin-bottom: 3%; + margin-left: 5%; } /* Container for backdrop description */ .waitingDescription { - position: fixed; - bottom: 0; - right: 0; - margin-right: 5%; - margin-bottom: 3%; - font-size: 1.5vw; + position: fixed; + bottom: 0; + right: 0; + margin-right: 5%; + margin-bottom: 3%; + font-size: 1.5vw; } #waiting-container h1, #waiting-container h2 { - margin: 25px 0; + margin: 25px 0; } #waiting-container h1 { - font-size: 45px; - font-weight: 300; + font-size: 45px; + font-weight: 300; } /* stylelint-disable no-descending-specificity */ .error-container h2, #waiting-container h2 { - font-size: 30px; - font-weight: 300; + font-size: 30px; + font-weight: 300; } /* stylelint-enable no-descending-specificity */ /* jellyfin logo in the waiting container */ #waiting-container .logo { - width: 4vw; - display: inline-block; - vertical-align: text-bottom; + width: 4vw; + display: inline-block; + vertical-align: text-bottom; } .waiting > #waiting-container-backdrop, @@ -294,18 +294,18 @@ body { .details .detailContent, .details .detailLogo, .details #waiting-container-backdrop { - /* opacity: 1; */ - display: initial; + /* opacity: 1; */ + display: initial; } /* stylelint-disable selector-type-no-unknown */ cast-media-player { - --spinner-image: url('../img/spinner.png'); - --playback-logo-image: url('../img/banner.svg'); - --watermark-image: url('../img/banner.svg'); - --watermark-size: 225px; - --watermark-position: top right; - --theme-hue: 195.3; /* Jellyfin blue */ - --progress-color: #00a4dc; + --spinner-image: url('../img/spinner.png'); + --playback-logo-image: url('../img/banner.svg'); + --watermark-image: url('../img/banner.svg'); + --watermark-size: 225px; + --watermark-position: top right; + --theme-hue: 195.3; /* Jellyfin blue */ + --progress-color: #00a4dc; } /* stylelint-enable selector-type-no-unknown */ diff --git a/src/helpers.ts b/src/helpers.ts index 9ce2015f..33d49398 100644 --- a/src/helpers.ts +++ b/src/helpers.ts @@ -16,14 +16,14 @@ import { GlobalScope, BusMessage, ItemIndex, ItemQuery } from './types/global'; * @returns position in ticks */ export function getCurrentPositionTicks($scope: GlobalScope): number { - let positionTicks = window.mediaManager.getCurrentTimeSec() * 10000000; - const mediaInformation = window.mediaManager.getMediaInformation(); + let positionTicks = window.mediaManager.getCurrentTimeSec() * 10000000; + const mediaInformation = window.mediaManager.getMediaInformation(); - if (mediaInformation && !mediaInformation.customData.canClientSeek) { - positionTicks += $scope.startPositionTicks || 0; - } + if (mediaInformation && !mediaInformation.customData.canClientSeek) { + positionTicks += $scope.startPositionTicks || 0; + } - return positionTicks; + return positionTicks; } /** @@ -33,28 +33,28 @@ export function getCurrentPositionTicks($scope: GlobalScope): number { * @returns progress information for use with the reporting APIs */ export function getReportingParams($scope: GlobalScope): PlaybackProgressInfo { - /* Math.round() calls: - * on 10.7, any floating point will give an API error, - * so it's actually really important to make sure that - * those fields are always rounded. - */ - return { - PositionTicks: Math.round(getCurrentPositionTicks($scope)), - IsPaused: - window.mediaManager.getPlayerState() === - cast.framework.messages.PlayerState.PAUSED, - IsMuted: window.volume.muted, - AudioStreamIndex: $scope.audioStreamIndex, - SubtitleStreamIndex: $scope.subtitleStreamIndex, - VolumeLevel: Math.round(window.volume.level * 100), - ItemId: $scope.itemId, - MediaSourceId: $scope.mediaSourceId, - CanSeek: $scope.canSeek, - PlayMethod: $scope.playMethod, - LiveStreamId: $scope.liveStreamId, - PlaySessionId: $scope.playSessionId, - RepeatMode: window.repeatMode - }; + /* Math.round() calls: + * on 10.7, any floating point will give an API error, + * so it's actually really important to make sure that + * those fields are always rounded. + */ + return { + PositionTicks: Math.round(getCurrentPositionTicks($scope)), + IsPaused: + window.mediaManager.getPlayerState() === + cast.framework.messages.PlayerState.PAUSED, + IsMuted: window.volume.muted, + AudioStreamIndex: $scope.audioStreamIndex, + SubtitleStreamIndex: $scope.subtitleStreamIndex, + VolumeLevel: Math.round(window.volume.level * 100), + ItemId: $scope.itemId, + MediaSourceId: $scope.mediaSourceId, + CanSeek: $scope.canSeek, + PlayMethod: $scope.playMethod, + LiveStreamId: $scope.liveStreamId, + PlaySessionId: $scope.playSessionId, + RepeatMode: window.repeatMode + }; } /** @@ -63,45 +63,45 @@ export function getReportingParams($scope: GlobalScope): PlaybackProgressInfo { * @returns ItemIndex including item and index, or null to end playback */ export function getNextPlaybackItemInfo(): ItemIndex | null { - const playlist = window.playlist; + const playlist = window.playlist; - if (!playlist) { - return null; - } - - let newIndex: number; - - if (window.currentPlaylistIndex == -1) { - newIndex = 0; - } else { - switch (window.repeatMode) { - case 'RepeatOne': - newIndex = window.currentPlaylistIndex; - break; - case 'RepeatAll': - newIndex = window.currentPlaylistIndex + 1; - - if (newIndex >= window.playlist.length) { - newIndex = 0; - } + if (!playlist) { + return null; + } + + let newIndex: number; - break; - default: - newIndex = window.currentPlaylistIndex + 1; - break; + if (window.currentPlaylistIndex == -1) { + newIndex = 0; + } else { + switch (window.repeatMode) { + case 'RepeatOne': + newIndex = window.currentPlaylistIndex; + break; + case 'RepeatAll': + newIndex = window.currentPlaylistIndex + 1; + + if (newIndex >= window.playlist.length) { + newIndex = 0; + } + + break; + default: + newIndex = window.currentPlaylistIndex + 1; + break; + } } - } - if (newIndex < playlist.length) { - const item = playlist[newIndex]; + if (newIndex < playlist.length) { + const item = playlist[newIndex]; - return { - item: item, - index: newIndex - }; - } + return { + item: item, + index: newIndex + }; + } - return null; + return null; } /** @@ -114,95 +114,97 @@ export function getNextPlaybackItemInfo(): ItemIndex | null { * @returns lots of data for the connected client */ export function getSenderReportingData( - $scope: GlobalScope, - reportingData: PlaybackProgressInfo + $scope: GlobalScope, + reportingData: PlaybackProgressInfo ): any { - const state: any = { - ItemId: reportingData.ItemId, - PlayState: reportingData, - QueueableMediaTypes: ['Audio', 'Video'] - }; - - state.NowPlayingItem = { - Id: reportingData.ItemId, - RunTimeTicks: $scope.runtimeTicks - }; - - const item = $scope.item; - - if (item) { - const nowPlayingItem = state.NowPlayingItem; - - nowPlayingItem.ServerId = item.ServerId; - nowPlayingItem.Chapters = item.Chapters || []; - - // TODO: Fill these - const mediaSource = item.MediaSources.filter((m: any) => { - return m.Id == reportingData.MediaSourceId; - })[0]; - - nowPlayingItem.MediaStreams = mediaSource ? mediaSource.MediaStreams : []; - - nowPlayingItem.MediaType = item.MediaType; - nowPlayingItem.Type = item.Type; - nowPlayingItem.Name = item.Name; - - nowPlayingItem.IndexNumber = item.IndexNumber; - nowPlayingItem.IndexNumberEnd = item.IndexNumberEnd; - nowPlayingItem.ParentIndexNumber = item.ParentIndexNumber; - nowPlayingItem.ProductionYear = item.ProductionYear; - nowPlayingItem.PremiereDate = item.PremiereDate; - nowPlayingItem.SeriesName = item.SeriesName; - nowPlayingItem.Album = item.Album; - nowPlayingItem.Artists = item.Artists; + const state: any = { + ItemId: reportingData.ItemId, + PlayState: reportingData, + QueueableMediaTypes: ['Audio', 'Video'] + }; - const imageTags = item.ImageTags || {}; + state.NowPlayingItem = { + Id: reportingData.ItemId, + RunTimeTicks: $scope.runtimeTicks + }; - if (item.SeriesPrimaryImageTag) { - nowPlayingItem.PrimaryImageItemId = item.SeriesId; - nowPlayingItem.PrimaryImageTag = item.SeriesPrimaryImageTag; - } else if (imageTags.Primary) { - nowPlayingItem.PrimaryImageItemId = item.Id; - nowPlayingItem.PrimaryImageTag = imageTags.Primary; - } else if (item.AlbumPrimaryImageTag) { - nowPlayingItem.PrimaryImageItemId = item.AlbumId; - nowPlayingItem.PrimaryImageTag = item.AlbumPrimaryImageTag; - } + const item = $scope.item; + + if (item) { + const nowPlayingItem = state.NowPlayingItem; + + nowPlayingItem.ServerId = item.ServerId; + nowPlayingItem.Chapters = item.Chapters || []; + + // TODO: Fill these + const mediaSource = item.MediaSources.filter((m: any) => { + return m.Id == reportingData.MediaSourceId; + })[0]; + + nowPlayingItem.MediaStreams = mediaSource + ? mediaSource.MediaStreams + : []; + + nowPlayingItem.MediaType = item.MediaType; + nowPlayingItem.Type = item.Type; + nowPlayingItem.Name = item.Name; + + nowPlayingItem.IndexNumber = item.IndexNumber; + nowPlayingItem.IndexNumberEnd = item.IndexNumberEnd; + nowPlayingItem.ParentIndexNumber = item.ParentIndexNumber; + nowPlayingItem.ProductionYear = item.ProductionYear; + nowPlayingItem.PremiereDate = item.PremiereDate; + nowPlayingItem.SeriesName = item.SeriesName; + nowPlayingItem.Album = item.Album; + nowPlayingItem.Artists = item.Artists; + + const imageTags = item.ImageTags || {}; + + if (item.SeriesPrimaryImageTag) { + nowPlayingItem.PrimaryImageItemId = item.SeriesId; + nowPlayingItem.PrimaryImageTag = item.SeriesPrimaryImageTag; + } else if (imageTags.Primary) { + nowPlayingItem.PrimaryImageItemId = item.Id; + nowPlayingItem.PrimaryImageTag = imageTags.Primary; + } else if (item.AlbumPrimaryImageTag) { + nowPlayingItem.PrimaryImageItemId = item.AlbumId; + nowPlayingItem.PrimaryImageTag = item.AlbumPrimaryImageTag; + } - if (item.BackdropImageTags && item.BackdropImageTags.length) { - nowPlayingItem.BackdropItemId = item.Id; - nowPlayingItem.BackdropImageTag = item.BackdropImageTags[0]; - } else if ( - item.ParentBackdropImageTags && - item.ParentBackdropImageTags.length - ) { - nowPlayingItem.BackdropItemId = item.ParentBackdropItemId; - nowPlayingItem.BackdropImageTag = item.ParentBackdropImageTags[0]; - } + if (item.BackdropImageTags && item.BackdropImageTags.length) { + nowPlayingItem.BackdropItemId = item.Id; + nowPlayingItem.BackdropImageTag = item.BackdropImageTags[0]; + } else if ( + item.ParentBackdropImageTags && + item.ParentBackdropImageTags.length + ) { + nowPlayingItem.BackdropItemId = item.ParentBackdropItemId; + nowPlayingItem.BackdropImageTag = item.ParentBackdropImageTags[0]; + } - if (imageTags.Thumb) { - nowPlayingItem.ThumbItemId = item.Id; - nowPlayingItem.ThumbImageTag = imageTags.Thumb; - } + if (imageTags.Thumb) { + nowPlayingItem.ThumbItemId = item.Id; + nowPlayingItem.ThumbImageTag = imageTags.Thumb; + } - if (imageTags.Logo) { - nowPlayingItem.LogoItemId = item.Id; - nowPlayingItem.LogoImageTag = imageTags.Logo; - } else if (item.ParentLogoImageTag) { - nowPlayingItem.LogoItemId = item.ParentLogoItemId; - nowPlayingItem.LogoImageTag = item.ParentLogoImageTag; - } + if (imageTags.Logo) { + nowPlayingItem.LogoItemId = item.Id; + nowPlayingItem.LogoImageTag = imageTags.Logo; + } else if (item.ParentLogoImageTag) { + nowPlayingItem.LogoItemId = item.ParentLogoItemId; + nowPlayingItem.LogoImageTag = item.ParentLogoImageTag; + } - if ($scope.playNextItem) { - const nextItemInfo = getNextPlaybackItemInfo(); + if ($scope.playNextItem) { + const nextItemInfo = getNextPlaybackItemInfo(); - if (nextItemInfo) { - state.NextMediaType = nextItemInfo.item.MediaType; - } + if (nextItemInfo) { + state.NextMediaType = nextItemInfo.item.MediaType; + } + } } - } - return state; + return state; } /** @@ -211,32 +213,32 @@ export function getSenderReportingData( * @param $scope - global context variable */ export function resetPlaybackScope($scope: GlobalScope): void { - DocumentManager.setAppStatus('waiting'); - - $scope.startPositionTicks = 0; - DocumentManager.setWaitingBackdrop(null, null); - $scope.mediaType = ''; - $scope.itemId = ''; - - $scope.audioStreamIndex = null; - $scope.subtitleStreamIndex = null; - $scope.mediaSource = null; - $scope.mediaSourceId = ''; - $scope.PlaybackMediaSource = null; - - $scope.playMethod = ''; - $scope.canSeek = false; - $scope.canClientSeek = false; - $scope.isChangingStream = false; - $scope.playNextItem = true; - - $scope.item = null; - $scope.liveStreamId = ''; - $scope.playSessionId = ''; - - // Detail content - DocumentManager.setLogo(null); - DocumentManager.setDetailImage(null); + DocumentManager.setAppStatus('waiting'); + + $scope.startPositionTicks = 0; + DocumentManager.setWaitingBackdrop(null, null); + $scope.mediaType = ''; + $scope.itemId = ''; + + $scope.audioStreamIndex = null; + $scope.subtitleStreamIndex = null; + $scope.mediaSource = null; + $scope.mediaSourceId = ''; + $scope.PlaybackMediaSource = null; + + $scope.playMethod = ''; + $scope.canSeek = false; + $scope.canClientSeek = false; + $scope.isChangingStream = false; + $scope.playNextItem = true; + + $scope.item = null; + $scope.liveStreamId = ''; + $scope.playSessionId = ''; + + // Detail content + DocumentManager.setLogo(null); + DocumentManager.setDetailImage(null); } /** @@ -246,99 +248,105 @@ export function resetPlaybackScope($scope: GlobalScope): void { * @returns one of the metadata classes in cast.framework.messages.*Metadata */ export function getMetadata(item: BaseItemDto): any { - let metadata: any; - let posterUrl = ''; + let metadata: any; + let posterUrl = ''; - if (item.SeriesPrimaryImageTag) { - posterUrl = JellyfinApi.createUrl( - `Items/${item.SeriesId}/Images/Primary?tag=${item.SeriesPrimaryImageTag}` - ); - } else if (item.AlbumPrimaryImageTag) { - posterUrl = JellyfinApi.createUrl( - `Items/${item.AlbumId}/Images/Primary?tag=${item.AlbumPrimaryImageTag}` - ); - } else if (item.ImageTags?.Primary) { - posterUrl = JellyfinApi.createUrl( - `Items/${item.Id}/Images/Primary?tag=${item.ImageTags.Primary}` - ); - } + if (item.SeriesPrimaryImageTag) { + posterUrl = JellyfinApi.createUrl( + `Items/${item.SeriesId}/Images/Primary?tag=${item.SeriesPrimaryImageTag}` + ); + } else if (item.AlbumPrimaryImageTag) { + posterUrl = JellyfinApi.createUrl( + `Items/${item.AlbumId}/Images/Primary?tag=${item.AlbumPrimaryImageTag}` + ); + } else if (item.ImageTags?.Primary) { + posterUrl = JellyfinApi.createUrl( + `Items/${item.Id}/Images/Primary?tag=${item.ImageTags.Primary}` + ); + } - if (item.Type == 'Episode') { - metadata = new cast.framework.messages.TvShowMediaMetadata(); - metadata.seriesTitle = item.SeriesName; + if (item.Type == 'Episode') { + metadata = new cast.framework.messages.TvShowMediaMetadata(); + metadata.seriesTitle = item.SeriesName; - if (item.PremiereDate) { - metadata.originalAirdate = parseISO8601Date( - item.PremiereDate - ).toISOString(); - } + if (item.PremiereDate) { + metadata.originalAirdate = parseISO8601Date( + item.PremiereDate + ).toISOString(); + } - if (item.IndexNumber != null) { - metadata.episode = item.IndexNumber; - } + if (item.IndexNumber != null) { + metadata.episode = item.IndexNumber; + } - if (item.ParentIndexNumber != null) { - metadata.season = item.ParentIndexNumber; - } - } else if (item.Type == 'Photo') { - metadata = new cast.framework.messages.PhotoMediaMetadata(); + if (item.ParentIndexNumber != null) { + metadata.season = item.ParentIndexNumber; + } + } else if (item.Type == 'Photo') { + metadata = new cast.framework.messages.PhotoMediaMetadata(); - if (item.PremiereDate) { - metadata.creationDateTime = parseISO8601Date( - item.PremiereDate - ).toISOString(); - } - // TODO more metadata? - } else if (item.Type == 'Audio') { - metadata = new cast.framework.messages.MusicTrackMediaMetadata(); - metadata.songName = item.Name; - metadata.artist = - item.Artists && item.Artists.length ? item.Artists.join(', ') : ''; - metadata.albumArtist = item.AlbumArtist; - metadata.albumName = item.Album; - - if (item.PremiereDate) { - metadata.releaseDate = parseISO8601Date(item.PremiereDate).toISOString(); - } + if (item.PremiereDate) { + metadata.creationDateTime = parseISO8601Date( + item.PremiereDate + ).toISOString(); + } + // TODO more metadata? + } else if (item.Type == 'Audio') { + metadata = new cast.framework.messages.MusicTrackMediaMetadata(); + metadata.songName = item.Name; + metadata.artist = + item.Artists && item.Artists.length ? item.Artists.join(', ') : ''; + metadata.albumArtist = item.AlbumArtist; + metadata.albumName = item.Album; + + if (item.PremiereDate) { + metadata.releaseDate = parseISO8601Date( + item.PremiereDate + ).toISOString(); + } - if (item.IndexNumber != null) { - metadata.trackNumber = item.IndexNumber; - } + if (item.IndexNumber != null) { + metadata.trackNumber = item.IndexNumber; + } - if (item.ParentIndexNumber != null) { - metadata.discNumber = item.ParentIndexNumber; - } + if (item.ParentIndexNumber != null) { + metadata.discNumber = item.ParentIndexNumber; + } - // previously: p.PersonType == 'Type'.. wtf? - const composer = (item.People || []).filter( - (p: BaseItemPerson) => p.Type == 'Composer' - )[0]; + // previously: p.PersonType == 'Type'.. wtf? + const composer = (item.People || []).filter( + (p: BaseItemPerson) => p.Type == 'Composer' + )[0]; - if (composer) { - metadata.composer = composer.Name; - } - } else if (item.Type == 'Movie') { - metadata = new cast.framework.messages.MovieMediaMetadata(); + if (composer) { + metadata.composer = composer.Name; + } + } else if (item.Type == 'Movie') { + metadata = new cast.framework.messages.MovieMediaMetadata(); - if (item.PremiereDate) { - metadata.releaseDate = parseISO8601Date(item.PremiereDate).toISOString(); - } - } else { - metadata = new cast.framework.messages.GenericMediaMetadata(); + if (item.PremiereDate) { + metadata.releaseDate = parseISO8601Date( + item.PremiereDate + ).toISOString(); + } + } else { + metadata = new cast.framework.messages.GenericMediaMetadata(); - if (item.PremiereDate) { - metadata.releaseDate = parseISO8601Date(item.PremiereDate).toISOString(); - } + if (item.PremiereDate) { + metadata.releaseDate = parseISO8601Date( + item.PremiereDate + ).toISOString(); + } - if (item.Studios && item.Studios.length) { - metadata.studio = item.Studios[0]; + if (item.Studios && item.Studios.length) { + metadata.studio = item.Studios[0]; + } } - } - metadata.title = item.Name ?? '????'; - metadata.images = [new cast.framework.messages.Image(posterUrl)]; + metadata.title = item.Name ?? '????'; + metadata.images = [new cast.framework.messages.Image(posterUrl)]; - return metadata; + return metadata; } /** @@ -351,141 +359,151 @@ export function getMetadata(item: BaseItemDto): any { * @returns object with enough information to start playback */ export function createStreamInfo( - item: BaseItemDto, - mediaSource: MediaSourceInfo, - startPosition: number | null + item: BaseItemDto, + mediaSource: MediaSourceInfo, + startPosition: number | null ): any { - let mediaUrl; - let contentType; - - // server seeking - const startPositionInSeekParam = startPosition ? startPosition / 10000000 : 0; - const seekParam = startPositionInSeekParam - ? `#t=${startPositionInSeekParam}` - : ''; - - let isStatic = false; - let streamContainer = mediaSource.Container; - - let playerStartPositionTicks = 0; - - const type = item.MediaType?.toLowerCase(); - - if (type == 'video') { - contentType = `video/${mediaSource.Container}`; - - if (mediaSource.SupportsDirectPlay) { - mediaUrl = mediaSource.Path; - isStatic = true; - } else if (mediaSource.SupportsDirectStream) { - mediaUrl = JellyfinApi.createUrl( - `videos/${item.Id}/stream.${mediaSource.Container}?mediaSourceId=${mediaSource.Id}&api_key=${JellyfinApi.accessToken}&static=true${seekParam}` - ); - isStatic = true; - playerStartPositionTicks = startPosition || 0; + let mediaUrl; + let contentType; + + // server seeking + const startPositionInSeekParam = startPosition + ? startPosition / 10000000 + : 0; + const seekParam = startPositionInSeekParam + ? `#t=${startPositionInSeekParam}` + : ''; + + let isStatic = false; + let streamContainer = mediaSource.Container; + + let playerStartPositionTicks = 0; + + const type = item.MediaType?.toLowerCase(); + + if (type == 'video') { + contentType = `video/${mediaSource.Container}`; + + if (mediaSource.SupportsDirectPlay) { + mediaUrl = mediaSource.Path; + isStatic = true; + } else if (mediaSource.SupportsDirectStream) { + mediaUrl = JellyfinApi.createUrl( + `videos/${item.Id}/stream.${mediaSource.Container}?mediaSourceId=${mediaSource.Id}&api_key=${JellyfinApi.accessToken}&static=true${seekParam}` + ); + isStatic = true; + playerStartPositionTicks = startPosition || 0; + } else { + // TODO deal with !TranscodingUrl + mediaUrl = JellyfinApi.createUrl( + mediaSource.TranscodingUrl + ); + + if (mediaSource.TranscodingSubProtocol == 'hls') { + mediaUrl += seekParam; + playerStartPositionTicks = startPosition || 0; + contentType = 'application/x-mpegURL'; + streamContainer = 'm3u8'; + } else { + contentType = `video/${mediaSource.TranscodingContainer}`; + streamContainer = mediaSource.TranscodingContainer; + + if ( + mediaUrl.toLowerCase().indexOf('copytimestamps=true') != -1 + ) { + startPosition = 0; + } + } + } } else { - // TODO deal with !TranscodingUrl - mediaUrl = JellyfinApi.createUrl(mediaSource.TranscodingUrl); - - if (mediaSource.TranscodingSubProtocol == 'hls') { - mediaUrl += seekParam; - playerStartPositionTicks = startPosition || 0; - contentType = 'application/x-mpegURL'; - streamContainer = 'm3u8'; - } else { - contentType = `video/${mediaSource.TranscodingContainer}`; - streamContainer = mediaSource.TranscodingContainer; - - if (mediaUrl.toLowerCase().indexOf('copytimestamps=true') != -1) { - startPosition = 0; + contentType = `audio/${mediaSource.Container}`; + + if (mediaSource.SupportsDirectPlay) { + mediaUrl = mediaSource.Path; + isStatic = true; + playerStartPositionTicks = startPosition || 0; + } else { + const isDirectStream = mediaSource.SupportsDirectStream; + + if (isDirectStream) { + const outputContainer = ( + mediaSource.Container || '' + ).toLowerCase(); + + mediaUrl = JellyfinApi.createUrl( + `Audio/${item.Id}/stream.${outputContainer}?mediaSourceId=${mediaSource.Id}&api_key=${JellyfinApi.accessToken}&static=true${seekParam}` + ); + isStatic = true; + } else { + streamContainer = mediaSource.TranscodingContainer; + contentType = `audio/${mediaSource.TranscodingContainer}`; + + // TODO deal with !TranscodingUrl + mediaUrl = JellyfinApi.createUrl( + mediaSource.TranscodingUrl + ); + } } - } } - } else { - contentType = `audio/${mediaSource.Container}`; - - if (mediaSource.SupportsDirectPlay) { - mediaUrl = mediaSource.Path; - isStatic = true; - playerStartPositionTicks = startPosition || 0; - } else { - const isDirectStream = mediaSource.SupportsDirectStream; - if (isDirectStream) { - const outputContainer = (mediaSource.Container || '').toLowerCase(); + // TODO: Remove the second half of the expression by supporting changing the mediaElement src dynamically. + // It is a pain and will require unbinding all event handlers during the operation + const canSeek = (mediaSource.RunTimeTicks || 0) > 0; + + const info: any = { + url: mediaUrl, + mediaSource: mediaSource, + isStatic: isStatic, + contentType: contentType, + streamContainer: streamContainer, + canSeek: canSeek, + canClientSeek: isStatic || (canSeek && streamContainer == 'm3u8'), + audioStreamIndex: mediaSource.DefaultAudioStreamIndex, + subtitleStreamIndex: mediaSource.DefaultSubtitleStreamIndex, + playerStartPositionTicks: playerStartPositionTicks, + startPositionTicks: startPosition + }; - mediaUrl = JellyfinApi.createUrl( - `Audio/${item.Id}/stream.${outputContainer}?mediaSourceId=${mediaSource.Id}&api_key=${JellyfinApi.accessToken}&static=true${seekParam}` - ); - isStatic = true; - } else { - streamContainer = mediaSource.TranscodingContainer; - contentType = `audio/${mediaSource.TranscodingContainer}`; - - // TODO deal with !TranscodingUrl - mediaUrl = JellyfinApi.createUrl(mediaSource.TranscodingUrl); - } - } - } - - // TODO: Remove the second half of the expression by supporting changing the mediaElement src dynamically. - // It is a pain and will require unbinding all event handlers during the operation - const canSeek = (mediaSource.RunTimeTicks || 0) > 0; - - const info: any = { - url: mediaUrl, - mediaSource: mediaSource, - isStatic: isStatic, - contentType: contentType, - streamContainer: streamContainer, - canSeek: canSeek, - canClientSeek: isStatic || (canSeek && streamContainer == 'm3u8'), - audioStreamIndex: mediaSource.DefaultAudioStreamIndex, - subtitleStreamIndex: mediaSource.DefaultSubtitleStreamIndex, - playerStartPositionTicks: playerStartPositionTicks, - startPositionTicks: startPosition - }; - - const subtitleStreams = - mediaSource.MediaStreams?.filter((stream: any) => { - return stream.Type === 'Subtitle'; - }) ?? []; - const subtitleTracks: Array = []; - - subtitleStreams.forEach((subtitleStream: any) => { - if (subtitleStream.DeliveryUrl === undefined) { - /* The CAF v3 player only supports vtt currently, - * SRT subs can be "transcoded" to vtt by jellyfin. - * The server will do that in accordance with the device profiles and - * give us a DeliveryUrl if that is the case. - * Support for more could be added with a custom implementation - **/ - return; - } + const subtitleStreams = + mediaSource.MediaStreams?.filter((stream: any) => { + return stream.Type === 'Subtitle'; + }) ?? []; + const subtitleTracks: Array = []; + + subtitleStreams.forEach((subtitleStream: any) => { + if (subtitleStream.DeliveryUrl === undefined) { + /* The CAF v3 player only supports vtt currently, + * SRT subs can be "transcoded" to vtt by jellyfin. + * The server will do that in accordance with the device profiles and + * give us a DeliveryUrl if that is the case. + * Support for more could be added with a custom implementation + **/ + return; + } - const textStreamUrl = subtitleStream.IsExternalUrl - ? subtitleStream.DeliveryUrl - : JellyfinApi.createUrl(subtitleStream.DeliveryUrl); + const textStreamUrl = subtitleStream.IsExternalUrl + ? subtitleStream.DeliveryUrl + : JellyfinApi.createUrl(subtitleStream.DeliveryUrl); - const track = new cast.framework.messages.Track( - info.subtitleStreamIndex, - cast.framework.messages.TrackType.TEXT - ); + const track = new cast.framework.messages.Track( + info.subtitleStreamIndex, + cast.framework.messages.TrackType.TEXT + ); - track.trackId = subtitleStream.Index; - track.trackContentId = textStreamUrl; - track.language = subtitleStream.Language; - track.name = subtitleStream.DisplayTitle; - // TODO this should not be hardcoded but we only support VTT currently - track.trackContentType = 'text/vtt'; - track.subtype = cast.framework.messages.TextTrackType.SUBTITLES; - subtitleTracks.push(track); - console.log(`Subtitle url: ${info.subtitleStreamUrl}`); - }); + track.trackId = subtitleStream.Index; + track.trackContentId = textStreamUrl; + track.language = subtitleStream.Language; + track.name = subtitleStream.DisplayTitle; + // TODO this should not be hardcoded but we only support VTT currently + track.trackContentType = 'text/vtt'; + track.subtype = cast.framework.messages.TextTrackType.SUBTITLES; + subtitleTracks.push(track); + console.log(`Subtitle url: ${info.subtitleStreamUrl}`); + }); - info.tracks = subtitleTracks; + info.tracks = subtitleTracks; - return info; + return info; } /** @@ -497,13 +515,13 @@ export function createStreamInfo( * @returns first first matching stream */ export function getStreamByIndex( - streams: Array, - type: string, - index: number + streams: Array, + type: string, + index: number ): any { - return streams.filter((s) => { - return s.Type == type && s.Index == index; - })[0]; + return streams.filter((s) => { + return s.Type == type && s.Index == index; + })[0]; } // defined for use in the 3 next functions @@ -524,29 +542,29 @@ const requiredItemFields = 'MediaSources,Chapters'; * @returns items for the queue */ export function getShuffleItems( - userId: string, - item: BaseItemDto + userId: string, + item: BaseItemDto ): Promise { - const query: ItemQuery = { - UserId: userId, - Fields: requiredItemFields, - Limit: 50, - Filters: 'IsNotFolder', - Recursive: true, - SortBy: 'Random' - }; - - if (item.Type == 'MusicArtist') { - query.MediaTypes = 'Audio'; - query.ArtistIds = item.Id; - } else if (item.Type == 'MusicGenre') { - query.MediaTypes = 'Audio'; - query.Genres = item.Name ?? undefined; - } else { - query.ParentId = item.Id; - } - - return getItemsForPlayback(userId, query); + const query: ItemQuery = { + UserId: userId, + Fields: requiredItemFields, + Limit: 50, + Filters: 'IsNotFolder', + Recursive: true, + SortBy: 'Random' + }; + + if (item.Type == 'MusicArtist') { + query.MediaTypes = 'Audio'; + query.ArtistIds = item.Id; + } else if (item.Type == 'MusicGenre') { + query.MediaTypes = 'Audio'; + query.Genres = item.Name ?? undefined; + } else { + query.ParentId = item.Id; + } + + return getItemsForPlayback(userId, query); } /** @@ -560,40 +578,40 @@ export function getShuffleItems( * @returns items for the queue */ export async function getInstantMixItems( - userId: string, - item: BaseItemDto + userId: string, + item: BaseItemDto ): Promise { - const query: any = { - UserId: userId, - Fields: requiredItemFields, - Limit: 50 - }; - - let url: string | null = null; - - if (item.Type == 'MusicArtist') { - url = 'Artists/InstantMix'; - query.Id = item.Id; - } else if (item.Type == 'MusicGenre') { - url = 'MusicGenres/InstantMix'; - query.Id = item.Id; - } else if (item.Type == 'MusicAlbum') { - url = `Albums/${item.Id}/InstantMix`; - } else if (item.Type == 'Audio') { - url = `Songs/${item.Id}/InstantMix`; - } else if (item.Type == 'Playlist') { - url = `Playlists/${item.Id}/InstantMix`; - } - - if (url) { - return JellyfinApi.authAjax(url, { - query: query, - type: 'GET', - dataType: 'json' - }); - } else { - throw new Error(`InstantMix: Unknown item type: ${item.Type}`); - } + const query: any = { + UserId: userId, + Fields: requiredItemFields, + Limit: 50 + }; + + let url: string | null = null; + + if (item.Type == 'MusicArtist') { + url = 'Artists/InstantMix'; + query.Id = item.Id; + } else if (item.Type == 'MusicGenre') { + url = 'MusicGenres/InstantMix'; + query.Id = item.Id; + } else if (item.Type == 'MusicAlbum') { + url = `Albums/${item.Id}/InstantMix`; + } else if (item.Type == 'Audio') { + url = `Songs/${item.Id}/InstantMix`; + } else if (item.Type == 'Playlist') { + url = `Playlists/${item.Id}/InstantMix`; + } + + if (url) { + return JellyfinApi.authAjax(url, { + query: query, + type: 'GET', + dataType: 'json' + }); + } else { + throw new Error(`InstantMix: Unknown item type: ${item.Type}`); + } } /** @@ -604,34 +622,34 @@ export async function getInstantMixItems( * @returns items to be played back */ export async function getItemsForPlayback( - userId: string, - query: ItemQuery + userId: string, + query: ItemQuery ): Promise { - query.UserId = userId; - query.Limit = query.Limit || 100; - query.Fields = requiredItemFields; - query.ExcludeLocationTypes = 'Virtual'; - - if (query.Ids && query.Ids.split(',').length == 1) { - const item = await JellyfinApi.authAjaxUser( - `Items/${query.Ids.split(',')[0]}`, - { - type: 'GET', - dataType: 'json' - } - ); + query.UserId = userId; + query.Limit = query.Limit || 100; + query.Fields = requiredItemFields; + query.ExcludeLocationTypes = 'Virtual'; + + if (query.Ids && query.Ids.split(',').length == 1) { + const item = await JellyfinApi.authAjaxUser( + `Items/${query.Ids.split(',')[0]}`, + { + type: 'GET', + dataType: 'json' + } + ); - return { - Items: [item], - TotalRecordCount: 1 - }; - } else { - return JellyfinApi.authAjaxUser('Items', { - query: query, - type: 'GET', - dataType: 'json' - }); - } + return { + Items: [item], + TotalRecordCount: 1 + }; + } else { + return JellyfinApi.authAjaxUser('Items', { + query: query, + type: 'GET', + dataType: 'json' + }); + } } /** @@ -643,19 +661,19 @@ export async function getItemsForPlayback( * @returns episode items */ export function getEpisodesForPlayback( - userId: string, - seriesId: string, - query: ItemQuery = {} + userId: string, + seriesId: string, + query: ItemQuery = {} ): Promise { - query.UserId = userId; - query.Fields = requiredItemFields; - query.ExcludeLocationTypes = 'Virtual'; - - return JellyfinApi.authAjax(`Shows/${seriesId}/Episodes`, { - query: query, - type: 'GET', - dataType: 'json' - }); + query.UserId = userId; + query.Fields = requiredItemFields; + query.ExcludeLocationTypes = 'Virtual'; + + return JellyfinApi.authAjax(`Shows/${seriesId}/Episodes`, { + query: query, + type: 'GET', + dataType: 'json' + }); } /** @@ -666,12 +684,12 @@ export function getEpisodesForPlayback( * @returns intro items */ export function getIntros( - firstItem: BaseItemDto + firstItem: BaseItemDto ): Promise { - return JellyfinApi.authAjaxUser(`Items/${firstItem.Id}/Intros`, { - dataType: 'json', - type: 'GET' - }); + return JellyfinApi.authAjaxUser(`Items/${firstItem.Id}/Intros`, { + dataType: 'json', + type: 'GET' + }); } /** @@ -680,10 +698,10 @@ export function getIntros( * @returns user object */ export function getUser(): Promise { - return JellyfinApi.authAjaxUser('', { - dataType: 'json', - type: 'GET' - }); + return JellyfinApi.authAjaxUser('', { + dataType: 'json', + type: 'GET' + }); } /** @@ -698,97 +716,99 @@ export function getUser(): Promise { * @returns Promise for search result containing items to play */ export async function translateRequestedItems( - userId: string, - items: Array, - smart = false + userId: string, + items: Array, + smart = false ): Promise { - const firstItem = items[0]; - - if (firstItem.Type == 'Playlist') { - return await getItemsForPlayback(userId, { - ParentId: firstItem.Id - }); - } else if (firstItem.Type == 'MusicArtist') { - return await getItemsForPlayback(userId, { - ArtistIds: firstItem.Id, - Filters: 'IsNotFolder', - Recursive: true, - SortBy: 'SortName', - MediaTypes: 'Audio' - }); - } else if (firstItem.Type == 'MusicGenre') { - return await getItemsForPlayback(userId, { - Genres: firstItem.Name ?? undefined, - Filters: 'IsNotFolder', - Recursive: true, - SortBy: 'SortName', - MediaTypes: 'Audio' - }); - } else if (firstItem.IsFolder) { - return await getItemsForPlayback(userId, { - ParentId: firstItem.Id, - Filters: 'IsNotFolder', - Recursive: true, - SortBy: 'SortName', - MediaTypes: 'Audio,Video' - }); - } else if (smart && firstItem.Type == 'Episode' && items.length == 1) { - const user = await getUser(); - - if (!user.Configuration?.EnableNextEpisodeAutoPlay) { - return { - Items: items - }; - } + const firstItem = items[0]; + + if (firstItem.Type == 'Playlist') { + return await getItemsForPlayback(userId, { + ParentId: firstItem.Id + }); + } else if (firstItem.Type == 'MusicArtist') { + return await getItemsForPlayback(userId, { + ArtistIds: firstItem.Id, + Filters: 'IsNotFolder', + Recursive: true, + SortBy: 'SortName', + MediaTypes: 'Audio' + }); + } else if (firstItem.Type == 'MusicGenre') { + return await getItemsForPlayback(userId, { + Genres: firstItem.Name ?? undefined, + Filters: 'IsNotFolder', + Recursive: true, + SortBy: 'SortName', + MediaTypes: 'Audio' + }); + } else if (firstItem.IsFolder) { + return await getItemsForPlayback(userId, { + ParentId: firstItem.Id, + Filters: 'IsNotFolder', + Recursive: true, + SortBy: 'SortName', + MediaTypes: 'Audio,Video' + }); + } else if (smart && firstItem.Type == 'Episode' && items.length == 1) { + const user = await getUser(); + + if (!user.Configuration?.EnableNextEpisodeAutoPlay) { + return { + Items: items + }; + } - const result = await getItemsForPlayback(userId, { - Ids: firstItem.Id - }); + const result = await getItemsForPlayback(userId, { + Ids: firstItem.Id + }); - if (!result.Items || result.Items.length < 1) { - return result; - } + if (!result.Items || result.Items.length < 1) { + return result; + } - const episode = result.Items[0]; + const episode = result.Items[0]; - if (!episode.SeriesId) { - return result; - } + if (!episode.SeriesId) { + return result; + } - const episodesResult = await getEpisodesForPlayback( - userId, - episode.SeriesId, - { - IsVirtualUnaired: false, - IsMissing: false, - UserId: userId - } - ); + const episodesResult = await getEpisodesForPlayback( + userId, + episode.SeriesId, + { + IsVirtualUnaired: false, + IsMissing: false, + UserId: userId + } + ); - let foundItem = false; + let foundItem = false; - episodesResult.Items = episodesResult.Items?.filter((e: BaseItemDto) => { - if (foundItem) { - return true; - } + episodesResult.Items = episodesResult.Items?.filter( + (e: BaseItemDto) => { + if (foundItem) { + return true; + } - if (e.Id == episode.Id) { - foundItem = true; + if (e.Id == episode.Id) { + foundItem = true; - return true; - } + return true; + } - return false; - }); + return false; + } + ); - episodesResult.TotalRecordCount = episodesResult.Items?.length || 0; + episodesResult.TotalRecordCount = episodesResult.Items?.length || 0; - return episodesResult; - } + return episodesResult; + } - return { - Items: items - }; + return { + Items: items + }; } /** @@ -801,11 +821,11 @@ export async function translateRequestedItems( * @returns reference to target object */ export function extend(target: any, source: any): any { - for (const i in source) { - target[i] = source[i]; - } + for (const i in source) { + target[i] = source[i]; + } - return target; + return target; } /** @@ -817,7 +837,7 @@ export function extend(target: any, source: any): any { * @returns date object */ export function parseISO8601Date(date: string): Date { - return new Date(date); + return new Date(date); } /** @@ -826,18 +846,18 @@ export function parseISO8601Date(date: string): Date { * @param message - to send */ export function broadcastToMessageBus(message: BusMessage): void { - window.castReceiverContext.sendCustomMessage( - 'urn:x-cast:com.connectsdk', - window.senderId, - message - ); + window.castReceiverContext.sendCustomMessage( + 'urn:x-cast:com.connectsdk', + window.senderId, + message + ); } /** * Inform the cast sender that we couldn't connect */ export function broadcastConnectionErrorMessage(): void { - broadcastToMessageBus({ type: 'connectionerror', message: '' }); + broadcastToMessageBus({ type: 'connectionerror', message: '' }); } /** @@ -847,5 +867,5 @@ export function broadcastConnectionErrorMessage(): void { * @returns string with non-whitespace non-word characters removed */ export function cleanName(name: string): string { - return name.replace(/[^\w\s]/gi, ''); + return name.replace(/[^\w\s]/gi, ''); } diff --git a/src/index.html b/src/index.html index a02f4e0b..339c68a2 100644 --- a/src/index.html +++ b/src/index.html @@ -1,59 +1,66 @@ - - - Jellyfin - - - - - -
-
-
- - Ready to cast -
-
-
- -
-
-
-
- -
- + + + Jellyfin + + + + + +
+
+
+ + Ready to cast +
+
-
+ +
+
+
+
+ +
+ +
+
-
-

- -

-
-
-
-
-
+
+

+ +

+
+
+
+
+
+
+

+

+
-

-

-
-
- - + + diff --git a/src/types/global.d.ts b/src/types/global.d.ts index 33090d55..2018cfb8 100644 --- a/src/types/global.d.ts +++ b/src/types/global.d.ts @@ -1,23 +1,23 @@ import { - CastReceiverContext, - PlayerManager + CastReceiverContext, + PlayerManager } from 'chromecast-caf-receiver/cast.framework'; import { SystemVolumeData } from 'chromecast-caf-receiver/cast.framework.system'; import { RepeatMode } from '../api/generated/models/repeat-mode'; import { BaseItemDto } from '../api/generated/models/base-item-dto'; export interface DeviceInfo { - deviceId: string | number; - deviceName: string; - versionNumber: string; + deviceId: string | number; + deviceName: string; + versionNumber: string; } export interface GlobalScope { - [key: string]: any; + [key: string]: any; } export interface Dictionary { - [Key: string]: T; + [Key: string]: T; } // Jellyfin Server @@ -26,93 +26,93 @@ export interface Dictionary { * Valid for item endpoints */ // TODO: API has an endpoint for this. Replace on https://github.com/jellyfin/jellyfin-chromecast/pull/109 export interface ItemQuery { - UserId?: string; - Limit?: number; - Fields?: string; - Filters?: string; - Recursive?: boolean; - ExcludeLocationTypes?: string; - Ids?: string; - SortBy?: string; - IsVirtualUnaired?: boolean; - IsMissing?: boolean; - ParentId?: string; - MediaTypes?: string; - Genres?: string; - ArtistIds?: string; + UserId?: string; + Limit?: number; + Fields?: string; + Filters?: string; + Recursive?: boolean; + ExcludeLocationTypes?: string; + Ids?: string; + SortBy?: string; + IsVirtualUnaired?: boolean; + IsMissing?: boolean; + ParentId?: string; + MediaTypes?: string; + Genres?: string; + ArtistIds?: string; } // Messagebus message export interface BusMessage { - type: string; - message?: string; - data?: string; + type: string; + message?: string; + data?: string; } // // For the old queue stuff // export interface ItemIndex { - item: BaseItemDto; - index: number; + item: BaseItemDto; + index: number; } // From commandHandler export interface PlayRequest { - items: BaseItemDto[]; - startPositionTicks: number | undefined; - mediaSourceId: string | undefined; - audioStreamIndex: number | undefined; - subtitleStreamIndex: number | undefined; - liveStreamId: string | undefined; + items: BaseItemDto[]; + startPositionTicks: number | undefined; + mediaSourceId: string | undefined; + audioStreamIndex: number | undefined; + subtitleStreamIndex: number | undefined; + liveStreamId: string | undefined; } export interface DisplayRequest { - ItemId: string; + ItemId: string; } export interface SetIndexRequest { - index: number; + index: number; } export interface SetRepeatModeRequest { - RepeatMode: RepeatMode; + RepeatMode: RepeatMode; } export interface SeekRequest { - position: number; // seconds + position: number; // seconds } export interface DataMessage { - options: - | PlayRequest - | DisplayRequest - | SetIndexRequest - | SetRepeatModeRequest - | SeekRequest; - command: string; + options: + | PlayRequest + | DisplayRequest + | SetIndexRequest + | SetRepeatModeRequest + | SeekRequest; + command: string; } interface SupportedCommands { - [command: string]: (data: DataMessage) => void; + [command: string]: (data: DataMessage) => void; } // /From commandHandler declare global { - export const PRODUCTION: boolean; - export const RECEIVERVERSION: string; - export const $scope: GlobalScope; - export interface Window { - deviceInfo: DeviceInfo; - mediaElement: HTMLElement | null; - mediaManager: PlayerManager; - castReceiverContext: CastReceiverContext; - playlist: Array; - currentPlaylistIndex: number; - repeatMode: RepeatMode; - reportEventType: 'repeatmodechange'; - subtitleAppearance: any; - MaxBitrate: number | undefined; - senderId: string | undefined; - volume: SystemVolumeData; - } + export const PRODUCTION: boolean; + export const RECEIVERVERSION: string; + export const $scope: GlobalScope; + export interface Window { + deviceInfo: DeviceInfo; + mediaElement: HTMLElement | null; + mediaManager: PlayerManager; + castReceiverContext: CastReceiverContext; + playlist: Array; + currentPlaylistIndex: number; + repeatMode: RepeatMode; + reportEventType: 'repeatmodechange'; + subtitleAppearance: any; + MaxBitrate: number | undefined; + senderId: string | undefined; + volume: SystemVolumeData; + } } diff --git a/stylelint.config.js b/stylelint.config.js index 802e6ddb..c14e2cce 100644 --- a/stylelint.config.js +++ b/stylelint.config.js @@ -1,7 +1,7 @@ module.exports = { - syntax: 'css', - extends: ['stylelint-config-standard', 'stylelint-config-prettier'], - rules: { - 'at-rule-no-unknown': null - } + syntax: 'css', + extends: ['stylelint-config-standard', 'stylelint-config-prettier'], + rules: { + 'at-rule-no-unknown': null + } }; diff --git a/tsconfig-webpack.json b/tsconfig-webpack.json index acf696cf..f8add636 100644 --- a/tsconfig-webpack.json +++ b/tsconfig-webpack.json @@ -1,15 +1,15 @@ { - "compilerOptions": { - "module": "commonjs", - "target": "es5", - "esModuleInterop": true, - "resolveJsonModule": true, - "allowSyntheticDefaultImports": true, - "types": [ - "@types/node", - "@types/webpack", - "@types/webpack-dev-server", - "@types/webpack-merge" - ] - } + "compilerOptions": { + "module": "commonjs", + "target": "es5", + "esModuleInterop": true, + "resolveJsonModule": true, + "allowSyntheticDefaultImports": true, + "types": [ + "@types/node", + "@types/webpack", + "@types/webpack-dev-server", + "@types/webpack-merge" + ] + } } diff --git a/tsconfig.json b/tsconfig.json index ff7ba7f4..a8aa9c3c 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,19 +1,19 @@ { - "compilerOptions": { - "target": "ES2018", - "module": "ESNext", - "moduleResolution": "Node", - "lib": ["dom", "ESNext"], - "resolveJsonModule": true, - "allowSyntheticDefaultImports": true, - "allowJs": true, - "sourceMap": true, - "outDir": "./dist/", - "strict": true, - "baseUrl": "./src", - "paths": { - "~/*": ["./*"] - }, - "types": ["@types/node", "@types/chromecast-caf-receiver"] - } + "compilerOptions": { + "target": "ES2018", + "module": "ESNext", + "moduleResolution": "Node", + "lib": ["dom", "ESNext"], + "resolveJsonModule": true, + "allowSyntheticDefaultImports": true, + "allowJs": true, + "sourceMap": true, + "outDir": "./dist/", + "strict": true, + "baseUrl": "./src", + "paths": { + "~/*": ["./*"] + }, + "types": ["@types/node", "@types/chromecast-caf-receiver"] + } } diff --git a/webpack.config.ts b/webpack.config.ts index a0057aec..10144510 100644 --- a/webpack.config.ts +++ b/webpack.config.ts @@ -10,98 +10,98 @@ import ImageMinimizerPlugin from 'image-minimizer-webpack-plugin'; import { version } from './package.json'; const common: webpack.Configuration = { - context: path.resolve(__dirname, 'src'), - entry: './app.ts', - output: { - filename: '[name].[fullhash].js', - path: path.resolve(__dirname, 'dist'), - publicPath: './' - }, - resolve: { - extensions: ['.ts', '.js'] - }, - plugins: [ - // @ts-expect-error - Typings mismatch between versions - new CleanWebpackPlugin(), - new HtmlWebpackPlugin({ - filename: 'index.html', - template: 'index.html', - hash: false, - favicon: 'favicon.ico' - }), - new ImageMinimizerPlugin({ - minimizerOptions: { - plugins: [ - [ - 'svgo', - { - plugins: [ - { - removeComments: false - } - ] + context: path.resolve(__dirname, 'src'), + entry: './app.ts', + output: { + filename: '[name].[fullhash].js', + path: path.resolve(__dirname, 'dist'), + publicPath: './' + }, + resolve: { + extensions: ['.ts', '.js'] + }, + plugins: [ + // @ts-expect-error - Typings mismatch between versions + new CleanWebpackPlugin(), + new HtmlWebpackPlugin({ + filename: 'index.html', + template: 'index.html', + hash: false, + favicon: 'favicon.ico' + }), + new ImageMinimizerPlugin({ + minimizerOptions: { + plugins: [ + [ + 'svgo', + { + plugins: [ + { + removeComments: false + } + ] + } + ] + ] } - ] + }) + ], + module: { + rules: [ + { test: /\.html$/, loader: 'html-loader' }, + { + test: /\.(png|svg|jpg|gif)$/, + use: 'file-loader' + }, + { + test: /\.(ttf|eot|woff(2)?)(\?[a-z0-9=&.]+)?$/, + loader: 'file-loader' + }, + { test: /\.css$/i, use: ['style-loader', 'css-loader'] }, + { test: /\.tsx?$/, loader: 'ts-loader' }, + { test: /\.js$/, loader: 'source-map-loader' } ] - } - }) - ], - module: { - rules: [ - { test: /\.html$/, loader: 'html-loader' }, - { - test: /\.(png|svg|jpg|gif)$/, - use: 'file-loader' - }, - { - test: /\.(ttf|eot|woff(2)?)(\?[a-z0-9=&.]+)?$/, - loader: 'file-loader' - }, - { test: /\.css$/i, use: ['style-loader', 'css-loader'] }, - { test: /\.tsx?$/, loader: 'ts-loader' }, - { test: /\.js$/, loader: 'source-map-loader' } - ] - } + } }; const development: webpack.Configuration = { - mode: 'development', - devtool: 'inline-source-map', - // @ts-expect-error - Typings mismatch between versions - devServer: { - contentBase: path.join(__dirname, 'dist'), - compress: true, - port: process.env.RECEIVER_PORT - ? Number.parseInt(process.env.RECEIVER_PORT, 10) - : 9000, - publicPath: '/' - }, - plugins: [ - new DefinePlugin({ - PRODUCTION: JSON.stringify(false), - RECEIVERVERSION: JSON.stringify(version) - }) - ] + mode: 'development', + devtool: 'inline-source-map', + // @ts-expect-error - Typings mismatch between versions + devServer: { + contentBase: path.join(__dirname, 'dist'), + compress: true, + port: process.env.RECEIVER_PORT + ? Number.parseInt(process.env.RECEIVER_PORT, 10) + : 9000, + publicPath: '/' + }, + plugins: [ + new DefinePlugin({ + PRODUCTION: JSON.stringify(false), + RECEIVERVERSION: JSON.stringify(version) + }) + ] }; const production: webpack.Configuration = { - mode: 'production', - plugins: [ - new DefinePlugin({ - PRODUCTION: JSON.stringify(true), - RECEIVERVERSION: JSON.stringify(version) - }) - ] + mode: 'production', + plugins: [ + new DefinePlugin({ + PRODUCTION: JSON.stringify(true), + RECEIVERVERSION: JSON.stringify(version) + }) + ] }; module.exports = (argv: { [key: string]: string }): webpack.Configuration => { - let config; + let config; - if (argv.mode === 'production') { - config = merge(common, production); - } else { - config = merge(common, development); - } + if (argv.mode === 'production') { + config = merge(common, production); + } else { + config = merge(common, development); + } - return config; + return config; }; From ce984f9cd2a3963ffb352fe8f50459d51fa7ee47 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fernando=20Fern=C3=A1ndez?= Date: Thu, 13 May 2021 11:57:28 +0200 Subject: [PATCH 08/10] refactor: address review comments --- .eslintrc.js | 4 ++-- src/components/documentManager.ts | 10 ++++++---- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/.eslintrc.js b/.eslintrc.js index 4d2cb800..14199053 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -1,9 +1,9 @@ module.exports = { root: true, env: { - node: true, browser: true, - es6: true + es6: true, + node: true }, extends: [ 'eslint:recommended', diff --git a/src/components/documentManager.ts b/src/components/documentManager.ts index 12ce29de..9918b27f 100644 --- a/src/components/documentManager.ts +++ b/src/components/documentManager.ts @@ -144,12 +144,14 @@ export abstract class DocumentManager { // stop cycling backdrops this.clearBackdropInterval(); - const urls = [ - await this.getWaitingBackdropUrl(item), - await this.getPrimaryImageUrl(item), - await this.getLogoUrl(item) + const promises = [ + this.getWaitingBackdropUrl(item), + this.getPrimaryImageUrl(item), + this.getLogoUrl(item) ]; + const urls = await Promise.all(promises); + requestAnimationFrame(() => { this.setWaitingBackdrop(urls[0], item); this.setDetailImage(urls[1]); From a050080ca966b3bdc3116dc20198da3e31038eae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fernando=20Fern=C3=A1ndez?= Date: Thu, 13 May 2021 12:12:07 +0200 Subject: [PATCH 09/10] chore: enforce keys in alphabetical order --- .eslintrc.js | 66 ++++++++++++++++++++++++++++------------------------ 1 file changed, 36 insertions(+), 30 deletions(-) diff --git a/.eslintrc.js b/.eslintrc.js index 14199053..a8d4ca86 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -1,5 +1,4 @@ module.exports = { - root: true, env: { browser: true, es6: true, @@ -18,39 +17,13 @@ module.exports = { 'plugin:import/typescript' ], plugins: ['prettier', 'promise', 'import', 'jsdoc'], + root: true, rules: { - 'import/newline-after-import': 'error', - 'import/order': 'error', - 'jsdoc/require-hyphen-before-param-description': 'error', - 'jsdoc/require-description': 'warn', - 'jsdoc/require-param-description': 'warn', - 'jsdoc/require-jsdoc': 'error', - //TypeScript and IntelliSense already provides us information about the function typings while hovering and - // eslint-jsdoc doesn't detect a mismatch between what's declared in the function and what's declared in - // JSDOC. - 'jsdoc/require-param-type': 'off', - 'jsdoc/require-returns-type': 'off', - 'jsdoc/check-indentation': 'error', - 'jsdoc/check-syntax': 'error', - 'jsdoc/check-param-names': 'error', - 'jsdoc/check-property-names': 'error', - 'jsdoc/check-tag-names': 'error', - 'jsdoc/no-types': 'error', - 'jsdoc/valid-types': 'off', - 'promise/no-nesting': 'error', - 'promise/no-return-in-finally': 'error', - 'promise/prefer-await-to-callbacks': 'error', - 'promise/prefer-await-to-then': 'error', - '@typescript-eslint/explicit-function-return-type': 'error', - '@typescript-eslint/prefer-ts-expect-error': 'error', - '@typescript-eslint/no-unused-vars': 'error', - 'prefer-arrow-callback': 'error', - 'prefer-template': 'error', curly: 'error', 'padding-line-between-statements': [ 'error', // Always require blank lines after directives (like 'use-strict'), except between directives - { blankLine: 'always', prev: 'directive', next: '*' }, + { blankLine: 'always', next: '*', prev: 'directive' }, { blankLine: 'any', prev: 'directive', next: 'directive' }, // Always require blank lines after import, except between imports { blankLine: 'always', prev: 'import', next: '*' }, @@ -84,7 +57,40 @@ module.exports = { }, // Always require blank lines before return statements { blankLine: 'always', prev: '*', next: 'return' } - ] + ], + 'import/newline-after-import': 'error', + 'import/order': 'error', + 'jsdoc/require-hyphen-before-param-description': 'error', + 'jsdoc/require-description': 'warn', + 'jsdoc/require-param-description': 'warn', + 'jsdoc/require-jsdoc': 'error', + //TypeScript and IntelliSense already provides us information about the function typings while hovering and + // eslint-jsdoc doesn't detect a mismatch between what's declared in the function and what's declared in + // JSDOC. + 'jsdoc/require-param-type': 'off', + 'jsdoc/require-returns-type': 'off', + 'jsdoc/check-indentation': 'error', + 'jsdoc/check-syntax': 'error', + 'jsdoc/check-param-names': 'error', + 'jsdoc/check-property-names': 'error', + 'jsdoc/check-tag-names': 'error', + 'jsdoc/no-types': 'error', + 'jsdoc/valid-types': 'off', + 'promise/no-nesting': 'error', + 'promise/no-return-in-finally': 'error', + 'promise/prefer-await-to-callbacks': 'error', + 'promise/prefer-await-to-then': 'error', + '@typescript-eslint/explicit-function-return-type': 'error', + '@typescript-eslint/prefer-ts-expect-error': 'error', + '@typescript-eslint/no-unused-vars': 'error', + 'prefer-arrow-callback': 'error', + 'prefer-template': 'error', + 'sort-keys': [ + 'error', + 'asc', + { caseSensitive: true, natural: false, minKeys: 2 } + ], + 'sort-vars': 'error' }, overrides: [ { From 27ff1feb9320717a7b87f3a74b578180d4159c4d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fernando=20Fern=C3=A1ndez?= Date: Thu, 13 May 2021 12:56:50 +0200 Subject: [PATCH 10/10] chore: manual lint fix --- .editorconfig | 6 +- .eslintrc.js | 106 ++++++++++++------------- package.json | 2 +- src/components/commandHandler.ts | 32 ++++---- src/components/deviceprofileBuilder.ts | 76 +++++++++--------- src/components/documentManager.ts | 12 +-- src/components/fetchhelper.ts | 4 +- src/components/jellyfinActions.ts | 62 +++++++-------- src/components/jellyfinApi.ts | 8 +- src/components/maincontroller.ts | 36 ++++----- src/components/playbackManager.ts | 4 +- src/helpers.ts | 82 +++++++++---------- stylelint.config.js | 4 +- webpack.config.ts | 46 +++++------ 14 files changed, 240 insertions(+), 240 deletions(-) diff --git a/.editorconfig b/.editorconfig index 3c44241c..9c61627f 100644 --- a/.editorconfig +++ b/.editorconfig @@ -1,9 +1,9 @@ root = true [*] +charset = utf-8 +end_of_line = lf indent_style = space indent_size = 4 -end_of_line = lf -charset = utf-8 -trim_trailing_whitespace = true insert_final_newline = true +trim_trailing_whitespace = true diff --git a/.eslintrc.js b/.eslintrc.js index a8d4ca86..c70176a6 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -16,97 +16,97 @@ module.exports = { 'plugin:import/warnings', 'plugin:import/typescript' ], + overrides: [ + { + env: { + browser: true, + es6: true, + node: false + }, + files: ['.js', '.ts'], + globals: { + $scope: 'writable', + cast: 'readonly', + PRODUCTION: 'readonly' + } + } + ], plugins: ['prettier', 'promise', 'import', 'jsdoc'], root: true, rules: { + '@typescript-eslint/explicit-function-return-type': 'error', + '@typescript-eslint/no-unused-vars': 'error', + '@typescript-eslint/prefer-ts-expect-error': 'error', curly: 'error', + 'import/newline-after-import': 'error', + 'import/order': 'error', + 'jsdoc/check-indentation': 'error', + 'jsdoc/check-param-names': 'error', + 'jsdoc/check-property-names': 'error', + 'jsdoc/check-syntax': 'error', + 'jsdoc/check-tag-names': 'error', + 'jsdoc/no-types': 'error', + 'jsdoc/require-description': 'warn', + 'jsdoc/require-hyphen-before-param-description': 'error', + 'jsdoc/require-jsdoc': 'error', + 'jsdoc/require-param-description': 'warn', + //TypeScript and IntelliSense already provides us information about the function typings while hovering and + // eslint-jsdoc doesn't detect a mismatch between what's declared in the function and what's declared in + // JSDOC. + 'jsdoc/require-param-type': 'off', + 'jsdoc/require-returns-type': 'off', + 'jsdoc/valid-types': 'off', 'padding-line-between-statements': [ 'error', // Always require blank lines after directives (like 'use-strict'), except between directives { blankLine: 'always', next: '*', prev: 'directive' }, - { blankLine: 'any', prev: 'directive', next: 'directive' }, + { blankLine: 'any', next: 'directive', prev: 'directive' }, // Always require blank lines after import, except between imports - { blankLine: 'always', prev: 'import', next: '*' }, - { blankLine: 'any', prev: 'import', next: 'import' }, + { blankLine: 'always', next: '*', prev: 'import' }, + { blankLine: 'any', next: 'import', prev: 'import' }, // Always require blank lines before and after every sequence of variable declarations and export { blankLine: 'always', - prev: '*', - next: ['const', 'let', 'var', 'export'] + next: ['const', 'let', 'var', 'export'], + prev: '*' }, { blankLine: 'always', - prev: ['const', 'let', 'var', 'export'], - next: '*' + next: '*', + prev: ['const', 'let', 'var', 'export'] }, { blankLine: 'any', - prev: ['const', 'let', 'var', 'export'], - next: ['const', 'let', 'var', 'export'] + next: ['const', 'let', 'var', 'export'], + prev: ['const', 'let', 'var', 'export'] }, // Always require blank lines before and after class declaration, if, do/while, switch, try { blankLine: 'always', - prev: '*', - next: ['if', 'class', 'for', 'do', 'while', 'switch', 'try'] + next: ['if', 'class', 'for', 'do', 'while', 'switch', 'try'], + prev: '*' }, { blankLine: 'always', - prev: ['if', 'class', 'for', 'do', 'while', 'switch', 'try'], - next: '*' + next: '*', + prev: ['if', 'class', 'for', 'do', 'while', 'switch', 'try'] }, // Always require blank lines before return statements - { blankLine: 'always', prev: '*', next: 'return' } + { blankLine: 'always', next: 'return', prev: '*' } ], - 'import/newline-after-import': 'error', - 'import/order': 'error', - 'jsdoc/require-hyphen-before-param-description': 'error', - 'jsdoc/require-description': 'warn', - 'jsdoc/require-param-description': 'warn', - 'jsdoc/require-jsdoc': 'error', - //TypeScript and IntelliSense already provides us information about the function typings while hovering and - // eslint-jsdoc doesn't detect a mismatch between what's declared in the function and what's declared in - // JSDOC. - 'jsdoc/require-param-type': 'off', - 'jsdoc/require-returns-type': 'off', - 'jsdoc/check-indentation': 'error', - 'jsdoc/check-syntax': 'error', - 'jsdoc/check-param-names': 'error', - 'jsdoc/check-property-names': 'error', - 'jsdoc/check-tag-names': 'error', - 'jsdoc/no-types': 'error', - 'jsdoc/valid-types': 'off', + 'prefer-arrow-callback': 'error', + 'prefer-template': 'error', 'promise/no-nesting': 'error', 'promise/no-return-in-finally': 'error', 'promise/prefer-await-to-callbacks': 'error', 'promise/prefer-await-to-then': 'error', - '@typescript-eslint/explicit-function-return-type': 'error', - '@typescript-eslint/prefer-ts-expect-error': 'error', - '@typescript-eslint/no-unused-vars': 'error', - 'prefer-arrow-callback': 'error', - 'prefer-template': 'error', 'sort-keys': [ 'error', 'asc', - { caseSensitive: true, natural: false, minKeys: 2 } + { caseSensitive: false, minKeys: 2, natural: true } ], 'sort-vars': 'error' }, - overrides: [ - { - files: ['.js', '.ts'], - env: { - node: false, - browser: true, - es6: true - }, - globals: { - cast: 'readonly', - PRODUCTION: 'readonly', - $scope: 'writable' - } - } - ], settings: { 'import/parsers': { '@typescript-eslint/parser': ['.ts', '.tsx'] diff --git a/package.json b/package.json index 6196e1a4..8698fec7 100644 --- a/package.json +++ b/package.json @@ -76,7 +76,7 @@ "lint:code": "eslint --ext .ts,.js,.json .", "lint:css": "stylelint **/*.css", "prepare": "npm run build:production", - "prettier": "prettier --write .", + "prettier": "prettier --check .", "start": "TS_NODE_PROJECT=\"tsconfig-webpack.json\" webpack serve --config webpack.config.ts", "test": "jest --passWithNoTests", "watch": "TS_NODE_PROJECT=\"tsconfig-webpack.json\" webpack --config webpack.config.ts --watch" diff --git a/src/components/commandHandler.ts b/src/components/commandHandler.ts index 3c98f22f..0e69dd1e 100644 --- a/src/components/commandHandler.ts +++ b/src/components/commandHandler.ts @@ -27,29 +27,29 @@ export abstract class CommandHandler { private static playerManager: framework.PlayerManager; private static playbackManager: playbackManager; private static supportedCommands: SupportedCommands = { - PlayNext: CommandHandler.playNextHandler, - PlayNow: CommandHandler.playNowHandler, - PlayLast: CommandHandler.playLastHandler, - Shuffle: CommandHandler.shuffleHandler, - InstantMix: CommandHandler.instantMixHandler, DisplayContent: CommandHandler.displayContentHandler, + Identify: CommandHandler.IdentifyHandler, + InstantMix: CommandHandler.instantMixHandler, + Mute: CommandHandler.MuteHandler, NextTrack: CommandHandler.nextTrackHandler, + Pause: CommandHandler.PauseHandler, + PlayLast: CommandHandler.playLastHandler, + PlayNext: CommandHandler.playNextHandler, + PlayNow: CommandHandler.playNowHandler, + PlayPause: CommandHandler.PlayPauseHandler, PreviousTrack: CommandHandler.previousTrackHandler, + Seek: CommandHandler.SeekHandler, SetAudioStreamIndex: CommandHandler.setAudioStreamIndexHandler, + SetRepeatMode: CommandHandler.SetRepeatModeHandler, SetSubtitleStreamIndex: CommandHandler.setSubtitleStreamIndexHandler, - VolumeUp: CommandHandler.VolumeUpHandler, - VolumeDown: CommandHandler.VolumeDownHandler, - ToggleMute: CommandHandler.ToggleMuteHandler, - Identify: CommandHandler.IdentifyHandler, SetVolume: CommandHandler.SetVolumeHandler, - Seek: CommandHandler.SeekHandler, - Mute: CommandHandler.MuteHandler, - Unmute: CommandHandler.MuteHandler, + Shuffle: CommandHandler.shuffleHandler, Stop: CommandHandler.StopHandler, - PlayPause: CommandHandler.PlayPauseHandler, - Pause: CommandHandler.PauseHandler, - SetRepeatMode: CommandHandler.SetRepeatModeHandler, - Unpause: CommandHandler.UnpauseHandler + ToggleMute: CommandHandler.ToggleMuteHandler, + Unmute: CommandHandler.MuteHandler, + Unpause: CommandHandler.UnpauseHandler, + VolumeDown: CommandHandler.VolumeDownHandler, + VolumeUp: CommandHandler.VolumeUpHandler }; static configure( diff --git a/src/components/deviceprofileBuilder.ts b/src/components/deviceprofileBuilder.ts index f14414f5..7ba74082 100644 --- a/src/components/deviceprofileBuilder.ts +++ b/src/components/deviceprofileBuilder.ts @@ -55,9 +55,9 @@ function createProfileCondition( ): ProfileCondition { return { Condition, + IsRequired, Property, - Value, - IsRequired + Value }; } @@ -75,9 +75,9 @@ function getResponseProfiles(): Array { // This seems related to DLNA, it might not be needed? return [ { - Type: DlnaProfileType.Video, Container: 'm4v', - MimeType: 'video/mp4' + MimeType: 'video/mp4', + Type: DlnaProfileType.Video } ]; } @@ -96,18 +96,18 @@ function getDirectPlayProfiles(): Array { for (const codec of vpxVideoCodecs) { DirectPlayProfiles.push({ + AudioCodec: webmAudioCodecs.join(','), Container: 'webm', Type: DlnaProfileType.Video, - AudioCodec: webmAudioCodecs.join(','), VideoCodec: codec }); } DirectPlayProfiles.push({ + AudioCodec: mp4AudioCodecs.join(','), Container: 'mp4,m4v', Type: DlnaProfileType.Video, - VideoCodec: mp4VideoCodecs.join(','), - AudioCodec: mp4AudioCodecs.join(',') + VideoCodec: mp4VideoCodecs.join(',') }); } @@ -116,9 +116,9 @@ function getDirectPlayProfiles(): Array { for (const audioFormat of supportedAudio) { if (audioFormat === 'mp3') { DirectPlayProfiles.push({ + AudioCodec: audioFormat, Container: audioFormat, - Type: DlnaProfileType.Audio, - AudioCodec: audioFormat + Type: DlnaProfileType.Audio }); } else if (audioFormat === 'webma') { DirectPlayProfiles.push({ @@ -135,8 +135,8 @@ function getDirectPlayProfiles(): Array { // aac also appears in the m4a and m4b container if (audioFormat === 'aac') { DirectPlayProfiles.push({ - Container: 'm4a,m4b', AudioCodec: audioFormat, + Container: 'm4a,m4b', Type: DlnaProfileType.Audio }); } @@ -152,7 +152,6 @@ function getCodecProfiles(): Array { const CodecProfiles: Array = []; const audioConditions: CodecProfile = { - Type: CodecType.Audio, Codec: 'flac', Conditions: [ createProfileCondition( @@ -165,7 +164,8 @@ function getCodecProfiles(): Array { ProfileConditionType.LessThanEqual, '24' ) - ] + ], + Type: CodecType.Audio }; CodecProfiles.push(audioConditions); @@ -176,7 +176,6 @@ function getCodecProfiles(): Array { } const aacConditions: CodecProfile = { - Type: CodecType.VideoAudio, Codec: 'aac', Conditions: [ // Not sure what secondary audio means in this context. Multiple audio tracks? @@ -190,7 +189,8 @@ function getCodecProfiles(): Array { ProfileConditionType.LessThanEqual, '2' ) - ] + ], + Type: CodecType.VideoAudio }; CodecProfiles.push(aacConditions); @@ -200,7 +200,6 @@ function getCodecProfiles(): Array { const h26xProfile: string = getH26xProfileSupport(currentDeviceId); const h26xConditions: CodecProfile = { - Type: CodecType.Video, Codec: 'h264', Conditions: [ createProfileCondition( @@ -224,13 +223,13 @@ function getCodecProfiles(): Array { maxWidth.toString(), true ) - ] + ], + Type: CodecType.Video }; CodecProfiles.push(h26xConditions); const videoConditions: CodecProfile = { - Type: CodecType.Video, Conditions: [ createProfileCondition( ProfileConditionValue.Width, @@ -238,20 +237,21 @@ function getCodecProfiles(): Array { maxWidth.toString(), true ) - ] + ], + Type: CodecType.Video }; CodecProfiles.push(videoConditions); const videoAudioConditions: CodecProfile = { - Type: CodecType.VideoAudio, Conditions: [ createProfileCondition( ProfileConditionValue.IsSecondaryAudio, ProfileConditionType.Equals, 'false' ) - ] + ], + Type: CodecType.VideoAudio }; CodecProfiles.push(videoAudioConditions); @@ -270,14 +270,14 @@ function getTranscodingProfiles(): Array { if (profileOptions.enableHls !== false) { TranscodingProfiles.push({ - Container: 'ts', - Type: DlnaProfileType.Audio, AudioCodec: hlsAudioCodecs.join(','), + BreakOnNonKeyFrames: false, + Container: 'ts', Context: EncodingContext.Streaming, - Protocol: 'hls', MaxAudioChannels: audioChannels.toString(), MinSegments: 1, - BreakOnNonKeyFrames: false + Protocol: 'hls', + Type: DlnaProfileType.Audio }); } @@ -286,12 +286,12 @@ function getTranscodingProfiles(): Array { // audio only profiles here for (const audioFormat of supportedAudio) { TranscodingProfiles.push({ - Container: audioFormat, - Type: DlnaProfileType.Audio, AudioCodec: audioFormat, + Container: audioFormat, Context: EncodingContext.Streaming, + MaxAudioChannels: audioChannels.toString(), Protocol: 'http', - MaxAudioChannels: audioChannels.toString() + Type: DlnaProfileType.Audio }); } @@ -308,29 +308,29 @@ function getTranscodingProfiles(): Array { profileOptions.enableHls !== false ) { TranscodingProfiles.push({ - Container: 'ts', - Type: DlnaProfileType.Video, AudioCodec: hlsAudioCodecs.join(','), - VideoCodec: hlsVideoCodecs.join(','), + BreakOnNonKeyFrames: false, + Container: 'ts', Context: EncodingContext.Streaming, - Protocol: 'hls', MaxAudioChannels: audioChannels.toString(), MinSegments: 1, - BreakOnNonKeyFrames: false + Protocol: 'hls', + Type: DlnaProfileType.Video, + VideoCodec: hlsVideoCodecs.join(',') }); } if (hasVP8Support() || hasVP9Support()) { TranscodingProfiles.push({ - Container: 'webm', - Type: DlnaProfileType.Video, AudioCodec: 'vorbis', - VideoCodec: 'vpx', + Container: 'webm', Context: EncodingContext.Streaming, - Protocol: 'http', // If audio transcoding is needed, limit channels to number of physical audio channels // Trying to transcode to 5 channels when there are only 2 speakers generally does not sound good - MaxAudioChannels: audioChannels.toString() + MaxAudioChannels: audioChannels.toString(), + Protocol: 'http', + Type: DlnaProfileType.Video, + VideoCodec: 'vpx' }); } @@ -370,8 +370,8 @@ export function getDeviceProfile(options: ProfileOptions): DeviceProfile { // MaxStaticBitrate seems to be for offline sync only const profile: DeviceProfile = { - MaxStreamingBitrate: options.bitrateSetting, MaxStaticBitrate: options.bitrateSetting, + MaxStreamingBitrate: options.bitrateSetting, MusicStreamingTranscodingBitrate: Math.min( options.bitrateSetting, 192000 diff --git a/src/components/documentManager.ts b/src/components/documentManager.ts index 9918b27f..a4f975bb 100644 --- a/src/components/documentManager.ts +++ b/src/components/documentManager.ts @@ -372,17 +372,17 @@ export abstract class DocumentManager { private static async setRandomUserBackdrop(): Promise { const result = await JellyfinApi.authAjaxUser('Items', { dataType: 'json', - type: 'GET', query: { - SortBy: 'Random', - IncludeItemTypes: 'Movie,Series', ImageTypes: 'Backdrop', - Recursive: true, + IncludeItemTypes: 'Movie,Series', Limit: 1, + MaxOfficialRating: 'PG-13', + Recursive: true, + SortBy: 'Random' // Although we're limiting to what the user has access to, // not everyone will want to see adult backdrops rotating on their TV. - MaxOfficialRating: 'PG-13' - } + }, + type: 'GET' }); let src: string | null = null; diff --git a/src/components/fetchhelper.ts b/src/components/fetchhelper.ts index e1bf18de..0fe22826 100644 --- a/src/components/fetchhelper.ts +++ b/src/components/fetchhelper.ts @@ -12,9 +12,9 @@ function getFetchPromise(request: any): Promise { } const fetchRequest: RequestInit = { + credentials: 'same-origin', headers: headers, - method: request.type, - credentials: 'same-origin' + method: request.type }; let contentType = request.contentType; diff --git a/src/components/jellyfinActions.ts b/src/components/jellyfinActions.ts index 9f3b25fa..0d408bba 100644 --- a/src/components/jellyfinActions.ts +++ b/src/components/jellyfinActions.ts @@ -79,16 +79,16 @@ export function reportPlaybackStart( broadcastToMessageBus({ //TODO: convert these to use a defined type in the type field - type: 'playbackstart', - data: getSenderReportingData($scope, reportingParams) + data: getSenderReportingData($scope, reportingParams), + type: 'playbackstart' }); restartPingInterval($scope, reportingParams); return JellyfinApi.authAjax('Sessions/Playing', { - type: 'POST', + contentType: 'application/json', data: JSON.stringify(reportingParams), - contentType: 'application/json' + type: 'POST' }); } @@ -108,8 +108,8 @@ export function reportPlaybackProgress( broadcastEventName = 'playbackprogress' ): Promise { broadcastToMessageBus({ - type: broadcastEventName, - data: getSenderReportingData($scope, reportingParams) + data: getSenderReportingData($scope, reportingParams), + type: broadcastEventName }); if (reportToServer === false) { @@ -120,9 +120,9 @@ export function reportPlaybackProgress( lastTranscoderPing = new Date().getTime(); return JellyfinApi.authAjax('Sessions/Playing/Progress', { - type: 'POST', + contentType: 'application/json', data: JSON.stringify(reportingParams), - contentType: 'application/json' + type: 'POST' }); } @@ -140,14 +140,14 @@ export function reportPlaybackStopped( stopPingInterval(); broadcastToMessageBus({ - type: 'playbackstop', - data: getSenderReportingData($scope, reportingParams) + data: getSenderReportingData($scope, reportingParams), + type: 'playbackstop' }); return JellyfinApi.authAjax('Sessions/Playing/Stopped', { - type: 'POST', + contentType: 'application/json', data: JSON.stringify(reportingParams), - contentType: 'application/json' + type: 'POST' }); } @@ -180,12 +180,12 @@ export function pingTranscoder( return JellyfinApi.authAjax( `Sessions/Playing/Ping?playSessionId=${reportingParams.PlaySessionId}`, { - type: 'POST', + contentType: 'application/json', data: JSON.stringify({ // jellyfin <= 10.6 wants it in the post data. PlaySessionId: reportingParams.PlaySessionId }), - contentType: 'application/json' + type: 'POST' } ); } @@ -276,9 +276,9 @@ export function getPlaybackInfo( // TODO: PlayRequestQuery might not be the proper type for this const query: PlayRequestQuery = { - UserId: JellyfinApi.userId ?? undefined, + MaxStreamingBitrate: maxBitrate, StartTimeTicks: startPosition || 0, - MaxStreamingBitrate: maxBitrate + UserId: JellyfinApi.userId ?? undefined }; if (audioStreamIndex != null) { @@ -298,11 +298,11 @@ export function getPlaybackInfo( } return JellyfinApi.authAjax(`Items/${item.Id}/PlaybackInfo`, { - query: query, - type: 'POST', - dataType: 'json', + contentType: 'application/json', data: JSON.stringify(postData), - contentType: 'application/json' + dataType: 'json', + query: query, + type: 'POST' }); } @@ -332,11 +332,11 @@ export function getLiveStream( }; const query: PlayRequestQuery = { - UserId: JellyfinApi.userId ?? undefined, - StartTimeTicks: startPosition || 0, ItemId: item.Id, MaxStreamingBitrate: maxBitrate, - PlaySessionId: playSessionId + PlaySessionId: playSessionId, + StartTimeTicks: startPosition || 0, + UserId: JellyfinApi.userId ?? undefined }; if (audioStreamIndex != null) { @@ -348,11 +348,11 @@ export function getLiveStream( } return JellyfinApi.authAjax('LiveStreams/Open', { - query: query, - type: 'POST', - dataType: 'json', + contentType: 'application/json', data: JSON.stringify(postData), - contentType: 'application/json' + dataType: 'json', + query: query, + type: 'POST' }); } @@ -370,8 +370,8 @@ export async function getDownloadSpeed(byteSize: number): Promise { const now = new Date().getTime(); await JellyfinApi.authAjax(path, { - type: 'GET', - timeout: 5000 + timeout: 5000, + type: 'GET' }); const responseTimeSeconds = (new Date().getTime() - now) / 1000; @@ -418,7 +418,7 @@ export function stopActiveEncodings($scope: GlobalScope): Promise { } return JellyfinApi.authAjax('Videos/ActiveEncodings', { - type: 'DELETE', - query: options + query: options, + type: 'DELETE' }); } diff --git a/src/components/jellyfinApi.ts b/src/components/jellyfinApi.ts index 976fe8a3..8221ea07 100644 --- a/src/components/jellyfinApi.ts +++ b/src/components/jellyfinApi.ts @@ -110,8 +110,8 @@ export abstract class JellyfinApi { } const params = { - url: this.createUrl(path), - headers: this.getSecurityHeaders() + headers: this.getSecurityHeaders(), + url: this.createUrl(path) }; return ajax({ ...params, ...args }); @@ -132,8 +132,8 @@ export abstract class JellyfinApi { } const params = { - url: this.createUserUrl(path), - headers: this.getSecurityHeaders() + headers: this.getSecurityHeaders(), + url: this.createUserUrl(path) }; return ajax({ ...params, ...args }); diff --git a/src/components/maincontroller.ts b/src/components/maincontroller.ts index 0b28d4ed..b51980c7 100644 --- a/src/components/maincontroller.ts +++ b/src/components/maincontroller.ts @@ -210,23 +210,23 @@ export async function reportDeviceCapabilities(): Promise { const maxBitrate = await getMaxBitrate(); const deviceProfile = getDeviceProfile({ - enableHls: true, - bitrateSetting: maxBitrate + bitrateSetting: maxBitrate, + enableHls: true }); const capabilities = { + DeviceProfile: deviceProfile, PlayableMediaTypes: ['Audio', 'Video'], - SupportsPersistentIdentifier: false, SupportsMediaControl: true, - DeviceProfile: deviceProfile + SupportsPersistentIdentifier: false }; hasReportedCapabilities = true; return JellyfinApi.authAjax('Sessions/Capabilities/Full', { - type: 'POST', + contentType: 'application/json', data: JSON.stringify(capabilities), - contentType: 'application/json' + type: 'POST' }); } @@ -243,9 +243,9 @@ export function processMessage(data: any): void { console.log('Invalid message sent from sender. Sending error response'); broadcastToMessageBus({ - type: 'error', message: - 'Missing one or more required params - command,options,userId,accessToken,serverAddress' + 'Missing one or more required params - command,options,userId,accessToken,serverAddress', + type: 'error' }); return; @@ -442,8 +442,8 @@ export async function changeStream( const maxBitrate = await getMaxBitrate(); const deviceProfile = getDeviceProfile({ - enableHls: true, - bitrateSetting: maxBitrate + bitrateSetting: maxBitrate, + enableHls: true }); const audioStreamIndex = params.AudioStreamIndex == null @@ -665,7 +665,7 @@ export function validatePlaybackInfoResult(result: any): boolean { * @param error */ export function showPlaybackInfoErrorMessage(error: string): void { - broadcastToMessageBus({ type: 'playbackerror', message: error }); + broadcastToMessageBus({ message: error, type: 'playbackerror' }); } /** @@ -805,17 +805,17 @@ export function createMediaInformation( mediaInfo.contentId = streamInfo.url; mediaInfo.contentType = streamInfo.contentType; mediaInfo.customData = { - startPositionTicks: streamInfo.startPositionTicks || 0, + audioStreamIndex: streamInfo.audioStreamIndex, + canClientSeek: streamInfo.canClientSeek, + canSeek: streamInfo.canSeek, itemId: item.Id, + liveStreamId: streamInfo.mediaSource.LiveStreamId, mediaSourceId: streamInfo.mediaSource.Id, - audioStreamIndex: streamInfo.audioStreamIndex, - subtitleStreamIndex: streamInfo.subtitleStreamIndex, playMethod: streamInfo.isStatic ? 'DirectStream' : 'Transcode', + playSessionId: playSessionId, runtimeTicks: streamInfo.mediaSource.RunTimeTicks, - liveStreamId: streamInfo.mediaSource.LiveStreamId, - canSeek: streamInfo.canSeek, - canClientSeek: streamInfo.canClientSeek, - playSessionId: playSessionId + startPositionTicks: streamInfo.startPositionTicks || 0, + subtitleStreamIndex: streamInfo.subtitleStreamIndex }; mediaInfo.metadata = getMetadata(item); diff --git a/src/components/playbackManager.ts b/src/components/playbackManager.ts index 8942af30..f59b9786 100644 --- a/src/components/playbackManager.ts +++ b/src/components/playbackManager.ts @@ -133,8 +133,8 @@ export class playbackManager { const maxBitrate = await getMaxBitrate(); const deviceProfile = getDeviceProfile({ - enableHls: true, - bitrateSetting: maxBitrate + bitrateSetting: maxBitrate, + enableHls: true }); const playbackInfo = await getPlaybackInfo( item, diff --git a/src/helpers.ts b/src/helpers.ts index 33d49398..962433bd 100644 --- a/src/helpers.ts +++ b/src/helpers.ts @@ -39,21 +39,21 @@ export function getReportingParams($scope: GlobalScope): PlaybackProgressInfo { * those fields are always rounded. */ return { - PositionTicks: Math.round(getCurrentPositionTicks($scope)), + AudioStreamIndex: $scope.audioStreamIndex, + CanSeek: $scope.canSeek, + IsMuted: window.volume.muted, IsPaused: window.mediaManager.getPlayerState() === cast.framework.messages.PlayerState.PAUSED, - IsMuted: window.volume.muted, - AudioStreamIndex: $scope.audioStreamIndex, - SubtitleStreamIndex: $scope.subtitleStreamIndex, - VolumeLevel: Math.round(window.volume.level * 100), ItemId: $scope.itemId, + LiveStreamId: $scope.liveStreamId, MediaSourceId: $scope.mediaSourceId, - CanSeek: $scope.canSeek, PlayMethod: $scope.playMethod, - LiveStreamId: $scope.liveStreamId, PlaySessionId: $scope.playSessionId, - RepeatMode: window.repeatMode + PositionTicks: Math.round(getCurrentPositionTicks($scope)), + RepeatMode: window.repeatMode, + SubtitleStreamIndex: $scope.subtitleStreamIndex, + VolumeLevel: Math.round(window.volume.level * 100) }; } @@ -96,8 +96,8 @@ export function getNextPlaybackItemInfo(): ItemIndex | null { const item = playlist[newIndex]; return { - item: item, - index: newIndex + index: newIndex, + item: item }; } @@ -451,17 +451,17 @@ export function createStreamInfo( const canSeek = (mediaSource.RunTimeTicks || 0) > 0; const info: any = { - url: mediaUrl, - mediaSource: mediaSource, - isStatic: isStatic, + audioStreamIndex: mediaSource.DefaultAudioStreamIndex, + canClientSeek: isStatic || (canSeek && streamContainer == 'm3u8'), + canSeek: canSeek, contentType: contentType, + isStatic: isStatic, + mediaSource: mediaSource, + playerStartPositionTicks: playerStartPositionTicks, + startPositionTicks: startPosition, streamContainer: streamContainer, - canSeek: canSeek, - canClientSeek: isStatic || (canSeek && streamContainer == 'm3u8'), - audioStreamIndex: mediaSource.DefaultAudioStreamIndex, subtitleStreamIndex: mediaSource.DefaultSubtitleStreamIndex, - playerStartPositionTicks: playerStartPositionTicks, - startPositionTicks: startPosition + url: mediaUrl }; const subtitleStreams = @@ -546,12 +546,12 @@ export function getShuffleItems( item: BaseItemDto ): Promise { const query: ItemQuery = { - UserId: userId, Fields: requiredItemFields, - Limit: 50, Filters: 'IsNotFolder', + Limit: 50, Recursive: true, - SortBy: 'Random' + SortBy: 'Random', + UserId: userId }; if (item.Type == 'MusicArtist') { @@ -582,9 +582,9 @@ export async function getInstantMixItems( item: BaseItemDto ): Promise { const query: any = { - UserId: userId, Fields: requiredItemFields, - Limit: 50 + Limit: 50, + UserId: userId }; let url: string | null = null; @@ -605,9 +605,9 @@ export async function getInstantMixItems( if (url) { return JellyfinApi.authAjax(url, { + dataType: 'json', query: query, - type: 'GET', - dataType: 'json' + type: 'GET' }); } else { throw new Error(`InstantMix: Unknown item type: ${item.Type}`); @@ -634,8 +634,8 @@ export async function getItemsForPlayback( const item = await JellyfinApi.authAjaxUser( `Items/${query.Ids.split(',')[0]}`, { - type: 'GET', - dataType: 'json' + dataType: 'json', + type: 'GET' } ); @@ -645,9 +645,9 @@ export async function getItemsForPlayback( }; } else { return JellyfinApi.authAjaxUser('Items', { + dataType: 'json', query: query, - type: 'GET', - dataType: 'json' + type: 'GET' }); } } @@ -670,9 +670,9 @@ export function getEpisodesForPlayback( query.ExcludeLocationTypes = 'Virtual'; return JellyfinApi.authAjax(`Shows/${seriesId}/Episodes`, { + dataType: 'json', query: query, - type: 'GET', - dataType: 'json' + type: 'GET' }); } @@ -730,25 +730,25 @@ export async function translateRequestedItems( return await getItemsForPlayback(userId, { ArtistIds: firstItem.Id, Filters: 'IsNotFolder', + MediaTypes: 'Audio', Recursive: true, - SortBy: 'SortName', - MediaTypes: 'Audio' + SortBy: 'SortName' }); } else if (firstItem.Type == 'MusicGenre') { return await getItemsForPlayback(userId, { - Genres: firstItem.Name ?? undefined, Filters: 'IsNotFolder', + Genres: firstItem.Name ?? undefined, + MediaTypes: 'Audio', Recursive: true, - SortBy: 'SortName', - MediaTypes: 'Audio' + SortBy: 'SortName' }); } else if (firstItem.IsFolder) { return await getItemsForPlayback(userId, { - ParentId: firstItem.Id, Filters: 'IsNotFolder', + MediaTypes: 'Audio,Video', + ParentId: firstItem.Id, Recursive: true, - SortBy: 'SortName', - MediaTypes: 'Audio,Video' + SortBy: 'SortName' }); } else if (smart && firstItem.Type == 'Episode' && items.length == 1) { const user = await getUser(); @@ -777,8 +777,8 @@ export async function translateRequestedItems( userId, episode.SeriesId, { - IsVirtualUnaired: false, IsMissing: false, + IsVirtualUnaired: false, UserId: userId } ); @@ -857,7 +857,7 @@ export function broadcastToMessageBus(message: BusMessage): void { * Inform the cast sender that we couldn't connect */ export function broadcastConnectionErrorMessage(): void { - broadcastToMessageBus({ type: 'connectionerror', message: '' }); + broadcastToMessageBus({ message: '', type: 'connectionerror' }); } /** diff --git a/stylelint.config.js b/stylelint.config.js index c14e2cce..73562f86 100644 --- a/stylelint.config.js +++ b/stylelint.config.js @@ -1,7 +1,7 @@ module.exports = { - syntax: 'css', extends: ['stylelint-config-standard', 'stylelint-config-prettier'], rules: { 'at-rule-no-unknown': null - } + }, + syntax: 'css' }; diff --git a/webpack.config.ts b/webpack.config.ts index 10144510..8da42000 100644 --- a/webpack.config.ts +++ b/webpack.config.ts @@ -12,22 +12,35 @@ import { version } from './package.json'; const common: webpack.Configuration = { context: path.resolve(__dirname, 'src'), entry: './app.ts', + module: { + rules: [ + { loader: 'html-loader', test: /\.html$/ }, + { + test: /\.(png|svg|jpg|gif)$/, + use: 'file-loader' + }, + { + loader: 'file-loader', + test: /\.(ttf|eot|woff(2)?)(\?[a-z0-9=&.]+)?$/ + }, + { test: /\.css$/i, use: ['style-loader', 'css-loader'] }, + { loader: 'ts-loader', test: /\.tsx?$/ }, + { loader: 'source-map-loader', test: /\.js$/ } + ] + }, output: { filename: '[name].[fullhash].js', path: path.resolve(__dirname, 'dist'), publicPath: './' }, - resolve: { - extensions: ['.ts', '.js'] - }, plugins: [ // @ts-expect-error - Typings mismatch between versions new CleanWebpackPlugin(), new HtmlWebpackPlugin({ + favicon: 'favicon.ico', filename: 'index.html', - template: 'index.html', hash: false, - favicon: 'favicon.ico' + template: 'index.html' }), new ImageMinimizerPlugin({ minimizerOptions: { @@ -46,36 +59,23 @@ const common: webpack.Configuration = { } }) ], - module: { - rules: [ - { test: /\.html$/, loader: 'html-loader' }, - { - test: /\.(png|svg|jpg|gif)$/, - use: 'file-loader' - }, - { - test: /\.(ttf|eot|woff(2)?)(\?[a-z0-9=&.]+)?$/, - loader: 'file-loader' - }, - { test: /\.css$/i, use: ['style-loader', 'css-loader'] }, - { test: /\.tsx?$/, loader: 'ts-loader' }, - { test: /\.js$/, loader: 'source-map-loader' } - ] + resolve: { + extensions: ['.ts', '.js'] } }; const development: webpack.Configuration = { - mode: 'development', - devtool: 'inline-source-map', // @ts-expect-error - Typings mismatch between versions devServer: { - contentBase: path.join(__dirname, 'dist'), compress: true, + contentBase: path.join(__dirname, 'dist'), port: process.env.RECEIVER_PORT ? Number.parseInt(process.env.RECEIVER_PORT, 10) : 9000, publicPath: '/' }, + devtool: 'inline-source-map', + mode: 'development', plugins: [ new DefinePlugin({ PRODUCTION: JSON.stringify(false),