mirror of
https://github.com/Stremio/stremio-web.git
synced 2026-03-11 17:15:48 +00:00
Merge branch 'development' into feat/gamepad-support
This commit is contained in:
commit
ddf842b0c7
149 changed files with 2960 additions and 1951 deletions
7
.github/workflows/auto_assign.yml
vendored
7
.github/workflows/auto_assign.yml
vendored
|
|
@ -13,8 +13,8 @@ jobs:
|
|||
steps:
|
||||
# Auto assign PR to author
|
||||
- name: Auto Assign PR to Author
|
||||
if: github.event_name == 'pull_request'
|
||||
uses: actions/github-script@v6
|
||||
if: github.event_name == 'pull_request' && github.actor != 'dependabot[bot]'
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
script: |
|
||||
|
|
@ -31,9 +31,10 @@ jobs:
|
|||
|
||||
# Dynamic labeling based on PR/Issue title
|
||||
- name: Label PRs and Issues
|
||||
if: github.actor != 'dependabot[bot]'
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
uses: actions/github-script@v6
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
script: |
|
||||
|
|
|
|||
2
.github/workflows/build.yml
vendored
2
.github/workflows/build.yml
vendored
|
|
@ -20,7 +20,7 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
- name: Setup node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
|
|
|
|||
36
.github/workflows/pages_cleanup.yml
vendored
Normal file
36
.github/workflows/pages_cleanup.yml
vendored
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
name: GitHub Pages Cleanup
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 0 * * 0'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
ref: gh-pages
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Delete directories older than 1 year
|
||||
run: |
|
||||
for dir in $(find . -mindepth 1 -maxdepth 2 -type d -not -path '*/\.*'); do
|
||||
if ! git log -1 --since="1 year ago" -- "$dir" | grep -q .; then
|
||||
echo "Deleting $dir"
|
||||
rm -rf "$dir"
|
||||
fi
|
||||
done
|
||||
|
||||
- name: Commit and push
|
||||
run: |
|
||||
git config --global user.name 'GitHub Pages Cleanup'
|
||||
git config --global user.email 'actions@stremio.com'
|
||||
git add -A
|
||||
git diff --cached --quiet || git commit -m "cleanup"
|
||||
git push origin gh-pages
|
||||
12
.github/workflows/release.yml
vendored
12
.github/workflows/release.yml
vendored
|
|
@ -9,7 +9,7 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
- name: Install NPM dependencies
|
||||
run: npm install
|
||||
- name: Build
|
||||
|
|
@ -19,16 +19,10 @@ jobs:
|
|||
- name: Zip build artifact
|
||||
run: zip -r stremio-web.zip ./build
|
||||
- name: Upload build artifact to GitHub release assets
|
||||
uses: svenstaro/upload-release-action@2.9.0
|
||||
uses: svenstaro/upload-release-action@2.11.2
|
||||
with:
|
||||
repo_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
file: stremio-web.zip
|
||||
asset_name: stremio-web.zip
|
||||
tag: ${{ github.ref }}
|
||||
overwrite: true
|
||||
- name: Upload build artifact to Netlify
|
||||
run: |
|
||||
curl -H "Content-Type: application/zip" \
|
||||
-H "Authorization: Bearer ${{ secrets.netlify_access_token }}" \
|
||||
--data-binary "@stremio-web.zip" \
|
||||
https://api.netlify.com/api/v1/sites/stremio-development.netlify.com/deploys
|
||||
overwrite: true
|
||||
96
package-lock.json
generated
96
package-lock.json
generated
|
|
@ -1,20 +1,20 @@
|
|||
{
|
||||
"name": "stremio",
|
||||
"version": "5.0.0-beta.23",
|
||||
"version": "5.0.0-beta.26",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "stremio",
|
||||
"version": "5.0.0-beta.23",
|
||||
"version": "5.0.0-beta.26",
|
||||
"license": "gpl-2.0",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "7.26.0",
|
||||
"@sentry/browser": "8.42.0",
|
||||
"@stremio/stremio-colors": "5.2.0",
|
||||
"@stremio/stremio-core-web": "https://stremio.github.io/stremio-core/stremio-core-web/feat/settings-gamepad-support/dev/stremio-stremio-core-web-0.49.2.tgz",
|
||||
"@stremio/stremio-icons": "5.4.1",
|
||||
"@stremio/stremio-video": "0.0.60",
|
||||
"@stremio/stremio-icons": "5.7.1",
|
||||
"@stremio/stremio-video": "0.0.61",
|
||||
"a-color-picker": "1.2.1",
|
||||
"bowser": "2.11.0",
|
||||
"buffer": "6.0.3",
|
||||
|
|
@ -36,7 +36,7 @@
|
|||
"react-i18next": "^15.1.3",
|
||||
"react-is": "18.3.1",
|
||||
"spatial-navigation-polyfill": "github:Stremio/spatial-navigation#64871b1422466f5f45d24ebc8bbd315b2ebab6a6",
|
||||
"stremio-translations": "github:Stremio/stremio-translations#a6be0425573917c2e82b66d28968c1a4d444cb96",
|
||||
"stremio-translations": "github:Stremio/stremio-translations#abe7684165a031755e9aee39da26daa806ba7824",
|
||||
"url": "0.11.4",
|
||||
"use-long-press": "^3.2.0"
|
||||
},
|
||||
|
|
@ -48,6 +48,8 @@
|
|||
"@stylistic/eslint-plugin": "^2.11.0",
|
||||
"@stylistic/eslint-plugin-jsx": "^2.11.0",
|
||||
"@types/hat": "^0.0.4",
|
||||
"@types/lodash.isequal": "^4.5.8",
|
||||
"@types/lodash.throttle": "^4.1.9",
|
||||
"@types/react": "^18.3.13",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"babel-loader": "9.2.1",
|
||||
|
|
@ -66,6 +68,7 @@
|
|||
"mini-css-extract-plugin": "2.9.2",
|
||||
"postcss-loader": "8.1.1",
|
||||
"readdirp": "4.0.2",
|
||||
"recast": "0.23.11",
|
||||
"terser-webpack-plugin": "5.3.10",
|
||||
"thread-loader": "^4.0.4",
|
||||
"ts-loader": "^9.5.1",
|
||||
|
|
@ -3398,9 +3401,10 @@
|
|||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@stremio/stremio-icons": {
|
||||
"version": "5.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@stremio/stremio-icons/-/stremio-icons-5.4.1.tgz",
|
||||
"integrity": "sha512-7g4JP7tPRT1UDZxbuH/Urq7fc6te3joy8qyx/NGWIW7wO169TTISO7ZWdejzESvUVgZ/7i6rzkRmXZ3wefWcBg==",
|
||||
"version": "5.7.1",
|
||||
"resolved": "https://registry.npmjs.org/@stremio/stremio-icons/-/stremio-icons-5.7.1.tgz",
|
||||
"integrity": "sha512-Z96p36LLX3G+ewMnFKmNZVsO/AtcHA33WQ3wGOYFubxiYADPRAkcLVU5rHIfiGSC9IUaUVhxQWTPVB9ScY4Q5Q==",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"react",
|
||||
"react-native",
|
||||
|
|
@ -3409,9 +3413,9 @@
|
|||
]
|
||||
},
|
||||
"node_modules/@stremio/stremio-video": {
|
||||
"version": "0.0.60",
|
||||
"resolved": "https://registry.npmjs.org/@stremio/stremio-video/-/stremio-video-0.0.60.tgz",
|
||||
"integrity": "sha512-RbmSi+Lk+3pb6f2ZkGVCnoMoJoujvVvSLDHiLGkXnzQwjYf2B2022NKlAQmHRuHN1sjD+VEsKD8foQH4hXGG1A==",
|
||||
"version": "0.0.61",
|
||||
"resolved": "https://registry.npmjs.org/@stremio/stremio-video/-/stremio-video-0.0.61.tgz",
|
||||
"integrity": "sha512-+m3ScDmImTilcpCnY5WO091SdWuDMrW8KkUs7y+ZL6PioZXNtd8fvRsmQoHKkWkkKX3K3LNTIfA7w5unITv1jA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"buffer": "6.0.3",
|
||||
|
|
@ -3806,6 +3810,33 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/lodash": {
|
||||
"version": "4.17.18",
|
||||
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.18.tgz",
|
||||
"integrity": "sha512-KJ65INaxqxmU6EoCiJmRPZC9H9RVWCRd349tXM2M3O5NA7cY6YL7c0bHAHQ93NOfTObEQ004kd2QVHs/r0+m4g==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/lodash.isequal": {
|
||||
"version": "4.5.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/lodash.isequal/-/lodash.isequal-4.5.8.tgz",
|
||||
"integrity": "sha512-uput6pg4E/tj2LGxCZo9+y27JNyB2OZuuI/T5F+ylVDYuqICLG2/ktjxx0v6GvVntAf8TvEzeQLcV0ffRirXuA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/lodash": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/lodash.throttle": {
|
||||
"version": "4.1.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/lodash.throttle/-/lodash.throttle-4.1.9.tgz",
|
||||
"integrity": "sha512-PCPVfpfueguWZQB7pJQK890F2scYKoDUL3iM522AptHWn7d5NQmeS/LTEHIcLr5PaTzl3dK2Z0xSUHHTHwaL5g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/lodash": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/mime": {
|
||||
"version": "1.3.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz",
|
||||
|
|
@ -4786,6 +4817,19 @@
|
|||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/ast-types": {
|
||||
"version": "0.16.1",
|
||||
"resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.16.1.tgz",
|
||||
"integrity": "sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/async": {
|
||||
"version": "3.2.6",
|
||||
"resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz",
|
||||
|
|
@ -12497,6 +12541,23 @@
|
|||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/recast": {
|
||||
"version": "0.23.11",
|
||||
"resolved": "https://registry.npmjs.org/recast/-/recast-0.23.11.tgz",
|
||||
"integrity": "sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ast-types": "^0.16.1",
|
||||
"esprima": "~4.0.0",
|
||||
"source-map": "~0.6.1",
|
||||
"tiny-invariant": "^1.3.3",
|
||||
"tslib": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 4"
|
||||
}
|
||||
},
|
||||
"node_modules/rechoir": {
|
||||
"version": "0.8.0",
|
||||
"resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.8.0.tgz",
|
||||
|
|
@ -13373,9 +13434,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/stremio-translations": {
|
||||
"version": "1.44.10",
|
||||
"resolved": "git+ssh://git@github.com/Stremio/stremio-translations.git#a6be0425573917c2e82b66d28968c1a4d444cb96",
|
||||
"integrity": "sha512-77kVE/eos/SA16kzeK7TTWmqoLF0mLPCJXjITwVIVzMHr8XyBPZFOfmiVEg4M6W1W7qYqA+dHhzicyLs7hJhlw==",
|
||||
"version": "1.44.12",
|
||||
"resolved": "git+ssh://git@github.com/Stremio/stremio-translations.git#abe7684165a031755e9aee39da26daa806ba7824",
|
||||
"integrity": "sha512-bMpdJTFZqgemdoOQAARMPG7XaFgeu/zW/0vHmzavTM9DYUNIGuQaTC5RbVXIIII00RLOXoGLYf+dsxRVFiS9mA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/string_decoder": {
|
||||
|
|
@ -13976,6 +14037,13 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tiny-invariant": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
|
||||
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tinycolor2": {
|
||||
"version": "1.6.0",
|
||||
"dev": true,
|
||||
|
|
|
|||
14
package.json
14
package.json
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "stremio",
|
||||
"displayName": "Stremio",
|
||||
"version": "5.0.0-beta.23",
|
||||
"version": "5.0.0-beta.26",
|
||||
"author": "Smart Code OOD",
|
||||
"private": true,
|
||||
"license": "gpl-2.0",
|
||||
|
|
@ -10,15 +10,16 @@
|
|||
"start-prod": "webpack serve --mode production",
|
||||
"build": "webpack --mode production",
|
||||
"test": "jest",
|
||||
"lint": "eslint src"
|
||||
"lint": "eslint src",
|
||||
"scan-translations": "npx jest ./tests/i18nScan.test.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/runtime": "7.26.0",
|
||||
"@sentry/browser": "8.42.0",
|
||||
"@stremio/stremio-colors": "5.2.0",
|
||||
"@stremio/stremio-core-web": "https://stremio.github.io/stremio-core/stremio-core-web/feat/settings-gamepad-support/dev/stremio-stremio-core-web-0.49.2.tgz",
|
||||
"@stremio/stremio-icons": "5.4.1",
|
||||
"@stremio/stremio-video": "0.0.60",
|
||||
"@stremio/stremio-icons": "5.7.1",
|
||||
"@stremio/stremio-video": "0.0.61",
|
||||
"a-color-picker": "1.2.1",
|
||||
"bowser": "2.11.0",
|
||||
"buffer": "6.0.3",
|
||||
|
|
@ -40,7 +41,7 @@
|
|||
"react-i18next": "^15.1.3",
|
||||
"react-is": "18.3.1",
|
||||
"spatial-navigation-polyfill": "github:Stremio/spatial-navigation#64871b1422466f5f45d24ebc8bbd315b2ebab6a6",
|
||||
"stremio-translations": "github:Stremio/stremio-translations#a6be0425573917c2e82b66d28968c1a4d444cb96",
|
||||
"stremio-translations": "github:Stremio/stremio-translations#abe7684165a031755e9aee39da26daa806ba7824",
|
||||
"url": "0.11.4",
|
||||
"use-long-press": "^3.2.0"
|
||||
},
|
||||
|
|
@ -52,6 +53,8 @@
|
|||
"@stylistic/eslint-plugin": "^2.11.0",
|
||||
"@stylistic/eslint-plugin-jsx": "^2.11.0",
|
||||
"@types/hat": "^0.0.4",
|
||||
"@types/lodash.isequal": "^4.5.8",
|
||||
"@types/lodash.throttle": "^4.1.9",
|
||||
"@types/react": "^18.3.13",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"babel-loader": "9.2.1",
|
||||
|
|
@ -70,6 +73,7 @@
|
|||
"mini-css-extract-plugin": "2.9.2",
|
||||
"postcss-loader": "8.1.1",
|
||||
"readdirp": "4.0.2",
|
||||
"recast": "0.23.11",
|
||||
"terser-webpack-plugin": "5.3.10",
|
||||
"thread-loader": "^4.0.4",
|
||||
"ts-loader": "^9.5.1",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
// Copyright (C) 2017-2023 Smart code 203358507
|
||||
|
||||
const React = require('react');
|
||||
const { useTranslation } = require('react-i18next');
|
||||
const PropTypes = require('prop-types');
|
||||
const classnames = require('classnames');
|
||||
const { default: Icon } = require('@stremio/stremio-icons/react');
|
||||
|
|
@ -8,6 +9,7 @@ const { Button } = require('stremio/components');
|
|||
const styles = require('./styles');
|
||||
|
||||
const ToastItem = ({ title, message, dataset, onSelect, onClose, ...props }) => {
|
||||
const { t } = useTranslation();
|
||||
const type = React.useMemo(() => {
|
||||
return ['success', 'alert', 'info', 'error'].includes(props.type) ?
|
||||
props.type
|
||||
|
|
@ -74,7 +76,7 @@ const ToastItem = ({ title, message, dataset, onSelect, onClose, ...props }) =>
|
|||
null
|
||||
}
|
||||
</div>
|
||||
<Button className={styles['close-button-container']} title={'Close'} tabIndex={-1} onClick={closeButtonOnClick}>
|
||||
<Button className={styles['close-button-container']} title={t('BUTTON_CLOSE')} tabIndex={-1} onClick={closeButtonOnClick}>
|
||||
<Icon className={styles['icon']} name={'close'} />
|
||||
</Button>
|
||||
</Button>
|
||||
|
|
|
|||
11
src/common/Toast/useToast.d.ts
vendored
Normal file
11
src/common/Toast/useToast.d.ts
vendored
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
type ToastOptions = {
|
||||
type: string,
|
||||
title: string,
|
||||
timeout: number,
|
||||
};
|
||||
|
||||
declare const useToast: () => {
|
||||
show: (options: ToastOptions) => void,
|
||||
};
|
||||
|
||||
export = useToast;
|
||||
|
|
@ -82,6 +82,19 @@
|
|||
transform: translateY(100%);
|
||||
}
|
||||
|
||||
.fade-enter {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.fade-active {
|
||||
opacity: 1;
|
||||
transition: opacity 0.3s cubic-bezier(0.32, 0, 0.67, 0);
|
||||
}
|
||||
|
||||
.fade-exit {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
@keyframes fade-in-no-motion {
|
||||
0% {
|
||||
opacity: 0;
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ const routesRegexp = require('./routesRegexp');
|
|||
const useAnimationFrame = require('./useAnimationFrame');
|
||||
const useBinaryState = require('./useBinaryState');
|
||||
const { default: useFullscreen } = require('./useFullscreen');
|
||||
const { default: useInterval } = require('./useInterval');
|
||||
const useLiveRef = require('./useLiveRef');
|
||||
const useModelState = require('./useModelState');
|
||||
const useNotifications = require('./useNotifications');
|
||||
|
|
@ -23,9 +24,11 @@ const useProfile = require('./useProfile');
|
|||
const { default: useSettings } = require('./useSettings');
|
||||
const { default: useShell } = require('./useShell');
|
||||
const useStreamingServer = require('./useStreamingServer');
|
||||
const { default: useTimeout } = require('./useTimeout');
|
||||
const useTorrent = require('./useTorrent');
|
||||
const useTranslate = require('./useTranslate');
|
||||
const { default: useOrientation } = require('./useOrientation');
|
||||
const { default: useLanguageSorting } = require('./useLanguageSorting');
|
||||
|
||||
module.exports = {
|
||||
FileDropProvider,
|
||||
|
|
@ -48,6 +51,7 @@ module.exports = {
|
|||
useAnimationFrame,
|
||||
useBinaryState,
|
||||
useFullscreen,
|
||||
useInterval,
|
||||
useLiveRef,
|
||||
useModelState,
|
||||
useNotifications,
|
||||
|
|
@ -56,7 +60,9 @@ module.exports = {
|
|||
useSettings,
|
||||
useShell,
|
||||
useStreamingServer,
|
||||
useTimeout,
|
||||
useTorrent,
|
||||
useTranslate,
|
||||
useOrientation,
|
||||
useLanguageSorting,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -42,11 +42,21 @@ const useFullscreen = () => {
|
|||
};
|
||||
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
|
||||
const activeElement = document.activeElement as HTMLElement;
|
||||
|
||||
const inputFocused =
|
||||
activeElement &&
|
||||
(activeElement.tagName === 'INPUT' ||
|
||||
activeElement.tagName === 'TEXTAREA' ||
|
||||
activeElement.tagName === 'SELECT' ||
|
||||
activeElement.isContentEditable);
|
||||
|
||||
if (event.code === 'Escape' && settings.escExitFullscreen) {
|
||||
exitFullscreen();
|
||||
}
|
||||
|
||||
if (event.code === 'KeyF') {
|
||||
if (event.code === 'KeyF' && !inputFocused) {
|
||||
toggleFullscreen();
|
||||
}
|
||||
|
||||
|
|
|
|||
26
src/common/useInterval.ts
Normal file
26
src/common/useInterval.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import { useEffect, useRef } from 'react';
|
||||
|
||||
const useInterval = (duration: number) => {
|
||||
const interval = useRef<NodeJS.Timer | null>(null);
|
||||
|
||||
const start = (callback: () => void) => {
|
||||
cancel();
|
||||
interval.current = setInterval(callback, duration);
|
||||
};
|
||||
|
||||
const cancel = () => {
|
||||
interval.current && clearInterval(interval.current);
|
||||
interval.current = null;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
return () => cancel();
|
||||
}, []);
|
||||
|
||||
return {
|
||||
start,
|
||||
cancel,
|
||||
};
|
||||
};
|
||||
|
||||
export default useInterval;
|
||||
38
src/common/useLanguageSorting.ts
Normal file
38
src/common/useLanguageSorting.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import { useMemo } from 'react';
|
||||
import interfaceLanguages from 'stremio/common/interfaceLanguages.json';
|
||||
|
||||
const useLanguageSorting = (options: MultiselectMenuOption[]) => {
|
||||
const userLangCode = useMemo(() => {
|
||||
const lang = interfaceLanguages.find((l) => l.codes.includes(navigator.language || 'en-US'));
|
||||
if (lang) {
|
||||
const threeLetter = lang.codes[1] || 'eng';
|
||||
const fullLocale = navigator.language || 'en-US';
|
||||
return [threeLetter, fullLocale];
|
||||
}
|
||||
return ['eng'];
|
||||
}, []);
|
||||
|
||||
const isLanguageDropdown = useMemo(() => {
|
||||
return options?.some((opt) => interfaceLanguages.some((l) => l.name === opt.label));
|
||||
}, [options]);
|
||||
|
||||
const sortedOptions = useMemo(() => {
|
||||
const matchingIndex = options.findIndex((opt) => {
|
||||
const lang = interfaceLanguages.find((l) => l.name === opt.label);
|
||||
return userLangCode.some((code) => lang?.codes.includes(code));
|
||||
});
|
||||
|
||||
if (matchingIndex === -1) {
|
||||
return [...options].sort((a, b) => a.label.localeCompare(b.label));
|
||||
}
|
||||
|
||||
const matchingOption = options[matchingIndex];
|
||||
const otherOptions = options.filter((_, idx) => idx !== matchingIndex).sort((a, b) => a.label.localeCompare(b.label));
|
||||
|
||||
return [matchingOption, ...otherOptions];
|
||||
}, [options, userLangCode, isLanguageDropdown]);
|
||||
|
||||
return { userLangCode, isLanguageDropdown, sortedOptions };
|
||||
};
|
||||
|
||||
export default useLanguageSorting;
|
||||
26
src/common/useTimeout.ts
Normal file
26
src/common/useTimeout.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import { useEffect, useRef } from 'react';
|
||||
|
||||
const useTimeout = (duration: number) => {
|
||||
const timeout = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
const start = (callback: () => void) => {
|
||||
cancel();
|
||||
timeout.current = setTimeout(callback, duration);
|
||||
};
|
||||
|
||||
const cancel = () => {
|
||||
timeout.current && clearTimeout(timeout.current);
|
||||
timeout.current = null;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
return () => cancel();
|
||||
}, []);
|
||||
|
||||
return {
|
||||
start,
|
||||
cancel,
|
||||
};
|
||||
};
|
||||
|
||||
export default useTimeout;
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
// Copyright (C) 2017-2023 Smart code 203358507
|
||||
|
||||
const React = require('react');
|
||||
const { useTranslation } = require('react-i18next');
|
||||
const PropTypes = require('prop-types');
|
||||
const classnames = require('classnames');
|
||||
const { default: Icon } = require('@stremio/stremio-icons/react');
|
||||
|
|
@ -8,6 +9,7 @@ const { default: Image } = require('stremio/components/Image');
|
|||
const styles = require('./styles');
|
||||
|
||||
const AddonDetails = ({ className, id, name, version, logo, description, types, transportUrl, official }) => {
|
||||
const { t } = useTranslation();
|
||||
const renderLogoFallback = React.useCallback(() => (
|
||||
<Icon className={styles['icon']} name={'addons'} />
|
||||
), []);
|
||||
|
|
@ -24,7 +26,7 @@ const AddonDetails = ({ className, id, name, version, logo, description, types,
|
|||
<span className={styles['name']}>{typeof name === 'string' && name.length > 0 ? name : id}</span>
|
||||
{
|
||||
typeof version === 'string' && version.length > 0 ?
|
||||
<span className={styles['version']}>v. {version}</span>
|
||||
<span className={styles['version']}>{t('ADDON_VERSION_SHORT', {version})}</span>
|
||||
:
|
||||
null
|
||||
}
|
||||
|
|
@ -41,7 +43,7 @@ const AddonDetails = ({ className, id, name, version, logo, description, types,
|
|||
{
|
||||
typeof transportUrl === 'string' && transportUrl.length > 0 ?
|
||||
<div className={styles['section-container']}>
|
||||
<span className={styles['section-header']}>URL: </span>
|
||||
<span className={styles['section-header']}>{`${t('URL')}:`}</span>
|
||||
<span className={classnames(styles['section-label'], styles['transport-url-label'])}>{transportUrl}</span>
|
||||
</div>
|
||||
:
|
||||
|
|
@ -50,7 +52,7 @@ const AddonDetails = ({ className, id, name, version, logo, description, types,
|
|||
{
|
||||
Array.isArray(types) && types.length > 0 ?
|
||||
<div className={styles['section-container']}>
|
||||
<span className={styles['section-header']}>Supported types: </span>
|
||||
<span className={styles['section-header']}>{`${t('ADDON_SUPPORTED_TYPES')}:`} </span>
|
||||
<span className={styles['section-label']}>
|
||||
{
|
||||
types.length === 1 ?
|
||||
|
|
@ -66,7 +68,7 @@ const AddonDetails = ({ className, id, name, version, logo, description, types,
|
|||
{
|
||||
!official ?
|
||||
<div className={styles['section-container']}>
|
||||
<div className={classnames(styles['section-label'], styles['disclaimer-label'])}>Using third-party add-ons will always be subject to your responsibility and the governing law of the jurisdiction you are located.</div>
|
||||
<div className={classnames(styles['section-label'], styles['disclaimer-label'])}>{t('ADDON_DISCLAIMER')}</div>
|
||||
</div>
|
||||
:
|
||||
null
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
// Copyright (C) 2017-2023 Smart code 203358507
|
||||
|
||||
const React = require('react');
|
||||
const { useTranslation } = require('react-i18next');
|
||||
const PropTypes = require('prop-types');
|
||||
const ModalDialog = require('stremio/components/ModalDialog');
|
||||
const { withCoreSuspender } = require('stremio/common/CoreSuspender');
|
||||
|
|
@ -43,13 +44,14 @@ function withRemoteAndLocalAddon(AddonDetails) {
|
|||
}
|
||||
|
||||
const AddonDetailsModal = ({ transportUrl, onCloseRequest }) => {
|
||||
const { t } = useTranslation();
|
||||
const { core } = useServices();
|
||||
const platform = usePlatform();
|
||||
const addonDetails = useAddonDetails(transportUrl);
|
||||
const modalButtons = React.useMemo(() => {
|
||||
const cancelButton = {
|
||||
className: styles['cancel-button'],
|
||||
label: 'Cancel',
|
||||
label: t('BUTTON_CANCEL'),
|
||||
props: {
|
||||
onClick: (event) => {
|
||||
if (typeof onCloseRequest === 'function') {
|
||||
|
|
@ -67,7 +69,7 @@ const AddonDetailsModal = ({ transportUrl, onCloseRequest }) => {
|
|||
addonDetails.remoteAddon.content.content.manifest.behaviorHints.configurable ?
|
||||
{
|
||||
className: styles['configure-button'],
|
||||
label: 'Configure',
|
||||
label: t('ADDON_CONFIGURE'),
|
||||
props: {
|
||||
onClick: (event) => {
|
||||
platform.openExternal(transportUrl.replace('manifest.json', 'configure'));
|
||||
|
|
@ -86,7 +88,7 @@ const AddonDetailsModal = ({ transportUrl, onCloseRequest }) => {
|
|||
const toggleButton = addonDetails.localAddon !== null ?
|
||||
{
|
||||
className: styles['uninstall-button'],
|
||||
label: 'Uninstall',
|
||||
label: t('ADDON_UNINSTALL'),
|
||||
props: {
|
||||
onClick: (event) => {
|
||||
core.transport.dispatch({
|
||||
|
|
@ -113,7 +115,7 @@ const AddonDetailsModal = ({ transportUrl, onCloseRequest }) => {
|
|||
{
|
||||
|
||||
className: styles['install-button'],
|
||||
label: 'Install',
|
||||
label: t('ADDON_INSTALL'),
|
||||
props: {
|
||||
onClick: (event) => {
|
||||
core.transport.dispatch({
|
||||
|
|
@ -141,21 +143,21 @@ const AddonDetailsModal = ({ transportUrl, onCloseRequest }) => {
|
|||
return addonDetails.remoteAddon?.content.type === 'Ready' ? addonDetails.remoteAddon.content.content.manifest.background : null;
|
||||
}, [addonDetails.remoteAddon]);
|
||||
return (
|
||||
<ModalDialog className={styles['addon-details-modal-container']} title={'Stremio addon'} buttons={modalButtons} background={modalBackground} onCloseRequest={onCloseRequest}>
|
||||
<ModalDialog className={styles['addon-details-modal-container']} title={t('STREMIO_COMMUNITY_ADDON')} buttons={modalButtons} background={modalBackground} onCloseRequest={onCloseRequest}>
|
||||
{
|
||||
addonDetails.selected === null ?
|
||||
<div className={styles['addon-details-message-container']}>
|
||||
Loading addon manifest
|
||||
{t('ADDON_LOADING_MANIFEST')}
|
||||
</div>
|
||||
:
|
||||
addonDetails.remoteAddon === null || addonDetails.remoteAddon.content.type === 'Loading' ?
|
||||
<div className={styles['addon-details-message-container']}>
|
||||
Loading addon manifest from {addonDetails.selected.transportUrl}
|
||||
{t('ADDON_LOADING_MANIFEST_FROM', { origin: addonDetails.selected.transportUrl})}
|
||||
</div>
|
||||
:
|
||||
addonDetails.remoteAddon.content.type === 'Err' && addonDetails.localAddon === null ?
|
||||
<div className={styles['addon-details-message-container']}>
|
||||
Failed to get addon manifest from {addonDetails.selected.transportUrl}
|
||||
{t('ADDON_LOADING_MANIFEST_FAILED', {origin: addonDetails.selected.transportUrl})}
|
||||
<div>{addonDetails.remoteAddon.content.content.message}</div>
|
||||
</div>
|
||||
:
|
||||
|
|
@ -174,17 +176,18 @@ AddonDetailsModal.propTypes = {
|
|||
onCloseRequest: PropTypes.func
|
||||
};
|
||||
|
||||
const AddonDetailsModalFallback = ({ onCloseRequest }) => (
|
||||
<ModalDialog
|
||||
const AddonDetailsModalFallback = ({ onCloseRequest }) => {
|
||||
const { t } = useTranslation();
|
||||
return <ModalDialog
|
||||
className={styles['addon-details-modal-container']}
|
||||
title={'Stremio addon'}
|
||||
title={t('STREMIO_COMMUNITY_ADDON')}
|
||||
onCloseRequest={onCloseRequest}
|
||||
>
|
||||
<div className={styles['addon-details-message-container']}>
|
||||
Loading addon manifest
|
||||
{t('ADDON_LOADING_MANIFEST')}
|
||||
</div>
|
||||
</ModalDialog>
|
||||
);
|
||||
</ModalDialog>;
|
||||
};
|
||||
|
||||
AddonDetailsModalFallback.propTypes = AddonDetailsModal.propTypes;
|
||||
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import styles from './Button.less';
|
|||
|
||||
type Props = {
|
||||
className?: string,
|
||||
style?: object,
|
||||
href?: string,
|
||||
target?: string
|
||||
title?: string,
|
||||
|
|
@ -15,6 +16,8 @@ type Props = {
|
|||
children: React.ReactNode,
|
||||
onKeyDown?: (event: React.KeyboardEvent) => void,
|
||||
onMouseDown?: (event: React.MouseEvent) => void,
|
||||
onMouseUp?: (event: React.MouseEvent) => void,
|
||||
onMouseLeave?: (event: React.MouseEvent) => void,
|
||||
onLongPress?: () => void,
|
||||
onClick?: (event: React.MouseEvent<HTMLDivElement>) => void,
|
||||
onDoubleClick?: () => void,
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@
|
|||
.link {
|
||||
font-size: 0.9rem;
|
||||
color: var(--primary-accent-color);
|
||||
margin-left: 0.5rem;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
|
|
|
|||
|
|
@ -80,7 +80,6 @@ const Checkbox = React.forwardRef<HTMLInputElement, Props>(({ name, disabled, cl
|
|||
</div>
|
||||
<div>
|
||||
<span>{label}</span>
|
||||
{' '}
|
||||
{
|
||||
href && link ?
|
||||
<Button className={styles['link']} href={href} target={'_blank'} tabIndex={-1}>
|
||||
|
|
|
|||
|
|
@ -1,75 +1,85 @@
|
|||
// Copyright (C) 2017-2023 Smart code 203358507
|
||||
|
||||
const React = require('react');
|
||||
const PropTypes = require('prop-types');
|
||||
const classnames = require('classnames');
|
||||
const AColorPicker = require('a-color-picker');
|
||||
const { useTranslation } = require('react-i18next');
|
||||
const { Button } = require('stremio/components');
|
||||
const ModalDialog = require('stremio/components/ModalDialog');
|
||||
const useBinaryState = require('stremio/common/useBinaryState');
|
||||
const ColorPicker = require('./ColorPicker');
|
||||
const styles = require('./styles');
|
||||
import React, { useCallback, useLayoutEffect, useMemo, useState } from 'react';
|
||||
import classnames from 'classnames';
|
||||
import * as AColorPicker from 'a-color-picker';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from 'stremio/components';
|
||||
import ModalDialog from 'stremio/components/ModalDialog';
|
||||
import useBinaryState from 'stremio/common/useBinaryState';
|
||||
import ColorPicker from './ColorPicker';
|
||||
import styles from './ColorInput.less';
|
||||
|
||||
const parseColor = (value) => {
|
||||
const parseColor = (value: string) => {
|
||||
const color = AColorPicker.parseColor(value, 'hexcss4');
|
||||
return typeof color === 'string' ? color : '#ffffffff';
|
||||
};
|
||||
|
||||
const ColorInput = ({ className, value, dataset, onChange, ...props }) => {
|
||||
type Props = {
|
||||
className: string,
|
||||
value: string,
|
||||
onChange?: (value: string) => void,
|
||||
onClick?: (event: React.MouseEvent) => void,
|
||||
};
|
||||
|
||||
const ColorInput = ({ className, value, onChange, ...props }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const [modalOpen, openModal, closeModal] = useBinaryState(false);
|
||||
const [tempValue, setTempValue] = React.useState(() => {
|
||||
const [tempValue, setTempValue] = useState(() => {
|
||||
return parseColor(value);
|
||||
});
|
||||
const labelButtonStyle = React.useMemo(() => ({
|
||||
|
||||
const labelButtonStyle = useMemo(() => ({
|
||||
backgroundColor: value
|
||||
}), [value]);
|
||||
const isTransparent = React.useMemo(() => {
|
||||
|
||||
const isTransparent = useMemo(() => {
|
||||
return parseColor(value).endsWith('00');
|
||||
}, [value]);
|
||||
const labelButtonOnClick = React.useCallback((event) => {
|
||||
|
||||
const labelButtonOnClick = useCallback((event: React.MouseEvent) => {
|
||||
if (typeof props.onClick === 'function') {
|
||||
props.onClick(event);
|
||||
}
|
||||
|
||||
// @ts-expect-error: Property 'openModalPrevented' does not exist on type 'MouseEvent'.
|
||||
if (!event.nativeEvent.openModalPrevented) {
|
||||
openModal();
|
||||
}
|
||||
}, [props.onClick]);
|
||||
const modalDialogOnClick = React.useCallback((event) => {
|
||||
|
||||
const modalDialogOnClick = useCallback((event: React.MouseEvent) => {
|
||||
// @ts-expect-error: Property 'openModalPrevented' does not exist on type 'MouseEvent'.
|
||||
event.nativeEvent.openModalPrevented = true;
|
||||
}, []);
|
||||
const modalButtons = React.useMemo(() => {
|
||||
const selectButtonOnClick = (event) => {
|
||||
|
||||
const modalButtons = useMemo(() => {
|
||||
const selectButtonOnClick = () => {
|
||||
if (typeof onChange === 'function') {
|
||||
onChange({
|
||||
type: 'change',
|
||||
value: tempValue,
|
||||
dataset: dataset,
|
||||
reactEvent: event,
|
||||
nativeEvent: event.nativeEvent
|
||||
});
|
||||
onChange(tempValue);
|
||||
}
|
||||
|
||||
closeModal();
|
||||
};
|
||||
return [
|
||||
{
|
||||
label: 'Select',
|
||||
label: t('SELECT'),
|
||||
props: {
|
||||
'data-autofocus': true,
|
||||
onClick: selectButtonOnClick
|
||||
}
|
||||
}
|
||||
];
|
||||
}, [tempValue, dataset, onChange]);
|
||||
const colorPickerOnInput = React.useCallback((event) => {
|
||||
setTempValue(parseColor(event.value));
|
||||
}, [tempValue, onChange]);
|
||||
|
||||
const colorPickerOnInput = useCallback((color: string) => {
|
||||
setTempValue(parseColor(color));
|
||||
}, []);
|
||||
React.useLayoutEffect(() => {
|
||||
|
||||
useLayoutEffect(() => {
|
||||
setTempValue(parseColor(value));
|
||||
}, [value, modalOpen]);
|
||||
|
||||
return (
|
||||
<Button title={isTransparent ? t('BUTTON_COLOR_TRANSPARENT') : value} {...props} style={labelButtonStyle} className={classnames(className, styles['color-input-container'])} onClick={labelButtonOnClick}>
|
||||
{
|
||||
|
|
@ -82,7 +92,7 @@ const ColorInput = ({ className, value, dataset, onChange, ...props }) => {
|
|||
}
|
||||
{
|
||||
modalOpen ?
|
||||
<ModalDialog title={'Choose a color:'} buttons={modalButtons} onCloseRequest={closeModal} onClick={modalDialogOnClick}>
|
||||
<ModalDialog title={t('CHOOSE_COLOR')} buttons={modalButtons} onCloseRequest={closeModal} onClick={modalDialogOnClick}>
|
||||
<ColorPicker className={styles['color-picker-container']} value={tempValue} onInput={colorPickerOnInput} />
|
||||
</ModalDialog>
|
||||
:
|
||||
|
|
@ -92,12 +102,4 @@ const ColorInput = ({ className, value, dataset, onChange, ...props }) => {
|
|||
);
|
||||
};
|
||||
|
||||
ColorInput.propTypes = {
|
||||
className: PropTypes.string,
|
||||
value: PropTypes.string,
|
||||
dataset: PropTypes.object,
|
||||
onChange: PropTypes.func,
|
||||
onClick: PropTypes.func
|
||||
};
|
||||
|
||||
module.exports = ColorInput;
|
||||
export default ColorInput;
|
||||
|
|
@ -29,10 +29,7 @@ const ColorPicker = ({ className, value, onInput }) => {
|
|||
React.useLayoutEffect(() => {
|
||||
if (typeof onInput === 'function') {
|
||||
pickerRef.current.on('change', (picker, value) => {
|
||||
onInput({
|
||||
type: 'input',
|
||||
value: parseColor(value)
|
||||
});
|
||||
onInput(parseColor(value));
|
||||
});
|
||||
}
|
||||
return () => {
|
||||
|
|
|
|||
|
|
@ -1,6 +0,0 @@
|
|||
// Copyright (C) 2017-2023 Smart code 203358507
|
||||
|
||||
const ColorInput = require('./ColorInput');
|
||||
|
||||
module.exports = ColorInput;
|
||||
|
||||
6
src/components/ColorInput/index.ts
Normal file
6
src/components/ColorInput/index.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
// Copyright (C) 2017-2023 Smart code 203358507
|
||||
|
||||
import ColorInput from './ColorInput';
|
||||
|
||||
export default ColorInput;
|
||||
|
||||
|
|
@ -65,7 +65,6 @@
|
|||
padding: 0 1rem;
|
||||
|
||||
.icon-container {
|
||||
height: 2rem;
|
||||
width: 2rem;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ const ActionButton = require('./ActionButton');
|
|||
const MetaLinks = require('./MetaLinks');
|
||||
const MetaPreviewPlaceholder = require('./MetaPreviewPlaceholder');
|
||||
const styles = require('./styles');
|
||||
const { Ratings } = require('./Ratings');
|
||||
|
||||
const ALLOWED_LINK_REDIRECTS = [
|
||||
routesRegexp.search.regexp,
|
||||
|
|
@ -24,7 +25,7 @@ const ALLOWED_LINK_REDIRECTS = [
|
|||
routesRegexp.metadetails.regexp
|
||||
];
|
||||
|
||||
const MetaPreview = React.forwardRef(({ className, compact, name, logo, background, runtime, releaseInfo, released, description, deepLinks, links, trailerStreams, inLibrary, toggleInLibrary }, ref) => {
|
||||
const MetaPreview = React.forwardRef(({ className, compact, name, logo, background, runtime, releaseInfo, released, description, deepLinks, links, trailerStreams, inLibrary, toggleInLibrary, ratingInfo }, ref) => {
|
||||
const { t } = useTranslation();
|
||||
const [shareModalOpen, openShareModal, closeShareModal] = useBinaryState(false);
|
||||
const linksGroups = React.useMemo(() => {
|
||||
|
|
@ -232,6 +233,15 @@ const MetaPreview = React.forwardRef(({ className, compact, name, logo, backgrou
|
|||
:
|
||||
null
|
||||
}
|
||||
{
|
||||
!compact && ratingInfo !== null ?
|
||||
<Ratings
|
||||
ratingInfo={ratingInfo}
|
||||
className={styles['ratings']}
|
||||
/>
|
||||
:
|
||||
null
|
||||
}
|
||||
{
|
||||
linksGroups.has(CONSTANTS.SHARE_LINK_CATEGORY) && !compact ?
|
||||
<React.Fragment>
|
||||
|
|
@ -287,7 +297,8 @@ MetaPreview.propTypes = {
|
|||
})),
|
||||
trailerStreams: PropTypes.array,
|
||||
inLibrary: PropTypes.bool,
|
||||
toggleInLibrary: PropTypes.func
|
||||
toggleInLibrary: PropTypes.func,
|
||||
ratingInfo: PropTypes.object,
|
||||
};
|
||||
|
||||
module.exports = MetaPreview;
|
||||
|
|
|
|||
61
src/components/MetaPreview/Ratings/Ratings.less
Normal file
61
src/components/MetaPreview/Ratings/Ratings.less
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
// Copyright (C) 2017-2025 Smart code 203358507
|
||||
|
||||
@import (reference) '~stremio/common/screen-sizes.less';
|
||||
|
||||
@height: 4rem;
|
||||
@width: 4rem;
|
||||
@height-mobile: 3rem;
|
||||
@width-mobile: 3rem;
|
||||
|
||||
|
||||
.ratings-container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
background-color: var(--overlay-color);
|
||||
border-radius: 2rem;
|
||||
height: @height;
|
||||
width: fit-content;
|
||||
|
||||
.icon-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: @height;
|
||||
width: @width;
|
||||
padding: 0 1rem;
|
||||
cursor: pointer;
|
||||
|
||||
.icon {
|
||||
width: calc(@width / 2);
|
||||
height: calc(@height / 2);
|
||||
color: var(--primary-foreground-color);
|
||||
opacity: 0.7;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media @phone-landscape {
|
||||
.ratings-container {
|
||||
height: @height-mobile;
|
||||
|
||||
.icon-container {
|
||||
height: @height-mobile;
|
||||
width: @width-mobile;
|
||||
|
||||
.icon {
|
||||
width: 1.75rem;
|
||||
height: 1.75rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
31
src/components/MetaPreview/Ratings/Ratings.tsx
Normal file
31
src/components/MetaPreview/Ratings/Ratings.tsx
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
// Copyright (C) 2017-2025 Smart code 203358507
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
import useRating from './useRating';
|
||||
import styles from './Ratings.less';
|
||||
import Icon from '@stremio/stremio-icons/react';
|
||||
import classNames from 'classnames';
|
||||
|
||||
type Props = {
|
||||
metaId?: string;
|
||||
ratingInfo?: Loadable<RatingInfo>;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const Ratings = ({ ratingInfo, className }: Props) => {
|
||||
const { onLiked, onLoved, liked, loved } = useRating(ratingInfo);
|
||||
const disabled = useMemo(() => ratingInfo?.type !== 'Ready', [ratingInfo]);
|
||||
|
||||
return (
|
||||
<div className={classNames(styles['ratings-container'], className)}>
|
||||
<div className={classNames(styles['icon-container'], { [styles['disabled']]: disabled })} onClick={onLiked}>
|
||||
<Icon name={liked ? 'thumbs-up' : 'thumbs-up-outline'} className={styles['icon']} />
|
||||
</div>
|
||||
<div className={classNames(styles['icon-container'], { [styles['disabled']]: disabled })} onClick={onLoved}>
|
||||
<Icon name={loved ? 'heart' : 'heart-outline'} className={styles['icon']} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Ratings;
|
||||
5
src/components/MetaPreview/Ratings/index.ts
Normal file
5
src/components/MetaPreview/Ratings/index.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
// Copyright (C) 2017-2025 Smart code 203358507
|
||||
|
||||
import Ratings from './Ratings';
|
||||
|
||||
export { Ratings };
|
||||
48
src/components/MetaPreview/Ratings/useRating.ts
Normal file
48
src/components/MetaPreview/Ratings/useRating.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
// Copyright (C) 2017-2025 Smart code 203358507
|
||||
|
||||
import { useMemo, useCallback } from 'react';
|
||||
import { useServices } from 'stremio/services';
|
||||
|
||||
const useRating = (ratingInfo?: Loadable<RatingInfo>) => {
|
||||
const { core } = useServices();
|
||||
|
||||
const setRating = useCallback((status: Rating) => {
|
||||
core.transport.dispatch({
|
||||
action: 'MetaDetails',
|
||||
args: {
|
||||
action: 'Rate',
|
||||
args: status,
|
||||
},
|
||||
});
|
||||
}, []);
|
||||
|
||||
const status = useMemo(() => {
|
||||
const content = ratingInfo?.type === 'Ready' ? ratingInfo.content as RatingInfo : null;
|
||||
return content?.status;
|
||||
}, [ratingInfo]);
|
||||
|
||||
const liked = useMemo(() => {
|
||||
return status === 'liked';
|
||||
}, [status]);
|
||||
|
||||
const loved = useMemo(() => {
|
||||
return status === 'loved';
|
||||
}, [status]);
|
||||
|
||||
const onLiked = useCallback(() => {
|
||||
setRating(status === 'liked' ? null : 'liked');
|
||||
}, [status]);
|
||||
|
||||
const onLoved = useCallback(() => {
|
||||
setRating(status === 'loved' ? null : 'loved');
|
||||
}, [status]);
|
||||
|
||||
return {
|
||||
onLiked,
|
||||
onLoved,
|
||||
liked,
|
||||
loved,
|
||||
};
|
||||
};
|
||||
|
||||
export default useRating;
|
||||
|
|
@ -159,7 +159,6 @@
|
|||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: flex-end;
|
||||
max-height: 15rem;
|
||||
flex-wrap: wrap;
|
||||
padding-top: 3.5rem;
|
||||
overflow: visible;
|
||||
|
|
@ -209,6 +208,11 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ratings {
|
||||
margin-bottom: 1rem;
|
||||
margin-right: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.share-prompt {
|
||||
|
|
@ -236,6 +240,10 @@
|
|||
border-radius: 2rem;
|
||||
}
|
||||
}
|
||||
|
||||
.ratings {
|
||||
margin-right: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
// Copyright (C) 2017-2023 Smart code 203358507
|
||||
|
||||
const React = require('react');
|
||||
const { useTranslation } = require('react-i18next');
|
||||
const PropTypes = require('prop-types');
|
||||
const classnames = require('classnames');
|
||||
const { useRouteFocused, useModalsContainer } = require('stremio-router');
|
||||
|
|
@ -10,6 +11,7 @@ const { Modal } = require('stremio-router');
|
|||
const styles = require('./styles');
|
||||
|
||||
const ModalDialog = ({ className, title, buttons, children, dataset, onCloseRequest, background, ...props }) => {
|
||||
const { t } = useTranslation();
|
||||
const routeFocused = useRouteFocused();
|
||||
const modalsContainer = useModalsContainer();
|
||||
const modalContainerRef = React.useRef(null);
|
||||
|
|
@ -60,7 +62,7 @@ const ModalDialog = ({ className, title, buttons, children, dataset, onCloseRequ
|
|||
<Modal ref={modalContainerRef} {...props} className={classnames(className, styles['modal-container'])} onMouseDown={onModalContainerMouseDown}>
|
||||
<div className={styles['modal-dialog-container']} onMouseDown={onModalDialogContainerMouseDown}>
|
||||
<div className={styles['modal-dialog-background']} style={{backgroundImage: `url('${background}')`}} />
|
||||
<Button className={styles['close-button-container']} title={'Close'} onClick={closeButtonOnClick}>
|
||||
<Button className={styles['close-button-container']} title={t('BUTTON_CLOSE')} onClick={closeButtonOnClick}>
|
||||
<Icon className={styles['icon']} name={'close'} />
|
||||
</Button>
|
||||
<div className={styles['modal-dialog-content']}>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
// Copyright (C) 2017-2023 Smart code 203358507
|
||||
|
||||
const React = require('react');
|
||||
const { useTranslation } = require('react-i18next');
|
||||
const PropTypes = require('prop-types');
|
||||
const classnames = require('classnames');
|
||||
const { default: Icon } = require('@stremio/stremio-icons/react');
|
||||
|
|
@ -11,6 +12,7 @@ const useBinaryState = require('stremio/common/useBinaryState');
|
|||
const styles = require('./styles');
|
||||
|
||||
const Multiselect = ({ className, mode, direction, title, disabled, dataset, options, renderLabelContent, renderLabelText, onOpen, onClose, onSelect, ...props }) => {
|
||||
const { t } = useTranslation();
|
||||
const [menuOpen, , closeMenu, toggleMenu] = useBinaryState(false);
|
||||
const filteredOptions = React.useMemo(() => {
|
||||
return Array.isArray(options) ?
|
||||
|
|
@ -122,7 +124,7 @@ const Multiselect = ({ className, mode, direction, title, disabled, dataset, opt
|
|||
))
|
||||
:
|
||||
<div className={styles['no-options-container']}>
|
||||
<div className={styles['label']}>No options available</div>
|
||||
<div className={styles['label']}>{t('NO_OPTIONS')}</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -50,7 +50,7 @@
|
|||
|
||||
.modal-container, .popup-menu-container {
|
||||
.menu-container {
|
||||
max-height: calc(3.2rem * 7);
|
||||
max-height: calc(3rem * 7);
|
||||
|
||||
.option-container {
|
||||
display: flex;
|
||||
|
|
|
|||
|
|
@ -2,7 +2,8 @@
|
|||
|
||||
@import (reference) '~stremio/common/screen-sizes.less';
|
||||
|
||||
@parent-height: 10rem;
|
||||
@parent-height: 12rem;
|
||||
@item-height: 3rem;
|
||||
|
||||
.dropdown {
|
||||
background: var(--modal-background-color);
|
||||
|
|
@ -18,7 +19,7 @@
|
|||
|
||||
&.open {
|
||||
display: block;
|
||||
max-height: calc(3.3rem * 7);
|
||||
max-height: calc(@item-height * 7);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -10,11 +10,11 @@ import styles from './Dropdown.less';
|
|||
|
||||
type Props = {
|
||||
options: MultiselectMenuOption[];
|
||||
value?: string | number;
|
||||
value?: any;
|
||||
menuOpen: boolean | (() => void);
|
||||
level: number;
|
||||
setLevel: (level: number) => void;
|
||||
onSelect: (value: string | number) => void;
|
||||
onSelect: (value: any) => void;
|
||||
};
|
||||
|
||||
const Dropdown = ({ level, setLevel, options, onSelect, value, menuOpen }: Props) => {
|
||||
|
|
@ -24,7 +24,7 @@ const Dropdown = ({ level, setLevel, options, onSelect, value, menuOpen }: Props
|
|||
|
||||
const selectedOption = options.find((opt) => opt.value === value);
|
||||
|
||||
const handleSetOptionRef = useCallback((optionValue: string | number) => (node: HTMLButtonElement | null) => {
|
||||
const handleSetOptionRef = useCallback((optionValue: any) => (node: HTMLButtonElement | null) => {
|
||||
if (node) {
|
||||
optionsRef.current.set(optionValue, node);
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
// Copyright (C) 2017-2024 Smart code 203358507
|
||||
|
||||
@height: 3rem;
|
||||
|
||||
.option {
|
||||
height: @height;
|
||||
font-size: var(--font-size-normal);
|
||||
color: var(--primary-foreground-color);
|
||||
align-items: center;
|
||||
|
|
|
|||
|
|
@ -8,8 +8,8 @@ import Icon from '@stremio/stremio-icons/react';
|
|||
|
||||
type Props = {
|
||||
option: MultiselectMenuOption;
|
||||
selectedValue?: string | number;
|
||||
onSelect: (value: string | number) => void;
|
||||
selectedValue?: any;
|
||||
onSelect: (value: any) => void;
|
||||
};
|
||||
|
||||
const Option = forwardRef<HTMLButtonElement, Props>(({ option, selectedValue, onSelect }, ref) => {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
// Copyright (C) 2017-2024 Smart code 203358507
|
||||
|
||||
@border-radius: 2.75rem;
|
||||
@height: 3rem;
|
||||
|
||||
.multiselect-menu {
|
||||
position: relative;
|
||||
|
|
@ -14,6 +15,7 @@
|
|||
}
|
||||
|
||||
.multiselect-button {
|
||||
height: @height;
|
||||
padding: 0.75rem 1.5rem;
|
||||
display: flex;
|
||||
flex: 1;
|
||||
|
|
|
|||
|
|
@ -11,13 +11,14 @@ import useOutsideClick from 'stremio/common/useOutsideClick';
|
|||
|
||||
type Props = {
|
||||
className?: string,
|
||||
title?: string | (() => string);
|
||||
title?: string | (() => string | null);
|
||||
options: MultiselectMenuOption[];
|
||||
value?: string | number;
|
||||
onSelect: (value: string | number) => void;
|
||||
value?: any;
|
||||
disabled?: boolean,
|
||||
onSelect: (value: any) => void;
|
||||
};
|
||||
|
||||
const MultiselectMenu = ({ className, title, options, value, onSelect }: Props) => {
|
||||
const MultiselectMenu = ({ className, title, options, value, disabled, onSelect }: Props) => {
|
||||
const [menuOpen, , closeMenu, toggleMenu] = useBinaryState(false);
|
||||
const multiselectMenuRef = useOutsideClick(() => closeMenu());
|
||||
const [level, setLevel] = React.useState<number>(0);
|
||||
|
|
@ -32,6 +33,7 @@ const MultiselectMenu = ({ className, title, options, value, onSelect }: Props)
|
|||
<div className={classNames(styles['multiselect-menu'], { [styles['active']]: menuOpen }, className)} ref={multiselectMenuRef}>
|
||||
<Button
|
||||
className={classNames(styles['multiselect-button'], { [styles['open']]: menuOpen })}
|
||||
disabled={disabled}
|
||||
onClick={toggleMenu}
|
||||
tabIndex={0}
|
||||
aria-haspopup='listbox'
|
||||
|
|
|
|||
2
src/components/MultiselectMenu/types.d.ts
vendored
2
src/components/MultiselectMenu/types.d.ts
vendored
|
|
@ -1,7 +1,7 @@
|
|||
type MultiselectMenuOption = {
|
||||
id?: number;
|
||||
label: string;
|
||||
value: number;
|
||||
value: string | number | null;
|
||||
destination?: string;
|
||||
default?: boolean;
|
||||
hidden?: boolean;
|
||||
|
|
|
|||
|
|
@ -70,7 +70,7 @@ const SharePrompt = ({ className, url }) => {
|
|||
onClick={selectInputContent}
|
||||
tabIndex={-1}
|
||||
/>
|
||||
<Button className={styles['copy-button']} title={'Copy to clipboard'} onClick={copyToClipboard}>
|
||||
<Button className={styles['copy-button']} title={t('CTX_COPY_TO_CLIPBOARD')} onClick={copyToClipboard}>
|
||||
<Icon className={styles['icon']} name={'link'} />
|
||||
<div className={styles['label']}>{ t('COPY') }</div>
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -1,26 +0,0 @@
|
|||
// Copyright (C) 2017-2023 Smart code 203358507
|
||||
|
||||
const React = require('react');
|
||||
const PropTypes = require('prop-types');
|
||||
const classnames = require('classnames');
|
||||
const { Button } = require('stremio/components');
|
||||
const styles = require('./styles');
|
||||
|
||||
const Toggle = React.forwardRef(({ className, checked, children, ...props }, ref) => {
|
||||
return (
|
||||
<Button {...props} ref={ref} className={classnames(className, styles['toggle-container'], { 'checked': checked })}>
|
||||
<div className={styles['toggle']} />
|
||||
{children}
|
||||
</Button>
|
||||
);
|
||||
});
|
||||
|
||||
Toggle.displayName = 'Toggle';
|
||||
|
||||
Toggle.propTypes = {
|
||||
className: PropTypes.string,
|
||||
checked: PropTypes.bool,
|
||||
children: PropTypes.node
|
||||
};
|
||||
|
||||
module.exports = Toggle;
|
||||
27
src/components/Toggle/Toggle.tsx
Normal file
27
src/components/Toggle/Toggle.tsx
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
// Copyright (C) 2017-2023 Smart code 203358507
|
||||
|
||||
import React, { forwardRef } from 'react';
|
||||
import classnames from 'classnames';
|
||||
import { Button } from 'stremio/components';
|
||||
import styles from './Toggle.less';
|
||||
|
||||
type Props = {
|
||||
className?: string,
|
||||
checked: boolean,
|
||||
disabled?: boolean,
|
||||
tabIndex?: number,
|
||||
children?: React.ReactNode,
|
||||
};
|
||||
|
||||
const Toggle = forwardRef(({ className, checked, children, ...props }: Props, ref) => {
|
||||
return (
|
||||
<Button {...props} ref={ref} className={classnames(className, styles['toggle-container'], { 'checked': checked })}>
|
||||
<div className={styles['toggle']} />
|
||||
{children}
|
||||
</Button>
|
||||
);
|
||||
});
|
||||
|
||||
Toggle.displayName = 'Toggle';
|
||||
|
||||
export default Toggle;
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
// Copyright (C) 2017-2023 Smart code 203358507
|
||||
|
||||
const Toggle = require('./Toggle');
|
||||
|
||||
module.exports = Toggle;
|
||||
5
src/components/Toggle/index.ts
Normal file
5
src/components/Toggle/index.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
// Copyright (C) 2017-2023 Smart code 203358507
|
||||
|
||||
import Toggle from './Toggle';
|
||||
|
||||
export default Toggle;
|
||||
|
|
@ -1,9 +1,9 @@
|
|||
// Copyright (C) 2017-2023 Smart code 203358507
|
||||
|
||||
const React = require('react');
|
||||
const { useTranslation } = require('react-i18next');
|
||||
const PropTypes = require('prop-types');
|
||||
const classnames = require('classnames');
|
||||
const { t } = require('i18next');
|
||||
const { useRouteFocused } = require('stremio-router');
|
||||
const { default: Icon } = require('@stremio/stremio-icons/react');
|
||||
const { Button, Image, Popup } = require('stremio/components');
|
||||
|
|
@ -12,9 +12,10 @@ const useProfile = require('stremio/common/useProfile');
|
|||
const VideoPlaceholder = require('./VideoPlaceholder');
|
||||
const styles = require('./styles');
|
||||
|
||||
const Video = ({ className, id, title, thumbnail, season, episode, released, upcoming, watched, progress, scheduled, seasonWatched, deepLinks, onMarkVideoAsWatched, onMarkSeasonAsWatched, ...props }) => {
|
||||
const Video = React.forwardRef(({ className, id, title, thumbnail, season, episode, released, upcoming, watched, progress, scheduled, seasonWatched, deepLinks, onMarkVideoAsWatched, onMarkSeasonAsWatched, ...props }, ref) => {
|
||||
const routeFocused = useRouteFocused();
|
||||
const profile = useProfile();
|
||||
const { t } = useTranslation();
|
||||
const [menuOpen, , closeMenu, toggleMenu] = useBinaryState(false);
|
||||
const popupLabelOnMouseUp = React.useCallback((event) => {
|
||||
if (!event.nativeEvent.togglePopupPrevented) {
|
||||
|
|
@ -67,10 +68,27 @@ const Video = ({ className, id, title, thumbnail, season, episode, released, upc
|
|||
}
|
||||
}
|
||||
}, [deepLinks]);
|
||||
const renderLabel = React.useMemo(() => function renderLabel({ className, id, title, thumbnail, episode, released, upcoming, watched, progress, scheduled, children, ...props }) {
|
||||
const renderLabel = React.useMemo(() => function renderLabel({ className, id, title, thumbnail, episode, released, upcoming, watched, progress, scheduled, children, ref: popupRef, ...props }) {
|
||||
const blurThumbnail = profile.settings.hideSpoilers && season && episode && !watched;
|
||||
const handleRef = React.useCallback((node) => {
|
||||
if (popupRef) {
|
||||
if (typeof popupRef === 'function') {
|
||||
popupRef(node);
|
||||
} else {
|
||||
popupRef.current = node;
|
||||
}
|
||||
}
|
||||
if (ref) {
|
||||
if (typeof ref === 'function') {
|
||||
ref(node);
|
||||
} else {
|
||||
ref.current = node;
|
||||
}
|
||||
}
|
||||
}, [popupRef]);
|
||||
|
||||
return (
|
||||
<Button {...props} className={classnames(className, styles['video-container'])} title={title}>
|
||||
<Button {...props} className={classnames(className, styles['video-container'])} title={title} ref={handleRef}>
|
||||
{
|
||||
typeof thumbnail === 'string' && thumbnail.length > 0 ?
|
||||
<div className={styles['thumbnail-container']}>
|
||||
|
|
@ -107,12 +125,12 @@ const Video = ({ className, id, title, thumbnail, season, episode, released, upc
|
|||
{
|
||||
released instanceof Date && !isNaN(released.getTime()) ?
|
||||
<div className={styles['released-container']}>
|
||||
{released.toLocaleString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })}
|
||||
{released.toLocaleString(profile.settings.interfaceLanguage, { year: 'numeric', month: 'short', day: 'numeric' })}
|
||||
</div>
|
||||
:
|
||||
scheduled ?
|
||||
<div className={styles['released-container']} title={'To be announced'}>
|
||||
TBA
|
||||
<div className={styles['released-container']} title={t('TBA')}>
|
||||
{t('TBA')}
|
||||
</div>
|
||||
:
|
||||
null
|
||||
|
|
@ -121,7 +139,7 @@ const Video = ({ className, id, title, thumbnail, season, episode, released, upc
|
|||
{
|
||||
upcoming && !watched ?
|
||||
<div className={styles['upcoming-container']}>
|
||||
<div className={styles['flag-label']}>Upcoming</div>
|
||||
<div className={styles['flag-label']}>{t('UPCOMING')}</div>
|
||||
</div>
|
||||
:
|
||||
null
|
||||
|
|
@ -130,7 +148,7 @@ const Video = ({ className, id, title, thumbnail, season, episode, released, upc
|
|||
watched ?
|
||||
<div className={styles['watched-container']}>
|
||||
<Icon className={styles['flag-icon']} name={'eye'} />
|
||||
<div className={styles['flag-label']}>Watched</div>
|
||||
<div className={styles['flag-label']}>{t('CTX_WATCHED')}</div>
|
||||
</div>
|
||||
:
|
||||
null
|
||||
|
|
@ -145,10 +163,10 @@ const Video = ({ className, id, title, thumbnail, season, episode, released, upc
|
|||
const renderMenu = React.useMemo(() => function renderMenu() {
|
||||
return (
|
||||
<div className={styles['context-menu-content']} onPointerDown={popupMenuOnPointerDown} onContextMenu={popupMenuOnContextMenu} onClick={popupMenuOnClick} onKeyDown={popupMenuOnKeyDown}>
|
||||
<Button className={styles['context-menu-option-container']} title={'Watch'}>
|
||||
<Button className={styles['context-menu-option-container']} title={t('CTX_WATCH')}>
|
||||
<div className={styles['context-menu-option-label']}>{t('CTX_WATCH')}</div>
|
||||
</Button>
|
||||
<Button className={styles['context-menu-option-container']} title={watched ? 'Mark as non-watched' : 'Mark as watched'} onClick={toggleWatchedOnClick}>
|
||||
<Button className={styles['context-menu-option-container']} title={watched ? t('CTX_MARK_NON_WATCHED') : t('CTX_MARK_WATCHED')} onClick={toggleWatchedOnClick}>
|
||||
<div className={styles['context-menu-option-label']}>{watched ? t('CTX_MARK_NON_WATCHED') : t('CTX_MARK_WATCHED')}</div>
|
||||
</Button>
|
||||
<Button className={styles['context-menu-option-container']} title={seasonWatched ? t('CTX_UNMARK_REST') : t('CTX_MARK_REST')} onClick={toggleWatchedSeasonOnClick}>
|
||||
|
|
@ -185,7 +203,7 @@ const Video = ({ className, id, title, thumbnail, season, episode, released, upc
|
|||
renderMenu={renderMenu}
|
||||
/>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
Video.Placeholder = VideoPlaceholder;
|
||||
|
||||
|
|
|
|||
2
src/modules.d.ts
vendored
2
src/modules.d.ts
vendored
|
|
@ -3,4 +3,6 @@ declare module '*.less' {
|
|||
export = resource;
|
||||
}
|
||||
|
||||
declare module 'stremio-router';
|
||||
declare module 'stremio/components/NavBar';
|
||||
declare module 'stremio/components/ModalDialog';
|
||||
|
|
|
|||
|
|
@ -89,7 +89,7 @@ const Addon = ({ className, id, name, version, logo, description, types, behavio
|
|||
</div>
|
||||
{
|
||||
typeof version === 'string' && version.length > 0 ?
|
||||
<div className={styles['version-container']} title={`v.${version}`}>v.{version}</div>
|
||||
<div className={styles['version-container']} title={t('ADDON_VERSION_SHORT', {version})}>{t('ADDON_VERSION_SHORT', {version})}</div>
|
||||
:
|
||||
null
|
||||
}
|
||||
|
|
|
|||
|
|
@ -124,7 +124,7 @@ const Addons = ({ urlParams, queryParams }) => {
|
|||
value={search}
|
||||
onChange={searchInputOnChange}
|
||||
/>
|
||||
<Button className={styles['filter-button']} title={'All filters'} onClick={openFiltersModal}>
|
||||
<Button className={styles['filter-button']} title={t('ALL_FILTERS')} onClick={openFiltersModal}>
|
||||
<Icon className={styles['filter-icon']} name={'filters'} />
|
||||
</Button>
|
||||
</div>
|
||||
|
|
@ -132,12 +132,12 @@ const Addons = ({ urlParams, queryParams }) => {
|
|||
installedAddons.selected !== null ?
|
||||
installedAddons.selectable.types.length === 0 ?
|
||||
<div className={styles['message-container']}>
|
||||
No addons ware installed!
|
||||
{t('NO_ADDONS')}
|
||||
</div>
|
||||
:
|
||||
installedAddons.catalog.length === 0 ?
|
||||
<div className={styles['message-container']}>
|
||||
No addons ware installed for that type!
|
||||
{t('NO_ADDONS_FOR_TYPE')}
|
||||
</div>
|
||||
:
|
||||
<div className={styles['addons-list-container']}>
|
||||
|
|
@ -216,7 +216,7 @@ const Addons = ({ urlParams, queryParams }) => {
|
|||
</div>
|
||||
{
|
||||
filtersModalOpen ?
|
||||
<ModalDialog title={'Addons filters'} className={styles['filters-modal']} onCloseRequest={closeFiltersModal}>
|
||||
<ModalDialog title={t('ADDONS_FILTERS')} className={styles['filters-modal']} onCloseRequest={closeFiltersModal}>
|
||||
{selectInputs.map((selectInput, index) => (
|
||||
<MultiselectMenu
|
||||
{...selectInput}
|
||||
|
|
@ -265,7 +265,7 @@ const Addons = ({ urlParams, queryParams }) => {
|
|||
<span className={styles['name']}>{typeof sharedAddon.manifest.name === 'string' && sharedAddon.manifest.name.length > 0 ? sharedAddon.manifest.name : sharedAddon.manifest.id}</span>
|
||||
{
|
||||
typeof sharedAddon.manifest.version === 'string' && sharedAddon.manifest.version.length > 0 ?
|
||||
<span className={styles['version']}>v. {sharedAddon.manifest.version}</span>
|
||||
<span className={styles['version']}>{t('ADDON_VERSION_SHORT', { version: sharedAddon.manifest.version })}</span>
|
||||
:
|
||||
null
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,8 +10,8 @@ const mapSelectableInputs = (installedAddons, remoteAddons, t) => {
|
|||
.concat(installedAddons.selectable.catalogs)
|
||||
.map(({ name, deepLinks }) => ({
|
||||
value: deepLinks.addons,
|
||||
label: t.stringWithPrefix(name, 'ADDON_'),
|
||||
title: t.stringWithPrefix(name, 'ADDON_'),
|
||||
label: t.stringWithPrefix(name.toUpperCase(), 'ADDON_'),
|
||||
title: t.stringWithPrefix(name.toUpperCase(), 'ADDON_'),
|
||||
})),
|
||||
value: selectedCatalog ? selectedCatalog.deepLinks.addons : undefined,
|
||||
title: remoteAddons.selected !== null ?
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
// Copyright (C) 2017-2024 Smart code 203358507
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import Icon from '@stremio/stremio-icons/react';
|
||||
import { Button } from 'stremio/components';
|
||||
import styles from './Details.less';
|
||||
|
|
@ -11,6 +12,7 @@ type Props = {
|
|||
};
|
||||
|
||||
const Details = ({ selected, items }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const videos = useMemo(() => {
|
||||
return items.find(({ date }) => date.day === selected?.day)?.items ?? [];
|
||||
}, [selected, items]);
|
||||
|
|
@ -33,7 +35,7 @@ const Details = ({ selected, items }: Props) => {
|
|||
{
|
||||
!videos.length ?
|
||||
<div className={styles['placeholder']}>
|
||||
No new episodes for this day
|
||||
{t('CALENDAR_NO_NEW_EPISODES')}
|
||||
</div>
|
||||
:
|
||||
null
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
// Copyright (C) 2017-2023 Smart code 203358507
|
||||
|
||||
const React = require('react');
|
||||
const { useTranslation } = require('react-i18next');
|
||||
const PropTypes = require('prop-types');
|
||||
const classnames = require('classnames');
|
||||
const { default: Icon } = require('@stremio/stremio-icons/react');
|
||||
|
|
@ -14,6 +15,7 @@ const styles = require('./styles');
|
|||
const SCROLL_TO_BOTTOM_THRESHOLD = 400;
|
||||
|
||||
const Discover = ({ urlParams, queryParams }) => {
|
||||
const { t } = useTranslation();
|
||||
const { core } = useServices();
|
||||
const [discover, loadNextPage] = useDiscover(urlParams, queryParams);
|
||||
const [selectInputs, hasNextPage] = useSelectableInputs(discover);
|
||||
|
|
@ -111,7 +113,7 @@ const Discover = ({ urlParams, queryParams }) => {
|
|||
/>
|
||||
))}
|
||||
<div className={styles['filter-container']}>
|
||||
<Button className={styles['filter-button']} title={'All filters'} onClick={openInputsModal}>
|
||||
<Button className={styles['filter-button']} title={t('ALL_FILTERS')} onClick={openInputsModal}>
|
||||
<Icon className={styles['filter-icon']} name={'filters'} />
|
||||
</Button>
|
||||
</div>
|
||||
|
|
@ -119,9 +121,9 @@ const Discover = ({ urlParams, queryParams }) => {
|
|||
{
|
||||
discover.catalog !== null && !discover.catalog.installed ?
|
||||
<div className={styles['missing-addon-warning-container']}>
|
||||
<div className={styles['warning-label']}>Addon is not installed. Install now?</div>
|
||||
<Button className={styles['install-button']} title={'Install addon'} onClick={openAddonModal}>
|
||||
<div className={styles['label']}>Install</div>
|
||||
<div className={styles['warning-label']}>{t('ERR_ADDON_NOT_INSTALLED')}</div>
|
||||
<Button className={styles['install-button']} title={t('INSTALL_ADDON')} onClick={openAddonModal}>
|
||||
<div className={styles['label']}>{t('ADDON_INSTALL')}</div>
|
||||
</Button>
|
||||
</div>
|
||||
:
|
||||
|
|
@ -132,7 +134,7 @@ const Discover = ({ urlParams, queryParams }) => {
|
|||
<DelayedRenderer delay={500}>
|
||||
<div className={styles['message-container']}>
|
||||
<Image className={styles['image']} src={require('/images/empty.png')} alt={' '} />
|
||||
<div className={styles['message-label']}>No catalog selected!</div>
|
||||
<div className={styles['message-label']}>{t('NO_CATALOG_SELECTED')}</div>
|
||||
</div>
|
||||
</DelayedRenderer>
|
||||
:
|
||||
|
|
@ -191,6 +193,8 @@ const Discover = ({ urlParams, queryParams }) => {
|
|||
trailerStreams={selectedMetaItem.trailerStreams}
|
||||
inLibrary={selectedMetaItem.inLibrary}
|
||||
toggleInLibrary={selectedMetaItem.inLibrary ? removeFromLibrary : addToLibrary}
|
||||
metaId={selectedMetaItem.id}
|
||||
like={selectedMetaItem.like}
|
||||
/>
|
||||
:
|
||||
discover.catalog !== null && discover.catalog.content.type === 'Loading' ?
|
||||
|
|
@ -201,7 +205,7 @@ const Discover = ({ urlParams, queryParams }) => {
|
|||
</div>
|
||||
{
|
||||
inputsModalOpen ?
|
||||
<ModalDialog title={'Catalog filters'} className={styles['selectable-inputs-modal']} onCloseRequest={closeInputsModal}>
|
||||
<ModalDialog title={t('CATALOG_FILTERS')} className={styles['selectable-inputs-modal']} onCloseRequest={closeInputsModal}>
|
||||
{selectInputs.map(({ title, options, value, onSelect }, index) => (
|
||||
<MultiselectMenu
|
||||
key={index}
|
||||
|
|
|
|||
|
|
@ -228,7 +228,7 @@
|
|||
overflow: visible !important;
|
||||
|
||||
.select-input {
|
||||
height: 3.5rem;
|
||||
height: 3rem;
|
||||
display: none;
|
||||
|
||||
&:nth-child(n+4) {
|
||||
|
|
@ -240,7 +240,7 @@
|
|||
}
|
||||
|
||||
.multiselect-menu-container {
|
||||
max-height: calc(3.2rem * 3);
|
||||
max-height: calc(3rem * 3);
|
||||
overflow: auto;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,7 +21,6 @@ const mapSelectableInputs = (discover, t) => {
|
|||
window.location = value;
|
||||
}
|
||||
};
|
||||
const selectedCatalog = discover.selectable.catalogs.find(({ selected }) => selected);
|
||||
const catalogSelect = {
|
||||
options: discover.selectable.catalogs
|
||||
.map(({ id, name, addon, deepLinks }) => ({
|
||||
|
|
@ -29,9 +28,9 @@ const mapSelectableInputs = (discover, t) => {
|
|||
label: t.catalogTitle({ addon, id, name }),
|
||||
title: `${name} (${addon.manifest.name})`
|
||||
})),
|
||||
value: discover.selected?.request.path.id
|
||||
? selectedCatalog.deepLinks.discover
|
||||
: undefined,
|
||||
value: discover.selectable.catalogs
|
||||
.filter(({ selected }) => selected)
|
||||
.map(({ deepLinks }) => deepLinks.discover),
|
||||
title: discover.selected !== null
|
||||
? () => {
|
||||
const selectableCatalog = discover.selectable.catalogs
|
||||
|
|
@ -49,7 +48,7 @@ const mapSelectableInputs = (discover, t) => {
|
|||
return {
|
||||
isRequired: isRequired,
|
||||
options: options.map(({ value, deepLinks }) => ({
|
||||
label: typeof value === 'string' ? t.stringWithPrefix(value) : t.string('NONE'),
|
||||
label: typeof value === 'string' ? t.string(value) : t.string('NONE'),
|
||||
value: JSON.stringify({
|
||||
href: deepLinks.discover,
|
||||
value
|
||||
|
|
@ -60,8 +59,8 @@ const mapSelectableInputs = (discover, t) => {
|
|||
value: selectedExtra.value,
|
||||
}),
|
||||
title: options.some(({ selected, value }) => selected && value === null) ?
|
||||
() => t.stringWithPrefix(name, 'SELECT_')
|
||||
: t.stringWithPrefix(selectedExtra.value),
|
||||
() => t.string(name.toUpperCase())
|
||||
: t.string(selectedExtra.value),
|
||||
onSelect: (value) => {
|
||||
const { href } = JSON.parse(value);
|
||||
window.location = href;
|
||||
|
|
|
|||
|
|
@ -299,10 +299,10 @@ const Intro = ({ queryParams }) => {
|
|||
<Image className={styles['logo']} src={require('/images/logo.png')} alt={' '} />
|
||||
</div>
|
||||
<div className={styles['title-container']}>
|
||||
Freedom to Stream
|
||||
{t('WEBSITE_SLOGAN_NEW_NEW')}
|
||||
</div>
|
||||
<div className={styles['slogan-container']}>
|
||||
All the Video Content You Enjoy in One Place
|
||||
{t('WEBSITE_SLOGAN_ALL')}
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles['content-container']}>
|
||||
|
|
@ -311,7 +311,7 @@ const Intro = ({ queryParams }) => {
|
|||
ref={emailRef}
|
||||
className={styles['credentials-text-input']}
|
||||
type={'email'}
|
||||
placeholder={'Email'}
|
||||
placeholder={t('EMAIL')}
|
||||
value={state.email}
|
||||
onChange={emailOnChange}
|
||||
onSubmit={emailOnSubmit}
|
||||
|
|
@ -320,7 +320,7 @@ const Intro = ({ queryParams }) => {
|
|||
ref={passwordRef}
|
||||
className={styles['credentials-text-input']}
|
||||
type={'password'}
|
||||
placeholder={'Password'}
|
||||
placeholder={t('PASSWORD')}
|
||||
value={state.password}
|
||||
onChange={passwordOnChange}
|
||||
onSubmit={passwordOnSubmit}
|
||||
|
|
@ -332,37 +332,37 @@ const Intro = ({ queryParams }) => {
|
|||
ref={confirmPasswordRef}
|
||||
className={styles['credentials-text-input']}
|
||||
type={'password'}
|
||||
placeholder={'Confirm Password'}
|
||||
placeholder={t('PASSWORD_CONFIRM')}
|
||||
value={state.confirmPassword}
|
||||
onChange={confirmPasswordOnChange}
|
||||
onSubmit={confirmPasswordOnSubmit}
|
||||
/>
|
||||
<Checkbox
|
||||
ref={termsRef}
|
||||
label={'I have read and agree with the Stremio'}
|
||||
link={'Terms and conditions'}
|
||||
label={t('READ_AND_AGREE')}
|
||||
link={t('TOS')}
|
||||
href={'https://www.stremio.com/tos'}
|
||||
checked={state.termsAccepted}
|
||||
onChange={toggleTermsAccepted}
|
||||
/>
|
||||
<Checkbox
|
||||
ref={privacyPolicyRef}
|
||||
label={'I have read and agree with the Stremio'}
|
||||
link={'Privacy Policy'}
|
||||
label={t('READ_AND_AGREE')}
|
||||
link={t('PRIVACY_POLICY')}
|
||||
href={'https://www.stremio.com/privacy'}
|
||||
checked={state.privacyPolicyAccepted}
|
||||
onChange={togglePrivacyPolicyAccepted}
|
||||
/>
|
||||
<Checkbox
|
||||
ref={marketingRef}
|
||||
label={'I agree to receive marketing communications from Stremio'}
|
||||
label={t('MARKETING_AGREE')}
|
||||
checked={state.marketingAccepted}
|
||||
onChange={toggleMarketingAccepted}
|
||||
/>
|
||||
</React.Fragment>
|
||||
:
|
||||
<div className={styles['forgot-password-link-container']}>
|
||||
<Button className={styles['forgot-password-link']} onClick={openPasswordRestModal}>Forgot password?</Button>
|
||||
<Button className={styles['forgot-password-link']} onClick={openPasswordRestModal}>{t('FORGOT_PASSWORD')}</Button>
|
||||
</div>
|
||||
}
|
||||
{
|
||||
|
|
@ -372,22 +372,22 @@ const Intro = ({ queryParams }) => {
|
|||
null
|
||||
}
|
||||
<Button className={classnames(styles['form-button'], styles['submit-button'])} onClick={state.form === SIGNUP_FORM ? signup : loginWithEmail}>
|
||||
<div className={styles['label']}>{state.form === SIGNUP_FORM ? 'Sign up' : 'Log in'}</div>
|
||||
<div className={styles['label']}>{state.form === SIGNUP_FORM ? t('SIGN_UP') : t('LOG_IN')}</div>
|
||||
</Button>
|
||||
</div>
|
||||
<div className={styles['options-container']}>
|
||||
<Button className={classnames(styles['form-button'], styles['facebook-button'])} onClick={loginWithFacebook}>
|
||||
<Icon className={styles['icon']} name={'facebook'} />
|
||||
<div className={styles['label']}>Continue with Facebook</div>
|
||||
<div className={styles['label']}>{t('FB_LOGIN')}</div>
|
||||
</Button>
|
||||
<Button className={classnames(styles['form-button'], styles['apple-button'])} onClick={loginWithApple}>
|
||||
<Icon className={styles['icon']} name={'macos'} />
|
||||
<div className={styles['label']}>Continue with Apple</div>
|
||||
<div className={styles['label']}>{t('APPLE_LOGIN')}</div>
|
||||
</Button>
|
||||
{
|
||||
state.form === SIGNUP_FORM ?
|
||||
<Button className={classnames(styles['form-button'], styles['login-form-button'])} onClick={switchFormOnClick}>
|
||||
<div className={styles['label']}>LOG IN</div>
|
||||
<div className={classnames(styles['label'], styles['uppercase'])}>{t('LOG_IN')}</div>
|
||||
</Button>
|
||||
:
|
||||
null
|
||||
|
|
@ -395,7 +395,7 @@ const Intro = ({ queryParams }) => {
|
|||
{
|
||||
state.form === LOGIN_FORM ?
|
||||
<Button className={classnames(styles['form-button'], styles['signup-form-button'])} onClick={switchFormOnClick}>
|
||||
<div className={styles['label']}>SIGN UP WITH EMAIL</div>
|
||||
<div className={classnames(styles['label'], styles['uppercase'])}>{t('SIGN_UP_EMAIL')}</div>
|
||||
</Button>
|
||||
:
|
||||
null
|
||||
|
|
@ -403,7 +403,7 @@ const Intro = ({ queryParams }) => {
|
|||
{
|
||||
state.form === SIGNUP_FORM ?
|
||||
<Button className={classnames(styles['form-button'], styles['guest-login-button'])} onClick={loginAsGuest}>
|
||||
<div className={styles['label']}>GUEST LOGIN</div>
|
||||
<div className={classnames(styles['label'], styles['uppercase'])}>{t('GUEST_LOGIN')}</div>
|
||||
</Button>
|
||||
:
|
||||
null
|
||||
|
|
@ -421,7 +421,7 @@ const Intro = ({ queryParams }) => {
|
|||
<Modal className={styles['loading-modal-container']}>
|
||||
<div className={styles['loader-container']}>
|
||||
<Icon className={styles['icon']} name={'person'} />
|
||||
<div className={styles['label']}>Authenticating...</div>
|
||||
<div className={styles['label']}>{t('AUTHENTICATING')}</div>
|
||||
<Button className={styles['button']} onClick={cancelLoginWithFacebook && cancelLoginWithApple}>
|
||||
{t('BUTTON_CANCEL')}
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
// Copyright (C) 2017-2023 Smart code 203358507
|
||||
|
||||
const React = require('react');
|
||||
const { useTranslation } = require('react-i18next');
|
||||
const PropTypes = require('prop-types');
|
||||
const { useRouteFocused } = require('stremio-router');
|
||||
const { usePlatform } = require('stremio/common');
|
||||
|
|
@ -9,6 +10,7 @@ const CredentialsTextInput = require('../CredentialsTextInput');
|
|||
const styles = require('./styles');
|
||||
|
||||
const PasswordResetModal = ({ email, onCloseRequest }) => {
|
||||
const { t } = useTranslation();
|
||||
const routeFocused = useRouteFocused();
|
||||
const platform = usePlatform();
|
||||
const [error, setError] = React.useState('');
|
||||
|
|
@ -23,13 +25,13 @@ const PasswordResetModal = ({ email, onCloseRequest }) => {
|
|||
return [
|
||||
{
|
||||
className: styles['cancel-button'],
|
||||
label: 'Cancel',
|
||||
label: t('BUTTON_CANCEL'),
|
||||
props: {
|
||||
onClick: onCloseRequest
|
||||
}
|
||||
},
|
||||
{
|
||||
label: 'Send',
|
||||
label: t('SEND'),
|
||||
props: {
|
||||
onClick: goToPasswordReset
|
||||
}
|
||||
|
|
@ -45,7 +47,7 @@ const PasswordResetModal = ({ email, onCloseRequest }) => {
|
|||
}
|
||||
}, [routeFocused]);
|
||||
return (
|
||||
<ModalDialog className={styles['password-reset-modal-container']} title={'Password reset'} buttons={passwordResetModalButtons} onCloseRequest={onCloseRequest}>
|
||||
<ModalDialog className={styles['password-reset-modal-container']} title={t('PASSWORD_RESET')} buttons={passwordResetModalButtons} onCloseRequest={onCloseRequest}>
|
||||
<CredentialsTextInput
|
||||
ref={emailRef}
|
||||
className={styles['credentials-text-input']}
|
||||
|
|
|
|||
|
|
@ -101,6 +101,10 @@
|
|||
color: var(--primary-foreground-color);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.uppercase {
|
||||
text-transform: uppercase;
|
||||
}
|
||||
}
|
||||
|
||||
.submit-button, .guest-login-button, .signup-form-button, .login-form-button {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
// Copyright (C) 2017-2023 Smart code 203358507
|
||||
|
||||
const React = require('react');
|
||||
const { useTranslation } = require('react-i18next');
|
||||
const PropTypes = require('prop-types');
|
||||
const classnames = require('classnames');
|
||||
const NotFound = require('stremio/routes/NotFound');
|
||||
|
|
@ -47,6 +48,7 @@ function withModel(Library) {
|
|||
}
|
||||
|
||||
const Library = ({ model, urlParams, queryParams }) => {
|
||||
const { t } = useTranslation();
|
||||
const profile = useProfile();
|
||||
const notifications = useNotifications();
|
||||
const [library, loadNextPage] = useLibrary(model, urlParams, queryParams);
|
||||
|
|
@ -86,7 +88,7 @@ const Library = ({ model, urlParams, queryParams }) => {
|
|||
src={require('/images/empty.png')}
|
||||
alt={' '}
|
||||
/>
|
||||
<div className={styles['message-label']}>{model === 'library' ? 'Library' : 'Continue Watching'} not loaded!</div>
|
||||
<div className={styles['message-label']}>{model === 'library' ? t('LIBRARY_NOT_LOADED') : t('BOARD_CONTINUE_WATCHING_NOT_LOADED')}</div>
|
||||
</div>
|
||||
</DelayedRenderer>
|
||||
:
|
||||
|
|
@ -97,7 +99,7 @@ const Library = ({ model, urlParams, queryParams }) => {
|
|||
src={require('/images/empty.png')}
|
||||
alt={' '}
|
||||
/>
|
||||
<div className={styles['message-label']}>Empty {model === 'library' ? 'Library' : 'Continue Watching'}</div>
|
||||
<div className={styles['message-label']}>{model === 'library' ? t('LIBRARY_EMPTY') : t('BOARD_CONTINUE_WATCHING_EMPTY')}</div>
|
||||
</div>
|
||||
:
|
||||
<div ref={scrollContainerRef} className={classnames(styles['meta-items-container'], 'animation-fade-in')} onScroll={onScroll}>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
// Copyright (C) 2017-2023 Smart code 203358507
|
||||
|
||||
const React = require('react');
|
||||
const { useTranslation } = require('react-i18next');
|
||||
const PropTypes = require('prop-types');
|
||||
const classnames = require('classnames');
|
||||
const { useServices, useContentGamepadNavigation } = require('stremio/services');
|
||||
|
|
@ -15,6 +16,7 @@ const styles = require('./styles');
|
|||
|
||||
const MetaDetails = ({ urlParams, queryParams }) => {
|
||||
const contentRef = React.useRef(null);
|
||||
const { t } = useTranslation();
|
||||
const { core } = useServices();
|
||||
const metaDetails = useMetaDetails(urlParams);
|
||||
const [season, setSeason] = useSeason(urlParams, queryParams);
|
||||
|
|
@ -131,20 +133,20 @@ const MetaDetails = ({ urlParams, queryParams }) => {
|
|||
<DelayedRenderer delay={500}>
|
||||
<div className={styles['meta-message-container']}>
|
||||
<Image className={styles['image']} src={require('/images/empty.png')} alt={' '} />
|
||||
<div className={styles['message-label']}>No meta was selected!</div>
|
||||
<div className={styles['message-label']}>{t('ERR_NO_META_SELECTED')}</div>
|
||||
</div>
|
||||
</DelayedRenderer>
|
||||
:
|
||||
metaDetails.metaItem === null ?
|
||||
<div className={styles['meta-message-container']}>
|
||||
<Image className={styles['image']} src={require('/images/empty.png')} alt={' '} />
|
||||
<div className={styles['message-label']}>No addons were requested for this meta!</div>
|
||||
<div className={styles['message-label']}>{t('ERR_NO_ADDONS_FOR_META')}</div>
|
||||
</div>
|
||||
:
|
||||
metaDetails.metaItem.content.type === 'Err' ?
|
||||
<div className={styles['meta-message-container']}>
|
||||
<Image className={styles['image']} src={require('/images/empty.png')} alt={' '} />
|
||||
<div className={styles['message-label']}>No metadata was found!</div>
|
||||
<div className={styles['message-label']}>{t('ERR_NO_META_FOUND')}</div>
|
||||
</div>
|
||||
:
|
||||
metaDetails.metaItem.content.type === 'Loading' ?
|
||||
|
|
@ -168,6 +170,8 @@ const MetaDetails = ({ urlParams, queryParams }) => {
|
|||
trailerStreams={metaDetails.metaItem.content.content.trailerStreams}
|
||||
inLibrary={metaDetails.metaItem.content.content.inLibrary}
|
||||
toggleInLibrary={metaDetails.metaItem.content.content.inLibrary ? removeFromLibrary : addToLibrary}
|
||||
metaId={metaDetails.metaItem.content.content.id}
|
||||
ratingInfo={metaDetails.ratingInfo}
|
||||
/>
|
||||
</React.Fragment>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -133,7 +133,7 @@ const StreamsList = ({ className, video, type, onEpisodeSearch, ...props }) => {
|
|||
: null
|
||||
}
|
||||
<Image className={styles['image']} src={require('/images/empty.png')} alt={' '} />
|
||||
<div className={styles['label']}>No addons were requested for streams!</div>
|
||||
<div className={styles['label']}>{t('ERR_NO_ADDONS_FOR_STREAMS')}</div>
|
||||
</div>
|
||||
:
|
||||
props.streams.every((streams) => streams.content.type === 'Err') ?
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ const SeasonsBar = ({ className, seasons, season, onSelect }) => {
|
|||
const options = React.useMemo(() => {
|
||||
return seasons.map((season) => ({
|
||||
value: String(season),
|
||||
label: season > 0 ? `${t('SEASON')} ${season}` : t('SPECIAL')
|
||||
label: season > 0 ? t('SEASON_NUMBER', { season }) : t('SPECIAL')
|
||||
}));
|
||||
}, [seasons]);
|
||||
const selectedSeason = React.useMemo(() => {
|
||||
|
|
@ -56,19 +56,19 @@ const SeasonsBar = ({ className, seasons, season, onSelect }) => {
|
|||
|
||||
return (
|
||||
<div className={classnames(className, styles['seasons-bar-container'])}>
|
||||
<Button className={classnames(styles['prev-season-button'], { 'disabled': prevDisabled })} title={'Previous season'} data-action={'prev'} onClick={prevNextButtonOnClick}>
|
||||
<Button className={classnames(styles['prev-season-button'], { 'disabled': prevDisabled })} title={t('PREV_SEASON')} data-action={'prev'} onClick={prevNextButtonOnClick}>
|
||||
<Icon className={styles['icon']} name={'chevron-back'} />
|
||||
<div className={styles['label']}>Prev</div>
|
||||
<div className={styles['label']}>{t('BUTTON_PREV')}</div>
|
||||
</Button>
|
||||
<MultiselectMenu
|
||||
className={styles['seasons-popup-label-container']}
|
||||
options={options}
|
||||
title={season > 0 ? `${t('SEASON')} ${season}` : t('SPECIAL')}
|
||||
title={season > 0 ? t('SEASON_NUMBER', { season }) : t('SPECIAL')}
|
||||
value={selectedSeason}
|
||||
onSelect={seasonOnSelect}
|
||||
/>
|
||||
<Button className={classnames(styles['next-season-button'], { 'disabled': nextDisabled })} title={'Next season'} data-action={'next'} onClick={prevNextButtonOnClick}>
|
||||
<div className={styles['label']}>Next</div>
|
||||
<Button className={classnames(styles['next-season-button'], { 'disabled': nextDisabled })} title={t('NEXT_SEASON')} data-action={'next'} onClick={prevNextButtonOnClick}>
|
||||
<div className={styles['label']}>{t('BUTTON_NEXT')}</div>
|
||||
<Icon className={styles['icon']} name={'chevron-forward'} />
|
||||
</Button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,24 +1,26 @@
|
|||
// Copyright (C) 2017-2023 Smart code 203358507
|
||||
|
||||
const React = require('react');
|
||||
const { useTranslation } = require('react-i18next');
|
||||
const PropTypes = require('prop-types');
|
||||
const classnames = require('classnames');
|
||||
const { default: Icon } = require('@stremio/stremio-icons/react');
|
||||
const styles = require('./styles');
|
||||
|
||||
const SeasonsBarPlaceholder = ({ className }) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className={classnames(className, styles['seasons-bar-placeholder-container'])}>
|
||||
<div className={styles['prev-season-button']}>
|
||||
<Icon className={styles['icon']} name={'chevron-back'} />
|
||||
<div className={styles['label']}>Prev</div>
|
||||
<div className={styles['label']}>{t('SEASON_PREV')}</div>
|
||||
</div>
|
||||
<div className={styles['seasons-popup-label-container']}>
|
||||
<div className={styles['seasons-popup-label']}>Season 1</div>
|
||||
<div className={styles['seasons-popup-label']}>{t('SEASON_NUMBER', { season: 1 })}</div>
|
||||
<Icon className={styles['seasons-popup-icon']} name={'caret-down'} />
|
||||
</div>
|
||||
<div className={styles['next-season-button']}>
|
||||
<div className={styles['label']}>Next</div>
|
||||
<div className={styles['label']}>{t('SEASON_NEXT')}</div>
|
||||
<Icon className={styles['icon']} name={'chevron-forward'} />
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ const PropTypes = require('prop-types');
|
|||
const classnames = require('classnames');
|
||||
const { t } = require('i18next');
|
||||
const { useServices } = require('stremio/services');
|
||||
const { useProfile } = require('stremio/common');
|
||||
const { Image, SearchBar, Toggle, Video } = require('stremio/components');
|
||||
const SeasonsBar = require('./SeasonsBar');
|
||||
const { default: EpisodePicker } = require('../EpisodePicker');
|
||||
|
|
@ -12,6 +13,7 @@ const styles = require('./styles');
|
|||
|
||||
const VideosList = ({ className, metaItem, libraryItem, season, seasonOnSelect, toggleNotifications }) => {
|
||||
const { core } = useServices();
|
||||
const profile = useProfile();
|
||||
const showNotificationsToggle = React.useMemo(() => {
|
||||
return metaItem?.content?.content?.inLibrary && metaItem?.content?.content?.videos?.length;
|
||||
}, [metaItem]);
|
||||
|
|
@ -122,7 +124,7 @@ const VideosList = ({ className, metaItem, libraryItem, season, seasonOnSelect,
|
|||
<div className={styles['message-container']}>
|
||||
<EpisodePicker className={styles['episode-picker']} onSubmit={onSeasonSearch} />
|
||||
<Image className={styles['image']} src={require('/images/empty.png')} alt={' '} />
|
||||
<div className={styles['label']}>No videos found for this meta!</div>
|
||||
<div className={styles['label']}>{t('ERR_NO_VIDEOS_FOR_META')}</div>
|
||||
</div>
|
||||
:
|
||||
<React.Fragment>
|
||||
|
|
@ -158,7 +160,7 @@ const VideosList = ({ className, metaItem, libraryItem, season, seasonOnSelect,
|
|||
return search.length === 0 ||
|
||||
(
|
||||
(typeof video.title === 'string' && video.title.toLowerCase().includes(search.toLowerCase())) ||
|
||||
(!isNaN(video.released.getTime()) && video.released.toLocaleString(undefined, { year: '2-digit', month: 'short', day: 'numeric' }).toLowerCase().includes(search.toLowerCase()))
|
||||
(!isNaN(video.released.getTime()) && video.released.toLocaleString(profile.settings.interfaceLanguage, { year: '2-digit', month: 'short', day: 'numeric' }).toLowerCase().includes(search.toLowerCase()))
|
||||
);
|
||||
})
|
||||
.map((video, index) => (
|
||||
|
|
|
|||
|
|
@ -1,15 +1,17 @@
|
|||
// Copyright (C) 2017-2023 Smart code 203358507
|
||||
|
||||
const React = require('react');
|
||||
const { useTranslation } = require('react-i18next');
|
||||
const { HorizontalNavBar, Image } = require('stremio/components');
|
||||
const styles = require('./styles');
|
||||
|
||||
const NotFound = () => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className={styles['not-found-container']}>
|
||||
<HorizontalNavBar
|
||||
className={styles['nav-bar']}
|
||||
title={'Page not found'}
|
||||
title={t('PAGE_NOT_FOUND')}
|
||||
backButton={true}
|
||||
fullscreenButton={true}
|
||||
navMenu={true}
|
||||
|
|
@ -20,7 +22,7 @@ const NotFound = () => {
|
|||
src={require('/images/empty.png')}
|
||||
alt={' '}
|
||||
/>
|
||||
<div className={styles['not-found-label']}>Page not found!</div>
|
||||
<div className={styles['not-found-label']}>{t('PAGE_NOT_FOUND')}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
23
src/routes/Player/Indicator/Indicator.less
Normal file
23
src/routes/Player/Indicator/Indicator.less
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
.indicator-container {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 4rem;
|
||||
user-select: none;
|
||||
|
||||
.indicator {
|
||||
flex: none;
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
padding: 0 2rem;
|
||||
border-radius: 4rem;
|
||||
text-align: center;
|
||||
font-weight: bold;
|
||||
color: var(--primary-foreground-color);
|
||||
background-color: var(--modal-background-color);
|
||||
}
|
||||
}
|
||||
74
src/routes/Player/Indicator/Indicator.tsx
Normal file
74
src/routes/Player/Indicator/Indicator.tsx
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import classNames from 'classnames';
|
||||
import { t } from 'i18next';
|
||||
import { Transition } from 'stremio/components';
|
||||
import { useBinaryState } from 'stremio/common';
|
||||
import styles from './Indicator.less';
|
||||
|
||||
type Property = {
|
||||
label: string,
|
||||
format: (value: number) => string,
|
||||
};
|
||||
|
||||
const PROPERTIES: Record<string, Property> = {
|
||||
'extraSubtitlesDelay': {
|
||||
label: 'SUBTITLES_DELAY',
|
||||
format: (value) => `${(value / 1000).toFixed(2)}s`,
|
||||
},
|
||||
};
|
||||
|
||||
type VideoState = Record<string, number>;
|
||||
|
||||
type Props = {
|
||||
className: string,
|
||||
videoState: VideoState,
|
||||
disabled: boolean,
|
||||
};
|
||||
|
||||
const Indicator = ({ className, videoState, disabled }: Props) => {
|
||||
const timeout = useRef<NodeJS.Timeout | null>(null);
|
||||
const prevVideoState = useRef<VideoState>(videoState);
|
||||
|
||||
const [shown, show, hide] = useBinaryState(false);
|
||||
const [current, setCurrent] = useState<string | null>(null);
|
||||
|
||||
const label = useMemo(() => {
|
||||
const property = current && PROPERTIES[current];
|
||||
return property && t(property.label);
|
||||
}, [current]);
|
||||
|
||||
const value = useMemo(() => {
|
||||
const property = current && PROPERTIES[current];
|
||||
const value = current && videoState[current];
|
||||
return property && value && property.format(value);
|
||||
}, [current, videoState]);
|
||||
|
||||
useEffect(() => {
|
||||
for (const property of Object.keys(PROPERTIES)) {
|
||||
const prev = prevVideoState.current[property];
|
||||
const next = videoState[property];
|
||||
|
||||
if (next && next !== prev) {
|
||||
setCurrent(property);
|
||||
show();
|
||||
|
||||
timeout.current && clearTimeout(timeout.current);
|
||||
timeout.current = setTimeout(hide, 1000);
|
||||
}
|
||||
}
|
||||
|
||||
prevVideoState.current = videoState;
|
||||
}, [videoState]);
|
||||
|
||||
return (
|
||||
<Transition when={shown && !disabled} name={'fade'}>
|
||||
<div className={classNames(className, styles['indicator-container'])}>
|
||||
<div className={styles['indicator']}>
|
||||
<div>{label} {value}</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
);
|
||||
};
|
||||
|
||||
export default Indicator;
|
||||
|
|
@ -7,8 +7,10 @@ const { default: Icon } = require('@stremio/stremio-icons/react');
|
|||
const { CONSTANTS, useProfile } = require('stremio/common');
|
||||
const { Button, Image } = require('stremio/components');
|
||||
const styles = require('./styles');
|
||||
const { useTranslation } = require('react-i18next');
|
||||
|
||||
const NextVideoPopup = ({ className, metaItem, nextVideo, onDismiss, onNextVideoRequested }) => {
|
||||
const { t } = useTranslation();
|
||||
const profile = useProfile();
|
||||
const blurPosterImage = profile.settings.hideSpoilers && metaItem.type === 'series';
|
||||
const watchNowButtonRef = React.useRef(null);
|
||||
|
|
@ -65,7 +67,7 @@ const NextVideoPopup = ({ className, metaItem, nextVideo, onDismiss, onNextVideo
|
|||
{
|
||||
typeof metaItem?.name === 'string' ?
|
||||
<div className={styles['name']}>
|
||||
<span className={styles['label']}>Next on</span> { metaItem.name }
|
||||
<span className={styles['label']}>{t('PLAYER_NEXT_VIDEO_TITLE_SHORT')}</span> { metaItem.name }
|
||||
</div>
|
||||
:
|
||||
null
|
||||
|
|
@ -82,11 +84,11 @@ const NextVideoPopup = ({ className, metaItem, nextVideo, onDismiss, onNextVideo
|
|||
<div className={styles['buttons-container']}>
|
||||
<Button className={classnames(styles['button-container'], styles['dismiss'])} onClick={onDismissButtonClick}>
|
||||
<Icon className={styles['icon']} name={'close'} />
|
||||
<div className={styles['label']}>Dismiss</div>
|
||||
<div className={styles['label']}>{t('PLAYER_NEXT_VIDEO_BUTTON_DISMISS')}</div>
|
||||
</Button>
|
||||
<Button ref={watchNowButtonRef} className={classnames(styles['button-container'], styles['play-button'])} onClick={onWatchNowButtonClick}>
|
||||
<Icon className={styles['icon']} name={'play'} />
|
||||
<div className={styles['label']}>Watch Now</div>
|
||||
<div className={styles['label']}>{t('PLAYER_NEXT_VIDEO_BUTTON_WATCH')}</div>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ const { useServices } = require('stremio/services');
|
|||
const Option = require('./Option');
|
||||
const styles = require('./styles');
|
||||
|
||||
const OptionsMenu = ({ className, stream, playbackDevices }) => {
|
||||
const OptionsMenu = ({ className, stream, playbackDevices, extraSubtitlesTracks, selectedExtraSubtitlesTrackId }) => {
|
||||
const { t } = useTranslation();
|
||||
const { core } = useServices();
|
||||
const platform = usePlatform();
|
||||
|
|
@ -25,6 +25,12 @@ const OptionsMenu = ({ className, stream, playbackDevices }) => {
|
|||
const externalDevices = React.useMemo(() => {
|
||||
return playbackDevices.filter(({ type }) => type === 'external');
|
||||
}, [playbackDevices]);
|
||||
|
||||
const subtitlesTrackUrl = React.useMemo(() => {
|
||||
const track = extraSubtitlesTracks?.find(({ id }) => id === selectedExtraSubtitlesTrackId);
|
||||
return track?.fallbackUrl ?? track?.url ?? null;
|
||||
}, [extraSubtitlesTracks, selectedExtraSubtitlesTrackId]);
|
||||
|
||||
const onCopyStreamButtonClick = React.useCallback(() => {
|
||||
if (streamingUrl || downloadUrl) {
|
||||
navigator.clipboard.writeText(streamingUrl || downloadUrl)
|
||||
|
|
@ -52,6 +58,11 @@ const OptionsMenu = ({ className, stream, playbackDevices }) => {
|
|||
platform.openExternal(streamingUrl || downloadUrl);
|
||||
}
|
||||
}, [streamingUrl, downloadUrl]);
|
||||
|
||||
const onDownloadSubtitlesClick = React.useCallback(() => {
|
||||
subtitlesTrackUrl && platform.openExternal(subtitlesTrackUrl);
|
||||
}, [subtitlesTrackUrl]);
|
||||
|
||||
const onExternalDeviceRequested = React.useCallback((deviceId) => {
|
||||
if (streamingUrl) {
|
||||
core.transport.dispatch({
|
||||
|
|
@ -94,6 +105,17 @@ const OptionsMenu = ({ className, stream, playbackDevices }) => {
|
|||
:
|
||||
null
|
||||
}
|
||||
{
|
||||
subtitlesTrackUrl ?
|
||||
<Option
|
||||
icon={'download'}
|
||||
label={t('CTX_DOWNLOAD_SUBS')}
|
||||
disabled={stream === null}
|
||||
onClick={onDownloadSubtitlesClick}
|
||||
/>
|
||||
:
|
||||
null
|
||||
}
|
||||
{
|
||||
streamingUrl && externalDevices.map(({ id, name }) => (
|
||||
<Option
|
||||
|
|
@ -114,6 +136,8 @@ OptionsMenu.propTypes = {
|
|||
className: PropTypes.string,
|
||||
stream: PropTypes.object,
|
||||
playbackDevices: PropTypes.array,
|
||||
extraSubtitlesTracks: PropTypes.array,
|
||||
selectedExtraSubtitlesTrackId: PropTypes.string,
|
||||
};
|
||||
|
||||
module.exports = OptionsMenu;
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ const useStatistics = require('./useStatistics');
|
|||
const useVideo = require('./useVideo');
|
||||
const styles = require('./styles');
|
||||
const Video = require('./Video');
|
||||
const { default: Indicator } = require('./Indicator/Indicator');
|
||||
|
||||
const GAMEPAD_HANDLER_ID = 'player';
|
||||
|
||||
|
|
@ -117,17 +118,22 @@ const Player = ({ urlParams, queryParams }) => {
|
|||
}, []);
|
||||
|
||||
const onEnded = React.useCallback(() => {
|
||||
// here we need to explicitly check for isNavigating.current
|
||||
// the ended event can be called multiple times by MPV inside Shell
|
||||
if (isNavigating.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
ended();
|
||||
if (player.nextVideo !== null) {
|
||||
onNextVideoRequested();
|
||||
if (window.playerNextVideo !== null) {
|
||||
nextVideo();
|
||||
|
||||
const deepLinks = window.playerNextVideo.deepLinks;
|
||||
handleNextVideoNavigation(deepLinks);
|
||||
} else {
|
||||
window.history.back();
|
||||
}
|
||||
}, [player.nextVideo, onNextVideoRequested]);
|
||||
}, []);
|
||||
|
||||
const onError = React.useCallback((error) => {
|
||||
console.error('Player', error);
|
||||
|
|
@ -219,10 +225,26 @@ const Player = ({ urlParams, queryParams }) => {
|
|||
video.setProp('extraSubtitlesDelay', delay);
|
||||
}, []);
|
||||
|
||||
const onIncreaseSubtitlesDelay = React.useCallback(() => {
|
||||
const delay = video.state.extraSubtitlesDelay + 250;
|
||||
onExtraSubtitlesDelayChanged(delay);
|
||||
}, [video.state.extraSubtitlesDelay, onExtraSubtitlesDelayChanged]);
|
||||
|
||||
const onDecreaseSubtitlesDelay = React.useCallback(() => {
|
||||
const delay = video.state.extraSubtitlesDelay - 250;
|
||||
onExtraSubtitlesDelayChanged(delay);
|
||||
}, [video.state.extraSubtitlesDelay, onExtraSubtitlesDelayChanged]);
|
||||
|
||||
const onSubtitlesSizeChanged = React.useCallback((size) => {
|
||||
updateSettings({ subtitlesSize: size });
|
||||
}, [updateSettings]);
|
||||
|
||||
const onUpdateSubtitlesSize = React.useCallback((delta) => {
|
||||
const sizeIndex = CONSTANTS.SUBTITLES_SIZES.indexOf(video.state.subtitlesSize);
|
||||
const size = CONSTANTS.SUBTITLES_SIZES[Math.max(0, Math.min(CONSTANTS.SUBTITLES_SIZES.length - 1, sizeIndex + delta))];
|
||||
onSubtitlesSizeChanged(size);
|
||||
}, [video.state.subtitlesSize, onSubtitlesSizeChanged]);
|
||||
|
||||
const onSubtitlesOffsetChanged = React.useCallback((offset) => {
|
||||
updateSettings({ subtitlesOffset: offset });
|
||||
}, [updateSettings]);
|
||||
|
|
@ -468,6 +490,14 @@ const Player = ({ urlParams, queryParams }) => {
|
|||
closeNextVideoPopup();
|
||||
}
|
||||
}
|
||||
if (player.nextVideo) {
|
||||
// This is a workaround for the fact that when we call onEnded nextVideo from the player is already set to null since core unloads the stream
|
||||
// we explicitly set it to a global variable so we can access it in the onEnded function
|
||||
// this is not a good solution but it works for now
|
||||
window.playerNextVideo = player.nextVideo;
|
||||
} else {
|
||||
window.playerNextVideo = null;
|
||||
}
|
||||
}, [player.nextVideo, video.state.time, video.state.duration]);
|
||||
|
||||
React.useEffect(() => {
|
||||
|
|
@ -510,6 +540,9 @@ const Player = ({ urlParams, queryParams }) => {
|
|||
defaultSubtitlesSelected.current = false;
|
||||
defaultAudioTrackSelected.current = false;
|
||||
nextVideoPopupDismissed.current = false;
|
||||
// we need a timeout here to make sure that previous page unloads and the new one loads
|
||||
// avoiding race conditions and flickering
|
||||
setTimeout(() => isNavigating.current = false, 1000);
|
||||
}, [video.state.stream]);
|
||||
|
||||
React.useEffect(() => {
|
||||
|
|
@ -642,6 +675,22 @@ const Player = ({ urlParams, queryParams }) => {
|
|||
|
||||
break;
|
||||
}
|
||||
case 'KeyG': {
|
||||
onDecreaseSubtitlesDelay();
|
||||
break;
|
||||
}
|
||||
case 'KeyH': {
|
||||
onIncreaseSubtitlesDelay();
|
||||
break;
|
||||
}
|
||||
case 'Minus': {
|
||||
onUpdateSubtitlesSize(-1);
|
||||
break;
|
||||
}
|
||||
case 'Equal': {
|
||||
onUpdateSubtitlesSize(1);
|
||||
break;
|
||||
}
|
||||
case 'Escape': {
|
||||
closeMenus();
|
||||
!settings.escExitFullscreen && window.history.back();
|
||||
|
|
@ -675,7 +724,30 @@ const Player = ({ urlParams, queryParams }) => {
|
|||
window.removeEventListener('keyup', onKeyUp);
|
||||
window.removeEventListener('wheel', onWheel);
|
||||
};
|
||||
}, [player.metaItem, player.selected, streamingServer.statistics, settings.seekTimeDuration, settings.seekShortTimeDuration, settings.escExitFullscreen, routeFocused, menusOpen, nextVideoPopupOpen, video.state.paused, video.state.time, video.state.volume, video.state.audioTracks, video.state.subtitlesTracks, video.state.extraSubtitlesTracks, video.state.playbackSpeed, toggleSubtitlesMenu, toggleStatisticsMenu, toggleSideDrawer]);
|
||||
}, [
|
||||
player.metaItem,
|
||||
player.selected,
|
||||
streamingServer.statistics,
|
||||
settings.seekTimeDuration,
|
||||
settings.seekShortTimeDuration,
|
||||
settings.escExitFullscreen,
|
||||
routeFocused,
|
||||
menusOpen,
|
||||
nextVideoPopupOpen,
|
||||
video.state.paused,
|
||||
video.state.time,
|
||||
video.state.volume,
|
||||
video.state.audioTracks,
|
||||
video.state.subtitlesTracks,
|
||||
video.state.extraSubtitlesTracks,
|
||||
video.state.playbackSpeed,
|
||||
toggleSubtitlesMenu,
|
||||
toggleStatisticsMenu,
|
||||
toggleSideDrawer,
|
||||
onDecreaseSubtitlesDelay,
|
||||
onIncreaseSubtitlesDelay,
|
||||
onUpdateSubtitlesSize,
|
||||
]);
|
||||
|
||||
React.useEffect(() => {
|
||||
video.events.on('error', onError);
|
||||
|
|
@ -764,6 +836,8 @@ const Player = ({ urlParams, queryParams }) => {
|
|||
className={classnames(styles['layer'], styles['menu-layer'])}
|
||||
stream={player?.selected?.stream}
|
||||
playbackDevices={playbackDevices}
|
||||
extraSubtitlesTracks={video.state.extraSubtitlesTracks}
|
||||
selectedExtraSubtitlesTrackId={video.state.selectedExtraSubtitlesTrackId}
|
||||
/>
|
||||
</ContextMenu>
|
||||
<HorizontalNavBar
|
||||
|
|
@ -815,6 +889,11 @@ const Player = ({ urlParams, queryParams }) => {
|
|||
onMouseOver={onBarMouseMove}
|
||||
onTouchEnd={onContainerMouseLeave}
|
||||
/>
|
||||
<Indicator
|
||||
className={classnames(styles['layer'], styles['indicator-layer'])}
|
||||
videoState={video.state}
|
||||
disabled={subtitlesMenuOpen}
|
||||
/>
|
||||
{
|
||||
nextVideoPopupOpen ?
|
||||
<NextVideoPopup
|
||||
|
|
@ -842,6 +921,7 @@ const Player = ({ urlParams, queryParams }) => {
|
|||
metaItem={player.metaItem?.content}
|
||||
seriesInfo={player.seriesInfo}
|
||||
closeSideDrawer={closeSideDrawer}
|
||||
selected={player.selected?.streamRequest?.path.id}
|
||||
/>
|
||||
</Transition>
|
||||
{
|
||||
|
|
@ -895,6 +975,8 @@ const Player = ({ urlParams, queryParams }) => {
|
|||
className={classnames(styles['layer'], styles['menu-layer'])}
|
||||
stream={player.selected.stream}
|
||||
playbackDevices={playbackDevices}
|
||||
extraSubtitlesTracks={video.state.extraSubtitlesTracks}
|
||||
selectedExtraSubtitlesTrackId={video.state.selectedExtraSubtitlesTrackId}
|
||||
/>
|
||||
:
|
||||
null
|
||||
|
|
|
|||
|
|
@ -57,7 +57,7 @@
|
|||
.info {
|
||||
padding: @padding;
|
||||
overflow-y: auto;
|
||||
flex: none;
|
||||
flex: 1;
|
||||
|
||||
.side-drawer-meta-preview {
|
||||
.action-buttons-container {
|
||||
|
|
@ -78,12 +78,6 @@
|
|||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: @small) {
|
||||
.side-drawer {
|
||||
max-width: 40dvw;
|
||||
}
|
||||
}
|
||||
|
||||
@media @phone-portrait {
|
||||
.side-drawer {
|
||||
max-width: 100dvw;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
// Copyright (C) 2017-2024 Smart code 203358507
|
||||
|
||||
import React, { useMemo, useCallback, useState, forwardRef, memo } from 'react';
|
||||
import React, { useMemo, useCallback, useState, forwardRef, memo, useRef } from 'react';
|
||||
import classNames from 'classnames';
|
||||
import Icon from '@stremio/stremio-icons/react';
|
||||
import { useServices } from 'stremio/services';
|
||||
|
|
@ -14,11 +14,14 @@ type Props = {
|
|||
seriesInfo: SeriesInfo;
|
||||
metaItem: MetaItem;
|
||||
closeSideDrawer: () => void;
|
||||
selected: string;
|
||||
transitionEnded: boolean;
|
||||
};
|
||||
|
||||
const SideDrawer = memo(forwardRef<HTMLDivElement, Props>(({ seriesInfo, className, closeSideDrawer, ...props }: Props, ref) => {
|
||||
const SideDrawer = memo(forwardRef<HTMLDivElement, Props>(({ seriesInfo, className, closeSideDrawer, selected, ...props }: Props, ref) => {
|
||||
const { core } = useServices();
|
||||
const [season, setSeason] = useState<number>(seriesInfo?.season);
|
||||
const selectedVideoRef = useRef<HTMLDivElement>(null);
|
||||
const metaItem = useMemo(() => {
|
||||
return seriesInfo ?
|
||||
{
|
||||
|
|
@ -75,8 +78,14 @@ const SideDrawer = memo(forwardRef<HTMLDivElement, Props>(({ seriesInfo, classNa
|
|||
event.stopPropagation();
|
||||
};
|
||||
|
||||
const onTransitionEnd = () => {
|
||||
selectedVideoRef.current?.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={ref} className={classNames(styles['side-drawer'], className)} onMouseDown={onMouseDown}>
|
||||
<div ref={ref} className={classNames(styles['side-drawer'], className)} onMouseDown={onMouseDown} onTransitionEnd={onTransitionEnd}>
|
||||
<div className={styles['close-button']} onClick={closeSideDrawer}>
|
||||
<Icon className={styles['icon']} name={'chevron-forward'} />
|
||||
</div>
|
||||
|
|
@ -105,6 +114,7 @@ const SideDrawer = memo(forwardRef<HTMLDivElement, Props>(({ seriesInfo, classNa
|
|||
{videos.map((video, index) => (
|
||||
<Video
|
||||
key={index}
|
||||
ref={video.id === selected ? selectedVideoRef : null}
|
||||
className={styles['video']}
|
||||
id={video.id}
|
||||
title={video.title}
|
||||
|
|
|
|||
|
|
@ -1,20 +1,22 @@
|
|||
// Copyright (C) 2017-2023 Smart code 203358507
|
||||
|
||||
const React = require('react');
|
||||
const { useTranslation } = require('react-i18next');
|
||||
const classNames = require('classnames');
|
||||
const PropTypes = require('prop-types');
|
||||
const styles = require('./styles.less');
|
||||
|
||||
const StatisticsMenu = ({ className, peers, speed, completed, infoHash }) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className={classNames(className, styles['statistics-menu-container'])}>
|
||||
<div className={styles['title']}>
|
||||
Statistics
|
||||
{t('PLAYER_STATISTICS')}
|
||||
</div>
|
||||
<div className={styles['stats']}>
|
||||
<div className={styles['stat']}>
|
||||
<div className={styles['label']}>
|
||||
Peers
|
||||
{t('PLAYER_PEERS')}
|
||||
</div>
|
||||
<div className={styles['value']}>
|
||||
{ peers }
|
||||
|
|
@ -22,15 +24,15 @@ const StatisticsMenu = ({ className, peers, speed, completed, infoHash }) => {
|
|||
</div>
|
||||
<div className={styles['stat']}>
|
||||
<div className={styles['label']}>
|
||||
Speed
|
||||
{t('PLAYER_SPEED')}
|
||||
</div>
|
||||
<div className={styles['value']}>
|
||||
{ speed } MB/s
|
||||
{`${speed} ${t('MB_S')}`}
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles['stat']}>
|
||||
<div className={styles['label']}>
|
||||
Completed
|
||||
{t('PLAYER_COMPLETED')}
|
||||
</div>
|
||||
<div className={styles['value']}>
|
||||
{ Math.min(completed, 100) } %
|
||||
|
|
@ -39,7 +41,7 @@ const StatisticsMenu = ({ className, peers, speed, completed, infoHash }) => {
|
|||
</div>
|
||||
<div className={styles['info-hash']}>
|
||||
<div className={styles['label']}>
|
||||
Info Hash
|
||||
{t('PLAYER_INFO_HASH')}
|
||||
</div>
|
||||
<div className={styles['value']}>
|
||||
{ infoHash }
|
||||
|
|
|
|||
|
|
@ -1,47 +0,0 @@
|
|||
// Copyright (C) 2017-2023 Smart code 203358507
|
||||
|
||||
const React = require('react');
|
||||
const PropTypes = require('prop-types');
|
||||
const classnames = require('classnames');
|
||||
const { default: Icon } = require('@stremio/stremio-icons/react');
|
||||
const { Button } = require('stremio/components');
|
||||
const styles = require('./styles');
|
||||
|
||||
const DiscreteSelectInput = ({ className, value, label, disabled, dataset, onChange }) => {
|
||||
const buttonOnClick = React.useCallback((event) => {
|
||||
if (typeof onChange === 'function') {
|
||||
onChange({
|
||||
type: 'change',
|
||||
value: event.currentTarget.dataset.type,
|
||||
dataset: dataset,
|
||||
reactEvent: event,
|
||||
nativeEvent: event.nativeEvent
|
||||
});
|
||||
}
|
||||
}, [dataset, onChange]);
|
||||
return (
|
||||
<div className={classnames(className, styles['discrete-input-container'], { 'disabled': disabled })}>
|
||||
<div className={styles['header']}>{label}</div>
|
||||
<div className={styles['input-container']} title={disabled ? `${label} is not configurable` : null}>
|
||||
<Button className={classnames(styles['button-container'], { 'disabled': disabled })} data-type={'decrement'} onClick={buttonOnClick}>
|
||||
<Icon className={styles['icon']} name={'remove'} />
|
||||
</Button>
|
||||
<div className={styles['option-label']} title={value}>{value}</div>
|
||||
<Button className={classnames(styles['button-container'], { 'disabled': disabled })} data-type={'increment'} onClick={buttonOnClick}>
|
||||
<Icon className={styles['icon']} name={'add'} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
DiscreteSelectInput.propTypes = {
|
||||
className: PropTypes.string,
|
||||
value: PropTypes.string,
|
||||
label: PropTypes.string,
|
||||
disabled: PropTypes.bool,
|
||||
dataset: PropTypes.object,
|
||||
onChange: PropTypes.func
|
||||
};
|
||||
|
||||
module.exports = DiscreteSelectInput;
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
// Copyright (C) 2017-2023 Smart code 203358507
|
||||
|
||||
const DiscreteSelectInput = require('./DiscreteSelectInput');
|
||||
|
||||
module.exports = DiscreteSelectInput;
|
||||
|
|
@ -1,14 +1,10 @@
|
|||
// Copyright (C) 2017-2023 Smart code 203358507
|
||||
|
||||
@import (reference) '~@stremio/stremio-colors/less/stremio-colors.less';
|
||||
|
||||
.discrete-input-container {
|
||||
.stepper {
|
||||
&:global(.disabled) {
|
||||
.header {
|
||||
color: var(--primary-foreground-color);
|
||||
}
|
||||
|
||||
.input-container {
|
||||
.content {
|
||||
opacity: 0.4;
|
||||
}
|
||||
}
|
||||
|
|
@ -19,14 +15,14 @@
|
|||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.input-container {
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
border-radius: 3.5rem;
|
||||
background: var(--overlay-color);
|
||||
|
||||
.button-container {
|
||||
.button {
|
||||
flex: none;
|
||||
width: 3.5rem;
|
||||
height: 3.5rem;
|
||||
|
|
@ -42,7 +38,7 @@
|
|||
}
|
||||
}
|
||||
|
||||
.option-label {
|
||||
.value {
|
||||
flex: 1;
|
||||
font-weight: 500;
|
||||
text-align: center;
|
||||
98
src/routes/Player/SubtitlesMenu/Stepper/Stepper.tsx
Normal file
98
src/routes/Player/SubtitlesMenu/Stepper/Stepper.tsx
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
import React, { useCallback, useEffect, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import classNames from 'classnames';
|
||||
import Icon from '@stremio/stremio-icons/react';
|
||||
import { Button } from 'stremio/components';
|
||||
import { useInterval, useTimeout } from 'stremio/common';
|
||||
import styles from './Stepper.less';
|
||||
|
||||
const clamp = (value: number, min?: number, max?: number) => {
|
||||
const minClamped = typeof min === 'number' ? Math.max(value, min) : value;
|
||||
const maxClamped = typeof max === 'number' ? Math.min(minClamped, max) : minClamped;
|
||||
return maxClamped;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
className: string,
|
||||
label: string,
|
||||
value: number,
|
||||
unit?: string,
|
||||
step: number,
|
||||
min?: number,
|
||||
max?: number,
|
||||
disabled?: boolean,
|
||||
onChange: (value: number) => void,
|
||||
};
|
||||
|
||||
const Stepper = ({ className, label, value, unit, step, min, max, disabled, onChange }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const localValue = useRef(value);
|
||||
|
||||
const interval = useInterval(100);
|
||||
const timeout = useTimeout(250);
|
||||
|
||||
const cancel = () => {
|
||||
interval.cancel();
|
||||
timeout.cancel();
|
||||
};
|
||||
|
||||
const updateValue = useCallback((delta: number) => {
|
||||
onChange(clamp(localValue.current + delta, min, max));
|
||||
}, [onChange]);
|
||||
|
||||
const onDecrementMouseDown = useCallback(() => {
|
||||
cancel();
|
||||
timeout.start(() => interval.start(() => updateValue(-step)));
|
||||
}, [updateValue]);
|
||||
|
||||
const onDecrementMouseUp = useCallback(() => {
|
||||
cancel();
|
||||
updateValue(-step);
|
||||
}, [updateValue]);
|
||||
|
||||
const onIncrementMouseDown = useCallback(() => {
|
||||
cancel();
|
||||
timeout.start(() => interval.start(() => updateValue(step)));
|
||||
}, [updateValue]);
|
||||
|
||||
const onIncrementMouseUp = useCallback(() => {
|
||||
cancel();
|
||||
updateValue(step);
|
||||
}, [updateValue]);
|
||||
|
||||
useEffect(() => {
|
||||
localValue.current = value;
|
||||
}, [value]);
|
||||
|
||||
return (
|
||||
<div className={classNames(styles['stepper'], className)}>
|
||||
<div className={styles['header']}>
|
||||
{ t(label) }
|
||||
</div>
|
||||
<div className={styles['content']}>
|
||||
<Button
|
||||
className={classNames(styles['button'], { 'disabled': disabled })}
|
||||
onMouseDown={onDecrementMouseDown}
|
||||
onMouseUp={onDecrementMouseUp}
|
||||
onMouseLeave={cancel}
|
||||
>
|
||||
<Icon className={styles['icon']} name={'remove'} />
|
||||
</Button>
|
||||
<div className={styles['value']}>
|
||||
{ disabled ? '--' : `${value}${unit}` }
|
||||
</div>
|
||||
<Button
|
||||
className={classNames(styles['button'], { 'disabled': disabled })}
|
||||
onMouseDown={onIncrementMouseDown}
|
||||
onMouseUp={onIncrementMouseUp}
|
||||
onMouseLeave={cancel}
|
||||
>
|
||||
<Icon className={styles['icon']} name={'add'} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Stepper;
|
||||
2
src/routes/Player/SubtitlesMenu/Stepper/index.ts
Normal file
2
src/routes/Player/SubtitlesMenu/Stepper/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
import Stepper from './Stepper';
|
||||
export default Stepper;
|
||||
|
|
@ -3,11 +3,12 @@
|
|||
const React = require('react');
|
||||
const PropTypes = require('prop-types');
|
||||
const classnames = require('classnames');
|
||||
const { CONSTANTS, comparatorWithPriorities, languages } = require('stremio/common');
|
||||
const { comparatorWithPriorities, languages } = require('stremio/common');
|
||||
const { SUBTITLES_SIZES } = require('stremio/common/CONSTANTS');
|
||||
const { Button } = require('stremio/components');
|
||||
const DiscreteSelectInput = require('./DiscreteSelectInput');
|
||||
const styles = require('./styles');
|
||||
const { t } = require('i18next');
|
||||
const { default: Stepper } = require('./Stepper');
|
||||
|
||||
const ORIGIN_PRIORITIES = {
|
||||
'LOCAL': 3,
|
||||
|
|
@ -98,51 +99,41 @@ const SubtitlesMenu = React.memo((props) => {
|
|||
}
|
||||
}
|
||||
}, [props.onSubtitlesTrackSelected, props.onExtraSubtitlesTrackSelected]);
|
||||
const onSubtitlesDelayChanged = React.useCallback((event) => {
|
||||
const delta = event.value === 'increment' ? 250 : -250;
|
||||
const onSubtitlesDelayChanged = React.useCallback((value) => {
|
||||
if (typeof props.selectedExtraSubtitlesTrackId === 'string') {
|
||||
if (props.extraSubtitlesDelay !== null && !isNaN(props.extraSubtitlesDelay)) {
|
||||
const extraDelay = props.extraSubtitlesDelay + delta;
|
||||
if (typeof props.onExtraSubtitlesDelayChanged === 'function') {
|
||||
props.onExtraSubtitlesDelayChanged(extraDelay);
|
||||
props.onExtraSubtitlesDelayChanged(value * 1000);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [props.selectedExtraSubtitlesTrackId, props.extraSubtitlesDelay, props.onExtraSubtitlesDelayChanged]);
|
||||
const onSubtitlesSizeChanged = React.useCallback((event) => {
|
||||
const delta = event.value === 'increment' ? 1 : -1;
|
||||
const onSubtitlesSizeChanged = React.useCallback((value) => {
|
||||
if (typeof props.selectedSubtitlesTrackId === 'string') {
|
||||
if (props.subtitlesSize !== null && !isNaN(props.subtitlesSize)) {
|
||||
const sizeIndex = CONSTANTS.SUBTITLES_SIZES.indexOf(props.subtitlesSize);
|
||||
const size = CONSTANTS.SUBTITLES_SIZES[Math.max(0, Math.min(CONSTANTS.SUBTITLES_SIZES.length - 1, sizeIndex + delta))];
|
||||
if (typeof props.onSubtitlesSizeChanged === 'function') {
|
||||
props.onSubtitlesSizeChanged(size);
|
||||
props.onSubtitlesSizeChanged(value);
|
||||
}
|
||||
}
|
||||
} else if (typeof props.selectedExtraSubtitlesTrackId === 'string') {
|
||||
if (props.extraSubtitlesSize !== null && !isNaN(props.extraSubtitlesSize)) {
|
||||
const extraSizeIndex = CONSTANTS.SUBTITLES_SIZES.indexOf(props.extraSubtitlesSize);
|
||||
const extraSize = CONSTANTS.SUBTITLES_SIZES[Math.max(0, Math.min(CONSTANTS.SUBTITLES_SIZES.length - 1, extraSizeIndex + delta))];
|
||||
if (typeof props.onExtraSubtitlesSizeChanged === 'function') {
|
||||
props.onExtraSubtitlesSizeChanged(extraSize);
|
||||
props.onExtraSubtitlesSizeChanged(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [props.selectedSubtitlesTrackId, props.selectedExtraSubtitlesTrackId, props.subtitlesSize, props.extraSubtitlesSize, props.onSubtitlesSizeChanged, props.onExtraSubtitlesSizeChanged]);
|
||||
const onSubtitlesOffsetChanged = React.useCallback((event) => {
|
||||
const delta = event.value === 'increment' ? 1 : -1;
|
||||
const onSubtitlesOffsetChanged = React.useCallback((value) => {
|
||||
if (typeof props.selectedSubtitlesTrackId === 'string') {
|
||||
if (props.subtitlesOffset !== null && !isNaN(props.subtitlesOffset)) {
|
||||
const offset = Math.max(0, Math.min(100, Math.floor(props.subtitlesOffset + delta)));
|
||||
if (typeof props.onSubtitlesOffsetChanged === 'function') {
|
||||
props.onSubtitlesOffsetChanged(offset);
|
||||
props.onSubtitlesOffsetChanged(value);
|
||||
}
|
||||
}
|
||||
} else if (typeof props.selectedExtraSubtitlesTrackId === 'string') {
|
||||
if (props.extraSubtitlesOffset !== null && !isNaN(props.extraSubtitlesOffset)) {
|
||||
const offset = Math.max(0, Math.min(100, Math.floor(props.extraSubtitlesOffset + delta)));
|
||||
if (typeof props.onExtraSubtitlesOffsetChanged === 'function') {
|
||||
props.onExtraSubtitlesOffsetChanged(offset);
|
||||
props.onExtraSubtitlesOffsetChanged(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -215,57 +206,35 @@ const SubtitlesMenu = React.memo((props) => {
|
|||
<div className={styles['subtitles-settings-container']}>
|
||||
<div className={styles['settings-header']}>{t('PLAYER_SUBTITLES_SETTINGS')}</div>
|
||||
<div className={styles['settings-list']}>
|
||||
<DiscreteSelectInput
|
||||
className={styles['discrete-input']}
|
||||
label={t('DELAY')}
|
||||
value={typeof props.selectedExtraSubtitlesTrackId === 'string' && props.extraSubtitlesDelay !== null && !isNaN(props.extraSubtitlesDelay) ? `${(props.extraSubtitlesDelay / 1000).toFixed(2)}s` : '--'}
|
||||
disabled={typeof props.selectedExtraSubtitlesTrackId !== 'string' || props.extraSubtitlesDelay === null || isNaN(props.extraSubtitlesDelay)}
|
||||
<Stepper
|
||||
className={styles['stepper']}
|
||||
label={'DELAY'}
|
||||
value={props.extraSubtitlesDelay / 1000}
|
||||
unit={'s'}
|
||||
step={0.25}
|
||||
disabled={props.extraSubtitlesDelay === null}
|
||||
onChange={onSubtitlesDelayChanged}
|
||||
/>
|
||||
<DiscreteSelectInput
|
||||
className={styles['discrete-input']}
|
||||
label={t('SIZE')}
|
||||
value={
|
||||
typeof props.selectedSubtitlesTrackId === 'string' ?
|
||||
props.subtitlesSize !== null && !isNaN(props.subtitlesSize) ? `${props.subtitlesSize}%` : '--'
|
||||
:
|
||||
typeof props.selectedExtraSubtitlesTrackId === 'string' ?
|
||||
props.extraSubtitlesSize !== null && !isNaN(props.extraSubtitlesSize) ? `${props.extraSubtitlesSize}%` : '--'
|
||||
:
|
||||
'--'
|
||||
}
|
||||
disabled={
|
||||
typeof props.selectedSubtitlesTrackId === 'string' ?
|
||||
props.subtitlesSize === null || isNaN(props.subtitlesSize)
|
||||
:
|
||||
typeof props.selectedExtraSubtitlesTrackId === 'string' ?
|
||||
props.extraSubtitlesSize === null || isNaN(props.extraSubtitlesSize)
|
||||
:
|
||||
true
|
||||
}
|
||||
<Stepper
|
||||
className={styles['stepper']}
|
||||
label={'SIZE'}
|
||||
value={props.selectedSubtitlesTrackId ? props.subtitlesSize : props.selectedExtraSubtitlesTrackId ? props.extraSubtitlesSize : null}
|
||||
unit={'%'}
|
||||
step={25}
|
||||
min={SUBTITLES_SIZES[0]}
|
||||
max={SUBTITLES_SIZES[SUBTITLES_SIZES.length - 1]}
|
||||
disabled={(props.selectedSubtitlesTrackId && props.subtitlesSize === null) || (props.selectedExtraSubtitlesTrackId && props.extraSubtitlesSize === null)}
|
||||
onChange={onSubtitlesSizeChanged}
|
||||
/>
|
||||
<DiscreteSelectInput
|
||||
className={styles['discrete-input']}
|
||||
label={t('PLAYER_SUBTITLES_VERTICAL_POSIITON')}
|
||||
value={
|
||||
typeof props.selectedSubtitlesTrackId === 'string' ?
|
||||
props.subtitlesOffset !== null && !isNaN(props.subtitlesOffset) ? `${props.subtitlesOffset}%` : '--'
|
||||
:
|
||||
typeof props.selectedExtraSubtitlesTrackId === 'string' ?
|
||||
props.extraSubtitlesOffset !== null && !isNaN(props.extraSubtitlesOffset) ? `${props.extraSubtitlesOffset}%` : '--'
|
||||
:
|
||||
'--'
|
||||
}
|
||||
disabled={
|
||||
typeof props.selectedSubtitlesTrackId === 'string' ?
|
||||
props.subtitlesOffset === null || isNaN(props.subtitlesOffset)
|
||||
:
|
||||
typeof props.selectedExtraSubtitlesTrackId === 'string' ?
|
||||
props.extraSubtitlesOffset === null || isNaN(props.extraSubtitlesOffset)
|
||||
:
|
||||
true
|
||||
}
|
||||
<Stepper
|
||||
className={styles['stepper']}
|
||||
label={'PLAYER_SUBTITLES_VERTICAL_POSIITON'}
|
||||
value={props.selectedSubtitlesTrackId ? props.subtitlesOffset : props.selectedExtraSubtitlesTrackId ? props.extraSubtitlesOffset : null}
|
||||
unit={'%'}
|
||||
step={1}
|
||||
min={0}
|
||||
max={100}
|
||||
disabled={(props.selectedSubtitlesTrackId && props.subtitlesOffset === null) || (props.selectedExtraSubtitlesTrackId && props.extraSubtitlesOffset === null)}
|
||||
onChange={onSubtitlesOffsetChanged}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -114,7 +114,7 @@
|
|||
flex: 1;
|
||||
}
|
||||
|
||||
.discrete-input {
|
||||
.stepper {
|
||||
padding: 0 1.5rem 1rem;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -107,6 +107,13 @@ html:not(.active-slider-within) {
|
|||
}
|
||||
}
|
||||
|
||||
&.indicator-layer {
|
||||
top: initial;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 10rem;
|
||||
}
|
||||
|
||||
&.menu-layer {
|
||||
top: initial;
|
||||
left: initial;
|
||||
|
|
|
|||
|
|
@ -102,8 +102,8 @@ const usePlayer = (urlParams) => {
|
|||
args: {
|
||||
action: 'TimeChanged',
|
||||
args: {
|
||||
time: Math.round(time),
|
||||
duration,
|
||||
time: Math.max(0, Math.round(time)),
|
||||
duration: Math.max(0, Math.round(duration)),
|
||||
device,
|
||||
}
|
||||
}
|
||||
|
|
@ -118,8 +118,8 @@ const usePlayer = (urlParams) => {
|
|||
args: {
|
||||
action: 'Seek',
|
||||
args: {
|
||||
time: Math.round(time),
|
||||
duration,
|
||||
time: Math.max(0, Math.round(time)),
|
||||
duration: Math.max(0, Math.round(duration)),
|
||||
device,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@
|
|||
}
|
||||
|
||||
.search-container {
|
||||
height: 100%;
|
||||
height: calc(100% - var(--safe-area-inset-bottom));
|
||||
width: 100%;
|
||||
background-color: transparent;
|
||||
|
||||
|
|
|
|||
11
src/routes/Settings/General/General.less
Normal file
11
src/routes/Settings/General/General.less
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
:import('~stremio/routes/Settings/components/Option/Option.less') {
|
||||
option-icon: icon;
|
||||
}
|
||||
|
||||
.trakt-container {
|
||||
margin-top: 2rem;
|
||||
|
||||
.option-icon {
|
||||
color: var(--color-trakt) !important;
|
||||
}
|
||||
}
|
||||
189
src/routes/Settings/General/General.tsx
Normal file
189
src/routes/Settings/General/General.tsx
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
import React, { forwardRef, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button, MultiselectMenu, Toggle } from 'stremio/components';
|
||||
import { useServices } from 'stremio/services';
|
||||
import { usePlatform, useToast } from 'stremio/common';
|
||||
import { Section, Option, Link } from '../components';
|
||||
import User from './User';
|
||||
import useDataExport from './useDataExport';
|
||||
import styles from './General.less';
|
||||
import useGeneralOptions from './useGeneralOptions';
|
||||
|
||||
type Props = {
|
||||
profile: Profile,
|
||||
};
|
||||
|
||||
const General = forwardRef<HTMLDivElement, Props>(({ profile }: Props, ref) => {
|
||||
const { t } = useTranslation();
|
||||
const { core, shell } = useServices();
|
||||
const platform = usePlatform();
|
||||
const toast = useToast();
|
||||
const [dataExport, loadDataExport] = useDataExport();
|
||||
|
||||
const {
|
||||
interfaceLanguageSelect,
|
||||
quitOnCloseToggle,
|
||||
escExitFullscreenToggle,
|
||||
hideSpoilersToggle,
|
||||
gamepadSupportToggle,
|
||||
} = useGeneralOptions(profile);
|
||||
|
||||
const [traktAuthStarted, setTraktAuthStarted] = useState(false);
|
||||
|
||||
const isTraktAuthenticated = useMemo(() => {
|
||||
const trakt = profile?.auth?.user?.trakt;
|
||||
return trakt && (Date.now() / 1000) < (trakt.created_at + trakt.expires_in);
|
||||
}, [profile.auth]);
|
||||
|
||||
const onExportData = useCallback(() => {
|
||||
loadDataExport();
|
||||
}, []);
|
||||
|
||||
const onCalendarSubscribe = useCallback(() => {
|
||||
if (!profile.auth) return;
|
||||
|
||||
const protocol = platform.name === 'ios' ? 'webcal' : 'https';
|
||||
const url = `${protocol}://www.strem.io/calendar/${profile.auth.user._id}.ics`;
|
||||
platform.openExternal(url);
|
||||
|
||||
toast.show({
|
||||
type: 'success',
|
||||
title: platform.name === 'ios' ?
|
||||
t('SETTINGS_SUBSCRIBE_CALENDAR_IOS_TOAST') :
|
||||
t('SETTINGS_SUBSCRIBE_CALENDAR_TOAST'),
|
||||
timeout: 25000
|
||||
});
|
||||
// Stremio 4 emits not documented event subscribeCalendar
|
||||
}, [profile.auth]);
|
||||
|
||||
const onToggleTrakt = useCallback(() => {
|
||||
if (!isTraktAuthenticated && profile.auth !== null && profile.auth.user !== null && typeof profile.auth.user._id === 'string') {
|
||||
platform.openExternal(`https://www.strem.io/trakt/auth/${profile.auth.user._id}`);
|
||||
setTraktAuthStarted(true);
|
||||
} else {
|
||||
core.transport.dispatch({
|
||||
action: 'Ctx',
|
||||
args: {
|
||||
action: 'LogoutTrakt'
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [isTraktAuthenticated, profile.auth]);
|
||||
|
||||
useEffect(() => {
|
||||
if (dataExport.exportUrl) {
|
||||
platform.openExternal(dataExport.exportUrl);
|
||||
}
|
||||
}, [dataExport.exportUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isTraktAuthenticated && traktAuthStarted) {
|
||||
core.transport.dispatch({
|
||||
action: 'Ctx',
|
||||
args: {
|
||||
action: 'InstallTraktAddon'
|
||||
}
|
||||
});
|
||||
setTraktAuthStarted(false);
|
||||
}
|
||||
}, [isTraktAuthenticated, traktAuthStarted]);
|
||||
|
||||
return <>
|
||||
<Section ref={ref}>
|
||||
<User profile={profile} />
|
||||
</Section>
|
||||
|
||||
<Section>
|
||||
{
|
||||
profile?.auth?.user &&
|
||||
<Link
|
||||
label={t('SETTINGS_DATA_EXPORT')}
|
||||
onClick={onExportData}
|
||||
/>
|
||||
}
|
||||
{
|
||||
profile?.auth?.user &&
|
||||
<Link
|
||||
label={t('SETTINGS_SUBSCRIBE_CALENDAR')}
|
||||
onClick={onCalendarSubscribe}
|
||||
/>
|
||||
}
|
||||
<Link
|
||||
label={t('SETTINGS_SUPPORT')}
|
||||
href={'https://stremio.zendesk.com/hc/en-us'}
|
||||
/>
|
||||
<Link
|
||||
label={t('SETTINGS_SOURCE_CODE')}
|
||||
href={`https://github.com/stremio/stremio-web/tree/${process.env.COMMIT_HASH}`}
|
||||
/>
|
||||
<Link
|
||||
label={t('TERMS_OF_SERVICE')}
|
||||
href={'https://www.stremio.com/tos'}
|
||||
/>
|
||||
<Link
|
||||
label={t('PRIVACY_POLICY')}
|
||||
href={'https://www.stremio.com/privacy'}
|
||||
/>
|
||||
{
|
||||
profile?.auth?.user &&
|
||||
<Link
|
||||
label={t('SETTINGS_ACC_DELETE')}
|
||||
href={'https://stremio.zendesk.com/hc/en-us/articles/360021428911-How-to-delete-my-account'}
|
||||
/>
|
||||
}
|
||||
{
|
||||
profile?.auth?.user?.email &&
|
||||
<Link
|
||||
label={t('SETTINGS_CHANGE_PASSWORD')}
|
||||
href={`https://www.strem.io/reset-password/${profile.auth.user.email}`}
|
||||
/>
|
||||
}
|
||||
<Option className={styles['trakt-container']} icon={'trakt'} label={t('SETTINGS_TRAKT')}>
|
||||
<Button className={'button'} title={isTraktAuthenticated ? t('LOG_OUT') : t('SETTINGS_TRAKT_AUTHENTICATE')} disabled={profile.auth === null} tabIndex={-1} onClick={onToggleTrakt}>
|
||||
{isTraktAuthenticated ? t('LOG_OUT') : t('SETTINGS_TRAKT_AUTHENTICATE')}
|
||||
</Button>
|
||||
</Option>
|
||||
</Section>
|
||||
|
||||
<Section>
|
||||
<Option label={'SETTINGS_UI_LANGUAGE'}>
|
||||
<MultiselectMenu
|
||||
className={'multiselect'}
|
||||
{...interfaceLanguageSelect}
|
||||
/>
|
||||
</Option>
|
||||
{
|
||||
shell.active &&
|
||||
<Option label={'SETTINGS_QUIT_ON_CLOSE'}>
|
||||
<Toggle
|
||||
tabIndex={-1}
|
||||
{...quitOnCloseToggle}
|
||||
/>
|
||||
</Option>
|
||||
}
|
||||
{
|
||||
shell.active &&
|
||||
<Option label={'SETTINGS_FULLSCREEN_EXIT'}>
|
||||
<Toggle
|
||||
tabIndex={-1}
|
||||
{...escExitFullscreenToggle}
|
||||
/>
|
||||
</Option>
|
||||
}
|
||||
<Option label={'SETTINGS_BLUR_UNWATCHED_IMAGE'}>
|
||||
<Toggle
|
||||
tabIndex={-1}
|
||||
{...hideSpoilersToggle}
|
||||
/>
|
||||
</Option>
|
||||
<Option label={'SETTINGS_GAMEPAD'}>
|
||||
<Toggle
|
||||
tabIndex={-1}
|
||||
{...gamepadSupportToggle}
|
||||
/>
|
||||
</Option>
|
||||
</Section>
|
||||
</>;
|
||||
});
|
||||
|
||||
export default General;
|
||||
87
src/routes/Settings/General/User/User.less
Normal file
87
src/routes/Settings/General/User/User.less
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
@import (reference) '~stremio/common/screen-sizes.less';
|
||||
|
||||
.user {
|
||||
gap: 1rem;
|
||||
|
||||
.user-info-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
|
||||
.avatar-container {
|
||||
flex: none;
|
||||
align-self: stretch;
|
||||
height: 5rem;
|
||||
width: 5rem;
|
||||
margin-right: 1rem;
|
||||
border: 2px solid var(--primary-accent-color);
|
||||
border-radius: 50%;
|
||||
background-size: cover;
|
||||
background-repeat: no-repeat;
|
||||
background-position: center;
|
||||
background-origin: content-box;
|
||||
background-clip: content-box;
|
||||
opacity: 0.9;
|
||||
background-color: var(--primary-foreground-color);
|
||||
}
|
||||
|
||||
.email-logout-container {
|
||||
flex: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: start;
|
||||
|
||||
.email-label-container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.email-label-container {
|
||||
.email-label {
|
||||
flex: 1;
|
||||
font-size: 1.1rem;
|
||||
color: var(--primary-foreground-color);
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.user-panel-container {
|
||||
flex: none;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
width: 10rem;
|
||||
height: 3.5rem;
|
||||
border-radius: 3.5rem;
|
||||
background-color: var(--overlay-color);
|
||||
|
||||
&:hover {
|
||||
outline: var(--focus-outline-size) solid var(--primary-foreground-color);
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.user-panel-label {
|
||||
flex: 1;
|
||||
max-height: 2.4em;
|
||||
padding: 0 0.5rem;
|
||||
font-weight: 500;
|
||||
text-align: center;
|
||||
color: var(--primary-foreground-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media only screen and (max-width: @minimum) {
|
||||
.user {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
|
||||
.user-panel-container {
|
||||
width: 100% !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
66
src/routes/Settings/General/User/User.tsx
Normal file
66
src/routes/Settings/General/User/User.tsx
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
import React, { useCallback, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useServices } from 'stremio/services';
|
||||
import { Link } from '../../components';
|
||||
import styles from './User.less';
|
||||
|
||||
type Props = {
|
||||
profile: Profile,
|
||||
};
|
||||
|
||||
const User = ({ profile }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const { core } = useServices();
|
||||
|
||||
const avatar = useMemo(() => (
|
||||
!profile.auth ?
|
||||
`url('${require('/images/anonymous.png')}')`
|
||||
:
|
||||
profile.auth.user.avatar ?
|
||||
`url('${profile.auth.user.avatar}')`
|
||||
:
|
||||
`url('${require('/images/default_avatar.png')}')`
|
||||
), [profile.auth]);
|
||||
|
||||
const onLogout = useCallback(() => {
|
||||
core.transport.dispatch({
|
||||
action: 'Ctx',
|
||||
args: {
|
||||
action: 'Logout'
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={styles['user']}>
|
||||
<div className={styles['user-info-content']}>
|
||||
<div
|
||||
className={styles['avatar-container']}
|
||||
style={{ backgroundImage: avatar }}
|
||||
/>
|
||||
<div className={styles['email-logout-container']}>
|
||||
<div className={styles['email-label-container']} title={profile.auth === null ? t('ANONYMOUS_USER') : profile.auth.user.email}>
|
||||
<div className={styles['email-label']}>
|
||||
{profile.auth === null ? t('ANONYMOUS_USER') : profile.auth.user.email}
|
||||
</div>
|
||||
</div>
|
||||
{
|
||||
profile.auth !== null ?
|
||||
<Link
|
||||
label={t('LOG_OUT')}
|
||||
onClick={onLogout}
|
||||
/>
|
||||
:
|
||||
<Link
|
||||
label={`${t('LOG_IN')} / ${t('SIGN_UP')}`}
|
||||
href={'#/intro'}
|
||||
target={'_self'}
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default User;
|
||||
2
src/routes/Settings/General/User/index.ts
Normal file
2
src/routes/Settings/General/User/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
import User from './User';
|
||||
export default User;
|
||||
2
src/routes/Settings/General/index.ts
Normal file
2
src/routes/Settings/General/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
import General from './General';
|
||||
export default General;
|
||||
6
src/routes/Settings/General/useDataExport.d.ts
vendored
Normal file
6
src/routes/Settings/General/useDataExport.d.ts
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
declare const useDataExport: () => [
|
||||
DataExport,
|
||||
() => void,
|
||||
];
|
||||
|
||||
export = useDataExport;
|
||||
109
src/routes/Settings/General/useGeneralOptions.ts
Normal file
109
src/routes/Settings/General/useGeneralOptions.ts
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
import { useMemo } from 'react';
|
||||
import { interfaceLanguages, useLanguageSorting } from 'stremio/common';
|
||||
import { useServices } from 'stremio/services';
|
||||
|
||||
const useGeneralOptions = (profile: Profile) => {
|
||||
const { core } = useServices();
|
||||
|
||||
const interfaceLanguageOptions = useMemo(() =>
|
||||
interfaceLanguages.map(({ name, codes }) => ({
|
||||
value: codes[0],
|
||||
label: name,
|
||||
})),
|
||||
[]);
|
||||
|
||||
const { sortedOptions } = useLanguageSorting(interfaceLanguageOptions);
|
||||
|
||||
const interfaceLanguageSelect = useMemo(() => ({
|
||||
options: sortedOptions,
|
||||
value:
|
||||
interfaceLanguages.find(({ codes }) => codes[1] === profile.settings.interfaceLanguage)?.codes?.[0] ||
|
||||
profile.settings.interfaceLanguage,
|
||||
onSelect: (value: string) => {
|
||||
core.transport.dispatch({
|
||||
action: 'Ctx',
|
||||
args: {
|
||||
action: 'UpdateSettings',
|
||||
args: {
|
||||
...profile.settings,
|
||||
interfaceLanguage: value
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}), [profile.settings, sortedOptions]);
|
||||
|
||||
const escExitFullscreenToggle = useMemo(() => ({
|
||||
checked: profile.settings.escExitFullscreen,
|
||||
onClick: () => {
|
||||
core.transport.dispatch({
|
||||
action: 'Ctx',
|
||||
args: {
|
||||
action: 'UpdateSettings',
|
||||
args: {
|
||||
...profile.settings,
|
||||
escExitFullscreen: !profile.settings.escExitFullscreen
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}), [profile.settings]);
|
||||
|
||||
const quitOnCloseToggle = useMemo(() => ({
|
||||
checked: profile.settings.quitOnClose,
|
||||
onClick: () => {
|
||||
core.transport.dispatch({
|
||||
action: 'Ctx',
|
||||
args: {
|
||||
action: 'UpdateSettings',
|
||||
args: {
|
||||
...profile.settings,
|
||||
quitOnClose: !profile.settings.quitOnClose
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}), [profile.settings]);
|
||||
|
||||
const hideSpoilersToggle = useMemo(() => ({
|
||||
checked: profile.settings.hideSpoilers,
|
||||
onClick: () => {
|
||||
core.transport.dispatch({
|
||||
action: 'Ctx',
|
||||
args: {
|
||||
action: 'UpdateSettings',
|
||||
args: {
|
||||
...profile.settings,
|
||||
hideSpoilers: !profile.settings.hideSpoilers
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}), [profile.settings]);
|
||||
|
||||
const gamepadSupportToggle = useMemo(() => ({
|
||||
checked: profile.settings.gamepadSupport,
|
||||
onClick: () => {
|
||||
core.transport.dispatch({
|
||||
action: 'Ctx',
|
||||
args: {
|
||||
action: 'UpdateSettings',
|
||||
args: {
|
||||
...profile.settings,
|
||||
gamepadSupport: !profile.settings.gamepadSupport
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}), [profile.settings]);
|
||||
|
||||
return {
|
||||
interfaceLanguageSelect,
|
||||
escExitFullscreenToggle,
|
||||
quitOnCloseToggle,
|
||||
hideSpoilersToggle,
|
||||
gamepadSupportToggle,
|
||||
};
|
||||
};
|
||||
|
||||
export default useGeneralOptions;
|
||||
31
src/routes/Settings/Info/Info.less
Normal file
31
src/routes/Settings/Info/Info.less
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
@import (reference) '~stremio/common/screen-sizes.less';
|
||||
|
||||
:import('~stremio/routes/Settings/components/Option/Option.less') {
|
||||
option-content: content;
|
||||
}
|
||||
|
||||
.info {
|
||||
display: none;
|
||||
|
||||
.option-content {
|
||||
color: var(--primary-foreground-color);
|
||||
overflow: hidden;
|
||||
|
||||
.label {
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media only screen and (max-width: @xsmall) {
|
||||
.info {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
|
||||
@media only screen and (max-width: @minimum) {
|
||||
.info {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
52
src/routes/Settings/Info/Info.tsx
Normal file
52
src/routes/Settings/Info/Info.tsx
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import React, { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useServices } from 'stremio/services';
|
||||
import { Option, Section } from '../components';
|
||||
import styles from './Info.less';
|
||||
|
||||
type Props = {
|
||||
streamingServer: StreamingServer,
|
||||
};
|
||||
|
||||
const Info = ({ streamingServer }: Props) => {
|
||||
const { shell } = useServices();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const settings = useMemo(() => (
|
||||
streamingServer?.settings?.type === 'Ready' ?
|
||||
streamingServer.settings.content as StreamingServerSettings : null
|
||||
), [streamingServer?.settings]);
|
||||
|
||||
return (
|
||||
<Section className={styles['info']}>
|
||||
<Option label={t('SETTINGS_APP_VERSION')}>
|
||||
<div className={styles['label']}>
|
||||
{process.env.VERSION}
|
||||
</div>
|
||||
</Option>
|
||||
<Option label={t('SETTINGS_BUILD_VERSION')}>
|
||||
<div className={styles['label']}>
|
||||
{process.env.COMMIT_HASH}
|
||||
</div>
|
||||
</Option>
|
||||
{
|
||||
settings?.serverVersion &&
|
||||
<Option label={t('SETTINGS_SERVER_VERSION')}>
|
||||
<div className={styles['label']}>
|
||||
{settings.serverVersion}
|
||||
</div>
|
||||
</Option>
|
||||
}
|
||||
{
|
||||
typeof shell?.transport?.props?.shellVersion === 'string' &&
|
||||
<Option label={t('SETTINGS_SHELL_VERSION')}>
|
||||
<div className={styles['label']}>
|
||||
{shell.transport.props.shellVersion}
|
||||
</div>
|
||||
</Option>
|
||||
}
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
export default Info;
|
||||
2
src/routes/Settings/Info/index.ts
Normal file
2
src/routes/Settings/Info/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
import Info from './Info';
|
||||
export default Info;
|
||||
62
src/routes/Settings/Menu/Menu.less
Normal file
62
src/routes/Settings/Menu/Menu.less
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
@import (reference) '~stremio/common/screen-sizes.less';
|
||||
|
||||
.menu {
|
||||
flex: none;
|
||||
align-self: stretch;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 18rem;
|
||||
padding: 3rem 1.5rem;
|
||||
|
||||
.button {
|
||||
flex: none;
|
||||
align-self: stretch;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 4rem;
|
||||
border-radius: 4rem;
|
||||
padding: 2rem;
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 500;
|
||||
color: var(--primary-foreground-color);
|
||||
opacity: 0.4;
|
||||
|
||||
&.selected {
|
||||
font-weight: 600;
|
||||
color: var(--primary-foreground-color);
|
||||
background-color: var(--overlay-color);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: var(--overlay-color);
|
||||
}
|
||||
}
|
||||
|
||||
.spacing {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.version-info-label {
|
||||
flex: 0 1 auto;
|
||||
margin: 0.5rem 0;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
color: var(--primary-foreground-color);
|
||||
opacity: 0.3;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
@media only screen and (max-width: @xsmall) {
|
||||
.menu {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media only screen and (max-width: @minimum) {
|
||||
.menu {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
62
src/routes/Settings/Menu/Menu.tsx
Normal file
62
src/routes/Settings/Menu/Menu.tsx
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import React, { useMemo } from 'react';
|
||||
import classNames from 'classnames';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useServices } from 'stremio/services';
|
||||
import { Button } from 'stremio/components';
|
||||
import { SECTIONS } from '../constants';
|
||||
import styles from './Menu.less';
|
||||
|
||||
type Props = {
|
||||
selected: string,
|
||||
streamingServer: StreamingServer,
|
||||
onSelect: (event: React.MouseEvent<HTMLDivElement>) => void,
|
||||
};
|
||||
|
||||
const Menu = ({ selected, streamingServer, onSelect }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const { shell } = useServices();
|
||||
|
||||
const settings = useMemo(() => (
|
||||
streamingServer?.settings?.type === 'Ready' ?
|
||||
streamingServer.settings.content as StreamingServerSettings : null
|
||||
), [streamingServer?.settings]);
|
||||
|
||||
return (
|
||||
<div className={styles['menu']}>
|
||||
<Button className={classNames(styles['button'], { [styles['selected']]: selected === SECTIONS.GENERAL })} title={t('SETTINGS_NAV_GENERAL')} data-section={SECTIONS.GENERAL} onClick={onSelect}>
|
||||
{ t('SETTINGS_NAV_GENERAL') }
|
||||
</Button>
|
||||
<Button className={classNames(styles['button'], { [styles['selected']]: selected === SECTIONS.PLAYER })} title={t('SETTINGS_NAV_PLAYER')} data-section={SECTIONS.PLAYER} onClick={onSelect}>
|
||||
{ t('SETTINGS_NAV_PLAYER') }
|
||||
</Button>
|
||||
<Button className={classNames(styles['button'], { [styles['selected']]: selected === SECTIONS.STREAMING })} title={t('SETTINGS_NAV_STREAMING')} data-section={SECTIONS.STREAMING} onClick={onSelect}>
|
||||
{ t('SETTINGS_NAV_STREAMING') }
|
||||
</Button>
|
||||
<Button className={classNames(styles['button'], { [styles['selected']]: selected === SECTIONS.SHORTCUTS })} title={t('SETTINGS_NAV_SHORTCUTS')} data-section={SECTIONS.SHORTCUTS} onClick={onSelect}>
|
||||
{ t('SETTINGS_NAV_SHORTCUTS') }
|
||||
</Button>
|
||||
|
||||
<div className={styles['spacing']} />
|
||||
<div className={styles['version-info-label']} title={process.env.VERSION}>
|
||||
{t('SETTINGS_APP_VERSION')}: {process.env.VERSION}
|
||||
</div>
|
||||
<div className={styles['version-info-label']} title={process.env.COMMIT_HASH}>
|
||||
{t('SETTINGS_BUILD_VERSION')}: {process.env.COMMIT_HASH}
|
||||
</div>
|
||||
{
|
||||
settings?.serverVersion &&
|
||||
<div className={styles['version-info-label']} title={settings.serverVersion}>
|
||||
{t('SETTINGS_SERVER_VERSION')}: {settings.serverVersion}
|
||||
</div>
|
||||
}
|
||||
{
|
||||
typeof shell?.transport?.props?.shellVersion === 'string' &&
|
||||
<div className={styles['version-info-label']} title={shell.transport.props.shellVersion}>
|
||||
{t('SETTINGS_SHELL_VERSION')}: {shell.transport.props.shellVersion}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Menu;
|
||||
2
src/routes/Settings/Menu/index.ts
Normal file
2
src/routes/Settings/Menu/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
import Menu from './Menu';
|
||||
export default Menu;
|
||||
146
src/routes/Settings/Player/Player.tsx
Normal file
146
src/routes/Settings/Player/Player.tsx
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
import React, { forwardRef } from 'react';
|
||||
import { ColorInput, MultiselectMenu, Toggle } from 'stremio/components';
|
||||
import { useServices } from 'stremio/services';
|
||||
import { Category, Option, Section } from '../components';
|
||||
import usePlayerOptions from './usePlayerOptions';
|
||||
|
||||
type Props = {
|
||||
profile: Profile,
|
||||
};
|
||||
|
||||
const Player = forwardRef<HTMLDivElement, Props>(({ profile }: Props, ref) => {
|
||||
const { shell } = useServices();
|
||||
|
||||
const {
|
||||
subtitlesLanguageSelect,
|
||||
subtitlesSizeSelect,
|
||||
subtitlesTextColorInput,
|
||||
subtitlesBackgroundColorInput,
|
||||
subtitlesOutlineColorInput,
|
||||
audioLanguageSelect,
|
||||
surroundSoundToggle,
|
||||
seekTimeDurationSelect,
|
||||
seekShortTimeDurationSelect,
|
||||
playInExternalPlayerSelect,
|
||||
nextVideoPopupDurationSelect,
|
||||
bingeWatchingToggle,
|
||||
playInBackgroundToggle,
|
||||
hardwareDecodingToggle,
|
||||
pauseOnMinimizeToggle,
|
||||
} = usePlayerOptions(profile);
|
||||
|
||||
return (
|
||||
<Section ref={ref} label={'SETTINGS_NAV_PLAYER'}>
|
||||
<Category icon={'subtitles'} label={'SETTINGS_SECTION_SUBTITLES'}>
|
||||
<Option label={'SETTINGS_SUBTITLES_LANGUAGE'}>
|
||||
<MultiselectMenu
|
||||
className={'multiselect'}
|
||||
{...subtitlesLanguageSelect}
|
||||
/>
|
||||
</Option>
|
||||
<Option label={'SETTINGS_SUBTITLES_SIZE'}>
|
||||
<MultiselectMenu
|
||||
className={'multiselect'}
|
||||
{...subtitlesSizeSelect}
|
||||
/>
|
||||
</Option>
|
||||
<Option label={'SETTINGS_SUBTITLES_COLOR'}>
|
||||
<ColorInput
|
||||
className={'color-input'}
|
||||
{...subtitlesTextColorInput}
|
||||
/>
|
||||
</Option>
|
||||
<Option label={'SETTINGS_SUBTITLES_COLOR_BACKGROUND'}>
|
||||
<ColorInput
|
||||
className={'color-input'}
|
||||
{...subtitlesBackgroundColorInput}
|
||||
/>
|
||||
</Option>
|
||||
<Option label={'SETTINGS_SUBTITLES_COLOR_OUTLINE'}>
|
||||
<ColorInput
|
||||
className={'color-input'}
|
||||
{...subtitlesOutlineColorInput}
|
||||
/>
|
||||
</Option>
|
||||
</Category>
|
||||
<Category icon={'volume-medium'} label={'SETTINGS_SECTION_AUDIO'}>
|
||||
<Option label={'SETTINGS_DEFAULT_AUDIO_TRACK'}>
|
||||
<MultiselectMenu
|
||||
className={'multiselect'}
|
||||
{...audioLanguageSelect}
|
||||
/>
|
||||
</Option>
|
||||
<Option label={'SETTINGS_SURROUND_SOUND'}>
|
||||
<Toggle
|
||||
tabIndex={-1}
|
||||
{...surroundSoundToggle}
|
||||
/>
|
||||
</Option>
|
||||
</Category>
|
||||
<Category icon={'remote'} label={'SETTINGS_SECTION_CONTROLS'}>
|
||||
<Option label={'SETTINGS_SEEK_KEY'}>
|
||||
<MultiselectMenu
|
||||
className={'multiselect'}
|
||||
{...seekTimeDurationSelect}
|
||||
/>
|
||||
</Option>
|
||||
<Option label={'SETTINGS_SEEK_KEY_SHIFT'}>
|
||||
<MultiselectMenu
|
||||
className={'multiselect'}
|
||||
{...seekShortTimeDurationSelect}
|
||||
/>
|
||||
</Option>
|
||||
<Option label={'SETTINGS_PLAY_IN_BACKGROUND'}>
|
||||
<Toggle
|
||||
disabled={true}
|
||||
tabIndex={-1}
|
||||
{...playInBackgroundToggle}
|
||||
/>
|
||||
</Option>
|
||||
</Category>
|
||||
<Category icon={'play'} label={'SETTINGS_SECTION_AUTO_PLAY'}>
|
||||
<Option label={'AUTO_PLAY'}>
|
||||
<Toggle
|
||||
tabIndex={-1}
|
||||
{...bingeWatchingToggle}
|
||||
/>
|
||||
</Option>
|
||||
<Option label={'SETTINGS_NEXT_VIDEO_POPUP_DURATION'}>
|
||||
<MultiselectMenu
|
||||
className={'multiselect'}
|
||||
disabled={!profile.settings.bingeWatching}
|
||||
{...nextVideoPopupDurationSelect}
|
||||
/>
|
||||
</Option>
|
||||
</Category>
|
||||
<Category icon={'glasses'} label={'SETTINGS_SECTION_ADVANCED'}>
|
||||
<Option label={'SETTINGS_PLAY_IN_EXTERNAL_PLAYER'}>
|
||||
<MultiselectMenu
|
||||
className={'multiselect'}
|
||||
{...playInExternalPlayerSelect}
|
||||
/>
|
||||
</Option>
|
||||
{
|
||||
shell.active &&
|
||||
<Option label={'SETTINGS_HWDEC'}>
|
||||
<Toggle
|
||||
tabIndex={-1}
|
||||
{...hardwareDecodingToggle}
|
||||
/>
|
||||
</Option>
|
||||
}
|
||||
{
|
||||
shell.active &&
|
||||
<Option label={'SETTINGS_PAUSE_MINIMIZED'}>
|
||||
<Toggle
|
||||
tabIndex={-1}
|
||||
{...pauseOnMinimizeToggle}
|
||||
/>
|
||||
</Option>
|
||||
}
|
||||
</Category>
|
||||
</Section>
|
||||
);
|
||||
});
|
||||
|
||||
export default Player;
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue