Merge branch 'development' into feat/gamepad-support

This commit is contained in:
Botzy 2025-08-28 12:08:03 +03:00
commit ddf842b0c7
149 changed files with 2960 additions and 1951 deletions

View file

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

View file

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

View file

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

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

View file

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

View file

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

@ -0,0 +1,11 @@
type ToastOptions = {
type: string,
title: string,
timeout: number,
};
declare const useToast: () => {
show: (options: ToastOptions) => void,
};
export = useToast;

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

@ -23,6 +23,7 @@
.link {
font-size: 0.9rem;
color: var(--primary-accent-color);
margin-left: 0.5rem;
&:hover {
text-decoration: underline;

View file

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

View file

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

View file

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

View file

@ -1,6 +0,0 @@
// Copyright (C) 2017-2023 Smart code 203358507
const ColorInput = require('./ColorInput');
module.exports = ColorInput;

View file

@ -0,0 +1,6 @@
// Copyright (C) 2017-2023 Smart code 203358507
import ColorInput from './ColorInput';
export default ColorInput;

View file

@ -65,7 +65,6 @@
padding: 0 1rem;
.icon-container {
height: 2rem;
width: 2rem;
}
}

View file

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

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

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

View file

@ -0,0 +1,5 @@
// Copyright (C) 2017-2025 Smart code 203358507
import Ratings from './Ratings';
export { Ratings };

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,7 +1,7 @@
type MultiselectMenuOption = {
id?: number;
label: string;
value: number;
value: string | number | null;
destination?: string;
default?: boolean;
hidden?: boolean;

View file

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

View file

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

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

View file

@ -1,5 +0,0 @@
// Copyright (C) 2017-2023 Smart code 203358507
const Toggle = require('./Toggle');
module.exports = Toggle;

View file

@ -0,0 +1,5 @@
// Copyright (C) 2017-2023 Smart code 203358507
import Toggle from './Toggle';
export default Toggle;

View file

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

@ -3,4 +3,6 @@ declare module '*.less' {
export = resource;
}
declare module 'stremio-router';
declare module 'stremio/components/NavBar';
declare module 'stremio/components/ModalDialog';

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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') ?

View file

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

View file

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

View file

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

View file

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

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,5 +0,0 @@
// Copyright (C) 2017-2023 Smart code 203358507
const DiscreteSelectInput = require('./DiscreteSelectInput');
module.exports = DiscreteSelectInput;

View file

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

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

View file

@ -0,0 +1,2 @@
import Stepper from './Stepper';
export default Stepper;

View file

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

View file

@ -114,7 +114,7 @@
flex: 1;
}
.discrete-input {
.stepper {
padding: 0 1.5rem 1rem;
}
}

View file

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

View file

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

View file

@ -12,7 +12,7 @@
}
.search-container {
height: 100%;
height: calc(100% - var(--safe-area-inset-bottom));
width: 100%;
background-color: transparent;

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

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

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

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

View file

@ -0,0 +1,2 @@
import User from './User';
export default User;

View file

@ -0,0 +1,2 @@
import General from './General';
export default General;

View file

@ -0,0 +1,6 @@
declare const useDataExport: () => [
DataExport,
() => void,
];
export = useDataExport;

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

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

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

View file

@ -0,0 +1,2 @@
import Info from './Info';
export default Info;

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

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

View file

@ -0,0 +1,2 @@
import Menu from './Menu';
export default Menu;

View 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